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