-
Notifications
You must be signed in to change notification settings - Fork 508
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Add dangerous workflow check with untrusted code checkout pattern (#…
…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
1 parent
4dde356
commit 1050b1c
Showing
11 changed files
with
654 additions
and
3 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
} | ||
} |
Oops, something went wrong.