diff --git a/checker/raw_result.go b/checker/raw_result.go index e615e51f..f3c9a569 100644 --- a/checker/raw_result.go +++ b/checker/raw_result.go @@ -29,6 +29,7 @@ type RawResults struct { BranchProtectionResults BranchProtectionsData CodeReviewResults CodeReviewData WebhookResults WebhooksData + ContributorsResults ContributorsData MaintainedResults MaintainedData SignedReleasesResults SignedReleasesData LicenseResults LicenseData @@ -54,6 +55,11 @@ type CodeReviewData struct { DefaultBranchCommits []DefaultBranchCommit } +// ContributorsData represents contributor information. +type ContributorsData struct { + Users []User +} + // VulnerabilitiesData contains the raw results // for the Vulnerabilities check. type VulnerabilitiesData struct { @@ -194,8 +200,8 @@ type MergeRequest struct { // Review represent a review using the built-in review system. type Review struct { - Reviewer User State string + Reviewer User // TODO(Review): add fields here if needed. } @@ -203,6 +209,23 @@ type Review struct { type User struct { RepoAssociation *RepoAssociation Login string + // Orgnization refers to a GitHub org. + Organizations []Organization + // Companies refer to a claim by a user in their profile. + Companies []Company + NumContributions uint +} + +// Organization represents a GitHub organization. +type Organization struct { + Login string + // TODO: other info. +} + +// Company represents a company in a user's profile. +type Company struct { + Name string + // TODO: other info. } // RepoAssociation represents a user relationship with a repo. diff --git a/checks/contributors.go b/checks/contributors.go index 3c938932..c30d59a6 100644 --- a/checks/contributors.go +++ b/checks/contributors.go @@ -15,19 +15,14 @@ package checks import ( - "fmt" - "strings" - "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/checks/evaluation" + "github.com/ossf/scorecard/v4/checks/raw" sce "github.com/ossf/scorecard/v4/errors" ) -const ( - minContributionsPerUser = 5 - numberCompaniesForTopScore = 3 - // CheckContributors is the registered name for Contributors. - CheckContributors = "Contributors" -) +// CheckContributors is the registered name for Contributors. +const CheckContributors = "Contributors" //nolint:gochecknoinits func init() { @@ -39,44 +34,17 @@ func init() { // Contributors run Contributors check. func Contributors(c *checker.CheckRequest) checker.CheckResult { - contribs, err := c.RepoClient.ListContributors() + rawData, err := raw.Contributors(c.RepoClient) if err != nil { - e := sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("Client.Repositories.ListContributors: %v", err)) + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) return checker.CreateRuntimeErrorResult(CheckContributors, e) } - companies := map[string]struct{}{} - for _, contrib := range contribs { - if contrib.NumContributions < minContributionsPerUser { - continue - } - - for _, org := range contrib.Organizations { - if org.Login != "" { - companies[org.Login] = struct{}{} - } - } - - company := contrib.Company - if company != "" { - company = strings.ToLower(company) - company = strings.ReplaceAll(company, "inc.", "") - company = strings.ReplaceAll(company, "llc", "") - company = strings.ReplaceAll(company, ",", "") - company = strings.TrimLeft(company, "@") - company = strings.Trim(company, " ") - companies[company] = struct{}{} - } - } - names := []string{} - for c := range companies { - names = append(names, c) + // Return raw results. + if c.RawResults != nil { + c.RawResults.ContributorsResults = rawData } - c.Dlogger.Info(&checker.LogMessage{ - Text: fmt.Sprintf("contributors work for: %v", strings.Join(names, ",")), - }) - - reason := fmt.Sprintf("%d different companies found", len(companies)) - return checker.CreateProportionalScoreResult(CheckContributors, reason, len(companies), numberCompaniesForTopScore) + // Return the score evaluation. + return evaluation.Contributors(CheckContributors, c.Dlogger, &rawData) } diff --git a/checks/evaluation/contributors.go b/checks/evaluation/contributors.go new file mode 100644 index 00000000..3bf87094 --- /dev/null +++ b/checks/evaluation/contributors.go @@ -0,0 +1,75 @@ +// Copyright 2021 Security Scorecard Authors +// +// 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 evaluation + +import ( + "fmt" + "sort" + "strings" + + "github.com/ossf/scorecard/v4/checker" + sce "github.com/ossf/scorecard/v4/errors" +) + +const ( + minContributionsPerUser = 5 + numberCompaniesForTopScore = 3 +) + +// Contributors applies the score policy for the Contributors check. +func Contributors(name string, dl checker.DetailLogger, + r *checker.ContributorsData, +) checker.CheckResult { + if r == nil { + e := sce.WithMessage(sce.ErrScorecardInternal, "empty raw data") + return checker.CreateRuntimeErrorResult(name, e) + } + + entities := make(map[string]bool) + + for _, user := range r.Users { + if user.NumContributions < minContributionsPerUser { + continue + } + + for _, org := range user.Organizations { + entities[org.Login] = true + } + + for _, comp := range user.Companies { + entities[comp.Name] = true + } + } + + names := []string{} + for c := range entities { + names = append(names, c) + } + + sort.Strings(names) + + if len(name) > 0 { + dl.Info(&checker.LogMessage{ + Text: fmt.Sprintf("contributors work for %v", strings.Join(names, ",")), + }) + } else { + dl.Warn(&checker.LogMessage{ + Text: "no contributors have an org or company", + }) + } + + reason := fmt.Sprintf("%d different organizations found", len(entities)) + return checker.CreateProportionalScoreResult(name, reason, len(entities), numberCompaniesForTopScore) +} diff --git a/checks/raw/contributors.go b/checks/raw/contributors.go new file mode 100644 index 00000000..2877b911 --- /dev/null +++ b/checks/raw/contributors.go @@ -0,0 +1,89 @@ +// Copyright 2020 Security Scorecard Authors +// +// 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 raw + +import ( + "fmt" + "strings" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/clients" +) + +// Contributors retrieves the raw data for the Contributors check. +func Contributors(c clients.RepoClient) (checker.ContributorsData, error) { + var users []checker.User + + contribs, err := c.ListContributors() + if err != nil { + return checker.ContributorsData{}, fmt.Errorf("Client.Repositories.ListContributors: %w", err) + } + + for _, contrib := range contribs { + user := checker.User{ + Login: contrib.User.Login, + NumContributions: uint(contrib.NumContributions), + } + + for _, org := range contrib.Organizations { + if org.Login != "" && !orgContains(user.Organizations, org.Login) { + user.Organizations = append(user.Organizations, + checker.Organization{ + Login: org.Login, + }, + ) + } + } + + company := contrib.Company + if company != "" { + company = strings.ToLower(company) + company = strings.ReplaceAll(company, "inc.", "") + company = strings.ReplaceAll(company, "llc", "") + company = strings.ReplaceAll(company, ",", "") + company = strings.TrimLeft(company, "@") + company = strings.Trim(company, " ") + if company != "" && !companyContains(user.Companies, company) { + user.Companies = append(user.Companies, + checker.Company{ + Name: company, + }, + ) + } + } + + users = append(users, user) + } + + return checker.ContributorsData{Users: users}, nil +} + +func companyContains(cs []checker.Company, name string) bool { + for _, a := range cs { + if a.Name == name { + return true + } + } + return false +} + +func orgContains(os []checker.Organization, login string) bool { + for _, a := range os { + if a.Login == login { + return true + } + } + return false +} diff --git a/cron/format/json_raw_results.go b/cron/format/json_raw_results.go index 293c6b04..59f4c1e0 100644 --- a/cron/format/json_raw_results.go +++ b/cron/format/json_raw_results.go @@ -118,7 +118,8 @@ type jsonRawResults struct { //nolint:unparam func addCodeReviewRawResults(r *jsonScorecardRawResult, cr *checker.CodeReviewData) error { r.Results.DefaultBranchCommits = []jsonDefaultBranchCommit{} - for _, commit := range cr.DefaultBranchCommits { + for i := range cr.DefaultBranchCommits { + commit := cr.DefaultBranchCommits[i] com := jsonDefaultBranchCommit{ Committer: jsonUser{ Login: commit.Committer.Login, diff --git a/pkg/json_raw_results.go b/pkg/json_raw_results.go index e435116a..7688e6c2 100644 --- a/pkg/json_raw_results.go +++ b/pkg/json_raw_results.go @@ -73,13 +73,33 @@ type jsonBranchProtection struct { } type jsonReview struct { - Reviewer jsonUser `json:"reviewer"` State string `json:"state"` + Reviewer jsonUser `json:"reviewer"` } type jsonUser struct { - RepoAssociation *string `json:"repo-association"` + RepoAssociation *string `json:"repo-association,omitempty"` Login string `json:"login"` + // Orgnization refers to a GitHub org. + Organizations []jsonOrganization `json:"organization,omitempty"` + // Companies refer to a claim by a user in their profile. + Companies []jsonCompany `json:"company,omitempty"` + NumContributions uint `json:"NumContributions"` +} + +type jsonContributors struct { + Users []jsonUser `json:"users"` + // TODO: high-level statistics, etc +} + +type jsonOrganization struct { + Login string `json:"login"` + // TODO: other info. +} + +type jsonCompany struct { + Name string `json:"name"` + // TODO: other info. } //nolint:govet @@ -92,11 +112,10 @@ type jsonMergeRequest struct { type jsonDefaultBranchCommit struct { // ApprovedReviews *jsonApprovedReviews `json:"approved-reviews"` - Committer jsonUser `json:"committer"` MergeRequest *jsonMergeRequest `json:"merge-request"` CommitMessage string `json:"commit-message"` SHA string `json:"sha"` - + Committer jsonUser `json:"committer"` // TODO: check runs, etc. } @@ -189,6 +208,10 @@ type jsonRawResults struct { DependencyUpdateTools []jsonTool `json:"dependency-update-tools"` // Branch protection settings for development and release branches. BranchProtections []jsonBranchProtection `json:"branch-protections"` + // Contributors. Note: we could use the list of commits instead to store this data. + // However, it's harder to get statistics using commit list, so we have a dedicated + // structure for it. + Contributors jsonContributors `json:"Contributors"` // Commits. DefaultBranchCommits []jsonDefaultBranchCommit `json:"default-branch-commits"` // Archived status of the repo. @@ -231,6 +254,38 @@ func (r *jsonScorecardRawResult) addDangerousWorkflowRawResults(df *checker.Dang return nil } +//nolint:unparam +func (r *jsonScorecardRawResult) addContributorsRawResults(cr *checker.ContributorsData) error { + r.Results.Contributors = jsonContributors{} + + for _, user := range cr.Users { + u := jsonUser{ + Login: user.Login, + NumContributions: user.NumContributions, + } + + for _, org := range user.Organizations { + u.Organizations = append(u.Organizations, + jsonOrganization{ + Login: org.Login, + }, + ) + } + + for _, comp := range user.Companies { + u.Companies = append(u.Companies, + jsonCompany{ + Name: comp.Name, + }, + ) + } + + r.Results.Contributors.Users = append(r.Results.Contributors.Users, u) + } + + return nil +} + //nolint:unparam func (r *jsonScorecardRawResult) addSignedReleasesRawResults(sr *checker.SignedReleasesData) error { r.Results.Releases = []jsonRelease{} @@ -307,7 +362,8 @@ func (r *jsonScorecardRawResult) setDefaultCommitData(commits []checker.DefaultB } r.Results.DefaultBranchCommits = []jsonDefaultBranchCommit{} - for _, commit := range commits { + for i := range commits { + commit := commits[i] com := jsonDefaultBranchCommit{ Committer: jsonUser{ Login: commit.Committer.Login, @@ -506,6 +562,11 @@ func (r *jsonScorecardRawResult) fillJSONRawResults(raw *checker.RawResults) err return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) } + // Contributors. + if err := r.addContributorsRawResults(&raw.ContributorsResults); err != nil { + return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + } + // CII-Best-Practices. if err := r.addOssfBestPracticesRawResults(&raw.CIIBestPracticesResults); err != nil { return sce.WithMessage(sce.ErrScorecardInternal, err.Error())