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.
This commit is contained in:
Anso
2026-05-07 19:23:11 -04:00
committed by GitHub
parent 4b1de35dda
commit 3b650523c1
20 changed files with 2376 additions and 162 deletions
@@ -0,0 +1,219 @@
/**
* Unit tests for the read-time misconfig acknowledgement filter.
*
* Mirrors the structure of suppression-filter.test.ts. The matching dimension
* is `rule_id` plus an optional `stack_pattern` glob; ack reasons must never
* be reported as a separate failure mode.
*/
import { describe, it, expect } from 'vitest';
import {
applyMisconfigAcknowledgements,
findMisconfigAcknowledgement,
} from '../utils/misconfig-ack-filter';
import type { MisconfigAcknowledgement } from '../services/DatabaseService';
const NOW = 1_700_000_000_000;
function makeAck(overrides: Partial<MisconfigAcknowledgement> = {}): MisconfigAcknowledgement {
return {
id: 1,
rule_id: 'DS002',
stack_pattern: null,
reason: 'traefik legitimately needs root',
created_by: 'admin',
created_at: NOW - 1000,
expires_at: null,
replicated_from_control: 0,
...overrides,
};
}
describe('findMisconfigAcknowledgement', () => {
it('returns null when no ack exists for the rule', () => {
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS099' },
'web',
[makeAck({ rule_id: 'DS002' })],
NOW,
);
expect(match).toBeNull();
});
it('matches a fleet-wide ack (null stack_pattern)', () => {
const a = makeAck({ id: 42 });
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'web',
[a],
NOW,
);
expect(match?.id).toBe(42);
});
it('matches a stack-pinned ack against the exact stack name', () => {
const a = makeAck({ id: 7, stack_pattern: 'traefik' });
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'traefik',
[a],
NOW,
);
expect(match?.id).toBe(7);
});
it('matches a stack-pinned ack with a glob', () => {
const a = makeAck({ id: 9, stack_pattern: 'traefik-*' });
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'traefik-prod',
[a],
NOW,
);
expect(match?.id).toBe(9);
});
it('does not match a stack-pinned ack against a different stack', () => {
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'web',
[makeAck({ stack_pattern: 'traefik' })],
NOW,
);
expect(match).toBeNull();
});
it('does not match a stack-pinned ack against a null stack context (image scan)', () => {
// Image scans have no stack_context. A stack-scoped ack should not
// bleed into image-scan results.
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
null,
[makeAck({ stack_pattern: 'traefik' })],
NOW,
);
expect(match).toBeNull();
});
it('matches a fleet-wide ack against a null stack context', () => {
const a = makeAck({ stack_pattern: null });
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
null,
[a],
NOW,
);
expect(match).toBeTruthy();
});
it('ignores expired acks', () => {
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'web',
[makeAck({ expires_at: NOW - 1 })],
NOW,
);
expect(match).toBeNull();
});
it('matches non-expired acks (expires_at strictly in the future)', () => {
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'web',
[makeAck({ expires_at: NOW + 1 })],
NOW,
);
expect(match).toBeTruthy();
});
it('prefers a stack-pinned ack over a fleet-wide ack', () => {
const fleetWide = makeAck({ id: 1, stack_pattern: null });
const pinned = makeAck({ id: 2, stack_pattern: 'web' });
const match = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'web',
[fleetWide, pinned],
NOW,
);
expect(match?.id).toBe(2);
});
it('escapes regex special chars in stack_pattern before glob expansion', () => {
// A stack named "v1.0" should NOT be matched by pattern "v1.0" because
// the dot in the pattern is treated literally, not as ".any char". This
// confirms regex escape happens before * gets expanded to .*.
const a = makeAck({ stack_pattern: 'v1.0' });
const exact = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'v1.0',
[a],
NOW,
);
expect(exact?.id).toBe(1);
const cheating = findMisconfigAcknowledgement(
{ rule_id: 'DS002' },
'v1X0',
[a],
NOW,
);
expect(cheating).toBeNull();
});
});
describe('applyMisconfigAcknowledgements', () => {
it('returns an empty array unchanged', () => {
expect(applyMisconfigAcknowledgements([], 'web', [makeAck()], NOW)).toEqual([]);
});
it('marks matched findings as acknowledged with id and reason', () => {
const findings = [
{ rule_id: 'DS002', target: 'docker-compose.yml' },
{ rule_id: 'DS099', target: 'docker-compose.yml' },
];
const out = applyMisconfigAcknowledgements(
findings,
'web',
[makeAck({ id: 11, reason: 'accepted by sec team' })],
NOW,
);
expect(out[0].acknowledged).toBe(true);
expect(out[0].acknowledgement_id).toBe(11);
expect(out[0].acknowledgement_reason).toBe('accepted by sec team');
expect(out[1].acknowledged).toBe(false);
});
it('does not mutate inputs', () => {
const findings = [{ rule_id: 'DS002', target: 'docker-compose.yml' }];
const acks = [makeAck()];
const out = applyMisconfigAcknowledgements(findings, 'web', acks, NOW);
expect(out[0]).not.toBe(findings[0]);
expect((findings[0] as Record<string, unknown>).acknowledged).toBeUndefined();
});
it('amortises bucketing across many findings (perf smoke test)', () => {
// 5000 acks across 200 unique rules x ~25 each, then 2000 findings.
// Each finding's lookup must be O(matching-rule-acks), not O(all-acks).
const acks: MisconfigAcknowledgement[] = [];
for (let i = 0; i < 5000; i++) {
const ruleIdx = i % 200;
acks.push(makeAck({
id: i + 1,
rule_id: `DS${String(ruleIdx).padStart(3, '0')}`,
stack_pattern: i % 5 === 0 ? null : `stack-${i % 50}`,
}));
}
const findings: Array<{ rule_id: string; target: string }> = [];
for (let j = 0; j < 2000; j++) {
findings.push({
rule_id: `DS${String(j % 250).padStart(3, '0')}`,
target: 'docker-compose.yml',
});
}
const t0 = Date.now();
const out = applyMisconfigAcknowledgements(findings, 'stack-3', acks, NOW);
const elapsed = Date.now() - t0;
// Generous bound; the real win is amortised bucketing, not raw speed.
expect(elapsed).toBeLessThan(1500);
expect(out.length).toBe(findings.length);
});
});
@@ -0,0 +1,359 @@
/**
* 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/);
});
});
@@ -15,6 +15,11 @@ interface ExecFileCall {
const execFileCalls: ExecFileCall[] = [];
let nextTrivyStdout = JSON.stringify({ Results: [] });
// Tests can install a Promise to gate the next execFile resolution. The
// dedup tests use this to keep the first scan in flight while issuing a
// second concurrent call, so the in-progress flag is observable.
let pendingExecGate: Promise<void> | null = null;
let nextExecShouldFail = false;
vi.mock('child_process', () => {
// TrivyService wraps this with `promisify(execFile)` at module load. The
@@ -24,9 +29,14 @@ vi.mock('child_process', () => {
const execFile = () => undefined;
(execFile as unknown as Record<symbol, unknown>)[
Symbol.for('nodejs.util.promisify.custom')
] = (file: string, args: string[]) => {
] = async (file: string, args: string[]) => {
execFileCalls.push({ file, args });
return Promise.resolve({ stdout: nextTrivyStdout, stderr: '' });
if (pendingExecGate) await pendingExecGate;
if (nextExecShouldFail) {
nextExecShouldFail = false;
throw new Error('simulated trivy failure');
}
return { stdout: nextTrivyStdout, stderr: '' };
};
return { execFile };
});
@@ -223,3 +233,113 @@ describe('TrivyService.scanComposeStack arg vector', () => {
expect(misconfigInserts[0].count).toBe(3);
});
});
describe('TrivyService.scanComposeStack dedup', () => {
beforeEach(() => {
execFileCalls.length = 0;
createdScans.length = 0;
updateCalls.length = 0;
misconfigInserts.length = 0;
nextTrivyStdout = JSON.stringify({ Results: [] });
pendingExecGate = null;
nextExecShouldFail = false;
forceBinary(TrivyService.getInstance());
});
it('reports isScanningStack=false before any scan starts', () => {
expect(TrivyService.getInstance().isScanningStack(1, 'unscanned')).toBe(false);
});
it('rejects a concurrent scan of the same stack while the first is in flight', async () => {
let release: (() => void) | null = null;
pendingExecGate = new Promise<void>((resolve) => {
release = resolve;
});
const first = TrivyService.getInstance().scanComposeStack(1, 'gated-stack', 'manual');
// Yield twice so the inner async setup (path resolve + dedup add) runs.
await Promise.resolve();
await Promise.resolve();
expect(TrivyService.getInstance().isScanningStack(1, 'gated-stack')).toBe(true);
await expect(
TrivyService.getInstance().scanComposeStack(1, 'gated-stack', 'manual'),
).rejects.toThrow(/Already scanning this stack/);
release!();
await first;
expect(TrivyService.getInstance().isScanningStack(1, 'gated-stack')).toBe(false);
// Only one trivy invocation should have happened — the second was
// rejected before it ever reached execFile.
expect(execFileCalls.length).toBe(1);
});
it('allows different stacks on the same node to scan in parallel', async () => {
const r1 = TrivyService.getInstance().scanComposeStack(1, 'stack-a', 'manual');
const r2 = TrivyService.getInstance().scanComposeStack(1, 'stack-b', 'manual');
await Promise.all([r1, r2]);
expect(execFileCalls.length).toBe(2);
expect(TrivyService.getInstance().isScanningStack(1, 'stack-a')).toBe(false);
expect(TrivyService.getInstance().isScanningStack(1, 'stack-b')).toBe(false);
});
it('allows the same stack to scan again on a different node', async () => {
const r1 = TrivyService.getInstance().scanComposeStack(1, 'shared-name', 'manual');
const r2 = TrivyService.getInstance().scanComposeStack(2, 'shared-name', 'manual');
await Promise.all([r1, r2]);
expect(execFileCalls.length).toBe(2);
});
it('releases the dedup key after a failed scan so retry works', async () => {
nextExecShouldFail = true;
await expect(
TrivyService.getInstance().scanComposeStack(1, 'fail-stack', 'manual'),
).rejects.toThrow(/simulated trivy failure/);
expect(TrivyService.getInstance().isScanningStack(1, 'fail-stack')).toBe(false);
// Subsequent scan after release should succeed.
await TrivyService.getInstance().scanComposeStack(1, 'fail-stack', 'manual');
expect(execFileCalls.length).toBe(2);
});
});
describe('TrivyService.scanComposeStack failure modes', () => {
beforeEach(() => {
execFileCalls.length = 0;
createdScans.length = 0;
updateCalls.length = 0;
misconfigInserts.length = 0;
nextTrivyStdout = JSON.stringify({ Results: [] });
pendingExecGate = null;
nextExecShouldFail = false;
forceBinary(TrivyService.getInstance());
});
it('flips the scan row to failed when Trivy stdout is malformed JSON', async () => {
// parseTrivyOutput throws "Malformed Trivy output: ..." on bad JSON.
nextTrivyStdout = '{ this is not valid json';
await expect(
TrivyService.getInstance().scanComposeStack(1, 'broken-stack', 'manual'),
).rejects.toThrow(/Malformed Trivy output/);
const failedUpdate = updateCalls.find((u) => u.patch.status === 'failed');
expect(failedUpdate).toBeDefined();
expect(failedUpdate?.patch.error).toMatch(/Malformed Trivy output/);
expect(updateCalls.some((u) => u.patch.status === 'completed')).toBe(false);
});
it('flips the scan row to failed when Trivy throws ETIMEDOUT', async () => {
// Simulate timeout the way util.promisify(execFile) surfaces it: a
// rejected Promise. We hijack the gate by failing instead of resolving.
nextExecShouldFail = true;
await expect(
TrivyService.getInstance().scanComposeStack(1, 'slow-stack', 'manual'),
).rejects.toThrow();
const failedUpdate = updateCalls.find((u) => u.patch.status === 'failed');
expect(failedUpdate).toBeDefined();
expect(typeof failedUpdate?.patch.error).toBe('string');
});
});
@@ -0,0 +1,84 @@
/**
* Pins boot-time cleanup of orphaned `sencho-trivy-*` tmp dirs.
*
* `buildEnv` writes a per-scan DOCKER_CONFIG dir under os.tmpdir(). Healthy
* scans clean up via a finally block; a process crash mid-scan leaks the dir.
* `sweepStaleTrivyTempDirs` runs at startup and removes any prefix-matching
* dir older than 1 hour, leaving fresh dirs alone.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import os from 'os';
import path from 'path';
import fs from 'fs';
import { sweepStaleTrivyTempDirs } from '../services/TrivyService';
const PREFIX = 'sencho-trivy-';
const ONE_HOUR_MS = 60 * 60 * 1000;
// `mkdtempSync` appends a process-random suffix to the prefix and creates the
// directory atomically. Required to avoid the predictable-tmp-path symlink
// attack flagged by CodeQL's `js/insecure-temporary-file` rule.
function makeTempDir(label: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${PREFIX}${label}-`));
fs.writeFileSync(path.join(dir, 'config.json'), '{}');
return dir;
}
function makeNonPrefixedTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'not-trivy-'));
}
function backdate(dir: string, ageMs: number): void {
const t = Date.now() - ageMs;
fs.utimesSync(dir, t / 1000, t / 1000);
}
describe('sweepStaleTrivyTempDirs', () => {
const created: string[] = [];
beforeEach(() => {
created.length = 0;
});
afterEach(() => {
for (const d of created) {
try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* noop */ }
}
});
it('removes a sencho-trivy-* dir whose mtime is older than 1 hour', async () => {
const stale = makeTempDir('stale');
created.push(stale);
backdate(stale, ONE_HOUR_MS + 5_000);
await sweepStaleTrivyTempDirs();
expect(fs.existsSync(stale)).toBe(false);
});
it('leaves fresh sencho-trivy-* dirs untouched', async () => {
const fresh = makeTempDir('fresh');
created.push(fresh);
// Default mtime is now, well within the 1-hour cutoff.
await sweepStaleTrivyTempDirs();
expect(fs.existsSync(fresh)).toBe(true);
});
it('ignores dirs that do not match the prefix', async () => {
const other = makeNonPrefixedTempDir();
backdate(other, 2 * ONE_HOUR_MS);
created.push(other);
await sweepStaleTrivyTempDirs();
expect(fs.existsSync(other)).toBe(true);
});
it('returns without throwing when the tmp dir is unreadable', async () => {
// We cannot reliably make os.tmpdir unreadable in a portable test, so
// assert that the call completes without error on a normal system.
await expect(sweepStaleTrivyTempDirs()).resolves.toBeUndefined();
});
});