Cosign、SBOM、Syft 与 Grype:构建可验证的供应链证据
一次并发发布中,构建 Job 把 service:release 推进仓库,SBOM Job、漏洞扫描 Job 和签名 Job 又各自解析了一遍这个 Tag。所有任务都显示绿色,实际却命中了三个不同 Digest:安全报告描述旧镜像,签名保护中间镜像,生产部署拉取最后一次覆盖后的镜像。流水线留下了很多 JSON,却没有一份证据真正约束上线字节。
解决这类事故不需要先搭一座庞大的供应链平台。交付流水线可以从一条最小证据链开始:只解析一次不可变 Digest;Syft 为它生成 SBOM;Grype 使用可追溯的数据库快照扫描这份 SBOM;Cosign 为同一 Digest 生成签名和 Attestation;发布前重新验证身份、Subject、证据存在性和门禁结果。任一步骤换了 Digest、丢了证据或扫描器异常,都必须停止晋级。
Digest 是整条流水线的唯一主键
OCI Tag 是方便人发现版本的可移动指针,Digest 才是 Manifest 或 Image Index 内容的散列身份。镜像推送完成后,构建 Job 应从 Registry 返回结果解析一次 Digest,把 仓库@摘要 写入只读流水线输出;后续 Job 只能读取这个值,不再接收 Tag:
set -euo pipefail
IMAGE='registry.example.internal/team/service'
TAG='release-candidate'
docker push "$IMAGE:$TAG"
DIGEST="$(docker buildx imagetools inspect "$IMAGE:$TAG" \
--format '{{json .Manifest.Digest}}' | tr -d '"')"
SUBJECT="$IMAGE@$DIGEST"
mkdir -p artifacts
printf '%s\n' "$SUBJECT" | tee artifacts/image-subject.txt
test "${DIGEST#sha256:}" != "$DIGEST"多架构镜像有 Index Digest 和每个平台的 Manifest Digest。若部署器按平台从 Index 选择镜像,最小做法是把 Index 作为发布 Subject,并在部署清单中固定该 Index Digest;需要逐平台验证时,再把平台 Manifest 列表作为额外证据。不要一边签 Index、一边拿某个平台 Manifest 去查签名。
SBOM、漏洞报告、签名和 Attestation 回答的问题不同。SBOM 描述组件,漏洞报告记录某个数据库快照对这些组件的匹配结果,签名证明指定密钥或身份认可特定 Digest,Attestation 则把 Subject 与结构化声明绑定。签过名的错误 SBOM仍是错误 SBOM,扫描通过也不能证明镜像来自允许的发布工作流。
安装工具并冻结可复现配置
开发机可从 Syft、Grype 和 Cosign 的官方 Release 或受支持包渠道安装;受控 Runner 应固定二进制版本和下载校验值,不在每次任务里执行浮动安装脚本。Cosign 命令按当前 3.x 行为编排,新脚本应使用标准 Sigstore Bundle,避免继续引入已弃用的 --tlog-upload、--new-bundle-format 或 --offline。--offline 不是可靠的断网保证,真正的网络隔离需要完整材料与 egress 阻断,相关恢复设计见信任根轮换、离线恢复与安全退出。
安装完成后先把工具身份写进证据目录:
{
syft version
grype version
cosign version
} > artifacts/tool-versions.txt
grype db status -o json > artifacts/grype-db-status.before.json || true
sha256sum artifacts/tool-versions.txt > artifacts/tool-versions.sha256开发机可以让 Grype 更新默认数据库,但发布 Runner 不应在扫描中途悄悄换库。联网同步 Job 先下载数据库归档,校验后发布为内部只读流水线制品;扫描 Job 只导入该归档,并关闭自动更新。数据库归档、校验和、Grype 版本和配置文件共同构成扫描配置,缺一项都无法解释结果为何变化。
最低配置可以放进仓库,例如 .grype.yaml:
fail-on-severity: high
output: json
check-for-app-update: false
db:
auto-update: false
validate-age: true阈值是团队决策,不是工具真理。高危门禁还要结合是否有修复版本、组件是否真实进入最终镜像、业务暴露面和有期限的例外。SBOM 的 SPDX、CycloneDX、PURL、关系和许可模型需要统一时,可继续阅读SPDX 与 CycloneDX SBOM;漏洞是否实际影响某产品的结构化表达则见VEX、OpenVEX 与 CSAF。
正向实验:生成 SBOM 并锁住数据库快照
先用一个小型 Node.js 清单证明 Syft 的输入和 Grype 的数据库状态都进入证据。实验不要求某个漏洞永久存在,因为漏洞情报会变化;稳定判断是“组件被识别、数据库快照被记录、扫描命令正常完成或按阈值返回明确状态”。
set -euo pipefail
LAB="$(mktemp -d)"
mkdir -p "$LAB/app" "$LAB/artifacts" "$LAB/grype-db"
cat > "$LAB/app/package-lock.json" <<'JSON'
{
"name": "evidence-lab",
"version": "1.0.0",
"lockfileVersion": 3,
"packages": {
"": {
"name": "evidence-lab",
"version": "1.0.0",
"dependencies": { "lodash": "4.17.21" }
},
"node_modules/lodash": {
"version": "4.17.21"
}
}
}
JSON
sha256sum "$LAB/app/package-lock.json" > "$LAB/artifacts/input.sha256"
syft scan "dir:$LAB/app" \
-o "syft-json=$LAB/artifacts/sbom.syft.json" \
-o "spdx-json=$LAB/artifacts/sbom.spdx.json"
jq -e '.artifacts | any(.name == "lodash" and .version == "4.17.21")' \
"$LAB/artifacts/sbom.syft.json"
sha256sum "$LAB/artifacts/sbom.syft.json" "$LAB/artifacts/sbom.spdx.json" \
> "$LAB/artifacts/sbom.sha256"扫描 Job 从受控位置取得事先同步的 $GRYPE_DB_ARCHIVE 与校验文件,再导入独立缓存目录。这样同一流水线重跑时不会因默认缓存被后台更新而改变结论:
set -euo pipefail
test -f "$GRYPE_DB_ARCHIVE"
test -f "$GRYPE_DB_ARCHIVE.sha256"
(cd "$(dirname "$GRYPE_DB_ARCHIVE")" && sha256sum -c "$(basename "$GRYPE_DB_ARCHIVE").sha256")
export GRYPE_DB_CACHE_DIR="$LAB/grype-db"
export GRYPE_DB_AUTO_UPDATE=false
export GRYPE_CHECK_FOR_APP_UPDATE=false
grype db import "$GRYPE_DB_ARCHIVE"
grype db status -o json > "$LAB/artifacts/grype-db-status.json"
set +e
grype "sbom:$LAB/artifacts/sbom.syft.json" \
--config .grype.yaml \
--output json > "$LAB/artifacts/grype.json"
grype_rc=$?
set -e
case "$grype_rc" in
0) printf 'scan complete: threshold not reached\n' ;;
2) printf 'scan complete: threshold reached\n' ;;
*) printf 'scanner failure: rc=%s\n' "$grype_rc" >&2; exit "$grype_rc" ;;
esac
jq -e '.source != null and (.matches | type == "array")' \
"$LAB/artifacts/grype.json"退出码 0 表示扫描正常且没有发现达到阈值,2 表示扫描正常但命中阈值,其他非零值表示工具、输入或数据库故障。门禁可以对 2 应用经过审批的例外,却不能把其他非零值、空文件或无法解析的 JSON 当成零漏洞。
反向实验:让陈旧 SBOM 和篡改签名稳定失败
先修改扫描输入但不重建 SBOM。哈希校验必须失败,这个结果证明“SBOM 文件存在”不能替代输入绑定:
set -euo pipefail
printf '\n' >> "$LAB/app/package-lock.json"
set +e
(cd "$LAB/app" && sha256sum -c "$LAB/artifacts/input.sha256")
stale_rc=$?
set -e
test "$stale_rc" -ne 0
printf 'expected stale SBOM rejection: rc=%s\n' "$stale_rc"再用 Cosign 3.x 的本地加密密钥做最小 blob 正反实验。它只证明公钥、Bundle 与字节的绑定,不代表 OIDC 身份、透明日志和生产授权已经成立;完整 Keyless、KMS、身份约束与 Bundle 模型见Sigstore Cosign 签名与验证。
set -euo pipefail
printf 'subject=%s\n' 'sha256:demo' > "$LAB/artifacts/release.txt"
export COSIGN_PASSWORD='temporary-lab-password'
(cd "$LAB" && cosign generate-key-pair)
cosign sign-blob --yes \
--key "$LAB/cosign.key" \
--bundle "$LAB/artifacts/release.sigstore.json" \
"$LAB/artifacts/release.txt"
cosign verify-blob \
--key "$LAB/cosign.pub" \
--bundle "$LAB/artifacts/release.sigstore.json" \
"$LAB/artifacts/release.txt"
printf 'subject=%s\n' 'sha256:tampered' > "$LAB/artifacts/release.txt"
if cosign verify-blob \
--key "$LAB/cosign.pub" \
--bundle "$LAB/artifacts/release.sigstore.json" \
"$LAB/artifacts/release.txt"; then
echo 'ERROR: tampered bytes were accepted' >&2
exit 1
fi正向验证应退出 0,篡改后的验证必须非零。若反例只因网络超时失败,它没有证明内容绑定,应在网络条件稳定后重跑;若希望证明无外呼,应把完整验证材料放进禁网容器并检查防火墙日志,而不是添加 --offline。
在 OCI 仓库完成签名与 Attestation
本地实验通过后,把同一组动作迁移到测试 Registry。签名 Job 只接收 artifacts/image-subject.txt,对 Digest 签名;使用 Keyless 时必须同时约束证书 Identity 和 OIDC Issuer,使用 KMS 时只授予指定密钥版本的 Sign。Fulcio、Rekor、TUF、私有 Sigstore 服务和信任根分发会显著改变验证架构,相关部署与故障链见Fulcio、Rekor 与 Sigstore 信任服务。
set -euo pipefail
SUBJECT="$(cat artifacts/image-subject.txt)"
EXPECTED_IDENTITY='https://github.com/example/service/.github/workflows/release.yml@refs/heads/main'
EXPECTED_ISSUER='https://token.actions.githubusercontent.com'
cosign sign --yes "$SUBJECT"
cosign verify "$SUBJECT" \
--certificate-identity "$EXPECTED_IDENTITY" \
--certificate-oidc-issuer "$EXPECTED_ISSUER" \
--output json > artifacts/signature-verification.json
test -s artifacts/signature-verification.jsonAttestation 的 Predicate 可以直接使用 Syft 生成的 SPDX JSON。verify-attestation 成功后还要解码 Statement,确认 Subject Digest 和 predicateType,不能只判断“仓库里有一份 Attestation”:
set -euo pipefail
SUBJECT="$(cat artifacts/image-subject.txt)"
DIGEST="${SUBJECT##*@}"
cosign attest --yes \
--predicate artifacts/sbom.spdx.json \
--type spdxjson \
"$SUBJECT"
cosign verify-attestation "$SUBJECT" \
--type spdxjson \
--certificate-identity "$EXPECTED_IDENTITY" \
--certificate-oidc-issuer "$EXPECTED_ISSUER" \
> artifacts/sbom-attestation-verification.json
jq -r '.[].payload' artifacts/sbom-attestation-verification.json \
| head -n 1 | base64 -d > artifacts/sbom-statement.json
jq -e --arg digest "${DIGEST#sha256:}" '
any(.subject[]; .digest.sha256 == $digest) and
.predicateType == "https://spdx.dev/Document"
' artifacts/sbom-statement.json反向验证至少做两次:拿一个未签名的新 Digest 执行 cosign verify,再把期望 Identity 改成未授权工作流执行 verify-attestation,两者都必须非零。Cosign 能证明“某身份签了某份声明”,业务门禁仍必须拒绝错误身份、错误 Subject、错误 Predicate Type 和缺失证据。
OCI 发布要验证证据闭包
Registry 中的签名与 Attestation 是关联 Subject 的 OCI 对象。发布前至少证明四件事:目标 Digest 可拉取、签名满足身份策略、SBOM Attestation 的 Subject 与类型正确、证据目录中的 Grype 扫描结果来自批准的数据库快照。验证完成后再生成单一门禁结果:
set -euo pipefail
jq -e '.source != null and (.matches | type == "array")' artifacts/grype.json
jq -e '.built != null or .schemaVersion != null' artifacts/grype-db-status.json
test -s artifacts/signature-verification.json
test -s artifacts/sbom-statement.json
sha256sum artifacts/image-subject.txt \
artifacts/tool-versions.txt \
artifacts/sbom.syft.json \
artifacts/sbom.spdx.json \
artifacts/grype.json \
artifacts/grype-db-status.json \
artifacts/signature-verification.json \
artifacts/sbom-statement.json \
> artifacts/evidence.sha256
printf '{"subject":"%s","decision":"allow"}\n' \
"$(cat artifacts/image-subject.txt)" > artifacts/gate-result.json这里的 allow 只能在扫描阈值、例外状态和所有验证命令均通过后写出,不能预先创建再等待步骤覆盖。gate-result.json 应同时记录策略版本、工作流运行标识和证据清单哈希,部署 Job 只消费这个结果中的 Digest。
跨 Registry 复制、OCI Referrers 发现、代理 Media Type、垃圾回收和证据保留一旦成为生产问题,应按OCI Referrers 与 Registry 证据保留建立完整治理。交付流水线的最低要求是:复制后重新执行签名和 Attestation 验证,并定期选择历史 Digest 做恢复抽查;只比较 Tag 数量无法证明证据仍在。
项目流水线按职责拆分门禁
一个可维护的项目接入可以拆成 build、resolve-digest、sbom、scan、sign、verify、promote 七个 Job。resolve-digest 之后把 Subject 作为不可变输出;sbom 与 scan 无镜像写权限;sign 只能读取已审批 Subject;verify 从 Registry 重新读取证据;promote 只接收已验证 Digest。Job 之间传递受限 artifact,不共享包含云凭证的工作目录缓存。
jobs:
sign:
needs: [build, sbom, scan]
if: needs.scan.outputs.decision == 'allow'
permissions:
contents: read
packages: write
id-token: write
environment: production-release
steps:
- uses: actions/download-artifact@<PINNED_COMMIT>
with:
name: release-evidence
- run: cosign sign --yes "$(cat artifacts/image-subject.txt)"
verify:
needs: [sign]
permissions:
contents: read
packages: read
steps:
- uses: actions/download-artifact@<PINNED_COMMIT>
with:
name: release-evidence
- run: ./ci/verify-release-evidence.sh示例中的 Action 必须固定到评审过的 Commit,发布环境启用审批和分支保护。Fork PR、普通分支和动态生成的脚本不能获得 id-token: write、Registry 写权限或 KMS 签名权限。验证 Job 不继承签名能力,避免“生成后自己宣布可信”。
门禁应区分四种结果:allow、deny-policy、error-evidence 和 error-infrastructure。前两种表示证据足够且策略给出结论,后两种表示证据或基础设施无法完成判断。生产晋级对后三者默认阻断;紧急例外只能绑定精确 Digest、审批人、补偿措施与到期条件,不能用放宽身份正则或忽略日志校验替代。
从失败证据定位断点
Tag 有签名,部署 Digest 没有签名。 打印构建输出、签名输入和部署清单中的 Digest。若三者不一致,通常是某个 Job 重新解析了 Tag。修复后删除 Tag 输入,只保留 image-subject.txt,并用移动 Tag 的反例回归。
SBOM 包数量突然下降。 先比较 Syft 版本、扫描源类型和 SBOM 哈希,再看多阶段构建是否删除包管理器元数据、Cataloger 配置是否改变、静态链接或 Vendored 依赖是否不可见。用关键组件存在性和历史趋势判断,不用固定包数冒充质量标准。
Grype 输出空文件或结果突然变化。 比较 SBOM 哈希、Grype 版本、数据库归档哈希、Schema、构建标识和配置摘要。退出码 2 是命中阈值,其他非零值才是扫描故障;JSON 解析失败、数据库不存在和零漏洞必须保留为三种状态。
Cosign 验证通过了错误发布者。 检查是否同时约束 --certificate-identity 与 --certificate-oidc-issuer,身份是否精确到仓库、工作流和受保护 Ref。不要为了排障把正则放宽成 .*,应保存实际证书声明并修订允许列表。
Attestation 存在但门禁拒绝。 解码 payload,比较 subject[].digest、predicateType 和 Predicate Schema。签名正确只能证明声明没有被替换,不能证明声明描述了当前 Digest。若同一 Subject 有多份声明,应遍历并选择满足策略的一份,不能无条件取第一份。
复制仓库找不到证据。 对源仓和目标仓执行同一组 cosign verify 与 verify-attestation,再检查 Referrers、跨仓复制过滤和 GC 记录。先修复复制闭包与保留策略,再补发证据,否则下次清理仍会复发。
权限与敏感数据要随阶段隔离
SBOM 和漏洞报告可能暴露私有包名、内部路径、基础镜像、许可证和未公开漏洞,应按工程敏感数据授权。流水线日志只输出 Digest、计数和决策,不把完整报告、OIDC Token、Registry 密码、KMS URI 中的敏感参数或 COSIGN_PASSWORD 回显;签名步骤禁止 set -x。
构建 Job 可以推送候选镜像但没有签名身份;扫描 Job 只读 Digest 和数据库快照;签名 Job 只能向目标仓库写关联证据并请求指定身份或密钥签名;验证 Job 只读 Registry、策略和可信材料。密钥管理员、发布审批人和 Registry 管理员应分离,减少一个账号同时替换镜像、签名并修改保留策略的机会。
镜像和包元数据本身也是不可信输入。超大镜像、深层目录、压缩炸弹和异常包数据库会消耗 CPU、内存、磁盘与网络。扫描 Runner 设置超时、临时磁盘上限和并发上限,不挂载宿主 Docker Socket,不注入云管理凭证;高风险外部制品在隔离 Runner 中扫描,失败后销毁工作目录。
容量与成本围绕每个 Digest 计算
每个发布 Digest 至少产生 Syft 原生 SBOM、交换格式 SBOM、Grype 报告、数据库状态、签名、Attestation、验证输出和证据清单。多架构镜像、重复格式、跨区域复制和每次数据库更新后的重扫都会放大存储与请求量:
月发布 Digest 数 × 每个 Digest 的证据字节
+ 多平台对象与关联证据
+ 数据库快照保留量
+ 跨区域复制副本
+ 审计与恢复保留量Syft 与 Grype 的耗时随镜像层数、文件数、包数和并发增长;Cosign 验证成本落在 Registry 请求、证书链与透明证据校验,KMS 还会产生请求费用和限流。用真实制品测量扫描耗时分位、峰值内存、临时磁盘、Registry 429/5xx、证据平均大小和数据库同步失败率,再确定并发与缓存。通用固定阈值无法替代项目基线。
数据库快照不必为每个发布复制一份完整归档,可以在证据中保存内容哈希并引用只读对象;保留系统必须防止仍被发布记录引用的快照提前删除。SBOM 同时保留 Syft JSON 与一种组织交换格式通常足够,盲目输出所有格式只会增加转换差异和存储成本。
回滚、清理与团队治理形成闭环
流水线失败时,回滚单位是上一个已经验证的 Digest,而不是把旧 Tag 再推一次。部署记录要保留旧 Digest、证据清单哈希和验证策略版本;回滚前重新验证旧制品的签名、Attestation 和门禁结果。若旧证据无法读取,应停止自动回滚并进入人工处置,不能重新签名后伪装成历史证据。
实验结束先删除本地私钥和临时目录:
unset COSIGN_PASSWORD
rm -f "$LAB/cosign.key" "$LAB/cosign.pub"
rm -rf "$LAB"测试 Registry 的清理顺序是先确认没有环境引用 Subject,再按 Registry 能力删除测试签名、Attestation 等关联对象,最后删除测试 Subject 和 Tag。公共透明日志中的事件通常不能按普通业务记录删除,测试前不要使用敏感仓库、私人身份或真实客户制品。生产清理必须保留审计和事故调查所需的不可变证据,不能把“释放空间”直接等同于删除历史闭包。
团队至少明确四类 Owner:构建平台维护工具版本、Runner 和 OIDC 工作流;安全团队维护 Grype 数据库同步、阈值、身份策略与例外;Registry 团队维护不可变性、复制和关联证据可用性;服务团队解释组件与漏洞并承担发布决策。工具升级先用固定 Digest 回放成功样本、篡改样本、错误身份和缺失 Attestation,确认旧制品仍可验证,再切换 Runner 镜像。
日常审计抽样检查“同一 Digest 是否贯穿 build、SBOM、scan、sign、verify、deploy”,而不是只数绿色 Job。更完整的信任根轮换、历史 Bundle 验证、离线恢复和平台退出属于独立生命周期工程,可沿信任根轮换、离线恢复与安全退出继续设计;交付团队应提供准确 Subject、完整证据清单和可重复正反样本,作为后续治理的输入。
