mirror of
https://github.com/anchore/syft.git
synced 2026-11-04 00:16:38 +01:00
fix: spdx and cyclonedx
Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import (
|
|||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -52,7 +54,8 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document {
|
|||||||
rels := relationship.NewIndex(s.Relationships...)
|
rels := relationship.NewIndex(s.Relationships...)
|
||||||
packages, otherLicenses := toPackages(rels, s.Artifacts.Packages, s)
|
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...)
|
allRelationships := append(toRelationships(rels.All()), archiveRelationships...)
|
||||||
|
|
||||||
// for valid SPDX we need a document describes relationship
|
// for valid SPDX we need a document describes relationship
|
||||||
@@ -62,7 +65,7 @@ func ToFormatModel(s sbom.SBOM) *spdx.Document {
|
|||||||
if rootPackage != nil {
|
if rootPackage != nil {
|
||||||
describesID = rootPackage.PackageSPDXIdentifier
|
describesID = rootPackage.PackageSPDXIdentifier
|
||||||
|
|
||||||
allRelationships = append(allRelationships, toRootRelationships(rootPackage, packages, files, allRelationships)...)
|
allRelationships = append(allRelationships, toRootRelationships(rootPackage, sourceFile, packages, files, allRelationships)...)
|
||||||
|
|
||||||
// append the root package
|
// append the root package
|
||||||
packages = append(packages, rootPackage)
|
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
|
// 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.
|
// within an archive is reached only through the archive's file, and an owned file through its package. When the
|
||||||
func toRootRelationships(rootPackage *spdx.Package, packages []*spdx.Package, files []*spdx.File, relationships []*spdx.Relationship) (out []*spdx.Relationship) {
|
// source is itself a file, the root package DESCRIBES it, and a scanned directory CONTAINS those files instead. A
|
||||||
contained := map[spdx.ElementID]bool{}
|
// 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 {
|
for _, r := range relationships {
|
||||||
if r.Relationship == string(helpers.ContainsRelationship) {
|
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 {
|
for _, p := range packages {
|
||||||
ids = append(ids, p.PackageSPDXIdentifier)
|
relate(rootPackage.PackageSPDXIdentifier, helpers.ContainsRelationship, p.PackageSPDXIdentifier)
|
||||||
}
|
}
|
||||||
|
var sha1s []string
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
ids = append(ids, f.FileSPDXIdentifier)
|
if _, has := contained[f.FileSPDXIdentifier]; has {
|
||||||
}
|
|
||||||
for _, id := range ids {
|
|
||||||
if contained[id] {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, &spdx.Relationship{
|
relate(fileContainer, helpers.ContainsRelationship, f.FileSPDXIdentifier)
|
||||||
RefA: spdx.DocElementID{
|
if fileContainer != rootPackage.PackageSPDXIdentifier {
|
||||||
ElementRefID: rootPackage.PackageSPDXIdentifier,
|
continue
|
||||||
},
|
}
|
||||||
Relationship: string(helpers.ContainsRelationship),
|
for _, c := range f.Checksums {
|
||||||
RefB: spdx.DocElementID{
|
if c.Algorithm == spdx.SHA1 {
|
||||||
ElementRefID: id,
|
sha1s = append(sha1s, c.Value)
|
||||||
},
|
break
|
||||||
})
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
rootPackage.PackageVerificationCode = toVerificationCode(sha1s)
|
||||||
|
rootPackage.FilesAnalyzed = rootPackage.PackageVerificationCode != nil
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:funlen
|
//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
|
// 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.
|
// 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)
|
_, coordinateSorter := formatInternal.GetLocationSorters(s)
|
||||||
top, entries := formatInternal.ArchiveEntries(s.AllCoordinates(), coordinateSorter)
|
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)
|
var addEntries func(archive file.Coordinates)
|
||||||
addEntries = func(archive file.Coordinates) {
|
addEntries = func(archive file.Coordinates) {
|
||||||
for _, c := range entries[archive] {
|
for _, c := range entries[archive] {
|
||||||
@@ -740,7 +772,7 @@ func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Re
|
|||||||
Relationship: string(helpers.ContainsRelationship),
|
Relationship: string(helpers.ContainsRelationship),
|
||||||
RefB: spdx.DocElementID{ElementRefID: toSPDXID(c)},
|
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)
|
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)
|
log.Debugf("unable to convert relative path '%s' to absolute path: %s", c.RealPath, err)
|
||||||
name = c.RealPath
|
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)
|
addEntries(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,6 +813,47 @@ func toFiles(s sbom.SBOM) (results []*spdx.File, archiveRelationships []*spdx.Re
|
|||||||
return results, archiveRelationships
|
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 {
|
func toFile(artifacts sbom.Artifacts, c file.Coordinates, name string) *spdx.File {
|
||||||
var metadata *file.Metadata
|
var metadata *file.Metadata
|
||||||
if metadataForLocation, exists := artifacts.FileMetadata[c]; exists {
|
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
|
// if a sha1 for a file is provided, then the validator will fail if the package does not have
|
||||||
// a package verification code
|
// a package verification code
|
||||||
coordinates := rels.Coordinates(p, artifact.ContainsRelationship)
|
coordinates := rels.Coordinates(p, artifact.ContainsRelationship)
|
||||||
var digests []file.Digest
|
var sha1s []string
|
||||||
for _, c := range coordinates {
|
for _, c := range coordinates {
|
||||||
digest := sbom.Artifacts.FileDigests[c]
|
digest := sbom.Artifacts.FileDigests[c]
|
||||||
if len(digest) == 0 {
|
if len(digest) == 0 {
|
||||||
@@ -893,27 +983,23 @@ func newPackageVerificationCode(rels *relationship.Index, p pkg.Package, sbom sb
|
|||||||
break
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sort templist in ascending order by SHA1 value
|
// sort templist in ascending order by SHA1 value
|
||||||
sort.SliceStable(digests, func(i, j int) bool {
|
sort.Strings(sha1s)
|
||||||
return digests[i].Value < digests[j].Value
|
|
||||||
})
|
|
||||||
|
|
||||||
// filelist = templist with "/n"s removed. /* ordered sequence of SHA1 values with no separators
|
// 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
|
//nolint:gosec
|
||||||
hasher := sha1.New()
|
hasher := sha1.New()
|
||||||
_, _ = hasher.Write([]byte(b.String()))
|
_, _ = hasher.Write([]byte(strings.Join(sha1s, "")))
|
||||||
return &spdx.PackageVerificationCode{
|
return &spdx.PackageVerificationCode{
|
||||||
// 7.9.1: Package Verification Code Value
|
// 7.9.1: Package Verification Code Value
|
||||||
// Cardinality: mandatory, one
|
// Cardinality: mandatory, one
|
||||||
@@ -934,10 +1020,6 @@ func trimPatchVersion(semver string) string {
|
|||||||
// spdx requires that the file name field is a relative filename
|
// spdx requires that the file name field is a relative filename
|
||||||
// with the root of the package archive or directory
|
// with the root of the package archive or directory
|
||||||
func convertAbsoluteToRelative(absPath string) (string, error) {
|
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)
|
// Ensure the absolute path is absolute (although it should already be)
|
||||||
if !path.IsAbs(absPath) {
|
if !path.IsAbs(absPath) {
|
||||||
// already relative
|
// already relative
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -557,7 +558,7 @@ func Test_toFiles(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
in sbom.SBOM
|
in sbom.SBOM
|
||||||
want spdx.File
|
want []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "File paths are converted to relative in final SPDX collection",
|
name: "File paths are converted to relative in final SPDX collection",
|
||||||
@@ -585,31 +586,56 @@ func Test_toFiles(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: spdx.File{
|
want: []string{"some/path"},
|
||||||
FileName: "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",
|
name: "a scanned file is indexed beside its parent directory, which was not scanned",
|
||||||
in: sbom.SBOM{Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{
|
in: sbom.SBOM{
|
||||||
{RealPath: ""}: {Type: stfile.TypeDirectory},
|
Source: source.Description{Metadata: source.FileMetadata{Path: "/dir/go.mod"}},
|
||||||
}}},
|
Artifacts: sbom.Artifacts{FileMetadata: map[file.Coordinates]file.Metadata{
|
||||||
want: spdx.File{FileName: "."},
|
{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{
|
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 {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
files, _ := toFiles(test.in)
|
files, _ := toFiles(test.in, toSourceFile(test.in))
|
||||||
require.Len(t, files, 1)
|
var names []string
|
||||||
assert.Equal(t, test.want.FileName, files[0].FileName)
|
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"}
|
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")
|
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"}
|
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 := pkg.Package{Name: "top"}
|
||||||
topPkg.SetID()
|
topPkg.SetID()
|
||||||
nestedPkg := pkg.Package{Name: "nested"}
|
nestedPkg := pkg.Package{Name: "nested"}
|
||||||
nestedPkg.SetID()
|
nestedPkg.SetID()
|
||||||
s := sbom.SBOM{
|
|
||||||
Artifacts: sbom.Artifacts{
|
sources := []source.Description{
|
||||||
Packages: pkg.NewCollection(topPkg, nestedPkg),
|
{Name: "img", Metadata: source.ImageMetadata{UserInput: "img:latest", ManifestDigest: "sha256:abc"}},
|
||||||
FileMetadata: map[file.Coordinates]file.Metadata{top: {}, inA: {}, inB: {}, owned: {}},
|
{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},
|
name: "an extracted archive is described, and contains what is at its root",
|
||||||
{From: a, To: nestedPkg, Type: artifact.ContainsRelationship},
|
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
|
rootPkg := doc.Packages[len(doc.Packages)-1]
|
||||||
war := file.NewCoordinates("/app.war", "sha256:layer")
|
names := map[spdx.ElementID]string{rootPkg.PackageSPDXIdentifier: "root", toSPDXID(p): "p"}
|
||||||
b := file.Coordinates{RealPath: "/app.war", FullPath: "/app.war!/WEB-INF/lib/b.jar", FileSystemID: "sha256:layer"}
|
for _, f := range doc.Files {
|
||||||
names := map[spdx.ElementID]string{}
|
names[f.FileSPDXIdentifier] = f.FileName
|
||||||
for _, f := range doc.Files {
|
}
|
||||||
names[f.FileSPDXIdentifier] = f.FileName
|
var got []string
|
||||||
assert.Equal(t, "layerID: sha256:layer", f.FileComment)
|
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) {
|
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)
|
spdxIDMap := make(map[string]any)
|
||||||
|
|
||||||
|
// found before the root package and its relationships are removed
|
||||||
|
sourceFile := findSourceFile(doc)
|
||||||
|
|
||||||
s := &sbom.SBOM{
|
s := &sbom.SBOM{
|
||||||
Source: extractSource(spdxIDMap, doc),
|
Source: extractSource(spdxIDMap, doc),
|
||||||
Artifacts: sbom.Artifacts{
|
Artifacts: sbom.Artifacts{
|
||||||
@@ -50,7 +53,7 @@ func ToSyftModel(doc *spdx.Document) (*sbom.SBOM, error) {
|
|||||||
|
|
||||||
collectSyftPackages(s, spdxIDMap, doc)
|
collectSyftPackages(s, spdxIDMap, doc)
|
||||||
|
|
||||||
collectSyftFiles(s, spdxIDMap, doc)
|
collectSyftFiles(s, spdxIDMap, doc, sourceFile)
|
||||||
|
|
||||||
s.Relationships = toSyftRelationships(spdxIDMap, doc)
|
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{}
|
files := map[spdx.ElementID]*spdx.File{}
|
||||||
for _, p := range doc.Packages {
|
for _, p := range doc.Packages {
|
||||||
for _, f := range p.Files {
|
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 {
|
for id, f := range files {
|
||||||
nested.own[id] = toSyftCoordinates(f)
|
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 {
|
for _, r := range doc.Relationships {
|
||||||
container, entry := r.RefA.ElementRefID, r.RefB.ElementRefID
|
container, entry := r.RefA.ElementRefID, r.RefB.ElementRefID
|
||||||
switch helpers.RelationshipType(r.Relationship) {
|
switch helpers.RelationshipType(r.Relationship) {
|
||||||
@@ -403,7 +436,8 @@ func collectSyftFiles(s *sbom.SBOM, spdxIDMap map[string]any, doc *spdx.Document
|
|||||||
default:
|
default:
|
||||||
continue
|
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
|
nested.container[entry] = container
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -568,7 +602,7 @@ func fileContainsRelationship(typ helpers.RelationshipType, a, b any) (artifact.
|
|||||||
return artifact.Relationship{}, false
|
return artifact.Relationship{}, false
|
||||||
}
|
}
|
||||||
container, ok := a.(file.Location)
|
container, ok := a.(file.Location)
|
||||||
if !ok {
|
if !ok || isScanRoot(container.Coordinates) {
|
||||||
return artifact.Relationship{}, false
|
return artifact.Relationship{}, false
|
||||||
}
|
}
|
||||||
var entry artifact.Identifiable
|
var entry artifact.Identifiable
|
||||||
@@ -612,6 +646,10 @@ func toSyftCoordinates(f *spdx.File) file.Coordinates {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func coordinatesFromFile(name, comment, id string) file.Coordinates {
|
func coordinatesFromFile(name, comment, id string) file.Coordinates {
|
||||||
|
// the scanned root
|
||||||
|
if name == "." {
|
||||||
|
name = ""
|
||||||
|
}
|
||||||
c := file.NewCoordinates(name, "")
|
c := file.NewCoordinates(name, "")
|
||||||
if v, ok := strings.CutPrefix(comment, layerIDCommentPrefix); ok {
|
if v, ok := strings.CutPrefix(comment, layerIDCommentPrefix); ok {
|
||||||
c.FileSystemID = v
|
c.FileSystemID = v
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func ToSyftModelV3(doc *spdx.Document) (*sbom.SBOM, error) {
|
|||||||
|
|
||||||
spdxMap := ptrMap[any]{}
|
spdxMap := ptrMap[any]{}
|
||||||
|
|
||||||
|
// found before the root package and its relationships are removed
|
||||||
|
sourceFile := v3findSourceFile(doc)
|
||||||
|
|
||||||
s := &sbom.SBOM{
|
s := &sbom.SBOM{
|
||||||
Source: v3extractSource(spdxMap, doc),
|
Source: v3extractSource(spdxMap, doc),
|
||||||
Artifacts: sbom.Artifacts{
|
Artifacts: sbom.Artifacts{
|
||||||
@@ -45,7 +48,7 @@ func ToSyftModelV3(doc *spdx.Document) (*sbom.SBOM, error) {
|
|||||||
|
|
||||||
v3collectSyftPackages(s, spdxMap, relationships, doc)
|
v3collectSyftPackages(s, spdxMap, relationships, doc)
|
||||||
|
|
||||||
v3collectSyftFiles(s, spdxMap, doc)
|
v3collectSyftFiles(s, spdxMap, doc, sourceFile)
|
||||||
|
|
||||||
s.Relationships = v3toSyftRelationships(spdxMap, doc)
|
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]()
|
nested := newNestedFiles[reflect.Value]()
|
||||||
var files []spdx.AnyFile
|
var files []spdx.AnyFile
|
||||||
for _, elementList := range []spdx.ElementList{doc.Elements, doc.RootElements} {
|
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)
|
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() {
|
for _, r := range doc.Elements.Relationships() {
|
||||||
container, ok := r.GetFrom().(spdx.AnyFile)
|
container, ok := r.GetFrom().(spdx.AnyFile)
|
||||||
if !ok || r.GetType() != spdx.RelationshipType_Contains {
|
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)]
|
_, knownContainer := nested.own[ptrTo(container)]
|
||||||
_, knownEntry := nested.own[ptrTo(entry)]
|
_, knownEntry := nested.own[ptrTo(entry)]
|
||||||
if knownContainer && knownEntry {
|
if knownContainer && knownEntry && !isScanRoot(nested.own[ptrTo(container)]) {
|
||||||
nested.container[ptrTo(entry)] = ptrTo(container)
|
nested.container[ptrTo(entry)] = ptrTo(container)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/anchore/syft/syft/format/internal/testutil"
|
"github.com/anchore/syft/syft/format/internal/testutil"
|
||||||
"github.com/anchore/syft/syft/pkg"
|
"github.com/anchore/syft/syft/pkg"
|
||||||
"github.com/anchore/syft/syft/sbom"
|
"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")
|
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
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ func TestEncodeDecodeSourceWithoutName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestEncodeDecodeRootDirectoryFile(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("", "")
|
root := file.NewCoordinates("", "")
|
||||||
etc := file.NewCoordinates("/etc", "")
|
etc := file.NewCoordinates("/etc", "")
|
||||||
for _, enc := range defaultFormatEncoders() {
|
for _, enc := range defaultFormatEncoders() {
|
||||||
@@ -252,7 +252,7 @@ func TestEncodeDecodeRootDirectoryFile(t *testing.T) {
|
|||||||
for c := range s.Artifacts.FileMetadata {
|
for c := range s.Artifacts.FileMetadata {
|
||||||
names = append(names, c.RealPath)
|
names = append(names, c.RealPath)
|
||||||
}
|
}
|
||||||
assert.ElementsMatch(t, []string{".", "etc"}, names)
|
assert.ElementsMatch(t, []string{"", "etc"}, names)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user