chore(lint): catch mholt/archives decompressors too

The decompression rule only matched the stdlib constructors, so the two
cataloger sites that decompress through mholt/archives went unreported while
being exactly as unbounded. Matches OpenReader on an archives.Decompressor,
which picks up both.

Also notes in the report text that a byte limit bounds the input and not what
the consumer retains. Measured on the mtree parser: 16MB of newlines, well
inside a 64MB byte cap, cost 8GB of peak heap, because the parser keeps an
object per line. A site can satisfy this rule and still OOM.

Worth knowing for the next rule: a type filter that fails to resolve takes the
whole rule set down with "used Run() with an empty rule set" rather than just
skipping that rule. Type.Is on an interface does that; Type.Implements with an
m.Import is what works.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman 2026-08-14 10:28:20 -04:00
parent cd57b0704e
commit 6f512803d2
No known key found for this signature in database

View File

@ -55,6 +55,8 @@ func noUnboundedAllocations(m dsl.Matcher) {
// nolint:unused // nolint:unused
func noUnboundedDecompression(m dsl.Matcher) { func noUnboundedDecompression(m dsl.Matcher) {
m.Import("github.com/mholt/archives")
// compression ratios are unbounded (gzip does ~1032:1), so a decompressed stream must be capped // compression ratios are unbounded (gzip does ~1032:1), so a decompressed stream must be capped
// independently of the compressed input, which is already bounded by the file it came from. // independently of the compressed input, which is already bounded by the file it came from.
// The constructor is flagged rather than the consumer because the consumer is often a // The constructor is flagged rather than the consumer because the consumer is often a
@ -68,7 +70,14 @@ func noUnboundedDecompression(m dsl.Matcher) {
`xz.NewReader($_)`, `xz.NewReader($_)`,
). ).
Where(m.File().PkgPath.Matches(`/cataloger/`)). Where(m.File().PkgPath.Matches(`/cataloger/`)).
Report("unbounded decompression in a cataloger: wrap the decompressed stream in io.LimitReader before anything consumes it, or nolint with the bound that already applies") Report("unbounded decompression in a cataloger: wrap the decompressed stream in io.LimitReader before anything consumes it, or nolint with the bound that already applies. Note that a byte limit bounds the input, not what the consumer retains: a parser that keeps an object per line still needs a count bound")
// the mholt/archives decompressors reach the same stdlib readers a layer down, so a site using them
// is exactly as unbounded while matching none of the constructors above
m.Match(`$d.OpenReader($_)`).
Where(m.File().PkgPath.Matches(`/cataloger/`) &&
m["d"].Type.Implements(`archives.Decompressor`)).
Report("unbounded decompression in a cataloger: $d.OpenReader returns a stream with no ceiling. bound it before anything consumes it, or nolint with the bound that already applies")
} }
// nolint:unused // nolint:unused