fix(security): clear cached policy evaluations when a scan policy is deleted (#758)

Vulnerability scans cache their policy verdict as a JSON blob in
vulnerability_scans.policy_evaluation. Deleting a scan policy used to
remove only the policies row and leave those blobs intact, so the
scheduler kept emitting violations and stacks remained marked as blocked
against a policy that no longer existed.

deleteScanPolicy now nulls out policy_evaluation on every scan whose
JSON references the deleted policy id, then deletes the policy row, in
one transaction.
This commit is contained in:
Anso
2026-04-24 22:28:25 -04:00
committed by GitHub
parent 4c35226719
commit 24c0a2833b
2 changed files with 159 additions and 1 deletions
@@ -0,0 +1,145 @@
/**
* Pins the cascade behavior of `DatabaseService.deleteScanPolicy`.
*
* Scans are evaluated against a policy at scan time and the result is cached
* inside `vulnerability_scans.policy_evaluation` as a JSON blob. When the
* underlying policy is deleted, those blobs must be cleared so the scheduler
* and UI stop reporting violations from a policy that no longer exists.
*/
import { describe, it, expect, beforeAll, afterAll } 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 seedScanWithEvaluation(policyId: number, digest: string): number {
const db = DatabaseService.getInstance();
const id = db.createVulnerabilityScan({
node_id: 1,
image_ref: `alpine:${digest.slice(-4)}`,
image_digest: digest,
scanned_at: Date.now(),
total_vulnerabilities: 1,
critical_count: 0,
high_count: 1,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: 'HIGH',
os_info: 'alpine 3.19',
trivy_version: '0.56.0',
scan_duration_ms: 1200,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
});
db.updateVulnerabilityScan(id, {
policy_evaluation: JSON.stringify({
policyId,
policyName: 'block-high',
maxSeverity: 'HIGH',
violated: true,
evaluatedAt: Date.now(),
}),
});
return id;
}
describe('deleteScanPolicy', () => {
it('removes the policy row and clears policy_evaluation on matching scans', () => {
const db = DatabaseService.getInstance();
const policy = db.createScanPolicy({
name: 'block-high',
node_id: null,
node_identity: '',
stack_pattern: '*',
max_severity: 'HIGH',
block_on_deploy: 1,
enabled: 1,
replicated_from_control: 0,
});
const otherPolicy = db.createScanPolicy({
name: 'block-critical',
node_id: null,
node_identity: '',
stack_pattern: '*',
max_severity: 'CRITICAL',
block_on_deploy: 1,
enabled: 1,
replicated_from_control: 0,
});
const matchingScanId = seedScanWithEvaluation(policy.id, 'sha256:matching');
const unrelatedScanId = seedScanWithEvaluation(otherPolicy.id, 'sha256:unrelated');
db.deleteScanPolicy(policy.id);
expect(db.getScanPolicy(policy.id)).toBeNull();
expect(db.getVulnerabilityScan(matchingScanId)?.policy_evaluation).toBeNull();
expect(db.getVulnerabilityScan(unrelatedScanId)?.policy_evaluation).not.toBeNull();
expect(db.getScanPolicy(otherPolicy.id)).not.toBeNull();
});
it('is a no-op for an unknown policy id', () => {
const db = DatabaseService.getInstance();
expect(() => db.deleteScanPolicy(999_999)).not.toThrow();
});
it('does not touch scans whose policy_evaluation is NULL', () => {
const db = DatabaseService.getInstance();
const policy = db.createScanPolicy({
name: 'block-medium',
node_id: null,
node_identity: '',
stack_pattern: '*',
max_severity: 'MEDIUM',
block_on_deploy: 1,
enabled: 1,
replicated_from_control: 0,
});
const unevaluatedScanId = db.createVulnerabilityScan({
node_id: 1,
image_ref: 'alpine:none',
image_digest: 'sha256:nopol',
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: 'alpine 3.19',
trivy_version: '0.56.0',
scan_duration_ms: 100,
triggered_by: 'manual',
status: 'completed',
error: null,
stack_context: null,
});
db.deleteScanPolicy(policy.id);
const row = db.getVulnerabilityScan(unevaluatedScanId);
expect(row).not.toBeNull();
expect(row?.policy_evaluation).toBeNull();
});
});
+14 -1
View File
@@ -3015,7 +3015,20 @@ export class DatabaseService {
}
public deleteScanPolicy(id: number): void {
this.db.prepare('DELETE FROM scan_policies WHERE id = ?').run(id);
// policy_evaluation is a JSON blob containing the policyId of the policy
// that produced it. Clear it on every scan referencing the deleted policy
// so cached scans no longer report stale violations after the policy is gone.
const clearEval = this.db.prepare(
`UPDATE vulnerability_scans
SET policy_evaluation = NULL
WHERE json_extract(policy_evaluation, '$.policyId') = ?`,
);
const deletePolicy = this.db.prepare('DELETE FROM scan_policies WHERE id = ?');
const txn = this.db.transaction((policyId: number) => {
clearEval.run(policyId);
deletePolicy.run(policyId);
});
txn(id);
}
/**