首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >不装 Docker、不登录,用 curl 把 GHCR 上的 Sigstore 签名和 SBOM 扒出来读一遍

不装 Docker、不登录,用 curl 把 GHCR 上的 Sigstore 签名和 SBOM 扒出来读一遍

原创
作者头像
用户5195948
发布2026-09-15 10:43:03
发布2026-09-15 10:43:03
200
举报

先说一个可能有点反直觉的事实:验证一个公开镜像的签名,不需要装 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 分清楚

这是所有混乱的起点:一个镜像身上挂着不止一个 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 打脸。比起被夸「流程做得挺全」,我更需要知道哪里对不上。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 先看结论
  • 一、先把三个 sha256 分清楚
  • 二、不用 Docker、不用登录,把签名扒出来
  • 三、签名里写了什么:一份 SLSA provenance
  • 四、签名是谁签的:一张只活了十分钟的证书
  • 五、证书过期了,签名为什么还算数:透明日志
  • 六、SBOM 挂在哪儿、里面有什么
  • 七、SBOM 里为什么没有文件清单:16 MiB 那道限制
  • 八、SBOM 不是事实,是扫描结果
  • 九、我们注册表里有三个都能验证通过的 v1.0.0
  • 十、缺的那一环:一份把 tag、commit、digest 绑死的清单
  • 十一、源码包我在 Windows 上按位重放出来了
  • 十二、如果你要验,照这个顺序做
  • 十三、现在还对不上的八个地方
  • 坦白局
  • 一句话结论
  • 来试试,也来挑刺
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档