diff --git a/docs/docs/supply-chain/sbom.md b/docs/docs/supply-chain/sbom.md index 7a1177a14b67..e8e214784b7f 100644 --- a/docs/docs/supply-chain/sbom.md +++ b/docs/docs/supply-chain/sbom.md @@ -1,11 +1,13 @@ -# SBOM generation +# SBOM + +## Generating Trivy can generate the following SBOM formats. - [CycloneDX](#cyclonedx) - [SPDX](#spdx) -## CLI commands +### CLI commands To generate SBOM, you can use the `--format` option for each subcommand such as `image`, `fs` and `vm`. ``` @@ -177,7 +179,7 @@ $ trivy fs --format cyclonedx --output result.json /app/myproject -## Supported packages +### Supported packages Trivy supports the following packages. - [OS packages][os_packages] @@ -196,8 +198,8 @@ In addition to the above packages, Trivy also supports the following packages fo [^1]: Use `startline == 1 and endline == 1` for unsupported file types [^2]: `envs/*/conda-meta/*.json` -## Formats -### CycloneDX +### Formats +#### CycloneDX Trivy can generate SBOM in the [CycloneDX][cyclonedx] format. Note that XML format is not supported at the moment. @@ -442,7 +444,7 @@ If you want to include vulnerabilities, you can enable vulnerability scanning vi $ trivy image --scanners vuln --format cyclonedx --output result.json alpine:3.15 ``` -### SPDX +#### SPDX Trivy can generate SBOM in the [SPDX][spdx] format. You can use the regular subcommands (like `image`, `fs` and `rootfs`) and specify `spdx` with the `--format` option. @@ -737,6 +739,31 @@ $ cat result.spdx.json | jq . +## Scanning +Trivy can take SBOM documents as input for scanning. +See [here](../target/sbom.md) for more details. + +Also, Trivy searches for SBOM files in container images. + +```bash +$ trivy image bitnami/elasticsearch:8.7.1 +``` + +For example, [Bitnami images](https://github.com/bitnami/containers) contain SBOM files in `/opt/bitnami` directory. +Trivy automatically detects the SBOM files and uses them for scanning. +It is enabled in the following targets. + +| Target | Enabled | +|:---------------:|:-------:| +| Container Image | ✓ | +| Filesystem | | +| Rootfs | ✓ | +| Git Repository | | +| VM Image | ✓ | +| Kubernetes | | +| AWS | | +| SBOM | | + [spdx]: https://spdx.dev/wp-content/uploads/sites/41/2020/08/SPDX-specification-2-2.pdf diff --git a/integration/testdata/fluentd-multiple-lockfiles-cyclonedx.json.golden b/integration/testdata/fluentd-multiple-lockfiles-cyclonedx.json.golden index 3b309bbba4d3..0cd8ef6bde1b 100644 --- a/integration/testdata/fluentd-multiple-lockfiles-cyclonedx.json.golden +++ b/integration/testdata/fluentd-multiple-lockfiles-cyclonedx.json.golden @@ -135,6 +135,7 @@ { "VulnerabilityID": "CVE-2020-8165", "PkgName": "activesupport", + "PkgPath": "var/lib/gems/2.5.0/specifications/activesupport-6.0.2.1.gemspec", "InstalledVersion": "6.0.2.1", "FixedVersion": "6.0.3.1, 5.2.4.3", "Layer": {}, diff --git a/mkdocs.yml b/mkdocs.yml index d0ef6b091efc..8db84cc39b03 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -38,14 +38,14 @@ nav: - Docs: - Overview: docs/index.md - Target: - Container Image: docs/target/container_image.md - Filesystem: docs/target/filesystem.md - Rootfs: docs/target/rootfs.md - Git Repository: docs/target/git-repository.md - Virtual Machine Image: docs/target/vm.md - Kubernetes: docs/target/kubernetes.md - AWS: docs/target/aws.md - SBOM: docs/target/sbom.md + - Container Image: docs/target/container_image.md + - Filesystem: docs/target/filesystem.md + - Rootfs: docs/target/rootfs.md + - Git Repository: docs/target/git-repository.md + - Virtual Machine Image: docs/target/vm.md + - Kubernetes: docs/target/kubernetes.md + - AWS: docs/target/aws.md + - SBOM: docs/target/sbom.md - Scanner: - Vulnerability: - Overview: docs/scanner/vulnerability/index.md @@ -110,6 +110,7 @@ nav: - Overview: docs/references/configuration/cli/trivy.md - AWS: docs/references/configuration/cli/trivy_aws.md - Config: docs/references/configuration/cli/trivy_config.md + - Convert: docs/references/configuration/cli/trivy_convert.md - Filesystem: docs/references/configuration/cli/trivy_filesystem.md - Image: docs/references/configuration/cli/trivy_image.md - Kubernetes: docs/references/configuration/cli/trivy_kubernetes.md diff --git a/pkg/commands/artifact/run.go b/pkg/commands/artifact/run.go index 7f2ce3dd8129..75ee85f883da 100644 --- a/pkg/commands/artifact/run.go +++ b/pkg/commands/artifact/run.go @@ -187,8 +187,9 @@ func (r *runner) ScanImage(ctx context.Context, opts flag.Options) (types.Report } func (r *runner) ScanFilesystem(ctx context.Context, opts flag.Options) (types.Report, error) { - // Disable the individual package scanning + // Disable scanning of individual package and SBOM files opts.DisabledAnalyzers = append(opts.DisabledAnalyzers, analyzer.TypeIndividualPkgs...) + opts.DisabledAnalyzers = append(opts.DisabledAnalyzers, analyzer.TypeSBOM) return r.scanFS(ctx, opts) } @@ -217,8 +218,9 @@ func (r *runner) ScanRepository(ctx context.Context, opts flag.Options) (types.R // Do not scan OS packages opts.VulnType = []string{types.VulnTypeLibrary} - // Disable the OS analyzers and individual package analyzers + // Disable the OS analyzers, individual package analyzers and SBOM analyzer opts.DisabledAnalyzers = append(analyzer.TypeIndividualPkgs, analyzer.TypeOSes...) + opts.DisabledAnalyzers = append(opts.DisabledAnalyzers, analyzer.TypeSBOM) var s InitializeScanner if opts.ServerAddr == "" { diff --git a/pkg/fanal/analyzer/all/import.go b/pkg/fanal/analyzer/all/import.go index 07bf9aec87df..16e24195da3b 100644 --- a/pkg/fanal/analyzer/all/import.go +++ b/pkg/fanal/analyzer/all/import.go @@ -44,5 +44,6 @@ import ( _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/pkg/dpkg" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/pkg/rpm" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/repo/apk" + _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/sbom" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/secret" ) diff --git a/pkg/fanal/analyzer/analyzer.go b/pkg/fanal/analyzer/analyzer.go index 3d87a02c0774..85285d442d6a 100644 --- a/pkg/fanal/analyzer/analyzer.go +++ b/pkg/fanal/analyzer/analyzer.go @@ -474,7 +474,20 @@ func (ag AnalyzerGroup) PostAnalyze(ctx context.Context, files *syncx.Map[Type, continue } - filteredFS, err := fsys.Filter(result.SystemInstalledFiles) + skippedFiles := result.SystemInstalledFiles + for _, app := range result.Applications { + skippedFiles = append(skippedFiles, app.FilePath) + for _, lib := range app.Libraries { + // The analysis result could contain packages listed in SBOM. + // The files of those packages don't have to be analyzed. + // This is especially helpful for expensive post-analyzers such as the JAR analyzer. + if lib.FilePath != "" { + skippedFiles = append(skippedFiles, lib.FilePath) + } + } + } + + filteredFS, err := fsys.Filter(skippedFiles) if err != nil { return xerrors.Errorf("unable to filter filesystem: %w", err) } diff --git a/pkg/fanal/analyzer/const.go b/pkg/fanal/analyzer/const.go index f7b5b9019405..60d018ed2d86 100644 --- a/pkg/fanal/analyzer/const.go +++ b/pkg/fanal/analyzer/const.go @@ -93,6 +93,7 @@ const ( // Non-packaged // ============ TypeExecutable Type = "executable" + TypeSBOM Type = "sbom" // ============ // Image Config diff --git a/pkg/fanal/analyzer/language/python/packaging/packaging.go b/pkg/fanal/analyzer/language/python/packaging/packaging.go index 4d4b7b3233e6..790b8e16d25b 100644 --- a/pkg/fanal/analyzer/language/python/packaging/packaging.go +++ b/pkg/fanal/analyzer/language/python/packaging/packaging.go @@ -6,7 +6,6 @@ import ( "context" "io" "os" - "path/filepath" "strings" "golang.org/x/xerrors" @@ -99,9 +98,6 @@ func (a packagingAnalyzer) open(file *zip.File) (dio.ReadSeekerAt, error) { } func (a packagingAnalyzer) Required(filePath string, _ os.FileInfo) bool { - // For Windows - filePath = filepath.ToSlash(filePath) - for _, r := range requiredFiles { if strings.HasSuffix(filePath, r) { return true diff --git a/pkg/fanal/analyzer/sbom/sbom.go b/pkg/fanal/analyzer/sbom/sbom.go new file mode 100644 index 000000000000..d3d26b81a293 --- /dev/null +++ b/pkg/fanal/analyzer/sbom/sbom.go @@ -0,0 +1,85 @@ +package sbom + +import ( + "context" + "os" + "path" + "strings" + + "golang.org/x/xerrors" + + "github.com/aquasecurity/trivy/pkg/fanal/analyzer" + "github.com/aquasecurity/trivy/pkg/sbom" +) + +func init() { + analyzer.RegisterAnalyzer(&sbomAnalyzer{}) +} + +const version = 1 + +var requiredSuffixes = []string{ + ".spdx", + ".spdx.json", + ".cdx", + ".cdx.json", +} + +type sbomAnalyzer struct{} + +func (a sbomAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) { + // Format auto-detection + format, err := sbom.DetectFormat(input.Content) + if err != nil { + return nil, xerrors.Errorf("failed to detect SBOM format: %w", err) + } + + bom, err := sbom.Decode(input.Content, format) + if err != nil { + return nil, xerrors.Errorf("SBOM decode error: %w", err) + } + + // For Bitnami images + if strings.HasPrefix(input.FilePath, "opt/bitnami/") { + dir, file := path.Split(input.FilePath) + bin := strings.TrimPrefix(file, ".spdx-") + bin = strings.TrimSuffix(bin, ".spdx") + binPath := path.Join(input.FilePath, "../bin", bin) + for i, app := range bom.Applications { + // Replace the SBOM path with the binary path + bom.Applications[i].FilePath = binPath + + for j, pkg := range app.Libraries { + if pkg.FilePath == "" { + continue + } + // Set the absolute path since SBOM in Bitnami images contain a relative path + // e.g. modules/apm/elastic-apm-agent-1.36.0.jar + // => opt/bitnami/elasticsearch/modules/apm/elastic-apm-agent-1.36.0.jar + bom.Applications[i].Libraries[j].FilePath = path.Join(dir, pkg.FilePath) + } + } + } + + return &analyzer.AnalysisResult{ + PackageInfos: bom.Packages, + Applications: bom.Applications, + }, nil +} + +func (a sbomAnalyzer) Required(filePath string, _ os.FileInfo) bool { + for _, suffix := range requiredSuffixes { + if strings.HasSuffix(filePath, suffix) { + return true + } + } + return false +} + +func (a sbomAnalyzer) Type() analyzer.Type { + return analyzer.TypeSBOM +} + +func (a sbomAnalyzer) Version() int { + return version +} diff --git a/pkg/fanal/analyzer/sbom/sbom_test.go b/pkg/fanal/analyzer/sbom/sbom_test.go new file mode 100644 index 000000000000..a2858f0abd3f --- /dev/null +++ b/pkg/fanal/analyzer/sbom/sbom_test.go @@ -0,0 +1,137 @@ +package sbom + +import ( + "context" + "github.com/aquasecurity/trivy/pkg/fanal/analyzer" + "github.com/aquasecurity/trivy/pkg/fanal/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "os" + "testing" +) + +func Test_sbomAnalyzer_Analyze(t *testing.T) { + tests := []struct { + name string + file string + want *analyzer.AnalysisResult + wantErr require.ErrorAssertionFunc + }{ + { + name: "valid spdx file", + file: "testdata/spdx.json", + want: &analyzer.AnalysisResult{ + Applications: []types.Application{ + { + Type: types.Jar, + FilePath: "opt/bitnami/bin/elasticsearch", + Libraries: []types.Package{ + { + FilePath: "opt/bitnami/modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent@1.36.0", + }, + { + FilePath: "opt/bitnami/modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent-cached-lookup-key", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0", + }, + }, + }, + }, + }, + wantErr: require.NoError, + }, + { + name: "valid cdx file", + file: "testdata/cdx.json", + want: &analyzer.AnalysisResult{ + Applications: []types.Application{ + { + Type: types.Jar, + FilePath: "opt/bitnami/bin/elasticsearch", + Libraries: []types.Package{ + { + FilePath: "opt/bitnami/modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent@1.36.0", + }, + { + FilePath: "opt/bitnami/modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent-cached-lookup-key", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0", + }, + }, + }, + }, + }, + wantErr: require.NoError, + }, + { + name: "invalid spdx file", + file: "testdata/invalid_spdx.json", + want: nil, + wantErr: require.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(tt.file) + require.NoError(t, err) + defer f.Close() + + a := sbomAnalyzer{} + got, err := a.Analyze(context.Background(), analyzer.AnalysisInput{ + FilePath: "opt/bitnami/.spdx-elasticsearch.spdx", + Content: f, + }) + tt.wantErr(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_packagingAnalyzer_Required(t *testing.T) { + tests := []struct { + name string + filePath string + want bool + }{ + { + name: "cdx", + filePath: "/test/result.cdx", + want: true, + }, + { + name: "spdx", + filePath: "/test/result.spdx", + want: true, + }, + { + name: "cdx.json", + filePath: "/test/result.cdx.json", + want: true, + }, + { + name: "spdx.json", + filePath: "/test/result.spdx.json", + want: true, + }, + { + name: "json", + filePath: "/test/result.json", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := sbomAnalyzer{} + got := a.Required(tt.filePath, nil) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/fanal/analyzer/sbom/testdata/cdx.json b/pkg/fanal/analyzer/sbom/testdata/cdx.json new file mode 100644 index 000000000000..9e031d0ecf1a --- /dev/null +++ b/pkg/fanal/analyzer/sbom/testdata/cdx.json @@ -0,0 +1,58 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:73f26314-e86a-4f5a-befc-f853a15b64e7", + "version": 1, + "metadata": { + "timestamp": "2023-06-01T13:10:23+00:00", + "tools": [ + { + "vendor": "aquasecurity", + "name": "trivy", + "version": "0.41.0-80-g1c03982fe" + } + ], + "component": { + "bom-ref": "pkg:oci/elasticsearch@sha256:d4b68b602eb3d92ea3256886761752ae1159dc01fd391f4c4a87ebf6ba9d3895?repository_url=index.docker.io%2Fbitnami%2Felasticsearch\u0026arch=arm64", + "type": "container", + "name": "bitnami/elasticsearch:8.7.1-debian-11-r7", + "purl": "pkg:oci/elasticsearch@sha256:d4b68b602eb3d92ea3256886761752ae1159dc01fd391f4c4a87ebf6ba9d3895?repository_url=index.docker.io%2Fbitnami%2Felasticsearch\u0026arch=arm64" + } + }, + "components": [ + { + "bom-ref": "pkg:maven/co.elastic.apm/apm-agent@1.36.0", + "type": "library", + "name": "co.elastic.apm:apm-agent", + "version": "1.36.0", + "purl": "pkg:maven/co.elastic.apm/apm-agent@1.36.0", + "properties": [ + { + "name": "aquasecurity:trivy:PkgType", + "value": "jar" + }, + { + "name": "aquasecurity:trivy:FilePath", + "value": "modules/apm/elastic-apm-agent-1.36.0.jar" + } + ] + }, + { + "bom-ref": "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0", + "type": "library", + "name": "co.elastic.apm:apm-agent-cached-lookup-key", + "version": "1.36.0", + "purl": "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0", + "properties": [ + { + "name": "aquasecurity:trivy:PkgType", + "value": "jar" + }, + { + "name": "aquasecurity:trivy:FilePath", + "value": "modules/apm/elastic-apm-agent-1.36.0.jar" + } + ] + } + ] +} \ No newline at end of file diff --git a/pkg/fanal/analyzer/sbom/testdata/invalid_spdx.json b/pkg/fanal/analyzer/sbom/testdata/invalid_spdx.json new file mode 100644 index 000000000000..dc9255659ea7 --- /dev/null +++ b/pkg/fanal/analyzer/sbom/testdata/invalid_spdx.json @@ -0,0 +1,3 @@ +{ + "invalid": "data" +} \ No newline at end of file diff --git a/pkg/fanal/analyzer/sbom/testdata/spdx.json b/pkg/fanal/analyzer/sbom/testdata/spdx.json new file mode 100644 index 000000000000..16495c54e1df --- /dev/null +++ b/pkg/fanal/analyzer/sbom/testdata/spdx.json @@ -0,0 +1,97 @@ +{ + "SPDXID": "SPDXRef-elasticsearch", + "spdxVersion": "SPDX-2.3", + "creationInfo": { + "created": "2023-05-17T15:59:30.511Z", + "creators": [ + "Organization: VMware, Inc." + ] + }, + "name": "SPDX document for Elasticsearch 8.7.1", + "dataLicense": "CC0-1.0", + "documentDescribes": [ + "SPDXRef-elasticsearch" + ], + "documentNamespace": "elasticsearch-8.7.1", + "packages": [ + { + "SPDXID": "SPDXRef-elasticsearch", + "name": "Elasticsearch", + "versionInfo": "8.7.1", + "downloadLocation": "https://github.com/elastic/elasticsearch/archive/v8.7.1.tar.gz", + "licenseConcluded": "Elastic-2.0", + "licenseDeclared": "Elastic-2.0", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:*:elasticsearch:elasticsearch:8.7.1:*:*:*:*:*:*:*" + } + ] + }, + { + "name": "co.elastic.apm:apm-agent", + "SPDXID": "SPDXRef-Package-d6465ccdd5385c16", + "versionInfo": "1.36.0", + "supplier": "NOASSERTION", + "downloadLocation": "NONE", + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "copyrightText": "", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:maven/co.elastic.apm/apm-agent@1.36.0" + } + ], + "primaryPackagePurpose": "LIBRARY", + "files": [ + { + "fileName":"modules/apm/elastic-apm-agent-1.36.0.jar", + "SPDXID": "SPDXRef-File-4d457bf4ff3526ea", + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "d2a9ad9b159eb650d25add9395c4f4198f200066" + } + ], + "copyrightText": "" + } + ] + }, + { + "name": "co.elastic.apm:apm-agent-cached-lookup-key", + "SPDXID": "SPDXRef-Package-8e3a2cf58d7bd790", + "versionInfo": "1.36.0", + "supplier": "NOASSERTION", + "downloadLocation": "NONE", + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "copyrightText": "", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0" + } + ], + "primaryPackagePurpose": "LIBRARY", + "files": [ + { + "fileName": "modules/apm/elastic-apm-agent-1.36.0.jar", + "SPDXID": "SPDXRef-File-4d457bf4ff3526ea", + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "d2a9ad9b159eb650d25add9395c4f4198f200066" + } + ], + "copyrightText": "" + } + ] + } + ], + "files": [] +} \ No newline at end of file diff --git a/pkg/fanal/artifact/sbom/sbom_test.go b/pkg/fanal/artifact/sbom/sbom_test.go index 1692a0171c5c..fc659c74c98a 100644 --- a/pkg/fanal/artifact/sbom/sbom_test.go +++ b/pkg/fanal/artifact/sbom/sbom_test.go @@ -29,7 +29,7 @@ func TestArtifact_Inspect(t *testing.T) { filePath: filepath.Join("testdata", "bom.json"), putBlobExpectation: cache.ArtifactCachePutBlobExpectation{ Args: cache.ArtifactCachePutBlobArgs{ - BlobID: "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + BlobID: "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", BlobInfo: types.BlobInfo{ SchemaVersion: types.BlobJSONSchemaVersion, OS: types.OS{ @@ -99,6 +99,7 @@ func TestArtifact_Inspect(t *testing.T) { Layer: types.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/maven/target/child-project-1.0.jar", }, }, }, @@ -114,6 +115,7 @@ func TestArtifact_Inspect(t *testing.T) { Layer: types.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/app/package.json", }, }, }, @@ -125,9 +127,9 @@ func TestArtifact_Inspect(t *testing.T) { want: types.ArtifactReference{ Name: filepath.Join("testdata", "bom.json"), Type: types.ArtifactCycloneDX, - ID: "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + ID: "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", BlobIDs: []string{ - "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", }, }, }, @@ -136,7 +138,7 @@ func TestArtifact_Inspect(t *testing.T) { filePath: filepath.Join("testdata", "sbom.cdx.intoto.jsonl"), putBlobExpectation: cache.ArtifactCachePutBlobExpectation{ Args: cache.ArtifactCachePutBlobArgs{ - BlobID: "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + BlobID: "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", BlobInfo: types.BlobInfo{ SchemaVersion: types.BlobJSONSchemaVersion, OS: types.OS{ @@ -206,6 +208,7 @@ func TestArtifact_Inspect(t *testing.T) { Layer: types.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/maven/target/child-project-1.0.jar", }, }, }, @@ -221,6 +224,7 @@ func TestArtifact_Inspect(t *testing.T) { Layer: types.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/app/package.json", }, }, }, @@ -232,9 +236,9 @@ func TestArtifact_Inspect(t *testing.T) { want: types.ArtifactReference{ Name: filepath.Join("testdata", "sbom.cdx.intoto.jsonl"), Type: types.ArtifactCycloneDX, - ID: "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + ID: "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", BlobIDs: []string{ - "sha256:f02a38a70e35a84032402711b68c75c6aafa1f77a01506a8e974cefd40e9038b", + "sha256:3dca5f9082ac4e9669b5e461ae54ffe70db4ea275a09506014b17e012687e855", }, }, }, diff --git a/pkg/purl/purl.go b/pkg/purl/purl.go index 7f4bae582828..5787da1b9f8f 100644 --- a/pkg/purl/purl.go +++ b/pkg/purl/purl.go @@ -73,9 +73,15 @@ func (p *PackageURL) Package() *ftypes.Package { if p.Type == packageurl.TypeMaven || p.Type == ftypes.Gradle { // Maven and Gradle packages separate ":" // e.g. org.springframework:spring-core - pkg.Name = strings.Join([]string{p.Namespace, p.Name}, ":") + pkg.Name = strings.Join([]string{ + p.Namespace, + p.Name, + }, ":") } else { - pkg.Name = strings.Join([]string{p.Namespace, p.Name}, "/") + pkg.Name = strings.Join([]string{ + p.Namespace, + p.Name, + }, "/") } return pkg diff --git a/pkg/sbom/cyclonedx/unmarshal.go b/pkg/sbom/cyclonedx/unmarshal.go index a06a3433ad7e..057bbf53e51e 100644 --- a/pkg/sbom/cyclonedx/unmarshal.go +++ b/pkg/sbom/cyclonedx/unmarshal.go @@ -370,6 +370,8 @@ func toPackage(component cdx.Component) (bool, string, *ftypes.Package, error) { pkg.Modularitylabel = value case PropertyLayerDiffID: pkg.Layer.DiffID = value + case PropertyFilePath: + pkg.FilePath = value } } diff --git a/pkg/sbom/cyclonedx/unmarshal_test.go b/pkg/sbom/cyclonedx/unmarshal_test.go index 18528c2593cb..fb07fc53327c 100644 --- a/pkg/sbom/cyclonedx/unmarshal_test.go +++ b/pkg/sbom/cyclonedx/unmarshal_test.go @@ -89,6 +89,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Layer: ftypes.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/gradle/target/gradle.lockfile", }, }, }, @@ -102,6 +103,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Layer: ftypes.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/maven/target/child-project-1.0.jar", }, }, }, @@ -117,6 +119,7 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { Layer: ftypes.Layer{ DiffID: "sha256:3c79e832b1b4891a1cb4a326ef8524e0bd14a2537150ac0e203a5677176c1ca1", }, + FilePath: "app/app/package.json", }, }, }, diff --git a/pkg/sbom/spdx/testdata/happy/no-relationship.json b/pkg/sbom/spdx/testdata/happy/no-relationship.json new file mode 100644 index 000000000000..16495c54e1df --- /dev/null +++ b/pkg/sbom/spdx/testdata/happy/no-relationship.json @@ -0,0 +1,97 @@ +{ + "SPDXID": "SPDXRef-elasticsearch", + "spdxVersion": "SPDX-2.3", + "creationInfo": { + "created": "2023-05-17T15:59:30.511Z", + "creators": [ + "Organization: VMware, Inc." + ] + }, + "name": "SPDX document for Elasticsearch 8.7.1", + "dataLicense": "CC0-1.0", + "documentDescribes": [ + "SPDXRef-elasticsearch" + ], + "documentNamespace": "elasticsearch-8.7.1", + "packages": [ + { + "SPDXID": "SPDXRef-elasticsearch", + "name": "Elasticsearch", + "versionInfo": "8.7.1", + "downloadLocation": "https://github.com/elastic/elasticsearch/archive/v8.7.1.tar.gz", + "licenseConcluded": "Elastic-2.0", + "licenseDeclared": "Elastic-2.0", + "filesAnalyzed": false, + "externalRefs": [ + { + "referenceCategory": "SECURITY", + "referenceType": "cpe23Type", + "referenceLocator": "cpe:2.3:*:elasticsearch:elasticsearch:8.7.1:*:*:*:*:*:*:*" + } + ] + }, + { + "name": "co.elastic.apm:apm-agent", + "SPDXID": "SPDXRef-Package-d6465ccdd5385c16", + "versionInfo": "1.36.0", + "supplier": "NOASSERTION", + "downloadLocation": "NONE", + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "copyrightText": "", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:maven/co.elastic.apm/apm-agent@1.36.0" + } + ], + "primaryPackagePurpose": "LIBRARY", + "files": [ + { + "fileName":"modules/apm/elastic-apm-agent-1.36.0.jar", + "SPDXID": "SPDXRef-File-4d457bf4ff3526ea", + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "d2a9ad9b159eb650d25add9395c4f4198f200066" + } + ], + "copyrightText": "" + } + ] + }, + { + "name": "co.elastic.apm:apm-agent-cached-lookup-key", + "SPDXID": "SPDXRef-Package-8e3a2cf58d7bd790", + "versionInfo": "1.36.0", + "supplier": "NOASSERTION", + "downloadLocation": "NONE", + "licenseConcluded": "NONE", + "licenseDeclared": "NONE", + "copyrightText": "", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceType": "purl", + "referenceLocator": "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0" + } + ], + "primaryPackagePurpose": "LIBRARY", + "files": [ + { + "fileName": "modules/apm/elastic-apm-agent-1.36.0.jar", + "SPDXID": "SPDXRef-File-4d457bf4ff3526ea", + "checksums": [ + { + "algorithm": "SHA1", + "checksumValue": "d2a9ad9b159eb650d25add9395c4f4198f200066" + } + ], + "copyrightText": "" + } + ] + } + ], + "files": [] +} \ No newline at end of file diff --git a/pkg/sbom/spdx/unmarshal.go b/pkg/sbom/spdx/unmarshal.go index 51c84b82c664..46f02125b496 100644 --- a/pkg/sbom/spdx/unmarshal.go +++ b/pkg/sbom/spdx/unmarshal.go @@ -2,12 +2,14 @@ package spdx import ( "bytes" + "errors" "fmt" "io" "strings" version "github.com/knqyf263/go-rpm-version" "github.com/package-url/packageurl-go" + "github.com/samber/lo" "github.com/spdx/tools-golang/json" "github.com/spdx/tools-golang/spdx" "github.com/spdx/tools-golang/spdx/v2/common" @@ -71,6 +73,11 @@ func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { packageSPDXIdentifierMap := createPackageSPDXIdentifierMap(spdxDocument.Packages) packageFilePaths := getPackageFilePaths(spdxDocument) + relationships := lo.Filter(spdxDocument.Relationships, func(rel *spdx.Relationship, _ int) bool { + // Skip the DESCRIBES relationship. + return rel.Relationship != common.TypeRelationshipDescribe && rel.Relationship != "DESCRIBE" + }) + // Package relationships would be as belows: // - Root (container image, filesystem, etc.) // - Operating System (debian 10) @@ -82,12 +89,7 @@ func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { // - Application 2 (Pipfile.lock) // - Python package A // - Python package B - for _, rel := range spdxDocument.Relationships { - if rel.Relationship == common.TypeRelationshipDescribe || rel.Relationship == "DESCRIBE" { - // Skip the DESCRIBES relationship. - continue - } - + for _, rel := range relationships { pkgA := packageSPDXIdentifierMap[string(rel.RefA.ElementRefID)] pkgB := packageSPDXIdentifierMap[string(rel.RefB.ElementRefID)] @@ -102,8 +104,10 @@ func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { s.SBOM.OS = parseOS(*pkgB) // Relationship: OS => OS package case isOperatingSystem(pkgA.PackageSPDXIdentifier): - pkg, err := parsePkg(*pkgB, packageFilePaths) - if err != nil { + pkg, _, err := parsePkg(*pkgB, packageFilePaths) + if errors.Is(err, errUnknownPackageFormat) { + continue + } else if err != nil { return xerrors.Errorf("failed to parse os package: %w", err) } osPkgs = append(osPkgs, *pkg) @@ -118,8 +122,10 @@ func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { apps[pkgA.PackageSPDXIdentifier] = app } - lib, err := parsePkg(*pkgB, packageFilePaths) - if err != nil { + lib, _, err := parsePkg(*pkgB, packageFilePaths) + if errors.Is(err, errUnknownPackageFormat) { + continue + } else if err != nil { return xerrors.Errorf("failed to parse language-specific package: %w", err) } app.Libraries = append(app.Libraries, *lib) @@ -136,11 +142,53 @@ func (s *SPDX) unmarshal(spdxDocument *spdx.Document) error { s.SBOM.Applications = append(s.SBOM.Applications, *app) } + // Fallback for when there are no effective relationships. + if len(relationships) == 0 { + if err := s.parsePackages(spdxDocument); err != nil { + return err + } + } + // Keep the original document s.SPDX = spdxDocument return nil } +// parsePackages processes the packages and categorizes them into OS packages and application packages. +// Note that all language-specific packages are treated as a single application. +func (s *SPDX) parsePackages(spdxDocument *spdx.Document) error { + var ( + osPkgs []ftypes.Package + app ftypes.Application + ) + + for _, p := range spdxDocument.Packages { + pkg, pkgType, err := parsePkg(*p, nil) + if errors.Is(err, errUnknownPackageFormat) { + continue + } else if err != nil { + return xerrors.Errorf("failed to parse package: %w", err) + } + switch pkgType { + case purl.TypeAPK, packageurl.TypeDebian, packageurl.TypeRPM: + osPkgs = append(osPkgs, *pkg) + default: + // Language-specific packages + if app.Type == "" { + app.Type = pkgType + } + app.Libraries = append(app.Libraries, *pkg) + } + } + if len(osPkgs) > 0 { + s.Packages = []ftypes.PackageInfo{{Packages: osPkgs}} + } + if len(app.Libraries) > 0 { + s.SBOM.Applications = append(s.SBOM.Applications, app) + } + return nil +} + func createPackageSPDXIdentifierMap(packages []*spdx.Package) map[string]*spdx.Package { ret := make(map[string]*spdx.Package) for _, info := range packages { @@ -188,10 +236,10 @@ func parseOS(pkg spdx.Package) ftypes.OS { } } -func parsePkg(spdxPkg spdx.Package, packageFilePaths map[string]string) (*ftypes.Package, error) { +func parsePkg(spdxPkg spdx.Package, packageFilePaths map[string]string) (*ftypes.Package, string, error) { pkg, pkgType, err := parseExternalReferences(spdxPkg.PackageExternalReferences) if err != nil { - return nil, xerrors.Errorf("external references error: %w", err) + return nil, "", xerrors.Errorf("external references error: %w", err) } if spdxPkg.PackageLicenseDeclared != "NONE" { @@ -202,19 +250,22 @@ func parsePkg(spdxPkg spdx.Package, packageFilePaths map[string]string) (*ftypes srcPkgName := strings.TrimPrefix(spdxPkg.PackageSourceInfo, fmt.Sprintf("%s: ", SourcePackagePrefix)) pkg.SrcEpoch, pkg.SrcName, pkg.SrcVersion, pkg.SrcRelease, err = parseSourceInfo(pkgType, srcPkgName) if err != nil { - return nil, xerrors.Errorf("failed to parse source info: %w", err) + return nil, "", xerrors.Errorf("failed to parse source info: %w", err) } } if path, ok := packageFilePaths[string(spdxPkg.PackageSPDXIdentifier)]; ok { pkg.FilePath = path + } else if len(spdxPkg.Files) > 0 { + // Take the first file name + pkg.FilePath = spdxPkg.Files[0].FileName } pkg.ID = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyPkgID) pkg.Layer.Digest = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDigest) pkg.Layer.DiffID = lookupAttributionTexts(spdxPkg.PackageAttributionTexts, PropertyLayerDiffID) - return pkg, nil + return pkg, pkgType, nil } func parseExternalReferences(refs []*spdx.PackageExternalReference) (*ftypes.Package, string, error) { @@ -227,7 +278,7 @@ func parseExternalReferences(refs []*spdx.PackageExternalReference) (*ftypes.Pac } pkg := packageURL.Package() pkg.Ref = ref.Locator - return pkg, packageURL.Type, nil + return pkg, packageURL.PackageType(), nil } } return nil, "", errUnknownPackageFormat diff --git a/pkg/sbom/spdx/unmarshal_test.go b/pkg/sbom/spdx/unmarshal_test.go index 3cddf4106349..26c526972e9d 100644 --- a/pkg/sbom/spdx/unmarshal_test.go +++ b/pkg/sbom/spdx/unmarshal_test.go @@ -33,8 +33,12 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { { Packages: []ftypes.Package{ { - Name: "musl", Version: "1.2.3-r0", SrcName: "musl", SrcVersion: "1.2.3-r0", Licenses: []string{"MIT"}, - Ref: "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", + Name: "musl", + Version: "1.2.3-r0", + SrcName: "musl", + SrcVersion: "1.2.3-r0", + Licenses: []string{"MIT"}, + Ref: "pkg:apk/alpine/musl@1.2.3-r0?distro=3.16.0", Layer: ftypes.Layer{ DiffID: "sha256:dd565ff850e7003356e2b252758f9bdc1ff2803f61e995e24c7844f6297f8fc3", }, @@ -198,6 +202,31 @@ func TestUnmarshaler_Unmarshal(t *testing.T) { }, }, }, + { + name: "happy path with no relationship", + inputFile: "testdata/happy/no-relationship.json", + want: types.SBOM{ + Applications: []ftypes.Application{ + { + Type: ftypes.Jar, + Libraries: []ftypes.Package{ + { + FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent@1.36.0", + }, + { + FilePath: "modules/apm/elastic-apm-agent-1.36.0.jar", + Name: "co.elastic.apm:apm-agent-cached-lookup-key", + Version: "1.36.0", + Ref: "pkg:maven/co.elastic.apm/apm-agent-cached-lookup-key@1.36.0", + }, + }, + }, + }, + }, + }, { name: "happy path only os component", inputFile: "testdata/happy/os-only-bom.json",