mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 02:41:14 +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:
@@ -1572,6 +1572,11 @@ export class DatabaseService {
|
||||
stmt.run('metrics_retention_hours', '24');
|
||||
stmt.run('log_retention_days', '30');
|
||||
stmt.run('scan_history_per_image_limit', '50');
|
||||
// Remove scan results when their image is gone from Docker or their
|
||||
// stack folder is deleted, so the Security Overview reflects what still
|
||||
// exists. On by default; operators who keep scan history for deleted
|
||||
// artifacts can turn it off.
|
||||
stmt.run('prune_orphaned_scans', '1');
|
||||
stmt.run('trivy_auto_update', '0');
|
||||
stmt.run('trivy_last_notified_version', '');
|
||||
stmt.run('deploy_block_honor_suppressions', '0');
|
||||
@@ -4495,6 +4500,55 @@ export class DatabaseService {
|
||||
return txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct image_refs that have at least one scan row for a node. Used by
|
||||
* the orphan-scan reconciler to compare stored scans against the artifacts
|
||||
* (images, stacks) that still exist on the host.
|
||||
*/
|
||||
public getDistinctScanImageRefs(nodeId: number): string[] {
|
||||
return (
|
||||
this.db
|
||||
.prepare(
|
||||
'SELECT DISTINCT image_ref FROM vulnerability_scans WHERE node_id = ?',
|
||||
)
|
||||
.all(nodeId) as Array<{ image_ref: string }>
|
||||
).map((r) => r.image_ref);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every scan (and its findings) for one (node_id, image_ref). Used
|
||||
* to purge scans whose artifact is gone. Children are deleted explicitly
|
||||
* because SQLite foreign-key cascade is not enabled at the connection
|
||||
* level (see pruneScanHistoryPerImage). Returns the parent rows removed;
|
||||
* idempotent (0 when nothing matches).
|
||||
*/
|
||||
public deleteScansByImageRef(nodeId: number, imageRef: string): number {
|
||||
const idSubquery =
|
||||
'SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?';
|
||||
const deleteChild = (table: string) =>
|
||||
this.db
|
||||
.prepare(`DELETE FROM ${table} WHERE scan_id IN (${idSubquery})`)
|
||||
.run(nodeId, imageRef);
|
||||
const deleteParent = this.db.prepare(
|
||||
'DELETE FROM vulnerability_scans WHERE node_id = ? AND image_ref = ?',
|
||||
);
|
||||
const txn = this.db.transaction(() => {
|
||||
deleteChild('vulnerability_details');
|
||||
deleteChild('secret_findings');
|
||||
deleteChild('misconfig_findings');
|
||||
return deleteParent.run(nodeId, imageRef).changes;
|
||||
});
|
||||
return txn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Purge the compose-config (misconfig) scans for a deleted stack, keyed by
|
||||
* the `stack:<name>` image_ref convention used by scanComposeStack.
|
||||
*/
|
||||
public deleteStackScans(nodeId: number, stackName: string): number {
|
||||
return this.deleteScansByImageRef(nodeId, `stack:${stackName}`);
|
||||
}
|
||||
|
||||
public getLatestScanForImage(
|
||||
nodeId: number,
|
||||
imageRef: string,
|
||||
|
||||
@@ -3,6 +3,8 @@ import semver from 'semver';
|
||||
import DockerController from './DockerController';
|
||||
import { DatabaseService, Node, StackAlert } from './DatabaseService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { normalizeImageRef } from './DriftDetectionService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { FleetUpdateTrackerService } from './FleetUpdateTrackerService';
|
||||
import { isValidVersion, getSenchoVersion } from './CapabilityRegistry';
|
||||
@@ -374,6 +376,17 @@ export class MonitorService {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const settings = db.getGlobalSettings();
|
||||
|
||||
// Prune scans for artifacts that no longer exist (own try/catch so a
|
||||
// reconcile failure never aborts the disk-usage check below). Lives
|
||||
// here because it is Docker-heavy and the 15-min janitor cadence is
|
||||
// the right throttle; it runs even when the disk alert is disabled.
|
||||
try {
|
||||
await this.reconcileOrphanedScans(db, settings);
|
||||
} catch (e) {
|
||||
console.error('[Monitor] Orphaned-scan reconcile failed', e);
|
||||
}
|
||||
|
||||
const janitorLimitGb = parseFloat(settings['docker_janitor_gb']);
|
||||
if (isNaN(janitorLimitGb) || janitorLimitGb <= 0) return;
|
||||
|
||||
@@ -445,6 +458,83 @@ export class MonitorService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove scans whose artifact no longer exists, so the Security Overview
|
||||
* reflects what is still on the host. Per-instance: only the local node's
|
||||
* scans are reconciled against the local Docker image list and stack dirs.
|
||||
*
|
||||
* Fail-safe by construction: a scan is only purged when we positively know
|
||||
* its artifact is gone. If the image list cannot be read, the whole pass is
|
||||
* skipped (we never delete on an unread list). Stack scans are reconciled
|
||||
* only when getStacks() returns a non-empty list, because it returns [] on
|
||||
* both an empty dir and an FS error and we cannot tell them apart.
|
||||
*
|
||||
* Image refs are compared after normalizeImageRef so equivalent forms match
|
||||
* (untagged `alpine` vs Docker's `alpine:latest`, `docker.io/library/`
|
||||
* prefixes), and both RepoTags and RepoDigests feed the live set so a
|
||||
* digest-pinned scan ref still matches a present image. Erring toward
|
||||
* "matches, keep" is the safe direction: the cost of a miss is a stale scan,
|
||||
* not a wrongly deleted one.
|
||||
*/
|
||||
private async reconcileOrphanedScans(
|
||||
db: DatabaseService,
|
||||
settings: Readonly<Record<string, string>>,
|
||||
): Promise<void> {
|
||||
// Destructive cleanup: enabled only on an explicit '1'. A missing key or
|
||||
// a read failure leaves it off, the safe default.
|
||||
if (settings['prune_orphaned_scans'] !== '1') return;
|
||||
|
||||
const nodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
|
||||
let liveImageRefs: Set<string>;
|
||||
try {
|
||||
const images = (await withTimeout(
|
||||
DockerController.getInstance(nodeId).getImages(),
|
||||
JANITOR_TIMEOUT_MS,
|
||||
'docker images (scan reconcile)',
|
||||
)) as Array<{ RepoTags?: string[]; RepoDigests?: string[] }>;
|
||||
liveImageRefs = new Set<string>();
|
||||
for (const img of images) {
|
||||
for (const ref of [...(img.RepoTags ?? []), ...(img.RepoDigests ?? [])]) {
|
||||
if (ref && !ref.startsWith('<none>')) liveImageRefs.add(normalizeImageRef(ref));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[Monitor:diag] Scan reconcile skipped: image list unavailable', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// getStacks() never throws (it swallows FS errors and returns []), so an
|
||||
// empty list is ambiguous (empty dir vs failed read); reconcileStacks
|
||||
// gates stack purging on a non-empty list to avoid deleting on a failure.
|
||||
const liveStacks = await FileSystemService.getInstance(nodeId).getStacks();
|
||||
const reconcileStacks = liveStacks.length > 0;
|
||||
const liveStackSet = new Set(liveStacks);
|
||||
|
||||
let purgedImageScans = 0;
|
||||
let purgedStackScans = 0;
|
||||
for (const ref of db.getDistinctScanImageRefs(nodeId)) {
|
||||
if (ref.startsWith('stack:')) {
|
||||
if (!reconcileStacks) continue;
|
||||
if (!liveStackSet.has(ref.slice('stack:'.length))) {
|
||||
purgedStackScans += db.deleteScansByImageRef(nodeId, ref);
|
||||
}
|
||||
} else if (!liveImageRefs.has(normalizeImageRef(ref))) {
|
||||
purgedImageScans += db.deleteScansByImageRef(nodeId, ref);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebugEnabled() && (purgedImageScans > 0 || purgedStackScans > 0)) {
|
||||
console.debug(
|
||||
`[Monitor:diag] Scan reconcile: purged ${purgedImageScans} image scan(s) `
|
||||
+ `(${liveImageRefs.size} live image refs), ${purgedStackScans} stack scan(s)`
|
||||
+ (reconcileStacks ? '' : ' (stack reconcile skipped: empty stack list)'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check GitHub/Docker Hub for a newer Sencho release and dispatch a
|
||||
* one-shot notification. Uses getLatestVersion() which wraps CacheService
|
||||
|
||||
Reference in New Issue
Block a user