feat(security): fleet-replicated CVE suppression list (#650)

Operators can accept known-benign findings once and have Sencho filter
them out of scan drawers, comparison views, and other read surfaces.
Suppressions replicate from the control instance to every remote node.

* New cve_suppressions table with a COALESCE-based unique index so NULL
  scope slots collide the way users expect
* Admin + paid-tier CRUD routes; writes are rejected on replicas
* Read-time filter enriches vulnerability details and compare payloads
  without mutating stored counts
* Settings > Security panel for managing rules, per-CVE suppress action
  in the scan drawer, dimmed rows with a shield-off indicator
* Vitest unit tests for the filter (glob, expiry, specificity) and
  route tests (auth, tier, replica, UNIQUE conflict)
This commit is contained in:
Anso
2026-04-17 05:16:34 -04:00
committed by GitHub
parent 708d15b2b3
commit 732fc95415
16 changed files with 1568 additions and 41 deletions
@@ -0,0 +1,165 @@
/**
* Unit tests for the read-time CVE suppression filter.
*/
import { describe, it, expect } from 'vitest';
import { applySuppressions, findSuppression } from '../utils/suppression-filter';
import type { CveSuppression } from '../services/DatabaseService';
const NOW = 1_700_000_000_000;
function makeSuppression(overrides: Partial<CveSuppression> = {}): CveSuppression {
return {
id: 1,
cve_id: 'CVE-2024-1234',
pkg_name: null,
image_pattern: null,
reason: 'known false positive',
created_by: 'admin',
created_at: NOW - 1000,
expires_at: null,
replicated_from_control: 0,
...overrides,
};
}
describe('findSuppression', () => {
it('returns null when no suppression exists for the CVE', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-9999', pkg_name: 'openssl' },
'nginx:1.25',
[makeSuppression({ cve_id: 'CVE-2024-1234' })],
NOW,
);
expect(match).toBeNull();
});
it('matches a fleet-wide suppression (null pkg, null pattern)', () => {
const s = makeSuppression({ id: 42 });
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25',
[s],
NOW,
);
expect(match?.id).toBe(42);
});
it('does not match a suppression pinned to a different pkg', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'glibc' },
'nginx:1.25',
[makeSuppression({ pkg_name: 'openssl' })],
NOW,
);
expect(match).toBeNull();
});
it('ignores expired suppressions', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25',
[makeSuppression({ expires_at: NOW - 1 })],
NOW,
);
expect(match).toBeNull();
});
it('accepts suppressions with expires_at in the future', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25',
[makeSuppression({ id: 7, expires_at: NOW + 10_000 })],
NOW,
);
expect(match?.id).toBe(7);
});
it('matches wildcard image_pattern via *', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'registry.example.com/nginx:1.25',
[makeSuppression({ image_pattern: '*nginx*' })],
NOW,
);
expect(match).not.toBeNull();
});
it('does not match an image_pattern with no wildcards unless exact', () => {
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25-alpine',
[makeSuppression({ image_pattern: 'nginx:1.25' })],
NOW,
);
expect(match).toBeNull();
});
it('escapes regex metacharacters in image_pattern', () => {
// The + should be literal; otherwise "a+" would match "aa"
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'aa',
[makeSuppression({ image_pattern: 'a+' })],
NOW,
);
expect(match).toBeNull();
});
it('prefers pkg-specific suppression over wildcard', () => {
const wildcard = makeSuppression({ id: 1, pkg_name: null });
const specific = makeSuppression({ id: 2, pkg_name: 'openssl' });
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25',
[wildcard, specific],
NOW,
);
expect(match?.id).toBe(2);
});
it('prefers image-pattern + pkg over pkg-only', () => {
const pkgOnly = makeSuppression({ id: 1, pkg_name: 'openssl' });
const both = makeSuppression({ id: 2, pkg_name: 'openssl', image_pattern: 'nginx*' });
const match = findSuppression(
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl' },
'nginx:1.25',
[pkgOnly, both],
NOW,
);
expect(match?.id).toBe(2);
});
});
describe('applySuppressions', () => {
it('enriches findings without mutating inputs', () => {
const findings = [
{ vulnerability_id: 'CVE-2024-1234', pkg_name: 'openssl', severity: 'HIGH' },
{ vulnerability_id: 'CVE-2024-9999', pkg_name: 'glibc', severity: 'LOW' },
];
const result = applySuppressions(
findings,
'nginx:1.25',
[makeSuppression({ id: 5, reason: 'accepted risk' })],
NOW,
);
expect(result[0]).toMatchObject({
vulnerability_id: 'CVE-2024-1234',
severity: 'HIGH',
suppressed: true,
suppression_id: 5,
suppression_reason: 'accepted risk',
});
expect(result[1]).toMatchObject({
vulnerability_id: 'CVE-2024-9999',
suppressed: false,
});
expect(result[1].suppression_id).toBeUndefined();
// Original findings untouched
expect(findings[0]).not.toHaveProperty('suppressed');
});
it('returns an empty array for empty input', () => {
expect(applySuppressions([], 'nginx:1.25', [], NOW)).toEqual([]);
});
});
@@ -0,0 +1,299 @@
/**
* Route-level tests for /api/security/suppressions CRUD.
* Covers: auth gating, paid-tier gating, admin-only writes, replica rejection,
* CVE format validation, UNIQUE conflict, update/delete behavior.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
import bcrypt from 'bcrypt';
let tmpDir: string;
let app: import('express').Express;
let adminAuthHeader: string;
let viewerAuthHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let FleetSyncService: typeof import('../services/FleetSyncService').FleetSyncService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ FleetSyncService } = await import('../services/FleetSyncService'));
const adminToken = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
adminAuthHeader = `Bearer ${adminToken}`;
// Seed a viewer user for admin-gate tests
const viewerHash = await bcrypt.hash('viewerpass', 1);
DatabaseService.getInstance().addUser({ username: 'viewer1', password_hash: viewerHash, role: 'viewer' });
const viewerToken = jwt.sign({ username: 'viewer1' }, TEST_JWT_SECRET, { expiresIn: '1m' });
viewerAuthHeader = `Bearer ${viewerToken}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
// Reset all rows and stubs before every test
const db = DatabaseService.getInstance();
db.getCveSuppressions().forEach((s) => db.deleteCveSuppression(s.id));
vi.restoreAllMocks();
// Default: paid tier + control role
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('control');
// Stub the async fleet push so it doesn't try to hit real nodes
vi.spyOn(FleetSyncService.getInstance(), 'pushResourceAsync').mockImplementation(() => {});
});
describe('GET /api/security/suppressions', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/security/suppressions');
expect(res.status).toBe(401);
});
it('requires paid tier', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('returns an empty list when no suppressions exist', async () => {
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
});
it('returns rows with active flag computed from expires_at', async () => {
const db = DatabaseService.getInstance();
db.createCveSuppression({
cve_id: 'CVE-2024-1000',
pkg_name: null,
image_pattern: null,
reason: 'still active',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: Date.now() + 60_000,
replicated_from_control: 0,
});
db.createCveSuppression({
cve_id: 'CVE-2024-1001',
pkg_name: null,
image_pattern: null,
reason: 'already expired',
created_by: TEST_USERNAME,
created_at: Date.now() - 10_000,
expires_at: Date.now() - 1,
replicated_from_control: 0,
});
const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
const byCve = Object.fromEntries(res.body.map((s: { cve_id: string; active: boolean }) => [s.cve_id, s.active]));
expect(byCve['CVE-2024-1000']).toBe(true);
expect(byCve['CVE-2024-1001']).toBe(false);
});
});
describe('POST /api/security/suppressions', () => {
const validBody = {
cve_id: 'CVE-2024-2000',
pkg_name: 'openssl',
image_pattern: 'nginx*',
reason: 'Vendor-confirmed false positive on alpine base images.',
};
it('rejects unauthenticated callers with 401', async () => {
const res = await request(app).post('/api/security/suppressions').send(validBody);
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', viewerAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
expect(res.body.code).toBe('ADMIN_REQUIRED');
});
it('rejects community tier with 403', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
expect(res.body.code).toBe('PAID_REQUIRED');
});
it('rejects writes on replicas with 403', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/control node/i);
});
it('rejects malformed CVE identifiers', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, cve_id: 'not-a-cve' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/cve_id/);
});
it('accepts GHSA identifiers', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, cve_id: 'GHSA-abcd-efgh-ijkl' });
expect(res.status).toBe(201);
expect(res.body.cve_id).toBe('GHSA-abcd-efgh-ijkl');
});
it('rejects empty reason with 400', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, reason: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/reason/);
});
it('creates a suppression and records created_by from the session', async () => {
const res = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
cve_id: 'CVE-2024-2000',
pkg_name: 'openssl',
image_pattern: 'nginx*',
reason: validBody.reason,
created_by: TEST_USERNAME,
replicated_from_control: 0,
});
expect(FleetSyncService.getInstance().pushResourceAsync).toHaveBeenCalledWith('cve_suppressions');
});
it('returns 409 when the UNIQUE key is violated', async () => {
const first = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(first.status).toBe(201);
const dup = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(dup.status).toBe(409);
expect(dup.body.error).toMatch(/already exists/i);
});
it('treats two NULL-scope entries for the same CVE as duplicates', async () => {
const minimal = { cve_id: 'CVE-2024-7777', reason: 'wildcard' };
const first = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(minimal);
expect(first.status).toBe(201);
const dup = await request(app)
.post('/api/security/suppressions')
.set('Authorization', adminAuthHeader)
.send(minimal);
expect(dup.status).toBe(409);
});
});
describe('PUT /api/security/suppressions/:id', () => {
it('updates mutable fields and rejects unknown fields silently', async () => {
const db = DatabaseService.getInstance();
const created = db.createCveSuppression({
cve_id: 'CVE-2024-3000',
pkg_name: null,
image_pattern: null,
reason: 'original reason',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const res = await request(app)
.put(`/api/security/suppressions/${created.id}`)
.set('Authorization', adminAuthHeader)
.send({ reason: 'updated reason', image_pattern: 'alpine*', cve_id: 'CVE-2099-9999' });
expect(res.status).toBe(200);
expect(res.body.reason).toBe('updated reason');
expect(res.body.image_pattern).toBe('alpine*');
// cve_id is immutable
expect(res.body.cve_id).toBe('CVE-2024-3000');
});
it('returns 404 for unknown ids', async () => {
const res = await request(app)
.put('/api/security/suppressions/99999')
.set('Authorization', adminAuthHeader)
.send({ reason: 'whatever' });
expect(res.status).toBe(404);
});
it('rejects writes on replicas', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.put('/api/security/suppressions/1')
.set('Authorization', adminAuthHeader)
.send({ reason: 'x' });
expect(res.status).toBe(403);
});
});
describe('DELETE /api/security/suppressions/:id', () => {
it('deletes an existing row and returns success', async () => {
const db = DatabaseService.getInstance();
const created = db.createCveSuppression({
cve_id: 'CVE-2024-4000',
pkg_name: null,
image_pattern: null,
reason: 'to be removed',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const res = await request(app)
.delete(`/api/security/suppressions/${created.id}`)
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
expect(db.getCveSuppression(created.id)).toBeNull();
expect(FleetSyncService.getInstance().pushResourceAsync).toHaveBeenCalledWith('cve_suppressions');
});
it('returns 400 for non-numeric id', async () => {
const res = await request(app)
.delete('/api/security/suppressions/not-a-number')
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(400);
});
it('rejects writes on replicas', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.delete('/api/security/suppressions/1')
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(403);
});
});
+159 -8
View File
@@ -74,6 +74,7 @@ import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyS
import TrivyInstaller from './services/TrivyInstaller';
import { severityRank } from './utils/severity';
import { validateImageRef } from './utils/image-ref';
import { applySuppressions } from './utils/suppression-filter';
import semver from 'semver';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
@@ -1992,13 +1993,29 @@ function validateScanPolicyRow(row: unknown): string | null {
return null;
}
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
function validateCveSuppressionRow(row: unknown): string | null {
if (!row || typeof row !== 'object') return 'row must be an object';
const r = row as Record<string, unknown>;
if (typeof r.cve_id !== 'string' || !CVE_ID_RE.test(r.cve_id)) return 'cve_id must be a valid CVE or GHSA identifier';
if (r.pkg_name !== null && typeof r.pkg_name !== 'string') return 'pkg_name must be a string or null';
if (typeof r.pkg_name === 'string' && r.pkg_name.length > 200) return 'pkg_name is too long';
if (r.image_pattern !== null && typeof r.image_pattern !== 'string') return 'image_pattern must be a string or null';
if (typeof r.image_pattern === 'string' && r.image_pattern.length > 300) return 'image_pattern is too long';
if (typeof r.reason !== 'string') return 'reason must be a string';
if (r.reason.length > 2000) return 'reason is too long';
if (typeof r.created_by !== 'string' || r.created_by.length > 200) return 'created_by must be a string';
if (typeof r.created_at !== 'number') return 'created_at must be a number';
if (r.expires_at !== null && typeof r.expires_at !== 'number') return 'expires_at must be a number or null';
return null;
}
// Fleet sync: receive a full replacement of a replicated resource from the control.
// Only scan_policies are supported today; future resources (CVE suppressions) will plug in here.
// Restricted to node_proxy Bearer tokens so only a sibling Sencho can push.
app.post('/api/fleet/sync/:resource', authMiddleware, (req: Request, res: Response): void => {
if (!requireNodeProxy(req, res)) return;
const resource = req.params.resource;
if (resource !== 'scan_policies') {
if (resource !== 'scan_policies' && resource !== 'cve_suppressions') {
res.status(400).json({ error: `Unsupported sync resource: ${resource}` });
return;
}
@@ -2013,8 +2030,9 @@ app.post('/api/fleet/sync/:resource', authMiddleware, (req: Request, res: Respon
res.status(413).json({ error: `Too many rows (max ${MAX_SYNC_ROWS})` });
return;
}
const validator = resource === 'scan_policies' ? validateScanPolicyRow : validateCveSuppressionRow;
for (let i = 0; i < rows.length; i++) {
const err = validateScanPolicyRow(rows[i]);
const err = validator(rows[i]);
if (err) {
res.status(400).json({ error: `Invalid row at index ${i}: ${err}` });
return;
@@ -7522,7 +7540,9 @@ app.get(
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset });
res.json(result);
const suppressions = db.getCveSuppressions();
const enriched = applySuppressions(result.items, scan.image_ref, suppressions);
res.json({ ...result, items: enriched });
},
);
@@ -7660,6 +7680,133 @@ app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Res
res.json({ success: true });
});
// CVE suppressions. Rules live on the control instance and replicate fleet-wide.
// Reads are open to any authenticated user so operators on replicas can audit; writes
// are admin-only and rejected on replicas.
app.get('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const now = Date.now();
const rows = DatabaseService.getInstance().getCveSuppressions().map((s) => ({
...s,
active: s.expires_at === null || s.expires_at > now,
}));
res.json(rows);
});
app.post('/api/security/suppressions', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const body = req.body ?? {};
const cveId = typeof body.cve_id === 'string' ? body.cve_id.trim() : '';
if (!CVE_ID_RE.test(cveId)) {
res.status(400).json({ error: 'cve_id must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx' });
return;
}
const pkgName = body.pkg_name == null || body.pkg_name === '' ? null : String(body.pkg_name).trim();
if (pkgName !== null && pkgName.length > 200) {
res.status(400).json({ error: 'pkg_name is too long' }); return;
}
const imagePattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
if (imagePattern !== null && imagePattern.length > 300) {
res.status(400).json({ error: 'image_pattern is too long' }); return;
}
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) {
res.status(400).json({ error: 'reason is required' }); return;
}
if (reason.length > 2000) {
res.status(400).json({ error: 'reason is too long' }); return;
}
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
try {
const suppression = DatabaseService.getInstance().createCveSuppression({
cve_id: cveId,
pkg_name: pkgName,
image_pattern: imagePattern,
reason,
created_by: req.user?.username || 'unknown',
created_at: Date.now(),
expires_at: expiresAt,
replicated_from_control: 0,
});
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.status(201).json(suppression);
} catch (error) {
const message = (error as Error).message || '';
if (message.includes('UNIQUE')) {
res.status(409).json({ error: 'A suppression already exists for this CVE, package, and image pattern.' });
return;
}
console.error('[Security] Failed to create suppression:', error);
res.status(500).json({ error: 'Failed to create suppression' });
}
});
app.put('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid suppression id' }); return;
}
const body = req.body ?? {};
const updates: Partial<{ reason: string; image_pattern: string | null; expires_at: number | null }> = {};
if (body.reason !== undefined) {
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; }
updates.reason = reason;
}
if (body.image_pattern !== undefined) {
const pattern = body.image_pattern == null || body.image_pattern === '' ? null : String(body.image_pattern).trim();
if (pattern !== null && pattern.length > 300) {
res.status(400).json({ error: 'image_pattern is too long' }); return;
}
updates.image_pattern = pattern;
}
if (body.expires_at !== undefined) {
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
updates.expires_at = expiresAt;
}
const suppression = DatabaseService.getInstance().updateCveSuppression(id, updates);
if (!suppression) {
res.status(404).json({ error: 'Suppression not found' }); return;
}
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json(suppression);
});
app.delete('/api/security/suppressions/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (FleetSyncService.getRole() === 'replica') {
res.status(403).json({ error: 'CVE suppressions are managed from the control node.' });
return;
}
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid suppression id' }); return;
}
DatabaseService.getInstance().deleteCveSuppression(id);
FleetSyncService.getInstance().pushResourceAsync('cve_suppressions');
res.json({ success: true });
});
app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => {
if (!requirePaid(req, res)) return;
const scanId1 = Number(req.query.scanId1);
@@ -7679,15 +7826,19 @@ app.get('/api/security/compare', authMiddleware, (req: Request, res: Response):
`${v.vulnerability_id}::${v.pkg_name}`;
const aMap = new Map(aVulns.map((v) => [keyOf(v), v]));
const bMap = new Map(bVulns.map((v) => [keyOf(v), v]));
const added = bVulns.filter((v) => !aMap.has(keyOf(v)));
const removed = aVulns.filter((v) => !bMap.has(keyOf(v)));
const unchanged = aVulns.filter((v) => bMap.has(keyOf(v)));
const addedRaw = bVulns.filter((v) => !aMap.has(keyOf(v)));
const removedRaw = aVulns.filter((v) => !bMap.has(keyOf(v)));
const unchangedRaw = aVulns.filter((v) => bMap.has(keyOf(v)));
const suppressions = db.getCveSuppressions();
const added = applySuppressions(addedRaw, b.image_ref, suppressions);
const removed = applySuppressions(removedRaw, a.image_ref, suppressions);
const unchanged = applySuppressions(unchangedRaw, b.image_ref, suppressions);
res.json({
scanA: { id: a.id, scanned_at: a.scanned_at, image_ref: a.image_ref },
scanB: { id: b.id, scanned_at: b.scanned_at, image_ref: b.image_ref },
added,
removed,
unchanged: unchanged.map((v) => ({ vulnerability_id: v.vulnerability_id, pkg_name: v.pkg_name, severity: v.severity })),
unchanged,
});
});
+120
View File
@@ -326,6 +326,18 @@ export interface FleetSyncStatus {
last_error: string | null;
}
export interface CveSuppression {
id: number;
cve_id: string;
pkg_name: string | null;
image_pattern: string | null;
reason: string;
created_by: string;
created_at: number;
expires_at: number | null;
replicated_from_control: number;
}
export interface ScanSummary {
image_ref: string;
highest_severity: VulnSeverity | null;
@@ -633,6 +645,23 @@ export class DatabaseService {
PRIMARY KEY (node_id, resource)
);
CREATE TABLE IF NOT EXISTS cve_suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
pkg_name TEXT,
image_pattern TEXT,
reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
replicated_from_control INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_cve ON cve_suppressions(cve_id);
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_expires ON cve_suppressions(expires_at);
-- COALESCE makes NULL scope slots collide the way users expect (NULL == NULL here).
CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_suppressions_unique
ON cve_suppressions(cve_id, COALESCE(pkg_name, ''), COALESCE(image_pattern, ''));
CREATE TABLE IF NOT EXISTS stack_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
@@ -2515,6 +2544,97 @@ export class DatabaseService {
.all(resource, cutoff) as FleetSyncStatus[];
}
// --- CVE Suppressions ---
public getCveSuppressions(): CveSuppression[] {
return this.db
.prepare('SELECT * FROM cve_suppressions ORDER BY cve_id, pkg_name')
.all() as CveSuppression[];
}
public getCveSuppression(id: number): CveSuppression | null {
return (
(this.db.prepare('SELECT * FROM cve_suppressions WHERE id = ?')
.get(id) as CveSuppression | undefined) ?? null
);
}
public createCveSuppression(
suppression: Omit<CveSuppression, 'id'>,
): CveSuppression {
const result = this.db
.prepare(
`INSERT INTO cve_suppressions
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
suppression.cve_id,
suppression.pkg_name,
suppression.image_pattern,
suppression.reason,
suppression.created_by,
suppression.created_at,
suppression.expires_at,
suppression.replicated_from_control ?? 0,
);
return { ...suppression, id: result.lastInsertRowid as number };
}
public updateCveSuppression(
id: number,
updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at'>>,
): CveSuppression | null {
const existing = this.getCveSuppression(id);
if (!existing) return null;
const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at']);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return existing;
values.push(id);
this.db
.prepare(`UPDATE cve_suppressions SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
return this.getCveSuppression(id);
}
public deleteCveSuppression(id: number): void {
this.db.prepare('DELETE FROM cve_suppressions WHERE id = ?').run(id);
}
/**
* Replace all replicated CVE suppressions in a single transaction.
* Preserves rows flagged as locally created on this instance.
*/
public replaceReplicatedCveSuppressions(rows: Array<Omit<CveSuppression, 'id'>>): void {
const deleteStmt = this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO cve_suppressions
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
);
const txn = this.db.transaction((items: Array<Omit<CveSuppression, 'id'>>) => {
deleteStmt.run();
for (const s of items) {
insertStmt.run(
s.cve_id,
s.pkg_name,
s.image_pattern,
s.reason,
s.created_by,
s.created_at,
s.expires_at,
);
}
});
txn(rows);
}
// --- Stack Labels ---
public getLabels(nodeId: number): Label[] {
+30 -4
View File
@@ -1,8 +1,14 @@
import axios, { AxiosError } from 'axios';
import { DatabaseService, Node, ScanPolicy } from './DatabaseService';
import { CveSuppression, DatabaseService, Node, ScanPolicy } from './DatabaseService';
import { NodeRegistry } from './NodeRegistry';
export type FleetResource = 'scan_policies';
export type FleetResource = 'scan_policies' | 'cve_suppressions';
export const FLEET_RESOURCES: readonly FleetResource[] = ['scan_policies', 'cve_suppressions'];
export function isFleetResource(value: unknown): value is FleetResource {
return typeof value === 'string' && (FLEET_RESOURCES as readonly string[]).includes(value);
}
export type FleetRole = 'control' | 'replica';
@@ -140,14 +146,20 @@ export class FleetSyncService {
* This promotes the instance to 'replica' mode if not already, caches
* the target identity it was told, and replaces replicated rows atomically.
*/
public applyIncomingSync(resource: FleetResource, rows: ScanPolicy[], targetIdentity: string): void {
public applyIncomingSync(
resource: FleetResource,
rows: ScanPolicy[] | Array<Omit<CveSuppression, 'id'>>,
targetIdentity: string,
): void {
const db = DatabaseService.getInstance();
db.setSystemState('fleet_role', 'replica');
if (targetIdentity) {
db.setSystemState('fleet_self_identity', targetIdentity);
}
if (resource === 'scan_policies') {
db.replaceReplicatedScanPolicies(rows);
db.replaceReplicatedScanPolicies(rows as ScanPolicy[]);
} else if (resource === 'cve_suppressions') {
db.replaceReplicatedCveSuppressions(rows as Array<Omit<CveSuppression, 'id'>>);
}
}
@@ -168,6 +180,20 @@ export class FleetSyncService {
updated_at: p.updated_at,
}));
}
if (resource === 'cve_suppressions') {
return db
.getCveSuppressions()
.filter((s) => s.replicated_from_control === 0)
.map((s) => ({
cve_id: s.cve_id,
pkg_name: s.pkg_name,
image_pattern: s.image_pattern,
reason: s.reason,
created_by: s.created_by,
created_at: s.created_at,
expires_at: s.expires_at,
}));
}
return [];
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Read-time CVE suppression filter.
*
* Suppressions never modify stored scan rows. They are applied at read time so
* toggling them off resurfaces findings without rescanning.
*
* A suppression matches a finding when:
* - cve_id equals the finding's vulnerability_id, AND
* - pkg_name is null OR equals the finding's pkg_name, AND
* - image_pattern is null OR matches the image reference (glob), AND
* - expires_at is null OR still in the future.
*/
import type { CveSuppression } from '../services/DatabaseService';
export interface SuppressionDecision {
suppressed: boolean;
suppression_id?: number;
suppression_reason?: string;
}
export interface SuppressibleFinding {
vulnerability_id: string;
pkg_name: string;
}
function matchesImagePattern(pattern: string | null, imageRef: string): boolean {
if (!pattern) return true;
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`).test(imageRef);
}
function isActive(suppression: CveSuppression, now: number): boolean {
return suppression.expires_at === null || suppression.expires_at > now;
}
/**
* Find the most specific active suppression matching a single finding.
* Specificity: entries that pin a specific pkg or image beat wildcard entries.
*/
export function findSuppression(
finding: SuppressibleFinding,
imageRef: string,
suppressions: CveSuppression[],
now: number = Date.now(),
): CveSuppression | null {
const matches = suppressions.filter((s) => {
if (!isActive(s, now)) return false;
if (s.cve_id !== finding.vulnerability_id) return false;
if (s.pkg_name !== null && s.pkg_name !== finding.pkg_name) return false;
if (!matchesImagePattern(s.image_pattern, imageRef)) return false;
return true;
});
if (matches.length === 0) return null;
const score = (s: CveSuppression): number =>
(s.pkg_name ? 2 : 0) + (s.image_pattern ? 1 : 0);
matches.sort((a, b) => score(b) - score(a));
return matches[0];
}
/**
* Enrich a list of findings with suppression decisions. Does not mutate inputs.
*/
export function applySuppressions<T extends SuppressibleFinding>(
findings: T[],
imageRef: string,
suppressions: CveSuppression[],
now: number = Date.now(),
): Array<T & SuppressionDecision> {
return findings.map((f) => {
const match = findSuppression(f, imageRef, suppressions, now);
if (!match) return { ...f, suppressed: false };
return {
...f,
suppressed: true,
suppression_id: match.id,
suppression_reason: match.reason,
};
});
}