Skip to content

Commit dc2387b

Browse files
committed
Update cache poisoning queries for the new cache-mode syntax https://github.blog/changelog/2026-09-10-control-github-actions-cache-access-with-cache-mode/
1 parent 7a367c9 commit dc2387b

22 files changed

Lines changed: 656 additions & 73 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
category: feature
3+
---
4+
* Added `Workflow.getCacheMode()` and `Job.getCacheMode()` to read explicitly declared cache modes. The `hasDefaultBranchCacheWriteAccess` predicate now accounts for these modes, job overrides, and explicit caller limits in reusable workflows.
5+
* `runsOnDefaultBranch` now includes unfiltered branch pushes and `pull_request` `closed` events that may refer to a merge into the default branch, while excluding tag-only pushes.

actions/ql/lib/codeql/actions/Ast.qll

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ class Workflow extends AstNode instanceof WorkflowImpl {
104104

105105
Permissions getPermissions() { result = super.getPermissions() }
106106

107+
/** Gets the explicitly declared cache mode for this workflow, if any. */
108+
string getCacheMode() { result = super.getCacheMode() }
109+
107110
Strategy getStrategy() { result = super.getStrategy() }
108111

109112
On getOn() { result = super.getOn() }
@@ -200,6 +203,9 @@ abstract class Job extends AstNode instanceof JobImpl {
200203

201204
Permissions getPermissions() { result = super.getPermissions() }
202205

206+
/** Gets the explicitly declared cache mode for this job, if any. */
207+
string getCacheMode() { result = super.getCacheMode() }
208+
203209
Strategy getStrategy() { result = super.getStrategy() }
204210

205211
string getARunsOnLabel() { result = super.getARunsOnLabel() }

actions/ql/lib/codeql/actions/ast/internal/Ast.qll

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,9 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
494494
/** Gets the permissions granted to this workflow. */
495495
PermissionsImpl getPermissions() { result.getNode() = n.lookup("permissions") }
496496

497+
/** Gets the explicitly declared cache mode for this workflow, if any. */
498+
string getCacheMode() { result = n.lookup("cache-mode").(YamlString).getValue() }
499+
497500
/** Gets the trigger event that starts this workflow. */
498501
override EventImpl getATriggerEvent() { this.getOn().getAnEvent() = result }
499502

@@ -923,6 +926,9 @@ class JobImpl extends AstNodeImpl, TJobNode {
923926
/** Gets the permissions for this job. */
924927
PermissionsImpl getPermissions() { result.getNode() = n.lookup("permissions") }
925928

929+
/** Gets the explicitly declared cache mode for this job, if any. */
930+
string getCacheMode() { result = n.lookup("cache-mode").(YamlString).getValue() }
931+
926932
/** Gets the strategy for this job. */
927933
StrategyImpl getStrategy() { result.getNode() = n.lookup("strategy") }
928934

actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,27 @@ string defaultBranchTriggerEvent() {
1010
]
1111
}
1212

13+
/**
14+
* Holds if `e` can run in the default-branch cache scope.
15+
* A pull request closed by a merge uses its base branch instead of its merge ref.
16+
*/
1317
predicate runsOnDefaultBranch(Event e) {
1418
(
1519
e.getName() = defaultBranchTriggerEvent() and
1620
not e.getName() = "pull_request_target"
1721
or
18-
e.getName() = "push" and
19-
e.getAPropertyValue("branches") = defaultBranchNames()
20-
or
21-
e.getName() = "pull_request_target" and
22+
(
23+
e.getName() = "push" and
24+
(
25+
e.hasProperty(["branches", "branches-ignore"])
26+
or
27+
not e.hasProperty(["tags", "tags-ignore"])
28+
)
29+
or
30+
e.getName() = "pull_request_target"
31+
or
32+
e.getName() = "pull_request" and e.getAnActivityType() = "closed"
33+
) and
2234
(
2335
// no filtering
2436
not e.hasProperty("branches") and not e.hasProperty("branches-ignore")
@@ -50,17 +62,61 @@ private string defaultBranchCacheWriteEvent() {
5062
]
5163
}
5264

53-
private predicate eventHasDefaultBranchCacheWriteAccess(Event event) {
54-
runsOnDefaultBranch(event) and event.getName() = defaultBranchCacheWriteEvent()
65+
private string getDeclaredCacheMode(Job job) {
66+
result = job.getCacheMode()
67+
or
68+
not exists(job.getCacheMode()) and
69+
result = job.getWorkflow().getCacheMode()
70+
}
71+
72+
private predicate cacheModeIsAllowed(string mode, string inheritedMode) {
73+
mode = ["read", "write", "write-only", "none"] and
74+
inheritedMode = ["default", "read", "write", "write-only", "none"] and
75+
(inheritedMode = ["default", "write"] or mode = ["none", inheritedMode])
76+
}
77+
78+
/**
79+
* Gets the effective declared cache mode, or `default` if no mode was declared in the call chain.
80+
* An event's implicit default does not limit the modes a reusable workflow can request.
81+
* Both the callee's workflow-level mode and its job-level modes must fit the caller's explicit limit.
82+
*/
83+
private string getEffectiveCacheMode(Job job, Event event) {
84+
exists(string inheritedMode |
85+
(
86+
job.getWorkflow().getOn().getAnEvent() = event and
87+
not event.getName() = "workflow_call" and
88+
inheritedMode = "default"
89+
or
90+
inheritedMode =
91+
getEffectiveCacheMode(job.getWorkflow().(ReusableWorkflow).getACaller(), event)
92+
)
93+
|
94+
(
95+
not exists(job.getWorkflow().getCacheMode())
96+
or
97+
cacheModeIsAllowed(job.getWorkflow().getCacheMode(), inheritedMode)
98+
) and
99+
if exists(getDeclaredCacheMode(job))
100+
then
101+
result = getDeclaredCacheMode(job) and
102+
cacheModeIsAllowed(result, inheritedMode)
103+
else result = inheritedMode
104+
)
55105
}
56106

57107
/**
58108
* Holds if `job` can write to the cache scope of the default branch for `event`.
59-
* Reusable workflow jobs inherit their caller's trigger event.
109+
* Job cache modes override workflow cache modes, subject to explicit limits from reusable workflow
110+
* callers. Trigger-based defaults apply only if no cache mode was declared in the call chain.
60111
*/
61112
predicate hasDefaultBranchCacheWriteAccess(LocalJob job, Event event) {
62113
job.getATriggerEvent() = event and
63-
eventHasDefaultBranchCacheWriteAccess(event)
114+
runsOnDefaultBranch(event) and
115+
exists(string mode | mode = getEffectiveCacheMode(job, event) |
116+
mode = ["write", "write-only"]
117+
or
118+
mode = "default" and event.getName() = defaultBranchCacheWriteEvent()
119+
)
64120
}
65121

66122
abstract class CacheWritingStep extends Step {

actions/ql/src/Security/CWE-349/CachePoisoningViaCodeInjection.md

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Overview
22

3-
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Action's cache from unprivileged workflow, potentially leading to code execution in privileged workflows.
3+
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Actions cache from an unprivileged workflow with cache-write access, potentially leading to code execution in privileged workflows.
44

55
An attacker with the ability to run code in the context of the default branch (e.g. through Code Injection or Execution of Untrusted Code) can exploit this to:
66

@@ -23,7 +23,13 @@ Due to the above design, if something is cached in the context of the default br
2323

2424
## Recommendation
2525

26-
1. Avoid using caching in workflows that handle sensitive operations like releases.
26+
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
27+
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
28+
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
29+
jobs that call reusable workflows to limit the access those workflows can request. Do not use
30+
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.
31+
32+
1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2733
2. If caching must be used:
2834
- Validate restored cache contents before use.
2935
- Use short-lived, workflow-specific cache keys.
@@ -34,9 +40,13 @@ Due to the above design, if something is cached in the context of the default br
3440

3541
## Example
3642

37-
GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
43+
By default, GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
3844
`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope.
39-
This query therefore reports only workflows whose trigger can write to that scope.
45+
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
46+
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
47+
mode set by their caller, but can override an implicit trigger-based default.
48+
This query reports only jobs that can write to the default branch cache scope, accounting for
49+
the trigger, branch, and effective cache mode.
4050

4151
### Incorrect Usage
4252

@@ -62,7 +72,7 @@ jobs:
6272
### Correct Usage
6373
6474
The following workflow passes the commit message through an environment variable, so the shell
65-
does not interpret its contents as code.
75+
does not interpret its contents as code. It also explicitly prevents cache saves with `cache-mode: read`.
6676

6777
```yaml
6878
name: Secure Workflow
@@ -73,6 +83,7 @@ on:
7383
jobs:
7484
build:
7585
permissions: {}
86+
cache-mode: read
7687
runs-on: ubuntu-latest
7788
steps:
7889
- env:
@@ -85,4 +96,5 @@ jobs:
8596

8697
- 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/).
8798
- 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).
99+
- GitHub Docs: [Controlling cache access with cache-mode](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode).
88100
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).

actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Overview
22

3-
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Action's cache from unprivileged workflow, potentially leading to code execution in privileged workflows.
3+
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Actions cache from an unprivileged workflow with cache-write access, potentially leading to code execution in privileged workflows.
44

55
An attacker with the ability to run code in the context of the default branch (e.g. through Code Injection or Execution of Untrusted Code) can exploit this to:
66

@@ -23,7 +23,13 @@ Due to the above design, if something is cached in the context of the default br
2323

2424
## Recommendation
2525

26-
1. Avoid using caching in workflows that handle sensitive operations like releases.
26+
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
27+
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
28+
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
29+
jobs that call reusable workflows to limit the access those workflows can request. Do not use
30+
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.
31+
32+
1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2733
2. If caching must be used:
2834
- Validate restored cache contents before use.
2935
- Use short-lived, workflow-specific cache keys.
@@ -34,9 +40,13 @@ Due to the above design, if something is cached in the context of the default br
3440

3541
## Example
3642

37-
GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
43+
By default, GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
3844
`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope.
39-
This query therefore reports only workflows whose trigger can write to that scope.
45+
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
46+
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
47+
mode set by their caller, but can override an implicit trigger-based default.
48+
This query reports only jobs that can write to the default branch cache scope, accounting for
49+
the trigger, branch, and effective cache mode.
4050

4151
### Incorrect Usage
4252

@@ -71,13 +81,16 @@ jobs:
7181
7282
### Correct Usage
7383
74-
The following workflow checking out untrusted files, but the cache is scoped to the Pull Request.
84+
The following workflow checks out untrusted files in a pull request's isolated cache scope.
85+
It also explicitly prevents cache saves with `cache-mode: read`.
7586

7687
```yaml
7788
name: Secure Workflow
7889
on:
7990
pull_request:
8091
92+
cache-mode: read
93+
8194
jobs:
8295
pr-comment:
8396
permissions: read-all
@@ -99,4 +112,5 @@ jobs:
99112

100113
- 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/).
101114
- 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).
115+
- GitHub Docs: [Controlling cache access with cache-mode](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode).
102116
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).

actions/ql/src/Security/CWE-349/CachePoisoningViaPoisonableStep.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
## Overview
22

3-
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Action's cache from unprivileged workflow, potentially leading to code execution in privileged workflows.
3+
GitHub Actions cache poisoning is a technique that allows an attacker to inject malicious content into the Actions cache from an unprivileged workflow with cache-write access, potentially leading to code execution in privileged workflows.
44

55
An attacker with the ability to run code in the context of the default branch (e.g. through Code Injection or Execution of Untrusted Code) can exploit this to:
66

@@ -23,7 +23,13 @@ Due to the above design, if something is cached in the context of the default br
2323

2424
## Recommendation
2525

26-
1. Avoid using caching in workflows that handle sensitive operations like releases.
26+
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
27+
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
28+
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
29+
jobs that call reusable workflows to limit the access those workflows can request. Do not use
30+
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.
31+
32+
1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2733
2. If caching must be used:
2834
- Validate restored cache contents before use.
2935
- Use short-lived, workflow-specific cache keys.
@@ -34,9 +40,13 @@ Due to the above design, if something is cached in the context of the default br
3440

3541
## Example
3642

37-
GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
43+
By default, GitHub gives workflows triggered by low-trust events, such as `issue_comment`,
3844
`pull_request_target`, and `workflow_run`, read-only access to the default branch cache scope.
39-
This query therefore reports only workflows whose trigger can write to that scope.
45+
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
46+
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
47+
mode set by their caller, but can override an implicit trigger-based default.
48+
This query reports only jobs that can write to the default branch cache scope, accounting for
49+
the trigger, branch, and effective cache mode.
4050

4151
### Incorrect Usage
4252

@@ -67,14 +77,16 @@ jobs:
6777
6878
### Correct Usage
6979
70-
The following workflow runs untrusted code in a non-privileged job and the cache is scoped to the Pull Request branch.
80+
The following workflow runs untrusted code in a non-privileged job with the cache scoped to the
81+
pull request's merge ref. It also explicitly prevents cache saves with `cache-mode: read`.
7182

7283
```yaml
7384
name: Secure Workflow
7485
on:
7586
pull_request:
7687
branches: [main]
7788
permissions: {}
89+
cache-mode: read
7890
jobs:
7991
test:
8092
runs-on: ubuntu-latest
@@ -90,4 +102,5 @@ jobs:
90102

91103
- 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/).
92104
- 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).
105+
- GitHub Docs: [Controlling cache access with cache-mode](https://docs.github.com/actions/reference/workflows-and-actions/dependency-caching#controlling-cache-access-with-cache-mode).
93106
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
category: minorAnalysis
3+
---
4+
* The cache-poisoning queries now honor workflow and job `cache-mode` settings, including overrides and explicit limits in reusable-workflow call chains. Jobs with `read` or `none` access are excluded, while low-trust triggers that explicitly request `write` or `write-only` access can now be reported.
5+
* Cache-poisoning analysis now recognizes unfiltered pushes to the default branch and `pull_request` runs for merged `closed` events with write-capable cache modes.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
on:
2+
workflow_call:
3+
workflow_dispatch:
4+
push: # No cache alert: tag-only pushes cannot write the default-branch cache.
5+
tags: ['v*']
6+
pull_request: # No cache alert: closed PRs target a feature branch.
7+
types: [closed]
8+
branches: [feature]
9+
10+
permissions: {}
11+
12+
jobs:
13+
inherited: # No cache alert: harmless inherited-mode probe.
14+
runs-on: ubuntu-latest
15+
steps:
16+
- run: echo test
17+
18+
read: # No cache alert: read mode prevents saves and conflicts with write-only callers.
19+
cache-mode: read
20+
runs-on: ubuntu-latest
21+
steps:
22+
- run: echo test
23+
24+
write: # No cache alert: harmless probe; write exceeds a write-only caller's limit.
25+
cache-mode: write
26+
runs-on: ubuntu-latest
27+
steps:
28+
- run: echo test
29+
30+
write-only: # No cache alert: harmless probe of permitted cache saves.
31+
cache-mode: write-only
32+
runs-on: ubuntu-latest
33+
steps:
34+
- run: echo test

0 commit comments

Comments
 (0)