large image scans no longer fail when the scanner log rotates

This commit is contained in:
Jarek Krochmalski committed 2026-09-02 15:52:44 +02:00
1 parent 9a3c52374c
commit ae754b3140
3 files changed
+89 -5

No files matched your search

+30
View File
@@ -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';
}
+12 -5
View File
@@ -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"');
}
+47
View File
@@ -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');
});
});