mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user