@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
797 lines (766 loc) • 22.4 kB
JavaScript
import { CDXGEN_SPDX_CREATED_BY, getTimestamp } from "../../helpers/utils.js";
export const SPDX_JSONLD_CONTEXT =
"https://spdx.org/rdf/3.0.1/spdx-context.jsonld";
export const SPDX_SPEC_VERSION = "3.0.1";
const SPDX_DOCUMENT_PROFILES = ["core", "software"];
const SPDX_RELATIONSHIP_DEPENDS_ON = "dependsOn";
const SPDX_EXTENSION_PROFILE = "extension";
const SPDX_EXTENSION_KEY = "extension";
const SPDX_CDX_PROPERTIES_EXTENSION_TYPE = "extension_CdxPropertiesExtension";
const SPDX_CDX_PROPERTY_ENTRY_TYPE = "extension_CdxPropertyEntry";
const SPDX_CDX_PROPERTY_KEY = "extension_cdxProperty";
const SPDX_CDX_PROPERTY_NAME_KEY = "extension_cdxPropName";
const SPDX_CDX_PROPERTY_VALUE_KEY = "extension_cdxPropValue";
const SPDX_EXTERNAL_REF_TYPE_MAP = Object.freeze({
website: "altWebPage",
documentation: "documentation",
distribution: "altDownloadLocation",
download: "altDownloadLocation",
"issue-tracker": "issueTracker",
"mailing-list": "mailingList",
vcs: "vcs",
"build-meta": "buildMeta",
"build-system": "buildSystem",
"release-notes": "releaseNotes",
chat: "chat",
social: "socialMedia",
"social-media": "socialMedia",
support: "support",
license: "license",
cwe: "cwe",
});
const SPDX_HASH_ALGORITHMS = new Set([
"sha1",
"sha224",
"sha256",
"sha384",
"sha512",
"sha3_256",
"sha3_384",
"sha3_512",
"md2",
"md4",
"md5",
"md6",
"adler32",
"blake2b_256",
"blake2b_384",
"blake2b_512",
"blake3",
"gost3411",
"ripemd_160",
"shake_256",
"sm3",
"streebog_256",
"streebog_512",
]);
/**
* Cache normalized SPDX hash algorithm names across conversions.
*
* This module-level cache intentionally lives for the process lifetime so
* repeated convertCycloneDxToSpdx() calls avoid repeated normalization work.
*/
const normalizedHashAlgorithmCache = new Map();
const toArray = (value) => {
if (Array.isArray(value)) {
return value;
}
if (value) {
return [value];
}
return [];
};
const normalizeHashAlgorithm = (algorithm) => {
const cacheKey = `${algorithm || ""}`;
if (normalizedHashAlgorithmCache.has(cacheKey)) {
return normalizedHashAlgorithmCache.get(cacheKey);
}
const normalized = `${algorithm || ""}`
.trim()
.toLowerCase()
.replace(/-/gu, "")
.replace(/\//gu, "_");
const normalizedAlgorithm = SPDX_HASH_ALGORITHMS.has(normalized)
? normalized
: undefined;
normalizedHashAlgorithmCache.set(cacheKey, normalizedAlgorithm);
return normalizedAlgorithm;
};
const toSerializableValue = (value) => {
if (value === undefined) {
return undefined;
}
if (value === null) {
return null;
}
if (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
) {
return value;
}
if (Array.isArray(value)) {
const output = [];
for (const item of value) {
const mappedValue = toSerializableValue(item);
if (mappedValue !== undefined) {
output.push(mappedValue);
}
}
return output;
}
if (typeof value === "object") {
const output = {};
for (const [key, item] of Object.entries(value)) {
const mappedValue = toSerializableValue(item);
if (mappedValue !== undefined) {
output[key] = mappedValue;
}
}
return output;
}
return `${value}`;
};
const addIfDefined = (obj, key, value) => {
if (value !== undefined && value !== null) {
obj[key] = value;
}
};
const hasEntries = (value) => {
if (!value) {
return false;
}
if (Array.isArray(value)) {
return value.length > 0;
}
if (typeof value === "object") {
return Object.keys(value).length > 0;
}
return true;
};
const isSimpleValue = (value) =>
value === null ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean";
const stringifyExtensionPropertyValue = (value) =>
typeof value === "string"
? value
: JSON.stringify(toSerializableValue(value));
const encodeSpdxFragment = (value) =>
`${value || "unknown"}`
.replace(/[^A-Za-z0-9._-]+/gu, "-")
.replace(/^-+/u, "")
.replace(/-+$/u, "") || "unknown";
const createNamespace = (bomJson) => {
const serial = `${bomJson?.serialNumber || ""}`.replace(/^urn:uuid:/u, "");
const componentName = `${bomJson?.metadata?.component?.name || ""}`.trim();
const serialFragment = serial ? encodeSpdxFragment(serial) : "";
const componentNameFragment = componentName
? encodeSpdxFragment(componentName)
: "";
const base = serialFragment || componentNameFragment || `${Date.now()}`;
return `urn:cdxgen:spdx:${base}#`;
};
const buildElementKey = (component) =>
component?.["bom-ref"] ||
component?.purl ||
`${component?.name || "component"}@${component?.version || "0"}`;
const buildSpdxId = (namespace, prefix, value) =>
`${namespace}${prefix}-${encodeSpdxFragment(value)}`;
const selectRootComponent = (bomJson) => {
if (bomJson?.metadata?.component) {
return bomJson.metadata.component;
}
return bomJson?.components?.[0];
};
const toSpdxHashes = (hashInput) => {
const hashes = [];
const originalHashes = [];
for (const hash of toArray(hashInput)) {
if (!hash?.content) {
continue;
}
const algorithm = normalizeHashAlgorithm(hash?.alg);
const originalHash = {
algorithm: hash?.alg || "unknown",
hashValue: hash.content,
};
if (algorithm) {
originalHash.normalizedAlgorithm = algorithm;
}
originalHashes.push(originalHash);
if (!algorithm) {
continue;
}
hashes.push({
type: "Hash",
algorithm,
hashValue: hash.content,
});
}
return { hashes, originalHashes };
};
const toPropertyList = (propertyInput) => {
const properties = [];
for (const property of toArray(propertyInput)) {
if (!property?.name) {
continue;
}
properties.push({
name: `${property.name}`,
value: `${property.value ?? ""}`,
});
}
return properties;
};
const toReferenceList = (referenceInput) => {
const references = [];
for (const reference of toArray(referenceInput)) {
if (!reference?.url) {
continue;
}
const serializedReference = {
type: `${reference?.type || "other"}`,
url: reference.url,
};
const refHashes = toSpdxHashes(reference?.hashes);
if (reference?.comment) {
serializedReference.comment = reference.comment;
}
if (refHashes.hashes.length) {
serializedReference.verifiedUsing = refHashes.hashes;
}
if (refHashes.originalHashes.length) {
serializedReference.originalHashes = refHashes.originalHashes;
}
references.push(serializedReference);
}
return references;
};
const toSpdxExternalRefList = (referenceInput) => {
const references = [];
for (const reference of toArray(referenceInput)) {
if (!reference?.url) {
continue;
}
const spdxReference = {
type: "ExternalRef",
externalRefType: SPDX_EXTERNAL_REF_TYPE_MAP[reference?.type] || "other",
locator: [reference.url],
};
if (reference?.comment) {
spdxReference.comment = reference.comment;
}
references.push(spdxReference);
}
return references;
};
const toSpdxExternalReferences = (component) => {
const references = toSpdxExternalRefList(component?.externalReferences);
const homepageReference = references.find((reference) =>
[
"altWebPage",
"documentation",
"altDownloadLocation",
"releaseNotes",
].includes(reference?.externalRefType),
);
const downloadReference = references.find(
(reference) => reference?.externalRefType === "altDownloadLocation",
);
return {
references,
homepageReference,
downloadReference,
};
};
const buildCycloneDxExtensionData = (component, additionalData = {}) => {
const extensionData = {};
const properties = toPropertyList(component?.properties);
const externalReferences = toReferenceList(component?.externalReferences);
const hashMappings = toSpdxHashes(component?.hashes);
addIfDefined(extensionData, "bomRef", component?.["bom-ref"]);
addIfDefined(extensionData, "group", component?.group);
addIfDefined(extensionData, "scope", component?.scope);
if (properties.length) {
extensionData.properties = properties;
}
if (externalReferences.length) {
extensionData.externalReferences = externalReferences;
}
if (hashMappings.originalHashes.length) {
extensionData.hashes = hashMappings.originalHashes;
}
addIfDefined(
extensionData,
"licenses",
toSerializableValue(component?.licenses),
);
addIfDefined(
extensionData,
"supplier",
toSerializableValue(component?.supplier),
);
addIfDefined(
extensionData,
"manufacturer",
toSerializableValue(component?.manufacturer),
);
addIfDefined(extensionData, "author", toSerializableValue(component?.author));
addIfDefined(
extensionData,
"authors",
toSerializableValue(component?.authors),
);
addIfDefined(
extensionData,
"publisher",
toSerializableValue(component?.publisher),
);
addIfDefined(
extensionData,
"maintainer",
toSerializableValue(component?.maintainer),
);
addIfDefined(
extensionData,
"maintainers",
toSerializableValue(component?.maintainers),
);
addIfDefined(extensionData, "tags", toSerializableValue(component?.tags));
addIfDefined(
extensionData,
"releaseNotes",
toSerializableValue(component?.releaseNotes),
);
addIfDefined(
extensionData,
"evidence",
toSerializableValue(component?.evidence),
);
addIfDefined(
extensionData,
"pedigree",
toSerializableValue(component?.pedigree),
);
addIfDefined(extensionData, "cpe", toSerializableValue(component?.cpe));
addIfDefined(extensionData, "swid", toSerializableValue(component?.swid));
addIfDefined(
extensionData,
"omniborId",
toSerializableValue(component?.omniborId),
);
addIfDefined(extensionData, "swhid", toSerializableValue(component?.swhid));
for (const [key, value] of Object.entries(additionalData)) {
addIfDefined(extensionData, key, toSerializableValue(value));
}
return hasEntries(extensionData) ? extensionData : undefined;
};
const maybeAppendExtensionPropertyEntries = (propertyEntries, key, value) => {
if (value === undefined || value === null) {
return;
}
if (
Array.isArray(value) &&
value.every((entry) => entry?.name && isSimpleValue(entry?.value))
) {
for (const entry of value) {
propertyEntries.push({
type: SPDX_CDX_PROPERTY_ENTRY_TYPE,
[SPDX_CDX_PROPERTY_NAME_KEY]: `${key}.${entry.name}`,
[SPDX_CDX_PROPERTY_VALUE_KEY]: `${entry.value}`,
});
}
return;
}
propertyEntries.push({
type: SPDX_CDX_PROPERTY_ENTRY_TYPE,
[SPDX_CDX_PROPERTY_NAME_KEY]: key,
[SPDX_CDX_PROPERTY_VALUE_KEY]: stringifyExtensionPropertyValue(value),
});
};
const toSpdxExtensions = (extensionData) => {
if (!hasEntries(extensionData)) {
return undefined;
}
const propertyEntries = [];
for (const [key, value] of Object.entries(extensionData)) {
maybeAppendExtensionPropertyEntries(propertyEntries, key, value);
}
if (!propertyEntries.length) {
return undefined;
}
return [
{
type: SPDX_CDX_PROPERTIES_EXTENSION_TYPE,
[SPDX_CDX_PROPERTY_KEY]: propertyEntries,
},
];
};
const createSyntheticElement = (
namespace,
source,
entryType,
index,
formulationIndex,
) => {
const name =
source?.name || source?.["bom-ref"] || `${entryType}-${index + 1}`;
const bomRef =
source?.["bom-ref"] ||
`urn:cdxgen:${entryType}:${formulationIndex ?? "root"}:${index}`;
const synthetic = {
type: "library",
name,
version: source?.version,
description: source?.description,
"bom-ref": bomRef,
properties: source?.properties,
externalReferences: source?.externalReferences,
hashes: source?.hashes,
cdxgenSyntheticSource: {
entryType,
source: toSerializableValue(source),
},
};
const syntheticKey = buildElementKey(synthetic);
const syntheticSpdxId = buildSpdxId(
namespace,
"SPDXRef",
`${entryType}-${syntheticKey}`,
);
return { synthetic, syntheticSpdxId };
};
const collectSyntheticComponents = (bomJson, namespace) => {
const syntheticComponents = [];
const syntheticRefs = [];
for (const [index, service] of toArray(bomJson?.services).entries()) {
const syntheticEntry = createSyntheticElement(
namespace,
service,
"service",
index,
);
syntheticComponents.push(syntheticEntry.synthetic);
syntheticRefs.push(syntheticEntry.syntheticSpdxId);
}
for (const [formulationIndex, formulation] of toArray(
bomJson?.formulation,
).entries()) {
for (const [serviceIndex, service] of toArray(
formulation?.services,
).entries()) {
const syntheticEntry = createSyntheticElement(
namespace,
service,
"formulation-service",
serviceIndex,
formulationIndex,
);
syntheticComponents.push(syntheticEntry.synthetic);
syntheticRefs.push(syntheticEntry.syntheticSpdxId);
}
for (const [workflowIndex, workflow] of toArray(
formulation?.workflows,
).entries()) {
const workflowEntry = createSyntheticElement(
namespace,
workflow,
"workflow",
workflowIndex,
formulationIndex,
);
syntheticComponents.push(workflowEntry.synthetic);
syntheticRefs.push(workflowEntry.syntheticSpdxId);
for (const [taskIndex, task] of toArray(workflow?.tasks).entries()) {
const taskEntry = createSyntheticElement(
namespace,
task,
"task",
taskIndex,
`${formulationIndex}-${workflowIndex}`,
);
syntheticComponents.push(taskEntry.synthetic);
syntheticRefs.push(taskEntry.syntheticSpdxId);
}
}
for (const [componentIndex, component] of toArray(
formulation?.components,
).entries()) {
const syntheticEntry = createSyntheticElement(
namespace,
component,
"formulation-component",
componentIndex,
formulationIndex,
);
syntheticComponents.push(syntheticEntry.synthetic);
syntheticRefs.push(syntheticEntry.syntheticSpdxId);
}
}
return { syntheticComponents, syntheticRefs };
};
const buildDocumentExtensionData = (bomJson) => {
const documentExtension = {};
const metadataProperties = toPropertyList(bomJson?.metadata?.properties);
const bomProperties = toPropertyList(bomJson?.properties);
if (metadataProperties.length) {
documentExtension.metadataProperties = metadataProperties;
}
if (bomProperties.length) {
documentExtension.bomProperties = bomProperties;
}
addIfDefined(
documentExtension,
"metadataTools",
toSerializableValue(bomJson?.metadata?.tools),
);
addIfDefined(
documentExtension,
"metadataAuthors",
toSerializableValue(bomJson?.metadata?.authors),
);
addIfDefined(
documentExtension,
"metadataAuthor",
toSerializableValue(bomJson?.metadata?.author),
);
addIfDefined(
documentExtension,
"metadataPublisher",
toSerializableValue(bomJson?.metadata?.publisher),
);
addIfDefined(
documentExtension,
"metadataMaintainer",
toSerializableValue(bomJson?.metadata?.maintainer),
);
addIfDefined(
documentExtension,
"metadataMaintainers",
toSerializableValue(bomJson?.metadata?.maintainers),
);
addIfDefined(
documentExtension,
"metadataTags",
toSerializableValue(bomJson?.metadata?.tags),
);
addIfDefined(
documentExtension,
"metadataSupplier",
toSerializableValue(bomJson?.metadata?.supplier),
);
addIfDefined(
documentExtension,
"metadataManufacturer",
toSerializableValue(bomJson?.metadata?.manufacturer),
);
addIfDefined(
documentExtension,
"metadataLicenses",
toSerializableValue(bomJson?.metadata?.licenses),
);
addIfDefined(
documentExtension,
"services",
toSerializableValue(bomJson?.services),
);
addIfDefined(
documentExtension,
"formulation",
toSerializableValue(bomJson?.formulation),
);
return hasEntries(documentExtension) ? documentExtension : undefined;
};
const toSpdxPackage = (component, creationInfoId, spdxId) => {
const spdxPackage = {
type: component?.type === "file" ? "software_File" : "software_Package",
spdxId,
creationInfo: creationInfoId,
name: component?.name || "unnamed-component",
};
if (component?.description) {
spdxPackage.description = component.description;
}
if (component?.version && component?.type !== "file") {
spdxPackage.software_packageVersion = component.version;
}
if (component?.purl && component?.type !== "file") {
spdxPackage.software_packageUrl = component.purl;
}
const hashMappings = toSpdxHashes(component?.hashes);
if (hashMappings.hashes.length) {
spdxPackage.verifiedUsing = hashMappings.hashes;
}
const externalReferenceData = toSpdxExternalReferences(component);
if (
externalReferenceData.homepageReference?.locator?.[0] &&
component?.type !== "file"
) {
spdxPackage.software_homePage =
externalReferenceData.homepageReference.locator[0];
}
if (
externalReferenceData.downloadReference?.locator?.[0] &&
component?.type !== "file"
) {
spdxPackage.software_downloadLocation =
externalReferenceData.downloadReference.locator[0];
}
if (externalReferenceData.references.length) {
spdxPackage.externalRef = externalReferenceData.references;
}
const additionalExtensionData = {};
if (component?.cdxgenSyntheticSource) {
additionalExtensionData.syntheticSource = component.cdxgenSyntheticSource;
}
const extensionData = buildCycloneDxExtensionData(
component,
additionalExtensionData,
);
const extensions = toSpdxExtensions(extensionData);
if (extensions) {
spdxPackage[SPDX_EXTENSION_KEY] = extensions;
}
return spdxPackage;
};
const buildRelationship = (creationInfoId, from, to, relationshipId) => ({
type: "Relationship",
spdxId: relationshipId,
creationInfo: creationInfoId,
from,
to,
relationshipType: SPDX_RELATIONSHIP_DEPENDS_ON,
});
/**
* Convert a CycloneDX BOM JSON document into an SPDX 3.0.1 JSON-LD document.
*
* @param {object|string} bomJson CycloneDX BOM JSON
* @param {object} [options] CLI options
* @returns {object|undefined} SPDX 3.0.1 JSON-LD document
*/
export function convertCycloneDxToSpdx(bomJson, options = {}) {
if (!bomJson) {
return undefined;
}
if (typeof bomJson === "string" || bomJson instanceof String) {
bomJson = JSON.parse(bomJson);
}
const namespace = createNamespace(bomJson);
const creationInfoId = buildSpdxId(namespace, "CreationInfo", "main");
const createdBy = [
CDXGEN_SPDX_CREATED_BY || "https://github.com/cdxgen/cdxgen",
];
const creationInfo = {
type: "CreationInfo",
"@id": creationInfoId,
specVersion: SPDX_SPEC_VERSION,
created: bomJson?.metadata?.timestamp || getTimestamp(),
createdBy,
};
const rootComponent = selectRootComponent(bomJson);
const syntheticComponentData = collectSyntheticComponents(bomJson, namespace);
const allComponents = [];
if (rootComponent) {
allComponents.push(rootComponent);
}
for (const component of toArray(bomJson?.components)) {
allComponents.push(component);
}
for (const syntheticComponent of syntheticComponentData.syntheticComponents) {
allComponents.push(syntheticComponent);
}
const dedupedComponents = new Map();
const refToSpdxId = new Map();
const graphElements = [];
for (const component of allComponents) {
const elementKey = buildElementKey(component);
if (dedupedComponents.has(elementKey)) {
continue;
}
const spdxId = buildSpdxId(namespace, "SPDXRef", elementKey);
dedupedComponents.set(elementKey, component);
refToSpdxId.set(elementKey, spdxId);
graphElements.push(toSpdxPackage(component, creationInfoId, spdxId));
}
const relationshipElements = [];
let relationshipIndex = 0;
for (const dependency of toArray(bomJson?.dependencies)) {
const sourceSpdxId = refToSpdxId.get(dependency?.ref);
if (
!sourceSpdxId ||
!Array.isArray(dependency?.dependsOn) ||
!dependency.dependsOn.length
) {
continue;
}
const toIds = dependency.dependsOn
.map((dependsOn) => refToSpdxId.get(dependsOn))
.filter(Boolean);
if (!toIds.length) {
continue;
}
relationshipIndex += 1;
relationshipElements.push(
buildRelationship(
creationInfoId,
sourceSpdxId,
toIds,
buildSpdxId(
namespace,
"Relationship",
`${dependency.ref}-${relationshipIndex}`,
),
),
);
}
const rootElementId = rootComponent
? refToSpdxId.get(buildElementKey(rootComponent))
: undefined;
const documentId = buildSpdxId(namespace, "SPDXRef", "DOCUMENT");
const spdxDocument = {
type: "SpdxDocument",
spdxId: documentId,
creationInfo: creationInfoId,
name:
options?.projectName ||
bomJson?.metadata?.component?.name ||
bomJson?.metadata?.component?.["bom-ref"] ||
"cdxgen SPDX export",
profileConformance: [...SPDX_DOCUMENT_PROFILES],
element: [
...graphElements.map((element) => element.spdxId),
...relationshipElements.map((element) => element.spdxId),
],
};
if (rootElementId) {
spdxDocument.rootElement = [rootElementId];
}
if (bomJson?.metadata?.component?.description) {
spdxDocument.description = bomJson.metadata.component.description;
}
const documentExtensionData = buildDocumentExtensionData(bomJson);
const documentExtensions = toSpdxExtensions(documentExtensionData);
if (documentExtensions) {
spdxDocument[SPDX_EXTENSION_KEY] = documentExtensions;
}
if (
documentExtensions ||
graphElements.some((element) =>
Array.isArray(element?.[SPDX_EXTENSION_KEY]),
)
) {
spdxDocument.profileConformance.push(SPDX_EXTENSION_PROFILE);
}
return {
"@context": SPDX_JSONLD_CONTEXT,
"@graph": [
creationInfo,
spdxDocument,
...graphElements,
...relationshipElements,
],
};
}