feat(security): gate deploys on exploitation risk, not just severity (#1432)

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.
This commit is contained in:
Anso
2026-06-24 20:05:17 -04:00
committed by GitHub
parent bb4ddde35a
commit 6527bc971b
31 changed files with 1259 additions and 127 deletions
@@ -30,7 +30,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: '',
stack_pattern: null,
max_severity: 'HIGH',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -40,7 +40,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: '',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -60,7 +60,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: '',
stack_pattern: null,
max_severity: 'LOW',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -70,7 +70,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: 'local',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -89,7 +89,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: 'https://other.example',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 1,
});
@@ -99,7 +99,7 @@ describe('getMatchingPolicy tiebreaker', () => {
node_identity: 'https://me.example',
stack_pattern: null,
max_severity: 'HIGH',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 1,
});
@@ -39,7 +39,7 @@ function seedFleetWideReplicated(name: string): void {
node_identity: '',
stack_pattern: '*',
max_severity: 'HIGH',
block_on_deploy: 0,
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(),
@@ -61,7 +61,7 @@ function seedReplicaScopedReplicated(name: string, nodeIdentity: string): void {
node_identity: nodeIdentity,
stack_pattern: '*',
max_severity: 'CRITICAL',
block_on_deploy: 0,
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(),
@@ -98,7 +98,7 @@ describe('getScanPoliciesForUi', () => {
node_identity: '',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 0,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -68,7 +68,7 @@ describe('deleteScanPolicy', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'HIGH',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -78,7 +78,7 @@ describe('deleteScanPolicy', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'CRITICAL',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -107,7 +107,7 @@ describe('deleteScanPolicy', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'MEDIUM',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -74,7 +74,7 @@ describe('replaceReplicatedScanPolicies', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'HIGH',
block_on_deploy: 1,
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(),
@@ -97,7 +97,7 @@ describe('replaceReplicatedScanPolicies', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'CRITICAL',
block_on_deploy: 1,
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(),
@@ -123,7 +123,7 @@ describe('replaceReplicatedScanPolicies', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'CRITICAL',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -138,7 +138,7 @@ describe('replaceReplicatedScanPolicies', () => {
node_identity: '',
stack_pattern: '*',
max_severity: 'HIGH',
block_on_deploy: 0,
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(),
@@ -0,0 +1,129 @@
/**
* Risk-input persistence and the informational post-scan evaluation:
* createScanPolicy/updateScanPolicy round-trip the three flags, a raw legacy
* insert migrates to severity-only defaults, and evaluateScanAgainstPolicies
* keys the banner on the same KEV/fixable/severity inputs as enforcement.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { VulnerabilityScan, VulnerabilityDetail } from '../services/DatabaseService';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
const db = DatabaseService.getInstance();
db.getScanPolicies().forEach((p) => db.deleteScanPolicy(p.id));
});
const basePolicy = {
name: 'p',
node_id: null,
node_identity: '',
stack_pattern: null,
max_severity: 'HIGH' as const,
block_on_deploy: 1,
enabled: 1,
block_on_severity: 1,
block_on_kev: 0,
block_on_fixable: 0,
replicated_from_control: 0,
};
const detail = (over: Partial<VulnerabilityDetail>): Omit<VulnerabilityDetail, 'id' | 'scan_id'> => ({
vulnerability_id: 'CVE-2026-0001',
pkg_name: 'openssl',
installed_version: '1.0',
fixed_version: null,
severity: 'HIGH',
title: null,
description: null,
primary_url: null,
...over,
});
function seedScan(stackContext: string, highest: VulnerabilityScan['highest_severity'], details: Array<Omit<VulnerabilityDetail, 'id' | 'scan_id'>>): VulnerabilityScan {
const db = DatabaseService.getInstance();
const id = db.createVulnerabilityScan({
node_id: 1, image_ref: 'nginx:1.14', image_digest: null, scanned_at: Date.now(),
total_vulnerabilities: details.length, 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: highest, os_info: null, trivy_version: '0.50.0',
scan_duration_ms: null, triggered_by: 'manual', status: 'completed', error: null,
stack_context: stackContext,
});
db.insertVulnerabilityDetails(id, details);
return db.getVulnerabilityScan(id) as VulnerabilityScan;
}
describe('scan_policies risk-input persistence', () => {
it('round-trips the three input flags on create', () => {
const db = DatabaseService.getInstance();
const p = db.createScanPolicy({ ...basePolicy, block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 });
const read = db.getScanPolicy(p.id)!;
expect(read).toMatchObject({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 });
});
it('updates input flags through updateScanPolicy', () => {
const db = DatabaseService.getInstance();
const p = db.createScanPolicy({ ...basePolicy });
const updated = db.updateScanPolicy(p.id, { block_on_kev: 1, block_on_fixable: 1 })!;
expect(updated).toMatchObject({ block_on_severity: 1, block_on_kev: 1, block_on_fixable: 1 });
});
it('migrates a raw row that omits the columns to severity-only defaults', () => {
const db = DatabaseService.getInstance();
// Simulate a legacy insert that predates the risk columns.
db.transaction(() => {
(db as unknown as { db: import('better-sqlite3').Database }).db
.prepare('INSERT INTO scan_policies (name, node_identity, max_severity, block_on_deploy, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
.run('legacy', '', 'CRITICAL', 1, 1, Date.now(), Date.now());
});
const legacy = db.getScanPolicies().find((p) => p.name === 'legacy')!;
expect(legacy).toMatchObject({ block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0 });
});
});
describe('evaluateScanAgainstPolicies risk inputs', () => {
it('flags a fixable-only policy when a Critical/High finding has a fix', () => {
const db = DatabaseService.getInstance();
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_fixable: 1 });
const scan = seedScan('web', 'HIGH', [detail({ severity: 'HIGH', fixed_version: '1.1' })]);
expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(true);
});
it('does not flag a fixable-only policy when nothing is fixable', () => {
const db = DatabaseService.getInstance();
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_fixable: 1 });
const scan = seedScan('web', 'CRITICAL', [detail({ severity: 'CRITICAL', fixed_version: null })]);
expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(false);
});
it('uses the aggregate severity for a severity-only policy (no detail read needed)', () => {
const db = DatabaseService.getInstance();
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', max_severity: 'HIGH' });
const scan = seedScan('web', 'CRITICAL', []);
expect(db.evaluateScanAgainstPolicies(1, scan, '')!.violated).toBe(true);
});
it('flags a KEV-only policy when a finding is known-exploited, and not otherwise', () => {
const db = DatabaseService.getInstance();
db.createScanPolicy({ ...basePolicy, stack_pattern: 'web', block_on_severity: 0, block_on_kev: 1 });
db.replaceKev([{ cve_id: 'CVE-2026-9100', date_added: '2026-01-01' }], Date.now());
const kevScan = seedScan('web', 'LOW', [detail({ vulnerability_id: 'CVE-2026-9100', severity: 'LOW' })]);
expect(db.evaluateScanAgainstPolicies(1, kevScan, '')!.violated).toBe(true);
const cleanScan = seedScan('web', 'CRITICAL', [detail({ vulnerability_id: 'CVE-2026-9200', severity: 'CRITICAL' })]);
expect(db.evaluateScanAgainstPolicies(1, cleanScan, '')!.violated).toBe(false);
});
});
@@ -101,6 +101,9 @@ function seedPolicy(overrides: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'u
max_severity: overrides.max_severity ?? 'CRITICAL',
block_on_deploy: overrides.block_on_deploy ?? 1,
enabled: overrides.enabled ?? 1,
block_on_severity: overrides.block_on_severity ?? 1,
block_on_kev: overrides.block_on_kev ?? 0,
block_on_fixable: overrides.block_on_fixable ?? 0,
replicated_from_control: overrides.replicated_from_control ?? 0,
});
}
@@ -34,7 +34,7 @@ function makeRow(name: string, overrides: Partial<ScanPolicy> = {}): ScanPolicy
node_identity: '',
stack_pattern: null,
max_severity: 'CRITICAL',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 1,
created_at: 1,
@@ -130,7 +130,7 @@ describe('Fleet Sync apply flow (real DB round trip)', () => {
// A local policy authored on this instance before it became a replica.
db.createScanPolicy({
name: 'local-keepme', node_id: null, node_identity: '', stack_pattern: null,
max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0,
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,
});
FleetSyncService.getInstance().applyIncomingSync('scan_policies', [makeRow('first-gen')], 'https://r.example', 1_000, 'fp');
@@ -420,6 +420,33 @@ describe('POST /api/fleet/sync/:resource stack_pattern ReDoS guard', () => {
});
});
describe('POST /api/fleet/sync/scan_policies risk-input validation', () => {
const pushRows = (row: Record<string, unknown>) =>
request(app)
.post('/api/fleet/sync/scan_policies')
.set('Authorization', nodeProxyAuthHeader)
.send({ rows: [{ name: 'p', node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', enabled: 1, ...row }] });
it('rejects a non-0/1 risk-input value', async () => {
const res = await pushRows({ block_on_deploy: 1, block_on_kev: 2 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/block_on_kev must be 0 or 1/);
});
it('rejects a blocking policy with every input off', async () => {
const res = await pushRows({ block_on_deploy: 1, block_on_severity: 0, block_on_kev: 0, block_on_fixable: 0 });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/at least one/i);
});
it('passes validation for a legacy row that omits the risk-input fields', async () => {
// A field-less blocking row defaults to severity-only and must not be
// rejected by the new cross-field guard (the back-compat contract).
const res = await pushRows({ block_on_deploy: 1 });
expect(res.body.error ?? '').not.toMatch(/at least one|must be 0 or 1/i);
});
});
describe('POST /api/fleet/role/demote', () => {
it('returns 401 without auth', async () => {
const res = await request(app).post('/api/fleet/role/demote').send({ confirm: true });
@@ -202,7 +202,7 @@ describe('FleetSyncService.applyIncomingSync', () => {
const rows = [{
id: 0, name: 'from-control', node_id: null, node_identity: '',
stack_pattern: null, max_severity: 'CRITICAL' as const,
block_on_deploy: 0, enabled: 1, replicated_from_control: 1,
block_on_deploy: 0, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 1,
created_at: 1, updated_at: 1,
}];
FleetSyncService.getInstance().applyIncomingSync('scan_policies', rows, 'https://me.example');
@@ -1252,7 +1252,7 @@ describe('GitSourceService.apply', () => {
node_identity: '',
stack_pattern: 'apply-policy-block',
max_severity: 'HIGH',
block_on_deploy: 1,
block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0,
enabled: 1,
replicated_from_control: 0,
});
@@ -24,6 +24,7 @@ interface DbStub {
getGlobalSettings: ReturnType<typeof vi.fn>;
getAllVulnerabilityDetails: ReturnType<typeof vi.fn>;
getCveSuppressions: ReturnType<typeof vi.fn>;
getCveIntel: ReturnType<typeof vi.fn>;
}
interface NotificationStub {
dispatchAlert: ReturnType<typeof vi.fn>;
@@ -42,6 +43,7 @@ const dbStub: DbStub = {
getGlobalSettings: vi.fn(),
getAllVulnerabilityDetails: vi.fn(),
getCveSuppressions: vi.fn(),
getCveIntel: vi.fn(),
};
const notificationStub: NotificationStub = {
dispatchAlert: vi.fn(),
@@ -79,6 +81,9 @@ function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
max_severity: 'HIGH',
block_on_deploy: 1,
enabled: 1,
block_on_severity: 1,
block_on_kev: 0,
block_on_fixable: 0,
replicated_from_control: 0,
created_at: Date.now(),
updated_at: Date.now(),
@@ -131,6 +136,7 @@ describe('enforcePolicyPreDeploy', () => {
dbStub.getGlobalSettings.mockReturnValue({});
dbStub.getAllVulnerabilityDetails.mockReturnValue([]);
dbStub.getCveSuppressions.mockReturnValue([]);
dbStub.getCveIntel.mockReset().mockReturnValue(new Map());
notificationStub.dispatchAlert.mockReset();
_resetTrivyMissingNotificationStateForTests();
});
@@ -411,10 +417,11 @@ interface FindingStub {
vulnerability_id: string;
pkg_name: string;
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
fixed_version: string | null;
}
function mkFinding(overrides: Partial<FindingStub> = {}): FindingStub {
return { vulnerability_id: 'CVE-2026-0001', pkg_name: 'openssl', severity: 'CRITICAL', ...overrides };
return { vulnerability_id: 'CVE-2026-0001', pkg_name: 'openssl', severity: 'CRITICAL', fixed_version: null, ...overrides };
}
function mkSuppression(overrides: Record<string, unknown> = {}) {
@@ -442,6 +449,7 @@ describe('enforcePolicyForImageRefs with suppression-aware blocking', () => {
dbStub.getGlobalSettings.mockReset().mockReturnValue({ deploy_block_honor_suppressions: '1' });
dbStub.getAllVulnerabilityDetails.mockReset().mockReturnValue([]);
dbStub.getCveSuppressions.mockReset().mockReturnValue([]);
dbStub.getCveIntel.mockReset().mockReturnValue(new Map());
notificationStub.dispatchAlert.mockReset();
_resetTrivyMissingNotificationStateForTests();
});
@@ -626,3 +634,153 @@ describe('enforcePolicyForImageRefs with suppression-aware blocking', () => {
expect(dbStub.insertAuditLog).not.toHaveBeenCalled();
});
});
describe('enforcePolicyForImageRefs - risk-based inputs (KEV / fixable / optional severity)', () => {
beforeEach(() => {
trivyStub.isTrivyAvailable.mockReset().mockReturnValue(true);
trivyStub.scanImagePreflight.mockReset();
composeStub.listStackImages.mockReset();
dbStub.getMatchingPolicy.mockReset();
dbStub.insertAuditLog.mockReset();
// Suppressions off by default; KEV/fixable still force a detail read.
dbStub.getGlobalSettings.mockReset().mockReturnValue({});
dbStub.getAllVulnerabilityDetails.mockReset().mockReturnValue([]);
dbStub.getCveSuppressions.mockReset().mockReturnValue([]);
dbStub.getCveIntel.mockReset().mockReturnValue(new Map());
notificationStub.dispatchAlert.mockReset();
_resetTrivyMissingNotificationStateForTests();
});
const kevIntel = (cve: string) => new Map([[cve, { kev: true, kevDate: null, epssScore: null, epssPercentile: null }]]);
it('blocks a KEV-only policy when a known-exploited CVE is present, even at LOW severity', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 40, highest_severity: 'LOW', total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-1000', severity: 'LOW' })]);
dbStub.getCveIntel.mockReturnValue(kevIntel('CVE-2026-1000'));
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(false);
expect(result.violations[0]).toMatchObject({ reasons: ['kev'], kevCount: 1, scanId: 40 });
});
it('allows a KEV-only policy when no finding is known-exploited', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 41, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-2000', severity: 'CRITICAL' })]);
dbStub.getCveIntel.mockReturnValue(new Map());
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
});
it('blocks a fixable-only policy when a Critical/High finding has a fix available', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 0, block_on_fixable: 1 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 42, highest_severity: 'HIGH', high_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-3000', severity: 'HIGH', fixed_version: '1.2.3' })]);
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(false);
expect(result.violations[0]).toMatchObject({ reasons: ['fixable'], fixableCount: 1, scanId: 42 });
});
it('allows a fixable-only policy when the Critical/High findings have no fix', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 0, block_on_fixable: 1 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 43, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-3001', severity: 'CRITICAL', fixed_version: null })]);
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
});
it('does not gate on severity when the severity input is off (KEV/fixable only)', async () => {
// A CRITICAL image with no KEV and no fix must pass a risk-first policy that
// leaves severity off: severity alone is no longer a blocking basis.
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1, max_severity: 'LOW' }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 44, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-4000', severity: 'CRITICAL', fixed_version: null })]);
dbStub.getCveIntel.mockReturnValue(new Map());
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
});
it('fails closed on a KEV input when detail rows are truncated (assume it is automatable)', async () => {
// Aggregate reports 1500 findings but only one detail row is present; KEV
// membership cannot be proven absent, so the gate must block.
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 45, highest_severity: 'LOW', total_vulnerabilities: 1500 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-5000', severity: 'LOW' })]);
dbStub.getCveIntel.mockReturnValue(new Map());
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(false);
expect(result.violations[0].reasons).toContain('kev');
});
it('fails closed on a KEV input when the detail read throws (transient DB error)', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 47, highest_severity: 'LOW', total_vulnerabilities: 3 }));
dbStub.getAllVulnerabilityDetails.mockImplementation(() => { throw new Error('database is locked'); });
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(false);
expect(result.violations[0].reasons).toContain('kev');
});
it('still allows when only the severity input is set and the detail read throws', async () => {
// Severity stays verifiable from the aggregate; a below-threshold image passes
// even though the (honor-suppressions) detail read failed.
dbStub.getGlobalSettings.mockReturnValue({ deploy_block_honor_suppressions: '1' });
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, max_severity: 'HIGH' }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 48, highest_severity: 'LOW', total_vulnerabilities: 2 }));
dbStub.getAllVulnerabilityDetails.mockImplementation(() => { throw new Error('database is locked'); });
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
});
it('does not audit a suppression pass when the suppressed finding is not KEV (KEV policy)', async () => {
// A non-KEV finding is suppressed under a KEV-only policy. The raw set carries
// no KEV finding, so there is nothing the suppression "saved": no audit.
dbStub.getGlobalSettings.mockReturnValue({ deploy_block_honor_suppressions: '1' });
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 49, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-7000', severity: 'CRITICAL' })]);
dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-7000' })]);
dbStub.getCveIntel.mockReturnValue(new Map()); // CVE-2026-7000 is not KEV
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(true);
expect(dbStub.insertAuditLog).not.toHaveBeenCalled();
});
it('allows and audits when an honored suppression covers the sole KEV finding', async () => {
dbStub.getGlobalSettings.mockReturnValue({ deploy_block_honor_suppressions: '1' });
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 0 }));
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 46, highest_severity: 'CRITICAL', critical_count: 1, total_vulnerabilities: 1 }));
dbStub.getAllVulnerabilityDetails.mockReturnValue([mkFinding({ vulnerability_id: 'CVE-2026-6000', severity: 'CRITICAL' })]);
dbStub.getCveSuppressions.mockReturnValue([mkSuppression({ cve_id: 'CVE-2026-6000' })]);
dbStub.getCveIntel.mockReturnValue(kevIntel('CVE-2026-6000'));
const result = await enforcePolicyForImageRefs('web', 1, ['nginx:1.14'], { bypass: false, actor: 'admin' });
expect(result.ok).toBe(true);
expect(result.violations).toEqual([]);
expect(dbStub.insertAuditLog).toHaveBeenCalledTimes(1);
expect(dbStub.insertAuditLog.mock.calls[0][0].summary).toContain('policy.suppression_pass');
});
});
@@ -0,0 +1,46 @@
/**
* The shared block message must name the inputs that actually matched, so a
* KEV-driven block never reads as a severity-threshold block.
*/
import { describe, it, expect } from 'vitest';
import { describePolicyBlock } from '../helpers/policyGate';
import type { PolicyViolation } from '../services/PolicyEnforcement';
import type { ScanPolicy } from '../services/DatabaseService';
const policy = { name: 'prod-gate', max_severity: 'CRITICAL' } as ScanPolicy;
const violation = (over: Partial<PolicyViolation>): PolicyViolation => ({
imageRef: 'nginx:1.14', severity: 'LOW', criticalCount: 0, highCount: 0,
kevCount: 0, fixableCount: 0, reasons: [], scanId: 1, ...over,
});
describe('describePolicyBlock', () => {
it('names KEV without mentioning a severity threshold', () => {
const msg = describePolicyBlock(policy, [violation({ kevCount: 1, reasons: ['kev'] })]);
expect(msg).toContain('known-exploited');
expect(msg).not.toContain('CRITICAL');
});
it('joins multiple distinct reasons across violations', () => {
const msg = describePolicyBlock(policy, [
violation({ reasons: ['kev'] }),
violation({ imageRef: 'redis:7', reasons: ['fixable'] }),
]);
expect(msg).toContain('known-exploited');
expect(msg).toContain('fixable');
});
it('de-duplicates a reason shared by multiple violations', () => {
const msg = describePolicyBlock(policy, [
violation({ reasons: ['kev'] }),
violation({ imageRef: 'redis:7', reasons: ['kev'] }),
]);
expect(msg.match(/known-exploited/g)).toHaveLength(1);
expect(msg).toContain('2 image(s)');
});
it('uses the supplied action verb and a generic phrase when no reason is set', () => {
const msg = describePolicyBlock(policy, [violation({})], 'update');
expect(msg).toContain('blocked update');
expect(msg).toContain('scan policy conditions');
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Pins the pure risk-decision helper shared by the pre-deploy gate and the
* informational post-scan evaluation. KEV membership is supplied as a test
* predicate, so these cases stay free of DB and intel-cache setup.
*/
import { describe, it, expect } from 'vitest';
import {
evaluatePolicyRisk,
describeReason,
describePolicyInputs,
type PolicyRiskInputs,
type RiskFinding,
} from '../utils/policy-risk';
const noKev = () => false;
const finding = (over: Partial<RiskFinding> = {}): RiskFinding => ({
vulnerability_id: 'CVE-2026-0001',
severity: 'HIGH',
fixed_version: null,
...over,
});
const inputs = (over: Partial<PolicyRiskInputs> = {}): PolicyRiskInputs => ({
blockOnSeverity: false,
blockOnKev: false,
blockOnFixable: false,
maxSeverity: 'HIGH',
...over,
});
describe('evaluatePolicyRisk', () => {
it('matches severity only when the highest finding meets the threshold', () => {
const high = evaluatePolicyRisk([finding({ severity: 'HIGH' })], noKev, inputs({ blockOnSeverity: true, maxSeverity: 'HIGH' }));
expect(high.reasons).toEqual(['severity']);
const low = evaluatePolicyRisk([finding({ severity: 'LOW' })], noKev, inputs({ blockOnSeverity: true, maxSeverity: 'HIGH' }));
expect(low.reasons).toEqual([]);
});
it('matches KEV when a finding is known-exploited, regardless of severity', () => {
const isKev = (id: string) => id === 'CVE-2026-9999';
const out = evaluatePolicyRisk(
[finding({ vulnerability_id: 'CVE-2026-9999', severity: 'LOW' })],
isKev,
inputs({ blockOnKev: true }),
);
expect(out.reasons).toEqual(['kev']);
expect(out.kevCount).toBe(1);
});
it('counts a Critical/High finding with a fix as fixable, but not one without', () => {
const fixable = evaluatePolicyRisk([finding({ severity: 'CRITICAL', fixed_version: '1.2.3' })], noKev, inputs({ blockOnFixable: true }));
expect(fixable.reasons).toEqual(['fixable']);
expect(fixable.fixableCount).toBe(1);
const noFix = evaluatePolicyRisk([finding({ severity: 'CRITICAL', fixed_version: null })], noKev, inputs({ blockOnFixable: true }));
expect(noFix.reasons).toEqual([]);
const lowFix = evaluatePolicyRisk([finding({ severity: 'MEDIUM', fixed_version: '1.0' })], noKev, inputs({ blockOnFixable: true }));
expect(lowFix.reasons).toEqual([]);
});
it('reports every input that matches, in display order', () => {
const isKev = () => true;
const out = evaluatePolicyRisk(
[finding({ severity: 'CRITICAL', fixed_version: '2.0' })],
isKev,
inputs({ blockOnSeverity: true, blockOnKev: true, blockOnFixable: true, maxSeverity: 'HIGH' }),
);
expect(out.reasons).toEqual(['severity', 'kev', 'fixable']);
});
it('returns no reasons when no input is enabled', () => {
const out = evaluatePolicyRisk([finding({ severity: 'CRITICAL' })], () => true, inputs());
expect(out.reasons).toEqual([]);
});
});
describe('describeReason / describePolicyInputs', () => {
it('labels each reason', () => {
expect(describeReason('severity')).toContain('severity');
expect(describeReason('kev')).toContain('KEV');
expect(describeReason('fixable')).toContain('fixable');
});
it('lists active inputs and notes when none are active', () => {
expect(describePolicyInputs(inputs({ blockOnSeverity: true, maxSeverity: 'HIGH' }))).toContain('severity>=HIGH');
expect(describePolicyInputs(inputs({ blockOnKev: true, blockOnFixable: true }))).toBe('KEV, fixable Critical/High');
expect(describePolicyInputs(inputs())).toBe('no active inputs');
});
});
@@ -0,0 +1,125 @@
/**
* Route-level tests for /api/security/policies risk inputs: the risk-first POST
* defaults, the "a blocking policy needs an active input" guard (POST and PUT),
* and round-tripping the three input flags.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let adminAuthHeader: string;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let FleetSyncService: typeof import('../services/FleetSyncService').FleetSyncService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ app } = await import('../index'));
({ LicenseService } = await import('../services/LicenseService'));
({ DatabaseService } = await import('../services/DatabaseService'));
({ FleetSyncService } = await import('../services/FleetSyncService'));
const adminToken = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
adminAuthHeader = `Bearer ${adminToken}`;
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
const db = DatabaseService.getInstance();
db.getScanPolicies().forEach((p) => db.deleteScanPolicy(p.id));
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(FleetSyncService, 'getRole').mockReturnValue('control');
vi.spyOn(FleetSyncService, 'getSelfIdentity').mockReturnValue('');
vi.spyOn(FleetSyncService.getInstance(), 'pushResourceAsync').mockImplementation(() => {});
vi.spyOn(FleetSyncService, 'resolveIdentityForNodeId').mockReturnValue('');
});
const post = (body: Record<string, unknown>) =>
request(app).post('/api/security/policies').set('Authorization', adminAuthHeader).send(body);
describe('POST /api/security/policies risk inputs', () => {
it('applies risk-first defaults when the input flags are omitted', async () => {
const res = await post({ name: 'risk-first', max_severity: 'CRITICAL', block_on_deploy: 1 });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 });
});
it('persists explicit input flags', async () => {
const res = await post({
name: 'sev-only', max_severity: 'HIGH', block_on_deploy: 1,
block_on_severity: true, block_on_kev: false, block_on_fixable: false,
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0 });
});
it('rejects a blocking policy with no active input', async () => {
const res = await post({
name: 'empty-gate', max_severity: 'CRITICAL', block_on_deploy: 1,
block_on_severity: false, block_on_kev: false, block_on_fixable: false,
});
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/at least one/i);
});
it('allows an evaluate-only policy with no active input', async () => {
const res = await post({
name: 'evaluate-only', max_severity: 'CRITICAL', block_on_deploy: 0,
block_on_severity: false, block_on_kev: false, block_on_fixable: false,
});
expect(res.status).toBe(201);
});
});
describe('PUT /api/security/policies/:id risk inputs', () => {
it('rejects turning off the last input on a blocking policy', async () => {
const created = await post({
name: 'kev-block', max_severity: 'CRITICAL', block_on_deploy: 1,
block_on_severity: false, block_on_kev: true, block_on_fixable: false,
});
const id = created.body.id as number;
const res = await request(app)
.put(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader)
.send({ block_on_kev: false });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/at least one/i);
});
it('rejects enabling block-on-deploy on an evaluate-only policy with no active input', async () => {
const created = await post({
name: 'evaluate-only', max_severity: 'CRITICAL', block_on_deploy: 0,
block_on_severity: false, block_on_kev: false, block_on_fixable: false,
});
const id = created.body.id as number;
const res = await request(app)
.put(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader)
.send({ block_on_deploy: true });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/at least one/i);
});
it('updates an input flag when at least one stays active', async () => {
const created = await post({
name: 'both', max_severity: 'CRITICAL', block_on_deploy: 1,
block_on_severity: false, block_on_kev: true, block_on_fixable: true,
});
const id = created.body.id as number;
const res = await request(app)
.put(`/api/security/policies/${id}`)
.set('Authorization', adminAuthHeader)
.send({ block_on_fixable: false });
expect(res.status).toBe(200);
expect(res.body).toMatchObject({ block_on_kev: 1, block_on_fixable: 0 });
});
});
@@ -108,9 +108,9 @@ describe('GET /api/security/overview', () => {
seedScan({ node_id: 2, image_ref: 'other:1', scanned_at: now, critical: 99 });
// One fleet-wide and one this-node block policy count; an other-node one does not.
db().createScanPolicy({ name: 'fw', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
db().createScanPolicy({ name: 'n1', node_id: 1, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
db().createScanPolicy({ name: 'n2', node_id: 2, node_identity: '', stack_pattern: null, max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, replicated_from_control: 0 });
db().createScanPolicy({ name: 'fw', node_id: null, node_identity: '', stack_pattern: null, 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 });
db().createScanPolicy({ name: 'n1', node_id: 1, node_identity: '', stack_pattern: null, 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 });
db().createScanPolicy({ name: 'n2', node_id: 2, node_identity: '', stack_pattern: null, 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 res = await request(app).get('/api/security/overview').set('Cookie', adminCookie);
expect(res.status).toBe(200);
@@ -284,8 +284,8 @@ describe('POST /api/stacks/bulk execution', () => {
const policySpy = vi.spyOn(policyMod, 'enforcePolicyPreDeploy').mockResolvedValue({
ok: false,
bypassed: false,
policy: { id: 1, name: 'block-criticals', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'HIGH', block_on_deploy: 1, enabled: 1, replicated_from_control: 0, created_at: Date.now(), updated_at: Date.now() },
violations: [{ imageRef: 'nginx:latest', severity: 'CRITICAL', criticalCount: 3, highCount: 0, scanId: 1 }],
policy: { id: 1, name: 'block-criticals', node_id: null, node_identity: '', stack_pattern: null, max_severity: 'HIGH', block_on_deploy: 1, block_on_severity: 1, block_on_kev: 0, block_on_fixable: 0, enabled: 1, replicated_from_control: 0, created_at: Date.now(), updated_at: Date.now() },
violations: [{ imageRef: 'nginx:latest', severity: 'CRITICAL', criticalCount: 3, highCount: 0, kevCount: 0, fixableCount: 0, reasons: ['severity'], scanId: 1 }],
});
mockUpdateStack.mockResolvedValue(undefined);
try {