「镜像我验过了,gh attestation verify 是通过的。」
这句话我自己也说过。直到我把我们家 v1.0.0 的三个镜像挨个扒了一遍, 才发现它的信息量比我以为的小得多。
我在自己的注册表里找到了三个都能验证通过的 server v1.0.0 镜像 ------ 同一个仓库、同一条工作流、同一个 tag 签的名,但对应三个不同的 commit, 其中两个从来就不是我们发布的那个。验签命令对着它们照样 exit 0。
这篇就是那一晚的实录:从 curl 匿名拿 token 开始,一层层剥到那张只活了十分钟的 签名证书,最后回答一个问题------跑起来之前,你到底该验什么。
先看结论
跑一个别人给的容器镜像之前,有四件不同的事要做,它们回答四个不同的问题:
| 动作 | 回答的问题 | 失败了意味着 |
|---|---|---|
| 按 digest 取镜像 | 我拿到的字节和我要的是不是同一串 | 你在跟 tag 赌运气 |
| 验构建来源证明 | 这串字节是谁、用什么、在哪儿造的 | 来路不明 |
| 读 SBOM | 这里面装了些什么 | 出了 CVE 你不知道自己中没中 |
| 核发布清单 | 这串 digest 是不是那个版本发布的那一个 | 你验的可能是另一个构建 |
最后一件最容易被跳过,而它恰恰是前三件都替代不了的。 下面用我们自己的 v1.0.0 把这四件事做一遍,全部是 2026-09-12 当晚的实测输出。
一、先把三个 sha256 分清楚
这是所有混乱的起点:一个镜像身上挂着不止一个 sha256,它们指的东西不一样。
匿名拿一个 GHCR 的 pull token(公开包不需要账号):
bash
TOKEN=$(curl -s "https://ghcr.io/token?scope=repository:soit-ai/soit/server:pull&service=ghcr.io" \
| sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
然后问 v1.0.0 这个 tag 指向什么:
bash
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
响应头里那行 docker-content-digest 就是索引摘要:
plaintext
docker-content-digest: sha256:96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929
Content-Type: application/vnd.oci.image.index.v1+json
Content-Length: 856
856 字节,说明它是一份 image index(多平台清单),不是镜像本体。把它取回来看,里面两条:
plaintext
manifests[0]: sha256:84e7f539...d32f 2589 字节 linux/amd64
manifests[1]: sha256:a6bd430d...e1be 565 字节 unknown/unknown
annotations:
vnd.docker.reference.type: attestation-manifest
vnd.docker.reference.digest: sha256:84e7f539...d32f
第二条那个 unknown/unknown 不是坏数据,是 BuildKit 自己给 amd64 那份清单挂的构建证明 。 注意它和后面要讲的 Sigstore 签名是两套东西------这是第一个容易混的地方。
再往下一层,84e7f539... 这份 amd64 清单里写着:
plaintext
config: sha256:021814982fcc214a02a2b322cbc3729b3ce43bd6e191aa0fa39da540a3e3cd1f 8769 字节
layers: 12 层,压缩后合计 684216450 字节(约 652 MiB)
到这里已经有三个 sha256 了,各有各的意思:
| 叫法 | 值(v1.0.0 的 server) | 指的是 |
|---|---|---|
| 索引摘要 | 96b80ae1...5929 |
多平台清单本身 |
| 平台清单摘要 | 84e7f539...d32f |
linux/amd64 那一份 |
| 配置摘要 | 02181498...cd1f |
镜像配置 JSON |
签名签的是第一个。 后面第八节还会冒出第四个 sha256,它谁都不是。
二、不用 Docker、不用登录,把签名扒出来
OCI 规范给「某个东西的附属品」定义了一个 referrers API。按规范该这么问:
bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/referrers/sha256:96b80ae1...5929
实测回的是:
plaintext
{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}
换成那份 amd64 清单的摘要问,一样的错。别慌,也别据此下「没有签名」的结论------ 规范里还有一条回退路径:把摘要里的冒号换成横线,当成一个 tag 去查。列一下 tag 就看见了:
bash
curl -s -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/tags/list
plaintext
{"name":"soit-ai/soit/server","tags":[
"v1.0.0",
"sha256-3a5b3b1a2d14e0646298826602124ba22e63f8c078f653e506a0a042bfd18246",
"sha256-236201a5a3ee861ffdb5fdd7ec134454619eb1ab0e777439c4a22a65002f874f",
"sha256-96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"]}
最后那个正是我们要的。前面两个先记住,第九节全靠它们。 把 sha256-96b80ae1... 当 tag 取回来,是一份小索引,挂着两个 Sigstore bundle:
plaintext
sha256:658b3428...80b9 812 字节
artifactType : application/vnd.dev.sigstore.bundle.v0.3+json
predicateType: https://slsa.dev/provenance/v1
created : 2026-08-05T16:14:12.946Z
sha256:59bac8c5...d9cf 814 字节
artifactType : application/vnd.dev.sigstore.bundle.v0.3+json
predicateType: https://spdx.dev/Document/v2.3
created : 2026-08-05T16:14:22.004Z
一份是构建来源证明 ,一份是 SBOM 证明 ,相隔 9 秒钟签出来。 各自的 manifest 里 subject 都指回 sha256:96b80ae1...5929,也就是索引摘要------ 这是「这份签名属于哪个镜像」的唯一绑定处。
取 bundle 本体(就是 manifest 里那唯一一层的 blob):
bash
curl -sL -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/blobs/sha256:eae5d902...dd86 -o bundle.json
sha256sum bundle.json
plaintext
eae5d902186b490570aba6f702f7ce03c46f935e403bf881ec27d99480d7dd86 *bundle.json
10405 字节
算出来的哈希和我请求的那个 blob 摘要一模一样------注册表是内容寻址的, 这一步本身就是一次校验,不需要信任任何人。
三、签名里写了什么:一份 SLSA provenance
bundle.json 是一份 Sigstore bundle,三个顶层字段:
plaintext
mediaType : application/vnd.dev.sigstore.bundle.v0.3+json
verificationMaterial : certificate / tlogEntries / timestampVerificationData
dsseEnvelope : payloadType / payload / signatures
dsseEnvelope.payloadType 是 application/vnd.in-toto+json,payload 是 base64。 解开就是签名真正覆盖的那段字节:
json
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{
"name": "ghcr.io/soit-ai/soit/server",
"digest": {"sha256": "96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"}
}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://actions.github.io/buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/tags/v1.0.0",
"repository": "https://github.com/soit-ai/soit",
"path": ".github/workflows/release.yml"
}
},
"internalParameters": {
"github": {
"event_name": "push",
"repository_id": "910429753",
"repository_owner_id": "193298865",
"runner_environment": "github-hosted"
}
},
"resolvedDependencies": [{
"uri": "git+https://github.com/soit-ai/soit@refs/tags/v1.0.0",
"digest": {"gitCommit": "8105cae074f1f27d7916acfe02f9d4eabb63169f"}
}]
},
"runDetails": {
"builder": {"id": "https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0"},
"metadata": {"invocationId": "https://github.com/soit-ai/soit/actions/runs/31023642816/attempts/1"}
}
}
}
这份声明说的是一句完整的话:「refs/tags/v1.0.0 上的 commit 8105cae..., 经 .github/workflows/release.yml 在 GitHub 托管的 runner 上、第 31023642816 次运行, 产出了摘要为 96b80ae1... 的这个镜像。」
我手上就有这个仓库,于是当场对了一下:
bash
git rev-parse v1.0.0^{commit}
# 8105cae074f1f27d7916acfe02f9d4eabb63169f
一致。 从注册表上的一串字节,到我本地磁盘上的一个 commit,这条线接上了。 注意 repository_id 和 repository_owner_id 这两个数字 ID------ 它们比仓库名值钱,因为仓库可以改名、可以转移,数字 ID 不会变。
四、签名是谁签的:一张只活了十分钟的证书
verificationMaterial.certificate.rawBytes 是一张 DER 证书,1735 字节。存下来用 openssl 看:
bash
openssl x509 -inform DER -in cert.der -noout -issuer -subject -dates -ext subjectAltName
plaintext
issuer=O = sigstore.dev, CN = sigstore-intermediate
subject=
notBefore=Aug 5 16:14:11 2026 GMT
notAfter =Aug 5 16:24:11 2026 GMT
X509v3 Subject Alternative Name: critical
URI:https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0
两处值得停一下:
第一,subject 是空的。 传统证书里放主体名的那一格什么都没有,身份整个搬到了 SAN 那条 URI 上------它不是一个人、不是一个组织,是一条工作流在某个 ref 上的身份。
第二,有效期正好十分钟(16:14:11 到 16:24:11)。这就是 keyless 签名的做法: 流水线拿 OIDC token 换一张短命证书,签完就扔,私钥不落盘、也不需要谁去保管。 十分钟之后这张证书过期------但签名依旧可验,靠的是下一节那条透明日志记录。
证书里还塞了一组 Sigstore 自定义扩展,全是构建现场的信息。以下是实测读到的值 (OID 的正式命名见 Fulcio 的 OID 文档,本文只记我读到的值,不替它做命名):
| OID 后缀 | 值 |
|---|---|
.1.1 / .1.8 |
https://token.actions.githubusercontent.com |
.1.2 / .1.20 |
push |
.1.3 / .1.10 / .1.13 / .1.19 |
8105cae074f1f27d7916acfe02f9d4eabb63169f |
.1.4 |
release |
.1.5 |
soit-ai/soit |
.1.6 / .1.14 |
refs/tags/v1.0.0 |
.1.9 / .1.18 |
.../release.yml@refs/tags/v1.0.0 |
.1.11 |
github-hosted |
.1.12 |
https://github.com/soit-ai/soit |
.1.15 / .1.17 |
910429753 / 193298865 |
.1.16 |
https://github.com/soit-ai |
.1.21 |
.../actions/runs/31023642816/attempts/1 |
.1.22 |
public |
.1.24 |
repo:soit-ai/soit:ref:refs/tags/v1.0.0 |
Extended Key Usage 是 Code Signing,Key Usage 只有 Digital Signature。 最后还有一条 CT 预证书 SCT,时间戳 Aug 5 16:14:11.958 2026 GMT------ 这张证书的签发行为本身也被公开记了一笔。
五、证书过期了,签名为什么还算数:透明日志
verificationMaterial.tlogEntries 里只有一条:
plaintext
logIndex : 2346649359
integratedTime : 1785946452 → 2026-08-05T16:14:12Z
kindVersion : {kind: dsse, version: 0.0.1}
这条记录在 Rekor(公开的 append-only 透明日志)里。它的作用是给时间作证 : 验证方要判断的不是「这张证书现在有效吗」------它早过期了------而是 「签名发生的那一刻,这张证书有效吗 」。日志里这条记录把时刻钉死在 16:14:12Z, 而证书的有效窗口是 16:14:11 到 16:24:11,落在里面。
顺带一个实测细节:这份 bundle 的 timestampVerificationData 是个空对象, 也就是说没有走 RFC 3161 的可信时间戳,时间完全由 Rekor 那条记录承担。 这不是错,是这条流水线当时的选择,但值得知道。
六、SBOM 挂在哪儿、里面有什么
另一个 bundle(predicateType 是 https://spdx.dev/Document/v2.3)解开之后, predicate 就是一整份 SPDX 文档。bundle 本体 3437645 字节,约 3.3 MiB:
plaintext
spdxVersion : SPDX-2.3
dataLicense : CC0-1.0
name : ghcr.io/soit-ai/soit/server
creationInfo : {"creators": ["Organization: Anchore, Inc", "Tool: syft-1.42.3"],
"licenseListVersion": "3.28",
"created": "2026-08-05T16:14:08Z"}
packages : 710
relationships : 2840
710 个包按 purl 类型分:
| 类型 | 个数 |
|---|---|
pkg:deb |
469 |
pkg:pypi |
217 |
pkg:generic |
1(python 3.11.15) |
pkg:oci |
1(镜像自己) |
469 个 deb 包是基础镜像带来的 (server/Dockerfile 用的是 python:3.11, Debian 底子),217 个才是我们自己的 Python 依赖。这个比例本身就是一条信息: 你签的名里,三分之二的内容不是你写的。
三个镜像横着比一下,能直接看出它们的分工:
| 镜像 | 包数 | 主要构成 | SBOM 生成时刻 |
|---|---|---|---|
server |
710 | 469 deb + 217 pypi | 16:14:08Z |
knowledge-worker |
775 | 469 deb + 281 pypi | 16:22:23Z |
web |
1331 | 1311 npm + 18 apk | 16:15:01Z |
knowledge-worker 比 server 多出来的 64 个 pypi 包里有 torch、transformers、 nvidia-cublas-cu12------它们是同一个 Dockerfile 的两个 target,差别就是 uv sync --extra knowledge-worker 那一句(server/Dockerfile:19 对 :27)。 web 是 node:24-alpine,所以 deb 变成了 18 个 apk。
七、SBOM 里为什么没有文件清单:16 MiB 那道限制
SPDX 文档正常应该有一个 files 段,记录每个包是从镜像里哪些文件认出来的。 这三份里都没有 。不是被删了藏着掖着,是流水线明着删的 (.github/workflows/release.yml:132--149):
bash
jq 'del(.files)
| .packages |= map(del(.hasFiles))
| .relationships |= map(select(
((.spdxElementId // "") | startswith("SPDXRef-File") | not)
and ((.relatedSpdxElement // "") | startswith("SPDXRef-File") | not)))' \
"$f" > "$f.tmp"
mv "$f.tmp" "$f"
size="$(stat -c%s "$f")"
test "$size" -le 16000000
工作流里那段注释写了原因:逐文件的 SPDX 条目会把带 ML 依赖的镜像 SBOM 顶过 actions/attest 16 MiB 的 subject 上限。 换句话说,这是一次被迫的取舍: 为了让 SBOM 能被签名,先把它瘦到能被签名。
代价是实打实的:留下的是包清单和包与包之间的关系,丢掉的是「这个包是从哪个文件 认出来的」 。出了 CVE 要定位到具体文件时,你得自己再扫一遍镜像。 后面那行 test "$size" -le 16000000 是道硬门槛------真顶到上限,流水线会在这里断掉, 而不是产出一份签不了名的 SBOM。这个设计我认为是对的:宁可断,不要半成品。
八、SBOM 不是事实,是扫描结果
这一节想说的话只有一句:SBOM 是一次扫描的输出,不是镜像内容的真相。 两个实测到的例子。
例一:一个 Linux 镜像里有五个 Windows 启动器。 710 个包里, Simple Launcher 1.1.0.14 出现了 5 次 ,每一条都只有 CPE、没有 purl:
plaintext
SPDXRef-Package-binary-Simple-Launcher-d60858f6579e7bb1
versionInfo : 1.1.0.14
externalRefs: cpe:2.3:a:Simple_Launcher:Simple_Launcher:1.1.0.14:*:*:*:*:*:*:*
这是 syft 的二进制分类器认出来的------Python 打包工具的 wheel 里躺着几个 Windows 的 可执行启动器,它们在这个 Linux 镜像里一行都不会被执行 ,但照样进了清单。 删掉 files 段之后连「它在哪个路径」都查不到了(第七节那笔账在这里付的)。
例二:一个对不上的 sha256。 那条代表镜像自己的 pkg:oci 记录长这样:
plaintext
pkg:oci/ghcr.io%2Fsoit-ai%2Fsoit%2Fserver@sha256%3Ae7c993b9ac5d7322058e0169c678b4ce21fe42fac72c5d3374404bf4642939ea?arch=amd64
e7c993b9... 这个值,既不是索引摘要 96b80ae1...,也不是 amd64 清单摘要 84e7f539...,也不是配置摘要 02181498...。我拿它去注册表查:
plaintext
GET /v2/soit-ai/soit/server/manifests/sha256:e7c993b9... → 404
注册表里根本没有这个对象。 我没能查清它到底是什么(合理的猜测是扫描器在本地 处理镜像时算出来的某个内部标识),所以这里只报告观测,不给结论。 但实践上的意思很清楚:读 SBOM 时,别拿文档内部那个 digest 去和签名里的 subject 对账------它们不是一回事,对不上属正常。 签名的绑定只有一处,就是 subject.digest,那个值是 96b80ae1...,和注册表返回的完全一致。
九、我们注册表里有三个都能验证通过的 v1.0.0
回到第二节记下的那两个 tag。把它们也当 referrers 回退标签取回来看:
| 回退标签对应的镜像摘要 | 挂着的证明 | 签名时刻 |
|---|---|---|
3a5b3b1a...8246 |
只有 provenance | 2026-08-05T15:41:15Z |
236201a5...f874f |
provenance + SBOM | 15:52:32Z / 15:52:44Z |
96b80ae1...5929 |
provenance + SBOM | 16:14:12Z / 16:14:22Z |
解开前两个的 provenance,ref 都是 refs/tags/v1.0.0,签名身份也都是同一条 release.yml,但 commit 不是同一个:
| 镜像摘要 | provenance 里的 commit | Actions run | 那个 commit 的标题 |
|---|---|---|---|
3a5b3b1a... |
0dacfc52... |
31020921135 | ci(release): create the artifacts directory before image SBOM generation |
236201a5... |
ec822c63... |
31021873557 | ci(release): catalog packages only in image SBOMs |
96b80ae1... |
8105cae0... |
31023642816 | ci(release): trim image SBOMs to package level before attestation |
三个 commit 在本地都能 git cat-file 到,都在 main 上,时间依次是 23:33、23:44、00:05(东八区)。结论很直白:那天晚上 tag 被重打过两次 , 前两次发布流程各自失败在 SBOM 那一步(看 commit 标题就知道在修什么), 而每一次失败之前,镜像已经推上去并且签过名了。
于是就有了这个结果。用官方命令验一个从来没被发布过的镜像:
bash
gh attestation verify \
oci://ghcr.io/soit-ai/soit/server@sha256:3a5b3b1a...8246 \
--repo soit-ai/soit --format json
plaintext
exit code : 0
subject : 3a5b3b1a...8246
predicate : https://slsa.dev/provenance/v1
ref : refs/tags/v1.0.0
commit : 0dacfc5219c4ae57d024f9cb4208e1efad5f0d17
san : https://github.com/soit-ai/soit/.github/workflows/release.yml@refs/tags/v1.0.0
tlog : rekor.sigstore.dev @ 2026-08-05T23:41:11+08:00
通过。exit 0。 签名没有任何问题,它诚实地说出了自己是什么: 一个由我们的 release 工作流、在 refs/tags/v1.0.0 上、从 commit 0dacfc52... 构建的镜像。 它唯一没说的是------它不是我们发布的那个。
这里没有攻击、没有伪造,三个镜像都是我们自己造的。 但把它换成一个更坏的情境立刻就成立了:一条被污染过一次、后来修好并重新打 tag 的流水线, 残留在注册表里的那个中间产物,验签是验不出来的 。 gh attestation verify 回答的是「这是不是那条流水线的产物 」, 它从来没承诺回答「这是不是那个发布」。
十、缺的那一环:一份把 tag、commit、digest 绑死的清单
补上这一环的东西不在签名里,在 GitHub Release 的资产里。我们那次发布挂了 7 个文件:
plaintext
soit-1.0.0.tar.gz 3148831
SHA256SUMS 433
release-artifacts.json 2877
server.spdx.json 3313874
knowledge-worker.spdx.json 4004516
web.spdx.json 3862614
source.spdx.json 3694033
release-artifacts.json 就是那份清单。核心部分:
json
{
"featureKey": "release.artifacts",
"schemaVersion": 1,
"version": "1.0.0",
"release_tag": "v1.0.0",
"commit": "8105cae074f1f27d7916acfe02f9d4eabb63169f",
"clean_worktree_at_tag": true,
"images": [{
"component": "server",
"name": "ghcr.io/soit-ai/soit/server",
"release_tag": "v1.0.0",
"digest": "sha256:96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929",
"reference": "ghcr.io/soit-ai/soit/server@sha256:96b80ae1...5929",
"sbom": {"format": "spdx-json", "path": "server.spdx.json", "sha256": "b83c3bc7...ef68", "attestation": "..."},
"provenance_attestation": "..."
}]
}
这份文件说的是第九节里签名不肯说的那句话:v1.0.0 这个版本, 对应的 server 镜像是 96b80ae1...,不是另外两个。
它不是手写的,是流水线在 publish-release 那个 job 里用 jq 拼出来的 (release.yml:300--347),拼完当场校验:
bash
python server/scripts/verify_release_artifacts.py artifacts/release-artifacts.json
我把公网上下下来的那份直接喂给仓库里的同一个脚本:
plaintext
{
"commit": "8105cae074f1f27d7916acfe02f9d4eabb63169f",
"images": ["knowledge-worker", "server", "web"],
"passed": true,
"release_tag": "v1.0.0"
}
exit=0
这个脚本 178 行(server/scripts/verify_release_artifacts.py), 纯标准库,不联网。它逐条要求:
release_tag必须等于v加version(:42);commit必须是 40 位小写十六进制,且不能是全零(:45);clean_worktree_at_tag必须是true(:49);- 每个镜像的
reference必须等于name@digest(:91)------ 也就是说,写成name:tag直接不合格; digest必须匹配^sha256:[0-9a-f]{64}$(:88);- 三个镜像一个不能少、一个不能多(
REQUIRED_IMAGES,:15与:114); - 所有证明 URL 两两不重(
:107--112),防止同一条证明被复制粘贴到多个位置。
仓库里还有一个测试盯着这件事:它把示例文档的 reference 改成 tag 形式, 然后断言脚本必须以 digest-pinned 报错 (server/tests/unit/test_release_operations_contract.py:85--88)。
十一、源码包我在 Windows 上按位重放出来了
镜像之外还有源码包。工作流用 git archive 生成(release.yml:253--263), 叫「确定性归档」。这个说法值不值钱,试一次就知道。先按 SHA256SUMS 核下载:
bash
grep "soit-1.0.0.tar.gz" SHA256SUMS | sha256sum -c -
# ./soit-1.0.0.tar.gz: OK
再在本地照同样的方式生成一份:
bash
git archive --format=tar.gz --prefix="soit-1.0.0/" \
--output=local.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
sha256sum local.tar.gz soit-1.0.0.tar.gz
plaintext
c11179c379ba7390c215133f342c92a7517a548f1536959cb43e187b16a7bab3 *local.tar.gz
5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *soit-1.0.0.tar.gz
对不上。 而且不是压缩层的差异------把 gzip 剥掉、比里面的 tar,照样不一样。 逐个成员比对之后原因很清楚:
plaintext
成员数 : 本地 1551 / 发布 1551 ------ 一样
只在一侧 : 0 / 0 ------ 一样
大小不同 : 1280 个
mtime : 全部 1785945959 ------ 一样
mode : 全部 0664 ------ 一样
示例:soit-1.0.0/.github/workflows/quality.yml 本地 15270 发布 14803
只有大小差,差值正好等于各文件的行数------是 CRLF 。 我这台机器 core.autocrlf=true,git archive 顺手把换行改了。关掉再来:
bash
git -c core.autocrlf=false archive --format=tar.gz --prefix="soit-1.0.0/" \
--output=local2.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
sha256sum local2.tar.gz
plaintext
5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *local2.tar.gz
一个字节不差。 一台 Windows 机器、git 2.55.0、一个月之后,重放出了 GitHub Actions 上生成的那个源码包。
这件事的两面都值得记住:「确定性」是真的 ------git archive 不写构建时间戳, mtime 取的是 commit 时间,所以跨机器可复现;但它对本地 git 配置敏感 , 复现失败的第一嫌疑人永远是 core.autocrlf,不是发布方作假。
十二、如果你要验,照这个顺序做
把前面十一节压成一段可以直接抄走的流程。以我们的 v1.0.0 为例, 换成你自己要验的项目同理:
bash
# ① 拿到 digest(别用 tag 记账,tag 会动)
docker buildx imagetools inspect ghcr.io/soit-ai/soit/server:v1.0.0 | head -3
# ② 验构建来源:这串字节是谁造的
gh attestation verify oci://ghcr.io/soit-ai/soit/server:v1.0.0 --repo soit-ai/soit
# ③ 验 SBOM 证明(默认不验,得显式要)
gh attestation verify oci://ghcr.io/soit-ai/soit/server:v1.0.0 --repo soit-ai/soit \
--predicate-type https://spdx.dev/Document/v2.3
# ④ 核发布清单:这个 digest 是不是那个版本发布的那一个
curl -sLO https://github.com/soit-ai/soit/releases/download/v1.0.0/release-artifacts.json
curl -sLO https://github.com/soit-ai/soit/releases/download/v1.0.0/SHA256SUMS
sha256sum -c SHA256SUMS
# ⑤ 按 digest 跑,不要按 tag 跑
docker pull ghcr.io/soit-ai/soit/server@sha256:96b80ae1...5929
第 ③ 步值得单独说:gh attestation verify 默认只验构建来源。 实测不加 --predicate-type 时返回 1 条结果,就是那份 SLSA provenance; 加上之后才返回 SPDX 那份(6022876 字节的 JSON,里面 710 个包,和第六节数出来的一致)。 很多人以为「验过了」包含 SBOM,其实没有。
第 ④ 步是这篇的全部意义。少了它,第 ② 步的绿色对第九节里那个 3a5b3b1a... 同样会亮。
十三、现在还对不上的八个地方
按惯例,这一节讲我们自己的问题。
① 我们自己的 compose 用 tag,不用 digest。 docker/docker-compose.images.yml 六个服务全是 ghcr.io/soit-ai/soit/server:${SOIT_IMAGE_TAG:-v1.0.0} 这种写法 (:17 到 :32),README 也这么写(README.md:146)。一边在 release-artifacts.json 里要求 digest 绑定,一边让用户按 tag 拉 ,口径是不一致的。 影响:tag 可以被重新指向,用户拿到的不一定是清单里那个。 绕法:用第十二节第 ⑤ 步按 digest 拉。 这条我打算提一个 issue,写这篇时还没提,所以正文里不挂链接。
② SHA256SUMS 里没有 release-artifacts.json 自己。 实测那份 433 字节的 SHA256SUMS 只有 5 行:四份 spdx 加一个源码包, 清单文件本身和 SHA256SUMS 自己都不在里面 (顺序上也确实没法在)。 release-artifacts.json 有自己的证明(流水线 release.yml:350--353 给它单签了一份), 所以不是没保护,但「下载全部资产然后 sha256sum -c 一把过」这个直觉动作覆盖不到它 。 影响:有人可能以为核完 SHA256SUMS 就核完了。这条也打算提 issue。
③ docs/release-process.md 落后于流水线。 文档写的是「发布之后,把真实的 tag、 commit、镜像摘要、SBOM 校验和、证明 URL 抄进 一份基于示例的证据文档,然后运行 verify_release_artifacts.py」(:24 到 :32)。但流水线早就自动干了这件事 , 而且把产物当成发布资产传了上去。文档描述的是一个已经被自动化取代的手工流程, 也没提用户可以直接下载那份 release-artifacts.json。 影响:读文档的人会以为这份清单是事后手写的,可信度反而打折。这条也打算提 issue。
④ 过期的构建没有清理。 第九节那两个镜像现在还在 GHCR 上,可以拉、可以验、 可以跑。我们没有「发布完成后清理同 tag 的中间产物」这一步。 影响:如前所述,验签救不了这种情况。 绕法目前只有一个------按 release-artifacts.json 里的 digest 拉。
⑤ 校验脚本只看形状,不联网核对。 verify_release_artifacts.py 对 provenance_attestation 的要求只有「是个非空字符串」(:58、:64、:106 都走 同一个 _require_text)。它不会去访问那个 URL,也不会去比对证明里的 subject digest 和清单里的 digest 是否一致。 影响:一份编得漂亮但 URL 全是假的清单,能通过这个脚本。 绕法:脚本是结构 校验,不是信任 校验,真正的信任来自 gh attestation verify, 两步都要做。这个分工我认为是合理的,但文档里没写清楚。
⑥ REQUIRED_IMAGES 是写死的三个。 verify_release_artifacts.py:15 写死了 {"server", "knowledge-worker", "web"},第 114 行要求完全相等。 哪天加第四个镜像,忘了改这里就会在发布的最后一步炸掉。 影响:发布日踩雷。绕法:知道它在那儿就行------而且它挡住的场景 (少了一个镜像也照发)比它带来的麻烦更值钱。
⑦ SBOM 丢了文件级信息,且没有人工复核假阳性的流程。 第七、八两节讲过。 Simple Launcher 这种条目没人负责标注「这是假阳性」,下一个读 SBOM 的人 还得重新判断一次。影响:SBOM 的信噪比随镜像变大而变差。
⑧ 我没能查清 SBOM 里那个 e7c993b9... 是什么。 第八节的观测到此为止。 这一条写在这里是为了不装懂:我只能确认它不在注册表里、和另外三个 digest 都不同, 不能告诉你它是怎么算出来的。
坦白局
- 本篇有实跑,但实跑的是「验证」,不是「运行」。 我没有把这三个镜像拉下来起一套环境, 本文的所有结论都停在「字节和签名」这一层。
- 第九节不是安全事件。 那三个镜像都是我们自己在同一晚构建的, 没有任何一方被入侵。我用它来说明的是验签语义的边界,不是「我们被攻击了」。
- 第八节那个对不上的 digest,我给的是观测不是结论。 猜测已经标明是猜测。
- 本文所有数字对应
v1.0.0这一次发布 (tag commit8105cae...)。 后续发布的数值会变,方法不变。 gh attestation verify的退出码语义我只实测了成功路径(exit 0)。 我没有构造一个签名被篡改的镜像去看它怎么失败。- 利益相关:我是 SOIT 的维护者。
一句话结论
验签通过只说明「这串字节出自那条流水线」,不说明「这就是那个发布」 ------ 把这两件事接起来的,是一份把 tag、commit 和 digest 绑死的清单; 在我们这儿它叫 release-artifacts.json,178 行的脚本盯着它, 而它最硬的一条断言只有一句:引用必须写成 name@digest,写成 tag 形式直接不合格。
来试试,也来挑刺
仓库在 github.com/soit-ai/soit。这篇里的每一步你都能自己跑一遍:
- 第二节那几条
curl不需要任何账号,公开包匿名 token 就够; - 第九节那个
3a5b3b1a...现在还在,你可以自己验一次,看它是不是真的 exit 0; - 第十一节的按位重放,记得先加
-c core.autocrlf=false。
如果你发现我哪一条说错了------尤其是第八节那个我没查清的 digest------ 欢迎直接开 issue 打脸。比起被夸「流程做得挺全」,我更需要知道哪里对不上。