From 085267b466d76e28466ae099a3e09b46f320b424 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 1 Jun 2026 13:39:46 -0400 Subject: [PATCH] feat(security): make CVE suppressions optionally honored by deploy-block policies (#1269) * feat(security): make CVE suppressions optionally honored by deploy-block policies Block-on-deploy policies evaluate the raw scan result, so a CVE an admin has accepted in CVE Suppressions still blocks the deploy. Add an opt-in, per-instance toggle ("Honor suppressions in deploy blocks", Settings -> Security) that, when on, re-derives each image's severity from the suppression-filtered findings before comparing to the policy threshold. A deploy that proceeds only because suppressions dropped it below the gate is recorded in the audit log. Default off, so the strict raw-scan behavior is unchanged unless an operator enables it. The setting governs the instance that runs the deploy and is not fleet-replicated. The gate fails safe: a suppression-read error or an empty detail set falls back to raw scan severity rather than dropping it. Also surface a previously swallowed error in the CVE suppressions and misconfig acknowledgement settings panels so a failed list load shows a toast instead of an empty list. * fix(security): gate on raw severity when preflight detail rows are truncated The suppression-aware deploy gate re-derived image severity from the stored vulnerability_details rows, assuming any non-empty set was complete. A cached pre-deploy scan keeps the full aggregate counts but copies only a bounded slice of detail rows, so recomputing from that slice could drop an unsuppressed blocking CVE below the threshold and let a deploy through. Guard the recompute: when the loaded detail rows do not match the scan's total finding count, gate on the raw scan severity (never drops severity). Suppression awareness still applies for scans whose details are stored in full, which is the common case. --- .../src/__tests__/policy-enforcement.test.ts | 234 ++++++++++++++++++ ...loy-block-honor-suppressions-route.test.ts | 107 ++++++++ backend/src/routes/security.ts | 25 ++ backend/src/services/DatabaseService.ts | 12 + backend/src/services/PolicyEnforcement.ts | 151 ++++++++++- docs/features/cve-suppressions.mdx | 13 +- docs/features/vulnerability-scanning.mdx | 11 +- .../components/settings/MisconfigAckPanel.tsx | 7 +- .../components/settings/SecuritySection.tsx | 37 ++- .../components/settings/SuppressionsPanel.tsx | 7 +- .../__tests__/MisconfigAckPanel.test.tsx | 75 ++++++ .../__tests__/SuppressionsPanel.test.tsx | 76 ++++++ frontend/src/hooks/useTrivyStatus.ts | 2 + frontend/src/types/security.ts | 1 + 14 files changed, 736 insertions(+), 22 deletions(-) create mode 100644 backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts create mode 100644 frontend/src/components/settings/__tests__/MisconfigAckPanel.test.tsx create mode 100644 frontend/src/components/settings/__tests__/SuppressionsPanel.test.tsx diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index 7cc57d0b..a72c6e6e 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -21,6 +21,9 @@ interface ComposeStub { interface DbStub { getMatchingPolicy: ReturnType; insertAuditLog: ReturnType; + getGlobalSettings: ReturnType; + getAllVulnerabilityDetails: ReturnType; + getCveSuppressions: ReturnType; } interface NotificationStub { dispatchAlert: ReturnType; @@ -36,6 +39,9 @@ const composeStub: ComposeStub = { const dbStub: DbStub = { getMatchingPolicy: vi.fn(), insertAuditLog: vi.fn(), + getGlobalSettings: vi.fn(), + getAllVulnerabilityDetails: vi.fn(), + getCveSuppressions: vi.fn(), }; const notificationStub: NotificationStub = { dispatchAlert: vi.fn(), @@ -117,6 +123,14 @@ describe('enforcePolicyPreDeploy', () => { composeStub.listStackImages.mockReset(); dbStub.getMatchingPolicy.mockReset(); dbStub.insertAuditLog.mockReset(); + dbStub.getGlobalSettings.mockReset(); + dbStub.getAllVulnerabilityDetails.mockReset(); + dbStub.getCveSuppressions.mockReset(); + // Default: suppression-aware blocking off, so behavior matches the raw-scan + // path unless a test opts in. + dbStub.getGlobalSettings.mockReturnValue({}); + dbStub.getAllVulnerabilityDetails.mockReturnValue([]); + dbStub.getCveSuppressions.mockReturnValue([]); notificationStub.dispatchAlert.mockReset(); _resetTrivyMissingNotificationStateForTests(); }); @@ -392,3 +406,223 @@ describe('enforcePolicyPreDeploy', () => { expect(composeStub.listStackImages).not.toHaveBeenCalled(); }); }); + +interface FindingStub { + vulnerability_id: string; + pkg_name: string; + severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; +} + +function mkFinding(overrides: Partial = {}): FindingStub { + return { vulnerability_id: 'CVE-2026-0001', pkg_name: 'openssl', severity: 'CRITICAL', ...overrides }; +} + +function mkSuppression(overrides: Record = {}) { + return { + id: 1, + cve_id: 'CVE-2026-0001', + pkg_name: null, + image_pattern: null, + reason: 'accepted after review', + created_by: 'admin', + created_at: Date.now(), + expires_at: null, + replicated_from_control: 0, + ...overrides, + }; +} + +describe('enforcePolicyForImageRefs with suppression-aware blocking', () => { + beforeEach(() => { + trivyStub.isTrivyAvailable.mockReset().mockReturnValue(true); + trivyStub.scanImagePreflight.mockReset(); + composeStub.listStackImages.mockReset(); + dbStub.getMatchingPolicy.mockReset().mockReturnValue(mkPolicy()); + dbStub.insertAuditLog.mockReset(); + dbStub.getGlobalSettings.mockReset().mockReturnValue({ deploy_block_honor_suppressions: '1' }); + dbStub.getAllVulnerabilityDetails.mockReset().mockReturnValue([]); + dbStub.getCveSuppressions.mockReset().mockReturnValue([]); + notificationStub.dispatchAlert.mockReset(); + _resetTrivyMissingNotificationStateForTests(); + }); + + it('allows the deploy and audits when a suppression covers the sole blocking CVE', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 7, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'admin', ip: '10.0.0.2' }); + + expect(result.ok).toBe(true); + expect(result.bypassed).toBe(false); + expect(result.violations).toEqual([]); + expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1); + const entry = dbStub.insertAuditLog.mock.calls[0][0]; + expect(entry.summary).toContain('policy.suppression_pass'); + expect(entry.summary).toContain('CVE-2026-0001'); + expect(entry.summary).toContain('nginx:1.14'); + expect(entry.username).toBe('admin'); + }); + + it('still blocks when only some of the blocking findings are suppressed, with recomputed counts', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 8, highest_severity: 'CRITICAL', critical_count: 1, high_count: 1, total_vulnerabilities: 2 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([ + mkFinding({ vulnerability_id: 'CVE-2026-0001', pkg_name: 'openssl', severity: 'CRITICAL' }), + mkFinding({ vulnerability_id: 'CVE-2026-0002', pkg_name: 'zlib', severity: 'HIGH' }), + ]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0]).toMatchObject({ imageRef: 'nginx:1.14', severity: 'HIGH', criticalCount: 0, highCount: 1, scanId: 8 }); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('does not audit when the raw scan was already below the threshold', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ max_severity: 'HIGH' })); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 9, highest_severity: 'LOW', total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0003', severity: 'LOW' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0003' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('still blocks when the matching suppression has expired', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 10, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001', expires_at: Date.now() - 1000 })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations[0]).toMatchObject({ severity: 'CRITICAL', scanId: 10 }); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('honors an image-pattern-scoped suppression that matches the deployed image', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 11, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001', image_pattern: '*nginx*' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1); + }); + + it('aggregates a suppression-driven pass across multiple images and de-dupes CVEs in the audit', async () => { + trivyStub.scanImagePreflight + .mockResolvedValueOnce(mkScan({ id: 20, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })) + .mockResolvedValueOnce(mkScan({ id: 21, highest_severity: 'CRITICAL', critical_count: 2, total_vulnerabilities: 2 })); + dbStub.getAllVulnerabilityDetails + .mockReturnValueOnce([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]) + .mockReturnValueOnce([ + mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' }), + mkFinding({ vulnerability_id: 'CVE-2026-0009', pkg_name: 'curl', severity: 'CRITICAL' }), + ]); + dbStub.getCveSuppressions.mockReturnValue([ + mkSuppression({ id: 1, cve_id: 'CVE-2026-0001' }), + mkSuppression({ id: 2, cve_id: 'CVE-2026-0009' }), + ]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14', 'redis:7'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1); + const summary = dbStub.insertAuditLog.mock.calls[0][0].summary as string; + expect(summary).toContain('nginx:1.14'); + expect(summary).toContain('redis:7'); + // CVE-2026-0001 appears on both images but must be listed once. + expect(summary.match(/CVE-2026-0001/g)).toHaveLength(1); + expect(summary).toContain('CVE-2026-0009'); + }); + + it('does not audit a suppression pass when another image still violates the policy', async () => { + trivyStub.scanImagePreflight + .mockResolvedValueOnce(mkScan({ id: 22, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })) + .mockResolvedValueOnce(mkScan({ id: 23, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails + .mockReturnValueOnce([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]) + .mockReturnValueOnce([mkFinding({ vulnerability_id: 'CVE-2026-0099', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14', 'redis:7'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0]).toMatchObject({ imageRef: 'redis:7', severity: 'CRITICAL' }); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('short-circuits before reading findings when the policy does not block on deploy', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_deploy: 0 })); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(true); + expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled(); + expect(dbStub.getAllVulnerabilityDetails).not.toHaveBeenCalled(); + }); + + it('still blocks when a suppression is scoped to a non-matching image pattern', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 24, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001', image_pattern: 'registry.internal/*' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations[0]).toMatchObject({ severity: 'CRITICAL', scanId: 24 }); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('gates on raw severity when stored detail rows are incomplete (cache-truncated scan)', async () => { + // The scan aggregate reports 1500 findings but only one detail row is + // present (a cache hit copies a bounded slice). The lone loaded finding is + // suppressed, but the recompute must not trust the truncated set: a + // blocking CVE could live in the rows that were not copied, so gate on raw. + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 30, highest_severity: 'CRITICAL', critical_count: 5, total_vulnerabilities: 1500 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations[0]).toMatchObject({ severity: 'CRITICAL', criticalCount: 5, scanId: 30 }); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); + + it('still allows the deploy when the suppression-pass audit write fails', async () => { + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 31, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-0001', severity: 'CRITICAL' })]); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + dbStub.insertAuditLog.mockImplementation(() => { throw new Error('audit buffer full'); }); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1); + }); + + it('ignores suppressions entirely when the toggle is off (identical to raw blocking)', async () => { + dbStub.getGlobalSettings.mockReturnValue({ deploy_block_honor_suppressions: '0' }); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 12, highest_severity: 'CRITICAL', critical_count: 1 })); + dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-0001' })]); + + const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations[0]).toMatchObject({ severity: 'CRITICAL', scanId: 12 }); + expect(dbStub.getAllVulnerabilityDetails).not.toHaveBeenCalled(); + expect(dbStub.insertAuditLog).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts new file mode 100644 index 00000000..d18e6d2c --- /dev/null +++ b/backend/src/__tests__/security-deploy-block-honor-suppressions-route.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the role + tier gate on PUT /api/security/deploy-block-honor-suppressions. + * + * The route flips the global `deploy_block_honor_suppressions` setting that the + * pre-deploy policy gate reads to decide whether a suppressed CVE still counts + * toward a block-on-deploy policy. It must be reachable only by an admin on a + * paid (Skipper or Admiral) tier, matching the trivy-auto-update toggle. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass4', 1); + DatabaseService.getInstance().addUser({ username: 'suppr-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'suppr-viewer', password: 'viewerpass4' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('PUT /api/security/deploy-block-honor-suppressions', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .send({ enabled: true }); + expect(res.status).toBe(401); + }); + + it('rejects authenticated viewer with 403', async () => { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .set('Cookie', viewerCookie) + .send({ enabled: true }); + expect(res.status).toBe(403); + }); + + it('rejects Community tier with 403', async () => { + const { LicenseService } = await import('../services/LicenseService'); + const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + try { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(403); + } finally { + spy.mockReturnValue('paid'); + } + }); + + it('accepts paid admin and persists the setting (enable)', async () => { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.honorSuppressionsOnDeploy).toBe(true); + expect(DatabaseService.getInstance().getGlobalSettings().deploy_block_honor_suppressions).toBe('1'); + }); + + it('accepts paid admin and persists the setting (disable)', async () => { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .set('Cookie', adminCookie) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body.honorSuppressionsOnDeploy).toBe(false); + expect(DatabaseService.getInstance().getGlobalSettings().deploy_block_honor_suppressions).toBe('0'); + }); + + it('rejects a non-boolean enabled with 400', async () => { + const res = await request(app) + .put('/api/security/deploy-block-honor-suppressions') + .set('Cookie', adminCookie) + .send({ enabled: '1' }); + expect(res.status).toBe(400); + }); + + it('exposes the current value via GET /trivy-status', async () => { + DatabaseService.getInstance().updateGlobalSetting('deploy_block_honor_suppressions', '1'); + const res = await request(app) + .get('/api/security/trivy-status') + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.honorSuppressionsOnDeploy).toBe(true); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index f0e1c33d..e00d2e1f 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -124,6 +124,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons version: svc.getVersion(), source: svc.getSource(), autoUpdate: settings.trivy_auto_update === '1', + honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1', busy: installer.isBusy(), }); }); @@ -216,6 +217,30 @@ securityRouter.put('/trivy-auto-update', authMiddleware, (req: Request, res: Res } }); +// When enabled, the pre-deploy block policy re-derives image severity from +// suppression-filtered findings, so an accepted CVE no longer blocks a deploy. +// Per-instance setting (not fleet-replicated): the gate runs on the node that +// deploys, against that node's own replicated suppression copy. Default off. +securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + // Require an explicit boolean so a stringy `"1"` cannot silently disable the + // gate (this toggle weakens a deploy block, so intent must be unambiguous). + if (typeof req.body?.enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + const enabled = req.body.enabled; + try { + DatabaseService.getInstance().updateGlobalSetting('deploy_block_honor_suppressions', enabled ? '1' : '0'); + res.json({ honorSuppressionsOnDeploy: enabled }); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to update setting'); + console.error('[Security] Deploy-block honor-suppressions toggle failed:', msg); + res.status(500).json({ error: msg }); + } +}); + securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; const svc = TrivyService.getInstance(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 411042bc..d5d7b603 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1248,6 +1248,7 @@ export class DatabaseService { stmt.run('scan_history_per_image_limit', '50'); stmt.run('trivy_auto_update', '0'); stmt.run('trivy_last_notified_version', ''); + stmt.run('deploy_block_honor_suppressions', '0'); stmt.run('mesh_auto_recreate', '0'); // Seed the default local node if none exists @@ -3810,6 +3811,17 @@ export class DatabaseService { return { items, total }; } + /** + * All findings for a scan, unpaginated. Used by the pre-deploy policy gate + * to re-derive severity from suppression-filtered findings, where every row + * must be considered rather than a single display page. + */ + public getAllVulnerabilityDetails(scanId: number): VulnerabilityDetail[] { + return this.db + .prepare('SELECT * FROM vulnerability_details WHERE scan_id = ?') + .all(scanId) as VulnerabilityDetail[]; + } + public insertSecretFindings( scanId: number, findings: Array>, diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 04213f65..1d53253c 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -10,12 +10,13 @@ */ import { ComposeService } from './ComposeService'; import { DatabaseService } from './DatabaseService'; -import type { ScanPolicy, VulnSeverity } from './DatabaseService'; +import type { ScanPolicy, VulnSeverity, VulnerabilityScan } from './DatabaseService'; import { FleetSyncService } from './FleetSyncService'; import { NotificationService } from './NotificationService'; import { sanitizeForLog } from '../utils/safeLog'; import TrivyService from './TrivyService'; -import { isSeverityAtLeast } from '../utils/severity'; +import { isSeverityAtLeast, severityRank } from '../utils/severity'; +import { applySuppressions } from '../utils/suppression-filter'; import { validateImageRef } from '../utils/image-ref'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; @@ -75,6 +76,121 @@ export function _resetTrivyMissingNotificationStateForTests(): void { trivyMissingNotifiedAt.clear(); } +type PreflightScan = Pick; + +interface ImageSeverityEvaluation { + /** Highest non-suppressed severity; UNKNOWN means no severity remains. */ + severity: VulnSeverity; + criticalCount: number; + highCount: number; + /** CVE IDs suppressed for this image; only populated when honoring suppressions. */ + suppressedCves: string[]; +} + +interface SuppressionPass { + imageRef: string; + cves: string[]; +} + +/** + * Resolve an image's effective severity for a policy decision. With + * honorSuppressions off this returns the stored scan's raw severity and counts + * (the historical behavior). With it on, the scan's findings are filtered + * through the active CVE suppressions for that image and severity + counts are + * re-derived from what remains, so an accepted CVE no longer drives a block. + */ +function evaluateImageSeverity( + scan: PreflightScan, + imageRef: string, + honorSuppressions: boolean, +): ImageSeverityEvaluation { + const raw: ImageSeverityEvaluation = { + severity: scan.highest_severity ?? 'UNKNOWN', + criticalCount: scan.critical_count, + highCount: scan.high_count, + suppressedCves: [], + }; + if (!honorSuppressions) return raw; + + const db = DatabaseService.getInstance(); + let findings; + let suppressions; + try { + findings = db.getAllVulnerabilityDetails(scan.id); + suppressions = db.getCveSuppressions(); + } catch (err) { + // A suppression-read failure must never drop severity. Fall back to the + // raw scan, which still gates: an accepted CVE stays blocking rather + // than slipping a deploy through on a transient DB error. + console.error('[Policy] Suppression re-derivation failed for %s; gating on raw scan severity:', sanitizeForLog(imageRef), sanitizeForLog(getErrorMessage(err, 'db read failed'))); + return raw; + } + // The stored detail rows must reproduce the scan's full finding set before a + // recompute can be trusted. A cache-hit preflight scan keeps the complete + // aggregate counts but copies only the first N detail rows, so recomputing + // from a truncated set could drop an unsuppressed blocking CVE below the + // threshold. When the counts disagree (including an empty detail table for a + // non-empty scan), gate on the raw scan severity, which never drops severity. + if (findings.length !== scan.total_vulnerabilities) { + if (scan.total_vulnerabilities > 0) { + console.warn( + '[Policy] Scan %d detail rows (%d) do not match its total (%d); gating on raw scan severity', + scan.id, findings.length, scan.total_vulnerabilities, + ); + } + return raw; + } + + const enriched = applySuppressions(findings, imageRef, suppressions); + let severity: VulnSeverity = 'UNKNOWN'; + let criticalCount = 0; + let highCount = 0; + const suppressedCves = new Set(); + for (const f of enriched) { + if (f.suppressed) { + suppressedCves.add(f.vulnerability_id); + continue; + } + if (severityRank(f.severity) > severityRank(severity)) severity = f.severity; + if (f.severity === 'CRITICAL') criticalCount++; + else if (f.severity === 'HIGH') highCount++; + } + return { severity, criticalCount, highCount, suppressedCves: [...suppressedCves] }; +} + +/** + * A deploy that would have been blocked on raw severity but proceeded because + * suppressions dropped every image below the threshold is a security-relevant + * event: record it so the suppression-driven pass is traceable in the audit log. + */ +function recordSuppressionPassAudit( + stackName: string, + nodeId: number, + policy: ScanPolicy, + passes: SuppressionPass[], + opts: PolicyEnforcementOptions, +): void { + const cves = [...new Set(passes.flatMap((p) => p.cves))]; + try { + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username: opts.actor, + method: opts.auditMethod ?? 'POST', + path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`, + status_code: 200, + node_id: nodeId, + ip_address: opts.ip ?? '', + summary: `policy.suppression_pass stack="${stackName}" policy="${policy.name}" images=[${passes.map((p) => p.imageRef).join(',')}] cves=[${cves.join(',')}]`, + }); + } catch (err) { + console.error('[Policy] Failed to record suppression-pass audit entry:', err); + } + console.warn( + '[Policy] Deploy for "%s" allowed by suppressions: %d image(s) would have met %s (policy "%s")', + sanitizeForLog(stackName), passes.length, policy.max_severity, sanitizeForLog(policy.name), + ); +} + export async function enforcePolicyPreDeploy( stackName: string, nodeId: number, @@ -141,15 +257,18 @@ export async function enforcePolicyForImageRefs( return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true }; } + const honorSuppressions = db.getGlobalSettings()['deploy_block_honor_suppressions'] === '1'; + const debug = isDebugEnabled(); if (debug) { console.log( - '[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d)', - sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length, + '[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d, honorSuppressions=%s)', + sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length, honorSuppressions, ); } const violations: PolicyViolation[] = []; + const suppressionPasses: SuppressionPass[] = []; for (const imageRef of imageRefs) { if (!validateImageRef(imageRef)) { if (failClosedInvalidRefs) { @@ -165,21 +284,28 @@ export async function enforcePolicyForImageRefs( } try { const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName); - const severity = scan.highest_severity ?? 'UNKNOWN'; + const evaluated = evaluateImageSeverity(scan, imageRef, honorSuppressions); + const rawSeverity = scan.highest_severity ?? 'UNKNOWN'; if (debug) { console.log( - '[Policy:debug] %s scanned: highest=%s vs max=%s', - sanitizeForLog(imageRef), severity, policy.max_severity, + '[Policy:debug] %s scanned: effective=%s raw=%s vs max=%s', + sanitizeForLog(imageRef), evaluated.severity, rawSeverity, policy.max_severity, ); } - if (isSeverityAtLeast(severity, policy.max_severity)) { + if (isSeverityAtLeast(evaluated.severity, policy.max_severity)) { violations.push({ imageRef, - severity, - criticalCount: scan.critical_count, - highCount: scan.high_count, + severity: evaluated.severity, + criticalCount: evaluated.criticalCount, + highCount: evaluated.highCount, scanId: scan.id, }); + } else if ( + honorSuppressions && + evaluated.suppressedCves.length > 0 && + isSeverityAtLeast(rawSeverity, policy.max_severity) + ) { + suppressionPasses.push({ imageRef, cves: evaluated.suppressedCves }); } } catch (err) { const message = getErrorMessage(err, 'pre-flight scan failed'); @@ -195,6 +321,9 @@ export async function enforcePolicyForImageRefs( } if (violations.length === 0) { + if (suppressionPasses.length > 0) { + recordSuppressionPassAudit(stackName, nodeId, policy, suppressionPasses, opts); + } return { ok: true, bypassed: false, policy, violations: [] }; } diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index 5f0fff15..9028452c 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -76,6 +76,14 @@ Suppressions are managed on the **control** Sencho instance and replicate automa The full replication, retry, and reanchor flow (including the API call to re-bind a replica to a new control) is documented in [Fleet Sync](/features/fleet-sync). +## Suppressions and deploy blocking + +By default, suppressions do not affect [block-on-deploy policies](/features/vulnerability-scanning#honoring-suppressions-in-deploy-blocks). A policy evaluates the raw scan result, so a CVE you have suppressed still blocks a deploy that violates the threshold. Suppressions silence the noise; the gate stays strict. + +To have an accepted CVE stop counting toward the gate, an admin can enable **Honor suppressions in deploy blocks** in **Settings → Security**. With it on, a block-on-deploy policy re-derives each image's severity from the findings that remain after suppressions are applied, so a deploy whose only blocking findings are all suppressed proceeds without a manual bypass. Sencho records each such suppression-driven pass in the audit log. + +The toggle is off by default and is set per Sencho instance, because the gate runs on whichever instance performs the deploy. + ## Removing a suppression Click the trash icon on any row in the panel. A confirmation dialog ("Remove suppression", kicker `SUPPRESSIONS · REMOVE · IRREVERSIBLE`) warns that future scan results will surface the CVE again wherever it applies. @@ -90,7 +98,10 @@ Suppressed findings carry through to the [SARIF export](/features/vulnerability- - Badge counts reflect the raw findings so they remain meaningful for alerting and policy evaluation. Suppressions apply when results are read for display (the scan drawer, the Compare sheet, the SARIF export), not when they are stored. Open the scan drawer to confirm the row is dimmed with a shield-off icon. + Badge counts reflect the raw findings so they remain meaningful for alerting. Suppressions apply when results are read for display (the scan drawer, the Compare sheet, the SARIF export), not when they are stored. Open the scan drawer to confirm the row is dimmed with a shield-off icon. + + + Block-on-deploy policies evaluate the raw scan result by default, so a suppressed CVE still counts toward the block. If you want accepted CVEs to stop counting, enable **Honor suppressions in deploy blocks** in **Settings → Security** on the instance that runs the deploy. See [Suppressions and deploy blocking](#suppressions-and-deploy-blocking) for the full behavior. Replication runs on every write. If the push failed (network blip, replica restart), the control retries every 5 minutes for 24 hours and the replica picks up the latest state on the next successful push. See [Fleet Sync](/features/fleet-sync) for how to investigate persistent push failures. diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index c1be89aa..e2d97ba8 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -34,6 +34,7 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Misconfig acknowledgements | ✓ | ✓ | ✓ | | Scheduled fleet scans (all images on a node) | | ✓ | ✓ | | Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ | +| Suppression-aware deploy blocking (optional toggle) | | ✓ | ✓ | | SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | | SARIF export (code scanning integration) | | ✓ | ✓ | | Auto-update of the managed Trivy binary | | ✓ | ✓ | @@ -194,6 +195,14 @@ Only one policy is evaluated per deploy. Use a single tight pattern rather than - **Block on deploy:** Off - **Enabled:** On +### Honoring suppressions in deploy blocks + +By default a block-on-deploy policy evaluates the **raw** scan result, so a CVE you have accepted in [CVE Suppressions](/features/cve-suppressions) still counts toward the block. That keeps the gate strict: suppressions silence alerts and dim findings in reports, but on their own they do not open the deploy path. + +To have an accepted CVE stop counting toward the gate, turn on **Honor suppressions in deploy blocks** in **Settings → Security**. With it on, the gate re-derives each image's severity from the findings that remain after suppressions are applied, so an image whose only blocking findings are all suppressed deploys without a manual bypass. When a deploy proceeds for this reason, Sencho records it in the audit log so the suppression-driven pass stays traceable. + +The toggle governs the Sencho instance that runs the deploy and is off by default. Enable it on each instance whose deploys should honor suppressions. + ### Creating a policy via the API Policy CRUD endpoints are documented in the [Security API reference](/api-reference/security). A typical create call from CI looks like this: @@ -422,7 +431,7 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, **Settings → Security** shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures. - Badge counts reflect raw findings so alerting and policy evaluation stay accurate. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces. + Badge counts always reflect raw findings so alerting stays accurate. Policy evaluation also uses raw findings by default; if you want suppressed CVEs to stop counting toward block-on-deploy policies, enable **Honor suppressions in deploy blocks** in **Settings → Security**. Open the scan drawer to confirm the row is dimmed with a shield-off icon. See [CVE Suppressions](/features/cve-suppressions) for how the filter is applied across the drawer, compare sheet, and other read surfaces. Secret detection matches against Trivy's built-in rule set, which focuses on well-known provider patterns. Plain text passwords, custom token formats, or values that do not match any published rule will not appear. Make sure you picked **Full scan (vulnerabilities + secrets)** from the shield-icon menu; a plain vulnerability scan does not walk the filesystem. diff --git a/frontend/src/components/settings/MisconfigAckPanel.tsx b/frontend/src/components/settings/MisconfigAckPanel.tsx index 2e72602a..d943ea53 100644 --- a/frontend/src/components/settings/MisconfigAckPanel.tsx +++ b/frontend/src/components/settings/MisconfigAckPanel.tsx @@ -46,10 +46,9 @@ export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) { const load = useCallback(async () => { try { const res = await apiFetch('/security/misconfig-acks', { localOnly: true }); - if (res.ok) { - const data = await res.json(); - setRows(Array.isArray(data) ? data : []); - } + if (!res.ok) throw new Error('Failed to load acknowledgements'); + const data = await res.json(); + setRows(Array.isArray(data) ? data : []); } catch (err) { console.error('Failed to load misconfig acknowledgements:', err); toast.error('Failed to load acknowledgements'); diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx index fdc833bc..02c0d573 100644 --- a/frontend/src/components/settings/SecuritySection.tsx +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -74,7 +74,7 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const { status: trivy, updateCheck, refresh: refreshTrivy, refreshUpdateCheck } = useTrivyStatus(); - const [trivyBusy, setTrivyBusy] = useState(null); + const [trivyBusy, setTrivyBusy] = useState(null); const [uninstallConfirm, setUninstallConfirm] = useState(false); const [fleetRole, setFleetRole] = useState('control'); const [fleetRoleProbeFailed, setFleetRoleProbeFailed] = useState(false); @@ -132,6 +132,25 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { } }; + const handleHonorSuppressionsToggle = async (enabled: boolean) => { + setTrivyBusy('honor-suppressions'); + try { + const res = await apiFetch('/security/deploy-block-honor-suppressions', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update setting'); + } + await refreshTrivy(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update setting'); + } finally { + setTrivyBusy(null); + } + }; + const fetchPolicies = async () => { try { const res = await apiFetch('/security/policies', { localOnly: true }); @@ -501,6 +520,22 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) { ))} + {isPaid && isAdmin && !isRemote && ( +
+
+ +

+ When on, a suppressed CVE no longer counts toward a block-on-deploy policy, so an accepted finding will not stop a deploy on this instance. Off by default: policies block on the raw scan result. +

+
+ +
+ )} + {!isRemote && } {!isRemote && } diff --git a/frontend/src/components/settings/SuppressionsPanel.tsx b/frontend/src/components/settings/SuppressionsPanel.tsx index a18ae7b1..8df7bdd4 100644 --- a/frontend/src/components/settings/SuppressionsPanel.tsx +++ b/frontend/src/components/settings/SuppressionsPanel.tsx @@ -48,10 +48,9 @@ export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) { const load = useCallback(async () => { try { const res = await apiFetch('/security/suppressions', { localOnly: true }); - if (res.ok) { - const data = await res.json(); - setRows(Array.isArray(data) ? data : []); - } + if (!res.ok) throw new Error('Failed to load suppressions'); + const data = await res.json(); + setRows(Array.isArray(data) ? data : []); } catch (err) { console.error('Failed to load suppressions:', err); toast.error('Failed to load suppressions'); diff --git a/frontend/src/components/settings/__tests__/MisconfigAckPanel.test.tsx b/frontend/src/components/settings/__tests__/MisconfigAckPanel.test.tsx new file mode 100644 index 00000000..f6e35337 --- /dev/null +++ b/frontend/src/components/settings/__tests__/MisconfigAckPanel.test.tsx @@ -0,0 +1,75 @@ +/** + * Coverage for MisconfigAckPanel load behavior. + * + * Mirrors the SuppressionsPanel regression: a non-ok acknowledgements response + * must surface an error toast rather than presenting an empty list as if there + * were no acknowledgements. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { MisconfigAcknowledgement } from '@/types/security'; + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: true }), +})); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { MisconfigAckPanel } from '../MisconfigAckPanel'; + +const mockedFetch = apiFetch as unknown as ReturnType; +const mockedToast = toast as unknown as { error: ReturnType }; + +function ack(overrides: Partial = {}): MisconfigAcknowledgement { + return { + id: 1, + rule_id: 'DS026', + stack_pattern: null, + reason: 'reverse proxy needs root', + created_by: 'admin', + created_at: 1_700_000_000_000, + expires_at: null, + replicated_from_control: 0, + active: true, + ...overrides, + }; +} + +describe('MisconfigAckPanel', () => { + beforeEach(() => { + mockedFetch.mockReset(); + mockedToast.error.mockReset(); + }); + + it('surfaces an error toast when the acknowledgements load fails', async () => { + mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) }); + + render(); + + await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('Failed to load acknowledgements')); + }); + + it('renders acknowledgements and does not toast on a successful load', async () => { + mockedFetch.mockResolvedValue({ ok: true, json: async () => [ack()] }); + + render(); + + await waitFor(() => expect(screen.getByText('DS026')).toBeInTheDocument()); + expect(mockedToast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/settings/__tests__/SuppressionsPanel.test.tsx b/frontend/src/components/settings/__tests__/SuppressionsPanel.test.tsx new file mode 100644 index 00000000..5bf45ce6 --- /dev/null +++ b/frontend/src/components/settings/__tests__/SuppressionsPanel.test.tsx @@ -0,0 +1,76 @@ +/** + * Coverage for SuppressionsPanel load behavior. + * + * Locks the regression fix where a non-ok suppressions response was swallowed + * silently: the panel must surface an error toast so a failed load is visible + * rather than presenting an empty list as if there were no suppressions. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { CveSuppression } from '@/types/security'; + +vi.mock('@/lib/api', () => ({ + apiFetch: vi.fn(), +})); + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { + error: vi.fn(), + success: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }, +})); + +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: true }), +})); + +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { SuppressionsPanel } from '../SuppressionsPanel'; + +const mockedFetch = apiFetch as unknown as ReturnType; +const mockedToast = toast as unknown as { error: ReturnType }; + +function suppression(overrides: Partial = {}): CveSuppression { + return { + id: 1, + cve_id: 'CVE-2026-0001', + pkg_name: null, + image_pattern: null, + reason: 'accepted after review', + created_by: 'admin', + created_at: 1_700_000_000_000, + expires_at: null, + replicated_from_control: 0, + active: true, + ...overrides, + }; +} + +describe('SuppressionsPanel', () => { + beforeEach(() => { + mockedFetch.mockReset(); + mockedToast.error.mockReset(); + }); + + it('surfaces an error toast when the suppressions load fails', async () => { + mockedFetch.mockResolvedValue({ ok: false, json: async () => ({ error: 'boom' }) }); + + render(); + + await waitFor(() => expect(mockedToast.error).toHaveBeenCalledWith('Failed to load suppressions')); + }); + + it('renders suppressions and does not toast on a successful load', async () => { + mockedFetch.mockResolvedValue({ ok: true, json: async () => [suppression()] }); + + render(); + + await waitFor(() => expect(screen.getByText('CVE-2026-0001')).toBeInTheDocument()); + expect(mockedToast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useTrivyStatus.ts b/frontend/src/hooks/useTrivyStatus.ts index 51ea00fa..d82cf770 100644 --- a/frontend/src/hooks/useTrivyStatus.ts +++ b/frontend/src/hooks/useTrivyStatus.ts @@ -7,6 +7,7 @@ const INITIAL_STATUS: TrivyStatus = { version: null, source: 'none', autoUpdate: false, + honorSuppressionsOnDeploy: false, busy: false, }; @@ -31,6 +32,7 @@ export function useTrivyStatus(): UseTrivyStatusResult { version: typeof d.version === 'string' ? d.version : null, source: d.source === 'managed' || d.source === 'host' ? d.source : 'none', autoUpdate: !!d.autoUpdate, + honorSuppressionsOnDeploy: !!d.honorSuppressionsOnDeploy, busy: !!d.busy, }); } catch (err) { diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 23d17e2d..8767c688 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -16,6 +16,7 @@ export interface TrivyStatus { version: string | null; source: TrivySource; autoUpdate: boolean; + honorSuppressionsOnDeploy: boolean; busy: boolean; }