/** * 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('is accessible on community 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(200); expect(res.body.code).not.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 users without stack:edit 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('PERMISSION_DENIED'); }); it('is accessible on community tier (admin still required)', 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(201); expect(res.body.code).not.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.code).toBe('REPLICA_READ_ONLY'); }); 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); }); it('defaults the triage status to accepted and justification to null', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send(validBody); expect(res.status).toBe(201); expect(res.body.status).toBe('accepted'); expect(res.body.justification).toBeNull(); }); it('stores a status and justification round trip', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ ...validBody, status: 'not_affected', justification: 'vulnerable_code_not_present' }); expect(res.status).toBe(201); expect(res.body.status).toBe('not_affected'); expect(res.body.justification).toBe('vulnerable_code_not_present'); }); it('rejects an invalid triage status', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ ...validBody, status: 'bogus' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/triage status/); }); it('rejects an invalid justification code', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ ...validBody, justification: 'not-a-real-code' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/triage justification/); }); }); 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('updates the triage status and justification', async () => { const db = DatabaseService.getInstance(); const created = db.createCveSuppression({ cve_id: 'CVE-2024-3001', 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({ status: 'false_positive', justification: 'component_not_present' }); expect(res.status).toBe(200); expect(res.body.status).toBe('false_positive'); expect(res.body.justification).toBe('component_not_present'); }); 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); }); }); describe('GET /api/security/suppressions on a replica', () => { it('still serves the list when the local instance is a replica', async () => { vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica'); const db = DatabaseService.getInstance(); db.createCveSuppression({ cve_id: 'CVE-2024-5500', pkg_name: null, image_pattern: null, reason: 'mirrored from control', created_by: 'control-admin', created_at: Date.now(), expires_at: null, replicated_from_control: 1, }); const res = await request(app).get('/api/security/suppressions').set('Authorization', adminAuthHeader); expect(res.status).toBe(200); expect(res.body).toHaveLength(1); expect(res.body[0].replicated_from_control).toBe(1); }); }); describe('Control-side audit log entries (L2)', () => { // The receive path on a replica writes an audit-log row when applying a sync. // Control-side writes need the same so the operator's actions show up in the // audit panel; otherwise the trail looks like it begins on the replica. // The assertions below also pin a privacy contract: the suppression's free-form // `reason` field never lands in the audit log, even though it is part of the // stored row. Reasons can carry incident-tracker IDs or vendor context the // operator did not intend to broadcast fleet-wide. function findSuppressionAuditEntries(): Array<{ summary: string; method: string }> { const db = DatabaseService.getInstance(); db.flushAuditLogBuffer(); return (db.getDb() .prepare("SELECT summary, method FROM audit_log WHERE summary LIKE 'cve_suppression.%' ORDER BY id") .all() as Array<{ summary: string; method: string }>); } beforeEach(() => { // Flush prior tests' buffered audit writes BEFORE the wipe; otherwise the // setTimeout flush could materialize them after the DELETE and pollute // this block's assertions. const db = DatabaseService.getInstance(); db.flushAuditLogBuffer(); db.getDb().prepare('DELETE FROM audit_log').run(); }); it('records a create entry without leaking the reason text', async () => { const secret = 'INC-12345 vendor-only do-not-broadcast'; const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ cve_id: 'CVE-2024-6001', pkg_name: 'openssl', image_pattern: 'nginx*', reason: secret, }); expect(res.status).toBe(201); const entries = findSuppressionAuditEntries(); expect(entries).toHaveLength(1); expect(entries[0].method).toBe('POST'); expect(entries[0].summary).toContain('cve_suppression.create'); expect(entries[0].summary).toContain('CVE-2024-6001'); expect(entries[0].summary).toContain('pkg=openssl'); expect(entries[0].summary).toContain('image=nginx*'); expect(entries[0].summary).not.toContain(secret); expect(entries[0].summary).not.toContain('INC-12345'); }); it('records an update entry with the changed field names but not the new reason', async () => { const db = DatabaseService.getInstance(); const created = db.createCveSuppression({ cve_id: 'CVE-2024-6002', pkg_name: null, image_pattern: null, reason: 'original', created_by: TEST_USERNAME, created_at: Date.now(), expires_at: null, replicated_from_control: 0, }); const sensitive = 'CONFIDENTIAL: internal ticket OPS-9911'; const res = await request(app) .put(`/api/security/suppressions/${created.id}`) .set('Authorization', adminAuthHeader) .send({ reason: sensitive }); expect(res.status).toBe(200); const entries = findSuppressionAuditEntries(); expect(entries).toHaveLength(1); expect(entries[0].method).toBe('PUT'); expect(entries[0].summary).toContain('cve_suppression.update'); expect(entries[0].summary).toContain(`id=${created.id}`); expect(entries[0].summary).toContain('fields=[reason]'); expect(entries[0].summary).not.toContain('CONFIDENTIAL'); expect(entries[0].summary).not.toContain('OPS-9911'); }); it('records a delete entry that names the CVE rather than the bare id', async () => { const db = DatabaseService.getInstance(); const created = db.createCveSuppression({ cve_id: 'CVE-2024-6003', pkg_name: 'glibc', image_pattern: null, reason: 'TICKET-7777 do not log', 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); const entries = findSuppressionAuditEntries(); expect(entries).toHaveLength(1); expect(entries[0].method).toBe('DELETE'); expect(entries[0].summary).toContain('cve_suppression.delete'); expect(entries[0].summary).toContain('CVE-2024-6003'); expect(entries[0].summary).toContain('pkg=glibc'); expect(entries[0].summary).not.toContain('TICKET-7777'); }); it('strips newlines and control chars from pkg/image so the audit row cannot be forged', async () => { // The route validators check length but not charset; a pattern with an // embedded newline could otherwise smuggle a fake `cve_suppression.delete:` // entry into the audit panel. const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ cve_id: 'CVE-2024-6004', pkg_name: 'evil\npkg', image_pattern: 'img\r\ncve_suppression.delete: id=999 (forged)', reason: 'log-injection guard', }); expect(res.status).toBe(201); const entries = findSuppressionAuditEntries(); expect(entries).toHaveLength(1); expect(entries[0].summary).not.toContain('\n'); expect(entries[0].summary).not.toContain('\r'); // The forged prefix the attacker tried to inject must not appear as a new // logical entry - the sanitiser turns control chars into `?` so the // string still appears, but on a single line, framed inside the real entry. expect(entries[0].summary).toContain('cve_suppression.create'); }); it('does not record an audit entry on validation failure', async () => { const res = await request(app) .post('/api/security/suppressions') .set('Authorization', adminAuthHeader) .send({ cve_id: 'not-a-cve', reason: 'whatever' }); expect(res.status).toBe(400); expect(findSuppressionAuditEntries()).toHaveLength(0); }); });