mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user