mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-28 03:06:57 +00:00
000a592388
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.
84 lines
3.7 KiB
TypeScript
84 lines
3.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|