diff --git a/backend/src/__tests__/database-scan-purge.test.ts b/backend/src/__tests__/database-scan-purge.test.ts new file mode 100644 index 00000000..e8aadc14 --- /dev/null +++ b/backend/src/__tests__/database-scan-purge.test.ts @@ -0,0 +1,141 @@ +/** + * Coverage for the orphan-scan purge helpers on the real DatabaseService: + * - getDistinctScanImageRefs (node-scoped, deduplicated) + * - deleteScansByImageRef (removes the scan AND its children explicitly, + * since SQLite FK cascade is not enabled on the connection) + * - deleteStackScans (purges the stack: misconfig scan only) + * + * Uses the real DatabaseService against a temp DB so the no-cascade delete path + * is exercised exactly as in production, not against an in-memory mirror that + * might enable foreign_keys and mask a missing child delete. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function db() { + return DatabaseService.getInstance(); +} + +function reset(): void { + const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + raw.prepare('DELETE FROM vulnerability_scans').run(); + raw.prepare('DELETE FROM vulnerability_details').run(); + raw.prepare('DELETE FROM secret_findings').run(); + raw.prepare('DELETE FROM misconfig_findings').run(); +} + +function seedScan(imageRef: string, nodeId = 1): number { + return db().createVulnerabilityScan({ + node_id: nodeId, + image_ref: imageRef, + image_digest: `sha256:${imageRef}-${Math.random().toString(16).slice(2)}`, + 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, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: null, + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: null, + }); +} + +function countChild(table: string, scanId: number): number { + const raw = (db() as unknown as { db: { prepare: (s: string) => { get: (...a: unknown[]) => { c: number } } } }).db; + return raw.prepare(`SELECT COUNT(*) as c FROM ${table} WHERE scan_id = ?`).get(scanId).c; +} + +beforeEach(reset); + +describe('getDistinctScanImageRefs', () => { + it('returns each image_ref once, scoped to the node', () => { + seedScan('nginx:1'); + seedScan('nginx:1'); // duplicate ref, same node + seedScan('redis:7'); + seedScan('other:1', 2); // different node + + const refs = db().getDistinctScanImageRefs(1).sort(); + expect(refs).toEqual(['nginx:1', 'redis:7']); + }); + + it('returns an empty array when the node has no scans', () => { + expect(db().getDistinctScanImageRefs(99)).toEqual([]); + }); +}); + +describe('deleteScansByImageRef', () => { + it('removes the scan and ALL of its child findings (no FK cascade reliance)', () => { + const scanId = seedScan('nginx:1'); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-1', pkg_name: 'libssl', installed_version: '1.0', fixed_version: '1.1', severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().insertSecretFindings(scanId, [ + { rule_id: 'aws-key', category: 'secret', severity: 'HIGH', title: null, target: 'app.env', start_line: 1, end_line: 1, match_excerpt: null }, + ]); + db().insertMisconfigFindings(scanId, [ + { rule_id: 'DS002', check_id: null, severity: 'MEDIUM', title: null, message: null, resolution: null, target: 'Dockerfile', primary_url: null }, + ]); + + expect(countChild('vulnerability_details', scanId)).toBe(1); + expect(countChild('secret_findings', scanId)).toBe(1); + expect(countChild('misconfig_findings', scanId)).toBe(1); + + const removed = db().deleteScansByImageRef(1, 'nginx:1'); + + expect(removed).toBe(1); + expect(db().getDistinctScanImageRefs(1)).toEqual([]); + expect(countChild('vulnerability_details', scanId)).toBe(0); + expect(countChild('secret_findings', scanId)).toBe(0); + expect(countChild('misconfig_findings', scanId)).toBe(0); + }); + + it('only deletes the requested ref and node, leaving others intact', () => { + seedScan('nginx:1'); + seedScan('redis:7'); + seedScan('nginx:1', 2); + + const removed = db().deleteScansByImageRef(1, 'nginx:1'); + + expect(removed).toBe(1); + expect(db().getDistinctScanImageRefs(1).sort()).toEqual(['redis:7']); + expect(db().getDistinctScanImageRefs(2)).toEqual(['nginx:1']); + }); + + it('is idempotent (0 when nothing matches)', () => { + expect(db().deleteScansByImageRef(1, 'ghost:1')).toBe(0); + }); +}); + +describe('deleteStackScans', () => { + it('purges only the stack: scan, not unrelated image scans', () => { + seedScan('stack:web'); + seedScan('stack:db'); + seedScan('nginx:1'); + + const removed = db().deleteStackScans(1, 'web'); + + expect(removed).toBe(1); + expect(db().getDistinctScanImageRefs(1).sort()).toEqual(['nginx:1', 'stack:db']); + }); +}); diff --git a/backend/src/__tests__/monitor-service.test.ts b/backend/src/__tests__/monitor-service.test.ts index 0791dc9a..af59b611 100644 --- a/backend/src/__tests__/monitor-service.test.ts +++ b/backend/src/__tests__/monitor-service.test.ts @@ -10,7 +10,7 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine mockCleanupOldMetrics, mockCleanupOldNotifications, mockCleanupOldAuditLogs, mockUpdateStackAlertLastFired, mockGetSystemState, mockSetSystemState, mockGetRunningContainers, mockGetAllContainers, mockGetContainerStatsStream, - mockGetContainerRestartCount, mockGetDiskUsage, + mockGetContainerRestartCount, mockGetDiskUsage, mockGetImages, mockGetStacks, mockDispatchAlert, mockCurrentLoad, mockMem, mockFsSize, mockExecAsync, @@ -36,6 +36,8 @@ const { mockGetGlobalSettings, mockGetNodes, mockGetStackAlerts, mockAddContaine reclaimableImages: 0, reclaimableContainers: 0, reclaimableVolumes: 0, reclaimableBuildCache: 0, reclaimableImageCount: 0, reclaimableContainerCount: 0, reclaimableVolumeCount: 0, reclaimableBuildCacheCount: 0, }), + mockGetImages: vi.fn().mockResolvedValue([]), + mockGetStacks: vi.fn().mockResolvedValue([]), mockDispatchAlert: vi.fn().mockResolvedValue(undefined), mockCurrentLoad: vi.fn().mockResolvedValue({ currentLoad: 10 }), mockMem: vi.fn().mockResolvedValue({ total: 16e9, used: 4e9, active: 4e9, available: 12e9, free: 12e9, buffcache: 0 }), @@ -71,6 +73,15 @@ vi.mock('../services/DockerController', () => ({ getContainerStatsStream: mockGetContainerStatsStream, getContainerRestartCount: mockGetContainerRestartCount, getDiskUsage: mockGetDiskUsage, + getImages: mockGetImages, + }), + }, +})); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + getStacks: mockGetStacks, }), }, })); @@ -1562,3 +1573,77 @@ describe('MonitorService - janitor cycle and circuit breaker', () => { expect((svc as any).janitorFirstTickTimeoutId).toBeNull(); }); }); + +describe('MonitorService - reconcileOrphanedScans', () => { + function makeDb(refs: string[]) { + return { + getDistinctScanImageRefs: vi.fn().mockReturnValue(refs), + deleteScansByImageRef: vi.fn().mockReturnValue(1), + }; + } + + async function run(db: ReturnType, settings: Record) { + const svc = MonitorService.getInstance(); + await (svc as any).reconcileOrphanedScans(db, settings); + return db.deleteScansByImageRef.mock.calls.map((c) => c[1] as string).sort(); + } + + it('does nothing when prune_orphaned_scans is not "1"', async () => { + const db = makeDb(['nginx:1']); + await run(db, { prune_orphaned_scans: '0' }); + expect(db.getDistinctScanImageRefs).not.toHaveBeenCalled(); + expect(db.deleteScansByImageRef).not.toHaveBeenCalled(); + }); + + it('purges only the image and stack scans whose artifact is gone, scoped to the local node', async () => { + mockGetImages.mockResolvedValue([ + { RepoTags: ['nginx:1', 'redis:7'] }, + { RepoTags: [':'] }, + { RepoTags: undefined }, // dangling image with no tags + ]); + mockGetStacks.mockResolvedValue(['web']); + const db = makeDb(['nginx:1', 'ghost:9', 'stack:web', 'stack:old']); + expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['ghost:9', 'stack:old']); + // Per-instance: reconciliation reads and deletes against the local node id only. + expect(db.getDistinctScanImageRefs).toHaveBeenCalledWith(1); + for (const call of db.deleteScansByImageRef.mock.calls) { + expect(call[0]).toBe(1); + } + }); + + it('keeps scans whose ref matches a live image after normalization (untagged, registry-qualified, digest-pinned)', async () => { + mockGetImages.mockResolvedValue([ + { RepoTags: ['alpine:latest', 'nginx:1.14'], RepoDigests: ['redis@sha256:abc'] }, + ]); + mockGetStacks.mockResolvedValue(['web']); + // Stored refs in non-canonical forms that all resolve to a present image: + // alpine -> alpine:latest, docker.io/library/nginx:1.14 -> nginx:1.14, + // docker.io/library/redis@sha256:abc -> redis@sha256:abc (digest). + const db = makeDb(['alpine', 'docker.io/library/nginx:1.14', 'docker.io/library/redis@sha256:abc', 'ghost:9']); + expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['ghost:9']); + }); + + it('skips stack reconciliation when getStacks returns empty (ambiguous), still purges image orphans', async () => { + mockGetImages.mockResolvedValue([{ RepoTags: ['nginx:1'] }]); + mockGetStacks.mockResolvedValue([]); + const db = makeDb(['stack:web', 'ghost:9']); + const deleted = await run(db, { prune_orphaned_scans: '1' }); + expect(deleted).toContain('ghost:9'); + expect(deleted).not.toContain('stack:web'); + }); + + it('purges nothing when the Docker image list cannot be read (fail-safe)', async () => { + mockGetImages.mockRejectedValue(new Error('docker down')); + mockGetStacks.mockResolvedValue(['web']); + const db = makeDb(['ghost:9', 'stack:old']); + await run(db, { prune_orphaned_scans: '1' }); + expect(db.deleteScansByImageRef).not.toHaveBeenCalled(); + }); + + it('purges every image scan when there are zero live images (empty but successful read)', async () => { + mockGetImages.mockResolvedValue([]); + mockGetStacks.mockResolvedValue(['web']); + const db = makeDb(['nginx:1', 'redis:7', 'stack:web']); + expect(await run(db, { prune_orphaned_scans: '1' })).toEqual(['nginx:1', 'redis:7']); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index c7828abd..16663fff 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -241,6 +241,46 @@ describe('prune_on_update (auto-prune after updates)', () => { }); }); +describe('prune_orphaned_scans (purge scans for deleted images/stacks)', () => { + it('defaults to ON in a freshly seeded database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('1'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.prune_orphaned_scans).toBeDefined(); + }); + + it('rejects a non-admin write with 403', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', viewerCookie) + .send({ key: 'prune_orphaned_scans', value: '0' }); + expect(res.status).toBe(403); + }); + + it('accepts a well-formed write and rejects a non-enum value', async () => { + const ok = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'prune_orphaned_scans', value: '0' }); + expect(ok.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('0'); + + const bad = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'prune_orphaned_scans', value: 'banana' }); + expect(bad.status).toBe(400); + expect(bad.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('0'); + + // Restore the seeded default so later suites observe the shipped behavior. + DatabaseService.getInstance().updateGlobalSetting('prune_orphaned_scans', '1'); + }); +}); + describe('health gate settings', () => { it('seeds enabled with a 90 second window in a fresh database', () => { const settings = DatabaseService.getInstance().getGlobalSettings(); diff --git a/backend/src/__tests__/stack-delete-purges-scans.test.ts b/backend/src/__tests__/stack-delete-purges-scans.test.ts new file mode 100644 index 00000000..5e111b8d --- /dev/null +++ b/backend/src/__tests__/stack-delete-purges-scans.test.ts @@ -0,0 +1,92 @@ +/** + * DELETE /api/stacks/:stackName must purge the deleted stack's compose-config + * (misconfig) scan, keyed by the `stack:` image_ref convention, so it no + * longer skews the Security Overview. Image scans are intentionally left to the + * janitor reconciler (images are shared and may still exist on the host). + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let ComposeService: typeof import('../services/ComposeService').ComposeService; +let FileSystemService: typeof import('../services/FileSystemService').FileSystemService; +let MeshService: typeof import('../services/MeshService').MeshService; +let adminCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ ComposeService } = await import('../services/ComposeService')); + ({ FileSystemService } = await import('../services/FileSystemService')); + ({ MeshService } = await import('../services/MeshService')); + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +beforeEach(() => { + vi.restoreAllMocks(); + // Stub leaf I/O so the real delete handler runs through to the DB cleanup. + vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined); + vi.spyOn(FileSystemService.prototype, 'deleteStack').mockResolvedValue(undefined); + vi.spyOn(MeshService.getInstance(), 'optOutStack').mockResolvedValue(undefined); + const raw = DatabaseService.getInstance().getDb(); + raw.prepare('DELETE FROM vulnerability_scans').run(); +}); + +function seedStackScan(nodeId: number, imageRef: string): void { + DatabaseService.getInstance().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, + secret_count: 0, + misconfig_count: 1, + scanners_used: 'misconfig', + highest_severity: 'MEDIUM', + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: imageRef.startsWith('stack:') ? imageRef.slice('stack:'.length) : null, + }); +} + +describe('DELETE /api/stacks/:stackName purges scan data', () => { + it("removes the deleted stack's stack: scan and leaves other scans", async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getNodes()[0].id; + seedStackScan(nodeId, 'stack:web'); + seedStackScan(nodeId, 'stack:db'); + seedStackScan(nodeId, 'nginx:1'); + + const res = await request(app).delete('/api/stacks/web').set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(db.getDistinctScanImageRefs(nodeId).sort()).toEqual(['nginx:1', 'stack:db']); + }); + + it('is a no-op (still 200) when the deleted stack has no scans', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getNodes()[0].id; + seedStackScan(nodeId, 'nginx:1'); + + const res = await request(app).delete('/api/stacks/never-scanned').set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(db.getDistinctScanImageRefs(nodeId)).toEqual(['nginx:1']); + }); +}); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 03b07325..e642975d 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -25,6 +25,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'audit_retention_days', 'mesh_auto_recreate', 'scan_history_per_image_limit', + 'prune_orphaned_scans', 'prune_on_update', 'reclaim_hero', 'snapshot_documentation', @@ -54,6 +55,7 @@ const SettingsPatchSchema = z.object({ 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), + prune_orphaned_scans: z.enum(['0', '1']), prune_on_update: z.enum(['0', '1']), reclaim_hero: z.enum(['0', '1']), snapshot_documentation: z.enum(['0', '1']), diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 26e7be43..8a47dfe4 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1047,6 +1047,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { DatabaseService.getInstance().deleteStackExposureIntents(req.nodeId, stackName); DatabaseService.getInstance().deleteStackExposure(req.nodeId, stackName); DatabaseService.getInstance().deleteStackProjectEnvFiles(req.nodeId, stackName); + DatabaseService.getInstance().deleteStackScans(req.nodeId, stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); } catch (dbErr) { console.error('[Stacks] Database cleanup failed for %s; files already removed:', sanitizeForLog(stackName), dbErr); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index ed10fc03..d3458a90 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1572,6 +1572,11 @@ export class DatabaseService { stmt.run('metrics_retention_hours', '24'); stmt.run('log_retention_days', '30'); stmt.run('scan_history_per_image_limit', '50'); + // Remove scan results when their image is gone from Docker or their + // stack folder is deleted, so the Security Overview reflects what still + // exists. On by default; operators who keep scan history for deleted + // artifacts can turn it off. + stmt.run('prune_orphaned_scans', '1'); stmt.run('trivy_auto_update', '0'); stmt.run('trivy_last_notified_version', ''); stmt.run('deploy_block_honor_suppressions', '0'); @@ -4495,6 +4500,55 @@ export class DatabaseService { return txn(); } + /** + * Distinct image_refs that have at least one scan row for a node. Used by + * the orphan-scan reconciler to compare stored scans against the artifacts + * (images, stacks) that still exist on the host. + */ + public getDistinctScanImageRefs(nodeId: number): string[] { + return ( + this.db + .prepare( + 'SELECT DISTINCT image_ref FROM vulnerability_scans WHERE node_id = ?', + ) + .all(nodeId) as Array<{ image_ref: string }> + ).map((r) => r.image_ref); + } + + /** + * Delete every scan (and its findings) for one (node_id, image_ref). Used + * to purge scans whose artifact is gone. Children are deleted explicitly + * because SQLite foreign-key cascade is not enabled at the connection + * level (see pruneScanHistoryPerImage). Returns the parent rows removed; + * idempotent (0 when nothing matches). + */ + public deleteScansByImageRef(nodeId: number, imageRef: string): number { + const idSubquery = + 'SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?'; + const deleteChild = (table: string) => + this.db + .prepare(`DELETE FROM ${table} WHERE scan_id IN (${idSubquery})`) + .run(nodeId, imageRef); + const deleteParent = this.db.prepare( + 'DELETE FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?', + ); + const txn = this.db.transaction(() => { + deleteChild('vulnerability_details'); + deleteChild('secret_findings'); + deleteChild('misconfig_findings'); + return deleteParent.run(nodeId, imageRef).changes; + }); + return txn(); + } + + /** + * Purge the compose-config (misconfig) scans for a deleted stack, keyed by + * the `stack:` image_ref convention used by scanComposeStack. + */ + public deleteStackScans(nodeId: number, stackName: string): number { + return this.deleteScansByImageRef(nodeId, `stack:${stackName}`); + } + public getLatestScanForImage( nodeId: number, imageRef: string, diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 6e136f9a..931e4f1f 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -3,6 +3,8 @@ import semver from 'semver'; import DockerController from './DockerController'; import { DatabaseService, Node, StackAlert } from './DatabaseService'; import { NodeRegistry } from './NodeRegistry'; +import { FileSystemService } from './FileSystemService'; +import { normalizeImageRef } from './DriftDetectionService'; import { NotificationService } from './NotificationService'; import { FleetUpdateTrackerService } from './FleetUpdateTrackerService'; import { isValidVersion, getSenchoVersion } from './CapabilityRegistry'; @@ -374,6 +376,17 @@ export class MonitorService { try { const db = DatabaseService.getInstance(); const settings = db.getGlobalSettings(); + + // Prune scans for artifacts that no longer exist (own try/catch so a + // reconcile failure never aborts the disk-usage check below). Lives + // here because it is Docker-heavy and the 15-min janitor cadence is + // the right throttle; it runs even when the disk alert is disabled. + try { + await this.reconcileOrphanedScans(db, settings); + } catch (e) { + console.error('[Monitor] Orphaned-scan reconcile failed', e); + } + const janitorLimitGb = parseFloat(settings['docker_janitor_gb']); if (isNaN(janitorLimitGb) || janitorLimitGb <= 0) return; @@ -445,6 +458,83 @@ export class MonitorService { } } + /** + * Remove scans whose artifact no longer exists, so the Security Overview + * reflects what is still on the host. Per-instance: only the local node's + * scans are reconciled against the local Docker image list and stack dirs. + * + * Fail-safe by construction: a scan is only purged when we positively know + * its artifact is gone. If the image list cannot be read, the whole pass is + * skipped (we never delete on an unread list). Stack scans are reconciled + * only when getStacks() returns a non-empty list, because it returns [] on + * both an empty dir and an FS error and we cannot tell them apart. + * + * Image refs are compared after normalizeImageRef so equivalent forms match + * (untagged `alpine` vs Docker's `alpine:latest`, `docker.io/library/` + * prefixes), and both RepoTags and RepoDigests feed the live set so a + * digest-pinned scan ref still matches a present image. Erring toward + * "matches, keep" is the safe direction: the cost of a miss is a stale scan, + * not a wrongly deleted one. + */ + private async reconcileOrphanedScans( + db: DatabaseService, + settings: Readonly>, + ): Promise { + // Destructive cleanup: enabled only on an explicit '1'. A missing key or + // a read failure leaves it off, the safe default. + if (settings['prune_orphaned_scans'] !== '1') return; + + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + + let liveImageRefs: Set; + try { + const images = (await withTimeout( + DockerController.getInstance(nodeId).getImages(), + JANITOR_TIMEOUT_MS, + 'docker images (scan reconcile)', + )) as Array<{ RepoTags?: string[]; RepoDigests?: string[] }>; + liveImageRefs = new Set(); + for (const img of images) { + for (const ref of [...(img.RepoTags ?? []), ...(img.RepoDigests ?? [])]) { + if (ref && !ref.startsWith('')) liveImageRefs.add(normalizeImageRef(ref)); + } + } + } catch (e) { + if (isDebugEnabled()) { + console.debug('[Monitor:diag] Scan reconcile skipped: image list unavailable', e); + } + return; + } + + // getStacks() never throws (it swallows FS errors and returns []), so an + // empty list is ambiguous (empty dir vs failed read); reconcileStacks + // gates stack purging on a non-empty list to avoid deleting on a failure. + const liveStacks = await FileSystemService.getInstance(nodeId).getStacks(); + const reconcileStacks = liveStacks.length > 0; + const liveStackSet = new Set(liveStacks); + + let purgedImageScans = 0; + let purgedStackScans = 0; + for (const ref of db.getDistinctScanImageRefs(nodeId)) { + if (ref.startsWith('stack:')) { + if (!reconcileStacks) continue; + if (!liveStackSet.has(ref.slice('stack:'.length))) { + purgedStackScans += db.deleteScansByImageRef(nodeId, ref); + } + } else if (!liveImageRefs.has(normalizeImageRef(ref))) { + purgedImageScans += db.deleteScansByImageRef(nodeId, ref); + } + } + + if (isDebugEnabled() && (purgedImageScans > 0 || purgedStackScans > 0)) { + console.debug( + `[Monitor:diag] Scan reconcile: purged ${purgedImageScans} image scan(s) ` + + `(${liveImageRefs.size} live image refs), ${purgedStackScans} stack scan(s)` + + (reconcileStacks ? '' : ' (stack reconcile skipped: empty stack list)'), + ); + } + } + /** * Check GitHub/Docker Hub for a newer Sencho release and dispatch a * one-shot notification. Uses getLatestVersion() which wraps CacheService diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 704e91b2..f163bd93 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -454,8 +454,11 @@ How long Sencho keeps historical data on this node before pruning it. | **Container metrics** | 24 hrs | 8,760 (1 year) | How long to keep per-container CPU, RAM, and network history for dashboard charts. | | **Notification log** | 30 days | 365 | How long to keep alert and notification history. | | **Scan history per image** | 50 scans | 1,000 | How many vulnerability scans to keep per image. Older scans beyond the cap are pruned. | +| **Remove scans for deleted images and stacks** | On | - | When on, scan results are deleted once their image is gone from this node or their stack is deleted, so the Security Overview stays tied to what still exists. Turn it off to keep scan history for removed images and stacks. | | **Audit log** | 90 days | 365 | How long to keep audit trail entries. Requires Admiral. | +Removing scans for deleted artifacts runs in the background a few minutes after an image or stack disappears, and immediately when you delete a stack. An image that is still present on the node keeps its scan results, even when no stack uses it. + Click **Save settings** to apply. --- diff --git a/frontend/src/components/settings/DataRetentionSection.tsx b/frontend/src/components/settings/DataRetentionSection.tsx index ad6a1346..e4d5e320 100644 --- a/frontend/src/components/settings/DataRetentionSection.tsx +++ b/frontend/src/components/settings/DataRetentionSection.tsx @@ -16,6 +16,7 @@ import { SettingsField } from './SettingsField'; import { SettingsActions, SettingsPrimaryButton } from './SettingsActions'; import { useMastheadStats } from './MastheadStatsContext'; import { useSettingsDirty } from './useSettingsDirty'; +import { TogglePill } from '@/components/ui/toggle-pill'; interface DataRetentionSectionProps { onDirtyChange?: (dirty: boolean) => void; @@ -31,13 +32,14 @@ function SectionSkeleton() { ); } -type DataRetentionFields = Pick; +type DataRetentionFields = Pick; const DEFAULT_DATA_RETENTION: DataRetentionFields = { 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, + prune_orphaned_scans: DEFAULT_SETTINGS.prune_orphaned_scans, }; export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProps) { @@ -76,6 +78,7 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp log_retention_days: nodeData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days, audit_retention_days: nodeData.audit_retention_days ?? DEFAULT_SETTINGS.audit_retention_days, scan_history_per_image_limit: nodeData.scan_history_per_image_limit ?? DEFAULT_SETTINGS.scan_history_per_image_limit, + prune_orphaned_scans: (nodeData.prune_orphaned_scans as '0' | '1') ?? DEFAULT_SETTINGS.prune_orphaned_scans, }; reset(safe); } catch (e) { @@ -98,6 +101,7 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp metrics_retention_hours: submitted.metrics_retention_hours, log_retention_days: submitted.log_retention_days, scan_history_per_image_limit: submitted.scan_history_per_image_limit, + prune_orphaned_scans: submitted.prune_orphaned_scans, }; // audit_retention_days is a paid-only key the backend rejects from a // Community operator. The field renders only when isPaid, so include it @@ -185,6 +189,16 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp + + onSettingChange('prune_orphaned_scans', next ? '1' : '0')} + /> + + {isPaid && ( = { log_retention_days: '30', audit_retention_days: '90', scan_history_per_image_limit: '50', + prune_orphaned_scans: '1', developer_mode: '0', health_gate_enabled: '1', health_gate_window_seconds: '90', @@ -137,6 +138,7 @@ describe('split section save payloads', () => { 'audit_retention_days', 'log_retention_days', 'metrics_retention_hours', + 'prune_orphaned_scans', 'scan_history_per_image_limit', ]); }); @@ -152,10 +154,22 @@ describe('split section save payloads', () => { expect(patchedKeys()).toEqual([ 'log_retention_days', 'metrics_retention_hours', + 'prune_orphaned_scans', 'scan_history_per_image_limit', ]); }); + it('DataRetentionSection sends prune_orphaned_scans="0" when the toggle is turned off', async () => { + render(); + const save = await screen.findByRole('button', { name: /save settings/i }); + fireEvent.click(screen.getByRole('switch')); // the only toggle: prune_orphaned_scans + fireEvent.click(save); + await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true)); + const patch = [...mockedFetch.mock.calls].reverse().find(c => c[1]?.method === 'PATCH'); + const body = JSON.parse(patch![1].body as string); + expect(body.prune_orphaned_scans).toBe('0'); + }); + it('DeveloperSection patches only developer_mode', async () => { render(); const save = await screen.findByRole('button', { name: /save settings/i }); diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index 7e0073c0..90901dd6 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -13,6 +13,7 @@ export interface PatchableSettings { audit_retention_days?: string; mesh_auto_recreate?: '0' | '1'; scan_history_per_image_limit?: string; + prune_orphaned_scans?: '0' | '1'; prune_on_update?: '0' | '1'; reclaim_hero?: '0' | '1'; snapshot_documentation?: '0' | '1'; @@ -36,6 +37,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = { audit_retention_days: '90', mesh_auto_recreate: '0', scan_history_per_image_limit: '50', + prune_orphaned_scans: '1', prune_on_update: '1', reclaim_hero: '1', snapshot_documentation: '0',