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:
Anso
2026-05-25 23:44:31 -04:00
committed by GitHub
parent 80499ee18d
commit 42e8d3a78c
8 changed files with 352 additions and 85 deletions
+90 -6
View File
@@ -1244,6 +1244,7 @@ export class DatabaseService {
stmt.run('developer_mode', '0');
stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30');
stmt.run('scan_history_per_image_limit', '50');
stmt.run('trivy_auto_update', '0');
stmt.run('trivy_last_notified_version', '');
stmt.run('mesh_auto_recreate', '0');
@@ -3402,7 +3403,7 @@ export class DatabaseService {
public getVulnerabilityScans(
nodeId: number,
opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {},
): { items: VulnerabilityScan[]; total: number } {
): { items: VulnerabilityScan[]; total: number; cappedImageRefs: string[]; perImageLimit: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
const offset = Math.max(0, opts.offset ?? 0);
const where = ['node_id = ?'];
@@ -3420,17 +3421,100 @@ export class DatabaseService {
params.push(opts.status);
}
const whereSql = where.join(' AND ');
// Grouped (history) view caps rows per image_ref so a hot image
// cannot drown out the others. Single-image deep-dive (imageRef set)
// bypasses the cap so users can drill past it.
const applyPerImageCap = !opts.imageRef;
const parsedLimit = parseInt(this.getGlobalSettings()['scan_history_per_image_limit'] ?? '50', 10);
const perImageLimit = parsedLimit > 0 ? parsedLimit : 50;
if (!applyPerImageCap) {
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
).cnt;
const items = this.db
.prepare(
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
return { items, total, cappedImageRefs: [], perImageLimit };
}
const rankedCte = `WITH ranked AS (
SELECT *, ROW_NUMBER() OVER (PARTITION BY image_ref ORDER BY scanned_at DESC) AS rn
FROM vulnerability_scans
WHERE ${whereSql}
)`;
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
.prepare(`${rankedCte} SELECT COUNT(*) as cnt FROM ranked WHERE rn <= ?`)
.get(...(params as never[]), perImageLimit) as { cnt: number }
).cnt;
const items = this.db
.prepare(
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
`${rankedCte} SELECT id, node_id, image_ref, image_digest, scanned_at,
total_vulnerabilities, critical_count, high_count, medium_count, low_count,
unknown_count, fixable_count, secret_count, misconfig_count, scanners_used,
highest_severity, os_info, trivy_version, scan_duration_ms, triggered_by,
status, error, stack_context, policy_evaluation
FROM ranked WHERE rn <= ?
ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
return { items, total };
.all(...(params as never[]), perImageLimit, limit, offset) as VulnerabilityScan[];
// Identify which image_refs sit at or above the cap so the UI can
// flag them. `>=` (not `>`) is intentional: the daily prune keeps
// each image at exactly perImageLimit rows, so by the time a user
// opens the history sheet the underlying count rarely exceeds the
// cap. Flagging at-cap groups still tells the truth (older scans
// have been or will be pruned at this image's next scan).
const cappedRows = this.db
.prepare(
`SELECT image_ref FROM vulnerability_scans
WHERE ${whereSql}
GROUP BY image_ref HAVING COUNT(*) >= ?`,
)
.all(...(params as never[]), perImageLimit) as Array<{ image_ref: string }>;
const cappedImageRefs = cappedRows.map((r) => r.image_ref);
return { items, total, cappedImageRefs, perImageLimit };
}
/**
* Per-image scan history pruner. For each (node_id, image_ref), keep the
* newest N scans (ordered by scanned_at DESC) and delete older rows along
* with their child findings. SQLite foreign-key cascade is not enabled
* at the connection level here, so children are deleted explicitly. The
* subquery is self-contained so we don't bind one parameter per ID. A
* first-run backlog of thousands of stale scans would otherwise blow
* past SQLITE_MAX_VARIABLE_NUMBER.
*/
public pruneScanHistoryPerImage(perImageLimit: number): number {
const limit = Math.max(1, Math.floor(perImageLimit));
const overflowSubquery = `SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY node_id, image_ref ORDER BY scanned_at DESC
) AS rn
FROM vulnerability_scans
) WHERE rn > ?`;
const deleteChild = (table: string) =>
this.db
.prepare(`DELETE FROM ${table} WHERE scan_id IN (${overflowSubquery})`)
.run(limit);
const deleteParent = this.db.prepare(
`DELETE FROM vulnerability_scans WHERE id IN (${overflowSubquery})`,
);
const txn = this.db.transaction(() => {
deleteChild('vulnerability_details');
deleteChild('secret_findings');
deleteChild('misconfig_findings');
return deleteParent.run(limit).changes;
});
return txn();
}
public getLatestScanForImage(
+3 -1
View File
@@ -551,7 +551,9 @@ export class MonitorService {
const notifSummary = db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
const auditRetentionDays = parseInt(settings['audit_retention_days'] || '90', 10);
db.cleanupOldAuditLogs(isNaN(auditRetentionDays) ? 90 : auditRetentionDays);
if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d`);
const scanPerImage = parseInt(settings['scan_history_per_image_limit'] || '50', 10);
const scanPruned = db.pruneScanHistoryPerImage(isNaN(scanPerImage) ? 50 : scanPerImage);
if (isDebugEnabled()) console.log(`[Monitor:diag] Cleanup: metrics ${isNaN(retentionHours) ? 24 : retentionHours}h, notifications ${isNaN(retentionDays) ? 30 : retentionDays}d (ttl=${notifSummary.ttl} perStack=${notifSummary.perStack} perNode=${notifSummary.perNode}), audit ${isNaN(auditRetentionDays) ? 90 : auditRetentionDays}d, scans pruned ${scanPruned}`);
} catch (e) {
console.error('MonitorService: failed to cleanup old data', e);
}