Files
sencho/backend/src/__tests__/misconfig-ack-routes.test.ts
T
Anso 3b650523c1 Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans

Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.

Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.

* feat(security): acknowledge misconfig findings

Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.

Schema and behavior parity with cve_suppressions:
  - UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
    collide as expected
  - blockIfReplica on every write
  - Audit-log entries name the scope (rule_id, stack_pattern) but
    never the reason text
  - replicated_from_control flag controls UI delete affordance and
    drives clearReplicatedRows on demote/reanchor
  - Validators reused: validateStackPatternForRedos for glob safety,
    sanitizeForLog for log fragments

SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.

Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.

Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.

* fix(security): reap orphaned trivy tmp dirs at startup

When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.

* perf(security): emit per-batch summary for scanAllNodeImages

Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.

* perf(security): cap SARIF export at 5000 findings per type

Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.

A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.

* docs(env): document TRIVY_BIN host-binary override

The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).

* test(security): cover scanComposeStack failure modes

Two new cases drive the existing try/catch through real failure
paths:
  - Malformed Trivy stdout: row flips to status='failed' with the
    parser error preserved on `error`.
  - execFile rejection: row flips to status='failed' with a string
    error message.

Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.

* test(e2e): security scanner + misconfig acknowledgement flow

Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
  - Trivy availability gate (skips suite when binary absent so CI
    without Trivy can opt out via E2E_SKIP_TRIVY=1)
  - Stack config scan completes and records misconfig findings
  - Concurrent stack scan returns 409 from the dedup gate
  - Misconfig ack POST creates and lists on Settings
  - Duplicate (rule_id, stack_pattern) returns 409
  - Malformed rule_id (shell metacharacters) returns 400
  - Misconfigs tab renders against a real stack scan

Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.

* docs(features): add misconfig acknowledgement workflow and SARIF cap

Refreshes vulnerability-scanning.mdx with:
  - Misconfig acknowledgements section covering the per-row dialog,
    Settings panel, scope/matching rules, and SARIF emission
  - Tier table row for the new feature
  - SARIF section note on the 5000 row-per-type cap and the
    properties.truncated marker for partial exports
  - Troubleshooting entries: SARIF cap, hidden Acknowledge button,
    findings resurfacing after delete, Trivy DB phone-home, and
    409 on concurrent compose-stack scans

* fix(ci): clear backend lint and CodeQL alerts

- Remove the dead fetchAllPages helper in routes/security.ts. It lost
  its callers when the SARIF endpoint switched to direct paged reads
  for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
  paths under os.tmpdir() with predictable names tripped CodeQL's
  js/insecure-temporary-file rule (high severity), which warns about
  symlink-pre-creation attacks even in test code. mkdtempSync appends
  a process-random suffix and creates the dir atomically; the
  sencho-trivy- prefix is preserved so the production sweep still
  matches the test fixtures.
2026-05-07 19:23:11 -04:00

360 lines
13 KiB
TypeScript

/**
* Route-level tests for /api/security/misconfig-acks CRUD.
*
* Mirrors suppression-routes.test.ts: auth gating, admin-only writes, replica
* rejection, rule_id format validation, UNIQUE conflict, audit-log entries
* (without leaking the reason field), read-time enrichment on
* /scans/:id/misconfigs.
*/
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}`;
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(() => {
const db = DatabaseService.getInstance();
db.getMisconfigAcknowledgements().forEach((a) => db.deleteMisconfigAcknowledgement(a.id));
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('control');
vi.spyOn(FleetSyncService.getInstance(), 'pushResourceAsync').mockImplementation(() => {});
});
describe('GET /api/security/misconfig-acks', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/security/misconfig-acks');
expect(res.status).toBe(401);
});
it('is accessible on community tier (mirrors CVE suppressions)', async () => {
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
const res = await request(app).get('/api/security/misconfig-acks').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body.code).not.toBe('PAID_REQUIRED');
});
it('returns an empty list when no acks exist', async () => {
const res = await request(app).get('/api/security/misconfig-acks').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.createMisconfigAcknowledgement({
rule_id: 'DS001',
stack_pattern: null,
reason: 'still active',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: Date.now() + 60_000,
replicated_from_control: 0,
});
db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_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/misconfig-acks').set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(res.body).toHaveLength(2);
const byRule = Object.fromEntries(
res.body.map((a: { rule_id: string; active: boolean }) => [a.rule_id, a.active]),
);
expect(byRule['DS001']).toBe(true);
expect(byRule['DS002']).toBe(false);
});
});
describe('POST /api/security/misconfig-acks', () => {
const validBody = {
rule_id: 'DS002',
stack_pattern: 'traefik-*',
reason: 'Traefik legitimately needs root for binding privileged ports.',
};
it('rejects unauthenticated callers with 401', async () => {
const res = await request(app).post('/api/security/misconfig-acks').send(validBody);
expect(res.status).toBe(401);
});
it('rejects non-admin users with 403', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', viewerAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
});
it('rejects writes from a replica with 403', async () => {
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(403);
});
it('rejects an empty rule_id', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, rule_id: '' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/rule_id/);
});
it('rejects rule_id with shell metacharacters', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, rule_id: 'DS002; rm -rf /' });
expect(res.status).toBe(400);
});
it('accepts the AVD long-form rule id', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, rule_id: 'AVD-DS-0002' });
expect(res.status).toBe(201);
expect(res.body.rule_id).toBe('AVD-DS-0002');
});
it('rejects empty reason', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, reason: ' ' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/reason/);
});
it('rejects an over-length stack_pattern', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, stack_pattern: 'a'.repeat(301) });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/stack_pattern/);
});
it('rejects redos-prone wildcard runs in stack_pattern', async () => {
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send({ ...validBody, stack_pattern: '****a' });
expect(res.status).toBe(400);
});
it('creates an ack and pushes the fleet resource', async () => {
const pushSpy = vi.spyOn(FleetSyncService.getInstance(), 'pushResourceAsync')
.mockImplementation(() => {});
const res = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(res.status).toBe(201);
expect(res.body.rule_id).toBe('DS002');
expect(res.body.stack_pattern).toBe('traefik-*');
expect(res.body.replicated_from_control).toBe(0);
expect(pushSpy).toHaveBeenCalledWith('misconfig_acknowledgements');
});
it('rejects a duplicate ack on the same (rule_id, stack_pattern) with 409', async () => {
const first = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(first.status).toBe(201);
const second = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(validBody);
expect(second.status).toBe(409);
});
it('rejects a duplicate fleet-wide ack (null stack_pattern) with 409', async () => {
// The UNIQUE index uses COALESCE(stack_pattern, ''), so two fleet-wide
// acks for the same rule must collide as if both were the empty string.
const fleetWide = { rule_id: 'DS099', reason: 'fleet-wide accept' };
const first = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(fleetWide);
expect(first.status).toBe(201);
expect(first.body.stack_pattern).toBeNull();
const second = await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(fleetWide);
expect(second.status).toBe(409);
});
it('writes an audit log entry that names the scope but not the reason', async () => {
await request(app)
.post('/api/security/misconfig-acks')
.set('Authorization', adminAuthHeader)
.send(validBody);
const logs = DatabaseService.getInstance().getAuditLogs({ limit: 5 });
const entry = logs.entries.find((l) => l.summary.startsWith('misconfig_ack.create'));
expect(entry).toBeDefined();
expect(entry!.summary).toMatch(/DS002/);
expect(entry!.summary).toMatch(/stack=traefik-\*/);
// Reason text is private; the audit log must not echo it.
expect(entry!.summary.toLowerCase()).not.toContain('legitimately');
});
});
describe('PUT /api/security/misconfig-acks/:id', () => {
it('rejects updates from a replica with 403', async () => {
const db = DatabaseService.getInstance();
const ack = db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_pattern: null,
reason: 'r',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.put(`/api/security/misconfig-acks/${ack.id}`)
.set('Authorization', adminAuthHeader)
.send({ reason: 'updated' });
expect(res.status).toBe(403);
});
it('returns 404 for a missing id', async () => {
const res = await request(app)
.put('/api/security/misconfig-acks/9999')
.set('Authorization', adminAuthHeader)
.send({ reason: 'whatever' });
expect(res.status).toBe(404);
});
it('updates only provided fields and leaves rule_id immutable', async () => {
const db = DatabaseService.getInstance();
const ack = db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_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/misconfig-acks/${ack.id}`)
.set('Authorization', adminAuthHeader)
.send({ reason: 'updated reason', stack_pattern: 'web-*' });
expect(res.status).toBe(200);
expect(res.body.rule_id).toBe('DS002');
expect(res.body.reason).toBe('updated reason');
expect(res.body.stack_pattern).toBe('web-*');
});
it('audit-log update entry names the changed fields but not their values', async () => {
const db = DatabaseService.getInstance();
const ack = db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_pattern: null,
reason: 'r',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
await request(app)
.put(`/api/security/misconfig-acks/${ack.id}`)
.set('Authorization', adminAuthHeader)
.send({ reason: 'this is super secret', expires_at: Date.now() + 1000 });
const logs = DatabaseService.getInstance().getAuditLogs({ limit: 5 });
const entry = logs.entries.find((l) => l.summary.startsWith('misconfig_ack.update'));
expect(entry).toBeDefined();
expect(entry!.summary).toMatch(/fields=\[reason,expires_at\]/);
expect(entry!.summary).not.toContain('super secret');
});
});
describe('DELETE /api/security/misconfig-acks/:id', () => {
it('rejects deletes from a replica with 403', async () => {
const db = DatabaseService.getInstance();
const ack = db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_pattern: null,
reason: 'r',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('replica');
const res = await request(app)
.delete(`/api/security/misconfig-acks/${ack.id}`)
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(403);
});
it('removes the row and writes an audit entry naming the scope', async () => {
const db = DatabaseService.getInstance();
const ack = db.createMisconfigAcknowledgement({
rule_id: 'DS002',
stack_pattern: 'traefik',
reason: 'r',
created_by: TEST_USERNAME,
created_at: Date.now(),
expires_at: null,
replicated_from_control: 0,
});
const res = await request(app)
.delete(`/api/security/misconfig-acks/${ack.id}`)
.set('Authorization', adminAuthHeader);
expect(res.status).toBe(200);
expect(db.getMisconfigAcknowledgement(ack.id)).toBeNull();
const logs = db.getAuditLogs({ limit: 5 });
const entry = logs.entries.find((l) => l.summary.startsWith('misconfig_ack.delete'));
expect(entry).toBeDefined();
expect(entry!.summary).toMatch(/DS002/);
expect(entry!.summary).toMatch(/stack=traefik/);
});
});