/** * Pins the argument vector TrivyService sends to `trivy config`. * * `trivy config` is a distinct subcommand from `trivy image` and does not * accept `--no-progress`. If the arg vector grows a flag the subcommand * rejects, every stack configuration scan fails with a fatal Trivy error. * This test locks the vector so the bug cannot quietly return. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; interface ExecFileCall { file: string; args: string[]; } const execFileCalls: ExecFileCall[] = []; let nextTrivyStdout = JSON.stringify({ Results: [] }); // Tests can install a Promise to gate the next execFile resolution. The // dedup tests use this to keep the first scan in flight while issuing a // second concurrent call, so the in-progress flag is observable. let pendingExecGate: Promise | null = null; let nextExecShouldFail = false; vi.mock('child_process', () => { // TrivyService wraps this with `promisify(execFile)` at module load. The // custom promisify symbol is what tells util.promisify to return // `{stdout, stderr}` rather than a single value, so we install our async // double there. const execFile = () => undefined; (execFile as unknown as Record)[ Symbol.for('nodejs.util.promisify.custom') ] = async (file: string, args: string[]) => { execFileCalls.push({ file, args }); if (pendingExecGate) await pendingExecGate; if (nextExecShouldFail) { nextExecShouldFail = false; throw new Error('simulated trivy failure'); } return { stdout: nextTrivyStdout, stderr: '' }; }; return { execFile }; }); interface UpdateCall { id: number; patch: Record; } interface MisconfigInsert { scanId: number; count: number; findings: Array>; } const createdScans: Array> = []; const updateCalls: UpdateCall[] = []; const misconfigInserts: MisconfigInsert[] = []; vi.mock('../services/DatabaseService', () => ({ DatabaseService: { getInstance: () => ({ getRegistries: () => [], createVulnerabilityScan: (scan: Record) => { createdScans.push(scan); return 42; }, updateVulnerabilityScan: (id: number, patch: Record) => { updateCalls.push({ id, patch }); }, insertMisconfigFindings: (scanId: number, findings: Array>) => { misconfigInserts.push({ scanId, count: findings.length, findings }); }, getVulnerabilityScan: (id: number) => ({ id, node_id: 1, image_ref: 'stack:test-stack', scanned_at: Date.now(), total_vulnerabilities: 0, critical_count: 0, high_count: 0, medium_count: 0, low_count: 0, unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, highest_severity: null, trivy_version: '0.50.0', status: 'completed', }), }), }, })); vi.mock('../services/FileSystemService', () => ({ FileSystemService: { getInstance: () => ({ getBaseDir: () => '/fake/compose', hasComposeFile: async () => true, }), }, })); vi.mock('../services/RegistryService', () => ({ RegistryService: { getInstance: () => ({ resolveDockerConfig: async () => ({ config: {}, warnings: [] }), }), }, })); vi.mock('../services/DockerController', () => ({ default: { getInstance: () => ({ getDocker: () => ({ getImage: () => ({ inspect: async () => ({}) }), }), }), }, })); vi.mock('../services/TrivyInstaller', () => ({ default: { getInstance: () => ({ binaryPath: () => '/fake/managed/trivy', cacheDir: () => '/tmp/trivy-cache', }), }, })); import TrivyService from '../services/TrivyService'; interface TrivyServiceInternals { binaryPath: string | null; version: string | null; source: string; } function forceBinary(svc: TrivyService): void { const internals = svc as unknown as TrivyServiceInternals; internals.binaryPath = '/fake/trivy'; internals.version = '0.50.0'; internals.source = 'managed'; } describe('TrivyService.scanComposeStack arg vector', () => { beforeEach(() => { execFileCalls.length = 0; createdScans.length = 0; updateCalls.length = 0; misconfigInserts.length = 0; nextTrivyStdout = JSON.stringify({ Results: [] }); forceBinary(TrivyService.getInstance()); }); it('invokes `trivy config` without the unsupported `--no-progress` flag', async () => { await TrivyService.getInstance().scanComposeStack(1, 'test-stack', 'manual'); expect(execFileCalls.length).toBe(1); const args = execFileCalls[0].args; expect(args).not.toContain('--no-progress'); }); it('pins the exact argument order so future edits cannot reintroduce the bug', async () => { await TrivyService.getInstance().scanComposeStack(1, 'test-stack', 'manual'); const call = execFileCalls[0]; expect(call.file).toBe('/fake/trivy'); expect(call.args.length).toBe(5); expect(call.args[0]).toBe('config'); expect(call.args[1]).toBe('--format'); expect(call.args[2]).toBe('json'); expect(call.args[3]).toBe('--quiet'); // Last arg is the resolved stack path; assert shape rather than exact // value so the test works across local and CI path separators. expect(call.args[4]).toContain('test-stack'); }); it('persists the scan row with scanners_used=config', async () => { await TrivyService.getInstance().scanComposeStack(1, 'test-stack', 'manual'); expect(createdScans.length).toBe(1); expect(createdScans[0].image_ref).toBe('stack:test-stack'); expect(createdScans[0].scanners_used).toBe('config'); expect(createdScans[0].triggered_by).toBe('manual'); }); it('rejects stack names that traverse outside the compose directory', async () => { await expect( TrivyService.getInstance().scanComposeStack(1, '../evil', 'manual'), ).rejects.toThrow(/Invalid stack path/); expect(execFileCalls.length).toBe(0); }); it('marks the scan row completed on a successful run', async () => { await TrivyService.getInstance().scanComposeStack(1, 'test-stack', 'manual'); // Two updates expected: one to record the results + status=completed, // and no failure update on the success path. const completed = updateCalls.find((u) => u.patch.status === 'completed'); expect(completed).toBeDefined(); expect(completed?.id).toBe(42); expect(updateCalls.some((u) => u.patch.status === 'failed')).toBe(false); }); it('tallies misconfig severities and rolls up highest_severity', async () => { nextTrivyStdout = JSON.stringify({ Results: [ { Target: 'docker-compose.yml', Misconfigurations: [ { ID: 'DS001', Severity: 'CRITICAL', Title: 'Privileged mode' }, { ID: 'DS002', Severity: 'HIGH', Title: 'Running as root' }, { ID: 'DS003', Severity: 'MEDIUM', Title: 'Missing healthcheck' }, ], }, ], }); await TrivyService.getInstance().scanComposeStack(1, 'test-stack', 'manual'); const completed = updateCalls.find((u) => u.patch.status === 'completed'); expect(completed).toBeDefined(); expect(completed?.patch.critical_count).toBe(1); expect(completed?.patch.high_count).toBe(1); expect(completed?.patch.medium_count).toBe(1); expect(completed?.patch.low_count).toBe(0); expect(completed?.patch.unknown_count).toBe(0); expect(completed?.patch.misconfig_count).toBe(3); expect(completed?.patch.highest_severity).toBe('CRITICAL'); expect(misconfigInserts.length).toBe(1); expect(misconfigInserts[0].scanId).toBe(42); expect(misconfigInserts[0].count).toBe(3); }); }); describe('TrivyService.scanComposeStack dedup', () => { beforeEach(() => { execFileCalls.length = 0; createdScans.length = 0; updateCalls.length = 0; misconfigInserts.length = 0; nextTrivyStdout = JSON.stringify({ Results: [] }); pendingExecGate = null; nextExecShouldFail = false; forceBinary(TrivyService.getInstance()); }); it('reports isScanningStack=false before any scan starts', () => { expect(TrivyService.getInstance().isScanningStack(1, 'unscanned')).toBe(false); }); it('rejects a concurrent scan of the same stack while the first is in flight', async () => { let release: (() => void) | null = null; pendingExecGate = new Promise((resolve) => { release = resolve; }); const first = TrivyService.getInstance().scanComposeStack(1, 'gated-stack', 'manual'); // Yield twice so the inner async setup (path resolve + dedup add) runs. await Promise.resolve(); await Promise.resolve(); expect(TrivyService.getInstance().isScanningStack(1, 'gated-stack')).toBe(true); await expect( TrivyService.getInstance().scanComposeStack(1, 'gated-stack', 'manual'), ).rejects.toThrow(/Already scanning this stack/); release!(); await first; expect(TrivyService.getInstance().isScanningStack(1, 'gated-stack')).toBe(false); // Only one trivy invocation should have happened — the second was // rejected before it ever reached execFile. expect(execFileCalls.length).toBe(1); }); it('allows different stacks on the same node to scan in parallel', async () => { const r1 = TrivyService.getInstance().scanComposeStack(1, 'stack-a', 'manual'); const r2 = TrivyService.getInstance().scanComposeStack(1, 'stack-b', 'manual'); await Promise.all([r1, r2]); expect(execFileCalls.length).toBe(2); expect(TrivyService.getInstance().isScanningStack(1, 'stack-a')).toBe(false); expect(TrivyService.getInstance().isScanningStack(1, 'stack-b')).toBe(false); }); it('allows the same stack to scan again on a different node', async () => { const r1 = TrivyService.getInstance().scanComposeStack(1, 'shared-name', 'manual'); const r2 = TrivyService.getInstance().scanComposeStack(2, 'shared-name', 'manual'); await Promise.all([r1, r2]); expect(execFileCalls.length).toBe(2); }); it('releases the dedup key after a failed scan so retry works', async () => { nextExecShouldFail = true; await expect( TrivyService.getInstance().scanComposeStack(1, 'fail-stack', 'manual'), ).rejects.toThrow(/simulated trivy failure/); expect(TrivyService.getInstance().isScanningStack(1, 'fail-stack')).toBe(false); // Subsequent scan after release should succeed. await TrivyService.getInstance().scanComposeStack(1, 'fail-stack', 'manual'); expect(execFileCalls.length).toBe(2); }); }); describe('TrivyService.scanComposeStack failure modes', () => { beforeEach(() => { execFileCalls.length = 0; createdScans.length = 0; updateCalls.length = 0; misconfigInserts.length = 0; nextTrivyStdout = JSON.stringify({ Results: [] }); pendingExecGate = null; nextExecShouldFail = false; forceBinary(TrivyService.getInstance()); }); it('flips the scan row to failed when Trivy stdout is malformed JSON', async () => { // parseTrivyOutput throws "Malformed Trivy output: ..." on bad JSON. nextTrivyStdout = '{ this is not valid json'; await expect( TrivyService.getInstance().scanComposeStack(1, 'broken-stack', 'manual'), ).rejects.toThrow(/Malformed Trivy output/); const failedUpdate = updateCalls.find((u) => u.patch.status === 'failed'); expect(failedUpdate).toBeDefined(); expect(failedUpdate?.patch.error).toMatch(/Malformed Trivy output/); expect(updateCalls.some((u) => u.patch.status === 'completed')).toBe(false); }); it('flips the scan row to failed when Trivy throws ETIMEDOUT', async () => { // Simulate timeout the way util.promisify(execFile) surfaces it: a // rejected Promise. We hijack the gate by failing instead of resolving. nextExecShouldFail = true; await expect( TrivyService.getInstance().scanComposeStack(1, 'slow-stack', 'manual'), ).rejects.toThrow(); const failedUpdate = updateCalls.find((u) => u.patch.status === 'failed'); expect(failedUpdate).toBeDefined(); expect(typeof failedUpdate?.patch.error).toBe('string'); }); });