/** * Pure risk-decision helper shared by the pre-deploy gate * (`PolicyEnforcement.evaluateImageRisk`) and the informational post-scan * evaluation (`DatabaseService.evaluateScanAgainstPolicies`). Keeping the match * logic in one place means the two surfaces score an identical finding set the * same way. * * It reports positive matches only. Callers own the finding set they pass in: * the gate filters suppressions and applies the incomplete-details fail-closed * rule, while the banner is best-effort (raw findings, no fail-closed), so the * two can still differ when suppressions or a truncated scan are involved. KEV * membership is supplied by the caller as a predicate. */ import type { ScanPolicy, VulnSeverity } from '../services/DatabaseService'; import { isSeverityAtLeast, severityRank } from './severity'; export type PolicyBlockReason = 'severity' | 'kev' | 'fixable'; /** The three risk inputs a policy can gate on. */ export interface PolicyRiskInputs { blockOnSeverity: boolean; blockOnKev: boolean; blockOnFixable: boolean; maxSeverity: VulnSeverity; } /** Project a stored policy row's 0/1 risk columns into the decision inputs. */ export function policyInputs(policy: ScanPolicy): PolicyRiskInputs { return { blockOnSeverity: policy.block_on_severity === 1, blockOnKev: policy.block_on_kev === 1, blockOnFixable: policy.block_on_fixable === 1, maxSeverity: policy.max_severity, }; } /** * True when a policy would block on deploy but no risk input is active, which * would persist as a silent no-op gate. Inputs are 0/1 flags already resolved by * the caller (route coercion, merged update, or replication defaults). */ export function isNoOpBlockingPolicy( blockOnDeploy: number, blockOnSeverity: number, blockOnKev: number, blockOnFixable: number, ): boolean { return blockOnDeploy === 1 && blockOnSeverity === 0 && blockOnKev === 0 && blockOnFixable === 0; } /** Minimal finding shape the risk evaluation needs. */ export interface RiskFinding { vulnerability_id: string; severity: VulnSeverity; fixed_version: string | null; } export interface PolicyRiskOutcome { /** Inputs that matched, in display order (severity, kev, fixable). */ reasons: PolicyBlockReason[]; highestSeverity: VulnSeverity; criticalCount: number; highCount: number; kevCount: number; fixableCount: number; } /** * Evaluate a set of non-suppressed findings against a policy's risk inputs. * Severity uses the highest finding severity; KEV uses the supplied membership * test; a finding is "fixable" when it is Critical/High and carries a fixed * version (mirrors the overview's `fixableCriticalHigh` semantics). */ export function evaluatePolicyRisk( findings: RiskFinding[], isKev: (cveId: string) => boolean, inputs: PolicyRiskInputs, ): PolicyRiskOutcome { let highestSeverity: VulnSeverity = 'UNKNOWN'; let criticalCount = 0; let highCount = 0; let kevCount = 0; let fixableCount = 0; for (const f of findings) { if (severityRank(f.severity) > severityRank(highestSeverity)) highestSeverity = f.severity; if (f.severity === 'CRITICAL') criticalCount++; else if (f.severity === 'HIGH') highCount++; if (isKev(f.vulnerability_id)) kevCount++; if ((f.severity === 'CRITICAL' || f.severity === 'HIGH') && f.fixed_version) fixableCount++; } const reasons: PolicyBlockReason[] = []; if (inputs.blockOnSeverity && isSeverityAtLeast(highestSeverity, inputs.maxSeverity)) reasons.push('severity'); if (inputs.blockOnKev && kevCount > 0) reasons.push('kev'); if (inputs.blockOnFixable && fixableCount > 0) reasons.push('fixable'); return { reasons, highestSeverity, criticalCount, highCount, kevCount, fixableCount }; } /** Human-readable label for a block reason; used in gate errors and audit logs. */ export function describeReason(reason: PolicyBlockReason): string { switch (reason) { case 'severity': return 'severity threshold'; case 'kev': return 'known-exploited CVE (KEV)'; case 'fixable': return 'fixable Critical/High'; } } /** * De-duplicated, human-readable summary of the inputs that actually matched * across a set of policy violations, joined with " + ". Used wherever a policy * block is reported to a user (the gate's 409 response, thrown gate errors, and * the deploy/auto-update block messages), so a KEV- or fixable-driven block * never reads as a severity-threshold block. Falls back to a generic phrase * when no reason was recorded (e.g. an image that could not be scanned). */ export function summarizeBlockReasons( violations: ReadonlyArray<{ reasons: readonly PolicyBlockReason[] }>, ): string { const labels = new Set(); for (const v of violations) { for (const r of v.reasons) labels.add(describeReason(r)); } return labels.size > 0 ? [...labels].join(' + ') : 'scan policy conditions'; } /** Compact descriptor of a policy's active inputs, for log and audit lines. */ export function describePolicyInputs(inputs: PolicyRiskInputs): string { const parts: string[] = []; if (inputs.blockOnSeverity) parts.push(`severity>=${inputs.maxSeverity}`); if (inputs.blockOnKev) parts.push('KEV'); if (inputs.blockOnFixable) parts.push('fixable Critical/High'); return parts.length ? parts.join(', ') : 'no active inputs'; }