npm audit:天生有缺陷
July 7, 2021
安全很重要。没有人愿意成为那个主张降低安全性的人。所以没人愿意说出真相。但总得有人说。
那我就来说吧。
npm audit
的工作方式是有问题的。在每次 npm install
后默认启用它的做法过于仓促,缺乏考虑,并且对前端工具链来说远远不够完善。
你听过“狼来了”的故事吗?(点此了解)剧透一下:最后羊被狼吃了。如果我们不想让自己的“羊”被吃掉,就需要更好的工具。
截至目前,npm audit
已经成为整个 npm 生态的一块污点。最好的修复时机是在它被默认启用之前。其次的最佳时机就是现在。
在这篇文章中,我会简要介绍它的工作原理,为什么它有问题,以及我希望看到哪些改变。
注:本文带有批判和略带讽刺的语气。我理解像 Node.js/npm 这样的大型项目维护起来非常不易,错误有时也需要时间才能显现。我只是对现状感到沮丧,而不是对相关人员有意见。之所以保留了讽刺的语气,是因为这些年我的挫败感越来越强烈,我也不想假装问题没有那么严重。最让我难过的是,很多人把这当作第一次编程体验,还有很多人因为这些无关紧要的警告被阻止发布自己的变更。我很高兴这个问题正在被重视,我也会尽力为提出的解决方案提供建议!💜
npm audit 是如何工作的?
(如果你已经了解其工作原理,可以跳到这里。)
你的 Node.js 应用有一棵依赖树。大致如下:
your-app
- view-library@1.0.0
- design-system@1.0.0
- model-layer@1.0.0
- database-layer@1.0.0
- network-utility@1.0.0
实际上,这棵树通常会更深。
假设现在 network-utility@1.0.0
被发现有安全漏洞:
your-app
- view-library@1.0.0
- design-system@1.0.0
- model-layer@1.0.0
- database-layer@1.0.0
- network-utility@1.0.0(有漏洞!)
这个漏洞会被发布到一个特殊的注册表,下次你运行 npm audit
时 npm
会访问它。从 npm v6+ 开始,你在每次 npm install
后都会看到类似提示:
1 vulnerabilities (0 moderate, 1 high)
To address issues that do not require attention, run:
npm audit fix
To address all issues (including breaking changes), run:
npm audit fix --force
你运行 npm audit fix
,npm 会尝试安装带有修复的最新 network-utility@1.0.1
。只要 database-layer
依赖的不是严格的 network-utility@1.0.0
,而是包含 1.0.1
的某个范围,修复就会“自动生效”,你的应用也能正常运行:
your-app
- view-library@1.0.0
- design-system@1.0.0
- model-layer@1.0.0
- database-layer@1.0.0
- network-utility@1.0.1(已修复!)
另一种情况是,database-layer@1.0.0
严格依赖 network-utility@1.0.0
。这时,database-layer
的维护者也需要发布新版本,允许依赖 network-utility@1.0.1
:
your-app
- view-library@1.0.0
- design-system@1.0.0
- model-layer@1.0.0
- database-layer@1.0.1(已更新以允许修复)
- network-utility@1.0.1(已修复!)
最后,如果整个依赖树无法平滑升级,你可以尝试 npm audit fix --force
。这种方式适用于 database-layer
既不接受新版本的 network-utility
,又不发布更新来兼容它的情况。此时你只能“强行”升级,可能会引入破坏性变更,但有时也不得不用。
这就是 npm audit
理论上的工作方式。
正如某位智者所说,理论上理论和实践没有区别,但实际上却有。而问题也正是从这里开始的。
为什么 npm audit 是有问题的?
让我们看看实际效果。我用 Create React App 做测试。
如果你不熟悉它,它是一个集成工具,整合了 Babel、webpack、TypeScript、ESLint、PostCSS、Terser 等多个工具。Create React App 会把你的 JavaScript 源码转成静态 HTML+JS+CSS 文件夹。注意,它不会生成 Node.js 应用。
我们来创建一个新项目!
npx create-react-app myapp
项目刚创建完,我就看到了:
found 5 vulnerabilities (3 moderate, 2 high)
run `npm audit fix` to fix them, or `npm audit` for details
天哪,这么快就有漏洞?我刚建的项目就已经不安全了?
npm 是这么告诉我的。
我们运行 npm audit
看看详情。
第一个“漏洞”
这是 npm audit
报告的第一个问题:
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Moderate │ Regular Expression Denial of Service │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ browserslist │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in │ >=4.16.5 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ react-scripts > react-dev-utils > browserslist │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://npmjs.com/advisories/1747 │
└───────────────┴──────────────────────────────────────────────────────────────┘
显然,browserslist
有漏洞。它是什么?怎么用的?Create React App 会根据你指定的目标浏览器生成优化后的 CSS。例如,你可以在 package.json
里这样配置:
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
这样输出的 CSS 就不会包含过时的 flexbox hack。由于多个工具都依赖同一份浏览器配置,Create React App 就用共享的 browserslist
包来解析配置。
那漏洞是什么?“正则表达式拒绝服务攻击” 意味着 browserslist
里有个正则,如果输入恶意字符串,可能会极度变慢。攻击者可以构造特殊配置字符串,传给 browserslist
,让它指数级变慢。听起来很糟糕……
等等!回想一下你的应用怎么工作的。你有一个本地配置文件,在你自己的机器上。你_构建_项目,得到静态的 HTML+CSS+JS 文件,然后部署到静态托管。应用用户根本不可能影响你的 package.json
配置。**这完全说不通。**如果攻击者已经能改你的配置文件,你遇到的麻烦可比慢正则严重多了!
所以,这个“中等”级别的“漏洞”在这个项目场景下既不严重,也不是漏洞。继续看下一个。
结论:这个“漏洞”在当前场景下完全荒谬。
第二个“漏洞”
下一个 npm audit
报告的问题:
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Moderate │ Regular expression denial of service │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ glob-parent │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in │ >=5.1.2 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ react-scripts > webpack-dev-server > chokidar > glob-parent │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://npmjs.com/advisories/1751 │
└───────────────┴──────────────────────────────────────────────────────────────┘
看看依赖链:webpack-dev-server > chokidar > glob-parent
。webpack-dev-server
是仅用于开发的本地服务器,用来本地快速预览你的应用。它用 chokidar
监听文件系统变化(比如你保存代码时),而 glob-parent
用于从文件监听模式中提取路径。
不幸的是,glob-parent
有漏洞!如果攻击者传入特殊文件路径,会让函数变得极慢,然后……
等等!开发服务器在你本地,文件也在你本地,文件监听用的是你自己配置的路径。这些逻辑都不会离开你的电脑。如果攻击者能在你本地开发时入侵你的机器,他们最想做的绝不是构造超长路径拖慢你开发,而是偷你的机密。所以这个威胁完全不成立。
这个“中等”级别的“漏洞”在当前场景下既不严重,也不是漏洞。
结论:这个“漏洞”在当前场景下完全荒谬。
第三个“漏洞”
再看下一个:
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ Moderate │ Regular expression denial of service │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ glob-parent │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in │ >=5.1.2 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ react-scripts > webpack > watchpack > watchpack-chokidar2 > │
│ │ chokidar > glob-parent │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://npmjs.com/advisories/1751 │
└───────────────┴──────────────────────────────────────────────────────────────┘
等等,这和上面那个一模一样,只是依赖路径不同。
结论:这个“漏洞”在当前场景下完全荒谬。
第四个“漏洞”
这个看起来很严重!npm audit
甚至用红色显示:
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ High │ Denial of Service │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ css-what │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in │ >=5.0.1 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ react-scripts > @svgr/webpack > @svgr/plugin-svgo > svgo > │
│ │ css-select > css-what │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://npmjs.com/advisories/1754 │
└───────────────┴──────────────────────────────────────────────────────────────┘
这个“高危”问题是什么?“拒绝服务攻击”。我可不想服务被拒绝!那会很糟糕……除非……
我们看看这个问题。原来 css-what
是个 CSS 选择器解析器,遇到特殊输入会变慢。它被用在一个把 SVG 文件转成 React 组件的插件里。
也就是说,如果攻击者控制了我的开发机或源码,放了一个特殊 SVG 文件,里面有特殊 CSS 选择器,会让构建变慢。听起来好像有点道理……
等等!如果攻击者能改我的源码,直接塞个挖矿脚本不香吗?为啥要加 SVG 文件,除非 SVG 也能挖矿?这完全说不通。
结论:这个“漏洞”在当前场景下完全荒谬。
所谓“高危”,也不过如此。
第五个“漏洞”
┌───────────────┬──────────────────────────────────────────────────────────────┐
│ High │ Denial of Service │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Package │ css-what │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Patched in │ >=5.0.1 │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Dependency of │ react-scripts │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ Path │ react-scripts > optimize-css-assets-webpack-plugin > cssnano │
│ │ > cssnano-preset-default > postcss-svgo > svgo > css-select │
│ │ > css-what │
├───────────────┼──────────────────────────────────────────────────────────────┤
│ More info │ https://npmjs.com/advisories/1754 │
└───────────────┴──────────────────────────────────────────────────────────────┘
这和上面那个完全一样。
结论:这个“漏洞”在当前场景下完全荒谬。
要不要继续?
到目前为止,“狼来了”已经喊了五次,其中两次还是重复的。剩下的在实际依赖场景下根本不是问题。
五次误报还不算太糟。
但实际上,这样的误报有成百上千。

过去几个月我花了好几个小时,逐条排查我们收到的所有 npm audit
问题,发现它们在像 Create React App 这样的构建工具依赖场景下,全部都是误报。
当然,这些问题理论上可以修复。我们可以放宽顶层依赖的版本范围(但这样补丁里的 bug 也更容易混进来)。我们也可以频繁发新版本,只为追赶这场“安全秀”。
但这远远不够。想象一下,如果你的测试有99%的时间都因为无关紧要的原因失败!这会浪费无数人力,让所有人都很痛苦:
- 让初学者痛苦,因为他们初次接触 Node.js 生态就遇到这些问题。Node.js/npm 本来就够让人困惑了(如果你按教程加了
sudo
,那就更惨),结果他们在尝试示例或创建项目时,第一眼就看到这些警告。初学者根本不知道什么是 RegExp,更别说判断 RegExp DDoS 或原型污染在用构建工具生成静态站点时是否需要担心。 - 让有经验的应用开发者痛苦,因为他们要么浪费时间做明显没必要的工作,要么和安全部门斗争,解释
npm audit
这个工具从设计上就不适合做真正的安全审计。可偏偏它还被默认启用了。 - 让维护者痛苦,因为他们不得不为那些根本不可能影响项目的虚假漏洞拉取补丁,否则用户就会受挫、恐慌,甚至两者兼有,导致维护者没法专注于修 bug 和做改进。
- 有一天,这会让我们的用户痛苦,因为我们已经让一代开发者要么被警告淹没、根本看不懂,要么干脆无视这些警告——因为每次都会出现,但有经验的开发者(正确地)告诉他们这些都不是实际问题。
更糟糕的是,npm audit fix
(工具建议用的命令)本身还有 bug。我今天运行 npm audit fix --force
,结果把主依赖降级到了三年前的版本,反而带来了真正的漏洞。谢谢你,npm,干得漂亮。
接下来怎么办?
我不知道怎么解决这个问题。但我又不是最初设计这个系统的人,也许不是最合适的解决者。我只知道它现在坏得一塌糊涂。
我见过一些可能的解决方案:
- 如果依赖不会在生产环境运行,就放到
devDependencies
。 这样可以标明某些依赖不会出现在生产代码路径里,因此没有风险。但这个方案有缺陷:npm audit
默认还是会对开发依赖报警。你得_知道_要用npm audit --production
才能不看见开发依赖的警告。能知道这点的人,大多本来也不信任这个工具。这对初学者和公司安全部门都没帮助。npm install
依然用的是普通的npm audit
信息,所以每次安装依赖时你还是会看到所有误报。- 任何安全专家都会告诉你,开发依赖其实也是攻击向量,甚至可能更危险,因为很难检测,且代码运行时信任度很高。**这也是问题如此严重的根本原因:任何真正的问题都会被
npm audit
培养出来的无视习惯淹没在几十个无关警告之下。**迟早会出事。
- 发布时内联所有依赖。 现在越来越多像 Create React App 这样的包都在用这个办法。例如,Vite 和 Next.js 都直接把依赖打包进主包,而不是依赖 npm 的
node_modules
机制。对维护者来说,好处很明显:启动更快,下载更小,用户也不会收到虚假漏洞报告。这是个“巧妙”利用规则的办法,但我担心 npm 这种激励机制会对生态造成什么影响。内联依赖其实违背了 npm 的初衷。 - 提供一种方式反驳漏洞报告。 其实 Node.js 和 npm 也不是没意识到这个问题。有人提出了不同的修复建议。例如,有个提案允许手动解决 audit 警告,让它们不再显示。但这还是把负担推给了应用用户,而他们未必知道依赖树深处的漏洞到底是真还是假。我自己也有个提案:我需要一种方式,能为我的用户标记某个漏洞绝对不会影响他们。如果你不信我的判断,为什么还要运行我的代码?当然我也欢迎讨论其他方案。
问题的根源在于,npm 默认加了一个行为,在很多场景下导致99%+的误报,极大地混淆了初学者的编程体验,让人和安全部门争吵,让维护者不想再碰 Node.js 生态,最终还会让真正的漏洞被忽略。
必须做点什么。
在此之前,我打算以后看到的所有 npm audit
GitHub issue,只要不是_真正_可能影响项目的漏洞,全部关闭。也欢迎其他维护者采用同样的策略。这会让用户感到沮丧,但问题的根本在 npm。我已经受够了这种“安全秀”。Node.js/npm 完全有能力解决这个问题。我也在和他们沟通,希望这个问题能被优先处理。
今天,npm audit
是天生有缺陷的。
无论是初学者、资深开发者、维护者、安全部门,还是最重要的——我们的用户,都值得拥有更好的工具。
Pay what you like