From b88bccae6e8619c5146a32c4d835d7a6a2be1e7c Mon Sep 17 00:00:00 2001 From: Matthieu Maitre Date: Tue, 10 Jan 2023 06:11:17 -0800 Subject: [PATCH] feat(python): Include Conda packages in SBOMs (#3379) Co-authored-by: knqyf263 --- docs/docs/sbom/supported.md | 8 +- integration/fs_test.go | 50 ++++++++- integration/integration_test.go | 59 ++++++++++ integration/sbom_test.go | 21 +--- .../testdata/conda-cyclonedx.json.golden | 83 ++++++++++++++ integration/testdata/conda-spdx.json.golden | 101 ++++++++++++++++++ .../conda-meta/openssl-1.1.1q-h7f8727e_0.json | 60 +++++++++++ .../conda-meta/pip-22.2.2-py38h06a4308_0.json | 62 +++++++++++ pkg/detector/library/driver.go | 3 + pkg/fanal/analyzer/all/import.go | 1 + pkg/fanal/analyzer/const.go | 7 +- .../analyzer/language/conda/meta/meta.go | 46 ++++++++ .../analyzer/language/conda/meta/meta_test.go | 101 ++++++++++++++++++ .../language/conda/meta/testdata/invalid.json | 1 + .../testdata/pip-22.2.2-py38h06a4308_0.json | 62 +++++++++++ pkg/fanal/applier/docker.go | 3 +- pkg/fanal/handler/sysfile/filter.go | 3 + pkg/fanal/types/const.go | 1 + pkg/purl/purl.go | 4 + pkg/purl/purl_test.go | 27 +++++ pkg/sbom/cyclonedx/marshal.go | 2 +- pkg/sbom/spdx/unmarshal.go | 2 +- pkg/scanner/local/scan.go | 1 + 23 files changed, 677 insertions(+), 31 deletions(-) create mode 100644 integration/testdata/conda-cyclonedx.json.golden create mode 100644 integration/testdata/conda-spdx.json.golden create mode 100644 integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json create mode 100644 integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json create mode 100644 pkg/fanal/analyzer/language/conda/meta/meta.go create mode 100644 pkg/fanal/analyzer/language/conda/meta/meta_test.go create mode 100644 pkg/fanal/analyzer/language/conda/meta/testdata/invalid.json create mode 100644 pkg/fanal/analyzer/language/conda/meta/testdata/pip-22.2.2-py38h06a4308_0.json diff --git a/docs/docs/sbom/supported.md b/docs/docs/sbom/supported.md index 7430c2b492..6907e3b605 100644 --- a/docs/docs/sbom/supported.md +++ b/docs/docs/sbom/supported.md @@ -4,11 +4,13 @@ ## Other language-specific packages -| Language | File | Dependency location[^1] | -|----------|--------------|:-----------------------:| -| Swift | Podfile.lock | - | +| Language | File | Dependency location[^1] | +|----------|-------------------|:-----------------------:| +| Python | conda package[^2] | - | +| Swift | Podfile.lock | - | [^1]: Use `startline == 1 and endline == 1` for unsupported file types +[^2]: `envs/*/conda-meta/*.json` [os_packages]: ../vulnerability/detection/os.md [language_packages]: ../vulnerability/detection/language.md diff --git a/integration/fs_test.go b/integration/fs_test.go index 349ce59e72..4d513ebe1f 100644 --- a/integration/fs_test.go +++ b/integration/fs_test.go @@ -28,6 +28,8 @@ func TestFilesystem(t *testing.T) { helmValuesFile []string skipFiles []string skipDirs []string + command string + format string } tests := []struct { name string @@ -263,6 +265,24 @@ func TestFilesystem(t *testing.T) { }, golden: "testdata/secrets.json.golden", }, + { + name: "conda generating CycloneDX SBOM", + args: args{ + command: "rootfs", + format: "cyclonedx", + input: "testdata/fixtures/fs/conda", + }, + golden: "testdata/conda-cyclonedx.json.golden", + }, + { + name: "conda generating SPDX SBOM", + args: args{ + command: "rootfs", + format: "spdx-json", + input: "testdata/fixtures/fs/conda", + }, + golden: "testdata/conda-spdx.json.golden", + }, } // Set up testing DB @@ -273,9 +293,24 @@ func TestFilesystem(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + + command := "fs" + if tt.args.command != "" { + command = tt.args.command + } + + format := "json" + if tt.args.format != "" { + format = tt.args.format + } + osArgs := []string{ - "-q", "--cache-dir", cacheDir, "fs", "--skip-db-update", "--skip-policy-update", - "--format", "json", "--offline-scan", "--security-checks", tt.args.securityChecks, + "-q", "--cache-dir", cacheDir, command, "--skip-db-update", "--skip-policy-update", + "--format", format, "--offline-scan", + } + + if tt.args.securityChecks != "" { + osArgs = append(osArgs, "--security-checks", tt.args.securityChecks) } if len(tt.args.policyPaths) != 0 { @@ -353,7 +388,16 @@ func TestFilesystem(t *testing.T) { require.NoError(t, err) // Compare want and got - compareReports(t, tt.golden, outputFile) + switch format { + case "cyclonedx": + compareCycloneDX(t, tt.golden, outputFile) + case "spdx-json": + compareSpdxJson(t, tt.golden, outputFile) + case "json": + compareReports(t, tt.golden, outputFile) + default: + require.Fail(t, "invalid format", "format: %s", format) + } }) } } diff --git a/integration/integration_test.go b/integration/integration_test.go index 627f011d57..81cf43d5f3 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -14,6 +14,9 @@ import ( "testing" "time" + cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/spdx/tools-golang/jsonloader" + "github.com/spdx/tools-golang/spdx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -121,6 +124,50 @@ func readReport(t *testing.T, filePath string) types.Report { return report } +func readCycloneDX(t *testing.T, filePath string) *cdx.BOM { + f, err := os.Open(filePath) + require.NoError(t, err) + defer f.Close() + + bom := cdx.NewBOM() + decoder := cdx.NewBOMDecoder(f, cdx.BOMFileFormatJSON) + err = decoder.Decode(bom) + require.NoError(t, err) + + // We don't compare values which change each time an SBOM is generated + bom.Metadata.Timestamp = "" + bom.Metadata.Component.BOMRef = "" + bom.SerialNumber = "" + if bom.Components != nil { + for i := range *bom.Components { + (*bom.Components)[i].BOMRef = "" + } + } + if bom.Dependencies != nil { + for j := range *bom.Dependencies { + (*bom.Dependencies)[j].Ref = "" + (*bom.Dependencies)[j].Dependencies = nil + } + } + + return bom +} + +func readSpdxJson(t *testing.T, filePath string) *spdx.Document2_2 { + f, err := os.Open(filePath) + require.NoError(t, err) + defer f.Close() + + bom, err := jsonloader.Load2_2(f) + require.NoError(t, err) + + // We don't compare values which change each time an SBOM is generated + bom.CreationInfo.Created = "" + bom.CreationInfo.DocumentNamespace = "" + + return bom +} + func execute(osArgs []string) error { // Setup CLI App app := commands.NewApp("dev") @@ -136,3 +183,15 @@ func compareReports(t *testing.T, wantFile, gotFile string) { got := readReport(t, gotFile) assert.Equal(t, want, got) } + +func compareCycloneDX(t *testing.T, wantFile, gotFile string) { + want := readCycloneDX(t, wantFile) + got := readCycloneDX(t, gotFile) + assert.Equal(t, want, got) +} + +func compareSpdxJson(t *testing.T, wantFile, gotFile string) { + want := readSpdxJson(t, wantFile) + got := readSpdxJson(t, gotFile) + assert.Equal(t, want, got) +} diff --git a/integration/sbom_test.go b/integration/sbom_test.go index f9e2c5f4c1..33784bfa01 100644 --- a/integration/sbom_test.go +++ b/integration/sbom_test.go @@ -3,11 +3,9 @@ package integration import ( - "os" "path/filepath" "testing" - cdx "github.com/CycloneDX/cyclonedx-go" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -128,9 +126,7 @@ func TestSBOM(t *testing.T) { // Compare want and got switch tt.args.format { case "cyclonedx": - want := decodeCycloneDX(t, tt.golden) - got := decodeCycloneDX(t, outputFile) - assert.Equal(t, want, got) + compareCycloneDX(t, tt.golden, outputFile) case "json": compareSBOMReports(t, tt.golden, outputFile, tt.override) default: @@ -165,18 +161,3 @@ func compareSBOMReports(t *testing.T, wantFile, gotFile string, overrideWant typ got := readReport(t, gotFile) assert.Equal(t, want, got) } - -func decodeCycloneDX(t *testing.T, filePath string) *cdx.BOM { - f, err := os.Open(filePath) - require.NoError(t, err) - defer f.Close() - - bom := cdx.NewBOM() - decoder := cdx.NewBOMDecoder(f, cdx.BOMFileFormatJSON) - err = decoder.Decode(bom) - require.NoError(t, err) - - bom.Metadata.Timestamp = "" - - return bom -} diff --git a/integration/testdata/conda-cyclonedx.json.golden b/integration/testdata/conda-cyclonedx.json.golden new file mode 100644 index 0000000000..bbb1e54238 --- /dev/null +++ b/integration/testdata/conda-cyclonedx.json.golden @@ -0,0 +1,83 @@ +{ + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "serialNumber": "urn:uuid:4dd4cf4a-d4de-4ea0-b75f-ad617f31b5a9", + "version": 1, + "metadata": { + "timestamp": "2023-01-08T23:57:37+00:00", + "tools": [ + { + "vendor": "aquasecurity", + "name": "trivy", + "version": "dev" + } + ], + "component": { + "bom-ref": "582a7c6f-b30e-4b65-a911-f3f5034aa003", + "type": "application", + "name": "testdata/fixtures/fs/conda", + "properties": [ + { + "name": "aquasecurity:trivy:SchemaVersion", + "value": "2" + } + ] + } + }, + "components": [ + { + "bom-ref": "pkg:conda/openssl@1.1.1q?file_path=miniconda3%2Fenvs%2Ftestenv%2Fconda-meta%2Fopenssl-1.1.1q-h7f8727e_0.json", + "type": "library", + "name": "openssl", + "version": "1.1.1q", + "licenses": [ + { + "expression": "OpenSSL" + } + ], + "purl": "pkg:conda/openssl@1.1.1q", + "properties": [ + { + "name": "aquasecurity:trivy:PkgType", + "value": "conda-pkg" + }, + { + "name": "aquasecurity:trivy:FilePath", + "value": "miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json" + } + ] + }, + { + "bom-ref": "pkg:conda/pip@22.2.2?file_path=miniconda3%2Fenvs%2Ftestenv%2Fconda-meta%2Fpip-22.2.2-py38h06a4308_0.json", + "type": "library", + "name": "pip", + "version": "22.2.2", + "licenses": [ + { + "expression": "MIT" + } + ], + "purl": "pkg:conda/pip@22.2.2", + "properties": [ + { + "name": "aquasecurity:trivy:PkgType", + "value": "conda-pkg" + }, + { + "name": "aquasecurity:trivy:FilePath", + "value": "miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json" + } + ] + } + ], + "dependencies": [ + { + "ref": "582a7c6f-b30e-4b65-a911-f3f5034aa003", + "dependsOn": [ + "pkg:conda/openssl@1.1.1q?file_path=miniconda3%2Fenvs%2Ftestenv%2Fconda-meta%2Fopenssl-1.1.1q-h7f8727e_0.json", + "pkg:conda/pip@22.2.2?file_path=miniconda3%2Fenvs%2Ftestenv%2Fconda-meta%2Fpip-22.2.2-py38h06a4308_0.json" + ] + } + ], + "vulnerabilities": [] +} diff --git a/integration/testdata/conda-spdx.json.golden b/integration/testdata/conda-spdx.json.golden new file mode 100644 index 0000000000..cd05328b2a --- /dev/null +++ b/integration/testdata/conda-spdx.json.golden @@ -0,0 +1,101 @@ +{ + "SPDXID": "SPDXRef-DOCUMENT", + "creationInfo": { + "created": "2023-01-08T23:58:16.700785648Z", + "creators": [ + "Tool: trivy", + "Organization: aquasecurity" + ] + }, + "dataLicense": "CC0-1.0", + "documentDescribes": [ + "SPDXRef-Filesystem-6e0ac6a0fab50ab4" + ], + "documentNamespace": "http://aquasecurity.github.io/trivy/filesystem/testdata/fixtures/fs/conda-3be0d21e-5711-451e-8b1b-2ac8775a3abb", + "files": [ + { + "SPDXID": "SPDXRef-File-600e5e0110a84891", + "fileName": "miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json" + }, + { + "SPDXID": "SPDXRef-File-7eb62e2a3edddc0a", + "fileName": "miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json" + } + ], + "name": "testdata/fixtures/fs/conda", + "packages": [ + { + "SPDXID": "SPDXRef-Application-ee5ef1aa4ac89125", + "filesAnalyzed": false, + "name": "conda-pkg", + "sourceInfo": "Conda" + }, + { + "SPDXID": "SPDXRef-Filesystem-6e0ac6a0fab50ab4", + "attributionTexts": [ + "SchemaVersion: 2" + ], + "filesAnalyzed": false, + "name": "testdata/fixtures/fs/conda" + }, + { + "SPDXID": "SPDXRef-Package-2984084f02572600", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:conda/openssl@1.1.1q", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "hasFiles": [ + "SPDXRef-File-600e5e0110a84891" + ], + "licenseConcluded": "OpenSSL", + "licenseDeclared": "OpenSSL", + "name": "openssl", + "versionInfo": "1.1.1q" + }, + { + "SPDXID": "SPDXRef-Package-ac33eb699b3aa81d", + "externalRefs": [ + { + "referenceCategory": "PACKAGE-MANAGER", + "referenceLocator": "pkg:conda/pip@22.2.2", + "referenceType": "purl" + } + ], + "filesAnalyzed": false, + "hasFiles": [ + "SPDXRef-File-7eb62e2a3edddc0a" + ], + "licenseConcluded": "MIT", + "licenseDeclared": "MIT", + "name": "pip", + "versionInfo": "22.2.2" + } + ], + "relationships": [ + { + "relatedSpdxElement": "SPDXRef-Filesystem-6e0ac6a0fab50ab4", + "relationshipType": "DESCRIBES", + "spdxElementId": "SPDXRef-DOCUMENT" + }, + { + "relatedSpdxElement": "SPDXRef-Application-ee5ef1aa4ac89125", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-Filesystem-6e0ac6a0fab50ab4" + }, + { + "relatedSpdxElement": "SPDXRef-Package-2984084f02572600", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-Application-ee5ef1aa4ac89125" + }, + { + "relatedSpdxElement": "SPDXRef-Package-ac33eb699b3aa81d", + "relationshipType": "CONTAINS", + "spdxElementId": "SPDXRef-Application-ee5ef1aa4ac89125" + } + ], + "spdxVersion": "SPDX-2.2" +} \ No newline at end of file diff --git a/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json b/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json new file mode 100644 index 0000000000..2cc3c34a1a --- /dev/null +++ b/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/openssl-1.1.1q-h7f8727e_0.json @@ -0,0 +1,60 @@ +{ + "build": "h7f8727e_0", + "build_number": 0, + "channel": "https://repo.anaconda.com/pkgs/main/linux-64", + "constrains": [], + "depends": [ + "ca-certificates", + "libgcc-ng >=7.5.0" + ], + "extracted_package_dir": "/home/mmaitre/miniconda3/pkgs/openssl-1.1.1q-h7f8727e_0", + "features": "", + "files": [ + "bin/c_rehash", + "", + "ssl/openssl.cnf.dist" + ], + "fn": "openssl-1.1.1q-h7f8727e_0.conda", + "legacy_bz2_md5": "ad51928702694e3f6d25b7d4229c84e6", + "license": "OpenSSL", + "license_family": "Apache", + "link": { + "source": "/home/user/miniconda3/pkgs/openssl-1.1.1q-h7f8727e_0", + "type": 1 + }, + "md5": "2ac47797afee2ece8d339c18b095b8d8", + "name": "openssl", + "package_tarball_full_path": "/home/user/miniconda3/pkgs/openssl-1.1.1q-h7f8727e_0.conda", + "paths_data": { + "paths": [ + { + "_path": "bin/c_rehash", + "file_mode": "text", + "path_type": "hardlink", + "prefix_placeholder": "/opt/conda/conda-bld/openssl_1657551138854/_h_env_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_place", + "sha256": "04c9f4a5c91e20a24d14a36668b5bebe826d6087fb2337b68e33a4d485f5586f", + "sha256_in_prefix": "fc2a6b708cccb8ba90d20e54a9b07257fce009bf315a36f73d3e542dd8674921", + "size_in_bytes": 6991 + }, + { + "_path": "" + }, + { + "_path": "ssl/openssl.cnf.dist", + "path_type": "hardlink", + "sha256": "f10ba64917b4458fafc1e078c2eb9e6a7602e68fc98c2e9e6df5e1636ae27d6b", + "sha256_in_prefix": "f10ba64917b4458fafc1e078c2eb9e6a7602e68fc98c2e9e6df5e1636ae27d6b", + "size_in_bytes": 10909 + } + ], + "paths_version": 1 + }, + "requested_spec": "None", + "sha256": "49804293b87141523b2606836ece8e2aaa5202983698fd91e7c36bdb8c8a8de5", + "size": 2649280, + "subdir": "linux-64", + "timestamp": 1657551292835, + "track_features": "", + "url": "https://repo.anaconda.com/pkgs/main/linux-64/openssl-1.1.1q-h7f8727e_0.conda", + "version": "1.1.1q" +} \ No newline at end of file diff --git a/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json b/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json new file mode 100644 index 0000000000..4788d70e21 --- /dev/null +++ b/integration/testdata/fixtures/fs/conda/miniconda3/envs/testenv/conda-meta/pip-22.2.2-py38h06a4308_0.json @@ -0,0 +1,62 @@ +{ + "build": "py38h06a4308_0", + "build_number": 0, + "channel": "https://repo.anaconda.com/pkgs/main/linux-64", + "constrains": [], + "depends": [ + "python >=3.8,<3.9.0a0", + "setuptools", + "wheel" + ], + "extracted_package_dir": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0", + "features": "", + "files": [ + "bin/pip", + "", + "lib/python3.8/site-packages/pip/py.typed" + ], + "fn": "pip-22.2.2-py38h06a4308_0.conda", + "legacy_bz2_md5": "2ac9f1cfec65a1e4ef00cc0132ecd753", + "legacy_bz2_size": 2849993, + "license": "MIT", + "license_family": "MIT", + "link": { + "source": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0", + "type": 1 + }, + "md5": "ed3e0331e7c614b3148c9911e1fc15e3", + "name": "pip", + "package_tarball_full_path": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0.conda", + "paths_data": { + "paths": [ + { + "_path": "bin/pip", + "file_mode": "text", + "path_type": "hardlink", + "prefix_placeholder": "/opt/conda/conda-bld/pip_1664552683240/_h_env_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold", + "sha256": "1110c03ca2fb86e43e8b52a61cd9d7c722b2817fc893a613e1a947f5d7049008", + "sha256_in_prefix": "14ed5ba79d096035b83a469e863acbbaadd3ea6ca5f23f79eefa91fa857d3f19", + "size_in_bytes": 475 + }, + { + "_path": "" + }, + { + "_path": "lib/python3.8/site-packages/pip/py.typed", + "path_type": "hardlink", + "sha256": "10156fbcf4539ff788a73e5ee50ced48276b317ed0c1ded53fddd14a82256762", + "sha256_in_prefix": "10156fbcf4539ff788a73e5ee50ced48276b317ed0c1ded53fddd14a82256762", + "size_in_bytes": 286 + } + ], + "paths_version": 1 + }, + "requested_spec": "None", + "sha256": "3fb76b94cfa5ea9732bfb241b3d234ec0a5a48d16755c3c1ef3c94630f91eb26", + "size": 2417732, + "subdir": "linux-64", + "timestamp": 1664552878795, + "track_features": "", + "url": "https://repo.anaconda.com/pkgs/main/linux-64/pip-22.2.2-py38h06a4308_0.conda", + "version": "22.2.2" +} \ No newline at end of file diff --git a/pkg/detector/library/driver.go b/pkg/detector/library/driver.go index 92f79a096e..c22e214831 100644 --- a/pkg/detector/library/driver.go +++ b/pkg/detector/library/driver.go @@ -65,6 +65,9 @@ func NewDriver(libType string) (Driver, error) { case ftypes.Cocoapods: log.Logger.Warn("CocoaPods is supported for SBOM, not for vulnerability scanning") return Driver{}, ErrSBOMSupportOnly + case ftypes.CondaPkg: + log.Logger.Warn("Conda package is supported for SBOM, not for vulnerability scanning") + return Driver{}, ErrSBOMSupportOnly default: return Driver{}, xerrors.Errorf("unsupported type %s", libType) } diff --git a/pkg/fanal/analyzer/all/import.go b/pkg/fanal/analyzer/all/import.go index 3540767f9c..7f0000c54b 100644 --- a/pkg/fanal/analyzer/all/import.go +++ b/pkg/fanal/analyzer/all/import.go @@ -6,6 +6,7 @@ import ( _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/config/all" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/executable" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/c/conan" + _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/conda/meta" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/dart/pub" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/dotnet/deps" _ "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language/dotnet/nuget" diff --git a/pkg/fanal/analyzer/const.go b/pkg/fanal/analyzer/const.go index e315e7f8fb..461cf53c3b 100644 --- a/pkg/fanal/analyzer/const.go +++ b/pkg/fanal/analyzer/const.go @@ -61,6 +61,9 @@ const ( TypeNuget Type = "nuget" TypeDotNetCore Type = "dotnet-core" + // Conda + TypeCondaPkg Type = "conda-pkg" + // Python TypePythonPkg Type = "python-pkg" TypePip Type = "pip" @@ -132,7 +135,7 @@ var ( // TypeLanguages has all language analyzers TypeLanguages = []Type{ TypeBundler, TypeGemSpec, TypeCargo, TypeComposer, TypeJar, TypePom, TypeGradleLock, - TypeNpmPkgLock, TypeNodePkg, TypeYarn, TypePnpm, TypeNuget, TypeDotNetCore, + TypeNpmPkgLock, TypeNodePkg, TypeYarn, TypePnpm, TypeNuget, TypeDotNetCore, TypeCondaPkg, TypePythonPkg, TypePip, TypePipenv, TypePoetry, TypeGoBinary, TypeGoMod, TypeRustBinary, TypeConanLock, TypeCocoaPods, TypePubSpecLock, TypeMixLock, } @@ -145,7 +148,7 @@ var ( } // TypeIndividualPkgs has all analyzers for individual packages - TypeIndividualPkgs = []Type{TypeGemSpec, TypeNodePkg, TypePythonPkg, TypeGoBinary, TypeJar, TypeRustBinary} + TypeIndividualPkgs = []Type{TypeGemSpec, TypeNodePkg, TypeCondaPkg, TypePythonPkg, TypeGoBinary, TypeJar, TypeRustBinary} // TypeConfigFiles has all config file analyzers TypeConfigFiles = []Type{TypeYaml, TypeJSON, TypeDockerfile, TypeTerraform, TypeCloudFormation, TypeHelm} diff --git a/pkg/fanal/analyzer/language/conda/meta/meta.go b/pkg/fanal/analyzer/language/conda/meta/meta.go new file mode 100644 index 0000000000..d3b53c3319 --- /dev/null +++ b/pkg/fanal/analyzer/language/conda/meta/meta.go @@ -0,0 +1,46 @@ +package meta + +import ( + "context" + "os" + "path/filepath" + "regexp" + + "golang.org/x/xerrors" + + "github.com/aquasecurity/go-dep-parser/pkg/conda/meta" + "github.com/aquasecurity/trivy/pkg/fanal/analyzer" + "github.com/aquasecurity/trivy/pkg/fanal/analyzer/language" + "github.com/aquasecurity/trivy/pkg/fanal/types" +) + +func init() { + analyzer.RegisterAnalyzer(&metaAnalyzer{}) +} + +const version = 1 + +var fileRegex = regexp.MustCompile(`.*/envs/.+/conda-meta/.+-.+-.+\.json`) + +type metaAnalyzer struct{} + +func (a metaAnalyzer) Analyze(_ context.Context, input analyzer.AnalysisInput) (*analyzer.AnalysisResult, error) { + p := meta.NewParser() + libs, deps, err := p.Parse(input.Content) + if err != nil { + return nil, xerrors.Errorf("%s parse error: %w", input.FilePath, err) + } + + return language.ToAnalysisResult(types.CondaPkg, input.FilePath, input.FilePath, libs, deps), nil +} +func (a metaAnalyzer) Required(filePath string, _ os.FileInfo) bool { + return fileRegex.MatchString(filepath.ToSlash(filePath)) +} + +func (a metaAnalyzer) Type() analyzer.Type { + return analyzer.TypeCondaPkg +} + +func (a metaAnalyzer) Version() int { + return version +} diff --git a/pkg/fanal/analyzer/language/conda/meta/meta_test.go b/pkg/fanal/analyzer/language/conda/meta/meta_test.go new file mode 100644 index 0000000000..e96a259252 --- /dev/null +++ b/pkg/fanal/analyzer/language/conda/meta/meta_test.go @@ -0,0 +1,101 @@ +package meta + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/fanal/analyzer" + "github.com/aquasecurity/trivy/pkg/fanal/types" +) + +func Test_packagingAnalyzer_Analyze(t *testing.T) { + tests := []struct { + name string + inputFile string + want *analyzer.AnalysisResult + wantErr string + }{ + { + name: "pip", + inputFile: "testdata/pip-22.2.2-py38h06a4308_0.json", + want: &analyzer.AnalysisResult{ + Applications: []types.Application{ + { + Type: types.CondaPkg, + FilePath: "testdata/pip-22.2.2-py38h06a4308_0.json", + Libraries: []types.Package{ + { + Name: "pip", + Version: "22.2.2", + Licenses: []string{"MIT"}, + FilePath: "testdata/pip-22.2.2-py38h06a4308_0.json", + }, + }, + }, + }, + }, + }, + { + name: "invalid", + inputFile: "testdata/invalid.json", + wantErr: "unable to parse conda package", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := os.Open(tt.inputFile) + require.NoError(t, err) + defer f.Close() + + stat, err := f.Stat() + require.NoError(t, err) + + a := metaAnalyzer{} + ctx := context.Background() + got, err := a.Analyze(ctx, analyzer.AnalysisInput{ + FilePath: tt.inputFile, + Info: stat, + Content: f, + }) + + if tt.wantErr != "" { + assert.ErrorContains(t, err, tt.wantErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } + +} + +func Test_packagingAnalyzer_Required(t *testing.T) { + tests := []struct { + name string + filePath string + want bool + }{ + { + name: "pip", + filePath: "/home//miniconda3/envs//conda-meta/pip-22.2.2-py38h06a4308_0.json", + want: true, + }, + { + name: "invalid", + filePath: "/home//miniconda3/envs//conda-meta/invalid.json", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := metaAnalyzer{} + got := a.Required(tt.filePath, nil) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/fanal/analyzer/language/conda/meta/testdata/invalid.json b/pkg/fanal/analyzer/language/conda/meta/testdata/invalid.json new file mode 100644 index 0000000000..9e26dfeeb6 --- /dev/null +++ b/pkg/fanal/analyzer/language/conda/meta/testdata/invalid.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/pkg/fanal/analyzer/language/conda/meta/testdata/pip-22.2.2-py38h06a4308_0.json b/pkg/fanal/analyzer/language/conda/meta/testdata/pip-22.2.2-py38h06a4308_0.json new file mode 100644 index 0000000000..4788d70e21 --- /dev/null +++ b/pkg/fanal/analyzer/language/conda/meta/testdata/pip-22.2.2-py38h06a4308_0.json @@ -0,0 +1,62 @@ +{ + "build": "py38h06a4308_0", + "build_number": 0, + "channel": "https://repo.anaconda.com/pkgs/main/linux-64", + "constrains": [], + "depends": [ + "python >=3.8,<3.9.0a0", + "setuptools", + "wheel" + ], + "extracted_package_dir": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0", + "features": "", + "files": [ + "bin/pip", + "", + "lib/python3.8/site-packages/pip/py.typed" + ], + "fn": "pip-22.2.2-py38h06a4308_0.conda", + "legacy_bz2_md5": "2ac9f1cfec65a1e4ef00cc0132ecd753", + "legacy_bz2_size": 2849993, + "license": "MIT", + "license_family": "MIT", + "link": { + "source": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0", + "type": 1 + }, + "md5": "ed3e0331e7c614b3148c9911e1fc15e3", + "name": "pip", + "package_tarball_full_path": "/home/user/miniconda3/pkgs/pip-22.2.2-py38h06a4308_0.conda", + "paths_data": { + "paths": [ + { + "_path": "bin/pip", + "file_mode": "text", + "path_type": "hardlink", + "prefix_placeholder": "/opt/conda/conda-bld/pip_1664552683240/_h_env_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold_placehold", + "sha256": "1110c03ca2fb86e43e8b52a61cd9d7c722b2817fc893a613e1a947f5d7049008", + "sha256_in_prefix": "14ed5ba79d096035b83a469e863acbbaadd3ea6ca5f23f79eefa91fa857d3f19", + "size_in_bytes": 475 + }, + { + "_path": "" + }, + { + "_path": "lib/python3.8/site-packages/pip/py.typed", + "path_type": "hardlink", + "sha256": "10156fbcf4539ff788a73e5ee50ced48276b317ed0c1ded53fddd14a82256762", + "sha256_in_prefix": "10156fbcf4539ff788a73e5ee50ced48276b317ed0c1ded53fddd14a82256762", + "size_in_bytes": 286 + } + ], + "paths_version": 1 + }, + "requested_spec": "None", + "sha256": "3fb76b94cfa5ea9732bfb241b3d234ec0a5a48d16755c3c1ef3c94630f91eb26", + "size": 2417732, + "subdir": "linux-64", + "timestamp": 1664552878795, + "track_features": "", + "url": "https://repo.anaconda.com/pkgs/main/linux-64/pip-22.2.2-py38h06a4308_0.conda", + "version": "22.2.2" +} \ No newline at end of file diff --git a/pkg/fanal/applier/docker.go b/pkg/fanal/applier/docker.go index 86b3c25d0e..19ddfb6eb0 100644 --- a/pkg/fanal/applier/docker.go +++ b/pkg/fanal/applier/docker.go @@ -232,12 +232,13 @@ func ApplyLayers(layers []types.BlobInfo) types.ArtifactDetail { return mergedLayer } -// aggregate merges all packages installed by pip/gem/npm/jar into each application +// aggregate merges all packages installed by pip/gem/npm/jar/conda into each application func aggregate(detail *types.ArtifactDetail) { var apps []types.Application aggregatedApps := map[string]*types.Application{ types.PythonPkg: {Type: types.PythonPkg}, + types.CondaPkg: {Type: types.CondaPkg}, types.GemSpec: {Type: types.GemSpec}, types.NodePkg: {Type: types.NodePkg}, types.Jar: {Type: types.Jar}, diff --git a/pkg/fanal/handler/sysfile/filter.go b/pkg/fanal/handler/sysfile/filter.go index e1fd98777e..344511bfdc 100644 --- a/pkg/fanal/handler/sysfile/filter.go +++ b/pkg/fanal/handler/sysfile/filter.go @@ -36,6 +36,9 @@ var ( // python types.PythonPkg, + // conda + types.CondaPkg, + // node.js types.NodePkg, diff --git a/pkg/fanal/types/const.go b/pkg/fanal/types/const.go index afadf3d8da..866a54941d 100644 --- a/pkg/fanal/types/const.go +++ b/pkg/fanal/types/const.go @@ -17,6 +17,7 @@ const ( Pip = "pip" Pipenv = "pipenv" Poetry = "poetry" + CondaPkg = "conda-pkg" PythonPkg = "python-pkg" NodePkg = "node-pkg" Yarn = "yarn" diff --git a/pkg/purl/purl.go b/pkg/purl/purl.go index ee8e5484c1..83ff0867d2 100644 --- a/pkg/purl/purl.go +++ b/pkg/purl/purl.go @@ -85,6 +85,8 @@ func (p *PackageURL) PackageType() string { return ftypes.Jar case packageurl.TypeGem: return ftypes.GemSpec + case packageurl.TypeConda: + return ftypes.CondaPkg case packageurl.TypePyPi: return ftypes.PythonPkg case packageurl.TypeGolang: @@ -305,6 +307,8 @@ func purlType(t string) string { return packageurl.TypeGem case ftypes.NuGet, ftypes.DotNetCore: return packageurl.TypeNuget + case ftypes.CondaPkg: + return packageurl.TypeConda case ftypes.PythonPkg, ftypes.Pip, ftypes.Pipenv, ftypes.Poetry: return packageurl.TypePyPi case ftypes.GoBinary, ftypes.GoModule: diff --git a/pkg/purl/purl_test.go b/pkg/purl/purl_test.go index 297b74f3ef..65c3831353 100644 --- a/pkg/purl/purl_test.go +++ b/pkg/purl/purl_test.go @@ -134,6 +134,21 @@ func TestNewPackageURL(t *testing.T) { }, }, }, + { + name: "conda package", + typ: ftypes.CondaPkg, + pkg: ftypes.Package{ + Name: "absl-py", + Version: "0.4.1", + }, + want: purl.PackageURL{ + PackageURL: packageurl.PackageURL{ + Type: packageurl.TypeConda, + Name: "absl-py", + Version: "0.4.1", + }, + }, + }, { name: "composer package", typ: ftypes.Composer, @@ -451,6 +466,18 @@ func TestFromString(t *testing.T) { }, }, }, + { + name: "happy path for conda", + purl: "pkg:conda/absl-py@0.4.1", + want: purl.PackageURL{ + PackageURL: packageurl.PackageURL{ + Type: packageurl.TypeConda, + Name: "absl-py", + Version: "0.4.1", + Qualifiers: packageurl.Qualifiers{}, + }, + }, + }, { name: "bad rpm", purl: "pkg:rpm/redhat/a--@1.0.0", diff --git a/pkg/sbom/cyclonedx/marshal.go b/pkg/sbom/cyclonedx/marshal.go index 69326c4bd4..c6a09022ce 100644 --- a/pkg/sbom/cyclonedx/marshal.go +++ b/pkg/sbom/cyclonedx/marshal.go @@ -249,7 +249,7 @@ func (e *Marshaler) marshalComponents(r types.Report, bomRef string) (*[]cdx.Com } if result.Type == ftypes.NodePkg || result.Type == ftypes.PythonPkg || - result.Type == ftypes.GemSpec || result.Type == ftypes.Jar { + result.Type == ftypes.GemSpec || result.Type == ftypes.Jar || result.Type == ftypes.CondaPkg { // If a package is language-specific package that isn't associated with a lock file, // it will be a dependency of a component under "metadata". // e.g. diff --git a/pkg/sbom/spdx/unmarshal.go b/pkg/sbom/spdx/unmarshal.go index bf5f16e824..45432d4ed6 100644 --- a/pkg/sbom/spdx/unmarshal.go +++ b/pkg/sbom/spdx/unmarshal.go @@ -143,7 +143,7 @@ func initApplication(pkg spdx.Package2_2) *ftypes.Application { FilePath: pkg.PackageSourceInfo, } if pkg.PackageName == ftypes.NodePkg || pkg.PackageName == ftypes.PythonPkg || - pkg.PackageName == ftypes.GemSpec || pkg.PackageName == ftypes.Jar { + pkg.PackageName == ftypes.GemSpec || pkg.PackageName == ftypes.Jar || pkg.PackageName == ftypes.CondaPkg { app.FilePath = "" } return app diff --git a/pkg/scanner/local/scan.go b/pkg/scanner/local/scan.go index 2af74cff08..f6fe7711e9 100644 --- a/pkg/scanner/local/scan.go +++ b/pkg/scanner/local/scan.go @@ -31,6 +31,7 @@ import ( var ( pkgTargets = map[string]string{ ftypes.PythonPkg: "Python", + ftypes.CondaPkg: "Conda", ftypes.GemSpec: "Ruby", ftypes.NodePkg: "Node.js", ftypes.Jar: "Java",