容器镜像签名实战:用 cosign 与 Sigstore 给镜像上锁,部署前验签挡住供应链投毒

你拉下来的镜像,真的是你构建的那个吗

假设你在服务器上这样部署一个服务:

docker pull ghcr.io/someuser/myapp:latest
docker run -d --name app ghcr.io/someuser/myapp:latest

然后一切正常运行。但这里藏着一个几乎没人认真想过的问题:你凭什么相信 ghcr.io/someuser/myapp:latest 这个镜像就是你上次 push 上去的那个?

镜像仓库是用「账号密码 / 访问令牌」保护推送的,但保护的是「谁能推」,不是「推上来的东西有没有被动过」。一旦你的仓库凭据泄露、CI 流水线被入侵、或者中间有人做了缓存投毒,攻击者可以推一个「同名 tag」的恶意镜像上去,而你的服务器拉取时不会做任何校验——它只认 tag 名字。这就是典型的供应链攻击:你没有犯错,攻击者攻陷了你依赖的某一环。

Sigstore 和 cosign 就是为了解决这个问题:给镜像签名,部署前验签。签名证明「这个镜像的字节级内容确实是由某个身份构建、且之后没被改过」。这篇文章讲清楚 cosign 的原理、怎么签名验签、怎么在 CI 和服务器上落地,最后列出几个一定要避开的坑。

先理解原理:镜像签名到底签的是什么

很多人以为签名是「把一段签名数据塞进镜像里」,其实不是。cosign 用的是分离签名(detached signature)的变体:

  1. 计算镜像的内容摘要(digest),比如 sha256:1a2b3c...。这个 digest 由镜像的 manifest 决定,manifest 又由各层的 digest 组成——也就是说,镜像里任何一个字节变了,digest 必然变。
  2. 用你的私钥对这个 digest 签名,得到一段签名。
  3. 把签名(以及公钥/证书)作为一个独立的 OCI artifact,推到同一个仓库里、关联到这个镜像的 digest 上。

验证时反过来:拉取镜像 → 重新计算 digest → 用公钥验证签名 → 确认签名对应的 digest 和当前镜像一致。任何环节对不上,验签就失败。

关键点:签名绑定的是 digest,不是 tag。tag(比如 latest)是可变的、可以指向任何 digest,所以永远不要「验证 tag」。部署时必须用 digest(image@sha256:...),或者「验签后立即按 digest 部署」,才能避免验签和运行之间被偷换。

cosign 有两种签名模式:

  • 密钥对模式(key pair):自己生成一对公私钥,私钥签名、公钥验证。离线可控,简单直接,适合个人和小团队。
  • 无密钥模式(keyless):不做长期密钥,借助 Sigstore 的 Fulcio(签发短期证书)和 Rekor(透明日志)。签名身份绑定到 OIDC(比如 GitHub Actions 的身份),签名事件被记录在公开的透明日志里,任何人都能审计。这是 Sigstore 的招牌能力,也是 GitHub Actions 里最省心的方式。

个人站长如果只是给自己构建的镜像加一道校验,密钥对模式就够了,我们就从这里开始。

生成密钥对并给镜像签名

先装 cosign,单个二进制,放 PATH 就行:

# 从官方 release 拿最新版(示例版本号按需替换)
curl -LO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
cosign version

生成密钥对。强烈建议给私钥设密码:

cosign generate-key-pair
# 会提示输入私钥密码(回车留空表示不加密,不推荐)
# 生成 cosign.key(私钥)和 cosign.pub(公钥)

cosign.key 是私钥,绝对不要进 Git、不要传公共仓库;cosign.pub 是公钥,可以随便分发,部署端要有。

给镜像签名前,先构建并推送镜像,拿到 digest:

docker build -t ghcr.io/someuser/myapp:v1.2.0 .
docker push ghcr.io/someuser/myapp:v1.2.0

# 拿到 digest
docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/someuser/myapp:v1.2.0
# 形如 ghcr.io/someuser/myapp@sha256:1a2b3c...

然后用 digest 签名(注意:用 digest 而非 tag):

cosign sign --key cosign.key \
  ghcr.io/someuser/myapp@sha256:1a2b3c...

# 会提示输入私钥密码,然后上传签名 artifact 到仓库

验证签名:

cosign verify --key cosign.pub \
  ghcr.io/someuser/myapp@sha256:1a2b3c...

验证通过会输出签名者的身份信息(证书/OIDC issuer 等),失败则报错并返回非零退出码。这个退出码非常有用——可以直接在部署脚本里 if cosign verify ...; then docker run ...; fi。

在 GitHub Actions 里用无密钥签名(最省心)

自己管私钥有个躲不开的麻烦:私钥放哪?放 GitHub Secrets 里,一旦仓库或 CI 被攻陷,私钥就泄露了,攻击者可以用你的身份签任何恶意镜像,验签反而成了帮凶。Sigstore 的无密钥模式就是为了消除这个长期私钥:

  • CI 运行时,Sigstore 通过 OIDC 验证「你现在确实是 GitHub Actions 里 someuser/myapp 这个仓库的 workflow」。
  • 验证通过后,Fulcio 签发一张有效期只有几分钟的短期证书,绑定这次构建的身份。
  • cosign 用这张短期证书签名,并把签名事件写进 Rekor 透明日志(不可篡改、公开可查)。
  • 证书很快过期,但签名记录留在日志里,事后仍能验证「是那个时刻、那个身份签的」。

GitHub Actions 里的配置非常短:

name: build-and-sign
on:
  push:
    tags: ['v*']
jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write        # 无密钥签名必须开,用于 OIDC
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push
        run: |
          docker build -t ghcr.io/${{ github.repository }}:${{ github.ref_name }} .
          docker push ghcr.io/${{ github.repository }}:${{ github.ref_name }}
      - uses: sigstore/cosign-installer@v3
      - name: Sign the image
        run: |
          cosign sign --yes \
            ghcr.io/${{ github.repository }}@$(docker inspect --format='{{index .RepoDigests 0}}' \
            ghcr.io/${{ github.repository }}:${{ github.ref_name }} | cut -d@ -f2)

id-token: write 这行权限是灵魂,没有它 OIDC 拿不到,无密钥签名会失败。签名完成后,任何人都可以用下面的命令验证,不需要你提供任何公钥:

cosign verify \
  --certificate-identity-regexp 'https://github.com/someuser/myapp/.*' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  ghcr.io/someuser/myapp@sha256:1a2b3c...

这条命令的含义是:「验证这个镜像的签名,且签名者必须是 GitHub Actions 里 someuser/myapp 仓库的身份、颁发者必须是 GitHub 的 OIDC。」如果攻击者用别的身份签了个镜像,这里会直接拒绝——这正是无密钥模式比密钥对更安全的地方:你验的不是「谁有这把钥匙」,而是「谁在什么条件下构建的」。

把验签接进部署流程

签了名不验,等于没签。真正有意义的是部署前强制验签。写一个部署脚本,把验签作为前置门禁:

#!/usr/bin/env bash
set -euo pipefail

IMAGE_DIGEST="$1"   # 形如 ghcr.io/someuser/myapp@sha256:...

# 1. 验签,失败直接退出
cosign verify \
  --key /etc/cosign/cosign.pub \
  "$IMAGE_DIGEST"

# 2. 验签通过,按 digest(不是 tag)部署
docker pull "$IMAGE_DIGEST"
docker stop myapp 2>/dev/null || true
docker rm myapp 2>/dev/null || true
docker run -d --name myapp --restart unless-stopped "$IMAGE_DIGEST"

echo "deployed: $IMAGE_DIGEST"

两个细节要盯死:

  • 部署用 digest。即使前面验的是 tag 对应的 digest,docker run 时也要用同一个 digest,否则 tag 在这几秒内被重指向,你运行的就是另一个未验证的镜像。
  • 公钥要放对地方。公钥文件在部署机上,权限设成只读,不要跟镜像放一起(放一起等于让攻击者能一起改)。

如果你用 Kubernetes,更省事的做法是用 policy-controller(Sigstore 的策略控制器)或者 Kyverno/OPA Gatekeeper 配一条 admission policy:集群里所有 Pod 只能运行通过验签的镜像,没签名的直接拒绝调度。相当于把「验签」从「你记得做」变成「集群强制做」。

五个必踩的坑

坑一:验 tag 而不是验 digest。 最常见的错误。tag 可变、可被重指向,验签通过后按 tag 拉取,中间有任何改动你都发现不了。永远验 digest、永远按 digest 部署。

坑二:私钥进 Git 或明文放 CI 变量。 密钥对模式下,私钥泄露等于签名体系崩盘。私钥要么本地加密保存、要么放专门的密钥管理(Vault/SOPS),CI 里通过安全注入使用。能用无密钥模式就别用长期私钥。

坑三:签了 latest 就到处用 latest。 latest 每次构建都指向新 digest,昨天的签名对今天的 latest 无效。生产上给镜像打不可变 tag(版本号或 commit sha),签名也对那个具体 digest 签,别用浮动 tag。

坑四:只签不验,或者验了不阻断。 有些流程「验签」只是打印一行日志,失败也照常部署。那签名就是个装饰。验签失败必须让部署硬失败(set -e + 非零退出),否则等于没做。

坑五:以为签名能防住一切。 签名只保证「镜像没被篡改、来源可信」,防不住「你签的那个镜像本身就是带漏洞的」。签名要配合镜像扫描(Trivy、Grype 扫 CVE)、SBOM(cosign attach sbom)、最小基础镜像一起用。签名是供应链安全的一环,不是全部。

给个人站的一条务实路径

如果你一个人维护几个服务,不需要一上来就搞全套 Sigstore,可以按这个顺序推进:

  1. 先固定 digest。哪怕不签名,部署时也用 image@sha256:...,别用 latest。这一步零成本,就能挡住 tag 被偷换。
  2. 加签名。GitHub Actions 构建的话直接上无密钥模式;本地构建就用密钥对,私钥加密保存。
  3. 部署脚本强制验签,失败即中止。
  4. 加镜像扫描,把高危 CVE 挡在上线之前。
  5. 有余力再上 admission policy,把验签变成集群级强制。

供应链安全听起来离个人站长很远,但你每天都在 docker pull 别人的镜像——基础镜像、中间件、开源工具,全是从公网拉下来的。cosign 给你的不只是一段签名,而是一种习惯:部署之前,先确认你拿到的确实是你要的那个东西。在一个镜像层层叠叠、依赖动辄上百个的世界里,这个习惯的价值只会越来越高。

Last modification:October 5th, 2026 at 09:25 pm

Leave a Comment