@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,618 lines (1,566 loc) • 66.6 kB
JavaScript
import { Buffer } from "node:buffer";
import {
createReadStream,
lstatSync,
readdirSync,
readFileSync,
} from "node:fs";
import { platform as _platform, userInfo as _userInfo, homedir } from "node:os";
import { basename, join, resolve, win32 } from "node:path";
import process from "node:process";
import stream from "node:stream/promises";
import { URL } from "node:url";
import got from "got";
import { x } from "tar";
import {
DEBUG_MODE,
extractPathEnv,
getAllFiles,
getTmpDir,
isDryRun,
readEnvironmentVariable,
recordActivity,
recordDecisionActivity,
recordSensitiveFileRead,
safeExistsSync,
safeExtractArchive,
safeMkdirSync,
safeMkdtempSync,
safeRmSync,
safeSpawnSync,
safeWriteSync,
} from "../helpers/utils.js";
import { getDirs, getOnlyDirs } from "./containerutils.js";
export const isWin = _platform() === "win32";
export const DOCKER_HUB_REGISTRY = "docker.io";
// Docker commonly stores Hub credentials under index.docker.io or
// registry-1.docker.io while pulls target docker.io.
const DOCKER_HUB_REGISTRY_ALIASES = new Set([
DOCKER_HUB_REGISTRY,
"index.docker.io",
"registry-1.docker.io",
]);
/**
* Encode a value as base64url (RFC 4648 §5) with padding.
* Docker Engine decodes X-Registry-Auth with Go's base64.URLEncoding,
* which uses the URL-safe alphabet (-_ instead of +/) and expects = padding.
* Node's "base64url" omits padding, so we convert from standard base64.
*/
const toBase64Url = (value) =>
Buffer.from(value).toString("base64").replace(/\+/g, "-").replace(/\//g, "_");
const normalizeRegistryPath = (registryPath) => {
if (!registryPath || registryPath === "/") {
return "";
}
let normalizedPath = registryPath.trim();
if (!normalizedPath.startsWith("/")) {
normalizedPath = `/${normalizedPath}`;
}
while (normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath.slice(0, -1);
}
const lowerCasePath = normalizedPath.toLowerCase();
if (lowerCasePath.endsWith("/v1") || lowerCasePath.endsWith("/v2")) {
normalizedPath = normalizedPath.slice(0, -3);
}
return normalizedPath === "/" ? "" : normalizedPath;
};
const buildRegistryAuthority = (hostname, port) => {
if (!hostname) {
return undefined;
}
hostname = hostname.toLowerCase();
if (hostname.includes(":") && !hostname.startsWith("[")) {
hostname = `[${hostname}]`;
}
if (port) {
return `${hostname}:${port}`;
}
return hostname;
};
const parseRawRegistryAuthority = (authority) => {
if (!authority?.trim()) {
return undefined;
}
authority = authority.trim();
if (authority.startsWith("[")) {
const closingBracketIndex = authority.indexOf("]");
if (closingBracketIndex === -1) {
return undefined;
}
const hostname = authority.slice(0, closingBracketIndex + 1);
const portSuffix = authority.slice(closingBracketIndex + 1);
if (!portSuffix) {
return buildRegistryAuthority(hostname);
}
if (!/^:\d+$/.test(portSuffix)) {
return undefined;
}
return buildRegistryAuthority(hostname, portSuffix.slice(1));
}
const colonIndex = authority.lastIndexOf(":");
if (colonIndex > -1 && authority.indexOf(":") === colonIndex) {
const portCandidate = authority.slice(colonIndex + 1);
if (/^\d+$/.test(portCandidate)) {
return buildRegistryAuthority(
authority.slice(0, colonIndex),
portCandidate,
);
}
}
return buildRegistryAuthority(authority);
};
const parseRegistryReference = (registry) => {
if (!registry?.trim()) {
return undefined;
}
registry = registry.trim();
if (registry.includes("://")) {
if (!URL.canParse(registry)) {
return undefined;
}
const registryUrl = new URL(registry);
const authoritySource = registry
.slice(registry.indexOf("://") + 3)
.split("/")[0];
return {
authority: parseRawRegistryAuthority(authoritySource),
path: normalizeRegistryPath(registryUrl.pathname),
};
}
const slashIndex = registry.indexOf("/");
const authority =
slashIndex === -1 ? registry : registry.slice(0, slashIndex);
const registryPath = slashIndex === -1 ? "" : registry.slice(slashIndex);
if (!authority) {
return undefined;
}
try {
// Raw registry references such as host:port are not absolute URLs, so we
// add an https scheme only to parse the authority and optional port.
return {
authority: parseRawRegistryAuthority(authority),
path: normalizeRegistryPath(registryPath),
};
} catch (_err) {
return undefined;
}
};
const looksLikeImageReference = (value) => {
if (typeof value !== "string" || !value.trim()) {
return false;
}
value = value.trim();
if (value.includes("://")) {
return false;
}
if (!value.includes("/")) {
if (value.includes(":")) {
const tagOrPortSuffix = value.slice(value.lastIndexOf(":") + 1);
if (!/^\d+$/.test(tagOrPortSuffix)) {
return true;
}
return !parseRegistryReference(value)?.authority;
}
return !(
value.includes(".") ||
value === "localhost" ||
value.startsWith("[")
);
}
const firstSegment = value.split("/")[0];
return !(
parseRegistryReference(firstSegment)?.authority &&
(firstSegment.includes(".") ||
firstSegment.includes(":") ||
firstSegment === "localhost" ||
firstSegment.startsWith("["))
);
};
const resolveRequestedRegistryRef = (forRegistry, requestedRegistryRef) => {
const fallbackRegistry =
forRegistry || process.env.DOCKER_SERVER_ADDRESS || DOCKER_HUB_REGISTRY;
if (
typeof requestedRegistryRef !== "string" ||
!requestedRegistryRef.trim()
) {
return fallbackRegistry;
}
requestedRegistryRef = requestedRegistryRef.trim();
if (requestedRegistryRef.includes("://")) {
return requestedRegistryRef;
}
return looksLikeImageReference(requestedRegistryRef)
? fallbackRegistry
: requestedRegistryRef;
};
const extractRequestedRegistryRefFromPath = (path, forRegistry) => {
if (!path?.includes("?")) {
return resolveRequestedRegistryRef(forRegistry, forRegistry);
}
const queryString = path.slice(path.indexOf("?") + 1);
const requestedImageRef = new URLSearchParams(queryString).get("fromImage");
return resolveRequestedRegistryRef(
forRegistry,
requestedImageRef || forRegistry,
);
};
const normalizeRegistryReference = (registry) => {
const parsedRegistry = parseRegistryReference(registry);
if (!parsedRegistry?.authority) {
return undefined;
}
return parsedRegistry.path
? `${parsedRegistry.authority}${parsedRegistry.path}`
: parsedRegistry.authority;
};
const registriesMatch = (configuredRegistry, requestedRegistry) => {
if (!requestedRegistry) {
return false;
}
const normalizedConfiguredRegistry =
parseRegistryReference(configuredRegistry);
const normalizedRequestedRegistry = parseRegistryReference(requestedRegistry);
if (
!normalizedConfiguredRegistry?.authority ||
!normalizedRequestedRegistry?.authority
) {
return false;
}
const hostMatches =
normalizedConfiguredRegistry.authority ===
normalizedRequestedRegistry.authority ||
(DOCKER_HUB_REGISTRY_ALIASES.has(normalizedConfiguredRegistry.authority) &&
DOCKER_HUB_REGISTRY_ALIASES.has(normalizedRequestedRegistry.authority));
if (!hostMatches) {
return false;
}
if (!normalizedConfiguredRegistry.path) {
return true;
}
return (
normalizedConfiguredRegistry.path === normalizedRequestedRegistry.path ||
normalizedRequestedRegistry.path.startsWith(
`${normalizedConfiguredRegistry.path}/`,
)
);
};
// Should we extract the tar image in non-strict mode
const NON_STRICT_TAR_EXTRACT = ["true", "1"].includes(
process?.env?.NON_STRICT_TAR_EXTRACT,
);
if (NON_STRICT_TAR_EXTRACT) {
console.log(
"Warning: Extracting container images and tar files in non-strict mode could lead to security risks!",
);
}
let dockerConn;
let isPodman = false;
let isPodmanRootless = true;
let isDockerRootless = false;
// https://github.com/containerd/containerd
let isContainerd = !!process.env.CONTAINERD_ADDRESS;
const WIN_LOCAL_TLS = "http://localhost:2375";
let isWinLocalTLS = false;
let isNerdctl;
let isColima;
if (
!process.env.DOCKER_HOST &&
(process.env.CONTAINERD_ADDRESS ||
(process.env.XDG_RUNTIME_DIR &&
safeExistsSync(
join(process.env.XDG_RUNTIME_DIR, "containerd-rootless", "api.sock"),
)))
) {
isContainerd = true;
}
/**
* Strip absolute path prefixes from a path string, handling both Unix and
* Windows paths (including UNC and extended-length paths such as //?/C:/).
* Taken from https://github.com/isaacs/node-tar/blob/main/src/strip-absolute-path.ts
*
* @param {string} path The path to strip
* @returns {string} The path with its absolute root removed
*/
export const stripAbsolutePath = (path) => {
// This appears to be a most frequent case, so let's return quickly.
if (path === "/") {
return "";
}
let parsed = win32.parse(path);
while (win32.isAbsolute(path) || parsed.root) {
// windows will think that //x/y/z has a "root" of //x/y/
// but strip the //?/C:/ off of //?/C:/path
const root =
path.charAt(0) === "/" && path.slice(0, 4) !== "//?/" ? "/" : parsed.root;
path = path.slice(root.length);
parsed = win32.parse(path);
}
return path;
};
/**
* Detect colima
*/
export function detectColima() {
if (isColima) {
return true;
}
if (_platform() === "darwin") {
const result = safeSpawnSync("colima", ["version"]);
if (result.status !== 0 || result.error) {
return false;
}
if (result?.stdout?.includes("colima version")) {
isColima = true;
console.log(
"Colima is known to have issues with volume mounts, which might result in incomplete BOM. Use it with caution!",
);
if (result?.stdout?.includes("runtime: containerd")) {
isNerdctl = true;
}
}
}
return isColima;
}
/**
* Detect if Rancher desktop is running on a mac.
*/
export function detectRancherDesktop() {
// Detect Rancher desktop and nerdctl on a mac
if (_platform() === "darwin") {
const limaHome = join(
homedir(),
"Library",
"Application Support",
"rancher-desktop",
"lima",
);
const limactl = join(
"/Applications",
"Rancher Desktop.app",
"Contents",
"Resources",
"resources",
"darwin",
"lima",
"bin",
"limactl",
);
// Is Rancher Desktop running
if (safeExistsSync(limactl) || safeExistsSync(limaHome)) {
const result = safeSpawnSync("rdctl", ["list-settings"]);
if (result.status !== 0 || result.error) {
if (
isNerdctl === undefined &&
result.stderr?.includes("connection refused")
) {
console.warn(
"Ensure Rancher Desktop is running prior to invoking cdxgen. To start from the command line, type the command 'rdctl start'",
);
isNerdctl = false;
}
} else {
if (DEBUG_MODE) {
console.log("Rancher Desktop found!");
}
isNerdctl = true;
}
}
}
return isNerdctl;
}
// Cache the registry auth keys
const registry_auth_keys = {};
const REQUEST_TIMEOUT_SECS = 60000;
/**
* Build a `got` options object for Docker / registry API requests. Resolves
* authentication headers by consulting (in order) the DOCKER_AUTH_CONFIG
* environment variable, DOCKER_USER/DOCKER_PASSWORD/DOCKER_EMAIL environment
* variables, hardcoded tokens in ~/.docker/config.json, credential helpers
* listed in credHelpers/credsStore, and finally TLS certificate files pointed
* to by DOCKER_CERT_PATH.
*
* @param {string} [forRegistry] Registry hostname (e.g. "registry-1.docker.io").
* Defaults to DOCKER_SERVER_ADDRESS env var or "docker.io".
* @param {string} [requestedRegistryRef] Requested registry/image reference used
* to scope config.json auth matching. Unqualified images default to Docker Hub.
* @returns {Object} Options object suitable for passing to `got`
*/
const getDefaultOptions = (forRegistry, requestedRegistryRef = forRegistry) => {
let authTokenSet = false;
const credentialSourceEvaluations = [];
let selectedCredentialSource;
const noteCredentialSource = (source, outcome, detail = undefined) => {
credentialSourceEvaluations.push({
detail,
outcome,
source,
});
if (outcome === "selected") {
selectedCredentialSource = source;
}
};
const dockerServerAddress = readEnvironmentVariable("DOCKER_SERVER_ADDRESS");
const dockerConfig = readEnvironmentVariable("DOCKER_CONFIG");
const dockerAuthConfig = readEnvironmentVariable("DOCKER_AUTH_CONFIG", {
sensitive: true,
});
const dockerUser = readEnvironmentVariable("DOCKER_USER", {
sensitive: true,
});
const dockerPassword = readEnvironmentVariable("DOCKER_PASSWORD", {
sensitive: true,
});
const dockerEmail = readEnvironmentVariable("DOCKER_EMAIL", {
sensitive: true,
});
if (!forRegistry) {
forRegistry = dockerServerAddress ?? DOCKER_HUB_REGISTRY;
}
requestedRegistryRef = resolveRequestedRegistryRef(
forRegistry,
requestedRegistryRef,
);
const normalizedForRegistry =
parseRegistryReference(forRegistry)?.authority ?? forRegistry;
const authDecisionTarget =
requestedRegistryRef ||
normalizedForRegistry ||
forRegistry ||
DOCKER_HUB_REGISTRY;
const opts = {
enableUnixSockets: true,
throwHttpErrors: true,
method: "GET",
timeout: {
request: REQUEST_TIMEOUT_SECS,
socket: REQUEST_TIMEOUT_SECS,
},
retry: {
limit: 3,
methods: ["GET", "POST", "HEAD"],
statusCodes: [408, 413, 429, 500, 502, 503, 504, 521, 522, 524],
},
hooks: { beforeError: [] },
mutableDefaults: true,
};
const DOCKER_CONFIG = dockerConfig || join(homedir(), ".docker");
const dockerConfigFile = join(DOCKER_CONFIG, "config.json");
// Support for private registry
if (dockerAuthConfig) {
opts.headers = {
"X-Registry-Auth": dockerAuthConfig,
};
authTokenSet = true;
noteCredentialSource("DOCKER_AUTH_CONFIG", "selected");
} else {
noteCredentialSource("DOCKER_AUTH_CONFIG", "skipped", "not set");
}
if (
!authTokenSet &&
dockerUser &&
dockerPassword &&
dockerEmail &&
normalizedForRegistry
) {
const authPayload = {
username: dockerUser,
email: dockerEmail,
serveraddress: normalizedForRegistry,
};
if (dockerUser === "<token>") {
authPayload.IdentityToken = dockerPassword;
} else {
authPayload.password = dockerPassword;
}
opts.headers = {
"X-Registry-Auth": toBase64Url(JSON.stringify(authPayload)),
};
authTokenSet = true;
noteCredentialSource(
"DOCKER_USER/DOCKER_PASSWORD/DOCKER_EMAIL",
"selected",
);
} else if (!authTokenSet) {
noteCredentialSource(
"DOCKER_USER/DOCKER_PASSWORD/DOCKER_EMAIL",
"skipped",
dockerUser || dockerPassword || dockerEmail
? "incomplete environment credentials"
: "not set",
);
}
if (!authTokenSet && safeExistsSync(dockerConfigFile)) {
const configData = readFileSync(dockerConfigFile, "utf-8");
recordSensitiveFileRead(dockerConfigFile, {
label: "Docker credential file",
});
if (configData) {
try {
const configJson = JSON.parse(configData);
if (configJson.auths) {
// Check if there are hardcoded tokens
for (const serverAddress of Object.keys(configJson.auths)) {
if (!registriesMatch(serverAddress, requestedRegistryRef)) {
continue;
}
if (configJson.auths[serverAddress].auth) {
// The Docker config stores auth as base64("user:pass").
// The X-Registry-Auth header expects base64-encoded JSON.
const decoded = Buffer.from(
configJson.auths[serverAddress].auth,
"base64",
).toString();
const sepIdx = decoded.indexOf(":");
const authPayload = {
username: decoded.substring(0, sepIdx),
password: decoded.substring(sepIdx + 1),
serveraddress: serverAddress,
};
opts.headers = {
"X-Registry-Auth": toBase64Url(JSON.stringify(authPayload)),
};
authTokenSet = true;
noteCredentialSource(
"docker-config-auth",
"selected",
serverAddress,
);
break;
}
if (configJson.credsStore) {
const helperAuthToken = getCredsFromHelper(
configJson.credsStore,
serverAddress,
);
if (helperAuthToken) {
opts.headers = {
"X-Registry-Auth": helperAuthToken,
};
authTokenSet = true;
noteCredentialSource(
`docker-credential-helper:${configJson.credsStore}`,
"selected",
serverAddress,
);
break;
}
}
}
} else if (configJson.credHelpers) {
// Support for credential helpers
for (const serverAddress of Object.keys(configJson.credHelpers)) {
if (!registriesMatch(serverAddress, requestedRegistryRef)) {
continue;
}
if (configJson.credHelpers[serverAddress]) {
const helperAuthToken = getCredsFromHelper(
configJson.credHelpers[serverAddress],
serverAddress,
);
if (helperAuthToken) {
opts.headers = {
"X-Registry-Auth": helperAuthToken,
};
authTokenSet = true;
noteCredentialSource(
`docker-credential-helper:${configJson.credHelpers[serverAddress]}`,
"selected",
serverAddress,
);
break;
}
}
}
}
if (!authTokenSet) {
noteCredentialSource(
"docker-config",
"skipped",
"no matching config.json auth entry",
);
}
} catch (_err) {
// pass
noteCredentialSource("docker-config", "skipped", "config parse failed");
}
}
} else if (!authTokenSet) {
noteCredentialSource("docker-config", "skipped", "config.json not found");
}
const userInfo = _userInfo();
opts.podmanPrefixUrl = isWin ? "" : "http://unix:/run/podman/podman.sock:";
opts.podmanRootlessPrefixUrl = isWin
? ""
: `http://unix:/run/user/${userInfo.uid}/podman/podman.sock:`;
const dockerHost = readEnvironmentVariable("DOCKER_HOST");
const dockerCertPath = readEnvironmentVariable("DOCKER_CERT_PATH");
const dockerTlsVerify = readEnvironmentVariable("DOCKER_TLS_VERIFY");
if (!dockerHost) {
if (isPodman) {
opts.prefixUrl = isPodmanRootless
? opts.podmanRootlessPrefixUrl
: opts.podmanPrefixUrl;
} else {
if (isWinLocalTLS) {
opts.prefixUrl = WIN_LOCAL_TLS;
} else {
// Named pipes syntax for Windows doesn't work with got
// See: https://github.com/sindresorhus/got/issues/2178
/*
opts.prefixUrl = isWin
? "npipe//./pipe/docker_engine:"
: "unix:/var/run/docker.sock:";
*/
opts.prefixUrl = isWin
? WIN_LOCAL_TLS
: isDockerRootless
? `http://unix:${homedir()}/.docker/run/docker.sock:`
: "http://unix:/var/run/docker.sock:";
}
}
} else {
let hostStr = dockerHost;
if (hostStr.startsWith("unix:///")) {
hostStr = hostStr.replace("unix:///", "http://unix:/");
if (hostStr.includes("docker.sock")) {
hostStr = hostStr.replace("docker.sock", "docker.sock:");
isDockerRootless = true;
}
}
opts.prefixUrl = hostStr;
if (dockerCertPath) {
const dockerCertFile = join(dockerCertPath, "cert.pem");
const dockerKeyFile = join(dockerCertPath, "key.pem");
const dockerCertificate = readFileSync(dockerCertFile, "utf8");
recordSensitiveFileRead(dockerCertFile, {
label: "Docker client certificate",
});
const dockerKey = readFileSync(dockerKeyFile, "utf8");
recordSensitiveFileRead(dockerKeyFile, {
label: "Docker client private key",
});
opts.https = {
certificate: dockerCertificate,
key: dockerKey,
};
// Disable tls on empty values
// From the docker docs: Setting the DOCKER_TLS_VERIFY environment variable to any value other than the empty string is equivalent to setting the --tlsverify flag
if (dockerTlsVerify === "") {
opts.https.rejectUnauthorized = false;
console.log("TLS Verification disabled for", hostStr);
}
}
}
if (!selectedCredentialSource) {
noteCredentialSource(
"anonymous",
"selected",
"no credential source resolved",
);
}
const skippedSources = credentialSourceEvaluations
.filter((entry) => entry.outcome !== "selected")
.map((entry) =>
entry.detail ? `${entry.source} (${entry.detail})` : entry.source,
);
recordDecisionActivity(`docker-auth:${authDecisionTarget}`, {
metadata: {
decisionType: "credential-source-selection",
evaluatedSources: credentialSourceEvaluations.map(
({ detail, outcome, source }) =>
detail ? `${source}:${outcome}:${detail}` : `${source}:${outcome}`,
),
selectedSource: selectedCredentialSource,
},
reason: `Selected Docker auth source '${selectedCredentialSource}' for ${authDecisionTarget}. Skipped: ${skippedSources.length ? skippedSources.join(", ") : "none"}.`,
});
return opts;
};
/**
* Establish (or reuse) a `got` client connected to the local Docker or Podman
* daemon. Tries multiple socket / URL candidates in order: the default Docker
* socket, the rootless Docker socket, the Windows TCP endpoint, the rootless
* Podman socket, and the root Podman socket. Sets the module-level flags
* `isPodman`, `isPodmanRootless`, `isDockerRootless`, and `isWinLocalTLS` as a
* side-effect. Returns `undefined` when containerd / nerdctl is in use or no
* daemon could be reached.
*
* @param {Object} options Additional `got` options to merge into the connection
* @param {string} [forRegistry] Registry hostname forwarded to `getDefaultOptions`
* @returns {Promise<import("got").Got|undefined>} A `got` instance bound to the
* daemon base URL, or `undefined`
*/
export const getConnection = async (options, forRegistry) => {
if (isContainerd || isNerdctl) {
return undefined;
}
if (isDryRun) {
try {
getDefaultOptions(forRegistry);
} catch (error) {
recordActivity({
kind: "read",
reason: `Dry run mode failed while tracing Docker credential inputs: ${error.message}`,
status: "failed",
target: error?.path || forRegistry || "container-daemon",
});
}
recordActivity({
kind: "network",
reason:
"Dry run mode blocks container daemon and registry HTTP requests.",
status: "blocked",
target: forRegistry || "container-daemon",
});
return undefined;
}
if (!dockerConn) {
const defaultOptions = getDefaultOptions(forRegistry);
const podmanRootlessUrl = defaultOptions.podmanRootlessPrefixUrl;
const podmanRootUrl = defaultOptions.podmanPrefixUrl;
const opts = Object.assign(
{},
{
enableUnixSockets: defaultOptions.enableUnixSockets,
throwHttpErrors: defaultOptions.throwHttpErrors,
method: defaultOptions.method,
prefixUrl: defaultOptions.prefixUrl,
},
options,
);
try {
await got.get("_ping", opts);
dockerConn = got.extend(opts);
if (DEBUG_MODE) {
if (isDockerRootless) {
console.log("Docker service in rootless mode detected.");
} else {
console.log(
"Docker service in root mode detected. Consider switching to rootless mode to improve security. See https://docs.docker.com/engine/security/rootless/",
);
}
}
} catch (_err) {
opts.prefixUrl = `http://unix:${homedir()}/.docker/run/docker.sock:`;
try {
await got.get("_ping", opts);
dockerConn = got.extend(opts);
isDockerRootless = true;
if (DEBUG_MODE) {
console.log("Docker service in rootless mode detected.");
}
return dockerConn;
} catch (_err) {
// ignore
}
try {
if (isWin) {
opts.prefixUrl = WIN_LOCAL_TLS;
await got.get("_ping", opts);
dockerConn = got.extend(opts);
isWinLocalTLS = true;
if (DEBUG_MODE) {
console.log("Docker desktop on Windows detected.");
}
} else {
opts.prefixUrl = podmanRootlessUrl;
await got.get("libpod/_ping", opts);
isPodman = true;
isPodmanRootless = true;
dockerConn = got.extend(opts);
if (DEBUG_MODE) {
console.log(
"Podman in rootless mode detected. Thank you for using podman!",
);
}
}
} catch (_err) {
try {
opts.prefixUrl = podmanRootUrl;
await got.get("libpod/_ping", opts);
isPodman = true;
isPodmanRootless = false;
dockerConn = got.extend(opts);
console.log(
"Podman in root mode detected. Consider switching to rootless mode to improve security. See https://github.com/containers/podman/blob/main/docs/tutorials/rootless_tutorial.md",
);
} catch (_err) {
if (_platform() === "win32") {
console.warn(
"Ensure Docker for Desktop is running as an administrator with 'Exposing daemon on TCP without TLS' setting turned on.",
);
} else if (_platform() === "darwin" && !isNerdctl) {
if (detectRancherDesktop() || detectColima()) {
return undefined;
}
if (isNerdctl === undefined) {
console.warn(
"Ensure Podman Desktop (open-source) or Docker for Desktop (May require subscription) is running.",
);
}
} else {
console.warn(
"Ensure docker/podman service or Docker for Desktop is running.",
);
console.log(
"Check if the post-installation steps were performed correctly as per this documentation https://docs.docker.com/engine/install/linux-postinstall/",
);
}
}
}
}
}
return dockerConn;
};
/**
* Send a single HTTP request to the Docker / Podman daemon via the `got`
* client returned by {@link getConnection}. GET requests are parsed as JSON;
* all other methods receive a Buffer response body.
*
* @param {string} path API path relative to the daemon base URL (e.g. "images/ubuntu:latest/json")
* @param {string} method HTTP method (e.g. "GET", "POST", "DELETE")
* @param {string} [forRegistry] Registry hostname forwarded to `getDefaultOptions` for auth headers
* @returns {Promise<Object|Buffer|undefined>} Parsed JSON object for GET
* requests, raw Buffer for other methods, or `undefined` if no client is available
*/
export const makeRequest = async (path, method, forRegistry) => {
if (isDryRun) {
recordActivity({
kind: "network",
reason:
"Dry run mode blocks container daemon and registry HTTP requests.",
status: "blocked",
target: `${method} ${forRegistry || "container-daemon"}/${path}`,
});
return undefined;
}
const client = await getConnection({}, forRegistry);
if (!client) {
return undefined;
}
// Use the client's prefixUrl (set correctly by getConnection for
// docker/podman). Only pass per-request auth headers and method options.
const defaultOptions = getDefaultOptions(
forRegistry,
extractRequestedRegistryRefFromPath(path, forRegistry),
);
const opts = {
responseType: method === "GET" ? "json" : "buffer",
resolveBodyOnly: true,
enableUnixSockets: true,
throwHttpErrors: true,
method,
};
if (defaultOptions.headers) {
opts.headers = defaultOptions.headers;
}
return await client(path, opts);
};
/**
* Parse image name
*
* docker pull debian
* docker pull debian:jessie
* docker pull ubuntu@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2
* docker pull myregistry.local:5000/testing/test-image
*/
export const parseImageName = (fullImageName) => {
const nameObj = {
registry: "",
repo: "",
tag: "",
digest: "",
platform: "",
group: "",
name: "",
};
if (!fullImageName) {
return nameObj;
}
// ensure it's lowercased
fullImageName = fullImageName.toLowerCase().trim();
// Extract platform
if (fullImageName.startsWith("--platform=")) {
const tmpName = fullImageName.replace("--platform=", "").split(" ");
nameObj.platform = tmpName[0];
fullImageName = tmpName[1];
}
// Extract registry name
if (
fullImageName.includes("/") &&
(fullImageName.includes(".") || fullImageName.includes(":"))
) {
let urlObj;
if (URL.canParse(fullImageName)) {
urlObj = new URL(fullImageName);
}
const tmpA = fullImageName.split("/");
if (
(urlObj && urlObj.pathname !== fullImageName) ||
tmpA[0].includes(".") ||
tmpA[0].includes(":")
) {
nameObj.registry = tmpA[0];
fullImageName = fullImageName.replace(`${tmpA[0]}/`, "");
}
}
// Extract digest name
if (fullImageName.includes("@sha256:")) {
const tmpA = fullImageName.split("@sha256:");
if (tmpA.length > 1) {
nameObj.digest = tmpA[tmpA.length - 1];
fullImageName = fullImageName.replace(`@sha256:${nameObj.digest}`, "");
}
}
// Extract tag name
if (fullImageName.includes(":")) {
const tmpA = fullImageName.split(":");
if (tmpA.length > 1) {
nameObj.tag = tmpA[tmpA.length - 1];
fullImageName = fullImageName.replace(`:${nameObj.tag}`, "");
}
}
// The left over string is the repo name
nameObj.repo = fullImageName;
nameObj.name = fullImageName;
// extract group name
if (fullImageName.includes("/")) {
const tmpA = fullImageName.split("/");
if (tmpA.length > 1) {
nameObj.name = tmpA[tmpA.length - 1];
nameObj.group = fullImageName.replace(`/${tmpA[tmpA.length - 1]}`, "");
}
}
return nameObj;
};
/**
* Prefer cli on windows, nerdctl on mac, or when using tcp/ssh based host.
*
* @returns boolean true if we should use the cli. false otherwise
*/
const getContainerCliCmd = () => {
if (process.env.DOCKER_CMD?.trim()) {
return process.env.DOCKER_CMD.trim();
}
detectRancherDesktop() || detectColima();
if (isNerdctl) {
return "nerdctl";
}
return "docker";
};
const needsCliFallback = () => {
if (
["true", "1"].includes(process.env.DOCKER_USE_CLI) ||
process.env.DOCKER_CMD?.trim() ||
(_platform() === "darwin" && (detectRancherDesktop() || detectColima()))
) {
return true;
}
return (
isWin ||
(process.env.DOCKER_HOST &&
(process.env.DOCKER_HOST.startsWith("tcp://") ||
process.env.DOCKER_HOST.startsWith("ssh://")))
);
};
/**
* Method to get image to the local registry by pulling from the remote if required
*/
export const getImage = async (fullImageName) => {
let localData;
let pullData;
const { registry, repo, tag, digest } = parseImageName(fullImageName);
const repoWithTag =
registry && registry !== DOCKER_HUB_REGISTRY
? fullImageName
: `${repo}:${tag !== "" ? tag : ":latest"}`;
// Fetch only the latest tag if none is specified
if (tag === "" && digest === "") {
fullImageName = `${fullImageName}:latest`;
}
if (isContainerd && !needsCliFallback()) {
console.log(
"containerd/nerdctl is currently unsupported. Export the image manually and run cdxgen against the tar image.",
);
return undefined;
}
if (needsCliFallback()) {
const dockerCmd = getContainerCliCmd();
let needsPull = true;
// Let's check the local cache first
let result = safeSpawnSync(dockerCmd, ["images", "--format=json"]);
if (result.status === 0 && result.stdout) {
for (const imgLine of result.stdout.split("\n")) {
try {
const imgObj = JSON.parse(Buffer.from(imgLine).toString());
if (`${imgObj.Repository}:${imgObj.Tag}` === fullImageName) {
needsPull = false;
break;
}
} catch (_err) {
// continue regardless of error
}
}
}
if (needsPull) {
result = safeSpawnSync(dockerCmd, ["pull", fullImageName]);
if (result.status !== 0 || result.error) {
if (result.stderr?.includes("docker daemon is not running")) {
console.log(
"Ensure Docker for Desktop is running as an administrator with 'Exposing daemon on TCP without TLS' setting turned on.",
);
} else if (result.stderr?.includes("not found")) {
console.log(
"Set the environment variable DOCKER_CMD to use an alternative command such as nerdctl or podman.",
);
} else if (result.stderr) {
console.log(result.stderr);
}
}
}
result = safeSpawnSync(dockerCmd, ["inspect", fullImageName]);
if (result.status !== 0 || result.error) {
if (result.stderr) {
console.log(result.stderr);
}
// Continue with the daemon client when the CLI fallback is unavailable
// or unable to inspect the image.
} else {
try {
const stdout = result.stdout;
if (stdout) {
const inspectData = JSON.parse(Buffer.from(stdout).toString());
if (inspectData && Array.isArray(inspectData)) {
return inspectData[0];
}
return inspectData;
}
} catch (_err) {
// continue regardless of error
}
}
}
try {
localData = await makeRequest(
`images/${repoWithTag}/json`,
"GET",
registry,
);
if (localData) {
return localData;
}
} catch (_err) {
// ignore
}
try {
localData = await makeRequest(`images/${repo}/json`, "GET", registry);
} catch (_err) {
try {
localData = await makeRequest(
`images/${fullImageName}/json`,
"GET",
registry,
);
if (localData) {
return localData;
}
} catch (_err) {
// ignore
}
if (DEBUG_MODE) {
console.log(
`Trying to pull the image ${fullImageName} from registry. This might take a while ...`,
);
}
// If the data is not available locally
try {
pullData = await makeRequest(
`images/create?fromImage=${fullImageName}`,
"POST",
registry,
);
if (
pullData &&
(pullData.includes("no match for platform in manifest") ||
pullData.includes("Error choosing an image from manifest list"))
) {
console.warn(
"You may have to enable experimental settings in docker to support this platform!",
);
console.warn(
"To scan windows images, run cdxgen on a windows server with hyper-v and docker installed. Switch to windows containers in your docker settings.",
);
return undefined;
}
} catch (_err) {
try {
if (DEBUG_MODE) {
console.log(`Re-trying the pull with the name ${repoWithTag}.`);
}
await makeRequest(
`images/create?fromImage=${repoWithTag}`,
"POST",
registry,
);
} catch (_err) {
// continue regardless of error
}
}
try {
if (DEBUG_MODE) {
console.log(`Trying with ${repoWithTag}`);
}
localData = await makeRequest(
`images/${repoWithTag}/json`,
"GET",
registry,
);
if (localData) {
return localData;
}
} catch (_err) {
try {
if (DEBUG_MODE) {
console.log(`Trying with ${repo}`);
}
localData = await makeRequest(`images/${repo}/json`, "GET", registry);
if (localData) {
return localData;
}
} catch (_err) {
// continue regardless of error
}
try {
if (DEBUG_MODE) {
console.log(`Trying with ${fullImageName}`);
}
localData = await makeRequest(
`images/${fullImageName}/json`,
"GET",
registry,
);
} catch (_err) {
// continue regardless of error
}
}
}
if (!localData) {
console.log(
`Unable to pull ${fullImageName}. Check if the name is valid. Perform any authentication prior to invoking cdxgen.`,
);
console.log(
`Try manually pulling this image using docker pull ${fullImageName}`,
);
}
return localData;
};
/**
* @typedef {{ path: string }} TarReadEntryLike
*/
/**
* Warnings such as TAR_ENTRY_INFO are treated as errors in strict mode. While this is mostly desired, we can relax this
* requirement for one particular warning related to absolute paths.
* This callback function checks for absolute paths in the entry read from the archive and strips them using a custom
* method.
*
* @param {TarReadEntryLike} entry ReadEntry object from node-tar
*/
function handleAbsolutePath(entry) {
if (entry.path === "/" || win32.isAbsolute(entry.path)) {
entry.path = stripAbsolutePath(entry.path);
}
}
/**
* Filter out problematic files, paths, and devices during tar extraction.
*/
function tarFilter(path, entry) {
const name = basename(path);
if (name.startsWith(".wh.")) {
return false;
}
const ext = win32.extname(name).toLowerCase();
if (MEDIA_EXTENSIONS.has(ext)) {
return false;
}
return !(
EXTRACT_EXCLUDE_PATHS.some((p) => path.includes(p)) ||
EXTRACT_EXCLUDE_TYPES.has(entry.type)
);
}
/**
* Suppress low-signal tar warnings (TAR_ENTRY_INFO, TAR_LONGLINK) that are
* expected when extracting container image layers. All other warning codes are
* logged when DEBUG_MODE is enabled.
*
* @param {string} code Tar warning code (e.g. "TAR_ENTRY_INFO")
* @param {string} message Human-readable warning message
*/
function handleTarWarning(code, message) {
if (code === "TAR_ENTRY_INFO" || code === "TAR_LONGLINK") {
return;
}
if (DEBUG_MODE) {
console.log(code, message);
}
}
// These paths are known to cause extract errors
const EXTRACT_EXCLUDE_PATHS = [
"etc/machine-id",
"etc/gshadow",
"etc/shadow",
"etc/passwd",
"etc/ssl/certs",
"etc/pki/ca-trust",
"usr/lib/systemd/",
"usr/lib64/libdevmapper.so",
"usr/sbin/",
"cacerts",
"ssl/certs",
"logs/",
"dev/",
"proc/",
"sys/",
"usr/share/zoneinfo/",
"usr/share/doc/",
"usr/share/man/",
"usr/share/icons/",
"usr/share/i18n/",
"var/lib/ca-certificates",
"root/.gnupg",
"root/.dotnet",
"usr/share/licenses/device-mapper-libs",
];
const MEDIA_EXTENSIONS = new Set([
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".ico",
".svg",
".mp3",
".wav",
".mp4",
".avi",
".mov",
".ttf",
".woff",
".woff2",
".eot",
]);
// These device types are known to cause extract errors
const EXTRACT_EXCLUDE_TYPES = new Set([
"BlockDevice",
"CharacterDevice",
"FIFO",
"MultiVolume",
"TapeVolume",
"SymbolicLink",
"RenamedOrSymlinked",
"HardLink",
"Link",
]);
/**
* Extract a container image tar archive into a destination directory.
* Applies path sanitisation, ownership/permission preservation settings, and
* an entry filter to skip problematic files and device nodes. Handles common
* tar errors gracefully, logging only unexpected ones.
*
* @param {string} fullImageName Path to the source tar archive
* @param {string} dir Destination directory to extract into
* @param {Object} options CLI options (uses `options.failOnError`)
* @returns {Promise<boolean>} `true` on success, `false` when the archive is
* empty or a non-fatal error was encountered
*/
export const extractTar = async (fullImageName, dir, options) => {
try {
return await safeExtractArchive(
fullImageName,
dir,
async () =>
await stream.pipeline(
createReadStream(fullImageName),
x({
sync: false,
preserveOwner: false,
noMtime: true,
noChmod: true,
strict: !NON_STRICT_TAR_EXTRACT,
C: dir,
portable: true,
unlink: true,
onwarn: handleTarWarning,
onReadEntry: handleAbsolutePath,
filter: tarFilter,
}),
),
"untar",
{
blockedReason:
"Dry run mode blocks untar and layer extraction operations because they create files on disk.",
metadata: {
archiveFormat: "tar",
},
},
);
} catch (err) {
if (err.code === "EPERM" && err.syscall === "symlink") {
console.log(
"Please run cdxgen from a powershell terminal with admin privileges to create symlinks.",
);
console.log(err);
} else if (
![
"TAR_BAD_ARCHIVE",
"TAR_ENTRY_INFO",
"TAR_ENTRY_INVALID",
"TAR_ENTRY_ERROR",
"TAR_ENTRY_UNSUPPORTED",
"TAR_ABORT",
"EACCES",
].includes(err.code)
) {
console.log(
`Error while extracting image ${fullImageName} to ${dir}. Please file this bug to the cdxgen repo. https://github.com/cdxgen/cdxgen/issues`,
);
console.log("------------");
console.log(err);
console.log("------------");
} else if (err.code === "TAR_BAD_ARCHIVE") {
if (DEBUG_MODE) {
console.log(`Archive ${fullImageName} is empty. Skipping.`);
}
return false;
} else if (["EACCES"].includes(err.code)) {
console.log(err);
} else if (["TAR_ENTRY_INFO", "TAR_ENTRY_INVALID"].includes(err.code)) {
if (
err?.header?.path?.includes("{") ||
err?.message?.includes("linkpath required") ||
err?.message?.includes("linkpath forbidden")
) {
return false;
}
if (DEBUG_MODE) {
console.log(err);
}
} else if (DEBUG_MODE) {
console.log(err.code, "is not handled yet in extractTar method.");
}
options.failOnError && process.exit(1);
return false;
}
};
const readArchiveJson = (jsonFile) => {
if (!jsonFile || !safeExistsSync(jsonFile)) {
return undefined;
}
return JSON.parse(
readFileSync(jsonFile, {
encoding: "utf-8",
}),
);
};
const tryReadArchiveJson = (jsonFile) => {
try {
return readArchiveJson(jsonFile);
} catch (_err) {
return undefined;
}
};
const digestToBlobPath = (digest) => {
if (!digest?.startsWith("sha256:")) {
return undefined;
}
return join("blobs", "sha256", digest.replace("sha256:", ""));
};
const archiveBlobPath = (tempDir, digest) => {
const blobPath = digestToBlobPath(digest);
return blobPath ? join(tempDir, blobPath) : undefined;
};
const toManifestEntry = (manifestBlob) => {
const configBlob = digestToBlobPath(manifestBlob?.config?.digest);
const layers =
manifestBlob?.layers
?.map((layer) => digestToBlobPath(layer?.digest))
.filter(Boolean) || [];
if (!configBlob && !layers.length) {
return undefined;
}
return {
Config: configBlob,
Layers: layers,
};
};
const resolveArchiveManifest = (manifestData, tempDir) => {
if (Array.isArray(manifestData)) {
return manifestData;
}
if (!manifestData || typeof manifestData !== "object") {
return [];
}
if (Array.isArray(manifestData.manifests)) {
const resolvedManifests = manifestData.manifests
.map((manifestEntry) => {
if (manifestEntry?.Layers?.length || manifestEntry?.Config) {
return manifestEntry;
}
const manifestBlob = tryReadArchiveJson(
archiveBlobPath(tempDir, manifestEntry?.digest),
);
const resolvedEntry = toManifestEntry(manifestBlob);
return resolvedEntry
? {
...manifestEntry,
...resolvedEntry,
}
: manifestEntry;
})
.filter(Boolean);
return resolvedManifests.length
? resolvedManifests
: manifestData.manifests;
}
const manifestEntry = toManifestEntry(manifestData);
return manifestEntry ? [manifestEntry] : [];
};
const discoverManifestFromBlobs = (tempDir) => {
const blobsDir = join(tempDir, "blobs", "sha256");
if (!safeExistsSync(blobsDir)) {
return undefined;
}
const blobFiles = readdirSync(blobsDir);
for (const blobFile of blobFiles) {
const manifestBlob = tryReadArchiveJson(join(blobsDir, blobFile));
const manifestEntry = toManifestEntry(manifestBlob);
if (manifestEntry?.Layers?.length || manifestEntry?.Config) {
return [manifestEntry];
}
}
return undefined;
};
/**
* Method to export a container image archive.
* Returns the location of the layers with additional packages related metadata
*/
export const exportArchive = async (fullImageName, options = {}) => {
if (isDryRun) {
recordActivity({
kind: "container",
reason:
"Dry run mode blocks container archive expansion and layer materialization.",
status: "blocked",
target: fullImageName,
});
return undefined;
}
if (!safeExistsSync(fullImageName)) {
console.log(`Unable to find container image archive ${fullImageName}`);
return undefined;
}
const manifest = {};
const tempDir = safeMkdtempSync(join(getTmpDir(), "docker-images-"));
const allLayersExplodedDir = join(tempDir, "all-layers");
const blobsDir = join(tempDir, "blobs", "sha256");
safeMkdirSync(allLayersExplodedDir);
const manifestFile = join(tempDir, "manifest.json");
const manifestIndexFile = join(tempDir, "index.json");
const synthesizedManifestFile = join(tempDir, "synthetic-manifest.json");
try {
await extractTar(fullImageName, tempDir, options);
if (safeExistsSync(manifestFile)) {
// docker archive manifest file
return await extractFromManifest(
manifestFile,
{},
tempDir,
allLayersExplodedDir,
options,
);
}
if (safeExistsSync(manifestIndexFile)) {
return await extractFromManifest(
manifestIndexFile,
{},
tempDir,
allLayersExplodedDir,
options,
);
}
// podman use blobs dir
if (safeExistsSync(blobsDir)) {
const discoveredManifest = discoverManifestFromBlobs(tempDir);
if (discoveredManifest?.length) {
safeWriteSync(
synthesizedManifestFile,
JSON.stringify(discoveredManifest),
"utf-8",
);
return await extractFromManifest(
synthesizedManifestFile,
{},
tempDir,
allLayersExplodedDir,
options,
);
}
if (DEBUG_MODE) {
console.log(
`Image archive ${fullImageName} successfully exported to directory ${tempDir}`,
);
}
const allBlobs = getAllFiles(blobsDir, "*");
for (const ablob of allBlobs) {
if (DEBUG_MODE) {
console.log(`Extracting ${ablob} to ${allLayersExplodedDir}`);
}
await extractTar(ablob, allLayersExplodedDir, options);
}
const lastLayerConfig = {};
// Bug #3565. We may not know the work directory, so we have to try and detect them.
const lastWorkingDir = "/";
const exportData = {
manifest,
allLayersDir: tempDir,
allLayersExplodedDir,
lastLayerConfig,
lastWorkingDir,
};
exportData.pkgPathList = getPkgPathList(exportData, lastWorkingDir);
return exportData;
}
console.log(`Unable to extract image archive to ${tempDir}`);
options.failOnError && process.exit(1);
} catch (_err) {
// ignore
options.failOnError && process.exit(1);
}
return undefined;
};
/**
* Parse a Docker/containerd manifest file and extract all image layers into a
* single merged directory. Resolves the last layer's config to determine the
* container's working directory, and builds the package path list for
* subsequent analysis.
*
* @param {string} manifestFile Path to the manifest.json (or index.json) file
* @param {Object} localData Local image inspect data (e.g. from `docker inspect`)
* @param {string} tempDir Temporary directory that holds the unpacked image
* @param {string} allLayersExplodedDir Directory where all layers are merged
* @param {Object} options CLI options (uses `options.failOnError`)
* @returns {Promise<Object>} Export data object containing `manifest`,
* `allLayersDir`, `allLayersExplodedDir`, `lastLayerConfig`,
* `lastWorkingDir`, `binPaths`, and `pkgPathList`
*/
export const extractFromManifest = async (
manifestFile,
localData,
tempDir,
allLayersExplodedDir,
options,
) => {
// Example of manifests
// [{"Config":"blobs/sha256/dedc100afa8d6718f5ac537730dd4a5ceea3563e695c90f1a8ac6df32c4cb291","RepoTags":["shiftleft/core:latest"],"Layers":["blobs/sha256/eaead16dc43bb8811d4ff450935d607f9ba4baffda4fc110cc402fa43f601d83","blobs/sha256/2039af03c0e17a3025b989335e9414149577fa09e7d0dcbee80155333639d11f"]}]
// {"schemaVersion":2,"manifests":[{"mediaType":"a