{"id":730,"date":"2026-09-24T13:01:48","date_gmt":"2026-09-24T13:01:48","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/github-actions-secrets-miri-cache-leak-the-env-block-at-the-top\/"},"modified":"2026-09-24T13:01:48","modified_gmt":"2026-09-24T13:01:48","slug":"github-actions-secrets-miri-cache-leak-the-env-block-at-the-top","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/github-actions-secrets-miri-cache-leak-the-env-block-at-the-top\/","title":{"rendered":"GitHub Actions Secrets Leaked Through a Cache: The Miri Lesson"},"content":{"rendered":"<p>Short version for the impatient: if a CI job caches <code>target\/<\/code> 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.<\/p>\n<p>I&rsquo;ll admit my first reaction to the headline was a bit smug. I don&rsquo;t run Miri in CI on most client projects, so I figured this one wasn&rsquo;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 &ldquo;assume the entire environment can be written to the filesystem.&rdquo; That isn&rsquo;t a Miri quirk. That&rsquo;s a fair description of half the build tooling I&rsquo;ve ever touched.<\/p>\n<p>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 <code>env:<\/code> block because it&rsquo;s convenient, and then caching build output from the same job.<\/p>\n<h2 id=\"what-actually-leaked-in-plain-terms\">What actually leaked, in plain terms<\/h2>\n<p>Here&rsquo;s the chain from the <a href=\"https:\/\/blog.rust-lang.org\/2026\/09\/21\/github-actions-leaking-secrets-when-miri-output-is-cached\/\" rel=\"nofollow noopener\" target=\"_blank\">Rust blog advisory<\/a>, boiled down.<\/p>\n<p><code>cargo miri<\/code> 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 <code>target\/<\/code>. Every one. If your job had <code>AWS_SECRET_ACCESS_KEY<\/code> or a deploy token in its environment, it went into a file under <code>target\/<\/code>.<\/p>\n<p>On its own, that&rsquo;s a file on a throwaway runner. The trouble is caching. Rust projects cache <code>target\/<\/code> heavily because cold builds are slow, usually with <code>actions\/cache<\/code> or <a href=\"https:\/\/github.com\/Swatinem\/rust-cache\" rel=\"nofollow noopener\" target=\"_blank\">Swatinem\/rust-cache<\/a>. A typical setup lets runs on <code>main<\/code> 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 <code>main<\/code> wrote.<\/p>\n<p>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 <code>target\/<\/code>, 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.<\/p>\n<p>The team scanned public GitHub repositories and found one that was vulnerable, plus seven that didn&rsquo;t look vulnerable but were close enough to get a heads-up. Small numbers. I don&rsquo;t read that as &ldquo;rare&rdquo;, though. The scan could only see public workflow files, and the post itself says the scan was probably imperfect.<\/p>\n<h2 id=\"the-short-term-fix-and-why-i-wouldnt-lean-on-it\">The short-term fix, and why I wouldn&rsquo;t lean on it<\/h2>\n<p>The <a href=\"https:\/\/github.com\/rust-lang\/miri\/pull\/5337\" rel=\"nofollow noopener\" target=\"_blank\">Miri patch<\/a> narrows what gets saved: only <code>CARGO_*<\/code> variables, minus anything matching <code>CARGO_*_TOKEN<\/code>, plus <code>OUT_DIR<\/code>. 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&rsquo;t have the fix yet.<\/p>\n<p>It&rsquo;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 <code>target\/<\/code>. Build scripts are arbitrary code. Any <code>build.rs<\/code> in your dependency tree can read the environment and write whatever it likes into <code>OUT_DIR<\/code>. Almost none do. You&rsquo;re still trusting every crate author never to do it, including the one who adds a debug dump in a patch release at 2am.<\/p>\n<p>So I treat the Miri change as closing one known hole. The workflow change is the real fix.<\/p>\n<h2 id=\"the-workflow-pattern-behind-the-leak\">The workflow pattern behind the leak<\/h2>\n<p>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.<\/p>\n<pre><code class=\"language-yaml\"># before: every step in the job can see both secrets\nname: ci\non: [push, pull_request]\n\nenv:\n  SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n  CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: dtolnay\/rust-toolchain@nightly\n        with:\n          components: miri\n      - uses: Swatinem\/rust-cache@v2\n      - run: cargo test\n      - run: cargo miri test\n      - run: .\/scripts\/upload-sourcemaps.sh\n<\/code><\/pre>\n<p>Nothing in that file looks wrong at a glance, which is exactly why it spreads. The workflow-level <code>env:<\/code> block means <code>cargo miri test<\/code> inherits both tokens, and the rust-cache step saves <code>target\/<\/code> when the job finishes. GitHub&rsquo;s own page on <a href=\"https:\/\/docs.github.com\/en\/actions\/how-tos\/write-workflows\/choose-what-workflows-do\/use-secrets\" rel=\"nofollow noopener\" target=\"_blank\">using secrets in a workflow<\/a> 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.<\/p>\n<h2 id=\"what-id-change-split-jobs-scope-secrets-to-steps\">What I&rsquo;d change: split jobs, scope secrets to steps<\/h2>\n<p>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 <code>env:<\/code>, and no other step sees it.<\/p>\n<pre><code class=\"language-yaml\"># after: the cached job has no secrets\nname: ci\non: [push, pull_request]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - uses: dtolnay\/rust-toolchain@nightly\n        with:\n          components: miri\n      - uses: Swatinem\/rust-cache@v2\n      - run: cargo test\n      - run: cargo miri test\n\n  release-artifacts:\n    needs: test\n    if: github.ref == 'refs\/heads\/main'\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions\/checkout@v4\n      - run: .\/scripts\/upload-sourcemaps.sh\n        env:\n          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}\n<\/code><\/pre>\n<p>The second job never touches the Rust cache, only runs on <code>main<\/code>, and hands one secret to one step. It&rsquo;ll be slower if it has to compile anything. I&rsquo;d take that trade every time.<\/p>\n<p>If you can&rsquo;t split the job today, the advisory lists quicker options: turn off caching for that job, move the secrets onto steps that don&rsquo;t call Miri, or switch Miri off for a while. Any of those is fine as a stopgap while you do the proper split.<\/p>\n<p>There&rsquo;s one sneaky path the advisory mentions almost in passing: a previous step can persist a secret into the environment. The usual way is <code>echo \"TOKEN=...\" &gt;&gt; \"$GITHUB_ENV\"<\/code>. Once that runs, every later step in the job has the value, including the one that runs Miri. So I&rsquo;d grep for <code>GITHUB_ENV<\/code> as part of this audit.<\/p>\n<pre><code class=\"language-bash\"># quick audit across a repo's workflows\ngrep -rnE 'secrets\\.|GITHUB_ENV|rust-cache|actions\/cache' .github\/workflows\/\n<\/code><\/pre>\n<p>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&rsquo;ve found your risky jobs.<\/p>\n<h2 id=\"if-you-think-you-were-exposed\">If you think you were exposed<\/h2>\n<p>Fixing the YAML doesn&rsquo;t remove what&rsquo;s already sitting in the cache. The Rust team&rsquo;s order is: fix the workflow, clear the cache, then consider rotating anything that might have leaked. GitHub&rsquo;s docs on <a href=\"https:\/\/docs.github.com\/en\/actions\/how-tos\/manage-workflow-runs\/manage-caches\" rel=\"nofollow noopener\" target=\"_blank\">managing caches<\/a> cover deleting entries from the UI or with the <code>gh<\/code> CLI:<\/p>\n<pre><code class=\"language-bash\">gh cache list --limit 100\ngh cache delete --all\n<\/code><\/pre>\n<p>On rotation, I&rsquo;d drop the word &ldquo;consider&rdquo;. If a token sat in a cache that PRs could read, and you can&rsquo;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.<\/p>\n<p>I&rsquo;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&rsquo;t have a clever answer for that one. Rotate and move on.<\/p>\n<h2 id=\"this-isnt-really-a-rust-problem\">This isn&rsquo;t really a Rust problem<\/h2>\n<p>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&rsquo;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&rsquo;t checked specific tools for this exact behavior, and I won&rsquo;t pretend I have. The point is that you shouldn&rsquo;t need to check, because the job writing the cache shouldn&rsquo;t hold anything worth stealing.<\/p>\n<p>I wrote about a related habit in <a href=\"https:\/\/abrarqasim.com\/blog\/github-actions-reusable-workflows-the-bug-i-fixed-eleven-times\/\" rel=\"noopener\">the reusable workflows bug I fixed eleven times<\/a>, where copy-pasted workflow blocks kept bringing back the same mistake. Secrets in a top-level <code>env:<\/code> 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.<\/p>\n<p>This is one of the first things I check when I take over a client&rsquo;s CI, and it&rsquo;s part of the DevOps cleanup work you&rsquo;ll find on <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">my portfolio<\/a>.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Open <code>.github\/workflows\/<\/code> in your busiest repo and run the grep above. For every job with a cache step, confirm it has no <code>secrets.<\/code> reference and no <code>GITHUB_ENV<\/code> 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&rsquo;t cache. Then run <code>gh cache delete --all<\/code> and rotate the token. It&rsquo;s maybe half an hour of work, and I&rsquo;d bet you find at least one job you&rsquo;d forgotten existed.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>A Rust advisory showed Miri writing CI env vars into a cached target\/ folder. Here&#8217;s how I scope GitHub Actions secrets so no cache ever holds a token.<\/p>\n","protected":false},"author":2,"featured_media":729,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"A Rust advisory showed Miri writing CI env vars into a cached target\/ folder. Here's how I scope GitHub Actions secrets so no cache ever holds a token.","rank_math_focus_keyword":"github actions secrets","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[302,142,151],"tags":[353,822,708,820,821,64],"class_list":["post-730","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops","category-rust","category-security","tag-caching","tag-ci-security","tag-github-actions","tag-github-actions-secrets","tag-miri","tag-rust"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/730","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=730"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/730\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/729"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=730"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=730"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=730"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}