mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
26d557a701
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.
93 lines
3.5 KiB
TypeScript
93 lines
3.5 KiB
TypeScript
/**
|
|
* 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']);
|
|
});
|
|
});
|