mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 02:12:59 +00:00
6527bc971b
Scan-policy deploy gates can now block on a known-exploited CVE (CISA KEV) and on a fixable Critical/High finding, in addition to an optional severity threshold. New policies default risk-first (KEV and fixable on, severity off); existing policies keep their severity-only behavior. CVSS stays captured for context but is never the sole basis for a block, and a finding whose exploitability cannot be confirmed is treated as risky rather than safe (incomplete scan detail fails closed on KEV/fixable inputs). The decision logic is shared between the pre-deploy gate and the informational post-scan banner via a pure helper, so the two never disagree. Block messages and the block dialog now name the conditions an image matched. Backend and frontend gates move together, the new inputs replicate across the fleet, and a blocking policy with no active input is rejected on both sides.
153 lines
5.6 KiB
TypeScript
153 lines
5.6 KiB
TypeScript
/**
|
|
* Pins the orphan-cleanup behavior of `replaceReplicatedScanPolicies`.
|
|
*
|
|
* A replica receives the full set of replicated policies on every sync push.
|
|
* Inserts always allocate fresh ids on the replica side, so any
|
|
* `vulnerability_scans.policy_evaluation` row that referenced the prior set
|
|
* by id is stale the moment the swap completes. The replace path must clear
|
|
* those orphans atomically inside the same transaction so a replica's UI
|
|
* stops showing 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 seedScan(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: 1,
|
|
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: 'CRITICAL',
|
|
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: 'mirrored',
|
|
maxSeverity: 'HIGH',
|
|
violated: true,
|
|
evaluatedAt: Date.now(),
|
|
}),
|
|
});
|
|
return id;
|
|
}
|
|
|
|
describe('replaceReplicatedScanPolicies', () => {
|
|
it('clears stale policy_evaluation when replicated rows are swapped for fresh ids', () => {
|
|
const db = DatabaseService.getInstance();
|
|
// Insert one replicated policy, simulating the receiver's state after
|
|
// the first sync push from a control.
|
|
db.replaceReplicatedScanPolicies([
|
|
{
|
|
id: 0,
|
|
name: 'mirrored-block',
|
|
node_id: null,
|
|
node_identity: '',
|
|
stack_pattern: '*',
|
|
max_severity: 'HIGH',
|
|
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
|
|
enabled: 1,
|
|
replicated_from_control: 1,
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
},
|
|
]);
|
|
const firstSet = db.getScanPolicies().filter((p) => p.replicated_from_control === 1);
|
|
expect(firstSet).toHaveLength(1);
|
|
const firstId = firstSet[0].id;
|
|
|
|
const scanId = seedScan(firstId, 'sha256:replica-staleness');
|
|
expect(db.getVulnerabilityScan(scanId)?.policy_evaluation).not.toBeNull();
|
|
|
|
// Second sync replaces the set; the new row has a different id.
|
|
db.replaceReplicatedScanPolicies([
|
|
{
|
|
id: 0,
|
|
name: 'mirrored-block',
|
|
node_id: null,
|
|
node_identity: '',
|
|
stack_pattern: '*',
|
|
max_severity: 'CRITICAL',
|
|
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
|
|
enabled: 1,
|
|
replicated_from_control: 1,
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
},
|
|
]);
|
|
const secondSet = db.getScanPolicies().filter((p) => p.replicated_from_control === 1);
|
|
expect(secondSet[0].id).not.toBe(firstId);
|
|
|
|
// Stale policy_evaluation pointing at the old id must have been
|
|
// cleared inside the same transaction as the swap.
|
|
expect(db.getVulnerabilityScan(scanId)?.policy_evaluation).toBeNull();
|
|
});
|
|
|
|
it('preserves policy_evaluation that still references a present policy', () => {
|
|
const db = DatabaseService.getInstance();
|
|
// Local-only (non-replicated) policy that survives across replication
|
|
// swaps. Its id stays valid and its policy_evaluation cache must NOT
|
|
// be cleared.
|
|
const local = db.createScanPolicy({
|
|
name: 'local-survivor',
|
|
node_id: null,
|
|
node_identity: '',
|
|
stack_pattern: '*',
|
|
max_severity: 'CRITICAL',
|
|
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
|
|
enabled: 1,
|
|
replicated_from_control: 0,
|
|
});
|
|
const scanId = seedScan(local.id, 'sha256:local-survivor');
|
|
expect(db.getVulnerabilityScan(scanId)?.policy_evaluation).not.toBeNull();
|
|
|
|
db.replaceReplicatedScanPolicies([
|
|
{
|
|
id: 0,
|
|
name: 'mirrored-other',
|
|
node_id: null,
|
|
node_identity: '',
|
|
stack_pattern: '*',
|
|
max_severity: 'HIGH',
|
|
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
|
|
enabled: 1,
|
|
replicated_from_control: 1,
|
|
created_at: Date.now(),
|
|
updated_at: Date.now(),
|
|
},
|
|
]);
|
|
|
|
expect(db.getVulnerabilityScan(scanId)?.policy_evaluation).not.toBeNull();
|
|
db.deleteScanPolicy(local.id);
|
|
});
|
|
});
|