Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 107 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ This runs a scan against the current project and writes a self-contained `depend
- **Full transitive tree** — shows depth, parent relationships, fan-in/fan-out, and dependency origins
- **Workspace support** — works across npm, pnpm, and Yarn workspaces
- **CI-friendly** — `--fail-on` flag lets you enforce licence and vulnerability policies in pipelines
- **Review-friendly outputs** — emit JSON, SARIF, CycloneDX SBOM, or SPDX SBOM artifacts from the same local scan
- **Change comparison** — compare a fresh scan with a previous `dependency-radar.json` to see added dependencies, removed dependencies, version changes, and new findings
- **Lockfile supply-chain signals** — flags git/local/tarball sources, missing integrity, unexpected registry hosts, and optional npm signature/provenance verification
- **Completely offline-capable** — use `--offline` to skip registry calls; all package metadata is read from local `node_modules`
- **Single self-contained HTML file** — no server needed; open it locally, attach it to a ticket, or share it with your team

Expand Down Expand Up @@ -94,6 +97,11 @@ The `scan` command is the default and can also be run explicitly as `npx depende
| `--project <path>` | Path to the project to scan (defaults to current directory) |
| `--quiet` | Suppress progress/info logs, browser opening, and footer messaging while keeping the final summary and failures visible |
| `--out <path>` | Output path for the report file |
| `--format <format>` | Output format: `html`, `json`, `sarif`, `cyclonedx`, or `spdx` |
| `--sbom <format>` | Convenience alias for SBOM output: `cyclonedx` or `spdx` |
| `--target-node <major>` | Add Node major compatibility findings based on local `engines.node` metadata |
| `--audit-signatures` | Run `npm audit signatures` for registry signature/provenance verification (opt-in; skipped with `--offline`) |
| `--schema` | Print the current Dependency Radar JSON schema, or write it with `--out <path>` |
| `--offline` | Skip `npm audit` and `npm outdated` (useful for offline/air-gapped scans) |
| `--json` | Output JSON instead of HTML (`dependency-radar.json`) |
| `--no-report` | Run analysis only; no HTML/JSON output written |
Expand Down Expand Up @@ -145,6 +153,26 @@ Notes:
- "Static import evidence" means Dependency Radar found local source imports for that package. It is a code-usage heuristic, not exploit reachability analysis.
- "Introduced via root packages" and "Direct parents" are shown from the current scan model. The command does not currently print full ancestry chains.

### Show why a dependency is present

Use `why` to print shortest dependency paths from direct dependencies to a package:

```bash
npx dependency-radar why lodash
```

This uses the same local scan model as the HTML report. When full paths are unavailable, it falls back to the package origins and direct parent evidence available in the report model.

### Compare against a previous report

Use `compare` to scan the current project and compare it with an earlier JSON report:

```bash
npx dependency-radar compare ./dependency-radar-before.json --json --offline
```

The comparison highlights added dependencies, removed dependencies, one-version package changes, new findings, and resolved findings. This is useful in pull requests and release checks.

### CI policy enforcement (`--fail-on`)

```
Expand All @@ -161,6 +189,7 @@ Supported rules:
| `licence-mismatch` | Fail if at least one dependency has a declared-vs-inferred licence mismatch |
| `copyleft-detected` | Fail if strong copyleft (GPL/AGPL) appears in runtime dependencies |
| `unknown-licence` | Fail if at least one dependency has neither declared nor inferred licence data |
| `supply-chain-source` | Fail if lockfile source signals detect git/local/tarball sources, missing integrity, or unexpected registry hosts |

When rules are violated, Dependency Radar prints `✖ Policy violations detected:` and exits `1`. Unknown rules also exit `1` with a clear error message.

Expand All @@ -176,6 +205,42 @@ npx dependency-radar --open
npx dependency-radar --project ./my-app --out ./reports/dependency-radar.html
```

### Example: write SARIF for CI/code scanning

```bash
npx dependency-radar --format sarif --out ./reports/dependency-radar.sarif
```

### Example: write an SBOM

```bash
npx dependency-radar --sbom cyclonedx --out ./reports/bom.cdx.json
```

```bash
npx dependency-radar --sbom spdx --out ./reports/bom.spdx.json
```

### Example: check Node upgrade readiness signals

```bash
npx dependency-radar --target-node 22
```

### Example: verify npm registry signatures/provenance

```bash
npx dependency-radar --audit-signatures
```

This runs `npm audit signatures` as an opt-in online check. It is skipped when `--offline` is used.

### Example: write the JSON schema

```bash
npx dependency-radar --schema --out ./reports/dependency-radar.schema.json
```

### Example: keep temp files for debugging
```bash
npx dependency-radar --keep-temp
Expand Down Expand Up @@ -241,7 +306,8 @@ The blocker detail counts can overlap: a single package may contribute to multip
| pnpm | ✅ Lockfile-first (`pnpm-lock.yaml`) | ✅ | ✅ | ✅ |
| Yarn Classic (v1) | ✅ Lockfile-first (`yarn.lock`) | ✅ | ✅ | ✅ |
| Yarn Berry (v2+, node-modules linker) | ✅ Lockfile-first (`yarn.lock`) | ✅ | ⚠️ Plugin-dependent | ✅ |
| Yarn Plug'n'Play | ❌ Not yet supported | | | |
| Yarn Plug'n'Play | ⚠️ Lockfile-derived graph only; package metadata may be incomplete without `node_modules` | ✅ | ⚠️ Plugin-dependent | ✅ |
| Bun | ⚠️ Text `bun.lock` parsing; binary `bun.lockb` is reported with a migration hint | N/A | ❌ | ⚠️ package.json workspaces only |

## Requirements

Expand All @@ -252,12 +318,13 @@ The blocker detail counts can overlap: a single package may contribute to multip

When you run `npx dependency-radar` (or `dependency-radar scan`), the CLI executes this pipeline:

1. Parse CLI options (`--project`, `--out`, `--offline`, `--json`, `--no-report`, `--keep-temp`, `--open`, `--fail-on`).
1. Parse CLI options (`--project`, `--out`, `--offline`, `--json`, `--no-report`, `--keep-temp`, `--open`, `--fail-on`, `--audit-signatures`, `--schema`).
2. Detect workspace/package-manager context:
- Workspace roots from `pnpm-workspace.yaml` or `package.json#workspaces`
- Dependency policy from `package.json` and `pnpm-workspace.yaml` overrides/resolutions
- Package manager from `packageManager`, lockfiles, and installed metadata
- Yarn Plug'n'Play detection (`.pnp.cjs`/`.pnp.js` or `.yarnrc.yml nodeLinker: pnp`)
- Bun text lockfile detection (`bun.lock`; binary `bun.lockb` is not parsed)
3. Create a temporary `.dependency-radar/` directory inside the scanned project.
4. For each workspace package (or just the project root in single-package mode), collect dependency graph data:
- Lockfile-first graph parsing (`pnpm-lock.yaml`, `npm-shrinkwrap.json`/`package-lock.json`, `yarn.lock`)
Expand All @@ -267,6 +334,8 @@ When you run `npx dependency-radar` (or `dependency-radar scan`), the CLI execut
- Vulnerabilities (`npm audit` / `pnpm audit` / `yarn audit` or `yarn npm audit`)
- Version drift (`npm outdated` / `pnpm outdated` / `yarn outdated`, where available)
- Source import graph (static import/require parsing in `src/` or project root)
- Lockfile supply-chain source signals
- Optional npm registry signature/provenance verification (`--audit-signatures`)
6. Normalize outputs into one internal shape and merge workspace package results.
- PNPM lock/CLI dependency trees are filtered to installed-only packages (non-installed optional/platform variants are dropped)
7. Resolve and crawl installed package directories in `node_modules` to collect local metadata:
Expand All @@ -278,10 +347,15 @@ When you run `npx dependency-radar` (or `dependency-radar scan`), the CLI execut
- Root-cause/origin and runtime-impact heuristics
- Install-time execution signals
- Local package metadata (`description`, links, deprecation, TypeScript type availability, installed file count, CLI `bin` presence)
9. Write final output as either:
9. Build normalized findings from the aggregated dependency model:
- Vulnerabilities, license review items, install-time execution surface, native bindings, deprecated packages, target Node compatibility findings, lockfile source signals, and npm signature/provenance failures
10. Write final output as one of:
- `dependency-radar.html` (self-contained report), or
- `dependency-radar.json` (raw aggregated model)
10. Remove `.dependency-radar/` unless `--keep-temp` is set.
- SARIF (`--format sarif`)
- CycloneDX SBOM (`--format cyclonedx` / `--sbom cyclonedx`)
- SPDX SBOM (`--format spdx` / `--sbom spdx`)
11. Remove `.dependency-radar/` unless `--keep-temp` is set.

The scan is local-first: package metadata is read from `node_modules`; only audit/outdated commands require registry access.

Expand Down Expand Up @@ -400,7 +474,7 @@ The JSON schema matches the `AggregatedData` TypeScript interface in `src/types.

```ts
export interface AggregatedData {
schemaVersion: '1.3'; // Report schema version for compatibility checks
schemaVersion: '1.4'; // Report schema version for compatibility checks
generatedAt: string; // ISO timestamp when the scan finished
dependencyRadarVersion: string; // CLI version that produced the report
git: {
Expand Down Expand Up @@ -438,11 +512,12 @@ export interface AggregatedData {
nodeVersion: string; // Node.js version from process.versions.node
runtimeVersion: string; // Node.js runtime version from process.version
minRequiredMajor: number; // Strictest Node major required by dependency engines (0 if unknown)
targetNodeMajor?: number; // Node major passed through --target-node
platform?: string; // OS platform (process.platform)
arch?: string; // CPU architecture (process.arch)
ci?: boolean; // True when CI indicators are detected
packageManagerField?: string; // package.json packageManager field (e.g. pnpm@9.1.0)
packageManager?: 'npm' | 'pnpm' | 'yarn'; // Package manager used for dependency/audit/outdated collection
packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun'; // Package manager used for dependency/audit/outdated collection
packageManagerVersion?: string; // Version of the selected package manager (when available)
toolVersions?: {
npm?: string;
Expand All @@ -452,15 +527,40 @@ export interface AggregatedData {
};
workspaces: {
enabled: boolean; // True when the scan used workspace aggregation
type?: 'npm' | 'pnpm' | 'yarn' | 'none'; // Workspace mode (CLI currently always emits this)
type?: 'npm' | 'pnpm' | 'yarn' | 'bun' | 'none'; // Workspace mode (CLI currently always emits this)
packageCount?: number; // Number of workspace packages scanned (CLI currently always emits this)
workspacePackages?: WorkspacePackage[]; // Lightweight first-party workspace metadata
};
summary: {
dependencyCount: number; // Total EXTERNAL dependencies in the graph
directCount: number; // External dependencies listed in package.json
transitiveCount: number; // External dependencies pulled in by other dependencies
findingCount?: number; // Number of normalized findings generated from the dependency model
};
supplyChain?: {
signals: Array<{
type:
| 'git-dependency'
| 'file-dependency'
| 'non-registry-tarball'
| 'missing-integrity'
| 'unexpected-registry-host'
| 'signature-verification-failed'
| 'signature-verification-unavailable';
packageName?: string;
packageVersion?: string;
packageId?: string;
source: string;
detail: string;
}>;
signatureAudit?: {
attempted: boolean;
ok: boolean;
output?: string;
error?: string;
};
};
findings?: DependencyFinding[]; // Normalized review/CI findings
dependencies: Record<string, DependencyRecord>; // External third-party packages keyed by name@version
}

Expand Down
44 changes: 35 additions & 9 deletions dist/aggregator.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.aggregateData = aggregateData;
const utils_1 = require("./utils");
const license_1 = require("./license");
const findings_1 = require("./findings");
const nodeEngine_1 = require("./nodeEngine");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const os_1 = __importDefault(require("os"));
Expand Down Expand Up @@ -286,7 +288,7 @@ function isWorkspacePackageNode(node, input) {
return false;
}
async function aggregateData(input) {
var _a, _b, _c, _d, _e, _f, _g, _h;
var _a, _b, _c, _d, _e, _f, _g, _h, _j;
const pkg = input.pkgOverride || (await (0, utils_1.readPackageJson)(input.projectPath));
let projectPkg = input.projectPackageJson;
if (!projectPkg) {
Expand All @@ -305,7 +307,8 @@ async function aggregateData(input) {
const importGraph = normalizeImportGraph((_c = input.importGraphResult) === null || _c === void 0 ? void 0 : _c.data);
const usageResult = buildUsageSummary(importGraph, input.projectPath);
const outdatedById = buildOutdatedMap(input.outdatedResult);
const outdatedUnknownNames = new Set(((_d = input.outdatedResult) === null || _d === void 0 ? void 0 : _d.unknownNames) || []);
const supplyChain = normalizeSupplyChain((_d = input.supplyChainResult) === null || _d === void 0 ? void 0 : _d.data);
const outdatedUnknownNames = new Set(((_e = input.outdatedResult) === null || _e === void 0 ? void 0 : _e.unknownNames) || []);
const packageMetaCache = new Map();
const resolvePaths = input.resolvePaths && input.resolvePaths.length > 0
? input.resolvePaths
Expand Down Expand Up @@ -346,7 +349,7 @@ async function aggregateData(input) {
const runtimeImpact = usageResult.runtimeImpact.get(node.name);
const introduction = determineIntroduction(direct, scope, rootCauses, runtimeImpact);
const parentIds = Array.from(node.parents).sort();
const origins = buildOrigins(rootCauses, parentIds, (_e = input.workspaceUsage) === null || _e === void 0 ? void 0 : _e.get(node.name), input.workspaceEnabled, MAX_TOP_ROOT_PACKAGES, MAX_TOP_PARENT_PACKAGES);
const origins = buildOrigins(rootCauses, parentIds, (_f = input.workspaceUsage) === null || _f === void 0 ? void 0 : _f.get(node.name), input.workspaceEnabled, MAX_TOP_ROOT_PACKAGES, MAX_TOP_PARENT_PACKAGES);
const execution = packageInsights.execution;
const id = node.key;
const upgrade = buildUpgradeBlock(packageInsights);
Expand All @@ -359,7 +362,10 @@ async function aggregateData(input) {
...(outdated ? { outdatedStatus: outdated.status } : {}),
...((outdated === null || outdated === void 0 ? void 0 : outdated.latestVersion) ? { latestVersion: outdated.latestVersion } : {}),
...((upgrade === null || upgrade === void 0 ? void 0 : upgrade.blockers) ? { blockers: upgrade.blockers } : {}),
...((upgrade === null || upgrade === void 0 ? void 0 : upgrade.blocksNodeMajor) ? { blocksNodeMajor: upgrade.blocksNodeMajor } : {})
...((upgrade === null || upgrade === void 0 ? void 0 : upgrade.blocksNodeMajor) ? { blocksNodeMajor: upgrade.blocksNodeMajor } : {}),
...(typeof input.targetNodeMajor === 'number' && packageInsights.nodeEngine
? { targetNodeCompatible: (0, nodeEngine_1.isNodeEngineTargetCompatible)(packageInsights.nodeEngine, input.targetNodeMajor) }
: {})
};
dependencies[id] = {
package: {
Expand All @@ -372,9 +378,9 @@ async function aggregateData(input) {
deprecated: packageInsights.deprecated,
links: {
npm: `https://www.npmjs.com/package/${node.name}`,
...(((_f = packageInsights.links) === null || _f === void 0 ? void 0 : _f.repository) ? { repository: packageInsights.links.repository } : {}),
...(((_g = packageInsights.links) === null || _g === void 0 ? void 0 : _g.homepage) ? { homepage: packageInsights.links.homepage } : {}),
...(((_h = packageInsights.links) === null || _h === void 0 ? void 0 : _h.bugs) ? { bugs: packageInsights.links.bugs } : {})
...(((_g = packageInsights.links) === null || _g === void 0 ? void 0 : _g.repository) ? { repository: packageInsights.links.repository } : {}),
...(((_h = packageInsights.links) === null || _h === void 0 ? void 0 : _h.homepage) ? { homepage: packageInsights.links.homepage } : {}),
...(((_j = packageInsights.links) === null || _j === void 0 ? void 0 : _j.bugs) ? { bugs: packageInsights.links.bugs } : {})
}
},
compliance: {
Expand Down Expand Up @@ -416,8 +422,8 @@ async function aggregateData(input) {
const nodeVersion = process.versions.node;
const dependencyCount = nodes.length;
const transitiveCount = dependencyCount - directCount;
return {
schemaVersion: '1.3',
const aggregated = {
schemaVersion: '1.4',
generatedAt: new Date().toISOString(),
dependencyRadarVersion,
git: {
Expand All @@ -428,6 +434,7 @@ async function aggregateData(input) {
nodeVersion,
runtimeVersion,
minRequiredMajor: minRequiredMajor !== null && minRequiredMajor !== void 0 ? minRequiredMajor : 0,
...(typeof input.targetNodeMajor === 'number' ? { targetNodeMajor: input.targetNodeMajor } : {}),
...(input.platform ? { platform: input.platform } : {}),
...(input.arch ? { arch: input.arch } : {}),
...(typeof input.ci === 'boolean' ? { ci: input.ci } : {}),
Expand All @@ -449,8 +456,27 @@ async function aggregateData(input) {
directCount,
transitiveCount
},
...(supplyChain ? { supplyChain } : {}),
dependencies
};
const findings = (0, findings_1.buildDependencyFindings)(aggregated, { targetNodeMajor: input.targetNodeMajor });
aggregated.findings = findings;
aggregated.summary.findingCount = findings.length;
return aggregated;
}
function normalizeSupplyChain(data) {
if (!data || typeof data !== 'object')
return undefined;
const signals = Array.isArray(data.signals) ? data.signals : [];
const signatureAudit = data.signatureAudit && typeof data.signatureAudit === 'object'
? data.signatureAudit
: undefined;
if (signals.length === 0 && !signatureAudit)
return undefined;
return {
signals,
...(signatureAudit ? { signatureAudit } : {})
};
}
function deriveMinRequiredMajor(engineRanges) {
let strictest;
Expand Down
Loading