mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 18:05:10 +00:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user