`gh attestation verify` said yes to an image we never released
gh attestation verify said yes to an image we never released
gh attestation verify 对一个我们从未发布的镜像给出了肯定结果
The four questions, and the one nobody asks. Pulling a container image somebody else built asks four separate questions. Most people answer three of them and skip the fourth: 四个问题,以及那个无人问津的问题。拉取他人构建的容器镜像时,实际上涉及四个独立的问题。大多数人回答了前三个,却忽略了第四个:
| Step | Question it answers | If you skip it |
|---|---|---|
| Fetch by digest | are these the bytes I asked for | you are gambling on a mutable tag |
| Verify provenance | who built these bytes, from what, where | unknown origin |
| Read the SBOM | what is inside | a CVE lands and you cannot tell if you are affected |
| Check the release manifest | is this digest the one that version shipped | you may be verifying a different build |
| 步骤 | 回答的问题 | 如果跳过会怎样 |
|---|---|---|
| 按摘要拉取 | 这是我请求的字节吗 | 你是在拿可变标签赌博 |
| 验证来源 | 谁构建了这些字节,基于什么,在哪里 | 来源不明 |
| 读取 SBOM | 里面有什么 | 当 CVE 出现时,你无法判断自己是否受影响 |
| 检查发布清单 | 这个摘要是该版本发布的那个吗 | 你可能验证的是一个不同的构建 |
The fourth is the one that has no substitute, and it is the one that is almost always skipped. What follows is all four, run for real against our own v1.0.0. 第四个问题是无可替代的,也是几乎总是被忽略的。接下来,我们将针对我们自己的 v1.0.0 版本,对这四个问题进行实际操作演示。
1. An image wears more than one sha256
1. 一个镜像拥有不止一个 sha256
This is where the confusion starts. Get an anonymous pull token — a public package needs no account: 困惑由此开始。获取一个匿名拉取令牌——公共包不需要账户:
TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:soit-ai/soit/server:pull&service=ghcr.io" \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
Ask what the v1.0.0 tag resolves to: 查询 v1.0.0 标签指向的内容:
curl -sI -H "Authorization: Bearer $TOKEN" \
-H "Accept: application/vnd.oci.image.index.v1+json" \
https://ghcr.io/v2/soit-ai/soit/server/manifests/v1.0.0
856 bytes: this is an image index, not the image. Inside it, two entries: 856 字节:这是一个镜像索引,而非镜像本身。其中包含两个条目:
That unknown/unknown entry is not corruption — it is BuildKit’s own attestation for the amd64 manifest, and it is a different mechanism from the Sigstore signatures we are about to read. First thing people conflate.
那个 unknown/unknown 条目并非损坏——它是 BuildKit 为 amd64 清单提供的自身证明,这与我们即将读取的 Sigstore 签名是不同的机制。这是人们最容易混淆的第一点。
One level down, the amd64 manifest says: 深入一层,amd64 清单显示:
Three hashes so far, three different meanings: 到目前为止有三个哈希值,代表三种不同的含义:
| Name | Value for server v1.0.0 | Refers to |
|---|---|---|
| index digest | 96b80ae1…5929 | the multi-platform index itself |
| platform manifest digest | 84e7f539…d32f | the linux/amd64 image |
| config digest | 02181498…cd1f | the image config JSON |
| 名称 | server v1.0.0 的值 | 指向 |
|---|---|---|
| 索引摘要 | 96b80ae1…5929 | 多平台索引本身 |
| 平台清单摘要 | 84e7f539…d32f | linux/amd64 镜像 |
| 配置摘要 | 02181498…cd1f | 镜像配置 JSON |
The signature covers the first one. A fourth hash shows up in section 8, and it is none of these. 签名覆盖的是第一个。第四个哈希值出现在第 8 节中,它不属于上述任何一个。
2. Pulling the signatures without Docker and without logging in
2. 在不使用 Docker 且不登录的情况下拉取签名
The OCI spec defines a referrers API for “things attached to this thing”: OCI 规范定义了一个用于“附加到此对象的事物”的引用者 API:
curl -s -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/referrers/sha256:96b80ae1...5929
Same error for the platform manifest digest. Do not conclude “unsigned” from this. The spec has a fallback: replace the colon with a dash and treat the digest as a tag. 平台清单摘要也会出现同样的错误。不要因此得出“未签名”的结论。规范有一个回退机制:将冒号替换为连字符,并将摘要视为标签。
List the tags and there it is: 列出标签,结果如下:
The last one is ours. Remember the first two — section 9 is entirely about them. Fetching sha256-96b80ae1… returns a small index holding two Sigstore bundles.
最后一个是我们的。记住前两个——第 9 节专门讨论它们。获取 sha256-96b80ae1… 会返回一个包含两个 Sigstore 束(bundle)的小索引。
Build provenance and an SBOM attestation, signed nine seconds apart. Both manifests carry subject pointing back at sha256:96b80ae1…5929 — the single place where “this signature belongs to that image” is actually written down.
构建来源证明和 SBOM 证明,签名时间仅相差九秒。两个清单都带有指向 sha256:96b80ae1…5929 的主体(subject)——这是唯一真正记录“此签名属于该镜像”的地方。
Pull the bundle blob and hash it: 拉取束 blob 并对其进行哈希计算:
curl -sL -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/blobs/sha256:eae5d902...dd86 -o bundle.json
sha256sum bundle.json
The hash equals the digest I asked for. A content-addressed registry means this step verifies itself; no trust required yet. 该哈希值与我请求的摘要一致。内容寻址注册表意味着这一步可以自我验证;目前还不需要信任。
3. What the signature actually covers
3. 签名实际覆盖的内容
The bundle has three top-level fields — mediaType, verificationMaterial, dsseEnvelope. The envelope’s payloadType is application/vnd.in-toto+json, and the base64 payload is the only thing the signature covers.
该束包含三个顶级字段——mediaType、verificationMaterial 和 dsseEnvelope。信封的 payloadType 是 application/vnd.in-toto+json,而 base64 载荷是签名唯一覆盖的内容。
One complete sentence: commit 8105cae… on refs/tags/v1.0.0, built by .github/workflows/release.yml on a GitHub-hosted runner in run 31023642816, produced the image with digest 96b80ae1….
总结成一句话:在 refs/tags/v1.0.0 上的提交 8105cae…,由 .github/workflows/release.yml 在 GitHub 托管的运行器上构建(运行 ID 为 31023642816),生成了摘要为 96b80ae1… 的镜像。
I had the repository on disk, so I closed the loop immediately: 我的磁盘上有该仓库,所以我立即闭环验证了这一点:
git rev-parse v1.0.0^{commit} # 8105cae074f1f27d7916acfe02f9d4eabb63169f
Match. Note repository_id and repository_owner_id: numeric IDs are worth more than the repos.
匹配成功。注意 repository_id 和 repository_owner_id:数字 ID 比仓库名称更有价值。