紧急?!消除 underscore 安全风险

1,265 阅读4分钟

「这是我参与2022首次更文挑战的第34天,活动详情查看:2022首次更文挑战」。

一、背景

是个周五,周五就应该有周五的态度!摸鱼专家表示今天安全团队的态度不太端正。。。忽然有人听见喊我老板和我的名字,让我俩看下。。。

我已经嗅到了危险的气息,我预感到又要周五上线。果不其然,我就听到了如下对话:

“这个包我们的项目中也没有用到啊,package.json 没有搜到”!

“是啊,似乎是个三方库的依赖,可能是依赖的依赖”,

“这没法升级啊,这咋办啊”,

“要不x老板和x老师你们看下?”

而我的反应:What? When ? Where ? How ....

这个时候我的老板转过头来说:今天凌晨两点,安全团队扫描了我们的 git 仓库,发现使用项目使用的 underscore 是带有安全漏的版本,并且发送了邮件,这个邮件居然限期当日修复,否则报请我上级批准....

修复建议就是升级到没有问题的版本,npm install xx@xx.xx.xx ????

我一想,这个事情肯定不是执行个把的 npm install 就能解决的问题,我长相不帅气,声音不磁性,有家有室的,蹦迪谁会叫我呢?

虽然这个时候心里想摸鱼,但是领导待咱不薄,咱摸鱼的时候领导从不说啥。

其实听到上面的对话,我心里就已经盘算好了解决方案,所以领导说完情况,我说这个问题好办,不出意外的话中午饭之前应该能搞定,但是可能涉及基础工具升级,影响范围待评估,上线时间得拉 QA 评估一下

二、安全漏洞

安全漏洞的英文翻译为:Security Vulnerability

本次遇到的安全问题是 underscore 这个库的安全问题,国家信息漏洞官方描述:存在代码注入漏洞,攻击者可利用该漏洞容易通过模板函数执行任意代码。

这里还有个工具查询该安全漏洞的具体情况的工具,传送门synk.io

synk.io 还给出了具体使用该漏洞注入的例子:

image.png

在上面的例子中,可以看到调用 child_process (Node.js 原生模块),调用系统命令创建文件,这是一个相当危险的后门。

但是从 synk.io 中可以看出,这个问题从 2012 年就有,直到 2021.3 才修复。所以你说紧急?紧急! 当然庆幸我们的业务没有收到这个漏洞的影响,真不知道该感谢老天爷,还是感谢老天爷....

三、排查问题

当我查到上面的问题细节的时候我就更自信了,尤其是看到 underscore 官方已经修复了。这是为啥呢?

因为问题不是我们最先发现的,漏洞早在一年前就修复了。所以依赖 underscore 的三方库理想情况下已经完成了升级,从前面背景描述可以看出问题的症结所在:

  1. 项目未直接依赖 underscore,但是确实安装了,表现就是 package.json dependencies/devDependencies 没有,但是 package-lock.json 中有;
  2. 有修复漏洞的版本,但是不知道如何入手升级;

解决第一个问题并不难,npm 提供了这种能力,查询一个包在项目中的依赖关系:npm ls <pkg-name>;

$ npm ls underscore

这个命令返回一个依赖树,你会发现这两个包依赖了一个共同依赖:normalize-package-data,而 noraml-package-data 依赖了 underscore

注意此时,我们的项目并不直接依赖 normalize-package-data,他们也是依赖的依赖,项目中直接依赖的是他们的上层:eslint-plugin-import、node-sass

四、解决方案

面对上面的问题,入手点很明确:从项目中找到依赖 underscore 的三方包,然后去查看这些三方包的 change-log 或者 commit 记录,看看是不是把 underscore 升级到修复漏洞后的版本。

所以问题就变的简单起来了,尝试升级 eslint-plugin-import、node-sass,所以问题又变成了个把的 npm install 命令;

package-lock.json 中搜索,在我们的项目使用的版本: eslint-plugin-import@2.23.4node-sass@5.0.0;接着去 github 搜索这两个包,选择距离漏洞版本最近的 release

eslint-plugin-import@2.24.0node-sass@6.0.0,当然要看下这个 release 的时间晚于漏洞发现时间。然后查看二者 package.json 文件,发现这俩包在新版本中移除了对 normalize-package-data 这个依赖,这个是真的狠。。。

然后,然后?本地测试一下没问题,提测,汇报,齐活儿~

五、临时解决方案

进度条告诉你不简单,今天这个漏洞的发展完全符合我的预期,即 underscore 漏洞发现时间较早,依赖 underscoreeslint-plugin-importnode-sass 早就在我司发现漏洞之前发现了漏洞,所以就大大方方的 npm install 就可以完成工作了;

但是,假如一个开源库 a,有个开源库 b 依赖 a。漏洞你首先发现的,当然你肯定不会首先告诉美国,你刚刚提了个 issuea 修复了,然后你也给 b 提了 issue,但是 b 的维护者因为打架被抓了。反正 b 的作者就是很倒霉,就是没办法升级 a 的版本。

还有另外一个场景,你使用的开源框架开发你的业务,你恰好命中了它的 bug,而 bug 来自另一三方包,框架也在迭代暂时不方便发版,而你又不得不解决的时候,你就需要下面的临时方案了;

5.1 package.json 的 overrides

先上文档传送门npm package.json overrides

原文:If you need to make specific changes to dependencies of your dependencies, for example replacing the version of a dependency with a known security issue, replacing an existing dependency with a fork, or making sure that the same version of a package is used everywhere, then you may add an override. Overrides provide a way to replace a package in your dependency tree with another version, or another package entirely. These changes can be scoped as specific or as vague as desired.

三流翻译凑合看吧:
当你需要指定一个依赖的依赖版本时,比如你需要解决一个安全问题,替换一个依赖为他的一个 fork 版本,并且这个包被安装的到处都是(即很多包都依赖了他),这时候你需要一个 overrides
overrides 提供了一种将你的依赖树中的包替换成另一个版本,或者将这个包完全替换成另一个的能力。这种更改可以是明确范围的,当然不想明确范围也是可以的;

npmpackage.json 中设置 overrides 字段强制某个依赖包以指定的版本被安装,会忽略原有的版本控制; 以下的例子和解释来自文档,并加入一些个人理解:

5.1.1 无条件覆盖

以下写法标识将会确保你的依赖中所有的 foo 包的安装版本为 1.0.0,无论你之前的依赖是什么版本,这就是一种不明确范围的写法(....or as vague as desired);

{ 
  "overrides": { 
    "foo": "1.0.0" // 所有的依赖 foo 的包都强制 1.0.0,这种就是不明确作用范围的写法
   } 
}

这是一种简写形式,他的完整版如下: 使用完整版可以控制 foo 本身为 1.0.0,还可以设置 foo 的后代依赖的 bar 的版本为 1.0.0

后代:原文的解释...also making 'bar' at any depth beyond 'foo' also 1.0.0,翻译,任何深度大于 foobar 的版本为 1.0.0,原文说的是深度,因为依赖树是一个有深度的结构,所以原文用了 depth;而我们用的后代,也是想表达相同的意思;

{
  "overrides": {
    "foo": {
      ".": "1.0.0", // . 代表 foo 本身
      "bar": "1.0.0" // bar 表示的是 foo 的后代中的 bar
    }
  }
}

5.1.2 后代包的替换

foobar 的后代包的时候才覆盖为 1.0.0,这个相当于重申一遍上面的全写形式,不过这里说的就是明确作用范围(be scoped as specfic...

{
  "overrides": {
    "bar": {
      "foo": "1.0.0" // 只覆盖 bar 后代中的 foo 包为 1.0.0
    }
  }
}

这个嵌套可以是任意多层(原文...Keys can be nested to any arbitrary length),下面的示例表示覆盖 foo 的版本为 1.0.0,仅当 foobar 依赖且 bar 又被 baz 依赖的情况下。

{
  "overrides": {
    "baz": {
      "bar": {
        "foo": "1.0.0"
      }
    }
  }
}

5.1.3 覆盖指定版本号的子依赖的版本

作为 overrides key 的被覆盖版本号的包名,可以携带一个版本号或者版本号范围;下面的例子,覆盖 foo 版本为 1.0.0,当它是版本号为 2.0.0bar 的后代的情况下;

{
  "overrides": {
    "bar@2.0.0": {
      "foo": "1.0.0"
    }
  }
}

原文中说了一种比较极端的场景:你的项目依赖了 foo 包,还依赖了一个 baz,而 baz 也依赖了 foo,你需要让 baz 下面的 foo 版本和顶层的 foo 包版本一样。

这种场景比较常见与公司内网包被重复依赖的问题,记得之前说过一个相同包有的装在项目根node_modules,有的装在某个包私有的 node_modules 的问题,当时我同事就想让私有的那一份和外面的一样,这个当时我以为做不到,看起来打脸了,无知是原罪啊。。。overrides 给出了解决方案:

overrides 设置某个 包的的 value 时,使用 $ 前缀引用一个外层依赖的包名,表示用外面包的版本覆盖当前包的版本,下面例子;

{
  "dependencies": {
    "foo": "^1.0.0"
  },
  "overrides": {
    // $foo 标识上面 dependencies 中的 foo 的版本号 1.0.0,你可以认为这是个变量;
    "foo": "$foo",
    "bar": "$foo"
  }
}

5.2 npm-force-resolutions

前面的 package.jsonoverrides 实现了问题,但是这个篇幅也很明显,那么有简单做法吗?

有, npm-force-resolutions

原文:This packages modifies package-lock.json to force the installation of specific version of a transitive dependency (dependency of dependency), 译: 这个包通过修改 package-lock.json 的方式让嵌套依赖安装指定版本...

5.2.1 用法:

  1. package.json 中加入 resolutions 属性,它的值是个对象;对象中的 key 是想要覆盖掉的包,值是版本号;
"resolutions": {
  "hoek": "4.2.1"
}
  1. package.jsonscripts 加入 preinstallpreinstallnpm 的生命周期脚本preinstall 表示对应的脚本将在 npm install 执行前运行;
"scripts": {
  "preinstall": "npx npm-force-resolutions"
}
  1. 接着就是调用 npm install 就可以了;

六、总结

本文我们从 underscore 的漏洞开始,讲述了如何解决安全漏洞:

通过 npm ls <pkg-name> 查询依赖关系,如果这些包已经升级到了没有安全漏洞的版本,我们就直接升级,这是最繁琐也是优解决方案;

接着我们有讲了两种临时解决方案:package.jsonoverrides 字段、npm-force-resolutions 包。无论是哪一种临时方案,都不应该首先使用,因为这会带来潜在的风险,越基础的包,越不要这么做。