mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user