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
@@ -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',