diff --git a/pkg/report/spdx/spdx.go b/pkg/report/spdx/spdx.go index 498a671500d..f86885edf14 100644 --- a/pkg/report/spdx/spdx.go +++ b/pkg/report/spdx/spdx.go @@ -1,185 +1,47 @@ package spdx import ( - "fmt" "io" - "strings" - "time" - "github.com/google/uuid" - "github.com/mitchellh/hashstructure/v2" "github.com/spdx/tools-golang/jsonsaver" - "github.com/spdx/tools-golang/spdx" "github.com/spdx/tools-golang/tvsaver" "golang.org/x/xerrors" - "k8s.io/utils/clock" - ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/sbom/spdx" "github.com/aquasecurity/trivy/pkg/types" ) -const ( - SPDXVersion = "SPDX-2.2" - DataLicense = "CC0-1.0" - SPDXIdentifier = "DOCUMENT" - DocumentNamespace = "http://aquasecurity.github.io/trivy" - CreatorOrganization = "aquasecurity" - CreatorTool = "trivy" -) - -type Hash func(v interface{}, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) - type Writer struct { - output io.Writer - version string - *options -} - -type newUUID func() uuid.UUID - -type options struct { - format spdx.Document2_1 - clock clock.Clock - newUUID newUUID - hasher Hash - spdxFormat string -} - -type option func(*options) - -type spdxSaveFunction func(*spdx.Document2_2, io.Writer) error - -func WithClock(clock clock.Clock) option { - return func(opts *options) { - opts.clock = clock - } -} - -func WithNewUUID(newUUID newUUID) option { - return func(opts *options) { - opts.newUUID = newUUID - } -} - -func WithHasher(hasher Hash) option { - return func(opts *options) { - opts.hasher = hasher - } + output io.Writer + version string + format string + marshaler *spdx.Marshaler } -func NewWriter(output io.Writer, version string, spdxFormat string, opts ...option) Writer { - o := &options{ - format: spdx.Document2_1{}, - clock: clock.RealClock{}, - newUUID: uuid.New, - hasher: hashstructure.Hash, - spdxFormat: spdxFormat, - } - - for _, opt := range opts { - opt(o) - } - +func NewWriter(output io.Writer, version string, spdxFormat string) Writer { return Writer{ - output: output, - version: version, - options: o, + output: output, + version: version, + format: spdxFormat, + marshaler: spdx.NewMarshaler(), } } -func (cw Writer) Write(report types.Report) error { - spdxDoc, err := cw.convertToBom(report, cw.version) +func (w Writer) Write(report types.Report) error { + spdxDoc, err := w.marshaler.Marshal(report) if err != nil { - return xerrors.Errorf("failed to convert bom: %w", err) + return xerrors.Errorf("failed to marshal spdx: %w", err) } - var saveFunc spdxSaveFunction - if cw.spdxFormat != "spdx-json" { - saveFunc = tvsaver.Save2_2 + if w.format == "spdx-json" { + if err := jsonsaver.Save2_2(spdxDoc, w.output); err != nil { + return xerrors.Errorf("failed to save spdx json: %w", err) + } } else { - saveFunc = jsonsaver.Save2_2 - } - - if err = saveFunc(spdxDoc, cw.output); err != nil { - return xerrors.Errorf("failed to save bom: %w", err) - } - return nil -} - -func (cw *Writer) convertToBom(r types.Report, version string) (*spdx.Document2_2, error) { - packages := make(map[spdx.ElementID]*spdx.Package2_2) - - for _, result := range r.Results { - for _, pkg := range result.Packages { - spdxPackage, err := cw.pkgToSpdxPackage(pkg) - if err != nil { - return nil, xerrors.Errorf("failed to parse pkg: %w", err) - } - packages[spdxPackage.PackageSPDXIdentifier] = &spdxPackage + if err := tvsaver.Save2_2(spdxDoc, w.output); err != nil { + return xerrors.Errorf("failed to save spdx tag-value: %w", err) } } - return &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: SPDXVersion, - DataLicense: DataLicense, - SPDXIdentifier: SPDXIdentifier, - DocumentName: r.ArtifactName, - DocumentNamespace: getDocumentNamespace(r, cw), - CreatorOrganizations: []string{CreatorOrganization}, - CreatorTools: []string{CreatorTool}, - Created: cw.clock.Now().UTC().Format(time.RFC3339Nano), - }, - Packages: packages, - }, nil -} - -func (cw *Writer) pkgToSpdxPackage(pkg ftypes.Package) (spdx.Package2_2, error) { - var spdxPackage spdx.Package2_2 - license := getLicense(pkg) - - pkgID, err := getPackageID(cw.hasher, pkg) - if err != nil { - return spdx.Package2_2{}, xerrors.Errorf("failed to get %s package ID: %w", pkg.Name, err) - } - - spdxPackage.PackageSPDXIdentifier = spdx.ElementID(pkgID) - spdxPackage.PackageName = pkg.Name - spdxPackage.PackageVersion = pkg.Version - - // The Declared License is what the authors of a project believe govern the package - spdxPackage.PackageLicenseConcluded = license - - // The Concluded License field is the license the SPDX file creator believes governs the package - spdxPackage.PackageLicenseDeclared = license - - return spdxPackage, nil -} - -func getLicense(p ftypes.Package) string { - if len(p.Licenses) == 0 { - return "NONE" - } - - return strings.Join(p.Licenses, ", ") -} - -func getDocumentNamespace(r types.Report, cw *Writer) string { - return DocumentNamespace + "/" + string(r.ArtifactType) + "/" + r.ArtifactName + "-" + cw.newUUID().String() -} - -func getPackageID(h Hash, p ftypes.Package) (string, error) { - // Not use these values for the hash - p.Layer = ftypes.Layer{} - p.FilePath = "" - - f, err := h(p, hashstructure.FormatV2, &hashstructure.HashOptions{ - ZeroNil: true, - SlicesAsSets: true, - }) - if err != nil { - return "", xerrors.Errorf("could not build package ID for package=%+v: %+v", p, err) - } - - return fmt.Sprintf("%x", f), nil + return nil } diff --git a/pkg/report/spdx/spdx_test.go b/pkg/report/spdx/spdx_test.go deleted file mode 100644 index fef62bdee00..00000000000 --- a/pkg/report/spdx/spdx_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package spdx_test - -import ( - "bytes" - "fmt" - "hash/fnv" - "testing" - "time" - - "github.com/mitchellh/hashstructure/v2" - - fos "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os" - ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" - "github.com/aquasecurity/trivy/pkg/report" - reportSpdx "github.com/aquasecurity/trivy/pkg/report/spdx" - "github.com/aquasecurity/trivy/pkg/types" - v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/uuid" - "github.com/spdx/tools-golang/jsonloader" - "github.com/spdx/tools-golang/spdx" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - fake "k8s.io/utils/clock/testing" -) - -func TestWriter_Write(t *testing.T) { - testCases := []struct { - name string - inputReport types.Report - wantSBOM *spdx.Document2_2 - }{ - { - name: "happy path for container scan", - inputReport: types.Report{ - SchemaVersion: report.SchemaVersion, - ArtifactName: "rails:latest", - ArtifactType: ftypes.ArtifactContainerImage, - Metadata: types.Metadata{ - Size: 1024, - OS: &ftypes.OS{ - Family: fos.CentOS, - Name: "8.3.2011", - Eosl: true, - }, - ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", - RepoTags: []string{"rails:latest"}, - DiffIDs: []string{"sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a"}, - RepoDigests: []string{"rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177"}, - ImageConfig: v1.ConfigFile{ - Architecture: "arm64", - }, - }, - Results: types.Results{ - { - Target: "rails:latest (centos 8.3.2011)", - Class: types.ClassOSPkg, - Type: fos.CentOS, - Packages: []ftypes.Package{ - { - Name: "binutils", - Version: "2.30", - Release: "93.el8", - Epoch: 0, - Arch: "aarch64", - SrcName: "binutils", - SrcVersion: "2.30", - SrcRelease: "93.el8", - SrcEpoch: 0, - Modularitylabel: "", - Licenses: []string{"GPLv3+"}, - }, - }, - }, - { - Target: "app/subproject/Gemfile.lock", - Class: types.ClassLangPkg, - Type: ftypes.Bundler, - Packages: []ftypes.Package{ - { - Name: "actionpack", - Version: "7.0.1", - }, - { - Name: "actioncontroller", - Version: "7.0.1", - }, - }, - }, - { - Target: "app/Gemfile.lock", - Class: types.ClassLangPkg, - Type: ftypes.Bundler, - Packages: []ftypes.Package{ - { - Name: "actionpack", - Version: "7.0.1", - }, - }, - }, - }, - }, - wantSBOM: &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: "SPDX-2.2", - DataLicense: "CC0-1.0", - SPDXIdentifier: "DOCUMENT", - DocumentName: "rails:latest", - DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/rails:latest-3ff14136-e09f-4df9-80ea-000000000001", - CreatorOrganizations: []string{"aquasecurity"}, - CreatorTools: []string{"trivy"}, - Created: "2021-08-25T12:20:30.000000005Z", - ExternalDocumentReferences: map[string]spdx.ExternalDocumentRef2_2{}, - }, - Packages: map[spdx.ElementID]*spdx.Package2_2{ - spdx.ElementID("eb0263038c3b445b"): { - PackageSPDXIdentifier: spdx.ElementID("eb0263038c3b445b"), - PackageName: "actioncontroller", - PackageVersion: "7.0.1", - PackageLicenseConcluded: "NONE", - PackageLicenseDeclared: "NONE", - IsFilesAnalyzedTagPresent: true, - }, - spdx.ElementID("826226d056ff30c0"): { - PackageSPDXIdentifier: spdx.ElementID("826226d056ff30c0"), - PackageName: "actionpack", - PackageVersion: "7.0.1", - PackageLicenseConcluded: "NONE", - PackageLicenseDeclared: "NONE", - IsFilesAnalyzedTagPresent: true, - }, - spdx.ElementID("fd0dc3cf913d5bc3"): { - PackageSPDXIdentifier: spdx.ElementID("fd0dc3cf913d5bc3"), - PackageName: "binutils", - PackageVersion: "2.30", - PackageLicenseConcluded: "GPLv3+", - PackageLicenseDeclared: "GPLv3+", - IsFilesAnalyzedTagPresent: true, - }, - }, - UnpackagedFiles: nil, - OtherLicenses: nil, - Relationships: nil, - Annotations: nil, - Reviews: nil, - }, - }, - { - name: "happy path for local container scan", - inputReport: types.Report{ - SchemaVersion: report.SchemaVersion, - ArtifactName: "centos:latest", - ArtifactType: ftypes.ArtifactContainerImage, - Metadata: types.Metadata{ - Size: 1024, - OS: &ftypes.OS{ - Family: fos.CentOS, - Name: "8.3.2011", - Eosl: true, - }, - ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", - RepoTags: []string{"centos:latest"}, - RepoDigests: []string{}, - ImageConfig: v1.ConfigFile{ - Architecture: "arm64", - }, - }, - Results: types.Results{ - { - Target: "centos:latest (centos 8.3.2011)", - Class: types.ClassOSPkg, - Type: fos.CentOS, - Packages: []ftypes.Package{ - { - Name: "acl", - Version: "2.2.53", - Release: "1.el8", - Epoch: 1, - Arch: "aarch64", - SrcName: "acl", - SrcVersion: "2.2.53", - SrcRelease: "1.el8", - SrcEpoch: 1, - Modularitylabel: "", - Licenses: []string{"GPLv2+"}, - }, - }, - }, - { - Target: "Ruby", - Class: types.ClassLangPkg, - Type: ftypes.GemSpec, - Packages: []ftypes.Package{ - { - Name: "actionpack", - Version: "7.0.1", - Layer: ftypes.Layer{ - DiffID: "sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", - }, - FilePath: "tools/project-john/specifications/actionpack.gemspec", - }, - { - Name: "actionpack", - Version: "7.0.1", - Layer: ftypes.Layer{ - DiffID: "sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", - }, - FilePath: "tools/project-doe/specifications/actionpack.gemspec", - }, - }, - }, - }, - }, - wantSBOM: &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: "SPDX-2.2", - DataLicense: "CC0-1.0", - SPDXIdentifier: "DOCUMENT", - DocumentName: "centos:latest", - DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/centos:latest-3ff14136-e09f-4df9-80ea-000000000001", - CreatorOrganizations: []string{"aquasecurity"}, - CreatorTools: []string{"trivy"}, - Created: "2021-08-25T12:20:30.000000005Z", - ExternalDocumentReferences: map[string]spdx.ExternalDocumentRef2_2{}, - }, - Packages: map[spdx.ElementID]*spdx.Package2_2{ - spdx.ElementID("d8dccb186bafaf37"): { - PackageSPDXIdentifier: spdx.ElementID("d8dccb186bafaf37"), - PackageName: "acl", - PackageVersion: "2.2.53", - PackageLicenseConcluded: "GPLv2+", - PackageLicenseDeclared: "GPLv2+", - IsFilesAnalyzedTagPresent: true, - }, - spdx.ElementID("826226d056ff30c0"): { - PackageSPDXIdentifier: spdx.ElementID("826226d056ff30c0"), - PackageName: "actionpack", - PackageVersion: "7.0.1", - PackageLicenseConcluded: "NONE", - PackageLicenseDeclared: "NONE", - IsFilesAnalyzedTagPresent: true, - }, - }, - UnpackagedFiles: nil, - OtherLicenses: nil, - Relationships: nil, - Annotations: nil, - Reviews: nil, - }, - }, - { - name: "happy path for fs scan", - inputReport: types.Report{ - SchemaVersion: report.SchemaVersion, - ArtifactName: "masahiro331/CVE-2021-41098", - ArtifactType: ftypes.ArtifactFilesystem, - Results: types.Results{ - { - Target: "Gemfile.lock", - Class: types.ClassLangPkg, - Type: ftypes.Bundler, - Packages: []ftypes.Package{ - { - Name: "actioncable", - Version: "6.1.4.1", - }, - }, - }, - }, - }, - wantSBOM: &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: "SPDX-2.2", - DataLicense: "CC0-1.0", - SPDXIdentifier: "DOCUMENT", - DocumentName: "masahiro331/CVE-2021-41098", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/masahiro331/CVE-2021-41098-3ff14136-e09f-4df9-80ea-000000000001", - CreatorOrganizations: []string{"aquasecurity"}, - CreatorTools: []string{"trivy"}, - Created: "2021-08-25T12:20:30.000000005Z", - ExternalDocumentReferences: map[string]spdx.ExternalDocumentRef2_2{}, - }, - Packages: map[spdx.ElementID]*spdx.Package2_2{ - spdx.ElementID("3da61e86d0530402"): { - PackageSPDXIdentifier: spdx.ElementID("3da61e86d0530402"), - PackageName: "actioncable", - PackageVersion: "6.1.4.1", - PackageLicenseConcluded: "NONE", - PackageLicenseDeclared: "NONE", - IsFilesAnalyzedTagPresent: true, - }, - }, - }, - }, - { - name: "happy path aggregate results", - inputReport: types.Report{ - SchemaVersion: report.SchemaVersion, - ArtifactName: "test-aggregate", - ArtifactType: ftypes.ArtifactRemoteRepository, - Results: types.Results{ - { - Target: "Node.js", - Class: types.ClassLangPkg, - Type: ftypes.NodePkg, - Packages: []ftypes.Package{ - { - Name: "ruby-typeprof", - Version: "0.20.1", - Licenses: []string{"MIT"}, - Layer: ftypes.Layer{ - DiffID: "sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e", - }, - FilePath: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json", - }, - }, - }, - }, - }, - wantSBOM: &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: "SPDX-2.2", - DataLicense: "CC0-1.0", - SPDXIdentifier: "DOCUMENT", - DocumentName: "test-aggregate", - DocumentNamespace: "http://aquasecurity.github.io/trivy/repository/test-aggregate-3ff14136-e09f-4df9-80ea-000000000001", - CreatorOrganizations: []string{"aquasecurity"}, - CreatorTools: []string{"trivy"}, - Created: "2021-08-25T12:20:30.000000005Z", - ExternalDocumentReferences: map[string]spdx.ExternalDocumentRef2_2{}, - }, - Packages: map[spdx.ElementID]*spdx.Package2_2{ - spdx.ElementID("9f3d92e5ae2cadfb"): { - PackageSPDXIdentifier: spdx.ElementID("9f3d92e5ae2cadfb"), - PackageName: "ruby-typeprof", - PackageVersion: "0.20.1", - PackageLicenseConcluded: "MIT", - PackageLicenseDeclared: "MIT", - IsFilesAnalyzedTagPresent: true, - }, - }, - }, - }, - { - name: "happy path empty", - inputReport: types.Report{ - SchemaVersion: report.SchemaVersion, - ArtifactName: "empty/path", - ArtifactType: ftypes.ArtifactFilesystem, - Results: types.Results{}, - }, - wantSBOM: &spdx.Document2_2{ - CreationInfo: &spdx.CreationInfo2_2{ - SPDXVersion: "SPDX-2.2", - DataLicense: "CC0-1.0", - SPDXIdentifier: "DOCUMENT", - DocumentName: "empty/path", - DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/empty/path-3ff14136-e09f-4df9-80ea-000000000001", - CreatorOrganizations: []string{"aquasecurity"}, - CreatorTools: []string{"trivy"}, - Created: "2021-08-25T12:20:30.000000005Z", - ExternalDocumentReferences: map[string]spdx.ExternalDocumentRef2_2{}, - }, - Packages: nil, - }, - }, - } - - clock := fake.NewFakeClock(time.Date(2021, 8, 25, 12, 20, 30, 5, time.UTC)) - - for _, tc := range testCases { - t.Run(tc.name, func(t *testing.T) { - var count int - newUUID := func() uuid.UUID { - count++ - return uuid.Must(uuid.Parse(fmt.Sprintf("3ff14136-e09f-4df9-80ea-%012d", count))) - } - - // Fake function calculating the hash value - h := fnv.New64() - hasher := func(v interface{}, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) { - pkg, ok := v.(ftypes.Package) - if !ok { - require.Failf(t, "unknown type", "%T", v) - } - - h.Reset() - if _, err := h.Write([]byte(pkg.Name)); err != nil { - return 0, err - } - - return h.Sum64(), nil - } - - output := bytes.NewBuffer(nil) - writer := reportSpdx.NewWriter(output, "dev", "spdx-json", - reportSpdx.WithClock(clock), reportSpdx.WithNewUUID(newUUID), reportSpdx.WithHasher(hasher)) - - err := writer.Write(tc.inputReport) - require.NoError(t, err) - - got, err := jsonloader.Load2_2(output) - require.NoError(t, err) - - assert.Equal(t, *tc.wantSBOM, *got) - }) - } -} diff --git a/pkg/sbom/spdx/marshal.go b/pkg/sbom/spdx/marshal.go new file mode 100644 index 00000000000..ff07312341e --- /dev/null +++ b/pkg/sbom/spdx/marshal.go @@ -0,0 +1,402 @@ +package spdx + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/mitchellh/hashstructure/v2" + "github.com/spdx/tools-golang/spdx" + "golang.org/x/xerrors" + "k8s.io/utils/clock" + + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/purl" + "github.com/aquasecurity/trivy/pkg/scanner/utils" + "github.com/aquasecurity/trivy/pkg/types" +) + +const ( + SPDXVersion = "SPDX-2.2" + DataLicense = "CC0-1.0" + SPDXIdentifier = "DOCUMENT" + DocumentNamespace = "http://aquasecurity.github.io/trivy" + CreatorOrganization = "aquasecurity" + CreatorTool = "trivy" +) + +const ( + CategoryPackageManager = "PACKAGE-MANAGER" + RefTypePurl = "purl" + + PropertySchemaVersion = "SchemaVersion" + + // Image properties + PropertySize = "Size" + PropertyImageID = "ImageID" + PropertyRepoDigest = "RepoDigest" + PropertyDiffID = "DiffID" + PropertyRepoTag = "RepoTag" + + // Package properties + PropertyLayerDiffID = "LayerDiffID" + PropertyLayerDigest = "LayerDigest" + + RelationShipContains = "CONTAINS" + RelationShipDescribe = "DESCRIBE" + RelationShipDependsOn = "DEPENDS_ON" + + ElementOperatingSystem = "OperatingSystem" + ElementApplication = "Application" + ElementPackage = "Package" + ElementFile = "File" +) + +var ( + SourcePackagePrefix = "built package from" +) + +type Marshaler struct { + format spdx.Document2_1 + clock clock.Clock + newUUID newUUID + hasher Hash +} + +type Hash func(v interface{}, format hashstructure.Format, opts *hashstructure.HashOptions) (uint64, error) + +type newUUID func() uuid.UUID + +type marshalOption func(*Marshaler) + +func WithClock(clock clock.Clock) marshalOption { + return func(opts *Marshaler) { + opts.clock = clock + } +} + +func WithNewUUID(newUUID newUUID) marshalOption { + return func(opts *Marshaler) { + opts.newUUID = newUUID + } +} + +func WithHasher(hasher Hash) marshalOption { + return func(opts *Marshaler) { + opts.hasher = hasher + } +} + +func NewMarshaler(opts ...marshalOption) *Marshaler { + m := &Marshaler{ + format: spdx.Document2_1{}, + clock: clock.RealClock{}, + newUUID: uuid.New, + hasher: hashstructure.Hash, + } + + for _, opt := range opts { + opt(m) + } + + return m +} + +func (m *Marshaler) Marshal(r types.Report) (*spdx.Document2_2, error) { + var relationShips []*spdx.Relationship2_2 + packages := make(map[spdx.ElementID]*spdx.Package2_2) + + // Root package contains OS, OS packages, language-specific packages and so on. + rootPkg, err := m.rootPackage(r) + if err != nil { + return nil, xerrors.Errorf("failed to generate a root package: %w", err) + } + packages[rootPkg.PackageSPDXIdentifier] = rootPkg + relationShips = append(relationShips, + relationShip(SPDXIdentifier, rootPkg.PackageSPDXIdentifier, RelationShipDescribe), + ) + + for _, result := range r.Results { + parentPackage, err := m.resultToSpdxPackage(result, r.Metadata.OS) + if err != nil { + return nil, xerrors.Errorf("failed to parse result: %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, result.Class, r.Metadata, pkg) + if err != nil { + return nil, xerrors.Errorf("failed to parse package: %w", err) + } + packages[spdxPackage.PackageSPDXIdentifier] = &spdxPackage + relationShips = append(relationShips, + relationShip(parentPackage.PackageSPDXIdentifier, spdxPackage.PackageSPDXIdentifier, RelationShipContains), + ) + } + } + + return &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: SPDXVersion, + DataLicense: DataLicense, + SPDXIdentifier: SPDXIdentifier, + DocumentName: r.ArtifactName, + DocumentNamespace: getDocumentNamespace(r, m), + CreatorOrganizations: []string{CreatorOrganization}, + CreatorTools: []string{CreatorTool}, + Created: m.clock.Now().UTC().Format(time.RFC3339Nano), + }, + Packages: packages, + Relationships: relationShips, + }, nil +} + +func (m *Marshaler) resultToSpdxPackage(result types.Result, os *ftypes.OS) (spdx.Package2_2, error) { + switch result.Class { + case types.ClassOSPkg: + osPkg, err := m.osPackage(os) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to parse operating system package: %w", err) + } + return osPkg, nil + case types.ClassLangPkg: + langPkg, err := m.langPackage(result.Target, result.Type) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to parse application package: %w", err) + } + return langPkg, nil + default: + // unsupported packages + return spdx.Package2_2{}, nil + } +} + +func (m *Marshaler) parseFile(filePath string) (spdx.File2_2, error) { + pkgID, err := calcPkgID(m.hasher, filePath) + if err != nil { + return spdx.File2_2{}, xerrors.Errorf("failed to get %s package ID: %w", filePath, err) + } + file := spdx.File2_2{ + FileSPDXIdentifier: spdx.ElementID(fmt.Sprintf("File-%s", pkgID)), + FileName: filePath, + } + return file, nil +} + +func (m *Marshaler) rootPackage(r types.Report) (*spdx.Package2_2, error) { + var externalReferences []*spdx.PackageExternalReference2_2 + 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.NewPackageURL(purl.TypeOCI, r.Metadata, ftypes.Package{}); err != nil { + return nil, xerrors.Errorf("failed to new package url for oci: %w", err) + } else if p.Type != "" { + externalReferences = append(externalReferences, purlExternalReference(p.ToString())) + } + + 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)) + } + + 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)) + if err != nil { + return nil, xerrors.Errorf("failed to get %s package ID: %w", err) + } + + return &spdx.Package2_2{ + PackageName: r.ArtifactName, + PackageSPDXIdentifier: elementID(camelCase(string(r.ArtifactType)), pkgID), + PackageAttributionTexts: attributionTexts, + PackageExternalReferences: externalReferences, + }, nil +} + +func (m *Marshaler) osPackage(osFound *ftypes.OS) (spdx.Package2_2, error) { + if osFound == nil { + return spdx.Package2_2{}, nil + } + + pkgID, err := calcPkgID(m.hasher, osFound) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to get os metadata package ID: %w", err) + } + + return spdx.Package2_2{ + PackageName: osFound.Family, + PackageVersion: osFound.Name, + PackageSPDXIdentifier: elementID(ElementOperatingSystem, pkgID), + }, nil +} + +func (m *Marshaler) langPackage(target, appType string) (spdx.Package2_2, error) { + pkgID, err := calcPkgID(m.hasher, fmt.Sprintf("%s-%s", target, appType)) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to get %s package ID: %w", target, err) + } + + return spdx.Package2_2{ + PackageName: appType, + PackageSourceInfo: target, // TODO: Files seems better + PackageSPDXIdentifier: elementID(ElementApplication, pkgID), + }, nil +} + +func (m *Marshaler) pkgToSpdxPackage(t string, class types.ResultClass, metadata types.Metadata, pkg ftypes.Package) (spdx.Package2_2, error) { + license := getLicense(pkg) + + pkgID, err := calcPkgID(m.hasher, pkg) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to get %s package ID: %w", pkg.Name, err) + } + + var pkgSrcInfo string + if class == types.ClassOSPkg { + pkgSrcInfo = fmt.Sprintf("%s: %s %s", SourcePackagePrefix, pkg.SrcName, utils.FormatSrcVersion(pkg)) + } + + packageURL, err := purl.NewPackageURL(t, metadata, pkg) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("failed to parse purl (%s): %w", pkg.Name, err) + } + pkgExtRefs := []*spdx.PackageExternalReference2_2{purlExternalReference(packageURL.String())} + + var attrTexts []string + attrTexts = appendAttributionText(attrTexts, PropertyLayerDigest, pkg.Layer.Digest) + attrTexts = appendAttributionText(attrTexts, PropertyLayerDiffID, pkg.Layer.DiffID) + + files, err := m.pkgFiles(pkg) + if err != nil { + return spdx.Package2_2{}, xerrors.Errorf("package file error: %w", err) + } + + return spdx.Package2_2{ + PackageName: pkg.Name, + PackageVersion: pkg.Version, + PackageSPDXIdentifier: elementID(ElementPackage, pkgID), + PackageSourceInfo: pkgSrcInfo, + + // 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, + Files: files, + }, nil +} + +func (m *Marshaler) pkgFiles(pkg ftypes.Package) (map[spdx.ElementID]*spdx.File2_2, error) { + if pkg.FilePath == "" { + return nil, nil + } + + file, err := m.parseFile(pkg.FilePath) + if err != nil { + return nil, xerrors.Errorf("failed to parse file: %w") + } + return map[spdx.ElementID]*spdx.File2_2{ + file.FileSPDXIdentifier: &file, + }, nil +} + +func elementID(elementType, pkgID string) spdx.ElementID { + return spdx.ElementID(fmt.Sprintf("%s-%s", elementType, pkgID)) +} + +func relationShip(refA, refB spdx.ElementID, operator string) *spdx.Relationship2_2 { + ref := spdx.Relationship2_2{ + RefA: spdx.MakeDocElementID("", string(refA)), + RefB: spdx.MakeDocElementID("", string(refB)), + Relationship: operator, + } + return &ref +} + +func appendAttributionText(attributionTexts []string, key, value string) []string { + if value == "" { + return attributionTexts + } + return append(attributionTexts, attributionText(key, value)) +} + +func attributionText(key, value string) string { + return fmt.Sprintf("%s: %s", key, value) +} + +func purlExternalReference(packageURL string) *spdx.PackageExternalReference2_2 { + return &spdx.PackageExternalReference2_2{ + Category: CategoryPackageManager, + RefType: RefTypePurl, + Locator: packageURL, + } +} + +func getLicense(p ftypes.Package) string { + if len(p.Licenses) == 0 { + return "NONE" + } + + return strings.Join(p.Licenses, ", ") +} + +func getDocumentNamespace(r types.Report, m *Marshaler) string { + return fmt.Sprintf("%s/%s/%s-%s", + DocumentNamespace, + string(r.ArtifactType), + r.ArtifactName, + m.newUUID().String(), + ) +} + +func calcPkgID(h Hash, v interface{}) (string, error) { + f, err := h(v, hashstructure.FormatV2, &hashstructure.HashOptions{ + ZeroNil: true, + SlicesAsSets: true, + }) + if err != nil { + return "", xerrors.Errorf("could not build package ID for %+v: %w", v, err) + } + + return fmt.Sprintf("%x", f), nil +} + +func camelCase(inputUnderScoreStr string) (camelCase string) { + isToUpper := false + for k, v := range inputUnderScoreStr { + if k == 0 { + camelCase = strings.ToUpper(string(inputUnderScoreStr[0])) + } else { + if isToUpper { + camelCase += strings.ToUpper(string(v)) + isToUpper = false + } else { + if v == '_' { + isToUpper = true + } else { + camelCase += string(v) + } + } + } + } + return +} diff --git a/pkg/sbom/spdx/marshal_test.go b/pkg/sbom/spdx/marshal_test.go new file mode 100644 index 00000000000..e01bc249432 --- /dev/null +++ b/pkg/sbom/spdx/marshal_test.go @@ -0,0 +1,688 @@ +package spdx_test + +import ( + "fmt" + "hash/fnv" + "testing" + "time" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/uuid" + "github.com/mitchellh/hashstructure/v2" + "github.com/spdx/tools-golang/spdx" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + fake "k8s.io/utils/clock/testing" + + fos "github.com/aquasecurity/trivy/pkg/fanal/analyzer/os" + ftypes "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/aquasecurity/trivy/pkg/report" + tspdx "github.com/aquasecurity/trivy/pkg/sbom/spdx" + "github.com/aquasecurity/trivy/pkg/types" +) + +func TestMarshaler_Marshal(t *testing.T) { + testCases := []struct { + name string + inputReport types.Report + wantSBOM *spdx.Document2_2 + }{ + { + name: "happy path for container scan", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "rails:latest", + ArtifactType: ftypes.ArtifactContainerImage, + Metadata: types.Metadata{ + Size: 1024, + OS: &ftypes.OS{ + Family: fos.CentOS, + Name: "8.3.2011", + Eosl: true, + }, + ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + RepoTags: []string{"rails:latest"}, + DiffIDs: []string{"sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a"}, + RepoDigests: []string{"rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177"}, + ImageConfig: v1.ConfigFile{ + Architecture: "arm64", + }, + }, + Results: types.Results{ + { + Target: "rails:latest (centos 8.3.2011)", + Class: types.ClassOSPkg, + Type: fos.CentOS, + Packages: []ftypes.Package{ + { + Name: "binutils", + Version: "2.30", + Release: "93.el8", + Epoch: 0, + Arch: "aarch64", + SrcName: "binutils", + SrcVersion: "2.30", + SrcRelease: "93.el8", + SrcEpoch: 0, + Modularitylabel: "", + Licenses: []string{"GPLv3+"}, + }, + }, + }, + { + Target: "app/subproject/Gemfile.lock", + Class: types.ClassLangPkg, + Type: ftypes.Bundler, + Packages: []ftypes.Package{ + { + Name: "actionpack", + Version: "7.0.1", + }, + { + Name: "actioncontroller", + Version: "7.0.1", + }, + }, + }, + { + Target: "app/Gemfile.lock", + Class: types.ClassLangPkg, + Type: ftypes.Bundler, + Packages: []ftypes.Package{ + { + Name: "actionpack", + Version: "7.0.1", + }, + }, + }, + }, + }, + wantSBOM: &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: "SPDX-2.2", + DataLicense: "CC0-1.0", + SPDXIdentifier: "DOCUMENT", + DocumentName: "rails:latest", + DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/rails:latest-3ff14136-e09f-4df9-80ea-000000000001", + CreatorOrganizations: []string{"aquasecurity"}, + CreatorTools: []string{"trivy"}, + Created: "2021-08-25T12:20:30.000000005Z", + }, + Packages: map[spdx.ElementID]*spdx.Package2_2{ + spdx.ElementID("ContainerImage-9396d894cd0cb6cb"): { + PackageSPDXIdentifier: spdx.ElementID("ContainerImage-9396d894cd0cb6cb"), + PackageName: "rails:latest", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:oci/rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177?repository_url=index.docker.io%2Flibrary%2Frails&arch=arm64", + }, + }, + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + "Size: 1024", + "RepoDigest: rails@sha256:a27fd8080b517143cbbbab9dfb7c8571c40d67d534bbdee55bd6c473f432b177", + "DiffID: sha256:d871dadfb37b53ef1ca45be04fc527562b91989991a8f545345ae3be0b93f92a", + "RepoTag: rails:latest", + }, + }, + spdx.ElementID("Application-73c871d73f3c8248"): { + PackageSPDXIdentifier: spdx.ElementID("Application-73c871d73f3c8248"), + PackageName: "bundler", + PackageSourceInfo: "app/subproject/Gemfile.lock", + }, + spdx.ElementID("Application-c3fac92c1ac0a9fa"): { + PackageSPDXIdentifier: spdx.ElementID("Application-c3fac92c1ac0a9fa"), + PackageName: "bundler", + PackageSourceInfo: "app/Gemfile.lock", + }, + spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"): { + PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"), + PackageName: "centos", + PackageVersion: "8.3.2011", + }, + + spdx.ElementID("Package-eb0263038c3b445b"): { + PackageSPDXIdentifier: spdx.ElementID("Package-eb0263038c3b445b"), + PackageName: "actioncontroller", + PackageVersion: "7.0.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actioncontroller@7.0.1", + }, + }, + }, + spdx.ElementID("Package-826226d056ff30c0"): { + PackageSPDXIdentifier: spdx.ElementID("Package-826226d056ff30c0"), + PackageName: "actionpack", + PackageVersion: "7.0.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actionpack@7.0.1", + }, + }, + }, + spdx.ElementID("Package-fd0dc3cf913d5bc3"): { + PackageSPDXIdentifier: spdx.ElementID("Package-fd0dc3cf913d5bc3"), + PackageName: "binutils", + PackageVersion: "2.30", + PackageLicenseConcluded: "GPLv3+", + PackageLicenseDeclared: "GPLv3+", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:rpm/centos/binutils@2.30-93.el8?arch=aarch64&distro=centos-8.3.2011", + }, + }, + PackageSourceInfo: "built package from: binutils 2.30-93.el8", + }, + }, + Relationships: []*spdx.Relationship2_2{ + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + Relationship: "DESCRIBE", + }, + { + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + RefB: spdx.DocElementID{ElementRefID: "Package-fd0dc3cf913d5bc3"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + RefB: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, + RefB: spdx.DocElementID{ElementRefID: "Package-826226d056ff30c0"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-73c871d73f3c8248"}, + RefB: spdx.DocElementID{ElementRefID: "Package-eb0263038c3b445b"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-9396d894cd0cb6cb"}, + RefB: spdx.DocElementID{ElementRefID: "Application-c3fac92c1ac0a9fa"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-c3fac92c1ac0a9fa"}, + RefB: spdx.DocElementID{ElementRefID: "Package-826226d056ff30c0"}, + Relationship: "CONTAINS", + }, + }, + UnpackagedFiles: nil, + OtherLicenses: nil, + Annotations: nil, + Reviews: nil, + }, + }, + { + name: "happy path for local container scan", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "centos:latest", + ArtifactType: ftypes.ArtifactContainerImage, + Metadata: types.Metadata{ + Size: 1024, + OS: &ftypes.OS{ + Family: fos.CentOS, + Name: "8.3.2011", + Eosl: true, + }, + ImageID: "sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + RepoTags: []string{"centos:latest"}, + RepoDigests: []string{}, + ImageConfig: v1.ConfigFile{ + Architecture: "arm64", + }, + }, + Results: types.Results{ + { + Target: "centos:latest (centos 8.3.2011)", + Class: types.ClassOSPkg, + Type: fos.CentOS, + Packages: []ftypes.Package{ + { + Name: "acl", + Version: "2.2.53", + Release: "1.el8", + Epoch: 1, + Arch: "aarch64", + SrcName: "acl", + SrcVersion: "2.2.53", + SrcRelease: "1.el8", + SrcEpoch: 1, + Modularitylabel: "", + Licenses: []string{"GPLv2+"}, + }, + }, + }, + { + Target: "Ruby", + Class: types.ClassLangPkg, + Type: ftypes.GemSpec, + Packages: []ftypes.Package{ + { + Name: "actionpack", + Version: "7.0.1", + Layer: ftypes.Layer{ + DiffID: "sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + }, + FilePath: "tools/project-john/specifications/actionpack.gemspec", + }, + { + Name: "actionpack", + Version: "7.0.1", + Layer: ftypes.Layer{ + DiffID: "sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + }, + FilePath: "tools/project-doe/specifications/actionpack.gemspec", + }, + }, + }, + }, + }, + wantSBOM: &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: "SPDX-2.2", + DataLicense: "CC0-1.0", + SPDXIdentifier: "DOCUMENT", + DocumentName: "centos:latest", + DocumentNamespace: "http://aquasecurity.github.io/trivy/container_image/centos:latest-3ff14136-e09f-4df9-80ea-000000000001", + CreatorOrganizations: []string{"aquasecurity"}, + CreatorTools: []string{"trivy"}, + Created: "2021-08-25T12:20:30.000000005Z", + }, + Packages: map[spdx.ElementID]*spdx.Package2_2{ + spdx.ElementID("ContainerImage-413bfede37ad01fc"): { + PackageName: "centos:latest", + PackageSPDXIdentifier: "ContainerImage-413bfede37ad01fc", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + "ImageID: sha256:5d0da3dc976460b72c77d94c8a1ad043720b0416bfc16c52c45d4847e53fadb6", + "Size: 1024", + "RepoTag: centos:latest", + }, + }, + spdx.ElementID("Application-441a648f2aeeee72"): { + PackageSPDXIdentifier: spdx.ElementID("Application-441a648f2aeeee72"), + PackageName: "gemspec", + PackageSourceInfo: "Ruby", + }, + spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"): { + PackageSPDXIdentifier: spdx.ElementID("OperatingSystem-197f9a00ebcb51f0"), + PackageName: "centos", + PackageVersion: "8.3.2011", + }, + spdx.ElementID("Package-d8dccb186bafaf37"): { + PackageSPDXIdentifier: spdx.ElementID("Package-d8dccb186bafaf37"), + PackageName: "acl", + PackageVersion: "2.2.53", + PackageLicenseConcluded: "GPLv2+", + PackageLicenseDeclared: "GPLv2+", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:rpm/centos/acl@1:2.2.53-1.el8?arch=aarch64&distro=centos-8.3.2011", + }, + }, + PackageSourceInfo: "built package from: acl 1:2.2.53-1.el8", + }, + spdx.ElementID("Package-13fe667a0805e6b7"): { + PackageSPDXIdentifier: spdx.ElementID("Package-13fe667a0805e6b7"), + PackageName: "actionpack", + PackageVersion: "7.0.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actionpack@7.0.1", + }, + }, + PackageAttributionTexts: []string{ + "LayerDiffID: sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + }, + Files: map[spdx.ElementID]*spdx.File2_2{ + "File-fa42187221d0d0a8": { + FileSPDXIdentifier: "File-fa42187221d0d0a8", + FileName: "tools/project-doe/specifications/actionpack.gemspec", + }, + }, + }, + spdx.ElementID("Package-d5443dbcbba0dbd4"): { + PackageSPDXIdentifier: spdx.ElementID("Package-d5443dbcbba0dbd4"), + PackageName: "actionpack", + PackageVersion: "7.0.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actionpack@7.0.1", + }, + }, + PackageAttributionTexts: []string{ + "LayerDiffID: sha256:ccb64cf0b7ba2e50741d0b64cae324eb5de3b1e2f580bbf177e721b67df38488", + }, + Files: map[spdx.ElementID]*spdx.File2_2{ + "File-6a540784b0dc6d55": { + FileSPDXIdentifier: "File-6a540784b0dc6d55", + FileName: "tools/project-john/specifications/actionpack.gemspec", + }, + }, + }, + }, + Relationships: []*spdx.Relationship2_2{ + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, + Relationship: "DESCRIBE", + }, + { + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, + RefB: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "OperatingSystem-197f9a00ebcb51f0"}, + RefB: spdx.DocElementID{ElementRefID: "Package-d8dccb186bafaf37"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "ContainerImage-413bfede37ad01fc"}, + RefB: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, + RefB: spdx.DocElementID{ElementRefID: "Package-d5443dbcbba0dbd4"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-441a648f2aeeee72"}, + RefB: spdx.DocElementID{ElementRefID: "Package-13fe667a0805e6b7"}, + Relationship: "CONTAINS", + }, + }, + + UnpackagedFiles: nil, + OtherLicenses: nil, + Annotations: nil, + Reviews: nil, + }, + }, + { + name: "happy path for fs scan", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "masahiro331/CVE-2021-41098", + ArtifactType: ftypes.ArtifactFilesystem, + Results: types.Results{ + { + Target: "Gemfile.lock", + Class: types.ClassLangPkg, + Type: ftypes.Bundler, + Packages: []ftypes.Package{ + { + Name: "actioncable", + Version: "6.1.4.1", + }, + }, + }, + }, + }, + wantSBOM: &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: "SPDX-2.2", + DataLicense: "CC0-1.0", + SPDXIdentifier: "DOCUMENT", + DocumentName: "masahiro331/CVE-2021-41098", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/masahiro331/CVE-2021-41098-3ff14136-e09f-4df9-80ea-000000000001", + CreatorOrganizations: []string{"aquasecurity"}, + CreatorTools: []string{"trivy"}, + Created: "2021-08-25T12:20:30.000000005Z", + }, + Packages: map[spdx.ElementID]*spdx.Package2_2{ + spdx.ElementID("Filesystem-5af0f1f08c20909a"): { + PackageSPDXIdentifier: spdx.ElementID("Filesystem-5af0f1f08c20909a"), + PackageName: "masahiro331/CVE-2021-41098", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + }, + }, + spdx.ElementID("Application-9dd4a4ba7077cc5a"): { + PackageSPDXIdentifier: spdx.ElementID("Application-9dd4a4ba7077cc5a"), + PackageName: "bundler", + PackageSourceInfo: "Gemfile.lock", + }, + spdx.ElementID("Package-3da61e86d0530402"): { + PackageSPDXIdentifier: spdx.ElementID("Package-3da61e86d0530402"), + PackageName: "actioncable", + PackageVersion: "6.1.4.1", + PackageLicenseConcluded: "NONE", + PackageLicenseDeclared: "NONE", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:gem/actioncable@6.1.4.1", + }, + }, + }, + }, + Relationships: []*spdx.Relationship2_2{ + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "Filesystem-5af0f1f08c20909a"}, + Relationship: "DESCRIBE", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Filesystem-5af0f1f08c20909a"}, + RefB: spdx.DocElementID{ElementRefID: "Application-9dd4a4ba7077cc5a"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-9dd4a4ba7077cc5a"}, + RefB: spdx.DocElementID{ElementRefID: "Package-3da61e86d0530402"}, + Relationship: "CONTAINS", + }, + }, + }, + }, + { + name: "happy path aggregate results", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "test-aggregate", + ArtifactType: ftypes.ArtifactRemoteRepository, + Results: types.Results{ + { + Target: "Node.js", + Class: types.ClassLangPkg, + Type: ftypes.NodePkg, + Packages: []ftypes.Package{ + { + Name: "ruby-typeprof", + Version: "0.20.1", + Licenses: []string{"MIT"}, + Layer: ftypes.Layer{ + DiffID: "sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e", + }, + FilePath: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json", + }, + }, + }, + }, + }, + wantSBOM: &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: "SPDX-2.2", + DataLicense: "CC0-1.0", + SPDXIdentifier: "DOCUMENT", + DocumentName: "test-aggregate", + DocumentNamespace: "http://aquasecurity.github.io/trivy/repository/test-aggregate-3ff14136-e09f-4df9-80ea-000000000001", + CreatorOrganizations: []string{"aquasecurity"}, + CreatorTools: []string{"trivy"}, + Created: "2021-08-25T12:20:30.000000005Z", + }, + Packages: map[spdx.ElementID]*spdx.Package2_2{ + spdx.ElementID("Repository-7cb7a269a391a798"): { + PackageName: "test-aggregate", + PackageSPDXIdentifier: "Repository-7cb7a269a391a798", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + }, + }, + spdx.ElementID("Application-24f8a80152e2c0fc"): { + PackageSPDXIdentifier: "Application-24f8a80152e2c0fc", + PackageName: "node-pkg", + PackageSourceInfo: "Node.js", + }, + spdx.ElementID("Package-daedb173cfd43058"): { + PackageSPDXIdentifier: spdx.ElementID("Package-daedb173cfd43058"), + PackageName: "ruby-typeprof", + PackageVersion: "0.20.1", + PackageLicenseConcluded: "MIT", + PackageLicenseDeclared: "MIT", + PackageExternalReferences: []*spdx.PackageExternalReference2_2{ + { + Category: tspdx.CategoryPackageManager, + RefType: tspdx.RefTypePurl, + Locator: "pkg:npm/ruby-typeprof@0.20.1", + }, + }, + PackageAttributionTexts: []string{ + "LayerDiffID: sha256:661c3fd3cc16b34c070f3620ca6b03b6adac150f9a7e5d0e3c707a159990f88e", + }, + Files: map[spdx.ElementID]*spdx.File2_2{ + "File-a52825a3e5bc6dfe": { + FileName: "usr/local/lib/ruby/gems/3.1.0/gems/typeprof-0.21.1/vscode/package.json", + FileSPDXIdentifier: "File-a52825a3e5bc6dfe", + }, + }, + }, + }, + Relationships: []*spdx.Relationship2_2{ + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "Repository-7cb7a269a391a798"}, + Relationship: "DESCRIBE", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Repository-7cb7a269a391a798"}, + RefB: spdx.DocElementID{ElementRefID: "Application-24f8a80152e2c0fc"}, + Relationship: "CONTAINS", + }, + { + RefA: spdx.DocElementID{ElementRefID: "Application-24f8a80152e2c0fc"}, + RefB: spdx.DocElementID{ElementRefID: "Package-daedb173cfd43058"}, + Relationship: "CONTAINS", + }, + }, + }, + }, + { + name: "happy path empty", + inputReport: types.Report{ + SchemaVersion: report.SchemaVersion, + ArtifactName: "empty/path", + ArtifactType: ftypes.ArtifactFilesystem, + Results: types.Results{}, + }, + wantSBOM: &spdx.Document2_2{ + CreationInfo: &spdx.CreationInfo2_2{ + SPDXVersion: "SPDX-2.2", + DataLicense: "CC0-1.0", + SPDXIdentifier: "DOCUMENT", + DocumentName: "empty/path", + DocumentNamespace: "http://aquasecurity.github.io/trivy/filesystem/empty/path-3ff14136-e09f-4df9-80ea-000000000001", + CreatorOrganizations: []string{"aquasecurity"}, + CreatorTools: []string{"trivy"}, + Created: "2021-08-25T12:20:30.000000005Z", + }, + Packages: map[spdx.ElementID]*spdx.Package2_2{ + spdx.ElementID("Filesystem-70f34983067dba86"): { + PackageName: "empty/path", + PackageSPDXIdentifier: "Filesystem-70f34983067dba86", + PackageAttributionTexts: []string{ + "SchemaVersion: 2", + }, + }, + }, + Relationships: []*spdx.Relationship2_2{ + { + RefA: spdx.DocElementID{ElementRefID: "DOCUMENT"}, + RefB: spdx.DocElementID{ElementRefID: "Filesystem-70f34983067dba86"}, + Relationship: "DESCRIBE", + }, + }, + }, + }, + } + + clock := fake.NewFakeClock(time.Date(2021, 8, 25, 12, 20, 30, 5, time.UTC)) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var count int + newUUID := func() uuid.UUID { + count++ + return uuid.Must(uuid.Parse(fmt.Sprintf("3ff14136-e09f-4df9-80ea-%012d", count))) + } + + // Fake function calculating the hash value + h := fnv.New64() + hasher := func(v interface{}, 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 + case string: + str = v.(string) + case *ftypes.OS: + str = v.(*ftypes.OS).Name + default: + require.Failf(t, "unknown type", "%T", v) + } + + if _, err := h.Write([]byte(str)); err != nil { + return 0, err + } + + return h.Sum64(), nil + } + + marshaler := tspdx.NewMarshaler(tspdx.WithClock(clock), tspdx.WithNewUUID(newUUID), tspdx.WithHasher(hasher)) + spdxDoc, err := marshaler.Marshal(tc.inputReport) + require.NoError(t, err) + + assert.Equal(t, tc.wantSBOM, spdxDoc) + }) + } +}