容器镜像签名验证实战:cosign 防篡改配置

容器镜像签名验证是供应链防篡改的最后一道闸门。镜像仓库被投毒、构建产物被替换、tag 被重新覆盖指向恶意镜像,这类事件在近年供应链攻击中反复出现,而仅靠镜像扫描(扫描的是内容而非来源)无法阻止。本文给出 cosign 签名与验证的完整配置:从密钥对生成、签名与验证、keyless 身份签名、SBOM 附着,到 Kubernetes 准入控制强制校验,附可直接执行的命令与策略代码。

适用场景

  • CI/CD 流水线构建镜像并推送到 Harbor、GitLab Registry 等私有仓库,需要确保上线镜像与构建产物一致。
  • Kubernetes 集群只允许运行已签名镜像,防止运维或攻击者直接 kubectl apply 一个未审计的第三方镜像。
  • 供应链安全合规要求提供”镜像来源可信、内容未篡改”的可验证证据。
  • 基础镜像或第三方组件由外部提供,需要在引入前验证其签名与出处。

先明确边界:镜像签名解决的是来源与完整性问题,即”这个镜像确实由该主体构建、且字节未被改动”;它不负责判断镜像里有没有漏洞。签名验证与镜像扫描、SBOM 分析是互补关系,不是替代关系。

前置条件

  • cosign v2 及以上版本,可通过官方二进制或 go install github.com/sigstore/cosign/v2/cmd/cosign@latest 安装。
  • 镜像仓库支持 OCI artifact 存储:Harbor 2.5 以上、GitLab Container Registry 15 以上或标准 registry v2 均可。
  • 本机已 docker login 或配置 ~/.docker/config.json 凭据,cosign 复用该凭据访问私有仓库。
  • Kubernetes 准入控制方案二选一:Kyverno 1.8 以上,或 sigstore 官方 policy-controller。
  • CI 环境如需 keyless 签名,需能访问 Fulcio 与 Rekor,且流水线具备 OIDC 身份(如 GitHub Actions 的 id-token 权限)。

原理说明

理解以下三点,配置会顺畅很多:

  • 签名绑定 digest,而不是 tag。tag 是可变的,攻击者可以把 web:1.0 重新指向另一个镜像。cosign sign 会自动把 tag 解析为 manifest digest,并对 digest 签名。因此验证与部署都应使用 @sha256:... 形式。
  • 签名本身也是一个 OCI 制品。cosign 把签名数据以 sha256-<digest>.sig 为 tag 存放在同一仓库中,所以签名随镜像一同分发,无需额外签名服务器,但要求仓库支持 OCI artifact。
  • keyless 用身份替代密钥。keyless 模式下,签名者用 OIDC 令牌向 Fulcio 换取短期证书,签名行为写入 Rekor 透明日志。验证时不再依赖公钥,而是校验”证书身份是否匹配预期 + 记录是否在透明日志中”。这消除了私钥保管问题,但引入对 Fulcio/Rekor 的依赖。

攻击面决定验证策略:如果攻击者能修改镜像内容,验证必然失败;如果攻击者能拿到签名私钥,则需要靠密钥轮换与 Rekor 记录审计发现。因此私钥与 CI 变量必须纳入密钥管理范围,不能写进仓库。

操作步骤

步骤一:生成签名密钥对

# 本地生成,交互输入两次口令
cosign generate-key-pair
# 生成 cosign.key(私钥,务必保密)与 cosign.pub(公钥,可公开分发)

# CI 环境用环境变量传入口令,非交互
COSIGN_PASSWORD='强随机口令' cosign generate-key-pair

# 校验密钥与权限
ls -l cosign.key cosign.pub
chmod 600 cosign.key

cosign.key 必须存入密钥管理系统(如 Vault、云 KMS),禁止提交到代码仓库。建议每季度轮换一次,轮换后旧公钥保留一段时间用于验证历史镜像。

步骤二:签名镜像

# 构建并推送镜像
docker build -t registry.example.com/app/web:1.0 .
docker push registry.example.com/app/web:1.0

# 取得不可变的 digest 引用
DIGEST_REF=$(docker inspect --format='{{index .RepoDigests 0}}' \
  registry.example.com/app/web:1.0)
echo "$DIGEST_REF"

# 用私钥对 digest 签名(cosign 会自动把 tag 归一为 digest)
cosign sign --key cosign.key "$DIGEST_REF"

签名完成后可在仓库中看到名为 sha256-xxxx.sig 的制品。签名必须针对 digest 执行,直接对 tag 签名的做法在 tag 被覆盖后即失去意义。

步骤三:验证签名

# 用公钥验证
cosign verify --key cosign.pub "$DIGEST_REF"

# 需要看签名载荷与证书时输出 JSON
cosign verify --key cosign.pub "$DIGEST_REF" | jq .

# 未签名或签名不匹配时,命令以非 0 退出码返回,可直接用于流水线门禁
if cosign verify --key cosign.pub "$DIGEST_REF" > /dev/null 2>&1; then
  echo "signature OK"
else
  echo "signature FAILED" && exit 1
fi

步骤四:配置 keyless 签名(推荐用于开源与 CI 场景)

# GitHub Actions 等具备 OIDC 身份的环境中,直接签名,无需本地私钥
cosign sign --yes "$DIGEST_REF"

# 验证 keyless 签名:必须同时限定身份与 OIDC 签发者
cosign verify "$DIGEST_REF" \
  --certificate-identity-regexp '^https://github\.com/your-org/your-repo/.*$' \
  --certificate-oidc-issuer 'https://token.actions.githubusercontent.com'

验证 keyless 签名时,两个参数缺一不可。只校验 issuer 会让任意使用同一 OIDC 提供方的仓库都能通过;只校验 identity 则无法限定签发来源。证书身份必须收紧到具体仓库或工作流。

步骤五:附着并校验 SBOM

# 生成 SBOM(syft 或镜像内构建工具均可)
syft registry.example.com/app/web:1.0 -o spdx-json > sbom.spdx.json

# 附着到镜像
cosign attach sbom --sbom sbom.spdx.json "$DIGEST_REF"

# 同时对 SBOM 做签名,防止 SBOM 被替换
cosign sign --key cosign.key --attachment sbom "$DIGEST_REF"

# 校验 SBOM 证明
cosign verify-attestation --type spdxjson --key cosign.pub "$DIGEST_REF" | jq .

步骤六:在 Kubernetes 准入门强制校验

以 Kyverno 的 verifyImages 为例,未通过验证的镜像会被直接拒绝创建:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signature
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: check-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "registry.example.com/app/*"
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
                      -----END PUBLIC KEY-----
                  # keyless 场景改用 keyless 条目
                  # keyless:
                  #   issuer: https://token.actions.githubusercontent.com
                  #   subject: https://github.com/your-org/your-repo/.github/workflows/build.yaml@refs/heads/main
kubectl apply -f verify-image-policy.yaml
kubectl get clusterpolicy verify-image-signature

使用 sigstore policy-controller 时,改为声明 ClusterImagePolicy,通过 policy.sigstore.dev/include: "true" 标签限定生效的命名空间。两种方案都要注意:校验对象必须是 digest,如果工作负载里写的是 tag,准入阶段会把 tag 解析为 digest 后验证,但一旦 tag 被覆盖,滚动更新会拉到新镜像并触发验证失败——这属于预期行为,也是签名机制起作用的表现。

步骤七:接入 CI 流水线门禁

# GitHub Actions 关键片段
# permissions 必须包含 id-token: write 才能 keyless 签名
jobs:
  build:
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: sigstore/cosign-installer@v3
      - name: Build and push
        run: |
          docker build -t $IMAGE:${{ github.sha }} .
          docker push $IMAGE:${{ github.sha }}
      - name: Sign image
        run: |
          DIGEST_REF=$(docker inspect --format='{{index .RepoDigests 0}}' \
            $IMAGE:${{ github.sha }})
          cosign sign --yes "$DIGEST_REF"
      - name: Verify before deploy
        run: |
          cosign verify "$DIGEST_REF" \
            --certificate-identity-regexp \
              "^https://github\.com/${{ github.repository }}/.*$" \
            --certificate-oidc-issuer \
              "https://token.actions.githubusercontent.com"

配置验证

检查项 验证命令 期望结果
签名制品已写入仓库 cosign triangulate "$DIGEST_REF" 输出 sha256-xxx.sig 引用
签名验证通过 cosign verify --key cosign.pub "$DIGEST_REF" 退出码 0,输出签名信息
篡改后验证失败 对未签名镜像执行 verify 退出码非 0,提示 no matching signatures
keyless 身份已限定 带 identity-regexp 与 issuer 的 verify 身份不匹配时验证失败
SBOM 已附着且签名 cosign verify-attestation --type spdxjson 返回 SBOM 内容
准入门策略生效 kubectl apply 未签名镜像 被拒绝,提示签名校验失败

关键回归测试是主动篡改验证:把一个未签名镜像改名为已签名镜像的 tag 并推送,确认 cosign verify 失败、Kubernetes 拒绝创建 Pod。只有这一步通过,才能证明门禁真正生效,而不是配置摆设。

常见问题

FAQ 1:只对 tag 签名行不行?为什么必须绑定 digest?

不行。tag 是可变指针,攻击者或误操作都能把 web:1.0 重新指向另一个镜像。若签名针对 tag 的当前指向,tag 被覆盖后签名与实际镜像内容就不再对应。正确做法是:

# 错误做法:依赖 tag
cosign sign --key cosign.key registry.example.com/app/web:1.0

# 正确做法:先解析为 digest,再签名与部署
DIGEST_REF=$(docker inspect --format='{{index .RepoDigests 0}}' \
  registry.example.com/app/web:1.0)
cosign sign --key cosign.key "$DIGEST_REF"
kubectl set image deploy/web web="$DIGEST_REF"

另外应开启仓库的不可变 tag 策略(Harbor 支持 immutable tag),从机制上防止 digest 被覆盖。

FAQ 2:keyless 与私钥签名怎么选?内网离线环境怎么办?

维度 私钥签名 keyless 签名
密钥管理 需自建保管与轮换流程 无需保管私钥
外部依赖 无,可完全离线 依赖 Fulcio 与 Rekor
可追溯性 靠内部流程记录 透明日志可公开审计
适用场景 内网、专有云、离线交付 CI/CD、开源、多团队协作

纯内网或离线环境选私钥签名,把 cosign.key 存入 Vault 或硬件密码模块。若希望内网也用 keyless,可以自建 Fulcio 与 Rekor 实例并设置 --fulcio-url、--rekor-url 指向内部服务,但维护成本明显高于私钥方案。

FAQ 3:签名存在哪里?私有仓库需要什么前提?

签名与镜像存放在同一个仓库,以 sha256-<digest>.sig 为 tag 的 OCI 制品形式存在,随镜像一起分发。前提是仓库支持 OCI artifact:

  • Harbor 需 2.5 以上版本,并在项目中允许 OCI artifact 类型。
  • 标准 registry v2 需支持 manifest list 与自定义 artifact type。
  • 若仓库不支持,签名推送会报错,此时需要升级仓库或改用支持该能力的托管服务。

另外要注意仓库的垃圾回收策略:清理未被 tag 引用的制品时可能把签名一并删除,需在保留策略中把 .sig 制品排除。

总结

容器镜像签名验证的落地要点可以压缩成四句话:签名永远绑定 digest 而不是 tag;验证必须同时限定签名身份与 OIDC 签发者;准入控制要用 Enforce 模式并做一次主动篡改回归测试;私钥与 SBOM 同样需要纳入密钥管理和签名范围。实施顺序建议为:先在 CI 中生成密钥对并对镜像签名,用 cosign verify 打通流水线门禁,再部署 Kyverno 或 policy-controller 在集群侧强制校验,最后补齐 SBOM 附着与签名,把镜像的来源、完整性、成分三项证据串成完整链条。这样即便镜像仓库或构建环境被入侵,未签名的投毒镜像也无法进入生产集群。