mirror of
https://github.com/anchore/syft.git
synced 2026-08-19 16:48:27 +02:00
fix(java): bound user-controlled reads in the graalvm native-image cataloger
Sizes, offsets and lengths in a native-image binary all come from the file being parsed, and several were acted on directly. The PE export directory sized a `make([]byte, Size)` from a uint32 in the optional header, so an 8KB file claiming 4GB reserved 4GB before reading a byte. It now reads what the file actually holds, bounded by the declared size, and still requires the whole directory to be present. This also fixes a latent bug: `unionreader.readerAtAdapter.ReadAt` can return a short read with a nil error, and the old code discarded the count, so squashfs-sourced binaries could parse zero padding as export data. The bounds checks in `decompressSbom` and the PE export walk validated only the end of a range, computed by adding to a value out of the file. Those sums wrap, and a wrapped sum compares as in range while the slice that follows it panics. They now subtract from the known-good length instead. The three `address - sectionBase` subtractions are unsigned and underflowed on an address below the base; they share one guarded helper now. The embedded SBOM decompresses through an `io.LimitedReader`, since the compressed bytes are bounded by the file but what they expand to is not. The limit is checked before the decoder's own error, which would otherwise report a size problem as "not a cyclonedx json document". Hitting it is logged, since the caller reports parse failures at trace level and a dropped SBOM would otherwise go unnoticed. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
parent
2293641e3b
commit
def9bc2e16
@ -82,6 +82,17 @@ const nativeImageMissingSymbolsError = "one or more symbols are missing from the
|
||||
const nativeImageInvalidIndexError = "parsing the executable file generated an invalid index"
|
||||
const nativeImageMissingExportedDataDirectoryError = "exported data directory is missing"
|
||||
|
||||
// nativeImageMaxDecompressedSbomSize bounds how far the embedded SBOM may decompress.
|
||||
//
|
||||
// A native-image SBOM enumerating thousands of Java dependencies comes to single-digit megabytes (20k
|
||||
// components is ~4MB, see TestDecompressSbom_AcceptsLargeSbom), so this is a ~20x margin over anything
|
||||
// real and should never truncate a genuine SBOM. Truncating one silently drops packages, so the margin
|
||||
// is deliberate, but the ceiling is not free either: the decoder buffers the whole stream before it can
|
||||
// identify it, and io.ReadAll's growth makes the real peak roughly double this. It has to bound the
|
||||
// decompressed stream rather than the compressed bytes, since gzip will happily turn a few kilobytes
|
||||
// into gigabytes. Hitting it is logged, because the packages are dropped either way.
|
||||
const nativeImageMaxDecompressedSbomSize = 100 * 1024 * 1024
|
||||
|
||||
// NewNativeImageCataloger returns a new Native Image cataloger object.
|
||||
func NewNativeImageCataloger() pkg.Cataloger {
|
||||
return &nativeImageCataloger{}
|
||||
@ -94,34 +105,48 @@ func (c *nativeImageCataloger) Name() string {
|
||||
|
||||
// decompressSbom returns the packages given within a native image executable's SBOM.
|
||||
func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
lengthEnd := lengthStart + 8
|
||||
bufLen := len(dataBuf)
|
||||
if lengthEnd > uint64(bufLen) {
|
||||
return decompressSbomWithLimit(dataBuf, sbomStart, lengthStart, nativeImageMaxDecompressedSbomSize)
|
||||
}
|
||||
|
||||
// decompressSbomWithLimit is decompressSbom with the decompressed-size bound passed in, so that a test
|
||||
// can exercise the bound without first having to produce the hundreds of megabytes it takes to trip the
|
||||
// real one. Production always goes through decompressSbom.
|
||||
//
|
||||
// Both offsets and the stored length come from the binary being parsed, so every bound below subtracts
|
||||
// from the known-good buffer length rather than adding to a user-controlled value: `start+n` wraps, and a
|
||||
// wrapped sum compares as in-range while the slice that follows it panics.
|
||||
func decompressSbomWithLimit(dataBuf []byte, sbomStart, lengthStart uint64, maxDecompressed int64) ([]pkg.Package, []artifact.Relationship, error) {
|
||||
bufLen := uint64(len(dataBuf))
|
||||
if lengthStart > bufLen || bufLen-lengthStart < 8 {
|
||||
return nil, nil, errors.New("the 'sbom_length' symbol overflows the binary")
|
||||
}
|
||||
|
||||
length := dataBuf[lengthStart:lengthEnd]
|
||||
p := bytes.NewBuffer(length)
|
||||
var storedLength uint64
|
||||
err := binary.Read(p, binary.LittleEndian, &storedLength)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not read from binary file: %w", err)
|
||||
}
|
||||
storedLength := binary.LittleEndian.Uint64(dataBuf[lengthStart : lengthStart+8])
|
||||
|
||||
log.WithFields("len", storedLength).Trace("found java native-image SBOM")
|
||||
sbomEnd := sbomStart + storedLength
|
||||
if sbomEnd > uint64(bufLen) {
|
||||
if sbomStart > bufLen || storedLength > bufLen-sbomStart {
|
||||
return nil, nil, errors.New("the sbom symbol overflows the binary")
|
||||
}
|
||||
|
||||
sbomCompressed := dataBuf[sbomStart:sbomEnd]
|
||||
p = bytes.NewBuffer(sbomCompressed)
|
||||
gzreader, err := gzip.NewReader(p)
|
||||
sbomCompressed := dataBuf[sbomStart : sbomStart+storedLength]
|
||||
gzreader, err := gzip.NewReader(bytes.NewBuffer(sbomCompressed))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not decompress the java native-image SBOM: %w", err)
|
||||
}
|
||||
|
||||
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(gzreader)
|
||||
// sbomCompressed is bounded by the file, but the decompressed stream is not, so bound it too. The
|
||||
// decoder buffers everything it is handed before it can even tell whether the payload is CycloneDX,
|
||||
// so a payload that is not an SBOM at all still costs whatever we allow here.
|
||||
limited := &io.LimitedReader{R: gzreader, N: maxDecompressed + 1}
|
||||
|
||||
sbom, _, _, err := cyclonedxjson.NewFormatDecoder().Decode(limited)
|
||||
// checked before err, since hitting the bound surfaces as an unhelpful "not a cyclonedx json
|
||||
// document" from the decoder rather than as anything about size
|
||||
if limited.N == 0 {
|
||||
log.WithFields("limit", maxDecompressed, "compressed", len(sbomCompressed)).
|
||||
Debug("java native-image SBOM decompresses past the size limit; skipping it")
|
||||
return nil, nil, fmt.Errorf("the java native-image SBOM decompresses past %d bytes", maxDecompressed)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("could not unmarshal the java native-image SBOM: %w", err)
|
||||
}
|
||||
@ -132,6 +157,16 @@ func decompressSbom(dataBuf []byte, sbomStart uint64, lengthStart uint64) ([]pkg
|
||||
return pkgs, sbom.Relationships, nil
|
||||
}
|
||||
|
||||
// symbolOffsets converts the SBOM symbol addresses into offsets within the section data that should hold
|
||||
// them. Both addresses come from the binary, and the subtraction is unsigned, so an address below the
|
||||
// section base underflows into a huge offset instead of a negative one.
|
||||
func symbolOffsets(sbomAddr, lengthAddr, sectionBase uint64) (sbomOffset uint64, lengthOffset uint64, err error) {
|
||||
if sbomAddr < sectionBase || lengthAddr < sectionBase {
|
||||
return 0, 0, errors.New("an SBOM symbol precedes the section that should contain it")
|
||||
}
|
||||
return sbomAddr - sectionBase, lengthAddr - sectionBase, nil
|
||||
}
|
||||
|
||||
// fileError logs an error message when an executable cannot be read.
|
||||
func fileError(filename string, err error) (nativeImage, error) {
|
||||
// We could not read the file as a binary for the desired platform, but it may still be a native-image executable.
|
||||
@ -210,11 +245,22 @@ func newPE(filename string, r io.ReaderAt) (nativeImage, error) {
|
||||
return fileError(filename, errors.New(nativeImageMissingExportedDataDirectoryError))
|
||||
}
|
||||
exportSymbolsOffset := uint64(exportSymbolsDataDirectory.VirtualAddress)
|
||||
exports := make([]byte, exportSymbolsDataDirectory.Size)
|
||||
_, err = r.ReadAt(exports, int64(exportSymbolsOffset))
|
||||
// Size is a user-controlled uint32 from the PE optional header, so sizing the buffer from it up
|
||||
// front lets a small file reserve up to 4GB. Reading grows the buffer to what the file actually holds
|
||||
// instead, bounded by the declared size, and the length check below still requires the whole directory
|
||||
// to be present rather than accepting a truncated read. The io.LimitReader is redundant with the
|
||||
// SectionReader, which already stops at exportSize, but the ruleguard rule in test/rules/rules.go
|
||||
// matches on the text of the io.ReadAll argument and cannot see that; removing it fails lint.
|
||||
exportSize := int64(exportSymbolsDataDirectory.Size)
|
||||
sectionReader := io.NewSectionReader(r, int64(exportSymbolsOffset), exportSize)
|
||||
exports, err := io.ReadAll(io.LimitReader(sectionReader, exportSize))
|
||||
if err != nil {
|
||||
return fileError(filename, fmt.Errorf("could not read the exported symbols data directory: %w", err))
|
||||
}
|
||||
if int64(len(exports)) != exportSize {
|
||||
return fileError(filename, fmt.Errorf("exported symbols data directory is truncated: got %d of %d bytes",
|
||||
len(exports), exportSize))
|
||||
}
|
||||
return nativeImagePE{
|
||||
file: bi,
|
||||
reader: r,
|
||||
@ -273,15 +319,16 @@ func (ni nativeImageElf) fetchPkgs() (pkgs []pkg.Package, relationships []artifa
|
||||
}
|
||||
dataSection := bi.Section(".data")
|
||||
if dataSection == nil {
|
||||
return nil, nil, fmt.Errorf("no .data section found in binary: %w", err)
|
||||
return nil, nil, errors.New("no .data section found in binary")
|
||||
}
|
||||
dataSectionBase := dataSection.Addr
|
||||
data, err := dataSection.Data()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("cannot read the .data section: %w", err)
|
||||
}
|
||||
sbomLocation := sbom.Value - dataSectionBase
|
||||
lengthLocation := sbomLength.Value - dataSectionBase
|
||||
sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSection.Addr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return decompressSbom(data, sbomLocation, lengthLocation)
|
||||
}
|
||||
@ -352,48 +399,39 @@ func (ni nativeImageMachO) fetchPkgs() (pkgs []pkg.Package, relationships []arti
|
||||
log.Tracef("cannot obtain buffer from data segment")
|
||||
return nil, nil, nil
|
||||
}
|
||||
sbomLocation := sbom.Value - dataSegment.Addr
|
||||
lengthLocation := sbomLength.Value - dataSegment.Addr
|
||||
sbomLocation, lengthLocation, err := symbolOffsets(sbom.Value, sbomLength.Value, dataSegment.Addr)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
|
||||
}
|
||||
|
||||
// fetchExportAttribute obtains an attribute from the exported symbols directory entry.
|
||||
func (ni nativeImagePE) fetchExportAttribute(i int) (uint32, error) {
|
||||
var attribute uint32
|
||||
n := len(ni.exports)
|
||||
// i is only ever 0-3, so this arithmetic cannot overflow; the bound is > rather than >= because an
|
||||
// attribute ending flush with the directory is still entirely present
|
||||
j := int(unsafe.Sizeof(ni.header)) + i*int(unsafe.Sizeof(ni.t.headerAttribute))
|
||||
if j+4 >= n {
|
||||
if j+4 > n {
|
||||
log.Tracef("invalid index to export directory entry attribute: %v", j)
|
||||
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
||||
}
|
||||
p := bytes.NewBuffer(ni.exports[j : j+4])
|
||||
err := binary.Read(p, binary.LittleEndian, &attribute)
|
||||
if err != nil {
|
||||
log.Tracef("error fetching export directory entry attribute: %v", err)
|
||||
return uint32(0), err
|
||||
}
|
||||
return attribute, nil
|
||||
return binary.LittleEndian.Uint32(ni.exports[j : j+4]), nil
|
||||
}
|
||||
|
||||
// fetchExportFunctionPointer obtains a function pointer from the exported symbols directory entry.
|
||||
func (ni nativeImagePE) fetchExportFunctionPointer(functionsBase uint32, i uint32) (uint32, error) {
|
||||
var pointer uint32
|
||||
|
||||
n := uint32(len(ni.exports))
|
||||
sz := uint32(unsafe.Sizeof(ni.t.functionPointer))
|
||||
j := functionsBase + i*sz
|
||||
if j+sz >= n {
|
||||
// functionsBase derives from a file-controlled RVA, so widen to uint64 before indexing: in uint32 the
|
||||
// sum wraps to a small value that passes the bound and then panics on the slice
|
||||
n := uint64(len(ni.exports))
|
||||
sz := uint64(unsafe.Sizeof(ni.t.functionPointer))
|
||||
j := uint64(functionsBase) + uint64(i)*sz
|
||||
if j > n || n-j < sz {
|
||||
log.Tracef("invalid index to exported function: %v", j)
|
||||
return uint32(0), errors.New(nativeImageInvalidIndexError)
|
||||
}
|
||||
p := bytes.NewBuffer(ni.exports[j : j+sz])
|
||||
err := binary.Read(p, binary.LittleEndian, &pointer)
|
||||
if err != nil {
|
||||
log.Tracef("error fetching exported function: %v", err)
|
||||
return uint32(0), err
|
||||
}
|
||||
return pointer, nil
|
||||
return binary.LittleEndian.Uint32(ni.exports[j : j+sz]), nil
|
||||
}
|
||||
|
||||
// fetchExportContent obtains the content of the export directory entry relevant to the SBOM.
|
||||
@ -425,27 +463,31 @@ func (ni nativeImagePE) fetchSbomSymbols(content *exportContentPE) {
|
||||
sbomBytes := []byte(nativeImageSbomSymbol + "\x00")
|
||||
sbomLengthBytes := []byte(nativeImageSbomLengthSymbol + "\x00")
|
||||
svmVersionInfoBytes := []byte(nativeImageSbomVersionSymbol + "\x00")
|
||||
n := uint32(len(ni.exports))
|
||||
n := uint64(len(ni.exports))
|
||||
sz := uint64(unsafe.Sizeof(ni.t.namePointer))
|
||||
|
||||
// the name array must start inside the directory we read; an RVA below it would underflow the
|
||||
// subtraction into a huge offset that then wraps back into range on the bound below
|
||||
if content.addressOfNames < ni.exportSymbols.VirtualAddress {
|
||||
log.Tracef("exported name array precedes the export directory: %v", content.addressOfNames)
|
||||
return
|
||||
}
|
||||
addressBase := uint64(content.addressOfNames - ni.exportSymbols.VirtualAddress)
|
||||
|
||||
// Find SBOM, SBOM Length, and SVM Version Symbol
|
||||
for i := uint32(0); i < content.numberOfNames; i++ {
|
||||
j := i * uint32(unsafe.Sizeof(ni.t.namePointer))
|
||||
addressBase := content.addressOfNames - ni.exportSymbols.VirtualAddress
|
||||
k := addressBase + j
|
||||
sz := uint32(unsafe.Sizeof(ni.t.namePointer))
|
||||
if k+sz >= n {
|
||||
k := addressBase + uint64(i)*sz
|
||||
if k > n || n-k < sz {
|
||||
log.Tracef("invalid index to exported function: %v", k)
|
||||
// If we are at the end of exports, stop looking
|
||||
return
|
||||
}
|
||||
var symbolAddress uint32
|
||||
p := bytes.NewBuffer(ni.exports[k : k+sz])
|
||||
err := binary.Read(p, binary.LittleEndian, &symbolAddress)
|
||||
if err != nil {
|
||||
log.Tracef("error fetching address of symbol %v", err)
|
||||
symbolAddress := binary.LittleEndian.Uint32(ni.exports[k : k+sz])
|
||||
if symbolAddress < ni.exportSymbols.VirtualAddress {
|
||||
log.Tracef("exported symbol precedes the export directory: %v", symbolAddress)
|
||||
return
|
||||
}
|
||||
symbolBase := symbolAddress - ni.exportSymbols.VirtualAddress
|
||||
symbolBase := uint64(symbolAddress - ni.exportSymbols.VirtualAddress)
|
||||
if symbolBase >= n {
|
||||
log.Tracef("invalid index to exported symbol: %v", symbolBase)
|
||||
return
|
||||
@ -480,6 +522,9 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
|
||||
if content.addressOfSbom == uint32(0) || content.addressOfSbomLength == uint32(0) || content.addressOfSvmVersion == uint32(0) {
|
||||
return nil, nil, errors.New(nativeImageMissingSymbolsError)
|
||||
}
|
||||
if content.addressOfFunctions < ni.exportSymbols.VirtualAddress {
|
||||
return nil, nil, errors.New("exported function array precedes the export directory")
|
||||
}
|
||||
functionsBase := content.addressOfFunctions - ni.exportSymbols.VirtualAddress
|
||||
sbomOffset := content.addressOfSbom
|
||||
sbomAddress, err := ni.fetchExportFunctionPointer(functionsBase, sbomOffset)
|
||||
@ -501,10 +546,13 @@ func (ni nativeImagePE) fetchPkgs() (pkgs []pkg.Package, relationships []artifac
|
||||
log.Tracef("cannot obtain buffer from the java native-image .data section")
|
||||
return nil, nil, nil
|
||||
}
|
||||
sbomLocation := sbomAddress - dataSection.VirtualAddress
|
||||
lengthLocation := sbomLengthAddress - dataSection.VirtualAddress
|
||||
sbomLocation, lengthLocation, err := symbolOffsets(uint64(sbomAddress), uint64(sbomLengthAddress),
|
||||
uint64(dataSection.VirtualAddress))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return decompressSbom(dataBuf, uint64(sbomLocation), uint64(lengthLocation))
|
||||
return decompressSbom(dataBuf, sbomLocation, lengthLocation)
|
||||
}
|
||||
|
||||
// fetchPkgs provides the packages available in a UnionReader.
|
||||
@ -525,6 +573,9 @@ func fetchPkgs(reader unionreader.UnionReader, location file.Location) ([]pkg.Pa
|
||||
for _, makeNativeImage := range imageFormats {
|
||||
ni, err := makeNativeImage(filename, r)
|
||||
if err != nil {
|
||||
// this covers both "not this format" and a real rejection of a file that is one, and the
|
||||
// latter drops every package the binary carries, so it cannot go unrecorded
|
||||
log.WithFields("file", filename, "error", err).Trace("unable to read possible java native-image")
|
||||
continue
|
||||
}
|
||||
if ni == nil {
|
||||
|
||||
@ -4,11 +4,13 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@ -265,3 +267,190 @@ func verifyRelationshipFields(t *testing.T, expected, actual []artifact.Relation
|
||||
require.Equal(t, expected[i].Type, actual[i].Type)
|
||||
}
|
||||
}
|
||||
|
||||
// buildMinimalPE64 assembles the smallest PE64 that debug/pe will parse, with DataDirectory[0] (the
|
||||
// export directory) set to the given RVA and size. totalSize is how large the resulting file is; the
|
||||
// point of the fixture is that size can claim far more than that.
|
||||
//
|
||||
// newPE uses the directory's VirtualAddress directly as a file offset rather than translating it, so the
|
||||
// fixture needs no sections and exportRVA doubles as the offset the read starts from.
|
||||
func buildMinimalPE64(exportRVA, exportSize uint32, totalSize int) []byte {
|
||||
const peHdrOff = 0x40
|
||||
buf := make([]byte, totalSize)
|
||||
copy(buf, []byte{'M', 'Z'})
|
||||
binary.LittleEndian.PutUint32(buf[0x3c:], peHdrOff)
|
||||
|
||||
p := buf[peHdrOff:]
|
||||
copy(p, []byte{'P', 'E', 0, 0})
|
||||
// COFF file header
|
||||
binary.LittleEndian.PutUint16(p[4:], 0x8664) // Machine = AMD64
|
||||
binary.LittleEndian.PutUint16(p[20:], 240) // SizeOfOptionalHeader
|
||||
binary.LittleEndian.PutUint16(p[22:], 0x22) // Characteristics: executable image
|
||||
|
||||
// optional header (PE32+)
|
||||
oh := p[24:]
|
||||
binary.LittleEndian.PutUint16(oh[0:], 0x20b) // Magic = PE32+
|
||||
binary.LittleEndian.PutUint32(oh[108:], 16) // NumberOfRvaAndSizes
|
||||
// DataDirectory[0] = export table
|
||||
binary.LittleEndian.PutUint32(oh[112:], exportRVA)
|
||||
binary.LittleEndian.PutUint32(oh[116:], exportSize)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func TestNewPE_ExportDirectorySizeIsNotAllocatedUpFront(t *testing.T) {
|
||||
// exportSymbolsDataDirectory.Size is a user-controlled uint32 from the PE optional header, and it used
|
||||
// to size a make([]byte, Size) before the read, so an 8KB file claiming 4GB reserved 4GB.
|
||||
data := buildMinimalPE64(0x1000, 0xFFFFFFFF, 8192)
|
||||
|
||||
var before, after runtime.MemStats
|
||||
runtime.ReadMemStats(&before)
|
||||
_, err := newPE("oversized-export-dir.exe", bytes.NewReader(data))
|
||||
runtime.ReadMemStats(&after)
|
||||
|
||||
require.Error(t, err, "an export directory the file cannot satisfy must not read as successful")
|
||||
require.Contains(t, err.Error(), "truncated")
|
||||
|
||||
allocated := after.TotalAlloc - before.TotalAlloc
|
||||
t.Logf("allocated %d bytes for a directory declaring %d", allocated, uint64(0xFFFFFFFF))
|
||||
require.Less(t, allocated, uint64(8*1024*1024),
|
||||
"the declared size must not be reserved before the read confirms the bytes exist")
|
||||
}
|
||||
|
||||
func TestNewPE_ExportDirectoryWithinFileIsRead(t *testing.T) {
|
||||
// the bound must not reject an export directory that really is present
|
||||
data := buildMinimalPE64(0x1000, 64, 8192)
|
||||
|
||||
ni, err := newPE("ok.exe", bytes.NewReader(data))
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ni)
|
||||
}
|
||||
|
||||
func TestDecompressSbom_RejectsOutOfRangeOffsets(t *testing.T) {
|
||||
// every offset and length below is read out of the binary, so each has to be rejected rather than
|
||||
// wrapped into a value that passes a bound and then panics on the slice that follows
|
||||
tests := []struct {
|
||||
name string
|
||||
bufLen int
|
||||
sbomStart uint64
|
||||
lengthStart uint64
|
||||
storedLength uint64
|
||||
}{
|
||||
{
|
||||
name: "length offset wraps past the end of the buffer",
|
||||
bufLen: 64,
|
||||
lengthStart: ^uint64(0) - 3, // lengthStart+8 wraps to 4, which compares as in range
|
||||
},
|
||||
{
|
||||
name: "length offset starts beyond the buffer",
|
||||
bufLen: 64,
|
||||
lengthStart: 100,
|
||||
},
|
||||
{
|
||||
name: "stored length wraps past the end of the buffer",
|
||||
bufLen: 64,
|
||||
sbomStart: 16,
|
||||
lengthStart: 0,
|
||||
storedLength: ^uint64(0) - 3, // sbomStart+storedLength wraps to 12
|
||||
},
|
||||
{
|
||||
name: "stored length runs past the end of the buffer",
|
||||
bufLen: 64,
|
||||
sbomStart: 16,
|
||||
lengthStart: 0,
|
||||
storedLength: 1000,
|
||||
},
|
||||
{
|
||||
name: "sbom offset starts beyond the buffer",
|
||||
bufLen: 64,
|
||||
sbomStart: ^uint64(0) - 3,
|
||||
lengthStart: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dataBuf := make([]byte, tt.bufLen)
|
||||
// subtract from the length rather than adding to the offset, for the same reason the code
|
||||
// under test has to: tt.lengthStart is deliberately large enough to wrap
|
||||
if tt.lengthStart <= uint64(tt.bufLen) && uint64(tt.bufLen)-tt.lengthStart >= 8 {
|
||||
binary.LittleEndian.PutUint64(dataBuf[tt.lengthStart:], tt.storedLength)
|
||||
}
|
||||
|
||||
require.NotPanics(t, func() {
|
||||
_, _, err := decompressSbom(dataBuf, tt.sbomStart, tt.lengthStart)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecompressSbom_RejectsStreamExpandingPastTheLimit(t *testing.T) {
|
||||
// the compressed bytes are bounded by the file, but what they expand to is not, and the decoder
|
||||
// buffers the whole stream before it can tell whether the payload is even CycloneDX. Zeros stand in
|
||||
// for any uniform, highly compressible payload: they expand about 1000x, far past what real JSON does.
|
||||
// the bound is passed in rather than taken from nativeImageMaxDecompressedSbomSize, so that tripping
|
||||
// it costs a megabyte instead of the several hundred the real limit would demand
|
||||
const limit = 1024 * 1024
|
||||
const decompressedSize = limit + 1
|
||||
|
||||
var compressed bytes.Buffer
|
||||
z := gzip.NewWriter(&compressed)
|
||||
_, err := z.Write(make([]byte, decompressedSize))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, z.Close())
|
||||
|
||||
t.Logf("%d compressed bytes expand to %d (%dx)", compressed.Len(), decompressedSize,
|
||||
decompressedSize/compressed.Len())
|
||||
|
||||
dataBuf := append(compressed.Bytes(), make([]byte, 8)...)
|
||||
lengthStart := uint64(compressed.Len())
|
||||
binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart)
|
||||
|
||||
_, _, err = decompressSbomWithLimit(dataBuf, 0, lengthStart, limit)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "decompresses past",
|
||||
"hitting the bound should say so, not report a parse failure")
|
||||
}
|
||||
|
||||
func TestDecompressSbom_AcceptsLargeSbom(t *testing.T) {
|
||||
// guards the risk the size limit introduces: a genuinely large SBOM must still be cataloged.
|
||||
// Components carry distinct names, versions, purls and hashes, so this is shaped like a real
|
||||
// CycloneDX document rather than a uniform payload.
|
||||
const components = 20000
|
||||
|
||||
var sb bytes.Buffer
|
||||
sb.WriteString(`{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,"components":[`)
|
||||
for i := 0; i < components; i++ {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
// a sha256-shaped value keeps the entropy (and so the compression ratio) realistic
|
||||
hash := sha256.Sum256([]byte(fmt.Sprintf("component-%d", i)))
|
||||
fmt.Fprintf(&sb, `{"type":"library","name":"lib-%d","version":"%d.%d.%d",`+
|
||||
`"purl":"pkg:maven/com.example.group%d/lib-%d@%d.%d.%d",`+
|
||||
`"hashes":[{"alg":"SHA-256","content":"%x"}]}`,
|
||||
i, i%20, i%7, i%13, i%50, i, i%20, i%7, i%13, hash)
|
||||
}
|
||||
sb.WriteString(`]}`)
|
||||
|
||||
raw := sb.Bytes()
|
||||
var compressed bytes.Buffer
|
||||
z := gzip.NewWriter(&compressed)
|
||||
_, err := z.Write(raw)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, z.Close())
|
||||
|
||||
t.Logf("%d components: %d bytes of JSON, %d compressed (%dx), limit %d",
|
||||
components, len(raw), compressed.Len(), len(raw)/compressed.Len(), nativeImageMaxDecompressedSbomSize)
|
||||
require.Less(t, int64(len(raw)), int64(nativeImageMaxDecompressedSbomSize),
|
||||
"a real SBOM of this size must sit well inside the limit, otherwise the limit is too low")
|
||||
|
||||
dataBuf := append(compressed.Bytes(), make([]byte, 8)...)
|
||||
lengthStart := uint64(compressed.Len())
|
||||
binary.LittleEndian.PutUint64(dataBuf[lengthStart:], lengthStart)
|
||||
|
||||
pkgs, _, err := decompressSbom(dataBuf, 0, lengthStart)
|
||||
require.NoError(t, err, "a large SBOM at a realistic ratio must not be rejected")
|
||||
require.Len(t, pkgs, components)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user