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();
});
});
+4 -1
View File
@@ -7,7 +7,7 @@ import { MonitorService } from '../services/MonitorService';
import { AutoHealService } from '../services/AutoHealService';
import { FleetSyncRetryService } from '../services/FleetSyncRetryService';
import { DockerEventManager } from '../services/DockerEventManager';
import TrivyService from '../services/TrivyService';
import TrivyService, { sweepStaleTrivyTempDirs } from '../services/TrivyService';
import { ImageUpdateService } from '../services/ImageUpdateService';
import { SchedulerService } from '../services/SchedulerService';
import { MfaService } from '../services/MfaService';
@@ -61,6 +61,9 @@ export async function startServer(server: Server): Promise<void> {
sweepStaleGitTempDirs().catch((err) => {
console.warn('[GitSource] Temp dir sweep failed:', (err as Error).message);
});
sweepStaleTrivyTempDirs().catch((err) => {
console.warn('[Trivy] Temp dir sweep failed:', (err as Error).message);
});
const isPilotAgent = process.env.SENCHO_MODE === 'pilot';
const listenHost = isPilotAgent ? '127.0.0.1' : undefined;
+29 -2
View File
@@ -93,6 +93,24 @@ function validateCveSuppressionRow(row: unknown): string | null {
return null;
}
function validateMisconfigAcknowledgementRow(row: unknown): string | null {
if (!row || typeof row !== 'object') return 'row must be an object';
const r = row as Record<string, unknown>;
if (typeof r.rule_id !== 'string' || r.rule_id.length === 0 || r.rule_id.length > 200) return 'rule_id must be a non-empty string up to 200 chars';
if (r.stack_pattern !== null && typeof r.stack_pattern !== 'string') return 'stack_pattern must be a string or null';
if (typeof r.stack_pattern === 'string') {
if (r.stack_pattern.length > 300) return 'stack_pattern is too long';
const patternError = validateStackPatternForRedos(r.stack_pattern);
if (patternError) return patternError;
}
if (typeof r.reason !== 'string') return 'reason must be a string';
if (r.reason.length > 2000) return 'reason is too long';
if (typeof r.created_by !== 'string' || r.created_by.length > 200) return 'created_by must be a string';
if (typeof r.created_at !== 'number') return 'created_at must be a number';
if (r.expires_at !== null && typeof r.expires_at !== 'number') return 'expires_at must be a number or null';
return null;
}
interface FleetNodeOverview {
id: number;
name: string;
@@ -327,7 +345,11 @@ fleetRouter.get('/role', authMiddleware, (req: Request, res: Response): void =>
fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response): void => {
if (!requireNodeProxy(req, res)) return;
const resource = req.params.resource;
if (resource !== 'scan_policies' && resource !== 'cve_suppressions') {
if (
resource !== 'scan_policies'
&& resource !== 'cve_suppressions'
&& resource !== 'misconfig_acknowledgements'
) {
res.status(400).json({ error: `Unsupported sync resource: ${resource}` });
return;
}
@@ -353,7 +375,12 @@ fleetRouter.post('/sync/:resource', authMiddleware, (req: Request, res: Response
res.status(413).json({ error: `Too many rows (max ${MAX_SYNC_ROWS})` });
return;
}
const validator = resource === 'scan_policies' ? validateScanPolicyRow : validateCveSuppressionRow;
const validator =
resource === 'scan_policies'
? validateScanPolicyRow
: resource === 'cve_suppressions'
? validateCveSuppressionRow
: validateMisconfigAcknowledgementRow;
for (let i = 0; i < rows.length; i++) {
const err = validator(rows[i]);
if (err) {
+221 -24
View File
@@ -9,6 +9,7 @@ import { FleetSyncService } from '../services/FleetSyncService';
import { LicenseService } from '../services/LicenseService';
import { validateImageRef } from '../utils/image-ref';
import { applySuppressions } from '../utils/suppression-filter';
import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter';
import { generateSarif } from '../services/SarifExporter';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
@@ -18,6 +19,10 @@ import { validateStackPatternForRedos } from './fleet';
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
const CVE_ID_RE = /^(CVE-\d{4}-\d{4,}|GHSA-[\w-]{14,})$/;
// Trivy emits misconfig rule ids in two shapes that Sencho persists verbatim:
// short alpha-numeric codes (e.g. "DS002") and the AVD-prefixed long form
// (e.g. "AVD-DS-0002"). Allow either, plus underscores for forward-compat.
const MISCONFIG_RULE_RE = /^[A-Z0-9][A-Z0-9_-]{0,199}$/i;
// Strip control characters and cap length so an operator-supplied pkg or image
// pattern cannot inject a fake audit row by smuggling a newline plus a forged
@@ -41,9 +46,18 @@ function describeSuppressionScope(s: { cve_id: string; pkg_name: string | null;
return pinned.length > 0 ? `${s.cve_id} (${pinned.join(', ')})` : s.cve_id;
}
function recordSuppressionAudit(
// Misconfig ack scope summary mirrors the suppression variant. Reason is
// elided on purpose; rule_id and stack_pattern are non-sensitive.
function describeAckScope(a: { rule_id: string; stack_pattern: string | null }): string {
const pinned: string[] = [];
if (a.stack_pattern) pinned.push(`stack=${sanitiseScopeFragment(a.stack_pattern, 300)}`);
return pinned.length > 0 ? `${a.rule_id} (${pinned.join(', ')})` : a.rule_id;
}
function recordSecurityAudit(
req: Request,
res: Response,
prefix: 'cve_suppression' | 'misconfig_ack',
action: 'create' | 'update' | 'delete',
summary: string,
): void {
@@ -56,13 +70,31 @@ function recordSuppressionAudit(
status_code: res.statusCode,
node_id: null,
ip_address: req.ip || 'unknown',
summary: `cve_suppression.${action}: ${summary}`,
summary: `${prefix}.${action}: ${summary}`,
});
} catch (err) {
console.warn('[Security] Suppression audit log write failed:', getErrorMessage(err, 'unknown'));
console.warn('[Security] Audit log write failed:', getErrorMessage(err, 'unknown'));
}
}
function recordSuppressionAudit(
req: Request,
res: Response,
action: 'create' | 'update' | 'delete',
summary: string,
): void {
recordSecurityAudit(req, res, 'cve_suppression', action, summary);
}
function recordAckAudit(
req: Request,
res: Response,
action: 'create' | 'update' | 'delete',
summary: string,
): void {
recordSecurityAudit(req, res, 'misconfig_ack', action, summary);
}
function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | undefined | null {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.length === 0) return null;
@@ -81,21 +113,6 @@ function shapeScanForResponse(scan: VulnerabilityScan): Omit<VulnerabilityScan,
return { ...rest, policy_evaluation: parsePolicyEvaluation(policy_evaluation) };
}
function fetchAllPages<T>(
q: (opts: { limit?: number; offset?: number }) => { items: T[]; total: number },
): T[] {
const pageSize = 1000;
const collected: T[] = [];
let offset = 0;
while (true) {
const page = q({ limit: pageSize, offset });
collected.push(...page.items);
if (collected.length >= page.total || page.items.length === 0) break;
offset += page.items.length;
}
return collected;
}
export const securityRouter = Router();
securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Response) => {
@@ -245,6 +262,9 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
res.status(400).json({ error: 'Invalid stack name' }); return;
}
if (svc.isScanningStack(req.nodeId, stackName)) {
res.status(409).json({ error: 'Already scanning this stack' }); return;
}
try {
const scan = await svc.scanComposeStack(req.nodeId, stackName, 'manual');
res.status(201).json(scan);
@@ -253,6 +273,9 @@ securityRouter.post('/scan/stack', authMiddleware, async (req: Request, res: Res
if (message === 'Invalid stack path' || message.startsWith('No compose file found')) {
res.status(404).json({ error: message }); return;
}
if (message === 'Already scanning this stack') {
res.status(409).json({ error: message }); return;
}
console.error('[Security] Stack config scan failed:', error);
res.status(500).json({ error: message || 'Failed to scan stack' });
}
@@ -372,7 +395,10 @@ securityRouter.get(
}
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
res.json(db.getMisconfigFindings(scanId, { severity, limit, offset }));
const result = db.getMisconfigFindings(scanId, { severity, limit, offset });
const acks = db.getMisconfigAcknowledgements();
const enriched = applyMisconfigAcknowledgements(result.items, scan.stack_context, acks);
res.json({ ...result, items: enriched });
},
);
@@ -436,11 +462,42 @@ securityRouter.get(
res.status(409).json({ error: 'Scan not complete' }); return;
}
try {
const details = fetchAllPages((opts) => db.getVulnerabilityDetails(scanId, opts));
const secrets = fetchAllPages((opts) => db.getSecretFindings(scanId, opts));
const misconfigs = fetchAllPages((opts) => db.getMisconfigFindings(scanId, opts));
const suppressed = applySuppressions(details, scan.image_ref, db.getCveSuppressions());
const sarif = generateSarif(scan, suppressed, secrets, misconfigs);
// Hard cap to bound memory and serialization on pathological scans.
// 5000 findings per type comfortably covers realistic scans; if any
// type trips the cap we surface `truncated` in the SARIF metadata so
// tooling can flag the export as partial.
const SARIF_ROW_LIMIT = 5000;
const detailsPage = db.getVulnerabilityDetails(scanId, { limit: SARIF_ROW_LIMIT });
const secretsPage = db.getSecretFindings(scanId, { limit: SARIF_ROW_LIMIT });
const misconfigsPage = db.getMisconfigFindings(scanId, { limit: SARIF_ROW_LIMIT });
const truncated =
detailsPage.total > SARIF_ROW_LIMIT
|| secretsPage.total > SARIF_ROW_LIMIT
|| misconfigsPage.total > SARIF_ROW_LIMIT;
if (truncated) {
console.warn(
`[Security] SARIF export truncated for scanId=${scanId}: `
+ `vulns=${detailsPage.total}, secrets=${secretsPage.total}, misconfigs=${misconfigsPage.total}, cap=${SARIF_ROW_LIMIT}`,
);
}
const suppressed = applySuppressions(detailsPage.items, scan.image_ref, db.getCveSuppressions());
const acknowledged = applyMisconfigAcknowledgements(
misconfigsPage.items,
scan.stack_context,
db.getMisconfigAcknowledgements(),
);
const sarif = generateSarif(scan, suppressed, secretsPage.items, acknowledged);
if (truncated) {
sarif.runs[0].properties = {
truncated: true,
row_limit: SARIF_ROW_LIMIT,
totals: {
vulnerabilities: detailsPage.total,
secrets: secretsPage.total,
misconfigs: misconfigsPage.total,
},
};
}
const safeName = scan.image_ref.replace(/[^a-zA-Z0-9._-]/g, '_') || `scan-${scanId}`;
res.setHeader('Content-Type', 'application/sarif+json');
res.setHeader('Content-Disposition', `attachment; filename="${safeName}.sarif.json"`);
@@ -682,6 +739,146 @@ securityRouter.delete('/suppressions/:id', authMiddleware, (req: Request, res: R
);
});
// --- Misconfig Acknowledgements ---
securityRouter.get('/misconfig-acks', authMiddleware, (req: Request, res: Response): void => {
const now = Date.now();
const rows = DatabaseService.getInstance().getMisconfigAcknowledgements().map((a) => ({
...a,
active: a.expires_at === null || a.expires_at > now,
}));
res.json(rows);
});
securityRouter.post('/misconfig-acks', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const body = req.body ?? {};
const ruleId = typeof body.rule_id === 'string' ? body.rule_id.trim() : '';
if (!MISCONFIG_RULE_RE.test(ruleId)) {
res.status(400).json({ error: 'rule_id must be a non-empty alpha-numeric identifier (e.g. "DS002" or "AVD-DS-0002")' });
return;
}
const stackPatternRaw = body.stack_pattern == null || body.stack_pattern === ''
? null
: String(body.stack_pattern).trim();
if (stackPatternRaw !== null) {
if (stackPatternRaw.length > 300) {
res.status(400).json({ error: 'stack_pattern is too long' }); return;
}
const patternError = validateStackPatternForRedos(stackPatternRaw);
if (patternError) {
res.status(400).json({ error: patternError }); return;
}
}
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) {
res.status(400).json({ error: 'reason is required' }); return;
}
if (reason.length > 2000) {
res.status(400).json({ error: 'reason is too long' }); return;
}
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
try {
const ack = DatabaseService.getInstance().createMisconfigAcknowledgement({
rule_id: ruleId,
stack_pattern: stackPatternRaw,
reason,
created_by: req.user?.username || 'unknown',
created_at: Date.now(),
expires_at: expiresAt,
replicated_from_control: 0,
});
FleetSyncService.getInstance().pushResourceAsync('misconfig_acknowledgements');
res.status(201).json(ack);
recordAckAudit(req, res, 'create', describeAckScope(ack));
} catch (error) {
const message = (error as Error).message || '';
if (message.includes('UNIQUE')) {
res.status(409).json({ error: 'An acknowledgement already exists for this rule and stack pattern.' });
return;
}
console.error('[Security] Failed to create misconfig acknowledgement:', error);
res.status(500).json({ error: 'Failed to create acknowledgement' });
}
});
securityRouter.put('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid acknowledgement id' }); return;
}
const body = req.body ?? {};
const updates: Partial<{ reason: string; stack_pattern: string | null; expires_at: number | null }> = {};
if (body.reason !== undefined) {
const reason = typeof body.reason === 'string' ? body.reason.trim() : '';
if (!reason) { res.status(400).json({ error: 'reason is required' }); return; }
if (reason.length > 2000) { res.status(400).json({ error: 'reason is too long' }); return; }
updates.reason = reason;
}
if (body.stack_pattern !== undefined) {
const pattern = body.stack_pattern == null || body.stack_pattern === ''
? null
: String(body.stack_pattern).trim();
if (pattern !== null) {
if (pattern.length > 300) {
res.status(400).json({ error: 'stack_pattern is too long' }); return;
}
const patternError = validateStackPatternForRedos(pattern);
if (patternError) {
res.status(400).json({ error: patternError }); return;
}
}
updates.stack_pattern = pattern;
}
if (body.expires_at !== undefined) {
const expiresAt = body.expires_at == null ? null : Number(body.expires_at);
if (expiresAt !== null && !Number.isFinite(expiresAt)) {
res.status(400).json({ error: 'expires_at must be a timestamp or null' }); return;
}
updates.expires_at = expiresAt;
}
const ack = DatabaseService.getInstance().updateMisconfigAcknowledgement(id, updates);
if (!ack) {
res.status(404).json({ error: 'Acknowledgement not found' }); return;
}
FleetSyncService.getInstance().pushResourceAsync('misconfig_acknowledgements');
res.json(ack);
const changed = Object.keys(updates);
recordAckAudit(
req,
res,
'update',
`id=${id} ${describeAckScope(ack)} fields=[${changed.join(',')}]`,
);
});
securityRouter.delete('/misconfig-acks/:id', authMiddleware, (req: Request, res: Response): void => {
if (!requireAdmin(req, res)) return;
if (blockIfReplica(res, 'misconfig acknowledgements')) return;
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
res.status(400).json({ error: 'Invalid acknowledgement id' }); return;
}
const db = DatabaseService.getInstance();
// Snapshot before delete so the audit summary names the rule rather than the bare id.
const existing = db.getMisconfigAcknowledgement(id);
db.deleteMisconfigAcknowledgement(id);
FleetSyncService.getInstance().pushResourceAsync('misconfig_acknowledgements');
res.json({ success: true });
recordAckAudit(
req,
res,
'delete',
existing ? `id=${id} ${describeAckScope(existing)}` : `id=${id} (not found)`,
);
});
securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): void => {
const scanId1 = Number(req.query.scanId1);
const scanId2 = Number(req.query.scanId2);
+134 -4
View File
@@ -562,6 +562,22 @@ export interface CveSuppression {
replicated_from_control: number;
}
/**
* Operator-acknowledged misconfiguration finding. Acknowledgements match by
* rule_id and an optional stack_pattern glob, are applied at read time, and
* never modify the persisted finding row. Mirrors `cve_suppressions` shape.
*/
export interface MisconfigAcknowledgement {
id: number;
rule_id: string;
stack_pattern: string | null;
reason: string;
created_by: string;
created_at: number;
expires_at: number | null;
replicated_from_control: number;
}
export interface ScanSummary {
image_ref: string;
highest_severity: VulnSeverity | null;
@@ -976,6 +992,21 @@ export class DatabaseService {
CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_suppressions_unique
ON cve_suppressions(cve_id, COALESCE(pkg_name, ''), COALESCE(image_pattern, ''));
CREATE TABLE IF NOT EXISTS misconfig_acknowledgements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id TEXT NOT NULL,
stack_pattern TEXT,
reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
replicated_from_control INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_misconfig_ack_rule ON misconfig_acknowledgements(rule_id);
CREATE INDEX IF NOT EXISTS idx_misconfig_ack_expires ON misconfig_acknowledgements(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_misconfig_ack_unique
ON misconfig_acknowledgements(rule_id, COALESCE(stack_pattern, ''));
CREATE TABLE IF NOT EXISTS stack_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
@@ -3907,6 +3938,104 @@ export class DatabaseService {
txn(rows);
}
// --- Misconfig Acknowledgements ---
public getMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
return this.db
.prepare('SELECT * FROM misconfig_acknowledgements ORDER BY rule_id, stack_pattern')
.all() as MisconfigAcknowledgement[];
}
/** Local-only acknowledgements; mirrors `getLocalCveSuppressions`. */
public getLocalMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
return this.db
.prepare('SELECT * FROM misconfig_acknowledgements WHERE replicated_from_control = 0 ORDER BY rule_id, stack_pattern')
.all() as MisconfigAcknowledgement[];
}
public getMisconfigAcknowledgement(id: number): MisconfigAcknowledgement | null {
return (
(this.db.prepare('SELECT * FROM misconfig_acknowledgements WHERE id = ?')
.get(id) as MisconfigAcknowledgement | undefined) ?? null
);
}
public createMisconfigAcknowledgement(
ack: Omit<MisconfigAcknowledgement, 'id'>,
): MisconfigAcknowledgement {
const result = this.db
.prepare(
`INSERT INTO misconfig_acknowledgements
(rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
ack.rule_id,
ack.stack_pattern,
ack.reason,
ack.created_by,
ack.created_at,
ack.expires_at,
ack.replicated_from_control ?? 0,
);
return { ...ack, id: result.lastInsertRowid as number };
}
public updateMisconfigAcknowledgement(
id: number,
updates: Partial<Pick<MisconfigAcknowledgement, 'reason' | 'stack_pattern' | 'expires_at'>>,
): MisconfigAcknowledgement | null {
const existing = this.getMisconfigAcknowledgement(id);
if (!existing) return null;
const ALLOWED = new Set(['reason', 'stack_pattern', 'expires_at']);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return existing;
values.push(id);
this.db
.prepare(`UPDATE misconfig_acknowledgements SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
return this.getMisconfigAcknowledgement(id);
}
public deleteMisconfigAcknowledgement(id: number): void {
this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE id = ?').run(id);
}
/**
* Replace all replicated misconfig acknowledgements in a single transaction.
* Preserves rows flagged as locally created on this instance.
*/
public replaceReplicatedMisconfigAcknowledgements(
rows: Array<Omit<MisconfigAcknowledgement, 'id'>>,
): void {
const deleteStmt = this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO misconfig_acknowledgements
(rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, 1)`,
);
const txn = this.db.transaction((items: Array<Omit<MisconfigAcknowledgement, 'id'>>) => {
deleteStmt.run();
for (const a of items) {
insertStmt.run(
a.rule_id,
a.stack_pattern,
a.reason,
a.created_by,
a.created_at,
a.expires_at,
);
}
});
txn(rows);
}
/**
* Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId`
* no longer exists in `scan_policies`. Used after replicated rows are
@@ -3926,15 +4055,16 @@ export class DatabaseService {
}
/**
* Atomically delete every replicated_from_control row from both
* scan_policies and cve_suppressions, then null out any orphaned
* policy_evaluation cache. Used by the demote endpoint and any future
* "drop replicated state" operation.
* Atomically delete every replicated_from_control row from scan_policies,
* cve_suppressions, and misconfig_acknowledgements, then null out any
* orphaned policy_evaluation cache. Used by the demote endpoint and any
* future "drop replicated state" operation.
*/
public clearReplicatedRows(): void {
this.transaction(() => {
this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run();
this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run();
this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1').run();
this.clearOrphanPolicyEvaluations();
});
}
+26 -3
View File
@@ -1,6 +1,6 @@
import axios, { AxiosError } from 'axios';
import { createHash } from 'crypto';
import { CveSuppression, DatabaseService, Node, ScanPolicy } from './DatabaseService';
import { CveSuppression, DatabaseService, MisconfigAcknowledgement, Node, ScanPolicy } from './DatabaseService';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import { isDebugEnabled } from '../utils/debug';
@@ -14,7 +14,11 @@ import {
export type { FleetResource };
export const FLEET_RESOURCES: readonly FleetResource[] = ['scan_policies', 'cve_suppressions'];
export const FLEET_RESOURCES: readonly FleetResource[] = [
'scan_policies',
'cve_suppressions',
'misconfig_acknowledgements',
];
export function isFleetResource(value: unknown): value is FleetResource {
return typeof value === 'string' && (FLEET_RESOURCES as readonly string[]).includes(value);
@@ -282,7 +286,9 @@ export class FleetSyncService {
*/
public applyIncomingSync(
resource: FleetResource,
rows: ScanPolicy[] | Array<Omit<CveSuppression, 'id'>>,
rows: ScanPolicy[]
| Array<Omit<CveSuppression, 'id'>>
| Array<Omit<MisconfigAcknowledgement, 'id'>>,
targetIdentity: string,
pushedAt?: number,
controlIdentity?: string,
@@ -336,6 +342,12 @@ export class FleetSyncService {
db.replaceReplicatedScanPolicies(rows as ScanPolicy[]);
} else if (resource === 'cve_suppressions') {
db.replaceReplicatedCveSuppressions(rows as Array<Omit<CveSuppression, 'id'>>);
} else if (resource === 'misconfig_acknowledgements') {
// Rows are shape-validated upstream by
// validateMisconfigAcknowledgementRow before this method runs,
// so a single declared-type assignment is honest.
const ackRows = rows as Array<Omit<MisconfigAcknowledgement, 'id'>>;
db.replaceReplicatedMisconfigAcknowledgements(ackRows);
}
// F4: persist an audit-log entry for the operator on the replica
// side. Without this, mirrored security-rule changes happen
@@ -372,6 +384,7 @@ export class FleetSyncService {
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('misconfig_acknowledgements'), '');
db.clearReplicatedRows();
});
FleetSyncService.cachedControlIdentity = null;
@@ -404,6 +417,7 @@ export class FleetSyncService {
db.setSystemState(SYNC_STATE_KEYS.fleetControlIdentity, '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('scan_policies'), '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('cve_suppressions'), '');
db.setSystemState(SYNC_STATE_KEYS.receivedPushedAt('misconfig_acknowledgements'), '');
db.clearReplicatedRows();
});
FleetSyncService.cachedControlIdentity = null;
@@ -507,6 +521,15 @@ export class FleetSyncService {
created_at: s.created_at,
expires_at: s.expires_at,
}));
} else if (resource === 'misconfig_acknowledgements') {
rows = db.getLocalMisconfigAcknowledgements().map((a) => ({
rule_id: a.rule_id,
stack_pattern: a.stack_pattern,
reason: a.reason,
created_by: a.created_by,
created_at: a.created_at,
expires_at: a.expires_at,
}));
} else {
return [];
}
+22 -2
View File
@@ -16,6 +16,7 @@ import type {
VulnSeverity,
} from './DatabaseService';
import type { SuppressionDecision } from '../utils/suppression-filter';
import type { MisconfigAcknowledgementDecision } from '../utils/misconfig-ack-filter';
export interface SarifSuppression {
kind: 'external';
@@ -63,10 +64,15 @@ export interface SarifDocument {
};
};
results: SarifResult[];
// SARIF 2.1.0 allows arbitrary properties on a run for tool-specific
// metadata. Sencho writes a truncation marker here when a scan
// exceeds the export row cap.
properties?: Record<string, unknown>;
}>;
}
type SuppressedVulnerability = VulnerabilityDetail & Partial<SuppressionDecision>;
type AcknowledgedMisconfig = MisconfigFinding & Partial<MisconfigAcknowledgementDecision>;
const SEVERITY_TO_LEVEL: Record<VulnSeverity, 'error' | 'warning' | 'note' | 'none'> = {
CRITICAL: 'error',
@@ -98,6 +104,19 @@ function toSuppressions(decision: Partial<SuppressionDecision>): SarifSuppressio
];
}
function toAckSuppressions(
decision: Partial<MisconfigAcknowledgementDecision>,
): SarifSuppression[] | undefined {
if (!decision.acknowledged) return undefined;
return [
{
kind: 'external',
status: 'accepted',
justification: decision.acknowledgement_reason?.trim() || 'Acknowledged in Sencho',
},
];
}
function vulnRule(detail: VulnerabilityDetail): SarifRule {
return {
id: detail.vulnerability_id,
@@ -191,7 +210,7 @@ function secretResult(finding: SecretFinding): SarifResult {
};
}
function misconfigResult(finding: MisconfigFinding): SarifResult {
function misconfigResult(finding: AcknowledgedMisconfig): SarifResult {
const parts = [finding.title || finding.rule_id];
if (finding.message) parts.push(finding.message);
if (finding.resolution) parts.push(`Fix: ${finding.resolution}`);
@@ -202,6 +221,7 @@ function misconfigResult(finding: MisconfigFinding): SarifResult {
locations: [
{ physicalLocation: { artifactLocation: { uri: finding.target } } },
],
suppressions: toAckSuppressions(finding),
properties: { 'security-severity': SEVERITY_TO_SCORE[finding.severity] },
};
}
@@ -210,7 +230,7 @@ export function generateSarif(
scan: VulnerabilityScan,
vulnerabilities: SuppressedVulnerability[],
secrets: SecretFinding[],
misconfigs: MisconfigFinding[],
misconfigs: AcknowledgedMisconfig[],
): SarifDocument {
const rules = new Map<string, SarifRule>();
for (const v of vulnerabilities) if (!rules.has(v.vulnerability_id)) rules.set(v.vulnerability_id, vulnRule(v));
+180 -121
View File
@@ -26,6 +26,43 @@ const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
const TRIVY_TEMP_DIR_PREFIX = 'sencho-trivy-';
const TRIVY_TEMP_DIR_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
/**
* Sweep leftover sencho-trivy-* temp dirs in the system tmp dir whose mtime
* is older than 1 hour. Runs once at service boot to clean up DOCKER_CONFIG
* dirs orphaned by a crashed scan process. Best-effort; swallows readdir or
* unlink failures so a quirky tmp dir cannot block startup.
*/
export async function sweepStaleTrivyTempDirs(): Promise<void> {
const tmp = os.tmpdir();
let entries: string[];
try {
entries = await fs.promises.readdir(tmp);
} catch {
return;
}
const cutoff = Date.now() - TRIVY_TEMP_DIR_MAX_AGE_MS;
let removed = 0;
for (const entry of entries) {
if (!entry.startsWith(TRIVY_TEMP_DIR_PREFIX)) continue;
const full = path.join(tmp, entry);
try {
const stat = await fs.promises.stat(full);
if (stat.mtimeMs < cutoff) {
await fs.promises.rm(full, { recursive: true, force: true });
removed++;
}
} catch {
/* race: dir already gone, or permissions; skip */
}
}
if (removed > 0) {
console.log(`[Trivy] Reaped ${removed} stale tmp dir(s) under ${tmp}`);
}
}
function diag(msg: string, ...args: unknown[]): void {
if (isDebugEnabled()) console.log(`[Trivy:diag] ${sanitizeForLog(msg)}`, ...args);
}
@@ -459,10 +496,18 @@ class TrivyService {
return `${nodeId}:${imageRef}`;
}
private stackScanKey(nodeId: number, stackName: string): string {
return `stack:${nodeId}:${stackName}`;
}
isScanning(nodeId: number, imageRef: string): boolean {
return this.scanningImages.has(this.scanKey(nodeId, imageRef));
}
isScanningStack(nodeId: number, stackName: string): boolean {
return this.scanningImages.has(this.stackScanKey(nodeId, stackName));
}
async scanImage(
imageRef: string,
nodeId: number,
@@ -835,129 +880,137 @@ class TrivyService {
if (!(await fsvc.hasComposeFile(resolved))) {
throw new Error(`No compose file found for stack: ${stackName}`);
}
const db = DatabaseService.getInstance();
const scanId = db.createVulnerabilityScan({
node_id: nodeId,
image_ref: `stack:${stackName}`,
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'config',
highest_severity: null,
os_info: null,
trivy_version: this.version,
scan_duration_ms: null,
triggered_by: triggeredBy,
status: 'in_progress',
error: null,
stack_context: stackName,
});
const startedAt = Date.now();
const dedupKey = this.stackScanKey(nodeId, stackName);
if (this.scanningImages.has(dedupKey)) {
throw new Error('Already scanning this stack');
}
this.scanningImages.add(dedupKey);
try {
const { env, cleanup } = await this.buildEnv();
try {
const args = ['config', '--format', 'json', '--quiet', resolved];
const { stdout } = await execFileAsync(binary, args, {
env,
timeout: SCAN_TIMEOUT_MS,
maxBuffer: 64 * 1024 * 1024,
});
const { misconfigs } = parseTrivyOutput(stdout);
let critical = 0,
high = 0,
medium = 0,
low = 0,
unknown = 0;
for (const m of misconfigs) {
switch (m.severity) {
case 'CRITICAL':
critical++;
break;
case 'HIGH':
high++;
break;
case 'MEDIUM':
medium++;
break;
case 'LOW':
low++;
break;
default:
unknown++;
}
}
const highestSeverity: VulnSeverity | null =
critical > 0 ? 'CRITICAL'
: high > 0 ? 'HIGH'
: medium > 0 ? 'MEDIUM'
: low > 0 ? 'LOW'
: unknown > 0 ? 'UNKNOWN'
: null;
db.updateVulnerabilityScan(scanId, {
scanned_at: Date.now(),
critical_count: critical,
high_count: high,
medium_count: medium,
low_count: low,
unknown_count: unknown,
misconfig_count: misconfigs.length,
highest_severity: highestSeverity,
trivy_version: this.version,
scan_duration_ms: Date.now() - startedAt,
status: 'completed',
});
db.insertMisconfigFindings(
scanId,
misconfigs.map((m) => ({
rule_id: m.ruleId,
check_id: m.checkId,
severity: m.severity,
title: m.title,
message: m.message,
resolution: m.resolution,
target: m.target,
primary_url: m.primaryUrl,
})),
);
const stored = db.getVulnerabilityScan(scanId);
if (!stored) throw new Error('Scan vanished after write');
try {
const evaluation = db.evaluateScanAgainstPolicies(
nodeId,
stored,
FleetSyncService.getSelfIdentity(),
);
if (evaluation) {
db.setScanPolicyEvaluation(scanId, evaluation);
stored.policy_evaluation = JSON.stringify(evaluation);
}
} catch (err) {
console.warn(
`[Trivy] policy evaluation failed for stack scanId=${scanId}:`,
getErrorMessage(err, 'unknown error'),
);
}
return stored;
} finally {
cleanup();
}
} catch (error) {
const msg = getErrorMessage(error, 'Stack scan failed');
db.updateVulnerabilityScan(scanId, {
status: 'failed',
error: msg,
scan_duration_ms: Date.now() - startedAt,
const db = DatabaseService.getInstance();
const scanId = db.createVulnerabilityScan({
node_id: nodeId,
image_ref: `stack:${stackName}`,
image_digest: null,
scanned_at: Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'config',
highest_severity: null,
os_info: null,
trivy_version: this.version,
scan_duration_ms: null,
triggered_by: triggeredBy,
status: 'in_progress',
error: null,
stack_context: stackName,
});
throw error;
const startedAt = Date.now();
try {
const { env, cleanup } = await this.buildEnv();
try {
const args = ['config', '--format', 'json', '--quiet', resolved];
const { stdout } = await execFileAsync(binary, args, {
env,
timeout: SCAN_TIMEOUT_MS,
maxBuffer: 64 * 1024 * 1024,
});
const { misconfigs } = parseTrivyOutput(stdout);
let critical = 0,
high = 0,
medium = 0,
low = 0,
unknown = 0;
for (const m of misconfigs) {
switch (m.severity) {
case 'CRITICAL':
critical++;
break;
case 'HIGH':
high++;
break;
case 'MEDIUM':
medium++;
break;
case 'LOW':
low++;
break;
default:
unknown++;
}
}
const highestSeverity: VulnSeverity | null =
critical > 0 ? 'CRITICAL'
: high > 0 ? 'HIGH'
: medium > 0 ? 'MEDIUM'
: low > 0 ? 'LOW'
: unknown > 0 ? 'UNKNOWN'
: null;
db.updateVulnerabilityScan(scanId, {
scanned_at: Date.now(),
critical_count: critical,
high_count: high,
medium_count: medium,
low_count: low,
unknown_count: unknown,
misconfig_count: misconfigs.length,
highest_severity: highestSeverity,
trivy_version: this.version,
scan_duration_ms: Date.now() - startedAt,
status: 'completed',
});
db.insertMisconfigFindings(
scanId,
misconfigs.map((m) => ({
rule_id: m.ruleId,
check_id: m.checkId,
severity: m.severity,
title: m.title,
message: m.message,
resolution: m.resolution,
target: m.target,
primary_url: m.primaryUrl,
})),
);
const stored = db.getVulnerabilityScan(scanId);
if (!stored) throw new Error('Scan vanished after write');
try {
const evaluation = db.evaluateScanAgainstPolicies(
nodeId,
stored,
FleetSyncService.getSelfIdentity(),
);
if (evaluation) {
db.setScanPolicyEvaluation(scanId, evaluation);
stored.policy_evaluation = JSON.stringify(evaluation);
}
} catch (err) {
console.warn(
`[Trivy] policy evaluation failed for stack scanId=${scanId}:`,
getErrorMessage(err, 'unknown error'),
);
}
return stored;
} finally {
cleanup();
}
} catch (error) {
const msg = getErrorMessage(error, 'Stack scan failed');
db.updateVulnerabilityScan(scanId, {
status: 'failed',
error: msg,
scan_duration_ms: Date.now() - startedAt,
});
throw error;
}
} finally {
this.scanningImages.delete(dedupKey);
}
}
@@ -968,6 +1021,7 @@ class TrivyService {
if (this.source === 'none') {
throw new Error('Trivy is not available on this host');
}
const batchStartedAt = Date.now();
const images = await DockerController.getInstance(nodeId).getImages();
const imageRefs = new Set<string>();
for (const img of images as Array<{ RepoTags?: string[] }>) {
@@ -1040,6 +1094,11 @@ class TrivyService {
}
await new Promise((r) => setTimeout(r, 300));
}
diag(
`scanAllNodeImages: nodeId=${nodeId} unique=${imageRefs.size} `
+ `scanned=${scanned} skipped=${skipped} failed=${failed} `
+ `violations=${violations.length} elapsedMs=${Date.now() - batchStartedAt}`,
);
return { scanned, skipped, failed, severity, violations };
}
+1 -1
View File
@@ -60,7 +60,7 @@ export const STALE_THRESHOLD_MS = 60 * 60 * 1000;
* their arguments without a cycle through FleetSyncService. The ordering
* below mirrors `FLEET_RESOURCES` in FleetSyncService.
*/
export type FleetResource = 'scan_policies' | 'cve_suppressions';
export type FleetResource = 'scan_policies' | 'cve_suppressions' | 'misconfig_acknowledgements';
/**
* `system_state` keys read or written by Fleet Sync. Centralized so a typo
+122
View File
@@ -0,0 +1,122 @@
/**
* Read-time misconfiguration acknowledgement filter.
*
* Acknowledgements never modify stored finding rows. They are applied at read
* time so deleting an ack resurfaces findings without rescanning.
*
* An acknowledgement matches a finding when:
* - rule_id equals the finding's rule_id, AND
* - stack_pattern is null OR matches the scan's stack_context (glob), AND
* - expires_at is null OR still in the future.
*
* Mirrors the design of `suppression-filter.ts`. The bucketing pass is shared
* spirit: pre-group by rule_id once so a multi-thousand-finding scan does not
* cross-multiply with the fleet ack list on every render.
*/
import type { MisconfigAcknowledgement } from '../services/DatabaseService';
export interface MisconfigAcknowledgementDecision {
acknowledged: boolean;
acknowledgement_id?: number;
acknowledgement_reason?: string;
}
export interface AcknowledgeableFinding {
rule_id: string;
}
function matchesStackPattern(pattern: string | null, stackContext: string | null): boolean {
// No pattern means fleet-wide; matches any stack including null contexts
// (e.g. image scans where stack_context is null).
if (!pattern) return true;
// Stack-scoped acks against an image scan (no stack_context) cannot match.
if (stackContext === null) return false;
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`).test(stackContext);
}
function isActive(ack: MisconfigAcknowledgement, now: number): boolean {
return ack.expires_at === null || ack.expires_at > now;
}
function specificityScore(a: MisconfigAcknowledgement): number {
return a.stack_pattern ? 1 : 0;
}
/**
* Pick the highest-specificity active ack from a candidate bucket already
* filtered to a single rule_id. A stack-scoped ack beats a fleet-wide ack.
*/
function pickFromBucket(
bucket: MisconfigAcknowledgement[],
stackContext: string | null,
now: number,
): MisconfigAcknowledgement | null {
let best: MisconfigAcknowledgement | null = null;
let bestScore = -1;
for (const a of bucket) {
if (!isActive(a, now)) continue;
if (!matchesStackPattern(a.stack_pattern, stackContext)) continue;
const score = specificityScore(a);
if (score > bestScore) {
best = a;
bestScore = score;
}
}
return best;
}
/**
* Find the most specific active acknowledgement matching a single finding.
* For one-shot lookups; prefer applyMisconfigAcknowledgements when enriching
* a list because that path amortizes the bucketing.
*/
export function findMisconfigAcknowledgement(
finding: AcknowledgeableFinding,
stackContext: string | null,
acks: MisconfigAcknowledgement[],
now: number = Date.now(),
): MisconfigAcknowledgement | null {
const bucket: MisconfigAcknowledgement[] = [];
for (const a of acks) {
if (a.rule_id === finding.rule_id) bucket.push(a);
}
if (bucket.length === 0) return null;
return pickFromBucket(bucket, stackContext, now);
}
/**
* Enrich a list of misconfig findings with acknowledgement decisions. Does
* not mutate inputs.
*
* Acks are bucketed by rule_id once before the per-finding scan, so the
* per-finding work is O(matching-rule-acks) rather than O(acks).
*/
export function applyMisconfigAcknowledgements<T extends AcknowledgeableFinding>(
findings: T[],
stackContext: string | null,
acks: MisconfigAcknowledgement[],
now: number = Date.now(),
): Array<T & MisconfigAcknowledgementDecision> {
if (findings.length === 0) return [];
const buckets = new Map<string, MisconfigAcknowledgement[]>();
for (const a of acks) {
const existing = buckets.get(a.rule_id);
if (existing) {
existing.push(a);
} else {
buckets.set(a.rule_id, [a]);
}
}
return findings.map((f) => {
const bucket = buckets.get(f.rule_id);
const match = bucket ? pickFromBucket(bucket, stackContext, now) : null;
if (!match) return { ...f, acknowledged: false };
return {
...f,
acknowledged: true,
acknowledgement_id: match.id,
acknowledgement_reason: match.reason,
};
});
}