diff --git a/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go new file mode 100644 index 00000000000..d75bc64b1d9 --- /dev/null +++ b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file.go @@ -0,0 +1,176 @@ +package kernel + +import ( + "bytes" + "compress/bzip2" + "compress/gzip" + "io" + "strings" + + "github.com/klauspost/compress/zstd" + "github.com/ulikunitz/xz" + + "github.com/anchore/syft/syft/internal/unionreader" + "github.com/anchore/syft/syft/pkg" +) + +const ( + // arm64ImageMagicOffset is the offset of the arm64 Linux kernel image magic + // (0x644d5241, "ARM\x64") in the image header; see + // Documentation/arch/arm64/booting.rst in the kernel source tree. + arm64ImageMagicOffset = 0x38 + + // maxARM64KernelFileSize bounds how much of a candidate kernel file is read + // while looking for a wrapped arm64 image (distro vmlinuz files are far smaller). + maxARM64KernelFileSize = 64 << 20 + + // maxARM64KernelDecompressedSize bounds decompression of a candidate payload + // as a safeguard against decompression bombs. + maxARM64KernelDecompressedSize = 256 << 20 +) + +var ( + arm64ImageMagic = []byte{'A', 'R', 'M', 0x64} + linuxVersionMarker = []byte("Linux version ") +) + +// kernelCompressionFormat pairs a compression stream magic with a decoder, used +// when searching for an arm64 kernel image wrapped inside another binary (e.g. +// EFI zboot), mirroring the approach of scripts/extract-vmlinux from the kernel +// source tree. Only formats with decoders already vendored by syft are covered. +type kernelCompressionFormat struct { + magic []byte + decompress func([]byte) []byte +} + +var kernelCompressionFormats = []kernelCompressionFormat{ + {[]byte{0x1f, 0x8b, 0x08}, gunzipBytes}, // gzip + {[]byte{0x28, 0xb5, 0x2f, 0xfd}, unzstdBytes}, // zstd + {[]byte{0xfd, '7', 'z', 'X', 'Z', 0x00}, unxzBytes}, // xz + {[]byte{'B', 'Z', 'h'}, bunzip2Bytes}, // bzip2 +} + +// parseARM64LinuxKernelImage detects arm64 kernel images, which deitch/magic +// does not recognize (its "Linux kernel" test keys off x86-specific bytes). +// arm64 distro kernels are shipped as a raw Image, a whole-file compressed +// Image (e.g. Ubuntu), or an EFI zboot PE binary wrapping a compressed Image +// (Amazon Linux, RHEL). +func parseARM64LinuxKernelImage(ur unionreader.UnionReader) (p pkg.LinuxKernel, ok bool) { + if _, err := ur.Seek(0, io.SeekStart); err != nil { + return p, false + } + data, err := io.ReadAll(io.LimitReader(ur, maxARM64KernelFileSize)) + if err != nil { + return p, false + } + payload := findARM64ImagePayload(data) + if payload == nil { + return p, false + } + version, extendedVersion, found := parseLinuxVersionBanner(payload) + if !found { + return p, false + } + return pkg.LinuxKernel{ + Architecture: "arm64", + Version: version, + ExtendedVersion: extendedVersion, + Format: "Image", + }, true +} + +// findARM64ImagePayload returns the arm64 kernel image contained in data: data +// itself when it is a raw image, or the first embedded compression stream that +// decompresses to an arm64 image. +func findARM64ImagePayload(data []byte) []byte { + if isARM64Image(data) { + return data + } + for _, format := range kernelCompressionFormats { + if payload := findCompressedARM64Image(data, format); payload != nil { + return payload + } + } + return nil +} + +func findCompressedARM64Image(data []byte, format kernelCompressionFormat) []byte { + offset := 0 + for offset < len(data) { + idx := bytes.Index(data[offset:], format.magic) + if idx < 0 { + return nil + } + offset += idx + if payload := format.decompress(data[offset:]); isARM64Image(payload) { + return payload + } + offset++ + } + return nil +} + +// isARM64Image reports whether b starts with an arm64 kernel image header. +func isARM64Image(b []byte) bool { + return len(b) > arm64ImageMagicOffset+len(arm64ImageMagic) && + bytes.Equal(b[arm64ImageMagicOffset:arm64ImageMagicOffset+len(arm64ImageMagic)], arm64ImageMagic) +} + +// parseLinuxVersionBanner extracts the version from the "Linux version ..." +// banner string embedded in every kernel image (linux_banner in init/version.c). +func parseLinuxVersionBanner(payload []byte) (version, extendedVersion string, ok bool) { + idx := bytes.Index(payload, linuxVersionMarker) + if idx < 0 { + return "", "", false + } + rest := payload[idx+len(linuxVersionMarker):] + if end := bytes.IndexAny(rest, "\x00\n\r"); end >= 0 { + rest = rest[:end] + } + extendedVersion = strings.TrimSpace(string(rest)) + fields := strings.Fields(extendedVersion) + if len(fields) == 0 { + return "", "", false + } + return fields[0], extendedVersion, true +} + +func gunzipBytes(data []byte) []byte { + r, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + return drainBounded(r) +} + +func unzstdBytes(data []byte) []byte { + r, err := zstd.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + defer r.Close() + return drainBounded(r) +} + +func unxzBytes(data []byte) []byte { + r, err := xz.NewReader(bytes.NewReader(data)) + if err != nil { + return nil + } + return drainBounded(r) +} + +func bunzip2Bytes(data []byte) []byte { + return drainBounded(bzip2.NewReader(bytes.NewReader(data))) +} + +// drainBounded reads up to maxARM64KernelDecompressedSize bytes, tolerating a +// trailing error after a valid stream prefix (wrapped payloads are commonly +// followed by unrelated bytes). +func drainBounded(r io.Reader) []byte { + data, _ := io.ReadAll(io.LimitReader(r, maxARM64KernelDecompressedSize)) + if len(data) == 0 { + return nil + } + return data +} diff --git a/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go new file mode 100644 index 00000000000..d99576d1163 --- /dev/null +++ b/syft/pkg/cataloger/kernel/parse_arm64_linux_kernel_file_test.go @@ -0,0 +1,223 @@ +package kernel + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/anchore/syft/syft/cpe" + "github.com/anchore/syft/syft/pkg" + "github.com/anchore/syft/syft/pkg/cataloger/generic" +) + +// spec-level constants, encoded here independently of the implementation: +// the arm64 kernel image header carries the u32 magic 0x644d5241 ("ARM\x64") +// at offset 0x38 (see Documentation/arch/arm64/booting.rst in the kernel tree). +const arm64SpecMagicOffset = 0x38 + +var arm64SpecMagic = []byte{'A', 'R', 'M', 0x64} + +// escapeCPEVersion mirrors the escaping createLinuxKernelCPEs applies when +// binding attributes to a formatted string (backslash before CPE punctuation). +func escapeCPEVersion(version string) string { + const cpePunctuation = "-!\"#$%&'()+,./:;<=>@[]^`{|}~" + var sb strings.Builder + for _, c := range version { + if strings.ContainsRune(cpePunctuation, c) { + sb.WriteRune('\\') + } + sb.WriteRune(c) + } + return sb.String() +} + +// arm64ImageBytes constructs a minimal synthetic arm64 kernel image: a header +// carrying the "ARM\x64" magic at offset 0x38 followed by a linux_banner-style +// version string embedded in the payload. +func arm64ImageBytes(banner string) []byte { + b := make([]byte, 0x400) + copy(b[arm64SpecMagicOffset:], arm64SpecMagic) + b = append(b, "Linux version "+banner...) + b = append(b, 0) + return b +} + +// zbootWrap mimics an EFI zboot binary: a PE32+-looking prefix (MZ magic, +// e_lfanew, PE signature) with the compressed kernel image embedded at a +// non-zero offset, like Amazon Linux / RHEL arm64 vmlinuz files. +func zbootWrap(compressed []byte) []byte { + prefix := make([]byte, 0x400) + copy(prefix, []byte{'M', 'Z'}) + binary.LittleEndian.PutUint32(prefix[0x3c:], 0x80) + copy(prefix[0x80:], []byte{'P', 'E', 0, 0}) + out := make([]byte, 0, len(prefix)+len(compressed)+4) + out = append(out, prefix...) + out = append(out, compressed...) + // trailing junk after the stream must be tolerated (PE overlay data) + return append(out, 0xde, 0xad, 0xbe, 0xef) +} + +// x86BzImageBytes constructs a minimal synthetic x86 bzImage that deitch/magic +// recognizes as a Linux kernel, guarding that the x86 path still takes +// priority over the arm64 fallback. +func x86BzImageBytes(version string) []byte { + b := make([]byte, 0x300) + binary.LittleEndian.PutUint16(b[510:], 0xAA55) // boot_flag + copy(b[514:], []byte("HdrS")) // header magic + binary.LittleEndian.PutUint16(b[518:], 0x0202) // header version > 0x1ff + binary.LittleEndian.PutUint16(b[526:], 0x40) // version string offset (0x40 + 0x200 = 0x240) + b[529] = 1 // loadflags: LOADED_HIGH => bzImage (not zImage) + copy(b[0x240:], version) + return b +} + +func TestParseLinuxKernelFile_arm64(t *testing.T) { + const amznBanner = "6.18.38-76.139.amzn2023.aarch64 (mockbuild@ip-10-0-48-196.ec2.internal) (gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5)) #1 SMP PREEMPT_DYNAMIC Thu Jan 15 11:03:06 UTC 2026" + const ubuntuBanner = "7.0.0-1006-aws (buildd@bos03-arm64-012) (aarch64-linux-gnu-gcc-13 (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0) #6-Ubuntu SMP Fri Jan 30 11:21:07 UTC 2026" + + tests := []struct { + name string + data []byte + wantVer string + wantExt string + }{ + { + name: "raw arm64 image", + data: arm64ImageBytes(amznBanner), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "whole-file gzip stream (ubuntu arm64 vmlinuz)", + data: gzCompress(arm64ImageBytes(ubuntuBanner)), + wantVer: "7.0.0-1006-aws", + wantExt: ubuntuBanner, + }, + { + name: "gzip payload inside efi zboot (amazon/rhel arm64 vmlinuz)", + data: zbootWrap(gzCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "zstd payload inside efi zboot", + data: zbootWrap(zstCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + { + name: "xz payload inside efi zboot", + data: zbootWrap(xzCompress(arm64ImageBytes(amznBanner))), + wantVer: "6.18.38-76.139.amzn2023.aarch64", + wantExt: amznBanner, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", tt.data) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + require.Len(t, pkgs, 1) + assert.Empty(t, rels) + + p := pkgs[0] + assert.Equal(t, "linux-kernel", p.Name) + assert.Equal(t, tt.wantVer, p.Version) + assert.Equal(t, pkg.LinuxKernelPkg, p.Type) + assert.Equal(t, "pkg:generic/linux-kernel@"+tt.wantVer, p.PURL) + // createLinuxKernelCPEs escapes CPE punctuation in the version (e.g. + // "-" -> "\-"), so the expected CPE must use the escaped form — the raw + // version with hyphens is not parseable as a formatted string. + require.Len(t, p.CPEs, 1) + assert.Equal(t, cpe.Must("cpe:2.3:o:linux:linux_kernel:"+escapeCPEVersion(tt.wantVer)+":*:*:*:*:*:*:*", cpe.NVDDictionaryLookupSource), p.CPEs[0]) + + meta, ok := p.Metadata.(pkg.LinuxKernel) + require.True(t, ok) + assert.Equal(t, "arm64", meta.Architecture) + assert.Equal(t, "Image", meta.Format) + assert.Equal(t, tt.wantVer, meta.Version) + assert.Equal(t, tt.wantExt, meta.ExtendedVersion) + }) + } +} + +func TestParseLinuxKernelFile_arm64_Negative(t *testing.T) { + t.Run("garbage file", func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", bytes.Repeat([]byte{0x5a}, 0x1000)) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + assert.Empty(t, pkgs) + assert.Empty(t, rels) + }) + + t.Run("arm64 image without version banner", func(t *testing.T) { + b := make([]byte, 0x400) + copy(b[arm64SpecMagicOffset:], arm64SpecMagic) + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", b) + pkgs, rels, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + assert.Empty(t, pkgs, "no version => no package (parity with the x86 empty-version rule)") + assert.Empty(t, rels) + }) + + // files that deitch/magic cannot read at all surface the magic error — + // this is pre-existing behavior that must be preserved + magicErrorCases := []struct { + name string + data []byte + }{ + {"too small to identify", []byte{'A', 'R', 'M', 0x64}}, + {"gzip payload without arm64 magic", gzCompress(bytes.Repeat([]byte{0x42}, 0x1000))}, + } + for _, tt := range magicErrorCases { + t.Run(tt.name, func(t *testing.T) { + reader := makeLocationReadCloser("/boot/vmlinuz-test.aarch64", tt.data) + pkgs, _, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to get magic type") + assert.Empty(t, pkgs) + }) + } +} + +func TestParseLinuxKernelFile_x86PathUnchanged(t *testing.T) { + const version = "5.10.121-linuxkit (root@buildkitsandbox) #1 SMP Fri Dec 2 10:35:42 UTC 2022" + reader := makeLocationReadCloser("/boot/vmlinuz-test.x86_64", x86BzImageBytes(version)) + + pkgs, _, err := parseLinuxKernelFile(testContext(t), nil, &generic.Environment{}, reader) + require.NoError(t, err) + require.Len(t, pkgs, 1, "x86 bzImage must still be detected through the deitch/magic path") + + meta, ok := pkgs[0].Metadata.(pkg.LinuxKernel) + require.True(t, ok) + assert.Equal(t, "x86", meta.Architecture) + assert.Equal(t, "bzImage", meta.Format) + assert.Equal(t, "5.10.121-linuxkit", meta.Version) + assert.Equal(t, version, meta.ExtendedVersion) +} + +func TestIsARM64Image(t *testing.T) { + img := arm64ImageBytes("1.2.3-test") + assert.True(t, isARM64Image(img)) + assert.False(t, isARM64Image(nil)) + assert.False(t, isARM64Image(make([]byte, arm64ImageMagicOffset+4))) + bad := bytes.Clone(img) + bad[arm64ImageMagicOffset] = 'X' + assert.False(t, isARM64Image(bad)) +} + +func TestParseLinuxVersionBanner(t *testing.T) { + payload := append([]byte{1, 2, 3}, []byte("Linux version 6.1.21-1.25.amzn2023.aarch64 (mockbuild@host) #1 SMP Tue May 1 00:00:00 UTC 2023\x00trailing")...) + version, extended, ok := parseLinuxVersionBanner(payload) + require.True(t, ok) + assert.Equal(t, "6.1.21-1.25.amzn2023.aarch64", version) + assert.Equal(t, "6.1.21-1.25.amzn2023.aarch64 (mockbuild@host) #1 SMP Tue May 1 00:00:00 UTC 2023", extended) + + _, _, ok = parseLinuxVersionBanner([]byte("no banner here")) + assert.False(t, ok) +} diff --git a/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go b/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go index d09601026b5..d0616148b6d 100644 --- a/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go +++ b/syft/pkg/cataloger/kernel/parse_linux_kernel_file.go @@ -24,23 +24,35 @@ func parseLinuxKernelFile(_ context.Context, _ file.Resolver, _ *generic.Environ return nil, nil, fmt.Errorf("unable to get union reader for file: %w", err) } magicType, err := magic.GetType(unionReader) - if err != nil { - return nil, nil, fmt.Errorf("unable to get magic type for file: %w", err) - } - if len(magicType) < 1 || magicType[0] != linuxKernelMagicName { - return nil, nil, nil + if err == nil && len(magicType) > 0 && magicType[0] == linuxKernelMagicName { + metadata := parseLinuxKernelMetadata(magicType) + if metadata.Version == "" { + return nil, nil, nil + } + + return []pkg.Package{ + newLinuxKernelPackage( + metadata, + reader.Location, + ), + }, nil, nil } - metadata := parseLinuxKernelMetadata(magicType) - if metadata.Version == "" { - return nil, nil, nil + + // deitch/magic only recognizes x86 kernel images; fall back to detecting + // arm64 images (raw, compressed, or EFI-zboot-wrapped) ourselves. + if metadata, ok := parseARM64LinuxKernelImage(unionReader); ok { + return []pkg.Package{ + newLinuxKernelPackage( + metadata, + reader.Location, + ), + }, nil, nil } - return []pkg.Package{ - newLinuxKernelPackage( - metadata, - reader.Location, - ), - }, nil, nil + if err != nil { + return nil, nil, fmt.Errorf("unable to get magic type for file: %w", err) + } + return nil, nil, nil } func parseLinuxKernelMetadata(magicType []string) (p pkg.LinuxKernel) {