Skip to content

Commit 6116554

Browse files
feat(untrusted_checkout_exec): account for actions/checkout allow-unsafe-pr-checkout
GitHub's actions/checkout now refuses to fetch untrusted fork PR code by default (v7.0.0, backported to v4/v5/v6 on 2026-07-16) unless allow-unsafe-pr-checkout: true is set. Suppress the finding when the checkout is on a fixed version with the safe default in effect, scoped to the events the guard actually covers (pull_request_target and PR-triggered workflow_run). - models: capture `with: allow-unsafe-pr-checkout` (string; absent/true/false/expr) - opa/rego/external/checkout_unsafe.rego: frozen set of pre-fix checkout SHAs (default-allow bad-set) + backport_floor_date; offline, works with analyze_local - utils: resolver (SHA set membership; tags via major/date gate) + guard helpers; new raw-`git` untrusted-checkout detection branch (git fetch pull/N/head, git checkout of a head ref) — never suppressed, like gh pr checkout - rule: event-aware suppression; same-step scan for gh/git run-block checkouts - scanner: inject scan_time into findings eval (POUTINE_SCAN_TIME overridable) - regen tool (build-tag checkout_unsafe_shas) + `make update-checkout-shas` - tests: fire/suppress matrix incl. git vectors and before/after backport; resolver eval matrix; deterministic scan clock pinned for tests/snapshots Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8918c66 commit 6116554

21 files changed

Lines changed: 884 additions & 28 deletions

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ update-vulndb:
3434
go test -tags build_platform_vuln_database -run TestPopulateBuildPlatformVulnDatabase -timeout 10m ./opa/
3535
opa fmt -w opa/rego/external/build_platform.rego
3636

37+
.PHONY: update-checkout-shas
38+
update-checkout-shas:
39+
go test -tags checkout_unsafe_shas -run TestPopulateCheckoutUnsafeShas -timeout 10m ./opa/
40+
opa fmt -w opa/rego/external/checkout_unsafe.rego
41+
3742
.PHONY: bench-org
3843
bench-org:
3944
go test -bench=BenchmarkAnalyzeOrg -benchtime=1x -count=3 -timeout=30m ./bench/analyze/

docs/content/en/rules/untrusted_checkout_exec.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,24 @@ Using workflows with `pull_request_target` has the added benefit (as opposed to
1414

1515
So-called "Living Off The Pipeline" tools are common development tools (typically CLIs), commonly used in CI/CD pipelines that have lesser-known RCE-By-Design features ("foot guns") that can be abused to execute arbitrary code. These tools are often used to automate tasks such as compiling, testing, packaging, linting or scanning. The gotcha comes from the fact that many of those tools will consume unutrusted input from files on disk and when you checkout untrusted code from a fork, you are effectively allowing the attacker to control the input to those tools.
1616

17+
## `actions/checkout` safe default (`allow-unsafe-pr-checkout`)
18+
19+
As of `actions/checkout@v7.0.0` (and backported to `v4`/`v5`/`v6` on 2026-07-16), `actions/checkout` **refuses to fetch untrusted fork pull request code** unless the step explicitly sets `allow-unsafe-pr-checkout: true`. When that protection is in effect, the untrusted code never lands on disk, so the code-execution premise of this rule no longer holds.
20+
21+
poutine accounts for this and **suppresses this finding** for an `actions/checkout@<ref>` step only when **all** of the following hold:
22+
23+
- the pinned version enforces the safe default — `v7`+ tags, `main`, a commit SHA that is not in the frozen pre-fix set, or `v4`/`v5`/`v6` (floating tag / `releases/v4..6` branch) once the backport date has passed; **and**
24+
- `allow-unsafe-pr-checkout` is not enabled (absent, or any value other than the literal `true`; a `${{ … }}` expression is treated as possibly-`true` and does **not** suppress); **and**
25+
- the triggering event is one the guard actually covers — `pull_request_target`, or a `workflow_run` whose triggering event is `pull_request`/`pull_request_target`.
26+
27+
The finding still fires when any of those is not met, in particular:
28+
29+
- old or SHA-pinned vulnerable versions (`v1`/`v2`/`v3`, pre-backport `v4`/`v5`/`v6`, or a SHA in the frozen pre-fix set), or `allow-unsafe-pr-checkout: true`;
30+
- events the guard does **not** cover — `issues`, `issue_comment`, `workflow_call`, and `workflow_run` triggered by those;
31+
- untrusted checkout performed via `gh pr checkout` or raw `git` (e.g. `git fetch … pull/<n>/head` then `git checkout`) in a `run:` block — these are explicitly out of scope of GitHub's change and remain exploitable.
32+
33+
The version/SHA resolution is fully offline (it works with `analyze_local`), using an embedded, frozen set of pre-fix `actions/checkout` commit SHAs. The scan instant used for the date gate can be pinned via the `POUTINE_SCAN_TIME` environment variable (RFC3339) for reproducible scans.
34+
1735
## Remediation
1836

1937
### GitHub Actions

models/github_actions.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,11 @@ type GithubActionsStep struct {
111111
With GithubActionsWith `json:"with,omitempty"`
112112
WithRef string `json:"with_ref,omitempty" yaml:"-"`
113113
WithScript string `json:"with_script,omitempty" yaml:"-"`
114-
Line int `json:"line" yaml:"-"`
115-
Action string `json:"action,omitempty" yaml:"-"`
114+
// WithAllowUnsafePrCheckout is the raw `with: allow-unsafe-pr-checkout` value, kept as a
115+
// string so callers can distinguish absent ("") from "true"/"false"/"${{ expr }}".
116+
WithAllowUnsafePrCheckout string `json:"with_allow_unsafe_pr_checkout,omitempty" yaml:"-"`
117+
Line int `json:"line" yaml:"-"`
118+
Action string `json:"action,omitempty" yaml:"-"`
116119

117120
Lines map[string]int `json:"lines" yaml:"-"`
118121
}
@@ -448,6 +451,9 @@ func (o *GithubActionsStep) UnmarshalYAML(node *yaml.Node) error {
448451
case "script":
449452
o.Lines["with_script"] = arg.Line
450453
o.WithScript = arg.Value
454+
case "allow-unsafe-pr-checkout":
455+
o.Lines["with_allow_unsafe_pr_checkout"] = arg.Line
456+
o.WithAllowUnsafePrCheckout = arg.Value
451457
}
452458
}
453459
}

models/github_actions_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ jobs:
498498
with:
499499
ref: ${{ github.head_ref }}
500500
script: "console.log(1)"
501+
allow-unsafe-pr-checkout: false
501502
env:
502503
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
503504
noperms:
@@ -580,6 +581,10 @@ jobs:
580581
assert.Equal(t, 51, workflow.Jobs[0].Steps[0].Lines["with_script"])
581582
assert.Equal(t, "console.log(1)", workflow.Jobs[0].Steps[0].With[1].Value)
582583
assert.Equal(t, "console.log(1)", workflow.Jobs[0].Steps[0].WithScript)
584+
assert.Equal(t, "allow-unsafe-pr-checkout", workflow.Jobs[0].Steps[0].With[2].Name)
585+
assert.Equal(t, 52, workflow.Jobs[0].Steps[0].Lines["with_allow_unsafe_pr_checkout"])
586+
assert.Equal(t, "false", workflow.Jobs[0].Steps[0].With[2].Value)
587+
assert.Equal(t, "false", workflow.Jobs[0].Steps[0].WithAllowUnsafePrCheckout)
583588
assert.Equal(t, "GITHUB_TOKEN", workflow.Jobs[0].Steps[0].Env[0].Name)
584589
assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", workflow.Jobs[0].Steps[0].Env[0].Value)
585590
assert.Equal(t, "noperms", workflow.Jobs[1].ID)

opa/opa_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,53 @@ func noOpaErrors(t *testing.T, err error) {
2929
panic(err)
3030
}
3131

32+
func TestCheckoutGuardResolution(t *testing.T) {
33+
const before = "2026-06-19T00:00:00Z" // before the v4/v5/v6 backport
34+
const after = "2026-08-01T00:00:00Z" // after the backport
35+
const v7sha = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
36+
const vulnsha = "34e114876b0b11c390a56381ad16ebd13914f8d5" // v4.3.1, in the frozen set
37+
38+
cases := []struct {
39+
name string
40+
step string // rego object literal
41+
date string
42+
protected bool // true => safe default neutralizes the checkout
43+
}{
44+
{"v7 tag", `{"uses": "actions/checkout@v7"}`, before, true},
45+
{"v7 patch", `{"uses": "actions/checkout@v7.0.0"}`, before, true},
46+
{"v8 future major", `{"uses": "actions/checkout@v8"}`, before, true},
47+
{"main branch", `{"uses": "actions/checkout@main"}`, before, true},
48+
{"v2 old tag", `{"uses": "actions/checkout@v2"}`, after, false},
49+
{"v3 old tag", `{"uses": "actions/checkout@v3.5.0"}`, after, false},
50+
{"v4 before backport", `{"uses": "actions/checkout@v4"}`, before, false},
51+
{"v4 after backport", `{"uses": "actions/checkout@v4"}`, after, true},
52+
{"v5 before backport", `{"uses": "actions/checkout@v5"}`, before, false},
53+
{"v6 after backport", `{"uses": "actions/checkout@v6"}`, after, true},
54+
{"releases/v4 before", `{"uses": "actions/checkout@releases/v4"}`, before, false},
55+
{"releases/v4 after", `{"uses": "actions/checkout@releases/v4"}`, after, true},
56+
{"safe sha (v7.0.0)", `{"uses": "actions/checkout@` + v7sha + `"}`, before, true},
57+
{"vulnerable sha (v4.3.1)", `{"uses": "actions/checkout@` + vulnsha + `"}`, after, false},
58+
{"unknown sha (default-allow)", `{"uses": "actions/checkout@0000000000000000000000000000000000000000"}`, after, true},
59+
{"allow-unsafe false", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "false"}`, before, true},
60+
{"allow-unsafe true", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "true"}`, before, false},
61+
{"allow-unsafe expr", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "${{ inputs.x }}"}`, before, false},
62+
{"gh/git run-block (no uses)", `{"run": "gh pr checkout 1"}`, after, false},
63+
}
64+
65+
opa, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
66+
noOpaErrors(t, err)
67+
68+
for _, c := range cases {
69+
t.Run(c.name, func(t *testing.T) {
70+
var n int
71+
query := fmt.Sprintf(`count([true | data.poutine.utils.checkout_guard_protects(%s, %q)])`, c.step, c.date)
72+
err := opa.Eval(context.TODO(), query, nil, &n)
73+
noOpaErrors(t, err)
74+
assert.Equal(t, c.protected, n == 1)
75+
})
76+
}
77+
}
78+
3279
func TestOpaBuiltins(t *testing.T) {
3380
cases := []struct {
3481
builtin string
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
//go:build checkout_unsafe_shas
2+
// +build checkout_unsafe_shas
3+
4+
package opa
5+
6+
// Regenerates opa/rego/external/checkout_unsafe.rego — the frozen set of actions/checkout
7+
// commit SHAs that LACK the safe-default fix (GitHub's allow-unsafe-pr-checkout change).
8+
//
9+
// Run with:
10+
//
11+
// make update-checkout-shas
12+
// # or: go test -tags checkout_unsafe_shas -run TestPopulateCheckoutUnsafeShas -timeout 10m ./opa
13+
//
14+
// A SHA is SAFE iff it is (or descends from) a release-line fix commit. Today only the v7
15+
// line is fixed (v7.0.0 = 9c091bb…); the v4/v5/v6 backports land on/after 2026-07-16 — add
16+
// their fix-commit SHAs to fixCommits below and re-run once they are published. Because this
17+
// is a default-ALLOW (bad) set, it is only complete/sound once every supported line is fixed:
18+
// freeze it AFTER the backports so no vulnerable commit is created after the freeze.
19+
20+
import (
21+
"crypto/sha256"
22+
"encoding/hex"
23+
"fmt"
24+
"os"
25+
"os/exec"
26+
"regexp"
27+
"sort"
28+
"strings"
29+
"testing"
30+
31+
"github.com/stretchr/testify/assert"
32+
"github.com/stretchr/testify/require"
33+
)
34+
35+
const (
36+
checkoutRepo = "https://github.com/actions/checkout.git"
37+
checkoutRegoFile = "rego/external/checkout_unsafe.rego"
38+
v7FixSHA = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" // actions/checkout v7.0.0
39+
expectMinUniverse = 200
40+
expectMaxUniverse = 600
41+
)
42+
43+
// fixCommits are the release-line fix commits. Any commit that is one of these or descends
44+
// from one is SAFE (enforces the safe default). Add the v4/v5/v6 backport SHAs here once
45+
// GitHub publishes them (on/after 2026-07-16).
46+
var fixCommits = []string{
47+
v7FixSHA,
48+
// "<v4 backport fix SHA>",
49+
// "<v5 backport fix SHA>",
50+
// "<v6 backport fix SHA>",
51+
}
52+
53+
func git(t *testing.T, dir string, args ...string) string {
54+
t.Helper()
55+
cmd := exec.Command("git", args...)
56+
cmd.Dir = dir
57+
out, err := cmd.Output()
58+
require.NoError(t, err, "git %s", strings.Join(args, " "))
59+
return strings.TrimSpace(string(out))
60+
}
61+
62+
func gitOK(dir string, args ...string) bool {
63+
cmd := exec.Command("git", args...)
64+
cmd.Dir = dir
65+
return cmd.Run() == nil
66+
}
67+
68+
func TestPopulateCheckoutUnsafeShas(t *testing.T) {
69+
tmp, err := os.MkdirTemp("", "checkout-shas")
70+
require.NoError(t, err)
71+
defer os.RemoveAll(tmp)
72+
73+
// Shallow-by-blob clone of the commit graph only (no file contents needed).
74+
git(t, tmp, "init", "-q", "repo")
75+
repo := tmp + "/repo"
76+
git(t, repo, "remote", "add", "origin", checkoutRepo)
77+
git(t, repo, "fetch", "-q", "--filter=blob:none", "origin",
78+
"refs/heads/main:refs/remotes/origin/main",
79+
"refs/heads/releases/*:refs/remotes/origin/releases/*",
80+
"refs/tags/*:refs/tags/*",
81+
)
82+
83+
// Sanity: v7.0.0 resolves to the documented fix commit.
84+
require.Equal(t, v7FixSHA, git(t, repo, "rev-parse", "v7.0.0^{commit}"),
85+
"v7.0.0 no longer resolves to the expected fix commit")
86+
87+
// Universe = every commit reachable from main + release branches + all tags.
88+
listCmd := exec.Command("git", "for-each-ref", "--format=%(refname)",
89+
"refs/remotes/origin/main", "refs/remotes/origin/releases", "refs/tags")
90+
listCmd.Dir = repo
91+
refs, err := listCmd.Output()
92+
require.NoError(t, err)
93+
94+
revCmd := exec.Command("git", "rev-list", "--stdin")
95+
revCmd.Dir = repo
96+
revCmd.Stdin = strings.NewReader(string(refs))
97+
universeOut, err := revCmd.Output()
98+
require.NoError(t, err)
99+
universe := strings.Fields(string(universeOut))
100+
require.GreaterOrEqual(t, len(universe), expectMinUniverse, "universe too small — partial fetch?")
101+
require.LessOrEqual(t, len(universe), expectMaxUniverse, "universe too large — unexpected refs?")
102+
103+
// Vulnerable = not a descendant of (or equal to) any fix commit.
104+
var vulnerable []string
105+
for _, c := range universe {
106+
safe := false
107+
for _, fix := range fixCommits {
108+
if gitOK(repo, "merge-base", "--is-ancestor", fix, c) {
109+
safe = true
110+
break
111+
}
112+
}
113+
if !safe {
114+
vulnerable = append(vulnerable, strings.ToLower(c))
115+
}
116+
}
117+
sort.Strings(vulnerable)
118+
119+
// Anti-gap assertions: the freeze must not silently drop or over-include.
120+
vulnSet := map[string]bool{}
121+
for _, s := range vulnerable {
122+
vulnSet[s] = true
123+
}
124+
assert.False(t, vulnSet[v7FixSHA], "v7.0.0 fix commit must NOT be in the vulnerable set")
125+
for _, tag := range []string{"v1.0.0", "v2.0.0", "v3.0.0"} {
126+
sha := strings.ToLower(git(t, repo, "rev-parse", tag+"^{commit}"))
127+
assert.True(t, vulnSet[sha], "%s (%s) must be in the vulnerable set", tag, sha)
128+
}
129+
assert.Greater(t, len(vulnerable), 100, "suspiciously few vulnerable SHAs")
130+
131+
sum := sha256.Sum256([]byte(strings.Join(vulnerable, "\n")))
132+
t.Logf("vulnerable_shas: %d entries, universe %d, sha256 %s",
133+
len(vulnerable), len(universe), hex.EncodeToString(sum[:]))
134+
135+
writeCheckoutUnsafeRego(t, vulnerable)
136+
}
137+
138+
func writeCheckoutUnsafeRego(t *testing.T, vulnerable []string) {
139+
t.Helper()
140+
content, err := os.ReadFile(checkoutRegoFile)
141+
require.NoError(t, err)
142+
143+
var b strings.Builder
144+
b.WriteString("vulnerable_shas := {\n")
145+
for _, s := range vulnerable {
146+
fmt.Fprintf(&b, "\t%q,\n", s)
147+
}
148+
b.WriteString("}")
149+
150+
re := regexp.MustCompile(`(?s)vulnerable_shas := \{.*?\n}`)
151+
updated := re.ReplaceAllString(string(content), b.String())
152+
require.Contains(t, updated, "vulnerable_shas := {", "replacement anchor not found")
153+
154+
require.NoError(t, os.WriteFile(checkoutRegoFile, []byte(updated), 0644))
155+
t.Logf("wrote %s — run `opa fmt -w %s`", checkoutRegoFile, checkoutRegoFile)
156+
}

0 commit comments

Comments
 (0)