UNPKG

@cyclonedx/cdxgen

Version:

Creates CycloneDX Software Bill of Materials (SBOM) from source or container image

242 lines (232 loc) 7.1 kB
/** * Orchestrates evaluation of internal compliance rule packs (SCVS + CRA) and * aggregates results into per-benchmark scorecards. * * This module is deliberately independent of the CycloneDX BOM audit engine * (lib/stages/postgen/auditBom.js). The two engines share similar *Finding* * output shape so reporters can consume either source uniformly. */ import { getAllComplianceRules, getCraRules, getScvsRules, } from "./complianceRules.js"; /** * Benchmark alias → rule filter. * Aliases are resolved case-insensitively on the CLI. */ const BENCHMARKS = { scvs: { id: "scvs", name: "OWASP SCVS (all levels)", standard: "SCVS", filter: (rules) => rules.filter((r) => r.standard === "SCVS"), // All SCVS rules are considered for overall score regardless of level. levelPredicate: () => true, }, "scvs-l1": { id: "scvs-l1", name: "OWASP SCVS Level 1", standard: "SCVS", filter: (rules) => rules.filter( (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L1"), ), levelPredicate: (rule) => rule.scvsLevels?.includes("L1"), }, "scvs-l2": { id: "scvs-l2", name: "OWASP SCVS Level 2", standard: "SCVS", filter: (rules) => rules.filter( (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L2"), ), levelPredicate: (rule) => rule.scvsLevels?.includes("L2"), }, "scvs-l3": { id: "scvs-l3", name: "OWASP SCVS Level 3", standard: "SCVS", filter: (rules) => rules.filter( (r) => r.standard === "SCVS" && r.scvsLevels?.includes("L3"), ), levelPredicate: (rule) => rule.scvsLevels?.includes("L3"), }, cra: { id: "cra", name: "EU Cyber Resilience Act (SBOM expectations)", standard: "CRA", filter: (rules) => rules.filter((r) => r.standard === "CRA"), levelPredicate: () => true, }, }; /** * Resolve a benchmark alias (case-insensitive). Returns null when unknown. * * @param {string} alias * @returns {object | null} */ export function resolveBenchmark(alias) { if (typeof alias !== "string") return null; return BENCHMARKS[alias.trim().toLowerCase()] || null; } /** * List all known benchmark aliases in a stable display order. * * @returns {Array<object>} */ export function listBenchmarks() { return Object.values(BENCHMARKS); } /** * Evaluate one rule against the BOM and return a Finding-shaped object. * * Rules are pure synchronous functions, but we wrap them in try/catch so one * bad rule cannot fail the entire run. * * @param {object} rule * @param {object} bomJson * @returns {object} Finding */ export function evaluateRule(rule, bomJson) { let result; try { result = rule.evaluate(bomJson); } catch (err) { result = { status: "fail", message: `Rule evaluation threw: ${err?.message || err}`, }; } const { status = "fail", message, mitigation, locations, evidence, } = result || {}; // Non-automatable rules always emit `info` severity regardless of status. const severity = rule.automatable === false ? "info" : status === "fail" ? rule.severity || "medium" : status === "manual" ? "info" : "info"; return { engine: "compliance", ruleId: rule.id, name: rule.name, description: rule.description, category: rule.category, standard: rule.standard, standardRefs: rule.standardRefs || [rule.id], scvsLevels: rule.scvsLevels || [], automatable: rule.automatable !== false, status, severity, message: message || rule.name, mitigation: mitigation || rule.mitigation, locations: Array.isArray(locations) ? locations : [], evidence: evidence && typeof evidence === "object" ? evidence : null, }; } /** * Evaluate every rule in the catalog, or a filtered subset. * * @param {object} bomJson CycloneDX BOM * @param {object} [opts] * @param {Array<string>} [opts.categories] Filter to these category values. * @param {Array<string>} [opts.benchmarks] Only run rules from these benchmark aliases. * @returns {Array<object>} Findings (one per rule) */ export function evaluateAll(bomJson, opts = {}) { let rules = getAllComplianceRules(); if (Array.isArray(opts.categories) && opts.categories.length > 0) { const wanted = new Set(opts.categories.map((c) => c.toLowerCase())); rules = rules.filter((r) => wanted.has((r.category || "").toLowerCase())); } if (Array.isArray(opts.benchmarks) && opts.benchmarks.length > 0) { const selected = new Set(); for (const alias of opts.benchmarks) { const bench = resolveBenchmark(alias); if (bench) { for (const r of bench.filter(rules)) { selected.add(r.id); } } } rules = rules.filter((r) => selected.has(r.id)); } return rules.map((r) => evaluateRule(r, bomJson)); } /** * Produce a scorecard for a single benchmark against a set of already-evaluated * findings. Scoring rules: * - pass counts as 1 / 1. * - fail counts as 0 / 1. * - manual is excluded from the percentage but counted separately. * * This mirrors how OWASP SCVS publishes results: automatable controls score a * percentage, manual controls are reported so reviewers can address them. * * @param {object} benchmark Result of resolveBenchmark * @param {Array<object>} findings Full set of findings from evaluateAll * @returns {object} */ export function scoreBenchmark(benchmark, findings) { const catalog = benchmark.filter(getAllComplianceRules()); const byId = new Map(findings.map((f) => [f.ruleId, f])); const controls = []; let pass = 0; let failed = 0; let manual = 0; for (const rule of catalog) { const f = byId.get(rule.id) || evaluateRule(rule, {}); controls.push({ id: rule.id, name: rule.name, standardRefs: rule.standardRefs, status: f.status, severity: f.severity, automatable: rule.automatable !== false, message: f.message, }); if (f.status === "pass") pass += 1; else if (f.status === "fail") failed += 1; else manual += 1; } const automatable = pass + failed; const scorePct = automatable === 0 ? 0 : Math.round((pass / automatable) * 100); return { id: benchmark.id, name: benchmark.name, standard: benchmark.standard, totalControls: catalog.length, pass, fail: failed, manual, automatable, scorePct, controls, }; } /** * Build scorecards for each requested benchmark. When no benchmarks are * specified, returns scorecards for every built-in benchmark alias. * * @param {Array<object>} findings * @param {Array<string>} [requestedAliases] * @returns {Array<object>} */ export function buildBenchmarkReports(findings, requestedAliases) { const aliases = requestedAliases?.length ? requestedAliases.map((a) => resolveBenchmark(a)).filter(Boolean) : Object.values(BENCHMARKS); return aliases.map((b) => scoreBenchmark(b, findings)); } export { getAllComplianceRules, getCraRules, getScvsRules };