diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index c5c7f6ea..5d9cf2c4 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -328,6 +328,8 @@ describe('enforcePolicyPreDeploy', () => { expect(result.violations).toHaveLength(1); expect(result.violations[0].imageRef).toBe('(compose parse error)'); expect(result.violations[0].severity).toBe('UNKNOWN'); + // The block is unactionable without naming why the gate ran a synthetic block. + expect(result.violations[0].error).toMatch(/compose file missing/i); expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled(); }); @@ -344,6 +346,30 @@ describe('enforcePolicyPreDeploy', () => { expect(result.violations[0].imageRef).toBe('nginx:1.14'); expect(result.violations[0].severity).toBe('UNKNOWN'); expect(result.violations[0].scanId).toBe(0); + // The synthetic violation must carry the scan failure reason so the block is + // actionable rather than an unexplained zero-count block. + expect(result.violations[0].error).toMatch(/trivy crashed/i); + }); + + it('records an evaluation-failure violation that keeps the scan id and names the reason', async () => { + // A KEV policy forces the per-finding detail path, and an intel lookup that + // throws after a successful scan exercises the evaluation-failure catch, + // which (unlike a scan failure) keeps the real scan id. + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy({ block_on_severity: 0, block_on_kev: 1 })); + trivyStub.isTrivyAvailable.mockReturnValue(true); + composeStub.listStackImages.mockResolvedValue(['nginx:1.14']); + trivyStub.scanImagePreflight.mockResolvedValue(mkScan({ id: 42, total_vulnerabilities: 1, highest_severity: 'CRITICAL', critical_count: 1 })); + dbStub.getAllVulnerabilityDetails.mockReturnValue([ + { id: 1, scan_id: 42, vulnerability_id: 'CVE-2024-1', pkg_name: 'p', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + dbStub.getCveIntel.mockImplementation(() => { throw new Error('intel db read failed'); }); + + const result = await enforcePolicyPreDeploy('web', 1, { bypass: false, actor: 'u' }); + + expect(result.ok).toBe(false); + expect(result.violations).toHaveLength(1); + expect(result.violations[0].scanId).toBe(42); + expect(result.violations[0].error).toMatch(/policy evaluation failed/i); }); it('skips image refs that fail validation without calling the scanner', async () => { @@ -408,6 +434,7 @@ describe('enforcePolicyPreDeploy', () => { severity: 'UNKNOWN', scanId: 0, }); + expect(result.violations[0].error).toMatch(/invalid image reference/i); expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled(); expect(composeStub.listStackImages).not.toHaveBeenCalled(); }); diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 86570891..dbee1241 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -40,6 +40,14 @@ export interface PolicyViolation { /** Which policy inputs matched (empty when the image could not be scanned). */ reasons: PolicyBlockReason[]; scanId: number; + /** + * Why the block is unactionable by policy: set when the gate blocked because + * the image could not be scanned or evaluated (compose parse error, scan + * failure, evaluation error), not because a policy input matched. Absent for + * a normal policy match. Lets the UI explain the failure instead of showing a + * zero-count block with no reason. + */ + error?: string; } export interface PolicyEnforcementOptions { @@ -304,6 +312,7 @@ export async function enforcePolicyPreDeploy( fixableCount: 0, reasons: [], scanId: 0, + error: `Compose file could not be parsed: ${message}`, }], }; } @@ -356,6 +365,7 @@ export async function enforcePolicyForImageRefs( fixableCount: 0, reasons: [], scanId: 0, + error: 'Invalid image reference; the image could not be scanned', }); } continue; @@ -375,6 +385,7 @@ export async function enforcePolicyForImageRefs( fixableCount: 0, reasons: [], scanId: 0, + error: `Pre-flight scan failed: ${message}`, }); continue; } @@ -416,7 +427,10 @@ export async function enforcePolicyForImageRefs( kevCount: 0, fixableCount: 0, reasons: [], + // The scan completed; only evaluation failed, so the real scan + // id is kept (the other failure sites have no scan and use 0). scanId: scan.id, + error: `Policy evaluation failed: ${message}`, }); } } diff --git a/frontend/src/components/stack/PolicyBlockDialog.tsx b/frontend/src/components/stack/PolicyBlockDialog.tsx index cfaf144b..ceaba564 100644 --- a/frontend/src/components/stack/PolicyBlockDialog.tsx +++ b/frontend/src/components/stack/PolicyBlockDialog.tsx @@ -22,6 +22,9 @@ export interface PolicyBlockViolation { /** Which inputs matched (empty when the image could not be scanned). */ reasons: PolicyBlockReason[]; scanId: number; + /** Set when the gate blocked because the image could not be scanned or + * evaluated (a scan/parse failure), rather than a policy input matching. */ + error?: string; } export interface PolicyBlockPayload { @@ -114,19 +117,30 @@ export function PolicyBlockDialog({
+ The deploy was blocked because the scan did not complete. Resolve the issue above and + deploy again, or bypass if you accept the risk. +
+ )}