mirror of
https://github.com/ossf/scorecard.git
synced 2024-09-11 00:45:36 +03:00
🌱 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:
parent
c1066d9ac2
commit
5b0ae81d49
303
checks/evaluation/permissions.go
Normal file
303
checks/evaluation/permissions.go
Normal 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
|
||||
}
|
@ -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.
|
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ func TestGithubTokenPermissions(t *testing.T) {
|
||||
Error: nil,
|
||||
Score: checker.MinResultScore,
|
||||
NumberOfWarn: 1,
|
||||
NumberOfInfo: 1,
|
||||
NumberOfInfo: 0,
|
||||
NumberOfDebug: 5,
|
||||
},
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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."
|
75
probes/hasNoGitHubWorkflowPermissionUnknown/impl.go
Normal file
75
probes/hasNoGitHubWorkflowPermissionUnknown/impl.go
Normal 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
|
||||
}
|
98
probes/hasNoGitHubWorkflowPermissionUnknown/impl_test.go
Normal file
98
probes/hasNoGitHubWorkflowPermissionUnknown/impl_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
169
probes/internal/utils/permissions/permissions.go
Normal file
169
probes/internal/utils/permissions/permissions.go
Normal 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
|
||||
}
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
35
probes/jobLevelPermissions/def.yml
Normal file
35
probes/jobLevelPermissions/def.yml
Normal 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."
|
109
probes/jobLevelPermissions/impl.go
Normal file
109
probes/jobLevelPermissions/impl.go
Normal 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
|
||||
}
|
57
probes/jobLevelPermissions/impl_test.go
Normal file
57
probes/jobLevelPermissions/impl_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
35
probes/topLevelPermissions/def.yml
Normal file
35
probes/topLevelPermissions/def.yml
Normal 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."
|
118
probes/topLevelPermissions/impl.go
Normal file
118
probes/topLevelPermissions/impl.go
Normal 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
|
||||
}
|
57
probes/topLevelPermissions/impl_test.go
Normal file
57
probes/topLevelPermissions/impl_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user