mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +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 {
|
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