引言
供应链安全要回答的问题和十年前已经完全不同。过去我们担心的是「依赖里有没有已知漏洞」,现在担心的是「我构建出来的那个二进制,到底是不是我以为的那份源码编出来的」。SolarWinds 事件里攻击者没有改任何上游仓库,而是污染了构建系统,让被正常签名的官方更新包带上后门;xz-utils 事件里攻击者用两年时间经营贡献者身份,最后试图在构建脚本里植入后门;event-stream、colors.js 这类 npm 事故则说明,一个下游数量以百万计的包,发布权限可能只握在一个人手里。
工程上的难点有三层。第一层是可见性:一个容器镜像里到底有哪些组件、哪个版本、从哪来、由谁构建,绝大多数团队答不上来,因为依赖是传递引入的,基础镜像、vendored 代码、构建期工具都可能带着组件进来。第二层是完整性:即使你能列出组件,也无法证明「这份制品确实由这份源码、在这条流水线上、由可信的人构建」——这正是 SLSA 与签名要解决的问题。第三层是可消费性:SBOM 生成容易,但生成一份几百兆、没人看的 JSON 毫无价值,必须让机器能拿它做漏洞匹配、许可证判定与策略门禁。
还有一条容易被忽略:供应链风险的成本曲线是非线性的。早期几个依赖靠人工核对可行,一旦依赖数破千、基础镜像每月更新,人工核对必然崩掉。此时再补自动化,历史制品的清单早已丢失,无法回溯「三个月前发布的那个版本里到底有没有被投毒的包」。所以供应链治理要尽早做成流水线的固定环节。
本文按「威胁模型 → SBOM 格式 → 生成与消费 → SLSA 溯源 → 签名验证 → 投毒防范 → 与漏洞响应衔接 → 私有仓库 → 落地路线」展开。需要划清一条边界:SBOM 用于许可证合规的那一面(义务清单、NOTICE、审计存证)另有专文,见开源许可证合规实战 ,本文重心在安全与完整性;漏洞的协调披露与 CVE 流程见开源漏洞响应与 CVE 流程 ,本文只讲「怎么把扫描结果接进流水线」。读者对象是负责 CI/CD 与制品安全的工程师。
目录
- 供应链威胁模型
- SBOM 格式:SPDX 与 CycloneDX 的安全视角
- SBOM 的生成与消费
- SLSA 等级与构建溯源
- 制品签名与 Sigstore 验证
- 依赖混淆与投毒防范
- 与漏洞响应的衔接:VEX 与告警收敛
- 私有仓库、镜像代理与 vendor 目录
- 分阶段落地路线与成熟度模型
1. 供应链威胁模型
先定义攻击面,否则防御会变成「把能买的工具都装上」。供应链攻击按「在链条的哪一环动手」分成四类,每类的检测手段不同。
四类供应链攻击:
1. 源码投毒 直接往上游仓库/分支塞恶意代码(含社工骗取提交权限)
2. 构建投毒 污染构建系统或构建脚本,源码干净但产物带后门(SolarWinds)
3. 分发投毒 劫持发布账号、抢注同名包、篡改镜像仓库中的制品
4. 依赖混淆 利用内网包名在公共仓库注册同名包,诱导构建拉取外部包
四类里构建投毒最难防,因为它的产物和正常产物在源码层面完全一致,只能靠「构建过程可验证」来识别——这也是 SLSA 存在的理由。分发投毒最容易被忽视,因为大多数团队信任镜像仓库的 tag,而 tag 是可以被覆盖的(latest 指向变了没人知道)。依赖混淆在引入私有包管理器的团队里高发,往往一次配置疏漏就中招。
| 攻击类型 | 典型手法 | 主要防线 | 检测难度 |
|---|---|---|---|
| 源码投毒 | 社工贡献者、抢注 abandoned 包 | 双人评审、CODEOWNERS、分支保护 | 中 |
| 构建投毒 | 篡改构建脚本、注入 CI 变量 | SLSA 溯源、隔离构建、签名 | 高 |
| 分发投毒 | 劫持发布凭证、覆盖 tag | 不可变 tag、签名验证、摘要锁定 | 中 |
| 依赖混淆 | 公共仓库注册内网包名 | scope 前缀、registry 优先级、代理白名单 | 低 |
威胁模型的另一个维度是爆炸半径:一个只有 200 行代码、却拖进 40 个传递依赖的包,供应链暴露面反而比一个大而全的框架更大。评估时要看传递依赖深度,而不只看直接依赖。相关方法(criticality score、下游广度)在开源项目健康度度量里有系统讨论,这里只把它当作选型输入。
从真实事件里提炼的三条教训
复盘近十年的公开供应链事件,能提炼出三条反复出现的教训,它们直接决定了防御投入的优先级。
教训一:攻击者偏好「链条最薄的一环」
被攻击的往往不是最流行的包,而是它依赖的、只有一两个维护者的小包
→ 防御要覆盖传递依赖,而不是只盯直接依赖
教训二:源码干净不等于产物干净
SolarWinds 的源码仓库没有任何异常,后门是在构建阶段注入的
→ 只做源码扫描(SAST、code review)无法覆盖这类攻击
教训三:签名普及度不足时,「官方渠道」本身就是攻击面
官网下载页、CDN、镜像仓库的 tag 都可能被替换
→ 必须做「内容摘要 + 签名 + 身份」三重绑定
这三条对应本文的三条主线:SBOM 解决可见性(覆盖传递依赖),SLSA 溯源解决构建完整性,Sigstore 签名解决分发完整性。理解了这个映射,后面的工具选型就不再是「装哪个」,而是「补哪一层」。
一个常见的认知误区
很多人把「SCA 工具报出零高危」当作供应链安全的达标线,这是错的。SCA 只回答「已知漏洞」,对「未知后门」「构建投毒」「账号接管」完全无感。真正的达标线应当是三条同时成立:能列出全部组件(可见性)、能验证制品来源(完整性)、能在上游被污染后快速定位受影响版本(可追溯性)。把这三条写进团队的安全目标,比订阅更多漏洞库更有效。
2. SBOM 格式:SPDX 与 CycloneDX 的安全视角
SBOM 是供应链治理的单一事实源。格式上主流是 SPDX 与 CycloneDX 两种,许可证场景更偏 SPDX,安全场景更偏 CycloneDX,但两者都能承载安全消费所需的核心字段。选型时真正要盯的不是「哪个更流行」,而是四个字段是否完整。
安全消费必需的四个字段:
purl 包唯一标识,形如 pkg:npm/lodash@4.17.21,漏洞库靠它匹配
依赖关系 dependsOn / dependencies,决定传递依赖能否展开
构建信息 supplier、author、tools、timestamp,用于溯源
哈希 SHA-256/SHA-512,用于制品与组件的一致性校验
purl(Package URL)是最容易被忽略却最关键的一个。没有 purl 的 SBOM 只能靠包名做模糊匹配,遇到 lodash 与 lodash-es 这类同源不同包、或者私有 registry 与公共 registry 同名的情况,漏洞匹配会大面积误报或漏报。生成 SBOM 后第一件事就是抽查 purl 覆盖率,低于 95% 就要调工具。
| 字段 | SPDX 表达 | CycloneDX 表达 | 安全消费影响 |
|---|---|---|---|
| 唯一标识 | externalRefs 中的 purl | components[].purl | 决定漏洞匹配准确率 |
| 依赖图 | relationships 数组 | dependencies 数组 | 决定能否展开传递依赖 |
| 许可证 | licenseConcluded/Declared | licenses[] | 合规侧主档 |
| 组件哈希 | PackageChecksum | hashes[] | 制品一致性校验 |
| 漏洞信息 | 不承载 | vulnerabilities[](1.4+) | VEX 与告警收敛 |
CycloneDX 1.4 起原生支持 vulnerabilities 与 VEX(Vulnerability Exploitability eXchange),这让它能同时充当「清单」和「这个漏洞对我是否可利用」的判定载体。SPDX 3.0 也在补这块,但生态工具跟进较慢。实践上建议:合规以 SPDX 为主档,安全以 CycloneDX 为主档,两者从同一份扫描结果转换生成,成本只有一条命令,没必要二选一。
还要理解 SBOM 的粒度问题。一份「文件级 SBOM」会列出每个文件的哈希,体积巨大但能精确到 vendored 代码;一份「包级 SBOM」只列包管理器认识的组件,体积小但漏掉复制进来的第三方代码。安全场景推荐至少做一次文件级扫描补盲区,日常 CI 用包级扫描保持速度。
3. SBOM 的生成与消费
生成侧的工具矩阵已经很成熟,选型的关键是「从哪生成」。三种来源的可靠性递增:
# 1) 从源码目录生成(最快,但看不到基础镜像里的组件)
syft dir:. -o cyclonedx-json=sbom.src.json --source-name app --source-version "$GIT_SHA"
# 2) 从容器镜像生成(推荐,含基础镜像全部层)
syft registry.example.com/app:1.2.3 -o cyclonedx-json=sbom.img.json \
--scope all-layers --source-name app --source-version 1.2.3
# 3) 从构建产物/文件系统生成(覆盖 vendored 与生成代码)
syft /path/to/rootfs -o cyclonedx-json=sbom.fs.json
--scope all-layers 不能省:默认只扫镜像最上层,会把基础镜像里打进底层的组件漏掉,而基础镜像恰恰是「带了漏洞的旧库」高发区。多语言项目可以用 cdxgen 统一入口:
cdxgen -t js -t java -t go -o sbom.cdx.json . # 多类型聚合
npm sbom --sbom-format cyclonedx > sbom.cdx.json # Node 内置(npm 10+)
生成只是第一步,消费才是价值所在。消费有三种典型场景:
# 场景 A:漏洞匹配(把 SBOM 喂给 SCA 或漏洞库)
grype sbom:sbom.img.json -o json > vulns.json
# 场景 B:与基线 diff,只对新增/变更组件做策略评估
cyclonedx-diff baseline.cdx.json sbom.cdx.json -o diff.json
# 场景 C:策略门禁(非零退出即阻断)
conftest test --policy policy/ sbom.cdx.json
diff 是最被低估的能力。对一个有 3000 个组件的制品,全量评估每次都跑一遍既慢又吵;而两次构建之间通常只有十几个组件变化,只对增量做策略判断,既快又能精确定位「是哪个 PR 引入了这个高风险组件」。这也是把门禁接进 GitHub Actions 工作流时的正确姿势:基线冻结在主干,PR 只比对增量。
消费侧还要建立覆盖度自检:SBOM 里的组件数应与包管理器锁文件(package-lock.json / go.sum / 解析后的 pom.xml)的条目数在同一量级。差一个数量级说明扫描方式有漏——这条自检比任何工具报告都可靠,且不依赖任何外部服务。
把 SBOM 当成数据来用
一次性生成、人工打开看一眼的 SBOM 没有价值。要让 SBOM 产生持续收益,必须把它当作可查询的数据存起来。Dependency-Track 是这类平台的代表:接收 SBOM 上传后,持续与漏洞库比对,新增漏洞自动告警,并支持「按项目、按版本、按组件」三种检索维度。
# 上传 SBOM 到 Dependency-Track,之后由平台负责持续监控
curl -X POST "$DT/api/v1/bom" \
-H "X-Api-Key: $DT_KEY" -H "Content-Type: multipart/form-data" \
-F "project=$PROJECT_UUID" -F "bom=@sbom.cdx.json"
平台化的价值在于把「扫描」从流水线里解耦出来:流水线只负责产出 SBOM 并上传,漏洞匹配、告警收敛、影响面分析都在平台上持续进行。这样当某个 CVE 在半年后被披露时,平台能立刻告诉你「哪些项目的哪些版本受影响」,而不必重新构建历史版本。自建平台要维护数据库与漏洞同步,成本不低;如果团队规模小,用现成的 SaaS 或直接依赖 CI 定时任务也够用。
另一个常见做法是把 SBOM 作为制品的一部分随镜像一起分发,让下游用户能拿到清单做自己的合规判断。这在大企业采购里越来越常见:客户会要求提供 SBOM 才允许入库。分发时要注意 SBOM 里可能包含内部仓库地址、构建路径等信息,需要做一次脱敏。
4. SLSA 等级与构建溯源
SLSA(Supply-chain Levels for Software Artifacts)是回答「这份制品能不能被信任」的框架,当前稳定版本是 v1.0,把要求拆成「构建级别」和「溯源级别」两条轨道。核心思路是用可验证的构建溯源替代对构建者的信任。
SLSA Build 级别(v1.0):
L0 无要求
L1 构建过程有溯源(provenance),但是否可信不保证
L2 托管构建平台 + 签名的溯源,构建隔离
L3 加固的构建平台:构建间隔离、凭证不可窃取、溯源不可伪造
SLSA Source 级别(v1.0 引入):L1~L4,关注源码与版本控制的可追溯性
L1 到 L2 的跨越是「有没有用托管构建 + 溯源是否签名」;L2 到 L3 的跨越是「构建平台本身是否加固到攻击者即使拿到 CI 凭证也无法伪造溯源」。绝大多数团队的目标应当是关键制品达到 L3,其余 L2,而不是全量追求 L3——L3 要求自建或使用加固的构建服务,成本不低。
溯源(provenance)是一份符合 in-toto 规范的 JSON,描述「谁、用什么、从哪份源码、执行了什么命令、产出了什么」。GitHub Actions 的官方 action 可以产出 SLSA L3 溯源:
# .github/workflows/release.yml 片段
- uses: actions/attest-build-provenance@v1
with:
subject-path: dist/app_1.2.3_linux_amd64.tar.gz
验证侧用 slsa-verifier 或 gh attestation verify:
slsa-verifier verify-artifact dist/app_1.2.3_linux_amd64.tar.gz \
--provenance-path provenance.intoto.jsonl \
--source-uri github.com/org/app --source-tag v1.2.3
验证命令的关键是把期望值写死:--source-uri 与 --source-tag 必须精确匹配,否则攻击者可以用同一构建平台产出一份「溯源合法但源码不是你的」的制品。策略上应当把「允许的 source-uri 列表」固化进准入控制器(如 Kubernetes 的 sigstore policy-controller),让部署时自动拒绝未验证的镜像。
SLSA 与 SBOM 是互补的:SBOM 回答「制品里有什么」,溯源回答「制品是怎么来的」。两者都要有,才能形成完整证据链。只签 SBOM 不签制品,攻击者可以替换制品而保留 SBOM;只签制品不签 SBOM,你无法知道被签的那份制品里有什么。
验证溯源时要盯住哪些字段
溯源文件不是「有就行」,验证时必须逐项核对关键字段,否则一份来自攻击者构建平台的合法溯源同样能骗过校验。
溯源验证的关键字段:
subject[].name 制品名与哈希,必须与待验证制品完全一致
subject[].digest 内容摘要,哈希不符直接拒绝
buildType 构建类型,应匹配你期望的构建方式
builder.id 构建者身份,如 github.com/actions/runner
invocation.parameters 构建参数,包含源码仓库与 commit/tag
metadata.buildStartedOn / finishedOn 构建时间窗口
其中最容易被忽略的是 invocation.parameters:它记录了「这份制品由哪个仓库的哪个 commit 构建」。如果验证时只校验 builder.id(确认来自 GitHub Actions),攻击者只要能用任意一个 GitHub Actions 工作流构建,就能产出「构建者合法但源码不是你的」的制品。因此策略上要把允许的源码仓库与 ref 写成白名单,逐项比对。
| 校验项 | 只校验它 | 后果 |
|---|---|---|
| 有溯源即可 | L1 心态 | 无法区分可信与不可信构建 |
| builder.id | 确认构建平台 | 攻击者用同平台的其他仓库伪造 |
| source-uri + ref | 钉死源码来源 | 需要维护白名单,是正确做法 |
| 全部字段 + 摘要 | 最强 | 配置成本高,关键制品值得 |
5. 制品签名与 Sigstore 验证
传统签名方案要求维护一套 GPG 密钥、发布公钥、处理密钥轮换与吊销,社区里真正做好的项目很少。Sigstore 用**无密钥签名(keyless)**绕开了这个问题:签名者用 OIDC 身份(如 GitHub Actions 的 workload identity)向 Fulcio 申请一张短时证书,私钥只在内存中存活几分钟,签名记录写入透明的 Rekor 日志,验证时只需校验「签名时身份是谁、是否在证书有效期内、记录是否在透明日志里」。
# 无密钥签名一个制品(在 CI 中运行,自动使用 OIDC 身份)
cosign sign-blob --yes dist/app.tar.gz --output-signature app.tar.gz.sig \
--output-certificate app.tar.gz.pem
# 验证:绑定到具体身份与颁发者
cosign verify-blob --signature app.tar.gz.sig --certificate app.tar.gz.pem \
--certificate-identity-regexp 'https://github.com/org/app/.github/workflows/release.yml@refs/tags/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
dist/app.tar.gz
验证命令里 --certificate-identity-regexp 与 --certificate-oidc-issuer 都不能省。只做「签名有效」的校验等于没校验:任何人都能签一个制品,签名本身有效。必须把身份钉死到「来自我这条 release 工作流、来自 GitHub 的 OIDC 颁发者」,才能防住冒名。
容器镜像的签名与验证同理:
cosign sign --yes registry.example.com/app@sha256:<digest>
cosign verify --certificate-identity-regexp '...' --certificate-oidc-issuer '...' \
registry.example.com/app@sha256:<digest>
注意签名对象应当是镜像摘要(digest)而不是 tag。tag 可以被覆盖,签名一个 tag 意味着签名可以被指向另一份内容;摘要不可变,签名才真正绑定到内容。同理,Kubernetes 部署清单里应当用 digest 引用镜像,imagePullPolicy: Always 配合 tag 也无法防止 tag 被改。
签名体系的最后一环是准入。签名存在但没人校验,等于没有。三条落地路径:CI 里校验(防止发布流程被注入)、镜像仓库 webhook 校验(拒绝未签名的 push)、集群准入控制器校验(拒绝未签名的部署)。三条都做最好,至少要做第三条,因为那才是真正拦住「带毒镜像上线」的地方。
6. 依赖混淆与投毒防范
依赖混淆(dependency confusion)是最容易防、也最容易漏的一类。原理很直接:如果包管理器在解析 @mycorp/utils 时既查私有 registry 又查公共 registry,攻击者在公共 registry 注册同名包并给一个更高的版本号,构建就会拉到攻击者的包。
防范措施(按有效性排序):
1. scope 前缀:内部包统一加组织 scope(@mycorp/),公共 registry 无法注册同名 scope
2. registry 优先级:配置 .npmrc / settings.xml 明确「先查私有源,命中即停」
3. 代理与白名单:所有依赖经私有代理(Nexus/Artifactory)拉取,公共源仅作上游镜像
4. 锁文件 + 完整性校验:提交 lockfile,启用 npm 的 integrity 与 go 的 GONOSUMCHECK
5. 命名空间声明:在公共 registry 主动注册内部包名(防御性占位,成本低)
Python 生态的同类问题是 --extra-index-url 的隐式行为:pip 会同时查询所有 index 并选版本最高的,等于给了公共源插队的机会。正确做法是用 --index-url 指向私有源,或改用 uv/poetry 的显式源配置。Go 的 GOPRIVATE / GONOSUMDB 则用于避免私有模块被送到公共 checksum 数据库。
第二类投毒是抢注(typosquatting):reqeusts、lodahs 这类拼写相近的包名,或利用 Unicode 同形字符。防范上,CI 里可以加一条依赖名与「已批准清单」比对的门禁,任何新增依赖名都要人工确认;对高频拼错的包名维护一份黑名单。
# 列出本次构建新增的依赖,人工确认
comm -13 <(sort baseline.deps) <(sort current.deps)
第三类是上游账号接管:维护者账号被钓鱼或 CI 凭证泄漏,攻击者直接发布恶意版本。这类无法从包名层面防,只能靠「新版本发布后不立即自动合并」的策略——对关键依赖设置冷却期(如 72 小时),配合社区告警,能在恶意版本被广泛传播前拦下一部分。npm 的 provenance 标记(发布时绑定 CI 身份)可以作为筛选信号:优先选择带 provenance 的版本。
| 投毒类型 | 检测信号 | 缓解措施 |
|---|---|---|
| 依赖混淆 | 包来源 registry 与预期不符 | scope 前缀 + 私有代理 |
| 抢注 | 依赖名不在批准清单 | 名称门禁 + 黑名单 |
| 账号接管 | 新版本无 provenance、维护者变更 | 冷却期 + 人工确认 |
| 构建脚本注入 | postinstall 脚本、构建期网络访问 | 禁用 postinstall、构建网络隔离 |
postinstall 脚本是 npm 生态的长期痛点,绝大多数包并不需要它。在 CI 中用 npm ci --ignore-scripts 全局禁用,再对确实需要的包单独放行,能挡掉相当一部分「安装即执行」的投毒。
CI 自身也是供应链的一环
流水线里引用的第三方 action、构建插件、基础镜像同样是供应链。GitHub Actions 的 uses: some/action@v3 是浮动引用,上游把 v3 指向恶意 commit 时你不会收到任何通知。正确做法是把 action 钉到 commit SHA:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
同时限制 GITHUB_TOKEN 权限(permissions: contents: read 起步),并优先使用 GitHub 官方或已启用 provenance 的 action。CI 凭证泄漏是构建投毒最常见的入口,把 token 权限收窄到「这次任务真正需要的最小集合」,能把单点失陷的影响面压到最低。
7. 与漏洞响应的衔接:VEX 与告警收敛
SBOM 生成的直接收益是漏洞匹配,但纯匹配会产生大量噪声:一个基础镜像里的 OpenSSL 被报出 20 个 CVE,其中大部分「存在但不可达」(函数没被调用、模块没被编译进来)。不收敛噪声,团队很快就会对告警脱敏,门禁也会被 continue-on-error 绕过。
VEX 是收敛的手段:对每个被报出的漏洞声明状态——not_affected(不可利用,附理由)、affected(受影响)、fixed(已修复)、under_investigation(调查中)。CycloneDX 1.4+ 原生承载 VEX,可以与 SBOM 合并成一份文件:
{
"vulnerabilities": [{
"id": "CVE-2026-12345",
"affects": [{"ref": "pkg:maven/org.example/lib@2.4.1"}],
"analysis": {
"state": "not_affected",
"justification": "code_not_reachable",
"detail": "vulnerable function is only reachable from test scope"
}
}]
}
justification 必须用规范枚举值(code_not_present / code_not_reachable / requires_configuration / requires_dependency / requires_environment / protected_by_compiler / protected_at_runtime / protected_at_perimeter),自由文本只能放 detail。用规范值的好处是下游工具能自动处理,审计时也有统一口径。
告警收敛的策略是分级 + 豁免 + 时限:affected 且组件在运行时路径上 → 立即阻断;affected 但仅在构建期 → 限时整改;not_affected 且理由充分 → 自动放行并归档 VEX。豁免同样要带过期时间,避免「一次判定永久放行」。漏洞从发现到修复的完整流程(分级、协调披露、补丁回溯)见开源漏洞响应与 CVE 流程,本文只负责把「扫出来的东西」变成「可执行的门禁信号」。
还有一条实践建议:把 SBOM 与 VEX 一起归档到不可变存储,每个制品一份。这样当某个 CVE 在半年后被披露时,可以直接查「受影响版本的 SBOM 里有没有这个组件」,而不必重新构建历史版本——历史构建环境通常早已销毁。
8. 私有仓库、镜像代理与 vendor 目录
供应链的物理入口是「包从哪里来」。三种模式的安全性递增:
三种依赖获取模式:
A. 直连公共源 最快,但受制于公共源的可用性与安全性,且无法审计
B. 代理/镜像 所有拉取经私有代理,可缓存、可扫描、可封禁,推荐
C. vendor 入库 依赖源码提交进仓库,完全离线可构建,成本最高
模式 B 是多数团队的甜点:Nexus / Artifactory 作为代理,上游仍是公共源,但所有下载经过代理,可以做「封禁特定版本」「只允许已扫描过的包」「保留副本以防上游删包」。上游删包是真实风险:npm 允许维护者 unpublish,一个被广泛依赖的包被删除后,直连模式的构建会直接失败,而代理模式因为有副本不受影响。
# Nexus 代理仓库的封禁与隔离策略(示意)
proxy:
remote: https://registry.npmjs.org
negativeCache: true
blockedVersions:
- "lodash@4.17.20" # 已知问题版本
quarantineHours: 24 # 新版本先隔离观察
模式 C(vendor)适合对构建可复现性要求极高的场景(如安全产品、嵌入式),代价是升级依赖变得笨重、仓库体积膨胀。一个折中是 vendored + 校验:依赖源码入库,同时记录每个组件的哈希与来源 URL,CI 里校验哈希一致。Go 的 vendor/ 目录配合 go mod verify、Rust 的 cargo vendor 都是这个思路。
容器侧还有一个独立话题:基础镜像的选择。优先用官方或厂商维护的最小镜像(如 distroless、alpine),减少自带组件数就等于减少漏洞面;同时按 digest 锁定基础镜像,避免 FROM node:20 在某天悄悄变成另一个内容。相关实践在 Docker 与容器基础
中有更完整的讨论。
9. 分阶段落地路线与成熟度模型
供应链治理不适合一次性铺开,按成熟度分四阶段推进,每阶段都有可交付的成果。
阶段一 可见性(1~2 个月)
- 所有制品在构建时产出 SBOM(CycloneDX + SPDX 双格式)
- SBOM 与制品哈希绑定,归档到不可变存储
- 抽查 purl 覆盖率 ≥ 95%
阶段二 门禁(2~3 个月)
- SBOM 与基线 diff,只对增量做策略评估
- 依赖名批准清单 + registry 白名单
- 关键依赖的漏洞告警接入工单系统
阶段三 完整性(3~6 个月)
- 制品签名(cosign keyless)+ 构建溯源(SLSA L2/L3)
- 发布流程与镜像仓库 webhook 校验签名
- VEX 收敛告警,豁免带过期时间
阶段四 准入(持续)
- 集群准入控制器拒绝未签名/未验证镜像
- 依赖健康度纳入选型流程
- 定期演练「某个上游被投毒,多久能定位并阻断」
| 阶段 | 核心能力 | 关键交付物 | 典型投入 |
|---|---|---|---|
| 一 | 看得见 | 全制品 SBOM + 归档 | 1 人月 |
| 二 | 管得住增量 | 策略门禁 + 白名单 | 1~2 人月 |
| 三 | 验得了来源 | 签名 + 溯源 + 验证 | 2~3 人月 |
| 四 | 拦得住上线 | 准入控制 + 演练 | 持续 |
衡量是否做到位的标准很朴素:被问「三个月前发布的 1.2.0 版本里,有没有那个被投毒的包」时,能不能在半小时内从归档里调出 SBOM 给出答案;被问「线上跑的这个镜像是不是我们构建的那份」时,能不能用一条 cosign verify 给出证据。能,就说明这套体系真的在运转。
权衡取舍
| 取舍点 | 偏 A | 偏 B | 建议 |
|---|---|---|---|
| SBOM 粒度 | 文件级,精确但体积大 | 包级,轻量但漏 vendored | 日常包级 + 定期文件级补盲 |
| 生成来源 | 源码目录,快但看不到基础镜像 | 镜像,全但慢 | 以镜像为准,源码作补充 |
| 签名方式 | GPG 长密钥,无需外部依赖 | Sigstore 无密钥,依赖透明日志 | 新项目用 Sigstore,老项目渐进 |
| 门禁强度 | 全量阻断,安全但吵 | 只告警,不阻断 | 增量阻断 + 存量清债 |
| 告警处理 | 全量上报,覆盖全但噪声大 | 只报运行时可达,静默但可能漏 | VEX 分级 + 豁免限期 |
| 依赖获取 | 直连公共源,简单 | 私有代理,可控但多一层 | 代理为主,关键场景 vendor |
| SLSA 目标 | 全量 L3,最强但成本高 | 全量 L1,便宜但价值有限 | 关键制品 L3,其余 L2 |
核心取舍是成本与可验证性的平衡:签名和溯源每加一层都增加构建复杂度与失败点,但少了任何一层,「制品可信」就只是口头承诺。工程上最优解通常是「SBOM 全覆盖、签名只覆盖发布制品、溯源只覆盖关键路径」,而不是所有制品一视同仁。
常见坑清单
- SBOM 里没有 purl:漏洞匹配只能靠包名模糊比对,误报漏报同时暴增——生成后必须抽查 purl 覆盖率。
- 只扫镜像最上层:基础镜像里的旧组件全部漏掉,而它们才是漏洞高发区——
--scope all-layers不能省。 - 签名 tag 而不是 digest:tag 可被覆盖,签名随之指向另一份内容——一律签 digest。
- 只验「签名有效」不验身份:任何人都能签一份有效签名——必须钉死
--certificate-identity与--certificate-oidc-issuer。 - 签名了但没人校验:签名形同虚设——至少在集群准入层做一次强制校验。
- 用
latest或浮动 tag 引用基础镜像:内容会悄悄变化且不可追溯——按 digest 锁定。 - npm 未禁用 postinstall:安装即执行,是投毒的主要入口——CI 用
--ignore-scripts。 - pip 用
--extra-index-url:公共源可插队抢版本,等于敞开依赖混淆——改用单一私有 index。 - SBOM 不带版本标识:一堆同名
sbom.json事后无法对账——命名里写入 product/version/commit。 - 全量阻断存量依赖:团队立刻加
continue-on-error绕过——冻结基线只卡增量。 - 漏洞告警不做收敛:噪声导致团队脱敏,真问题被淹没——用 VEX 分级并给豁免设过期。
- 归档放 CI 制品里:CI 制品有保留期,审计时补不回来——写入不可变存储。
小结
软件供应链安全的工程化本质是三件事:看得见(每个制品都有带 purl 的 SBOM 并归档)、验得了(制品签名 + 构建溯源,验证时钉死身份)、拦得住(增量门禁 + 准入控制 + 告警收敛)。SBOM 负责第一件,SLSA 与 Sigstore 负责第二件,策略与准入负责第三件,缺一不可。把这三件事做成流水线的固定环节之后,供应链治理就不再是出事后的应急排查,而是每次构建都在自动执行的日常。
落地顺序建议从 SBOM 开始:先让每个制品都有带版本标识、带 purl 的 SBOM,再接增量门禁,最后补签名与溯源。跳过第一步直接上签名,通常以「签了一份自己都看不懂的制品」告终;跳过第二步直接上准入,则容易因为误报太多被运维关掉。
如果想继续深入,建议沿两条线读:一条是合规侧,从 开源许可证合规实战 理解同一份 SBOM 如何同时服务于许可证义务与审计存证;另一条是响应侧,从 开源漏洞响应与 CVE 流程 理解漏洞从被发现到补丁回溯的完整链路,把「防注入」与「出事后怎么办」两条判断链接起来。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。