mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans
Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.
Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.
* feat(security): acknowledge misconfig findings
Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.
Schema and behavior parity with cve_suppressions:
- UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
collide as expected
- blockIfReplica on every write
- Audit-log entries name the scope (rule_id, stack_pattern) but
never the reason text
- replicated_from_control flag controls UI delete affordance and
drives clearReplicatedRows on demote/reanchor
- Validators reused: validateStackPatternForRedos for glob safety,
sanitizeForLog for log fragments
SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.
Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.
Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.
* fix(security): reap orphaned trivy tmp dirs at startup
When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.
* perf(security): emit per-batch summary for scanAllNodeImages
Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.
* perf(security): cap SARIF export at 5000 findings per type
Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.
A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.
* docs(env): document TRIVY_BIN host-binary override
The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).
* test(security): cover scanComposeStack failure modes
Two new cases drive the existing try/catch through real failure
paths:
- Malformed Trivy stdout: row flips to status='failed' with the
parser error preserved on `error`.
- execFile rejection: row flips to status='failed' with a string
error message.
Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.
* test(e2e): security scanner + misconfig acknowledgement flow
Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
- Trivy availability gate (skips suite when binary absent so CI
without Trivy can opt out via E2E_SKIP_TRIVY=1)
- Stack config scan completes and records misconfig findings
- Concurrent stack scan returns 409 from the dedup gate
- Misconfig ack POST creates and lists on Settings
- Duplicate (rule_id, stack_pattern) returns 409
- Malformed rule_id (shell metacharacters) returns 400
- Misconfigs tab renders against a real stack scan
Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.
* docs(features): add misconfig acknowledgement workflow and SARIF cap
Refreshes vulnerability-scanning.mdx with:
- Misconfig acknowledgements section covering the per-row dialog,
Settings panel, scope/matching rules, and SARIF emission
- Tier table row for the new feature
- SARIF section note on the 5000 row-per-type cap and the
properties.truncated marker for partial exports
- Troubleshooting entries: SARIF cap, hidden Acknowledge button,
findings resurfacing after delete, Trivy DB phone-home, and
409 on concurrent compose-stack scans
* fix(ci): clear backend lint and CodeQL alerts
- Remove the dead fetchAllPages helper in routes/security.ts. It lost
its callers when the SARIF endpoint switched to direct paged reads
for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
paths under os.tmpdir() with predictable names tripped CodeQL's
js/insecure-temporary-file rule (high severity), which warns about
symlink-pre-creation attacks even in test code. mkdtempSync appends
a process-random suffix and creates the dir atomically; the
sencho-trivy- prefix is preserved so the production sweep still
matches the test fixtures.
This commit is contained in:
@@ -0,0 +1,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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user