UNPKG

@cyclonedx/cdxgen

Version:

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

1,543 lines (1,523 loc) 62.2 kB
#!/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