From 0ce95830c87b0324b14587fcf425a5e28738586e Mon Sep 17 00:00:00 2001 From: Carol Valencia <8355621+krol3@users.noreply.github.com> Date: Wed, 14 Sep 2022 03:20:57 -0300 Subject: [PATCH 1/4] docs: azure doc and trivy (#2869) Co-authored-by: carolina valencia --- docs/docs/integrations/azure-devops.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/docs/integrations/azure-devops.md b/docs/docs/integrations/azure-devops.md index 17535ccee84..1fbac0e848d 100644 --- a/docs/docs/integrations/azure-devops.md +++ b/docs/docs/integrations/azure-devops.md @@ -4,6 +4,11 @@ ![trivy-azure](https://github.com/aquasecurity/trivy-azure-pipelines-task/blob/main/screenshot.png?raw=true) +### [Use ImageCleaner to clean up stale images on your Azure Kubernetes Service cluster][azure2] + +It's common to use pipelines to build and deploy images on Azure Kubernetes Service (AKS) clusters. While great for image creation, this process often doesn't account for the stale images left behind and can lead to image bloat on cluster nodes. These images can present security issues as they may contain vulnerabilities. By cleaning these unreferenced images, you can remove an area of risk in your clusters. When done manually, this process can be time intensive, which ImageCleaner can mitigate via automatic image identification and removal. + + Vulnerability is determined based on a trivy scan, after which images with a LOW, MEDIUM, HIGH, or CRITICAL classification are flagged. An updated ImageList will be automatically generated by ImageCleaner based on a set time interval, and can also be supplied manually. ### [Microsoft Defender for container registries and Trivy][azure] This blog explains how to scan your Azure Container Registry-based container images with the integrated vulnerability scanner when they're built as part of your GitHub workflows. @@ -14,4 +19,4 @@ The findings of the CI/CD scans are an enrichment to the existing registry scan [action]: https://github.com/aquasecurity/trivy-azure-pipelines-task [azure]: https://docs.microsoft.com/en-us/azure/defender-for-cloud/defender-for-containers-cicd - +[azure2]: https://docs.microsoft.com/en-us/azure/aks/image-cleaner?tabs=azure-cli From 39f83afefefada16d356e9fdc3d07faa9c323c13 Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Wed, 14 Sep 2022 11:41:55 +0300 Subject: [PATCH 2/4] chore: bump Go to 1.19 (#2861) Signed-off-by: knqyf263 --- .github/workflows/reusable-release.yaml | 5 ++- .github/workflows/test.yaml | 35 +++++++++---------- .golangci.yaml | 7 ++-- Dockerfile.protoc | 2 +- Makefile | 2 +- go.mod | 2 +- pkg/fanal/analyzer/pkg/rpm/rpm.go | 10 +++--- pkg/fanal/artifact/sbom/sbom.go | 4 +-- pkg/fanal/test/integration/library_test.go | 8 +++++ .../packages/opensuse-leap-151.json.golden | 12 +++---- .../packages/suse-15.3_ndb.json.golden | 8 ++--- pkg/flag/cache_flags.go | 17 ++++----- pkg/flag/misconf_flags.go | 11 +++--- pkg/flag/options.go | 2 +- pkg/flag/report_flags.go | 11 +++--- pkg/k8s/commands/run.go | 5 +-- pkg/sbom/cyclonedx/unmarshal.go | 19 +++++----- 17 files changed, 82 insertions(+), 78 deletions(-) diff --git a/.github/workflows/reusable-release.yaml b/.github/workflows/reusable-release.yaml index 0db71c1ccad..47cc3d3941e 100644 --- a/.github/workflows/reusable-release.yaml +++ b/.github/workflows/reusable-release.yaml @@ -13,7 +13,6 @@ on: type: string env: - GO_VERSION: "1.18" GH_USER: "aqua-bot" jobs: @@ -63,7 +62,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v3 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod - name: Checkout code uses: actions/checkout@v3 @@ -106,4 +105,4 @@ jobs: # use 'github.sha' to create a unique cache folder for each run. # use 'github.workflow' to create a unique cache folder if some runs have same commit sha. # e.g. build and release runs - key: ${{ runner.os }}-bins-${{github.workflow}}-${{github.sha}} \ No newline at end of file + key: ${{ runner.os }}-bins-${{github.workflow}}-${{github.sha}} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index bd0f3353b45..7da575afb19 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,8 +10,7 @@ on: - 'LICENSE' pull_request: env: - GO_VERSION: "1.18" - TINYGO_VERSION: "0.24.0" + TINYGO_VERSION: "0.25.0" jobs: test: name: Test @@ -22,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod - name: go mod tidy run: | @@ -35,7 +34,7 @@ jobs: - name: Lint uses: golangci/golangci-lint-action@v3.2.0 with: - version: v1.45 + version: v1.49 args: --deadline=30m skip-cache: true # https://github.com/golangci/golangci-lint-action/issues/244#issuecomment-1052197778 @@ -51,36 +50,34 @@ jobs: name: Integration Test runs-on: ubuntu-latest steps: - - name: Set up Go - uses: actions/setup-go@v3 - with: - go-version: ${{ env.GO_VERSION }} - id: go + - name: Check out code into the Go module directory + uses: actions/checkout@v3 - - name: Check out code into the Go module directory - uses: actions/checkout@v3 + - name: Set up Go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod - - name: Run integration tests - run: make test-integration + - name: Run integration tests + run: make test-integration module-test: name: Module Integration Test runs-on: ubuntu-latest steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Set up Go uses: actions/setup-go@v3 with: - go-version: ${{ env.GO_VERSION }} - id: go + go-version-file: go.mod - name: Install TinyGo run: | wget https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_amd64.deb sudo dpkg -i tinygo_${TINYGO_VERSION}_amd64.deb - - name: Checkout - uses: actions/checkout@v3 - - name: Run module integration tests run: | make test-module-integration @@ -107,7 +104,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: ${{ env.GO_VERSION }} + go-version-file: go.mod - name: Run GoReleaser uses: goreleaser/goreleaser-action@v3 diff --git a/.golangci.yaml b/.golangci.yaml index 844a86c0269..bcdaafec3a3 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -21,18 +21,17 @@ linters-settings: local-prefixes: github.com/aquasecurity gosec: excludes: + - G114 - G204 - G402 linters: disable-all: true enable: - - structcheck + - unused - ineffassign - typecheck - govet - - varcheck - - deadcode - revive - gosec - unconvert @@ -43,7 +42,7 @@ linters: - misspell run: - go: 1.18 + go: 1.19 skip-files: - ".*._mock.go$" - ".*._test.go$" diff --git a/Dockerfile.protoc b/Dockerfile.protoc index 791d6185d2c..88671e805ff 100644 --- a/Dockerfile.protoc +++ b/Dockerfile.protoc @@ -1,4 +1,4 @@ -FROM golang:1.18.4 +FROM golang:1.19.0 # Install protoc (cf. http://google.github.io/proto-lens/installing-protoc.html) ENV PROTOC_ZIP=protoc-3.19.4-linux-x86_64.zip diff --git a/Makefile b/Makefile index 8a81662f199..6f8c498b710 100644 --- a/Makefile +++ b/Makefile @@ -26,7 +26,7 @@ $(GOBIN)/crane: go install github.com/google/go-containerregistry/cmd/crane@v0.9.0 $(GOBIN)/golangci-lint: - curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOBIN) v1.45.2 + curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(GOBIN) v1.49.0 $(GOBIN)/labeler: go install github.com/knqyf263/labeler@latest diff --git a/go.mod b/go.mod index beeaa91a270..15dec9f3c00 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/aquasecurity/trivy -go 1.18 +go 1.19 require ( github.com/CycloneDX/cyclonedx-go v0.6.0 diff --git a/pkg/fanal/analyzer/pkg/rpm/rpm.go b/pkg/fanal/analyzer/pkg/rpm/rpm.go index f35b430e64d..c4431a918a9 100644 --- a/pkg/fanal/analyzer/pkg/rpm/rpm.go +++ b/pkg/fanal/analyzer/pkg/rpm/rpm.go @@ -160,10 +160,12 @@ func (a rpmPkgAnalyzer) parsePkgInfo(rc io.Reader) ([]types.Package, []string, e return pkgs, installedFiles, nil } -// splitFileName returns a name, version, release, epoch, arch -// e.g. -// foo-1.0-1.i386.rpm returns foo, 1.0, 1, i386 -// 1:bar-9-123a.ia64.rpm returns bar, 9, 123a, 1, ia64 +// splitFileName returns a name, version, release, epoch, arch: +// +// e.g. +// foo-1.0-1.i386.rpm => foo, 1.0, 1, i386 +// 1:bar-9-123a.ia64.rpm => bar, 9, 123a, 1, ia64 +// // https://github.com/rpm-software-management/yum/blob/043e869b08126c1b24e392f809c9f6871344c60d/rpmUtils/miscutils.py#L301 func splitFileName(filename string) (name, ver, rel string, err error) { if strings.HasSuffix(filename, ".rpm") { diff --git a/pkg/fanal/artifact/sbom/sbom.go b/pkg/fanal/artifact/sbom/sbom.go index d20615d5eb9..bc5310d98f3 100644 --- a/pkg/fanal/artifact/sbom/sbom.go +++ b/pkg/fanal/artifact/sbom/sbom.go @@ -13,7 +13,6 @@ import ( "github.com/aquasecurity/trivy/pkg/attestation" "github.com/aquasecurity/trivy/pkg/fanal/analyzer" - "github.com/aquasecurity/trivy/pkg/fanal/analyzer/config" "github.com/aquasecurity/trivy/pkg/fanal/artifact" "github.com/aquasecurity/trivy/pkg/fanal/cache" "github.com/aquasecurity/trivy/pkg/fanal/handler" @@ -29,8 +28,7 @@ type Artifact struct { analyzer analyzer.AnalyzerGroup handlerManager handler.Manager - artifactOption artifact.Option - configScannerOption config.ScannerOption + artifactOption artifact.Option } func NewArtifact(filePath string, c cache.ArtifactCache, opt artifact.Option) (artifact.Artifact, error) { diff --git a/pkg/fanal/test/integration/library_test.go b/pkg/fanal/test/integration/library_test.go index df496704de1..7c1eec215a5 100644 --- a/pkg/fanal/test/integration/library_test.go +++ b/pkg/fanal/test/integration/library_test.go @@ -241,6 +241,14 @@ func commonChecks(t *testing.T, detail types.ArtifactDetail, tc testCase) { } func checkOSPackages(t *testing.T, detail types.ArtifactDetail, tc testCase) { + // Sort OS packages for consistency + sort.Slice(detail.Packages, func(i, j int) bool { + if detail.Packages[i].Name != detail.Packages[j].Name { + return detail.Packages[i].Name < detail.Packages[j].Name + } + return detail.Packages[i].Version < detail.Packages[j].Version + }) + splitted := strings.Split(tc.remoteImageName, ":") goldenFile := fmt.Sprintf("testdata/goldens/packages/%s.json.golden", splitted[len(splitted)-1]) diff --git a/pkg/fanal/test/integration/testdata/goldens/packages/opensuse-leap-151.json.golden b/pkg/fanal/test/integration/testdata/goldens/packages/opensuse-leap-151.json.golden index 9209f6f2040..f86b539200c 100644 --- a/pkg/fanal/test/integration/testdata/goldens/packages/opensuse-leap-151.json.golden +++ b/pkg/fanal/test/integration/testdata/goldens/packages/opensuse-leap-151.json.golden @@ -209,8 +209,8 @@ }, { "Name": "gpg-pubkey", - "Version": "3dbdc284", - "Release": "53674dd4", + "Version": "307e3d54", + "Release": "5aaa90a5", "Arch": "None", "License": "pubkey", "Layer": { @@ -219,8 +219,8 @@ }, { "Name": "gpg-pubkey", - "Version": "307e3d54", - "Release": "5aaa90a5", + "Version": "39db7c82", + "Release": "5847eb1f", "Arch": "None", "License": "pubkey", "Layer": { @@ -229,8 +229,8 @@ }, { "Name": "gpg-pubkey", - "Version": "39db7c82", - "Release": "5847eb1f", + "Version": "3dbdc284", + "Release": "53674dd4", "Arch": "None", "License": "pubkey", "Layer": { diff --git a/pkg/fanal/test/integration/testdata/goldens/packages/suse-15.3_ndb.json.golden b/pkg/fanal/test/integration/testdata/goldens/packages/suse-15.3_ndb.json.golden index f697d356845..0b3f4f1acd2 100644 --- a/pkg/fanal/test/integration/testdata/goldens/packages/suse-15.3_ndb.json.golden +++ b/pkg/fanal/test/integration/testdata/goldens/packages/suse-15.3_ndb.json.golden @@ -209,8 +209,8 @@ }, { "Name": "gpg-pubkey", - "Version": "39db7c82", - "Release": "5f68629b", + "Version": "307e3d54", + "Release": "5aaa90a5", "Arch": "None", "License": "pubkey", "Layer": { @@ -219,8 +219,8 @@ }, { "Name": "gpg-pubkey", - "Version": "307e3d54", - "Release": "5aaa90a5", + "Version": "39db7c82", + "Release": "5f68629b", "Arch": "None", "License": "pubkey", "Layer": { diff --git a/pkg/flag/cache_flags.go b/pkg/flag/cache_flags.go index d4aad96bd5a..e37f040f778 100644 --- a/pkg/flag/cache_flags.go +++ b/pkg/flag/cache_flags.go @@ -9,14 +9,15 @@ import ( "golang.org/x/xerrors" ) -// e.g. config yaml -// cache: -// clear: true -// backend: "redis://localhost:6379" -// redis: -// ca: ca-cert.pem -// cert: cert.pem -// key: key.pem +// e.g. config yaml: +// +// cache: +// clear: true +// backend: "redis://localhost:6379" +// redis: +// ca: ca-cert.pem +// cert: cert.pem +// key: key.pem var ( ClearCacheFlag = Flag{ Name: "clear-cache", diff --git a/pkg/flag/misconf_flags.go b/pkg/flag/misconf_flags.go index 69356d18e82..2281de2c69e 100644 --- a/pkg/flag/misconf_flags.go +++ b/pkg/flag/misconf_flags.go @@ -4,11 +4,12 @@ import ( "github.com/aquasecurity/trivy/pkg/log" ) -// e.g. config yaml -// misconfiguration: -// trace: true -// config-policy: "custom-policy/policy" -// policy-namespaces: "user" +// e.g. config yaml: +// +// misconfiguration: +// trace: true +// config-policy: "custom-policy/policy" +// policy-namespaces: "user" var ( IncludeNonFailuresFlag = Flag{ Name: "include-non-failures", diff --git a/pkg/flag/options.go b/pkg/flag/options.go index 35caeb2de46..bf6852b1570 100644 --- a/pkg/flag/options.go +++ b/pkg/flag/options.go @@ -289,7 +289,7 @@ func (f *Flags) Bind(cmd *cobra.Command) error { return nil } -//nolint: gocyclo +// nolint: gocyclo func (f *Flags) ToOptions(appVersion string, args []string, globalFlags *GlobalFlagGroup, output io.Writer) (Options, error) { var err error opts := Options{ diff --git a/pkg/flag/report_flags.go b/pkg/flag/report_flags.go index e450b993b1a..13d7b647caf 100644 --- a/pkg/flag/report_flags.go +++ b/pkg/flag/report_flags.go @@ -14,12 +14,11 @@ import ( "github.com/aquasecurity/trivy/pkg/result" ) -// e.g. config yaml -// report: -// format: table -// dependency-tree: true -// exit-code: 1 -// severity: HIGH,CRITICAL +// e.g. config yaml: +// +// format: table +// dependency-tree: true +// severity: HIGH,CRITICAL var ( FormatFlag = Flag{ Name: "format", diff --git a/pkg/k8s/commands/run.go b/pkg/k8s/commands/run.go index 62ca5164f0e..a424c47b047 100644 --- a/pkg/k8s/commands/run.go +++ b/pkg/k8s/commands/run.go @@ -93,8 +93,9 @@ func run(ctx context.Context, opts flag.Options, cluster string, artifacts []*ar // To show all the results, user needs to specify "--report all" explicitly // even though the default value of "--report" is "all". // -// e.g. $ trivy k8s --report all cluster -// $ trivy k8s --report all all +// e.g. +// $ trivy k8s --report all cluster +// $ trivy k8s --report all all // // Or they can use "--format json" with implicit "--report all". // diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index 6f7a2f69d40..1744fb1b559 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -155,17 +155,16 @@ func parsePkgs(components []cdx.Component, seen map[string]struct{}) ([]ftypes.P } // walkDependencies takes all nested dependencies of the root component. -// -// e.g. Library A, B, C, D and E will be returned as dependencies of Application 1. -// type: Application 1 -// - type: Library A -// - type: Library B -// - type: Application 2 -// - type: Library C -// - type: Application 3 -// - type: Library D -// - type: Library E func (c *CycloneDX) walkDependencies(rootRef string) []cdx.Component { + // e.g. Library A, B, C, D and E will be returned as dependencies of Application 1. + // type: Application 1 + // - type: Library A + // - type: Library B + // - type: Application 2 + // - type: Library C + // - type: Application 3 + // - type: Library D + // - type: Library E var components []cdx.Component for _, dep := range c.dependencies[rootRef] { component, ok := c.components[dep] From dac2b4a281981c9264a1e4ec06254248124856a3 Mon Sep 17 00:00:00 2001 From: Teppei Fukuda Date: Wed, 14 Sep 2022 13:27:27 +0300 Subject: [PATCH 3/4] build: checkout before setting up Go (#2873) --- .github/workflows/reusable-release.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/reusable-release.yaml b/.github/workflows/reusable-release.yaml index 47cc3d3941e..554d8820f23 100644 --- a/.github/workflows/reusable-release.yaml +++ b/.github/workflows/reusable-release.yaml @@ -59,16 +59,16 @@ jobs: username: ${{ secrets.ECR_ACCESS_KEY_ID }} password: ${{ secrets.ECR_SECRET_ACCESS_KEY }} - - name: Setup Go - uses: actions/setup-go@v3 - with: - go-version-file: go.mod - - name: Checkout code uses: actions/checkout@v3 with: fetch-depth: 0 + - name: Setup Go + uses: actions/setup-go@v3 + with: + go-version-file: go.mod + - name: Generate SBOM uses: CycloneDX/gh-gomod-generate-sbom@v1 with: From 3165c376e229d2c6e1a0d905b1fcd36701ebac12 Mon Sep 17 00:00:00 2001 From: Masahiro331 Date: Wed, 14 Sep 2022 19:36:10 +0900 Subject: [PATCH 4/4] feat(sbom): Add marshal for spdx (#2867) Co-authored-by: knqyf263 --- pkg/report/spdx/spdx.go | 178 +-------- pkg/report/spdx/spdx_test.go | 407 -------------------- pkg/sbom/spdx/marshal.go | 402 ++++++++++++++++++++ pkg/sbom/spdx/marshal_test.go | 688 ++++++++++++++++++++++++++++++++++ 4 files changed, 1110 insertions(+), 565 deletions(-) delete mode 100644 pkg/report/spdx/spdx_test.go create mode 100644 pkg/sbom/spdx/marshal.go create mode 100644 pkg/sbom/spdx/marshal_test.go 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) + }) + } +}