From d991deb7ee0046349606a605f4b5e2e89de1ca62 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 08:17:11 +0800 Subject: [PATCH] feat(ql3): add external release workstation ceremony --- deploy/console/ql3-cluster-copilot/README.md | 44 ++ docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...ster-admin-release-workstation-ceremony.md | 119 ++++ package.json | 2 + ...min-release-workstation-ceremony-audit.cjs | 465 ++++++++++++ ...ter-admin-release-workstation-ceremony.cjs | 664 ++++++++++++++++++ ...ter-copilot-console-distribution-audit.cjs | 72 +- ...erAdminReleaseWorkstationCeremony.test.cjs | 277 ++++++++ ...inReleaseWorkstationCeremonyAudit.test.cjs | 263 +++++++ ...erCopilotConsoleDistributionAudit.test.cjs | 50 +- 10 files changed, 1949 insertions(+), 8 deletions(-) create mode 100644 docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md create mode 100644 scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs create mode 100644 scripts/ql3-cluster-admin-release-workstation-ceremony.cjs create mode 100644 test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs create mode 100644 test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index bb5bb31a..fdc629aa 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -34,6 +34,50 @@ deploy/console/ql3-cluster-copilot/verify-release.sh \ refs/tags/v3.0.0-alpha.0 ``` +For a release acceptance ceremony, prefer the source-tag workstation runner. +Create a current-owner `0700` report directory and a canonical `0600` file +containing only a short-lived GitHub token. Pass canonical absolute executable +paths (resolve Homebrew symlinks first). The token is sent only to the three +`gh attestation verify` children, never in argv, the report or the `cosign` and +`docker` environments. + +```sh +node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs \ + --image=ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ + --repository=replace-owner/qinglong \ + --source-revision=REPLACE_40_HEX_SOURCE_REVISION \ + --source-ref=refs/tags/v3.0.0-alpha.0 \ + --cosign=/canonical/absolute/cosign \ + --gh=/canonical/absolute/gh \ + --docker=/canonical/absolute/docker \ + --github-token-file=/absolute/private/release/github-token \ + --output=/absolute/private/release/admin-ceremony.json +``` + +In addition to the signature, provenance, CycloneDX and OS-vulnerability +checks, the runner pulls the same immutable digest, confirms its local +`RepoDigests` binding, and runs the image-carried `evidence-verify` command on +a fixed non-sensitive vector with network disabled, a read-only root, no +capabilities, no-new-privileges, 128 MiB, 0.25 CPU and 32 PIDs. It writes one +new `0600` low-sensitive report and will not replace an existing file. + +Audit that report independently against the expected public release identity: + +```sh +node scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs \ + --report=/absolute/private/release/admin-ceremony.json \ + --image=ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:REPLACE_64_HEX \ + --repository=replace-owner/qinglong \ + --source-revision=REPLACE_40_HEX_SOURCE_REVISION \ + --source-ref=refs/tags/v3.0.0-alpha.0 +``` + +The offline audit verifies canonical encoding, exact structure, tool and +transcript digests, isolation claims and expected release binding. It does not +replay registry or transparency-log queries and cannot turn the unsigned local +report into an attestation or action authority. Keep the report private even +though it contains no credential or workstation identity. + After verification, pull that exact digest. The signature covers the embedded host launcher and templates as part of the image filesystem. Operators may either use the launcher from the matching reviewed tag or extract its exact diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index b5e343d7..21e766c7 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-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io//qinglong3-cluster-admin@sha256:`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATH,token 只注入 3 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX 与 OS-vulnerability evidence,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identity;offline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed`、`reportAttestation=none`、`actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`,ADR-0424 必须保持 Proposed;stub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted。 - D-331/ADR-0423(已接受):`@qinglong/cluster-admin` 在既有 `copilot-console/` 职责目录增加独立 TypeScript evidence verifier,并以第 11 个静态产品命令 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 交付。它只通过 no-follow/stable descriptor 读取一个最大 512 KiB 的 canonical absolute UTF-8 JSON,拒绝 BOM、CRLF、minified、duplicate-key、symlink、relative path 与读取中漂移;独立固定检查 exact bundle/request shape、13 operations、16-entry/8 MiB/64-item/depth/key ceiling、安全字段白名单和顺序 typed alias,再重算不含 `contentDigest` 的 canonical SHA-256。结果明确只证明 `bundleDigest=verified`;没有原始 fact 时逐条 digest 为 `not_recomputed_without_raw_facts`,server signature/attestation/durable audit 均未验证且 action authority 为 none。实现不读 stdin/environment/context,不联网、不写文件、不新增 package、依赖、route、listener、数据库、Kubernetes workload 或 Edge/Standalone closure。定向门 18/18,Cluster Admin 387 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,225 pass/2 条件 skip/0 fail。真实 arm64 Admin image `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 11 个命令、有效 bundle、tamper rejection 与零 verifier file write。npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;结构/依赖/部署/发布/Console 审计零 finding,workspace 保持 18 package、无 single/shallow package,Cluster Admin 122 个源码中 121 个位于领域目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes。因本门没有 schema/migration/SQL/role/Pool/连接拓扑变化,不重复冒充执行 HA,复用紧邻 D-330 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一门应完成公开 release digest 的外部工作站 ceremony,不得给 verifier 增加上传、签名或行动能力。 - D-330/ADR-0422(已接受):同一 loopback-only Cluster field ledger 现可由用户显式导出纯浏览器本地的脱敏 evidence bundle。导出只消费本页已逐次读取的最近 16 条、最多 8 MiB canonical fact,不调用 upstream/BFF、补读详情/分页、轮询、上传或持久化;固定 sanitizer 只保留 operation、非权威本机观察时间、安全枚举/boolean/有界 number、结构计数/分页事实、per-bundle typed alias 与原始 fact canonical byte count/SHA-256,自由文本、名称、路径/URL/command/input/output/environment、reason/error/message、credential/token/session/authorization、未知字段及 Copilot model text 一律省略。bundle 固定为 UTF-8 `qinglong/cluster-console-redacted-evidence-bundle@v1` JSON、最大 512 KiB,顶层 self-digest 明确不是 server signature/audit/action authority;生成器作为第 4 个 digest-bound asset 留在既有 `@qinglong/cluster-admin`,不增加 package、依赖、Cluster/BFF route、数据库、对象存储、Kubernetes workload 或 Edge/Standalone closure。定向门 24/24,Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSON,upstream 计数仍为 3,390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,1,199 个源码中 1,181 个位于职责目录。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。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。 - D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operation;browser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`,workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。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。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。 diff --git a/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md b/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md new file mode 100644 index 00000000..2e809285 --- /dev/null +++ b/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md @@ -0,0 +1,119 @@ +# ADR-0424:外部工作站 Cluster Admin Release Ceremony + +- 状态:Proposed +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-332、Phase 2 +- 扩展:ADR-0420、ADR-0423 + +## 背景 + +ADR-0420 已固定 Cluster Console 只随 signed multi-architecture Admin OCI +分发,ADR-0423 已在真实本地镜像中证明离线 evidence verifier 能在严格资源与 +authority 边界内运行。但源码审计、stub release verifier 测试和本地构建镜像都 +不能证明一个公开发布 digest 确实由预期 GitHub Actions workflow 生成,也不能 +证明签名、provenance、SBOM、OS vulnerability evidence 与最终拉取的镜像是同一 +对象。 + +公开 release 的验证发生在 operator workstation,具有 registry、GitHub +attestation service、transparency log、本地 Docker daemon 和短期 GitHub token +等外部条件。它不能伪装成可离线复现的单元测试,也不能把本地执行报告升级为 +服务端证明或行动权限。 + +## 决策 + +1. 在 exact reviewed `v3.*` source tag 提供 + `ql3-cluster-admin-release-workstation-ceremony.cjs`。输入必须精确绑定 + `ghcr.io//qinglong3-cluster-admin@sha256:`、`owner/repo`、40-hex + source revision、完整 `refs/tags/v3.*` ref,以及 canonical absolute + `cosign`、`gh`、`docker`、短期 token file 和新 report path;mutable tag、branch + ref、owner 漂移、symlink、group/other-writable executable 或已存在输出均失败关闭。 +2. 三个外部工具按绝对路径直接执行,不经 shell 或 ambient `PATH`。GitHub token + 必须来自 current-owner `0600` bounded file,只注入三个 `gh attestation verify` + 子进程;不得进入 argv、`cosign`/`docker` 环境、报告或失败输出。工具在执行前后 + 复验 device/inode/size/SHA-256,降低 ceremony 中途替换风险。 +3. ceremony 精确执行一次 keyless signature 验证,以及绑定 release workflow、 + source digest、source tag、非 self-hosted runner 和 OCI bundle 的 provenance、 + CycloneDX、OS-vulnerability 三类 GitHub attestation 验证。随后拉取同一 digest, + 要求本地 image inspection 的 Linux `amd64|arm64` `RepoDigests` 包含精确输入。 +4. ceremony 使用固定、非敏感、单条 `run_read` redacted evidence vector 检验最终 + release image 内的第 11 个 `evidence-verify` 产品命令。该容器使用 non-root + UID/GID 10001、read-only root、network none、drop ALL、no-new-privileges、 + 128 MiB、0.25 CPU 与 32 PIDs,只读挂载 vector;输出必须与独立 verifier 的 + exact no-authority result 一致,vector inode/size/mtime/digest 前后不变。 +5. 成功只新建一个 current-owner `0600`、two-space canonical JSON 报告。报告保留 + public release identity、工具 SHA-256/size、七步 argv/stdout/stderr digest 与字节数、 + verification/isolation 结果和自身 canonical SHA-256,不保留原始工具输出、token、 + executable path 或 workstation identity。它明确声明 + `reportAttestation=none`、`actionAuthority=none`。 +6. 独立 offline audit 使用 no-follow stable read 校验报告 canonical encoding、exact + shape、expected release identity、工具与七步 transcript digest、一致的 isolation/ + limitation 以及顶层 digest。其结果固定为 `externalResults=not_replayed`;离线审计 + 不能证明外部命令确实运行,也不能重放某一历史时点的 registry、GitHub 或 + transparency-log 状态。 +7. ceremony 与 auditor 保留在根 `scripts/`,不新拆 workspace package、不加入 + Admin image,从而避免“用待验证镜像验证自身”的循环,也不进入 Edge、Standalone、 + AI、MCP Local artifact、Kubernetes workload、数据库或常驻服务闭包。 + +## 不选择 + +- **只保留 `verify-release.sh` 的布尔输出**:适合人工快速检查,但缺少工具固定、 + 最终 pull/inspect、镜像内 verifier smoke 与可独立审计的 digest-level transcript。 +- **保存完整 `cosign`/`gh` 输出**:会不必要地扩大身份、registry metadata 和未来 + 输出格式的泄漏面,也让报告兼容性依赖外部工具展示层。 +- **把 token 交给全部子进程或继承完整环境**:扩大 credential 与 ambient authority + 暴露,且降低 ceremony 可解释性。 +- **把 runner 烘焙进 Admin image**:产生自验证循环,并迫使运行容器获得 Docker + daemon 与 registry authority。 +- **把本地报告签成 QingLong authority**:工作站报告只记录一次观察,不是 release + workflow attestation、Cluster durable audit 或 action approval。 + +## 验收 + +1. stub ceremony 必须证明精确 1 次 signature、3 次 attestation、1 次 immutable + pull、1 次 digest inspection、1 次隔离 verifier;token 只出现在 `gh` 环境,报告 + 为 `0600`、不泄漏 token 且不覆盖现有文件。 +2. mutable image、branch ref、source revision/owner drift、tool failure、executable + drift、非私有 token/report、verifier 输出漂移或 vector mutation 必须失败关闭, + 失败输出不得包含路径、输入或外部工具 transcript。 +3. offline audit 必须接受真实 canonical report,并拒绝 tamper、即使重新计算顶层 + digest 的 claim/isolation/shape 扩宽、expected release swapping、noncanonical JSON、 + symlink 与权限漂移。 +4. Console distribution audit、Cluster Admin、18-package、backend、npm pack、依赖/ + deployment/release 以及 14 档 Local artifact 门必须通过;package 总数与低配设备 + 默认 Edge/Standalone artifact 必须保持不变。 +5. 必须从公开 `v3.*` release 获取真实 immutable Admin digest,并在装有真实 + `cosign`、authenticated `gh` 和 Docker 的外部工作站完成 ceremony 与独立 audit, + 才能把本 ADR 从 Proposed 改为 Accepted。 + +## 当前状态 + +截至 2026-08-16,本地仓库没有 `v3.*` tag,开发工作站未安装 `gh` 与 `cosign`, +且项目公开 GitHub Releases 尚无可供输入的 3.0 release digest。因此本门只可完成 +runner、auditor、negative contract、资源与 artifact 回归;不得用 stub 或本地 +`d332-local` 镜像伪造第 5 条外部验收。发布 digest 可用后,按本 ADR 记录真实 report +digest、工具 digest 与外部审计结果,再独立接受本决策。 + +## 实现门结果 + +2026-08-16,D-332 已完成可在发布后直接执行的实现门,但没有改变本 ADR 的 +`Proposed` 状态: + +- runner/stub tools、独立 offline auditor、原 release verifier 与 Console + distribution 定向门全部通过;覆盖精确 signature/attestation/pull/inspect/run + argv、token 仅注入 `gh`、tool drift、权限扩宽、no-replace、结构重签与 expected + identity swapping 等正负路径。 +- backend 全量为 1,233 pass、2 条件 skip、0 fail;Cluster Admin 为 387 pass、 + 3 条件 skip、0 fail;18-package clean build/test 退出 0。沙箱内 loopback listener + 的 `EPERM` 已通过同命令非沙箱重跑消除,不记作产品失败。 +- workspace 继续为 18 packages,`singleSourcePackages=[]`、 + `shallowSourcePackages=[]`;Cluster dependency、Edge import、Cluster deployment、 + image release、OS vulnerability、Console 与 distribution 审计均 compatible。 +- Cluster Admin npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte + unpacked;D-332 的根脚本和 ADR 没有进入 npm/OCI 产品内容。 +- 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。 +- 本门没有 schema、migration、SQL、role、Pool 或连接拓扑变化,因此不重复冒充 + PostgreSQL HA 执行;继续复用紧邻 D-331 已通过的 PostgreSQL 18.6 arm64 + 142/142、timeline `1→2` 物理 HA 基线。唯一未满足项仍是第 5 条真实公开 release + digest 外部 ceremony。 diff --git a/package.json b/package.json index ca4ec973..2603ad64 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,8 @@ "audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs", "audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs", "audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-audit.cjs", + "evidence:cluster-admin-release-workstation:ql3": "node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs", + "audit:cluster-admin-release-workstation:ql3": "node scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs", "test:cluster-admin-product-live:ql3": "node scripts/ql3-cluster-admin-product-live-contract.cjs", "audit:provider-credential-management-deployment:ql3": "node scripts/ql3-provider-credential-management-deployment-audit.cjs", "audit:provider-credential-test-deployment:ql3": "node scripts/ql3-provider-credential-test-deployment-audit.cjs", diff --git a/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs b/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs new file mode 100644 index 00000000..0c631c0e --- /dev/null +++ b/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs @@ -0,0 +1,465 @@ +#!/usr/bin/env node + +'use strict'; + +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { TextDecoder } = require('node:util'); + +const SCHEMA = 'qinglong/cluster-admin-release-workstation-ceremony@v1'; +const AUDIT_SCHEMA = + 'qinglong/cluster-admin-release-workstation-ceremony-audit@v1'; +const CONTROL = /[\u0000-\u001f\u007f]/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const MAX_REPORT_BYTES = 512 * 1024; +const EMPTY_DIGEST = + 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + +class ReleaseWorkstationCeremonyAuditError extends Error { + constructor(message) { + super( + `Cluster Admin release workstation ceremony audit failed: ${message}`, + ); + this.name = 'ReleaseWorkstationCeremonyAuditError'; + } +} + +function fail(message) { + throw new ReleaseWorkstationCeremonyAuditError(message); +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(Object.is(value, -0) ? 0 : value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) + .join(',')}}`; +} + +function exactKeys(value, keys) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...keys].sort()) + ); +} + +function exactObject(value, keys, label) { + if (!exactKeys(value, keys)) fail(`${label} shape is invalid`); + return value; +} + +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 expected = [ + 'report', + 'image', + 'repository', + 'source-revision', + 'source-ref', + ]; + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail('arguments are invalid'); + } + return Object.freeze({ + reportFile: values.report, + image: values.image, + repository: values.repository, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + }); +} + +function readReport(filePath) { + if ( + typeof filePath !== 'string' || + !path.isAbsolute(filePath) || + filePath.length > 4096 || + CONTROL.test(filePath) || + path.normalize(filePath) !== filePath + ) { + fail('report path is invalid'); + } + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + fail('report is unavailable'); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > MAX_REPORT_BYTES || + fs.realpathSync(filePath) !== filePath || + typeof process.getuid !== 'function' || + stat.uid !== process.getuid() || + (stat.mode & 0o077) !== 0 + ) { + fail('report must be one canonical owner-private bounded file'); + } + let descriptor = -1; + let bytes; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | + (fs.constants.O_CLOEXEC ?? 0) | + (fs.constants.O_NOFOLLOW ?? 0), + ); + const opened = fs.fstatSync(descriptor); + if ( + opened.dev !== stat.dev || + opened.ino !== stat.ino || + opened.mode !== stat.mode || + opened.uid !== stat.uid || + opened.size !== stat.size + ) { + fail('report changed before open'); + } + 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('report read was incomplete'); + offset += count; + } + const after = fs.fstatSync(descriptor); + if ( + after.dev !== opened.dev || + after.ino !== opened.ino || + after.size !== opened.size || + after.mtimeMs !== opened.mtimeMs + ) { + fail('report changed while read'); + } + let value; + try { + value = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes), + ); + } catch { + fail('report must contain UTF-8 JSON'); + } + if (`${JSON.stringify(value, null, 2)}\n` !== bytes.toString('utf8')) { + fail('report encoding is not canonical'); + } + return Object.freeze({ bytes, value }); + } catch (error) { + if (bytes) bytes.fill(0); + if (error instanceof ReleaseWorkstationCeremonyAuditError) throw error; + fail('report could not be read safely'); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function validIsoTime(value) { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u.test(value) && + Number.isFinite(Date.parse(value)) + ); +} + +function same(value, expected, label) { + if (JSON.stringify(value) !== JSON.stringify(expected)) { + fail(`${label} is invalid`); + } +} + +function validateRelease(value, expected) { + exactObject( + value, + ['image', 'repository', 'sourceRevision', 'sourceRef', 'workflowIdentity'], + 'release', + ); + const owner = expected.repository.split('/')[0]; + if ( + !/^[a-z0-9][a-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/u.test( + expected.repository, + ) || + !new RegExp( + `^ghcr\\.io/${owner}/qinglong3-cluster-admin@sha256:[a-f0-9]{64}$`, + 'u', + ).test(expected.image) || + !/^[a-f0-9]{40}$/u.test(expected.sourceRevision) || + !/^refs\/tags\/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test( + expected.sourceRef, + ) + ) { + fail('expected release identity is invalid'); + } + const workflow = `${expected.repository}/.github/workflows/ql3-image-release.yml`; + same( + value, + { + image: expected.image, + repository: expected.repository, + sourceRevision: expected.sourceRevision, + sourceRef: expected.sourceRef, + workflowIdentity: `https://github.com/${workflow}@${expected.sourceRef}`, + }, + 'release identity', + ); +} + +function auditReport(value, expected) { + exactObject( + value, + [ + 'schema', + 'schemaVersion', + 'observedAt', + 'release', + 'tools', + 'verification', + 'evidenceVector', + 'isolation', + 'steps', + 'claims', + 'contentDigest', + ], + 'report', + ); + if ( + value.schema !== SCHEMA || + value.schemaVersion !== 1 || + !validIsoTime(value.observedAt) || + typeof value.contentDigest !== 'string' || + !DIGEST.test(value.contentDigest) + ) { + fail('report identity is invalid'); + } + validateRelease(value.release, expected); + if (!Array.isArray(value.tools) || value.tools.length !== 3) { + fail('tool evidence is invalid'); + } + const expectedTools = ['cosign', 'gh', 'docker']; + value.tools.forEach((tool, index) => { + exactObject(tool, ['name', 'sha256', 'sizeBytes'], 'tool'); + if ( + tool.name !== expectedTools[index] || + typeof tool.sha256 !== 'string' || + !DIGEST.test(tool.sha256) || + !Number.isSafeInteger(tool.sizeBytes) || + tool.sizeBytes < 2 || + tool.sizeBytes > 256 * 1024 * 1024 + ) { + fail('tool evidence is invalid'); + } + }); + same( + value.verification, + { + keylessSignature: true, + provenance: true, + cyclonedxSbom: true, + osVulnerabilityEvidence: true, + imagePulled: true, + localRepoDigestBound: true, + embeddedEvidenceVerifier: true, + }, + 'verification claims', + ); + exactObject( + value.evidenceVector, + [ + 'schema', + 'contentDigest', + 'entryCount', + 'totalRawCanonicalBytes', + 'classification', + ], + 'evidence vector', + ); + if ( + value.evidenceVector.schema !== + 'qinglong/cluster-console-redacted-evidence-bundle@v1' || + !/^[a-f0-9]{64}$/u.test(value.evidenceVector.contentDigest) || + value.evidenceVector.entryCount !== 1 || + !Number.isSafeInteger(value.evidenceVector.totalRawCanonicalBytes) || + value.evidenceVector.totalRawCanonicalBytes < 2 || + value.evidenceVector.totalRawCanonicalBytes > 8 * 1024 * 1024 || + value.evidenceVector.classification !== 'synthetic_non_sensitive' + ) { + fail('evidence vector is invalid'); + } + same( + value.isolation, + { + network: 'none_for_embedded_verifier', + readOnlyRoot: true, + capabilities: 'none', + noNewPrivileges: true, + pids: 32, + memoryBytes: 134217728, + cpus: 0.25, + verifierMutation: false, + verifierFileWrites: false, + }, + 'verifier isolation', + ); + const expectedSteps = [ + ['keyless_signature', 'cosign'], + ['provenance_attestation', 'gh'], + ['cyclonedx_sbom_attestation', 'gh'], + ['os_vulnerability_attestation', 'gh'], + ['immutable_image_pull', 'docker'], + ['local_digest_inspection', 'docker'], + ['embedded_evidence_verifier', 'docker'], + ]; + if ( + !Array.isArray(value.steps) || + value.steps.length !== expectedSteps.length + ) { + fail('step evidence is invalid'); + } + const toolDigests = Object.fromEntries( + value.tools.map((tool) => [tool.name, tool.sha256]), + ); + value.steps.forEach((step, index) => { + exactObject( + step, + [ + 'sequence', + 'name', + 'tool', + 'executableSha256', + 'argvSha256', + 'stdoutBytes', + 'stdoutSha256', + 'stderrBytes', + 'stderrSha256', + 'exitCode', + ], + 'step', + ); + const [name, tool] = expectedSteps[index]; + if ( + step.sequence !== index + 1 || + step.name !== name || + step.tool !== tool || + step.executableSha256 !== toolDigests[tool] || + !DIGEST.test(step.argvSha256) || + !Number.isSafeInteger(step.stdoutBytes) || + step.stdoutBytes < 0 || + step.stdoutBytes > 1024 * 1024 || + !DIGEST.test(step.stdoutSha256) || + !Number.isSafeInteger(step.stderrBytes) || + step.stderrBytes < 0 || + step.stderrBytes > 1024 * 1024 || + !DIGEST.test(step.stderrSha256) || + step.exitCode !== 0 + ) { + fail('step evidence is invalid'); + } + }); + const verifierStep = value.steps[6]; + if ( + verifierStep.stdoutBytes < 2 || + verifierStep.stderrBytes !== 0 || + verifierStep.stderrSha256 !== EMPTY_DIGEST + ) { + fail('embedded verifier transcript is invalid'); + } + same( + value.claims, + { + externalToolResults: 'exit_zero_with_digest_only_transcript', + registryAvailability: 'observed_once', + offlineAudit: 'structure_and_digest_only', + workstationIdentityIncluded: false, + credentialIncluded: false, + reportAttestation: 'none', + actionAuthority: 'none', + }, + 'claim boundary', + ); + const unsigned = {}; + for (const [key, entry] of Object.entries(value)) { + if (key !== 'contentDigest') unsigned[key] = entry; + } + if ( + digest(Buffer.from(canonicalize(unsigned), 'utf8')) !== value.contentDigest + ) { + fail('report digest is invalid'); + } + return Object.freeze({ + compatible: true, + reportContentDigest: value.contentDigest, + releaseImage: value.release.image, + verificationSteps: value.steps.length, + externalResults: 'not_replayed', + actionAuthority: 'none', + }); +} + +function runCli(argv) { + const options = parseArguments(argv); + const report = readReport(options.reportFile); + try { + const result = auditReport(report.value, options); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + schema: AUDIT_SCHEMA, + ...result, + })}\n`, + ); + } finally { + report.bytes.fill(0); + } +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error + ? error.message + : 'release workstation ceremony audit failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = { + AUDIT_SCHEMA, + SCHEMA, + auditReport, + parseArguments, + readReport, + runCli, +}; diff --git a/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs b/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs new file mode 100644 index 00000000..4297f670 --- /dev/null +++ b/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs @@ -0,0 +1,664 @@ +#!/usr/bin/env node + +'use strict'; + +const { spawnSync } = require('node:child_process'); +const { createHash, webcrypto } = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { + createClusterConsoleEvidenceBundle, + serializeClusterConsoleEvidenceBundle, +} = require('../packages/ql3-cluster-admin/assets/copilot-console/evidence-bundle.js'); + +const SCHEMA = 'qinglong/cluster-admin-release-workstation-ceremony@v1'; +const WORKFLOW = '.github/workflows/ql3-image-release.yml'; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const MAX_TOOL_BYTES = 256 * 1024 * 1024; +const CONTROL = /[\u0000-\u001f\u007f]/u; +const SHA256 = /^sha256:[a-f0-9]{64}$/u; + +class ReleaseWorkstationCeremonyError extends Error { + constructor() { + super('Cluster Admin release workstation ceremony failed'); + this.name = 'ReleaseWorkstationCeremonyError'; + } +} + +function fail() { + throw new ReleaseWorkstationCeremonyError(); +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(Object.is(value, -0) ? 0 : value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) + .join(',')}}`; +} + +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(); + values[match[1]] = match[2]; + } + const expected = [ + 'image', + 'repository', + 'source-revision', + 'source-ref', + 'cosign', + 'gh', + 'docker', + 'github-token-file', + 'output', + ]; + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail(); + } + const repository = values.repository; + if (!/^[a-z0-9][a-z0-9-]{0,38}\/[A-Za-z0-9_.-]{1,100}$/u.test(repository)) { + fail(); + } + const owner = repository.slice(0, repository.indexOf('/')); + const imagePattern = new RegExp( + `^ghcr\\.io/${owner}/qinglong3-cluster-admin@sha256:[a-f0-9]{64}$`, + 'u', + ); + if ( + !imagePattern.test(values.image) || + !/^[a-f0-9]{40}$/u.test(values['source-revision']) || + !/^refs\/tags\/v3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test( + values['source-ref'], + ) + ) { + fail(); + } + return Object.freeze({ + image: values.image, + repository, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + cosign: values.cosign, + gh: values.gh, + docker: values.docker, + githubTokenFile: values['github-token-file'], + output: values.output, + }); +} + +function canonicalRegularFile(filePath, options) { + if ( + typeof filePath !== 'string' || + !path.isAbsolute(filePath) || + filePath.length > 4096 || + CONTROL.test(filePath) || + path.normalize(filePath) !== filePath + ) { + fail(); + } + let stat; + try { + stat = fs.lstatSync(filePath); + } catch { + fail(); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < options.minimumBytes || + stat.size > options.maximumBytes || + fs.realpathSync(filePath) !== filePath || + (stat.mode & options.forbiddenMode) !== 0 || + (options.executable && (stat.mode & 0o111) === 0) || + (options.currentOwner && + (typeof process.getuid !== 'function' || stat.uid !== process.getuid())) + ) { + fail(); + } + return stat; +} + +function readToken(filePath) { + const before = canonicalRegularFile(filePath, { + minimumBytes: 1, + maximumBytes: 4096, + forbiddenMode: 0o077, + executable: false, + currentOwner: true, + }); + let descriptor = -1; + let bytes; + try { + descriptor = fs.openSync( + filePath, + 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(); + } + 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(); + offset += count; + } + const token = bytes.toString('utf8'); + if ( + token.trim() !== token || + token.length < 8 || + CONTROL.test(token) || + /\s/u.test(token) + ) { + fail(); + } + return Object.freeze({ bytes, token }); + } catch (error) { + if (bytes) bytes.fill(0); + if (error instanceof ReleaseWorkstationCeremonyError) throw error; + fail(); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function executable(filePath) { + const stat = canonicalRegularFile(filePath, { + minimumBytes: 2, + maximumBytes: MAX_TOOL_BYTES, + forbiddenMode: 0o022, + executable: true, + currentOwner: false, + }); + return Object.freeze({ + path: filePath, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + sha256: digest(fs.readFileSync(filePath)), + }); +} + +function verifyExecutable(tool) { + const current = executable(tool.path); + if ( + current.dev !== tool.dev || + current.ino !== tool.ino || + current.size !== tool.size || + current.sha256 !== tool.sha256 + ) { + fail(); + } +} + +function privateUnusedOutput(filePath) { + if ( + typeof filePath !== 'string' || + !path.isAbsolute(filePath) || + filePath.length > 4096 || + CONTROL.test(filePath) || + path.normalize(filePath) !== filePath || + fs.existsSync(filePath) + ) { + fail(); + } + const parent = path.dirname(filePath); + let stat; + try { + stat = fs.lstatSync(parent); + } catch { + fail(); + } + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(parent) !== parent || + typeof process.getuid !== 'function' || + stat.uid !== process.getuid() || + (stat.mode & 0o077) !== 0 + ) { + fail(); + } + return parent; +} + +function writeNoReplace(filePath, value) { + privateUnusedOutput(filePath); + const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8'); + 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(); + offset += count; + } + fs.fsyncSync(descriptor); + } finally { + bytes.fill(0); + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function runStep(tool, name, args, env, timeoutMs) { + verifyExecutable(tool); + const result = spawnSync(tool.path, args, { + encoding: 'buffer', + env, + timeout: timeoutMs, + maxBuffer: MAX_OUTPUT_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_OUTPUT_BYTES || + stderr.length > MAX_OUTPUT_BYTES + ) { + stdout.fill(0); + stderr.fill(0); + fail(); + } + return Object.freeze({ + record: Object.freeze({ + sequence: 0, + name, + tool: path.basename(tool.path), + executableSha256: tool.sha256, + argvSha256: digest(Buffer.from(JSON.stringify(args), 'utf8')), + stdoutBytes: stdout.length, + stdoutSha256: digest(stdout), + stderrBytes: stderr.length, + stderrSha256: digest(stderr), + exitCode: 0, + }), + stdout, + stderr, + }); +} + +function parseJson(bytes) { + try { + return JSON.parse(bytes.toString('utf8')); + } catch { + fail(); + } +} + +async function syntheticEvidenceVector() { + const bundle = await createClusterConsoleEvidenceBundle( + [ + { + operation: 'run_read', + observedAtMs: 1_700_000_000_000, + request: { + schema: 'qinglong/cluster-copilot-console-read-request@v1', + operation: 'run_read', + projectId: 'ceremony-project', + requestId: 'ceremony-request', + runId: 'ceremony-run', + }, + fact: { + schema: 'qinglong/bounded-run-projection@v1', + schemaVersion: 1, + status: 'succeeded', + projectId: 'ceremony-project', + runId: 'ceremony-run', + outputAvailable: false, + }, + }, + ], + 1_700_000_001_000, + webcrypto, + ); + return Object.freeze({ + bundle, + encoded: serializeClusterConsoleEvidenceBundle(bundle), + }); +} + +function expectedEvidenceVerification(bundle) { + return { + schema: 'qinglong/cluster-console-evidence-verification@v1', + status: 'verified', + bundle: { + schema: 'qinglong/cluster-console-redacted-evidence-bundle@v1', + contentDigest: bundle.contentDigest, + entryCount: 1, + totalRawCanonicalBytes: bundle.source.totalRawCanonicalBytes, + }, + integrity: { + bundleDigest: 'verified', + rawFactDigests: 'not_recomputed_without_raw_facts', + }, + claims: { + serverSignature: 'not_verified', + attestation: 'not_verified', + durableAudit: 'not_verified', + actionAuthority: 'none', + }, + execution: { networkAccess: false, mutation: false, fileWrites: false }, + }; +} + +function withSequence(steps) { + return steps.map((step, index) => + Object.freeze({ ...step, sequence: index + 1 }), + ); +} + +async function runCeremony(options) { + const outputParent = privateUnusedOutput(options.output); + const tools = Object.freeze({ + cosign: executable(options.cosign), + gh: executable(options.gh), + docker: executable(options.docker), + }); + const token = readToken(options.githubTokenFile); + const publicEnv = Object.freeze({ + LANG: 'C', + LC_ALL: 'C', + NO_COLOR: '1', + GH_PROMPT_DISABLED: '1', + }); + const githubEnv = Object.freeze({ + ...publicEnv, + GH_TOKEN: token.token, + }); + const temporary = fs.mkdtempSync( + path.join(outputParent, '.ql3-admin-release-ceremony-'), + ); + fs.chmodSync(temporary, 0o700); + const vectorFile = path.join(temporary, 'synthetic-evidence.json'); + const steps = []; + const transcripts = []; + const keep = (result) => { + steps.push(result.record); + transcripts.push(result.stdout, result.stderr); + return result; + }; + const discard = (result) => { + keep(result); + result.stdout.fill(0); + result.stderr.fill(0); + }; + try { + const vector = await syntheticEvidenceVector(); + fs.writeFileSync(vectorFile, vector.encoded, { mode: 0o444, flag: 'wx' }); + const before = fs.statSync(vectorFile); + const workflow = `${options.repository}/${WORKFLOW}`; + const identity = `https://github.com/${workflow}@${options.sourceRef}`; + discard( + runStep( + tools.cosign, + 'keyless_signature', + [ + 'verify', + '--certificate-identity', + identity, + '--certificate-oidc-issuer', + 'https://token.actions.githubusercontent.com', + options.image, + ], + publicEnv, + 300_000, + ), + ); + const attestation = (name, predicateType) => { + const args = [ + 'attestation', + 'verify', + `oci://${options.image}`, + '--repo', + options.repository, + '--signer-workflow', + workflow, + '--source-digest', + options.sourceRevision, + '--source-ref', + options.sourceRef, + ]; + if (predicateType) args.push('--predicate-type', predicateType); + args.push('--deny-self-hosted-runners', '--bundle-from-oci'); + discard(runStep(tools.gh, name, args, githubEnv, 300_000)); + }; + attestation('provenance_attestation', ''); + attestation('cyclonedx_sbom_attestation', 'https://cyclonedx.org/bom'); + attestation( + 'os_vulnerability_attestation', + 'https://qinglong.dev/attestations/image-os-vulnerability/v1', + ); + discard( + runStep( + tools.docker, + 'immutable_image_pull', + ['pull', options.image], + publicEnv, + 600_000, + ), + ); + const inspection = keep( + runStep( + tools.docker, + 'local_digest_inspection', + ['image', 'inspect', options.image], + publicEnv, + 60_000, + ), + ); + const inspected = parseJson(inspection.stdout); + if ( + !Array.isArray(inspected) || + inspected.length !== 1 || + inspected[0]?.Os !== 'linux' || + !['amd64', 'arm64'].includes(inspected[0]?.Architecture) || + !Array.isArray(inspected[0]?.RepoDigests) || + !inspected[0].RepoDigests.includes(options.image) + ) { + fail(); + } + const mount = `type=bind,src=${vectorFile},dst=/evidence/bundle.json,readonly`; + const verified = keep( + runStep( + tools.docker, + 'embedded_evidence_verifier', + [ + 'run', + '--rm', + '--read-only', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + '32', + '--memory', + '128m', + '--cpus', + '0.25', + '--mount', + mount, + options.image, + 'evidence-verify', + '--bundle=/evidence/bundle.json', + ], + publicEnv, + 60_000, + ), + ); + if ( + verified.stderr.length !== 0 || + JSON.stringify(parseJson(verified.stdout)) !== + JSON.stringify(expectedEvidenceVerification(vector.bundle)) + ) { + fail(); + } + const after = fs.statSync(vectorFile); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + digest(fs.readFileSync(vectorFile)) !== + digest(Buffer.from(vector.encoded)) + ) { + fail(); + } + for (const tool of Object.values(tools)) verifyExecutable(tool); + const unsigned = { + schema: SCHEMA, + schemaVersion: 1, + observedAt: new Date().toISOString(), + release: { + image: options.image, + repository: options.repository, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + workflowIdentity: identity, + }, + tools: ['cosign', 'gh', 'docker'].map((name) => ({ + name, + sha256: tools[name].sha256, + sizeBytes: tools[name].size, + })), + verification: { + keylessSignature: true, + provenance: true, + cyclonedxSbom: true, + osVulnerabilityEvidence: true, + imagePulled: true, + localRepoDigestBound: true, + embeddedEvidenceVerifier: true, + }, + evidenceVector: { + schema: vector.bundle.schema, + contentDigest: vector.bundle.contentDigest, + entryCount: vector.bundle.source.entryCount, + totalRawCanonicalBytes: vector.bundle.source.totalRawCanonicalBytes, + classification: 'synthetic_non_sensitive', + }, + isolation: { + network: 'none_for_embedded_verifier', + readOnlyRoot: true, + capabilities: 'none', + noNewPrivileges: true, + pids: 32, + memoryBytes: 134217728, + cpus: 0.25, + verifierMutation: false, + verifierFileWrites: false, + }, + steps: withSequence(steps), + claims: { + externalToolResults: 'exit_zero_with_digest_only_transcript', + registryAvailability: 'observed_once', + offlineAudit: 'structure_and_digest_only', + workstationIdentityIncluded: false, + credentialIncluded: false, + reportAttestation: 'none', + actionAuthority: 'none', + }, + }; + const report = Object.freeze({ + ...unsigned, + contentDigest: digest(Buffer.from(canonicalize(unsigned), 'utf8')), + }); + writeNoReplace(options.output, report); + return report; + } finally { + token.bytes.fill(0); + for (const transcript of transcripts) transcript.fill(0); + try { + fs.rmSync(temporary, { recursive: true, force: true }); + } catch { + // A private synthetic-only directory is best-effort cleanup on failure. + } + } +} + +async function runCli(argv) { + const report = await runCeremony(parseArguments(argv)); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + component: 'qinglong3-cluster-admin-release-workstation-ceremony', + compatible: true, + contentDigest: report.contentDigest, + })}\n`, + ); +} + +if (require.main === module) { + runCli(process.argv.slice(2)).catch(() => { + process.stderr.write( + '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-workstation-ceremony","event":"ceremony_failed"}\n', + ); + process.exitCode = 1; + }); +} + +module.exports = { + SCHEMA, + expectedEvidenceVerification, + parseArguments, + runCeremony, + runCli, + syntheticEvidenceVector, +}; diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index e989245d..96cf1acc 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -6,6 +6,9 @@ const path = require('node:path'); const FILES = Object.freeze({ launcher: 'deploy/console/ql3-cluster-copilot/docker-loopback.sh', verifier: 'deploy/console/ql3-cluster-copilot/verify-release.sh', + ceremony: 'scripts/ql3-cluster-admin-release-workstation-ceremony.cjs', + ceremonyAudit: + 'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs', environment: 'deploy/console/ql3-cluster-copilot/host-environment.example.json', image: 'deploy/containers/ql3-cluster-admin/Dockerfile', @@ -107,10 +110,72 @@ function auditClusterCopilotConsoleDistribution(options = {}) { ); rejectFragments( 'verifier', - [':latest', 'refs/heads/', '--insecure-ignore-tlog', '--certificate-identity-regexp'], + [ + ':latest', + 'refs/heads/', + '--insecure-ignore-tlog', + '--certificate-identity-regexp', + ], 'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_WIDENED', ); + requireFragments( + 'ceremony', + [ + "'qinglong/cluster-admin-release-workstation-ceremony@v1'", + 'GH_TOKEN: token.token', + "'--certificate-identity'", + "'--certificate-oidc-issuer'", + "'https://token.actions.githubusercontent.com'", + "'--signer-workflow'", + "'--source-digest'", + "'--source-ref'", + "'--deny-self-hosted-runners'", + "'--bundle-from-oci'", + "'https://cyclonedx.org/bom'", + "'https://qinglong.dev/attestations/image-os-vulnerability/v1'", + "'immutable_image_pull'", + "'local_digest_inspection'", + "'embedded_evidence_verifier'", + "'--read-only'", + "'--network',\n 'none'", + "'--cap-drop'", + "'no-new-privileges'", + "'10001:10001'", + "'128m'", + "'0.25'", + "'evidence-verify'", + "reportAttestation: 'none'", + 'writeNoReplace(options.output, report)', + ], + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_DRIFT', + ); + rejectFragments( + 'ceremony', + [ + 'shell: true', + 'env: process.env', + ':latest', + '--privileged', + 'GH_TOKEN: token.token,\n ...publicEnv', + ], + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_WIDENED', + ); + requireFragments( + 'ceremonyAudit', + [ + "'qinglong/cluster-admin-release-workstation-ceremony-audit@v1'", + 'report must be one canonical owner-private bounded file', + "externalResults: 'not_replayed'", + "offlineAudit: 'structure_and_digest_only'", + "reportAttestation: 'none'", + "actionAuthority: 'none'", + 'O_NOFOLLOW', + 'canonicalize(unsigned)', + ], + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_AUDIT_DRIFT', + ); + let environment; try { environment = JSON.parse(source.environment); @@ -128,8 +193,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) { const expectedEnvironment = { QL3_COPILOT_CONSOLE_IMAGE: 'ghcr.io/replace-owner/qinglong3-cluster-admin@sha256:' + '0'.repeat(64), - QL3_COPILOT_CONSOLE_PRIVATE_ROOT: - '/absolute/private/ql3-copilot-console', + QL3_COPILOT_CONSOLE_PRIVATE_ROOT: '/absolute/private/ql3-copilot-console', QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress', QL3_COPILOT_CONSOLE_PORT: '5701', QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact', @@ -225,6 +289,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) { hostPublication: '127.0.0.1', kubernetesResident: false, additionalWorkspacePackages: 0, + externalWorkstationCeremony: 'source-tag-private-report', + ceremonyStatus: 'implementation-ready-public-release-pending', findings: Object.freeze(findings), compatible: findings.length === 0, }); diff --git a/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs b/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs new file mode 100644 index 00000000..8b76265d --- /dev/null +++ b/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs @@ -0,0 +1,277 @@ +'use strict'; + +const assert = require('node:assert/strict'); +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 ROOT = path.resolve(__dirname, '../..'); +const script = path.join( + ROOT, + 'scripts/ql3-cluster-admin-release-workstation-ceremony.cjs', +); +const auditScript = path.join( + ROOT, + 'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs', +); +const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat( + 64, +)}`; +const repository = 'example/qinglong'; +const revision = 'c'.repeat(40); +const sourceRef = 'refs/tags/v3.0.0-alpha.1'; +const token = 'github_pat_ceremony_fixture_only'; + +function fixture(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-ceremony-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const capture = path.join(directory, 'calls.jsonl'); + const tokenFile = path.join(directory, 'github-token'); + const output = path.join(directory, 'report.json'); + fs.writeFileSync(tokenFile, token, { mode: 0o600 }); + const tools = {}; + for (const name of ['cosign', 'gh', 'docker']) { + const tool = path.join(directory, name); + 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'); +if (name === 'docker' && args[0] === 'image' && args[1] === 'inspect') { + process.stdout.write(JSON.stringify([{ Os: 'linux', Architecture: 'arm64', RepoDigests: [${JSON.stringify( + image, + )}] }])); +} else if (name === 'docker' && args[0] === 'run') { + const mount = args[args.indexOf('--mount') + 1]; + const sourceFile = mount.split(',').find((part) => part.startsWith('src=')).slice(4); + const bundle = JSON.parse(fs.readFileSync(sourceFile, 'utf8')); + process.stdout.write(JSON.stringify({ + schema: 'qinglong/cluster-console-evidence-verification@v1', + status: 'verified', + bundle: { + schema: bundle.schema, + contentDigest: bundle.contentDigest, + entryCount: bundle.source.entryCount, + totalRawCanonicalBytes: bundle.source.totalRawCanonicalBytes, + }, + integrity: { bundleDigest: 'verified', rawFactDigests: 'not_recomputed_without_raw_facts' }, + claims: { serverSignature: 'not_verified', attestation: 'not_verified', durableAudit: 'not_verified', actionAuthority: 'none' }, + execution: { networkAccess: false, mutation: false, fileWrites: false }, + }) + '\\n'); +} +`; + fs.writeFileSync(tool, source, { mode: 0o700 }); + tools[name] = tool; + } + return { capture, directory, output, tokenFile, tools }; +} + +function args(value) { + return [ + `--image=${image}`, + `--repository=${repository}`, + `--source-revision=${revision}`, + `--source-ref=${sourceRef}`, + `--cosign=${value.tools.cosign}`, + `--gh=${value.tools.gh}`, + `--docker=${value.tools.docker}`, + `--github-token-file=${value.tokenFile}`, + `--output=${value.output}`, + ]; +} + +function invoke(arguments_) { + return spawnSync(process.execPath, [script, ...arguments_], { + cwd: ROOT, + encoding: 'utf8', + env: {}, + }); +} + +test('runs the immutable release ceremony and writes only digest-level evidence', (t) => { + const value = fixture(t); + const result = invoke(args(value)); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + const output = JSON.parse(result.stdout); + assert.equal(output.compatible, true); + assert.match(output.contentDigest, /^sha256:[a-f0-9]{64}$/); + + const reportText = fs.readFileSync(value.output, 'utf8'); + const report = JSON.parse(reportText); + assert.equal( + report.schema, + 'qinglong/cluster-admin-release-workstation-ceremony@v1', + ); + assert.equal(report.release.image, image); + assert.equal(report.release.sourceRevision, revision); + assert.equal(report.verification.embeddedEvidenceVerifier, true); + assert.equal(report.evidenceVector.classification, 'synthetic_non_sensitive'); + assert.equal(report.steps.length, 7); + assert.equal(report.claims.reportAttestation, 'none'); + assert.equal(reportText.includes(token), false); + assert.equal(fs.statSync(value.output).mode & 0o777, 0o600); + assert.equal( + fs + .readdirSync(value.directory) + .some((name) => name.startsWith('.ql3-admin-release-ceremony-')), + false, + ); + + const calls = fs + .readFileSync(value.capture, 'utf8') + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + assert.deepEqual( + calls.map(({ name }) => name), + ['cosign', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'], + ); + assert.deepEqual( + calls.map(({ tokenPresent }) => tokenPresent), + [false, true, true, true, false, false, false], + ); + assert.deepEqual(calls[0].args, [ + 'verify', + '--certificate-identity', + `https://github.com/${repository}/.github/workflows/ql3-image-release.yml@${sourceRef}`, + '--certificate-oidc-issuer', + 'https://token.actions.githubusercontent.com', + image, + ]); + for (const call of calls.slice(1, 4)) { + for (const required of [ + 'attestation', + 'verify', + `oci://${image}`, + '--repo', + repository, + '--signer-workflow', + `${repository}/.github/workflows/ql3-image-release.yml`, + '--source-digest', + revision, + '--source-ref', + sourceRef, + '--deny-self-hosted-runners', + '--bundle-from-oci', + ]) { + assert.ok(call.args.includes(required)); + } + } + assert.equal(calls[1].args.includes('--predicate-type'), false); + assert.ok(calls[2].args.includes('https://cyclonedx.org/bom')); + assert.ok( + calls[3].args.includes( + 'https://qinglong.dev/attestations/image-os-vulnerability/v1', + ), + ); + const dockerRun = calls[6].args; + for (const required of [ + '--read-only', + 'none', + 'ALL', + 'no-new-privileges', + '10001:10001', + '32', + '128m', + '0.25', + image, + 'evidence-verify', + '--bundle=/evidence/bundle.json', + ]) { + assert.ok(dockerRun.includes(required)); + } + + const audit = spawnSync( + process.execPath, + [ + auditScript, + `--report=${value.output}`, + `--image=${image}`, + `--repository=${repository}`, + `--source-revision=${revision}`, + `--source-ref=${sourceRef}`, + ], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.equal(audit.status, 0, audit.stderr); + assert.equal(JSON.parse(audit.stdout).externalResults, 'not_replayed'); +}); + +test('rejects mutable and source-unbound inputs before invoking tools', (t) => { + const value = fixture(t); + const base = args(value); + for (const [prefix, replacement] of [ + ['--image=', '--image=ghcr.io/example/qinglong3-cluster-admin:latest'], + ['--source-revision=', '--source-revision=short'], + ['--source-ref=', '--source-ref=refs/heads/next'], + ]) { + const rejected = invoke( + base.map((argument) => + argument.startsWith(prefix) ? replacement : argument, + ), + ); + assert.equal(rejected.status, 1); + assert.equal(rejected.stdout, ''); + assert.deepEqual(JSON.parse(rejected.stderr), { + schemaVersion: 1, + component: 'qinglong3-cluster-admin-release-workstation-ceremony', + event: 'ceremony_failed', + }); + } + assert.equal(fs.existsSync(value.capture), false); + assert.equal(fs.existsSync(value.output), false); +}); + +test('fails closed on tool failure and never replaces a report', (t) => { + const value = fixture(t); + fs.writeFileSync( + value.tools.cosign, + `#!${process.execPath}\nprocess.exit(19);\n`, + { mode: 0o700 }, + ); + const failed = invoke(args(value)); + assert.equal(failed.status, 1); + assert.equal(failed.stdout, ''); + assert.equal(fs.existsSync(value.output), false); + + fs.writeFileSync(value.output, 'owner-data\n', { mode: 0o600 }); + const rejected = invoke(args(value)); + assert.equal(rejected.status, 1); + assert.equal(fs.readFileSync(value.output, 'utf8'), 'owner-data\n'); +}); + +test('rejects widened file authority and executable drift', (t) => { + const value = fixture(t); + fs.chmodSync(value.tokenFile, 0o644); + const publicToken = invoke(args(value)); + assert.equal(publicToken.status, 1); + assert.equal(fs.existsSync(value.capture), false); + + fs.chmodSync(value.tokenFile, 0o600); + fs.chmodSync(value.tools.gh, 0o720); + const writableTool = invoke(args(value)); + assert.equal(writableTool.status, 1); + assert.equal(fs.existsSync(value.capture), false); + + fs.chmodSync(value.tools.gh, 0o700); + fs.writeFileSync( + value.tools.cosign, + `#!${process.execPath}\nconst fs=require('node:fs');fs.appendFileSync(process.argv[1],'\\n');\n`, + { mode: 0o700 }, + ); + const driftedTool = invoke(args(value)); + assert.equal(driftedTool.status, 1); + assert.equal(fs.existsSync(value.output), false); +}); diff --git a/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs b/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs new file mode 100644 index 00000000..68f78321 --- /dev/null +++ b/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs @@ -0,0 +1,263 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { createHash } = require('node:crypto'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + AUDIT_SCHEMA, + auditReport, +} = require('../../scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs'); + +const ROOT = path.resolve(__dirname, '../..'); +const script = path.join( + ROOT, + 'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs', +); +const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat( + 64, +)}`; +const repository = 'example/qinglong'; +const sourceRevision = 'c'.repeat(40); +const sourceRef = 'refs/tags/v3.0.0-alpha.1'; +const emptyDigest = + 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + +function canonicalize(value) { + if (value === null || typeof value !== 'object') { + return JSON.stringify(Object.is(value, -0) ? 0 : value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalize(value[key])}`) + .join(',')}}`; +} + +function digest(value) { + return `sha256:${createHash('sha256').update(value).digest('hex')}`; +} + +function resign(report) { + const unsigned = {}; + for (const [key, value] of Object.entries(report)) { + if (key !== 'contentDigest') unsigned[key] = value; + } + report.contentDigest = digest(Buffer.from(canonicalize(unsigned), 'utf8')); + return report; +} + +function validReport() { + const toolDigest = { + cosign: `sha256:${'1'.repeat(64)}`, + gh: `sha256:${'2'.repeat(64)}`, + docker: `sha256:${'3'.repeat(64)}`, + }; + const definitions = [ + ['keyless_signature', 'cosign'], + ['provenance_attestation', 'gh'], + ['cyclonedx_sbom_attestation', 'gh'], + ['os_vulnerability_attestation', 'gh'], + ['immutable_image_pull', 'docker'], + ['local_digest_inspection', 'docker'], + ['embedded_evidence_verifier', 'docker'], + ]; + return resign({ + schema: 'qinglong/cluster-admin-release-workstation-ceremony@v1', + schemaVersion: 1, + observedAt: '2026-08-16T12:00:00.000Z', + release: { + image, + repository, + sourceRevision, + sourceRef, + workflowIdentity: `https://github.com/${repository}/.github/workflows/ql3-image-release.yml@${sourceRef}`, + }, + tools: ['cosign', 'gh', 'docker'].map((name, index) => ({ + name, + sha256: toolDigest[name], + sizeBytes: 1000 + index, + })), + verification: { + keylessSignature: true, + provenance: true, + cyclonedxSbom: true, + osVulnerabilityEvidence: true, + imagePulled: true, + localRepoDigestBound: true, + embeddedEvidenceVerifier: true, + }, + evidenceVector: { + schema: 'qinglong/cluster-console-redacted-evidence-bundle@v1', + contentDigest: '4'.repeat(64), + entryCount: 1, + totalRawCanonicalBytes: 123, + classification: 'synthetic_non_sensitive', + }, + isolation: { + network: 'none_for_embedded_verifier', + readOnlyRoot: true, + capabilities: 'none', + noNewPrivileges: true, + pids: 32, + memoryBytes: 134217728, + cpus: 0.25, + verifierMutation: false, + verifierFileWrites: false, + }, + steps: definitions.map(([name, tool], index) => ({ + sequence: index + 1, + name, + tool, + executableSha256: toolDigest[tool], + argvSha256: `sha256:${'56789ab'[index].repeat(64)}`, + stdoutBytes: index === 6 ? 400 : 0, + stdoutSha256: index === 6 ? `sha256:${'d'.repeat(64)}` : emptyDigest, + stderrBytes: 0, + stderrSha256: emptyDigest, + exitCode: 0, + })), + claims: { + externalToolResults: 'exit_zero_with_digest_only_transcript', + registryAvailability: 'observed_once', + offlineAudit: 'structure_and_digest_only', + workstationIdentityIncluded: false, + credentialIncluded: false, + reportAttestation: 'none', + actionAuthority: 'none', + }, + }); +} + +const expected = { image, repository, sourceRevision, sourceRef }; + +function fixture(t, report = validReport()) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-ceremony-audit-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const reportFile = path.join(directory, 'report.json'); + fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, { + mode: 0o600, + }); + return { directory, reportFile }; +} + +function args(reportFile, overrides = {}) { + return [ + `--report=${reportFile}`, + `--image=${overrides.image ?? image}`, + `--repository=${repository}`, + `--source-revision=${sourceRevision}`, + `--source-ref=${sourceRef}`, + ]; +} + +test('accepts a canonical digest-bound ceremony report without replay claims', (t) => { + const report = validReport(); + assert.deepEqual(auditReport(report, expected), { + compatible: true, + reportContentDigest: report.contentDigest, + releaseImage: image, + verificationSteps: 7, + externalResults: 'not_replayed', + actionAuthority: 'none', + }); + const value = fixture(t, report); + const result = spawnSync( + process.execPath, + [script, ...args(value.reportFile)], + { + cwd: ROOT, + encoding: 'utf8', + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + assert.deepEqual(JSON.parse(result.stdout), { + schemaVersion: 1, + schema: AUDIT_SCHEMA, + compatible: true, + reportContentDigest: report.contentDigest, + releaseImage: image, + verificationSteps: 7, + externalResults: 'not_replayed', + actionAuthority: 'none', + }); +}); + +test('rejects structural claim widening even after the report is re-digested', () => { + for (const mutate of [ + (report) => { + report.verification.provenance = false; + }, + (report) => { + report.claims.reportAttestation = 'verified'; + }, + (report) => { + report.isolation.network = 'default'; + }, + (report) => { + report.steps[6].stderrBytes = 1; + }, + (report) => { + report.secret = 'must-not-be-accepted'; + }, + ]) { + const report = validReport(); + mutate(report); + resign(report); + assert.throws( + () => auditReport(report, expected), + /release workstation ceremony audit failed/i, + ); + } +}); + +test('rejects report swapping, noncanonical encoding and symlinks', (t) => { + const value = fixture(t); + const swapped = spawnSync( + process.execPath, + [ + script, + ...args(value.reportFile, { + image: `ghcr.io/example/qinglong3-cluster-admin@sha256:${'a'.repeat( + 64, + )}`, + }), + ], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.equal(swapped.status, 1); + assert.equal(swapped.stdout, ''); + + fs.writeFileSync(value.reportFile, JSON.stringify(validReport()), { + mode: 0o600, + }); + const noncanonical = spawnSync( + process.execPath, + [script, ...args(value.reportFile)], + { cwd: ROOT, encoding: 'utf8' }, + ); + assert.equal(noncanonical.status, 1); + + const target = path.join(value.directory, 'target.json'); + fs.renameSync(value.reportFile, target); + fs.symlinkSync(target, value.reportFile); + const linked = spawnSync( + process.execPath, + [script, ...args(value.reportFile)], + { + cwd: ROOT, + encoding: 'utf8', + }, + ); + assert.equal(linked.status, 1); +}); diff --git a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs index d9688b24..e2e4a9b7 100644 --- a/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleDistributionAudit.test.cjs @@ -27,6 +27,8 @@ test('accepts the signed multi-architecture Admin OCI workstation distribution', hostPublication: '127.0.0.1', kubernetesResident: false, additionalWorkspacePackages: 0, + externalWorkstationCeremony: 'source-tag-private-report', + ceremonyStatus: 'implementation-ready-public-release-pending', findings: [], compatible: true, }); @@ -34,7 +36,8 @@ test('accepts the signed multi-architecture Admin OCI workstation distribution', test('rejects remote publication and weakened image runtime authority', () => { for (const transform of [ - (source) => source.replace('127.0.0.1:$port:$port/tcp', '0.0.0.0:$port:$port/tcp'), + (source) => + source.replace('127.0.0.1:$port:$port/tcp', '0.0.0.0:$port:$port/tcp'), (source) => source.replace('--cap-drop ALL', '--privileged'), (source) => source.replace('--network "$network"', '--network host'), ]) { @@ -68,10 +71,11 @@ test('rejects verifier, embedded artifact and release workflow drift', () => { ], [ '.github/workflows/ql3-image-release.yml', - (source) => source.replace( - 'Promote only the verified digest to immutable release tags', - 'Promote mutable release tags', - ), + (source) => + source.replace( + 'Promote only the verified digest to immutable release tags', + 'Promote mutable release tags', + ), 'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT', ], ]; @@ -84,3 +88,39 @@ test('rejects verifier, embedded artifact and release workflow drift', () => { assert.ok(report.findings.some((finding) => finding.code === code)); } }); + +test('rejects external workstation ceremony and offline audit widening', () => { + const fixtures = [ + [ + 'scripts/ql3-cluster-admin-release-workstation-ceremony.cjs', + (source) => + source.replace( + "'--network',\n 'none'", + "'--network',\n 'default'", + ), + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_DRIFT', + ], + [ + 'scripts/ql3-cluster-admin-release-workstation-ceremony.cjs', + (source) => source.replace('windowsHide: true', 'shell: true'), + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_CEREMONY_WIDENED', + ], + [ + 'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs', + (source) => + source.replace( + "externalResults: 'not_replayed'", + "externalResults: 'verified'", + ), + 'QL3_CLUSTER_ADMIN_RELEASE_WORKSTATION_AUDIT_DRIFT', + ], + ]; + for (const [target, transform, code] of fixtures) { + const report = auditClusterCopilotConsoleDistribution({ + root: ROOT, + readFile: intercept(target, transform), + }); + assert.equal(report.compatible, false); + assert.ok(report.findings.some((finding) => finding.code === code)); + } +});