Short version for the impatient: if a CI job caches target/ and that same job can see a secret as an environment variable, assume the secret can end up in the cache. The Rust Security Response Team just published a concrete case of this with Miri, and the fix they recommend is mostly about how you write your workflow file.
I'll admit my first reaction to the headline was a bit smug. I don't run Miri in CI on most client projects, so I figured this one wasn't mine. Then I read the threat model section of the advisory and the smugness wore off. The line that got me is the warning that many tools "assume the entire environment can be written to the filesystem." That isn't a Miri quirk. That's a fair description of half the build tooling I've ever touched.
So this post is less about Miri and more about the habit that made the Miri issue exploitable: putting GitHub Actions secrets in a top-level env: block because it's convenient, and then caching build output from the same job.
What actually leaked, in plain terms
Here's the chain from the Rust blog advisory, boiled down.
cargo miri invokes Miri several times per build, and it needs to remember build-relevant environment variables between those runs. The code that did this stored every environment variable into target/. Every one. If your job had AWS_SECRET_ACCESS_KEY or a deploy token in its environment, it went into a file under target/.
On its own, that's a file on a throwaway runner. The trouble is caching. Rust projects cache target/ heavily because cold builds are slow, usually with actions/cache or Swatinem/rust-cache. A typical setup lets runs on main write the cache and lets pull requests only read it. That read-only rule stops cache poisoning. It does nothing to stop a PR from reading what main wrote.
And who can run a PR workflow? GitHub asks a maintainer to approve CI for a first-time contributor. After someone has landed one change, their later PRs run CI on every push. The advisory spells out the nasty part: a past contributor could push a commit that dumps the cached target/, grab the secret from the job output, then push a second commit over the first. GitHub sometimes hides overwritten commits in the UI, and run logs get deleted after a few months.
The team scanned public GitHub repositories and found one that was vulnerable, plus seven that didn't look vulnerable but were close enough to get a heads-up. Small numbers. I don't read that as "rare", though. The scan could only see public workflow files, and the post itself says the scan was probably imperfect.
The short-term fix, and why I wouldn't lean on it
The Miri patch narrows what gets saved: only CARGO_* variables, minus anything matching CARGO_*_TOKEN, plus OUT_DIR. The nightly dated 2026-09-22 is the first one that carries it. If you pin an older nightly in your toolchain file, you don't have the fix yet.
It's a good patch. But the advisory is blunt that Cargo, Miri and Rust in general make no promise to keep environment variables out of target/. Build scripts are arbitrary code. Any build.rs in your dependency tree can read the environment and write whatever it likes into OUT_DIR. Almost none do. You're still trusting every crate author never to do it, including the one who adds a debug dump in a patch release at 2am.
So I treat the Miri change as closing one known hole. The workflow change is the real fix.
The workflow pattern behind the leak
This is roughly what I see in a lot of Rust repos, and in a couple of my own older ones too. Secrets live at the top because some step near the end needs them, and the cache step sits at the start of the same job.
# before: every step in the job can see both secrets
name: ci
on: [push, pull_request]
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- uses: Swatinem/rust-cache@v2
- run: cargo test
- run: cargo miri test
- run: ./scripts/upload-sourcemaps.sh
Nothing in that file looks wrong at a glance, which is exactly why it spreads. The workflow-level env: block means cargo miri test inherits both tokens, and the rust-cache step saves target/ when the job finishes. GitHub's own page on using secrets in a workflow passes secrets per step in its examples. I think a lot of us skim past that detail because the top-level block is less typing.
What I'd change: split jobs, scope secrets to steps
Two rules. A job that writes a shared cache gets no secrets at all. A step that needs a secret gets it in its own env:, and no other step sees it.
# after: the cached job has no secrets
name: ci
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
with:
components: miri
- uses: Swatinem/rust-cache@v2
- run: cargo test
- run: cargo miri test
release-artifacts:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./scripts/upload-sourcemaps.sh
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
The second job never touches the Rust cache, only runs on main, and hands one secret to one step. It'll be slower if it has to compile anything. I'd take that trade every time.
If you can't split the job today, the advisory lists quicker options: turn off caching for that job, move the secrets onto steps that don't call Miri, or switch Miri off for a while. Any of those is fine as a stopgap while you do the proper split.
There's one sneaky path the advisory mentions almost in passing: a previous step can persist a secret into the environment. The usual way is echo "TOKEN=..." >> "$GITHUB_ENV". Once that runs, every later step in the job has the value, including the one that runs Miri. So I'd grep for GITHUB_ENV as part of this audit.
# quick audit across a repo's workflows
grep -rnE 'secrets\.|GITHUB_ENV|rust-cache|actions/cache' .github/workflows/
That one command shows where secrets enter, where they get promoted to job-wide env, and which jobs cache. Cross-reference the three and you've found your risky jobs.
If you think you were exposed
Fixing the YAML doesn't remove what's already sitting in the cache. The Rust team's order is: fix the workflow, clear the cache, then consider rotating anything that might have leaked. GitHub's docs on managing caches cover deleting entries from the UI or with the gh CLI:
gh cache list --limit 100
gh cache delete --all
On rotation, I'd drop the word "consider". If a token sat in a cache that PRs could read, and you can't prove nobody read it, rotate it. Rotating a Sentry token takes five minutes. Explaining to a client why their crates.io account published a strange version takes a lot longer.
I'm less sure what to say about the logs. Old CI logs expire, so you may not be able to confirm or rule out access at all. I don't have a clever answer for that one. Rotate and move on.
This isn't really a Rust problem
I keep coming back to this. Miri got caught because someone went looking (Predrag Gruevski reported it, and the team credits him in the post). The same pattern can show up anywhere a tool writes its environment into an output directory and that directory gets cached. I'd look hard at anything that snapshots env for reproducible builds, and at Docker layer caches built with secrets passed as build args. I haven't checked specific tools for this exact behavior, and I won't pretend I have. The point is that you shouldn't need to check, because the job writing the cache shouldn't hold anything worth stealing.
I wrote about a related habit in the reusable workflows bug I fixed eleven times, where copy-pasted workflow blocks kept bringing back the same mistake. Secrets in a top-level env: spread the same way. Someone adds one for a single step, the next person copies the file into a new repo, and a year later every job in the org can see every token.
This is one of the first things I check when I take over a client's CI, and it's part of the DevOps cleanup work you'll find on my portfolio.
What to do this week
Open .github/workflows/ in your busiest repo and run the grep above. For every job with a cache step, confirm it has no secrets. reference and no GITHUB_ENV write that carries a secret. When you find one, move the secret to the single step that needs it, or into a separate job that doesn't cache. Then run gh cache delete --all and rotate the token. It's maybe half an hour of work, and I'd bet you find at least one job you'd forgotten existed.
Originally published at abrarqasim.com. I write there about React, PHP, Rust, Go and the AI tooling around them.
Top comments (0)