diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index e07bd10d..947c2793 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -300,6 +300,7 @@ jobs: test/back/ql3ClusterImageSbom.test.cjs test/back/ql3ClusterImageReleaseAudit.test.cjs test/back/ql3ReleaseCandidateContract.test.cjs + test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 0542ef36..ea8f00a4 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -92,6 +92,7 @@ jobs: - name: Re-audit commit-scoped private production evidence env: RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} run: | set -euo pipefail umask 077 @@ -116,6 +117,40 @@ jobs: --ca-rollover-report="${evidence_dir}/worker-management-ca-rollover.json" \ --source-commit="${GITHUB_SHA}" \ --release-version="${RELEASE_VERSION}" + receipt_root="${RUNNER_TEMP}/ql3-private-release-evidence-receipts" + install -d -m 0700 "${receipt_root}" + receipt="${receipt_root}/worker-management.json" + node scripts/ql3-private-release-evidence-receipt-contract.cjs \ + --mode=worker-create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --report="${evidence_dir}/worker-management-release-evidence.json" \ + --ceremony-report="${evidence_dir}/worker-management-ceremony.json" \ + --durable-audit-report="${evidence_dir}/worker-management-durable-audit.json" \ + --pki-rotation-report="${evidence_dir}/worker-management-pki-rotation-v2.json" \ + --ca-rollover-report="${evidence_dir}/worker-management-ca-rollover.json" \ + --output="${receipt}" > /dev/null + node scripts/ql3-private-release-evidence-receipt-contract.cjs \ + --mode=audit \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --evidence-kind=worker-management \ + --receipt="${receipt}" > /dev/null + + - name: Upload only the content-free Worker evidence receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-worker-management + path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json + if-no-files-found: error + retention-days: 1 + compression-level: 9 + overwrite: false + include-hidden-files: false cluster-dr-release-evidence: name: Audit private CloudNativePG disaster-recovery evidence @@ -138,6 +173,7 @@ jobs: - name: Re-audit commit-scoped private disaster-recovery evidence env: RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} run: | set -euo pipefail umask 077 @@ -162,6 +198,36 @@ jobs: --report="${report}" \ --source-commit="${GITHUB_SHA}" \ --release-version="${RELEASE_VERSION}" + receipt_root="${RUNNER_TEMP}/ql3-private-release-evidence-receipts" + install -d -m 0700 "${receipt_root}" + receipt="${receipt_root}/cloudnativepg-disaster-recovery.json" + node scripts/ql3-private-release-evidence-receipt-contract.cjs \ + --mode=dr-create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --report="${report}" \ + --output="${receipt}" > /dev/null + node scripts/ql3-private-release-evidence-receipt-contract.cjs \ + --mode=audit \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --evidence-kind=cloudnativepg-disaster-recovery \ + --receipt="${receipt}" > /dev/null + + - name: Upload only the content-free disaster-recovery evidence receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-cloudnativepg-disaster-recovery + path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/cloudnativepg-disaster-recovery.json + if-no-files-found: error + retention-days: 1 + compression-level: 9 + overwrite: false + include-hidden-files: false os-vulnerability: name: Scan ${{ matrix.image }} OS packages on ${{ matrix.image_arch }} @@ -610,11 +676,20 @@ jobs: name: Close and publish the complete deployment release set needs: - release-candidate + - worker-management-release-evidence + - cluster-dr-release-evidence - publish if: >- always() && needs.release-candidate.result == 'success' && - needs.publish.result == 'success' + needs.publish.result == 'success' && + ( + needs.release-candidate.outputs.cluster-evidence-required != 'true' || + ( + needs.worker-management-release-evidence.result == 'success' && + needs.cluster-dr-release-evidence.result == 'success' + ) + ) runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: @@ -639,6 +714,14 @@ jobs: path: ${{ runner.temp }}/release-records merge-multiple: true + - name: Download the same-run content-free private evidence receipts + if: needs.release-candidate.outputs.cluster-evidence-required == 'true' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-* + path: ${{ runner.temp }}/private-release-evidence-receipts + merge-multiple: true + - name: Aggregate and independently audit the complete release set id: release-set env: @@ -650,7 +733,9 @@ jobs: owner="${GITHUB_REPOSITORY_OWNER,,}" source_repository="${GITHUB_REPOSITORY,,}" candidate="${RUNNER_TEMP}/release-set-candidate.json" + evidence_receipts="${RUNNER_TEMP}/private-release-evidence-receipts" bundle="${RUNNER_TEMP}/release-catalog-bundle" + install -d -m 0700 "${evidence_receipts}" mkdir "${bundle}" report="${bundle}/qinglong3-release-set-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" plan="${bundle}/qinglong3-release-catalog-plan-${RELEASE_VERSION}-${RELEASE_SCOPE}.json" @@ -670,6 +755,7 @@ jobs: --repository-owner="${owner}" \ --candidate="${candidate}" \ --records="${RUNNER_TEMP}/release-records" \ + --evidence-receipts="${evidence_receipts}" \ --output="${report}" node scripts/ql3-release-set-contract.cjs \ --mode=audit \ @@ -680,6 +766,7 @@ jobs: --repository-owner="${owner}" \ --candidate="${candidate}" \ --records="${RUNNER_TEMP}/release-records" \ + --evidence-receipts="${evidence_receipts}" \ --report="${report}" > "${RUNNER_TEMP}/release-set-audit.json" node scripts/ql3-release-set-contract.cjs \ --mode=inspect \ diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 2db19210..3a239b3a 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,28 @@ 最新增量证据(2026-08-18): +- D-346/ADR-0438(已接受;首份真实公开收据待实际 release tag):Cluster/All 发布不再只通过 GitHub `needs` 保存“两个私有 evidence job + 成功”的瞬时调度事实。Worker management 与 CloudNativePG DR 私有 runner 在 source-aware gate 成功后,各自产生一个 + `qinglong/private-release-evidence-receipt@v1`:只投影 exact version/source tag/revision/scope、evidence kind、24 小时 freshness、 + 原报告 SHA-256、可选静态审计摘要和自身 digest,显式禁止原始报告、路径、credential、token、Kubernetes object 与 transcript 发布,并声明 + 公开消费者不能在缺少私有报告时重放现场结果。两个 1 天 artifact 均绑定同一 `run_id/run_attempt`,只作为跨 job 交接;长期 authority 升为 + `qinglong/release-set@v2`,其 digest 对完整收据对象做闭包。Local 必须零收据且不运行私有 evidence job;Cluster/All 必须按固定顺序恰好包含 + Worker 与 DR 两份同 identity 收据,缺失、重复、额外 kind、source/scope/freshness/static audit/self-digest 漂移均在 tag promotion 前失败关闭。 + OCI artifact/file media type同步升为 `application/vnd.qinglong.release-set.v2+json`,不在 v1 下偷换语义。Barman/cert-manager 静态 lock 继续 + 保持 `releaseReady=false`:它们只证明供应链选择,某次发布的现场 ready 结论由 freshness-bound 收据链给出。实现不新增 package、生产依赖、 + 数据库、migration、Pod、controller、listener、timer、设备工具或稳态资源;低配设备仍只消费可信工作站生成的 Local selection 与 immutable + image。定向 receipt/release-set/catalog/部署锁/workflow 契约 134/134;完整 backend 1,362 项为 1,360 pass/2 条件 skip/0 fail, + 18-package clean build/test 退出 0。12 项 package boundary、Cluster dependency、Edge import、Cluster/Worker/CloudNativePG 部署、backup、 + Barman/cert-manager selection、image release、deployment-lock surface 与 release-version 审计全部 compatible;package boundary 保持 + 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。14 档 Local artifact 全部 compatible 且与 D-345 字节基线完全一致:默认 + Edge/Standalone 为 2,589,890/2,589,968 bytes,adopted 为 2,809,185/2,809,308 bytes,application 为 + 3,632,769/3,632,889 bytes,application-api 为 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes, + application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes。阶段完整性 PostgreSQL HA 在 Docker Engine + 恢复后以 18.6/arm64 重跑 142/142 gates、timeline `1→2`,独立 evidence audit compatible,mode-0600 报告 SHA-256 为 + `1e7c31cc6c7aa3e1e0398eb51696b6054850d5fc25910a08d6faffc9a75f1c6f`,container/network/volume 残留为 0。以上证明实现、 + 失败关闭与本地/HA 回归边界;第一份线上公开收据及其 catalog 闭包仍只能由实际受保护 `v3` release tag dispatch 产生,不能由单测、 + workflow 静态审计或本地 fixture 冒充。 + - D-345/ADR-0437(已接受;首份真实公开 catalog evidence 待实际 release tag):Local 发布不再只依赖 image publisher 在完整 release-set/catalog 形成前直接掌握 digest 的 Compose rollout。`release-set` 成功后新增只读 `release-catalog-local-deployment-live`,仅在 `local|all` scope 运行;它从公开 discovery ref 独立消费 immutable catalog,重建并审计 diff --git a/docs/adr/ADR-0438-content-free-private-release-evidence-receipt-chain.md b/docs/adr/ADR-0438-content-free-private-release-evidence-receipt-chain.md new file mode 100644 index 00000000..84e194de --- /dev/null +++ b/docs/adr/ADR-0438-content-free-private-release-evidence-receipt-chain.md @@ -0,0 +1,83 @@ +# ADR-0438:内容无关的私有发布证据收据链 + +- 状态:Accepted +- 日期:2026-08-18 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-335、D-336、D-346 +- 关联 ADR:ADR-0245、ADR-0264、ADR-0425、ADR-0427、ADR-0428、ADR-0430、ADR-0436、ADR-0437 +- Supersedes:ADR-0427 的 release-set v1 聚合边界,以及 ADR-0428 的 release-set v1 OCI media type + +## 上下文 + +Cluster/All 发布已经要求两个 commit-scoped、24 小时内的私有门成功:Worker management 重新审计五份来源报告,CloudNativePG +disaster recovery 重新审计对象存储 backup/WAL/latest restore/PITR、证书轮换和三项静态供应链锁。publisher 通过 `needs` 只能看到 job +成功或失败,最终公开 release-set 却只记录候选策略中的“这些证据必须存在”,没有绑定本次 workflow attempt 实际审核的报告摘要。 + +因此 durable catalog 可以证明镜像、source 和 deployment family,却不能证明某个 Cluster release-set 对应哪一组私有发布证据。直接上传 +原始报告会泄漏生产环境事实并扩大保留面;只依赖 GitHub job 状态又会在 workflow artifact 过期后丢失可验证的发布闭包。 + +## 决策 + +1. 两个私有 evidence job 在既有 release gate 成功后,各自产生一份 + `qinglong/private-release-evidence-receipt@v1`。收据精确绑定 release version、40-hex source revision、source tag、`cluster|all` + scope、evidence kind、report SHA-256、观测/审核时间和 24 小时窗口,并具有自身 canonical SHA-256。 +2. Worker 收据只投影 source-aware 聚合报告摘要。CloudNativePG 收据额外投影 backup、Barman Cloud 与 cert-manager 三项静态审计结果摘要; + 静态 selection lock 继续保持 `releaseReady=false`,因为它自身不携带时效证据。实际某次发布是否 ready 由收据与 release-set 闭包决定, + 不能永久翻转一个静态文件来冒充持续有效的现场证据。 +3. 收据不得包含原始报告、路径、credential、token、连接信息、私钥、Kubernetes 对象或命令 transcript。它显式声明 + `sourceReportsUploaded=false`、`privateReportContentPublished=false`,并诚实记录公开消费者无法在缺少私有报告时重放外部结果。 +4. 私有 job 只上传收据文件,保留 1 天;原报告仍只存在于 commit-scoped ephemeral runner mount,禁止 cache 或 artifact 上传。artifact 名必须 + 同时绑定 `run_id/run_attempt/evidenceKind`,下载时也必须使用同一模式。 +5. release-set 升级为 `qinglong/release-set@v2`:`local` scope 必须恰好零份收据;`cluster|all` 必须按固定顺序恰好包含 + `worker-management` 与 `cloudnativepg-disaster-recovery` 两份同 release identity 收据。缺失、重复、额外 kind、source/scope、freshness、 + static audit 或任一 digest 漂移均在 tag promotion 和 catalog publication 前失败关闭。 +6. release-set v2 把完整 content-free 收据嵌入自身并纳入 `releaseSetDigest`,随后由 file provenance、OCI catalog immutable digest、Cosign 与 + GitHub provenance 共同保护。原始 1 天 receipt artifact 只是跨 job 交接,长期 authority 是公开 catalog 中的 release-set v2。 +7. OCI artifact/file media type 升为 `application/vnd.qinglong.release-set.v2+json`。不能用 v1 media type 承载新增字段,也不接受 v1 + release-set 进入新部署锁或 catalog consumer。 +8. 不新增 workspace package、production dependency、数据库、migration、listener、timer、controller、Pod 或设备侧工具。所有新工作只发生在 + Cluster/All 的短生命周期 release runner;Local 发布不等待私有 evidence job,Edge/Standalone 只消费工作站生成的 selection 与 immutable + image。 + +## 失败与恢复 + +- 私有报告不新鲜或 source 不匹配:私有 job 失败,不生成收据;重新取得同 source 的现场证据后重跑受保护 tag。 +- 收据 artifact 缺失、来自其他 attempt 或目录中出现额外文件:release-set 聚合失败;不能以 job success 或手工 digest 替代。 +- 收据已生成但 publish matrix 失败:不 promotion;同一 tag 重跑必须重新审核仍在时效窗口内的证据并产生新收据。 +- catalog 消费者可验证收据结构、自摘要和 release-set 闭包,但不能声称重放了私有现场结果;需要审计原始事实时进入受控私有 evidence 环境。 +- v1 release-set:3.0 尚未正式发布,不提供隐式兼容;从同一 source 重新执行 release workflow 生成 v2。 + +## 部署与资源影响 + +- Local scope 的 release-set v2 收据数组为空,不启动两个私有 runner,不增加路由设备下载、CPU、内存或稳态连接。 +- Cluster/All 的公开 release-set 增加两份小型 content-free JSON,节点无需安装证据工具或访问私有报告;可信工作站随 catalog 一并审核。 +- 原始证据不离开私有 runner,公开内容仅能做 equality/source/freshness binding,不能反推出 credential 或生产对象内容。 + +## 被拒绝的替代方案 + +### 只依赖 GitHub `needs..result` + +拒绝。它是瞬时调度事实,不能进入 release-set digest,也无法在 workflow 记录过期后由部署者独立核对。 + +### 上传全部私有证据到 release-set job + +拒绝。公共 publisher 不需要原始生产事实;扩大跨 runner 传输和保留面违反最小披露边界。 + +### 把静态 Barman/cert-manager lock 永久改为 release-ready + +拒绝。静态供应链选择没有 24 小时 freshness、backup/PITR 或 rotation 事实。ready 是 source-scoped release 结论,不是永久 lock 属性。 + +### 只在 image record 中加入一个布尔值 + +拒绝。布尔值无法绑定证据内容,也会把 deployment-family 级事实复制到每个镜像 publisher;release-set 才是跨镜像与私有证据的唯一终态 authority。 + +## 验证 + +- receipt contract 覆盖 Worker/DR exact projection、source/scope/freshness/report/static-lock/self-digest 漂移、私有字段注入、closed CLI、stable + private read、mode `0600` no-replace 和内容无关输出; +- release-set contract 覆盖 Local 零收据、Cluster/All exact-two、缺失/额外/漂移收据、v2 standalone inspection 与 digest 闭包; +- workflow 静态门冻结同 attempt artifact、私有报告不上传、两份收据 create/audit、release-set 下载与 aggregate/audit handoff,以及 v2 OCI media type; +- 定向契约 134/134,完整 backend 为 1,360 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;12 项架构/部署审计和 14 档 + Local artifact 全部 compatible,workspace 保持 18 packages 且没有 single-source/shallow-source package; +- PostgreSQL 18.6/arm64 HA 重跑 142/142 gates、timeline `1→2`,独立 evidence audit compatible,报告 SHA-256 为 + `1e7c31cc6c7aa3e1e0398eb51696b6054850d5fc25910a08d6faffc9a75f1c6f`,container/network/volume 残留为 0; +- 完整结果同步记录于 QL-RFC-0001 D-346;首份真实公开收据和 catalog 仍须由受保护 `v3` release tag 产生。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 0c0e85f0..554d81e2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -441,6 +441,7 @@ | [ADR-0435](./ADR-0435-uid-resource-version-fenced-kubernetes-resource-retirement.md) | UID/resourceVersion 围栏的 Kubernetes 资源退役 Ceremony | Accepted | | [ADR-0436](./ADR-0436-post-publication-catalog-bound-kubernetes-release-gate.md) | 发布后 Catalog-bound Kubernetes Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) | | [ADR-0437](./ADR-0437-post-publication-catalog-bound-local-release-gate.md) | 发布后 Catalog-bound Local Release Gate | Accepted(首份真实公开 catalog evidence 待实际 release tag) | +| [ADR-0438](./ADR-0438-content-free-private-release-evidence-receipt-chain.md) | 内容无关的私有发布证据收据链 | Accepted(首份真实公开收据待实际 release tag) | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index 06f3aed0..beeacb78 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -13,6 +13,17 @@ ghcr.io//qinglong3-release-catalog:v- `ghcr.io//qinglong3-release-catalog@sha256:`,验证这个 immutable reference 后,再从 JSON 的 `images[].reference` 读取完整 `ghcr.io//@sha256:`。 +## 私有发布证据收据链 + +当前长期 authority 是 `qinglong/release-set@v2`。`local` scope 的 `evidenceReceipts` 必须为空;`cluster|all` 必须按顺序恰好包含 +`worker-management` 与 `cloudnativepg-disaster-recovery` 两份 `qinglong/private-release-evidence-receipt@v1`。每份收据绑定同一 +version/source tag/revision/scope、24 小时 freshness、私有报告 digest 和自身 digest;DR 收据还绑定 CloudNativePG backup、Barman Cloud 与 +cert-manager 三项静态审计摘要。 + +这些收据不包含原始生产报告、路径、credential、token、Kubernetes object 或 transcript。公开 consumer 可以重算收据和 release-set digest, +但必须保持 `publicConsumerReplay=not_possible_without_private_reports`;它不能声称重放了私有现场结果。原始报告不上传,只有收据以 1 天 artifact +从私有 job 交给 release-set job,随后完整嵌入 release-set v2 并由 durable catalog 长期保护。 + ## 发布流水线内置 Local 与 Cluster 下游门 `local|all` scope 在 durable catalog 发布后启动独立 `release-catalog-local-deployment-live`。它与 publisher 权限隔离,从公开 catalog @@ -393,12 +404,14 @@ SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable reference;discovery tag 无 authority。 2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中 - `qinglong/release-set@v1` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 + `qinglong/release-set@v2` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 -4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的 +4. Local scope 必须为零私有收据;Cluster/All 必须精确包含两份同 source、同 scope、自摘要有效且 freshness 闭合的 content-free 收据。 + static lock compatible 不等于现场证据已公开重放,任何 consumer 都必须保留该限制。 +5. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的 `newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、 consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。 -5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned +6. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receipt;version/source/catalog tag 都只能用于发现,部署始终以 release set 中的镜像 digest 为准。 diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 66aeadcd..4cf806bf 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow( ); requirePattern( source, - /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, - 'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory', + /node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/, + 'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory', ); requirePattern( source, @@ -537,11 +537,16 @@ function auditReleaseWorkflow(source) { publishJob.if, ) || JSON.stringify(releaseSetJob?.needs) !== - JSON.stringify(['release-candidate', 'publish']) || + JSON.stringify([ + 'release-candidate', + 'worker-management-release-evidence', + 'cluster-dr-release-evidence', + 'publish', + ]) || releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' || releaseSetJob?.['timeout-minutes'] !== 20 || typeof releaseSetJob?.if !== 'string' || - !/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test( + !/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test( releaseSetJob.if, ) ) { @@ -630,7 +635,7 @@ function auditReleaseWorkflow(source) { } if ( !Array.isArray(drEvidenceJob?.steps) || - drEvidenceJob.steps.length !== 3 || + drEvidenceJob.steps.length !== 4 || drEvidenceJob.steps[0]?.uses !== 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false || @@ -640,7 +645,7 @@ function auditReleaseWorkflow(source) { typeof drEvidenceJob.steps[2]?.run !== 'string' ) { throw new Error( - 'private disaster-recovery evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps', + 'private disaster-recovery evidence job must contain only the reviewed checkout, audit, content-free receipt and upload steps', ); } const publishSteps = publishJob?.steps; @@ -707,7 +712,7 @@ function auditReleaseWorkflow(source) { const releaseSetSteps = releaseSetJob?.steps; if ( !Array.isArray(releaseSetSteps) || - releaseSetSteps.length !== 15 || + releaseSetSteps.length !== 16 || releaseSetSteps[0]?.uses !== 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || releaseSetSteps[0]?.with?.['persist-credentials'] !== false || @@ -723,54 +728,65 @@ function auditReleaseWorkflow(source) { path: '${{ runner.temp }}/release-records', 'merge-multiple': true, }) || - releaseSetSteps[3]?.id !== 'release-set' || - !/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test( - releaseSetSteps[3]?.run ?? '', - ) || - !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test( + releaseSetSteps[3]?.if !== + "needs.release-candidate.outputs.cluster-evidence-required == 'true'" || + releaseSetSteps[3]?.uses !== + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c' || + JSON.stringify(releaseSetSteps[3]?.with) !== + JSON.stringify({ + pattern: + 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*', + path: '${{ runner.temp }}/private-release-evidence-receipts', + 'merge-multiple': true, + }) || + releaseSetSteps[4]?.id !== 'release-set' || + !/evidence_receipts="\$\{RUNNER_TEMP\}\/private-release-evidence-receipts"[\s\S]*install -d -m 0700 "\$\{evidence_receipts\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--evidence-receipts="\$\{evidence_receipts\}"[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test( releaseSetSteps[4]?.run ?? '', ) || - releaseSetSteps[5]?.uses !== - 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || + !/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test( + releaseSetSteps[5]?.run ?? '', + ) || releaseSetSteps[6]?.uses !== + 'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' || + releaseSetSteps[7]?.uses !== 'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' || !/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test( - releaseSetSteps[7]?.run ?? '', + releaseSetSteps[8]?.run ?? '', ) || - releaseSetSteps[8]?.uses !== + releaseSetSteps[9]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[8]?.with) !== + JSON.stringify(releaseSetSteps[9]?.with) !== JSON.stringify({ 'subject-path': '${{ steps.release-set.outputs.report }}', }) || - releaseSetSteps[9]?.id !== 'catalog' || + releaseSetSteps[10]?.id !== 'catalog' || !/artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*image digest "\$\{discovery_tag\}"[\s\S]*artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*GITHUB_OUTPUT/.test( - releaseSetSteps[9]?.run ?? '', - ) || - !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( releaseSetSteps[10]?.run ?? '', ) || - releaseSetSteps[11]?.uses !== + !/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test( + releaseSetSteps[11]?.run ?? '', + ) || + releaseSetSteps[12]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[11]?.with) !== + JSON.stringify(releaseSetSteps[12]?.with) !== JSON.stringify({ 'subject-name': '${{ steps.catalog.outputs.repository }}', 'subject-digest': '${{ steps.catalog.outputs.digest }}', 'push-to-registry': true, }) || - releaseSetSteps[12]?.id !== 'catalog-receipt' || + releaseSetSteps[13]?.id !== 'catalog-receipt' || !/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test( - releaseSetSteps[12]?.run ?? '', + releaseSetSteps[13]?.run ?? '', ) || - releaseSetSteps[13]?.uses !== + releaseSetSteps[14]?.uses !== 'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' || - JSON.stringify(releaseSetSteps[13]?.with) !== + JSON.stringify(releaseSetSteps[14]?.with) !== JSON.stringify({ 'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}', }) || - releaseSetSteps[14]?.uses !== + releaseSetSteps[15]?.uses !== 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || - JSON.stringify(releaseSetSteps[14]?.with) !== + JSON.stringify(releaseSetSteps[15]?.with) !== JSON.stringify({ name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}', path: '${{ steps.release-set.outputs.bundle }}', @@ -928,17 +944,50 @@ function auditReleaseWorkflow(source) { } if ( !Array.isArray(evidenceJob?.steps) || - evidenceJob.steps.length !== 3 || + evidenceJob.steps.length !== 4 || evidenceJob.steps[0]?.uses !== 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || evidenceJob.steps[0]?.with?.['persist-credentials'] !== false || evidenceJob.steps[1]?.uses !== 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' || evidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' || - typeof evidenceJob.steps[2]?.run !== 'string' + typeof evidenceJob.steps[2]?.run !== 'string' || + evidenceJob.steps[3]?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(evidenceJob.steps[3]?.with) !== + JSON.stringify({ + name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-worker-management', + path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json', + 'if-no-files-found': 'error', + 'retention-days': 1, + 'compression-level': 9, + overwrite: false, + 'include-hidden-files': false, + }) || + !Array.isArray(drEvidenceJob?.steps) || + drEvidenceJob.steps.length !== 4 || + drEvidenceJob.steps[0]?.uses !== + 'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' || + drEvidenceJob.steps[0]?.with?.['persist-credentials'] !== false || + drEvidenceJob.steps[1]?.uses !== + 'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' || + drEvidenceJob.steps[1]?.with?.['node-version'] !== '24.18.0' || + typeof drEvidenceJob.steps[2]?.run !== 'string' || + drEvidenceJob.steps[3]?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(drEvidenceJob.steps[3]?.with) !== + JSON.stringify({ + name: 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-cloudnativepg-disaster-recovery', + path: '${{ runner.temp }}/ql3-private-release-evidence-receipts/cloudnativepg-disaster-recovery.json', + 'if-no-files-found': 'error', + 'retention-days': 1, + 'compression-level': 9, + overwrite: false, + 'include-hidden-files': false, + }) ) { throw new Error( - 'private evidence job must contain only the reviewed credential-free checkout, Node setup and audit steps', + 'private evidence jobs must contain only reviewed credential-free checkout, audit, content-free receipt and upload steps', ); } if (/actions\/cache@/.test(source)) { @@ -951,6 +1000,16 @@ function auditReleaseWorkflow(source) { /GITHUB_REF_TYPE}" != "tag"[\s\S]*GITHUB_REF_NAME}" != "v\$\{RELEASE_VERSION\}"/, 'private evidence gate must bind dispatch version to the exact tag', ); + requirePattern( + source, + /ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=worker-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=worker-management[\s\S]*--receipt="\$\{receipt\}"/, + 'Worker evidence job must create and audit only one content-free source-bound receipt', + ); + requirePattern( + source, + /ql3-private-release-evidence-receipt-contract\.cjs[\s\S]*--mode=dr-create[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*--report="\$\{report\}"[\s\S]*--output="\$\{receipt\}"[\s\S]*--mode=audit[\s\S]*--evidence-kind=cloudnativepg-disaster-recovery[\s\S]*--receipt="\$\{receipt\}"/, + 'disaster-recovery evidence job must create and audit only one content-free source-bound receipt', + ); requireExactOccurrences( source, /evidence_dir="\/run\/qinglong3-release-evidence\/\$\{GITHUB_SHA\}"/g, @@ -1199,13 +1258,15 @@ function auditReleaseWorkflow(source) { sourceAware: true, privateEphemeralRunner: true, maximumAgeSeconds: 86400, - artifactUpload: false, + privateReportUpload: false, + contentFreeReceiptPublished: true, }, cloudNativePgDisasterRecoveryEvidence: { sourceAware: true, privateEphemeralRunner: true, maximumAgeSeconds: 86400, - artifactUpload: false, + privateReportUpload: false, + contentFreeReceiptPublished: true, staticLocksReaudited: true, }, osVulnerabilityScan: { @@ -1241,6 +1302,7 @@ function auditReleaseWorkflow(source) { releaseSet: { sourceDerived: true, sameRunRecords: true, + sameRunPrivateEvidenceReceipts: true, exactScopeClosure: true, standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', @@ -1250,7 +1312,7 @@ function auditReleaseWorkflow(source) { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v1+json', + artifactType: 'application/vnd.qinglong.release-set.v2+json', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, diff --git a/scripts/ql3-private-release-evidence-receipt-contract.cjs b/scripts/ql3-private-release-evidence-receipt-contract.cjs new file mode 100644 index 00000000..9dd47839 --- /dev/null +++ b/scripts/ql3-private-release-evidence-receipt-contract.cjs @@ -0,0 +1,513 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const { + auditWorkerCredentialManagementReleaseGate, +} = require('./ql3-worker-credential-management-release-gate.cjs'); +const { + validateCloudNativePgDrReleaseEvidence, +} = require('./ql3-cloudnativepg-dr-evidence-audit.cjs'); +const { + auditCloudNativePgBackup, +} = require('./ql3-cloudnativepg-backup-audit.cjs'); +const { + auditBarmanCloudSupplyChain, +} = require('./ql3-barman-cloud-supply-chain-audit.cjs'); +const { + auditCertManagerSelection, +} = require('./ql3-cert-manager-selection-audit.cjs'); + +const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1'; +const EVIDENCE_KINDS = Object.freeze([ + 'worker-management', + 'cloudnativepg-disaster-recovery', +]); +const EVIDENCE_FIXTURES = Object.freeze({ + 'worker-management': 'qinglong/worker-credential-management-release-gate@v1', + 'cloudnativepg-disaster-recovery': + 'qinglong/cloudnativepg-disaster-recovery@v1', +}); +const MAX_JSON_BYTES = 1024 * 1024; +const MAX_EVIDENCE_AGE_SECONDS = 24 * 60 * 60; +const MAX_FUTURE_SKEW_MS = 5 * 60 * 1000; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; +const REVISION_PATTERN = /^[a-f0-9]{40}$/u; +const VERSION_PATTERN = + /^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u; +const STATIC_AUDIT_NAMES = Object.freeze([ + 'cloudnativepg-backup', + 'barman-cloud-supply-chain', + 'cert-manager-selection', +]); + +class QingLong3PrivateReleaseEvidenceReceiptError extends Error { + constructor(message) { + super(`QingLong 3 private release evidence receipt failed: ${message}`); + this.name = 'QingLong3PrivateReleaseEvidenceReceiptError'; + } +} + +function fail(message) { + throw new QingLong3PrivateReleaseEvidenceReceiptError(message); +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalJson(value) { + return `${JSON.stringify(value)}\n`; +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function canonicalAbsolute(filePath, label) { + if ( + typeof filePath !== 'string' || + !path.isAbsolute(filePath) || + path.resolve(filePath) !== filePath + ) { + fail(`${label} path must be canonical and absolute`); + } + if (fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath)) { + fail(`${label} parent must be canonical`); + } + return filePath; +} + +function readJsonFile(filePath, label, privateInput = false) { + const resolved = canonicalAbsolute(filePath, label); + const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0); + const descriptor = fs.openSync(resolved, flags); + try { + const before = fs.fstatSync(descriptor); + if ( + !before.isFile() || + before.nlink !== 1 || + before.size < 2 || + before.size > MAX_JSON_BYTES || + fs.realpathSync(resolved) !== resolved || + (privateInput && + (before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600)) + ) { + fail(`${label} must be one bounded canonical regular file`); + } + const bytes = fs.readFileSync(descriptor); + const after = fs.fstatSync(descriptor); + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + bytes.length !== before.size + ) { + bytes.fill(0); + fail(`${label} changed while being read`); + } + let value; + try { + value = JSON.parse(bytes.toString('utf8')); + } catch { + bytes.fill(0); + fail(`${label} must contain valid JSON`); + } + if (canonicalJson(value) !== bytes.toString('utf8')) { + bytes.fill(0); + fail(`${label} must use exact canonical JSON encoding`); + } + return Object.freeze({ value, bytes }); + } finally { + fs.closeSync(descriptor); + } +} + +function writeNoReplace(filePath, value) { + const resolved = canonicalAbsolute(filePath, 'output'); + if (fs.existsSync(resolved)) fail('output must not already exist'); + fs.writeFileSync(resolved, canonicalJson(value), { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); +} + +function validateIdentity(options) { + if ( + !VERSION_PATTERN.test(options.version || '') || + !REVISION_PATTERN.test(options.sourceRevision || '') || + options.sourceRef !== `refs/tags/v${options.version}` || + !['cluster', 'all'].includes(options.releaseScope) + ) { + fail('release identity must be one exact Cluster-capable QingLong 3 tag'); + } + if (!EVIDENCE_KINDS.includes(options.evidenceKind)) { + fail('evidence kind is invalid'); + } +} + +function staticAuditReceipts(root) { + const reports = [ + ['cloudnativepg-backup', auditCloudNativePgBackup({ root })], + ['barman-cloud-supply-chain', auditBarmanCloudSupplyChain({ root })], + ['cert-manager-selection', auditCertManagerSelection({ root })], + ]; + if (reports.some(([, report]) => report.compatible !== true)) { + fail('one or more static disaster-recovery locks are incompatible'); + } + return Object.freeze( + reports.map(([name, report]) => + Object.freeze({ + name, + auditDigest: sha256(JSON.stringify(report)), + compatible: true, + }), + ), + ); +} + +function assembleReceipt(options) { + validateIdentity(options); + if (!DIGEST_PATTERN.test(options.reportDigest || '')) { + fail('private evidence report digest is invalid'); + } + const observedAtMs = Date.parse(options.observedAt); + const validatedAtMs = options.nowMs; + if ( + !Number.isSafeInteger(validatedAtMs) || + !Number.isFinite(observedAtMs) || + observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS || + validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000 + ) { + fail('private evidence is outside the release freshness window'); + } + const staticAudits = + options.evidenceKind === 'cloudnativepg-disaster-recovery' + ? staticAuditReceipts(options.root) + : Object.freeze([]); + const unsigned = { + schemaVersion: 1, + schema: RECEIPT_SCHEMA, + release: { + version: options.version, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + scope: options.releaseScope, + }, + evidenceKind: options.evidenceKind, + evidence: { + fixture: options.fixture, + observedAt: options.observedAt, + validatedAt: new Date(validatedAtMs).toISOString(), + maximumAgeSeconds: MAX_EVIDENCE_AGE_SECONDS, + reportDigest: options.reportDigest, + sourceReportsUploaded: false, + }, + staticAudits, + verification: { + sourceAwareAudit: true, + privateEvidenceReplayed: true, + publicConsumerReplay: 'not_possible_without_private_reports', + privateReportContentPublished: false, + compatible: true, + }, + }; + return Object.freeze({ + ...unsigned, + receiptDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function createWorkerReceipt( + options, + dependencies = { + now: Date.now, + auditGate: auditWorkerCredentialManagementReleaseGate, + }, +) { + const nowMs = dependencies.now(); + const gate = dependencies.auditGate( + { + reportFile: options.reportFile, + ceremonyReportFile: options.ceremonyReportFile, + durableAuditReportFile: options.durableAuditReportFile, + pkiRotationReportFile: options.pkiRotationReportFile, + caRolloverReportFile: options.caRolloverReportFile, + sourceCommit: options.sourceRevision, + releaseVersion: options.version, + }, + { now: () => nowMs }, + ); + const report = readJsonFile( + options.reportFile, + 'Worker management evidence', + true, + ); + try { + const receipt = assembleReceipt({ + ...options, + evidenceKind: 'worker-management', + fixture: gate.fixture, + observedAt: report.value.observedAt, + reportDigest: gate.evidenceReportSha256, + nowMs, + }); + writeNoReplace(options.outputFile, receipt); + return receipt; + } finally { + report.bytes.fill(0); + } +} + +function createCloudNativePgReceipt( + options, + dependencies = { + now: Date.now, + validateEvidence: validateCloudNativePgDrReleaseEvidence, + }, +) { + const nowMs = dependencies.now(); + const report = readJsonFile( + options.reportFile, + 'CloudNativePG disaster-recovery evidence', + true, + ); + try { + const audit = dependencies.validateEvidence(report.value, { + sourceCommit: options.sourceRevision, + releaseVersion: options.version, + nowMs, + }); + if (!audit.compatible) { + fail('CloudNativePG disaster-recovery evidence is incompatible'); + } + const receipt = assembleReceipt({ + ...options, + evidenceKind: 'cloudnativepg-disaster-recovery', + fixture: audit.fixture, + observedAt: report.value.observedAt, + reportDigest: sha256(report.bytes), + nowMs, + }); + writeNoReplace(options.outputFile, receipt); + return receipt; + } finally { + report.bytes.fill(0); + } +} + +function inspectPrivateReleaseEvidenceReceipt(actual, options) { + validateIdentity(options); + const expectedStaticNames = + options.evidenceKind === 'cloudnativepg-disaster-recovery' + ? STATIC_AUDIT_NAMES + : []; + if ( + !exactKeys(actual, [ + 'schemaVersion', + 'schema', + 'release', + 'evidenceKind', + 'evidence', + 'staticAudits', + 'verification', + 'receiptDigest', + ]) || + actual.schemaVersion !== 1 || + actual.schema !== RECEIPT_SCHEMA || + !exactKeys(actual.release, [ + 'version', + 'sourceRevision', + 'sourceRef', + 'scope', + ]) || + JSON.stringify(actual.release) !== + JSON.stringify({ + version: options.version, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + scope: options.releaseScope, + }) || + actual.evidenceKind !== options.evidenceKind || + !exactKeys(actual.evidence, [ + 'fixture', + 'observedAt', + 'validatedAt', + 'maximumAgeSeconds', + 'reportDigest', + 'sourceReportsUploaded', + ]) || + actual.evidence.fixture !== EVIDENCE_FIXTURES[options.evidenceKind] || + actual.evidence.maximumAgeSeconds !== MAX_EVIDENCE_AGE_SECONDS || + !DIGEST_PATTERN.test(actual.evidence.reportDigest || '') || + actual.evidence.sourceReportsUploaded !== false || + !Array.isArray(actual.staticAudits) || + JSON.stringify(actual.staticAudits.map((entry) => entry?.name)) !== + JSON.stringify(expectedStaticNames) || + actual.staticAudits.some( + (entry) => + !exactKeys(entry, ['name', 'auditDigest', 'compatible']) || + !DIGEST_PATTERN.test(entry.auditDigest || '') || + entry.compatible !== true, + ) || + !exactKeys(actual.verification, [ + 'sourceAwareAudit', + 'privateEvidenceReplayed', + 'publicConsumerReplay', + 'privateReportContentPublished', + 'compatible', + ]) || + JSON.stringify(actual.verification) !== + JSON.stringify({ + sourceAwareAudit: true, + privateEvidenceReplayed: true, + publicConsumerReplay: 'not_possible_without_private_reports', + privateReportContentPublished: false, + compatible: true, + }) + ) { + fail('receipt shape or release binding is invalid'); + } + const observedAtMs = Date.parse(actual.evidence.observedAt); + const validatedAtMs = Date.parse(actual.evidence.validatedAt); + if ( + !Number.isFinite(observedAtMs) || + !Number.isFinite(validatedAtMs) || + observedAtMs > validatedAtMs + MAX_FUTURE_SKEW_MS || + validatedAtMs - observedAtMs > MAX_EVIDENCE_AGE_SECONDS * 1000 + ) { + fail('receipt freshness binding is invalid'); + } + const { receiptDigest, ...unsigned } = actual; + if ( + !DIGEST_PATTERN.test(receiptDigest || '') || + receiptDigest !== sha256(JSON.stringify(unsigned)) + ) { + fail('receipt digest is invalid'); + } + return Object.freeze({ + compatible: true, + evidenceKind: actual.evidenceKind, + receiptDigest, + reportDigest: actual.evidence.reportDigest, + publicConsumerReplay: actual.verification.publicConsumerReplay, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + const identity = [ + 'release-scope', + 'source-ref', + 'source-revision', + 'version', + ]; + const expected = + values.mode === 'worker-create' + ? [ + 'mode', + ...identity, + 'report', + 'ceremony-report', + 'durable-audit-report', + 'pki-rotation-report', + 'ca-rollover-report', + 'output', + ] + : values.mode === 'dr-create' + ? ['mode', ...identity, 'report', 'output'] + : values.mode === 'audit' + ? ['mode', ...identity, 'evidence-kind', 'receipt'] + : []; + if ( + expected.length === 0 || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail('arguments are invalid'); + } + return Object.freeze({ + mode: values.mode, + version: values.version, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + releaseScope: values['release-scope'], + ...(values['evidence-kind'] + ? { evidenceKind: values['evidence-kind'] } + : {}), + ...(values.report ? { reportFile: values.report } : {}), + ...(values['ceremony-report'] + ? { ceremonyReportFile: values['ceremony-report'] } + : {}), + ...(values['durable-audit-report'] + ? { durableAuditReportFile: values['durable-audit-report'] } + : {}), + ...(values['pki-rotation-report'] + ? { pkiRotationReportFile: values['pki-rotation-report'] } + : {}), + ...(values['ca-rollover-report'] + ? { caRolloverReportFile: values['ca-rollover-report'] } + : {}), + ...(values.output ? { outputFile: values.output } : {}), + ...(values.receipt ? { receiptFile: values.receipt } : {}), + }); +} + +function runCli( + argv, + root = path.resolve(__dirname, '..'), + output = process.stdout, +) { + const options = parseArguments(argv); + if (options.mode === 'audit') { + const receipt = readJsonFile(options.receiptFile, 'receipt').value; + const result = inspectPrivateReleaseEvidenceReceipt(receipt, options); + output.write(canonicalJson(result)); + return result; + } + const createOptions = { ...options, root }; + const receipt = + options.mode === 'worker-create' + ? createWorkerReceipt(createOptions) + : createCloudNativePgReceipt(createOptions); + output.write(canonicalJson(receipt)); + return receipt; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : 'receipt contract failed'}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + EVIDENCE_KINDS, + RECEIPT_SCHEMA, + QingLong3PrivateReleaseEvidenceReceiptError, + createCloudNativePgReceipt, + createWorkerReceipt, + inspectPrivateReleaseEvidenceReceipt, + parseArguments, + runCli, +}); diff --git a/scripts/ql3-release-catalog-contract.cjs b/scripts/ql3-release-catalog-contract.cjs index 37ed2090..b6738a78 100644 --- a/scripts/ql3-release-catalog-contract.cjs +++ b/scripts/ql3-release-catalog-contract.cjs @@ -12,7 +12,7 @@ const { const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1'; const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1'; -const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json'; +const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v2+json'; const FILE_MEDIA_TYPE = ARTIFACT_TYPE; const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json'; const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json'; diff --git a/scripts/ql3-release-set-contract.cjs b/scripts/ql3-release-set-contract.cjs index 7a6293d4..2e266bf3 100644 --- a/scripts/ql3-release-set-contract.cjs +++ b/scripts/ql3-release-set-contract.cjs @@ -12,11 +12,15 @@ const { SCHEMA: RELEASE_CANDIDATE_SCHEMA, auditReleaseCandidateContract, } = require('./ql3-release-candidate-contract.cjs'); +const { + EVIDENCE_KINDS, + inspectPrivateReleaseEvidenceReceipt, +} = require('./ql3-private-release-evidence-receipt-contract.cjs'); const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs'); const DEFAULT_ROOT = path.resolve(__dirname, '..'); const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1'; -const RELEASE_SET_SCHEMA = 'qinglong/release-set@v1'; +const RELEASE_SET_SCHEMA = 'qinglong/release-set@v2'; const MAX_JSON_BYTES = 1024 * 1024; const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u; const OWNER_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/u; @@ -237,9 +241,48 @@ function deploymentFamily(candidate, family, imageNames) { }); } +function expectedEvidenceKinds(releaseScope) { + return releaseScope === 'local' ? [] : [...EVIDENCE_KINDS]; +} + +function validateEvidenceReceipts(receipts, candidate) { + if (!Array.isArray(receipts)) { + fail('private release evidence receipts must be an array'); + } + const expectedKinds = expectedEvidenceKinds(candidate.release.scope); + if (receipts.length !== expectedKinds.length) { + fail('private release evidence receipt count differs from release scope'); + } + const receiptsByKind = new Map(); + for (const receipt of receipts) { + const evidenceKind = receipt?.evidenceKind; + if ( + !expectedKinds.includes(evidenceKind) || + receiptsByKind.has(evidenceKind) + ) { + fail('private release evidence receipts must be exact and unique'); + } + inspectPrivateReleaseEvidenceReceipt(receipt, { + version: candidate.release.version, + sourceRevision: candidate.release.sourceRevision, + sourceRef: candidate.release.sourceRef, + releaseScope: candidate.release.scope, + evidenceKind, + }); + receiptsByKind.set(evidenceKind, receipt); + } + return Object.freeze( + expectedKinds.map((evidenceKind) => receiptsByKind.get(evidenceKind)), + ); +} + function createReleaseSet(options) { const candidate = verifyCandidate(options.candidate, options); const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner); + const evidenceReceipts = validateEvidenceReceipts( + options.evidenceReceipts, + candidate, + ); if (!Array.isArray(options.records)) fail('image records must be an array'); if (options.records.length !== candidate.images.length) { fail('image record count differs from the release candidate'); @@ -281,6 +324,7 @@ function createReleaseSet(options) { local: deploymentFamily(candidate, 'local', localImages), cluster: deploymentFamily(candidate, 'cluster', clusterImages), }, + evidenceReceipts, images, promotion: { authority: 'complete_verified_release_set', @@ -292,6 +336,9 @@ function createReleaseSet(options) { requiredVerification: { imageKeylessSignature: true, imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS], + privateReleaseEvidenceReceipts: expectedEvidenceKinds( + candidate.release.scope, + ), releaseSetBuildProvenance: true, }, }; @@ -313,6 +360,9 @@ function auditReleaseSet(actual, options) { imageCount: actual.images.length, images: Object.freeze(actual.images.map((entry) => entry.name)), references: Object.freeze(actual.images.map((entry) => entry.reference)), + evidenceReceiptDigests: Object.freeze( + actual.evidenceReceipts.map((entry) => entry.receiptDigest), + ), tagPromotionAuthority: actual.promotion.authority, }); } @@ -337,6 +387,7 @@ function inspectReleaseSet(actual, options) { 'repositoryOwner', 'platforms', 'deploymentFamilies', + 'evidenceReceipts', 'images', 'promotion', 'requiredVerification', @@ -361,6 +412,7 @@ function inspectReleaseSet(actual, options) { JSON.stringify(actual.platforms) !== JSON.stringify(['linux/amd64', 'linux/arm64']) || !exactKeys(actual.deploymentFamilies, ['local', 'cluster']) || + !Array.isArray(actual.evidenceReceipts) || !Array.isArray(actual.images) || !exactKeys(actual.promotion, [ 'authority', @@ -380,12 +432,16 @@ function inspectReleaseSet(actual, options) { !exactKeys(actual.requiredVerification, [ 'imageKeylessSignature', 'imageAttestations', + 'privateReleaseEvidenceReceipts', 'releaseSetBuildProvenance', ]) || JSON.stringify(actual.requiredVerification) !== JSON.stringify({ imageKeylessSignature: true, imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS], + privateReleaseEvidenceReceipts: expectedEvidenceKinds( + options.releaseScope, + ), releaseSetBuildProvenance: true, }) ) { @@ -448,6 +504,20 @@ function inspectReleaseSet(actual, options) { ) { fail('standalone release set deployment families are invalid'); } + const expectedReceiptKinds = expectedEvidenceKinds(options.releaseScope); + if (actual.evidenceReceipts.length !== expectedReceiptKinds.length) { + fail('standalone release set evidence receipt count is invalid'); + } + for (let index = 0; index < expectedReceiptKinds.length; index += 1) { + const evidenceKind = expectedReceiptKinds[index]; + inspectPrivateReleaseEvidenceReceipt(actual.evidenceReceipts[index], { + version: options.version, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + releaseScope: options.releaseScope, + evidenceKind, + }); + } const { releaseSetDigest, ...unsigned } = actual; if ( !DIGEST_PATTERN.test(releaseSetDigest || '') || @@ -462,8 +532,12 @@ function inspectReleaseSet(actual, options) { imageCount: actual.images.length, images: Object.freeze([...expectedNames]), references: Object.freeze(actual.images.map((entry) => entry.reference)), + evidenceReceiptDigests: Object.freeze( + actual.evidenceReceipts.map((entry) => entry.receiptDigest), + ), verification: 'standalone_structure_identity_and_self_digest', sourceRecordsReplayed: false, + privateEvidenceReplayed: false, }); } @@ -492,6 +566,33 @@ function readRecordDirectory(directoryPath, candidate) { ); } +function readEvidenceReceiptDirectory(directoryPath, candidate) { + const resolved = resolveCanonicalAbsolute( + directoryPath, + 'private evidence receipts', + ); + const stat = fs.lstatSync(resolved); + if ( + !stat.isDirectory() || + stat.isSymbolicLink() || + fs.realpathSync(resolved) !== resolved + ) { + fail('private evidence receipts must be one canonical directory'); + } + const expectedNames = expectedEvidenceKinds(candidate.release.scope).map( + (kind) => `${kind}.json`, + ); + const actualNames = fs.readdirSync(resolved).sort(); + if ( + JSON.stringify(actualNames) !== JSON.stringify([...expectedNames].sort()) + ) { + fail('private evidence receipt directory differs from release scope'); + } + return expectedNames.map((name) => + readCanonicalJson(path.join(resolved, name), 'private evidence receipt'), + ); +} + function parseArguments(argv) { const values = {}; for (const argument of argv) { @@ -513,9 +614,9 @@ function parseArguments(argv) { values.mode === 'record-image' ? [...common, 'digest', 'image', 'output'] : values.mode === 'aggregate' - ? [...common, 'output', 'records'] + ? [...common, 'evidence-receipts', 'output', 'records'] : values.mode === 'audit' - ? [...common, 'records', 'report'] + ? [...common, 'evidence-receipts', 'records', 'report'] : values.mode === 'inspect' ? [...identity, 'report'] : []; @@ -537,6 +638,9 @@ function parseArguments(argv) { ...(values.image ? { image: values.image } : {}), ...(values.digest ? { digest: values.digest } : {}), ...(values.records ? { records: values.records } : {}), + ...(values['evidence-receipts'] + ? { evidenceReceipts: values['evidence-receipts'] } + : {}), ...(values.output ? { output: values.output } : {}), ...(values.report ? { report: values.report } : {}), }); @@ -558,11 +662,16 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) { return record; } const records = readRecordDirectory(options.records, candidate); + const evidenceReceipts = readEvidenceReceiptDirectory( + options.evidenceReceipts, + candidate, + ); if (options.mode === 'aggregate') { const releaseSet = createReleaseSet({ ...options, candidate, records, + evidenceReceipts, root, }); writeNoReplace(options.output, releaseSet); @@ -574,6 +683,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) { ...options, candidate, records, + evidenceReceipts, root, }); output.write(canonicalJson(audit)); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 9cc7651c..0e8a2213 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -54,13 +54,15 @@ test('accepts the reviewed native CI and digest release contracts', () => { sourceAware: true, privateEphemeralRunner: true, maximumAgeSeconds: 86400, - artifactUpload: false, + privateReportUpload: false, + contentFreeReceiptPublished: true, }, cloudNativePgDisasterRecoveryEvidence: { sourceAware: true, privateEphemeralRunner: true, maximumAgeSeconds: 86400, - artifactUpload: false, + privateReportUpload: false, + contentFreeReceiptPublished: true, staticLocksReaudited: true, }, osVulnerabilityScan: { @@ -96,6 +98,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { releaseSet: { sourceDerived: true, sameRunRecords: true, + sameRunPrivateEvidenceReceipts: true, exactScopeClosure: true, standaloneInspection: true, tagPromotionAuthority: 'complete_verified_release_set', @@ -105,7 +108,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { }, durableCatalog: { repository: 'qinglong3-release-catalog', - artifactType: 'application/vnd.qinglong.release-set.v1+json', + artifactType: 'application/vnd.qinglong.release-set.v2+json', basenameOnly: true, crossRunnerDeterministic: true, byteExactRoundTrip: true, @@ -175,6 +178,17 @@ test('rejects removal of the durable release-catalog contract tests', () => { ); }); +test('rejects removal of the private release evidence receipt contract tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs', + 'test/back/private-receipt-tests-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /private evidence receipt/, + ); +}); + test('rejects removal of the deployment-lock materialization contract tests', () => { const mutated = ciSource.replace( 'test/back/ql3DeploymentLockContract.test.cjs', @@ -470,14 +484,14 @@ test('rejects a reusable private evidence runner', () => { ); }); -test('rejects private evidence uploaded as a workflow artifact', () => { +test('rejects private report content uploaded instead of its digest-only receipt', () => { const mutated = releaseSource.replace( - ' - name: Re-audit commit-scoped private production evidence', - ' - uses: actions/upload-artifact@v4\n - name: Re-audit commit-scoped private production evidence', + 'path: ${{ runner.temp }}/ql3-private-release-evidence-receipts/worker-management.json', + 'path: /run/qinglong3-release-evidence/${{ github.sha }}/worker-management-release-evidence.json', ); assert.throws( () => auditReleaseWorkflow(mutated), - /only the reviewed|never persist private evidence/, + /content-free receipt and upload steps/, ); }); @@ -655,8 +669,8 @@ test('rejects scanning a daemon tag instead of the exact OCI tar', () => { test('rejects retaining scanned native release artifacts for more than one day', () => { const mutated = releaseSource.replace( - ' retention-days: 1', - ' retention-days: 2', + 'name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-${{ matrix.image_arch }}\n path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1', + 'name: ql3-release-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.image }}-${{ matrix.image_arch }}\n path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 2', ); assert.throws( () => auditReleaseWorkflow(mutated), @@ -666,8 +680,8 @@ test('rejects retaining scanned native release artifacts for more than one day', test('rejects overwrite authority on a scanned native artifact', () => { const mutated = releaseSource.replace( - ' overwrite: false', - ' overwrite: true', + 'path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1\n compression-level: 0\n overwrite: false', + 'path: ${{ runner.temp }}/ql3-native\n if-no-files-found: error\n retention-days: 1\n compression-level: 0\n overwrite: true', ); assert.throws( () => auditReleaseWorkflow(mutated), @@ -676,10 +690,16 @@ test('rejects overwrite authority on a scanned native artifact', () => { }); test('rejects a movable upload-artifact action', () => { - const mutated = releaseSource.replace( - 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1', - 'actions/upload-artifact@v7.0.1', - ); + const marker = + ' - name: Upload the scanned immutable native OCI artifact'; + const offset = releaseSource.indexOf(marker); + assert.notEqual(offset, -1); + const mutated = `${releaseSource.slice(0, offset)}${releaseSource + .slice(offset) + .replace( + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1', + 'actions/upload-artifact@v7.0.1', + )}`; assert.throws( () => auditReleaseWorkflow(mutated), /upload only bound immutable evidence/, @@ -864,6 +884,34 @@ test('rejects release-set closure before every image publisher succeeds', () => assert.throws(() => auditReleaseWorkflow(mutated), /complete publish matrix/); }); +test('rejects release-set closure detached from same-run private evidence receipts', () => { + const mutated = releaseSource.replace( + 'ql3-private-release-evidence-${{ github.run_id }}-${{ github.run_attempt }}-*', + 'ql3-private-release-evidence-${{ github.run_id }}-*', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /release-set job must download only same-run records/, + ); +}); + +test('rejects release-set aggregation that ignores private evidence receipts', () => { + const marker = + ' - name: Aggregate and independently audit the complete release set'; + const offset = releaseSource.indexOf(marker); + assert.notEqual(offset, -1); + const mutated = `${releaseSource.slice(0, offset)}${releaseSource + .slice(offset) + .replace( + ' --evidence-receipts="${evidence_receipts}" \\', + ' --evidence-receipts="${RUNNER_TEMP}/empty" \\', + )}`; + assert.throws( + () => auditReleaseWorkflow(mutated), + /release-set job must download only same-run records/, + ); +}); + test('rejects image records detached from the same workflow attempt', () => { const mutated = releaseSource.replace( 'ql3-release-record-${{ github.run_id }}-${{ github.run_attempt }}-*', diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs index 60aa03f0..742a19eb 100644 --- a/test/back/ql3DeploymentLockContract.test.cjs +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -42,6 +42,9 @@ const { catalogLockedArtifacts, deploymentArtifacts, } = require('../../scripts/ql3-kubernetes-deployment-live-contract.cjs'); +const { + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); const root = path.resolve(__dirname, '../..'); const version = readReleaseIdentity(root).version; @@ -97,6 +100,7 @@ function releaseSet(scope) { root, candidate, records, + evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release), ...identity, releaseScope: scope, }); diff --git a/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs b/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs new file mode 100644 index 00000000..772f5c69 --- /dev/null +++ b/test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs @@ -0,0 +1,275 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + createCloudNativePgReceipt, + createWorkerReceipt, + inspectPrivateReleaseEvidenceReceipt, + parseArguments, + runCli, +} = require('../../scripts/ql3-private-release-evidence-receipt-contract.cjs'); +const { + privateReleaseEvidenceReceipt, + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); + +const RELEASE = Object.freeze({ + version: '3.0.0-alpha.0', + sourceRevision: 'a'.repeat(40), + sourceRef: 'refs/tags/v3.0.0-alpha.0', + scope: 'cluster', +}); + +function options(evidenceKind) { + return Object.freeze({ + version: RELEASE.version, + sourceRevision: RELEASE.sourceRevision, + sourceRef: RELEASE.sourceRef, + releaseScope: RELEASE.scope, + evidenceKind, + }); +} + +function temporaryDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-private-release-receipt-')), + ); + fs.chmodSync(directory, 0o700); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +test('accepts only the exact Worker and disaster-recovery receipt projections', () => { + const receipts = privateReleaseEvidenceReceipts(RELEASE); + assert.deepEqual( + receipts.map((receipt) => receipt.evidenceKind), + ['worker-management', 'cloudnativepg-disaster-recovery'], + ); + for (const receipt of receipts) { + const result = inspectPrivateReleaseEvidenceReceipt( + receipt, + options(receipt.evidenceKind), + ); + assert.equal(result.compatible, true); + assert.equal(result.receiptDigest, receipt.receiptDigest); + assert.equal( + result.publicConsumerReplay, + 'not_possible_without_private_reports', + ); + assert.equal(JSON.stringify(receipt).includes('/run/qinglong3'), false); + assert.equal(JSON.stringify(receipt).includes('token'), false); + } +}); + +test('rejects source, scope, freshness, digest and static-lock drift', () => { + const mutations = [ + (receipt) => { + receipt.release.sourceRevision = 'b'.repeat(40); + }, + (receipt) => { + receipt.release.scope = 'all'; + }, + (receipt) => { + receipt.evidence.observedAt = '2026-08-16T00:00:00.000Z'; + }, + (receipt) => { + receipt.evidence.reportDigest = `sha256:${'0'.repeat(64)}`; + }, + (receipt) => { + receipt.staticAudits.pop(); + }, + ]; + for (const mutate of mutations) { + const receipt = JSON.parse( + JSON.stringify( + privateReleaseEvidenceReceipt( + RELEASE, + 'cloudnativepg-disaster-recovery', + ), + ), + ); + mutate(receipt); + assert.throws( + () => + inspectPrivateReleaseEvidenceReceipt( + receipt, + options('cloudnativepg-disaster-recovery'), + ), + /receipt|freshness/, + ); + } +}); + +test('rejects publishing private material in an otherwise re-digested receipt', () => { + const receipt = JSON.parse( + JSON.stringify(privateReleaseEvidenceReceipt(RELEASE, 'worker-management')), + ); + receipt.evidence.privateReport = { credential: 'must-not-publish' }; + assert.throws( + () => + inspectPrivateReleaseEvidenceReceipt( + receipt, + options('worker-management'), + ), + /receipt shape/, + ); +}); + +test('audit CLI accepts a canonical receipt and rejects open argument shapes', (t) => { + const directory = temporaryDirectory(t); + const receiptPath = path.join(directory, 'worker-management.json'); + const receipt = privateReleaseEvidenceReceipt(RELEASE, 'worker-management'); + fs.writeFileSync(receiptPath, `${JSON.stringify(receipt)}\n`, { + mode: 0o600, + }); + const args = [ + '--mode=audit', + `--version=${RELEASE.version}`, + `--source-revision=${RELEASE.sourceRevision}`, + `--source-ref=${RELEASE.sourceRef}`, + `--release-scope=${RELEASE.scope}`, + '--evidence-kind=worker-management', + `--receipt=${receiptPath}`, + ]; + const writes = []; + assert.equal( + runCli(args, path.resolve(__dirname, '../..'), { + write(value) { + writes.push(value); + }, + }).compatible, + true, + ); + assert.equal( + JSON.parse(writes.join('')).receiptDigest, + receipt.receiptDigest, + ); + assert.throws( + () => parseArguments([...args, '--extra=true']), + /arguments are invalid/, + ); + assert.throws( + () => + runCli( + args.map((entry) => + entry === `--release-scope=${RELEASE.scope}` + ? '--release-scope=local' + : entry, + ), + path.resolve(__dirname, '../..'), + { write() {} }, + ), + /Cluster-capable/, + ); +}); + +test('create wrappers publish only no-replace digest projections after their source-aware gates', (t) => { + const directory = temporaryDirectory(t); + const reportPath = path.join(directory, 'private-report.json'); + fs.writeFileSync( + reportPath, + `${JSON.stringify({ observedAt: '2026-08-18T00:00:00.000Z' })}\n`, + { mode: 0o600 }, + ); + const common = { + root: path.resolve(__dirname, '../..'), + version: RELEASE.version, + sourceRevision: RELEASE.sourceRevision, + sourceRef: RELEASE.sourceRef, + releaseScope: RELEASE.scope, + reportFile: reportPath, + }; + const now = () => Date.parse('2026-08-18T00:05:00.000Z'); + const workerPath = path.join(directory, 'worker.json'); + const worker = createWorkerReceipt( + { + ...common, + ceremonyReportFile: path.join(directory, 'private-ceremony.json'), + durableAuditReportFile: path.join(directory, 'private-durable.json'), + pkiRotationReportFile: path.join(directory, 'private-pki.json'), + caRolloverReportFile: path.join(directory, 'private-ca.json'), + outputFile: workerPath, + }, + { + now, + auditGate(options) { + assert.equal(options.sourceCommit, RELEASE.sourceRevision); + return { + fixture: 'qinglong/worker-credential-management-release-gate@v1', + evidenceReportSha256: `sha256:${'5'.repeat(64)}`, + }; + }, + }, + ); + assert.equal(worker.staticAudits.length, 0); + assert.equal(fs.statSync(workerPath).mode & 0o777, 0o600); + assert.equal(JSON.stringify(worker).includes('private-report'), false); + + const drPath = path.join(directory, 'dr.json'); + const dr = createCloudNativePgReceipt( + { ...common, outputFile: drPath }, + { + now, + validateEvidence(_report, options) { + assert.equal(options.sourceCommit, RELEASE.sourceRevision); + return { + fixture: 'qinglong/cloudnativepg-disaster-recovery@v1', + compatible: true, + }; + }, + }, + ); + assert.deepEqual( + dr.staticAudits.map((entry) => entry.name), + [ + 'cloudnativepg-backup', + 'barman-cloud-supply-chain', + 'cert-manager-selection', + ], + ); + assert.equal(fs.statSync(drPath).mode & 0o777, 0o600); + assert.throws( + () => + createCloudNativePgReceipt( + { ...common, outputFile: drPath }, + { + now, + validateEvidence() { + return { + fixture: 'qinglong/cloudnativepg-disaster-recovery@v1', + compatible: true, + }; + }, + }, + ), + /output must not already exist/, + ); + fs.chmodSync(reportPath, 0o640); + assert.throws( + () => + createWorkerReceipt( + { + ...common, + ceremonyReportFile: path.join(directory, 'private-ceremony.json'), + durableAuditReportFile: path.join(directory, 'private-durable.json'), + pkiRotationReportFile: path.join(directory, 'private-pki.json'), + caRolloverReportFile: path.join(directory, 'private-ca.json'), + outputFile: path.join(directory, 'untrusted-worker.json'), + }, + { + now, + auditGate() { + return { + fixture: 'qinglong/worker-credential-management-release-gate@v1', + evidenceReportSha256: `sha256:${'5'.repeat(64)}`, + }; + }, + }, + ), + /bounded canonical regular file/, + ); +}); diff --git a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs index dbe15681..bc81aa44 100644 --- a/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs +++ b/test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs @@ -19,6 +19,9 @@ const { const { auditClusterImageCiWorkflow, } = require('../../scripts/ql3-cluster-image-release-audit.cjs'); +const { + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); const ROOT = path.resolve(__dirname, '../..'); const SCRIPT = path.join( @@ -53,6 +56,12 @@ function selectedImages(scope) { function releaseSet(scope) { const selected = selectedImages(scope); + const release = { + version: VERSION, + sourceRevision: REVISION, + sourceRef: SOURCE_REF, + scope, + }; const images = selected.map(([name, repository], index) => { const digest = `sha256:${(index + 1).toString(16).repeat(64)}`; const reference = `ghcr.io/${OWNER}/${repository}`; @@ -70,13 +79,8 @@ function releaseSet(scope) { const names = images.map((entry) => entry.name); const unsigned = { schemaVersion: 1, - schema: 'qinglong/release-set@v1', - release: { - version: VERSION, - sourceRevision: REVISION, - sourceRef: SOURCE_REF, - scope, - }, + schema: 'qinglong/release-set@v2', + release, candidate: { schema: 'qinglong/release-candidate-contract@v1', contractDigest: `sha256:${'b'.repeat(64)}`, @@ -95,6 +99,7 @@ function releaseSet(scope) { images: names.filter((name) => name !== 'local'), }, }, + evidenceReceipts: privateReleaseEvidenceReceipts(release), images, promotion: { authority: 'complete_verified_release_set', @@ -111,6 +116,10 @@ function releaseSet(scope) { 'os-vulnerability', 'release-candidate-contract', ], + privateReleaseEvidenceReceipts: + scope === 'local' + ? [] + : ['worker-management', 'cloudnativepg-disaster-recovery'], releaseSetBuildProvenance: true, }, }; diff --git a/test/back/ql3ReleaseCatalogContract.test.cjs b/test/back/ql3ReleaseCatalogContract.test.cjs index 2ea7d89f..a5473551 100644 --- a/test/back/ql3ReleaseCatalogContract.test.cjs +++ b/test/back/ql3ReleaseCatalogContract.test.cjs @@ -28,6 +28,9 @@ const { const { readReleaseIdentity, } = require('../../scripts/lib/ql3-release-identity.cjs'); +const { + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); const root = path.resolve(__dirname, '../..'); const version = readReleaseIdentity(root).version; @@ -65,6 +68,7 @@ function releaseSet(scope) { root, candidate, records, + evidenceReceipts: privateReleaseEvidenceReceipts(candidate.release), ...identity, releaseScope: scope, }); diff --git a/test/back/ql3ReleaseEvidenceFixture.cjs b/test/back/ql3ReleaseEvidenceFixture.cjs new file mode 100644 index 00000000..505f215b --- /dev/null +++ b/test/back/ql3ReleaseEvidenceFixture.cjs @@ -0,0 +1,75 @@ +'use strict'; + +const crypto = require('node:crypto'); + +const RECEIPT_SCHEMA = 'qinglong/private-release-evidence-receipt@v1'; +const FIXTURES = Object.freeze({ + 'worker-management': 'qinglong/worker-credential-management-release-gate@v1', + 'cloudnativepg-disaster-recovery': + 'qinglong/cloudnativepg-disaster-recovery@v1', +}); +const STATIC_AUDITS = Object.freeze([ + 'cloudnativepg-backup', + 'barman-cloud-supply-chain', + 'cert-manager-selection', +]); + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function privateReleaseEvidenceReceipt(release, evidenceKind) { + const staticAudits = + evidenceKind === 'cloudnativepg-disaster-recovery' + ? STATIC_AUDITS.map((name, index) => ({ + name, + auditDigest: `sha256:${String(index + 7).repeat(64)}`, + compatible: true, + })) + : []; + const unsigned = { + schemaVersion: 1, + schema: RECEIPT_SCHEMA, + release: { ...release }, + evidenceKind, + evidence: { + fixture: FIXTURES[evidenceKind], + observedAt: '2026-08-18T00:00:00.000Z', + validatedAt: '2026-08-18T00:05:00.000Z', + maximumAgeSeconds: 86_400, + reportDigest: `sha256:${ + evidenceKind === 'worker-management' ? '5' : '6' + }`.padEnd(71, evidenceKind === 'worker-management' ? '5' : '6'), + sourceReportsUploaded: false, + }, + staticAudits, + verification: { + sourceAwareAudit: true, + privateEvidenceReplayed: true, + publicConsumerReplay: 'not_possible_without_private_reports', + privateReportContentPublished: false, + compatible: true, + }, + }; + return Object.freeze({ + ...unsigned, + receiptDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function privateReleaseEvidenceReceipts(release) { + return release.scope === 'local' + ? [] + : [ + privateReleaseEvidenceReceipt(release, 'worker-management'), + privateReleaseEvidenceReceipt( + release, + 'cloudnativepg-disaster-recovery', + ), + ]; +} + +module.exports = Object.freeze({ + privateReleaseEvidenceReceipt, + privateReleaseEvidenceReceipts, +}); diff --git a/test/back/ql3ReleaseSetContract.test.cjs b/test/back/ql3ReleaseSetContract.test.cjs index 002267f4..7575403f 100644 --- a/test/back/ql3ReleaseSetContract.test.cjs +++ b/test/back/ql3ReleaseSetContract.test.cjs @@ -19,6 +19,9 @@ const { const { readReleaseIdentity, } = require('../../scripts/lib/ql3-release-identity.cjs'); +const { + privateReleaseEvidenceReceipts, +} = require('./ql3ReleaseEvidenceFixture.cjs'); const root = path.resolve(__dirname, '../..'); const version = readReleaseIdentity(root).version; @@ -52,6 +55,10 @@ function recordsFor(releaseCandidate) { ); } +function evidenceFor(releaseCandidate) { + return privateReleaseEvidenceReceipts(releaseCandidate.release); +} + function temporaryDirectory(t) { const directory = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-release-set-')), @@ -71,6 +78,7 @@ test('aggregates the independent Local image into one immutable release set', () root, candidate: releaseCandidate, records, + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'local', }); @@ -88,6 +96,7 @@ test('aggregates the independent Local image into one immutable release set', () root, candidate: releaseCandidate, records, + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'local', }).compatible, @@ -101,6 +110,7 @@ test('closes cluster and all scopes over the exact candidate image order', () => root, candidate: clusterCandidate, records: recordsFor(clusterCandidate).reverse(), + evidenceReceipts: evidenceFor(clusterCandidate), ...identity, releaseScope: 'cluster', }); @@ -114,12 +124,17 @@ test('closes cluster and all scopes over the exact candidate image order', () => 'admin', 'worker', ]); + assert.deepEqual( + clusterSet.evidenceReceipts.map((entry) => entry.evidenceKind), + ['worker-management', 'cloudnativepg-disaster-recovery'], + ); const allCandidate = candidate('all'); const allSet = createReleaseSet({ root, candidate: allCandidate, records: recordsFor(allCandidate), + evidenceReceipts: evidenceFor(allCandidate), ...identity, releaseScope: 'all', }); @@ -127,6 +142,51 @@ test('closes cluster and all scopes over the exact candidate image order', () => assert.deepEqual(allSet.deploymentFamilies.local.images, ['local']); }); +test('requires exact private evidence receipts only for Cluster-capable scopes', () => { + const releaseCandidate = candidate('cluster'); + const records = recordsFor(releaseCandidate); + const receipts = evidenceFor(releaseCandidate); + assert.throws( + () => + createReleaseSet({ + root, + candidate: releaseCandidate, + records, + evidenceReceipts: receipts.slice(1), + ...identity, + releaseScope: 'cluster', + }), + /receipt count differs/, + ); + const drifted = JSON.parse(JSON.stringify(receipts)); + drifted[0].release.sourceRevision = 'c'.repeat(40); + assert.throws( + () => + createReleaseSet({ + root, + candidate: releaseCandidate, + records, + evidenceReceipts: drifted, + ...identity, + releaseScope: 'cluster', + }), + /receipt shape|release binding/, + ); + const localCandidate = candidate('local'); + assert.throws( + () => + createReleaseSet({ + root, + candidate: localCandidate, + records: recordsFor(localCandidate), + evidenceReceipts: [receipts[0]], + ...identity, + releaseScope: 'local', + }), + /receipt count differs/, + ); +}); + test('rejects missing, duplicate and cross-candidate image evidence', () => { const releaseCandidate = candidate('cluster'); const records = recordsFor(releaseCandidate); @@ -136,6 +196,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => { root, candidate: releaseCandidate, records: records.slice(1), + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'cluster', }), @@ -147,6 +208,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => { root, candidate: releaseCandidate, records: [records[0], records[0], records[2], records[3]], + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'cluster', }), @@ -160,6 +222,7 @@ test('rejects missing, duplicate and cross-candidate image evidence', () => { root, candidate: releaseCandidate, records: [mutated, ...records.slice(1)], + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'cluster', }), @@ -199,6 +262,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () => root, candidate: releaseCandidate, records, + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'local', }); @@ -210,6 +274,7 @@ test('rejects mutable identity, malformed owner and post-aggregate drift', () => root, candidate: releaseCandidate, records, + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'local', }), @@ -224,6 +289,7 @@ test('inspects a release set without short-lived candidate or image records', () root, candidate: releaseCandidate, records: recordsFor(releaseCandidate), + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: scope, }); @@ -243,6 +309,7 @@ test('standalone inspection rejects image, family and self-digest drift', () => root, candidate: releaseCandidate, records: recordsFor(releaseCandidate), + evidenceReceipts: evidenceFor(releaseCandidate), ...identity, releaseScope: 'cluster', }); @@ -256,6 +323,9 @@ test('standalone inspection rejects image, family and self-digest drift', () => (value) => { value.releaseSetDigest = `sha256:${'0'.repeat(64)}`; }, + (value) => { + value.evidenceReceipts[0].receiptDigest = `sha256:${'0'.repeat(64)}`; + }, ]) { const drifted = JSON.parse(JSON.stringify(releaseSet)); mutate(drifted); @@ -265,7 +335,7 @@ test('standalone inspection rejects image, family and self-digest drift', () => ...identity, releaseScope: 'cluster', }), - /standalone release set/, + /standalone release set|receipt digest/, ); } assert.throws( @@ -283,7 +353,9 @@ test('standalone inspection rejects image, family and self-digest drift', () => test('CLI records, aggregates and audits exact no-replace files', (t) => { const directory = temporaryDirectory(t); const recordsDirectory = path.join(directory, 'records'); + const evidenceDirectory = path.join(directory, 'evidence'); fs.mkdirSync(recordsDirectory); + fs.mkdirSync(evidenceDirectory); const releaseCandidate = candidate('local'); const candidatePath = path.join(directory, 'candidate.json'); writeCanonical(candidatePath, releaseCandidate); @@ -315,6 +387,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { '--mode=aggregate', ...common, `--records=${recordsDirectory}`, + `--evidence-receipts=${evidenceDirectory}`, `--output=${setPath}`, ], root, @@ -326,6 +399,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { '--mode=audit', ...common, `--records=${recordsDirectory}`, + `--evidence-receipts=${evidenceDirectory}`, `--report=${setPath}`, ], root, @@ -356,6 +430,7 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { '--mode=aggregate', ...common, `--records=${recordsDirectory}`, + `--evidence-receipts=${evidenceDirectory}`, `--output=${setPath}`, ], root, @@ -368,7 +443,9 @@ test('CLI records, aggregates and audits exact no-replace files', (t) => { test('CLI rejects extra records, symlinks and open argument shapes', (t) => { const directory = temporaryDirectory(t); const recordsDirectory = path.join(directory, 'records'); + const evidenceDirectory = path.join(directory, 'evidence'); fs.mkdirSync(recordsDirectory); + fs.mkdirSync(evidenceDirectory); const releaseCandidate = candidate('local'); const candidatePath = path.join(directory, 'candidate.json'); writeCanonical(candidatePath, releaseCandidate); @@ -392,6 +469,7 @@ test('CLI rejects extra records, symlinks and open argument shapes', (t) => { '--mode=aggregate', ...common, `--records=${recordsDirectory}`, + `--evidence-receipts=${evidenceDirectory}`, `--output=${path.join(directory, 'set.json')}`, ], root, @@ -409,6 +487,7 @@ test('CLI rejects extra records, symlinks and open argument shapes', (t) => { '--mode=aggregate', ...common, `--records=${recordsDirectory}`, + `--evidence-receipts=${evidenceDirectory}`, `--output=${path.join(directory, 'set.json')}`, ], root,