UNPKG

@cyclonedx/cdxgen

Version:

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

1,590 lines (1,501 loc) 106 kB
import { execFileSync, spawnSync } from "node:child_process"; import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from "node:fs"; import { createServer } from "node:http"; import { tmpdir } from "node:os"; import { dirname, join, normalize, sep } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import esmock from "esmock"; import { assert, describe, it } from "poku"; import sinon from "sinon"; import { readBinary } from "../helpers/protobom.js"; import { getRecordedActivities, resetRecordedActivities, setDryRunMode, } from "../helpers/utils.js"; import { auditBom } from "../stages/postgen/auditBom.js"; import { postProcess } from "../stages/postgen/postgen.js"; import { createBom, createChromeExtensionBom, createJavaBom, createNodejsBom, createPHPBom, createPythonBom, createRustBom, listComponents, submitBom, } from "./index.js"; const fixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "chrome-extensions", ); const cargoFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "cargo-workspace-repotest", ); const cargoCacheFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "cargo-cache-fixture", "registry", "cache", "index.crates.io-1949cf8c6b5b557f", ); const mcpFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "mcp-repotest", ); const cbomFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "cbom-js-repotest", ); const cacheDisableFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", "cache-disable-repotest", ); const composerFixtureDir = join( dirname(fileURLToPath(import.meta.url)), "..", "..", "test", "data", ); const repoDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); function getProp(obj, name) { return obj?.properties?.find((property) => property.name === name)?.value; } function createComposerNodeModulesFixture() { const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-composer-node-modules-")); const packageDir = join(tmpDir, "node_modules", "moment-timezone"); mkdirSync(packageDir, { recursive: true }); writeFileSync( join(packageDir, "composer.json"), readFileSync(join(composerFixtureDir, "composer.json"), "utf-8"), ); writeFileSync( join(packageDir, "composer.lock"), readFileSync(join(composerFixtureDir, "composer.lock"), "utf-8"), ); return tmpDir; } function createJarNodeModulesFixture() { const tmpDir = mkdtempSync(join(tmpdir(), "cdxgen-jar-node-modules-")); const packageDir = join(tmpDir, "node_modules", "font-mfizz"); mkdirSync(packageDir, { recursive: true }); writeFileSync(join(packageDir, "blaze.jar"), "fake jar content"); return tmpDir; } const stubbedJarPackage = { group: "org.slf4j", name: "slf4j-simple", version: "2.0.17", purl: "pkg:maven/org.slf4j/slf4j-simple@2.0.17?type=jar", "bom-ref": "pkg:maven/org.slf4j/slf4j-simple@2.0.17?type=jar", }; async function loadStubbedCreateJarBom() { const actualUtils = await import("../helpers/utils.js"); const extractJarArchive = sinon.stub().resolves([stubbedJarPackage]); const getMvnMetadata = sinon.stub().callsFake(async (pkgList) => pkgList); const mockedIndex = await esmock("./index.js", { "../helpers/utils.js": { ...actualUtils, extractJarArchive, getMvnMetadata, }, }); return mockedIndex.createJarBom; } function toPortablePath(filePath) { return normalize(filePath).split(sep).join("/"); } function getNpmPackFilePaths() { const command = process.platform === "win32" ? { args: ["/c", "npm", "pack", "--dry-run", "--json"], file: process.env.ComSpec || "cmd.exe", } : { args: ["pack", "--dry-run", "--json"], file: "npm", }; const packOutput = execFileSync(command.file, command.args, { cwd: repoDir, encoding: "utf8", }); const [packSummary] = JSON.parse(packOutput); return packSummary.files.map((file) => toPortablePath(file.path)); } function buildMinimalCliEnv(extraEnv = {}) { const baseEnv = { HOME: process.env.HOME, PATH: process.env.PATH, TMPDIR: process.env.TMPDIR, }; if (process.platform === "win32") { baseEnv.SystemRoot = process.env.SystemRoot; baseEnv.TEMP = process.env.TEMP; baseEnv.TMP = process.env.TMP; baseEnv.USERPROFILE = process.env.USERPROFILE; } return Object.fromEntries( Object.entries({ ...baseEnv, ...extraEnv, }).filter(([, value]) => value !== undefined), ); } async function startSubmitBomTestServer(requestHandler) { const requests = []; const server = createServer((req, res) => { let body = ""; req.setEncoding("utf8"); req.on("data", (chunk) => { body += chunk; }); req.on("end", async () => { const request = { body, headers: req.headers, rawHeaders: req.rawHeaders, method: req.method, url: req.url, }; requests.push(request); const response = (await requestHandler(request, requests.length)) || {}; if (res.writableEnded) { return; } res.writeHead(response.statusCode || 200, { "Content-Type": "application/json", }); res.end(JSON.stringify(response.body || { success: true })); }); }); await new Promise((resolve) => { server.listen(0, "127.0.0.1", resolve); }); const address = server.address(); const serverUrl = `http://127.0.0.1:${address.port}`; return { close: () => new Promise((resolve, reject) => { server.close((error) => { if (error) { reject(error); return; } resolve(); }); }), requests, serverUrl, }; } function getRequestHeader(request, headerName) { const normalizedHeaderName = headerName.toLowerCase(); const directValue = request?.headers?.[normalizedHeaderName]; if (directValue !== undefined) { return Array.isArray(directValue) ? directValue[0] : directValue; } const rawHeaders = request?.rawHeaders; if (!Array.isArray(rawHeaders)) { return undefined; } for (let index = 0; index < rawHeaders.length; index += 2) { if (rawHeaders[index]?.toLowerCase() === normalizedHeaderName) { return rawHeaders[index + 1]; } } return undefined; } describe("CLI tests", () => { describe("component creation", () => { it("keeps readable OBOM bom-refs when no package purl type is available", () => { const components = listComponents( { specVersion: 1.7 }, undefined, [ { "bom-ref": "osquery:authorized_keys_snapshot:data:root@ssh-ed25519[key_file=/root/.ssh/authorized_keys]", name: "root", properties: [ { name: "cdx:osquery:category", value: "authorized_keys_snapshot", }, ], type: "data", version: "ssh-ed25519", }, ], "", ); assert.strictEqual(components.length, 1); assert.strictEqual(components[0].purl, undefined); assert.strictEqual( components[0]["bom-ref"], "osquery:authorized_keys_snapshot:data:root@ssh-ed25519[key_file=/root/.ssh/authorized_keys]", ); assert.strictEqual(components[0].type, "data"); }); it("does not invoke dosai crypto analysis for non-.NET CBOM scans", async () => { const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-cbom-non-dotnet-")); const collectDosaiCryptoComponents = sinon.stub().resolves([]); try { const { createCryptoCertsBom } = await esmock("./index.js", { "../helpers/cbomutils.js": { collectDosaiCryptoComponents, collectSourceCryptoComponents: sinon.stub().resolves([]), }, }); await createCryptoCertsBom(tempDir, { projectType: ["js"], specVersion: 1.7, }); sinon.assert.notCalled(collectDosaiCryptoComponents); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); it("invokes dosai crypto analysis for explicit .NET CBOM scans", async () => { const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-cbom-dotnet-")); const collectDosaiCryptoComponents = sinon.stub().resolves([ { name: "sha-256", type: "cryptographic-asset", "bom-ref": "crypto/algorithm/sha-256@2.16.840.1.101.3.4.2.1", cryptoProperties: { assetType: "algorithm", oid: "2.16.840.1.101.3.4.2.1", }, }, ]); try { const { createCryptoCertsBom } = await esmock("./index.js", { "../helpers/cbomutils.js": { collectDosaiCryptoComponents, collectSourceCryptoComponents: sinon.stub().resolves([]), }, }); const bomData = await createCryptoCertsBom(tempDir, { projectType: ["dotnet"], specVersion: 1.7, }); sinon.assert.calledOnce(collectDosaiCryptoComponents); assert.strictEqual(bomData.bomJson.components[0].name, "sha-256"); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); it("invokes dosai crypto analysis when universal scans contain .NET project files", async () => { const tempDir = mkdtempSync( join(tmpdir(), "cdxgen-cbom-dotnet-indicator-"), ); const collectDosaiCryptoComponents = sinon.stub().resolves([]); try { writeFileSync(join(tempDir, "app.csproj"), "<Project />"); const { createCryptoCertsBom } = await esmock("./index.js", { "../helpers/cbomutils.js": { collectDosaiCryptoComponents, collectSourceCryptoComponents: sinon.stub().resolves([]), }, }); await createCryptoCertsBom(tempDir, { projectType: ["universal"], specVersion: 1.7, }); sinon.assert.calledOnce(collectDosaiCryptoComponents); } finally { rmSync(tempDir, { force: true, recursive: true }); } }); it("does not interpret shell metacharacters in Maven module paths", async () => { if (process.platform === "win32") { return; } const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-maven-shell-")); const fakeBinDir = join(tempDir, "bin"); const repoDir = join(tempDir, "repo"); const markerFile = join(tmpdir(), "CDXGEN_GITURL_E2E_MARKER_TEST"); const shellIfs = "$" + "{IFS}"; const maliciousDirName = `evil;cd${shellIfs}..;cd${shellIfs}..;printf${shellIfs}CDXGEN_MAVEN_GIT_URL_E2E_SHELL_INJECTION>CDXGEN_GITURL_E2E_MARKER_TEST;#`; const maliciousModuleDir = join(repoDir, maliciousDirName); const originalPath = process.env.PATH; const originalMvnCmd = process.env.MVN_CMD; const originalMavenCmd = process.env.MAVEN_CMD; const originalMvnArgs = process.env.MVN_ARGS; try { rmSync(markerFile, { force: true }); mkdirSync(fakeBinDir, { recursive: true }); mkdirSync(maliciousModuleDir, { recursive: true }); writeFileSync( join(maliciousModuleDir, "pom.xml"), "<project><modelVersion>4.0.0</modelVersion><groupId>org.example</groupId><artifactId>evil</artifactId><version>1.0.0</version></project>", ); writeFileSync(join(maliciousModuleDir, "settings.xml"), "<settings />"); const fakeMvn = join(fakeBinDir, "mvn"); writeFileSync( fakeMvn, `#!/bin/sh for arg do case "$arg" in -DoutputFile=*) output="\${arg#-DoutputFile=}" mkdir -p "$(dirname "$output")" printf 'org.example:evil:jar:1.0.0:compile\\n' > "$output" ;; esac done `, ); chmodSync(fakeMvn, 0o755); process.env.PATH = `${fakeBinDir}${process.env.PATH ? `:${process.env.PATH}` : ""}`; delete process.env.MVN_CMD; delete process.env.MAVEN_CMD; delete process.env.MVN_ARGS; await createJavaBom(repoDir, { multiProject: true, projectType: ["java"], specVersion: 1.6, }); assert.strictEqual(existsSync(markerFile), false); } finally { if (originalPath === undefined) { delete process.env.PATH; } else { process.env.PATH = originalPath; } if (originalMvnCmd === undefined) { delete process.env.MVN_CMD; } else { process.env.MVN_CMD = originalMvnCmd; } if (originalMavenCmd === undefined) { delete process.env.MAVEN_CMD; } else { process.env.MAVEN_CMD = originalMavenCmd; } if (originalMvnArgs === undefined) { delete process.env.MVN_ARGS; } else { process.env.MVN_ARGS = originalMvnArgs; } rmSync(markerFile, { force: true }); rmSync(tempDir, { force: true, recursive: true }); } }); }); describe("distribution filters", () => { it("keeps npm types while excluding poku tests from npm pack output", () => { const packedPaths = getNpmPackFilePaths(); assert.ok( packedPaths.some((path) => path.startsWith("types/")), "expected npm pack output to keep generated type definitions", ); assert.ok( packedPaths.every((path) => !path.endsWith(".poku.js")), "expected npm pack output to exclude co-located poku tests", ); assert.ok( packedPaths.every((path) => !path.startsWith("test/")), "expected npm pack output to exclude test fixtures", ); }); }); describe("dry-run tracing", () => { it("captures sensitive file reads and environment reads for private registry style Docker inputs", () => { const fixtureRoot = mkdtempSync( join(tmpdir(), "cdxgen-dry-run-registry-"), ); const dockerConfigDir = join(fixtureRoot, "docker-config"); mkdirSync(dockerConfigDir, { recursive: true }); writeFileSync( join(dockerConfigDir, "config.json"), JSON.stringify({ credHelpers: { "docker.io": "osxkeychain", }, }), ); try { const output = execFileSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "--dry-run", "-t", "oci", "docker.io/library/alpine:3.20", "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv({ DOCKER_CONFIG: dockerConfigDir, }), }, ); assert.match(output, /cdxgen dry-run activity summary/); assert.match(output, /process\.env:DOCKER_CONFIG/); } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); it("supports bom audit in dry-run mode while skipping predictive dependency analysis", () => { const result = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "--dry-run", "--bom-audit", "--bom-audit-categories", "mcp-server", "-t", "js", mcpFixtureDir, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(result.status, 0); const output = `${result.stdout}${result.stderr}`; assert.match(output, /BOM Audit Findings/); assert.match(output, /MCP-001/); assert.match( output, /Dry-run mode only planned predictive audit targets/i, ); }); it("enforces CDXGEN_ALLOWED_HOSTS for Dependency-Track submission in secure CLI mode", () => { const result = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "--dry-run", "-t", "js", mcpFixtureDir, "--server-url", "https://blocked.example.com", "--api-key", "test-api-key", "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv({ CDXGEN_ALLOWED_HOSTS: "allowed.example.com", CDXGEN_SECURE_MODE: "true", }), }, ); const output = `${result.stdout}${result.stderr}`; assert.strictEqual(result.status, 1); assert.match( output, /Dependency-Track server host 'blocked\.example\.com' is not allowed/i, ); }); }); describe("protobuf CLI round-trip", () => { it("generates, converts, and validates protobuf BOMs for CycloneDX 1.6 and 1.7", () => { const fixtureRoot = mkdtempSync( join(tmpdir(), "cdxgen-proto-roundtrip-"), ); try { for (const specVersion of ["1.6", "1.7"]) { const jsonPath = join(fixtureRoot, `bom-${specVersion}.json`); const protoPath = join(fixtureRoot, `bom-${specVersion}.cdx`); const spdxPath = join(fixtureRoot, `bom-${specVersion}.spdx.json`); const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "-t", "js", mcpFixtureDir, "-o", jsonPath, "--spec-version", specVersion, "--export-proto", "--proto-bin-file", protoPath, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(generateResult.status, 0); assert.ok(existsSync(jsonPath)); assert.ok(existsSync(protoPath)); const convertResult = spawnSync( process.execPath, [ join(repoDir, "bin", "convert.js"), "-i", protoPath, "-o", spdxPath, ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(convertResult.status, 0); assert.ok(existsSync(spdxPath)); const validateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "validate.js"), "-i", protoPath, "--fail-severity", "critical", "--no-deep", "--report", "json", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(validateResult.status, 0); assert.doesNotMatch( `${validateResult.stdout}${validateResult.stderr}`, /Failed to parse|non-CycloneDX|Unsupported CycloneDX specVersion/i, ); } } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); it("preserves user output directories for research-profile protobuf exports", () => { const fixtureRoot = mkdtempSync( join(tmpdir(), "cdxgen-proto-research-roundtrip-"), ); try { const jsonPath = join(fixtureRoot, "research.json"); const protoPath = join(fixtureRoot, "research.cdx"); const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "-t", "js", "-t", "mcp", mcpFixtureDir, "--profile", "research", "-o", jsonPath, "--export-proto", "--proto-bin-file", protoPath, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(generateResult.status, 0); assert.ok(existsSync(fixtureRoot)); assert.ok(existsSync(jsonPath)); assert.ok(existsSync(protoPath)); const generatedBom = JSON.parse(readFileSync(jsonPath, "utf8")); assert.ok((generatedBom.services || []).length >= 1); const roundTrippedBom = readBinary(protoPath); assert.ok(roundTrippedBom); assert.ok((roundTrippedBom.formulation || []).length >= 1); assert.ok((roundTrippedBom.services || []).length >= 1); } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); it("exports standards-enabled BOMs to protobuf using canonical definitions objects", () => { const fixtureRoot = mkdtempSync( join(tmpdir(), "cdxgen-proto-standards-roundtrip-"), ); try { const jsonPath = join(fixtureRoot, "standards.json"); const protoPath = join(fixtureRoot, "standards.cdx"); const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "-t", "js", mcpFixtureDir, "--standard", "asvs-5.0", "-o", jsonPath, "--export-proto", "--proto-bin-file", protoPath, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(generateResult.status, 0); assert.ok(existsSync(jsonPath)); assert.ok(existsSync(protoPath)); const roundTrippedBom = readBinary(protoPath); assert.ok(roundTrippedBom); assert.equal(Array.isArray(roundTrippedBom.definitions), false); assert.ok((roundTrippedBom.definitions?.standards || []).length >= 1); } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); it("round-trips research, standards, and CBOM protobuf exports with canonical JSON", () => { const fixtureRoot = mkdtempSync( join(tmpdir(), "cdxgen-proto-mode-roundtrip-"), ); const scenarios = [ { args: [ "-t", "js", "-t", "mcp", mcpFixtureDir, "--profile", "research", ], assertRoundTrip: (bomJson) => { assert.ok((bomJson.formulation || []).length >= 1); }, expectedSpecVersion: (specVersion) => specVersion, name: "research", }, { args: [cbomFixtureDir, "--include-crypto", "--evidence", "--deep"], assertRoundTrip: (bomJson) => { const cryptoComponents = (bomJson.components || []).filter( (component) => component.type === "cryptographic-asset", ); assert.ok(cryptoComponents.length >= 3); assert.equal( cryptoComponents.some( (component) => component.purl !== undefined, ), false, ); }, isolateDepsSlicesFile: true, expectedSpecVersion: (specVersion) => specVersion, name: "cbom", }, { args: ["-t", "js", mcpFixtureDir, "--standard", "asvs-5.0"], assertRoundTrip: (bomJson) => { assert.equal(Array.isArray(bomJson.definitions), false); assert.ok((bomJson.definitions?.standards || []).length >= 1); }, expectedSpecVersion: () => "1.7", name: "standards", }, ]; try { for (const scenario of scenarios) { for (const specVersion of ["1.6", "1.7"]) { const jsonPath = join( fixtureRoot, `${scenario.name}-${specVersion}.json`, ); const protoPath = join( fixtureRoot, `${scenario.name}-${specVersion}.cdx`, ); const spdxPath = join( fixtureRoot, `${scenario.name}-${specVersion}.spdx.json`, ); const depsSlicesPath = join( fixtureRoot, `${scenario.name}-${specVersion}.deps.slices.json`, ); const depsSlicesArgs = scenario.isolateDepsSlicesFile ? ["--deps-slices-file", depsSlicesPath] : []; const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), ...scenario.args, "-o", jsonPath, "--spec-version", specVersion, ...depsSlicesArgs, "--export-proto", "--proto-bin-file", protoPath, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual( generateResult.status, 0, `${scenario.name} ${specVersion}: ${generateResult.stdout}${generateResult.stderr}`, ); const generatedBom = JSON.parse(readFileSync(jsonPath, "utf8")); assert.strictEqual( generatedBom.specVersion, scenario.expectedSpecVersion(specVersion), ); const convertResult = spawnSync( process.execPath, [ join(repoDir, "bin", "convert.js"), "-i", protoPath, "-o", spdxPath, ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual( convertResult.status, 0, `${scenario.name} ${specVersion}: ${convertResult.stdout}${convertResult.stderr}`, ); const validateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "validate.js"), "-i", protoPath, "--fail-severity", "critical", "--no-deep", "--report", "json", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual( validateResult.status, 0, `${scenario.name} ${specVersion}: ${validateResult.stdout}${validateResult.stderr}`, ); const roundTrippedBom = readBinary(protoPath); assert.ok(roundTrippedBom); assert.strictEqual(roundTrippedBom.bomFormat, "CycloneDX"); assert.strictEqual( roundTrippedBom.specVersion, scenario.expectedSpecVersion(specVersion), ); scenario.assertRoundTrip(roundTrippedBom); } } assert.strictEqual( existsSync(join(repoDir, "deps.slices.json")), false, "protobuf round-trip tests must not leave deps.slices.json in the repository root", ); } finally { rmSync(join(repoDir, "deps.slices.json"), { force: true }); rmSync(fixtureRoot, { force: true, recursive: true }); } }); }); describe("CycloneDX 2.0 JSON output", () => { it("generates valid experimental 2.0-dev JSON with specFormat", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-json20-")); try { const jsonPath = join(fixtureRoot, "bom-2.0.json"); const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "-t", "js", mcpFixtureDir, "-o", jsonPath, "--spec-version", "2.0", "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual( generateResult.status, 0, `${generateResult.stdout}${generateResult.stderr}`, ); const generatedBom = JSON.parse(readFileSync(jsonPath, "utf8")); assert.strictEqual(generatedBom.specFormat, "CycloneDX"); assert.strictEqual(generatedBom.bomFormat, undefined); assert.strictEqual(generatedBom.specVersion, "2.0"); assert.ok(Array.isArray(generatedBom.metadata?.tools?.components)); const validateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "validate.js"), "-i", jsonPath, "--fail-severity", "critical", "--no-deep", "--report", "json", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual( validateResult.status, 0, `${validateResult.stdout}${validateResult.stderr}`, ); } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); it("rejects experimental 2.0-dev protobuf export until cdx-proto supports it", () => { const fixtureRoot = mkdtempSync(join(tmpdir(), "cdxgen-proto20-")); try { const jsonPath = join(fixtureRoot, "bom-2.0.json"); const protoPath = join(fixtureRoot, "bom-2.0.cdx"); const generateResult = spawnSync( process.execPath, [ join(repoDir, "bin", "cdxgen.js"), "-t", "js", mcpFixtureDir, "-o", jsonPath, "--spec-version", "2.0", "--export-proto", "--proto-bin-file", protoPath, "--no-banner", ], { cwd: repoDir, encoding: "utf8", env: buildMinimalCliEnv(), }, ); assert.strictEqual(generateResult.status, 1); assert.match( `${generateResult.stdout}${generateResult.stderr}`, /CycloneDX 2\.0 is not currently supported for protobuf export/i, ); } finally { rmSync(fixtureRoot, { force: true, recursive: true }); } }); }); describe("submitBom()", () => { it("should report blocked Dependency-Track submission during dry-run", async () => { const recordActivity = sinon.stub(); const actualUtils = await import("../helpers/utils.js"); const { submitBom } = await esmock("./index.js", { "../helpers/utils.js": { ...actualUtils, isDryRun: true, recordActivity, }, }); const response = await submitBom( { apiKey: "TEST_API_KEY", projectId: "f7cb9f02-8041-4991-9101-b01fa07a6522", projectName: "cdxgen-test-project", projectVersion: "1.0.0", serverUrl: "https://dtrack.example.com", }, { bom: "test" }, ); assert.strictEqual(response, undefined); sinon.assert.calledWithMatch(recordActivity, { kind: "network", status: "blocked", target: sinon.match("https://dtrack.example.com"), }); }); it("should successfully report the SBOM with given project id, name, version and a single tag", async () => { const server = await startSubmitBomTestServer(async () => ({ body: { success: true }, })); const serverUrl = server.serverUrl; const projectId = "f7cb9f02-8041-4991-9101-b01fa07a6522"; const projectName = "cdxgen-test-project"; const projectVersion = "1.0.0"; const projectTag = "tag1"; const bomContent = { bom: "test" }; const apiKey = "TEST_API_KEY"; const skipDtTlsCheck = false; const expectedRequestPayload = { autoCreate: "true", bom: "eyJib20iOiJ0ZXN0In0=", // stringified and base64 encoded bomContent project: projectId, projectName, projectVersion, projectTags: [{ name: projectTag }], }; try { const response = await submitBom( { serverUrl, projectId, projectName, projectVersion, apiKey, skipDtTlsCheck, projectTag, }, bomContent, ); assert.deepEqual(response, { success: true }); assert.equal(server.requests.length, 1); assert.equal(server.requests[0].method, "PUT"); assert.equal(server.requests[0].url, "/api/v1/bom"); assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey); assert.equal( getRequestHeader(server.requests[0], "content-type"), "application/json", ); assert.deepEqual( JSON.parse(server.requests[0].body), expectedRequestPayload, ); } finally { await server.close(); } }); it("should successfully report the SBOM with given parent project, name, version and multiple tags", async () => { const server = await startSubmitBomTestServer(async () => ({ body: { success: true }, })); const serverUrl = server.serverUrl; const projectName = "cdxgen-test-project"; const projectVersion = "1.1.0"; const projectTags = ["tag1", "tag2"]; const parentProjectId = "5103b8b4-4ca3-46ea-8051-036a3b2ab17e"; const bomContent = { bom: "test2", }; const apiKey = "TEST_API_KEY"; const skipDtTlsCheck = false; const expectedRequestPayload = { autoCreate: "true", bom: "eyJib20iOiJ0ZXN0MiJ9", // stringified and base64 encoded bomContent parentUUID: parentProjectId, projectName, projectVersion, projectTags: [{ name: projectTags[0] }, { name: projectTags[1] }], }; try { const response = await submitBom( { serverUrl, parentProjectId, projectName, projectVersion, apiKey, skipDtTlsCheck, projectTag: projectTags, }, bomContent, ); assert.deepEqual(response, { success: true }); assert.equal(server.requests.length, 1); assert.equal(server.requests[0].method, "PUT"); assert.equal(server.requests[0].url, "/api/v1/bom"); assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey); assert.equal( getRequestHeader(server.requests[0], "content-type"), "application/json", ); assert.deepEqual( JSON.parse(server.requests[0].body), expectedRequestPayload, ); } finally { await server.close(); } }); it("should include parentName and parentVersion when parent project name and version are passed", async () => { const server = await startSubmitBomTestServer(async () => ({ body: { success: true }, })); const serverUrl = server.serverUrl; const projectName = "cdxgen-test-project"; const projectVersion = "2.0.0"; const parentProjectName = "parent-project"; const parentProjectVersion = "1.0.0"; const bomContent = { bom: "test3", }; const apiKey = "TEST_API_KEY"; const skipDtTlsCheck = false; const expectedRequestPayload = { autoCreate: "true", bom: "eyJib20iOiJ0ZXN0MyJ9", // stringified and base64 encoded bomContent parentName: parentProjectName, parentVersion: parentProjectVersion, projectName, projectVersion, }; try { const response = await submitBom( { serverUrl, projectName, projectVersion, parentProjectName, parentProjectVersion, apiKey, skipDtTlsCheck, }, bomContent, ); assert.deepEqual(response, { success: true }); assert.equal(server.requests.length, 1); assert.equal(server.requests[0].method, "PUT"); assert.equal(server.requests[0].url, "/api/v1/bom"); assert.equal(getRequestHeader(server.requests[0], "x-api-key"), apiKey); assert.equal( getRequestHeader(server.requests[0], "content-type"), "application/json", ); assert.deepEqual( JSON.parse(server.requests[0].body), expectedRequestPayload, ); } finally { await server.close(); } }); it("should include configurable autoCreate and isLatest values in payload", async () => { const server = await startSubmitBomTestServer(async () => ({ body: { success: true }, })); const serverUrl = server.serverUrl; const projectName = "cdxgen-test-project"; const apiKey = "TEST_API_KEY"; try { const response = await submitBom( { serverUrl, projectName, apiKey, autoCreate: false, isLatest: true, }, { bom: "test4" }, ); assert.deepEqual(response, { success: true }); assert.equal(server.requests.length, 1); const payload = JSON.parse(server.requests[0].body); assert.equal(payload.autoCreate, "false"); assert.equal(payload.isLatest, true); assert.equal(payload.projectVersion, "main"); } finally { await server.close(); } }); it("should reject invalid mixed parent modes before making network request", async () => { const response = await submitBom( { serverUrl: "https://dtrack.example.com", projectName: "cdxgen-test-project", parentProjectId: "5103b8b4-4ca3-46ea-8051-036a3b2ab17e", parentProjectName: "parent", parentProjectVersion: "1.0.0", }, { bom: "test5" }, ); assert.equal(response, undefined); }); it("rejects malformed Dependency-Track URLs before making a request", async () => { const response = await submitBom( { serverUrl: "file:///tmp/dtrack", projectName: "cdxgen-test-project", apiKey: "TEST_API_KEY", }, { bom: "test-invalid-url" }, ); assert.equal(response, undefined); }); it("disables redirects for the POST fallback request too", async () => { const server = await startSubmitBomTestServer( async (_request, requestCount) => { if (requestCount === 1) { return { body: { error: "Method not allowed" }, statusCode: 405 }; } return { body: { success: true }, statusCode: 200 }; }, ); try { const response = await submitBom( { serverUrl: server.serverUrl, projectName: "cdxgen-test-project", apiKey: "TEST_API_KEY\r\n", }, { bom: "test6" }, ); assert.deepEqual(response, { success: true }); assert.equal(server.requests.length, 2); assert.equal(server.requests[0].method, "PUT"); assert.equal(server.requests[1].method, "POST"); assert.equal(server.requests[1].url, "/api/v1/bom"); assert.equal( getRequestHeader(server.requests[1], "x-api-key"), "TEST_API_KEY", ); assert.equal( getRequestHeader(server.requests[1], "content-type"), "application/json", ); } finally { await server.close(); } }); }); describe("createCocoaBom()", () => { it("should skip missing Podfile.lock when failOnError is false", async () => { const { createCocoaBom } = await import("./index.js"); const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-cocoa-")); const podFile = join(tempDir, "Podfile"); writeFileSync( podFile, "platform :ios, '14.0'\n\ntarget 'TestApp' do\nend\n", "utf-8", ); const consoleLogStub = sinon.stub(console, "log"); try { const bomData = await createCocoaBom(tempDir, { deep: false, failOnError: false, installDeps: false, multiProject: false, }); assert.equal(bomData, undefined); sinon.assert.calledWithMatch( consoleLogStub, sinon.match("No 'Podfile.lock' found"), ); } finally { consoleLogStub.restore(); rmSync(tempDir, { force: true, recursive: true }); } }); it("should not warn or exit for deep mode when Podfile.lock exists", async () => { const { createCocoaBom } = await import("./index.js"); const tempDir = mkdtempSync(join(tmpdir(), "cdxgen-cocoa-deep-")); const podFile = join(tempDir, "Podfile"); const lockFile = join(tempDir, "Podfile.lock"); writeFileSync( podFile, "platform :ios, '14.0'\n\ntarget 'TestApp' do\nend\n", "utf-8", ); writeFileSync(lockFile, "PODS: []\nDEPENDENCIES: []\n", "utf-8"); const processExitStub = sinon.stub(process, "exit"); try { await createCocoaBom(tempDir, { deep: true, failOnError: true, installDeps: false, multiProject: false, }); sinon.assert.notCalled(processExitStub); } finally { processExitStub.restore(); rmSync(tempDir, { force: true, recursive: true }); } }); }); describe("createChromeExtensionBom()", () => { it("should catalog a directly provided extension and its node dependencies", async () => { const tempRoot = mkdtempSync(join(tmpdir(), "cdxgen-chrome-ext-cli-")); const extensionId = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; const extensionIdDir = join(tempRoot, extensionId); const extensionVersionDir = join(extensionIdDir, "1.2.3"); try { mkdirSync(extensionVersionDir, { recursive: true }); writeFileSync( join(extensionVersionDir, "manifest.json"), JSON.stringify({ manifest_version: 3, name: "CLI Test Extension", description: "Direct path test", version: "1.2.3", }), "utf-8", ); writeFileSync( join(extensionVersionDir, "package.json"), JSON.stringify({ name: "chrome-extension-cli-test", version: "1.2.3", dependencies: { "left-pad": "1.3.0", }, }), "utf-8", ); writeFileSync( join(extensionVersionDir, "package-lock.json"), JSON.stringify({ name: "chrome-extension-cli-test", version: "1.2.3", lockfileVersion: 3, requires: true, packages: { "": { name: "chrome-extension-cli-test", version: "1.2.3", dependencies: { "left-pad": "1.3.0", }, }, "node_modules/left-pad": { version: "1.3.0", }, }, }), "utf-8", ); const bomData = await createChromeExtensionBom(extensionIdDir, { projectType: ["chrome-extension"], multiProject: false, }); const components = bomData?.bomJson?.components || []; assert.ok( components.some( (component) => component.purl === `pkg:chrome-extension/${extensionId}@1.2.3`, ), ); assert.ok( components.some( (component) => component.name === "left-pad" && component.purl?.startsWith("pkg:npm/left-pad@1.3.0"), ), ); } finally { rmSync(tempRoot, { recursive: true, force: true }); } }); it("should parse an AI-targeted community extension manifest from direct version path", async () => { const tempRoot = mkdtempSync(join(tmpdir(), "cdxgen-chrome-ext-cli-ai-")); const extensionId = "llllllllllllllllllllllllllllllll"; const extensionVersion = "1.0.0"; const extensionVersionDir = join(tempRoot, extensionId, extensionVersion); try { mkdirSync(extensionVersionDir, { recursive: true }); writeFileSync( join(extensionVersionDir, "manifest.json"), readFileSync( join(fixtureDir, "chrome-copilottts-manifest.json"), "utf-8", ), "utf-8", ); const bomData = await createChromeExtensionBom(extensionVersionDir, { projectType: ["chrome-extension"], multiProject: false, }); const extensionComponent = (bomData?.bomJson?.components || []).find( (component) => component.purl === `pkg:chrome-extension/${extensionId}@${extensionVersion}`, ); assert.ok(extensionComponent, "expected direct extension component"); const properties = extensionComponent.properties || []; assert.ok( properties.some( (prop) => prop.name === "cdx:chrome-extension:permissions" && prop.value.includes("scripting"), ), ); assert.ok( properties.some( (prop) => prop.name === "cdx:chrome-extension:capability:codeInjection" && prop.value === "true", ), ); assert.ok( properties.some( (prop) => prop.name === "cdx:chrome-extension:hostPermissions" && prop.value.includes("https://github.com/copilot/tasks/*"), ), ); } finally { rmSync(tempRoot, { recursive: true, force: true }); } }); it("should not scan installed browser locations without explicit extension project type", async () => { const discoverChromiumExtensionDirs = sinon.stub().returns([ { browser: "Google Chrome", channel: "stable", dir: join(tmpdir(), "fake-browser-dir"), }, ]); const collectInstalledChromeExtensions = sinon.stub().returns([ { type: "application", name: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", version: "1.0.0", purl: "pkg:chrome-extension/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@1.0.0", "bom-ref": "pkg:chrome-extension/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@1.0.0", }, ]); const { createChromeExtensionBom: createChromeExtensionBomMocked } = await esmock("./index.js", { "../helpers/chromextutils.js": { CHROME_EXTENSION_PURL_TYPE: "chrome-extension", collectChromeExtensionsFromPath: sinon .stub() .returns({ components: [], extensionDirs: [] }), collectInstalledChromeExtensions, discoverChromiumExtensionDirs, }, }); const bomData = await createChromeExtensionBomMocked( join(tmpdir(), "generic-project"), { deep: true, multiProject: false, projectType: ["js"], }, ); assert.deepStrictEqual(bomData?.bomJson?.components || [], []); sinon.assert.notCalled(discoverChromiumExtensionDirs); sinon.assert.notCalled(collectInstalledChromeExtensions); }); }); describe("createVscodeExtensionBom()", () => { it("should not scan installed IDE locations without explicit extension project type", async () => { const discoverIdeExtensionDirs = sinon.stub().returns([ { name: "VS Code", dir: join(tmpdir(), "fake-ide-dir"), }, ]); const collectInstalledExtensions = sinon.stub().returns([ { type: "application", name: "sample.publisher", version: "1.0.0", purl: "pkg:vscode-extension/sample/publisher@1.0.0", "bom-ref": "pkg:vscode-extension/sample/publisher@1.0.0", }, ]); const { createVscodeExtensionBom: createVscodeExtensionBomMocked } = await esmock("./index.js", { "../helpers/vsixutils.js": { cleanupTempDir: sinon.stub(), collectInstalledExtensions, discoverIdeExtensionDirs, extractVsixToTempDir: sinon.stub(), parseVsixFile: sinon.stub(), VSCODE_EXTENSION_PURL_TYPE: "vscode-extension", }, }); const bomData = await createVscodeExtensionBomMocked( join(tmpdir(), "generic-project"), { deep: true, multiProject: false, projectType: ["js"], }, ); assert.deepStrictEqual(bomData?.bomJson?.components || [], []); sinon.assert.notCalled(discoverIdeExtensionDirs); sinon.assert.notCalled(collectInstalledExtensions); }); it("should scan installed IDE locations when explicit