From 4f6ca1b1009189c38146049cc1146b5cdd60e2bb Mon Sep 17 00:00:00 2001 From: becojo <172889+becojo@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:13:00 -0400 Subject: [PATCH] feat: add `unverified_script_exec` rule (#129) * add unverified_script_exec rule * add safe patterns --------- Co-authored-by: Becojo --- opa/opa_test.go | 22 ++++++ opa/rego/rules/unverified_script_exec.rego | 75 ++++++++++++++++++++ scanner/inventory_test.go | 23 ++++++ scanner/testdata/.github/workflows/valid.yml | 16 +++++ 4 files changed, 136 insertions(+) create mode 100644 opa/rego/rules/unverified_script_exec.rego diff --git a/opa/opa_test.go b/opa/opa_test.go index 8af0def..3367c82 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -5,6 +5,7 @@ import ( "github.com/boostsecurityio/poutine/models" "github.com/open-policy-agent/opa/ast" + "fmt" "github.com/stretchr/testify/assert" "testing" ) @@ -178,3 +179,24 @@ func TestCapabilities(t *testing.T) { } } } + +func TestRulesMetadataLevel(t *testing.T) { + opa, err := NewOpa() + noOpaErrors(t, err) + + query := `{rule_id: rule.level | + rule := data.rules[rule_id].rule; + not input[rule.level] + }` + + var result map[string]string + err = opa.Eval(context.TODO(), query, map[string]interface{}{ + "note": true, + "warning": true, + "error": true, + "none": true, + }, &result) + noOpaErrors(t, err) + + assert.Empty(t, result, fmt.Sprintf("rules with invalid levels: %v", result)) +} diff --git a/opa/rego/rules/unverified_script_exec.rego b/opa/rego/rules/unverified_script_exec.rego new file mode 100644 index 0000000..7509bcc --- /dev/null +++ b/opa/rego/rules/unverified_script_exec.rego @@ -0,0 +1,75 @@ +# METADATA +# title: Unverified Script Execution +# description: |- +# The pipeline executes a script or binary fetched from a remote +# server without verifying its integrity. +# custom: +# level: note +package rules.unverified_script_exec + +import data.poutine +import data.poutine.utils +import rego.v1 + +rule := poutine.rule(rego.metadata.chain()) + +patterns.shell contains sprintf("(%s)", [concat("|", [ + `(bash|source) <\(curl [^\)\n]+?\)`, + `(curl|wget|iwr)[^\n]{0,256}(\|(|.*?[^a-z])((ba)?sh|python|php|node|iex|perl)|chmod ([aug]?\+x|[75]))`, + `iex[^\n]{0,512}\.DownloadString\([^\)]+?\)`, + `deno (run|install) (-A|--allow-all)[^\n]{0,128}https://[^\s]{0,128}`, +])]) + +patterns.safe contains sprintf("(%s)", [concat("|", [ + `https://raw\.githubusercontent\.com/[^/]+/[^/]+/[a-f0-9]{40}/`, + `https://github\.com/[^/]+/[^/]+/raw/[a-f0-9]{40}/`, +])]) + +results contains poutine.finding(rule, pkg_purl, _scripts[pkg_purl][_]) + +_unverified_scripts(script) = [sprintf("Command: %s", [match]) | + match := regex.find_n(patterns.shell[_], script, -1)[_] + not _is_safe(match) +] + +_is_safe(match) = regex.match(patterns.safe[_], match) + +_scripts[pkg.purl] contains { + "path": workflow.path, + "step": step_id, + "job": job.id, + "line": step.lines.run, + "details": details, +} if { + pkg := input.packages[_] + workflow := pkg.github_actions_workflows[_] + job := workflow.jobs[_] + step := job.steps[step_id] + details := _unverified_scripts(step.run)[_] +} + +_scripts[pkg.purl] contains { + "path": action.path, + "step": step_id, + "line": step.lines.run, + "details": details, +} if { + pkg := input.packages[_] + action := pkg.github_actions_metadata[_] + step := action.runs.steps[step_id] + details := _unverified_scripts(step.run)[_] +} + +_scripts[pkg.purl] contains { + "path": config.path, + "line": script.line, + "job": job.name, + "details": details, +} if { + some attr in {"before_script", "after_script", "script"} + pkg := input.packages[_] + config := pkg.gitlabci_configs[_] + job := array.concat(config.jobs, [config["default"]])[_] + script := job[attr][_] + details := _unverified_scripts(script.run)[_] +} diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index 3808511..bb7d8f6 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -82,6 +82,7 @@ func TestFindings(t *testing.T) { "github_action_from_unverified_creator_used", "debug_enabled", "job_all_secrets", + "unverified_script_exec", }) findings := []opa.Finding{ @@ -308,6 +309,28 @@ func TestFindings(t *testing.T) { Step: "3", }, }, + { + RuleId: "unverified_script_exec", + Purl: purl, + Meta: opa.FindingMeta{ + Path: ".github/workflows/valid.yml", + Line: 70, + Job: "build", + Step: "12", + Details: "Command: curl https://example.com | bash", + }, + }, + { + RuleId: "unverified_script_exec", + Purl: purl, + Meta: opa.FindingMeta{ + Path: ".github/workflows/valid.yml", + Line: 75, + Job: "build", + Step: "13", + Details: "Command: curl https://raw.githubusercontent.com/org/repo/main/install.sh | bash", + }, + }, } assert.Equal(t, len(findings), len(results.Findings)) diff --git a/scanner/testdata/.github/workflows/valid.yml b/scanner/testdata/.github/workflows/valid.yml index a861dd3..09bb6ef 100644 --- a/scanner/testdata/.github/workflows/valid.yml +++ b/scanner/testdata/.github/workflows/valid.yml @@ -64,3 +64,19 @@ jobs: run: | # substring of go\ generate should not trigger cargo generate + + # unverified_script_exec + - id: 12 + run: | + curl https://example.com | bash + + # unverified_script_exec + - id: 13 + run: | + curl https://raw.githubusercontent.com/org/repo/main/install.sh | bash + + # safe unverified_script_exec + - id: 13 + run: | + curl https://raw.githubusercontent.com/org/repo/0a727065ae5a2313e8e6acf172844e8ca30c1822/install.sh | bash + curl https://github.com/org/repo/raw/0a727065ae5a2313e8e6acf172844e8ca30c1822/install.sh | bash