From 9f047fdf113a7770a05d19fc5f88b34666a56ad0 Mon Sep 17 00:00:00 2001 From: Will Bates Date: Fri, 3 Apr 2026 21:47:37 -0400 Subject: [PATCH] fix: detect compressed kernel modules (.ko.gz, .ko.xz, .ko.zst) The linux-kernel-cataloger only matched plain *.ko files, missing compressed modules produced when CONFIG_MODULE_COMPRESS is enabled (common on Debian 13 / Ubuntu 24.04+). This resulted in near-zero module packages being reported for such filesystems. Changes: - Add *.ko.gz, *.ko.xz, *.ko.zst glob patterns to both the cataloger and capabilities.yaml so the file resolver picks up compressed modules - Add decompressedModuleReader() which detects the extension and transparently decompresses via compress/gzip, ulikunitz/xz, or klauspost/compress/zstd before handing the ELF bytes to the existing parseLinuxKernelModuleMetadata parser - Promote github.com/klauspost/compress from indirect to direct dependency - Add unit tests covering all three compression formats plus the uncompressed baseline, using a programmatically generated minimal ELF Fixes #4721 Signed-off-by: Will Bates --- go.mod | 2 +- syft/pkg/cataloger/kernel/capabilities.yaml | 5 +- syft/pkg/cataloger/kernel/cataloger.go | 5 +- .../kernel/parse_linux_kernel_module_file.go | 69 ++++- .../parse_linux_kernel_module_file_test.go | 258 ++++++++++++++++++ 5 files changed, 335 insertions(+), 4 deletions(-) create mode 100644 syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go diff --git a/go.mod b/go.mod index a7e4c86d8..962075add 100644 --- a/go.mod +++ b/go.mod @@ -62,6 +62,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.7.8 github.com/jinzhu/copier v0.4.0 github.com/kastenhq/goversion v0.0.0-20230811215019-93b2f8823953 + github.com/klauspost/compress v1.18.5 github.com/magiconair/properties v1.8.10 github.com/mholt/archives v0.1.5 github.com/moby/sys/mountinfo v0.7.2 @@ -215,7 +216,6 @@ require ( github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/pgzip v1.2.6 // indirect github.com/kr/pretty v0.3.1 // indirect diff --git a/syft/pkg/cataloger/kernel/capabilities.yaml b/syft/pkg/cataloger/kernel/capabilities.yaml index 5888f515a..c6e8afe1e 100644 --- a/syft/pkg/cataloger/kernel/capabilities.yaml +++ b/syft/pkg/cataloger/kernel/capabilities.yaml @@ -4,7 +4,7 @@ configs: # AUTO-GENERATED - config structs and their fields kernel.LinuxKernelCatalogerConfig: fields: - key: CatalogModules - description: CatalogModules enables cataloging linux kernel modules (`*.ko` files) in addition to the kernel itself. + description: CatalogModules enables cataloging linux kernel modules (`*.ko` and compressed `*.ko.gz`, `*.ko.xz`, `*.ko.zst` files) in addition to the kernel itself. app_key: linux-kernel.catalog-modules catalogers: - ecosystem: linux # MANUAL @@ -36,6 +36,9 @@ catalogers: - '**/zImage' - '**/zImage-*' - '**/lib/modules/**/*.ko' + - '**/lib/modules/**/*.ko.gz' + - '**/lib/modules/**/*.ko.xz' + - '**/lib/modules/**/*.ko.zst' metadata_types: # AUTO-GENERATED - pkg.LinuxKernel - pkg.LinuxKernelModule diff --git a/syft/pkg/cataloger/kernel/cataloger.go b/syft/pkg/cataloger/kernel/cataloger.go index c9ddcb4ec..4ea830575 100644 --- a/syft/pkg/cataloger/kernel/cataloger.go +++ b/syft/pkg/cataloger/kernel/cataloger.go @@ -17,7 +17,7 @@ import ( var _ pkg.Cataloger = (*linuxKernelCataloger)(nil) type LinuxKernelCatalogerConfig struct { - // CatalogModules enables cataloging linux kernel modules (`*.ko` files) in addition to the kernel itself. + // CatalogModules enables cataloging linux kernel modules (`*.ko` and compressed `*.ko.gz`, `*.ko.xz`, `*.ko.zst` files) in addition to the kernel itself. // app-config: linux-kernel.catalog-modules CatalogModules bool `yaml:"catalog-modules" json:"catalog-modules" mapstructure:"catalog-modules"` } @@ -47,6 +47,9 @@ var kernelArchiveGlobs = []string{ var kernelModuleGlobs = []string{ "**/lib/modules/**/*.ko", + "**/lib/modules/**/*.ko.gz", + "**/lib/modules/**/*.ko.xz", + "**/lib/modules/**/*.ko.zst", } // NewLinuxKernelCataloger returns a new kernel files cataloger object. 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 3e3e061e8..ad12147f3 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file.go @@ -1,11 +1,17 @@ package kernel import ( + "bytes" + "compress/gzip" "context" "debug/elf" "fmt" + "io" "strings" + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" + "github.com/anchore/syft/syft/artifact" "github.com/anchore/syft/syft/file" "github.com/anchore/syft/syft/internal/unionreader" @@ -20,7 +26,13 @@ func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic if err != nil { return nil, nil, fmt.Errorf("unable to get union reader for file: %w", err) } - metadata, err := parseLinuxKernelModuleMetadata(unionReader) + + moduleReader, err := decompressedModuleReader(reader.RealPath, unionReader) + if err != nil { + return nil, nil, fmt.Errorf("unable to decompress kernel module %q: %w", reader.RealPath, err) + } + + metadata, err := parseLinuxKernelModuleMetadata(moduleReader) if err != nil { return nil, nil, fmt.Errorf("unable to parse kernel module metadata: %w", err) } @@ -39,6 +51,61 @@ func parseLinuxKernelModuleFile(ctx context.Context, _ file.Resolver, _ *generic }, nil, nil } +// decompressedModuleReader returns a UnionReader over the decompressed contents of the kernel module +// if the path indicates it is compressed (.ko.gz, .ko.xz, .ko.zst). For plain .ko files, the +// original reader is returned unchanged. +func decompressedModuleReader(path string, r unionreader.UnionReader) (unionreader.UnionReader, error) { + var decompressed []byte + + switch { + case strings.HasSuffix(path, ".ko.gz"): + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("unable to create gzip reader: %w", err) + } + defer gz.Close() + decompressed, err = io.ReadAll(gz) + if err != nil { + return nil, fmt.Errorf("unable to decompress gzip stream: %w", err) + } + + case strings.HasSuffix(path, ".ko.xz"): + xzr, err := xz.NewReader(r) + if err != nil { + return nil, fmt.Errorf("unable to create xz reader: %w", err) + } + decompressed, err = io.ReadAll(xzr) + if err != nil { + return nil, fmt.Errorf("unable to decompress xz stream: %w", err) + } + + case strings.HasSuffix(path, ".ko.zst"): + zstdr, err := zstd.NewReader(r) + if err != nil { + return nil, fmt.Errorf("unable to create zstd reader: %w", err) + } + defer zstdr.Close() + decompressed, err = io.ReadAll(zstdr) + if err != nil { + return nil, fmt.Errorf("unable to decompress zstd stream: %w", err) + } + + default: + return r, nil + } + + br := bytes.NewReader(decompressed) + return struct { + io.ReadCloser + io.ReaderAt + io.Seeker + }{ + ReadCloser: io.NopCloser(br), + ReaderAt: br, + Seeker: br, + }, nil +} + func parseLinuxKernelModuleMetadata(r unionreader.UnionReader) (p *pkg.LinuxKernelModule, err error) { // filename: /lib/modules/5.15.0-1031-aws/kernel/zfs/zzstd.ko // version: 1.4.5a 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 new file mode 100644 index 000000000..1e9c6d61f --- /dev/null +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_module_file_test.go @@ -0,0 +1,258 @@ +package kernel + +import ( + "bytes" + "compress/gzip" + "context" + "encoding/binary" + "io" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ulikunitz/xz" + + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +// minimalKOBytes constructs a minimal ELF64 LE relocatable object with a .modinfo +// section containing the given null-terminated key=value entries. +func minimalKOBytes(entries []string) []byte { + // Build .modinfo section data: each entry is key=value\0 + var modinfo []byte + for _, e := range entries { + modinfo = append(modinfo, []byte(e)...) + modinfo = append(modinfo, 0) + } + + // Section name string table: \0 .modinfo\0 .shstrtab\0 + shstrtab := []byte("\x00.modinfo\x00.shstrtab\x00") + modinfoNameOff := uint32(1) // offset of ".modinfo" in shstrtab + shstrtabNameOff := uint32(10) // offset of ".shstrtab" in shstrtab + + // ELF64 header is 64 bytes. + // We have 3 sections: null, .modinfo, .shstrtab + const ( + elfHeaderSize = 64 + sectionHdrSize = 64 + numSections = 3 + ) + + modinfoOff := uint64(elfHeaderSize) + modinfoSize := uint64(len(modinfo)) + + shstrtabOff := modinfoOff + modinfoSize + shstrtabSize := uint64(len(shstrtab)) + + // Align section header table to 8 bytes + shdrsOff := shstrtabOff + shstrtabSize + if shdrsOff%8 != 0 { + shdrsOff += 8 - (shdrsOff % 8) + } + + buf := new(bytes.Buffer) + le := binary.LittleEndian + + // ELF header + buf.Write([]byte{0x7f, 'E', 'L', 'F'}) // magic + buf.WriteByte(2) // EI_CLASS: ELFCLASS64 + buf.WriteByte(1) // EI_DATA: ELFDATA2LSB + buf.WriteByte(1) // EI_VERSION: EV_CURRENT + buf.WriteByte(0) // EI_OSABI + buf.Write(make([]byte, 8)) // EI_ABIVERSION + padding + + writeU16 := func(v uint16) { binary.Write(buf, le, v) } //nolint:errcheck + writeU32 := func(v uint32) { binary.Write(buf, le, v) } //nolint:errcheck + writeU64 := func(v uint64) { binary.Write(buf, le, v) } //nolint:errcheck + + writeU16(1) // e_type: ET_REL + writeU16(62) // e_machine: EM_X86_64 + writeU32(1) // e_version: EV_CURRENT + writeU64(0) // e_entry + writeU64(0) // e_phoff (no program headers) + writeU64(shdrsOff) // e_shoff + writeU32(0) // e_flags + writeU16(elfHeaderSize) // e_ehsize + writeU16(0) // e_phentsize + writeU16(0) // e_phnum + writeU16(sectionHdrSize) // e_shentsize + writeU16(numSections) // e_shnum + writeU16(numSections - 1) // e_shstrndx (.shstrtab is last) + + // Write section data + buf.Write(modinfo) + buf.Write(shstrtab) + + // Pad to shdrsOff + for uint64(buf.Len()) < shdrsOff { + buf.WriteByte(0) + } + + // Section header 0: null + buf.Write(make([]byte, sectionHdrSize)) + + // Section header 1: .modinfo (SHT_PROGBITS=1) + writeU32(modinfoNameOff) // sh_name + writeU32(1) // sh_type: SHT_PROGBITS + writeU64(0) // sh_flags + writeU64(0) // sh_addr + writeU64(modinfoOff) // sh_offset + writeU64(modinfoSize) // sh_size + writeU32(0) // sh_link + writeU32(0) // sh_info + writeU64(1) // sh_addralign + writeU64(0) // sh_entsize + + // Section header 2: .shstrtab (SHT_STRTAB=3) + writeU32(shstrtabNameOff) // sh_name + writeU32(3) // sh_type: SHT_STRTAB + writeU64(0) // sh_flags + writeU64(0) // sh_addr + writeU64(shstrtabOff) // sh_offset + writeU64(shstrtabSize) // sh_size + writeU32(0) // sh_link + writeU32(0) // sh_info + writeU64(1) // sh_addralign + writeU64(0) // sh_entsize + + return buf.Bytes() +} + +func gzCompress(data []byte) []byte { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func xzCompress(data []byte) []byte { + var buf bytes.Buffer + w, _ := xz.NewWriter(&buf) + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +func zstCompress(data []byte) []byte { + var buf bytes.Buffer + w, _ := zstd.NewWriter(&buf) + _, _ = w.Write(data) + _ = w.Close() + return buf.Bytes() +} + +// makeLocationReadCloser wraps a byte slice as a file.LocationReadCloser with the given path. +func makeLocationReadCloser(path string, data []byte) file.LocationReadCloser { + return file.LocationReadCloser{ + Location: file.NewVirtualLocation(path, path), + ReadCloser: io.NopCloser(bytes.NewReader(data)), + } +} + +func TestParseLinuxKernelModuleFile_Compressed(t *testing.T) { + modinfo := []string{ + "name=dummy_mod", + "version=1.2.3", + "vermagic=6.1.0-rc1 SMP mod_unload", + "license=GPL v2", + } + koBytes := minimalKOBytes(modinfo) + + tests := []struct { + name string + path string + data []byte + wantName string + wantVer string + wantKV string // expected KernelVersion from vermagic + }{ + { + name: "uncompressed .ko", + path: "/lib/modules/6.1.0-rc1/kernel/dummy_mod.ko", + data: koBytes, + wantName: "dummy_mod", + wantVer: "1.2.3", + wantKV: "6.1.0-rc1", + }, + { + name: "gzip-compressed .ko.gz", + path: "/lib/modules/6.1.0-rc1/kernel/dummy_mod.ko.gz", + data: gzCompress(koBytes), + wantName: "dummy_mod", + wantVer: "1.2.3", + wantKV: "6.1.0-rc1", + }, + { + name: "xz-compressed .ko.xz", + path: "/lib/modules/6.1.0-rc1/kernel/dummy_mod.ko.xz", + data: xzCompress(koBytes), + wantName: "dummy_mod", + wantVer: "1.2.3", + wantKV: "6.1.0-rc1", + }, + { + name: "zstd-compressed .ko.zst", + path: "/lib/modules/6.1.0-rc1/kernel/dummy_mod.ko.zst", + data: zstCompress(koBytes), + wantName: "dummy_mod", + wantVer: "1.2.3", + wantKV: "6.1.0-rc1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := makeLocationReadCloser(tt.path, tt.data) + pkgs, rels, err := parseLinuxKernelModuleFile(context.Background(), nil, &generic.Environment{}, reader) + require.NoError(t, err) + require.Len(t, pkgs, 1) + assert.Empty(t, rels) + assert.Equal(t, tt.wantName, pkgs[0].Name) + assert.Equal(t, tt.wantVer, pkgs[0].Version) + + meta, ok := pkgs[0].Metadata.(pkg.LinuxKernelModule) + require.True(t, ok) + assert.Equal(t, tt.wantKV, meta.KernelVersion) + }) + } +} + +func TestDecompressedModuleReader(t *testing.T) { + koBytes := minimalKOBytes([]string{"name=test", "vermagic=5.15.0 SMP mod_unload"}) + + tests := []struct { + name string + path string + data []byte + }{ + {"uncompressed", "/lib/modules/5.15.0/kernel/test.ko", koBytes}, + {"gz", "/lib/modules/5.15.0/kernel/test.ko.gz", gzCompress(koBytes)}, + {"xz", "/lib/modules/5.15.0/kernel/test.ko.xz", xzCompress(koBytes)}, + {"zst", "/lib/modules/5.15.0/kernel/test.ko.zst", zstCompress(koBytes)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wrapped := struct { + io.ReadCloser + io.ReaderAt + io.Seeker + }{ + ReadCloser: io.NopCloser(bytes.NewReader(tt.data)), + ReaderAt: bytes.NewReader(tt.data), + Seeker: bytes.NewReader(tt.data), + } + got, err := decompressedModuleReader(tt.path, wrapped) + require.NoError(t, err) + require.NotNil(t, got) + + b, err := io.ReadAll(got) + require.NoError(t, err) + assert.Equal(t, koBytes, b, "decompressed bytes should match original .ko bytes") + }) + } +}