diff --git a/Makefile b/Makefile index 2f99409f..8cd90141 100644 --- a/Makefile +++ b/Makefile @@ -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/ diff --git a/docs/content/en/rules/untrusted_checkout_exec.md b/docs/content/en/rules/untrusted_checkout_exec.md index 647c274c..41e06967 100644 --- a/docs/content/en/rules/untrusted_checkout_exec.md +++ b/docs/content/en/rules/untrusted_checkout_exec.md @@ -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@` 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//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 diff --git a/models/github_actions.go b/models/github_actions.go index 4ca1ebf5..c415fc92 100644 --- a/models/github_actions.go +++ b/models/github_actions.go @@ -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:"-"` } @@ -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 } } } diff --git a/models/github_actions_test.go b/models/github_actions_test.go index e203621a..9468c5d8 100644 --- a/models/github_actions_test.go +++ b/models/github_actions_test.go @@ -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: @@ -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) diff --git a/opa/opa_test.go b/opa/opa_test.go index 391114ce..b141573c 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -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 diff --git a/opa/populate_checkout_unsafe_shas_test.go b/opa/populate_checkout_unsafe_shas_test.go new file mode 100644 index 00000000..14da2f6f --- /dev/null +++ b/opa/populate_checkout_unsafe_shas_test.go @@ -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, + // "", + // "", + // "", +} + +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) +} diff --git a/opa/rego/external/checkout_unsafe.rego b/opa/rego/external/checkout_unsafe.rego new file mode 100644 index 00000000..3a3e0cda --- /dev/null +++ b/opa/rego/external/checkout_unsafe.rego @@ -0,0 +1,246 @@ +package external.checkout_unsafe + +# GENERATED by `go test -tags checkout_unsafe_shas ./opa` — do not hand-edit. +# +# vulnerable_shas is the frozen set of actions/checkout commit SHAs that LACK the +# safe-default fix (i.e. are NOT a descendant of a release-line fix commit: +# v7.0.0 = 9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0, plus v4/v5/v6 backports once they land). +# default-allow: a 40-hex actions/checkout SHA NOT in this set is treated as safe. +# See untrusted_checkout_exec / poutine.utils.checkout_guard_protects. +vulnerable_shas := { + "009b9ae9e446ad8d9b8c809870b0fbcc5e03573e", + "00a3be89340a3ce8d704f82f44a5e7f9e3a84dfe", + "01a434328acfaec94cbfc1cd07b3373e4693d132", + "01aecccf739ca6ff86c0539fbc67a7a5007bbc81", + "0299a0d2b67d48224ce047d03c69693b37fe77fe", + "033fa0dc0b82693d8986f1016a0ec2c5e7d9cbb1", + "06218e4404b044c23590a921fa858fb632a41afe", + "064fe7f3312418007dea2b49a19844a9ee378f49", + "069c6959146423d11cd0184e6accf28f9d45f06e", + "08c6903cd8c0fde910a37f88322edcfb5dd907a8", + "08eba0b27e820071cde6df949e0beb9ba4906955", + "090d9c9dfda6bb13508d978c6be93801de84f967", + "0963d3b35f4826b619c2436e1bdce37c147daeaf", + "096e9277500008410ac4dd98a7bb0d9052330de8", + "09d2acae674a48949e3602304ab46fd20ae0c42f", + "0ad4b8fadaa221de15dcec353f45205ec38ea70b", + "0b496e91ec7ae4428c3ed2eeb4c3a40df431f2cc", + "0c366fd6a839edf440554fa01a7085ccba70ac98", + "0f9f3aa320cb53abeb534aeb54048075d9697a0e", + "0ffe6f9c5599e73776da5b7f113e994bc0a76ede", + "1044a6dea927916f2c38ba5aeffbc0a847b1221a", + "11bd71901bbe5b1630ceea73d27597364c9af683", + "130a169078a413d3a5246a393625e8e742f387f6", + "1433f62caac9c18949f9a498f6f28c05388ea443", + "163217dfcd28294438ea1c1c149cfaf66eec283e", + "1af3b93b6815bc44a9784bd300feb67ff0d1eeb3", + "1cce3390c2bfda521930d01229c073c7ff920824", + "1d96c772d19495a3b5c517cd2bc0cb401ea0529f", + "1e204e9a9253d643386038d443f96446fa156a97", + "1e31de5234b9f8995739874a8ce0492dc87873e2", + "1f9a0c22da41e6ebfa534300ef656657ea2c6707", + "2036a08e25fa78bbd946711a407b529a0a1204bf", + "204620207c9669dc859681b83d09302c7deb97ce", + "21dc310f1948a06cc989491cb1b4a86777f22918", + "230611dbd0eb52da1e1f4f7bc8bb0c3a339fc8b7", + "24cb9080177205b6e8c946b17badbe402adc938f", + "24ed1a352802348c9e4e8d13de9177fb95b537ba", + "2541b1294d2704b0964813337f33b291d3f8596b", + "25a956c84d5dd820d28caab9f86b8d183aeeff3d", + "2650dbd060003e3b5ae211e4358852f336b682a7", + "26d48e8ea150211a9bc3b1f0c20448599687d926", + "27135e314dd1818f797af1db9dae03a9f045786b", + "28c7f3d2b5162b5ddd3dfd9a45aa55eaf396478b", + "299dd5064ede81803aeb1ce63fee4e150d9ae5f1", + "2bd2911be9963da3ff84b7b09a28872059aa0564", + "2d1c1198e79c30cca5c3957b1e3b65ce95b5356e", + "2d7d9f7ff5b310f983d059b68785b3c74d8b8edd", + "2ff2fbdea48a8f5da77a31e7dd5ecb46c017ffc3", + "34e114876b0b11c390a56381ad16ebd13914f8d5", + "3537747199ad29df25693bc607e99df5d7726ffd", + "37b082107ba410260a3aaddf93122e04801ce631", + "3b9b8c884f6b4bb4d5be2779c26374abadae0871", + "3ba5ee6fac7e0e30e2ea884e236f282d3a775891", + "3d677ac575eac4b370e52131024fa99ee754def1", + "3df4ab11eba7bda6032a0b82a6bb43b11571feac", + "3df79e0276f4013ea6ed57534f3478cd2b1ef8c0", + "3f603f6d5e9f40714f97b2f017aa0df2a443192a", + "3fc17f8645e9648158a6d23b033ab5f62df29f3c", + "40a16ebeed7da831425b665e600750cb36b38d06", + "422dc4567157f4d62b665a8a288310365b1d194b", + "43045ae669be728bd34ed56fcd1a230c0dc4d8e2", + "442567ba5761652b13c5c842a2f959ac9da6be57", + "44679f67d234667eaeb138dbcde468669a5181a8", + "44c2b7a8a4ea60a981eaca3cf939b5f4305c123b", + "453ee27fca95fa9e03a24c1969a92c82e1a9b15e", + "473055ba18d6d2da209cd46110aadb9275e3194e", + "47fbe2df0ad0e27efb67a70beac3555f192b062f", + "4817b449b0ed7c775a0bcecaa398041ab5d09b51", + "50fbc622fc4ef5163becd7fab6573eac35f8462e", + "5126516654c75f76bca1de45dd82a3006d8890f9", + "537c7ef99cef6e5ddb5e7ff5d16d14510503801d", + "53bed0742eb3f0455187c7c7042d27f51b856f02", + "556e4c3cb0b8b54b734286d5439adadcb0a8cb92", + "56c00a7b1f53d3094df328ad4c2cd2b2d385c569", + "574281d34cf49767d4b75b691c4c4f4655e0c93f", + "58070a9fc3a91197fc9cbf24841ea31a2ab19980", + "5881116d181dc80f3ed5f395296a5579ee6fc6a4", + "592cf69a223b04e75ddf345919130b91010eb2a6", + "5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f", + "5c3ccc22eb2c950a0fa5bc7c47190d8e3f7e681a", + "61b9e3751b92087fd0b06925ba6dd6314e06f089", + "61fd8fd0c7a28ab9f73c23c595edbd0550bf0e78", + "631c7dc4f80f88219c5ee78fee08c6b62fac8da1", + "65865e15a14a3de9378a18a026ce6548b17a39ed", + "689bf84be4a745196a5c809a6afb12708ee43c3c", + "692973e3d937129bcbf40652eb9f2f61becf3332", + "6a84743051be17cee477b0a26bd866b5dba996e4", + "6b42224f41ee5dfe5395e27c8b2746f1f9955030", + "6ccd57f4c5d15bdc2fef309bd9fb6cc9db2ef1c6", + "6d193bf28034eafb982f37bd894289fe649468fc", + "6e6328ef28ba9c951379a07c83913e2acc8bc9a0", + "71cf2267d89c5cb81562390fa70a37fa40b1305e", + "722adc63f1aa60a57ec37892e133b1d319cae598", + "72f2cec99f417b1a1c5e2e88945068983b7965f9", + "7523e237893f02412c876c5511929ce0c74c348d", + "755da8c3cf115ac066823e79a1e1788f8940201b", + "7739b9ba2efcda9dde65ad1e3c2dbe65b41dfba7", + "77904fd4316d60fc138fe2b8286ca220f763853e", + "7884fcad6b5d53d10323aee724dc68d8b9096a2e", + "7990b10a0ca6be1ddd27e4d84e6dbbe788d86662", + "7b187184d12a8f064f797aeb51e4873c109637c7", + "7cdaf2fbc075e6f3b9ca94cfd6cec5adc8a75622", + "7d09575332117a40b46e5e020664df234cd416f3", + "7f00b66d06eed909da8e56729955e53d186d95ed", + "7f0669ca1fd955c0e0fd85ee8d5b8d16978be97e", + "80602fafba6e982172195b721791020f4f17a227", + "8230315d06ad95c617244d2f265d237a1682d445", + "826ba42d6c06e4d78b1b33478af7b54277e60b52", + "83b7061638ee4956cf7545a6f7efe594e5ad0247", + "8410ad0602e1e429cee44a835ae9f77f654a6694", + "8459bc0c7e3759cdf591f513d9f141a95fef0a8f", + "8461dbfed36a8af202384a5b1ee0f7f1bf947821", + "8530928916aaef40f59e6f221989ccb31f5759e7", + "85b1f35505da871133b65f059e96210c65650a8b", + "85e47d1a2bef5be8023f6dce02e0e8451938924f", + "85e6279cec87321a52edac9c87bce653a07cf6c2", + "86f86b36ef15e6570752e7175f451a512eac206b", + "885641592076c27bfb56c028cd5612cdad63e16d", + "8ade135a41bc03ea155e62e844d188df1ea18608", + "8b5e8b768746b50394015010d25e690bfab9dfbc", + "8e5e7e5ab8b370d6c329ec480221332ada57f0ab", + "8e8c483db84b4bee98b60c0593521ed34d9990e8", + "8eb1f6a495037164bea451156472f35fdd6bafc0", + "8edcb1bdb4e267140fa742c62e395cd74f332709", + "8f4b7f84864484a7bf31766abe9204da3cbe65b3", + "8f9e05e482293f862823fcca12d9eddfb3723131", + "900f2210b1d28bbbd0bd22d17926b9e224e8f231", + "93cb6efe18208431cddfb8368fd83d5badbf9bfd", + "93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8", + "94c2de77cccf605d74201a8aec6dd8fc0717ad66", + "94d077c24971944d312dd9197c1bdfba62b39878", + "96f53100ba2a5449eb71d2e6604bbcd94b9449b5", + "97a652b80035363df47baee5031ec8670b8878ac", + "97b30c411cc8e273e8f90d632b8e53d2604a90ca", + "9839dc14a02ddc6b6995e69eb3ecb98132fc8b6b", + "9a3a9ade8222dcdf9d3c77710b10df47ee7c7c89", + "9a9194f87191a7e9055e3e9b95b8cfb13023bb08", + "9b4c13b0bfa31b4514c14f74b5a166c2708f43c6", + "9bb56186c3b09b4f86b1c65136769dd318469633", + "9c1e94e0ad997d618b6113a2171b055037589028", + "9f265659d3bb64ab1440b03b12f4d47a24320917", + "a12a3943b4bdde767164f792f33f40b04645d846", + "a14471d838f6a7ce15cab8740f25e337c51e7cad", + "a4b69b48862e969425d8dc115dcb965f288ea29b", + "a572f640b07e96fc5837b3adfa0e5a2ddd8dae21", + "a5ac7e51b41094c92402da3b24376905380afc29", + "a6747255bd19d7a757dbdda8c654a9f84db19839", + "a81bbbf8298c0fa03ea29cdc473d45769f953675", + "aabbfeb2ce60b5bd82389903509092c4648a9713", + "aadec899646c8e0f34c52d9219c2faac36626b55", + "ac455590d1debd05854e62f352cf7a59e27328bc", + "ac593985615ec2ede58e132d2e21d2b1cbd6127c", + "add3486cc3b55d4a5e11c8045058cef96538edc7", + "ae525b22625099736a2909d0eb22ec50cbe398fc", + "af513c7a016048ae468971c52ed77d9562c7c819", + "afe4af09a72596f47d806ee5f8b2674ec07fdc73", + "b17fe1e4d59a9d1d95a7aead5e6fcd13e50939a5", + "b1ec3021b8fa02164da82ca1557d017d83b0e179", + "b2e6b7ed13bcde9d37c9e3e6967cd3ecfd2807ad", + "b2eb13baee0ef6ef21737c8cf4a6a32f4e002442", + "b32f140b0c872d58512e0a66172253c302617b90", + "b4483adec309c0d01a5435c5e24eb40de5773ad9", + "b4626ce19ce1106186ddf9bb20e706842f11a7c3", + "b4b537b06a577732e04b29acc7294f645c135da0", + "b4ffde65f46336ab88eb53be808477a3936bae11", + "b6849436894e144dbce29d7d7fda2ae3bf9d8365", + "b80ff79f1755d06ba70441c368a6fe801f5f3a62", + "bc50a995b88ec9334cb2f3b1c49502d9834ed2c5", + "be0f44845645e415725af198163a96fea9e54334", + "be6c44d969b1b004a9e0f7853e9cc9977ea0f7f0", + "bf085276cecdb0cc76fbbe0687a5a0e786646936", + "bf4af63534d79cb0712d8c86d8f2404844fa5a79", + "c170eefc2657d93cc91397be50a299bff978a052", + "c2d88d3ecc89a9ef08eebf45d9637801dcee7eb5", + "c49af7ca1f339b07a5baac8c8bfc49a5248f31d3", + "c533a0a4cfc4962971818edcfac47a2899e69799", + "c85684db76ba6ef08713c10cf4befe3318887415", + "c85c95e3d7251135ab7dc9ce3241c5835cc595a9", + "c952173edf28a2bd22e1a4926590c1ac39630461", + "cab31617d857bf9e70dc35fd9e4dafe350794082", + "cacfc4155de4db33162bcb6c82700751fda6bd91", + "cbb722410c2e876e24abbe8de2cc27693e501dcb", + "cc70598ce853d5d678b2440190cb18368e5cee8c", + "cd6a9fd49371476d813e892956e2e920fcc3fb7e", + "cd7d8d697e10461458bc61a30d094dc601a8b017", + "d106d4669b3bfcb17f11f83f98e1cab478e9f635", + "d50f8ea76748df49594d9b109b614f3b4db63c71", + "d632683dd7b4114ad314bca15554477dd762a938", + "d914b262ffc244530a203ab40decab34c3abf34d", + "dac8cc78a1c612c854c383833b39e5a3f357b1f5", + "db0cee9a514becbbd4a101a5fbbbf47865ee316c", + "db41740e12847bb616a339b75eb9414e711417df", + "dc323e67f16fb5f7663d20ff7941f27f5809e9b6", + "dcd71f646680f2efd8db4afa5ad64fdcba30e748", + "dd960bd3c3f080561a1810e32349ac211ecec7d4", + "de0fac2e4500dabe0009e67214ff5f5447ce83dd", + "de5a000abf73b6f4965bd1bcdf8f8d94a56ea815", + "df0bcddf6d6823307c716b56a7ef9c3b25078874", + "df4cb1c069e1874edd31b4311f1884172cec0e10", + "df86c829ebbc4e80aa9885a4762d84e11e0eeacb", + "dfd70d4a2dece5f4a1af8dc99e1508a16e916b60", + "e2f20e631ae6d7dd3b768f56a5d2af784dd54791", + "e347bba93bdcadab0b55e4b333254f9bb40bdb0c", + "e3bc06d98631ce7e0e3db6bd158fafe028709e9f", + "e3d2460bbb42d7710191569f88069044cfb9d8cf", + "e52d022eb52c224e5f2201beb687a66849e3b200", + "e6d535c99c374d0c3f6d8cd8086a57b43c6c700a", + "e8bd1dffb6451bb0d84dbcd3ed059daca1371180", + "eb35239ec22e9029a5be28f8c41e67452f615f0f", + "eb8a193c1dbf4bbb2053320cef52bacc1a485839", + "ec3a7ce113134d7a93b817d10a8272cb61118579", + "eccf386318b560bdd401913a9fe3cca56dc369d6", + "ee0669bd1cc54295c223e0bb666b733df41de1c5", + "eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871", + "f0282184c7ce73ab54c7e4ab5a617122602e575f", + "f095bcc56b7c2baf48f3ac70d6d6782f4f553222", + "f2190623701cfebaaf26554b39e1e29cc4a1f2fb", + "f25a3a9f25bd5f4c5d77189cab02ff357b5aedeb", + "f43a0e5ff2bd294095638e18286ca9a3d1956744", + "f466b96953a8e78166c9b97a44a70220f1a3e77e", + "f67ee5d6224ccd5af1909d53ea1faa5efb91db29", + "f6ce2afa7079cb075a124c93c79d61779d845782", + "f858c22e963bc60bc9d01c3d105c52a45a7deb6e", + "f90c7b395dac7c5a277c1a6d93d5057c1cddb74e", + "f95f2a38561736d1542cb9fbf736eea3d00ab5a6", + "f9e715a95fcd1f9253f77dd28f11e88d2d6460c7", + "fb6f360df236bd2026c7963cf88c8ddf20b4f0e2", + "fbb30c60ab3f94a2c03755a7fb875120c137bef7", + "fd084cde189b7b76ec305d52e27be545a0172823", + "fd47087372161c6f2a7b96d2ef87e944d89023ed", + "ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493", +} + +# RFC3339 UTC. On/after this date the floating v4/v5/v6 major tags and releases/v4..6 +# branches auto-update to a fixed version (GitHub backport). Compared lexicographically. +backport_floor_date := "2026-07-16T00:00:00Z" diff --git a/opa/rego/poutine/utils.rego b/opa/rego/poutine/utils.rego index 9b0ba65b..c15c22b2 100644 --- a/opa/rego/poutine/utils.rego +++ b/opa/rego/poutine/utils.rego @@ -1,5 +1,6 @@ package poutine.utils +import data.external.checkout_unsafe import rego.v1 unpinned_github_action(purl) if { @@ -20,16 +21,88 @@ unpinned_purl(purl) if { } find_pr_checkouts(workflow) := xs if { - xs := {{"job_idx": j, "step_idx": i, "workflow": workflow} | + xs := (({{"job_idx": j, "step_idx": i, "workflow": workflow} | s := workflow.jobs[j].steps[i] startswith(s.uses, "actions/checkout@") contains(s.with_ref, "${{") } | {{"job_idx": j, "step_idx": i, "workflow": workflow} | s := workflow.jobs[j].steps[i] regex.match("gh pr checkout ", s.run) + }) | {{"job_idx": j, "step_idx": i, "workflow": workflow} | + # raw git fetch of a PR ref (pull/N/head|merge, refs/pull/...) — still in scope per + # GitHub's "What's not changing" note; never neutralized by the checkout safe default. + s := workflow.jobs[j].steps[i] + regex.match(`git fetch\b.*\bpull/.*/(head|merge)\b`, s.run) + }) | {{"job_idx": j, "step_idx": i, "workflow": workflow} | + # raw git checkout/switch of an untrusted head ref. + s := workflow.jobs[j].steps[i] + regex.match(`git (checkout|switch)\b.*(github\.event\.(pull_request|issue|workflow_run)|\bhead\.sha\b|\bhead\.ref\b|\bhead_sha\b)`, s.run) } } +# checkout_is_action is true when a find_pr_checkouts member is an `actions/checkout@…` step +# (vs a `gh`/`git` run-block). Only action checkouts can be neutralized by the safe default; +# run-block vectors always fire. +checkout_is_action(checkout) if { + step := checkout.workflow.jobs[checkout.job_idx].steps[checkout.step_idx] + startswith(step.uses, "actions/checkout@") +} + +# checkout_guard_protects is true when an actions/checkout step is on a fixed version (the +# safe default refuses unsafe fork-PR checkouts) AND allow-unsafe-pr-checkout is not enabled. +checkout_guard_protects(step, scan_date) if { + startswith(step.uses, "actions/checkout@") + ref := substring(step.uses, count("actions/checkout@"), -1) + _checkout_ref_safe(ref, scan_date) + _allow_unsafe_is_default(step) +} + +# allow-unsafe-pr-checkout is at its safe default when absent or set to anything the runner +# does not treat as true. A `${{ … }}` expression is treated as possibly-true (not default). +_allow_unsafe_is_default(step) if not step.with_allow_unsafe_pr_checkout + +_allow_unsafe_is_default(step) if { + v := step.with_allow_unsafe_pr_checkout + not contains(v, "${{") + lower(trim_space(v)) != "true" +} + +# _checkout_ref_safe: a 40-hex SHA is safe iff NOT in the frozen vulnerable set (default-allow); +# a tag/branch is safe only if explicitly recognized as a fixed line (default-deny). +_checkout_ref_safe(ref, _) if { + regex.match("^[a-fA-F0-9]{40}$", ref) + not checkout_unsafe.vulnerable_shas[lower(ref)] +} + +_checkout_ref_safe(ref, scan_date) if { + not regex.match("^[a-fA-F0-9]{40}$", ref) + _ref_tag_safe(ref, scan_date) +} + +_ref_tag_safe(ref, _) if _ref_major(ref) >= 7 # v7, v7.x, v7.x.x, v8… + +_ref_tag_safe("main", _) := true # tip of main carries the fix + +_ref_tag_safe(ref, scan_date) if { + ref in {"v4", "v5", "v6"} # floating major tags auto-update on the backport date + scan_date >= checkout_unsafe.backport_floor_date +} + +_ref_tag_safe(ref, scan_date) if { + ref in {"releases/v4", "releases/v5", "releases/v6"} + scan_date >= checkout_unsafe.backport_floor_date +} + +# v1/v2/v3 (+ their tags) and pinned v4.x.x/v5.x.x/v6.x.x: no clause → not safe → fire. +# (Add a semver.constraint_check clause for the v4/v5/v6 backport floor versions once known, +# behind a strict ^v\d+\.\d+\.\d+$ guard so a malformed tag cannot error eval.) + +# _ref_major is the integer major for a tag like v7 / v7.1 / v7.1.2; undefined otherwise. +_ref_major(ref) := to_number(matches[0][1]) if { + matches := regex.find_all_string_submatch_n(`^v([0-9]+)(\.[0-9]+){0,2}$`, ref, 1) + count(matches) == 1 +} + workflow_steps_after(options) := steps if { steps := {{"step": s, "job_idx": options.job_idx, "step_idx": k} | s := options.workflow.jobs[options.job_idx].steps[k] @@ -56,7 +129,7 @@ empty(xs) if { count(xs) == 0 } -workflow_run_parents(pkg, workflow) = parents if { +workflow_run_parents(pkg, workflow) := parents if { parent_names = {name | event := workflow.events[_] event.name == "workflow_run" @@ -68,7 +141,7 @@ workflow_run_parents(pkg, workflow) = parents if { } } -to_set(xs) = xs if { +to_set(xs) := xs if { is_set(xs) } else := {v | v := xs[_]} if { is_array(xs) @@ -145,7 +218,6 @@ job_steps_before(options) := steps if { } } - ######################################################################## # find_first_uses_in_job ######################################################################## @@ -186,7 +258,7 @@ _secrets_bracket_double(str) := {m[1] | } extract_referenced_secrets(str) := sort(secrets) if { - secrets := _secrets_dot_notation(str) | _secrets_bracket_single(str) | _secrets_bracket_double(str) + secrets := (_secrets_dot_notation(str) | _secrets_bracket_single(str)) | _secrets_bracket_double(str) } # Extract secrets from a job by marshaling to JSON and searching diff --git a/opa/rego/rules/untrusted_checkout_exec.rego b/opa/rego/rules/untrusted_checkout_exec.rego index e138e061..b242d30e 100644 --- a/opa/rego/rules/untrusted_checkout_exec.rego +++ b/opa/rego/rules/untrusted_checkout_exec.rego @@ -27,37 +27,36 @@ github.workflow_run.parent.events contains event if some event in { "issue_comment", } -build_github_actions[action] = { - "bundler":{"ruby/setup-ruby"}, - "cargo":{"actions-rs/cargo"}, - "checkov":{"bridgecrewio/checkov-action"}, - "docker":{"docker/build-push-action", "docker/setup-buildx-action"}, - "eslint":{"reviewdog/action-eslint", "stefanoeb/eslint-action", "tj-actions/eslint-changed-files", "sibiraj-s/action-eslint", "tinovyatkin/action-eslint", "bradennapier/eslint-plus-action", "CatChen/eslint-suggestion-action", "iCrawl/action-eslint", "ninosaurus/eslint-check"}, - "golangci-lint":{"golangci/golangci-lint-action"}, +build_github_actions[action] := { + "bundler": {"ruby/setup-ruby"}, + "cargo": {"actions-rs/cargo"}, + "checkov": {"bridgecrewio/checkov-action"}, + "docker": {"docker/build-push-action", "docker/setup-buildx-action"}, + "eslint": {"reviewdog/action-eslint", "stefanoeb/eslint-action", "tj-actions/eslint-changed-files", "sibiraj-s/action-eslint", "tinovyatkin/action-eslint", "bradennapier/eslint-plus-action", "CatChen/eslint-suggestion-action", "iCrawl/action-eslint", "ninosaurus/eslint-check"}, + "golangci-lint": {"golangci/golangci-lint-action"}, "goreleaser": {"goreleaser/goreleaser-action"}, "gradle": {"gradle/gradle-build-action"}, "maven": {"qcastel/github-actions-maven-release", "samuelmeuli/action-maven-publish", "LucaFeger/action-maven-cli"}, - "megalinter":{"oxsecurity/megalinter"}, + "megalinter": {"oxsecurity/megalinter"}, "mkdocs": {"mhausenblas/mkdocs-deploy-gh-pages", "athackst/mkdocs-simple-plugin"}, "msbuild": {"MVS-Telecom/publish-nuget"}, "mypy": {"ricardochaves/python-lint", "jpetrucciani/mypy-check", "sunnysid3up/python-linter", "tsuyoshicho/action-mypy"}, - "npm": {"actions/setup-node","JS-DevTools/npm-publish"}, - "phpstan":{"php-actions/phpstan"}, + "npm": {"actions/setup-node", "JS-DevTools/npm-publish"}, + "phpstan": {"php-actions/phpstan"}, "pip": {"brettcannon/pip-secure-install", "BSFishy/pip-action"}, - "pre-commit": {"dbt-checkpoint/dbt-checkpoint", "pre-commit/action", "pre-commit-ci/lite-action", "browniebroke/pre-commit-autoupdate-action", "cloudposse/github-action-pre-commit"}, - "pre-commit":{"pre-commit/action"}, + "pre-commit": {"pre-commit/action"}, "python": {"hynek/build-and-inspect-python-package"}, "rake": {"magefile/mage-action"}, "rubocop": {"reviewdog/action-rubocop", "andrewmcodes-archive/rubocop-linter-action", "gimenete/rubocop-action", "r7kamura/rubocop-todo-corrector"}, "sonar-scanner": {"sonarsource/sonarqube-scan-action"}, - "stylelint":{"actions-hub/stylelint"}, + "stylelint": {"actions-hub/stylelint"}, "terraform": {"OP5dev/TF-via-PR", "dflook/terraform-plan", "dflook/terraform-apply"}, "tflint": {"reviewdog/action-tflint", "devops-infra/action-tflint"}, "tofu": {"dflook/tofu-plan", "dflook/tofu-apply"}, "vale": {"gaurav-nelson/github-action-vale-lint", "errata-ai/vale-action"}, }[action] -build_commands[cmd] = { +build_commands[cmd] := { "ant": {"^ant "}, "bash": {"\\S+\\.sh\\b"}, "bundler": {"bundle install", "bundle exec "}, @@ -69,13 +68,13 @@ build_commands[cmd] = { "go generate": {"go generate"}, "gomplate": {"gomplate "}, "goreleaser": {"goreleaser build", "goreleaser release"}, - "gradle": {"gradle ", "./gradlew ", "./gradlew.bat "}, # https://docs.gradle.org/current/userguide/gradle_wrapper_basics.html + "gradle": {"gradle ", "./gradlew ", "./gradlew.bat "}, # https://docs.gradle.org/current/userguide/gradle_wrapper_basics.html "make": {"make "}, "maven": {"mvn ", "./mvnw ", "./mvnw.bat", "./mvnw.cmd", "./mvnw.sh "}, # https://maven.apache.org/wrapper/ "mkdocs": {"mkdocs build"}, "msbuild": {"msbuild "}, "mypy": {"mypy "}, - "npm": {"npm diff", "npm restart", "npm (rum|urn|run(-script)?)", "npm start", "npm stop", "npm t(e?st)?", "npm ver(si|is)on","npm (install|add|i|in|ins|inst|insta|instal|inst|isnta|isntal|isntall)", "npm ci(\\b|$)"}, + "npm": {"npm diff", "npm restart", "npm (rum|urn|run(-script)?)", "npm start", "npm stop", "npm t(e?st)?", "npm ver(si|is)on", "npm (install|add|i|in|ins|inst|insta|instal|inst|isnta|isntal|isntall)", "npm ci(\\b|$)"}, "phpstan": {"phpstan "}, "pip": {"pip install", "pipenv install", "pipenv run "}, "powershell": {"\\S+\\.ps1\\b"}, @@ -131,7 +130,6 @@ results contains poutine.finding(rule, pkg_purl, { ) } - results contains poutine.finding(rule, pkg_purl, { "path": workflow_path, "line": step.lines.uses, @@ -152,6 +150,43 @@ _lotp_targets_meta(cmd, content) := {"lotp_targets": targets} if { targets := utils.resolve_lotp_targets(cmd, content) } else := {} +# Scan time (RFC3339 UTC), injected by the scanner; absent => far past => fail-safe (fires). +scan_date := object.get(input, "scan_time", "0001-01-01T00:00:00Z") + +# Events for which actions/checkout's safe default actually refuses the unsafe fork-PR +# checkout. The guard does nothing for issue_comment / issues / workflow_call / plain +# pull_request, so a finding under those keeps firing even on a fixed checkout version. +_direct_guarded_events := {"pull_request_target"} + +_parent_guarded_events := {"pull_request_target", "pull_request"} + +# A direct-event finding is neutralized when the checkout is guard-protected AND every flagged +# trigger event is one the guard covers. +_neutralized_direct(workflow, checkout) if { + utils.checkout_guard_protects(workflow.jobs[checkout.job_idx].steps[checkout.step_idx], scan_date) + flagged := {e | some i; e := workflow.events[i].name; e in github.events} + count(flagged) > 0 + every e in flagged { e in _direct_guarded_events } +} + +# A workflow_run finding is neutralized when the checkout is guard-protected AND every matched +# parent (triggering) event is a pull_request* event the guard covers. +_neutralized_parent(checkout, parent_events) if { + utils.checkout_guard_protects(checkout.workflow.jobs[checkout.job_idx].steps[checkout.step_idx], scan_date) + count(parent_events) > 0 + every e in parent_events { e in _parent_guarded_events } +} + +# Steps to scan for dangerous build commands after a checkout. actions/checkout puts the build +# in a later step; gh/git run-block checkouts usually fetch+build in one script, so include the +# checkout step itself for those. +_steps_to_scan(checkout) := utils.workflow_steps_after(checkout) | _run_block_self_step(checkout) + +_run_block_self_step(checkout) := {{"step": step, "job_idx": checkout.job_idx, "step_idx": checkout.step_idx}} if { + not utils.checkout_is_action(checkout) + step := checkout.workflow.jobs[checkout.job_idx].steps[checkout.step_idx] +} else := set() + _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[_] @@ -160,23 +195,26 @@ _steps_after_untrusted_checkout contains [pkg.purl, workflow.path, events, s.ste events := [event | event := workflow.events[i].name] pr_checkout := utils.find_pr_checkouts(workflow)[_] - s := utils.workflow_steps_after(pr_checkout)[_] + not _neutralized_direct(workflow, pr_checkout) + s := _steps_to_scan(pr_checkout)[_] } _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_purl, workflow] := _workflows_runs_from_pr[_] + [pkg_purl, workflow, parent_events] := _workflows_runs_from_pr[_] events := [event | event := workflow.events[i].name] pr_checkout := utils.find_pr_checkouts(workflow)[_] - s := utils.workflow_steps_after(pr_checkout)[_] + not _neutralized_parent(pr_checkout, parent_events) + s := _steps_to_scan(pr_checkout)[_] } -_workflows_runs_from_pr contains [pkg.purl, workflow] if { +_workflows_runs_from_pr contains [pkg.purl, workflow, parent_events] if { pkg := input.packages[_] workflow := pkg.github_actions_workflows[_] parent := utils.workflow_run_parents(pkg, workflow)[_] utils.filter_workflow_events(parent, github.workflow_run.parent.events) + parent_events := {e | some i; e := parent.events[i].name; e in github.workflow_run.parent.events} } # Azure Devops diff --git a/scanner/inventory.go b/scanner/inventory.go index 8e513881..c0b6f805 100644 --- a/scanner/inventory.go +++ b/scanner/inventory.go @@ -3,6 +3,8 @@ package scanner import ( "context" "fmt" + "os" + "time" "github.com/boostsecurityio/poutine/models" "github.com/boostsecurityio/poutine/opa" @@ -19,6 +21,9 @@ type Inventory struct { pkgsupplyClient ReputationClient providerVersion string provider string + // now returns the scan time, exposed to rules as input.scan_time. It governs time-dependent + // rules such as untrusted_checkout_exec (the actions/checkout v4/v5/v6 backport date-gate). + now func() time.Time } func NewInventory(opa *opa.Opa, pkgSupplyClient ReputationClient, provider string, providerVersion string) *Inventory { @@ -27,9 +32,22 @@ func NewInventory(opa *opa.Opa, pkgSupplyClient ReputationClient, provider strin pkgsupplyClient: pkgSupplyClient, provider: provider, providerVersion: providerVersion, + now: defaultScanClock(), } } +// defaultScanClock returns time.Now, unless POUTINE_SCAN_TIME is set to an RFC3339 timestamp +// (for reproducible / "as-of" scans and deterministic tests), in which case that fixed time is +// used. An unparseable value falls back to time.Now. +func defaultScanClock() func() time.Time { + if v := os.Getenv("POUTINE_SCAN_TIME"); v != "" { + if t, err := time.Parse(time.RFC3339, v); err == nil { + return func() time.Time { return t } + } + } + return time.Now +} + type InventoryScannerI interface { Run(pkgInsights *models.PackageInsights) error } @@ -113,6 +131,7 @@ func (i *Inventory) analyzePackageForFindings(ctx context.Context, pkgInsights m "reputation": reputation, "provider": i.provider, "version": i.providerVersion, + "scan_time": i.now().UTC().Format(time.RFC3339), }, analysisResults, ) diff --git a/scanner/testdata_checkout/.github/workflows/gh_pr_checkout.yml b/scanner/testdata_checkout/.github/workflows/gh_pr_checkout.yml new file mode 100644 index 00000000..35ecba2a --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/gh_pr_checkout.yml @@ -0,0 +1,8 @@ +name: gh-pr-checkout +on: pull_request_target +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: gh pr checkout ${{ github.event.pull_request.number }} + - run: make build diff --git a/scanner/testdata_checkout/.github/workflows/git_benign.yml b/scanner/testdata_checkout/.github/workflows/git_benign.yml new file mode 100644 index 00000000..09126449 --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/git_benign.yml @@ -0,0 +1,11 @@ +name: git-benign +on: pull_request_target +jobs: + build: + runs-on: ubuntu-latest + steps: + # benign git usage with no untrusted PR ref must NOT be detected as a checkout + - run: | + git fetch --tags origin main + git checkout main + make build diff --git a/scanner/testdata_checkout/.github/workflows/git_vectors.yml b/scanner/testdata_checkout/.github/workflows/git_vectors.yml new file mode 100644 index 00000000..fa1fa610 --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/git_vectors.yml @@ -0,0 +1,15 @@ +name: git-vectors +on: pull_request_target +jobs: + samestep: + runs-on: ubuntu-latest + steps: + - run: | + git fetch origin pull/${{ github.event.number }}/head + git checkout FETCH_HEAD + make build + headsha: + runs-on: ubuntu-latest + steps: + - run: git checkout ${{ github.event.pull_request.head.sha }} + - run: npm install diff --git a/scanner/testdata_checkout/.github/workflows/multi_event.yml b/scanner/testdata_checkout/.github/workflows/multi_event.yml new file mode 100644 index 00000000..a82bfc8e --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/multi_event.yml @@ -0,0 +1,13 @@ +name: multi-event +on: + pull_request_target: + issue_comment: + types: [created] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + - run: npm install diff --git a/scanner/testdata_checkout/.github/workflows/prt_matrix.yml b/scanner/testdata_checkout/.github/workflows/prt_matrix.yml new file mode 100644 index 00000000..2c153ad4 --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/prt_matrix.yml @@ -0,0 +1,54 @@ +name: prt-matrix +on: pull_request_target +jobs: + safe_v7: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + - run: npm install + unsafe_true: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + allow-unsafe-pr-checkout: true + - run: npm install + unsafe_expr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.head_ref }} + allow-unsafe-pr-checkout: ${{ inputs.danger }} + - run: npm install + old_v3: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.head_ref }} + - run: npm install + safe_sha: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 + with: + ref: ${{ github.head_ref }} + - run: npm install + vuln_sha: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.head_ref }} + - run: npm install + v4_gated: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + - run: npm install diff --git a/scanner/testdata_checkout/.github/workflows/wr_child_comment.yml b/scanner/testdata_checkout/.github/workflows/wr_child_comment.yml new file mode 100644 index 00000000..1219bc46 --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/wr_child_comment.yml @@ -0,0 +1,13 @@ +name: WR after Comment Handler +on: + workflow_run: + workflows: ["Comment Handler"] + types: [completed] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - run: npm install diff --git a/scanner/testdata_checkout/.github/workflows/wr_child_pr.yml b/scanner/testdata_checkout/.github/workflows/wr_child_pr.yml new file mode 100644 index 00000000..4279753d --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/wr_child_pr.yml @@ -0,0 +1,13 @@ +name: WR after PR Build +on: + workflow_run: + workflows: ["PR Build"] + types: [completed] +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.workflow_run.head_sha }} + - run: npm install diff --git a/scanner/testdata_checkout/.github/workflows/wr_parent_comment.yml b/scanner/testdata_checkout/.github/workflows/wr_parent_comment.yml new file mode 100644 index 00000000..cf14eef2 --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/wr_parent_comment.yml @@ -0,0 +1,9 @@ +name: Comment Handler +on: + issue_comment: + types: [created] +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo handle diff --git a/scanner/testdata_checkout/.github/workflows/wr_parent_pr.yml b/scanner/testdata_checkout/.github/workflows/wr_parent_pr.yml new file mode 100644 index 00000000..4699ae6a --- /dev/null +++ b/scanner/testdata_checkout/.github/workflows/wr_parent_pr.yml @@ -0,0 +1,7 @@ +name: PR Build +on: pull_request +jobs: + build: + runs-on: ubuntu-latest + steps: + - run: echo build diff --git a/scanner/untrusted_checkout_guard_test.go b/scanner/untrusted_checkout_guard_test.go new file mode 100644 index 00000000..0cffd759 --- /dev/null +++ b/scanner/untrusted_checkout_guard_test.go @@ -0,0 +1,95 @@ +package scanner + +import ( + "context" + "os" + "sort" + "testing" + "time" + + "github.com/boostsecurityio/poutine/models" + "github.com/boostsecurityio/poutine/opa" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMain pins the scan clock for the scanner package so date-sensitive rules — namely the +// actions/checkout v4/v5/v6 backport gate in untrusted_checkout_exec — stay deterministic +// regardless of when the suite runs (it must not start failing on 2026-07-16). Tests that need +// a specific instant override inventory.now directly (see firedCheckoutKeys). +func TestMain(m *testing.M) { + if os.Getenv("POUTINE_SCAN_TIME") == "" { + _ = os.Setenv("POUTINE_SCAN_TIME", "2026-06-19T00:00:00Z") + } + os.Exit(m.Run()) +} + +// firedCheckoutKeys scans testdata_checkout with a pinned scan time and returns the set of +// "::" keys that produced an untrusted_checkout_exec finding. +func firedCheckoutKeys(t *testing.T, scanTime string) []string { + t.Helper() + o, err := opa.NewOpa(context.TODO(), &models.Config{Include: []models.ConfigInclude{}}) + require.NoError(t, err) + + i := NewInventory(o, nil, "github", "") + at, err := time.Parse(time.RFC3339, scanTime) + require.NoError(t, err) + i.now = func() time.Time { return at } + + pkg := &models.PackageInsights{Purl: "pkg:github/org/owner", SourceGitRepo: "org/owner", SourceGitRef: "main"} + require.NoError(t, pkg.NormalizePurl()) + + scanned, err := i.ScanPackage(context.Background(), *pkg, "testdata_checkout") + require.NoError(t, err) + + seen := map[string]bool{} + for _, f := range scanned.FindingsResults.Findings { + if f.RuleId == "untrusted_checkout_exec" { + seen[f.Meta.Path+"::"+f.Meta.Job] = true + } + } + keys := make([]string, 0, len(seen)) + for k := range seen { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +func TestUntrustedCheckoutGuard(t *testing.T) { + const wf = ".github/workflows/" + + // Date-independent expectations (true regardless of the backport date). + alwaysFires := []string{ + wf + "prt_matrix.yml::unsafe_true", // allow-unsafe-pr-checkout: true + wf + "prt_matrix.yml::unsafe_expr", // allow-unsafe-pr-checkout: ${{ expr }} => possibly true + wf + "prt_matrix.yml::old_v3", // v3 never gets the fix + wf + "prt_matrix.yml::vuln_sha", // SHA in the frozen vulnerable set + wf + "multi_event.yml::build", // issue_comment is not guard-covered + wf + "gh_pr_checkout.yml::build", // gh pr checkout never suppressed + wf + "git_vectors.yml::samestep", // git fetch pull/N/head + build in one step + wf + "git_vectors.yml::headsha", // git checkout of head.sha + wf + "wr_child_comment.yml::build", // workflow_run whose parent is issue_comment + } + neverFires := []string{ + wf + "prt_matrix.yml::safe_v7", // v7 fixed + wf + "prt_matrix.yml::safe_sha", // v7.0.0 SHA, not in vulnerable set + wf + "git_benign.yml::build", // benign git, no PR ref + wf + "wr_child_pr.yml::build", // workflow_run whose parent is pull_request (covered) + } + + t.Run("before backport date (v4/v5/v6 still vulnerable)", func(t *testing.T) { + fired := firedCheckoutKeys(t, "2026-06-19T00:00:00Z") + want := append(append([]string{}, alwaysFires...), wf+"prt_matrix.yml::v4_gated") + assert.ElementsMatch(t, want, fired) + for _, k := range neverFires { + assert.NotContains(t, fired, k) + } + }) + + t.Run("after backport date (v4/v5/v6 auto-updated)", func(t *testing.T) { + fired := firedCheckoutKeys(t, "2026-08-01T00:00:00Z") + assert.ElementsMatch(t, alwaysFires, fired) // v4_gated now suppressed + assert.NotContains(t, fired, wf+"prt_matrix.yml::v4_gated") + }) +} diff --git a/test/snapshot/snapshot_test.go b/test/snapshot/snapshot_test.go index 7a6277b0..ac557fb9 100644 --- a/test/snapshot/snapshot_test.go +++ b/test/snapshot/snapshot_test.go @@ -23,6 +23,12 @@ import ( func setupAnalyzer(t *testing.T, command string, buf *bytes.Buffer) *analyze.Analyzer { t.Helper() + // Pin the scan clock so the actions/checkout v4/v5/v6 backport date-gate in + // untrusted_checkout_exec is deterministic and the snapshot does not flip on 2026-07-16. + if os.Getenv("POUTINE_SCAN_TIME") == "" { + t.Setenv("POUTINE_SCAN_TIME", "2026-06-19T00:00:00Z") + } + token := os.Getenv("GH_TOKEN") if token == "" { t.Skip("GH_TOKEN not set, skipping snapshot test")