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.
This commit is contained in:
Anso
2026-06-26 18:58:54 -04:00
committed by GitHub
parent 89a13f51e9
commit 000a592388
2 changed files with 88 additions and 1 deletions
@@ -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<string> }).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);
});
});
+5 -1
View File
@@ -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
: [];