mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
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:
+30
-79
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user