mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user