@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,543 lines (1,523 loc) • 62.2 kB
JavaScript
#!/usr/bin/env node
import { Buffer } from "node:buffer";
import crypto from "node:crypto";
import fs from "node:fs";
import http from "node:http";
import https from "node:https";
import {
basename,
dirname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import process from "node:process";
import { parse as _load } from "yaml";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { createBom, submitBom } from "../lib/cli/index.js";
import { signBom, verifyBom } from "../lib/helpers/bomSigner.js";
import { isCycloneDxBom } from "../lib/helpers/bomUtils.js";
import {
displaySelfThreatModel,
printActivitySummary,
printCallStack,
printDependencyTree,
printEnvironmentAuditFindings,
printFormulation,
printOccurrences,
printReachables,
printServices,
printSponsorBanner,
printSummary,
printTable,
} from "../lib/helpers/display.js";
import {
createOutputPlan,
getOutputDirectory,
} from "../lib/helpers/exportUtils.js";
import {
ensureNoMixedHbomProjectTypes,
ensureSupportedHbomSpecVersion,
hasHbomProjectType,
isHbomOnlyProjectTypes,
} from "../lib/helpers/hbom.js";
import { TRACE_MODE, thoughtEnd, thoughtLog } from "../lib/helpers/logger.js";
import { importProtobomModule } from "../lib/helpers/protobomLoader.js";
import {
cleanupSourceDir,
findGitRefForPurlVersion,
gitClone,
isAllowedPath,
isAllowedWinPath,
maybePurlSource,
maybeRemotePath,
PURL_REGISTRY_LOOKUP_WARNING,
resolveGitUrlFromPurl,
resolvePurlSourceDirectory,
sanitizeRemoteUrlForLogs,
validateAndRejectGitSource,
validatePurlSource,
} from "../lib/helpers/source.js";
import {
commandsExecuted,
DEBUG_MODE,
getDefaultBomAuditCategories,
getTmpDir,
isAllowedHttpHost,
isBun,
isDeno,
isDryRun,
isMac,
isNode,
isSecureMode,
isWin,
readEnvironmentVariable,
recordActivity,
recordSensitiveFileRead,
remoteHostsAccessed,
retrieveCdxgenVersion,
safeExistsSync,
safeMkdirSync,
safeWriteSync,
setActivityContext,
setDryRunMode,
shouldRunPredictiveBomAudit,
toCamel,
} from "../lib/helpers/utils.js";
import { postProcess } from "../lib/stages/postgen/postgen.js";
import { convertCycloneDxToSpdx } from "../lib/stages/postgen/spdxConverter.js";
import { auditEnvironment } from "../lib/stages/pregen/envAudit.js";
import { prepareEnv } from "../lib/stages/pregen/pregen.js";
import { validateBom, validateSpdx } from "../lib/validator/bomValidator.js";
// Support for config files
const configPaths = [
".cdxgenrc",
".cdxgen.json",
".cdxgen.yml",
".cdxgen.yaml",
];
let config = {};
for (const configPattern of configPaths) {
const configPath = join(process.cwd(), configPattern);
if (!safeExistsSync(configPath)) {
continue;
}
try {
if (configPath.endsWith(".yml") || configPath.endsWith(".yaml")) {
config = _load(fs.readFileSync(configPath, "utf-8"));
} else {
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
}
if (isSecureMode || DEBUG_MODE) {
console.log(`Config file '${configPath}' loaded successfully.`);
}
const sensitiveOptions = ["server-url", "include-formulation"];
for (const opt of sensitiveOptions) {
if (config[opt] !== undefined || config[toCamel(opt)] !== undefined) {
const foundKey = config[opt] !== undefined ? opt : toCamel(opt);
console.warn(
`SECURE MODE: Config file sets '${foundKey}'. Verify this is intentional.`,
);
}
}
} catch (_e) {
console.log("Invalid config file", configPath);
}
}
const _yargs = yargs(hideBin(process.argv));
const invokedCommandName = basename(process.argv[1] || "cdxgen").replace(
/\.(?:[cm]?js|exe)$/u,
"",
);
const args = _yargs
.env("CDXGEN")
.parserConfiguration({
"greedy-arrays": false,
"short-option-groups": false,
"dot-notation": false,
"parse-numbers": true,
"boolean-negation": true,
})
.option("output", {
alias: "o",
description: "Output file. Default bom.json",
default: "bom.json",
})
.option("evinse-output", {
description:
"Create bom with evidence as a separate file. Default bom.json",
hidden: true,
})
.option("type", {
alias: "t",
description:
"Project type. Please refer to https://cdxgen.github.io/cdxgen/#/PROJECT_TYPES for supported languages/platforms.",
})
.option("exclude-type", {
description:
"Project types to exclude. Please refer to https://cdxgen.github.io/cdxgen/#/PROJECT_TYPES for supported languages/platforms.",
})
.option("recurse", {
alias: "r",
type: "boolean",
default: true,
description:
"Recurse mode suitable for mono-repos. Defaults to true. Pass --no-recurse to disable.",
})
.option("print", {
alias: "p",
type: "boolean",
description: "Print the SBOM as a table with tree.",
})
.option("resolve-class", {
alias: "c",
type: "boolean",
description: "Resolve class names for packages. jars only for now.",
})
.option("deep", {
type: "boolean",
description:
"Perform deep searches for components. Useful while scanning C/C++ apps, live OS and oci images.",
})
.option("git-branch", {
description: "Git branch to clone when the source is a git URL or purl",
type: "string",
})
.option("server-url", {
description: "Dependency track url. Eg: https://deptrack.cyclonedx.io",
type: "string",
})
.option("skip-dt-tls-check", {
type: "boolean",
default: false,
description: "Skip TLS certificate check when calling Dependency-Track. ",
})
.option("api-key", {
description: "Dependency track api key",
type: "string",
})
.option("project-group", {
description: "Dependency track project group",
})
.option("project-name", {
description:
"Dependency track project name. Default use the directory name",
})
.option("project-version", {
description: "Dependency track project version",
default: "",
type: "string",
})
.option("project-tag", {
description: "Dependency track project tag. Multiple values allowed.",
})
.option("project-id", {
description:
"Dependency track project id. Either provide the id or the project name and version together",
type: "string",
})
.option("parent-project-id", {
description: "Dependency track parent project id",
type: "string",
})
.option("parent-project-name", {
description: "Dependency track parent project name",
type: "string",
})
.option("parent-project-version", {
description: "Dependency track parent project version",
type: "string",
})
.option("auto-create", {
description: "Dependency track autoCreate value for BOM uploads",
type: "boolean",
hidden: true,
})
.option("is-latest", {
description: "Dependency track isLatest value for BOM uploads",
type: "boolean",
hidden: true,
})
.option("required-only", {
type: "boolean",
description:
"Include only the packages with required scope on the SBOM. Would set compositions.aggregate to incomplete unless --no-auto-compositions is passed.",
})
.option("fail-on-error", {
type: "boolean",
default: isSecureMode,
description: "Fail if any dependency extractor fails.",
})
.option("dry-run", {
type: "boolean",
default: isDryRun,
description:
"Read-only mode. cdxgen only performs file reads and reports blocked writes, command execution, temp creation, network access, and submissions.",
})
.option("include-runtime", {
type: "boolean",
default: false,
description:
"For HBOM runs, also collect OBOM runtime inventory and emit a merged host view with strict hardware/runtime topology links.",
})
.option("activity-report", {
choices: ["json", "jsonl"],
description: "Render the activity report as JSON or JSON Lines.",
hidden: true,
type: "string",
})
.option("no-babel", {
type: "boolean",
description:
"Do not use babel to perform usage analysis for JavaScript/TypeScript projects.",
})
.option("generate-key-and-sign", {
type: "boolean",
description:
"Generate an RSA public/private key pair and then sign the generated SBOM using JSON Web Signatures.",
})
.option("server", {
type: "boolean",
description: "Run cdxgen as a server",
})
.option("server-host", {
description: "Listen address",
default: "127.0.0.1",
type: "string",
})
.option("server-port", {
description: "Listen port",
default: 9090,
type: "number",
})
.option("install-deps", {
type: "boolean",
default: !isSecureMode,
description:
"Install dependencies automatically for some projects. Defaults to true but disabled for containers and oci scans. Use --no-install-deps to disable this feature.",
})
.option("validate", {
type: "boolean",
default: true,
description:
"Validate the generated SBOM using json schema. Defaults to true. Pass --no-validate to disable.",
})
.option("evidence", {
type: "boolean",
default: false,
description: "Generate SBOM with evidence for supported languages.",
})
.option("deps-slices-file", {
description: "Path for the parsedeps slice file created by atom.",
default: "deps.slices.json",
hidden: true,
})
.option("usages-slices-file", {
description: "Path for the usages slices file created by atom.",
hidden: true,
})
.option("data-flow-slices-file", {
description: "Path for the data-flow slices file created by atom.",
hidden: true,
})
.option("reachables-slices-file", {
description: "Path for the reachables slices file created by atom.",
hidden: true,
})
.option("semantics-slices-file", {
description: "Path for the semantics slices file.",
default: "semantics.slices.json",
hidden: true,
})
.option("openapi-spec-file", {
description: "Path for the openapi specification file (SaaSBOM).",
hidden: true,
})
.option("spec-version", {
description: "CycloneDX Specification version to use. Defaults to 1.7",
default: 1.7,
type: "number",
choices: [1.4, 1.5, 1.6, 1.7, 2.0],
})
.option("filter", {
description:
"Filter components containing this word in purl or component.properties.value. Multiple values allowed.",
})
.option("only", {
description:
"Include components only containing this word in purl. Useful to generate BOM with first party components alone. Multiple values allowed.",
})
.option("author", {
description:
"The person(s) who created the BOM. Set this value if you're intending the modify the BOM and claim authorship.",
default: "OWASP Foundation",
})
.option("profile", {
description: "BOM profile to use for generation. Default generic.",
default: "generic",
choices: [
"appsec",
"research",
"operational",
"threat-modeling",
"license-compliance",
"generic",
"machine-learning",
"ml",
"deep-learning",
"ml-deep",
"ml-tiny",
],
})
.option("lifecycle", {
description: "Product lifecycle for the generated BOM.",
hidden: true,
choices: ["pre-build", "build", "post-build"],
})
.option("include-release-notes", {
type: "boolean",
default: false,
hidden: true,
description:
"Attach CycloneDX releaseNotes to the cdxgen tool component in metadata.",
})
.option("release-notes-current-tag", {
type: "string",
hidden: true,
description:
"Current git tag used to build CycloneDX releaseNotes for cdxgen metadata.",
})
.option("release-notes-previous-tag", {
type: "string",
hidden: true,
description:
"Previous git tag used to build CycloneDX releaseNotes for cdxgen metadata.",
})
.option("include-regex", {
description:
"glob pattern to include. This overrides the default pattern used during auto-detection.",
type: "string",
})
.option("exclude", {
alias: "exclude-regex",
description: "Additional glob pattern(s) to ignore",
type: "array",
})
.option("export-proto", {
type: "boolean",
default: false,
description: "Serialize and export BOM as protobuf binary.",
})
.option("format", {
description:
"Export format(s). Supports cyclonedx, spdx, repeated --format flags, or a comma-separated list such as cyclonedx,spdx.",
})
.option("proto-bin-file", {
description: "Path for the serialized protobuf binary.",
default: "bom.cdx",
})
.option("include-formulation", {
type: "boolean",
default: false,
description:
"Generate formulation section with git metadata and build tools. Defaults to false.",
})
.option("include-crypto", {
type: "boolean",
default: false,
description: "Include crypto libraries as components.",
})
.option("standard", {
description:
"The list of standards which may consist of regulations, industry or organizational-specific standards, maturity models, best practices, or any other requirements which can be evaluated against or attested to.",
choices: [
"asvs-5.0",
"asvs-4.0.3",
"bsimm-v13",
"masvs-2.0.0",
"nist_ssdf-1.1",
"pcissc-secure-slc-1.1",
"scvs-1.0.0",
"ssaf-DRAFT-2023-11",
],
})
.option("no-banner", {
type: "boolean",
default: false,
hidden: true,
description:
"Do not show the donation banner. Set this attribute if you are an active sponsor for OWASP CycloneDX.",
})
.option("json-pretty", {
type: "boolean",
default: DEBUG_MODE,
description: "Pretty-print the generated BOM json.",
})
.option("feature-flags", {
description: "Experimental feature flags to enable. Advanced users only.",
hidden: true,
choices: ["safe-pip-install", "suggest-build-tools", "ruby-docker-install"],
})
.option("min-confidence", {
description:
"Minimum confidence needed for the identity of a component from 0 - 1, where 1 is 100% confidence.",
default: 0,
type: "number",
})
.option("technique", {
description: "Analysis technique to use",
choices: [
"auto",
"source-code-analysis",
"binary-analysis",
"manifest-analysis",
"hash-comparison",
"instrumentation",
"filename",
],
})
.option("tlp-classification", {
description:
"Traffic Light Protocol (TLP) is a classification system for identifying the potential risk associated with an artefact, including whether it is subject to certain types of legal, financial, or technical threats. Refer to [https://www.first.org/tlp/](https://www.first.org/tlp/) for further information.",
choices: ["CLEAR", "GREEN", "AMBER", "AMBER_AND_STRICT", "RED"],
hidden: true,
})
.option("env-audit", {
type: "boolean",
description:
"Display a pre-generation environment and configuration security assessment",
default: false,
hidden: true,
})
.option("bom-audit", {
type: "boolean",
description: "Perform post-generation security audit of BOM data",
default: false,
hidden: true,
})
.option("bom-audit-rules-dir", {
description:
"Directory containing additional YAML audit rules (merged with built-in)",
type: "string",
hidden: true,
})
.option("bom-audit-categories", {
description:
"Comma-separated list of rule categories to enable (default: all)",
type: "string",
hidden: true,
})
.option("bom-audit-min-severity", {
description:
"Minimum severity to report: low, medium, or high (default: low)",
type: "string",
choices: ["low", "medium", "high"],
default: "low",
hidden: true,
})
.option("bom-audit-fail-severity", {
description: "Severity threshold for secure mode failure (default: high)",
type: "string",
choices: ["high", "medium", "low"],
default: "high",
hidden: true,
})
.option("bom-audit-scope", {
description:
"Predictive audit target scope. Use 'required' to scan only dependencies with scope=required (missing scope is treated as required).",
type: "string",
choices: ["all", "required"],
default: "all",
hidden: true,
})
.option("bom-audit-max-targets", {
description:
"Optional upper bound for predictive audit targets. By default cdxgen scans required dependencies first and expands to at least 50 targets.",
type: "number",
hidden: true,
})
.option("bom-audit-include-trusted", {
description:
"Include packages already marked with trusted publishing metadata in predictive BOM audit target selection.",
type: "boolean",
default: false,
hidden: true,
})
.option("bom-audit-only-trusted", {
description:
"Restrict predictive BOM audit target selection to packages marked with trusted publishing metadata.",
type: "boolean",
default: false,
hidden: true,
})
.completion("completion", "Generate bash/zsh completion")
.array("type")
.array("excludeType")
.array("filter")
.array("only")
.array("author")
.array("format")
.array("standard")
.array("feature-flags")
.array("technique")
.option("auto-compositions", {
type: "boolean",
default: true,
description:
"Automatically set compositions when the BOM was filtered. Defaults to true",
})
.example([
["$0 -t java .", "Generate a Java SBOM for the current directory"],
[
"$0 -t java -t js .",
"Generate a SBOM for Java and JavaScript in the current directory",
],
["$0 -t hbom .", "Generate an HBOM for the current host"],
[
"$0 -t java --profile ml .",
"Generate a Java SBOM for machine learning purposes.",
],
[
"$0 -t python --profile research .",
"Generate a Python SBOM for appsec research.",
],
["$0 --server", "Run cdxgen as a server"],
])
.epilogue("for documentation, visit https://cdxgen.github.io/cdxgen")
.config(config)
.scriptName(invokedCommandName || "cdxgen")
.version(retrieveCdxgenVersion())
.alias("v", "version")
.help(false)
.option("help", {
alias: "h",
type: "boolean",
description: "Show help",
})
.wrap(Math.min(120, yargs().terminalWidth())).argv;
if (process.env?.CDXGEN_NODE_OPTIONS) {
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS || ""} ${process.env.CDXGEN_NODE_OPTIONS}`;
}
if (args.help) {
console.log(`${retrieveCdxgenVersion()}\n`);
_yargs.showHelp();
process.exit(0);
}
if (args.bomAuditIncludeTrusted && args.bomAuditOnlyTrusted) {
console.error(
"Use either --bom-audit-include-trusted or --bom-audit-only-trusted, not both.",
);
process.exit(1);
}
// Native Enterprise Network Configuration (Node.js v22.21+, Bun, Deno)
// https://nodejs.org/en/learn/http/enterprise-network-configuration
// https://docs.deno.com/runtime/reference/env_variables/#special-environment-variables
// https://bun.com/docs/guides/http/proxy#environment-variables
if (process.env.HTTP_PROXY || process.env.HTTPS_PROXY) {
if (isNode && !isBun && !isDeno) {
process.env.NODE_USE_ENV_PROXY = "1";
try {
const proxyEnv = {
HTTP_PROXY: process.env.HTTP_PROXY,
HTTPS_PROXY: process.env.HTTPS_PROXY,
NO_PROXY: process.env.NO_PROXY,
};
http.globalAgent = new http.Agent({ proxyEnv });
https.globalAgent = new https.Agent({ proxyEnv });
thoughtLog("Configured native Node.js global agents for HTTP proxy. 🌐");
} catch (_e) {
console.warn(
"Warning: Native proxy configuration failed. Please use Node.js v22.21.0+ for proxy support.",
);
}
} else {
thoughtLog("Using runtime-native (Deno/Bun) proxy support. 🌐");
}
}
if (!process.env.NODE_USE_SYSTEM_CA) {
process.env.NODE_USE_SYSTEM_CA = "1";
}
const filePath = args._[0] || process.cwd();
const sourceInputIsRemoteOrPurl =
maybeRemotePath(filePath) || maybePurlSource(filePath);
if (!args.projectName) {
if (filePath !== ".") {
args.projectName = basename(filePath);
} else {
args.projectName = basename(resolve(filePath));
}
}
thoughtLog(`Let's try to generate a CycloneDX BOM for the path '${filePath}'`);
if (
!sourceInputIsRemoteOrPurl &&
(filePath.includes(" ") || filePath.includes("\r") || filePath.includes("\n"))
) {
console.log(
`'${filePath}' contains spaces. This could lead to bugs when invoking external build tools.`,
);
if (isSecureMode) {
process.exit(1);
}
}
// Support for obom/cbom aliases
if (invokedCommandName.includes("obom") && !args.type) {
args.type = ["os"];
thoughtLog(
"Ok, the user wants to generate an Operations Bill-of-Materials (OBOM).",
);
}
if (invokedCommandName.includes("spdxgen") && !args.format) {
args.format = "spdx";
thoughtLog("Ok, defaulting the export format to SPDX.");
}
/**
* Command line options
*/
const options = Object.assign({}, args, {
projectType: args.type,
multiProject: args.recurse,
noBabel: args.noBabel || args.babel === false,
project: args.projectId,
deep: args.deep || args.evidence,
output:
isSecureMode && args.output === "bom.json"
? sourceInputIsRemoteOrPurl
? resolve(args.output)
: resolve(join(filePath, args.output))
: args.output,
exclude: args.exclude || args.excludeRegex,
include: args.include || args.includeRegex,
});
setDryRunMode(options.dryRun);
setActivityContext({
projectType: Array.isArray(options.projectType)
? options.projectType.join(",")
: options.projectType,
sourcePath: filePath,
});
const outputPlan = createOutputPlan(options);
for (const outputFile of Object.values(outputPlan.outputs)) {
const outputDirectory = getOutputDirectory(outputFile);
if (
outputDirectory &&
outputDirectory !== process.cwd() &&
!safeExistsSync(outputDirectory)
) {
safeMkdirSync(outputDirectory, { recursive: true });
}
}
// Filter duplicate types. Eg: -t gradle -t gradle
if (options.projectType && Array.isArray(options.projectType)) {
options.projectType = Array.from(new Set(options.projectType));
}
try {
ensureNoMixedHbomProjectTypes(options.projectType);
if (hasHbomProjectType(options.projectType)) {
ensureSupportedHbomSpecVersion(options.specVersion);
}
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (!options.projectType) {
thoughtLog(
"Ok, the user wants me to identify all the project types and generate a consolidated BOM document.",
);
}
// Handle dedicated cbom and saasbom commands
if (["cbom", "saasbom"].includes(invokedCommandName)) {
if (invokedCommandName.includes("cbom")) {
thoughtLog(
"Ok, the user wants to generate Cryptographic Bill-of-Materials (CBOM).",
);
options.includeCrypto = true;
} else if (invokedCommandName.includes("saasbom")) {
thoughtLog(
"Ok, the user wants to generate a Software as a Service Bill-of-Materials (SaaSBOM). I should carefully collect the services, endpoints, and data flows.",
);
if (process.env?.CDXGEN_IN_CONTAINER !== "true") {
thoughtLog(
"Wait, I'm not running in a container. This means the chances of successfully collecting this inventory are quite low. Perhaps this is an advanced user who has set up atom and atom-tools already 🤔?",
);
}
}
options.evidence = true;
options.specVersion = 1.7;
options.deep = true;
}
if (invokedCommandName.includes("cdxgen-secure")) {
thoughtLog(
"Ok, the user wants cdxgen to run in secure mode by default. Let's try and use the permissions api.",
);
console.log(
"NOTE: Secure mode only restricts cdxgen from performing certain activities such as package installation. It does not provide security guarantees in the presence of malicious code.",
);
options.installDeps = false;
process.env.CDXGEN_SECURE_MODE = true;
}
if (isDryRun) {
thoughtLog(
"Ok, the user wants cdxgen to run in dry-run mode. I must avoid writes, child processes, temp directories, network submissions, and cloning.",
);
options.installDeps = false;
}
if (options.standard) {
options.specVersion = 1.7;
}
const isHbomOnlyInvocation = isHbomOnlyProjectTypes(options.projectType);
if (options.includeFormulation && isHbomOnlyInvocation) {
thoughtLog(
"HBOM-only invocations do not benefit from formulation data. Let's ignore this option to keep the resulting document focused on hardware inventory.",
);
console.log(
"NOTE: Ignoring formulation collection for HBOM-only invocations because the resulting hardware BOM does not need workflow or dependency-tree enrichment.",
);
options.includeFormulation = false;
} else if (options.includeFormulation) {
if (options.serverUrl) {
thoughtLog(
"Wait, the user specified a server URL and wants to include formulation data. Let's warn about accidentally disclosing sensitive data to a remote server.",
);
console.warn(
`\x1b[1;35mWARNING: The formulation section may include sensitive data such as emails and secrets. This data will be submitted to '${options.serverUrl}' automatically.\x1b[0m`,
);
if (isSecureMode) {
process.exit(1);
}
} else {
thoughtLog(
"Wait, the user wants to include formulation data. Let's warn about accidentally disclosing sensitive data via the generated BOM.",
);
console.log(
"NOTE: The formulation section may include sensitive data such as emails and secrets.\nPlease review the generated SBOM before distribution or LLM training.\n",
);
}
}
/**
* Method to apply advanced options such as profile and lifecycles
*
* @param {object} options CLI options
*/
const applyAdvancedOptions = (options) => {
if (options?.profile !== "generic") {
thoughtLog(`BOM profile to use is '${options.profile}'.`);
} else {
thoughtLog(
"The user hasn't specified a profile. Should I suggest one to optimize the BOM for a specific use case or persona 🤔?",
);
}
switch (options.profile) {
case "appsec":
options.deep = true;
options.bomAudit = true;
break;
case "research":
options.deep = true;
options.evidence = true;
options.includeCrypto = true;
options.bomAudit = true;
process.env.CDX_MAVEN_INCLUDE_TEST_SCOPE = "true";
process.env.ASTGEN_IGNORE_DIRS = "";
process.env.ASTGEN_IGNORE_FILE_PATTERN = "";
break;
case "operational":
if (options?.projectType) {
options.projectType.push("os");
} else {
options.projectType = ["os"];
}
options.bomAudit = true;
break;
case "threat-modeling":
options.deep = true;
options.evidence = true;
options.bomAudit = true;
break;
case "license-compliance":
process.env.FETCH_LICENSE = "true";
break;
case "ml-tiny":
process.env.FETCH_LICENSE = "true";
options.deep = false;
options.evidence = false;
options.includeCrypto = false;
options.installDeps = false;
options.bomAudit = false;
break;
case "machine-learning":
case "ml":
process.env.FETCH_LICENSE = "true";
options.deep = true;
options.evidence = false;
options.includeCrypto = false;
options.installDeps = !isSecureMode;
break;
case "deep-learning":
case "ml-deep":
process.env.FETCH_LICENSE = "true";
options.deep = true;
options.evidence = true;
options.includeCrypto = true;
options.installDeps = !isSecureMode;
options.bomAudit = true;
break;
default:
break;
}
if (options.lifecycle) {
thoughtLog(
`BOM must be generated for the lifecycle '${options.lifecycle}'.`,
);
}
switch (options.lifecycle) {
case "pre-build":
options.installDeps = false;
break;
case "post-build":
if (
!options.projectType ||
![
"csharp",
"dotnet",
"container",
"docker",
"podman",
"oci",
"android",
"apk",
"aab",
"go",
"golang",
"rust",
"rust-lang",
"cargo",
"caxa",
].includes(options.projectType[0])
) {
console.log(
"PREVIEW: post-build lifecycle SBOM generation is supported only for limited project types.",
);
process.exit(1);
}
options.installDeps = true;
break;
default:
break;
}
// When the user specifies source-code-analysis as a technique, then enable deep and evidence mode.
if (options?.technique && Array.isArray(options.technique)) {
if (options?.technique?.includes("source-code-analysis")) {
options.deep = true;
options.evidence = true;
}
if (options.technique.length === 1) {
thoughtLog(
`Wait, the user wants me to use only the following technique: '${options.technique.join(", ")}'.`,
);
} else {
thoughtLog(
`Alright, I will use only the following techniques: '${options.technique.join(", ")}' for the final BOM.`,
);
}
}
if (!options.installDeps) {
thoughtLog(
"I must avoid any package installations and focus solely on the available artefacts, such as lock files.",
);
}
if (options.bomAudit) {
if (isHbomOnlyInvocation) {
thoughtLog(
"HBOM-only bom-audit runs should stay focused on hardware inventory. Skipping automatic formulation collection.",
);
} else if (!options.includeFormulation) {
console.log(
"NOTE: Automatically collecting formulation information. The section may include sensitive data such as emails and secrets.\nPlease review the generated SBOM before distribution or LLM training.\n",
);
options.includeFormulation = true;
}
}
return options;
};
applyAdvancedOptions(options);
if (options.bomAudit && !options.bomAuditCategories) {
const defaultBomAuditCategories = getDefaultBomAuditCategories(
options,
process.argv[1],
);
if (defaultBomAuditCategories) {
options.bomAuditCategories = defaultBomAuditCategories;
thoughtLog(
`Defaulting BOM audit categories to '${defaultBomAuditCategories}' for this OBOM or explicit os-only invocation.`,
);
}
}
const envAuditFindings = auditEnvironment();
if (options.envAudit) {
displaySelfThreatModel(filePath, config, options, envAuditFindings);
}
/**
* Check for node >= 20 permissions
*
* @param {string} filePath File path
* @param {Object} options CLI Options
* @returns
*/
const checkPermissions = (filePath, options) => {
const fullFilePath = resolve(filePath);
if (
process.getuid &&
process.getuid() === 0 &&
process.env?.CDXGEN_IN_CONTAINER !== "true"
) {
console.log(
"\x1b[1;35mSECURE MODE: DO NOT run cdxgen with root privileges.\x1b[0m",
);
}
if (!process.permission) {
if (isSecureMode) {
console.log(
"\x1b[1;35mSecure mode requires permission-related arguments. These can be passed as CLI arguments directly to the node runtime or set the NODE_OPTIONS environment variable as shown below.\x1b[0m",
);
const childProcessArgs = isDryRun
? ""
: options?.lifecycle !== "pre-build"
? " --allow-child-process"
: "";
const fsWriteArgs = isDryRun
? ""
: ` --allow-fs-write="${getTmpDir()}/*" --allow-fs-write="${options.output}"`;
const nodeOptionsVal = `--permission --allow-fs-read="${getTmpDir()}/*" --allow-fs-read="${fullFilePath}/*"${fsWriteArgs}${childProcessArgs}`;
console.log(
`${isWin ? "$env:" : "export "}NODE_OPTIONS='${nodeOptionsVal}'`,
);
if (process.env?.CDXGEN_IN_CONTAINER !== "true") {
console.log(
"TIP: Run cdxgen using the secure container image 'ghcr.io/cyclonedx/cdxgen-secure' for best experience.",
);
}
}
return true;
}
// Secure mode checks
if (isSecureMode) {
if (process.env?.GITHUB_TOKEN) {
console.log(
"Ensure that the GitHub token provided to cdxgen is restricted to read-only scopes.",
);
}
if (process.permission.has("fs.read", "*")) {
console.log(
"\x1b[1;35mSECURE MODE: DO NOT run cdxgen with FileSystemRead permission set to wildcard.\x1b[0m",
);
}
if (process.permission.has("fs.write", "*")) {
console.log(
"\x1b[1;35mSECURE MODE: DO NOT run cdxgen with FileSystemWrite permission set to wildcard.\x1b[0m",
);
}
if (process.permission.has("worker")) {
console.log(
"SECURE MODE: DO NOT run cdxgen with worker thread permission! Remove `--allow-worker` argument.",
);
}
if (filePath !== fullFilePath) {
console.log(
`\x1b[1;35mSECURE MODE: Invoke cdxgen with an absolute path to improve security. Use '${fullFilePath}' instead of '${filePath}'\x1b[0m`,
);
if (fullFilePath.includes(" ")) {
console.log(
"\x1b[1;35mSECURE MODE: Directory names containing spaces are known to cause issues. Rename the directories by replacing spaces with hyphens or underscores.\x1b[0m",
);
} else if (fullFilePath.length > 255 && isWin) {
console.log(
"Ensure 'Enable Win32 Long paths' is set to 'Enabled' by using Group Policy Editor.",
);
}
return false;
}
}
if (!process.permission.has("fs.read", filePath)) {
console.log(
`\x1b[1;35mSECURE MODE: FileSystemRead permission required. Please invoke cdxgen with the argument --allow-fs-read="${resolve(
filePath,
)}"\x1b[0m`,
);
return false;
}
if (!isDryRun && !process.permission.has("fs.write", options.output)) {
console.log(
`\x1b[1;35mSECURE MODE: FileSystemWrite permission is required to create the output BOM file. Please invoke cdxgen with the argument --allow-fs-write="${options.output}"\x1b[0m`,
);
}
if (!isDryRun && options.evidence) {
const slicesFilesKeys = [
"deps-slices-file",
"usages-slices-file",
"reachables-slices-file",
];
if (options?.type?.includes("swift") || options?.type?.includes("scala")) {
slicesFilesKeys.push("semantics-slices-file");
}
for (const sf of slicesFilesKeys) {
let issueFound = false;
if (!process.permission.has("fs.write", options[sf])) {
console.log(
`SECURE MODE: FileSystemWrite permission is required to create the output slices file. Please invoke cdxgen with the argument --allow-fs-write="${options[sf]}"`,
);
if (!issueFound) {
issueFound = true;
}
}
if (issueFound) {
return false;
}
}
}
if (!isDryRun && !process.permission.has("fs.write", getTmpDir())) {
console.log(
`FileSystemWrite permission may be required for the TEMP directory. Please invoke cdxgen with the argument --allow-fs-write="${join(getTmpDir(), "*")}" in case of any crashes.`,
);
if (isMac) {
console.log(
"TIP: macOS doesn't use the `/tmp` prefix for TEMP directories. Use the argument shown above.",
);
}
}
if (!isDryRun && !process.permission.has("child") && !isSecureMode) {
console.log(
"ChildProcess permission is missing. This is required to spawn commands for some languages. Please invoke cdxgen with the argument --allow-child-process in case of issues.",
);
}
if (
!isDryRun &&
process.permission.has("child") &&
options?.lifecycle === "pre-build"
) {
console.log(
"SECURE MODE: ChildProcess permission is not required for pre-build SBOM generation. Please invoke cdxgen without the argument --allow-child-process.",
);
return false;
}
return true;
};
const needsBomSigning = ({ generateKeyAndSign }) =>
generateKeyAndSign ||
(() => {
const sbomSignAlgorithm = readEnvironmentVariable("SBOM_SIGN_ALGORITHM");
const sbomSignPrivateKey = readEnvironmentVariable(
"SBOM_SIGN_PRIVATE_KEY",
{
sensitive: true,
},
);
const sbomSignPrivateKeyBase64 = readEnvironmentVariable(
"SBOM_SIGN_PRIVATE_KEY_BASE64",
{
sensitive: true,
},
);
return (
sbomSignAlgorithm &&
sbomSignAlgorithm !== "none" &&
((sbomSignPrivateKey && safeExistsSync(sbomSignPrivateKey)) ||
sbomSignPrivateKeyBase64)
);
})();
const stringifyJson = (jsonPayload, jsonPretty) =>
typeof jsonPayload === "string" || jsonPayload instanceof String
? jsonPayload
: JSON.stringify(jsonPayload, null, jsonPretty ? 2 : null);
const writeCycloneDxOutput = (jsonFile, bomJson, options) => {
const jsonPayload = stringifyJson(bomJson, options.jsonPretty);
safeWriteSync(jsonFile, jsonPayload);
if (jsonFile.endsWith("bom.json")) {
thoughtLog(
`Let's save the file to "${jsonFile}". Should I suggest the '.cdx.json' file extension for better semantics?`,
);
} else {
thoughtLog(`Let's save the file to "${jsonFile}".`);
}
if (!jsonPayload || !needsBomSigning(options)) {
return jsonPayload;
}
if (isDryRun) {
recordActivity({
kind: "sign",
reason: "Dry run mode skips BOM signing and key generation.",
status: "blocked",
target: jsonFile,
});
return jsonPayload;
}
const sbomSignAlgorithm = readEnvironmentVariable("SBOM_SIGN_ALGORITHM");
const sbomSignPrivateKey = readEnvironmentVariable("SBOM_SIGN_PRIVATE_KEY", {
sensitive: true,
});
const sbomSignPrivateKeyBase64 = readEnvironmentVariable(
"SBOM_SIGN_PRIVATE_KEY_BASE64",
{
sensitive: true,
},
);
const sbomSignPublicKey = readEnvironmentVariable("SBOM_SIGN_PUBLIC_KEY");
const sbomSignPublicKeyBase64 = readEnvironmentVariable(
"SBOM_SIGN_PUBLIC_KEY_BASE64",
);
let alg = sbomSignAlgorithm || "RS512";
if (alg.includes("none")) {
alg = "RS512";
}
let privateKeyToUse;
let jwkPublicKey;
let publicKeyFile;
if (options.generateKeyAndSign) {
const jdirName = dirname(jsonFile);
publicKeyFile = join(jdirName, "public.key");
const privateKeyFile = join(jdirName, "private.key");
const privateKeyB64File = join(jdirName, "private.key.base64");
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 4096,
publicKeyEncoding: {
type: "spki",
format: "pem",
},
privateKeyEncoding: {
type: "pkcs8",
format: "pem",
},
});
safeWriteSync(publicKeyFile, publicKey);
safeWriteSync(privateKeyFile, privateKey);
safeWriteSync(
privateKeyB64File,
Buffer.from(privateKey, "utf8").toString("base64"),
);
console.log(
"Created public/private key pairs for testing purposes",
publicKeyFile,
privateKeyFile,
privateKeyB64File,
);
privateKeyToUse = privateKey;
jwkPublicKey = crypto.createPublicKey(publicKey).export({ format: "jwk" });
} else {
if (sbomSignPrivateKey) {
recordSensitiveFileRead(sbomSignPrivateKey, {
label: "SBOM signing private key",
});
privateKeyToUse = fs.readFileSync(sbomSignPrivateKey, "utf8");
} else if (sbomSignPrivateKeyBase64) {
privateKeyToUse = Buffer.from(
sbomSignPrivateKeyBase64,
"base64",
).toString("utf8");
}
if (sbomSignPublicKey && safeExistsSync(sbomSignPublicKey)) {
jwkPublicKey = crypto
.createPublicKey(fs.readFileSync(sbomSignPublicKey, "utf8"))
.export({ format: "jwk" });
} else if (sbomSignPublicKeyBase64) {
jwkPublicKey = Buffer.from(sbomSignPublicKeyBase64, "base64").toString(
"utf8",
);
}
}
try {
const bomJsonUnsignedObj = JSON.parse(jsonPayload);
const signOptions = {
privateKey: privateKeyToUse,
algorithm: alg,
publicKeyJwk: jwkPublicKey,
mode: readEnvironmentVariable("SBOM_SIGN_MODE") || "replace",
signComponents: true,
signServices: true,
signAnnotations: true,
};
thoughtLog(`Signing the BOM file "${jsonFile}".`);
recordActivity({
kind: "sign",
status: "completed",
target: jsonFile,
});
const signedBom = signBom(bomJsonUnsignedObj, signOptions);
safeWriteSync(
jsonFile,
JSON.stringify(signedBom, null, options.jsonPretty ? 2 : null),
);
if (publicKeyFile) {
const publicKeyStr = fs.readFileSync(publicKeyFile, "utf8");
const signatureVerification = verifyBom(signedBom, publicKeyStr);
if (signatureVerification) {
console.log(
"SBOM signature is verifiable natively with the public key and the algorithm",
publicKeyFile,
alg,
);
} else {
console.log("SBOM signature verification was unsuccessful");
console.log("Check if the public key was exported in PEM format");
}
}
} catch (ex) {
console.log("SBOM signing was unsuccessful:", ex.message);
console.log(
"Check if the private key was exported in PEM format and the algorithm is JSF-compliant.",
);
}
return jsonPayload;
};
/**
* Method to start the bom creation process
*/
(async () => {
// Display the sponsor banner
printSponsorBanner(options);
// Our quest to audit and check the SBOM generation environment to prevent our users from getting exploited
// during SBOM generation.
if (envAuditFindings?.length) {
printEnvironmentAuditFindings(envAuditFindings);
// Only abort in secure mode for high or critical findings; low/medium are informational.
if (
isSecureMode &&
envAuditFindings.some((f) => ["high", "critical"].includes(f.severity))
) {
process.exit(1);
}
}
// Start SBOM server
if (options.server) {
const serverModule = await import("../lib/server/server.js");
return serverModule.start(options);
}
let sourcePath = filePath;
let purlResolution;
if (isDryRun && maybePurlSource(sourcePath)) {
recordActivity({
kind: "clone",
reason:
"Dry run mode blocks package-url source resolution and repository cloning.",
status: "blocked",
target: sourcePath,
});
console.warn("Dry run mode skips purl source resolution.");
printActivitySummary(options.activityReport);
return;
}
if (maybePurlSource(sourcePath)) {
const purlValidationError = validatePurlSource(sourcePath);
if (purlValidationError) {
console.error(purlValidationError.error, purlValidationError.details);
process.exit(1);
}
purlResolution = await resolveGitUrlFromPurl(sourcePath);
if (!purlResolution?.repoUrl) {
console.error(
"Unable to resolve the provided package URL to a repository URL.",
);
process.exit(1);
}
console.warn(
`${PURL_REGISTRY_LOOKUP_WARNING} Registry: ${purlResolution.registry}, purl type: ${purlResolution.type}, resolved URL: ${sanitizeRemoteUrlForLogs(purlResolution.repoUrl)}`,
);
sourcePath = purlResolution.repoUrl;
}
if (
maybeRemotePath(sourcePath) &&
isSecureMode &&
!process.env.CDXGEN_GIT_ALLOWED_HOSTS &&
!process.env.CDXGEN_SERVER_ALLOWED_HOSTS
) {
console.error(
"SECURE MODE: Configure CDXGEN_GIT_ALLOWED_HOSTS (or CDXGEN_SERVER_ALLOWED_HOSTS) before using git URL or purl sources.",
);
process.exit(1);
}
if (!maybeRemotePath(sourcePath) && !isAllowedPath(resolve(sourcePath))) {
console.error(
"Path is not allowed as per CDXGEN_ALLOWED_PATHS/CDXGEN_SERVER_ALLOWED_PATHS.",
);
process.exit(1);
}
if (!maybeRemotePath(sourcePath) && !isAllowedWinPath(resolve(sourcePath))) {
console.error("Path is not allowed on this platform.");
process.exit(1);
}
if (maybeRemotePath(sourcePath)) {
const validationError = validateAndRejectGitSource(sourcePath);
if (validationError) {
console.error(validationError.error, validationError.details);
process.exit(1);
}
}
const checkPath = maybeRemotePath(sourcePath) ? getTmpDir() : sourcePath;
if (maybeRemotePath(sourcePath)) {
options.releaseNotesGitUrl = sourcePath;
}
if (!checkPermissions(checkPath, options)) {
if (isSecureMode) {
process.exit(1);
}
return;
}
let srcDir = sourcePath;
let cleanup = false;
let gitRef = options.gitBranch;
if (maybeRemotePath(sourcePath)) {
if (isDryRun) {
recordActivity({
kind: "clone",
reason: "Dry run mode blocks cloning git URL sources.",
status: "blocked",
target: sourcePath,
});
console.warn("Dry run mode skips remote git source cloning.");
printActivitySummary(options.activityReport);
return;
}
if (!gitRef && purlResolution?.version) {
gitRef = findGitRefForPurlVersion(sourcePath, purlResolution);
if (!gitRef) {
console.warn(
`Unable to find a matching git tag for version '${purlResolution.version}'. Falling back to repository default branch.`,
);
}
}
srcDir = gitClone(sourcePath, gitRef);
if (purlResolution?.type === "npm") {
const cloneRootDir = srcDir;
const purlSourceDir = resolvePurlSourceDirectory(srcDir, purlResolution);
if (purlSourceDir) {
if (purlSourceDir !== cloneRootDir) {
const relativeDir = relative(cloneRootDir, purlSourceDir);
if (relativeDir.startsWith("..") || isAbsolute(relativeDir)) {
console.warn(
`Ignoring detected npm package directory outside clone root: ${purlSourceDir}`,
);
} else {
console.warn(
`Using npm package directory '${purlSourceDir}' for purl '${purlResolution.namespace ? `${purlResolution.namespace}/` : ""}${purlResolution.name}'.`,
);
srcDir = purlSourceDir;
}
}
}
}
cleanup = true;
}
setActivityContext({ sourcePath: srcDir });
if (!hasHbomProjectType(options.projectType)) {
prepareEnv(srcDir, options);
}
thoughtLog("Getting ready to generate the BOM ⚡️.");
const originalFetchPackageMetadata = process.env.CDXGEN_FETCH_PKG_METADATA;
const shouldRunPredictiveAudit = shouldRunPredictiveBomAudit(
options,
process.argv[1],
);
if (options.bomAudit && shouldRunPredictiveAudit) {
process.env.CDXGEN_FETCH_PKG_METADATA = "true";
}
let bomNSData;
try {
bomNSData = (await createBom(srcDir, options)) || {};
} finally {
if (originalFetchPackageMetadata === undefined) {
delete process.env.CDXGEN_FETCH_PKG_METADATA;
} else {
process.env.CDXGEN_FETCH_PKG_METADATA = originalFetchPackageMetadata;
}
}
if (bomNSData?.bomJson) {
thoughtLog(
"Tweaking the generated BOM data with useful annotations and properties.",
);
}
// Add extra metadata and annotations with post processing
bomNSData = postProcess(bomNSData, options, srcDir);
setActivityContext({
projectType: Array.isArray(options.projectType)
? options.projectType.join(",")
: options.projectType,
sourcePath: srcDir,
});
if (options.bomAudit && bomNSData?.bomJson) {
const { finalizeAuditReport, runAuditFromBoms } = await import(
"../lib/audit/index.js"
);
const { createProgressTracker } = await import("../lib/audit/progress.js");
const { collectAuditTargets } = await import("../lib/audit/targets.js");
const { formatPredictiveAnnotations, renderConsoleReport } = await import(
"../lib/audit/reporters.js"
);
const {
auditBom,
formatDryRunSupportSummary,
formatAnnotations,
formatConsoleOutput,
getBomAuditDryRunSupportSummary,
hasCriticalFindings,
} = await import("../lib/stages/postgen/auditBom.js");
thoughtLog("Let's run security audit...");
const postAuditFindings = await auditBom(bomNSData.bomJson, options);
if (postAuditFindings.length) {
formatConsoleOutput(postAuditFindings);
} else if (DEBUG_MODE) {
console.log("BOM audit: No findings");
}
if (isDryRun) {
const dryRunSupportSummary =
await getBomAuditDryRunSupportSummary(options);
const dryRunSupportMessage =
formatDryRunSupportSummary(dryRunSupportSummary);
if (dryRunSupportMessage) {
console.log(dryRunSupportMessage);
}
}
if (postAuditFindings.length && options.specVersion >= 1.4) {
bomNSData.bomJson.annotations = [
...(bomNSData.bomJson.annotations || []),
...formatAnnotations(postAuditFindings, bomNSData.bomJson),
];
thoughtLog(
`Embedded ${postAuditFindings.length} audit findings as CycloneDX annotations`,
);
}
if (isSecureMode && hasCriticalFindings(postAuditFindings, options)) {
console.error("\nSecure mode: Critical audit findings detected.");
console.error(
"Review findings above or adjust --bom-audit-fail-severity to proceed.",
);
if (cleanup) {
cleanupSourceDir(srcDir);
}
process.exit(1);
}
if (!shouldRunPredictiveAudit) {
thoughtLog(
"Skipping predictive dependency audit for this OBOM or explicit os-only invocation.",
);
} else {
thoughtLog("Let's run predictive dependency audit...");
const progressTracker = createProgressTracker();
const predictiveAuditScope =
options.bomAuditScope === "required" ? "required" : undefined;
const predictiveAuditTrusted = options.bomAuditOnlyTrusted
? "only"
: options.bomAuditIncludeTrusted
? "include"
: undefined;
const requiredAuditTargetCount = collectAuditTargets(
[
{
bomJson: bomNSData.bomJson,
source: filePath,
},
],
{
scope: "required",
trusted: predictiveAuditTrusted,
},
).targets.len