From 1de49f8b1aeb93c4a518871ef13354267bcc7acb Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 26 Jun 2026 16:43:26 -0400 Subject: [PATCH] fix: name matched risk inputs in policy scan banner and alerts (#1473) The pre-deploy gate names the inputs that matched a scan policy (a known-exploited CVE, a fixable Critical/High, or a severity threshold), but the informational post-scan surfaces still framed every violation as a severity ceiling. The scan detail banner read "blocks severities at or above X, highest severity is Y" and the scheduled-scan alert read " exceeds ", which is wrong for a KEV- or fixable-only policy that never gated on severity. Persist the matched reasons on the policy evaluation, carry them on the scheduled-scan violation, and render them on the banner so every policy surface names the input that actually matched. Evaluations persisted before this change carry no reasons: the parser defaults the field to an empty array and the banner falls back to a plain violation notice. --- .../database-scan-policy-risk.test.ts | 38 ++++++++++++++++++- .../src/__tests__/scheduler-policy.test.ts | 14 +++++-- backend/src/services/DatabaseService.ts | 25 +++++++++--- backend/src/services/SchedulerService.ts | 2 +- backend/src/services/TrivyService.ts | 37 +++++++++--------- .../src/components/VulnerabilityScanSheet.tsx | 18 ++++----- frontend/src/lib/policyReasons.test.ts | 22 +++++++++++ frontend/src/lib/policyReasons.ts | 27 +++++++++++++ frontend/src/types/security.ts | 6 +++ 9 files changed, 150 insertions(+), 39 deletions(-) create mode 100644 frontend/src/lib/policyReasons.test.ts create mode 100644 frontend/src/lib/policyReasons.ts diff --git a/backend/src/__tests__/database-scan-policy-risk.test.ts b/backend/src/__tests__/database-scan-policy-risk.test.ts index 1c581eb6..c1a78363 100644 --- a/backend/src/__tests__/database-scan-policy-risk.test.ts +++ b/backend/src/__tests__/database-scan-policy-risk.test.ts @@ -10,10 +10,11 @@ import type { VulnerabilityScan, VulnerabilityDetail } from '../services/Databas let tmpDir: string; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let parsePolicyEvaluation: typeof import('../services/DatabaseService').parsePolicyEvaluation; beforeAll(async () => { tmpDir = await setupTestDb(); - ({ DatabaseService } = await import('../services/DatabaseService')); + ({ DatabaseService, parsePolicyEvaluation } = await import('../services/DatabaseService')); }); afterAll(() => { @@ -126,4 +127,39 @@ describe('evaluateScanAgainstPolicies risk inputs', () => { const cleanScan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-9200', severity: 'CRITICAL' })]); expect(db.evaluateScanAgainstPolicies(1, cleanScan, '')!.violated).toBe(false); }); + + it('records the matched inputs as reasons so the banner can name them', () => { + const db = DatabaseService.getInstance(); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 }); + db.replaceKev([{ cve_id: 'CVE-2026-9100', date_added: '2026-01-01' }], Date.now()); + const scan = seedScan('web', 'CRITICAL', [ + detail({ vulnerability_id: 'CVE-2026-9100', severity: 'CRITICAL', fixed_version: '2.0' }), + ]); + expect(db.evaluateScanAgainstPolicies(1, scan, '')!.reasons).toEqual(['kev', 'fixable']); + }); + + it('records ["severity"] for a severity-only violation and [] when within limits', () => { + const db = DatabaseService.getInstance(); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', max_severity: 'HIGH' }); + expect(db.evaluateScanAgainstPolicies(1, seedScan('web', 'CRITICAL', []), '')!.reasons).toEqual(['severity']); + expect(db.evaluateScanAgainstPolicies(1, seedScan('web', 'LOW', []), '')!.reasons).toEqual([]); + }); +}); + +describe('parsePolicyEvaluation', () => { + const base = { policyId: 1, policyName: 'p', maxSeverity: 'HIGH', violated: true }; + + it('defaults reasons to [] for rows persisted before reason tracking', () => { + expect(parsePolicyEvaluation(JSON.stringify(base))).toMatchObject({ violated: true, reasons: [] }); + }); + + it('drops reason values outside the known set', () => { + const tampered = JSON.stringify({ ...base, reasons: ['kev', 'banana', 'fixable'] }); + expect(parsePolicyEvaluation(tampered)!.reasons).toEqual(['kev', 'fixable']); + }); + + it('returns null for null or malformed input', () => { + expect(parsePolicyEvaluation(null)).toBeNull(); + expect(parsePolicyEvaluation('not json')).toBeNull(); + }); }); diff --git a/backend/src/__tests__/scheduler-policy.test.ts b/backend/src/__tests__/scheduler-policy.test.ts index fc6e55d1..0edca7c4 100644 --- a/backend/src/__tests__/scheduler-policy.test.ts +++ b/backend/src/__tests__/scheduler-policy.test.ts @@ -118,6 +118,7 @@ function summaryWith(violations: Array<{ maxSeverity: string; severity: string; scanId: number; + reasons: Array<'severity' | 'kev' | 'fixable'>; }>) { return { scanned: violations.length, @@ -142,8 +143,9 @@ describe('SchedulerService - scheduled scan policy alerts', () => { policyId: 1, policyName: 'prod-high-gate', maxSeverity: 'HIGH', - severity: 'CRITICAL', + severity: 'MEDIUM', scanId: 42, + reasons: ['kev'], }, { imageRef: 'redis:6', @@ -152,6 +154,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => { maxSeverity: 'HIGH', severity: 'HIGH', scanId: 43, + reasons: ['severity'], }, ])); @@ -160,11 +163,15 @@ describe('SchedulerService - scheduled scan policy alerts', () => { const warningCalls = mockDispatchAlert.mock.calls.filter((c) => c[0] === 'warning'); expect(warningCalls).toHaveLength(2); + // The first violation matched KEV, so the alert names the known-exploited + // input and never the severity ceiling it did not enforce. expect(warningCalls[0][2]).toContain('prod-high-gate'); expect(warningCalls[0][2]).toContain('nginx:1.14'); - expect(warningCalls[0][2]).toContain('CRITICAL'); - expect(warningCalls[0][2]).toContain('HIGH'); + expect(warningCalls[0][2]).toContain('known-exploited CVE (KEV)'); + expect(warningCalls[0][2]).not.toContain('HIGH'); + // The second matched the severity threshold. expect(warningCalls[1][2]).toContain('redis:6'); + expect(warningCalls[1][2]).toContain('severity threshold'); }); it('does not dispatch any policy alert when no violations occur', async () => { @@ -194,6 +201,7 @@ describe('SchedulerService - scheduled scan policy alerts', () => { maxSeverity: 'CRITICAL', severity: 'CRITICAL', scanId: 44, + reasons: ['severity'], }, ])); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5cf7cf49..f86db5f0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -3,7 +3,7 @@ import path from 'path'; import fs from 'fs'; import { CryptoService } from './CryptoService'; import { isSeverityAtLeast } from '../utils/severity'; -import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk'; +import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk'; import type { AuditStatsInput } from './AuditAnomalyService'; import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types'; import type { BackendScheduledAction } from './scheduledActionRegistry'; @@ -601,6 +601,10 @@ export interface PolicyEvaluation { policyId: number; policyName: string; maxSeverity: VulnSeverity; + // Inputs that actually matched (severity / kev / fixable). Empty when not + // violated. Lets the scan banner name the reason instead of always citing a + // severity threshold the policy may not have gated on. + reasons: PolicyBlockReason[]; violated: boolean; evaluatedAt: number; } @@ -640,6 +644,8 @@ export interface VulnerabilityScan { // a clean vuln read can never be a secret-only or config scan in disguise. export const VULN_BEARING_SCANNER_SETS = ['vuln', 'vuln,secret'] as const; +const VALID_BLOCK_REASONS: ReadonlySet = new Set(['severity', 'kev', 'fixable']); + export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null { if (!raw) return null; try { @@ -647,6 +653,12 @@ export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEva if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') { return null; } + // Rows persisted before reason tracking lack `reasons`; default to empty + // and keep only the known inputs so a stray stored value cannot reach the + // banner or alert text. + parsed.reasons = Array.isArray(parsed.reasons) + ? parsed.reasons.filter((r) => VALID_BLOCK_REASONS.has(r)) + : []; return parsed; } catch { return null; @@ -5527,24 +5539,25 @@ export class DatabaseService { const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity); if (!policy) return null; const inputs = policyInputs(policy); - let violated: boolean; + let reasons: PolicyBlockReason[]; if (!inputs.blockOnKev && !inputs.blockOnFixable) { // Severity-only banner from the stored aggregate: no per-finding read. - violated = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity); + const severityHit = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity); + reasons = severityHit ? ['severity'] : []; } else { // Best-effort banner: scores the raw findings without honoring // suppressions or the truncation fail-closed rule. The pre-deploy gate // is authoritative; this only drives the informational scan banner. const findings = this.getAllVulnerabilityDetails(scan.id); const intel = inputs.blockOnKev ? this.getCveIntel(findings.map((f) => f.vulnerability_id)) : null; - const outcome = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs); - violated = outcome.reasons.length > 0; + reasons = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs).reasons; } return { policyId: policy.id, policyName: policy.name, maxSeverity: policy.max_severity, - violated, + reasons, + violated: reasons.length > 0, evaluatedAt: Date.now(), }; } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 2003806e..832521a9 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -954,7 +954,7 @@ export class SchedulerService { NotificationService.getInstance().dispatchAlert( 'warning', 'scan_finding', - `Policy "${v.policyName}" violated by ${v.imageRef}: ${v.severity} exceeds ${v.maxSeverity}`, + `Policy "${v.policyName}" violated by ${v.imageRef}: matched ${summarizeBlockReasons([v])}`, { actor: 'system:scheduler' }, ); } diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 13e4a5bc..c36128dc 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -9,6 +9,7 @@ import { VulnSeverity, VulnScanTrigger, VulnerabilityScan, + parsePolicyEvaluation, } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; import { RegistryService } from './RegistryService'; @@ -19,6 +20,7 @@ import { FleetSyncService } from './FleetSyncService'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { SEVERITY_ORDER } from '../utils/severity'; +import type { PolicyBlockReason } from '../utils/policy-risk'; const execFileAsync = promisify(execFile); @@ -138,6 +140,9 @@ export interface ScanAllNodeImagesViolation { severity: VulnSeverity; policyName: string; maxSeverity: VulnSeverity; + // Inputs that matched (severity / kev / fixable), so the scheduled-scan + // alert names the reason rather than always citing a severity threshold. + reasons: PolicyBlockReason[]; } export interface ScanAllNodeImagesResult { @@ -1189,24 +1194,20 @@ class TrivyService { }; const collectViolation = (row: VulnerabilityScan | null): void => { - if (!row || !row.policy_evaluation) return; - try { - const parsed = JSON.parse(row.policy_evaluation) as { - violated: boolean; - policyName: string; - maxSeverity: VulnSeverity; - }; - if (parsed.violated) { - violations.push({ - imageRef: row.image_ref, - scanId: row.id, - severity: row.highest_severity ?? 'UNKNOWN', - policyName: parsed.policyName, - maxSeverity: parsed.maxSeverity, - }); - } - } catch { - // Ignore malformed evaluation JSON; presence is informational. + if (!row) return; + // Shared parser tolerates malformed JSON (returns null) and normalizes + // reasons, so the scheduled-scan alert names the same validated inputs + // as the banner. + const parsed = parsePolicyEvaluation(row.policy_evaluation); + if (parsed?.violated) { + violations.push({ + imageRef: row.image_ref, + scanId: row.id, + severity: row.highest_severity ?? 'UNKNOWN', + policyName: parsed.policyName, + maxSeverity: parsed.maxSeverity, + reasons: parsed.reasons, + }); } }; diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 32417728..6880814f 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -47,6 +47,7 @@ import { cn } from '@/lib/utils'; import { cveUrl } from '@/lib/cveUrl'; import { SEVERITY_ROW_TINT } from '@/lib/severityStyles'; import { formatTimeAgo } from '@/lib/relativeTime'; +import { formatPolicyReasons } from '@/lib/policyReasons'; import type { VulnerabilityScan, VulnerabilityDetail, @@ -629,16 +630,13 @@ export function VulnerabilityScanSheet({ Policy violation
- {scan.policy_evaluation.policyName} blocks - severities at or above{' '} - - {scan.policy_evaluation.maxSeverity} - - . This scan's highest severity is{' '} - - {scan.highest_severity ?? 'UNKNOWN'} - - . + This scan violates{' '} + {scan.policy_evaluation.policyName} + {scan.policy_evaluation.reasons.length > 0 ? ( + <>: matched {formatPolicyReasons(scan.policy_evaluation.reasons, scan.policy_evaluation.maxSeverity)}. + ) : ( + <>. + )}
)} diff --git a/frontend/src/lib/policyReasons.test.ts b/frontend/src/lib/policyReasons.test.ts new file mode 100644 index 00000000..6281d8d9 --- /dev/null +++ b/frontend/src/lib/policyReasons.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { formatPolicyReasons } from './policyReasons'; + +describe('formatPolicyReasons', () => { + it('names a KEV match without citing a severity ceiling', () => { + expect(formatPolicyReasons(['kev'], 'HIGH')).toBe('a known-exploited CVE (KEV)'); + }); + + it('names the configured ceiling for a severity match', () => { + expect(formatPolicyReasons(['severity'], 'CRITICAL')).toBe('severity at or above CRITICAL'); + }); + + it('joins every matched reason in order', () => { + expect(formatPolicyReasons(['severity', 'kev', 'fixable'], 'HIGH')).toBe( + 'severity at or above HIGH, a known-exploited CVE (KEV), a fixable Critical/High', + ); + }); + + it('returns an empty string when no reason was recorded', () => { + expect(formatPolicyReasons([], 'HIGH')).toBe(''); + }); +}); diff --git a/frontend/src/lib/policyReasons.ts b/frontend/src/lib/policyReasons.ts new file mode 100644 index 00000000..68553f70 --- /dev/null +++ b/frontend/src/lib/policyReasons.ts @@ -0,0 +1,27 @@ +import type { PolicyBlockReason, VulnSeverity } from '@/types/security'; + +/** + * Human-readable description of the policy inputs that matched, for the scan + * detail banner. Echoes the deploy-gate dialog's phrasing (PolicyBlockDialog) + * so the banner and the block dialog read consistently, and names the + * configured severity ceiling so the severity input stays specific. Returns an + * empty string when no reason was recorded (evaluations persisted before + * reason tracking). + */ +export function formatPolicyReasons( + reasons: PolicyBlockReason[], + maxSeverity: VulnSeverity, +): string { + return reasons + .map((reason) => { + switch (reason) { + case 'severity': + return `severity at or above ${maxSeverity}`; + case 'kev': + return 'a known-exploited CVE (KEV)'; + case 'fixable': + return 'a fixable Critical/High'; + } + }) + .join(', '); +} diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 5f93f70d..2350e888 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -4,10 +4,16 @@ export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight'; +export type PolicyBlockReason = 'severity' | 'kev' | 'fixable'; + export interface ScanPolicyEvaluation { policyId: number; policyName: string; maxSeverity: VulnSeverity; + // Inputs that matched (severity / kev / fixable). Empty for evaluations + // persisted before reason tracking; the banner falls back to a plain + // violation notice in that case. + reasons: PolicyBlockReason[]; violated: boolean; }