你的npm install是安全的,但包跑起来之后呢?
作者: CBISMB
责任编辑: 邹大斌
来源: CBISMB
时间: 2026-10-27 10:45
关键字: 供应链攻击 ,npm ,依赖安全 ,indexed-btree ,运行时
浏览: 0
点赞: 0
收藏: 0
多年来,我们判断一个可疑npm包时最先问的问题之一,就是它有没有跑preinstall或postinstall脚本。这些钩子能在开发者写下第一行使用该依赖的代码之前就执行。把它们拦住是明智的防御,可以阻止某类包仅仅因为被人安装就运行起来。
但安装只是一个包在应用里生命的起点。应用一启动,就会用自己的权限、环境变量、网络访问和数据去调用这个包的函数。一个包可以在安装阶段表现完美,却在应用真正使用它时变得危险。
最近披露的indexed-btree事件正是这个区别的教训。Checkmarx Zero发现了一个恶意npm包,它模仿合法的sorted-btree库。这个包没有恶意的安装钩子,它的加载器藏在BTree.prototype.set里——应用在正常使用中会调用这个方法。据报道,触发器会先检查某个特定的键值,然后在独立的Node进程中启动一个混淆过的文件。仅仅安装这个包不会触发该行为;单独import一次也未必会。必须让应用走到相关方法和条件才会触发。
那个确切的键值和第二阶段机制对事件响应人员有用,但更宏观的设计失误要简单得多:安装期的策略能告诉我们一个包在安装时是否执行过,却无法告诉我们这个包内部的代码之后是否安全。
边界划错了
当团队关掉生命周期脚本时,安装过程确实在一个具体、可衡量的意义上变得更安全了。npm的ignore-scripts设置可以阻止package.json里声明的脚本在安装期间运行,npm也提供了审批安装期脚本的控制手段。但这两套机制都没有宣称要检查一个库运行时代码的每一个分支。
我们有时会说得好像安静的安装就等于干净的依赖,这是一种范畴错误。无论package.json里有没有"scripts"字段,包都是可执行代码。应用可能在启动时加载它、在请求处理器里调用它,或者在能访问CI凭证的构建工具里运行它。攻击者可以选择哪个时刻对他有利。
至少有三个时刻需要考虑:安装、模块加载、以及普通的函数调用。它们的风险不同,需要的测试也不同。有些恶意包在模块加载时执行,另一些则等待函数调用、特定参数、某个环境变量,或应用生命周期中更靠后的时点。indexed-btree属于最后一类。只检查安装钩子的扫描,是只看了一个时刻,却把它当成了全部。
这一点很重要,因为运行时的上下文可能比安装时的上下文更有价值。开发者的笔记本上可能有云凭证,CI任务可能有发布Token,生产服务可能能访问客户记录或内部API。具体能碰到什么取决于每个环境如何配置,但包通常是带着调用它的那个进程所拥有的权限运行的。如果那个进程能读密钥或建立网络连接,不受信任的依赖代码也可能做到同样的事。
看着正常的包最难判断
一个依赖往往必须在外观和行为上都足够像那个被期待的库,才能通过常规测试。复制一份API、看起来可信的仓库、能跑通的主路径,都能让一次替换显得稀松平常。开发者会审查自己需要的行为,而恶意行为可以藏在一条不常见的路径上,或藏在一个项目测试永远走不到的条件后面。
indexed-btree这个例子特别有教育意义,因为触发器被放在了一个看起来完全正常的数据结构操作里。对一个B树调用set,有什么可疑的呢?测试套件可能反复调用它,却仍然错过与某个特定键相关的条件。即使条件被触发,一个分离出来的子进程加上被吞掉的错误,也能让调用方应用看起来一切健康。这是对报告中那条代码路径的观察,并不证明每一次安装都导致了失陷。
这同样解释了为什么下载量、漂亮的README和一次通过的安装都不能建立信任。下载数字描述的是分发量,而不是某个具体版本的代码是否安全。与已知项目相似的名字值得更仔细地审视,尤其是当一个新的依赖出现在pull request里,或者lockfile的变化意外引入了一个不同的包时。
安全审查应该问的问题很具体:这次改动把什么代码带进了我们的应用,它在哪里运行,那个进程能访问什么?这个问题比检查生命周期脚本更难,但它才符合依赖实际的运作方式。普通的漏洞扫描也未必能回答它——一个新发布的恶意包并不需要拥有CVE或已知易受攻击的版本,才会出现在漏洞库的告警里。
给新依赖设一道闸门
务实的答案,是把依赖变更变成代码审查中一个独立、可强制执行的部分。一个新增了包或改变了其解析版本的pull request,应当在合并前产出一份依赖差异。已知漏洞检查依然有用,但只是其中一项输入:indexed-btree不需要已知CVE就已经很危险。这道闸门还必须展示确切的包名、版本、可获取时的来源信息,以及真正会运行的已发布文件。
对于收到常规更新的既有依赖,团队可以沿用既定策略。但出现新名字、切换到仿冒包、发布者变更,或者出现无法解释的可执行文件或混淆文件时,就应当暂停合并,交由负责人调查。这是一条决策规则,而不是要求每个工程师去读node_modules的每一行。审查的是解析后的产物及其相对上一个已批准版本的变化,并记录这个包为什么需要、将在哪里执行。
用自动化的依赖差异让它在每个pull request上都可见,然后在分支保护里要求这项检查。例如GitHub的依赖审查可以强制检查新引入的已知漏洞并展示包的变更,但它并不能担保一个没有告警的包就是安全的。为此要另加一条规则,针对新的或异常的包身份,并把这类情况路由给指定审查人。合并的结论应当是明确的:批准这个具体版本用于某个确定用途、发起调查,或者拒绝。
当审查提出疑问时,在一个没有生产密钥的一次性测试环境里运行已发布的产物,调用应用实际会用到的API和输入,观察子进程、意外的文件读取和对外请求,一旦库出现了它本不该有的行为就让测试失败。就indexed-btree而言,只做import的测试可能会错过BTree.prototype.set里那个条件调用。而通过的测试只覆盖了被执行的路径,无法证明每一个隐藏条件都被触达。
更强的保护在恶意路径逃过测试时依然有效:给进程更少的能力。构建任务不该继承发布凭证,除非它正在做发布。生产服务应当只拿到它确实需要的密钥和网络目标。在工作负载允许的前提下,用操作系统或容器隔离限制文件系统访问、进程创建和出网。这些限制需要针对真实应用验证,让团队清楚哪些路径是被有意放行的。
对一个明天就想落地的团队来说,最低限度的策略很明确:把lockfile和依赖差异纳入每个pull request;新的包身份或异常的发布产物必须指定负责人;把不确定的包放进隔离任务中,用真实的调用跑一遍;让使用第三方代码的进程拿不到高价值凭证。这些步骤单独哪一个都不能发现所有恶意包,但合在一起,就能防止一次干净的安装变成一次不劳而获的批准。
把安全声明说得更诚实
"我们拦住了安装脚本"这句话,在它字面意思成立时是有价值的。麻烦始于它变成"依赖不可能执行恶意代码"的简称。一个恶意库如果它的正常API就能给它一条进入特权进程的路,它根本不需要安装钩子。
下一代供应链防御必须跟着依赖走过install这条命令之后。这意味着把引入一个包当成一个关于可执行行为的决定,而不只是package.json里有没有脚本的决定。恶意的indexed-btree包是个及时的样本,但它背后的根本问题在这次事件之后依然存在:应用最终总要去运行它选择信任的那段代码。






京公网安备:11010502051901号