@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
412 lines (394 loc) • 12.9 kB
JavaScript
import { assert, describe, it } from "poku";
import { validateSpdx } from "../../validator/bomValidator.js";
import {
convertCycloneDxToSpdx,
SPDX_JSONLD_CONTEXT,
} from "./spdxConverter.js";
function sampleBom() {
return {
bomFormat: "CycloneDX",
specVersion: 1.7,
serialNumber: "urn:uuid:1b671687-395b-41f5-a30f-a58921a69b79",
version: 1,
metadata: {
timestamp: "2024-02-02T00:00:00Z",
component: {
type: "application",
name: "demo-app",
version: "1.0.0",
"bom-ref": "pkg:generic/demo-app@1.0.0",
properties: [{ name: "cdx:app:tier", value: "backend" }],
},
properties: [{ name: "cdx:bom:componentTypes", value: "library" }],
},
components: [
{
type: "library",
name: "lodash",
version: "4.17.21",
purl: "pkg:npm/lodash@4.17.21",
"bom-ref": "pkg:npm/lodash@4.17.21",
hashes: [
{ alg: "SHA-256", content: "abc123" },
{ alg: "BLAKE2s", content: "def456" },
],
properties: [{ name: "cdx:npm:hasInstallScript", value: "true" }],
externalReferences: [
{ type: "website", url: "https://lodash.com" },
{ type: "vcs", url: "https://github.com/lodash/lodash.git" },
],
author: "Legacy Author",
authors: [{ name: "Lodash Author", email: "author@lodash.com" }],
publisher: "OpenJS Foundation",
maintainers: [{ name: "Lodash Maintainer" }],
tags: ["utility", "js"],
licenses: [{ license: { id: "MIT" } }],
},
],
dependencies: [
{
ref: "pkg:generic/demo-app@1.0.0",
dependsOn: ["pkg:npm/lodash@4.17.21"],
},
{ ref: "pkg:npm/lodash@4.17.21", dependsOn: [] },
],
formulation: [
{
services: [
{
"bom-ref": "urn:example:service:api",
name: "api-service",
properties: [{ name: "cdx:service:httpMethod", value: "GET" }],
},
],
workflows: [
{
"bom-ref": "urn:example:workflow:build",
name: "build-workflow",
tasks: [
{
"bom-ref": "urn:example:task:build",
name: "build-task",
properties: [
{
name: "cdx:github:workflow:hasWritePermissions",
value: "true",
},
],
},
],
},
],
},
],
};
}
function minimalBom() {
return {
bomFormat: "CycloneDX",
specVersion: 1.7,
metadata: {
timestamp: "2024-02-02T00:00:00Z",
component: {
type: "application",
name: "demo-app",
version: "1.0.0",
purl: "pkg:generic/demo-app@1.0.0",
"bom-ref": "pkg:generic/demo-app@1.0.0",
},
},
components: [
{
type: "library",
name: "left-pad",
version: "1.3.0",
purl: "pkg:npm/left-pad@1.3.0",
"bom-ref": "pkg:npm/left-pad@1.3.0",
},
],
dependencies: [
{
ref: "pkg:generic/demo-app@1.0.0",
dependsOn: ["pkg:npm/left-pad@1.3.0"],
},
{ ref: "pkg:npm/left-pad@1.3.0", dependsOn: [] },
],
};
}
function getExtensionPropertyMap(spdxElement) {
const propertyMap = new Map();
for (const extension of spdxElement?.extension || []) {
for (const entry of extension?.extension_cdxProperty || []) {
propertyMap.set(
entry.extension_cdxPropName,
entry.extension_cdxPropValue,
);
}
}
return propertyMap;
}
describe("convertCycloneDxToSpdx", () => {
it("converts a CycloneDX BOM into SPDX 3.0.1 JSON-LD", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
assert.strictEqual(spdxJson["@context"], SPDX_JSONLD_CONTEXT);
assert.ok(Array.isArray(spdxJson["@graph"]));
assert.ok(
spdxJson["@graph"].some((element) => element.type === "SpdxDocument"),
);
assert.ok(
spdxJson["@graph"].some((element) => element.type === "Relationship"),
);
assert.deepStrictEqual(spdxJson["@graph"][0].createdBy, [
"https://github.com/cdxgen/cdxgen",
]);
});
it("produces an export accepted by the bundled validator", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
assert.strictEqual(validateSpdx(spdxJson), true);
});
it("converts CycloneDX 1.6 BOMs to valid SPDX 3.0.1 JSON-LD", () => {
const bom16 = sampleBom();
bom16.specVersion = 1.6;
const spdxJson = convertCycloneDxToSpdx(bom16, {
projectName: "demo-app",
});
assert.strictEqual(validateSpdx(spdxJson), true);
});
it("converts CycloneDX 1.7 BOMs to valid SPDX 3.0.1 JSON-LD", () => {
const bom17 = sampleBom();
bom17.specVersion = 1.7;
const spdxJson = convertCycloneDxToSpdx(bom17, {
projectName: "demo-app",
});
assert.strictEqual(validateSpdx(spdxJson), true);
});
it("preserves advanced CycloneDX data in SPDX extension fields", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
const packageElement = spdxJson["@graph"].find(
(element) => element.software_packageUrl === "pkg:npm/lodash@4.17.21",
);
assert.ok(packageElement);
assert.ok(Array.isArray(packageElement.externalRef));
assert.strictEqual(
packageElement.externalRef[0].externalRefType,
"altWebPage",
);
const packageExtensionProperties = getExtensionPropertyMap(packageElement);
assert.strictEqual(
packageElement.extension[0].type,
"extension_CdxPropertiesExtension",
);
assert.strictEqual(
packageExtensionProperties.get("properties.cdx:npm:hasInstallScript"),
"true",
);
assert.strictEqual(
packageExtensionProperties.get("hashes"),
'[{"algorithm":"SHA-256","hashValue":"abc123","normalizedAlgorithm":"sha256"},{"algorithm":"BLAKE2s","hashValue":"def456"}]',
);
assert.strictEqual(
packageExtensionProperties.get("author"),
"Legacy Author",
);
assert.strictEqual(
packageExtensionProperties.get("authors"),
'[{"name":"Lodash Author","email":"author@lodash.com"}]',
);
assert.strictEqual(
packageExtensionProperties.get("publisher"),
"OpenJS Foundation",
);
assert.strictEqual(
packageExtensionProperties.get("maintainers"),
'[{"name":"Lodash Maintainer"}]',
);
assert.strictEqual(
packageExtensionProperties.get("tags"),
'["utility","js"]',
);
assert.strictEqual(
packageExtensionProperties.get("licenses"),
'[{"license":{"id":"MIT"}}]',
);
const documentElement = spdxJson["@graph"].find(
(element) => element.type === "SpdxDocument",
);
assert.ok(documentElement);
const documentExtensionProperties =
getExtensionPropertyMap(documentElement);
assert.strictEqual(
documentElement.profileConformance.includes("extension"),
true,
);
assert.strictEqual(
documentExtensionProperties.get(
"metadataProperties.cdx:bom:componentTypes",
),
"library",
);
assert.strictEqual(
documentExtensionProperties.get("formulation"),
JSON.stringify(sampleBom().formulation),
);
});
it("preserves MCP services and community skill components in SPDX export extensions", () => {
const bom = sampleBom();
bom.services = [
{
"bom-ref": "urn:service:mcp:remoteDocs:configured",
name: "remoteDocs",
endpoints: ["https://docs.example.com/mcp"],
properties: [
{ name: "cdx:mcp:inventorySource", value: "config-file" },
{ name: "cdx:mcp:configFormat", value: "opencode" },
{ name: "cdx:mcp:authPosture", value: "oauth" },
],
},
];
bom.formulation[0].components = [
{
type: "file",
name: "SKILL.md",
"bom-ref": "file:/repo/.opencode/skills/git-release/SKILL.md",
properties: [
{ name: "cdx:file:kind", value: "skill-file" },
{ name: "cdx:skill:name", value: "git-release" },
{
name: "cdx:skill:description",
value: "Prepare consistent releases",
},
],
},
];
const spdxJson = convertCycloneDxToSpdx(bom, {
projectName: "demo-app",
});
const documentElement = spdxJson["@graph"].find(
(element) => element.type === "SpdxDocument",
);
assert.ok(documentElement);
const documentExtensionProperties =
getExtensionPropertyMap(documentElement);
assert.strictEqual(
documentExtensionProperties.get("services"),
JSON.stringify(bom.services),
);
const serviceElement = spdxJson["@graph"].find(
(element) =>
getExtensionPropertyMap(element).get("bomRef") ===
"urn:service:mcp:remoteDocs:configured",
);
assert.ok(
serviceElement,
"expected synthetic SPDX element for MCP service",
);
assert.strictEqual(
getExtensionPropertyMap(serviceElement).get(
"properties.cdx:mcp:inventorySource",
),
"config-file",
);
const skillElement = spdxJson["@graph"].find(
(element) =>
getExtensionPropertyMap(element).get("bomRef") ===
"file:/repo/.opencode/skills/git-release/SKILL.md",
);
assert.ok(skillElement, "expected SPDX element for skill file component");
assert.strictEqual(
getExtensionPropertyMap(skillElement).get("properties.cdx:skill:name"),
"git-release",
);
});
it("omits document-level SPDX extensions while package-level metadata still enables the extension profile", () => {
const spdxJson = convertCycloneDxToSpdx(minimalBom(), {
projectName: "demo-app",
});
const packageElement = spdxJson["@graph"].find(
(element) => element.software_packageUrl === "pkg:npm/left-pad@1.3.0",
);
const documentElement = spdxJson["@graph"].find(
(element) => element.type === "SpdxDocument",
);
assert.ok(packageElement);
assert.ok(documentElement);
assert.strictEqual(documentElement.extension, undefined);
assert.strictEqual(
documentElement.profileConformance.includes("extension"),
true,
);
assert.strictEqual(
getExtensionPropertyMap(packageElement).get("bomRef"),
"pkg:npm/left-pad@1.3.0",
);
});
it("uses component bom-ref as document name fallback before version", () => {
const bom = sampleBom();
delete bom.metadata.component.name;
const spdxJson = convertCycloneDxToSpdx(bom);
const documentElement = spdxJson["@graph"].find(
(element) => element.type === "SpdxDocument",
);
assert.ok(documentElement);
assert.strictEqual(documentElement.name, "pkg:generic/demo-app@1.0.0");
});
it("rejects malformed SPDX exports", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
spdxJson["@context"] = "https://example.com/not-spdx";
assert.strictEqual(validateSpdx(spdxJson), false);
});
it("rejects SPDX relationships with non-string from references", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
const relationship = spdxJson["@graph"].find(
(element) => element.type === "Relationship",
);
relationship.from = [relationship.from];
assert.strictEqual(validateSpdx(spdxJson), false);
});
it("rejects SPDX exports with malformed extension entries", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
const packageElement = spdxJson["@graph"].find(
(element) => element.software_packageUrl === "pkg:npm/lodash@4.17.21",
);
delete packageElement.extension[0].type;
assert.strictEqual(validateSpdx(spdxJson), false);
});
it("uses the official JSON-LD compact extension terms", () => {
const spdxJson = convertCycloneDxToSpdx(sampleBom(), {
projectName: "demo-app",
});
const documentElement = spdxJson["@graph"].find(
(element) => element.type === "SpdxDocument",
);
assert.ok(documentElement);
assert.strictEqual(
documentElement.extension[0].type,
"extension_CdxPropertiesExtension",
);
assert.strictEqual(
documentElement.extension[0].extension_cdxProperty[0].type,
"extension_CdxPropertyEntry",
);
assert.strictEqual(
typeof documentElement.extension[0].extension_cdxProperty[0]
.extension_cdxPropName,
"string",
);
assert.strictEqual(
typeof documentElement.extension[0].extension_cdxProperty[0]
.extension_cdxPropValue,
"string",
);
});
});