mirror of
https://github.com/ossf/scorecard.git
synced 2024-09-17 11:57:12 +03:00
✨ [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:
parent
c795615321
commit
3d9b1d2900
@ -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 {
|
||||
|
@ -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.
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
|
402
checks/evaluation/branch_protection.go
Normal file
402
checks/evaluation/branch_protection.go
Normal 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
|
||||
}
|
295
checks/evaluation/branch_protection_test.go
Normal file
295
checks/evaluation/branch_protection_test.go
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
152
checks/raw/branch_protection.go
Normal file
152
checks/raw/branch_protection.go
Normal 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
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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")
|
||||
)
|
@ -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
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user