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);
});
});
+25
View File
@@ -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();
+12
View File
@@ -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'>>,
+140 -11
View File
@@ -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: [] };
}
+12 -1
View File
@@ -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-
<AccordionGroup>
<Accordion title="I suppressed a CVE but the count on the badge is unchanged">
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.
</Accordion>
<Accordion title="A suppressed CVE still blocks my deploy">
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.
</Accordion>
<Accordion title="A suppression I added on the control is not visible on a replica">
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.
+10 -1
View File
@@ -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.
</Accordion>
<Accordion title="I suppressed a CVE but the scan badge count is unchanged">
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.
</Accordion>
<Accordion title="The Secrets tab is empty on an image I expect to contain credentials">
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.
@@ -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');
@@ -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 | 'install' | 'update' | 'uninstall' | 'auto-update'>(null);
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'honor-suppressions'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false);
const [fleetRole, setFleetRole] = useState<FleetRole>('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 }) {
</div>
))}
{isPaid && isAdmin && !isRemote && (
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-4 py-3">
<div className="min-w-0">
<Label className="text-sm">Honor suppressions in deploy blocks</Label>
<p className="text-xs text-muted-foreground mt-0.5">
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.
</p>
</div>
<TogglePill
checked={trivy.honorSuppressionsOnDeploy}
onChange={handleHonorSuppressionsToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
@@ -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');
@@ -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<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function ack(overrides: Partial<MisconfigAcknowledgement> = {}): 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(<MisconfigAckPanel isReplica={false} />);
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(<MisconfigAckPanel isReplica={false} />);
await waitFor(() => expect(screen.getByText('DS026')).toBeInTheDocument());
expect(mockedToast.error).not.toHaveBeenCalled();
});
});
@@ -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<typeof vi.fn>;
const mockedToast = toast as unknown as { error: ReturnType<typeof vi.fn> };
function suppression(overrides: Partial<CveSuppression> = {}): 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(<SuppressionsPanel isReplica={false} />);
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(<SuppressionsPanel isReplica={false} />);
await waitFor(() => expect(screen.getByText('CVE-2026-0001')).toBeInTheDocument());
expect(mockedToast.error).not.toHaveBeenCalled();
});
});
+2
View File
@@ -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) {
+1
View File
@@ -16,6 +16,7 @@ export interface TrivyStatus {
version: string | null;
source: TrivySource;
autoUpdate: boolean;
honorSuppressionsOnDeploy: boolean;
busy: boolean;
}