UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

444 lines (410 loc) 16.8 kB
import { strict as assert } from "node:assert"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import esmock from "esmock"; import { describe, test } from "poku"; import { findJSImportsExports } from "./analyzer.js"; import { findDenoJson, parseDenoJsonFile, parseDenoLock, stripJsonc, } from "./denoutils.js"; import { addEvidenceForImports } from "./utils.js"; const V5_FIXTURE = "./test/data/deno-project/deno.lock"; const V5_PROJECT = "./test/data/deno-project"; const V2_FIXTURE = "./test/data/deno-test.lock.json"; // Collect the unique occurrence locations recorded on a component. const occurrenceLocations = (pkg) => [ ...new Set((pkg?.evidence?.occurrences || []).map((o) => o.location)), ]; // Collect the occurrence "location#line" markers recorded on a component. const occurrenceLines = (pkg) => (pkg?.evidence?.occurrences || []).map( (o) => `${o.location}${o.line ? `#${o.line}` : ""}`, ); describe("stripJsonc", () => { test("strips line and block comments plus trailing commas", () => { const src = `{ // a line comment "a": 1, /* a block comment */ "b": "https://deno.land/x", // url with slashes inside a string "c": 2, }`; const parsed = JSON.parse(stripJsonc(src)); assert.deepStrictEqual(parsed, { a: 1, b: "https://deno.land/x", c: 2, }); }); test("handles escaped quotes inside strings", () => { const src = '{"msg": "say \\"hi\\" // not a comment"}'; const parsed = JSON.parse(stripJsonc(src)); assert.strictEqual(parsed.msg, 'say "hi" // not a comment'); }); test("passes through empty/blank input", () => { assert.strictEqual(stripJsonc(""), ""); }); }); describe("parseDenoJsonFile / findDenoJson", () => { test("parses the fixture deno.json", () => { const config = parseDenoJsonFile(join(V5_PROJECT, "deno.json")); assert.strictEqual(config.name, "@demo/deno-app"); assert.strictEqual(config.version, "1.0.0"); assert.strictEqual(config.exports, "./main.ts"); }); test("parses a deno.jsonc (jsonc-tolerant)", () => { const dir = mkdtempSync(join(tmpdir(), "cdxgen-deno-jsonc-")); try { const jsoncPath = join(dir, "deno.jsonc"); writeFileSync( jsoncPath, `{ // manifest "name": "@demo/jsonc-app", "version": "2.5.0", }`, ); const config = parseDenoJsonFile(jsoncPath); assert.strictEqual(config.name, "@demo/jsonc-app"); assert.strictEqual(config.version, "2.5.0"); } finally { rmSync(dir, { force: true, recursive: true }); } }); test("returns undefined for a missing file", () => { assert.strictEqual( parseDenoJsonFile(join(V5_PROJECT, "missing.json")), undefined, ); }); test("findDenoJson prefers deno.json and falls back to deno.jsonc", () => { assert.strictEqual(findDenoJson(V5_PROJECT), join(V5_PROJECT, "deno.json")); const dir = mkdtempSync(join(tmpdir(), "cdxgen-deno-find-")); try { const jsoncPath = join(dir, "deno.jsonc"); writeFileSync(jsoncPath, "{}"); assert.strictEqual(findDenoJson(dir), jsoncPath); assert.strictEqual(findDenoJson(tmpdir()), undefined); } finally { rmSync(dir, { force: true, recursive: true }); } }); }); describe("parseDenoLock", async () => { await test("returns empty lists for a missing file", async () => { const { pkgList, dependenciesList } = await parseDenoLock( "./test/data/deno-project/does-not-exist.lock", ); assert.strictEqual(pkgList.length, 0); assert.strictEqual(dependenciesList.length, 0); }); await test("parses the v5 fixture into jsr + npm components", async () => { const parentComponent = { "bom-ref": "pkg:npm/@demo/deno-app@1.0.0" }; const { pkgList, dependenciesList } = await parseDenoLock(V5_FIXTURE, { parentComponent, }); // 12 jsr packages + 2 npm packages. assert.strictEqual(pkgList.length, 14); const byPurl = Object.fromEntries(pkgList.map((p) => [p.purl, p])); // jsr package -> pkg:npm/@jsr/<owner>__<name> (dep-scan-friendly). The // `@jsr` scope is the purl group/namespace and `<owner>__<name>` is the // npm-compat package name, so the purl is a valid two-segment npm purl. // The purl keeps the group's `@` URL-encoded (%40jsr); the bom-ref is the // decoded form (mirrors how bun/npm packages are keyed). const assertPurl = "pkg:npm/%40jsr/std__assert@1.0.19"; const assertBomRef = "pkg:npm/@jsr/std__assert@1.0.19"; const assertPkg = pkgList.find( (p) => p.group === "@jsr" && p.name === "std__assert", ); assert.ok(assertPkg, "expected an @std/assert component"); assert.strictEqual(assertPkg.purl, assertPurl); assert.strictEqual(assertPkg["bom-ref"], assertBomRef); // jsr integrity is raw sha256 hex; normalised to a sha256- prefixed value // while the hex payload is preserved verbatim (not base64-re-encoded). assert.strictEqual( assertPkg._integrity, "sha256-eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", ); // jsr components carry a deterministic canonical jsr.io website reference // (the npm-mirror tarball is only known after a registry lookup). assert.ok( assertPkg.externalReferences.some( (ref) => ref.type === "website" && ref.url === "https://jsr.io/@std/assert@1.0.19", ), "expected a jsr.io website external reference", ); // Original jsr identity is preserved for traceability. const jsrKeyProp = assertPkg.properties.find( (p) => p.name === "cdx:deno:jsrKey", ); assert.ok(jsrKeyProp, "expected a cdx:deno:jsrKey property"); assert.strictEqual(jsrKeyProp.value, "@std/assert@1.0.19"); // npm package -> pkg:npm/<name> with sha512 integrity + distribution ref. const chalkPurl = "pkg:npm/chalk@5.6.2"; const chalkPkg = byPurl[chalkPurl]; assert.ok(chalkPkg, "expected a chalk component"); assert.ok(chalkPkg._integrity.startsWith("sha512-")); assert.ok( chalkPkg.externalReferences.some( (ref) => ref.type === "distribution" && ref.url === "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", ), "chalk should carry a registry tarball distribution reference", ); // Dependency graph: @std/http depends on the other @std packages, and // @std/assert depends on @std/internal. The parent is wired to its direct // workspace dependencies. const httpEntry = dependenciesList.find( (d) => d.ref === "pkg:npm/@jsr/std__http@1.1.2", ); assert.ok(httpEntry, "expected an @std/http dependency entry"); assert.ok( httpEntry.dependsOn.length >= 9, `expected @std/http to depend on >=9 packages, got ${httpEntry.dependsOn.length}`, ); const assertEntry = dependenciesList.find( (d) => d.ref === "pkg:npm/@jsr/std__assert@1.0.19", ); assert.ok(assertEntry, "expected an @std/assert dependency entry"); assert.ok( assertEntry.dependsOn.includes("pkg:npm/@jsr/std__internal@1.0.14"), "expected @std/assert -> @std/internal edge", ); // Every component must appear in the dependency graph, including leaf jsr // packages that have no dependencies of their own (regression guard). const cliEntry = dependenciesList.find( (d) => d.ref === "pkg:npm/@jsr/std__cli@1.0.32", ); assert.ok(cliEntry, "expected leaf @std/cli to appear in the graph"); assert.strictEqual(cliEntry.dependsOn.length, 0); for (const p of pkgList) { assert.ok( dependenciesList.some((d) => d.ref === p["bom-ref"]), `component ${p["bom-ref"]} missing from the dependency graph`, ); } const parentEntry = dependenciesList.find( (d) => d.ref === "pkg:npm/@demo/deno-app@1.0.0", ); assert.ok(parentEntry, "expected a parent dependency entry"); // assert, http, chalk, lodash are the 4 direct workspace deps. assert.strictEqual(parentEntry.dependsOn.length, 4); }); await test("parses the v2 fixture (nested npm.packages) with integrity and edges", async () => { const parentComponent = { "bom-ref": "pkg:npm/demo@1.0.0" }; const { pkgList, dependenciesList } = await parseDenoLock(V2_FIXTURE, { parentComponent, }); // The stale fixture is a real cdxgen 9.x install with >100 packages. assert.ok( pkgList.length > 100, `expected many v2 packages, got ${pkgList.length}`, ); const atomPkg = pkgList.find( (p) => p.group === "@appthreat" && p.name === "atom", ); assert.ok(atomPkg, "expected @appthreat/atom in the v2 fixture"); assert.strictEqual(atomPkg.version, "0.10.1"); assert.ok( atomPkg._integrity.startsWith("sha512-"), "v2 npm integrity should be sha512- SRI", ); // v2 nested packages carry explicit dependency maps -> many edges. const edgesWithDeps = dependenciesList.filter((d) => d.dependsOn.length); assert.ok( edgesWithDeps.length > 10, `expected many v2 edges with deps, got ${edgesWithDeps.length}`, ); // Parent wired to the top-level specifier (@cyclonedx/cdxgen@9.0.1). const parentEntry = dependenciesList.find( (d) => d.ref === "pkg:npm/demo@1.0.0", ); assert.ok(parentEntry, "expected a parent dependency entry for v2"); assert.ok( parentEntry.dependsOn.includes("pkg:npm/@cyclonedx/cdxgen@9.0.1"), "expected parent -> @cyclonedx/cdxgen edge", ); }); }); describe("occurrence evidence for deno and jsr imports", async () => { await test("attaches occurrences to jsr and npm components from source imports", async () => { // The fixture's main.ts imports @std/assert, @std/http/file-server (jsr) // and chalk, lodash (npm). const { pkgList } = await parseDenoLock(V5_FIXTURE, { parentComponent: { "bom-ref": "pkg:npm/@demo/deno-app@1.0.0" }, }); const { allImports, allExports } = await findJSImportsExports( V5_PROJECT, false, ); await addEvidenceForImports(pkgList, allImports, allExports, false); const byName = Object.fromEntries(pkgList.map((p) => [p.name, p])); // jsr imports: the source uses the original `@std/...` specifier even // though the component is published under the `@jsr` npm-compat scope. assert.deepStrictEqual(occurrenceLocations(byName["std__assert"]), [ "main.ts", ]); assert.strictEqual(byName["std__assert"].scope, "required"); // A jsr subpath import (@std/http/file-server) must still resolve to the // owning @std/http component. assert.deepStrictEqual(occurrenceLocations(byName["std__http"]), [ "main.ts", ]); // npm imports continue to resolve as before. assert.deepStrictEqual(occurrenceLocations(byName["chalk"]), ["main.ts"]); assert.deepStrictEqual(occurrenceLocations(byName["lodash"]), ["main.ts"]); // Transitive-only jsr packages that are never imported in source get no // occurrence evidence. assert.strictEqual(occurrenceLocations(byName["std__internal"]).length, 0); }); await test("records use sites of imported bindings, not just import lines", async () => { const { pkgList } = await parseDenoLock(V5_FIXTURE, { parentComponent: { "bom-ref": "pkg:npm/@demo/deno-app@1.0.0" }, }); const { allImports, allExports } = await findJSImportsExports( V5_PROJECT, false, ); await addEvidenceForImports(pkgList, allImports, allExports, false); const byName = Object.fromEntries(pkgList.map((p) => [p.name, p])); // chalk is imported on line 3 and its `green` method is called on line 6: // both the import site and the call site are recorded as occurrences. assert.deepStrictEqual(occurrenceLines(byName["chalk"]).sort(), [ "main.ts#3", "main.ts#6", ]); // The jsr `assert` binding is invoked directly (assert(true)) on line 9, // which is tracked as an additional use site alongside the import. assert.deepStrictEqual(occurrenceLines(byName["std__assert"]).sort(), [ "main.ts#1", "main.ts#9", ]); // fileServer is only referenced (typeof), never called, so only the import // site is recorded. assert.deepStrictEqual(occurrenceLines(byName["std__http"]), ["main.ts#2"]); }); }); describe("offline metadata mining from node_modules", async () => { // Build a throwaway project with a v5 deno.lock and a node_modules tree // containing minimal package.json manifests for an npm and a jsr package. const setupProject = () => { const dir = mkdtempSync(join(tmpdir(), "cdxgen-deno-nm-")); writeFileSync( join(dir, "deno.lock"), JSON.stringify({ version: "5", specifiers: { "jsr:@std/assert@1": "1.0.19", "npm:chalk@^5": "5.6.2", }, jsr: { "@std/assert@1.0.19": { integrity: "abcd" } }, npm: { "chalk@5.6.2": { integrity: "sha512-deadbeef" } }, workspace: { dependencies: ["jsr:@std/assert@1", "npm:chalk@^5"] }, }), ); const writeManifest = (relDir, manifest) => { const pkgDir = join(dir, "node_modules", relDir); mkdirSync(pkgDir, { recursive: true }); writeFileSync(join(pkgDir, "package.json"), JSON.stringify(manifest)); }; writeManifest("chalk", { name: "chalk", version: "5.6.2", license: "MIT", description: "Terminal string styling done right", repository: { url: "git+https://github.com/chalk/chalk.git" }, }); // jsr packages materialise under their @jsr npm-compat name. writeManifest(join("@jsr", "std__assert"), { name: "@jsr/std__assert", version: "1.0.19", license: "MIT", description: "Common assertion functions", }); return dir; }; await test("enriches npm and jsr components from node_modules without network", async () => { const dir = setupProject(); try { const { pkgList } = await parseDenoLock(join(dir, "deno.lock"), { projectRoot: dir, }); const chalk = pkgList.find((p) => p.name === "chalk"); // license/description are mined offline (raw string; normalised later in // the pipeline) and the repository becomes a vcs external reference. assert.strictEqual(chalk.license, "MIT"); assert.strictEqual( chalk.description, "Terminal string styling done right", ); assert.ok( chalk.externalReferences.some( (ref) => ref.type === "vcs" && ref.url === "git+https://github.com/chalk/chalk.git", ), "expected a vcs external reference mined from package.json", ); // jsr packages resolve under their @jsr npm-compat directory. const stdAssert = pkgList.find((p) => p.name === "std__assert"); assert.strictEqual(stdAssert.license, "MIT"); assert.strictEqual(stdAssert.description, "Common assertion functions"); } finally { rmSync(dir, { force: true, recursive: true }); } }); }); describe("jsr metadata API", async () => { await test("fetches license, description and repository from api.jsr.io", async () => { // Mock the shared HTTP client so the test is hermetic (no network). const requestedUrls = []; const fakeAgent = { get: async (url) => { requestedUrls.push(url); if (url.endsWith("/versions/1.0.19")) { return { body: { license: "MIT" } }; } return { body: { description: "Common assertion functions", githubRepository: { owner: "denoland", name: "std" }, }, }; }, }; const { getJsrMetadata } = await esmock("./denoutils.js", { "./utils.js": { cdxgenAgent: fakeAgent }, }); const component = { name: "std__assert", group: "@jsr", version: "1.0.19", properties: [{ name: "cdx:deno:jsrKey", value: "@std/assert@1.0.19" }], }; await getJsrMetadata([component]); assert.strictEqual(component.license, "MIT"); assert.strictEqual(component.description, "Common assertion functions"); assert.ok( component.externalReferences.some( (ref) => ref.type === "vcs" && ref.url === "https://github.com/denoland/std", ), "expected a github vcs external reference from the jsr API", ); // The original jsr scope/name/version drive the api.jsr.io paths. assert.ok( requestedUrls.includes( "https://api.jsr.io/scopes/std/packages/assert/versions/1.0.19", ), ); assert.ok( requestedUrls.includes("https://api.jsr.io/scopes/std/packages/assert"), ); }); });