Skip to content

Commit

Permalink
✨ Add dangerous workflow check with untrusted code checkout pattern (#…
Browse files Browse the repository at this point in the history
…1168)

* add dangerous workflow check with untrusted code checkout pattern

Signed-off-by: Asra Ali <[email protected]>

* update

Signed-off-by: Asra Ali <[email protected]>

* add env var

Signed-off-by: Asra Ali <[email protected]>

* fix comment

Signed-off-by: Asra Ali <[email protected]>

* add repos git checks.yaml

Signed-off-by: Asra Ali <[email protected]>

* update checks.md

Signed-off-by: Asra Ali <[email protected]>

* address comments

Signed-off-by: Asra Ali <[email protected]>

* fix merge

Signed-off-by: Asra Ali <[email protected]>

* add delete

Signed-off-by: Asra Ali <[email protected]>

* update docs

Signed-off-by: Asra Ali <[email protected]>

Co-authored-by: Naveen <[email protected]>
  • Loading branch information
asraa and naveensrinivasan authored Nov 15, 2021
1 parent 4dde356 commit 1050b1c
Show file tree
Hide file tree
Showing 11 changed files with 654 additions and 3 deletions.
268 changes: 268 additions & 0 deletions checks/dangerous_workflow.go
Original file line number Diff line number Diff line change
@@ -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)
}
109 changes: 109 additions & 0 deletions checks/dangerous_workflow_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading

0 comments on commit 1050b1c

Please sign in to comment.