Files
syft/syft/pkg/cataloger/binary
Alex Goodman 3325493831 chore: pin the bounds from the unbounded allocation work (#5298)
* build(deps): bump go-make to v0.8.1

Picks up golangci-lint 2.13.2 (built with go1.27) in place of 2.11.4 (go1.26).
The old build panics outright on a go1.27 GOROOT with "file requires newer Go
version go1.27", and an older mise-shim build fails the quieter way: it loses
type info and reports zero findings with exit 0, so a ruleguard run comes back
green without having evaluated anything.

Also folds in what the newer linter flags:

- `reflect.Ptr` -> `reflect.Pointer` in three files, applied by --fix
- `tw.CellFormatting{MergeMode: ...}` -> `tw.CellMerging{Mode: ...}` in the
  cataloger info table, for a staticcheck SA1019 deprecation

Note the deprecation text names `CellConfig.CellMerging.Mode`, but the field is
`CellConfig.Merging`; `CellMerging` is the type. That command path has no test
coverage, so the swap was checked by rendering the same hierarchical-merge table
both ways and diffing: byte identical.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(lint): tune goconst for data tables

goconst got stricter in golangci-lint 2.13 and now counts strings in map
literals and struct fields, which lights up every lookup table in the repo. It
was already enabled, so this is main going red on a linter bump rather than
anything new in the tree: 101 findings, and the default threshold of 3 means a
vendor name appearing in three rows of a table reads as a magic constant.

Raising `min-occurrences` to 5 takes that to 14. The rest split by whether the
repeated string is data or an actual constant:

- `cpegenerate` is excluded by path. It maps artifact names to vendors,
  products, and maven group ids, and repeats strings 35, 83, even 293 times, so
  no threshold reaches it. Hoisting those would trade a readable table for
  indirection.
- the two SBOM fixture builders in `format/internal/testutil` get a
  function-level nolint each. The repeated names and versions are what the
  golden files assert against.
- `mysql-cluster` and `php` were genuine repeated literals and are now
  constants.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(test): extract measureAlloc into a shared helper

The allocation-budget tests added during the unbounded allocation work grew a
copy of the same helper in every package they touched: four identical
`measureAlloc` definitions plus two more spelled out inline against
`runtime.MemStats`.

Now `testutils.MeasureAlloc`, at the repo root so `internal/spillbuf` can reach
it too. The doc comment carries the two things that were only recorded in some
of the copies: TotalAlloc is process-wide so callers must not use t.Parallel,
and a budget assertion only means something if the fixture would really blow
past it unguarded, so measure the unguarded path as well.

No behavior change, the body was identical in all six.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* chore(lint): add ruleguard rules for overflow and swallowed EOF

Two rules out of the recent unbounded allocation work (#5187, #5195, #5267,
#5291, #5292, #5293, #5294).

`noOverflowingBoundsCheck` flags `offset + size > limit` (and <, <=, >=) where
an operand is unsigned, neither addend is constant, and the operands are at
least 32 bits. Unsigned addition wraps, so an offset out of a file header can
carry the sum past the wrap point and pass a check it should have failed. The
fix is to subtract against the limit instead. Constant addends are excluded
because `pos+4 <= uint32(len(buf))` is the normal way to step a cursor, and the
32-bit floor drops nibbles widened for a comparison. Would have caught four
sites across #5195 and #5291.

`noSwallowedEOF` flags `return nil` / `return nil, nil` on an io.EOF check. The
caller gets a zero value it cannot tell from real data, and anything decoding a
fixed-size struct off it parses the padding as content. That is the shape behind
#5186.

Both decide from the expression alone, and writing the fix deletes the pattern,
so neither needs a suppression anywhere in the tree. That was the bar: rules for
the allocation and decompression classes were tried and dropped, because the
bound they look for lives in a preceding statement (or, for a decompressor
constructor, always downstream), so no correct implementation can silence them.
That would have meant ~15 permanent nolints on code that is already right. The
rules here that do work don't judge whether a bound exists, they route to a
wrapper that takes one: noDirectTempFiles, noDirectELFOpen.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: pin allocation budgets on the alpm, deb, and changelog bounds

The bounds added for #5292, #5293 and #5294 are asserted by the error they
return. That keeps passing if the bound is removed and something else rejects
the input later, which is the failure these were written for. Each of these
measures bytes instead, and proves the fixture is really a bomb first so a
fixture that quietly stops being one fails rather than passing.

- alpm mtree: 16MB of newlines, well inside the 64MB byte cap, costs 16.5GB
  uncapped against 238MB with maxMtreeLines. That 16.5GB is the measurement the
  line cap was added for and it was recorded only in the commit message. Note
  what the cap actually buys: 300k entries still cost a few hundred MB, per
  concurrent cataloger.
- deb members: 1.28GB against 392KB. Both arms read with io.ReadAll on purpose,
  since the stream itself never holds much whatever the cap says; what the cap
  protects is a consumer that buffers, and the real one is a tar reader building
  entries.
- snap changelog: 1.28GB against 175KB. This one has no explicit cap at all. It
  is held by bufio.Scanner refusing a token past MaxScanTokenSize, which is
  incidental, so switching to io.ReadAll or a bufio.Reader to pick up long lines
  would remove it silently.

Nothing added for the kernel module reader: its copy is already
io.Copy(tempFile, io.LimitReader(rc, max+1)), so peak disk is bounded by
construction and heap is streaming. A heap budget would assert the wrong
resource, and the three existing subtests cover the cap, at-cap fidelity, and
spill cleanup.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: pin allocation budgets on the ai and alpm byte caps

Follows the same pattern as the alpm line cap: measure bytes, and prove the
fixture is really a bomb first so one that quietly stops being a bomb fails
rather than passes.

- safetensors: 252MB against 2.6KB. The existing over-cap case deliberately
  omits the body, since the guard rejects on the declared length before reading
  anything. That is the right test for the guard but it would keep passing if
  the check moved after the read, so this one delivers the bytes.
- gguf: 268MB against 168MB. copyHeader io.Copy's whatever it is handed, so
  maxHeaderSize holds only because every caller wraps the reader first. This
  pins that the limit is where the bound lives, so dropping the wrapper at a
  call site fails here.
- alpm byte cap: 740MB against 132MB, the counterpart to the line cap budget.

Worth knowing for anyone writing the next one of these: a single enormous line
does not exercise the alpm byte cap. go-mtree runs its own bufio.Scanner and
refuses a token past 64KB, so that fixture fails as "token too long" before the
byte cap is consulted. The fixture here uses ordinary 1KB lines, enough of them
to pass 64MB while staying under the 300k line cap, so the byte cap is actually
the thing under test.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

* test: assert bytes retained instead of allocated for gguf copyHeader

the bounded arm writes into a bytes.Buffer, whose doubling growth costs a
multiple of what it holds and lands on a different power of two depending on
the platform. it measured 168MB locally and 320MB on CI, past the budget.
buf.Len() states the same property deterministically.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
2026-09-17 14:11:16 -04:00
..

Adding tests for the Binary classifier cataloger

Tip

TL;DR to add a test for a new classifier:

  1. head to the correct directory: cd testdata
  2. add a new entry to config.yaml to track where to get the binary from (verify the entry with make list)
  3. run make download to get the binary
  4. run make add-snippet and follow the prompts (use / to search; answer y at the (Y/n/q) prompt to write the snippet)
  5. add a new test case to Test_Cataloger_PositiveCases in ../classifier_cataloger_test.go
  6. if you added a new classifier, regenerate the capabilities catalog from the repo root and commit the result: go generate ./internal/capabilities

To test the binary cataloger we run it against a set of files ("test fixtures"). There are two kinds of test fixtures:

  • Full binaries: files downloaded and cached at test runtime
  • Snippets: ~100 byte files checked into the repo

The upside with snippets is that they live with the test, don't necessarily require network access or hosting concerns, and are easy to add. The downside is that they are not the entire real binary so modifications may require recreating the snippet entirely.

The upside with full binaries is that they are the "Real McCoy" and allows the business logic to change without needing to update the fixture. The downside is that they require network access and take up a lot of space. For instance, downloading all binaries for testing today requires downloading ~15GB of container images and ends up being ~500MB of disk space.

You can find the test fixtures at the following locations:

syft/pkg/cataloger/binary/testdata/
└── classifiers/
    ├── bin/        # full binaries
    ├── ...
    └── snippets/   # snippets

And use tooling to list and manage the fixtures:

  • make list - list all fixtures
  • make download - download binaries that are not covered by a snippet
  • make download-all - download all binaries
  • make add-snippet - add a new snippet based off of a configured binary
  • capture-snippet.sh - add a new snippet based off of a binary on your local machine (not recommended, but allowed)

There is a config.yaml that tracks all binaries that the tests can use. This makes it possible to download it at any time from a hosted source. Today the only method allowed is to download a container image and extract files out.

Here is an example entry in config.yaml for a binary reference from a container image:

from-images:
  
  # note the group-name is assumed from the single binary name extracted: "redis-server"
  - version: 7.2.3
    images:
      # note we're pulling the same binary from multiple images (representing different architectures)
      - ref: redis:7.2.3@sha256:d4c84914b872521e215f77d8845914c2268a96b0e35bacd5691e1f5e1f88b500
        platform: linux/amd64
      - ref: redis:7.2.3@sha256:a0a0c38b31011b813cddf78d997f8ccba13019c27efd386984b0cfc1e4b618ff
        platform: linux/arm64
    # the paths to extract from the binary...
    paths:
      - /usr/local/bin/redis-server

  # since there are multiple binaries in the image, we need to specify the group-name
  - name: ruby-bullseye-shared-libs
    version: 2.7.7
    images:
      - ref: ruby:2.7.7-bullseye@sha256:055191740a063f33fef1f09423e5ed8f91143aae62a3772a90910118464c5120
        platform: linux/amd64
    paths:
      - /usr/local/bin/ruby
      - /usr/local/lib/libruby.so.2.7.7
      - /usr/local/lib/libruby.so.2.7

Note

You will need a system with go, bash, strings, and xxd installed to capture test snippets.

Testing

The test cases have been setup to allow testing against full binaries or a mix of both (default). To force running only against the original full binaries (instead of snippets), run with the -must-use-original-binaries flag:

go test -v ./syft/pkg/cataloger/binary -run Test_Cataloger_PositiveCases/ -must-use-original-binaries

To run against a single fixture, append its name to the -run filter:

go test -v ./syft/pkg/cataloger/binary -run Test_Cataloger_PositiveCases/<name> -must-use-original-binaries

Adding a new test fixture

Even if you are adding a snippet, it is best practice to:

  • create that snippet from a full binary (not craft a snippet by hand)
  • track where the binary is from and how to download it in config.yaml
  1. Follow the steps above to add a full binary

  2. Run make add-snippet and follow the prompts to create a new snippet (answer y at the (Y/n/q) prompt to write the snippet)

    • you should see your binary in the list of binaries to choose from. If not, check step 2
    • if the search results in no matching snippets, you can specify your own search with --search-for <grep-pattern>
    • you should see a new snippet file created in snippets/
  3. Write a test that references your new snippet by <name>/<version>/<architecture>

    • <name> is the name of the binary (e.g. curl) or the name in config.yaml if specified
    • note that your test does not know about if it's running against a snippet or a full binary

Adding a full binary

  1. Add a new entry to config.yaml with the following fields
  • if you are adding a single binary, the name field does not need to be specified
  • the name field is useful for distinguishing a quality about the binary (e.g. java vs java-jre-ibm)
  1. Run make download and ensure your new binary is downloaded

Adding a custom snippet

If you need to add a snippet that is not based off of a full binary, you can use the capture-snippet.sh script.

./capture-snippet.sh <binary-path> <version> [--search-for <pattern>] [--length <length>] [--prefix-length <prefix_length>] [--group <name>]

This is not recommended because it is not reproducible and does not allow for the test to be run against a full binary.