Skip to content

Commit f350a41

Browse files
Add lotp_targets field to untrusted_checkout_exec findings (#386)
Resolves the target file(s) an attacker should inject into when exploiting a pwn request vulnerability. Static targets use a lookup table (npm→package.json, make→Makefile, etc.), dynamic targets extract file paths via regex from step.run content. The field is an array (lotp_targets) to handle cases where a single run: block references multiple scripts. URL-based references are filtered out to only surface local repository files. Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 849436a commit f350a41

5 files changed

Lines changed: 179 additions & 56 deletions

File tree

opa/rego/poutine/utils.rego

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,59 @@ to_set(xs) = xs if {
7575
is_array(xs)
7676
} else := {xs}
7777

78+
########################################################################
79+
# lotp_target resolution
80+
########################################################################
81+
82+
lotp_static_targets := {
83+
"ant": "build.xml",
84+
"bundler": "Gemfile",
85+
"cargo": "Cargo.toml",
86+
"checkov": ".checkov.yml",
87+
"docker": "Dockerfile",
88+
"eslint": "eslint.config.js",
89+
"golangci-lint": ".golangci.yml",
90+
"gomplate": ".gomplate.yaml",
91+
"goreleaser": ".goreleaser.yaml",
92+
"gradle": "build.gradle",
93+
"make": "Makefile",
94+
"maven": "pom.xml",
95+
"mkdocs": "mkdocs.yml",
96+
"msbuild": "Directory.Build.props",
97+
"mypy": "mypy.ini",
98+
"npm": "package.json",
99+
"phpstan": "phpstan.neon",
100+
"pip": "requirements.txt",
101+
"pre-commit": ".pre-commit-config.yaml",
102+
"rake": "Rakefile",
103+
"rubocop": ".rubocop.yml",
104+
"sonar-scanner": "sonar-project.properties",
105+
"stylelint": ".stylelintrc.js",
106+
"terraform": "main.tf",
107+
"tflint": ".tflint.hcl",
108+
"tofu": "main.tf",
109+
"vale": ".vale.ini",
110+
"webpack": "webpack.config.js",
111+
"yarn": "package.json",
112+
}
113+
114+
lotp_dynamic_target_patterns := {
115+
"bash": `(\S+\.sh)\b`,
116+
"powershell": `(\S+\.ps1)\b`,
117+
"python": `python3?\s+(\S+\.py)\b`,
118+
"chmod": `chmod\s+\S+\s+(\S+)`,
119+
}
120+
121+
resolve_lotp_targets(cmd, run_content) := [lotp_static_targets[cmd]] if {
122+
lotp_static_targets[cmd]
123+
} else := targets if {
124+
pattern := lotp_dynamic_target_patterns[cmd]
125+
matches := regex.find_all_string_submatch_n(pattern, run_content, -1)
126+
unique := {trim_left(m[1], "./") | m := matches[_]; not contains(m[1], "://")}
127+
count(unique) > 0
128+
targets := sort(unique)
129+
}
130+
78131
########################################################################
79132
# job order utils
80133
########################################################################

opa/rego/rules/untrusted_checkout_exec.rego

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,18 @@ build_commands[cmd] = {
9696
"yarn": {"yarn "},
9797
}[cmd]
9898

99-
results contains poutine.finding(rule, pkg_purl, {
100-
"path": workflow_path,
101-
"line": step.lines.run,
102-
"job": job_id,
103-
"lotp_tool": cmd,
104-
"_job": job_obj,
105-
"details": sprintf("Detected usage of `%s`", [cmd]),
106-
"event_triggers": workflow_events,
107-
}) if {
99+
results contains poutine.finding(rule, pkg_purl, object.union(
100+
{
101+
"path": workflow_path,
102+
"line": step.lines.run,
103+
"job": job_id,
104+
"lotp_tool": cmd,
105+
"_job": job_obj,
106+
"details": sprintf("Detected usage of `%s`", [cmd]),
107+
"event_triggers": workflow_events,
108+
},
109+
_lotp_targets_meta(cmd, step.run),
110+
)) if {
108111
[pkg_purl, workflow_path, workflow_events, step, job_id, job_obj] := _steps_after_untrusted_checkout[_]
109112
regex.match(
110113
sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]),
@@ -145,6 +148,10 @@ results contains poutine.finding(rule, pkg_purl, {
145148
)
146149
}
147150

151+
_lotp_targets_meta(cmd, content) := {"lotp_targets": targets} if {
152+
targets := utils.resolve_lotp_targets(cmd, content)
153+
} else := {}
154+
148155
_steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.step, workflow.jobs[s.job_idx].id, workflow.jobs[s.job_idx]] if {
149156
pkg := input.packages[_]
150157
workflow := pkg.github_actions_workflows[_]
@@ -174,14 +181,17 @@ _workflows_runs_from_pr contains [pkg.purl, workflow] if {
174181

175182
# Azure Devops
176183

177-
results contains poutine.finding(rule, pkg_purl, {
178-
"path": pipeline_path,
179-
"job": job,
180-
"step": s.step_idx,
181-
"line": s.step.lines[attr],
182-
"lotp_tool": cmd,
183-
"details": sprintf("Detected usage of `%s`", [cmd]),
184-
}) if {
184+
results contains poutine.finding(rule, pkg_purl, object.union(
185+
{
186+
"path": pipeline_path,
187+
"job": job,
188+
"step": s.step_idx,
189+
"line": s.step.lines[attr],
190+
"lotp_tool": cmd,
191+
"details": sprintf("Detected usage of `%s`", [cmd]),
192+
},
193+
_lotp_targets_meta(cmd, s.step[attr]),
194+
)) if {
185195
[pkg_purl, pipeline_path, s, job] := _steps_after_untrusted_checkout_ado[_]
186196
regex.match(
187197
sprintf("([^a-z]|^)(%v)", [concat("|", build_commands[cmd])]),
@@ -218,14 +228,17 @@ find_ado_checkout(stage) := xs if {
218228

219229
# Pipeline As Code Tekton
220230

221-
results contains poutine.finding(rule, pkg.purl, {
222-
"path": pipeline.path,
223-
"job": task.name,
224-
"step": step_idx,
225-
"line": step.lines.script,
226-
"lotp_tool": cmd,
227-
"details": sprintf("Detected usage of `%s`", [cmd]),
228-
}) if {
231+
results contains poutine.finding(rule, pkg.purl, object.union(
232+
{
233+
"path": pipeline.path,
234+
"job": task.name,
235+
"step": step_idx,
236+
"line": step.lines.script,
237+
"lotp_tool": cmd,
238+
"details": sprintf("Detected usage of `%s`", [cmd]),
239+
},
240+
_lotp_targets_meta(cmd, step.script),
241+
)) if {
229242
pkg := input.packages[_]
230243
pipeline := pkg.pipeline_as_code_tekton[_]
231244
contains(pipeline.api_version, "tekton.dev")

results/results.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type FindingMeta struct {
2828
InjectionSources []string `json:"injection_sources,omitempty"` // Sources confirmed as injected into a sink
2929
LOTPTool string `json:"lotp_tool,omitempty"` // Living Off The Pipeline tool (e.g., npm, pip)
3030
LOTPAction string `json:"lotp_action,omitempty"` // Living Off The Pipeline GitHub Action
31+
LOTPTargets []string `json:"lotp_targets,omitempty"` // Target files for LOTP injection (e.g., ["Makefile"], ["build.sh", "verify.sh"])
3132
ReferencedSecrets []string `json:"referenced_secrets,omitempty"` // Secrets referenced in workflow (excludes GITHUB_TOKEN)
3233
}
3334

scanner/inventory_test.go

Lines changed: 82 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,7 @@ func TestFindings(t *testing.T) {
206206
Line: 30,
207207
Details: "Detected usage of `npm`",
208208
LOTPTool: "npm",
209+
LOTPTargets: []string{"package.json"},
209210
EventTriggers: []string{"push", "pull_request_target"},
210211
ReferencedSecrets: []string{},
211212
},
@@ -232,6 +233,7 @@ func TestFindings(t *testing.T) {
232233
Line: 60,
233234
Details: "Detected usage of `pre-commit`",
234235
LOTPTool: "pre-commit",
236+
LOTPTargets: []string{".pre-commit-config.yaml"},
235237
EventTriggers: []string{"push", "pull_request_target"},
236238
ReferencedSecrets: []string{},
237239
},
@@ -271,6 +273,7 @@ func TestFindings(t *testing.T) {
271273
Line: 13,
272274
Details: "Detected usage of `npm`",
273275
LOTPTool: "npm",
276+
LOTPTargets: []string{"package.json"},
274277
EventTriggers: []string{"workflow_run"},
275278
ReferencedSecrets: []string{},
276279
},
@@ -460,60 +463,65 @@ func TestFindings(t *testing.T) {
460463
RuleId: "untrusted_checkout_exec",
461464
Purl: purl,
462465
Meta: results.FindingMeta{
463-
Path: "azure-pipelines-2.yml",
464-
Line: 13,
465-
Job: "",
466-
Step: "1",
467-
Details: "Detected usage of `bash`",
468-
LOTPTool: "bash",
466+
Path: "azure-pipelines-2.yml",
467+
Line: 13,
468+
Job: "",
469+
Step: "1",
470+
Details: "Detected usage of `bash`",
471+
LOTPTool: "bash",
472+
LOTPTargets: []string{"script.sh"},
469473
},
470474
},
471475
{
472476
RuleId: "untrusted_checkout_exec",
473477
Purl: purl,
474478
Meta: results.FindingMeta{
475-
Path: "azure-pipelines-2.yml",
476-
Line: 14,
477-
Job: "",
478-
Step: "2",
479-
Details: "Detected usage of `npm`",
480-
LOTPTool: "npm",
479+
Path: "azure-pipelines-2.yml",
480+
Line: 14,
481+
Job: "",
482+
Step: "2",
483+
Details: "Detected usage of `npm`",
484+
LOTPTool: "npm",
485+
LOTPTargets: []string{"package.json"},
481486
},
482487
},
483488
{
484489
RuleId: "untrusted_checkout_exec",
485490
Purl: purl,
486491
Meta: results.FindingMeta{
487-
Path: "azure-pipelines-4.yml",
488-
Line: 10,
489-
Job: "",
490-
Step: "1",
491-
Details: "Detected usage of `bash`",
492-
LOTPTool: "bash",
492+
Path: "azure-pipelines-4.yml",
493+
Line: 10,
494+
Job: "",
495+
Step: "1",
496+
Details: "Detected usage of `bash`",
497+
LOTPTool: "bash",
498+
LOTPTargets: []string{"script.sh"},
493499
},
494500
},
495501
{
496502
RuleId: "untrusted_checkout_exec",
497503
Purl: purl,
498504
Meta: results.FindingMeta{
499-
Path: "azure-pipelines-4.yml",
500-
Line: 11,
501-
Job: "",
502-
Step: "2",
503-
Details: "Detected usage of `npm`",
504-
LOTPTool: "npm",
505+
Path: "azure-pipelines-4.yml",
506+
Line: 11,
507+
Job: "",
508+
Step: "2",
509+
Details: "Detected usage of `npm`",
510+
LOTPTool: "npm",
511+
LOTPTargets: []string{"package.json"},
505512
},
506513
},
507514
{
508515
RuleId: "untrusted_checkout_exec",
509516
Purl: purl,
510517
Meta: results.FindingMeta{
511-
Path: ".tekton/pipeline-as-code-tekton.yml",
512-
Line: 43,
513-
Job: "vale",
514-
Step: "0",
515-
Details: "Detected usage of `vale`",
516-
LOTPTool: "vale",
518+
Path: ".tekton/pipeline-as-code-tekton.yml",
519+
Line: 43,
520+
Job: "vale",
521+
Step: "0",
522+
Details: "Detected usage of `vale`",
523+
LOTPTool: "vale",
524+
LOTPTargets: []string{".vale.ini"},
517525
},
518526
},
519527
{
@@ -565,6 +573,34 @@ func TestFindings(t *testing.T) {
565573
},
566574
},
567575
// test_new_fields.yml findings
576+
{
577+
RuleId: "untrusted_checkout_exec",
578+
Purl: purl,
579+
Meta: results.FindingMeta{
580+
Path: ".github/workflows/test_new_fields.yml",
581+
Line: 39,
582+
Job: "vulnerable_checkout",
583+
Details: "Detected usage of `bash`",
584+
LOTPTool: "bash",
585+
LOTPTargets: []string{"scripts/build.sh", "scripts/verify.sh"},
586+
EventTriggers: []string{"pull_request_target"},
587+
ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"},
588+
},
589+
},
590+
{
591+
RuleId: "untrusted_checkout_exec",
592+
Purl: purl,
593+
Meta: results.FindingMeta{
594+
Path: ".github/workflows/test_new_fields.yml",
595+
Line: 39,
596+
Job: "vulnerable_checkout",
597+
Details: "Detected usage of `chmod`",
598+
LOTPTool: "chmod",
599+
LOTPTargets: []string{"scripts/build.sh"},
600+
EventTriggers: []string{"pull_request_target"},
601+
ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"},
602+
},
603+
},
568604
{
569605
RuleId: "injection",
570606
Purl: purl,
@@ -595,6 +631,7 @@ func TestFindings(t *testing.T) {
595631
Job: "vulnerable_checkout",
596632
Details: "Detected usage of `npm`",
597633
LOTPTool: "npm",
634+
LOTPTargets: []string{"package.json"},
598635
EventTriggers: []string{"pull_request_target"},
599636
ReferencedSecrets: []string{"API_KEY", "DATABASE_PASSWORD", "DEPLOY_TOKEN", "ENABLE_BUILD"},
600637
},
@@ -744,18 +781,32 @@ func TestStructuredFindingFields(t *testing.T) {
744781
// Test lotp_tool and referenced_secrets fields
745782
var lotpFinding *results.Finding
746783
for idx, f := range scannedPackage.FindingsResults.Findings {
747-
if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" {
784+
if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" && f.Meta.LOTPTool == "npm" {
748785
lotpFinding = &scannedPackage.FindingsResults.Findings[idx]
749786
break
750787
}
751788
}
752789
assert.NotNil(t, lotpFinding, "Expected to find untrusted_checkout_exec finding for test_new_fields.yml")
753790
if lotpFinding != nil {
754791
assert.Equal(t, "npm", lotpFinding.Meta.LOTPTool, "LOTPTool should be 'npm'")
792+
assert.Equal(t, []string{"package.json"}, lotpFinding.Meta.LOTPTargets, "LOTPTargets should resolve npm to package.json")
755793
assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "API_KEY")
756794
assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DATABASE_PASSWORD")
757795
assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "DEPLOY_TOKEN")
758796
assert.Contains(t, lotpFinding.Meta.ReferencedSecrets, "ENABLE_BUILD")
759797
assert.NotContains(t, lotpFinding.Meta.ReferencedSecrets, "GITHUB_TOKEN", "GITHUB_TOKEN should be excluded")
760798
}
799+
800+
var bashMultiTargetFinding *results.Finding
801+
for idx, f := range scannedPackage.FindingsResults.Findings {
802+
if f.RuleId == "untrusted_checkout_exec" && f.Meta.Path == ".github/workflows/test_new_fields.yml" && f.Meta.LOTPTool == "bash" {
803+
bashMultiTargetFinding = &scannedPackage.FindingsResults.Findings[idx]
804+
break
805+
}
806+
}
807+
assert.NotNil(t, bashMultiTargetFinding, "Expected to find bash finding with multiple targets")
808+
if bashMultiTargetFinding != nil {
809+
assert.Equal(t, []string{"scripts/build.sh", "scripts/verify.sh"}, bashMultiTargetFinding.Meta.LOTPTargets,
810+
"LOTPTargets should contain all .sh files from the run block, deduplicated and sorted")
811+
}
761812
}

scanner/testdata/.github/workflows/test_new_fields.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ jobs:
3535
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
3636
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3737

38+
- name: Run build scripts
39+
run: |
40+
chmod +x ./scripts/build.sh && ./scripts/build.sh
41+
./scripts/verify.sh
42+
3843
- name: Deploy
3944
uses: some/action@v1
4045
with:

0 commit comments

Comments
 (0)