mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
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:
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -117,6 +117,7 @@
|
||||
"features/api-tokens",
|
||||
"features/private-registries",
|
||||
"features/vulnerability-scanning",
|
||||
"features/cve-suppressions",
|
||||
"features/auto-update-policies",
|
||||
"features/scheduled-operations",
|
||||
"features/sso",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
title: "CVE Suppressions"
|
||||
description: "Accept known-benign vulnerabilities fleet-wide so your scan results stay focused on findings that actually need action."
|
||||
---
|
||||
|
||||
Not every CVE that Trivy reports requires a response. Some are false positives on your base image, some have been accepted by your security review, and some are waiting on an upstream patch. CVE suppressions let you annotate these findings once so they stop competing for attention in every scan, comparison, and alert.
|
||||
|
||||
<Note>
|
||||
CVE suppressions require a **Skipper** or **Admiral** license.
|
||||
</Note>
|
||||
|
||||
## What suppressions do
|
||||
|
||||
A suppression is a rule that says "this CVE is acknowledged." When a scan's findings are read back for display or comparison, Sencho checks each finding against the active suppression list:
|
||||
|
||||
- Suppressed findings remain in the database and in the scan totals. Nothing is deleted.
|
||||
- In the scan drawer and the comparison sheet, suppressed rows are dimmed and marked with a shield-off icon.
|
||||
- The reason you recorded is visible in the row so reviewers understand why it was accepted.
|
||||
|
||||
Counts on badges and summary ribbons continue to reflect the raw findings. Suppressions are a visual filter, not an accounting trick. If you suppress a CVE and then remove the suppression, the finding resurfaces on the next read, without rescanning.
|
||||
|
||||
## Creating a suppression
|
||||
|
||||
Go to **Settings → Security** and scroll to **CVE Suppressions**, then click **Add Suppression**.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/cve-suppressions/settings-panel.png" alt="Settings Security section showing the CVE Suppressions panel with a list of accepted CVEs" />
|
||||
</Frame>
|
||||
|
||||
| Field | Description |
|
||||
|-------|-------------|
|
||||
| **CVE ID** | The identifier of the finding. Accepts both `CVE-YYYY-NNNN` and GitHub advisory IDs like `GHSA-xxxx-xxxx-xxxx`. |
|
||||
| **Package** | Optional. Leave empty to suppress every occurrence of this CVE regardless of package, or set it to a specific package name (e.g. `openssl`) to narrow the scope. |
|
||||
| **Image pattern** | Optional glob against image references (e.g. `registry.example.com/app*`). Leave empty to apply fleet-wide. |
|
||||
| **Reason** | Required. A short note explaining why this CVE is accepted. Surfaced on every suppressed row. |
|
||||
| **Expires in** | Optional. Number of days after which the suppression stops applying. Useful for "patched in the next release" acknowledgements. Leave empty for an indefinite suppression. |
|
||||
|
||||
<Frame>
|
||||
<img src="/images/cve-suppressions/create-dialog.png" alt="Dialog for adding a new CVE suppression with fields for CVE ID, package, image pattern, reason, and expiry" />
|
||||
</Frame>
|
||||
|
||||
### How specificity is resolved
|
||||
|
||||
When multiple suppressions match the same finding, the most specific one wins:
|
||||
|
||||
1. A suppression that pins both a package name and an image pattern is the most specific.
|
||||
2. A suppression with only a package name beats a wildcard pattern.
|
||||
3. A suppression with only an image pattern beats a fully-wildcard rule.
|
||||
|
||||
The reason field of the winning suppression is the one displayed on the row.
|
||||
|
||||
## Viewing suppressed findings
|
||||
|
||||
Open any scan drawer and scroll to the vulnerability table. Suppressed rows look like this:
|
||||
|
||||
<Frame>
|
||||
<img src="/images/cve-suppressions/suppressed-row.png" alt="Vulnerability scan drawer with a suppressed row dimmed and labeled with a shield-off icon" />
|
||||
</Frame>
|
||||
|
||||
Suppressed rows also carry through to the **Compare scans** view. Both the added and removed columns show the suppression state, so a finding that you've already accepted will not look like a new regression when comparing an older baseline.
|
||||
|
||||
## Fleet-wide replication
|
||||
|
||||
Suppressions are managed on the **control** Sencho instance and replicate automatically to every remote Sencho you've registered. There is nothing extra to configure:
|
||||
|
||||
- Creating or editing a suppression on the control pushes the full list to every remote.
|
||||
- Remote instances show the suppression list in a read-only state. The **Add Suppression** and **Delete** buttons are hidden, and a banner explains that rules are managed upstream.
|
||||
- Incoming scan results on the control and every remote apply the same suppression set.
|
||||
|
||||
If a push to a remote fails (for example because the remote is temporarily offline), Sencho records the failure and retries on the next fleet sync tick. See [Fleet Sync](/features/fleet-sync) for the details of how replication works and how to inspect push status.
|
||||
|
||||
## Removing a suppression
|
||||
|
||||
Click the trash icon on any row in the suppressions panel. A confirmation dialog calls out that removing the rule will cause matching findings to reappear in scan results.
|
||||
|
||||
To change a suppression's scope (for example, to narrow an image pattern or extend an expiry), delete the existing rule and create a new one with the updated fields.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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. The filter is applied in the scan drawer, the comparison sheet, and every read surface, but the stored totals do not change. Open the scan drawer to confirm the row is dimmed with a shield-off icon.
|
||||
|
||||
### A suppression I added on the control is not visible on a remote
|
||||
|
||||
Replication runs on every write. If the push failed (network blip, remote restart), check the fleet sync status on the control under **Fleet → Sync status**. The remote picks up the latest state on the next successful push.
|
||||
|
||||
### I see suppressions on a remote but cannot edit them
|
||||
|
||||
Remote Sencho instances are read-only for security rules. Sign in to the control instance to add, edit, or delete suppressions. Changes sync automatically.
|
||||
|
||||
### A suppression does not match a finding I expect it to
|
||||
|
||||
Two common causes:
|
||||
|
||||
- **Image pattern mismatch.** The pattern uses glob syntax where `*` matches any sequence. `nginx*` matches `nginx:1.25` but not `docker.io/library/nginx:1.25`; use `*nginx*` for a broader match.
|
||||
- **Expired rule.** If **Expires in** was set, the suppression stops applying after the deadline. The row shows an "expired" badge; edit it or create a fresh rule.
|
||||
@@ -237,3 +237,7 @@ Two completed scans are required to run a comparison. If you have only one scan
|
||||
### Scan policies are missing on one of my nodes
|
||||
|
||||
Scan policies are managed from the control Sencho instance and replicate to every remote. When you view **Settings → Security** on a remote Sencho (a replica), you will see a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures.
|
||||
|
||||
### I suppressed a CVE but the scan badge count is unchanged
|
||||
|
||||
Badge counts reflect the 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.
|
||||
|
||||
@@ -1491,6 +1491,7 @@ export default function ResourcesView() {
|
||||
onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }}
|
||||
canGenerateSbom={isPaid}
|
||||
canCompare={isPaid}
|
||||
canManageSuppressions={isPaid && isAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
MinusCircle,
|
||||
PlusCircle,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Equal,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
@@ -282,27 +283,37 @@ export function ScanComparisonSheet({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((v, idx) => {
|
||||
const rowClass =
|
||||
const baseRowClass =
|
||||
filter === 'added'
|
||||
? 'bg-destructive/5'
|
||||
: filter === 'removed'
|
||||
? 'bg-success/5'
|
||||
: 'opacity-70';
|
||||
const rowClass = cn(baseRowClass, v.suppressed && 'opacity-60');
|
||||
return (
|
||||
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{v.primary_url ? (
|
||||
<a
|
||||
href={v.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hover:underline"
|
||||
>
|
||||
{v.vulnerability_id}
|
||||
</a>
|
||||
) : (
|
||||
v.vulnerability_id
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{v.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{v.primary_url ? (
|
||||
<a
|
||||
href={v.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hover:underline"
|
||||
>
|
||||
{v.vulnerability_id}
|
||||
</a>
|
||||
) : (
|
||||
v.vulnerability_id
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
|
||||
{v.pkg_name}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { ScanComparisonSheet } from './ScanComparisonSheet';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import type { VulnerabilityScan } from '@/types/security';
|
||||
|
||||
@@ -56,6 +57,7 @@ function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] {
|
||||
|
||||
export function SecurityHistoryView() {
|
||||
const { isPaid } = useLicense();
|
||||
const { isAdmin } = useAuth();
|
||||
const { activeNode } = useNodes();
|
||||
const [scans, setScans] = useState<VulnerabilityScan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -304,6 +306,7 @@ export function SecurityHistoryView() {
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isPaid}
|
||||
canCompare={false}
|
||||
canManageSuppressions={isPaid && isAdmin}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -28,6 +29,16 @@ import {
|
||||
GitCompare,
|
||||
} from 'lucide-react';
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { ScanComparisonSheet } from './ScanComparisonSheet';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
@@ -44,6 +55,15 @@ interface VulnerabilityScanSheetProps {
|
||||
onRescan?: (imageRef: string) => void;
|
||||
canGenerateSbom?: boolean;
|
||||
canCompare?: boolean;
|
||||
canManageSuppressions?: boolean;
|
||||
}
|
||||
|
||||
interface SuppressDialogState {
|
||||
cveId: string;
|
||||
pkgName: string;
|
||||
imagePattern: string;
|
||||
reason: string;
|
||||
expiresInDays: string;
|
||||
}
|
||||
|
||||
type SeverityFilter = 'ALL' | VulnSeverity;
|
||||
@@ -77,6 +97,7 @@ export function VulnerabilityScanSheet({
|
||||
onRescan,
|
||||
canGenerateSbom = false,
|
||||
canCompare = false,
|
||||
canManageSuppressions = false,
|
||||
}: VulnerabilityScanSheetProps) {
|
||||
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
|
||||
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
||||
@@ -89,6 +110,8 @@ export function VulnerabilityScanSheet({
|
||||
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
|
||||
const [compareLoading, setCompareLoading] = useState(false);
|
||||
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
|
||||
const [suppressForm, setSuppressForm] = useState<SuppressDialogState | null>(null);
|
||||
const [savingSuppression, setSavingSuppression] = useState(false);
|
||||
|
||||
const DETAIL_FETCH_LIMIT = 500;
|
||||
|
||||
@@ -194,6 +217,60 @@ export function VulnerabilityScanSheet({
|
||||
}
|
||||
}, [scan, compareOptions.length, compareLoading]);
|
||||
|
||||
const openSuppressDialog = useCallback((d: VulnerabilityDetail) => {
|
||||
setSuppressForm({
|
||||
cveId: d.vulnerability_id,
|
||||
pkgName: d.pkg_name,
|
||||
imagePattern: '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
});
|
||||
}, []);
|
||||
|
||||
const submitSuppression = useCallback(async () => {
|
||||
if (!suppressForm) return;
|
||||
const reason = suppressForm.reason.trim();
|
||||
if (!reason) {
|
||||
toast.error('A reason is required.');
|
||||
return;
|
||||
}
|
||||
const days = suppressForm.expiresInDays.trim();
|
||||
let expiresAt: number | null = null;
|
||||
if (days) {
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error('Expiry must be a positive number of days or blank.');
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
setSavingSuppression(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/suppressions', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({
|
||||
cve_id: suppressForm.cveId,
|
||||
pkg_name: suppressForm.pkgName || null,
|
||||
image_pattern: suppressForm.imagePattern.trim() || null,
|
||||
reason,
|
||||
expires_at: expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to create suppression');
|
||||
}
|
||||
toast.success('Suppression created');
|
||||
setSuppressForm(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to create suppression');
|
||||
} finally {
|
||||
setSavingSuppression(false);
|
||||
}
|
||||
}, [suppressForm, load]);
|
||||
|
||||
const exportCsv = useCallback(() => {
|
||||
if (!scan || details.length === 0) return;
|
||||
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
|
||||
@@ -446,27 +523,40 @@ export function VulnerabilityScanSheet({
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[110px]">Installed</TableHead>
|
||||
<TableHead className="w-[110px]">Fixed</TableHead>
|
||||
{canManageSuppressions && <TableHead className="w-[40px]" />}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((d) => (
|
||||
<TableRow key={d.id}>
|
||||
<TableRow key={d.id} className={d.suppressed ? 'opacity-60' : undefined}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{d.primary_url ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{d.suppressed && (
|
||||
<ShieldOff
|
||||
className="w-3 h-3 text-muted-foreground"
|
||||
strokeWidth={1.5}
|
||||
aria-label="Suppressed"
|
||||
/>
|
||||
)}
|
||||
{d.primary_url ? (
|
||||
<a
|
||||
href={d.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
>
|
||||
{d.vulnerability_id}
|
||||
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
|
||||
</a>
|
||||
) : (
|
||||
d.vulnerability_id
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={d.pkg_name}>
|
||||
<TableCell
|
||||
className="font-mono text-xs truncate max-w-[180px]"
|
||||
title={d.suppression_reason || d.pkg_name}
|
||||
>
|
||||
{d.pkg_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -483,6 +573,21 @@ export function VulnerabilityScanSheet({
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
{canManageSuppressions && (
|
||||
<TableCell>
|
||||
{!d.suppressed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground/70 hover:text-foreground"
|
||||
title="Suppress this CVE"
|
||||
onClick={() => openSuppressDialog(d)}
|
||||
>
|
||||
<ShieldOff className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -498,6 +603,83 @@ export function VulnerabilityScanSheet({
|
||||
currentScanId={compareBaselineId != null ? scanId : null}
|
||||
onClose={() => setCompareBaselineId(null)}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={suppressForm !== null}
|
||||
onOpenChange={(open) => !open && setSuppressForm(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Suppress CVE</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Accept this CVE as known-benign so it stops triggering alerts across the fleet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{suppressForm && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">CVE</Label>
|
||||
<div className="font-mono text-sm">{suppressForm.cveId}</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Package</Label>
|
||||
<div className="font-mono text-sm truncate" title={suppressForm.pkgName}>
|
||||
{suppressForm.pkgName || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppress-pattern">Image pattern (optional)</Label>
|
||||
<Input
|
||||
id="suppress-pattern"
|
||||
placeholder="e.g. registry.internal/* (leave blank for all images)"
|
||||
value={suppressForm.imagePattern}
|
||||
onChange={(e) =>
|
||||
setSuppressForm((f) => (f ? { ...f, imagePattern: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppress-reason">Reason</Label>
|
||||
<textarea
|
||||
id="suppress-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Why is this CVE safe to accept?"
|
||||
value={suppressForm.reason}
|
||||
onChange={(e) =>
|
||||
setSuppressForm((f) => (f ? { ...f, reason: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="suppress-expiry">Expires in (days, optional)</Label>
|
||||
<Input
|
||||
id="suppress-expiry"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for no expiry"
|
||||
value={suppressForm.expiresInDays}
|
||||
onChange={(e) =>
|
||||
setSuppressForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSuppressForm(null)} disabled={savingSuppression}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={submitSuppression} disabled={savingSuppression}>
|
||||
{savingSuppression ? 'Saving...' : 'Suppress'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ShieldCheck, Plus, Trash2, Pencil, Download, RefreshCw, Loader2, Info }
|
||||
import type { FleetRole, ScanPolicy, VulnSeverity } from '@/types/security';
|
||||
import { useLicense } from '@/context/LicenseContext';
|
||||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||||
import { SuppressionsPanel } from './SuppressionsPanel';
|
||||
|
||||
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
|
||||
{ value: 'CRITICAL', label: 'Critical' },
|
||||
@@ -452,6 +453,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<SuppressionsPanel isReplica={isReplica} />
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { ChevronLeft, ChevronRight, Plus, ShieldOff, Trash2 } from 'lucide-react';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import type { CveSuppression } from '@/types/security';
|
||||
|
||||
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
interface SuppressionFormState {
|
||||
cveId: string;
|
||||
pkgName: string;
|
||||
imagePattern: string;
|
||||
reason: string;
|
||||
expiresInDays: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: SuppressionFormState = {
|
||||
cveId: '',
|
||||
pkgName: '',
|
||||
imagePattern: '',
|
||||
reason: '',
|
||||
expiresInDays: '',
|
||||
};
|
||||
|
||||
interface SuppressionsPanelProps {
|
||||
isReplica: boolean;
|
||||
}
|
||||
|
||||
export function SuppressionsPanel({ isReplica }: SuppressionsPanelProps) {
|
||||
const [rows, setRows] = useState<CveSuppression[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<SuppressionFormState>(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleteRow, setDeleteRow] = useState<CveSuppression | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
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 : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load suppressions:', err);
|
||||
toast.error('Failed to load suppressions');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
|
||||
const needsPagination = rows.length > PAGE_SIZE;
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(EMPTY_FORM);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const cveId = form.cveId.trim();
|
||||
if (!CVE_ID_RE.test(cveId)) {
|
||||
toast.error('CVE must look like CVE-YYYY-NNNN or GHSA-xxxx-xxxx-xxxx.');
|
||||
return;
|
||||
}
|
||||
const reason = form.reason.trim();
|
||||
if (!reason) {
|
||||
toast.error('A reason is required.');
|
||||
return;
|
||||
}
|
||||
let expiresAt: number | null = null;
|
||||
const days = form.expiresInDays.trim();
|
||||
if (days) {
|
||||
const n = Number(days);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
toast.error('Expiry must be a positive number of days or blank.');
|
||||
return;
|
||||
}
|
||||
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch('/security/suppressions', {
|
||||
method: 'POST',
|
||||
localOnly: true,
|
||||
body: JSON.stringify({
|
||||
cve_id: cveId,
|
||||
pkg_name: form.pkgName.trim() || null,
|
||||
image_pattern: form.imagePattern.trim() || null,
|
||||
reason,
|
||||
expires_at: expiresAt,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to create suppression');
|
||||
}
|
||||
toast.success('Suppression created');
|
||||
setDialogOpen(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to create suppression');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteRow) return;
|
||||
try {
|
||||
const res = await apiFetch(`/security/suppressions/${deleteRow.id}`, {
|
||||
method: 'DELETE',
|
||||
localOnly: true,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error || 'Failed to delete suppression');
|
||||
}
|
||||
toast.success('Suppression removed');
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error((err as Error)?.message || 'Failed to delete suppression');
|
||||
} finally {
|
||||
setDeleteRow(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatExpiry = (row: CveSuppression): string => {
|
||||
if (row.expires_at === null) return 'Never';
|
||||
const d = new Date(row.expires_at);
|
||||
return d.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<ShieldOff className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<span className="font-medium text-sm">CVE Suppressions</span>
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
|
||||
{rows.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{needsPagination && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!isReplica && (
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<Plus className="w-4 h-4 mr-1.5" />
|
||||
Add Suppression
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Accept known-benign CVEs so they stop triggering alerts. Suppressions apply at read time across every
|
||||
instance in the fleet and never modify stored scan data.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-10 w-full rounded" />
|
||||
<Skeleton className="h-10 w-full rounded" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">
|
||||
No suppressions yet. Accept a CVE from any scan result to silence it fleet-wide.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
<ScrollArea className="max-h-[420px] pr-2">
|
||||
<ul className="divide-y divide-glass-border">
|
||||
{pageItems.map((row) => (
|
||||
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-mono text-xs font-medium">{row.cve_id}</span>
|
||||
{row.pkg_name && (
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{row.pkg_name}
|
||||
</Badge>
|
||||
)}
|
||||
{row.image_pattern && (
|
||||
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
|
||||
{row.image_pattern}
|
||||
</Badge>
|
||||
)}
|
||||
{!row.active && (
|
||||
<Badge variant="secondary" className="text-[10px]">expired</Badge>
|
||||
)}
|
||||
{row.replicated_from_control === 1 && (
|
||||
<Badge variant="secondary" className="text-[10px]">replicated</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-2">{row.reason}</div>
|
||||
<div className="text-[11px] font-mono text-stat-subtitle">
|
||||
by {row.created_by} - expires {formatExpiry(row)}
|
||||
</div>
|
||||
</div>
|
||||
{!isReplica && row.replicated_from_control === 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
|
||||
onClick={() => setDeleteRow(row)}
|
||||
title="Remove suppression"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Suppression</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Accept a CVE as known-benign so it stops triggering alerts across the fleet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-cve">CVE or advisory ID</Label>
|
||||
<Input
|
||||
id="s-cve"
|
||||
placeholder="CVE-2024-12345 or GHSA-xxxx-xxxx-xxxx"
|
||||
value={form.cveId}
|
||||
onChange={(e) => setForm({ ...form, cveId: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-pkg">Package (optional)</Label>
|
||||
<Input
|
||||
id="s-pkg"
|
||||
placeholder="e.g. openssl (leave blank to match every package)"
|
||||
value={form.pkgName}
|
||||
onChange={(e) => setForm({ ...form, pkgName: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-image">Image pattern (optional)</Label>
|
||||
<Input
|
||||
id="s-image"
|
||||
placeholder="e.g. registry.internal/* (leave blank for all images)"
|
||||
value={form.imagePattern}
|
||||
onChange={(e) => setForm({ ...form, imagePattern: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-reason">Reason</Label>
|
||||
<textarea
|
||||
id="s-reason"
|
||||
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="Why is this CVE safe to accept?"
|
||||
value={form.reason}
|
||||
onChange={(e) => setForm({ ...form, reason: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="s-expiry">Expires in (days, optional)</Label>
|
||||
<Input
|
||||
id="s-expiry"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Leave blank for no expiry"
|
||||
value={form.expiresInDays}
|
||||
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog open={deleteRow !== null} onOpenChange={(open) => !open && setDeleteRow(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove suppression?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Future scan results will surface {deleteRow?.cve_id} again wherever it applies.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,22 @@ export interface VulnerabilityDetail {
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
primary_url: string | null;
|
||||
suppressed?: boolean;
|
||||
suppression_id?: number;
|
||||
suppression_reason?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
@@ -92,6 +108,9 @@ export interface ScanCompareVulnerability {
|
||||
installed_version?: string;
|
||||
fixed_version?: string | null;
|
||||
primary_url?: string | null;
|
||||
suppressed?: boolean;
|
||||
suppression_id?: number;
|
||||
suppression_reason?: string;
|
||||
}
|
||||
|
||||
export interface ScanCompareResult {
|
||||
@@ -99,5 +118,5 @@ export interface ScanCompareResult {
|
||||
scanB: { id: number; scanned_at: number; image_ref: string };
|
||||
added: ScanCompareVulnerability[];
|
||||
removed: ScanCompareVulnerability[];
|
||||
unchanged: Pick<ScanCompareVulnerability, 'vulnerability_id' | 'pkg_name' | 'severity'>[];
|
||||
unchanged: ScanCompareVulnerability[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user