@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
694 lines (647 loc) • 22.7 kB
JavaScript
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import fs from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { isCycloneDxBom } from "../helpers/bomUtils.js";
import { cdxgenAgent, getAllFiles, safeExistsSync } from "../helpers/utils.js";
const ORAS_CREATED_ANNOTATION = "org.opencontainers.image.created";
// artifactType values used for CycloneDX SBOM referrers. Registries such as
// ghcr.io ignore the `?artifactType=` referrers query filter and return *every*
// referrer (build-provenance attestations, cosign signatures, other SBOM
// formats, ...) alongside the CycloneDX SBOM. Tags that accumulate many
// referrers over time (e.g. the rolling `nightly`/`master` tags) therefore
// require us to filter client-side, otherwise the newest referrer selected may
// not be a CycloneDX SBOM at all and its layer will fail to parse as JSON.
const CYCLONEDX_ARTIFACT_TYPES = new Set([
"application/vnd.cyclonedx+json",
"sbom/cyclonedx",
]);
function isCycloneDxArtifactType(artifactType) {
return !!artifactType && CYCLONEDX_ARTIFACT_TYPES.has(artifactType);
}
// mediaType values a referrer manifest layer may use to carry a CycloneDX BOM.
// `attachBomNative` writes `application/vnd.cyclonedx+json`; BuildKit native
// attestations wrap the BOM in an in-toto statement layer. We use the layer
// descriptor's mediaType (the authoritative signal — blob responses are served
// as octet-stream) to pick the correct layer instead of assuming layers[0].
const CYCLONEDX_LAYER_MEDIA_TYPES = new Set([
"application/vnd.cyclonedx+json",
"application/vnd.in-toto+json",
]);
function selectBomLayer(layers) {
if (!Array.isArray(layers) || !layers.length) {
return undefined;
}
return (
layers.find((layer) => CYCLONEDX_LAYER_MEDIA_TYPES.has(layer?.mediaType)) ||
// Fall back to the first layer for older attachments / registries that omit
// the layer mediaType.
layers[0]
);
}
// OCI registry probes (manifest/token/referrers/blob lookups) run on the audit
// hot path — including from inside JSONata rules (`$hasSbom`/`$hasSignedSbom`),
// whose evaluation is time-bounded. Use a smaller per-request timeout than the
// global cdxgenAgent default so an unreachable/slow registry fails fast instead
// of tying up the scan. Overridable via CDXGEN_OCI_TIMEOUT_MS.
const DEFAULT_OCI_TIMEOUT_MS = 8000;
const MAX_OCI_TIMEOUT_MS = 300000;
function getOciTimeoutMs() {
const raw = process.env.CDXGEN_OCI_TIMEOUT_MS;
if (raw === undefined || raw === "") {
return DEFAULT_OCI_TIMEOUT_MS;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
return DEFAULT_OCI_TIMEOUT_MS;
}
return Math.min(parsed, MAX_OCI_TIMEOUT_MS);
}
function getManifestDescriptors(manifestObj) {
if (Array.isArray(manifestObj?.manifests)) {
return manifestObj.manifests;
}
if (Array.isArray(manifestObj?.referrers)) {
return manifestObj.referrers;
}
return [];
}
function getRepositoryRef(image) {
let repositoryRef = image;
const digestIndex = repositoryRef.indexOf("@");
if (digestIndex !== -1) {
repositoryRef = repositoryRef.slice(0, digestIndex);
}
const lastSlashIndex = repositoryRef.lastIndexOf("/");
const tagIndex = repositoryRef.lastIndexOf(":");
if (tagIndex > lastSlashIndex) {
repositoryRef = repositoryRef.slice(0, tagIndex);
}
return repositoryRef;
}
function getManifestImageRef(image, manifest) {
if (manifest?.reference) {
return manifest.reference;
}
if (manifest?.digest) {
return `${getRepositoryRef(image)}@${manifest.digest}`;
}
return undefined;
}
function getManifestCreatedAt(manifest) {
const createdAt = manifest?.annotations?.[ORAS_CREATED_ANNOTATION];
if (!createdAt) {
return undefined;
}
const createdAtTimestamp = Date.parse(createdAt);
if (Number.isNaN(createdAtTimestamp)) {
return undefined;
}
return createdAtTimestamp;
}
/**
* Return the candidate SBOM referrer image references, newest first.
*
* Descriptors whose `artifactType` identifies them as a CycloneDX SBOM are
* preferred. Only when no descriptor advertises a CycloneDX artifactType (e.g.
* a registry that strips the field, or an older attachment) do we fall back to
* considering every descriptor, preserving the previous best-effort behaviour.
*
* @param {string} image OCI image reference used to build digest references.
* @param {Object} manifestObj Referrers response / image index object.
* @returns {string[]} Ordered list of candidate image references (newest first).
*/
function selectManifestImageRefs(image, manifestObj) {
const manifestDescriptors = getManifestDescriptors(manifestObj);
const allCandidates = manifestDescriptors
.map((manifest, index) => {
const imageRef = getManifestImageRef(image, manifest);
if (!imageRef) {
return undefined;
}
return {
artifactType: manifest?.artifactType,
createdAt: getManifestCreatedAt(manifest),
imageRef,
index,
};
})
.filter(Boolean);
const cdxCandidates = allCandidates.filter((c) =>
isCycloneDxArtifactType(c.artifactType),
);
const candidates = cdxCandidates.length ? cdxCandidates : allCandidates;
candidates.sort((a, b) => {
if (a.createdAt !== undefined || b.createdAt !== undefined) {
if (a.createdAt === undefined) {
return 1;
}
if (b.createdAt === undefined) {
return -1;
}
if (b.createdAt !== a.createdAt) {
return b.createdAt - a.createdAt;
}
}
return b.index - a.index;
});
return candidates.map((c) => c.imageRef);
}
function _getBomFiles(tmpDir) {
let bomFiles = getAllFiles(tmpDir, "**/*.{bom,cdx}.json");
if (!bomFiles.length) {
bomFiles = getAllFiles(tmpDir, "**/bom.json");
}
if (!bomFiles.length) {
bomFiles = getAllFiles(tmpDir, "**/*.json");
}
return bomFiles;
}
function parseImageRef(image) {
let registry = "docker.io";
let repoAndTag = image;
const firstSlash = image.indexOf("/");
if (firstSlash !== -1) {
const hostCandidate = image.slice(0, firstSlash);
if (
hostCandidate.includes(".") ||
hostCandidate.includes(":") ||
hostCandidate === "localhost"
) {
registry = hostCandidate;
repoAndTag = image.slice(firstSlash + 1);
}
}
let repository = repoAndTag;
let reference = "latest";
const atIndex = repoAndTag.indexOf("@");
if (atIndex !== -1) {
repository = repoAndTag.slice(0, atIndex);
reference = repoAndTag.slice(atIndex + 1);
} else {
const colonIndex = repoAndTag.lastIndexOf(":");
if (colonIndex !== -1) {
repository = repoAndTag.slice(0, colonIndex);
reference = repoAndTag.slice(colonIndex + 1);
}
}
if (registry === "docker.io" && !repository.includes("/")) {
repository = `library/${repository}`;
}
return { registry, repository, reference };
}
function getDockerCreds(registry) {
try {
const configPath = join(homedir(), ".docker", "config.json");
if (safeExistsSync(configPath)) {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
if (config.auths) {
const auth =
config.auths[registry] ||
config.auths[`https://${registry}`] ||
config.auths[`https://${registry}/v1/`];
if (auth?.auth) {
return auth.auth;
}
}
}
} catch (_e) {
/* ignore */
}
return process.env.DOCKER_AUTH;
}
async function getOciToken(registry, repository, scope) {
const creds = getDockerCreds(registry);
const scheme = registry.startsWith("localhost") ? "http" : "https";
const authUrl = `${scheme}://${registry}/v2/`;
try {
const res = await cdxgenAgent.get(authUrl, {
throwHttpErrors: false,
timeout: getOciTimeoutMs(),
});
if (res.statusCode === 401) {
const wwwAuth = res.headers["www-authenticate"];
if (wwwAuth?.toLowerCase().startsWith("bearer ")) {
const params = {};
wwwAuth
.substring(7)
.split(",")
.forEach((part) => {
const [k, v] = part.split("=");
if (v) params[k.trim()] = v.trim().replace(/"/g, "");
});
if (params.realm) {
const reqUrl = new URL(params.realm);
if (params.service)
reqUrl.searchParams.set("service", params.service);
reqUrl.searchParams.set("scope", `repository:${repository}:${scope}`);
const options = {
responseType: "json",
timeout: getOciTimeoutMs(),
};
if (creds) {
options.headers = { Authorization: `Basic ${creds}` };
}
const tokenRes = await cdxgenAgent.get(reqUrl.toString(), options);
if (
tokenRes.body &&
(tokenRes.body.token || tokenRes.body.access_token)
) {
return tokenRes.body.token || tokenRes.body.access_token;
}
}
}
}
} catch (_e) {
/* ignore */
}
if (creds) return `Basic ${creds}`;
return null;
}
/**
* Fetch a URL and parse the response body as JSON without throwing on a
* non-JSON payload. Registries that do not implement an endpoint (notably
* ghcr.io for the OCI referrers API) answer with a 404 *HTML* error page; the
* shared httpClient parses `responseType: "json"` eagerly and would throw on
* that HTML before the caller can inspect the status code. Reading as text and
* parsing defensively lets callers route on `statusCode` and fall back cleanly.
*
* @returns {Promise<{statusCode: number, headers: Object, body: Object|undefined}>}
*/
async function fetchJson(url, headers) {
const res = await cdxgenAgent.get(url, {
headers,
responseType: "text",
throwHttpErrors: false,
timeout: getOciTimeoutMs(),
});
let body;
if (typeof res.body === "string" && res.body.length) {
try {
body = JSON.parse(res.body);
} catch {
body = undefined;
}
}
return { statusCode: res.statusCode, headers: res.headers, body };
}
async function fetchManifest(registry, repository, reference, token) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
const url = `${scheme}://${registry}/v2/${repository}/manifests/${reference}`;
const headers = {
Accept:
"application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
};
if (token) {
headers.Authorization = token.startsWith("Basic")
? token
: `Bearer ${token}`;
}
const res = await fetchJson(url, headers);
if (res.statusCode === 200) {
return {
manifest: res.body,
digest: res.headers["docker-content-digest"],
mediaType: res.headers["content-type"],
};
}
return null;
}
async function discoverReferrers(registry, repository, digest, token) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
const url = `${scheme}://${registry}/v2/${repository}/referrers/${digest}?artifactType=application/vnd.cyclonedx+json`;
const headers = {};
if (token) {
headers.Authorization = token.startsWith("Basic")
? token
: `Bearer ${token}`;
}
try {
const res = await fetchJson(url, headers);
if (res.statusCode === 200) {
return res.body;
}
} catch (_e) {
/* ignore */
}
return undefined;
}
async function pullBlob(registry, repository, digest, token) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
const url = `${scheme}://${registry}/v2/${repository}/blobs/${digest}`;
const headers = {};
if (token) {
headers.Authorization = token.startsWith("Basic")
? token
: `Bearer ${token}`;
}
const res = await cdxgenAgent.get(url, {
headers,
responseType: "buffer",
timeout: getOciTimeoutMs(),
});
return res.body;
}
/**
* Retrieves a CycloneDX BOM attached to an OCI image purely in JavaScript
* without relying on the `oras` CLI tool.
*
* @param {string} image OCI image reference (e.g. `"registry.example.com/org/app:tag"`)
* @param {string} [platform] OCI platform string (e.g. `"linux/amd64"`); no-op for JS implementation
* @returns {Promise<Object|undefined>} Parsed CycloneDX BOM JSON object, or `undefined` if not found
*/
export async function getBomWithOras(image, _platform = undefined) {
const { registry, repository, reference } = parseImageRef(image);
const token = await getOciToken(registry, repository, "pull");
try {
const targetManifest = await fetchManifest(
registry,
repository,
reference,
token,
);
if (!targetManifest?.digest) {
return undefined;
}
const digest = targetManifest.digest;
let referrersObj = await discoverReferrers(
registry,
repository,
digest,
token,
);
// If not found with artifactType filter, try fetching all referrers
if (!referrersObj?.manifests || referrersObj.manifests.length === 0) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
let url = `${scheme}://${registry}/v2/${repository}/referrers/${digest}`;
const headers = token
? {
Authorization: token.startsWith("Basic")
? token
: `Bearer ${token}`,
}
: {};
let res = await fetchJson(url, headers);
if (res.statusCode === 200 && res.body?.manifests) {
referrersObj = res.body;
} else {
// Registries such as ghcr.io do not implement the referrers API and
// answer with a 404 HTML page. Fall back to the OCI referrers tag
// schema, where the SBOM index is stored under a `sha256-<digest>` tag.
const fallbackTag = digest.replace(":", "-");
url = `${scheme}://${registry}/v2/${repository}/manifests/${fallbackTag}`;
headers.Accept = "application/vnd.oci.image.index.v1+json";
res = await fetchJson(url, headers);
if (res.statusCode === 200) {
referrersObj = res.body;
}
}
}
// Prefer CycloneDX-typed referrers, newest first. Registries that ignore
// the artifactType query filter return every referrer, so we may still see
// non-SBOM referrers here (e.g. on the rolling nightly tag). Try each
// candidate until one yields a valid CycloneDX BOM instead of failing on
// the first non-JSON layer.
const imageRefs = selectManifestImageRefs(image, referrersObj);
for (const imageRef of imageRefs) {
const refParsed = parseImageRef(imageRef);
const manifestNode = await fetchManifest(
refParsed.registry,
refParsed.repository,
refParsed.reference,
token,
);
const bomLayer = selectBomLayer(manifestNode?.manifest?.layers);
if (!bomLayer?.digest) {
continue;
}
const blob = await pullBlob(
registry,
refParsed.repository,
bomLayer.digest,
token,
);
let bomJson;
try {
bomJson = JSON.parse(blob.toString("utf8"));
} catch {
// Defensive: the mediaType claimed JSON but the layer did not parse
// (truncated/corrupt blob). Skip it and try the next candidate.
continue;
}
// Extract from in-toto envelope (BuildKit native attestations)
if (
bomJson &&
(bomJson._type === "https://in-toto.io/Statement/v0.1" ||
bomJson._type === "https://in-toto.io/Statement/v1") &&
bomJson.predicateType === "https://cyclonedx.org/bom" &&
bomJson.predicate
) {
bomJson = bomJson.predicate;
}
if (isCycloneDxBom(bomJson)) {
return bomJson;
}
}
} catch (e) {
console.log(
`Unable to pull the SBOM attachment for ${image} natively! ${e.message}`,
);
}
return undefined;
}
function getDigest(buffer) {
return `sha256:${createHash("sha256").update(buffer).digest("hex")}`;
}
async function pushBlob(registry, repository, buffer, token) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
const digest = getDigest(buffer);
const headers = {};
if (token) {
headers.Authorization = token.startsWith("Basic")
? token
: `Bearer ${token}`;
}
// 1. Initiate upload
const initUrl = `${scheme}://${registry}/v2/${repository}/blobs/uploads/`;
const initRes = await cdxgenAgent.post(initUrl, {
headers,
throwHttpErrors: false,
});
if (initRes.statusCode === 201 || initRes.statusCode === 202) {
let location = initRes.headers.location;
if (!location.startsWith("http")) {
location = `${scheme}://${registry}${location.startsWith("/") ? "" : "/"}${location}`;
}
const uploadUrl = new URL(location);
uploadUrl.searchParams.set("digest", digest);
// 2. Upload blob
const putHeaders = {
...headers,
"Content-Length": buffer.length.toString(),
"Content-Type": "application/octet-stream",
};
const putRes = await cdxgenAgent.put(uploadUrl.toString(), {
headers: putHeaders,
body: buffer,
throwHttpErrors: false,
});
if (putRes.statusCode === 201 || putRes.statusCode === 202) {
return { digest, size: buffer.length };
}
throw new Error(
`Failed to upload blob: ${putRes.statusCode} ${putRes.body}`,
);
}
if (initRes.statusCode === 401) {
throw new Error(
`Unauthorized to initiate blob upload: ${initRes.statusCode}`,
);
}
throw new Error(
`Failed to initiate blob upload: ${initRes.statusCode} ${initRes.body}`,
);
}
async function pushManifest(registry, repository, manifestObj, token) {
const scheme = registry.startsWith("localhost") ? "http" : "https";
const buffer = Buffer.from(JSON.stringify(manifestObj));
const digest = getDigest(buffer);
const url = `${scheme}://${registry}/v2/${repository}/manifests/${digest}`;
const headers = {
"Content-Type":
manifestObj.mediaType || "application/vnd.oci.image.manifest.v1+json",
};
if (token) {
headers.Authorization = token.startsWith("Basic")
? token
: `Bearer ${token}`;
}
const res = await cdxgenAgent.put(url, {
headers,
body: buffer,
throwHttpErrors: false,
});
if (res.statusCode === 201 || res.statusCode === 202) {
return digest;
}
throw new Error(`Failed to push manifest: ${res.statusCode} ${res.body}`);
}
export async function attachBomNative(image, bomJson) {
const { registry, repository, reference } = parseImageRef(image);
const token = await getOciToken(registry, repository, "pull,push");
// 1. Fetch target manifest
const targetManifest = await fetchManifest(
registry,
repository,
reference,
token,
);
if (!targetManifest?.digest) {
throw new Error(`Target image ${image} not found or no access.`);
}
// 2. Push SBOM blob
const bomBuffer = Buffer.from(JSON.stringify(bomJson));
const blobInfo = await pushBlob(registry, repository, bomBuffer, token);
// Push the empty config blob ({}) required by the OCI 1.1 artifact manifest
const emptyConfigBuffer = Buffer.from("{}");
await pushBlob(registry, repository, emptyConfigBuffer, token);
// 3. Push OCI 1.1 Manifest
const manifestObj = {
schemaVersion: 2,
mediaType: "application/vnd.oci.image.manifest.v1+json",
artifactType: "application/vnd.cyclonedx+json",
config: {
mediaType: "application/vnd.oci.empty.v1+json",
digest:
"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
size: 2,
},
layers: [
{
mediaType: "application/vnd.cyclonedx+json",
digest: blobInfo.digest,
size: blobInfo.size,
},
],
subject: {
mediaType:
targetManifest.mediaType ||
"application/vnd.oci.image.manifest.v1+json",
digest: targetManifest.digest,
size: targetManifest.manifest
? Buffer.from(JSON.stringify(targetManifest.manifest)).length
: 0,
},
annotations: {
"org.opencontainers.image.created": new Date().toISOString(),
},
};
let manifestDigest = await pushManifest(
registry,
repository,
manifestObj,
token,
);
// Probe if referrers API is supported by the registry
const probeUrl = `${registry.startsWith("localhost") ? "http" : "https"}://${registry}/v2/${repository}/referrers/${targetManifest.digest}`;
const probeHeaders = token
? { Authorization: token.startsWith("Basic") ? token : `Bearer ${token}` }
: {};
const probeRes = await cdxgenAgent.get(probeUrl, {
headers: probeHeaders,
throwHttpErrors: false,
timeout: getOciTimeoutMs(),
});
if (probeRes.statusCode === 404 || probeRes.statusCode === 400) {
// Registry does not support referrers API natively. Create the fallback tag.
const fallbackTag = targetManifest.digest.replace(":", "-");
// Check if the fallback tag already exists (an Image Index)
const fallbackUrl = `${registry.startsWith("localhost") ? "http" : "https"}://${registry}/v2/${repository}/manifests/${fallbackTag}`;
const getRes = await cdxgenAgent.get(fallbackUrl, {
headers: {
...probeHeaders,
Accept: "application/vnd.oci.image.index.v1+json",
},
responseType: "json",
throwHttpErrors: false,
timeout: getOciTimeoutMs(),
});
let indexObj;
if (getRes.statusCode === 200 && getRes.body && getRes.body.manifests) {
indexObj = getRes.body;
indexObj.manifests.push({
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: manifestDigest,
size: Buffer.from(JSON.stringify(manifestObj)).length,
artifactType: "application/vnd.cyclonedx+json",
});
} else {
indexObj = {
schemaVersion: 2,
mediaType: "application/vnd.oci.image.index.v1+json",
manifests: [
{
mediaType: "application/vnd.oci.image.manifest.v1+json",
digest: manifestDigest,
size: Buffer.from(JSON.stringify(manifestObj)).length,
artifactType: "application/vnd.cyclonedx+json",
},
],
};
}
const indexBuffer = Buffer.from(JSON.stringify(indexObj));
const putHeaders = {
...probeHeaders,
"Content-Type": "application/vnd.oci.image.index.v1+json",
};
await cdxgenAgent.put(fallbackUrl, {
headers: putHeaders,
body: indexBuffer,
throwHttpErrors: false,
});
manifestDigest = getDigest(indexBuffer);
}
console.log(`Attached SBOM natively to ${image} via ${manifestDigest}`);
return manifestDigest;
}