From 97f000b461251cec288e4f97840cf811be767d7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 27 Aug 2026 09:43:09 -0400 Subject: [PATCH 1/4] Update untrusted_checkout_exec to respect actions/checkout new behavior --- cmd/handle_analyze_manifest_test.go | 2 +- .../en/rules/untrusted_checkout_exec.md | 3 + opa/opa_test.go | 221 +++++++++++++++- opa/rego/poutine/checkout_guard_data.rego | 246 ++++++++++++++++++ opa/rego/poutine/utils.rego | 132 ++++++++++ opa/rego/rules/untrusted_checkout_exec.rego | 5 + scanner/inventory_test.go | 15 +- .../.github/workflows/test_new_fields.yml | 10 + 8 files changed, 626 insertions(+), 8 deletions(-) create mode 100644 opa/rego/poutine/checkout_guard_data.rego diff --git a/cmd/handle_analyze_manifest_test.go b/cmd/handle_analyze_manifest_test.go index c19fb80f..8b3b2e55 100644 --- a/cmd/handle_analyze_manifest_test.go +++ b/cmd/handle_analyze_manifest_test.go @@ -290,7 +290,7 @@ jobs: err = json.Unmarshal([]byte(contentText), &insights) require.NoError(t, err) - assert.Len(t, insights.Findings, 3, "Found findings") + assert.Len(t, insights.Findings, 1, "Found findings") t.Logf("Successfully analyzed manifest with %d findings", len(insights.Findings)) } diff --git a/docs/content/en/rules/untrusted_checkout_exec.md b/docs/content/en/rules/untrusted_checkout_exec.md index 647c274c..bf987e94 100644 --- a/docs/content/en/rules/untrusted_checkout_exec.md +++ b/docs/content/en/rules/untrusted_checkout_exec.md @@ -10,6 +10,8 @@ severity: error The workflow appears to checkout untrusted code from a fork and uses a command that is known to allow code execution. +Supported `actions/checkout` releases refuse fork pull request code by default for `pull_request_target` and pull-request-originated `workflow_run` events. Poutine suppresses this finding when that guard applies. The finding remains when the checkout uses `allow-unsafe-pr-checkout: true`, a dynamic value for that input, a known vulnerable commit or a semantic version below its release line's fixed version, or the workflow also uses an event that the guard does not cover. Moving major tags from v2 onward and branch-like refs are assumed to contain the guard. + Using workflows with `pull_request_target` has the added benefit (as opposed to `pull_request`) of allowing access to secrets even in forked repositories. There can be good reasons to do so if you need to use API Keys to talk to some external services or want to interact with the GitHub API with `write` permissions. However, this comes at the cost of paying extra attention to the tools you use in your workflow. 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. @@ -170,6 +172,7 @@ jobs: with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} + allow-unsafe-pr-checkout: true # Explicitly disables the fork pull request guard # (5) Persisting credentials is not necessary - Though this is not a panacea, credentials can still be dumped from memory # (6) Checking untrusted code in default workspace path - In this scenario, it's good to explicitely define the path with untrusted code - name: Install dependencies diff --git a/opa/opa_test.go b/opa/opa_test.go index 391114ce..b4433ee6 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -2,14 +2,17 @@ package opa import ( "context" + "crypto/sha256" "embed" + "fmt" + "strings" + "testing" + "github.com/boostsecurityio/poutine/models" "github.com/boostsecurityio/poutine/results" "github.com/open-policy-agent/opa/v1/ast" - - "fmt" "github.com/stretchr/testify/assert" - "testing" + "github.com/stretchr/testify/require" ) //go:embed testdata/embedded @@ -158,6 +161,218 @@ func TestJobUsesSelfHostedRunner(t *testing.T) { } } +func TestCheckoutForkPRGuard(t *testing.T) { + o, err := NewOpa(context.TODO(), &models.Config{ + Include: []models.ConfigInclude{}, + }) + require.NoError(t, err) + + tests := []struct { + name string + input map[string]interface{} + want bool + }{ + { + name: "fixed release on pull request target", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4", + nil, + nil, + ), + want: true, + }, + { + name: "fixed commit on pull request target", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a", + nil, + nil, + ), + want: true, + }, + { + name: "vulnerable commit", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@1e31de5234b9f8995739874a8ce0492dc87873e2", + nil, + nil, + ), + }, + { + name: "vulnerable version ref", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4.3.1", + nil, + nil, + ), + }, + { + name: "unlisted version below fixed floor", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4.3.2", + nil, + nil, + ), + }, + { + name: "major minor below fixed floor", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4.3", + nil, + nil, + ), + }, + { + name: "major minor at fixed floor", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4.4", + nil, + nil, + ), + want: true, + }, + { + name: "vulnerable v1 major ref", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v1", + nil, + nil, + ), + }, + { + name: "unknown version ref", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v8", + nil, + nil, + ), + want: true, + }, + { + name: "future semantic version ref", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v8.0.0", + nil, + nil, + ), + want: true, + }, + { + name: "branch ref", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@feature-branch", + nil, + nil, + ), + want: true, + }, + { + name: "unsafe checkout opt out", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v4", + []map[string]interface{}{{"name": "allow-unsafe-pr-checkout", "value": "true"}}, + nil, + ), + }, + { + name: "unguarded event", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}, {"name": "issue_comment"}}, + "actions/checkout@v4", + nil, + nil, + ), + }, + { + name: "pull request workflow run", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "workflow_run", "workflows": []string{"PR checks"}}}, + "actions/checkout@v4", + nil, + []map[string]interface{}{{ + "github_actions_workflows": []map[string]interface{}{{ + "name": "PR checks", + "events": []map[string]interface{}{{"name": "pull_request"}}, + }}, + }}, + ), + want: true, + }, + { + name: "non pull request workflow run", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "workflow_run", "workflows": []string{"Issue checks"}}}, + "actions/checkout@v4", + nil, + []map[string]interface{}{{ + "github_actions_workflows": []map[string]interface{}{{ + "name": "Issue checks", + "events": []map[string]interface{}{{"name": "issue_comment"}}, + }}, + }}, + ), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var got bool + err := o.Eval( + context.TODO(), + `count([true | utils.checkout_fork_pr_guard_blocks_step(input.workflow, input.step, {"issues", "issue_comment", "workflow_call"})]) > 0`, + tt.input, + &got, + ) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func checkoutGuardInput(events []map[string]interface{}, uses string, with, packages []map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "workflow": map[string]interface{}{"events": events}, + "step": map[string]interface{}{ + "uses": uses, + "with_ref": "${{ github.event.pull_request.head.sha }}", + "with": with, + }, + "packages": packages, + } +} + +func TestCheckoutGuardData(t *testing.T) { + o, err := NewOpa(context.TODO(), &models.Config{ + Include: []models.ConfigInclude{}, + }) + require.NoError(t, err) + + var vulnerableSHAs []string + err = o.Eval( + context.TODO(), + `sort([sha | sha := data.poutine.checkout_guard_data.vulnerable_commit_shas[_]])`, + nil, + &vulnerableSHAs, + ) + require.NoError(t, err) + assert.Len(t, vulnerableSHAs, 223) + digest := sha256.Sum256([]byte(strings.Join(vulnerableSHAs, "\n"))) + assert.Equal(t, "017930673376b24e4bff0fa4bd8fd9c4c196da4b6cd5eb193b421032b43598c1", fmt.Sprintf("%x", digest)) + +} + func TestWithConfig(t *testing.T) { o, err := NewOpa(context.TODO(), &models.Config{ Include: []models.ConfigInclude{}, diff --git a/opa/rego/poutine/checkout_guard_data.rego b/opa/rego/poutine/checkout_guard_data.rego new file mode 100644 index 00000000..5c249dca --- /dev/null +++ b/opa/rego/poutine/checkout_guard_data.rego @@ -0,0 +1,246 @@ +# Frozen actions/checkout guard classification data. +# +# Provenance: official https://github.com/actions/checkout.git graph observed +# 2026-08-07 after the revised 2026-07-20 rollout. The 255-commit universe is +# reachable from main (f548e57e544e1ff5a4c46bf1e1b8685f8e4a348a), +# releases/*, and tags. A commit is vulnerable when it is neither equal to nor +# descended from an actual guard-introduction commit: +# v2 262cdb5f1c2e469ea6a6810758c890b43e82bd2c +# v3 58246fdaeb114bc8d7e5ba1c0dd4504aa481f1c6 +# v4 c915c33a16f01166c17c4e35fe1d4085a2d71adb +# v5 6026fb2ad3aa556d78b517cf65c8c1f832075860 +# v6 f93ca50bde35f63a65c67fc94cc8821ead20e631 +# v7 f9e715a95fcd1f9253f77dd28f11e88d2d6460c7 +# Each root adds the input, source helper, tests, and built dist code; its first +# parent lacks the input/helper. No commits outside the reachable universe were +# added. Set count: 223. SHA-256 of sorted SHAs joined by LF without a trailing +# LF: 017930673376b24e4bff0fa4bd8fd9c4c196da4b6cd5eb193b421032b43598c1. +package poutine.checkout_guard_data + +import rego.v1 + +vulnerable_commit_shas := { + "009b9ae9e446ad8d9b8c809870b0fbcc5e03573e", + "00a3be89340a3ce8d704f82f44a5e7f9e3a84dfe", + "01a434328acfaec94cbfc1cd07b3373e4693d132", + "01aecccf739ca6ff86c0539fbc67a7a5007bbc81", + "0299a0d2b67d48224ce047d03c69693b37fe77fe", + "033fa0dc0b82693d8986f1016a0ec2c5e7d9cbb1", + "06218e4404b044c23590a921fa858fb632a41afe", + "064fe7f3312418007dea2b49a19844a9ee378f49", + "069c6959146423d11cd0184e6accf28f9d45f06e", + "08c6903cd8c0fde910a37f88322edcfb5dd907a8", + "08eba0b27e820071cde6df949e0beb9ba4906955", + "090d9c9dfda6bb13508d978c6be93801de84f967", + "0963d3b35f4826b619c2436e1bdce37c147daeaf", + "096e9277500008410ac4dd98a7bb0d9052330de8", + "09d2acae674a48949e3602304ab46fd20ae0c42f", + "0ad4b8fadaa221de15dcec353f45205ec38ea70b", + "0b496e91ec7ae4428c3ed2eeb4c3a40df431f2cc", + "0c366fd6a839edf440554fa01a7085ccba70ac98", + "0ffe6f9c5599e73776da5b7f113e994bc0a76ede", + "11bd71901bbe5b1630ceea73d27597364c9af683", + "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", + "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", + "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", + "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", + "f095bcc56b7c2baf48f3ac70d6d6782f4f553222", + "f2190623701cfebaaf26554b39e1e29cc4a1f2fb", + "f25a3a9f25bd5f4c5d77189cab02ff357b5aedeb", + "f43a0e5ff2bd294095638e18286ca9a3d1956744", + "f466b96953a8e78166c9b97a44a70220f1a3e77e", + "f67ee5d6224ccd5af1909d53ea1faa5efb91db29", + "f6ce2afa7079cb075a124c93c79d61779d845782", + "f858c22e963bc60bc9d01c3d105c52a45a7deb6e", + "f90c7b395dac7c5a277c1a6d93d5057c1cddb74e", + "f95f2a38561736d1542cb9fbf736eea3d00ab5a6", + "fb6f360df236bd2026c7963cf88c8ddf20b4f0e2", + "fbb30c60ab3f94a2c03755a7fb875120c137bef7", + "fd084cde189b7b76ec305d52e27be545a0172823", + "fd47087372161c6f2a7b96d2ef87e944d89023ed", + "ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493", +} diff --git a/opa/rego/poutine/utils.rego b/opa/rego/poutine/utils.rego index 9b0ba65b..6414e9cb 100644 --- a/opa/rego/poutine/utils.rego +++ b/opa/rego/poutine/utils.rego @@ -194,3 +194,135 @@ job_referenced_secrets(job) := secrets if { job_json := json.marshal(job) secrets := extract_referenced_secrets(job_json) } + +######################################################################## +# actions/checkout pull_request_target / workflow_run safety default +# +# https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/ +# +# The guard shipped in v7 and was backported to supported v2-v6 releases. It +# refuses fork pull request code in `pull_request_target` and selected +# `workflow_run` workflows unless `allow-unsafe-pr-checkout: true` is set. +######################################################################## + +checkout_fork_pr_guard_blocks_step(workflow, step, non_blocked_events) if { + _checkout_has_pr_safe_default(step.uses) + not _checkout_allows_unsafe_pr_checkout(step) + _checkout_targets_fork_pr_head(step) + _workflow_has_event_in(workflow, {"pull_request_target", "workflow_run"}) + not _workflow_has_event_in(workflow, non_blocked_events) + not _workflow_run_from_unsafe_upstream(workflow) +} + +_workflow_run_from_unsafe_upstream(workflow) if { + some event in workflow.events + event.name == "workflow_run" + not _workflow_run_event_safe(event) +} + +_workflow_run_event_safe(event) if { + count(event.workflows) > 0 + every upstream in event.workflows { + _upstream_is_pull_request_only(upstream) + } +} + +_upstream_is_pull_request_only(name) if { + some pkg in input.packages + some workflow in pkg.github_actions_workflows + workflow.name == name + count(workflow.events) > 0 + every event in workflow.events { + startswith(event.name, "pull_request") + } +} + +# A ref is fixed unless it is a vulnerable commit SHA or a semantic version +# below the fixed floor for its release line. Moving major tags from v2 onward +# and branch-like refs are assumed to track guarded code. +_checkout_has_pr_safe_default(uses) if { + ref := _checkout_ref(uses) + not regex.match(`^[0-9A-Fa-f]{40}$`, ref) + not _checkout_version_ref_is_vulnerable(ref) +} + +_checkout_has_pr_safe_default(uses) if { + ref := _checkout_ref(uses) + regex.match(`^[0-9A-Fa-f]{40}$`, ref) + sha := lower(ref) + not sha in data.poutine.checkout_guard_data.vulnerable_commit_shas +} + +_checkout_guard_fixed_version_floors := { + "2": "2.8.0", + "3": "3.7.0", + "4": "4.4.0", + "5": "5.1.0", + "6": "6.1.0", +} + +_checkout_version_ref_is_vulnerable("v1") + +_checkout_version_ref_is_vulnerable(ref) if { + version := _checkout_semver(ref) + major := split(version, ".")[0] + major == "1" +} + +_checkout_version_ref_is_vulnerable(ref) if { + version := _checkout_semver(ref) + major := split(version, ".")[0] + fixed_floor := _checkout_guard_fixed_version_floors[major] + semver.compare(version, fixed_floor) < 0 +} + +_checkout_semver(ref) := sprintf("%s.0", [trim_prefix(ref, "v")]) if { + regex.match(`^v[0-9]+\.[0-9]+$`, ref) + semver.is_valid(sprintf("%s.0", [trim_prefix(ref, "v")])) +} + +_checkout_semver(ref) := version if { + regex.match(`^v[0-9]+\.[0-9]+\.[0-9]+$`, ref) + version := trim_prefix(ref, "v") + semver.is_valid(version) +} + +_checkout_ref(uses) := ref if { + matches := regex.find_all_string_submatch_n(`(?i)^actions/checkout@(.+)$`, uses, 1) + count(matches) == 1 + ref := matches[0][1] +} + +_checkout_allows_unsafe_pr_checkout(step) if { + some w in step["with"] + w.name == "allow-unsafe-pr-checkout" + lower(trim_space(w.value)) == "true" +} + +_checkout_allows_unsafe_pr_checkout(step) if { + some w in step["with"] + w.name == "allow-unsafe-pr-checkout" + contains(w.value, "${{") +} + +_checkout_targets_fork_pr_head(step) if { + some w in step["with"] + w.name == "repository" + regex.match(`(?i)github\.event\.(pull_request\.head\.repo|workflow_run\.head_repository)`, w.value) +} + +_checkout_targets_fork_pr_head(step) if { + regex.match(`(?i)^refs/pull/.+/(head|merge)$`, step.with_ref) +} + +_checkout_targets_fork_pr_head(step) if { + regex.match( + `(?i)github\.event\.(pull_request\.head\.sha|pull_request\.merge_commit_sha|workflow_run\.head_sha|workflow_run\.head_commit\.id)`, + step.with_ref, + ) +} + +_workflow_has_event_in(workflow, names) if { + some event in workflow.events + event.name in names +} diff --git a/opa/rego/rules/untrusted_checkout_exec.rego b/opa/rego/rules/untrusted_checkout_exec.rego index e138e061..fbbb30f6 100644 --- a/opa/rego/rules/untrusted_checkout_exec.rego +++ b/opa/rego/rules/untrusted_checkout_exec.rego @@ -27,6 +27,9 @@ github.workflow_run.parent.events contains event if some event in { "issue_comment", } +# Risky events handled by this rule that actions/checkout does not guard. +github.checkout_guard_unguarded_events := github.events - {"pull_request_target"} + build_github_actions[action] = { "bundler":{"ruby/setup-ruby"}, "cargo":{"actions-rs/cargo"}, @@ -160,6 +163,7 @@ _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)[_] + not utils.checkout_fork_pr_guard_blocks_step(workflow, workflow.jobs[pr_checkout.job_idx].steps[pr_checkout.step_idx], github.checkout_guard_unguarded_events) s := utils.workflow_steps_after(pr_checkout)[_] } @@ -168,6 +172,7 @@ _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)[_] + not utils.checkout_fork_pr_guard_blocks_step(workflow, workflow.jobs[pr_checkout.job_idx].steps[pr_checkout.step_idx], github.checkout_guard_unguarded_events) s := utils.workflow_steps_after(pr_checkout)[_] } diff --git a/scanner/inventory_test.go b/scanner/inventory_test.go index 8bbe92ad..4597cfed 100644 --- a/scanner/inventory_test.go +++ b/scanner/inventory_test.go @@ -607,7 +607,7 @@ func TestFindings(t *testing.T) { Purl: purl, Meta: results.FindingMeta{ Path: ".github/workflows/test_new_fields.yml", - Line: 39, + Line: 40, Job: "vulnerable_checkout", Details: "Detected usage of `bash`", LOTPTool: "bash", @@ -621,7 +621,7 @@ func TestFindings(t *testing.T) { Purl: purl, Meta: results.FindingMeta{ Path: ".github/workflows/test_new_fields.yml", - Line: 39, + Line: 40, Job: "vulnerable_checkout", Details: "Detected usage of `chmod`", LOTPTool: "chmod", @@ -649,7 +649,7 @@ func TestFindings(t *testing.T) { Purl: purl, Meta: results.FindingMeta{ Path: ".github/workflows/test_new_fields.yml", - Line: 44, + Line: 45, Job: "vulnerable_checkout", Step: "3", Details: "some/action@v1", @@ -661,7 +661,7 @@ func TestFindings(t *testing.T) { Purl: purl, Meta: results.FindingMeta{ Path: ".github/workflows/test_new_fields.yml", - Line: 29, + Line: 30, Job: "vulnerable_checkout", Details: "Detected usage of `npm`", LOTPTool: "npm", @@ -959,4 +959,11 @@ func TestStructuredFindingFields(t *testing.T) { 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") } + + for _, finding := range scannedPackage.FindingsResults.Findings { + if finding.RuleId == "untrusted_checkout_exec" { + assert.NotEqual(t, "guarded_checkout", finding.Meta.Job, + "checkout's fork PR guard should suppress execution findings") + } + } } diff --git a/scanner/testdata/.github/workflows/test_new_fields.yml b/scanner/testdata/.github/workflows/test_new_fields.yml index 05fe93fc..211365ea 100644 --- a/scanner/testdata/.github/workflows/test_new_fields.yml +++ b/scanner/testdata/.github/workflows/test_new_fields.yml @@ -23,6 +23,7 @@ jobs: uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} + allow-unsafe-pr-checkout: true # LOTP - should populate lotp_tool and referenced_secrets - name: Install dependencies @@ -46,3 +47,12 @@ jobs: # Secret in with block token: ${{ secrets.DEPLOY_TOKEN }} password: ${{ secrets.DATABASE_PASSWORD }} + + guarded_checkout: + runs-on: ubuntu-latest + steps: + - name: Checkout PR code with the safe default + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + - run: npm install From df7315b0c22a8d4e7b526a0a1e243e6d6dce0e3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 27 Aug 2026 10:15:54 -0400 Subject: [PATCH 2/4] Update untrusted_checkout_exec to respect actions/checkout new behavior --- opa/opa_test.go | 47 +++++++++++++++++++++++++++++++++++++ opa/rego/poutine/utils.rego | 2 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/opa/opa_test.go b/opa/opa_test.go index b4433ee6..6277cb4f 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -238,6 +238,53 @@ func TestCheckoutForkPRGuard(t *testing.T) { ), want: true, }, + { + name: "prerelease below fixed floor", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v2.8.0-rc.1", + nil, + nil, + ), + }, + { + name: "build metadata at fixed floor", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v2.8.0+build.1", + nil, + nil, + ), + want: true, + }, + { + name: "v1 prerelease", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v1.2.3-alpha.1", + nil, + nil, + ), + }, + { + name: "numeric prerelease follows Rego semver", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v2.8.0-01", + nil, + nil, + ), + }, + { + name: "invalid empty prerelease identifier", + input: checkoutGuardInput( + []map[string]interface{}{{"name": "pull_request_target"}}, + "actions/checkout@v2.8.0-alpha..1", + nil, + nil, + ), + want: true, + }, { name: "vulnerable v1 major ref", input: checkoutGuardInput( diff --git a/opa/rego/poutine/utils.rego b/opa/rego/poutine/utils.rego index 6414e9cb..03af470a 100644 --- a/opa/rego/poutine/utils.rego +++ b/opa/rego/poutine/utils.rego @@ -282,7 +282,7 @@ _checkout_semver(ref) := sprintf("%s.0", [trim_prefix(ref, "v")]) if { } _checkout_semver(ref) := version if { - regex.match(`^v[0-9]+\.[0-9]+\.[0-9]+$`, ref) + regex.match(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`, ref) version := trim_prefix(ref, "v") semver.is_valid(version) } From cd62feb47d52b7acbd4919c57c05d96d60c3fcb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 27 Aug 2026 10:59:31 -0400 Subject: [PATCH 3/4] Remove messypoutine results fixed by actions/checkout v7 port to v4 --- .../snapshot/__snapshots__/snapshot_test.snap | 74 ------------------- 1 file changed, 74 deletions(-) diff --git a/test/snapshot/__snapshots__/snapshot_test.snap b/test/snapshot/__snapshots__/snapshot_test.snap index 9a46288d..9fa25d80 100755 --- a/test/snapshot/__snapshots__/snapshot_test.snap +++ b/test/snapshot/__snapshots__/snapshot_test.snap @@ -398,43 +398,6 @@ "purl": "pkg:github/messypoutine/gravy-overflow", "rule_id": "untrusted_checkout_exec" }, - { - "meta": { - "details": "Detected usage the GitHub Action `actions/setup-node`", - "event_triggers": [ - "pull_request_target" - ], - "job": "chicken-popcorn", - "line": 34, - "lotp_action": "actions/setup-node", - "path": ".github/workflows/level3.yml", - "referenced_secrets": [ - "FLAG_GRAVY_OVERFLOW_L3_SOME_MEAT" - ] - }, - "purl": "pkg:github/messypoutine/gravy-overflow", - "rule_id": "untrusted_checkout_exec" - }, - { - "meta": { - "details": "Detected usage of `npm`", - "event_triggers": [ - "pull_request_target" - ], - "job": "chicken-popcorn", - "line": 38, - "lotp_targets": [ - "package.json" - ], - "lotp_tool": "npm", - "path": ".github/workflows/level3.yml", - "referenced_secrets": [ - "FLAG_GRAVY_OVERFLOW_L3_SOME_MEAT" - ] - }, - "purl": "pkg:github/messypoutine/gravy-overflow", - "rule_id": "untrusted_checkout_exec" - }, { "meta": { "details": "Detected usage of `bash`", @@ -910,43 +873,6 @@ }, "purl": "pkg:github/messypoutine/gravy-overflow", "rule_id": "untrusted_checkout_exec" - }, - { - "meta": { - "details": "Detected usage the GitHub Action `actions/setup-node`", - "event_triggers": [ - "pull_request_target" - ], - "job": "chicken-popcorn", - "line": 34, - "lotp_action": "actions/setup-node", - "path": ".github/workflows/level3.yml", - "referenced_secrets": [ - "FLAG_GRAVY_OVERFLOW_L3_SOME_MEAT" - ] - }, - "purl": "pkg:github/messypoutine/gravy-overflow", - "rule_id": "untrusted_checkout_exec" - }, - { - "meta": { - "details": "Detected usage of `npm`", - "event_triggers": [ - "pull_request_target" - ], - "job": "chicken-popcorn", - "line": 38, - "lotp_targets": [ - "package.json" - ], - "lotp_tool": "npm", - "path": ".github/workflows/level3.yml", - "referenced_secrets": [ - "FLAG_GRAVY_OVERFLOW_L3_SOME_MEAT" - ] - }, - "purl": "pkg:github/messypoutine/gravy-overflow", - "rule_id": "untrusted_checkout_exec" } ], "rules": { From 533cbfe90c278f43adb055214b8f90152c9be692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 27 Aug 2026 11:08:39 -0400 Subject: [PATCH 4/4] Fix golangci-lint --- opa/opa_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/opa/opa_test.go b/opa/opa_test.go index 6277cb4f..a4d2d97f 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "embed" + "encoding/hex" "fmt" "strings" "testing" @@ -416,7 +417,11 @@ func TestCheckoutGuardData(t *testing.T) { require.NoError(t, err) assert.Len(t, vulnerableSHAs, 223) digest := sha256.Sum256([]byte(strings.Join(vulnerableSHAs, "\n"))) - assert.Equal(t, "017930673376b24e4bff0fa4bd8fd9c4c196da4b6cd5eb193b421032b43598c1", fmt.Sprintf("%x", digest)) + assert.Equal( + t, + "017930673376b24e4bff0fa4bd8fd9c4c196da4b6cd5eb193b421032b43598c1", + hex.EncodeToString(digest[:]), + ) }