diff --git a/opa/rego/poutine/utils.rego b/opa/rego/poutine/utils.rego index 774556b..deb6d51 100644 --- a/opa/rego/poutine/utils.rego +++ b/opa/rego/poutine/utils.rego @@ -75,6 +75,59 @@ to_set(xs) = xs if { is_array(xs) } else := {xs} +######################################################################## +# lotp_target resolution +######################################################################## + +lotp_static_targets := { + "ant": "build.xml", + "bundler": "Gemfile", + "cargo": "Cargo.toml", + "checkov": ".checkov.yml", + "docker": "Dockerfile", + "eslint": "eslint.config.js", + "golangci-lint": ".golangci.yml", + "gomplate": ".gomplate.yaml", + "goreleaser": ".goreleaser.yaml", + "gradle": "build.gradle", + "make": "Makefile", + "maven": "pom.xml", + "mkdocs": "mkdocs.yml", + "msbuild": "Directory.Build.props", + "mypy": "mypy.ini", + "npm": "package.json", + "phpstan": "phpstan.neon", + "pip": "requirements.txt", + "pre-commit": ".pre-commit-config.yaml", + "rake": "Rakefile", + "rubocop": ".rubocop.yml", + "sonar-scanner": "sonar-project.properties", + "stylelint": ".stylelintrc.js", + "terraform": "main.tf", + "tflint": ".tflint.hcl", + "tofu": "main.tf", + "vale": ".vale.ini", + "webpack": "webpack.config.js", + "yarn": "package.json", +} + +lotp_dynamic_target_patterns := { + "bash": `(\S+\.sh)\b`, + "powershell": `(\S+\.ps1)\b`, + "python": `python3?\s+(\S+\.py)\b`, + "chmod": `chmod\s+\S+\s+(\S+)`, +} + +resolve_lotp_targets(cmd, run_content) := [lotp_static_targets[cmd]] if { + lotp_static_targets[cmd] +} else := targets if { + pattern := lotp_dynamic_target_patterns[cmd] + matches := regex.find_all_string_submatch_n(pattern, run_content, -1) + unique := {trim_left(m[1], "./") | m := matches[_]; not contains(m[1], "://")} + count(unique) > 0 + targets := sort(unique) +} + ######################################################################## # job order utils ######################################################################## diff --git a/opa/rego/rules/untrusted_checkout_exec.rego b/opa/rego/rules/untrusted_checkout_exec.rego index 42f63eb..e138e06 100644 --- a/opa/rego/rules/untrusted_checkout_exec.rego +++ b/opa/rego/rules/untrusted_checkout_exec.rego @@ -96,15 +96,18 @@ build_commands[cmd] = { "yarn": {"yarn "}, }[cmd] -results contains poutine.finding(rule, pkg_purl, { - "path": workflow_path, - "line": step.lines.run, - "job": job_id, - "lotp_tool": cmd, - "_job": job_obj, - "details": sprintf("Detected usage of `%s`", [cmd]), - "event_triggers": workflow_events, -}) if { +results contains poutine.finding(rule, pkg_purl, object.union( + { + "path": workflow_path, + "line": step.lines.run, + "job": job_id, + "lotp_tool": cmd, + "_job": job_obj, + "details": sprintf("Detected usage of `%s`", [cmd]), + "event_triggers": workflow_events, + }, + _lotp_targets_meta(cmd, step.run), +)) if { [pkg_purl, workflow_path, workflow_events, step, job_id, job_obj] := _steps_after_untrusted_checkout[_] regex.match( sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]), @@ -145,6 +148,10 @@ results contains poutine.finding(rule, pkg_purl, { ) } +_lotp_targets_meta(cmd, content) := {"lotp_targets": targets} if { + targets := utils.resolve_lotp_targets(cmd, content) +} else := {} + _steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.step, workflow.jobs[s.job_idx].id, workflow.jobs[s.job_idx]] if { pkg := input.packages[_] workflow := pkg.github_actions_workflows[_] @@ -174,14 +181,17 @@ _workflows_runs_from_pr contains [pkg.purl, workflow] if { # Azure Devops -results contains poutine.finding(rule, pkg_purl, { - "path": pipeline_path, - "job": job, - "step": s.step_idx, - "line": s.step.lines[attr], - "lotp_tool": cmd, - "details": sprintf("Detected usage of `%s`", [cmd]), -}) if { +results contains poutine.finding(rule, pkg_purl, object.union( + { + "path": pipeline_path, + "job": job, + "step": s.step_idx, + "line": s.step.lines[attr], + "lotp_tool": cmd, + "details": sprintf("Detected usage of `%s`", [cmd]), + }, + _lotp_targets_meta(cmd, s.step[attr]), +)) if { [pkg_purl, pipeline_path, s, job] := _steps_after_untrusted_checkout_ado[_] regex.match( sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]), @@ -218,14 +228,17 @@ find_ado_checkout(stage) := xs if { # Pipeline As Code Tekton -results contains poutine.finding(rule, pkg.purl, { - "path": pipeline.path, - "job": task.name, - "step": step_idx, - "line": step.lines.script, - "lotp_tool": cmd, - "details": sprintf("Detected usage of `%s`", [cmd]), -}) if { +results contains poutine.finding(rule, pkg.purl, object.union( + { + "path": pipeline.path, + "job": task.name, + "step": step_idx, + "line": step.lines.script, + "lotp_tool": cmd, + "details": sprintf("Detected usage of `%s`", [cmd]), + }, + _lotp_targets_meta(cmd, step.script), +)) if { pkg := input.packages[_] pipeline := pkg.pipeline_as_code_tekton[_] contains(pipeline.api_version, "tekton.dev") diff --git a/results/results.go b/results/results.go index 8cddfcd..178140d 100644 --- a/results/results.go +++ b/results/results.go @@ -28,6 +28,7 @@ type FindingMeta struct { InjectionSources []string `json:"injection_sources,omitempty"` // Sources confirmed as injected into a sink LOTPTool string `json:"lotp_tool,omitempty"` // Living Off The Pipeline tool (e.g., npm, pip) LOTPAction string `json:"lotp_action,omitempty"` // Living Off The Pipeline GitHub Action + LOTPTargets []string `json:"lotp_targets,omitempty"` // Target files for LOTP injection (e.g., ["Makefile"], ["build.sh", "verify.sh"]) ReferencedSecrets []string `json:"referenced_secrets,omitempty"` // Secrets referenced in workflow (excludes GITHUB_TOKEN) } diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index 935ed37..1a3988d 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -206,6 +206,7 @@ func TestFindings(t *testing.T) { Line: 30, Details: "Detected usage of `npm`", LOTPTool: "npm", + LOTPTargets: []string{"package.json"}, EventTriggers: []string{"push", "pull_request_target"}, ReferencedSecrets: []string{}, }, @@ -232,6 +233,7 @@ func TestFindings(t *testing.T) { Line: 60, Details: "Detected usage of `pre-commit`", LOTPTool: "pre-commit", + LOTPTargets: []string{".pre-commit-config.yaml"}, EventTriggers: []string{"push", "pull_request_target"}, ReferencedSecrets: []string{}, }, @@ -271,6 +273,7 @@ func TestFindings(t *testing.T) { Line: 13, Details: "Detected usage of `npm`", LOTPTool: "npm", + LOTPTargets: []string{"package.json"}, EventTriggers: []string{"workflow_run"}, ReferencedSecrets: []string{}, }, @@ -460,60 +463,65 @@ func TestFindings(t *testing.T) { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-2.yml", - Line: 13, - Job: "", - Step: "1", - Details: "Detected usage of `bash`", - LOTPTool: "bash", + Path: "azure-pipelines-2.yml", + Line: 13, + Job: "", + Step: "1", + Details: "Detected usage of `bash`", + LOTPTool: "bash", + LOTPTargets: []string{"script.sh"}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-2.yml", - Line: 14, - Job: "", - Step: "2", - Details: "Detected usage of `npm`", - LOTPTool: "npm", + Path: "azure-pipelines-2.yml", + Line: 14, + Job: "", + Step: "2", + Details: "Detected usage of `npm`", + LOTPTool: "npm", + LOTPTargets: []string{"package.json"}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-4.yml", - Line: 10, - Job: "", - Step: "1", - Details: "Detected usage of `bash`", - LOTPTool: "bash", + Path: "azure-pipelines-4.yml", + Line: 10, + Job: "", + Step: "1", + Details: "Detected usage of `bash`", + LOTPTool: "bash", + LOTPTargets: []string{"script.sh"}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: "azure-pipelines-4.yml", - Line: 11, - Job: "", - Step: "2", - Details: "Detected usage of `npm`", - LOTPTool: "npm", + Path: "azure-pipelines-4.yml", + Line: 11, + Job: "", + Step: "2", + Details: "Detected usage of `npm`", + LOTPTool: "npm", + LOTPTargets: []string{"package.json"}, }, }, { RuleId: "untrusted_checkout_exec", Purl: purl, Meta: results.FindingMeta{ - Path: ".tekton/pipeline-as-code-tekton.yml", - Line: 43, - Job: "vale", - Step: "0", - Details: "Detected usage of `vale`", - LOTPTool: "vale", + Path: ".tekton/pipeline-as-code-tekton.yml", + Line: 43, + Job: "vale", + Step: "0", + Details: "Detected usage of `vale`", + LOTPTool: "vale", + LOTPTargets: []string{".vale.ini"}, }, }, { @@ -565,6 +573,34 @@ func TestFindings(t *testing.T) { }, }, // test_new_fields.yml findings + { + RuleId: "untrusted_checkout_exec", + Purl: purl, + Meta: results.FindingMeta{ + Path: ".github/workflows/test_new_fields.yml", + Line: 39, + Job: "vulnerable_checkout", + Details: "Detected usage of `bash`", + LOTPTool: "bash", + LOTPTargets: []string{"scripts/build.sh", "scripts/verify.sh"}, + EventTriggers: []string{"pull_request_target"}, + ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"}, + }, + }, + { + RuleId: "untrusted_checkout_exec", + Purl: purl, + Meta: results.FindingMeta{ + Path: ".github/workflows/test_new_fields.yml", + Line: 39, + Job: "vulnerable_checkout", + Details: "Detected usage of `chmod`", + LOTPTool: "chmod", + LOTPTargets: []string{"scripts/build.sh"}, + EventTriggers: []string{"pull_request_target"}, + ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"}, + }, + }, { RuleId: "injection", Purl: purl, @@ -595,6 +631,7 @@ func TestFindings(t *testing.T) { Job: "vulnerable_checkout", Details: "Detected usage of `npm`", LOTPTool: "npm", + LOTPTargets: []string{"package.json"}, EventTriggers: []string{"pull_request_target"}, ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"}, }, @@ -744,7 +781,7 @@ func TestStructuredFindingFields(t *testing.T) { // Test lotp_tool and referenced_secrets fields var lotpFinding *results.Finding for idx, f := range scannedPackage.FindingsResults.Findings { - if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" { + if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" && f.Meta.LOTPTool == "npm" { lotpFinding = &scannedPackage.FindingsResults.Findings[idx] break } @@ -752,10 +789,24 @@ func TestStructuredFindingFields(t *testing.T) { assert.NotNil(t, lotpFinding, "Expected to find untrusted_checkout_exec finding for test_new_fields.yml") if lotpFinding != nil { assert.Equal(t, "npm", lotpFinding.Meta.LOTPTool, "LOTPTool should be 'npm'") + assert.Equal(t, []string{"package.json"}, lotpFinding.Meta.LOTPTargets, "LOTPTargets should resolve npm to package.json") assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "API_KEY") assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DATABASE_PASSWORD") assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DEPLOY_TOKEN") assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "ENABLE_BUILD") assert.NotContains(t, lotpFinding.Meta.ReferencedSecrets, "GITHUB_TOKEN", "GITHUB_TOKEN should be excluded") } + + var bashMultiTargetFinding *results.Finding + for idx, f := range scannedPackage.FindingsResults.Findings { + if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" && f.Meta.LOTPTool == "bash" { + bashMultiTargetFinding = &scannedPackage.FindingsResults.Findings[idx] + break + } + } + assert.NotNil(t, bashMultiTargetFinding, "Expected to find bash finding with multiple targets") + if bashMultiTargetFinding != nil { + assert.Equal(t, []string{"scripts/build.sh", "scripts/verify.sh"}, bashMultiTargetFinding.Meta.LOTPTargets, + "LOTPTargets should contain all .sh files from the run block, deduplicated and sorted") + } } diff --git a/scanner/testdata/.github/workflows/test_new_fields.yml b/scanner/testdata/.github/workflows/test_new_fields.yml index de0bd9a..05fe93f 100644 --- a/scanner/testdata/.github/workflows/test_new_fields.yml +++ b/scanner/testdata/.github/workflows/test_new_fields.yml @@ -35,6 +35,11 @@ jobs: DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Run build scripts + run: | + chmod +x ./scripts/build.sh && ./scripts/build.sh + ./scripts/verify.sh + - name: Deploy uses: some/action@v1 with: