@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
403 lines (393 loc) • 13.8 kB
JavaScript
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { thoughtLog } from "../../helpers/logger.js";
import { dirNameStr } from "../../helpers/utils.js";
// Tags per BOM type.
const componentTags = JSON.parse(
readFileSync(join(dirNameStr, "data", "component-tags.json"), "utf-8"),
);
function humanifyTimestamp(timestamp) {
const dateObj = new Date(Date.parse(timestamp));
return dateObj.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
});
}
function toArticle(s) {
return /^[aeiou]/i.test(s) ? "an" : "a";
}
function joinArray(arr) {
if (!Array.isArray(arr)) {
return arr;
}
if (arr.length <= 1) {
return arr.join(", ");
}
const last = arr.pop();
return `${arr.join(", ")}${arr.length > 1 ? "," : ""} and ${last}`;
}
function cleanNames(s) {
return s?.replace(/[+]/g, " ");
}
function cleanTypes(s) {
return s?.replace(/[+-_]/g, " ");
}
/**
* Method to determine the type of the BOM.
*
* @param {Object} bomJson BOM JSON Object
*
* @returns {String} Type of the bom such as sbom, cbom, obom, ml-bom etc
*/
export function findBomType(bomJson) {
let description = "Software Bill-of-Materials (SBOM)";
let bomType = "SBOM";
const metadata = bomJson.metadata;
const lifecycles = metadata?.lifecycles || [];
const cryptoAssetsCount = bomJson?.components?.filter(
(c) => c.type === "cryptographic-asset",
).length;
const dataCount = bomJson?.components?.filter(
(c) =>
c?.data?.length > 0 ||
(c.modelCard && Object.keys(c?.modelCard).length > 0),
).length;
// Is this an OBOM?
if (lifecycles.filter((l) => l.phase === "operations").length > 0) {
bomType = "OBOM";
description = "Operations Bill-of-Materials (OBOM)";
} else if (cryptoAssetsCount > 0) {
bomType = "CBOM";
description = "Cryptography Bill-of-Materials (CBOM)";
} else if (dataCount > 0) {
bomType = "ML-BOM";
description = "Machine-Learning Bill-of-Materials (ML-BOM)";
} else if (bomJson?.services?.length > 0) {
bomType = "SaaSBOM";
description = "Software-as-a-Service BOM (SaaSBOM)";
} else if (bomJson.declarations?.attestations?.length > 0) {
bomType = "CDXA";
description = "CycloneDX Attestations (CDXA)";
}
return {
bomType,
bomTypeDescription: description,
};
}
/**
* Create the textual representation of the metadata section.
*
* @param {Object} bomJson BOM JSON Object
*
* @returns {String | undefined} Textual representation of the metadata
*/
export function textualMetadata(bomJson) {
if (!bomJson?.metadata) {
return undefined;
}
let text = "";
const { bomType, bomTypeDescription } = findBomType(bomJson);
const metadata = bomJson.metadata;
const lifecycles = metadata?.lifecycles || [];
const cryptoAssetsCount = bomJson?.components?.filter(
(c) => c.type === "cryptographic-asset",
).length;
const vsixCount = bomJson?.components?.filter((c) =>
c?.purl?.startsWith("pkg:vsix"),
).length;
const swidCount = bomJson?.components?.filter((c) =>
c?.purl?.startsWith("pkg:swid"),
).length;
if (metadata?.timestamp) {
text = `This ${bomTypeDescription} document was created on ${humanifyTimestamp(metadata.timestamp)}`;
}
if (metadata?.tools) {
const tools = metadata.tools.components;
// Only components would be supported. If you need support for services, send a PR!
if (tools && Array.isArray(tools)) {
if (tools.length === 1) {
text = `${text} with ${tools[0].name}.`;
} else {
text = `${text}. The xBOM tools used are: ${joinArray(tools.map((t) => t.name))}.`;
}
}
}
if (lifecycles && Array.isArray(lifecycles)) {
if (lifecycles.length === 1) {
const thePhase = lifecycles[0].phase;
if (thePhase === "pre-build") {
text = `${text} The data was captured during the ${thePhase} lifecycle phase without building the application.`;
} else {
text = `${text} The data was captured during the ${thePhase} lifecycle phase.`;
}
} else {
text = `${text} The lifecycles phases represented are: ${joinArray(lifecycles.map((l) => l.phase))}.`;
}
}
if (metadata?.component) {
const parentVersion = metadata.component.version;
const cleanTypeName = cleanTypes(metadata.component.type);
if (
parentVersion &&
!["", "unspecified", "latest", "master", "main"].includes(parentVersion)
) {
let versionType = "version";
if (parentVersion.includes(" ") || parentVersion.includes("(")) {
versionType = "the build name";
} else if (
parentVersion.toLowerCase().includes("dev") ||
parentVersion.toLowerCase().includes("snapshot")
) {
versionType = "the dev version";
} else if (
parentVersion.toLowerCase().includes("release") ||
parentVersion.toLowerCase().includes("final")
) {
versionType = "the release version";
}
text = `${text} The document describes ${toArticle(metadata.component.type)} ${cleanTypeName} named '${cleanNames(metadata.component.name)}' with ${versionType} '${parentVersion}'.`;
} else {
text = `${text} The document describes ${toArticle(metadata.component.type)} ${cleanTypeName} named '${cleanNames(metadata.component.name)}'.`;
}
if (cryptoAssetsCount) {
text = `${text} There are ${cryptoAssetsCount} cryptographic assets listed under components in this ${bomType}.`;
}
if (
metadata?.component.components &&
Array.isArray(metadata.component?.components)
) {
text = `${text} The ${cleanTypeName} also has ${metadata.component.components.length} child modules/components.`;
}
}
let metadataProperties = metadata.properties || [];
if (
metadata?.component?.properties &&
Array.isArray(metadata.component.properties)
) {
metadataProperties = metadataProperties.concat(
metadata.component.properties,
);
}
let bomPkgTypes = [];
let bomPkgNamespaces = [];
let componentSrcFiles = [];
let imageRepoTag;
let imageArch;
let imageOs;
let imageComponentTypes;
let osBuildVersion;
const bundledSdks = [];
let appLanguage;
for (const aprop of metadataProperties) {
switch (aprop.name) {
case "cdx:bom:componentTypes":
bomPkgTypes = aprop?.value.split("\\n");
break;
case "cdx:bom:componentNamespaces":
bomPkgNamespaces = aprop?.value.split("\\n");
break;
case "cdx:bom:componentSrcFiles":
componentSrcFiles = aprop?.value.split("\\n");
break;
case "oci:image:RepoTag":
imageRepoTag = aprop.value;
break;
case "arch":
case "oci:image:Architecture":
imageArch = aprop.value;
break;
case "oci:image:Os":
imageOs = aprop.value;
break;
case "oci:image:componentTypes":
imageComponentTypes = aprop.value.split("\\n");
break;
case "build_version":
osBuildVersion = aprop.value;
break;
case "oci:image:bundles:AndroidSdk":
case "oci:image:bundles:Sdkman":
case "oci:image:bundles:Nvm":
case "oci:image:bundles:Rbenv":
case "oci:image:bundles:DotnetSdk":
bundledSdks.push(
aprop.name.split(":").pop().replace(/Sdk$/, "").toLowerCase(),
);
break;
case "oci:image:appLanguage":
appLanguage = aprop.value;
break;
default:
break;
}
}
if (bomJson?.components?.length) {
text = `${text} There are ${bomJson.components.length} components.`;
} else {
text = `${text} BOM file is empty without components.`;
thoughtLog(
"It looks like I didn't find any components, so the BOM is empty.",
);
if (bomJson?.dependencies?.length) {
thoughtLog(
`There are ${bomJson.dependencies.length} dependencies and no components; this is confusing 😵💫.`,
);
} else if (
metadata?.component?.components &&
Array.isArray(metadata.component?.components) &&
metadata?.component.components.length > 1
) {
thoughtLog(
`I did find ${metadata.component.components.length} child modules, so I'm confident things will work with some troubleshooting.`,
);
}
}
if (appLanguage) {
text = `${text} This container image is for a ${appLanguage} application.`;
}
if (imageOs && imageArch && imageRepoTag) {
text = `${text} The ${imageOs} image uses the ${imageArch} architecture and has the registry tag ${imageRepoTag}.`;
}
if (imageArch && osBuildVersion) {
text = `${text} The OS uses the ${imageArch} architecture and has the build version '${osBuildVersion}'.`;
}
if (imageComponentTypes && imageComponentTypes.length > 0) {
text = `${text} The OS components are of types ${joinArray(imageComponentTypes)}.`;
}
if (bundledSdks.length) {
text = `${text} Furthermore, the container image bundles the following SDKs: ${bundledSdks.join(", ")}`;
}
if (bomPkgTypes.length && bomPkgNamespaces.length) {
if (bomPkgTypes.length === 1) {
if (bomPkgNamespaces.length === 1) {
text = `${text} The package type in this ${bomType} is ${joinArray(bomPkgTypes)} with a single purl namespace '${bomPkgNamespaces.join(", ")}' described under components.`;
} else {
text = `${text} The package type in this ${bomType} is ${joinArray(bomPkgTypes)} with ${bomPkgNamespaces.length} purl namespaces described under components.`;
}
if (componentSrcFiles.length) {
if (componentSrcFiles.length <= 2) {
text = `${text} The components were identified from the source files: ${componentSrcFiles.join(", ")}.`;
} else {
text = `${text} The components were identified from ${componentSrcFiles.length} source files.`;
}
}
} else {
text = `${text} ${bomPkgTypes.length} package type(s) and ${bomPkgNamespaces.length} purl namespaces are described in the document under components.`;
}
}
if (bomType === "OBOM") {
if (vsixCount > 0) {
text = `${text} The system appears to be set up for remote development, with ${vsixCount} Visual Studio Code extensions installed.`;
}
if (swidCount > 0) {
text = `${text} In addition, there are ${swidCount} applications installed on the system.`;
}
}
if (bomType === "SaaSBOM") {
text = `${text} ${bomJson.services.length} are described in this ${bomType} under services.`;
}
if (bomType === "CDXA") {
text = `${text} ${bomJson.declarations.attestations.length} attestations are found under declarations.`;
}
if (bomJson?.formulation?.length > 0) {
text = `${text} Further, there is a formulation section with components, workflows and steps for reproducibility.`;
}
thoughtLog(`Let me summarize this xBOM:\n${text}`);
return text;
}
/**
* Extract interesting tags from the component attribute
*
* @param {Object} component CycloneDX component
* @param {String} bomType BOM type
* @param {String} parentComponentType Parent component type
*
* @returns {Array | undefined} Array of string tags
*/
export function extractTags(
component,
bomType = "all",
parentComponentType = "application",
) {
if (
!component ||
(!component.description && !component.properties && !component.name)
) {
return undefined;
}
bomType = bomType?.toLowerCase();
const tags = new Set();
if (
component.type &&
!["library", "application", "file"].includes(component.type)
) {
tags.add(component.type);
}
(component?.tags || []).forEach((tag) => {
if (tag.length) {
tags.add(tag);
}
});
const desc = component?.description?.toLowerCase();
const compProps = component.properties || [];
// Collect both the BOM specific tags and all tags
let compNameTags = (componentTags.name[bomType] || []).concat(
componentTags.name.all || [],
);
// For SBOMs with a container component as parent, utilize the tags
// from OBOM
if (bomType === "sbom" && parentComponentType === "container") {
compNameTags = compNameTags.concat(componentTags.name.obom || []);
}
const compDescTags = (componentTags.description[bomType] || []).concat(
componentTags.description.all || [],
);
const compPropsTags = (componentTags.properties[bomType] || []).concat(
componentTags.properties.all || [],
);
if (component?.name) {
// {"devel": ["/-(dev|devel|headers)$/"]}
for (const anameTagObject of compNameTags) {
for (const compCategoryTag of Object.keys(anameTagObject)) {
for (const catRegexStr of anameTagObject[compCategoryTag]) {
// Regex-based search on the name
if (new RegExp(catRegexStr, "ig").test(component.name)) {
tags.add(compCategoryTag);
}
}
}
}
}
// Identify tags from description
if (desc) {
for (const adescTag of compDescTags) {
if (desc.includes(` ${adescTag} `) || desc.includes(` ${adescTag}.`)) {
tags.add(adescTag);
}
const stemmedTag = adescTag.replace(/(ion|ed|er|en|ing)$/, "");
const stemmedDesc = adescTag.replace(/(ion|ed|er|en|ing) $/, " ");
if (
stemmedDesc.includes(` ${stemmedTag} `) ||
stemmedDesc.includes(` ${stemmedTag}.`)
) {
tags.add(adescTag);
}
}
}
// Identify tags from properties as a fallback
if (!tags.size) {
for (const adescTag of compPropsTags) {
for (const aprop of compProps) {
if (
aprop.name !== "SrcFile" &&
aprop?.value?.toLowerCase().includes(adescTag)
) {
tags.add(adescTag);
}
}
}
}
return Array.from(tags).sort();
}