/** * 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); }); }); // ── markStaleScansAsFailed ─────────────────────────────────────── describe('markStaleScansAsFailed', () => { function markStale(olderThanMs: number): number { const cutoff = Date.now() - olderThanMs; const result = db .prepare( `UPDATE vulnerability_scans SET status = 'failed', error = 'Scan did not complete within expected time', scan_duration_ms = ? - scanned_at WHERE status = 'in_progress' AND scanned_at < ?`, ) .run(Date.now(), cutoff); return result.changes; } it('flips in_progress scans older than the cutoff to failed', () => { const now = Date.now(); const stale = insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 }); const fresh = insertScan({ status: 'in_progress', scanned_at: now - 1_000 }); const changed = markStale(15 * 60 * 1000); expect(changed).toBe(1); const staleRow = db.prepare('SELECT status, error FROM vulnerability_scans WHERE id = ?').get(stale) as { status: string; error: string; }; const freshRow = db.prepare('SELECT status FROM vulnerability_scans WHERE id = ?').get(fresh) as { status: string; }; expect(staleRow.status).toBe('failed'); expect(staleRow.error).toMatch(/did not complete/i); expect(freshRow.status).toBe('in_progress'); }); it('leaves completed and failed rows untouched', () => { const now = Date.now(); const completed = insertScan({ status: 'completed', scanned_at: now - 30 * 60 * 1000 }); const failed = insertScan({ status: 'failed', scanned_at: now - 30 * 60 * 1000 }); const changed = markStale(15 * 60 * 1000); expect(changed).toBe(0); const rows = db .prepare('SELECT id, status FROM vulnerability_scans WHERE id IN (?, ?)') .all(completed, failed) as Array<{ id: number; status: string }>; expect(rows.find((r) => r.id === completed)?.status).toBe('completed'); expect(rows.find((r) => r.id === failed)?.status).toBe('failed'); }); it('is idempotent when called repeatedly', () => { const now = Date.now(); insertScan({ status: 'in_progress', scanned_at: now - 30 * 60 * 1000 }); expect(markStale(15 * 60 * 1000)).toBe(1); expect(markStale(15 * 60 * 1000)).toBe(0); expect(markStale(15 * 60 * 1000)).toBe(0); }); }); // ── vulnerability_details cascade ──────────────────────────────── describe('vulnerability_details cascade', () => { 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(); }); }); });