From 7c120816456f01b527031fdcb24734dd4197c276 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 26 Jun 2026 21:11:54 -0400 Subject: [PATCH] fix: honor suppressions in the informational scan policy evaluation (#1481) The pre-deploy gate filters suppressed findings when the honor-suppressions setting is enabled, but the informational evaluation that drives the scan banner and the scheduled-scan alert always scored the raw findings. A finding that was fully suppressed therefore showed a policy violation on the banner even though the gate would let the deploy through, so the two surfaces disagreed. evaluateScanAgainstPolicies now mirrors the gate: when honor-suppressions is on it loads the detail rows and drops suppressed findings before scoring (for the severity input too, matching how the gate forces the detail path in that mode), so the banner and alert agree with the gate. With the setting off, both continue to score the raw findings. The truncation fail-closed rule stays gate-only; the gate remains authoritative for blocking. --- .../database-scan-policy-risk.test.ts | 74 ++++++++++++++++++- backend/src/services/DatabaseService.ts | 21 ++++-- 2 files changed, 88 insertions(+), 7 deletions(-) diff --git a/backend/src/__tests__/database-scan-policy-risk.test.ts b/backend/src/__tests__/database-scan-policy-risk.test.ts index c1a78363..d21a1429 100644 --- a/backend/src/__tests__/database-scan-policy-risk.test.ts +++ b/backend/src/__tests__/database-scan-policy-risk.test.ts @@ -4,7 +4,7 @@ * insert migrates to severity-only defaults, and evaluateScanAgainstPolicies * keys the banner on the same KEV/fixable/severity inputs as enforcement. */ -import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import type { VulnerabilityScan, VulnerabilityDetail } from '../services/DatabaseService'; @@ -146,6 +146,78 @@ describe('evaluateScanAgainstPolicies risk inputs', () => { }); }); +describe('evaluateScanAgainstPolicies honors suppressions in lockstep with the gate', () => { + function suppress(cveId: string): void { + DatabaseService.getInstance().createCveSuppression({ + cve_id: cveId, pkg_name: null, image_pattern: null, reason: 'accepted', + created_by: 'admin', created_at: Date.now(), expires_at: null, replicated_from_control: 0, status: 'accepted', + }); + } + + afterEach(() => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '0'); + (db as unknown as { db: import('better-sqlite3').Database }).db + .prepare('DELETE FROM cve_suppressions').run(); + }); + + it('does not flag a fully suppressed KEV finding when honor-suppressions is enabled (the gate would pass)', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '1'); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1 }); + db.replaceKev([{ cve_id: 'CVE-2026-7000', date_added: '2026-01-01' }], Date.now()); + const scan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-7000', severity: 'CRITICAL' })]); + suppress('CVE-2026-7000'); + expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(false); + }); + + it('still flags the suppressed finding when honor-suppressions is disabled (raw banner)', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '0'); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1 }); + db.replaceKev([{ cve_id: 'CVE-2026-7001', date_added: '2026-01-01' }], Date.now()); + const scan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-7001', severity: 'CRITICAL' })]); + suppress('CVE-2026-7001'); + expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(true); + }); + + it('clears a severity-only violation when honor-suppressions removes the only finding (gate parity)', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '1'); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', max_severity: 'HIGH' }); + const scan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-7002', severity: 'CRITICAL' })]); + suppress('CVE-2026-7002'); + expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(false); + }); + + it('still flags when only some findings are suppressed, naming the surviving reason', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '1'); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1 }); + db.replaceKev([ + { cve_id: 'CVE-2026-7010', date_added: '2026-01-01' }, + { cve_id: 'CVE-2026-7011', date_added: '2026-01-01' }, + ], Date.now()); + const scan = seedScan('web', 'CRITICAL', [ + detail({ vulnerability_id: 'CVE-2026-7010', severity: 'CRITICAL' }), + detail({ vulnerability_id: 'CVE-2026-7011', severity: 'CRITICAL', pkg_name: 'libfoo' }), + ]); + suppress('CVE-2026-7010'); // one KEV dismissed, the other still live + const result = db.evaluateScanAgainstPolicies(1, scan, '')!; + expect(result.violated).toBe(true); + expect(result.reasons).toEqual(['kev']); + }); + + it('clears a fixable-only violation when honor-suppressions drops the fixable finding', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('deploy_block_honor_suppressions', '1'); + db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_fixable: 1 }); + const scan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-7020', severity: 'CRITICAL', fixed_version: '2.0' })]); + suppress('CVE-2026-7020'); + expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(false); + }); +}); + describe('parsePolicyEvaluation', () => { const base = { policyId: 1, policyName: 'p', maxSeverity: 'HIGH', violated: true }; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index e2a3c5a1..33c68034 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { CryptoService } from './CryptoService'; import { isSeverityAtLeast } from '../utils/severity'; import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk'; +import { applySuppressions } from '../utils/suppression-filter'; import type { AuditStatsInput } from './AuditAnomalyService'; import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types'; import type { BackendScheduledAction } from './scheduledActionRegistry'; @@ -5612,18 +5613,26 @@ export class DatabaseService { const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity); if (!policy) return null; const inputs = policyInputs(policy); + // The pre-deploy gate filters suppressed findings when this setting is on, + // so the informational banner honors them too; otherwise the banner could + // claim a violation the gate would not enforce. With it off, both score the + // raw findings. (The gate's truncation fail-closed rule stays gate-only; + // the banner reflects the findings it can read and the gate is + // authoritative for blocking.) + const honorSuppressions = this.getGlobalSettings()['deploy_block_honor_suppressions'] === '1'; let reasons: PolicyBlockReason[]; - if (!inputs.blockOnKev && !inputs.blockOnFixable) { + const needsDetails = inputs.blockOnKev || inputs.blockOnFixable || honorSuppressions; + if (!needsDetails) { // Severity-only banner from the stored aggregate: no per-finding read. 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; - reasons = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs).reasons; + const evalSet = honorSuppressions + ? applySuppressions(findings, scan.image_ref, this.getCveSuppressions()).filter((f) => !f.suppressed) + : findings; + const intel = inputs.blockOnKev ? this.getCveIntel(evalSet.map((f) => f.vulnerability_id)) : null; + reasons = evaluatePolicyRisk(evalSet, (cveId) => intel?.get(cveId)?.kev === true, inputs).reasons; } return { policyId: policy.id,