UNPKG

@cyclonedx/cdxgen

Version:

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

560 lines (538 loc) 17.3 kB
import { thoughtLog } from "../helpers/logger.js"; import { hasRegistryProvenanceEvidenceProperties, hasTrustedPublishingProperties, } from "../helpers/provenanceUtils.js"; export const SEVERITY_ORDER = { none: -1, low: 0, medium: 1, high: 2, critical: 3, }; const BASE_FINDING_WEIGHT = { low: 4, medium: 10, high: 18, critical: 30, }; // Predictive scoring weighs dependency-source findings more heavily than generic // CI hygiene because source mutability/local-path/git-origin issues tend to map // more directly to reviewable upstream compromise exposure for package targets. const CATEGORY_WEIGHT = { "ai-agent": 6, "ci-permission": 4, "dependency-source": 10, "package-integrity": 6, }; const RULE_SPECIFIC_WEIGHT = { "CI-019": 16, "INT-009": 14, }; const PRIORITY_CORROBORATION_RULES = new Set(["CI-019", "INT-009"]); // Require multiple compromise-oriented signals before escalating to critical so // a single strong heuristic or one noisy category cannot dominate the final // predictive severity on its own. const MIN_COMPROMISE_SIGNALS_FOR_CRITICAL = 2; const MIN_COMPROMISE_SIGNALS_FOR_HIGH = 1; const CI_HYGIENE_RULES = new Set([ "CI-001", "CI-002", "CI-003", "CI-005", "CI-014", ]); const PACKAGE_HYGIENE_RULES = new Set(["INT-001"]); const SIGNAL_BUCKET_WEIGHT = { "ci-compromise": 12, "ci-hygiene": 2, "dependency-compromise": 10, "package-hygiene": 3, "package-compromise": 12, other: 4, }; /** * Emit a short thought-log explanation for the final package risk decision. * * @param {object} target audit target descriptor * @param {object} assessment final predictive risk assessment * @returns {void} */ function logRiskAssessmentDecision(target, assessment) { const reasonPreview = Array.isArray(assessment?.reasons) ? assessment.reasons.slice(0, 2) : []; const indicators = { confidence: assessment?.confidence, confidenceLabel: assessment?.confidenceLabel, distinctCategories: assessment?.distinctCategoryCount, findings: assessment?.findingsCount, purl: target?.purl, reasons: reasonPreview, score: assessment?.score, severity: assessment?.severity, strongSignals: assessment?.strongSignalCount, }; if (["medium", "high", "critical"].includes(assessment?.severity)) { thoughtLog("Predictive audit considered the package risky.", indicators); return; } thoughtLog("Predictive audit kept the package at low risk.", indicators); } /** * Clamp a number into a fixed range. * * @param {number} value input number * @param {number} min minimum value * @param {number} max maximum value * @returns {number} clamped number */ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } /** * Retrieve a custom property value from a target descriptor. * * @param {object} target audit target * @param {string} propertyName property name * @returns {string | undefined} property value */ function getTargetProperty(target, propertyName) { return target?.properties?.find((property) => property.name === propertyName) ?.value; } function getTargetListProperty(target, propertyName) { const propertyValue = getTargetProperty(target, propertyName); if (!propertyValue || typeof propertyValue !== "string") { return []; } return propertyValue .split(",") .map((entry) => entry.trim()) .filter(Boolean); } function getCargoPredictiveSignals(target) { const buildScriptCapabilities = getTargetListProperty( target, "cdx:cargo:buildScriptCapabilities", ); const nativeBuildIndicators = getTargetListProperty( target, "cdx:cargo:nativeBuildIndicators", ); return { buildDependency: getTargetProperty(target, "cdx:cargo:dependencyKind") === "build", buildScript: getTargetProperty(target, "cdx:cargo:hasBuildScript") === "true", nativeBuild: getTargetProperty(target, "cdx:cargo:hasNativeBuild") === "true", networkCapableBuildScript: buildScriptCapabilities.includes("network-access"), processCapableBuildScript: buildScriptCapabilities.includes("process-execution"), runtimeFacing: Boolean(target?.runtimeFacingCargo), workspaceDependency: getTargetProperty(target, "cdx:cargo:workspaceDependencyResolved") === "true", buildOnlyWorkspace: Boolean(target?.buildOnlyWorkspace), yanked: getTargetProperty(target, "cdx:cargo:yanked") === "true", buildScriptCapabilities, nativeBuildIndicators, }; } /** * Classify a finding into a coarse signal bucket for conservative scoring. * * @param {object} finding predictive audit finding * @returns {"ci-hygiene" | "ci-compromise" | "dependency-compromise" | "package-hygiene" | "package-compromise" | "other"} signal bucket */ function classifyFindingSignalBucket(finding) { if (finding?.category === "ai-agent") { return "package-compromise"; } if (finding?.category === "ci-permission") { return CI_HYGIENE_RULES.has(finding?.ruleId) ? "ci-hygiene" : "ci-compromise"; } if (finding?.category === "package-integrity") { return PACKAGE_HYGIENE_RULES.has(finding?.ruleId) ? "package-hygiene" : "package-compromise"; } if (finding?.category === "dependency-source") { return "dependency-compromise"; } return "other"; } /** * Convert a numeric confidence score into a human readable label. * * @param {number} confidence confidence score * @returns {string} confidence label */ export function confidenceLabel(confidence) { if (confidence >= 0.85) { return "high"; } if (confidence >= 0.6) { return "medium"; } return "low"; } /** * Check if a severity meets the given threshold. * * @param {string} severity severity to compare * @param {string} threshold threshold severity * @returns {boolean} true if severity is at or above threshold */ export function severityMeetsThreshold(severity, threshold) { const resolvedSeverity = SEVERITY_ORDER[severity] ?? SEVERITY_ORDER.none; const resolvedThreshold = SEVERITY_ORDER[threshold] ?? SEVERITY_ORDER.low; return resolvedSeverity >= resolvedThreshold; } /** * Conservatively score predictive supply-chain risk for a single target. * * High and critical require corroboration across categories and strong findings, * which keeps false positives low. * * @param {object[]} findings post-generation audit findings * @param {object} target target metadata * @param {object} context additional scan context * @returns {object} conservative risk assessment */ export function scoreTargetRisk(findings, target, context = {}) { if (!Array.isArray(findings) || findings.length === 0) { const explicitReason = context?.skipReason || context?.scanErrorReason || context?.errorMessage || (context?.scanError ? `Predictive audit for '${target.purl}' could not complete successfully.` : `${target.type} package '${target.purl}' did not trigger any predictive audit rules.`); const assessment = { categoryCounts: {}, confidence: 0.35, confidenceLabel: "low", distinctCategoryCount: 0, findingsCount: 0, formulationSignalCount: 0, reasons: [explicitReason], score: 0, severity: "none", strongSignalCount: 0, }; logRiskAssessmentDecision(target, assessment); return assessment; } const categoryCounts = {}; const attackTactics = new Set(); const attackTechniques = new Set(); const distinctCategories = new Set(); const matchedPriorityRules = new Set(); let score = 0; let strongSignalCount = 0; let ciCompromiseSignalCount = 0; let ciHygieneSignalCount = 0; let formulationSignalCount = 0; let compromiseSignalCount = 0; let packageIntegrityCompromiseSignalCount = 0; let packageIntegrityHygieneSignalCount = 0; let priorityCorroborationCount = 0; let cargoBuildSignalCount = 0; for (const finding of findings) { const findingSeverity = finding?.severity || "low"; const findingCategory = finding?.category || "unknown"; const signalBucket = classifyFindingSignalBucket(finding); let findingScore = BASE_FINDING_WEIGHT[findingSeverity] ?? 4; findingScore += CATEGORY_WEIGHT[findingCategory] ?? 4; findingScore += SIGNAL_BUCKET_WEIGHT[signalBucket] ?? SIGNAL_BUCKET_WEIGHT.other; findingScore += RULE_SPECIFIC_WEIGHT[finding?.ruleId] ?? 0; if (target?.type === "cargo") { const cargoSignals = getCargoPredictiveSignals(target); if ( ["dependency-compromise", "package-compromise"].includes( signalBucket, ) && cargoSignals.nativeBuild ) { findingScore += 6; cargoBuildSignalCount += 1; } if ( signalBucket === "package-compromise" && (cargoSignals.processCapableBuildScript || cargoSignals.networkCapableBuildScript) ) { findingScore += 4; cargoBuildSignalCount += 1; } if ( signalBucket === "dependency-compromise" && (cargoSignals.buildDependency || cargoSignals.workspaceDependency) ) { findingScore += 3; cargoBuildSignalCount += 1; } if ( ["dependency-compromise", "package-compromise"].includes( signalBucket, ) && cargoSignals.runtimeFacing ) { findingScore += 2; } if ( cargoSignals.buildOnlyWorkspace && !cargoSignals.runtimeFacing && signalBucket !== "ci-compromise" ) { findingScore -= 2; } if (finding?.ruleId === "PROV-015" && cargoSignals.yanked) { findingScore += cargoSignals.nativeBuild ? 8 : 4; } } if (signalBucket === "ci-hygiene") { ciHygieneSignalCount += 1; } else if (signalBucket === "ci-compromise") { ciCompromiseSignalCount += 1; } else if (signalBucket === "package-compromise") { packageIntegrityCompromiseSignalCount += 1; } else if (signalBucket === "package-hygiene") { packageIntegrityHygieneSignalCount += 1; } if ( finding?.ruleId?.startsWith("CI-") || finding?.location?.file?.includes(".github/workflows") ) { formulationSignalCount += 1; findingScore += signalBucket === "ci-hygiene" ? 1 : 4; } if (["high", "critical"].includes(findingSeverity)) { strongSignalCount += 1; if ( signalBucket === "ci-compromise" || signalBucket === "dependency-compromise" || signalBucket === "package-compromise" ) { compromiseSignalCount += 1; } } if (PRIORITY_CORROBORATION_RULES.has(finding?.ruleId)) { priorityCorroborationCount += 1; matchedPriorityRules.add(finding.ruleId); } (finding?.attackTactics || finding?.attack?.tactics || []).forEach((id) => { if (id) { attackTactics.add(id); } }); (finding?.attackTechniques || finding?.attack?.techniques || []).forEach( (id) => { if (id) { attackTechniques.add(id); } }, ); categoryCounts[findingCategory] = (categoryCounts[findingCategory] || 0) + 1; distinctCategories.add(findingCategory); score += findingScore; } score += Math.max(0, distinctCategories.size - 1) * 8; score += Math.max(0, strongSignalCount - 1) * 10; score += Math.max(0, formulationSignalCount - 1) * 2; if (target?.type === "cargo" && cargoBuildSignalCount > 0) { score += Math.min(cargoBuildSignalCount, 3) * 3; } if ( target?.type === "cargo" && target?.buildOnlyWorkspace && !target?.runtimeFacingCargo ) { score = Math.max(0, score - 3); } const hasTrustedPublishing = hasTrustedPublishingProperties( target?.properties, ); const hasProvenanceEvidence = hasRegistryProvenanceEvidenceProperties( target?.properties, ); const hasVerifiedPublisher = getTargetProperty(target, "cdx:pypi:uploaderVerified") === "true"; let provenanceDiscount = 0; if (hasProvenanceEvidence) { provenanceDiscount += 4; } if (hasTrustedPublishing) { provenanceDiscount += 6; } if (hasVerifiedPublisher) { provenanceDiscount += 2; } score -= Math.min(provenanceDiscount, 10); if (score < 0) { score = 0; } const effectiveStrongSignalCount = strongSignalCount + priorityCorroborationCount; let confidence = 0.45; if (context?.resolution?.repoUrl) { confidence += 0.15; } if (target?.version) { confidence += 0.1; } if (context?.versionMatched) { confidence += 0.1; } if (context?.bomJson?.formulation?.length) { confidence += 0.15; } if (context?.sourceDirectoryConfidence === "high") { confidence += 0.05; } if (context?.sourceDirectoryConfidence === "low") { confidence -= 0.1; } if (context?.scanError) { confidence -= 0.35; } if (!context?.resolution?.repoUrl) { confidence -= 0.2; } confidence = clamp(confidence, 0.05, 0.95); let severity = "low"; if (score >= 84) { severity = "critical"; } else if (score >= 52) { severity = "high"; } else if (score >= 24) { severity = "medium"; } if ( severity === "critical" && (effectiveStrongSignalCount < 3 || compromiseSignalCount < MIN_COMPROMISE_SIGNALS_FOR_CRITICAL || distinctCategories.size < 2 || confidence < 0.85) ) { severity = "high"; } if ( severity === "high" && (effectiveStrongSignalCount < 2 || compromiseSignalCount < MIN_COMPROMISE_SIGNALS_FOR_HIGH || distinctCategories.size < 2 || confidence < 0.65) ) { severity = "medium"; } if (context?.scanError && severityMeetsThreshold(severity, "high")) { severity = "medium"; } const reasons = []; if (ciHygieneSignalCount > 0) { reasons.push( `${ciHygieneSignalCount} CI hygiene signal(s) were observed in GitHub Actions or privileged workflow configuration.`, ); } if (ciCompromiseSignalCount > 0) { reasons.push( `${ciCompromiseSignalCount} compromise-oriented CI signal(s) increased the predictive risk score.`, ); } if (target?.type === "cargo" && cargoBuildSignalCount > 0) { const cargoSignals = getCargoPredictiveSignals(target); const cargoReasonParts = []; if (cargoSignals.nativeBuild) { cargoReasonParts.push("native build tooling"); } if (cargoSignals.processCapableBuildScript) { cargoReasonParts.push("process-capable build scripts"); } if (cargoSignals.networkCapableBuildScript) { cargoReasonParts.push("network-capable build scripts"); } if (cargoSignals.workspaceDependency) { cargoReasonParts.push("workspace-resolved member dependencies"); } if (cargoSignals.runtimeFacing) { cargoReasonParts.push("runtime-facing crate exposure"); } if (cargoSignals.buildOnlyWorkspace && !cargoSignals.runtimeFacing) { cargoReasonParts.push("build-only workspace helper role"); } if (cargoReasonParts.length) { reasons.push( `Cargo build-surface signals (${cargoReasonParts.join(", ")}) increased the predictive review priority.`, ); } } if (packageIntegrityCompromiseSignalCount > 0) { reasons.push( `${packageIntegrityCompromiseSignalCount} package-integrity compromise signal(s) corroborated the package risk posture.`, ); } if (packageIntegrityHygieneSignalCount > 0) { reasons.push( `${packageIntegrityHygieneSignalCount} package-integrity hygiene signal(s) were recorded for review.`, ); } if (distinctCategories.size > 1) { reasons.push( `${distinctCategories.size} distinct rule categories corroborated the package risk posture.`, ); } if (strongSignalCount > 0) { reasons.push( `${strongSignalCount} strong finding(s) were observed across the generated source SBOM.`, ); } if (priorityCorroborationCount > 0) { reasons.push( `${priorityCorroborationCount} high-confidence compound rule(s) received additional predictive weight (${Array.from(matchedPriorityRules).join(", ")}).`, ); } if (attackTactics.size > 0 || attackTechniques.size > 0) { reasons.push( `${attackTactics.size} ATT&CK tactic(s) and ${attackTechniques.size} ATT&CK technique(s) were implicated by the audit findings.`, ); } if (hasTrustedPublishing || hasProvenanceEvidence || hasVerifiedPublisher) { reasons.push( "Registry provenance or trusted-publishing evidence reduced the final predictive score.", ); } if (reasons.length === 0) { reasons.push( `Findings remained isolated, so severity stayed conservative for '${target.purl}'.`, ); } const assessment = { categoryCounts, attackTacticCount: attackTactics.size, attackTechniqueCount: attackTechniques.size, confidence, confidenceLabel: confidenceLabel(confidence), ciCompromiseSignalCount, ciHygieneSignalCount, distinctCategoryCount: distinctCategories.size, findingsCount: findings.length, formulationSignalCount, packageIntegrityCompromiseSignalCount, packageIntegrityHygieneSignalCount, priorityCorroborationCount, reasons, score, severity, strongSignalCount, }; logRiskAssessmentDecision(target, assessment); return assessment; }