📦 本文基于的完整项目源码: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 模式把"密钥"换成"短期身份”:

  1. CI 任务通过 OIDC 向 Sigstore Fulcio 申请一份短期证书(10 分钟有效),证书的 SAN 字段写的是 OIDC issuer 的 identity(例如 https://github.com/meirongdev/shop/.github/workflows/supply-chain.yml@refs/heads/main)。
  2. cosign 用这个短期证书签镜像,签名 + 证书 + transparency log entry 一起发布到 OCI registry。
  3. 短期证书过期后立刻作废——攻击者偷不到长期密钥,因为根本不存在。
  4. 所有签名记录写入 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 不解决的问题:

  1. 不是运行时防御。攻击者已经在构建机里植入恶意代码,CI 还会照样签——签的就是被污染的镜像。SBOM/cosign 是事后审计身份绑定,不是 zero-trust。
  2. 依赖消费者主动验签。你 push 了带签名的镜像,但下游没配 admission policy,等于没签。如果要形成闭环,Kyverno/Gatekeeper 这层通常也得补上。
  3. CycloneDX 看不见的依赖不会进 SBOM。例如:
    • JNI native libs(C/C++ .so 文件,系统包管理装的)
    • 通过 reflection 动态加载的可选依赖
    • 容器基础镜像里的 OS 包(要靠 Syft 这类工具补 OS 层 SBOM) 完整供应链清单 = Maven SBOM + 容器层 SBOM,两份都要有。
  4. transparency log 是可信但非匿名。Sigstore Rekor 是公开的,攻击者能根据 log 反推你的 CI 模式。私有项目要做 OPSEC 的话需要谨慎。
  5. 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 升序:

  1. SBOM 生成(投入:5 分钟)

    • cyclonedx-maven-plugin 加进根 pom 的 pluginManagement
    • make sbom Makefile target
    • CI workflow upload-artifact
  2. 镜像签名 + attestation(投入:30 分钟,需要 GHCR/ECR 接入)

    • GitHub Actions permissions: id-token: write
    • sigstore/cosign-installer + cosign sign + cosign attest
  3. Admission 验签(投入:1-2 小时,需要集群权限)

    • 装 Kyverno
    • 写 verify-images policy
    • 灰度 namespace 先 Audit 再 Enforce

三步做完,供应链基线通常就比“只有镜像发布”前进一大截。很多团队会把它当成向 SLSA Level 2 靠近的起点。