diff --git a/go.mod b/go.mod index 1a99fa7..7e7030e 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ module github.com/bomly-dev/bomly-plugin-scorecard-matcher -go 1.26.3 +go 1.27.0 require ( - github.com/anchore/packageurl-go v0.2.0 - github.com/bomly-dev/bomly-sdk v0.3.0 + github.com/bomly-dev/bomly-sdk v0.8.0 go.uber.org/zap v1.28.0 ) require ( + github.com/CycloneDX/cyclonedx-go v0.11.0 // indirect github.com/fatih/color v1.13.0 // indirect + github.com/github/go-spdx/v2 v2.7.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.8.0 // indirect @@ -17,6 +18,8 @@ require ( github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/oklog/run v1.1.0 // indirect + github.com/package-url/packageurl-go v0.1.7 // indirect + github.com/spdx/tools-golang v0.6.0-rc4 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/go.sum b/go.sum index 2fe7f47..adcd000 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ -github.com/anchore/packageurl-go v0.2.0 h1:CkrM4RMUwrEGAiE1OVlxaZNzWj0TuHRey7o4T/EAErk= -github.com/anchore/packageurl-go v0.2.0/go.mod h1:2JCgOQMIsqZ7TmliXG4PnUthPJAKE3mWQbsW2XHjAOE= -github.com/bomly-dev/bomly-sdk v0.3.0 h1:JtC7qZ9yq3r4fUYyq7e/Os4f9wGMa4Qot8U0l9MepFA= -github.com/bomly-dev/bomly-sdk v0.3.0/go.mod h1:yn1LBkoHG9gDBXKyRj0UNJo0BlXl8Bj9Ymb3WKLIh78= +github.com/CycloneDX/cyclonedx-go v0.11.0 h1:GokP8FiRC+foiuwWhSSLpSD5H4hSWtGnR3wo7apkBFI= +github.com/CycloneDX/cyclonedx-go v0.11.0/go.mod h1:vUvbCXQsEm48OI6oOlanxstwNByXjCZ2wuleUlwGEO8= +github.com/bomly-dev/bomly-sdk v0.8.0 h1:XpIr0yS5vg2B2oMt9xPornZ+krgy2AYlm/ts/At4JA0= +github.com/bomly-dev/bomly-sdk v0.8.0/go.mod h1:7RJLUANK8xHMZ5/r45zYxGyKUHC8yQiVem22yM0MyZw= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= +github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -11,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/github/go-spdx/v2 v2.7.0 h1:GzfXx4wFdlilARxmFRXW/mgUy3A4vSqZocCMFV6XFdQ= +github.com/github/go-spdx/v2 v2.7.0/go.mod h1:Ftc45YYG1WzpzwEPKRVm9Jv8vDqOrN4gWoCkK+bHer0= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -38,12 +42,24 @@ github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPn github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/package-url/packageurl-go v0.1.7 h1:iFWg6tzAjLA6F/qX3M5nZaiMHJgc+p2zxVyr/fY+sZY= +github.com/package-url/packageurl-go v0.1.7/go.mod h1:nKAWB8E6uk1MHqiS/lQb9pYBGH2+mdJ2PJc2s50dQY0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spdx/tools-golang v0.6.0-rc4 h1:2GkvNr0DcnJHY9BDm3OYHo229jZS/h4qYDK+tHYXPOo= +github.com/spdx/tools-golang v0.6.0-rc4/go.mod h1:ruCHu3shgy7bVbZ7gtEU4Gq4fI08n2SdXtgV5PoN/OM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/terminalstatic/go-xsd-validate v0.1.6 h1:TenYeQ3eY631qNi1/cTmLH/s2slHPRKTTHT+XSHkepo= +github.com/terminalstatic/go-xsd-validate v0.1.6/go.mod h1:18lsvYFofBflqCrvo1umpABZ99+GneNTw2kEEc8UPJw= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= diff --git a/plugin/matcher_test.go b/plugin/matcher_test.go index cb4a738..b33418d 100644 --- a/plugin/matcher_test.go +++ b/plugin/matcher_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/bomly-dev/bomly-sdk" + "github.com/bomly-dev/bomly-sdk/testkit" ) const sampleResponse = `{ @@ -45,7 +46,7 @@ func newMatcher(t *testing.T, base string) *Matcher { return m } -func newGraph(t *testing.T, deps ...*sdk.Dependency) *sdk.Graph { +func newGraph(t *testing.T, deps ...*sdk.DependencyNode) *sdk.Graph { t.Helper() g := sdk.New() for _, d := range deps { @@ -57,8 +58,8 @@ func newGraph(t *testing.T, deps ...*sdk.Dependency) *sdk.Graph { } // scorecardOf returns the enriched scorecard for a dependency from the registry. -func scorecardOf(reg *sdk.PackageRegistry, dep *sdk.Dependency) *sdk.PackageScorecard { - pkg, ok := reg.Get(sdk.CanonicalPackageURLFromDependency(dep)) +func scorecardOf(reg *sdk.PackageRegistry, dep *sdk.DependencyNode) *sdk.PackageScorecard { + pkg, ok := reg.Get(dep.NodeID()) if !ok || pkg == nil { return nil } @@ -76,7 +77,7 @@ func TestMatch_AttachesScorecardToPackages(t *testing.T) { }) matcher := newMatcher(t, base) - dep := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Name: "scorecard", Version: "v5.0.0", PURL: "pkg:github/ossf/scorecard@v5.0.0"}}) + dep := testkit.MustDependencyNode(t, "pkg:github/ossf/scorecard@v5.0.0") g := newGraph(t, dep) registry := sdk.NewPackageRegistry() @@ -116,7 +117,7 @@ func TestMatch_CacheHitSkipsAPI(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } - dep1 := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/ossf/scorecard@v5.0.0", Version: "v5.0.0"}}) + dep1 := testkit.MustDependencyNode(t, "pkg:github/ossf/scorecard@v5.0.0") reg1 := sdk.NewPackageRegistry() if _, err := matcher1.Match(context.Background(), sdk.MatchRequest{Graph: newGraph(t, dep1), Registry: reg1}); err != nil { t.Fatalf("first Match: %v", err) @@ -127,7 +128,7 @@ func TestMatch_CacheHitSkipsAPI(t *testing.T) { if err != nil { t.Fatalf("New2: %v", err) } - dep2 := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/ossf/scorecard@v5.0.0", Version: "v5.0.0"}}) + dep2 := testkit.MustDependencyNode(t, "pkg:github/ossf/scorecard@v5.0.0") reg2 := sdk.NewPackageRegistry() if _, err := matcher2.Match(context.Background(), sdk.MatchRequest{Graph: newGraph(t, dep2), Registry: reg2}); err != nil { t.Fatalf("second Match: %v", err) @@ -146,7 +147,7 @@ func TestMatch_NotFoundCachedAsSentinel(t *testing.T) { }) dir := t.TempDir() - dep1 := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/unscored/repo@1.0.0", Version: "1.0.0"}}) + dep1 := testkit.MustDependencyNode(t, "pkg:github/unscored/repo@1.0.0") reg1 := sdk.NewPackageRegistry() matcher, err := New(Config{APIBase: base, CacheDir: dir}) if err != nil { @@ -160,7 +161,7 @@ func TestMatch_NotFoundCachedAsSentinel(t *testing.T) { } // Second invocation should hit the sentinel cache (no extra API call). - dep2 := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/unscored/repo@1.0.0", Version: "1.0.0"}}) + dep2 := testkit.MustDependencyNode(t, "pkg:github/unscored/repo@1.0.0") reg2 := sdk.NewPackageRegistry() if _, err := matcher.Match(context.Background(), sdk.MatchRequest{Graph: newGraph(t, dep2), Registry: reg2}); err != nil { t.Fatalf("second Match: %v", err) @@ -176,7 +177,7 @@ func TestMatch_ServerErrorIsNonFatal(t *testing.T) { }) matcher := newMatcher(t, base) - dep := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/example/repo@1.0.0", Version: "1.0.0"}}) + dep := testkit.MustDependencyNode(t, "pkg:github/example/repo@1.0.0") reg := sdk.NewPackageRegistry() if _, err := matcher.Match(context.Background(), sdk.MatchRequest{Graph: newGraph(t, dep), Registry: reg}); err != nil { t.Fatalf("Match must not return an error on transport failure; got %v", err) @@ -194,7 +195,7 @@ func TestMatch_SkipsPackagesWithoutResolvableRepo(t *testing.T) { t.Cleanup(srv.Close) matcher := newMatcher(t, srv.URL) - dep := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:npm/internal-only@1.0.0", Version: "1.0.0"}}) + dep := testkit.MustDependencyNode(t, "pkg:npm/internal-only@1.0.0") reg := sdk.NewPackageRegistry() if _, err := matcher.Match(context.Background(), sdk.MatchRequest{Graph: newGraph(t, dep), Registry: reg}); err != nil { t.Fatalf("Match: %v", err) @@ -214,8 +215,8 @@ func TestMatch_ComponentModeOnlyEnrichesTarget(t *testing.T) { }) matcher := newMatcher(t, base) - target := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:github/ossf/scorecard@v5.0.0", Version: "v5.0.0"}}) - other := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{PURL: "pkg:golang/github.com/sirupsen/logrus@v1.9.0", Version: "v1.9.0"}}) + target := testkit.MustDependencyNode(t, "pkg:github/ossf/scorecard@v5.0.0") + other := testkit.MustDependencyNode(t, "pkg:golang/github.com/sirupsen/logrus@v1.9.0") g := newGraph(t, target, other) reg := sdk.NewPackageRegistry() diff --git a/plugin/module_test.go b/plugin/module_test.go index 2f7647f..4216357 100644 --- a/plugin/module_test.go +++ b/plugin/module_test.go @@ -14,6 +14,7 @@ import ( sdk "github.com/bomly-dev/bomly-sdk" "github.com/bomly-dev/bomly-sdk/conformance" + "github.com/bomly-dev/bomly-sdk/testkit" "go.uber.org/zap" ) @@ -74,10 +75,10 @@ func newScorecardFixtureServer(t *testing.T) *httptest.Server { func newDeltaGraphAndRegistry(t *testing.T) (*sdk.Graph, *sdk.PackageRegistry) { t.Helper() - scored := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Name: "scorecard", Version: "v5.0.0", PURL: "pkg:github/ossf/scorecard@v5.0.0"}}) - unscored := sdk.NewDependency(sdk.Dependency{Coordinates: sdk.Coordinates{Name: "left-pad", Version: "1.3.0", PURL: "pkg:npm/left-pad@1.3.0", Ecosystem: sdk.EcosystemNPM}}) + scored := testkit.MustDependencyNode(t, "pkg:github/ossf/scorecard@v5.0.0") + unscored := testkit.MustDependencyNode(t, "pkg:npm/left-pad@1.3.0") graph := sdk.New() - for _, dep := range []*sdk.Dependency{scored, unscored} { + for _, dep := range []*sdk.DependencyNode{scored, unscored} { if err := graph.AddNode(dep); err != nil { t.Fatalf("AddNode: %v", err) } diff --git a/plugin/reporesolve.go b/plugin/reporesolve.go index 0334b67..e8203d2 100644 --- a/plugin/reporesolve.go +++ b/plugin/reporesolve.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - "github.com/anchore/packageurl-go" "github.com/bomly-dev/bomly-sdk" + "github.com/bomly-dev/bomly-sdk/purlkit" ) // githubRepoPattern matches an org/repo segment in any github.com URL form @@ -22,12 +22,22 @@ var githubRepoPattern = regexp.MustCompile(`github\.com[/:]([A-Za-z0-9_.-]+)/([A // owner/repo, so it is safe to append to the api.scorecard.dev URL. // // Resolution order, cheapest first: -// 1. PURL `repository_url` / `vcs_url` qualifier (set by Syft for some -// ecosystems). -// 2. PURL of type `golang` — module path is the repo for github.com modules. -// 3. PURL of type `github` — `pkg:github/{owner}/{repo}`. -// 4. PackageResolvedURL — common for npm/pnpm/yarn tarballs hosted on GitHub. -// 5. NPM metadata `repository` link. +// 1. The package's detected origins — the vetted ADR-0033 repository or +// artifact URL the detector recorded. +// 2. PURL `repository_url` / `vcs_url` qualifier, for a PURL that arrived +// from outside the graph and still carries them. +// 3. PURL of type `golang` — module path is the repo for github.com modules. +// 4. PURL of type `github` — `pkg:github/{owner}/{repo}`. +// 5. PackageResolvedURL — common for npm/pnpm/yarn tarballs hosted on GitHub. +// 6. NPM metadata `repository` link. +// +// Origins lead because ADR-0041 moved the evidence there. The URL-valued +// qualifiers this used to read first — repository_url, download_url, vcs_url — +// are relocated out of a package PURL and into origins when a node is +// constructed, so the qualifier step alone would now find nothing for any +// package that came through the graph. That failure is silent: resolution +// returns "" and the package is simply never scored, which looks like a +// package with no GitHub source rather than a bug. // // Multiple packages frequently resolve to the same repo (a monorepo's npm // packages all point at one source); the matcher dedupes by the returned @@ -37,6 +47,9 @@ func resolveRepo(pkg *sdk.Package) string { return "" } + if repo := repoFromOrigins(pkg.DetectedOrigins); repo != "" { + return repo + } if repo := repoFromPURL(pkg.PURL); repo != "" { return repo } @@ -49,12 +62,30 @@ func resolveRepo(pkg *sdk.Package) string { return "" } +// repoFromOrigins reads the vetted origin evidence. A repository claim is +// preferred over an artifact URL: the first names a source repository +// outright, while the second is a download location that merely happens to +// be hosted on GitHub. +func repoFromOrigins(origins []sdk.DependencyOrigin) string { + for _, origin := range origins { + if repo := extractGithubRepo(origin.Repository); repo != "" { + return repo + } + } + for _, origin := range origins { + if repo := extractGithubRepo(origin.ArtifactURL); repo != "" { + return repo + } + } + return "" +} + func repoFromPURL(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } - parsed, err := packageurl.FromString(raw) + parsed, err := purlkit.Parse(raw) if err != nil { return "" } diff --git a/plugin/reporesolve_origins_test.go b/plugin/reporesolve_origins_test.go new file mode 100644 index 0000000..1626052 --- /dev/null +++ b/plugin/reporesolve_origins_test.go @@ -0,0 +1,79 @@ +package plugin + +import ( + "testing" + + sdk "github.com/bomly-dev/bomly-sdk" +) + +// TestResolveRepoReadsDetectedOrigins pins the fix for a silent regression the +// SDK v0.7.0 bump would otherwise have introduced. +// +// This matcher used to read the repository out of the package PURL's +// repository_url / vcs_url / download_url qualifiers. ADR-0041 relocates those +// out of a package PURL and into origins at node construction, so that path +// now finds nothing for any package that came through the graph — and the +// failure is silent. Resolution returns "", the package is never scored, and +// the result is indistinguishable from a package that genuinely has no GitHub +// source. +func TestResolveRepoReadsDetectedOrigins(t *testing.T) { + cases := []struct { + name string + pkg *sdk.Package + want string + }{ + { + name: "repository origin", + pkg: &sdk.Package{ + PURL: "pkg:npm/left-pad@1.3.0", + DetectedOrigins: []sdk.DependencyOrigin{{Repository: "https://github.com/ossf/scorecard", Revision: "abc123"}}, + }, + want: "github.com/ossf/scorecard", + }, + { + name: "artifact origin hosted on github", + pkg: &sdk.Package{ + PURL: "pkg:npm/left-pad@1.3.0", + DetectedOrigins: []sdk.DependencyOrigin{{ArtifactURL: "https://github.com/ossf/scorecard/archive/v5.0.0.tar.gz"}}, + }, + want: "github.com/ossf/scorecard", + }, + { + name: "a repository claim outranks an artifact URL", + pkg: &sdk.Package{ + PURL: "pkg:npm/left-pad@1.3.0", + DetectedOrigins: []sdk.DependencyOrigin{ + {ArtifactURL: "https://github.com/mirror/copy/archive/v1.tar.gz"}, + {Repository: "https://github.com/ossf/scorecard"}, + }, + }, + want: "github.com/ossf/scorecard", + }, + { + name: "no github source anywhere", + pkg: &sdk.Package{ + PURL: "pkg:npm/left-pad@1.3.0", + DetectedOrigins: []sdk.DependencyOrigin{{Repository: "https://gitlab.com/owner/repo"}}, + }, + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolveRepo(tc.pkg); got != tc.want { + t.Errorf("resolveRepo = %q, want %q", got, tc.want) + } + }) + } +} + +// TestResolveRepoStillReadsQualifiers pins that the qualifier path is kept as +// a fallback. A PURL that arrived from outside the graph — an ingested SBOM +// that was never through a node constructor — can still carry them, and +// dropping the step would lose a source the document did state. +func TestResolveRepoStillReadsQualifiers(t *testing.T) { + pkg := &sdk.Package{PURL: "pkg:npm/left-pad@1.3.0?repository_url=https://github.com/ossf/scorecard"} + if got := resolveRepo(pkg); got != "github.com/ossf/scorecard" { + t.Errorf("resolveRepo = %q, want the qualifier fallback to still work", got) + } +}