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 {
+29 -3
View File
@@ -1,13 +1,36 @@
import type { Request, Response } from 'express';
import { enforcePolicyPreDeploy, type PolicyEnforcementOptions } from '../services/PolicyEnforcement';
import { enforcePolicyPreDeploy, type PolicyEnforcementOptions, type PolicyViolation } from '../services/PolicyEnforcement';
import DockerController from '../services/DockerController';
import { DatabaseService } from '../services/DatabaseService';
import type { ScanPolicy } from '../services/DatabaseService';
import { NotificationService } from '../services/NotificationService';
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
import { LicenseService } from '../services/LicenseService';
import { effectiveTier } from '../middleware/tierGates';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { describeReason } from '../utils/policy-risk';
type BlockableAction = 'deploy' | 'update' | 'rollback';
/**
* One-line block message naming the risk inputs that matched, shared by the
* thrown-error and 409-response paths so they never drift. Falls back to a
* generic phrase when no reason was recorded (e.g. an image that could not be
* scanned).
*/
export function describePolicyBlock(
policy: ScanPolicy | undefined,
violations: PolicyViolation[],
action: BlockableAction = 'deploy',
): string {
const reasons = new Set<string>();
for (const v of violations) {
for (const r of v.reasons) reasons.add(describeReason(r));
}
const reasonText = reasons.size > 0 ? [...reasons].join(' + ') : 'scan policy conditions';
return `Policy "${policy?.name ?? 'policy'}" blocked ${action}: ${violations.length} image(s) matched ${reasonText}`;
}
// Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
// `stack:deploy` permission alone is not sufficient because the `deployer`
@@ -47,7 +70,7 @@ export async function assertPolicyGateAllows(
): Promise<void> {
const gate = await enforcePolicyPreDeploy(stackName, nodeId, options);
if (!gate.ok) {
throw new Error(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`);
throw new Error(describePolicyBlock(gate.policy, gate.violations));
}
}
@@ -64,11 +87,14 @@ export async function runPolicyGate(
const gate = await enforcePolicyPreDeploy(stackName, nodeId, buildPolicyGateOptions(req));
if (!gate.ok) {
res.status(409).json({
error: `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`,
error: describePolicyBlock(gate.policy, gate.violations),
policy: gate.policy && {
id: gate.policy.id,
name: gate.policy.name,
maxSeverity: gate.policy.max_severity,
blockOnSeverity: gate.policy.block_on_severity,
blockOnKev: gate.policy.block_on_kev,
blockOnFixable: gate.policy.block_on_fixable,
},
violations: gate.violations,
});
+15
View File
@@ -31,6 +31,7 @@ import { withTimeout, TimeoutError } from '../utils/withTimeout';
// paths cap the slow `docker system df` call at the same 8s budget (F-6).
const FLEET_DF_TIMEOUT_MS = 8_000;
import { POLICY_SEVERITIES } from '../utils/severity';
import { isNoOpBlockingPolicy } from '../utils/policy-risk';
import { sanitizeForLog, redactSensitiveText } from '../utils/safeLog';
import { formatNoTargetError } from '../utils/remoteTarget';
import { CloudBackupService } from '../services/CloudBackupService';
@@ -104,6 +105,20 @@ function validateScanPolicyRow(row: unknown): string | null {
if (r.node_identity.length > 500) return 'node_identity is too long';
if (!isIntFlag(r.block_on_deploy)) return 'block_on_deploy must be 0 or 1';
if (!isIntFlag(r.enabled)) return 'enabled must be 0 or 1';
// Risk inputs are optional for back-compat: a legacy control omits them and
// the receiver defaults such rows to severity-only. Reject only bad values.
if (r.block_on_severity !== undefined && !isIntFlag(r.block_on_severity)) return 'block_on_severity must be 0 or 1';
if (r.block_on_kev !== undefined && !isIntFlag(r.block_on_kev)) return 'block_on_kev must be 0 or 1';
if (r.block_on_fixable !== undefined && !isIntFlag(r.block_on_fixable)) return 'block_on_fixable must be 0 or 1';
// Cross-field invariant at the trust boundary: a replicated blocking policy
// must have at least one active input, or it would persist as a silent no-op
// gate. Absent fields default to severity-only (same as the receiver apply).
const sev = r.block_on_severity === undefined ? 1 : (r.block_on_severity as number);
const kev = r.block_on_kev === undefined ? 0 : (r.block_on_kev as number);
const fix = r.block_on_fixable === undefined ? 0 : (r.block_on_fixable as number);
if (isNoOpBlockingPolicy(r.block_on_deploy, sev, kev, fix)) {
return 'a blocking policy must enable at least one of severity, KEV, or fixable';
}
return null;
}
+34 -3
View File
@@ -21,6 +21,7 @@ import { isDebugEnabled } from '../utils/debug';
import { blockIfReplica } from '../middleware/fleetSyncGuards';
import { validateStackPatternForRedos } from './fleet';
import { FINDING_SEVERITIES, POLICY_SEVERITIES } from '../utils/severity';
import { isNoOpBlockingPolicy } from '../utils/policy-risk';
import { DEFAULT_POLICY_PACKS } from '../services/policy-packs';
import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic';
@@ -1018,7 +1019,7 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
if (!requireAdmin(req, res)) return;
if (!requirePaid(req, res)) return;
if (blockIfReplica(res, 'security policies')) return;
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled } = req.body ?? {};
const { name, node_id, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable } = req.body ?? {};
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ error: 'Policy name is required' }); return;
}
@@ -1032,6 +1033,16 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
res.status(400).json({ error: patternError }); return;
}
}
// Risk-first defaults when a field is omitted (KEV + fixable on, severity off),
// so an API-created policy matches the UI default rather than the severity-only
// migration default that exists only to preserve older rows.
const blockOnDeploy = block_on_deploy ? 1 : 0;
const blockOnSeverity = block_on_severity === undefined ? 0 : (block_on_severity ? 1 : 0);
const blockOnKev = block_on_kev === undefined ? 1 : (block_on_kev ? 1 : 0);
const blockOnFixable = block_on_fixable === undefined ? 1 : (block_on_fixable ? 1 : 0);
if (isNoOpBlockingPolicy(blockOnDeploy, blockOnSeverity, blockOnKev, blockOnFixable)) {
res.status(400).json({ error: 'A blocking policy must enable at least one of: severity threshold, KEV, or fixable.' }); return;
}
try {
const resolvedNodeId = node_id != null ? Number(node_id) : null;
const policy = DatabaseService.getInstance().createScanPolicy({
@@ -1040,8 +1051,11 @@ securityRouter.post('/policies', authMiddleware, (req: Request, res: Response):
node_identity: FleetSyncService.resolveIdentityForNodeId(resolvedNodeId),
stack_pattern: normalizedPattern,
max_severity,
block_on_deploy: block_on_deploy ? 1 : 0,
block_on_deploy: blockOnDeploy,
enabled: enabled === false ? 0 : 1,
block_on_severity: blockOnSeverity,
block_on_kev: blockOnKev,
block_on_fixable: blockOnFixable,
replicated_from_control: 0,
});
FleetSyncService.getInstance().pushResourceAsync('scan_policies');
@@ -1086,7 +1100,24 @@ securityRouter.put('/policies/:id', authMiddleware, (req: Request, res: Response
}
if (body.block_on_deploy !== undefined) updates.block_on_deploy = body.block_on_deploy ? 1 : 0;
if (body.enabled !== undefined) updates.enabled = body.enabled ? 1 : 0;
const policy = DatabaseService.getInstance().updateScanPolicy(id, updates);
if (body.block_on_severity !== undefined) updates.block_on_severity = body.block_on_severity ? 1 : 0;
if (body.block_on_kev !== undefined) updates.block_on_kev = body.block_on_kev ? 1 : 0;
if (body.block_on_fixable !== undefined) updates.block_on_fixable = body.block_on_fixable ? 1 : 0;
const db = DatabaseService.getInstance();
const existing = db.getScanPolicy(id);
if (!existing) {
res.status(404).json({ error: 'Policy not found' }); return;
}
// Validate the post-update state: a policy that ends up blocking must still
// have at least one active input. Merge the patch over the existing row.
const mergedBlockOnDeploy = (updates.block_on_deploy as number | undefined) ?? existing.block_on_deploy;
const mergedSeverity = (updates.block_on_severity as number | undefined) ?? existing.block_on_severity;
const mergedKev = (updates.block_on_kev as number | undefined) ?? existing.block_on_kev;
const mergedFixable = (updates.block_on_fixable as number | undefined) ?? existing.block_on_fixable;
if (isNoOpBlockingPolicy(mergedBlockOnDeploy, mergedSeverity, mergedKev, mergedFixable)) {
res.status(400).json({ error: 'A blocking policy must enable at least one of: severity threshold, KEV, or fixable.' }); return;
}
const policy = db.updateScanPolicy(id, updates);
if (!policy) {
res.status(404).json({ error: 'Policy not found' }); return;
}
+3 -3
View File
@@ -42,7 +42,7 @@ import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { sendGitSourceError } from '../utils/gitSourceHttp';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describePolicyBlock } from '../helpers/policyGate';
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { parseComposeSelection, defaultEnvPath } from '../helpers/gitSourceSelection';
@@ -391,7 +391,7 @@ async function runStackBulkOp(
return {
stackName,
ok: false,
error: `Policy "${gate.policy?.name}" blocked update`,
error: describePolicyBlock(gate.policy, gate.violations, 'update'),
code: 'policy_blocked',
};
}
@@ -849,7 +849,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
buildPolicyGateOptions(req),
);
if (!gate.ok) {
deployError = `Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`;
deployError = describePolicyBlock(gate.policy, gate.violations);
} else {
try {
await ComposeService.getInstance(req.nodeId).deployStack(stack_name);
+51 -6
View File
@@ -3,6 +3,7 @@ import path from 'path';
import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
import { evaluatePolicyRisk, policyInputs } from '../utils/policy-risk';
import type { AuditStatsInput } from './AuditAnomalyService';
import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types';
@@ -687,6 +688,12 @@ export interface ScanPolicy {
block_on_deploy: number;
enabled: number;
replicated_from_control: number;
/** Block when an image's highest non-suppressed severity meets max_severity. */
block_on_severity: number;
/** Block when any non-suppressed CVE is in the CISA known-exploited (KEV) set. */
block_on_kev: number;
/** Block when any non-suppressed Critical/High finding has a fix available. */
block_on_fixable: number;
created_at: number;
updated_at: number;
}
@@ -815,6 +822,7 @@ export class DatabaseService {
this.migrateNotificationRoutesMatchers();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
this.migrateScanPolicyRiskColumns();
this.migrateSecretMisconfigColumns();
this.migrateAgentsAndNotificationsNodeId();
this.migratePolicyEvaluationColumn();
@@ -1148,6 +1156,9 @@ export class DatabaseService {
max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
block_on_deploy INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
block_on_severity INTEGER NOT NULL DEFAULT 1,
block_on_kev INTEGER NOT NULL DEFAULT 0,
block_on_fixable INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
@@ -1754,6 +1765,17 @@ export class DatabaseService {
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
}
/**
* Risk-based deploy-gate inputs. The defaults preserve existing rows as
* severity-only (block_on_severity=1, KEV/fixable off); new policies set
* these explicitly to the risk-first posture at their create path.
*/
private migrateScanPolicyRiskColumns(): void {
this.tryAddColumn('scan_policies', 'block_on_severity', 'INTEGER NOT NULL DEFAULT 1');
this.tryAddColumn('scan_policies', 'block_on_kev', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('scan_policies', 'block_on_fixable', 'INTEGER NOT NULL DEFAULT 0');
}
private migrateSecretMisconfigColumns(): void {
this.tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0');
@@ -5048,8 +5070,8 @@ export class DatabaseService {
const now = Date.now();
const result = this.db
.prepare(
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
policy.name,
@@ -5059,6 +5081,9 @@ export class DatabaseService {
policy.max_severity,
policy.block_on_deploy,
policy.enabled,
policy.block_on_severity,
policy.block_on_kev,
policy.block_on_fixable,
policy.replicated_from_control ?? 0,
now,
now,
@@ -5081,7 +5106,8 @@ export class DatabaseService {
if (!existing) return null;
const ALLOWED_COLUMNS = new Set([
'name', 'node_id', 'node_identity', 'stack_pattern', 'max_severity',
'block_on_deploy', 'enabled', 'replicated_from_control',
'block_on_deploy', 'enabled', 'block_on_severity', 'block_on_kev',
'block_on_fixable', 'replicated_from_control',
]);
const fields: string[] = [];
const values: unknown[] = [];
@@ -5132,8 +5158,8 @@ export class DatabaseService {
const now = Date.now();
const deleteStmt = this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, block_on_severity, block_on_kev, block_on_fixable, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
);
const txn = this.db.transaction((policies: ScanPolicy[]) => {
deleteStmt.run();
@@ -5146,6 +5172,11 @@ export class DatabaseService {
p.max_severity,
p.block_on_deploy,
p.enabled,
// A legacy control omits the risk columns; default replicated rows
// to severity-only so an older control's intent is preserved.
p.block_on_severity ?? 1,
p.block_on_kev ?? 0,
p.block_on_fixable ?? 0,
p.created_at ?? now,
p.updated_at ?? now,
);
@@ -5221,11 +5252,25 @@ export class DatabaseService {
): PolicyEvaluation | null {
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
if (!policy) return null;
const inputs = policyInputs(policy);
let violated: boolean;
if (!inputs.blockOnKev && !inputs.blockOnFixable) {
// Severity-only banner from the stored aggregate: no per-finding read.
violated = inputs.blockOnSeverity && isSeverityAtLeast(scan.highest_severity, policy.max_severity);
} else {
// Best-effort banner: scores the raw findings without honoring
// suppressions or the truncation fail-closed rule. The pre-deploy gate
// is authoritative; this only drives the informational scan banner.
const findings = this.getAllVulnerabilityDetails(scan.id);
const intel = inputs.blockOnKev ? this.getCveIntel(findings.map((f) => f.vulnerability_id)) : null;
const outcome = evaluatePolicyRisk(findings, (cveId) => intel?.get(cveId)?.kev === true, inputs);
violated = outcome.reasons.length > 0;
}
return {
policyId: policy.id,
policyName: policy.name,
maxSeverity: policy.max_severity,
violated: isSeverityAtLeast(scan.highest_severity, policy.max_severity),
violated,
evaluatedAt: Date.now(),
};
}
+3
View File
@@ -565,6 +565,9 @@ export class FleetSyncService {
max_severity: p.max_severity,
block_on_deploy: p.block_on_deploy,
enabled: p.enabled,
block_on_severity: p.block_on_severity,
block_on_kev: p.block_on_kev,
block_on_fixable: p.block_on_fixable,
created_at: p.created_at,
updated_at: p.updated_at,
}));
+136 -53
View File
@@ -10,22 +10,35 @@
*/
import { ComposeService } from './ComposeService';
import { DatabaseService } from './DatabaseService';
import type { ScanPolicy, VulnSeverity, VulnerabilityScan } from './DatabaseService';
import type { ScanPolicy, VulnSeverity, VulnerabilityScan, VulnerabilityDetail } from './DatabaseService';
import { FleetSyncService } from './FleetSyncService';
import { NotificationService } from './NotificationService';
import { sanitizeForLog } from '../utils/safeLog';
import TrivyService from './TrivyService';
import { isSeverityAtLeast, severityRank } from '../utils/severity';
import { isSeverityAtLeast } from '../utils/severity';
import { applySuppressions } from '../utils/suppression-filter';
import { validateImageRef } from '../utils/image-ref';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import {
evaluatePolicyRisk,
describePolicyInputs,
policyInputs,
type PolicyBlockReason,
type PolicyRiskInputs,
} from '../utils/policy-risk';
export interface PolicyViolation {
imageRef: string;
severity: VulnSeverity;
criticalCount: number;
highCount: number;
/** Non-suppressed CVEs in the CISA known-exploited (KEV) set on this image. */
kevCount: number;
/** Non-suppressed Critical/High findings with a fix available on this image. */
fixableCount: number;
/** Which policy inputs matched (empty when the image could not be scanned). */
reasons: PolicyBlockReason[];
scanId: number;
}
@@ -78,13 +91,19 @@ export function _resetTrivyMissingNotificationStateForTests(): void {
type PreflightScan = Pick<VulnerabilityScan, 'id' | 'highest_severity' | 'critical_count' | 'high_count' | 'total_vulnerabilities'>;
interface ImageSeverityEvaluation {
interface ImageRiskEvaluation {
/** Policy inputs that matched for this image. */
reasons: PolicyBlockReason[];
/** Highest non-suppressed severity; UNKNOWN means no severity remains. */
severity: VulnSeverity;
criticalCount: number;
highCount: number;
kevCount: number;
fixableCount: number;
/** CVE IDs suppressed for this image; only populated when honoring suppressions. */
suppressedCves: string[];
/** True when the same inputs would have matched if suppressions were ignored. */
rawWouldBlock: boolean;
}
interface SuppressionPass {
@@ -93,69 +112,122 @@ interface SuppressionPass {
}
/**
* Resolve an image's effective severity for a policy decision. With
* honorSuppressions off this returns the stored scan's raw severity and counts
* (the historical behavior). With it on, the scan's findings are filtered
* through the active CVE suppressions for that image and severity + counts are
* re-derived from what remains, so an accepted CVE no longer drives a block.
* Aggregate-only evaluation. Two roles: the cheap fast path for a severity-only
* policy that does not need detail rows (`failClosed=false`), and the fallback
* when the detail rows cannot be trusted. Severity stays verifiable from the
* stored aggregate counts, so it always gates. KEV/fixability cannot be read
* from aggregates: when `failClosed` is set the active KEV/fixable inputs block
* anyway, because their absence cannot be proven without the details.
*/
function evaluateImageSeverity(
function aggregateFallback(
inputs: PolicyRiskInputs,
rawSeverity: VulnSeverity,
scan: PreflightScan,
imageRef: string,
honorSuppressions: boolean,
): ImageSeverityEvaluation {
const raw: ImageSeverityEvaluation = {
severity: scan.highest_severity ?? 'UNKNOWN',
failClosed: boolean,
): ImageRiskEvaluation {
const reasons: PolicyBlockReason[] = [];
if (inputs.blockOnSeverity && isSeverityAtLeast(rawSeverity, inputs.maxSeverity)) reasons.push('severity');
if (failClosed && inputs.blockOnKev) reasons.push('kev');
if (failClosed && inputs.blockOnFixable) reasons.push('fixable');
return {
reasons,
severity: rawSeverity,
criticalCount: scan.critical_count,
highCount: scan.high_count,
kevCount: 0,
fixableCount: 0,
suppressedCves: [],
rawWouldBlock: reasons.length > 0,
};
if (!honorSuppressions) return raw;
}
/**
* Resolve which of a policy's risk inputs (severity, KEV, fixability) an image
* matches. A severity-only policy that is not honoring suppressions keeps the
* cheap aggregate path (historical behavior). Any KEV/fixable input, or honoring
* suppressions, requires the per-finding detail rows: KEV/fixability cannot be
* read from the aggregate counts, so the details are loaded regardless of the
* honor flag. KEV/fixable are evaluated over the non-suppressed set only.
*/
function evaluateImageRisk(
scan: PreflightScan,
imageRef: string,
policy: ScanPolicy,
honorSuppressions: boolean,
): ImageRiskEvaluation {
const inputs = policyInputs(policy);
const rawSeverity = scan.highest_severity ?? 'UNKNOWN';
const needsDetails = inputs.blockOnKev || inputs.blockOnFixable || honorSuppressions;
if (!needsDetails) {
return aggregateFallback(inputs, rawSeverity, scan, false);
}
const db = DatabaseService.getInstance();
let findings;
let findings: VulnerabilityDetail[];
let suppressions;
try {
findings = db.getAllVulnerabilityDetails(scan.id);
suppressions = db.getCveSuppressions();
} catch (err) {
// A suppression-read failure must never drop severity. Fall back to the
// raw scan, which still gates: an accepted CVE stays blocking rather
// than slipping a deploy through on a transient DB error.
console.error('[Policy] Suppression re-derivation failed for %s; gating on raw scan severity:', sanitizeForLog(imageRef), sanitizeForLog(getErrorMessage(err, 'db read failed')));
return raw;
// Detail read failed: severity still gates from the aggregate, but
// KEV/fixability are unknowable. Fail closed on them, consistent with the
// truncated-details path below: a policy that explicitly opted into a
// KEV/fixable gate must not silently degrade to "allow" on a transient
// read error. The admin bypass path stays available.
console.error('[Policy] Detail read failed for %s; gating severity on aggregate, failing closed on KEV/fixable:', sanitizeForLog(imageRef), sanitizeForLog(getErrorMessage(err, 'db read failed')));
return aggregateFallback(inputs, rawSeverity, scan, true);
}
// The stored detail rows must reproduce the scan's full finding set before a
// recompute can be trusted. A cache-hit preflight scan keeps the complete
// aggregate counts but copies only the first N detail rows, so recomputing
// from a truncated set could drop an unsuppressed blocking CVE below the
// threshold. When the counts disagree (including an empty detail table for a
// non-empty scan), gate on the raw scan severity, which never drops severity.
// The stored detail rows must reproduce the scan's full finding set before
// KEV/fixability can be trusted. A cache-hit preflight scan keeps the
// complete aggregate counts but copies only the first N detail rows. When the
// counts disagree, gate severity on the raw aggregate and fail closed on any
// KEV/fixable input: absence of a known-exploited or fixable finding cannot be
// proven from a truncated set, so the unverifiable finding is treated as risky.
if (findings.length !== scan.total_vulnerabilities) {
if (scan.total_vulnerabilities > 0) {
console.warn(
'[Policy] Scan %d detail rows (%d) do not match its total (%d); gating on raw scan severity',
'[Policy] Scan %d detail rows (%d) do not match its total (%d); gating severity on raw scan, failing closed on KEV/fixable',
scan.id, findings.length, scan.total_vulnerabilities,
);
}
return raw;
return aggregateFallback(inputs, rawSeverity, scan, true);
}
const enriched = applySuppressions(findings, imageRef, suppressions);
let severity: VulnSeverity = 'UNKNOWN';
let criticalCount = 0;
let highCount = 0;
// KEV membership is the same for the full set and the non-suppressed subset,
// so resolve intel once over every CVE and reuse it for both the effective
// decision and the suppression-pass check below.
const intel = inputs.blockOnKev ? db.getCveIntel(findings.map((f) => f.vulnerability_id)) : null;
const isKev = (cveId: string): boolean => intel?.get(cveId)?.kev === true;
const suppressedCves = new Set<string>();
for (const f of enriched) {
if (f.suppressed) {
suppressedCves.add(f.vulnerability_id);
continue;
let evalSet: VulnerabilityDetail[];
if (honorSuppressions) {
evalSet = [];
for (const f of applySuppressions(findings, imageRef, suppressions)) {
if (f.suppressed) { suppressedCves.add(f.vulnerability_id); continue; }
evalSet.push(f);
}
if (severityRank(f.severity) > severityRank(severity)) severity = f.severity;
if (f.severity === 'CRITICAL') criticalCount++;
else if (f.severity === 'HIGH') highCount++;
} else {
evalSet = findings;
}
return { severity, criticalCount, highCount, suppressedCves: [...suppressedCves] };
const outcome = evaluatePolicyRisk(evalSet, isKev, inputs);
// When honoring suppressions, "would have blocked on the raw set" detects a
// pass that only succeeded because an accepted CVE was filtered out.
const rawWouldBlock = honorSuppressions
? evaluatePolicyRisk(findings, isKev, inputs).reasons.length > 0
: outcome.reasons.length > 0;
return {
reasons: outcome.reasons,
severity: outcome.highestSeverity,
criticalCount: outcome.criticalCount,
highCount: outcome.highCount,
kevCount: outcome.kevCount,
fixableCount: outcome.fixableCount,
suppressedCves: [...suppressedCves],
rawWouldBlock,
};
}
/**
@@ -186,8 +258,8 @@ function recordSuppressionPassAudit(
console.error('[Policy] Failed to record suppression-pass audit entry:', err);
}
console.warn(
'[Policy] Deploy for "%s" allowed by suppressions: %d image(s) would have met %s (policy "%s")',
sanitizeForLog(stackName), passes.length, policy.max_severity, sanitizeForLog(policy.name),
'[Policy] Deploy for "%s" allowed by suppressions: %d image(s) would have matched %s (policy "%s")',
sanitizeForLog(stackName), passes.length, describePolicyInputs(policyInputs(policy)), sanitizeForLog(policy.name),
);
}
@@ -228,6 +300,9 @@ export async function enforcePolicyPreDeploy(
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
kevCount: 0,
fixableCount: 0,
reasons: [],
scanId: 0,
}],
};
@@ -262,8 +337,8 @@ export async function enforcePolicyForImageRefs(
const debug = isDebugEnabled();
if (debug) {
console.log(
'[Policy:debug] Evaluating "%s" against policy "%s" (max=%s, images=%d, honorSuppressions=%s)',
sanitizeForLog(stackName), sanitizeForLog(policy.name), policy.max_severity, imageRefs.length, honorSuppressions,
'[Policy:debug] Evaluating "%s" against policy "%s" (inputs=%s, images=%d, honorSuppressions=%s)',
sanitizeForLog(stackName), sanitizeForLog(policy.name), describePolicyInputs(policyInputs(policy)), imageRefs.length, honorSuppressions,
);
}
@@ -277,6 +352,9 @@ export async function enforcePolicyForImageRefs(
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
kevCount: 0,
fixableCount: 0,
reasons: [],
scanId: 0,
});
}
@@ -284,26 +362,28 @@ export async function enforcePolicyForImageRefs(
}
try {
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
const evaluated = evaluateImageSeverity(scan, imageRef, honorSuppressions);
const rawSeverity = scan.highest_severity ?? 'UNKNOWN';
const evaluated = evaluateImageRisk(scan, imageRef, policy, honorSuppressions);
if (debug) {
console.log(
'[Policy:debug] %s scanned: effective=%s raw=%s vs max=%s',
sanitizeForLog(imageRef), evaluated.severity, rawSeverity, policy.max_severity,
'[Policy:debug] %s scanned: severity=%s kev=%d fixable=%d matched=[%s]',
sanitizeForLog(imageRef), evaluated.severity, evaluated.kevCount, evaluated.fixableCount, evaluated.reasons.join(','),
);
}
if (isSeverityAtLeast(evaluated.severity, policy.max_severity)) {
if (evaluated.reasons.length > 0) {
violations.push({
imageRef,
severity: evaluated.severity,
criticalCount: evaluated.criticalCount,
highCount: evaluated.highCount,
kevCount: evaluated.kevCount,
fixableCount: evaluated.fixableCount,
reasons: evaluated.reasons,
scanId: scan.id,
});
} else if (
honorSuppressions &&
evaluated.suppressedCves.length > 0 &&
isSeverityAtLeast(rawSeverity, policy.max_severity)
evaluated.rawWouldBlock
) {
suppressionPasses.push({ imageRef, cves: evaluated.suppressedCves });
}
@@ -315,6 +395,9 @@ export async function enforcePolicyForImageRefs(
severity: 'UNKNOWN',
criticalCount: 0,
highCount: 0,
kevCount: 0,
fixableCount: 0,
reasons: [],
scanId: 0,
});
}
@@ -352,8 +435,8 @@ export async function enforcePolicyForImageRefs(
}
console.warn(
'[Policy] Blocked deploy for "%s": %d image(s) exceed %s (policy "%s")',
sanitizeForLog(stackName), violations.length, policy.max_severity, sanitizeForLog(policy.name),
'[Policy] Blocked deploy for "%s": %d image(s) matched %s (policy "%s")',
sanitizeForLog(stackName), violations.length, describePolicyInputs(policyInputs(policy)), sanitizeForLog(policy.name),
);
return { ok: false, bypassed: false, policy, violations };
}
+117
View File
@@ -0,0 +1,117 @@
/**
* Pure risk-decision helper shared by the pre-deploy gate
* (`PolicyEnforcement.evaluateImageRisk`) and the informational post-scan
* evaluation (`DatabaseService.evaluateScanAgainstPolicies`). Keeping the match
* logic in one place means the two surfaces score an identical finding set the
* same way.
*
* It reports positive matches only. Callers own the finding set they pass in:
* the gate filters suppressions and applies the incomplete-details fail-closed
* rule, while the banner is best-effort (raw findings, no fail-closed), so the
* two can still differ when suppressions or a truncated scan are involved. KEV
* membership is supplied by the caller as a predicate.
*/
import type { ScanPolicy, VulnSeverity } from '../services/DatabaseService';
import { isSeverityAtLeast, severityRank } from './severity';
export type PolicyBlockReason = 'severity' | 'kev' | 'fixable';
/** The three risk inputs a policy can gate on. */
export interface PolicyRiskInputs {
blockOnSeverity: boolean;
blockOnKev: boolean;
blockOnFixable: boolean;
maxSeverity: VulnSeverity;
}
/** Project a stored policy row's 0/1 risk columns into the decision inputs. */
export function policyInputs(policy: ScanPolicy): PolicyRiskInputs {
return {
blockOnSeverity: policy.block_on_severity === 1,
blockOnKev: policy.block_on_kev === 1,
blockOnFixable: policy.block_on_fixable === 1,
maxSeverity: policy.max_severity,
};
}
/**
* True when a policy would block on deploy but no risk input is active, which
* would persist as a silent no-op gate. Inputs are 0/1 flags already resolved by
* the caller (route coercion, merged update, or replication defaults).
*/
export function isNoOpBlockingPolicy(
blockOnDeploy: number,
blockOnSeverity: number,
blockOnKev: number,
blockOnFixable: number,
): boolean {
return blockOnDeploy === 1 && blockOnSeverity === 0 && blockOnKev === 0 && blockOnFixable === 0;
}
/** Minimal finding shape the risk evaluation needs. */
export interface RiskFinding {
vulnerability_id: string;
severity: VulnSeverity;
fixed_version: string | null;
}
export interface PolicyRiskOutcome {
/** Inputs that matched, in display order (severity, kev, fixable). */
reasons: PolicyBlockReason[];
highestSeverity: VulnSeverity;
criticalCount: number;
highCount: number;
kevCount: number;
fixableCount: number;
}
/**
* Evaluate a set of non-suppressed findings against a policy's risk inputs.
* Severity uses the highest finding severity; KEV uses the supplied membership
* test; a finding is "fixable" when it is Critical/High and carries a fixed
* version (mirrors the overview's `fixableCriticalHigh` semantics).
*/
export function evaluatePolicyRisk(
findings: RiskFinding[],
isKev: (cveId: string) => boolean,
inputs: PolicyRiskInputs,
): PolicyRiskOutcome {
let highestSeverity: VulnSeverity = 'UNKNOWN';
let criticalCount = 0;
let highCount = 0;
let kevCount = 0;
let fixableCount = 0;
for (const f of findings) {
if (severityRank(f.severity) > severityRank(highestSeverity)) highestSeverity = f.severity;
if (f.severity === 'CRITICAL') criticalCount++;
else if (f.severity === 'HIGH') highCount++;
if (isKev(f.vulnerability_id)) kevCount++;
if ((f.severity === 'CRITICAL' || f.severity === 'HIGH') && f.fixed_version) fixableCount++;
}
const reasons: PolicyBlockReason[] = [];
if (inputs.blockOnSeverity && isSeverityAtLeast(highestSeverity, inputs.maxSeverity)) reasons.push('severity');
if (inputs.blockOnKev && kevCount > 0) reasons.push('kev');
if (inputs.blockOnFixable && fixableCount > 0) reasons.push('fixable');
return { reasons, highestSeverity, criticalCount, highCount, kevCount, fixableCount };
}
/** Human-readable label for a block reason; used in gate errors and audit logs. */
export function describeReason(reason: PolicyBlockReason): string {
switch (reason) {
case 'severity':
return 'severity threshold';
case 'kev':
return 'known-exploited CVE (KEV)';
case 'fixable':
return 'fixable Critical/High';
}
}
/** Compact descriptor of a policy's active inputs, for log and audit lines. */
export function describePolicyInputs(inputs: PolicyRiskInputs): string {
const parts: string[] = [];
if (inputs.blockOnSeverity) parts.push(`severity>=${inputs.maxSeverity}`);
if (inputs.blockOnKev) parts.push('KEV');
if (inputs.blockOnFixable) parts.push('fixable Critical/High');
return parts.length ? parts.join(', ') : 'no active inputs';
}