fix: normalize decoded FullPath

Nothing checked that a decoded `FullPath` actually extends `RealPath` with an archive chain. The SPDX
decoder sets `FullPath` to the file name even when the name doesn't end with the comment's chain, and
syft-json takes whatever is in the document. `{RealPath: "/x", FullPath: "/x"}` and `{RealPath: "/x"}`
share an ID but are different map keys, so decoded files, unknowns and locations could fail to line
up with the same coordinates built by a resolver.

The syft-json and SPDX decoders now clear a `FullPath` that equals `RealPath` or doesn't start with
`RealPath` + `!/`.

Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
Alex Goodman
2026-10-21 11:32:50 -04:00
parent b9fdabc2f6
commit f0129112be
5 changed files with 71 additions and 5 deletions
@@ -549,7 +549,7 @@ func coordinatesFromFile(name, comment, id string) file.Coordinates {
if v, ok := strings.CutPrefix(id, layerIDCommentPrefix); ok { if v, ok := strings.CutPrefix(id, layerIDCommentPrefix); ok {
c.FileSystemID = v c.FileSystemID = v
} }
return c return internal.NormalizeCoordinates(c)
} }
func toSyftLocation(f *spdx.File) file.Location { func toSyftLocation(f *spdx.File) file.Location {
@@ -789,3 +789,12 @@ func Test_skipsPackagesWithGeneratedFromRelationship(t *testing.T) {
assert.NotNil(t, s.Artifacts.Packages.Package("1")) assert.NotNil(t, s.Artifacts.Packages.Package("1"))
assert.Nil(t, s.Artifacts.Packages.Package("1-src")) assert.Nil(t, s.Artifacts.Packages.Package("1-src"))
} }
func Test_coordinatesFromFile_nonCanonicalChainIsDropped(t *testing.T) {
// a chain the name doesn't end with leaves FullPath equal to RealPath, which must decode as a plain file
got := coordinatesFromFile("/app.jar", "archivePath: other.jar!/x", "")
assert.Equal(t, file.NewCoordinates("/app.jar", ""), got)
nested := coordinatesFromFile("/app.war!/lib/a.jar", "archivePath: lib/a.jar", "")
assert.Equal(t, file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/lib/a.jar"}, nested)
}
+25
View File
@@ -0,0 +1,25 @@
package internal
import (
"strings"
"github.com/anchore/syft/syft/file"
)
// NormalizeCoordinates clears a decoded FullPath that adds no archive chain to RealPath (one equal to
// RealPath, or one not starting with RealPath and "!/"), so decoded coordinates compare equal to the
// ones a resolver builds.
func NormalizeCoordinates(c file.Coordinates) file.Coordinates {
if !strings.HasPrefix(c.FullPath, c.RealPath+file.ArchivePathSeparator) {
c.FullPath = ""
}
return c
}
// NormalizeLocations applies NormalizeCoordinates to each location in place.
func NormalizeLocations(locations []file.Location) []file.Location {
for i := range locations {
locations[i].Coordinates = NormalizeCoordinates(locations[i].Coordinates)
}
return locations
}
+4 -4
View File
@@ -80,7 +80,7 @@ func toSyftFiles(files []model.File) sbom.Artifacts {
} }
for _, f := range files { for _, f := range files {
coord := f.Location coord := internal.NormalizeCoordinates(f.Location)
if f.Metadata != nil { if f.Metadata != nil {
fm, err := safeFileModeConvert(f.Metadata.Mode) fm, err := safeFileModeConvert(f.Metadata.Mode)
if err != nil { if err != nil {
@@ -163,7 +163,7 @@ func toSyftLicenses(m []model.License) (p []pkg.License) {
SPDXExpression: l.SPDXExpression, SPDXExpression: l.SPDXExpression,
Type: l.Type, Type: l.Type,
URLs: l.URLs, URLs: l.URLs,
Locations: file.NewLocationSet(l.Locations...), Locations: file.NewLocationSet(internal.NormalizeLocations(l.Locations)...),
Contents: l.Contents, Contents: l.Contents,
}) })
} }
@@ -239,7 +239,7 @@ func toSyftRelationships(doc *model.Document, catalog *pkg.Collection, relations
idMap[doc.Source.ID] = toSyftSource(doc.Source) idMap[doc.Source.ID] = toSyftSource(doc.Source)
for _, f := range doc.Files { for _, f := range doc.Files {
idMap[f.ID] = f.Location idMap[f.ID] = internal.NormalizeCoordinates(f.Location)
} }
var out []artifact.Relationship var out []artifact.Relationship
@@ -346,7 +346,7 @@ func toSyftPackage(p model.Package, idAliases map[string]string) pkg.Package {
Name: p.Name, Name: p.Name,
Version: p.Version, Version: p.Version,
FoundBy: p.FoundBy, FoundBy: p.FoundBy,
Locations: file.NewLocationSet(p.Locations...), Locations: file.NewLocationSet(internal.NormalizeLocations(p.Locations)...),
Licenses: pkg.NewLicenseSet(toSyftLicenses(p.Licenses)...), Licenses: pkg.NewLicenseSet(toSyftLicenses(p.Licenses)...),
Language: p.Language, Language: p.Language,
Type: p.Type, Type: p.Type,
@@ -599,3 +599,35 @@ func Test_safeFileModeConvert(t *testing.T) {
}) })
} }
} }
func Test_toSyftModel_fullPathIsNormalized(t *testing.T) {
plain := file.NewCoordinates("/app.jar", "")
nested := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/lib/a.jar"}
decoded := []file.Coordinates{
{RealPath: "/app.jar", FullPath: "/app.jar"},
{RealPath: "/app.jar", FullPath: "/elsewhere!/app.jar"},
nested,
}
want := []file.Coordinates{plain, plain, nested}
var files []model.File
for _, c := range decoded {
files = append(files, model.File{ID: string(c.ID()), Location: c, Unknowns: []string{"x"}})
}
got := toSyftFiles(files)
for _, c := range want {
assert.Contains(t, got.Unknowns, c)
}
assert.Len(t, got.Unknowns, 2)
var locations []file.Location
for _, c := range decoded {
locations = append(locations, file.NewLocationFromCoordinates(c))
}
p := toSyftPackage(model.Package{PackageBasicData: model.PackageBasicData{Locations: locations}}, nil)
var gotCoordinates []file.Coordinates
for _, l := range p.Locations.ToSlice() {
gotCoordinates = append(gotCoordinates, l.Coordinates)
}
assert.ElementsMatch(t, []file.Coordinates{plain, plain, nested}, gotCoordinates)
}