SPDX 与 CycloneDX SBOM:从组件清单到可验证制品关系图
发布门禁收到一份结构完全合法的 SBOM:组件名、版本、许可证和依赖关系都有,扫描平台却没有把高危漏洞关联到线上镜像。复盘发现,生成任务记录的是源码目录中的包版本,部署系统消费的是多架构镜像 digest;一个私有 Maven 包还在转换时丢失了仓库 qualifier。文档通过了 JSON Schema,真正要保护的制品身份却没有命中。
另一支团队把 CycloneDX 转成 SPDX 后沿用原签名,并把转换成功当成“语义无损”。几个月后审计才发现 services、VEX analysis 和局部引用没有进入目标模型,签名验证的又只是转换前字节。SBOM 的价值不在于仓库里多了一个 JSON 文件,而在于组件、关系、标识和生成证据始终绑定同一个不可变制品,并且每次转换、裁剪和分发都留下可验证的派生链。
先把制品身份、包坐标和文档引用拆开
SBOM 描述“某个对象由哪些组件构成、它们怎样关联、有哪些许可和生成证据”,不证明组件没有漏洞,也不证明生成者可信。消费端首先要冻结待发布制品的 digest,再判断 SBOM 的 subject 是否指向该 digest。容器 tag、文件名、流水线编号和仓库路径都可以移动,不能代替内容摘要。
purl 是包坐标。它的 type、namespace、name、version、qualifiers 和 subpath 都可能影响身份,例如源码仓库、包类型或发行版 qualifier 被删除后,两个原本不同的包可能被误合并。CPE 面向平台命名与逻辑匹配,通配符、ANY、NA 和字典映射适合漏洞情报关联,却也可能扩大或缩小命中集合。两者都不是制品字节的 digest。
SPDX 的 spdxId、CycloneDX 的 bom-ref 是文档内引用键。它们让 relationship 或 dependency 指向同一文档中的节点,但跨文档没有天然稳定性。SPDX DocumentNamespace 和 CycloneDX serialNumber 标识文档实例,也不等于被描述制品。工程上要同时保留四类身份:制品 digest、规范化 purl、可选 CPE、局部引用;任何转换器都不能把它们压成一个“统一 ID”。
选择稳定模型而不是追逐最高版本号
SPDX 3.0.1是稳定的 3.x 基线,模型由 Core 和 Software、Licensing、Security、Build 等 profile 组成;声明“支持 SPDX 3”时必须列出实际支持的 profile 与 serialization。SPDX 3.1-RC1仍是预发布,只适合兼容性验证。大量现有交换链仍使用 SPDX 2.3;ISO/IEC 5962:2021 对应的是 SPDX 2.2.1,不能据此宣称 SPDX 3.0.1 已具有同一 ISO 版本身份。
CycloneDX 1.7是稳定规范,并对应 ECMA-424 第 2 版。它支持 JSON、XML 和 Protocol Buffers,并能表达 components、services、dependencies、compositions、formulation、properties、annotations、vulnerabilities 等对象。生产者和消费者仍要分别声明支持哪些对象;只接受 specVersion: 1.7 不代表理解所有语义。
选型应从消费问题反推。许可证、文件和 snippet 粒度、SPDX License Expression 与关系图是主轴时,SPDX 通常更自然;服务、数据流、运行生命周期、BOM-Link、VEX 与丰富组件证据需要放在同一模型时,CycloneDX 通常更直接。两个格式可以并存,但应从同一构建事实生成,不要把多次有损转换当成主数据同步方式。
安装验证工具并固定可审计版本
学习环境准备 curl、jq 与 sha256sum。CycloneDX 官方 CLI 的源码、release 和安装入口位于 CycloneDX/cyclonedx-cli。安装时固定经过评估的 release,不使用浮动容器标签;先由工具管理员设置 CYCLONEDX_CLI_VERSION 和 EXPECTED_SHA256,再把已核验下载物复制到项目工具目录。
test -n "$CYCLONEDX_CLI_VERSION" && test -n "$EXPECTED_SHA256"
mkdir -p ".tools/cyclonedx/$CYCLONEDX_CLI_VERSION"
printf '%s %s\n' "$EXPECTED_SHA256" ./downloads/cyclonedx-cli | sha256sum -c -
cp ./downloads/cyclonedx-cli ".tools/cyclonedx/$CYCLONEDX_CLI_VERSION/cyclonedx-cli"
chmod 0755 ".tools/cyclonedx/$CYCLONEDX_CLI_VERSION/cyclonedx-cli"
ln -sfn "cyclonedx/$CYCLONEDX_CLI_VERSION/cyclonedx-cli" .tools/cyclonedx-cli
.tools/cyclonedx-cli --helpSPDX 2.x 可从官方 tools-python 或 tools-java 选择解析器;SPDX 3 要同时考虑 JSON Schema 与 SHACL/对象模型验证。不要因为某工具包名里有 SPDX 就推断它支持 3.0.1、所有 profile 或 canonical serialization。将二进制 digest、工具版本、schema URL、启用的 profile 和命令参数写入生成记录,升级工具时重放样本集。
验证器启用后先跑一个受控空项目,确认“合法样本通过、非法样本退出非零”。如果 CLI 参数在所固定版本中不同,以该 release 的 --help 为准并把实际命令封装为 scripts/verify-sbom;流水线只调用封装入口,避免各 Job 各自解释版本。
从构建事实生成组件图而不是扫描工作区快照
生成时机决定 SBOM 描述什么。源码清单只能回答声明依赖,构建清单能观察解析后的锁文件与编译材料,制品清单还能发现打包后的文件和系统库。对发布门禁,至少保存最终制品 digest,并说明 generation context 是 source、build 还是 post-build。扫描器可以参与发现组件,但 SBOM 仍不是漏洞扫描结果。
最小对象链是 subject、component/package、relationship/dependency、license、external reference 和 creation metadata。SPDX 2.3 中常见的是 Document、Package、File、Snippet 与 Relationship;SPDX 3 把关系提升为有类型、有属性的元素,并由 profile 扩展能力。CycloneDX 用 metadata.component 指向主组件,components[].bom-ref 建立节点,dependencies[].ref/dependsOn 建边。
生成器读取私有包源时只授予下载所需的只读凭据,输出前扫描 URL、token、用户名、工作区路径、内部域名和联系人。构建参数、source info、properties 与 annotations 很容易泄露内部拓扑。对外 SBOM 不是简单删除敏感字段:裁剪后要重新生成 serial/namespace、记录裁剪策略和源文档 digest,并重新签名。
第一组正反实验:结构合法与引用完整性
准备一个只有应用和一个库的 CycloneDX 1.7 样本。正向样本让根组件依赖库,两个 bom-ref 唯一且引用存在;随后执行官方 CLI schema 校验,再用 jq 检查所有依赖端点都在节点集合中。
# 正向:结构与局部引用同时通过
.tools/cyclonedx-cli validate \
--input-file bom.cdx.json --input-version v1_7 --fail-on-errors
jq -e '
([.metadata.component["bom-ref"]] + [.components[]["bom-ref"]]) as $nodes
| ($nodes | length) == ($nodes | unique | length)
and all(.dependencies[]?; (.ref as $r | $nodes | index($r)) != null)
and all(.dependencies[]?.dependsOn[]?; (. as $r | $nodes | index($r)) != null)
' bom.cdx.json预期两条命令都退出 0。保存 CLI 标准错误、schema 版本、节点数、边数和文档 SHA-256。schema 通过只证明结构符合 1.7;第二条检查证明这份样本没有重复局部 ID 或悬空边,仍没有证明 purl 正确、subject 命中或签名可信。
反向样本复制 bom-ref,并让一条 dependency 指向不存在的节点。有些 schema 只能检查字段形状,未必检查图的全局唯一性与引用闭包,因此业务门禁必须独立失败。
# 反向:制造重复节点和悬空引用
jq '.components[1]["bom-ref"] = .components[0]["bom-ref"]
| .dependencies[0].dependsOn += ["urn:missing:component"]' \
bom.cdx.json > bom.cdx.invalid.json
.tools/cyclonedx-cli validate \
--input-file bom.cdx.invalid.json --input-version v1_7 --fail-on-errors
jq -e '([.metadata.component["bom-ref"]] + [.components[]["bom-ref"]]) as $nodes
| ($nodes | length) == ($nodes | unique | length)
and all(.dependencies[]?.dependsOn[]?; (. as $r | $nodes | index($r)) != null)' \
bom.cdx.invalid.json预期至少引用门禁退出非零,并输出重复 ref 或 urn:missing:component。若 schema 命令仍通过,这是可接受且重要的证据:结构验证没有覆盖图语义,不能删除第二层门禁。
第二组正反实验:身份命中与 schema 通过后的失败
先计算待发布制品摘要,生成 SBOM 后把 subject digest、完整 purl 和局部引用分别抽出。下面的 expected-subject.json 由构建任务创建,只包含 Registry、算法与 digest,不从 SBOM 自己反推期望值。
# 正向:外部构建事实与 SBOM subject 一致
sha256sum dist/app.tar > artifact.sha256
jq -n --arg digest "$(cut -d' ' -f1 artifact.sha256)" \
'{algorithm:"SHA-256", digest:$digest}' > expected-subject.json
jq -e --slurpfile e expected-subject.json '
.metadata.component.hashes
| any(.[]; .alg == $e[0].algorithm and .content == $e[0].digest)
' bom.cdx.json
jq -e '.components[] | select(.name=="private-lib")
| .purl | contains("@") and contains("repository_url=")' bom.cdx.json预期 subject 命中,私有依赖的 purl 保留版本与经批准的 qualifier。实际 purl type 的 qualifier 规则必须交给 purl 库解析,contains 只用于这个受控样本的显性演示,不能替代生产解析器。
然后只改 subject digest 的一个字符,并删除私有包 qualifier;JSON 仍然可能符合 schema,但身份策略必须拒绝。
# 反向:保持 schema 合法,制造制品错配与包坐标退化
jq '(.metadata.component.hashes[] | select(.alg=="SHA-256").content) |=
(.[0:-1] + (if endswith("0") then "1" else "0" end))
| (.components[] | select(.name=="private-lib").purl) |= split("?")[0]' \
bom.cdx.json > bom.cdx.identity-conflict.json
.tools/cyclonedx-cli validate \
--input-file bom.cdx.identity-conflict.json --input-version v1_7 --fail-on-errors
jq -e --slurpfile e expected-subject.json '.metadata.component.hashes
| any(.[]; .alg == $e[0].algorithm and .content == $e[0].digest)' \
bom.cdx.identity-conflict.json预期 schema 校验可以通过,而 subject 检查退出非零。门禁还应报告 JSON path、期望 digest、观察 digest 和退化 purl,不能只给“SBOM 无效”。这组证据直接说明 schema valid 不等于可用于漏洞、许可或晋级关联。
格式转换必须交付字段损失报告
官方 CycloneDX CLI 可以转换多种序列化,并能输出 SPDX JSON 2.3;这不意味着 CycloneDX 1.7 与 SPDX 3.0.1 可无损互换。CycloneDX 的 services、data flow、lifecycles、formulation、identity evidence、BOM-Link、VEX analysis、签名与扩展,无法假定进入 SPDX 2.3。反方向转换时,SPDX File/Snippet、declared/concluded license 和细粒度 relationship 也可能被压缩。
转换任务要产生三件制品:目标文档、机器可读 loss report、映射表。loss report 至少把源路径分成 preserved、transformed、dropped、ambiguous,并记录转换器版本、源/目标 digest 与 ID 映射。对声明可逆的子集执行 A→B→A 后比较规范化对象图,而不是比较 JSON 文本顺序。
.tools/cyclonedx-cli convert --input-file bom.cdx.json \
--output-file bom.spdx.json --output-format spdxjson
./scripts/verify-sbom bom.spdx.json
./scripts/report-sbom-loss bom.cdx.json bom.spdx.json > conversion-loss.json
jq -e '.dropped == [] and .ambiguous == []' conversion-loss.json最后一条是否允许通过由用途决定。内部归档可以接受明确记录的非关键损失;用于自动漏洞抑制、许可放行或客户合同的文档,应对关键字段 fail closed。转换会改变字节、文档 ID 和通常的规范化表示,原签名不能沿用。目标文件作为衍生物重新验 schema、重新绑定 subject、重新签名,并把源文档 digest 与 loss report 放入 provenance 或 attestation。
把生成、校验和 Registry 分发接入流水线
构建阶段先产出不可变制品并计算 digest,再从锁文件、构建图和制品内容生成 SBOM。校验阶段依次执行 schema、引用闭包、purl/CPE 解析、subject digest、敏感信息与策略检查。只有这些检查通过,才签署 SBOM 或把它封装为 attestation,并作为 OCI artifact/referrer 关联到 subject digest。
sbom:
subject: "registry.example.test/team/app@sha256:<ARTIFACT_DIGEST>"
formats: [cyclonedx-1.7-json, spdx-3.0.1-jsonld]
gates: [schema, references, identity, sensitive-data, loss-policy]
publish:
attach_by: digest
visibility: internal
sign_derived_documents: trueRegistry 端要验证 referrer 的 subject descriptor,复制和晋级时同时复制 SBOM、签名、attestation 与 loss report。只复制镜像 manifest 会留下“生产仓库有制品、证据仓库仍在测试环境”的断链。GC 策略要把引用证据纳入可达性;Registry 不支持 referrers API 时可使用经过测试的兼容标签,但仍以 descriptor digest 建立权威关联,不能把标签当身份。
流水线权限至少分为:构建读取私有依赖、生成器写临时工作区、发布者写指定 repository 的 referrer、签名者调用受限密钥或身份、消费者只读。生成器不需要删除 Registry 制品,校验器不需要私有包下载凭据,公共镜像同步器不能读取 restricted SBOM。
对私有依赖和缓解信息做分类分发
SBOM 会暴露私有包名、精确旧版本、供应商、内部仓库 URL、源路径、服务端点、认证方式和数据流。开放格式或许可证不代表某个 SBOM 实例可以公开。建立 public、customer、internal、restricted 视图,并让每个视图保留同一 subject digest、裁剪策略 ID、源文档 digest 和审计记录。
公共视图可去除内部 repository qualifier、源路径、联系人、services endpoint 与自由文本;客户视图按合同保留交付组件和许可;内部视图保留排障关系;restricted 视图保存未公开供应商、漏洞缓解细节和构建证据。VEX 的补偿控制、不可达路径和修复窗口通常比组件名更敏感,不应随公共 SBOM 自动分发。
裁剪后的文档是新衍生物,必须重新赋予 namespace/serial、重新验证并重新签名。访问日志记录调用身份、subject、视图、文档 digest 和结果,不在日志中打印完整 SBOM、token 或签名私钥路径。
清理、回滚与故障定位从派生物向源头推进
一次性实验结束先删除 Registry 中测试 referrer,再删除测试制品,最后撤销测试身份并清理本地文件。共享仓库中禁止按模糊标签批量删除。可以先列出 subject 的 referrers,逐个核对 descriptor digest 和 annotation,再执行删除并复查引用为空。
rm -f bom.cdx.invalid.json bom.cdx.identity-conflict.json
rm -f bom.spdx.json conversion-loss.json expected-subject.json artifact.sha256
rm -f .tools/cyclonedx-cli
rm -rf ".tools/cyclonedx/$CYCLONEDX_CLI_VERSION"
# Registry 清理由管理员使用目标产品的 digest 删除接口执行,并保存审计记录失败排查按“生成输入→结构→引用→身份→转换→签名→发布→消费”推进。组件少了先看锁文件、构建阶段和扫描器排除规则;引用悬空查生成器对象图;漏洞不命中查 purl/CPE 规范化与 subject;验签失败查文档是否被转换或裁剪;Registry 查不到证据则查 subject descriptor、复制策略与 GC。不要通过重新生成一份新 SBOM覆盖事故证据,原文和失败输出应按留存策略归档。
容量、成本与高可用围绕组件和关系基数计算
SBOM 成本不只按文件数增长。一个制品可能有多架构、多层、多个格式和多个分发视图;每份文档又包含大量组件、文件、关系、许可证证据和签名。估算时记录每个 subject 的组件数、关系数、文档字节、转换衍生物数、签名数、查询频率和留存代际。全量 File/Snippet 清单会显著增加生成时间、对象存储、索引和客户下载成本。
高可用的关键是可重建,而不是让生成 CLI 常驻多副本。生成任务可水平扩展,但必须固定工具、schema 和输入;Registry、对象存储、签名服务、策略服务与元数据索引分别设计备份和故障域。发布链短暂不可用时,高风险晋级应显示 unknown 并失败关闭,不能把“取不到 SBOM”解释为“没有组件风险”。
监控生成耗时与失败率、每份组件/关系基数、schema 与语义失败分类、loss report 非空率、subject 错配数、签名失败、referrer 复制延迟、GC 删除数和下载拒绝。阈值来自历史基线、制品规模和发布 SLO;组件数突然下降比固定的“最多多少组件”更能发现生成器退化。
升级、迁移和退出要能离线重建同一对象图
升级生成器、SPDX profile、CycloneDX specVersion 或 Registry 前,冻结代表性制品、原始锁文件、生成参数、工具 digest、schema、签名策略和期望对象图。候选环境重放后比较 subject、purl/CPE、节点、边、许可证与敏感字段扫描结果。SPDX 3.0.1 迁往 3.1 时,在 3.1 成为稳定且工具链通过兼容门禁前,不把 RC 输出设为唯一权威格式。
双格式迁移期间从同一构建事实并行生成,消费者双读并记录差异,不从格式 A 链式转换出 B 再宣称二者等价。切换后保留旧 reader 和回滚窗口;旧文档、签名和 schema 继续可离线验证。回滚只改变消费优先级,不删除新格式证据。
退出某工具或 Registry 时先暂停新发布,导出原始 SBOM、派生文档、loss report、签名、subject 映射、生成器信息、访问策略和审计决策;在空环境中按 digest 重建索引并抽样验证对象图。随后撤销生成、发布和读取身份,删除临时缓存与无主 referrer,保留合规所需归档。最终证据是旧身份无法再写入、归档仍可验签、任一文档都能回链到目标 artifact digest。
SPDX 3.0.1 规范。SPDX 3 serialization 与验证要求。CycloneDX 1.7 规范总览
CycloneDX 官方 CLI。ECMA-424 CycloneDX 标准入口。Package-URL ECMA-427
