@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,591 lines (1,523 loc) • 46.7 kB
JavaScript
import { arch, homedir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import process from "node:process";
import { compareLoose } from "semver";
import {
CARGO_CMD,
DEBUG_MODE,
DOTNET_CMD,
GCC_CMD,
GO_CMD,
getJavaCommand,
getPythonCommand,
getRuntimeInformation,
getTmpDir,
isMac,
isSecureMode,
isWin,
MAX_BUFFER,
NPM_CMD,
RUBY_CMD,
RUSTC_CMD,
SWIFT_CMD,
safeExistsSync,
safeSpawnSync,
} from "./utils.js";
export const GIT_COMMAND = process.env.GIT_CMD || "git";
/**
* Config overrides applied (via `-c`) to every git invocation to neutralize
* repository-controlled code-execution vectors. A cloned/untrusted repo can set
* these in its local `.git/config`, so we force-disable them:
* - `core.fsmonitor=false` — a repo-set fsmonitor value is a command git runs.
* - `core.hooksPath=/dev/null` — prevents any hook script from executing.
* - `safe.bareRepository=explicit` — avoids operating on an embedded bare repo.
* `-c` overrides take precedence over the repo-local config. Diff-producing
* commands (`git show`/`git diff`) additionally pass `--no-ext-diff`/
* `--no-textconv` to block `.gitattributes`-driven external diff and textconv
* drivers, which are executed as shell commands.
*/
const GIT_HARDENING_CONFIG_ARGS = Object.freeze([
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
"-c",
"safe.bareRepository=explicit",
]);
/**
* Environment applied to every git invocation. Disables interactive prompts and,
* in secure mode, ignores system/global git config so only the hardened `-c`
* overrides and the (untrusted) repo config remain, mirroring hardenedGitCommand.
*
* @returns {Object} environment for spawning git
*/
function hardenedGitEnv() {
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (isSecureMode) {
env.GIT_CONFIG_NOSYSTEM = "1";
env.GIT_CONFIG_GLOBAL = "/dev/null";
}
return env;
}
// sdkman tool aliases
export const SDKMAN_JAVA_TOOL_ALIASES = {
java8: process.env.JAVA8_TOOL || "8.0.452-amzn", // Temurin no longer offers java8 :(
java11: process.env.JAVA11_TOOL || "11.0.31-tem",
java17: process.env.JAVA17_TOOL || "17.0.19-tem",
java21: process.env.JAVA21_TOOL || "21.0.11-tem",
java22: process.env.JAVA22_TOOL || "22.0.2-tem",
java23: process.env.JAVA23_TOOL || "23.0.2-tem",
java24: process.env.JAVA24_TOOL || "24.0.2-tem",
java25: process.env.JAVA25_TOOL || "25.0.3-tem",
java26: process.env.JAVA26_TOOL || "26.0.1-tem",
};
/**
* Retrieves a git config item
* @param {string} configKey Git config key
* @param {string} dir repo directory
*
* @returns Output from git config or undefined
*/
export function getGitConfig(configKey, dir) {
return execGitCommand(dir, ["config", "--get", configKey]);
}
/**
* Retrieves the git origin url
* @param {string} dir repo directory
*
* @returns Output from git config or undefined
*/
export function getOriginUrl(dir) {
return getGitConfig("remote.origin.url", dir);
}
/**
* Retrieves the git branch name
* @param {string} configKey Git config key
* @param {string} dir repo directory
*
* @returns Output from git config or undefined
*/
export function getBranch(_configKey, dir) {
return execGitCommand(dir, ["rev-parse", "--abbrev-ref", "HEAD"]);
}
/**
* Retrieves the tree and parent hash for a git repo
* @param {string} dir repo directory
*
* @returns Output from git cat-file or undefined
*/
export function gitTreeHashes(dir) {
const treeHashes = {};
const output = execGitCommand(dir, ["cat-file", "commit", "HEAD"]);
if (output) {
output.split("\n").forEach((l) => {
l = l.replace("\r", "");
if (l === "\n" || l.startsWith("#")) {
return;
}
if (l.startsWith("tree") || l.startsWith("parent")) {
const tmpA = l.split(" ");
if (tmpA && tmpA.length === 2) {
treeHashes[tmpA[0]] = tmpA[1];
}
}
});
}
return treeHashes;
}
/**
* Retrieves the files list from git
* @param {string} dir repo directory
*
* @returns Output from git config or undefined
*/
export function listFiles(dir) {
const filesList = [];
const output = execGitCommand(dir, [
"ls-tree",
"-l",
"-r",
"--full-tree",
"HEAD",
]);
if (output) {
output.split("\n").forEach((l) => {
l = l.replace("\r", "");
if (l === "\n" || l.startsWith("#")) {
return;
}
const tmpA = l.split(" ");
if (tmpA && tmpA.length >= 5) {
const lastParts = tmpA[tmpA.length - 1].split("\t");
filesList.push({
hash: tmpA[2],
name: lastParts[lastParts.length - 1],
omniborId: `gitoid:blob:sha1:${tmpA[2]}`,
swhid: `swh:1:rev:${tmpA[2]}`,
});
}
});
}
return filesList;
}
/**
* Execute a git command
*
* @param {string} dir Repo directory
* @param {Array} args arguments to git command
*
* @returns Output from the git command
*/
export function execGitCommand(dir, args) {
// Prepend hardening `-c` overrides before the subcommand and run git with a
// hardened environment so a scanned/untrusted repo cannot execute hooks,
// fsmonitor commands, or config-driven scripts.
return getCommandOutput(
GIT_COMMAND,
dir,
[...GIT_HARDENING_CONFIG_ARGS, ...args],
{
env: hardenedGitEnv(),
},
);
}
/**
* Retrieves the author names and emails from the git commit log
*
* @param {string} dir Repo directory
* @param {number} maxCount Maximum number of commits to retrieve
*
* @returns {Array<{name: string, email: string}>} Array of authors
*/
export function gitLogAuthors(dir, maxCount = 20) {
const count = safeGitLogCount(maxCount);
const output = execGitCommand(dir, [
"log",
"-n",
`${count}`,
"--format=%an|%ae",
]);
if (!output) {
return [];
}
const authors = [];
const lines = output.split("\n");
const seen = new Set();
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
if (seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
const parts = trimmed.split("|");
const name = parts[0]?.trim() || "";
const email = parts[1]?.trim() || "";
authors.push({ name, email });
}
return authors;
}
/**
* Retrieves the commit logs for a git repo, returning hashes and messages
*
* @param {string} dir Repo directory
* @param {number} maxCount Maximum number of commits to retrieve
*
* @returns {Array<{hash: string, message: string}>} Array of commit objects
*/
export function gitLogTrailers(dir, maxCount = 20) {
const count = safeGitLogCount(maxCount);
// %x1e (record separator) is a control character that cannot appear in a
// commit message, so it is a safe boundary between commits.
const output = execGitCommand(dir, [
"log",
"-n",
`${count}`,
"--format=%H%n%B%x1e",
]);
if (!output) {
return [];
}
const commits = [];
const parts = output.split("\x1e");
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const lines = trimmed.split("\n");
const hash = lines[0]?.trim();
const message = lines.slice(1).join("\n").trim();
if (hash) {
commits.push({ hash, message });
}
}
return commits;
}
/**
* Collect Java version and installed modules
*
* @param {string} dir Working directory
* @returns Object containing the java details
*/
export function collectJavaInfo(dir) {
const versionDesc = getCommandOutput(getJavaCommand(), dir, ["--version"]);
const moduleDesc =
getCommandOutput(getJavaCommand(), dir, ["--list-modules"]) || "";
if (versionDesc) {
return {
type: "platform",
name: "java",
version: versionDesc.split("\n")[0].replace("java ", ""),
description: versionDesc,
properties: [
{
name: "java:modules",
value: moduleDesc.replaceAll("\n", ", "),
},
],
};
}
return undefined;
}
/**
* Collect dotnet version
*
* @param {string} dir Working directory
* @returns Object containing dotnet details
*/
export function collectDotnetInfo(dir) {
const versionDesc = getCommandOutput(DOTNET_CMD, dir, ["--version"]);
const moduleDesc =
getCommandOutput(DOTNET_CMD, dir, ["--list-runtimes"]) || "";
if (versionDesc) {
return {
type: "platform",
name: "dotnet",
version: versionDesc.trim(),
description: moduleDesc.replaceAll("\n", "\\n"),
};
}
return undefined;
}
/**
* Collect python version
*
* @param {string} dir Working directory
* @returns Object containing python details
*/
export function collectPythonInfo(dir) {
const versionDesc = getCommandOutput(getPythonCommand(), dir, [
"-S",
"--version",
]);
const moduleDesc =
getCommandOutput(getPythonCommand(), dir, [
"-I",
"-m",
"pip",
"--version",
]) || "";
if (versionDesc) {
return {
type: "platform",
name: "python",
version: versionDesc.replace("Python ", ""),
description: moduleDesc.replaceAll("\n", "\\n"),
};
}
return undefined;
}
/**
* Collect node runtime version
*
* @param {string} dir Working directory
* @returns {Object} Object containing node details
*/
export function collectNodeInfo(dir) {
const runtimeInfo = getRuntimeInformation();
const nodeInfo = {
type: "platform",
name: runtimeInfo.runtime,
version: runtimeInfo.version,
components: runtimeInfo.components,
};
const moduleDesc = getCommandOutput(NPM_CMD, dir, ["--version"]);
if (moduleDesc) {
nodeInfo.description = `npm: ${moduleDesc}`;
}
return nodeInfo;
}
/**
* Collect gcc version
*
* @param {string} dir Working directory
* @returns Object containing gcc details
*/
export function collectGccInfo(dir) {
const versionDesc = getCommandOutput(GCC_CMD, dir, ["--version"]);
const moduleDesc = getCommandOutput(GCC_CMD, dir, ["-print-search-dirs"]);
if (versionDesc) {
return {
type: "platform",
name: "gcc",
version: versionDesc.split("\n")[0],
description: (moduleDesc || "").replaceAll("\n", "\\n"),
};
}
return undefined;
}
/**
* Collect rust version
*
* @param {string} dir Working directory
* @returns Object containing rust details
*/
export function collectRustInfo(dir) {
const versionDesc = getCommandOutput(RUSTC_CMD, dir, ["--version"]);
const moduleDesc = getCommandOutput(CARGO_CMD, dir, ["--version"]);
if (versionDesc) {
return {
type: "platform",
name: "rustc",
version: versionDesc.trim(),
description: (moduleDesc || "").trim(),
};
}
return undefined;
}
/**
* Collect go version
*
* @param {string} dir Working directory
* @returns Object containing go details
*/
export function collectGoInfo(dir) {
const versionDesc = getCommandOutput(GO_CMD, dir, ["version"]);
if (versionDesc) {
return {
type: "platform",
name: "go",
version: versionDesc.trim(),
};
}
return undefined;
}
/**
* Collect swift version
*
* @param {string} dir Working directory
* @returns Object containing swift details
*/
export function collectSwiftInfo(dir) {
const versionDesc = getCommandOutput(SWIFT_CMD, dir, ["--version"]);
if (versionDesc) {
return {
type: "platform",
name: "swift",
version: versionDesc.trim(),
};
}
return undefined;
}
/**
* Collect Ruby version
*
* @param {string} dir Working directory
* @returns Object containing Ruby details
*/
export function collectRubyInfo(dir) {
const versionDesc = getCommandOutput(RUBY_CMD, dir, ["--version"]);
if (versionDesc) {
return {
type: "platform",
name: "ruby",
version: versionDesc.trim(),
};
}
return undefined;
}
/**
* Method to run a swift command
*
* @param {String} dir Working directory
* @param {Array} args Command arguments
* @returns Object containing swift details
*/
export function runSwiftCommand(dir, args) {
return getCommandOutput(SWIFT_CMD, dir, args);
}
export function collectEnvInfo(dir) {
const infoComponents = [];
let cmp = collectJavaInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectDotnetInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectPythonInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectNodeInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectGccInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectRustInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectGoInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
cmp = collectRubyInfo(dir);
if (cmp) {
infoComponents.push(cmp);
}
return infoComponents;
}
/**
* Execute any command to retrieve the output
*
* @param {*} cmd Command to execute
* @param {*} dir working directory
* @param {*} args arguments
* @param {Object} [spawnOverrides] extra options merged into spawnSync (e.g. env)
* @returns String output from the command or undefined in case of error
*/
const getCommandOutput = (cmd, dir, args, spawnOverrides = {}) => {
let commandToUse = cmd;
// If the command includes space, automatically move it to the front of the args.
if (cmd?.trim().includes(" ")) {
const tmpA = cmd.split(" ");
commandToUse = tmpA.shift();
if (args?.length && tmpA.length) {
args = tmpA.concat(args);
}
}
if (DEBUG_MODE) {
if (dir) {
if (safeExistsSync(join(dir, commandToUse))) {
console.warn(
`SECURE MODE: Found ${commandToUse} inside ${dir}. This command will not be executed.`,
);
return undefined;
}
console.log(`Executing ${commandToUse} in ${dir}`);
} else {
console.log(`Executing ${commandToUse}`);
}
}
const result = safeSpawnSync(commandToUse, args, {
cwd: dir,
shell: isWin,
maxBuffer: MAX_BUFFER * 2,
...spawnOverrides,
});
const stdout = result.stdout ? result.stdout.toString() : "";
const stderr = result.stderr ? result.stderr.toString() : "";
return `${stdout}\n${stderr}`.trim() || undefined;
};
/**
* Method to check if sdkman is available.
*/
export function isSdkmanAvailable() {
let isSdkmanSetup =
["SDKMAN_DIR", "SDKMAN_CANDIDATES_DIR"].filter(
(v) => process.env[v] && safeExistsSync(process.env[v]),
).length >= 1;
if (!isSdkmanSetup && safeExistsSync(join(homedir(), ".sdkman", "bin"))) {
process.env.SDKMAN_DIR = join(homedir(), ".sdkman");
process.env.SDKMAN_CANDIDATES_DIR = join(
homedir(),
".sdkman",
"candidates",
);
isSdkmanSetup = true;
}
return isSdkmanSetup;
}
/**
* Method to check if nvm is available.
*/
export function isNvmAvailable() {
const result = safeSpawnSync(
process.env.SHELL || "bash",
["-i", "-c", process.env.NVM_CMD || "nvm"],
{
shell: process.env.SHELL || true,
},
);
return result.status === 0;
}
/**
* Method to check if a given sdkman tool is installed and available.
*
* @param {String} toolType Tool type such as java, gradle, maven etc.
* @param {String} toolName Tool name with version. Eg: 22.0.2-tem
*
* @returns {Boolean} true if the tool is available. false otherwise.
*/
export function isSdkmanToolAvailable(toolType, toolName) {
toolName = getSdkmanToolFullname(toolName);
let isToolAvailable =
process.env.SDKMAN_CANDIDATES_DIR &&
safeExistsSync(
join(process.env.SDKMAN_CANDIDATES_DIR, toolType, toolName, "bin"),
);
if (
!isToolAvailable &&
safeExistsSync(
join(homedir(), ".sdkman", "candidates", toolType, toolName, "bin"),
)
) {
process.env.SDKMAN_CANDIDATES_DIR = join(
homedir(),
".sdkman",
"candidates",
);
isToolAvailable = true;
}
return isToolAvailable;
}
/**
* Method to install and use a given sdkman tool.
*
* @param {String} toolType Tool type such as java, gradle, maven etc.
* @param {String} toolName Tool name with version. Eg: 22.0.2-tem
*
* @returns {Boolean} true if the tool is available. false otherwise.
*/
export function installSdkmanTool(toolType, toolName) {
if (isWin) {
return false;
}
toolName = getSdkmanToolFullname(toolName);
let result;
if (!isSdkmanToolAvailable(toolType, toolName)) {
let installDir = "";
if (process.env.SDKMAN_CANDIDATES_DIR) {
installDir = join(process.env.SDKMAN_CANDIDATES_DIR, toolType);
}
console.log("About to install", toolType, toolName, installDir);
result = safeSpawnSync(
process.env.SHELL || "bash",
[
"-i",
"-c",
`"echo -e "no" | sdk install ${toolType} ${toolName} ${installDir}"`.trim(),
],
{
shell: process.env.SHELL || true,
},
);
if (DEBUG_MODE) {
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
}
if (result.status === 1 || result.error) {
console.log(
"Unable to install",
toolType,
toolName,
"due to below errors.",
);
return false;
}
}
const toolUpper = toolType.toUpperCase();
// Set process env variables
if (
process.env[`${toolUpper}_HOME`] &&
process.env[`${toolUpper}_HOME`].includes("current")
) {
process.env[`${toolUpper}_HOME`] = process.env[`${toolUpper}_HOME`].replace(
"current",
toolName,
);
console.log(
`${toolUpper}_HOME`,
"set to",
process.env[`${toolUpper}_HOME`],
);
} else if (
process.env.SDKMAN_CANDIDATES_DIR &&
safeExistsSync(join(process.env.SDKMAN_CANDIDATES_DIR, toolType, toolName))
) {
process.env[`${toolUpper}_HOME`] = join(
process.env.SDKMAN_CANDIDATES_DIR,
toolType,
toolName,
);
console.log(
`${toolUpper}_HOME`,
"set to",
process.env[`${toolUpper}_HOME`],
);
} else {
console.log(
"Directory",
join(process.env.SDKMAN_CANDIDATES_DIR, toolType, toolName),
"is not found",
);
}
const toolCurrentBin = join(toolType, "current", "bin");
if (process.env?.PATH.includes(toolCurrentBin)) {
process.env.PATH = process.env.PATH.replace(
toolCurrentBin,
join(toolType, toolName, "bin"),
);
} else if (process.env.SDKMAN_CANDIDATES_DIR) {
const fullToolBinDir = join(
process.env.SDKMAN_CANDIDATES_DIR,
toolType,
toolName,
"bin",
);
if (!process.env?.PATH?.includes(fullToolBinDir)) {
process.env.PATH = `${fullToolBinDir}${delimiter}${process.env.PATH}`;
}
}
return true;
}
/**
* Method to check if a given nvm tool is installed and available.
*
* @param {String} toolName Tool name with version. Eg: 22.0.2-tem
*
* @returns {String} path of nvm if present, otherwise false
*/
export function getNvmToolDirectory(toolName) {
const resultWhichNode = safeSpawnSync(
process.env.SHELL || "bash",
["-i", "-c", `"nvm which ${toolName}"`],
{
shell: process.env.SHELL || true,
},
);
if (DEBUG_MODE) {
if (resultWhichNode.stdout) {
console.log(resultWhichNode.stdout);
}
if (resultWhichNode.stderr) {
console.log(resultWhichNode.stderr);
}
}
if (resultWhichNode.status !== 0 || resultWhichNode.stderr) {
return;
}
return dirname(resultWhichNode.stdout.trim());
}
/**
* Method to return nvm tool path
*
* @param {String} toolVersion Tool name with version. Eg: 22.0.2-tem
*
* @returns {String} path of the tool if not found installs and then returns paths. false if encounters an error.
*/
export function getOrInstallNvmTool(toolVersion) {
const nvmNodePath = getNvmToolDirectory(toolVersion);
if (!nvmNodePath) {
// nvm couldn't directly use toolName so maybe needs to be installed
const resultInstall = safeSpawnSync(
process.env.SHELL || "bash",
["-i", "-c", `"nvm install ${toolVersion}"`],
{
shell: process.env.SHELL || true,
},
);
if (DEBUG_MODE) {
if (resultInstall.stdout) {
console.log(resultInstall.stdout);
}
if (resultInstall.stderr) {
console.log(resultInstall.stderr);
}
}
if (resultInstall.status !== 0) {
// There was some problem install the tool
// output has already been printed out
return false;
}
const nvmNodePath = getNvmToolDirectory(toolVersion);
if (nvmNodePath) {
return nvmNodePath;
}
return false;
}
return nvmNodePath;
}
/**
* Retrieve sdkman tool full name
*/
function getSdkmanToolFullname(toolName) {
return SDKMAN_JAVA_TOOL_ALIASES[toolName] || toolName;
}
/**
* Method to check if rbenv is available.
*
* @returns {Boolean} true if rbenv is available. false otherwise.
*/
export function isRbenvAvailable() {
let result = safeSpawnSync(
process.env.SHELL || "bash",
["-i", "-c", process.env.RBENV_CMD || "rbenv", "--version"],
{
shell: process.env.SHELL || true,
},
);
if (result.status !== 0) {
result = safeSpawnSync(process.env.RBENV_CMD || "rbenv", ["--version"], {
shell: isWin,
});
return result.status === 0;
}
}
/**
* Returns the rbenv binary directory for the given Ruby version.
* Respects the `RBENV_ROOT` environment variable when set; otherwise falls back
* to `~/.rbenv/versions/<rubyVersion>/bin`.
*
* @param {string} rubyVersion Ruby version string (e.g. `"3.2.2"`)
* @returns {string} Absolute path to the rbenv bin directory for that version
*/
export function rubyVersionDir(rubyVersion) {
return process.env.RBENV_ROOT
? join(process.env.RBENV_ROOT, "versions", rubyVersion, "bin")
: join(homedir(), ".rbenv", "versions", rubyVersion, "bin");
}
/**
* Perform bundle install using Ruby container images. Not working cleanly yet.
*
* @param rubyVersion Ruby version
* @param cdxgenGemHome Gem Home
* @param filePath Path
*/
export function bundleInstallWithDocker(rubyVersion, cdxgenGemHome, filePath) {
const ociCmd = process.env.DOCKER_CMD || "docker";
const ociArgs = [
"run",
"--rm",
"-e",
"GEM_HOME=/gems",
"-v",
`/tmp:${getTmpDir()}:rw`,
"-v",
`${filePath}:/app:rw`,
"-v",
`${cdxgenGemHome}:/gems:rw`,
"-w",
"/app",
"-it",
`docker.io/ruby:${rubyVersion}`,
"bash",
"-c",
"bundle",
"install",
];
console.log(`Performing bundle install with: ${ociCmd}`);
const result = safeSpawnSync(ociCmd, ociArgs, {
shell: isWin,
stdio: "inherit",
});
if (result.error || result.status !== 0) {
return false;
}
if (safeExistsSync(join(filePath, "Gemfile.lock"))) {
console.log(
"Gemfile.lock was generated successfully. Thank you for trying this feature!",
);
}
return result.status === 0;
}
/**
* Install a particular ruby version using rbenv.
*
* @param rubyVersion Ruby version to install
* @param filePath File path
*/
export function installRubyVersion(rubyVersion, filePath) {
if (!rubyVersion) {
return { fullToolBinDir: undefined, status: false };
}
const existingRuby = collectRubyInfo(filePath);
if (existingRuby?.version?.startsWith(`ruby ${rubyVersion} `)) {
return { fullToolBinDir: undefined, status: true };
}
const fullToolBinDir = rubyVersionDir(rubyVersion);
if (safeExistsSync(fullToolBinDir)) {
const result = safeSpawnSync(
process.env.RBENV_CMD || "rbenv",
["local", rubyVersion],
{
shell: isWin,
},
);
if (result.error || result.status !== 0) {
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
}
if (result.status === 0) {
return { fullToolBinDir, status: true };
}
}
// Check if we're trying to install Ruby 1.x or 2.x
if (rubyVersion.startsWith("1.")) {
console.log(
`Ruby version ${rubyVersion} requires very old versions of Linux such as debian:8. Consider using the container image "ghcr.io/cyclonedx/debian-ruby18:master" to build the application first and then invoke cdxgen with the arguments "--lifecycle pre-build".`,
);
console.log("The below install step is likely to fail.");
} else if (
rubyVersion.startsWith("2.") &&
process.env?.CDXGEN_IN_CONTAINER !== "true"
) {
console.log(
`Installing Ruby version ${rubyVersion} requires specific development libraries. Consider using the custom container image "ghcr.io/cyclonedx/cdxgen-debian-ruby26:v12" instead.`,
);
console.log("The below install step is likely to fail.");
}
console.log(
`Attempting to install Ruby ${rubyVersion} using rbenv. This might take a while ...`,
);
if (process.env?.CDXGEN_IN_CONTAINER === "true") {
console.log(
`To speed up this step, use bind mounts. Example: "--mount type=bind,src=/tmp/rbenv,dst=/root/.rbenv/versions/${rubyVersion}"`,
);
}
const result = safeSpawnSync(
process.env.RBENV_CMD || "rbenv",
["install", rubyVersion],
{
shell: isWin,
},
);
if (result.error || result.status !== 0) {
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
if (isMac) {
console.log(
"Try running the commands `sudo xcode-select --install` followed by `xcodebuild -runFirstLaunch`.",
);
console.log(
"TIP: Run the command `brew info ruby` and follow the instructions to set the environment variables sLDFLAGS, CPPFLAGS, and PKG_CONFIG_PATH.",
);
}
if (process.env?.CDXGEN_IN_CONTAINER === "true") {
console.log(
"Are there any devel packages that could be included in the cdxgen container image to avoid these errors? Start a discussion thread here: https://github.com/cdxgen/cdxgen/discussions",
);
} else {
console.log(
`TIP: Try using the custom container image "ghcr.io/cyclonedx/cdxgen-debian-ruby34" with the argument "-t ruby${rubyVersion}"`,
);
}
}
return { fullToolBinDir, status: result.status === 0 };
}
/**
* Method to install bundler using gem.
*
* @param rubyVersion Ruby version
* @param bundlerVersion Bundler version
*/
export function installRubyBundler(rubyVersion, bundlerVersion) {
const minRubyVersion = "3.1.0";
let bundlerWarningShown = false;
if (!bundlerVersion && compareLoose(rubyVersion, minRubyVersion) === -1) {
console.log(
`Default installation for bundler requires Ruby >= ${minRubyVersion}. Attempting to detect and install an older version of bundler for Ruby ${rubyVersion}.`,
);
bundlerWarningShown = true;
}
const fullToolBinDir = rubyVersionDir(rubyVersion);
if (safeExistsSync(fullToolBinDir)) {
const gemInstallArgs = ["install", "bundler"];
if (bundlerVersion) {
gemInstallArgs.push("-v");
gemInstallArgs.push(bundlerVersion);
}
if (!bundlerWarningShown) {
if (bundlerVersion) {
console.log(
`Installing bundler ${bundlerVersion} using ${join(fullToolBinDir, "gem")}`,
);
} else {
console.log(
`Installing bundler using ${join(fullToolBinDir, "gem")} ${gemInstallArgs.join(" ")}`,
);
}
}
const result = safeSpawnSync(join(fullToolBinDir, "gem"), gemInstallArgs, {
shell: isWin,
});
if (bundlerWarningShown) {
if (result.stderr?.includes("Try installing it with")) {
const oldBundlerVersion = result.stderr
.split("`\n")[0]
.split("Try installing it with `")
.pop()
.split(" ")
.pop()
.replaceAll("`", "");
if (/^\d+/.test(oldBundlerVersion)) {
console.log(
`The last version of bundler to support your Ruby & RubyGems was ${oldBundlerVersion}. cdxgen will now attempt to install this version.`,
);
return installRubyBundler(rubyVersion, oldBundlerVersion);
}
}
} else {
if (result.error || result.status !== 0) {
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
}
return result.status === 0;
}
}
return false;
}
/**
* Method to perform bundle install
*
* @param cdxgenGemHome cdxgen Gem home
* @param rubyVersion Ruby version
* @param bundleCommand Bundle command to use
* @param basePath working directory
*
* @returns {boolean} true if the install was successful. false otherwise.
*/
export function performBundleInstall(
cdxgenGemHome,
rubyVersion,
bundleCommand,
basePath,
) {
if (arch() !== "x64") {
console.log(
`INFO: Many Ruby packages have limited support for ${arch()} architecture. Run the cdxgen container image with --platform=linux/amd64 for best experience.`,
);
}
let installArgs = ["install"];
if (process.env.BUNDLE_INSTALL_ARGS) {
installArgs = installArgs.concat(
process.env.BUNDLE_INSTALL_ARGS.split(" "),
);
}
const gemFileLock = join(basePath, "Gemfile.lock");
console.log(
`Invoking ${bundleCommand} ${installArgs.join(" ")} from ${basePath} with GEM_HOME ${cdxgenGemHome}. Please wait ...`,
);
let result = safeSpawnSync(bundleCommand, installArgs, {
shell: isWin,
cwd: basePath,
env: {
...process.env,
GEM_HOME: cdxgenGemHome,
},
});
if (result.error || result.status !== 0) {
let pythonWarningShown = false;
let rubyVersionWarningShown = false;
if (
result?.stderr?.includes("requires python 2 to be installed") ||
result?.stdout?.includes("requires python 2 to be installed")
) {
pythonWarningShown = true;
console.log(
"A native module requires python 2 to be installed. Please install python 2.7.18 from https://www.python.org/downloads/release/python-2718/.",
);
console.log(
"NOTE: Python 2.7.x has now reached end-of-life. Python 2.7.18, is the FINAL RELEASE of Python 2.7.x. It will no longer be supported or updated. You should stop using this project in production and decommission immediately.",
);
console.log(
"Further, the project might need older versions of gcc and other build tools which might not be readily available in this environment.",
);
if (process.env?.CDXGEN_IN_CONTAINER === "true") {
console.log(
"cdxgen container images do not bundle Python 2. Run cdxgen in cli mode to proceed with the SBOM generation.",
);
}
console.log(
"Alternatively, ensure Gemfile.lock is present locally and invoke cdxgen with the argument `--lifecycle pre-build`.",
);
}
if (
result?.stderr?.includes("Running `bundle update ") ||
result?.stdout?.includes("Running `bundle update ")
) {
console.log(
"Gemfile.lock appears to be outdated. Attempting automated update.",
);
const packageToUpdate = result.stderr
.split("Running `bundle update ")
.pop()
.split("`")[0];
let updateArgs = ["update"];
if (packageToUpdate?.length && !packageToUpdate.includes(" ")) {
updateArgs.push(packageToUpdate);
}
if (process.env.BUNDLE_UPDATE_ARGS) {
updateArgs = updateArgs.concat(
process.env.BUNDLE_UPDATE_ARGS.split(" "),
);
}
console.log(`${bundleCommand}`);
result = safeSpawnSync(bundleCommand, updateArgs, {
shell: isWin,
cwd: basePath,
env: {
...process.env,
GEM_HOME: cdxgenGemHome,
},
});
if (result.error || result.status !== 0) {
console.log("------------");
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
console.log("------------");
}
return result.status === 0;
}
if (
result?.stderr?.includes("Your Ruby version is ") ||
result?.stdout?.includes("Your Ruby version is ")
) {
console.log(
"This project requires a specific version of Ruby. The version requirements can be found in the error message below.",
);
rubyVersionWarningShown = true;
}
if (result?.stderr?.includes("requires rubygems version")) {
console.log(
"This project requires a specific version of RubyGems. To do this, the existing version must be uninstalled followed by installing the required version. `sudo gem uninstall rubygems-update -v <existing version>` and then `sudo gem install rubygems-update -v <required version>`.",
);
rubyVersionWarningShown = true;
if (safeExistsSync(gemFileLock)) {
console.log("Run `bundle install` command to troubleshoot the build.");
} else {
console.log(
"Try building this project directly and set the environment variable CDXGEN_GEM_HOME with the gems directory. Look for any Dockerfile or CI workflow files for information regarding the exact version of Ruby, RubyGems, Bundler needed to build this project.",
);
}
if (process.env?.CDXGEN_IN_CONTAINER === "true") {
console.log(
"TIP: Create your own container image by using an existing Ruby base image from here: https://github.com/cdxgen/cdxgen/tree/master/ci/images/debian",
);
}
}
if (result?.stderr?.includes("Bundler cannot continue")) {
console.log(
'Bundle install is unable to continue due to a dependency resolution and build issue. Running bundle install without certain groups might work in such instances. Try running cdxgen with the environment variable `BUNDLE_INSTALL_ARGS`. Example: to skip `test` group, set the variable `"BUNDLE_INSTALL_ARGS=--without test"`',
);
console.log(
"NOTE: The generated SBOM would be incomplete with this workaround.",
);
}
if (result?.stderr?.includes("Target architecture x64 is only supported")) {
console.log(
"A gem native extension requires x64/amd64 architecture. Run the cdxgen container image with the argument '--platform=linux/amd64'.",
);
}
if (
!pythonWarningShown &&
(result?.stderr?.includes("Failed to build gem native extension") ||
result?.stderr?.includes("Gem::Ext::BuildError"))
) {
console.log(
"Bundler failed to build some gem native extension(s). Carefully review the below error to install any required development libraries.",
);
}
console.log("------------");
if (result.stdout) {
console.log(result.stdout);
}
if (result.stderr) {
console.log(result.stderr);
}
console.log("------------");
if (
process.env?.CDXGEN_IN_CONTAINER === "true" &&
!rubyVersionWarningShown
) {
console.log(
"Are there any devel packages that could be included in the cdxgen container image to avoid these errors? Start a discussion thread here: https://github.com/cdxgen/cdxgen/discussions",
);
console.log("------------");
} else if (rubyVersion) {
console.log(
`TIP: Try using the custom container image "ghcr.io/cyclonedx/cdxgen-debian-ruby34" with the argument "-t ruby${rubyVersion}".`,
);
} else {
console.log(
`TIP: Try invoking cdxgen with a Ruby version type. With the custom container image "ghcr.io/cyclonedx/cdxgen-debian-ruby34", you can pass the argument "-t ruby<version>". Example: "-t ruby3.3.6"`,
);
}
}
return result.status === 0;
}
/**
* Validates if a git ref name is safe to use in commands.
*
* @param {string} refName The git ref name to check
* @returns {boolean} True if the ref name is safe, false otherwise
*/
function isSafeRef(refName) {
if (!refName || typeof refName !== "string") {
return false;
}
if (refName.startsWith("-")) {
return false;
}
return /^[A-Za-z0-9._/@+-]+$/.test(refName);
}
// Upper bound for the commit window so a caller-supplied count cannot blow up
// the git invocation.
const MAX_GIT_LOG_COUNT = 100000;
/**
* Coerce a caller-supplied commit count into a safe, bounded positive integer.
* The result is always a plain integer, so it can never carry shell
* metacharacters into the (potentially shell-invoked, on Windows) git command.
*
* @param {*} maxCount Requested commit count (may be any type)
* @param {number} [fallback] Default when the value is missing/invalid
* @returns {number} A positive integer in [1, MAX_GIT_LOG_COUNT]
*/
function safeGitLogCount(maxCount, fallback = 20) {
const n = Number.parseInt(maxCount, 10);
if (!Number.isInteger(n) || n <= 0) {
return fallback;
}
return Math.min(n, MAX_GIT_LOG_COUNT);
}
/**
* Validates that a commit-ish (hash or ref) is safe to place in a git command.
* Reuses the ref charset (alphanumerics, `._/@+-`, no leading `-`), which also
* covers hex object names and symbolic refs such as `HEAD`.
*
* @param {string} commitish Commit hash or ref
* @returns {boolean} True when safe to use as a git argument
*/
function isSafeCommitish(commitish) {
return isSafeRef(commitish);
}
/**
* Retrieves the commit logs for a git repo with detailed author, committer, parents, signatures, and body.
*
* @param {string} dir Repo directory
* @param {number} maxCount Maximum number of commits to retrieve
* @returns {Array<Object>} Array of detailed commit objects
*/
export function gitLogCommitsDetailed(dir, maxCount = 20) {
const count = safeGitLogCount(maxCount);
const output = execGitCommand(dir, [
"log",
"-n",
`${count}`,
"--format=%H%n%an%n%ae%n%cn%n%ce%n%P%n%G?%n%B%x1e",
]);
if (!output) {
return [];
}
const commits = [];
const parts = output.split("\x1e");
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const lines = trimmed.split("\n");
if (lines.length < 7) {
continue;
}
const hash = lines[0]?.trim();
const authorName = lines[1]?.trim() || "";
const authorEmail = lines[2]?.trim() || "";
const committerName = lines[3]?.trim() || "";
const committerEmail = lines[4]?.trim() || "";
const parents = lines[5]?.trim() ? lines[5].trim().split(/\s+/) : [];
const signatureStatus = lines[6]?.trim() || "N";
const message = lines.slice(7).join("\n").trim();
// Check if Signed-off-by trailer is present
const hasSignedOff = /signed-off-by:/i.test(message);
if (hash) {
commits.push({
hash,
authorName,
authorEmail,
committerName,
committerEmail,
parents,
signatureStatus,
message,
hasSignedOff,
});
}
}
return commits;
}
/**
* Determines whether a path looks like a test/spec file, using path-segment and
* filename patterns rather than a naive substring match (which would flag files
* such as `src/attestation.js` or `contest.py`).
*
* @param {string} filePath Repo-relative file path
* @returns {boolean} True if the path is a test/spec source file
*/
function isTestPath(filePath) {
if (!filePath) {
return false;
}
return (
/(^|\/)(tests?|specs?|__tests__|__mocks__|testing)\//i.test(filePath) ||
/(^|\/)(test|spec)_[^/]+$/i.test(filePath) ||
/[._-](test|spec|tests|specs)\.[A-Za-z0-9]+$/i.test(filePath)
);
}
/**
* Determines whether a path is a CI/build/script file where a bare `|| true`
* suppression is a meaningful quality-gate-weakening signal.
*
* @param {string} filePath Repo-relative file path
* @returns {boolean} True if the path is a CI/build/script file
*/
function isCiOrScriptPath(filePath) {
if (!filePath) {
return false;
}
return (
filePath.includes(".github/workflows") ||
/(^|\/)\.gitlab-ci\.yml$/i.test(filePath) ||
/(^|\/)(Makefile|makefile|Justfile)$/.test(filePath) ||
/\.(sh|bash|bat|ps1|mk)$/i.test(filePath) ||
filePath.endsWith("package.json") ||
/(azure-pipelines|\.circleci\/|Jenkinsfile|\.drone|bitbucket-pipelines)/i.test(
filePath,
)
);
}
/**
* High-signal quality-gate weakening tokens. These are conservative on purpose:
* they only match constructs that intentionally silence failures or bypass
* verification, to keep the false-positive rate low for ordinary refactors.
*/
const CI_WEAKENING_PATTERNS = [
/continue-on-error\s*:\s*true/i,
/--no-verify\b/,
/HUSKY\s*=\s*0/,
/--ignore-scripts\b/,
/pytest\.mark\.skip\b/,
];
/**
* Runs a git show on a commit hash and analyzes the diff for test-file
* deletions and quality-gate-weakening changes (e.g. `|| true`,
* `continue-on-error: true`, `--no-verify`). Detection is intentionally
* conservative to avoid false positives from ordinary refactors.
*
* @param {string} dir Repo directory
* @param {string} commitHash Commit hash to analyze
* @returns {Object} Commit diff analysis results
*/
export function gitCommitDiffAnalysis(dir, commitHash) {
const empty = {
testFilesDeleted: [],
weakeningTokens: [],
touchedFiles: [],
addedLinesCount: 0,
deletedLinesCount: 0,
};
// Reject anything that is not a safe commit-ish (hex hash or ref); this keeps
// shell metacharacters and option-like values out of the git argument.
if (!isSafeCommitish(commitHash)) {
return empty;
}
// --no-ext-diff / --no-textconv block .gitattributes-driven external diff and
// textconv drivers (arbitrary shell commands) from an untrusted repo.
const output = execGitCommand(dir, [
"show",
"--no-ext-diff",
"--no-textconv",
commitHash,
]);
if (!output) {
return empty;
}
const testFilesDeleted = [];
const weakeningTokens = [];
const touchedFiles = [];
let addedLinesCount = 0;
let deletedLinesCount = 0;
// Parse the diff
const lines = output.split("\n");
let currentFile = "";
let currentFileIsTest = false;
let currentFileIsCi = false;
for (const line of lines) {
// Check file headers
if (line.startsWith("diff --git ")) {
currentFile = "";
currentFileIsTest = false;
currentFileIsCi = false;
const parts = line.split(" ");
if (parts.length >= 4) {
let file = parts[2];
if (file.startsWith("a/")) {
file = file.substring(2);
}
touchedFiles.push(file);
currentFile = file;
currentFileIsTest = isTestPath(file);
currentFileIsCi = isCiOrScriptPath(file);
}
} else if (line.startsWith("--- a/")) {
const oldFile = line.substring(6).trim();
if (!currentFile) {
currentFile = oldFile;
currentFileIsTest = isTestPath(oldFile);
currentFileIsCi = isCiOrScriptPath(oldFile);
}
} else if (line.startsWith("+++ /dev/null") && currentFileIsTest) {
// Whole test file removed
testFilesDeleted.push(currentFile);
} else if (line.startsWith("+") && !line.startsWith("+++")) {
addedLinesCount++;
// Quality-gate weakening tokens are only meaningful inside CI/build/script
// config files. Matching them in arbitrary source or docs produces false
// positives (e.g. a linter's own rule set, or documentation that merely
// mentions `--no-verify`), so they are scoped to CI/script files.
if (currentFileIsCi) {
const addedContent = line.substring(1);
if (
CI_WEAKENING_PATTERNS.some((re) => re.test(addedContent)) ||
/\|\|\s*true\b/.test(addedContent)
) {
weakeningTokens.push(addedContent.trim());
}
}
} else if (line.startsWith("-") && !line.startsWith("---")) {
deletedLinesCount++;
}
}
return {
testFilesDeleted,
weakeningTokens,
touchedFiles,
addedLinesCount,
deletedLinesCount,
};
}
/**
* Retrieves the git-ai notes metadata for recent commits.
*
* @param {string} dir Repo directory
* @param {Object} [options] Options for note retrieval
* @param {string} [options.ref] Notes reference path (defaults to refs/notes/ai)
* @param {number} [options.maxCount] Maximum commits to scan (defaults to 20)
* @returns {Array<Object>} Array of note objects { hash, note }
*/
export function gitAiNotes(dir, options = {}) {
const ref = options.ref || "refs/notes/ai";
const maxCount = safeGitLogCount(options.maxCount);
if (!isSafeRef(ref)) {
return [];
}
const output = execGitCommand(dir, [
"log",
"-n",
`${maxCount}`,
`--notes=${ref}`,
"--format=%H%n%N%x1e",
]);
if (!output) {
return [];
}
const notes = [];
const parts = output.split("\x1e");
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const newlineIdx = trimmed.indexOf("\n");
let hash = "";
let noteContent = "";
if (newlineIdx === -1) {
hash = trimmed;
} else {
hash = trimmed.substring(0, newlineIdx).trim();
noteContent = trimmed.substring(newlineIdx + 1).trim();
}
if (hash && noteContent) {
notes.push({ hash, note: noteContent });
}
}
return notes;
}
// Revert/hotfix/rollback intent in a commit subject. Scoped to the subject line
// (not the full body) to avoid matching commits that merely discuss a revert.
const REVERT_HOTFIX_RE = /\b(?:revert|roll[- ]?back|hot[- ]?fix)\b/i;
/**
* Retrieves recent commits whose subject indicates a revert, hotfix, or
* rollback. Bounded to the same recency window as the other collectors — unlike
* `git log --grep`, which traverses the entire history looking for matches.
*
* @param {string} dir Repo directory
* @param {number} maxCount Maximum number of recent commits to scan
* @returns {Array<Object>} Array of { hash, message } revert/hotfix commits
*/
export function gitRevertsAndHotfixes(dir, maxCount = 20) {
const count = safeGitLogCount(maxCount);
const output = execGitCommand(dir, [
"log",
"-n",
`${count}`,
"--format=%H%n%B%x1e",
]);
if (!output) {
return [];
}
const entries = [];
const parts = output.split("\x1e");
for (const part of parts) {
const trimmed = part.trim();
if (!trimmed) {
continue;
}
const lines = trimmed.split("\n");
const hash = lines[0]?.trim();
const message = lines.slice(1).join("\n").trim();
const subject = message.split("\n")[0] || "";
if (hash && REVERT_HOTFIX_RE.test(subject)) {
entries.push({ hash, message });
}
}
return entries;
}