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 <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-29 15:50:03 -04:00
parent c38a68e9de
commit 07c2c816c6
5 changed files with 154 additions and 14 deletions
+17 -13
View File
@@ -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 {
@@ -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)
}
}
})
}
}
@@ -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 != "/"
}
@@ -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...)))
}
+2 -1
View File
@@ -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