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
+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