@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
733 lines (718 loc) • 25.8 kB
JavaScript
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
formatHbomHardwareClassSummary,
getHbomSummary,
isHbomLikeBom,
} from "../../helpers/hbomAnalysis.js";
import { getContainerFileInventoryStats } from "../../helpers/inventoryStats.js";
import { thoughtLog } from "../../helpers/logger.js";
import { getTrustedPublishingComponentCounts } from "../../helpers/provenanceUtils.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",
timeZone: "UTC",
});
}
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, " ");
}
/**
* Count GitHub workflow components and extract security-relevant stats
*
* @param {Array} components BOM components array
*
* @returns {Object} Statistics about GitHub workflow components
*/
function getGitHubWorkflowStats(components) {
const stats = {
totalActions: 0,
officialActions: 0,
verifiedActions: 0,
shaPinned: 0,
tagPinned: 0,
branchPinned: 0,
unknownPinned: 0,
workflowCount: 0,
jobCount: 0,
hasWritePermissions: 0,
hasIdTokenWrite: 0,
continueOnError: 0,
workflows: new Set(),
jobs: new Set(),
runners: new Set(),
environments: new Set(),
};
if (!components || !Array.isArray(components)) {
return stats;
}
for (const comp of components) {
if (comp?.scope === "excluded") {
continue;
}
if (comp?.purl?.startsWith("pkg:github/")) {
stats.totalActions++;
const props = comp.properties || [];
const propMap = {};
for (const prop of props) {
propMap[prop.name] = prop.value;
}
if (propMap["cdx:github:workflow:name"]) {
stats.workflows.add(propMap["cdx:github:workflow:name"]);
}
if (propMap["cdx:github:job:name"]) {
stats.jobs.add(propMap["cdx:github:job:name"]);
}
if (propMap["cdx:actions:isOfficial"] === "true") {
stats.officialActions++;
}
if (propMap["cdx:actions:isVerified"] === "true") {
stats.verifiedActions++;
}
const pinningType = propMap["cdx:github:action:versionPinningType"];
if (pinningType === "sha") {
stats.shaPinned++;
} else if (pinningType === "tag") {
stats.tagPinned++;
} else if (pinningType === "branch") {
stats.branchPinned++;
} else {
stats.unknownPinned++;
}
if (propMap["cdx:github:workflow:hasWritePermissions"] === "true") {
stats.hasWritePermissions++;
}
if (propMap["cdx:github:workflow:hasIdTokenWrite"] === "true") {
stats.hasIdTokenWrite++;
}
if (propMap["cdx:github:step:continueOnError"] === "true") {
stats.continueOnError++;
}
if (propMap["cdx:github:job:runner"]) {
String(propMap["cdx:github:job:runner"])
.split(",")
.filter((r) => r.includes("$"))
.forEach((r) => {
stats.runners.add(r.trim());
});
}
if (propMap["cdx:github:job:environment"]) {
stats.environments.add(propMap["cdx:github:job:environment"]);
}
}
}
stats.workflowCount = stats.workflows.size;
stats.jobCount = stats.jobs.size;
stats.workflows = Array.from(stats.workflows);
stats.jobs = Array.from(stats.jobs);
stats.runners = Array.from(stats.runners);
stats.environments = Array.from(stats.environments);
return stats;
}
/**
* Generate security assessment text based on GitHub workflow properties
*
* @param {Object} stats GitHub workflow statistics
*
* @returns {String} Security assessment text
*/
function generateSecurityAssessment(stats) {
let text = "";
const securityIssues = [];
const securityStrengths = [];
if (stats.branchPinned > 0) {
securityIssues.push(
`${stats.branchPinned} action(s) use branch references instead of pinned versions, which may introduce supply chain risks`,
);
}
if (stats.unknownPinned > 0) {
securityIssues.push(
`${stats.unknownPinned} action(s) have unknown version pinning types`,
);
}
if (stats.shaPinned > 0) {
securityStrengths.push(
`${stats.shaPinned} action(s) are pinned to specific commit SHAs for maximum security`,
);
}
if (stats.tagPinned > 0) {
securityStrengths.push(
`${stats.tagPinned} action(s) are pinned to version tags`,
);
}
if (stats.hasWritePermissions > 0) {
securityIssues.push(
`${stats.hasWritePermissions} workflow(s) have write permissions to repository resources`,
);
}
if (stats.hasIdTokenWrite > 0) {
securityIssues.push(
`${stats.hasIdTokenWrite} workflow(s) have id-token write access, enabling OIDC authentication`,
);
}
if (stats.officialActions > 0) {
securityStrengths.push(
`${stats.officialActions} action(s) are official GitHub Actions from github.com org`,
);
}
if (stats.verifiedActions > 0) {
securityStrengths.push(
`${stats.verifiedActions} action(s) are from verified creators`,
);
}
if (stats.continueOnError > 0) {
securityIssues.push(
`${stats.continueOnError} step(s) continue on error, which may mask failures`,
);
}
if (securityStrengths.length > 0) {
text = `${text} Security strengths: ${joinArray(securityStrengths)}.`;
}
if (securityIssues.length > 0) {
text = `${text} Security considerations: ${joinArray(securityIssues)}.`;
}
const totalActions = stats.totalActions || 1;
const securePinned = stats.shaPinned + stats.tagPinned;
const pinningScore = (securePinned / totalActions) * 100;
if (pinningScore >= 80) {
text = `${text} Overall, the workflow demonstrates good version pinning practices.`;
} else if (pinningScore >= 50) {
text = `${text} Overall, the workflow has moderate version pinning practices with room for improvement.`;
} else {
text = `${text} Overall, the workflow would benefit from improved version pinning practices.`;
}
return text;
}
/**
* 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;
const githubActionCount = bomJson?.components?.filter((c) =>
c?.purl?.startsWith("pkg:github/"),
).length;
const hasWorkflowProperties = bomJson?.components?.some((c) =>
c?.properties?.some(
(p) =>
p.name?.startsWith("cdx:github:") || p.name?.startsWith("cdx:actions:"),
),
);
// Is this a GitHub Workflow BOM?
if (githubActionCount > 0 && hasWorkflowProperties) {
bomType = "SBOM";
description = "Software Bill-of-Materials (SBOM) including GitHub Actions";
}
// Is this an HBOM?
else if (isHbomLikeBom(bomJson)) {
bomType = "HBOM";
description = "Hardware Bill-of-Materials (HBOM)";
}
// Is this an OBOM?
else 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 tlpClassification =
metadata.distributionConstraints?.tlp || metadata.distribution;
const cryptoAssetsCount = bomJson?.components?.filter(
(c) => c.type === "cryptographic-asset",
).length;
const vsixCount = bomJson?.components?.filter((c) =>
c?.purl?.startsWith("pkg:vscode-extension"),
).length;
const swidCount = bomJson?.components?.filter((c) =>
c?.purl?.startsWith("pkg:swid"),
).length;
const { unpackagedExecutableCount, unpackagedSharedLibraryCount } =
getContainerFileInventoryStats(bomJson?.components);
const githubStats = getGitHubWorkflowStats(bomJson?.components);
const hbomSummary = bomType === "HBOM" ? getHbomSummary(bomJson) : undefined;
const trustedPublishingCounts = getTrustedPublishingComponentCounts(
bomJson?.components,
);
const isGitHubBom = bomType === "SBOM";
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 (tlpClassification) {
text = `${text} The Traffic Light Protocol (TLP) classification for this document is '${tlpClassification}'.`;
}
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.`;
}
}
if (isGitHubBom && githubStats.totalActions > 0) {
text = `${text} This ${bomType} contains ${githubStats.totalActions} GitHub Action references across ${githubStats.workflowCount} workflow(s) and ${githubStats.jobCount} job(s).`;
if (githubStats.workflows.length > 0) {
if (githubStats.workflows.length <= 3) {
text = `${text} The workflows are: ${joinArray(githubStats.workflows)}.`;
} else {
text = `${text} There are ${githubStats.workflows.length} workflows including ${joinArray(githubStats.workflows.slice(0, 3))}${githubStats.workflows.length > 3 ? " and others" : ""}.`;
}
}
if (githubStats.environments.length > 0) {
text = `${text} Jobs are deployed to ${joinArray(githubStats.environments)} environment(s).`;
}
const pinningText = [];
if (githubStats.shaPinned > 0) {
pinningText.push(`${githubStats.shaPinned} SHA-pinned`);
}
if (githubStats.tagPinned > 0) {
pinningText.push(`${githubStats.tagPinned} tag-pinned`);
}
if (githubStats.branchPinned > 0) {
pinningText.push(`${githubStats.branchPinned} branch-referenced`);
}
if (githubStats.unknownPinned > 0) {
pinningText.push(`${githubStats.unknownPinned} with unknown pinning`);
}
if (pinningText.length > 0) {
text = `${text} Version pinning breakdown: ${pinningText.join(", ")}.`;
}
if (githubStats.officialActions > 0 || githubStats.verifiedActions > 0) {
const trustText = [];
if (githubStats.officialActions > 0) {
trustText.push(`${githubStats.officialActions} official`);
}
if (githubStats.verifiedActions > 0) {
trustText.push(`${githubStats.verifiedActions} verified`);
}
text = `${text} ${joinArray(trustText)} action(s) are from trusted sources.`;
}
const securityText = generateSecurityAssessment(githubStats);
if (securityText) {
text = `${text}${securityText}`;
}
}
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) {
if (!isGitHubBom) {
text = `${text} There are ${bomJson.components.length} components.`;
}
if (trustedPublishingCounts.total > 0) {
text = `${text} Trusted publishing metadata is present for ${trustedPublishingCounts.npm} npm component(s) and ${trustedPublishingCounts.pypi} PyPI component(s).`;
}
} 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 (unpackagedExecutableCount || unpackagedSharedLibraryCount) {
text = `${text} The container or rootfs inventory includes ${unpackagedExecutableCount} executable file component(s) and ${unpackagedSharedLibraryCount} shared library component(s) that were not traced to OS package ownership.`;
}
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 === "HBOM" && hbomSummary) {
if (hbomSummary.hardwareClassCount > 0) {
text = `${text} The hardware inventory spans ${hbomSummary.hardwareClassCount} hardware classes.`;
const hardwareClassSummary = formatHbomHardwareClassSummary(
hbomSummary.hardwareClassCounts,
);
if (hardwareClassSummary) {
text = `${text} The most represented hardware classes are ${hardwareClassSummary}.`;
}
}
if (hbomSummary.collectorProfile) {
text = `${text} Collector profile '${hbomSummary.collectorProfile}' recorded ${hbomSummary.evidenceCommandCount} command evidence entr${hbomSummary.evidenceCommandCount === 1 ? "y" : "ies"}`;
if (hbomSummary.evidenceFileCount > 0) {
text = `${text} and ${hbomSummary.evidenceFileCount} observed file entr${hbomSummary.evidenceFileCount === 1 ? "y" : "ies"}`;
}
text = `${text}.`;
}
if (hbomSummary.identifierPolicy) {
text = `${text} Identifier policy is '${hbomSummary.identifierPolicy}'.`;
}
}
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);
}
}
}
}
// GitHub workflow specific tags from properties
if (bomType === "sbom" || bomType === "all") {
for (const aprop of compProps) {
// Security-related tags
if (
aprop.name === "cdx:github:action:isShaPinned" &&
aprop.value === "true"
) {
tags.add("sha-pinned");
tags.add("secure-versioning");
}
if (
aprop.name === "cdx:github:action:versionPinningType" &&
aprop.value !== "sha"
) {
tags.add(`pinning-${aprop.value}`);
}
if (aprop.name === "cdx:actions:isOfficial" && aprop.value === "true") {
tags.add("official-action");
tags.add("trusted-source");
}
if (aprop.name === "cdx:actions:isVerified" && aprop.value === "true") {
tags.add("verified-action");
tags.add("trusted-source");
}
if (
aprop.name === "cdx:github:workflow:hasWritePermissions" &&
aprop.value === "true"
) {
tags.add("write-permissions");
tags.add("elevated-access");
}
if (
aprop.name === "cdx:github:workflow:hasIdTokenWrite" &&
aprop.value === "true"
) {
tags.add("id-token-write");
tags.add("oidc-enabled");
}
if (
aprop.name === "cdx:github:step:continueOnError" &&
aprop.value === "true"
) {
tags.add("continue-on-error");
}
}
}
return Array.from(tags).sort();
}