fix: spdx and cyclonedx

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow
2026-10-28 21:30:36 -04:00
parent 1344a5f598
commit 071d7a4461
6 changed files with 622 additions and 142 deletions
+124 -42
View File
@@ -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
@@ -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)
}
@@ -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
@@ -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)
}
}
+81
View File
@@ -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)
})
}
}
}
+2 -2
View File
@@ -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)
})
}
}