diff --git a/syft/pkg/cataloger/arch/parse_alpm_db.go b/syft/pkg/cataloger/arch/parse_alpm_db.go index 6e409824b..1d4066c23 100644 --- a/syft/pkg/cataloger/arch/parse_alpm_db.go +++ b/syft/pkg/cataloger/arch/parse_alpm_db.go @@ -2,6 +2,7 @@ package arch import ( "bufio" + "bytes" "compress/gzip" "context" "fmt" @@ -277,15 +278,31 @@ func parsePkgFiles(pkgFields map[string]any) (*parsedData, error) { return &entry, nil } +// maxMtreeSize bounds the decompressed mtree listing. An mtree names every file in a package along +// with its digests, so even a very large package lands well under this. The cap is here because gzip +// reaches roughly 1032:1, so without it a small crafted member expands until the process dies, and +// mtree.ParseSpec materializes every entry before returning any of them. +const maxMtreeSize = 64 * 1024 * 1024 + func parseMtree(r io.Reader) ([]pkg.AlpmFileRecord, error) { - var err error var entries []pkg.AlpmFileRecord - r, err = gzip.NewReader(r) + gzReader, err := gzip.NewReader(r) if err != nil { return nil, err } - specDh, err := mtree.ParseSpec(r) + + // read one byte past the cap so that hitting it is distinguishable from a listing that simply ends + // there. Truncating instead would hand back a package silently missing most of its files. + data, err := io.ReadAll(io.LimitReader(gzReader, maxMtreeSize+1)) + if err != nil { + return nil, err + } + if len(data) > maxMtreeSize { + return nil, fmt.Errorf("mtree file is larger than the max allowed size (%d bytes)", maxMtreeSize) + } + + specDh, err := mtree.ParseSpec(bytes.NewReader(data)) if err != nil { return nil, err } diff --git a/syft/pkg/cataloger/arch/parse_alpm_db_test.go b/syft/pkg/cataloger/arch/parse_alpm_db_test.go index a046fff88..be4fc0d65 100644 --- a/syft/pkg/cataloger/arch/parse_alpm_db_test.go +++ b/syft/pkg/cataloger/arch/parse_alpm_db_test.go @@ -2,6 +2,9 @@ package arch import ( "bufio" + "bytes" + "compress/gzip" + "io" "os" "testing" "time" @@ -217,3 +220,48 @@ func TestMtreeParse(t *testing.T) { } } + +// gzipOfSize returns a gzip member that decompresses to exactly n bytes. The payload is +// highly compressible, which is the whole point: the caller supplies kilobytes and the +// decompressed stream is whatever size it asks for. +func gzipOfSize(t *testing.T, n int64) io.Reader { + t.Helper() + + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + chunk := make([]byte, 32*1024) + for remaining := n; remaining > 0; { + size := int64(len(chunk)) + if remaining < size { + size = remaining + } + written, err := w.Write(chunk[:size]) + require.NoError(t, err) + remaining -= int64(written) + } + require.NoError(t, w.Close()) + + return bytes.NewReader(buf.Bytes()) +} + +func Test_parseMtree_boundsDecompressedSize(t *testing.T) { + t.Run("rejects a listing past the cap", func(t *testing.T) { + r := gzipOfSize(t, maxMtreeSize+1) + + _, err := parseMtree(r) + + require.ErrorContains(t, err, "larger than the max allowed size") + }) + + t.Run("a listing at the cap is not rejected on size", func(t *testing.T) { + // guards the off-by-one: at exactly the cap the size check must not fire, so whatever + // happens next is the mtree parser's business and not ours + r := gzipOfSize(t, maxMtreeSize) + + _, err := parseMtree(r) + + if err != nil { + require.NotContains(t, err.Error(), "larger than the max allowed size") + } + }) +}