Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions actions/ql/lib/change-notes/2026-09-17-cache-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
category: feature
---
* 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.
* `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.
6 changes: 6 additions & 0 deletions actions/ql/lib/codeql/actions/Ast.qll
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ class Workflow extends AstNode instanceof WorkflowImpl {

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

/** Gets the explicitly declared cache mode for this workflow, if any. */
string getCacheMode() { result = super.getCacheMode() }

Strategy getStrategy() { result = super.getStrategy() }

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

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

/** Gets the explicitly declared cache mode for this job, if any. */
string getCacheMode() { result = super.getCacheMode() }

Strategy getStrategy() { result = super.getStrategy() }

string getARunsOnLabel() { result = super.getARunsOnLabel() }
Expand Down
6 changes: 6 additions & 0 deletions actions/ql/lib/codeql/actions/ast/internal/Ast.qll
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,9 @@ class WorkflowImpl extends AstNodeImpl, TWorkflowNode {
/** Gets the permissions granted to this workflow. */
PermissionsImpl getPermissions() { result.getNode() = n.lookup("permissions") }

/** Gets the explicitly declared cache mode for this workflow, if any. */
string getCacheMode() { result = n.lookup("cache-mode").(YamlString).getValue() }

/** Gets the trigger event that starts this workflow. */
override EventImpl getATriggerEvent() { this.getOn().getAnEvent() = result }

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

/** Gets the explicitly declared cache mode for this job, if any. */
string getCacheMode() { result = n.lookup("cache-mode").(YamlString).getValue() }

/** Gets the strategy for this job. */
StrategyImpl getStrategy() { result.getNode() = n.lookup("strategy") }

Expand Down
72 changes: 64 additions & 8 deletions actions/ql/lib/codeql/actions/security/CachePoisoningQuery.qll
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,27 @@ string defaultBranchTriggerEvent() {
]
}

/**
* Holds if `e` can run in the default-branch cache scope.
* A pull request closed by a merge uses its base branch instead of its merge ref.
*/
predicate runsOnDefaultBranch(Event e) {
(
e.getName() = defaultBranchTriggerEvent() and
not e.getName() = "pull_request_target"
or
e.getName() = "push" and
e.getAPropertyValue("branches") = defaultBranchNames()
or
e.getName() = "pull_request_target" and
(
e.getName() = "push" and
(
e.hasProperty(["branches", "branches-ignore"])
or
not e.hasProperty(["tags", "tags-ignore"])
Comment thread
JarLob marked this conversation as resolved.
)
or
e.getName() = "pull_request_target"
or
e.getName() = "pull_request" and e.getAnActivityType() = "closed"
) and
(
// no filtering
not e.hasProperty("branches") and not e.hasProperty("branches-ignore")
Expand Down Expand Up @@ -50,17 +62,61 @@ private string defaultBranchCacheWriteEvent() {
]
}

private predicate eventHasDefaultBranchCacheWriteAccess(Event event) {
runsOnDefaultBranch(event) and event.getName() = defaultBranchCacheWriteEvent()
private string getDeclaredCacheMode(Job job) {
result = job.getCacheMode()
or
not exists(job.getCacheMode()) and
result = job.getWorkflow().getCacheMode()
}

private predicate cacheModeIsAllowed(string mode, string inheritedMode) {
mode = ["read", "write", "write-only", "none"] and
inheritedMode = ["default", "read", "write", "write-only", "none"] and
(inheritedMode = ["default", "write"] or mode = ["none", inheritedMode])
}

/**
* Gets the effective declared cache mode, or `default` if no mode was declared in the call chain.
* An event's implicit default does not limit the modes a reusable workflow can request.
* Both the callee's workflow-level mode and its job-level modes must fit the caller's explicit limit.
*/
private string getEffectiveCacheMode(Job job, Event event) {
exists(string inheritedMode |
(
job.getWorkflow().getOn().getAnEvent() = event and
not event.getName() = "workflow_call" and
inheritedMode = "default"
or
inheritedMode =
getEffectiveCacheMode(job.getWorkflow().(ReusableWorkflow).getACaller(), event)
)
|
(
not exists(job.getWorkflow().getCacheMode())
or
cacheModeIsAllowed(job.getWorkflow().getCacheMode(), inheritedMode)
) and
if exists(getDeclaredCacheMode(job))
then
result = getDeclaredCacheMode(job) and
cacheModeIsAllowed(result, inheritedMode)
else result = inheritedMode
)
}

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

abstract class CacheWritingStep extends Step {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Overview

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.
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.

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:

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

## Recommendation

1. Avoid using caching in workflows that handle sensitive operations like releases.
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
jobs that call reusable workflows to limit the access those workflows can request. Do not use
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.

1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2. If caching must be used:
- Validate restored cache contents before use.
- Use short-lived, workflow-specific cache keys.
Expand All @@ -34,9 +40,13 @@ 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`,
By default, 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.
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
mode set by their caller, but can override an implicit trigger-based default.
This query reports only jobs that can write to the default branch cache scope, accounting for
the trigger, branch, and effective cache mode.

### Incorrect Usage

Expand All @@ -62,7 +72,7 @@ jobs:
### Correct Usage

The following workflow passes the commit message through an environment variable, so the shell
does not interpret its contents as code.
does not interpret its contents as code. It also explicitly prevents cache saves with `cache-mode: read`.

```yaml
name: Secure Workflow
Expand All @@ -73,6 +83,7 @@ on:
jobs:
build:
permissions: {}
cache-mode: read
runs-on: ubuntu-latest
steps:
- env:
Expand All @@ -85,4 +96,5 @@ jobs:

- 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: [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).
- 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).
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).
24 changes: 19 additions & 5 deletions actions/ql/src/Security/CWE-349/CachePoisoningViaDirectCache.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Overview

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.
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.

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:

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

## Recommendation

1. Avoid using caching in workflows that handle sensitive operations like releases.
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
jobs that call reusable workflows to limit the access those workflows can request. Do not use
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.

1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2. If caching must be used:
- Validate restored cache contents before use.
- Use short-lived, workflow-specific cache keys.
Expand All @@ -34,9 +40,13 @@ 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`,
By default, 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.
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
mode set by their caller, but can override an implicit trigger-based default.
This query reports only jobs that can write to the default branch cache scope, accounting for
the trigger, branch, and effective cache mode.

### Incorrect Usage

Expand Down Expand Up @@ -71,13 +81,16 @@ jobs:

### Correct Usage

The following workflow checking out untrusted files, but the cache is scoped to the Pull Request.
The following workflow checks out untrusted files in a pull request's isolated cache scope.
It also explicitly prevents cache saves with `cache-mode: read`.

```yaml
name: Secure Workflow
on:
pull_request:

cache-mode: read

jobs:
pr-comment:
permissions: read-all
Expand All @@ -99,4 +112,5 @@ jobs:

- 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: [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).
- 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).
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## Overview

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.
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.

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:

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

## Recommendation

1. Avoid using caching in workflows that handle sensitive operations like releases.
Set `cache-mode: read` on workflows or jobs that process untrusted input and only need to restore
caches, or `cache-mode: none` if they do not need cache access. These restrictions are enforced by
the cache service. Job-level settings override workflow-level settings. Set an explicit mode on
jobs that call reusable workflows to limit the access those workflows can request. Do not use
`write-only` to prevent cache poisoning: it prevents restores but still permits saves.

1. Avoid restoring caches in workflows that handle sensitive operations like releases. Use `cache-mode: none` to disable cache access.
2. If caching must be used:
- Validate restored cache contents before use.
- Use short-lived, workflow-specific cache keys.
Expand All @@ -34,9 +40,13 @@ 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`,
By default, 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.
An explicit `cache-mode: write` or `cache-mode: write-only` grants write access even for these
triggers, while `read` and `none` prevent cache saves. Reusable workflows cannot exceed an explicit
mode set by their caller, but can override an implicit trigger-based default.
This query reports only jobs that can write to the default branch cache scope, accounting for
the trigger, branch, and effective cache mode.

### Incorrect Usage

Expand Down Expand Up @@ -67,14 +77,16 @@ jobs:

### Correct Usage

The following workflow runs untrusted code in a non-privileged job and the cache is scoped to the Pull Request branch.
The following workflow runs untrusted code in a non-privileged job with the cache scoped to the
pull request's merge ref. It also explicitly prevents cache saves with `cache-mode: read`.

```yaml
name: Secure Workflow
on:
pull_request:
branches: [main]
permissions: {}
cache-mode: read
jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -90,4 +102,5 @@ jobs:

- 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: [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).
- 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).
- Scribe Security Blog: [Cache Poisoning in GitHub Actions](https://scribesecurity.com/blog/github-cache-poisoning/).
5 changes: 5 additions & 0 deletions actions/ql/src/change-notes/2026-09-17-cache-mode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
category: minorAnalysis
---
* 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.
* Cache-poisoning analysis now recognizes unfiltered pushes to the default branch and `pull_request` runs for merged `closed` events with write-capable cache modes.
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
on:
workflow_call:
workflow_dispatch:
push: # No cache alert: tag-only pushes cannot write the default-branch cache.
tags: ['v*']
pull_request: # No cache alert: closed PRs target a feature branch.
types: [closed]
branches: [feature]

permissions: {}

jobs:
inherited: # No cache alert: harmless inherited-mode probe.
runs-on: ubuntu-latest
steps:
- run: echo test

read: # No cache alert: read mode prevents saves and conflicts with write-only callers.
cache-mode: read
runs-on: ubuntu-latest
steps:
- run: echo test

write: # No cache alert: harmless probe; write exceeds a write-only caller's limit.
cache-mode: write
runs-on: ubuntu-latest
steps:
- run: echo test

write-only: # No cache alert: harmless probe of permitted cache saves.
cache-mode: write-only
runs-on: ubuntu-latest
steps:
- run: echo test
Loading
Loading