你的 npm 安装是安全的,但包运行时会发生什么?
阻止安装脚本,堵住了恶意 npm 包的一条路径。恶意的 indexed-btree 包说明,为什么应用程序代码需要有自己的信任边界。
多年来,我们判断一个可疑 npm 包时,最先问的问题之一就是:它是否会运行 preinstall 或 postinstall 脚本?这些钩子可以在开发者写下一行使用依赖项的代码之前执行。阻止它们是一种合理的防御。仅仅因为有人安装了它,就阻止某一类包运行,也是合理的。
但安装只是包在应用程序中生命周期的开始。应用程序一旦启动,就会调用包函数,而这些函数拥有自己的权限、环境变量、网络访问和数据。包在安装过程中可以毫无问题,但在应用程序使用它时仍然可能很危险。
这一区别正是最近披露的 indexed-btree 活动的教训。Checkmarx Zero 发现了一个恶意 npm 包,它模仿合法的排序 B-tree 库。这个包没有恶意安装钩子。它的加载器位于 BTree.prototype.set 中,而应用程序会在正常使用过程中调用该方法。据报告,其触发器会检查特定键值,然后在单独的 Node 进程中启动混淆文件。仅仅安装这个包不会触发该行为;单独导入它也不一定会触发该行为。应用程序必须触达相关方法和条件。
具体的键值和第二阶段机制对事件响应人员很有用。更大的设计缺陷其实更简单:安装时策略能告诉我们包是否在安装过程中执行,却无法告诉我们该包中的代码在之后执行时是否安全。
划错的边界
当一个团队关闭生命周期脚本时,其安装过程会以具体且可度量的方式变得更安全。npm 的 ignore-scripts 设置可以阻止 package.json 中声明的脚本在安装过程中运行。npm 还提供在安装时批准脚本的控制机制。这两种机制都不声称能检查库运行时代码的每个分支。
我们有时说得好像安静的安装就意味着干净的依赖。这是范畴错误。无论 package.json 是否包含 scripts 字段,包都是可执行代码。应用程序可以在启动时加载它,在请求处理程序中调用它,或在构建工具中运行它,并访问 CI 凭据。攻击者可以选择哪个时刻最有用。
至少有三个时刻需要考虑:安装、模块加载和普通函数调用。它们具有不同的风险,需要不同的测试。有些恶意包在模块加载时执行;另一些则等待函数调用、特定参数、环境变量或应用生命周期中的较晚时点。indexed-btree 包属于最后一类。只检查安装钩子的扫描只看到了一个时刻,却把它当成全部。
这一点很重要,因为运行时上下文可能比安装上下文更有价值。开发者的笔记本电脑可能有云凭据。CI 作业可能有发布令牌。生产服务可以访问客户记录或内部 API。确切的访问权限取决于每个环境如何配置,但包通常以调用它的进程可用的权限运行。如果进程可以读取机密或打开网络连接,不受信任的依赖代码也可能能够做到这一点。
为什么看似正常的包很难判断
依赖项通常必须在外观和行为上足够像预期库,才能通过常规测试。复制的 API、看似合理的仓库和正常路径,可以让替换看起来稀松平常。开发者会审查他们需要的行为,而恶意行为可能位于不常见的路径上,或藏在项目测试永远不会触达的条件后面。
indexed-btree 示例尤其有启发性,因为触发逻辑被放在看似合法的数据结构操作中。在 B-tree 上调用 set 并不可疑。测试套件可能会反复调用它,却仍然错过与特定键绑定的条件。即使触达该条件,分离的子进程和被吞掉的错误也会让调用方应用看起来一切正常。这是对已报告代码路径的观察,并不证明每次安装都会导致失陷。
这也是为什么下载量、打磨过的 README 和通过的安装都不会建立信任。下载数字描述的是分布,而不是特定版本中代码的安全性。与已知项目相似的包名值得仔细审查,尤其是当拉取请求中出现新的依赖项,或锁文件变更意外引入不同的包时。
安全审查的问题应该是具体的:这次变更给我们的应用程序带来了什么代码?它在哪里运行?该进程可以访问什么?这个问题比检查生命周期脚本更难,但它符合依赖项的实际运作方式。常规漏洞扫描也可能回答不了这个问题。新发布的恶意包并不需要 CVE 或已知易受攻击版本;漏洞公告数据库也不会仅仅因此就报告它。
新依赖项的闸门
实际答案是让依赖变更成为代码审查中独立、可执行的一环。添加包或更改其解析版本的拉取请求,应在合并前生成依赖差异。已知漏洞检查仍然有用,但只是一个输入:indexed-btree 不需要已知 CVE 也能造成危害。闸门还必须显示确切的包名、版本、来源(如可用)以及实际运行的已发布文件。
对于接受常规更新的现有依赖,团队可以套用正常策略。新名称、切换到相似包、发布者变更,或出现无法解释的可执行文件或混淆文件,都应暂停合并,交由负责人调查。这是一条决策规则,而不是要求每个工程师阅读 node_modules 的每一行。检查已解析的制品及其相对此前批准版本的变化;记录为什么需要这个包,以及它将在哪里执行。
在每个拉取请求中显示自动依赖差异,然后要求将其纳入分支保护。例如,GitHub 的依赖审查可以强制检查新引入的已知漏洞,并显示包变更。它并不证明未被公告的包就是安全的。为新的或意外的包身份添加单独规则,并将这些案例转给指定审查者。合并结果应当明确:批准用于既定用途的确切版本、调查它,或拒绝它。
测试行为并限制影响范围
当审查提出疑问时,在没有生产机密的一次性测试环境中运行已发布制品。演练应用程序实际使用的 API 调用和输入。留意子进程、意外文件读取和出站请求,并让库不应有的行为导致测试失败。就 indexed-btree 而言,仅导入的测试可能会错过 BTree.prototype.set 中的条件调用。通过的测试覆盖了演练过的路径;它不能证明每个隐藏条件都被触达。
即使恶意路径逃过测试,更强的保护仍然适用:给进程更少的能力。构建作业不应继承发布凭据,除非它们正在执行发布。生产服务应只获得所需的机密和网络目的地。使用操作系统或容器隔离来限制文件系统访问、进程创建和工作负载允许的出站访问。这些限制需要针对实际应用进行测试,让团队知道哪些路径是刻意允许的。
对于明天就要采用这套做法的团队,最低政策很明确。让锁文件和依赖差异成为每个拉取请求的一部分。要求新包身份或意外发布制品必须有负责人。在隔离环境中用真实调用运行不确定的包。将高价值凭据排除在使用第三方代码的进程之外。这些步骤单独都不能检测每个恶意包;它们合在一起,可以防止一次干净的安装变成未经批准的运行。
更诚实的安全声明
“我们阻止安装脚本”这句话有价值,因为它确实意味着这一点。当它变成“依赖项无法执行恶意代码”的简写时,问题就开始了。如果恶意库的正常 API 就为它提供了进入特权进程的路径,那它不需要安装钩子。
下一代供应链防御必须跟随依赖项,超越安装命令。这意味着把采用一个包视为对可执行行为的决定,而不只是对 package.json 是否包含脚本的决定。恶意 indexed-btree 包是一个及时的例证,但这次活动结束后,根本问题仍在:应用程序最终必须运行它选择信任的代码。