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
@@ -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:<name> 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:<name> 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']);
});
});
+86 -1
View File
@@ -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<typeof makeDb>, settings: Record<string, string>) {
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: ['<none>:<none>'] },
{ 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']);
});
});
@@ -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();
@@ -0,0 +1,92 @@
/**
* DELETE /api/stacks/:stackName must purge the deleted stack's compose-config
* (misconfig) scan, keyed by the `stack:<name>` 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:<name> 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']);
});
});
+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);
+54
View File
@@ -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:<name>` 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,
+90
View File
@@ -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<Record<string, string>>,
): Promise<void> {
// 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<string>;
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<string>();
for (const img of images) {
for (const ref of [...(img.RepoTags ?? []), ...(img.RepoDigests ?? [])]) {
if (ref && !ref.startsWith('<none>')) 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
+3
View File
@@ -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.
---
@@ -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<PatchableSettings, 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit'>;
type DataRetentionFields = Pick<PatchableSettings, 'metrics_retention_hours' | 'log_retention_days' | 'audit_retention_days' | 'scan_history_per_image_limit' | 'prune_orphaned_scans'>;
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
</div>
</SettingsField>
<SettingsField
label="Remove scans for deleted images and stacks"
helper="Keep the Security Overview tied to what still exists by deleting scan results once their image is gone from this node or their stack is deleted. On by default; turn it off to retain scan history for removed images and stacks."
>
<TogglePill
checked={settings.prune_orphaned_scans === '1'}
onChange={(next) => onSettingChange('prune_orphaned_scans', next ? '1' : '0')}
/>
</SettingsField>
{isPaid && (
<SettingsField
label="Audit log"
@@ -47,6 +47,7 @@ const FULL_SETTINGS: Record<string, string> = {
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(<DataRetentionSection />);
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(<DeveloperSection />);
const save = await screen.findByRole('button', { name: /save settings/i });
@@ -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',