feat: SPDX 3 (#4269)

Signed-off-by: Keith Zantow <kzantow@gmail.com>
This commit is contained in:
Keith Zantow 2026-06-22 10:59:34 -04:00 committed by GitHub
parent 0e8d6deabe
commit 9c321691d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 2063 additions and 100 deletions

View File

@ -13,12 +13,10 @@ func DefaultFormatSPDXJSON() FormatSPDXJSON {
} }
func (o FormatSPDXJSON) config(v string) spdxjson.EncoderConfig { func (o FormatSPDXJSON) config(v string) spdxjson.EncoderConfig {
var pretty bool c := spdxjson.DefaultEncoderConfig()
c.Version = v
if o.Pretty != nil { if o.Pretty != nil {
pretty = *o.Pretty c.Pretty = *o.Pretty
}
return spdxjson.EncoderConfig{
Version: v,
Pretty: pretty,
} }
return c
} }

View File

@ -2,6 +2,8 @@ package options
import ( import (
"testing" "testing"
"github.com/stretchr/testify/require"
) )
func TestFormatSPDXJSON_buildConfig(t *testing.T) { func TestFormatSPDXJSON_buildConfig(t *testing.T) {
@ -11,5 +13,7 @@ func TestFormatSPDXJSON_buildConfig(t *testing.T) {
ft = setAllToNonZero(t, ft).(*FormatSPDXJSON) ft = setAllToNonZero(t, ft).(*FormatSPDXJSON)
subject := ft.config("Version") subject := ft.config("Version")
assertExpectedValue(t, subject) require.Equal(t, "Version", subject.Version)
require.Equal(t, "2.3", subject.DefaultVersion)
require.True(t, subject.Pretty)
} }

6
go.mod
View File

@ -79,7 +79,7 @@ require (
github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e github.com/scylladb/go-set v1.0.3-0.20200225121959-cc7b2070d91e
github.com/sergi/go-diff v1.4.0 github.com/sergi/go-diff v1.4.0
github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb
github.com/spdx/tools-golang v0.5.7 github.com/spdx/tools-golang v0.6.0-rc4
github.com/spf13/afero v1.15.0 github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@ -127,7 +127,7 @@ require (
github.com/STARRY-S/zip v0.2.3 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/agext/levenshtein v1.2.1 // indirect github.com/agext/levenshtein v1.2.1 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect github.com/anchore/go-lzo v0.1.0 // indirect
github.com/anchore/go-struct-converter v0.1.0 // indirect github.com/anchore/go-struct-converter v0.2.0-rc2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aquasecurity/go-version v0.0.1 // indirect github.com/aquasecurity/go-version v0.0.1 // indirect
@ -258,12 +258,14 @@ require (
github.com/pborman/indent v1.2.1 // indirect github.com/pborman/indent v1.2.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/piprate/json-gold v0.7.0 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/profile v1.7.0 // indirect github.com/pkg/profile v1.7.0 // indirect
github.com/pkg/xattr v0.4.12 // indirect github.com/pkg/xattr v0.4.12 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect

12
go.sum
View File

@ -138,8 +138,8 @@ github.com/anchore/go-macholibre v0.1.0 h1:qHbdusBZNcZM/uuKf1Psa9xxAFSoyRTps8GW9
github.com/anchore/go-macholibre v0.1.0/go.mod h1:eu0gbwaZ+ocVFJLePdmPPDKU8MboV1MKsUCr36Ckd5s= github.com/anchore/go-macholibre v0.1.0/go.mod h1:eu0gbwaZ+ocVFJLePdmPPDKU8MboV1MKsUCr36Ckd5s=
github.com/anchore/go-rpmdb v0.1.0 h1:Q8dc208/HYzCqhx0L1zurfm1UPil24hlo9NjkdFmLdE= github.com/anchore/go-rpmdb v0.1.0 h1:Q8dc208/HYzCqhx0L1zurfm1UPil24hlo9NjkdFmLdE=
github.com/anchore/go-rpmdb v0.1.0/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU= github.com/anchore/go-rpmdb v0.1.0/go.mod h1:eQVa6QFGzKy0qMcnW2pez0XBczvgwSjw9vA23qifEyU=
github.com/anchore/go-struct-converter v0.1.0 h1:2rDRssAl6mgKBSLNiVCMADgZRhoqtw9dedlWa0OhD30= github.com/anchore/go-struct-converter v0.2.0-rc2 h1:q+859fW2/jbHJHB2etbNfRlFwYpknyvbqqk1hUdamQ4=
github.com/anchore/go-struct-converter v0.1.0/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA= github.com/anchore/go-struct-converter v0.2.0-rc2/go.mod h1:cDBA5vhcR62nXWo8QH9/Kk2807o65ISaHPNPX66L+Uw=
github.com/anchore/go-sync v0.1.0 h1:1TEZM7jISrvtoBMOF79xP0caQKASAtgW1yKqc0EjyZg= github.com/anchore/go-sync v0.1.0 h1:1TEZM7jISrvtoBMOF79xP0caQKASAtgW1yKqc0EjyZg=
github.com/anchore/go-sync v0.1.0/go.mod h1:Iposeub0kHipoTei1icj4Tys0SJN+cCdxEnkS7bZUFs= github.com/anchore/go-sync v0.1.0/go.mod h1:Iposeub0kHipoTei1icj4Tys0SJN+cCdxEnkS7bZUFs=
github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZVsCYMrIZBpFxwV26CbsuoEh5muXD5I1Ods= github.com/anchore/go-version v1.2.2-0.20200701162849-18adb9c92b9b h1:e1bmaoJfZVsCYMrIZBpFxwV26CbsuoEh5muXD5I1Ods=
@ -789,6 +789,8 @@ github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7ol
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/piprate/json-gold v0.7.0 h1:bEMirgA5y8Z2loTQfxyIFfY+EflxH1CTP6r/KIlcJNw=
github.com/piprate/json-gold v0.7.0/go.mod h1:RVhE35veDX19r5gfUAR+IYHkAUuPwJO8Ie/qVeFaIzw=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@ -809,6 +811,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU=
github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
@ -877,8 +881,8 @@ github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cw
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb h1:bLo8hvc8XFm9J47r690TUKBzcjSWdJDxmjXJZ+/f92U= github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb h1:bLo8hvc8XFm9J47r690TUKBzcjSWdJDxmjXJZ+/f92U=
github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM= github.com/spdx/gordf v0.0.0-20201111095634-7098f93598fb/go.mod h1:uKWaldnbMnjsSAXRurWqqrdyZen1R7kxl8TkmWk2OyM=
github.com/spdx/tools-golang v0.5.7 h1:+sWcKGnhwp3vLdMqPcLdA6QK679vd86cK9hQWH3AwCg= github.com/spdx/tools-golang v0.6.0-rc4 h1:2GkvNr0DcnJHY9BDm3OYHo229jZS/h4qYDK+tHYXPOo=
github.com/spdx/tools-golang v0.5.7/go.mod h1:jg7w0LOpoNAw6OxKEzCoqPC2GCTj45LyTlVmXubDsYw= github.com/spdx/tools-golang v0.6.0-rc4/go.mod h1:ruCHu3shgy7bVbZ7gtEU4Gq4fI08n2SdXtgV5PoN/OM=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=

View File

@ -399,7 +399,7 @@ func collectDocRelationships(spdxIDMap map[string]any, doc *spdx.Document) (out
to = toLocation to = toLocation
case helpers.OtherRelationship: case helpers.OtherRelationship:
// Encoding uses a specifically formatted comment... // Encoding uses a specifically formatted comment...
if strings.Index(r.RelationshipComment, string(artifact.EvidentByRelationship)) == 0 { if strings.HasPrefix(r.RelationshipComment, string(artifact.EvidentByRelationship)) {
typ = artifact.EvidentByRelationship typ = artifact.EvidentByRelationship
to = toLocation to = toLocation
} }
@ -418,7 +418,7 @@ func collectDocRelationships(spdxIDMap map[string]any, doc *spdx.Document) (out
to = toPackage to = toPackage
case helpers.OtherRelationship: case helpers.OtherRelationship:
// Encoding uses a specifically formatted comment... // Encoding uses a specifically formatted comment...
if strings.Index(r.RelationshipComment, string(artifact.OwnershipByFileOverlapRelationship)) == 0 { if strings.HasPrefix(r.RelationshipComment, string(artifact.OwnershipByFileOverlapRelationship)) {
typ = artifact.OwnershipByFileOverlapRelationship typ = artifact.OwnershipByFileOverlapRelationship
to = toPackage to = toPackage
} }
@ -462,10 +462,10 @@ func collectPackageFileRelationships(spdxIDMap map[string]any, doc *spdx.Documen
func toSyftCoordinates(f *spdx.File) file.Coordinates { func toSyftCoordinates(f *spdx.File) file.Coordinates {
const layerIDPrefix = "layerID: " const layerIDPrefix = "layerID: "
var fileSystemID string var fileSystemID string
if strings.Index(f.FileComment, layerIDPrefix) == 0 { if strings.HasPrefix(f.FileComment, layerIDPrefix) {
fileSystemID = strings.TrimPrefix(f.FileComment, layerIDPrefix) fileSystemID = strings.TrimPrefix(f.FileComment, layerIDPrefix)
} }
if strings.Index(string(f.FileSPDXIdentifier), layerIDPrefix) == 0 { if strings.HasPrefix(string(f.FileSPDXIdentifier), layerIDPrefix) {
fileSystemID = strings.TrimPrefix(string(f.FileSPDXIdentifier), layerIDPrefix) fileSystemID = strings.TrimPrefix(string(f.FileSPDXIdentifier), layerIDPrefix)
} }
return file.Coordinates{ return file.Coordinates{
@ -481,8 +481,8 @@ func toSyftLocation(f *spdx.File) file.Location {
func requireAndTrimPrefix(val any, prefix string) string { func requireAndTrimPrefix(val any, prefix string) string {
if v, ok := val.(string); ok { if v, ok := val.(string); ok {
if i := strings.Index(v, prefix); i == 0 { if strings.HasPrefix(v, prefix) {
return strings.Replace(v, prefix, "", 1) return strings.TrimPrefix(v, prefix)
} }
} }
return "" return ""

View File

@ -0,0 +1,774 @@
package spdxhelpers
import (
"errors"
"fmt"
"reflect"
"regexp"
"strconv"
"strings"
spdx "github.com/spdx/tools-golang/spdx/v3/v3_0"
"github.com/anchore/packageurl-go"
"github.com/anchore/syft/internal/log"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/format/internal"
"github.com/anchore/syft/syft/format/internal/spdxutil/helpers"
"github.com/anchore/syft/syft/license"
"github.com/anchore/syft/syft/linux"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
func ToSyftModelV3(doc *spdx.Document) (*sbom.SBOM, error) {
if doc == nil {
return nil, errors.New("cannot convert SPDX document to Syft model because document is nil")
}
spdxMap := ptrMap[any]{}
s := &sbom.SBOM{
Source: v3extractSource(spdxMap, doc),
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(),
FileMetadata: map[file.Coordinates]file.Metadata{},
FileDigests: map[file.Coordinates][]file.Digest{},
LinuxDistribution: v3findLinuxReleaseByPURL(doc),
},
}
relationships := v3relationshipMap(doc)
v3collectSyftPackages(s, spdxMap, relationships, doc)
v3collectSyftFiles(s, spdxMap, doc)
s.Relationships = v3toSyftRelationships(spdxMap, doc)
return s, nil
}
func v3removePackage(packages spdx.ElementList, remove spdx.AnyPackage) (pkgs spdx.ElementList) {
for _, p := range packages {
if p == remove {
continue
}
pkgs = append(pkgs, p)
}
return pkgs
}
func v3removeRelationships(elements spdx.ElementList, element spdx.AnyElement) (relations spdx.ElementList) {
for _, e := range elements {
if r, ok := e.(spdx.AnyRelationship); ok {
if r != nil && r.GetFrom() == element {
continue
}
if r != nil {
var tos spdx.ElementList
for _, to := range r.GetTo() {
if to == element {
continue
}
tos = append(tos, to)
}
r.SetTo(tos)
if len(r.GetTo()) == 0 {
continue
}
}
relations = append(relations, r)
} else {
relations = append(relations, e)
}
}
return relations
}
func v3findRootPackages(doc spdx.AnyElementCollection) (out spdx.PackageList) {
for _, p := range doc.GetRootElements().Packages() {
out = append(out, p)
}
for _, s := range doc.GetRootElements().SBOMs() {
for _, p := range s.GetRootElements().Packages() {
out = append(out, p)
}
}
return
}
func v3extractSource(spdxMap ptrMap[any], doc *spdx.Document) source.Description {
namespace := doc.ID
if namespace == "" && len(doc.NamespaceMaps) > 0 {
namespace = string(doc.NamespaceMaps[0].GetNamespace())
}
src := extractSourceFromNamespace(namespace)
rootPackages := v3findRootPackages(doc)
if len(rootPackages) != 1 {
return src
}
p := rootPackages[0]
switch p.GetPrimaryPurpose() {
case spdx.SoftwarePurpose_Container:
src = v3containerSource(p)
case spdx.SoftwarePurpose_File:
src = v3fileSource(p)
default:
return src
}
spdxMap.Set(p, src)
doc.Elements = v3removePackage(doc.Elements, p)
doc.Elements = v3removeRelationships(doc.Elements, p)
return src
}
func v3containerSource(p spdx.AnyPackage) source.Description {
container := p.GetName()
v := p.GetVersion()
if v != "" {
container += ":" + v
}
digest := ""
if len(p.GetVerifiedUsing().Hashes()) > 0 {
h := p.GetVerifiedUsing().Hashes()[0]
if h != nil {
digest = fmt.Sprintf("%s:%s", v3fromChecksumAlgorithm(h.GetAlgorithm()), h.GetValue())
}
}
supplier := ""
if p.GetSuppliedBy() != nil {
supplier = v3agentString(p.GetSuppliedBy())
}
return source.Description{
ID: p.GetID(),
Name: p.GetName(),
Version: p.GetVersion(),
Supplier: supplier,
Metadata: source.ImageMetadata{
UserInput: container,
ID: p.GetID(),
Layers: nil, // TODO handle formats with nested layer packages like Tern and K8s BOM tool
ManifestDigest: digest,
},
}
}
func v3fileSource(p spdx.AnyPackage) source.Description {
typeRegex := regexp.MustCompile("DocumentRoot-([^-]+)-.*$")
typeName := typeRegex.ReplaceAllString(p.GetID(), "$1")
var version string
var metadata any
switch {
case typeName == prefixDirectory:
// is a Syft SBOM, explicitly a directory source
metadata, version = v3directorySourceMetadata(p)
case typeName == prefixFile:
// is a Syft SBOM, explicitly a file source
metadata, version = v3fileSourceMetadata(p)
case isDirectory(p.GetName()):
// is a non-Syft SBOM, which looks like a directory
metadata, version = v3directorySourceMetadata(p)
default:
// is a non-Syft SBOM, which is probably a file
metadata, version = v3fileSourceMetadata(p)
}
supplier := ""
if p.GetSuppliedBy() != nil {
supplier = p.GetSuppliedBy().GetName()
}
return source.Description{
ID: p.GetID(),
Name: p.GetName(),
Version: version,
Supplier: supplier,
Metadata: metadata,
}
}
func v3fileSourceMetadata(p spdx.AnyPackage) (any, string) {
version := p.GetVersion()
m := source.FileMetadata{
Path: p.GetName(),
}
// if this is a Syft SBOM, we might have output a digest as the version
checksum := v3toChecksum(p.GetVersion())
for _, d := range p.GetVerifiedUsing().Hashes() {
if checksum != nil && checksum.GetValue() == d.GetValue() {
version = ""
}
m.Digests = append(m.Digests, file.Digest{
Algorithm: v3fromChecksumAlgorithm(d.GetAlgorithm()),
Value: d.GetValue(),
})
}
return m, version
}
func v3directorySourceMetadata(p spdx.AnyPackage) (any, string) {
return source.DirectoryMetadata{
Path: p.GetName(),
Base: "",
}, p.GetVersion()
}
func v3findLinuxReleaseByPURL(doc *spdx.Document) *linux.Release {
for _, p := range doc.Elements.Packages() {
purlValue := v3findPURLValue(p)
if purlValue == "" {
continue
}
purl, err := packageurl.FromString(purlValue)
if err != nil {
log.Warnf("unable to parse purl: %s", purlValue)
continue
}
distro := v3findQualifierValue(purl, pkg.PURLQualifierDistro)
if distro != "" {
parts := strings.Split(distro, "-")
name := parts[0]
version := ""
if len(parts) > 1 {
version = parts[1]
}
return &linux.Release{
PrettyName: name,
Name: name,
ID: name,
IDLike: []string{name},
Version: version,
VersionID: version,
}
}
}
return nil
}
func v3collectSyftPackages(s *sbom.SBOM, spdxMap ptrMap[any], relationships ptrMap[[]spdx.AnyRelationship], doc *spdx.Document) {
skipIDs := v3packageIDsToSkip(doc)
found := ptrMap[struct{}]{}
// tools-golang collects all elements from the JSON LD @graph to the root SpdxDocument.Elements property
for _, elementList := range []spdx.ElementList{doc.Elements, doc.RootElements} {
for _, p := range elementList.Packages() {
if p == nil || skipIDs.Has(p) || found.Has(p) {
continue
}
found.Set(p, struct{}{})
syftPkg := v3toSyftPackage(relationships, p)
spdxMap.Set(p, syftPkg)
s.Artifacts.Packages.Add(syftPkg)
}
}
}
func v3collectSyftFiles(s *sbom.SBOM, spdxMap ptrMap[any], doc *spdx.Document) {
found := ptrMap[struct{}]{}
for _, elementList := range []spdx.ElementList{doc.Elements, doc.RootElements} {
for _, f := range elementList.Files() {
if found.Has(f) {
continue
}
found.Set(f, struct{}{})
l := v3toSyftLocation(f)
spdxMap.Set(f, l)
s.Artifacts.FileMetadata[l.Coordinates] = v3toFileMetadata(f)
s.Artifacts.FileDigests[l.Coordinates] = v3toFileDigests(f)
}
}
}
func v3toFileDigests(f spdx.AnyFile) (digests []file.Digest) {
for _, h := range f.GetVerifiedUsing().Hashes() {
digests = append(digests, file.Digest{
Algorithm: v3fromChecksumAlgorithm(h.GetAlgorithm()),
Value: h.GetValue(),
})
}
return digests
}
func v3fromChecksumAlgorithm(algorithm spdx.HashAlgorithm) string {
// it might be better to have a specific case statement with constants
parts := strings.Split(algorithm.GetID(), "/")
return strings.ToLower(parts[len(parts)-1])
}
func v3toFileMetadata(f spdx.AnyFile) (meta file.Metadata) {
// FIXME Syft is currently lossy due to the SPDX 2.2.1 spec not supporting arbitrary mimetypes
if f.GetContentType() != "" {
meta.MIMEType = f.GetContentType()
}
return meta
}
func v3toSyftRelationships(spdxMap ptrMap[any], doc *spdx.Document) []artifact.Relationship {
out := v3collectDocRelationships(spdxMap, doc)
return out
}
//nolint:gocognit
func v3collectDocRelationships(spdxMap ptrMap[any], doc *spdx.Document) (out []artifact.Relationship) {
for _, r := range doc.Elements.Relationships() {
from := r.GetFrom()
if from == nil || from.GetID() == "" {
log.Debugf("ignoring relationship to external document: %+v", r)
continue
}
a := spdxMap.Get(from)
for _, to := range r.GetTo() {
b := spdxMap.Get(to)
from, fromOk := a.(pkg.Package)
toPackage, toPackageOk := b.(pkg.Package)
toLocation, toLocationOk := b.(file.Location)
//nolint:staticcheck
if !fromOk || !(toPackageOk || toLocationOk) {
log.Debugf("unable to find valid relationship mapping from SPDX, ignoring: (from: %+v) (to: %+v)", a, b)
continue
}
var to artifact.Identifiable
var typ artifact.RelationshipType
if toLocationOk {
switch r.GetType() {
case spdx.RelationshipType_Contains:
typ = artifact.ContainsRelationship
to = toLocation
case spdx.RelationshipType_Other:
// Encoding uses a specifically formatted comment...
if strings.HasPrefix(r.GetComment(), string(artifact.EvidentByRelationship)) {
typ = artifact.EvidentByRelationship
to = toLocation
}
}
} else {
switch r.GetType() {
case spdx.RelationshipType_DependsOn:
typ = artifact.DependencyOfRelationship
to = from
from = toPackage
case spdx.RelationshipType_Contains:
typ = artifact.ContainsRelationship
to = toPackage
case spdx.RelationshipType_Other:
// Encoding uses a specifically formatted comment...
if strings.HasPrefix(r.GetComment(), string(artifact.OwnershipByFileOverlapRelationship)) {
typ = artifact.OwnershipByFileOverlapRelationship
to = toPackage
}
}
}
if typ != "" && to != nil {
out = append(out, artifact.Relationship{
From: from,
To: to,
Type: typ,
})
}
}
}
return out
}
func v3toSyftCoordinates(f spdx.AnyFile) file.Coordinates {
const layerIDPrefix = "layerID: "
var fileSystemID string
if strings.HasPrefix(f.GetComment(), layerIDPrefix) {
fileSystemID = strings.TrimPrefix(f.GetComment(), layerIDPrefix)
}
if strings.HasPrefix(f.GetID(), layerIDPrefix) {
fileSystemID = strings.TrimPrefix(f.GetID(), layerIDPrefix)
}
return file.Coordinates{
RealPath: f.GetName(),
FileSystemID: fileSystemID,
}
}
func v3toSyftLocation(f spdx.AnyFile) file.Location {
l := file.NewVirtualLocationFromCoordinates(v3toSyftCoordinates(f), f.GetName())
return l
}
func v3findQualifierValue(purl packageurl.PackageURL, qualifier string) string {
for _, q := range purl.Qualifiers {
if q.Key == qualifier {
return q.Value
}
}
return ""
}
func v3extractPkgInfo(p spdx.AnyPackage) pkgInfo {
pu := v3findPURLValue(p)
purl, err := packageurl.FromString(pu)
if err != nil {
return pkgInfo{}
}
return pkgInfo{
purl,
pkg.TypeByName(purl.Type),
pkg.LanguageByName(purl.Type),
}
}
func v3toSyftPackage(relationships ptrMap[[]spdx.AnyRelationship], p spdx.AnyPackage) pkg.Package {
info := v3extractPkgInfo(p)
sP := &pkg.Package{
Type: info.typ,
Name: p.GetName(),
Version: p.GetVersion(),
Licenses: pkg.NewLicenseSet(v3parseSPDXLicenses(relationships, p)...),
CPEs: v3extractCPEs(p),
PURL: v3purlValue(info.purl),
Language: info.lang,
Metadata: v3extractMetadata(p, info),
}
internal.Backfill(sP)
if p.GetID() != "" {
// always prefer the IDs from the SBOM over derived IDs
sP.OverrideID(artifact.ID(p.GetID()))
} else {
sP.SetID()
}
return *sP
}
func v3purlValue(purl packageurl.PackageURL) string {
val := purl.String()
if _, err := packageurl.FromString(val); err != nil {
return ""
}
return val
}
func v3parseSPDXLicenses(relationships ptrMap[[]spdx.AnyRelationship], p spdx.AnyPackage) []pkg.License {
licenses := make([]pkg.License, 0)
// licenses are defined with relationships in SPDX 3, see:
// https://github.com/spdx/tools-golang/blob/spdx3/spdx/v3/v3_0/convert.go#L536
rels := relationships.Get(p)
for _, r := range rels {
if r.GetType() == spdx.RelationshipType_HasConcludedLicense {
licenses = append(licenses, v3toSyftLicenses(license.Concluded, r.GetTo().LicenseInfos()...)...)
}
if r.GetType() == spdx.RelationshipType_HasDeclaredLicense {
licenses = append(licenses, v3toSyftLicenses(license.Declared, r.GetTo().LicenseInfos()...)...)
}
}
return licenses
}
func v3toSyftLicenses(licenseType license.Type, licenses ...spdx.AnyLicenseInfo) []pkg.License {
var out []pkg.License
for _, lic := range licenses {
if lic == nil {
continue
}
value := v3licenseInfoToExpression(lic)
if value == "" {
log.Debugf("skipping SPDX license during import: %#v", lic)
continue
}
l := pkg.NewLicense(value)
if l.Value != "" {
l.Type = licenseType
out = append(out, l)
}
}
return out
}
// v3licenseInfoToExpression recursively converts an SPDX 3.0 license info object to an SPDX expression string.
func v3licenseInfoToExpression(info spdx.AnyLicenseInfo) string {
switch li := info.(type) {
case spdx.AnyLicenseExpression:
return li.GetLicenseExpression()
case spdx.AnyOrLaterOperator:
subject := v3licenseInfoToExpression(li.GetSubjectLicense())
if subject != "" && !strings.HasSuffix(subject, "+") {
return subject + "+"
}
case spdx.AnyWithAdditionOperator:
subject := v3licenseInfoToExpression(li.GetSubjectExtendableLicense())
addition := li.GetSubjectAddition()
if subject != "" && addition != nil {
return subject + " WITH " + addition.GetAdditionText()
}
case spdx.AnyConjunctiveLicenseSet:
var parts []string
for _, m := range li.GetMembers() {
if e := v3licenseInfoToExpression(m); e != "" {
parts = append(parts, e)
}
}
if len(parts) > 0 {
return strings.Join(parts, " AND ")
}
case spdx.AnyDisjunctiveLicenseSet:
var parts []string
for _, m := range li.GetMembers() {
if e := v3licenseInfoToExpression(m); e != "" {
parts = append(parts, e)
}
}
if len(parts) > 0 {
return "(" + strings.Join(parts, " OR ") + ")"
}
case spdx.AnyListedLicense:
return li.GetName()
case spdx.AnyCustomLicense:
if li.GetID() != "" {
return li.GetID()
}
return li.GetName()
}
return ""
}
//nolint:funlen
func v3extractMetadata(p spdx.AnyPackage, info pkgInfo) any {
arch := info.qualifierValue(pkg.PURLQualifierArch)
upstreamValue := info.qualifierValue(pkg.PURLQualifierUpstream)
upstream := strings.SplitN(upstreamValue, "@", 2)
upstreamName := upstream[0]
upstreamVersion := ""
if len(upstream) > 1 {
upstreamVersion = upstream[1]
}
supplier := ""
if p.GetSuppliedBy() != nil {
supplier = v3agentString(p.GetSuppliedBy())
}
originator := ""
if len(p.GetOriginatedBy()) > 0 {
// FIXME there could be multiple
originator = v3agentString(p.GetOriginatedBy()[0])
}
switch info.typ {
case pkg.ApkPkg:
return pkg.ApkDBEntry{
Package: p.GetName(),
OriginPackage: upstreamName,
Maintainer: supplier,
Version: p.GetVersion(),
Architecture: arch,
URL: string(p.GetHomePage()),
Description: p.GetDescription(),
}
case pkg.RpmPkg:
converted, err := strconv.Atoi(info.qualifierValue(pkg.PURLQualifierEpoch))
var epoch *int
if err != nil {
epoch = nil
} else {
epoch = &converted
}
return pkg.RpmDBEntry{
Name: p.GetName(),
Version: p.GetVersion(),
Epoch: epoch,
Arch: arch,
SourceRpm: upstreamValue,
Vendor: originator,
}
case pkg.DebPkg:
return pkg.DpkgDBEntry{
Package: p.GetName(),
Source: upstreamName,
Version: p.GetVersion(),
SourceVersion: upstreamVersion,
Architecture: arch,
Maintainer: originator,
}
case pkg.JavaPkg:
var digests []file.Digest
for _, value := range p.GetVerifiedUsing() {
h, _ := value.(spdx.AnyHash)
if h != nil {
digests = append(digests, file.Digest{Algorithm: v3fromChecksumAlgorithm(h.GetAlgorithm()), Value: h.GetValue()})
}
}
return pkg.JavaArchive{
ArchiveDigests: digests,
}
case pkg.GoModulePkg:
var h1Digest string
for _, value := range p.GetVerifiedUsing() {
h, _ := value.(spdx.AnyHash)
if h == nil {
continue
}
digest, err := helpers.HDigestFromSHA(v3fromChecksumAlgorithm(h.GetAlgorithm()), h.GetValue())
if err != nil {
log.Debugf("invalid h1digest: %v %v", value, err)
continue
}
h1Digest = digest
break
}
return pkg.GolangBinaryBuildinfoEntry{
H1Digest: h1Digest,
}
}
return nil
}
func v3agentString(agent spdx.AnyAgent) string {
out := ""
switch o := agent.(type) {
case spdx.AnyOrganization:
out = o.GetName()
case spdx.AnyPerson:
out = o.GetName()
}
if out == helpers.NOASSERTION {
return ""
}
return out
}
func v3findPURLValue(p spdx.AnyPackage) string {
if p.GetPackageURL() != "" {
return string(p.GetPackageURL())
}
for _, r := range p.GetExternalIdentifiers() {
if r.GetType() == spdx.ExternalIdentifierType_PackageURL {
for _, l := range r.GetIdentifierLocators() {
// FIXME multiple values
return string(l)
}
}
}
return ""
}
func v3extractCPEs(p spdx.AnyPackage) (cpes []cpe.CPE) {
for _, r := range p.GetExternalIdentifiers() {
if r.GetType() == spdx.ExternalIdentifierType_Cpe23 || r.GetType() == spdx.ExternalIdentifierType_Cpe22 {
c, err := cpe.New(r.GetIdentifier(), cpe.DeclaredSource)
if err == nil {
cpes = append(cpes, c)
continue
}
log.Tracef("unable to extract SPDX IDENTIFIER CPE=%q: %+v", r.GetIdentifier(), err)
// try the locators
for _, l := range r.GetIdentifierLocators() {
c, err = cpe.New(string(l), cpe.DeclaredSource)
if err != nil {
log.Tracef("unable to extract SPDX CPE=%q: %+v", l, err)
continue
}
cpes = append(cpes, c)
}
}
}
return cpes
}
// v3packageIDsToSkip returns a set of packageIDs that should not be imported
func v3packageIDsToSkip(doc *spdx.Document) ptrMap[struct{}] {
skipIDs := ptrMap[struct{}]{}
for _, r := range doc.Elements.Relationships() {
if r != nil && r.GetFrom() != nil && r.GetType() == spdx.RelationshipType_Generates {
skipIDs.Set(r.GetFrom(), struct{}{}) // flipped from GENERATED_FROM
}
}
return skipIDs
}
// toChecksum takes a checksum in the format <algorithm>:<hash> and returns an spdx.Checksum or nil if the string is invalid
func v3toChecksum(algorithmHash string) spdx.AnyHash {
parts := strings.Split(algorithmHash, ":")
if len(parts) < 2 {
return nil
}
return &spdx.Hash{
Algorithm: v3toChecksumAlgorithm(parts[0]),
Value: parts[1],
}
}
func v3toChecksumAlgorithm(algorithm string) spdx.HashAlgorithm {
// this needs to be an uppercase version of our algorithm
switch strings.ToLower(algorithm) {
case "sha1":
return spdx.HashAlgorithm_Sha1
case "sha256":
return spdx.HashAlgorithm_Sha256
case "sha384":
return spdx.HashAlgorithm_Sha384
case "sha512":
return spdx.HashAlgorithm_Sha512
case "md5":
return spdx.HashAlgorithm_Md5
}
return spdx.HashAlgorithm{}
}
func v3relationshipMap(doc *spdx.Document) ptrMap[[]spdx.AnyRelationship] {
relationships := ptrMap[[]spdx.AnyRelationship]{}
for _, r := range doc.Elements.Relationships() {
existing := relationships.Get(r.GetFrom())
relationships.Set(r.GetFrom(), append(existing, r))
}
return relationships
}
// SPDX 3 values are stored as pointers, and there is a distinct possibility that an ID
// will be blank if it was a JSON-LD blank node in the document, as these IDs are not persisted
// nor should they be persisted from decoding
type ptrMap[T any] map[reflect.Value]T
func (s ptrMap[T]) Set(k any, v T) {
s[ptrTo(k)] = v
}
func (s ptrMap[T]) Get(k any) T {
return s[ptrTo(k)]
}
func (s ptrMap[T]) Remove(k any) {
delete(s, ptrTo(k))
}
func (s ptrMap[T]) Has(k any) bool {
_, ok := s[ptrTo(k)]
return ok
}
func ptrTo(k any) reflect.Value {
rv := reflect.ValueOf(k)
if rv.Kind() != reflect.Pointer {
// this case is a programming problem -- all objects in the SPDX 3 model are pointers and may not have IDs set
panic(fmt.Errorf("value is not a pointer; comparable SPDX 3 elements are pointers, this is probably an implementation issue: %#v", k))
}
return rv
}

View File

@ -0,0 +1,436 @@
package spdxhelpers
import (
"testing"
spdx "github.com/spdx/tools-golang/spdx/v3/v3_0"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/license"
"github.com/anchore/syft/syft/pkg"
)
func TestToSyftModelV3_documentConversion(t *testing.T) {
// Build SPDX 3.0 elements
pkg1 := &spdx.Package{
ID: "pkg-1-id",
Name: "pkg-1",
Version: "1.0.0",
ExternalIdentifiers: spdx.ExternalIdentifierList{
&spdx.ExternalIdentifier{
Type: spdx.ExternalIdentifierType_PackageURL,
Identifier: "pkg:npm/pkg-1@1.0.0",
IdentifierLocators: []spdx.URI{"pkg:npm/pkg-1@1.0.0"},
},
},
}
pkg2 := &spdx.Package{
ID: "pkg-2-id",
Name: "pkg-2",
Version: "2.0.0",
ExternalIdentifiers: spdx.ExternalIdentifierList{
&spdx.ExternalIdentifier{
Type: spdx.ExternalIdentifierType_PackageURL,
Identifier: "pkg:npm/pkg-2@2.0.0",
IdentifierLocators: []spdx.URI{"pkg:npm/pkg-2@2.0.0"},
},
},
}
file1 := &spdx.File{
ID: "file-1-id",
Name: "/src/main.go",
VerifiedUsing: spdx.IntegrityMethodList{
&spdx.Hash{
Algorithm: spdx.HashAlgorithm_Sha256,
Value: "abc123",
},
},
}
file2 := &spdx.File{
ID: "file-2-id",
Name: "/src/util.go",
}
// licenses
pkg1ConcludedLicense := &spdx.ListedLicense{Name: "MIT"}
pkg1DeclaredLicense := &spdx.DisjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "Apache-2.0"},
},
}
pkg2ConcludedLicense := &spdx.ConjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "BSD-3-Clause"},
&spdx.OrLaterOperator{
SubjectLicense: &spdx.ListedLicense{Name: "GPL-2.0"},
},
},
}
// relationships
pkg1DependsOnPkg2 := &spdx.Relationship{
From: pkg1,
To: spdx.ElementList{pkg2},
Type: spdx.RelationshipType_DependsOn,
}
pkg1ContainsFile1 := &spdx.Relationship{
From: pkg1,
To: spdx.ElementList{file1},
Type: spdx.RelationshipType_Contains,
}
pkg2ContainsFile2 := &spdx.Relationship{
From: pkg2,
To: spdx.ElementList{file2},
Type: spdx.RelationshipType_Contains,
}
pkg1HasConcludedLicense := &spdx.Relationship{
From: pkg1,
To: spdx.ElementList{pkg1ConcludedLicense},
Type: spdx.RelationshipType_HasConcludedLicense,
}
pkg1HasDeclaredLicense := &spdx.Relationship{
From: pkg1,
To: spdx.ElementList{pkg1DeclaredLicense},
Type: spdx.RelationshipType_HasDeclaredLicense,
}
pkg2HasConcludedLicense := &spdx.Relationship{
From: pkg2,
To: spdx.ElementList{pkg2ConcludedLicense},
Type: spdx.RelationshipType_HasConcludedLicense,
}
sbomElement := &spdx.SBOM{
RootElements: spdx.ElementList{pkg1},
Elements: spdx.ElementList{
pkg1, pkg2,
file1, file2,
pkg1DependsOnPkg2,
pkg1ContainsFile1,
pkg2ContainsFile2,
pkg1HasConcludedLicense,
pkg1HasDeclaredLicense,
pkg2HasConcludedLicense,
},
}
doc := &spdx.Document{
SpdxDocument: spdx.SpdxDocument{
ID: "https://example.org/test-doc",
Name: "test-document",
RootElements: spdx.ElementList{sbomElement},
Elements: spdx.ElementList{
sbomElement,
pkg1, pkg2,
file1, file2,
pkg1DependsOnPkg2,
pkg1ContainsFile1,
pkg2ContainsFile2,
pkg1HasConcludedLicense,
pkg1HasDeclaredLicense,
pkg2HasConcludedLicense,
},
},
}
// Convert
result, err := ToSyftModelV3(doc)
require.NoError(t, err)
require.NotNil(t, result)
// Verify packages
allPkgs := result.Artifacts.Packages.Sorted()
require.Len(t, allPkgs, 2, "expected 2 packages")
pkgsByName := map[string]pkg.Package{}
for _, p := range allPkgs {
pkgsByName[p.Name] = p
}
syftPkg1, ok := pkgsByName["pkg-1"]
require.True(t, ok, "pkg-1 not found")
assert.Equal(t, "1.0.0", syftPkg1.Version)
assert.Equal(t, "pkg:npm/pkg-1@1.0.0", syftPkg1.PURL)
syftPkg2, ok := pkgsByName["pkg-2"]
require.True(t, ok, "pkg-2 not found")
assert.Equal(t, "2.0.0", syftPkg2.Version)
assert.Equal(t, "pkg:npm/pkg-2@2.0.0", syftPkg2.PURL)
// Verify pkg-1 licenses: concluded MIT + declared (MIT OR Apache-2.0)
pkg1Licenses := syftPkg1.Licenses.ToSlice()
require.NotEmpty(t, pkg1Licenses, "pkg-1 should have licenses")
var pkg1Concluded, pkg1Declared []pkg.License
for _, l := range pkg1Licenses {
switch l.Type {
case license.Concluded:
pkg1Concluded = append(pkg1Concluded, l)
case license.Declared:
pkg1Declared = append(pkg1Declared, l)
}
}
require.Len(t, pkg1Concluded, 1)
assert.Equal(t, "MIT", pkg1Concluded[0].Value)
require.Len(t, pkg1Declared, 1)
assert.Equal(t, "(MIT OR Apache-2.0)", pkg1Declared[0].Value)
// Verify pkg-2 licenses: concluded BSD-3-Clause AND GPL-2.0-only+
pkg2Licenses := syftPkg2.Licenses.ToSlice()
require.NotEmpty(t, pkg2Licenses, "pkg-2 should have licenses")
var pkg2Concluded []pkg.License
for _, l := range pkg2Licenses {
if l.Type == license.Concluded {
pkg2Concluded = append(pkg2Concluded, l)
}
}
require.Len(t, pkg2Concluded, 1)
assert.Equal(t, "BSD-3-Clause AND GPL-2.0+", pkg2Concluded[0].Value)
// Verify files
coords1 := file.Coordinates{RealPath: "/src/main.go"}
digests, ok := result.Artifacts.FileDigests[coords1]
require.True(t, ok, "file1 digests not found")
require.Len(t, digests, 1)
assert.Equal(t, "sha256", digests[0].Algorithm)
assert.Equal(t, "abc123", digests[0].Value)
coords2 := file.Coordinates{RealPath: "/src/util.go"}
_, ok = result.Artifacts.FileMetadata[coords2]
assert.True(t, ok, "file2 metadata not found")
// Verify relationships
require.NotEmpty(t, result.Relationships)
foundDependsOn := false
foundPkg1ContainsFile := false
foundPkg2ContainsFile := false
for _, rel := range result.Relationships {
fromPkg, fromOk := rel.From.(pkg.Package)
if !fromOk {
continue
}
switch toPkg := rel.To.(type) {
case pkg.Package:
if fromPkg.Name == "pkg-2" && toPkg.Name == "pkg-1" && rel.Type == artifact.DependencyOfRelationship {
foundDependsOn = true
}
case file.Location:
if fromPkg.Name == "pkg-1" && toPkg.RealPath == "/src/main.go" && rel.Type == artifact.ContainsRelationship {
foundPkg1ContainsFile = true
}
if fromPkg.Name == "pkg-2" && toPkg.RealPath == "/src/util.go" && rel.Type == artifact.ContainsRelationship {
foundPkg2ContainsFile = true
}
}
}
assert.True(t, foundDependsOn, "expected pkg-2 DependencyOf pkg-1 relationship")
assert.True(t, foundPkg1ContainsFile, "expected pkg-1 Contains /src/main.go relationship")
assert.True(t, foundPkg2ContainsFile, "expected pkg-2 Contains /src/util.go relationship")
}
func Test_v3licenseInfoToExpression(t *testing.T) {
tests := []struct {
name string
info spdx.AnyLicenseInfo
expected string
}{
{
name: "listed license",
info: &spdx.ListedLicense{Name: "MIT"},
expected: "MIT",
},
{
name: "custom license with ID",
info: &spdx.CustomLicense{ID: "LicenseRef-Custom-1", Name: "Custom License"},
expected: "LicenseRef-Custom-1",
},
{
name: "custom license without ID falls back to name",
info: &spdx.CustomLicense{Name: "Custom License"},
expected: "Custom License",
},
{
name: "license expression",
info: &spdx.LicenseExpression{LicenseExpression: "MIT AND Apache-2.0"},
expected: "MIT AND Apache-2.0",
},
{
name: "or-later operator",
info: &spdx.OrLaterOperator{
SubjectLicense: &spdx.ListedLicense{Name: "GPL-2.0"},
},
expected: "GPL-2.0+",
},
{
name: "conjunctive license set (AND)",
info: &spdx.ConjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "Apache-2.0"},
},
},
expected: "MIT AND Apache-2.0",
},
{
name: "disjunctive license set (OR)",
info: &spdx.DisjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "Apache-2.0"},
},
},
expected: "(MIT OR Apache-2.0)",
},
{
name: "with-addition operator",
info: &spdx.WithAdditionOperator{
SubjectExtendableLicense: &spdx.ListedLicense{Name: "GPL-2.0-only"},
SubjectAddition: &spdx.ListedLicenseException{AdditionText: "Classpath-exception-2.0"},
},
expected: "GPL-2.0-only WITH Classpath-exception-2.0",
},
{
name: "nested: (MIT OR Apache-2.0) AND GPL-2.0",
info: &spdx.ConjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.DisjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "Apache-2.0"},
},
},
&spdx.ListedLicense{Name: "GPL-2.0-only"},
},
},
expected: "(MIT OR Apache-2.0) AND GPL-2.0-only",
},
{
name: "nested: GPL-2.0 WITH Classpath OR MIT",
info: &spdx.DisjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.WithAdditionOperator{
SubjectExtendableLicense: &spdx.ListedLicense{Name: "GPL-2.0-only"},
SubjectAddition: &spdx.ListedLicenseException{AdditionText: "Classpath-exception-2.0"},
},
&spdx.ListedLicense{Name: "MIT"},
},
},
expected: "(GPL-2.0-only WITH Classpath-exception-2.0 OR MIT)",
},
{
name: "nil returns empty",
info: nil,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := v3licenseInfoToExpression(tt.info)
assert.Equal(t, tt.expected, got)
})
}
}
func Test_v3toSyftLicenses(t *testing.T) {
tests := []struct {
name string
licenseType license.Type
licenses []spdx.AnyLicenseInfo
expected []string
}{
{
name: "simple listed license",
licenseType: license.Declared,
licenses: []spdx.AnyLicenseInfo{
&spdx.ListedLicense{Name: "MIT"},
},
expected: []string{"MIT"},
},
{
name: "conjunctive set produces single expression",
licenseType: license.Concluded,
licenses: []spdx.AnyLicenseInfo{
&spdx.ConjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "Apache-2.0"},
},
},
},
expected: []string{"MIT AND Apache-2.0"},
},
{
name: "multiple license infos",
licenseType: license.Declared,
licenses: []spdx.AnyLicenseInfo{
&spdx.ListedLicense{Name: "MIT"},
&spdx.ListedLicense{Name: "GPL-2.0-only"},
},
expected: []string{"MIT", "GPL-2.0-only"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := v3toSyftLicenses(tt.licenseType, tt.licenses...)
require.Len(t, got, len(tt.expected))
for i, l := range got {
assert.Equal(t, tt.licenseType, l.Type)
assert.Equal(t, tt.expected[i], l.Value)
}
})
}
}
func Test_v3parseSPDXLicenses(t *testing.T) {
p := &spdx.Package{Name: "test-pkg"}
concludedLicense := &spdx.ListedLicense{Name: "MIT"}
declaredLicense := &spdx.ConjunctiveLicenseSet{
Members: spdx.LicenseInfoList{
&spdx.ListedLicense{Name: "Apache-2.0"},
&spdx.ListedLicense{Name: "BSD-3-Clause"},
},
}
relationships := ptrMap[[]spdx.AnyRelationship]{}
relationships.Set(p, []spdx.AnyRelationship{
&spdx.Relationship{
Type: spdx.RelationshipType_HasConcludedLicense,
From: p,
To: spdx.ElementList{concludedLicense},
},
&spdx.Relationship{
Type: spdx.RelationshipType_HasDeclaredLicense,
From: p,
To: spdx.ElementList{declaredLicense},
},
})
licenses := v3parseSPDXLicenses(relationships, p)
require.Len(t, licenses, 2)
var concluded, declared []pkg.License
for _, l := range licenses {
switch l.Type {
case license.Concluded:
concluded = append(concluded, l)
case license.Declared:
declared = append(declared, l)
}
}
require.Len(t, concluded, 1)
assert.Equal(t, "MIT", concluded[0].Value)
require.Len(t, declared, 1)
assert.Equal(t, "Apache-2.0 AND BSD-3-Clause", declared[0].Value)
}

View File

@ -14,12 +14,22 @@ import (
) )
type EncoderCollection struct { type EncoderCollection struct {
encoders []sbom.FormatEncoder defaultVersions map[sbom.FormatID]string
encoders []sbom.FormatEncoder
} }
func NewEncoderCollection(encoders ...sbom.FormatEncoder) *EncoderCollection { func NewEncoderCollection(encoders ...sbom.FormatEncoder) *EncoderCollection {
defaultVersions := map[sbom.FormatID]string{}
for _, encoder := range encoders {
if defaultVersion, ok := encoder.(defaultVersionIndicator); ok {
if defaultVersion.DefaultVersion() {
defaultVersions[encoder.ID()] = encoder.Version()
}
}
}
return &EncoderCollection{ return &EncoderCollection{
encoders: encoders, defaultVersions: defaultVersions,
encoders: encoders,
} }
} }
@ -77,13 +87,20 @@ func (e EncoderCollection) Get(name string, version string) sbom.FormatEncoder {
var mostRecentFormat sbom.FormatEncoder var mostRecentFormat sbom.FormatEncoder
for _, f := range e.encoders { for _, f := range e.encoders {
defaultVersion := e.defaultVersions[f.ID()]
log.WithFields("name", f.ID(), "version", f.Version(), "aliases", f.Aliases()).Trace("considering format") log.WithFields("name", f.ID(), "version", f.Version(), "aliases", f.Aliases()).Trace("considering format")
names := []string{string(f.ID())} names := []string{string(f.ID())}
names = append(names, f.Aliases()...) names = append(names, f.Aliases()...)
for _, n := range names { for _, n := range names {
if cleanFormatName(n) == name && versionMatches(f.Version(), version) { if cleanFormatName(n) == name {
if mostRecentFormat == nil || f.Version() > mostRecentFormat.Version() { // if the name or alias matches, and the requested version is any-version, use the default
mostRecentFormat = f if version == sbom.AnyVersion && defaultVersion != "" {
version = defaultVersion
}
if versionMatches(f.Version(), version) {
if mostRecentFormat == nil || f.Version() > mostRecentFormat.Version() {
mostRecentFormat = f
}
} }
} }
} }
@ -141,3 +158,9 @@ func Encode(s sbom.SBOM, f sbom.FormatEncoder) ([]byte, error) {
return buff.Bytes(), nil return buff.Bytes(), nil
} }
type defaultVersionIndicator interface {
// DefaultVersion a sbom.FormatEncoder may indicate it is the default version to be used
// when no version is specified for the type
DefaultVersion() bool
}

View File

@ -4,7 +4,9 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/format/internal/spdxutil"
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
) )
@ -109,3 +111,76 @@ func Test_versionMatches(t *testing.T) {
}) })
} }
} }
func Test_EncoderCollection_Get(t *testing.T) {
tests := []struct {
name string
searchName string
searchVersion string
expectedID sbom.FormatID
expectedVersion string
}{
{
name: "explicit name and version",
searchName: "spdx-json",
searchVersion: "2.3",
expectedID: spdxutil.JSONFormatID,
expectedVersion: "2.3",
},
{
name: "explicit name without version gets default",
searchName: "spdx-json",
searchVersion: "",
expectedID: spdxutil.JSONFormatID,
expectedVersion: "2.3",
},
{
name: "alias name with version",
searchName: "spdx",
searchVersion: "2.2",
expectedID: spdxutil.TagValueFormatID,
expectedVersion: "2.2",
},
{
name: "alias name without version gets default",
searchName: "spdx",
searchVersion: "",
expectedID: spdxutil.TagValueFormatID,
expectedVersion: "2.3",
},
{
name: "invalid name gets nothing",
searchName: "json-spdx",
searchVersion: "2.3",
expectedID: "",
},
{
name: "invalid version gets nothing",
searchName: "spdx-json",
searchVersion: "2.0",
expectedID: "",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
config := DefaultEncodersConfig()
// ensure SPDX default is 2.3 for test
config.SPDXJSON.DefaultVersion = "2.3"
encoders, err := config.Encoders()
require.NoError(t, err)
collection := NewEncoderCollection(encoders...)
result := collection.Get(test.searchName, test.searchVersion)
if test.expectedID != "" {
require.NotNil(t, result, "expected to find encoder but got nil")
if result != nil {
require.Equal(t, test.expectedID, result.ID())
require.Equal(t, test.expectedVersion, result.Version())
}
} else {
require.Nil(t, result, "expected nil but found encoder")
}
})
}
}

View File

@ -4,9 +4,10 @@ import (
"github.com/anchore/syft/syft/sbom" "github.com/anchore/syft/syft/sbom"
) )
const DefaultVersion = "2.3"
const ( const (
DefaultVersion = "2.3"
V3_0 = "3.0"
JSONFormatID sbom.FormatID = "spdx-json" JSONFormatID sbom.FormatID = "spdx-json"
TagValueFormatID sbom.FormatID = "spdx-tag-value" TagValueFormatID sbom.FormatID = "spdx-tag-value"
) )
@ -19,7 +20,10 @@ func SupportedVersions(id sbom.FormatID) []string {
if id != JSONFormatID { if id != JSONFormatID {
// JSON format is not supported in v2.1 // JSON format is not supported in v2.1
return append([]string{"2.1"}, versions...) versions = append([]string{"2.1"}, versions...)
} else {
// is JSON, v3 only supported in JSON format:
versions = append(versions, V3_0)
} }
return versions return versions

View File

@ -44,6 +44,10 @@ func (d decoder) Decode(r io.Reader) (*sbom.SBOM, sbom.FormatID, string, error)
return nil, "", "", fmt.Errorf("unable to seek to start of SPDX JSON SBOM: %+v", err) return nil, "", "", fmt.Errorf("unable to seek to start of SPDX JSON SBOM: %+v", err)
} }
if strings.HasPrefix(version, "3") {
return decodeSpdx3(version, reader)
}
doc, err := spdxJson.Read(reader) doc, err := spdxJson.Read(reader)
if err != nil { if err != nil {
return nil, id, version, fmt.Errorf("unable to decode spdx json: %w", err) return nil, id, version, fmt.Errorf("unable to decode spdx json: %w", err)
@ -62,11 +66,6 @@ func (d decoder) Identify(r io.Reader) (sbom.FormatID, string) {
return "", "" return "", ""
} }
if _, err := reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of SPDX JSON SBOM: %+v", err)
return "", ""
}
// Example JSON document // Example JSON document
// { // {
// "spdxVersion": "SPDX-2.3", // "spdxVersion": "SPDX-2.3",
@ -85,7 +84,18 @@ func (d decoder) Identify(r io.Reader) (sbom.FormatID, string) {
id, version := getFormatInfo(doc.SPDXVersion) id, version := getFormatInfo(doc.SPDXVersion)
if version == "" || id != ID { if version == "" || id != ID {
// not a spdx json document that we support // not a spdx 2 json document that we support, check for v3
if _, err = reader.Seek(0, io.SeekStart); err != nil {
log.Debugf("unable to seek to start of SPDX JSON SBOM: %+v", err)
return "", ""
}
id3, version3 := identifySpdx3(reader)
if id3 != "" && version3 != "" {
return id3, version3
}
return "", "" return "", ""
} }

View File

@ -9,6 +9,7 @@ import (
"github.com/spdx/tools-golang/spdx/v2/v2_1" "github.com/spdx/tools-golang/spdx/v2/v2_1"
"github.com/spdx/tools-golang/spdx/v2/v2_2" "github.com/spdx/tools-golang/spdx/v2/v2_2"
"github.com/spdx/tools-golang/spdx/v2/v2_3" "github.com/spdx/tools-golang/spdx/v2/v2_3"
"github.com/spdx/tools-golang/spdx/v3/v3_0"
"github.com/anchore/syft/syft/format/common/spdxhelpers" "github.com/anchore/syft/syft/format/common/spdxhelpers"
"github.com/anchore/syft/syft/format/internal/spdxutil" "github.com/anchore/syft/syft/format/internal/spdxutil"
@ -22,8 +23,9 @@ func SupportedVersions() []string {
} }
type EncoderConfig struct { type EncoderConfig struct {
Version string Version string
Pretty bool // don't include spaces and newlines; same as jq -c Pretty bool // don't include spaces and newlines; same as jq -c
DefaultVersion string
} }
type encoder struct { type encoder struct {
@ -38,8 +40,9 @@ func NewFormatEncoderWithConfig(cfg EncoderConfig) (sbom.FormatEncoder, error) {
func DefaultEncoderConfig() EncoderConfig { func DefaultEncoderConfig() EncoderConfig {
return EncoderConfig{ return EncoderConfig{
Version: spdxutil.DefaultVersion, DefaultVersion: spdxutil.DefaultVersion,
Pretty: false, Version: spdxutil.DefaultVersion,
Pretty: false,
} }
} }
@ -68,6 +71,7 @@ func (e encoder) Encode(writer io.Writer, s sbom.SBOM) error {
doc := v2_1.Document{} doc := v2_1.Document{}
err = convert.Document(latestDoc, &doc) err = convert.Document(latestDoc, &doc)
encodeDoc = doc encodeDoc = doc
case "2.2": case "2.2":
doc := v2_2.Document{} doc := v2_2.Document{}
err = convert.Document(latestDoc, &doc) err = convert.Document(latestDoc, &doc)
@ -77,6 +81,11 @@ func (e encoder) Encode(writer io.Writer, s sbom.SBOM) error {
doc := v2_3.Document{} doc := v2_3.Document{}
err = convert.Document(latestDoc, &doc) err = convert.Document(latestDoc, &doc)
encodeDoc = doc encodeDoc = doc
case spdxutil.V3_0:
doc := &v3_0.Document{}
err = convert.Document(latestDoc, doc)
encodeDoc = doc
default: default:
return fmt.Errorf("unsupported SPDX version %q", e.cfg.Version) return fmt.Errorf("unsupported SPDX version %q", e.cfg.Version)
} }
@ -95,3 +104,7 @@ func (e encoder) Encode(writer io.Writer, s sbom.SBOM) error {
return enc.Encode(encodeDoc) return enc.Encode(encodeDoc)
} }
func (e encoder) DefaultVersion() bool {
return e.cfg.DefaultVersion == e.cfg.Version
}

View File

@ -216,6 +216,10 @@ func redactor(values ...string) testutil.Redactor {
// each SBOM reports a unique documentNamespace when generated, this is not useful for snapshot testing // each SBOM reports a unique documentNamespace when generated, this is not useful for snapshot testing
`"documentNamespace":\s+"[^"]*"`: `"documentNamespace":"redacted"`, `"documentNamespace":\s+"[^"]*"`: `"documentNamespace":"redacted"`,
// spdx3 IDs are URI with the documentnamespace spdxId containing a UID; namespace is the equivalent documentNamespace with the same value
`"spdxId":\s+"[^"]*"`: `"spdxId":"https://redacted"`,
`"namespace":\s+"[^"]*"`: `"namespace":"https://redacted/"`,
// the license list will be updated periodically, the value here should not be directly tested in snapshot tests // the license list will be updated periodically, the value here should not be directly tested in snapshot tests
`"licenseListVersion":\s+"[^"]*"`: `"licenseListVersion":"redacted"`, `"licenseListVersion":\s+"[^"]*"`: `"licenseListVersion":"redacted"`,
}, },
@ -239,12 +243,14 @@ func TestSupportedVersions(t *testing.T) {
"2.2": 2, "2.2": 2,
// the source-to-package relationships can be removed since the primaryPackagePurpose info is available in 2.3 // the source-to-package relationships can be removed since the primaryPackagePurpose info is available in 2.3
"2.3": 0, "2.3": 0,
"3.0": 0,
} }
pkgCountOffsetPerVersion := map[string]int{ pkgCountOffsetPerVersion := map[string]int{
"2.1": 1, // the source is mapped as a package, but cannot distinguish it since the primaryPackagePurpose info is not available until 2.3 "2.1": 1, // the source is mapped as a package, but cannot distinguish it since the primaryPackagePurpose info is not available until 2.3
"2.2": 1, // the source is mapped as a package, but cannot distinguish it since the primaryPackagePurpose info is not available until 2.3 "2.2": 1, // the source is mapped as a package, but cannot distinguish it since the primaryPackagePurpose info is not available until 2.3
"2.3": 0, // the source package can be removed since the primaryPackagePurpose info is available "2.3": 0, // the source package can be removed since the primaryPackagePurpose info is available
"3.0": 0,
} }
for _, enc := range encs { for _, enc := range encs {

View File

@ -0,0 +1,63 @@
package spdxjson
import (
"encoding/json"
"fmt"
"io"
"regexp"
"github.com/spdx/tools-golang/spdx/v3/v3_0"
"github.com/anchore/syft/syft/format/common/spdxhelpers"
"github.com/anchore/syft/syft/format/internal/spdxutil"
"github.com/anchore/syft/syft/sbom"
)
var spdx3_0contextRegex = spdxContextRegex(spdxutil.V3_0)
func decodeSpdx3(version string, reader io.Reader) (*sbom.SBOM, sbom.FormatID, string, error) {
switch version {
case spdxutil.V3_0:
doc := v3_0.NewDocument(v3_0.ProfileIdentifierType_Software, "", nil, nil)
err := doc.FromJSON(reader)
if err != nil {
return nil, "", "", err
}
sb, err := spdxhelpers.ToSyftModelV3(doc)
return sb, spdxutil.JSONFormatID, spdxutil.V3_0, err
default:
return nil, "", "", fmt.Errorf("unsupported version: %v", version)
}
}
func identifySpdx3(reader io.Reader) (sbom.FormatID, string) {
type Document struct {
Context string `json:"@context"`
}
dec := json.NewDecoder(reader)
var doc Document
if err := dec.Decode(&doc); err != nil {
// maybe not json? maybe not valid? doesn't matter, we won't process it.
return "", ""
}
formatID := sbom.FormatID("")
spdxVersion := ""
switch {
case doc.Context == "":
case spdx3_0contextRegex.MatchString(doc.Context):
formatID = spdxutil.JSONFormatID
spdxVersion = spdxutil.V3_0
default:
}
return formatID, spdxVersion
}
func spdxContextRegex(minorVersion string) *regexp.Regexp {
// today this is "3.0.1", but is likely to be changed to only include the minor version "3.0"
return regexp.MustCompile(regexp.QuoteMeta("https://spdx.org/rdf/") + minorVersion + `(\.\d+)?` + regexp.QuoteMeta("/spdx-context.jsonld"))
}

View File

@ -0,0 +1,426 @@
package spdxjson
import (
"bytes"
"context"
"sort"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
"github.com/anchore/syft/syft/artifact"
"github.com/anchore/syft/syft/cpe"
"github.com/anchore/syft/syft/file"
"github.com/anchore/syft/syft/format/internal/spdxutil"
"github.com/anchore/syft/syft/pkg"
"github.com/anchore/syft/syft/sbom"
"github.com/anchore/syft/syft/source"
)
// TestSPDX3JSONRoundTrip_AllPackageTypes encodes a syft SBOM as SPDX 3.0 JSON and decodes it back, asserting
// (via a full struct diff) that every package field representable in SPDX 3.0 survives the trip.
//
// Each case provides the package to encode (input) and the package expected after decoding
// (want). They differ because some information is not representable in the SPDX 3.0 model:
// - Locations / FoundBy are not encoded per-package (ignored in the diff below).
// - The package ID is derived from the SPDX element ID rather than the original syft ID
// (the unexported id field is ignored in the diff below).
// - Type and Language are reconstructed from the package URL type.
// - Metadata is only reconstructed for the package types with dedicated handling in the
// SPDX encoder/decoder (apk, deb, rpm, java-archive, go-module); for all other types the
// decoded package carries no metadata.
func TestSPDX3JSONRoundTrip_AllPackageTypes(t *testing.T) {
ctx := context.Background()
license := func(value string) pkg.LicenseSet {
return pkg.NewLicenseSet(pkg.NewLicenseWithContext(ctx, value))
}
cpes := func(value string) []cpe.CPE {
return []cpe.CPE{cpe.Must(value, cpe.DeclaredSource)}
}
cases := []struct {
name string
input pkg.Package
want pkg.Package
}{
{
name: "python package (no reconstructed metadata)",
input: pkg.Package{
Name: "package-python",
Version: "1.0.1",
Type: pkg.PythonPkg,
Language: pkg.Python,
Licenses: license("MIT"),
CPEs: cpes("cpe:2.3:a:python:package-python:1.0.1:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/python")),
PURL: "pkg:pypi/package-python@1.0.1",
Metadata: pkg.PythonPackage{Name: "package-python", Version: "1.0.1"},
},
want: pkg.Package{
Name: "package-python",
Version: "1.0.1",
Type: pkg.PythonPkg,
Language: pkg.Python,
Licenses: license("MIT"),
CPEs: cpes("cpe:2.3:a:python:package-python:1.0.1:*:*:*:*:*:*:*"),
PURL: "pkg:pypi/package-python@1.0.1",
},
},
{
name: "npm package (no reconstructed metadata)",
input: pkg.Package{
Name: "package-npm",
Version: "2.0.1",
Type: pkg.NpmPkg,
Language: pkg.JavaScript,
Licenses: license("Apache-2.0"),
CPEs: cpes("cpe:2.3:a:npm:package-npm:2.0.1:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/npm")),
PURL: "pkg:npm/package-npm@2.0.1",
Metadata: pkg.NpmPackage{Name: "package-npm", Version: "2.0.1"},
},
want: pkg.Package{
Name: "package-npm",
Version: "2.0.1",
Type: pkg.NpmPkg,
Language: pkg.JavaScript,
Licenses: license("Apache-2.0"),
CPEs: cpes("cpe:2.3:a:npm:package-npm:2.0.1:*:*:*:*:*:*:*"),
PURL: "pkg:npm/package-npm@2.0.1",
},
},
{
name: "apk package",
input: pkg.Package{
Name: "package-apk",
Version: "3.0",
Type: pkg.ApkPkg,
Licenses: license("GPL-2.0-only"),
CPEs: cpes("cpe:2.3:a:alpine:package-apk:3.0:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/apk")),
PURL: "pkg:apk/alpine/package-apk@3.0?arch=x86_64&upstream=apk-origin",
Metadata: pkg.ApkDBEntry{
Package: "package-apk",
OriginPackage: "apk-origin",
Maintainer: "Alpine Maintainer",
Version: "3.0",
Architecture: "x86_64",
Description: "the apk package",
},
},
want: pkg.Package{
Name: "package-apk",
Version: "3.0",
Type: pkg.ApkPkg,
Licenses: license("GPL-2.0-only"),
CPEs: cpes("cpe:2.3:a:alpine:package-apk:3.0:*:*:*:*:*:*:*"),
PURL: "pkg:apk/alpine/package-apk@3.0?arch=x86_64&upstream=apk-origin",
Metadata: pkg.ApkDBEntry{
Package: "package-apk",
OriginPackage: "apk-origin",
Maintainer: "Alpine Maintainer",
Version: "3.0",
Architecture: "x86_64",
Description: "the apk package",
},
},
},
{
name: "deb package",
input: pkg.Package{
Name: "package-deb",
Version: "4.0",
Type: pkg.DebPkg,
Licenses: license("LGPL-2.1-only"),
CPEs: cpes("cpe:2.3:a:debian:package-deb:4.0:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/deb")),
PURL: "pkg:deb/debian/package-deb@4.0?arch=amd64&upstream=deb-src%404.1",
Metadata: pkg.DpkgDBEntry{
Package: "package-deb",
Source: "deb-src",
Version: "4.0",
SourceVersion: "4.1",
Architecture: "amd64",
Maintainer: "Debian Maintainer",
},
},
want: pkg.Package{
Name: "package-deb",
Version: "4.0",
Type: pkg.DebPkg,
Licenses: license("LGPL-2.1-only"),
CPEs: cpes("cpe:2.3:a:debian:package-deb:4.0:*:*:*:*:*:*:*"),
PURL: "pkg:deb/debian/package-deb@4.0?arch=amd64&upstream=deb-src%404.1",
Metadata: pkg.DpkgDBEntry{
Package: "package-deb",
Source: "deb-src",
Version: "4.0",
SourceVersion: "4.1",
Architecture: "amd64",
Maintainer: "Debian Maintainer",
},
},
},
{
name: "rpm package",
input: pkg.Package{
Name: "package-rpm",
Version: "5.0",
Type: pkg.RpmPkg,
Licenses: license("BSD-3-Clause"),
CPEs: cpes("cpe:2.3:a:redhat:package-rpm:5.0:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/rpm")),
PURL: "pkg:rpm/redhat/package-rpm@5.0?arch=x86_64&upstream=rpm-src-5.0",
Metadata: pkg.RpmDBEntry{
Name: "package-rpm",
Version: "5.0",
Arch: "x86_64",
SourceRpm: "rpm-src-5.0",
Vendor: "RedHat",
},
},
want: pkg.Package{
Name: "package-rpm",
Version: "5.0",
Type: pkg.RpmPkg,
Licenses: license("BSD-3-Clause"),
CPEs: cpes("cpe:2.3:a:redhat:package-rpm:5.0:*:*:*:*:*:*:*"),
PURL: "pkg:rpm/redhat/package-rpm@5.0?arch=x86_64&upstream=rpm-src-5.0",
Metadata: pkg.RpmDBEntry{
Name: "package-rpm",
Version: "5.0",
Arch: "x86_64",
SourceRpm: "rpm-src-5.0",
Vendor: "RedHat",
},
},
},
{
name: "java package",
input: pkg.Package{
Name: "package-java",
Version: "6.0",
Type: pkg.JavaPkg,
Language: pkg.Java,
Licenses: license("EPL-2.0"),
CPEs: cpes("cpe:2.3:a:example:package-java:6.0:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/java")),
PURL: "pkg:maven/com.example/package-java@6.0",
Metadata: pkg.JavaArchive{
ArchiveDigests: []file.Digest{
{Algorithm: "sha1", Value: "3b4ab96c371d913e2a88c269844b6c5fb5cbe761"},
},
},
},
want: pkg.Package{
Name: "package-java",
Version: "6.0",
Type: pkg.JavaPkg,
Language: pkg.Java,
Licenses: license("EPL-2.0"),
CPEs: cpes("cpe:2.3:a:example:package-java:6.0:*:*:*:*:*:*:*"),
PURL: "pkg:maven/com.example/package-java@6.0",
Metadata: pkg.JavaArchive{
ArchiveDigests: []file.Digest{
{Algorithm: "sha1", Value: "3b4ab96c371d913e2a88c269844b6c5fb5cbe761"},
},
},
},
},
{
name: "go module package",
input: pkg.Package{
Name: "package-go",
Version: "7.0",
Type: pkg.GoModulePkg,
Language: pkg.Go,
Licenses: license("MPL-2.0"),
CPEs: cpes("cpe:2.3:a:example:package-go:7.0:*:*:*:*:*:*:*"),
Locations: file.NewLocationSet(file.NewLocation("/go")),
PURL: "pkg:golang/example.com/package-go@7.0",
Metadata: pkg.GolangBinaryBuildinfoEntry{
H1Digest: "h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=",
},
},
want: pkg.Package{
Name: "package-go",
Version: "7.0",
Type: pkg.GoModulePkg,
Language: pkg.Go,
Licenses: license("MPL-2.0"),
CPEs: cpes("cpe:2.3:a:example:package-go:7.0:*:*:*:*:*:*:*"),
PURL: "pkg:golang/example.com/package-go@7.0",
Metadata: pkg.GolangBinaryBuildinfoEntry{
H1Digest: "h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=",
},
},
},
}
var inputs []pkg.Package
var want []pkg.Package
for _, c := range cases {
in := c.input
in.SetID()
inputs = append(inputs, in)
want = append(want, c.want)
}
subject := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(inputs...),
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "v0.42.0-bogus",
},
Source: source.Description{
Metadata: source.DirectoryMetadata{Path: "/home/app", Base: "/home/app"},
},
}
cfg := DefaultEncoderConfig()
cfg.Pretty = true
cfg.Version = spdxutil.V3_0
enc, err := NewFormatEncoderWithConfig(cfg)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
dec := NewFormatDecoder()
id, version := dec.Identify(bytes.NewReader(buf.Bytes()))
require.Equal(t, ID, id)
require.Equal(t, spdxutil.V3_0, version)
s, decodeID, decodeVersion, err := dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.NotNil(t, s)
require.Equal(t, ID, decodeID)
require.Equal(t, spdxutil.V3_0, decodeVersion)
// the directory source should be recognized as the document root and not surface as a package
require.Equal(t, len(want), s.Artifacts.Packages.PackageCount())
got := s.Artifacts.Packages.Sorted()
sortByName(want)
sortByName(got)
diff := cmp.Diff(want, got,
cmpopts.IgnoreUnexported(pkg.Package{}, pkg.LicenseSet{}, file.LocationSet{}),
cmpopts.IgnoreFields(pkg.Package{}, "Locations", "FoundBy"),
)
require.Empty(t, diff, "decoded packages differ from expected (-want +got):\n%s", diff)
}
func sortByName(pkgs []pkg.Package) {
sort.Slice(pkgs, func(i, j int) bool {
return pkgs[i].Name < pkgs[j].Name
})
}
// TestSPDX3JSONRoundTrip_FullSBOM encodes a more complete SBOM (a container image source, several
// packages, and relationships between them) as SPDX 3.0 JSON and decodes it back, verifying that
// the source description and the package relationships survive the round trip.
func TestSPDX3JSONRoundTrip_FullSBOM(t *testing.T) {
newPkg := func(name string) pkg.Package {
p := pkg.Package{
Name: name,
Version: "1.0",
Type: pkg.NpmPkg,
Language: pkg.JavaScript,
PURL: "pkg:npm/" + name + "@1.0",
Locations: file.NewLocationSet(
file.NewLocation("/" + name),
),
}
p.SetID()
return p
}
app := newPkg("app")
lib := newPkg("lib")
dep := newPkg("dep")
subject := sbom.SBOM{
Artifacts: sbom.Artifacts{
Packages: pkg.NewCollection(app, lib, dep),
},
Relationships: []artifact.Relationship{
// app contains lib
{From: app, To: lib, Type: artifact.ContainsRelationship},
// dep is a dependency of lib
{From: dep, To: lib, Type: artifact.DependencyOfRelationship},
},
Descriptor: sbom.Descriptor{
Name: "syft",
Version: "v0.42.0-bogus",
},
Source: source.Description{
Name: "some-image",
Version: "some-tag",
Supplier: "some-supplier",
Metadata: source.ImageMetadata{
UserInput: "some-image:some-tag",
ManifestDigest: "sha256:abcdef0123456789",
Architecture: "amd64",
},
},
}
cfg := DefaultEncoderConfig()
cfg.Pretty = true
cfg.Version = spdxutil.V3_0
enc, err := NewFormatEncoderWithConfig(cfg)
require.NoError(t, err)
var buf bytes.Buffer
require.NoError(t, enc.Encode(&buf, subject))
dec := NewFormatDecoder()
s, decodeID, decodeVersion, err := dec.Decode(bytes.NewReader(buf.Bytes()))
require.NoError(t, err)
require.NotNil(t, s)
require.Equal(t, ID, decodeID)
require.Equal(t, spdxutil.V3_0, decodeVersion)
// the container image source is decoded as the document root rather than a package
require.Equal(t, 3, s.Artifacts.Packages.PackageCount())
// source information survives, and the source is not surfaced as a package
// (the ID is re-derived from the SPDX element ID and is therefore not compared)
require.Equal(t, "some-image", s.Source.Name)
require.Equal(t, "some-tag", s.Source.Version)
require.Equal(t, "some-supplier", s.Source.Supplier)
require.IsType(t, source.ImageMetadata{}, s.Source.Metadata)
imageMetadata := s.Source.Metadata.(source.ImageMetadata)
// the user input is reconstructed from the image name and tag (version)
require.Equal(t, "some-image:some-tag", imageMetadata.UserInput)
require.Equal(t, "sha256:abcdef0123456789", imageMetadata.ManifestDigest)
// relationships between packages survive, including direction
type rel struct {
from string
to string
typ artifact.RelationshipType
}
var got []rel
for _, r := range s.Relationships {
from, fromOk := r.From.(pkg.Package)
to, toOk := r.To.(pkg.Package)
require.True(t, fromOk, "relationship from should be a package: %+v", r.From)
require.True(t, toOk, "relationship to should be a package: %+v", r.To)
got = append(got, rel{from: from.Name, to: to.Name, typ: r.Type})
}
require.ElementsMatch(t, []rel{
{from: "app", to: "lib", typ: artifact.ContainsRelationship},
{from: "dep", to: "lib", typ: artifact.DependencyOfRelationship},
}, got)
}

View File

@ -0,0 +1,141 @@
{
"@context" : "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph" : [ {
"@id" : "_:creationInfo_0",
"type" : "CreationInfo",
"specVersion" : "3.0.1",
"createdBy" : [ "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd0" ],
"createdUsing" : [ "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/additionalToolSPDXRef-gnrtd2", "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/additionalToolSPDXRef-gnrtd1" ],
"created" : "2021-08-26T01:46:00Z"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd23",
"type" : "Relationship",
"relationshipType" : "hasDeclaredLicense",
"to" : [ "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd8" ],
"from" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd4",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/document0",
"type" : "SpdxDocument",
"dataLicense" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd24",
"rootElement" : [ "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd4" ],
"name" : "hello",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/additionalToolSPDXRef-gnrtd1",
"type" : "Tool",
"name" : "github.com/spdx/tools-golang/builder",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/additionalToolSPDXRef-gnrtd2",
"type" : "Tool",
"name" : "github.com/spdx/tools-golang/idsearcher",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd8",
"type" : "simplelicensing_LicenseExpression",
"simplelicensing_licenseExpression" : "GPL-3.0-or-later",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd20",
"type" : "simplelicensing_LicenseExpression",
"simplelicensing_licenseExpression" : "NOASSERTION",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd24",
"type" : "simplelicensing_LicenseExpression",
"simplelicensing_licenseExpression" : "CC0-1.0",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd14",
"type" : "LifecycleScopedRelationship",
"relationshipType" : "usesTool",
"scope" : "build",
"to" : [ "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd13" ],
"completeness" : "noAssertion",
"from" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd4",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd0",
"type" : "Person",
"externalIdentifier" : [ {
"type" : "ExternalIdentifier",
"identifier" : "steve@swinslow.net",
"externalIdentifierType" : "email"
} ],
"name" : "Steve Winslow",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd6",
"type" : "software_File",
"software_copyrightText" : "Copyright Contributors to the spdx-examples project.",
"verifiedUsing" : [ {
"type" : "Hash",
"algorithm" : "md5",
"hashValue" : "935054fe899ca782e11003bbae5e166c"
}, {
"type" : "Hash",
"algorithm" : "sha1",
"hashValue" : "20862a6d08391d07d09344029533ec644fac6b21"
}, {
"type" : "Hash",
"algorithm" : "sha256",
"hashValue" : "b4e5ca56d1f9110ca94ed0bf4e6d9ac11c2186eb7cd95159c6fdb50e8db5a823"
} ],
"name" : "./src/hello.c",
"software_primaryPurpose" : "source",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd11",
"type" : "software_File",
"software_copyrightText" : "NOASSERTION",
"verifiedUsing" : [ {
"type" : "Hash",
"algorithm" : "sha1",
"hashValue" : "20291a81ef065ff891b537b64d4fdccaf6f5ac02"
}, {
"type" : "Hash",
"algorithm" : "sha256",
"hashValue" : "83a33ff09648bb5fc5272baca88cf2b59fd81ac4cc6817b86998136af368708e"
}, {
"type" : "Hash",
"algorithm" : "md5",
"hashValue" : "08a12c966d776864cc1eb41fd03c3c3d"
} ],
"name" : "./build/hello",
"contentType" : "application/octet-stream",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd13",
"type" : "software_File",
"software_copyrightText" : "NOASSERTION",
"verifiedUsing" : [ {
"type" : "Hash",
"algorithm" : "sha1",
"hashValue" : "69a2e85696fff1865c3f0686d6c3824b59915c80"
}, {
"type" : "Hash",
"algorithm" : "sha256",
"hashValue" : "5da19033ba058e322e21c90e6d6d859c90b1b544e7840859c12cae5da005e79c"
}, {
"type" : "Hash",
"algorithm" : "md5",
"hashValue" : "559424589a4f3f75fd542810473d8bc1"
} ],
"name" : "./src/Makefile",
"software_primaryPurpose" : "source",
"creationInfo" : "_:creationInfo_0"
}, {
"spdxId" : "https://swinslow.net/spdx-examples/example1/hello-v3-specv3/SPDXRef-gnrtd4",
"type" : "software_Package",
"software_copyrightText" : "NOASSERTION",
"software_downloadLocation" : "git+https://github.com/swinslow/spdx-examples.git#example1/content",
"verifiedUsing" : [ {
"type" : "PackageVerificationCode",
"algorithm" : "sha1",
"hashValue" : "9d20237bb72087e87069f96afb41c6ca2fa2a342"
} ],
"name" : "hello",
"creationInfo" : "_:creationInfo_0"
} ]
}

View File

@ -29,6 +29,7 @@ func TestAllFormatsConvertable(t *testing.T) {
{to: "spdx-json", from: "cyclonedx-json"}, {to: "spdx-json", from: "cyclonedx-json"},
{to: "cyclonedx-json", from: "syft-json"}, {to: "cyclonedx-json", from: "syft-json"},
{to: "cyclonedx-json", from: "spdx-json"}, {to: "cyclonedx-json", from: "spdx-json"},
{to: "spdx-json@3", from: "spdx-tag-value@2.1"},
} }
for _, test := range tests { for _, test := range tests {

View File

@ -65,6 +65,10 @@ func Test_formatVersionsExpressible(t *testing.T) {
format: "spdx-json@2.3", format: "spdx-json@2.3",
assertion: assertInOutput(`"spdxVersion":"SPDX-2.3"`), assertion: assertInOutput(`"spdxVersion":"SPDX-2.3"`),
}, },
{
format: "spdx-json@3.0",
assertion: assertInOutput(`https://spdx.org/rdf/3.0`),
},
} }
for _, test := range tests { for _, test := range tests {

View File

@ -28,6 +28,7 @@ func TestConvertCmd(t *testing.T) {
}{ }{
{from: "syft-json", to: "spdx-tag-value", expect: mustEncoder(spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.DefaultEncoderConfig()))}, {from: "syft-json", to: "spdx-tag-value", expect: mustEncoder(spdxtagvalue.NewFormatEncoderWithConfig(spdxtagvalue.DefaultEncoderConfig()))},
{from: "syft-json", to: "spdx-json", expect: mustEncoder(spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig()))}, {from: "syft-json", to: "spdx-json", expect: mustEncoder(spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig()))},
{from: "syft-json", to: "spdx-json@3.0", expect: mustEncoder(spdxjson.NewFormatEncoderWithConfig(spdxjson.DefaultEncoderConfig()))},
{from: "syft-json", to: "cyclonedx-json", expect: mustEncoder(cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.DefaultEncoderConfig()))}, {from: "syft-json", to: "cyclonedx-json", expect: mustEncoder(cyclonedxjson.NewFormatEncoderWithConfig(cyclonedxjson.DefaultEncoderConfig()))},
{from: "syft-json", to: "cyclonedx-xml", expect: mustEncoder(cyclonedxxml.NewFormatEncoderWithConfig(cyclonedxxml.DefaultEncoderConfig()))}, {from: "syft-json", to: "cyclonedx-xml", expect: mustEncoder(cyclonedxxml.NewFormatEncoderWithConfig(cyclonedxxml.DefaultEncoderConfig()))},
} }

View File

@ -31,7 +31,7 @@ func TestSPDXJSONSchema(t *testing.T) {
{ {
name: "scan:image:docker-archive:pkg-coverage", name: "scan:image:docker-archive:pkg-coverage",
subcommand: "scan", subcommand: "scan",
args: []string{"-o", "spdx-json"}, args: []string{"-o", "spdx-json@2.3"},
fixture: imageFixture, fixture: imageFixture,
}, },
{ {

View File

@ -2,23 +2,20 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
)
"github.com/anchore/stereoscope/pkg/imagetest" const (
validatorV2 = "ghcr.io/spdx/tools-java/tools-java:v1.1.8@sha256:c3b9e848083132e03b30302576b9b51adffd454f43c786f1708cc37c0861a2aa"
validatorV3 = "ghcr.io/spdx/tools-java/tools-java:v2.0.4@sha256:15062f85b4be9688c7bf42df34ad6b84e084ed46e262e1f2dc1603795de9f7b4"
) )
func TestSpdxValidationTooling(t *testing.T) { func TestSpdxValidationTooling(t *testing.T) {
// note: the external tooling requires that the daemon explicitly has the image loaded, not just that
// we can get the image from a cache tar.
imgTag := imagetest.LoadFixtureImageIntoDocker(t, "image-java-spdx-tools")
images := []string{ images := []string{
"alpine:3.17.3@sha256:b6ca290b6b4cdcca5b3db3ffa338ee0285c11744b4a6abaa9627746ee3291d8d", "alpine:3.17.3@sha256:b6ca290b6b4cdcca5b3db3ffa338ee0285c11744b4a6abaa9627746ee3291d8d",
"photon:3.0@sha256:888675e193418d924feea262cf639c46532b63c2027a39fd3ac75383b3c1130e", "photon:3.0@sha256:888675e193418d924feea262cf639c46532b63c2027a39fd3ac75383b3c1130e",
@ -32,43 +29,44 @@ func TestSpdxValidationTooling(t *testing.T) {
} }
tests := []struct { tests := []struct {
name string name string
syftArgs []string format string
images []string validator string
setup func(t *testing.T)
env map[string]string
}{ }{
{ {
name: "spdx validation tooling tag value", name: "spdx 2.3 validation tooling tag value",
syftArgs: []string{"scan", "-o", "spdx"}, format: "spdx",
images: images,
env: env,
}, },
{ {
name: "spdx validation tooling json", name: "spdx 2.3 validation tooling json",
syftArgs: []string{"scan", "-o", "spdx-json"}, format: "spdx-json",
images: images,
env: env,
}, },
{ {
name: "spdx validation tooling tag value", name: "spdx 3.0 validation tooling json",
syftArgs: []string{"scan", "-o", "spdx@2.2"}, format: "spdx-json@3.0",
images: images, validator: validatorV3,
env: env,
}, },
{ {
name: "spdx validation tooling json", name: "spdx 2.2 validation tooling tag value",
syftArgs: []string{"scan", "-o", "spdx-json@2.2"}, format: "spdx@2.2",
images: images, },
env: env, {
name: "spdx 2.2 validation tooling json",
format: "spdx-json@2.2",
}, },
} }
for _, test := range tests { for _, image := range images {
for _, image := range test.images { syftJsonFile := filepath.Join(t.TempDir(), "sbom.syft.json")
t.Run(test.name+"_"+image, func(t *testing.T) {
args := append(test.syftArgs, image) cmd, _, stderr := runSyft(t, env, "-o", "syft-json", "--file", syftJsonFile, image)
if cmd.ProcessState.ExitCode() != 0 {
t.Fatalf("failed to run syft: %s", stderr)
}
for _, test := range tests {
t.Run(test.name+"_"+image, func(t *testing.T) {
t.Parallel()
var suffix string var suffix string
if strings.Contains(test.name, "json") { if strings.Contains(test.name, "json") {
@ -78,30 +76,27 @@ func TestSpdxValidationTooling(t *testing.T) {
} }
dir := t.TempDir() dir := t.TempDir()
sbomPath := filepath.Join(dir, fmt.Sprintf("sbom%s", suffix)) sbomFile := fmt.Sprintf("sbom%s", suffix)
sbomPath := filepath.Join(dir, sbomFile)
args = append(args, "--file", sbomPath) cmd, _, stderr = runSyft(t, nil, "convert", syftJsonFile, "-o", test.format, "--file", sbomPath)
cmd, _, stderr := runSyft(t, test.env, args...)
if cmd.ProcessState.ExitCode() != 0 { if cmd.ProcessState.ExitCode() != 0 {
t.Fatalf("failed to run syft: %s", stderr) t.Fatalf("failed to run syft convert: %s", stderr)
} }
cwd, err := os.Getwd() if test.validator == "" {
require.NoError(t, err) test.validator = validatorV2
}
// validate against spdx java tooling // validate against spdx java tooling
fileArg := fmt.Sprintf("DIR=%s", dir) validateCmd := exec.Command("docker", "run", "--rm", "-i", "--platform", "linux/amd64",
mountArg := fmt.Sprintf("BASE=%s", path.Base(sbomPath)) "-v", dir+":/data", test.validator, "Verify", "/data/"+sbomFile)
imageArg := fmt.Sprintf("IMAGE=%s", imgTag)
validateCmd := exec.Command("make", "validate", fileArg, mountArg, imageArg)
validateCmd.Dir = filepath.Join(cwd, "testdata", "image-java-spdx-tools")
stdout, stderr, err := runCommand(validateCmd, map[string]string{}) stdout, stderr, err := runCommand(validateCmd, map[string]string{})
if err != nil { if err != nil {
t.Fatalf("invalid SPDX document:%v\nSTDOUT:\n%s\nSTDERR:\n%s", err, stdout, stderr) t.Fatalf("invalid SPDX document:%v\nSTDOUT:\n%s\nSTDERR:\n%s", err, stdout, stderr)
} }
require.Contains(t, stdout, "SPDX Document is valid")
}) })
} }
} }

View File

@ -1,7 +0,0 @@
FROM openjdk:11@sha256:e81b7f317654b0f26d3993e014b04bcb29250339b11b9de41e130feecd4cd43c
RUN wget --no-verbose https://github.com/spdx/tools-java/releases/download/v1.1.3/tools-java-1.1.3.zip && \
unzip tools-java-1.1.3.zip && \
rm tools-java-1.1.3.zip
ENTRYPOINT ["java", "-jar", "tools-java-1.1.3-jar-with-dependencies.jar"]

View File

@ -1,10 +0,0 @@
all: build validate
IMAGE := "spdx-java-tools:latest"
.PHONY: build
build:
docker build -t spdx-java-tools:latest .
validate:
docker run --rm -v $(DIR):/home/build/ $(IMAGE) Verify /home/build/$(BASE)