diff --git a/backend/src/__tests__/database-matching-policy.test.ts b/backend/src/__tests__/database-matching-policy.test.ts index 3283a001..51741c40 100644 --- a/backend/src/__tests__/database-matching-policy.test.ts +++ b/backend/src/__tests__/database-matching-policy.test.ts @@ -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, }); diff --git a/backend/src/__tests__/database-policies-for-ui.test.ts b/backend/src/__tests__/database-policies-for-ui.test.ts index eeaf327b..cce39c0f 100644 --- a/backend/src/__tests__/database-policies-for-ui.test.ts +++ b/backend/src/__tests__/database-policies-for-ui.test.ts @@ -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, }); diff --git a/backend/src/__tests__/database-policy-delete.test.ts b/backend/src/__tests__/database-policy-delete.test.ts index 7e9c66de..769fcd67 100644 --- a/backend/src/__tests__/database-policy-delete.test.ts +++ b/backend/src/__tests__/database-policy-delete.test.ts @@ -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, }); diff --git a/backend/src/__tests__/database-replicated-policies.test.ts b/backend/src/__tests__/database-replicated-policies.test.ts index 2206a5f0..8c3e9dc3 100644 --- a/backend/src/__tests__/database-replicated-policies.test.ts +++ b/backend/src/__tests__/database-replicated-policies.test.ts @@ -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(), diff --git a/backend/src/__tests__/database-scan-policy-risk.test.ts b/backend/src/__tests__/database-scan-policy-risk.test.ts new file mode 100644 index 00000000..1c581eb6 --- /dev/null +++ b/backend/src/__tests__/database-scan-policy-risk.test.ts @@ -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): Omit => ({ + 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>): 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); + }); +}); diff --git a/backend/src/__tests__/database-security-overview-helpers.test.ts b/backend/src/__tests__/database-security-overview-helpers.test.ts index 45eb9c9e..7f967a0b 100644 --- a/backend/src/__tests__/database-security-overview-helpers.test.ts +++ b/backend/src/__tests__/database-security-overview-helpers.test.ts @@ -101,6 +101,9 @@ function seedPolicy(overrides: Partial = {}): 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'); diff --git a/backend/src/__tests__/fleet-sync-routes.test.ts b/backend/src/__tests__/fleet-sync-routes.test.ts index cc6e25a3..c86b2a21 100644 --- a/backend/src/__tests__/fleet-sync-routes.test.ts +++ b/backend/src/__tests__/fleet-sync-routes.test.ts @@ -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) => + 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 }); diff --git a/backend/src/__tests__/fleet-sync-service.test.ts b/backend/src/__tests__/fleet-sync-service.test.ts index 3fc30ac1..f30430d5 100644 --- a/backend/src/__tests__/fleet-sync-service.test.ts +++ b/backend/src/__tests__/fleet-sync-service.test.ts @@ -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'); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 6d17203d..09fc0f30 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -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, }); diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index a72c6e6e..c5c7f6ea 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -24,6 +24,7 @@ interface DbStub { getGlobalSettings: ReturnType; getAllVulnerabilityDetails: ReturnType; getCveSuppressions: ReturnType; + getCveIntel: ReturnType; } interface NotificationStub { dispatchAlert: ReturnType; @@ -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 { 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 { - 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 = {}) { @@ -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'); + }); +}); diff --git a/backend/src/__tests__/policy-gate-describe.test.ts b/backend/src/__tests__/policy-gate-describe.test.ts new file mode 100644 index 00000000..56279e22 --- /dev/null +++ b/backend/src/__tests__/policy-gate-describe.test.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/backend/src/__tests__/policy-risk.test.ts b/backend/src/__tests__/policy-risk.test.ts new file mode 100644 index 00000000..8d7f00e4 --- /dev/null +++ b/backend/src/__tests__/policy-risk.test.ts @@ -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 => ({ + vulnerability_id: 'CVE-2026-0001', + severity: 'HIGH', + fixed_version: null, + ...over, +}); + +const inputs = (over: Partial = {}): 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'); + }); +}); diff --git a/backend/src/__tests__/scan-policies-route.test.ts b/backend/src/__tests__/scan-policies-route.test.ts new file mode 100644 index 00000000..a0ee162f --- /dev/null +++ b/backend/src/__tests__/scan-policies-route.test.ts @@ -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) => + 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 }); + }); +}); diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index a34e524c..872cb7b3 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -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); diff --git a/backend/src/__tests__/stack-bulk-routes.test.ts b/backend/src/__tests__/stack-bulk-routes.test.ts index eb1018f2..80d9a3e3 100644 --- a/backend/src/__tests__/stack-bulk-routes.test.ts +++ b/backend/src/__tests__/stack-bulk-routes.test.ts @@ -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 { diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts index 232a6a79..41f2be0e 100644 --- a/backend/src/helpers/policyGate.ts +++ b/backend/src/helpers/policyGate.ts @@ -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(); + 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 { 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, }); diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 17f1e02c..35caefc6 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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; } diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index d1ad3ba6..9267e65c 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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; } diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 5e25e298..b2a86b7d 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 908c214f..43d8256d 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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(), }; } diff --git a/backend/src/services/FleetSyncService.ts b/backend/src/services/FleetSyncService.ts index 26789f3d..27d9180d 100644 --- a/backend/src/services/FleetSyncService.ts +++ b/backend/src/services/FleetSyncService.ts @@ -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, })); diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 1d53253c..bd07b124 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -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; -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(); - 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 }; } diff --git a/backend/src/utils/policy-risk.ts b/backend/src/utils/policy-risk.ts new file mode 100644 index 00000000..bb3e50c1 --- /dev/null +++ b/backend/src/utils/policy-risk.ts @@ -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'; +} diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx index 258ba00c..5e0233dc 100644 --- a/docs/api-reference/security.mdx +++ b/docs/api-reference/security.mdx @@ -9,7 +9,7 @@ All endpoints require [Bearer token authentication](/api-reference/overview#auth ## Scan policies -Scan policies define severity thresholds that Sencho evaluates on every post-deploy and scheduled scan. When a policy has `block_on_deploy=1`, a matching deploy is rejected at the pre-flight stage with an HTTP 409 response. See [Deploy Enforcement](/features/deploy-enforcement) for the full flow. +Scan policies define risk conditions that Sencho evaluates on every post-deploy and scheduled scan: a known-exploited CVE (CISA KEV), a fixable Critical/High finding, and an optional severity threshold. When a policy has `block_on_deploy=1`, a deploy whose images match any enabled condition is rejected at the pre-flight stage with an HTTP 409 response. See [Deploy Enforcement](/features/deploy-enforcement) for the full flow. Writes are admin-only and rejected on replica nodes (policies are managed on the control instance and replicate fleet-wide). @@ -37,6 +37,9 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \ "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": 1745107200000, "updated_at": 1745107200000 @@ -54,11 +57,16 @@ curl -H "Authorization: Bearer YOUR_API_TOKEN" \ |-------|------|:--------:|-------------| | `name` | string | yes | Human-readable name shown in the UI. | | `stack_pattern` | string or `null` | no | Glob against stack names (e.g. `prod-*`). `null` matches every stack. | -| `max_severity` | string | yes | One of `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`. A scan whose highest finding meets or exceeds this severity triggers the policy. | -| `block_on_deploy` | `0` or `1` | yes | When `1`, pre-flight violations reject deploys with HTTP 409. When `0`, post-deploy and scheduled scans dispatch warning alerts instead. | +| `max_severity` | string | yes | One of `CRITICAL`, `HIGH`, `MEDIUM`, `LOW`. The threshold used when `block_on_severity` is on. Always stored for context. | +| `block_on_deploy` | `0` or `1` | yes | When `1`, pre-flight matches reject deploys with HTTP 409. When `0`, post-deploy and scheduled scans dispatch warning alerts instead. | | `enabled` | `0` or `1` | no | Defaults to `1`. Disabled policies are never evaluated. | +| `block_on_severity` | `0` or `1` | no | Block when an image's highest non-suppressed finding meets or exceeds `max_severity`. Defaults to `0`. | +| `block_on_kev` | `0` or `1` | no | Block when an image carries a CVE on the CISA known-exploited (KEV) list. Defaults to `1`. | +| `block_on_fixable` | `0` or `1` | no | Block when an image has a Critical/High finding with a fix available. Defaults to `1`. | | `node_id` | number or `null` | no | Scope the policy to one node. `null` applies the policy fleet-wide. | +A blocking policy (`block_on_deploy=1`) must enable at least one of `block_on_severity`, `block_on_kev`, or `block_on_fixable`. When the three input fields are omitted, they default risk-first (`block_on_kev=1`, `block_on_fixable=1`, `block_on_severity=0`). + ```bash curl -X POST https://your-sencho-instance:1852/api/security/policies \ -H "Authorization: Bearer YOUR_API_TOKEN" \ @@ -78,6 +86,7 @@ curl -X POST https://your-sencho-instance:1852/api/security/policies \ - `400` `max_severity must be CRITICAL, HIGH, MEDIUM, or LOW` - `400` `Policy name is required` +- `400` `A blocking policy must enable at least one of: severity threshold, KEV, or fixable.` - `403` when called on a replica node ### Update policy diff --git a/docs/features/deploy-enforcement.mdx b/docs/features/deploy-enforcement.mdx index 447082c2..218a04d6 100644 --- a/docs/features/deploy-enforcement.mdx +++ b/docs/features/deploy-enforcement.mdx @@ -3,7 +3,7 @@ title: "Deploy Enforcement" description: "Block deploys that violate a scan policy before docker compose up runs, with an admin bypass path and a full audit trail." --- -Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. When a [scan policy](/features/vulnerability-scanning#scan-policies) with **Block on deploy** enabled matches a stack, Sencho scans every image referenced by the stack's compose file before starting any container. If any image meets or exceeds the policy's severity threshold, the deploy is rejected and the stack never starts. Detection always continues post-deploy and on a schedule, so images that develop new vulnerabilities after the initial deploy still surface through alerts. +Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. When a [scan policy](/features/vulnerability-scanning#scan-policies) with **Block on deploy** enabled matches a stack, Sencho scans every image referenced by the stack's compose file before starting any container. A policy gates on exploitation risk: a known-exploited CVE (CISA KEV), a fixable Critical/High finding, or a raw severity threshold. If any image matches an enabled condition, the deploy is rejected and the stack never starts. Detection always continues post-deploy and on a schedule, so images that develop new vulnerabilities after the initial deploy still surface through alerts. Deploy enforcement and scan policies require an **Admiral** license. @@ -11,27 +11,29 @@ Deploy enforcement is the pre-flight half of Sencho's vulnerability workflow. Wh ## Configuring a block policy -Policies are managed on the **Security** page → **Policies** tab. The **Add policy** button opens the editor; existing policies appear as a list of cards with `max: ` and `block` badges, the configured stack-pattern scope, and pencil and trash buttons. +Policies are managed on the **Security** page → **Policies** tab. The **Add policy** button opens the editor; existing policies appear as a list of cards with a badge per active block condition (`KEV`, `Fixable`, and `max: ` when the severity threshold is on), a `block` badge, the configured stack-pattern scope, and pencil and trash buttons. Scan Policies card showing a configured policy with the name 'Production block on critical', a max: CRITICAL badge, a block badge, and the Scope demo-blocked-* set as the stack pattern -The editor exposes the five fields that govern enforcement: +The editor exposes the fields that govern enforcement: - New policy modal with kicker SECURITY · NEW POLICY, fields for Name, Stack pattern, Max severity (Critical), Block on deploy toggle ON, and Enabled toggle ON + New policy modal with kicker SECURITY · NEW POLICY, fields for Name, Stack pattern, the Block conditions group (Severity threshold, Known-exploited (KEV), Fixable Critical/High), a Block on deploy toggle, and an Enabled toggle | Field | Purpose | |-------|---------| | **Name** | A descriptive label that appears on the block dialog and in audit log entries. | | **Stack pattern (optional)** | Glob-style match against stack names. `prod-*` matches `prod-api` but not `production-api`. Leave blank to apply to every stack on the node. | -| **Max severity** | The threshold. If any image's highest finding meets or exceeds this severity, the policy fires. | -| **Block on deploy** | When on, the pre-flight gate hard-rejects deploys that violate the threshold. When off, the policy still evaluates post-deploy and scheduled scans and dispatches warning alerts on violations. | +| **Block conditions** | What makes the policy fire. Enable any combination: **Severity threshold** (highest finding meets or exceeds the chosen severity), **Known-exploited (KEV)** (a CVE on the CISA known-exploited list), and **Fixable Critical/High** (a Critical or High finding with a fix available). At least one is required to block on deploy. | +| **Block on deploy** | When on, the pre-flight gate hard-rejects deploys that match any block condition. When off, the policy still evaluates post-deploy and scheduled scans and dispatches warning alerts on matches. | | **Enabled** | Disabled policies are skipped during evaluation. | -The editor sets the pattern, severity, and toggles. Per-node scoping is set via the [Security API](/api-reference/security#scan-policies) (`node_id`) or replicated from a control node via [Fleet Federation](/features/fleet-federation). When more than one enabled policy matches a stack on the target node, a node-scoped policy wins over a fleet-wide one, a policy with a stack pattern wins over a catch-all, and the lowest-numbered policy breaks any remaining tie. +New policies default to a risk-first posture: known-exploited and fixable conditions on, the severity threshold off until you turn it on. CVSS is always captured and shown for context but is never the sole basis for a block. A finding whose exploitability cannot be confirmed is treated as risky, not safe. + +The editor sets the pattern, the block conditions, and the toggles. Per-node scoping is set via the [Security API](/api-reference/security#scan-policies) (`node_id`) or replicated from a control node via [Fleet Federation](/features/fleet-federation). When more than one enabled policy matches a stack on the target node, a node-scoped policy wins over a fleet-wide one, a policy with a stack pattern wins over a catch-all, and the lowest-numbered policy breaks any remaining tie. ## How enforcement runs @@ -52,10 +54,10 @@ On every one of these actions, Sencho: 1. Picks the matching enabled policy by precedence (node-scoped over fleet-wide, stack-pattern over catch-all, then lowest id) on the target node. 2. If the policy has **Block on deploy** off, lets the deploy proceed and evaluates the post-deploy scan against the policy for alerting. -3. If **Block on deploy** is on, enumerates the stack's images with `docker compose config --images`, runs a pre-flight Trivy scan against each one, and compares the highest severity in each scan against the policy threshold. -4. If every image is below the threshold, the deploy proceeds. A post-deploy drift scan still runs in the background. +3. If **Block on deploy** is on, enumerates the stack's images with `docker compose config --images`, runs a pre-flight Trivy scan against each one, and evaluates each enabled block condition (known-exploited, fixable, severity threshold) against the scan's non-suppressed findings. +4. If no image matches a block condition, the deploy proceeds. A post-deploy drift scan still runs in the background. 5. If the compose file fails to parse, enforcement fails closed: the deploy is rejected with a single synthetic violation labeled `(compose parse error)` so a malformed file cannot slip past the gate. -6. If any image violates the threshold, the deploy is rejected with HTTP `409 Conflict` and the stack never starts. The UI opens a dialog listing the offending images. +6. If any image matches a block condition, the deploy is rejected with HTTP `409 Conflict` and the stack never starts. The UI opens a dialog listing the offending images and the conditions each one matched. Pre-flight scans use the same 24-hour digest cache as on-demand scans, so the second deploy of the same image does not pay the full scan time. @@ -68,8 +70,8 @@ Pre-flight scans use the same 24-hour digest cache as on-demand scans, so the se The dialog shows: - A kicker with the stack name, the words **SCAN POLICY**, and **BLOCKED** in the destructive accent color. -- The policy name and a sentence naming the threshold the image crossed. -- One row per offending image, with the image reference in monospace, the counts of critical and high findings, and a severity chip in the matching color. +- The policy name and a sentence naming the conditions the policy blocks on. +- One row per offending image, with the image reference in monospace, the counts of critical and high findings (plus known-exploited and fixable counts when present), a badge for each matched condition, and a severity chip in the matching color. - A **Close** button that dismisses the dialog without deploying. - A destructive **Deploy anyway** button when the current user is an admin (see bypass below). For non-admin sessions the primary slot is replaced by a disabled outline button labeled **Admin required to bypass**. @@ -114,7 +116,7 @@ Neither drift mechanism blocks, stops, or quarantines a running stack automatica 1. Open the **Security** page → **Scanner setup** tab on the target node and confirm Trivy is installed. Sencho fails open when Trivy is missing, dispatching a warning alert instead of blocking. [Install Trivy](/operations/trivy-setup) to enforce the policy. 2. Confirm the policy is enabled and the stack pattern matches the stack name. `prod-*` matches `prod-api` but not `production-api`. An empty pattern matches every stack on the node. - 3. Check the highest severity in the latest scan for each image. If no image reached the threshold, the gate correctly allowed the deploy. + 3. Check the latest scan for each image against the policy's block conditions. If no image carried a known-exploited CVE, a fixable Critical/High finding, or (when the severity threshold is on) a finding at or above the threshold, the gate correctly allowed the deploy. Only users with the `admin` role can bypass a block. Ask an admin to review the violations and either bypass the single deploy, upgrade the base image, or [suppress](/features/cve-suppressions) the offending CVE with an expiry. diff --git a/frontend/src/components/security/ScanPolicyManager.tsx b/frontend/src/components/security/ScanPolicyManager.tsx index dd77b562..5eedb59a 100644 --- a/frontend/src/components/security/ScanPolicyManager.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -31,14 +31,23 @@ interface PolicyFormState { max_severity: VulnSeverity; block_on_deploy: boolean; enabled: boolean; + block_on_severity: boolean; + block_on_kev: boolean; + block_on_fixable: boolean; } +// New policies default risk-first (block on known-exploited and on fixable +// Critical/High), with the raw severity threshold off until the operator turns +// it on. Existing policies keep whatever they were saved with. const EMPTY_FORM: PolicyFormState = { name: '', stack_pattern: '', max_severity: 'CRITICAL', block_on_deploy: false, enabled: true, + block_on_severity: false, + block_on_kev: true, + block_on_fixable: true, }; /** @@ -184,6 +193,9 @@ export function ScanPolicyManager() { max_severity: policy.max_severity, block_on_deploy: policy.block_on_deploy === 1, enabled: policy.enabled === 1, + block_on_severity: policy.block_on_severity === 1, + block_on_kev: policy.block_on_kev === 1, + block_on_fixable: policy.block_on_fixable === 1, }); setDialogOpen(true); }; @@ -193,6 +205,12 @@ export function ScanPolicyManager() { toast.error('Policy name is required'); return; } + // A blocking policy with no active input would block nothing; the backend + // rejects it too. Catch it here so the operator gets immediate feedback. + if (form.block_on_deploy && !form.block_on_severity && !form.block_on_kev && !form.block_on_fixable) { + toast.error('Enable at least one input (severity, KEV, or fixable) to block on deploy.'); + return; + } setSaving(true); try { const payload = { @@ -201,6 +219,9 @@ export function ScanPolicyManager() { max_severity: form.max_severity, block_on_deploy: form.block_on_deploy ? 1 : 0, enabled: form.enabled ? 1 : 0, + block_on_severity: form.block_on_severity ? 1 : 0, + block_on_kev: form.block_on_kev ? 1 : 0, + block_on_fixable: form.block_on_fixable ? 1 : 0, }; const url = editingId ? `/security/policies/${editingId}` : '/security/policies'; const method = editingId ? 'PUT' : 'POST'; @@ -338,7 +359,7 @@ export function ScanPolicyManager() { } title="No scan policies configured" - subtitle="Add one to enforce severity thresholds across your fleet." + subtitle="Add one to gate deploys on known-exploited, fixable, or high-severity findings across your fleet." /> )} @@ -349,9 +370,21 @@ export function ScanPolicyManager() {
{policy.name} - - max: {policy.max_severity} - + {policy.block_on_severity === 1 && ( + + max: {policy.max_severity} + + )} + {policy.block_on_kev === 1 && ( + + KEV + + )} + {policy.block_on_fixable === 1 && ( + + Fixable + + )} {policy.block_on_deploy === 1 && ( block @@ -439,21 +472,62 @@ export function ScanPolicyManager() {

- - setForm({ ...form, max_severity: v as VulnSeverity })} - /> + +

+ What makes this policy flag an image. Enable at least one to block on deploy. CVSS is always captured for context but never the sole basis. +

+
+
+
+ +

Flag an image whose highest finding meets or exceeds the chosen severity.

+
+ setForm({ ...form, block_on_severity: c })} + /> +
+ {form.block_on_severity && ( + setForm({ ...form, max_severity: v as VulnSeverity })} + /> + )} +
+
+
+ +

Flag an image carrying a CVE on the CISA known-exploited list.

+
+ setForm({ ...form, block_on_kev: c })} + /> +
+
+
+ +

Flag an image with a Critical or High finding that has a fix available.

+
+ setForm({ ...form, block_on_fixable: c })} + /> +

- Reject a deploy before containers start when any image meets or exceeds the threshold. With this off, the policy only evaluates and raises an alert. + Reject a deploy before containers start when any image matches the block conditions above. With this off, the policy only evaluates and raises an alert.

setForm({ ...form, block_on_deploy: c })} /> diff --git a/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx index 4bee526b..5242fd6f 100644 --- a/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx +++ b/frontend/src/components/security/__tests__/ScanPolicyManager.test.tsx @@ -5,7 +5,8 @@ * configured". */ import { it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; +import { toast } from '@/components/ui/toast-store'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); vi.mock('@/context/LicenseContext'); @@ -70,3 +71,55 @@ it('shows the empty state when there are genuinely no policies', async () => { render(); await waitFor(() => expect(screen.getByText('No scan policies configured')).toBeInTheDocument()); }); + +const riskPolicy = { + id: 1, name: 'risk-gate', node_id: null, node_identity: '', stack_pattern: null, + max_severity: 'CRITICAL', block_on_deploy: 1, enabled: 1, + block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1, + replicated_from_control: 0, created_at: 1, updated_at: 1, +}; + +it('renders a per-input badge for each active input (KEV/Fixable, no severity)', async () => { + setup({ isPaid: true }); + mockedFetch.mockImplementation((url: string) => + Promise.resolve(url.startsWith('/fleet/role') ? jsonResponse(200, { role: 'control' }) : jsonResponse(200, [riskPolicy])), + ); + render(); + await waitFor(() => expect(screen.getByText('risk-gate')).toBeInTheDocument()); + expect(screen.getByText('KEV')).toBeInTheDocument(); + expect(screen.getByText('Fixable')).toBeInTheDocument(); + expect(screen.queryByText(/^max:/)).not.toBeInTheDocument(); +}); + +it('sends the risk-first defaults (KEV + fixable on, severity off) when creating a policy', async () => { + setup({ isPaid: true }); + render(); + await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Add policy')); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'new-gate' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + await waitFor(() => { + const call = mockedFetch.mock.calls.find(([url, opts]) => url === '/security/policies' && opts?.method === 'POST'); + expect(call).toBeTruthy(); + const body = JSON.parse((call![1] as { body: string }).body); + expect(body).toMatchObject({ block_on_severity: 0, block_on_kev: 1, block_on_fixable: 1 }); + }); +}); + +it('blocks a save that turns on block-on-deploy with no active input', async () => { + setup({ isPaid: true }); + render(); + await waitFor(() => expect(screen.getByText('Add policy')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Add policy')); + fireEvent.change(screen.getByLabelText('Name'), { target: { value: 'empty-gate' } }); + + const dialog = screen.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('switch', { name: 'Known-exploited (KEV)' })); // KEV off + fireEvent.click(within(dialog).getByRole('switch', { name: 'Fixable Critical/High' })); // fixable off + fireEvent.click(within(dialog).getByRole('switch', { name: 'Block on deploy' })); // block-on-deploy on + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + + expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/at least one input/i)); + expect(mockedFetch.mock.calls.some(([url, opts]) => url === '/security/policies' && opts?.method === 'POST')).toBe(false); +}); diff --git a/frontend/src/components/stack/PolicyBlockDialog.tsx b/frontend/src/components/stack/PolicyBlockDialog.tsx index 2e48ccdd..cfaf144b 100644 --- a/frontend/src/components/stack/PolicyBlockDialog.tsx +++ b/frontend/src/components/stack/PolicyBlockDialog.tsx @@ -5,23 +5,59 @@ import { ModalFooter, } from '@/components/ui/modal'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { SeverityChip } from '@/components/VulnerabilityScanSheet'; import type { VulnSeverity } from '@/types/security'; +/** Risk inputs a deploy gate can block on; mirrors the backend reason set. */ +export type PolicyBlockReason = 'severity' | 'kev' | 'fixable'; + export interface PolicyBlockViolation { imageRef: string; severity: VulnSeverity | string; criticalCount: number; highCount: number; + kevCount: number; + fixableCount: number; + /** Which inputs matched (empty when the image could not be scanned). */ + reasons: PolicyBlockReason[]; scanId: number; } export interface PolicyBlockPayload { error: string; - policy: { id: number; name: string; maxSeverity: string } | null; + policy: + | { + id: number; + name: string; + maxSeverity: string; + // Active inputs (0/1). Absent on older control payloads, where the + // dialog falls back to severity-only wording. + blockOnSeverity?: number; + blockOnKev?: number; + blockOnFixable?: number; + } + | null; violations: PolicyBlockViolation[]; } +const REASON_LABEL: Record = { + severity: 'Severity', + kev: 'KEV', + fixable: 'Fixable', +}; + +/** Plain-language list of the inputs a policy blocks on, for the dialog copy. */ +function describePolicyInputs(policy: PolicyBlockPayload['policy']): string { + if (!policy) return 'its scan policy conditions'; + const parts: string[] = []; + if (policy.blockOnSeverity) parts.push(`severity at or above ${policy.maxSeverity}`); + if (policy.blockOnKev) parts.push('a known-exploited CVE (KEV)'); + if (policy.blockOnFixable) parts.push('a fixable Critical/High finding'); + // Older payloads omit the flags entirely; describe the severity threshold. + return parts.length > 0 ? parts.join(', ') : `severity at or above ${policy.maxSeverity}`; +} + /** The only stack operations the backend scan-policy gate can reject. */ export type PolicyBlockableAction = 'deploy' | 'update' | 'rollback'; @@ -52,7 +88,7 @@ export function PolicyBlockDialog({ onBypass, }: PolicyBlockDialogProps) { const policyName = payload?.policy?.name ?? 'policy'; - const maxSeverity = payload?.policy?.maxSeverity ?? ''; + const inputsText = describePolicyInputs(payload?.policy ?? null); const violations = payload?.violations ?? []; return ( @@ -60,13 +96,12 @@ export function PolicyBlockDialog({

Policy {policyName} blocks deploys - when any image meets or exceeds{' '} - {maxSeverity}.{' '} + on {inputsText}.{' '} The following {violations.length === 1 ? 'image' : `${violations.length} images`} triggered the block.

@@ -81,7 +116,18 @@ export function PolicyBlockDialog({
{v.imageRef}
{v.criticalCount} critical · {v.highCount} high + {v.kevCount > 0 && <> · {v.kevCount} KEV} + {v.fixableCount > 0 && <> · {v.fixableCount} fixable}
+ {(v.reasons ?? []).length > 0 && ( +
+ {(v.reasons ?? []).map((r) => ( + + {REASON_LABEL[r]} + + ))} +
+ )}
diff --git a/frontend/src/components/stack/__tests__/PolicyBlockDialog.test.tsx b/frontend/src/components/stack/__tests__/PolicyBlockDialog.test.tsx new file mode 100644 index 00000000..df503afb --- /dev/null +++ b/frontend/src/components/stack/__tests__/PolicyBlockDialog.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { PolicyBlockDialog, type PolicyBlockPayload } from '../PolicyBlockDialog'; + +const payload: PolicyBlockPayload = { + error: 'blocked', + policy: { id: 1, name: 'prod-gate', maxSeverity: 'CRITICAL', blockOnSeverity: 0, blockOnKev: 1, blockOnFixable: 1 }, + violations: [ + { imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 0, kevCount: 1, fixableCount: 1, reasons: ['kev', 'fixable'], scanId: 1 }, + ], +}; + +describe('PolicyBlockDialog', () => { + it('describes the active inputs (KEV + fixable, not the severity threshold)', () => { + render( + , + ); + const desc = screen.getAllByText(/known-exploited CVE \(KEV\)/i); + expect(desc.length).toBeGreaterThan(0); + expect(screen.getAllByText(/fixable Critical\/High finding/i).length).toBeGreaterThan(0); + }); + + it('renders a reason badge per matched input on the violation row', () => { + render( + , + ); + expect(screen.getByText('KEV')).toBeInTheDocument(); + expect(screen.getByText('Fixable')).toBeInTheDocument(); + expect(screen.getByText(/1 KEV/)).toBeInTheDocument(); + }); + + it('falls back to severity wording when input flags are absent (older payload)', () => { + const legacy: PolicyBlockPayload = { + error: 'blocked', + policy: { id: 1, name: 'old-gate', maxSeverity: 'HIGH' }, + violations: [{ imageRef: 'redis:7', severity: 'HIGH', criticalCount: 0, highCount: 1, kevCount: 0, fixableCount: 0, reasons: [], scanId: 2 }], + }; + render( + , + ); + expect(screen.getAllByText(/severity at or above HIGH/i).length).toBeGreaterThan(0); + }); +}); diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 11480c08..3e8829ff 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -188,6 +188,12 @@ export interface ScanPolicy { max_severity: VulnSeverity; block_on_deploy: number; enabled: 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; replicated_from_control: number; created_at: number; updated_at: number;