先说一个可能有点反直觉的事实:验证一个公开镜像的签名,不需要装 Docker,也不需要登录。
注册表就是一个 HTTP API。拿一个匿名 token,几个 curl 就能把 manifest、Sigstore 签名 bundle、SBOM 全部取回本地,然后用几行 Python 解开来读。
这篇把这个过程完整走一遍,对象是我们自己开源项目的 v1.0.0 镜像:从 OCI image index 开始,到 DSSE 信封里的 in-toto 声明,再到那张由 Fulcio 签发、有效期只有十分钟的证书里塞着的二十几个 OID。最后给一段可以直接抄走的验证流程。
跑一个别人给的容器镜像之前,有四件不同的事要做,它们回答四个不同的问题:
动作 | 回答的问题 | 失败了意味着 |
|---|---|---|
按 digest 取镜像 | 我拿到的字节和我要的是不是同一串 | 你在跟 tag 赌运气 |
验构建来源证明 | 这串字节是谁、用什么、在哪儿造的 | 来路不明 |
读 SBOM | 这里面装了些什么 | 出了 CVE 你不知道自己中没中 |
核发布清单 | 这串 digest 是不是那个版本发布的那一个 | 你验的可能是另一个构建 |
最后一件最容易被跳过,而它恰恰是前三件都替代不了的。下面用我们自己的 v1.0.0 把这四件事做一遍,全部是 2026-09-12 当晚的实测输出。
这是所有混乱的起点:一个镜像身上挂着不止一个 sha256,它们指的东西不一样。
匿名拿一个 GHCR 的 pull token(公开包不需要账号):
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 指向什么:
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 就是索引摘要:
docker-content-digest: sha256:96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929
Content-Type: application/vnd.oci.image.index.v1+json
Content-Length: 856856 字节,说明它是一份 image index(多平台清单),不是镜像本体。把它取回来看,里面两条:
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 清单里写着:
config: sha256:021814982fcc214a02a2b322cbc3729b3ce43bd6e191aa0fa39da540a3e3cd1f 8769 字节
layers: 12 层,压缩后合计 684216450 字节(约 652 MiB)到这里已经有三个 sha256 了,各有各的意思:
叫法 | 值(v1.0.0 的 server) | 指的是 |
|---|---|---|
索引摘要 |
| 多平台清单本身 |
平台清单摘要 |
| linux/amd64 那一份 |
配置摘要 |
| 镜像配置 JSON |
签名签的是第一个。 后面第八节还会冒出第四个 sha256,它谁都不是。
OCI 规范给「某个东西的附属品」定义了一个 referrers API。按规范该这么问:
curl -s -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/referrers/sha256:96b80ae1...5929实测回的是:
{"errors":[{"code":"MANIFEST_UNKNOWN","message":"manifest unknown"}]}换成那份 amd64 清单的摘要问,一样的错。别慌,也别据此下「没有签名」的结论——规范里还有一条回退路径:把摘要里的冒号换成横线,当成一个 tag 去查。列一下 tag 就看见了:
curl -s -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/tags/list{"name":"soit-ai/soit/server","tags":[
"v1.0.0",
"sha256-3a5b3b1a2d14e0646298826602124ba22e63f8c078f653e506a0a042bfd18246",
"sha256-236201a5a3ee861ffdb5fdd7ec134454619eb1ab0e777439c4a22a65002f874f",
"sha256-96b80ae141000adde27cf3dedb27935b5f5d0085d69025cffb4bbb63276d5929"]}最后那个正是我们要的。前面两个先记住,第九节全靠它们。把 sha256-96b80ae1… 当 tag 取回来,是一份小索引,挂着两个 Sigstore bundle:
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):
curl -sL -H "Authorization: Bearer $TOKEN" \
https://ghcr.io/v2/soit-ai/soit/server/blobs/sha256:eae5d902...dd86 -o bundle.json
sha256sum bundle.jsoneae5d902186b490570aba6f702f7ce03c46f935e403bf881ec27d99480d7dd86 *bundle.json
10405 字节算出来的哈希和我请求的那个 blob 摘要一模一样——注册表是内容寻址的,这一步本身就是一次校验,不需要信任任何人。
bundle.json 是一份 Sigstore bundle,三个顶层字段:
mediaType : application/vnd.dev.sigstore.bundle.v0.3+json
verificationMaterial : certificate / tlogEntries / timestampVerificationData
dsseEnvelope : payloadType / payload / signaturesdsseEnvelope.payloadType 是 application/vnd.in-toto+json,payload 是 base64。解开就是签名真正覆盖的那段字节:
{
"_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… 的这个镜像。」
我手上就有这个仓库,于是当场对了一下:
git rev-parse v1.0.0^{commit}
# 8105cae074f1f27d7916acfe02f9d4eabb63169f一致。 从注册表上的一串字节,到我本地磁盘上的一个 commit,这条线接上了。注意 repository_id 和 repository_owner_id 这两个数字 ID——它们比仓库名值钱,因为仓库可以改名、可以转移,数字 ID 不会变。
verificationMaterial.certificate.rawBytes 是一张 DER 证书,1735 字节。存下来用 openssl 看:
openssl x509 -inform DER -in cert.der -noout -issuer -subject -dates -ext subjectAltNameissuer=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 后缀 | 值 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Extended Key Usage 是 Code Signing,Key Usage 只有 Digital Signature。最后还有一条 CT 预证书 SCT,时间戳 Aug 5 16:14:11.958 2026 GMT——这张证书的签发行为本身也被公开记了一笔。
verificationMaterial.tlogEntries 里只有一条:
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 那条记录承担。这不是错,是这条流水线当时的选择,但值得知道。
另一个 bundle(predicateType 是 https://spdx.dev/Document/v2.3)解开之后,predicate 就是一整份 SPDX 文档。bundle 本体 3437645 字节,约 3.3 MiB:
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 : 2840710 个包按 purl 类型分:
类型 | 个数 |
|---|---|
| 469 |
| 217 |
| 1( |
| 1(镜像自己) |
469 个 deb 包是基础镜像带来的(server/Dockerfile 用的是 python:3.11,Debian 底子),217 个才是我们自己的 Python 依赖。这个比例本身就是一条信息:你签的名里,三分之二的内容不是你写的。
三个镜像横着比一下,能直接看出它们的分工:
镜像 | 包数 | 主要构成 | SBOM 生成时刻 |
|---|---|---|---|
| 710 | 469 deb + 217 pypi | 16:14:08Z |
| 775 | 469 deb + 281 pypi | 16:22:23Z |
| 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。
SPDX 文档正常应该有一个 files 段,记录每个包是从镜像里哪些文件认出来的。这三份里都没有。不是被删了藏着掖着,是流水线明着删的(.github/workflows/release.yml:132–149):
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 是一次扫描的输出,不是镜像内容的真相。两个实测到的例子。
例一:一个 Linux 镜像里有五个 Windows 启动器。 710 个包里,Simple Launcher 1.1.0.14 出现了 5 次,每一条都只有 CPE、没有 purl:
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 记录长这样:
pkg:oci/ghcr.io%2Fsoit-ai%2Fsoit%2Fserver@sha256%3Ae7c993b9ac5d7322058e0169c678b4ce21fe42fac72c5d3374404bf4642939ea?arch=amd64e7c993b9… 这个值,既不是索引摘要 96b80ae1…,也不是 amd64 清单摘要84e7f539…,也不是配置摘要 02181498…。我拿它去注册表查:
GET /v2/soit-ai/soit/server/manifests/sha256:e7c993b9... → 404注册表里根本没有这个对象。 我没能查清它到底是什么(合理的猜测是扫描器在本地处理镜像时算出来的某个内部标识),所以这里只报告观测,不给结论。但实践上的意思很清楚:读 SBOM 时,别拿文档内部那个 digest 去和签名里的 subject对账——它们不是一回事,对不上属正常。 签名的绑定只有一处,就是subject.digest,那个值是 96b80ae1…,和注册表返回的完全一致。
回到第二节记下的那两个 tag。把它们也当 referrers 回退标签取回来看:
回退标签对应的镜像摘要 | 挂着的证明 | 签名时刻 |
|---|---|---|
| 只有 provenance | 2026-08-05T15:41:15Z |
| provenance + SBOM | 15:52:32Z / 15:52:44Z |
| provenance + SBOM | 16:14:12Z / 16:14:22Z |
解开前两个的 provenance,ref 都是 refs/tags/v1.0.0,签名身份也都是同一条release.yml,但 commit 不是同一个:
镜像摘要 | provenance 里的 commit | Actions run | 那个 commit 的标题 |
|---|---|---|---|
|
| 31020921135 |
|
|
| 31021873557 |
|
|
| 31023642816 |
|
三个 commit 在本地都能 git cat-file 到,都在 main 上,时间依次是23:33、23:44、00:05(东八区)。结论很直白:那天晚上 tag 被重打过两次,前两次发布流程各自失败在 SBOM 那一步(看 commit 标题就知道在修什么),而每一次失败之前,镜像已经推上去并且签过名了。
于是就有了这个结果。用官方命令验一个从来没被发布过的镜像:
gh attestation verify \
oci://ghcr.io/soit-ai/soit/server@sha256:3a5b3b1a...8246 \
--repo soit-ai/soit --format jsonexit 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 回答的是「这是不是那条流水线的产物」,它从来没承诺回答「这是不是那个发布」。
补上这一环的东西不在签名里,在 GitHub Release 的资产里。我们那次发布挂了 7 个文件:
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 3694033release-artifacts.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),拼完当场校验:
python server/scripts/verify_release_artifacts.py artifacts/release-artifacts.json我把公网上下下来的那份直接喂给仓库里的同一个脚本:
{
"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);:107–112),防止同一条证明被复制粘贴到多个位置。仓库里还有一个测试盯着这件事:它把示例文档的 reference 改成 tag 形式,然后断言脚本必须以 digest-pinned 报错(server/tests/unit/test_release_operations_contract.py:85–88)。
镜像之外还有源码包。工作流用 git archive 生成(release.yml:253–263),叫「确定性归档」。这个说法值不值钱,试一次就知道。先按 SHA256SUMS 核下载:
grep "soit-1.0.0.tar.gz" SHA256SUMS | sha256sum -c -
# ./soit-1.0.0.tar.gz: OK再在本地照同样的方式生成一份:
git archive --format=tar.gz --prefix="soit-1.0.0/" \
--output=local.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
sha256sum local.tar.gz soit-1.0.0.tar.gzc11179c379ba7390c215133f342c92a7517a548f1536959cb43e187b16a7bab3 *local.tar.gz
5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *soit-1.0.0.tar.gz对不上。 而且不是压缩层的差异——把 gzip 剥掉、比里面的 tar,照样不一样。逐个成员比对之后原因很清楚:
成员数 : 本地 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 顺手把换行改了。关掉再来:
git -c core.autocrlf=false archive --format=tar.gz --prefix="soit-1.0.0/" \
--output=local2.tar.gz 8105cae074f1f27d7916acfe02f9d4eabb63169f
sha256sum local2.tar.gz5d3dd50f491a897ba9a184ad6dc474e2ef55508224404266d91b3e1e1d32ae9d *local2.tar.gz一个字节不差。 一台 Windows 机器、git 2.55.0、一个月之后,重放出了GitHub Actions 上生成的那个源码包。
这件事的两面都值得记住:「确定性」是真的——git archive 不写构建时间戳,mtime 取的是 commit 时间,所以跨机器可复现;但它对本地 git 配置敏感,复现失败的第一嫌疑人永远是 core.autocrlf,不是发布方作假。
把前面十一节压成一段可以直接抄走的流程。以我们的 v1.0.0 为例,换成你自己要验的项目同理:
# ① 拿到 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 都不同,不能告诉你它是怎么算出来的。
v1.0.0 这一次发布(tag commit 8105cae…)。后续发布的数值会变,方法不变。gh attestation verify 的退出码语义我只实测了成功路径(exit 0)。我没有构造一个签名被篡改的镜像去看它怎么失败。验签通过只说明「这串字节出自那条流水线」,不说明「这就是那个发布」——把这两件事接起来的,是一份把 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 打脸。比起被夸「流程做得挺全」,我更需要知道哪里对不上。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。