Add dangerous workflow check with untrusted code checkout pattern (#1168)

* add dangerous workflow check with untrusted code checkout pattern

Signed-off-by: Asra Ali <asraa@google.com>

* update

Signed-off-by: Asra Ali <asraa@google.com>

* add env var

Signed-off-by: Asra Ali <asraa@google.com>

* fix comment

Signed-off-by: Asra Ali <asraa@google.com>

* add repos git checks.yaml

Signed-off-by: Asra Ali <asraa@google.com>

* update checks.md

Signed-off-by: Asra Ali <asraa@google.com>

* address comments

Signed-off-by: Asra Ali <asraa@google.com>

* fix merge

Signed-off-by: Asra Ali <asraa@google.com>

* add delete

Signed-off-by: Asra Ali <asraa@google.com>

* update docs

Signed-off-by: Asra Ali <asraa@google.com>

Co-authored-by: Naveen <172697+naveensrinivasan@users.noreply.github.com>
This commit is contained in:
asraa 2021-11-15 14:18:10 -06:00 committed by GitHub
parent 4dde356329
commit 1050b1cd60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 654 additions and 3 deletions

View File

@ -0,0 +1,268 @@
// Copyright 2021 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checks
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
"github.com/ossf/scorecard/v3/checker"
"github.com/ossf/scorecard/v3/checks/fileparser"
sce "github.com/ossf/scorecard/v3/errors"
)
// CheckDangerousWorkflow is the exported name for Dangerous-Workflow check.
const CheckDangerousWorkflow = "Dangerous-Workflow"
//nolint:gochecknoinits
func init() {
registerCheck(CheckDangerousWorkflow, DangerousWorkflow)
}
// Holds stateful data to pass thru callbacks.
// Each field correpsonds to a dangerous GitHub workflow pattern, and
// will hold true if the pattern is avoided, false otherwise.
type patternCbData struct {
workflowPattern map[string]bool
}
// DangerousWorkflow runs Dangerous-Workflow check.
func DangerousWorkflow(c *checker.CheckRequest) checker.CheckResult {
// data is shared across all GitHub workflows.
data := patternCbData{
workflowPattern: make(map[string]bool),
}
err := CheckFilesContent(".github/workflows/*", false,
c, validateGitHubActionWorkflowPatterns, &data)
return createResultForDangerousWorkflowPatterns(data, err)
}
// Check file content.
func validateGitHubActionWorkflowPatterns(path string, content []byte, dl checker.DetailLogger,
data FileCbData) (bool, error) {
if !fileparser.IsWorkflowFile(path) {
return true, nil
}
// Verify the type of the data.
pdata, ok := data.(*patternCbData)
if !ok {
// This never happens.
panic("invalid type")
}
if !CheckFileContainsCommands(content, "#") {
return true, nil
}
var workflow map[interface{}]interface{}
err := yaml.Unmarshal(content, &workflow)
if err != nil {
return false,
sce.WithMessage(sce.ErrScorecardInternal, fmt.Sprintf("yaml.Unmarshal: %v", err))
}
// 1. Check for untrusted code checkout with pull_request_target and a ref
if err := validateUntrustedCodeCheckout(workflow, path, dl, pdata); err != nil {
return false, err
}
// TODO: Check other dangerous patterns.
return true, nil
}
func validateUntrustedCodeCheckout(config map[interface{}]interface{}, path string,
dl checker.DetailLogger, pdata *patternCbData) error {
checkPullRequestTrigger, err := checkPullRequestTrigger(config)
if err != nil {
return err
}
if checkPullRequestTrigger {
return validateUntrustedCodeCheckoutRef(config, path, dl, pdata)
}
return nil
}
func validateUntrustedCodeCheckoutRef(config map[interface{}]interface{}, path string,
dl checker.DetailLogger, pdata *patternCbData) error {
var jobs interface{}
// Now check if this is used with untrusted code checkout ref in jobs
jobs, ok := config["jobs"]
if !ok {
return nil
}
mjobs, ok := jobs.(map[string]interface{})
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
for _, value := range mjobs {
job, ok := value.(map[string]interface{})
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
if err := checkJobForUntrustedCodeCheckout(job, path, dl, pdata); err != nil {
return err
}
}
return nil
}
func checkPullRequestTrigger(config map[interface{}]interface{}) (bool, error) {
// Check event trigger (required) is pull_request_target
trigger, ok := config["on"]
if !ok {
return false, sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
isPullRequestTrigger := false
switch val := trigger.(type) {
case string:
if strings.EqualFold(val, "pull_request_target") {
isPullRequestTrigger = true
}
case []string:
for _, onVal := range val {
if strings.EqualFold(onVal, "pull_request_target") {
isPullRequestTrigger = true
}
}
case map[interface{}]interface{}:
for k := range val {
key, ok := k.(string)
if !ok {
return false, sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
if strings.EqualFold(key, "pull_request_target") {
isPullRequestTrigger = true
}
}
default:
return false, sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
return isPullRequestTrigger, nil
}
func checkJobForUntrustedCodeCheckout(job map[string]interface{}, path string,
dl checker.DetailLogger, pdata *patternCbData) error {
steps, ok := job["steps"]
if !ok {
return nil
}
msteps, ok := steps.([]interface{})
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
// Check each step, which is a map, for checkouts with untrusted ref
for _, step := range msteps {
mstep, ok := step.(map[string]interface{})
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
// Check for a step that uses actions/checkout
uses, ok := mstep["uses"]
if !ok {
continue
}
muses, ok := uses.(string)
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
// Uses defaults if not defined.
with, ok := mstep["with"]
if !ok {
continue
}
mwith, ok := with.(map[string]interface{})
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
// Check for reference. If not defined for a pull_request_target event, this defaults to
// the base branch of the pull request.
ref, ok := mwith["ref"]
if !ok {
continue
}
mref, ok := ref.(string)
if !ok {
return sce.WithMessage(sce.ErrScorecardInternal, errInvalidGitHubWorkflow.Error())
}
if strings.Contains(muses, "actions/checkout") &&
strings.Contains(mref, "github.event.pull_request.head.sha") {
dl.Warn3(&checker.LogMessage{
Path: path,
Type: checker.FileTypeSource,
// TODO: set line correctly.
Offset: 1,
Text: fmt.Sprintf("untrusted code checkout '%v'", mref),
// TODO: set Snippet.
})
// Detected untrusted checkout.
pdata.workflowPattern["untrusted_checkout"] = true
}
}
return nil
}
// Calculate the workflow score.
func calculateWorkflowScore(result patternCbData) int {
// Start with a perfect score.
score := float32(checker.MaxResultScore)
// pull_request_event indicates untrusted code checkout
if ok := result.workflowPattern["untrusted_checkout"]; ok {
score -= 10
}
// We're done, calculate the final score.
if score < checker.MinResultScore {
return checker.MinResultScore
}
return int(score)
}
// Create the result.
func createResultForDangerousWorkflowPatterns(result patternCbData, err error) checker.CheckResult {
if err != nil {
return checker.CreateRuntimeErrorResult(CheckDangerousWorkflow, err)
}
score := calculateWorkflowScore(result)
if score != checker.MaxResultScore {
return checker.CreateResultWithScore(CheckDangerousWorkflow,
"dangerous workflow patterns detected", score)
}
return checker.CreateMaxScoreResult(CheckDangerousWorkflow,
"no dangerous workflow patterns detected")
}
func testValidateGitHubActionDangerousWOrkflow(pathfn string,
content []byte, dl checker.DetailLogger) checker.CheckResult {
data := patternCbData{
workflowPattern: make(map[string]bool),
}
_, err := validateGitHubActionWorkflowPatterns(pathfn, content, dl, &data)
return createResultForDangerousWorkflowPatterns(data, err)
}

View File

@ -0,0 +1,109 @@
// Copyright 2021 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package checks
import (
"fmt"
"io/ioutil"
"testing"
"github.com/ossf/scorecard/v3/checker"
scut "github.com/ossf/scorecard/v3/utests"
)
func TestGithubDangerousWorkflow(t *testing.T) {
t.Parallel()
tests := []struct {
name string
filename string
expected scut.TestReturn
}{
{
name: "Non-yaml file",
filename: "./testdata/script.sh",
expected: scut.TestReturn{
Error: nil,
Score: checker.MaxResultScore,
NumberOfWarn: 0,
NumberOfInfo: 0,
NumberOfDebug: 0,
},
},
{
name: "run untrusted code checkout test",
filename: "./testdata/github-workflow-dangerous-pattern-untrusted-checkout.yml",
expected: scut.TestReturn{
Error: nil,
Score: checker.MinResultScore,
NumberOfWarn: 0,
NumberOfInfo: 1,
NumberOfDebug: 0,
},
},
{
name: "run trusted code checkout test",
filename: "./testdata/github-workflow-dangerous-pattern-trusted-checkout.yml",
expected: scut.TestReturn{
Error: nil,
Score: checker.MaxResultScore,
NumberOfWarn: 0,
NumberOfInfo: 0,
NumberOfDebug: 0,
},
},
{
name: "run default code checkout test",
filename: "./testdata/github-workflow-dangerous-pattern-default-checkout.yml",
expected: scut.TestReturn{
Error: nil,
Score: checker.MaxResultScore,
NumberOfWarn: 0,
NumberOfInfo: 0,
NumberOfDebug: 0,
},
},
{
name: "run safe trigger with code checkout test",
filename: "./testdata/github-workflow-dangerous-pattern-safe-trigger.yml",
expected: scut.TestReturn{
Error: nil,
Score: checker.MaxResultScore,
NumberOfWarn: 0,
NumberOfInfo: 0,
NumberOfDebug: 0,
},
},
}
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()
var content []byte
var err error
if tt.filename == "" {
content = make([]byte, 0)
} else {
content, err = ioutil.ReadFile(tt.filename)
if err != nil {
panic(fmt.Errorf("cannot read file: %w", err))
}
}
dl := scut.TestDetailLogger{}
r := testValidateGitHubActionDangerousWOrkflow(tt.filename, content, &dl)
scut.ValidateTestReturn(t, tt.name, &tt.expected, &r, &dl)
})
}
}

View File

@ -0,0 +1,38 @@
# 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.
on:
pull_request_target
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- uses: actions/setup-node@v1
- run: |
npm install
npm build
- uses: completely/fakeaction@v2
with:
arg1: ${{ secrets.supersecret }}
- uses: fakerepo/comment-on-pr@v1
with:
message: |
Thank you!

View File

@ -0,0 +1,38 @@
# 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.
on:
pull_request
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: my-branch
- uses: actions/setup-node@v1
- run: |
npm install
npm build
- uses: completely/fakeaction@v2
with:
arg1: ${{ secrets.supersecret }}
- uses: fakerepo/comment-on-pr@v1
with:
message: |
Thank you!

View File

@ -0,0 +1,38 @@
# 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.
on:
pull_request_target
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
- run: |
npm install
npm build
- uses: completely/fakeaction@v2
with:
arg1: ${{ secrets.supersecret }}
- uses: fakerepo/comment-on-pr@v1
with:
message: |
Thank you!

View File

@ -0,0 +1,38 @@
# 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.
on:
pull_request_target
jobs:
build:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
ref: ${{ github.event.pull_request.head.sha }}
- uses: actions/setup-node@v1
- run: |
npm install
npm build
- uses: completely/fakeaction@v2
with:
arg1: ${{ secrets.supersecret }}
- uses: fakerepo/comment-on-pr@v1
with:
message: |
Thank you!

View File

@ -132,6 +132,16 @@ func isSupportedCheck(names []string, name string) bool {
return false
}
func getAllChecks() checker.CheckNameToFnMap {
// Returns the full list of checks, given any environment variable constraints.
possibleChecks := checks.AllChecks
// TODO: Remove this to enable the DANGEROUS_WORKFLOW by default in the next release.
if _, dangerousWorkflowCheck := os.LookupEnv("ENABLE_DANGEROUS_WORKFLOW"); !dangerousWorkflowCheck {
delete(possibleChecks, checks.CheckDangerousWorkflow)
}
return possibleChecks
}
func getEnabledChecks(sp *spol.ScorecardPolicy, argsChecks []string,
supportedChecks []string, repoType string) (checker.CheckNameToFnMap, error) {
enabledChecks := checker.CheckNameToFnMap{}
@ -167,7 +177,7 @@ func getEnabledChecks(sp *spol.ScorecardPolicy, argsChecks []string,
}
default:
// Enable all checks that are supported.
for checkName := range checks.AllChecks {
for checkName := range getAllChecks() {
if !isSupportedCheck(supportedChecks, checkName) {
continue
}
@ -476,7 +486,7 @@ func fetchGitRepositoryFromRubyGems(packageName string) (string, error) {
// Enables checks by name.
func enableCheck(checkName string, enabledChecks *checker.CheckNameToFnMap) bool {
if enabledChecks != nil {
for key, checkFn := range checks.AllChecks {
for key, checkFn := range getAllChecks() {
if strings.EqualFold(key, checkName) {
(*enabledChecks)[key] = checkFn
return true
@ -507,7 +517,7 @@ func init() {
&metaData, "metadata", []string{}, "metadata for the project. It can be multiple separated by commas")
rootCmd.Flags().BoolVar(&showDetails, "show-details", false, "show extra details about each check")
checkNames := []string{}
for checkName := range checks.AllChecks {
for checkName := range getAllChecks() {
checkNames = append(checkNames, checkName)
}
rootCmd.Flags().StringSliceVar(&checksToRun, "checks", []string{},

View File

@ -196,6 +196,8 @@ func main() {
delete(checksToRun, checks.CheckCITests)
// TODO: Re-add Contributors check after fixing: #859.
delete(checksToRun, checks.CheckContributors)
// TODO: Add this in v4
delete(checksToRun, checks.CheckDangerousWorkflow)
for {
req, err := subscriber.SynchronousPull()
if err != nil {

View File

@ -237,6 +237,29 @@ participants.
**Remediation steps**
- Ask contributors to [join their respective organizations](https://docs.github.com/en/organizations/managing-membership-in-your-organization/inviting-users-to-join-your-organization), if they have not already. Otherwise, there is no remediation for this check; it simply provides insight into which organizations have contributed so that you can make a trust-based decision based on that information.
## Dangerous-Workflow
Risk: `High` (vulnerable to repository compromise)
This check determines whether the project's GitHub Action workflows has dangerous
code patterns. Some examples of these patterns are untrusted code checkouts,
logging github context and secrets, or use of potentially untrusted inputs in scripts.
The first code pattern checked is the misuse of potentially dangerous triggers.
This checks if a `pull_request_target` workflow trigger was used in conjunction
with an explicit pull request checkout. Workflows triggered with `pull_request_target`
have write permission to the target repository and access to target repository
secrets. With the PR checkout, PR authors may compromise the repository, for
example, by using build scripts controlled by the author of the PR or reading
token in memory. This check does not detect whether untrusted code checkouts are
used safely, for example, only on pull request that have been assigned a label.
The highest score is awarded when all workflows avoid the dangerous code patterns.
**Remediation steps**
- Avoid the dangerous workflow patterns. See this [post](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/) for information on avoiding untrusted code checkouts.
## Dependency-Update-Tool
Risk: `High` (possibly vulnerable to attacks on known flaws)

View File

@ -629,3 +629,30 @@ checks:
- >-
Fix the vulnerabilities. The details of each vulnerability can be found
on <https://osv.dev>.
Dangerous-Workflow:
risk: High
tags: supply-chain, security, infrastructure
repos: GitHub, local
short: Determines if the project's GitHub Action workflows avoid dangerous patterns.
description: |
Risk: `High` (vulnerable to repository compromise)
This check determines whether the project's GitHub Action workflows has dangerous
code patterns. Some examples of these patterns are untrusted code checkouts,
logging github context and secrets, or use of potentially untrusted inputs in scripts.
The first code pattern checked is the misuse of potentially dangerous triggers.
This checks if a `pull_request_target` workflow trigger was used in conjunction
with an explicit pull request checkout. Workflows triggered with `pull_request_target`
have write permission to the target repository and access to target repository
secrets. With the PR checkout, PR authors may compromise the repository, for
example, by using build scripts controlled by the author of the PR or reading
token in memory. This check does not detect whether untrusted code checkouts are
used safely, for example, only on pull request that have been assigned a label.
The highest score is awarded when all workflows avoid the dangerous code patterns.
remediation:
- >-
Avoid the dangerous workflow patterns. See this [post](https://securitylab.github.com/research/github-actions-preventing-pwn-requests/)
for information on avoiding untrusted code checkouts.

View File

@ -0,0 +1,60 @@
// Copyright 2021 Security Scorecard Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package e2e
import (
"context"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
"github.com/ossf/scorecard/v3/checker"
"github.com/ossf/scorecard/v3/checks"
"github.com/ossf/scorecard/v3/clients/githubrepo"
scut "github.com/ossf/scorecard/v3/utests"
)
var _ = Describe("E2E TEST:"+checks.CheckTokenPermissions, func() {
Context("E2E TEST:Validating dangerous workflow check", func() {
It("Should return dangerous workflow works", func() {
dl := scut.TestDetailLogger{}
repo, err := githubrepo.MakeGithubRepo("ossf-tests/scorecard-check-dangerous-workflow-e2e")
Expect(err).Should(BeNil())
repoClient := githubrepo.CreateGithubRepoClient(context.Background(), logger)
err = repoClient.InitRepo(repo)
Expect(err).Should(BeNil())
req := checker.CheckRequest{
Ctx: context.Background(),
RepoClient: repoClient,
Repo: repo,
Dlogger: &dl,
}
expected := scut.TestReturn{
Error: nil,
Score: checker.MinResultScore,
NumberOfWarn: 1,
NumberOfInfo: 0,
NumberOfDebug: 0,
}
result := checks.DangerousWorkflow(&req)
// UPGRADEv2: to remove.
// Old version.
Expect(result.Error).Should(BeNil())
Expect(result.Pass).Should(BeFalse())
// New version.
Expect(scut.ValidateTestReturn(nil, "dangerous workflow", &expected, &result, &dl)).Should(BeTrue())
})
})
})