feat(parser): bound single-file gzip by ratio guard instead of fixed byte cap

Plain gzipped logs (nvidia-bug-report-*.log.gz) routinely exceed the old
50MB decompression cap, which silently dropped the tail. Replace it with a
decompression-ratio bomb guard plus a 1GB absolute memory ceiling.

See ADL-054.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-27 17:05:33 +03:00
co-authored by Claude Sonnet 5
parent 632067fef5
commit 9d701885da
2 changed files with 141 additions and 15 deletions
+65 -15
View File
@@ -18,7 +18,15 @@ import (
const maxSingleFileSize = 10 * 1024 * 1024
const maxSingleFileSizeLarge = 1024 * 1024 * 1024
const maxZipArchiveSize = 50 * 1024 * 1024
const maxGzipDecompressedSize = 50 * 1024 * 1024
// A plain gzipped log file (e.g. nvidia-bug-report-*.log.gz) is decompressed
// fully into memory, but without a fixed byte cap — real bug-report dumps
// routinely exceed 50MB and a hard cap silently drops the tail. Instead it's
// bounded by a decompression-ratio guard (catches gzip bombs, which achieve
// enormous ratios) plus a generous absolute ceiling as a memory safety net.
const gzipBombRatio = 300 // decompressed:compressed ratio that trips the bomb guard
const gzipBombMinCompressed = 4096 // don't evaluate the ratio until this many compressed bytes have actually been consumed, to avoid false positives from gzip header/buffering overhead on tiny inputs
const gzipAbsoluteCeiling = 1024 * 1024 * 1024 // 1GB hard safety net regardless of ratio, matches maxSingleFileSizeLarge
var supportedArchiveExt = map[string]struct{}{
@@ -167,7 +175,8 @@ func extractTarFromReader(r io.Reader) ([]ExtractedFile, error) {
}
func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, error) {
gzr, err := gzip.NewReader(r)
compressed := &countingReader{r: r}
gzr, err := gzip.NewReader(compressed)
if err != nil {
return nil, fmt.Errorf("gzip reader: %w", err)
}
@@ -192,15 +201,12 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
return extractTarEntriesWithLimit(buf, maxSingleFileSizeLarge)
}
// Not a tar archive - treat as a single gzipped file. A hard byte cap
// is safe here since there's no container structure to corrupt.
decompressed, err := io.ReadAll(io.LimitReader(buf, maxGzipDecompressedSize+1))
// Not a tar archive - treat as a single gzipped file. No fixed byte cap:
// stream the decompression and only stop early on a runaway compression
// ratio (gzip bomb) or the absolute memory safety net.
decompressed, truncMsg, err := readGzipWithBombGuard(buf, compressed)
if err != nil {
return nil, fmt.Errorf("read gzip content: %w", err)
}
gzipTruncated := len(decompressed) > maxGzipDecompressedSize
if gzipTruncated {
decompressed = decompressed[:maxGzipDecompressedSize]
return nil, err
}
baseName := strings.TrimSuffix(filename, ".gz")
@@ -213,17 +219,61 @@ func extractTarGzFromReader(r io.Reader, filename string) ([]ExtractedFile, erro
Content: decompressed,
ModTime: gzr.ModTime,
}
if gzipTruncated {
if truncMsg != "" {
file.Truncated = true
file.TruncatedMessage = fmt.Sprintf(
"decompressed gzip content exceeded %d bytes and was truncated",
maxGzipDecompressedSize,
)
file.TruncatedMessage = truncMsg
}
return []ExtractedFile{file}, nil
}
// countingReader tracks the number of bytes read from the underlying reader.
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
// readGzipWithBombGuard streams dec (a gzip decompression stream) to
// completion, tracking bytes consumed from the compressed source via
// compressed. It aborts early — returning what was decompressed so far plus
// a non-empty reason — if the decompression ratio suggests a gzip bomb, or
// if the absolute ceiling is hit regardless of ratio.
func readGzipWithBombGuard(dec io.Reader, compressed *countingReader) ([]byte, string, error) {
var out bytes.Buffer
chunk := make([]byte, 256*1024)
for {
n, readErr := dec.Read(chunk)
if n > 0 {
out.Write(chunk[:n])
if int64(out.Len()) > gzipAbsoluteCeiling {
return out.Bytes(), fmt.Sprintf(
"decompressed gzip content exceeded the %d byte safety ceiling and was truncated",
gzipAbsoluteCeiling,
), nil
}
if compressed.n >= gzipBombMinCompressed && int64(out.Len()) > compressed.n*gzipBombRatio {
return out.Bytes(), fmt.Sprintf(
"decompression ratio exceeded %dx (likely a gzip bomb) and was truncated after %d bytes",
gzipBombRatio, out.Len(),
), nil
}
}
if readErr == io.EOF {
return out.Bytes(), "", nil
}
if readErr != nil {
return nil, "", fmt.Errorf("read gzip content: %w", readErr)
}
}
}
// extractTarEntriesWithLimit streams tar entries from r, skipping individual
// files over 10MB, and stops once the cumulative extracted content reaches
// totalLimit — always at an entry boundary, never mid-file.