UNPKG

@cyclonedx/cdxgen

Version:

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

1,377 lines (1,327 loc) 41.7 kB
import { readFileSync } from "node:fs"; import { basename, join, relative, sep } from "node:path"; import process from "node:process"; import { PackageURL } from "packageurl-js"; import { AI_INVENTORY_PROJECT_TYPES, matchesAiInventoryExcludeType, optionIncludesAiInventoryProjectType, } from "../../helpers/aiInventory.js"; import { isCycloneDx20SpecVersion, normalizeCycloneDxSpecVersion, setCycloneDxFormat, toCycloneDxSpecVersionString, } from "../../helpers/bomUtils.js"; import { mergeDependencies, mergeServices } from "../../helpers/depsUtils.js"; import { addFormulationSection } from "../../helpers/formulationParsers.js"; import { getContainerFileInventoryStats } from "../../helpers/inventoryStats.js"; import { thoughtLog } from "../../helpers/logger.js"; import { buildReleaseNotesFromGit } from "../../helpers/source.js"; import { DEBUG_MODE, dirNameStr, getTimestamp, getTmpDir, hasAnyProjectType, isDryRun, resetActivityContext, safeExistsSync, safeRmSync, setActivityContext, } from "../../helpers/utils.js"; import { extractTags, findBomType, textualMetadata } from "./annotator.js"; /** * Convert directories to relative dir format carefully avoiding arbitrary relativization for unrelated directories. * * @param d Directory to convert * @param options CLI options * * @returns {string} Relative directory */ function relativeDir(d, options) { // Container images might have such directories if (/^\/(usr|lib|root|bin)/.test(d)) { return d; } const tmpDir = getTmpDir(); if (d.startsWith(tmpDir)) { const rd = relative(tmpDir, d); return rd.includes("all-layers") ? rd.split("all-layers").pop() : rd; } const baseDir = options.filePath || process.cwd(); if (safeExistsSync(baseDir)) { const rdir = relative(baseDir, d); return rdir.startsWith(join("..", "..")) ? d : rdir; } return d; } /** * Attach the CycloneDX formulation section to an already-built BOM JSON object. * * This is intentionally called once, from {@link postProcess}, so that the * formulation section is added exactly once regardless of how many per-language * `buildBomNSData` calls were made during BOM generation. * * @param {Object} bomJson The assembled BOM JSON object (mutated in place). * @param {Object} options CLI options. * @param {string} filePath File path. * @param {Array} [formulationList] Optional language-specific formulation * data (e.g. from Pixi) carried on `bomNSData`. * @returns {Object} The same `bomJson` with `formulation` populated. */ function applyFormulation(bomJson, options, filePath, formulationList) { if ( !options.includeFormulation || options.specVersion < 1.5 || !bomJson || bomJson.formulation !== undefined ) { return bomJson; } const context = formulationList?.length ? { formulationList } : {}; setActivityContext({ bomMutation: "formulation", capability: "bom-mutation", projectType: "Formulation", sourcePath: filePath || options.filePath || process.cwd(), }); let formulationData; try { formulationData = addFormulationSection(filePath, options, context); } finally { resetActivityContext(); } if (!formulationData) { return bomJson; } bomJson.formulation = formulationData.formulation; const formulationServices = formulationData.formulation.flatMap( (entry) => entry?.services || [], ); if (formulationServices.length) { bomJson.services = mergeServices( bomJson.services || [], formulationServices, ); } if (formulationData.dependencies?.length) { bomJson.dependencies = mergeDependencies( bomJson.dependencies || [], formulationData.dependencies, ); } return bomJson; } const WEAK_TLP_CLASSIFICATIONS = new Set(["CLEAR", "GREEN", "AMBER"]); const SENSITIVE_PROPERTY_NAMES = new Set([ "cdx:agent:description", "cdx:agent:hiddenMcpUrls", "cdx:agent:permission", "cdx:mcp:command", "cdx:mcp:configuredEndpoints", "cdx:mcp:description", "cdx:mcp:resourceUri", "cdx:skill:metadata", ]); const SENSITIVE_PROPERTY_PREFIXES = ["cdx:crewai:", "cdx:mcp:auth:"]; const SECRET_ASSIGNMENT_PATTERN = /(?:^|[\s,{\[])(?:authorization|password|passwd|pwd|token|access[_-]?token|id[_-]?token|refresh[_-]?token|api[_-]?key|client[_-]?secret|secret|session(?:id)?|cookie)\s*(?:[:=]|=>)\s*["'`]?[^"'`\s,}\]]{4,}/iu; const ENV_SECRET_PATTERN = /\b[A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|API_KEY|CLIENT_SECRET|SESSION|COOKIE)[A-Z0-9_]*=\S+/u; const AUTH_HEADER_PATTERN = /\bAuthorization\s*:\s*(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}/iu; const BEARER_TOKEN_PATTERN = /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/u; const PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u; const SIGNED_URL_PARAM_NAMES = new Set([ "access_token", "api_key", "client_secret", "id_token", "signature", "sig", "token", "x-amz-signature", "x-goog-signature", ]); const COMPONENT_1_6_ONLY_FIELDS = new Set([ "authors", "manufacturer", "omniborId", "swhid", "tags", ]); const COMPONENT_1_7_ONLY_FIELDS = new Set([ "isExternal", "patentAssertions", "versionRange", ]); const SERVICE_1_6_ONLY_FIELDS = new Set(["tags"]); const SERVICE_1_7_ONLY_FIELDS = new Set(["patentAssertions"]); const METADATA_1_6_ONLY_FIELDS = new Set(["manufacturer"]); const METADATA_1_7_ONLY_FIELDS = new Set(["distributionConstraints"]); const METADATA_2_0_REMOVED_FIELDS = new Set(["manufacture"]); const COMPONENT_2_0_REMOVED_FIELDS = new Set(["author", "modified"]); function normalizeTlpClassification(tlpClassification) { return String(tlpClassification || "") .trim() .toUpperCase(); } function hasSensitivePropertyName(propertyName) { if (SENSITIVE_PROPERTY_NAMES.has(propertyName)) { return true; } return SENSITIVE_PROPERTY_PREFIXES.some((prefix) => propertyName.startsWith(prefix), ); } function extractUrlCandidates(value) { return Array.from(value.matchAll(/https?:\/\/[^\s]+/gu), (match) => match[0].replace(/[),.;]+$/u, ""), ); } function hasSensitiveUrlValue(value) { for (const candidate of extractUrlCandidates(value)) { if (!URL.canParse(candidate)) { continue; } const parsedUrl = new URL(candidate); if (parsedUrl.username || parsedUrl.password || parsedUrl.hash) { return true; } for (const [paramName] of parsedUrl.searchParams) { if (SIGNED_URL_PARAM_NAMES.has(paramName.toLowerCase())) { return true; } } } return false; } function hasKnownSensitiveText(value) { return ( SECRET_ASSIGNMENT_PATTERN.test(value) || ENV_SECRET_PATTERN.test(value) || AUTH_HEADER_PATTERN.test(value) || BEARER_TOKEN_PATTERN.test(value) || PRIVATE_KEY_PATTERN.test(value) ); } function propertyContainsSensitiveValue(propertyName, propertyValue) { if (!hasSensitivePropertyName(propertyName) || !propertyValue?.trim()) { return false; } return ( hasSensitiveUrlValue(propertyValue) || hasKnownSensitiveText(propertyValue) ); } function collectSensitivePropertyViolations( subject, violations = [], location = "bom", seen = new Set(), ) { if (!subject || typeof subject !== "object" || seen.has(subject)) { return violations; } seen.add(subject); if (Array.isArray(subject.properties)) { const subjectLabel = subject["bom-ref"] || subject.name || location; for (const property of subject.properties) { if ( typeof property?.name === "string" && typeof property?.value === "string" && propertyContainsSensitiveValue(property.name, property.value) ) { violations.push({ propertyName: property.name, subjectLabel, }); } } } if (Array.isArray(subject)) { subject.forEach((entry, index) => { collectSensitivePropertyViolations( entry, violations, `${location}[${index}]`, seen, ); }); return violations; } for (const [key, value] of Object.entries(subject)) { if (key === "properties") { continue; } collectSensitivePropertyViolations( value, violations, `${location}.${key}`, seen, ); } return violations; } function validateTlpClassification(bomJson, options) { const specVersion = normalizeCycloneDxSpecVersion( bomJson?.specVersion || options?.specVersion, ) || 0; if (specVersion < 1.7) { return bomJson; } const tlpClassification = normalizeTlpClassification( bomJson?.metadata?.distributionConstraints?.tlp || bomJson?.metadata?.distribution || options?.tlpClassification, ); if (!WEAK_TLP_CLASSIFICATIONS.has(tlpClassification)) { return bomJson; } const violations = collectSensitivePropertyViolations(bomJson); if (!violations.length) { return bomJson; } const uniqueViolations = [ ...new Set( violations.map( ({ propertyName, subjectLabel }) => `${propertyName} on ${subjectLabel}`, ), ), ]; const errorMessage = `CycloneDX 1.7+ BOMs with TLP classification '${tlpClassification}' must not include known sensitive property values. ` + "Redact the values or raise the TLP classification to AMBER_AND_STRICT or RED. " + `Found: ${uniqueViolations.slice(0, 5).join("; ")}${uniqueViolations.length > 5 ? `; and ${uniqueViolations.length - 5} more` : ""}`; if (options?.failOnError) { throw new Error(errorMessage); } console.warn(errorMessage); return bomJson; } function applyContainerInventoryMetadata(bomJson) { if (!bomJson?.metadata) { return bomJson; } const { unpackagedExecutableCount, unpackagedSharedLibraryCount } = getContainerFileInventoryStats(bomJson.components); const metadataProperties = Array.isArray(bomJson.metadata.properties) ? [...bomJson.metadata.properties] : []; const propertyNamesToReplace = new Set([ "cdx:container:unpackagedExecutableCount", "cdx:container:unpackagedSharedLibraryCount", ]); const retainedProperties = metadataProperties.filter( (property) => !propertyNamesToReplace.has(property?.name), ); if ( unpackagedExecutableCount || metadataProperties.some( (property) => property?.name === "cdx:container:unpackagedExecutableCount", ) ) { retainedProperties.push({ name: "cdx:container:unpackagedExecutableCount", value: String(unpackagedExecutableCount), }); } if ( unpackagedSharedLibraryCount || metadataProperties.some( (property) => property?.name === "cdx:container:unpackagedSharedLibraryCount", ) ) { retainedProperties.push({ name: "cdx:container:unpackagedSharedLibraryCount", value: String(unpackagedSharedLibraryCount), }); } if (retainedProperties.length) { bomJson.metadata.properties = retainedProperties; } return bomJson; } function deleteFields(subject, fields) { if (!subject || typeof subject !== "object") { return; } for (const fieldName of fields) { delete subject[fieldName]; } } function isObjectRecord(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function normalizeComponentForSpecVersion(subject, specVersion) { if (specVersion < 1.6) { deleteFields(subject, COMPONENT_1_6_ONLY_FIELDS); } if (specVersion < 1.7) { deleteFields(subject, COMPONENT_1_7_ONLY_FIELDS); } } function normalizeServiceForSpecVersion(subject, specVersion) { if (specVersion < 1.6) { deleteFields(subject, SERVICE_1_6_ONLY_FIELDS); } if (specVersion < 1.7) { deleteFields(subject, SERVICE_1_7_ONLY_FIELDS); } } function normalizeMetadataForSpecVersion(subject, specVersion) { if (specVersion < 1.6) { deleteFields(subject, METADATA_1_6_ONLY_FIELDS); } if (specVersion < 1.7) { deleteFields(subject, METADATA_1_7_ONLY_FIELDS); } } function authorStringToAuthors(authorValue) { if (typeof authorValue !== "string") { return undefined; } const authors = authorValue .split(",") .map((author) => author.trim()) .filter(Boolean) .map((name) => ({ name })); return authors.length ? authors : undefined; } function normalizeLegacyToolComponent(tool) { if (!isObjectRecord(tool)) { return tool; } if (!tool.type) { tool.type = "application"; } if (tool.vendor && !tool.publisher) { tool.publisher = tool.vendor; } delete tool.vendor; if (!tool.authors && tool.author) { tool.authors = authorStringToAuthors(tool.author); } deleteFields(tool, COMPONENT_2_0_REMOVED_FIELDS); normalizeComponentsForSpecVersion(tool.components); return tool; } function hasExplicitSpecVersion(specVersion) { return ( specVersion !== undefined && specVersion !== null && `${specVersion}`.trim() !== "" ); } function resolveSpecVersionForCompatibility(bomJson, options) { if (hasExplicitSpecVersion(options?.specVersion)) { return options.specVersion; } if (hasExplicitSpecVersion(bomJson?.specVersion)) { return bomJson.specVersion; } return "1.7"; } function normalizeLegacyToolService(service) { if (!isObjectRecord(service)) { return service; } if (service.vendor && !service.provider) { service.provider = typeof service.vendor === "string" ? { name: service.vendor } : service.vendor; } delete service.vendor; deleteFields(service, COMPONENT_2_0_REMOVED_FIELDS); normalizeServicesForSpecVersion(service.services); return service; } function normalizeComponentsForSpecVersion(components) { if (!Array.isArray(components)) { return; } for (const component of components) { normalizeComponentForSpecVersion20(component); } } function normalizeComponentForSpecVersion20(component) { if (!isObjectRecord(component)) { return; } if (!component.authors && component.author) { component.authors = authorStringToAuthors(component.author); } deleteFields(component, COMPONENT_2_0_REMOVED_FIELDS); normalizeComponentsForSpecVersion(component.components); } function normalizeServicesForSpecVersion(services) { if (!Array.isArray(services)) { return; } for (const service of services) { normalizeLegacyToolService(service); } } function normalizeFormulationForSpecVersion(formulation) { if (!Array.isArray(formulation)) { return; } for (const formula of formulation) { if (!isObjectRecord(formula)) { continue; } normalizeComponentsForSpecVersion(formula.components); normalizeServicesForSpecVersion(formula.services); } } function normalizeVulnerabilitiesForSpecVersion(vulnerabilities) { if (!Array.isArray(vulnerabilities)) { return; } for (const vulnerability of vulnerabilities) { if (!isObjectRecord(vulnerability?.tools)) { continue; } normalizeComponentsForSpecVersion(vulnerability.tools.components); normalizeServicesForSpecVersion(vulnerability.tools.services); } } function normalizeDefinitionsForSpecVersion(definitions) { if (!isObjectRecord(definitions)) { return; } normalizeComponentsForSpecVersion(definitions.components); normalizeServicesForSpecVersion(definitions.services); } function migrateLegacyManufactureForSpecVersion(metadata) { if (!metadata.manufacture) { return; } if (isObjectRecord(metadata.component) && !metadata.component.manufacturer) { metadata.component.manufacturer = metadata.manufacture; return; } if (!metadata.manufacturer) { metadata.manufacturer = metadata.manufacture; } } function normalizeToolsForSpecVersion(subject, specVersion) { if (!subject || !isCycloneDx20SpecVersion(specVersion)) { return; } if (Array.isArray(subject.tools)) { subject.tools = { components: subject.tools.map((tool) => normalizeLegacyToolComponent(isObjectRecord(tool) ? { ...tool } : tool), ), }; return; } if (!isObjectRecord(subject.tools)) { return; } if (Array.isArray(subject.tools.components)) { subject.tools.components = subject.tools.components.map((tool) => normalizeLegacyToolComponent(tool), ); } if (Array.isArray(subject.tools.services)) { subject.tools.services = subject.tools.services.map((service) => normalizeLegacyToolService(service), ); } } function upgradeSubjectForSpecVersion(subject, specVersion) { if (!isObjectRecord(subject) || !isCycloneDx20SpecVersion(specVersion)) { return; } if (isObjectRecord(subject.metadata)) { migrateLegacyManufactureForSpecVersion(subject.metadata); deleteFields(subject.metadata, METADATA_2_0_REMOVED_FIELDS); normalizeToolsForSpecVersion(subject.metadata, specVersion); normalizeComponentForSpecVersion20(subject.metadata.component); } normalizeComponentsForSpecVersion(subject.components); normalizeFormulationForSpecVersion(subject.formulation); normalizeDefinitionsForSpecVersion(subject.definitions); normalizeVulnerabilitiesForSpecVersion(subject.vulnerabilities); } function downgradeSubjectForSpecVersion(subject, specVersion, parentKey) { if (!subject || typeof subject !== "object") { return; } if (Array.isArray(subject)) { subject.forEach((entry) => { downgradeSubjectForSpecVersion(entry, specVersion, parentKey); }); return; } if (parentKey === "metadata") { normalizeMetadataForSpecVersion(subject, specVersion); } if (parentKey === "component" || parentKey === "components") { normalizeComponentForSpecVersion(subject, specVersion); } if (parentKey === "service" || parentKey === "services") { normalizeServiceForSpecVersion(subject, specVersion); } if (specVersion < 1.6) { if (subject.cryptoProperties) { delete subject.cryptoProperties; } if ( subject?.evidence?.occurrences && Array.isArray(subject.evidence.occurrences) ) { subject.evidence.occurrences.forEach((occurrence) => { delete occurrence.line; delete occurrence.offset; delete occurrence.symbol; delete occurrence.additionalContext; }); } if ( subject?.evidence?.identity && Array.isArray(subject.evidence.identity) ) { subject.evidence.identity = subject.evidence.identity[0]; if (subject.evidence.identity?.concludedValue) { delete subject.evidence.identity.concludedValue; } } } else if ( specVersion < 1.7 && subject.cryptoProperties?.assetType === "certificate" && subject.cryptoProperties.certificateProperties ) { const certificateProperties = subject.cryptoProperties.certificateProperties; if ( !certificateProperties.certificateExtension && certificateProperties.certificateFileExtension ) { certificateProperties.certificateExtension = certificateProperties.certificateFileExtension; } delete certificateProperties.serialNumber; delete certificateProperties.certificateFileExtension; delete certificateProperties.fingerprint; } Object.entries(subject).forEach(([key, value]) => { downgradeSubjectForSpecVersion(value, specVersion, key); }); } function applySpecVersionCompatibility(bomJson, options) { const requestedSpecVersion = resolveSpecVersionForCompatibility( bomJson, options, ); const normalizedSpecVersion = toCycloneDxSpecVersionString(requestedSpecVersion); if (!normalizedSpecVersion) { thoughtLog( "Skipping CycloneDX specVersion compatibility updates for malformed explicit specVersion.", { specVersion: requestedSpecVersion, }, ); return bomJson; } const specVersion = normalizeCycloneDxSpecVersion(normalizedSpecVersion); if (specVersion < 1.7) { downgradeSubjectForSpecVersion(bomJson, specVersion); } else if (isCycloneDx20SpecVersion(specVersion)) { upgradeSubjectForSpecVersion(bomJson, specVersion); } return setCycloneDxFormat(bomJson, normalizedSpecVersion); } /** * Filter and enhance BOM post generation. * * @param {Object} bomNSData BOM with namespaces object * @param {Object} options CLI options * @param {string} [filePath] Source path used for formulation and metadata context * * @returns {Object} Modified bomNSData */ export function postProcess(bomNSData, options, filePath) { let jsonPayload = bomNSData.bomJson; if ( typeof bomNSData.bomJson === "string" || bomNSData.bomJson instanceof String ) { jsonPayload = JSON.parse(bomNSData.bomJson); } bomNSData.bomJson = filterBom(jsonPayload, options); bomNSData.bomJson = applyStandards(bomNSData.bomJson, options); bomNSData.bomJson = applyMetadata(bomNSData.bomJson, options); bomNSData.bomJson = applyContainerInventoryMetadata(bomNSData.bomJson); bomNSData.bomJson = applyFormulation( bomNSData.bomJson, options, filePath, bomNSData.formulationList, ); bomNSData.bomJson = applyReleaseNotes(bomNSData.bomJson, options, filePath); bomNSData.bomJson = applySpecVersionCompatibility(bomNSData.bomJson, options); bomNSData.bomJson = validateTlpClassification(bomNSData.bomJson, options); // Support for automatic annotations if (options.specVersion >= 1.6) { setActivityContext({ bomMutation: "annotations", capability: "bom-mutation", projectType: "Annotations", sourcePath: filePath || options.filePath || process.cwd(), }); try { bomNSData.bomJson = annotate(bomNSData.bomJson, options); } finally { resetActivityContext(); } } cleanupEnv(options); cleanupTmpDir(); return bomNSData; } function applyReleaseNotes(bomJson, options, filePath) { if (!options?.includeReleaseNotes) { return bomJson; } const specVersion = Number(options.specVersion || 1.7); if (specVersion < 1.6) { const errorMessage = "releaseNotes in metadata.tools.components requires CycloneDX spec version 1.6 or above."; if (options.failOnError) { throw new Error(errorMessage); } console.warn(errorMessage); return bomJson; } const toolComponents = bomJson?.metadata?.tools?.components; if (!Array.isArray(toolComponents) || !toolComponents.length) { return bomJson; } const cdxgenToolComponent = toolComponents.find( (comp) => comp?.group === "@cyclonedx" && comp?.name === "cdxgen", ); if (!cdxgenToolComponent) { return bomJson; } const releaseNotes = buildReleaseNotesFromGit(filePath, options); if (!releaseNotes) { const errorMessage = "Unable to compute release notes. Provide --release-notes-current-tag and optionally --release-notes-previous-tag."; if (options.failOnError) { throw new Error(errorMessage); } console.warn(errorMessage); return bomJson; } cdxgenToolComponent.releaseNotes = releaseNotes; return bomJson; } /** * Apply additional metadata based on components * * @param {Object} bomJson BOM JSON Object * @param {Object} options CLI options * * @returns {Object} Filtered BOM JSON */ export function applyMetadata(bomJson, options) { if (!bomJson?.components) { return bomJson; } const bomPkgTypes = new Set(); const bomPkgNamespaces = new Set(); const bomSrcFiles = new Set(); for (const comp of bomJson.components) { if (comp.purl) { try { const purlObj = PackageURL.fromString(comp.purl); if (purlObj?.type) { bomPkgTypes.add(purlObj.type); } if (purlObj?.namespace) { bomPkgNamespaces.add(purlObj.namespace); } } catch (_e) { // ignore } } if (comp.properties) { for (const aprop of comp.properties) { if (aprop.name === "SrcFile" && aprop.value) { const rdir = relativeDir(aprop.value, options); if (comp.type !== "file") { bomSrcFiles.add(rdir); } // Fix the filename to use relative directory if (rdir !== aprop.value) { aprop.value = rdir; } } } } if (comp?.evidence?.identity && Array.isArray(comp.evidence.identity)) { for (const aidentityEvidence of comp.evidence.identity) { if (aidentityEvidence.concludedValue) { const rdir = relativeDir(aidentityEvidence.concludedValue, options); if (comp.type !== "file") { bomSrcFiles.add(rdir); } if (rdir !== aidentityEvidence.concludedValue) { aidentityEvidence.concludedValue = rdir; } } if ( aidentityEvidence.methods && Array.isArray(aidentityEvidence.methods) ) { for (const amethod of aidentityEvidence.methods) { const rdir = relativeDir(amethod.value, options); if ( comp.type !== "file" && ["manifest-analysis"].includes(amethod.technique) && amethod.value ) { bomSrcFiles.add(rdir); } // Fix the filename to use relative directory if (rdir !== amethod.value) { amethod.value = rdir; } } } } } } if (!bomJson.metadata.properties) { bomJson.metadata.properties = []; } if (bomPkgTypes.size) { const componentTypesArray = Array.from(bomPkgTypes).sort(); // Check if cdx:bom:componentTypes property already exists const existingTypesProperty = bomJson.metadata.properties.find( (p) => p.name === "cdx:bom:componentTypes", ); if (!existingTypesProperty) { bomJson.metadata.properties.push({ name: "cdx:bom:componentTypes", value: componentTypesArray.join("\\n"), }); } if (componentTypesArray.length > 1) { thoughtLog( `BOM includes the ${componentTypesArray.length} component types: ${componentTypesArray.join(", ")}`, ); } } if (bomPkgNamespaces.size) { // Check if cdx:bom:componentNamespaces property already exists const existingNamespacesProperty = bomJson.metadata.properties.find( (p) => p.name === "cdx:bom:componentNamespaces", ); if (!existingNamespacesProperty) { bomJson.metadata.properties.push({ name: "cdx:bom:componentNamespaces", value: Array.from(bomPkgNamespaces).sort().join("\\n"), }); } } if (bomSrcFiles.size) { const bomSrcFilesArray = Array.from(bomSrcFiles).sort(); // Check if cdx:bom:componentSrcFiles property already exists const existingSrcFilesProperty = bomJson.metadata.properties.find( (p) => p.name === "cdx:bom:componentSrcFiles", ); if (!existingSrcFilesProperty) { bomJson.metadata.properties.push({ name: "cdx:bom:componentSrcFiles", value: bomSrcFilesArray.join("\\n"), }); } if (bomSrcFilesArray.length > 1 && bomSrcFilesArray.length < 5) { thoughtLog( `BOM includes information from ${bomSrcFilesArray.length} manifest files: ${bomSrcFilesArray.join(", ")}`, ); } } else { if (!bomPkgTypes.has("oci")) { thoughtLog("BOM lacks package manifest details. Please help us improve!"); } } return bomJson; } /** * Apply definitions.standards based on options * * @param {Object} bomJson BOM JSON Object * @param {Object} options CLI options * * @returns {Object} Filtered BOM JSON */ export function applyStandards(bomJson, options) { if (options.standard && Array.isArray(options.standard)) { for (let astandard of options.standard) { // See issue: #1953 if (astandard.includes(sep)) { astandard = basename(astandard); } const templateFile = join( dirNameStr, "data", "templates", `${astandard}.cdx.json`, ); if (safeExistsSync(templateFile)) { const templateData = JSON.parse(readFileSync(templateFile, "utf-8")); if (templateData?.metadata?.licenses) { if (!bomJson.metadata.licenses) { bomJson.metadata.licenses = []; } bomJson.metadata.licenses = bomJson.metadata.licenses.concat( templateData.metadata.licenses, ); } if (templateData?.definitions?.standards) { if (!bomJson.definitions) { bomJson.definitions = { standards: [] }; } bomJson.definitions.standards = bomJson.definitions.standards.concat( templateData.definitions.standards, ); } } } } return bomJson; } /** * Method to normalize the identity field from a component's evidence block. * * In different versions of CycloneDX, the `identity` field can be either a single object or an array of objects. * This function ensures that the result is always an array for consistent processing. * * @param {Object} comp - The component object potentially containing evidence.identity. * @returns {Array} An array of identity objects (empty if none are present). */ function normalizeIdentities(comp) { const identity = comp?.evidence?.identity; if (Array.isArray(identity)) { return identity; } if (identity) { return [identity]; } return []; } /** * Method to get the purl identity confidence. * * @param comp Component * @returns {undefined|number} Max of all the available purl identity confidence or undefined */ function getIdentityConfidence(comp) { if (!comp.evidence) { return undefined; } let confidence; for (const aidentity of normalizeIdentities(comp)) { if (aidentity?.field === "purl") { if (confidence === undefined) { confidence = aidentity.confidence || 0; } else { confidence = Math.max(aidentity.confidence, confidence); } } } return confidence; } /** * Method to get the list of techniques used for identity. * * @param comp Component * @returns {Set|undefined} Set of technique. evidence.identity.methods.technique */ function getIdentityTechniques(comp) { if (!comp.evidence) { return undefined; } const techniques = new Set(); for (const aidentity of normalizeIdentities(comp)) { if (aidentity?.field === "purl") { for (const amethod of aidentity.methods || []) { techniques.add(amethod?.technique); } } } return techniques; } /** * Filter BOM based on options * * @param {Object} bomJson BOM JSON Object * @param {Object} options CLI options * * @returns {Object} Filtered BOM JSON */ export function filterBom(bomJson, options) { const newPkgMap = {}; const newServices = []; let filtered = false; let anyFiltered = false; if (!bomJson?.components) { return bomJson; } for (const comp of bomJson.components) { if (shouldExcludeInventoryType(comp, options)) { filtered = true; continue; } // minimum confidence filter if (options?.minConfidence > 0) { const confidence = Math.min(options.minConfidence, 1); const identityConfidence = getIdentityConfidence(comp); if (identityConfidence !== undefined && identityConfidence < confidence) { filtered = true; continue; } } // identity technique filter if (options?.technique?.length && !options.technique.includes("auto")) { const allowedTechniques = new Set( Array.isArray(options.technique) ? options.technique : [options.technique], ); const usedTechniques = getIdentityTechniques(comp); // Set.intersection is only available in node >= 22. See Bug# 1651 if ( usedTechniques && ![...usedTechniques].some((i) => allowedTechniques.has(i)) ) { filtered = true; continue; } } if ( options.requiredOnly && comp.scope && ["optional", "excluded"].includes(comp.scope) ) { filtered = true; } else if (options.only?.length) { const componentPurl = comp.purl?.toLowerCase?.() || ""; if (!Array.isArray(options.only)) { options.only = [options.only]; } // See issue: #1962 let purlfiltered = true; for (const filterstr of options.only) { if ( filterstr.length && componentPurl.includes(filterstr.toLowerCase()) ) { filtered = true; purlfiltered = false; break; } } if (!purlfiltered) { newPkgMap[comp["bom-ref"]] = comp; } } else if (options.filter?.length) { if (!Array.isArray(options.filter)) { options.filter = [options.filter]; } let purlfiltered = false; const componentPurl = comp.purl?.toLowerCase?.() || ""; for (const filterstr of options.filter) { // Check the purl if ( filterstr.length && componentPurl.includes(filterstr.toLowerCase()) ) { filtered = true; purlfiltered = true; continue; } // Look for any properties value matching the string const properties = comp.properties || []; for (const aprop of properties) { if ( filterstr.length && aprop?.value?.toLowerCase().includes(filterstr.toLowerCase()) ) { filtered = true; purlfiltered = true; } } } if (!purlfiltered) { newPkgMap[comp["bom-ref"]] = comp; } } else { newPkgMap[comp["bom-ref"]] = comp; } } for (const service of bomJson.services || []) { if (shouldExcludeInventoryType(service, options)) { filtered = true; continue; } newServices.push(service); } if (filtered) { if (!anyFiltered) { anyFiltered = true; } const newcomponents = []; const newdependencies = []; const retainedRefs = new Set(); for (const aref of Object.keys(newPkgMap).sort()) { newcomponents.push(newPkgMap[aref]); retainedRefs.add(aref); } for (const service of newServices) { if (service?.["bom-ref"]) { retainedRefs.add(service["bom-ref"]); } } if (bomJson.metadata?.component?.["bom-ref"]) { newPkgMap[bomJson.metadata.component["bom-ref"]] = bomJson.metadata.component; retainedRefs.add(bomJson.metadata.component["bom-ref"]); } if (bomJson.metadata?.component?.components) { for (const comp of bomJson.metadata.component.components) { newPkgMap[comp["bom-ref"]] = comp; retainedRefs.add(comp["bom-ref"]); } } for (const adep of bomJson.dependencies || []) { if (retainedRefs.has(adep.ref)) { const newdepson = (adep.dependsOn || []).filter((d) => retainedRefs.has(d), ); const obj = { ref: adep.ref, dependsOn: newdepson, }; // Filter provides array if needed if (adep.provides?.length) { obj.provides = adep.provides.filter((d) => retainedRefs.has(d)); } newdependencies.push(obj); } } bomJson.components = newcomponents; bomJson.dependencies = newdependencies; bomJson.services = newServices; // We set the compositions.aggregate to incomplete by default if ( options.specVersion >= 1.5 && options.autoCompositions && bomJson.metadata?.component ) { if (!bomJson.compositions) { bomJson.compositions = []; } bomJson.compositions.push({ "bom-ref": bomJson.metadata.component["bom-ref"], aggregate: options.only ? "incomplete_first_party_only" : "incomplete", }); } } if (!anyFiltered && DEBUG_MODE) { if ( options.requiredOnly && !options.deep && hasAnyProjectType(["python"], options, false) ) { console.log( "TIP: Try running cdxgen with --deep argument to identify component usages with atom.", ); } else if ( options.requiredOnly && options.noBabel && hasAnyProjectType(["js"], options, false) ) { console.log( "Enable babel by removing the --no-babel argument to improve usage detection.", ); } } return bomJson; } function shouldExcludeInventoryType(subject, options) { return AI_INVENTORY_PROJECT_TYPES.some( (type) => optionIncludesAiInventoryProjectType(options?.excludeType, type) && matchesAiInventoryExcludeType(subject, type), ); } /** * Clean up */ export function cleanupEnv(_options) { if (isDryRun) { return; } if (process.env?.PIP_TARGET?.startsWith(getTmpDir())) { safeRmSync(process.env.PIP_TARGET, { recursive: true, force: true }); } } /** * Removes the cdxgen temporary directory if it was created inside the system * temp directory (as indicated by `CDXGEN_TMP_DIR`). No-ops when the variable * is unset or points outside the system temp directory. * * @returns {void} */ export function cleanupTmpDir() { if (isDryRun) { return; } if (process.env?.CDXGEN_TMP_DIR?.startsWith(getTmpDir())) { safeRmSync(process.env.CDXGEN_TMP_DIR, { recursive: true, force: true }); } } function stripBomLink(serialNumber, version, ref) { return ref.replace(`${serialNumber}/${version - 1}/`, ""); } /** * Annotate the document with annotator * * @param {Object} bomJson BOM JSON Object * @param {Object} options CLI options * * @returns {Object} Annotated BOM JSON */ export function annotate(bomJson, options) { if (!bomJson?.components) { return bomJson; } const bomAnnotations = bomJson?.annotations || []; const cdxgenAnnotator = bomJson.metadata.tools.components.filter( (c) => c.name === "cdxgen", ); if (!cdxgenAnnotator.length) { return bomJson; } const { bomType } = findBomType(bomJson); const requiresContextTuning = [ "deep-learning", "machine-learning", "ml", "ml-deep", "ml-tiny", ].includes(options?.profile); const requiresContextTrimming = (requiresContextTuning && ["saasbom"].includes(bomType.toLowerCase())) || ["ml-tiny"].includes(options?.profile); // Construct the bom-link prefix to use for context tuning const bomLinkPrefix = `${bomJson.serialNumber}/${bomJson.version}/`; const metadataAnnotations = textualMetadata(bomJson); let parentBomRef; if (bomJson.metadata?.component?.["bom-ref"]) { if (requiresContextTuning) { bomJson.metadata.component["bom-ref"] = `${bomLinkPrefix}${stripBomLink(bomJson.serialNumber, bomJson.version, bomJson.metadata.component["bom-ref"])}`; } parentBomRef = bomJson.metadata.component["bom-ref"]; } if (metadataAnnotations) { bomAnnotations.push({ "bom-ref": "metadata-annotations", subjects: parentBomRef ? [parentBomRef] : [bomJson.serialNumber], annotator: { component: cdxgenAnnotator[0], }, timestamp: getTimestamp(), text: metadataAnnotations, }); } bomJson.annotations = bomAnnotations; // Shall we trim the metadata section if (requiresContextTrimming) { if (bomJson?.metadata?.component?.components) { bomJson.metadata.component.components = undefined; } if (bomJson?.metadata?.component?.["bom-ref"]) { bomJson.metadata.component["bom-ref"] = undefined; } if (bomJson?.metadata?.component?.properties) { bomJson.metadata.component.properties = undefined; } if (bomJson?.metadata?.properties) { bomJson.metadata.properties = undefined; } } // Tag the components for (const comp of bomJson.components) { const tags = extractTags(comp, bomType, bomJson.metadata?.component?.type); if (tags?.length) { comp.tags = tags; } if (requiresContextTuning) { comp["bom-ref"] = `${bomLinkPrefix}${stripBomLink(bomJson.serialNumber, bomJson.version, comp["bom-ref"])}`; comp.description = undefined; comp.properties = undefined; comp.evidence = undefined; } if (requiresContextTrimming) { comp.authors = undefined; comp.supplier = undefined; comp.publisher = undefined; comp["bom-ref"] = undefined; comp.externalReferences = undefined; comp.description = undefined; comp.properties = undefined; comp.evidence = undefined; // We will lose information about nested components, such as the files in case of poetry.lock comp.components = undefined; } } // For tiny models, we can remove the dependencies section if (requiresContextTrimming) { bomJson.dependencies = undefined; if (bomType.toLowerCase() === "saasbom") { bomJson.components = undefined; let i = 0; for (const aserv of bomJson.services) { aserv.name = `service-${i++}`; } } } // Problem: information such as the dependency tree are specific to an sbom // To prevent the models from incorrectly learning about the trees, we automatically convert all bom-ref // references to [bom-link](https://cyclonedx.org/capabilities/bomlink/) format if (requiresContextTuning && bomJson?.dependencies?.length) { const newDeps = []; for (const dep of bomJson.dependencies) { const newRef = `${bomLinkPrefix}${stripBomLink(bomJson.serialNumber, bomJson.version, dep.ref)}`; const newDependsOn = []; for (const adon of dep.dependsOn) { newDependsOn.push( `${bomLinkPrefix}${stripBomLink(bomJson.serialNumber, bomJson.version, adon)}`, ); } newDeps.push({ ref: newRef, dependsOn: newDependsOn.sort(), }); } // Overwrite the dependencies bomJson.dependencies = newDeps; } return bomJson; }