mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat(security): per-image scroll + retention cap in scan history (#1231)
* feat(security): per-image scroll + retention cap in scan history Long scan histories for hot images used to monopolise the Scan history sheet: a single image with dozens of scans pushed every other image off screen, and the underlying vulnerability_scans table grew without bound. Each image group's table now renders inside its own ScrollArea capped at max-h-64 (~6 rows visible) so a busy image scrolls independently while the list of images stays navigable. A new global setting scan_history_per_image_limit (default 50, min 5, max 1000) backs both a window-function query that caps the response per image_ref and a prune step that runs on the existing MonitorService cleanup tick. The response now carries cappedImageRefs + perImageLimit so the UI can render a "Capped at N · older scans pruned" hint on groups sitting at the ceiling without a second settings round-trip. Single-image deep-dive (imageRef query param) bypasses the cap so a user clicking into one image can still see its full history. The prune uses self-contained subqueries to avoid SQLITE_MAX_VARIABLE_NUMBER issues on first-run installs with large backlogs, and explicitly deletes child rows from vulnerability_details, secret_findings, and misconfig_findings inside a transaction since FK cascade is not enabled at the connection level. Settings → Developer → Data retention gains a "Scan history per image" field. * fix(security): skip searchDraft debounce on mount to stop page-reset race The searchDraft debounce useEffect fires once on initial mount with the unchanged value and, 300ms later, unconditionally calls setPage(0). When a user (or a test) paginates inside that 300ms window, the pending debounce silently undoes the page advance. CI surfaced this as a flaky 3rd fetch in the "advances offset when the user pages forward" test once the per-image cap work added enough state-update overhead to push the click past the 300ms threshold on the slower Linux jsdom run. Track searchDraft with a ref and exit the effect when the value has not actually changed, so the debounce only runs in response to real user typing.
This commit is contained in:
@@ -16,13 +16,14 @@ beforeAll(async () => {
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
function seedScan(overrides: Partial<{
|
||||
node_id: number;
|
||||
image_ref: string;
|
||||
scanned_at: number;
|
||||
status: 'completed' | 'in_progress' | 'failed';
|
||||
}> = {}): number {
|
||||
const db = DatabaseService.getInstance();
|
||||
return db.createVulnerabilityScan({
|
||||
node_id: 1,
|
||||
node_id: overrides.node_id ?? 1,
|
||||
image_ref: overrides.image_ref ?? 'alpine:3.19',
|
||||
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
|
||||
scanned_at: overrides.scanned_at ?? Date.now(),
|
||||
@@ -93,3 +94,113 @@ describe('getVulnerabilityScans filters and pagination', () => {
|
||||
expect(page1.items[0].id).not.toBe(page2.items[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVulnerabilityScans per-image cap', () => {
|
||||
it('caps rows per image_ref when no imageRef filter is set', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
||||
|
||||
for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
||||
|
||||
const result = db.getVulnerabilityScans(1, { limit: 500 });
|
||||
const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest');
|
||||
const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest');
|
||||
|
||||
expect(hotRows).toHaveLength(10);
|
||||
expect(coolRows).toHaveLength(5);
|
||||
expect(result.total).toBe(15);
|
||||
expect(result.cappedImageRefs).toEqual(['hot:latest']);
|
||||
expect(result.perImageLimit).toBe(10);
|
||||
});
|
||||
|
||||
it('bypasses the cap when imageRef targets a single image', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.updateGlobalSetting('scan_history_per_image_limit', '10');
|
||||
|
||||
for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
|
||||
const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
||||
expect(result.items).toHaveLength(30);
|
||||
expect(result.total).toBe(30);
|
||||
expect(result.cappedImageRefs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pruneScanHistoryPerImage', () => {
|
||||
it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(10);
|
||||
|
||||
const after = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
|
||||
expect(after.items).toHaveLength(50);
|
||||
const oldest = Math.min(...after.items.map((s) => s.scanned_at));
|
||||
expect(oldest).toBe(1010);
|
||||
|
||||
const cool = db.getVulnerabilityScans(1, { imageRef: 'cool:latest', limit: 500 });
|
||||
expect(cool.items).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('is a no-op when no image exceeds the cap', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(0);
|
||||
});
|
||||
|
||||
it('partitions by node_id so two nodes scanning the same image keep independent histories', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
db.getDb()
|
||||
.prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
|
||||
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
|
||||
.run(Date.now());
|
||||
|
||||
for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i });
|
||||
for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i });
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(20);
|
||||
|
||||
const node1 = db.getVulnerabilityScans(1, { imageRef: 'alpine:3.19', limit: 500 });
|
||||
const node2 = db.getVulnerabilityScans(2, { imageRef: 'alpine:3.19', limit: 500 });
|
||||
expect(node1.items).toHaveLength(50);
|
||||
expect(node2.items).toHaveLength(50);
|
||||
});
|
||||
|
||||
it('deletes child vulnerability_details rows for pruned scans', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const ids: number[] = [];
|
||||
for (let i = 0; i < 60; i++) {
|
||||
ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }));
|
||||
}
|
||||
const oldestScanId = ids[0];
|
||||
db.insertVulnerabilityDetails(oldestScanId, [{
|
||||
vulnerability_id: 'CVE-2020-0001',
|
||||
pkg_name: 'libfoo',
|
||||
installed_version: '1.0',
|
||||
fixed_version: '1.1',
|
||||
severity: 'HIGH',
|
||||
title: 'Test',
|
||||
description: null,
|
||||
primary_url: null,
|
||||
}]);
|
||||
|
||||
const beforeChildren = db.getDb()
|
||||
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
||||
.get(oldestScanId) as { cnt: number };
|
||||
expect(beforeChildren.cnt).toBe(1);
|
||||
|
||||
const deleted = db.pruneScanHistoryPerImage(50);
|
||||
expect(deleted).toBe(10);
|
||||
|
||||
const afterChildren = db.getDb()
|
||||
.prepare('SELECT COUNT(*) as cnt FROM vulnerability_details WHERE scan_id = ?')
|
||||
.get(oldestScanId) as { cnt: number };
|
||||
expect(afterChildren.cnt).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user