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({
{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]} - - ))} -
+ {v.error ? ( + <> +
+ Could not be scanned +
+
{v.error}
+ + ) : ( + <> +
+ {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]} + + ))} +
+ )} + )}
@@ -134,6 +148,12 @@ export function PolicyBlockDialog({ )) )}
+ {violations.some((v) => v.error) && ( +

+ The deploy was blocked because the scan did not complete. Resolve the issue above and + deploy again, or bypass if you accept the risk. +

+ )} { expect(screen.getByText('KEV')).toBeInTheDocument(); expect(screen.getByText('Fixable')).toBeInTheDocument(); expect(screen.getByText(/1 KEV/)).toBeInTheDocument(); + // A clean policy match must not show the scan-failure recovery hint. + expect(screen.queryByText(/deploy again/i)).not.toBeInTheDocument(); + }); + + it('renders matched and scan-failed violations together with a single recovery hint', () => { + const mixed: 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 }, + { imageRef: 'redis:7', severity: 'UNKNOWN', criticalCount: 0, highCount: 0, kevCount: 0, fixableCount: 0, reasons: [], scanId: 0, error: 'Pre-flight scan failed: timeout' }, + ], + }; + render( + , + ); + // The matched row keeps its counts and reason badges. + expect(screen.getByText(/1 KEV/)).toBeInTheDocument(); + expect(screen.getByText('Fixable')).toBeInTheDocument(); + // The failed row shows its reason under the could-not-be-scanned label. + expect(screen.getByText(/Pre-flight scan failed: timeout/i)).toBeInTheDocument(); + expect(screen.getByText(/could not be scanned/i)).toBeInTheDocument(); + // The recovery hint appears once for the whole list, not per failed row. + expect(screen.getAllByText(/deploy again/i)).toHaveLength(1); + }); + + it('explains a scan failure with its reason instead of a zero-count block', () => { + const failed: PolicyBlockPayload = { + error: 'blocked', + policy: { id: 1, name: 'prod-gate', maxSeverity: 'CRITICAL', blockOnSeverity: 0, blockOnKev: 1, blockOnFixable: 1 }, + violations: [ + { imageRef: 'nginx:1.14', severity: 'UNKNOWN', criticalCount: 0, highCount: 0, kevCount: 0, fixableCount: 0, reasons: [], scanId: 0, error: 'Pre-flight scan failed: trivy crashed' }, + ], + }; + render( + , + ); + // The actual failure reason is shown, not an unexplained "0 critical 0 high". + expect(screen.getByText(/Pre-flight scan failed: trivy crashed/i)).toBeInTheDocument(); + expect(screen.getByText(/could not be scanned/i)).toBeInTheDocument(); + // A recovery hint points the operator at the fix-and-retry path. + expect(screen.getByText(/deploy again/i)).toBeInTheDocument(); }); it('falls back to severity wording when input flags are absent (older payload)', () => {