Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions opa/rego/poutine/utils.rego
Original file line number Diff line number Diff line change
Expand Up @@ -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
########################################################################
Expand Down
63 changes: 38 additions & 25 deletions opa/rego/rules/untrusted_checkout_exec.rego
Original file line number Diff line number Diff line change
Expand Up @@ -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])]),
Expand Down Expand Up @@ -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[_]
Expand Down Expand Up @@ -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])]),
Expand Down Expand Up @@ -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")
Expand Down
1 change: 1 addition & 0 deletions results/results.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
113 changes: 82 additions & 31 deletions scanner/inventory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{},
},
Expand All @@ -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{},
},
Expand Down Expand Up @@ -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{},
},
Expand Down Expand Up @@ -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"},
},
},
{
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"},
},
Expand Down Expand Up @@ -744,18 +781,32 @@ 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
}
}
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")
}
}
5 changes: 5 additions & 0 deletions scanner/testdata/.github/workflows/test_new_fields.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading