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

✨ Add dangerous workflow check with untrusted code checkout pattern #1168

Merged
merged 21 commits into from
Nov 15, 2021
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
262 changes: 262 additions & 0 deletions checks/dangerous_workflow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
// 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"
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
}

// TokenPermissions runs Token-Permissions 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,
asraa marked this conversation as resolved.
Show resolved Hide resolved
data FileCbData) (bool, error) {
// 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))
}
azeemshaikh38 marked this conversation as resolved.
Show resolved Hide resolved

// 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 {
isPullRequestTrigger, err := checkPullRequestTrigger(config)
if err != nil {
return err
}

if isPullRequestTrigger {
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) {
asraa marked this conversation as resolved.
Show resolved Hide resolved
// 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, uses default main branch.
asraa marked this conversation as resolved.
Show resolved Hide resolved
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 }}") {
asraa marked this conversation as resolved.
Show resolved Hide resolved
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 -= 9
asraa marked this conversation as resolved.
Show resolved Hide resolved
}

// 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,
"workflow patterns pass dangerous workflow check")
asraa marked this conversation as resolved.
Show resolved Hide resolved
}

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)
}
98 changes: 98 additions & 0 deletions checks/dangerous_workflow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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: "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)
})
}
}
Loading