UNPKG

@cyclonedx/cdxgen

Version:

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

450 lines (420 loc) 15.4 kB
/** * CLI argument → options conversion for cdxgen. * * Extracts the complex arg-to-options mapping from `bin/cdxgen.js` into * pure, testable functions with no side-effects (no process.exit, no * console output). * * Architecture note: This module is a helper and must NOT import from * `lib/cli/index.js` or `lib/stages/`. See AGENTS.md "Module layering * rules". * */ import { join, resolve } from "node:path"; import { DEFAULT_CDX_SPEC_VERSION, normalizeCycloneDxComponentTypeFilter, } from "../helpers/bomUtils.js"; import { isHbomOnlyProjectTypes } from "../helpers/hbom.js"; import { isSecureMode } from "../helpers/utils.js"; /** * The set of valid CycloneDX lifecycle values for the `post-build` * lifecycle that have dedicated SBOM support. * @type {ReadonlySet<string>} */ const POST_BUILD_SUPPORTED_PROJECT_TYPES = new Set([ "csharp", "dotnet", "container", "docker", "podman", "oci", "android", "apk", "aab", "go", "golang", "rust", "rust-lang", "cargo", "caxa", ]); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Check whether a value was explicitly provided by the user (i.e., is not * the yargs default). Yargs always populates default values, so we cannot * simply check for truthiness. * * We detect "user-provided" by checking whether the actual value differs * from the declared yargs default. This is robust for primitive types * (number, string, boolean) which is the case for all cdxgen CLI flags. * * @param {*} value The value from the parsed args * @param {*} yaDefault The default declared in yargs `.option()` * @returns {boolean} True when the user explicitly set this flag */ export function isUserProvided(value, yaDefault) { return value !== undefined && value !== yaDefault; } /** * Apply command-name-based defaults to the raw parsed args. When cdxgen is * invoked via a symlink or alias (e.g. `obom`, `cbom`, `spdxgen`, `aibom`), * certain args receive implicit values. * * **Pure function** — returns a shallow copy of `args` with mutations; the * original `args` object is not modified. * * @param {object} args Parsed yargs argv * @param {string} invokedCommandName Binary name without extension * @returns {object} A shallow copy of args with alias-based defaults applied */ export function applyCommandNameDefaults(args, invokedCommandName) { const result = { ...args }; if (invokedCommandName.includes("obom") && !result.type) { result.type = ["os"]; } if (invokedCommandName.includes("spdxgen") && !result.format) { result.format = "spdx"; } if (invokedCommandName.includes("aibom") && !result.type) { result.type = ["ai"]; result.includeFormulation = true; if (!result.bomAuditCategories) { result.bomAuditCategories = "ai-bom"; } } return result; } /** * Build the initial `options` object from parsed CLI args. This performs * the field renames and derived-value computations that create the shape * expected by `createBom`. * * @param {object} args Parsed yargs argv (after command-name * defaults have been applied) * @param {object} context * @param {string} context.filePath Resolved source path * @param {boolean} context.isRemoteOrPurl True when source is a URL or purl * @returns {object} The initial options object */ export function buildInitialOptions(args, { filePath, isRemoteOrPurl }) { const res = Object.assign({}, args, { projectType: args.type, multiProject: args.recurse, noBabel: args.noBabel || args.babel === false, project: args.projectId, deep: args.deep || args.evidence, aiProvenance: args.aiProvenance, output: isSecureMode && args.output === "bom.json" ? isRemoteOrPurl ? resolve(args.output) : resolve(join(filePath, args.output)) : args.output, exclude: args.exclude || args.excludeRegex, include: args.include || args.includeRegex, noIgnore: args.noIgnore, gitAiNotesRef: args.gitAiNotesRef || "refs/notes/ai", // License normalization and enrichment are on by default; the only escape // hatch is the corresponding environment variable. licenseEnhance: process.env.CDXGEN_LICENSE_ENHANCE !== "false", licenseEnrich: process.env.CDXGEN_LICENSE_ENRICH !== "false", licensePolicy: args.licensePolicy || process.env.CDXGEN_LICENSE_POLICY, licenseRef: args.licenseRef === true || process.env.CDXGEN_LICENSE_REF === "true", }); if (res.licenseEnhance !== undefined) { process.env.CDXGEN_LICENSE_ENHANCE = String(res.licenseEnhance); } if (res.licenseEnrich !== undefined) { process.env.CDXGEN_LICENSE_ENRICH = String(res.licenseEnrich); } if (res.licensePolicy !== undefined) { process.env.CDXGEN_LICENSE_POLICY = String(res.licensePolicy); } else { delete process.env.CDXGEN_LICENSE_POLICY; } if (res.licenseRef !== undefined) { process.env.CDXGEN_LICENSE_REF = String(res.licenseRef); } return res; } /** * @typedef {object} OptionsWarning * @property {"info"|"warn"|"error"} level Severity level * @property {string} message Human-readable description */ /** * Apply command-specific, standard, and security overrides to the options * object. This covers: * * - Dedup of `projectType` * - `cbom` / `saasbom` command overrides * - `cdxgen-secure` mode * - Dry-run mode * - Standards-driven specVersion default * - HBOM formulation suppression * * **Mutates `options` in place** and returns a list of warnings that the * caller should surface to the user (e.g. via `console.warn`). * * @param {object} options The options object from Phase 2 * @param {object} context * @param {string} context.invokedCommandName Binary name (e.g. "cbom") * @param {boolean} context.userSetSpecVersion True when the user explicitly * passed `--spec-version` * @param {boolean} [context.isDryRun=false] Current dry-run state * @returns {OptionsWarning[]} Warnings to surface to the user */ export function applyPostConstructionOverrides(options, context) { const { invokedCommandName, userSetSpecVersion, isDryRun = false } = context; const warnings = []; if (options.projectType && Array.isArray(options.projectType)) { options.projectType = Array.from(new Set(options.projectType)); } // --- cbom / saasbom command overrides ---------------------------------- if (["cbom", "saasbom"].includes(invokedCommandName)) { if (invokedCommandName.includes("cbom")) { if (normalizeCycloneDxComponentTypeFilter(options.componentType).length) { warnings.push({ level: "error", message: "The cbom command does not support --component-type. Use cdxgen with --include-crypto when you need component-type filtering.", }); } options.includeCrypto = true; } options.evidence = true; // Only set the default spec version if the user did not explicitly provide one if (!userSetSpecVersion) { options.specVersion = DEFAULT_CDX_SPEC_VERSION; } options.deep = true; } // --- cdxgen-secure command overrides ----------------------------------- if (invokedCommandName.includes("cdxgen-secure")) { warnings.push({ level: "info", message: "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; } // --- Dry-run mode overrides -------------------------------------------- if (isDryRun) { options.installDeps = false; } // When a standard is requested, the minimum spec version is 1.6 if (options.standard) { if (!userSetSpecVersion) { options.specVersion = DEFAULT_CDX_SPEC_VERSION; } else if (options.specVersion < 1.6) { warnings.push({ level: "warn", message: "WARNING: Standards (definition.standards) requires CycloneDX specifications version 1.6 or above. Overriding the version to 1.6 automatically.", }); options.specVersion = 1.6; } } // --- HBOM formulation suppression -------------------------------------- const isHbomOnly = isHbomOnlyProjectTypes(options.projectType); if (options.includeFormulation && isHbomOnly) { warnings.push({ level: "info", message: "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) { warnings.push({ level: "warn", message: `WARNING: The formulation section may include sensitive data such as emails and secrets. This data will be submitted to '${options.serverUrl}' automatically.`, }); } else { warnings.push({ level: "info", message: "NOTE: The formulation section may include sensitive data such as emails and secrets.\nPlease review the generated SBOM before distribution or LLM training.", }); } } return warnings; } /** * Apply advanced option expansion based on `profile`, `lifecycle`, and * `technique` settings. This is the extracted form of the * `applyAdvancedOptions()` function that was previously inline in * `bin/cdxgen.js`. * * **Mutates `options` in place.** * * Design note: Several profiles unconditionally override user flags like * `deep`, `evidence`, and `installDeps`. This is intentional — the profile * is a "meta-flag" that sets a curated combination. When users select a * profile, they opt into its opinionated defaults. * * @param {object} options The options object * @param {object} [context] * @param {boolean} [context.isSecureMode] Whether cdxgen runs in secure mode * @returns {OptionsWarning[]} Warnings for the caller */ export function applyAdvancedOptions(options, context = {}) { const secureMode = context.isSecureMode ?? isSecureMode; const warnings = []; const isHbomOnly = isHbomOnlyProjectTypes(options.projectType); // --- Profile expansion ------------------------------------------------- switch (options.profile) { case "appsec": options.deep = true; options.bomAudit = true; break; case "research": options.deep = true; options.evidence = true; options.includeCrypto = true; break; case "operational": if (options?.projectType) { // Avoid duplicate "os" entries when the user already specified it if (!options.projectType.includes("os")) { 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 = !secureMode; break; case "deep-learning": case "ml-deep": process.env.FETCH_LICENSE = "true"; options.deep = true; options.evidence = true; options.includeCrypto = true; options.installDeps = !secureMode; options.bomAudit = true; break; default: break; } // --- Lifecycle expansion ----------------------------------------------- switch (options.lifecycle) { case "pre-build": options.installDeps = false; break; case "post-build": if ( !options.projectType || !POST_BUILD_SUPPORTED_PROJECT_TYPES.has(options.projectType[0]) ) { warnings.push({ level: "error", message: "PREVIEW: post-build lifecycle SBOM generation is supported only for limited project types.", }); } options.installDeps = true; break; default: break; } // --- Technique expansion ----------------------------------------------- if (options?.technique && Array.isArray(options.technique)) { if (options.technique.includes("source-code-analysis")) { options.deep = true; options.evidence = true; } } // --- BOM audit → auto-formulation -------------------------------------- if (options.bomAudit) { if (isHbomOnly) { // HBOM-only: skip formulation } else if (!options.includeFormulation) { warnings.push({ level: "info", message: "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.", }); options.includeFormulation = true; } } return warnings; } /** * Build the complete `options` object from parsed CLI arguments. * * This is the single entry point that replaces the ~330 lines of inline * imperative code in `bin/cdxgen.js`. It orchestrates all four phases: * * 1. Command-name alias expansion * 2. Initial options construction (field renames, derived values) * 3. Post-construction overrides (dedup, command flags, specVersion) * 4. Advanced options (profile, lifecycle, technique) * * Returns both the options object and a list of warnings that the caller * (typically `bin/cdxgen.js`) should surface to the user. * * @param {object} args Raw parsed yargs argv * @param {object} context * @param {string} context.invokedCommandName Binary name without extension * @param {string} context.filePath Resolved source path * @param {boolean} context.isRemoteOrPurl True for URL/purl sources * @param {boolean} context.userSetSpecVersion True when the user explicitly * passed `--spec-version` * @param {boolean} [context.isDryRun=false] Dry-run mode * @param {boolean} [context.isSecureMode] Secure mode * @returns {{ options: object, warnings: OptionsWarning[] }} */ export function buildOptionsFromArgs(args, context) { const { invokedCommandName, filePath, isRemoteOrPurl, userSetSpecVersion, isDryRun = false, } = context; const expandedArgs = applyCommandNameDefaults(args, invokedCommandName); const options = buildInitialOptions(expandedArgs, { filePath, isRemoteOrPurl, }); const phase3Warnings = applyPostConstructionOverrides(options, { invokedCommandName, userSetSpecVersion, isDryRun, }); const phase4Warnings = applyAdvancedOptions(options, { isSecureMode: context.isSecureMode, }); return { options, warnings: [...phase3Warnings, ...phase4Warnings], }; }