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.
This commit is contained in:
Anso
2026-06-01 13:39:46 -04:00
committed by GitHub
parent d8f73f8203
commit 085267b466
14 changed files with 736 additions and 22 deletions
@@ -21,6 +21,9 @@ interface ComposeStub {
interface DbStub {
getMatchingPolicy: ReturnType<typeof vi.fn>;
insertAuditLog: ReturnType<typeof vi.fn>;
getGlobalSettings: ReturnType<typeof vi.fn>;
getAllVulnerabilityDetails: ReturnType<typeof vi.fn>;
getCveSuppressions: ReturnType<typeof vi.fn>;
}
interface NotificationStub {
dispatchAlert: ReturnType<typeof vi.fn>;
@@ -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> = {}): FindingStub {
return { vulnerability_id: 'CVE-2026-0001', pkg_name: 'openssl', severity: 'CRITICAL', ...overrides };
}
function mkSuppression(overrides: Record<string, unknown> = {}) {
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();
});
});
@@ -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);
});
});