概述

先说一个让我后怕的事。

去年我们一个内部镜像仓库的权限配置出了问题,松了几天。事后复盘虽然没发现被人动过手脚,但那几天我心里一直打鼓——万一有人往仓库里推了个同名 tag 的镜像,把 app:latest 换成他自己的版本,我们的部署流水线照样拉、照样跑,谁能发现?

digest 确实会变。但那时候我们的部署根本没人去核对 digest。镜像这东西,默认的逻辑是"谁能推就信谁"。仓库里躺着一个 app:v1.2.3,你怎么知道它是你的 CI 构建推上去的,而不是别人冒名顶替推的?

答案很扎心:光看,你不知道。得有签名。

这篇文章讲的是怎么用 cosign(Sigstore 生态的核心工具)给容器镜像签名和验证,把"镜像从构建到运行"这条链条上的信任问题解决掉。核心思路是:CI 构建完镜像后签名,部署前验证签名,验不过的镜像根本不让进生产环境。

镜像供应链的安全威胁

在讲方案之前,得先搞清楚我们在防什么。

攻击路径

容器镜像供应链攻击的核心特点是:攻击者不需要直接入侵你的生产服务器,他只需要在镜像到达生产之前的任何一个环节动手脚。

典型的攻击路径:

攻击者
  ├─ 路径1:上传恶意基础镜像到 Docker Hub
  │         → 开发者基于该镜像构建应用
  │         → 恶意代码进入 CI/CD 流水线
  │         → 部署到生产环境
  ├─ 路径2:入侵 CI 系统,篡改构建产物
  │         → 推送同名 tag 的篡改镜像
  │         → 部署系统拉取"最新"镜像
  │         → 后门进入生产
  ├─ 路径3:中间人攻击,篡改镜像传输
  │         → 拦截 docker pull 请求
  │         → 返回篡改后的镜像层
  └─ 路径4:内部人员误操作或恶意操作
            → 直接推送未经验证的镜像到生产仓库

路径 1 和路径 2 是最常见的。2024 年安全研究人员在 Docker Hub 上发现了超过 10000 个镜像泄露了生产系统的敏感凭证。Sysdig 的容器安全报告显示,超过 65% 的容器镜像包含已知高危漏洞——这些漏洞往往来自基础镜像。

为什么传统方案不够用

传统方案问题
只用 HTTPS 传输防了路径 3(中间人),但防不了路径 1 和 2
仓库权限管控防不了 CI 被攻破后推送篡改镜像
digest 固定防了 tag 被重新指向,但没法验证"这个 digest 的镜像确实是你构建的"
GPG 签名密钥管理太反人类,私钥存储、轮换、吊销全是大坑

digest 固定(digest pinning)是最常见的做法——在部署配置里写死镜像的 SHA256 digest,不用 tag。这确实防止了 tag 被重新指向的问题。但它解决不了"这个 digest 的镜像到底是谁构建的"这个信任问题。攻击者完全可以推一个新镜像,拿到新 digest,然后想办法让你的部署配置更新成那个新 digest。

签名解决的就是这个信任问题:它能证明"这个镜像确实是由指定的构建系统(比如你的 GitHub Actions workflow)构建并签名的,构建之后没被篡改过"。

Sigstore 生态:让签名不再反人类

老办法为什么没人愿意搞

签名这事二十年前就有了,GPG 那一套。问题是密钥管理太反人性。

你得生成一对密钥。私钥得妥善保管——存哪?放 CI 的 secret 里?那 CI 被攻破私钥就泄了。私钥还会过期、要轮换、人离职了得吊销。然后公钥怎么分发给验证方、怎么建立信任链,又是一摊事。

流程重到大部分团队签着签着就放弃了,“反正内部用,应该没事吧”。这种心理就是安全防护失效的开始。

Sigstore 的核心创新:无密钥签名

Sigstore 是 Linux 基金会旗下的开源项目,它的核心创新叫 keyless signing(无密钥签名)。不是说真的没有密钥,而是密钥临时生成、用完就扔。

先说人话:传统的签名方式就像你有一枚私人印章,随身带着,每次盖章都用它——印章丢了或被盗就完蛋。keyless 签名就像你每次盖章都临时刻一枚新印章,盖完就把印章销毁,而"你确实盖过这个章"这个事实,由一个权威的公证处(Sigstore 的证书颁发机构 Fulcio)做担保,并记录在一个所有人都能查的公开账本(透明日志 Rekor)上。

整个流程串起来是这样:

签名时:
  1. cosign 通过 OIDC 让你的 CI 证明身份
     → "我是 GitHub Actions 里 xxx 仓库的工作流"
  2. Fulcio(证书颁发机构)签发一张短期证书
     → 有效期就几分钟,过了就作废
  3. 用这张证书对应的临时私钥完成签名
  4. 私钥扔掉——不需要保管
  5. 签名记录 + 证书写入 Rekor(透明日志)
     → 不可篡改、可公开查询

验证时:
  1. 从 Rekor 查签名记录和证书
  2. 验证证书确实由 Fulcio 签发
  3. 验证证书里的身份信息(OIDC issuer + identity)
  4. 验证签名与镜像 digest 匹配
  5. 全部通过 → 信任这个镜像

没有长期私钥要保管,自然就没有私钥泄露和轮换的问题。这是 Sigstore 最聪明的地方。

Sigstore 三件套

组件作用类比
Cosign签名和验证工具印章的使用者
Fulcio证书颁发机构(CA)公证处
Rekor透明日志(Transparency Log)公开账本

三者协作:cosign 发起签名 → Fulcio 基于 OIDC 身份签发短期证书 → 签名记录写入 Rekor。验证时反过来:从 Rekor 查记录 → 验 Fulcio 证书 → 验身份和签名。

cosign 实战

安装

# Linux
curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64"
mv cosign-linux-amd64 /usr/local/bin/cosign
chmod +x /usr/local/bin/cosign

# macOS
brew install cosign

# 验证安装
cosign version

方式一:keyless 签名(推荐)

keyless 签名是 Sigstore 的核心能力,也是我最推荐生产环境使用的方式。

# 签名:签的是 digest 不是 tag
# tag 是可变指针,今天指这个明天能被重新指向另一个
# digest 是内容哈希,内容变一个字节它就变,签它才有意义
cosign sign registry.example.com/app@sha256:abc123def456...

# 本地执行时会弹浏览器让你通过 OIDC 登录
# CI 里通常配置 OIDC token,自动完成身份验证

为什么必须签 digest 不签 tag?tag 只是个可变的指针,今天指向你的镜像,明天能被人重新指向另一个镜像。你签一个 tag 等于什么也没保证——验签通过的时候 tag 指向的可能已经不是原来那个镜像了。digest 是内容的 SHA256 哈希,内容变一个字节它就变,签 digest 才有实际意义。

方式二:密钥对签名

有些场景不适合用 keyless(比如内网环境没法连 Fulcio),可以用传统的密钥对方式:

# 生成密钥对
# 会提示设置密码,私钥用这个密码加密
cosign generate-key-pair
# 生成两个文件:
#   cosign.key - 私钥(必须保密保管)
#   cosign.pub - 公钥(分发给验证方)

# 用私钥签名
cosign sign --key cosign.key \
  registry.example.com/app@sha256:abc123def456...

# 用公钥验证
cosign verify --key cosign.pub \
  registry.example.com/app@sha256:abc123def456...

密钥对方式的缺点前面说了:私钥的保管、轮换、吊销都是负担。但如果你的环境不允许联外网(没法用 Fulcio 的 OIDC),这是唯一选择。

私钥保管建议:别放在 CI 的 secret 里——CI 被攻破私钥就泄了。用 HashiCorp Vault、AWS KMS、Azure Key Vault 这类密钥管理系统,cosign 原生支持对接。

验证:签了不验等于白签

签名只是第一步,真正挡住掉包的是验证——而且要卡在准入的位置,让验不过的镜像根本进不了生产。

# keyless 验证 - 必须指定身份和 issuer
# 这两个参数是关键,新手最容易漏
cosign verify \
  --certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  registry.example.com/app@sha256:abc123def456...

验证不能只验"这镜像有签名"——攻击者也能给自己的镜像签个名。你要验的是"这个签名来自我信任的构建系统"。

  • --certificate-identity:指定你信任的构建身份(比如你的 GitHub Actions workflow 的 URL)
  • --certificate-oidc-issuer:指定 OIDC 签发方(GitHub Actions 是 https://token.actions.githubusercontent.com

这两个参数一起构成"我信任谁构建的镜像"这个白名单。只有身份和 issuer 都匹配的签名才算通过。

CI/CD 集成:GitHub Actions

把签名嵌入 CI 流水线,让每次构建的镜像自动签名。

配置 OIDC 权限

GitHub Actions 做 keyless 签名需要先开启 OIDC 权限。在 workflow 文件里加:

# .github/workflows/build-and-sign.yml
name: Build and Sign

on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write   # 关键:开启 OIDC token 写权限
  packages: write

jobs:
  build-sign:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Login to registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build image
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:latest
          # 关键:同时输出 digest,签名要用
          outputs: type=oci,dest=/tmp/image.tar

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Get image digest
        id: digest
        run: |
          DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/${{ github.repository }}:latest | cut -d'@' -f2)
          echo "digest=$DIGEST" >> $GITHUB_OUTPUT
          echo "ref=ghcr.io/${{ github.repository }}@${DIGEST}" >> $GITHUB_OUTPUT          

      - name: Sign image (keyless)
        run: |
          cosign sign --yes \
            ghcr.io/${{ github.repository }}@${{ steps.digest.outputs.digest }}          
        # --yes 跳过交互确认,CI 环境必须加

      - name: Verify signature
        run: |
          cosign verify \
            --certificate-identity "https://github.com/${{ github.repository }}/.github/workflows/build-and-sign.yml@refs/heads/main" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ghcr.io/${{ github.repository }}@${{ steps.digest.outputs.digest }}          
        # 签完立即验一遍,确认签名有效

      - name: Attach SBOM (可选但推荐)
        run: |
          # 生成 SBOM 并附加到镜像
          # SBOM = Software Bill of Materials,软件物料清单
          syft ghcr.io/${{ github.repository }}@${{ steps.digest.outputs.digest }} -o spdx-json > /tmp/sbom.spdx.json
          cosign attach sbom --sbom /tmp/sbom.spdx.json \
            ghcr.io/${{ github.repository }}@${{ steps.digest.outputs.digest }}

          # 签名 SBOM,防止 SBOM 被篡改
          cosign sign --yes \
            --attachment sbom \
            ghcr.io/${{ github.repository }}@${{ steps.digest.outputs.digest }}          

完整流水线的安全检查点

一个完整的供应链安全流水线不止签名这一步,应该是一串检查点:

阶段安全动作工具
代码提交pre-commit 钩子检查密钥泄露git-secrets, trufflehog
构建依赖漏洞扫描(SCA)syft + grype
构建镜像漏洞扫描trivy
推送镜像签名cosign
推送SBOM 生成与附加syft + cosign
部署签名验证cosign verify
部署策略验证(Kyverno/OPA)Kyverno

签名是这串检查点里的一环。不要只做签名不做漏洞扫描——一个有签名的漏洞镜像照样是定时炸弹。

Kubernetes 准入验证

签名放在 CI 里还不够——如果有人绕过 CI 直接推镜像到仓库,再手动部署呢?真正的防线要放在 Kubernetes 集群入口,让没通过签名验证的镜像根本部署不进来。

方案一:Kyverno 策略

Kyverno 是一个 Kubernetes 策略引擎,可以写策略验证镜像签名。这是我最推荐的方式,配置简单,策略即代码。

# kyverno-verify-images.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce  # Enforce=拒绝,Audit=只记录
  background: false
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-ghcr-images
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"  # 只验证自己仓库的镜像
          mutateDigest: true      # 自动把 tag 替换成 digest
          attestors:
            - count: 1
              entries:
                - keyless:
                    subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev

这个策略做的事:

  1. 匹配所有 ghcr.io/myorg/* 的镜像
  2. mutateDigest: true 自动把 tag 替换成 digest(防止 tag 指向被篡改)
  3. 用 keyless 方式验证签名,指定信任的 subject 和 issuer
  4. 从 Rekor 透明日志查签名记录
  5. Enforce 模式下,验不过直接拒绝部署

部署这个策略后,任何人尝试部署一个没有有效签名的 ghcr.io/myorg/* 镜像,都会被 Kubernetes 拒绝。

方案二:cosign webhook

如果不想装 Kyverno,可以用 cosign 自带的 admission webhook。配置稍复杂但功能够用:

# cosign 的 admission controller 配置
apiVersion: v1
kind: ConfigMap
metadata:
  name: cosign-webhook-config
  namespace: cosign-system
data:
  config.yaml: |
    # 指定允许的镜像和对应的验证方式
    allow:
      - pattern: "ghcr.io/myorg/*"
        verify_config:
          keyless:
            subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
            issuer: "https://token.actions.githubusercontent.com"
      - pattern: "docker.io/library/*"
        # 官方镜像不需要验证签名
        skip_verify: true    

豁免策略

不是所有镜像都需要签名验证。比如 pausekube-proxy 这些系统组件,还有 docker.io/library/* 的官方镜像。策略里要加白名单:

# Kyverno 策略加白名单
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  background: false
  webhookTimeoutSeconds: 30
  rules:
    - name: exclude-system-namespaces
      match:
        any:
          - resources:
              kinds:
                - Pod
      exclude:
        any:
          - resources:
              namespaces:
                - kube-system
                - kube-public
                - kube-node-lease
                - cosign-system
                - kyverno
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          # ... 验证配置

进阶:证明(Attestation)

签名只证明了"这个镜像是我构建的"。但供应链安全还需要回答更多问题:这个镜像用了什么基础镜像?构建时的依赖列表是什么?扫描过漏洞吗?通过了哪些测试?

这些信息用"证明"(Attestation)来记录。attestation 是附加在镜像上的结构化数据,本身也被签名。

生成 SBOM 证明

# 用 syft 生成 SBOM
syft ghcr.io/myorg/app@sha256:abc123... -o spdx-json > sbom.spdx.json

# 用 cosign 附加 SBOM 证明(会自动签名)
cosign attest --yes \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/myorg/app@sha256:abc123...

# 验证 SBOM 证明
cosign verify-attestation \
  --certificate-identity "https://github.com/myorg/app/.github/workflows/build.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --type spdxjson \
  ghcr.io/myorg/app@sha256:abc123...

漏洞扫描证明

# 用 grype 扫描漏洞
grype ghcr.io/myorg/app@sha256:abc123... -o json > vuln-scan.json

# 附加扫描结果作为证明
cosign attest --yes \
  --predicate vuln-scan.json \
  --type vuln \
  ghcr.io/myorg/app@sha256:abc123...

多层验证策略

有了多层证明,可以在 Kyverno 里写更细粒度的策略:只允许有 SBOM 证明且没有 Critical 漏洞的镜像部署。

# Kyverno 多层验证策略
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-sbom-and-scan
spec:
  validationFailureAction: Enforce
  background: false
  webhookTimeoutSeconds: 30
  rules:
    - name: check-signature-and-sbom
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - count: 1
              entries:
                - keyless:
                    subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
                    issuer: "https://token.actions.githubusercontent.com"
          attestations:
            - type: spdxjson
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
                        issuer: "https://token.actions.githubusercontent.com"
            # 还可以加漏洞扫描证明的验证
            - type: vuln
              attestors:
                - count: 1
                  entries:
                    - keyless:
                        subject: "https://github.com/myorg/*/.github/workflows/*.yml@refs/heads/main"
                        issuer: "https://token.actions.githubusercontent.com"

实战踩坑总结

落地 cosign 的过程中踩过的几个坑:

坑 1:签 tag 不签 digest。 最早图省事直接 cosign sign ghcr.io/myorg/app:latest。后来发现 tag 被重新指向了另一个镜像,验签照样通过——因为签名验证的是 tag 对应的 digest,tag 变了 digest 也变了,但旧签名还在仓库里。正确做法:永远签 digest,Kyverno 策略里开 mutateDigest: true 强制把 tag 转成 digest。

坑 2:验证只验"有签名"不验身份。 cosign verify 不加 --certificate-identity--certificate-oidc-issuer 参数,只验证"镜像有签名",不验证"签名来自谁"。攻击者给自己的镜像签个名也能通过。这两个参数必须加。

坑 3:webhook 超时。 keyless 验证需要查 Rekor 透明日志,网络不好时会超时。Kubernetes 默认 webhook 超时 10 秒,建议调到 30 秒。如果经常超时,考虑用 --certificate-identity 缩小查询范围,或者部署本地 Rekor 实例。

坑 4:公网依赖。 keyless 签名和验证都依赖 Sigstore 的公共服务(Fulcio、Rekor)。内网环境用不了。这种情况只能用密钥对方式,或者自部署 Sigstore 私有实例(成本不低,建议团队规模大时再考虑)。

坑 5:密钥对签名的私钥管理。 用密钥对签名时,私钥放哪都是问题。放过 CI secret、放过 K8s Secret、放过员工电脑。最后用 HashiCorp Vault 管理才踏实。cosign 原生支持 Vault 后端,配置好后 cosign sign --key hashivault:// 就行,私钥永远不出 Vault。

总结

容器镜像签名解决的是供应链信任问题:证明"这个镜像确实是我构建的,构建后没被篡改"。没有签名,你的部署系统没法区分"可信镜像"和"被掉包的镜像"。

落地路径建议分三步走:

  1. 第一步:CI 签名。 在构建流水线末端加 cosign sign,用 keyless 方式,零密钥管理成本。这是最轻量的起步,不需要改部署侧任何东西。

  2. 第二步:部署验证。 装 Kyverno,配 verifyImages 策略,把签名验证卡在 Kubernetes 准入层。没通过验证的镜像拒绝部署。这才是真正生效的防线。

  3. 第三步:多层证明。 加 SBOM 证明、漏洞扫描证明,在策略里要求镜像同时有签名和 SBOM 才能部署。供应链安全从"有签名"升级到"有签名 + 有物料清单 + 漏洞已扫描"。

几个关键决策:

  • 签名方式选 keyless 不选密钥对——除非内网环境没法联外网,keyless 省掉了所有密钥管理负担
  • 签 digest 不签 tag——tag 是可变指针,签它等于没签
  • 验证必须验身份——--certificate-identity--certificate-oidc-issuer 不能漏
  • 防线放在准入层不在 CI 层——CI 被绕过的可能性永远存在,Kubernetes 准入是最后一道门

供应链安全不是做了签名就万事大吉。签名是信任链的一个环节,配合漏洞扫描、SBOM、策略引擎一起用,才能形成完整的防护。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. 用 Sigstore 给镜像签名,下游能验来源不怕被掉包 — 百家号博主,Sigstore keyless 签名原理与 cosign 命令实战,本文核心方案的主要参考
  2. Docker 容器安全深度实战:从镜像构建到运行时防护的生产级安全体系 — 程序员茄子,容器供应链攻击路径分析与多阶段构建安全实践
  3. Red Hat Trusted Application Pipeline 文档 — Red Hat,企业级镜像签名验证与策略合规框架
  4. 2026 DevSecOps 工程师面试实战50题:CI/CD 安全+容器逃逸防御+供应链投毒检测全指南 — CSDN 博主,DevSecOps 供应链安全检查点全景与安全左移实践
  5. Docker 镜像库的安全合规与信创适配实践指南(2026) — 博客园博主,镜像漏洞扫描、SBOM 生成与等保合规实践