From 071d7a4461e6245400854af394608ee8cfdd61f3 Mon Sep 17 00:00:00 2001 From: Keith Zantow Date: Wed, 28 Oct 2026 21:30:36 -0400 Subject: [PATCH] fix: spdx and cyclonedx Signed-off-by: Keith Zantow --- .../common/spdxhelpers/to_format_model.go | 166 +++++-- .../spdxhelpers/to_format_model_test.go | 418 ++++++++++++++---- .../common/spdxhelpers/to_syft_model.go | 46 +- .../common/spdxhelpers/to_syft_model_v3.go | 49 +- syft/format/spdxjson/encoder_test.go | 81 ++++ syft/format/spdxtagvalue/encoder_test.go | 4 +- 6 files changed, 622 insertions(+), 142 deletions(-) diff --git a/syft/format/common/spdxhelpers/to_format_model.go b/syft/format/common/spdxhelpers/to_format_model.go index 40963c047..bd347ffe0 100644 --- a/syft/format/common/spdxhelpers/to_format_model.go +++ b/syft/format/common/spdxhelpers/to_format_model.go @@ -6,6 +6,8 @@ import ( "crypto/sha1" "fmt" "path" + "path/filepath" + "slices" "sort" "strings" "time" @@ -52,7 +54,8 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document { rels := relationship.NewIndex(s.Relationships...) packages, otherLicenses := toPackages(rels, s.Artifacts.Packages, s) - files, archiveRelationships := toFiles(s) + sourceFile := toSourceFile(s) + files, archiveRelationships := toFiles(s, sourceFile) allRelationships := append(toRelationships(rels.All()), archiveRelationships...) // for valid SPDX we need a document describes relationship @@ -62,7 +65,7 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document { if rootPackage != nil { describesID = rootPackage.PackageSPDXIdentifier - allRelationships = append(allRelationships, toRootRelationships(rootPackage, packages, files, allRelationships)...) + allRelationships = append(allRelationships, toRootRelationships(rootPackage, sourceFile, packages, files, allRelationships)...) // append the root package packages = append(packages, rootPackage) @@ -160,38 +163,58 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document { } } -// toRootRelationships relates the root package to each package and file nothing else CONTAINS: what is -// within an archive is reached only through the archive's file, and an owned file through its package. -func toRootRelationships(rootPackage *spdx.Package, packages []*spdx.Package, files []*spdx.File, relationships []*spdx.Relationship) (out []*spdx.Relationship) { - contained := map[spdx.ElementID]bool{} +// toRootRelationships relates the root package to every package, and to each file nothing else CONTAINS: what is +// within an archive is reached only through the archive's file, and an owned file through its package. When the +// source is itself a file, the root package DESCRIBES it, and a scanned directory CONTAINS those files instead. A +// package containing files must have analyzed them, so the root package then gets a verification code over them. +func toRootRelationships(rootPackage *spdx.Package, sourceFile *file.Coordinates, packages []*spdx.Package, files []*spdx.File, relationships []*spdx.Relationship) (out []*spdx.Relationship) { + contained := map[spdx.ElementID]struct{}{} for _, r := range relationships { if r.Relationship == string(helpers.ContainsRelationship) { - contained[r.RefB.ElementRefID] = true + contained[r.RefB.ElementRefID] = struct{}{} + } + } + relate := func(a spdx.ElementID, typ helpers.RelationshipType, b spdx.ElementID) { + out = append(out, &spdx.Relationship{ + RefA: spdx.DocElementID{ElementRefID: a}, + Relationship: string(typ), + RefB: spdx.DocElementID{ElementRefID: b}, + }) + } + + fileContainer := rootPackage.PackageSPDXIdentifier + if sourceFile != nil { + id := toSPDXID(*sourceFile) + relate(rootPackage.PackageSPDXIdentifier, helpers.DescribesRelationship, id) + contained[id] = struct{}{} + if isScanRoot(*sourceFile) { + fileContainer = id } } - var ids []spdx.ElementID for _, p := range packages { - ids = append(ids, p.PackageSPDXIdentifier) + relate(rootPackage.PackageSPDXIdentifier, helpers.ContainsRelationship, p.PackageSPDXIdentifier) } + var sha1s []string for _, f := range files { - ids = append(ids, f.FileSPDXIdentifier) - } - for _, id := range ids { - if contained[id] { + if _, has := contained[f.FileSPDXIdentifier]; has { continue } - out = append(out, &spdx.Relationship{ - RefA: spdx.DocElementID{ - ElementRefID: rootPackage.PackageSPDXIdentifier, - }, - Relationship: string(helpers.ContainsRelationship), - RefB: spdx.DocElementID{ - ElementRefID: id, - }, - }) + relate(fileContainer, helpers.ContainsRelationship, f.FileSPDXIdentifier) + if fileContainer != rootPackage.PackageSPDXIdentifier { + continue + } + for _, c := range f.Checksums { + if c.Algorithm == spdx.SHA1 { + sha1s = append(sha1s, c.Value) + break + } + } } - return + + rootPackage.PackageVerificationCode = toVerificationCode(sha1s) + rootPackage.FilesAnalyzed = rootPackage.PackageVerificationCode != nil + return out } //nolint:funlen @@ -728,10 +751,19 @@ const layerIDCommentPrefix = "layerID: " // toFiles describes every file, including each archive holding one. A file within an archive is named by // its path in that archive, and the archive CONTAINS it. -func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Relationship) { +func toFiles(s sbom.SBOM, sourceFile *file.Coordinates) (results []*spdx.File, archiveRelationships []*spdx.Relationship) { _, coordinateSorter := formatInternal.GetLocationSorters(s) top, entries := formatInternal.ArchiveEntries(s.AllCoordinates(), coordinateSorter) + // a file holding entries is an archive, whatever its content starts with (such as a launch script) + describe := func(c file.Coordinates, name string) *spdx.File { + f := toFile(s.Artifacts, c, name) + if len(entries[c]) > 0 { + f.FileTypes = withArchiveType(f.FileTypes) + } + return f + } + var addEntries func(archive file.Coordinates) addEntries = func(archive file.Coordinates) { for _, c := range entries[archive] { @@ -740,7 +772,7 @@ func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Re Relationship: string(helpers.ContainsRelationship), RefB: spdx.DocElementID{ElementRefID: toSPDXID(c)}, }) - results = append(results, toFile(s.Artifacts, c, strings.TrimPrefix(c.LocalPath(), "/"))) + results = append(results, describe(c, strings.TrimPrefix(c.LocalPath(), "/"))) addEntries(c) } } @@ -750,7 +782,24 @@ func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Re log.Debugf("unable to convert relative path '%s' to absolute path: %s", c.RealPath, err) name = c.RealPath } - results = append(results, toFile(s.Artifacts, c, name)) + var archive *source.FileMetadata + if isScanRoot(c) { + // the scanned root is the root package, unless it is the file the source is + if sourceFile == nil || c != *sourceFile { + continue + } + name = "." + if m, ok := s.Source.Metadata.(source.FileMetadata); ok { + archive = &m + name = filepath.Base(m.Path) + } + } + f := describe(c, name) + if archive != nil { + // an extracted archive is the archive file, not the directory it was extracted to + f.FileTypes = withArchiveType(toFileTypes(&file.Metadata{MIMEType: archive.MIMEType})) + } + results = append(results, f) addEntries(c) } @@ -764,6 +813,47 @@ func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Re return results, archiveRelationships } +// toSourceFile finds the file the scanned source is: a directory, or an extracted archive, is the scanned root, +// while a single file is indexed beside its parent directory, which was not scanned. +func toSourceFile(s sbom.SBOM) *file.Coordinates { + var top []file.Coordinates + for _, c := range s.AllCoordinates() { + if c.FullNestedPath() == c.RealPath { + top = append(top, c) + } + } + match := isScanRoot + switch m := s.Source.Metadata.(type) { + case source.DirectoryMetadata: + case source.FileMetadata: + name := filepath.Base(m.Path) + single := func(c file.Coordinates) bool { return strings.TrimPrefix(c.RealPath, "/") == name } + if slices.ContainsFunc(top, single) { + match = single + } + default: + return nil + } + i := slices.IndexFunc(top, match) + if i < 0 { + return nil + } + return &top[i] +} + +// withArchiveType adds the archive file type, in place of other +func withArchiveType(types []string) []string { + if slices.Contains(types, string(helpers.ArchiveFileType)) { + return types + } + types = slices.DeleteFunc(types, func(t string) bool { return t == string(helpers.OtherFileType) }) + return append(types, string(helpers.ArchiveFileType)) +} + +func isScanRoot(c file.Coordinates) bool { + return c.RealPath == "" || c.RealPath == "/" +} + func toFile(artifacts sbom.Artifacts, c file.Coordinates, name string) *spdx.File { var metadata *file.Metadata if metadataForLocation, exists := artifacts.FileMetadata[c]; exists { @@ -879,7 +969,7 @@ func newPackageVerificationCode(rels *relationship.Index, p pkg.Package, sbom sb // if a sha1 for a file is provided, then the validator will fail if the package does not have // a package verification code coordinates := rels.Coordinates(p, artifact.ContainsRelationship) - var digests []file.Digest + var sha1s []string for _, c := range coordinates { digest := sbom.Artifacts.FileDigests[c] if len(digest) == 0 { @@ -893,27 +983,23 @@ func newPackageVerificationCode(rels *relationship.Index, p pkg.Package, sbom sb break } } - digests = append(digests, d) + sha1s = append(sha1s, d.Value) } + return toVerificationCode(sha1s) +} - if len(digests) == 0 { +func toVerificationCode(sha1s []string) *spdx.PackageVerificationCode { + if len(sha1s) == 0 { return nil } // sort templist in ascending order by SHA1 value - sort.SliceStable(digests, func(i, j int) bool { - return digests[i].Value < digests[j].Value - }) + sort.Strings(sha1s) // filelist = templist with "/n"s removed. /* ordered sequence of SHA1 values with no separators - var b strings.Builder - for _, digest := range digests { - b.WriteString(digest.Value) - } - //nolint:gosec hasher := sha1.New() - _, _ = hasher.Write([]byte(b.String())) + _, _ = hasher.Write([]byte(strings.Join(sha1s, ""))) return &spdx.PackageVerificationCode{ // 7.9.1: Package Verification Code Value // Cardinality: mandatory, one @@ -934,10 +1020,6 @@ func trimPatchVersion(semver string) string { // spdx requires that the file name field is a relative filename // with the root of the package archive or directory func convertAbsoluteToRelative(absPath string) (string, error) { - // the root itself; an empty file name is not valid SPDX (tag-value omits it and cannot read it back) - if absPath == "" || absPath == "/" { - return ".", nil - } // Ensure the absolute path is absolute (although it should already be) if !path.IsAbs(absPath) { // already relative diff --git a/syft/format/common/spdxhelpers/to_format_model_test.go b/syft/format/common/spdxhelpers/to_format_model_test.go index 2ef2544ef..b1786d42a 100644 --- a/syft/format/common/spdxhelpers/to_format_model_test.go +++ b/syft/format/common/spdxhelpers/to_format_model_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "regexp" + "slices" "strings" "testing" @@ -557,7 +558,7 @@ func Test_toFiles(t *testing.T) { tests := []struct { name string in sbom.SBOM - want spdx.File + want []string }{ { name: "File paths are converted to relative in final SPDX collection", @@ -585,31 +586,56 @@ func Test_toFiles(t *testing.T) { }, }, }, - want: spdx.File{ - FileName: "some/path", + want: []string{"some/path"}, + }, + { + name: "the scanned root is the root package, not a file", + in: sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: ""}: {Type: stfile.TypeDirectory}, + {RealPath: "/etc"}: {Type: stfile.TypeDirectory}, + }}}, + want: []string{"etc"}, + }, + { + name: "a scanned directory is a file", + in: sbom.SBOM{ + Source: source.Description{Metadata: source.DirectoryMetadata{Path: "/dir"}}, + Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: ""}: {Type: stfile.TypeDirectory}, + {RealPath: "/etc"}: {Type: stfile.TypeDirectory}, + }}, }, + want: []string{".", "etc"}, }, { - name: "the scanned root has a name", - in: sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ - {RealPath: ""}: {Type: stfile.TypeDirectory}, - }}}, - want: spdx.File{FileName: "."}, + name: "a scanned file is indexed beside its parent directory, which was not scanned", + in: sbom.SBOM{ + Source: source.Description{Metadata: source.FileMetadata{Path: "/dir/go.mod"}}, + Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ + {RealPath: ""}: {Type: stfile.TypeDirectory}, + {RealPath: "/go.mod"}: {Type: stfile.TypeRegular}, + }}, + }, + want: []string{"go.mod"}, }, { - name: "the absolute root has a name", + name: "the absolute root is the root package, not a file", in: sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ - {RealPath: "/"}: {Type: stfile.TypeDirectory}, + {RealPath: "/"}: {Type: stfile.TypeDirectory}, + {RealPath: "/etc"}: {Type: stfile.TypeDirectory}, }}}, - want: spdx.File{FileName: "."}, + want: []string{"etc"}, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - files, _ := toFiles(test.in) - require.Len(t, files, 1) - assert.Equal(t, test.want.FileName, files[0].FileName) + files, _ := toFiles(test.in, toSourceFile(test.in)) + var names []string + for _, f := range files { + names = append(names, f.FileName) + } + assert.Equal(t, test.want, names) }) } } @@ -1179,93 +1205,279 @@ func Test_nestedArchiveFiles(t *testing.T) { inB := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/b.jar!/odd:na%me.txt", FileSystemID: "sha256:layer"} owned := file.NewCoordinates("/usr/bin/tool", "sha256:layer") a := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/a.jar", FileSystemID: "sha256:layer"} + war := file.NewCoordinates("/app.war", "sha256:layer") + b := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/b.jar", FileSystemID: "sha256:layer"} topPkg := pkg.Package{Name: "top"} topPkg.SetID() nestedPkg := pkg.Package{Name: "nested"} nestedPkg.SetID() - s := sbom.SBOM{ - Artifacts: sbom.Artifacts{ - Packages: pkg.NewCollection(topPkg, nestedPkg), - FileMetadata: map[file.Coordinates]file.Metadata{top: {}, inA: {}, inB: {}, owned: {}}, + + sources := []source.Description{ + {Name: "img", Metadata: source.ImageMetadata{UserInput: "img:latest", ManifestDigest: "sha256:abc"}}, + {Name: "dir", Metadata: source.DirectoryMetadata{Path: "/dir"}}, + } + for _, src := range sources { + t.Run(src.Name, func(t *testing.T) { + s := sbom.SBOM{ + Source: src, + Artifacts: sbom.Artifacts{ + Packages: pkg.NewCollection(topPkg, nestedPkg), + FileMetadata: map[file.Coordinates]file.Metadata{top: {}, inA: {}, inB: {}, owned: {}}, + }, + Relationships: []artifact.Relationship{ + {From: topPkg, To: owned, Type: artifact.ContainsRelationship}, + {From: a, To: nestedPkg, Type: artifact.ContainsRelationship}, + }, + } + + doc := ToFormatModel(s) + + // each archive holding a described file is described too, named by its path in its own container + names := map[spdx.ElementID]string{} + for _, f := range doc.Files { + names[f.FileSPDXIdentifier] = f.FileName + assert.Equal(t, "layerID: sha256:layer", f.FileComment) + } + assert.Equal(t, map[spdx.ElementID]string{ + toSPDXID(top): "etc/os-release", + toSPDXID(owned): "usr/bin/tool", + toSPDXID(war): "app.war", + toSPDXID(a): "WEB-INF/lib/a.jar", + toSPDXID(b): "WEB-INF/lib/b.jar", + toSPDXID(inA): "META-INF/MANIFEST.MF", + toSPDXID(inB): "odd:na%me.txt", + }, names) + + var described []spdx.ElementID + var contains [][2]spdx.ElementID + for _, r := range doc.Relationships { + switch r.Relationship { + case string(helpers.DescribesRelationship): + described = append(described, r.RefB.ElementRefID) + case string(helpers.ContainsRelationship): + contains = append(contains, [2]spdx.ElementID{r.RefA.ElementRefID, r.RefB.ElementRefID}) + } + } + rootPkg := doc.Packages[len(doc.Packages)-1] + root := rootPkg.PackageSPDXIdentifier + assert.Equal(t, []spdx.ElementID{root}, described) + + // the root contains every package, but only the files nothing else does: not what is within an archive, + // nor an owned file + assert.ElementsMatch(t, [][2]spdx.ElementID{ + {root, toSPDXID(topPkg)}, + {root, toSPDXID(nestedPkg)}, + {root, toSPDXID(top)}, + {root, toSPDXID(war)}, + {toSPDXID(topPkg), toSPDXID(owned)}, + {toSPDXID(a), toSPDXID(nestedPkg)}, + {toSPDXID(war), toSPDXID(a)}, + {toSPDXID(war), toSPDXID(b)}, + {toSPDXID(a), toSPDXID(inA)}, + {toSPDXID(b), toSPDXID(inB)}, + }, contains) + + // a package containing files must have analyzed them + assert.True(t, rootPkg.FilesAnalyzed) + assert.NotNil(t, rootPkg.PackageVerificationCode) + + // other tools may instead have the document describe the top-level files directly + describesFiles := ToFormatModel(s) + for _, r := range describesFiles.Relationships { + if r.RefA.ElementRefID == root && (r.RefB.ElementRefID == toSPDXID(top) || r.RefB.ElementRefID == toSPDXID(war)) { + r.RefA.ElementRefID = "DOCUMENT" + r.Relationship = string(helpers.DescribesRelationship) + } + } + + for _, d := range []struct { + name string + doc *spdx.Document + }{ + {name: "root contains files", doc: doc}, + {name: "document describes files", doc: describesFiles}, + } { + t.Run(d.name, func(t *testing.T) { + decoded, err := ToSyftModel(d.doc) + require.NoError(t, err) + + assert.Equal(t, src.Name, decoded.Source.Name) + assert.IsType(t, src.Metadata, decoded.Source.Metadata) + + // decoded paths are relative, as SPDX names are + relative := func(c file.Coordinates) file.Coordinates { + c.RealPath = strings.TrimPrefix(c.RealPath, "/") + c.FullPath = strings.TrimPrefix(c.FullPath, "/") + return c + } + var got []file.Coordinates + for c := range decoded.Artifacts.FileMetadata { + got = append(got, c) + } + assert.ElementsMatch(t, []file.Coordinates{relative(top), relative(owned), relative(war), relative(a), relative(b), relative(inA), relative(inB)}, got) + + var gotContains []artifact.Relationship + for _, r := range decoded.Relationships { + _, fromFile := r.From.(file.Coordinates) + _, toFile := r.To.(file.Coordinates) + if fromFile && toFile { + gotContains = append(gotContains, r) + } + } + assert.ElementsMatch(t, []artifact.Relationship{ + {From: relative(war), To: relative(a), Type: artifact.ContainsRelationship}, + {From: relative(war), To: relative(b), Type: artifact.ContainsRelationship}, + {From: relative(a), To: relative(inA), Type: artifact.ContainsRelationship}, + {From: relative(b), To: relative(inB), Type: artifact.ContainsRelationship}, + }, gotContains) + }) + } + }) + } +} + +func Test_sourceFile(t *testing.T) { + root := file.NewCoordinates("", "") + war := file.NewCoordinates("/app.war", "") + jar := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/a.jar"} + etc := file.NewCoordinates("/etc/os-release", "") + p := pkg.Package{Name: "p"} + p.SetID() + + tests := []struct { + name string + source source.Description + files []file.Coordinates + // relationships as "from type to", with the root package as "root" + want []string + wantAnalyzed bool + // the file types of the file the root package describes + wantTypes []string + // decoded files, and the file relationships among them, as "from type to" + wantFiles []string + wantContains []string + }{ + { + name: "a scanned directory is described, and contains what is at its root", + source: source.Description{Name: "dir", Metadata: source.DirectoryMetadata{Path: "/dir"}}, + files: []file.Coordinates{root, etc, war, jar}, + want: []string{ + "root DESCRIBES .", + "root CONTAINS p", + ". CONTAINS etc/os-release", + ". CONTAINS app.war", + "app.war CONTAINS a.jar", + }, + // the scanned root decodes back to the scanned root, and holds nothing as an archive would + wantFiles: []string{"", "etc/os-release", "app.war", "app.war!/a.jar"}, + wantContains: []string{"app.war contains app.war!/a.jar"}, }, - Relationships: []artifact.Relationship{ - {From: topPkg, To: owned, Type: artifact.ContainsRelationship}, - {From: a, To: nestedPkg, Type: artifact.ContainsRelationship}, + { + name: "an extracted archive is described, and contains what is at its root", + source: source.Description{Name: "t.tar", Metadata: source.FileMetadata{Path: "/dir/t.tar"}}, + files: []file.Coordinates{root, etc}, + want: []string{ + "root DESCRIBES t.tar", + "root CONTAINS p", + "t.tar CONTAINS etc/os-release", + }, + // the scanned root is the directory the archive was extracted to, but describes the archive + wantTypes: []string{"ARCHIVE"}, + // the described archive is the scanned root, holding what is at the root + wantFiles: []string{"", "etc/os-release"}, + }, + { + name: "an extracted archive takes its file types from its MIME type", + source: source.Description{Name: "t.zip", Metadata: source.FileMetadata{Path: "/dir/t.zip", MIMEType: "application/zip"}}, + files: []file.Coordinates{root, etc}, + want: []string{ + "root DESCRIBES t.zip", + "root CONTAINS p", + "t.zip CONTAINS etc/os-release", + }, + wantTypes: []string{"APPLICATION", "ARCHIVE"}, + wantFiles: []string{"", "etc/os-release"}, + }, + { + name: "a scanned file is described, but not its parent directory", + source: source.Description{Name: "app.war", Metadata: source.FileMetadata{Path: "/dir/app.war"}}, + files: []file.Coordinates{root, war, jar}, + want: []string{ + "root DESCRIBES app.war", + "root CONTAINS p", + "app.war CONTAINS a.jar", + }, + // an archive read in place is the scanned root too, so decodes as if it had been extracted + wantFiles: []string{"", "a.jar"}, + }, + { + name: "without the scanned root, the root package contains what is at its root", + source: source.Description{Name: "dir", Metadata: source.DirectoryMetadata{Path: "/dir"}}, + files: []file.Coordinates{etc}, + want: []string{ + "root CONTAINS p", + "root CONTAINS etc/os-release", + }, + wantAnalyzed: true, + wantFiles: []string{"etc/os-release"}, }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + metadata := map[file.Coordinates]file.Metadata{} + for _, c := range tt.files { + metadata[c] = file.Metadata{} + } + s := sbom.SBOM{ + Source: tt.source, + Artifacts: sbom.Artifacts{Packages: pkg.NewCollection(p), FileMetadata: metadata}, + } - doc := ToFormatModel(s) + doc := ToFormatModel(s) - // each archive holding a described file is described too, named by its path in its own container - war := file.NewCoordinates("/app.war", "sha256:layer") - b := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/b.jar", FileSystemID: "sha256:layer"} - names := map[spdx.ElementID]string{} - for _, f := range doc.Files { - names[f.FileSPDXIdentifier] = f.FileName - assert.Equal(t, "layerID: sha256:layer", f.FileComment) + rootPkg := doc.Packages[len(doc.Packages)-1] + names := map[spdx.ElementID]string{rootPkg.PackageSPDXIdentifier: "root", toSPDXID(p): "p"} + for _, f := range doc.Files { + names[f.FileSPDXIdentifier] = f.FileName + } + var got []string + for _, r := range doc.Relationships { + if r.RefA.ElementRefID == "DOCUMENT" { + continue + } + got = append(got, names[r.RefA.ElementRefID]+" "+r.Relationship+" "+names[r.RefB.ElementRefID]) + } + assert.ElementsMatch(t, tt.want, got) + assert.Equal(t, tt.wantAnalyzed, rootPkg.FilesAnalyzed) + if tt.wantTypes != nil { + for _, r := range doc.Relationships { + if r.RefA.ElementRefID == rootPkg.PackageSPDXIdentifier && r.Relationship == string(helpers.DescribesRelationship) { + i := slices.IndexFunc(doc.Files, func(f *spdx.File) bool { return f.FileSPDXIdentifier == r.RefB.ElementRefID }) + require.GreaterOrEqual(t, i, 0) + assert.Equal(t, tt.wantTypes, doc.Files[i].FileTypes) + } + } + } + + decoded, err := ToSyftModel(doc) + require.NoError(t, err) + + var gotFiles []string + for c := range decoded.Artifacts.FileMetadata { + gotFiles = append(gotFiles, c.FullNestedPath()) + } + assert.ElementsMatch(t, tt.wantFiles, gotFiles) + + var gotContains []string + for _, r := range decoded.Relationships { + if from, ok := r.From.(file.Coordinates); ok { + to, _ := r.To.(file.Coordinates) + gotContains = append(gotContains, from.FullNestedPath()+" "+string(r.Type)+" "+to.FullNestedPath()) + } + } + assert.ElementsMatch(t, tt.wantContains, gotContains) + }) } - assert.Equal(t, map[spdx.ElementID]string{ - toSPDXID(top): "etc/os-release", - toSPDXID(owned): "usr/bin/tool", - toSPDXID(war): "app.war", - toSPDXID(a): "WEB-INF/lib/a.jar", - toSPDXID(b): "WEB-INF/lib/b.jar", - toSPDXID(inA): "META-INF/MANIFEST.MF", - toSPDXID(inB): "odd:na%me.txt", - }, names) - - var root spdx.ElementID - var contains [][2]spdx.ElementID - for _, r := range doc.Relationships { - switch r.Relationship { - case string(helpers.DescribesRelationship): - root = r.RefB.ElementRefID - case string(helpers.ContainsRelationship): - contains = append(contains, [2]spdx.ElementID{r.RefA.ElementRefID, r.RefB.ElementRefID}) - } - } - // the root contains only what nothing else does: not what is within an archive, nor an owned file - assert.ElementsMatch(t, [][2]spdx.ElementID{ - {root, toSPDXID(topPkg)}, - {root, toSPDXID(top)}, - {root, toSPDXID(war)}, - {toSPDXID(topPkg), toSPDXID(owned)}, - {toSPDXID(a), toSPDXID(nestedPkg)}, - {toSPDXID(war), toSPDXID(a)}, - {toSPDXID(war), toSPDXID(b)}, - {toSPDXID(a), toSPDXID(inA)}, - {toSPDXID(b), toSPDXID(inB)}, - }, contains) - - decoded, err := ToSyftModel(doc) - require.NoError(t, err) - - // decoded paths are relative, as SPDX names are - relative := func(c file.Coordinates) file.Coordinates { - c.RealPath = strings.TrimPrefix(c.RealPath, "/") - c.FullPath = strings.TrimPrefix(c.FullPath, "/") - return c - } - var got []file.Coordinates - for c := range decoded.Artifacts.FileMetadata { - got = append(got, c) - } - assert.ElementsMatch(t, []file.Coordinates{relative(top), relative(owned), relative(war), relative(a), relative(b), relative(inA), relative(inB)}, got) - - var gotContains []artifact.Relationship - for _, r := range decoded.Relationships { - _, fromFile := r.From.(file.Coordinates) - _, toFile := r.To.(file.Coordinates) - if fromFile && toFile { - gotContains = append(gotContains, r) - } - } - assert.ElementsMatch(t, []artifact.Relationship{ - {From: relative(war), To: relative(a), Type: artifact.ContainsRelationship}, - {From: relative(war), To: relative(b), Type: artifact.ContainsRelationship}, - {From: relative(a), To: relative(inA), Type: artifact.ContainsRelationship}, - {From: relative(b), To: relative(inB), Type: artifact.ContainsRelationship}, - }, gotContains) } func Test_toRootPackage_name(t *testing.T) { @@ -1313,3 +1525,27 @@ func Test_toRootPackage_name(t *testing.T) { }) } } + +func Test_toFiles_archiveType(t *testing.T) { + // a file holding entries is an archive, even when its content starts like something else, such as a Spring Boot + // executable jar's launch script + jar := file.NewCoordinates("/app.jar", "") + s := sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{ + jar: {MIMEType: "text/x-shellscript"}, + file.NewCoordinates("/plain.zip", ""): {MIMEType: "application/zip"}, + {RealPath: "/app.jar", FullPath: "/app.jar!/lib/b.jar!/c.txt"}: {MIMEType: "text/plain"}, + }}} + + files, _ := toFiles(s, nil) + got := map[string][]string{} + for _, f := range files { + got[f.FileName] = f.FileTypes + } + assert.Equal(t, map[string][]string{ + "app.jar": {"TEXT", "ARCHIVE"}, + "plain.zip": {"APPLICATION", "ARCHIVE"}, + // added only to hold its entry, with no metadata of its own + "lib/b.jar": {"ARCHIVE"}, + "c.txt": {"TEXT"}, + }, got) +} diff --git a/syft/format/common/spdxhelpers/to_syft_model.go b/syft/format/common/spdxhelpers/to_syft_model.go index 79513d46a..7f132547d 100644 --- a/syft/format/common/spdxhelpers/to_syft_model.go +++ b/syft/format/common/spdxhelpers/to_syft_model.go @@ -38,6 +38,9 @@ func ToSyftModel(doc *spdx.Document) (*sbom.SBOM, error) { spdxIDMap := make(map[string]any) + // found before the root package and its relationships are removed + sourceFile := findSourceFile(doc) + s := &sbom.SBOM{ Source: extractSource(spdxIDMap, doc), Artifacts: sbom.Artifacts{ @@ -50,7 +53,7 @@ func ToSyftModel(doc *spdx.Document) (*sbom.SBOM, error) { collectSyftPackages(s, spdxIDMap, doc) - collectSyftFiles(s, spdxIDMap, doc) + collectSyftFiles(s, spdxIDMap, doc, sourceFile) s.Relationships = toSyftRelationships(spdxIDMap, doc) @@ -379,7 +382,32 @@ func collectSyftPackages(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Docum } } -func collectSyftFiles(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Document) { +// findSourceFile returns the file the root package DESCRIBES when it CONTAINS files: an archive scanned as the source +// is the scanned root, so what it contains is at the root rather than within an archive. +func findSourceFile(doc *spdx.Document) spdx.ElementID { + roots := findRootPackages(doc) + if len(roots) != 1 { + return "" + } + var described spdx.ElementID + containers := map[spdx.ElementID]bool{} + for _, r := range doc.Relationships { + switch helpers.RelationshipType(r.Relationship) { + case helpers.DescribesRelationship: + if r.RefA.ElementRefID == roots[0].PackageSPDXIdentifier { + described = r.RefB.ElementRefID + } + case helpers.ContainsRelationship: + containers[r.RefA.ElementRefID] = true + } + } + if !containers[described] { + return "" + } + return described +} + +func collectSyftFiles(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Document, sourceFile spdx.ElementID) { files := map[spdx.ElementID]*spdx.File{} for _, p := range doc.Packages { for _, f := range p.Files { @@ -394,6 +422,11 @@ func collectSyftFiles(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Document for id, f := range files { nested.own[id] = toSyftCoordinates(f) } + if c, ok := nested.own[sourceFile]; ok { + // the scanned root + c.RealPath = "" + nested.own[sourceFile] = c + } for _, r := range doc.Relationships { container, entry := r.RefA.ElementRefID, r.RefB.ElementRefID switch helpers.RelationshipType(r.Relationship) { @@ -403,7 +436,8 @@ func collectSyftFiles(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Document default: continue } - if files[container] != nil && files[entry] != nil { + // the scanned root holds what is at the root, not within an archive + if files[container] != nil && files[entry] != nil && !isScanRoot(nested.own[container]) { nested.container[entry] = container } } @@ -568,7 +602,7 @@ func fileContainsRelationship(typ helpers.RelationshipType, a, b any) (artifact. return artifact.Relationship{}, false } container, ok := a.(file.Location) - if !ok { + if !ok || isScanRoot(container.Coordinates) { return artifact.Relationship{}, false } var entry artifact.Identifiable @@ -612,6 +646,10 @@ func toSyftCoordinates(f *spdx.File) file.Coordinates { } func coordinatesFromFile(name, comment, id string) file.Coordinates { + // the scanned root + if name == "." { + name = "" + } c := file.NewCoordinates(name, "") if v, ok := strings.CutPrefix(comment, layerIDCommentPrefix); ok { c.FileSystemID = v diff --git a/syft/format/common/spdxhelpers/to_syft_model_v3.go b/syft/format/common/spdxhelpers/to_syft_model_v3.go index 5ba755906..3d0e91044 100644 --- a/syft/format/common/spdxhelpers/to_syft_model_v3.go +++ b/syft/format/common/spdxhelpers/to_syft_model_v3.go @@ -31,6 +31,9 @@ func ToSyftModelV3(doc *spdx.Document) (*sbom.SBOM, error) { spdxMap := ptrMap[any]{} + // found before the root package and its relationships are removed + sourceFile := v3findSourceFile(doc) + s := &sbom.SBOM{ Source: v3extractSource(spdxMap, doc), Artifacts: sbom.Artifacts{ @@ -45,7 +48,7 @@ func ToSyftModelV3(doc *spdx.Document) (*sbom.SBOM, error) { v3collectSyftPackages(s, spdxMap, relationships, doc) - v3collectSyftFiles(s, spdxMap, doc) + v3collectSyftFiles(s, spdxMap, doc, sourceFile) s.Relationships = v3toSyftRelationships(spdxMap, doc) @@ -282,7 +285,40 @@ func v3collectSyftPackages(s *sbom.SBOM, spdxMap ptrMap[any], relationships ptrM } } -func v3collectSyftFiles(s *sbom.SBOM, spdxMap ptrMap[any], doc *spdx.Document) { +// v3findSourceFile returns the file the root package DESCRIBES when it CONTAINS files: an archive scanned as the +// source is the scanned root, so what it contains is at the root rather than within an archive. +func v3findSourceFile(doc *spdx.Document) spdx.AnyFile { + roots := v3findRootPackages(doc) + if len(roots) != 1 { + return nil + } + var described spdx.AnyFile + containers := ptrMap[bool]{} + for _, r := range doc.Elements.Relationships() { + from := r.GetFrom() + if from == nil { + continue + } + switch r.GetType() { + case spdx.RelationshipType_Describes: + if from == spdx.AnyElement(roots[0]) { + for _, to := range r.GetTo() { + if f, ok := to.(spdx.AnyFile); ok { + described = f + } + } + } + case spdx.RelationshipType_Contains: + containers.Set(from, true) + } + } + if described == nil || !containers.Get(described) { + return nil + } + return described +} + +func v3collectSyftFiles(s *sbom.SBOM, spdxMap ptrMap[any], doc *spdx.Document, sourceFile spdx.AnyFile) { nested := newNestedFiles[reflect.Value]() var files []spdx.AnyFile for _, elementList := range []spdx.ElementList{doc.Elements, doc.RootElements} { @@ -294,6 +330,13 @@ func v3collectSyftFiles(s *sbom.SBOM, spdxMap ptrMap[any], doc *spdx.Document) { files = append(files, f) } } + if sourceFile != nil { + if c, ok := nested.own[ptrTo(sourceFile)]; ok { + // the scanned root + c.RealPath = "" + nested.own[ptrTo(sourceFile)] = c + } + } for _, r := range doc.Elements.Relationships() { container, ok := r.GetFrom().(spdx.AnyFile) if !ok || r.GetType() != spdx.RelationshipType_Contains { @@ -306,7 +349,7 @@ func v3collectSyftFiles(s *sbom.SBOM, spdxMap ptrMap[any], doc *spdx.Document) { } _, knownContainer := nested.own[ptrTo(container)] _, knownEntry := nested.own[ptrTo(entry)] - if knownContainer && knownEntry { + if knownContainer && knownEntry && !isScanRoot(nested.own[ptrTo(container)]) { nested.container[ptrTo(entry)] = ptrTo(container) } } diff --git a/syft/format/spdxjson/encoder_test.go b/syft/format/spdxjson/encoder_test.go index bb85b008b..29634b81a 100644 --- a/syft/format/spdxjson/encoder_test.go +++ b/syft/format/spdxjson/encoder_test.go @@ -15,6 +15,7 @@ import ( "github.com/anchore/syft/syft/format/internal/testutil" "github.com/anchore/syft/syft/pkg" "github.com/anchore/syft/syft/sbom" + "github.com/anchore/syft/syft/source" ) var updateSnapshot = flag.Bool("update-spdx-json", false, "update the *.golden files for spdx-json encoders") @@ -281,3 +282,83 @@ func defaultFormatEncoders() []sbom.FormatEncoder { } return encs } + +func TestEncodeDecodeTopLevelFiles(t *testing.T) { + // the root package contains the top-level files, or the scanned root does when it is described + root := file.NewCoordinates("", "") + etc := file.NewCoordinates("/etc/os-release", "") + jar := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/a.jar"} + war := file.NewCoordinates("/app.war", "") + tests := []struct { + name string + src source.Description + files []file.Coordinates + wantPaths []string + wantRels []string + }{ + { + name: "img", + src: source.Description{Name: "img", Metadata: source.ImageMetadata{UserInput: "img:latest", ManifestDigest: "sha256:abc"}}, + files: []file.Coordinates{etc, jar}, + wantPaths: []string{"etc/os-release", "app.war", "app.war!/WEB-INF/lib/a.jar"}, + wantRels: []string{"app.war contains app.war!/WEB-INF/lib/a.jar"}, + }, + { + name: "dir", + src: source.Description{Name: "dir", Metadata: source.DirectoryMetadata{Path: "dir"}}, + files: []file.Coordinates{etc, jar}, + wantPaths: []string{"etc/os-release", "app.war", "app.war!/WEB-INF/lib/a.jar"}, + wantRels: []string{"app.war contains app.war!/WEB-INF/lib/a.jar"}, + }, + { + name: "dir-root", + src: source.Description{Name: "dir", Metadata: source.DirectoryMetadata{Path: "dir"}}, + files: []file.Coordinates{root, etc, jar}, + wantPaths: []string{"", "etc/os-release", "app.war", "app.war!/WEB-INF/lib/a.jar"}, + wantRels: []string{"app.war contains app.war!/WEB-INF/lib/a.jar"}, + }, + { + // a scanned archive is the scanned root, holding what is at the root + name: "file", + src: source.Description{Name: "app.war", Metadata: source.FileMetadata{Path: "dir/app.war"}}, + files: []file.Coordinates{war, jar}, + wantPaths: []string{"", "WEB-INF/lib/a.jar"}, + }, + } + for _, tt := range tests { + src := tt.src + for _, enc := range defaultFormatEncoders() { + t.Run(tt.name+"_"+enc.Version(), func(t *testing.T) { + metadata := map[file.Coordinates]file.Metadata{} + for _, c := range tt.files { + metadata[c] = file.Metadata{} + } + var buf bytes.Buffer + require.NoError(t, enc.Encode(&buf, sbom.SBOM{ + Source: src, + Artifacts: sbom.Artifacts{FileMetadata: metadata}, + })) + + s, _, _, err := NewFormatDecoder().Decode(bytes.NewReader(buf.Bytes())) + require.NoError(t, err, "encoded document:\n%s", buf.String()) + + assert.Equal(t, src.Name, s.Source.Name) + assert.IsType(t, src.Metadata, s.Source.Metadata) + + var paths []string + for c := range s.Artifacts.FileMetadata { + paths = append(paths, c.FullNestedPath()) + } + assert.ElementsMatch(t, tt.wantPaths, paths) + + var rels []string + for _, r := range s.Relationships { + from, _ := r.From.(file.Coordinates) + to, _ := r.To.(file.Coordinates) + rels = append(rels, from.FullNestedPath()+" "+string(r.Type)+" "+to.FullNestedPath()) + } + assert.ElementsMatch(t, tt.wantRels, rels) + }) + } + } +} diff --git a/syft/format/spdxtagvalue/encoder_test.go b/syft/format/spdxtagvalue/encoder_test.go index cd99110b4..7542c60d8 100644 --- a/syft/format/spdxtagvalue/encoder_test.go +++ b/syft/format/spdxtagvalue/encoder_test.go @@ -231,7 +231,7 @@ func TestEncodeDecodeSourceWithoutName(t *testing.T) { } func TestEncodeDecodeRootDirectoryFile(t *testing.T) { - // a directory scan with all file metadata describes the scanned root, whose relative path is empty + // a directory scan with all file metadata includes the scanned root, which has no relative path of its own root := file.NewCoordinates("", "") etc := file.NewCoordinates("/etc", "") for _, enc := range defaultFormatEncoders() { @@ -252,7 +252,7 @@ func TestEncodeDecodeRootDirectoryFile(t *testing.T) { for c := range s.Artifacts.FileMetadata { names = append(names, c.RealPath) } - assert.ElementsMatch(t, []string{".", "etc"}, names) + assert.ElementsMatch(t, []string{"", "etc"}, names) }) } }