From 628400ac192d10da47b2f804bd69ced0924742de Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 26 Jun 2026 19:13:47 -0400 Subject: [PATCH] fix: explain why a failed pre-deploy scan blocks a deploy (#1477) When the pre-deploy gate could not scan or evaluate an image (a compose parse error, a scan failure, an invalid image reference, or an evaluation error), it pushed a synthetic violation with zero counts and no reason. The block dialog then showed "0 critical, 0 high" with no explanation and only Close or admin bypass, so an operator could not tell why the deploy was blocked or what to fix. The synthetic violation now carries the failure reason in an error field, which flows through the existing 409 block payload. The block dialog renders that reason under a "Could not be scanned" label instead of a misleading zero-count row, and shows a recovery hint pointing at the fix-and-deploy-again path. --- .../src/__tests__/policy-enforcement.test.ts | 27 +++++++++++ backend/src/services/PolicyEnforcement.ts | 14 ++++++ .../components/stack/PolicyBlockDialog.tsx | 46 +++++++++++++------ .../__tests__/PolicyBlockDialog.test.tsx | 42 +++++++++++++++++ 4 files changed, 116 insertions(+), 13 deletions(-) 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)', () => {