[RAW] Branch Protection support (#1396)

* raw bp

* missing files

* context never nil

* support raw bp

* unit tests

* remove comments

* merging

* linter
This commit is contained in:
laurentsimon 2021-12-16 13:42:05 -08:00 committed by GitHub
parent c795615321
commit 3d9b1d2900
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 992 additions and 805 deletions

View File

@ -20,6 +20,13 @@ import (
"math"
)
type (
// DetailType is the type of details.
DetailType int
// FileType is the type of a file.
FileType int
)
const (
// MaxResultConfidence implies full certainty about a check result.
// TODO(#1393): remove after deprecation.
@ -101,12 +108,6 @@ type LogMessage struct {
Version int // `3` to indicate the detail was logged using new structure.
}
// DetailType is the type of details.
type DetailType int
// FileType is the type of a file.
type FileType int
// CreateProportionalScore creates a proportional score.
func CreateProportionalScore(success, total int) int {
if total == 0 {

View File

@ -20,6 +20,14 @@ type RawResults struct {
BinaryArtifactResults BinaryArtifactData
SecurityPolicyResults SecurityPolicyData
DependencyUpdateToolResults DependencyUpdateToolData
BranchProtectionResults BranchProtectionsData
}
// SecurityPolicyData contains the raw results
// for the Security-Policy check.
type SecurityPolicyData struct {
// Files contains a list of files.
Files []File
}
// BinaryArtifactData contains the raw results
@ -37,20 +45,44 @@ type DependencyUpdateToolData struct {
Tools []Tool
}
// SecurityPolicyData contains the raw results
// for the Security-Policy check.
type SecurityPolicyData struct {
// Files contains a list of files.
Files []File
// BranchProtectionsData contains the raw results
// for the Branch-Protection check.
type BranchProtectionsData struct {
Branches []BranchProtectionData
}
// File represents a file.
type File struct {
Path string
Snippet string // Snippet of code
Offset int // Offset in the file of Path (line for source/text files).
Type FileType // Type of file.
// TODO: add hash.
// BranchProtectionData contains the raw results
// for one branch.
//nolint:govet
type BranchProtectionData struct {
Protected *bool
AllowsDeletions *bool
AllowsForcePushes *bool
RequiresCodeOwnerReviews *bool
RequiresLinearHistory *bool
DismissesStaleReviews *bool
EnforcesAdmins *bool
RequiresStatusChecks *bool
RequiresUpToDateBranchBeforeMerging *bool
RequiredApprovingReviewCount *int
// StatusCheckContexts is always available, so
// we don't use a pointer.
StatusCheckContexts []string
Name string
}
// Tool represents a tool.
type Tool struct {
// Runs of the tool.
Runs []Run
// Issues created by the tool.
Issues []Issue
// Merges requests created by the tool.
MergeRequests []MergeRequest
Name string
URL string
Desc string
ConfigFiles []File
}
// Run represents a run.
@ -71,16 +103,11 @@ type MergeRequest struct {
// TODO: add fields, e.g., State=["merged"|"closed"]
}
// Tool represents a tool.
type Tool struct {
// Runs of the tool.
Runs []Run
// Issues created by the tool.
Issues []Issue
// Merges requests created by the tool.
MergeRequests []MergeRequest
Name string
URL string
Desc string
ConfigFiles []File
// File represents a file.
type File struct {
Path string
Snippet string // Snippet of code
Offset int // Offset in the file of Path (line for source/text files).
Type FileType // Type of file.
// TODO: add hash.
}

View File

@ -15,495 +15,36 @@
package checks
import (
"errors"
"fmt"
"regexp"
"github.com/ossf/scorecard/v3/checker"
"github.com/ossf/scorecard/v3/clients"
"github.com/ossf/scorecard/v3/checks/evaluation"
"github.com/ossf/scorecard/v3/checks/raw"
sce "github.com/ossf/scorecard/v3/errors"
)
const (
// CheckBranchProtection is the exported name for Branch-Protected check.
CheckBranchProtection = "Branch-Protection"
minReviews = 2
// Points incremented at each level.
adminNonAdminBasicLevel = 3 // Level 1.
adminNonAdminReviewLevel = 3 // Level 2.
nonAdminContextLevel = 2 // Level 3.
nonAdminThoroughReviewLevel = 1 // Level 4.
adminThoroughReviewLevel = 1 // Level 5.
)
type scoresInfo struct {
basic int
adminBasic int
review int
adminReview int
context int
thoroughReview int
adminThoroughReview int
}
// Maximum score depending on whether admin token is used.
type levelScore struct {
scores scoresInfo // Score result for a branch.
maxes scoresInfo // Maximum possible score for a branch.
}
//nolint:gochecknoinits
func init() {
registerCheck(CheckBranchProtection, BranchProtection)
}
type branchMap map[string]*clients.BranchRef
func (b branchMap) getBranchByName(name string) (*clients.BranchRef, error) {
val, exists := b[name]
if exists {
return val, nil
}
// Ideally, we should check using repositories.GetBranch if there was a branch redirect.
// See https://github.com/google/go-github/issues/1895
// For now, handle the common master -> main redirect.
if name == "master" {
val, exists := b["main"]
if exists {
return val, nil
}
}
return nil, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("could not find branch name %s: %v", name, errInternalBranchNotFound))
}
func getBranchMapFrom(branches []*clients.BranchRef) branchMap {
ret := make(branchMap)
for _, branch := range branches {
branchName := getBranchName(branch)
if branchName != "" {
ret[branchName] = branch
}
}
return ret
}
func getBranchName(branch *clients.BranchRef) string {
if branch == nil || branch.Name == nil {
return ""
}
return *branch.Name
}
// BranchProtection runs Branch-Protection check.
// BranchProtection runs the Branch-Protection check.
func BranchProtection(c *checker.CheckRequest) checker.CheckResult {
// Checks branch protection on both release and development branch.
return checkReleaseAndDevBranchProtection(c.RepoClient, c.Dlogger)
}
func computeNonAdminBasicScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.basic
}
return score
}
func computeAdminBasicScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminBasic
}
return score
}
func computeNonAdminReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.review
}
return score
}
func computeAdminReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminReview
}
return score
}
func computeNonAdminThoroughReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.thoroughReview
}
return score
}
func computeAdminThoroughReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminThoroughReview
}
return score
}
func computeNonAdminContextScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.context
}
return score
}
func noarmalizeScore(score, max, level int) float64 {
if max == 0 {
return float64(level)
}
return float64(score*level) / float64(max)
}
func computeScore(scores []levelScore) (int, error) {
if len(scores) == 0 {
return 0, sce.WithMessage(sce.ErrScorecardInternal, "scores are empty")
}
score := float64(0)
maxScore := scores[0].maxes
// First, check if they all pass the basic (admin and non-admin) checks.
maxBasicScore := maxScore.basic * len(scores)
maxAdminBasicScore := maxScore.adminBasic * len(scores)
basicScore := computeNonAdminBasicScore(scores)
adminBasicScore := computeAdminBasicScore(scores)
score += noarmalizeScore(basicScore+adminBasicScore, maxBasicScore+maxAdminBasicScore, adminNonAdminBasicLevel)
if basicScore != maxBasicScore ||
adminBasicScore != maxAdminBasicScore {
return int(score), nil
}
// Second, check the (admin and non-admin) reviews.
maxReviewScore := maxScore.review * len(scores)
maxAdminReviewScore := maxScore.adminReview * len(scores)
reviewScore := computeNonAdminReviewScore(scores)
adminReviewScore := computeAdminReviewScore(scores)
score += noarmalizeScore(reviewScore+adminReviewScore, maxReviewScore+maxAdminReviewScore, adminNonAdminReviewLevel)
if reviewScore != maxReviewScore ||
adminReviewScore != maxAdminReviewScore {
return int(score), nil
}
// Third, check the use of non-admin context.
maxContextScore := maxScore.context * len(scores)
contextScore := computeNonAdminContextScore(scores)
score += noarmalizeScore(contextScore, maxContextScore, nonAdminContextLevel)
if contextScore != maxContextScore {
return int(score), nil
}
// Fourth, check the thorough non-admin reviews.
maxThoroughReviewScore := maxScore.thoroughReview * len(scores)
thoroughReviewScore := computeNonAdminThoroughReviewScore(scores)
score += noarmalizeScore(thoroughReviewScore, maxThoroughReviewScore, nonAdminThoroughReviewLevel)
if thoroughReviewScore != maxThoroughReviewScore {
return int(score), nil
}
// Last, check the thorough admin review config.
// This one is controversial and has usability issues
// https://github.com/ossf/scorecard/issues/1027, so we may remove it.
maxAdminThoroughReviewScore := maxScore.adminThoroughReview * len(scores)
adminThoroughReviewScore := computeAdminThoroughReviewScore(scores)
score += noarmalizeScore(adminThoroughReviewScore, maxAdminThoroughReviewScore, adminThoroughReviewLevel)
if adminThoroughReviewScore != maxAdminThoroughReviewScore {
return int(score), nil
}
return int(score), nil
}
func info(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Info(desc, args...)
}
func debug(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Debug(desc, args...)
}
func warn(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Warn(desc, args...)
}
func checkReleaseAndDevBranchProtection(
repoClient clients.RepoClient, dl checker.DetailLogger) checker.CheckResult {
// Get all branches. This will include information on whether they are protected.
branches, err := repoClient.ListBranches()
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBranchProtection, e)
}
branchesMap := getBranchMapFrom(branches)
// Get release branches.
releases, err := repoClient.ListReleases()
rawData, err := raw.BranchProtection(c.RepoClient)
if err != nil {
e := sce.WithMessage(sce.ErrScorecardInternal, err.Error())
return checker.CreateRuntimeErrorResult(CheckBranchProtection, e)
}
commit := regexp.MustCompile("^[a-f0-9]{40}$")
checkBranches := make(map[string]bool)
for _, release := range releases {
if release.TargetCommitish == "" {
// Log with a named error if target_commitish is nil.
e := sce.WithMessage(sce.ErrScorecardInternal, errInternalCommitishNil.Error())
return checker.CreateRuntimeErrorResult(CheckBranchProtection, e)
}
// TODO: if this is a sha, get the associated branch. for now, ignore.
if commit.Match([]byte(release.TargetCommitish)) {
continue
}
// Try to resolve the branch name.
b, err := branchesMap.getBranchByName(release.TargetCommitish)
if err != nil {
// If the commitish branch is still not found, fail.
return checker.CreateRuntimeErrorResult(CheckBranchProtection, err)
}
// Branch is valid, add to list of branches to check.
checkBranches[*b.Name] = true
// Return raw results.
if c.RawResults != nil {
c.RawResults.BranchProtectionResults = rawData
return checker.CheckResult{}
}
// Add default branch.
defaultBranch, err := repoClient.GetDefaultBranch()
if err != nil {
return checker.CreateRuntimeErrorResult(CheckBranchProtection, err)
}
defaultBranchName := getBranchName(defaultBranch)
if defaultBranchName != "" {
checkBranches[defaultBranchName] = true
}
var scores []levelScore
// Check protections on all the branches.
for b := range checkBranches {
var score levelScore
branch, err := branchesMap.getBranchByName(b)
if err != nil {
if errors.Is(err, errInternalBranchNotFound) {
continue
}
return checker.CreateRuntimeErrorResult(CheckBranchProtection, err)
}
// Protected field only indates that the branch matches
// one `Branch protection rules`. All settings may be disabled,
// so it does not provide any guarantees.
protected := !(branch.Protected != nil && !*branch.Protected)
if !protected {
dl.Warn("branch protection not enabled for branch '%s'", b)
}
score.scores.basic, score.maxes.basic =
basicNonAdminProtection(&branch.BranchProtectionRule, b, dl, protected)
score.scores.adminBasic, score.maxes.adminBasic =
basicAdminProtection(&branch.BranchProtectionRule, b, dl, protected)
score.scores.review, score.maxes.review =
nonAdminReviewProtection(&branch.BranchProtectionRule)
score.scores.adminReview, score.maxes.adminReview =
adminReviewProtection(&branch.BranchProtectionRule, b, dl, protected)
score.scores.context, score.maxes.context =
nonAdminContextProtection(&branch.BranchProtectionRule, b, dl, protected)
score.scores.thoroughReview, score.maxes.thoroughReview =
nonAdminThoroughReviewProtection(&branch.BranchProtectionRule, b, dl, protected)
score.scores.adminThoroughReview, score.maxes.adminThoroughReview =
adminThoroughReviewProtection(&branch.BranchProtectionRule, b, dl, protected) // Do we want this?
scores = append(scores, score)
}
if len(scores) == 0 {
return checker.CreateInconclusiveResult(CheckBranchProtection, "unable to detect any development/release branches")
}
score, err := computeScore(scores)
if err != nil {
return checker.CreateRuntimeErrorResult(CheckBranchProtection, err)
}
switch score {
case checker.MinResultScore:
return checker.CreateMinScoreResult(CheckBranchProtection,
"branch protection not enabled on development/release branches")
case checker.MaxResultScore:
return checker.CreateMaxScoreResult(CheckBranchProtection,
"branch protection is fully enabled on development and all release branches")
default:
return checker.CreateResultWithScore(CheckBranchProtection,
"branch protection is not maximal on development and all release branches", score)
}
}
func basicNonAdminProtection(protection *clients.BranchProtectionRule,
branch string, dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
max++
if protection.AllowForcePushes != nil {
switch *protection.AllowForcePushes {
case true:
warn(dl, doLogging, "'force pushes' enabled on branch '%s'", branch)
case false:
info(dl, doLogging, "'force pushes' disabled on branch '%s'", branch)
score++
}
}
max++
if protection.AllowDeletions != nil {
switch *protection.AllowDeletions {
case true:
warn(dl, doLogging, "'allow deletion' enabled on branch '%s'", branch)
case false:
info(dl, doLogging, "'allow deletion' disabled on branch '%s'", branch)
score++
}
}
return score, max
}
func basicAdminProtection(protection *clients.BranchProtectionRule,
branch string, dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
// nil typically means we do not have access to the value.
if protection.EnforceAdmins != nil {
// Note: we don't inrecase max possible score for non-admin viewers.
max++
switch *protection.EnforceAdmins {
case true:
info(dl, doLogging, "settings apply to administrators on branch '%s'", branch)
score++
case false:
warn(dl, doLogging, "settings do not apply to administrators on branch '%s'", branch)
}
} else {
debug(dl, doLogging, "unable to retrieve whether or not settings apply to administrators on branch '%s'", branch)
}
return score, max
}
func nonAdminContextProtection(protection *clients.BranchProtectionRule, branch string,
dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
// This means there are specific checks enabled.
// If only `Requires status check to pass before merging` is enabled
// but no specific checks are declared, it's equivalent
// to having no status check at all.
max++
switch {
case len(protection.CheckRules.Contexts) > 0:
info(dl, doLogging, "status check found to merge onto on branch '%s'", branch)
score++
default:
warn(dl, doLogging, "no status checks found to merge onto branch '%s'", branch)
}
return score, max
}
func nonAdminReviewProtection(protection *clients.BranchProtectionRule) (int, int) {
score := 0
max := 0
max++
if protection.RequiredPullRequestReviews.RequiredApprovingReviewCount != nil &&
*protection.RequiredPullRequestReviews.RequiredApprovingReviewCount > 0 {
// We do not display anything here, it's done in nonAdminThoroughReviewProtection()
score++
}
return score, max
}
func adminReviewProtection(protection *clients.BranchProtectionRule, branch string,
dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
if protection.CheckRules.UpToDateBeforeMerge != nil {
// Note: `This setting will not take effect unless at least one status check is enabled`.
max++
switch *protection.CheckRules.UpToDateBeforeMerge {
case true:
info(dl, doLogging, "status checks require up-to-date branches for '%s'", branch)
score++
default:
warn(dl, doLogging, "status checks do not require up-to-date branches for '%s'", branch)
}
} else {
debug(dl, doLogging, "unable to retrieve whether up-to-date branches are needed to merge on branch '%s'", branch)
}
return score, max
}
func adminThoroughReviewProtection(protection *clients.BranchProtectionRule, branch string,
dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
if protection.RequiredPullRequestReviews.DismissStaleReviews != nil {
// Note: we don't inrecase max possible score for non-admin viewers.
max++
switch *protection.RequiredPullRequestReviews.DismissStaleReviews {
case true:
info(dl, doLogging, "Stale review dismissal enabled on branch '%s'", branch)
score++
case false:
warn(dl, doLogging, "Stale review dismissal disabled on branch '%s'", branch)
}
} else {
debug(dl, doLogging, "unable to retrieve review dismissal on branch '%s'", branch)
}
return score, max
}
func nonAdminThoroughReviewProtection(protection *clients.BranchProtectionRule, branch string,
dl checker.DetailLogger, doLogging bool) (int, int) {
score := 0
max := 0
max++
if protection.RequiredPullRequestReviews.RequiredApprovingReviewCount != nil {
switch *protection.RequiredPullRequestReviews.RequiredApprovingReviewCount >= minReviews {
case true:
info(dl, doLogging, "number of required reviewers is %d on branch '%s'",
*protection.RequiredPullRequestReviews.RequiredApprovingReviewCount, branch)
score++
default:
warn(dl, doLogging, "number of required reviewers is only %d on branch '%s'",
*protection.RequiredPullRequestReviews.RequiredApprovingReviewCount, branch)
}
} else {
warn(dl, doLogging, "number of required reviewers is 0 on branch '%s'", branch)
}
return score, max
// Return the score evaluation.
return evaluation.BranchProtection(CheckBranchProtection, c.Dlogger, &rawData)
}

View File

@ -26,6 +26,13 @@ import (
scut "github.com/ossf/scorecard/v3/utests"
)
func getBranchName(branch *clients.BranchRef) string {
if branch == nil || branch.Name == nil {
return ""
}
return *branch.Name
}
func getBranch(branches []*clients.BranchRef, name string) *clients.BranchRef {
for _, branch := range branches {
branchName := getBranchName(branch)
@ -50,22 +57,6 @@ func scrubBranches(branches []*clients.BranchRef) []*clients.BranchRef {
return ret
}
func testScore(protection *clients.BranchProtectionRule,
branch string, dl checker.DetailLogger) (int, error) {
var score levelScore
score.scores.basic, score.maxes.basic = basicNonAdminProtection(protection, branch, dl, true)
score.scores.adminBasic, score.maxes.adminBasic = basicAdminProtection(protection, branch, dl, true)
score.scores.review, score.maxes.review = nonAdminReviewProtection(protection)
score.scores.adminReview, score.maxes.adminReview = adminReviewProtection(protection, branch, dl, true)
score.scores.context, score.maxes.context = nonAdminContextProtection(protection, branch, dl, true)
score.scores.thoroughReview, score.maxes.thoroughReview =
nonAdminThoroughReviewProtection(protection, branch, dl, true)
score.scores.adminThoroughReview, score.maxes.adminThoroughReview =
adminThoroughReviewProtection(protection, branch, dl, true)
return computeScore([]levelScore{score})
}
func TestReleaseAndDevBranchProtected(t *testing.T) {
t.Parallel()
@ -432,7 +423,11 @@ func TestReleaseAndDevBranchProtected(t *testing.T) {
return tt.branches, nil
}).AnyTimes()
dl := scut.TestDetailLogger{}
r := checkReleaseAndDevBranchProtection(mockRepoClient, &dl)
req := checker.CheckRequest{
Dlogger: &dl,
RepoClient: mockRepoClient,
}
r := BranchProtection(&req)
if !scut.ValidateTestReturn(t, tt.name, &tt.expected, &r, &dl) {
t.Fail()
}
@ -440,278 +435,3 @@ func TestReleaseAndDevBranchProtected(t *testing.T) {
})
}
}
func TestIsBranchProtected(t *testing.T) {
t.Parallel()
trueVal := true
falseVal := false
var zeroVal int32
var oneVal int32 = 1
tests := []struct {
name string
protection *clients.BranchProtectionRule
expected scut.TestReturn
}{
{
name: "Nothing is enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &trueVal,
UpToDateBeforeMerge: &falseVal,
Contexts: nil,
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Nothing is enabled and values in github.Protection are nil",
expected: scut.TestReturn{
Error: nil,
Score: 0,
NumberOfWarn: 2,
NumberOfInfo: 0,
NumberOfDebug: 3,
},
protection: &clients.BranchProtectionRule{},
},
{
name: "Required status check enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 3,
NumberOfInfo: 4,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &trueVal,
UpToDateBeforeMerge: &trueVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Required status check enabled without checking for status string",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &trueVal,
UpToDateBeforeMerge: &trueVal,
Contexts: nil,
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Required pull request enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &trueVal,
UpToDateBeforeMerge: &falseVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &oneVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &trueVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Required admin enforcement enabled",
expected: scut.TestReturn{
Error: nil,
Score: 3,
NumberOfWarn: 3,
NumberOfInfo: 4,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &falseVal,
UpToDateBeforeMerge: &falseVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &trueVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Required linear history enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &falseVal,
UpToDateBeforeMerge: &falseVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &trueVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
{
name: "Allow force push enabled",
expected: scut.TestReturn{
Error: nil,
Score: 1,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &falseVal,
UpToDateBeforeMerge: &falseVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &trueVal,
AllowDeletions: &falseVal,
},
},
{
name: "Allow deletions enabled",
expected: scut.TestReturn{
Error: nil,
Score: 1,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &falseVal,
UpToDateBeforeMerge: &falseVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &falseVal,
RequireCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
},
EnforceAdmins: &falseVal,
RequireLinearHistory: &falseVal,
AllowForcePushes: &falseVal,
AllowDeletions: &trueVal,
},
},
{
name: "Branches are protected",
expected: scut.TestReturn{
Error: nil,
Score: 8,
NumberOfWarn: 1,
NumberOfInfo: 6,
NumberOfDebug: 0,
},
protection: &clients.BranchProtectionRule{
CheckRules: clients.StatusChecksRule{
RequiresStatusChecks: &falseVal,
UpToDateBeforeMerge: &trueVal,
Contexts: []string{"foo"},
},
RequiredPullRequestReviews: clients.PullRequestReviewRule{
DismissStaleReviews: &trueVal,
RequireCodeOwnerReviews: &trueVal,
RequiredApprovingReviewCount: &oneVal,
},
EnforceAdmins: &trueVal,
RequireLinearHistory: &trueVal,
AllowForcePushes: &falseVal,
AllowDeletions: &falseVal,
},
},
}
for _, tt := range tests {
tt := tt // Re-initializing variable so it is not changed while executing the closure below
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dl := scut.TestDetailLogger{}
score, err := testScore(tt.protection, "test", &dl)
actual := &checker.CheckResult{
Score: score,
Error: err,
}
if !scut.ValidateTestReturn(t, tt.name, &tt.expected, actual, &dl) {
t.Fail()
}
})
}
}

View File

@ -24,8 +24,6 @@ var (
errInternalInvalidYamlFile = errors.New("invalid yaml file")
errInternalFilenameMatch = errors.New("filename match error")
errInternalEmptyFile = errors.New("empty file")
errInternalCommitishNil = errors.New("commitish is nil")
errInternalBranchNotFound = errors.New("branch not found")
errInvalidGitHubWorkflow = errors.New("invalid GitHub workflow")
errInternalNoReviews = errors.New("no reviews found")
errInternalNoCommits = errors.New("no commits found")

View File

@ -0,0 +1,402 @@
// 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 evaluation
import (
"github.com/ossf/scorecard/v3/checker"
sce "github.com/ossf/scorecard/v3/errors"
)
const (
minReviews = 2
// Points incremented at each level.
adminNonAdminBasicLevel = 3 // Level 1.
adminNonAdminReviewLevel = 3 // Level 2.
nonAdminContextLevel = 2 // Level 3.
nonAdminThoroughReviewLevel = 1 // Level 4.
adminThoroughReviewLevel = 1 // Level 5.
)
type scoresInfo struct {
basic int
adminBasic int
review int
adminReview int
context int
thoroughReview int
adminThoroughReview int
}
// Maximum score depending on whether admin token is used.
type levelScore struct {
scores scoresInfo // Score result for a branch.
maxes scoresInfo // Maximum possible score for a branch.
}
// BranchProtection runs Branch-Protection check.
func BranchProtection(name string, dl checker.DetailLogger,
r *checker.BranchProtectionsData) checker.CheckResult {
var scores []levelScore
// Check protections on all the branches.
for i := range r.Branches {
var score levelScore
b := r.Branches[i]
// Protected field only indates that the branch matches
// one `Branch protection rules`. All settings may be disabled,
// so it does not provide any guarantees.
protected := !(b.Protected != nil && !*b.Protected)
if !protected {
dl.Warn("branch protection not enabled for branch '%s'", b.Name)
}
score.scores.basic, score.maxes.basic = basicNonAdminProtection(&b, dl)
score.scores.adminBasic, score.maxes.adminBasic = basicAdminProtection(&b, dl)
score.scores.review, score.maxes.review = nonAdminReviewProtection(&b)
score.scores.adminReview, score.maxes.adminReview = adminReviewProtection(&b, dl)
score.scores.context, score.maxes.context = nonAdminContextProtection(&b, dl)
score.scores.thoroughReview, score.maxes.thoroughReview = nonAdminThoroughReviewProtection(&b, dl)
// Do we want this?
score.scores.adminThoroughReview, score.maxes.adminThoroughReview = adminThoroughReviewProtection(&b, dl)
scores = append(scores, score)
}
if len(scores) == 0 {
return checker.CreateInconclusiveResult(name, "unable to detect any development/release branches")
}
score, err := computeScore(scores)
if err != nil {
return checker.CreateRuntimeErrorResult(name, err)
}
switch score {
case checker.MinResultScore:
return checker.CreateMinScoreResult(name,
"branch protection not enabled on development/release branches")
case checker.MaxResultScore:
return checker.CreateMaxScoreResult(name,
"branch protection is fully enabled on development and all release branches")
default:
return checker.CreateResultWithScore(name,
"branch protection is not maximal on development and all release branches", score)
}
}
func computeNonAdminBasicScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.basic
}
return score
}
func computeAdminBasicScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminBasic
}
return score
}
func computeNonAdminReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.review
}
return score
}
func computeAdminReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminReview
}
return score
}
func computeNonAdminThoroughReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.thoroughReview
}
return score
}
func computeAdminThoroughReviewScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.adminThoroughReview
}
return score
}
func computeNonAdminContextScore(scores []levelScore) int {
score := 0
for _, s := range scores {
score += s.scores.context
}
return score
}
func noarmalizeScore(score, max, level int) float64 {
if max == 0 {
return float64(level)
}
return float64(score*level) / float64(max)
}
func computeScore(scores []levelScore) (int, error) {
if len(scores) == 0 {
return 0, sce.WithMessage(sce.ErrScorecardInternal, "scores are empty")
}
score := float64(0)
maxScore := scores[0].maxes
// First, check if they all pass the basic (admin and non-admin) checks.
maxBasicScore := maxScore.basic * len(scores)
maxAdminBasicScore := maxScore.adminBasic * len(scores)
basicScore := computeNonAdminBasicScore(scores)
adminBasicScore := computeAdminBasicScore(scores)
score += noarmalizeScore(basicScore+adminBasicScore, maxBasicScore+maxAdminBasicScore, adminNonAdminBasicLevel)
if basicScore != maxBasicScore ||
adminBasicScore != maxAdminBasicScore {
return int(score), nil
}
// Second, check the (admin and non-admin) reviews.
maxReviewScore := maxScore.review * len(scores)
maxAdminReviewScore := maxScore.adminReview * len(scores)
reviewScore := computeNonAdminReviewScore(scores)
adminReviewScore := computeAdminReviewScore(scores)
score += noarmalizeScore(reviewScore+adminReviewScore, maxReviewScore+maxAdminReviewScore, adminNonAdminReviewLevel)
if reviewScore != maxReviewScore ||
adminReviewScore != maxAdminReviewScore {
return int(score), nil
}
// Third, check the use of non-admin context.
maxContextScore := maxScore.context * len(scores)
contextScore := computeNonAdminContextScore(scores)
score += noarmalizeScore(contextScore, maxContextScore, nonAdminContextLevel)
if contextScore != maxContextScore {
return int(score), nil
}
// Fourth, check the thorough non-admin reviews.
maxThoroughReviewScore := maxScore.thoroughReview * len(scores)
thoroughReviewScore := computeNonAdminThoroughReviewScore(scores)
score += noarmalizeScore(thoroughReviewScore, maxThoroughReviewScore, nonAdminThoroughReviewLevel)
if thoroughReviewScore != maxThoroughReviewScore {
return int(score), nil
}
// Last, check the thorough admin review config.
// This one is controversial and has usability issues
// https://github.com/ossf/scorecard/issues/1027, so we may remove it.
maxAdminThoroughReviewScore := maxScore.adminThoroughReview * len(scores)
adminThoroughReviewScore := computeAdminThoroughReviewScore(scores)
score += noarmalizeScore(adminThoroughReviewScore, maxAdminThoroughReviewScore, adminThoroughReviewLevel)
if adminThoroughReviewScore != maxAdminThoroughReviewScore {
return int(score), nil
}
return int(score), nil
}
func info(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Info(desc, args...)
}
func debug(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Debug(desc, args...)
}
func warn(dl checker.DetailLogger, doLogging bool, desc string, args ...interface{}) {
if !doLogging {
return
}
dl.Warn(desc, args...)
}
func basicNonAdminProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
max++
if branch.AllowsForcePushes != nil {
switch *branch.AllowsForcePushes {
case true:
warn(dl, log, "'force pushes' enabled on branch '%s'", branch.Name)
case false:
info(dl, log, "'force pushes' disabled on branch '%s'", branch.Name)
score++
}
}
max++
if branch.AllowsDeletions != nil {
switch *branch.AllowsDeletions {
case true:
warn(dl, log, "'allow deletion' enabled on branch '%s'", branch.Name)
case false:
info(dl, log, "'allow deletion' disabled on branch '%s'", branch.Name)
score++
}
}
return score, max
}
func basicAdminProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
// nil typically means we do not have access to the value.
if branch.EnforcesAdmins != nil {
// Note: we don't inrecase max possible score for non-admin viewers.
max++
switch *branch.EnforcesAdmins {
case true:
info(dl, log, "settings apply to administrators on branch '%s'", branch.Name)
score++
case false:
warn(dl, log, "settings do not apply to administrators on branch '%s'", branch.Name)
}
} else {
debug(dl, log, "unable to retrieve whether or not settings apply to administrators on branch '%s'", branch.Name)
}
return score, max
}
func nonAdminContextProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
// This means there are specific checks enabled.
// If only `Requires status check to pass before merging` is enabled
// but no specific checks are declared, it's equivalent
// to having no status check at all.
max++
switch {
case len(branch.StatusCheckContexts) > 0:
info(dl, log, "status check found to merge onto on branch '%s'", branch.Name)
score++
default:
warn(dl, log, "no status checks found to merge onto branch '%s'", branch.Name)
}
return score, max
}
func nonAdminReviewProtection(branch *checker.BranchProtectionData) (int, int) {
score := 0
max := 0
max++
if branch.RequiredApprovingReviewCount != nil &&
*branch.RequiredApprovingReviewCount > 0 {
// We do not display anything here, it's done in nonAdminThoroughReviewProtection()
score++
}
return score, max
}
func adminReviewProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
if branch.RequiresUpToDateBranchBeforeMerging != nil {
// Note: `This setting will not take effect unless at least one status check is enabled`.
max++
switch *branch.RequiresUpToDateBranchBeforeMerging {
case true:
info(dl, log, "status checks require up-to-date branches for '%s'", branch.Name)
score++
default:
warn(dl, log, "status checks do not require up-to-date branches for '%s'", branch.Name)
}
} else {
debug(dl, log, "unable to retrieve whether up-to-date branches are needed to merge on branch '%s'", branch.Name)
}
return score, max
}
func adminThoroughReviewProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
if branch.DismissesStaleReviews != nil {
// Note: we don't inrecase max possible score for non-admin viewers.
max++
switch *branch.DismissesStaleReviews {
case true:
info(dl, log, "Stale review dismissal enabled on branch '%s'", branch.Name)
score++
case false:
warn(dl, log, "Stale review dismissal disabled on branch '%s'", branch.Name)
}
} else {
debug(dl, log, "unable to retrieve review dismissal on branch '%s'", branch.Name)
}
return score, max
}
func nonAdminThoroughReviewProtection(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, int) {
score := 0
max := 0
// Only log information if the branch is protected.
log := branch.Protected != nil && *branch.Protected
max++
if branch.RequiredApprovingReviewCount != nil {
switch *branch.RequiredApprovingReviewCount >= minReviews {
case true:
info(dl, log, "number of required reviewers is %d on branch '%s'",
*branch.RequiredApprovingReviewCount, branch.Name)
score++
default:
warn(dl, log, "number of required reviewers is only %d on branch '%s'",
*branch.RequiredApprovingReviewCount, branch.Name)
}
} else {
warn(dl, log, "number of required reviewers is 0 on branch '%s'", branch.Name)
}
return score, max
}

View File

@ -0,0 +1,295 @@
// 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 evaluation
import (
"testing"
"github.com/ossf/scorecard/v3/checker"
scut "github.com/ossf/scorecard/v3/utests"
)
func testScore(branch *checker.BranchProtectionData, dl checker.DetailLogger) (int, error) {
var score levelScore
score.scores.basic, score.maxes.basic = basicNonAdminProtection(branch, dl)
score.scores.adminBasic, score.maxes.adminBasic = basicAdminProtection(branch, dl)
score.scores.review, score.maxes.review = nonAdminReviewProtection(branch)
score.scores.adminReview, score.maxes.adminReview = adminReviewProtection(branch, dl)
score.scores.context, score.maxes.context = nonAdminContextProtection(branch, dl)
score.scores.thoroughReview, score.maxes.thoroughReview = nonAdminThoroughReviewProtection(branch, dl)
score.scores.adminThoroughReview, score.maxes.adminThoroughReview = adminThoroughReviewProtection(branch, dl)
return computeScore([]levelScore{score})
}
func TestIsBranchProtected(t *testing.T) {
t.Parallel()
trueVal := true
falseVal := false
var zeroVal int
oneVal := 1
tests := []struct {
name string
branch *checker.BranchProtectionData
expected scut.TestReturn
}{
{
name: "Nothing is enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &trueVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: nil,
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &falseVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Nothing is enabled and values are nil",
expected: scut.TestReturn{
Error: nil,
Score: 0,
NumberOfWarn: 2,
NumberOfInfo: 0,
NumberOfDebug: 3,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
},
},
{
name: "Required status check enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 3,
NumberOfInfo: 4,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &trueVal,
RequiresUpToDateBranchBeforeMerging: &trueVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &falseVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Required status check enabled without checking for status string",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &trueVal,
RequiresUpToDateBranchBeforeMerging: &trueVal,
StatusCheckContexts: nil,
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &falseVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Required pull request enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &trueVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &oneVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &trueVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Required admin enforcement enabled",
expected: scut.TestReturn{
Error: nil,
Score: 3,
NumberOfWarn: 3,
NumberOfInfo: 4,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &falseVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &trueVal,
RequiresLinearHistory: &trueVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Required linear history enabled",
expected: scut.TestReturn{
Error: nil,
Score: 2,
NumberOfWarn: 4,
NumberOfInfo: 3,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &falseVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &trueVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Allow force push enabled",
expected: scut.TestReturn{
Error: nil,
Score: 1,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &falseVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &falseVal,
AllowsForcePushes: &trueVal,
AllowsDeletions: &falseVal,
},
},
{
name: "Allow deletions enabled",
expected: scut.TestReturn{
Error: nil,
Score: 1,
NumberOfWarn: 5,
NumberOfInfo: 2,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &falseVal,
RequiresUpToDateBranchBeforeMerging: &falseVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &falseVal,
RequiresCodeOwnerReviews: &falseVal,
RequiredApprovingReviewCount: &zeroVal,
EnforcesAdmins: &falseVal,
RequiresLinearHistory: &falseVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &trueVal,
},
},
{
name: "Branches are protected",
expected: scut.TestReturn{
Error: nil,
Score: 8,
NumberOfWarn: 1,
NumberOfInfo: 6,
NumberOfDebug: 0,
},
branch: &checker.BranchProtectionData{
Name: "branch-name",
Protected: &trueVal,
RequiresStatusChecks: &falseVal,
RequiresUpToDateBranchBeforeMerging: &trueVal,
StatusCheckContexts: []string{"foo"},
DismissesStaleReviews: &trueVal,
RequiresCodeOwnerReviews: &trueVal,
RequiredApprovingReviewCount: &oneVal,
EnforcesAdmins: &trueVal,
RequiresLinearHistory: &trueVal,
AllowsForcePushes: &falseVal,
AllowsDeletions: &falseVal,
},
},
}
for _, tt := range tests {
tt := tt // Re-initializing variable so it is not changed while executing the closure below
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dl := scut.TestDetailLogger{}
score, err := testScore(tt.branch, &dl)
actual := &checker.CheckResult{
Score: score,
Error: err,
}
if !scut.ValidateTestReturn(t, tt.name, &tt.expected, actual, &dl) {
t.Fail()
}
})
}
}

View File

@ -0,0 +1,152 @@
// 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 raw
import (
"errors"
"fmt"
"regexp"
"github.com/ossf/scorecard/v3/checker"
"github.com/ossf/scorecard/v3/clients"
sce "github.com/ossf/scorecard/v3/errors"
)
type branchMap map[string]*clients.BranchRef
// BranchProtection retrieves the raw data for the Branch-Protection check.
func BranchProtection(c clients.RepoClient) (checker.BranchProtectionsData, error) {
// Checks branch protection on both release and development branch.
// Get all branches. This will include information on whether they are protected.
branches, err := c.ListBranches()
if err != nil {
return checker.BranchProtectionsData{}, fmt.Errorf("%w", err)
}
branchesMap := getBranchMapFrom(branches)
// Get release branches.
releases, err := c.ListReleases()
if err != nil {
return checker.BranchProtectionsData{}, fmt.Errorf("%w", err)
}
commit := regexp.MustCompile("^[a-f0-9]{40}$")
checkBranches := make(map[string]bool)
for _, release := range releases {
if release.TargetCommitish == "" {
// Log with a named error if target_commitish is nil.
return checker.BranchProtectionsData{}, fmt.Errorf("%w", errInternalCommitishNil)
}
// TODO: if this is a sha, get the associated branch. for now, ignore.
if commit.Match([]byte(release.TargetCommitish)) {
continue
}
// Try to resolve the branch name.
b, err := branchesMap.getBranchByName(release.TargetCommitish)
if err != nil {
// If the commitish branch is still not found, fail.
return checker.BranchProtectionsData{}, err
}
// Branch is valid, add to list of branches to check.
checkBranches[*b.Name] = true
}
// Add default branch.
defaultBranch, err := c.GetDefaultBranch()
if err != nil {
return checker.BranchProtectionsData{}, fmt.Errorf("%w", err)
}
defaultBranchName := getBranchName(defaultBranch)
if defaultBranchName != "" {
checkBranches[defaultBranchName] = true
}
rawData := checker.BranchProtectionsData{}
// Check protections on all the branches.
for b := range checkBranches {
branch, err := branchesMap.getBranchByName(b)
if err != nil {
if errors.Is(err, errInternalBranchNotFound) {
continue
}
return checker.BranchProtectionsData{}, err
}
// Protected field only indates that the branch matches
// one `Branch protection rules`. All settings may be disabled,
// so it does not provide any guarantees.
protected := !(branch.Protected != nil && !*branch.Protected)
bpData := checker.BranchProtectionData{Name: b}
bp := branch.BranchProtectionRule
bpData.Protected = &protected
bpData.RequiresLinearHistory = bp.RequireLinearHistory
bpData.AllowsForcePushes = bp.AllowForcePushes
bpData.AllowsDeletions = bp.AllowDeletions
bpData.EnforcesAdmins = bp.EnforceAdmins
bpData.RequiresCodeOwnerReviews = bp.RequiredPullRequestReviews.RequireCodeOwnerReviews
bpData.DismissesStaleReviews = bp.RequiredPullRequestReviews.DismissStaleReviews
bpData.RequiresUpToDateBranchBeforeMerging = bp.CheckRules.UpToDateBeforeMerge
if bp.RequiredPullRequestReviews.RequiredApprovingReviewCount != nil {
v := int(*bp.RequiredPullRequestReviews.RequiredApprovingReviewCount)
bpData.RequiredApprovingReviewCount = &v
}
bpData.StatusCheckContexts = bp.CheckRules.Contexts
rawData.Branches = append(rawData.Branches, bpData)
}
// No error, return the data.
return rawData, nil
}
func (b branchMap) getBranchByName(name string) (*clients.BranchRef, error) {
val, exists := b[name]
if exists {
return val, nil
}
// Ideally, we should check using repositories.GetBranch if there was a branch redirect.
// See https://github.com/google/go-github/issues/1895
// For now, handle the common master -> main redirect.
if name == "master" {
val, exists := b["main"]
if exists {
return val, nil
}
}
return nil, sce.WithMessage(sce.ErrScorecardInternal,
fmt.Sprintf("could not find branch name %s: %v", name, errInternalBranchNotFound))
}
func getBranchMapFrom(branches []*clients.BranchRef) branchMap {
ret := make(branchMap)
for _, branch := range branches {
branchName := getBranchName(branch)
if branchName != "" {
ret[branchName] = branch
}
}
return ret
}
func getBranchName(branch *clients.BranchRef) string {
if branch == nil || branch.Name == nil {
return ""
}
return *branch.Name
}

View File

@ -31,7 +31,7 @@ func DependencyUpdateTool(c clients.RepoClient) (checker.DependencyUpdateToolDat
return checker.DependencyUpdateToolData{}, fmt.Errorf("%w", err)
}
// No error, return the files.
// No error, return the tools.
return checker.DependencyUpdateToolData{Tools: tools}, nil
}

View File

@ -14,12 +14,12 @@
package raw
import "github.com/ossf/scorecard/v3/checker"
import (
"errors"
)
// File represents a file.
type File struct {
Path string
Type checker.FileType
Offset int
// TODO: add hash if needed.
}
//nolint
var (
errInternalCommitishNil = errors.New("commitish is nil")
errInternalBranchNotFound = errors.New("branch not found")
)

View File

@ -46,6 +46,24 @@ type jsonTool struct {
// TODO: Runs, Issues, Merge requests.
}
type jsonBranchProtectionSettings struct {
RequiredApprovingReviewCount *int `json:"required-reviewer-count"`
AllowsDeletions *bool `json:"allows-deletions"`
AllowsForcePushes *bool `json:"allows-force-pushes"`
RequiresCodeOwnerReviews *bool `json:"requires-code-owner-review"`
RequiresLinearHistory *bool `json:"required-linear-history"`
DismissesStaleReviews *bool `json:"dismisses-stale-reviews"`
EnforcesAdmins *bool `json:"enforces-admin"`
RequiresStatusChecks *bool `json:"requires-status-checks"`
RequiresUpToDateBranchBeforeMerging *bool `json:"requires-updated-branches-to-merge"`
StatusCheckContexts []string `json:"status-checks-contexts"`
}
type jsonBranchProtection struct {
Protection *jsonBranchProtectionSettings `json:"protection"`
Name string `json:"name"`
}
type jsonRawResults struct {
// List of binaries found in the repo.
Binaries []jsonFile `json:"binaries"`
@ -55,6 +73,8 @@ type jsonRawResults struct {
// List of update tools.
// Note: we return one at most.
DependencyUpdateTools []jsonTool `json:"dependency-update-tools"`
// Branch protection settings for development and release branches.
BranchProtections []jsonBranchProtection `json:"branch-protections"`
}
//nolint:unparam
@ -93,14 +113,40 @@ func (r *jsonScorecardRawResult) addDependencyUpdateToolRawResults(dut *checker.
for _, f := range t.ConfigFiles {
r.Results.DependencyUpdateTools[offset].ConfigFiles =
append(r.Results.DependencyUpdateTools[offset].ConfigFiles, jsonFile{
Path: f.Path,
Offset: f.Offset,
Path: f.Path,
})
}
}
return nil
}
//nolint:unparam
func (r *jsonScorecardRawResult) addBranchProtectionRawResults(bp *checker.BranchProtectionsData) error {
r.Results.BranchProtections = []jsonBranchProtection{}
for _, v := range bp.Branches {
var bp *jsonBranchProtectionSettings
if v.Protected != nil && *v.Protected {
bp = &jsonBranchProtectionSettings{
AllowsDeletions: v.AllowsDeletions,
AllowsForcePushes: v.AllowsForcePushes,
RequiresCodeOwnerReviews: v.RequiresCodeOwnerReviews,
RequiresLinearHistory: v.RequiresLinearHistory,
DismissesStaleReviews: v.DismissesStaleReviews,
EnforcesAdmins: v.EnforcesAdmins,
RequiresStatusChecks: v.RequiresStatusChecks,
RequiresUpToDateBranchBeforeMerging: v.RequiresUpToDateBranchBeforeMerging,
RequiredApprovingReviewCount: v.RequiredApprovingReviewCount,
StatusCheckContexts: v.StatusCheckContexts,
}
}
r.Results.BranchProtections = append(r.Results.BranchProtections, jsonBranchProtection{
Name: v.Name,
Protection: bp,
})
}
return nil
}
func (r *jsonScorecardRawResult) fillJSONRawResults(raw *checker.RawResults) error {
// Binary-Artifacts.
if err := r.addBinaryArtifactRawResults(&raw.BinaryArtifactResults); err != nil {
@ -112,11 +158,16 @@ func (r *jsonScorecardRawResult) fillJSONRawResults(raw *checker.RawResults) err
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
// Dependecy-Update-Tool.
// Dependency-Update-Tool.
if err := r.addDependencyUpdateToolRawResults(&raw.DependencyUpdateToolResults); err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
// Branch-Protection.
if err := r.addBranchProtectionRawResults(&raw.BranchProtectionResults); err != nil {
return sce.WithMessage(sce.ErrScorecardInternal, err.Error())
}
return nil
}