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
@@ -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();