diff --git a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go index ab7f7860e..de92128a2 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go @@ -22,6 +22,12 @@ import ( const modinfoName = ".modinfo" +// maxDecompressedModuleSize bounds what a compressed kernel module may expand to. The compressed size +// is bounded by the file in the image, what it expands to is not, and it lands in a temp file, so the +// exposure is disk. Real modules are tens of MB. This bounds one spill, not the process: peak disk is +// this times cataloger parallelism (NumCPU*4 by default, unbounded when --parallelism is negative). +const maxDecompressedModuleSize = 256 * 1024 * 1024 + func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic.Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) { unionReader, err := unionreader.GetUnionReader(reader) if err != nil { @@ -61,6 +67,11 @@ func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic // the returned reader and must Close it; the underlying reader (r) is not closed by Close on the // passthrough path — its lifecycle is the caller's. func decompressedModuleReader(ctx context.Context, path string, r unionreader.UnionReader) (unionreader.UnionReader, error) { + return decompressedModuleReaderWithLimit(ctx, path, r, maxDecompressedModuleSize) +} + +// decompressedModuleReaderWithLimit takes the bound as a parameter so tests can exercise it cheaply. +func decompressedModuleReaderWithLimit(ctx context.Context, path string, r unionreader.UnionReader, maxDecompressed int64) (unionreader.UnionReader, error) { // fast path: plain .ko files don't need format sniffing if strings.HasSuffix(path, ".ko") { return &nopCloseUnionReader{UnionReader: r}, nil @@ -97,10 +108,17 @@ func decompressedModuleReader(ctx context.Context, path string, r unionreader.Un } tfr := &tempFileUnionReader{File: tempFile, cleanup: fileCleanup} - if _, err := io.Copy(tempFile, rc); err != nil { + // read one past the cap so hitting it is distinguishable from a module that ends there; truncating + // would hand the parser a partial ELF, which reads as corrupt rather than rejected. + written, err := io.Copy(tempFile, io.LimitReader(rc, maxDecompressed+1)) + if err != nil { _ = tfr.Close() return nil, fmt.Errorf("unable to write decompressed kernel module: %w", err) } + if written > maxDecompressed { + _ = tfr.Close() + return nil, fmt.Errorf("decompressed kernel module is larger than the max allowed size (%d bytes)", maxDecompressed) + } if _, err := tempFile.Seek(0, io.SeekStart); err != nil { _ = tfr.Close() return nil, fmt.Errorf("unable to rewind decompressed kernel module: %w", err) diff --git a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go index cb99485bc..763feed1d 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go @@ -17,6 +17,7 @@ import ( "github.com/anchore/syft/internal/tmpdir" "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/internal/unionreader" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" ) @@ -253,6 +254,58 @@ func TestDecompressedModuleReader(t *testing.T) { } } +// wrapUnionReader adapts a byte slice to the reader set decompressedModuleReader takes. +func wrapUnionReader(data []byte) unionreader.UnionReader { + br := bytes.NewReader(data) + return struct { + io.ReadCloser + io.ReaderAt + io.Seeker + }{ + ReadCloser: io.NopCloser(br), + ReaderAt: br, + Seeker: br, + } +} + +func TestDecompressedModuleReader_boundsDecompressedSize(t *testing.T) { + koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP mod_unload"}) + compressed := gzCompress(koBytes) + + t.Run("rejects a module that expands past the cap", func(t *testing.T) { + _, err := decompressedModuleReaderWithLimit(testContext(t), "/test.ko.gz", + wrapUnionReader(compressed), int64(len(koBytes)-1)) + + require.ErrorContains(t, err, "larger than the max allowed size") + }) + + t.Run("a module at exactly the cap is accepted whole", func(t *testing.T) { + // the byte-for-byte assert is the point: a "did not error" check would miss a truncating read + got, err := decompressedModuleReaderWithLimit(testContext(t), "/test.ko.gz", + wrapUnionReader(compressed), int64(len(koBytes))) + require.NoError(t, err) + t.Cleanup(func() { _ = got.Close() }) + + b, err := io.ReadAll(got) + require.NoError(t, err) + assert.Equal(t, koBytes, b, "at-cap module must come back byte for byte") + }) + + t.Run("the partial spill is cleaned up when the cap is hit", func(t *testing.T) { + // otherwise repeated hostile modules exhaust the disk anyway, which is what the cap prevents + dir := t.TempDir() + ctx := tmpdir.WithValue(context.Background(), tmpdir.FromPath(dir)) + + _, err := decompressedModuleReaderWithLimit(ctx, "/test.ko.gz", + wrapUnionReader(compressed), int64(len(koBytes)-1)) + require.Error(t, err) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries, "temp spill should be removed when the module is rejected") + }) +} + func TestDecompressedModuleReader_TempFileRemovedOnClose(t *testing.T) { koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP"}) data := gzCompress(koBytes)