mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 01:37: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:
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user