mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 14:02:26 +01:00
fix: bound total decompression under each top-level archive
the limiter bounds what's held *at once*, and each archive gives its bytes back when it's released. So a recursive bomb (42.zip shape) never trips a limit: every level fits on its own, and the scan just runs for effectively forever. Capping each archive at N× its own size wouldn't help either, since every level of such a bomb is high-ratio, so the work still grows about N-fold per level. - each top-level archive gets one decompression budget of `max(100 × its size, 1 GiB)`, spent by every archive nested under it at any depth - the budget is attached to the context when the walk enters a top-level archive, so `Extract` picks it up without a signature change - once it's spent, the archive being extracted is truncated with a "possible decompression bomb" unknown and the walk moves on. The next top-level archive starts fresh real trees (a war of jars, a layer of tarballs) expand a few times over, well under the ratio. Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -184,6 +184,11 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi
|
||||
|
||||
started := time.Now()
|
||||
|
||||
if depth == 0 {
|
||||
// one budget for this archive and everything nested in it, however deep
|
||||
ctx = archive.WithBudget(ctx, archive.NewBudget(archiveTreeBudget(parentResolver, location)))
|
||||
}
|
||||
|
||||
// extracted files keep the filesystem ID of where the archive was found; the nesting chain is
|
||||
// carried separately as the archive path. The chain names real paths, not the link the archive was
|
||||
// reached by, so it is stable and joins to the archive's package location by prefix.
|
||||
@@ -234,6 +239,22 @@ func (c *archiveCataloger) processArchive(ctx context.Context, parentResolver fi
|
||||
return c.catalog(ctx, extracted, depth+1, builder)
|
||||
}
|
||||
|
||||
// a top-level archive and everything nested in it may decompress to archiveTreeRatio times its size, or
|
||||
// archiveTreeFloorBytes if that is more. Real trees (a war of jars, a layer of tarballs) expand a few
|
||||
// times over; a recursive bomb expands by orders of magnitude at every level.
|
||||
var (
|
||||
archiveTreeRatio int64 = 100
|
||||
archiveTreeFloorBytes int64 = 1 << 30
|
||||
)
|
||||
|
||||
func archiveTreeBudget(resolver file.Resolver, location file.Location) int64 {
|
||||
var size int64
|
||||
if m, err := resolver.FileMetadataByLocation(location); err == nil && m.FileInfo != nil {
|
||||
size = m.Size()
|
||||
}
|
||||
return max(archiveTreeRatio*size, archiveTreeFloorBytes)
|
||||
}
|
||||
|
||||
func (c *archiveCataloger) isAncestor(digests []file.Digest) bool {
|
||||
for _, d := range digests {
|
||||
if slices.Contains(c.ancestors, d) {
|
||||
|
||||
@@ -369,6 +369,33 @@ func Test_archiveCataloger_stopsWhenTheContextIsCancelled(t *testing.T) {
|
||||
assert.Zero(t, ran)
|
||||
}
|
||||
|
||||
func Test_archiveCataloger_decompressionBudgetIsSharedByATreeAndFreshPerTopLevelArchive(t *testing.T) {
|
||||
// a recursive bomb fits the limits one level at a time, so what bounds it is the total decompressed
|
||||
// under one top-level archive
|
||||
origRatio, origFloor := archiveTreeRatio, archiveTreeFloorBytes
|
||||
archiveTreeRatio, archiveTreeFloorBytes = 0, 100_000
|
||||
t.Cleanup(func() { archiveTreeRatio, archiveTreeFloorBytes = origRatio, origFloor })
|
||||
|
||||
zeros := make([]byte, 60_000)
|
||||
nested := makeZip(t, map[string][]byte{"zeros.bin": zeros})
|
||||
rootDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "bomb.zip"), makeZip(t, map[string][]byte{"a.zip": nested, "b.zip": nested}), 0o600))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "sibling.zip"), makeZip(t, map[string][]byte{"zeros.bin": zeros}), 0o600))
|
||||
|
||||
var seen []string
|
||||
fileCounts := map[string]int{}
|
||||
tsk := newTestTask(t, cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2), capturingTask(t, &seen, &fileCounts))
|
||||
s := newTestSBOM()
|
||||
require.NoError(t, tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(s)))
|
||||
|
||||
assert.Equal(t, 1, fileCounts["/bomb.zip:a.zip"], "the first nested archive fits the tree's budget")
|
||||
assert.Equal(t, 0, fileCounts["/bomb.zip:b.zip"], "the second does not: the budget is shared, not per archive")
|
||||
reasons := s.Artifacts.Unknowns[file.Coordinates{RealPath: "b.zip", ArchivePath: "/bomb.zip"}]
|
||||
require.Len(t, reasons, 1)
|
||||
assert.Contains(t, reasons[0], "possible decompression bomb")
|
||||
assert.Equal(t, 1, fileCounts["/sibling.zip"], "another top-level archive starts with a fresh budget")
|
||||
}
|
||||
|
||||
func Test_NewArchiveCatalogerTask_dropsItselfFromSubPipeline(t *testing.T) {
|
||||
// a copy of itself in the sub-pipeline would process every nesting level twice
|
||||
cfg := cataloging.DefaultArchiveSearchConfig().WithMaxDepth(2)
|
||||
@@ -949,6 +976,11 @@ func (d dirTestResolver) FileContentsByLocation(loc file.Location) (io.ReadClose
|
||||
return os.Open(filepath.Join(d.dir, filepath.FromSlash(loc.RealPath)))
|
||||
}
|
||||
|
||||
func (d dirTestResolver) FileMetadataByLocation(loc file.Location) (file.Metadata, error) {
|
||||
info, err := os.Stat(filepath.Join(d.dir, filepath.FromSlash(loc.RealPath)))
|
||||
return file.Metadata{FileInfo: info}, err
|
||||
}
|
||||
|
||||
// makeZip builds a zip in sorted entry-name order: limits are enforced as the walk proceeds, so entry
|
||||
// order decides which entries land before a truncation.
|
||||
func makeZip(t *testing.T, entries map[string][]byte) []byte {
|
||||
@@ -1034,6 +1066,11 @@ func (r twoLayerResolver) FileContentsByLocation(loc file.Location) (io.ReadClos
|
||||
return io.NopCloser(bytes.NewReader(body)), nil
|
||||
}
|
||||
|
||||
// FileMetadataByLocation reports no metadata, so the archive falls back to the minimum budget.
|
||||
func (r twoLayerResolver) FileMetadataByLocation(file.Location) (file.Metadata, error) {
|
||||
return file.Metadata{}, os.ErrNotExist
|
||||
}
|
||||
|
||||
// incompressible returns n bytes deflate cannot shrink, so an archive built from them has a
|
||||
// predictable size to set a limit against.
|
||||
func incompressible(n int) []byte {
|
||||
@@ -1091,6 +1128,10 @@ func (a allFilesResolver) FileContentsByLocation(loc file.Location) (io.ReadClos
|
||||
return os.Open(filepath.Join(a.dir, filepath.FromSlash(loc.RealPath)))
|
||||
}
|
||||
|
||||
func (a allFilesResolver) FileMetadataByLocation(loc file.Location) (file.Metadata, error) {
|
||||
return dirTestResolver{dir: a.dir}.FileMetadataByLocation(loc)
|
||||
}
|
||||
|
||||
func (p *recordingPublisher) Publish(e partybus.Event) {
|
||||
p.events = append(p.events, e)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user