From 07c2c816c6e483527e9aa71558cb20f6834cc7f0 Mon Sep 17 00:00:00 2001 From: Alex Goodman Date: Thu, 29 Oct 2026 15:50:03 -0400 Subject: [PATCH] fix: keep embedded SBOM relationships from adding files to the scan The bitnami and `sbom` catalogers decode SBOMs found in the scanned image and kept the relationships from them. The decoder nests a file under another the SBOM says CONTAINS it, and makes a file the root package DESCRIBES the scan root, so an embedded SBOM could inject archive chains or root files that encoders then rebuilt in the output. - both catalogers now drop relationships with a file endpoint that is nested or at the scan root - bitnami also keeps only relationships from its own packages Signed-off-by: Alex Goodman --- syft/pkg/cataloger/bitnami/cataloger.go | 30 +++++---- syft/pkg/cataloger/bitnami/cataloger_test.go | 66 +++++++++++++++++++ .../internal/embeddedsbom/relationships.go | 36 ++++++++++ .../embeddedsbom/relationships_test.go | 33 ++++++++++ syft/pkg/cataloger/sbom/cataloger.go | 3 +- 5 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 syft/pkg/cataloger/internal/embeddedsbom/relationships.go create mode 100644 syft/pkg/cataloger/internal/embeddedsbom/relationships_test.go diff --git a/syft/pkg/cataloger/bitnami/cataloger.go b/syft/pkg/cataloger/bitnami/cataloger.go index d505088a7..8c4c6bb23 100644 --- a/syft/pkg/cataloger/bitnami/cataloger.go +++ b/syft/pkg/cataloger/bitnami/cataloger.go @@ -14,6 +14,7 @@ import ( "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" + "github.com/anchore/syft/syft/pkg/cataloger/internal/embeddedsbom" ) const catalogerName = "bitnami-cataloger" @@ -109,24 +110,27 @@ func parseSBOM(_ context.Context, resolver file.Resolver, _ *generic.Environment return pkgs, filterRelationships(s.Relationships, pkgs), nil } -// filterRelationships filters out relationships that are not related to Bitnami packages +// filterRelationships filters out relationships that are not from Bitnami packages // and replaces the package information with the one with completed info func filterRelationships(relationships []artifact.Relationship, pkgs []pkg.Package) []artifact.Relationship { var result []artifact.Relationship - for _, r := range relationships { - if value, ok := r.From.(pkg.Package); ok { - found := false - for _, p := range pkgs { - if value.PURL == p.PURL { - r.From = p - found = true - break - } - } - if !found { - continue + for _, r := range embeddedsbom.Relationships(relationships) { + // only what a package says about itself is kept + value, ok := r.From.(pkg.Package) + if !ok { + continue + } + found := false + for _, p := range pkgs { + if value.PURL == p.PURL { + r.From = p + found = true + break } } + if !found { + continue + } if value, ok := r.To.(pkg.Package); ok { found := false for _, p := range pkgs { diff --git a/syft/pkg/cataloger/bitnami/cataloger_test.go b/syft/pkg/cataloger/bitnami/cataloger_test.go index 624b8ab1c..04d7a816f 100644 --- a/syft/pkg/cataloger/bitnami/cataloger_test.go +++ b/syft/pkg/cataloger/bitnami/cataloger_test.go @@ -2,6 +2,8 @@ package bitnami import ( "context" + "io" + "strings" "testing" "github.com/stretchr/testify/require" @@ -622,3 +624,67 @@ func TestBitnamiCataloger(t *testing.T) { }) } } + +func Test_filterRelationships_keepsOnlyPackageRelationships(t *testing.T) { + p := pkg.Package{Name: "postgresql", PURL: "pkg:bitnami/postgresql@17.2.0"} + jar := file.NewLocation("app.jar") + inner := file.NewLocation("BOOT-INF/lib/log4j-core-2.14.1.jar") + // as the decoder nests it when the SBOM also says the jar CONTAINS it + nested := file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/app.jar", FullPath: "/app.jar!/BOOT-INF/lib/log4j-core-2.14.1.jar"}) + relationships := []artifact.Relationship{ + {From: p, To: jar, Type: artifact.ContainsRelationship}, + {From: jar, To: inner, Type: artifact.ContainsRelationship}, + {From: jar, To: p, Type: artifact.ContainsRelationship}, + {From: p, To: nested, Type: artifact.ContainsRelationship}, + } + + got := filterRelationships(relationships, []pkg.Package{p}) + + require.Equal(t, relationships[:1], got) +} + +func Test_parseSBOM_nestingInTheSBOMIsNotKept(t *testing.T) { + // a file the SBOM says another CONTAINS decodes as nested within it, and one the SBOM DESCRIBES as the scan + // root; neither is anything the scan found + const head = `{ + "spdxVersion":"SPDX-2.3","dataLicense":"CC0-1.0","SPDXID":"SPDXRef-DOCUMENT","name":"x","documentNamespace":"https://x/y", + "creationInfo":{"created":"2024-01-01T00:00:00Z","creators":["Tool: x"]}, + "packages":[{"name":"postgresql","SPDXID":"SPDXRef-pg","versionInfo":"17.2.0","downloadLocation":"NONE", + "externalRefs":[{"referenceCategory":"PACKAGE-MANAGER","referenceType":"purl","referenceLocator":"pkg:bitnami/postgresql@17.2.0"}]}], + "files":[{"fileName":"app.jar","SPDXID":"SPDXRef-F1","checksums":[{"algorithm":"SHA1","checksumValue":"0000000000000000000000000000000000000000"}]}, + {"fileName":"BOOT-INF/lib/log4j-core-2.14.1.jar","SPDXID":"SPDXRef-F2","checksums":[{"algorithm":"SHA1","checksumValue":"0000000000000000000000000000000000000000"}]}], + "relationships":[` + tests := []struct { + name string + relationships string + }{ + { + name: "nested", + relationships: `{"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-pg"}, + {"spdxElementId":"SPDXRef-pg","relationshipType":"CONTAINS","relatedSpdxElement":"SPDXRef-F2"}, + {"spdxElementId":"SPDXRef-F1","relationshipType":"CONTAINS","relatedSpdxElement":"SPDXRef-F2"}`, + }, + { + name: "scan root", + relationships: `{"spdxElementId":"SPDXRef-DOCUMENT","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-pg"}, + {"spdxElementId":"SPDXRef-pg","relationshipType":"DESCRIBES","relatedSpdxElement":"SPDXRef-F1"}, + {"spdxElementId":"SPDXRef-pg","relationshipType":"CONTAINS","relatedSpdxElement":"SPDXRef-F1"}, + {"spdxElementId":"SPDXRef-F1","relationshipType":"CONTAINS","relatedSpdxElement":"SPDXRef-F2"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + loc := file.NewLocation("/opt/bitnami/postgresql/.spdx-postgresql.spdx") + doc := head + tt.relationships + "]}" + _, relationships, err := parseSBOM(context.Background(), file.NewMockResolverForPaths(), nil, file.NewLocationReadCloser(loc, io.NopCloser(strings.NewReader(doc)))) + require.NoError(t, err) + + for _, r := range relationships { + if l, ok := r.To.(file.Location); ok { + require.Empty(t, l.FullPath, "%+v", r) + require.NotContains(t, []string{"", "/"}, l.RealPath, "%+v", r) + } + } + }) + } +} diff --git a/syft/pkg/cataloger/internal/embeddedsbom/relationships.go b/syft/pkg/cataloger/internal/embeddedsbom/relationships.go new file mode 100644 index 000000000..1010f592e --- /dev/null +++ b/syft/pkg/cataloger/internal/embeddedsbom/relationships.go @@ -0,0 +1,36 @@ +/* +Package embeddedsbom holds what catalogers that read an SBOM found within the scan share. +*/ +package embeddedsbom + +import ( + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" +) + +// Relationships keeps the relationships decoded from an embedded SBOM that may enter the scan's own results. A +// file the SBOM nests within an archive, or makes the scan root, is nothing this scan found, and encoders would +// rebuild archives and roots from it. +func Relationships(relationships []artifact.Relationship) []artifact.Relationship { + var out []artifact.Relationship + for _, r := range relationships { + if plainFile(r.From) && plainFile(r.To) { + out = append(out, r) + } + } + return out +} + +// plainFile reports false for a file endpoint nested in an archive or at the scan root, and true otherwise. +func plainFile(v artifact.Identifiable) bool { + var c file.Coordinates + switch v := v.(type) { + case file.Location: + c = v.Coordinates + case file.Coordinates: + c = v + default: + return true + } + return c.FullPath == "" && c.RealPath != "" && c.RealPath != "/" +} diff --git a/syft/pkg/cataloger/internal/embeddedsbom/relationships_test.go b/syft/pkg/cataloger/internal/embeddedsbom/relationships_test.go new file mode 100644 index 000000000..0638c7a40 --- /dev/null +++ b/syft/pkg/cataloger/internal/embeddedsbom/relationships_test.go @@ -0,0 +1,33 @@ +package embeddedsbom + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/anchore/syft/syft/artifact" + "github.com/anchore/syft/syft/file" + "github.com/anchore/syft/syft/pkg" +) + +func TestRelationships(t *testing.T) { + p := pkg.Package{Name: "postgresql"} + flat := file.NewLocation("/opt/app.jar") + nested := file.NewLocationFromCoordinates(file.Coordinates{RealPath: "/app.jar", FullPath: "/app.jar!/lib/a.jar"}) + root := file.NewLocation("") + + keep := []artifact.Relationship{ + {From: p, To: p, Type: artifact.DependencyOfRelationship}, + {From: p, To: flat, Type: artifact.ContainsRelationship}, + {From: p, To: flat.Coordinates, Type: artifact.ContainsRelationship}, + } + drop := []artifact.Relationship{ + {From: p, To: nested, Type: artifact.ContainsRelationship}, + {From: p, To: nested.Coordinates, Type: artifact.ContainsRelationship}, + {From: p, To: root, Type: artifact.ContainsRelationship}, + {From: p, To: file.NewLocation("/"), Type: artifact.ContainsRelationship}, + {From: root, To: flat, Type: artifact.ContainsRelationship}, + } + + assert.Equal(t, keep, Relationships(append(append([]artifact.Relationship{}, keep...), drop...))) +} diff --git a/syft/pkg/cataloger/sbom/cataloger.go b/syft/pkg/cataloger/sbom/cataloger.go index 550464890..17a036895 100644 --- a/syft/pkg/cataloger/sbom/cataloger.go +++ b/syft/pkg/cataloger/sbom/cataloger.go @@ -15,6 +15,7 @@ import ( "github.com/anchore/syft/syft/format" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/pkg/cataloger/generic" + "github.com/anchore/syft/syft/pkg/cataloger/internal/embeddedsbom" ) const catalogerName = "sbom-cataloger" @@ -53,7 +54,7 @@ func parseSBOM(_ context.Context, _ file.Resolver, _ *generic.Environment, reade } var pkgs []pkg.Package - relationships := s.Relationships + relationships := embeddedsbom.Relationships(s.Relationships) for _, p := range s.Artifacts.Packages.Sorted() { // replace all locations on the package with the location of the SBOM file. // Why not keep the original list of locations? Since the "locations" field is meant to capture