软件供应链最小基线:SBOM + cosign 镜像签名
目录
📦 本文基于的完整项目源码:meirongdev/shop
Log4Shell 让全行业意识到:没人能回答"我用了哪些库的哪些版本" 的时候,所谓应急响应就是几千个工程师在同时翻自己的 pom.xml。SLSA、cosign、Sigstore 这套工具链,更多是在试着把这个问题收敛成一次可查询的清单比对。本文聚焦最小基线:每个产物有 SBOM、每个镜像有签名、SBOM 作为 attestation 绑定到镜像。
一、为什么 SBOM 是底线#
Q:SBOM 到底是什么,跟以前我们说的"依赖列表"有什么本质区别?
SBOM(Software Bill of Materials)是一份机器可读的、可校验的依赖清单,每条记录包含:
- 名称、版本、purl(package URL,唯一定位坐标)
- 哈希(SHA-256,验证产物未被篡改)
- 许可证
- 直接 / 间接依赖关系(树状结构)
- 可选:CVE / VEX 状态
mvn dependency:tree 生成的是给人看的文本;SBOM 是给工具看的、跨语言统一的 JSON 文档。Log4Shell 当时如果每个交付物都已经有 SBOM 在手,问题从"全员盲查"变成 jq '.components[] | select(.purl | contains("log4j-core") and contains("@2."))'。
Q:CycloneDX 和 SPDX 选哪个?
两个都是 ISO 认可的 SBOM 标准,95% 场景下结果等价。差异在重点:
- CycloneDX — OWASP 出品,安全工程师视角,原生支持 VEX(漏洞利用状态)、SaaSBOM、ML-BOM。漏洞工具链(Dependency-Track、Trivy、Grype)的 first-class 输入格式。
- SPDX — Linux Foundation 出品,许可证合规视角,法务工具(FOSSA、Black Duck)原生消费。
Java 生态里我会先选 CycloneDX——cyclonedx-maven-plugin 上手成本比较低。如果你的合规需求是法务驱动而非安全驱动,再考虑 SPDX。
二、Maven 项目生成 SBOM#
Q:每个模块都生成一份还是整个 reactor 一份?
我的经验是:先 aggregate,需要时再 per-module。理由:
- 一个 16 服务的 monorepo 生成 16 份 SBOM,既冗余又难关联。
- 如果只发布镜像(典型 Spring Boot 应用场景),SBOM 跟着镜像走,per-module SBOM 没人消费。
- aggregate BOM 已经包含每个模块的内部依赖关系,丢失信息很少。
只有当某个 jar 真的会被外部下游消费(library 模式),才给那个 jar 单独生成 SBOM。
Q:怎么配?
在根 pom.xml 的 <pluginManagement> 加:
<plugin>
<groupId>org.cyclonedx</groupId>
<artifactId>cyclonedx-maven-plugin</artifactId>
<version>2.9.0</version>
<configuration>
<outputFormat>json</outputFormat>
<outputName>shop-platform-cyclonedx</outputName>
<outputDirectory>${project.basedir}/target/sbom</outputDirectory>
<projectType>application</projectType>
<schemaVersion>1.5</schemaVersion>
<includeBomSerialNumber>true</includeBomSerialNumber>
<includeCompileScope>true</includeCompileScope>
<includeProvidedScope>true</includeProvidedScope>
<includeRuntimeScope>true</includeRuntimeScope>
<includeTestScope>false</includeTestScope>
</configuration>
</plugin>
不绑 <phase> 是有意的——SBOM 更像发布动作的一部分,不是每次 mvn package 都需要的产物。提供一个显式入口:
sbom: ## Generate aggregate CycloneDX SBOM
$(MVNW) -q -DskipTests org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
@echo "SBOM written to target/sbom/shop-platform-cyclonedx.json"
makeAggregateBom 扫整个 reactor,输出一份 JSON,CycloneDX 1.5 schema。一次跑下来 200-500 components 是常态。
Q:测试依赖要不要包含?
我这里不包含。SBOM 描述的是运行时风险面,测试库不会进生产镜像,纳入只会让告警噪声变大。对大多数服务来说,<includeTestScope>false</includeTestScope> 会是更合适的默认值。
三、镜像签名为什么用 cosign keyless#
Q:传统的 GPG 签名/x509 签名为什么不够?
我觉得最麻烦的还是密钥管理:
- 私钥放哪?放 CI 里就有泄露风险,用 HSM 又增加运维。
- 怎么轮换?密钥轮换之前签过的镜像怎么办?
- 谁批准签名?审计链路怎么留?
CI/CD 主导的发布流程下,“长期持有的私钥"本身就是攻击面。SolarWinds 事件的核心就是攻击者拿到了 Orion 的合法签名密钥。
Q:cosign keyless 怎么解决这个问题?
Sigstore 的 keyless 模式把"密钥"换成"短期身份”:
- CI 任务通过 OIDC 向 Sigstore Fulcio 申请一份短期证书(10 分钟有效),证书的 SAN 字段写的是 OIDC issuer 的 identity(例如
https://github.com/meirongdev/shop/.github/workflows/supply-chain.yml@refs/heads/main)。 - cosign 用这个短期证书签镜像,签名 + 证书 + transparency log entry 一起发布到 OCI registry。
- 短期证书过期后立刻作废——攻击者偷不到长期密钥,因为根本不存在。
- 所有签名记录写入 Rekor transparency log(公开、append-only),任何人事后可以审计"哪个 workflow 在什么时间签了哪个镜像"。
Q:验证方一边怎么验签?
cosign verify ghcr.io/meirongdev/shop/buyer-bff:v1.2.3 \
--certificate-identity-regexp "^https://github.com/meirongdev/shop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
验证规则是"签名身份 = 我项目的 GitHub Actions workflow"。任何不来自这个 workflow 的签名都会被拒绝,包括内部人员私自签的。
四、把 SBOM 绑到镜像:cosign attestation#
Q:SBOM 放 GitHub release 行不行?
行,但信任链断了:你怎么证明这份 SBOM 对应的就是这个镜像?文件名约定不算证明。
cosign attestation 把 SBOM 作为带签名的断言直接绑定到镜像 digest:
cosign attest --yes \
--predicate target/sbom/shop-platform-cyclonedx.json \
--type cyclonedx \
ghcr.io/meirongdev/shop/buyer-bff@sha256:abcd...
attestation 也走 keyless OIDC 流程,跟镜像签名共享同一份身份。验证:
cosign verify-attestation ghcr.io/meirongdev/shop/buyer-bff:v1.2.3 \
--type cyclonedx \
--certificate-identity-regexp "^https://github.com/meirongdev/shop" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
| jq '.payload | @base64d | fromjson | .predicate.components | length'
只要 attestation 验证通过,SBOM 内容就是可信的——因为它是被 CI 用 keyless 身份签过的、写进 Rekor 公开日志的、绑定到这个镜像 digest 的。
五、GitHub Actions 完整工作流#
permissions:
contents: read
packages: write # push 到 GHCR
id-token: write # 申请 OIDC token 给 cosign
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
cache: maven
- run: make sbom
- uses: actions/upload-artifact@v4
with:
name: sbom-cyclonedx
path: target/sbom/shop-platform-cyclonedx.json
sign:
needs: sbom
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with: { name: sbom-cyclonedx, path: target/sbom/ }
- uses: sigstore/cosign-installer@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- run: |
IMAGE="ghcr.io/${{ github.repository }}/buyer-bff:${GITHUB_SHA}"
docker push "$IMAGE"
cosign sign --yes "$IMAGE"
cosign attest --yes \
--predicate target/sbom/shop-platform-cyclonedx.json \
--type cyclonedx \
"$IMAGE"
id-token: write 是 keyless 的钥匙——没有它 OIDC token 拿不到,整条链路就走不通。
六、运行时强制:Kyverno admission policy#
Q:CI 签了名,怎么保证 K8s 集群里不会跑没签名的镜像?
K8s admission webhook 拦截。Kyverno 的 verify-images policy:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-shop-images
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds: [Pod]
namespaces: [shop, shop-canary]
verifyImages:
- imageReferences:
- "ghcr.io/meirongdev/shop/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/meirongdev/shop/.github/workflows/supply-chain.yml@*"
issuer: "https://token.actions.githubusercontent.com"
任何 namespace shop 下的 Pod 启动前都会被验签:身份不匹配——拒绝调度。这才是闭环:CI 签 → registry 存 → admission 验。
七、绕不开的 tradeoff#
Q:上了 SBOM + 签名是不是供应链就安全了?
不是。坦率列出 SBOM/cosign 不解决的问题:
- 不是运行时防御。攻击者已经在构建机里植入恶意代码,CI 还会照样签——签的就是被污染的镜像。SBOM/cosign 是事后审计和身份绑定,不是 zero-trust。
- 依赖消费者主动验签。你 push 了带签名的镜像,但下游没配 admission policy,等于没签。如果要形成闭环,Kyverno/Gatekeeper 这层通常也得补上。
- CycloneDX 看不见的依赖不会进 SBOM。例如:
- JNI native libs(C/C++ .so 文件,系统包管理装的)
- 通过 reflection 动态加载的可选依赖
- 容器基础镜像里的 OS 包(要靠 Syft 这类工具补 OS 层 SBOM) 完整供应链清单 = Maven SBOM + 容器层 SBOM,两份都要有。
- transparency log 是可信但非匿名。Sigstore Rekor 是公开的,攻击者能根据 log 反推你的 CI 模式。私有项目要做 OPSEC 的话需要谨慎。
- OIDC issuer 信任传递。keyless 把信任根从"私钥"换成"GitHub OIDC"——你的整个供应链安全等于 GitHub 的身份系统安全。能接受这个等价交换的项目才适合 keyless;不接受的项目应该走 KMS-backed signing key。
Q:什么场景下不应该急着上?
完全内部、不发布到 registry、没有外部消费者的实验项目——上 SBOM 没成本(一行 Make target),但上签名的 ROI 接近零。
但对任何会被生产环境部署的项目——哪怕是 demo——我都会建议尽早把 SBOM 当作类似 .gitignore 的基础设施补上。Maven plugin 一个版本号、一个 Make target、几分钟搞定。下一次 Log4Shell 等级的事件爆发时,能在较短时间里回答"我有没有受影响"和"我能不能证明我没受影响",通常会比临时翻依赖树更从容。
八、最小落地清单#
按 ROI 升序:
-
SBOM 生成(投入:5 分钟)
cyclonedx-maven-plugin加进根 pom 的 pluginManagementmake sbomMakefile target- CI workflow upload-artifact
-
镜像签名 + attestation(投入:30 分钟,需要 GHCR/ECR 接入)
- GitHub Actions
permissions: id-token: write sigstore/cosign-installer+cosign sign+cosign attest
- GitHub Actions
-
Admission 验签(投入:1-2 小时,需要集群权限)
- 装 Kyverno
- 写 verify-images policy
- 灰度 namespace 先 Audit 再 Enforce
三步做完,供应链基线通常就比“只有镜像发布”前进一大截。很多团队会把它当成向 SLSA Level 2 靠近的起点。