把 Actions 固定到标签为什么挡不住:三层失效

发布时间:2026/10/21 20:52:58
把 Actions 固定到标签为什么挡不住:三层失效 2025 年 3 月tj-actions/changed-files这个被超过 23,000 个仓库使用的 Action 被入侵CVE-2025-30066CWE-506。攻击者做的动作很少把一个恶意提交推上去然后把已有的几个发布标签重新指向它。凡是按标签引用该 Action 的流水线在那两天里跑的都是恶意代码。这件事的对策通常会被总结成一句话把第三方 Action 固定到版本。但这句建议预设了一个不变量——一个版本标识对应一份确定的产物而这次事件显示它在三个层面上各自失效了一次而三个层面需要的修复机制互不替代。这一篇与同系列上一篇讲的是供应链上的不同问题。上一篇处理的是哪些文件会随源码一起走xz 那次的释放包与仓库差异这一篇处理的是一个版本标识究竟标识了什么。前者是文件集合的问题后者是引用语义的问题两者的排查手段也不同前者要列文件后者要问“这个标识在哪一层的控制之下”。先看实际发生了什么因为细节决定了三层分别指什么。那次事件里实际发生了什么受影响区间是0 → 46.0.1last_known_affected: 45.0.7时间窗是 2025 年 3 月 14 日到 15 日恶意提交是0e58ed8671d6b60d0890c21b07f8835ace038e67。公告列出的被重指向标签包括v1.0.0、v35.7.7-sec与v44.5.1也就是说一个用v35.7.7-sec的流水线在攻击者改标签之前和之后引用的是同一个字符串而指向的是不同的代码。外泄方式决定了后面要讲的“另一个面”。恶意代码从 Runner Worker 进程的内存中提取密钥双重 base64 编码后打印到 Actions 的构建日志里。公告的原话是This script extracted secrets from the Runner Worker process memory and printed them in GitHub Actions logs, making them publicly accessible in repositories with public workflow logs.也就是说密钥没有被发往任何攻击者服务器。整个链条上的网络出站只有一处拉取那个执行扫描的脚本。公共仓库的 Actions 日志是公开可读的所以“打印”这个动作本身就完成了外泄。而这个唯一的外发动作恰好就是它被发现的原因。公告记录说这次事件由安全团队的网络异常检测发现被标记的是一次往gist.githubusercontent.com的出站请求而不是别的什么。把这两件事放在一起看会得到一个有点反直觉的结论外泄通道选了最隐蔽的那种日志却因为拉取脚本这一步不得不联网而暴露。如果那段脚本能以别的方式送到目标机器上比如它本来就在镜像里、或者通过一次看似正常的依赖拉取这条链上就没有任何异常网络行为可供检测。这一点对检测面的判断有直接影响。检测到一个静默的外泄通道靠的往往是攻击者为了维持链条而不得不做的那个“不静默”的动作。这类信号是机会性的它在这次被抓住了但它不构成对这条通道的通用覆盖。那种“因为被检测到所以这类问题可控”的推断前提是攻击者必须联网而这个前提在别的投递方式下不成立。规模数字要读准。公告说的“超过 23,000 个仓库”指的是使用这个 Action 的仓库数不是 Actions 市场的整体规模——CISA 的公告里没有给任何仓库计数只说它是一个“popular third-party GitHub Action”。把前者当成后者是这句数字最常见的误读。第一层标签不是一个标识Git 的标签有两种而这里涉及的是可变的那种。一个轻量标签只是某个引用的别名即使是附注标签它所在的那个引用本身也可以被重新指向只要有人能推送到那个仓库。tj-actions这次就是这条路径。攻击者拿到了tj-actions-bot账号的凭据于是可以直接改写标签引用。用户的 workflow 文件里那一行uses: tj-actions/changed-filesv35一个字都没变行为却变了。这一层失效最直接的证据不是攻击本身而是今天可以查到的状态。标签v45.0.7现在指向提交a284dc1814e3fd07f2e34267fc8f81227ed29fb8而这个提交自己的提交信息是Upgraded to v45.0.8 (#2462)。一个标着 45.0.7 的标签指向了一个自称是 45.0.8 的提交。这不是攻击的残留而是“标签只是一个可被移动的引用”这件事的常态表象它在事件过去一年多之后仍然摆在那里任何人不带任何前置知识都能查到。同时那个恶意提交现在已经不可达GitHub API 对它的响应是422 No commit found for SHA仓库里全部 379 个标签引用中没有任何一个指向它。这说明清理工作做过了但标签机制本身没有变。这一层的严重性有一个前置原因GitHub 的文档写得很直接This means that a compromise of a single action within a workflow can be very significant, as that compromised action would have access to all secrets configured on your repository, and may be able to use theGITHUB_TOKENto write to the repository.Action 不是一个被沙箱限制的依赖它是流水线的一个执行步骤与你的构建脚本享有同样的凭据。这一点解释了为什么“改一个标签”这种看起来很小的动作能一次影响两万多个仓库受影响的不是引用了某段有问题代码的项目而是把凭据交给了那一步的所有项目。这一层最能说明问题的一处材料是那份公告自己的补救建议。它在同一个列表里写了两句话If you are using tagged versions (e.g.,v35,v44.5.1), no action is required as these tags have been updated and are now safe to use.If your workflows reference the malicious commit directly by its SHA, update them immediately.把这两句并排读。用可变标签的人被告知“无需动作”而用固定 SHA 的人被告知“必须立即更新”。这与通行建议的方向正好相反通行建议说标签不可信、SHA 可信。这里不做动机判断公告要传达的可能是“标签已经被我们改回正确指向了所以你可以继续用”这个事实层面的意思。但从使用者的角度看它给出的行动指示是清楚的依赖一个可被移动的引用是无需动作的依赖一个不可移动的标识是需要动作的。而后者之所以需要动作恰恰因为它不可移动那个 SHA 指向的就是恶意提交本身。这一层失效的修复方向是“把不可变标识做出来”。GitHub 的安全加固文档在这条路上走得比多数人以为的更远它现在把 lockfile 表述为强于 SHA 固定——The best measure is to lock your workflow dependencies with a lockfile. This records the exact commit for every action your workflows depend on, including transitive dependencies, and verifies the identity of each source repository at run time. This is the strongest protection available, and supersedes SHA pinning for repositories that adopt it.同一页也把标签的风险写明了Note that there is risk to this approach even if you trust the author, because a tag can be moved or deleted if a bad actor gains access to the repository storing the action.同一页的导航里现在还多了一项 “Use immutable releases”。它与 lockfile 解决的是同一类问题的两个不同位置lockfile 记录的是“我要哪个提交”immutable releases 约束的是“发布出去的引用能不能被改”。前者在你的仓库里后者在被依赖的仓库里一个使用者能控制的部分只有前者后者要靠发布方启用。这解释了为什么第一条建议是“用 lockfile”而不是“要求所有 Action 都启用不可变发布”后者不在使用者的能力范围内。需要注意一处时间关系这段关于 lockfile 的表述是当前页面的内容在 2025 年 3 月那份公告引用这一页时被引用的位置是另外两行。也就是说“lockfile 强于 SHA 固定”这个判断在事件发生时不在这份文档里它是后来补上的位置。第二层传递依赖会把固定关系再打开一次第二层与标签可变这件事不同它说的是你能固定的只有你直接引用的那一层。reviewdog/action-setupv1CVE-2025-30154在 2025 年 3 月 11 日的 18:42 到 20:31 UTC 之间被入侵恶意提交f0d342d2…修复提交3f401fe1…。这份公告里有一句是本文这一层的全部依据Other reviewdog actions that usereviewdog/action-setupv1would also be compromised, regardless of version or pinning method:reviewdog/action-shellcheck,reviewdog/action-composite-template,reviewdog/action-staticcheck,reviewdog/action-ast-grep,reviewdog/action-typos.“regardless of version or pinning method”这七个词把第一层的修复直接取消了。假设你按最严格的方式把reviewdog/action-shellcheck固定到一个 SHA你固定的内容是“这个 Action 在使用时是什么”。而action-shellcheck自己的 workflow 里会引用reviewdog/action-setupv1那个引用是它的不是你的它的标签被移动时你的固定没有任何作用。把“依赖是一个图”在 Actions 这个环境里说具体一点。一次 workflow 运行时被取用的东西至少有三类。第一类是你的 workflow 文件里直接写的那些uses:行这一层由你的仓库决定。第二类是每个被引用 Action 自己的定义文件它的action.yml会声明这个 Action 怎么运行是 JavaScript、是 Docker还是一串 compose 步骤这份定义在那个 Action 的仓库里。第三类是复合 Action 内部再引用的其他 Action一个复合 Action 的步骤序列里同样有uses:行而那些行是它的作者的固定方式不是你写下的。action-setupv1这次出问题在第二类与第三类之间的位置它不是被直接写在你 workflow 里的某个步骤而是被别的 Action 当作前置步骤引用。于是“我固定了什么”这个问题在你的仓库里有一个明确答案而实际被取用的东西比这个答案多。这也是为什么文档强调 lockfile 会记录“including transitive dependencies”它把第三类也纳进了记录范围。而没有 lockfile 的时候第三类在你的仓库里没有任何可见痕迹你可以把一个 workflow 文件从头读到尾看不到任何一行提到那个被间接引用的 Action。这就是传递层的性质固定是一条边上的操作而依赖是一个图。你在自己的仓库里能控制的边只有从你出发的那些从被依赖者出发的边在别人的仓库里而它们同样可以是可变的。CISA 的表述把两次入侵的关系写成The compromise oftj-actions/changed-fileswas potentially enabled by a compromise of another GitHub Action,reviewdog/action-setupv1(tracked as CVE-2025-30154), which occurred around the same time.这里还有一处时间上的落差。reviewdog 那次入侵发生在 3 月 11 日的 18:42 到 20:31不到两小时而对应的 CVE 公告发布于 3 月 19 日。被入侵的时间窗只有一百多分钟但从被利用到公开披露隔了八天。这八天里任何在这段时间内运行过相关 workflow 的仓库都已经交出了它的凭据而那些仓库在当时不可能知道要去轮换什么——CVE 编号还不存在。这处时间差还有一个更直接的推论CVE 编号的存在与否不能用来划定“我有没有被影响”的边界。一份被入侵的 Action 在被披露之前对世界而言只是“最近更新过”的代码受影响者在那段时间里没有任何可依据的告警也没有可查询的编号。把排查条件写成“查我有没有用过带某 CVE 的版本”在时间上就漏掉了披露之前的那一段。这是协调披露在这类事件里的固有代价不是流程缺陷。但它对排查方式有直接影响按 CVE 公布日期去划定影响范围会漏掉公布之前的那段时间。这也是 CISA 为什么要给出一个比公告更宽的审计窗口它按实际可能被影响的时间来划而不是按披露口径来划。注意potentially enabled这个限定。CISA 没有断言因果链只说时间上接近且可能是这样。这个限定要保留把两次入侵说成已证实的因果是超出材料的。但即便因果不成立传递层这一层也已经由 reviewdog 自己的公告独立证成了那五个 Action 的受影响原因不是“它们的代码被改”而是“它们消费了一个被改的 Action”。这与 tj-actions 是不是被它打开的无关。两处来源在这里不一致。时间窗有两个版本CISA 让使用者去审计2025-03-12 00:00到2025-03-15 12:00 UTC之间的运行而 GitHub 公告说的是 3 月 14 日到 15 日。两个窗口不一致前者的起点早了整整两天。哪个更准无法从公开材料判定两处原文并列保留不做取舍。实践中按更宽的那个去审计是可核对的取舍它只增加工作量不增加漏掉的风险。第三层出处证明回答的不是这个问题第三层是这次事件里最容易搞混的一层。假设你已经用了不可变标识也已经知道了传递层的存在还剩一个问题你怎么知道某个 Action 的这个提交确实是仓库作者写的、而不是别人推上去的出处证明artifact attestations就是为这个问题设计的机制。但它回答的问题与“这个 Action 良不良性”是两件事而 GitHub 自己的官方文档把这一点写在了最前面It is important to remember that artifact attestations are not a guarantee that an artifact is secure. Instead, artifact attestations link you to the source code and the build instructions that produced them. It is up to you to define your policy criteria, evaluate that policy by evaluating the content, and make an informed risk decision when you are consuming software.还有一句把它的生效条件也说清了Generating attestations alone doesn’t provide any security benefit, the attestations must be verified for the benefit to be realized.“证明”这个动作的价值不在于生成而在于验证而验证需要一个用户自己定义策略。这解释了为什么把 attestation 当成一道自动防线是行不通的它输出的是一个关于来源与构建过程的声明判断“这个东西能不能用”仍然要由使用者的策略来完成。私有仓库还有一处结构差异Private repositories that generate artifact attestations use GitHub’s Sigstore instance. GitHub’s Sigstore instance uses the same codebase as the Sigstore Public Good Instance, but it does not have a transparency log and only federates with GitHub Actions.公开仓库的证明会写入公开可读的不可变透明日志而私有仓库的证明没有透明日志。透明日志的作用是让“同一份东西被给了两个不同的说法”这件事可被发现。没有它签名仍然有效但“有没有被换过”这个问题失去了可核查的途径。把“验证”这一步说具体能看出它为什么不能自动化到“通过就是不安全为零”。要判断一份证明能说明什么至少要回答三个问题这份证明是谁签的签名主体是不是我以为的那个工作流、它声明的输入是不是我希望的那些源提交、构建步骤、触发事件、以及声明的内容与我要用的这份东西是否一致内容哈希能否对上。三个问题里前两个由证明本身回答第三个需要一份独立的内容基准而那份基准必须由使用者自己维护因为只有使用者知道“我想要的是什么”。这也是文档那句“生成证明本身不带来任何安全收益”的落点证明是一份待检验的声明而不是一个结论。它把一个原本无法核对的判断“这个东西是谁怎么造出来的”变成了一份可核对的材料“这份声明说了什么、谁签的”而做出判断这一步仍然在使用者那里。还有一处边界要写清这一层的问题与标签可变无关。一个用不可变标识、依赖图也完全干净的项目仍然需要回答“这个提交的内容我信不信”。反过来一个把证明配置齐全的项目也可能在某条引用上仍然用的是可移动标签。两层回答的是不同的问题配置齐全其中一层不会让另一层自动成立。把这一层与第一、二层并排可以看清为什么它需要另一套机制。前两层的问题都能用“把标识做不可变”来收敛而第三层的问题不是标识可不可变而是标识指向的那个东西好不好。回答后面的问题需要内容层面的对照——SBOM 与哈希清单是往这个方向去的机制而它们与前两层是正交的。一次没有被问到的问题为什么按标签的人“无需动作”在进入最后一个面之前回头把那份公告的补救建议读完整。它给使用者的是三段指示除了前面引的两句之外还有一句关于凭据的Rotate potentially exposed secrets。三段指示合起来看得到一个有点奇怪的分布该做动作的是两类人直接引用了恶意 SHA 的以及在 3 月 14 到 15 日之间跑过流水线的。而按标签引用、但恰好没有在那两天跑过的人被明确告知不需要做任何事。这个分布是合理的没有被影响的运行不需要轮换凭据但它留下一个没有回答的问题那些按v35引用的流水线在下一次运行时怎么知道自己引用的是不是又是一份被改过的代码 公告回答的是这一次事件里“谁受影响”而不是这个引用方式在结构上能不能自证。这一点是三层失效在实践中的交汇处。第一层说标签可变第二层说你管不到传递层第三层说证明回答的不是安全性三层各自的结论都指向同一件事“我上次检查过它是好的”与“这次它还是好的”之间没有任何机制在把关。按标签引用的问题不是它在某个时刻指向了错的东西而是它在下一次被解析之前不会告诉你它指向了什么。另一个面产物不是唯一的输出还剩一件事不属于上面任何一层而这次事件把它暴露得很清楚密钥是通过构建日志出去的。这不是“版本固定”层面的问题。就算一个仓库做到了完美的不可变标识、解决了传递依赖、也验证了出处证明一次构建仍然会产出一些非产物的东西日志、缓存、测试报告、制品附件。这些东西的可读范围与产物不同。日志容易被忽略原因有一个具体的心理机制产物是“要发出去的东西”因此天然会被审查而日志是“过程记录”看起来只是给自己排错用的。但对公共仓库这个直觉不成立日志的默认可读范围与仓库源码相同。这次的外泄之所以成立靠的就是这个落差攻击者不需要任何外发通道只需要让密钥出现在一个“以为是内部”的地方。这一点也能解释为什么这类事件的排查往往滞后日志是被写出来的而写日志的代码通常由平台提供、被使用者信任因此没有人会去读一条平台输出的日志里是否包含了自己的密钥。CISA 给出的密钥识别方法也从侧面印证了这个通道Secrets may be obfuscated as a double-encoded base64 payload它提醒排查者日志里可能有一段看着无害的 base64。这里不做解码演示但那条提示本身就说明这次的外泄在日志里留下的是可检索的密文形态而不是明文密钥串。这一层能推出的工程判断与前两层不同。前两层是“把标识和来源管好”这一层是“审查一次构建的输出面有哪些、各自对谁可见”。对公共仓库来说后者的默认答案是“比你预期的大”。顺带把操作层面的时间点摆出来因为它们决定排查范围。两份 CVE 都进了 CISA 的已知被利用目录CVE-2025-30066 于 2025 年 3 月 18 日收录要求 4 月 8 日前处置CVE-2025-30154 于 3 月 24 日收录要求 4 月 14 日前处置。从事件发现到进入已知被利用目录只有三天这个速度在这类目录里算快的它反映的是“影响面大且利用已确认”而不是“危害程度分级高”两份都是 HIGH 而非 CRITICAL。把“进了 KEV”读成“严重性最高”是不准的它说明的是“已被实际利用”。三层失效各自需要不同的机制把三层放在一起会看到一个不容易在第一时间看清的结构它们的修复机制互不替代。层失效的环节修复方向标签层一个版本标识可以指向不同产物把标识做成不可变SHA、lockfile、immutable releases传递层你只能固定自己出发的那条边不在你自己的仓库内可解需要依赖图整体参与证明层出处证明回答“谁产生的”不回答“好不好”内容层面的对照哈希清单、SBOM 与策略SHA 固定还有一个常被跳过的前置要求。文档在讲这个做法时补了一句When selecting a SHA, you should verify it is from the action’s repository and not a repository fork.一个 40 位哈希不包含“它属于哪个仓库”这个信息。如果使用者从某个 fork 或者从一份文档里抄来一个 SHA那个 SHA 在语法上完全合法、在引用上也完全有效只是它指向的是别人仓库里的东西。固定到这个 SHA 的人会得到一份“不可变”的依赖而那份依赖从来不是他以为的那个。这处要求把“固定”这个词的含义又推进了一层不可变标识解决的是“它会不会变”而“它是不是我以为的那个”是另一个问题。两者都要回答而后者只能靠人工核对来源。文档把这一条写在操作说明里而不是脚注里说明它在实践中出过问题。第一层的修复是技术性的而且已经可用文档明确说 lockfile 会记录包括传递依赖在内的每一个 Action 的精确提交。这恰好也是第二层的部分答案lockfile 把依赖图整体纳入记录而“只固定直接引用”做不到。这一点说明第一层与第二层不是完全正交的lockfile 是同时覆盖两层的机制而 SHA 固定只覆盖第一层。第三层则是另一回事。它要用到的机制不属于“依赖管理”这个范畴而属于“产物审查”。两者拼在一起才算完整而把它们混为一谈会得到错误的安心一个仓库可能有一份完全不可变的依赖清单同时对清单里每个条目的实际内容毫无判断。三层失效落到日常动作上是三个可以直接问的问题。第一问我引用的是位置还是东西 如果 workflow 文件里出现的是v35这类名字那引用的是位置如果出现的是完整哈希或由 lockfile 记录那引用的是东西。第二问我引用了谁引用过的什么 这一问的答案通常不在自己的仓库里能查的地方是对方 Action 的action.yml与它的复合步骤序列查不到时lockfile 是把它变成可查的手段。第三问我用什么判断某个提交的内容可以接受 这一问没有配置项可以一键解决它要求使用者有一个关于“什么可以进入我的构建”的立场。三个问题里只有第一个能靠改一行配置回答。第二、三个分别需要读别人的仓库与维护自己的判断标准这也是为什么“固定到版本”这条建议听起来完整、实践上只覆盖了三分之一。那份公告的措辞把“用标签的人无需动作”和“用 SHA 的人必须动作”并排写在一起这个并置本身就说明了一件事。它说明“固定”这个词在实践中可以指两种很不一样的东西指明一个位置或者指明一个东西。前者是引用后者是内容而只有在能对内容做判断第三层时后者才真正带来安全。三层失效的根都在这个区分上我们习惯用位置来指代东西而位置是会变的。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询