验证一个容器镜像到底在验什么?我把自家 v1.0.0 的签名从注册表一路扒到了证书里

「镜像我验过了,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.payloadTypeapplication/vnd.in-toto+jsonpayload 是 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_idrepository_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 UsageCode SigningKey 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:1116:24:11,落在里面。

顺带一个实测细节:这份 bundle 的 timestampVerificationData 是个空对象, 也就是说没有走 RFC 3161 的可信时间戳,时间完全由 Rekor 那条记录承担。 这不是错,是这条流水线当时的选择,但值得知道。

六、SBOM 挂在哪儿、里面有什么

另一个 bundle(predicateTypehttps://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-workerserver 多出来的 64 个 pypi 包里有 torchtransformersnvidia-cublas-cu12------它们是同一个 Dockerfile 的两个 target,差别就是 uv sync --extra knowledge-worker 那一句(server/Dockerfile:19:27)。 webnode: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:3323:4400: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 必须等于 vversion: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=truegit 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.pyprovenance_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 commit 8105cae...)。 后续发布的数值会变,方法不变。
  • gh attestation verify 的退出码语义我只实测了成功路径(exit 0)。 我没有构造一个签名被篡改的镜像去看它怎么失败。
  • 利益相关:我是 SOIT 的维护者。

一句话结论

验签通过只说明「这串字节出自那条流水线」,不说明「这就是那个发布」 ------ 把这两件事接起来的,是一份把 tag、commit 和 digest 绑死的清单; 在我们这儿它叫 release-artifacts.json,178 行的脚本盯着它, 而它最硬的一条断言只有一句:引用必须写成 name@digest,写成 tag 形式直接不合格。

来试试,也来挑刺

仓库在 github.com/soit-ai/soit。这篇里的每一步你都能自己跑一遍:

  1. 第二节那几条 curl 不需要任何账号,公开包匿名 token 就够;
  2. 第九节那个 3a5b3b1a... 现在还在,你可以自己验一次,看它是不是真的 exit 0;
  3. 第十一节的按位重放,记得先加 -c core.autocrlf=false

如果你发现我哪一条说错了------尤其是第八节那个我没查清的 digest------ 欢迎直接开 issue 打脸。比起被夸「流程做得挺全」,我更需要知道哪里对不上。

相关推荐
2601_962300473 小时前
python是跨平台的吗
python·开源·跨平台·面向对象·
yu俞娥宝3 小时前
DeepSeek Harness 开源贡献手记:参与AI智能体框架共建的实战与成长
人工智能·开源
冬奇Lab14 小时前
一天一个开源项目(第216篇):OpenViking - 给 AI Agent 装上可自进化的上下文数据库
人工智能·开源·资讯
Bruce_Liuxiaowei15 小时前
2026年9月第2周网络安全形势周报
网络·安全·web安全·网络安全·漏洞评级
hasty16 小时前
Origin 校验不是身份认证:MySQL MCP Server 漏洞给 AI 平台的警告
数据库·mysql·安全
小杨不想秃头16 小时前
《信息安全工程师教程(第2版)》通关笔记
安全
workflower18 小时前
人形机器人安全伦理标准
人工智能·安全·机器学习·机器人·无人机
我不是程序员三三19 小时前
内网监控系统|终端监控上线验收与常态化安全自查清单
安全
其实防守也摸鱼20 小时前
每天一个知识点——RCE漏洞
运维·服务器·数据库·windows·安全·github·漏洞