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
12 changes: 8 additions & 4 deletions src/output/sbom-dependency-edges.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { loadBunLockGraph } from "../parsers/bun-lock-graph.js";
import { loadNpmLockGraph } from "../parsers/npm-lock-graph.js";
import { loadPnpmLockGraph } from "../parsers/pnpm-lock-graph.js";
import type { PackageRef, ScanInput } from "../types.js";
Expand Down Expand Up @@ -33,13 +34,13 @@ function addEdge(edges: DependencyEdge[], seen: Set<string>, child: string, pare
* 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 lockfiles with no graph implementation yet (Yarn
* and Bun), so those fall back to the caller's path-derived behaviour rather
* Returns an empty list for lockfiles with no graph implementation yet (Yarn),
* so those fall back to the caller's path-derived behaviour rather
* than silently losing every edge.
*/
/**
* The slice of a lockfile graph the edge builder needs. npm and pnpm build
* their graphs very differently, but both can answer these three questions,
* The slice of a lockfile graph the edge builder needs. npm, pnpm and Bun build
* their graphs very differently, but all can answer these three questions,
* which is all it takes to reconstruct the dependency relationships.
*/
type EdgeGraph = {
Expand All @@ -62,6 +63,9 @@ function loadEdgeGraph(scanInput: ScanInput): EdgeGraph | null {
if (scanInput.source === "pnpm-lock") {
return loadPnpmLockGraph(scanInput.filePath);
}
if (scanInput.source === "bun-lock") {
return loadBunLockGraph(scanInput.filePath);
}
} catch {
// A lockfile we cannot read is not worth failing an SBOM over.
return null;
Expand Down
68 changes: 68 additions & 0 deletions src/parsers/bun-lock-graph.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from "node:fs";

/**
* Key resolution for `bun.lock`.
*
Expand Down Expand Up @@ -146,3 +148,69 @@ export function collectReachableKeys(

return reachable;
}

/** Reads and parses a bun.lock. Bun writes JSONC, so trailing commas are stripped. */
export function readAndParseBunLock(filePath: string): unknown {
return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/,(\s*[}\]])/g, "$1"));
}

/**
* A node in the Bun dependency graph, identified by its lockfile key.
*
* Bun encodes duplicate versions in the key, so several keys can describe the
* same `name@version` when one version is installed under more than one parent.
* That is why `nodeIdsFor` returns a list rather than a single id.
*/
export type BunGraphNode = { name: string; version: string };

export type BunLockGraph = {
nodeIdsFor(name: string, version: string | null): readonly string[];
parentsFor(nodeId: string): readonly string[];
getNode(nodeId: string): Readonly<BunGraphNode> | null;
};

const EMPTY: readonly string[] = Object.freeze([]);

/**
* Builds the complete child-to-parents map for a bun.lock.
*
* The parser walks this same structure, but `collectBunPaths` caps it at five
* paths of at most ten segments per package. Rebuilding edges from those
* truncated paths loses routes on any sizeable tree, and a package whose
* surviving routes all traverse filtered-out packages ends up with no parent at
* all. This keeps every edge.
*
* Returns null for a lockfile it cannot read, so the caller falls back to
* path-derived behaviour rather than emitting an SBOM with no edges.
*/
export function loadBunLockGraph(filePath: string): BunLockGraph | null {
const raw = readAndParseBunLock(filePath) as any;
const packages = raw?.packages;
if (!packages || typeof packages !== "object") return null;

const keyIndex = buildBunKeyIndex(packages);
const childGraph = buildBunChildKeyGraph(packages);

const parentsByKey = new Map<string, string[]>();
for (const [parentKey, childKeys] of childGraph) {
for (const childKey of childKeys) {
const parents = parentsByKey.get(childKey);
if (!parents) parentsByKey.set(childKey, [parentKey]);
else if (!parents.includes(parentKey)) parents.push(parentKey);
}
}

const keysByNameVersion = new Map<string, string[]>();
for (const [key, node] of keyIndex) {
const id = `${node.name}@${node.version}`;
const keys = keysByNameVersion.get(id);
if (!keys) keysByNameVersion.set(id, [key]);
else keys.push(key);
}

return {
nodeIdsFor: (name, version) => keysByNameVersion.get(`${name}@${version ?? ""}`) ?? EMPTY,
parentsFor: nodeId => parentsByKey.get(nodeId) ?? EMPTY,
getNode: nodeId => keyIndex.get(nodeId) ?? null,
};
}
11 changes: 3 additions & 8 deletions src/parsers/bun-lock.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import fs from "node:fs";
import type { PackageRef } from "../types.js";
import { uniquePathArrays } from "../utils/array.js";
import { upsertPackage } from "./utils.js";
import {
buildBunChildKeyGraph,
buildBunKeyIndex,
collectReachableKeys,
readAndParseBunLock,
resolveBunChildKey,
} from "./bun-lock-graph.js";

Expand All @@ -17,11 +17,6 @@ const DEPENDENCY_SECTIONS = ["dependencies", "optionalDependencies", "devDepende
/** A traversal starting point: a dependency declared by the root or by a workspace member. */
type BunSeed = { key: string; path: string[]; dev: boolean };

function parseJsonc(text: string): unknown {
// bun.lock uses JSONC (trailing commas). Strip them before parsing.
return JSON.parse(text.replace(/,(\s*[}\]])/g, "$1"));
}

/**
* Builds the traversal seeds for a lockfile.
*
Expand Down Expand Up @@ -134,7 +129,7 @@ function createPackageRefs(
}

export function buildBunWorkspaceMap(filePath: string): Map<string, string[]> {
const raw = parseJsonc(fs.readFileSync(filePath, "utf8")) as any;
const raw = readAndParseBunLock(filePath) as any;
const workspaces = raw?.workspaces ?? {};
const map = new Map<string, string[]>();

Expand All @@ -155,7 +150,7 @@ export function buildBunWorkspaceMap(filePath: string): Map<string, string[]> {
}

export function loadFromBunLock(filePath: string, prodOnly: boolean): PackageRef[] {
const raw = parseJsonc(fs.readFileSync(filePath, "utf8")) as any;
const raw = readAndParseBunLock(filePath) as any;
const packages = raw?.packages ?? {};
const workspaces = raw?.workspaces ?? {};

Expand Down
17 changes: 17 additions & 0 deletions tests/fixtures/lockfile-bun-graph/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions tests/output/sbom-dependency-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,60 @@ describe("resolveDependencyEdges - pnpm", () => {
expect(edgesFor()).toContainEqual({ child: "express@4.17.1", parent: null });
});
});

describe("resolveDependencyEdges - bun", () => {
const BUN = "tests/fixtures/lockfile-bun-graph/bun.lock";

const pkg = (name: string, version: string): PackageRef =>
({ name, version, ecosystem: "npm", paths: [] }) as PackageRef;

const allPackages = [
pkg("express", "4.17.1"),
pkg("body-parser", "1.19.0"),
pkg("ms", "2.0.0"),
pkg("vite", "5.0.0"),
pkg("next", "15.0.0"),
pkg("postcss", "8.4.31"),
pkg("postcss", "8.5.15"),
pkg("@acme/web", "workspace:apps/web"),
];

const edgesFor = (packages: PackageRef[] = allPackages) =>
resolveDependencyEdges({ source: "bun-lock", filePath: BUN } as ScanInput, packages);

it("resolves edges from the bun lock graph instead of returning nothing", () => {
expect(edgesFor().length).toBeGreaterThan(0);
});

it("keeps every parent of a package reached through more than one route", () => {
const parentsOfMs = edgesFor()
.filter(e => e.child === "ms@2.0.0")
.map(e => e.parent)
.sort();
expect(parentsOfMs).toEqual(["body-parser@1.19.0", "express@4.17.1", "vite@5.0.0"]);
});

it("attributes each version of a package to the parent that installs it", () => {
const edges = edgesFor();
// The nested copy belongs to next; the top-level copy belongs to vite. Deriving
// these from bare names would have collapsed both onto one version.
expect(edges).toContainEqual({ child: "postcss@8.4.31", parent: "next@15.0.0" });
expect(edges).toContainEqual({ child: "postcss@8.5.15", parent: "vite@5.0.0" });
expect(edges).not.toContainEqual({ child: "postcss@8.4.31", parent: "vite@5.0.0" });
});

it("anchors a root-level package to the project rather than orphaning it", () => {
expect(edgesFor()).toContainEqual({ child: "express@4.17.1", parent: null });
});

it("anchors to the root rather than emitting a reference to a filtered-out package", () => {
// body-parser is excluded from the document, so ms must not reference it.
const trimmed = allPackages.filter(p => p.name !== "body-parser");
const parentsOfMs = edgesFor(trimmed)
.filter(e => e.child === "ms@2.0.0")
.map(e => e.parent);

expect(parentsOfMs).not.toContain("body-parser@1.19.0");
expect(parentsOfMs.length).toBeGreaterThan(0);
});
});
72 changes: 72 additions & 0 deletions tests/parsers/bun-lock-graph.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
loadBunLockGraph,
splitBunPackageKey,
resolveBunChildKey,
buildBunChildKeyGraph,
Expand Down Expand Up @@ -139,3 +143,71 @@ describe("collectReachableKeys", () => {
expect([...collectReachableKeys(["a"], graph)].sort()).toEqual(["a", "b"]);
});
});

describe("loadBunLockGraph", () => {
function writeLock(contents: unknown): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cve-lite-bun-graph-"));
const lockPath = path.join(dir, "bun.lock");
fs.writeFileSync(lockPath, JSON.stringify(contents), "utf8");
return lockPath;
}

const lock = {
lockfileVersion: 1,
workspaces: { "": { name: "root", dependencies: { next: "^15.0.0", vite: "^5.0.0" } } },
packages: {
next: ["next@15.0.0", "", { dependencies: { postcss: "8.4.31" } }, "sha512-next"],
"next/postcss": ["postcss@8.4.31", "", {}, "sha512-old"],
vite: ["vite@5.0.0", "", { dependencies: { postcss: "^8.5.0" } }, "sha512-vite"],
postcss: ["postcss@8.5.15", "", {}, "sha512-new"],
},
};

it("maps each version of a package to its own parent", () => {
const graph = loadBunLockGraph(writeLock(lock))!;

const nested = graph.nodeIdsFor("postcss", "8.4.31");
const topLevel = graph.nodeIdsFor("postcss", "8.5.15");

expect(nested).toEqual(["next/postcss"]);
expect(topLevel).toEqual(["postcss"]);
expect(graph.parentsFor(nested[0]!)).toEqual(["next"]);
expect(graph.parentsFor(topLevel[0]!)).toEqual(["vite"]);
});

it("resolves a node id back to its name and version", () => {
const graph = loadBunLockGraph(writeLock(lock))!;

expect(graph.getNode("next/postcss")).toEqual({ name: "postcss", version: "8.4.31" });
expect(graph.getNode("nope")).toBeNull();
});

it("returns every node id when one version is installed under several parents", () => {
const graph = loadBunLockGraph(
writeLock({
lockfileVersion: 1,
workspaces: { "": { name: "root", dependencies: { a: "^1.0.0", b: "^1.0.0" } } },
packages: {
a: ["a@1.0.0", "", { dependencies: { dup: "1.0.0" } }, "sha512-a"],
"a/dup": ["dup@1.0.0", "", {}, "sha512-d1"],
b: ["b@1.0.0", "", { dependencies: { dup: "1.0.0" } }, "sha512-b"],
"b/dup": ["dup@1.0.0", "", {}, "sha512-d2"],
},
}),
)!;

// Same name@version, two distinct lockfile nodes, so both must be returned or
// the edge builder would miss one of the two parents.
expect([...graph.nodeIdsFor("dup", "1.0.0")].sort()).toEqual(["a/dup", "b/dup"]);
});

it("reports no parents for a package nothing depends on", () => {
const graph = loadBunLockGraph(writeLock(lock))!;

expect(graph.parentsFor("next")).toEqual([]);
});

it("returns null for a lockfile with no packages section", () => {
expect(loadBunLockGraph(writeLock({ lockfileVersion: 1 }))).toBeNull();
});
});
6 changes: 3 additions & 3 deletions website/docs/spdx.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,11 @@ Dependency relationships are derived from the resolved dependency paths, so a tr

**How complete the graph is depends on your package manager.**

For **npm** and **pnpm** projects, edges come directly from the resolved lockfile graph, so the graph is complete. Measured on `@lit-internal/monorepo` (npm, 2060 packages) and on two large pnpm monorepos, every package has a parent edge except the root project itself, which correctly has none.
For **npm**, **pnpm** and **Bun** projects, edges come directly from the resolved lockfile graph, so the graph is complete. Measured on `@lit-internal/monorepo` (npm, 2060 packages), on two large pnpm monorepos, and on a 1518-package Bun workspace monorepo, every package has a parent edge except the root project itself, which correctly has none.

For **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.
For **Yarn**, 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 Yarn or Bun project, that is a known limitation rather than a bug, tracked in [#1109](https://github.com/OWASP/cve-lite-cli/issues/1109) and [#1108](https://github.com/OWASP/cve-lite-cli/issues/1108).
If you need an exhaustive dependency graph on a Yarn project, that is a known limitation rather than a bug, tracked in [#1109](https://github.com/OWASP/cve-lite-cli/issues/1109).

### Why licenses are sometimes NOASSERTION

Expand Down