mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
3b650523c1
* 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.
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
/**
|
|
* 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/);
|
|
});
|
|
});
|