UBEL — Unified Bill / Enforced Law
Node.js Supply-Chain Security CLI
Ubel resolves dependencies, generates PURLs, scans them through OSV.dev and NVD, and enforces configurable security policies at install-time to block supply-chain attacks before they reach production.
This document covers the Node.js ecosystem (npm, pnpm, bun, yarn).
Features
- Full dependency resolution with PURL generation via lockfile dry-run
- Querying authoritative vulnerability sources in real time, allowing newly published advisories to be detected immediately without waiting for scheduled database refreshes unlike the competitors.
- OSV.dev vulnerability scanning via batched API queries and NVD's APIs
- Concurrent vulnerability enrichment (CVSS, fix recommendations, references)
- Policy engine — block/allow by severity threshold and unknown-severity packages
- Malicious package (infection) detection — always blocked regardless of policy
checkmode — dry-run resolution and scan with no side effectsinstallmode — scan-gate before installation; blocks if policy violatedhealthmode — scan the current project's installed dependencies- Atomic lockfile revert — originals are always restored on violation or error
- Disk-based lockfile backup under
.ubel/lockfiles/<timestamp>/with manual recovery on failure - Dependency graph with introduced-by and parent tracking
- Automatic report generation: timestamped JSON (
*.json) + HTML (*.html) + SBOM (*.cdx.json) + SARIF (*.sarif.json) per scan, pluslatest.*convenience links - Zero external runtime dependencies (Node.js stdlib only)
- Complete compliant, and enriched SBOM Cyclonedx v1.6 files with full dependencies and vulnerabilities data in VEX
- Complete compliant, and enriched SARIF v2.1.0 files
- Reachability analysis — each vulnerability is annotated with a heuristic reachability assessment derived from package type, scope, dependency depth, attack vector, and import-scan confirmation across all supported ecosystems (see Reachability Analysis)
Installation
npm install -g @arcane-spark/ubel-nodeAfter installation, the following entry-point binaries are available:
| Binary | Package Manager |
|---|---|
ubel-npm |
npm |
ubel-pnpm |
pnpm |
ubel-bun |
bun |
ubel-agent |
AI agent workspace scan ( OS, runtimes, tools, dependencies ) |
ubel-platform |
Host platform scan (OS, runtimes, tools) |
yarn does not support a lockfile-only dry-run —
yarn addalways writesnode_modules. UBEL supports yarn inhealthscan mode only and cannot provide install-blocking firewall coverage for it.
Requirements
- Node.js
>=18.0.0 - The package manager binary being targeted (
npm,pnpm, orbun) must be available onPATH
Usage
ubel-npm <mode> [packages...]
ubel-pnpm <mode> [packages...]
ubel-bun <mode> [packages...]
Package arguments are optional for check and install — when omitted, the existing lockfile in the working directory is used as the dependency source.
Firewall Mechanics
npm
ubel-npm check and ubel-npm install <pkg> invoke npm's --package-lock-only flag, which resolves the full dependency tree and writes a candidate package-lock.json without touching node_modules/. UBEL scans the candidate lockfile, then makes a binary decision:
- Clean — the candidate lockfile is accepted and the actual install proceeds via
npm ci. - Violation —
package-lock.jsonis reverted to its pre-scan state from the disk backup.node_modules/is never touched. The process exits non-zero.
pnpm
Identical flow to npm, using pnpm's --lockfile-only flag. The candidate pnpm-lock.yaml is written, scanned, then either accepted or reverted. node_modules/ is never written during the scan phase.
bun
Uses bun's --lockfile-only flag. The candidate bun.lock is written and scanned before any node_modules/ mutation. The revert path is identical to npm and pnpm.
UBEL's firewall always blocks pre/post install scripts to prevent running malicious scripts
All the 3 package manager are triggered with the flag: --ignore-scripts
Lockfile backup and recovery
Before any dry-run mutation, originals are backed up to .ubel/lockfiles/<timestamp>/. If the revert itself fails (e.g. a disk error mid-restore), the original lockfile is preserved at the backup path and its location is printed to stderr so the user can recover manually.
TOCTOU integrity protection
After the dry-run completes and the scan passes policy, there is a window between the scan decision and the real install during which the on-disk lockfile or package.json could be mutated — by another process, a racing script, or a compromised tool. UBEL closes this window with SHA-256 integrity checks before any real install is allowed to proceed.
At the end of every dry-run, UBEL captures two digests in memory:
_candidateLockfileHash— SHA-256 of the raw candidate lockfile bytes written to disk by the dry-run (package-lock.json,pnpm-lock.yaml, orbun.lock)._candidatePackageJsonHash— SHA-256 ofpackage.jsonas it exists on disk after the dry-run. For npm, this digest is re-captured after UBEL regeneratespackage.jsonwith exact pinned versions from the lockfile, so the hash always reflects the file that will be present at install time.
Immediately before invoking the real install command (npm ci, pnpm install --frozen-lockfile, bun install --frozen-lockfile), both files are re-hashed from disk and compared against the in-memory digests. If either hash does not match, the install is aborted and the lockfile is reverted — nothing is written to node_modules/. The mismatch details (expected hash, actual hash, file path) are printed to stderr.
Lockfile integrity check FAILED — the lockfile was modified after scanning.
Expected : a3f1…
Got : 9c2b…
File : /project/package-lock.json
If no lockfile existed before the dry-run (fresh project), the absence itself is recorded as the expected state and enforced the same way.
This protection also extends to the backup manifest files created earlier before reverting the changes.
Modes
health
Scans the current project's installed dependency graph without running any install. Reads the existing lockfile directly and submits resolved packages to OSV.dev and NVD's APIs.
ubel-npm health
ubel-pnpm health
ubel-bun healthFull-stack monorepo scanning
When invoked programmatically with full_stack: true, health walks the entire directory tree from the project root and collects packages across all supported ecosystems in a single pass — no per-language configuration required. Mixed-stack monorepos (e.g. a Node.js frontend, Python backend, Rust service, and Go tooling in the same repo) are fully covered in one invocation.
| Ecosystem | Package Manager | Resolved From |
|---|---|---|
| Node.js | npm, pnpm, yarn, bun | node_modules/ (on-disk walk) |
| Python | pip / virtualenv | .venv, venv, virtualenv directories |
| PHP | Composer | vendor/ |
| Rust | Cargo | Cargo.lock |
| Go | Go Modules | go.sum |
| C# / .NET | NuGet | packages.lock.json / obj/project.assets.json |
| Java | Maven | pom.xml resolved dependencies |
| Ruby | Bundler | Gemfile.lock |
Each discovered package is deduplicated by PURL before submission, so packages shared across sub-projects are scanned exactly once.
Platform scanning (Linux)
When invoked with scan_os: true on Linux, the scanner reads the host's system package database directly — no elevated privileges required — and includes all installed system packages in the scan inventory.
| Distribution | Package Manager | Source | PURL type |
|---|---|---|---|
| Ubuntu | dpkg | /var/lib/dpkg/status |
pkg:deb/ubuntu/ |
| Debian | dpkg | /var/lib/dpkg/status |
pkg:deb/debian/ |
| Alpine / Alpaquita | apk | /lib/apk/db/installed |
pkg:apk/alpine/ |
| Red Hat / RHEL | rpm | rpm -qa |
pkg:rpm/redhat/ |
| AlmaLinux | rpm | rpm -qa |
pkg:rpm/almalinux/ |
| Rocky Linux | rpm | rpm -qa |
pkg:rpm/rocky-linux/ |
| CentOS / Fedora | rpm | rpm -qa |
pkg:rpm/redhat/ |
Each package entry includes its binary install paths and direct dependency edges as reported by the package database.
Platform scanning (Windows)
When invoked with scan_os: true on Windows, the scanner probes the registry and known binary paths — no elevated privileges required — and enumerates the following software components using CPE 2.3 identifiers:
| Category | Components |
|---|---|
| Operating system | Windows 10 / 11 (build-accurate CPE version) |
| Security | Windows Defender |
| Runtimes | Node.js, Python, PHP, Go, Rust, Ruby, JRE, JDK |
| .NET | All installed .NET Core / Desktop / ASP.NET runtimes (multi-version) |
| Browsers | Chrome, Firefox, Microsoft Edge |
| Developer tools | Git, Docker Desktop, Visual Studio, Cursor |
| Shell | PowerShell |
check
Dry-run: resolves the given packages (or the existing lockfile) via a lockfile-only pass, scans the resolved set, and exits. Nothing is installed and lockfiles are fully reverted to their original state afterwards.
# Scan specific packages without installing
ubel-npm check lodash express
# Scan the current lockfile with no changes
ubel-npm checkExits 0 if policy passes, 1 if policy blocks or the scan fails.
install
Same pipeline as check, but proceeds to install (via npm ci / pnpm install --frozen-lockfile / bun install) if and only if the policy decision is allow.
ubel-npm install lodash@4.17.21 express
ubel-npm install # resolves from existing lockfile
ubel-pnpm install react react-dom
ubel-bun installIf the policy blocks, installation is aborted, the lockfile is reverted, and the process exits 1.
threshold
Sets the severity level at or above which vulnerabilities block the scan. Accepts low, medium, high, critical, or none (disable threshold blocking).
ubel-npm threshold high # block high and critical
ubel-npm threshold critical # block critical only
ubel-npm threshold none # disable severity blockingInfections (MAL-* advisories) are always blocked regardless of this setting.
The threshold is persisted to the local policy file and applies to all subsequent scans until changed.
block-unknown
Controls whether packages with unknown-severity vulnerabilities are blocked.
ubel-npm block-unknown true
ubel-npm block-unknown falsePolicy
Policy is stored as JSON at .ubel/local/policy/config.json relative to the project root.
Default policy created on first run:
{
"severity_threshold": "high",
"block_unknown_vulnerabilities": true
}Severity threshold — vulnerabilities at or above this level cause a block. Severity order: low → medium → high → critical.
Block unknown — when true, any vulnerability whose severity cannot be determined also causes a block.
Infections — advisories with IDs beginning MAL- are always blocked and are not subject to either setting above.
Reachability Analysis
Every vulnerability in the report is annotated with a heuristic reachability assessment. The analyzer operates on the existing report fields — package type, scope, dependency depth, CVSS attack vector, and the dependency graph — and optionally performs a source-level import scan over the project files to confirm or refute whether the vulnerable package is actually used by application code.
The goal is prioritization: to separate vulnerabilities in packages your code actively exercises from those in packages that are installed but unreachable from any production code path.
Decision ladder
Signals are evaluated in strict priority order. The first matching rule wins.
| Priority | Signal | Reachability | Confidence |
|---|---|---|---|
| 0a | Vuln ID starts with MAL- |
total |
high |
| 0b | Package scope includes env |
total |
high |
| 1 | Package type is non-library (app, framework, plugin, OS package, …) | total |
high |
| 2 | Scope is dev or test |
unreachable |
high |
| 3 | Import scan: package imported in source files | high or medium |
high |
| 4a | Import scan: direct import absent, but importing parent found | medium or low |
medium |
| 4b | Import scan: no direct or parent import found | unreachable |
medium |
| 5 | Orphan tool (no dependents in graph, no import scan available) | unreachable |
medium |
| 6 | Depth + attack vector heuristics | medium or low |
low |
Priority 0a (MAL-) — Malware advisories represent active supply-chain infections. The vulnerable code is the infection vector; reachability is unconditional regardless of how or whether the package is imported.
Priority 0b (env scope) — Packages carrying the env scope are part of the execution environment itself — OS packages, system libraries, runtimes, container-layer components. They are not imported by application code; they are the environment. Reachability is unconditional.
Priority 1 (non-library type) — Frameworks, applications, plugins, and OS-level packages have no meaningful import boundary. The component itself is the attack surface.
Priority 2 (dev/test scope) — Packages that are exclusively development or test dependencies are excluded from production runtimes. Scope is derived from package.json devDependencies and propagated through the dependency graph via BFS.
Priorities 3–4 (import scan) — When a project root is provided, UBEL scans source files for import statements matching the package. For transitive dependencies where the package itself is not directly imported, it checks whether any of the package's parents in the dependency graph are imported — confirming that the transitive path is exercised.
Priority 5 (orphan tool) — Root packages with no dependents and no import scan result are most likely standalone CLI tools included in the environment but not called by application code.
Priority 6 (heuristics) — When no higher-priority signal is available, depth in the dependency tree and the CVSS attack vector are used as weak proxies. Network-reachable (AV:N) and shallow (depth ≤ 1) packages score higher.
Import scan coverage
Source files are scanned for ecosystem-appropriate import patterns:
| Ecosystem | Extensions | Patterns matched |
|---|---|---|
| Node.js | .js .ts .mjs .cjs .jsx .tsx |
require('<pkg>'), from '<pkg>' |
| Python | .py |
import <pkg>, from <pkg> |
| Java / Kotlin | .java .kt .groovy .scala |
import <group>.<artifact> |
| C# / .NET | .cs .vb .fs |
using <Namespace> |
| PHP | .php |
use <Vendor>\\, require '<pkg>' |
| Go | .go |
"<module-path>" |
| Rust | .rs |
use <crate>::, extern crate <crate> |
| Ruby | .rb |
require '<gem>' |
Output fields
Each vulnerability record in the enriched report includes a reachability object:
{
"reachability": {
"reachable": true,
"level": "high",
"confidence": "high",
"rationale": "Import of this package was found in project source code. Found in 2 source file(s): src/index.js, src/utils.js. Depth=0, AV=N.",
"tags": ["import_confirmed", "network_av"],
"signals": {
"depth": 0,
"attack_vector": "N",
"is_orphan_tool": false,
"scope": "prod",
"num_paths": 3,
"introduced_by_count": 1,
"pkg_type": "library",
"is_non_library": false,
"is_malware": false,
"has_env_scope": false,
"import_scan": {
"searched": true,
"found": true,
"files_scanned": 87,
"matched_files": ["src/index.js", "src/utils.js"],
"skipped_no_source": false
}
}
}
}| Field | Description |
|---|---|
reachable |
true if the vulnerable code is considered reachable from production |
level |
total, high, medium, or low |
confidence |
high, medium, or low — reflects how much evidence backs the verdict |
rationale |
Human-readable explanation of which signal drove the decision |
tags |
Machine-readable labels identifying which signals fired (e.g. import_confirmed, dev_scope, malware, env_scope) |
signals |
Full signal snapshot — all inputs that were considered, regardless of which rule fired |
Package Argument Validation
All package specifiers passed to check and install are validated against an allow-list pattern before any subprocess is invoked. Accepted formats:
name
name@version
@scope/name
@scope/name@version
Specifiers containing shell metacharacters or other unsafe characters are rejected immediately and the process exits non-zero before any filesystem or network operation occurs.
Programmatic API
main() doubles as a programmatic entry point for agents, platform scanners, and the VS Code extension:
import { main } from "@arcane-spark/ubel-node";
const report = await main({
projectRoot : "/abs/path/to/project",
engine : "npm",
mode : "health",
is_script : true,
save_reports: true,
scan_os : false,
full_stack : false,
scan_node : true,
scan_scope : "repository", // repository | agent | developer_platform | editor_extension
});
// report is the full finalJson object (inventory, vulnerabilities, decision, …)When called this way, the banner and interactive console output are suppressed. The return value is the same machine-readable report object written to disk.
Reports
Every scan writes two files to a timestamped path and overwrites the latest* convenience links:
.ubel/reports/latest.json ← always current
.ubel/reports/latest.html ← always current
.ubel/reports/latest.cdx.json ← always current
.ubel/reports/latest.sarif.json ← always current
.ubel/local/reports/<ecosystem>/<mode>/<YYYY>/<MM>/<DD>/
<ecosystem>_<mode>_<engine>__<timestamp>.json
<ecosystem>_<mode>_<engine>__<timestamp>.html
<ecosystem>_<mode>_<engine>__<timestamp>.cdx.json
<ecosystem>_<mode>_<engine>__<timestamp>.sarif.json
The HTML report is fully self-contained (no server required) and includes:
- Dashboard with severity breakdown chart and policy decision
- Searchable, filterable vulnerability table
- Full inventory with state (safe / vulnerable / infected / undetermined)
- Interactive force-directed dependency graph with vulnerable-subtree filter
- Per-vulnerability detail modals (CVSS vector, fix recommendations, OSV/NVD references)
- System and runtime metadata
The JSON report contains the full machine-readable equivalent and can be consumed by CI/CD tooling directly.
CI/CD Integration
All CLI commands exit non-zero on policy violations, making them native to any CI runner:
# GitHub Actions
- name: UBEL dependency scan
run: ubel-npm check
- name: UBEL firewall-gated install
run: ubel-npm install# Dockerfile
RUN ubel-npm install
Quick-start examples
# Scan the current lockfile without installing anything
ubel-npm check
# Gate the actual install behind a policy scan
ubel-npm install
# Scan a single package for vulnerabilities before it touches node_modules
ubel-npm check lodash@4.17.20
# Tighten policy, then re-scan
ubel-npm threshold critical
ubel-npm check
# Scan the installed project dependencies
ubel-npm health
# Same workflows with pnpm and bun
ubel-pnpm install react react-dom
ubel-bun checkUbel — Secure every dependency, before it reaches production.