From aa10db1d09261741faf5b2f8cd6a64436facf2f6 Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 20 Apr 2026 23:14:23 -0400 Subject: [PATCH] fix(trivy): remove unsupported --no-progress flag from `trivy config` (#718) The `trivy config` subcommand does not accept `--no-progress`; the flag exists only on `trivy image`. Every stack configuration scan therefore failed with `FATAL Fatal error unknown flag: --no-progress`, and the "Scan configuration" action on the stack details page has been non-functional since it shipped. Removing the flag is the complete fix. `trivy config` is silent by default, so the flag was redundant even if it had been accepted. A new Vitest spec pins the exact argument vector (`['config', '--format', 'json', '--quiet', ]`) so a future edit cannot reintroduce the bug, and exercises the success path end to end: status transitions to `completed`, misconfig severities tally correctly, and `highest_severity` rolls up to the worst finding. --- .../trivy-scan-compose-stack.test.ts | 225 ++++++++++++++++++ backend/src/services/TrivyService.ts | 2 +- 2 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 backend/src/__tests__/trivy-scan-compose-stack.test.ts diff --git a/backend/src/__tests__/trivy-scan-compose-stack.test.ts b/backend/src/__tests__/trivy-scan-compose-stack.test.ts new file mode 100644 index 00000000..441e36c7 --- /dev/null +++ b/backend/src/__tests__/trivy-scan-compose-stack.test.ts @@ -0,0 +1,225 @@ +/** + * 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: [] }); + +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') + ] = (file: string, args: string[]) => { + execFileCalls.push({ file, args }); + return Promise.resolve({ 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); + }); +}); diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 486adc6e..2fc6ce35 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -806,7 +806,7 @@ class TrivyService { try { const { env, cleanup } = await this.buildEnv(); try { - const args = ['config', '--format', 'json', '--quiet', '--no-progress', resolved]; + const args = ['config', '--format', 'json', '--quiet', resolved]; const { stdout } = await execFileAsync(binary, args, { env, timeout: SCAN_TIMEOUT_MS,