Skip to content

Commit

Permalink
feat: add SPDX support to scorecard (#28)
Browse files Browse the repository at this point in the history
* feat: add SPDX support to scorecard enrich command
  • Loading branch information
mcombuechen authored Jun 29, 2023
1 parent ebba7bd commit f543a3b
Show file tree
Hide file tree
Showing 6 changed files with 272 additions and 94 deletions.
30 changes: 30 additions & 0 deletions internal/utils/spdx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package utils

import (
"fmt"

"github.com/package-url/packageurl-go"
spdx_2_3 "github.com/spdx/tools-golang/spdx/v2/v2_3"
)

func GetPurlFromSPDXPackage(pkg *spdx_2_3.Package) (*packageurl.PackageURL, error) {
var p string

for _, ref := range pkg.PackageExternalReferences {
if ref.RefType == "purl" {
p = ref.Locator
break
}
}

if p == "" {
return nil, fmt.Errorf("no purl on package %s", pkg.PackageName)
}

purl, err := packageurl.FromString(p)
if err != nil {
return nil, err
}

return &purl, nil
}
74 changes: 22 additions & 52 deletions lib/scorecard/enrich.go
Original file line number Diff line number Diff line change
@@ -1,65 +1,35 @@
/*
* © 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 scorecard

import (
"net/http"
"strings"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/package-url/packageurl-go"
"github.com/remeh/sizedwaitgroup"
"github.com/spdx/tools-golang/spdx"

"github.com/snyk/parlay/lib/ecosystems"
"github.com/snyk/parlay/lib/sbom"
)

func enrichExternalReference(component cdx.Component, url string, comment string, refType cdx.ExternalReferenceType) cdx.Component {
ext := cdx.ExternalReference{
URL: url,
Comment: comment,
Type: refType,
}
if component.ExternalReferences == nil {
component.ExternalReferences = &[]cdx.ExternalReference{ext}
} else {
*component.ExternalReferences = append(*component.ExternalReferences, ext)
}
return component
}

func EnrichSBOM(doc *sbom.SBOMDocument) *sbom.SBOMDocument {
bom, ok := doc.BOM.(*cdx.BOM)
if !ok {
return doc
}

if bom.Components == nil {
return doc
}

wg := sizedwaitgroup.New(20)
newComponents := make([]cdx.Component, len(*bom.Components))
for i, component := range *bom.Components {
wg.Add()
go func(component cdx.Component, i int) {
// TODO: return when there is no usable Purl on the component.
purl, _ := packageurl.FromString(component.PackageURL) //nolint:errcheck
resp, err := ecosystems.GetPackageData(purl)
if err == nil && resp.JSON200 != nil && resp.JSON200.RepositoryUrl != nil {
scorecardUrl := strings.ReplaceAll(*resp.JSON200.RepositoryUrl, "https://", "https://api.securityscorecards.dev/projects/")
response, err := http.Get(scorecardUrl)
if err == nil {
defer response.Body.Close()
if response.StatusCode == http.StatusOK {
component = enrichExternalReference(component, scorecardUrl, "OpenSSF Scorecard", cdx.ERTypeOther)
}
}
}
newComponents[i] = component
wg.Done()
}(component, i)
switch bom := doc.BOM.(type) {
case *cdx.BOM:
enrichCDX(bom)
case *spdx.Document:
enrichSPDX(bom)
}
wg.Wait()
bom.Components = &newComponents

return doc
}
73 changes: 73 additions & 0 deletions lib/scorecard/enrich_cyclonedx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* © 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 scorecard

import (
"net/http"
"strings"

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/package-url/packageurl-go"
"github.com/remeh/sizedwaitgroup"

"github.com/snyk/parlay/lib/ecosystems"
)

func cdxEnrichExternalReference(component cdx.Component, url string, comment string, refType cdx.ExternalReferenceType) cdx.Component {
ext := cdx.ExternalReference{
URL: url,
Comment: comment,
Type: refType,
}
if component.ExternalReferences == nil {
component.ExternalReferences = &[]cdx.ExternalReference{ext}
} else {
*component.ExternalReferences = append(*component.ExternalReferences, ext)
}
return component
}

func enrichCDX(bom *cdx.BOM) {
if bom.Components == nil {
return
}

wg := sizedwaitgroup.New(20)
newComponents := make([]cdx.Component, len(*bom.Components))
for i, component := range *bom.Components {
wg.Add()
go func(component cdx.Component, i int) {
// TODO: return when there is no usable Purl on the component.
purl, _ := packageurl.FromString(component.PackageURL) //nolint:errcheck
resp, err := ecosystems.GetPackageData(purl)
if err == nil && resp.JSON200 != nil && resp.JSON200.RepositoryUrl != nil {
scorecardUrl := strings.ReplaceAll(*resp.JSON200.RepositoryUrl, "https://", "https://api.securityscorecards.dev/projects/")
response, err := http.Get(scorecardUrl)
if err == nil {
defer response.Body.Close()
if response.StatusCode == http.StatusOK {
component = cdxEnrichExternalReference(component, scorecardUrl, "OpenSSF Scorecard", cdx.ERTypeOther)
}
}
}
newComponents[i] = component
wg.Done()
}(component, i)
}
wg.Wait()
bom.Components = &newComponents
}
66 changes: 66 additions & 0 deletions lib/scorecard/enrich_spdx.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/*
* © 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 scorecard

import (
"net/http"
"strings"

"github.com/remeh/sizedwaitgroup"
"github.com/spdx/tools-golang/spdx"
spdx_2_3 "github.com/spdx/tools-golang/spdx/v2/v2_3"

"github.com/snyk/parlay/internal/utils"
"github.com/snyk/parlay/lib/ecosystems"
)

func enrichSPDX(bom *spdx.Document) {
wg := sizedwaitgroup.New(20)

for i, pkg := range bom.Packages {
wg.Add()

go func(pkg *spdx_2_3.Package, i int) {
defer wg.Done()

purl, err := utils.GetPurlFromSPDXPackage(pkg)
if err != nil {
return
}

resp, err := ecosystems.GetPackageData(*purl)
if err != nil || resp.JSON200 == nil || resp.JSON200.RepositoryUrl == nil {
return
}

scURL := strings.ReplaceAll(*resp.JSON200.RepositoryUrl, "https://", "https://api.securityscorecards.dev/projects/")

response, err := http.Get(scURL)
if err != nil || response.StatusCode != http.StatusOK {
return
}

pkg.PackageExternalReferences = append(pkg.PackageExternalReferences, &spdx_2_3.PackageExternalReference{
Category: "OTHER",
RefType: "openssfscorecard",
Locator: scURL,
})
}(pkg, i)
}

wg.Wait()
}
97 changes: 79 additions & 18 deletions lib/scorecard/enrich_test.go
Original file line number Diff line number Diff line change
@@ -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 scorecard

import (
Expand All @@ -7,29 +23,18 @@ import (

cdx "github.com/CycloneDX/cyclonedx-go"
"github.com/jarcoal/httpmock"
"github.com/spdx/tools-golang/spdx"
spdx_2_3 "github.com/spdx/tools-golang/spdx/v2/v2_3"
"github.com/stretchr/testify/assert"

"github.com/snyk/parlay/lib/sbom"
)

func TestEnrichSBOM(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
const scorecardURL = "https://api.securityscorecards.dev/projects/example.com/repository"

httpmock.RegisterResponder("GET", `=~^https://packages.ecosyste.ms/api/v1/registries`,
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, map[string]interface{}{
"repository_url": "https://example.com/repository",
})
})

httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("unexpected HTTP request: " + req.URL.String())
})

scorecardUrl := "https://api.securityscorecards.dev/projects/example.com/repository"
httpmock.RegisterResponder("GET", scorecardUrl,
httpmock.NewStringResponder(http.StatusOK, "{}"))
func TestEnrichSBOM_CycloneDX(t *testing.T) {
teardown := setupEcosystemsAPIMock(t)
defer teardown()

bom := &cdx.BOM{
Components: &[]cdx.Component{
Expand All @@ -48,7 +53,7 @@ func TestEnrichSBOM(t *testing.T) {
enrichedComponent := (*bom.Components)[0]
assert.NotNil(t, enrichedComponent.ExternalReferences)
assert.Len(t, *enrichedComponent.ExternalReferences, 1)
assert.Equal(t, scorecardUrl, (*enrichedComponent.ExternalReferences)[0].URL)
assert.Equal(t, scorecardURL, (*enrichedComponent.ExternalReferences)[0].URL)
assert.Equal(t, "OpenSSF Scorecard", (*enrichedComponent.ExternalReferences)[0].Comment)
assert.Equal(t, cdx.ERTypeOther, (*enrichedComponent.ExternalReferences)[0].Type)

Expand Down Expand Up @@ -119,3 +124,59 @@ func TestEnrichSBOM_ErrorFetchingScorecard(t *testing.T) {
enrichedComponent := (*bom.Components)[0]
assert.Nil(t, enrichedComponent.ExternalReferences)
}

func TestEnrichSBOM_SPDX(t *testing.T) {
teardown := setupEcosystemsAPIMock(t)
defer teardown()

bom := &spdx.Document{
Packages: []*spdx_2_3.Package{
{
PackageExternalReferences: []*spdx_2_3.PackageExternalReference{
{
Category: "OTHER",
RefType: "purl",
Locator: "pkg:golang/snyk/parlay",
},
},
},
},
}
doc := &sbom.SBOMDocument{BOM: bom}

EnrichSBOM(doc)

pkg := bom.Packages[0]
assert.NotNil(t, pkg.PackageExternalReferences)
assert.Len(t, pkg.PackageExternalReferences, 2)

scRef := pkg.PackageExternalReferences[1]
assert.Equal(t, scorecardURL, scRef.Locator)
assert.Equal(t, "openssfscorecard", scRef.RefType)
assert.Equal(t, "OTHER", scRef.Category)
}

func setupEcosystemsAPIMock(t *testing.T) func() {
t.Helper()

httpmock.Activate()
httpmock.RegisterResponder(
"GET",
"=~^https://packages.ecosyste.ms/api/v1/registries",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewJsonResponse(200, map[string]interface{}{
"repository_url": "https://example.com/repository",
})
},
)
httpmock.RegisterResponder(
"GET",
scorecardURL,
httpmock.NewStringResponder(http.StatusOK, "{}"),
)
httpmock.RegisterNoResponder(func(req *http.Request) (*http.Response, error) {
return nil, errors.New("unexpected HTTP request: " + req.URL.String())
})

return httpmock.DeactivateAndReset
}
Loading

0 comments on commit f543a3b

Please sign in to comment.