UNPKG

@cyclonedx/cdxgen

Version:

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

1,635 lines (1,583 loc) 53.8 kB
import { readFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; 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; }; /** * 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(", ")}`, ); } if (displayedProvenanceCount > 0) { summaryLines.push( `Legend: ${REGISTRY_PROVENANCE_ICON} = registry provenance or trusted publishing evidence`, ); summaryLines.push( `${REGISTRY_PROVENANCE_ICON} ${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); } } /** * 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 = Array.from( new Set( comp.evidence.callstack.frames.map( (c) => `${c.fullFilename}${c.line ? `#${c.line}` : ""}`, ), ), ).sort(locationComparator); 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 (!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 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" }\n${completedCount} completed ${blockedCount} blocked ${failedCount} failed`, }, columns: [ { width: 14 }, { width: 14 }, { width: 14 }, { width: 12 }, { width: 48, wrapWord: true }, { width: 28, wrapWord: true }, ], }; console.log(table(data, config)); } /** * @typedef {{type: string, variable: string, severity: string, message: string, mitigation: string}} EnvAuditFinding */ const summarizeEnvAuditSeverities = (envAuditFindings) => { const counts = { critical: 0, high: 0, medium: 0, low: 0 }; for (const finding of envAuditFindings) { if (counts[finding.severity] !== undefined) { counts[finding.severity] += 1; } } return ["critical", "high", "medium", "low"] .filter((severity) => counts[severity] > 0) .map((severity) => `${counts[severity]} ${severity}`) .join(" "); }; const buildEnvironmentAuditGroups = (envAuditFindings) => { const groups = new Map(); for (const finding of envAuditFindings) { const isCredentialExposure = finding.type === "credential-exposure"; const groupKey = isCredentialExposure ? "credential-exposure" : JSON.stringify([ finding.type, finding.severity, finding.message, finding.mitigation, ]); if (!groups.has(groupKey)) { groups.set(groupKey, { details: isCredentialExposure ? "Credential-like environment variables are set. Build tools or install scripts invoked during SBOM generation may read inherited environment variables." : finding.message, mitigation: isCredentialExposure ? "Unset unneeded secrets when scanning untrusted repositories. Prefer ephemeral, scoped CI credentials injected only for the step that needs them." : finding.mitigation, severity: finding.severity, title: ENV_AUDIT_TYPE_LABELS[finding.type] || toCamel(finding.type || "Finding"), variables: new Set(), }); } groups.get(groupKey).variables.add(finding.variable); } return [...groups.values()] .map((group) => ({ ...group, variables: [...group.variables].filter(Boolean).sort(), })) .sort((left, right) => { const severityDiff = (ENV_AUDIT_SEVERITY_RANK[right.severity] || 0) - (ENV_AUDIT_SEVERITY_RANK[left.severity] || 0); if (severityDiff !== 0) { return severityDiff; } return left.title.localeCompare(right.title); }); }; /** * Prints a grouped secure-mode environment audit call-out panel. * * @param {EnvAuditFinding[]} envAuditFindings Audit findings to display * @returns {void} */ export function printEnvironmentAuditFindings(envAuditFindings = []) { if (!envAuditFindings.length) { return; } const groupedFindings = buildEnvironmentAuditGroups(envAuditFindings); const severitySummary = summarizeEnvAuditSeverities(envAuditFindings); const data = [["Category", "Severity", "Variable(s)", "Details"]]; for (const finding of groupedFindings) { data.push([ finding.title, finding.severity.toUpperCase(), finding.variables.join("\n"), `${finding.details}\nMitigation: ${finding.mitigation}`, ]); } const config = { header: { alignment: "center", content: `SECURE MODE: Environment audit\n${severitySummary || `${envAuditFindings.length} finding(s)`}`, }, columns: [ { width: 22 }, { width: 10 }, { width: 24, wrapWord: true }, { width: 50, wrapWord: true }, ], columnDefault: { wrapWord: true }, }; console.log(table(data, config)); } /** * Runs the pre-generation environment audit and renders the results as formatted * tables to the console. Called when the --env-audit CLI flag is set. * * @param {string} filePath Project path being scanned * @param {Object} config Loaded .cdxgenrc / config-file values * @param {Object} options Effective CLI options * @param {EnvAuditFinding[]} envAuditFindings Audit findings to display */ export function displaySelfThreatModel( filePath, config, options, envAuditFindings, ) { const TLP = options.tlpClassification; const risks = []; let riskScore = 0; const addRisk = (level, reason, category = "configuration") => { const scores = { low: 1, medium: 3, high: 5, critical: 8 }; riskScore = Math.min(10, riskScore + scores[level]); risks.push({ level, reason, category }); }; // Config file risks if (Object.keys(config).length > 0) { addRisk( "medium", "A .cdxgenrc config file was loaded from the working directory. It may override security-relevant settings without being visible on the command line.", "configuration", ); const sensitive = ["server-url", "api-key", "include-formulation"]; for (const key of sensitive) { if (config[key] || config[toCamel(key)]) { addRisk( key === "api-key" ? "high" : "medium", `Config file sets '${key}', which affects SBOM content or remote submission behavior.`, "configuration", ); } } } // Remote submission risks if (options.serverUrl) { const isHttps = options.serverUrl.startsWith("https://"); addRisk( isHttps ? "medium" : "critical", `SBOM will be submitted to ${options.serverUrl}${!isHttps ? " over plain HTTP — contents may be intercepted or tampered in transit." : "."}`, "network", ); if (options.skipDtTlsCheck) { addRisk( "high", "TLS certificate validation is disabled for Dependency-Track uploads. SBOM contents may be intercepted or tampered in transit.", "network", ); } } // Data exposure risks if (options.includeFormulation) { addRisk( "medium", "Formulation mode is active. The SBOM will include build metadata such as git history, committer identities, and CI environment variables.", "data-exposure", ); } if (options.evidence || options.deep) { addRisk( "medium", "Evidence / deep mode will invoke build tools and parse source files to collect call graph and reachability evidence. Malicious build scripts may execute.", "data-exposure", ); } if (options.installDeps) { addRisk( "high", "Dependency auto-install is enabled. Lifecycle hooks (install scripts) from third-party packages will execute in the current environment.", "data-exposure", ); } // Output path outside the project directory if (options.output) { const resolvedOutput = path.resolve(options.output); const resolvedProject = path.resolve(filePath); if ( !resolvedOutput.startsWith(resolvedProject + path.sep) && resolvedOutput !== resolvedProject ) { addRisk( "medium", `Output path '${options.output}' resolves to '${resolvedOutput}', which is outside the project directory '${resolvedProject}'. Ensure this is intentional.`, "configuration", ); } } // Environment variable risks (config-layer only; env-audit covers the rest) if (process.env.CDXGEN_SERVER_URL) { addRisk( "low", "CDXGEN_SERVER_URL is set in the environment and will override any --server-url value.", "environment", ); } // Integrate environment audit findings if (envAuditFindings?.length) { for (const f of envAuditFindings) { const categoryMap = { "code-execution": "runtime", "debug-exposure": "runtime", "environment-variable": "environment", "network-interception": "network", "credential-exposure": "environment", "permission-misuse": "runtime", privilege: "runtime", }; addRisk( f.severity, `${f.variable}: ${f.message}`, categoryMap[f.type] || "configuration", ); } } const nodeOptions = process.env.NODE_OPTIONS || ""; const riskLevel = riskScore >= 8 ? "CRITICAL" : riskScore >= 5 ? "HIGH" : riskScore >= 3 ? "MEDIUM" : "LOW"; const riskColor = { CRITICAL: "\x1b[1;31m", HIGH: "\x1b[1;33m", MEDIUM: "\x1b[1;36m", LOW: "\x1b[1;32m", }; const reset = "\x1b[0m"; const tlpGuidance = { CLEAR: "May be shared publicly. No restrictions.", GREEN: "Limited to community/peers. Not for public posting.", AMBER: "Limited to organisation and trusted partners. Handle-in-confidence.", AMBER_AND_STRICT: "Organisation only. No external sharing.", RED: "Named recipients only. Do not forward or store beyond session.", }; const tlpValue = TLP ? `${TLP} — ${tlpGuidance[TLP]}` : "Not set — no distribution constraints recorded."; const headerData = [ ["TLP Classification", tlpValue], ["Risk Score", `${riskScore}/10`], ["Risk Level", `${riskColor[riskLevel]}${riskLevel}${reset}`], ]; const headerConfig = { header: { alignment: "center", content: "SBOM Generation Environment Assessment\nPre-generation security audit by cdxgen", }, columns: [{ width: 30, alignment: "right" }, { width: 70 }], columnDefault: { wrapWord: true }, }; console.log(table(headerData, headerConfig)); if (risks.length > 0) { const findingsData = [["#", "Severity", "Category", "Finding"]]; risks.forEach(({ level, reason, category }, i) => { const severityColor = level === "critical" ? "\x1b[1;31m" : level === "high" ? "\x1b[1;33m" : level === "medium" ? "\x1b[1;36m" : "\x1b[1;32m"; findingsData.push([ `${i + 1}`, `${severityColor}${level.toUpperCase()}${reset}`, category, reason, ]); }); const findingsConfig = { header: { alignment: "center", content: `Findings (${risks.length})`, }, columns: [ { width: 5, alignment: "right" }, { width: 12 }, { width: 17 }, { width: 66 }, ], columnDefault: { wrapWord: true }, }; console.log(table(findingsData, findingsConfig)); } else { const noFindingsData = [ [ `${riskColor[riskLevel]}✅ No risks detected in the current configuration.${reset}`, ], ]; const noFindingsConfig = { header: { alignment: "center", content: "📋 Findings" }, columns: [{ width: 100, alignment: "center" }], }; console.log(table(noFindingsData, noFindingsConfig)); } const configData = [ ["Setting", "Value"], ["Project", options.projectName || filePath], ["Type(s)", options.projectType?.join(", ") || "auto-detect"], ["Profile", options.profile || "generic"], ["Path", filePath], ["Output", options.output || "(stdout)"], ["Recursive", options.recursive