From ab74caa87f67d4bf58238c566c1fdca0e8efcfa2 Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Mon, 18 Mar 2024 12:52:11 +0400 Subject: [PATCH] refactor(sbom): use intermediate representation for SPDX (#6310) Signed-off-by: knqyf263 Co-authored-by: DmitriyLewen --- integration/testdata/conda-spdx.json.golden | 39 +- ...fluentd-multiple-lockfiles.cdx.json.golden | 24 +- pkg/fanal/analyzer/sbom/sbom_test.go | 24 +- pkg/fanal/applier/docker.go | 9 +- pkg/fanal/types/const.go | 8 + pkg/k8s/scanner/scanner.go | 6 +- pkg/k8s/scanner/scanner_test.go | 2 +- pkg/report/spdx/spdx.go | 2 +- pkg/sbom/core/bom.go | 55 +- pkg/sbom/cyclonedx/marshal.go | 21 +- pkg/sbom/cyclonedx/marshal_test.go | 4 +- pkg/sbom/cyclonedx/unmarshal.go | 12 +- pkg/sbom/io/decode.go | 62 +- pkg/sbom/io/encode.go | 106 ++-- pkg/sbom/io/encode_test.go | 5 +- pkg/sbom/sbom.go | 13 +- pkg/sbom/spdx/marshal.go | 568 +++++++++--------- pkg/sbom/spdx/marshal_test.go | 510 +++++++++------- ...lid-source-info.json => invalid-purl.json} | 4 +- pkg/sbom/spdx/unmarshal.go | 388 +++++------- pkg/sbom/spdx/unmarshal_test.go | 54 +- 21 files changed, 1041 insertions(+), 875 deletions(-) rename pkg/sbom/spdx/testdata/sad/{invalid-source-info.json => invalid-purl.json} (92%) diff --git a/integration/testdata/conda-spdx.json.golden b/integration/testdata/conda-spdx.json.golden index be1146b285c4..db81eb8abd13 100644 --- a/integration/testdata/conda-spdx.json.golden +++ b/integration/testdata/conda-spdx.json.golden @@ -3,7 +3,7 @@ "dataLicense": "CC0-1.0", "SPDXID": "SPDXRef-DOCUMENT", "name": "testdata/fixtures/repo/conda", - "documentNamespace": "http://aquasecurity.github.io/trivy/filesystem/testdata/fixtures/repo/conda-3ff14136-e09f-4df9-80ea-000000000001", + "documentNamespace": "http://aquasecurity.github.io/trivy/filesystem/testdata/fixtures/repo/conda-3ff14136-e09f-4df9-80ea-000000000004", "creationInfo": { "creators": [ "Organization: aquasecurity", @@ -12,17 +12,9 @@ "created": "2021-08-25T12:20:30Z" }, "packages": [ - { - "name": "conda-pkg", - "SPDXID": "SPDXRef-Application-ee5ef1aa4ac89125", - "downloadLocation": "NONE", - "filesAnalyzed": false, - "sourceInfo": "Conda", - "primaryPackagePurpose": "APPLICATION" - }, { "name": "openssl", - "SPDXID": "SPDXRef-Package-20b95c21bfbf9fc4", + "SPDXID": "SPDXRef-Package-b8061a5279413d55", "versionInfo": "1.1.1q", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -39,11 +31,14 @@ "referenceLocator": "pkg:conda/openssl@1.1.1q" } ], + "attributionTexts": [ + "PkgType: conda-pkg" + ], "primaryPackagePurpose": "LIBRARY" }, { "name": "pip", - "SPDXID": "SPDXRef-Package-11a429ec3bd01d80", + "SPDXID": "SPDXRef-Package-84198b3828050c11", "versionInfo": "22.2.2", "supplier": "NOASSERTION", "downloadLocation": "NONE", @@ -60,6 +55,9 @@ "referenceLocator": "pkg:conda/pip@22.2.2" } ], + "attributionTexts": [ + "PkgType: conda-pkg" + ], "primaryPackagePurpose": "LIBRARY" }, { @@ -105,27 +103,22 @@ }, { "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", - "relatedSpdxElement": "SPDXRef-Application-ee5ef1aa4ac89125", + "relatedSpdxElement": "SPDXRef-Package-84198b3828050c11", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Application-ee5ef1aa4ac89125", - "relatedSpdxElement": "SPDXRef-Package-20b95c21bfbf9fc4", - "relationshipType": "CONTAINS" - }, - { - "spdxElementId": "SPDXRef-Package-20b95c21bfbf9fc4", - "relatedSpdxElement": "SPDXRef-File-600e5e0110a84891", + "spdxElementId": "SPDXRef-Filesystem-2e2426fd0f2580ef", + "relatedSpdxElement": "SPDXRef-Package-b8061a5279413d55", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Application-ee5ef1aa4ac89125", - "relatedSpdxElement": "SPDXRef-Package-11a429ec3bd01d80", + "spdxElementId": "SPDXRef-Package-84198b3828050c11", + "relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a", "relationshipType": "CONTAINS" }, { - "spdxElementId": "SPDXRef-Package-11a429ec3bd01d80", - "relatedSpdxElement": "SPDXRef-File-7eb62e2a3edddc0a", + "spdxElementId": "SPDXRef-Package-b8061a5279413d55", + "relatedSpdxElement": "SPDXRef-File-600e5e0110a84891", "relationshipType": "CONTAINS" } ] diff --git a/integration/testdata/fluentd-multiple-lockfiles.cdx.json.golden b/integration/testdata/fluentd-multiple-lockfiles.cdx.json.golden index 40fdceb532c5..934bda200639 100644 --- a/integration/testdata/fluentd-multiple-lockfiles.cdx.json.golden +++ b/integration/testdata/fluentd-multiple-lockfiles.cdx.json.golden @@ -286,7 +286,7 @@ "bom-ref": "pkg:deb/debian/bsdutils@2.33.1-0.1?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "bsdutils", - "version": "2.33.1-0.1", + "version": "1:2.33.1-0.1", "licenses": [ { "license": { @@ -628,7 +628,7 @@ "bom-ref": "pkg:deb/debian/diffutils@3.7-3?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "diffutils", - "version": "3.7-3", + "version": "1:3.7-3", "licenses": [ { "license": { @@ -1338,7 +1338,7 @@ "bom-ref": "pkg:deb/debian/libattr1@2.4.48-4?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "libattr1", - "version": "2.4.48-4", + "version": "1:2.4.48-4", "licenses": [ { "license": { @@ -1396,7 +1396,7 @@ "bom-ref": "pkg:deb/debian/libaudit-common@2.8.4-3?arch=all&distro=debian-10.2&epoch=1", "type": "library", "name": "libaudit-common", - "version": "2.8.4-3", + "version": "1:2.8.4-3", "licenses": [ { "license": { @@ -1454,7 +1454,7 @@ "bom-ref": "pkg:deb/debian/libaudit1@2.8.4-3?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "libaudit1", - "version": "2.8.4-3", + "version": "1:2.8.4-3", "licenses": [ { "license": { @@ -2091,7 +2091,7 @@ "bom-ref": "pkg:deb/debian/libgcc1@8.3.0-6?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "libgcc1", - "version": "8.3.0-6", + "version": "1:8.3.0-6", "purl": "pkg:deb/debian/libgcc1@8.3.0-6?arch=amd64&distro=debian-10.2&epoch=1", "properties": [ { @@ -2285,7 +2285,7 @@ "bom-ref": "pkg:deb/debian/libgmp10@6.1.2%2Bdfsg-4?arch=amd64&distro=debian-10.2&epoch=2", "type": "library", "name": "libgmp10", - "version": "6.1.2+dfsg-4", + "version": "2:6.1.2+dfsg-4", "licenses": [ { "license": { @@ -3286,7 +3286,7 @@ "bom-ref": "pkg:deb/debian/libpcre3@8.39-12?arch=amd64&distro=debian-10.2&epoch=2", "type": "library", "name": "libpcre3", - "version": "8.39-12", + "version": "2:8.39-12", "purl": "pkg:deb/debian/libpcre3@8.39-12?arch=amd64&distro=debian-10.2&epoch=2", "properties": [ { @@ -4450,7 +4450,7 @@ "bom-ref": "pkg:deb/debian/login@4.5-1.1?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "login", - "version": "4.5-1.1", + "version": "1:4.5-1.1", "licenses": [ { "license": { @@ -4742,7 +4742,7 @@ "bom-ref": "pkg:deb/debian/passwd@4.5-1.1?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "passwd", - "version": "4.5-1.1", + "version": "1:4.5-1.1", "licenses": [ { "license": { @@ -5338,7 +5338,7 @@ "bom-ref": "pkg:deb/debian/ruby@2.5.1?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "ruby", - "version": "2.5.1", + "version": "1:2.5.1", "licenses": [ { "license": { @@ -5690,7 +5690,7 @@ "bom-ref": "pkg:deb/debian/zlib1g@1.2.11.dfsg-1?arch=amd64&distro=debian-10.2&epoch=1", "type": "library", "name": "zlib1g", - "version": "1.2.11.dfsg-1", + "version": "1:1.2.11.dfsg-1", "licenses": [ { "license": { diff --git a/pkg/fanal/analyzer/sbom/sbom_test.go b/pkg/fanal/analyzer/sbom/sbom_test.go index c6f5b4b33701..3bcb619d402b 100644 --- a/pkg/fanal/analyzer/sbom/sbom_test.go +++ b/pkg/fanal/analyzer/sbom/sbom_test.go @@ -31,6 +31,7 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { Type: types.Jar, Libraries: types.Packages{ { + ID: "co.elastic.apm:apm-agent:1.36.0", Name: "co.elastic.apm:apm-agent", Version: "1.36.0", FilePath: "opt/bitnami/elasticsearch", @@ -44,6 +45,7 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { }, }, { + ID: "co.elastic.apm:apm-agent-cached-lookup-key:1.36.0", Name: "co.elastic.apm:apm-agent-cached-lookup-key", Version: "1.36.0", FilePath: "opt/bitnami/elasticsearch", @@ -57,6 +59,7 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { }, }, { + ID: "co.elastic.apm:apm-agent-common:1.36.0", Name: "co.elastic.apm:apm-agent-common", Version: "1.36.0", FilePath: "opt/bitnami/elasticsearch", @@ -70,6 +73,7 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { }, }, { + ID: "co.elastic.apm:apm-agent-core:1.36.0", Name: "co.elastic.apm:apm-agent-core", Version: "1.36.0", FilePath: "opt/bitnami/elasticsearch", @@ -89,7 +93,8 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { FilePath: "opt/bitnami/elasticsearch", Libraries: types.Packages{ { - Name: "elasticsearch", + ID: "Elasticsearch@8.9.1", + Name: "Elasticsearch", Version: "8.9.1", Arch: "arm64", Licenses: []string{"Elastic-2.0"}, @@ -169,7 +174,8 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { FilePath: "opt/bitnami/postgresql", Libraries: types.Packages{ { - Name: "gdal", + ID: "GDAL@3.7.1", + Name: "GDAL", Version: "3.7.1", Licenses: []string{"MIT"}, Identifier: types.PkgIdentifier{ @@ -181,7 +187,8 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { }, }, { - Name: "geos", + ID: "GEOS@3.8.3", + Name: "GEOS", Version: "3.8.3", Licenses: []string{"LGPL-2.1-only"}, Identifier: types.PkgIdentifier{ @@ -193,7 +200,8 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { }, }, { - Name: "postgresql", + ID: "PostgreSQL@15.3.0", + Name: "PostgreSQL", Version: "15.3.0", Licenses: []string{"PostgreSQL"}, Identifier: types.PkgIdentifier{ @@ -203,9 +211,15 @@ func Test_sbomAnalyzer_Analyze(t *testing.T) { Version: "15.3.0", }, }, + DependsOn: []string{ + "GEOS@3.8.3", + "Proj@6.3.2", + "GDAL@3.7.1", + }, }, { - Name: "proj", + ID: "Proj@6.3.2", + Name: "Proj", Version: "6.3.2", Licenses: []string{"MIT"}, Identifier: types.PkgIdentifier{ diff --git a/pkg/fanal/applier/docker.go b/pkg/fanal/applier/docker.go index 730737e8a370..abcc1ce51958 100644 --- a/pkg/fanal/applier/docker.go +++ b/pkg/fanal/applier/docker.go @@ -263,12 +263,9 @@ func newPURL(pkgType ftypes.TargetType, metadata types.Metadata, pkg ftypes.Pack func aggregate(detail *ftypes.ArtifactDetail) { var apps []ftypes.Application - aggregatedApps := map[ftypes.LangType]*ftypes.Application{ - ftypes.PythonPkg: {Type: ftypes.PythonPkg}, - ftypes.CondaPkg: {Type: ftypes.CondaPkg}, - ftypes.GemSpec: {Type: ftypes.GemSpec}, - ftypes.NodePkg: {Type: ftypes.NodePkg}, - ftypes.Jar: {Type: ftypes.Jar}, + aggregatedApps := make(map[ftypes.LangType]*ftypes.Application) + for _, t := range ftypes.AggregatingTypes { + aggregatedApps[t] = &ftypes.Application{Type: t} } for _, app := range detail.Applications { diff --git a/pkg/fanal/types/const.go b/pkg/fanal/types/const.go index 115850f43978..b46b36a8d425 100644 --- a/pkg/fanal/types/const.go +++ b/pkg/fanal/types/const.go @@ -81,6 +81,14 @@ const ( OCP LangType = "ocp" // Red Hat OpenShift Container Platform ) +var AggregatingTypes = []LangType{ + PythonPkg, + CondaPkg, + GemSpec, + NodePkg, + Jar, +} + // Config files const ( JSON ConfigType = "json" diff --git a/pkg/k8s/scanner/scanner.go b/pkg/k8s/scanner/scanner.go index 16ac301c9fa4..55fe4c1e9386 100644 --- a/pkg/k8s/scanner/scanner.go +++ b/pkg/k8s/scanner/scanner.go @@ -375,7 +375,9 @@ func (s *Scanner) clusterInfoToReportResources(allArtifact []*artifacts.Artifact return nil, fmt.Errorf("failed to find node name") } - kbom := core.NewBOM() + kbom := core.NewBOM(core.Options{ + GenerateBOMRef: true, + }) for _, artifact := range allArtifact { switch artifact.Kind { case controlPlaneComponents: @@ -413,7 +415,7 @@ func (s *Scanner) clusterInfoToReportResources(allArtifact []*artifacts.Artifact } imageComponent := &core.Component{ - Type: core.TypeContainer, + Type: core.TypeContainerImage, Name: name, Version: cDigest, PkgID: core.PkgID{ diff --git a/pkg/k8s/scanner/scanner_test.go b/pkg/k8s/scanner/scanner_test.go index 8c9850c12b76..9269f78cf11b 100644 --- a/pkg/k8s/scanner/scanner_test.go +++ b/pkg/k8s/scanner/scanner_test.go @@ -155,7 +155,7 @@ func TestScanner_Scan(t *testing.T) { }, }, { - Type: core.TypeContainer, + Type: core.TypeContainerImage, Name: "k8s.gcr.io/kube-apiserver", Version: "sha256:18e61c783b41758dd391ab901366ec3546b26fae00eef7e223d1f94da808e02f", PkgID: core.PkgID{ diff --git a/pkg/report/spdx/spdx.go b/pkg/report/spdx/spdx.go index 7984db0e1517..a8550ddca0f3 100644 --- a/pkg/report/spdx/spdx.go +++ b/pkg/report/spdx/spdx.go @@ -30,7 +30,7 @@ func NewWriter(output io.Writer, version string, spdxFormat types.Format) Writer } func (w Writer) Write(ctx context.Context, report types.Report) error { - spdxDoc, err := w.marshaler.Marshal(ctx, report) + spdxDoc, err := w.marshaler.MarshalReport(ctx, report) if err != nil { return xerrors.Errorf("failed to marshal spdx: %w", err) } diff --git a/pkg/sbom/core/bom.go b/pkg/sbom/core/bom.go index 5f55f673306b..54755a81e6c8 100644 --- a/pkg/sbom/core/bom.go +++ b/pkg/sbom/core/bom.go @@ -11,11 +11,14 @@ import ( ) const ( - TypeApplication ComponentType = "application" - TypeContainer ComponentType = "container" - TypeLibrary ComponentType = "library" - TypeOS ComponentType = "os" - TypePlatform ComponentType = "platform" + TypeFilesystem ComponentType = "filesystem" + TypeRepository ComponentType = "repository" + TypeContainerImage ComponentType = "container_image" + TypeVM ComponentType = "vm" + TypeApplication ComponentType = "application" + TypeLibrary ComponentType = "library" + TypeOS ComponentType = "os" + TypePlatform ComponentType = "platform" // Metadata properties PropertySchemaVersion = "SchemaVersion" @@ -59,7 +62,7 @@ type BOM struct { components map[uuid.UUID]*Component relationships map[uuid.UUID][]Relationship - // Vulnerabilities is a list of vulnerabilities that affect the component + // Vulnerabilities is a list of vulnerabilities that affect the component. // CycloneDX: vulnerabilities // SPDX: N/A vulnerabilities map[uuid.UUID][]Vulnerability @@ -67,6 +70,9 @@ type BOM struct { // purls is a map of package URLs to UUIDs // This is used to ensure that each package URL is only represented once in the BOM. purls map[string][]uuid.UUID + + // opts is a set of options for the BOM. + opts Options } type Component struct { @@ -98,6 +104,21 @@ type Component struct { // SPDX: package.versionInfo Version string + // SrcName is the name of the source component + // CycloneDX: N/A + // SPDX: package.sourceInfo + SrcName string + + // SrcVersion is the version of the source component + // CycloneDX: N/A + // SPDX: package.sourceInfo + SrcVersion string + + // SrcFile is the file path where the component is found. + // CycloneDX: N/A + // SPDX: package.sourceInfo + SrcFile string + // Licenses is a list of licenses that apply to the component // CycloneDX: component.licenses // SPDX: package.licenseConcluded, package.licenseDeclared @@ -139,9 +160,10 @@ type File struct { Path string // Hash is a hash that uniquely identify the component. + // A file can have several digests with different algorithms, like SHA1, SHA256, etc. // CycloneDX: component.hashes - // SPDX: package.files[].checksum - Hash digest.Digest + // SPDX: package.files[].checksums + Digests []digest.Digest } type Property struct { @@ -182,12 +204,17 @@ type Vulnerability struct { DataSource *dtypes.DataSource } -func NewBOM() *BOM { +type Options struct { + GenerateBOMRef bool +} + +func NewBOM(opts Options) *BOM { return &BOM{ components: make(map[uuid.UUID]*Component), relationships: make(map[uuid.UUID][]Relationship), vulnerabilities: make(map[uuid.UUID][]Vulnerability), purls: make(map[string][]uuid.UUID), + opts: opts, } } @@ -245,14 +272,18 @@ func (b *BOM) Root() *Component { if !ok { return nil } - root.PkgID.BOMRef = b.bomRef(root) + if b.opts.GenerateBOMRef { + root.PkgID.BOMRef = b.bomRef(root) + } return root } func (b *BOM) Components() map[uuid.UUID]*Component { // Fill in BOMRefs for components - for id, c := range b.components { - b.components[id].PkgID.BOMRef = b.bomRef(c) + if b.opts.GenerateBOMRef { + for id, c := range b.components { + b.components[id].PkgID.BOMRef = b.bomRef(c) + } } return b.components } diff --git a/pkg/sbom/cyclonedx/marshal.go b/pkg/sbom/cyclonedx/marshal.go index be9fc23372b7..684b1b7d235d 100644 --- a/pkg/sbom/cyclonedx/marshal.go +++ b/pkg/sbom/cyclonedx/marshal.go @@ -48,7 +48,8 @@ func NewMarshaler(version string) Marshaler { // MarshalReport converts the Trivy report to the CycloneDX format func (m *Marshaler) MarshalReport(ctx context.Context, report types.Report) (*cdx.BOM, error) { // Convert into an intermediate representation - bom, err := sbomio.NewEncoder().Encode(report) + opts := core.Options{GenerateBOMRef: true} + bom, err := sbomio.NewEncoder(opts).Encode(report) if err != nil { return nil, xerrors.Errorf("failed to marshal report: %w", err) } @@ -218,9 +219,9 @@ func (m *Marshaler) marshalVulnerabilities() *[]cdx.Vulnerability { // componentType converts the Trivy component type to the CycloneDX component type func (*Marshaler) componentType(t core.ComponentType) (cdx.ComponentType, error) { switch t { - case core.TypeContainer: + case core.TypeContainerImage, core.TypeVM: return cdx.ComponentTypeContainer, nil - case core.TypeApplication: + case core.TypeApplication, core.TypeFilesystem, core.TypeRepository: return cdx.ComponentTypeApplication, nil case core.TypeLibrary: return cdx.ComponentTypeLibrary, nil @@ -249,17 +250,17 @@ func (*Marshaler) Supplier(supplier string) *cdx.OrganizationalEntity { } func (*Marshaler) Hashes(files []core.File) *[]cdx.Hash { - hashes := lo.FilterMap(files, func(f core.File, index int) (digest.Digest, bool) { - return f.Hash, f.Hash != "" + digests := lo.FlatMap(files, func(file core.File, _ int) []digest.Digest { + return file.Digests }) - if len(hashes) == 0 { + if len(digests) == 0 { return nil } var cdxHashes []cdx.Hash - for _, h := range hashes { + for _, d := range digests { var alg cdx.HashAlgorithm - switch h.Algorithm() { + switch d.Algorithm() { case digest.SHA1: alg = cdx.HashAlgoSHA1 case digest.SHA256: @@ -267,13 +268,13 @@ func (*Marshaler) Hashes(files []core.File) *[]cdx.Hash { case digest.MD5: alg = cdx.HashAlgoMD5 default: - log.Logger.Debugf("Unable to convert %q algorithm to CycloneDX format", h.Algorithm()) + log.Logger.Debugf("Unable to convert %q algorithm to CycloneDX format", d.Algorithm()) continue } cdxHashes = append(cdxHashes, cdx.Hash{ Algorithm: alg, - Value: h.Encoded(), + Value: d.Encoded(), }) } return &cdxHashes diff --git a/pkg/sbom/cyclonedx/marshal_test.go b/pkg/sbom/cyclonedx/marshal_test.go index 7999ea2eae70..de723236a66a 100644 --- a/pkg/sbom/cyclonedx/marshal_test.go +++ b/pkg/sbom/cyclonedx/marshal_test.go @@ -24,7 +24,7 @@ import ( ) func TestMarshaler_MarshalReport(t *testing.T) { - testSBOM := core.NewBOM() + testSBOM := core.NewBOM(core.Options{GenerateBOMRef: true}) testSBOM.AddComponent(&core.Component{ Root: true, Type: core.TypeApplication, @@ -1022,7 +1022,7 @@ func TestMarshaler_MarshalReport(t *testing.T) { BOMRef: "pkg:rpm/centos/acl@2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011&epoch=1", Type: cdx.ComponentTypeLibrary, Name: "acl", - Version: "2.2.53-1.el8", + Version: "1:2.2.53-1.el8", Licenses: &cdx.Licenses{ cdx.LicenseChoice{ License: &cdx.License{ diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index b234b85fd637..8821fe8b111a 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -37,7 +37,7 @@ func DecodeJSON(r io.Reader) (*cdx.BOM, error) { func (b *BOM) UnmarshalJSON(data []byte) error { log.Logger.Debug("Unmarshalling CycloneDX JSON...") if b.BOM == nil { - b.BOM = core.NewBOM() + b.BOM = core.NewBOM(core.Options{GenerateBOMRef: true}) } cdxBOM, err := DecodeJSON(bytes.NewReader(data)) @@ -143,9 +143,11 @@ func (b *BOM) parseComponent(c cdx.Component) (*core.Component, error) { Group: c.Group, Version: c.Version, Licenses: b.unmarshalLicenses(c.Licenses), - Files: lo.Map(b.unmarshalHashes(c.Hashes), func(d digest.Digest, _ int) core.File { - return core.File{Hash: d} // CycloneDX doesn't have a file path for the hash - }), + Files: []core.File{ + { + Digests: b.unmarshalHashes(c.Hashes), + }, + }, PkgID: core.PkgID{ PURL: &purl, BOMRef: c.BOMRef, @@ -161,7 +163,7 @@ func (b *BOM) unmarshalType(t cdx.ComponentType) (core.ComponentType, error) { var ctype core.ComponentType switch t { case cdx.ComponentTypeContainer: - ctype = core.TypeContainer + ctype = core.TypeContainerImage case cdx.ComponentTypeApplication: ctype = core.TypeApplication case cdx.ComponentTypeLibrary: diff --git a/pkg/sbom/io/decode.go b/pkg/sbom/io/decode.go index f0385ddedc26..af61f41b5a8c 100644 --- a/pkg/sbom/io/decode.go +++ b/pkg/sbom/io/decode.go @@ -2,14 +2,18 @@ package io import ( "errors" + "slices" "sort" "strconv" + debver "github.com/knqyf263/go-deb-version" + rpmver "github.com/knqyf263/go-rpm-version" "github.com/package-url/packageurl-go" "go.uber.org/zap" "golang.org/x/exp/maps" "golang.org/x/xerrors" + "github.com/aquasecurity/trivy/pkg/dependency" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/log" "github.com/aquasecurity/trivy/pkg/purl" @@ -125,7 +129,7 @@ func (m *Decoder) decodeComponents(sbom *types.SBOM) error { // Third-party SBOMs may contain packages in types other than "Library" if c.Type == core.TypeLibrary || c.PkgID.PURL != nil { pkg, err := m.decodeLibrary(c) - if errors.Is(err, ErrUnsupportedType) { + if errors.Is(err, ErrUnsupportedType) || errors.Is(err, ErrPURLEmpty) { continue } else if err != nil { return xerrors.Errorf("failed to decode library: %w", err) @@ -156,15 +160,19 @@ func (m *Decoder) buildDependencyGraph() { } func (m *Decoder) decodeApplication(c *core.Component) *ftypes.Application { - app := &ftypes.Application{ - FilePath: c.Name, - } + var app ftypes.Application for _, prop := range c.Properties { if prop.Name == core.PropertyType { app.Type = ftypes.LangType(prop.Value) } } - return app + + // Aggregation Types use the name of the language (e.g. `Java`, `Python`, etc.) as the component name. + // Other language files use the file path as their name. + if !slices.Contains(ftypes.AggregatingTypes, app.Type) { + app.FilePath = c.Name + } + return &app } func (m *Decoder) decodeLibrary(c *core.Component) (*ftypes.Package, error) { @@ -182,6 +190,7 @@ func (m *Decoder) decodeLibrary(c *core.Component) (*ftypes.Package, error) { return nil, ErrUnsupportedType } pkg.Name = m.pkgName(pkg, c) + pkg.ID = dependency.ID(p.LangType(), pkg.Name, p.Version) // Re-generate ID with the updated name var err error for _, prop := range c.Properties { @@ -211,12 +220,19 @@ func (m *Decoder) decodeLibrary(c *core.Component) (*ftypes.Package, error) { pkg.Identifier.BOMRef = c.PkgID.BOMRef pkg.Licenses = c.Licenses - if len(c.Files) > 0 { - pkg.Digest = c.Files[0].Hash + + for _, f := range c.Files { + if f.Path != "" && pkg.FilePath == "" { + pkg.FilePath = f.Path + } + // An empty path represents a package digest + if f.Path == "" && len(f.Digests) > 0 { + pkg.Digest = f.Digests[0] + } } if p.Class() == types.ClassOSPkg { - m.fillSrcPkg(pkg) + m.fillSrcPkg(c, pkg) } return pkg, nil @@ -241,7 +257,12 @@ func (m *Decoder) pkgName(pkg *ftypes.Package, c *core.Component) string { return c.Name } -func (m *Decoder) fillSrcPkg(pkg *ftypes.Package) { +func (m *Decoder) fillSrcPkg(c *core.Component, pkg *ftypes.Package) { + if c.SrcName != "" && pkg.SrcName == "" { + pkg.SrcName = c.SrcName + } + m.parseSrcVersion(pkg, c.SrcVersion) + // Fill source package information for components in third-party SBOMs . if pkg.SrcName == "" { pkg.SrcName = pkg.Name @@ -257,6 +278,29 @@ func (m *Decoder) fillSrcPkg(pkg *ftypes.Package) { } } +// parseSrcVersion parses the version of the source package. +func (m *Decoder) parseSrcVersion(pkg *ftypes.Package, ver string) { + if ver == "" { + return + } + switch pkg.Identifier.PURL.Type { + case packageurl.TypeRPM: + v := rpmver.NewVersion(ver) + pkg.SrcEpoch = v.Epoch() + pkg.SrcVersion = v.Version() + pkg.SrcRelease = v.Release() + case packageurl.TypeDebian: + v, err := debver.NewVersion(ver) + if err != nil { + log.Logger.Debugw("Failed to parse Debian version", zap.Error(err)) + return + } + pkg.SrcEpoch = v.Epoch() + pkg.SrcVersion = v.Version() + pkg.SrcRelease = v.Revision() + } +} + // addOSPkgs traverses relationships and adds OS packages func (m *Decoder) addOSPkgs(sbom *types.SBOM) { var pkgs []ftypes.Package diff --git a/pkg/sbom/io/encode.go b/pkg/sbom/io/encode.go index 73c0d4fef3dc..5bb181992975 100644 --- a/pkg/sbom/io/encode.go +++ b/pkg/sbom/io/encode.go @@ -2,48 +2,52 @@ package io import ( "fmt" + "slices" "strconv" "github.com/package-url/packageurl-go" "github.com/samber/lo" "golang.org/x/xerrors" + "github.com/aquasecurity/trivy/pkg/digest" ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/purl" "github.com/aquasecurity/trivy/pkg/sbom/core" + "github.com/aquasecurity/trivy/pkg/scanner/utils" "github.com/aquasecurity/trivy/pkg/types" ) type Encoder struct { - bom *core.BOM + bom *core.BOM + opts core.Options } -func NewEncoder() *Encoder { - return &Encoder{} +func NewEncoder(opts core.Options) *Encoder { + return &Encoder{opts: opts} } -func (m *Encoder) Encode(report types.Report) (*core.BOM, error) { +func (e *Encoder) Encode(report types.Report) (*core.BOM, error) { // Metadata component - root, err := m.rootComponent(report) + root, err := e.rootComponent(report) if err != nil { return nil, xerrors.Errorf("failed to create root component: %w", err) } - m.bom = core.NewBOM() - m.bom.AddComponent(root) + e.bom = core.NewBOM(e.opts) + e.bom.AddComponent(root) for _, result := range report.Results { - m.encodeResult(root, report.Metadata, result) + e.encodeResult(root, report.Metadata, result) } // Components that do not have their own dependencies MUST be declared as empty elements within the graph. - if _, ok := m.bom.Relationships()[root.ID()]; !ok { - m.bom.AddRelationship(root, nil, "") + if _, ok := e.bom.Relationships()[root.ID()]; !ok { + e.bom.AddRelationship(root, nil, "") } - return m.bom, nil + return e.bom, nil } -func (m *Encoder) rootComponent(r types.Report) (*core.Component, error) { +func (e *Encoder) rootComponent(r types.Report) (*core.Component, error) { root := &core.Component{ Root: true, Name: r.ArtifactName, @@ -58,7 +62,7 @@ func (m *Encoder) rootComponent(r types.Report) (*core.Component, error) { switch r.ArtifactType { case ftypes.ArtifactContainerImage: - root.Type = core.TypeContainer + root.Type = core.TypeContainerImage props = append(props, core.Property{ Name: core.PropertyImageID, Value: r.Metadata.ImageID, @@ -73,9 +77,11 @@ func (m *Encoder) rootComponent(r types.Report) (*core.Component, error) { } case ftypes.ArtifactVM: - root.Type = core.TypeContainer - case ftypes.ArtifactFilesystem, ftypes.ArtifactRepository: - root.Type = core.TypeApplication + root.Type = core.TypeVM + case ftypes.ArtifactFilesystem: + root.Type = core.TypeFilesystem + case ftypes.ArtifactRepository: + root.Type = core.TypeRepository case ftypes.ArtifactCycloneDX: return r.BOM.Root(), nil } @@ -113,9 +119,8 @@ func (m *Encoder) rootComponent(r types.Report) (*core.Component, error) { return root, nil } -func (m *Encoder) encodeResult(root *core.Component, metadata types.Metadata, result types.Result) { - if result.Type == ftypes.NodePkg || result.Type == ftypes.PythonPkg || - result.Type == ftypes.GemSpec || result.Type == ftypes.Jar || result.Type == ftypes.CondaPkg { +func (e *Encoder) encodeResult(root *core.Component, metadata types.Metadata, result types.Result) { + if slices.Contains(ftypes.AggregatingTypes, result.Type) { // If a package is language-specific package that isn't associated with a lock file, // it will be a dependency of a component under "metadata". // e.g. @@ -126,7 +131,7 @@ func (m *Encoder) encodeResult(root *core.Component, metadata types.Metadata, re // ref. https://cyclonedx.org/use-cases/#inventory // Dependency graph from #1 to #2 - m.encodePackages(root, result) + e.encodePackages(root, result) } else if result.Class == types.ClassOSPkg || result.Class == types.ClassLangPkg { // If a package is OS package, it will be a dependency of "Operating System" component. // e.g. @@ -146,21 +151,21 @@ func (m *Encoder) encodeResult(root *core.Component, metadata types.Metadata, re // -> etc. // #2 - appComponent := m.resultComponent(root, result, metadata.OS) + appComponent := e.resultComponent(root, result, metadata.OS) // #3 - m.encodePackages(appComponent, result) + e.encodePackages(appComponent, result) } } -func (m *Encoder) encodePackages(parent *core.Component, result types.Result) { +func (e *Encoder) encodePackages(parent *core.Component, result types.Result) { // Get dependency parents first parents := ftypes.Packages(result.Packages).ParentDeps() // Group vulnerabilities by package ID vulns := make(map[string][]core.Vulnerability) for _, vuln := range result.Vulnerabilities { - v := m.vulnerability(vuln) + v := e.vulnerability(vuln) vulns[v.PkgID] = append(vulns[v.PkgID], v) } @@ -171,15 +176,15 @@ func (m *Encoder) encodePackages(parent *core.Component, result types.Result) { result.Packages[i].ID = pkgID // Convert packages to components - c := m.component(result.Type, pkg) - components[pkgID] = c + c := e.component(result, pkg) + components[pkgID+pkg.FilePath] = c // Add a component - m.bom.AddComponent(c) + e.bom.AddComponent(c) // Add vulnerabilities if vv := vulns[pkgID]; vv != nil { - m.bom.AddVulnerabilities(c, vv) + e.bom.AddVulnerabilities(c, vv) } } @@ -190,26 +195,26 @@ func (m *Encoder) encodePackages(parent *core.Component, result types.Result) { continue } - directPkg := components[pkg.ID] - m.bom.AddRelationship(parent, directPkg, core.RelationshipContains) + directPkg := components[pkg.ID+pkg.FilePath] + e.bom.AddRelationship(parent, directPkg, core.RelationshipContains) for _, dep := range pkg.DependsOn { indirectPkg, ok := components[dep] if !ok { continue } - m.bom.AddRelationship(directPkg, indirectPkg, core.RelationshipDependsOn) + e.bom.AddRelationship(directPkg, indirectPkg, core.RelationshipDependsOn) } // Components that do not have their own dependencies MUST be declared as empty elements within the graph. // TODO: Should check if the component has actually no dependencies or the dependency graph is not supported. if len(pkg.DependsOn) == 0 { - m.bom.AddRelationship(directPkg, nil, "") + e.bom.AddRelationship(directPkg, nil, "") } } } -func (m *Encoder) resultComponent(root *core.Component, r types.Result, osFound *ftypes.OS) *core.Component { +func (e *Encoder) resultComponent(root *core.Component, r types.Result, osFound *ftypes.OS) *core.Component { component := &core.Component{ Name: r.Target, Properties: []core.Property{ @@ -235,18 +240,24 @@ func (m *Encoder) resultComponent(root *core.Component, r types.Result, osFound component.Type = core.TypeApplication } - m.bom.AddRelationship(root, component, core.RelationshipContains) + e.bom.AddRelationship(root, component, core.RelationshipContains) return component } -func (*Encoder) component(pkgType ftypes.TargetType, pkg ftypes.Package) *core.Component { +func (*Encoder) component(result types.Result, pkg ftypes.Package) *core.Component { name := pkg.Name - version := pkg.Version + version := utils.FormatVersion(pkg) var group string // there are cases when we can't build purl // e.g. local Go packages if pu := pkg.Identifier.PURL; pu != nil { version = pu.Version + for _, q := range pu.Qualifiers { + if q.Key == "epoch" && q.Value != "0" { + version = fmt.Sprintf("%s:%s", q.Value, version) + } + } + // Use `group` field for GroupID and `name` for ArtifactID for java files // https://github.com/aquasecurity/trivy/issues/4675 // Use `group` field for npm scopes @@ -264,7 +275,7 @@ func (*Encoder) component(pkgType ftypes.TargetType, pkg ftypes.Package) *core.C }, { Name: core.PropertyPkgType, - Value: string(pkgType), + Value: string(result.Type), }, { Name: core.PropertyFilePath, @@ -303,16 +314,25 @@ func (*Encoder) component(pkgType ftypes.TargetType, pkg ftypes.Package) *core.C var files []core.File if pkg.FilePath != "" || pkg.Digest != "" { files = append(files, core.File{ - Path: pkg.FilePath, - Hash: pkg.Digest, + Path: pkg.FilePath, + Digests: lo.Ternary(pkg.Digest != "", []digest.Digest{pkg.Digest}, nil), }) } + // TODO(refactor): simplify the list of conditions + var srcFile string + if result.Class == types.ClassLangPkg && !slices.Contains(ftypes.AggregatingTypes, result.Type) { + srcFile = result.Target + } + return &core.Component{ - Type: core.TypeLibrary, - Name: name, - Group: group, - Version: version, + Type: core.TypeLibrary, + Name: name, + Group: group, + Version: version, + SrcName: pkg.SrcName, + SrcVersion: utils.FormatSrcVersion(pkg), + SrcFile: srcFile, PkgID: core.PkgID{ PURL: pkg.Identifier.PURL, }, diff --git a/pkg/sbom/io/encode_test.go b/pkg/sbom/io/encode_test.go index 5c2af5b54d9f..a57bddd9983d 100644 --- a/pkg/sbom/io/encode_test.go +++ b/pkg/sbom/io/encode_test.go @@ -113,7 +113,7 @@ func TestEncoder_Encode(t *testing.T) { }, wantComponents: map[uuid.UUID]*core.Component{ uuid.MustParse("3ff14136-e09f-4df9-80ea-000000000001"): { - Type: core.TypeContainer, + Type: core.TypeContainerImage, Name: "debian:12", Root: true, PkgID: core.PkgID{ @@ -320,7 +320,8 @@ func TestEncoder_Encode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { uuid.SetFakeUUID(t, "3ff14136-e09f-4df9-80ea-%012d") - got, err := sbomio.NewEncoder().Encode(tt.report) + opts := core.Options{GenerateBOMRef: true} + got, err := sbomio.NewEncoder(opts).Encode(tt.report) if tt.wantErr != "" { require.ErrorContains(t, err, tt.wantErr) return diff --git a/pkg/sbom/sbom.go b/pkg/sbom/sbom.go index 2d8d74b267a0..5b1055ed7174 100644 --- a/pkg/sbom/sbom.go +++ b/pkg/sbom/sbom.go @@ -183,8 +183,7 @@ func decodeAttestCycloneDXJSONFormat(r io.ReadSeeker) (Format, bool) { func Decode(f io.Reader, format Format) (types.SBOM, error) { var ( v interface{} - bom = core.NewBOM() - sbom types.SBOM + bom = core.NewBOM(core.Options{}) decoder interface{ Decode(any) error } ) @@ -212,10 +211,10 @@ func Decode(f io.Reader, format Format) (types.SBOM, error) { } decoder = json.NewDecoder(f) case FormatSPDXJSON: - v = &spdx.SPDX{SBOM: &sbom} + v = &spdx.SPDX{BOM: bom} decoder = json.NewDecoder(f) case FormatSPDXTV: - v = &spdx.SPDX{SBOM: &sbom} + v = &spdx.SPDX{BOM: bom} decoder = spdx.NewTVDecoder(f) default: return types.SBOM{}, xerrors.Errorf("%s scanning is not yet supported", format) @@ -227,11 +226,7 @@ func Decode(f io.Reader, format Format) (types.SBOM, error) { return types.SBOM{}, xerrors.Errorf("failed to decode: %w", err) } - // TODO: use BOM in SPDX - if format == FormatSPDXJSON || format == FormatSPDXTV { - return sbom, nil - } - + var sbom types.SBOM if err := sbomio.NewDecoder(bom).Decode(&sbom); err != nil { return types.SBOM{}, xerrors.Errorf("failed to decode: %w", err) } diff --git a/pkg/sbom/spdx/marshal.go b/pkg/sbom/spdx/marshal.go index ceb9a1ae24ce..6c1490fe1aec 100644 --- a/pkg/sbom/spdx/marshal.go +++ b/pkg/sbom/spdx/marshal.go @@ -4,26 +4,25 @@ import ( "context" "fmt" "sort" - "strconv" "strings" "time" "github.com/mitchellh/hashstructure/v2" + "github.com/package-url/packageurl-go" "github.com/samber/lo" "github.com/spdx/tools-golang/spdx" "github.com/spdx/tools-golang/spdx/v2/common" spdxutils "github.com/spdx/tools-golang/utils" - "golang.org/x/exp/maps" + "golang.org/x/exp/slices" "golang.org/x/xerrors" "github.com/aquasecurity/trivy/pkg/clock" "github.com/aquasecurity/trivy/pkg/digest" - ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" "github.com/aquasecurity/trivy/pkg/licensing" "github.com/aquasecurity/trivy/pkg/licensing/expression" "github.com/aquasecurity/trivy/pkg/log" - "github.com/aquasecurity/trivy/pkg/purl" - "github.com/aquasecurity/trivy/pkg/scanner/utils" + "github.com/aquasecurity/trivy/pkg/sbom/core" + sbomio "github.com/aquasecurity/trivy/pkg/sbom/io" "github.com/aquasecurity/trivy/pkg/types" "github.com/aquasecurity/trivy/pkg/uuid" ) @@ -40,19 +39,6 @@ const ( CategoryPackageManager = "PACKAGE-MANAGER" RefTypePurl = "purl" - PropertySchemaVersion = "SchemaVersion" - - // Image properties - PropertySize = "Size" - PropertyImageID = "ImageID" - PropertyRepoDigest = "RepoDigest" - PropertyDiffID = "DiffID" - PropertyRepoTag = "RepoTag" - - // Package properties - PropertyPkgID = "PkgID" - PropertyLayerDiffID = "LayerDiffID" - PropertyLayerDigest = "LayerDigest" // Package Purpose fields PackagePurposeOS = "OPERATING-SYSTEM" PackagePurposeContainer = "CONTAINER" @@ -75,8 +61,20 @@ const ( var ( SourcePackagePrefix = "built package from" + SourceFilePrefix = "package found in" ) +// duplicateProperties contains a list of properties contained in other fields. +var duplicateProperties = []string{ + // `SourceInfo` contains SrcName and SrcVersion (it contains PropertySrcRelease and PropertySrcEpoch) + core.PropertySrcName, + core.PropertySrcRelease, + core.PropertySrcEpoch, + core.PropertySrcVersion, + // `File` contains filePath. + core.PropertyFilePath, +} + type Marshaler struct { format spdx.Document hasher Hash @@ -107,75 +105,95 @@ func NewMarshaler(version string, opts ...marshalOption) *Marshaler { return m } -func (m *Marshaler) Marshal(ctx context.Context, r types.Report) (*spdx.Document, error) { - var relationShips []*spdx.Relationship - packages := make(map[spdx.ElementID]*spdx.Package) - pkgDownloadLocation := getPackageDownloadLocation(r.ArtifactType, r.ArtifactName) +func (m *Marshaler) MarshalReport(ctx context.Context, report types.Report) (*spdx.Document, error) { + // Convert into an intermediate representation + bom, err := sbomio.NewEncoder(core.Options{}).Encode(report) + if err != nil { + return nil, xerrors.Errorf("failed to marshal report: %w", err) + } + + return m.Marshal(ctx, bom) +} + +func (m *Marshaler) Marshal(ctx context.Context, bom *core.BOM) (*spdx.Document, error) { + var ( + relationShips []*spdx.Relationship + packages []*spdx.Package + ) + + root := bom.Root() + pkgDownloadLocation := m.packageDownloadLocation(root) + + // Component ID => SPDX ID + packageIDs := make(map[uuid.UUID]spdx.ElementID) // Root package contains OS, OS packages, language-specific packages and so on. - rootPkg, err := m.rootPackage(r, pkgDownloadLocation) + rootPkg, err := m.rootSPDXPackage(root, pkgDownloadLocation) if err != nil { return nil, xerrors.Errorf("failed to generate a root package: %w", err) } - packages[rootPkg.PackageSPDXIdentifier] = rootPkg + packages = append(packages, rootPkg) relationShips = append(relationShips, - relationShip(DocumentSPDXIdentifier, rootPkg.PackageSPDXIdentifier, RelationShipDescribe), + m.spdxRelationShip(DocumentSPDXIdentifier, rootPkg.PackageSPDXIdentifier, RelationShipDescribe), ) + packageIDs[root.ID()] = rootPkg.PackageSPDXIdentifier - var spdxFiles []*spdx.File - - for _, result := range r.Results { - if len(result.Packages) == 0 { + var files []*spdx.File + for _, c := range bom.Components() { + if c.Root { continue } - parentPackage, err := m.resultToSpdxPackage(result, r.Metadata.OS, pkgDownloadLocation) + spdxPackage, err := m.spdxPackage(c, pkgDownloadLocation) if err != nil { - return nil, xerrors.Errorf("failed to parse result: %w", err) + return nil, xerrors.Errorf("spdx package error: %w", err) } - packages[parentPackage.PackageSPDXIdentifier] = &parentPackage - relationShips = append(relationShips, - relationShip(rootPkg.PackageSPDXIdentifier, parentPackage.PackageSPDXIdentifier, RelationShipContains), - ) - - for _, pkg := range result.Packages { - spdxPackage, err := m.pkgToSpdxPackage(result.Type, pkgDownloadLocation, result.Class, r.Metadata, pkg) - if err != nil { - return nil, xerrors.Errorf("failed to parse package: %w", err) - } - packages[spdxPackage.PackageSPDXIdentifier] = &spdxPackage + packages = append(packages, &spdxPackage) + packageIDs[c.ID()] = spdxPackage.PackageSPDXIdentifier + + spdxFiles, err := m.spdxFiles(c) + if err != nil { + return nil, xerrors.Errorf("spdx files error: %w", err) + } else if len(spdxFiles) == 0 { + continue + } + + files = append(files, spdxFiles...) + for _, file := range spdxFiles { relationShips = append(relationShips, - relationShip(parentPackage.PackageSPDXIdentifier, spdxPackage.PackageSPDXIdentifier, RelationShipContains), + m.spdxRelationShip(spdxPackage.PackageSPDXIdentifier, file.FileSPDXIdentifier, RelationShipContains), ) - files, err := m.pkgFiles(pkg) - if err != nil { - return nil, xerrors.Errorf("package file error: %w", err) - } else if files == nil { - continue - } + } + verificationCode, err := spdxutils.GetVerificationCode(spdxFiles, "") + if err != nil { + return nil, xerrors.Errorf("package verification error: %w", err) + } + spdxPackage.FilesAnalyzed = true + spdxPackage.PackageVerificationCode = &verificationCode + } - spdxFiles = append(spdxFiles, files...) - for _, file := range files { - relationShips = append(relationShips, - relationShip(spdxPackage.PackageSPDXIdentifier, file.FileSPDXIdentifier, RelationShipContains), - ) + for id, rels := range bom.Relationships() { + for _, rel := range rels { + refA, ok := packageIDs[id] + if !ok { + continue } - - verificationCode, err := spdxutils.GetVerificationCode(files, "") - if err != nil { - return nil, xerrors.Errorf("package verification error: %w", err) + refB, ok := packageIDs[rel.Dependency] + if !ok { + continue } - - spdxPackage.FilesAnalyzed = true - spdxPackage.PackageVerificationCode = &verificationCode + relationShips = append(relationShips, m.spdxRelationShip(refA, refB, m.spdxRelationshipType(rel.Type))) } } + sortPackages(packages) + sortRelationships(relationShips) + sortFiles(files) return &spdx.Document{ SPDXVersion: spdx.Version, DataLicense: spdx.DataLicense, SPDXIdentifier: DocumentSPDXIdentifier, - DocumentName: r.ArtifactName, - DocumentNamespace: getDocumentNamespace(r, m), + DocumentName: root.Name, + DocumentNamespace: getDocumentNamespace(root), CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -189,214 +207,215 @@ func (m *Marshaler) Marshal(ctx context.Context, r types.Report) (*spdx.Document }, Created: clock.Now(ctx).UTC().Format(time.RFC3339), }, - Packages: toPackages(packages), + Packages: packages, Relationships: relationShips, - Files: spdxFiles, + Files: files, }, nil } -func toPackages(packages map[spdx.ElementID]*spdx.Package) []*spdx.Package { - ret := maps.Values(packages) - sort.Slice(ret, func(i, j int) bool { - if ret[i].PackageName != ret[j].PackageName { - return ret[i].PackageName < ret[j].PackageName - } - return ret[i].PackageSPDXIdentifier < ret[j].PackageSPDXIdentifier - }) - return ret -} - -func (m *Marshaler) resultToSpdxPackage(result types.Result, os *ftypes.OS, pkgDownloadLocation string) (spdx.Package, error) { - switch result.Class { - case types.ClassOSPkg: - osPkg, err := m.osPackage(os, pkgDownloadLocation) - if err != nil { - return spdx.Package{}, xerrors.Errorf("failed to parse operating system package: %w", err) - } - return osPkg, nil - case types.ClassLangPkg: - langPkg, err := m.langPackage(result.Target, pkgDownloadLocation, result.Type) - if err != nil { - return spdx.Package{}, xerrors.Errorf("failed to parse application package: %w", err) - } - return langPkg, nil - default: - // unsupported packages - return spdx.Package{}, nil - } -} - -func (m *Marshaler) parseFile(filePath string, d digest.Digest) (spdx.File, error) { - pkgID, err := calcPkgID(m.hasher, filePath) - if err != nil { - return spdx.File{}, xerrors.Errorf("failed to get %s package ID: %w", filePath, err) - } - file := spdx.File{ - FileSPDXIdentifier: spdx.ElementID(fmt.Sprintf("File-%s", pkgID)), - FileName: filePath, - Checksums: digestToSpdxFileChecksum(d), +func (m *Marshaler) packageDownloadLocation(root *core.Component) string { + location := noneField + // this field is used for git/mercurial/subversion/bazaar: + // https://spdx.github.io/spdx-spec/v2.2.2/package-information/#77-package-download-location-field + if root.Type == core.TypeRepository { + // Trivy currently only supports git repositories. Format examples: + // git+https://git.myproject.org/MyProject.git + // git+http://git.myproject.org/MyProject + location = fmt.Sprintf("git+%s", root.Name) } - return file, nil + return location } -func (m *Marshaler) rootPackage(r types.Report, pkgDownloadLocation string) (*spdx.Package, error) { +func (m *Marshaler) rootSPDXPackage(root *core.Component, pkgDownloadLocation string) (*spdx.Package, error) { var externalReferences []*spdx.PackageExternalReference - attributionTexts := []string{attributionText(PropertySchemaVersion, strconv.Itoa(r.SchemaVersion))} - // When the target is a container image, add PURL to the external references of the root package. - if p, err := purl.New(purl.TypeOCI, r.Metadata, ftypes.Package{}); err != nil { - return nil, xerrors.Errorf("failed to new package url for oci: %w", err) - } else if p != nil { - externalReferences = append(externalReferences, purlExternalReference(p.String())) - } - - if r.Metadata.ImageID != "" { - attributionTexts = appendAttributionText(attributionTexts, PropertyImageID, r.Metadata.ImageID) - } - if r.Metadata.Size != 0 { - attributionTexts = appendAttributionText(attributionTexts, PropertySize, strconv.FormatInt(r.Metadata.Size, 10)) + if root.PkgID.PURL != nil { + externalReferences = append(externalReferences, m.purlExternalReference(root.PkgID.PURL.String())) } - for _, d := range r.Metadata.RepoDigests { - attributionTexts = appendAttributionText(attributionTexts, PropertyRepoDigest, d) - } - for _, d := range r.Metadata.DiffIDs { - attributionTexts = appendAttributionText(attributionTexts, PropertyDiffID, d) - } - for _, t := range r.Metadata.RepoTags { - attributionTexts = appendAttributionText(attributionTexts, PropertyRepoTag, t) - } - - pkgID, err := calcPkgID(m.hasher, fmt.Sprintf("%s-%s", r.ArtifactName, r.ArtifactType)) + pkgID, err := calcPkgID(m.hasher, fmt.Sprintf("%s-%s", root.Name, root.Type)) if err != nil { return nil, xerrors.Errorf("failed to get %s package ID: %w", pkgID, err) } pkgPurpose := PackagePurposeSource - if r.ArtifactType == ftypes.ArtifactContainerImage { + if root.Type == core.TypeContainerImage { pkgPurpose = PackagePurposeContainer } return &spdx.Package{ - PackageName: r.ArtifactName, - PackageSPDXIdentifier: elementID(camelCase(string(r.ArtifactType)), pkgID), + PackageName: root.Name, + PackageSPDXIdentifier: elementID(camelCase(string(root.Type)), pkgID), PackageDownloadLocation: pkgDownloadLocation, - PackageAttributionTexts: attributionTexts, + PackageAttributionTexts: m.spdxAttributionTexts(root), PackageExternalReferences: externalReferences, PrimaryPackagePurpose: pkgPurpose, }, nil } -func (m *Marshaler) osPackage(osFound *ftypes.OS, pkgDownloadLocation string) (spdx.Package, error) { - if osFound == nil { - return spdx.Package{}, nil - } - - pkgID, err := calcPkgID(m.hasher, osFound) - if err != nil { - return spdx.Package{}, xerrors.Errorf("failed to get os metadata package ID: %w", err) +func (m *Marshaler) appendAttributionText(attributionTexts []string, key, value string) []string { + if value == "" { + return attributionTexts } - - return spdx.Package{ - PackageName: string(osFound.Family), - PackageVersion: osFound.Name, - PackageSPDXIdentifier: elementID(ElementOperatingSystem, pkgID), - PackageDownloadLocation: pkgDownloadLocation, - PrimaryPackagePurpose: PackagePurposeOS, - }, nil + return append(attributionTexts, fmt.Sprintf("%s: %s", key, value)) } -func (m *Marshaler) langPackage(target, pkgDownloadLocation string, appType ftypes.LangType) (spdx.Package, error) { - pkgID, err := calcPkgID(m.hasher, fmt.Sprintf("%s-%s", target, appType)) - if err != nil { - return spdx.Package{}, xerrors.Errorf("failed to get %s package ID: %w", target, err) +func (m *Marshaler) purlExternalReference(packageURL string) *spdx.PackageExternalReference { + return &spdx.PackageExternalReference{ + Category: CategoryPackageManager, + RefType: RefTypePurl, + Locator: packageURL, } - - return spdx.Package{ - PackageName: string(appType), - PackageSourceInfo: target, // TODO: Files seems better - PackageSPDXIdentifier: elementID(ElementApplication, pkgID), - PackageDownloadLocation: pkgDownloadLocation, - PrimaryPackagePurpose: PackagePurposeApplication, - }, nil } -func (m *Marshaler) pkgToSpdxPackage(t ftypes.TargetType, pkgDownloadLocation string, class types.ResultClass, metadata types.Metadata, pkg ftypes.Package) (spdx.Package, error) { - license := GetLicense(pkg) - - pkgID, err := calcPkgID(m.hasher, pkg) +func (m *Marshaler) spdxPackage(c *core.Component, pkgDownloadLocation string) (spdx.Package, error) { + pkgID, err := calcPkgID(m.hasher, c) if err != nil { - return spdx.Package{}, xerrors.Errorf("failed to get %s package ID: %w", pkg.Name, err) + return spdx.Package{}, xerrors.Errorf("failed to get os metadata package ID: %w", err) } - var pkgSrcInfo string - if class == types.ClassOSPkg && pkg.SrcName != "" { - pkgSrcInfo = fmt.Sprintf("%s: %s %s", SourcePackagePrefix, pkg.SrcName, utils.FormatSrcVersion(pkg)) + var elementType, purpose, license, sourceInfo string + var supplier *spdx.Supplier + switch c.Type { + case core.TypeOS: + elementType = ElementOperatingSystem + purpose = PackagePurposeOS + case core.TypeApplication: + elementType = ElementApplication + purpose = PackagePurposeApplication + case core.TypeLibrary: + elementType = ElementPackage + purpose = PackagePurposeLibrary + license = m.spdxLicense(c) + + if c.SrcName != "" { + sourceInfo = fmt.Sprintf("%s: %s %s", SourcePackagePrefix, c.SrcName, c.SrcVersion) + } else if c.SrcFile != "" { + sourceInfo = fmt.Sprintf("%s: %s", SourceFilePrefix, c.SrcFile) + } + + supplier = &spdx.Supplier{Supplier: PackageSupplierNoAssertion} + if c.Supplier != "" { + supplier = &spdx.Supplier{ + SupplierType: PackageSupplierOrganization, // Always use "Organization" at the moment as it is difficult to distinguish between "Person" or "Organization". + Supplier: c.Supplier, + } + } } var pkgExtRefs []*spdx.PackageExternalReference - if pkg.Identifier.PURL != nil { - pkgExtRefs = []*spdx.PackageExternalReference{purlExternalReference(pkg.Identifier.PURL.String())} + if c.PkgID.PURL != nil { + pkgExtRefs = []*spdx.PackageExternalReference{m.purlExternalReference(c.PkgID.PURL.String())} } - var attrTexts []string - attrTexts = appendAttributionText(attrTexts, PropertyPkgID, pkg.ID) - attrTexts = appendAttributionText(attrTexts, PropertyLayerDigest, pkg.Layer.Digest) - attrTexts = appendAttributionText(attrTexts, PropertyLayerDiffID, pkg.Layer.DiffID) - - supplier := &spdx.Supplier{Supplier: PackageSupplierNoAssertion} - if pkg.Maintainer != "" { - supplier = &spdx.Supplier{ - SupplierType: PackageSupplierOrganization, // Always use "Organization" at the moment as it is difficult to distinguish between "Person" or "Organization". - Supplier: pkg.Maintainer, + var digests []digest.Digest + for _, f := range c.Files { + // The file digests are stored separately. + if f.Path != "" { + continue } - } - - var checksum []spdx.Checksum - if pkg.Digest != "" && class == types.ClassOSPkg { - checksum = digestToSpdxFileChecksum(pkg.Digest) + digests = append(digests, f.Digests...) } return spdx.Package{ - PackageName: pkg.Name, - PackageVersion: utils.FormatVersion(pkg), - PackageSPDXIdentifier: elementID(ElementPackage, pkgID), - PackageDownloadLocation: pkgDownloadLocation, - PackageSourceInfo: pkgSrcInfo, + PackageSPDXIdentifier: elementID(elementType, pkgID), + PackageName: spdxPkgName(c), + PackageVersion: c.Version, + PrimaryPackagePurpose: purpose, + PackageDownloadLocation: pkgDownloadLocation, + PackageExternalReferences: pkgExtRefs, + PackageAttributionTexts: m.spdxAttributionTexts(c), + PackageSourceInfo: sourceInfo, + PackageSupplier: supplier, + PackageChecksums: m.spdxChecksums(digests), // The Declared License is what the authors of a project believe govern the package PackageLicenseConcluded: license, // The Concluded License field is the license the SPDX file creator believes governs the package PackageLicenseDeclared: license, - - PackageExternalReferences: pkgExtRefs, - PackageAttributionTexts: attrTexts, - PrimaryPackagePurpose: PackagePurposeLibrary, - PackageSupplier: supplier, - PackageChecksums: checksum, }, nil } -func (m *Marshaler) pkgFiles(pkg ftypes.Package) ([]*spdx.File, error) { - if pkg.FilePath == "" { - return nil, nil +func spdxPkgName(component *core.Component) string { + if p := component.PkgID.PURL; p != nil && component.Group != "" { + if p.Type == packageurl.TypeMaven || p.Type == packageurl.TypeGradle { + return component.Group + ":" + component.Name + } + return component.Group + "/" + component.Name } + return component.Name +} - file, err := m.parseFile(pkg.FilePath, pkg.Digest) - if err != nil { - return nil, xerrors.Errorf("failed to parse file: %w", err) +func (m *Marshaler) spdxAttributionTexts(c *core.Component) []string { + var texts []string + for _, p := range c.Properties { + // Add properties that are not in other fields. + if !slices.Contains(duplicateProperties, p.Name) { + texts = m.appendAttributionText(texts, p.Name, p.Value) + } } - return []*spdx.File{ - &file, - }, nil + return texts } -func elementID(elementType, pkgID string) spdx.ElementID { - return spdx.ElementID(fmt.Sprintf("%s-%s", elementType, pkgID)) +func (m *Marshaler) spdxLicense(c *core.Component) string { + if len(c.Licenses) == 0 { + return noneField + } + return NormalizeLicense(c.Licenses) +} + +func (m *Marshaler) spdxChecksums(digests []digest.Digest) []common.Checksum { + var checksums []common.Checksum + for _, d := range digests { + var alg spdx.ChecksumAlgorithm + switch d.Algorithm() { + case digest.SHA1: + alg = spdx.SHA1 + case digest.SHA256: + alg = spdx.SHA256 + case digest.MD5: + alg = spdx.MD5 + default: + return nil + } + checksums = append(checksums, spdx.Checksum{ + Algorithm: alg, + Value: d.Encoded(), + }) + } + + return checksums +} + +func (m *Marshaler) spdxFiles(c *core.Component) ([]*spdx.File, error) { + var files []*spdx.File + for _, file := range c.Files { + if file.Path == "" || len(file.Digests) == 0 { + continue + } + spdxFile, err := m.spdxFile(file.Path, file.Digests) + if err != nil { + return nil, xerrors.Errorf("failed to parse file: %w", err) + } + files = append(files, spdxFile) + } + return files, nil +} + +func (m *Marshaler) spdxFile(filePath string, digests []digest.Digest) (*spdx.File, error) { + pkgID, err := calcPkgID(m.hasher, filePath) + if err != nil { + return nil, xerrors.Errorf("failed to get %s package ID: %w", filePath, err) + } + return &spdx.File{ + FileSPDXIdentifier: spdx.ElementID(fmt.Sprintf("File-%s", pkgID)), + FileName: filePath, + Checksums: m.spdxChecksums(digests), + }, nil } -func relationShip(refA, refB spdx.ElementID, operator string) *spdx.Relationship { +func (m *Marshaler) spdxRelationShip(refA, refB spdx.ElementID, operator string) *spdx.Relationship { ref := spdx.Relationship{ RefA: common.MakeDocElementID("", string(refA)), RefB: common.MakeDocElementID("", string(refB)), @@ -405,51 +424,65 @@ func relationShip(refA, refB spdx.ElementID, operator string) *spdx.Relationship return &ref } -func appendAttributionText(attributionTexts []string, key, value string) []string { - if value == "" { - return attributionTexts +func (m *Marshaler) spdxRelationshipType(relType core.RelationshipType) string { + switch relType { + case core.RelationshipDependsOn: + return RelationShipDependsOn + case core.RelationshipContains: + return RelationShipContains + case core.RelationshipDescribes: + return RelationShipDescribe + default: + return RelationShipDependsOn } - return append(attributionTexts, attributionText(key, value)) } -func attributionText(key, value string) string { - return fmt.Sprintf("%s: %s", key, value) +func sortPackages(pkgs []*spdx.Package) { + sort.Slice(pkgs, func(i, j int) bool { + switch { + case pkgs[i].PrimaryPackagePurpose != pkgs[j].PrimaryPackagePurpose: + return pkgs[i].PrimaryPackagePurpose < pkgs[j].PrimaryPackagePurpose + case pkgs[i].PackageName != pkgs[j].PackageName: + return pkgs[i].PackageName < pkgs[j].PackageName + default: + return pkgs[i].PackageSPDXIdentifier < pkgs[j].PackageSPDXIdentifier + } + }) } -func purlExternalReference(packageURL string) *spdx.PackageExternalReference { - return &spdx.PackageExternalReference{ - Category: CategoryPackageManager, - RefType: RefTypePurl, - Locator: packageURL, - } +func sortRelationships(rels []*spdx.Relationship) { + sort.Slice(rels, func(i, j int) bool { + switch { + case rels[i].RefA.ElementRefID != rels[j].RefA.ElementRefID: + return rels[i].RefA.ElementRefID < rels[j].RefA.ElementRefID + case rels[i].RefB.ElementRefID != rels[j].RefB.ElementRefID: + return rels[i].RefB.ElementRefID < rels[j].RefB.ElementRefID + default: + return rels[i].Relationship < rels[j].Relationship + } + }) } -func GetLicense(p ftypes.Package) string { - if len(p.Licenses) == 0 { - return noneField - } - - license := strings.Join(lo.Map(p.Licenses, func(license string, index int) string { - // e.g. GPL-3.0-with-autoconf-exception - license = strings.ReplaceAll(license, "-with-", " WITH ") - license = strings.ReplaceAll(license, "-WITH-", " WITH ") +func sortFiles(files []*spdx.File) { + sort.Slice(files, func(i, j int) bool { + switch { + case files[i].FileName != files[j].FileName: + return files[i].FileName < files[j].FileName + default: + return files[i].FileSPDXIdentifier < files[j].FileSPDXIdentifier + } + }) +} - return fmt.Sprintf("(%s)", license) - }), " AND ") - s, err := expression.Normalize(license, licensing.Normalize, expression.NormalizeForSPDX) - if err != nil { - // Not fail on the invalid license - log.Logger.Warnf("Unable to marshal SPDX licenses %q", license) - return "" - } - return s +func elementID(elementType, pkgID string) spdx.ElementID { + return spdx.ElementID(fmt.Sprintf("%s-%s", elementType, pkgID)) } -func getDocumentNamespace(r types.Report, m *Marshaler) string { +func getDocumentNamespace(root *core.Component) string { return fmt.Sprintf("%s/%s/%s-%s", DocumentNamespace, - string(r.ArtifactType), - strings.ReplaceAll(strings.ReplaceAll(r.ArtifactName, "https://", ""), "http://", ""), // remove http(s):// prefix when scanning repos + string(root.Type), + strings.ReplaceAll(strings.ReplaceAll(root.Name, "https://", ""), "http://", ""), // remove http(s):// prefix when scanning repos uuid.New().String(), ) } @@ -487,40 +520,19 @@ func camelCase(inputUnderScoreStr string) (camelCase string) { return } -func getPackageDownloadLocation(t ftypes.ArtifactType, artifactName string) string { - location := noneField - // this field is used for git/mercurial/subversion/bazaar: - // https://spdx.github.io/spdx-spec/v2.2.2/package-information/#77-package-download-location-field - if t == ftypes.ArtifactRepository { - // Trivy currently only supports git repositories. Format examples: - // git+https://git.myproject.org/MyProject.git - // git+http://git.myproject.org/MyProject - location = fmt.Sprintf("git+%s", artifactName) - } - return location -} - -func digestToSpdxFileChecksum(d digest.Digest) []common.Checksum { - if d == "" { - return nil - } - - var alg spdx.ChecksumAlgorithm - switch d.Algorithm() { - case digest.SHA1: - alg = spdx.SHA1 - case digest.SHA256: - alg = spdx.SHA256 - case digest.MD5: - alg = spdx.MD5 - default: - return nil - } +func NormalizeLicense(licenses []string) string { + license := strings.Join(lo.Map(licenses, func(license string, index int) string { + // e.g. GPL-3.0-with-autoconf-exception + license = strings.ReplaceAll(license, "-with-", " WITH ") + license = strings.ReplaceAll(license, "-WITH-", " WITH ") - return []spdx.Checksum{ - { - Algorithm: alg, - Value: d.Encoded(), - }, + return fmt.Sprintf("(%s)", license) + }), " AND ") + s, err := expression.Normalize(license, licensing.Normalize, expression.NormalizeForSPDX) + if err != nil { + // Not fail on the invalid license + log.Logger.Warnf("Unable to marshal SPDX licenses %q", license) + return "" } + return s } diff --git a/pkg/sbom/spdx/marshal_test.go b/pkg/sbom/spdx/marshal_test.go index a66a1d5ee46c..c7757de8ca81 100644 --- a/pkg/sbom/spdx/marshal_test.go +++ b/pkg/sbom/spdx/marshal_test.go @@ -2,6 +2,7 @@ package spdx_test import ( "context" + "github.com/aquasecurity/trivy/pkg/sbom/core" "github.com/package-url/packageurl-go" "hash/fnv" "testing" @@ -144,7 +145,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "rails:latest", - DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/rails:latest-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/rails:latest-3ff14136-e09f-4df9-80ea-000000000009", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -160,12 +161,56 @@ func TestMarshaler_Marshal(t *testing.T) { }, Packages: []*spdx.Package{ { - PackageSPDXIdentifier: spdx.ElementID("Package-eb0263038c3b445b"), + PackageSPDXIdentifier: spdx.ElementID("Application-9f48cdd13858abaf"), + PackageDownloadLocation: "NONE", + PackageName: "app/Gemfile.lock", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageAttributionTexts: []string{ + "Class: lang-pkgs", + "Type: bundler", + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Application-692290f4b2235359"), + PackageDownloadLocation: "NONE", + PackageName: "app/subproject/Gemfile.lock", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageAttributionTexts: []string{ + "Class: lang-pkgs", + "Type: bundler", + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("ContainerImage-9396d894cd0cb6cb"), + PackageDownloadLocation: "NONE", + PackageName: "rails:latest", + PackageExternalReferences: []*spdx.PackageExternalReference{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:oci/rails@sha256%3Aa27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?arch=arm64&repository_url=index.docker.io%2Flibrary%2Frails", + }, + }, + PackageAttributionTexts: []string{ + "DiffID: sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a", + "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + "RepoDigest: rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177", + "RepoTag: rails:latest", + "SchemaVersion: 2", + "Size: 1024", + }, + PrimaryPackagePurpose: tspdx.PackagePurposeContainer, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-b8d4663e6d412e7"), PackageDownloadLocation: "NONE", PackageName: "actioncontroller", PackageVersion: "7.0.1", PackageLicenseConcluded: "NONE", PackageLicenseDeclared: "NONE", + PackageAttributionTexts: []string{ + "PkgType: bundler", + }, PackageExternalReferences: []*spdx.PackageExternalReference{ { Category: tspdx.CategoryPackageManager, @@ -175,14 +220,39 @@ func TestMarshaler_Marshal(t *testing.T) { }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: app/subproject/Gemfile.lock", + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-3b51e821f6796568"), + PackageDownloadLocation: "NONE", + PackageName: "actionpack", + PackageVersion: "7.0.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageAttributionTexts: []string{ + "PkgType: bundler", + }, + PackageExternalReferences: []*spdx.PackageExternalReference{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actionpack@7.0.1", + }, + }, + PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, + PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: app/subproject/Gemfile.lock", }, { - PackageSPDXIdentifier: spdx.ElementID("Package-826226d056ff30c0"), + PackageSPDXIdentifier: spdx.ElementID("Package-fb5630bc7d55a21c"), PackageDownloadLocation: "NONE", PackageName: "actionpack", PackageVersion: "7.0.1", PackageLicenseConcluded: "NONE", PackageLicenseDeclared: "NONE", + PackageAttributionTexts: []string{ + "PkgType: bundler", + }, PackageExternalReferences: []*spdx.PackageExternalReference{ { Category: tspdx.CategoryPackageManager, @@ -192,14 +262,18 @@ func TestMarshaler_Marshal(t *testing.T) { }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: app/Gemfile.lock", }, { - PackageSPDXIdentifier: spdx.ElementID("Package-fd0dc3cf913d5bc3"), + PackageSPDXIdentifier: spdx.ElementID("Package-5d43902b18ed2e2c"), PackageDownloadLocation: "NONE", PackageName: "binutils", PackageVersion: "2.30-93.el8", PackageLicenseConcluded: "GPL-3.0-or-later", PackageLicenseDeclared: "GPL-3.0-or-later", + PackageAttributionTexts: []string{ + "PkgType: centos", + }, PackageSupplier: &spdx.Supplier{ SupplierType: tspdx.PackageSupplierOrganization, Supplier: "CentOS", @@ -221,87 +295,56 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, { - PackageSPDXIdentifier: spdx.ElementID("Application-73c871d73f3c8248"), - PackageDownloadLocation: "NONE", - PackageName: "bundler", - PackageSourceInfo: "app/subproject/Gemfile.lock", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, - }, - { - PackageSPDXIdentifier: spdx.ElementID("Application-c3fac92c1ac0a9fa"), - PackageDownloadLocation: "NONE", - PackageName: "bundler", - PackageSourceInfo: "app/Gemfile.lock", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, - }, - { - PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"), + PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-20f7fa3049cc748c"), PackageDownloadLocation: "NONE", PackageName: "centos", PackageVersion: "8.3.2011", PrimaryPackagePurpose: tspdx.PackagePurposeOS, - }, - { - PackageSPDXIdentifier: spdx.ElementID("ContainerImage-9396d894cd0cb6cb"), - PackageDownloadLocation: "NONE", - PackageName: "rails:latest", - PackageExternalReferences: []*spdx.PackageExternalReference{ - { - Category: tspdx.CategoryPackageManager, - RefType: tspdx.RefTypePurl, - Locator: "pkg:oci/rails@sha256%3Aa27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?arch=arm64&repository_url=index.docker.io%2Flibrary%2Frails", - }, - }, PackageAttributionTexts: []string{ - "SchemaVersion: 2", - "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", - "Size: 1024", - "RepoDigest: rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177", - "DiffID: sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a", - "RepoTag: rails:latest", + "Class: os-pkgs", + "Type: centos", }, - PrimaryPackagePurpose: tspdx.PackagePurposeContainer, }, }, Relationships: []*spdx.Relationship{ { - RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, - RefB: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, - Relationship: "DESCRIBES", + RefA: spdx.DocElementID{ElementRefID: "Application-692290f4b2235359"}, + RefB: spdx.DocElementID{ElementRefID: "Package-3b51e821f6796568"}, + Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, - RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + RefA: spdx.DocElementID{ElementRefID: "Application-692290f4b2235359"}, + RefB: spdx.DocElementID{ElementRefID: "Package-b8d4663e6d412e7"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, - RefB: spdx.DocElementID{ElementRefID: "Package-fd0dc3cf913d5bc3"}, + RefA: spdx.DocElementID{ElementRefID: "Application-9f48cdd13858abaf"}, + RefB: spdx.DocElementID{ElementRefID: "Package-fb5630bc7d55a21c"}, Relationship: "CONTAINS", }, { RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, - RefB: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, + RefB: spdx.DocElementID{ElementRefID: "Application-692290f4b2235359"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, - RefB: spdx.DocElementID{ElementRefID: "Package-826226d056ff30c0"}, + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + RefB: spdx.DocElementID{ElementRefID: "Application-9f48cdd13858abaf"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, - RefB: spdx.DocElementID{ElementRefID: "Package-eb0263038c3b445b"}, + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-20f7fa3049cc748c"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, - RefB: spdx.DocElementID{ElementRefID: "Application-c3fac92c1ac0a9fa"}, - Relationship: "CONTAINS", + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + Relationship: "DESCRIBES", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-c3fac92c1ac0a9fa"}, - RefB: spdx.DocElementID{ElementRefID: "Package-826226d056ff30c0"}, + RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-20f7fa3049cc748c"}, + RefB: spdx.DocElementID{ElementRefID: "Package-5d43902b18ed2e2c"}, Relationship: "CONTAINS", }, }, @@ -420,7 +463,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "centos:latest", - DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/centos:latest-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/centos:latest-3ff14136-e09f-4df9-80ea-000000000006", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -436,12 +479,27 @@ func TestMarshaler_Marshal(t *testing.T) { }, Packages: []*spdx.Package{ { - PackageSPDXIdentifier: spdx.ElementID("Package-d8dccb186bafaf37"), + PackageName: "centos:latest", + PackageSPDXIdentifier: "ContainerImage-413bfede37ad01fc", + PackageDownloadLocation: "NONE", + PackageAttributionTexts: []string{ + "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + "RepoTag: centos:latest", + "SchemaVersion: 2", + "Size: 1024", + }, + PrimaryPackagePurpose: tspdx.PackagePurposeContainer, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-40c4059fe08523bf"), PackageDownloadLocation: "NONE", PackageName: "acl", PackageVersion: "1:2.2.53-1.el8", PackageLicenseConcluded: "GPL-2.0-or-later", PackageLicenseDeclared: "GPL-2.0-or-later", + PackageAttributionTexts: []string{ + "PkgType: centos", + }, PackageExternalReferences: []*spdx.PackageExternalReference{ { Category: tspdx.CategoryPackageManager, @@ -460,7 +518,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, { - PackageSPDXIdentifier: spdx.ElementID("Package-13fe667a0805e6b7"), + PackageSPDXIdentifier: spdx.ElementID("Package-69f68dd639314edd"), PackageDownloadLocation: "NONE", PackageName: "actionpack", PackageVersion: "7.0.1", @@ -475,6 +533,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, PackageAttributionTexts: []string{ "LayerDiffID: sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + "PkgType: gemspec", }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, @@ -484,7 +543,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, { - PackageSPDXIdentifier: spdx.ElementID("Package-d5443dbcbba0dbd4"), + PackageSPDXIdentifier: spdx.ElementID("Package-da2cda24d2ecbfe6"), PackageDownloadLocation: "NONE", PackageName: "actionpack", PackageVersion: "7.0.1", @@ -499,6 +558,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, PackageAttributionTexts: []string{ "LayerDiffID: sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + "PkgType: gemspec", }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, @@ -508,93 +568,73 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, { - PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"), + PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-20f7fa3049cc748c"), PackageDownloadLocation: "NONE", PackageName: "centos", PackageVersion: "8.3.2011", PrimaryPackagePurpose: tspdx.PackagePurposeOS, - }, - { - PackageName: "centos:latest", - PackageSPDXIdentifier: "ContainerImage-413bfede37ad01fc", - PackageDownloadLocation: "NONE", PackageAttributionTexts: []string{ - "SchemaVersion: 2", - "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", - "Size: 1024", - "RepoTag: centos:latest", + "Class: os-pkgs", + "Type: centos", }, - PrimaryPackagePurpose: tspdx.PackagePurposeContainer, - }, - { - PackageSPDXIdentifier: spdx.ElementID("Application-441a648f2aeeee72"), - PackageDownloadLocation: "NONE", - PackageName: "gemspec", - PackageSourceInfo: "Ruby", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, }, }, Files: []*spdx.File{ { - FileSPDXIdentifier: "File-6a540784b0dc6d55", - FileName: "tools/project-john/specifications/actionpack.gemspec", + FileSPDXIdentifier: "File-fa42187221d0d0a8", + FileName: "tools/project-doe/specifications/actionpack.gemspec", Checksums: []spdx.Checksum{ { Algorithm: spdx.SHA1, - Value: "d2f9f9aed5161f6e4116a3f9573f41cd832f137c", + Value: "413f98442c83808042b5d1d2611a346b999bdca5", }, }, }, { - FileSPDXIdentifier: "File-fa42187221d0d0a8", - FileName: "tools/project-doe/specifications/actionpack.gemspec", + FileSPDXIdentifier: "File-6a540784b0dc6d55", + FileName: "tools/project-john/specifications/actionpack.gemspec", Checksums: []spdx.Checksum{ { Algorithm: spdx.SHA1, - Value: "413f98442c83808042b5d1d2611a346b999bdca5", + Value: "d2f9f9aed5161f6e4116a3f9573f41cd832f137c", }, }, }, }, Relationships: []*spdx.Relationship{ - { - RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, - RefB: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, - Relationship: "DESCRIBES", - }, { RefA: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, - RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-20f7fa3049cc748c"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, - RefB: spdx.DocElementID{ElementRefID: "Package-d8dccb186bafaf37"}, + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, + RefB: spdx.DocElementID{ElementRefID: "Package-69f68dd639314edd"}, Relationship: "CONTAINS", }, { RefA: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, - RefB: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, + RefB: spdx.DocElementID{ElementRefID: "Package-da2cda24d2ecbfe6"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, - RefB: spdx.DocElementID{ElementRefID: "Package-d5443dbcbba0dbd4"}, - Relationship: "CONTAINS", + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, + Relationship: "DESCRIBES", }, { - RefA: spdx.DocElementID{ElementRefID: "Package-d5443dbcbba0dbd4"}, - RefB: spdx.DocElementID{ElementRefID: "File-6a540784b0dc6d55"}, + RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-20f7fa3049cc748c"}, + RefB: spdx.DocElementID{ElementRefID: "Package-40c4059fe08523bf"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, - RefB: spdx.DocElementID{ElementRefID: "Package-13fe667a0805e6b7"}, + RefA: spdx.DocElementID{ElementRefID: "Package-69f68dd639314edd"}, + RefB: spdx.DocElementID{ElementRefID: "File-fa42187221d0d0a8"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Package-13fe667a0805e6b7"}, - RefB: spdx.DocElementID{ElementRefID: "File-fa42187221d0d0a8"}, + RefA: spdx.DocElementID{ElementRefID: "Package-da2cda24d2ecbfe6"}, + RefB: spdx.DocElementID{ElementRefID: "File-6a540784b0dc6d55"}, Relationship: "CONTAINS", }, }, @@ -629,6 +669,26 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, }, + { + Target: "pom.xml", + Class: types.ClassLangPkg, + Type: ftypes.Pom, + Packages: []ftypes.Package{ + { + ID: "com.example:example:1.0.0", + Name: "com.example:example", + Version: "1.0.0", + Identifier: ftypes.PkgIdentifier{ + PURL: &packageurl.PackageURL{ + Type: packageurl.TypeMaven, + Namespace: "com.example", + Name: "example", + Version: "1.0.0", + }, + }, + }, + }, + }, }, }, wantSBOM: &spdx.Document{ @@ -636,7 +696,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "masahiro331/CVE-2021-41098", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/masahiro331/CVE-2021-41098-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/masahiro331/CVE-2021-41098-3ff14136-e09f-4df9-80ea-000000000006", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -652,7 +712,27 @@ func TestMarshaler_Marshal(t *testing.T) { }, Packages: []*spdx.Package{ { - PackageSPDXIdentifier: spdx.ElementID("Package-3da61e86d0530402"), + PackageSPDXIdentifier: spdx.ElementID("Application-ed046c4a6b4da30f"), + PackageDownloadLocation: "NONE", + PackageName: "Gemfile.lock", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageAttributionTexts: []string{ + "Class: lang-pkgs", + "Type: bundler", + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Application-800d9e6e0f88ab3a"), + PackageDownloadLocation: "NONE", + PackageName: "pom.xml", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageAttributionTexts: []string{ + "Class: lang-pkgs", + "Type: pom", + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-e78eaf94802a53dc"), PackageDownloadLocation: "NONE", PackageName: "actioncable", PackageVersion: "6.1.4.1", @@ -667,13 +747,32 @@ func TestMarshaler_Marshal(t *testing.T) { }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: Gemfile.lock", + PackageAttributionTexts: []string{ + "PkgType: bundler", + }, }, { - PackageSPDXIdentifier: spdx.ElementID("Application-9dd4a4ba7077cc5a"), + PackageSPDXIdentifier: spdx.ElementID("Package-69cd7625c68537c7"), PackageDownloadLocation: "NONE", - PackageName: "bundler", - PackageSourceInfo: "Gemfile.lock", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageName: "com.example:example", + PackageVersion: "1.0.0", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:maven/com.example/example@1.0.0", + }, + }, + PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, + PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: pom.xml", + PackageAttributionTexts: []string{ + "PkgID: com.example:example:1.0.0", + "PkgType: pom", + }, }, { PackageSPDXIdentifier: spdx.ElementID("Filesystem-5af0f1f08c20909a"), @@ -686,6 +785,16 @@ func TestMarshaler_Marshal(t *testing.T) { }, }, Relationships: []*spdx.Relationship{ + { + RefA: spdx.DocElementID{ElementRefID: "Application-800d9e6e0f88ab3a"}, + RefB: spdx.DocElementID{ElementRefID: "Package-69cd7625c68537c7"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-ed046c4a6b4da30f"}, + RefB: spdx.DocElementID{ElementRefID: "Package-e78eaf94802a53dc"}, + Relationship: "CONTAINS", + }, { RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, RefB: spdx.DocElementID{ElementRefID: "Filesystem-5af0f1f08c20909a"}, @@ -693,12 +802,12 @@ func TestMarshaler_Marshal(t *testing.T) { }, { RefA: spdx.DocElementID{ElementRefID: "Filesystem-5af0f1f08c20909a"}, - RefB: spdx.DocElementID{ElementRefID: "Application-9dd4a4ba7077cc5a"}, + RefB: spdx.DocElementID{ElementRefID: "Application-800d9e6e0f88ab3a"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-9dd4a4ba7077cc5a"}, - RefB: spdx.DocElementID{ElementRefID: "Package-3da61e86d0530402"}, + RefA: spdx.DocElementID{ElementRefID: "Filesystem-5af0f1f08c20909a"}, + RefB: spdx.DocElementID{ElementRefID: "Application-ed046c4a6b4da30f"}, Relationship: "CONTAINS", }, }, @@ -730,6 +839,7 @@ func TestMarshaler_Marshal(t *testing.T) { Layer: ftypes.Layer{ DiffID: "sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e", }, + Digest: "sha256:a5efa82f08774597165e8c1a102d45d0406913b74c184883ac91f409ae26009d", FilePath: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json", }, }, @@ -741,7 +851,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "http://test-aggregate", - DocumentNamespace: "http://aquasecurity.github.io/trivy/repository/test-aggregate-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/repository/test-aggregate-3ff14136-e09f-4df9-80ea-000000000003", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -757,23 +867,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, Packages: []*spdx.Package{ { - PackageName: "http://test-aggregate", - PackageSPDXIdentifier: "Repository-1a78857c1a6a759e", - PackageDownloadLocation: "git+http://test-aggregate", - PackageAttributionTexts: []string{ - "SchemaVersion: 2", - }, - PrimaryPackagePurpose: tspdx.PackagePurposeSource, - }, - { - PackageSPDXIdentifier: "Application-24f8a80152e2c0fc", - PackageDownloadLocation: "git+http://test-aggregate", - PackageName: "node-pkg", - PackageSourceInfo: "Node.js", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, - }, - { - PackageSPDXIdentifier: spdx.ElementID("Package-daedb173cfd43058"), + PackageSPDXIdentifier: spdx.ElementID("Package-52b8e939bac2d133"), PackageDownloadLocation: "git+http://test-aggregate", PackageName: "ruby-typeprof", PackageVersion: "0.20.1", @@ -788,6 +882,7 @@ func TestMarshaler_Marshal(t *testing.T) { }, PackageAttributionTexts: []string{ "LayerDiffID: sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e", + "PkgType: node-pkg", }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, @@ -796,11 +891,26 @@ func TestMarshaler_Marshal(t *testing.T) { Value: "da39a3ee5e6b4b0d3255bfef95601890afd80709", }, }, + { + PackageSPDXIdentifier: "Repository-1a78857c1a6a759e", + PackageName: "http://test-aggregate", + PackageDownloadLocation: "git+http://test-aggregate", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + }, + PrimaryPackagePurpose: tspdx.PackagePurposeSource, + }, }, Files: []*spdx.File{ { FileName: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json", FileSPDXIdentifier: "File-a52825a3e5bc6dfe", + Checksums: []common.Checksum{ + { + Algorithm: common.SHA256, + Value: "a5efa82f08774597165e8c1a102d45d0406913b74c184883ac91f409ae26009d", + }, + }, }, }, Relationships: []*spdx.Relationship{ @@ -810,18 +920,13 @@ func TestMarshaler_Marshal(t *testing.T) { Relationship: "DESCRIBES", }, { - RefA: spdx.DocElementID{ElementRefID: "Repository-1a78857c1a6a759e"}, - RefB: spdx.DocElementID{ElementRefID: "Application-24f8a80152e2c0fc"}, - Relationship: "CONTAINS", - }, - { - RefA: spdx.DocElementID{ElementRefID: "Application-24f8a80152e2c0fc"}, - RefB: spdx.DocElementID{ElementRefID: "Package-daedb173cfd43058"}, + RefA: spdx.DocElementID{ElementRefID: "Package-52b8e939bac2d133"}, + RefB: spdx.DocElementID{ElementRefID: "File-a52825a3e5bc6dfe"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Package-daedb173cfd43058"}, - RefB: spdx.DocElementID{ElementRefID: "File-a52825a3e5bc6dfe"}, + RefA: spdx.DocElementID{ElementRefID: "Repository-1a78857c1a6a759e"}, + RefB: spdx.DocElementID{ElementRefID: "Package-52b8e939bac2d133"}, Relationship: "CONTAINS", }, }, @@ -840,7 +945,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "empty/path", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/empty/path-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/empty/path-3ff14136-e09f-4df9-80ea-000000000002", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ @@ -903,8 +1008,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "secret", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/secret-3ff14136-e09f-4df9-80ea-000000000001", - + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/secret-3ff14136-e09f-4df9-80ea-000000000002", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -946,7 +1050,7 @@ func TestMarshaler_Marshal(t *testing.T) { ArtifactType: ftypes.ArtifactFilesystem, Results: types.Results{ { - Target: "artifact", + Target: "/usr/local/bin/test", Class: types.ClassLangPkg, Type: ftypes.GoBinary, Packages: []ftypes.Package{ @@ -975,7 +1079,7 @@ func TestMarshaler_Marshal(t *testing.T) { DataLicense: spdx.DataLicense, SPDXIdentifier: "DOCUMENT", DocumentName: "go-artifact", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/go-artifact-3ff14136-e09f-4df9-80ea-000000000001", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/go-artifact-3ff14136-e09f-4df9-80ea-000000000005", CreationInfo: &spdx.CreationInfo{ Creators: []common.Creator{ { @@ -991,7 +1095,17 @@ func TestMarshaler_Marshal(t *testing.T) { }, Packages: []*spdx.Package{ { - PackageSPDXIdentifier: spdx.ElementID("Package-9164ae38c5cdf815"), + PackageSPDXIdentifier: spdx.ElementID("Application-aab0f4e8cf174c67"), + PackageDownloadLocation: "NONE", + PackageName: "/usr/local/bin/test", + PrimaryPackagePurpose: tspdx.PackagePurposeApplication, + PackageAttributionTexts: []string{ + "Class: lang-pkgs", + "Type: gobinary", + }, + }, + { + PackageSPDXIdentifier: spdx.ElementID("Package-9a16e221e11f8a90"), PackageDownloadLocation: "NONE", PackageName: "./private_repos/cnrm.googlesource.com/cnrm/", PackageVersion: "(devel)", @@ -999,25 +1113,13 @@ func TestMarshaler_Marshal(t *testing.T) { PackageLicenseDeclared: "NONE", PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, - }, - { - PackageName: "go-artifact", - PackageSPDXIdentifier: "Filesystem-e340f27468b382be", - PackageDownloadLocation: "NONE", + PackageSourceInfo: "package found in: /usr/local/bin/test", PackageAttributionTexts: []string{ - "SchemaVersion: 2", + "PkgType: gobinary", }, - PrimaryPackagePurpose: tspdx.PackagePurposeSource, }, { - PackageSPDXIdentifier: spdx.ElementID("Application-6666b83a5d554671"), - PackageDownloadLocation: "NONE", - PackageName: "gobinary", - PackageSourceInfo: "artifact", - PrimaryPackagePurpose: tspdx.PackagePurposeApplication, - }, - { - PackageSPDXIdentifier: spdx.ElementID("Package-8451f2bc8e1f45aa"), + PackageSPDXIdentifier: spdx.ElementID("Package-b9b7ae633941e083"), PackageDownloadLocation: "NONE", PackageName: "golang.org/x/crypto", PackageVersion: "v0.0.1", @@ -1032,27 +1134,40 @@ func TestMarshaler_Marshal(t *testing.T) { }, PrimaryPackagePurpose: tspdx.PackagePurposeLibrary, PackageSupplier: &spdx.Supplier{Supplier: tspdx.PackageSupplierNoAssertion}, + PackageSourceInfo: "package found in: /usr/local/bin/test", + PackageAttributionTexts: []string{ + "PkgType: gobinary", + }, + }, + { + PackageName: "go-artifact", + PackageSPDXIdentifier: "Filesystem-e340f27468b382be", + PackageDownloadLocation: "NONE", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + }, + PrimaryPackagePurpose: tspdx.PackagePurposeSource, }, }, Relationships: []*spdx.Relationship{ { - RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, - RefB: spdx.DocElementID{ElementRefID: "Filesystem-e340f27468b382be"}, - Relationship: "DESCRIBES", + RefA: spdx.DocElementID{ElementRefID: "Application-aab0f4e8cf174c67"}, + RefB: spdx.DocElementID{ElementRefID: "Package-9a16e221e11f8a90"}, + Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Filesystem-e340f27468b382be"}, - RefB: spdx.DocElementID{ElementRefID: "Application-6666b83a5d554671"}, + RefA: spdx.DocElementID{ElementRefID: "Application-aab0f4e8cf174c67"}, + RefB: spdx.DocElementID{ElementRefID: "Package-b9b7ae633941e083"}, Relationship: "CONTAINS", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-6666b83a5d554671"}, - RefB: spdx.DocElementID{ElementRefID: "Package-9164ae38c5cdf815"}, - Relationship: "CONTAINS", + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "Filesystem-e340f27468b382be"}, + Relationship: "DESCRIBES", }, { - RefA: spdx.DocElementID{ElementRefID: "Application-6666b83a5d554671"}, - RefB: spdx.DocElementID{ElementRefID: "Package-8451f2bc8e1f45aa"}, + RefA: spdx.DocElementID{ElementRefID: "Filesystem-e340f27468b382be"}, + RefB: spdx.DocElementID{ElementRefID: "Application-aab0f4e8cf174c67"}, Relationship: "CONTAINS", }, }, @@ -1064,17 +1179,18 @@ func TestMarshaler_Marshal(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Fake function calculating the hash value h := fnv.New64() - hasher := func(v interface{}, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) { + hasher := func(v any, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) { h.Reset() var str string - switch v.(type) { - case ftypes.Package: - str = v.(ftypes.Package).Name + v.(ftypes.Package).FilePath + switch vv := v.(type) { + case *core.Component: + str = vv.Name + vv.Version + vv.SrcFile + for _, f := range vv.Files { + str += f.Path + } case string: - str = v.(string) - case *ftypes.OS: - str = v.(*ftypes.OS).Name + str = vv default: require.Failf(t, "unknown type", "%T", v) } @@ -1090,7 +1206,7 @@ func TestMarshaler_Marshal(t *testing.T) { uuid.SetFakeUUID(t, "3ff14136-e09f-4df9-80ea-%012d") marshaler := tspdx.NewMarshaler("0.38.1", tspdx.WithHasher(hasher)) - spdxDoc, err := marshaler.Marshal(ctx, tc.inputReport) + spdxDoc, err := marshaler.MarshalReport(ctx, tc.inputReport) require.NoError(t, err) assert.Equal(t, tc.wantSBOM, spdxDoc) @@ -1101,62 +1217,52 @@ func TestMarshaler_Marshal(t *testing.T) { func Test_GetLicense(t *testing.T) { tests := []struct { name string - input ftypes.Package + input []string want string }{ { name: "happy path", - input: ftypes.Package{ - Licenses: []string{ - "GPLv2+", - }, + input: []string{ + "GPLv2+", }, want: "GPL-2.0-or-later", }, { name: "happy path with multi license", - input: ftypes.Package{ - Licenses: []string{ - "GPLv2+", - "GPLv3+", - }, + input: []string{ + "GPLv2+", + "GPLv3+", }, want: "GPL-2.0-or-later AND GPL-3.0-or-later", }, { name: "happy path with OR operator", - input: ftypes.Package{ - Licenses: []string{ - "GPLv2+", - "LGPL 2.0 or GNU LESSER", - }, + input: []string{ + "GPLv2+", + "LGPL 2.0 or GNU LESSER", }, want: "GPL-2.0-or-later AND (LGPL-2.0-only OR LGPL-3.0-only)", }, { name: "happy path with AND operator", - input: ftypes.Package{ - Licenses: []string{ - "GPLv2+", - "LGPL 2.0 and GNU LESSER", - }, + input: []string{ + "GPLv2+", + "LGPL 2.0 and GNU LESSER", }, want: "GPL-2.0-or-later AND LGPL-2.0-only AND LGPL-3.0-only", }, { name: "happy path with WITH operator", - input: ftypes.Package{ - Licenses: []string{ - "AFL 2.0", - "AFL 3.0 with distribution exception", - }, + input: []string{ + "AFL 2.0", + "AFL 3.0 with distribution exception", }, want: "AFL-2.0 AND AFL-3.0 WITH distribution-exception", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, tspdx.GetLicense(tt.input), "getLicense(%v)", tt.input) + assert.Equal(t, tt.want, tspdx.NormalizeLicense(tt.input)) }) } } diff --git a/pkg/sbom/spdx/testdata/sad/invalid-source-info.json b/pkg/sbom/spdx/testdata/sad/invalid-purl.json similarity index 92% rename from pkg/sbom/spdx/testdata/sad/invalid-source-info.json rename to pkg/sbom/spdx/testdata/sad/invalid-purl.json index 1c761c1f53fa..da87237d54b7 100644 --- a/pkg/sbom/spdx/testdata/sad/invalid-source-info.json +++ b/pkg/sbom/spdx/testdata/sad/invalid-purl.json @@ -27,13 +27,13 @@ "externalRefs": [ { "referenceCategory": "PACKAGE-MANAGER", - "referenceLocator": "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + "referenceLocator": "pkg:invalid", "referenceType": "purl" } ], "filesAnalyzed": false, "name": "musl", - "sourceInfo": "built package from: invalid", + "sourceInfo": "built package from: musl", "versionInfo": "1.2.3-r0" } ], diff --git a/pkg/sbom/spdx/unmarshal.go b/pkg/sbom/spdx/unmarshal.go index 718bdd608886..5b1d4138e7cb 100644 --- a/pkg/sbom/spdx/unmarshal.go +++ b/pkg/sbom/spdx/unmarshal.go @@ -2,13 +2,10 @@ package spdx import ( "bytes" - "errors" "fmt" "io" - "sort" "strings" - version "github.com/knqyf263/go-rpm-version" "github.com/package-url/packageurl-go" "github.com/samber/lo" "github.com/spdx/tools-golang/json" @@ -17,17 +14,14 @@ import ( "github.com/spdx/tools-golang/tagvalue" "golang.org/x/xerrors" - ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" - "github.com/aquasecurity/trivy/pkg/purl" - "github.com/aquasecurity/trivy/pkg/types" -) - -var ( - errUnknownPackageFormat = xerrors.New("unknown package format") + "github.com/aquasecurity/trivy/pkg/sbom/core" ) type SPDX struct { - *types.SBOM + *core.BOM + + trivySBOM bool + pkgFilePaths map[common.ElementID]string } func NewTVDecoder(r io.Reader) *TVDecoder { @@ -48,8 +42,7 @@ func (tv *TVDecoder) Decode(v interface{}) error { if !ok { return xerrors.Errorf("invalid struct type tag-value decoder needed SPDX struct") } - err = a.unmarshal(spdxDocument) - if err != nil { + if err = a.unmarshal(spdxDocument); err != nil { return xerrors.Errorf("failed to unmarshal spdx: %w", err) } @@ -57,292 +50,219 @@ func (tv *TVDecoder) Decode(v interface{}) error { } func (s *SPDX) UnmarshalJSON(b []byte) error { + if s.BOM == nil { + s.BOM = core.NewBOM(core.Options{}) + } + if s.pkgFilePaths == nil { + s.pkgFilePaths = make(map[common.ElementID]string) + } + spdxDocument, err := json.Read(bytes.NewReader(b)) if err != nil { return xerrors.Errorf("failed to load spdx json: %w", err) } - err = s.unmarshal(spdxDocument) - if err != nil { + + if err = s.unmarshal(spdxDocument); err != nil { return xerrors.Errorf("failed to unmarshal spdx: %w", err) } return nil } func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { - var osPkgs []ftypes.Package - apps := make(map[common.ElementID]*ftypes.Application) - packageSPDXIdentifierMap := createPackageSPDXIdentifierMap(spdxDocument.Packages) - packageFilePaths := getPackageFilePaths(spdxDocument) + s.trivySBOM = s.isTrivySBOM(spdxDocument) - // Hold packages that are not processed by relationships - orphanPkgs := createPackageSPDXIdentifierMap(spdxDocument.Packages) + // Parse files and find file paths for packages + s.parseFiles(spdxDocument) - relationships := lo.Filter(spdxDocument.Relationships, func(rel *spdx.Relationship, _ int) bool { - // Skip the DESCRIBES relationship. - return rel.Relationship != common.TypeRelationshipDescribe && rel.Relationship != "DESCRIBE" - }) + // Convert all SPDX packages into Trivy components + components, err := s.parsePackages(spdxDocument) + if err != nil { + return xerrors.Errorf("package parse error: %w", err) + } - // Package relationships would be as belows: - // - Root (container image, filesystem, etc.) - // - Operating System (debian 10) - // - OS package A - // - OS package B - // - Application 1 (package-lock.json) - // - Node.js package A - // - Node.js package B - // - Application 2 (Pipfile.lock) - // - Python package A - // - Python package B - for _, rel := range relationships { - pkgA := packageSPDXIdentifierMap[rel.RefA.ElementRefID] - pkgB := packageSPDXIdentifierMap[rel.RefB.ElementRefID] - - if pkgA == nil || pkgB == nil { - // Skip the missing pkg relationship. + // Parse relationships and build the dependency graph + for _, rel := range spdxDocument.Relationships { + // Skip the DESCRIBES relationship. + if rel.Relationship == common.TypeRelationshipDescribe || rel.Relationship == "DESCRIBE" { continue } - switch { - // Relationship: root package => OS - case isOperatingSystem(pkgB.PackageSPDXIdentifier): - s.SBOM.Metadata.OS = parseOS(*pkgB) - delete(orphanPkgs, pkgB.PackageSPDXIdentifier) - // Relationship: OS => OS package - case isOperatingSystem(pkgA.PackageSPDXIdentifier): - pkg, _, err := parsePkg(*pkgB, packageFilePaths) - if errors.Is(err, errUnknownPackageFormat) { - continue - } else if err != nil { - return xerrors.Errorf("failed to parse os package: %w", err) - } - osPkgs = append(osPkgs, *pkg) - delete(orphanPkgs, pkgB.PackageSPDXIdentifier) - // Relationship: root package => application - case isApplication(pkgB.PackageSPDXIdentifier): - // pass - // Relationship: application => language-specific package - case isApplication(pkgA.PackageSPDXIdentifier): - app, ok := apps[pkgA.PackageSPDXIdentifier] - if !ok { - app = initApplication(*pkgA) - apps[pkgA.PackageSPDXIdentifier] = app - } - - lib, _, err := parsePkg(*pkgB, packageFilePaths) - if errors.Is(err, errUnknownPackageFormat) { - continue - } else if err != nil { - return xerrors.Errorf("failed to parse language-specific package: %w", err) - } - app.Libraries = append(app.Libraries, *lib) - - // They are no longer orphan packages - delete(orphanPkgs, pkgA.PackageSPDXIdentifier) - delete(orphanPkgs, pkgB.PackageSPDXIdentifier) - } - } - - // Fill OS packages - if len(osPkgs) > 0 { - s.Packages = []ftypes.PackageInfo{{Packages: osPkgs}} - } - - // Fill applications - for _, app := range apps { - s.SBOM.Applications = append(s.SBOM.Applications, *app) - } - - // Fallback for when there are no effective relationships. - if err := s.parsePackages(orphanPkgs); err != nil { - return err + compA := components[rel.RefA.ElementRefID] + compB := components[rel.RefB.ElementRefID] + s.BOM.AddRelationship(compA, compB, s.parseRelationshipType(rel.Relationship)) } return nil } -// parsePackages processes the packages and categorizes them into OS packages and application packages. -// Note that all language-specific packages are treated as a single application. -func (s *SPDX) parsePackages(pkgs map[common.ElementID]*spdx.Package) error { - var ( - osPkgs []ftypes.Package - apps = make(map[ftypes.LangType]ftypes.Application) - ) - - for _, p := range pkgs { - pkg, pkgURL, err := parsePkg(*p, nil) - if errors.Is(err, errUnknownPackageFormat) { +// parseFiles parses Relationships and finds filepaths for packages +func (s *SPDX) parseFiles(spdxDocument *spdx.Document) { + fileSPDXIdentifierMap := lo.SliceToMap(spdxDocument.Files, func(file *spdx.File) (common.ElementID, *spdx.File) { + return file.FileSPDXIdentifier, file + }) + + for _, rel := range spdxDocument.Relationships { + if rel.Relationship != common.TypeRelationshipContains && rel.Relationship != "CONTAIN" { + // Skip the DESCRIBES relationship. continue - } else if err != nil { - return xerrors.Errorf("failed to parse package: %w", err) } - switch pkgURL.Class() { - case types.ClassOSPkg: - osPkgs = append(osPkgs, *pkg) - case types.ClassLangPkg: - // Language-specific packages - pkgType := pkgURL.LangType() - app, ok := apps[pkgType] - if !ok { - app.Type = pkgType + + // hasFiles field is deprecated + // https://github.com/spdx/tools-golang/issues/171 + // hasFiles values converted in Relationships + // https://github.com/spdx/tools-golang/pull/201 + if isFile(rel.RefB.ElementRefID) { + file, ok := fileSPDXIdentifierMap[rel.RefB.ElementRefID] + if ok { + // Save filePaths for packages + // Insert filepath will be later + s.pkgFilePaths[rel.RefA.ElementRefID] = file.FileName } - app.Libraries = append(app.Libraries, *pkg) - apps[pkgType] = app + continue } } - if len(osPkgs) > 0 { - s.Packages = []ftypes.PackageInfo{{Packages: osPkgs}} - } - for _, app := range apps { - sort.Sort(app.Libraries) - s.SBOM.Applications = append(s.SBOM.Applications, app) - } - return nil } -func createPackageSPDXIdentifierMap(packages []*spdx.Package) map[common.ElementID]*spdx.Package { - return lo.SliceToMap(packages, func(pkg *spdx.Package) (common.ElementID, *spdx.Package) { - return pkg.PackageSPDXIdentifier, pkg - }) -} - -func createFileSPDXIdentifierMap(files []*spdx.File) map[string]*spdx.File { - ret := make(map[string]*spdx.File) - for _, file := range files { - ret[string(file.FileSPDXIdentifier)] = file +func (s *SPDX) parsePackages(spdxDocument *spdx.Document) (map[common.ElementID]*core.Component, error) { + // Find a root package + var rootID common.ElementID + for _, rel := range spdxDocument.Relationships { + if rel.RefA.ElementRefID == DocumentSPDXIdentifier && rel.Relationship == RelationShipDescribe { + rootID = rel.RefB.ElementRefID + break + } } - return ret -} - -func isOperatingSystem(elementID spdx.ElementID) bool { - return strings.HasPrefix(string(elementID), ElementOperatingSystem) -} -func isApplication(elementID spdx.ElementID) bool { - return strings.HasPrefix(string(elementID), ElementApplication) -} - -func isFile(elementID spdx.ElementID) bool { - return strings.HasPrefix(string(elementID), ElementFile) -} + // Convert packages into components + components := make(map[common.ElementID]*core.Component) + for _, pkg := range spdxDocument.Packages { + component, err := s.parsePackage(*pkg) + if err != nil { + return nil, xerrors.Errorf("failed to parse package: %w", err) + } + components[pkg.PackageSPDXIdentifier] = component -func initApplication(pkg spdx.Package) *ftypes.Application { - app := &ftypes.Application{Type: ftypes.LangType(pkg.PackageName)} - switch app.Type { - case ftypes.NodePkg, ftypes.PythonPkg, ftypes.GemSpec, ftypes.Jar, ftypes.CondaPkg: - app.FilePath = "" - default: - app.FilePath = pkg.PackageSourceInfo + if pkg.PackageSPDXIdentifier == rootID { + component.Root = true + } + s.BOM.AddComponent(component) } - - return app + return components, nil } -func parseOS(pkg spdx.Package) *ftypes.OS { - return &ftypes.OS{ - Family: ftypes.OSType(pkg.PackageName), - Name: pkg.PackageVersion, +func (s *SPDX) parsePackage(spdxPkg spdx.Package) (*core.Component, error) { + var err error + component := &core.Component{ + Type: s.parseType(spdxPkg), + Name: spdxPkg.PackageName, + Version: spdxPkg.PackageVersion, } -} -func parsePkg(spdxPkg spdx.Package, packageFilePaths map[string]string) (*ftypes.Package, *purl.PackageURL, error) { - pkgURL, err := parseExternalReferences(spdxPkg.PackageExternalReferences) - if err != nil { - return nil, nil, xerrors.Errorf("external references error: %w", err) + // PURL + if component.PkgID.PURL, err = s.parseExternalReferences(spdxPkg.PackageExternalReferences); err != nil { + return nil, xerrors.Errorf("external references error: %w", err) } - pkg := pkgURL.Package() + // License if spdxPkg.PackageLicenseDeclared != "NONE" { - pkg.Licenses = strings.Split(spdxPkg.PackageLicenseDeclared, ",") + component.Licenses = strings.Split(spdxPkg.PackageLicenseDeclared, ",") } + // Source package if strings.HasPrefix(spdxPkg.PackageSourceInfo, SourcePackagePrefix) { srcPkgName := strings.TrimPrefix(spdxPkg.PackageSourceInfo, fmt.Sprintf("%s: ", SourcePackagePrefix)) - pkg.SrcEpoch, pkg.SrcName, pkg.SrcVersion, pkg.SrcRelease, err = parseSourceInfo(pkgURL.Type, srcPkgName) - if err != nil { - return nil, nil, xerrors.Errorf("failed to parse source info: %w", err) - } + component.SrcName, component.SrcVersion, _ = strings.Cut(srcPkgName, " ") } - if path, ok := packageFilePaths[string(spdxPkg.PackageSPDXIdentifier)]; ok { - pkg.FilePath = path + // Files + // TODO: handle checksums as well + if path, ok := s.pkgFilePaths[spdxPkg.PackageSPDXIdentifier]; ok { + component.Files = []core.File{ + {Path: path}, + } } else if len(spdxPkg.Files) > 0 { - // Take the first file name - pkg.FilePath = spdxPkg.Files[0].FileName + component.Files = []core.File{ + {Path: spdxPkg.Files[0].FileName}, // Take the first file name + } } - pkg.ID = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyPkgID) - pkg.Layer.Digest = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDigest) - pkg.Layer.DiffID = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDiffID) + // Attributions + for _, attr := range spdxPkg.PackageAttributionTexts { + k, v, ok := strings.Cut(attr, ": ") + if !ok { + continue + } + component.Properties = append(component.Properties, core.Property{ + Name: k, + Value: v, + }) + } + + // For backward-compatibility + // Older Trivy versions put the file path in "sourceInfo" and the package type in "name". + if s.trivySBOM && component.Type == core.TypeApplication && spdxPkg.PackageSourceInfo != "" { + component.Name = spdxPkg.PackageSourceInfo + component.Properties = append(component.Properties, core.Property{ + Name: core.PropertyType, + Value: spdxPkg.PackageName, + }) + } - return pkg, pkgURL, nil + return component, nil } -func parseExternalReferences(refs []*spdx.PackageExternalReference) (*purl.PackageURL, error) { +func (s *SPDX) parseType(pkg spdx.Package) core.ComponentType { + id := string(pkg.PackageSPDXIdentifier) + switch { + case strings.HasPrefix(id, ElementOperatingSystem): + return core.TypeOS + case strings.HasPrefix(id, ElementApplication): + return core.TypeApplication + case strings.HasPrefix(id, ElementPackage): + return core.TypeLibrary + default: + return core.TypeLibrary // unknown is handled as a library + } +} + +func (s *SPDX) parseRelationshipType(rel string) core.RelationshipType { + switch rel { + case common.TypeRelationshipDescribe: + return core.RelationshipDescribes + case common.TypeRelationshipContains, "CONTAIN": + return core.RelationshipContains + case common.TypeRelationshipDependsOn: + return core.RelationshipDependsOn + default: + return core.RelationshipContains + } +} + +func (s *SPDX) parseExternalReferences(refs []*spdx.PackageExternalReference) (*packageurl.PackageURL, error) { for _, ref := range refs { // Extract the package information from PURL if ref.RefType != RefTypePurl || ref.Category != CategoryPackageManager { continue } - packageURL, err := purl.FromString(ref.Locator) + packageURL, err := packageurl.FromString(ref.Locator) if err != nil { return nil, xerrors.Errorf("failed to parse purl from string: %w", err) } - return packageURL, nil + return &packageURL, nil } - return nil, errUnknownPackageFormat + return nil, nil } -func lookupAttributionTexts(attributionTexts []string, key string) string { - for _, text := range attributionTexts { - if strings.HasPrefix(text, key) { - return strings.TrimPrefix(text, fmt.Sprintf("%s: ", key)) +func (s *SPDX) isTrivySBOM(spdxDocument *spdx.Document) bool { + for _, c := range spdxDocument.CreationInfo.Creators { + if c.CreatorType == "Tool" && strings.HasPrefix(c.Creator, "trivy") { + return true } } - return "" + return false } -func parseSourceInfo(pkgType, sourceInfo string) (epoch int, name, ver, rel string, err error) { - srcNameVersion := strings.TrimPrefix(sourceInfo, fmt.Sprintf("%s: ", SourcePackagePrefix)) - ss := strings.Split(srcNameVersion, " ") - if len(ss) != 2 { - return 0, "", "", "", xerrors.Errorf("invalid source info (%s)", sourceInfo) - } - name = ss[0] - if pkgType == packageurl.TypeRPM { - v := version.NewVersion(ss[1]) - epoch = v.Epoch() - ver = v.Version() - rel = v.Release() - } else { - ver = ss[1] - } - return epoch, name, ver, rel, nil -} - -// getPackageFilePaths parses Relationships and finds filepaths for packages -func getPackageFilePaths(spdxDocument *spdx.Document) map[string]string { - packageFilePaths := make(map[string]string) - fileSPDXIdentifierMap := createFileSPDXIdentifierMap(spdxDocument.Files) - for _, rel := range spdxDocument.Relationships { - if rel.Relationship != common.TypeRelationshipContains && rel.Relationship != "CONTAIN" { - // Skip the DESCRIBES relationship. - continue - } - - // hasFiles field is deprecated - // https://github.com/spdx/tools-golang/issues/171 - // hasFiles values converted in Relationships - // https://github.com/spdx/tools-golang/pull/201 - if isFile(rel.RefB.ElementRefID) { - file, ok := fileSPDXIdentifierMap[string(rel.RefB.ElementRefID)] - if ok { - // Save filePaths for packages - // Insert filepath will be later - packageFilePaths[string(rel.RefA.ElementRefID)] = file.FileName - } - continue - } - } - return packageFilePaths +func isFile(elementID spdx.ElementID) bool { + return strings.HasPrefix(string(elementID), ElementFile) } diff --git a/pkg/sbom/spdx/unmarshal_test.go b/pkg/sbom/spdx/unmarshal_test.go index cee50461508e..f65294020728 100644 --- a/pkg/sbom/spdx/unmarshal_test.go +++ b/pkg/sbom/spdx/unmarshal_test.go @@ -2,6 +2,7 @@ package spdx_test import ( "encoding/json" + sbomio "github.com/aquasecurity/trivy/pkg/sbom/io" "github.com/package-url/packageurl-go" "os" "sort" @@ -27,6 +28,15 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { inputFile: "testdata/happy/bom.json", want: types.SBOM{ Metadata: types.Metadata{ + ImageID: "sha256:49193a2310dbad4c02382da87ac624a80a92387a4f7536235f9ba590e5bcd7b5", + DiffIDs: []string{ + "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", + "sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3", + }, + RepoTags: []string{ + "maven-test-project:latest", + "tmp-test:latest", + }, OS: &ftypes.OS{ Family: "alpine", Name: "3.16.0", @@ -36,6 +46,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { { Packages: ftypes.Packages{ { + ID: "musl@1.2.3-r0", Name: "musl", Version: "1.2.3-r0", SrcName: "musl", @@ -68,6 +79,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { FilePath: "app/composer/composer.lock", Libraries: ftypes.Packages{ { + ID: "pear/log@1.13.1", Name: "pear/log", Version: "1.13.1", Identifier: ftypes.PkgIdentifier{ @@ -83,7 +95,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, }, { - + ID: "pear/pear_exception@v1.0.0", Name: "pear/pear_exception", Version: "v1.0.0", Identifier: ftypes.PkgIdentifier{ @@ -105,6 +117,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { FilePath: "app/gobinary/gobinary", Libraries: ftypes.Packages{ { + ID: "github.com/package-url/packageurl-go@v0.1.1-0.20220203205134-d70459300c8a", Name: "github.com/package-url/packageurl-go", Version: "v0.1.1-0.20220203205134-d70459300c8a", Identifier: ftypes.PkgIdentifier{ @@ -125,6 +138,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Type: "jar", Libraries: ftypes.Packages{ { + ID: "org.codehaus.mojo:child-project:1.0", Name: "org.codehaus.mojo:child-project", Identifier: ftypes.PkgIdentifier{ PURL: &packageurl.PackageURL{ @@ -145,6 +159,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Type: "node-pkg", Libraries: ftypes.Packages{ { + ID: "bootstrap@5.0.2", Name: "bootstrap", Version: "5.0.2", Identifier: ftypes.PkgIdentifier{ @@ -170,7 +185,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { want: types.SBOM{ Applications: []ftypes.Application{ { - Type: "node-pkg", + Type: ftypes.NodePkg, Libraries: ftypes.Packages{ { ID: "yargs-parser@21.1.1", @@ -228,6 +243,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { FilePath: "app/composer/composer.lock", Libraries: ftypes.Packages{ { + ID: "pear/log@1.13.1", Name: "pear/log", Version: "1.13.1", Identifier: ftypes.PkgIdentifier{ @@ -240,7 +256,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, }, { - + ID: "pear/pear_exception@v1.0.0", Name: "pear/pear_exception", Version: "v1.0.0", Identifier: ftypes.PkgIdentifier{ @@ -266,9 +282,10 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Type: ftypes.Jar, Libraries: ftypes.Packages{ { - FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", + ID: "co.elastic.apm:apm-agent:1.36.0", Name: "co.elastic.apm:apm-agent", Version: "1.36.0", + FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", Identifier: ftypes.PkgIdentifier{ PURL: &packageurl.PackageURL{ Type: packageurl.TypeMaven, @@ -279,9 +296,10 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, }, { - FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", + ID: "co.elastic.apm:apm-agent-cached-lookup-key:1.36.0", Name: "co.elastic.apm:apm-agent-cached-lookup-key", Version: "1.36.0", + FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", Identifier: ftypes.PkgIdentifier{ PURL: &packageurl.PackageURL{ Type: packageurl.TypeMaven, @@ -315,8 +333,8 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, { name: "sad path invalid purl", - inputFile: "testdata/sad/invalid-source-info.json", - wantErr: "failed to parse source info:", + inputFile: "testdata/sad/invalid-purl.json", + wantErr: "purl is missing type or name", }, } @@ -326,22 +344,24 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { require.NoError(t, err) defer f.Close() - v := &spdx.SPDX{SBOM: &types.SBOM{}} - err = json.NewDecoder(f).Decode(v) + var v spdx.SPDX + err = json.NewDecoder(f).Decode(&v) if tt.wantErr != "" { - require.Error(t, err) - assert.Contains(t, err.Error(), tt.wantErr) + assert.ErrorContains(t, err, tt.wantErr) return } - // Not compare the SPDX field - v.BOM = nil + var got types.SBOM + err = sbomio.NewDecoder(v.BOM).Decode(&got) + require.NoError(t, err) + + // Not compare BOM + got.BOM = nil - sort.Slice(v.Applications, func(i, j int) bool { - return v.Applications[i].Type < v.Applications[j].Type + sort.Slice(got.Applications, func(i, j int) bool { + return got.Applications[i].Type < got.Applications[j].Type }) - require.NoError(t, err) - assert.Equal(t, tt.want, *v.SBOM) + assert.Equal(t, tt.want, got) }) } }