@cyclonedx/cdxgen
Version:
Creates CycloneDX Software Bill of Materials (SBOM) from source or container image
1,517 lines (1,497 loc) • 54.7 kB
JavaScript
/**
* Internal compliance rule catalog for cdx-validate.
*
* Implements OWASP SCVS (Software Component Verification Standard) controls
* and selected EU Cyber Resilience Act (CRA) SBOM expectations as plain
* JavaScript evaluators. Controls that are not automatable from a static
* CycloneDX BOM (for example, process or organizational controls) are still
* modelled so that benchmark reports can surface them as "manual review
* required" items with a stable identifier.
*
* Each rule exports:
* id - Stable short identifier (e.g. "SCVS-1.1").
* name - Human readable short name.
* description - Long description (wording taken from the source standard).
* standard - Source standard key: "SCVS" or "CRA".
* standardRefs - Array of canonical control identifiers.
* category - Grouping used by --categories.
* severity - Severity emitted for a failing automatable rule.
* scvsLevels - For SCVS rules, the levels (L1/L2/L3) that require the
* control. Non-SCVS rules use an empty array.
* automatable - True when evaluate() returns a deterministic pass/fail
* from the BOM alone. False means the rule is emitted as
* severity "info" / status "manual" so downstream tooling
* can track coverage.
* evaluate - Function(bomJson) => RuleResult.
*
* RuleResult shape:
* {
* status: "pass" | "fail" | "manual",
* message: string, // human readable summary
* mitigation?: string,
* locations?: Array<{ bomRef?, purl?, file? }>,
* evidence?: Record<string, any>
* }
*/
import { PackageURL } from "packageurl-js";
import { isCycloneDxBom } from "../helpers/bomUtils.js";
/**
* Extract the first SPDX-ish license id from a CycloneDX component's licenses
* block. Returns null when no license is declared.
*
* @param {object} comp CycloneDX component
* @returns {string | null}
*/
function componentLicenseId(comp) {
if (!comp?.licenses?.length) {
return null;
}
for (const entry of comp.licenses) {
if (entry?.license?.id) {
return entry.license.id;
}
if (entry?.expression) {
return entry.expression;
}
}
for (const entry of comp.licenses) {
if (entry?.license?.name) {
return entry.license.name;
}
}
return null;
}
function getAllComponents(bomJson) {
const results = [];
function traverse(comps) {
if (!Array.isArray(comps)) {
return;
}
for (const c of comps) {
if (c?.scope === "excluded") {
continue;
}
results.push(c);
if (c.components) {
traverse(c.components);
}
}
}
traverse(bomJson?.components);
return results;
}
/**
* Collect libraries/frameworks/applications worth evaluating for inventory
* checks. Crypto-assets and data types are excluded because they are tracked
* with different schemas in CycloneDX.
*
* @param {object} bomJson
* @returns {Array<object>}
*/
function inventoryComponents(bomJson) {
if (!Array.isArray(bomJson?.components)) {
return [];
}
return getAllComponents(bomJson).filter((c) =>
[
"application",
"framework",
"library",
"container",
"operating-system",
].includes(c?.type),
);
}
/**
* Format a component identifier for console messages.
*
* @param {object} comp
* @returns {string}
*/
function compLabel(comp) {
return comp?.purl || comp?.["bom-ref"] || comp?.name || "<unknown>";
}
/**
* Build a Set of all bom-refs declared anywhere in the BOM so that we can
* detect orphan components that are not reachable from the dependency tree.
*
* @param {object} bomJson
* @returns {Set<string>}
*/
function collectReferencedRefs(bomJson) {
const refs = new Set();
const rootRef = bomJson?.metadata?.component?.["bom-ref"];
if (rootRef) {
refs.add(rootRef);
}
for (const dep of bomJson?.dependencies || []) {
if (dep?.ref) {
refs.add(dep.ref);
}
for (const child of dep?.dependsOn || []) {
refs.add(child);
}
for (const prov of dep?.provides || []) {
refs.add(prov);
}
}
return refs;
}
/**
* Validate that a license expression is syntactically a known SPDX identifier
* or an expression built from SPDX operators. This is a best-effort check
* that tokenises the expression first — avoiding backtracking-heavy regex
* alternations — and then validates each token with a simple character-class
* pattern.
*
* @param {string} expr
* @returns {boolean}
*/
function looksLikeSpdx(expr) {
if (!expr || typeof expr !== "string") {
return false;
}
const trimmed = expr.trim();
// Reject obvious "unknown" placeholders emitted by several tools.
const lower = trimmed.toLowerCase();
if (["noassertion", "unknown", "unlicensed", ""].includes(lower)) {
return false;
}
// Strip balanced parentheses so we can focus on the identifier+operator
// shape. Parentheses are structural only in SPDX expressions.
const withoutParens = trimmed.replace(/[()]/g, " ");
// Identifiers: alphanumeric, dots, dashes, pluses, slashes, colons.
const tokenPattern = /^[A-Za-z0-9.+\-/:]+$/;
const operators = new Set(["AND", "OR", "WITH"]);
// Split on whitespace once; linear scan below validates every token. Two
// consecutive operators or two consecutive identifiers both fail.
const tokens = withoutParens.split(/\s+/).filter(Boolean);
if (tokens.length === 0) {
return false;
}
let expectIdentifier = true;
for (const tok of tokens) {
if (operators.has(tok)) {
if (expectIdentifier) return false; // operator cannot come first
expectIdentifier = true;
} else {
if (!expectIdentifier) return false; // two identifiers in a row
if (!tokenPattern.test(tok)) return false;
expectIdentifier = false;
}
}
// Must end on an identifier, not an operator.
return !expectIdentifier;
}
/**
* Helper to build a standard "pass" rule result.
*/
function pass(message, extras = {}) {
return { status: "pass", message, ...extras };
}
/**
* Helper to build a standard "fail" rule result.
*/
function fail(message, extras = {}) {
return { status: "fail", message, ...extras };
}
/**
* Helper to build a standard "manual" rule result (non-automatable control).
*/
function manual(message, extras = {}) {
return { status: "manual", message, ...extras };
}
const CDX_AUDIT_MANUAL_COMMAND = "cdx-audit --bom bom.json --scope required";
function buildCdxAuditAssistMitigation(reviewFocus) {
return `${reviewFocus} To support manual verification, run \`${CDX_AUDIT_MANUAL_COMMAND}\` against the same SBOM and review the resulting repository, workflow, provenance, and publishing findings.`;
}
function buildCdxAuditAssistEvidence(controlId) {
return {
reviewMode: "manual-with-cdx-audit",
standardRef: `SCVS-${controlId}`,
suggestedCommand: CDX_AUDIT_MANUAL_COMMAND,
};
}
/**
* Factory for SCVS manual-review rules. These are emitted so that benchmark
* reports can accurately reflect per-level coverage even when the rule cannot
* be evaluated automatically.
*
* @param {string} id
* @param {string} name
* @param {string} description
* @param {{ l1: boolean, l2: boolean, l3: boolean }} levels
* @param {{ mitigation?: string, evidence?: object }} [options]
* @returns {object}
*/
function scvsManual(id, name, description, levels, options = {}) {
const required = [];
if (levels.l1) required.push("L1");
if (levels.l2) required.push("L2");
if (levels.l3) required.push("L3");
return {
id: `SCVS-${id}`,
name,
description,
standard: "SCVS",
standardRefs: [`SCVS-${id}`],
category: "compliance-scvs",
severity: "info",
scvsLevels: required,
automatable: false,
evaluate: () =>
manual(
`${name} is not automatable from the BOM and requires manual review.`,
{
evidence: options.evidence,
mitigation: options.mitigation || description,
},
),
};
}
// ---------------------------------------------------------------------------
// OWASP SCVS automatable rules
// ---------------------------------------------------------------------------
/** @type {Array<object>} */
const SCVS_RULES = [
{
id: "SCVS-1.1",
name: "Components and versions known",
description:
"All direct and transitive components and their versions are known at completion of a build.",
standard: "SCVS",
standardRefs: ["SCVS-1.1"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (comps.length === 0) {
return fail(
"BOM has no application, framework, or library components.",
{
mitigation:
"Regenerate the BOM with cdxgen so that all direct and transitive components are captured.",
},
);
}
const missing = comps.filter((c) => !c.version);
if (missing.length) {
return fail(`${missing.length} component(s) are missing a version.`, {
mitigation:
"Ensure lockfiles are committed and cdxgen has access to them; set --project-version for the root component.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
name: c.name,
})),
evidence: { missingVersionCount: missing.length },
});
}
return pass(`All ${comps.length} components have a version.`);
},
},
scvsManual(
"1.2",
"Package managers used for third-party binaries",
"Package managers are used to manage all third-party binary components.",
{ l1: true, l2: true, l3: true },
),
{
id: "SCVS-1.3",
name: "Machine-readable third-party inventory",
description:
"An accurate inventory of all third-party components is available in a machine-readable format.",
standard: "SCVS",
standardRefs: ["SCVS-1.3"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
if (!isCycloneDxBom(bomJson)) {
return fail(
"BOM is not a valid CycloneDX document (bomFormat/specVersion missing).",
{
mitigation:
"Produce the SBOM with cdxgen or another CycloneDX-compliant tool.",
},
);
}
const comps = inventoryComponents(bomJson);
return pass(
`Machine-readable CycloneDX ${bomJson.specVersion} inventory with ${comps.length} component(s).`,
);
},
},
scvsManual(
"1.4",
"SBOMs generated for published applications",
"Software bill of materials are generated for publicly or commercially available applications.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"1.5",
"SBOMs required for new procurements",
"Software bill of materials are required for new procurements.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"1.6",
"SBOMs continuously maintained",
"Software bill of materials continuously maintained and current for all systems.",
{ l1: false, l2: false, l3: true },
),
{
id: "SCVS-1.7",
name: "Consistent machine-readable identifiers",
description:
"Components are uniquely identified in a consistent, machine-readable format.",
standard: "SCVS",
standardRefs: ["SCVS-1.7"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.purl && !(c.cpe || c.swid?.tagId));
if (missing.length) {
return fail(
`${missing.length} component(s) lack a purl, cpe, or swid identifier.`,
{
mitigation:
"Ensure component identifiers are added during generation (cdxgen emits purls automatically).",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
name: c.name,
})),
evidence: { missingIdentifierCount: missing.length },
},
);
}
return pass(
`All ${comps.length} component(s) have a machine-readable identifier.`,
);
},
},
{
id: "SCVS-1.8",
name: "Component type is known",
description: "The component type is known throughout inventory.",
standard: "SCVS",
standardRefs: ["SCVS-1.8"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = getAllComponents(bomJson);
const missing = comps.filter((c) => !c?.type);
if (missing.length) {
return fail(`${missing.length} component(s) are missing type.`, {
mitigation: "Set 'type' on each component (library, framework, …).",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
name: c?.name,
})),
});
}
return pass(`All ${comps.length} component(s) have a type.`);
},
},
scvsManual(
"1.9",
"Component function is known",
"The component function is known throughout inventory.",
{ l1: false, l2: false, l3: true },
),
{
id: "SCVS-1.10",
name: "Point of origin is known",
description: "Point of origin is known for all components.",
standard: "SCVS",
standardRefs: ["SCVS-1.10"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter(
(c) => !c.purl && !c.supplier?.name && !c.publisher,
);
if (missing.length) {
return fail(
`${missing.length} component(s) lack a point of origin (purl, supplier, or publisher).`,
{
mitigation:
"Populate purl, supplier, or publisher for every component.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
name: c?.name,
})),
},
);
}
return pass(
`All ${comps.length} component(s) have a point of origin reference.`,
);
},
},
{
id: "SCVS-2.1",
name: "Structured machine-readable SBOM",
description:
"A structured, machine readable software bill of materials (SBOM) format is present.",
standard: "SCVS",
standardRefs: ["SCVS-2.1"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
if (isCycloneDxBom(bomJson)) {
return pass(`SBOM format is CycloneDX ${bomJson.specVersion}.`);
}
return fail("bomFormat or specVersion missing from the SBOM root.", {
mitigation: "Use cdxgen or another CycloneDX-compliant generator.",
});
},
},
scvsManual(
"2.2",
"SBOM creation is automated and reproducible",
"SBOM creation is automated and reproducible.",
{ l1: false, l2: true, l3: true },
),
{
id: "SCVS-2.3",
name: "SBOM has unique identifier",
description: "Each SBOM has a unique identifier.",
standard: "SCVS",
standardRefs: ["SCVS-2.3"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
if (
bomJson?.serialNumber &&
/^urn:uuid:[0-9a-f-]{36}$/i.test(bomJson.serialNumber)
) {
return pass(`Unique serialNumber present (${bomJson.serialNumber}).`);
}
return fail("BOM serialNumber is missing or not a urn:uuid value.", {
mitigation:
"Ensure the SBOM includes a serialNumber of the form urn:uuid:<uuid>.",
});
},
},
{
id: "SCVS-2.4",
name: "SBOM is signed",
description:
"SBOM has been signed by publisher, supplier, or certifying authority.",
standard: "SCVS",
standardRefs: ["SCVS-2.4"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L2", "L3"],
automatable: true,
evaluate(bomJson) {
if (bomJson?.signature) {
const algo =
bomJson.signature.algorithm ||
bomJson.signature.signers?.[0]?.algorithm ||
bomJson.signature.chain?.[0]?.algorithm;
return pass(`BOM is signed${algo ? ` (${algo})` : ""}.`);
}
return fail("BOM is not signed.", {
mitigation:
"Sign the SBOM with `cdx-sign -i bom.json -k private.pem` before distribution.",
});
},
},
scvsManual(
"2.5",
"SBOM signature verification exists",
"SBOM signature verification exists.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"2.6",
"SBOM signature verification is performed",
"SBOM signature verification is performed.",
{ l1: false, l2: false, l3: true },
),
{
id: "SCVS-2.7",
name: "SBOM is timestamped",
description: "SBOM is timestamped.",
standard: "SCVS",
standardRefs: ["SCVS-2.7"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const ts = bomJson?.metadata?.timestamp;
if (typeof ts !== "string" || ts.length === 0) {
return fail("metadata.timestamp is missing.");
}
if (Number.isNaN(Date.parse(ts))) {
return fail(`metadata.timestamp is not a valid ISO-8601 date: ${ts}`);
}
return pass(`metadata.timestamp present (${ts}).`);
},
},
scvsManual(
"2.8",
"SBOM is analyzed for risk",
"SBOM is analyzed for risk.",
{
l1: true,
l2: true,
l3: true,
},
{
evidence: buildCdxAuditAssistEvidence("2.8"),
mitigation: buildCdxAuditAssistMitigation(
"Use predictive audit evidence to show how the SBOM is being reviewed for workflow, provenance, and publishing risk.",
),
},
),
{
id: "SCVS-2.9",
name: "Complete and accurate inventory",
description:
"SBOM contains a complete and accurate inventory of all components the SBOM describes.",
standard: "SCVS",
standardRefs: ["SCVS-2.9"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (comps.length === 0) {
return fail("BOM has no inventory components.");
}
if (
!Array.isArray(bomJson?.dependencies) ||
bomJson.dependencies.length === 0
) {
return fail(
"BOM has components but no dependency graph — inventory is not demonstrably complete.",
{
mitigation:
"Ensure cdxgen is run with access to the full dependency tree so that the 'dependencies' section is populated.",
},
);
}
return pass(
`${comps.length} component(s) with a ${bomJson.dependencies.length}-node dependency graph.`,
);
},
},
scvsManual(
"2.10",
"SBOM contains accurate test inventory",
"SBOM contains an accurate inventory of all test components for the asset or application it describes.",
{ l1: false, l2: true, l3: true },
),
{
id: "SCVS-2.11",
name: "SBOM contains asset metadata",
description:
"SBOM contains metadata about the asset or software the SBOM describes.",
standard: "SCVS",
standardRefs: ["SCVS-2.11"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L2", "L3"],
automatable: true,
evaluate(bomJson) {
const meta = bomJson?.metadata?.component;
if (!meta?.name) {
return fail("metadata.component is missing or has no name.", {
mitigation:
"Pass --project-name (and --project-version) when running cdxgen.",
});
}
if (!meta.version) {
return fail("metadata.component.version is missing.", {
mitigation: "Pass --project-version when running cdxgen.",
});
}
return pass(
`Root asset metadata present (${meta.name}@${meta.version}).`,
);
},
},
{
id: "SCVS-2.12",
name: "Identifiers derived from native ecosystems",
description:
"Component identifiers are derived from their native ecosystems (if applicable).",
standard: "SCVS",
standardRefs: ["SCVS-2.12"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const invalid = [];
for (const c of comps) {
if (!c.purl) continue;
try {
PackageURL.fromString(c.purl);
} catch (_err) {
invalid.push(c);
}
}
if (invalid.length) {
return fail(`${invalid.length} component(s) have an invalid purl.`, {
mitigation:
"Use PackageURL.fromString to validate purls; regenerate with the latest cdxgen.",
locations: invalid.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
});
}
return pass(
`All ${comps.filter((c) => c.purl).length} component purls are parseable.`,
);
},
},
{
id: "SCVS-2.13",
name: "Point of origin identified with PURL",
description:
"Component point of origin is identified in a consistent, machine readable format (e.g. PURL).",
standard: "SCVS",
standardRefs: ["SCVS-2.13"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.purl);
if (missing.length) {
return fail(`${missing.length} component(s) are missing a purl.`, {
mitigation:
"Purls are the preferred SBOM identifier — regenerate with cdxgen.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
name: c?.name,
})),
});
}
return pass(`All ${comps.length} inventory component(s) have a purl.`);
},
},
{
id: "SCVS-2.14",
name: "Components have license information",
description:
"Components defined in SBOM have accurate license information.",
standard: "SCVS",
standardRefs: ["SCVS-2.14"],
category: "compliance-scvs",
severity: "high",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.licenses?.length);
if (missing.length) {
return fail(
`${missing.length} component(s) are missing license information.`,
{
mitigation:
"Run cdxgen with FETCH_LICENSE=true, or provide a license policy.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
name: c?.name,
})),
evidence: { missingLicenseCount: missing.length },
},
);
}
return pass(
`All ${comps.length} inventory component(s) declare license information.`,
);
},
},
{
id: "SCVS-2.15",
name: "Valid SPDX identifiers or expressions",
description:
"Components defined in SBOM have valid SPDX license IDs or expressions (if applicable).",
standard: "SCVS",
standardRefs: ["SCVS-2.15"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const invalid = [];
const evidence = new Set();
for (const c of comps) {
const lic = componentLicenseId(c);
if (lic && !looksLikeSpdx(lic)) {
invalid.push({ comp: c, lic });
evidence.add(lic);
}
}
if (invalid.length) {
return fail(
`${invalid.length} component(s) use a non-SPDX license expression.`,
{
mitigation:
"Normalize license identifiers to SPDX license IDs or expressions.",
locations: invalid.slice(0, 25).map(({ comp, _ }) => ({
bomRef: comp["bom-ref"],
purl: comp.purl,
})),
evidence: Array.from(evidence),
},
);
}
return pass(
"SPDX license identifiers are valid for all components with license data.",
);
},
},
{
id: "SCVS-2.16",
name: "Components have copyright statements",
description: "Components defined in SBOM have valid copyright statements.",
standard: "SCVS",
standardRefs: ["SCVS-2.16"],
category: "compliance-scvs",
severity: "low",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.copyright);
if (missing.length) {
return fail(
`${missing.length} component(s) are missing copyright statements.`,
{
mitigation:
"Populate copyright metadata for each component (cdxgen does this when license data is available).",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
},
);
}
return pass(`All ${comps.length} component(s) declare a copyright.`);
},
},
scvsManual(
"2.17",
"Modified components have pedigree information",
"Components defined in SBOM which have been modified from the original have detailed provenance and pedigree information.",
{ l1: false, l2: false, l3: true },
),
{
id: "SCVS-2.18",
name: "Components have file hashes",
description:
"Components defined in SBOM have one or more file hashes (SHA-256, SHA-512, etc).",
standard: "SCVS",
standardRefs: ["SCVS-2.18"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.hashes?.length);
if (missing.length) {
return fail(`${missing.length} component(s) are missing file hashes.`, {
mitigation:
"Run cdxgen with FETCH_LICENSE/lockfile context so tarball hashes are captured.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
evidence: { missingHashesCount: missing.length },
});
}
return pass(`All ${comps.length} component(s) have one or more hashes.`);
},
},
scvsManual(
"3.1",
"Application uses a repeatable build",
"Application uses a repeatable build.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"3.2",
"Build documentation exists",
"Documentation exists on how the application is built and instructions for repeating the build.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"3.3",
"Application uses CI build pipeline",
"Application uses a continuous integration build pipeline.",
{ l1: true, l2: true, l3: true },
{
evidence: buildCdxAuditAssistEvidence("3.3"),
mitigation: buildCdxAuditAssistMitigation(
"Review the resolved repository workflows to confirm a CI build pipeline is present and corresponds to the released package.",
),
},
),
scvsManual(
"3.4",
"Build outputs immutable",
"Application build pipeline prohibits alteration of build outside of the job performing the build.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"3.5",
"Package-manager settings immutable",
"Application build pipeline prohibits alteration of package management settings.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"3.6",
"No arbitrary code execution",
"Application build pipeline prohibits the execution of arbitrary code outside of the context of a jobs build script.",
{ l1: false, l2: true, l3: true },
{
evidence: buildCdxAuditAssistEvidence("3.6"),
mitigation: buildCdxAuditAssistMitigation(
"Review workflow and publishing findings for risky scripts, hidden Unicode, and legacy token-based release steps that may indicate unsafe build execution paths.",
),
},
),
scvsManual(
"3.7",
"Builds only from version control",
"Application build pipeline may only perform builds of source code maintained in version control systems.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"3.8",
"DNS/network settings immutable",
"Application build pipeline prohibits alteration of DNS and network settings during build.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.9",
"Certificate trust stores immutable",
"Application build pipeline prohibits alteration of certificate trust stores.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.10",
"Pipeline authentication enforced",
"Application build pipeline enforces authentication and defaults to deny.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"3.11",
"Pipeline authorization enforced",
"Application build pipeline enforces authorization and defaults to deny.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"3.12",
"Separation of concerns for system settings",
"Application build pipeline requires separation of concerns for the modification of system settings.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.13",
"Verifiable audit log of system changes",
"Application build pipeline maintains a verifiable audit log of all system changes.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.14",
"Verifiable audit log of build changes",
"Application build pipeline maintains a verifiable audit log of all build job changes.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.15",
"Build pipeline maintenance cadence",
"Application build pipeline has required maintenance cadence where the entire stack is updated, patched, and re-certified for use.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"3.16",
"Compiler/tooling tamper monitoring",
"Compilers, version control clients, development utilities, and software development kits are analyzed and monitored for tampering, trojans, or malicious code.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"3.17",
"Build-time manipulations known",
"All build-time manipulations to source or binaries are known and well defined.",
{ l1: true, l2: true, l3: true },
),
{
id: "SCVS-3.18",
name: "Checksums of components documented",
description:
"Checksums of all first-party and third-party components are documented for every build.",
standard: "SCVS",
standardRefs: ["SCVS-3.18"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const missing = comps.filter((c) => !c.hashes?.length);
if (missing.length) {
return fail(
`${missing.length} component(s) have no checksum recorded.`,
{
mitigation:
"Populate component 'hashes' during generation (cdxgen captures these when lockfile or tarball data is available).",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
},
);
}
return pass(`All ${comps.length} component(s) have recorded checksums.`);
},
},
scvsManual(
"3.19",
"Checksums delivered out-of-band",
"Checksums of all components are accessible and delivered out-of-band whenever those components are packaged or distributed.",
{ l1: false, l2: true, l3: true },
),
{
id: "SCVS-3.20",
name: "Unused components identified",
description:
"Unused direct and transitive components have been identified.",
standard: "SCVS",
standardRefs: ["SCVS-3.20"],
category: "compliance-scvs",
severity: "low",
scvsLevels: ["L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (!comps.length) {
return fail("No components to analyse.");
}
const refs = collectReferencedRefs(bomJson);
const orphans = comps.filter(
(c) => c["bom-ref"] && !refs.has(c["bom-ref"]),
);
if (orphans.length) {
return fail(
`${orphans.length} component(s) are not referenced by the dependency graph.`,
{
mitigation:
"Remove unused components or ensure the dependency graph is complete.",
locations: orphans.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
},
);
}
return pass(
`All ${comps.length} inventory component(s) are reachable from the dependency graph.`,
);
},
},
scvsManual(
"3.21",
"Unused components removed",
"Unused direct and transitive components have been removed from the application.",
{ l1: false, l2: false, l3: true },
),
// V4 - Package Management (mostly process controls)
scvsManual(
"4.1",
"Binary components from a package repository",
"Binary components are retrieved from a package repository.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.2",
"Package repository congruent with origin",
"Package repository contents are congruent to an authoritative point of origin for open source components.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.3",
"Package repository strong authentication",
"Package repository requires strong authentication.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"4.4",
"Package repository MFA for publishing",
"Package repository supports multi-factor authentication component publishing.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"4.5",
"Packages published with MFA",
"Package repository components have been published with multi-factor authentication.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"4.6",
"Security incident reporting supported",
"Package repository supports security incident reporting.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"4.7",
"Security incident reporting automated",
"Package repository automates security incident reporting.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"4.8",
"Publisher security notifications",
"Package repository notifies publishers of security issues.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"4.9",
"User security notifications",
"Package repository notifies users of security issues.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"4.10",
"Version-to-source correlation",
"Package repository provides a verifiable way of correlating component versions to specific source codes in version control.",
{ l1: false, l2: true, l3: true },
{
evidence: buildCdxAuditAssistEvidence("4.10"),
mitigation: buildCdxAuditAssistMitigation(
"Review the resolved repository URL, version mapping, and source correlation details for the component version under review.",
),
},
),
scvsManual(
"4.11",
"Package repository auditability",
"Package repository provides auditability when components are updated.",
{ l1: true, l2: true, l3: true },
{
evidence: buildCdxAuditAssistEvidence("4.11"),
mitigation: buildCdxAuditAssistMitigation(
"Review provenance, publisher drift, publish timing, and trusted-publishing signals to assess whether package updates are auditable.",
),
},
),
scvsManual(
"4.12",
"Code signing for production publishing",
"Package repository requires code signing to publish packages to production repositories.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"4.13",
"Package manager verifies remote integrity",
"Package manager verifies the integrity of packages when they are retrieved from remote repository.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.14",
"Package manager verifies local integrity",
"Package manager verifies the integrity of packages when they are retrieved from file system.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.15",
"TLS required for package repository",
"Package repository enforces use of TLS for all interactions.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.16",
"Package manager validates TLS chain",
"Package manager validates TLS certificate chain to repository and fails securely when validation fails.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.17",
"Static analysis prior to publishing",
"Package repository requires and/or performs static code analysis prior to publishing a component and makes results available for others to consume.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"4.18",
"Package manager does not execute code",
"Package manager does not execute component code.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"4.19",
"Install documented in machine-readable form",
"Package manager documents package installation in machine-readable form.",
{ l1: true, l2: true, l3: true },
),
// V5 - Component Analysis
scvsManual(
"5.1",
"Component analyzable by linters/SAST",
"Component can be analyzed with linters and/or static analysis tools.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"5.2",
"Components analyzed prior to use",
"Component is analyzed using linters and/or static analysis tools prior to use.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"5.3",
"Analysis repeated on upgrade",
"Linting and/or static analysis is performed with every upgrade of a component.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"5.4",
"Automated vulnerability identification",
"An automated process of identifying all publicly disclosed vulnerabilities in third-party and open source components is used.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"5.5",
"Automated dataflow exploitability",
"An automated process of identifying confirmed dataflow exploitability is used.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"5.6",
"Non-specified component versions identified",
"An automated process of identifying non-specified component versions is used.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"5.7",
"Out-of-date components identified",
"An automated process of identifying out-of-date components is used.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"5.8",
"End-of-life components identified",
"An automated process of identifying end-of-life / end-of-support components is used.",
{ l1: false, l2: false, l3: true },
),
scvsManual(
"5.9",
"Automated component type identification",
"An automated process of identifying component type is used.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"5.10",
"Automated component function identification",
"An automated process of identifying component function is used.",
{ l1: false, l2: false, l3: true },
),
{
id: "SCVS-5.11",
name: "Automated component quantity identification",
description:
"An automated process of identifying component quantity is used.",
standard: "SCVS",
standardRefs: ["SCVS-5.11"],
category: "compliance-scvs",
severity: "low",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (comps.length === 0) {
return fail("BOM has no inventory components to quantify.");
}
return pass(`BOM declares ${comps.length} inventory component(s).`);
},
},
{
id: "SCVS-5.12",
name: "Automated component license identification",
description:
"An automated process of identifying component license is used.",
standard: "SCVS",
standardRefs: ["SCVS-5.12"],
category: "compliance-scvs",
severity: "medium",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (comps.length === 0) {
return fail("BOM has no components to analyse for licenses.");
}
const withLic = comps.filter((c) => c.licenses?.length);
const ratio = withLic.length / comps.length;
if (ratio < 0.5) {
return fail(
`Only ${withLic.length}/${comps.length} (${Math.round(ratio * 100)}%) components have license data.`,
{
mitigation: "Run cdxgen with FETCH_LICENSE=true.",
},
);
}
return pass(
`${withLic.length}/${comps.length} (${Math.round(ratio * 100)}%) components have license data.`,
);
},
},
// V6 - Pedigree and Provenance
scvsManual(
"6.1",
"Point of origin verifiable",
"Point of origin is verifiable for source code and binary components.",
{ l1: false, l2: true, l3: true },
{
evidence: buildCdxAuditAssistEvidence("6.1"),
mitigation: buildCdxAuditAssistMitigation(
"Review the resolved repository and registry provenance signals to confirm the package point of origin is verifiable.",
),
},
),
scvsManual(
"6.2",
"Chain of custody auditable",
"Chain of custody if auditable for source code and binary components.",
{ l1: false, l2: false, l3: true },
{
evidence: buildCdxAuditAssistEvidence("6.2"),
mitigation: buildCdxAuditAssistMitigation(
"Review provenance, publisher identity changes, trusted-publishing status, and source-repository correlation to assess auditable chain-of-custody evidence.",
),
},
),
{
id: "SCVS-6.3",
name: "Provenance of modified components",
description: "Provenance of modified components is known and documented.",
standard: "SCVS",
standardRefs: ["SCVS-6.3"],
category: "compliance-scvs",
severity: "low",
scvsLevels: ["L1", "L2", "L3"],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
const modified = comps.filter((c) => c.pedigree);
if (modified.length === 0) {
// No modified components is a pass — nothing to document.
return pass("No modified components declared — nothing to document.");
}
const missing = modified.filter(
(c) =>
!c.pedigree?.ancestors?.length &&
!c.pedigree?.descendants?.length &&
!c.pedigree?.commits?.length &&
!c.pedigree?.patches?.length,
);
if (missing.length) {
return fail(
`${missing.length} component(s) have an empty pedigree object.`,
{
mitigation:
"Populate pedigree.ancestors / commits / patches for modified components.",
locations: missing.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
},
);
}
return pass(
`${modified.length} modified component(s) have pedigree information.`,
);
},
},
scvsManual(
"6.4",
"Pedigree of modifications documented",
"Pedigree of component modification is documented and verifiable.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"6.5",
"Modified components uniquely identified",
"Modified components are uniquely identified and distinct from origin component.",
{ l1: false, l2: true, l3: true },
),
scvsManual(
"6.6",
"Modified components analyzed equally",
"Modified components are analyzed with the same level of precision as unmodified components.",
{ l1: true, l2: true, l3: true },
),
scvsManual(
"6.7",
"Risk of modified variants analyzed",
"Risk unique to modified components can be analyzed and associated specifically to modified variant.",
{ l1: true, l2: true, l3: true },
),
];
// ---------------------------------------------------------------------------
// EU Cyber Resilience Act (CRA) — SBOM expectations
// Based on Annex I section 2 "vulnerability handling requirements" and the
// ENISA SBOM guidance for CRA compliance.
// ---------------------------------------------------------------------------
/** @type {Array<object>} */
const CRA_RULES = [
{
id: "CRA-MIN-001",
name: "SBOM supplier identified",
description:
"CRA Article 13(24): The manufacturer must be identifiable from the SBOM so users can reach them for vulnerability reports.",
standard: "CRA",
standardRefs: ["CRA-ANNEX-I-2-1"],
category: "compliance-cra",
severity: "high",
scvsLevels: [],
automatable: true,
evaluate(bomJson) {
const supplier =
bomJson?.metadata?.supplier?.name ||
bomJson?.metadata?.manufacture?.name ||
bomJson?.metadata?.manufacturer?.name;
if (!supplier) {
return fail(
"metadata.supplier / metadata.manufacturer is missing the manufacturer name.",
{
mitigation:
"Populate metadata.supplier.name so downstream users know who to contact.",
},
);
}
return pass(`Manufacturer declared: ${supplier}.`);
},
},
{
id: "CRA-MIN-002",
name: "Manufacturer vulnerability contact",
description:
"CRA Annex I(2)(2): Manufacturers must provide a contact address for vulnerability reports.",
standard: "CRA",
standardRefs: ["CRA-ANNEX-I-2-2"],
category: "compliance-cra",
severity: "high",
scvsLevels: [],
automatable: true,
evaluate(bomJson) {
const supplier =
bomJson?.metadata?.supplier ||
bomJson?.metadata?.manufacture ||
bomJson?.metadata?.manufacturer;
const contacts = supplier?.contact || [];
const hasContact = Array.isArray(contacts)
? contacts.some((c) => c?.email || c?.phone)
: Boolean(contacts?.email || contacts?.phone);
const hasUrl = Array.isArray(supplier?.url)
? supplier.url.some((u) => u)
: typeof supplier?.url === "string" && supplier.url.length > 0;
if (!hasContact && !hasUrl) {
return fail(
"No vulnerability contact (email / phone / URL) recorded on the manufacturer.",
{
mitigation:
"Populate metadata.supplier.contact[].email (or .url) with your PSIRT address.",
},
);
}
return pass("Manufacturer contact information present.");
},
},
{
id: "CRA-MIN-003",
name: "Unique SBOM identifier",
description:
"CRA requires that each SBOM is uniquely addressable for vulnerability correlation.",
standard: "CRA",
standardRefs: ["CRA-ANNEX-I-2-3"],
category: "compliance-cra",
severity: "high",
scvsLevels: [],
automatable: true,
evaluate(bomJson) {
if (
bomJson?.serialNumber &&
/^urn:uuid:[0-9a-f-]{36}$/i.test(bomJson.serialNumber)
) {
return pass(`serialNumber present (${bomJson.serialNumber}).`);
}
return fail("serialNumber missing or not a urn:uuid.");
},
},
{
id: "CRA-MIN-004",
name: "Inventory has dependency relationships",
description:
"CRA requires a complete inventory including dependency relationships so vulnerabilities can be traced.",
standard: "CRA",
standardRefs: ["CRA-ANNEX-I-2-4"],
category: "compliance-cra",
severity: "high",
scvsLevels: [],
automatable: true,
evaluate(bomJson) {
const comps = inventoryComponents(bomJson);
if (comps.length === 0) {
return fail("BOM has no inventory components.");
}
const deps = Array.isArray(bomJson?.dependencies)
? bomJson.dependencies
: [];
if (deps.length === 0) {
return fail(
"BOM has components but no dependency relationships — root-cause analysis is not possible.",
{
mitigation:
"Ensure cdxgen runs with access to lockfiles so the dependency graph is captured.",
},
);
}
const covered = new Set();
for (const dep of deps) {
if (dep?.ref) covered.add(dep.ref);
for (const child of dep?.dependsOn || []) covered.add(child);
}
const uncovered = comps.filter(
(c) => !c["bom-ref"] || !covered.has(c["bom-ref"]),
);
if (uncovered.length > comps.length * 0.25) {
return fail(
`${uncovered.length}/${comps.length} component(s) are not represented in the dependency graph.`,
{
mitigation:
"Regenerate with deeper analysis so all components appear in the dependency graph.",
locations: uncovered.slice(0, 25).map((c) => ({
bomRef: c["bom-ref"],
purl: c.purl,
})),
},