如果值班群里突然出现这样一条消息,你会先做什么?
我们使用的某个 npm SDK 被确认投毒。恶意版本已经撤下,官方发布了干净版本。CI 目前还能正常构建。
很多技术面试答案会停在三步:锁定安全版本、重新安装依赖、跑一次安全扫描。听起来动作很快,实际只处理了"以后别再装到它",没有回答更危险的问题:污染构件是否已经进入开发机和 CI,恶意逻辑是否运行过,它接触过哪些秘密,以及旧构建产物是否仍在对外服务。
2026 年 7 月 8 日发生的 Injective npm 供应链事件,恰好把这个问题讲得很清楚。公开技术分析记录显示,攻击者在受信任的代码与发布链路中加入恶意代码,随后自动发布机制在几十秒内把同步版本推到 18 个包。受污染的 @injectivelabs/sdk-ts@1.20.21 从可安装到干净版本替换,中间只有大约 17 分钟。
17 分钟看起来很短。对自动化依赖更新、全球时区里的开发机、持续运行的 CI 和镜像缓存来说,它已经足够长。
更关键的是,这次恶意代码并不依赖大家熟悉的 preinstall 或 postinstall。它藏在运行时路径里:当应用调用助记词或私钥派生相关能力时,敏感值会进入一条额外的收集路径,经过短暂批处理和 Base64 编码,再借看似正常的请求头尝试外带。于是,npm install --ignore-scripts、构建成功、单次 SCA 扫描都无法单独回答"我们是否安全"。
这也是一道很好的中高级技术面试题:它考的不是背安全工具名称,而是能否在信息不完整、时间紧张的情况下,建立一条可验证的事故响应链。
先把 17 分钟放回真正的时间轴
根据 StepSecurity 公开的仓库与发布记录,关键节点大致如下:
| UTC 时间 | 事件 | 对响应的意义 |
|---|---|---|
| 20:24 | 第一笔恶意提交进入代码库 | 源码信任边界已经失守 |
| 20:48 | 出现第二次格式化式改动 | 可能用于降低改动的可疑度 |
| 20:54 | 版本号被提升 | 发布流程即将被触发 |
| 20:59:17--21:00:39 | 18 个包被自动发布 | 污染从源码扩散到公共制品 |
| 21:16 | 恶意改动被回滚 | 仓库 HEAD 恢复不等于已发布制品消失 |
| 21:47:52--21:49:16 | 干净的 1.20.23 陆续发布 | 新安装可恢复,但旧环境仍需处置 |
所谓"17 分钟",描述的是某个污染版本可直接从 npm 获取的窗口。它不代表恶意代码只运行了 17 分钟。只要 tarball 已进入 npm 缓存、公司代理仓库、CI 缓存、开发机 node_modules、容器层或已经部署的 bundle,它的实际寿命就可能远长于公开窗口。
面试中遇到"受影响版本已撤下"时,第一句可以这样回答:
下架只关闭新的分发入口;已经解析、缓存、构建或部署的构件不会自动消失。我会把时间轴拆成"可安装窗口"和"内部使用窗口",再决定隔离、轮换与重建范围。
这句话比"先升级依赖"更接近事故现场。
版本命中只是起点,真正要确认四个时间
排查暴露面时,我会同时寻找四个时间点:
- 首次可安装时间:恶意版本何时出现在公共仓库。
- 首次进入内部时间:最早哪台开发机、CI Runner 或代理仓库拿到该 tarball。
- 首次执行时间:什么时候有进程真正加载并走到恶意路径。
- 最后执行时间:旧构件、镜像或服务何时彻底退出。
前两个时间决定污染传播面,后两个时间决定秘密暴露面。
可以按下面的顺序收证:
- 在
package-lock.json、pnpm-lock.yaml、yarn.lock中搜索精确包名和版本; - 检查制品清单、SBOM、容器镜像层和前端静态 bundle 的依赖来源;
- 查询公司 npm 代理、CI 缓存与开发机缓存中 tarball 的完整性摘要;
- 对照 CI 作业时间、部署时间和镜像 digest,确认哪些构建实际用了污染制品;
- 从应用调用链判断恶意函数是否可能触发,而不是只看包有没有安装;
- 检查 DNS、代理、出口网关和应用日志中与可疑端点、路径、请求头模式相符的记录。
SBOM 很适合回答"哪些系统包含了它",锁文件适合回答"解析到了哪个版本",缓存与构建日志适合回答"何时进入",运行日志和网络证据才更接近"是否执行"。四类证据互相补位,任何一类单独拿出来都不够。
为什么 --ignore-scripts 没有挡住这次风险
npm 供应链攻击经常利用生命周期脚本,所以关闭安装脚本仍然是有价值的减面措施。但把它当成万能开关,会漏掉至少三类路径:
1. 运行时触发
恶意逻辑直接进入库的导出函数。安装阶段没有异常,只有业务代码调用相关 API 时才执行。这次事件的关键风险就在这里。
2. 构建时触发
打包器、编译插件、代码生成器、测试环境或文档构建会加载依赖。即使线上运行时不调用,CI 也可能已经执行了它。
3. 开发工具触发
编辑器插件、lint 规则、测试运行器和脚手架常常拥有读取仓库文件、环境变量和网络访问的能力。它们不必依赖安装钩子,也能接触高价值数据。
因此,更完整的判断应当是:一个外部构件在哪些阶段获得了什么权限?
| 阶段 | 可能接触的资产 | 典型控制 |
|---|---|---|
| 安装 | 文件系统、shell、用户目录 | 禁用脚本、隔离安装、无秘密环境 |
| 构建 | 源码、CI Token、制品仓库凭据 | 临时 Runner、最小权限、出站限制 |
| 测试 | 测试账号、数据库、云沙箱 | 独立凭据、短生命周期、审计日志 |
| 运行 | 用户数据、业务密钥、钱包或签名材料 | 进程隔离、密钥服务、网络策略 |
| 发布 | npm/OIDC/签名身份 | 可信发布、双人审核、来源证明 |
面试官追问"我们已经设置 ignore-scripts=true,还需要做什么",可以从这张权限表继续,而不是简单回答"再加一个扫描器"。
升级依赖为什么不能撤销已经发生的事
把 1.20.21 升到干净版本,能阻止后续进程继续加载旧恶意代码,却无法完成三件事:
- 撤回已经发出的网络请求;
- 让已被读取的密钥自动失效;
- 清除仍然引用旧 tarball 的缓存、镜像和部署制品。
这就是为什么安全团队会把"升级"和"凭据轮换"分开管理。前者修复代码状态,后者修复信任状态。
如果恶意路径能够接触助记词、私钥、npm Token、云凭据或 CI 发布身份,响应时不能用"日志里暂时没看到成功外传"直接排除风险。日志可能不完整,终端可能绕过代理,请求字段可能伪装成遥测,Base64 也不会让内容自动变得安全。更稳妥的做法是按照"能够接触"与"实际使用"分层:
| 分层 | 处置 |
|---|---|
| 污染构件存在但未加载 | 隔离构件,重建环境,核验未执行证据 |
| 进程加载但触发路径不成立 | 保留调用链与测试证据,继续排查同环境秘密 |
| 触发路径可能成立 | 将相关秘密视为暴露,立即轮换或迁移资产 |
| 已观察到外带或异常使用 | 进入完整入侵响应,吊销会话、封禁凭据并追踪后续行为 |
对助记词或私钥而言,"轮换"通常还意味着把资产迁移到新密钥控制的地址;对云密钥而言,要先创建新凭据、切换服务、验证稳定,再吊销旧凭据;对 npm 发布 Token,要同时检查它是否还能修改其他包。不同秘密的恢复动作不同,但共同原则是:修包不能代替修信任。
一条可执行的五阶段响应链
阶段一:发现与定级
先固定已知事实,不急着扩写攻击故事:受影响的精确包名与版本、公开时间线、恶意函数、可能的网络指标、上游修复版本、内部依赖路径。把"已证实""高度可能""仍待确认"分列记录。
初始定级至少考虑:包是否直接处理秘密;是否位于 CI 或生产链路;是否存在传递依赖;污染版本是否进入缓存;是否有出站能力。它比下载量更能决定组织风险。
阶段二:遏制
在仓库和代理层封禁污染版本,暂停会继续消费旧缓存的流水线,隔离命中的开发机或 Runner,冻结相关发布权限。对已部署服务,可根据风险选择下线、切流或替换镜像。
遏制动作要避免破坏证据。直接清理所有缓存虽然看起来干净,却可能让团队失去 tarball 摘要、文件时间、网络记录和构建链证据。先快照,再清除。
阶段三:根除
从可信来源获取干净构件,校验版本、摘要、来源和对应提交;清理代理仓库与 CI 缓存;重建 Runner、容器和依赖目录。对于能接触秘密的环境,优先选择一次性重建,而不是在原机上反复"杀毒后继续用"。
根除还包括修复发布链:检查谁能直接推送、谁能发布包、自动发布由什么事件触发、发布身份能覆盖哪些命名空间、是否存在长期 Token,以及源代码与制品之间能否建立可验证关联。
阶段四:恢复
恢复必须使用新生成的构件,不复用旧镜像层。逐步放量后监控异常出站、鉴权失败、密钥吊销影响和业务错误。高风险系统可以先恢复只读能力,再恢复签名、资金、发布等敏感操作。
"服务能启动"不是恢复完成。至少还要证明:污染版本已从活动制品中消失,相关秘密已更换,旧凭据已被吊销,构建环境来自干净基线,出口策略能观察并阻止同类异常。
阶段五:复盘与修复信任链
复盘的核心不是问"谁手滑了",而是找出一个身份为何能同时完成写代码、触发发布和扩大影响。可以优先落地这些控制:
- 保护默认分支,禁止直接推送;
- 高风险目录和发布配置要求明确 reviewer;
- 发布身份改用短生命周期的可信发布,不保留广权限长期 Token;
- 让源码提交、构建环境、制品摘要和签名来源可关联;
- 构建 Runner 默认不携带生产秘密,敏感作业使用最小权限身份;
- 对包安装与构建环境设置出站白名单或至少完整代理审计;
- 保存 SBOM、lockfile、镜像 digest 和构建 provenance,支持分钟级反查。
这些控制的价值在于缩小"一个受信任身份被拿下"之后的爆炸半径。
锁文件、SBOM、签名分别证明什么
供应链讨论里常出现三个词:锁文件、SBOM、来源证明。它们解决的问题不同。
锁文件证明一次解析选择了哪些版本和摘要。它提高可复现性,也能阻止依赖在没有审查的情况下漂移。但如果锁定的正是污染构件,它会稳定地重复安装污染构件。
SBOM列出软件由哪些组件构成,适合在情报出现后快速反查受影响服务。它提高可见性,却不能证明某段代码从未执行。
来源与签名证明把制品与构建流程、代码仓库、工作流身份关联起来,用于识别手工上传、来源不明或构建链偏离。它能提高篡改成本,但若攻击者已经控制被信任的发布链,组织仍需审核源代码变化和权限边界。
所以成熟答案不会说"上了 SBOM 就安全",而会说:锁文件负责解析确定性,SBOM 负责资产定位,来源证明负责构建身份,运行观测负责执行证据,密钥治理负责暴露后的恢复。
面试官真正想听的,是你的判断顺序
面对这类题,我会用一段六分钟回答,而不是堆几十个工具名。
**前 30 秒给结论:**污染版本撤下只阻止新分发;已经进入组织的构件、执行过的代码和接触过的秘密需要独立处置。
**接下来 90 秒讲威胁模型:**攻击入口在受信任发布链;触发点在运行时;目标是助记词或私钥派生路径;外带尝试伪装成普通网络字段。
**再用两分钟讲响应:**锁定版本与时间窗,封禁并隔离,保留证据,判断执行与秘密接触,轮换凭据,从干净环境重建,再逐步恢复。
**最后两分钟讲长期控制和指标:**分支保护、双人审核、短期发布身份、来源证明、隔离构建、出口控制;指标看发现到封禁时间、受影响资产确认率、凭据轮换完成率、干净重建覆盖率和旧构件归零时间。
我会把这条回答放进 offer.cc 做连续追问练习:如果日志缺失怎么办,如果包是传递依赖怎么办,如果只能证明"安装过"却不能证明"执行过"怎么办。连续追问能迫使答案从安全口号落到证据和取舍。
一个团队明天就能用的检查表
30 分钟内
- 固定包名、污染版本、干净版本和公开时间线;
- 在依赖清单、锁文件和代理仓库中精确搜索;
- 阻止新的安装与发布,保全缓存和日志证据;
- 找出能接触高价值秘密的命中环境。
4 小时内
- 将命中构建映射到镜像、部署和用户流量;
- 分析运行时触发条件并查询网络指标;
- 隔离高风险 Runner、开发机和服务;
- 开始轮换可能暴露的密钥与发布身份;
- 从可信基线构建替代制品。
24 小时内
- 让所有活动部署离开污染构件;
- 吊销旧凭据并检查异常使用;
- 清理代理、CI 与镜像缓存;
- 形成"受影响、未受影响、仍待确认"资产表;
- 给每一项恢复结论附证据负责人和时间。
一周内
- 修复分支、发布与构建权限;
- 为关键包建立 SBOM 与 provenance 留存;
- 为构建环境增加出站控制和秘密隔离;
- 用本次时间线做一次桌面演练,验证下次能否更快封禁和反查。
结尾:安全版本解决未来,事故响应解决过去
Injective 事件最值得工程师记住的,不是某个具体包名,而是一个时间差:公共仓库里的污染窗口可以很短,内部构件和秘密的风险窗口却可能很长。
安全版本发布后,未来的安装有了正确选择。团队仍要处理过去发生的传播、执行和信任暴露。把这两件事分开,才能回答清楚"为什么升级依赖还不算止血"。
下一次技术面试遇到供应链题,试着少背几个工具,多给四类证据:哪个构件进来了,在哪运行过,接触过什么秘密,靠什么证明已经恢复。能把这四个问题讲明白,答案就已经从知识点跨进了工程现场。
参考资料
- 中文深度分析:cn-sec.com/archives/53...
- 中文事件快报:cn-sec.com/archives/53...
- StepSecurity 事件时间线:www.stepsecurity.io/blog/inject...
- GitHub 恶意依赖告警说明:docs.github.com/zh/code-sec...
- InfoQ 中文供应链案例:www.infoq.cn/article/ePx...
- 国家互联网应急中心相关中文报道:www.ithome.com/0/937/703.h...