@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,651 lines (1,595 loc) • 64.7 kB
JavaScript
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"