UNPKG

@cyclonedx/cdxgen

Version:

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

1,767 lines (1,675 loc) 698 kB
import { Buffer } from "node:buffer"; import { spawnSync } from "node:child_process"; import { createHash, randomUUID } from "node:crypto"; import { chmodSync, constants, copyFileSync, createReadStream, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync, } from "node:fs"; import { homedir, platform, tmpdir } from "node:os"; import path, { delimiter as _delimiter, sep as _sep, basename, dirname, extname, join, relative, resolve, } from "node:path"; import process from "node:process"; import { fileURLToPath, URL } from "node:url"; import toml from "@iarna/toml"; import { load } from "cheerio"; import { parseEDNString } from "edn-data"; import { globSync } from "glob"; import got from "got"; import iconv from "iconv-lite"; import Keyv from "keyv"; import StreamZip from "node-stream-zip"; import { PackageURL } from "packageurl-js"; import propertiesReader from "properties-reader"; import { clean, coerce, compare, maxSatisfying, parse, satisfies, valid, } from "semver"; import { xml2js } from "xml-js"; import { parse as _load, parseAllDocuments } from "yaml"; import { getTreeWithPlugin } from "../managers/piptree.js"; import { IriValidationStrategy, validateIri } from "../parsers/iri.js"; import Arborist from "../third-party/arborist/lib/index.js"; import { analyzeSuspiciousJsFile } from "./analyzer.js"; import { DEFAULT_HBOM_AUDIT_CATEGORIES } from "./auditCategories.js"; import { parseWorkflowFile } from "./ciParsers/githubActions.js"; import { addDosaiSetValue, buildDosaiPurlAliasMap, dosaiSourceLocation, dosaiSourceLocationFromNode, resolveDosaiComponentPurl, } from "./dosaiParsers.js"; import { extractPackageInfoFromHintPath } from "./dotnetutils.js"; import { createOccurrenceEvidence, parseOccurrenceEvidenceLocation, } from "./evidenceUtils.js"; import { createGtfoBinsPropertiesFromRow } from "./gtfobins.js"; import { thoughtLog, traceLog } from "./logger.js"; import { createLolbasProperties } from "./lolbas.js"; import { createOsQueryFallbackBomRef, createOsQueryPurl, deriveOsQueryDescription, deriveOsQueryName, deriveOsQueryPublisher, deriveOsQueryVersion, sanitizeOsQueryIdentity, shouldCreateOsQueryPurl, } from "./osqueryTransform.js"; import { collectPyLockFileComponents, collectPyLockPackageProperties, collectPyLockTopLevelProperties, getPyLockPackages, isDefaultPypiRegistry, isPyLockObject, } from "./pylockutils.js"; import { get_python_command_from_env, getVenvMetadata } from "./pythonutils.js"; import { collectCargoRegistryProvenanceProperties, collectNpmRegistryProvenanceProperties, collectPypiRegistryProvenanceProperties, } from "./registryProvenance.js"; let url = import.meta?.url; if (url && !url.startsWith("file://")) { url = new URL(`file://${import.meta.url}`).toString(); } // TODO: verify if this is a good method (Prabhu) // this is due to dirNameStr being "cdxgen/lib/helpers" which causes errors export const dirNameStr = url ? dirname(dirname(dirname(fileURLToPath(url)))) : __dirname; export const isSecureMode = ["true", "1"].includes(process.env?.CDXGEN_SECURE_MODE) || process.env?.NODE_OPTIONS?.includes("--permission"); // CLI dry-run must be detected during module initialization because some probes // execute while modules are imported, before bin/cdxgen.js can thread options. const hasDryRunArg = process.argv?.some( (arg) => arg === "--dry-run" || arg === "--dry-run=true" || arg === "--dry-run=1", ); export let isDryRun = ["true", "1"].includes(process.env?.CDXGEN_DRY_RUN) || hasDryRunArg; export const isNode = globalThis.process?.versions?.node !== undefined; export const isBun = globalThis.Bun?.version !== undefined; export const isDeno = globalThis.Deno?.version?.deno !== undefined; export const isWin = platform() === "win32"; export const isMac = platform() === "darwin"; export const DRY_RUN_ERROR_CODE = "CDXGEN_DRY_RUN"; const activityLedger = []; let activityCounter = 0; let currentActivityContext = {}; const dryRunReadTraceState = globalThis.__cdxgenDryRunReadTraceState || (globalThis.__cdxgenDryRunReadTraceState = { environmentReads: new Map(), observations: new Map(), recordActivity: undefined, sensitiveFileReads: new Map(), }); const SENSITIVE_ENV_VAR_PATTERN = /(^|_)(?:token|key|secret|pass(?:word)?|credential(?:s)?|cred|auth|session|cookie|email|user)$/i; const DIRECTORY_DISCOVERY_NAMES = new Set([ ".cargo", ".docker", ".gem", ".github", ".m2", ".nuget", ".venv", ".yarn", "blobs", "extensions", "node_modules", "target", "vendor", ]); const LOCKFILE_ACTIVITY_HINTS = new Map([ [ "bun.lock", { classification: "lockfile", ecosystem: "bun", label: "Bun lockfile" }, ], [ "cargo.lock", { classification: "lockfile", ecosystem: "cargo", label: "Cargo lockfile" }, ], [ "composer.lock", { classification: "lockfile", ecosystem: "composer", label: "Composer lockfile", }, ], [ "gemfile.lock", { classification: "lockfile", ecosystem: "rubygems", label: "Bundler lockfile", }, ], [ "package-lock.json", { classification: "lockfile", ecosystem: "npm", label: "npm lockfile" }, ], [ "packages.lock.json", { classification: "lockfile", ecosystem: "nuget", label: "NuGet lockfile" }, ], [ "pdm.lock", { classification: "lockfile", ecosystem: "python", label: "PDM lockfile" }, ], [ "pnpm-lock.yaml", { classification: "lockfile", ecosystem: "pnpm", label: "pnpm lockfile" }, ], [ "poetry.lock", { classification: "lockfile", ecosystem: "python", label: "Poetry lockfile", }, ], [ "podfile.lock", { classification: "lockfile", ecosystem: "cocoapods", label: "CocoaPods lockfile", }, ], [ "pylock.toml", { classification: "lockfile", ecosystem: "python", label: "PEP 751 lockfile", }, ], [ "uv.lock", { classification: "lockfile", ecosystem: "python", label: "uv lockfile" }, ], [ "yarn.lock", { classification: "lockfile", ecosystem: "yarn", label: "Yarn lockfile" }, ], ]); const MANIFEST_ACTIVITY_HINTS = new Map([ [ "cargo.toml", { classification: "manifest", ecosystem: "cargo", label: "Cargo manifest" }, ], [ "composer.json", { classification: "manifest", ecosystem: "composer", label: "Composer manifest", }, ], [ "gemfile", { classification: "manifest", ecosystem: "rubygems", label: "Gem manifest", }, ], [ "package.json", { classification: "manifest", ecosystem: "npm", label: "package manifest" }, ], [ "pom.xml", { classification: "manifest", ecosystem: "maven", label: "Maven manifest" }, ], [ "pyproject.toml", { classification: "manifest", ecosystem: "python", label: "Python project manifest", }, ], [ "requirements.txt", { classification: "manifest", ecosystem: "python", label: "Python requirements manifest", }, ], [ "setup.py", { classification: "manifest", ecosystem: "python", label: "Python setup manifest", }, ], ]); const SENSITIVE_CONFIG_ACTIVITY_HINTS = [ { matcher: (lowerPath, _baseName) => lowerPath.includes("/.cargo/config.toml") || lowerPath.endsWith("/.cargo/credentials") || lowerPath.endsWith("/.cargo/credentials.toml"), metadata: { classification: "config", ecosystem: "cargo", label: "Cargo registry configuration", sensitive: true, }, }, { matcher: (lowerPath, baseName) => lowerPath.includes("/.docker/config.json") || (baseName === "config.json" && lowerPath.includes("/docker")), metadata: { classification: "credential", ecosystem: "oci", label: "Docker credential file", sensitive: true, }, }, { matcher: (lowerPath) => lowerPath.endsWith("/.gem/credentials"), metadata: { classification: "credential", ecosystem: "rubygems", label: "RubyGems credentials file", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === ".npmrc" || baseName === ".pnpmrc" || baseName === ".yarnrc", metadata: { classification: "config", ecosystem: "npm", label: "JavaScript package manager configuration", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === ".yarnrc.yml", metadata: { classification: "config", ecosystem: "yarn", label: "Yarn configuration", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === ".pypirc" || baseName === "pip.conf", metadata: { classification: "config", ecosystem: "python", label: "Python package publishing configuration", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === "uv.toml" || baseName === "poetry.toml", metadata: { classification: "config", ecosystem: "python", label: "Python package manager configuration", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === "nuget.config", metadata: { classification: "config", ecosystem: "nuget", label: "NuGet configuration", sensitive: true, }, }, { matcher: (_lowerPath, baseName) => baseName === "settings.xml", metadata: { classification: "config", ecosystem: "maven", label: "Maven settings.xml", sensitive: true, }, }, ]; const CERTIFICATE_FILE_EXTENSIONS = new Set([".crt", ".cer", ".pem"]); const KEY_FILE_EXTENSIONS = new Set([ ".key", ".jks", ".keystore", ".p12", ".pfx", ]); const buildReadCountSuffix = (count) => (count > 1 ? ` (${count} times)` : ""); const buildEnvironmentReadReason = (varName, count, sensitive) => `Read ${sensitive ? "sensitive " : ""}environment variable ${varName}${buildReadCountSuffix(count)}.`; const buildSensitiveFileReadReason = (filePath, count, label) => `Read ${label} ${filePath}${buildReadCountSuffix(count)}.`; function emitActivity(activity) { if (typeof dryRunReadTraceState.recordActivity !== "function") { return undefined; } return dryRunReadTraceState.recordActivity(activity); } function classifyActivityPath(filePath) { if (typeof filePath !== "string" || !filePath.length) { return undefined; } const normalizedPath = filePath.replaceAll("\\", "/"); const lowerPath = normalizedPath.toLowerCase(); const baseName = basename(lowerPath); if (LOCKFILE_ACTIVITY_HINTS.has(baseName)) { return LOCKFILE_ACTIVITY_HINTS.get(baseName); } if (MANIFEST_ACTIVITY_HINTS.has(baseName)) { return MANIFEST_ACTIVITY_HINTS.get(baseName); } for (const { matcher, metadata } of SENSITIVE_CONFIG_ACTIVITY_HINTS) { if (matcher(lowerPath, baseName)) { return metadata; } } if ( lowerPath.includes("/cache/") || lowerPath.includes("/.cache/") || lowerPath.includes("/caches/") ) { return { classification: "cache", label: "cache path", }; } if ( CERTIFICATE_FILE_EXTENSIONS.has(extname(baseName)) || baseName === "cert.pem" ) { return { classification: "certificate", label: "certificate file", sensitive: true, }; } if ( KEY_FILE_EXTENSIONS.has(extname(baseName)) || baseName === "key.pem" || baseName.startsWith("id_") ) { return { classification: "key", label: "private key file", sensitive: true, }; } const trimmedPath = normalizedPath.endsWith("/") ? normalizedPath.slice(0, -1) : normalizedPath; const directoryName = basename(trimmedPath.toLowerCase()); if (DIRECTORY_DISCOVERY_NAMES.has(directoryName)) { return { classification: "directory", label: "directory discovery path", }; } return undefined; } function classifyDiscoveryPattern(pattern) { const patternValue = Array.isArray(pattern) ? pattern.join(",") : String(pattern); const lowerPattern = patternValue.toLowerCase(); if ( lowerPattern.includes("package-lock.json") || lowerPattern.includes("pnpm-lock.yaml") || lowerPattern.includes("yarn.lock") || lowerPattern.includes("poetry.lock") || lowerPattern.includes("uv.lock") || lowerPattern.includes("cargo.lock") || lowerPattern.includes("gemfile.lock") ) { return { discoveryType: "lockfile-discovery", label: "lockfile discovery", }; } if ( lowerPattern.includes("package.json") || lowerPattern.includes("pom.xml") || lowerPattern.includes("pyproject.toml") || lowerPattern.includes("cargo.toml") || lowerPattern.includes("composer.json") ) { return { discoveryType: "manifest-discovery", label: "manifest discovery", }; } return { discoveryType: "directory-enumeration", label: "directory enumeration", }; } function recordDeduplicatedRead(traceMap, traceKey, activity, createReason) { const existingTrace = traceMap.get(traceKey); if (existingTrace) { existingTrace.count += 1; if (existingTrace.entry) { existingTrace.entry.count = existingTrace.count; existingTrace.entry.reason = createReason(existingTrace.count); } return existingTrace.entry; } const entry = emitActivity({ ...activity, reason: createReason(1), }); if (entry) { entry.count = 1; } traceMap.set(traceKey, { count: 1, entry, }); return entry; } export function isSensitiveEnvironmentVariableName(varName) { return typeof varName === "string" && SENSITIVE_ENV_VAR_PATTERN.test(varName); } export function recordObservedActivity(kind, target, options = {}) { if (!(isDryRun || DEBUG_MODE) || !kind || !target) { return undefined; } const status = options.status || "completed"; const traceKey = options.traceKey || `${kind}:${status}:${target}:${options.traceDetail || ""}`; const metadata = options.metadata || {}; const reasonBuilder = options.reasonBuilder || ((count) => options.reason ? `${options.reason}${buildReadCountSuffix(count)}` : `Recorded ${kind} activity for ${target}${buildReadCountSuffix(count)}.`); return recordDeduplicatedRead( dryRunReadTraceState.observations, traceKey, { kind, status, target, ...metadata, }, reasonBuilder, ); } export function recordDecisionActivity(target, options = {}) { return recordObservedActivity(options.kind || "decision", target, options); } export function recordDiscoveryActivity(target, options = {}) { return recordObservedActivity(options.kind || "discover", target, options); } export function recordPolicyActivity(target, options = {}) { return recordObservedActivity(options.kind || "policy", target, options); } function normalizeRecordedPathForComparison( candidatePath, basePath = undefined, ) { if (typeof candidatePath !== "string" || !candidatePath.length) { return undefined; } let normalizedPath = candidatePath.replaceAll("\\", "/"); if (basePath && path.isAbsolute(candidatePath)) { const resolvedBasePath = resolve(basePath); const normalizedBasePath = resolvedBasePath.replaceAll("\\", "/"); const isWithinBasePath = (candidate) => { const normalizedCandidate = candidate.replaceAll("\\", "/"); return ( normalizedCandidate === normalizedBasePath || normalizedCandidate.startsWith(`${normalizedBasePath}/`) ); }; const resolvedCandidatePath = resolve(candidatePath); if (isWithinBasePath(resolvedCandidatePath)) { normalizedPath = relative( resolvedBasePath, resolvedCandidatePath, ).replaceAll("\\", "/"); } else { const rebasedCandidatePath = resolve( resolvedBasePath, candidatePath.replace(/^([A-Za-z]:)?[\\/]+/, ""), ); if (isWithinBasePath(rebasedCandidatePath)) { normalizedPath = relative( resolvedBasePath, rebasedCandidatePath, ).replaceAll("\\", "/"); } } } return normalizedPath; } export function recordSymlinkResolution( sourcePath, resolvedPath, options = {}, ) { const normalizedSourcePath = normalizeRecordedPathForComparison( sourcePath, options.basePath, ); const normalizedResolvedPath = normalizeRecordedPathForComparison( resolvedPath, options.basePath, ); const status = options.status || "completed"; if ( !normalizedSourcePath || (status === "completed" && (!normalizedResolvedPath || normalizedSourcePath === normalizedResolvedPath)) ) { return undefined; } const metadata = { capability: "symlink-resolution", ...(normalizedResolvedPath ? { resolvedPath: normalizedResolvedPath } : {}), ...(options.errorCode ? { errorCode: options.errorCode } : {}), ...(options.metadata || {}), }; return recordObservedActivity("symlink-resolution", normalizedSourcePath, { metadata, reason: options.reason || (status === "failed" ? `Failed to resolve symlink ${normalizedSourcePath}.` : `Resolved symlink ${normalizedSourcePath} to ${normalizedResolvedPath}.`), status, }); } function getArchiveSourceByteSize(sourcePath) { if (!sourcePath || !safeExistsSync(sourcePath)) { return undefined; } try { const sourceStats = lstatSync(sourcePath); return sourceStats.isFile() ? sourceStats.size : undefined; } catch { return undefined; } } export function recordEnvironmentRead(varName, options = {}) { // Read tracing intentionally mirrors the activity ledger's dry-run/debug behavior. if (!(isDryRun || DEBUG_MODE) || !varName) { return undefined; } const source = options.source || "process.env"; const sensitive = options.sensitive ?? isSensitiveEnvironmentVariableName(varName); const status = options.status || "completed"; const traceKey = `${source}:${varName}:${status}`; const target = `${source}:${varName}`; return recordDeduplicatedRead( dryRunReadTraceState.environmentReads, traceKey, { kind: "env", redacted: sensitive, secretCategory: sensitive ? "environment-variable" : undefined, sensitive, status, target, }, (count) => options.reason || buildEnvironmentReadReason(varName, count, sensitive), ); } export function recordSensitiveFileRead(filePath, options = {}) { // Read tracing intentionally mirrors the activity ledger's dry-run/debug behavior. if (!(isDryRun || DEBUG_MODE) || !filePath) { return undefined; } const kind = options.kind || "read"; const pathMetadata = classifyActivityPath(filePath) || {}; const label = options.label || pathMetadata.label || "sensitive file"; const status = options.status || "completed"; const traceKey = `${kind}:${status}:${filePath}`; return recordDeduplicatedRead( dryRunReadTraceState.sensitiveFileReads, traceKey, { classification: pathMetadata.classification, ecosystem: pathMetadata.ecosystem, kind, redacted: pathMetadata.sensitive ?? true, secretCategory: pathMetadata.classification === "key" ? "private-key" : pathMetadata.classification === "certificate" ? "certificate" : "credential-file", status, target: filePath, }, (count) => options.reason || buildSensitiveFileReadReason(filePath, count, label), ); } export function readEnvironmentVariable(varName, options = {}) { recordEnvironmentRead(varName, options); return process.env[varName]; } export function setDryRunMode(enabled) { isDryRun = !!enabled; if (enabled) { process.env.CDXGEN_DRY_RUN = "true"; return; } delete process.env.CDXGEN_DRY_RUN; } export function createDryRunError(action, target, reason) { const message = reason || `Dry run mode blocked the attempted ${action} operation.`; const error = new Error(message); error.code = DRY_RUN_ERROR_CODE; error.name = "DryRunError"; error.action = action; error.target = target; error.dryRun = true; return error; } export function isDryRunError(error) { return !!(error?.dryRun || error?.code === DRY_RUN_ERROR_CODE); } export function setActivityContext(context = {}) { currentActivityContext = { ...currentActivityContext, ...context, }; } export function resetActivityContext() { currentActivityContext = {}; } export function recordActivity(activity) { if (!(isDryRun || DEBUG_MODE)) { return undefined; } const identifier = `ACT-${String(++activityCounter).padStart(4, "0")}`; const entry = { identifier, ...currentActivityContext, timestamp: new Date().toISOString(), ...activity, }; activityLedger.push(entry); traceLog("activity", entry); return entry; } dryRunReadTraceState.recordActivity = recordActivity; export function getRecordedActivities() { return [...activityLedger]; } export function resetRecordedActivities() { activityLedger.length = 0; activityCounter = 0; dryRunReadTraceState.environmentReads.clear(); dryRunReadTraceState.observations.clear(); dryRunReadTraceState.sensitiveFileReads.clear(); } function recordFilesystemActivity( kind, target, status, reason = undefined, metadata = {}, ) { return recordActivity({ kind, ...metadata, reason, status, target, }); } function hasReadPermission(filePath) { if (!(isSecureMode && process.permission)) { return true; } return process.permission.has("fs.read", join(filePath, "", "*")); } function hasWritePermission(filePath) { if (!(isSecureMode && process.permission)) { return true; } const candidatePaths = [ filePath, join(filePath, "", "*"), join(dirname(filePath), "*"), ]; return candidatePaths.some((candidatePath) => process.permission.has("fs.write", candidatePath), ); } /** * Safely check if a file path exists without crashing due to a lack of permissions * * @param {String} filePath File path * @Boolean True if the path exists. False otherwise */ export function safeExistsSync(filePath) { const pathMetadata = classifyActivityPath(filePath); if (!hasReadPermission(filePath)) { if (DEBUG_MODE) { console.log("cdxgen lacks read permission for a requested path."); } if (pathMetadata) { recordPolicyActivity(filePath, { metadata: { classification: pathMetadata.classification, ecosystem: pathMetadata.ecosystem, policyType: "fs.read", }, reason: `Denied inspection of ${pathMetadata.label} ${filePath} due to missing fs.read permission.`, status: "blocked", }); } return false; } const exists = existsSync(filePath); if (pathMetadata) { const inspectionKind = pathMetadata.classification === "directory" ? "discover" : "inspect"; recordObservedActivity(inspectionKind, filePath, { metadata: { classification: pathMetadata.classification, ecosystem: pathMetadata.ecosystem, exists, redacted: pathMetadata.sensitive ?? false, }, reasonBuilder: (count) => `${exists ? "Inspected" : "Checked for"} ${pathMetadata.label} ${filePath}${buildReadCountSuffix(count)}.`, }); } return exists; } export function safeWriteSync(filePath, data, options) { if (isDryRun) { recordFilesystemActivity( "write", filePath, "blocked", "Dry run mode blocks filesystem writes.", ); return undefined; } if (!hasWritePermission(filePath)) { if (DEBUG_MODE) { console.log("cdxgen lacks write permission for a requested path."); } recordFilesystemActivity( "write", filePath, "blocked", "cdxgen lacks write permission for this path.", ); return undefined; } writeFileSync(filePath, data, options); recordFilesystemActivity("write", filePath, "completed"); } /** * Safely create a directory without crashing due to a lack of permissions * * @param {String} filePath File path * @param options {Options} mkdir options * @Boolean True if the path exists. False otherwise */ export function safeMkdirSync(filePath, options) { if (isDryRun) { recordFilesystemActivity( "mkdir", filePath, "blocked", "Dry run mode blocks directory creation.", ); return undefined; } if (!hasWritePermission(filePath)) { if (DEBUG_MODE) { console.log("cdxgen lacks write permission for a requested path."); } recordFilesystemActivity( "mkdir", filePath, "blocked", "cdxgen lacks write permission for this path.", ); return undefined; } mkdirSync(filePath, options); recordFilesystemActivity("mkdir", filePath, "completed"); } export function safeMkdtempSync(prefix, options = undefined) { const resourceType = typeof prefix === "string" && prefix.toLowerCase().includes("cache") ? "cache" : "temporary-workspace"; if (isDryRun) { const tempPath = `${prefix}${randomUUID().replaceAll("-", "").slice(0, 6)}`; recordFilesystemActivity( "temp-dir", tempPath, "blocked", `Dry run mode blocks temporary directory creation for ${resourceType}.`, { resourceType, }, ); return tempPath; } const tempPath = mkdtempSync(prefix, options); recordFilesystemActivity("temp-dir", tempPath, "completed", undefined, { resourceType, }); return tempPath; } export function safeRmSync(filePath, options = undefined) { if (isDryRun) { recordFilesystemActivity( "cleanup", filePath, "blocked", "Dry run mode blocks filesystem deletions.", ); return undefined; } rmSync(filePath, options); recordFilesystemActivity("cleanup", filePath, "completed"); } export function safeUnlinkSync(filePath) { if (isDryRun) { recordFilesystemActivity( "cleanup", filePath, "blocked", "Dry run mode blocks file deletions.", ); return undefined; } unlinkSync(filePath); recordFilesystemActivity("cleanup", filePath, "completed"); } export function safeCopyFileSync(src, dest, mode = undefined) { if (isDryRun) { recordFilesystemActivity( "write", dest, "blocked", `Dry run mode blocks copying files from ${src}.`, ); return undefined; } const result = mode === undefined ? copyFileSync(src, dest) : copyFileSync(src, dest, mode); recordFilesystemActivity("write", dest, "completed", `Copied from ${src}.`); return result; } export async function safeExtractArchive( sourcePath, targetPath, extractor, kind = "unzip", options = undefined, ) { const traceArchiveStats = isDryRun || DEBUG_MODE; const sourceBytes = traceArchiveStats ? getArchiveSourceByteSize(sourcePath) : undefined; if (isDryRun) { recordActivity({ archiveKind: kind, capability: "archive-extraction", kind, ...(options?.metadata || {}), ...(sourceBytes !== undefined ? { sourceBytes } : {}), reason: options?.blockedReason || `Dry run mode blocks ${kind} extraction from ${sourcePath} into ${targetPath}.`, status: "blocked", target: `${sourcePath} -> ${targetPath}`, }); return false; } try { await extractor(); recordActivity({ archiveKind: kind, capability: "archive-extraction", kind, ...(options?.metadata || {}), ...(sourceBytes !== undefined ? { sourceBytes } : {}), status: "completed", target: `${sourcePath} -> ${targetPath}`, }); return true; } catch (error) { recordActivity({ archiveKind: kind, capability: "archive-extraction", kind, ...(options?.metadata || {}), ...(sourceBytes !== undefined ? { sourceBytes } : {}), ...(error?.code ? { errorCode: error.code } : {}), reason: options?.failureReason || `Failed ${kind} extraction from ${sourcePath} into ${targetPath}: ${error.message}`, status: "failed", target: `${sourcePath} -> ${targetPath}`, }); throw error; } } export const commandsExecuted = new Set(); function isAllowedCommand( command, allowedCommandsEnv = readEnvironmentVariable("CDXGEN_ALLOWED_COMMANDS"), ) { if (!allowedCommandsEnv) { return true; } return allowedCommandsEnv .split(",") .map((entry) => entry.trim()) .includes(command.trim()); } const ALLOWED_WRAPPERS = new Set(["gradlew", "mvnw"]); /** * Check for Windows CWD executable hijack when shell: true is used. * cmd.exe searches CWD before PATH, allowing local files to shadow system commands. * * @param {string} command The executable to spawn * @param {Object} options Options forwarded to spawnSync (e.g. cwd, env, shell) * * @returns {boolean} true if there is a hijack risk. false otherwise. */ function isWindowsShellHijackRisk(command, options) { const cwd = options?.cwd; const usesShell = options?.shell === true; if (!isWin || !usesShell || !cwd || !command) { return false; } if (/[\/\\]/.test(command)) { return false; } const cmdBase = command.toLowerCase(); if (ALLOWED_WRAPPERS.has(cmdBase)) { return false; } const pathExt = ( process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC" ) .split(";") .filter(Boolean); const candidates = [ cmdBase, ...pathExt.map((ext) => cmdBase + ext.toLowerCase()), ]; const absCwd = resolve(cwd); for (const candidate of candidates) { const candidatePath = path.join(absCwd, candidate); if (existsSync(candidatePath)) { return true; } } return false; } const VERSION_PROBE_ARGS = new Set(["--version", "-version", "version"]); const POSIX_SHELL_METACHARACTERS = /[;&|<>$`\\\n\r]/; const WINDOWS_SHELL_METACHARACTERS = /[&|<>^%\n\r]/; function hasShellMetacharacters(value) { if (value === undefined || value === null) { return false; } const stringValue = String(value); return isWin ? WINDOWS_SHELL_METACHARACTERS.test(stringValue) : POSIX_SHELL_METACHARACTERS.test(stringValue); } function getUnsafeShellToken(command, args) { if (hasShellMetacharacters(command)) { return command; } const argList = Array.isArray(args) ? args : args === undefined || args === null ? [] : [args]; return argList.find((arg) => hasShellMetacharacters(arg)); } function recordSuspiciousShellPathActivities(files, metadata = {}) { for (const file of files) { if (!hasShellMetacharacters(file)) { continue; } recordActivity({ classification: "suspicious-path", discoveryType: metadata.discoveryType, kind: "inspect", pattern: metadata.pattern, reason: "Suspicious path contains shell metacharacters. cdxgen passes direct process arguments as argv values, but review this path before invoking external build tools on untrusted projects.", risk: "shell-metacharacters", status: "completed", target: file, }); } } function detectProbeType(command, args = []) { const normalizedCommand = basename(String(command || "")).toLowerCase(); const normalizedArgs = (args || []).map((arg) => String(arg).toLowerCase()); if ( normalizedArgs.some((arg) => VERSION_PROBE_ARGS.has(arg)) || (normalizedArgs.length === 1 && normalizedArgs[0] === "-v") ) { return "version-check"; } if (normalizedCommand === "which" || normalizedArgs.includes("--help")) { return "capability-probe"; } if ( normalizedCommand.startsWith("python") && normalizedArgs.includes("-c") && normalizedArgs.some((arg) => arg.includes("import")) ) { return "runtime-probe"; } return undefined; } function buildCommandActivityDescriptor(command, args, options) { const target = `${command}${args?.length ? ` ${args.join(" ")}` : ""}`; const cdxgenActivity = options?.cdxgenActivity || {}; const probeType = cdxgenActivity.probeType || detectProbeType(command, args); const metadata = { ...(cdxgenActivity.metadata || {}), }; if (probeType) { metadata.capability = metadata.capability || "tool-runtime-probe"; metadata.probeType = probeType; } if (cdxgenActivity.gitOperation) { metadata.gitOperation = cdxgenActivity.gitOperation; } return { blockedReason: cdxgenActivity.blockedReason || (probeType ? `Dry run mode blocks ${probeType.replaceAll("-", " ")} command execution.` : "Dry run mode blocks child process execution."), kind: cdxgenActivity.kind || "execute", metadata, target: cdxgenActivity.target || target, }; } function getOutputByteSize(value, encoding = "utf-8") { if (value === undefined || value === null) { return 0; } if (Buffer.isBuffer(value)) { return value.length; } if (ArrayBuffer.isView(value)) { return value.byteLength; } const safeEncoding = typeof encoding === "string" && encoding !== "buffer" ? encoding : "utf8"; return Buffer.byteLength(String(value), safeEncoding); } /** * Safe wrapper around spawnSync that enforces permission checks, injects default * options (maxBuffer, encoding, timeout), warns about unsafe Python and pip/uv * invocations, and records every executed command in the commandsExecuted set. * * @param {string} command The executable to spawn * @param {string[]} args Arguments to pass to the command * @param {Object} options Options forwarded to spawnSync (e.g. cwd, env, shell) * @returns {Object} spawnSync result object with status, stdout, stderr, and error fields */ export function safeSpawnSync(command, args, options) { const activityDescriptor = buildCommandActivityDescriptor( command, args, options, ); const allowedCommandsEnv = readEnvironmentVariable("CDXGEN_ALLOWED_COMMANDS"); const commandAllowed = isAllowedCommand(command, allowedCommandsEnv); if (allowedCommandsEnv) { recordPolicyActivity(command, { metadata: { allowed: commandAllowed, allowlist: allowedCommandsEnv, policyType: "command-allowlist", }, reason: `${commandAllowed ? "Allowed" : "Blocked"} command ${command} against CDXGEN_ALLOWED_COMMANDS.`, status: commandAllowed ? "completed" : "blocked", traceDetail: "allowlist", }); } if (isSecureMode && process.permission) { const hasChildPermission = process.permission.has("child"); recordPolicyActivity(command, { metadata: { allowed: hasChildPermission, policyType: "child-process", }, reason: `${hasChildPermission ? "Confirmed" : "Denied"} child-process permission for ${command}.`, status: hasChildPermission ? "completed" : "blocked", traceDetail: "child-permission", }); } if (isDryRun) { const error = createDryRunError( "execute", command, activityDescriptor.blockedReason, ); recordActivity({ kind: activityDescriptor.kind, ...activityDescriptor.metadata, reason: error.message, status: "blocked", target: activityDescriptor.target, }); return { status: 1, stdout: undefined, stderr: undefined, error, }; } if ( (isSecureMode && process.permission && !process.permission.has("child")) || !commandAllowed ) { if (DEBUG_MODE) { console.log(`cdxgen lacks execute permission for ${command}`); } recordActivity({ kind: activityDescriptor.kind, ...activityDescriptor.metadata, reason: "cdxgen lacks execute permission for this command.", status: "blocked", target: activityDescriptor.target, }); return { status: 1, stdout: undefined, stderr: undefined, error: new Error("No execute permission"), }; } if (isSecureMode) { if (isWindowsShellHijackRisk(command, options)) { const blockedReason = `${command} matches local file in cwd (Windows shell hijack risk)`; console.warn(`\x1b[1;31mSecurity Alert: ${blockedReason}\x1b[0m`); recordActivity({ kind: activityDescriptor.kind, ...activityDescriptor.metadata, reason: blockedReason, status: "blocked", target: activityDescriptor.target, }); return { status: 1, stdout: undefined, stderr: undefined, error: new Error(blockedReason), }; } if (options?.cwd && options.cwd !== resolve(options.cwd)) { if (DEBUG_MODE) { console.log( "Executing commands with a relative cwd can cause security issues.", ); } } } if (!options) { options = {}; } else if (options.cdxgenActivity) { options = { ...options, }; } if (options.cdxgenActivity) { delete options.cdxgenActivity; } if (options.shell === true) { const unsafeShellToken = getUnsafeShellToken(command, args); if (unsafeShellToken !== undefined) { const blockedReason = `Blocked shell execution for ${command}: command or argument contains shell metacharacters.`; console.warn(`\x1b[1;31mSecurity Alert: ${blockedReason}\x1b[0m`); recordActivity({ kind: activityDescriptor.kind, ...activityDescriptor.metadata, reason: blockedReason, status: "blocked", target: activityDescriptor.target, }); return { status: 1, stdout: undefined, stderr: undefined, error: new Error(blockedReason), }; } } // Inject maxBuffer if (!options.maxBuffer) { options.maxBuffer = MAX_BUFFER; } // Inject encoding if (!options.encoding) { options.encoding = "utf-8"; } // Inject timeout if (!options.timeout) { options.timeout = TIMEOUT_MS; } // Emit certain operational warnings only once per process to keep audit logs readable. const emitNoticeOnce = (noticeKey, message, level = "warn") => { if (!globalThis.__cdxgenNoticeCache) { globalThis.__cdxgenNoticeCache = new Set(); } if (globalThis.__cdxgenNoticeCache.has(noticeKey)) { return; } globalThis.__cdxgenNoticeCache.add(noticeKey); if (level === "log") { console.log(message); return; } console.warn(message); }; // Check for -S for python invocations in secure mode if (command.includes("python") && (!args?.length || args[0] !== "-S")) { if (isSecureMode) { emitNoticeOnce( "python-without-S-secure", "\x1b[1;35mNotice: Running python command without '-S' argument. This is a bug in cdxgen. Please report with an example repo here https://github.com/cdxgen/cdxgen/issues.\x1b[0m", ); } else if (process.env?.CDXGEN_IN_CONTAINER === "true") { emitNoticeOnce( "python-without-S-container", "Running python command without '-S' argument.", "log", ); } else { emitNoticeOnce( "python-without-S-host", "\x1b[1;35mNotice: Running python command without '-S' argument. Only run cdxgen in trusted directories to prevent auto-executing local scripts.\x1b[0m", ); } } let isPyPackageInstall = false; if (command.includes("pip") && args?.includes("install")) { isPyPackageInstall = true; } else if ( command.includes("python") && args?.includes("pip") && args?.includes("install") ) { isPyPackageInstall = true; } else if ( command.includes("uv") && args?.includes("pip") && args?.includes("install") ) { isPyPackageInstall = true; } if (isPyPackageInstall) { const hasOnlyBinary = args?.some( (arg) => arg === "--only-binary" || arg.startsWith("--only-binary="), ); if (!hasOnlyBinary) { if (isSecureMode) { emitNoticeOnce( "pip-without-only-binary-secure", "\x1b[1;31mSecurity Alert: pip/uv install invoked without '--only-binary' argument in secure mode. This is a bug in cdxgen and introduces Arbitrary Code Execution (ACE) risks. Please report with an example repo here https://github.com/cdxgen/cdxgen/issues.\x1b[0m", ); } else if (process.env?.CDXGEN_IN_CONTAINER === "true") { emitNoticeOnce( "pip-without-only-binary-container", "Running pip/uv install without '--only-binary' argument.", "log", ); } else { emitNoticeOnce( "pip-without-only-binary-host", "\x1b[1;35mNotice: pip/uv install invoked without '--only-binary'. This allows executing untrusted setup.py scripts. Only run cdxgen in trusted directories.\x1b[0m", ); } } } traceLog("spawn", { command, args, ...options }); commandsExecuted.add(command); // Fix for DEP0190 warning if (options?.shell === true) { if (args?.length) { command = `${command} ${args.join(" ")}`; args = undefined; } } const result = spawnSync(command, args, options); recordActivity({ kind: activityDescriptor.kind, ...activityDescriptor.metadata, stderrBytes: getOutputByteSize(result.stderr, options.encoding), reason: result.error?.message, status: result.status === 0 && !result.error ? "completed" : "failed", stdoutBytes: getOutputByteSize(result.stdout, options.encoding), target: activityDescriptor.target, }); return result; } const licenseMapping = JSON.parse( readFileSync(join(dirNameStr, "data", "lic-mapping.json"), "utf-8"), ); const vendorAliases = JSON.parse( readFileSync(join(dirNameStr, "data", "vendor-alias.json"), "utf-8"), ); const spdxLicenses = JSON.parse( readFileSync(join(dirNameStr, "data", "spdx-licenses.json"), "utf-8"), ); const knownLicenses = JSON.parse( readFileSync(join(dirNameStr, "data", "known-licenses.json"), "utf-8"), ); const mesonWrapDB = JSON.parse( readFileSync(join(dirNameStr, "data", "wrapdb-releases.json"), "utf-8"), ); export const frameworksList = JSON.parse( readFileSync(join(dirNameStr, "data", "frameworks-list.json"), "utf-8"), ); const selfPJson = JSON.parse( readFileSync(join(dirNameStr, "package.json"), "utf-8"), ); const CPP_STD_MODULES = JSON.parse( readFileSync(join(dirNameStr, "data", "glibc-stdlib.json"), "utf-8"), ); export const CDXGEN_VERSION = selfPJson.version; // Refer to contrib/py-modules.py for a script to generate this list // The script needs to be used once every few months to update this list const PYTHON_STD_MODULES = JSON.parse( readFileSync(join(dirNameStr, "data", "python-stdlib.json"), "utf-8"), ); // Mapping between modules and package names const PYPI_MODULE_PACKAGE_MAPPING = JSON.parse( readFileSync(join(dirNameStr, "data", "pypi-pkg-aliases.json"), "utf-8"), ); // FIXME. This has to get removed, once we improve the module detection one-liner. // If you're a Rubyist, please help us improve this code. const RUBY_KNOWN_MODULES = JSON.parse( readFileSync(join(dirNameStr, "data", "ruby-known-modules.json"), "utf-8"), ); // Debug mode flag export const DEBUG_MODE = ["debug", "verbose"].includes(process.env.CDXGEN_DEBUG_MODE) || process.env.SCAN_DEBUG_MODE === "debug"; export const CDXGEN_SPDX_CREATED_BY = process.env.CDXGEN_SPDX_CREATED_BY; // Table border style for console output. export const TABLE_BORDER_STYLE = ["ascii", "unicode", "auto"].includes( `${process.env.CDXGEN_TABLE_BORDER || ""}`.toLowerCase(), ) ? `${process.env.CDXGEN_TABLE_BORDER}`.toLowerCase() : "auto"; // Timeout milliseconds. Default 20 mins export const TIMEOUT_MS = Number.parseInt(process.env.CDXGEN_TIMEOUT_MS, 10) || 20 * 60 * 1000; // Max buffer for stdout and stderr. Defaults to 100MB export const MAX_BUFFER = Number.parseInt(process.env.CDXGEN_MAX_BUFFER, 10) || 100 * 1024 * 1024; // Metadata cache export let metadata_cache = {}; // Speed up lookup namespaces for a given jar const jarNSMapping_cache = {}; // Temporary files written by cdxgen, will be removed on exit const temporaryFiles = new Set(); process.on("exit", () => temporaryFiles.forEach((tempFile) => { if (safeExistsSync(tempFile)) { safeUnlinkSync(tempFile); } }), ); // Whether test scope shall be included for java/maven projects; default, if unset shall be 'true' export const includeMavenTestScope = !process.env.CDX_MAVEN_INCLUDE_TEST_SCOPE || ["true", "1"].includes(process.env.CDX_MAVEN_INCLUDE_TEST_SCOPE); // Whether to use the native maven dependency tree command. Defaults to true. export const PREFER_MAVEN_DEPS_TREE = !["false", "0"].includes( process.env?.PREFER_MAVEN_DEPS_TREE, ); /** * Determines whether license information should be fetched from remote sources, * based on the FETCH_LICENSE environment variable. * * @returns {boolean} True if the FETCH_LICENSE env var is set to "true" or "1" */ export function shouldFetchLicense() { return ( process.env.FETCH_LICENSE && ["true", "1"].includes(process.env.FETCH_LICENSE) ); } /** * Determines whether remote package metadata should be fetched for enrichment. * * @returns {boolean} True when registry metadata enrichment is enabled. */ export function shouldFetchPackageMetadata() { return ( shouldFetchLicense() || (process.env.CDXGEN_FETCH_PKG_METADATA && ["true", "1"].includes(process.env.CDXGEN_FETCH_PKG_METADATA)) ); } /** * Determines whether VCS (version control system) information should be fetched * for Go packages, based on the GO_FETCH_VCS environment variable. * * @returns {boolean} True if the GO_FETCH_VCS env var is set to "true" or "1" */ export function shouldFetchVCS() { return ( process.env.GO_FETCH_VCS && ["true", "1"].includes(process.env.GO_FETCH_VCS) ); } // Whether license information should be fetched export const FETCH_LICENSE = shouldFetchLicense(); // Whether search.maven.org will be used to identify jars without maven metadata; default, if unset shall be 'true' export const SEARCH_MAVEN_ORG = !process.env.SEARCH_MAVEN_ORG || ["true", "1"].includes(process.env.SEARCH_MAVEN_ORG); // circuit breaker for search maven.org let search_maven_org_errors = 0; const MAX_SEARCH_MAVEN_ORG_ERRORS = 1; // circuit breaker for get repo license let get_repo_license_errors = 0; const MAX_GET_REPO_LICENSE_ERRORS = 5; const MAX_LICENSE_ID_LENGTH = 100; export const JAVA_CMD = getJavaCommand(); /** * Returns the Java executable command to use, resolved in priority order: * JAVA_CMD env var > JAVA_HOME/bin/java > "java". * * @returns {string} Path or name of the Java executable */ export function getJavaCommand() { let javaCmd = "java"; if (process.env.JAVA_CMD) { javaCmd = process.env.JAVA_CMD; } else if ( process.env.JAVA_HOME && safeExistsSync(process.env.JAVA_HOME) && safeExistsSync(join(process.env.JAVA_HOME, "bin", "java")) ) { javaCmd = join(process.env.JAVA_HOME, "bin", "java"); } return javaCmd; } export const PYTHON_CMD = getPythonCommand(); /** * Returns the Python executable command to use, resolved in priority order: * PYTHON_CMD env var > CONDA_PYTHON_EXE env var > "python". * * @returns {string} Path or name of the Python executable */ export function getPythonCommand() { let pythonCmd = "python"; if (process.env.PYTHON_CMD) { pythonCmd = process.env.PYTHON_CMD; } else if (process.env.CONDA_PYTHON_EXE) { pythonCmd = process.env.CONDA_PYTHON_EXE; } return pythonCmd; } export let DOTNET_CMD = "dotnet"; if (process.env.DOTNET_CMD) { DOTNET_CMD = process.env.DOTNET_CMD; } export let NODE_CMD = "node"; if (process.env.NODE_CMD) { NODE_CMD = process.env.NODE_CMD; } export let NPM_CMD = "npm"; if (process.env.NPM_CMD) { NPM_CMD = process.env.NPM_CMD; } export let YARN_CMD = "yarn"; if (process.env.YARN_CMD) { YARN_CMD = process.env.YARN_CMD; } export let GCC_CMD = "gcc"; if (process.env.GCC_CMD) { GCC_CMD = process.env.GCC_CMD; } export let RUSTC_CMD = "rustc"; if (process.env.RUSTC_CMD) { RUSTC_CMD = process.env.RUSTC_CMD; } export let GO_CMD = "go"; if (process.env.GO_CMD) { GO_CMD = process.env.GO_CMD; } export let CARGO_CMD = "cargo"; if (process.env.CARGO_CMD) { CARGO_CMD = process.env.CARGO_CMD; } // Clojure CLI export let CLJ_CMD = "clj"; if (process.env.CLJ_CMD) { CLJ_CMD = process.env.CLJ_CMD; } export let LEIN_CMD = "lein"; if (process.env.LEIN_CMD) { LEIN_CMD = process.env.LEIN_CMD; } export let CDXGEN_TEMP_DIR = "temp"; if (process.env.CDXGEN_TEMP_DIR) { CDXGEN_TEMP_DIR = process.env.CDXGEN_TEMP_DIR; } export const SWIFT_CMD = process.env.SWIFT_CMD || "swift"; export const RUBY_CMD = process.env.RUBY_CMD || "ruby"; // Python components that can be excluded export const PYTHON_EXCLUDED_COMPONENTS = [ "pip", "setuptools", "wheel", "conda", "conda-build", "conda-index", "conda-libmamba-solver", "conda-package-handling", "conda-package-streaming", "conda-content-trust", ]; // Project type aliases export const PROJECT_TYPE_ALIASES = { java: [ "java", "java8", "java11", "java17", "java21", "java22", "java23", "java24", "groovy", "kotlin", "kt", "scala", "jvm", "gradle", "mvn", "maven", "sbt", "bazel", "quarkus", "mill", ], android: ["android", "apk", "aab"], jar: ["jar", "war", "ear"], "gradle-index": ["gradle-index", "gradle-cache"], "sbt-index": ["sbt-index", "sbt-cache"], "maven-index": ["maven-index", "maven-cache", "maven-core"], "cargo-cache": ["cargo-cache", "cargo-index"], js: [ "npm", "pnpm", "nodejs", "nodejs8", "nodejs10", "nodejs12", "nodejs14", "nodejs16", "nodejs18", "nodejs20", "n