diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index d210e48c..8eef1065 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -10,7 +10,7 @@ const { mockGetDueScheduledTasks, mockCreateScheduledTaskRun, mockUpdateScheduledTaskRun, mockUpdateScheduledTask, mockCleanupOldTaskRuns, mockGetScheduledTask, mockGetNodes, mockGetNode, mockCreateSnapshot, mockInsertSnapshotFiles, mockClearStackUpdateStatus, - mockMarkStaleRunsAsFailed, + mockMarkStaleRunsAsFailed, mockDeleteOldScans, mockGetTier, mockGetVariant, mockGetContainersByStack, mockRestartContainer, mockPruneSystem, mockUpdateStack, @@ -31,6 +31,7 @@ const { mockInsertSnapshotFiles: vi.fn(), mockClearStackUpdateStatus: vi.fn(), mockMarkStaleRunsAsFailed: vi.fn().mockReturnValue(0), + mockDeleteOldScans: vi.fn().mockReturnValue(0), mockGetTier: vi.fn().mockReturnValue('paid'), mockGetVariant: vi.fn().mockReturnValue('admiral'), mockGetContainersByStack: vi.fn().mockResolvedValue([]), @@ -60,6 +61,7 @@ vi.mock('../services/DatabaseService', () => ({ insertSnapshotFiles: mockInsertSnapshotFiles, clearStackUpdateStatus: mockClearStackUpdateStatus, markStaleRunsAsFailed: mockMarkStaleRunsAsFailed, + deleteOldScans: mockDeleteOldScans, }), }, })); diff --git a/backend/src/__tests__/trivy-service.test.ts b/backend/src/__tests__/trivy-service.test.ts new file mode 100644 index 00000000..e21e5eb8 --- /dev/null +++ b/backend/src/__tests__/trivy-service.test.ts @@ -0,0 +1,54 @@ +/** + * Unit tests for TrivyService parsing, severity computation, and concurrency guard. + * + * Focuses on the pure logic exposed on the singleton: output parsing of Trivy JSON, + * highest-severity rollup, duplicate scan prevention, and graceful handling + * when the binary is not available. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import TrivyService from '../services/TrivyService'; + +describe('TrivyService', () => { + let svc: TrivyService; + + beforeEach(() => { + svc = TrivyService.getInstance(); + }); + + describe('isTrivyAvailable', () => { + it('returns false when binary has not been detected', () => { + // Service default state: available=false until initialize() runs + // Tests must not assert true here because CI may or may not have trivy installed. + const available = svc.isTrivyAvailable(); + expect(typeof available).toBe('boolean'); + }); + }); + + describe('detectTrivy', () => { + it('returns structured result regardless of binary presence', async () => { + const result = await svc.detectTrivy(); + expect(result).toHaveProperty('available'); + expect(result).toHaveProperty('version'); + expect(typeof result.available).toBe('boolean'); + }); + }); + + describe('scanImage', () => { + it('throws when Trivy is not available', async () => { + // Force availability off for this assertion + // The service caches state; reset via detectTrivy (will probably return false in CI) + const detect = await svc.detectTrivy(); + if (!detect.available) { + await expect(svc.scanImage('alpine:3.19', 1)).rejects.toThrow( + /Trivy is not available/i, + ); + } + }); + }); + + describe('isScanning guard', () => { + it('reports false for images not currently being scanned', () => { + expect(svc.isScanning(1, 'nginx:latest')).toBe(false); + }); + }); +}); diff --git a/backend/src/__tests__/vulnerability-db.test.ts b/backend/src/__tests__/vulnerability-db.test.ts new file mode 100644 index 00000000..b92b8df3 --- /dev/null +++ b/backend/src/__tests__/vulnerability-db.test.ts @@ -0,0 +1,533 @@ +/** + * Tests for the vulnerability scan / policy storage layer. + * + * Mirrors the SQL and logic in DatabaseService for the three vulnerability + * tables (vulnerability_scans, vulnerability_details, scan_policies) against + * an in-memory SQLite database so behavior can be asserted without booting + * the real DatabaseService singleton. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Database from 'better-sqlite3'; + +type Severity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS vulnerability_scans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + image_ref TEXT NOT NULL, + image_digest TEXT, + scanned_at INTEGER NOT NULL, + total_vulnerabilities INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + unknown_count INTEGER NOT NULL DEFAULT 0, + fixable_count INTEGER NOT NULL DEFAULT 0, + highest_severity TEXT, + os_info TEXT, + trivy_version TEXT, + scan_duration_ms INTEGER, + triggered_by TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'completed', + error TEXT, + stack_context TEXT + ); + + CREATE TABLE IF NOT EXISTS vulnerability_details ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + vulnerability_id TEXT NOT NULL, + pkg_name TEXT NOT NULL, + installed_version TEXT NOT NULL, + fixed_version TEXT, + severity TEXT NOT NULL, + title TEXT, + description TEXT, + primary_url TEXT, + FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS scan_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + node_id INTEGER, + stack_pattern TEXT, + max_severity TEXT NOT NULL DEFAULT 'CRITICAL', + block_on_deploy INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); +`; + +interface ScanRow { + id: number; + node_id: number; + image_ref: string; + image_digest: string | null; + scanned_at: number; + total_vulnerabilities: number; + critical_count: number; + high_count: number; + medium_count: number; + low_count: number; + unknown_count: number; + fixable_count: number; + highest_severity: Severity | null; + status: string; + triggered_by: string; +} + +interface DetailRow { + id: number; + scan_id: number; + vulnerability_id: string; + pkg_name: string; + installed_version: string; + fixed_version: string | null; + severity: Severity; +} + +interface PolicyRow { + id: number; + name: string; + node_id: number | null; + stack_pattern: string | null; + max_severity: Severity; + block_on_deploy: number; + enabled: number; + created_at: number; + updated_at: number; +} + +describe('Vulnerability scan storage (in-memory SQLite)', () => { + let db: Database.Database; + + beforeEach(() => { + db = new Database(':memory:'); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + for (const stmt of SCHEMA.split(';').map((s) => s.trim()).filter(Boolean)) { + db.prepare(stmt + ';').run(); + } + }); + + afterEach(() => { + db.close(); + }); + + // Helpers that mirror the DatabaseService implementation. + + function insertScan(overrides: Partial = {}): number { + const scan: Omit = { + node_id: 1, + image_ref: 'nginx:latest', + image_digest: 'sha256:abc', + 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, + status: 'completed', + triggered_by: 'manual', + ...overrides, + }; + const r = db + .prepare( + `INSERT INTO vulnerability_scans ( + node_id, image_ref, image_digest, scanned_at, + total_vulnerabilities, critical_count, high_count, medium_count, + low_count, unknown_count, fixable_count, highest_severity, + os_info, trivy_version, scan_duration_ms, triggered_by, status, + error, stack_context + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + scan.node_id, + scan.image_ref, + scan.image_digest, + scan.scanned_at, + scan.total_vulnerabilities, + scan.critical_count, + scan.high_count, + scan.medium_count, + scan.low_count, + scan.unknown_count, + scan.fixable_count, + scan.highest_severity, + null, + null, + null, + scan.triggered_by, + scan.status, + null, + null, + ); + return r.lastInsertRowid as number; + } + + function insertDetails(scanId: number, rows: Array>): void { + const stmt = db.prepare( + `INSERT INTO vulnerability_details ( + scan_id, vulnerability_id, pkg_name, installed_version, + fixed_version, severity, title, description, primary_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const txn = db.transaction((items: Array>) => { + for (const d of items) { + stmt.run( + scanId, + d.vulnerability_id ?? 'CVE-0000-0000', + d.pkg_name ?? 'libssl', + d.installed_version ?? '1.0.0', + d.fixed_version ?? null, + d.severity ?? 'LOW', + null, + null, + null, + ); + } + }); + txn(rows); + } + + function insertPolicy(overrides: Partial = {}): number { + const now = Date.now(); + const r = db + .prepare( + `INSERT INTO scan_policies (name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + overrides.name ?? 'default', + overrides.node_id ?? null, + overrides.stack_pattern ?? null, + overrides.max_severity ?? 'CRITICAL', + overrides.block_on_deploy ?? 0, + overrides.enabled ?? 1, + overrides.created_at ?? now, + overrides.updated_at ?? now, + ); + return r.lastInsertRowid as number; + } + + function count(table: string): number { + return (db.prepare(`SELECT COUNT(*) as c FROM ${table}`).get() as { c: number }).c; + } + + // ── vulnerability_scans CRUD ────────────────────────────────────── + + describe('scan insert / retrieve', () => { + it('round-trips a scan record by id', () => { + const id = insertScan({ + image_ref: 'alpine:3.19', + critical_count: 2, + high_count: 5, + total_vulnerabilities: 7, + highest_severity: 'CRITICAL', + }); + const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(id) as ScanRow; + expect(row).toBeDefined(); + expect(row.image_ref).toBe('alpine:3.19'); + expect(row.critical_count).toBe(2); + expect(row.high_count).toBe(5); + expect(row.total_vulnerabilities).toBe(7); + expect(row.highest_severity).toBe('CRITICAL'); + }); + + it('returns undefined for an unknown id', () => { + const row = db.prepare('SELECT * FROM vulnerability_scans WHERE id = ?').get(999); + expect(row).toBeUndefined(); + }); + }); + + // ── getLatestScanByDigest ──────────────────────────────────────── + + describe('getLatestScanByDigest', () => { + it('returns the most recent completed scan for a digest', () => { + insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' }); + insertScan({ image_digest: 'sha256:target', scanned_at: 2000, status: 'completed' }); + insertScan({ image_digest: 'sha256:other', scanned_at: 3000, status: 'completed' }); + + const row = db + .prepare( + "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1", + ) + .get('sha256:target') as ScanRow | undefined; + expect(row?.scanned_at).toBe(2000); + }); + + it('ignores failed or in_progress scans when resolving the cache', () => { + insertScan({ image_digest: 'sha256:target', scanned_at: 5000, status: 'in_progress' }); + insertScan({ image_digest: 'sha256:target', scanned_at: 4000, status: 'failed' }); + insertScan({ image_digest: 'sha256:target', scanned_at: 1000, status: 'completed' }); + + const row = db + .prepare( + "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1", + ) + .get('sha256:target') as ScanRow | undefined; + expect(row?.scanned_at).toBe(1000); + }); + }); + + // ── deleteOldScans ─────────────────────────────────────────────── + + describe('deleteOldScans', () => { + it('removes scans older than the cutoff and keeps newer ones', () => { + const now = Date.now(); + insertScan({ scanned_at: now - 100_000_000 }); + insertScan({ scanned_at: now - 200_000_000 }); + insertScan({ scanned_at: now - 1_000 }); + + const cutoff = now - 50_000_000; + const deleted = db + .prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?') + .run(cutoff).changes; + + expect(deleted).toBe(2); + expect(count('vulnerability_scans')).toBe(1); + }); + }); + + // ── vulnerability_details cascade ──────────────────────────────── + + describe('vulnerability_details cascade', () => { + it('cascades deletes when the parent scan is removed', () => { + const id = insertScan(); + insertDetails(id, [ + { vulnerability_id: 'CVE-1', severity: 'HIGH' }, + { vulnerability_id: 'CVE-2', severity: 'LOW' }, + { vulnerability_id: 'CVE-3', severity: 'CRITICAL', fixed_version: '2.0.0' }, + ]); + + expect(count('vulnerability_details')).toBe(3); + + db.prepare('DELETE FROM vulnerability_scans WHERE id = ?').run(id); + + expect(count('vulnerability_scans')).toBe(0); + expect(count('vulnerability_details')).toBe(0); + }); + + it('batches inserts inside a transaction (no partial writes on scan isolation)', () => { + const id = insertScan(); + const other = insertScan({ image_ref: 'other:1.0' }); + + insertDetails(id, [ + { vulnerability_id: 'CVE-A', severity: 'HIGH' }, + { vulnerability_id: 'CVE-B', severity: 'HIGH' }, + ]); + + const rows = db + .prepare('SELECT vulnerability_id FROM vulnerability_details WHERE scan_id = ?') + .all(id) as { vulnerability_id: string }[]; + expect(rows.map((r) => r.vulnerability_id).sort()).toEqual(['CVE-A', 'CVE-B']); + + // Other scan unaffected. + const otherCount = db + .prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ?') + .get(other) as { c: number }; + expect(otherCount.c).toBe(0); + }); + }); + + // ── getVulnerabilityDetails ordering ───────────────────────────── + + describe('getVulnerabilityDetails ordering', () => { + it('orders by severity (CRITICAL → HIGH → MEDIUM → LOW → UNKNOWN) then pkg_name', () => { + const id = insertScan(); + insertDetails(id, [ + { vulnerability_id: 'CVE-L', severity: 'LOW', pkg_name: 'aaa' }, + { vulnerability_id: 'CVE-C', severity: 'CRITICAL', pkg_name: 'bbb' }, + { vulnerability_id: 'CVE-H', severity: 'HIGH', pkg_name: 'ccc' }, + { vulnerability_id: 'CVE-U', severity: 'UNKNOWN', pkg_name: 'ddd' }, + { vulnerability_id: 'CVE-M', severity: 'MEDIUM', pkg_name: 'eee' }, + ]); + + const severityOrder = `CASE severity + WHEN 'CRITICAL' THEN 0 + WHEN 'HIGH' THEN 1 + WHEN 'MEDIUM' THEN 2 + WHEN 'LOW' THEN 3 + ELSE 4 END`; + const rows = db + .prepare( + `SELECT severity, pkg_name FROM vulnerability_details WHERE scan_id = ? ORDER BY ${severityOrder}, pkg_name`, + ) + .all(id) as Array<{ severity: Severity; pkg_name: string }>; + + expect(rows.map((r) => r.severity)).toEqual([ + 'CRITICAL', + 'HIGH', + 'MEDIUM', + 'LOW', + 'UNKNOWN', + ]); + }); + + it('filters by severity when requested', () => { + const id = insertScan(); + insertDetails(id, [ + { vulnerability_id: 'CVE-1', severity: 'HIGH' }, + { vulnerability_id: 'CVE-2', severity: 'HIGH' }, + { vulnerability_id: 'CVE-3', severity: 'LOW' }, + ]); + + const highs = db + .prepare('SELECT COUNT(*) as c FROM vulnerability_details WHERE scan_id = ? AND severity = ?') + .get(id, 'HIGH') as { c: number }; + expect(highs.c).toBe(2); + }); + }); + + // ── getImageScanSummaries (latest-per-image JOIN) ──────────────── + + describe('getImageScanSummaries', () => { + const SUMMARIES_SQL = ` + SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities, + vs.critical_count, vs.high_count, vs.medium_count, vs.low_count, + vs.unknown_count, vs.fixable_count, vs.scanned_at + FROM vulnerability_scans vs + INNER JOIN ( + SELECT image_ref, MAX(scanned_at) AS max_scanned + FROM vulnerability_scans + WHERE node_id = ? AND status = 'completed' + GROUP BY image_ref + ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at + WHERE vs.node_id = ? AND vs.status = 'completed'`; + + it('returns only the latest scan per image_ref', () => { + insertScan({ image_ref: 'nginx:1', scanned_at: 100, highest_severity: 'LOW' }); + insertScan({ image_ref: 'nginx:1', scanned_at: 200, highest_severity: 'HIGH' }); + insertScan({ image_ref: 'nginx:1', scanned_at: 300, highest_severity: 'CRITICAL' }); + insertScan({ image_ref: 'redis:7', scanned_at: 150, highest_severity: 'MEDIUM' }); + + const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ + image_ref: string; + scanned_at: number; + highest_severity: Severity; + }>; + + expect(rows.length).toBe(2); + const byImage = Object.fromEntries(rows.map((r) => [r.image_ref, r])); + expect(byImage['nginx:1'].scanned_at).toBe(300); + expect(byImage['nginx:1'].highest_severity).toBe('CRITICAL'); + expect(byImage['redis:7'].scanned_at).toBe(150); + }); + + it('ignores in_progress and failed scans', () => { + insertScan({ image_ref: 'alpine:3', scanned_at: 500, status: 'in_progress' }); + insertScan({ image_ref: 'alpine:3', scanned_at: 100, status: 'completed', highest_severity: 'LOW' }); + + const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ image_ref: string; scanned_at: number }>; + expect(rows.length).toBe(1); + expect(rows[0].scanned_at).toBe(100); + }); + + it('scopes results to the requested node', () => { + insertScan({ image_ref: 'nginx:1', scanned_at: 100, node_id: 1 }); + insertScan({ image_ref: 'nginx:1', scanned_at: 200, node_id: 2 }); + + const rows = db.prepare(SUMMARIES_SQL).all(1, 1) as Array<{ scanned_at: number }>; + expect(rows.length).toBe(1); + expect(rows[0].scanned_at).toBe(100); + }); + }); + + // ── scan_policies CRUD + glob matching ─────────────────────────── + + describe('scan_policies CRUD', () => { + it('creates and retrieves a policy', () => { + const id = insertPolicy({ name: 'prod-gate', stack_pattern: 'prod-*', max_severity: 'HIGH', block_on_deploy: 1 }); + const row = db.prepare('SELECT * FROM scan_policies WHERE id = ?').get(id) as PolicyRow; + expect(row.name).toBe('prod-gate'); + expect(row.stack_pattern).toBe('prod-*'); + expect(row.max_severity).toBe('HIGH'); + expect(row.block_on_deploy).toBe(1); + expect(row.enabled).toBe(1); + }); + + it('deletes a policy by id', () => { + const id = insertPolicy(); + insertPolicy({ name: 'other' }); + db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id); + expect(count('scan_policies')).toBe(1); + }); + }); + + describe('getMatchingPolicy (glob + scope priority)', () => { + // Mirrors the matching logic in DatabaseService.getMatchingPolicy. + function findMatching(nodeId: number, stackName: string | null): PolicyRow | null { + const policies = db + .prepare( + 'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)', + ) + .all(nodeId) as PolicyRow[]; + const matchesStack = (pattern: string | null): boolean => { + if (!pattern) return true; + if (!stackName) return false; + const regex = new RegExp( + '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', + ); + return regex.test(stackName); + }; + const scoped = policies.filter((p) => matchesStack(p.stack_pattern)); + if (scoped.length === 0) return null; + scoped.sort((a, b) => { + if (a.node_id && !b.node_id) return -1; + if (!a.node_id && b.node_id) return 1; + if (a.stack_pattern && !b.stack_pattern) return -1; + if (!a.stack_pattern && b.stack_pattern) return 1; + return 0; + }); + return scoped[0]; + } + + it('matches a glob pattern against the stack name', () => { + insertPolicy({ name: 'prod-policy', stack_pattern: 'prod-*', max_severity: 'HIGH' }); + expect(findMatching(1, 'prod-web')?.name).toBe('prod-policy'); + expect(findMatching(1, 'dev-api')).toBeNull(); + }); + + it('null pattern acts as a wildcard (matches any stack)', () => { + insertPolicy({ name: 'catch-all', stack_pattern: null }); + expect(findMatching(1, 'anything')?.name).toBe('catch-all'); + expect(findMatching(1, null)?.name).toBe('catch-all'); + }); + + it('scoped policy (node_id set) wins over global when both match', () => { + insertPolicy({ name: 'global', stack_pattern: null, node_id: null }); + insertPolicy({ name: 'node-specific', stack_pattern: null, node_id: 1 }); + expect(findMatching(1, 'whatever')?.name).toBe('node-specific'); + }); + + it('patterned policy wins over wildcard when both match', () => { + insertPolicy({ name: 'wildcard', stack_pattern: null }); + insertPolicy({ name: 'patterned', stack_pattern: 'prod-*' }); + expect(findMatching(1, 'prod-web')?.name).toBe('patterned'); + }); + + it('ignores disabled policies', () => { + insertPolicy({ name: 'disabled', stack_pattern: null, enabled: 0 }); + expect(findMatching(1, 'prod-web')).toBeNull(); + }); + + it('escapes regex metacharacters in patterns (dots are literal)', () => { + insertPolicy({ name: 'literal-dot', stack_pattern: 'app.prod' }); + expect(findMatching(1, 'app.prod')?.name).toBe('literal-dot'); + // The dot must NOT act as "any character", otherwise "appXprod" would match. + expect(findMatching(1, 'appXprod')).toBeNull(); + }); + + it('ignores policies scoped to a different node', () => { + insertPolicy({ name: 'node-2-only', node_id: 2, stack_pattern: null }); + expect(findMatching(1, 'whatever')).toBeNull(); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 6d04bffd..7f9fd557 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -69,6 +69,7 @@ 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 semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; import { isValidStackName, isValidRemoteUrl, isPathWithinBase, isValidCidr, isValidIPv4, isValidDockerResourceId } from './utils/validation'; @@ -1578,12 +1579,78 @@ function isSqliteUniqueViolation(error: unknown): boolean { return error instanceof Error && 'code' in error && (error as { code: string }).code === 'SQLITE_CONSTRAINT_UNIQUE'; } -// Tier gate for scheduled tasks: 'update' action requires Skipper+, everything else requires Admiral. +// Tier gate for scheduled tasks: 'update' and 'scan' actions require Skipper+, everything else requires Admiral. const requireScheduledTaskTier = (action: string, req: Request, res: Response): boolean => { - if (action === 'update') return requirePaid(req, res); + if (action === 'update' || action === 'scan') return requirePaid(req, res); return requireAdmiral(req, res); }; +async function triggerPostDeployScan( + stackName: string, + nodeId: number, +): Promise { + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) return; + try { + const docker = DockerController.getInstance(nodeId).getDocker(); + const containers = await docker.listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${stackName}`] }, + }); + const imageRefs = new Set(); + for (const c of containers as Array<{ Image?: string }>) { + if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image); + } + if (imageRefs.size === 0) return; + + 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; + } + const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName); + + if (scan.critical_count > 0 || scan.high_count > 0) { + NotificationService.getInstance().dispatchAlert( + scan.critical_count > 0 ? 'error' : 'warning', + `Vulnerability scan for ${imageRef}: ${scan.critical_count} critical, ${scan.high_count} high`, + stackName, + ); + } + + if ( + policy && + severityRank(scan.highest_severity) >= severityRank(policy.max_severity) + ) { + NotificationService.getInstance().dispatchAlert( + policy.block_on_deploy ? 'error' : 'warning', + `Policy "${policy.name}" triggered for ${imageRef}: ${scan.highest_severity} exceeds ${policy.max_severity}`, + stackName, + ); + } + } catch (err) { + console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, (err as Error).message); + } + } + } catch (err) { + console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, (err as Error).message); + } +} + // --- Scoped RBAC Permission Engine (Admiral) --- type PermissionAction = @@ -4474,6 +4541,11 @@ app.post('/api/stacks/:stackName/git-source/apply', async (req: Request, res: Re console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`); } res.json(result); + if (result.deployed) { + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), + ); + } } catch (error) { sendGitSourceError(res, error); } @@ -4641,6 +4713,11 @@ app.post('/api/stacks/from-git', async (req: Request, res: Response) => { deployed, deployError, }); + if (deployed) { + triggerPostDeployScan(stack_name, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err), + ); + } } catch (error) { if (fromGitDiag) { const code = error instanceof GitSourceError ? error.code : 'UNKNOWN'; @@ -4791,6 +4868,9 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => console.log(`[Stacks] Deploy completed: ${stackName}`); if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); res.json({ message: 'Deployed successfully' }); + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), + ); } catch (error: unknown) { console.error(`[Stacks] Deploy failed: ${stackName}`, error); const rolledBack = LicenseService.getInstance().getTier() === 'paid'; @@ -4910,6 +4990,9 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => console.log(`[Stacks] Update completed: ${stackName}`); if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`); res.json({ status: 'Update completed' }); + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), + ); } catch (error) { console.error(`[Stacks] Update failed: ${stackName}`, error); const rolledBack = LicenseService.getInstance().getTier() === 'paid'; @@ -6033,8 +6116,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (!['stack', 'fleet', 'system'].includes(target_type)) { res.status(400).json({ error: 'Invalid target_type. Must be stack, fleet, or system.' }); return; } - if (!['restart', 'snapshot', 'prune', 'update'].includes(action)) { - res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, or update.' }); return; + if (!['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) { + res.status(400).json({ error: 'Invalid action. Must be restart, snapshot, prune, update, or scan.' }); return; } // Tier gate based on action type if (!requireScheduledTaskTier(action, req, res)) return; @@ -6051,6 +6134,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { if (action === 'prune' && target_type !== 'system') { res.status(400).json({ error: 'Prune action requires target_type "system".' }); return; } + if (action === 'scan' && target_type !== 'system') { + res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; + } if (target_type === 'stack' && (!target_id || !node_id)) { res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return; } @@ -6151,7 +6237,7 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (target_type && !['stack', 'fleet', 'system'].includes(target_type)) { res.status(400).json({ error: 'Invalid target_type' }); return; } - if (action && !['restart', 'snapshot', 'prune', 'update'].includes(action)) { + if (action && !['restart', 'snapshot', 'prune', 'update', 'scan'].includes(action)) { res.status(400).json({ error: 'Invalid action' }); return; } @@ -6169,6 +6255,9 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { if (finalAction === 'prune' && finalTargetType !== 'system') { res.status(400).json({ error: 'Prune action requires target_type "system".' }); return; } + if (finalAction === 'scan' && finalTargetType !== 'system') { + res.status(400).json({ error: 'Scan action requires target_type "system".' }); return; + } // Validate prune targets const validPruneTargets = ['containers', 'images', 'networks', 'volumes']; @@ -6847,7 +6936,7 @@ app.post('/api/templates/refresh-cache', authMiddleware, (req: Request, res: Res app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Response) => { if (!requireAdmin(req, res)) return; try { - const { stackName, template, envVars } = req.body; + const { stackName, template, envVars, skip_scan } = req.body; if (!stackName || !template) { return res.status(400).json({ error: 'stackName and template are required' }); @@ -6906,6 +6995,11 @@ app.post('/api/templates/deploy', authMiddleware, async (req: Request, res: Resp invalidateNodeCaches(req.nodeId); console.log(`[Templates] Deploy completed: ${stackName}`); res.json({ success: true, message: 'Template deployed successfully' }); + if (!skip_scan) { + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err), + ); + } } catch (deployError: unknown) { const rawError = getErrorMessage(deployError, String(deployError)); console.error(`[Templates] Deploy failed: ${stackName} -`, rawError); @@ -7151,6 +7245,299 @@ app.post('/api/auto-update/execute', authMiddleware, async (req: Request, res: R } }); +// ========================= +// Vulnerability Scanning Routes +// ========================= + +app.get('/api/security/trivy-status', authMiddleware, (_req: Request, res: Response) => { + const svc = TrivyService.getInstance(); + res.json({ available: svc.isTrivyAvailable(), version: svc.getVersion() }); +}); + +app.post('/api/security/scan', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) { + 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) { + res.status(400).json({ error: 'imageRef is required' }); + return; + } + const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null; + const force = req.body?.force === true; + const nodeId = req.nodeId; + if (svc.isScanning(nodeId, imageRef)) { + 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, + }); + 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, + }); + } + })(); +}); + +app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => { + try { + const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined; + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, { + imageRef, + limit, + offset, + }); + res.json(result); + } catch (error) { + console.error('[Security] Failed to list scans:', error); + res.status(500).json({ error: 'Failed to list scans' }); + } +}); + +app.get('/api/security/scans/:scanId', authMiddleware, (req: Request, res: Response): void => { + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const scan = DatabaseService.getInstance().getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + res.json(scan); +}); + +app.get( + '/api/security/scans/:scanId/vulnerabilities', + authMiddleware, + (req: Request, res: Response): void => { + const scanId = Number(req.params.scanId); + if (!Number.isFinite(scanId)) { + res.status(400).json({ error: 'Invalid scan id' }); return; + } + const db = DatabaseService.getInstance(); + const scan = db.getVulnerabilityScan(scanId); + if (!scan || scan.node_id !== req.nodeId) { + res.status(404).json({ error: 'Scan not found' }); return; + } + const severity = typeof req.query.severity === 'string' + ? (req.query.severity.toUpperCase() as 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN') + : undefined; + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'UNKNOWN']); + if (severity && !validSeverities.has(severity)) { + res.status(400).json({ error: 'Invalid severity filter' }); return; + } + const limit = req.query.limit ? Number(req.query.limit) : undefined; + const offset = req.query.offset ? Number(req.query.offset) : undefined; + const result = db.getVulnerabilityDetails(scanId, { severity, limit, offset }); + res.json(result); + }, +); + +app.get('/api/security/image-summaries', authMiddleware, (req: Request, res: Response) => { + try { + const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId); + res.json(summaries); + } catch (error) { + console.error('[Security] Failed to fetch image summaries:', error); + res.status(500).json({ error: 'Failed to fetch image summaries' }); + } +}); + +app.post('/api/security/sbom', authMiddleware, async (req: Request, res: Response): Promise => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const svc = TrivyService.getInstance(); + if (!svc.isTrivyAvailable()) { + res.status(503).json({ error: 'Trivy is not available on this host' }); return; + } + const imageRef = typeof req.body?.imageRef === 'string' ? req.body.imageRef.trim() : ''; + const formatRaw = typeof req.body?.format === 'string' ? req.body.format : 'spdx-json'; + if (!imageRef) { + res.status(400).json({ error: 'imageRef is required' }); return; + } + if (formatRaw !== 'spdx-json' && formatRaw !== 'cyclonedx') { + res.status(400).json({ error: 'format must be spdx-json or cyclonedx' }); return; + } + try { + const sbom = await svc.generateSBOM(imageRef, formatRaw as SbomFormat); + const safeName = imageRef.replace(/[^a-zA-Z0-9._-]/g, '_'); + const ext = formatRaw === 'spdx-json' ? 'spdx.json' : 'cdx.json'; + res.setHeader('Content-Type', 'application/json'); + res.setHeader('Content-Disposition', `attachment; filename="${safeName}.${ext}"`); + res.send(sbom); + } catch (error) { + console.error('[Security] SBOM generation failed:', error); + res.status(500).json({ error: (error as Error).message || 'Failed to generate SBOM' }); + } +}); + +app.get('/api/security/policies', authMiddleware, (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + res.json(DatabaseService.getInstance().getScanPolicies()); +}); + +app.post('/api/security/policies', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {}; + if (!name || typeof name !== 'string' || !name.trim()) { + res.status(400).json({ error: 'Policy name is required' }); return; + } + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); + if (!validSeverities.has(max_severity)) { + res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; + } + try { + const policy = DatabaseService.getInstance().createScanPolicy({ + name: name.trim(), + node_id: node_id != null ? Number(node_id) : null, + stack_pattern: stack_pattern ? String(stack_pattern) : null, + max_severity, + block_on_deploy: block_on_deploy ? 1 : 0, + enabled: enabled === false ? 0 : 1, + }); + res.status(201).json(policy); + } catch (error) { + console.error('[Security] Failed to create policy:', error); + res.status(500).json({ error: 'Failed to create policy' }); + } +}); + +app.put('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid policy id' }); return; + } + const body = req.body ?? {}; + const updates: Record = {}; + if (body.name !== undefined) updates.name = String(body.name).trim(); + if (body.node_id !== undefined) updates.node_id = body.node_id != null ? Number(body.node_id) : null; + if (body.stack_pattern !== undefined) updates.stack_pattern = body.stack_pattern ? String(body.stack_pattern) : null; + if (body.max_severity !== undefined) { + const validSeverities = new Set(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']); + if (!validSeverities.has(body.max_severity)) { + res.status(400).json({ error: 'max_severity must be CRITICAL, HIGH, MEDIUM, or LOW' }); return; + } + updates.max_severity = body.max_severity; + } + if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0; + if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0; + const policy = DatabaseService.getInstance().updateScanPolicy(id, updates); + if (!policy) { + res.status(404).json({ error: 'Policy not found' }); return; + } + res.json(policy); +}); + +app.delete('/api/security/policies/:id', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (!requirePaid(req, res)) return; + const id = Number(req.params.id); + if (!Number.isFinite(id)) { + res.status(400).json({ error: 'Invalid policy id' }); return; + } + DatabaseService.getInstance().deleteScanPolicy(id); + res.json({ success: true }); +}); + +app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): void => { + if (!requirePaid(req, res)) return; + const scanId1 = Number(req.query.scanId1); + const scanId2 = Number(req.query.scanId2); + if (!Number.isFinite(scanId1) || !Number.isFinite(scanId2)) { + res.status(400).json({ error: 'scanId1 and scanId2 are required' }); return; + } + const db = DatabaseService.getInstance(); + const a = db.getVulnerabilityScan(scanId1); + const b = db.getVulnerabilityScan(scanId2); + if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) { + res.status(404).json({ error: 'One or both scans not found' }); return; + } + const aVulns = db.getVulnerabilityDetails(scanId1, { limit: 1000 }).items; + const bVulns = db.getVulnerabilityDetails(scanId2, { limit: 1000 }).items; + const keyOf = (v: { vulnerability_id: string; pkg_name: string }) => + `${v.vulnerability_id}::${v.pkg_name}`; + const aMap = new Map(aVulns.map((v) => [keyOf(v), v])); + const bMap = new Map(bVulns.map((v) => [keyOf(v), v])); + const added = bVulns.filter((v) => !aMap.has(keyOf(v))); + const removed = aVulns.filter((v) => !bMap.has(keyOf(v))); + const unchanged = aVulns.filter((v) => bMap.has(keyOf(v))); + res.json({ + scanA: { id: a.id, scanned_at: a.scanned_at, image_ref: a.image_ref }, + scanB: { id: b.id, scanned_at: b.scanned_at, image_ref: b.image_ref }, + added, + removed, + unchanged: unchanged.map((v) => ({ vulnerability_id: v.vulnerability_id, pkg_name: v.pkg_name, severity: v.severity })), + }); +}); + // ========================= // Node Management API // ========================= diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index bcb01ac7..bd895afc 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -33,6 +33,7 @@ export const CAPABILITIES = [ 'users', 'registries', 'self-update', + 'vulnerability-scanning', ] as const; export type Capability = (typeof CAPABILITIES)[number]; diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 5b7e9770..02b56798 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -212,7 +212,7 @@ export interface ScheduledTask { target_type: 'stack' | 'fleet' | 'system'; target_id: string | null; node_id: number | null; - action: 'restart' | 'snapshot' | 'prune' | 'update'; + action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan'; cron_expression: string; enabled: number; created_by: string; @@ -264,6 +264,72 @@ export interface NotificationRoute { updated_at: number; } +export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; +export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; +export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy'; + +export interface VulnerabilityScan { + id: number; + node_id: number; + image_ref: string; + image_digest: string | null; + scanned_at: number; + total_vulnerabilities: number; + critical_count: number; + high_count: number; + medium_count: number; + low_count: number; + unknown_count: number; + fixable_count: number; + highest_severity: VulnSeverity | null; + os_info: string | null; + trivy_version: string | null; + scan_duration_ms: number | null; + triggered_by: VulnScanTrigger; + status: VulnScanStatus; + error: string | null; + stack_context: string | null; +} + +export interface VulnerabilityDetail { + id: number; + scan_id: number; + vulnerability_id: string; + pkg_name: string; + installed_version: string; + fixed_version: string | null; + severity: VulnSeverity; + title: string | null; + description: string | null; + primary_url: string | null; +} + +export interface ScanPolicy { + id: number; + name: string; + node_id: number | null; + stack_pattern: string | null; + max_severity: VulnSeverity; + block_on_deploy: number; + enabled: number; + created_at: number; + updated_at: number; +} + +export interface ScanSummary { + image_ref: string; + highest_severity: VulnSeverity | null; + total: number; + critical: number; + high: number; + medium: number; + low: number; + unknown: number; + fixable: number; + scanned_at: number; + scan_id: number; +} + export class DatabaseService { private static instance: DatabaseService; private db: Database.Database; @@ -490,6 +556,62 @@ export class DatabaseService { CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status ON scheduled_task_runs(status); CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at); + CREATE TABLE IF NOT EXISTS vulnerability_scans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + image_ref TEXT NOT NULL, + image_digest TEXT, + scanned_at INTEGER NOT NULL, + total_vulnerabilities INTEGER NOT NULL DEFAULT 0, + critical_count INTEGER NOT NULL DEFAULT 0, + high_count INTEGER NOT NULL DEFAULT 0, + medium_count INTEGER NOT NULL DEFAULT 0, + low_count INTEGER NOT NULL DEFAULT 0, + unknown_count INTEGER NOT NULL DEFAULT 0, + fixable_count INTEGER NOT NULL DEFAULT 0, + highest_severity TEXT, + os_info TEXT, + trivy_version TEXT, + scan_duration_ms INTEGER, + triggered_by TEXT NOT NULL DEFAULT 'manual', + status TEXT NOT NULL DEFAULT 'completed', + error TEXT, + stack_context TEXT + ); + + 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 TABLE IF NOT EXISTS vulnerability_details ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scan_id INTEGER NOT NULL, + vulnerability_id TEXT NOT NULL, + pkg_name TEXT NOT NULL, + installed_version TEXT NOT NULL, + fixed_version TEXT, + severity TEXT NOT NULL, + title TEXT, + description TEXT, + primary_url TEXT, + FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_vuln_details_scan ON vulnerability_details(scan_id); + CREATE INDEX IF NOT EXISTS idx_vuln_details_severity ON vulnerability_details(severity); + + CREATE TABLE IF NOT EXISTS scan_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + node_id INTEGER, + stack_pattern TEXT, + max_severity TEXT NOT NULL DEFAULT 'CRITICAL', + block_on_deploy INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS stack_labels ( id INTEGER PRIMARY KEY AUTOINCREMENT, node_id INTEGER NOT NULL DEFAULT 0, @@ -1902,6 +2024,350 @@ export class DatabaseService { this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff); } + // --- Vulnerability Scans --- + + public createVulnerabilityScan( + scan: Omit, + ): number { + const stmt = this.db.prepare( + `INSERT INTO vulnerability_scans ( + node_id, image_ref, image_digest, scanned_at, + total_vulnerabilities, critical_count, high_count, medium_count, + low_count, unknown_count, fixable_count, highest_severity, + os_info, trivy_version, scan_duration_ms, triggered_by, status, + error, stack_context + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const result = stmt.run( + scan.node_id, + scan.image_ref, + scan.image_digest, + scan.scanned_at, + scan.total_vulnerabilities, + scan.critical_count, + scan.high_count, + scan.medium_count, + scan.low_count, + scan.unknown_count, + scan.fixable_count, + scan.highest_severity, + scan.os_info, + scan.trivy_version, + scan.scan_duration_ms, + scan.triggered_by, + scan.status, + scan.error, + scan.stack_context, + ); + return result.lastInsertRowid as number; + } + + public updateVulnerabilityScan( + id: number, + updates: Partial>, + ): void { + const ALLOWED_COLUMNS = new Set([ + 'node_id', 'image_ref', 'image_digest', 'scanned_at', + 'total_vulnerabilities', 'critical_count', 'high_count', + 'medium_count', 'low_count', 'unknown_count', 'fixable_count', + 'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms', + 'triggered_by', 'status', 'error', 'stack_context', + ]); + const fields: string[] = []; + const values: unknown[] = []; + for (const [key, value] of Object.entries(updates)) { + if (!ALLOWED_COLUMNS.has(key)) continue; + fields.push(`${key} = ?`); + values.push(value); + } + if (fields.length === 0) return; + values.push(id); + this.db + .prepare(`UPDATE vulnerability_scans SET ${fields.join(', ')} WHERE id = ?`) + .run(...(values as never[])); + } + + public getVulnerabilityScan(id: number): VulnerabilityScan | null { + return ( + (this.db + .prepare('SELECT * FROM vulnerability_scans WHERE id = ?') + .get(id) as VulnerabilityScan | undefined) ?? null + ); + } + + public getVulnerabilityScans( + nodeId: number, + opts: { imageRef?: string; limit?: number; offset?: number } = {}, + ): { items: VulnerabilityScan[]; total: number } { + const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)); + const offset = Math.max(0, opts.offset ?? 0); + const where = ['node_id = ?']; + const params: unknown[] = [nodeId]; + if (opts.imageRef) { + where.push('image_ref = ?'); + params.push(opts.imageRef); + } + const whereSql = where.join(' AND '); + const total = ( + this.db + .prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`) + .get(...(params as never[])) as { cnt: number } + ).cnt; + const items = this.db + .prepare( + `SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`, + ) + .all(...(params as never[]), limit, offset) as VulnerabilityScan[]; + return { items, total }; + } + + public getLatestScanForImage( + nodeId: number, + imageRef: string, + ): VulnerabilityScan | null { + return ( + (this.db + .prepare( + 'SELECT * FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? ORDER BY scanned_at DESC LIMIT 1', + ) + .get(nodeId, imageRef) as VulnerabilityScan | undefined) ?? null + ); + } + + public getLatestScanByDigest(digest: string): VulnerabilityScan | null { + if (!digest) return null; + return ( + (this.db + .prepare( + "SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1", + ) + .get(digest) as VulnerabilityScan | undefined) ?? null + ); + } + + public deleteOldScans(olderThanMs: number): number { + const cutoff = Date.now() - olderThanMs; + const result = this.db + .prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?') + .run(cutoff); + return result.changes; + } + + public isImageBeingScanned(nodeId: number, imageRef: string): boolean { + const row = this.db + .prepare( + "SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? AND status = 'in_progress' LIMIT 1", + ) + .get(nodeId, imageRef); + return !!row; + } + + public insertVulnerabilityDetails( + scanId: number, + details: Array>, + ): void { + if (details.length === 0) return; + const stmt = this.db.prepare( + `INSERT INTO vulnerability_details ( + scan_id, vulnerability_id, pkg_name, installed_version, + fixed_version, severity, title, description, primary_url + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const txn = this.db.transaction((rows: typeof details) => { + for (const d of rows) { + stmt.run( + scanId, + d.vulnerability_id, + d.pkg_name, + d.installed_version, + d.fixed_version, + d.severity, + d.title, + d.description, + d.primary_url, + ); + } + }); + txn(details); + } + + public getVulnerabilityDetails( + scanId: number, + opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {}, + ): { items: VulnerabilityDetail[]; total: number } { + const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000)); + const offset = Math.max(0, opts.offset ?? 0); + const where = ['scan_id = ?']; + const params: unknown[] = [scanId]; + if (opts.severity) { + where.push('severity = ?'); + params.push(opts.severity); + } + const whereSql = where.join(' AND '); + const total = ( + this.db + .prepare(`SELECT COUNT(*) as cnt FROM vulnerability_details WHERE ${whereSql}`) + .get(...(params as never[])) as { cnt: number } + ).cnt; + const severityOrder = `CASE severity + WHEN 'CRITICAL' THEN 0 + WHEN 'HIGH' THEN 1 + WHEN 'MEDIUM' THEN 2 + WHEN 'LOW' THEN 3 + ELSE 4 END`; + const items = this.db + .prepare( + `SELECT * FROM vulnerability_details WHERE ${whereSql} ORDER BY ${severityOrder}, pkg_name LIMIT ? OFFSET ?`, + ) + .all(...(params as never[]), limit, offset) as VulnerabilityDetail[]; + return { items, total }; + } + + public getImageScanSummaries(nodeId: number): Record { + const rows = this.db + .prepare( + `SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities, + vs.critical_count, vs.high_count, vs.medium_count, vs.low_count, + vs.unknown_count, vs.fixable_count, vs.scanned_at + FROM vulnerability_scans vs + INNER JOIN ( + SELECT image_ref, MAX(scanned_at) AS max_scanned + FROM vulnerability_scans + WHERE node_id = ? AND status = 'completed' + GROUP BY image_ref + ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at + WHERE vs.node_id = ? AND vs.status = 'completed'`, + ) + .all(nodeId, nodeId) as Array<{ + image_ref: string; + scan_id: number; + highest_severity: VulnSeverity | null; + total_vulnerabilities: number; + critical_count: number; + high_count: number; + medium_count: number; + low_count: number; + unknown_count: number; + fixable_count: number; + scanned_at: number; + }>; + const out: Record = {}; + for (const r of rows) { + out[r.image_ref] = { + image_ref: r.image_ref, + highest_severity: r.highest_severity, + total: r.total_vulnerabilities, + critical: r.critical_count, + high: r.high_count, + medium: r.medium_count, + low: r.low_count, + unknown: r.unknown_count, + fixable: r.fixable_count, + scanned_at: r.scanned_at, + scan_id: r.scan_id, + }; + } + return out; + } + + // --- Scan Policies --- + + public getScanPolicies(): ScanPolicy[] { + return this.db + .prepare('SELECT * FROM scan_policies ORDER BY created_at DESC') + .all() as ScanPolicy[]; + } + + public getScanPolicy(id: number): ScanPolicy | null { + return ( + (this.db + .prepare('SELECT * FROM scan_policies WHERE id = ?') + .get(id) as ScanPolicy | undefined) ?? null + ); + } + + public createScanPolicy( + policy: Omit, + ): ScanPolicy { + const now = Date.now(); + const result = this.db + .prepare( + `INSERT INTO scan_policies (name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + policy.name, + policy.node_id, + policy.stack_pattern, + policy.max_severity, + policy.block_on_deploy, + policy.enabled, + now, + now, + ); + return { ...policy, id: result.lastInsertRowid as number, created_at: now, updated_at: now }; + } + + public updateScanPolicy( + id: number, + updates: Partial>, + ): ScanPolicy | null { + const existing = this.getScanPolicy(id); + if (!existing) return null; + const ALLOWED_COLUMNS = new Set([ + 'name', 'node_id', 'stack_pattern', 'max_severity', + 'block_on_deploy', 'enabled', + ]); + const fields: string[] = []; + const values: unknown[] = []; + for (const [key, value] of Object.entries(updates)) { + if (!ALLOWED_COLUMNS.has(key)) continue; + fields.push(`${key} = ?`); + values.push(value); + } + if (fields.length === 0) return existing; + fields.push('updated_at = ?'); + values.push(Date.now()); + values.push(id); + this.db + .prepare(`UPDATE scan_policies SET ${fields.join(', ')} WHERE id = ?`) + .run(...(values as never[])); + return this.getScanPolicy(id); + } + + public deleteScanPolicy(id: number): void { + this.db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id); + } + + public getMatchingPolicy( + nodeId: number, + stackName: string | null, + ): ScanPolicy | null { + const policies = this.db + .prepare( + 'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)', + ) + .all(nodeId) as ScanPolicy[]; + const matchesStack = (pattern: string | null): boolean => { + if (!pattern) return true; + if (!stackName) return false; + const regex = new RegExp( + '^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$', + ); + return regex.test(stackName); + }; + const scoped = policies.filter((p) => matchesStack(p.stack_pattern)); + if (scoped.length === 0) return null; + scoped.sort((a, b) => { + if (a.node_id && !b.node_id) return -1; + if (!a.node_id && b.node_id) return 1; + if (a.stack_pattern && !b.stack_pattern) return -1; + if (!a.stack_pattern && b.stack_pattern) return 1; + return 0; + }); + return scoped[0]; + } + // --- Stack Labels --- public getLabels(nodeId: number): Label[] { diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index fb0792a6..a58c98b7 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -12,6 +12,7 @@ import { getErrorMessage } from '../utils/errors'; import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture'; import { NodeRegistry } from './NodeRegistry'; import { NotificationService } from './NotificationService'; +import TrivyService from './TrivyService'; export class SchedulerService { private static instance: SchedulerService; @@ -82,9 +83,10 @@ export class SchedulerService { // Clean up old runs periodically (piggyback on tick) db.cleanupOldTaskRuns(30); + db.deleteOldScans(90 * 24 * 60 * 60 * 1000); for (const task of dueTasks) { - if (!isAdmiral && task.action !== 'update') { + if (!isAdmiral && task.action !== 'update' && task.action !== 'scan') { if (isDebugEnabled()) console.log(`[SchedulerService] Task ${task.id} skipped: action "${task.action}" requires Admiral tier`); continue; } @@ -159,6 +161,9 @@ export class SchedulerService { case 'update': output = await this.executeUpdate(task); break; + case 'scan': + output = await this.executeScan(task); + break; } if (isDebugEnabled()) console.log(`[SchedulerService:debug] Task ${task.id} action completed in ${Date.now() - actionStart}ms`); @@ -498,4 +503,23 @@ export class SchedulerService { return `Stack "${stackName}": updated (${updatedImages.join(', ')}).`; } + + private async executeScan(task: ScheduledTask): Promise { + const trivy = TrivyService.getInstance(); + if (!trivy.isTrivyAvailable()) { + throw new Error('Trivy binary is not available on this node'); + } + + const nodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId(); + if (task.node_id == null && isDebugEnabled()) { + console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`); + } + + const summary = await trivy.scanAllNodeImages(nodeId, 'scheduled'); + + const parts: string[] = [`Scanned ${summary.scanned} image(s)`]; + if (summary.skipped > 0) parts.push(`${summary.skipped} skipped (cached)`); + if (summary.failed > 0) parts.push(`${summary.failed} failed`); + return parts.join('; '); + } } diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts new file mode 100644 index 00000000..664794dd --- /dev/null +++ b/backend/src/services/TrivyService.ts @@ -0,0 +1,515 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import DockerController from './DockerController'; +import { + DatabaseService, + VulnSeverity, + VulnScanTrigger, + VulnerabilityScan, +} from './DatabaseService'; +import { RegistryService } from './RegistryService'; +import { disableCapability } from './CapabilityRegistry'; + +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; + +interface TrivyRawVulnerability { + VulnerabilityID?: string; + PkgName?: string; + InstalledVersion?: string; + FixedVersion?: string; + Severity?: string; + Title?: string; + Description?: string; + PrimaryURL?: string; +} + +interface TrivyRawResult { + Target?: string; + Vulnerabilities?: TrivyRawVulnerability[]; +} + +interface TrivyRawOutput { + Metadata?: { + OS?: { Family?: string; Name?: string }; + ImageID?: string; + RepoDigests?: string[]; + }; + Results?: TrivyRawResult[]; +} + +export interface TrivyVulnerability { + vulnerabilityId: string; + pkgName: string; + installedVersion: string; + fixedVersion: string | null; + severity: VulnSeverity; + title: string; + description: string; + primaryUrl: string | null; +} + +export interface TrivyScanResult { + imageRef: string; + imageDigest: string | null; + scannedAt: number; + totalVulnerabilities: number; + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; + unknownCount: number; + fixableCount: number; + highestSeverity: VulnSeverity | null; + vulnerabilities: TrivyVulnerability[]; + metadata: { + os: string | null; + trivyVersion: string | null; + scanDurationMs: number; + }; +} + +export type SbomFormat = 'spdx-json' | 'cyclonedx'; + +function normalizeSeverity(raw: string | undefined): VulnSeverity { + const s = (raw ?? '').toUpperCase(); + if (s === 'CRITICAL' || s === 'HIGH' || s === 'MEDIUM' || s === 'LOW') return s; + return 'UNKNOWN'; +} + +function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | null { + if (vulns.length === 0) return null; + let highestIdx = -1; + for (const v of vulns) { + const idx = SEVERITY_ORDER.indexOf(v.severity); + if (idx > highestIdx) highestIdx = idx; + } + return highestIdx >= 0 ? SEVERITY_ORDER[highestIdx] : null; +} + +class TrivyService { + private static instance: TrivyService; + private available = false; + private version: string | null = null; + private detectionTimestamp = 0; + private scanningImages: Set = new Set(); + + public static getInstance(): TrivyService { + if (!TrivyService.instance) { + TrivyService.instance = new TrivyService(); + } + return TrivyService.instance; + } + + async initialize(): Promise { + await this.detectTrivy(); + if (!this.available) { + disableCapability('vulnerability-scanning'); + console.log('[Trivy] Binary not found on PATH; vulnerability scanning disabled'); + } else { + console.log(`[Trivy] Available (version ${this.version})`); + } + } + + async detectTrivy(): Promise<{ available: boolean; version: string | null }> { + try { + const { stdout } = await execFileAsync('trivy', ['--version'], { timeout: 5000 }); + const match = stdout.match(/Version:\s*([^\s\n]+)/i); + this.version = match ? match[1] : stdout.split('\n')[0]?.trim() || 'unknown'; + this.available = true; + } catch { + this.available = false; + this.version = null; + } + this.detectionTimestamp = Date.now(); + return { available: this.available, version: this.version }; + } + + isTrivyAvailable(): boolean { + return this.available; + } + + getVersion(): string | null { + return this.version; + } + + invalidateDetection(): void { + this.detectionTimestamp = 0; + } + + private async buildEnv( + sendWarning?: (msg: string) => void, + ): Promise<{ env: Record; cleanup: () => void }> { + const registries = DatabaseService.getInstance().getRegistries(); + const baseEnv: Record = { + ...process.env, + PATH: + process.env.PATH || + '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', + }; + if (registries.length === 0) { + return { env: baseEnv, cleanup: () => undefined }; + } + const { config, warnings } = await RegistryService.getInstance().resolveDockerConfig(); + if (sendWarning) { + for (const w of warnings) sendWarning(w); + } + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sencho-trivy-')); + const configPath = path.join(tmpDir, 'config.json'); + fs.writeFileSync(configPath, JSON.stringify(config), { mode: 0o600 }); + const cleanup = () => { + try { + fs.unlinkSync(configPath); + } catch { + /* noop */ + } + try { + fs.rmdirSync(tmpDir); + } catch { + /* noop */ + } + }; + return { env: { ...baseEnv, DOCKER_CONFIG: tmpDir }, cleanup }; + } + + async getImageDigest(imageRef: string, nodeId: number): Promise { + try { + const docker = DockerController.getInstance(nodeId).getDocker(); + const info = (await docker.getImage(imageRef).inspect()) as { + RepoDigests?: string[]; + Id?: string; + }; + if (info.RepoDigests && info.RepoDigests.length > 0) { + const digest = info.RepoDigests[0].split('@')[1]; + if (digest) return digest; + } + return info.Id ?? null; + } catch { + return null; + } + } + + private scanKey(nodeId: number, imageRef: string): string { + return `${nodeId}:${imageRef}`; + } + + isScanning(nodeId: number, imageRef: string): boolean { + 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, + options: { useCache?: boolean; digest?: string | null } = {}, + ): Promise { + if (!this.available) { + throw new Error('Trivy is not available on this host'); + } + const key = this.scanKey(nodeId, imageRef); + if (this.scanningImages.has(key)) { + throw new Error('Already scanning this image'); + } + this.scanningImages.add(key); + const startedAt = Date.now(); + + try { + const digest = options.digest ?? (await this.getImageDigest(imageRef, nodeId)); + + if (options.useCache !== false && digest) { + const cached = DatabaseService.getInstance().getLatestScanByDigest(digest); + if (cached && startedAt - cached.scanned_at < DIGEST_CACHE_TTL_MS) { + const details = + DatabaseService.getInstance().getVulnerabilityDetails(cached.id, { + limit: 1000, + }).items; + return { + imageRef, + imageDigest: digest, + scannedAt: cached.scanned_at, + totalVulnerabilities: cached.total_vulnerabilities, + criticalCount: cached.critical_count, + highCount: cached.high_count, + mediumCount: cached.medium_count, + lowCount: cached.low_count, + unknownCount: cached.unknown_count, + fixableCount: cached.fixable_count, + highestSeverity: cached.highest_severity, + vulnerabilities: details.map((d) => ({ + vulnerabilityId: d.vulnerability_id, + pkgName: d.pkg_name, + installedVersion: d.installed_version, + fixedVersion: d.fixed_version, + severity: d.severity, + title: d.title ?? '', + description: d.description ?? '', + primaryUrl: d.primary_url, + })), + metadata: { + os: cached.os_info, + trivyVersion: cached.trivy_version, + scanDurationMs: cached.scan_duration_ms ?? 0, + }, + }; + } + } + + const { env, cleanup } = await this.buildEnv(); + try { + const args = [ + 'image', + '--format', + 'json', + '--quiet', + '--no-progress', + '--scanners', + 'vuln', + imageRef, + ]; + const { stdout } = await execFileAsync('trivy', args, { + env, + timeout: SCAN_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }); + const { vulnerabilities, os: osInfo } = this.parseTrivyOutput(stdout); + + let critical = 0, + high = 0, + medium = 0, + low = 0, + unknown = 0, + fixable = 0; + for (const v of vulnerabilities) { + switch (v.severity) { + case 'CRITICAL': + critical++; + break; + case 'HIGH': + high++; + break; + case 'MEDIUM': + medium++; + break; + case 'LOW': + low++; + break; + default: + unknown++; + } + if (v.fixedVersion) fixable++; + } + + return { + imageRef, + imageDigest: digest, + scannedAt: Date.now(), + totalVulnerabilities: vulnerabilities.length, + criticalCount: critical, + highCount: high, + mediumCount: medium, + lowCount: low, + unknownCount: unknown, + fixableCount: fixable, + highestSeverity: computeHighestSeverity(vulnerabilities), + vulnerabilities, + metadata: { + os: osInfo, + trivyVersion: this.version, + scanDurationMs: Date.now() - startedAt, + }, + }; + } finally { + cleanup(); + } + } finally { + this.scanningImages.delete(key); + } + } + + async runScanAndPersist( + imageRef: string, + nodeId: number, + triggeredBy: VulnScanTrigger, + stackContext: string | null = null, + ): Promise { + const db = DatabaseService.getInstance(); + const startedAt = Date.now(); + 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: this.version, + scan_duration_ms: null, + triggered_by: triggeredBy, + status: 'in_progress', + error: null, + stack_context: stackContext, + }); + + try { + const result = await this.scanImage(imageRef, nodeId); + 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, + })), + ); + const stored = db.getVulnerabilityScan(scanId); + if (!stored) throw new Error('Scan vanished after write'); + return stored; + } catch (error) { + const msg = (error as Error).message || 'Scan failed'; + db.updateVulnerabilityScan(scanId, { + status: 'failed', + error: msg, + scan_duration_ms: Date.now() - startedAt, + }); + throw error; + } + } + + async scanAllNodeImages( + nodeId: number, + triggeredBy: VulnScanTrigger = 'scheduled', + ): Promise<{ scanned: number; skipped: number; failed: number }> { + if (!this.available) { + throw new Error('Trivy is not available on this host'); + } + const images = await DockerController.getInstance(nodeId).getImages(); + const imageRefs = new Set(); + for (const img of images as Array<{ RepoTags?: string[] }>) { + for (const tag of img.RepoTags ?? []) { + if (tag && tag !== ':') imageRefs.add(tag); + } + } + + let scanned = 0; + let skipped = 0; + let failed = 0; + for (const ref of imageRefs) { + try { + const digest = await this.getImageDigest(ref, nodeId); + if (digest) { + const cached = + DatabaseService.getInstance().getLatestScanByDigest(digest); + if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) { + skipped++; + continue; + } + } + await this.runScanAndPersist(ref, nodeId, triggeredBy, null); + scanned++; + } catch (err) { + failed++; + console.warn(`[Trivy] Failed to scan ${ref}:`, (err as Error).message); + } + await new Promise((r) => setTimeout(r, 300)); + } + return { scanned, skipped, failed }; + } + + async generateSBOM(imageRef: string, format: SbomFormat): Promise { + if (!this.available) { + throw new Error('Trivy is not available on this host'); + } + const { env, cleanup } = await this.buildEnv(); + try { + const { stdout } = await execFileAsync( + 'trivy', + ['image', '--format', format, '--quiet', '--no-progress', imageRef], + { + env, + timeout: SBOM_TIMEOUT_MS, + maxBuffer: 64 * 1024 * 1024, + }, + ); + return stdout; + } finally { + cleanup(); + } + } +} + +export default TrivyService; diff --git a/docs/docs.json b/docs/docs.json index f1c68427..bc109244 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -115,6 +115,7 @@ "features/audit-log", "features/api-tokens", "features/private-registries", + "features/vulnerability-scanning", "features/auto-update-policies", "features/scheduled-operations", "features/sso", @@ -140,6 +141,7 @@ "operations/backup", "operations/upgrade", "operations/self-hosting", + "operations/trivy-setup", "operations/two-factor-admin" ] } diff --git a/docs/features/overview.mdx b/docs/features/overview.mdx index ae56ab0a..ed6e0354 100644 --- a/docs/features/overview.mdx +++ b/docs/features/overview.mdx @@ -103,6 +103,10 @@ Create point-in-time snapshots of every compose file and environment file across Store credentials for private Docker registries: Docker Hub organizations, GHCR, AWS ECR, and self-hosted registries. Sencho injects them automatically during deploy and pull operations. ECR short-lived tokens are refreshed on every operation. Admiral only. [Learn more →](/features/private-registries) +## Vulnerability scanning + +Scan container images for known CVEs with [Trivy](https://trivy.dev). On-demand scanning and severity badges are available on every tier; scheduled scans, scan policies that gate deploys, SBOM generation, and scan history are available on Skipper and Admiral. [Learn more →](/features/vulnerability-scanning) + ## Audit log Track every mutating action across your Sencho instance with a searchable audit trail. See who deployed, stopped, deleted, or changed settings, with timestamps, user attribution, and node context. Admiral only. [Learn more →](/features/audit-log) diff --git a/docs/features/scheduled-operations.mdx b/docs/features/scheduled-operations.mdx index bbb58dd0..f9df7b7b 100644 --- a/docs/features/scheduled-operations.mdx +++ b/docs/features/scheduled-operations.mdx @@ -23,6 +23,7 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i | **Restart Stack** | A specific stack (or specific services within it) on a specific node | Restarts all or selected containers in the stack | | **Fleet Snapshot** | All nodes | Creates a fleet-wide backup of all compose files and `.env` files | | **System Prune** | The default node | Prunes selected resources, optionally filtered by Docker label | +| **Vulnerability Scan** | All images on a specific node | Runs Trivy against every image on the target node and records the results. Requires Trivy to be installed — see [Installing Trivy](/operations/trivy-setup). Available on Skipper and Admiral. | ## Creating a Scheduled Task diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx new file mode 100644 index 00000000..6f3195a0 --- /dev/null +++ b/docs/features/vulnerability-scanning.mdx @@ -0,0 +1,188 @@ +--- +title: "Vulnerability Scanning" +description: "Scan container images for known CVEs, surface severity badges in the Resources Hub, and alert on policy violations." +--- + +Sencho integrates with [Trivy](https://trivy.dev) to scan container images for known vulnerabilities (CVEs), surface severity badges next to your images, and alert when a scan result exceeds a configured threshold. On-demand scanning is available on every tier; automation, policies, and SBOM generation are Skipper and Admiral. + + + Resources Hub showing vulnerability severity badges next to image tags + + +## Prerequisites + +The Trivy CLI must be available on the machine running Sencho. Trivy is not bundled with the Sencho Docker image; see [Installing Trivy](/operations/trivy-setup) for mount and installation options. Sencho checks for Trivy on startup and hides scanning UI when the binary is not available. + +## Tier availability + +| Feature | Community | Skipper | Admiral | +|---------|:---------:|:-------:|:-------:| +| On-demand image scanning | ✓ | ✓ | ✓ | +| Severity badges in the Resources Hub | ✓ | ✓ | ✓ | +| Scan results drawer with vulnerability table | ✓ | ✓ | ✓ | +| Post-deploy automated scanning | ✓ | ✓ | ✓ | +| Scheduled fleet scans (all images on a node) | | ✓ | ✓ | +| Scan policies (warning and critical alerts) | | ✓ | ✓ | +| SBOM generation (SPDX, CycloneDX) | | ✓ | ✓ | +| Scan history and comparison | | ✓ | ✓ | + +## On-demand scanning + +Navigate to the **Resources** tab and open the **Images** panel. When Trivy is available, every image row shows a shield icon alongside the delete action. + +1. Click the shield icon on any image row to start a scan. +2. The row shows a loading spinner while Trivy runs. Most scans finish in 10 to 60 seconds depending on image size and whether the Trivy database is already cached. +3. When the scan completes, a severity badge appears next to the image status (e.g. `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`, or `CLEAN`). +4. Click the badge to open the scan results drawer. + + + Vulnerability scan results drawer showing CVE table with severity, package, and fix columns + + +### Reading severity badges + +Hovering over a severity badge reveals the breakdown of vulnerabilities by severity and the timestamp of the last scan. The badge color reflects the highest severity found: + +| Badge | Meaning | +|-------|---------| +| **CRITICAL** (red) | At least one vulnerability with a CVSS score of 9.0 or higher | +| **HIGH** (amber) | At least one high-severity vulnerability | +| **MEDIUM** (blue) | Only medium and lower vulnerabilities | +| **LOW** (muted) | Only low-severity vulnerabilities | +| **CLEAN** (green) | Scan completed with zero findings | + +Scan results are cached by image digest. If the same digest is scanned again within 24 hours, Sencho returns the cached result instantly instead of re-running Trivy. + +## The scan results drawer + +The drawer shows a full breakdown of the most recent scan for an image: + +- **Summary**: counts per severity (critical, high, medium, low), total vulnerabilities, how many have a fix available, the Trivy version used, and when the scan ran. +- **Filter tabs**: narrow the table to a specific severity. +- **Vulnerability table**: paginated list of every CVE found, including: + - **CVE ID** (linked to the upstream advisory) + - **Package** name and installed version + - **Severity** badge + - **Fixed version** with a green indicator if a fix is available + +### Actions + +From the drawer header you can: + +- **Re-scan**: kick off a fresh scan, ignoring the digest cache. +- **Download SBOM**: export a Software Bill of Materials in SPDX JSON or CycloneDX format (Skipper and Admiral). +- **Export CSV**: export the full vulnerability list for offline review. + +## Post-deploy automated scanning + +When Trivy is available, Sencho automatically scans every deployed image in the background after a successful deploy. This applies to all deploy paths: + +- Stack deploy and redeploy +- Stack update +- Template deploy from the App Store +- Git source apply +- Git source create + +The deploy itself is never blocked by scanning; scans run asynchronously and surface their results via the severity badges in the Resources Hub. If high or critical vulnerabilities are found, an alert is dispatched through your configured [notification channels](/features/alerts-notifications). + +### Opting out per deployment + +The App Store deploy sheet includes an **Scan images for vulnerabilities after deploy** toggle (enabled by default). Uncheck it to skip the post-deploy scan for that single deployment. This does not disable scan policies globally; it simply opts this deployment out of scanning. + + + App Store deploy sheet showing the auto-scan checkbox enabled by default + + +## Scheduled fleet scans + + + Scheduled scans require a **Skipper** or **Admiral** license. + + +You can run recurring scans of every image on a node through the standard [Scheduled Operations](/features/scheduled-operations) system. Create a new scheduled task with action **Scan** and a cron expression. The scheduler iterates every image on the target node with a short delay between scans and records the result in the task's run history. + +Use scheduled scans to keep CVE badges fresh even for images that are rarely redeployed: nightly (`0 3 * * *`) is a good default for most fleets. + +## Scan policies + + + Scan policies require a **Skipper** or **Admiral** license. + + +Policies let you define severity thresholds that the post-deploy scanner evaluates against. When a deploy's scan exceeds a policy's threshold, Sencho dispatches an alert. The policy's **Block on deploy** toggle controls the alert severity: warning when off, critical when on. + + + Security section of Settings showing the scan policies list with add policy button + + +### Creating a policy + +Go to **Settings → Security** and click **Add Policy**. + +| Field | Description | +|-------|-------------| +| **Name** | A descriptive label (e.g. "Production critical block"). | +| **Stack pattern** | Optional glob against stack names (e.g. `prod-*`). Leave empty to match every stack. | +| **Max severity** | The threshold. If a scan finds any vulnerability at or above this severity, the policy fires. | +| **Block on deploy** | When enabled, policy violations are dispatched as critical (error) alerts. When disabled, they are dispatched as warnings. | +| **Enabled** | Disabled policies are skipped during evaluation. | + +### Policy scoping + +When multiple policies match a deploy, Sencho picks the most specific one: + +1. Policies scoped to a specific node win over global policies. +2. Policies with a stack pattern win over wildcard policies. +3. Disabled policies are never applied. + +Only one policy is evaluated per deploy; use a single tight pattern rather than overlapping policies for clarity. + +## SBOM generation + + + SBOM generation requires a **Skipper** or **Admiral** license. + + +A Software Bill of Materials (SBOM) is a machine-readable inventory of every package present in a container image. SBOMs are required by an increasing number of security frameworks (SLSA, Executive Order 14028, EU Cyber Resilience Act) and are useful for offline compliance reviews. + +From the scan results drawer, click **Download SBOM** and choose a format: + +| Format | Use case | +|--------|----------| +| **SPDX JSON** | Widely supported, best for tooling integration. | +| **CycloneDX** | Richer dependency metadata, better for supply-chain analysis. | + +The download starts immediately and uses the image's digest (when available) in the filename. + +## Scan history + +Every scan Sencho runs is stored with its full vulnerability detail. Scan records are automatically pruned after 90 days to keep the database compact. The history is used to power: + +- **Digest caching**: skip re-scanning an image that has already been scanned within 24 hours. +- **Trend badges**: surface whether the latest scan added or resolved vulnerabilities compared to the previous scan for the same image. + +## How it works + +1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability. +2. When a scan is triggered, Sencho resolves the image digest via Docker and checks the 24-hour cache. If a completed scan exists for that digest, it is returned instantly. +3. Otherwise, Sencho spawns `trivy image --format json --quiet ` and parses the JSON output into the vulnerability database. +4. Private registry credentials are forwarded automatically by writing a temporary `DOCKER_CONFIG` for the Trivy subprocess, then deleting it when the scan completes. +5. Scan status, counts per severity, and the full vulnerability list are persisted for display in the drawer and for policy evaluation. + +## Troubleshooting + +### Scan button is not visible + +Sencho hides scanning UI when the Trivy binary is not detected. Check **Settings → Support** for the Trivy availability status, then follow [Installing Trivy](/operations/trivy-setup) if it is missing. + +### Scans time out + +The default scan timeout is 5 minutes. Very large images (2+ GB) over a slow connection may exceed this; pre-pulling the image to the host speeds up the scan significantly because Trivy works against the local image store. + +### Private registry images fail to scan + +Sencho forwards the same registry credentials configured under **Settings → Registries** to Trivy during a scan. If a pull works in Sencho but a scan fails, make sure the image has been pulled at least once (Trivy can then work against the cached local image). + +### 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. diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx new file mode 100644 index 00000000..8a086927 --- /dev/null +++ b/docs/operations/trivy-setup.mdx @@ -0,0 +1,185 @@ +--- +title: Installing Trivy +description: Install and mount the Trivy CLI so Sencho can scan container images for vulnerabilities. +--- + +Sencho's [Vulnerability Scanning](/features/vulnerability-scanning) feature uses the [Trivy](https://trivy.dev) CLI. Trivy is not bundled with the Sencho Docker image; operators provide it via a bind mount or a custom image so Sencho can find it on `PATH`. Once Trivy is available, the scanning UI appears automatically. + +## Why Trivy is not bundled + +Trivy's vulnerability database updates multiple times per day and is around 100 MB. Bundling Trivy would force every Sencho instance to carry an out-of-date database in its image, then re-download on first scan. By keeping Trivy external, you can: + +- Pick the Trivy version you want and upgrade it on your own schedule +- Persist the Trivy cache (the vulnerability DB) across Sencho container restarts +- Pre-seed an air-gapped cache for environments without internet access + +## Installing Trivy on the host + +### Linux (Debian / Ubuntu) + +```bash +sudo apt-get install wget gnupg +wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | sudo apt-key add - +echo "deb https://aquasecurity.github.io/trivy-repo/deb generic main" | sudo tee /etc/apt/sources.list.d/trivy.list +sudo apt-get update +sudo apt-get install trivy +``` + +### Linux (RHEL / Fedora) + +```bash +sudo rpm --import https://aquasecurity.github.io/trivy-repo/rpm/public.key +echo "[trivy] +name=Trivy repository +baseurl=https://aquasecurity.github.io/trivy-repo/rpm/releases/\$basearch/ +gpgcheck=1 +enabled=1" | sudo tee /etc/yum.repos.d/trivy.repo +sudo dnf install trivy +``` + +### macOS + +```bash +brew install trivy +``` + +### Verify the install + +```bash +trivy --version +``` + +The command should print a `Version: X.Y.Z` line. Note the path that `which trivy` (or `where trivy` on Windows) returns; you will mount that path into the Sencho container. + +## Making Trivy available to Sencho + +Sencho runs inside a container and looks for `trivy` on its own `PATH`. There are two approaches: + +### Option 1: Bind mount the host binary + +Mount the host's Trivy binary into the Sencho container. This is the simplest option when Sencho and Trivy share the same CPU architecture. + +```yaml +services: + sencho: + image: sencho/sencho:latest + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./sencho-data:/app/data + - /opt/compose:/opt/compose + - /usr/local/bin/trivy:/usr/local/bin/trivy:ro + - trivy-cache:/root/.cache/trivy + environment: + - COMPOSE_DIR=/opt/compose + - TRIVY_CACHE_DIR=/root/.cache/trivy + +volumes: + trivy-cache: +``` + +The `trivy-cache` volume persists the vulnerability database across Sencho container restarts so Trivy does not re-download it every time. + +Adjust the first path if `which trivy` on the host prints something other than `/usr/local/bin/trivy` (for example `/usr/bin/trivy` on some distributions). + +### Option 2: Build a custom Sencho image + +If the host's Trivy binary is not ABI-compatible with the Sencho container (for example because you are running macOS host binaries or a different glibc version), install Trivy inside the image instead: + +```dockerfile +FROM sencho/sencho:latest + +RUN apk add --no-cache curl ca-certificates \ + && curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + +ENV TRIVY_CACHE_DIR=/root/.cache/trivy +``` + +Build and run: + +```bash +docker build -t sencho-with-trivy . +docker compose up -d +``` + +## Persisting the vulnerability database + +Trivy downloads a ~100 MB vulnerability database on first run and refreshes it every six hours. Without a persistent cache, every Sencho restart re-downloads the database, wasting bandwidth and adding 10–30 seconds to the first scan. + +Set `TRIVY_CACHE_DIR` to a directory inside a named or bind-mounted volume (see Option 1 above). The directory must be writable by the Sencho process. + +## Air-gapped environments + +Trivy supports offline use through pre-built database bundles. + +1. On a networked machine, download the latest DB bundle: + + ```bash + trivy image --download-db-only + cp -r ~/.cache/trivy /path/to/portable/cache + ``` + +2. Transfer the cache directory to the air-gapped host. + +3. Mount it into the Sencho container at `TRIVY_CACHE_DIR`: + + ```yaml + volumes: + - /path/to/portable/cache:/root/.cache/trivy:ro + environment: + - TRIVY_CACHE_DIR=/root/.cache/trivy + ``` + +4. Set `TRIVY_SKIP_DB_UPDATE=true` to prevent Trivy from attempting a refresh: + + ```yaml + environment: + - TRIVY_CACHE_DIR=/root/.cache/trivy + - TRIVY_SKIP_DB_UPDATE=true + ``` + +Plan to refresh the bundle on a schedule (weekly is typical) so CVE data stays current. + +## Verifying Sencho detects Trivy + +After restarting Sencho with the mount in place: + +1. Open the **Resources** tab. +2. Look at the **Images** panel. If Trivy is detected, a shield icon appears in the Actions column next to the delete icon on every row. +3. You can also check **Settings → Support** for an explicit Trivy availability status. + +If the shield icon is missing, see the troubleshooting section below. + +## Troubleshooting + +### Sencho does not detect Trivy + +Sencho runs `trivy --version` on startup and caches the result. If you added the mount after Sencho started, restart the container so the check runs again. + +Verify the binary is visible from inside the container: + +```bash +docker exec sencho trivy --version +``` + +If the command returns "not found", the mount path inside the container is wrong. The binary must be on `PATH`. Both `/usr/local/bin/trivy` and `/usr/bin/trivy` work. + +### Binary exists but reports an exec format error + +This means the host binary is not ABI-compatible with the Sencho image. Use Option 2 (custom image) instead; the `install.sh` script pulls the right architecture-specific build. + +### Scans take a long time on first run + +The first scan after a Trivy install downloads the vulnerability database. Expect 10–30 seconds of additional latency. Subsequent scans are near-instant once the cache is warm and `TRIVY_CACHE_DIR` is persisted. + +### Private registry images fail to scan + +Trivy scans the locally-cached image layers. If Sencho can pull the image but a scan fails, pull the image to the host first (a deploy will do this) and retry the scan. + +### Permission denied writing to the Trivy cache + +`TRIVY_CACHE_DIR` must be writable by the Sencho process. If you mounted the directory from the host with restrictive permissions, either loosen them (`chmod -R a+w /path/to/cache`) or use a named Docker volume which inherits the container user's permissions. + +### Trivy version is reported as "unknown" + +Sencho reads the version from `trivy --version` output. Very old Trivy releases (< 0.35) use a different output format. Upgrade to a recent version. diff --git a/docs/security.mdx b/docs/security.mdx index 31e86da3..248224d8 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -51,6 +51,10 @@ Every self-hosted instance includes the full security stack, with advanced featu Long-lived JWT bearer tokens, encrypted at rest, injected automatically during proxied requests. + + Trivy-powered CVE scanning of container images, with severity badges, post-deploy automation, and policy gating. + + ## Tier availability @@ -66,7 +70,9 @@ Every Sencho instance includes the foundational security stack. Advanced access- | Encryption at rest (AES-256-GCM) | ✓ | ✓ | ✓ | | Rate limiting (auth + API) | ✓ | ✓ | ✓ | | Node-to-node authentication | ✓ | ✓ | ✓ | +| Vulnerability scanning (on-demand + post-deploy) | ✓ | ✓ | ✓ | | Multi-user with RBAC (Admin, Viewer) | | ✓ | ✓ | +| Scan policies, scheduled scans, SBOM generation | | ✓ | ✓ | | Advanced RBAC (Deployer, Node Admin, Auditor) | | | ✓ | | Scoped permissions (per-stack, per-node) | | | ✓ | | API tokens (scoped, expiring) | | | ✓ | diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index b55edbdb..59295dae 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -6,7 +6,8 @@ import { Label } from "@/components/ui/label"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter } from "@/components/ui/sheet"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Button } from "@/components/ui/button"; -import { Search, Rocket, Loader2, Info, ExternalLink, Star } from "lucide-react"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Search, Rocket, Loader2, Info, ExternalLink, Star, ShieldCheck } from "lucide-react"; import { toast } from "@/components/ui/toast-store"; import { cn } from '@/lib/utils'; import { apiFetch } from '@/lib/api'; @@ -78,9 +79,17 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { const [newEnvKey, setNewEnvKey] = useState(''); const [portsInUse, setPortsInUse] = useState>({}); const [newEnvVal, setNewEnvVal] = useState(''); + const [autoScan, setAutoScan] = useState(true); + const [trivyAvailable, setTrivyAvailable] = useState(false); useEffect(() => { fetchTemplates(); + apiFetch('/security/trivy-status') + .then(r => r.ok ? r.json() : null) + .then(d => { if (d) setTrivyAvailable(!!d.available); }) + .catch((err) => { + console.error('Failed to fetch Trivy status:', err); + }); }, []); const fetchTemplates = async () => { @@ -215,7 +224,8 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { body: JSON.stringify({ stackName: stackName.trim(), template: modifiedTemplate, - envVars: finalEnvVars + envVars: finalEnvVars, + skip_scan: !autoScan }) }); const data = await res.json(); @@ -546,7 +556,23 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { -
+
+ {trivyAvailable && ( +
+ setAutoScan(!!checked)} + /> + +
+ )} + + +
+ + +
+
+
Last scanned
+
{relative}
+ {summary.total > 0 && ( +
+ {summary.critical > 0 && {summary.critical}C} + {summary.high > 0 && {summary.high}H} + {summary.medium > 0 && {summary.medium}M} + {summary.low > 0 && {summary.low}L} +
+ )} + {summary.total === 0 && ( +
No vulnerabilities
+ )} +
+
+
+ + ); +} + // ── Quick Clean Prune Button ─────────────────────────────────────────────────── interface PruneButtonProps { @@ -380,13 +464,20 @@ export default function ResourcesView() { const [selectedOrphans, setSelectedOrphans] = useState([]); const [bulkPurgeConfirm, setBulkPurgeConfirm] = useState(false); + // Vulnerability scanning state + const trivy = useTrivyStatus(); + const [scanSummaries, setScanSummaries] = useState>({}); + const [scanningImageRef, setScanningImageRef] = useState(null); + const [inspectScanId, setInspectScanId] = useState(null); + const fetchAllData = async () => { setIsLoading(true); try { - const [usageRes, resourcesRes, orphansRes] = await Promise.all([ + const [usageRes, resourcesRes, orphansRes, summariesRes] = await Promise.all([ apiFetch('/system/docker-df'), apiFetch('/system/resources'), apiFetch('/system/orphans'), + apiFetch('/security/image-summaries').catch(() => null), ]); if (usageRes.ok) setUsage(await usageRes.json()); @@ -400,6 +491,10 @@ export default function ResourcesView() { setOrphans(await orphansRes.json()); setSelectedOrphans([]); } + if (summariesRes && summariesRes.ok) { + const data = await summariesRes.json(); + setScanSummaries(data ?? {}); + } } catch (err) { console.error('Failed to fetch data', err); toast.error('Failed to load resources data'); @@ -494,6 +589,48 @@ export default function ResourcesView() { } }; + const handleScanImage = async (imageRef: string, force = false) => { + setScanningImageRef(imageRef); + const loadingId = toast.loading(`Scanning ${imageRef}...`); + try { + const res = await apiFetch('/security/scan', { + method: 'POST', + body: JSON.stringify({ imageRef, force }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error || 'Failed to start scan'); + const scanId = data.scanId as number; + + const deadline = Date.now() + 5 * 60 * 1000; + while (Date.now() < deadline) { + await new Promise(r => setTimeout(r, 3000)); + const poll = await apiFetch(`/security/scans/${scanId}`); + if (!poll.ok) continue; + const poll_data = await poll.json(); + if (poll_data.status !== 'in_progress') { + if (poll_data.status === 'failed') { + throw new Error(poll_data.error || 'Scan failed'); + } + toast.success(`Scan complete: ${poll_data.total_vulnerabilities} vulnerabilities found`); + setInspectScanId(scanId); + const summariesRes = await apiFetch('/security/image-summaries'); + if (summariesRes.ok) { + const summaries = await summariesRes.json(); + setScanSummaries(summaries ?? {}); + } + return; + } + } + throw new Error('Scan timed out'); + } catch (error) { + const err = error as { message?: string }; + toast.error(err?.message || 'Scan failed'); + } finally { + toast.dismiss(loadingId); + setScanningImageRef(null); + } + }; + const handleCreateNetwork = async () => { setIsCreatingNetwork(true); try { @@ -720,12 +857,36 @@ export default function ResourcesView() { {img.Containers > 0 ? "In Use" : "Unused"} + {(() => { + const tag = img.RepoTags?.[0]; + const summary = tag ? scanSummaries[tag] : undefined; + if (!summary) return null; + return setInspectScanId(summary.scan_id)} />; + })()}
- {isAdmin && } +
+ {trivy.available && isAdmin && img.RepoTags?.[0] && img.RepoTags[0] !== ':' && ( + + )} + {isAdmin && } +
))} @@ -1306,6 +1467,13 @@ export default function ResourcesView() { + + setInspectScanId(null)} + onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }} + canGenerateSbom={isPaid} + />
); } diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1987a555..886d9270 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -16,7 +16,7 @@ import { SENCHO_SETTINGS_CHANGED } from '@/lib/events'; import type { SenchoSettingsChangedDetail } from '@/lib/events'; import { Shield, Activity, Bell, Code, Server, Package, X, - Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, + Info, Crown, Webhook, Users, Zap, Database, LifeBuoy, Lock, Tag, Route, ShieldCheck, } from 'lucide-react'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -33,6 +33,7 @@ import { NotificationsSection, NotificationRoutingSection, WebhooksSection, + SecuritySection, DeveloperSection, AppStoreSection, SupportSection, @@ -44,7 +45,8 @@ import type { PatchableSettings, SectionId } from './settings'; const GLOBAL_ONLY_SECTIONS: ReadonlySet = new Set([ 'account', 'license', 'users', 'sso', 'api-tokens', 'registries', - 'labels', 'notifications', 'notification-routing', 'webhooks', 'nodes', 'appstore', + 'labels', 'notifications', 'notification-routing', 'webhooks', 'security', + 'nodes', 'appstore', ]); interface SettingsModalProps { @@ -305,6 +307,8 @@ export function SettingsModal({ isOpen, onClose, initialSection }: SettingsModal return ; case 'webhooks': return ; + case 'security': + return ; case 'developer': return ( } label="Webhooks" locked={!isPaid} /> )} + {!isRemote && isAdmin && ( + } label="Security" locked={!isPaid} /> + )} } diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx new file mode 100644 index 00000000..1cdfbb35 --- /dev/null +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -0,0 +1,408 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + ShieldCheck, + ExternalLink, + ChevronLeft, + ChevronRight, + RefreshCw, + Download, + Loader2, + Check, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import type { + VulnerabilityScan, + VulnerabilityDetail, + VulnSeverity, +} from '@/types/security'; + +interface VulnerabilityScanSheetProps { + scanId: number | null; + onClose: () => void; + onRescan?: (imageRef: string) => void; + canGenerateSbom?: boolean; +} + +type SeverityFilter = 'ALL' | VulnSeverity; + +const PAGE_SIZE = 25; + +const SEVERITY_CLASSES: Record = { + CRITICAL: 'text-destructive border-destructive/40 bg-destructive/10', + HIGH: 'text-warning border-warning/40 bg-warning/10', + MEDIUM: 'text-info border-info/40 bg-info/10', + LOW: 'text-muted-foreground border-border bg-muted/30', + UNKNOWN: 'text-muted-foreground border-border bg-muted/20', +}; + +function SeverityChip({ severity }: { severity: VulnSeverity }) { + return ( + + {severity} + + ); +} + +export function VulnerabilityScanSheet({ + scanId, + onClose, + onRescan, + canGenerateSbom = false, +}: VulnerabilityScanSheetProps) { + const [scan, setScan] = useState(null); + const [details, setDetails] = useState([]); + const [loading, setLoading] = useState(false); + const [severityFilter, setSeverityFilter] = useState('ALL'); + const [page, setPage] = useState(0); + const [downloadingSbom, setDownloadingSbom] = useState(false); + + 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`), + ]); + if (!scanRes.ok) throw new Error('Failed to fetch scan'); + if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities'); + const scanData = await scanRes.json(); + const detailsData = await detailsRes.json(); + setScan(scanData); + setDetails(Array.isArray(detailsData.items) ? detailsData.items : []); + setPage(0); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to load scan'); + } finally { + setLoading(false); + } + }, [scanId]); + + useEffect(() => { + if (scanId != null) load(); + else { + setScan(null); + setDetails([]); + setSeverityFilter('ALL'); + setPage(0); + } + }, [scanId, load]); + + const filtered = useMemo(() => { + if (severityFilter === 'ALL') return details; + return details.filter((d) => d.severity === severityFilter); + }, [details, severityFilter]); + + const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + const pageItems = filtered.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); + const needsPagination = filtered.length > PAGE_SIZE; + + const downloadSbom = useCallback( + async (format: 'spdx-json' | 'cyclonedx') => { + if (!scan) return; + setDownloadingSbom(true); + try { + const res = await apiFetch('/security/sbom', { + method: 'POST', + body: JSON.stringify({ imageRef: scan.image_ref, format }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || 'Failed to generate SBOM'); + } + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-sbom-${format}.json`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success('SBOM downloaded'); + } catch (err) { + toast.error((err as Error)?.message || 'SBOM generation failed'); + } finally { + setDownloadingSbom(false); + } + }, + [scan], + ); + + const exportCsv = useCallback(() => { + if (!scan || details.length === 0) return; + const header = 'CVE,Package,Severity,Installed,Fixed,URL\n'; + const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; + const rows = details + .map((d) => + [ + escape(d.vulnerability_id), + escape(d.pkg_name), + escape(d.severity), + escape(d.installed_version), + escape(d.fixed_version ?? ''), + escape(d.primary_url ?? ''), + ].join(','), + ) + .join('\n'); + const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, [scan, details]); + + return ( + !open && onClose()}> + + + + + + {scan?.image_ref ?? 'Loading...'} + + + + + {loading && !scan && ( +
+ +
+ )} + + {scan && ( +
+ {/* Summary stats */} +
+
+ {scan.critical_count > 0 && ( + + {scan.critical_count} CRITICAL + + )} + {scan.high_count > 0 && ( + + {scan.high_count} HIGH + + )} + {scan.medium_count > 0 && ( + + {scan.medium_count} MEDIUM + + )} + {scan.low_count > 0 && ( + + {scan.low_count} LOW + + )} + {scan.total_vulnerabilities === 0 && ( + + No vulnerabilities + + )} +
+ +
+
+
Total
+
{scan.total_vulnerabilities}
+
+
+
Fixable
+
{scan.fixable_count}
+
+
+
Triggered
+
{scan.triggered_by}
+
+
+
Scanned
+
+ {new Date(scan.scanned_at).toLocaleString()} +
+
+
+ +
+ {onRescan && ( + + )} + {canGenerateSbom && ( + + + + + + downloadSbom('spdx-json')}> + SPDX JSON + + downloadSbom('cyclonedx')}> + CycloneDX + + + + )} + +
+
+ + {/* Severity filter tabs */} +
+ {(['ALL', 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as SeverityFilter[]).map((s) => ( + + ))} + {needsPagination && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} +
+ + +
+ {pageItems.length === 0 ? ( +
+ {details.length === 0 + ? 'No vulnerabilities found.' + : 'No vulnerabilities match the selected filter.'} +
+ ) : ( + + + + CVE + Package + Severity + Installed + Fixed + + + + {pageItems.map((d) => ( + + + {d.primary_url ? ( + + {d.vulnerability_id} + + + ) : ( + d.vulnerability_id + )} + + + {d.pkg_name} + + + + + {d.installed_version} + + {d.fixed_version ? ( + + + {d.fixed_version} + + ) : ( + - + )} + + + ))} + +
+ )} +
+
+
+ )} +
+
+ ); +} + +export { SeverityChip }; +export type { VulnerabilityScanSheetProps }; diff --git a/frontend/src/components/settings/SecuritySection.tsx b/frontend/src/components/settings/SecuritySection.tsx new file mode 100644 index 00000000..db516f76 --- /dev/null +++ b/frontend/src/components/settings/SecuritySection.tsx @@ -0,0 +1,357 @@ +import { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Switch } from '@/components/ui/switch'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Combobox } from '@/components/ui/combobox'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { toast } from '@/components/ui/toast-store'; +import { apiFetch } from '@/lib/api'; +import { PaidGate } from '@/components/PaidGate'; +import { TierBadge } from '@/components/TierBadge'; +import { ShieldCheck, Plus, Trash2, Pencil } from 'lucide-react'; +import type { ScanPolicy, VulnSeverity } from '@/types/security'; + +const SEVERITY_OPTIONS: Array<{ value: VulnSeverity; label: string }> = [ + { value: 'CRITICAL', label: 'Critical' }, + { value: 'HIGH', label: 'High' }, + { value: 'MEDIUM', label: 'Medium' }, + { value: 'LOW', label: 'Low' }, +]; + +interface PolicyFormState { + name: string; + stack_pattern: string; + max_severity: VulnSeverity; + block_on_deploy: boolean; + enabled: boolean; +} + +const EMPTY_FORM: PolicyFormState = { + name: '', + stack_pattern: '', + max_severity: 'CRITICAL', + block_on_deploy: false, + enabled: true, +}; + +export function SecuritySection({ isPaid }: { isPaid: boolean }) { + const [policies, setPolicies] = useState([]); + const [loading, setLoading] = useState(true); + const [dialogOpen, setDialogOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(EMPTY_FORM); + const [saving, setSaving] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const fetchPolicies = async () => { + try { + const res = await apiFetch('/security/policies', { localOnly: true }); + if (res.ok) { + const data = await res.json(); + setPolicies(Array.isArray(data) ? data : []); + } + } catch (err) { + console.error('Failed to load scan policies:', err); + toast.error('Failed to load scan policies'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (isPaid) fetchPolicies(); + else setLoading(false); + }, [isPaid]); + + const openCreate = () => { + setEditingId(null); + setForm(EMPTY_FORM); + setDialogOpen(true); + }; + + const openEdit = (policy: ScanPolicy) => { + setEditingId(policy.id); + setForm({ + name: policy.name, + stack_pattern: policy.stack_pattern ?? '', + max_severity: policy.max_severity, + block_on_deploy: policy.block_on_deploy === 1, + enabled: policy.enabled === 1, + }); + setDialogOpen(true); + }; + + const handleSave = async () => { + if (!form.name.trim()) { + toast.error('Policy name is required'); + return; + } + setSaving(true); + try { + const payload = { + name: form.name.trim(), + stack_pattern: form.stack_pattern.trim() || null, + max_severity: form.max_severity, + block_on_deploy: form.block_on_deploy ? 1 : 0, + enabled: form.enabled ? 1 : 0, + }; + const url = editingId ? `/security/policies/${editingId}` : '/security/policies'; + const method = editingId ? 'PUT' : 'POST'; + const res = await apiFetch(url, { + method, + localOnly: true, + body: JSON.stringify(payload), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to save policy'); + } + toast.success(editingId ? 'Policy updated' : 'Policy created'); + setDialogOpen(false); + fetchPolicies(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to save policy'); + } finally { + setSaving(false); + } + }; + + const handleDelete = async () => { + if (deleteId == null) return; + try { + const res = await apiFetch(`/security/policies/${deleteId}`, { + method: 'DELETE', + localOnly: true, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to delete policy'); + } + toast.success('Policy deleted'); + fetchPolicies(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to delete policy'); + } finally { + setDeleteId(null); + } + }; + + if (!isPaid) { + return ( +
+
+

+ Security +

+

+ Define vulnerability scan policies that gate or warn on deploys. +

+
+ +
+
+
+
+ +
+ ); + } + + return ( +
+
+
+

+ Security +

+

+ Policies evaluate every post-deploy scan and alert (or block) when severity exceeds the threshold. +

+
+ +
+ + {loading && ( +
+ + +
+ )} + + {!loading && policies.length === 0 && ( +
+ +

No scan policies configured.

+

+ Add one to enforce severity thresholds across your fleet. +

+
+ )} + + {!loading && + policies.map((policy) => ( +
+
+
+ + {policy.name} + + max: {policy.max_severity} + + {policy.block_on_deploy === 1 && ( + + block + + )} + {policy.enabled === 0 && ( + + disabled + + )} +
+
+ + +
+
+
+ Scope: {policy.stack_pattern ? ( + {policy.stack_pattern} + ) : ( + all stacks + )} +
+
+ ))} + + + + + {editingId ? 'Edit Policy' : 'New Policy'} + + Configure the severity threshold and scope for this scan policy. + + +
+
+ + setForm({ ...form, name: e.target.value })} + /> +
+
+ + setForm({ ...form, stack_pattern: e.target.value })} + /> +

+ Glob-style pattern matched against stack names. Leave blank to apply to all stacks. +

+
+
+ + setForm({ ...form, max_severity: v as VulnSeverity })} + /> +
+
+
+ +

+ Emit a critical alert when this policy is violated after a deploy. +

+
+ setForm({ ...form, block_on_deploy: c })} + /> +
+
+
+ +

Disabled policies are skipped during evaluation.

+
+ setForm({ ...form, enabled: c })} + /> +
+
+ + + + +
+
+ + !open && setDeleteId(null)}> + + + Delete scan policy? + + This removes the policy immediately. Existing scans are not affected. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/frontend/src/components/settings/index.ts b/frontend/src/components/settings/index.ts index 659922f9..95c9210a 100644 --- a/frontend/src/components/settings/index.ts +++ b/frontend/src/components/settings/index.ts @@ -4,6 +4,7 @@ export { UsersSection } from './UsersSection'; export { SystemSection } from './SystemSection'; export { NotificationsSection } from './NotificationsSection'; export { WebhooksSection } from './WebhooksSection'; +export { SecuritySection } from './SecuritySection'; export { DeveloperSection } from './DeveloperSection'; export { AppStoreSection } from './AppStoreSection'; export { SupportSection } from './SupportSection'; diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index e9f6a9f9..b02dbf33 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -37,6 +37,7 @@ export type SectionId = | 'system' | 'notifications' | 'webhooks' + | 'security' | 'developer' | 'nodes' | 'appstore' diff --git a/frontend/src/hooks/useTrivyStatus.ts b/frontend/src/hooks/useTrivyStatus.ts new file mode 100644 index 00000000..0d54382c --- /dev/null +++ b/frontend/src/hooks/useTrivyStatus.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react'; +import { apiFetch } from '@/lib/api'; +import type { TrivyStatus } from '@/types/security'; + +export function useTrivyStatus(): TrivyStatus { + const [status, setStatus] = useState({ available: false, version: null }); + + useEffect(() => { + let cancelled = false; + apiFetch('/security/trivy-status') + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (cancelled || !d) return; + setStatus({ + available: !!d.available, + version: typeof d.version === 'string' ? d.version : null, + }); + }) + .catch((err) => { + console.error('Failed to fetch Trivy status:', err); + }); + return () => { + cancelled = true; + }; + }, []); + + return status; +} diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts new file mode 100644 index 00000000..041bd988 --- /dev/null +++ b/frontend/src/types/security.ts @@ -0,0 +1,70 @@ +export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN'; +export type VulnScanStatus = 'in_progress' | 'completed' | 'failed'; +export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy'; + +export interface TrivyStatus { + available: boolean; + version: string | null; +} + +export interface VulnerabilityScan { + id: number; + node_id: number; + image_ref: string; + image_digest: string | null; + scanned_at: number; + total_vulnerabilities: number; + critical_count: number; + high_count: number; + medium_count: number; + low_count: number; + unknown_count: number; + fixable_count: number; + highest_severity: VulnSeverity | null; + os_info: string | null; + trivy_version: string | null; + scan_duration_ms: number | null; + triggered_by: VulnScanTrigger; + status: VulnScanStatus; + error: string | null; + stack_context: string | null; +} + +export interface VulnerabilityDetail { + id: number; + scan_id: number; + vulnerability_id: string; + pkg_name: string; + installed_version: string; + fixed_version: string | null; + severity: VulnSeverity; + title: string | null; + description: string | null; + primary_url: string | null; +} + +export interface ScanSummary { + image_ref: string; + highest_severity: VulnSeverity | null; + scanned_at: number; + scan_id: number; + total: number; + critical: number; + high: number; + medium: number; + low: number; + unknown: number; + fixable: number; +} + +export interface ScanPolicy { + id: number; + name: string; + node_id: number | null; + stack_pattern: string | null; + max_severity: VulnSeverity; + block_on_deploy: number; + enabled: number; + created_at: number; + updated_at: number; +}