diff --git a/backend/src/__tests__/database-scan-list.test.ts b/backend/src/__tests__/database-scan-list.test.ts index 9d61acef..764051ed 100644 --- a/backend/src/__tests__/database-scan-list.test.ts +++ b/backend/src/__tests__/database-scan-list.test.ts @@ -16,13 +16,14 @@ beforeAll(async () => { afterAll(() => cleanupTestDb(tmpDir)); function seedScan(overrides: Partial<{ + node_id: number; image_ref: string; scanned_at: number; status: 'completed' | 'in_progress' | 'failed'; }> = {}): number { const db = DatabaseService.getInstance(); return db.createVulnerabilityScan({ - node_id: 1, + node_id: overrides.node_id ?? 1, image_ref: overrides.image_ref ?? 'alpine:3.19', image_digest: `sha256:${Math.random().toString(16).slice(2)}`, scanned_at: overrides.scanned_at ?? Date.now(), @@ -93,3 +94,113 @@ describe('getVulnerabilityScans filters and pagination', () => { expect(page1.items[0].id).not.toBe(page2.items[0].id); }); }); + +describe('getVulnerabilityScans per-image cap', () => { + it('caps rows per image_ref when no imageRef filter is set', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('scan_history_per_image_limit', '10'); + + for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }); + for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i }); + + const result = db.getVulnerabilityScans(1, { limit: 500 }); + const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest'); + const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest'); + + expect(hotRows).toHaveLength(10); + expect(coolRows).toHaveLength(5); + expect(result.total).toBe(15); + expect(result.cappedImageRefs).toEqual(['hot:latest']); + expect(result.perImageLimit).toBe(10); + }); + + it('bypasses the cap when imageRef targets a single image', () => { + const db = DatabaseService.getInstance(); + db.updateGlobalSetting('scan_history_per_image_limit', '10'); + + for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }); + + const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 }); + expect(result.items).toHaveLength(30); + expect(result.total).toBe(30); + expect(result.cappedImageRefs).toEqual([]); + }); +}); + +describe('pruneScanHistoryPerImage', () => { + it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => { + const db = DatabaseService.getInstance(); + for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }); + for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i }); + + const deleted = db.pruneScanHistoryPerImage(50); + expect(deleted).toBe(10); + + const after = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 }); + expect(after.items).toHaveLength(50); + const oldest = Math.min(...after.items.map((s) => s.scanned_at)); + expect(oldest).toBe(1010); + + const cool = db.getVulnerabilityScans(1, { imageRef: 'cool:latest', limit: 500 }); + expect(cool.items).toHaveLength(5); + }); + + it('is a no-op when no image exceeds the cap', () => { + const db = DatabaseService.getInstance(); + for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i }); + + const deleted = db.pruneScanHistoryPerImage(50); + expect(deleted).toBe(0); + }); + + it('partitions by node_id so two nodes scanning the same image keep independent histories', () => { + const db = DatabaseService.getInstance(); + db.getDb() + .prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at) + VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`) + .run(Date.now()); + + for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i }); + for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i }); + + const deleted = db.pruneScanHistoryPerImage(50); + expect(deleted).toBe(20); + + const node1 = db.getVulnerabilityScans(1, { imageRef: 'alpine:3.19', limit: 500 }); + const node2 = db.getVulnerabilityScans(2, { imageRef: 'alpine:3.19', limit: 500 }); + expect(node1.items).toHaveLength(50); + expect(node2.items).toHaveLength(50); + }); + + it('deletes child vulnerability_details rows for pruned scans', () => { + const db = DatabaseService.getInstance(); + const ids: number[] = []; + for (let i = 0; i < 60; i++) { + ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i })); + } + const oldestScanId = ids[0]; + db.insertVulnerabilityDetails(oldestScanId, [{ + vulnerability_id: 'CVE-2020-0001', + pkg_name: 'libfoo', + installed_version: '1.0', + fixed_version: '1.1', + severity: 'HIGH', + title: 'Test', + description: null, + primary_url: null, + }]); + + const beforeChildren = db.getDb() + .prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?') + .get(oldestScanId) as { cnt: number }; + expect(beforeChildren.cnt).toBe(1); + + const deleted = db.pruneScanHistoryPerImage(50); + expect(deleted).toBe(10); + + const afterChildren = db.getDb() + .prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?') + .get(oldestScanId) as { cnt: number }; + expect(afterChildren.cnt).toBe(0); + }); +}); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 1cf77767..0663c4d5 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -23,6 +23,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'log_retention_days', 'audit_retention_days', 'mesh_auto_recreate', + 'scan_history_per_image_limit', ]); // Bulk PATCH schema. All keys optional; present keys are fully validated. @@ -39,6 +40,7 @@ const SettingsPatchSchema = z.object({ log_retention_days: z.coerce.number().int().min(1).max(365).transform(String), audit_retention_days: z.coerce.number().int().min(1).max(365).transform(String), mesh_auto_recreate: z.enum(['0', '1']), + scan_history_per_image_limit: z.coerce.number().int().min(5).max(1000).transform(String), }).partial(); export const settingsRouter = Router(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a2cd0e4f..1ebbfc5c 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1244,6 +1244,7 @@ export class DatabaseService { stmt.run('developer_mode', '0'); stmt.run('metrics_retention_hours', '24'); stmt.run('log_retention_days', '30'); + stmt.run('scan_history_per_image_limit', '50'); stmt.run('trivy_auto_update', '0'); stmt.run('trivy_last_notified_version', ''); stmt.run('mesh_auto_recreate', '0'); @@ -3402,7 +3403,7 @@ export class DatabaseService { public getVulnerabilityScans( nodeId: number, opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {}, - ): { items: VulnerabilityScan[]; total: number } { + ): { items: VulnerabilityScan[]; total: number; cappedImageRefs: string[]; perImageLimit: number } { const limit = Math.max(1, Math.min(opts.limit ?? 50, 500)); const offset = Math.max(0, opts.offset ?? 0); const where = ['node_id = ?']; @@ -3420,17 +3421,100 @@ export class DatabaseService { params.push(opts.status); } const whereSql = where.join(' AND '); + + // Grouped (history) view caps rows per image_ref so a hot image + // cannot drown out the others. Single-image deep-dive (imageRef set) + // bypasses the cap so users can drill past it. + const applyPerImageCap = !opts.imageRef; + const parsedLimit = parseInt(this.getGlobalSettings()['scan_history_per_image_limit'] ?? '50', 10); + const perImageLimit = parsedLimit > 0 ? parsedLimit : 50; + + if (!applyPerImageCap) { + 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, cappedImageRefs: [], perImageLimit }; + } + + const rankedCte = `WITH ranked AS ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY image_ref ORDER BY scanned_at DESC) AS rn + FROM vulnerability_scans + WHERE ${whereSql} + )`; const total = ( this.db - .prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`) - .get(...(params as never[])) as { cnt: number } + .prepare(`${rankedCte} SELECT COUNT(*) as cnt FROM ranked WHERE rn <= ?`) + .get(...(params as never[]), perImageLimit) as { cnt: number } ).cnt; const items = this.db .prepare( - `SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`, + `${rankedCte} SELECT id, node_id, image_ref, image_digest, scanned_at, + total_vulnerabilities, critical_count, high_count, medium_count, low_count, + unknown_count, fixable_count, secret_count, misconfig_count, scanners_used, + highest_severity, os_info, trivy_version, scan_duration_ms, triggered_by, + status, error, stack_context, policy_evaluation + FROM ranked WHERE rn <= ? + ORDER BY scanned_at DESC LIMIT ? OFFSET ?`, ) - .all(...(params as never[]), limit, offset) as VulnerabilityScan[]; - return { items, total }; + .all(...(params as never[]), perImageLimit, limit, offset) as VulnerabilityScan[]; + + // Identify which image_refs sit at or above the cap so the UI can + // flag them. `>=` (not `>`) is intentional: the daily prune keeps + // each image at exactly perImageLimit rows, so by the time a user + // opens the history sheet the underlying count rarely exceeds the + // cap. Flagging at-cap groups still tells the truth (older scans + // have been or will be pruned at this image's next scan). + const cappedRows = this.db + .prepare( + `SELECT image_ref FROM vulnerability_scans + WHERE ${whereSql} + GROUP BY image_ref HAVING COUNT(*) >= ?`, + ) + .all(...(params as never[]), perImageLimit) as Array<{ image_ref: string }>; + const cappedImageRefs = cappedRows.map((r) => r.image_ref); + + return { items, total, cappedImageRefs, perImageLimit }; + } + + /** + * Per-image scan history pruner. For each (node_id, image_ref), keep the + * newest N scans (ordered by scanned_at DESC) and delete older rows along + * with their child findings. SQLite foreign-key cascade is not enabled + * at the connection level here, so children are deleted explicitly. The + * subquery is self-contained so we don't bind one parameter per ID. A + * first-run backlog of thousands of stale scans would otherwise blow + * past SQLITE_MAX_VARIABLE_NUMBER. + */ + public pruneScanHistoryPerImage(perImageLimit: number): number { + const limit = Math.max(1, Math.floor(perImageLimit)); + const overflowSubquery = `SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY node_id, image_ref ORDER BY scanned_at DESC + ) AS rn + FROM vulnerability_scans + ) WHERE rn > ?`; + const deleteChild = (table: string) => + this.db + .prepare(`DELETE FROM ${table} WHERE scan_id IN (${overflowSubquery})`) + .run(limit); + const deleteParent = this.db.prepare( + `DELETE FROM vulnerability_scans WHERE id IN (${overflowSubquery})`, + ); + + const txn = this.db.transaction(() => { + deleteChild('vulnerability_details'); + deleteChild('secret_findings'); + deleteChild('misconfig_findings'); + return deleteParent.run(limit).changes; + }); + return txn(); } public getLatestScanForImage( diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 6299c1b1..485321b3 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -551,7 +551,9 @@ export class MonitorService { const notifSummary = db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays); const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10); db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays); - if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d`); + const scanPerImage = parseInt(settings['scan_history_per_image_limit'] || '50', 10); + const scanPruned = db.pruneScanHistoryPerImage(isNaN(scanPerImage) ? 50 : scanPerImage); + if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d, scans pruned ${scanPruned}`); } catch (e) { console.error('MonitorService: failed to cleanup old data', e); } diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx index 10feb134..0339f953 100644 --- a/frontend/src/components/SecurityHistoryView.tsx +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -65,6 +65,7 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) const { activeNode } = useNodes(); const [scans, setScans] = useState([]); const [total, setTotal] = useState(0); + const [capInfo, setCapInfo] = useState<{ perImageLimit: number; refs: Set } | null>(null); const [loading, setLoading] = useState(false); const [searchDraft, setSearchDraft] = useState(''); const [search, setSearch] = useState(''); @@ -88,6 +89,9 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; setScans(items); setTotal(typeof body?.total === 'number' ? body.total : items.length); + const limit = typeof body?.perImageLimit === 'number' ? body.perImageLimit : 0; + const refs: string[] = Array.isArray(body?.cappedImageRefs) ? body.cappedImageRefs : []; + setCapInfo(limit > 0 ? { perImageLimit: limit, refs: new Set(refs) } : null); } catch (err) { toast.error((err as Error)?.message || 'Could not load scan history'); } finally { @@ -114,7 +118,13 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) // happen to match the previous values, so the fetch re-runs exactly once. }, [open, load, page, search, reloadToken]); + // Skip the initial mount: the effect fires once with the original + // searchDraft, and unconditionally resetting page to 0 after 300ms races + // with any pagination the user may have done in that window. + const prevSearchDraftRef = useRef(searchDraft); useEffect(() => { + if (prevSearchDraftRef.current === searchDraft) return; + prevSearchDraftRef.current = searchDraft; const t = setTimeout(() => { setSearch(searchDraft); setPage(0); @@ -227,79 +237,89 @@ export function SecurityHistoryView({ open, onClose }: SecurityHistoryViewProps) ) : (
- {groups.map((group) => ( -
-
- - {group.image_ref} - - - {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} - -
- - - - - Scanned - Trigger - Highest - Total - Fixable - - - - - {group.scans.map((scan) => { - const isSelected = selected.includes(scan.id); - return ( - - - toggleSelect(scan.id)} - aria-label={`Select scan ${scan.id}`} - /> - - - {new Date(scan.scanned_at).toLocaleString()} - - - {scan.triggered_by} - - - {scan.highest_severity ? ( - - ) : ( - none - )} - - - {scan.total_vulnerabilities} - - - {scan.fixable_count} - - - - + {groups.map((group) => { + const isCapped = capInfo?.refs.has(group.image_ref) ?? false; + return ( +
+
+ + {group.image_ref} + + + {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} + + {isCapped && capInfo && ( + + Capped at {capInfo.perImageLimit} ยท older scans pruned + + )} +
+ +
+ + + + Scanned + Trigger + Highest + Total + Fixable + - ); - })} - -
-
- ))} + + + {group.scans.map((scan) => { + const isSelected = selected.includes(scan.id); + return ( + + + toggleSelect(scan.id)} + aria-label={`Select scan ${scan.id}`} + /> + + + {new Date(scan.scanned_at).toLocaleString()} + + + {scan.triggered_by} + + + {scan.highest_severity ? ( + + ) : ( + none + )} + + + {scan.total_vulnerabilities} + + + {scan.fixable_count} + + + + + + ); + })} + + + +
+ ); + })}
)} diff --git a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx index 1bc3a209..c160bd29 100644 --- a/frontend/src/components/__tests__/SecurityHistoryView.test.tsx +++ b/frontend/src/components/__tests__/SecurityHistoryView.test.tsx @@ -86,11 +86,19 @@ function scan(overrides: Partial = {}): VulnerabilityScan { }; } -function listResponse(items: VulnerabilityScan[], total?: number): Response { +function listResponse( + items: VulnerabilityScan[], + opts: { total?: number; cappedImageRefs?: string[]; perImageLimit?: number } = {}, +): Response { return { ok: true, status: 200, - json: async () => ({ items, total: total ?? items.length }), + json: async () => ({ + items, + total: opts.total ?? items.length, + cappedImageRefs: opts.cappedImageRefs ?? [], + perImageLimit: opts.perImageLimit ?? 50, + }), } as unknown as Response; } @@ -116,7 +124,7 @@ describe('SecurityHistoryView', () => { }); it('advances offset when the user pages forward', async () => { - mockedFetch.mockResolvedValue(listResponse([scan()], 250)); + mockedFetch.mockResolvedValue(listResponse([scan()], { total: 250 })); const user = userEvent.setup(); render(); @@ -228,4 +236,21 @@ describe('SecurityHistoryView', () => { expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeEnabled(); }); + + it('renders the cap hint only for images flagged in cappedImageRefs', async () => { + mockedFetch.mockResolvedValue( + listResponse( + [ + scan({ id: 1, image_ref: 'hot:latest', scanned_at: 1000 }), + scan({ id: 2, image_ref: 'cool:latest', scanned_at: 2000 }), + ], + { cappedImageRefs: ['hot:latest'], perImageLimit: 50 }, + ), + ); + render(); + + const cappedHint = await screen.findByText(/Capped at 50 . older scans pruned/); + expect(cappedHint).toBeInTheDocument(); + expect(screen.queryAllByText(/Capped at 50/)).toHaveLength(1); + }); }); diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index f96c49df..8d55f881 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -30,13 +30,14 @@ function SectionSkeleton() { ); } -type DeveloperFields = Pick; +type DeveloperFields = Pick; const DEFAULT_DEVELOPER: DeveloperFields = { developer_mode: DEFAULT_SETTINGS.developer_mode, metrics_retention_hours: DEFAULT_SETTINGS.metrics_retention_hours, log_retention_days: DEFAULT_SETTINGS.log_retention_days, audit_retention_days: DEFAULT_SETTINGS.audit_retention_days, + scan_history_per_image_limit: DEFAULT_SETTINGS.scan_history_per_image_limit, }; export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { @@ -51,7 +52,8 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { settings.developer_mode !== serverSettingsRef.current.developer_mode || settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours || settings.log_retention_days !== serverSettingsRef.current.log_retention_days || - settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days; + settings.audit_retention_days !== serverSettingsRef.current.audit_retention_days || + settings.scan_history_per_image_limit !== serverSettingsRef.current.scan_history_per_image_limit; useEffect(() => { onDirtyChange?.(hasChanges); @@ -85,6 +87,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours, log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, audit_retention_days: localData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days, + scan_history_per_image_limit: localData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit, }; setSettings(safe); serverSettingsRef.current = { ...safe }; @@ -108,6 +111,7 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { metrics_retention_hours: settings.metrics_retention_hours, log_retention_days: settings.log_retention_days, audit_retention_days: settings.audit_retention_days, + scan_history_per_image_limit: settings.scan_history_per_image_limit, }; setIsSaving(true); try { @@ -185,6 +189,23 @@ export function DeveloperSection({ onDirtyChange }: DeveloperSectionProps) { + +
+ onSettingChange('scan_history_per_image_limit', e.target.value)} + className="w-24" + /> + scans +
+
+ {isPaid && license?.variant === 'admiral' && (