38.10 供应链安全
一句话
供应链安全的本质是:你信任的不是自己的代码,而是几百个陌生人的提交;只能靠三件事守住——锁定(谁进来)、证明(产物是不是这份源码构建的)、可追溯(出事能定位到哪条依赖)。
四个误区,先拆掉
| 误区 | 现实 |
|---|---|
| “我只用了 10 个依赖” | 本仓库实测:package.json 声明 10 个,yarn.lock 里 1469 条,实际可达 501 个包名——信任半径是声明数的 50 倍 |
| “用官方源就安全” | 官方源只保证“没被篡改地分发”,不保证包本身是善意的。投毒、抢注、维护者账号被接管,都发生在官方源上 |
| “lock 文件可以不定” | 不锁 = 每次构建可能装到不同代码。锁文件是可复现构建的最低要求,不是可选项 |
| “签名是形式主义” | 签名解决的是另一件事:你拿到的产物是不是从这份源码、在这条流水线上构建的。没有它,源码审计再严也挡不住“构建环节被替换” |
四类攻击,四种长相
| 攻击 | 手法 | 公开案例 | 检测手段 |
|---|---|---|---|
| 依赖投毒 | 新包内置恶意安装脚本,或往热门包塞后门 | event-stream 被转手后引入恶意依赖 flatmap-stream(2018) | 安装脚本审计、--ignore-scripts、行为监控 |
| 抢注 / 拼写混淆 | 用近似名(crossenv vs cross-env)骗开发者打错字 | 2017 年 npm 上批量出现的仿冒包 | 依赖白名单、名称相似度检查 |
| 维护者账号被盗 / 主动作恶 | 拿合法发布权限发一个带后门的版本 | xz-utils 5.6.x 后门(CVE-2024-3094,2024) | 版本钉死 + 发布者异常告警 |
| 构建与仓库被篡改 | 不在源码里动手,改构建机或制品仓库 | 各类“源码干净、产物带马”的事件 | SBOM + 签名 + 可复现构建 |
⚠️ 四类里有三类的共同点是:恶意代码不在你的仓库里。所以“代码审计 + SAST”对它们全部无效——只能靠依赖清单与产物证明。
先量一下你的信任半径
这个仓库是文档站,只声明了 10 个依赖,但 lock 里躺着 1469 条。用下面的程序把“声明 → 解析 → 可达”三层拆开:
package main
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
)
// 供应链的"信任半径":你在 package.json 里只声明了 10 个包,
// 但构建时会解析出全部传递依赖——它们每一个都能在你的 CI 里执行安装脚本。
type entry struct {
keys []string
version string
deps map[string]string // 包名 → 版本范围
}
func main() {
lockPath, pkgPath := os.Args[1], os.Args[2]
raw, err := os.ReadFile(lockPath)
if err != nil {
panic(err)
}
// 一、解析 yarn.lock(v1 格式:块之间空行,块头是 "name@range, name@range:")
var entries []*entry
var cur *entry
inDeps := false
for _, line := range strings.Split(string(raw), "\n") {
if line == "" {
cur = nil
continue
}
if !strings.HasPrefix(line, " ") {
cur = &entry{deps: map[string]string{}}
header := strings.TrimSuffix(line, ":")
for _, k := range strings.Split(header, ", ") {
cur.keys = append(cur.keys, strings.Trim(k, `"`))
}
entries = append(entries, cur)
inDeps = false
continue
}
trimmed := strings.TrimSpace(line)
if trimmed == "dependencies:" || trimmed == "optionalDependencies:" {
inDeps = true
continue
}
if inDeps && strings.HasPrefix(line, " ") {
parts := strings.SplitN(strings.Trim(trimmed, `"`), " ", 2)
if len(parts) == 2 {
cur.deps[parts[0]] = strings.TrimSpace(strings.Trim(parts[1], `"`))
}
continue
}
if strings.HasPrefix(trimmed, "version ") {
cur.version = strings.Trim(strings.TrimPrefix(trimmed, "version "), `"`)
}
inDeps = false
}
byKey := map[string]*entry{}
names := map[string][]string{} // 包名 → 出现的版本
for _, e := range entries {
for _, k := range e.keys {
byKey[k] = e
}
name := e.keys[0]
if i := strings.LastIndex(name, "@"); i > 0 {
name = name[:i]
}
names[name] = append(names[name], e.version)
}
// 二、从 package.json 的直接依赖出发做 BFS
var pkg struct {
Dependencies map[string]string `json:"dependencies"`
DevDependencies map[string]string `json:"devDependencies"`
}
rawPkg, err := os.ReadFile(pkgPath)
if err != nil {
panic(err)
}
_ = json.Unmarshal(rawPkg, &pkg)
direct := map[string]string{}
for k, v := range pkg.Dependencies {
direct[k] = v
}
for k, v := range pkg.DevDependencies {
direct[k] = v
}
depth := map[*entry]int{}
var queue []*entry
for name, rng := range direct {
if e, ok := byKey[name+"@"+rng]; ok {
if _, seen := depth[e]; !seen {
depth[e] = 1
queue = append(queue, e)
}
} else {
fmt.Printf(" ⚠ 直接依赖 %s@%s 在 lock 里找不到精确条目\n", name, rng)
}
}
for len(queue) > 0 {
e := queue[0]
queue = queue[1:]
for name, rng := range e.deps {
ne, ok := byKey[name+"@"+rng]
if !ok {
continue
}
if _, seen := depth[ne]; !seen {
depth[ne] = depth[e] + 1
queue = append(queue, ne)
}
}
}
dist := map[int]int{}
reachNames := map[string]bool{}
maxDepth := 0
for e, d := range depth {
dist[d]++
maxDepth = max(maxDepth, d)
for _, k := range e.keys {
if i := strings.LastIndex(k, "@"); i > 0 {
reachNames[k[:i]] = true
}
}
}
fmt.Println("【一】规模:声明的 vs 实际会被拉进来的")
fmt.Printf("package.json 直接声明 %d 个(含 devDependencies)\n", len(direct))
fmt.Printf("lock 里的条目 %d 个(去重后的 name@range)\n", len(entries))
fmt.Printf("可达的唯一包名 %d 个 → 传递依赖 %d 个\n", len(reachNames), len(reachNames)-len(direct))
fmt.Printf("信任半径 %.1f 个包 / 每个直接依赖\n", float64(len(reachNames))/float64(len(direct)))
fmt.Println("\n【二】深度分布:你在哪一层之后就没看过它的代码")
fmt.Printf("%-8s %8s\n", "层数", "包数")
for d := 1; d <= maxDepth; d++ {
fmt.Printf("%-8d %8d\n", d, dist[d])
}
deep := 0
for d, n := range dist {
if d >= 5 {
deep += n
}
}
fmt.Printf("≥5 层的包 %d 个(占可达 %.1f%%)\n", deep, float64(deep)/float64(len(depth))*100)
fmt.Println("\n【三】同一包的多个版本共存(升级/清理时最容易漏的地方)")
type nv struct {
name string
vers []string
}
var multi []nv
for name, vers := range names {
if len(vers) > 1 {
multi = append(multi, nv{name, vers})
}
}
sort.Slice(multi, func(i, j int) bool {
if len(multi[i].vers) != len(multi[j].vers) {
return len(multi[i].vers) > len(multi[j].vers)
}
return multi[i].name < multi[j].name // 同数量时按名字排,输出才可复现
})
fmt.Printf("多版本共存的包 %d 个 / 唯一包名 %d 个\n", len(multi), len(names))
show := 5
for i, m := range multi {
if i >= show {
break
}
fmt.Printf(" %-24s %d 个版本:%s\n", m.name, len(m.vers), strings.Join(m.vers, " / "))
}
fmt.Println("\n【四】lock 里存在、但依赖图到不了的条目(死条目 / optional 未装)")
fmt.Printf("不可达条目 %d 个(占 lock %.1f%%)\n", len(entries)-len(depth), float64(len(entries)-len(depth))/float64(len(entries))*100)
}
在本仓库根目录实跑(GO111MODULE=off go run main.go yarn.lock package.json,Go 1.27.1 darwin/arm64):
【一】规模:声明的 vs 实际会被拉进来的
package.json 直接声明 10 个(含 devDependencies)
lock 里的条目 1469 个(去重后的 name@range)
可达的唯一包名 501 个 → 传递依赖 491 个
信任半径 50.1 个包 / 每个直接依赖
【二】深度分布:你在哪一层之后就没看过它的代码
层数 包数
1 10
2 20
3 156
4 157
5 107
6 76
7 47
8 12
9 5
≥5 层的包 247 个(占可达 41.9%)
【三】同一包的多个版本共存(升级/清理时最容易漏的地方)
多版本共存的包 177 个 / 唯一包名 1242 个
ansi-regex 4 个版本:2.1.1 / 3.0.1 / 4.1.1 / 5.0.1
camelcase 4 个版本:2.1.1 / 4.1.0 / 5.3.1 / 6.3.0
debug 4 个版本:2.6.9 / 3.1.0 / 3.2.7 / 4.3.4
kind-of 4 个版本:3.2.2 / 4.0.0 / 5.1.0 / 6.0.3
semver 4 个版本:5.7.1 / 7.0.0 / 6.3.0 / 7.3.7
【四】lock 里存在、但依赖图到不了的条目(死条目 / optional 未装)
不可达条目 879 个(占 lock 59.8%)
逐条读这份输出:
- 信任半径 50.1:每声明 1 个依赖,平均带进来 50 个包。这 50 个包的安装脚本、构建期代码、运行时逻辑,全都在你的权限下执行。“我只用了 10 个依赖”这句话,在任何供应链评审里都不成立。
- 41.9% 的包在第 5 层之后(247 个,最深 9 层)。这些包你没有直接选过、没见过 README、也不在评审范围里——但它们照样能读环境变量、发网络请求。深度本身就是风险:层级越深,越没人审。
- 177 个包存在多版本共存:
semver同时有 4 个版本。这意味着同一个 CVE 可能在多个副本里各修一次,而依赖扫描器按“包名 + 版本”报,很容易只修了叶子上那一个,另一个副本还在。 - 59.8% 的 lock 条目到不了(879 个)。它们可能是 optional 依赖、跨平台条件依赖、或历史残留。lock ≠ 实际安装图——做 SBOM 时要用“实际解析结果”,不能直接拿 lock 当清单,否则既高估了风险面,又漏掉了运行时真正加载的那份。
三道防线:锁定 → 证明 → 可追溯
| 防线 | 解决什么 | 具体做法 | 判据 |
|---|---|---|---|
| 锁定 | 谁被装进来 | 提交 lock 文件、CI 用 npm ci / --frozen-lockfile、私有代理镜像 + 白名单、--ignore-scripts、新依赖需评审 | 同一 commit 两次构建产出的依赖树哈希一致 |
| 证明 | 产物是不是从这份源码构建的 | 构建时生成 SBOM(SPDX / CycloneDX)、产物签名(Sigstore / cosign)、构建平台出证(SLSA 等级) | 部署前 cosign verify 能通过,且 SBOM 与部署镜像 digest 对得上 |
| 可追溯 | 出事能定位到哪条依赖 | 依赖清单入库(每个版本一份 SBOM)、漏洞告警订阅、可替换计划(关键依赖有替代品) | 拿到一个 CVE,10 分钟内能回答“我们哪些服务用了它、在哪个版本” |
🔑 三件事里,“可追溯”最容易做却最少人做。多数团队有 lock 文件、有个扫描器,但没有“某个 CVE 出来时,10 分钟内列出受影响服务与版本”的能力——而这恰恰是 0day 当天唯一能救命的东西。
落地:指名工具与可抄命令
| 环节 | 指名 | 可抄命令 |
|---|---|---|
| 生成 SBOM | Syft(Anchore) | syft dir:. -o spdx-json=sbom.spdx.json |
| 镜像/目录漏洞扫描 | Trivy(Aqua) | trivy fs --scanners vuln --severity HIGH,CRITICAL . |
| 产物签名与验签 | Cosign(Sigstore) | cosign sign --key cosign.key $IMAGE;部署前 cosign verify --key cosign.pub $IMAGE |
| 依赖锁定 | npm / yarn / pnpm 原生 | npm ci --ignore-scripts(CI 用 ci 而不是 install) |
⚠️ 未验证:本机未安装 syft / trivy / cosign,上述命令没有在本机执行,参数以所用版本官方文档为准。能验证的是它们各自解决的问题:SBOM(有什么)、扫描(有没有已知漏洞)、签名(是不是这份产物)——三者不能互相替代,常见错误是只做扫描就当供应链安全做完了。
可退:签名与 SBOM 上线时先用 warn 模式(验签失败只告警不阻断),跑满一个发布周期确认覆盖率(通常先暴露“有 20% 的镜像不是这条流水线构建的”),再切阻断。
什么时候不用这么重
- 纯静态文档站 / 无构建期执行的产物:锁定 + 定期扫描足够,签名可以后置(没有运行时被注入的面)。
- 内部工具、不接触用户数据:白名单 +
--ignore-scripts足够,SBOM 粒度可以到“镜像级”而不必到“包级”。 - 强监管行业:反过来,SBOM 与签名通常是合规硬要求,不是可选项。
反模式
| 反模式 | 后果 |
|---|---|
| 不提交 lock 文件 | 同一 commit 在不同机器上装出不同代码,故障无法复现 |
| 允许安装脚本任意执行 | 一个被投毒的包在 npm install 阶段就拿到 shell,扫描器还没机会跑 |
| 只做扫描不做 SBOM | 0day 出来时答不出“我们受影响吗”,只能等厂商更新规则 |
| 拿 lock 当 SBOM 直接用 | 59.8% 的条目不可达(本例),既高估风险面又漏掉运行时真实依赖 |
| 依赖树里存在多版本却不治理 | 同一 CVE 要修多处,且旧副本常被遗忘 |
| 签名只签“最新版”,不签历史版本 | 回滚到旧版本时验签失败,团队选择“先跳过验签” |
关联
- 前置:38.9 安全开发 (SDL / DevSecOps / SAST / DAST)(SCA 在流水线里的位置与误报处理)
- 并列:6.6 镜像安全与扫描(镜像层的漏洞与基线)、54.1 不可变制品(制品不可变是签名能成立的前提)
- 下游:38.11 安全应急响应(依赖被投毒时的处置流程)、52.3 依赖与漏洞扫描
自检
Q1 一个团队用 npm install(不是 npm ci)、不提交 lock 文件、也没有 SBOM。0day 出来后他们最可能卡在哪一步?补救顺序是什么?
▸ 答案:卡在**“我们到底装的是哪个版本”**——没有 lock 与 SBOM,连“受影响版本是否在用”都答不出,只能全量升级或全量停机检查。补救顺序:① 立刻用 npm ci + 提交 lock,把当前生产版本固化(先止血,别再漂移);② 生成一次 SBOM 并入库,建立“服务 → 依赖版本”的映射;③ 接入漏洞告警,按“高危 + 公网可达”排序处理;④ 最后再补签名与 SLSA——顺序不能反,没有清单的签名只是给未知内容盖章。
Q2 本例中 59.8% 的 lock 条目不可达。那做 SBOM 时直接用 lock 文件会有什么后果?
▸ 答案:两个方向都错:① 高估——把 optional、跨平台、历史残留的包也算进来,风险面虚高,团队疲于修根本不存在的依赖;② 漏报——运行时真正加载的是那份“实际解析图”,若它包含 lock 里没有的条目(例如通过 require 动态加载、或 postinstall 拉取的二进制),SBOM 里就缺失。正确做法是用解析后的实际依赖图(npm ls --all --json、syft 扫目录)生成 SBOM,lock 只作为复现构建的输入。
Q3 为什么“锁定 + 扫描 + 签名”三件都要,缺一件会怎样?各给一个具体失效场景。
▸ 答案:缺锁定 → 同一 commit 装出不同代码,故障无法复现(场景:^1.2.3 范围依赖在两次构建间各解析到不同次版本);缺扫描 → 已知 CVE 长期在线上(场景:log4j 类漏洞,锁定了但版本旧);缺签名 → 源码干净、产物被换(场景:构建机被入侵后往镜像里塞后门,扫描器扫的是源码与已知 CVE,扫不出新后门)。三者是不同层的证据,不是同一个检查的三个强度。
Q4 一个 20 人团队、每天发版 3 次,供应链安全做到什么程度算“够了”?
▸ 答案:按“投入产出 + 可执行”给三档,这一档足够:① 锁定(lock + npm ci --ignore-scripts + 新依赖需一人 review)——1 天工作量,消掉“版本漂移”与“安装脚本投毒”两类;② 清单与告警(每镜像一份 SBOM 入库 + 高危 CVE 告警到值班群,SLA 7 天)——2 天,解决 0day 时“答不出”的问题;③ 签名(cosign 签镜像,部署时 warn 模式验签)——3 天,解决产物被替换。总投入约 6 人日、之后每周不到 1 小时维护。超过这个投入(例如自建完整 SLSA 3 级)通常要等团队过百人或进入强监管行业——在此之前,先把“锁定 + 清单”做扎实比追等级更划算。