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 <william.bates11@outlook.com>
This commit is contained in:
Will Bates 2026-04-03 21:47:37 -04:00 committed by Alex Goodman
parent 07ae2ca08d
commit 9f047fdf11
No known key found for this signature in database
5 changed files with 335 additions and 4 deletions

2
go.mod
View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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")
})
}
}