@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
517 lines (484 loc) • 16 kB
JavaScript
import { assert, describe, it } from "poku";
import {
filterInvalidCryptoComponents,
mergeDependencies,
mergeServices,
propagateRequiredScopeFromDependencies,
trimComponents,
} from "./depsUtils.js";
describe("mergeDependencies()", () => {
it("merges two non-overlapping dependency arrays", () => {
const a = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1"] }];
const b = [{ ref: "pkg:npm/c@1", dependsOn: ["pkg:npm/d@1"] }];
const result = mergeDependencies(a, b);
assert.strictEqual(result.length, 2);
const aEntry = result.find((d) => d.ref === "pkg:npm/a@1");
assert.ok(aEntry);
assert.deepStrictEqual(aEntry.dependsOn, ["pkg:npm/b@1"]);
});
it("merges dependsOn sets for the same ref", () => {
const a = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1"] }];
const b = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/c@1"] }];
const result = mergeDependencies(a, b);
assert.strictEqual(result.length, 1);
const entry = result[0];
assert.ok(entry.dependsOn.includes("pkg:npm/b@1"));
assert.ok(entry.dependsOn.includes("pkg:npm/c@1"));
});
it("deduplicates identical dependsOn entries", () => {
const a = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1"] }];
const b = [
{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1", "pkg:npm/c@1"] },
];
const result = mergeDependencies(a, b);
assert.strictEqual(result.length, 1);
assert.strictEqual(
result[0].dependsOn.filter((x) => x === "pkg:npm/b@1").length,
1,
);
});
it("handles undefined newDependencies gracefully", () => {
const a = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1"] }];
const result = mergeDependencies(a, undefined);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].ref, "pkg:npm/a@1");
});
it("handles empty arrays", () => {
assert.deepStrictEqual(mergeDependencies([], []), []);
assert.deepStrictEqual(mergeDependencies([], undefined), []);
});
it("merges a single dependency object (non-array)", () => {
const a = [{ ref: "pkg:npm/a@1", dependsOn: ["pkg:npm/b@1"] }];
const single = { ref: "pkg:npm/c@1", dependsOn: ["pkg:npm/d@1"] };
const result = mergeDependencies(a, single);
assert.strictEqual(result.length, 2);
});
it("handles the provides field for OmniBOR / ADG links", () => {
const a = [
{
ref: "gitoid:commit:sha1:abc",
dependsOn: [],
provides: ["gitoid:commit:sha1:def"],
},
];
const b = [
{
ref: "gitoid:commit:sha1:def",
provides: ["gitoid:blob:sha1:001", "gitoid:blob:sha1:002"],
},
];
const result = mergeDependencies(a, b);
assert.ok(
result.every((d) => Array.isArray(d.provides)),
"all entries should have provides",
);
const defEntry = result.find((d) => d.ref === "gitoid:commit:sha1:def");
assert.ok(defEntry);
assert.ok(defEntry.provides.includes("gitoid:blob:sha1:001"));
assert.ok(defEntry.provides.includes("gitoid:blob:sha1:002"));
});
it("excludes parent component from dependsOn", () => {
const parentComponent = { "bom-ref": "pkg:npm/myapp@1.0.0" };
const a = [
{
ref: "pkg:npm/a@1",
dependsOn: ["pkg:npm/myapp@1.0.0", "pkg:npm/b@1"],
},
];
const result = mergeDependencies(a, [], parentComponent);
const entry = result.find((d) => d.ref === "pkg:npm/a@1");
assert.ok(
!entry.dependsOn.includes("pkg:npm/myapp@1.0.0"),
"parent should be excluded",
);
assert.ok(entry.dependsOn.includes("pkg:npm/b@1"));
});
it("merges parser-returned dependencies into BOM dependencies", () => {
const bomDeps = [{ ref: "pkg:npm/app@1", dependsOn: ["pkg:npm/lib@2"] }];
const parserDeps = [
{
ref: "workflow-bom-ref-1",
dependsOn: ["task-bom-ref-1", "task-bom-ref-2"],
},
{ ref: "task-bom-ref-1", dependsOn: ["pkg:github/actions/checkout@v4"] },
];
const result = mergeDependencies(bomDeps, parserDeps);
assert.strictEqual(result.length, 3);
const wfEntry = result.find((d) => d.ref === "workflow-bom-ref-1");
assert.ok(wfEntry);
assert.ok(wfEntry.dependsOn.includes("task-bom-ref-1"));
assert.ok(wfEntry.dependsOn.includes("task-bom-ref-2"));
});
it("filters out null and undefined entries from dependsOn", () => {
const deps = [
{
ref: "pkg:composer/foo/bar",
dependsOn: [null, undefined, "pkg:composer/vendor/lib@1.0"],
},
];
const result = mergeDependencies(deps, []);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].dependsOn, [
"pkg:composer/vendor/lib@1.0",
]);
assert.ok(!result[0].dependsOn.includes(null), "null must be filtered");
assert.ok(
!result[0].dependsOn.includes(undefined),
"undefined must be filtered",
);
});
it("filters out null and undefined from dependsOn even with a parentComponent", () => {
const parent = { "bom-ref": "pkg:composer/foo/bar" };
const deps = [
{
ref: "pkg:composer/foo/bar",
dependsOn: [null, "pkg:composer/vendor/lib@1.0"],
},
];
const result = mergeDependencies(deps, [], parent);
const entry = result.find((d) => d.ref === "pkg:composer/foo/bar");
assert.ok(entry);
assert.deepStrictEqual(entry.dependsOn, ["pkg:composer/vendor/lib@1.0"]);
assert.ok(!entry.dependsOn.includes(null), "null must be filtered");
});
});
describe("propagateRequiredScopeFromDependencies()", () => {
it("marks transitive dependencies of required components as required", () => {
const components = [
{
name: "app-lib",
"bom-ref": "pkg:npm/app-lib@1.0.0",
scope: "required",
},
{
name: "runtime-a",
"bom-ref": "pkg:npm/runtime-a@1.0.0",
scope: "optional",
},
{
name: "runtime-b",
"bom-ref": "pkg:npm/runtime-b@1.0.0",
scope: "optional",
},
];
const dependencies = [
{ ref: "pkg:npm/app-lib@1.0.0", dependsOn: ["pkg:npm/runtime-a@1.0.0"] },
{
ref: "pkg:npm/runtime-a@1.0.0",
dependsOn: ["pkg:npm/runtime-b@1.0.0"],
},
];
propagateRequiredScopeFromDependencies(components, dependencies);
assert.strictEqual(components[1].scope, "required");
assert.strictEqual(components[2].scope, "required");
});
it("preserves known development, optional, and peer-only packages", () => {
const components = [
{
name: "app-lib",
"bom-ref": "pkg:npm/app-lib@1.0.0",
scope: "required",
},
{
name: "dev-tool",
"bom-ref": "pkg:npm/dev-tool@1.0.0",
scope: "optional",
properties: [{ name: "cdx:npm:package:development", value: "true" }],
},
{
name: "optional-runtime",
"bom-ref": "pkg:npm/optional-runtime@1.0.0",
scope: "optional",
properties: [{ name: "cdx:npm:package:optional", value: "true" }],
},
{
name: "peer-runtime",
"bom-ref": "pkg:npm/peer-runtime@1.0.0",
scope: "optional",
properties: [{ name: "cdx:npm:package:peer", value: "true" }],
},
];
const dependencies = [
{
ref: "pkg:npm/app-lib@1.0.0",
dependsOn: [
"pkg:npm/dev-tool@1.0.0",
"pkg:npm/optional-runtime@1.0.0",
"pkg:npm/peer-runtime@1.0.0",
],
},
];
propagateRequiredScopeFromDependencies(components, dependencies);
assert.strictEqual(components[1].scope, "optional");
assert.strictEqual(components[2].scope, "optional");
assert.strictEqual(components[3].scope, "optional");
});
it("handles decoded and encoded bom-ref variants", () => {
const components = [
{
name: "scoped",
"bom-ref": "pkg:npm/@scope/scoped@1.0.0",
scope: "required",
},
{ name: "child", purl: "pkg:npm/@scope/child@1.0.0", scope: "optional" },
];
const dependencies = [
{
ref: "pkg:npm/%40scope/scoped@1.0.0",
dependsOn: ["pkg:npm/%40scope/child@1.0.0"],
},
];
propagateRequiredScopeFromDependencies(components, dependencies);
assert.strictEqual(components[1].scope, "required");
});
});
describe("trimComponents()", () => {
it("retains hashes from duplicate components", () => {
const components = [
{
name: "jquery",
version: "3.5.1",
purl: "pkg:npm/jquery@3.5.1",
type: "library",
properties: [{ name: "SrcFile", value: "Scripts/jquery.min.js" }],
},
{
name: "jquery",
version: "3.5.1",
purl: "pkg:npm/jquery@3.5.1",
type: "framework",
hashes: [{ alg: "SHA-512", content: "abc123" }],
properties: [{ name: "SrcFile", value: "package-lock.json" }],
},
];
const result = trimComponents(components);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].hashes, [
{ alg: "SHA-512", content: "abc123" },
]);
});
it("merges and deduplicates hashes from duplicate components", () => {
const components = [
{
name: "jquery",
version: "3.5.1",
purl: "pkg:npm/jquery@3.5.1",
type: "library",
hashes: [{ alg: "SHA-512", content: "abc123" }],
properties: [{ name: "SrcFile", value: "Scripts/jquery.min.js" }],
},
{
name: "jquery",
version: "3.5.1",
purl: "pkg:npm/jquery@3.5.1",
type: "framework",
hashes: [
{ alg: "SHA-512", content: "abc123" },
{ alg: "SHA-256", content: "def456" },
],
properties: [{ name: "SrcFile", value: "package-lock.json" }],
},
];
const result = trimComponents(components);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].hashes, [
{ alg: "SHA-512", content: "abc123" },
{ alg: "SHA-256", content: "def456" },
]);
});
it("retains identity tool references when merging duplicate components", () => {
const components = [
{
name: "openssl",
version: "3.0.0",
purl: "pkg:rpm/redhat/openssl@3.0.0",
type: "library",
evidence: {
identity: [
{
field: "purl",
confidence: 1,
methods: [
{
technique: "binary-analysis",
confidence: 1,
value: "openssl",
},
],
tools: ["pkg:generic/trivy@0.1.0"],
},
],
},
},
{
name: "openssl",
version: "3.0.0",
purl: "pkg:rpm/redhat/openssl@3.0.0",
type: "library",
evidence: {
identity: [
{
field: "purl",
confidence: 1,
methods: [
{
technique: "binary-analysis",
confidence: 1,
value: "openssl",
},
],
tools: ["pkg:generic/blint@1.2.3"],
},
],
},
},
];
const result = trimComponents(components);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].evidence.identity[0].tools, [
"pkg:generic/trivy@0.1.0",
"pkg:generic/blint@1.2.3",
]);
});
});
describe("mergeServices()", () => {
it("merges matching services and deduplicates endpoints and properties", () => {
const result = mergeServices(
[
{
"bom-ref": "urn:service:mcp:demo:1.0.0",
name: "demo",
version: "1.0.0",
endpoints: ["/mcp"],
authenticated: false,
properties: [{ name: "cdx:mcp:transport", value: "streamable-http" }],
},
],
[
{
"bom-ref": "urn:service:mcp:demo:1.0.0",
name: "demo",
version: "1.0.0",
endpoints: ["/mcp", "/.well-known/oauth-authorization-server"],
authenticated: true,
"x-trust-boundary": true,
properties: [
{ name: "cdx:mcp:transport", value: "streamable-http" },
{ name: "cdx:mcp:capabilities:tools", value: "true" },
],
},
],
);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].endpoints, [
"/mcp",
"/.well-known/oauth-authorization-server",
]);
assert.strictEqual(result[0].authenticated, true);
assert.strictEqual(result[0]["x-trust-boundary"], true);
assert.strictEqual(result[0].properties.length, 2);
});
it("retains distinct services", () => {
const result = mergeServices(
[{ "bom-ref": "urn:service:mcp:stdio:1.0.0", name: "stdio" }],
[{ "bom-ref": "urn:service:mcp:http:1.0.0", name: "http" }],
);
assert.strictEqual(result.length, 2);
});
it("normalizes string endpoints when merging matching services", () => {
const result = mergeServices(
[
{
"bom-ref": "urn:service:mcp:demo:1.0.0",
endpoints: "/mcp",
name: "demo",
},
],
[
{
"bom-ref": "urn:service:mcp:demo:1.0.0",
endpoints: ["/mcp", "/health"],
name: "demo",
},
],
);
assert.deepStrictEqual(result[0].endpoints, ["/mcp", "/health"]);
});
});
describe("filterInvalidCryptoComponents()", () => {
it("removes algorithm components without OID", () => {
const components = [
{
name: "sha-256",
type: "cryptographic-asset",
cryptoProperties: {
assetType: "algorithm",
oid: "2.16.840.1.101.3.4.2.1",
},
},
{
name: "unknown-algo",
type: "cryptographic-asset",
cryptoProperties: { assetType: "algorithm" },
},
{ name: "express", type: "library", purl: "pkg:npm/express@4.18.0" },
];
const result = filterInvalidCryptoComponents(components);
assert.strictEqual(result.length, 2);
assert.strictEqual(result[0].name, "sha-256");
assert.strictEqual(result[1].name, "express");
});
it("removes crypto components without cryptoProperties", () => {
const components = [
{ name: "bad-cert", type: "cryptographic-asset" },
{
name: "good-cert",
type: "cryptographic-asset",
cryptoProperties: {
assetType: "certificate",
algorithmProperties: {
executionEnvironment: "unknown",
implementationPlatform: "unknown",
},
},
},
];
const result = filterInvalidCryptoComponents(components);
assert.strictEqual(result.length, 1);
assert.strictEqual(result[0].name, "good-cert");
});
it("removes certificate components without algorithmProperties", () => {
const components = [
{
name: "bad-cert",
type: "cryptographic-asset",
cryptoProperties: { assetType: "certificate" },
},
];
const result = filterInvalidCryptoComponents(components);
assert.strictEqual(result.length, 0);
});
it("preserves related-crypto-material components", () => {
const components = [
{
name: "gpg-key",
type: "cryptographic-asset",
cryptoProperties: {
assetType: "related-crypto-material",
relatedCryptoMaterialProperties: {
type: "public-key",
id: "abc",
state: "active",
},
},
},
];
const result = filterInvalidCryptoComponents(components);
assert.strictEqual(result.length, 1);
});
it("returns an empty array for empty/falsy input", () => {
assert.strictEqual(filterInvalidCryptoComponents([]).length, 0);
assert.deepStrictEqual(filterInvalidCryptoComponents(undefined), []);
assert.deepStrictEqual(filterInvalidCryptoComponents(null), []);
});
});