From 46b3a95eb6b373504f7627ca250e57a3beb48086 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 8 Sep 2026 14:22:35 -0400 Subject: [PATCH 1/2] fix(sbom): resolve dependency edges from the lock graph, not truncated paths The SBOM dependency graph reconstructed parent edges by prefix-matching PackageRef.paths, which upsertPackage caps at five per package. On a large tree that discards a lot: measured on examples/lit, 592 of 2549 packages exceed five paths, with semver@6.3.1 alone having 49. When the five surviving routes all traverse packages excluded from the scan, the prefix lookup finds nothing and the package ends up with no parent edge. The lock graph already answers this directly. parentsFor() is a public, pre-frozen, complete child-to-parents map that the display cap never touches, so no second traversal and no raised cap are needed. The five-path bound stays where it earns its place, in remediation output, where a representative route is enough. Two cases needed handling beyond the straight lookup. Workspace members are symlinked rather than installed, so the graph records them without a version and they never appear in the scanned package set; their dependencies were being dropped entirely. And --prod-only shrinks the package set without shrinking the graph, so an edge can name a package absent from the document. Both now anchor to the root project rather than orphaning the package, because it is genuinely in the tree and the root is the only anchor available. A dangling reference is never emitted. Measured on examples/lit: DEPENDENCY_OF edges 2664 -> 4082, packages with no parent 111 -> 1, and that one is the root project, which correctly has none. Zero dangling references. pnpm, Yarn and Bun have no lock graph, so the resolver returns an empty list and the path-derived behaviour is kept. That fallback is selected by length rather than truthiness, since an empty array is truthy and checking the reference alone silently emptied the graph for every non-npm ecosystem. Verified against examples/astro: 3513 edges retained. Closes #1079 --- src/output/sbom-dependency-edges.ts | 94 +++++++++++++ src/output/spdx.ts | 56 +++++++- src/output/write-outputs.ts | 8 +- tests/output/sbom-dependency-edges.test.ts | 149 +++++++++++++++++++++ tests/spdx.test.ts | 56 ++++++++ 5 files changed, 361 insertions(+), 2 deletions(-) create mode 100644 src/output/sbom-dependency-edges.ts create mode 100644 tests/output/sbom-dependency-edges.test.ts diff --git a/src/output/sbom-dependency-edges.ts b/src/output/sbom-dependency-edges.ts new file mode 100644 index 00000000..b65ac626 --- /dev/null +++ b/src/output/sbom-dependency-edges.ts @@ -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, 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; + 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(); + + 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; +} diff --git a/src/output/spdx.ts b/src/output/spdx.ts index ce0f7c43..0a616a5f 100644 --- a/src/output/spdx.ts +++ b/src/output/spdx.ts @@ -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"; @@ -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 = { @@ -289,6 +298,47 @@ function buildDependencyEdges(entries: IndexedPackage[], index: Map(); + 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(); + + 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[], @@ -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", diff --git a/src/output/write-outputs.ts b/src/output/write-outputs.ts index 35179a70..c119b5aa 100644 --- a/src/output/write-outputs.ts +++ b/src/output/write-outputs.ts @@ -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"; @@ -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 { diff --git a/tests/output/sbom-dependency-edges.test.ts b/tests/output/sbom-dependency-edges.test.ts new file mode 100644 index 00000000..435aaad2 --- /dev/null +++ b/tests/output/sbom-dependency-edges.test.ts @@ -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): { 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([]); + }); +}); diff --git a/tests/spdx.test.ts b/tests/spdx.test.ts index 214b98bd..e75b0813 100644 --- a/tests/spdx.test.ts +++ b/tests/spdx.test.ts @@ -411,6 +411,62 @@ describe("buildSpdxDocument dependency graph", () => { }); }); + it("prefers resolved graph edges over path reconstruction when supplied", () => { + // qs has no `paths` at all here, so a path-derived graph would produce + // nothing. The supplied edge still connects it. + const graphOnly = [ + pkgWithPaths("express", "4.17.1", [["project", "express"]]), + makePackage("qs", "6.7.0"), + ]; + const doc = buildSpdxDocument(graphOnly, [], meta, "1.0.0", null, { + dependencyEdges: [{ child: "qs@6.7.0", parent: "express@4.17.1" }], + }); + expect(edges(doc)).toContainEqual({ + spdxElementId: idOf(doc, "qs"), + relatedSpdxElement: idOf(doc, "express"), + relationshipType: "DEPENDENCY_OF", + }); + }); + + it("anchors a null parent to the root project", () => { + const doc = buildSpdxDocument([makePackage("express", "4.17.1")], [], meta, "1.0.0", null, { + dependencyEdges: [{ child: "express@4.17.1", parent: null }], + }); + expect(edges(doc)).toContainEqual({ + spdxElementId: idOf(doc, "express"), + relatedSpdxElement: idOf(doc, "my-app"), + relationshipType: "DEPENDENCY_OF", + }); + }); + + it("ignores a supplied edge whose parent is not in the document", () => { + const doc = buildSpdxDocument([makePackage("qs", "6.7.0")], [], meta, "1.0.0", null, { + dependencyEdges: [{ child: "qs@6.7.0", parent: "express@4.17.1" }], + }); + expect(edges(doc).some(r => r.spdxElementId === idOf(doc, "qs"))).toBe(false); + }); + + it("falls back to path reconstruction when the supplied edge list is empty", () => { + // resolveDependencyEdges returns [] for pnpm, Yarn and Bun. An empty array + // is truthy, so this must be checked by length or those ecosystems lose + // every edge. + const doc = buildSpdxDocument(tree, [], meta, "1.0.0", null, { dependencyEdges: [] }); + expect(edges(doc)).toContainEqual({ + spdxElementId: idOf(doc, "bytes"), + relatedSpdxElement: idOf(doc, "body-parser"), + relationshipType: "DEPENDENCY_OF", + }); + }); + + it("falls back to path reconstruction when no edges are supplied", () => { + const doc = buildSpdxDocument(tree, [], meta, "1.0.0"); + expect(edges(doc)).toContainEqual({ + spdxElementId: idOf(doc, "bytes"), + relatedSpdxElement: idOf(doc, "body-parser"), + relationshipType: "DEPENDENCY_OF", + }); + }); + it("emits no dependency edges when there is no root project to anchor them", () => { const doc = buildSpdxDocument(tree, [], null, "1.0.0"); expect(edges(doc).some(r => r.relatedSpdxElement.includes("DOCUMENT"))).toBe(false); From 00df51f8d3d611202b669e0c24003597b0683354 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Tue, 8 Sep 2026 14:23:04 -0400 Subject: [PATCH 2/2] docs(spdx): the dependency graph is now complete for npm projects The guide documented the five-path cap as a limitation affecting all projects, measured at roughly 5 percent of packages left without a parent edge on a large monorepo. That is no longer true for npm, where edges now come from the resolved lockfile graph. Split the guidance by package manager rather than deleting it: npm is complete, and pnpm, Yarn and Bun still derive edges from capped paths and retain the limitation. Says so plainly instead of implying the graph is exhaustive everywhere. --- website/docs/spdx.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/website/docs/spdx.md b/website/docs/spdx.md index 4d9f1e18..b73c0fa5 100644 --- a/website/docs/spdx.md +++ b/website/docs/spdx.md @@ -50,11 +50,13 @@ The [NTIA minimum elements for an SBOM](https://www.ntia.gov/report/2021/minimum Dependency relationships are derived from the resolved dependency paths, so a transitive package is linked to the parent that actually pulled it in rather than to the project root. -**A caveat that matters on large trees.** The scanner records at most five dependency paths per package, a deliberate bound that keeps large projects manageable. Dependency edges are derived from those paths, so a package reachable by more than five routes loses some of its edges, and a package whose five recorded routes all run through packages excluded from the scan can end up with no parent edge at all. +**How complete the graph is depends on your package manager.** -The effect is small on ordinary projects and grows with tree size. Scanning this project (43 production packages) leaves a single edge missing. Scanning a large monorepo (`@lit-internal/monorepo`, 2060 packages) leaves roughly 5 percent of packages without a parent edge. +For **npm** projects, edges come directly from the resolved lockfile graph, so the graph is complete. Measured on a large monorepo (`@lit-internal/monorepo`, 2060 packages), every package has a parent edge except the root project itself, which correctly has none. -The package list is always complete; only the edges between packages can be partial. If you need an exhaustive dependency graph rather than a representative one, treat this as a known limitation and track it in the linked issue. +For **pnpm, Yarn and Bun**, edges are derived from recorded dependency paths instead, and the scanner keeps at most five paths per package as a deliberate bound on large trees. A package reachable by more than five routes will therefore have some edges missing, and one whose five recorded routes all run through packages excluded from the scan can end up without a parent edge. The package list is always complete; only the edges between packages can be partial. + +If you need an exhaustive dependency graph on a non-npm project, that is a known limitation rather than a bug. ### Why licenses are sometimes NOASSERTION