mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user