diff --git a/lib/ecosystems/enrich.go b/lib/ecosystems/enrich.go index bb2852a..4b5d392 100644 --- a/lib/ecosystems/enrich.go +++ b/lib/ecosystems/enrich.go @@ -18,6 +18,7 @@ package ecosystems import ( cdx "github.com/CycloneDX/cyclonedx-go" + "github.com/spdx/tools-golang/spdx" "github.com/snyk/parlay/lib/sbom" ) @@ -26,6 +27,8 @@ func EnrichSBOM(doc *sbom.SBOMDocument) *sbom.SBOMDocument { switch bom := doc.BOM.(type) { case *cdx.BOM: enrichCDX(bom) + case *spdx.Document: + enrichSPDX(bom) } return doc } diff --git a/lib/ecosystems/enrich_cyclonedx.go b/lib/ecosystems/enrich_cyclonedx.go index 826e3c1..3f63085 100644 --- a/lib/ecosystems/enrich_cyclonedx.go +++ b/lib/ecosystems/enrich_cyclonedx.go @@ -1,3 +1,19 @@ +/* + * © 2023 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + package ecosystems import ( @@ -13,29 +29,29 @@ import ( type cdxEnricher = func(cdx.Component, packages.Package) cdx.Component var cdxEnrichers = []cdxEnricher{ - enrichDescription, - enrichLicense, - enrichHomepage, - enrichRegistryURL, - enrichRepositoryURL, - enrichDocumentationURL, - enrichFirstReleasePublishedAt, - enrichLatestReleasePublishedAt, - enrichRepoArchived, - enrichLocation, - enrichTopics, - enrichAuthor, - enrichSupplier, -} - -func enrichDescription(component cdx.Component, packageData packages.Package) cdx.Component { + enrichCDXDescription, + enrichCDXLicense, + enrichCDXHomepage, + enrichCDXRegistryURL, + enrichCDXRepositoryURL, + enrichCDXDocumentationURL, + enrichCDXFirstReleasePublishedAt, + enrichCDXLatestReleasePublishedAt, + enrichCDXRepoArchived, + enrichCDXLocation, + enrichCDXTopics, + enrichCDXAuthor, + enrichCDXSupplier, +} + +func enrichCDXDescription(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.Description != nil { component.Description = *packageData.Description } return component } -func enrichLicense(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXLicense(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.NormalizedLicenses != nil { if len(packageData.NormalizedLicenses) > 0 { expression := packageData.NormalizedLicenses[0] @@ -75,23 +91,23 @@ func enrichProperty(component cdx.Component, name string, value string) cdx.Comp return component } -func enrichHomepage(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXHomepage(component cdx.Component, packageData packages.Package) cdx.Component { return enrichExternalReference(component, packageData, packageData.Homepage, cdx.ERTypeWebsite) } -func enrichRegistryURL(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXRegistryURL(component cdx.Component, packageData packages.Package) cdx.Component { return enrichExternalReference(component, packageData, packageData.RegistryUrl, cdx.ERTypeDistribution) } -func enrichRepositoryURL(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXRepositoryURL(component cdx.Component, packageData packages.Package) cdx.Component { return enrichExternalReference(component, packageData, packageData.RepositoryUrl, cdx.ERTypeVCS) } -func enrichDocumentationURL(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXDocumentationURL(component cdx.Component, packageData packages.Package) cdx.Component { return enrichExternalReference(component, packageData, packageData.DocumentationUrl, cdx.ERTypeDocumentation) } -func enrichFirstReleasePublishedAt(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXFirstReleasePublishedAt(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.FirstReleasePublishedAt == nil { return component } @@ -99,7 +115,7 @@ func enrichFirstReleasePublishedAt(component cdx.Component, packageData packages return enrichProperty(component, "ecosystems:first_release_published_at", timestamp) } -func enrichLatestReleasePublishedAt(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXLatestReleasePublishedAt(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.LatestReleasePublishedAt == nil { return component } @@ -107,7 +123,7 @@ func enrichLatestReleasePublishedAt(component cdx.Component, packageData package return enrichProperty(component, "ecosystems:latest_release_published_at", timestamp) } -func enrichRepoArchived(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXRepoArchived(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.RepoMetadata != nil { if archived, ok := (*packageData.RepoMetadata)["archived"].(bool); ok && archived { return enrichProperty(component, "ecosystems:repository_archived", "true") @@ -116,7 +132,7 @@ func enrichRepoArchived(component cdx.Component, packageData packages.Package) c return component } -func enrichLocation(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXLocation(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.RepoMetadata != nil { meta := *packageData.RepoMetadata if ownerRecord, ok := meta["owner_record"].(map[string]interface{}); ok { @@ -128,7 +144,7 @@ func enrichLocation(component cdx.Component, packageData packages.Package) cdx.C return component } -func enrichAuthor(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXAuthor(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.RepoMetadata != nil { meta := *packageData.RepoMetadata if ownerRecord, ok := meta["owner_record"].(map[string]interface{}); ok { @@ -141,7 +157,7 @@ func enrichAuthor(component cdx.Component, packageData packages.Package) cdx.Com return component } -func enrichSupplier(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXSupplier(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.RepoMetadata != nil { meta := *packageData.RepoMetadata if ownerRecord, ok := meta["owner_record"].(map[string]interface{}); ok { @@ -161,7 +177,7 @@ func enrichSupplier(component cdx.Component, packageData packages.Package) cdx.C return component } -func enrichTopics(component cdx.Component, packageData packages.Package) cdx.Component { +func enrichCDXTopics(component cdx.Component, packageData packages.Package) cdx.Component { if packageData.RepoMetadata != nil { meta := *packageData.RepoMetadata diff --git a/lib/ecosystems/enrich_test.go b/lib/ecosystems/enrich_cyclonedx_test.go similarity index 91% rename from lib/ecosystems/enrich_test.go rename to lib/ecosystems/enrich_cyclonedx_test.go index 2dbf771..a297f29 100644 --- a/lib/ecosystems/enrich_test.go +++ b/lib/ecosystems/enrich_cyclonedx_test.go @@ -29,7 +29,7 @@ import ( "github.com/snyk/parlay/lib/sbom" ) -func TestEnrichSBOM(t *testing.T) { +func TestEnrichSBOM_CycloneDX(t *testing.T) { httpmock.Activate() defer httpmock.DeactivateAndReset() @@ -118,7 +118,7 @@ func TestEnrichDescription(t *testing.T) { pack := packages.Package{ Description: &desc, } - component = enrichDescription(component, pack) + component = enrichCDXDescription(component, pack) assert.Equal(t, "description", component.Description) } @@ -131,7 +131,7 @@ func TestEnrichLicense(t *testing.T) { pack := packages.Package{ NormalizedLicenses: []string{"BSD-3-Clause"}, } - component = enrichLicense(component, pack) + component = enrichCDXLicense(component, pack) licenses := *component.Licenses comp := cdx.LicenseChoice(cdx.LicenseChoice{Expression: "BSD-3-Clause"}) @@ -172,7 +172,7 @@ func TestEnrichHomepageWithNilHomepage(t *testing.T) { component := cdx.Component{} packageData := packages.Package{Homepage: nil} - result := enrichHomepage(component, packageData) + result := enrichCDXHomepage(component, packageData) assert.Equal(t, component, result) } @@ -181,7 +181,7 @@ func TestEnrichHomepageWithNonNullHomepage(t *testing.T) { component := cdx.Component{} packageData := packages.Package{Homepage: pointerToString("https://example.com")} - result := enrichHomepage(component, packageData) + result := enrichCDXHomepage(component, packageData) expected := cdx.Component{ ExternalReferences: &[]cdx.ExternalReference{ @@ -195,7 +195,7 @@ func TestEnrichRegistryURLWithNilRegistryURL(t *testing.T) { component := cdx.Component{} packageData := packages.Package{RegistryUrl: nil} - result := enrichRegistryURL(component, packageData) + result := enrichCDXRegistryURL(component, packageData) assert.Equal(t, component, result) } @@ -204,7 +204,7 @@ func TestEnrichRegistryURLWithNonNullRegistryURL(t *testing.T) { component := cdx.Component{} packageData := packages.Package{RegistryUrl: pointerToString("https://example.com")} - result := enrichRegistryURL(component, packageData) + result := enrichCDXRegistryURL(component, packageData) expected := cdx.Component{ ExternalReferences: &[]cdx.ExternalReference{ @@ -224,13 +224,13 @@ func TestEnrichLatestReleasePublishedAt(t *testing.T) { LatestReleasePublishedAt: nil, } - result := enrichLatestReleasePublishedAt(component, packageData) + result := enrichCDXLatestReleasePublishedAt(component, packageData) assert.Equal(t, component, result) latestReleasePublishedAt := time.Date(2023, time.May, 1, 0, 0, 0, 0, time.UTC) packageData.LatestReleasePublishedAt = &latestReleasePublishedAt expectedTimestamp := latestReleasePublishedAt.UTC().Format(time.RFC3339) - result = enrichLatestReleasePublishedAt(component, packageData) + result = enrichCDXLatestReleasePublishedAt(component, packageData) prop := (*result.Properties)[0] assert.Equal(t, "ecosystems:latest_release_published_at", prop.Name) @@ -243,7 +243,7 @@ func TestEnrichLocation(t *testing.T) { // Test case 1: packageData.RepoMetadata is nil component := cdx.Component{Name: "test"} packageData := packages.Package{} - result := enrichLocation(component, packageData) + result := enrichCDXLocation(component, packageData) assert.Equal(component, result) // Test case 2: packageData.RepoMetadata is not nil, but "owner_record" is missing @@ -251,7 +251,7 @@ func TestEnrichLocation(t *testing.T) { packageData = packages.Package{RepoMetadata: &map[string]interface{}{ "not_owner_record": map[string]interface{}{}, }} - result = enrichLocation(component, packageData) + result = enrichCDXLocation(component, packageData) assert.Equal(component, result) // Test case 3: "location" field is missing in "owner_record" @@ -261,7 +261,7 @@ func TestEnrichLocation(t *testing.T) { "not_location": "test", }, }} - result = enrichLocation(component, packageData) + result = enrichCDXLocation(component, packageData) assert.Equal(component, result) // Test case 4: "location" field is present in "owner_record" @@ -277,6 +277,6 @@ func TestEnrichLocation(t *testing.T) { {Name: "ecosystems:owner_location", Value: "test_location"}, }, } - result = enrichLocation(component, packageData) + result = enrichCDXLocation(component, packageData) assert.Equal(expectedComponent, result) } diff --git a/lib/ecosystems/enrich_spdx.go b/lib/ecosystems/enrich_spdx.go new file mode 100644 index 0000000..83c2684 --- /dev/null +++ b/lib/ecosystems/enrich_spdx.go @@ -0,0 +1,90 @@ +/* + * © 2023 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ecosystems + +import ( + "errors" + "fmt" + "strings" + + "github.com/package-url/packageurl-go" + "github.com/spdx/tools-golang/spdx" + "github.com/spdx/tools-golang/spdx/v2/v2_3" + + "github.com/snyk/parlay/ecosystems/packages" +) + +func enrichSPDX(bom *spdx.Document) { + packages := bom.Packages + + for _, pkg := range packages { + purl, err := extractPurl(pkg) + if err != nil { + continue + } + + resp, err := GetPackageData(*purl) + if err != nil { + continue + } + + pkgData := resp.JSON200 + if pkgData == nil { + continue + } + + enrichSPDXDescription(pkg, pkgData) + enrichSPDXLicense(pkg, pkgData) + enrichSPDXHomepage(pkg, pkgData) + } +} + +func extractPurl(pkg *v2_3.Package) (*packageurl.PackageURL, error) { + for _, ref := range pkg.PackageExternalReferences { + if ref.RefType != "purl" { + continue + } + purl, err := packageurl.FromString(ref.Locator) + if err != nil { + return nil, err + } + return &purl, nil + } + return nil, errors.New("no purl found on SPDX package") +} + +func enrichSPDXLicense(pkg *v2_3.Package, data *packages.Package) { + if len(data.NormalizedLicenses) == 1 { + pkg.PackageLicenseConcluded = data.NormalizedLicenses[0] + } else if len(data.NormalizedLicenses) > 1 { + pkg.PackageLicenseConcluded = fmt.Sprintf("(%s)", strings.Join(data.NormalizedLicenses, " OR ")) + } +} + +func enrichSPDXHomepage(pkg *v2_3.Package, data *packages.Package) { + if data.Homepage == nil { + return + } + pkg.PackageHomePage = *data.Homepage +} + +func enrichSPDXDescription(pkg *v2_3.Package, data *packages.Package) { + if data.Description == nil { + return + } + pkg.PackageDescription = *data.Description +} diff --git a/lib/ecosystems/enrich_spdx_test.go b/lib/ecosystems/enrich_spdx_test.go new file mode 100644 index 0000000..5f33c14 --- /dev/null +++ b/lib/ecosystems/enrich_spdx_test.go @@ -0,0 +1,75 @@ +/* + * © 2023 Snyk Limited All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package ecosystems + +import ( + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/spdx/tools-golang/spdx/v2/common" + "github.com/spdx/tools-golang/spdx/v2/v2_3" + "github.com/stretchr/testify/assert" + + "github.com/snyk/parlay/lib/sbom" +) + +func TestEnrichSBOM_SPDX(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + httpmock.RegisterResponder("GET", `=~^https://packages.ecosyste.ms/api/v1/registries`, + func(req *http.Request) (*http.Response, error) { + return httpmock.NewJsonResponse(200, map[string]interface{}{ + "description": "description", + "normalized_licenses": []string{ + "BSD-3-Clause", + }, + "homepage": "https://github.com/spdx/tools-golang", + }) + }) + + bom := &v2_3.Document{ + Packages: []*v2_3.Package{ + { + PackageSPDXIdentifier: "pkg:golang/github.com/spdx/tools-golang@v0.5.2", + PackageName: "github.com/spdx/tools-golang", + PackageVersion: "v0.5.2", + PackageExternalReferences: []*v2_3.PackageExternalReference{ + { + Category: common.CategoryPackageManager, + RefType: "purl", + Locator: "pkg:golang/github.com/spdx/tools-golang@v0.5.2", + }, + }, + }, + }, + } + doc := &sbom.SBOMDocument{BOM: bom} + + EnrichSBOM(doc) + + pkgs := bom.Packages + + assert.Equal(t, "description", pkgs[0].PackageDescription) + assert.Equal(t, "BSD-3-Clause", pkgs[0].PackageLicenseConcluded) + assert.Equal(t, "https://github.com/spdx/tools-golang", pkgs[0].PackageHomePage) + + httpmock.GetTotalCallCount() + calls := httpmock.GetCallCountInfo() + assert.Equal(t, len(pkgs), calls[`GET =~^https://packages.ecosyste.ms/api/v1/registries`]) +}