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%)

逐条读这份输出:

  1. 信任半径 50.1:每声明 1 个依赖,平均带进来 50 个包。这 50 个包的安装脚本、构建期代码、运行时逻辑,全都在你的权限下执行。“我只用了 10 个依赖”这句话,在任何供应链评审里都不成立。
  2. 41.9% 的包在第 5 层之后(247 个,最深 9 层)。这些包你没有直接选过、没见过 README、也不在评审范围里——但它们照样能读环境变量、发网络请求。深度本身就是风险:层级越深,越没人审。
  3. 177 个包存在多版本共存:semver 同时有 4 个版本。这意味着同一个 CVE 可能在多个副本里各修一次,而依赖扫描器按“包名 + 版本”报,很容易只修了叶子上那一个,另一个副本还在。
  4. 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 要修多处,且旧副本常被遗忘
签名只签“最新版”,不签历史版本 回滚到旧版本时验签失败,团队选择“先跳过验签”

关联

自检

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 级)通常要等团队过百人或进入强监管行业——在此之前,先把“锁定 + 清单”做扎实比追等级更划算。