很多团队把 GitHub Actions 缓存视为单纯的性能优化,但缓存实际上是一个能跨工作流运行、跨分支传递文件的持久化通道。Rust Security Response Team 收到报告:Miri 会把环境变量保存到 target/,如果 CI 同时缓存该目录,受信任分支中的密钥就可能随缓存流向 Pull Request 作业。
问题不只是某个工具“写了不该写的数据”,而是三个常见配置叠加后的结果:运行 cargo miri、向作业注入密钥、缓存 target/ 并允许 PR 恢复该缓存。
泄露链条是怎样形成的
cargo miri 在多次调用之间需要保留与构建有关的环境变量。此前的实现选择将全部环境变量保存到 target/。这意味着下面这些内容都可能落盘:
- 在工作流或 job 级别通过
env设置的变量; - 通过
${{ secrets.* }}注入当前步骤的令牌; - 前序步骤通过某种持久化方式加入环境的值;
- 与 Cargo 构建并无关系,但碰巧存在于进程环境中的凭据。
单次运行结束后删除 runner,本来能阻断这类数据继续传播。但如果工作流使用 actions/cache、swatinem/rust-cache 或其他机制保存 target/,这些文件会进入 GitHub Actions 缓存。
典型的缓存策略允许主分支等受信任运行写入缓存,同时允许 PR 读取已有缓存。这样可以防止 PR 直接污染主分支缓存,却不能防止 PR 读取一份已经夹带密钥的缓存。攻击者不需要让 PR 作业直接获得 ${{ secrets.* }},只需要读取此前由受信任作业写入的文件。
这类行为还不容易追溯:有权限持续触发 PR CI 的贡献者可以先提交检查缓存内容的代码,再用后续提交覆盖痕迹;被覆盖提交和历史 CI 日志也不会永久保留。
如何判断仓库是否受影响
需要同时检查四个条件:
- CI 中运行了
cargo miri; - 运行 Miri 的步骤可以访问密钥环境变量;
- 工作流缓存了
target/; - Pull Request 作业能够恢复该缓存。
可以先在仓库根目录执行下面的静态检查:
rg -n --hidden \
'cargo( +\+[^ ]+)? +miri|actions/cache|rust-cache|target/?|secrets\.' \
.github/workflows
如果没有安装 ripgrep,也可以使用:
grep -RInE \
'cargo( +\+[^ ]+)? +miri|actions/cache|rust-cache|target/?|secrets\.' \
.github/workflows
搜索结果不能直接证明泄露,但能快速定位需要人工关联的配置。尤其要留意这种组合:
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
steps:
- run: cargo miri test
- uses: actions/cache@v4
with:
path: target
key: rust-target-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
这里的问题不是 RELEASE_TOKEN 是否真的被测试代码使用,而是它对整个 job 可见,Miri 进程也会继承它。随后对 target/ 的缓存把临时环境变成了持久化数据。
还应检查 shell 脚本、复合 Action 和可复用工作流。顶层 YAML 中没有 cargo miri,不代表被调用的脚本没有运行它;同样,密钥也可能在被调用的 workflow 中以 job 级 env 注入。
更安全的工作流写法
最稳妥的临时方案,是让 Miri job 完全接触不到密钥,并且不缓存 target/。下面是一份可以直接改造的最小工作流:
name: Miri
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
miri:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install nightly and Miri
run: rustup toolchain install nightly --profile minimal --component miri
- name: Run Miri
run: cargo +nightly miri test
运行前只需要根据项目情况调整测试命令,例如增加 --workspace 或 feature 参数。这个 job 没有工作流级、job 级或步骤级密钥,也没有保存 target/。
如果确实需要下载缓存,可以只缓存 Cargo 下载内容,而不要缓存构建输出:
- name: Cache Cargo downloads only
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-downloads-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-downloads-${{ runner.os }}-
这并不意味着这些目录在所有威胁模型下都天然安全,但它避免了把本次编译产生的 target/ 作为跨信任边界的共享产物。
发布、上传或签名步骤若必须使用密钥,应放入独立 job,并限制为受信任事件。例如可以这样实践:
publish:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: miri
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Publish artifact
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: ./scripts/publish.sh
关键点是把密钥限制在真正使用它的单个步骤中,不要放在 workflow 或 job 顶层。这个示例还假设 publish.sh 不会把令牌写入随后公开的产物或缓存;脚本本身仍需审计。
修复工具之后,为什么仍不能放松缓存边界
Miri 的短期修复是只保留 CARGO_* 环境变量和 OUT_DIR,并排除形如 CARGO_*_TOKEN 的变量。更长期的方向,是由 Miri 与 Cargo 建立更精确的机制,只传递真正影响构建的环境变量。
来源说明修复计划进入标记为 2026-09-22 的 upcoming nightly,同时也提醒补丁可能尚未出现在当前 nightly 中。因此,不应仅凭“使用 nightly”判断已经安全;应核对实际工具链版本和发布状态。
更重要的是,Rust、Cargo 和 Miri 并不承诺任意环境变量永远不会进入 target/。即使不使用 Miri,依赖包的 build.rs、编译器包装器、测试工具或项目自己的生成脚本,也可能把环境写入文件、诊断信息或构建产物。缓存系统看不懂哪些字节属于秘密,它只负责保存目录。
因此应该采用更一般的规则:
能写入公共或 PR 可读缓存的作业,不应同时拥有秘密。
如果某项构建确实需要访问私有服务,可以将它拆成不向低信任上下文共享缓存的独立 job,或使用短期、最小权限的凭据,并明确审查其输出路径。
发现风险后的处置清单
仅修改 YAML 不能清除已经存在的缓存。建议按以下顺序处理:
- 立即停止缓存 Miri job 的
target/; - 将所有密钥从 workflow/job 级
env下移到必要步骤; - 删除仓库已有的 Actions 缓存;
- 轮换可能在受影响期间出现过的令牌、密钥和证书;
- 检查历史 PR 工作流、异常日志访问和可疑提交;
- 验证所用 Miri 版本是否包含修复;
- 审计其他会写入缓存目录的构建工具和脚本。
安装 GitHub CLI 并完成 gh auth login 后,可以列出和清理当前仓库缓存:
gh cache list --limit 100
gh cache delete --all --confirm
请在正确的仓库目录中运行,或通过 --repo OWNER/REPO 明确指定仓库。删除缓存会让后续 CI 暂时变慢,但不能撤销已经发生的读取,因此密钥轮换仍然必要。
这次事件最值得保留的经验,不是简单地把 Miri 升级到某个版本,而是重新定义 CI 的信任边界:环境变量可能落盘,构建目录可能包含敏感信息,而缓存会把一次运行中的文件带到未来的另一次运行。只要 PR 能读取缓存,就应把该缓存视为对外可见的数据。