Files
sencho/backend/src/utils/policy-risk.ts
T
Anso ca496c89dc fix: name matched risk inputs in policy block messages (#1471)
The auto-update, bulk-label, scheduler, and blueprint deploy block
messages hardcoded "image(s) exceed <max_severity>", which is wrong
under the risk-first policy model: a block can be driven by a
known-exploited (KEV) or fixable Critical/High input while the severity
threshold was never the trigger. In those cases the message named a
severity ceiling the policy did not enforce.

Route all four message paths through a shared summarizeBlockReasons
helper (the same reason text the deploy-gate 409 response and the block
dialog already use), so every surface names the inputs that actually
matched. Falls back to a generic phrase when no reason was recorded.
2026-06-26 15:34:24 -04:00

136 lines
5.4 KiB
TypeScript

/**
* 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<string>();
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';
}