做安全检查时,最让人安心的一句话大概是:

未发现高危漏洞。

这句话当然有价值。问题在于,人很容易顺手把它理解成“这次发布是安全的”。

漏洞扫描通常检查源代码、依赖包、容器文件系统和已知 CVE。它能发现一扇没有上锁的门,却未必知道送到服务器上的那栋房子,是否真是图纸里的那一栋。

线上运行的镜像可能来自开发者电脑;CI 使用的第三方 Action 可能在更新后换了内容;制品仓库的长期密钥可能已经泄漏;同一个 latest 标签可能在两次部署之间悄悄指向了不同镜像。源码仓库干干净净,发布链路照样可以出事。

SLSA 软件供应链威胁模型

图:SLSA 1.2 对软件供应链威胁位置的划分。攻击面贯穿源码、构建、依赖、制品发布和分发。

一份扫描报告覆盖不了整条链路

从一次提交到用户真正运行软件,中间至少经过这些东西:

开发者提交
  ↓
代码仓库与评审
  ↓
CI 工作流
  ↓
依赖下载与构建环境
  ↓
二进制文件或容器镜像
  ↓
制品仓库
  ↓
部署系统
  ↓
生产环境

其中任何一步被改动,最后得到的东西都可能偏离仓库里的代码。

SLSA 在官方威胁模型中列过一些很典型的情况:攻击者拿到仓库权限后提交恶意代码、构建平台在编译期间注入内容、发布凭据泄漏后有人替换制品、包管理器中出现拼写相近的恶意包。它们的共同点很简单:只看最终源码,证据不够。

安全团队常问“这个镜像有没有漏洞”。供应链安全还会继续追问:

  • 它是由哪次提交构建的?
  • 构建时用了哪些依赖和参数?
  • 谁触发了发布?
  • 构建完成后有没有被替换?
  • 部署系统为什么信任这个制品?
  • 出现新漏洞时,哪些版本真的受影响?

这些问题回答不出来,扫描工具再多也只是把局部照得很亮,其他地方仍然是黑的。

SBOM 是清单,不是安全证书

SBOM 经常被翻译为“软件物料清单”。可以把它理解成软件的配料表:包含了哪些组件、各自是什么版本、使用什么许可证、由谁提供。

它最实际的用途出现在漏洞公开之后。

假设某个基础库突然曝出严重漏洞。没有 SBOM,团队只能在几十个仓库、镜像和历史版本里到处搜;有了与制品绑定的 SBOM,可以先查清哪些镜像真正包含该版本,再决定修复和回滚范围。

但 SBOM 本身不会阻止攻击。清单写得再详细,也不能证明镜像确实由声明的源码构建,更不能证明构建过程没有被动手脚。

所以要把两个概念分开:

  • SBOM 说明制品里面有什么。
  • Provenance 说明制品怎么来的。

后者通常译作“来源证明”或“构建溯源”。它会记录源码位置、提交版本、构建器身份、构建参数和输出摘要等信息。SLSA 1.2 就围绕这些证据设计了逐级增强的安全要求。

给镜像签名也没有想象中万能

签名能证明某个身份认可了某个制品,前提是验证方真的检查了签名、身份和摘要。

这里有三个常见坑。

第一个坑是只签标签。标签会变,摘要不会。部署时使用:

registry.example.com/app:latest

你只能知道它叫 latest。改成:

registry.example.com/app@sha256:...

部署对象才被固定下来。

第二个坑是签名私钥长期躺在 CI Secret 里。谁拿到这个密钥,谁就能制造“看起来合法”的签名。现在更推荐使用 OIDC 和短期身份凭据,让流水线在运行时换取临时权限,避免保存长期云密钥或仓库密码。

第三个坑是签完不验。很多团队在构建阶段生成了签名和证明,部署阶段仍然照常拉取任何镜像。证据成了附件,没有成为准入条件。

真正有用的流程应该在部署前执行策略校验:镜像摘要是否固定、签名身份是否符合预期、来源证明是否来自认可的构建器、源码仓库和分支是否正确、SBOM 是否存在。任何一项不满足,部署直接停止。

小团队先把发布权收紧

供应链安全很容易被写成一份庞大的合规清单。小团队没必要第一天就搭出完整平台,可以先处理最容易造成严重后果的地方。

发布只能从 CI 发生

不要让开发者在本地执行 docker push 后直接上线。正式版本统一由受保护分支或版本标签触发,构建、测试、生成 SBOM、签名和推送全部留在流水线里。

这条规定的意义不在于 CI 天生可信,而在于流程可重复、可审计,也更容易加上强制策略。

工作流权限按任务拆开

测试任务通常只需要读取代码,不该拥有发布包、修改仓库和访问生产环境的权限。发布任务可以获得更高权限,但只在满足分支、标签、环境审批等条件时运行。

GitHub Actions 可以显式声明:

permissions:
  contents: read

需要发布镜像的 Job 再单独增加 packages: write。需要使用 OIDC 时,仅给对应任务增加 id-token: write

OpenSSF Scorecard 会检查分支保护、代码评审、工作流令牌权限、依赖固定、危险工作流和签名发布等项目。它不能代替人工审计,但很适合拿来发现仓库里那些长期没人注意的基础问题。

OpenSSF Scorecard 检查范围

图:OpenSSF Scorecard 将检查范围覆盖到源码、构建、依赖、测试和项目维护。

第三方 Action 和依赖要固定到不可变版本

下面这种写法很方便:

uses: some-org/some-action@main

可它把未来的代码更新也一起信任了。更稳妥的方式是固定到完整提交 SHA,并通过受控流程更新。

应用依赖同样如此。锁文件要提交到仓库,生产构建不应在没有评审的情况下自动漂移到新版本。基础镜像最好固定 digest,至少保证同一次发布可以重新得到相同输入。

构建时生成 SBOM 和来源证明

Docker Buildx 已经能在构建镜像时附加 SBOM 与 provenance:

docker buildx build \
  --provenance=mode=max \
  --sbom=true \
  -t registry.example.com/app:${GIT_SHA} \
  --push .

这里有个容易忽略的细节:证明材料需要随镜像推送并由支持 OCI Referrers 的仓库保存。只在本地 load 一个镜像,往往不会得到同样的留存效果。构建完成后还应检查制品仓库中是否真的存在这些证明,别把“命令执行成功”当成“证据已经可用”。

部署只认摘要和策略

生产部署清单应固定镜像摘要。更新时由发布流程提交新的摘要,经过评审后再部署。

进一步可以使用 Sigstore Cosign、Kyverno、Gatekeeper 或云厂商的制品策略功能,校验签名与 attestation。工具选哪个并不关键,关键是验证发生在部署入口,而且失败时不能绕过。

凭据要短命,也要有退出机制

能用 OIDC 换取临时凭据,就少存一份长期 Secret。实在需要长期凭据,也要明确权限范围、轮换周期、负责人和撤销方式。

账号离职、仓库迁移、CI 平台更换、制品仓库切换时,旧权限必须被清理。很多事故并不需要高明漏洞,一枚多年没人管的 Token 已经足够。

一个能真正执行的最低基线

对普通 Web 项目,我更建议先做到下面这些:

  1. 主分支启用保护和强制评审,禁止直接推送。
  2. 正式制品只由 CI 构建和发布,本地账号没有生产发布权。
  3. 工作流权限默认只读,高权限集中在独立发布 Job。
  4. 第三方 Action 固定完整提交 SHA,应用依赖保留锁文件。
  5. 容器镜像同时生成 SBOM 和 provenance,并保存在制品仓库。
  6. 生产环境使用镜像 digest,不使用会漂移的 latest
  7. 部署前验证签名、来源和允许的仓库、分支、构建器。
  8. 使用 OIDC 或短期凭据,定期清理旧 Token 和离职账号。
  9. 保留历史制品、构建日志与证明材料,确保事故发生后能调查。
  10. 新 CVE 出现时,能通过 SBOM 快速定位受影响的服务和版本。

这十条做完,安全扫描仍然要继续跑。只是这时,扫描报告终于被放进了一条有身份、有来源、有证据的发布链路里。

最后

软件供应链安全听起来很大,实际往往从一个很小的问题开始:

服务器上这个文件,到底是谁、在什么环境、用哪份源码构建出来的?

如果团队只能回答“应该是 CI 打的”,那就还没有证据。

安全工程最怕“应该”。代码应该经过评审,镜像应该来自主分支,密钥应该没有泄漏,线上应该就是刚才扫描过的版本。事故发生以后,这些话没有一条能用于追责、回滚或恢复。

把源码、构建和部署连成一条可验证的证据链,才有资格对外说这个版本值得信任。


站内延伸阅读

参考资料