Skip to content
Draft
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
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ update-vulndb:
go test -tags build_platform_vuln_database -run TestPopulateBuildPlatformVulnDatabase -timeout 10m ./opa/
opa fmt -w opa/rego/external/build_platform.rego

.PHONY: update-checkout-shas
update-checkout-shas:
go test -tags checkout_unsafe_shas -run TestPopulateCheckoutUnsafeShas -timeout 10m ./opa/
opa fmt -w opa/rego/external/checkout_unsafe.rego

.PHONY: bench-org
bench-org:
go test -bench=BenchmarkAnalyzeOrg -benchtime=1x -count=3 -timeout=30m ./bench/analyze/
Expand Down
18 changes: 18 additions & 0 deletions docs/content/en/rules/untrusted_checkout_exec.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ Using workflows with `pull_request_target` has the added benefit (as opposed to

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.

## `actions/checkout` safe default (`allow-unsafe-pr-checkout`)

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.

poutine accounts for this and **suppresses this finding** for an `actions/checkout@<ref>` step only when **all** of the following hold:

- 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**
- `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**
- 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`.

The finding still fires when any of those is not met, in particular:

- 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`;
- events the guard does **not** cover — `issues`, `issue_comment`, `workflow_call`, and `workflow_run` triggered by those;
- 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.

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.

## Remediation

### GitHub Actions
Expand Down
10 changes: 8 additions & 2 deletions models/github_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ type GithubActionsStep struct {
With GithubActionsWith `json:"with,omitempty"`
WithRef string `json:"with_ref,omitempty" yaml:"-"`
WithScript string `json:"with_script,omitempty" yaml:"-"`
Line int `json:"line" yaml:"-"`
Action string `json:"action,omitempty" yaml:"-"`
// WithAllowUnsafePrCheckout is the raw `with: allow-unsafe-pr-checkout` value, kept as a
// string so callers can distinguish absent ("") from "true"/"false"/"${{ expr }}".
WithAllowUnsafePrCheckout string `json:"with_allow_unsafe_pr_checkout,omitempty" yaml:"-"`
Line int `json:"line" yaml:"-"`
Action string `json:"action,omitempty" yaml:"-"`

Lines map[string]int `json:"lines" yaml:"-"`
}
Expand Down Expand Up @@ -448,6 +451,9 @@ func (o *GithubActionsStep) UnmarshalYAML(node *yaml.Node) error {
case "script":
o.Lines["with_script"] = arg.Line
o.WithScript = arg.Value
case "allow-unsafe-pr-checkout":
o.Lines["with_allow_unsafe_pr_checkout"] = arg.Line
o.WithAllowUnsafePrCheckout = arg.Value
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions models/github_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ jobs:
with:
ref: ${{ github.head_ref }}
script: "console.log(1)"
allow-unsafe-pr-checkout: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
noperms:
Expand Down Expand Up @@ -580,6 +581,10 @@ jobs:
assert.Equal(t, 51, workflow.Jobs[0].Steps[0].Lines["with_script"])
assert.Equal(t, "console.log(1)", workflow.Jobs[0].Steps[0].With[1].Value)
assert.Equal(t, "console.log(1)", workflow.Jobs[0].Steps[0].WithScript)
assert.Equal(t, "allow-unsafe-pr-checkout", workflow.Jobs[0].Steps[0].With[2].Name)
assert.Equal(t, 52, workflow.Jobs[0].Steps[0].Lines["with_allow_unsafe_pr_checkout"])
assert.Equal(t, "false", workflow.Jobs[0].Steps[0].With[2].Value)
assert.Equal(t, "false", workflow.Jobs[0].Steps[0].WithAllowUnsafePrCheckout)
assert.Equal(t, "GITHUB_TOKEN", workflow.Jobs[0].Steps[0].Env[0].Name)
assert.Equal(t, "${{ secrets.GITHUB_TOKEN }}", workflow.Jobs[0].Steps[0].Env[0].Value)
assert.Equal(t, "noperms", workflow.Jobs[1].ID)
Expand Down
47 changes: 47 additions & 0 deletions opa/opa_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,53 @@ func noOpaErrors(t *testing.T, err error) {
panic(err)
}

func TestCheckoutGuardResolution(t *testing.T) {
const before = "2026-06-19T00:00:00Z" // before the v4/v5/v6 backport
const after = "2026-08-01T00:00:00Z" // after the backport
const v7sha = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"
const vulnsha = "34e114876b0b11c390a56381ad16ebd13914f8d5" // v4.3.1, in the frozen set

cases := []struct {
name string
step string // rego object literal
date string
protected bool // true => safe default neutralizes the checkout
}{
{"v7 tag", `{"uses": "actions/checkout@v7"}`, before, true},
{"v7 patch", `{"uses": "actions/checkout@v7.0.0"}`, before, true},
{"v8 future major", `{"uses": "actions/checkout@v8"}`, before, true},
{"main branch", `{"uses": "actions/checkout@main"}`, before, true},
{"v2 old tag", `{"uses": "actions/checkout@v2"}`, after, false},
{"v3 old tag", `{"uses": "actions/checkout@v3.5.0"}`, after, false},
{"v4 before backport", `{"uses": "actions/checkout@v4"}`, before, false},
{"v4 after backport", `{"uses": "actions/checkout@v4"}`, after, true},
{"v5 before backport", `{"uses": "actions/checkout@v5"}`, before, false},
{"v6 after backport", `{"uses": "actions/checkout@v6"}`, after, true},
{"releases/v4 before", `{"uses": "actions/checkout@releases/v4"}`, before, false},
{"releases/v4 after", `{"uses": "actions/checkout@releases/v4"}`, after, true},
{"safe sha (v7.0.0)", `{"uses": "actions/checkout@` + v7sha + `"}`, before, true},
{"vulnerable sha (v4.3.1)", `{"uses": "actions/checkout@` + vulnsha + `"}`, after, false},
{"unknown sha (default-allow)", `{"uses": "actions/checkout@0000000000000000000000000000000000000000"}`, after, true},
{"allow-unsafe false", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "false"}`, before, true},
{"allow-unsafe true", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "true"}`, before, false},
{"allow-unsafe expr", `{"uses": "actions/checkout@v7", "with_allow_unsafe_pr_checkout": "${{ inputs.x }}"}`, before, false},
{"gh/git run-block (no uses)", `{"run": "gh pr checkout 1"}`, after, false},
}

opa, err := NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}})
noOpaErrors(t, err)

for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var n int
query := fmt.Sprintf(`count([true | data.poutine.utils.checkout_guard_protects(%s, %q)])`, c.step, c.date)
err := opa.Eval(context.TODO(), query, nil, &n)
noOpaErrors(t, err)
assert.Equal(t, c.protected, n == 1)
})
}
}

func TestOpaBuiltins(t *testing.T) {
cases := []struct {
builtin string
Expand Down
156 changes: 156 additions & 0 deletions opa/populate_checkout_unsafe_shas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//go:build checkout_unsafe_shas
// +build checkout_unsafe_shas

package opa

// Regenerates opa/rego/external/checkout_unsafe.rego — the frozen set of actions/checkout
// commit SHAs that LACK the safe-default fix (GitHub's allow-unsafe-pr-checkout change).
//
// Run with:
//
// make update-checkout-shas
// # or: go test -tags checkout_unsafe_shas -run TestPopulateCheckoutUnsafeShas -timeout 10m ./opa
//
// A SHA is SAFE iff it is (or descends from) a release-line fix commit. Today only the v7
// line is fixed (v7.0.0 = 9c091bb…); the v4/v5/v6 backports land on/after 2026-07-16 — add
// their fix-commit SHAs to fixCommits below and re-run once they are published. Because this
// is a default-ALLOW (bad) set, it is only complete/sound once every supported line is fixed:
// freeze it AFTER the backports so no vulnerable commit is created after the freeze.

import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
checkoutRepo = "https://github.com/actions/checkout.git"
checkoutRegoFile = "rego/external/checkout_unsafe.rego"
v7FixSHA = "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" // actions/checkout v7.0.0
expectMinUniverse = 200
expectMaxUniverse = 600
)

// fixCommits are the release-line fix commits. Any commit that is one of these or descends
// from one is SAFE (enforces the safe default). Add the v4/v5/v6 backport SHAs here once
// GitHub publishes them (on/after 2026-07-16).
var fixCommits = []string{
v7FixSHA,
// "<v4 backport fix SHA>",
// "<v5 backport fix SHA>",
// "<v6 backport fix SHA>",
}

func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.Output()
require.NoError(t, err, "git %s", strings.Join(args, " "))
return strings.TrimSpace(string(out))
}

func gitOK(dir string, args ...string) bool {
cmd := exec.Command("git", args...)
cmd.Dir = dir
return cmd.Run() == nil
}

func TestPopulateCheckoutUnsafeShas(t *testing.T) {
tmp, err := os.MkdirTemp("", "checkout-shas")
require.NoError(t, err)
defer os.RemoveAll(tmp)

// Shallow-by-blob clone of the commit graph only (no file contents needed).
git(t, tmp, "init", "-q", "repo")
repo := tmp + "/repo"
git(t, repo, "remote", "add", "origin", checkoutRepo)
git(t, repo, "fetch", "-q", "--filter=blob:none", "origin",
"refs/heads/main:refs/remotes/origin/main",
"refs/heads/releases/*:refs/remotes/origin/releases/*",
"refs/tags/*:refs/tags/*",
)

// Sanity: v7.0.0 resolves to the documented fix commit.
require.Equal(t, v7FixSHA, git(t, repo, "rev-parse", "v7.0.0^{commit}"),
"v7.0.0 no longer resolves to the expected fix commit")

// Universe = every commit reachable from main + release branches + all tags.
listCmd := exec.Command("git", "for-each-ref", "--format=%(refname)",
"refs/remotes/origin/main", "refs/remotes/origin/releases", "refs/tags")
listCmd.Dir = repo
refs, err := listCmd.Output()
require.NoError(t, err)

revCmd := exec.Command("git", "rev-list", "--stdin")
revCmd.Dir = repo
revCmd.Stdin = strings.NewReader(string(refs))
universeOut, err := revCmd.Output()
require.NoError(t, err)
universe := strings.Fields(string(universeOut))
require.GreaterOrEqual(t, len(universe), expectMinUniverse, "universe too small — partial fetch?")
require.LessOrEqual(t, len(universe), expectMaxUniverse, "universe too large — unexpected refs?")

// Vulnerable = not a descendant of (or equal to) any fix commit.
var vulnerable []string
for _, c := range universe {
safe := false
for _, fix := range fixCommits {
if gitOK(repo, "merge-base", "--is-ancestor", fix, c) {
safe = true
break
}
}
if !safe {
vulnerable = append(vulnerable, strings.ToLower(c))
}
}
sort.Strings(vulnerable)

// Anti-gap assertions: the freeze must not silently drop or over-include.
vulnSet := map[string]bool{}
for _, s := range vulnerable {
vulnSet[s] = true
}
assert.False(t, vulnSet[v7FixSHA], "v7.0.0 fix commit must NOT be in the vulnerable set")
for _, tag := range []string{"v1.0.0", "v2.0.0", "v3.0.0"} {
sha := strings.ToLower(git(t, repo, "rev-parse", tag+"^{commit}"))
assert.True(t, vulnSet[sha], "%s (%s) must be in the vulnerable set", tag, sha)
}
assert.Greater(t, len(vulnerable), 100, "suspiciously few vulnerable SHAs")

sum := sha256.Sum256([]byte(strings.Join(vulnerable, "\n")))
t.Logf("vulnerable_shas: %d entries, universe %d, sha256 %s",
len(vulnerable), len(universe), hex.EncodeToString(sum[:]))

writeCheckoutUnsafeRego(t, vulnerable)
}

func writeCheckoutUnsafeRego(t *testing.T, vulnerable []string) {
t.Helper()
content, err := os.ReadFile(checkoutRegoFile)
require.NoError(t, err)

var b strings.Builder
b.WriteString("vulnerable_shas := {\n")
for _, s := range vulnerable {
fmt.Fprintf(&b, "\t%q,\n", s)
}
b.WriteString("}")

re := regexp.MustCompile(`(?s)vulnerable_shas := \{.*?\n}`)
updated := re.ReplaceAllString(string(content), b.String())
require.Contains(t, updated, "vulnerable_shas := {", "replacement anchor not found")

require.NoError(t, os.WriteFile(checkoutRegoFile, []byte(updated), 0644))
t.Logf("wrote %s — run `opa fmt -w %s`", checkoutRegoFile, checkoutRegoFile)
}
Loading
Loading