mirror of
https://github.com/anchore/syft.git
synced 2026-10-31 14:36:33 +01:00
fix: hold archive chains to the same bounds when scanning and decoding
Decoding SPDX, CycloneDX and syft-json rebuilt nested archive chains from whatever the document said, with no checks on entry names, depth or length. A small crafted document could forge archive boundaries or cost memory quadratic in its size (a 3 MB SPDX allocated ~14 GB). - decoders now hold names to the scan's `EntryName` rules, and chains to `MaxDepth` (16) and PATH_MAX in length - a file within a refused chain is dropped, rather than moved to a top-level path or merged into its outer archive - the scan holds itself to the same bounds, so its SBOMs always decode back: a configured `nested-archive-max-depth` past 16 stops at 16, and an entry whose chain would be too long is skipped and the archive reported as truncated Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -228,6 +228,18 @@ func TestResolver_extract_dataEdgeCases(t *testing.T) {
|
||||
assert.ErrorContains(t, r.Truncated, "path components")
|
||||
})
|
||||
|
||||
t.Run("an entry whose chain is longer than a path is skipped and reported", func(t *testing.T) {
|
||||
// the chain is the archive's own path, the separator, then the name
|
||||
fits := strings.Repeat("a", MaxPathBytes-len("test-archive!/"))
|
||||
data := zipBytes(t, map[string]string{
|
||||
fits: "at the cap",
|
||||
fits + "b": "over the cap",
|
||||
})
|
||||
r, _ := extractBytes(t, data, "test.zip", nil)
|
||||
assert.Equal(t, []string{fits}, storedNames(t, r))
|
||||
assert.ErrorContains(t, r.Truncated, "archive chain")
|
||||
})
|
||||
|
||||
t.Run("an over-long entry name is skipped", func(t *testing.T) {
|
||||
data := zipBytes(t, map[string]string{
|
||||
strings.Repeat("a/", maxEntryNameBytes) + "f": "deep",
|
||||
|
||||
@@ -487,7 +487,17 @@ func (entryReader) Close() error { return nil }
|
||||
|
||||
// maxEntryNameBytes is the longest entry name stored: PATH_MAX. Every path component becomes a node,
|
||||
// so a longer name is a cheap way to inflate the index and no filesystem could hold it anyway.
|
||||
const maxEntryNameBytes = 4096
|
||||
const maxEntryNameBytes = MaxPathBytes
|
||||
|
||||
// MaxPathBytes is PATH_MAX, the longest whole archive chain a scan stores or a decoded document may build.
|
||||
// Each nested file copies its container's chain, so without it a long name shared by many small entries
|
||||
// costs memory quadratic in the document.
|
||||
const MaxPathBytes = 4096
|
||||
|
||||
// MaxDepth is the deepest archives nest: a scan stops there whatever depth is configured, and a decoded
|
||||
// document is held to it. No real nesting comes close, and a hostile archive (a zip quine, a recursive bomb)
|
||||
// must still end.
|
||||
const MaxDepth = 16
|
||||
|
||||
// maxEntryNameDepth is the most path components an entry name may have. Adding an entry costs work per
|
||||
// component, and no real archive nests anywhere near this deep.
|
||||
@@ -507,7 +517,7 @@ func (r *Resolver) entryHeader(f archives.FileInfo) (tar.Header, bool) {
|
||||
return tar.Header{}, false
|
||||
}
|
||||
|
||||
name, err := entryName(f.NameInArchive)
|
||||
name, err := EntryName(f.NameInArchive)
|
||||
if err != nil {
|
||||
r.truncate(fmt.Errorf("an entry was skipped: %w", err))
|
||||
return tar.Header{}, false
|
||||
@@ -515,6 +525,11 @@ func (r *Resolver) entryHeader(f archives.FileInfo) (tar.Header, bool) {
|
||||
if name == "" {
|
||||
return tar.Header{}, false
|
||||
}
|
||||
// held to the chain length a decoder accepts back, so the SBOM round-trips
|
||||
if chain := len(r.archiveCoordinates.FullNestedPath()) + len(file.ArchivePathSeparator) + len(name); chain > MaxPathBytes {
|
||||
r.truncate(fmt.Errorf("an entry was skipped: its path of %d bytes within the archive chain exceeds the %d byte cap", chain, MaxPathBytes))
|
||||
return tar.Header{}, false
|
||||
}
|
||||
|
||||
hdr, err := tar.FileInfoHeader(f.FileInfo, f.LinkTarget)
|
||||
if err != nil {
|
||||
@@ -533,11 +548,12 @@ func (r *Resolver) entryHeader(f archives.FileInfo) (tar.Header, bool) {
|
||||
return *hdr, true
|
||||
}
|
||||
|
||||
// entryName cleans an archive entry's name to a path relative to the archive root, returning "" when it
|
||||
// EntryName cleans an archive entry's name to a path relative to the archive root, returning "" when it
|
||||
// names nothing inside the archive. This is the trust boundary for names: every one ends up in FullPath
|
||||
// and the encoders, so a name that could forge an archive boundary ("!/") or a line in a line-oriented
|
||||
// format (control characters, unicode line separators) is refused.
|
||||
func entryName(name string) (string, error) {
|
||||
// format (control characters, unicode line separators) is refused. Decoders building nested coordinates
|
||||
// from a document hold its names to the same rules.
|
||||
func EntryName(name string) (string, error) {
|
||||
if len(name) > maxEntryNameBytes {
|
||||
return "", fmt.Errorf("entry name of %d bytes exceeds the %d byte cap", len(name), maxEntryNameBytes)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ const ArchiveCatalogerTaskName = "archive-cataloger"
|
||||
// unknowns.IncludeUnexpandedArchives it also reports archives in formats it does not extract.
|
||||
//
|
||||
// exclusions are the scan's exclusion patterns, applied inside each archive. Returns nil when archive
|
||||
// cataloging is disabled (MaxDepth == 0; negative means as deep as unboundedArchiveDepth).
|
||||
// cataloging is disabled (MaxDepth == 0; negative or beyond archive.MaxDepth means as deep as archive.MaxDepth).
|
||||
func NewArchiveCatalogerTask(cfg cataloging.ArchiveSearchConfig, unknowns cataloging.UnknownsConfig, pkgTasks, fileTasks []Task, exclusions []string) Task {
|
||||
return newArchiveCatalogerTask(cfg.MaxDepth, archiveLimits(cfg), unknowns.IncludeUnexpandedArchives, pkgTasks, fileTasks, exclusions)
|
||||
}
|
||||
@@ -138,10 +138,6 @@ func (c *archiveCataloger) logStats() {
|
||||
log.WithFields(fields...).Info("nested archive cataloging complete")
|
||||
}
|
||||
|
||||
// unboundedArchiveDepth is the depth a negative MaxDepth stops at. No real nesting comes close, and a
|
||||
// hostile archive (a zip quine, a recursive bomb) must still end.
|
||||
const unboundedArchiveDepth = 16
|
||||
|
||||
// catalog processes every archive in the resolver, recursing into each up to maxDepth.
|
||||
func (c *archiveCataloger) catalog(ctx context.Context, resolver file.Resolver, depth int, builder sbomsync.Builder) error {
|
||||
return c.catalogCandidates(ctx, resolver, c.discoverArchives(resolver), depth, builder)
|
||||
@@ -149,8 +145,9 @@ func (c *archiveCataloger) catalog(ctx context.Context, resolver file.Resolver,
|
||||
|
||||
func (c *archiveCataloger) catalogCandidates(ctx context.Context, resolver file.Resolver, candidates []archiveCandidate, depth int, builder sbomsync.Builder) error {
|
||||
maxDepth := c.maxDepth
|
||||
if maxDepth < 0 {
|
||||
maxDepth = unboundedArchiveDepth
|
||||
// a deeper scan would write chains no decoder accepts back
|
||||
if maxDepth < 0 || maxDepth > archive.MaxDepth {
|
||||
maxDepth = archive.MaxDepth
|
||||
}
|
||||
if depth >= maxDepth {
|
||||
// java's own recursion is off while this task runs, so an archive left here is otherwise lost
|
||||
|
||||
@@ -247,20 +247,25 @@ func Test_NewArchiveCatalogerTask_gating(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_archiveCataloger_negativeDepthStopsAtTheCeiling(t *testing.T) {
|
||||
// "unbounded" must still end, or a zip quine or recursive bomb runs forever
|
||||
func Test_archiveCataloger_depthStopsAtTheCeiling(t *testing.T) {
|
||||
// "unbounded" must still end, or a zip quine or recursive bomb runs forever, and a configured depth
|
||||
// past the ceiling must too, or the SBOM holds chains no decoder accepts back
|
||||
chain := testutils.Zip(t, map[string][]byte{"deep.txt": []byte("deep")})
|
||||
for range unboundedArchiveDepth + 4 {
|
||||
for range archive.MaxDepth + 4 {
|
||||
chain = testutils.Zip(t, map[string][]byte{"n.zip": chain})
|
||||
}
|
||||
rootDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(rootDir, "outer.zip"), chain, 0o600))
|
||||
|
||||
var seen []string
|
||||
tsk := newTestTask(t, cataloging.DefaultArchiveSearchConfig().WithMaxDepth(-1), capturingTask(t, &seen, nil))
|
||||
_ = tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(newTestSBOM()))
|
||||
for _, depth := range []int{-1, archive.MaxDepth + 2} {
|
||||
t.Run(fmt.Sprint(depth), func(t *testing.T) {
|
||||
var seen []string
|
||||
tsk := newTestTask(t, cataloging.DefaultArchiveSearchConfig().WithMaxDepth(depth), capturingTask(t, &seen, nil))
|
||||
_ = tsk.Execute(context.Background(), dirTestResolver{dir: rootDir}, sbomsync.NewBuilder(newTestSBOM()))
|
||||
|
||||
assert.Len(t, seen, unboundedArchiveDepth)
|
||||
assert.Len(t, seen, archive.MaxDepth)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_archiveCataloger_anArchiveIdenticalToAnAncestorIsNotEntered(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user