diff --git a/src/lib/server/scanner-output-core.ts b/src/lib/server/scanner-output-core.ts new file mode 100644 index 0000000..84aeaf5 --- /dev/null +++ b/src/lib/server/scanner-output-core.ts @@ -0,0 +1,30 @@ +/** + * Pure, side-effect-free helpers for handling scanner (Trivy/Grype) stdout. Kept out of + * scanner.ts so they can be unit-tested without pulling in docker/db (which break bun test). + */ + +/** + * Bound a large scanner output for logging: keep a head and tail (default 2 KB each) with a + * marker for the elided middle, so a failed parse of a multi-MB report never floods stderr / + * the log shipper (#1496). Small outputs pass through unchanged. + */ +export function truncateForLog(output: string, keep = 2048): string { + if (output.length <= keep * 2) return output; + const elided = output.length - keep * 2; + return `${output.slice(0, keep)}\n...[${elided} bytes elided]...\n${output.slice(-keep)}`; +} + +/** + * Classify a scanner output that failed to parse as JSON, to surface a useful error. + * - 'truncated': a large buffer that does NOT start with `{` is a mid-document fragment - the + * head of the JSON was lost to container-log rotation (#1496). + * - 'cli-error': a short non-JSON output is a genuine error message printed by the scanner CLI. + * - 'unknown': anything else (e.g. starts with `{` but is still unparseable). + */ +export function classifyUnparseableOutput(output: string): 'truncated' | 'cli-error' | 'unknown' { + const trimmed = output.trimStart(); + if (output.length > 100_000 && !trimmed.startsWith('{')) return 'truncated'; + const firstLine = trimmed.split('\n', 1)[0].trim(); + if (firstLine && !firstLine.startsWith('{')) return 'cli-error'; + return 'unknown'; +} diff --git a/src/lib/server/scanner.ts b/src/lib/server/scanner.ts index 903aebd..5572cfa 100644 --- a/src/lib/server/scanner.ts +++ b/src/lib/server/scanner.ts @@ -17,6 +17,7 @@ import { import { getEnvironment, getEnvSetting, getSetting } from './db'; import { sendEventNotification } from './notifications'; import { detectRemoteSocketPath } from './scanner-socket-detect'; +import { truncateForLog, classifyUnparseableOutput } from './scanner-output-core'; import { getHostDockerSocket, getHostDataDir, @@ -546,11 +547,17 @@ function parseTrivyOutput(output: string): { vulnerabilities: Vulnerability[]; s console.error('[Trivy] Failed to parse output:', errorMsg); console.error('[Trivy] Output length:', output.length); console.error('[Trivy] First 32 bytes (hex):', Buffer.from(output.slice(0, 32)).toString('hex')); - console.error('[Trivy] Full output:', output); - // Check if output looks like an error message from trivy - const firstLine = output.split('\n')[0].trim(); - if (firstLine && !firstLine.startsWith('{')) { - throw new Error(`Scanner output error: ${firstLine}`); + // Never dump the whole report: a large truncated report is ~13 MB and this ran + // on every failed scan (#1496). Log only a bounded head + tail for diagnosis. + console.error('[Trivy] Output (head/tail):', truncateForLog(output)); + + const kind = classifyUnparseableOutput(output); + if (kind === 'truncated') { + // The head of the JSON was lost to container-log rotation the helper now disables (#1496). + throw new Error('Scanner output truncated (container log rotation?) - the JSON report was too large to read back in full'); + } + if (kind === 'cli-error') { + throw new Error(`Scanner output error: ${output.trimStart().split('\n', 1)[0].trim()}`); } throw new Error('Failed to parse scanner output - ensure CLI args include "--format json"'); } diff --git a/tests/unit/scanner-output-core.test.ts b/tests/unit/scanner-output-core.test.ts new file mode 100644 index 0000000..f4747ef --- /dev/null +++ b/tests/unit/scanner-output-core.test.ts @@ -0,0 +1,47 @@ +import { describe, test, expect } from 'bun:test'; +import { truncateForLog, classifyUnparseableOutput } from '../../src/lib/server/scanner-output-core'; + +describe('truncateForLog', () => { + test('short output passes through unchanged', () => { + const s = 'a small error line'; + expect(truncateForLog(s)).toBe(s); + }); + test('output at exactly 2*keep is unchanged', () => { + const s = 'x'.repeat(20); + expect(truncateForLog(s, 10)).toBe(s); + }); + test('large output is bounded to head + tail with an elision marker', () => { + const s = 'H'.repeat(50) + 'M'.repeat(1000) + 'T'.repeat(50); + const out = truncateForLog(s, 10); + expect(out.startsWith('HHHHHHHHHH')).toBe(true); + expect(out.endsWith('TTTTTTTTTT')).toBe(true); + expect(out).toContain('bytes elided'); + // only head(10) + tail(10) + marker - far shorter than the 1100-char input + expect(out.length).toBeLessThan(100); + // the elided count is the middle + expect(out).toContain(`[${s.length - 20} bytes elided]`); + }); +}); + +describe('classifyUnparseableOutput', () => { + test('a large buffer not starting with { is truncated (log rotation)', () => { + // mimic the #1496 fragment: starts mid-JSON (leading spaces then a value) + const frag = ' "https://ubuntu.com/security/notices/USN-1"\n' + 'x'.repeat(200_000); + expect(classifyUnparseableOutput(frag)).toBe('truncated'); + }); + test('a short non-JSON line is a CLI error message', () => { + expect(classifyUnparseableOutput('FATAL failed to download vulnerability DB')).toBe('cli-error'); + }); + test('leading whitespace before { is not misread as an error', () => { + // a genuine (if unparseable-later) JSON doc that does start with { after trim + expect(classifyUnparseableOutput(' \n { "SchemaVersion": 2 ')).toBe('unknown'); + }); + test('a large fragment that happens to start with { is not "truncated"', () => { + // only a NON-brace lead signals a lost head; a brace-led large blob is "unknown" + const big = '{' + 'x'.repeat(200_000); + expect(classifyUnparseableOutput(big)).toBe('unknown'); + }); + test('empty output is unknown, not a cli-error', () => { + expect(classifyUnparseableOutput('')).toBe('unknown'); + }); +});