mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Omit<SecretFinding, 'id' | 'scan_id'>>,
|
||||
|
||||
@@ -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<VulnerabilityScan, 'id' | 'highest_severity' | 'critical_count' | 'high_count' | 'total_vulnerabilities'>;
|
||||
|
||||
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<string>();
|
||||
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: [] };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user