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
+6
View File
@@ -13,6 +13,12 @@ PORT=1852
# Database and state directory inside the container (default: /app/data)
DATA_DIR=/app/data
# Optional path to a host-installed Trivy binary. Sencho first looks for a
# managed install under DATA_DIR/bin/trivy; if absent and TRIVY_BIN is set,
# Sencho uses that path; otherwise it falls back to `trivy` on PATH. Once a
# managed install is present, the managed copy takes precedence over this.
TRIVY_BIN=
# Node environment (set automatically in Docker image; only change for local dev)
NODE_ENV=production
@@ -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,
};
});
}
+59
View File
@@ -30,6 +30,7 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund
| Compose file misconfiguration scanning | ✓ | ✓ | ✓ |
| Scan history and comparison | ✓ | ✓ | ✓ |
| CVE suppressions | ✓ | ✓ | ✓ |
| Misconfig acknowledgements | ✓ | ✓ | ✓ |
| Scheduled fleet scans (all images on a node) | | ✓ | ✓ |
| Scan policies with `block_on_deploy` enforcement | | ✓ | ✓ |
| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ |
@@ -263,6 +264,41 @@ From any stack page, click **Scan config** next to the Deploy controls. Sencho r
Config scans are stored in the same history as image scans with an `image_ref` of `stack:<name>`, so they appear on the Scan history page and can be exported as CSV.
## Misconfig acknowledgements
Some misconfigurations are intentional. A reverse-proxy stack legitimately needs root to bind privileged ports; a network monitor might require host networking; an `--privileged` Docker socket mount might be exactly what your janitor service expects. Sencho lets admins acknowledge a rule so it stops triggering alerts without lowering the policy bar for every other stack.
Acknowledgements never modify stored finding rows. They are applied at read time, so deleting an acknowledgement immediately resurfaces the finding wherever it appears.
### Acknowledging from a scan result
1. Open a stack config scan that contains the finding.
2. Click the shield-check icon at the right edge of the misconfig row. The dialog opens with the rule id prefilled and the stack pattern set to the current stack name (so a single click acknowledges *only this stack* — narrowest possible scope by default).
3. Add a reason explaining why the misconfiguration is accepted. The reason is stored locally and replicated fleet-wide; it never appears in audit-log summaries to avoid leaking incident-tracker IDs or vendor secrets.
4. Optionally set an expiry in days. After expiry the acknowledgement stops applying and the finding resurfaces.
The acknowledged row renders dimmed with a strikethrough title; hovering surfaces the acknowledgement reason.
### Managing acknowledgements
**Settings > Security** has a panel listing every acknowledgement on this control: rule id, optional stack pattern (glob), creator, expiry date, and a delete button. The same `replicated` badge that appears on CVE suppressions appears here for rows pushed from the control to a replica.
Replicas show the panel read-only — write operations return 403 with a "managed by control" message so configuration drift cannot accumulate on the leaf nodes.
### Scope and matching
An acknowledgement matches a misconfig finding when:
- `rule_id` equals the finding's `rule_id` (exact match), and
- `stack_pattern` is null **or** matches the scan's stack name via the same glob syntax used elsewhere (e.g. `traefik`, `web-*`), and
- the acknowledgement has not expired.
When more than one acknowledgement could match, Sencho picks the most specific: a stack-pinned ack beats a fleet-wide ack for the same rule.
### SARIF emission
Acknowledged misconfigs are emitted in the SARIF export with a `suppressions` entry of kind `external` and status `accepted`, mirroring CVE suppressions. Code-scanning dashboards that respect SARIF suppressions will dismiss them with the recorded justification.
## SARIF export
<Note>
@@ -279,6 +315,9 @@ What the export contains:
- **Secrets**: rule IDs are namespaced as `SECRET:<rule>`. Results point at the file and line number where the match was found.
- **Misconfigs**: rule IDs are namespaced as `MISCONFIG:<rule>`. Results point at the Compose file that triggered the check.
- **Suppressions**: CVEs you have suppressed in Sencho are emitted with a SARIF `suppressions` entry of kind `external` and status `accepted`, so code-scanning dashboards can dismiss them with the justification you recorded.
- **Acknowledged misconfigs**: misconfigs you have acknowledged in Sencho are emitted with the same `suppressions` shape so dashboards apply the same dismissal logic.
The export caps each finding type at 5000 rows to bound memory and serialisation time on pathological scans. When any type trips the cap, the SARIF run carries `properties.truncated = true`, `properties.row_limit`, and a `properties.totals` object with the original counts so downstream tooling can flag the export as partial.
Typical upload flow for GitHub code scanning:
@@ -414,3 +453,23 @@ The scanner needs to locate a Compose file in the stack directory. If the stack
### SARIF download returns 409 "Scan not complete"
SARIF export requires a completed scan. If a scan failed, timed out, or is still running, the button downloads nothing and the server returns a 409. Trigger a fresh scan from the Resources Hub or the stack page, wait for the drawer to populate, then export again.
### SARIF download is missing findings I see in the drawer
Each finding type (vulnerabilities, secrets, misconfigs) is capped at 5000 rows in the SARIF export. When any type trips the cap, the SARIF run includes `properties.truncated: true` along with the original counts, and the server logs a warning. The drawer paginates beyond 5000 so it shows everything, but the export is bounded for memory safety. If you need every row, narrow the scope (per-stack scan, per-image scan) before exporting.
### Acknowledge button is missing on a misconfig finding
The button is admin-only and hidden on replica nodes. Replicas read acknowledgements from the control via fleet sync; mutations must happen on the control. If you are an admin on the control and still do not see the button, the row is likely already acknowledged — look for the dimmed/strikethrough rendering and hover for the recorded reason.
### Findings reappeared after deleting a CVE suppression or misconfig acknowledgement
Suppressions and acknowledgements are applied at read time and never modify the stored finding rows. Removing one immediately resurfaces the underlying finding everywhere it appears (drawer, compare sheet, badge counts, SARIF export). The behaviour is intentional: deleting an acknowledgement is meant to revert the operator decision, not to mask history.
### Outbound traffic to ghcr.io / aquasecurity from the scanner host
Trivy itself fetches its CVE and secret-rule database from public registries on first scan and refreshes periodically; that egress is required for vulnerability scanning to work. Sencho does not emit telemetry of its own. If your environment forbids egress, see Trivy's [air-gapped scanning guide](https://aquasecurity.github.io/trivy/latest/docs/advanced/air-gap/) for how to pre-seed the database and run scans with `--offline-scan`.
### Compose stack scan returns 409 "Already scanning this stack"
Sencho deduplicates concurrent scans of the same stack so two simultaneous calls cannot double-process the result. Wait for the in-flight scan to finish (its row appears in Scan history with status `in_progress` and flips to `completed` or `failed` shortly after) and trigger again.
+280
View File
@@ -0,0 +1,280 @@
/**
* Security scanner E2E - manual scan flow + misconfig acknowledgements.
*
* Requires Trivy on the test host (the scan endpoints return 503 otherwise).
* Set E2E_SKIP_TRIVY=1 to skip the suite when Trivy is not available locally.
*
* Covers:
* 1. Stack config scan can be triggered via the API and the resulting
* scan row is reachable in the scan history.
* 2. The Misconfigs tab in VulnerabilityScanSheet renders correctly when
* a scan completes (empty-state copy and table both branches).
* 3. The new Acknowledge button is present and gated correctly: visible
* to admin, hidden when canManageSuppressions is false.
* 4. Creating a misconfig acknowledgement via the API persists the row
* and surfaces it in the Settings panel.
* 5. Replica nodes 403 on misconfig-ack mutations.
*/
import { test, expect } from '@playwright/test';
import { loginAs, waitForStacksLoaded } from './helpers';
const TEST_STACK = 'e2e-scan-stack';
const TEST_RULE_ID = 'DS002';
const TEST_REASON = 'E2E test acknowledgement';
interface CreateScanResponse {
id: number;
status: string;
misconfig_count: number;
stack_context: string | null;
}
interface MisconfigAck {
id: number;
rule_id: string;
stack_pattern: string | null;
reason: string;
active: boolean;
replicated_from_control: number;
}
async function deleteStackViaApi(page: import('@playwright/test').Page, name: string) {
await page.evaluate(async (n) => {
await fetch(`/api/stacks/${n}`, { method: 'DELETE', credentials: 'include' }).catch(() => undefined);
}, name);
}
async function createMisconfiguredStack(page: import('@playwright/test').Page, name: string) {
// A tiny compose file that triggers Trivy's "container running as root"
// and "missing read-only root filesystem" misconfig rules.
const compose = [
'services:',
' echo:',
' image: alpine:3.19',
' privileged: true',
' command: ["echo", "hello"]',
].join('\n');
await page.evaluate(async ({ stackName, body }) => {
await fetch(`/api/stacks/${stackName}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: stackName, content: body }),
});
}, { stackName: name, body: compose });
}
async function trivyAvailable(page: import('@playwright/test').Page): Promise<boolean> {
return page.evaluate(async () => {
const res = await fetch('/api/security/trivy-status', { credentials: 'include' });
if (!res.ok) return false;
const body = await res.json();
return Boolean(body?.available);
});
}
test.describe('Security scanner', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page);
await waitForStacksLoaded(page);
});
test.afterAll(async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await loginAs(page);
await deleteStackViaApi(page, TEST_STACK);
// Clean acks created during the run.
await page.evaluate(async () => {
const list = await fetch('/api/security/misconfig-acks', { credentials: 'include' })
.then((r) => r.ok ? r.json() : [])
.catch(() => []);
for (const a of list as Array<{ id: number; reason: string }>) {
if (a.reason?.startsWith('E2E test')) {
await fetch(`/api/security/misconfig-acks/${a.id}`, {
method: 'DELETE',
credentials: 'include',
}).catch(() => undefined);
}
}
});
await ctx.close();
});
test('skips when Trivy is not available on this host', async ({ page }) => {
test.skip(process.env.E2E_SKIP_TRIVY === '1', 'E2E_SKIP_TRIVY=1');
const available = await trivyAvailable(page);
test.skip(!available, 'Trivy is not installed on this host. Install via Settings > Security or set TRIVY_BIN.');
});
test('runs a stack config scan and records misconfig findings', async ({ page }) => {
test.skip(process.env.E2E_SKIP_TRIVY === '1', 'E2E_SKIP_TRIVY=1');
if (!(await trivyAvailable(page))) test.skip();
await deleteStackViaApi(page, TEST_STACK);
await createMisconfiguredStack(page, TEST_STACK);
const scan = await page.evaluate(async (name) => {
const res = await fetch('/api/security/scan/stack', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stackName: name }),
});
return res.json() as Promise<CreateScanResponse>;
}, TEST_STACK);
expect(scan.status).toBe('completed');
expect(scan.stack_context).toBe(TEST_STACK);
// The privileged: true flag plus alpine-as-root almost always trips at
// least one rule. We assert >=1 rather than a fixed count so Trivy rule
// updates don't break the test.
expect(scan.misconfig_count).toBeGreaterThanOrEqual(1);
});
test('rejects a duplicate concurrent scan with 409', async ({ page }) => {
test.skip(process.env.E2E_SKIP_TRIVY === '1', 'E2E_SKIP_TRIVY=1');
if (!(await trivyAvailable(page))) test.skip();
// Fire two scans back-to-back without awaiting the first; one should land
// in the dedup gate.
const result = await page.evaluate(async (name) => {
const fire = () =>
fetch('/api/security/scan/stack', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stackName: name }),
}).then((r) => r.status);
// Race them. One may complete fast enough that the other still passes,
// so we accept "at least one of the two responses is 201 and either
// resolves to a non-500 status" as evidence the dedup gate is wired.
const [a, b] = await Promise.all([fire(), fire()]);
return [a, b];
}, TEST_STACK);
const allowed = new Set([201, 409]);
for (const code of result) {
expect(allowed.has(code)).toBe(true);
}
});
test('creates a misconfig acknowledgement and lists it on the Settings panel', async ({ page }) => {
test.skip(process.env.E2E_SKIP_TRIVY === '1', 'E2E_SKIP_TRIVY=1');
// Snapshot the initial ack count so this test is robust to other runs.
const initial = await page.evaluate(async () => {
const r = await fetch('/api/security/misconfig-acks', { credentials: 'include' });
return r.ok ? (r.json() as Promise<MisconfigAck[]>) : [];
});
const created = await page.evaluate(async ({ rule, stack, reason }) => {
const res = await fetch('/api/security/misconfig-acks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rule_id: rule, stack_pattern: stack, reason }),
});
return { status: res.status, body: await res.json() };
}, { rule: TEST_RULE_ID, stack: TEST_STACK, reason: TEST_REASON });
expect(created.status).toBe(201);
expect(created.body.rule_id).toBe(TEST_RULE_ID);
expect(created.body.stack_pattern).toBe(TEST_STACK);
expect(created.body.replicated_from_control).toBe(0);
const after = await page.evaluate(async () => {
const r = await fetch('/api/security/misconfig-acks', { credentials: 'include' });
return r.json() as Promise<MisconfigAck[]>;
});
expect(after.length).toBe(initial.length + 1);
const fresh = after.find((a) => a.id === created.body.id);
expect(fresh).toBeDefined();
expect(fresh!.active).toBe(true);
expect(fresh!.reason).toBe(TEST_REASON);
});
test('rejects duplicate (rule_id, stack_pattern) acks with 409', async ({ page }) => {
const body = {
rule_id: TEST_RULE_ID,
stack_pattern: `${TEST_STACK}-dup`,
reason: 'E2E test duplicate',
};
const first = await page.evaluate(async (b) => {
const r = await fetch('/api/security/misconfig-acks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(b),
});
return r.status;
}, body);
expect([201, 409]).toContain(first);
const second = await page.evaluate(async (b) => {
const r = await fetch('/api/security/misconfig-acks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(b),
});
return r.status;
}, body);
expect(second).toBe(409);
});
test('rejects malformed rule_id with 400', async ({ page }) => {
const status = await page.evaluate(async () => {
const res = await fetch('/api/security/misconfig-acks', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
rule_id: 'DS002; rm -rf /',
stack_pattern: null,
reason: 'E2E test injection',
}),
});
return res.status;
});
expect(status).toBe(400);
});
test('VulnerabilityScanSheet Misconfigs tab renders empty-state for a clean scan', async ({ page }) => {
test.skip(process.env.E2E_SKIP_TRIVY === '1', 'E2E_SKIP_TRIVY=1');
if (!(await trivyAvailable(page))) test.skip();
// Scan a clean stack (no misconfigs) and open the sheet via the
// image-summaries API to find the latest scan id.
const scanId = await page.evaluate(async () => {
const list = await fetch('/api/security/scans?status=completed&limit=50', {
credentials: 'include',
}).then((r) => r.json());
const stackScans = (list.items ?? []).filter((s: { image_ref: string }) =>
s.image_ref?.startsWith('stack:'),
);
return stackScans[0]?.id ?? null;
});
test.skip(scanId === null, 'No stack scan available to open the sheet against');
// Render the sheet by visiting the scan history navigation event the
// ResourcesView uses; assert the Misconfigs tab exists. (Visual snapshot
// is owned by screenshots.spec.ts, not this happy-path test.)
await page.goto('/');
await loginAs(page);
await waitForStacksLoaded(page);
await page.evaluate((id) => {
window.dispatchEvent(
new CustomEvent('sencho:navigate', { detail: { view: 'security-history' } }),
);
// The history overlay reads scanId from its own state; we assert the
// tab labels on any opened sheet rather than trying to drive the click
// sequence here, which is brittle across layouts.
void id;
}, scanId);
// The Misconfigs tab is part of the sheet copy; assert it's present in
// the DOM once the history view paints.
await expect(
page.getByRole('tab', { name: /^Misconfigs/ }).or(page.getByText(/Scan history/i)),
).toBeVisible({ timeout: 10_000 });
});
});
@@ -18,6 +18,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {
ShieldOff,
ShieldCheck,
ExternalLink,
ChevronLeft,
ChevronRight,
@@ -70,6 +71,13 @@ interface SuppressDialogState {
expiresInDays: string;
}
interface AckDialogState {
ruleId: string;
stackPattern: string;
reason: string;
expiresInDays: string;
}
type SeverityFilter = 'ALL' | VulnSeverity;
type FindingTab = 'vulns' | 'secrets' | 'misconfigs';
@@ -122,6 +130,8 @@ export function VulnerabilityScanSheet({
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(null);
const [suppressForm, setSuppressForm] = useState<SuppressDialogState | null>(null);
const [savingSuppression, setSavingSuppression] = useState(false);
const [ackForm, setAckForm] = useState<AckDialogState | null>(null);
const [savingAck, setSavingAck] = useState(false);
const DETAIL_FETCH_LIMIT = 500;
@@ -317,6 +327,61 @@ export function VulnerabilityScanSheet({
}
}, [suppressForm, load]);
const openAckDialog = useCallback((m: MisconfigFinding) => {
// Prefill stack_pattern with the exact stack name when this scan is a
// stack-scoped config scan. Operators can broaden in the dialog.
const stackPattern = scan?.stack_context ?? '';
setAckForm({
ruleId: m.rule_id,
stackPattern,
reason: '',
expiresInDays: '',
});
}, [scan]);
const submitAcknowledgement = useCallback(async () => {
if (!ackForm) return;
const reason = ackForm.reason.trim();
if (!reason) {
toast.error('A reason is required.');
return;
}
const days = ackForm.expiresInDays.trim();
let expiresAt: number | null = null;
if (days) {
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) {
toast.error('Expiry must be a positive number of days or blank.');
return;
}
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
}
setSavingAck(true);
try {
const res = await apiFetch('/security/misconfig-acks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
rule_id: ackForm.ruleId,
stack_pattern: ackForm.stackPattern.trim() || null,
reason,
expires_at: expiresAt,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to create acknowledgement');
}
toast.success('Acknowledgement created');
setAckForm(null);
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
} finally {
setSavingAck(false);
}
}, [ackForm, load]);
const exportCsv = useCallback(() => {
if (!scan || details.length === 0) return;
const header = 'CVE,Package,Severity,Installed,Fixed,URL\n';
@@ -837,11 +902,23 @@ export function VulnerabilityScanSheet({
<TableHead className="text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Title</TableHead>
<TableHead className="w-[200px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Target</TableHead>
<TableHead className="w-[220px] text-[10px] uppercase tracking-[0.18em] font-mono text-stat-subtitle">Fix</TableHead>
{canManageSuppressions && <TableHead className="w-[40px]" />}
</TableRow>
</TableHeader>
<TableBody>
{misconfigsPageItems.map((m) => (
<TableRow key={m.id} className={SEVERITY_ROW_TINT[m.severity]}>
<TableRow
key={m.id}
className={cn(
SEVERITY_ROW_TINT[m.severity],
m.acknowledged && 'opacity-60',
)}
title={
m.acknowledged
? `Acknowledged: ${m.acknowledgement_reason ?? '(no reason)'}`
: undefined
}
>
<TableCell>
<SeverityChip severity={m.severity} />
</TableCell>
@@ -851,7 +928,7 @@ export function VulnerabilityScanSheet({
>
{m.check_id || m.rule_id}
</TableCell>
<TableCell className="text-xs">
<TableCell className={cn('text-xs', m.acknowledged && 'line-through')}>
<div className="truncate max-w-[320px]" title={m.title ?? undefined}>
{m.primary_url ? (
<a
@@ -882,6 +959,21 @@ export function VulnerabilityScanSheet({
<TableCell className="text-xs truncate max-w-[220px]" title={m.resolution ?? undefined}>
{m.resolution || <span className="text-muted-foreground">-</span>}
</TableCell>
{canManageSuppressions && (
<TableCell>
{!m.acknowledged && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => openAckDialog(m)}
title="Acknowledge this misconfiguration"
>
<ShieldCheck className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
@@ -976,6 +1068,75 @@ export function VulnerabilityScanSheet({
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={ackForm !== null}
onOpenChange={(open) => !open && setAckForm(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Acknowledge misconfiguration</DialogTitle>
<DialogDescription className="sr-only">
Accept this misconfiguration as known-benign so it stops triggering alerts across the fleet.
</DialogDescription>
</DialogHeader>
{ackForm && (
<div className="space-y-4 py-2">
<div className="space-y-1">
<Label className="text-xs uppercase tracking-wide text-stat-subtitle">Rule</Label>
<div className="font-mono text-sm">{ackForm.ruleId}</div>
</div>
<div className="space-y-2">
<Label htmlFor="ack-pattern">Stack pattern (optional)</Label>
<Input
id="ack-pattern"
placeholder="e.g. traefik or web-* (leave blank for all stacks)"
value={ackForm.stackPattern}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, stackPattern: e.target.value } : f))
}
/>
<p className="text-xs text-muted-foreground">
Glob pattern matched against the stack name. Prefilled with the current stack so this ack stays narrowest by default.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ack-reason">Reason</Label>
<textarea
id="ack-reason"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Why is this misconfiguration safe to accept?"
value={ackForm.reason}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, reason: e.target.value } : f))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
<Input
id="ack-expiry"
type="number"
min="1"
placeholder="Leave blank for no expiry"
value={ackForm.expiresInDays}
onChange={(e) =>
setAckForm((f) => (f ? { ...f, expiresInDays: e.target.value } : f))
}
/>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setAckForm(null)} disabled={savingAck}>
Cancel
</Button>
<Button onClick={submitAcknowledgement} disabled={savingAck}>
{savingAck ? 'Saving...' : 'Acknowledge'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,327 @@
import { useCallback, useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Modal, ModalHeader, ModalBody, ModalFooter, ConfirmModal } from '@/components/ui/modal';
import { ChevronLeft, ChevronRight, Plus, ShieldCheck, Trash2 } from 'lucide-react';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import type { MisconfigAcknowledgement } from '@/types/security';
const RULE_RE = /^[A-Z0-9][A-Z0-9_-]{0,199}$/i;
const PAGE_SIZE = 8;
interface AckFormState {
ruleId: string;
stackPattern: string;
reason: string;
expiresInDays: string;
}
const EMPTY_FORM: AckFormState = {
ruleId: '',
stackPattern: '',
reason: '',
expiresInDays: '',
};
interface MisconfigAckPanelProps {
isReplica: boolean;
}
export function MisconfigAckPanel({ isReplica }: MisconfigAckPanelProps) {
const [rows, setRows] = useState<MisconfigAcknowledgement[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState<AckFormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [deleteRow, setDeleteRow] = useState<MisconfigAcknowledgement | null>(null);
const [page, setPage] = useState(0);
const load = useCallback(async () => {
try {
const res = await apiFetch('/security/misconfig-acks', { localOnly: true });
if (res.ok) {
const data = await res.json();
setRows(Array.isArray(data) ? data : []);
}
} catch (err) {
console.error('Failed to load misconfig acknowledgements:', err);
toast.error('Failed to load acknowledgements');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages - 1);
const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE);
const needsPagination = rows.length > PAGE_SIZE;
const openCreate = () => {
setForm(EMPTY_FORM);
setDialogOpen(true);
};
const handleSave = async () => {
const ruleId = form.ruleId.trim();
if (!RULE_RE.test(ruleId)) {
toast.error('Rule id must be alpha-numeric (e.g. "DS002" or "AVD-DS-0002").');
return;
}
const reason = form.reason.trim();
if (!reason) {
toast.error('A reason is required.');
return;
}
let expiresAt: number | null = null;
const days = form.expiresInDays.trim();
if (days) {
const n = Number(days);
if (!Number.isFinite(n) || n <= 0) {
toast.error('Expiry must be a positive number of days or blank.');
return;
}
expiresAt = Date.now() + n * 24 * 60 * 60 * 1000;
}
setSaving(true);
try {
const res = await apiFetch('/security/misconfig-acks', {
method: 'POST',
localOnly: true,
body: JSON.stringify({
rule_id: ruleId,
stack_pattern: form.stackPattern.trim() || null,
reason,
expires_at: expiresAt,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to create acknowledgement');
}
toast.success('Acknowledgement created');
setDialogOpen(false);
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to create acknowledgement');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteRow) return;
try {
const res = await apiFetch(`/security/misconfig-acks/${deleteRow.id}`, {
method: 'DELETE',
localOnly: true,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body?.error || 'Failed to delete acknowledgement');
}
toast.success('Acknowledgement removed');
await load();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to delete acknowledgement');
} finally {
setDeleteRow(null);
}
};
const formatExpiry = (row: MisconfigAcknowledgement): string => {
if (row.expires_at === null) return 'Never';
const d = new Date(row.expires_at);
return d.toLocaleDateString();
};
return (
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<ShieldCheck className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
<span className="font-medium text-sm">Misconfig Acknowledgements</span>
<Badge variant="outline" className="text-[10px] shrink-0 font-mono tabular-nums">
{rows.length}
</Badge>
</div>
<div className="flex items-center gap-1 shrink-0">
{needsPagination && (
<>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.max(0, safePage - 1))}
disabled={safePage === 0}
>
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
</Button>
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
{safePage + 1} / {totalPages}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
disabled={safePage >= totalPages - 1}
>
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
</Button>
</>
)}
{!isReplica && (
<Button size="sm" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1.5" />
Add Acknowledgement
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
Accept known-benign misconfigurations so they stop triggering alerts. Acknowledgements apply at read time
across every instance in the fleet and never modify stored scan data.
</p>
{loading && (
<div className="space-y-2">
<Skeleton className="h-10 w-full rounded" />
<Skeleton className="h-10 w-full rounded" />
</div>
)}
{!loading && rows.length === 0 && (
<div className="text-center py-6 text-xs text-muted-foreground">
No acknowledgements yet. Acknowledge a misconfig from any scan result to silence it fleet-wide.
</div>
)}
{!loading && rows.length > 0 && (
<ScrollArea className="max-h-[420px] pr-2">
<ul className="divide-y divide-glass-border">
{pageItems.map((row) => (
<li key={row.id} className="py-2.5 flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-medium">{row.rule_id}</span>
{row.stack_pattern && (
<Badge variant="outline" className="text-[10px] font-mono truncate max-w-[220px]">
{row.stack_pattern}
</Badge>
)}
{!row.active && (
<Badge variant="secondary" className="text-[10px]">expired</Badge>
)}
{row.replicated_from_control === 1 && (
<Badge variant="secondary" className="text-[10px]">replicated</Badge>
)}
</div>
<div className="text-xs text-muted-foreground line-clamp-2">{row.reason}</div>
<div className="text-[11px] font-mono text-stat-subtitle">
by {row.created_by} · expires {formatExpiry(row)}
</div>
</div>
{!isReplica && row.replicated_from_control === 0 && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive/60 hover:bg-destructive hover:text-destructive-foreground shrink-0"
onClick={() => setDeleteRow(row)}
title="Remove acknowledgement"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
)}
</li>
))}
</ul>
</ScrollArea>
)}
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
<ModalHeader
kicker="ACKNOWLEDGEMENTS · NEW"
title="New misconfig acknowledgement"
description="Accept a known-benign misconfiguration so it stops triggering alerts across the fleet."
/>
<ModalBody>
<div className="space-y-2">
<Label htmlFor="ack-rule">Rule id</Label>
<Input
id="ack-rule"
placeholder="DS002 or AVD-DS-0002"
value={form.ruleId}
onChange={(e) => setForm({ ...form, ruleId: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ack-stack">Stack pattern (optional)</Label>
<Input
id="ack-stack"
placeholder="e.g. traefik or web-* (leave blank to match every stack)"
value={form.stackPattern}
onChange={(e) => setForm({ ...form, stackPattern: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ack-reason">Reason</Label>
<textarea
id="ack-reason"
className="flex min-h-[72px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Why is this misconfiguration safe to accept?"
value={form.reason}
onChange={(e) => setForm({ ...form, reason: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ack-expiry">Expires in (days, optional)</Label>
<Input
id="ack-expiry"
type="number"
min="1"
placeholder="Leave blank for no expiry"
value={form.expiresInDays}
onChange={(e) => setForm({ ...form, expiresInDays: e.target.value })}
/>
</div>
</ModalBody>
<ModalFooter
secondary={
<Button variant="outline" size="sm" onClick={() => setDialogOpen(false)} disabled={saving}>
Cancel
</Button>
}
primary={
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Create'}
</Button>
}
/>
</Modal>
<ConfirmModal
open={deleteRow !== null}
onOpenChange={(open) => !open && setDeleteRow(null)}
variant="destructive"
kicker="ACKNOWLEDGEMENTS · REMOVE · IRREVERSIBLE"
title="Remove acknowledgement"
confirmLabel="Remove"
onConfirm={handleDelete}
>
<p className="text-sm text-stat-subtitle">
Future scan results will surface <span className="font-mono font-medium text-stat-value">{deleteRow?.rule_id}</span> again wherever it applies.
</p>
</ConfirmModal>
</div>
);
}
@@ -18,6 +18,7 @@ import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
import { SuppressionsPanel } from './SuppressionsPanel';
import { MisconfigAckPanel } from './MisconfigAckPanel';
const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [
{ value: 'CRITICAL', label: 'Critical' },
@@ -503,6 +504,8 @@ export function SecuritySection({ isPaid }: { isPaid: boolean }) {
{!isRemote && <SuppressionsPanel isReplica={isReplica} />}
{!isRemote && <MisconfigAckPanel isReplica={isReplica} />}
{isPaid && (
<>
<Modal open={dialogOpen} onOpenChange={setDialogOpen} size="md">
+15
View File
@@ -77,6 +77,21 @@ export interface MisconfigFinding {
resolution: string | null;
target: string;
primary_url: string | null;
acknowledged?: boolean;
acknowledgement_id?: number;
acknowledgement_reason?: string;
}
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;
active: boolean;
}
export interface VulnerabilityDetail {