fix: don't fail the whole SBOM when a cataloger panics on one file (#5342)

* fix(generic): recover from parser and processor panics

- a panic while parsing one file becomes an unknown for that location instead of failing the whole SBOM
- a panic in a package processor is logged as a warning and the unprocessed results are kept
- stacks go to the trace log, not into the unknown text

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

* fix(unknown): keep errors that unwrap to nil

`sync.PanicError` with a non-error panic value (e.g. `panic("boom")`) unwraps to nil, and was being dropped entirely from the error graph instead of being kept as a leaf.

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

* fix(task): don't put the stack trace in task panic errors

Task panics are still fatal, but the error now reads `panic in task "<name>": <value>` and the stack goes to the debug log.

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

* test(generic): cover chained processors when a later one panics

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

* fix(file): recover from panics in the executable and file digest catalogers

A panic while reading one file is now recorded as an unknown for that file instead of failing the whole SBOM, the same as generic catalogers.

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

---------

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-09-29 09:21:03 -04:00
committed by GitHub
parent f7f3c6efc3
commit 10af8342c0
10 changed files with 261 additions and 12 deletions
+25 -2
View File
@@ -2,6 +2,8 @@ package generic
import (
"context"
"fmt"
"runtime/debug"
"github.com/anchore/go-logger"
"github.com/anchore/go-sync"
@@ -221,12 +223,24 @@ func (c *Cataloger) Catalog(ctx context.Context, resolver file.Resolver) ([]pkg.
func (c *Cataloger) process(ctx context.Context, resolver file.Resolver, pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
for _, p := range c.processors {
pkgs, rels, err = p.process(ctx, resolver, pkgs, rels, err)
pkgs, rels, err = c.processSafely(ctx, p, resolver, pkgs, rels, err)
}
return pkgs, rels, err
}
func invokeParser(ctx context.Context, resolver file.Resolver, location file.Location, logger logger.Logger, parser Parser, env *Environment) ([]pkg.Package, []artifact.Relationship, error) {
func (c *Cataloger) processSafely(ctx context.Context, p processExecutor, resolver file.Resolver, pkgs []pkg.Package, rels []artifact.Relationship, err error) (outPkgs []pkg.Package, outRels []artifact.Relationship, outErr error) {
defer func() {
if r := recover(); r != nil {
// keep the unprocessed results; there is no single location to attach an unknown to
log.WithFields("cataloger", c.upstreamCataloger, "panic", r).Warn("recovered from panic in package processor")
log.Tracef("processor panic stack:\n%s", debug.Stack())
outPkgs, outRels, outErr = pkgs, rels, err
}
}()
return p.process(ctx, resolver, pkgs, rels, err)
}
func invokeParser(ctx context.Context, resolver file.Resolver, location file.Location, logger logger.Logger, parser Parser, env *Environment) (pkgs []pkg.Package, rels []artifact.Relationship, err error) {
contentReader, err := resolver.FileContentsByLocation(location)
if err != nil {
logger.WithFields("location", location.RealPath, "error", err).Debug("unable to fetch contents")
@@ -234,6 +248,15 @@ func invokeParser(ctx context.Context, resolver file.Resolver, location file.Loc
}
defer internal.CloseAndLogError(contentReader, location.AccessPath)
defer func() {
if r := recover(); r != nil {
// a malformed file must not take down the whole SBOM; the caller records this as an unknown
logger.WithFields("location", location.RealPath, "panic", r).Debug("recovered from panic while parsing file")
logger.Tracef("parser panic stack:\n%s", debug.Stack())
pkgs, rels, err = nil, nil, fmt.Errorf("recovered from panic while parsing file: %v", r)
}
}()
discoveredPackages, discoveredRelationships, err := parser(ctx, resolver, env, file.NewLocationReadCloser(location, contentReader))
if err != nil {
// these errors are propagated up, and are likely to be coordinate errors
+113 -4
View File
@@ -10,7 +10,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/go-sync"
"github.com/anchore/syft/internal/unknown"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
@@ -190,12 +189,122 @@ func TestClosesFileOnParserPanic(t *testing.T) {
_, _, err := c.Catalog(ctx, resolver)
require.Error(t, err)
var panicErr sync.PanicError
require.ErrorAs(t, err, &panicErr)
assert.Equal(t, "panic!", panicErr.Value)
var coordErr *unknown.CoordinateError
require.ErrorAs(t, err, &coordErr)
assert.ErrorContains(t, err, "recovered from panic while parsing file: panic!")
require.True(t, spy.closed)
}
func TestParserPanicBecomesUnknown(t *testing.T) {
tests := []struct {
name string
doPanic func()
wantErr string
}{
{
name: "string panic",
doPanic: func() { panic("boom") },
wantErr: "boom",
},
{
name: "runtime panic",
doPanic: func() {
var m map[string]int
m["x"] = 1
},
wantErr: "assignment to entry in nil map",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := func(_ context.Context, _ file.Resolver, _ *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
if reader.Path() == "testdata/another-path.txt" {
tt.doPanic()
}
return []pkg.Package{{Name: "good", Locations: file.NewLocationSet(reader.Location)}}, nil, nil
}
resolver := file.NewMockResolverForPaths("testdata/a-path.txt", "testdata/another-path.txt")
c := NewCataloger("unit-test-cataloger").
WithParserByPath(parser, "testdata/a-path.txt", "testdata/another-path.txt")
pkgs, _, err := c.Catalog(context.Background(), resolver)
require.Len(t, pkgs, 1)
assert.Equal(t, "good", pkgs[0].Name)
unknowns, remaining := unknown.ExtractCoordinateErrors(err)
require.NoError(t, remaining)
require.Len(t, unknowns, 1)
assert.Equal(t, "testdata/another-path.txt", unknowns[0].Coordinates.RealPath)
assert.ErrorContains(t, unknowns[0].Reason, tt.wantErr)
})
}
}
func TestProcessorPanicKeepsResults(t *testing.T) {
parser := func(_ context.Context, _ file.Resolver, _ *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {
return []pkg.Package{{Name: "good", Locations: file.NewLocationSet(reader.Location)}}, nil, nil
}
rename := func(pkgs []pkg.Package, rels []artifact.Relationship, err error) ([]pkg.Package, []artifact.Relationship, error) {
for i := range pkgs {
pkgs[i].Name = "processed"
}
return pkgs, rels, err
}
tests := []struct {
name string
cataloger *Cataloger
want string
}{
{
name: "processor",
cataloger: NewCataloger("unit-test-cataloger").
WithParserByPath(parser, "testdata/a-path.txt").
WithProcessors(func([]pkg.Package, []artifact.Relationship, error) ([]pkg.Package, []artifact.Relationship, error) {
panic("boom")
}),
want: "good",
},
{
// output from earlier processors survives a later processor panicking
name: "chained processors",
cataloger: NewCataloger("unit-test-cataloger").
WithParserByPath(parser, "testdata/a-path.txt").
WithProcessors(rename, func([]pkg.Package, []artifact.Relationship, error) ([]pkg.Package, []artifact.Relationship, error) {
panic("boom")
}),
want: "processed",
},
{
name: "resolving processor",
cataloger: NewCataloger("unit-test-cataloger").
WithParserByPath(parser, "testdata/a-path.txt").
WithResolvingProcessors(func(context.Context, file.Resolver, []pkg.Package, []artifact.Relationship, error) ([]pkg.Package, []artifact.Relationship, error) {
var m map[string]int
m["x"] = 1
return nil, nil, nil
}),
want: "good",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resolver := file.NewMockResolverForPaths("testdata/a-path.txt")
pkgs, _, err := tt.cataloger.Catalog(context.Background(), resolver)
require.NoError(t, err)
require.Len(t, pkgs, 1)
assert.Equal(t, tt.want, pkgs[0].Name)
})
}
}
func Test_CatalogerWithParserByMediaType(t *testing.T) {
allParsedPaths := make(map[string]bool)
parser := func(_ context.Context, resolver file.Resolver, env *Environment, reader file.LocationReadCloser) ([]pkg.Package, []artifact.Relationship, error) {