mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add external release workstation ceremony
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/<owner>/qinglong3-cluster-admin@sha256:<digest>`、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 常驻服务。
|
||||
|
||||
@@ -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/<owner>/qinglong3-cluster-admin@sha256:<digest>`、`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。
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user