UNPKG

@cyclonedx/cdxgen

Version:

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

412 lines (394 loc) 12.9 kB
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", ); }); });