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,