fix(security): harden Trivy scan lifecycle, logging, and docs (#639)

* fix(security): harden Trivy scan lifecycle, logging, and docs

- Call TrivyService.initialize() at startup so capability state is
  accurate before first request; add periodic re-detect to the scheduler
  so newly installed Trivy binaries light up without a restart.
- Add markStaleScansAsFailed sweep (+ idx_vuln_scans_status index) to
  recover any scan row left in_progress after a crash or timeout; sweep
  runs before the paid-tier gate so every tier self-heals.
- Split scanImage persistence into beginScan/finishScan so the manual
  scan route owns a single code path and can return a scanId synchronously
  while work continues asynchronously.
- Validate image refs on /api/security/scan and /sbom via new utility;
  defense-in-depth against shell-metacharacter payloads.
- Dispatch a warning-level alert when a post-deploy scan fails so the
  operator has a user-visible path to the failure instead of a silent log.
- Share DIGEST_CACHE_TTL_MS and severity ordering across service and
  route layers; remove dead invalidateDetection().
- Add [Trivy:diag] logging gated behind developer_mode for support
  diagnostics; production logs unchanged.
- Frontend: defensive toast fallback chain, sr-only SheetDescription,
  and a truncation badge when the 500-item detail fetch is capped.
- Tests: extend trivy-service and vulnerability-db suites; add
  image-ref and severity unit tests.
- Docs: expand vulnerability-scanning troubleshooting with recovery,
  re-detect, and diagnostic-log guidance; link Dockerfile comment to
  trivy-setup.

* fix(security): drop unnecessary escape in image-ref forbidden-char regex
This commit is contained in:
Anso
2026-04-16 20:32:38 -04:00
committed by GitHub
parent f8eb1b4e88
commit dc8370f5a4
16 changed files with 563 additions and 140 deletions
+30 -79
View File
@@ -69,7 +69,9 @@ import { getErrorMessage } from './utils/errors';
import { captureLocalNodeFiles, captureRemoteNodeFiles, SnapshotNodeData } from './utils/snapshot-capture';
import { GlobalLogEntry, normalizeContainerName, parseLogTimestamp, detectLogLevel, demuxDockerLog } from './utils/log-parsing';
import SelfUpdateService from './services/SelfUpdateService';
import TrivyService, { SbomFormat } from './services/TrivyService';
import TrivyService, { SbomFormat, DIGEST_CACHE_TTL_MS } from './services/TrivyService';
import { severityRank } from './utils/severity';
import { validateImageRef } from './utils/image-ref';
import semver from 'semver';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation';
@@ -1605,22 +1607,13 @@ async function triggerPostDeployScan(
const db = DatabaseService.getInstance();
const policy = db.getMatchingPolicy(nodeId, stackName);
const severityRank = (s: string | null | undefined): number => {
switch ((s ?? '').toUpperCase()) {
case 'CRITICAL': return 4;
case 'HIGH': return 3;
case 'MEDIUM': return 2;
case 'LOW': return 1;
default: return 0;
}
};
for (const imageRef of imageRefs) {
try {
const digest = await svc.getImageDigest(imageRef, nodeId);
if (digest) {
const cached = db.getLatestScanByDigest(digest);
if (cached && Date.now() - cached.scanned_at < 24 * 60 * 60 * 1000) continue;
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue;
}
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
@@ -1643,7 +1636,13 @@ async function triggerPostDeployScan(
);
}
} catch (err) {
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, (err as Error).message);
const message = (err as Error).message;
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
NotificationService.getInstance().dispatchAlert(
'warning',
`Post-deploy scan failed for ${imageRef} (${stackName}): ${message}`,
stackName,
);
}
}
} catch (err) {
@@ -7261,11 +7260,16 @@ app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): vo
res.status(503).json({ error: 'Trivy is not available on this host' });
return;
}
const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
if (!imageRef) {
const rawImageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : '';
if (!rawImageRef) {
res.status(400).json({ error: 'imageRef is required' });
return;
}
if (!validateImageRef(rawImageRef)) {
res.status(400).json({ error: 'Invalid imageRef format' });
return;
}
const imageRef = rawImageRef;
const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null;
const force = req.body?.force === true;
const nodeId = req.nodeId;
@@ -7273,73 +7277,12 @@ app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): vo
res.status(409).json({ error: 'Already scanning this image' });
return;
}
const db = DatabaseService.getInstance();
const scanId = db.createVulnerabilityScan({
node_id: nodeId,
image_ref: imageRef,
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,
highest_severity: null,
os_info: null,
trivy_version: svc.getVersion(),
scan_duration_ms: null,
triggered_by: 'manual',
status: 'in_progress',
error: null,
stack_context: stackContext,
});
const scanId = svc.beginScan(imageRef, nodeId, 'manual', stackContext);
res.status(202).json({ scanId });
const startedAt = Date.now();
(async () => {
try {
const result = await svc.scanImage(imageRef, nodeId, { useCache: !force });
db.updateVulnerabilityScan(scanId, {
image_digest: result.imageDigest,
scanned_at: result.scannedAt,
total_vulnerabilities: result.totalVulnerabilities,
critical_count: result.criticalCount,
high_count: result.highCount,
medium_count: result.mediumCount,
low_count: result.lowCount,
unknown_count: result.unknownCount,
fixable_count: result.fixableCount,
highest_severity: result.highestSeverity,
os_info: result.metadata.os,
trivy_version: result.metadata.trivyVersion,
scan_duration_ms: result.metadata.scanDurationMs,
status: 'completed',
});
db.insertVulnerabilityDetails(
scanId,
result.vulnerabilities.map((v) => ({
vulnerability_id: v.vulnerabilityId,
pkg_name: v.pkgName,
installed_version: v.installedVersion,
fixed_version: v.fixedVersion,
severity: v.severity,
title: v.title || null,
description: v.description || null,
primary_url: v.primaryUrl,
})),
);
} catch (err) {
const msg = (err as Error).message || 'Scan failed';
console.error(`[Security] Scan failed for ${imageRef}:`, msg);
db.updateVulnerabilityScan(scanId, {
status: 'failed',
error: msg,
scan_duration_ms: Date.now() - startedAt,
});
}
})();
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force }).catch((err) => {
console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message);
});
});
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
@@ -7420,6 +7363,9 @@ app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Respons
if (!imageRef) {
res.status(400).json({ error: 'imageRef is required' }); return;
}
if (!validateImageRef(imageRef)) {
res.status(400).json({ error: 'Invalid imageRef format' }); return;
}
if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') {
res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return;
}
@@ -7839,6 +7785,11 @@ async function startServer() {
// Start Docker Event Stream (causal crash/OOM/health detection per local node)
await DockerEventManager.getInstance().start();
// Detect Trivy binary so the vulnerability-scanning capability reflects
// reality before any request hits and so the first scan does not pay
// detection latency.
await TrivyService.getInstance().initialize();
// Start Background Image Update Checker
ImageUpdateService.getInstance().start();