@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
598 lines (557 loc) • 18.3 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";
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;
}
function selectManifestImageRef(image, manifestObj) {
const manifestDescriptors = getManifestDescriptors(manifestObj);
const candidates = manifestDescriptors
.map((manifest, index) => {
const imageRef = getManifestImageRef(image, manifest);
if (!imageRef) {
return undefined;
}
return {
createdAt: getManifestCreatedAt(manifest),
imageRef,
index,
};
})
.filter(Boolean);
if (!candidates.length) {
return undefined;
}
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[0]?.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 });
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" };
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;
}
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 cdxgenAgent.get(url, {
headers,
responseType: "json",
throwHttpErrors: false,
});
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 cdxgenAgent.get(url, {
headers,
responseType: "json",
throwHttpErrors: false,
});
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" });
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 cdxgenAgent.get(url, {
headers,
responseType: "json",
throwHttpErrors: false,
});
if (res.statusCode === 200) {
referrersObj = res.body;
} else if (res.statusCode === 404 || res.statusCode === 400) {
// Fallback to OCI referrers tag schema
const fallbackTag = digest.replace(":", "-");
url = `${scheme}://${registry}/v2/${repository}/manifests/${fallbackTag}`;
headers.Accept = "application/vnd.oci.image.index.v1+json";
res = await cdxgenAgent.get(url, {
headers,
responseType: "json",
throwHttpErrors: false,
});
if (res.statusCode === 200) {
referrersObj = res.body;
}
}
}
let imageRef = selectManifestImageRef(image, referrersObj);
if (!imageRef && referrersObj?.manifests) {
for (const m of referrersObj.manifests) {
if (
m.artifactType === "application/vnd.cyclonedx+json" ||
m.artifactType === "sbom/cyclonedx"
) {
imageRef = `${repository}@${m.digest}`;
break;
}
}
}
if (imageRef) {
const refParsed = parseImageRef(imageRef);
const manifestNode = await fetchManifest(
refParsed.registry,
refParsed.repository,
refParsed.reference,
token,
);
if (
manifestNode?.manifest?.layers &&
manifestNode.manifest.layers.length > 0
) {
const layerDigest = manifestNode.manifest.layers[0].digest;
const blob = await pullBlob(
registry,
refParsed.repository,
layerDigest,
token,
);
let bomJson = JSON.parse(blob.toString("utf8"));
// 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,
});
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,
});
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;
}