Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Token-Permission: Allow top level permissions not defined if all run level permissions are #1356

Merged
merged 9 commits into from
Dec 8, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions checks/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ import (

//nolint
var (
errInternalInvalidDockerFile = errors.New("invalid Dockerfile")
errInternalInvalidYamlFile = errors.New("invalid yaml file")
errInternalFilenameMatch = errors.New("filename match error")
errInternalEmptyFile = errors.New("empty file")
errInternalCommitishNil = errors.New("commitish is nil")
errInternalBranchNotFound = errors.New("branch not found")
errInvalidGitHubWorkflow = errors.New("invalid GitHub workflow")
errInternalNoReviews = errors.New("no reviews found")
errInternalNoCommits = errors.New("no commits found")
errInternalInvalidDockerFile = errors.New("invalid Dockerfile")
errInternalInvalidYamlFile = errors.New("invalid yaml file")
errInternalFilenameMatch = errors.New("filename match error")
errInternalEmptyFile = errors.New("empty file")
errInternalCommitishNil = errors.New("commitish is nil")
errInternalBranchNotFound = errors.New("branch not found")
errInvalidGitHubWorkflow = errors.New("invalid GitHub workflow")
errInternalNoReviews = errors.New("no reviews found")
errInternalNoCommits = errors.New("no commits found")
errInternalInvalidPermissions = errors.New("invalid permissions")
)
71 changes: 47 additions & 24 deletions checks/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import (
)

// CheckTokenPermissions is the exported name for Token-Permissions check.
const CheckTokenPermissions = "Token-Permissions"
const (
CheckTokenPermissions = "Token-Permissions"
runLevelPermission = "run level"
topLevelPermission = "top level"
)

//nolint:gochecknoinits
func init() {
Expand All @@ -53,8 +57,8 @@ func TokenPermissions(c *checker.CheckRequest) checker.CheckResult {
return createResultForLeastPrivilegeTokens(data, err)
}

func validatePermission(permissionKey string, permissionValue *actionlint.PermissionScope, path string,
dl checker.DetailLogger, pPermissions map[string]bool,
func validatePermission(permissionKey string, permissionValue *actionlint.PermissionScope,
permLevel, path string, dl checker.DetailLogger, pPermissions map[string]bool,
ignoredPermissions map[string]bool) error {
if permissionValue.Value == nil {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
Expand All @@ -70,7 +74,7 @@ func validatePermission(permissionKey string, permissionValue *actionlint.Permis
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: fmt.Sprintf("'%v' permission set to '%v'", permissionKey, val),
Text: fmt.Sprintf("%s '%v' permission set to '%v'", permLevel, permissionKey, val),
// TODO: set Snippet.
})
recordPermissionWrite(permissionKey, pPermissions)
Expand All @@ -81,7 +85,7 @@ func validatePermission(permissionKey string, permissionValue *actionlint.Permis
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: fmt.Sprintf("'%v' permission set to '%v'", permissionKey, val),
Text: fmt.Sprintf("%s '%v' permission set to '%v'", permLevel, permissionKey, val),
// TODO: set Snippet.
})
}
Expand All @@ -92,17 +96,17 @@ func validatePermission(permissionKey string, permissionValue *actionlint.Permis
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: fmt.Sprintf("'%v' permission set to '%v'", permissionKey, val),
Text: fmt.Sprintf("%s '%v' permission set to '%v'", permLevel, permissionKey, val),
// TODO: set Snippet.
})
return nil
}

func validateMapPermissions(scopes map[string]*actionlint.PermissionScope, path string,
func validateMapPermissions(scopes map[string]*actionlint.PermissionScope, permLevel, path string,
dl checker.DetailLogger, pPermissions map[string]bool,
ignoredPermissions map[string]bool) error {
for key, v := range scopes {
if err := validatePermission(key, v, path, dl, pPermissions, ignoredPermissions); err != nil {
if err := validatePermission(key, v, permLevel, path, dl, pPermissions, ignoredPermissions); err != nil {
return err
}
}
Expand All @@ -119,7 +123,7 @@ func recordAllPermissionsWrite(pPermissions map[string]bool) {
pPermissions["all"] = true
}

func validatePermissions(permissions *actionlint.Permissions, path string,
func validatePermissions(permissions *actionlint.Permissions, permLevel, path string,
dl checker.DetailLogger, pPermissions map[string]bool,
ignoredPermissions map[string]bool) error {
allIsSet := permissions != nil && permissions.All != nil && permissions.All.Value != ""
Expand All @@ -129,7 +133,7 @@ func validatePermissions(permissions *actionlint.Permissions, path string,
Path: path,
Type: checker.FileTypeSource,
Offset: checker.OffsetDefault,
Text: "permissions set to 'none'",
Text: fmt.Sprintf("%s permissions set to 'none'", permLevel),
})
}
if allIsSet {
Expand All @@ -138,12 +142,13 @@ func validatePermissions(permissions *actionlint.Permissions, path string,
if permissions.All.Pos != nil {
lineNumber = permissions.All.Pos.Line
}

if !strings.EqualFold(val, "read-all") && val != "" {
dl.Warn3(&checker.LogMessage{
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: fmt.Sprintf("permissions set to '%v'", val),
Text: fmt.Sprintf("%s permissions set to '%v'", permLevel, val),
// TODO: set Snippet.
})
recordAllPermissionsWrite(pPermissions)
Expand All @@ -154,10 +159,10 @@ func validatePermissions(permissions *actionlint.Permissions, path string,
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: fmt.Sprintf("permissions set to '%v'", val),
Text: fmt.Sprintf("%s permissions set to '%v'", permLevel, val),
// TODO: set Snippet.
})
} else /* scopeIsSet == true */ if err := validateMapPermissions(permissions.Scopes, path, dl, pPermissions,
} else /* scopeIsSet == true */ if err := validateMapPermissions(permissions.Scopes, permLevel, path, dl, pPermissions,
ignoredPermissions); err != nil {
return err
}
Expand All @@ -172,13 +177,13 @@ func validateTopLevelPermissions(workflow *actionlint.Workflow, path string,
Path: path,
Type: checker.FileTypeSource,
Offset: checker.OffsetDefault,
Text: "no permission defined",
Text: fmt.Sprintf("no %s permission defined", topLevelPermission),
})
recordAllPermissionsWrite(pdata.topLevelWritePermissions)
return nil
}

return validatePermissions(workflow.Permissions, path, dl,
return validatePermissions(workflow.Permissions, topLevelPermission, path, dl,
pdata.topLevelWritePermissions, map[string]bool{})
}

Expand All @@ -198,11 +203,13 @@ func validateRunLevelPermissions(workflow *actionlint.Workflow, path string,
Path: path,
Type: checker.FileTypeSource,
Offset: lineNumber,
Text: "no permission defined",
Text: fmt.Sprintf("no %s permission defined", runLevelPermission),
})
recordAllPermissionsWrite(pdata.runLevelWritePermissions)
continue
}
err := validatePermissions(job.Permissions, path, dl, pdata.runLevelWritePermissions, ignoredPermissions)
err := validatePermissions(job.Permissions, runLevelPermission,
path, dl, pdata.runLevelWritePermissions, ignoredPermissions)
if err != nil {
return err
}
Expand All @@ -225,9 +232,18 @@ func isPermissionOfInterest(name string, ignoredPermissions map[string]bool) boo
}

func permissionIsPresent(result permissionCbData, name string) bool {
_, ok1 := result.topLevelWritePermissions[name]
_, ok2 := result.runLevelWritePermissions[name]
return ok1 || ok2
return permissionIsPresentInTopLevel(result, name) ||
permissionIsPresentInRunLevel(result, name)
}

func permissionIsPresentInTopLevel(result permissionCbData, name string) bool {
_, ok := result.topLevelWritePermissions[name]
return ok
}

func permissionIsPresentInRunLevel(result permissionCbData, name string) bool {
_, ok := result.runLevelWritePermissions[name]
return ok
}

// Calculate the score.
Expand All @@ -236,13 +252,20 @@ func calculateScore(result permissionCbData) int {
// 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/.

if permissionIsPresent(result, "all") {
return checker.MinResultScore
}

// Start with a perfect score.
score := float32(checker.MaxResultScore)

// If no top level permissions are defined, all the permissions
// are enabled by default, hence "all". In this case,
if permissionIsPresentInTopLevel(result, "all") {
laurentsimon marked this conversation as resolved.
Show resolved Hide resolved
if permissionIsPresentInRunLevel(result, "all") {
laurentsimon marked this conversation as resolved.
Show resolved Hide resolved
// ... give lowest score if no run level permissions are defined either.
return checker.MinResultScore
}
// ... reduce score if run level permissions are defined.
score--
}

// 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.
Expand Down
11 changes: 11 additions & 0 deletions checks/permissions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,17 @@ func TestGithubTokenPermissions(t *testing.T) {
NumberOfDebug: 3,
},
},
{
name: "workflow jobs only",
filename: "./testdata/github-workflow-permissions-jobs-only.yaml",
expected: scut.TestReturn{
Error: nil,
Score: 9,
NumberOfWarn: 1,
NumberOfInfo: 3,
NumberOfDebug: 4,
},
},
}
for _, tt := range tests {
tt := tt // Re-initializing variable so it is not changed while executing the closure below
Expand Down
34 changes: 34 additions & 0 deletions checks/testdata/github-workflow-permissions-jobs-only.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Copyright 2021 Security Scorecard Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
name: write-and-read workflow
on: [push]

jobs:
jobOne:
runs-on: ubuntu-latest
permissions:
steps:
- run: echo "write-and-read workflow"
jobTwo:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- run: echo "write-and-read workflow"
jobThree:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- run: echo "write-and-read workflow"
5 changes: 4 additions & 1 deletion docs/checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -528,7 +528,7 @@ information about a bug is not publicly visible.
**Remediation steps**
- Place a security policy file `SECURITY.md` in the root directory of your repository. This makes it easily discoverable by a vulnerability reporter.
- The file should contain information on what constitutes a vulnerability and a way to report it securely (e.g. issue tracker with private issue support, encrypted email with a published public key).
- For GitHub, see more information [here](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository).
- For GitHub, see more information [here](https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository.).

## Signed-Releases

Expand Down Expand Up @@ -572,6 +572,9 @@ yaml file are set as read-only at the
[top level](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions)
and the required write permissions are declared at the
[run-level](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idpermissions).
One point is reduced from the score if all jobs have their permissions defined but the top level permissions are not defined.
This configuration is secure, but there is a chance that when a new job is added to the workflow, its job permissions could be
left undefined because of human error.

The check cannot detect if the "read-only" GitHub permission setting is
enabled, as there is no API available.
Expand Down
3 changes: 3 additions & 0 deletions docs/checks/internal/checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,9 @@ checks:
[top level](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#permissions)
and the required write permissions are declared at the
[run-level](https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idpermissions).
One point is reduced from the score if all jobs have their permissions defined but the top level permissions are not defined.
This configuration is secure, but there is a chance that when a new job is added to the workflow, its job permissions could be
left undefined because of human error.

The check cannot detect if the "read-only" GitHub permission setting is
enabled, as there is no API available.
Expand Down