feat: purge scan data for deleted images and stacks (#1467)

Vulnerability scan rows were never cleaned up when their image was removed
from Docker or their stack was deleted, so the Security Overview (including
the Top exploit-risk findings card) kept surfacing findings for artifacts that
no longer exist.

Scan results now reflect what is still on the host:

- Deleting a stack immediately purges its stack:<name> compose-config scan.
- A background reconciliation in the monitor janitor removes scans whose image
  is gone from the node, or whose stack folder no longer exists. It is
  fail-safe: a scan is only removed when its artifact is positively known to be
  gone, the Docker image list is read with a timeout (skipped on failure), and
  stack scans are reconciled only when the stack list is non-empty.
- An opt-out "Remove scans for deleted images and stacks" setting (on by
  default, per-node) lets operators retain scan history for removed artifacts.

Scan deletes remove child findings explicitly, since SQLite foreign-key cascade
is not enabled on the connection.
This commit is contained in:
Anso
2026-06-26 11:59:37 -04:00
committed by GitHub
parent eaf0642d88
commit 26d557a701
12 changed files with 540 additions and 2 deletions
+2
View File
@@ -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']),
+1
View File
@@ -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);