diff --git a/cmd/handle_analyze_manifest_test.go b/cmd/handle_analyze_manifest_test.go index c19fb80..8b3b2e5 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 647c274..bf987e9 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 391114c..a4d2d97 100644 --- a/opa/opa_test.go +++ b/opa/opa_test.go @@ -2,14 +2,18 @@ package opa import ( "context" + "crypto/sha256" "embed" + "encoding/hex" + "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 +162,269 @@ 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: "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( + []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", + hex.EncodeToString(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 0000000..5c249dc --- /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 9b0ba65..03af470 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]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$`, 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 e138e06..fbbb30f 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 8bbe92a..4597cfe 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 05fe93f..211365e 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 diff --git a/test/snapshot/__snapshots__/snapshot_test.snap b/test/snapshot/__snapshots__/snapshot_test.snap index 9a46288..9fa25d8 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": {