UNPKG

@cyclonedx/cdxgen

Version:

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

711 lines (670 loc) 22.4 kB
import { basename } from "node:path"; import { getHbomCommandDiagnosticSummary } from "./hbomAnalysis.js"; import { importHbomModule } from "./hbomLoader.js"; import { resolveCdxgenPlugins, resolvePluginBinary } from "./plugins.js"; import { isAllowedPath } from "./source.js"; import { isDryRun, isSecureMode, readEnvironmentVariable, recordActivity, } from "./utils.js"; const HBOM_PROJECT_TYPE_SET = new Set(["hardware", "hbom"]); const HBOM_TRACE_PATH_ACTIVITY_KINDS = new Set([ "dir-read", "file-read", "mkdir", "symlink-read", ]); function parseAllowlistEntries(allowlistValue) { if (typeof allowlistValue !== "string") { return []; } return allowlistValue .split(",") .map((entry) => entry.trim()) .filter(Boolean); } function getConfiguredHbomCommandAllowlist() { return readEnvironmentVariable("CDXGEN_ALLOWED_COMMANDS"); } function getConfiguredHbomPathAllowlist() { return ( readEnvironmentVariable("CDXGEN_ALLOWED_PATHS") || readEnvironmentVariable("CDXGEN_SERVER_ALLOWED_PATHS") ); } function isAllowedHbomCommand(command, allowlistValue = undefined) { const normalizedCommand = `${command ?? ""}`.trim(); if (!normalizedCommand) { return true; } const effectiveAllowlist = allowlistValue ?? getConfiguredHbomCommandAllowlist(); const allowlistEntries = parseAllowlistEntries(effectiveAllowlist); if (!allowlistEntries.length) { return true; } const commandName = basename(normalizedCommand); return allowlistEntries.some( (entry) => entry === normalizedCommand || entry === commandName, ); } function formatHbomCommandTarget(command, args = []) { return `${command}${args.length ? ` ${args.join(" ")}` : ""}`; } function buildHbomPlanActivities(planEntry, normalizedOptions) { const baseArgs = [...(planEntry.args || [])]; const baseTarget = formatHbomCommandTarget(planEntry.command, baseArgs); const activities = [ { args: baseArgs, command: planEntry.command, id: planEntry.id, kind: "command", target: baseTarget, }, ]; if ( normalizedOptions.includePrivilegedEnrichment === true && planEntry.sudoRetryOnPermissionDenied === true ) { activities.push({ args: ["-n", planEntry.command, ...baseArgs], command: "sudo", id: `${planEntry.id}:sudo-retry`, kind: "command-retry", target: formatHbomCommandTarget("sudo", [ "-n", planEntry.command, ...baseArgs, ]), }); } return activities; } function collectDisallowedHbomCommands( activities = [], allowlistValue = undefined, ) { const disallowedCommands = new Map(); for (const activity of activities) { if (!new Set(["command", "command-retry"]).has(activity?.kind)) { continue; } const requestedCommand = `${ activity.retryCommand ?? activity.command ?? activity.requestedCommand ?? "" }`.trim(); if ( !requestedCommand || isAllowedHbomCommand(requestedCommand, allowlistValue) ) { continue; } const existingEntry = disallowedCommands.get(requestedCommand) ?? { command: requestedCommand, commandName: basename(requestedCommand), ids: new Set(), targets: new Set(), }; if (activity.id) { existingEntry.ids.add(`${activity.id}`); } const formattedTarget = `${ activity.target || formatHbomCommandTarget(requestedCommand, activity.args) }`.trim(); if (formattedTarget) { existingEntry.targets.add(formattedTarget); } disallowedCommands.set(requestedCommand, existingEntry); } return [...disallowedCommands.values()].sort((leftEntry, rightEntry) => leftEntry.command.localeCompare(rightEntry.command), ); } function collectDisallowedHbomPaths(activities = []) { const disallowedPaths = new Map(); for (const activity of activities) { if (!HBOM_TRACE_PATH_ACTIVITY_KINDS.has(activity?.kind)) { continue; } const declaredPath = `${activity.path ?? activity.target ?? ""}`.trim(); if (!declaredPath || isAllowedPath(declaredPath)) { continue; } const existingEntry = disallowedPaths.get(declaredPath) ?? { activityKinds: new Set(), ids: new Set(), path: declaredPath, }; existingEntry.activityKinds.add(`${activity.kind}`); if (activity.id) { existingEntry.ids.add(`${activity.id}`); } disallowedPaths.set(declaredPath, existingEntry); } return [...disallowedPaths.values()].sort((leftEntry, rightEntry) => leftEntry.path.localeCompare(rightEntry.path), ); } function createHbomAllowlistPreflightError( disallowedCommands, disallowedPaths, ) { const messageLines = [ "HBOM secure-mode preflight blocked live collection because the dry-run plan includes resources outside the configured allowlists.", ]; if (disallowedCommands.length) { messageLines.push("", "Commands not allowed by CDXGEN_ALLOWED_COMMANDS:"); for (const commandEntry of disallowedCommands) { const commandSuffix = commandEntry.commandName !== commandEntry.command ? ` (basename: ${commandEntry.commandName})` : ""; const detailParts = []; if (commandEntry.ids.size) { detailParts.push(`ids=${[...commandEntry.ids].join(",")}`); } if (commandEntry.targets.size) { detailParts.push(`targets=${[...commandEntry.targets].join(" | ")}`); } messageLines.push( `- ${commandEntry.command}${commandSuffix}${detailParts.length ? ` — ${detailParts.join("; ")}` : ""}`, ); } } if (disallowedPaths.length) { messageLines.push("", "Paths not allowed by CDXGEN_ALLOWED_PATHS:"); for (const pathEntry of disallowedPaths) { const detailParts = []; if (pathEntry.activityKinds.size) { detailParts.push(`kinds=${[...pathEntry.activityKinds].join(",")}`); } if (pathEntry.ids.size) { detailParts.push(`ids=${[...pathEntry.ids].join(",")}`); } messageLines.push( `- ${pathEntry.path}${detailParts.length ? ` — ${detailParts.join("; ")}` : ""}`, ); } } messageLines.push( "", "Review 'hbom --dry-run' (or 'cdxgen --dry-run -t hbom') to inspect the planned commands and declared paths, then expand CDXGEN_ALLOWED_COMMANDS and CDXGEN_ALLOWED_PATHS before retrying secure mode.", ); return new Error(messageLines.join("\n")); } async function enforceSecureModeHbomAllowlists(hbomModule, normalizedOptions) { if (isDryRun || normalizedOptions?.isDryRun || !isSecureMode) { return; } const commandAllowlistValue = getConfiguredHbomCommandAllowlist(); const pathAllowlistValue = getConfiguredHbomPathAllowlist(); const hasCommandAllowlist = parseAllowlistEntries(commandAllowlistValue).length > 0; const hasPathAllowlist = parseAllowlistEntries(pathAllowlistValue).length > 0; if (!hasCommandAllowlist && !hasPathAllowlist) { return; } let traceActivities = []; if (hasPathAllowlist || typeof hbomModule.getCommandPlan !== "function") { if (typeof hbomModule.createCollectorTrace !== "function") { throw new Error( "HBOM secure mode requires a cdx-hbom build with dry-run trace support to enforce the configured allowlists. Upgrade '@cdxgen/cdx-hbom' and retry.", ); } const preflightTrace = hbomModule.createCollectorTrace(); const preflightBom = await hbomModule.collectHardware({ ...normalizedOptions, dryRun: true, trace: preflightTrace, }); traceActivities = Array.isArray( hbomModule.getCollectorTrace?.(preflightBom)?.activities, ) ? hbomModule.getCollectorTrace(preflightBom).activities : Array.isArray(preflightTrace?.activities) ? preflightTrace.activities : []; } else if ( hasCommandAllowlist && normalizedOptions.includeCommandEnrichment !== false ) { traceActivities = hbomModule .getCommandPlan(normalizedOptions) .flatMap((planEntry) => buildHbomPlanActivities(planEntry, normalizedOptions), ); } const disallowedCommands = hasCommandAllowlist && normalizedOptions.includeCommandEnrichment !== false ? collectDisallowedHbomCommands(traceActivities, commandAllowlistValue) : []; const disallowedPaths = hasPathAllowlist ? collectDisallowedHbomPaths(traceActivities) : []; if (!disallowedCommands.length && !disallowedPaths.length) { return; } for (const commandEntry of disallowedCommands) { recordActivity({ kind: "policy", policyType: "hbom-command-allowlist", reason: "HBOM secure-mode preflight blocked a planned command outside CDXGEN_ALLOWED_COMMANDS.", status: "blocked", target: commandEntry.command, }); } for (const pathEntry of disallowedPaths) { recordActivity({ kind: "policy", policyType: "hbom-path-allowlist", reason: "HBOM secure-mode preflight blocked a declared path outside CDXGEN_ALLOWED_PATHS.", status: "blocked", target: pathEntry.path, }); } throw createHbomAllowlistPreflightError(disallowedCommands, disallowedPaths); } /** * Determine whether the supplied project types include HBOM. * * @param {string|string[]|undefined|null} projectTypes Project types. * @returns {boolean} True when HBOM is requested. */ export function hasHbomProjectType(projectTypes) { return normalizeProjectTypes(projectTypes).some((projectType) => HBOM_PROJECT_TYPE_SET.has(projectType), ); } /** * Determine whether the supplied project types are exclusively HBOM-oriented. * * @param {string|string[]|undefined|null} projectTypes Project types. * @returns {boolean} True when at least one project type is supplied and all are HBOM-oriented. */ export function isHbomOnlyProjectTypes(projectTypes) { const normalizedProjectTypes = normalizeProjectTypes(projectTypes); return ( normalizedProjectTypes.length > 0 && normalizedProjectTypes.every((projectType) => HBOM_PROJECT_TYPE_SET.has(projectType), ) ); } /** * Reject mixed HBOM and non-HBOM project types. * * @param {string|string[]|undefined|null} projectTypes Project types. */ export function ensureNoMixedHbomProjectTypes(projectTypes) { const normalizedProjectTypes = normalizeProjectTypes(projectTypes); if ( !normalizedProjectTypes.length || !hasHbomProjectType(normalizedProjectTypes) ) { return; } const nonHbomProjectTypes = normalizedProjectTypes.filter( (projectType) => !HBOM_PROJECT_TYPE_SET.has(projectType), ); if (nonHbomProjectTypes.length) { throw new Error( `HBOM project types cannot be mixed with other project types: ${normalizedProjectTypes.join(", ")}. Generate HBOM separately using 'hbom' or 'cdxgen -t hbom'.`, ); } } /** * Ensure HBOM generation uses the supported CycloneDX version. * * @param {number|string|undefined|null} specVersion Requested spec version. */ export function ensureSupportedHbomSpecVersion(specVersion) { if (specVersion === undefined || specVersion === null || specVersion === "") { return; } if (Number(specVersion) !== 1.7) { throw new Error("HBOM generation currently supports only CycloneDX 1.7."); } } /** * Ensure merged HBOM + runtime collection has access to osquery. * * @param {object} [options={}] CLI options. * @param {string} [commandName="hbom"] Invoked command name for tailored guidance. */ export function ensureHbomRuntimeSupport(options = {}, commandName = "hbom") { if (!options.includeRuntime) { return; } const pluginRuntime = resolveCdxgenPlugins(); const osqueryBinary = resolvePluginBinary("osquery", pluginRuntime); if (osqueryBinary) { return; } const normalizedCommandName = `${commandName || "hbom"}`.trim() || "hbom"; const standardCommandName = normalizedCommandName.endsWith("-slim") ? normalizedCommandName.replace(/-slim$/u, "") : "hbom"; const followUpGuidance = normalizedCommandName.endsWith("-slim") ? `'${normalizedCommandName}' is hardware-only by default. Use '${standardCommandName}' for bundled '--include-runtime' support, or configure OSQUERY_CMD explicitly.` : `'${standardCommandName}' is the bundled option required for '--include-runtime' support. Install the optional '@cdxgen/cdxgen-plugins-bin*' companion bundle or configure OSQUERY_CMD explicitly.`; throw new Error( `HBOM '--include-runtime' requires the osquery companion binary from '@cdxgen/cdxgen-plugins-bin*'. ${followUpGuidance}`, ); } /** * Translate cdxgen CLI options to cdx-hbom collector options. * * @param {object} [options={}] CLI options. * @returns {object} cdx-hbom collector options. */ export function normalizeHbomOptions(options = {}) { const timeoutValue = options.timeoutMs ?? options.timeout; const timeoutMs = timeoutValue === undefined || timeoutValue === null || timeoutValue === "" ? undefined : Number.parseInt(`${timeoutValue}`, 10); const includeCommandEnrichment = options.includeCommandEnrichment ?? !options.noCommandEnrichment; const allowPartial = options.allowPartial ?? !options.strict; return { allowPartial, architecture: options.arch ?? options.architecture, dryRun: options.dryRun ?? isDryRun, includeCommandEnrichment, includePlistEnrichment: options.includePlistEnrichment ?? options.plistEnrichment ?? false, includePrivilegedEnrichment: options.includePrivilegedEnrichment ?? options.privileged ?? false, includeSensitiveIdentifiers: options.includeSensitiveIdentifiers ?? options.sensitive ?? false, platform: options.platform, timeoutMs: Number.isNaN(timeoutMs) || timeoutMs <= 0 ? undefined : timeoutMs, }; } function getHbomTraceKind(activity) { if (activity.kind === "command") { return "execute"; } if (activity.kind === "file-read" || activity.kind === "symlink-read") { return "read"; } if (activity.kind === "dir-read") { return "discover"; } return activity.kind || "hbom"; } function getHbomTraceReason(activity) { if (activity.reason) { return activity.reason; } if (activity.kind === "command") { if (activity.status === "completed") { return undefined; } return `HBOM command ${activity.id || activity.command || activity.target} did not complete successfully.`; } return undefined; } function recordHbomCollectorTrace(trace) { const activities = Array.isArray(trace?.activities) ? trace.activities : []; for (const activity of activities) { recordActivity({ category: activity.category, commandId: activity.id, hbomActivityKind: activity.kind, parser: activity.parser, phase: activity.phase, purpose: activity.purpose, kind: getHbomTraceKind(activity), reason: getHbomTraceReason(activity), status: activity.status, target: activity.target, }); } } /** * Build an activity target for the requested HBOM collection. * * @param {object} [options={}] CLI options. * @returns {string} Activity target description. */ function getHbomCollectionTarget(options = {}) { const platform = options.platform ? `${options.platform}`.trim() : ""; const architecture = options.architecture ?? options.arch; const normalizedArchitecture = architecture ? `${architecture}`.trim() : ""; if (platform && normalizedArchitecture) { return `${platform}/${normalizedArchitecture}`; } if (platform || normalizedArchitecture) { return platform || normalizedArchitecture; } return "current-host"; } /** * Create a minimal CycloneDX HBOM document for dry-run mode. * * @param {object} [options={}] CLI options. * @returns {object} Synthetic CycloneDX HBOM document. */ function createDryRunHbomDocument(options = {}) { return { bomFormat: "CycloneDX", components: [], dependencies: [], metadata: { timestamp: new Date().toISOString(), tools: { components: [], }, }, specVersion: `${options.specVersion || "1.7"}`, version: 1, }; } function addUniqueStringProperty(properties, propertyName, propertyValue) { if (propertyValue === undefined || propertyValue === null) { return; } const normalizedValue = `${propertyValue}`.trim(); if (!normalizedValue) { return; } if ( properties.some( (property) => property?.name === propertyName && `${property?.value ?? ""}` === normalizedValue, ) ) { return; } properties.push({ name: propertyName, value: normalizedValue, }); } export function addHbomAnalysisProperties(bomJson) { if (!bomJson || typeof bomJson !== "object") { return bomJson; } const commandDiagnosticSummary = getHbomCommandDiagnosticSummary(bomJson); const retainedProperties = Array.isArray(bomJson.properties) ? bomJson.properties.filter( (property) => !`${property?.name || ""}`.startsWith("cdx:hbom:analysis:"), ) : []; addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:commandDiagnosticCount", commandDiagnosticSummary.commandDiagnosticCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:actionableDiagnosticCount", commandDiagnosticSummary.actionableDiagnosticCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:missingCommandCount", commandDiagnosticSummary.missingCommandCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:installHintCount", commandDiagnosticSummary.installHintCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:permissionDeniedCount", commandDiagnosticSummary.permissionDeniedCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:privilegeHintCount", commandDiagnosticSummary.privilegeHintCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:partialSupportCount", commandDiagnosticSummary.partialSupportCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:timeoutCount", commandDiagnosticSummary.timeoutCount, ); addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:commandErrorCount", commandDiagnosticSummary.commandErrorCount, ); if (commandDiagnosticSummary.diagnosticIssues.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:diagnosticIssues", commandDiagnosticSummary.diagnosticIssues.join(","), ); } if (commandDiagnosticSummary.missingCommands.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:missingCommands", commandDiagnosticSummary.missingCommands.join(","), ); } if (commandDiagnosticSummary.missingCommandIds.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:missingCommandIds", commandDiagnosticSummary.missingCommandIds.join(","), ); } if (commandDiagnosticSummary.permissionDeniedCommands.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:permissionDeniedCommands", commandDiagnosticSummary.permissionDeniedCommands.join(","), ); } if (commandDiagnosticSummary.permissionDeniedIds.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:permissionDeniedIds", commandDiagnosticSummary.permissionDeniedIds.join(","), ); } if (commandDiagnosticSummary.partialSupportIds.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:partialSupportIds", commandDiagnosticSummary.partialSupportIds.join(","), ); } if (commandDiagnosticSummary.timeoutIds.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:timeoutIds", commandDiagnosticSummary.timeoutIds.join(","), ); } if (commandDiagnosticSummary.commandErrorIds.length) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:commandErrorIds", commandDiagnosticSummary.commandErrorIds.join(","), ); } if (commandDiagnosticSummary.requiresPrivilegedEnrichment) { addUniqueStringProperty( retainedProperties, "cdx:hbom:analysis:requiresPrivileged", true, ); } bomJson.properties = retainedProperties; return bomJson; } /** * Generate an HBOM using the optional cdx-hbom package. * * @param {object} [options={}] CLI options. * @returns {Promise<object>} CycloneDX HBOM document. */ export async function createHbomDocument(options = {}) { ensureSupportedHbomSpecVersion(options.specVersion); const hbomModule = await importHbomModule(options); if (typeof hbomModule.collectHardware !== "function") { throw new Error( "The installed '@cdxgen/cdx-hbom' package does not expose collectHardware().", ); } const normalizedOptions = normalizeHbomOptions(options); await enforceSecureModeHbomAllowlists(hbomModule, normalizedOptions); if (isDryRun && typeof hbomModule.createCollectorTrace === "function") { normalizedOptions.trace = hbomModule.createCollectorTrace(); } else if (isDryRun) { recordActivity({ kind: "hbom", reason: "Dry run mode blocks HBOM collection and reports the requested host inventory instead.", status: "blocked", target: getHbomCollectionTarget(options), }); return createDryRunHbomDocument(options); } const bomJson = addHbomAnalysisProperties( await hbomModule.collectHardware(normalizedOptions), ); if (isDryRun) { recordHbomCollectorTrace( hbomModule.getCollectorTrace?.(bomJson) ?? normalizedOptions.trace, ); } return bomJson; } /** * Normalize project types to lowercase strings. * * @param {string|string[]|undefined|null} projectTypes Project types. * @returns {string[]} Normalized project types. */ function normalizeProjectTypes(projectTypes) { if (!projectTypes) { return []; } const values = Array.isArray(projectTypes) ? projectTypes : [projectTypes]; return values .flatMap((projectType) => `${projectType}`.split(",")) .map((projectType) => projectType.trim().toLowerCase()) .filter(Boolean); }