🌱 migrate token permission check to probes (#3816)

* 🌱 migrate token permission check to probes

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* combine seperate write-probes into two that combine them all

Signed-off-by: AdamKorcz <adam@adalogics.com>

* change write probes to read and write

Signed-off-by: AdamKorcz <adam@adalogics.com>

* minor nit

Signed-off-by: AdamKorcz <adam@adalogics.com>

* remove WritaAll probes

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* Merge read-perm probe with job/top probes

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* minor refactoring

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* fix copy paste error

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* fix linter issues and restructure code

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* remove hasGitHubWorkflowPermissionNone probe

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* Remove 'hasGitHubWorkflowPermissionUndeclared' probe

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* bit of clean up

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* reduce code complexity and remove comment

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* simplify file location

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* change probe text

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* invert name of probe

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* OutcomeNotApplicable -> OutcomeError

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* OutcomeNotAvailable -> OutcomeNotApplicable

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* more OutcomeNotAvailable -> OutcomeNotApplicable

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* change name of 'notAvailableOrNotApplicable'

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* fix linter issues

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* add comments to remediation fields

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* add check for nil-dereference

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* remove the permissionLocation finding value

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* rename checkAndLogNotAvailableOrNotApplicable to isBothUndeclaredAndNotAvailableOrNotApplicable

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* use raw metadata for remediation output

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* change 'branch' to 'defaultBranch'

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* remove unused fields in rule Remediation

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* fix remediation

Signed-off-by: Adam Korczynski <adam@adalogics.com>

* change 'metadata.defaultBranch' to 'metadata.repository.defaultBranch'

Signed-off-by: Adam Korczynski <adam@adalogics.com>

---------

Signed-off-by: Adam Korczynski <adam@adalogics.com>
Signed-off-by: AdamKorcz <adam@adalogics.com>
This commit is contained in:
AdamKorcz 2024-03-22 17:38:02 +00:00 committed by GitHub
parent c1066d9ac2
commit 5b0ae81d49
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1235 additions and 612 deletions

View File

@ -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
}

View File

@ -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.

View File

@ -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
}

View File

@ -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)
}

View File

@ -109,7 +109,7 @@ func TestGithubTokenPermissions(t *testing.T) {
Error: nil,
Score: checker.MinResultScore,
NumberOfWarn: 1,
NumberOfInfo: 1,
NumberOfInfo: 0,
NumberOfDebug: 5,
},
},

View File

@ -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
}

View File

@ -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.

View File

@ -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."

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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
}

View File

@ -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,
},
},
}
}

View File

@ -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."

View File

@ -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
}

View File

@ -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)
})
}
}

View File

@ -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."

View File

@ -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
}

View File

@ -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)
})
}
}