Software

Does your GitHub Actions cache leak secrets? A Miri audit

By · Thu Oct 15 2026 · 8 min read · 0 views

View as a Web Story

Software#github actions#rust#miri#ci security#secrets#cache

Four-question audit for the Miri GitHub Actions cache secrets leak in Rust CI

Does your GitHub Actions cache leak secrets? A Miri audit

Is your workflow exposed to the Miri secrets leak?

You are exposed only when four conditions hold together. The Rust project published the details on September 21, 2026, in its post on GitHub Actions leaking secrets when Miri output is cached. Answer these four questions for each workflow in your repository:

  1. Does any job run cargo miri?
  2. Can that job see a secret as an environment variable, set at the step, job or workflow level?
  3. Does the job cache target/, for example with actions/cache or swatinem/rust-cache?
  4. Can pull requests read that cache?

Four "yes" answers mean you should act today. A single "no" breaks the chain. The Rust team adds a wider warning: a job that holds secrets and writes to a public cache is at risk even without Miri, because many tools assume they may write the whole environment to disk.

Four-question audit for the Miri cache leak, ending in fix or no action

What actually leaked, and how?

Miri is a Rust interpreter that detects undefined behaviour in unsafe code. The cargo miri command runs it several times during one build. To stay consistent between runs, Miri saved the build-relevant environment variables into the target/ directory. The Rust team says the problem was not a vulnerability by itself, but it becomes exploitable once a cache is involved.

A GitHub Actions cache is a store that keeps files between workflow runs, usually the target/ folder, so the next run compiles less. GitHub's caching documentation says a pull request run can restore caches from its own branch and from its base branch. It also warns: "Anyone with read access can create a pull request on a repository and access the contents of a cache."

Put those together. A trusted run on main writes target/, with the secret inside. An attacker opens a pull request that changes the workflow to print the cache. The pull request restores the cache from the base branch and prints your secret in its job log.

According to a secondary write-up of the advisory, the Rust team scanned public repositories. It found one vulnerable repository and seven more that looked close enough to warrant a heads-up. That scan could only see public workflow files, so the true number is probably higher.

Which Miri version has the fix?

The first Miri nightly with the fix is dated September 22, 2026, per the Rust blog. The patch, Miri pull request 5337, saves only CARGO_* variables, excludes CARGO_*_TOKEN, and adds OUT_DIR. A nightly pinned before that date still has the problem. Check your rust-toolchain.toml and any toolchain: nightly-2026-... line.

What did we find when we tried to reproduce it?

We could not reproduce the leak with a plain run, and you should treat that as a warning about the limits of a quick check. Our test setup was Rust nightly-2026-09-20, which predates the fix, on an Apple Silicon Mac. We exported two fake secrets, ran cargo miri run and then cargo miri test on a new project, and searched the whole target/ folder for both values with grep -a. We found nothing.

Two readings fit that result. The first is that Miri stores environment data in a form that a plain-text search does not match. The second is that exposure depends on the workflow, such as MIRIFLAGS or a particular sequence of commands. We cannot tell which from outside. The consequence is clear: a clean grep does not prove your cache is clean. It can only prove the opposite.

Advertisement

That is why the audit above asks about conditions, not about file contents. Use the scanner below as a canary for plain-text leaks, which includes anything a build.rs script writes. Do not use it as a clean bill of health.

How do you scan a cache for leaked secrets?

Run a script that searches a folder for the values of your secret-looking environment variables, and fails if any appears. Call it as scan-cache.sh <dir> [VAR ...]; it exits with code 1 when a value is found. We wrote one in plain bash. It prints the variable name and the file, never the value. It skips values shorter than eight characters, because short strings match too much.

#!/usr/bin/env bash
dir=${1:?usage: scan-cache.sh <dir> [VAR ...]}; shift
names="$*"
[ -z "$names" ] && names=$(compgen -e | grep -E 'TOKEN|SECRET|KEY|PASS|CREDENTIAL')
hits=0
for n in $names; do
  v=${!n}
  [ ${#v} -ge 8 ] || continue
  case $v in *$'\n'*) continue;; esac
  files=$(grep -rlaF -- "$v" "$dir" 2>/dev/null)
  if [ -n "$files" ]; then
    echo "LEAK: \$$n found in:"; echo "$files" | sed 's/^/  /'; hits=1
  fi
done
[ $hits -eq 0 ] && echo "clean: no secret values found in $dir"
exit $hits

We tested it with a planted canary on 2 folders. A file in a fake cache folder held a made-up token, and the script returned LEAK: $MY_API_TOKEN found in: plus the path, with exit code 1. A second folder without the token returned clean and exit code 0. It also runs on macOS bash 3.2, which has no mapfile.

How long does the scan take?

About 15 seconds for 236 MB. We measured 3 runs of the script on a 236 MB folder. We built a synthetic Rust crate with incremental compilation on, which produced a 236 MB target/ folder. Three runs with three secrets took 15.4, 16.0 and 15.3 seconds. The time grows with the number of secrets and the folder size, so scan on a schedule, not on every commit.

Run it as a step at the end of a job that holds secrets:

- name: Scan target for secret values
  run: bash scripts/scan-cache.sh target API_TOKEN DEPLOY_KEY
  env:
    API_TOKEN: ${{ secrets.API_TOKEN }}
    DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}

Name the variables on the command line so the script does not depend on guessing. Notice that this step itself holds the secrets. Run it after the cache save, and never let its job write to a shared cache.

How do you fix a workflow that fails the audit?

Apply four fixes, in this order. The first three stop the exposure. The fourth cleans up the past.

Fix order for an exposed workflow: scope secrets, split jobs, delete caches, rotate secrets

  1. Scope each secret to one step. Remove secrets from workflow-level and job-level env: blocks. Pass a secret only to the step that needs it.
  2. Split jobs by trust. A job that writes a shared cache gets no secrets. A release or deploy job runs on main, restores no shared cache, and holds the secrets.
  3. Update Miri or stop caching. Use a nightly dated 2026-09-22 or later, or switch off caching for any job that runs Miri while you wait.
  4. Delete caches, then rotate. Delete the old cache entries first, because rotating a secret while the old cache still exists leaves the old value readable. Then rotate every secret the job could see.

Here is a hardened workflow. The test job has no secrets and can use the cache. The release job has the secret in one step and does not touch the cache:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: swatinem/rust-cache@v2
      - run: cargo +nightly miri test
  release:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cargo publish
        env:
          CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}

The hardened workflow uses ubuntu-latest, which moves to Ubuntu 26.04 on October 19, as our note on the ubuntu-latest move to Ubuntu 26.04 explains, so retest it after the switch. Delete every cache from the command line with GitHub's CLI:

gh cache delete --all

Check that your own history is clean, too. A step earlier in a job can write a secret into GITHUB_ENV, and every later step then inherits it. Search your workflows for the patterns that matter:

grep -rnE 'secrets\.|GITHUB_ENV|rust-cache|actions/cache' .github/workflows

How do you see what your caches hold right now?

Use gh cache list, which shows each cache entry's key, size and last access time without downloading anything. The command takes flags that matter for this audit, and we checked each one against the GitHub CLI help on version 2.98.0:

gh cache list --sort size_in_bytes --limit 10
gh cache list --ref refs/heads/main --key Linux
gh cache list --json key,ref,sizeInBytes --jq '.[] | select(.ref != "refs/heads/main")'

The first line lists your ten largest entries, and the second narrows the list to entries on main whose key starts with Linux. The third prints entries written by any other ref, such as refs/pull/12/merge. Entries on main are the ones a pull request can restore from its base branch, so they are the ones to worry about.

Look at the last-accessed time too. A cache that nobody has read for a week is about to expire, according to GitHub's seven-day rule. A cache that a pull request read recently is one that you should treat as exposed. The list shows metadata only, so it cannot tell you whether a secret sits inside an entry. For that, restore the cache into a scratch job and run the scanner above.

Do low-trust triggers change the risk?

Yes, they reduce it for forks, but not for your own contributors. GitHub's documentation says events such as pull_request_target, issue_comment and workflow_run get read-only access to default-branch caches by default. Only trusted triggers like push and schedule can write them.

The risk therefore sits with people who can already push a branch or open a pull request that runs your workflow. In a small open-source project, that is anyone who has ever had a pull request merged. In a company repository, it is every developer, including a compromised laptop.

The cache size limits matter for cleanup, too. GitHub gives each repository 10 GB of cache by default and removes entries not accessed for seven days, so an old poisoned entry can expire on its own. Do not wait for that. A busy repository touches its cache daily, and the entry never ages out.

What is the rule to take away?

The rule is simple: never let a job that holds a secret write to a cache that another job reads. Miri is only the example. Any tool can write its environment to disk, and a build.rs script runs arbitrary code. The same pattern caught a Gradle action in 2023, as the advisory for CVE-2023-30853 records, and its fix was the same: update and delete the cache.

If you only have time for one change, move your secrets out of workflow-level env: blocks. It takes minutes and works for every tool, not only Miri.

Advertisement

FAQ

Does Miri leak secrets in GitHub Actions?

It did, in nightlies before September 22, 2026. Miri saved every environment variable into `target/`. If CI cached that folder and pull requests could read the cache, a secret in the job environment could be read from it. A newer nightly saves only `CARGO_*` variables.

How do I know if my GitHub Actions cache has secrets in it?

You cannot be certain, but you can look. Search the restored cache folder for your secret values with a plain-text scan, and check whether the job ran Miri with secrets in its environment. A clean scan is not proof, because the data may be stored in a different form.

Is swatinem/rust-cache vulnerable?

The cache action itself is not the flaw. The Rust advisory names it as a common way to cache `target/`, and the exposure comes from what ends up inside that folder. Remove secrets from the job that writes the cache, and the cache action is safe to keep.

Do I need to rotate secrets after a cache leak?

Yes, if a job with those secrets ran Miri on a cached `target/` that pull requests could read. Delete the cache entries first with `gh cache delete --all`, then rotate every secret the job could see. Old logs may have expired, so you cannot prove nobody read them.

Which GitHub Actions events can write the cache?

Trusted events such as `push` and `schedule`. GitHub's documentation says low-trust events like `pull_request_target`, `issue_comment` and `workflow_run` get read-only access to default-branch caches by default. A pull request's own run can only create caches scoped to its merge ref.

Comments

Loading…

Sign in to join the conversation.

Related posts