@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,590 lines (1,501 loc) • 106 kB
JavaScript
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