研究
Git 里从未出现的 Axios 恶意版本,如何穿过 npm 抵达构建身份
2026 年 3 月 31 日,npm 在约三小时内交付了两个未经授权、且在 Git 历史里没有对应源码的 Axios 版本;一次看似普通的依赖安装由此跨过锁文件、生命周期脚本、开发机与 CI 身份,迫使响应者从缓存、进程、凭据和构建产物中还原一条已被清理过的交付链。

文章导航
1 Git 里从未存在过的版本
1.1 一次完全正常的构建,却有一份解释不通的身世
2026 年 3 月 31 日 00:21 UTC,一条构建流水线可以把工程师教给它的每一步都做对,最后仍然成为事故的第一现场。它从经过评审的应用提交开始,从正确的组织签出代码,执行团队每天都在使用的包管理命令,测试没有报错,产物外观看起来也和平常一样。危险对象没有混进 Pull Request,更没有出现在代码评审页面;它披着团队早已信任的库名,从包注册表直接走进了安装过程。
这个对象是 [email protected]。大约 01:00 UTC,旧版本线又出现了第二个未经授权的发布 [email protected]。两者都没有审阅者理应看到的源码标签、Git 提交、发布讨论或正常工作流记录。微软威胁情报与 Axios 维护者的事后说明都指出:被入侵的维护者账户绕过正常发布流程,直接把它们推到了 npm。两个包的清单都多出一个运行时依赖 [email protected]。Axios 源码从未导入它,但这并不妨碍执行——依赖自己的安装生命周期已经提供了那条边。
这组矛盾,才是这起事件值得在三小时下架窗口之外反复研究的原因。开发者读到的 Axios 源码与构建实际收到的 Axios 包,是两件保管链不同的对象。Git 可以如实告诉你“没有恶意提交”,npm 也可以如实记录“我交付过一个携带恶意依赖的包”。如果源码审计只在 Axios 仓库里搜索载荷,它会给出一个令人安心、却回答错问题的结论。
正确的第一个问题要窄得多:这次安装究竟解析到了哪一组字节?要回答它,需要锁文件或解析记录、注册表地址、完整性值或 shasum、缓存键、安装时间以及包管理器当时的策略。只有先把这个对象钉死,调查才有资格继续问:生命周期脚本是否运行、网络传输是否成功、进程继承了哪个身份、机器之外又留下了什么。每次跨越都有独立证据,把它们压扁成一个“受影响/不受影响”开关,只会同时制造误报与漏报。
微软把 plain-crypto-js 背后的基础设施和账户归因于 Sapphire Sleet;Axios 维护者则描述了一场定向社会工程活动,以及主要维护者设备遭到入侵的过程。这些都是来源明确的攻击者与初始访问判断,不会替代某个组织内部需要完成的机械证明。锁或缓存可以证明包被选中,终端或运行器遥测可以证明脚本执行,网络与文件记录可以证明第二阶段,身份与产物历史才可以证明后果。本文始终把这几类问题分开。
公开时间线很短。3 月 30 日 05:57 UTC,一个看似干净的 [email protected] 先被发布;随后出现带安装钩子的 4.2.1,再之后才轮到两个未授权 Axios 包。3 月 31 日 01:00 左右,社区开始报告异常;维护者记录显示,两个 Axios 版本在 03:15 左右被移除,恶意依赖在 03:29 被撤下。可发布窗口短,不代表调查窗口也短。窗口内生成的锁、预热过的缓存、持久工作区或容器镜像层,都可能在注册表停止解析之后继续传递那个对象。
所以开场其实有两只钟。注册表事件持续了几个小时;某个组织的暴露周期,则从第一次受影响解析开始,一直延伸到最后一枚凭据、最后一份缓存、最后一个产物、最后一次部署和所有派生输出都得到处置。安全与工程负责人最终要签字关闭的是第二只钟。它从安装记录开始,以一条重新构建、独立证明过的血缘结束;npm 下架通知只关闭了第一只钟。
1.2 调查沿对象前进,包名只负责提供入口
包名很适合搜索,却不是可靠的证据身份。“axios”这串字符可能是一份清单里的版本范围,可能是几种锁格式中的精确解析,可能是缓存里的 tarball,也可能是一个清单后来被改写过的解包目录、输出里的捆绑副本,或带来源证明的注册表版本。两个对象可以共用同一个版本标签却拥有不同字节;两个字节完全相同的文件,也可能沿不同保管路径抵达。严肃的复核必须给每个对象单独建一行记录。
在源码这一行,SOSEC 把最后一个干净的 1.x 版本固定到标签 v1.14.0、提交 46bee3dea75ef53a8eae49f3b7487e6341de6074。它的清单只声明预期运行时依赖 follow-redirects、form-data 与 proxy-from-env,没有 plain-crypto-js。在干净注册表对象这一行,复核期间取得的 1.14.0 tarball 与注册表 SHA-1 相符,并得到 SHA-256 90e97a3194f24251d54a0b751fb2f1e38ce1427dfa15a6098f2f1b064274de2a。这个基线不是在重造已经删除的恶意样本,而是在回答最后一次授权发布到底长什么样。
恶意对象必须换一种证据标签。SOSEC 复核时,npm 已经不再提供 1.14.1、0.30.4 与 4.2.1,因此本文不会声称在本地执行过这些已移除 tarball。Axios 清单层面的变更、保留的 shasum、postinstall 脚本、字符串解码函数、平台分支、清理动作和第二阶段观察,来自微软以及在包尚可获得时留存并分析它的研究者;维护者时间线则独立固定了发布和移除时刻。把这次证据交接写清楚,比悄悄把截图或来历不明的第三方归档冒充本地复现更可靠。
对象一旦拆开,事件反而变得容易理解。干净源码说明维护者想发布什么,注册表元数据说明客户端当时可能解析到什么,锁说明某张依赖图实际选了什么,包管理器说明生命周期策略如何生效,进程与网络记录说明执行走到了哪里,缓存或镜像层说明字节是否继续存活,凭据提供者与部署系统则说明哪些权限变得可用、哪些产物离开运行器进入了下游系统。没有任何一个扫描器需要凭空猜完全部故事。
这种对象纪律也能防止矫枉过正。清单允许 1.14.1 的项目有资格选中它,却不代表它真的选中过。锁固定在 1.14.0、离线镜像从未同步、或者构建严格执行冻结安装,都可能把恶意对象挡在门外。反过来,一个当前显示 1.14.0 的仓库,也可能在窗口内构建过 1.14.1,随后才回退。只看当前清单,两个方向的案件都无法关闭。
接下来的路线已经清晰:先确认注册表对象何时可用、解析器如何把它留下;再沿依赖进入 npm 生命周期,看看一个从未被 Axios 导入的运行时包为何仍会执行;然后把安装器连接到运行器凭据和该任务推广出去的每一份产物。源码与注册表的矛盾不是脚注,而是贯穿后面七章的线。
2 四只钟,取代“只有三小时”的安慰
2.1 发布只是一次机会,不是安装发生过的证明
“在线大约三小时”用来描述两个 Axios 注册表条目并没有错,却会误导运营人员几乎所有真正需要做的决定。注册表发布时间只界定了一个全新解析器最早何时可能选中未授权版本;它不能说明某个仓库是否允许该版本、锁是否把它固定下来、缓存是否已经保存、脚本是否启用,也不能说明对象仍可取得时有没有构建运行。那些事实属于另外三只钟。
第一只钟归注册表。维护者记录了 1.14.1 在 00:21 UTC 发布,0.30.4 在 01:00 左右发布,两个 Axios 版本在 03:15 左右移除,依赖则在 03:29 撤下。注册表元数据与留存包分析共同圈定了普通在线解析的机会窗口。这只钟适合查询包代理和解析日志,却不能单独把所有版本范围兼容的项目都变成受害主机。
第二只钟归依赖解析。^1.14.0 这样的范围可以接纳之后的 1.x 版本,^0.30.0 可以接纳之后的 0.30.x 版本。没有适用锁文件时,包管理器可能从配置的注册表或镜像里选择当时最新的匹配项;提交了锁并执行冻结安装则不同,它理应请求锁里明确写下的对象并验证完整性。具体命令、包管理器版本、工作区拓扑、锁格式与注册表配置,都必须进入证据记录。
第三只钟从安装抵达机器开始。下载或解包一个依赖,并不必然等于运行了它的生命周期脚本。项目可能设置 ignore-scripts=true,命令可能显式附带相同策略,较新的脚本许可机制可能要求逐包批准,沙箱也可能拒绝进程或网络副作用。不过在 npm 对事发期默认行为的说明中,符合条件的依赖脚本会随普通安装执行。要把“被选中”与“真正跑过”分开,进程遥测是最有力的证据。
第四只钟归后果。第二阶段可以在包进程结束后继续存活,被窃取的秘密可能几小时甚至几周后才被使用,任务产生的容器或发布产物可能等运行器销毁后才进入推广,带毒缓存也可能在 npm 删除源版本后继续喂给下一个任务。因此,调查窗口必须覆盖安装所触及身份与输出的有效寿命,注册表可用区间只是它的起点。
| 时钟 | 它能回答的问题 | 关闭它需要的证据 |
|---|---|---|
| 注册表 | 配置好的解析器从何时起可能取得未授权版本? | 发布与移除记录、镜像同步、代理及注册表日志。 |
| 解析 | 这张依赖图具体选中了哪个包对象? | 锁提交、解析地址、完整性值或 shasum、缓存键、包管理输出。 |
| 执行 | 生命周期钩子是否运行,后续传输是否成功? | 进程树、脚本策略、文件写入、DNS、代理、EDR 与运行器日志。 |
| 后果 | 哪些凭据、共享状态、输出与部署继续处于风险中? | 秘密注入记录、身份使用、缓存血缘、产物摘要和推广历史。 |
这四只钟也解释了为什么事件统计经常互相打架。注册表统计下载,源码扫描器统计允许相关版本的清单,SBOM 统计已经解析的组件,EDR 统计安装脚本,云审计则可能统计受影响任务可用凭据的后续使用。每个数字都有自己的分母;如果没有把它与下一只钟连接起来,就不应该把它改名叫“受害者数量”。
2.2 锁与缓存能活得比已删除版本更久
发布窗口内生成的锁,可以在 npm 停止展示未授权版本后继续保存这次解析。后来能否再次取回 tarball,取决于配置的来源与缓存副本:内部代理可能留有内容块,开发者缓存可能直接满足安装,CI 缓存可能恢复已经解开的依赖树,容器镜像层则可能早已把包封进去。下架改变的是未来从公开注册表解析的结果,不会撤销每一个已经存储的副本。
所以搜索锁文件时,不能只留下一个版本号。仓库和分支、锁路径与格式、引入解析的提交、解析地址、完整性值或 shasum、工作区包,以及哪条依赖路径需要 Axios,都要一起保存。周边结构也要足够完整,才能把真实包条目与文档、测试数据、扫描器规则区分开。如果锁后来被重写,Git 历史与构建产物元数据仍可能还原它在构建时的状态。
缓存需要同样谨慎。还没确定钩子是否运行就删除本地 node_modules,很可能先毁掉时间戳与清单残留。包管理器缓存通常包含索引元数据、内容寻址数据块和访问时间;CI 平台的缓存键可能来自锁哈希、分支、工具版本或用户自定义字符串,它们能显示哪些后续任务恢复过同一状态。先保留相关条目和平台审计记录,再让运营副本失效。
恶意依赖的清理动作让这种保全尤其重要。留存样本分析显示,setup.js 会尝试删除自身,删除携带钩子的清单,再用一个看似干净、声称版本为 4.2.0 的 package.md 覆盖 package.json。调查者若在执行后才到场,可能只看见一个自报版本与原始安装对象不符的目录。此时,父项目的锁、缓存元数据、文件系统时间线、进程树与网络记录,反而比被改写过的子清单更可信。
现在,仓库历史承担时间线的功能,不能被当作无罪判决。搜索所有活动分支与发布分支、构建系统使用的标签、归档仓库、生成锁、部署包、SBOM 与依赖更新 PR。当前默认分支干净,只能关闭“现在是什么状态”;真正的问题是,是否有任何可构建状态解析过未授权对象,并在一个有用身份下把它交给安装器。
镜像运营方应在常规保留期抹掉记录之前,导出同步与请求历史:上游地址、包名和版本、时间、请求主体、响应状态、内容摘要与驱逐状态都要保留。如果镜像从未取得恶意对象,而客户端又被强制只使用它,这可以成为很强的排除证据;如果它确实缓存过,镜像自身就把窗口延伸到了公开注册表下架之后,必须进入清理范围。
走到这里,那个短暂事件已经被拆成一组可以逐项回答的案件:兼容范围但没有解析记录,只是一条线索;匹配锁确认包被选中;缓存恢复或包下载说明字节抵达机器;生命周期进程证明执行;平台产物或连接证明下一阶段;凭据与推广记录定义组织后果。下一章要解决的是更棘手的一问:当恶意 tarball 已被删除,如何为实际交付对象建立一条可信保管链。
3 实际交付的对象,才是证据室的中心
3.1 先固定干净基线,再谈它从哪里分叉
恶意注册表条目消失以后,人很容易从几张清单截图开始,试图倒着拼回全貌。这样做颠倒了举证责任。更稳妥的调查,首先固定一个能够端到端重现保管链的授权对象。对 Axios 1.x 而言,标签 v1.14.0 指向提交 46bee3dea75ef53a8eae49f3b7487e6341de6074;仓库里保存着源码、构建脚本、发布工作流和清单,正常授权版本本应从这些对象中产生。
该提交上的清单提供了一个简单、后果却很大的否定事实:运行时依赖块里有 follow-redirects、form-data 和 proxy-from-env,没有 plain-crypto-js。全仓搜索也找不到 Axios 合理导入这个新增名称的地方。这不能直接证明已删除的 1.14.1 tarball 里有什么;它能证明的是,事件来源所描述的新增依赖不属于最后一条干净源码线,也没有应用运行时职责可以解释它为什么突然出现。
干净注册表包给出了第二个保管点。复核时,npm 的 1.14.0 元数据指向同一个 Git head,同时提供 shasum、integrity 与来源证明字段。下载 tarball 的 SHA-1 与注册表 shasum 相符,SHA-256 为 90e97a3194f24251d54a0b751fb2f1e38ce1427dfa15a6098f2f1b064274de2a;其中清单和构建文件也与干净标签一致。这样,运营方就能识别一件已知干净的包对象,而不必相信“latest”或一张随时会变的网页。
事发前的工作流解释了预期发布路径:版本标签触发签出、依赖安装、构建与 npm publish --provenance;发布任务同时接收一枚长期 npm 令牌。面对一次授权发布,审阅者可以寻找标签、工作流运行、具体提交与注册表对象。两个事故版本没有这条普通源码轨迹,也没有可信发布者绑定,但被入侵账户仍持有足以直接发布到注册表的权限。
缺失的轨迹是一项异常,不是一台万能探测器。旧版本、紧急流程或迁移中的项目,可能因正当理由没有完整来源证明。价值来自比较:一个已经形成“标签—工作流”惯例的项目,突然出现一个没有标签和工作流的包;它还新增了从未见过的运行时依赖,并沿项目事后说明确认未授权的路径发布。多项相互独立的异常,都收敛到同一件交付对象上。
0.x 版本线需要自己的干净参照。仍然合法停留在 0.30 的组织,不能假定 1.x 标签描述了自己的依赖图。维护者建议把 0.30.3 视作 0.30.4 之前的安全版本。响应清单应为每个应用分别保留干净对象、锁结构与发布流,不能把全部证据硬改写成围绕 1.14.0 的故事。攻击机制相同,依赖图和升级决定仍然属于各自应用。
固定基线也让后续重建可以衡量。团队可以清除受影响缓存,恢复一份经过评审、解析到干净 Axios 对象的锁,在阻止未批准脚本的策略下安装,再把新输出摘要与原任务产物比较。若发生变化,差异必须解释;若没有变化,重建仍有意义,因为它把一条无法信任的执行血缘,替换成输入与身份都能得到证明的新血缘。
3.2 恶意包被删除,限制的是复现方式,不是公开结论
SOSEC 复核时,向 npm 查询 1.14.1、0.30.4 和 4.2.1,都已经得不到在线版本对象。这个缺口要求文章遵守一套发布纪律:我们没有从非官方归档随便抓一份副本,没有把未知镜像当成真实来源,也没有因为文件名对得上就执行包。相反,正文明确标注哪些结论来自本地可检查的官方对象,哪些来自对象仍可获取时已经留存样本的事件研究者。
微软报告的是一处外科手术式清单变更:Axios 源码与构建 bundle 没有改变,运行时依赖却指向 [email protected]。Axios 维护者独立确认两个版本、新增依赖、发布账户与移除时间线。StepSecurity 的留存比较给出包 shasum,并逐行分析 setup.js,覆盖 postinstall 入口、解码函数、平台选择、进程启动和覆盖清单的尝试。这些记录互相吻合,足以支持交付链结论,而无需假装原始字节今天仍能从 npm 获得。
仍有一些说法不受现有证据支持。下载量不能识别每个真正执行过脚本的组织;目录自报 4.2.0,无法排除它早先以 4.2.1 运行,因为清理流程本来就会写入那个值;匹配锁不能证明第二阶段成功连接;攻击者归因也不能证明某个地址在更晚时刻仍由同一方控制。每项限制都应紧贴它所约束的事实;泛泛的免责声明说不清哪项限制究竟约束哪条结论。
组织内部可能拥有比公开记录更强的私有证据。内部制品库可能留着准确 tarball 与完整性元数据,EDR 可能保留脚本的进程祖先与文件写入,代理可能保留目标与响应。应当为这些对象计算摘要、限制访问并挂接到案件,但不要把它们作为样本重新分发。只要保管链完整,一宗个案就可以从“依据来源描述机制”升级为“本地重现了执行”,同时公开文章仍保持在已披露范围内。
时间也在同一张台账里归一化。注册表时间戳用 UTC,工作站可能使用本地时区,流水线界面可能按查看者所在地渲染,终端事件还可能等运行器重连后延迟上传。每条记录都保留原始时间、声明时区、归一化 UTC 与采集来源。窗口只有三小时,经不起一次悄无声息的八小时时区错误,尤其当团队正在判断某次构建发生在移除之前还是之后。
完整性字段必须带上名字。npm 历史 shasum 是包归档的 SHA-1,integrity 通常保存强度更高的子资源完整性值,取证时本地计算的 SHA-256 又是一枚独立调查摘要。只记一长串十六进制字符,很容易拿错对象比较。台账要同时存算法、编码形式、具体哈希对象与来源,避免把干净 tarball 摘要拿去和清单 blob 或解包目录哈希相比较。
注册表地址也是对象身份的一部分。公共 npm 地址、企业镜像、虚拟仓库与本地缓存,都可能满足同一个包名和版本,却沿着不同保管路径提供内容。不同锁格式记录来源的方式并不一致;锁里缺失时,要用镜像访问日志与包管理配置补足。这既影响暴露判断,也影响恢复:从不可信替代来源取得的“干净版本”,不会自动等于已经验证过的上游对象。
包内容既要按字节比较,也要按结构比较。对一件固定对象而言,归档摘要相同是最直接、最强的简单比较;如果重新打包或元数据规范化导致字节不同,调查者就要比较规范化文件表、权限位、路径、清单字段、构建 bundle、源码映射与来源证明中的主体摘要,并记录每一步转换。只看源码差异会漏掉清单变更,只看清单也会漏掉被替换的构建文件;比较方法必须跟着包真正执行的方式走。
采集动作本身不能制造新事件。注册表元数据与已知干净的官方对象可以在隔离复核流程中获取;被删除的恶意对象不应为了让文章显得“完整”,就从随机归档四处搜寻、装到开发机上,或主动访问历史基础设施。如果组织自己的缓存已经留有可疑条目,分析者应在受控恶意样本流程中使用副本,并保存原件。安全且可认证的保管链到哪里结束,公开复核就在哪里停下。
4 一个从未被使用的依赖,打开六步执行链
4.1 Semver 与锁,决定哪只包裹抵达
这条链在任何恶意 JavaScript 运行之前就已经开始。根应用、工作区包或传递依赖声明一个 Axios 版本范围;包管理器把它与锁策略、配置的注册表、工作区状态和当时可用版本结合起来。发布窗口内,一次允许 1.14.1 或 0.30.4 的全新解析就可能选中未授权版本;指向 1.14.0 或 0.30.3 的冻结锁,则会请求干净对象。
npm install 与 npm ci 的区别很重要,但命令名称本身还不够。npm 把 ci 定义为要求已有锁,且当包元数据与锁不一致时拒绝重写,团队也常用它获得可重复自动化。不过工作区、被省略的依赖类别、旧 peer-dependency 参数、替代注册表与包管理器版本,仍会影响最终依赖树。必须从实际任务记录命令行、工具版本、环境配置和锁提交,不能仅从流水线模板猜测。
未经授权的 Axios tarball 抵达后,npm 读取其清单,把声明的运行时依赖加进图中。Axios 源码不需要出现一行 require("plain-crypto-js")。包安装依据清单边工作,不依据应用导入可达性。因此,“零导入”是异常,却不是保护:这个依赖的用途就是被安装,而它自己的清单定义了包管理器将调用的行为。
看似干净的 4.2.0 为社会判断和自动评分增加了一层掩护。一个已经有过一次发布的注册表账户,不像首个版本就被大项目引用的账户那样崭新;4.2.1 随后才加入安装钩子。只按照包名年龄或直接父包热度评分的检测,会错过两个相邻版本之间真正决定性的差异。
锁审阅因此必须走到准确子节点。一份锁可以分别写出 Axios 1.14.1 与 plain-crypto-js,并为两者各自保存解析地址和完整性值。有的格式按路径放置包条目,有的维护独立解析表。安装前生成的 SBOM 可能只捕获预期图,安装后从依赖树生成的 SBOM 又可能看见被改写成 4.2.0 的清单。父项目锁与缓存摘要,才是更好的历史锚点。
仓库也可能从未直接写出 Axios。框架、SDK、CLI、测试工具和内部包都能传递引入它。组织级查询必须把直接清单搜索、锁解析器、依赖图导出、产物与 SBOM 库存、注册表代理记录结合起来。受影响构建的负责人,是那张可运行依赖图的负责人,不一定是最初选择 Axios 的团队。
4.2 生命周期钩子继承的是安装者权限
关键跨越发生在 npm 处理 postinstall: node setup.js 时。按照相关默认行为,只要策略没有禁用或限制,符合条件的脚本就会运行。脚本不会被限制在应用日后进入生产环境时获得的权限里;它以执行依赖安装的用户或服务身份运行,身处开发工作站或构建环境,继承该进程的环境变量、文件系统视图、网络路径与注入凭据。
留存对象分析描述了两种字符串转换,用来隐藏模块名、平台标识、路径与命令。入口例程在运行时恢复字符串,加载子进程、操作系统与文件系统函数,识别平台,选择交付路径,启动后续程序,再以宽泛方式吞掉错误。混淆改变的是静态审阅看见什么,不会改变终端或运行器能够记录的进程祖先:包管理器、适用时的生命周期 shell、Node,以及随后出现的平台子进程。
据报告,macOS 分支把原生载荷写到 /Library/Caches/com.apple.act.mond,名称刻意贴近系统文件;Windows 分支先在临时目录暂存内容,创建 %PROGRAMDATA%\wt.exe,并通向一条已报告的用户 Run 键持久化路径;兜底分支在具备 shell、传输工具与 Python 的环境里写入 /tmp/ld.py 并分离启动。微软已经公布各平台摘要,防守方可以结合历史时间语境检索。
所有分支都联系同一处已公布端点,以不同 POST 正文选择响应。该目标适合做历史 DNS、代理、防火墙与 EDR 查询,却不能假定一个地址或域名永远由攻击者控制,本文也不会指导读者主动请求它。检测应把目标与进程祖先、包时间和创建路径组合起来,避免基础设施日后换手时,一枚旧指标变成没有上下文的永久封禁。
出站连接被阻断,会改变后果等级。未经授权的 Axios 对象仍然被解析,依赖仍然安装,钩子也可能已经执行;如果网络控制阻止响应,且没有其他阶段抵达,证据就可能支持“在第二阶段成功前完成遏制”。进程可用凭据仍值得排查本地访问或日志暴露,但它与已经出现载荷、子进程、持久化或后续身份使用的案件,可以采用不同优先级。
清理例程既是最后一个技术步骤,也是第一个取证陷阱。它尝试删除 setup.js,删除包含生命周期脚本的清单,再把一个看似干净的文件改名覆盖 package.json;替换件声称版本为 4.2.0,并省略钩子。事后库存如果只信安装目录里的清单,就会把最需要的证据自己降级。父锁、包管理缓存、文件日志、终端遥测与构建日志因此上升为主证据。
脚本策略可以强力切断这条链,但必须精确落地。全局 ignore-scripts 可能阻止必要的原生构建或初始化任务,也可能在某些工作流里被覆盖。较新的 npm 控制可以按包批准。持久做法是列出哪些依赖确实需要安装代码,按评审版本或受控包身份批准,并在未经审阅的传递依赖新增生命周期脚本时让构建失败。Axios 事后威胁模型也明确指出:即使禁用生命周期脚本,流水线主动调用的构建插件仍然可执行。
网络策略提供另一道独立断点。只能访问内部包镜像与获批构建服务的运行器,无法自由连接任意第二阶段主机。策略需要纳入合法编译器下载、浏览器二进制、容器注册表与源码站点,再衡量例外。一次拒绝事件必须保留进程身份、目标、任务、仓库与锁摘要,由此成为可操作证据;匿名防火墙计数做不到这一点。
到这里,链已经从包保管跨进一台机器的身份。在开发终端上,它可能触及源码、SSH、云与密码管理器;在 CI 中,它也许寿命很短,却能携带联邦令牌、注册表权限、签名密钥或部署连接。下一章就沿这些权限向外走,并解释为什么删除一台临时运行器,从来不等于事件结束。
5 临时运行器可以消失,事故却会留下
这时,依赖已经跨过最重要的界线。它不再只是注册表清单里一行意外文字,而是以“制造软件的身份”执行的代码。在笔记本上,这个身份可能属于开发者;在持续集成中,它也许只存在几分钟,却能在这几分钟里读取源码签出、接收短期云声明、拉取私有包、签名产物、推送镜像,甚至调用部署连接。运行器可以销毁,它替系统做出的每个决定却可能继续顺流而下。
所以“销毁临时运行器”既有必要,也远远不够。销毁能移除一处潜在持久化表面,却不会收回已经呈给进程的注册表令牌,不会让已推送镜像失效,不会抹掉已经推广的产物,更不能证明一枚联邦令牌从未被复制。事件必须按信任图还原:起点是一宗受影响安装,向外分叉到身份、存储、输出和后续自动化。
每家组织的图都不同,但词汇相当稳定。一项任务有源码提交、锁摘要、运行器身份、注入变量和挂载文件、网络策略、缓存输入与输出、产物目标、签名操作、部署权限与人类负责人。把这些对象写出来,“CI 暴露了”就不再是一句让所有人焦虑的概括,而会变成一组有限、可以逐项回答并关闭的问题。
5.1 构建权限往往比应用运行权限大得多
应用团队常从生产运行权限出发思考。浏览器 bundle 发布后读不到构建服务器环境,容器可能以非特权用户启动,Serverless 函数也可能只得到一项很窄的角色。这些后置限制都没有描述安装依赖的那个进程。安装发生得更早,系统正有意信任它把源码转换为产物,因此周围通常摆着更有价值的材料。
这些材料可能是访问私有 scope 的 npm 凭据、拉取子模块的 Git 令牌、SSH agent、可以交换云凭据的 OIDC 声明、容器注册表密码、代码签名接口、发布密钥、部署 webhook、包镜像客户端证书。它也可能不是凭据,而是信息:尚未发布的源码、客户专属配置、内部主机名、测试数据与未来产品名称。只搜索显眼静态秘密的复核,会漏掉现代流水线偏爱的短期权限与环境权限。
短期凭据通过缩小复用时间改善了系统,却不会让受影响任务变得无害。十分钟有效的令牌,在那十分钟内依然可以完成高权限操作。响应者应保留身份提供方的签发与交换记录,记下准确 audience 和 claims,确认换到了哪个角色,并审阅从安装时刻直到凭据过期的操作。面对联邦身份,“轮换”不一定是正确动词;移除信任、收紧声明、作废会话与复核角色活动,才是对应措施。
服务连接也要用同样精度分析。有的构建系统把秘密注入进程,有的只在代理层提供任务级连接,不暴露可复用值;有的把连接限制在带审批门的环境,有的旧模板却默认让任务每一步都能使用。后果等级必须跟随实际交付机制。“这个项目拥有生产权限”不能证明恶意进程得到过它;“同一任务里的部署步骤自动获准使用连接”,才是一条强得多的连接证据。
自托管运行器再增加一层。持久工作区可能留下被改写的依赖树,共享 npm 缓存可能把已删除 tarball 或元数据带给后续任务,长期运行 agent 还能保留临时运行器删除后丢失的进程、文件与网络遥测。它也可能具备常驻网络路线和本地凭据,而这些权限原本从未打算交给一次性工作负载。同样的持久性一面扩大风险,另一面也提供区分解析、执行、交付与后续使用所需的证据。
托管运行器只是改变证据位置,不会让证据消失。微软的 Azure Pipelines 指南把托管服务与受污染包区分开,同时要求客户调查安装过依赖的任务。流水线日志、任务元数据、缓存记录、身份提供方事件、制品库、源码审计与下游部署系统,仍可重建信任图。问题不在虚拟机是否还活着,而在组织有没有保存围绕它的记录。
5.2 输出会继承疑问,直到新血缘把它切断
构建成功不是构建干净的证据。Axios 应用源码本身完全可能正常编译、正常测试,因为新增依赖无需改变任何 Axios 函数;恶意生命周期进程可以与一条看似有效的转换同时运行。测试通过只能证明预期行为仍然存在,不能证明安装期间没有发生额外行为。
受影响安装之后产生的每一项输出都要进入复核:JavaScript bundle、容器镜像、原生包、SBOM、校验值、签名、测试报告、缓存与中间归档。这并不等于每件输出都已被证明遭到修改,而是说它们的干净血缘被打断了。用经过验证的源码提交与锁,在限制出站、重新授权的干净运行器上重建,才能创造一件恢复信心的比较对象。
摘要相同很有帮助,却要正确解释。若一件确定性输出在干净环境重建后逐字节相同,说明公开载荷没有修改该输出的信心会提高;但这不会抹掉凭据暴露或其他副作用。如果构建本来就非确定性,摘要不同属于预期,也不能单独证明篡改。团队需要继续比较结构化内容、构建清单、来源证明、文件表、编译器输入与签名事件。
签名同样需要血缘。有效签名回答的是哪把钥匙或哪项服务批准了对象,并不保证请求签名的进程干净。如果受影响任务能够调用签名,调查者应复核安装后每次签名请求,并尽可能把签名授权绑定到受审工作流身份、源码提交与构建证明。修复不是给旧产物重新签一次,而是从头构建并签署一件路径可以完整解释的新对象。
缓存是最容易形成二次投递循环的地方。npm 移除版本后,全新注册表解析会停止,但内容寻址镜像、包管理缓存、持久工作区或容器层仍可能满足下次任务。只按包名删除不够,因为条目可能按地址、完整性值、锁摘要或不透明缓存键存放。响应者要枚举受影响任务可达的所有缓存命名空间,在策略允许时保留取证副本,让运营副本失效,并要求干净重建展示一次真正全新的解析路径。
窗口内生成的锁也一样。包被删除后,锁仍记得准确对象,因此继续选中它。仓库示例、归档发布分支和模板项目,即使默认分支早已更新,也可能保存这次选择。一份有毒锁在被替换、评审并传播到每个仍可构建的分支或消费者之前,始终是一条仍然有效的交付指令。
到本章结束,事件已经不能再用“三小时注册表可用”来界定。它的运营时长,是直到最后一份受影响锁、缓存、凭据与派生输出被排除、作废或重建为止。正因为第二只钟更长,发布修复才不能只堵住旧入口;它还要让下一次分叉更难发生、更容易被看见。
6 修复的意义,是让每次发布都有旁证
事故前最后一个干净 Axios 提交,保留了旧发布安排。版本标签触发 GitHub Actions,工作流签出仓库,执行 npm ci,构建项目并携带来源证明发布;发布步骤也接收一枚长期 NPM_TOKEN。这套流程可以生成归因清晰的正常版本,但只要有人拿到具备发布权限的账户凭据,就仍有另一条路:发布一个没有匹配标签、工作流运行或源码提交的注册表版本。
恶意版本走的正是这条无声小路。最能暴露它的不是某种新奇混淆,而是正常发布故事的缺席:没有对应的 Axios 开发变更,没有解释版本号的源码标签,也没有把包与仓库事件绑定起来的预期工作流身份。只检查包名与 semver 的消费者会看见一次看似合理的更新;同时要求源码、工作流、来源证明与注册表互相吻合的消费者,会先看见证人缺席。
事后 Axios 改了不止一项控制,因为没有任何一项能覆盖全程。提交 ebf30369328b61c7ea9d152d4d5afa7d26e59c06 从工作流里移除长期发布令牌,改用 npm 可信发布和 OIDC。后续历史还固定 action、收窄权限、为旧版本线建立独立可信路径,并把发布放到 npm-publish 环境之后。提交 302e2739c602f00e323d4f3f5c79500647633a73 又增加仓库级 ignore-scripts=true。
6.1 OIDC 改变的是谁有资格代表仓库说话
静态注册表令牌是持有者凭据:谁拿到,谁就可以在过期或撤销前尝试发布,前提只是注册表策略允许。可信发布交换则要求 npm 接受一枚针对指定仓库、指定工作流、在明确条件下签发的短期身份。关键属性不只是凭据寿命短,而是注册表可以要求请求必须从一条特定自动化路径抵达。
这条路径由此成为可执行的发布对象。审阅者可以问:注册表版本有没有匹配标签,可信工作流是否运行,它签出了哪个提交,哪个环境批准,任务持有哪些权限,来源证明是否把包绑定到这次执行。注册表若出现一个版本,而源码与工作流对象完全不存在,分叉就会成为一项核心事件信号,不再是事故以后才被勉强解释的空白。
来源证明是一名证人,不是最终判决。它可以把构建对象绑定到工作流与源码提交,却不能证明工作流里执行的每个依赖都安全,也不能证明源码评审充分,更不能排除被入侵工作流生成恶意产物。它的价值在于把声明写成可以比较的对象。消费者能够要求声明存在、仓库与工作流符合预期;维护者也能发现一份缺少它的发布。
受保护环境在构建即将取得发布权限的时刻加入一次刻意停顿。人或策略引擎可以在注册表接受对象前审阅标签、变更记录、测试、包内容与来源证明预览。审批者若独立于准备发布的账户,而且权限只在批准之后授予,停顿才最有价值;如果凭据早在审批前就可用,最后补一个装饰性点击并没有关闭同一条边。
固定 action 与最小工作流权限,会减少可以改变发布的活动部件。分支名或可变 action 标签日后可以指向别处,固定提交则记录当时选择的准确外部工作流代码;收窄权限能防止构建步骤继承并不需要的仓库写权限。它们都无法单独抵御所有受污染依赖,却可以在构建组件异常时压缩可用权力。
6.2 脚本策略关上一扇门,也把剩余的门写在纸上
仓库级 ignore-scripts=true 直接针对本次事件使用的机制。依赖安装期间,npm 生命周期钩子不再为项目默认执行;当前发布流程也在安装命令上显式限制脚本。重复并非多余:仓库策略保护普通贡献者,命令行选择则在执行点写明发布任务的预期。
这个设置有真实成本。依赖合法原生构建、二进制下载或初始化任务的包可能失败或改变行为。成熟落地方式应先列出确实需要安装代码的包,把它们限制在经过评审的版本或受控构建器里,并让例外可见,无需重新全局打开脚本。一个意外新增的传递生命周期钩子,应当让构建失败并产生评审事件,决不能悄悄扩大可执行集合。
Axios 当前威胁模型之所以有价值,是因为它明确写出剩余缺口。禁用生命周期脚本并不会把整条 JavaScript 构建变成声明式过程。项目会主动调用 Rollup、Babel、测试、lint 以及它们的插件;工作流既然要求这些代码运行,它们就会以构建进程权限执行。攻击者若控制一个被主动调用的插件,根本不需要 postinstall。锁执行、依赖评审、出站限制、运行器隔离与可重复输出依旧必要。
这个区别能避免常见的事后错误:把当次利用机制当成完整威胁模型。3 月的链使用幽灵运行时依赖与生命周期钩子,因此阻止钩子可以切断它;更大的供应链问题,则是任何未评审代码在可信机器制造或发布软件时执行。修复后的设计要让所有此类执行都可以列举、数量最小,并被证据包围。
冻结安装是下一名证人。npm ci 把已提交锁当成契约,清单与锁不一致就失败;它先移除现有依赖树,再安装锁里准确记录的图,从流程上排除机会式更新。这会显著减少意外解析,但只在锁本身经过评审时成立。恶意窗口内生成的锁会忠实重现有毒对象,因此历史、评审责任人、解析地址与完整性值仍然重要。
内部注册表镜像可以增加隔离与留存。它能延迟新版本、要求来源证明、扫描生命周期脚本、保存已删除元数据,并给响应者一份哪些项目取得过哪些对象的稳定记录;但它也会成为集中的高价值信任点。镜像必须认证上游对象,保护自己的管理路径,保留不可变日志,并让策略绕过可见。“来自我们的镜像”必须表示对象通过了明确检查;仅仅被镜像缓存远远不够。
最强修复是一组会让生产线停下的“不一致”:版本没有受保护标签就拒绝,没有可信工作流就拒绝,没有预期来源证明就拒绝,锁变更未评审就拒绝,新增生命周期脚本就拒绝,安装进程访问未知目标则阻断并记录。每项检查都让另一个系统为发布作证,攻击者只改一份记录就无法拼出完整故事。
这样的设计还会缩短未来调查。响应者不必再从消失的运行器和自我改写的清单里考古,只需比较少量持久回执。Axios 的教训不是 Git 没有用,而是 Git 如实描述了一件对象,注册表却交付了另一件。修复的意义,是让它们在消费者信任任何一方之前先碰面。
7 响应先连接记录,下载量只提供背景
版本被移除以后,最容易取得的数字,反而最难直接支持决定。注册表下载量只能估算对象被请求多少次,兼容 semver 范围只能估算多少项目有资格选中它。两者都不能证明某台机器收到对象、允许钩子执行、取得第二阶段、暴露身份或生成被推广产物。响应必须按这些问题的先后顺序向前走。
调查的基本单位是一宗安装事件。每宗事件把仓库和提交连接到一份锁、一个包来源、一个时刻、一台主机或运行器、一条安装命令、一项脚本策略、一棵进程树和一组输出。没有这次连接,组织往往只得到两张很差的清单:所有曾经提到 Axios 的仓库,以及所有曾经运行 Node 的机器。两张都很长,却都无法告诉负责人下一步该做什么。
先从三项包身份开始:[email protected]、[email protected] 与 [email protected]。搜索清单、锁、包管理元数据、内部镜像、SBOM 仓库、归档分支、发布标签、容器构建输入、模板仓库和缓存;格式允许时保留解析地址、integrity 或 shasum。依赖名本身依然有用,因为清理流程可能让本地清单声称自己是更早、看似干净的版本。
7.1 一次只为一段跨越定级
一套实用案件模型把证据分成等级,不使用单一的“暴露/未暴露”标记。0 级是可选中:项目存在兼容范围,却没有在窗口内解析的证据;1 级是已解析:锁、镜像或缓存记录指向未授权 Axios 或新增依赖;2 级是已安装:包管理或文件系统记录把依赖放到具体主机或运行器;3 级是生命周期执行:进程遥测、日志或关联文件事件显示 setup.js 或其子链运行;4 级是第二阶段活动:已公布目标、暂存路径、载荷摘要、子进程或持久化出现;5 级是下游后果:凭据被使用、源码被改变、签名发出或受影响构建产物得到推广。
等级不代表低级案件可以忽略。一份 1 级锁只要仍可构建,明天就能重新投递;一宗 2 级安装也可能只是因为日志缺失而没有进程证据,并不等于脚本没跑。模型的作用,是写清已经证明什么、仍可能发生什么、哪种证据能推动案件升级。它既不会把一次被阻断连接描述成载荷成功,也不会把遥测缺失自动变成干净证明。
| 证据等级 | 已经确定的事实 | 立即行动 |
|---|---|---|
| 0 — 可选中 | 版本范围或间接依赖可能选择该版本。 | 定位锁与构建执行,不推断已经安装。 |
| 1 — 已解析 | 持久记录选中了未授权对象。 | 隔离锁与缓存,识别每一项消费构建。 |
| 2 — 已安装 | 包图到达一台具体主机或运行器。 | 保留遥测,枚举进程当时可用的权限。 |
| 3 — 已执行 | 生命周期进程或特征子进程链运行。 | 隔离、采集、撤销可达权限并复核输出。 |
| 4 — 已交付 | 平台第二阶段或持久化指标出现。 | 启动终端事件响应并扩大身份狩猎。 |
| 5 — 有后果 | 观察到身份使用或下游产物流转。 | 界定控制面,干净重建并恢复。 |
保全必须先于删除。面对持久运行器或开发终端,先隔离网络,再采集项目锁、缓存索引、包目录名称与时间、进程祖先、DNS 和代理事件、文件日志或 EDR 活动、临时路径、持久化位置与相关身份日志。复制出的可执行文件应进入恶意样本处理流程。公开指标足够用于历史检索,没有任何防守理由要求主动联系已公布端点或执行已移除包。
时间窗口要向两侧延伸。前端要早于 Axios 发布,因为依赖的干净前序版本与恶意后续版本已经铺设交付路径;后端要晚于注册表移除,因为锁、镜像、缓存和容器层仍能供给字节。身份复核从每次实际安装开始,持续到令牌过期、撤销或最后一次无法解释的使用;产物复核则持续到受影响构建的每个输出被作废或替换。
凭据响应依据可达性,不依据恐惧。导出任务可用的准确环境变量、挂载文件、服务连接、OIDC 交换、角色承担、签名接口、包凭据与部署门。可复用秘密和活动会话要从已知干净系统撤销;联邦路径则移除或收紧信任条件并审阅已签发会话;签名与发布系统要复核执行后每项请求,并与受批准源码和工作流记录比较。
7.2 用一条可对照的干净历史关闭事件
恢复要创造一条能与受影响历史比较的新血缘。用经过评审的解析替换有毒锁;证据保全后清空运营缓存与持久工作区;从可信基础启动干净运行器,限制出站,只注入重新授权的凭据,按冻结解析和脚本策略安装,再构建同一源码并生成新的来源证明。新产物必须有明确负责人和推广决定。
随后逐项处置每件受影响输出:从未发布且可以删除,就记录删除;已经发布但未部署,就撤销或标记并移除推广资格;已经部署,就找全环境并用干净重建替换;客户已经下载,就按真实触达范围准确发布范围与修复。台账中没有记录,不能当作产物随运行器一起消失的证据。
检测要保留这次事件的结构信号。包版本没有项目预期标签或来源证明时告警;复核没有任何导入的新运行时依赖;检查传递图中新增的生命周期脚本;把包管理进程与异常子解释器、异常目标关联;注意安装清单在同一进程树期间发生变化。这些行为在公开摘要与基础设施过期后仍然有用。
准入测试让控制从纸面变成事实。一件没有标签的测试发布应被来源策略拒绝;受控传递依赖引入未批准安装脚本应让冻结安装失败;测试构建步骤访问未批准目标应被阻断,并产生可以归属的日志;没有环境审批的发布工作流则不应获得注册表权限。测试结果应该和书面政策放在一起,因为它证明这些关键检查点真的执行了。
责任归属把系统闭合。包团队负责源码与发布配置;平台团队负责运行器镜像、缓存隔离与身份注入;安全运营负责遥测连接与证据保留;身份和云团队负责撤销与审计;产品负责人决定重建产物如何抵达客户。单一事件协调者维护安装事件台账,让这些视角回到同一组事实。
台账必须像记录正证据一样记录负证据。“没有观察到连接”只有在传感器、采集区间、主机身份与保留周期都已知时才有意义。DNS 日志已经过期的运行器,不能和完整网络遥测明确显示请求被拒绝的运行器放在同一栏;工作区清理后的空目录,也不能说明早先什么都没跑。把传感器和覆盖范围写在每项结论旁边,才能阻止“没有看见”被升级成“确定没有”。
大型单仓要按每个安装任务调查,不能按仓库整体猜。一项提交可能启动多个任务,使用不同工作区、锁、包管理器、缓存范围与凭据。分别记录每条安装命令,只关联从它产生的输出。这样,毫无发布权限的文档构建不会继承发布任务的后果等级;一项不起眼却握有强服务连接的工具任务,也不会藏进“所有测试通过”的总状态里。
包镜像应能直接回答历史问题:缓存了哪个上游对象、何时进入、携带什么完整性值、哪些客户端请求、策略是否放行、何时被隔离。如果镜像会重写元数据或重新打包,就同时保留上游回执与最终服务对象。否则,一项有用控制会变成新的分叉来源,调查者无法分辨运行器字节来自 npm、镜像转换还是陈旧本地缓存。
证据保留期还要覆盖软件推广周期。包可能在夜间构建安装,几天后签名,过了冻结期才推广,数周后才部署进离线环境。只保存一天的运行器日志无法支持这条血缘。保留策略应从依赖解析到最终部署的最长路径倒推;对支持期超过原始遥测窗口的产物,还应保留不可变摘要记录。
7.3 前七十二小时,每个时段都要有明确决定
- 最初四小时,首要任务是停止新的解析,同时不破坏证据。内部镜像隔离三项包身份,暂停自动依赖更新,立即保留注册表与代理当前日志,找出所有使用窗口内生成锁的运行中构建。此时不要大规模删除缓存,也不要让开发者重新安装项目。第一份产物应是一张带时间的“可能安装事件”清单,以及每宗事件能够解释构建的负责人。
- 同一时段内,组织只发布一份内部案件定义。它写清准确版本、UTC 窗口、清理陷阱、必填字段、证据等级与采集联系人。短表单只问真正需要连接的内容:仓库、锁提交、包管理器、安装命令、任务或主机、缓存键、脚本策略、交付凭据和输出位置。输入一致,中央响应组便可直接合并案件,也不用替每个团队翻译一张不同格式的表格。
- 四到十二小时,把确认解析的案件推进到安装层分诊。保全持久主机,请求托管运行器记录,查询包管理进程祖先;在审计保留发生变化之前,导出匹配任务的身份签发与服务连接可用性。来自这些任务的输出先进入临时推广冻结。冻结并不是声称产物已恶意,而是在事实尚未收齐时,阻止一条不确定血缘扩大成客户问题。
- 此时,每宗事件都要由一名决定负责人赋予等级,并写下一项证据缺口。“已经解析,但因为缺少进程遥测无法判断执行”是一项诚实结果;它会触发缓存失效、凭据可达性复核与干净重建,却不被冒充成已确认载荷交付。“观察到生命周期进程,但出站被拒绝”需要更强终端采集,同时仍应与出现匹配文件和后续身份使用的主机区分。
- 十二到二十四小时,身份与产物两条线并行推进。身份团队撤销可复用凭据,检查三级及以上事件可达的联邦会话;发布团队列出从受影响任务派生的每个摘要、签名、注册表推送和部署;终端团队按既有流程采集各平台指标。中央台账记录每个动作关闭哪条边,避免“一枚令牌已经轮换”被误写成“整宗事件已经关闭”。
- 只要输入与权限准备好,干净重建就应开始,不必等所有取证问题都回答完。替代构建使用经过评审的锁、已知干净包对象、新运行器、受限出站和新凭据,生成来源证明与输出比较。这让紧急业务在调查继续时仍有一条安全交付路径,也减少团队为了赶发布而申请推广一件历史未决产物的冲动。
- 到四十八小时,负责人收到的报告必须保留分母:有资格选中的仓库、确认解析、确认安装、观察到生命周期执行、第二阶段发现、可达凭据、受影响输出、完成重建,以及因遥测缺失仍未决的案件。每项数字都要附采集覆盖与最后更新时间。把它们揉成一个“受影响系统总数”,等于丢掉证据阶梯,也几乎注定在下一次更新时改口。
- 到七十二小时,未决案件必须明确处置,不能靠沉默自然老化。负责人可以取得缺失证据,可以保守地按已经执行处理,也可以记录现有控制为何排除了后续阶段。选择、批准人、剩余风险与下次复核日期都进入台账。生产冻结只能依据干净替代产物或有证据的排除解除,不能因为公开事件已经离开新闻周期就自动解除。
紧急阶段结束后,同一份台账会转化为改进清单。反复缺少进程遥测,就变成运行器可观测性任务;无法回答凭据如何交付,就变成身份注入盘点;无法追踪输出,就变成产物血缘工程;缓存找不到消费者,就变成镜像审计要求。每个调查缺口都要转换成系统负责人、验收测试与到期日,一句“吸取教训”无法完成这件事。
具体采集还要尊重不同包管理器留下的证据形状。npm 的 package-lock、pnpm 的内容寻址存储与 lockfile、Yarn 不同代际的锁和离线缓存,字段位置与缓存语义都不相同。中央规则可以统一包身份和时间,却不应把所有格式当成文本搜索。每种工具都需要解析器、样例与验证用例,输出共同字段的同时保留原始记录,避免一次格式误读把真实解析归为普通文档命中。
分支范围也不能只靠默认分支决定。仍在维护的发布线、热修复标签、客户专属分支、构建系统保存的 detached commit,以及归档后仍可被重新发布的仓库,都可能携带窗口内锁。仓库负责人应明确列出“当前可构建集合”,中央调查再把每个集合成员连接到实际任务。这样,历史噪声不会淹没响应,一条不常见却仍具发布能力的旧线也不会被漏掉。
主机采集应留下可复核的原始时间线,单独一张指标命中表不够。进程创建、父子关系、命令行、用户、工作目录、文件创建与重命名、网络请求、阻断结果和采集覆盖,都要能够按时间重新排序。平台指标帮助定位,但真正区分“仅解析”“脚本运行”“载荷抵达”的,是这些事件之间的顺序。摘要命中若缺乏来源路径,也不能自动说明文件由本次安装产生。
凭据负责人真正要回答的是“哪一步、以什么形式、在什么时间把哪项权限交给了哪个进程”;“任务里有没有秘密”过于笼统。任务级环境变量、步骤级秘密、挂载证书、代理代办连接与运行时 OIDC 交换,暴露面并不相同。把交付模型写入台账后,团队可以只撤销真正可达的高价值权限,同时为无法证明隔离的情况采取保守处置,避免全公司无差别轮换之后仍不清楚原因。
面向客户的沟通同样以产物血缘为单位。版本号、镜像摘要、发布日期、分发渠道、是否经过受影响任务、替代产物和验证办法,都要准确列出。若调查只确认依赖被解析,措辞就停在解析;若重建比较确认输出字节一致,也要说明凭据与构建环境仍曾单独复核。清楚区分这些层次,既不会淡化风险,也不会把所有下载者描述成已经执行第二阶段。
事件的关闭条件因此可以衡量:没有任何可构建分支或活动缓存继续解析未授权对象;每宗安装事件都有证据等级和负责人;每项可达凭据都被作废、复核或证明未曾交付;每件受影响输出都被删除、排除或由经过证明的干净构建产物替换;发布控制能够拒绝本次事件使用的分叉。只有做到这里,那场三小时注册表事件才不再投下一条没有尽头的阴影。
8 那个缺失的提交,首先是警报,不是不在场证明
回到开头那名工程师。仓库看起来干净,是因为固定下来的 Axios 源码确实干净;应用冒烟测试通过,是因为新增包根本不需要改变 Axios 行为;后来查看依赖也可能看起来无害,是因为安装器尝试覆盖自己的清单。每个观察单独看都说得通。真正的错误,是让任何一个观察去回答它天生回答不了的问题。
Git 回答维护者提交了什么,npm 回答客户端可能收到什么,锁回答项目选中了什么,包管理器和终端回答什么真正运行,身份提供方回答哪些权限变得可用,制品库与部署系统回答哪些东西在运行器之后继续存活。只有把这些记录摆到同一条时间线上,事件才变得可读。
这也是“注册表—源码分叉”能够复用于未来检测的原因。一个流行名称不是源码身份,一个 semver 字符串不是发布仪式;有效包签名或来源证明,也只有与它声称的工作流和源码相互对应时才有意义。干净仓库不会替一件分叉的交付对象开脱,它恰恰提供了让分叉显形的基线。
8.1 独立证据越多,结论才越强
固定的 v1.14.0 提交与清单、事发前发布工作流、OIDC 修复、脚本策略、npm 生命周期与锁语义,说明正常发布原本如何工作、维护者后来改了什么;已删除对象的清单差异、设置流程、清理行为、平台路径和摘要,则由微软、维护者、Azure Pipelines 指南及留存样本研究承接。SOSEC 没有取得或执行已删除 tarball,因此本地案件若要从“来源描述的机制”推进到“确认执行”,仍需自己的锁、进程、网络、文件和身份记录。微软对 Sapphire Sleet 的归因可以辅助情报研判,但隔离运行器、处置可达凭据和重建产物,应由交付对象与本地证据直接驱动。
8.2 持久控制,是让发布故事一页也跳不过去
修复后的系统要求每次发布用几种语言讲同一个故事:受保护源码标签、固定依赖图、已知构建工作流、短期发布身份、经过评审的环境、来源证明,以及字节与这些声明相符的注册表对象。消费者再补上自己的章节:固定对象、限制安装代码与出站、记录构建,并把输出挂到同一条血缘上。
系统要有用,不需要每名证人都完美。价值来自“不一致会大声叫停”。注册表包没有标签、来源证明不是预期工作流、锁变更没有评审、新生命周期钩子没有批准、安装进程访问未知主机——任何一项都应在缺页变成事故报告前打断故事。
团队可以分阶段落地。先把锁、注册表回执、任务身份与产物摘要保留到足以互相连接;再执行冻结解析,让生命周期代码可见;随后把发布绑定到可信工作流,并在消费端要求来源证明;最后把签名和部署决定也连接到同一血缘。即使整个环境还没有一步到位,每个阶段都能减少下一次调查的不确定性。
旧项目需要一条迁移路径,永久豁免只会把最弱发布路线保留下来。暂时无法使用可信发布的仓库,仍可以要求受保护标签、专用发布身份、独立审批、不可变日志与发布后 tarball—源码构建比较;确实依赖安装脚本的包,可以隔离该构建步骤,移走发布凭据,限制出站,并在生成文件进入主图前提供证明。目标是把剩余信任写出并约束,不能借兼容性把它藏起来。
消费者还要提前决定“旁证暂时不可用”时怎么办。来源服务故障、注册表元数据延迟或内部镜像失败,都不应把策略悄悄降级为“名字对就接收”。高风险发布流水线可以默认拒绝(fail closed),或把对象送入隔离;低风险开发流程可以允许明确、限时的例外,但要记录申请人、理由、对象摘要与后续复核。这样,例外也会成为发布记录中一名看得见的证人。
控制成效应由可验证行为衡量:构建是否使用经过评审的锁,未批准生命周期钩子是否被阻止,注册表—源码不一致多久能被隔离,发布产物是否带有可验证来源,缓存和部署能否回溯到消费任务与源码。平台可用冻结安装、脚本准入、可信镜像、最小运行器身份、网络策略、持久回执和审批后才取得短期权限的发布模板承担日常负担;再用无害的缺标签测试包、no-op 生命周期声明或专用拒绝目标演练停线、告警、留证和权限隔离。演练若只留下仪表盘数字而没有可连接回执,响应链仍未准备好。
于是,Axios 事件在结尾完成一次反转。最初,没有恶意 Git 提交看起来像一条安慰;走完全程,它反而是最明确的停线理由:正在交付的软件,在本应授权它的源码历史里找不到位置。工程师不该在“相信 Git”与“相信 npm”之间二选一。交付系统应该要求两者一致,保存每张回执,并在它们不一致时拒绝构建。
研究记录
9证据、对象与来源
下面保留本文实际使用的标识、时间和原始材料,便于继续调查。
9.1可检索观测值
来自引用材料或本次调查的值,并附有使用这些值所需的上下文。
| 类型 | 值 | 说明 | 操作 |
|---|---|---|---|
| SHA-256 | 92ff08773995ebc8d55ec4b8e1a225d0d1e51efa4ef88b8849d0071230c9645a | 已公布 macOS 第二阶段摘要 | |
| SHA-256 | ed8560c1ac7ceb6983ba995124d5917dc1a00288912387a6389296637d5f815c | 已公布 Windows PowerShell 阶段摘要 | |
| SHA-256 | f7d335205b8d7b20208fb3ef93ee6dc817905dc3ae0c10a0b164f4e7d07121cd | 已公布 Windows 批处理阶段摘要 | |
| SHA-256 | fcb81618bb15edfdedfb638b4c08a2af9cac9ecfa551af135a8402bf980375cf | 已公布 Linux Python 阶段摘要 |
9.2研究对象
报告涉及的产品、行为者、技术、受影响对象和控制点。
未经授权的 1.x 注册表版本,已从 npm 移除
未经授权的 0.x 注册表版本,已从 npm 移除
携带已报告 postinstall 交付链的依赖
已公布历史基础设施,需结合时间与进程祖先判断
已公布历史地址与 TCP 8000,托管归属可能改变
9.3事件时间
- 看似干净的依赖版本先行发布
[email protected] 建立一条更早的发布记录。
- 带生命周期交付的版本出现
[email protected] 引入已报告的 postinstall 路径。
- 两条 Axios 版本线分叉
未授权的 1.14.1 与 0.30.4 注册表对象引用新增依赖。
- 未授权 Axios 版本被移除
npm 移除两个版本,相关依赖约在 03:29 被撤下。
- 发布与安装控制加固
Axios 采用可信发布,收紧工作流权限,加入审批控制,并按项目策略禁用依赖生命周期脚本。
- SOSEC 完成证据复核
SOSEC 固定干净源码对象,区分留存样本结论,并围绕安装事件血缘重建响应链。
9.4来源与材料
- 微软威胁情报:Axios npm 供应链事件分析https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/
- Axios 维护者事后说明与时间线https://github.com/axios/axios/issues/10636
- 微软 Azure Pipelines 客户处置指南https://devblogs.microsoft.com/devops/axios-npm-supply-chain-compromise-guidance-for-azure-pipelines-customers/
- Axios v1.14.0 固定干净源码提交https://github.com/axios/axios/tree/46bee3dea75ef53a8eae49f3b7487e6341de6074
- 固定提交上的 Axios 干净清单https://github.com/axios/axios/blob/46bee3dea75ef53a8eae49f3b7487e6341de6074/package.json
- Axios 事发前发布工作流https://github.com/axios/axios/blob/46bee3dea75ef53a8eae49f3b7487e6341de6074/.github/workflows/publish.yml
- Axios 迁移可信发布的提交https://github.com/axios/axios/commit/ebf30369328b61c7ea9d152d4d5afa7d26e59c06
- Axios 项目级安装脚本策略提交https://github.com/axios/axios/commit/302e2739c602f00e323d4f3f5c79500647633a73
- Axios 当前源码与发布威胁模型https://github.com/axios/axios/blob/v1.x/THREATMODEL.md
- Axios 官方发布历史https://github.com/axios/axios/releases
- npm ci 行为与冻结锁契约https://docs.npmjs.com/cli/commands/npm-ci/
- npm 生命周期脚本文档https://docs.npmjs.com/cli/using-npm/scripts/
- npm 配置与 ignore-scripts 策略https://docs.npmjs.com/cli/configuring-npm/npmrc/
- npm 可信发布https://docs.npmjs.com/trusted-publishers/
- npm package-lock 语义https://docs.npmjs.com/cli/configuring-npm/package-lock-json/
- npm 来源证明声明https://docs.npmjs.com/generating-provenance-statements/
- StepSecurity 对已移除包的留存样本分析https://www.stepsecurity.io/blog/axios-compromised-on-npm-malicious-versions-drop-remote-access-trojan