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