commit 3ee3c748e91ff044ef2b1371bd668df8cbd708df Author: Dan Lorenc Date: Fri Oct 9 09:47:59 2020 -0500 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..09783425 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# IDE directories +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..f49a4e16 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..3a44d8a7 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Open Source Scorecards + +Motivation: + +## Usage + +The program only requires one argument to run, the name of the repo: + +```shell +$ scorecards --repo=github.com/kubernetes/kubernetes +Security-MD 10 true +Contributors 10 true +Signed-Tags 7 false +Signed-Releases 0 false +Code-Review 10 true +CI-Tests 10 true +Frozen-Deps 10 true +``` + +You'll probably also need to set an Oauth token to avoid rate limits. +You can create a personal access token by following these steps: https://docs.github.com/en/free-pro-team@latest/developers/apps/about-apps#personal-access-tokens + +Set that as an environment variable: + +```shell +export GITHUB_OAUTH_TOKEN= +``` + +## Checks + +The following checks are all run against the target project: + +| Name | Description | +|---|---| +| Security-MD | Does the project contain security policies? | +| Contributors | Does the project have contributors from at least two different organizations? | +| Frozen-Deps | Does the project declare and freeze dependencies? | +| Signed-Tags | Does the project cryptographically sign release tags? | +| Signed-Releases | Does the project cryptographically sign releases? | +| CI-Tests | Does the project run tests in CI? | +| Code-Review | Does the project require code review before code is merged? | + +To see detailed information on how each check works, see the check-specific documentation pages. + +## Results + +Each check returns a pass/fail decision, as well as a confidence score between 0 and 10. +A confidence of 0 should indicate the check was unable to achieve any real signal, and the result +should be ignored. +A confidence of 10 indicates the check is completely sure of the result. + +Many of the checks are based on heuristics, contributions are welcome to improve the detection! + +## Contributing + +See the [Contributing](contributing.md) documentation for guidance on how to contribute. diff --git a/checker/checker.go b/checker/checker.go new file mode 100644 index 00000000..261a8c12 --- /dev/null +++ b/checker/checker.go @@ -0,0 +1,15 @@ +package checker + +import ( + "context" + "net/http" + + "github.com/google/go-github/v32/github" +) + +type Checker struct { + Ctx context.Context + Client *github.Client + HttpClient *http.Client + Owner, Repo string +} diff --git a/checks/check.go b/checks/check.go new file mode 100644 index 00000000..ce6e90c7 --- /dev/null +++ b/checks/check.go @@ -0,0 +1,55 @@ +package checks + +import "github.com/dlorenc/scorecard/checker" + +type CheckResult struct { + Pass bool + Message string + Confidence int + ShouldRetry bool + Error error +} + +var InconclusiveResult = CheckResult{ + Pass: false, + Confidence: 0, +} + +var retryResult = CheckResult{ + Pass: false, + ShouldRetry: true, +} + +func RetryResult(err error) CheckResult { + r := retryResult + r.Error = err + return r +} + +type CheckFn func(*checker.Checker) CheckResult + +func MultiCheck(fns ...CheckFn) CheckFn { + threshold := 7 + + return func(c *checker.Checker) CheckResult { + var maxResult CheckResult + + for _, fn := range fns { + result := fn(c) + if result.Confidence > threshold { + return result + } + if result.Confidence >= maxResult.Confidence { + maxResult = result + } + } + return maxResult + } +} + +type NamedCheck struct { + Name string + Fn CheckFn +} + +var AllChecks = []NamedCheck{} diff --git a/checks/codereview.go b/checks/codereview.go new file mode 100644 index 00000000..31e6276f --- /dev/null +++ b/checks/codereview.go @@ -0,0 +1,127 @@ +package checks + +import ( + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Code-Review", + Fn: DoesCodeReview, + }) +} + +// DoesCodeReview attempts to determine whether a project requires review before code gets merged. +// It uses a set of heuristics: +// - Looking at the repo configuration to see if reviews are required +// - Checking if most of the recent merged PRs were "Approved" +// - Looking for other well-known review labels +func DoesCodeReview(c *checker.Checker) CheckResult { + return MultiCheck( + IsPrReviewRequired, + GithubCodeReview, + ProwCodeReview, + )(c) +} + +func GithubCodeReview(c *checker.Checker) CheckResult { + // Look at some merged PRs to see if they were reviewed + prs, _, err := c.Client.PullRequests.List(c.Ctx, c.Owner, c.Repo, &github.PullRequestListOptions{ + State: "closed", + }) + if err != nil { + return InconclusiveResult + } + + totalMerged := 0 + totalReviewed := 0 + for _, pr := range prs { + if pr.MergedAt == nil { + continue + } + totalMerged++ + // Merged PR! + reviews, _, err := c.Client.PullRequests.ListReviews(c.Ctx, c.Owner, c.Repo, pr.GetNumber(), &github.ListOptions{}) + if err != nil { + continue + } + for _, r := range reviews { + if r.GetState() == "APPROVED" { + totalReviewed++ + break + } + } + } + + // Threshold is 3/4 of merged PRs + actual := float32(totalReviewed) / float32(totalMerged) + if actual >= .75 { + return CheckResult{ + Pass: true, + Confidence: int(actual * 10), + } + } + return CheckResult{ + Pass: false, + Confidence: int(10 - int(actual*10)), + } +} + +func IsPrReviewRequired(c *checker.Checker) CheckResult { + // Look to see if review is enforced. + r, _, err := c.Client.Repositories.Get(c.Ctx, c.Owner, c.Repo) + if err != nil { + return RetryResult(err) + } + + // Check the branch protection rules, we may not be able to get these though. + bp, _, err := c.Client.Repositories.GetBranchProtection(c.Ctx, c.Owner, c.Repo, r.GetDefaultBranch()) + if err != nil { + return InconclusiveResult + } + if bp.GetRequiredPullRequestReviews().RequiredApprovingReviewCount >= 1 { + return CheckResult{ + Pass: true, + Confidence: 10, + } + } + return InconclusiveResult +} + +func ProwCodeReview(c *checker.Checker) CheckResult { + // Look at some merged PRs to see if they were reviewed + prs, _, err := c.Client.PullRequests.List(c.Ctx, c.Owner, c.Repo, &github.PullRequestListOptions{ + State: "closed", + }) + if err != nil { + return InconclusiveResult + } + + totalMerged := 0 + totalReviewed := 0 + for _, pr := range prs { + if pr.MergedAt == nil { + continue + } + totalMerged++ + for _, l := range pr.Labels { + if l.GetName() == "lgtm" || l.GetName() == "approved" { + totalReviewed++ + break + } + } + } + // Threshold is 3/4 of merged PRs + actual := float32(totalReviewed) / float32(totalMerged) + if actual >= .75 { + return CheckResult{ + Pass: true, + Confidence: int(actual * 10), + } + } + return CheckResult{ + Pass: false, + Confidence: int(10 - int(actual*10)), + } +} diff --git a/checks/contributors.go b/checks/contributors.go new file mode 100644 index 00000000..5170a22f --- /dev/null +++ b/checks/contributors.go @@ -0,0 +1,43 @@ +package checks + +import ( + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Contributors", + Fn: Contributors, + }) +} + +func Contributors(c *checker.Checker) CheckResult { + contribs, _, err := c.Client.Repositories.ListContributors(c.Ctx, c.Owner, c.Repo, &github.ListContributorsOptions{}) + if err != nil { + return RetryResult(err) + } + + companies := map[string]struct{}{} + for _, contrib := range contribs { + if contrib.GetContributions() >= 5 { + u, _, err := c.Client.Users.Get(c.Ctx, contrib.GetLogin()) + if err != nil { + return RetryResult(err) + } + if u.GetCompany() != "" { + companies[u.GetCompany()] = struct{}{} + } + } + if len(companies) > 2 { + return CheckResult{ + Pass: true, + Confidence: 10, + } + } + } + return CheckResult{ + Pass: false, + Confidence: 10, + } +} diff --git a/checks/deps.go b/checks/deps.go new file mode 100644 index 00000000..abd16d00 --- /dev/null +++ b/checks/deps.go @@ -0,0 +1,88 @@ +package checks + +import ( + "archive/tar" + "compress/gzip" + "io" + "strings" + + "github.com/dlorenc/scorecard/checker" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Frozen-Deps", + Fn: FrozenDeps, + }) +} + +func FrozenDeps(c *checker.Checker) CheckResult { + r, _, err := c.Client.Repositories.Get(c.Ctx, c.Owner, c.Repo) + if err != nil { + return RetryResult(err) + } + url := r.GetArchiveURL() + url = strings.Replace(url, "{archive_format}", "tarball", 1) + url = strings.Replace(url, "{/ref}", r.GetDefaultBranch(), 1) + + // Download + resp, err := c.HttpClient.Get(url) + if err != nil { + return RetryResult(err) + } + defer resp.Body.Close() + + gz, err := gzip.NewReader(resp.Body) + if err != nil { + return RetryResult(err) + } + tr := tar.NewReader(gz) + + for { + hdr, err := tr.Next() + // Strip the repo name + names := strings.SplitN(hdr.Name, "/", 2) + if len(names) < 2 { + continue + } + + name := names[1] + if err == io.EOF { + break + } else if err != nil { + return RetryResult(err) + } + + switch strings.ToLower(name) { + case "go.mod", "go.sum": + return CheckResult{ + Pass: true, + Confidence: 10, + } + case "vendor", "third_party": + return CheckResult{ + Pass: true, + Confidence: 10, + } + case "package-lock.json": + return CheckResult{ + Pass: true, + Confidence: 10, + } + case "requirements.txt": + return CheckResult{ + Pass: true, + Confidence: 10, + } + case "gemfile.lock": + return CheckResult{ + Pass: true, + Confidence: 10, + } + } + } + return CheckResult{ + Pass: false, + Confidence: 5, + } +} diff --git a/checks/releases.go b/checks/releases.go new file mode 100644 index 00000000..28398532 --- /dev/null +++ b/checks/releases.go @@ -0,0 +1,63 @@ +package checks + +import ( + "strings" + + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Signed-Releases", + Fn: SignedReleases, + }) +} + +func SignedReleases(c *checker.Checker) CheckResult { + releases, _, err := c.Client.Repositories.ListReleases(c.Ctx, c.Owner, c.Repo, &github.ListOptions{}) + if err != nil { + return RetryResult(err) + } + + totalReleases := 0 + totalSigned := 0 + for _, r := range releases { + assets, _, err := c.Client.Repositories.ListReleaseAssets(c.Ctx, c.Owner, c.Repo, r.GetID(), &github.ListOptions{}) + if err != nil { + return RetryResult(err) + } + if len(assets) <= 1 { + continue + } + totalReleases++ + signed := false + for _, asset := range assets { + for _, suffix := range []string{".sig", ".minisig"} { + if strings.HasSuffix(asset.GetName(), suffix) { + signed = true + break + } + } + if signed { + totalSigned++ + break + } + } + } + + if totalReleases == 0 { + return InconclusiveResult + } + actual := float32(totalSigned) / float32(totalReleases) + if actual >= .75 { + return CheckResult{ + Pass: true, + Confidence: int(actual * 10), + } + } + return CheckResult{ + Pass: false, + Confidence: int(10 - int(actual*10)), + } +} diff --git a/checks/security.go b/checks/security.go new file mode 100644 index 00000000..2db12978 --- /dev/null +++ b/checks/security.go @@ -0,0 +1,31 @@ +package checks + +import ( + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Security-MD", + Fn: Securitymd, + }) +} + +func Securitymd(c *checker.Checker) CheckResult { + for _, fp := range []string{".github/SECURITY.md", ".github/security.md", "security.md", "SECURITY.md"} { + dc, err := c.Client.Repositories.DownloadContents(c.Ctx, c.Owner, c.Repo, fp, &github.RepositoryContentGetOptions{}) + if err != nil { + continue + } + dc.Close() + return CheckResult{ + Pass: true, + Confidence: 10, + } + } + return CheckResult{ + Pass: false, + Confidence: 10, + } +} diff --git a/checks/tags.go b/checks/tags.go new file mode 100644 index 00000000..9d81552c --- /dev/null +++ b/checks/tags.go @@ -0,0 +1,46 @@ +package checks + +import ( + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "Signed-Tags", + Fn: SignedTags, + }) +} + +func SignedTags(c *checker.Checker) CheckResult { + tags, _, err := c.Client.Repositories.ListTags(c.Ctx, c.Owner, c.Repo, &github.ListOptions{}) + if err != nil { + return RetryResult(err) + } + + totalReleases := 0 + totalSigned := 0 + for _, t := range tags { + totalReleases++ + gt, _, err := c.Client.Git.GetCommit(c.Ctx, c.Owner, c.Repo, t.GetCommit().GetSHA()) + if err != nil { + return RetryResult(err) + } + if gt.GetVerification().GetVerified() { + totalSigned++ + } + } + + // Threshold is 3/4 of releases + actual := float32(totalSigned) / float32(totalReleases) + if actual >= .75 { + return CheckResult{ + Pass: true, + Confidence: int(actual * 10), + } + } + return CheckResult{ + Pass: false, + Confidence: int(10 - int(actual*10)), + } +} diff --git a/checks/tests.go b/checks/tests.go new file mode 100644 index 00000000..5c7a31b4 --- /dev/null +++ b/checks/tests.go @@ -0,0 +1,66 @@ +package checks + +import ( + "strings" + + "github.com/dlorenc/scorecard/checker" + "github.com/google/go-github/v32/github" +) + +func init() { + AllChecks = append(AllChecks, NamedCheck{ + Name: "CI-Tests", + Fn: GithubChecks, + }) +} + +func GithubChecks(c *checker.Checker) CheckResult { + prs, _, err := c.Client.PullRequests.List(c.Ctx, c.Owner, c.Repo, &github.PullRequestListOptions{ + State: "closed", + }) + if err != nil { + return RetryResult(err) + } + + totalMerged := 0 + totalTested := 0 + for _, pr := range prs { + if pr.MergedAt == nil { + continue + } + totalMerged++ + statuses, _, err := c.Client.Repositories.ListStatuses(c.Ctx, c.Owner, c.Repo, pr.GetHead().GetSHA(), &github.ListOptions{}) + if err != nil { + return RetryResult(err) + } + for _, status := range statuses { + if status.GetState() != "success" { + continue + } + c := status.GetContext() + hadTest := false + for _, pattern := range []string{"travis-ci", "buildkite", "e2e"} { + if strings.Contains(c, pattern) { + hadTest = true + break + } + } + if hadTest { + totalTested++ + break + } + } + } + // Threshold is 3/4 of merged PRs + actual := float32(totalTested) / float32(totalMerged) + if actual >= .75 { + return CheckResult{ + Pass: true, + Confidence: int(actual * 10), + } + } + return CheckResult{ + Pass: false, + Confidence: int(10 - int(actual*10)), + } +} diff --git a/contributing.md b/contributing.md new file mode 100644 index 00000000..c7a9171b --- /dev/null +++ b/contributing.md @@ -0,0 +1,66 @@ +# Contributing to OSS Scorecards! + +Thank you for contributing your time and expertise to the OSS Scorecards project. +This document describes the contribution guidelines for the project. + +**Note:** Before you start contributing, you must read and abide by our **[Code of Conduct](./code-of-conduct.md)**. + +## Contributing code + +### Getting started + +1. Create [a GitHub account](https://github.com/join) +1. Create a [personal access token](https://docs.github.com/en/free-pro-team@latest/developers/apps/about-apps#personal-access-tokens) +1. Set up your [development environment](#environment-setup) + +Then you can [iterate](#iterating). + +## Environment Setup + +You must install these tools: + +1. [`git`](https://help.github.com/articles/set-up-git/): For source control + +1. [`go`](https://golang.org/doc/install): The language Tekton Pipelines is + built in. You need go version [v1.15](https://golang.org/dl/) or higher. + +## Iterating + +You can build the project with: + +```shell +go build . +``` + +You can also use `go run` to iterate without a separate rebuild step: + +```shell +go run . --repo= +``` + +You can run tests with: + +```shell +go test . +``` + +## Adding New Checks + +Each check is currently just a function of type `CheckFn`. +The signature is: + +```golang +type CheckFn func(*checker.Checker) CheckResult +``` + +Checks are registered in an init function: + +```golang + AllChecks = append(AllChecks, NamedCheck{ + Name: "Code-Review", + Fn: DoesCodeReview, + }) +``` + +Currently only one set of checks can be run. +In the future, we'll allow declaring multiple suites and configuring which checks get run. diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..9083efca --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/dlorenc/scorecard + +go 1.15 + +require ( + github.com/google/go-github/v32 v32.1.0 + golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..c13eac6c --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II= +github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI= +github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= diff --git a/main.go b/main.go new file mode 100644 index 00000000..a25c589a --- /dev/null +++ b/main.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "net/http" + "strings" + "sync" + + "github.com/dlorenc/scorecard/checker" + "github.com/dlorenc/scorecard/checks" + "github.com/dlorenc/scorecard/roundtripper" + "github.com/google/go-github/v32/github" +) + +var repo = flag.String("repo", "", "url to the repo") +var checksToRun = flag.String("checks", "", "specific checks to run, instead of all") + +func main() { + flag.Parse() + + split := strings.SplitN(*repo, "/", 3) + host, owner, repo := split[0], split[1], split[2] + + switch host { + case "github.com": + default: + log.Fatalf("unsupported host: %s", host) + } + + ctx := context.Background() + + rt := roundtripper.NewTransport(ctx) + + client := &http.Client{ + Transport: rt, + } + ghClient := github.NewClient(client) + + c := &checker.Checker{ + Ctx: ctx, + Client: ghClient, + HttpClient: client, + Owner: owner, + Repo: repo, + } + + wg := sync.WaitGroup{} + for _, check := range checks.AllChecks { + check := check + wg.Add(1) + go func() { + defer wg.Done() + var r checks.CheckResult + for retriesRemaining := 3; retriesRemaining > 0; retriesRemaining-- { + r = check.Fn(c) + if r.ShouldRetry { + log.Println(r.Error) + continue + } + break + } + fmt.Println(check.Name, r.Confidence, r.Pass) + }() + } + wg.Wait() + +} diff --git a/roundtripper/roundtripper.go b/roundtripper/roundtripper.go new file mode 100644 index 00000000..6e3544a5 --- /dev/null +++ b/roundtripper/roundtripper.go @@ -0,0 +1,118 @@ +package roundtripper + +import ( + "bytes" + "context" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "strconv" + "time" + + "golang.org/x/oauth2" +) + +const GITHUB_AUTH_TOKEN = "GITHUB_AUTH_TOKEN" + +// RateLimitRoundTripper is a rate-limit aware http.Transport for Github. +type RateLimitRoundTripper struct { + InnerTransport http.RoundTripper +} + +// NewTransport returns a configured http.Transport for use with GitHub +func NewTransport(ctx context.Context) http.RoundTripper { + token := os.Getenv(GITHUB_AUTH_TOKEN) + + // Start with oauth + transport := http.DefaultTransport + if token != "" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + transport = oauth2.NewClient(ctx, ts).Transport + } + + // Wrap that with the rate limiter + rateLimit := &RateLimitRoundTripper{ + InnerTransport: transport, + } + + // Wrap that with the response cacher + cache := &CachingRoundTripper{ + innerTransport: rateLimit, + respCache: map[url.URL]*http.Response{}, + bodyCache: map[url.URL][]byte{}, + } + + return cache +} + +// Roundtrip handles caching and ratelimiting of responses from GitHub. +func (gh *RateLimitRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + resp, err := gh.InnerTransport.RoundTrip(r) + if err != nil { + return nil, err + } + + rateLimit := resp.Header.Get("X-RateLimit-Remaining") + remaining, err := strconv.Atoi(rateLimit) + if err != nil { + return resp, nil + } + + if remaining <= 0 { + reset, err := strconv.Atoi(resp.Header.Get("X-RateLimit-Reset")) + if err != nil { + return resp, nil + } + + duration := time.Until(time.Unix(int64(reset), 0)) + log.Printf("Rate limit exceeded. Waiting %s to retry...", duration) + + // Retry + time.Sleep(duration) + log.Print("Rate limit exceeded. Retrying...") + return gh.RoundTrip(r) + } + + return resp, err +} + +type CachingRoundTripper struct { + innerTransport http.RoundTripper + respCache map[url.URL]*http.Response + bodyCache map[url.URL][]byte +} + +func (rt *CachingRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + // Check the cache + resp, ok := rt.respCache[*r.URL] + if ok { + log.Printf("Cache hit on %s", r.URL.String()) + resp.Body = ioutil.NopCloser(bytes.NewReader(rt.bodyCache[*r.URL])) + return resp, nil + } + + // Get the real value + resp, err := rt.innerTransport.RoundTrip(r) + if err != nil { + return nil, err + } + + // Add to cache + if resp.StatusCode == http.StatusOK { + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + rt.respCache[*r.URL] = resp + rt.bodyCache[*r.URL] = body + + resp.Body = ioutil.NopCloser(bytes.NewReader(body)) + } + return resp, err +}