GUAC 与 Grafeas 供应链证据图谱:从原始文档到可追溯决策
发布门禁显示某镜像“没有高危漏洞”,事故复盘却从另一份 SBOM 找到了受影响组件。构建系统保存的是镜像 digest,扫描平台只记了 tag,签名服务保存了 attestation,工单又引用一个已经移动的文件名。每套工具都拥有一小块真相,却没有人能回答:这些记录是否指向同一个不可变制品,哪个来源先观察到它,冲突结论为何并存,策略最终消费了哪一份证据。
另一条流水线把所有文档导入图平台后,查询立刻返回“已签名、无漏洞”。团队于是把“图里存在一条边”当成密码学验证结果。后来才发现导入者有权写任意关系,签名从未经过验证,外部漏洞源也因网络失败没有更新。证据聚合让信息更容易关联,但不会凭空提高证据可信度;可信度仍来自不可变身份、原始载荷、生产者身份、验签结果和消费策略的共同约束。
先把原始证据、图关系和信任结论分成三层
一份 SPDX SBOM、一份 SLSA provenance、一个签名 envelope 和一条漏洞观察都是原始证据。它们可能使用 pURL、OCI digest、Git revision、文件哈希或自定义 URI 指认对象。摄取系统解析这些文档,把不同格式映射为 Source、Package、Artifact 等规范化实体,再建立“包含”“构建自”“证明”“发现漏洞”等关系。查询层沿关系回答影响面问题,策略层才结合生产者、验签结果、时效和例外作出允许或拒绝。
最小链路可以记成:
原始文档 -> collector -> blob/消息 -> ingestor -> 规范化身份
-> ontology 关系 -> assembler -> 持久后端 -> 查询
-> 验签与来源判断 -> 策略消费 -> 发布决定GUAC 主要覆盖前半段的收集、解析、规范化、关联和查询。官方的组件说明把完整部署描述为一组异步服务:Collector 获取文档,Ingestor 转成 GUAC 对象,Assembler 写入后端,GraphQL 服务负责查询;CollectSub 和 certifier 还可能根据已发现实体继续收集或补充外部信息。它不是 Registry,也不是自动验证所有签名的裁判。
Grafeas 则是一套制品元数据 API。它用 Note 描述可复用的高层元数据,用 Occurrence 表示该 Note 在某个具体资源上的实例。它适合让扫描器、构建器和部署系统以统一 API 生产、读取元数据,但不是 GUAC 的“轻量图数据库版”,也不提供任意属性图遍历。两者可以协作:Grafeas 承担有明确所有者的记录面,GUAC 汇聚多来源并回答跨证据关系问题。
用不可变身份阻止同名对象被误合并
证据图最危险的错误不是查不到,而是把不同对象合并成一个。orders:release 会移动,包名与版本可能在不同生态或构建参数下重复,文件名更不具备全局唯一性。容器制品优先使用 repository@sha256:...;软件包使用完整 pURL,并保留 namespace、version、qualifiers 与 subpath;源码使用仓库身份和 commit digest。查询可以接受 tag 作为入口,但解析后必须冻结 digest,再围绕 digest 建图。
规范化也不是简单的小写与去空格。pkg:maven/example/lib@1.0?type=jar 和缺少 qualifier 的身份未必等价;同版本在两个仓库生成的二进制 digest 也可能不同。GUAC 会将软件身份映射到统一 ontology,但官方也提醒图关联可能依赖启发式信息。图边因此必须携带来源上下文:原始文档 digest、source URI 的脱敏引用、collector、观察批次和验证状态。没有这些上下文,冲突只能被最后写入覆盖。
冲突证据应并存,而不是投票消失。扫描器 A 在 T0 报告受影响,VEX 生产者 B 在 T+1 声明 not_affected,外部情报 C 在 T+2 更新版本区间。正确模型保留三次观察及各自生产者,再让策略判断 B 是否有资格为该产品发布 VEX、声明是否过期、目标 digest 是否一致。GUAC 的图负责让这些关系可查询,验签器负责证明文档来自谁,策略引擎负责决定信谁。
跑起 GUAC 并观察一次摄取的中间状态
本地学习环境需要 Docker、Compose 和 jq。从 GUAC release 取得与操作系统和 CPU 架构匹配的 guacone,同时取得同一 release 附带的 Compose 文件,并按官方校验说明验证下载物。不要从浮动分支混搭二进制与 Compose。官方安装入口使用演示 Compose,默认暴露 GraphQL 8080 和 CollectSub 2782;这些端口只绑定本机或隔离网络。
docker compose -f guac-demo-compose.yaml -p guac up -d --force-recreate
docker compose -f guac-demo-compose.yaml -p guac ps
guacone version
curl -fsS http://localhost:8080/ >/dev/null预期 Compose 服务处于 running/healthy,版本输出与下载的 release 一致,GraphQL 入口可连接。若容器反复重启,先执行 docker compose ... logs --no-color,按 blobstore、消息服务、ingestor、assembler、GraphQL 的顺序找第一处失败。端口可访问不代表摄取链完整,更不能代表持久化成立。
准备一个只含脱敏测试资料的 evidence/ 目录,再运行:
mkdir -p evidence
guacone collect files ./evidence/
docker compose -f guac-demo-compose.yaml -p guac logs --no-color --since 2m放入受支持的 SPDX、CycloneDX 或 attestation 样本后,日志应依次出现收集、解析和写入活动。查询前先对运行实例做 introspection,而不是复制旧博客字段。官方明确说明 GraphQL definitions 尚未稳定,内部 id 也是后端相关的不透明值,不能拿去做跨实例主键。可以保存当前 schema:
gql-cli http://localhost:8080/query --print-schema > schema.graphql
test -s schema.graphql成功只证明当前实例能输出 schema。随后从 GraphiQL 文档或保存的 SDL 选取 Package/Artifact 最小查询,检查返回实体的 pURL、digest 和关系来源。生产环境关闭 debug playground 与匿名 introspection,查询客户端使用受限服务身份。
GUAC 正反实验揭示持久化、去重和身份冲突
先做正向实验。准备两份文档:SBOM 指向 sha256:<SUBJECT_A>,provenance 的 subject 也指向同一 digest;保留两份原文的 SHA-256。摄取一次后查询目标 Artifact 及其 SBOM、构建关系,再重复摄取相同文件。
sha256sum evidence/sbom.spdx.json evidence/provenance.intoto.json > evidence.sha256
guacone collect files ./evidence/
guacone collect files ./evidence/
curl -fsS http://localhost:8080/query \
-H 'content-type: application/json' \
--data-binary @query-by-subject.json | jq . > result.json预期结果是同一 subject 可同时追到两类来源,语义实体不会因重放无限翻倍;若版本实际保留多次观察,也应能区分“同一语义关系”和“两个摄取事件”。失败证据包括解析错误、队列消息持续积压、GraphQL 返回空集合或重复关系线性增长。此时先确认输入格式、collector 支持状态、消息消费和 assembler 写入,不要把空结果解释为“没有风险”。
再做反向实验:复制 SBOM,将 subject digest 改一位,同时保留相同包名与版本;另一份样本保留 digest,却改变 pURL qualifier。摄取后分别按 digest 和完整 pURL 查询。
cp evidence/sbom.spdx.json evidence/sbom-conflict.spdx.json
jq 'walk(if type == "string" then sub("sha256:aaaa"; "sha256:aaab") else . end)' \
evidence/sbom-conflict.spdx.json > evidence/sbom-conflict.tmp
mv evidence/sbom-conflict.tmp evidence/sbom-conflict.spdx.json
guacone collect files ./evidence/sbom-conflict.spdx.json预期反例是两个 digest 不得被合成同一 Artifact;只按 name/version 的宽查询可以返回多个候选,而精确身份查询只能命中目标。若系统静默合并,就把该格式和当前版本标记为不满足晋级条件,保留原文并修正规范化键。实验结束删除冲突样本、查询文件和结果,停止演示栈:
rm -f evidence/sbom-conflict.spdx.json evidence.sha256 result.json schema.graphql
docker compose -f guac-demo-compose.yaml -p guac down -vdown -v 会删除演示数据卷,只能用于一次性环境。共享环境必须先确认卷归属和备份。
Grafeas 的 Note 与 Occurrence 是所有权模型
在 Grafeas 中,Note 描述“什么元数据可以被复用”,例如一个漏洞定义、一个 builder 或一个 attestation authority;Occurrence 描述“该 Note 如何出现在这个具体资源上”。官方概念文档建议把 Notes 和 Occurrences 放在不同项目,以便 Note 只由提供者修改,而引用它的消费者可读;Occurrence 写权限只授予有资格建立引用的 producer。
名称通常形如 projects/<provider>/notes/<note> 与 projects/<consumer>/occurrences/<occurrence>。Occurrence 的 resource_uri 必须唯一、不可变,容器应绑定内容 digest。Note owner、Occurrence producer、资源 owner 是三个角色:漏洞情报方维护 Note,受控扫描器写 Occurrence,应用团队读取并消费。任何一个项目管理员都能任意写三类对象,会把“存在记录”降格成自我声明。
Grafeas 仓库将 proto/v1 声明为权威 API,而旧的运行页面仍可见 beta 路径。因此实现时先固定目标 release 或 commit,从权威 protobuf生成客户端,再核对服务端路由、kind/oneof 与存储后端。不要把旧 curl 示例中的 /v1beta1 当成生产合同,也不要根据发布节奏推断项目生命周期。
本地可从官方仓库构建参考 server,使用隔离 PostgreSQL 或内存后端理解 API;生产部署则要补 TLS、认证授权、数据库持久化、备份和审计。镜像、依赖和 API 都固定到已评估版本,不使用浮动 latest。
Grafeas 正反实验验证资源绑定和伪造写入
使用目标 proto/v1 生成的客户端或 REST gateway,先创建 provider Note,再为不可变资源创建 consumer Occurrence。下面 JSON 表达的是对象关系,具体 oneof 字段以生成客户端为准:
{
"note": "projects/evidence-provider/notes/builder-approved",
"occurrence_project": "projects/app-team",
"resource_uri": "https://registry.example.test/app@sha256:<SUBJECT_DIGEST>",
"producer": "ci-evidence-writer",
"kind": "ATTESTATION"
}正向操作必须分别用三个身份执行:Note owner 创建和更新 Note;producer 只能在 consumer 项目创建 Occurrence;reader 能读取二者但不能修改。预期 get/list 能从 Occurrence 解析到 Note,审计日志显示不同主体完成不同动作。删除测试 Occurrence 后再删 Note,确保没有遗留引用;若 API 拒绝删除被引用 Note,先清理引用并保留审计证据。
反向实验有两组。第一组把 resource_uri 改为可变 tag,门禁应在写入前拒绝;如果服务端允许保存,消费策略也必须拒绝 tag-only 记录。第二组给伪造 producer 合法 schema 写权限,让它创建指向高信任 Note 的 Occurrence。预期 IAM 拒绝写入,或策略因 producer/Note owner 不在允许集合而拒绝消费。
正向:owner 写 Note -> producer 写 digest Occurrence -> reader 可读 -> policy 接受
反向 A:producer 写 tag Occurrence -> immutable-resource rule 拒绝
反向 B:unknown producer 写合法 Occurrence -> IAM 或 policy 拒绝
失败证据:PERMISSION_DENIED、immutable resource violation、untrusted producer若反向 B 仍被接受,问题不在 JSON schema,而在授权和消费策略。清理时撤销测试角色绑定、删除测试项目对象、销毁临时数据库;不要保留拥有 Note 与 Occurrence 全写权限的共享 token。
把图查询接入项目而不是接管验签
流水线接入分成生产与消费两条。生产侧在构建、SBOM、扫描和 attestation 任务结束后,把原始文档写入不可变对象存储或 Registry,记录文档 digest,再向 GUAC collector 或 Grafeas API 提交引用。GUAC 可异步摄取,Grafeas producer 直接创建 Occurrence。任务成功条件至少包含“原始证据已保存”和“提交已被接受”,不能等待图中所有 enrichment 完成后才结束构建。
消费侧先冻结制品 digest,再查询需要的关系集合,把原始证据交给对应 verifier,最后把验证结果、图查询版本和策略版本送入晋级决策。推荐输出一个小型决策包:subject digest、命中的证据 descriptor、原始文档 digest、producer、验签结论、冲突集合、策略版本和 decision。图内部 ID 不进入长期审计引用。
evidence_query:
subject: "registry.example.test/app@sha256:<SUBJECT_DIGEST>"
required: [provenance, sbom, vulnerability-observation]
reject_when:
- identity_is_tag_only
- source_is_unknown
- verified_signature_is_false
- conflicting_current_claims_unresolved超时策略要区分故障类型。查询服务不可达、消息积压和 verifier 错误是“无法判断”,高风险晋级应 fail closed;外部 enrichment 暂时失败不能被转换成“没有漏洞”。开发环境可允许带审计的降级,但必须显示 unknown,不允许伪装为 pass。
排障沿 collector、队列、解析、写入和查询逐段推进
“图里没有数据”至少有五种原因。Collector 没读到文件时检查路径、凭据和过滤器;blob 已保存但消息未发布时检查 pub/sub;队列有消息但 Ingestor 报错时保存文档 digest、格式和解析错误;Assembler 失败时检查 GraphQL mutation、后端连接和 schema;写入成功却查询为空时检查精确身份、query schema 与权限。
“关系数量突然膨胀”先区分业务增长、重放重复和 enrichment 扇出。记录每种文档的输入数、解析成功/失败数、规范化实体增量、关系增量、队列年龄和外部请求数。一个 SBOM 可能触发大量包实体及后续查询,所以容量模型不能按文件数线性估算。
“两个结论互相冲突”不要直接删除旧边。按 subject digest、predicate 类型、producer、observed-at、有效期和原始文档 digest 分组,确认是否真指向同一对象,再检查 verifier 与策略。Grafeas 查询 Occurrence 时若 Note 详情不可读,要呈现“引用不可解析”,不能把它解释成 Occurrence 不存在或验证通过。
“升级后查询报字段不存在”说明 GraphQL 契约漂移。对候选实例执行 introspection,重放代表性原文,比较实体/关系集合和保存查询。Grafeas 从 beta 客户端迁移到 v1 时,以 protobuf 为源显式映射 kind/oneof/名称,并做双读比对;禁止在生产请求里临时猜字段。
容量、成本与高可用围绕异步积压设计
GUAC 容量由原始文档大小、解析扇出、规范化基数、关系数、GraphQL 查询宽度和 enrichment 外部调用共同决定。监控 collector 吞吐、队列年龄、解析失败率、assembler 延迟、数据库连接/IOPS/索引体积、查询 p95/p99 和宽查询取消数。限制 GraphQL 深度、复杂度、分页与超时,避免一次影响面查询拖垮写入。
演示内存后端重启即空,只适合学习和一致性测试。生产持久化优先采用官方列为完整支持的 PostgreSQL/ent 路径,并按目标版本检查后端支持矩阵。高可用不是多起几个 GraphQL 副本:blobstore、消息系统、数据库、原始证据仓库和 verifier 都必须有故障域、备份、RPO/RTO 与恢复顺序。消费者在短暂读延迟下要知道结果水位,不能把旧快照当最新结论。
Grafeas 的主要容量是 Note/Occurrence 写入、过滤查询、数据库索引与审计。高基数通常落在 resource URI 和 occurrence;索引应围绕真实过滤模式设计。成本还包括对象存储、数据库备份、外部漏洞 API、日志和跨区流量。禁止把完整 SBOM、token 或数据库连接串打印到日志;GraphQL playground、宽查询与原始 provenance 都可能暴露内部包、仓库和 builder 拓扑。
权限上至少分 collector reader、ingestor/assembler writer、query reader、schema admin、Note owner、Occurrence producer 和 policy consumer。凭据通过秘密管理器短期下发,队列、blobstore、数据库和备份分别加密与审计。服务账号离职或流水线退出时,撤销的不只是 API token,还包括 bucket、Registry、消息主题和数据库角色。
升级、迁移与退出必须能从原文重建
升级 GUAC 前保存当前二进制/镜像 digest、Compose 或部署声明、GraphQL SDL、关键查询、后端备份和一组代表性原始证据。候选环境从空后端重放原文,比较规范化身份、关系集合、冲突保留和查询结果。通过后再切 collector 与 consumer;跨实例绝不复用 GraphQL id。
后端迁移同样以原文重放和集合比对为准。数据库备份可缩短恢复时间,但不能替代原始签名 envelope、未知字段和未来重新解析能力。反过来,只保存原文也无法恢复当时策略消费了哪组关系,因此还要导出决策包、schema 和策略版本。
Grafeas 迁移先导出 Notes、Occurrences、引用关系、owner 映射和 protobuf 版本。新旧端双读时按不可变 resource_uri 和 Note 名称比对,不以数据库自增 ID 对齐。写入权保持单一;若需要双写,必须有幂等键、差异队列和明确权威端。回滚时撤销新 producer,恢复旧读写路由,并处理新端已经产生的 Occurrence,不能只改 DNS。
退出时暂停新摄取,等待队列排空,冻结最终水位;导出原文及 digest、规范化身份、关键关系、冲突记录、schema、策略决策和审计;在空环境重建并运行保存查询。随后撤销 collectors、Note/Occurrence producers、GraphQL readers、数据库与对象存储凭据,清理队列、临时文件、备份副本和开放端口。最终反向证明是旧身份写入失败、旧查询入口不可达、归档仍能离线核验,而不是“容器已经停止”。
同一套能力在不同规模下可以采用三种形态。个人实验用单机 Compose 和内存后端,优点是启动快、清理明确,代价是重启即丢失,不能承载审计。小团队共享环境把原始证据仓库、消息服务、GUAC 组件和 PostgreSQL 分开,使用单一受控入口和每日备份,重点解决重复摄取、权限分工和磁盘增长。组织级环境再按摄取面与查询面拆分故障域:collector 贴近数据来源,消息和 blobstore 吸收峰值,多个 ingestor/assembler 横向处理,查询副本服务交互分析,主数据库与备份承担持久状态。选择形态的依据是证据来源数、日增关系量、查询并发、恢复目标和团队维护能力,不是容器副本越多越成熟。
异步链路还需要一个可解释水位。每批输入生成批次 ID,记录原始文档数量、最后消息序号、解析完成数、写入完成数和 enrichment 状态。策略消费查询结果时携带“已处理到哪个批次”,这样消息积压时能返回“数据只完整到 T0”,而不是把旧图冒充当前视图。若某批有一份文档永久解析失败,不能让队列无限重试阻塞后续输入;应转入隔离队列,保留原文摘要、解析器版本和错误类别,修复后从原文重放。
幂等不能只靠“数据库里已经有这个名字”。较稳妥的键至少分三层:原始文档以内容 digest 去重,规范化实体以完整软件身份或制品 digest 去重,观察关系以 subject、predicate、producer、原文 digest 和语义版本组合去重。同一原文重放不制造新事实;同一 producer 对同一对象发布新版本结论则保留新观察;不同 producer 的冲突不能互相覆盖。删除错误摄取也应写更正记录,说明哪条关系为何失效,而不是直接抹去历史。
身份冲突的裁决顺序可以固定。先确认对象类型,避免把 Source、Package 和 Artifact 混为一谈;再比较不可变 digest;没有 digest 时比较完整 pURL 或仓库与 revision;仍有多个候选时保留“未解析”并要求生产者补充身份。禁止根据名称相似度自动连边后直接进入发布策略。启发式关系可以服务调查,但必须带低置信标记,策略默认不把它等同于精确关联。
Grafeas 的跨项目模型也影响故障恢复。只恢复 Occurrences 数据库而没有恢复或重新授权 Notes,API 可能返回存在引用却无法解释的半状态;只恢复 Notes 又会失去它们在哪些资源上出现。恢复清单因此按 Note 名称、owner、Occurrence 名称、resource URI、kind 和引用关系建立集合,并在隔离环境用原 producer 与只读 consumer 分别测试。权限恢复晚于数据时,服务应明确返回权限错误,不能缓存旧 Note 内容并继续给出通过结论。
项目推广时给每个证据生产者一份可执行合同:它能提交哪些文档或 kind,subject 怎样规范化,原文保存在哪里,签名由谁验证,失败重试多久,何时进入隔离队列,退出时由谁撤销身份。消费团队则维护必需证据集合、允许 producer、最大证据年龄、冲突处理和 unknown 语义。平台团队维护 schema、队列、数据库、备份与查询限额。安全团队负责 verifier 和信任根,但不能获得任意改写业务 Occurrence 的常驻权限。
上线门槛应使用正反请求而不是页面截图。选择一个测试 digest,证明正确生产者的已验签证据能被查询和接受;再分别替换 digest、producer、签名结果和 Note owner,确认每种错误都在预期边界失败。随后重启一个 ingestor、阻断一个 enrichment 数据源、恢复数据库备份,并验证系统能区分积压、外部未知和持久数据恢复。只有这些状态都可观察、可恢复,证据图才从“方便搜索的数据集”变成可用于发布决策的基础设施。
当错误证据已经影响发布时,处置顺序先冻结策略消费水位和受影响 subject 集合,再保存原始文档、查询响应、生产者审计与验签日志。确认问题来自错误原文、解析器映射、身份误合并、外部情报过期还是未授权写入后,选择不同修复:错误原文由原 producer 发布带关联的更正;解析错误修复 ingestor 后重放原文;身份误合并建立新的精确实体并迁移关系;未授权写入立即撤销身份并隔离其全部 Occurrences。修复完成用原查询和反向查询证明错误关系不再被当前策略消费,同时历史调查仍能解释它曾经存在。
更正机制不能把审计历史变成无限噪声。查询默认返回当前有效观察,但调查模式能查看 superseded、retracted、expired 和 conflict 等状态;策略明确哪些状态可参与决策。若底层 schema 没有统一状态字段,可以用组织自己的决策索引保存“原关系、替代关系、原因、批准者和生效批次”,不要直接修改签名原文。这样既保留不可抵赖证据,又避免每个消费者重复发明“最后一条记录获胜”的危险规则。
证据的新鲜度也不是一个全局时长。provenance 对不可变构建通常长期有效,漏洞观察会随情报更新,VEX 可能因产品版本或缓解措施变化而失效,签名证书还要结合签发时的信任材料解释。策略分别定义每类证据的观察窗口、重新获取条件和撤销来源。查询结果缺少近期漏洞信息时返回 stale 或 unknown,不能因为历史上曾经“无漏洞”就继续通过。
容量压测应使用合成软件身份和脱敏文档,分别制造大量小 SBOM、单个超大 SBOM、重复重放、宽关系查询和 enrichment 断网恢复。观察队列是否有界、数据库是否出现热点、GraphQL 是否能取消超时查询、恢复时是否形成外部 API 重试风暴。压测停止后删除合成原文、图关系、查询导出和临时凭据,并确认备份策略没有把测试数据永久带入正式档案。
退出后的可移植性还要防止隐性锁定。查询报告不要只导出 GraphQL JSON,因为字段会随 schema 变化;同时导出原始证据、开放身份、关系三元组或组织定义的中立清单。Grafeas 导出不能只保存数据库表,要保留 protobuf 版本和 oneof 语义。新平台若无法表达某类关系,迁移记录必须标为降级并阻止旧系统销毁,直到消费者确认替代查询和审计要求已经成立。
GUAC 安装与 Compose 入口。GUAC 组件和异步摄取链。GUAC GraphQL 定义与不透明 ID 约束
GUAC 发布页。Grafeas 项目与权威 API 入口。Grafeas Note、Occurrence 与资源 URI 概念
