From 761de929ca15540ff4e9c200a0e94a273904f5ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Loba=C4=8Devski?= Date: Sun, 19 Jul 2026 07:01:24 +0000 Subject: [PATCH 1/3] Account cache poisoning queries for read-only access on low-trust triggers --- .../actions/security/CachePoisoningQuery.qll | 31 ++++++++- .../actions/security/CodeInjectionQuery.qll | 14 +--- .../CWE-349/CachePoisoningViaCodeInjection.md | 31 +++++---- .../CWE-349/CachePoisoningViaCodeInjection.ql | 1 + .../CWE-349/CachePoisoningViaDirectCache.md | 66 ++++++------------- .../CWE-349/CachePoisoningViaDirectCache.ql | 12 +--- .../CachePoisoningViaPoisonableStep.md | 28 +++++--- .../CachePoisoningViaPoisonableStep.ql | 12 +--- ...26-07-18-read-only-default-branch-cache.md | 4 ++ .../workflows/cache_write_capable_push.yml | 10 +++ .../cache_write_capable_workflow_dispatch.yml | 17 +++++ .../CachePoisoningViaCodeInjection.expected | 3 +- .../CachePoisoningViaDirectCache.expected | 9 +-- .../CachePoisoningViaPoisonableStep.expected | 10 +-- 14 files changed, 131 insertions(+), 117 deletions(-) create mode 100644 actions/ql/src/change-notes/2026-07-18-read-only-default-branch-cache.md create mode 100644 actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_push.yml create mode 100644 actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml diff --git a/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll b/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll index e5c5a3655101..52ceb9a94e0c 100644 --- a/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll +++ b/actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll @@ -5,8 +5,8 @@ string defaultBranchTriggerEvent() { [ "check_run", "check_suite", "delete", "discussion", "discussion_comment", "fork", "gollum", "issue_comment", "issues", "label", "milestone", "project", "project_card", "project_column", - "public", "pull_request_comment", "pull_request_target", "repository_dispatch", "schedule", - "watch", "workflow_run" + "public", "pull_request_comment", "pull_request_target", "repository_dispatch", + "registry_package", "page_build", "schedule", "watch", "workflow_dispatch", "workflow_run" ] } @@ -42,6 +42,33 @@ predicate runsOnDefaultBranch(Event e) { ) } +private string defaultBranchCacheWriteEvent() { + result = + [ + "push", "workflow_dispatch", "repository_dispatch", "delete", "registry_package", + "page_build", "schedule" + ] +} + +private predicate eventHasDefaultBranchCacheWriteAccess(Event event) { + runsOnDefaultBranch(event) and event.getName() = defaultBranchCacheWriteEvent() +} + +/** Holds if `job` can write to the cache scope of the default branch for `event`. */ +predicate hasDefaultBranchCacheWriteAccess(LocalJob job, Event event) { + job.getATriggerEvent() = event and + ( + eventHasDefaultBranchCacheWriteAccess(event) + or + // the workflow caller runs in the context of the default branch + event.getName() = "workflow_call" and + exists(ExternalJob caller | + job.getEnclosingWorkflow().(ReusableWorkflow).getACaller() = caller and + eventHasDefaultBranchCacheWriteAccess(caller.getATriggerEvent()) + ) + ) +} + abstract class CacheWritingStep extends Step { abstract string getPath(); } diff --git a/actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll b/actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll index 3d5b8852b850..2afa68244091 100644 --- a/actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll +++ b/actions/ql/lib/codeql/actions/security/CodeInjectionQuery.qll @@ -29,22 +29,10 @@ Event getRelevantCachePoisoningEventForSink(DataFlow::Node sink) { exists(LocalJob job | job = sink.asExpr().getEnclosingJob() and job.getATriggerEvent() = result and - // job can be triggered by an external user - result.isExternallyTriggerable() and // excluding privileged workflows since they can be exploited in easier circumstances // which is covered by `actions/code-injection/critical` not job.isPrivilegedExternallyTriggerable(result) and - ( - // the workflow runs in the context of the default branch - runsOnDefaultBranch(result) - or - // the workflow caller runs in the context of the default branch - result.getName() = "workflow_call" and - exists(ExternalJob caller | - caller.getCallee() = job.getLocation().getFile().getRelativePath() and - runsOnDefaultBranch(caller.getATriggerEvent()) - ) - ) + hasDefaultBranchCacheWriteAccess(job, result) ) } diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.md b/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.md index f75028a27e61..0ef3199e0fd9 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.md +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.md @@ -34,48 +34,55 @@ Due to the above design, if something is cached in the context of the default br ## Example +GitHub gives workflows triggered by low-trust events, such as `issue_comment`, +`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope. +This query therefore reports only workflows whose trigger can write to that scope. + ### Incorrect Usage -The following workflow is vulnerable to code injection in a non-privileged job but in the context of the default branch. +The following workflow interpolates a commit message directly into a script on a push to the +default branch. A commit message originating from a merged contribution may contain shell syntax, +which can expose the cache write token and allow the default branch cache to be poisoned. ```yaml name: Vulnerable Workflow on: - issue_comment: - types: [created] + push: + branches: [main] jobs: - pr-comment: + build: permissions: {} runs-on: ubuntu-latest steps: - run: | - echo ${{ github.event.comment.body }} + echo ${{ github.event.head_commit.message }} ``` ### Correct Usage -The following workflow is not vulnerable to code injections even if it runs in the context of the default branch. +The following workflow passes the commit message through an environment variable, so the shell +does not interpret its contents as code. ```yaml name: Secure Workflow on: - issue_comment: - types: [created] + push: + branches: [main] jobs: - pr-comment: + build: permissions: {} runs-on: ubuntu-latest steps: - env: - BODY: ${{ github.event.comment.body }} + MESSAGE: ${{ github.event.head_commit.message }} run: | - echo "$BODY" + echo "$MESSAGE" ``` ## References - Adnan Khan's Blog: [The Monsters in Your Build Cache – GitHub Actions Cache Poisoning](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/). -- GitHub Docs: [GitHub Actions Caching Documentation](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows). +- GitHub Docs: [Cache access for low-trust workflow triggers](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#cache-access-for-low-trust-workflow-triggers). - Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/). diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.ql b/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.ql index 2fe792aba1e6..b970288ac31b 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.ql +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.ql @@ -22,6 +22,7 @@ from CodeInjectionFlow::PathNode source, CodeInjectionFlow::PathNode sink, Event where CodeInjectionFlow::flowPath(source, sink) and event = getRelevantCachePoisoningEventForSink(sink.getNode()) and + source.getNode().(RemoteFlowSource).getEventName() = event.getName() and // the checkout is not controlled by an access check not exists(ControlCheck check | check.protects(source.getNode().asExpr(), event, "code-injection") diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md b/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md index 849b771a8ff0..a22c41ad3e3a 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md @@ -34,35 +34,39 @@ Due to the above design, if something is cached in the context of the default br ## Example +GitHub gives workflows triggered by low-trust events, such as `issue_comment`, +`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope. +This query therefore reports only workflows whose trigger can write to that scope. + ### Incorrect Usage -The following workflow is caching an attacker-controlled file (`large_file`) in the context of the default branch. +The following write-capable manually dispatched workflow accepts a revision without validation, +fetches files from it, and saves those files in the default branch cache. This is unsafe if an +untrusted integration or automation can influence the dispatch input. ```yaml name: Vulnerable Workflow on: - issue_comment: - types: [created] + workflow_dispatch: + inputs: + head_sha: + required: true jobs: - pr-comment: + cache: permissions: read-all runs-on: ubuntu-latest steps: - - uses: xt0rted/pull-request-comment-branch@v2 - id: comment-branch - - uses: actions/checkout@v3 - with: - ref: ${{ steps.comment-branch.outputs.head_sha }} - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - - name: Cache pip dependencies + - env: + HEAD_SHA: ${{ github.event.inputs.head_sha }} + run: | + git fetch origin "$HEAD_SHA" + git checkout "$HEAD_SHA" + - name: Cache fetched files uses: actions/cache@v4 - id: cache-pip with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} - restore-keys: ${{ runner.os }}-pip- + path: . + key: dispatched-${{ github.event.inputs.head_sha }} ``` ### Correct Usage @@ -91,36 +95,8 @@ jobs: restore-keys: ${{ runner.os }}-pip- ``` -Note, that the example above doesn't allow using secrets if the Pull Request originates from a fork. In case secrets are needed, `pull_request_target` with labels as `safe to test` can be used, but the code in Pull Request must be manually reviewed before applying the label. - -```yaml -name: Secure Workflow -on: - pull_request_target: - types: [labeled] - -jobs: - pr-comment: - if: contains(github.event.pull_request.labels.*.name, 'safe to test') - permissions: read-all - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha}} - - name: Set up Python 3.10 - uses: actions/setup-python@v5 - - name: Cache pip dependencies - uses: actions/cache@v4 - id: cache-pip - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} - restore-keys: ${{ runner.os }}-pip- -``` - ## References - Adnan Khan's Blog: [The Monsters in Your Build Cache – GitHub Actions Cache Poisoning](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/). -- GitHub Docs: [GitHub Actions Caching Documentation](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows). +- GitHub Docs: [Cache access for low-trust workflow triggers](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#cache-access-for-low-trust-workflow-triggers). - Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/). diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.ql b/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.ql index 85a0f53df1dc..b410f92b68a1 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.ql +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.ql @@ -40,17 +40,7 @@ where job.getATriggerEvent() = event and // job can be triggered by an external user event.isExternallyTriggerable() and - ( - // the workflow runs in the context of the default branch - runsOnDefaultBranch(event) - or - // the workflow's caller runs in the context of the default branch - event.getName() = "workflow_call" and - exists(ExternalJob caller | - caller.getCallee() = job.getLocation().getFile().getRelativePath() and - runsOnDefaultBranch(caller.getATriggerEvent()) - ) - ) and + hasDefaultBranchCacheWriteAccess(job, event) and // the job writes to the cache // (No need to follow the checkout/download step since the cache is normally write after the job completes) job.getAStep() = step and diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.md b/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.md index fefd6d61a44d..e5fd868609c4 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.md +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.md @@ -34,25 +34,35 @@ Due to the above design, if something is cached in the context of the default br ## Example +GitHub gives workflows triggered by low-trust events, such as `issue_comment`, +`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope. +This query therefore reports only workflows whose trigger can write to that scope. + ### Incorrect Usage -The following workflow runs untrusted code in a non-privileged job but in the context of the default branch. +The following write-capable manually dispatched workflow fetches an unvalidated revision and then +executes code from it. The executed code can use the cache token to poison the default branch cache +if an untrusted integration or automation can influence the dispatch input. ```yaml name: Vulnerable Workflow on: - pull_request_target: - branches: [main] -permissions: {} + workflow_dispatch: + inputs: + head_sha: + required: true jobs: test: + permissions: {} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - with: - ref: ${{ github.event.pull_request.head.sha }} + - env: + HEAD_SHA: ${{ github.event.inputs.head_sha }} + run: | + git fetch origin "$HEAD_SHA" + git checkout "$HEAD_SHA" - name: Run tests - run: ./run_tests.sh + run: npm install ``` ### Correct Usage @@ -79,5 +89,5 @@ jobs: ## References - Adnan Khan's Blog: [The Monsters in Your Build Cache – GitHub Actions Cache Poisoning](https://adnanthekhan.com/2024/05/06/the-monsters-in-your-build-cache-github-actions-cache-poisoning/). -- GitHub Docs: [GitHub Actions Caching Documentation](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows). +- GitHub Docs: [Cache access for low-trust workflow triggers](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#cache-access-for-low-trust-workflow-triggers). - Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/). diff --git a/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.ql b/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.ql index 95adcfaf78ec..f8466179caeb 100644 --- a/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.ql +++ b/actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.ql @@ -40,17 +40,7 @@ where job.getATriggerEvent() = event and // job can be triggered by an external user event.isExternallyTriggerable() and - ( - // the workflow runs in the context of the default branch - runsOnDefaultBranch(event) - or - // the workflow's caller runs in the context of the default branch - event.getName() = "workflow_call" and - exists(ExternalJob caller | - caller.getCallee() = job.getLocation().getFile().getRelativePath() and - runsOnDefaultBranch(caller.getATriggerEvent()) - ) - ) and + hasDefaultBranchCacheWriteAccess(job, event) and // the job executes checked-out code // (The cache specific token can be leaked even for non-privileged workflows) source.getAFollowingStep() = step and diff --git a/actions/ql/src/change-notes/2026-07-18-read-only-default-branch-cache.md b/actions/ql/src/change-notes/2026-07-18-read-only-default-branch-cache.md new file mode 100644 index 000000000000..9952be542003 --- /dev/null +++ b/actions/ql/src/change-notes/2026-07-18-read-only-default-branch-cache.md @@ -0,0 +1,4 @@ +--- +category: minorAnalysis +--- +* The `actions/cache-poisoning/code-injection`, `actions/cache-poisoning/direct-cache`, and `actions/cache-poisoning/poisonable-step` queries now account for read-only cache access on low-trust triggers that run in the default branch scope. Results are retained for triggers that GitHub allows to write to that cache scope. diff --git a/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_push.yml b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_push.yml new file mode 100644 index 000000000000..b9fbe2d2d0b4 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_push.yml @@ -0,0 +1,10 @@ +on: + push: + branches: [main] + +jobs: + injection: + permissions: {} + runs-on: ubuntu-latest + steps: + - run: echo "${{ github.event.head_commit.message }}" \ No newline at end of file diff --git a/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml new file mode 100644 index 000000000000..74695bc76942 --- /dev/null +++ b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml @@ -0,0 +1,17 @@ +on: workflow_dispatch + +jobs: + cache: + permissions: {} + runs-on: ubuntu-latest + steps: + - env: + HEAD_SHA: ${{ github.event.inputs.head_sha }} + run: | + git fetch origin "$HEAD_SHA" + git checkout "$HEAD_SHA" + - run: npm install + - uses: actions/cache@v4 + with: + path: .npm + key: workflow-dispatch diff --git a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaCodeInjection.expected b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaCodeInjection.expected index 9cfac091f675..8cfbf6c2965c 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaCodeInjection.expected +++ b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaCodeInjection.expected @@ -1,10 +1,11 @@ edges | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | .github/workflows/code_injection2.yml:16:21:16:70 | steps.modified_files.outputs.files_modified | provenance | | nodes +| .github/workflows/cache_write_capable_push.yml:10:21:10:59 | github.event.head_commit.message | semmle.label | github.event.head_commit.message | | .github/workflows/code_injection1.yml:11:17:11:48 | github.event.comment.body | semmle.label | github.event.comment.body | | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | semmle.label | Uses Step: modified_files | | .github/workflows/code_injection2.yml:16:21:16:70 | steps.modified_files.outputs.files_modified | semmle.label | steps.modified_files.outputs.files_modified | | .github/workflows/neg_code_injection1.yml:11:17:11:48 | github.event.comment.body | semmle.label | github.event.comment.body | subpaths #select -| .github/workflows/code_injection1.yml:11:17:11:48 | github.event.comment.body | .github/workflows/code_injection1.yml:11:17:11:48 | github.event.comment.body | .github/workflows/code_injection1.yml:11:17:11:48 | github.event.comment.body | Unprivileged code injection in $@, which may lead to cache poisoning ($@). | .github/workflows/code_injection1.yml:11:17:11:48 | github.event.comment.body | ${{ github.event.comment.body }} | .github/workflows/code_injection1.yml:2:3:2:15 | issue_comment | issue_comment | +| .github/workflows/cache_write_capable_push.yml:10:21:10:59 | github.event.head_commit.message | .github/workflows/cache_write_capable_push.yml:10:21:10:59 | github.event.head_commit.message | .github/workflows/cache_write_capable_push.yml:10:21:10:59 | github.event.head_commit.message | Unprivileged code injection in $@, which may lead to cache poisoning ($@). | .github/workflows/cache_write_capable_push.yml:10:21:10:59 | github.event.head_commit.message | ${{ github.event.head_commit.message }} | .github/workflows/cache_write_capable_push.yml:2:3:2:6 | push | push | diff --git a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected index 4cc8536b5943..8d0b858d0035 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected +++ b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected @@ -1,4 +1,6 @@ edges +| .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | .github/workflows/code_injection2.yml:16:9:16:71 | Run Step | | .github/workflows/direct_cache1.yml:10:9:13:6 | Uses Step: comment-branch | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | @@ -44,9 +46,4 @@ edges | .github/workflows/poisonable_step5.yml:17:9:22:6 | Uses Step | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | #select -| .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache1.yml:2:3:2:15 | issue_comment | issue_comment | -| .github/workflows/direct_cache2.yml:14:9:18:6 | Uses Step | .github/workflows/direct_cache2.yml:11:9:14:6 | Uses Step | .github/workflows/direct_cache2.yml:14:9:18:6 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache2.yml:3:5:3:23 | pull_request_target | pull_request_target | -| .github/workflows/direct_cache3.yml:19:9:23:6 | Uses Step | .github/workflows/direct_cache3.yml:14:9:19:6 | Uses Step | .github/workflows/direct_cache3.yml:19:9:23:6 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache3.yml:2:3:2:15 | issue_comment | issue_comment | -| .github/workflows/direct_cache4.yml:17:9:21:6 | Uses Step | .github/workflows/direct_cache4.yml:14:9:17:6 | Uses Step | .github/workflows/direct_cache4.yml:17:9:21:6 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache4.yml:4:3:4:21 | pull_request_target | pull_request_target | -| .github/workflows/direct_cache5.yml:17:9:21:6 | Uses Step | .github/workflows/direct_cache5.yml:14:9:17:6 | Uses Step | .github/workflows/direct_cache5.yml:17:9:21:6 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache5.yml:4:3:4:21 | pull_request_target | pull_request_target | -| .github/workflows/direct_cache6.yml:20:9:26:46 | Uses Step: cache-pip | .github/workflows/direct_cache6.yml:13:9:16:6 | Uses Step | .github/workflows/direct_cache6.yml:20:9:26:46 | Uses Step: cache-pip | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/direct_cache6.yml:4:3:4:21 | pull_request_target | pull_request_target | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:1:5:1:21 | workflow_dispatch | workflow_dispatch | diff --git a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected index 6b1a3e873134..ba48d939eda7 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected +++ b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected @@ -1,4 +1,6 @@ edges +| .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | .github/workflows/code_injection2.yml:16:9:16:71 | Run Step | | .github/workflows/direct_cache1.yml:10:9:13:6 | Uses Step: comment-branch | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | @@ -44,10 +46,4 @@ edges | .github/workflows/poisonable_step5.yml:17:9:22:6 | Uses Step | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | #select -| .github/workflows/poisonable_step1.yml:15:9:17:2 | Run Step | .github/workflows/poisonable_step1.yml:12:9:15:6 | Uses Step | .github/workflows/poisonable_step1.yml:15:9:17:2 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step1.yml:2:3:2:15 | issue_comment | issue_comment | -| .github/workflows/poisonable_step1.yml:26:9:28:2 | Uses Step | .github/workflows/poisonable_step1.yml:23:9:26:6 | Uses Step | .github/workflows/poisonable_step1.yml:26:9:28:2 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step1.yml:2:3:2:15 | issue_comment | issue_comment | -| .github/workflows/poisonable_step1.yml:37:9:37:75 | Run Step | .github/workflows/poisonable_step1.yml:34:9:37:6 | Uses Step | .github/workflows/poisonable_step1.yml:37:9:37:75 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step1.yml:2:3:2:15 | issue_comment | issue_comment | -| .github/workflows/poisonable_step2.yml:22:9:26:31 | Uses Step | .github/workflows/poisonable_step2.yml:15:9:20:6 | Uses Step | .github/workflows/poisonable_step2.yml:22:9:26:31 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step2.yml:5:3:5:21 | pull_request_target | pull_request_target | -| .github/workflows/poisonable_step3.yml:19:7:19:32 | Run Step | .github/workflows/poisonable_step3.yml:13:7:19:4 | Uses Step | .github/workflows/poisonable_step3.yml:19:7:19:32 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step3.yml:4:3:4:21 | pull_request_target | pull_request_target | -| .github/workflows/poisonable_step4.yml:18:9:18:19 | Run Step | .github/workflows/poisonable_step4.yml:13:9:18:6 | Uses Step | .github/workflows/poisonable_step4.yml:18:9:18:19 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step4.yml:3:3:3:21 | pull_request_target | pull_request_target | -| .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | .github/workflows/poisonable_step5.yml:17:9:22:6 | Uses Step | .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/poisonable_step5.yml:3:3:3:21 | pull_request_target | pull_request_target | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:1:5:1:21 | workflow_dispatch | workflow_dispatch | From 754e40ba72950614ea7249398fb79956310c72b2 Mon Sep 17 00:00:00 2001 From: JarLob Date: Thu, 30 Jul 2026 19:05:20 +0300 Subject: [PATCH 2/3] Optimize Actions event source matching Bind event-property and event-context matching to source expressions so code-injection queries avoid materializing global source/event relations. --- .../codeql/actions/dataflow/FlowSources.qll | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll b/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll index 18cc4322c81b..b4a279ac1151 100644 --- a/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll +++ b/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll @@ -52,29 +52,41 @@ class GitHubCtxSource extends RemoteFlowSource { override string getEventName() { result = event } } +bindingset[expression] +private predicate untrustedEventProperty(Expression expression, string kind) { + exists(string regexp | + untrustedEventPropertiesDataModel(regexp, kind) and + not kind = "json" and + normalizeExpr(expression.getExpression()).regexpMatch("(?i)\\s*" + wrapRegexp(regexp) + ".*") + ) +} + +bindingset[expression, event] +private predicate expressionContainsEventContext(Expression expression, string event) { + exists(string contextPrefix | + contextTriggerDataModel(event, contextPrefix) and + normalizeExpr(expression.getExpression()).matches("%" + contextPrefix + "%") + ) +} + class GitHubEventCtxSource extends RemoteFlowSource { string flag; string context; string event; GitHubEventCtxSource() { - exists(Expression e, string regexp | + exists(Expression e | this.asExpr() = e and context = e.getExpression() and ( // the context is available for the job trigger events event = e.getATriggerEvent().getName() and - exists(string context_prefix | - contextTriggerDataModel(event, context_prefix) and - normalizeExpr(context).matches("%" + context_prefix + "%") - ) + expressionContainsEventContext(e, event) or not exists(e.getATriggerEvent()) and event = "unknown" ) and - untrustedEventPropertiesDataModel(regexp, flag) and - not flag = "json" and - normalizeExpr(context).regexpMatch("(?i)\\s*" + wrapRegexp(regexp) + ".*") + untrustedEventProperty(e, flag) ) } @@ -177,32 +189,40 @@ class GitHubEventPathSource extends RemoteFlowSource, CommandSource { override Run getEnclosingRun() { result = run } } +bindingset[expression, event] +private predicate jsonSourceForEvent(Expression expression, string event) { + exists(string context, string regexp, string contextPrefix | + context = expression.getExpression() and + untrustedEventPropertiesDataModel(regexp, _) and + contextTriggerDataModel(event, contextPrefix) and + normalizeExpr(context).matches("%" + contextPrefix + "%") and + normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp(regexp) + ".*") + ) + or + exists(string context, string regexp, string kind | + context = expression.getExpression() and + untrustedEventPropertiesDataModel(regexp, kind) and + contextTriggerDataModel(event, _) and + normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp("\\bgithub.event\\b") + ".*") + ) +} + class GitHubEventJsonSource extends RemoteFlowSource { string flag; string event; GitHubEventJsonSource() { - exists(Expression e, string context, string regexp | + exists(Expression e | this.asExpr() = e and - context = e.getExpression() and - untrustedEventPropertiesDataModel(regexp, _) and ( // only contexts for the triggering events are considered tainted. // eg: for `pull_request`, we only consider `github.event.pull_request` event = e.getEnclosingWorkflow().getATriggerEvent().getName() and - exists(string context_prefix | - contextTriggerDataModel(event, context_prefix) and - normalizeExpr(context).matches("%" + context_prefix + "%") - ) and - normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp(regexp) + ".*") - or - // github.event is tainted for all triggers - event = e.getEnclosingWorkflow().getATriggerEvent().getName() and - contextTriggerDataModel(e.getEnclosingWorkflow().getATriggerEvent().getName(), _) and - normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp("\\bgithub.event\\b") + ".*") + jsonSourceForEvent(e, event) or not exists(e.getATriggerEvent()) and - event = "unknown" + event = "unknown" and + exists(string regexp, string kind | untrustedEventPropertiesDataModel(regexp, kind)) ) and flag = "json" ) From d2f52440807585a8637906c720f8b6c3147c342c Mon Sep 17 00:00:00 2001 From: JarLob Date: Thu, 30 Jul 2026 19:39:25 +0300 Subject: [PATCH 3/3] Address cache poisoning review feedback --- .../ql/lib/codeql/actions/dataflow/FlowSources.qll | 6 +++--- .../cache_write_capable_workflow_dispatch.yml | 12 ++++++++++-- .../CWE-349/CachePoisoningViaDirectCache.expected | 7 ++++--- .../CWE-349/CachePoisoningViaPoisonableStep.expected | 7 ++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll b/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll index b4a279ac1151..f44a4603df19 100644 --- a/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll +++ b/actions/ql/lib/codeql/actions/dataflow/FlowSources.qll @@ -199,9 +199,9 @@ private predicate jsonSourceForEvent(Expression expression, string event) { normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp(regexp) + ".*") ) or - exists(string context, string regexp, string kind | + exists(string context | context = expression.getExpression() and - untrustedEventPropertiesDataModel(regexp, kind) and + untrustedEventPropertiesDataModel(_, _) and contextTriggerDataModel(event, _) and normalizeExpr(context).regexpMatch("(?i).*" + wrapJsonRegexp("\\bgithub.event\\b") + ".*") ) @@ -222,7 +222,7 @@ class GitHubEventJsonSource extends RemoteFlowSource { or not exists(e.getATriggerEvent()) and event = "unknown" and - exists(string regexp, string kind | untrustedEventPropertiesDataModel(regexp, kind)) + untrustedEventPropertiesDataModel(_, _) ) and flag = "json" ) diff --git a/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml index 74695bc76942..5b9510222ac7 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml +++ b/actions/ql/test/query-tests/Security/CWE-349/.github/workflows/cache_write_capable_workflow_dispatch.yml @@ -1,15 +1,23 @@ -on: workflow_dispatch +on: + workflow_dispatch: + inputs: + head_sha: + description: Commit SHA to test + required: true + type: string jobs: cache: permissions: {} runs-on: ubuntu-latest steps: + - uses: actions/checkout@v4 - env: HEAD_SHA: ${{ github.event.inputs.head_sha }} run: | + [[ "$HEAD_SHA" =~ ^[0-9a-fA-F]{40}$ ]] || exit 1 git fetch origin "$HEAD_SHA" - git checkout "$HEAD_SHA" + git checkout --detach "$HEAD_SHA" - run: npm install - uses: actions/cache@v4 with: diff --git a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected index 8d0b858d0035..0491a0de4e8d 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected +++ b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaDirectCache.expected @@ -1,6 +1,7 @@ edges -| .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | -| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:15:6 | Uses Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:22:9:25:33 | Uses Step | | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | .github/workflows/code_injection2.yml:16:9:16:71 | Run Step | | .github/workflows/direct_cache1.yml:10:9:13:6 | Uses Step: comment-branch | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | @@ -46,4 +47,4 @@ edges | .github/workflows/poisonable_step5.yml:17:9:22:6 | Uses Step | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | #select -| .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:1:5:1:21 | workflow_dispatch | workflow_dispatch | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:22:9:25:33 | Uses Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:22:9:25:33 | Uses Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:2:3:2:19 | workflow_dispatch | workflow_dispatch | diff --git a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected index ba48d939eda7..0b637891d8e0 100644 --- a/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected +++ b/actions/ql/test/query-tests/Security/CWE-349/CachePoisoningViaPoisonableStep.expected @@ -1,6 +1,7 @@ edges -| .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | -| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:17:33 | Uses Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:14:9:15:6 | Uses Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:22:9:25:33 | Uses Step | | .github/workflows/code_injection2.yml:12:9:16:6 | Uses Step: modified_files | .github/workflows/code_injection2.yml:16:9:16:71 | Run Step | | .github/workflows/direct_cache1.yml:10:9:13:6 | Uses Step: comment-branch | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | | .github/workflows/direct_cache1.yml:13:9:18:6 | Uses Step | .github/workflows/direct_cache1.yml:18:9:22:6 | Uses Step | @@ -46,4 +47,4 @@ edges | .github/workflows/poisonable_step5.yml:17:9:22:6 | Uses Step | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | | .github/workflows/poisonable_step5.yml:22:9:24:6 | Uses Step | .github/workflows/poisonable_step5.yml:24:9:28:31 | Uses Step | #select -| .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:8:9:13:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:13:9:14:6 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:1:5:1:21 | workflow_dispatch | workflow_dispatch | +| .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:15:9:21:6 | Run Step | .github/workflows/cache_write_capable_workflow_dispatch.yml:21:9:22:6 | Run Step | Potential cache poisoning in the context of the default branch due to privilege checkout of untrusted code. ($@). | .github/workflows/cache_write_capable_workflow_dispatch.yml:2:3:2:19 | workflow_dispatch | workflow_dispatch |