mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
: [];
|
||||
|
||||
Reference in New Issue
Block a user