From 000a5923882279a0b55223f10aca438ffe335f18 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 26 Jun 2026 18:58:54 -0400 Subject: [PATCH] fix: copy the full finding set when reusing a cached scan for the deploy gate (#1476) The pre-deploy gate reuses a cached scan for the same image digest within 24h. The cache-hit path copied only the first 1000 detail rows while keeping the cached scan's full aggregate total, so the persisted preflight scan stored fewer detail rows than its total_vulnerabilities. The gate's integrity check in evaluateImageRisk treats that mismatch as untrustworthy and fails closed on every active KEV or fixable input, blocking a deploy with no actual matching finding and bypassing honored suppressions. The cache-hit copy now reads the complete detail set (getAllVulnerabilityDetails) so the persisted scan keeps stored details equal to total_vulnerabilities, matching a fresh scan and letting the gate evaluate the real findings. --- .../trivy-preflight-cache-details.test.ts | 83 +++++++++++++++++++ backend/src/services/TrivyService.ts | 6 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/trivy-preflight-cache-details.test.ts diff --git a/backend/src/__tests__/trivy-preflight-cache-details.test.ts b/backend/src/__tests__/trivy-preflight-cache-details.test.ts new file mode 100644 index 00000000..09e8516a --- /dev/null +++ b/backend/src/__tests__/trivy-preflight-cache-details.test.ts @@ -0,0 +1,83 @@ +/** + * Regression coverage for the pre-deploy gate false-block on cached large scans. + * + * A cache-hit preflight scan must copy the cached scan's FULL finding set, not a + * truncated page. When the copy kept only the first 1000 detail rows but + * preserved the full aggregate total, the persisted scan was internally + * inconsistent (stored rows < total_vulnerabilities), and the deploy gate's + * integrity check (evaluateImageRisk) failed closed on every KEV/fixable input, + * blocking a deploy with no actual matching finding. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { DatabaseService } from '../services/DatabaseService'; + +let tmpDir: string; +let TrivyService: typeof import('../services/TrivyService').default; + +const DIGEST = 'sha256:cachedbig'; +const TOTAL = 1100; // deliberately over the old 1000-row copy cap + +beforeAll(async () => { + tmpDir = await setupTestDb(); + TrivyService = (await import('../services/TrivyService')).default; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function svc() { + return TrivyService.getInstance(); +} +function db() { + return DatabaseService.getInstance(); +} + +/** Seed a consistent cached vuln scan: total_vulnerabilities matches its detail rows. */ +function seedCachedScan(): number { + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'big:1', image_digest: DIGEST, scanned_at: Date.now(), + total_vulnerabilities: TOTAL, critical_count: TOTAL, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + const details = Array.from({ length: TOTAL }, (_, i) => ({ + vulnerability_id: `CVE-2024-${i}`, pkg_name: `pkg-${i}`, installed_version: '1', fixed_version: null, + severity: 'CRITICAL' as const, title: null, description: null, primary_url: null, + })); + db().insertVulnerabilityDetails(scanId, details); + return scanId; +} + +beforeEach(() => { + const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + raw.prepare('DELETE FROM vulnerability_details').run(); + raw.prepare('DELETE FROM vulnerability_scans').run(); + (svc() as unknown as { scanningImages: Set }).scanningImages.clear(); + // The cache-hit path returns before invoking the binary; a non-null path is + // enough to pass the availability guard. + (svc() as unknown as { binaryPath: string }).binaryPath = '/fake/trivy'; +}); + +describe('TrivyService cache-hit detail copy', () => { + it('copies every cached vulnerability detail, not a truncated page', async () => { + seedCachedScan(); + const result = await svc().scanImage('big:1', 1, { useCache: true, digest: DIGEST, scanners: ['vuln'] }); + expect(result.totalVulnerabilities).toBe(TOTAL); + expect(result.vulnerabilities).toHaveLength(TOTAL); + }); + + it('persists a preflight scan whose stored detail count matches its total (no false-block mismatch)', async () => { + seedCachedScan(); + // scanImagePreflight resolves the digest itself; the cache hit then reuses it. + vi.spyOn(svc(), 'getImageDigest').mockResolvedValue(DIGEST); + + const persisted = await svc().scanImagePreflight('big:1', 1, 'web'); + + const storedDetailCount = db().getAllVulnerabilityDetails(persisted.id).length; + expect(persisted.total_vulnerabilities).toBe(TOTAL); + expect(storedDetailCount).toBe(persisted.total_vulnerabilities); + // The severity aggregates must survive the cache round-trip too. + expect(persisted.critical_count).toBe(TOTAL); + }); +}); diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index c36128dc..3d549893 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -651,7 +651,11 @@ class TrivyService { `scanImage: cache hit for digest=${digest} scanId=${cached.id} ageMs=${startedAt - cached.scanned_at}`, ); const db = DatabaseService.getInstance(); - const details = db.getVulnerabilityDetails(cached.id, { limit: 1000 }).items; + // Copy the cached scan's full finding set, not a truncated page: + // the persisted copy must keep stored details == total_vulnerabilities, + // or the deploy gate's integrity check fails closed on a cached scan + // that has more findings than a single detail page would hold. + const details = db.getAllVulnerabilityDetails(cached.id); const cachedSecrets = scanners.includes('secret') ? db.getSecretFindings(cached.id, { limit: 1000 }).items : [];