diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 6cee5427..e07bd10d 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -302,6 +302,7 @@ jobs: test/back/ql3ReleaseCandidateContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs + test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs test/back/ql3DeploymentLockContract.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs test/back/ql3LocalImageAudit.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 138bf089..2693d16d 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- D-338/ADR-0430(已接受;真实公开 catalog 运行待实际 release tag):发布端的 durable catalog 不再由部署者通过松散 shell 重定向手工消费。可信工作站上的 `ql3-release-catalog-consumption-ceremony.cjs` 从 exact source version/revision/tag、closed `local|cluster|all` scope 与 owner/source repository 推导唯一 discovery ref,前后两次解析必须得到同一 digest,后续只使用 catalog `@sha256:` immutable reference。ceremony 以绝对路径、dev/inode/size/SHA-256 固定 `regctl|cosign|gh`,owner-private token 只进入单个 GitHub provenance verifier;环境、cache/config/tmp 与最终写入均有封闭边界。下载的 canonical release set 经过 standalone identity/family/self-digest inspection,raw OCI manifest 同时按 digest、media type、empty config、单 layer、basename、size/content digest 与四项 annotation 重建 publication plan/receipt。成功后才以 `0700` no-replace 目录和三项 `0600` 文件发布 release set、raw manifest 与 self-digest report;offline audit 要求 exact-three-file,并完全重建结构/manifest/report,同时诚实声明网络签名结果未离线 replay。该 ceremony 无 registry/GitHub mutation、deployment action authority、Compose/Kubernetes apply 或数据库访问;D337 继续只消费审计后的 release set。Local/低配设备不安装任何工作站工具,Cluster 也不新增 controller/CRD/RBAC。独立 ceremony 20/20、完整定向发布链 121/121 已通过;backend 共 1,315 项,1,313 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 仍未实际产生,因此本门不宣称已取得真实 Cosign/GitHub/registry 成功证据。 - D-337/ADR-0429(已接受):D-336 的 durable release set 现在可以离线物化为最终部署 authority,而不是由运维者手工复制 digest。可信工作站上的 `ql3-deployment-lock-contract.cjs` 不联网、不连接 Kubernetes API、不执行 rollout:Local/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selection;Cluster/All 先消费 `kubectl kustomize` 的最终多文档 YAML,再只改写封闭的 Pod/Deployment/StatefulSet/DaemonSet/ReplicaSet/Job/CronJob container 字段和 exact Plugin Package admission ConfigMap,生成带输入/输出 digest、各 role occurrence、release annotation 与 self digest 的 locked manifest/report。调用方必须显式声明 required role,未知位置的完整 role authority、畸形 container image、缺失角色、YAML alias/cycle/非 mapping、超限或覆盖输出全部失败关闭;audit 从原 release set 与原 render byte-exact 重建。采用 post-render 是因为真实原型证明外层 Kustomize component 不能可靠覆盖内层已选 repository/digest,且 `images` transformer 不处理 ConfigMap `data.image`。本机 kubectl 1.36.1/Kustomize 5.8.1 已真实渲染 CloudNativePG Core、Cluster AI、Worker node 与 Plugin Package Executor 四类清单,内层零 digest 均被同一 release set 的精确引用替换;定向 deployment-lock 11/11、发布链路联动 101/101,静态审计冻结 224 个 YAML、31 个直接 role image 引用与两个 admission authority。工具只在工作站运行,低配路由器只消费 Local selection,不新增 Node/YAML/Kubernetes/registry 工具、package、依赖、常驻进程或资源;Cluster 也不新增 controller/webhook/CRD/RBAC。完整 backend 共 1,295 项,1,293 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,10 项架构/部署审计与 14 档 Local artifact 全部 compatible。默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。 - D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io//qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v-` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。 - D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。 diff --git a/docs/adr/ADR-0430-auditable-release-catalog-consumption-ceremony.md b/docs/adr/ADR-0430-auditable-release-catalog-consumption-ceremony.md new file mode 100644 index 00000000..ba8b0d41 --- /dev/null +++ b/docs/adr/ADR-0430-auditable-release-catalog-consumption-ceremony.md @@ -0,0 +1,99 @@ +# ADR-0430:可审计的 Release Catalog 消费工作站 Ceremony + +- 状态:Accepted(实现与本地契约门完成;真实公开 catalog 结果待实际 release tag) +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-335、D-336、D-337、D-338 +- 关联 ADR:ADR-0424、ADR-0425、ADR-0427、ADR-0428、ADR-0429 + +## 上下文 + +ADR-0428 已让发布 workflow 生成持久 OCI release catalog,ADR-0429 已能把验证后的 release set 离线物化为 Local +selection 或 Kubernetes locked manifest。但部署者侧仍依赖手工 shell 串联 discovery tag 解析、Cosign、GitHub +attestation、`regctl artifact get`、raw manifest 读取和 standalone inspection。该流程会把文件写入安全性留给调用者的 +umask/重定向语义,也不检查 discovery tag 是否在 ceremony 中途移动,不保留工具身份、命令边界或可离线复验的 manifest。 + +因此,发布端已经有机器化证据,并不等于部署端消费同一 immutable catalog 的过程已经机器化。直接在 release workflow +内自动部署又会把发布写权限与生产部署权限合并,破坏显式运维授权边界。 + +## 决策 + +1. 新增根级 `ql3-release-catalog-consumption-ceremony.cjs`,只在可信维护工作站显式运行。`create` 模式从 + source-derived version、40-hex revision、exact tag ref、closed `local|cluster|all` scope、owner/source repository 和 + 三个 canonical absolute executable 推导唯一 catalog discovery reference,不接受任意 registry/repository/ref。 +2. ceremony 对 discovery tag 解析两次;两次都必须返回同一 lowercase SHA-256 digest。所有后续命令只使用第一次得到的 + `ghcr.io//qinglong3-release-catalog@sha256:...`。tag 在验证窗口中移动时失败且不发布 bundle;tag 永远没有部署 + authority。 +3. immutable catalog 必须通过 exact workflow identity 的 keyless Cosign verification,以及绑定 source repository、 + workflow、tag、revision、非 self-hosted runner 和 OCI bundle 的 GitHub provenance verification。owner-private token 只注入 + 单个 `gh attestation verify` 子进程;`regctl` 与 `cosign` 不接收该 token,也不继承 ambient environment。 +4. ceremony 直接捕获而不是 shell 重定向 release-set 与 raw manifest bytes。release set 必须是 bounded canonical UTF-8 JSON, + 通过 standalone structure/identity/family/self-digest inspection;raw manifest 必须与 immutable digest、单层 media type、 + empty config、basename、size/content digest 和四项 annotation 完全一致。工具在每一步前及 ceremony 结束前按 + path/dev/inode/size/SHA-256 复验。 +5. 成功后才创建一个此前不存在的 owner-private `0700` bundle directory,并以 `0600`、no-replace、fsync 写入三项精确文件: + 原始 canonical release set、原始 raw catalog manifest、canonical consumption report。report 绑定 source/release/catalog、 + reconstructed plan/receipt digest、文件 digest、镜像闭包、工具 digest、六步 argv/stdout/stderr digest 和自身 digest;不保存 + token、原始 transcript、本机路径或 workstation identity。 +6. `audit` 模式完全离线,不调用外部工具。它要求 bundle 只有上述三项文件,重新执行 release-set inspection、catalog plan、 + raw manifest 与 receipt reconstruction,并精确重建 report;外部 Cosign/GitHub 结果只作为 digest transcript 记录,明确 + `externalToolResultsReplayed=false`,不能冒充离线重验了网络证明。 +7. 外部工具的 cache/config/tmp 只能写入 bundle parent 下的 ceremony 私有临时目录,成功或失败后 best-effort 清理。ceremony + 不执行 registry/GitHub mutation、不运行 Compose/Kubernetes apply、不连接数据库,也不取得部署 action authority。D337 + materializer 只消费 audit 成功 bundle 中的 release set,部署仍是后续独立人工授权步骤。 + +## 部署与资源影响 + +- 不新增 workspace package、生产 dependency、镜像层、数据库、migration、SQL、role、Pool、Pod、controller、listener、timer + 或 watcher;18-package 边界保持不变。 +- Edge/Standalone/低配路由设备不安装 Node、regctl、Cosign、GitHub CLI 或 ceremony。可信工作站只把审计后的 Local release set、 + selection 和 immutable image 交给设备。 +- Cluster 工作站可消费 `cluster|all` 的四角色闭包,再把 release set 交给离线 Kubernetes post-render materializer;ceremony + 本身没有 Kubernetes client 或 API authority。 +- 公开 GHCR、Fulcio/Rekor 与 GitHub Attestations 的实际可用性只由真实 release/consumption run 证明。本地 fake tool 测试只证明 + 命令、隔离、输入输出和失败关闭契约。 + +## 失败与恢复 + +- discovery 两次解析不同:丢弃本次结果,以新的未使用输出目录重新开始;不能把任一 tag 值当成部署 authority。 +- Cosign/GitHub/registry 失败:不发布 bundle,不降级为仅检查 JSON 或 tag。 +- tool inode/bytes、token file、output parent 在运行中漂移:失败关闭;不从 ambient PATH 换用另一个 executable。 +- 创建最终目录后发生进程/文件系统失败:保留不完整目录供调查,但该目录无法通过 exact-three-file audit;恢复必须使用新目录, + 不覆盖或补写旧 bundle。 +- offline audit 失败:重新执行在线 ceremony;不得手工修复 report/digest 声称通过。 + +## 被拒绝的替代方案 + +### 保留文档中的 shell 重定向即可 + +拒绝。它无法统一 no-replace、mode、symlink、输出上限、tool mutation、tag 中途移动和持久 manifest 证据,且容易把调用者的 +ambient credential/environment 带入工具。 + +### 只保存下载后的 release-set JSON + +拒绝。release-set self digest 不能证明 OCI manifest 的 media type、layer、title、annotation 或 immutable digest;raw manifest +是离线重建 publication plan/receipt 的必要证据。 + +### 在 release workflow 成功后自动部署 + +拒绝。发布 OIDC/package 写权限与生产集群/路由设备 authority 必须分离;验证成功只生成无行动权的部署输入。 + +### 在路由设备上运行 ceremony + +拒绝。该工具依赖 Node、regctl、Cosign 与 GitHub CLI,并执行网络验真;这些成本和凭据面不应进入低资源运行设备。 + +## 验证 + +- ceremony 正负门覆盖 Local/All scope、六步工具与 token 隔离、双次 discovery、canonical release set、raw manifest、工具 + mutation、external failure、no-replace、private mode、symlink/open CLI,以及 release-set/manifest/report/目录漂移;独立 + ceremony 测试 20/20; +- 与 deployment lock、release candidate、release set、release catalog 和 image release workflow 的定向发布链联动 121/121;共享 + supply-chain CI 显式运行新测试,删除该测试会失败关闭; +- 完整 backend 共 1,315 项,1,313 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 仍为 + 18 packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`; +- release version、deployment lock surfaces、package boundary、cluster dependency、Edge import、Cluster/Worker deployment、image + release、Local image 与 Console distribution 共 10 项审计全部 compatible;14 档 Local artifact 全部 compatible,默认 + Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 + 7,315,930/7,316,038 bytes; +- Cluster Admin pack dry-run 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked; +- 本 Gate 不改变数据库或 HA 拓扑,除非完整回归发现相关漂移,否则复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA + 基线,不把未重跑结果声明为本阶段新证据。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 913bf93c..dd5b832d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -433,6 +433,7 @@ | [ADR-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted | | [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted | | [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted | +| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index c6e5f2b7..0ec27cd6 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -26,7 +26,9 @@ image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin ## 工作站验真 -以下命令应在可信维护工作站运行;先设置目标发布的显式值: +以下命令应在可信维护工作站运行;先设置目标发布的显式值、三个经过 `realpath` 解析的工具路径,以及一个 +current-owner、无 group/other 权限、无换行的短期 GitHub token file。`output_parent` 必须是已有的 owner-private +目录,`bundle` 必须尚不存在: ```sh owner='' @@ -35,58 +37,53 @@ version='' scope='local' # 或 cluster/all source_ref='refs/tags/v' source_revision='<40-hex-git-revision>' -catalog="ghcr.io/${owner}/qinglong3-release-catalog" -discovery="${catalog}:v${version}-${scope}" -digest="$(regctl image digest "${discovery}")" -immutable="${catalog}@${digest}" -release_set="$(pwd)/qinglong3-release-set-${version}-${scope}.json" +regctl_path='/regctl' +cosign_path='/cosign' +gh_path='/gh' +token_file='' +output_parent='' +bundle="${output_parent}/qinglong3-release-catalog-consumption-${version}-${scope}" ``` -要求 `digest` 精确匹配 `sha256:<64 lowercase hex>`,然后按 immutable reference 下载并验证: +使用机器化 ceremony 完成 discovery 双次解析、immutable Cosign/GitHub provenance 验证、release-set 下载/inspection、raw +manifest/plan/receipt reconstruction 和 no-replace bundle 发布: ```sh -regctl artifact get \ - --file "qinglong3-release-set-${version}-${scope}.json" \ - "${immutable}" > "${release_set}" - -cosign verify \ - --certificate-identity \ - "https://github.com/${repository}/.github/workflows/ql3-image-release.yml@${source_ref}" \ - --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - "${immutable}" - -gh attestation verify "oci://${immutable}" \ - --repo "${repository}" \ - --signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \ - --source-digest "${source_revision}" \ - --source-ref "${source_ref}" \ - --deny-self-hosted-runners \ - --bundle-from-oci - -node scripts/ql3-release-set-contract.cjs \ - --mode=inspect \ +node scripts/ql3-release-catalog-consumption-ceremony.cjs \ + --mode=create \ --version="${version}" \ --source-revision="${source_revision}" \ --source-ref="${source_ref}" \ --release-scope="${scope}" \ --repository-owner="${owner}" \ - --report="${release_set}" + --source-repository="${repository}" \ + --regctl="${regctl_path}" \ + --cosign="${cosign_path}" \ + --gh="${gh_path}" \ + --github-token-file="${token_file}" \ + --output-directory="${bundle}" + +node scripts/ql3-release-catalog-consumption-ceremony.cjs \ + --mode=audit \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --source-repository="${repository}" \ + --output-directory="${bundle}" + +release_set="${bundle}/qinglong3-release-set-${version}-${scope}.json" +catalog_manifest="${bundle}/qinglong3-release-catalog-manifest-${version}-${scope}.json" +consumption_report="${bundle}/qinglong3-release-catalog-consumption-${version}-${scope}.json" ``` -若使用 90 天 bundle 中的文件,还应验证文件 provenance;它是 OCI catalog 的补充证据,不替代上述 immutable -catalog 验证: +bundle 只能包含上面三项 `0600` 文件,目录自身为 `0700`。ceremony 不使用 shell 重定向、不覆盖文件,并在每一步前与 +结束前复验三个 executable;GitHub token 只进入一个 `gh attestation verify` 子进程。raw manifest 让离线 audit 能重建 +catalog plan/receipt,但它不会离线重放网络签名,因此 audit 输出必须保持 `externalToolResultsReplayed:false`。 -```sh -gh attestation verify "${release_set}" \ - --repo "${repository}" \ - --signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \ - --source-digest "${source_revision}" \ - --source-ref "${source_ref}" \ - --deny-self-hosted-runners -``` - -`inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image -records;其输出必须保持 `sourceRecordsReplayed:false`。 +若改用 90 天 Actions bundle 的 release-set 文件,仍需另行验证该文件的 provenance;它不能替代上述 immutable catalog +ceremony,也不能与在线 ceremony 下载的文件混合后伪造成同一 three-file bundle。 ## 生成离线 deployment lock diff --git a/package.json b/package.json index b6ae2880..249645d1 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,7 @@ "audit:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-evidence-audit.cjs", "gate:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-gate.cjs", "audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs", + "release-catalog-consumption:ql3": "node scripts/ql3-release-catalog-consumption-ceremony.cjs", "deployment-lock:ql3": "node scripts/ql3-deployment-lock-contract.cjs", "audit:deployment-lock-surfaces:ql3": "node scripts/ql3-deployment-lock-contract.cjs --mode=surfaces-audit", "audit:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs", diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index b1b74e0f..cd408f9d 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow( ); requirePattern( source, - /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, - 'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests', + /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, + 'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory', ); requirePattern( source, diff --git a/scripts/ql3-release-catalog-consumption-ceremony.cjs b/scripts/ql3-release-catalog-consumption-ceremony.cjs new file mode 100644 index 00000000..f2a2dfd2 --- /dev/null +++ b/scripts/ql3-release-catalog-consumption-ceremony.cjs @@ -0,0 +1,1095 @@ +#!/usr/bin/env node + +'use strict'; + +const { spawnSync } = require('node:child_process'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const semver = require('semver'); + +const { + ARTIFACT_TYPE, + createCatalogPlan, + createCatalogReceipt, +} = require('./ql3-release-catalog-contract.cjs'); +const { + RELEASE_SET_SCHEMA, + inspectReleaseSet, +} = require('./ql3-release-set-contract.cjs'); +const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); + +const SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1'; +const WORKFLOW = '.github/workflows/ql3-image-release.yml'; +const RELEASE_SCOPES = Object.freeze(['local', 'cluster', 'all']); +const TOOL_NAMES = Object.freeze(['regctl', 'cosign', 'gh']); +const MAX_TOOL_BYTES = 256 * 1024 * 1024; +const MAX_ARTIFACT_BYTES = 1024 * 1024; +const MAX_TRANSCRIPT_BYTES = 2 * 1024 * 1024; +const MAX_REPORT_BYTES = 1024 * 1024; +const CONTROL = /[\u0000-\u001f\u007f]/u; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u; +const REPOSITORY_PATTERN = + /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u; + +class QingLong3ReleaseCatalogConsumptionError extends Error { + constructor(message) { + super(`QingLong 3 release catalog consumption failed: ${message}`); + this.name = 'QingLong3ReleaseCatalogConsumptionError'; + } +} + +function fail(message) { + throw new QingLong3ReleaseCatalogConsumptionError(message); +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalJson(value) { + return `${JSON.stringify(value)}\n`; +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function resolveCanonicalAbsolute(value, label) { + if ( + typeof value !== 'string' || + !path.isAbsolute(value) || + value.length > 4096 || + CONTROL.test(value) || + path.resolve(value) !== value + ) { + fail(`${label} must be one canonical absolute path`); + } + return value; +} + +function currentUid() { + if (typeof process.getuid !== 'function') { + fail('workstation owner identity is unavailable'); + } + return process.getuid(); +} + +function canonicalPrivateDirectory(directoryPath, label) { + const resolved = resolveCanonicalAbsolute(directoryPath, label); + let stat; + try { + stat = fs.lstatSync(resolved); + } catch { + fail(`${label} must exist`); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(resolved) !== resolved || + stat.uid !== currentUid() || + (stat.mode & 0o077) !== 0 + ) { + fail(`${label} must be one owner-private canonical directory`); + } + return Object.freeze({ + path: resolved, + dev: stat.dev, + ino: stat.ino, + }); +} + +function preflightOutputDirectory(directoryPath) { + const resolved = resolveCanonicalAbsolute(directoryPath, 'output directory'); + if (fs.existsSync(resolved)) { + fail('output directory must not already exist'); + } + const parent = canonicalPrivateDirectory( + path.dirname(resolved), + 'output parent', + ); + return Object.freeze({ path: resolved, parent }); +} + +function executable(filePath, expectedName) { + const resolved = resolveCanonicalAbsolute(filePath, expectedName); + let stat; + try { + stat = fs.lstatSync(resolved); + } catch { + fail(`${expectedName} executable is unavailable`); + } + if ( + path.basename(resolved) !== expectedName || + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > MAX_TOOL_BYTES || + fs.realpathSync(resolved) !== resolved || + (stat.mode & 0o111) === 0 || + (stat.mode & 0o022) !== 0 + ) { + fail(`${expectedName} must be one immutable canonical executable`); + } + return Object.freeze({ + name: expectedName, + path: resolved, + dev: stat.dev, + ino: stat.ino, + sizeBytes: stat.size, + sha256: sha256(fs.readFileSync(resolved)), + }); +} + +function verifyExecutable(tool) { + const current = executable(tool.path, tool.name); + if ( + current.dev !== tool.dev || + current.ino !== tool.ino || + current.sizeBytes !== tool.sizeBytes || + current.sha256 !== tool.sha256 + ) { + fail(`${tool.name} executable changed during the ceremony`); + } +} + +function readPrivateToken(filePath) { + const resolved = resolveCanonicalAbsolute(filePath, 'GitHub token file'); + let before; + try { + before = fs.lstatSync(resolved); + } catch { + fail('GitHub token file is unavailable'); + } + if ( + !before.isFile() || + before.isSymbolicLink() || + before.size < 8 || + before.size > 4096 || + before.uid !== currentUid() || + (before.mode & 0o077) !== 0 || + fs.realpathSync(resolved) !== resolved + ) { + fail('GitHub token file must be one owner-private canonical file'); + } + let descriptor = -1; + let bytes; + try { + descriptor = fs.openSync( + resolved, + fs.constants.O_RDONLY | + (fs.constants.O_CLOEXEC ?? 0) | + (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor); + if ( + opened.dev !== before.dev || + opened.ino !== before.ino || + opened.mode !== before.mode || + opened.uid !== before.uid || + opened.size !== before.size + ) { + fail('GitHub token file changed while opening'); + } + bytes = Buffer.alloc(opened.size); + let offset = 0; + while (offset < bytes.length) { + const count = fs.readSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (count < 1) fail('GitHub token file read was incomplete'); + offset += count; + } + const token = bytes.toString('utf8'); + if ( + token.trim() !== token || + token.length < 8 || + CONTROL.test(token) || + /\s/u.test(token) + ) { + fail('GitHub token file is malformed'); + } + return Object.freeze({ + bytes, + token, + dev: opened.dev, + ino: opened.ino, + }); + } catch (error) { + if (bytes) bytes.fill(0); + if (error instanceof QingLong3ReleaseCatalogConsumptionError) throw error; + fail('GitHub token file could not be read safely'); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function validateIdentity(options) { + if ( + typeof options.version !== 'string' || + !VERSION_PATTERN.test(options.version) || + semver.valid(options.version) !== options.version || + !/^[a-f0-9]{40}$/u.test(options.sourceRevision || '') || + options.sourceRef !== `refs/tags/v${options.version}` || + !RELEASE_SCOPES.includes(options.releaseScope) || + !OWNER_PATTERN.test(options.repositoryOwner || '') || + !REPOSITORY_PATTERN.test(options.sourceRepository || '') || + !options.sourceRepository.startsWith(`${options.repositoryOwner}/`) + ) { + fail('release identity is invalid'); + } + return Object.freeze({ + version: options.version, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + releaseScope: options.releaseScope, + repositoryOwner: options.repositoryOwner, + sourceRepository: options.sourceRepository, + }); +} + +function releaseSetFileName(identity) { + return `qinglong3-release-set-${identity.version}-${identity.releaseScope}.json`; +} + +function manifestFileName(identity) { + return `qinglong3-release-catalog-manifest-${identity.version}-${identity.releaseScope}.json`; +} + +function reportFileName(identity) { + return `qinglong3-release-catalog-consumption-${identity.version}-${identity.releaseScope}.json`; +} + +function catalogIdentity(identity) { + const repository = `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog`; + return Object.freeze({ + repository, + discovery: `${repository}:v${identity.version}-${identity.releaseScope}`, + workflow: `${identity.sourceRepository}/${WORKFLOW}`, + workflowIdentity: `https://github.com/${identity.sourceRepository}/${WORKFLOW}@${identity.sourceRef}`, + }); +} + +function argvDigest(args) { + return sha256(Buffer.from(JSON.stringify(args), 'utf8')); +} + +function runStep(tool, name, args, env, timeoutMs) { + verifyExecutable(tool); + const result = spawnSync(tool.path, args, { + encoding: 'buffer', + env, + timeout: timeoutMs, + maxBuffer: MAX_TRANSCRIPT_BYTES, + windowsHide: true, + }); + const stdout = Buffer.isBuffer(result.stdout) + ? result.stdout + : Buffer.alloc(0); + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr + : Buffer.alloc(0); + if ( + result.error || + result.signal || + result.status !== 0 || + stdout.length > MAX_TRANSCRIPT_BYTES || + stderr.length > MAX_TRANSCRIPT_BYTES + ) { + stdout.fill(0); + stderr.fill(0); + fail(`${name} did not complete successfully`); + } + return Object.freeze({ + record: Object.freeze({ + sequence: 0, + name, + tool: tool.name, + executableSha256: tool.sha256, + argvSha256: argvDigest(args), + stdoutBytes: stdout.length, + stdoutSha256: sha256(stdout), + stderrBytes: stderr.length, + stderrSha256: sha256(stderr), + exitCode: 0, + }), + stdout, + stderr, + }); +} + +function parseDigestOutput(bytes) { + const text = bytes.toString('utf8'); + const digest = text.endsWith('\n') ? text.slice(0, -1) : text; + if ( + !DIGEST_PATTERN.test(digest) || + (text !== digest && text !== `${digest}\n`) + ) { + fail('catalog discovery did not resolve to one exact digest'); + } + return digest; +} + +function parseCanonicalReleaseSet(bytes, identity) { + if (bytes.length < 2 || bytes.length > MAX_ARTIFACT_BYTES) { + fail('release set exceeds the bounded artifact size'); + } + const text = bytes.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(bytes)) { + fail('release set must be valid UTF-8'); + } + let value; + try { + value = JSON.parse(text); + } catch { + fail('release set must contain valid JSON'); + } + if (canonicalJson(value) !== text) { + fail('release set must use exact canonical JSON encoding'); + } + const inspection = inspectReleaseSet(value, identity); + return Object.freeze({ value, inspection }); +} + +function parseManifest(bytes, manifestDigest, plan) { + if (bytes.length < 2 || bytes.length > MAX_ARTIFACT_BYTES) { + fail('catalog manifest exceeds the bounded artifact size'); + } + const text = bytes.toString('utf8'); + if (!Buffer.from(text, 'utf8').equals(bytes)) { + fail('catalog manifest must be valid UTF-8'); + } + const receipt = createCatalogReceipt(plan, text, manifestDigest); + return Object.freeze({ text, receipt }); +} + +function withSequence(records) { + return records.map((record, index) => + Object.freeze({ ...record, sequence: index + 1 }), + ); +} + +function expectedArguments(identity, catalog, immutable, fileName) { + return Object.freeze([ + Object.freeze(['image', 'digest', catalog.discovery]), + Object.freeze([ + 'verify', + '--certificate-identity', + catalog.workflowIdentity, + '--certificate-oidc-issuer', + 'https://token.actions.githubusercontent.com', + immutable, + ]), + Object.freeze([ + 'attestation', + 'verify', + `oci://${immutable}`, + '--repo', + identity.sourceRepository, + '--signer-workflow', + catalog.workflow, + '--source-digest', + identity.sourceRevision, + '--source-ref', + identity.sourceRef, + '--deny-self-hosted-runners', + '--bundle-from-oci', + ]), + Object.freeze(['artifact', 'get', '--file', fileName, immutable]), + Object.freeze(['manifest', 'get', immutable, '--format', 'raw-body']), + Object.freeze(['image', 'digest', catalog.discovery]), + ]); +} + +function createReport({ + identity, + catalog, + manifestDigest, + plan, + receipt, + releaseSet, + releaseSetBytes, + manifestBytes, + tools, + steps, + observedAt, +}) { + const unsigned = { + schemaVersion: 1, + schema: SCHEMA, + observedAt, + release: { + version: identity.version, + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + scope: identity.releaseScope, + }, + sourceRepository: identity.sourceRepository, + workflowIdentity: catalog.workflowIdentity, + discovery: { + reference: catalog.discovery, + authority: 'none', + initialDigest: manifestDigest, + finalDigest: manifestDigest, + stableDuringCeremony: true, + }, + catalog: { + repository: 'qinglong3-release-catalog', + immutableReference: `${catalog.repository}@${manifestDigest}`, + manifestDigest, + artifactType: ARTIFACT_TYPE, + planDigest: plan.planDigest, + reconstructedReceiptDigest: receipt.receiptDigest, + }, + releaseSet: { + schema: RELEASE_SET_SCHEMA, + releaseSetDigest: releaseSet.value.releaseSetDigest, + fileName: releaseSetFileName(identity), + contentDigest: sha256(releaseSetBytes), + bytes: releaseSetBytes.length, + imageCount: releaseSet.inspection.imageCount, + images: [...releaseSet.inspection.images], + references: [...releaseSet.inspection.references], + }, + files: { + releaseSet: { + name: releaseSetFileName(identity), + bytes: releaseSetBytes.length, + sha256: sha256(releaseSetBytes), + }, + catalogManifest: { + name: manifestFileName(identity), + bytes: manifestBytes.length, + sha256: sha256(manifestBytes), + }, + }, + tools: TOOL_NAMES.map((name) => ({ + name, + sizeBytes: tools[name].sizeBytes, + sha256: tools[name].sha256, + })), + verification: { + discoveryResolvedTwice: true, + discoveryStable: true, + keylessSignature: 'exact_workflow_identity', + githubProvenance: 'source_tag_and_revision_bound', + releaseSetInspection: 'standalone_structure_identity_and_self_digest', + remoteManifestStructure: 'exact', + catalogReceiptReconstructed: true, + outputBytes: 'exact_downloaded_bytes', + }, + steps, + claims: { + externalToolResults: 'exit_zero_with_digest_only_transcript', + offlineAudit: 'structure_identity_manifest_and_self_digest', + networkAccess: 'registry_and_github_verification_only', + workstationFileWrites: 'private_temporary_only_plus_final_bundle', + registryMutation: false, + githubMutation: false, + deploymentMutation: false, + credentialsIncluded: false, + discoveryTagAuthority: 'none', + actionAuthority: 'none', + }, + }; + return Object.freeze({ + ...unsigned, + contentDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function writeNoReplace(filePath, bytes) { + let descriptor = -1; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_CLOEXEC ?? 0) | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + let offset = 0; + while (offset < bytes.length) { + const count = fs.writeSync( + descriptor, + bytes, + offset, + bytes.length - offset, + offset, + ); + if (count < 1) fail('evidence file write was incomplete'); + offset += count; + } + fs.fsyncSync(descriptor); + } catch (error) { + if (error instanceof QingLong3ReleaseCatalogConsumptionError) throw error; + fail('evidence file could not be created without replacement'); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function publishBundle( + output, + identity, + releaseSetBytes, + manifestBytes, + report, +) { + const parent = canonicalPrivateDirectory(output.parent.path, 'output parent'); + if ( + parent.dev !== output.parent.dev || + parent.ino !== output.parent.ino || + fs.existsSync(output.path) + ) { + fail('output directory changed before publication'); + } + try { + fs.mkdirSync(output.path, { mode: 0o700 }); + } catch { + fail('output directory could not be created without replacement'); + } + writeNoReplace( + path.join(output.path, releaseSetFileName(identity)), + releaseSetBytes, + ); + writeNoReplace( + path.join(output.path, manifestFileName(identity)), + manifestBytes, + ); + writeNoReplace( + path.join(output.path, reportFileName(identity)), + Buffer.from(canonicalJson(report), 'utf8'), + ); + const descriptor = fs.openSync(output.path, fs.constants.O_RDONLY); + try { + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function readBundleFile(directory, name, maximumBytes, requireCanonical) { + const filePath = path.join(directory, name); + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + fail(`bundle file is missing: ${name}`); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + stat.uid !== currentUid() || + (stat.mode & 0o077) !== 0 || + fs.realpathSync(filePath) !== filePath + ) { + fail(`bundle file is not one owner-private canonical file: ${name}`); + } + const bytes = fs.readFileSync(filePath); + if (requireCanonical) { + const text = bytes.toString('utf8'); + let value; + try { + value = JSON.parse(text); + } catch { + fail(`bundle file must contain valid JSON: ${name}`); + } + if (canonicalJson(value) !== text) { + fail(`bundle file must use canonical JSON: ${name}`); + } + return Object.freeze({ bytes, value }); + } + return Object.freeze({ bytes }); +} + +function validateDynamicReport(report, identity, catalog, manifestDigest) { + if ( + !exactKeys(report, [ + 'schemaVersion', + 'schema', + 'observedAt', + 'release', + 'sourceRepository', + 'workflowIdentity', + 'discovery', + 'catalog', + 'releaseSet', + 'files', + 'tools', + 'verification', + 'steps', + 'claims', + 'contentDigest', + ]) || + report.schemaVersion !== 1 || + report.schema !== SCHEMA || + typeof report.observedAt !== 'string' || + Number.isNaN(Date.parse(report.observedAt)) || + new Date(report.observedAt).toISOString() !== report.observedAt || + !Array.isArray(report.tools) || + report.tools.length !== TOOL_NAMES.length || + !Array.isArray(report.steps) || + report.steps.length !== 6 || + !DIGEST_PATTERN.test(report.contentDigest || '') + ) { + fail('consumption report shape is invalid'); + } + const tools = {}; + for (let index = 0; index < TOOL_NAMES.length; index += 1) { + const tool = report.tools[index]; + const name = TOOL_NAMES[index]; + if ( + !exactKeys(tool, ['name', 'sizeBytes', 'sha256']) || + tool.name !== name || + !Number.isSafeInteger(tool.sizeBytes) || + tool.sizeBytes < 2 || + tool.sizeBytes > MAX_TOOL_BYTES || + !DIGEST_PATTERN.test(tool.sha256 || '') + ) { + fail('consumption report tool evidence is invalid'); + } + tools[name] = tool; + } + const immutable = `${catalog.repository}@${manifestDigest}`; + const arguments_ = expectedArguments( + identity, + catalog, + immutable, + releaseSetFileName(identity), + ); + const names = [ + 'discovery_digest_before', + 'keyless_signature', + 'catalog_provenance', + 'release_set_download', + 'catalog_manifest_download', + 'discovery_digest_after', + ]; + const toolNames = ['regctl', 'cosign', 'gh', 'regctl', 'regctl', 'regctl']; + for (let index = 0; index < report.steps.length; index += 1) { + const step = report.steps[index]; + const tool = tools[toolNames[index]]; + if ( + !exactKeys(step, [ + 'sequence', + 'name', + 'tool', + 'executableSha256', + 'argvSha256', + 'stdoutBytes', + 'stdoutSha256', + 'stderrBytes', + 'stderrSha256', + 'exitCode', + ]) || + step.sequence !== index + 1 || + step.name !== names[index] || + step.tool !== toolNames[index] || + step.executableSha256 !== tool.sha256 || + step.argvSha256 !== argvDigest(arguments_[index]) || + !Number.isSafeInteger(step.stdoutBytes) || + step.stdoutBytes < 0 || + step.stdoutBytes > MAX_TRANSCRIPT_BYTES || + !DIGEST_PATTERN.test(step.stdoutSha256 || '') || + !Number.isSafeInteger(step.stderrBytes) || + step.stderrBytes < 0 || + step.stderrBytes > MAX_TRANSCRIPT_BYTES || + !DIGEST_PATTERN.test(step.stderrSha256 || '') || + (step.stdoutBytes === 0 && + step.stdoutSha256 !== sha256(Buffer.alloc(0))) || + (step.stderrBytes === 0 && + step.stderrSha256 !== sha256(Buffer.alloc(0))) || + step.exitCode !== 0 + ) { + fail('consumption report step evidence is invalid'); + } + } + const digestOutputs = [ + Buffer.from(manifestDigest, 'utf8'), + Buffer.from(`${manifestDigest}\n`, 'utf8'), + ]; + for (const index of [0, 5]) { + if ( + !digestOutputs.some( + (bytes) => + report.steps[index].stdoutBytes === bytes.length && + report.steps[index].stdoutSha256 === sha256(bytes), + ) + ) { + fail('discovery digest transcript is invalid'); + } + } + return Object.freeze({ tools, steps: report.steps }); +} + +function auditCeremonyBundle(options) { + const identity = validateIdentity(options); + const catalog = catalogIdentity(identity); + const directory = canonicalPrivateDirectory( + resolveCanonicalAbsolute(options.outputDirectory, 'output directory'), + 'output directory', + ); + const expectedNames = [ + manifestFileName(identity), + reportFileName(identity), + releaseSetFileName(identity), + ].sort(); + if ( + JSON.stringify(fs.readdirSync(directory.path).sort()) !== + JSON.stringify(expectedNames) + ) { + fail('consumption bundle must contain the exact three evidence files'); + } + const releaseFile = readBundleFile( + directory.path, + releaseSetFileName(identity), + MAX_ARTIFACT_BYTES, + true, + ); + const manifestFile = readBundleFile( + directory.path, + manifestFileName(identity), + MAX_ARTIFACT_BYTES, + false, + ); + const reportFile = readBundleFile( + directory.path, + reportFileName(identity), + MAX_REPORT_BYTES, + true, + ); + const releaseSet = parseCanonicalReleaseSet(releaseFile.bytes, identity); + const manifestDigest = sha256(manifestFile.bytes); + const plan = createCatalogPlan(releaseSet.value, identity); + const manifest = parseManifest(manifestFile.bytes, manifestDigest, plan); + const dynamic = validateDynamicReport( + reportFile.value, + identity, + catalog, + manifestDigest, + ); + const downloadStep = reportFile.value.steps[3]; + const manifestStep = reportFile.value.steps[4]; + if ( + downloadStep.stdoutBytes !== releaseFile.bytes.length || + downloadStep.stdoutSha256 !== sha256(releaseFile.bytes) || + manifestStep.stdoutBytes !== manifestFile.bytes.length || + manifestStep.stdoutSha256 !== manifestDigest + ) { + fail('download transcripts differ from the durable bundle bytes'); + } + const expected = createReport({ + identity, + catalog, + manifestDigest, + plan, + receipt: manifest.receipt, + releaseSet, + releaseSetBytes: releaseFile.bytes, + manifestBytes: manifestFile.bytes, + tools: dynamic.tools, + steps: dynamic.steps, + observedAt: reportFile.value.observedAt, + }); + if (JSON.stringify(reportFile.value) !== JSON.stringify(expected)) { + fail('consumption report differs from the verified bundle'); + } + return Object.freeze({ + compatible: true, + releaseScope: identity.releaseScope, + releaseSetDigest: releaseSet.value.releaseSetDigest, + catalogManifestDigest: manifestDigest, + immutableReference: `${catalog.repository}@${manifestDigest}`, + imageCount: releaseSet.inspection.imageCount, + discoveryTagAuthority: 'none', + externalToolResultsReplayed: false, + deploymentMutation: false, + contentDigest: expected.contentDigest, + }); +} + +function runCeremony(options) { + const identity = validateIdentity(options); + const output = preflightOutputDirectory(options.outputDirectory); + const tools = Object.freeze( + Object.fromEntries( + TOOL_NAMES.map((name) => [name, executable(options[name], name)]), + ), + ); + const identities = TOOL_NAMES.map( + (name) => `${tools[name].dev}:${tools[name].ino}`, + ); + if (new Set(identities).size !== identities.length) { + fail('workstation tools must be three distinct executables'); + } + const catalog = catalogIdentity(identity); + const temporary = fs.mkdtempSync( + path.join(output.parent.path, '.ql3-release-catalog-consumption-'), + ); + const records = []; + const transcripts = []; + let token; + const keep = (result) => { + records.push(result.record); + transcripts.push(result.stdout, result.stderr); + return result; + }; + try { + fs.chmodSync(temporary, 0o700); + const cacheDirectory = path.join(temporary, 'cache'); + const configDirectory = path.join(temporary, 'config'); + const temporaryDirectory = path.join(temporary, 'tmp'); + for (const directory of [ + cacheDirectory, + configDirectory, + temporaryDirectory, + ]) { + fs.mkdirSync(directory, { mode: 0o700 }); + } + token = readPrivateToken(options.githubTokenFile); + if ( + TOOL_NAMES.some( + (name) => + tools[name].dev === token.dev && tools[name].ino === token.ino, + ) + ) { + fail('GitHub token file cannot alias a workstation executable'); + } + const publicEnv = Object.freeze({ + LANG: 'C', + LC_ALL: 'C', + NO_COLOR: '1', + GH_PROMPT_DISABLED: '1', + XDG_CACHE_HOME: cacheDirectory, + XDG_CONFIG_HOME: configDirectory, + TMPDIR: temporaryDirectory, + }); + const githubEnv = Object.freeze({ ...publicEnv, GH_TOKEN: token.token }); + const before = keep( + runStep( + tools.regctl, + 'discovery_digest_before', + ['image', 'digest', catalog.discovery], + publicEnv, + 300_000, + ), + ); + const manifestDigest = parseDigestOutput(before.stdout); + const immutable = `${catalog.repository}@${manifestDigest}`; + keep( + runStep( + tools.cosign, + 'keyless_signature', + [ + 'verify', + '--certificate-identity', + catalog.workflowIdentity, + '--certificate-oidc-issuer', + 'https://token.actions.githubusercontent.com', + immutable, + ], + publicEnv, + 300_000, + ), + ); + keep( + runStep( + tools.gh, + 'catalog_provenance', + [ + 'attestation', + 'verify', + `oci://${immutable}`, + '--repo', + identity.sourceRepository, + '--signer-workflow', + catalog.workflow, + '--source-digest', + identity.sourceRevision, + '--source-ref', + identity.sourceRef, + '--deny-self-hosted-runners', + '--bundle-from-oci', + ], + githubEnv, + 300_000, + ), + ); + const downloaded = keep( + runStep( + tools.regctl, + 'release_set_download', + ['artifact', 'get', '--file', releaseSetFileName(identity), immutable], + publicEnv, + 300_000, + ), + ); + const releaseSet = parseCanonicalReleaseSet(downloaded.stdout, identity); + const plan = createCatalogPlan(releaseSet.value, identity); + const rawManifest = keep( + runStep( + tools.regctl, + 'catalog_manifest_download', + ['manifest', 'get', immutable, '--format', 'raw-body'], + publicEnv, + 300_000, + ), + ); + const manifest = parseManifest(rawManifest.stdout, manifestDigest, plan); + const after = keep( + runStep( + tools.regctl, + 'discovery_digest_after', + ['image', 'digest', catalog.discovery], + publicEnv, + 300_000, + ), + ); + if (parseDigestOutput(after.stdout) !== manifestDigest) { + fail('catalog discovery changed during the ceremony'); + } + for (const tool of Object.values(tools)) verifyExecutable(tool); + const report = createReport({ + identity, + catalog, + manifestDigest, + plan, + receipt: manifest.receipt, + releaseSet, + releaseSetBytes: downloaded.stdout, + manifestBytes: rawManifest.stdout, + tools, + steps: withSequence(records), + observedAt: new Date().toISOString(), + }); + publishBundle( + output, + identity, + downloaded.stdout, + rawManifest.stdout, + report, + ); + return report; + } finally { + if (token) token.bytes.fill(0); + for (const transcript of transcripts) transcript.fill(0); + try { + fs.rmSync(temporary, { recursive: true, force: true }); + } catch { + // Only a private ceremony scratch directory is eligible for cleanup. + } + } +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) { + fail('arguments are invalid'); + } + values[match[1]] = match[2]; + } + const identity = [ + 'mode', + 'output-directory', + 'release-scope', + 'repository-owner', + 'source-ref', + 'source-repository', + 'source-revision', + 'version', + ]; + const expected = + values.mode === 'create' + ? [...identity, 'cosign', 'gh', 'github-token-file', 'regctl'] + : values.mode === 'audit' + ? identity + : []; + if ( + expected.length === 0 || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail('arguments are invalid'); + } + return Object.freeze({ + mode: values.mode, + version: values.version, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + releaseScope: values['release-scope'], + repositoryOwner: values['repository-owner'], + sourceRepository: values['source-repository'], + outputDirectory: values['output-directory'], + ...(values.regctl ? { regctl: values.regctl } : {}), + ...(values.cosign ? { cosign: values.cosign } : {}), + ...(values.gh ? { gh: values.gh } : {}), + ...(values['github-token-file'] + ? { githubTokenFile: values['github-token-file'] } + : {}), + }); +} + +function runCli(argv, output = process.stdout) { + const options = parseArguments(argv); + const result = + options.mode === 'create' + ? runCeremony(options) + : auditCeremonyBundle(options); + output.write( + canonicalJson({ + schemaVersion: 1, + component: 'qinglong3-release-catalog-consumption-ceremony', + mode: options.mode, + compatible: true, + releaseScope: options.releaseScope, + immutableReference: + options.mode === 'create' + ? result.catalog.immutableReference + : result.immutableReference, + contentDigest: result.contentDigest, + }), + ); + return result; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error + ? error.message + : 'release catalog consumption failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + SCHEMA, + QingLong3ReleaseCatalogConsumptionError, + auditCeremonyBundle, + catalogIdentity, + parseArguments, + runCeremony, + runCli, +}); diff --git a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs new file mode 100644 index 00000000..dbe15681 --- /dev/null +++ b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs @@ -0,0 +1,536 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + ARTIFACT_TYPE, + FILE_MEDIA_TYPE, + OCI_EMPTY_CONFIG_DIGEST, + OCI_EMPTY_CONFIG_MEDIA_TYPE, + OCI_MANIFEST_MEDIA_TYPE, + createCatalogPlan, +} = require('../../scripts/ql3-release-catalog-contract.cjs'); +const { + auditClusterImageCiWorkflow, +} = require('../../scripts/ql3-cluster-image-release-audit.cjs'); + +const ROOT = path.resolve(__dirname, '../..'); +const SCRIPT = path.join( + ROOT, + 'scripts/ql3-release-catalog-consumption-ceremony.cjs', +); +const VERSION = '3.0.0-alpha.0'; +const REVISION = 'a'.repeat(40); +const SOURCE_REF = `refs/tags/v${VERSION}`; +const OWNER = 'example'; +const SOURCE_REPOSITORY = `${OWNER}/qinglong`; +const TOKEN = 'github_pat_catalog_consumption_fixture'; + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function selectedImages(scope) { + const cluster = [ + ['control', 'qinglong3-cluster-control'], + ['control-ai', 'qinglong3-cluster-control-ai'], + ['admin', 'qinglong3-cluster-admin'], + ['worker', 'qinglong3-worker'], + ]; + const local = [['local', 'qinglong3-local-application']]; + return scope === 'local' + ? local + : scope === 'cluster' + ? cluster + : [...cluster, ...local]; +} + +function releaseSet(scope) { + const selected = selectedImages(scope); + const images = selected.map(([name, repository], index) => { + const digest = `sha256:${(index + 1).toString(16).repeat(64)}`; + const reference = `ghcr.io/${OWNER}/${repository}`; + return { + name, + repository, + digest, + reference: `${reference}@${digest}`, + versionTag: `${reference}:${VERSION}`, + sourceTag: `${reference}:sha-${REVISION}`, + platforms: ['linux/amd64', 'linux/arm64'], + imageRecordDigest: `sha256:${(index + 6).toString(16).repeat(64)}`, + }; + }); + const names = images.map((entry) => entry.name); + const unsigned = { + schemaVersion: 1, + schema: 'qinglong/release-set@v1', + release: { + version: VERSION, + sourceRevision: REVISION, + sourceRef: SOURCE_REF, + scope, + }, + candidate: { + schema: 'qinglong/release-candidate-contract@v1', + contractDigest: `sha256:${'b'.repeat(64)}`, + }, + repositoryOwner: OWNER, + platforms: ['linux/amd64', 'linux/arm64'], + deploymentFamilies: { + local: { + selected: ['local', 'all'].includes(scope), + profiles: ['edge', 'standalone'], + images: names.filter((name) => name === 'local'), + }, + cluster: { + selected: ['cluster', 'all'].includes(scope), + profiles: ['cluster', 'worker-edge', 'worker-node'], + images: names.filter((name) => name !== 'local'), + }, + }, + images, + promotion: { + authority: 'complete_verified_release_set', + versionTags: 'promote_after_complete_set_audit', + sourceTags: 'promote_after_complete_set_audit', + crossRepositoryAtomicity: false, + recovery: 'verify_exact_digest_then_continue', + }, + requiredVerification: { + imageKeylessSignature: true, + imageAttestations: [ + 'github-provenance', + 'cyclonedx-sbom', + 'os-vulnerability', + 'release-candidate-contract', + ], + releaseSetBuildProvenance: true, + }, + }; + return { ...unsigned, releaseSetDigest: sha256(JSON.stringify(unsigned)) }; +} + +function catalogManifest(value, scope) { + const identity = { + version: VERSION, + sourceRevision: REVISION, + sourceRef: SOURCE_REF, + releaseScope: scope, + repositoryOwner: OWNER, + sourceRepository: SOURCE_REPOSITORY, + }; + const plan = createCatalogPlan(value, identity); + return { + schemaVersion: 2, + mediaType: OCI_MANIFEST_MEDIA_TYPE, + artifactType: ARTIFACT_TYPE, + config: { + mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE, + digest: OCI_EMPTY_CONFIG_DIGEST, + size: 2, + }, + layers: [ + { + mediaType: FILE_MEDIA_TYPE, + digest: plan.releaseSet.contentDigest, + size: plan.releaseSet.bytes, + annotations: { + 'org.opencontainers.image.title': plan.releaseSet.fileName, + }, + }, + ], + annotations: { ...plan.catalog.annotations }, + }; +} + +function fixture(t, options = {}) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-catalog-consumption-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const scope = options.scope || 'local'; + const release = releaseSet(scope); + const releaseText = options.nonCanonicalRelease + ? `${JSON.stringify(release, null, 2)}\n` + : `${JSON.stringify(release)}\n`; + const manifest = catalogManifest(release, scope); + const manifestText = JSON.stringify(manifest); + const manifestDigest = sha256(Buffer.from(manifestText, 'utf8')); + const changedDigest = `sha256:${'f'.repeat(64)}`; + const capture = path.join(directory, 'calls.jsonl'); + const state = path.join(directory, 'state'); + const tokenFile = path.join(directory, 'github-token'); + const outputDirectory = path.join(directory, 'bundle'); + fs.writeFileSync(tokenFile, TOKEN, { mode: 0o600 }); + const tools = {}; + for (const name of ['regctl', 'cosign', 'gh']) { + const tool = path.join(directory, name); + let behavior = ''; + if (name === 'regctl') { + behavior = ` +if (args[0] === 'image' && args[1] === 'digest') { + const count = fs.existsSync(${JSON.stringify( + state, + )}) ? Number(fs.readFileSync(${JSON.stringify(state)}, 'utf8')) : 0; + fs.writeFileSync(${JSON.stringify(state)}, String(count + 1)); + const digest = count > 0 && ${JSON.stringify( + Boolean(options.changeDiscovery), + )} ? ${JSON.stringify(changedDigest)} : ${JSON.stringify(manifestDigest)}; + process.stdout.write(digest + '\\n'); + if (count === 0 && ${JSON.stringify(Boolean(options.mutateCosign))}) { + fs.appendFileSync(${JSON.stringify(path.join(directory, 'cosign'))}, '\\n'); + } +} else if (args[0] === 'artifact' && args[1] === 'get') { + process.stdout.write(${JSON.stringify(releaseText)}); +} else if (args[0] === 'manifest' && args[1] === 'get') { + process.stdout.write(${JSON.stringify( + options.manifestDrift ? `${manifestText} ` : manifestText, + )}); +} else { + process.exitCode = 7; +} +`; + } else { + behavior = ` +process.stdout.write(JSON.stringify({ verified: true, tool: name }) + '\\n'); +if (name === 'cosign' && ${JSON.stringify(Boolean(options.cosignFailure))}) { + process.exitCode = 9; +} +`; + } + const source = `#!${process.execPath} +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const name = path.basename(process.argv[1]); +const args = process.argv.slice(2); +fs.appendFileSync(${JSON.stringify( + capture, + )}, JSON.stringify({ name, args, tokenPresent: process.env.GH_TOKEN === ${JSON.stringify( + TOKEN, + )} }) + '\\n'); +${behavior} +`; + fs.writeFileSync(tool, source, { mode: 0o700 }); + tools[name] = tool; + } + return { + capture, + directory, + manifestDigest, + outputDirectory, + release, + scope, + tokenFile, + tools, + }; +} + +function createArguments(value) { + return [ + '--mode=create', + `--version=${VERSION}`, + `--source-revision=${REVISION}`, + `--source-ref=${SOURCE_REF}`, + `--release-scope=${value.scope}`, + `--repository-owner=${OWNER}`, + `--source-repository=${SOURCE_REPOSITORY}`, + `--regctl=${value.tools.regctl}`, + `--cosign=${value.tools.cosign}`, + `--gh=${value.tools.gh}`, + `--github-token-file=${value.tokenFile}`, + `--output-directory=${value.outputDirectory}`, + ]; +} + +function auditArguments(value) { + return [ + '--mode=audit', + `--version=${VERSION}`, + `--source-revision=${REVISION}`, + `--source-ref=${SOURCE_REF}`, + `--release-scope=${value.scope}`, + `--repository-owner=${OWNER}`, + `--source-repository=${SOURCE_REPOSITORY}`, + `--output-directory=${value.outputDirectory}`, + ]; +} + +function invoke(arguments_) { + return spawnSync(process.execPath, [SCRIPT, ...arguments_], { + cwd: ROOT, + encoding: 'utf8', + env: {}, + }); +} + +function createAndAudit(value) { + const created = invoke(createArguments(value)); + assert.equal(created.status, 0, created.stderr); + assert.equal(created.stderr, ''); + const audited = invoke(auditArguments(value)); + assert.equal(audited.status, 0, audited.stderr); + assert.equal(audited.stderr, ''); + return { + created: JSON.parse(created.stdout), + audited: JSON.parse(audited.stdout), + }; +} + +test('consumes one immutable catalog into an auditable no-replace bundle', (t) => { + const value = fixture(t); + const result = createAndAudit(value); + assert.equal(result.created.compatible, true); + assert.equal(result.audited.compatible, true); + assert.equal(result.audited.releaseScope, 'local'); + assert.equal( + result.created.immutableReference, + `ghcr.io/${OWNER}/qinglong3-release-catalog@${value.manifestDigest}`, + ); + + assert.equal(fs.statSync(value.outputDirectory).mode & 0o777, 0o700); + const names = fs.readdirSync(value.outputDirectory).sort(); + assert.deepEqual(names, [ + `qinglong3-release-catalog-consumption-${VERSION}-local.json`, + `qinglong3-release-catalog-manifest-${VERSION}-local.json`, + `qinglong3-release-set-${VERSION}-local.json`, + ]); + for (const name of names) { + assert.equal( + fs.statSync(path.join(value.outputDirectory, name)).mode & 0o777, + 0o600, + ); + } + const report = fs.readFileSync( + path.join( + value.outputDirectory, + `qinglong3-release-catalog-consumption-${VERSION}-local.json`, + ), + 'utf8', + ); + assert.equal(report.includes(TOKEN), false); + const parsed = JSON.parse(report); + assert.equal( + parsed.schema, + 'qinglong/release-catalog-consumption-ceremony@v1', + ); + assert.equal(parsed.verification.discoveryResolvedTwice, true); + assert.equal(parsed.verification.catalogReceiptReconstructed, true); + assert.equal(parsed.claims.deploymentMutation, false); + assert.equal( + parsed.claims.workstationFileWrites, + 'private_temporary_only_plus_final_bundle', + ); + assert.equal(parsed.steps.length, 6); + assert.equal( + fs + .readdirSync(value.directory) + .some((name) => name.startsWith('.ql3-release-catalog-consumption-')), + false, + ); + + const calls = fs + .readFileSync(value.capture, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.deepEqual( + calls.map((entry) => entry.name), + ['regctl', 'cosign', 'gh', 'regctl', 'regctl', 'regctl'], + ); + assert.deepEqual( + calls.map((entry) => entry.tokenPresent), + [false, false, true, false, false, false], + ); + assert.deepEqual(calls[0].args, [ + 'image', + 'digest', + `ghcr.io/${OWNER}/qinglong3-release-catalog:v${VERSION}-local`, + ]); + assert.ok(calls[1].args.includes(parsed.workflowIdentity)); + assert.ok(calls[2].args.includes('--deny-self-hosted-runners')); + assert.deepEqual(calls[3].args.slice(0, 4), [ + 'artifact', + 'get', + '--file', + `qinglong3-release-set-${VERSION}-local.json`, + ]); +}); + +test('supports the complete all-scope image family without runtime coupling', (t) => { + const value = fixture(t, { scope: 'all' }); + createAndAudit(value); + const report = JSON.parse( + fs.readFileSync( + path.join( + value.outputDirectory, + `qinglong3-release-catalog-consumption-${VERSION}-all.json`, + ), + 'utf8', + ), + ); + assert.deepEqual(report.releaseSet.images, [ + 'control', + 'control-ai', + 'admin', + 'worker', + 'local', + ]); + assert.equal(report.releaseSet.imageCount, 5); + assert.equal(report.claims.deploymentMutation, false); +}); + +test('fails before publication when discovery changes during verification', (t) => { + const value = fixture(t, { changeDiscovery: true }); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /catalog discovery changed/); + assert.equal(fs.existsSync(value.outputDirectory), false); +}); + +test('rejects non-canonical release sets and mismatched raw manifests', async (t) => { + await t.test('release set', () => { + const value = fixture(t, { nonCanonicalRelease: true }); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /canonical JSON/); + assert.equal(fs.existsSync(value.outputDirectory), false); + }); + await t.test('manifest', () => { + const value = fixture(t, { manifestDrift: true }); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /catalog manifest digest/); + assert.equal(fs.existsSync(value.outputDirectory), false); + }); +}); + +test('rejects external verification failure and executable mutation', async (t) => { + await t.test('cosign failure', () => { + const value = fixture(t, { cosignFailure: true }); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /keyless_signature/); + assert.equal(fs.existsSync(value.outputDirectory), false); + }); + await t.test('tool mutation', () => { + const value = fixture(t, { mutateCosign: true }); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /executable changed/); + assert.equal(fs.existsSync(value.outputDirectory), false); + }); +}); + +test('offline audit detects every durable bundle drift surface', async (t) => { + for (const [name, mutate] of [ + [ + 'release set', + (value) => { + const file = path.join( + value.outputDirectory, + `qinglong3-release-set-${VERSION}-local.json`, + ); + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + parsed.images[0].digest = `sha256:${'e'.repeat(64)}`; + fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`); + }, + ], + [ + 'manifest', + (value) => { + fs.appendFileSync( + path.join( + value.outputDirectory, + `qinglong3-release-catalog-manifest-${VERSION}-local.json`, + ), + ' ', + ); + }, + ], + [ + 'report', + (value) => { + const file = path.join( + value.outputDirectory, + `qinglong3-release-catalog-consumption-${VERSION}-local.json`, + ); + const parsed = JSON.parse(fs.readFileSync(file, 'utf8')); + parsed.claims.deploymentMutation = true; + fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`); + }, + ], + [ + 'extra file', + (value) => { + fs.writeFileSync(path.join(value.outputDirectory, 'unexpected'), 'x', { + mode: 0o600, + }); + }, + ], + ]) { + await t.test(name, () => { + const value = fixture(t); + createAndAudit(value); + mutate(value); + const audited = invoke(auditArguments(value)); + assert.notEqual(audited.status, 0); + }); + } +}); + +test('rejects output reuse, public credentials, symlinks and open CLI shapes', async (t) => { + await t.test('no replace', () => { + const value = fixture(t); + createAndAudit(value); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /must not already exist/); + }); + await t.test('public token', () => { + const value = fixture(t); + fs.chmodSync(value.tokenFile, 0o644); + const result = invoke(createArguments(value)); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /owner-private/); + }); + await t.test('symlink tool', () => { + const value = fixture(t); + const link = path.join(value.directory, 'linked'); + fs.symlinkSync(value.tools.regctl, link); + const arguments_ = createArguments(value).map((argument) => + argument.startsWith('--regctl=') ? `--regctl=${link}` : argument, + ); + const result = invoke(arguments_); + assert.notEqual(result.status, 0); + }); + await t.test('open arguments', () => { + const value = fixture(t); + const result = invoke([...createArguments(value), '--unknown=true']); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /arguments are invalid/); + }); +}); + +test('supply-chain CI cannot silently remove the consumption ceremony tests', () => { + const source = fs.readFileSync( + path.join(ROOT, '.github/workflows/ql3-ci.yml'), + 'utf8', + ); + const mutated = source.replace( + 'test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs', + 'test/back/release-catalog-consumption-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /catalog consumption ceremony/, + ); +});