@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
440 lines (409 loc) • 13.8 kB
JavaScript
import {
chmodSync,
existsSync,
readFileSync,
realpathSync,
unlinkSync,
} from "node:fs";
import { createRequire } from "node:module";
import { arch, platform, tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { thoughtLog } from "./logger.js";
let SaferExec;
try {
({ SaferExec } = await import("@cdxgen/safer-exec"));
} catch {
SaferExec = undefined;
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Parses a command string into command and arguments array.
* @param {string} cmdStr - Command string to parse
* @returns {{cmd: string, args: string[]}} Parsed command and arguments
*/
export function parseCommand(cmdStr) {
const args = [];
let current = "";
let inDoubleQuote = false;
let inSingleQuote = false;
for (let i = 0; i < cmdStr.length; i++) {
const char = cmdStr[i];
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
} else if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
} else if (char === " " && !inDoubleQuote && !inSingleQuote) {
if (current) {
args.push(current);
current = "";
}
} else {
current += char;
}
}
if (current) {
args.push(current);
}
return {
cmd: args[0],
args: args.slice(1),
};
}
/**
* Custom cdxgen resolver for @cdxgen/safer-exec binary dependency.
* Validates existence and ensures executable permissions to prevent EACCES issues.
*
* @returns {string|undefined} Path to the resolved binary or undefined if not found
*/
export function resolveSaferExecBinary() {
const currentPlatform = platform();
const currentArch = arch();
let pkgName = "";
if (currentPlatform === "darwin") {
if (currentArch === "arm64") {
pkgName = "@cdxgen/safer-exec-darwin-arm64";
} else if (currentArch === "x64") {
pkgName = "@cdxgen/safer-exec-darwin-amd64";
}
} else if (currentPlatform === "linux") {
if (currentArch === "x64") {
pkgName = "@cdxgen/safer-exec-linux-amd64";
} else if (currentArch === "arm64") {
pkgName = "@cdxgen/safer-exec-linux-arm64";
}
}
if (!pkgName) {
return undefined;
}
try {
const require = createRequire(import.meta.url);
const mainPkgPath = require.resolve("@cdxgen/safer-exec");
// Resolve standard pnpm, npm, and yarn physical locations of node_modules relative to resolved package file
const searchDirs = [];
let curDir = dirname(mainPkgPath);
while (curDir && curDir !== dirname(curDir)) {
if (basename(curDir) === "node_modules") {
searchDirs.push(curDir);
}
const nodeModulesSub = join(curDir, "node_modules");
if (existsSync(nodeModulesSub)) {
searchDirs.push(nodeModulesSub);
}
curDir = dirname(curDir);
}
for (const modulesDir of searchDirs) {
// Direct structure under node_modules
const directPath = join(modulesDir, pkgName, "bin", "safer-exec");
let realDirectPath;
try {
realDirectPath = realpathSync(directPath);
} catch (_err) {
realDirectPath = directPath;
}
if (existsSync(realDirectPath)) {
try {
chmodSync(realDirectPath, 0o755);
} catch (_err) {
// ignore
}
return realDirectPath;
}
}
} catch (err) {
console.log(
"[cdxgen trace] error resolving safer-exec package path:",
err.message,
);
}
return undefined;
}
/**
* Executes a command under safer-exec tracing and returns an array of loaded library paths
* and collected HTTP access entries.
*
* @param {string} commandStr - Command to execute and trace
* @param {string} [workingDir] - Working directory for the command
* @param {Object} [options] - Additional sandbox options
* @param {string[]} [options.readPaths] - Extra filesystem read paths merged with READ_PATHS
* @param {string[]} [options.writePaths] - Sandbox write paths (default: [tmpdir()])
* @param {number} [options.maxMemoryMB] - Max memory in MB (default: TRACE_MAX_MEMORY_MB)
* @param {number} [options.maxCPUCores] - Max CPU cores as fractional number
* @param {number} [options.maxProcesses] - Max process count (default: TRACE_MAX_PROCESSES)
* @param {number} [options.timeoutMs] - Trace timeout in ms (default: TRACE_TIMEOUT_MS)
* @param {boolean} [options.disableNetwork] - Disable network in sandbox (default: true)
* @param {boolean} [options.traceHTTPURLs] - Enable eBPF-based HTTP URL tracing (Linux only)
* @param {number} [options.tracePeriod] - Stop tracing after N seconds (for long-running commands)
* @param {boolean} [options.sanitizeEnv] - Strip sensitive env vars before sandboxed execution
* @param {boolean} [options.enableDiff] - Enable filesystem mutation diffing
* @param {boolean} [options.strict] - Treat sandbox setup warnings as hard errors
* @param {string[]} [options.allowHosts] - Hostnames to allow network access to
* @param {number[]} [options.allowPorts] - TCP ports to allow
* @param {string[]} [options.allowUrls] - URL-based allow rules (Linux, requires traceHTTPURLs)
* @param {boolean} [options.blockFork] - Prevent forking new processes
* @param {boolean} [options.traceExec] - Log every child process spawned
* @param {string[]} [options.allowExec] - Executables the command is allowed to run
* @param {string[]} [options.blockExec] - Executables to block from running
* @returns {Promise<{libPaths: string[], httpAccessEntries: Object[]}>} Collected libraries and HTTP URLs
*/
export async function executeAndTrace(commandStr, workingDir, options = {}) {
const emptyResult = { libPaths: [], httpAccessEntries: [] };
if (!commandStr) {
return emptyResult;
}
const { cmd, args } = parseCommand(commandStr);
if (!cmd) {
return emptyResult;
}
thoughtLog(
`Executing and tracing command: ${cmd} with args: ${args.join(", ")} in dir: ${workingDir || process.cwd()}`,
);
if (!SaferExec) {
return emptyResult;
}
try {
const exec = new SaferExec();
if (workingDir) {
exec.workingDir(workingDir);
}
const detectedBinary = resolveSaferExecBinary();
if (detectedBinary) {
console.log(
`[cdxgen trace] detected safer-exec go binary: ${detectedBinary}`,
);
exec.binaryPath(detectedBinary);
}
exec.traceLibraries().suppressLibLoadStderr(true).enableAudit();
// Apply sandbox options
if (options.disableNetwork !== false && !options.traceHTTPURLs) {
exec.disableNetwork();
}
if (options.readPaths?.length) {
exec.readPaths(options.readPaths);
}
if (options.writePaths?.length) {
exec.writePaths(options.writePaths);
}
if (options.maxMemoryMB != null) {
exec.maxMemory(options.maxMemoryMB);
}
if (options.maxProcesses != null) {
exec.maxProcesses(options.maxProcesses);
}
if (options.maxCPUCores != null) {
exec.maxCPUCores(options.maxCPUCores);
}
if (options.timeoutMs != null) {
exec.timeout(options.timeoutMs);
}
// Sanitize environment
if (options.sanitizeEnv) {
exec.sanitizeEnv(true);
}
if (options.allowEnvs?.length) {
exec.allowEnvs(...options.allowEnvs);
}
if (options.allowHidden != null) {
exec.allowHidden(options.allowHidden);
}
if (options.allowListen?.length) {
exec.allowListen(options.allowListen);
}
if (options.cryptoProbeMode) {
exec.cryptoProbeMode(options.cryptoProbeMode);
}
// Filesystem diff
if (options.enableDiff) {
exec.enableDiff();
}
// Strict mode
if (options.strict) {
exec.strict();
}
// Network allow lists
if (options.allowHosts?.length) {
exec.allowHosts(...options.allowHosts);
}
if (options.allowPorts?.length) {
exec.allowPorts(...options.allowPorts);
}
if (options.allowUrls?.length) {
exec.allowUrls(...options.allowUrls);
}
// Fork and exec control
if (options.blockFork) {
exec.blockFork();
}
if (options.traceExec) {
exec.traceExec();
}
if (options.allowExec?.length) {
exec.allowExec(...options.allowExec);
}
if (options.blockExec?.length) {
exec.blockExec(...options.blockExec);
}
// Enable HTTP URL tracing
if (options.traceHTTPURLs) {
exec.traceHTTPURLs();
}
// Set trace period as timeout to auto-stop long-running commands
if (options.tracePeriod != null && options.tracePeriod > 0) {
const periodMs = options.tracePeriod * 1000;
exec.timeout(periodMs);
}
// Collect HTTP URLs and crypto from audit events
const collectedUrls = [];
const collectedCrypto = [];
exec.on("audit", (entry) => {
if (entry?.type === "http-request") {
collectedUrls.push(entry);
} else if (
entry?.type === "crypto-library" ||
entry?.type === "crypto-cipher"
) {
collectedCrypto.push(entry);
}
});
let tempCbomPath;
if (options.traceCrypto !== false) {
exec.traceCrypto();
if (options.cbom) {
exec.cbom(options.cbom);
} else {
tempCbomPath = join(
tmpdir(),
`cdxgen-cbom-${Math.random().toString(36).substring(2, 15)}.json`,
);
exec.cbom(tempCbomPath);
}
}
const result = await exec.run(cmd, args);
if (result && result.exitCode !== 0 && result.stderr) {
if (result.stderr.includes("[safer-exec] Error:")) {
console.error(
"Tracing launcher execution failed:",
result.stderr.trim(),
);
}
} else if (options.traceHTTPURLs && result?.stderr) {
// Surface eBPF/http-trace warnings even when exit code is 0
const stderr = result.stderr || "";
if (
stderr.includes("http-trace") ||
stderr.includes("httptrace") ||
stderr.includes("SSL/TLS libraries")
) {
console.warn("[cdxgen trace] HTTP URL tracing warning:", stderr.trim());
}
}
// Read and parse CBOM file if temp/explicit path was used
let cryptoComponents = [];
const cbomPathToRead = options.cbom || tempCbomPath;
if (cbomPathToRead && existsSync(cbomPathToRead)) {
try {
const cbomData = JSON.parse(readFileSync(cbomPathToRead, "utf8"));
if (cbomData && Array.isArray(cbomData.components)) {
cryptoComponents = cbomData.components;
}
} catch (err) {
console.warn("[cdxgen trace] failed to parse CBOM:", err.message);
} finally {
if (tempCbomPath) {
try {
unlinkSync(tempCbomPath);
} catch (_err) {
// ignore
}
}
}
}
// Also collect any http-request entries from the audit log that arrived after event emission
if (result?.auditLog) {
const libs = result.auditLog
.filter((e) => e.type === "lib-load")
.map((e) => e.target)
.filter(Boolean);
const urls = result.auditLog
.filter((e) => e.type === "http-request")
.filter(
(e) =>
!collectedUrls.some(
(u) =>
u.host === e.host && u.path === e.path && u.method === e.method,
),
);
const cryptoLogs = result.auditLog
.filter(
(e) => e.type === "crypto-library" || e.type === "crypto-cipher",
)
.filter(
(e) =>
!collectedCrypto.some(
(c) => c.type === e.type && c.name === e.name && c.pid === e.pid,
),
);
return {
libPaths: Array.from(new Set(libs)),
httpAccessEntries: [...collectedUrls, ...urls],
cryptoEntries: [...collectedCrypto, ...cryptoLogs],
cryptoComponents,
};
}
return {
libPaths: [],
httpAccessEntries: collectedUrls,
cryptoEntries: collectedCrypto,
cryptoComponents,
};
} catch (err) {
console.error("Tracing command execution failed:", err);
}
return emptyResult;
}
/**
* Groups HTTP access entries into a CycloneDX services-ready map.
* Each unique (host, port, protocol) combination becomes a service.
*
* @param {Object[]} httpAccessEntries - Collected HTTP access entries
* @returns {Object.<string, { endpoints: Set<string>, properties: Object[] }>} Services map
*/
export function groupHttpEntriesToServices(httpAccessEntries) {
const servicesMap = {};
for (const entry of httpAccessEntries) {
const serviceName = `dynamic-${entry.host}-${entry.port || 443}`;
if (!servicesMap[serviceName]) {
servicesMap[serviceName] = {
endpoints: new Set(),
properties: [],
};
}
const endpoint = `https://${entry.host}${entry.port && entry.port !== 443 ? `:${entry.port}` : ""}${entry.path || "/"}`;
servicesMap[serviceName].endpoints.add(endpoint);
if (entry.method) {
const methodProp = {
name: "cdx:service:httpMethod",
value: entry.method,
};
if (
!servicesMap[serviceName].properties.some(
(p) => p.name === methodProp.name && p.value === methodProp.value,
)
) {
servicesMap[serviceName].properties.push(methodProp);
}
}
if (entry.query) {
const queryProp = { name: "cdx:dynamic:httpQuery", value: entry.query };
if (
!servicesMap[serviceName].properties.some(
(p) => p.name === queryProp.name && p.value === queryProp.value,
)
) {
servicesMap[serviceName].properties.push(queryProp);
}
}
}
return servicesMap;
}