本文旨在介绍使用 NPM 包管理器时的供应链安全最佳实践,由 OSSF 开源开发者最佳实践工作组总结发布。本文将会包含:
供应链安全大背景下 NPM 安全特性概述
NPM 确保供应链安全的建议
实现供应链安全的详细信息
本文旨在补充 NPM 官方文档,读者可参照阅读。
CI 配置
CI 配置中需要遵守最低权限原则。
在通过 GitHub Actions 运行 CI 的情况下,默认的非权限环境是一个无法获取 GitHub Secrets 且没有写入权限的工作流,例如 permissions: read-all
、permissions:
、contents: none
与 contents: read
。有关权限的更多内容,请参阅官方文档。
可以安装 OpenSSF Security Scorecards来标记项目的非读类权限。
依赖管理
准备使用
在使用依赖时,首先要确定其来源、可信度与安全状况。例如 envoyproxy这样的项目对必须使用的外部依赖有着详细的文档记录。
建议:
一定要注意仿冒攻击,攻击者可能会创建看起来像官方的软件包名称来诱使用户安装恶意软件包,此前已经有过多次案例(1、2、3)。尽管 NPM Registry 会进行扫描检测域名抢注,但是系统毕竟并不完美,用户仍然需要保持警惕。可以通过软件包在 GitHub 上的 Start 数量、贡献者数量来评估其可信度。
i. 按照之前的要求是可以使用大写字符的,这也是攻击者攻击的另一个方式,例如 JSONStream 与 jsonstream。
ii. NPM 包名称不再支持非 ASCII 字符,开发人员不必担心存在同形异义攻击(使用类似 ASCII 字符的非 ASCII 字符来进行命名)。当然,该属性在不同的 Registry 中不同,要根据使用的具体情况来判断。当发现感兴趣的 GitHub 项目时,一定要按照文档来确定包名称。
i. 可以使用 OpenSSF Security Scorecards了解依赖项的安全状况。能够使用 deps.dev确定传递依赖的安全状况,也可以使用 component analysis列出依赖传递关系。
可以使用 npm-audit确定依赖中存在的漏洞。
警告:必须说明的是,NPM Registry 不存在组织认证,通常遵循“先到先得”原则。后续,也可以启动“争议程序”质疑组织的占有权。
声明
在 NPM 中,package.json 会描述一个项目的名称、组件、依赖项等基本信息。可以使用包名称、URL、repo 等方式定义依赖关系,也可以为每个依赖关系附加约束条件,例如特定标签、特定分支、特定版本或者特定 commit。
注意:文件中不会列出传递依赖,只列出了直接依赖。
项目类型
本文主要讨论三种类型的项目:
库:通常以 API 调用的方式被其他项目所使用,清单文件中通常会包含
main
、exports
、browser
、module
与types
条目。
独立小程序:通常用户安装作为本地程序使用,可以通过 npx 独立安装运行,也可以通过全局安装。例如 clipboard-cli。
应用程序:团队协作开发的项目,例如网站或者其他 Web 应用程序,例如面向用户 SaaS 的 React Web 应用程序。这些程序的清单文件中通常会包含
"private": true
。
可重复安装
可重复安装需要保证每次安装时的依赖完全相同。这样做具有很多优点,例如:
确保安装的依赖是经过审查确认的依赖。
如果发现依赖之中存在漏洞,可以快速发现基础设施中可能受到的破坏。
能够缓解类似恶意依赖等威胁,如果在 CI/CD 系统或开发人员机器上安装并运行了恶意依赖,攻击者就实现了快速入侵。
在安装前检测是否存在漏洞,例如内存破坏漏洞。
减轻由于错误配置公共 Registry 为代理软件包而引入的包可变性。尽管版本原则上是不可变的,但主要还是由 Registry 在维持不可变性。
提高例如 GitHub 安全告警等自动化工具的准确性。
使维护人员可以在默认分支接受更新前就测试更新代码,例如通过 renovatebot 的 stableDays。
有两种方法可以实现可靠的重复安装:完整依赖与哈希固定。
使用锁定文件
锁定文件使用哈希函数实现哈希固定。哈希固定主动告知包管理器每个依赖项的预期哈希值,而不是选择信任 Registry。在每次安装时,包管理器都会验证每个依赖项的哈希值是否是符合预期的,任何不符合预期的恶意更改都会被发现并被拒绝。
NPM 也提供了两个方式来实现哈希固定。
package-lock.json
package-lock.json 中包含所有依赖项以及哈希值:
{"name": "A","version": "0.1.0",...metadata fields..."dependencies": {"B": {"version": "0.0.1","resolved": "https://registry.npmjs.org/B/-/B-0.0.1.tgz","integrity": "sha512-DeAdb33F+""dependencies": {"C": {"version": "git://github.com/org/C.git#5c380ae319fc4efe9e7f2d9c78b0faa588fd99b4"}}}}}
package-lock.json 实际上就是依赖项的快照,只允许“原模原样”的安装。锁定文件是通过各种命令生成/更新而来的,例如使用 npm install 命令。如果某些依赖项丢失或者未能通过哈希固定校验(如 integrity 字段不存在),命令将会更新锁定文件。
由于锁定文件无法上传到 Registry 中,这意味着使用者通过 npm install 安装的依赖可能与开发者使用的依赖不完全相同。使用 package-lock.json 类似于动态链接,加载时使用系统上可用的库文件来解决依赖关系,使用该锁定文件时将决定依赖项的选择权交给使用者。
npm-shrinkwrap.json
npm-shrinkwrap.json是另一个形式的锁定文件,与 package-lock.json 的主要区别是 npm-shrinkwrap.json 可以上传到 Registry。这可以保证使用者与开发者使用的依赖是一致的。使用 npm-shrinkwrap.json 类似静态链接,所有依赖都在发布前就声明好,使用该锁定文件时将决定依赖项的选择权交给开发者。
要生成 npm-shrinkwrap.json
,必须存在 package-lock.json
文件再执行 npm shrinkwrap must be run
命令。
锁定文件与命令
某些 npm 命令会将锁定文件视为只读,而其他命令不会。例如以下命令会将锁定文件视为只读:
npm ci,用于安装项目与依赖项
npm install-ci-test,用于安装项目与运行测试
以下命令不会将锁定文件视为只读,可能会获取不固定的依赖项并更新锁定文件:
npm install、npm i、npm install -g
npm update
npm install-test
npm pkg set、npm pkg delete
npm exec、npx
npm set-script
建议:
开发人员应该为项目提交清单文件。要创建清单文件,请参阅官方创建 package.json的文档。
将依赖添加到清单文件中,在本地运行
npm install --save <dep-name>
即可将更新的清单提交存储库。要从 Registry 中运行独立小程序,请确保该软件包是在 package.json 文件中声明的依赖的一部分,再在构建时安装在环境中。
开发人员应该为项目提交锁定文件。这样能够为整体环境提供可重复安装的便利,包括项目开发人员的机器、CI、生产或其他可以访问敏感数据的场景(例如 PII、对存储库的写入权限等)。
本地运行测试时,开发人员应该将锁定文件视为只读(请参阅锁定文件与命令),除非要增加或者删除依赖项。
下面对各种不同类型项目的锁定文件进行阐述:如果项目是库:
i. 开发人员不应该发布npm-shrinkwrap.json
,版本解析的选择权应该留给使用者。允许用户在支持的最低版本到最新版本间自行选择,例如使用^m.n.o
确定默认范围、使用~m.n.o
确定较小范围。用户可以自行规避具有严重漏洞的版本,查看 semver 计算器可以帮助确定范围。
ii. 在 CI 中,应该配置忽略锁定文件package-lock.json
(例如:npm install --no-package-lock)。CI 测试应该在用户使用前就使用广泛的版本进行测试以暴露问题,因此在测试时需要拉取最新的软件包。
iii. 在本地,开发人员应该只运行将锁定文件视为只读的命令(请参阅锁定文件与命令),除非要增加或者删除依赖项。
iiii. 在 CI 配置中要遵循最小权限原则,这特别重要,因为锁定文件被忽略了。如果项目是独立小程序:
i. 开发人员可能会发布npm-shrinkwrap.json
,在其中控制更新所有依赖项,用户将无法对其进行编辑。如果希望独立小程序被其他项目所使用,请不要使用npm-shrinkwrap.json
,这样会妨碍用户解析依赖项目。
ii. 在 CI 中,只运行将锁定文件视为只读的命令(请参阅锁定文件与命令)。
iii. 在本地,开发人员应该只运行将锁定文件视为只读的命令(请参阅锁定文件与命令),除非要增加或者删除依赖项。
iiii. 在 CI 配置中要遵循最小权限原则。如果项目是应用程序:
i. 开发人员应该将锁定文件提交到存储库中。
ii. 在 CI 中,只运行将锁定文件视为只读的命令(请参阅锁定文件与命令)。
iii. 在本地,开发人员应该只运行将锁定文件视为只读的命令(请参阅锁定文件与命令),除非要增加或者删除依赖项。
完整依赖
完整依赖会在存储库中保留所有依赖项的本地副本,这样虽然可以保证可重复安装,但也引入了安全风险。这样不仅难以保持依赖更新,审计依赖代码的能力也变差了。除了安全风险,例如存储库大小、可用性、开发人员体验等问题也至关重要。出于以上考虑,在没有工具和解决方案能顺利解决这些问题,不建议使用这种方法。
维护
定期更新依赖关系至关重要,尤其是在披露/修补了重要漏洞时。现在,许多管理依赖的工具,也能够进行安全检查。
建议:
可以使用如 dependabot或 renovatebot之类的工具来管理依赖项。这些工具会提交请求,开发者在查看请求后可以将其合并到默认分支中。
及时了解依赖中存在的漏洞:
i. 已经安装上述工具就请启用安全告警,详情参阅 dependabot config与 renovatebot config。其中 renovatebot 是支持 config-as-code 的,可以很容易验证是否启用安全告警。
ii. 如果更喜欢使用专用工具进行处理,可以定期执行 npm-audit,甚至可以配置在 GitHub Action 中。可以定期执行 npm prune 并提交合并来删除依赖项,现有工具不支持该功能,可以使用 GitHub Action 进行操作。
发布
账户
在 NPM Registry 上发布软件包需要注册一个用户账户,建议该账户启用双因子认证。
签名与验证
默认 NPM 包都使用 NPM Registry 拥有的 ECDSA 密钥进行签名。在 npm CLI v8.15.0 或更高版本下,可以使用 npm audit signatures
命令进行签名验证。
警告:NPM 不支持用户级签名。
发布
发布会将软件包上传到 Registry 上供其他人安装和下载。
建议:
使用 CI 进行发布时,要通过自动化令牌进行身份验证再发布到默认的 NPM Registry。
使用以下命令发布软件包:
npm ci npm publish
公共软件包的使用者可能会成为域名抢注的受害者,为了缓解此问题应该在 Registry 上创建自己的组织。
注意:
NPM Registry 不支持默认的 GitHub Token 进行身份验证,用户需要将其保存为 GitHub Secret。
不要使用 GitHub Action 发布软件包。
通过 NPM 命令行使用 CIDR 条目限定 IP 范围提供额外保护。
私有软件包
当项目依赖内部私有 Registry 的私有软件包时,就可能会发生依赖混淆攻击(也被称为替代攻击)。攻击者可能会在公共 Registry 上注册相同的软件包名。按照默认情况,NPM 将会从公共 Registry 中获取依赖项,在 Alex Birsan 的文章中给出了该类攻击的详细介绍。必须使用 Scopes 框定 NPM 获取正确依赖项的途径,才能规范该行为。
Scopes
Scopes 是一个以 @ 为前缀的命名法,位于包名的起始位置。例如 @myorg/foo 就是一个 Scopes 化的软件包。在 package.json 和 Javascript 代码中,Scopes 化的软件包与其他软件包的使用并无差别。
{"name": "@myorg/foo","version": "1.2.3","description": "just a scoped package name example","dependencies": {"@myorg/bar": "2.x"}}
// es modules styleimport foo from "@myorg/foo";// commonjs styleconst foo = require("@myorg/foo");
公共 Registry 中 Scopes 化的软件包只能由与之相关的用户或者组织来发布,并且 Scopes 内的软件包也可以设置为私有。此外,Scopes 名称可以链接到特定的 Registry。
建议:
在公共 Registry 中,免费创建一个名为 myorg 的组织。之后,就没有人可以在公共 Registry 的
@myorg
下发布任何内容。配置错误发生时间,构建将遇到 404 错误触发失败,并不会默认获取不受信任的内容。触发构建失败这一点非常重要,这可以防止攻击者在公共 Registry 中仿冒组织名称,导致巨大的安全风险。使用 login 命令确保对
@myorg
内所有软件包的请求都会发送到https://registry.myorg.local
的内部 Registry。任何未指明 Scope 的请求都发送到默认 Registry。
npm login --scope=myorg --registry=https://registry.myorg.local
这会将 login 信息保存在 ~/.npmrc
文件中,如下所示:
@myorg:registry = https://registry.myorg.local/
//registry.myorg.local:_authToken = xyzabc123-arbitrary-token-value
不要将
~/.npmrc
文件与凭据信息也提交到存储库中。在自动化开发环境中,配置使用自动密钥,与 GitHub 在工作流中使用的解决方案类似。如果 Registry 支持临时凭据,就不要使用长期凭据。
在项目的根目录中创建文件
.npmrc
,如下所示:@myorg:registry = https://registry.myorg.local/
可以执行如下命令检查当前配置的 Registry:npm config get registry
这样就可以将该项目的 Scopes 与内部 Registry 绑定。
有关该主题的更多信息,请参阅有关 NPM 替代攻击的文章。
私有 Registry 配置
如果使用内部私有 Registry:
仅使用支持范围内私有 Registry
确定私有 Registry 的软件包不可变:
i.确保 Registry 配置为不合并上游公共 Registry 的同名项。尽管这样有时可以解决解析冲突问题,但这样也会使名称劫持利用存在可乘之机。如果可能的话,尽量使用不具备此功能的私有 Registry 实现。
ii.将软件包发布到内部代理 Registry 后,如果发现软件包被删除不要直接请求公共 Registry。如果从公共 Registry 下载该软件包,又会面临攻击者接管恶意软件包名的威胁,攻击者就可以获取系统的访问权限。不能忽略构建失败的情况。构建项目时可能会遇到 404 错误,这不代表 NPM 请求不受信任的内容。但也不要忽略构建失败,在构建失败时要立刻进行修复。
有关该主题的更多信息,请参阅有关 NPM 替代攻击的文章。