UNPKG

@cyclonedx/cdxgen

Version:

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

1,651 lines (1,595 loc) 64.7 kB
import { readFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; import { detectAiModelVariants } from "./aiModelVariants.js"; import { formatOccurrenceEvidence } from "./evidenceUtils.js"; import { formatHbomHardwareClassSummary, getHbomSummary, isHbomLikeBom, } from "./hbomAnalysis.js"; import { getHostViewSummary, isMergedHostViewBom } from "./hostTopology.js"; import { getPropertyValue } from "./inventoryStats.js"; import { hasComponentRegistryProvenance, REGISTRY_PROVENANCE_ICON, } from "./provenanceUtils.js"; import { createStream, table } from "./table.js"; import { getRecordedActivities, isDryRun, isSecureMode, safeExistsSync, toCamel, } from "./utils.js"; // https://github.com/yangshun/tree-node-cli/blob/master/src/index.js const SYMBOLS_ANSI = { BRANCH: "├── ", EMPTY: "", INDENT: " ", LAST_BRANCH: "└── ", VERTICAL: "│ ", }; const MAX_TREE_DEPTH = 6; const CYCLE_NODE_ICON = "↺"; const REPEATED_NODE_ICON = "⤴"; const MULTIVALUE_ACTIVITY_TARGET_KEYS = new Set([ "LockFiles", "ManifestFiles", "PkgFiles", "SrcFiles", ]); const PATH_SEPARATOR_REGEX = /[\\/]+/; const SUSPICIOUS_SHELL_PATH_LABEL = "⚠ shell-metacharacters"; const ENV_AUDIT_SEVERITY_RANK = { low: 1, medium: 2, high: 3, critical: 4, }; const ENV_AUDIT_TYPE_LABELS = { "code-execution": "Code Execution", "credential-exposure": "Credential Exposure", "debug-exposure": "Debug Exposure", "environment-variable": "Environment Variable", "network-interception": "Network Interception", "permission-misuse": "Permission Misuse", privilege: "Privilege", }; const highlightStr = (s, highlight) => { if (highlight && s?.includes(highlight)) { s = s.replaceAll(highlight, `\x1b[1;33m${highlight}\x1b[0m`); } return s; }; const formatComponentName = (component, highlight) => { const displayName = highlightStr(component?.name || "", highlight); if (hasComponentRegistryProvenance(component)) { return `${REGISTRY_PROVENANCE_ICON} ${displayName}`; } return displayName; }; const isAiInventoryComponent = (component) => Boolean( component?.type === "machine-learning-model" || component?.modelCard || component?.pedigree || getPropertyValue(component, "cdx:ai:provider") || getPropertyValue(component, "cdx:ai:kind"), ); const getAiInventoryComponents = (bomJson) => (bomJson?.components || []).filter((component) => isAiInventoryComponent(component), ); const isAiInventoryService = (service) => Boolean( getPropertyValue(service, "cdx:ai:kind") || service?.provider?.name || (service?.tags || []).includes("ai"), ); const getAiInventoryServices = (bomJson) => (bomJson?.services || []).filter((service) => isAiInventoryService(service)); const uniqueStrings = (values) => [...new Set((values || []).filter(Boolean))]; const formatLimitedList = (values, max = 3) => { const uniqueValues = uniqueStrings(values); if (!uniqueValues.length) { return ""; } return `${uniqueValues.slice(0, max).join(", ")}${ uniqueValues.length > max ? ` +${uniqueValues.length - max} more` : "" }`; }; const getAiVariantValues = (component) => detectAiModelVariants({ description: component?.description, metadata: [component?.modelCard?.modelParameters?.task], modelName: [component?.group, component?.name].filter(Boolean).join("/"), notes: [component?.pedigree?.notes], quantization: getPropertyValue(component, "cdx:ai:quantization"), tags: component?.tags, }); const getAiDatasetNames = (component, bomJson) => uniqueStrings( component?.modelCard?.modelParameters?.datasets?.map((dataset) => { if (dataset?.name) { return dataset.name; } if (!dataset?.ref) { return undefined; } const resolved = (bomJson?.components || []).find( (candidate) => candidate?.["bom-ref"] === dataset.ref, ); if (!resolved) { return dataset.ref; } return resolved.group ? `${resolved.group}/${resolved.name}` : resolved.name; }) || [], ); const formatAiLicense = (component) => (component?.licenses || []) .map( (entry) => entry?.license?.id || entry?.license?.expression || entry?.license?.name || entry?.expression, ) .filter(Boolean) .join(", "); const formatPedigreeNodeLabel = (component) => [ component?.group ? `${component.group}/${component.name}` : component?.name, component?.version ? `@${component.version}` : "", ].join(""); const formatAiQuantizationExplanation = (quantization) => quantization ? `${quantization} — compressed weights for lower memory / faster inference trade-offs` : undefined; /** * Build operator-facing AI-BOM summary lines. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {string[]} formatted AI-BOM summary lines */ export function buildAiBomInsightLines(bomJson) { const aiComponents = getAiInventoryComponents(bomJson); if (!aiComponents.length) { return []; } const aiServices = getAiInventoryServices(bomJson); const lines = [ "", `AI-BOM: ${aiComponents.length} AI component(s) with model, pedigree, and runtime context`, ]; if (aiServices.length) { lines.push( `Usage: ${aiServices.length} AI service(s) expose ${formatLimitedList( aiServices.map( (service) => service?.provider?.name || service?.group || service?.name, ), 4, )}`, ); } for (const component of aiComponents.slice(0, 8)) { const task = component?.modelCard?.modelParameters?.task || getPropertyValue(component, "cdx:ai:modality"); const provider = getPropertyValue(component, "cdx:ai:provider"); const quantization = formatAiQuantizationExplanation( getPropertyValue(component, "cdx:ai:quantization"), ); const license = formatAiLicense(component); const datasets = getAiDatasetNames(component, bomJson); const variants = getAiVariantValues(component); lines.push( `${formatPedigreeNodeLabel(component)}${component?.description ? ` — ${component.description}` : ""}`, ); if (provider) { lines.push(`${SYMBOLS_ANSI.INDENT}Provider: ${provider}`); } if (task) { lines.push(`${SYMBOLS_ANSI.INDENT}Task: ${task}`); } if (quantization) { lines.push(`${SYMBOLS_ANSI.INDENT}Quantization: ${quantization}`); } if (variants.length) { lines.push(`${SYMBOLS_ANSI.INDENT}Variants: ${variants.join(", ")}`); } if (license) { lines.push(`${SYMBOLS_ANSI.INDENT}License: ${license}`); } const ancestors = component?.pedigree?.ancestors || []; if (ancestors.length) { lines.push( `${SYMBOLS_ANSI.INDENT}Pedigree: derived from ${ancestors.length} upstream model(s)`, ); ancestors.slice(0, 4).forEach((ancestor, index) => { const branch = index === ancestors.slice(0, 4).length - 1 ? SYMBOLS_ANSI.LAST_BRANCH : SYMBOLS_ANSI.BRANCH; lines.push( `${SYMBOLS_ANSI.INDENT}${SYMBOLS_ANSI.INDENT}${branch}${formatPedigreeNodeLabel(ancestor)}`, ); }); } if (datasets.length) { lines.push( `${SYMBOLS_ANSI.INDENT}Datasets: ${datasets.slice(0, 3).join(", ")}${datasets.length > 3 ? ` +${datasets.length - 3} more` : ""}`, ); } if (component?.pedigree?.notes) { lines.push( `${SYMBOLS_ANSI.INDENT}ℹ ${String(component.pedigree.notes).replace(/^Hugging Face relation:\s*/u, "Relation: ")}`, ); } } if (aiComponents.length > 8) { lines.push(`… ${aiComponents.length - 8} more AI component(s) omitted`); } return lines; } /** * Print operator-facing AI-BOM summary lines. * * @param {Object} bomJson CycloneDX BOM JSON object */ export function printAiBomInsights(bomJson) { const lines = buildAiBomInsightLines(bomJson); if (lines.length) { console.log(lines.join("\n")); } } /** * Build AI-BOM pedigree lines for REPL inspection. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {string[]} formatted pedigree lines */ export function buildAiBomPedigreeLines(bomJson) { const pedigreeComponents = getAiInventoryComponents(bomJson).filter( (component) => component?.pedigree?.ancestors?.length || component?.pedigree?.variants?.length || component?.pedigree?.notes, ); if (!pedigreeComponents.length) { return []; } const lines = ["", `AI pedigree view: ${pedigreeComponents.length} model(s)`]; for (const component of pedigreeComponents.slice(0, 10)) { lines.push(formatPedigreeNodeLabel(component)); for (const [label, nodes] of [ ["Ancestors", component?.pedigree?.ancestors || []], ["Variants", component?.pedigree?.variants || []], ]) { if (!nodes.length) { continue; } lines.push(`${SYMBOLS_ANSI.INDENT}${label}:`); nodes.slice(0, 4).forEach((node, index) => { lines.push( `${SYMBOLS_ANSI.INDENT}${SYMBOLS_ANSI.INDENT}${ index === nodes.slice(0, 4).length - 1 ? SYMBOLS_ANSI.LAST_BRANCH : SYMBOLS_ANSI.BRANCH }${formatPedigreeNodeLabel(node)}`, ); }); } if (component?.pedigree?.notes) { lines.push(`${SYMBOLS_ANSI.INDENT}ℹ ${component.pedigree.notes}`); } } if (pedigreeComponents.length > 10) { lines.push( `… ${pedigreeComponents.length - 10} more pedigree entries omitted`, ); } return lines; } /** * Print AI-BOM pedigree lines. * * @param {Object} bomJson CycloneDX BOM JSON object */ export function printAiBomPedigree(bomJson) { const lines = buildAiBomPedigreeLines(bomJson); if (lines.length) { console.log(lines.join("\n")); } } /** * Build AI-BOM model variant lines for REPL inspection. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {string[]} formatted variant lines */ export function buildAiBomVariantLines(bomJson) { const variantComponents = getAiInventoryComponents(bomJson).filter( (component) => getAiVariantValues(component).length, ); if (!variantComponents.length) { return []; } const lines = ["", `AI variant view: ${variantComponents.length} model(s)`]; for (const component of variantComponents.slice(0, 10)) { const variants = getAiVariantValues(component); lines.push( `${formatPedigreeNodeLabel(component)} — ${variants.join(", ")}`, ); if (component?.pedigree?.notes) { lines.push(`${SYMBOLS_ANSI.INDENT}ℹ ${component.pedigree.notes}`); } } if (variantComponents.length > 10) { lines.push( `… ${variantComponents.length - 10} more variant entries omitted`, ); } return lines; } /** * Print AI-BOM model variant lines. * * @param {Object} bomJson CycloneDX BOM JSON object */ export function printAiBomVariants(bomJson) { const lines = buildAiBomVariantLines(bomJson); if (lines.length) { console.log(lines.join("\n")); } } /** * Build AI-BOM dataset usage lines for REPL inspection. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {string[]} formatted dataset lines */ export function buildAiBomDatasetLines(bomJson) { const datasetComponents = getAiInventoryComponents(bomJson).filter( (component) => getAiDatasetNames(component).length, ); if (!datasetComponents.length) { return []; } const lines = ["", `AI dataset view: ${datasetComponents.length} model(s)`]; for (const component of datasetComponents.slice(0, 10)) { lines.push( `${formatPedigreeNodeLabel(component)} — ${formatLimitedList( getAiDatasetNames(component), 4, )}`, ); } if (datasetComponents.length > 10) { lines.push( `… ${datasetComponents.length - 10} more dataset entries omitted`, ); } return lines; } /** * Print AI-BOM dataset usage lines. * * @param {Object} bomJson CycloneDX BOM JSON object */ export function printAiBomDatasets(bomJson) { const lines = buildAiBomDatasetLines(bomJson); if (lines.length) { console.log(lines.join("\n")); } } /** * Builds the summary and provenance lines printed after the component table. * * @param {Object} bomJson CycloneDX BOM JSON object * @param {string[]|undefined} filterTypes Optional list of component types to include * @param {string|undefined} summaryText Optional summary message to print after the table * @param {number} displayedProvenanceCount Number of displayed components with registry provenance * @returns {string[]} Summary lines to print */ export const buildTableSummaryLines = ( bomJson, filterTypes, summaryText, displayedProvenanceCount = 0, ) => { const summaryLines = []; if (summaryText) { summaryLines.push(summaryText); } else if (!filterTypes && isHbomLikeBom(bomJson)) { const hbomSummary = getHbomSummary(bomJson); summaryLines.push( `HBOM includes ${hbomSummary.componentCount} hardware component(s) across ${hbomSummary.hardwareClassCount} hardware class(es)`, ); if (hbomSummary.hardwareClassCounts.length) { summaryLines.push( `Top hardware classes: ${formatHbomHardwareClassSummary(hbomSummary.hardwareClassCounts)}`, ); } if (hbomSummary.collectorProfile) { summaryLines.push( `Collector profile: ${hbomSummary.collectorProfile}; command evidence: ${hbomSummary.evidenceCommandCount}; observed files: ${hbomSummary.evidenceFileCount}`, ); } if (hbomSummary.commandDiagnosticCount) { const diagnosticDetails = []; if (hbomSummary.missingCommandCount) { diagnosticDetails.push( `missing commands: ${hbomSummary.missingCommandCount}`, ); } if (hbomSummary.permissionDeniedCount) { diagnosticDetails.push( `permission denied: ${hbomSummary.permissionDeniedCount}`, ); } if (hbomSummary.partialSupportCount) { diagnosticDetails.push( `partial support: ${hbomSummary.partialSupportCount}`, ); } if (hbomSummary.timeoutCount) { diagnosticDetails.push(`timeouts: ${hbomSummary.timeoutCount}`); } if (hbomSummary.commandErrorCount) { diagnosticDetails.push( `other command errors: ${hbomSummary.commandErrorCount}`, ); } summaryLines.push( `Collector diagnostics: ${hbomSummary.commandDiagnosticCount} issue(s)${diagnosticDetails.length ? `; ${diagnosticDetails.join(", ")}` : ""}`, ); if (hbomSummary.requiresPrivilegedEnrichment) { summaryLines.push( "Permission-sensitive enrichments were skipped or blocked. Re-run with --privileged where policy allows.", ); } } if (isMergedHostViewBom(bomJson)) { const hostViewSummary = getHostViewSummary(bomJson); summaryLines.push( `Host topology view: ${hostViewSummary.runtimeComponentCount} runtime component(s), ${hostViewSummary.topologyLinkCount} strict host/runtime topology link(s), ${hostViewSummary.linkedHardwareComponentCount} linked hardware component(s)`, ); if (hostViewSummary.linkedRuntimeCategories.length) { summaryLines.push( `Linked runtime categories: ${hostViewSummary.linkedRuntimeCategories.join(", ")}`, ); } } } else if (!filterTypes) { summaryLines.push( `BOM includes ${bomJson?.components?.length || 0} components and ${ bomJson?.dependencies?.length || 0 } dependencies`, ); } else { summaryLines.push( `Components filtered based on type: ${filterTypes.join(", ")}`, ); } const instrumentedCount = bomJson?.components?.filter((c) => c.evidence?.identity?.some((id) => id.methods?.some((m) => m.technique === "instrumentation"), ), ).length; if (instrumentedCount > 0) { summaryLines.push( `Instrumented components: ${instrumentedCount} dynamic librar${instrumentedCount === 1 ? "y" : "ies"} tracked via process tracing.`, ); } if (displayedProvenanceCount > 0) { summaryLines.push( `${displayedProvenanceCount} component(s) include registry provenance or trusted publishing metadata.`, ); } return summaryLines; }; const HBOM_COLUMN_PRIORITY = Object.freeze([ ["cdx:hbom:status", "status"], ["cdx:hbom:connected", "connected"], ["cdx:hbom:connectionState", "connectionState"], ["cdx:hbom:securityMode", "securityMode"], ["cdx:hbom:health", "health"], ["cdx:hbom:smartStatus", "smartStatus"], ["cdx:hbom:powerSource", "powerSource"], ["cdx:hbom:maximumCapacity", "maximumCapacity"], ["cdx:hbom:chargePercent", "chargePercent"], ["cdx:hbom:capacity", "capacity"], ["cdx:hbom:size", "size"], ["cdx:hbom:sizeBytes", "sizeBytes"], ["cdx:hbom:linkRateMbps", "linkRateMbps"], ["cdx:hbom:speedMbps", "speedMbps"], ["cdx:hbom:resolution", "resolution"], ["cdx:hbom:transport", "transport"], ["cdx:hbom:connectionType", "connectionType"], ["cdx:hbom:firmwareVersion", "firmwareVersion"], ["cdx:hbom:driver", "driver"], ["cdx:hbom:channel", "channel"], ["cdx:hbom:phyMode", "phyMode"], ["cdx:hbom:temperatureCelsius", "temperatureCelsius"], ["cdx:hostview:runtimeAddressCount", "runtimeAddrs"], ["cdx:hostview:kernel_modules:count", "kernelMods"], ["cdx:hostview:mount_hardening:count", "runtimeMounts"], ["cdx:hostview:runtime-storage:count", "runtimeStorage"], ["cdx:hostview:linkedRuntimeCategoryCount", "runtimeLinks"], ]); const HBOM_CLASS_PROPERTY_PRIORITY = Object.freeze({ "audio-device": Object.freeze([ ["cdx:hbom:transport", "transport"], ["cdx:hbom:defaultOutput", "defaultOutput"], ["cdx:hbom:defaultInput", "defaultInput"], ["cdx:hbom:sampleRate", "sampleRate"], ]), bus: Object.freeze([ ["cdx:hbom:speed", "speed"], ["cdx:hbom:linkStatus", "linkStatus"], ["cdx:hbom:receptacleStatus", "receptacleStatus"], ]), camera: Object.freeze([ ["cdx:hbom:isVirtual", "virtual"], ["cdx:hbom:cameraModelId", "modelId"], ]), "bluetooth-controller": Object.freeze([ ["cdx:hbom:state", "state"], ["cdx:hbom:transport", "transport"], ["cdx:hbom:firmwareVersion", "firmware"], ]), "bluetooth-device": Object.freeze([ ["cdx:hbom:connectionState", "connection"], ["cdx:hbom:rssi", "rssi"], ["cdx:hbom:firmwareVersion", "firmware"], ["cdx:hbom:minorType", "minorType"], ]), display: Object.freeze([ ["cdx:hbom:resolution", "resolution"], ["cdx:hbom:connectionType", "connection"], ["cdx:hbom:vendorId", "vendorId"], ["cdx:hbom:productId", "productId"], ]), memory: Object.freeze([ ["cdx:hbom:size", "size"], ["cdx:hbom:sizeBytes", "sizeBytes"], ["cdx:hbom:memoryOnlineSize", "onlineSize"], ["cdx:hbom:addressSizes", "addressSizes"], ]), "network-interface": Object.freeze([ ["cdx:hbom:status", "status"], ["cdx:hbom:speedMbps", "speedMbps"], ["cdx:hbom:duplex", "duplex"], ["cdx:hbom:driver", "driver"], ["cdx:hostview:runtimeAddressCount", "runtimeAddrs"], ["cdx:hostview:kernel_modules:count", "kernelMods"], ]), power: Object.freeze([ ["cdx:hbom:health", "health"], ["cdx:hbom:chargePercent", "charge%"], ["cdx:hbom:maximumCapacity", "maxCapacity"], ["cdx:hbom:cycleCount", "cycles"], ["cdx:hbom:powerSource", "source"], ]), "power-adapter": Object.freeze([ ["cdx:hbom:connected", "connected"], ["cdx:hbom:watts", "watts"], ["cdx:hbom:isCharging", "charging"], ]), processor: Object.freeze([ ["cdx:hbom:coreCount", "cores"], ["cdx:hbom:logicalCpuCount", "logical"], ["cdx:hbom:physicalCpuCount", "physical"], ]), storage: Object.freeze([ ["cdx:hbom:capacity", "capacity"], ["cdx:hbom:smartStatus", "smart"], ["cdx:hbom:wearPercentageUsed", "wearUsed"], ["cdx:hbom:transport", "transport"], ["cdx:hbom:firmwareVersion", "firmware"], ["cdx:hostview:mount_hardening:count", "runtimeMounts"], ["cdx:hostview:runtime-storage:count", "runtimeStorage"], ]), "storage-volume": Object.freeze([ ["cdx:hbom:size", "size"], ["cdx:hbom:capacity", "capacity"], ["cdx:hbom:fileVault", "fileVault"], ["cdx:hbom:isEncrypted", "encrypted"], ["cdx:hbom:isRemovable", "removable"], ["cdx:hostview:mount_hardening:count", "runtimeMounts"], ["cdx:hostview:runtime-storage:count", "runtimeStorage"], ]), sensor: Object.freeze([ ["cdx:hbom:temperatureCelsius", "tempC"], ["cdx:hbom:fanCount", "fanCount"], ]), "thermal-zone": Object.freeze([ ["cdx:hbom:temperatureCelsius", "tempC"], ["cdx:hbom:fanCount", "fanCount"], ]), "wireless-adapter": Object.freeze([ ["cdx:hbom:connected", "connected"], ["cdx:hbom:securityMode", "security"], ["cdx:hbom:linkRateMbps", "linkMbps"], ["cdx:hbom:channel", "channel"], ["cdx:hbom:phyMode", "phy"], ["cdx:hostview:runtimeAddressCount", "runtimeAddrs"], ]), }); function formatHbomKeyProperties(component) { const hardwareClass = getPropertyValue(component, "cdx:hbom:hardwareClass"); const classSpecificPriority = HBOM_CLASS_PROPERTY_PRIORITY[hardwareClass] || []; const details = []; const seenPropertyNames = new Set(); for (const [propertyName, label] of [ ...classSpecificPriority, ...HBOM_COLUMN_PRIORITY, ]) { if (seenPropertyNames.has(propertyName)) { continue; } seenPropertyNames.add(propertyName); const value = getPropertyValue(component, propertyName); if (value === undefined || value === null || value === "") { continue; } details.push(`${label}=${value}`); if (details.length >= 3) { break; } } return details.join(", "); } function printHBOMTable(bomJson, filterTypes, highlight, summaryText) { const config = { columnDefault: { width: 28, }, columnCount: 5, columns: [ { width: 22 }, { width: 32 }, { width: 24 }, { width: 52 }, { width: 24 }, ], }; const stream = createStream(config); stream.write([ "Hardware Class", "Name", "Manufacturer / Version", "Key Properties", "Tags", ]); for (const comp of bomJson.components) { if (filterTypes && !filterTypes.includes(comp.type)) { continue; } const manufacturerOrVersion = [comp.manufacturer?.name, comp.version] .filter(Boolean) .join(" / "); stream.write([ highlightStr( getPropertyValue(comp, "cdx:hbom:hardwareClass") || comp.type || "", highlight, ), formatComponentName(comp, highlight), highlightStr(manufacturerOrVersion, highlight), highlightStr(formatHbomKeyProperties(comp), highlight), (comp.tags || []).join(", "), ]); } stream.end(); console.log(); for (const line of buildTableSummaryLines( bomJson, filterTypes, summaryText, 0, )) { console.log(line); } if (!filterTypes?.length) { printAiBomInsights(bomJson); } } /** * Builds legend lines for dependency tree marker icons. * * @param {string[]} treeGraphics Dependency tree lines * @returns {string[]} Legend lines to print after the tree output */ export const buildDependencyTreeLegendLines = (treeGraphics) => { const legendLines = []; if (treeGraphics.some((line) => line.includes(`${REPEATED_NODE_ICON} `))) { legendLines.push(`${REPEATED_NODE_ICON} = already shown`); } if (treeGraphics.some((line) => line.includes(`${CYCLE_NODE_ICON} `))) { legendLines.push(`${CYCLE_NODE_ICON} = cycle`); } if (!legendLines.length) { return legendLines; } return [`Legend: ${legendLines.join("; ")}`]; }; export function buildActivitySummaryPayload(activities, dryRunMode = isDryRun) { const completedCount = activities.filter( ({ status }) => status === "completed", ).length; const blockedCount = activities.filter( ({ status }) => status === "blocked", ).length; const failedCount = activities.filter( ({ status }) => status === "failed", ).length; return { activities, mode: dryRunMode ? "dry-run" : "debug", summary: { blocked: blockedCount, completed: completedCount, failed: failedCount, total: activities.length, }, }; } export function serializeActivitySummary( activities, reportType = "json", dryRunMode = isDryRun, ) { const activitySummaryPayload = buildActivitySummaryPayload( activities, dryRunMode, ); if (reportType === "json") { return [JSON.stringify(activitySummaryPayload, null, 2)]; } if (reportType === "jsonl") { return [ JSON.stringify({ mode: activitySummaryPayload.mode, recordType: "summary", ...activitySummaryPayload.summary, }), ...activities.map((activity) => JSON.stringify({ recordType: "activity", ...activity, }), ), ]; } return []; } const splitCommaSeparatedActivityEntries = (value) => value .split(",") .map((entry) => entry.trim()) .filter(Boolean); const activityPathDepth = (entry) => entry.split(PATH_SEPARATOR_REGEX).filter(Boolean).length; const sortActivityTargetEntries = (entries) => [...entries].sort((left, right) => { const depthDiff = activityPathDepth(left) - activityPathDepth(right); if (depthDiff !== 0) { return depthDiff; } const lengthDiff = left.length - right.length; if (lengthDiff !== 0) { return lengthDiff; } return left.localeCompare(right); }); const isLikelyActivityPathList = (entries) => entries.length > 1 && entries.every( (entry) => PATH_SEPARATOR_REGEX.test(entry) && !entry.includes("://"), ); /** * Prints the BOM components as a streaming table to the console. * Delegates to {@link printOSTable} automatically when the BOM metadata indicates * an operating-system or platform component type. * * @param {Object} bomJson CycloneDX BOM JSON object * @param {string[]} [filterTypes] Optional list of component types to include; all types shown when omitted * @param {string} [highlight] Optional string to highlight in the output * @param {string} [summaryText] Optional summary message to print after the table * @returns {void} */ export function printTable( bomJson, filterTypes = undefined, highlight = undefined, summaryText = undefined, ) { if (!bomJson?.components) { return; } if ( bomJson.metadata?.component && ["operating-system", "platform"].includes(bomJson.metadata.component.type) ) { return printOSTable(bomJson); } if (isHbomLikeBom(bomJson) && !filterTypes?.includes("cryptographic-asset")) { return printHBOMTable(bomJson, filterTypes, highlight, summaryText); } const config = { columnDefault: { width: 30, }, columnCount: 5, columns: [ { width: 25 }, { width: 35 }, { width: 25, alignment: "right" }, { width: 15 }, { width: 25 }, ], }; const stream = createStream(config); let displayedProvenanceCount = 0; stream.write([ filterTypes?.includes("cryptographic-asset") ? "Asset Type / Group" : "Group", "Name", filterTypes?.includes("cryptographic-asset") ? "Version / oid" : "Version", "Scope", "Tags", ]); for (const comp of bomJson.components) { if (filterTypes && !filterTypes.includes(comp.type)) { continue; } if (comp.type === "cryptographic-asset") { stream.write([ comp.cryptoProperties?.assetType || comp.group || "", comp.name, `\x1b[1;35m${comp.cryptoProperties?.oid || ""}\x1b[0m`, comp.scope || "", (comp.tags || []).join(", "), ]); } else { if (hasComponentRegistryProvenance(comp)) { displayedProvenanceCount += 1; } stream.write([ highlightStr(comp.group || "", highlight), formatComponentName(comp, highlight), `\x1b[1;35m${comp.version || ""}\x1b[0m`, comp.scope || "", (comp.tags || []).join(", "), ]); } } stream.end(); console.log(); for (const line of buildTableSummaryLines( bomJson, filterTypes, summaryText, displayedProvenanceCount, )) { console.log(line); } } const formatProps = (props) => { const retList = []; for (const p of props) { retList.push(`\x1b[0;32m${p.name}\x1b[0m ${p.value}`); } return retList.join("\n"); }; /** * Prints OS package components from the BOM as a formatted streaming table. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printOSTable(bomJson) { const config = { columnDefault: { width: 50, }, columnCount: 4, columns: [{ width: 20 }, { width: 40 }, { width: 50 }, { width: 25 }], }; const stream = createStream(config); stream.write(["Type", "Title", "Properties", "Tags"]); for (const comp of bomJson.components) { stream.write([ comp.type, `\x1b[1;35m${comp.name.replace(/\+/g, " ").replace(/--/g, "::")}\x1b[0m`, formatProps(comp.properties || []), (comp.tags || []).join(", "), ]); } stream.end(); console.log(); } /** * Prints the services listed in the BOM as a formatted table. * Includes endpoint URLs, authentication flag, and cross-trust-boundary flag. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printServices(bomJson) { const data = [["Name", "Endpoints", "Authenticated", "X Trust Boundary"]]; if (!bomJson?.services) { return; } for (const aservice of bomJson.services) { data.push([ aservice.name || "", aservice.endpoints ? aservice.endpoints.join("\n") : "", aservice.authenticated ? "\x1b[1;35mYes\x1b[0m" : "", aservice.xTrustBoundary ? "\x1b[1;35mYes\x1b[0m" : "", ]); } const config = { header: { alignment: "center", content: "List of Services\nGenerated with \u2665 by cdxgen", }, }; if (data.length > 1) { console.log(table(data, config)); } } /** * Prints the formulation components from the BOM as a formatted table. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printFormulation(bomJson) { const data = [["Type", "Name", "Version"]]; if (!bomJson?.formulation) { return; } for (const aform of bomJson.formulation) { if (aform.components) { for (const acomp of aform.components) { data.push([acomp.type || "", acomp.name || "", acomp.version || ""]); } } } const config = { header: { alignment: "center", content: "Formulation\nGenerated with \u2665 by cdxgen", }, }; if (data.length > 1) { console.log(table(data, config)); } } const locationComparator = (a, b) => { if (a && b && a.includes("#") && b.includes("#")) { const tmpA = a.split("#"); const tmpB = b.split("#"); if (tmpA.length === 2 && tmpB.length === 2) { if (tmpA[0] === tmpB[0]) { return tmpA[1] - tmpB[1]; } } } if (a && b) { const tmpA = a.match(/^(.*):(\d+)(?::(\d+))?$/); const tmpB = b.match(/^(.*):(\d+)(?::(\d+))?$/); if (tmpA && tmpB && tmpA[1] === tmpB[1]) { const lineComparison = Number(tmpA[2]) - Number(tmpB[2]); if (lineComparison !== 0) { return lineComparison; } return Number(tmpA[3] || 0) - Number(tmpB[3] || 0); } } return a.localeCompare(b); }; /** * Prints component evidence occurrences (file locations) as a streaming table. * Only components that have `evidence.occurrences` are included. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printOccurrences(bomJson) { if (!bomJson?.components) { return; } const data = ["Group", "Name", "Version", "Occurrences"]; const config = { columnDefault: { width: 30, }, columnCount: 4, columns: [ { width: 30 }, { width: 30 }, { width: 25, alignment: "right" }, { width: 80 }, ], }; const stream = createStream(config); // Create stream with the config const header = "Component Evidence\nGenerated with \u2665 by cdxgen"; console.log(header); stream.write(data); // Stream the components for (const comp of bomJson.components) { if (comp.evidence?.occurrences) { const row = [ comp.group || "", comp.name, comp.version || "", comp.evidence.occurrences .map((occurrence) => formatOccurrenceEvidence(occurrence)) .sort(locationComparator) .join("\n"), ]; stream.write(row); } } stream.end(); console.log(); } /** * Prints the call stack evidence for each component in the BOM as a formatted table. * Only components that have `evidence.callstack.frames` are included. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printCallStack(bomJson) { const data = [["Group", "Name", "Version", "Call Stack"]]; if (!bomJson?.components) { return; } for (const comp of bomJson.components) { if (!comp.evidence?.callstack?.frames) { continue; } const frames = []; const seenFrames = new Set(); for (const frame of comp.evidence.callstack.frames) { const location = `${frame.fullFilename}${frame.line ? `#${frame.line}` : ""}`; if (!frame.fullFilename || seenFrames.has(location)) { continue; } seenFrames.add(location); frames.push(location); } const frameDisplay = [frames[0]]; if (frames.length > 1) { for (let i = 1; i < frames.length - 1; i++) { frameDisplay.push(`${SYMBOLS_ANSI.BRANCH} ${frames[i]}`); } frameDisplay.push( `${SYMBOLS_ANSI.LAST_BRANCH} ${frames[frames.length - 1]}`, ); } data.push([ comp.group || "", comp.name, comp.version || "", frameDisplay.join("\n"), ]); } const config = { header: { alignment: "center", content: "Component Call Stack Evidence\nGenerated with \u2665 by cdxgen", }, }; if (data.length > 1) { console.log(table(data, config)); } } /** * Prints the dependency tree from the BOM as an ASCII tree diagram. * Uses the `table` library for small trees and plain console output for larger ones. * * @param {Object} bomJson CycloneDX BOM JSON object containing a `dependencies` array * @param {string} [mode="dependsOn"] Dependency relation to traverse (`"dependsOn"` or `"provides"`) * @param {string} [highlight] Optional string to highlight in the tree output * @returns {void} */ export function printDependencyTree( bomJson, mode = "dependsOn", highlight = undefined, ) { const dependencies = bomJson.dependencies || []; if (!dependencies.length) { return; } const treeGraphics = buildDependencyTreeLines(dependencies, mode); const legendLines = buildDependencyTreeLegendLines(treeGraphics); // table library is too slow for display large lists. // Fixes #491 if (treeGraphics.length && treeGraphics.length < 100) { const treeType = mode && mode === "provides" ? "Crypto Implementation" : "Dependency"; const config = { header: { alignment: "center", content: `${treeType} Tree\nGenerated with \u2665 by cdxgen`, }, }; console.log( table([[highlightStr(treeGraphics.join("\n"), highlight)]], config), ); } else if (treeGraphics.length < 500) { // https://github.com/nodejs/node/issues/35973 console.log(highlightStr(treeGraphics.join("\n"), highlight)); } else { console.log(highlightStr(treeGraphics.slice(0, 500).join("\n"), highlight)); } if (legendLines.length) { console.log(legendLines.join("\n")); } } const dependencyTreePrefix = (ancestorContinuations, isLast) => { let prefix = ""; for (const hasNextSibling of ancestorContinuations) { prefix = `${prefix}${hasNextSibling ? "│ " : " "}`; } return `${prefix}${isLast ? SYMBOLS_ANSI.LAST_BRANCH : SYMBOLS_ANSI.BRANCH}`; }; const dependencyTreeRefKey = (ref) => ref.toLowerCase(); const compareDependencyTreeNodes = (a, b) => { if (a.order !== b.order) { return a.order - b.order; } return a.ref.localeCompare(b.ref); }; const createDependencyTreeGraph = (dependencies, mode) => { const nodes = new Map(); let nextOrder = 0; const ensureNode = (ref) => { if (!ref) { return undefined; } const refKey = dependencyTreeRefKey(ref); if (!nodes.has(refKey)) { nodes.set(refKey, { childKeys: new Set(), children: [], order: nextOrder, parents: new Set(), ref, }); nextOrder += 1; } return nodes.get(refKey); }; for (const dependency of dependencies) { const rawChildren = Array.isArray(dependency?.[mode]) ? dependency[mode].filter(Boolean) : []; const childRefs = Array.from(new Set(rawChildren)).sort((a, b) => a.localeCompare(b), ); let parentNode; if (mode !== "provides" || childRefs.length) { parentNode = ensureNode(dependency.ref); } if (!childRefs.length) { continue; } parentNode = parentNode || ensureNode(dependency.ref); for (const childRef of childRefs) { const childNode = ensureNode(childRef); if (!parentNode || !childNode) { continue; } parentNode.childKeys.add(dependencyTreeRefKey(childRef)); childNode.parents.add(dependencyTreeRefKey(parentNode.ref)); } } for (const node of nodes.values()) { node.children = Array.from(node.childKeys).sort((a, b) => compareDependencyTreeNodes(nodes.get(a), nodes.get(b)), ); } return nodes; }; const renderDependencyTreeNode = ( nodes, nodeKey, depth, ancestorContinuations, isLast, renderedNodes, treeGraphics, visitingNodes = new Set(), ) => { const node = nodes.get(nodeKey); if (!node || renderedNodes.has(nodeKey)) { return; } const prefix = depth === 0 ? SYMBOLS_ANSI.EMPTY : dependencyTreePrefix(ancestorContinuations, isLast); treeGraphics.push(`${prefix}${node.ref}`); renderedNodes.add(nodeKey); if (depth >= MAX_TREE_DEPTH) { return; } const nextVisitingNodes = new Set(visitingNodes); nextVisitingNodes.add(nodeKey); const nextAncestorContinuations = depth === 0 ? ancestorContinuations : [...ancestorContinuations, !isLast]; const childEntries = []; for (const childKey of node.children) { if (nextVisitingNodes.has(childKey)) { childEntries.push({ childKey, isCycle: true }); continue; } if (renderedNodes.has(childKey)) { childEntries.push({ childKey, isRepeated: true }); continue; } childEntries.push({ childKey, isCycle: false }); } for (let i = 0; i < childEntries.length; i++) { const childEntry = childEntries[i]; const childNode = nodes.get(childEntry.childKey); const childIsLast = i === childEntries.length - 1; if (!childNode) { continue; } if (childEntry.isCycle) { treeGraphics.push( `${dependencyTreePrefix(nextAncestorContinuations, childIsLast)}${CYCLE_NODE_ICON} ${childNode.ref}`, ); continue; } if (childEntry.isRepeated) { treeGraphics.push( `${dependencyTreePrefix(nextAncestorContinuations, childIsLast)}${REPEATED_NODE_ICON} ${childNode.ref}`, ); continue; } renderDependencyTreeNode( nodes, childEntry.childKey, depth + 1, nextAncestorContinuations, childIsLast, renderedNodes, treeGraphics, nextVisitingNodes, ); } }; /** * Builds printable dependency tree lines from a BOM dependency graph. * Produces a spanning forest so shared children are rendered once, while * disconnected or cyclic subgraphs are still emitted as dangling trees. * * @param {Object[]} dependencies CycloneDX dependency objects * @param {string} [mode="dependsOn"] Dependency relation to traverse * @returns {string[]} Dependency tree lines ready for console rendering */ export const buildDependencyTreeLines = (dependencies, mode = "dependsOn") => { const nodes = createDependencyTreeGraph(dependencies, mode); if (!nodes.size) { return []; } const nodeEntries = Array.from(nodes.entries()).sort(([, a], [, b]) => compareDependencyTreeNodes(a, b), ); const rootKeys = nodeEntries .filter(([, node]) => !node.parents.size) .map(([nodeKey]) => nodeKey); const renderedNodes = new Set(); const treeGraphics = []; for (let i = 0; i < rootKeys.length; i++) { renderDependencyTreeNode( nodes, rootKeys[i], 0, [], i === rootKeys.length - 1, renderedNodes, treeGraphics, ); } const danglingNodeKeys = nodeEntries .map(([nodeKey]) => nodeKey) .filter((nodeKey) => !renderedNodes.has(nodeKey)); for (let i = 0; i < danglingNodeKeys.length; i++) { renderDependencyTreeNode( nodes, danglingNodeKeys[i], 0, [], i === danglingNodeKeys.length - 1, renderedNodes, treeGraphics, ); } return treeGraphics; }; /** * Prints a table of reachable components derived from a reachability slices file. * Aggregates per-purl reachable-flow counts and sorts them descending. * * @param {Object} sliceArtefacts Slice artefact paths, must include `reachablesSlicesFile` * @returns {void} */ export function printReachables(sliceArtefacts) { const reachablesSlicesFile = sliceArtefacts.reachablesSlicesFile; if (!reachablesSlicesFile || !safeExistsSync(reachablesSlicesFile)) { return; } const purlCounts = {}; const reachablesSlices = JSON.parse( readFileSync(reachablesSlicesFile, "utf-8"), ); const rflows = Array.isArray(reachablesSlices) ? reachablesSlices : reachablesSlices.reachables || []; for (const areachable of rflows) { const purls = areachable.purls || []; for (const apurl of purls) { purlCounts[apurl] = (purlCounts[apurl] || 0) + 1; } } const sortedPurls = Object.fromEntries( Object.entries(purlCounts).sort(([, a], [, b]) => b - a), ); const data = [["Package URL", "Reachable Flows"]]; for (const apurl of Object.keys(sortedPurls)) { data.push([apurl, `${sortedPurls[apurl]}`]); } const config = { header: { alignment: "center", content: "Reachable Components\nGenerated with \u2665 by cdxgen", }, }; if (data.length > 1) { console.log(table(data, config)); } } /** * Prints a formatted table of CycloneDX vulnerability objects. * * @param {Object[]} vulnerabilities Array of CycloneDX vulnerability objects * @returns {void} */ export function printVulnerabilities(vulnerabilities) { if (!vulnerabilities) { return; } const data = [["Ref", "Ratings", "State", "Justification"]]; for (const avuln of vulnerabilities) { const arow = [ avuln["bom-ref"], `${avuln?.ratings .map((r) => r?.severity?.toUpperCase()) .join("\n")}\n${avuln?.ratings.map((r) => r?.score).join("\n")}`, avuln?.analysis?.state || "", avuln?.analysis?.justification || "", ]; data.push(arow); } const config = { header: { alignment: "center", content: "Vulnerabilities\nGenerated with \u2665 by cdxgen", }, }; if (data.length > 1) { console.log(table(data, config)); } console.log(`${vulnerabilities.length} vulnerabilities found.`); } /** * Prints an OWASP donation banner when running in a CI environment. * The banner is suppressed when `options.noBanner` is set or the repository * belongs to the cdxgen project itself. * * @param {Object} options CLI options * @returns {void} */ export function printSponsorBanner(options) { if ( process?.env?.CI && !options.noBanner && !process.env?.GITHUB_REPOSITORY?.toLowerCase().startsWith("cdxgen") ) { const config = { header: { alignment: "center", content: "\u00A4 Donate to the OWASP Foundation", }, }; let message = "OWASP foundation relies on donations to fund our projects.\nDonation link: https://owasp.org/donate/?reponame=www-project-cdxgen&title=OWASP+cdxgen"; if (options.serverUrl && options.apiKey) { message = `${message}\nDependency Track: https://owasp.org/donate/?reponame=www-project-dependency-track&title=OWASP+Dependency-Track`; } const data = [[message]]; console.log(table(data, config)); } } /** * Prints a BOM summary table including generator tool names, component package types, * and component namespaces extracted from BOM metadata properties. * * @param {Object} bomJson CycloneDX BOM JSON object * @returns {void} */ export function printSummary(bomJson) { const config = { header: { alignment: "center", content: "BOM summary", }, columns: [{ wrapWord: true, width: 100 }], }; const metadataProperties = bomJson?.metadata?.properties; if (!metadataProperties) { return; } let message = ""; let bomPkgTypes = []; let bomPkgNamespaces = []; // Print any annotations found const annotations = bomJson?.annotations || []; if (annotations.length) { for (const annot of annotations) { message = `${message}\n${annot.text}`; } } const tools = bomJson?.metadata?.tools?.components; if (tools) { message = `${message}\n\n** Generator Tools **`; for (const atool of tools) { if (atool.name && atool.version) { message = `${message}\n${atool.name} (${atool.version})`; } } } for (const aprop of metadataProperties) { if (aprop.name === "cdx:bom:componentTypes") { bomPkgTypes = aprop?.value.split("\\n"); } if (aprop.name === "cdx:bom:componentNamespaces") { bomPkgNamespaces = aprop?.value.split("\\n"); } } if (!bomPkgTypes.length && !bomPkgNamespaces.length) { return; } message = `${message}\n\n** Package Types (${bomPkgTypes.length}) **\n${bomPkgTypes.join("\n")}`; if (bomPkgNamespaces.length) { message = `${message}\n\n** Namespaces (${bomPkgNamespaces.length}) **\n${bomPkgNamespaces.join("\n")}`; } const aiInsightLines = buildAiBomInsightLines(bomJson); if (aiInsightLines.length) { message = `${message}\n\n** AI-BOM **\n${aiInsightLines.join("\n")}`; } const data = [[message]]; console.log(table(data, config)); } export function printActivitySummary(reportType = undefined) { const activities = getRecordedActivities(); if (!activities.length) { return; } const activitySummaryPayload = buildActivitySummaryPayload(activities); const completedCount = activitySummaryPayload.summary.completed; const blockedCount = activitySummaryPayload.summary.blocked; const failedCount = activitySummaryPayload.summary.failed; const formatStatus = (status) => { if (status === "completed") { return "completed"; } if (status === "blocked") { return "blocked"; } if (status === "failed") { return "failed"; } return status || ""; }; if (reportType === "json") { for (const line of serializeActivitySummary(activities, reportType)) { console.log(line); } return; } if (reportType === "jsonl") { for (const line of serializeActivitySummary(activities, reportType)) { console.log(line); } return; } const formatActivityTarget = (activity) => { const target = activity?.target; const suspiciousPrefix = activity?.risk === "shell-metacharacters" ? `${SUSPICIOUS_SHELL_PATH_LABEL}\n` : ""; if (typeof target !== "string" || !target.includes(",")) { return `${suspiciousPrefix}${target || ""}`; } const targetEntries = splitCommaSeparatedActivityEntries(target); if (isLikelyActivityPathList(targetEntries)) { return `${suspiciousPrefix}${sortActivityTargetEntries(targetEntries).join("\n")}`; } if (!(target.includes(":") || target.includes("="))) { return `${suspiciousPrefix}${target || ""}`; } const targetSegments = target.split(/,\s*(?=[A-Za-z][\w-]*\s*[:=])/); let didFormat = false; const renderedSegments = targetSegments.map((segment) => { const segmentMatch = segment.match(/^([A-Za-z][\w-]*)\s*([:=])\s*(.*)$/); if (!segmentMatch) { return segment; } const [, key, separator, value] = segmentMatch; if (!MULTIVALUE_ACTIVITY_TARGET_KEYS.has(key) || !value.includes(",")) { return segment; } didFormat = true; return `${key}${separator}\n${sortActivityTargetEntries( splitCommaSeparatedActivityEntries(value), ) .map((entry) => `- ${entry}`) .join("\n")}`; }); return `${suspiciousPrefix}${didFormat ? renderedSegments.join("\n") : target}`; }; const formatActivityType = (type) => { if (typeof type !== "string" || !type.includes(",")) { return type || ""; } return splitCommaSeparatedActivityEntries(type) .sort((left, right) => left.localeCompare(right)) .join("\n"); }; const data = [ [ "Identifier", "Type", "Package Type", "Activity", "Target", "Outcome / Why", ], ]; for (const activity of activities) { data.push([ activity.identifier, formatActivityType(activity.projectType), activity.packageType || "", activity.kind || "", formatActivityTarget(activity), activity.reason ? `${formatStatus(activity.status)}\n${activity.reason}`.trim() : formatStatus(activity.status), ]); } const config = { header: { alignment: "center", content: `${ isDryRun ? "cdxgen dry-run activity summary" : "cdxgen debug activity summary"