- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 开篇 — 你亲手装的包是 14 个,实际装进来的是 1,100 个
- 传递依赖 — 风险的真实形状
- 锁文件保证什么,又不保证什么
- typosquatting 与 dependency confusion — 名字就是攻击面
- postinstall — 安装本身就是任意代码执行
- SBOM 是为事故当天那 5 分钟准备的
- 告警的信噪比 — 可达性,以及签名
- 收尾 — 三项管控就能收拾掉大部分
开篇 — 你亲手装的包是 14 个,实际装进来的是 1,100 个
打开一个项目,把真实的数字数一遍。
jq '.dependencies | length' package.json
jq '.devDependencies | length' package.json
npm ls --all --parseable 2>/dev/null | wc -l
npm ls --omit=dev --all --parseable 2>/dev/null | wc -l
14
23
1123
486
自己写下的是 37 个,装进来的包却是 1,123 个。其中 486 个会跟着一起部署到生产运行时。剩下的 637 个只在构建和测试时用到,但那个时刻的进程通常能访问整个仓库和 CI 密钥。
这个数字就是供应链安全的起点。我们审阅并选择过的是 37 个,而决定去信任的是 1,123 个。剩下的 1,086 个里,大多数连名字都没听说过。
这里还附带着一个常见的误解:反正有锁文件,没事的。锁文件确实解决了一个重要问题,但那个问题不是安全。本文先从锁文件保证什么、不保证什么讲起,再梳理几项真正有效的管控。
传递依赖 — 风险的真实形状
传递依赖比直接依赖更危险,原因有三个。
第一,没有权限边界。无论是 JavaScript 还是 Python,在默认执行模型下任何一个包都能访问文件系统、网络和环境变量。一个字符串填充的小工具,同样能读走整个进程环境并发到外面去。依赖树上任何一片叶子,都拥有和应用完全相同的权限。
第二,看不见维护主体。去确认某个包是通过哪条路径进来的,结果往往令人吃惊。
npm ls chalk --all
app@1.4.2
└─┬ jest@29.7.0
└─┬ @jest/core@29.7.0
└─┬ jest-config@29.7.0
└─┬ jest-validate@29.7.0
└── chalk@4.1.2
要往下走四层才看得到。这个包的维护者是谁、账号有没有开启两步验证,我们都不知道。相当多的真实事故都发生在维护者账号被盗或者交接过程中。
第三,没有升级的决定权。就算发现了漏洞,在中间那层的包放宽范围之前,上层也动不了。强行覆盖的办法是有的,但要承担副作用。
{
"overrides": {
"semver": "7.6.3"
}
}
哪怕只是定期看一眼依赖树的规模,判断也会不一样。
# 找出在生产依赖树里拉进最多包的直接依赖
for dep in $(jq -r '.dependencies | keys[]' package.json); do
count=$(npm ls "$dep" --omit=dev --all --parseable 2>/dev/null | wc -l)
printf '%6d %s\n' "$count" "$dep"
done | sort -rn | head -5
214 @aws-sdk/client-s3
131 express
88 sharp
41 pg
12 zod
为了一个功能就引入两百多个包是否合理,要看具体情况。但不知道这个数字就做决定,和知道之后再做决定,是两回事。
锁文件保证什么,又不保证什么
我们来真读一条锁文件里的条目。
"node_modules/left-pad": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
}
integrity 是 tarball 的 SHA-512 哈希。安装时下载下来的字节如果和这个哈希不一致,安装就会失败。它保证的是下面三件事。
拿到的字节和生成锁文件那一刻完全相同;仓库或中间链路上内容被改动就会立即失败;以及所有开发者和 CI 拿到的是同一棵树。也就是说,锁文件解决的问题是可复现性。
它不保证的是那些字节是否安全。只要维护者按正常流程发布了一个夹带恶意代码的新版本,那个 tarball 的哈希也会被正常地算出来。锁文件保证的只是"和我上周看到的是同一个东西",它并没有说"这东西是无害的"。
更重要的是锁文件在什么时候会被更新。实务上的事故常常出在这里。
# 信任锁文件并原样安装。与 package.json 不一致就失败。
npm ci
# 只要满足 package.json 的范围就更新锁文件。新版本可能被装进来。
npm install
如果 CI 里用的是 npm install,那锁文件基本上就没有意义了。带脱字号范围的依赖每次构建都可能取到新的补丁版本,而那一刻的最新版可能正是被投毒的版本。其他生态也有同样的区分。
pnpm install --frozen-lockfile
yarn install --immutable
uv sync --locked
poetry install --sync
cargo build --locked
go build -mod=readonly ./...
pip install --require-hashes -r requirements.txt
Python 的 --require-hashes 尤其容易被漏掉。没有这个选项,即便 requirements.txt 里把版本钉死了,也不会做哈希校验。
# requirements.txt
requests==2.32.3 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
总结起来,锁文件是必要条件而不是充分条件。锁文件做的事是抓出"有没有人偷偷改过",而"发布出来的东西本身是不是恶意的"必须由别的管控来回答。
typosquatting 与 dependency confusion — 名字就是攻击面
包名是字符串。而安装命令是人用手敲出来的。
typosquatting 是事先把和常用名字只差一两个字母的包传上去。requests 和 request、python-dateutil 和 python-dateutils、crossenv 和 cross-env 这样的组合被反复发现。抄文档时敲错,或者凭记忆敲名字的时候,就会中招。
更具结构性的是 dependency confusion。它的成立条件很明确。
内部专用包的名字在公开仓库上是空着的,构建工具会同时查询多个索引并选版本更高的那个,而这个名字在内部索引里是不带作用域注册的。攻击者把同名包以 99.0.0 的版本传到公开仓库。下一次构建时,公司的 CI 就会把公开版下载下来并执行。
Python 里最常见的错误是这个。
# pip.conf — 危险的配置
[global]
index-url = https://pypi.org/simple
extra-index-url = https://pypi.internal.acme.com/simple
extra-index-url 会把两个索引都查一遍,然后选版本更高的。这里根本没有优先级的概念。必须把内部索引作为唯一入口,并让这个索引去代理公开的包。
# pip.conf — 安全的配置
[global]
index-url = https://pypi.internal.acme.com/simple
在 npm 里,把作用域固定到指定仓库是标准防御。
# .npmrc
@acme:registry=https://npm.internal.acme.com/
//npm.internal.acme.com/:_authToken=${NPM_TOKEN}
registry=https://registry.npmjs.org/
这样一来,以 @acme 开头的名字绝不会从公开仓库解析。这里还得补上一条。把作用域名字本身也在公开仓库上抢注下来会更安全。因为即便构建跑在缺了配置文件的环境里,攻击者也没法往那个作用域下发包。
如果用了内部代理,就要把名字遮蔽的阻断策略打开。也就是:只要内部存在同名的包,代理就干脆不向下游提供公开版。
安装前先确认一下包,也是有帮助的习惯。
npm view left-pad time.created time.modified maintainers dist.tarball
time.created = '2014-03-20T21:08:33.000Z'
time.modified = '2018-03-05T21:16:27.081Z'
maintainers = [ 'azer <azer@roadbeats.com>' ]
dist.tarball = 'https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz'
创建日期就在几天前而下载量暴涨的包,以及维护者最近刚变更过的包,都要多看一眼。
postinstall — 安装本身就是任意代码执行
这是最被低估的一点。npm install 不是一条把文件下载下来解压的命令。它是一条执行每个包的安装脚本的命令。
{
"name": "acme-logger",
"version": "99.0.0",
"scripts": {
"postinstall": "node ./scripts/setup.js"
}
}
setup.js 到底干了什么,没有人会去审。这个脚本在开发者的笔记本上以开发者权限运行,在 CI 上则运行在持有部署凭证的环境里。真实事故中反复出现的模式,就是收集环境变量和查询云元数据。
先数一下当前依赖树里有多少个包带安装脚本。
find node_modules -maxdepth 4 -name package.json -not -path '*/node_modules/*/node_modules/*' \
-exec jq -r 'select(.scripts.preinstall or .scripts.install or .scripts.postinstall) | .name' {} + \
2>/dev/null | sort -u
@parcel/watcher
bcrypt
esbuild
lefthook
sharp
不是几百个,通常也就五到二十个。这个量级是管得住的。所以实务上的建议很明确。默认阻断脚本执行,只对真正需要的包放行。
# .npmrc
ignore-scripts=true
npm ci --ignore-scripts
需要原生绑定的包得做构建,所以要单独放行。pnpm 把这个概念做成了配置项。
# pnpm-workspace.yaml
onlyBuiltDependencies:
- bcrypt
- esbuild
- sharp
Python 也有同样的问题。安装源码分发包会执行 setup.py。只允许 wheel,这条路径就消失了。
pip install --only-binary=:all: -r requirements.txt
Rust 的 build.rs 同样会执行任意代码。从这个角度看,Go 模块相对安全,因为它在安装时点上没有会被执行的钩子。
按生态整理一下就是这样。
| 生态 | 可复现的安装命令 | 安装时的代码执行 | 哈希固定的方式 | 内部索引优先级配置 |
|---|---|---|---|---|
| npm | npm ci | preinstall、postinstall | 锁文件的 integrity | .npmrc 作用域固定 |
| pnpm | pnpm install --frozen-lockfile | 仅限白名单 | 锁文件的 integrity | .npmrc 作用域固定 |
| pip | pip install --require-hashes -r | setup.py,仅源码分发包 | requirements 哈希注释 | index-url 单一化,禁用 extra |
| uv, poetry | uv sync --locked | 构建后端的执行 | uv.lock、poetry.lock | 显式声明索引优先级 |
| cargo | cargo build --locked | build.rs | Cargo.lock 校验和 | source replacement 配置 |
| go modules | go build -mod=readonly | 没有 | go.sum、校验和数据库 | 指定 GOPRIVATE、GOPROXY |
SBOM 是为事故当天那 5 分钟准备的
SBOM 经常被当作合规文档来介绍,结果就变成了一份生成之后再没人打开的文件。它真正的价值要具体得多。在某个被广泛使用的包爆出投毒的那一天,"我们用了这个吗"这个问题,你能不能在几分钟内答上来。
如果这个答案要靠在群里问、靠人去翻仓库,半天就过去了。服务有 40 个的话,一天都不够。
在构建流水线里生成 SBOM,并集中收在一个地方。
syft packages dir:. -o cyclonedx-json > "sboms/${SERVICE_NAME}.cdx.json"
syft packages registry.acme.com/payments:1.4.2 -o cyclonedx-json > sboms/payments-image.cdx.json
而在事故当天,这一段就够了。
PKG=event-stream
for f in sboms/*.cdx.json; do
jq -r --arg p "$PKG" \
'.components[]? | select(.name==$p) | "\(input_filename) \(.name)@\(.version)"' "$f"
done
sboms/checkout-api.cdx.json event-stream@3.3.6
sboms/notification-worker.cdx.json event-stream@4.0.1
40 个服务里命中了两个,连版本都出来了。从这一刻起,它就不再是安全问题,而变成了部署问题。
对保存下来的 SBOM 重新跑一遍漏洞判定也很快。
grype sbom:sboms/checkout-api.cdx.json --fail-on high
NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY
event-stream 3.3.6 4.0.1 npm GHSA-mh6f-8j2x Critical
semver 5.7.1 7.5.2 npm GHSA-c2qf-rxjj High
有一点要注意:SBOM 的准确度取决于它是在哪个时点生成的。从源码目录生成的 SBOM 和从最终镜像生成的 SBOM 是不一样的。镜像里含有基础镜像带来的 OS 包,源码里没有。如果打算把它用于事故响应,就必须从真正部署出去的产物上生成。
告警的信噪比 — 可达性,以及签名
第一次打开依赖扫描器,通常会看到这样的数字。
npm audit --json | jq '.metadata.vulnerabilities'
{
"info": 0,
"low": 41,
"moderate": 88,
"high": 23,
"critical": 4,
"total": 156
}
156 条。以这个状态丢给团队,什么都不会发生。要做的第一件事不是把数字压小,而是只把有意义的留下来。
第一道过滤是它会不会被部署出去。
npm audit --omit=dev --json | jq '.metadata.vulnerabilities'
{
"info": 0,
"low": 3,
"moderate": 7,
"high": 2,
"critical": 0,
"total": 12
}
156 条里有 144 条是开发依赖。这并不意味着开发依赖的漏洞没有意义。构建环境握着密钥,反而可能更危险。只是应对方式和紧急程度不同。把生产运行时的远程代码执行和测试运行器的正则表达式拒绝服务放进同一个队列,结果是两个都处理不掉。
第二道过滤是可达性。已经装上了,和有漏洞的代码位于执行路径上,是两回事。Go 的工具把这个概念展示得最清楚。
govulncheck ./...
Vulnerability #1: GO-2024-2687
HTTP/2 CONTINUATION 帧处理中的资源耗尽
Found in: golang.org/x/net/http2@v0.21.0
Fixed in: golang.org/x/net/http2@v0.23.0
Example traces found:
#1: internal/gateway/server.go:88:21: gateway.Serve calls http2.Server.ServeConn
=== Informational ===
Vulnerability #2: GO-2024-2611
Found in: golang.org/x/net/html@v0.21.0
Fixed in: golang.org/x/net/html@v0.23.0
No traces found.
Your code is affected by 1 vulnerability from 1 module.
两条都装上了,真正被调用的只有一条。响应顺序自动就定下来了。其他生态里类似的分析也在增多,而把判定结果作为文档共享出来的格式就是 VEX。
这里要点出一个反方向的常见错误:为了消除告警而把所有东西一股脑升到最新。大规模批量升级会把回归风险一次性全引进来,出问题时又很难定位原因。更何况,当被投毒的版本恰恰就是最新版时,升级就等于感染。实际上在多起事故里,受害者正是那些认真应用自动更新的团队。
现实可行的策略是这样的。生产运行时可达的条目立刻处理,其余的走定期批次。给新版本设一个宽限窗口,只接收发布后已经过了一段时间的版本。仅这一项配置,就能让你躲开绝大多数在发布之后才被发现的投毒事故。
{
"packageRules": [
{
"matchDepTypes": ["dependencies"],
"minimumReleaseAge": "5 days"
}
]
}
最后一层是签名与来源证明。这一步确认的是:这个包是不是真的由它声称的源码构建出来的。
npm audit signatures
audited 1123 packages in 3s
1119 packages have verified registry signatures
4 packages have missing registry signatures
如果你是发布方,可以给产物附上来源证明。
npm publish --provenance --access public
容器镜像和发布产物则是签名与验证配套使用。
cosign sign --yes registry.acme.com/payments:1.4.2
cosign verify registry.acme.com/payments:1.4.2 \
--certificate-identity-regexp 'https://github.com/acme/payments/.github/workflows/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
签名证明的不是"这些字节是安全的",而是"这些字节出自这条流水线"。它本身并不能拦住恶意代码,但它让事故发生时能追溯到什么东西从哪里进来的,也堵死了通过入侵镜像仓库把产物掉包的路径。
收尾 — 三项管控就能收拾掉大部分
依赖供应链是一个无法彻底封死的领域。因为必须信任的代码以千为单位,而其中绝大部分你读不完。所以目标不是阻断,而是缩小暴露面和加快响应速度。
性价比最高的管控有三项。
在 CI 里只用可复现的安装命令。npm ci、--frozen-lockfile、--locked、--require-hashes。这一条缺席,其余所有管控都会失去意义。
默认阻断安装脚本,只对需要的包放行。名单通常在十个上下,仅这一招就几乎抹掉了安装时点的任意代码执行路径。
从部署产物生成 SBOM 并集中收好。平时用不上,事故当天却能把半天压缩成 5 分钟。
最后只更正一个关于锁文件的误解就收尾。锁文件里的整合性哈希,意思不是"这个包是安全的",而是"这个包和我上次看到的是同一个"。这两句话之间的差别,几乎就是供应链安全的全部。