diff --git a/.github/workflows/scorecard-analysis.yml b/.github/workflows/scorecard-analysis.yml index 093312b10c4..dbddc000cd4 100644 --- a/.github/workflows/scorecard-analysis.yml +++ b/.github/workflows/scorecard-analysis.yml @@ -7,8 +7,6 @@ on: schedule: # Weekly on Saturdays. - cron: '30 1 * * 6' -# pull_request: -# branches: [main] permissions: read-all @@ -17,19 +15,22 @@ jobs: name: Scorecard analysis runs-on: ubuntu-latest permissions: + # Needed for Code scanning upload security-events: write + # Needed for GitHub OIDC token if publish_results is true id-token: write steps: - name: "Checkout code" uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false - name: "Run analysis" uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 with: results_file: results.sarif results_format: sarif - repo_token: ${{ secrets.GITHUB_TOKEN }} # Scorecard team runs a weekly scan of public GitHub repos, # see https://github.com/ossf/scorecard#public-data. # Setting `publish_results: true` helps us scale by leveraging your workflow to @@ -37,16 +38,19 @@ jobs: # And it's free for you! publish_results: true + # Upload the results as artifacts (optional). Commenting out will disable + # uploads of run results in SARIF format to the repository Actions tab. # https://docs.github.com/en/actions/advanced-guides/storing-workflow-data-as-artifacts - # Optional. - name: "Upload artifact" - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v3 + uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 with: name: SARIF file path: results.sarif retention-days: 5 - - name: "Upload SARIF results" - uses: github/codeql-action/upload-sarif@cdcdbb579706841c47f7063dda365e292e5cad7a # v1 + # Upload the results to GitHub's code scanning dashboard (optional). + # Commenting out will disable upload of results to your repo's Code Scanning dashboard + - name: "Upload to code-scanning" + uses: github/codeql-action/upload-sarif@83a02f7883b12e0e4e1a146174f5e2292a01e601 # v2.16.4 with: sarif_file: results.sarif diff --git a/checks/evaluation/permissions.go b/checks/evaluation/permissions.go new file mode 100644 index 00000000000..c0e3b274d33 --- /dev/null +++ b/checks/evaluation/permissions.go @@ -0,0 +1,303 @@ +// Copyright 2021 OpenSSF Scorecard Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package evaluation + +import ( + "fmt" + + "github.com/ossf/scorecard/v4/checker" + sce "github.com/ossf/scorecard/v4/errors" + "github.com/ossf/scorecard/v4/finding" + "github.com/ossf/scorecard/v4/probes/hasNoGitHubWorkflowPermissionUnknown" + "github.com/ossf/scorecard/v4/probes/jobLevelPermissions" + "github.com/ossf/scorecard/v4/probes/topLevelPermissions" +) + +func isWriteAll(f *finding.Finding) bool { + return (f.Values["tokenName"] == "all" || f.Values["tokenName"] == "write-all") +} + +// TokenPermissions applies the score policy for the Token-Permissions check. +// +//nolint:gocognit +func TokenPermissions(name string, + findings []finding.Finding, + dl checker.DetailLogger, +) checker.CheckResult { + expectedProbes := []string{ + hasNoGitHubWorkflowPermissionUnknown.Probe, + jobLevelPermissions.Probe, + topLevelPermissions.Probe, + } + if !finding.UniqueProbesEqual(findings, expectedProbes) { + e := sce.WithMessage(sce.ErrScorecardInternal, "invalid probe results") + return checker.CreateRuntimeErrorResult(name, e) + } + + // Start with a perfect score. + score := float32(checker.MaxResultScore) + + // hasWritePermissions is a map that holds information about the + // workflows in the project that have write permissions. It holds + // information about the write permissions of jobs and at the + // top-level too. The inner map (map[string]bool) has the + // workflow path as its key, and the value determines whether + // that workflow has write permissions at either "job" or "top" + // level. + hasWritePermissions := make(map[string]map[string]bool) + hasWritePermissions["jobLevel"] = make(map[string]bool) + hasWritePermissions["topLevel"] = make(map[string]bool) + + // undeclaredPermissions is a map that holds information about the + // workflows in the project that have undeclared permissions. It holds + // information about the undeclared permissions of jobs and at the + // top-level too. The inner map (map[string]bool) has the + // workflow path as its key, and the value determines whether + // that workflow has undeclared permissions at either "job" or "top" + // level. + undeclaredPermissions := make(map[string]map[string]bool) + undeclaredPermissions["jobLevel"] = make(map[string]bool) + undeclaredPermissions["topLevel"] = make(map[string]bool) + + for i := range findings { + f := &findings[i] + + // Log workflows with "none" permissions + if f.Values["permissionLevel"] == string(checker.PermissionLevelNone) { + dl.Info(&checker.LogMessage{ + Finding: f, + }) + continue + } + + // Log workflows with "read" permissions + if f.Values["permissionLevel"] == string(checker.PermissionLevelRead) { + dl.Info(&checker.LogMessage{ + Finding: f, + }) + } + + if isBothUndeclaredAndNotAvailableOrNotApplicable(f, dl) { + return checker.CreateInconclusiveResult(name, "Token permissions are not available") + } + + // If there are no TokenPermissions + if f.Outcome == finding.OutcomeNotApplicable { + return checker.CreateInconclusiveResult(name, "No tokens found") + } + + if f.Outcome != finding.OutcomeNegative { + continue + } + if f.Location == nil { + continue + } + fPath := f.Location.Path + + addProbeToMaps(fPath, undeclaredPermissions, hasWritePermissions) + + if f.Values["permissionLevel"] == string(checker.PermissionLevelUndeclared) { + score = updateScoreAndMapFromUndeclared(undeclaredPermissions, + hasWritePermissions, f, score, dl) + continue + } + + switch f.Probe { + case hasNoGitHubWorkflowPermissionUnknown.Probe: + dl.Debug(&checker.LogMessage{ + Finding: f, + }) + case topLevelPermissions.Probe: + if f.Values["permissionLevel"] != string(checker.PermissionLevelWrite) { + continue + } + hasWritePermissions["topLevel"][fPath] = true + + if !isWriteAll(f) { + score -= reduceBy(f, dl) + continue + } + + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + // "all" is evaluated separately. If the project also has write permissions + // or undeclared permissions at the job level, this is particularly bad. + if hasWritePermissions["jobLevel"][fPath] || + undeclaredPermissions["jobLevel"][fPath] { + return checker.CreateMinScoreResult(name, "detected GitHub workflow tokens with excessive permissions") + } + score -= 0.5 + case jobLevelPermissions.Probe: + if f.Values["permissionLevel"] != string(checker.PermissionLevelWrite) { + continue + } + + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + hasWritePermissions["jobLevel"][fPath] = true + + // If project has "all" writepermissions too at top level, this is + // particularly bad. + if hasWritePermissions["topLevel"][fPath] { + score = checker.MinResultScore + break + } + // If project has not declared permissions at top level:: + if undeclaredPermissions["topLevel"][fPath] { + score -= 0.5 + } + default: + continue + } + } + if score < checker.MinResultScore { + score = checker.MinResultScore + } + + logIfNoWritePermissionsFound(hasWritePermissions, dl) + + if score != checker.MaxResultScore { + return checker.CreateResultWithScore(name, + "detected GitHub workflow tokens with excessive permissions", int(score)) + } + + return checker.CreateMaxScoreResult(name, + "GitHub workflow tokens follow principle of least privilege") +} + +func logIfNoWritePermissionsFound(hasWritePermissions map[string]map[string]bool, + dl checker.DetailLogger, +) { + foundWritePermissions := false + for _, isWritePermission := range hasWritePermissions["jobLevel"] { + if isWritePermission { + foundWritePermissions = true + } + } + if !foundWritePermissions { + text := fmt.Sprintf("no %s write permissions found", checker.PermissionLocationJob) + dl.Info(&checker.LogMessage{ + Text: text, + }) + } +} + +func updateScoreFromUndeclaredJob(undeclaredPermissions map[string]map[string]bool, + hasWritePermissions map[string]map[string]bool, + fPath string, + score float32, +) float32 { + if hasWritePermissions["topLevel"][fPath] || + undeclaredPermissions["topLevel"][fPath] { + score = checker.MinResultScore + } + return score +} + +func updateScoreFromUndeclaredTop(undeclaredPermissions map[string]map[string]bool, + fPath string, + score float32, +) float32 { + if undeclaredPermissions["jobLevel"][fPath] { + score = checker.MinResultScore + } else { + score -= 0.5 + } + return score +} + +func isBothUndeclaredAndNotAvailableOrNotApplicable(f *finding.Finding, dl checker.DetailLogger) bool { + if f.Values["permissionLevel"] == string(checker.PermissionLevelUndeclared) { + if f.Outcome == finding.OutcomeNotAvailable { + return true + } else if f.Outcome == finding.OutcomeNotApplicable { + dl.Debug(&checker.LogMessage{ + Finding: f, + }) + return false + } + } + return false +} + +func updateScoreAndMapFromUndeclared(undeclaredPermissions map[string]map[string]bool, + hasWritePermissions map[string]map[string]bool, + f *finding.Finding, + score float32, dl checker.DetailLogger, +) float32 { + fPath := f.Location.Path + if f.Probe == jobLevelPermissions.Probe { + dl.Debug(&checker.LogMessage{ + Finding: f, + }) + undeclaredPermissions["jobLevel"][fPath] = true + score = updateScoreFromUndeclaredJob(undeclaredPermissions, + hasWritePermissions, + fPath, + score) + } else if f.Probe == topLevelPermissions.Probe { + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + undeclaredPermissions["topLevel"][fPath] = true + score = updateScoreFromUndeclaredTop(undeclaredPermissions, + fPath, + score) + } + + return score +} + +func addProbeToMaps(fPath string, hasWritePermissions, undeclaredPermissions map[string]map[string]bool) { + if _, ok := undeclaredPermissions["jobLevel"][fPath]; !ok { + undeclaredPermissions["jobLevel"][fPath] = false + } + if _, ok := undeclaredPermissions["topLevel"][fPath]; !ok { + undeclaredPermissions["topLevel"][fPath] = false + } + if _, ok := hasWritePermissions["jobLevel"][fPath]; !ok { + hasWritePermissions["jobLevel"][fPath] = false + } + if _, ok := hasWritePermissions["topLevel"][fPath]; !ok { + hasWritePermissions["topLevel"][fPath] = false + } +} + +func reduceBy(f *finding.Finding, dl checker.DetailLogger) float32 { + if f.Values["permissionLevel"] != string(checker.PermissionLevelWrite) { + return 0 + } + tokenName := f.Values["tokenName"] + switch tokenName { + case "checks", "statuses": + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + return 0.5 + case "contents", "packages", "actions": + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + return checker.MaxResultScore + case "deployments", "security-events": + dl.Warn(&checker.LogMessage{ + Finding: f, + }) + return 1.0 + } + return 0 +} diff --git a/checks/evaluation/permissions/gitHubWorkflowPermissionsStepsNoWrite.yml b/checks/evaluation/permissions/gitHubWorkflowPermissionsStepsNoWrite.yml deleted file mode 100644 index 171f8503fb1..00000000000 --- a/checks/evaluation/permissions/gitHubWorkflowPermissionsStepsNoWrite.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Copyright 2023 OpenSSF 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. - -id: gitHubWorkflowPermissionsStepsNoWrite -short: Checks that GitHub workflows do not have steps with dangerous write permissions -motivation: > - Even with permissions default set to read, some scopes having write permissions in their steps brings incurs a risk to the project. - By giving write permission to the Actions you call in jobs, an external Action you call could abuse them. Depending on the permissions, - this could let the external Action commit unreviewed code, remove pre-submit checks to introduce a bug. - For more information about the scopes and the vulnerabilities involved, see https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions. - -implementation: > - The probe is implemented by checking whether the `permissions` keyword is given non-write permissions for the following - scopes: `statuses`, `checks`, `security-events`, `deployments`, `contents`, `packages`, `actions`. - Write permissions given to recognized packaging actions or commands are allowed and are considered an acceptable risk. -remediation: - effort: High - text: - - Verify which permissions are needed and consider whether you can reduce them. - markdown: - - Verify which permissions are needed and consider whether you can reduce them. diff --git a/checks/evaluation/permissions/permissions.go b/checks/evaluation/permissions/permissions.go deleted file mode 100644 index 23ee6c2a7db..00000000000 --- a/checks/evaluation/permissions/permissions.go +++ /dev/null @@ -1,564 +0,0 @@ -// Copyright 2021 OpenSSF 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 ( - "embed" - "fmt" - "strings" - - "github.com/ossf/scorecard/v4/checker" - sce "github.com/ossf/scorecard/v4/errors" - "github.com/ossf/scorecard/v4/finding" - "github.com/ossf/scorecard/v4/remediation" -) - -//go:embed *.yml -var probes embed.FS - -type permissions struct { - topLevelWritePermissions map[string]bool - jobLevelWritePermissions map[string]bool -} - -var ( - stepsNoWriteID = "gitHubWorkflowPermissionsStepsNoWrite" - topNoWriteID = "gitHubWorkflowPermissionsTopNoWrite" -) - -type permissionLevel string - -const ( - // permissionLevelNone is a permission set to `none`. - permissionLevelNone permissionLevel = "none" - // permissionLevelRead is a permission set to `read`. - permissionLevelRead permissionLevel = "read" - // permissionLevelUnknown is for other kinds of alerts, mostly to support debug messages. - // TODO: remove it once we have implemented severity (#1874). - permissionLevelUnknown permissionLevel = "unknown" - // permissionLevelUndeclared is an undeclared permission. - permissionLevelUndeclared permissionLevel = "undeclared" - // permissionLevelWrite is a permission set to `write` for a permission we consider potentially dangerous. - permissionLevelWrite permissionLevel = "write" -) - -// permissionLocation represents a declaration type. -type permissionLocationType string - -const ( - // permissionLocationNil is in case the permission is nil. - permissionLocationNil permissionLocationType = "nil" - // permissionLocationNotDeclared is for undeclared permission. - permissionLocationNotDeclared permissionLocationType = "not declared" - // permissionLocationTop is top-level workflow permission. - permissionLocationTop permissionLocationType = "top" - // permissionLocationJob is job-level workflow permission. - permissionLocationJob permissionLocationType = "job" -) - -// permissionType represents a permission type. -type permissionType string - -const ( - // permissionTypeNone represents none permission type. - permissionTypeNone permissionType = "none" - // permissionTypeNone is the "all" github permission type. - permissionTypeAll permissionType = "all" - // permissionTypeNone is the "statuses" github permission type. - permissionTypeStatuses permissionType = "statuses" - // permissionTypeNone is the "checks" github permission type. - permissionTypeChecks permissionType = "checks" - // permissionTypeNone is the "security-events" github permission type. - permissionTypeSecurityEvents permissionType = "security-events" - // permissionTypeNone is the "deployments" github permission type. - permissionTypeDeployments permissionType = "deployments" - // permissionTypeNone is the "packages" github permission type. - permissionTypePackages permissionType = "packages" - // permissionTypeNone is the "actions" github permission type. - permissionTypeActions permissionType = "actions" -) - -// TokenPermissions applies the score policy for the Token-Permissions check. -func TokenPermissions(name string, c *checker.CheckRequest, r *checker.TokenPermissionsData) checker.CheckResult { - if r == nil { - e := sce.WithMessage(sce.ErrScorecardInternal, "empty raw data") - return checker.CreateRuntimeErrorResult(name, e) - } - - if r.NumTokens == 0 { - return checker.CreateInconclusiveResult(name, "no tokens found") - } - - // This is a temporary step that should be replaced by probes in ./probes - findings, err := rawToFindings(r) - if err != nil { - e := sce.WithMessage(sce.ErrScorecardInternal, "could not convert raw data to findings") - return checker.CreateRuntimeErrorResult(name, e) - } - - score, err := applyScorePolicy(findings, c) - if err != nil { - return checker.CreateRuntimeErrorResult(name, err) - } - - if score != checker.MaxResultScore { - return checker.CreateResultWithScore(name, - "detected GitHub workflow tokens with excessive permissions", score) - } - - return checker.CreateMaxScoreResult(name, - "GitHub workflow tokens follow principle of least privilege") -} - -// rawToFindings is a temporary step for converting the raw results -// to findings. This should be replaced by probes in ./probes. -func rawToFindings(results *checker.TokenPermissionsData) ([]finding.Finding, error) { - var findings []finding.Finding - - for _, r := range results.TokenPermissions { - var loc *finding.Location - if r.File != nil { - loc = &finding.Location{ - Type: r.File.Type, - Path: r.File.Path, - LineStart: newUint(r.File.Offset), - } - if r.File.Snippet != "" { - loc.Snippet = newStr(r.File.Snippet) - } - } - text, err := createText(r) - if err != nil { - return nil, err - } - - f, err := createFinding(r.LocationType, text, loc) - if err != nil { - return nil, err - } - - switch r.Type { - case checker.PermissionLevelNone: - f = f.WithOutcome(finding.OutcomePositive) - f = f.WithValue("PermissionLevel", string(permissionLevelNone)) - case checker.PermissionLevelRead: - f = f.WithOutcome(finding.OutcomePositive) - f = f.WithValue("PermissionLevel", string(permissionLevelRead)) - case checker.PermissionLevelUnknown: - f = f.WithValue("PermissionLevel", string(permissionLevelUnknown)) - f = f.WithOutcome(finding.OutcomeError) - case checker.PermissionLevelUndeclared: - var locationType permissionLocationType - //nolint:gocritic - if r.LocationType == nil { - locationType = permissionLocationNil - } else if *r.LocationType == checker.PermissionLocationTop { - locationType = permissionLocationTop - } else { - locationType = permissionLocationNotDeclared - } - permType := permTypeToEnum(r.Name) - f = f.WithValues(map[string]string{ - "PermissionLevel": string(permissionLevelUndeclared), - "LocationType": string(locationType), - "PermissionType": string(permType), - }) - case checker.PermissionLevelWrite: - var locationType permissionLocationType - switch *r.LocationType { - case checker.PermissionLocationTop: - locationType = permissionLocationTop - case checker.PermissionLocationJob: - locationType = permissionLocationJob - default: - locationType = permissionLocationNotDeclared - } - permType := permTypeToEnum(r.Name) - f = f.WithValues(map[string]string{ - "PermissionLevel": string(permissionLevelWrite), - "LocationType": string(locationType), - "PermissionType": string(permType), - }) - f = f.WithOutcome(finding.OutcomeNegative) - } - findings = append(findings, *f) - } - return findings, nil -} - -func permTypeToEnum(tokenName *string) permissionType { - if tokenName == nil { - return permissionTypeNone - } - switch *tokenName { - //nolint:goconst - case "all": - return permissionTypeAll - case "statuses": - return permissionTypeStatuses - case "checks": - return permissionTypeChecks - case "security-events": - return permissionTypeSecurityEvents - case "deployments": - return permissionTypeDeployments - case "contents": - return permissionTypePackages - case "actions": - return permissionTypeActions - default: - return permissionTypeNone - } -} - -func permTypeToName(permType string) *string { - var permName string - switch permissionType(permType) { - case permissionTypeAll: - permName = "all" - case permissionTypeStatuses: - permName = "statuses" - case permissionTypeChecks: - permName = "checks" - case permissionTypeSecurityEvents: - permName = "security-events" - case permissionTypeDeployments: - permName = "deployments" - case permissionTypePackages: - permName = "contents" - case permissionTypeActions: - permName = "actions" - default: - permName = "" - } - return &permName -} - -func createFinding(loct *checker.PermissionLocation, text string, loc *finding.Location) (*finding.Finding, error) { - probe := stepsNoWriteID - if loct == nil || *loct == checker.PermissionLocationTop { - probe = topNoWriteID - } - content, err := probes.ReadFile(probe + ".yml") - if err != nil { - return nil, fmt.Errorf("reading %v.yml: %w", probe, err) - } - f, err := finding.FromBytes(content, probe) - if err != nil { - return nil, - sce.WithMessage(sce.ErrScorecardInternal, err.Error()) - } - f = f.WithMessage(text) - if loc != nil { - f = f.WithLocation(loc) - } - return f, nil -} - -// avoid memory aliasing by returning a new copy. -func newUint(u uint) *uint { - return &u -} - -// avoid memory aliasing by returning a new copy. -func newStr(s string) *string { - return &s -} - -func applyScorePolicy(findings []finding.Finding, c *checker.CheckRequest) (int, error) { - // See list https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/. - // Note: there are legitimate reasons to use some of the permissions like checks, deployments, etc. - // in CI/CD systems https://docs.travis-ci.com/user/github-oauth-scopes/. - - hm := make(map[string]permissions) - dl := c.Dlogger - //nolint:errcheck - remediationMetadata, _ := remediation.New(c) - negativeProbeResults := map[string]bool{ - stepsNoWriteID: false, - topNoWriteID: false, - } - - for i := range findings { - f := &findings[i] - pLevel := permissionLevel(f.Values["PermissionLevel"]) - switch pLevel { - case permissionLevelNone, permissionLevelRead: - dl.Info(&checker.LogMessage{ - Finding: f, - }) - case permissionLevelUnknown: - dl.Debug(&checker.LogMessage{ - Finding: f, - }) - - case permissionLevelUndeclared: - switch permissionLocationType(f.Values["LocationType"]) { - case permissionLocationNil: - return checker.InconclusiveResultScore, - sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") - case permissionLocationTop: - warnWithRemediation(dl, remediationMetadata, f, negativeProbeResults) - default: - // We warn only for top-level. - dl.Debug(&checker.LogMessage{ - Finding: f, - }) - } - - // Group results by workflow name for score computation. - if err := updateWorkflowHashMap(hm, f); err != nil { - return checker.InconclusiveResultScore, err - } - - case permissionLevelWrite: - warnWithRemediation(dl, remediationMetadata, f, negativeProbeResults) - - // Group results by workflow name for score computation. - if err := updateWorkflowHashMap(hm, f); err != nil { - return checker.InconclusiveResultScore, err - } - } - } - - if err := reportDefaultFindings(findings, c.Dlogger, negativeProbeResults); err != nil { - return checker.InconclusiveResultScore, err - } - return calculateScore(hm), nil -} - -func reportDefaultFindings(results []finding.Finding, - dl checker.DetailLogger, negativeProbeResults map[string]bool, -) error { - // Workflow files found, report positive findings if no - // negative findings were found. - // NOTE: we don't consider probe `topNoWriteID` - // because positive results are already reported. - found := negativeProbeResults[stepsNoWriteID] - if !found { - text := fmt.Sprintf("no %s write permissions found", checker.PermissionLocationJob) - if err := reportFinding(stepsNoWriteID, - text, finding.OutcomePositive, dl); err != nil { - return err - } - } - - return nil -} - -func reportFinding(probe, text string, o finding.Outcome, dl checker.DetailLogger) error { - content, err := probes.ReadFile(probe + ".yml") - if err != nil { - return fmt.Errorf("%w", err) - } - f, err := finding.FromBytes(content, probe) - if err != nil { - return sce.WithMessage(sce.ErrScorecardInternal, err.Error()) - } - f = f.WithMessage(text).WithOutcome(o) - dl.Info(&checker.LogMessage{ - Finding: f, - }) - return nil -} - -func warnWithRemediation(logger checker.DetailLogger, - rem *remediation.RemediationMetadata, - f *finding.Finding, - negativeProbeResults map[string]bool, -) { - if f.Location != nil && f.Location.Path != "" { - f = f.WithRemediationMetadata(map[string]string{ - "repo": rem.Repo, - "branch": rem.Branch, - "workflow": strings.TrimPrefix(f.Location.Path, ".github/workflows/"), - }) - } - logger.Warn(&checker.LogMessage{ - Finding: f, - }) - - // Record that we found a negative result. - negativeProbeResults[f.Probe] = true -} - -func recordPermissionWrite(hm map[string]permissions, path string, - locType permissionLocationType, permType string, -) { - if _, exists := hm[path]; !exists { - hm[path] = permissions{ - topLevelWritePermissions: make(map[string]bool), - jobLevelWritePermissions: make(map[string]bool), - } - } - - // Select the hash map to update. - m := hm[path].jobLevelWritePermissions - if locType == permissionLocationTop { - m = hm[path].topLevelWritePermissions - } - - // Set the permission name to record. - permName := permTypeToName(permType) - name := "all" - if permName != nil && *permName != "" { - name = *permName - } - m[name] = true -} - -func updateWorkflowHashMap(hm map[string]permissions, f *finding.Finding) error { - if _, ok := f.Values["LocationType"]; !ok { - return sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") - } - - if f.Location == nil || f.Location.Path == "" { - return sce.WithMessage(sce.ErrScorecardInternal, "path is not set") - } - - if permissionLevel(f.Values["PermissionLevel"]) != permissionLevelWrite && - permissionLevel(f.Values["PermissionLevel"]) != permissionLevelUndeclared { - return nil - } - plt := permissionLocationType(f.Values["LocationType"]) - recordPermissionWrite(hm, f.Location.Path, plt, f.Values["PermissionType"]) - - return nil -} - -func createText(t checker.TokenPermission) (string, error) { - // By default, use the message already present. - if t.Msg != nil { - return *t.Msg, nil - } - - // Ensure there's no implementation bug. - if t.LocationType == nil { - return "", sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") - } - - // Use a different text depending on the type. - if t.Type == checker.PermissionLevelUndeclared { - return fmt.Sprintf("no %s permission defined", *t.LocationType), nil - } - - if t.Value == nil { - return "", sce.WithMessage(sce.ErrScorecardInternal, "Value fields is nil") - } - - if t.Name == nil { - return fmt.Sprintf("%s permissions set to '%v'", *t.LocationType, - *t.Value), nil - } - - return fmt.Sprintf("%s '%v' permission set to '%v'", *t.LocationType, - *t.Name, *t.Value), nil -} - -// Calculate the score. -func calculateScore(result map[string]permissions) int { - // See list https://github.blog/changelog/2021-04-20-github-actions-control-permissions-for-github_token/. - // Note: there are legitimate reasons to use some of the permissions like checks, deployments, etc. - // in CI/CD systems https://docs.travis-ci.com/user/github-oauth-scopes/. - - // Start with a perfect score. - score := float32(checker.MaxResultScore) - - // Retrieve the overall results. - for _, perms := range result { - // If no top level permissions are defined, all the permissions - // are enabled by default. In this case, - if permissionIsPresentInTopLevel(perms, "all") { - if permissionIsPresentInRunLevel(perms, "all") { - // ... give lowest score if no run level permissions are defined either. - return checker.MinResultScore - } - // ... reduce score if run level permissions are defined. - score -= 0.5 - } - - // status: https://docs.github.com/en/rest/reference/repos#statuses. - // May allow an attacker to change the result of pre-submit and get a PR merged. - // Low risk: -0.5. - if permissionIsPresentInTopLevel(perms, "statuses") { - score -= 0.5 - } - - // checks. - // May allow an attacker to edit checks to remove pre-submit and introduce a bug. - // Low risk: -0.5. - if permissionIsPresentInTopLevel(perms, "checks") { - score -= 0.5 - } - - // secEvents. - // May allow attacker to read vuln reports before patch available. - // Low risk: -1 - if permissionIsPresentInTopLevel(perms, "security-events") { - score-- - } - - // deployments: https://docs.github.com/en/rest/reference/repos#deployments. - // May allow attacker to charge repo owner by triggering VM runs, - // and tiny chance an attacker can trigger a remote - // service with code they own if server accepts code/location var unsanitized. - // Low risk: -1 - if permissionIsPresentInTopLevel(perms, "deployments") { - score-- - } - - // contents. - // Allows attacker to commit unreviewed code. - // High risk: -10 - if permissionIsPresentInTopLevel(perms, "contents") { - score -= checker.MaxResultScore - } - - // packages: https://docs.github.com/en/packages/learn-github-packages/about-permissions-for-github-packages. - // Allows attacker to publish packages. - // High risk: -10 - if permissionIsPresentInTopLevel(perms, "packages") { - score -= checker.MaxResultScore - } - - // actions. - // May allow an attacker to steal GitHub secrets by approving to run an action that needs approval. - // High risk: -10 - if permissionIsPresentInTopLevel(perms, "actions") { - score -= checker.MaxResultScore - } - - if score < checker.MinResultScore { - break - } - } - - // We're done, calculate the final score. - if score < checker.MinResultScore { - return checker.MinResultScore - } - - return int(score) -} - -func permissionIsPresentInTopLevel(perms permissions, name string) bool { - _, ok := perms.topLevelWritePermissions[name] - return ok -} - -func permissionIsPresentInRunLevel(perms permissions, name string) bool { - _, ok := perms.jobLevelWritePermissions[name] - return ok -} diff --git a/checks/permissions.go b/checks/permissions.go index abb3f902558..8d7fbe73fdf 100644 --- a/checks/permissions.go +++ b/checks/permissions.go @@ -16,9 +16,11 @@ package checks import ( "github.com/ossf/scorecard/v4/checker" - evaluation "github.com/ossf/scorecard/v4/checks/evaluation/permissions" + "github.com/ossf/scorecard/v4/checks/evaluation" "github.com/ossf/scorecard/v4/checks/raw" sce "github.com/ossf/scorecard/v4/errors" + "github.com/ossf/scorecard/v4/probes" + "github.com/ossf/scorecard/v4/probes/zrunner" ) // CheckTokenPermissions is the exported name for Token-Permissions check. @@ -44,11 +46,17 @@ func TokenPermissions(c *checker.CheckRequest) checker.CheckResult { return checker.CreateRuntimeErrorResult(CheckTokenPermissions, e) } - // Return raw results. - if c.RawResults != nil { - c.RawResults.TokenPermissionsResults = rawData + // Set the raw results. + pRawResults := getRawResults(c) + pRawResults.TokenPermissionsResults = rawData + + // Evaluate the probes. + findings, err := zrunner.Run(pRawResults, probes.TokenPermissions) + if err != nil { + e := sce.WithMessage(sce.ErrScorecardInternal, err.Error()) + return checker.CreateRuntimeErrorResult(CheckTokenPermissions, e) } // Return the score evaluation. - return evaluation.TokenPermissions(CheckTokenPermissions, c, &rawData) + return evaluation.TokenPermissions(CheckTokenPermissions, findings, c.Dlogger) } diff --git a/checks/permissions_test.go b/checks/permissions_test.go index 16c7b9bc4ab..5fa7a98c64d 100644 --- a/checks/permissions_test.go +++ b/checks/permissions_test.go @@ -109,7 +109,7 @@ func TestGithubTokenPermissions(t *testing.T) { Error: nil, Score: checker.MinResultScore, NumberOfWarn: 1, - NumberOfInfo: 1, + NumberOfInfo: 0, NumberOfDebug: 5, }, }, diff --git a/checks/raw/permissions.go b/checks/raw/permissions.go index c3c7132db44..7c1d8c46507 100644 --- a/checks/raw/permissions.go +++ b/checks/raw/permissions.go @@ -104,6 +104,7 @@ var validateGitHubActionTokenPermissions fileparser.DoWhileTrueOnFileContent = f // 2. Run-level permission definitions, // see https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idpermissions. ignoredPermissions := createIgnoredPermissions(workflow, path, pdata) + if err := validatejobLevelPermissions(workflow, path, pdata, ignoredPermissions); err != nil { return false, err } diff --git a/probes/entries.go b/probes/entries.go index 8c355cbeb3e..d18db1cb61c 100644 --- a/probes/entries.go +++ b/probes/entries.go @@ -33,10 +33,12 @@ import ( "github.com/ossf/scorecard/v4/probes/hasFSFOrOSIApprovedLicense" "github.com/ossf/scorecard/v4/probes/hasLicenseFile" "github.com/ossf/scorecard/v4/probes/hasLicenseFileAtTopDir" + "github.com/ossf/scorecard/v4/probes/hasNoGitHubWorkflowPermissionUnknown" "github.com/ossf/scorecard/v4/probes/hasOSVVulnerabilities" "github.com/ossf/scorecard/v4/probes/hasOpenSSFBadge" "github.com/ossf/scorecard/v4/probes/hasRecentCommits" "github.com/ossf/scorecard/v4/probes/issueActivityByProjectMember" + "github.com/ossf/scorecard/v4/probes/jobLevelPermissions" "github.com/ossf/scorecard/v4/probes/notArchived" "github.com/ossf/scorecard/v4/probes/notCreatedRecently" "github.com/ossf/scorecard/v4/probes/packagedWithAutomatedWorkflow" @@ -59,6 +61,7 @@ import ( "github.com/ossf/scorecard/v4/probes/toolDependabotInstalled" "github.com/ossf/scorecard/v4/probes/toolPyUpInstalled" "github.com/ossf/scorecard/v4/probes/toolRenovateInstalled" + "github.com/ossf/scorecard/v4/probes/topLevelPermissions" "github.com/ossf/scorecard/v4/probes/webhooksUseSecrets" ) @@ -150,6 +153,11 @@ var ( PinnedDependencies = []ProbeImpl{ pinsDependencies.Run, } + TokenPermissions = []ProbeImpl{ + hasNoGitHubWorkflowPermissionUnknown.Run, + jobLevelPermissions.Run, + topLevelPermissions.Run, + } // Probes which aren't included by any checks. // These still need to be listed so they can be called with --probes. diff --git a/checks/evaluation/permissions/gitHubWorkflowPermissionsTopNoWrite.yml b/probes/hasNoGitHubWorkflowPermissionUnknown/def.yml similarity index 58% rename from checks/evaluation/permissions/gitHubWorkflowPermissionsTopNoWrite.yml rename to probes/hasNoGitHubWorkflowPermissionUnknown/def.yml index 91b2f117c93..5f2b8593942 100644 --- a/checks/evaluation/permissions/gitHubWorkflowPermissionsTopNoWrite.yml +++ b/probes/hasNoGitHubWorkflowPermissionUnknown/def.yml @@ -1,4 +1,4 @@ -# Copyright 2023 OpenSSF Scorecard Authors +# Copyright 2024 OpenSSF Scorecard Authors # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -12,24 +12,24 @@ # See the License for the specific language governing permissions and # limitations under the License. -id: gitHubWorkflowPermissionsTopNoWrite -short: Checks that GitHub workflows do not have default write permissions +id: hasNoGitHubWorkflowPermissionUnknown +short: Checks that GitHub workflows have workflows with unknown permissions motivation: > - If no permissions are declared, a workflow's GitHub token's permissions default to write for all scopes. - This include write permissions to push to the repository, to read encrypted secrets, etc. - For more information, see https://docs.github.com/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token. + Unknown permissions may be a result of a bug or another error from fetching the permission levels. implementation: > - The rule is implemented by checking whether the `permissions` keyword is defined at the top of the workflow, - and that no write permissions are given. + The probe checks the permission levels of a projects workflows and collects the workflows that have unknown permissions. +outcome: + - The probe returns 1 negative outcome per workflow without unknown permission level(s). + - The probe returns 1 positive outcome if the project has no workflows with unknown permission levels. remediation: effort: Low text: - - Visit https://app.stepsecurity.io/secureworkflow/${{ metadata.repo }}/${{ metadata.workflow }}/${{ metadata.branch }}?enable=permissions + - Visit https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions - Tick the 'Restrict permissions for GITHUB_TOKEN' - Untick other options - "NOTE: If you want to resolve multiple issues at once, you can visit https://app.stepsecurity.io/securerepo instead." markdown: - - Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ metadata.repo }}/${{ metadata.workflow }}/${{ metadata.branch }}?enable=permissions). + - Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions). - Tick the 'Restrict permissions for GITHUB_TOKEN' - Untick other options - "NOTE: If you want to resolve multiple issues at once, you can visit [https://app.stepsecurity.io/securerepo](https://app.stepsecurity.io/securerepo) instead." diff --git a/probes/hasNoGitHubWorkflowPermissionUnknown/impl.go b/probes/hasNoGitHubWorkflowPermissionUnknown/impl.go new file mode 100644 index 00000000000..0c6aad3e1d0 --- /dev/null +++ b/probes/hasNoGitHubWorkflowPermissionUnknown/impl.go @@ -0,0 +1,75 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package hasNoGitHubWorkflowPermissionUnknown + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/finding" + "github.com/ossf/scorecard/v4/probes/internal/utils/permissions" + "github.com/ossf/scorecard/v4/probes/internal/utils/uerror" +) + +//go:embed *.yml +var fs embed.FS + +const Probe = "hasNoGitHubWorkflowPermissionUnknown" + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + results := raw.TokenPermissionsResults + var findings []finding.Finding + + if results.NumTokens == 0 { + f, err := finding.NewWith(fs, Probe, + "No token permissions found", + nil, finding.OutcomeNotApplicable) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + return findings, Probe, nil + } + + for _, r := range results.TokenPermissions { + if r.Type != checker.PermissionLevelUnknown { + continue + } + + // Create finding + f, err := permissions.CreateNegativeFinding(r, Probe, fs, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + + if len(findings) == 0 { + f, err := finding.NewWith(fs, Probe, + "no workflows with unknown permissions", + nil, finding.OutcomePositive) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + return findings, Probe, nil +} diff --git a/probes/hasNoGitHubWorkflowPermissionUnknown/impl_test.go b/probes/hasNoGitHubWorkflowPermissionUnknown/impl_test.go new file mode 100644 index 00000000000..97603167579 --- /dev/null +++ b/probes/hasNoGitHubWorkflowPermissionUnknown/impl_test.go @@ -0,0 +1,98 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package hasNoGitHubWorkflowPermissionUnknown + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/finding" + "github.com/ossf/scorecard/v4/probes/internal/utils/test" +) + +func Test_Run(t *testing.T) { + t.Parallel() + permLoc := checker.PermissionLocationTop + value := "value" + tests := []test.TestData{ + { + Name: "No Tokens", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 0, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + Name: "Correct permission level", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + Type: checker.PermissionLevelUnknown, + LocationType: &permLoc, + Value: &value, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNegative, + }, + }, + { + Name: "Incorrect permission level", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + Type: checker.PermissionLevelRead, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomePositive, + }, + }, + } + 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() + + findings, s, err := Run(tt.Raw) + if !cmp.Equal(tt.Err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.Err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.Outcomes) + }) + } +} diff --git a/probes/internal/utils/permissions/permissions.go b/probes/internal/utils/permissions/permissions.go new file mode 100644 index 00000000000..bc22f904b0f --- /dev/null +++ b/probes/internal/utils/permissions/permissions.go @@ -0,0 +1,169 @@ +// Copyright 2024 OpenSSF 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 permissions + +import ( + "embed" + "fmt" + "strings" + + "github.com/ossf/scorecard/v4/checker" + sce "github.com/ossf/scorecard/v4/errors" + "github.com/ossf/scorecard/v4/finding" +) + +func createText(t checker.TokenPermission) (string, error) { + // By default, use the message already present. + if t.Msg != nil { + return *t.Msg, nil + } + + // Ensure there's no implementation bug. + if t.LocationType == nil { + return "", sce.WithMessage(sce.ErrScorecardInternal, "locationType is nil") + } + + // Use a different text depending on the type. + if t.Type == checker.PermissionLevelUndeclared { + return fmt.Sprintf("no %s permission defined", *t.LocationType), nil + } + + if t.Value == nil { + return "", sce.WithMessage(sce.ErrScorecardInternal, "Value fields is nil") + } + + if t.Name == nil { + return fmt.Sprintf("%s permissions set to '%v'", *t.LocationType, + *t.Value), nil + } + + return fmt.Sprintf("%s '%v' permission set to '%v'", *t.LocationType, + *t.Name, *t.Value), nil +} + +func CreateNegativeFinding(r checker.TokenPermission, + probe string, + fs embed.FS, + metadata map[string]string, +) (*finding.Finding, error) { + // Create finding + text, err := createText(r) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + f, err := finding.NewWith(fs, probe, + text, nil, finding.OutcomeNegative) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + + if r.File != nil { + f = f.WithLocation(r.File.Location()) + workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/") + f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath}) + } + if metadata != nil { + f = f.WithRemediationMetadata(metadata) + } + + if r.Name != nil { + f = f.WithValue("tokenName", *r.Name) + } + f = f.WithValue("permissionLevel", string(r.Type)) + return f, nil +} + +func ReadPositiveLevelFinding(probe string, + fs embed.FS, + r checker.TokenPermission, + metadata map[string]string, +) (*finding.Finding, error) { + f, err := finding.NewWith(fs, probe, + "found token with 'read' permissions", + nil, finding.OutcomePositive) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + if r.File != nil { + f = f.WithLocation(r.File.Location()) + workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/") + f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath}) + } + if metadata != nil { + f = f.WithRemediationMetadata(metadata) + } + + f = f.WithValue("permissionLevel", "read") + return f, nil +} + +func CreateNoneFinding(probe string, + fs embed.FS, + r checker.TokenPermission, + metadata map[string]string, +) (*finding.Finding, error) { + // Create finding + f, err := finding.NewWith(fs, probe, + "found token with 'none' permissions", + nil, finding.OutcomeNegative) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + if r.File != nil { + f = f.WithLocation(r.File.Location()) + workflowPath := strings.TrimPrefix(f.Location.Path, ".github/workflows/") + f = f.WithRemediationMetadata(map[string]string{"workflow": workflowPath}) + } + if metadata != nil { + f = f.WithRemediationMetadata(metadata) + } + + f = f.WithValue("permissionLevel", string(r.Type)) + return f, nil +} + +func CreateUndeclaredFinding(probe string, + fs embed.FS, + r checker.TokenPermission, + metadata map[string]string, +) (*finding.Finding, error) { + var f *finding.Finding + var err error + switch { + case r.LocationType == nil: + f, err = finding.NewWith(fs, probe, + "could not determine the location type", + nil, finding.OutcomeNotApplicable) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + case *r.LocationType == checker.PermissionLocationTop, + *r.LocationType == checker.PermissionLocationJob: + // Create finding + f, err = CreateNegativeFinding(r, probe, fs, metadata) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + default: + f, err = finding.NewWith(fs, probe, + "could not determine the location type", + nil, finding.OutcomeError) + if err != nil { + return nil, fmt.Errorf("create finding: %w", err) + } + } + f = f.WithValue("permissionLevel", string(r.Type)) + return f, nil +} diff --git a/probes/internal/utils/test/test.go b/probes/internal/utils/test/test.go index 484a4949bc3..9d1d150bc91 100644 --- a/probes/internal/utils/test/test.go +++ b/probes/internal/utils/test/test.go @@ -17,6 +17,8 @@ package test import ( "testing" + "github.com/ossf/scorecard/v4/checker" + sce "github.com/ossf/scorecard/v4/errors" "github.com/ossf/scorecard/v4/finding" ) @@ -32,3 +34,147 @@ func AssertOutcomes(t *testing.T, got []finding.Finding, want []finding.Outcome) } } } + +// Tests for permissions-probes. +type TestData struct { + Name string + Err error + Raw *checker.RawResults + Outcomes []finding.Outcome +} + +func GetTests(locationType checker.PermissionLocation, + permissionType checker.PermissionLevel, + tokenName string, +) []TestData { + name := tokenName // Should come from each probe test. + value := "value" + var wrongPermissionLocation checker.PermissionLocation + if locationType == checker.PermissionLocationTop { + wrongPermissionLocation = checker.PermissionLocationJob + } else { + wrongPermissionLocation = checker.PermissionLocationTop + } + + return []TestData{ + { + Name: "No Tokens", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 0, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNotApplicable, + }, + }, + { + Name: "Correct name", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + LocationType: &locationType, + Name: &name, + Value: &value, + Msg: nil, + Type: permissionType, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNegative, + }, + }, + { + Name: "Two tokens", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 2, + TokenPermissions: []checker.TokenPermission{ + { + LocationType: &locationType, + Name: &name, + Value: &value, + Msg: nil, + Type: permissionType, + }, + { + LocationType: &locationType, + Name: &name, + Value: &value, + Msg: nil, + Type: permissionType, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNegative, finding.OutcomeNegative, + }, + }, + { + Name: "Value is nil - Everything else correct", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + LocationType: &locationType, + Name: &name, + Value: nil, + Msg: nil, + Type: permissionType, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomeNegative, + }, + Err: sce.ErrScorecardInternal, + }, + { + Name: "Wrong locationType wrong type", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + LocationType: &wrongPermissionLocation, + Name: &name, + Value: nil, + Msg: nil, + Type: checker.PermissionLevel("999"), + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomePositive, + }, + }, + { + Name: "Wrong locationType correct type", + Raw: &checker.RawResults{ + TokenPermissionsResults: checker.TokenPermissionsData{ + NumTokens: 1, + TokenPermissions: []checker.TokenPermission{ + { + LocationType: &wrongPermissionLocation, + Name: &name, + Value: nil, + Msg: nil, + Type: permissionType, + }, + }, + }, + }, + Outcomes: []finding.Outcome{ + finding.OutcomePositive, + }, + }, + } +} diff --git a/probes/jobLevelPermissions/def.yml b/probes/jobLevelPermissions/def.yml new file mode 100644 index 00000000000..706c183ccf0 --- /dev/null +++ b/probes/jobLevelPermissions/def.yml @@ -0,0 +1,35 @@ +# Copyright 2024 OpenSSF 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. + +id: jobLevelPermissions +short: Checks that GitHub workflows do not have "write" permissions at the "job" level. +motivation: > + In some circumstances, having "write" permissions at the "job" level may enable attackers to escalate privileges. +implementation: > + The probe checks the permission level, the workflow type and the permission type of each workflow in the project. +outcome: + - The probe returns 1 negative outcome per workflow with "write" permissions at the "job" level. + - The probe returns 1 positive outcome if the project has no workflows "write" permissions a the "job" level. +remediation: + effort: Low + text: + - Visit https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions + - Tick the 'Restrict permissions for GITHUB_TOKEN' + - Untick other options + - "NOTE: If you want to resolve multiple issues at once, you can visit https://app.stepsecurity.io/securerepo instead." + markdown: + - Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions). + - Tick the 'Restrict permissions for GITHUB_TOKEN' + - Untick other options + - "NOTE: If you want to resolve multiple issues at once, you can visit [https://app.stepsecurity.io/securerepo](https://app.stepsecurity.io/securerepo) instead." diff --git a/probes/jobLevelPermissions/impl.go b/probes/jobLevelPermissions/impl.go new file mode 100644 index 00000000000..e4a5b030ae6 --- /dev/null +++ b/probes/jobLevelPermissions/impl.go @@ -0,0 +1,109 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package jobLevelPermissions + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/finding" + "github.com/ossf/scorecard/v4/probes/internal/utils/permissions" + "github.com/ossf/scorecard/v4/probes/internal/utils/uerror" +) + +//go:embed *.yml +var fs embed.FS + +const Probe = "jobLevelPermissions" + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + results := raw.TokenPermissionsResults + var findings []finding.Finding + + if results.NumTokens == 0 { + f, err := finding.NewWith(fs, Probe, + "No token permissions found", + nil, finding.OutcomeNotApplicable) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + return findings, Probe, nil + } + + for _, r := range results.TokenPermissions { + if r.LocationType == nil { + continue + } + if *r.LocationType != checker.PermissionLocationJob { + continue + } + + switch r.Type { + case checker.PermissionLevelNone: + f, err := permissions.CreateNoneFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + case checker.PermissionLevelUndeclared: + f, err := permissions.CreateUndeclaredFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + case checker.PermissionLevelRead: + f, err := permissions.ReadPositiveLevelFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + default: + // to satisfy linter + } + + if r.Name == nil { + continue + } + + f, err := permissions.CreateNegativeFinding(r, Probe, fs, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + f = f.WithValue("permissionLevel", string(r.Type)) + f = f.WithValue("tokenName", *r.Name) + findings = append(findings, *f) + } + + if len(findings) == 0 { + f, err := finding.NewWith(fs, Probe, + "no job-level permissions found", + nil, finding.OutcomePositive) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + return findings, Probe, nil +} diff --git a/probes/jobLevelPermissions/impl_test.go b/probes/jobLevelPermissions/impl_test.go new file mode 100644 index 00000000000..6909f700c2e --- /dev/null +++ b/probes/jobLevelPermissions/impl_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package jobLevelPermissions + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/probes/internal/utils/test" +) + +func Test_Run(t *testing.T) { + t.Parallel() + + tests := test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "actions") + + tests = append(tests, test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "checks")...) + tests = append(tests, test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "contents")...) + tests = append(tests, test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "deployments")...) + tests = append(tests, test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "packages")...) + tests = append(tests, test.GetTests(checker.PermissionLocationJob, checker.PermissionLevelWrite, "security-events")...) + + 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() + + findings, s, err := Run(tt.Raw) + if !cmp.Equal(tt.Err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.Err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.Outcomes) + }) + } +} diff --git a/probes/topLevelPermissions/def.yml b/probes/topLevelPermissions/def.yml new file mode 100644 index 00000000000..44c122f8d73 --- /dev/null +++ b/probes/topLevelPermissions/def.yml @@ -0,0 +1,35 @@ +# Copyright 2024 OpenSSF 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. + +id: topLevelPermissions +short: Checks that the project does not have any top-level write permissions in its workflows. +motivation: > + In some circumstances, having "write" permissions at the "top" level may enable attackers to escalate privileges. +implementation: > + The probe checks the permission level, the workflow type and the permission type of each workflow in the project. +outcome: + - The probe returns 1 negative outcome per workflow with "write" permissions at the "top" level. + - The probe returns 1 positive outcome if the project has no workflows "write" permissions a the "top" level. +remediation: + effort: Low + text: + - Visit https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions + - Tick the 'Restrict permissions for GITHUB_TOKEN' + - Untick other options + - "NOTE: If you want to resolve multiple issues at once, you can visit https://app.stepsecurity.io/securerepo instead." + markdown: + - Visit [https://app.stepsecurity.io/secureworkflow](https://app.stepsecurity.io/secureworkflow/${{ metadata.repository.uri }}/${{ metadata.workflow }}/${{ metadata.repository.defaultBranch }}?enable=permissions). + - Tick the 'Restrict permissions for GITHUB_TOKEN' + - Untick other options + - "NOTE: If you want to resolve multiple issues at once, you can visit [https://app.stepsecurity.io/securerepo](https://app.stepsecurity.io/securerepo) instead." diff --git a/probes/topLevelPermissions/impl.go b/probes/topLevelPermissions/impl.go new file mode 100644 index 00000000000..41425f28b1c --- /dev/null +++ b/probes/topLevelPermissions/impl.go @@ -0,0 +1,118 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package topLevelPermissions + +import ( + "embed" + "fmt" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/finding" + "github.com/ossf/scorecard/v4/probes/internal/utils/permissions" + "github.com/ossf/scorecard/v4/probes/internal/utils/uerror" +) + +//go:embed *.yml +var fs embed.FS + +const Probe = "topLevelPermissions" + +func Run(raw *checker.RawResults) ([]finding.Finding, string, error) { + if raw == nil { + return nil, "", fmt.Errorf("%w: raw", uerror.ErrNil) + } + + results := raw.TokenPermissionsResults + var findings []finding.Finding + + if results.NumTokens == 0 { + f, err := finding.NewWith(fs, Probe, + "No token permissions found", + nil, finding.OutcomeNotApplicable) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + return findings, Probe, nil + } + + for _, r := range results.TokenPermissions { + if r.LocationType == nil { + continue + } + if *r.LocationType != checker.PermissionLocationTop { + continue + } + + switch r.Type { + case checker.PermissionLevelNone: + f, err := permissions.CreateNoneFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + case checker.PermissionLevelUndeclared: + f, err := permissions.CreateUndeclaredFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + case checker.PermissionLevelRead: + f, err := permissions.ReadPositiveLevelFinding(Probe, fs, r, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + continue + default: + // to satisfy linter + } + + tokenName := "" + switch { + case r.Name == nil && r.Value == nil: + continue + case r.Value != nil && *r.Value == "write-all": + tokenName = *r.Value + case r.Name != nil: + tokenName = *r.Name + default: + continue + } + + // Create finding + f, err := permissions.CreateNegativeFinding(r, Probe, fs, raw.Metadata.Metadata) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + f = f.WithValue("permissionLevel", string(r.Type)) + f = f.WithValue("tokenName", tokenName) + findings = append(findings, *f) + } + + if len(findings) == 0 { + f, err := finding.NewWith(fs, Probe, + "no job-level permissions found", + nil, finding.OutcomePositive) + if err != nil { + return nil, Probe, fmt.Errorf("create finding: %w", err) + } + findings = append(findings, *f) + } + return findings, Probe, nil +} diff --git a/probes/topLevelPermissions/impl_test.go b/probes/topLevelPermissions/impl_test.go new file mode 100644 index 00000000000..e58ff21ed5c --- /dev/null +++ b/probes/topLevelPermissions/impl_test.go @@ -0,0 +1,57 @@ +// Copyright 2024 OpenSSF 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. + +//nolint:stylecheck +package topLevelPermissions + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/ossf/scorecard/v4/checker" + "github.com/ossf/scorecard/v4/probes/internal/utils/test" +) + +func Test_Run(t *testing.T) { + t.Parallel() + + tests := test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "actions") + + tests = append(tests, test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "checks")...) + tests = append(tests, test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "contents")...) + tests = append(tests, test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "deployments")...) + tests = append(tests, test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "packages")...) + tests = append(tests, test.GetTests(checker.PermissionLocationTop, checker.PermissionLevelWrite, "security-events")...) + + 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() + + findings, s, err := Run(tt.Raw) + if !cmp.Equal(tt.Err, err, cmpopts.EquateErrors()) { + t.Errorf("mismatch (-want +got):\n%s", cmp.Diff(tt.Err, err, cmpopts.EquateErrors())) + } + if err != nil { + return + } + if diff := cmp.Diff(Probe, s); diff != "" { + t.Errorf("mismatch (-want +got):\n%s", diff) + } + test.AssertOutcomes(t, findings, tt.Outcomes) + }) + } +}