mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
ca496c89dc
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.
136 lines
5.4 KiB
TypeScript
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';
|
|
}
|