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
94 changes: 94 additions & 0 deletions src/output/sbom-dependency-edges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { loadNpmLockGraph } from "../parsers/npm-lock-graph.js";
import type { PackageRef, ScanInput } from "../types.js";

/**
* One edge of the dependency graph, keyed by `name@version` rather than by
* lockfile node id, because an SBOM package is a name and a version. A `parent`
* of null means the root project pulled it in directly.
*/
export type DependencyEdge = {
child: string;
parent: string | null;
};

function packageKey(name: string, version: string): string {
return `${name}@${version}`;
}

/** Several graph nodes collapse to one name@version, so edges are deduplicated. */
function addEdge(edges: DependencyEdge[], seen: Set<string>, child: string, parent: string | null): void {
const key = `${child} ${parent ?? ""}`;
if (seen.has(key)) return;
seen.add(key);
edges.push({ child, parent });
}

/**
* Resolves dependency edges from the npm lock graph.
*
* The graph keeps a complete child-to-parents map (`parentsFor`) that is not
* subject to the five-path cap applied to `PackageRef.paths`. Reconstructing
* edges from those truncated paths loses roughly a quarter of the routes on a
* large tree, and a package whose surviving routes all traverse filtered-out
* packages ends up with no parent at all.
*
* Returns an empty list for anything other than an npm lockfile, so pnpm, Yarn
* and Bun fall back to the caller's path-derived behaviour rather than silently
* losing every edge.
*/
export function resolveDependencyEdges(scanInput: ScanInput, allPackages: PackageRef[]): DependencyEdge[] {
if (scanInput.source !== "package-lock" || !scanInput.filePath) return [];

let graph: ReturnType<typeof loadNpmLockGraph>;
try {
graph = loadNpmLockGraph(scanInput.filePath, { includePaths: false });
} catch {
// A lockfile we cannot read is not worth failing an SBOM over. The caller
// falls back to path-derived edges.
return [];
}

// Only packages present in the document can be referenced. `--prod-only` and
// similar filters shrink this set without shrinking the graph, and an edge to
// a package absent from the document would be a dangling SPDX reference.
const included = new Set(allPackages.map(p => packageKey(p.name, p.version)));

const edges: DependencyEdge[] = [];
const seen = new Set<string>();

for (const pkg of allPackages) {
const childKey = packageKey(pkg.name, pkg.version);
let anchored = false;

for (const nodeId of graph.nodeIdsFor(pkg.name, pkg.version)) {
for (const parentNodeId of graph.parentsFor(nodeId)) {
const parentNode = graph.getNode(parentNodeId);

// Workspace members are symlinked rather than installed, so the graph
// records them without a version and they never appear in the scanned
// package set. They cannot be referenced as SPDX packages.
if (!parentNode?.version) continue;

const parentKey = packageKey(parentNode.name, parentNode.version);
if (parentKey === childKey) continue;

// Referencing a package absent from the document would be a dangling
// SPDX reference. `--prod-only` and similar filters shrink the package
// set without shrinking the graph.
if (!included.has(parentKey)) continue;

addEdge(edges, seen, childKey, parentKey);
anchored = true;
}
}

// Either nothing depends on it, or every parent was a workspace member or
// filtered out of the document. Anchoring to the root keeps the package in
// the graph: it is genuinely part of the tree, and the root is the only
// anchor the document can offer. Orphaning it would silently drop a
// dependency relationship, which is an NTIA minimum element.
if (!anchored) addEdge(edges, seen, childKey, null);
}

return edges;
}
56 changes: 55 additions & 1 deletion src/output/spdx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { SuggestedFixCommandPlan } from "../remediation/fix-commands.js";
import { findSuggestedCommandForFinding } from "../remediation/fix-commands.js";
import { getRecommendedAction } from "./formatters.js";
import { buildPurl } from "../utils/purl.js";
import type { DependencyEdge } from "./sbom-dependency-edges.js";
import { getCliVersion } from "../utils/version-info.js";
import { sbomTimestamp, writeSbomFile } from "./sbom-file.js";

Expand Down Expand Up @@ -48,6 +49,14 @@ export type SpdxOptions = {
* `documentNamespace` still vary per run: both are required by the spec.
*/
inventoryOnly?: boolean;
/**
* Dependency edges resolved from the lockfile graph, keyed by `name@version`.
* Preferred over reconstructing edges from `PackageRef.paths`, which are
* capped at five per package and therefore lose routes on large trees. When
* absent (pnpm, Yarn, Bun, or an unreadable lockfile) the path-derived
* fallback is used instead.
*/
dependencyEdges?: DependencyEdge[];
};

export type SpdxRelationship = {
Expand Down Expand Up @@ -289,6 +298,47 @@ function buildDependencyEdges(entries: IndexedPackage[], index: Map<string, stri
return edges;
}

/**
* Maps edges already resolved from the lockfile graph onto SPDX ids.
*
* A null parent means the root project depends on the package directly, so it
* anchors to the root element. An edge naming a package absent from the
* document is dropped rather than emitted as a dangling reference.
*/
function buildEdgesFromResolved(
resolved: DependencyEdge[],
entries: IndexedPackage[],
rootId: string | null,
): SpdxRelationship[] {
const idByPackage = new Map<string, string>();
for (const { ref, id } of entries) {
const key = `${ref.name}@${ref.version}`;
if (!idByPackage.has(key)) idByPackage.set(key, id);
}

const edges: SpdxRelationship[] = [];
const seen = new Set<string>();

for (const { child, parent } of resolved) {
const childId = idByPackage.get(child);
if (!childId) continue;

const parentId = parent === null ? rootId : idByPackage.get(parent);
if (!parentId || parentId === childId) continue;

const key = `${childId} ${parentId}`;
if (seen.has(key)) continue;
seen.add(key);
edges.push({
spdxElementId: childId,
relatedSpdxElement: parentId,
relationshipType: "DEPENDENCY_OF",
});
}

return edges;
}

export function buildSpdxDocument(
allPackages: PackageRef[],
findings: Finding[],
Expand Down Expand Up @@ -324,7 +374,11 @@ export function buildSpdxDocument(

// The graph describes the tree, not the findings, so it is inventory data and
// stays even in inventory-only mode.
relationships.push(...buildDependencyEdges(indexed, indexPackagesByPath(indexed, root?.pkg.SPDXID ?? null)));
// Checked by length, not truthiness: resolveDependencyEdges returns an empty
// array for pnpm, Yarn and Bun, and an empty array is truthy.
relationships.push(...(options.dependencyEdges?.length
? buildEdgesFromResolved(options.dependencyEdges, indexed, root?.pkg.SPDXID ?? null)
: buildDependencyEdges(indexed, indexPackagesByPath(indexed, root?.pkg.SPDXID ?? null))));

return {
spdxVersion: "SPDX-2.3",
Expand Down
8 changes: 7 additions & 1 deletion src/output/write-outputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { serializeFinding } from "./formatters.js";
import { writeSarifReport, deriveLockfileUri } from "./sarif.js";
import { writeCycloneDxReport } from "./cyclonedx.js";
import { writeSpdxReport } from "./spdx.js";
import { resolveDependencyEdges } from "./sbom-dependency-edges.js";
import { resolveSbomFormat } from "../utils/sbom-format.js";
import { overrideFindingsToJson } from "./override-findings-json.js";
import { maintenanceFindingsToJson } from "./maintenance-findings-json.js";
Expand Down Expand Up @@ -109,7 +110,12 @@ export async function writeOutputs(
scanState.sorted,
scanState.suggestedFixCommands,
projectMeta,
{ inventoryOnly: options.sbomInventoryOnly },
{
inventoryOnly: options.sbomInventoryOnly,
// Resolved from the lockfile graph where available. Empty for pnpm,
// Yarn and Bun, which falls back to path-derived edges.
dependencyEdges: resolveDependencyEdges(scanInput, scanState.allPackages),
},
);
console.log(`${chalk.gray("SPDX SBOM written to")} ${chalk.cyan(spdxFilename)}`);
} else {
Expand Down
149 changes: 149 additions & 0 deletions tests/output/sbom-dependency-edges.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveDependencyEdges } from "../../src/output/sbom-dependency-edges.js";
import type { PackageRef, ScanInput } from "../../src/types.js";

function writeLock(packages: Record<string, unknown>): { dir: string; file: string } {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-edges-"));
const file = path.join(dir, "package-lock.json");
fs.writeFileSync(file, JSON.stringify({ name: "app", version: "1.0.0", lockfileVersion: 3, packages }));
return { dir, file };
}

function scanInput(file: string | null, source: ScanInput["source"] = "package-lock"): ScanInput {
return { mode: "lockfile", source, filePath: file, packages: [], notes: [], warnings: [], skippedDependencies: [] } as ScanInput;
}

function pkg(name: string, version: string): PackageRef {
return { name, version, ecosystem: "npm" };
}

/**
* The lock graph keeps a complete child-to-parents edge map that is not subject
* to the five-path display cap, so edges are resolved from it rather than
* reconstructed from truncated `paths`.
*/
describe("resolveDependencyEdges", () => {
it("resolves a direct dependency's parent as the root project", () => {
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { express: "^4.0.0" } },
"node_modules/express": { version: "4.17.1" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("express", "4.17.1")]);
expect(edges).toContainEqual({ child: "express@4.17.1", parent: null });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("resolves a transitive dependency to the package that pulled it in", () => {
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { express: "^4.0.0" } },
"node_modules/express": { version: "4.17.1", dependencies: { qs: "6.7.0" } },
"node_modules/qs": { version: "6.7.0" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("express", "4.17.1"), pkg("qs", "6.7.0")]);
expect(edges).toContainEqual({ child: "qs@6.7.0", parent: "express@4.17.1" });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("keeps every parent when a package is pulled in by more than one", () => {
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { a: "1.0.0", b: "1.0.0" } },
"node_modules/a": { version: "1.0.0", dependencies: { shared: "2.0.0" } },
"node_modules/b": { version: "1.0.0", dependencies: { shared: "2.0.0" } },
"node_modules/shared": { version: "2.0.0" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("a", "1.0.0"), pkg("b", "1.0.0"), pkg("shared", "2.0.0")]);
const parents = edges.filter(e => e.child === "shared@2.0.0").map(e => e.parent).sort();
expect(parents).toEqual(["a@1.0.0", "b@1.0.0"]);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("drops an edge whose parent was filtered out of the scanned package set", () => {
// --prod-only filters the package list but not the graph. An edge to a
// package absent from the document would be a dangling SPDX reference.
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { express: "^4.0.0" } },
"node_modules/express": { version: "4.17.1", dependencies: { qs: "6.7.0" } },
"node_modules/qs": { version: "6.7.0" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("qs", "6.7.0")]);
// No dangling reference to a package that is not in the document.
expect(edges.some(e => e.parent === "express@4.17.1")).toBe(false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("deduplicates edges when several graph nodes collapse to one name@version", () => {
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { a: "1.0.0" } },
"node_modules/a": { version: "1.0.0", dependencies: { dep: "1.0.0" } },
"node_modules/a/node_modules/dep": { version: "1.0.0" },
"node_modules/dep": { version: "1.0.0" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("a", "1.0.0"), pkg("dep", "1.0.0")]);
const keys = edges.map(e => `${e.child}<-${e.parent}`);
expect(new Set(keys).size).toBe(keys.length);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("anchors to the root when every parent is a workspace member", () => {
// Workspace members are symlinked, not installed, so the graph records them
// with a null version and they never appear in the scanned package set.
// Dropping the edge would orphan the package; from the document's point of
// view a workspace member's dependency is a dependency of the project.
const { dir, file } = writeLock({
"": { name: "monorepo", version: "1.0.0", workspaces: ["packages/*"] },
"packages/site": { name: "@scope/site", version: "1.0.0", dependencies: { eleventy: "1.0.2" } },
"node_modules/@scope/site": { resolved: "packages/site", link: true },
"node_modules/eleventy": { version: "1.0.2" },
});
try {
const edges = resolveDependencyEdges(scanInput(file), [pkg("eleventy", "1.0.2")]);
expect(edges).toContainEqual({ child: "eleventy@1.0.2", parent: null });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("anchors to the root rather than orphaning when no parent survives filtering", () => {
const { dir, file } = writeLock({
"": { name: "app", version: "1.0.0", dependencies: { express: "^4.0.0" } },
"node_modules/express": { version: "4.17.1", dependencies: { qs: "6.7.0" } },
"node_modules/qs": { version: "6.7.0" },
});
try {
// express filtered out of the document; qs must still be reachable.
const edges = resolveDependencyEdges(scanInput(file), [pkg("qs", "6.7.0")]);
expect(edges).toContainEqual({ child: "qs@6.7.0", parent: null });
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});

it("returns no edges for a non-npm lockfile, so other ecosystems fall back", () => {
expect(resolveDependencyEdges(scanInput("/tmp/pnpm-lock.yaml", "pnpm-lock"), [pkg("x", "1.0.0")])).toEqual([]);
});

it("returns no edges when there is no lockfile path", () => {
expect(resolveDependencyEdges(scanInput(null), [pkg("x", "1.0.0")])).toEqual([]);
});

it("returns no edges rather than throwing when the lockfile is unreadable", () => {
expect(resolveDependencyEdges(scanInput("/nonexistent/package-lock.json"), [pkg("x", "1.0.0")])).toEqual([]);
});
});
Loading