diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index 834786ea..1d081a9c 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -1219,6 +1219,92 @@ jobs: overwrite: false include-hidden-files: false + cluster-vault-kv-worker-secret-live: + name: Vault KV Worker Secret direct custody (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x64 + kubernetesArch: amd64 + - runner: ubuntu-24.04-arm + arch: arm64 + kubernetesArch: arm64 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + with: + version: '8.3.1' + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + - name: Verify native runner architecture + run: node -e "if (process.arch !== '${{ matrix.arch }}') throw new Error('unexpected architecture ' + process.arch)" + - name: Install workspace dependencies without lifecycle scripts + run: pnpm install --frozen-lockfile --ignore-scripts + - name: Install verified kubectl v1.34.3 + shell: bash + run: | + set -euo pipefail + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/kubectl" \ + "https://dl.k8s.io/release/v1.34.3/bin/linux/${{ matrix.kubernetesArch }}/kubectl" + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + "https://dl.k8s.io/release/v1.34.3/bin/linux/${{ matrix.kubernetesArch }}/kubectl.sha256" + test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \ + "$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)" + chmod 0755 "${RUNNER_TEMP}/kubectl" + - name: Build the direct custody adapter + run: | + pnpm --filter @qinglong/runtime-core build + pnpm --filter @qinglong/cluster-control build + - name: Verify the fail-closed adapter and evidence contracts + run: | + node --test \ + packages/ql3-cluster-control/test/vaultKvSecretProvider.test.cjs \ + packages/ql3-cluster-control/test/vaultKvWorkerIngressConfig.test.cjs \ + test/back/ql3VaultKvWorkerSecretLiveAudit.test.cjs \ + test/back/ql3VaultKvWorkerSecretDeploymentAudit.test.cjs + pnpm audit:vault-kv-worker-secret-deployment:ql3 + "${RUNNER_TEMP}/kubectl" kustomize \ + deploy/kubernetes/ql3-cluster/vault-kv-worker-secret \ + >/dev/null + - name: Preload the digest-pinned native Vault image + run: docker pull docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569 + - name: Prove TLS, least privilege, value and token rotation, seal and recovery + env: + QL3_RUN_VAULT_KV_WORKER_SECRET_LIVE: 'true' + QL3_VAULT_KV_WORKER_SECRET_REPORT: ${{ runner.temp }}/ql3-vault-kv-worker-secret/report.json + run: | + umask 077 + mkdir -m 0700 "$(dirname "${QL3_VAULT_KV_WORKER_SECRET_REPORT}")" + node scripts/ql3-vault-kv-worker-secret-live-contract.cjs >/dev/null + - name: Re-audit content-free direct custody evidence and cleanup + env: + QL3_VAULT_KV_WORKER_SECRET_REPORT: ${{ runner.temp }}/ql3-vault-kv-worker-secret/report.json + run: | + test "$(stat -c '%a' "${QL3_VAULT_KV_WORKER_SECRET_REPORT}")" = '600' + pnpm audit:vault-kv-worker-secret-live:ql3 \ + "--report=${QL3_VAULT_KV_WORKER_SECRET_REPORT}" + sha256sum "${QL3_VAULT_KV_WORKER_SECRET_REPORT}" + test -z "$(docker ps -aq --filter name=ql3-vault-kv-worker-)" + - name: Upload audited content-free Vault KV custody evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-vault-kv-worker-secret-${{ matrix.arch }}-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-vault-kv-worker-secret/report.json + if-no-files-found: error + retention-days: 14 + compression-level: 9 + overwrite: false + include-hidden-files: false + cluster-plugin-package-kubernetes-live: name: Plugin Package Kubernetes CAS and RBAC live contract runs-on: ubuntu-24.04 diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index ea8204ea..d330a68e 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -1032,6 +1032,22 @@ Kubernetes API permission. Version-pinned SecretRefs use a different hash and remain immutable by policy; retain their files until referenced Run/Artifact retention has completed. +For deployments that require direct external custody, use the optional +`vault-kv-worker-secret` overlay instead of projecting value files: + +```bash +kubectl kustomize \ + deploy/kubernetes/ql3-cluster/vault-kv-worker-secret >/dev/null +``` + +That overlay mounts only a private Vault CA and a short-lived, orphan, +non-renewable read token. The adapter revalidates the token and fetches exact +digest-derived KV v2 paths over TLS 1.3 for every authorized delivery; it has no +cache, watcher, renewal loop or fallback to `mounted-files`. Follow the +overlay's README for the exact policy, payload and token-rotation contract. +External Vault HA, seal custody, audit devices, backup and disaster recovery +remain deployment-specific gates. + Create a distinct migration Secret: ```yaml diff --git a/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/README.md b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/README.md new file mode 100644 index 00000000..96d3aef6 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/README.md @@ -0,0 +1,50 @@ +# Vault KV v2 Worker Secret custody + +This overlay replaces the base `mounted-files` value projection with direct, +TLS 1.3 Vault KV v2 resolution. The control Pods mount only the private Vault +CA and one short-lived read token; Secret values and opaque Legacy Env bundles +remain in Vault and are fetched only after the durable Worker delivery authority +has been validated. + +The exact Vault policy is read-only: + +```hcl +path "worker-secrets/data/values/production/*" { + capabilities = ["read"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} +``` + +Issue an orphan, non-renewable service token with only that policy and a TTL no +greater than 15 minutes. Rotate the projected `token` atomically before expiry; +the adapter rereads and revalidates it for every authorized resolution. A Vault +Agent or external Secret operator may own this projection, but must not project +the actual Worker Secret values. + +Store each value at +`worker-secrets/data/values/production/` with this +exact KV payload: + +```json +{ + "schemaVersion": 1, + "secretRefDigest": "", + "encoding": "base64", + "value": "" +} +``` + +Create the two private Kubernetes Secret objects from +`credentials.example.yaml` through the deployment authority, then render: + +```sh +kubectl kustomize deploy/kubernetes/ql3-cluster/vault-kv-worker-secret +``` + +The overlay intentionally does not create a NetworkPolicy because the base +control-plane deployment has no universal egress policy and external Vault +topologies differ. If the namespace is default-deny, explicitly allow DNS, +PostgreSQL, the artifact store and TCP 8200 only to the reviewed Vault identity. diff --git a/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/credentials.example.yaml b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/credentials.example.yaml new file mode 100644 index 00000000..6d5d8092 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/credentials.example.yaml @@ -0,0 +1,20 @@ +# Create these objects through the deployment's Secret authority; do not commit +# real CA or token material. The Vault token must be an orphan, non-renewable +# service token with only ql3-worker-secret-read and at most a 15-minute TTL. +apiVersion: v1 +kind: Secret +metadata: + name: ql3-cluster-worker-vault-trust + namespace: qinglong3-system +type: Opaque +stringData: + ca.pem: REPLACE_WITH_PRIVATE_VAULT_CA_PEM +--- +apiVersion: v1 +kind: Secret +metadata: + name: ql3-cluster-worker-vault-auth + namespace: qinglong3-system +type: Opaque +stringData: + token: REPLACE_WITH_SHORT_LIVED_ORPHAN_VAULT_TOKEN diff --git a/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/deployment-patch.yaml b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/deployment-patch.yaml new file mode 100644 index 00000000..8671e1e7 --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/deployment-patch.yaml @@ -0,0 +1,58 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ql3-cluster-control +spec: + template: + spec: + containers: + - name: cluster-control + env: + - name: QL3_WORKER_SECRET_PROVIDER + value: vault-kv-v2 + - name: QL3_WORKER_SECRET_ROOT_DIRECTORY + $patch: delete + - name: QL3_WORKER_SECRET_VAULT_ENDPOINT + value: https://vault.vault.svc.cluster.local:8200 + - name: QL3_WORKER_SECRET_VAULT_CA_FILE + value: /var/run/secrets/qinglong3/worker-vault-trust/ca.pem + - name: QL3_WORKER_SECRET_VAULT_TOKEN_FILE + value: /var/run/secrets/qinglong3/worker-vault-auth/token + - name: QL3_WORKER_SECRET_VAULT_KV_MOUNT + value: worker-secrets + - name: QL3_WORKER_SECRET_VAULT_PATH_PREFIX + value: values/production + - name: QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY + value: ql3-worker-secret-read + - name: QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS + value: '900' + - name: QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS + value: '5000' + - name: QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY + value: '4' + volumeMounts: + - name: worker-secret-values + $patch: delete + - name: worker-vault-trust + mountPath: /var/run/secrets/qinglong3/worker-vault-trust + readOnly: true + - name: worker-vault-auth + mountPath: /var/run/secrets/qinglong3/worker-vault-auth + readOnly: true + volumes: + - name: worker-secret-values + $patch: delete + - name: worker-vault-trust + secret: + secretName: ql3-cluster-worker-vault-trust + defaultMode: 292 + items: + - key: ca.pem + path: ca.pem + - name: worker-vault-auth + secret: + secretName: ql3-cluster-worker-vault-auth + defaultMode: 288 + items: + - key: token + path: token diff --git a/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/kustomization.yaml b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/kustomization.yaml new file mode 100644 index 00000000..b658c45c --- /dev/null +++ b/deploy/kubernetes/ql3-cluster/vault-kv-worker-secret/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../base + +patches: + - path: deployment-patch.yaml diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 3daa251b..2f6ea7da 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-24): +- D-404/ADR-0499(已验收):Cluster Worker 现在有可选的直接外部 Secret custody adapter,而不再只能依赖 Kubernetes Secret value projection。`vault-kv-v2` 位于既有 `@qinglong/cluster-control` Remote Execution 子域,只有显式选择 provider 的 Cluster 进程才动态加载;基础部署继续使用 `mounted-files`,Edge/Standalone 不新增 package、依赖、daemon、timer、watcher、连接池或常驻内存。adapter 只在 durable Run/Attempt/Lease/Worker Session/execution digest/SecretRef authority 通过后,用 `SHA-256(canonical SecretRef)` 路径读取 KV v2;只接受显式私有 CA 的 TLS 1.3、每次重新打开的短期 orphan/non-renewable service token 和唯一精确 policy,不跟随 redirect、不使用系统 CA 回退、不缓存值或 token。Kubernetes overlay 删除 value Secret projection,只挂载 CA 与 token;普通 Secret、opaque environment bundle 和总响应仍受原 16/96/256 KiB 边界约束,空 Secret 保持合法,异常 envelope、metadata、digest、token、TLS 或 Vault availability 均失败关闭且不回退。真实 arm64 Vault 1.21.4 gate 已完成 3-share/2-threshold init、两个普通 Secret 与一个 bundle、value/token 原子轮换、旧 accessor revoke、缺失 material、不可信 CA、seal/unseal 与同持久存储容器替换;`0600` content-free 报告 SHA-256 为 `df225509cb763009b610cb0aea2207e0b07b5e05a44cf8cf0dff1633c1624d52`,audit 为 `compatible=true/findings=[]`。Cluster Control 为 `279 total / 277 pass / 2 conditional skip / 0 fail`,backend 为 `1574 total / 1572 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package、Cluster dependency、122-module Edge import、部署和 14 档 Local artifact 审计全部 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。共享 CI 新增原生 x64/arm64 live matrix。该 fixture 关闭 QingLong 直接 custody adapter/data-boundary 门,不冒充生产 Vault HA、KMS/HSM seal、审计设备或灾备证明;ADR-0491 现在只剩固定低性能物理 Edge 的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复门。 + - D-397/ADR-0491(进行中):Secret/Config reconciliation 已先冻结行为保持边界,不能把“密文已保存”冒充“任务已迁移”。2.x `Envs` 的 active 行必须按 `isPinned DESC、position DESC、createdAt ASC、id ASC` 重放旧顺序,同名值用 `&` 形成唯一 effective Secret;该 Secret 后续必须在同一事务中绑定到经 Automation adoption ledger 证明的全部 Legacy Task 新修订,并同步追加指向新 Task revision 的 Trigger/dispatch @@ -87,8 +89,7 @@ 因而不重跑且不重新占有 PostgreSQL HA 证明;相邻已通过的 remote CI/HA 只作为基线。 D-385~D-388 的 `config.sh`/Keyv/SSH data-directory lineage 与 SQLite `Envs` 保持分离;当前无稳定生产 schema 的历史 `Configs` 表继续 sealed+manual, - 不猜字段。ADR-0494 已关闭基础 Cluster mounted-files provider live 子门;后续切片仍必须完成固定低性能设备的真实 Edge 空间/写放大/断电证据, - 以及 Cluster Legacy Env migration 的专用 PostgreSQL SERIALIZABLE ledger、Task/Trigger revision mutation 与直接外部 custody adapter。HA promotion 后 receipt replay 已由 D-403/ADR-0498 关闭。D-397 apply + 不猜字段。ADR-0494 已关闭基础 Cluster mounted-files provider live 子门,ADR-0495~ADR-0498 已关闭 Cluster plan/application/HA replay,ADR-0499 已关闭直接 Vault KV 外部 custody adapter;后续仍必须完成固定低性能设备的真实 Edge 空间、RSS/I/O、写放大、ENOSPC 与断电证据。D-397 apply 只声明 sealed source retained 且 `physicalErasureGuaranteed=false`;明文销毁必须在 restart/ readiness、观察窗和 rollback retention 之后另行强认证。Cluster 必须使用 PostgreSQL SERIALIZABLE ledger、外部 KMS/Secret provider 与 HA evidence, 不复用 Local SQLite/POSIX authority,也不得把明文写入 PostgreSQL、ConfigMap、Pod env 或 Job command。 @@ -174,8 +175,8 @@ `a7c8a05e08c748d677475a09ce2998b741ed9326e27678613e93d431bc3769aa`; 真实用例覆盖 Trigger 固定 Task r1、Task current r2、原子生成 Task r3/Trigger r2 并重定向 pin, 同时证明 bundle ref-only execution、schedule reset、无流消费 replay 与数据库角色隔离。D-402 - 关闭 mutation/receipt 边界;相邻 D-403 已继续关闭 promotion 后 receipt replay,direct external - custody 与固定低性能 Edge 物理证据仍是 ADR-0491 转 Accepted 前的门禁。 + 关闭 mutation/receipt 边界;相邻 D-403 已继续关闭 promotion 后 receipt replay,D-404/ADR-0499 已关闭 direct external + custody adapter;固定低性能 Edge 物理证据是 ADR-0491 转 Accepted 前唯一剩余门禁。 - D-403/ADR-0498(已验收):ADR-0497 application receipt 已进入真实 PostgreSQL 18 physical HA 领域门,而不是继续引用通用表存活作为间接证据。主库与 standby 达到 @@ -199,8 +200,8 @@ `1569 total / 1567 pass / 2 conditional skip / 0 fail`,四项 package/依赖/import 边界均 compatible。 基础 Edge/Standalone 仍只包含 Local SQLite、runtime-core 与 SemVer,均为 325 files/58 modules, 大小 `2,669,390 / 2,669,468 bytes`,距 4 MiB 上限保留 `1,524,914 / 1,524,836 bytes`;它只证明 - 常驻闭包未扩大,不冒充真实设备证据。D-403 关闭 promotion replay;ADR-0491 现在只剩 direct - external custody 与固定低性能 Edge 空间/写放大/断电恢复两项硬门。 + 常驻闭包未扩大,不冒充真实设备证据。D-403 关闭 promotion replay,D-404/ADR-0499 关闭 direct + external custody adapter;ADR-0491 现在只剩固定低性能 Edge 空间、RSS/I/O、写放大、ENOSPC 与断电恢复硬门。 - D-396/ADR-0490(已验收):Run History 不再只有永久 `manual_external`,但也没有被错误实现为 Legacy 日志到 3.0 Run ledger 的回灌。 新的 Local adapter 以 ADR-0482 sealed capture bundle 作为 append-only 保全资产:Legacy history 必须逐事实选择 `retain_both`,Target history diff --git a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md index 94653149..b970f82c 100644 --- a/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md +++ b/docs/adr/ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md @@ -1,9 +1,9 @@ # ADR-0491:有界 Secret/Config Reconciliation 与任务环境绑定 -- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication、独立 signed decision、逐项 Automation adoption provenance、Local SQLite 原子 application publisher、Owner prepared/apply/rollback 编排、ADR-0492 completion v3;ADR-0494 完成 Cluster mounted-files provider live 子门,ADR-0495 完成 content-free Cluster plan ledger,ADR-0496 完成 opaque environment bundle 数据面,ADR-0497 完成 Cluster Task/Trigger 原子 mutation 与 receipt,ADR-0498 完成 promotion 后 exact replay;真实 Edge 空间证据与直接外部 custody gate 尚未完成) +- 状态:Proposed(D-397 已实现 Legacy Env inspection、私有有界 row plan、durable plan publication、独立 signed decision、逐项 Automation adoption provenance、Local SQLite 原子 application publisher、Owner prepared/apply/rollback 编排、ADR-0492 completion v3;ADR-0494 完成 Cluster mounted-files provider live 子门,ADR-0495 完成 content-free Cluster plan ledger,ADR-0496 完成 opaque environment bundle 数据面,ADR-0497 完成 Cluster Task/Trigger 原子 mutation 与 receipt,ADR-0498 完成 promotion 后 exact replay,ADR-0499 完成直接 Vault KV 外部 custody adapter;仅固定物理 Edge 证据尚未完成) - 日期:2026-08-23 - 决策:D-397 -- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490、ADR-0495、ADR-0496、ADR-0497、ADR-0498 +- 关联:ADR-0073、ADR-0074、ADR-0092、ADR-0094、ADR-0480、ADR-0482、ADR-0483、ADR-0484、ADR-0485、ADR-0486、ADR-0487、ADR-0488、ADR-0490、ADR-0495、ADR-0496、ADR-0497、ADR-0498、ADR-0499 ## 背景 @@ -147,4 +147,4 @@ D-397 当前八切片已经实现:absent、unsupported、Edge over-budget、2. ADR-0494 已完成 Cluster `mounted-files` provider live 子门:真实三节点 K3s 中两个 management replica、direct exact-key executor 和两个跨节点 provider observer 完成 PostgreSQL durable approval/binding、Kubernetes atomic projection rotation、无 Secret API 权限/ServiceAccount token、只读 `0440`、内容脱敏及删除后 fail-closed;v2 私有报告 24/24 gates 为 true,并保持 v1 verifier 兼容。该门不增加 Edge 闭包,也不等于直接 Vault/KMS/HSM custody。 -转为 Accepted 前仍必须完成:固定低性能 Edge 设备的真实空间/写放大/断电恢复证据,以及直接外部 custody adapter。ADR-0495 已完成专用 PostgreSQL plan ledger,ADR-0496 已完成只保存 pinned bundle ref、通过 fenced remote delivery 取回 typed carrier 并在 Worker 内存展开的数据面;ADR-0497 又在一个 Project-serialized SERIALIZABLE transaction 中完成逐项 Task/Trigger current-head revalidation、revision/execution mutation、schedule reset 和 content-free append-only receipt,并支持合法历史 Task pin。ADR-0498 已证明该首次提交在 `remote_apply` 下到达 standby,并在 timeline `1→2`、`pg_rewind` 重建同步副本后由 Automation Manager 精确重放为 `existing`,不打开 mutation stream 或增加 durable row。它仍不写入 Secret material,也不等于 direct Vault/KMS/HSM custody。ADR-0492 已完成本机 completion schema 演进和 completed-head 后 rollback material 回收,ADR-0493 又让没有 Legacy 身份输入的 fresh v52 目标身份经 signed `retain_target` 正确形成 no-effect,并精确消除六张已知目标表的 `unknown` 误判。Legacy `Auths/Users` 或真正未知表仍保持 manual;Local Owner 编排、mounted-files gate、plan/application ledger 或 HA replay 证据都不得冒充完整外部密钥托管。 +转为 Accepted 前仍必须完成固定低性能 Edge 设备的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复证据。ADR-0495 已完成专用 PostgreSQL plan ledger,ADR-0496 已完成只保存 pinned bundle ref、通过 fenced remote delivery 取回 typed carrier 并在 Worker 内存展开的数据面;ADR-0497 又在一个 Project-serialized SERIALIZABLE transaction 中完成逐项 Task/Trigger current-head revalidation、revision/execution mutation、schedule reset 和 content-free append-only receipt,并支持合法历史 Task pin。ADR-0498 已证明该首次提交在 `remote_apply` 下到达 standby,并在 timeline `1→2`、`pg_rewind` 重建同步副本后由 Automation Manager 精确重放为 `existing`,不打开 mutation stream 或增加 durable row。ADR-0499 进一步增加可选 `vault-kv-v2` provider:它只在 durable delivery authority 通过后,以 digest-derived path、显式 CA、TLS 1.3 和每次重新验证的短期最小权限 token 直接读取外部 Vault;Kubernetes overlay 不再投影真实值,本地 arm64 live gate 已覆盖 value/token rotation、revoke、seal/unseal、容器替换持久恢复与 content-free evidence。该单机 Vault fixture 只关闭 QingLong adapter/data-boundary 门,不冒充生产 Vault HA、KMS/HSM seal、审计设备或灾备证明。ADR-0492 已完成本机 completion schema 演进和 completed-head 后 rollback material 回收,ADR-0493 又让没有 Legacy 身份输入的 fresh v52 目标身份经 signed `retain_target` 正确形成 no-effect,并精确消除六张已知目标表的 `unknown` 误判。Legacy `Auths/Users` 或真正未知表仍保持 manual。 diff --git a/docs/adr/ADR-0499-direct-vault-kv-worker-secret-custody.md b/docs/adr/ADR-0499-direct-vault-kv-worker-secret-custody.md new file mode 100644 index 00000000..e1351ca6 --- /dev/null +++ b/docs/adr/ADR-0499-direct-vault-kv-worker-secret-custody.md @@ -0,0 +1,104 @@ +# ADR-0499:直接 Vault KV Worker Secret 外部托管 + +- 状态:Accepted +- 日期:2026-08-24 +- 决策:D-404 +- 关联:ADR-0058、ADR-0114、ADR-0233、ADR-0491、ADR-0494、ADR-0496、ADR-0497、ADR-0498 + +## 背景 + +ADR-0494 已证明 `mounted-files` provider 可以在 Kubernetes atomic projection 轮换后无重启读取新值,并且 Worker ingress 不需要 Secret API 权限。但该模式仍由 Kubernetes Secret 保存真实值,不能满足要求控制面只持有短期访问能力、密钥材料始终由外部托管系统保管的部署。 + +QingLong 3.0 需要一个可选的直接外部 custody adapter,同时保持小型 Edge/Standalone 默认闭包不变,也不能把 Vault token、Secret 值、Legacy Env 名称或 provider 路径写入 PostgreSQL、Pod environment、公开 evidence 或日志。 + +## 决策 + +### 1. Provider 边界 + +在既有 `@qinglong/cluster-control` Remote Execution 子域中增加 `vault-kv-v2` provider,不新建 workspace package。基础 Kubernetes 部署继续默认使用 `mounted-files`;只有显式选择 `QL3_WORKER_SECRET_PROVIDER=vault-kv-v2` 的 Cluster overlay 才加载该实现。Edge/Standalone 不导入 Cluster Control,因此其制品、常驻内存和依赖树不增加 Vault 客户端。 + +provider 只在 durable Worker delivery authority 已经通过 Run、Attempt、Lease、Session、execution digest 和精确 SecretRef 集合校验后执行。每个路径由 `SHA-256(canonical SecretRef)` 推导,不把 SecretRef 或环境变量名放入 URL。普通 Secret 与 opaque environment bundle 继续分别服从 16 KiB、96 KiB 和总交付上限。 + +### 2. 传输与身份 + +实现只接受无凭据、无 path/query/fragment 的 `https://` authority,使用显式投影 CA,并要求协商 TLS 1.3;不跟随 redirect,不使用系统 CA 回退。控制 Pod 只投影 CA 和短期 token,不投影真实 Secret 值,也不挂载 ServiceAccount token。 + +token 必须同时满足: + +- orphan、non-renewable、service token; +- 只含一个精确配置的读取 policy; +- 剩余 TTL 大于 0 且不超过配置上限,部署默认上限为 900 秒; +- 每次 `verify` 或 `resolve` 都重新打开投影文件并调用 `auth/token/lookup-self`。 + +实现没有 cache、watcher、timer、renewal loop 或后台连接。token 轮换由外部部署 authority 原子替换投影文件;旧 token 的撤销与新 token 的签发不由 QingLong 接管。Vault 不可达、封存、CA 不可信、token 过宽/过期或材料缺失时交付失败关闭,不回退到 `mounted-files`。 + +### 3. KV v2 数据合同 + +每个值固定存放在: + +```text +/data// +``` + +KV payload 必须是 exact shape: + +```json +{ + "schemaVersion": 1, + "secretRefDigest": "", + "encoding": "base64", + "value": "" +} +``` + +KV metadata 必须表明 version 为正整数、未删除且未销毁。空 Secret 是合法的 canonical base64;超限、非 canonical 编码、digest 不一致、未知字段或异常 metadata 均失败关闭。成功 material 在调用方 `dispose()` 时清零;报告和错误只暴露有界分类,不包含 endpoint path、token、SecretRef 或 value。 + +### 4. Kubernetes 部署 + +`deploy/kubernetes/ql3-cluster/vault-kv-worker-secret` overlay 删除基础部署的 value Secret env/mount/volume,只增加私有 CA 与短期 token 投影。示例文件只含占位符,仓库不保存可用 credential。 + +Vault policy 固定为: + +```hcl +path "worker-secrets/data/values/production/*" { + capabilities = ["read"] +} + +path "auth/token/lookup-self" { + capabilities = ["read"] +} +``` + +外部 Vault 的 HA、unseal、KMS/HSM seal、审计设备、备份和灾备属于部署方独立门禁。本 ADR 证明 QingLong 的直接 custody 数据边界,不把单机 Vault file storage fixture 冒充生产 Vault HA。 + +## 被拒绝的替代方案 + +### 在 PostgreSQL 或 ConfigMap 保存密文 + +拒绝。即使值已加密,也会扩大数据库/配置平面的 custody、备份和读取权限,并把轮换与数据库生命周期绑定。 + +### 给控制 Pod Kubernetes Secret API 权限 + +拒绝。它扩大为 namespace 级读取能力,无法把访问限制到 durable delivery authority 中的精确引用。 + +### 引入 Vault SDK、Agent sidecar 或常驻续租器作为必选依赖 + +拒绝。直接 HTTPS adapter 已能满足有界请求;额外 SDK 或 sidecar 会扩大依赖、镜像、常驻内存和凭据生命周期。部署方可以负责 token projection,但不得通过该机制把真实值重新投影进 Pod。 + +## 验证 + +真实 arm64 live contract 使用 digest-pinned Vault 1.21.4、私有 TLS CA、3-share/2-threshold 初始化和短期 orphan token,完成两个普通 Secret 与一个 opaque environment bundle 的读取,并证明: + +- value 轮换和 token 原子轮换均无需重启; +- 旧 token accessor 撤销后不再可用; +- 缺失 material、不可信 CA 和 sealed Vault 均失败关闭; +- unseal 后恢复,Vault 容器在同一持久存储上替换后值仍可读取; +- 私有 `0600` 报告不含 token、SecretRef、value、证书私钥或 Vault path。 + +最终本地报告 SHA-256 为 `df225509cb763009b610cb0aea2207e0b07b5e05a44cf8cf0dff1633c1624d52`,离线 audit 为 `compatible=true`、`findings=[]`。共享 CI 在原生 x64/arm64 runner 上分别构建 provider、审计 overlay、拉取同一 digest-pinned Vault image 并重跑完整 live contract;远程运行结果作为提交后的独立证据。 + +本地完整验证中,Cluster Control 为 `279 total / 277 pass / 2 conditional skip / 0 fail`,backend 为 `1574 total / 1572 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Cluster dependency、122-module Edge import、service-manager bridge、Cluster deployment、Local image、Vault overlay 和 14 档 Local artifact 均 compatible。基础 Edge/Standalone 制品仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 loaded modules,不包含 Cluster Control 或 Vault adapter。 + +## 影响与剩余门禁 + +D-404 关闭 ADR-0491 的直接外部 custody adapter 门。ADR-0491 转 Accepted 前只剩固定低性能物理 Edge 的真实空间、RSS/I/O、写放大、ENOSPC 与断电恢复证据;开发机、Docker、CI runner 和 Cluster Vault live gate 均不能替代该设备证据。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 6350e2fd..edc0bc18 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -494,13 +494,15 @@ | [ADR-0488](./ADR-0488-cross-domain-reconciliation-completion-fence.md) | 跨领域 Reconciliation 完成围栏与目标重启授权 | Accepted | | [ADR-0489](./ADR-0489-service-manager-completion-restart-lineage.md) | Service Manager 完成围栏重启谱系 | Accepted | | [ADR-0490](./ADR-0490-run-history-terminal-preservation.md) | Run History 终态保全与跨领域完成证明 | Accepted | -| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(D-397 local apply/rollback/completion complete;Edge/Cluster gate pending) | +| [ADR-0491](./ADR-0491-bounded-secret-config-reconciliation-and-task-binding.md) | 有界 Secret/Config Reconciliation 与任务环境绑定 | Proposed(直接外部 custody 已完成;仅固定物理 Edge gate pending) | | [ADR-0492](./ADR-0492-compatible-secret-config-completion-v3.md) | 兼容 Secret/Config 的 Reconciliation Completion v3 | Accepted | | [ADR-0493](./ADR-0493-target-native-reconciliation-domain-preservation.md) | 目标原生 Reconciliation 域分类与身份保留 | Accepted | | [ADR-0494](./ADR-0494-postgresql-secret-binding-and-mounted-provider-live-rotation.md) | PostgreSQL Secret Binding 与 Mounted Provider 在线轮换门 | Accepted | | [ADR-0495](./ADR-0495-content-free-cluster-legacy-env-migration-plan-ledger.md) | 无敏感内容的 Cluster Legacy Env 迁移计划账本 | Accepted | | [ADR-0496](./ADR-0496-opaque-cluster-environment-bundle-delivery.md) | Cluster 不透明环境 Bundle 的有界交付与 Worker 内存展开 | Accepted | | [ADR-0497](./ADR-0497-atomic-cluster-legacy-env-migration-application.md) | Cluster Legacy Env 的原子 Task/Trigger 迁移与只追加回执 | Accepted | +| [ADR-0498](./ADR-0498-cluster-legacy-env-application-ha-replay.md) | Cluster Legacy Env Application 的 HA Promotion 后精确重放 | Accepted | +| [ADR-0499](./ADR-0499-direct-vault-kv-worker-secret-custody.md) | 直接 Vault KV Worker Secret 外部托管 | Accepted | ## 规则 diff --git a/package.json b/package.json index c8a9fb26..0a2430fb 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,9 @@ "audit:prompt-output-external-recovery-deployment:ql3": "node scripts/ql3-prompt-output-external-recovery-deployment-audit.cjs", "custody:vault-transit:ql3": "node scripts/ql3-vault-transit-prompt-output-custody.cjs", "test:vault-transit-custody-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-vault-transit-prompt-output-custody-live-contract.cjs", + "test:vault-kv-worker-secret-live:ql3": "pnpm --filter @qinglong/runtime-core build && pnpm --filter @qinglong/cluster-control build && node scripts/ql3-vault-kv-worker-secret-live-contract.cjs", + "audit:vault-kv-worker-secret-live:ql3": "node scripts/ql3-vault-kv-worker-secret-live-audit.cjs", + "audit:vault-kv-worker-secret-deployment:ql3": "node scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs", "test:postgres-backup-prompt-output-recovery-live:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-postgres-prompt-output-recovery-live-contract.cjs", "audit:cluster-deployment:ql3": "node scripts/ql3-cluster-deployment-audit.cjs", "audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs", diff --git a/packages/ql3-cluster-control/package.json b/packages/ql3-cluster-control/package.json index 527476bd..8aa8dd6c 100644 --- a/packages/ql3-cluster-control/package.json +++ b/packages/ql3-cluster-control/package.json @@ -150,6 +150,11 @@ "require": "./dist/remote-execution/mountedSecretProvider.js", "default": "./dist/remote-execution/mountedSecretProvider.js" }, + "./vault-kv-secret-provider": { + "types": "./dist/remote-execution/vaultKvSecretProvider.d.ts", + "require": "./dist/remote-execution/vaultKvSecretProvider.js", + "default": "./dist/remote-execution/vaultKvSecretProvider.js" + }, "./trusted-tool-result-keyring": { "types": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.d.ts", "require": "./dist/trusted-tool/key-management/toolResultProjectedKeyring.js", diff --git a/packages/ql3-cluster-control/src/production-process/processApplication.ts b/packages/ql3-cluster-control/src/production-process/processApplication.ts index 6cc2b58f..8b9db05b 100644 --- a/packages/ql3-cluster-control/src/production-process/processApplication.ts +++ b/packages/ql3-cluster-control/src/production-process/processApplication.ts @@ -128,8 +128,11 @@ async function createWorkerArtifactBinding( async function createWorkerSecretProvider( config: NonNullable, ): Promise> { - if (config.provider !== 'mounted-files') { - throw new TypeError('Cluster Worker Secret provider is unsupported'); + if (config.provider === 'vault-kv-v2') { + const provider = await import( + '../remote-execution/vaultKvSecretProvider.js' + ); + return provider.createClusterVaultKvSecretProvider(config); } const provider = await import('../remote-execution/mountedSecretProvider.js'); return provider.createClusterMountedSecretProvider({ diff --git a/packages/ql3-cluster-control/src/remote-execution/vaultKvSecretProvider.ts b/packages/ql3-cluster-control/src/remote-execution/vaultKvSecretProvider.ts new file mode 100644 index 00000000..9fcca21a --- /dev/null +++ b/packages/ql3-cluster-control/src/remote-execution/vaultKvSecretProvider.ts @@ -0,0 +1,637 @@ +// Remote Execution owns direct external Secret custody resolution. +import { Buffer } from 'node:buffer'; +import https from 'node:https'; +import { isIP } from 'node:net'; +import { basename, dirname, isAbsolute, normalize } from 'node:path'; +import type { TLSSocket } from 'node:tls'; + +import { + MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES, + MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES, + MAX_REMOTE_SECRET_VALUE_BYTES, + normalizeRemoteWorkerSecretDeliveryAuthority, + type RemoteWorkerSecretDeliveryAuthority, + type RemoteWorkerSecretResolution, + type RemoteWorkerSecretValueProvider, +} from '@qinglong/runtime-core/remote-secret-delivery'; +import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; + +import { PrivateProjectedFileReader } from '../security/privateProjectedFile'; + +const MAX_CA_BYTES = 1024 * 1024; +const MAX_TOKEN_BYTES = 4096; +const MAX_RESPONSE_BYTES = + MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES + 16 * 1024; +const TOKEN_PATTERN = /^[\x21-\x7e]{1,4096}$/; +const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; +const PREFIX_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_/-]{0,511}$/; +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,252}$/; +const BASE64_PATTERN = + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/; + +export interface ClusterVaultKvSecretProviderOptions { + readonly endpoint: string; + readonly caFile: string; + readonly tokenFile: string; + readonly kvMount: string; + readonly pathPrefix: string; + readonly expectedPolicy: string; + readonly maximumTokenTtlSeconds: number; + readonly requestTimeoutMs: number; + readonly maximumConcurrency: number; + readonly namespace?: string; +} + +interface NormalizedClusterVaultKvSecretProviderOptions + extends ClusterVaultKvSecretProviderOptions { + readonly endpoint: string; + readonly caRootDirectory: string; + readonly caFileName: string; + readonly tokenRootDirectory: string; + readonly tokenFileName: string; +} + +export interface ClusterVaultKvRequest { + readonly endpoint: string; + readonly path: string; + readonly requestTimeoutMs: number; + readonly namespace?: string; +} + +export type ClusterVaultKvRequester = ( + request: Readonly, + material: Readonly<{ readonly ca: Buffer; readonly token: string }>, +) => Promise; + +export interface ClusterVaultKvSecretProviderDependencies { + readonly request?: ClusterVaultKvRequester; +} + +export class ClusterVaultKvSecretProviderError extends Error { + readonly code = 'QL3_CLUSTER_VAULT_KV_SECRET_UNAVAILABLE'; + + constructor( + readonly reason: + | 'invalid_configuration' + | 'trust_unavailable' + | 'authentication_unavailable' + | 'material_unavailable', + options?: ErrorOptions, + ) { + super(`Cluster Vault KV Secret provider failed: ${reason}`, options); + this.name = 'ClusterVaultKvSecretProviderError'; + } +} + +function invalidConfiguration(cause?: unknown): never { + throw new ClusterVaultKvSecretProviderError('invalid_configuration', { + cause: cause instanceof Error ? cause : undefined, + }); +} + +function boundedInteger( + value: unknown, + minimum: number, + maximum: number, +): number { + if ( + typeof value !== 'number' || + !Number.isSafeInteger(value) || + value < minimum || + value > maximum + ) { + return invalidConfiguration(); + } + return value; +} + +function safeName(value: unknown): string { + if (typeof value !== 'string' || !NAME_PATTERN.test(value)) { + return invalidConfiguration(); + } + return value; +} + +function safePrefix(value: unknown): string { + if ( + typeof value !== 'string' || + !PREFIX_PATTERN.test(value) || + value.includes('//') || + value.split('/').some((part) => part === '.' || part === '..') + ) { + return invalidConfiguration(); + } + return value; +} + +function absoluteProjectedFile(value: unknown): Readonly<{ + rootDirectory: string; + fileName: string; +}> { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + normalize(value) !== value || + value.includes('\0') || + Buffer.byteLength(value, 'utf8') > 4096 + ) { + return invalidConfiguration(); + } + const fileName = basename(value); + const rootDirectory = dirname(value); + if ( + !FILE_NAME_PATTERN.test(fileName) || + rootDirectory === value || + rootDirectory === dirname(rootDirectory) + ) { + return invalidConfiguration(); + } + return Object.freeze({ rootDirectory, fileName }); +} + +export function normalizeClusterVaultKvSecretProviderOptions( + value: ClusterVaultKvSecretProviderOptions, +): Readonly { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalidConfiguration(); + } + let endpoint: URL; + try { + endpoint = new URL(value.endpoint); + } catch (cause) { + return invalidConfiguration(cause); + } + if ( + endpoint.protocol !== 'https:' || + endpoint.username || + endpoint.password || + endpoint.pathname !== '/' || + endpoint.search || + endpoint.hash + ) { + return invalidConfiguration(); + } + const ca = absoluteProjectedFile(value.caFile); + const token = absoluteProjectedFile(value.tokenFile); + const namespace = + value.namespace === undefined ? undefined : safePrefix(value.namespace); + return Object.freeze({ + endpoint: endpoint.toString(), + caFile: value.caFile, + caRootDirectory: ca.rootDirectory, + caFileName: ca.fileName, + tokenFile: value.tokenFile, + tokenRootDirectory: token.rootDirectory, + tokenFileName: token.fileName, + kvMount: safeName(value.kvMount), + pathPrefix: safePrefix(value.pathPrefix), + expectedPolicy: safeName(value.expectedPolicy), + maximumTokenTtlSeconds: boundedInteger( + value.maximumTokenTtlSeconds, + 30, + 3600, + ), + requestTimeoutMs: boundedInteger(value.requestTimeoutMs, 100, 30_000), + maximumConcurrency: boundedInteger(value.maximumConcurrency, 1, 8), + ...(namespace === undefined ? {} : { namespace }), + }); +} + +function exactKeys(value: unknown, expected: readonly string[]): boolean { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...expected].sort()) + ); +} + +function canonicalBase64(value: unknown): Buffer { + if ( + typeof value !== 'string' || + value.length > + Math.ceil(MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES / 3) * 4 + 4 || + !BASE64_PATTERN.test(value) + ) { + throw new ClusterVaultKvSecretProviderError('material_unavailable'); + } + const bytes = Buffer.from(value, 'base64'); + if (bytes.toString('base64') !== value) { + bytes.fill(0); + throw new ClusterVaultKvSecretProviderError('material_unavailable'); + } + return bytes; +} + +function parseVaultKvValue( + value: unknown, + expectedSecretRefDigest: string, + maximumBytes: number, +): Buffer { + const envelope = value as { + readonly data?: { + readonly data?: unknown; + readonly metadata?: { + readonly deletion_time?: unknown; + readonly destroyed?: unknown; + readonly version?: unknown; + }; + }; + }; + const material = envelope?.data?.data as { + readonly schemaVersion?: unknown; + readonly secretRefDigest?: unknown; + readonly encoding?: unknown; + readonly value?: unknown; + }; + const metadata = envelope?.data?.metadata; + if ( + !exactKeys(material, [ + 'encoding', + 'schemaVersion', + 'secretRefDigest', + 'value', + ]) || + material.schemaVersion !== 1 || + material.secretRefDigest !== expectedSecretRefDigest || + material.encoding !== 'base64' || + metadata?.destroyed !== false || + metadata?.deletion_time !== '' || + !Number.isSafeInteger(metadata?.version) || + Number(metadata?.version) < 1 + ) { + throw new ClusterVaultKvSecretProviderError('material_unavailable'); + } + const bytes = canonicalBase64(material.value); + if (bytes.byteLength > maximumBytes) { + bytes.fill(0); + throw new ClusterVaultKvSecretProviderError('material_unavailable'); + } + return bytes; +} + +function parseToken(bytes: Buffer): string { + const raw = bytes.toString('utf8'); + const token = raw.endsWith('\n') ? raw.slice(0, -1) : raw; + if (!TOKEN_PATTERN.test(token) || (raw !== token && raw !== `${token}\n`)) { + throw new ClusterVaultKvSecretProviderError('authentication_unavailable'); + } + return token; +} + +function secretValue(bytes: Buffer): string { + try { + const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + if (value.includes('\0')) throw new Error('Secret contains NUL'); + return value; + } catch (cause) { + throw new ClusterVaultKvSecretProviderError('material_unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); + } +} + +function validateTokenLookup( + value: unknown, + expectedPolicy: string, + maximumTokenTtlSeconds: number, +): void { + const data = (value as { readonly data?: Record })?.data; + if ( + !data || + !Array.isArray(data.policies) || + JSON.stringify(data.policies) !== JSON.stringify([expectedPolicy]) || + data.orphan !== true || + data.renewable !== false || + data.type !== 'service' || + !Number.isSafeInteger(data.ttl) || + Number(data.ttl) < 1 || + Number(data.ttl) > maximumTokenTtlSeconds + ) { + throw new ClusterVaultKvSecretProviderError('authentication_unavailable'); + } +} + +async function requestVaultJson( + request: Readonly, + material: Readonly<{ readonly ca: Buffer; readonly token: string }>, +): Promise { + const target = new URL(request.path, request.endpoint); + return new Promise((resolve, reject) => { + const requestHandle = https.request( + target, + { + method: 'GET', + ca: material.ca, + rejectUnauthorized: true, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + ...(isIP(target.hostname) === 0 ? { servername: target.hostname } : {}), + headers: { + accept: 'application/json', + 'x-vault-token': material.token, + ...(request.namespace === undefined + ? {} + : { 'x-vault-namespace': request.namespace }), + }, + }, + (response) => { + const socket = response.socket as TLSSocket; + const peerAuthorized = socket.authorized; + const tlsProtocol = socket.getProtocol(); + const chunks: Buffer[] = []; + let length = 0; + const dispose = () => + chunks.splice(0).forEach((chunk) => chunk.fill(0)); + response.on('data', (chunk: Buffer) => { + length += chunk.byteLength; + if (length > MAX_RESPONSE_BYTES) { + response.destroy( + new ClusterVaultKvSecretProviderError('material_unavailable'), + ); + return; + } + chunks.push(Buffer.from(chunk)); + }); + response.once('error', (cause) => { + dispose(); + reject(cause); + }); + response.on('end', () => { + const bytes = Buffer.concat(chunks); + dispose(); + try { + if ( + response.statusCode !== 200 || + peerAuthorized !== true || + tlsProtocol !== 'TLSv1.3' + ) { + throw new ClusterVaultKvSecretProviderError( + 'material_unavailable', + ); + } + resolve(JSON.parse(bytes.toString('utf8'))); + } catch (cause) { + reject(cause); + } finally { + bytes.fill(0); + } + }); + }, + ); + requestHandle.setTimeout(request.requestTimeoutMs, () => { + requestHandle.destroy( + new ClusterVaultKvSecretProviderError('material_unavailable'), + ); + }); + requestHandle.once('error', reject); + requestHandle.end(); + }); +} + +/** + * A no-cache, no-watcher Vault KV v2 adapter. Every authorized delivery reads + * the projected short-lived token again, revalidates its least-privilege + * policy, and resolves only digest-derived paths over pinned TLS 1.3. + */ +export class ClusterVaultKvSecretProvider + implements RemoteWorkerSecretValueProvider +{ + readonly #options: Readonly; + readonly #caReader: PrivateProjectedFileReader; + readonly #tokenReader: PrivateProjectedFileReader; + readonly #request: ClusterVaultKvRequester; + + constructor( + options: ClusterVaultKvSecretProviderOptions, + dependencies: ClusterVaultKvSecretProviderDependencies = {}, + ) { + this.#options = normalizeClusterVaultKvSecretProviderOptions(options); + if ( + !dependencies || + typeof dependencies !== 'object' || + Array.isArray(dependencies) || + (dependencies.request !== undefined && + typeof dependencies.request !== 'function') + ) { + invalidConfiguration(); + } + this.#request = dependencies.request ?? requestVaultJson; + try { + this.#caReader = new PrivateProjectedFileReader({ + rootDirectory: this.#options.caRootDirectory, + minimumBytes: 32, + maximumBytes: MAX_CA_BYTES, + access: 'read_only_keyring', + }); + this.#tokenReader = new PrivateProjectedFileReader({ + rootDirectory: this.#options.tokenRootDirectory, + minimumBytes: 1, + maximumBytes: MAX_TOKEN_BYTES + 1, + access: 'private_material', + }); + } catch (cause) { + invalidConfiguration(cause); + } + } + + async #withCredentials( + action: (ca: Buffer, token: string) => Promise, + ): Promise { + let ca: Buffer | undefined; + let tokenBytes: Buffer | undefined; + let token: string | undefined; + try { + [ca, tokenBytes] = await Promise.all([ + this.#caReader.read(this.#options.caFileName), + this.#tokenReader.read(this.#options.tokenFileName), + ]); + token = parseToken(tokenBytes); + return await action(ca, token); + } catch (cause) { + if (cause instanceof ClusterVaultKvSecretProviderError) throw cause; + throw new ClusterVaultKvSecretProviderError('trust_unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); + } finally { + ca?.fill(0); + tokenBytes?.fill(0); + token = undefined; + } + } + + async #requestJson( + path: string, + ca: Buffer, + token: string, + ): Promise { + try { + return await this.#request( + Object.freeze({ + endpoint: this.#options.endpoint, + path, + requestTimeoutMs: this.#options.requestTimeoutMs, + ...(this.#options.namespace === undefined + ? {} + : { namespace: this.#options.namespace }), + }), + Object.freeze({ ca, token }), + ); + } catch (cause) { + if (cause instanceof ClusterVaultKvSecretProviderError) throw cause; + throw new ClusterVaultKvSecretProviderError('material_unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); + } + } + + async #authenticate(ca: Buffer, token: string): Promise { + try { + const response = await this.#requestJson( + '/v1/auth/token/lookup-self', + ca, + token, + ); + validateTokenLookup( + response, + this.#options.expectedPolicy, + this.#options.maximumTokenTtlSeconds, + ); + } catch (cause) { + if ( + cause instanceof ClusterVaultKvSecretProviderError && + cause.reason === 'authentication_unavailable' + ) { + throw cause; + } + throw new ClusterVaultKvSecretProviderError( + 'authentication_unavailable', + { cause: cause instanceof Error ? cause : undefined }, + ); + } + } + + async verify(): Promise { + await this.#withCredentials(async (ca, token) => { + await this.#authenticate(ca, token); + }); + } + + async resolve( + authority: Readonly, + ): Promise> { + let normalized: Readonly; + try { + normalized = normalizeRemoteWorkerSecretDeliveryAuthority(authority); + } catch (cause) { + throw new ClusterVaultKvSecretProviderError('material_unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); + } + return this.#withCredentials(async (ca, token) => { + await this.#authenticate(ca, token); + const inputs = [ + ...normalized.secretRefs.map((secretRef) => + Object.freeze({ + kind: 'secret' as const, + secretRef, + maximumBytes: MAX_REMOTE_SECRET_VALUE_BYTES, + }), + ), + ...normalized.environmentBundleRefs.map((secretRef) => + Object.freeze({ + kind: 'environment-bundle' as const, + secretRef, + maximumBytes: MAX_REMOTE_ENVIRONMENT_BUNDLE_VALUE_BYTES, + }), + ), + ]; + const buffers: Buffer[] = []; + try { + const resolved: Array< + Readonly<{ + kind: 'secret' | 'environment-bundle'; + secretRef: string; + value: string; + byteLength: number; + }> + > = []; + for ( + let offset = 0; + offset < inputs.length; + offset += this.#options.maximumConcurrency + ) { + const batch = inputs.slice( + offset, + offset + this.#options.maximumConcurrency, + ); + const values = await Promise.all( + batch.map(async (input) => { + const digest = secretProjectionFileName(input.secretRef); + const response = await this.#requestJson( + `/v1/${encodeURIComponent( + this.#options.kvMount, + )}/data/${this.#options.pathPrefix + .split('/') + .map(encodeURIComponent) + .join('/')}/${digest}`, + ca, + token, + ); + const bytes = parseVaultKvValue( + response, + digest, + input.maximumBytes, + ); + buffers.push(bytes); + return Object.freeze({ + kind: input.kind, + secretRef: input.secretRef, + value: secretValue(bytes), + byteLength: bytes.byteLength, + }); + }), + ); + resolved.push(...values); + } + const values = resolved + .filter(({ kind }) => kind === 'secret') + .map(({ secretRef, value }) => Object.freeze({ secretRef, value })); + const totalBytes = resolved + .filter(({ kind }) => kind === 'secret') + .reduce((sum, item) => sum + item.byteLength, 0); + if (totalBytes > MAX_REMOTE_SECRET_DELIVERY_TOTAL_VALUE_BYTES) { + throw new ClusterVaultKvSecretProviderError('material_unavailable'); + } + const environmentBundles = resolved + .filter(({ kind }) => kind === 'environment-bundle') + .map(({ secretRef, value }) => Object.freeze({ secretRef, value })); + let disposed = false; + return Object.freeze({ + values: Object.freeze(values), + environmentBundles: Object.freeze(environmentBundles), + dispose() { + if (disposed) return; + disposed = true; + buffers.splice(0).forEach((bytes) => bytes.fill(0)); + }, + }); + } catch (cause) { + buffers.splice(0).forEach((bytes) => bytes.fill(0)); + if (cause instanceof ClusterVaultKvSecretProviderError) throw cause; + throw new ClusterVaultKvSecretProviderError('material_unavailable', { + cause: cause instanceof Error ? cause : undefined, + }); + } + }); + } +} + +export async function createClusterVaultKvSecretProvider( + options: ClusterVaultKvSecretProviderOptions, +): Promise> { + const provider = new ClusterVaultKvSecretProvider(options); + await provider.verify(); + return provider; +} diff --git a/packages/ql3-cluster-control/src/worker-ingress/workerIngressConfig.ts b/packages/ql3-cluster-control/src/worker-ingress/workerIngressConfig.ts index 5205165c..2e4805d6 100644 --- a/packages/ql3-cluster-control/src/worker-ingress/workerIngressConfig.ts +++ b/packages/ql3-cluster-control/src/worker-ingress/workerIngressConfig.ts @@ -52,6 +52,24 @@ export interface ClusterWorkerMountedSecretConfig { readonly rootDirectory: string; } +export interface ClusterWorkerVaultKvSecretConfig { + readonly provider: 'vault-kv-v2'; + readonly endpoint: string; + readonly caFile: string; + readonly tokenFile: string; + readonly kvMount: string; + readonly pathPrefix: string; + readonly expectedPolicy: string; + readonly maximumTokenTtlSeconds: number; + readonly requestTimeoutMs: number; + readonly maximumConcurrency: number; + readonly namespace?: string; +} + +export type ClusterWorkerSecretConfig = + | ClusterWorkerMountedSecretConfig + | ClusterWorkerVaultKvSecretConfig; + export interface EnabledClusterWorkerIngressConfig { readonly enabled: true; readonly profile: 'cluster-control'; @@ -70,7 +88,7 @@ export interface EnabledClusterWorkerIngressConfig { readonly workerCredentialPepper: string; }>; readonly artifact: Readonly; - readonly secret?: Readonly; + readonly secret?: Readonly; } export type ClusterWorkerIngressConfig = @@ -327,13 +345,11 @@ function workerArtifactS3( ); if ( prefix !== undefined && - ( - !/^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/.test(prefix) || + (!/^[A-Za-z0-9][A-Za-z0-9/_=-]{0,254}$/.test(prefix) || prefix.startsWith('/') || prefix.endsWith('/') || prefix.includes('//') || - prefix.split('/').some((segment) => segment === '.' || segment === '..') - ) + prefix.split('/').some((segment) => segment === '.' || segment === '..')) ) { throw new ClusterWorkerIngressConfigError( 'QL3_WORKER_ARTIFACT_S3_PREFIX is invalid', @@ -388,11 +404,7 @@ function workerArtifactS3( endpoint = parsed.origin; } const encryptionMode = - boundedValue( - environment, - 'QL3_WORKER_ARTIFACT_S3_ENCRYPTION', - 3, - ) ?? 's3'; + boundedValue(environment, 'QL3_WORKER_ARTIFACT_S3_ENCRYPTION', 3) ?? 's3'; if (encryptionMode !== 's3' && encryptionMode !== 'kms') { throw new ClusterWorkerIngressConfigError( 'QL3_WORKER_ARTIFACT_S3_ENCRYPTION must be s3 or kms', @@ -415,9 +427,7 @@ function workerArtifactS3( bucket, region, ...(prefix === undefined ? {} : { prefix }), - ...(expectedBucketOwner === undefined - ? {} - : { expectedBucketOwner }), + ...(expectedBucketOwner === undefined ? {} : { expectedBucketOwner }), ...(endpoint === undefined ? {} : { endpoint }), forcePathStyle: booleanValue( environment, @@ -433,18 +443,96 @@ function workerArtifactS3( function workerSecret( environment: ClusterWorkerIngressEnvironment, -): Readonly | undefined { - const provider = boundedValue( - environment, - 'QL3_WORKER_SECRET_PROVIDER', - 32, - ); +): Readonly | undefined { + const provider = boundedValue(environment, 'QL3_WORKER_SECRET_PROVIDER', 32); if (provider === undefined || provider === 'disabled') return undefined; - if (provider !== 'mounted-files') { + if (provider !== 'mounted-files' && provider !== 'vault-kv-v2') { throw new ClusterWorkerIngressConfigError( - 'QL3_WORKER_SECRET_PROVIDER must be disabled or mounted-files', + 'QL3_WORKER_SECRET_PROVIDER must be disabled, mounted-files or vault-kv-v2', ); } + if (provider === 'vault-kv-v2') { + const endpoint = boundedValue( + environment, + 'QL3_WORKER_SECRET_VAULT_ENDPOINT', + 2048, + true, + )!; + let parsedEndpoint: URL; + try { + parsedEndpoint = new URL(endpoint); + } catch { + throw new ClusterWorkerIngressConfigError( + 'QL3_WORKER_SECRET_VAULT_ENDPOINT must be an HTTPS authority', + ); + } + if ( + parsedEndpoint.protocol !== 'https:' || + parsedEndpoint.username || + parsedEndpoint.password || + parsedEndpoint.pathname !== '/' || + parsedEndpoint.search || + parsedEndpoint.hash + ) { + throw new ClusterWorkerIngressConfigError( + 'QL3_WORKER_SECRET_VAULT_ENDPOINT must be an HTTPS authority', + ); + } + const namespace = boundedValue( + environment, + 'QL3_WORKER_SECRET_VAULT_NAMESPACE', + 512, + ); + return Object.freeze({ + provider, + endpoint: parsedEndpoint.toString(), + caFile: absoluteFile(environment, 'QL3_WORKER_SECRET_VAULT_CA_FILE'), + tokenFile: absoluteFile( + environment, + 'QL3_WORKER_SECRET_VAULT_TOKEN_FILE', + ), + kvMount: boundedValue( + environment, + 'QL3_WORKER_SECRET_VAULT_KV_MOUNT', + 128, + true, + )!, + pathPrefix: boundedValue( + environment, + 'QL3_WORKER_SECRET_VAULT_PATH_PREFIX', + 512, + true, + )!, + expectedPolicy: boundedValue( + environment, + 'QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY', + 128, + true, + )!, + maximumTokenTtlSeconds: integerValue( + environment, + 'QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS', + 900, + 30, + 3600, + ), + requestTimeoutMs: integerValue( + environment, + 'QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS', + 5000, + 100, + 30_000, + ), + maximumConcurrency: integerValue( + environment, + 'QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY', + 4, + 1, + 8, + ), + ...(namespace === undefined ? {} : { namespace }), + }); + } return Object.freeze({ provider, rootDirectory: absoluteFile( diff --git a/packages/ql3-cluster-control/test/vaultKvSecretProvider.test.cjs b/packages/ql3-cluster-control/test/vaultKvSecretProvider.test.cjs new file mode 100644 index 00000000..9ed61283 --- /dev/null +++ b/packages/ql3-cluster-control/test/vaultKvSecretProvider.test.cjs @@ -0,0 +1,297 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { chmod, mkdtemp, rename, rm, writeFile } = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + ClusterVaultKvSecretProvider, + ClusterVaultKvSecretProviderError, + normalizeClusterVaultKvSecretProviderOptions, +} = require('@qinglong/cluster-control/vault-kv-secret-provider'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); + +const SECRET_REF = createSecretRef({ + projectId: 'project-1', + name: 'api-token', +}); +const SECOND_SECRET_REF = createSecretRef({ + projectId: 'project-1', + name: 'certificate', + version: 3, +}); +const ENVIRONMENT_BUNDLE_REF = createSecretRef({ + projectId: 'project-1', + name: 'legacy-env-bundle', + version: 4, +}); + +function authority(secretRefs = [SECRET_REF], environmentBundleRefs = []) { + return { + workerId: 'worker-1', + workerSessionId: '018f0000-0000-7000-8000-000000000001', + workerGeneration: 1, + runId: 'run-1', + attemptId: 'attempt-1', + projectId: 'project-1', + taskId: 'task-1', + taskRevision: 'revision-1', + executionDigest: 'a'.repeat(64), + offerId: 'offer-1', + leaseGeneration: 1, + leaseVersion: 1, + secretRefs, + environmentBundleRefs, + }; +} + +function lookup(policy = 'ql3-worker-secret-read', overrides = {}) { + return { + data: { + policies: [policy], + orphan: true, + renewable: false, + type: 'service', + ttl: 600, + ...overrides, + }, + }; +} + +function envelope(secretRef, value, overrides = {}) { + return { + data: { + data: { + schemaVersion: 1, + secretRefDigest: secretProjectionFileName(secretRef), + encoding: 'base64', + value: Buffer.from(value).toString('base64'), + ...overrides, + }, + metadata: { + deletion_time: '', + destroyed: false, + version: 1, + }, + }, + }; +} + +async function projectedFile(file, value, mode) { + await writeFile(file, value); + await chmod(file, mode); +} + +async function fixture(t) { + const directory = await mkdtemp(path.join(os.tmpdir(), 'ql3-vault-kv-')); + t.after(() => rm(directory, { recursive: true, force: true })); + await chmod(directory, 0o700); + const caFile = path.join(directory, 'ca.pem'); + const tokenFile = path.join(directory, 'token'); + await projectedFile(caFile, 'x'.repeat(64), 0o440); + await projectedFile(tokenFile, 'token-generation-one\n', 0o440); + return { + directory, + caFile, + tokenFile, + options: { + endpoint: 'https://vault.internal:8200', + caFile, + tokenFile, + kvMount: 'worker-secrets', + pathPrefix: 'values/production', + expectedPolicy: 'ql3-worker-secret-read', + maximumTokenTtlSeconds: 900, + requestTimeoutMs: 5000, + maximumConcurrency: 2, + }, + }; +} + +test('normalizes only a pinned HTTPS Vault KV v2 authority', async (t) => { + const { options } = await fixture(t); + assert.deepEqual( + normalizeClusterVaultKvSecretProviderOptions({ + ...options, + namespace: 'organization/team-a', + }), + { + ...options, + endpoint: 'https://vault.internal:8200/', + caRootDirectory: path.dirname(options.caFile), + caFileName: path.basename(options.caFile), + tokenRootDirectory: path.dirname(options.tokenFile), + tokenFileName: path.basename(options.tokenFile), + namespace: 'organization/team-a', + }, + ); + for (const invalid of [ + { ...options, endpoint: 'http://vault.internal:8200' }, + { ...options, endpoint: 'https://user@vault.internal:8200' }, + { ...options, endpoint: 'https://vault.internal:8200/v1' }, + { ...options, caFile: 'relative.pem' }, + { ...options, kvMount: '../secret' }, + { ...options, pathPrefix: 'values//production' }, + { ...options, maximumTokenTtlSeconds: 3601 }, + { ...options, maximumConcurrency: 9 }, + ]) { + assert.throws( + () => normalizeClusterVaultKvSecretProviderOptions(invalid), + ClusterVaultKvSecretProviderError, + ); + } +}); + +test('revalidates the short-lived token and reads digest paths without a cache', async (t) => { + const { options, tokenFile, directory } = await fixture(t); + const requests = []; + let generation = 1; + let active = 0; + let maximumActive = 0; + const values = new Map([ + [secretProjectionFileName(SECRET_REF), () => `token-${generation}`], + [secretProjectionFileName(SECOND_SECRET_REF), () => 'certificate-value'], + [ + secretProjectionFileName(ENVIRONMENT_BUNDLE_REF), + () => + JSON.stringify({ + schema: 'qinglong/environment-bundle@v1', + entries: [{ name: 'LEGACY_VALUE', value: `bundle-${generation}` }], + }), + ], + ]); + const provider = new ClusterVaultKvSecretProvider(options, { + async request(request, material) { + requests.push({ ...request, token: material.token }); + if (request.path === '/v1/auth/token/lookup-self') return lookup(); + active += 1; + maximumActive = Math.max(maximumActive, active); + await new Promise((resolve) => setImmediate(resolve)); + active -= 1; + const digest = request.path.split('/').at(-1); + const selected = values.get(digest); + assert.ok(selected, 'only digest-derived reviewed paths are requested'); + const secretRef = + digest === secretProjectionFileName(SECRET_REF) + ? SECRET_REF + : digest === secretProjectionFileName(SECOND_SECRET_REF) + ? SECOND_SECRET_REF + : ENVIRONMENT_BUNDLE_REF; + return envelope(secretRef, selected()); + }, + }); + await provider.verify(); + const first = await provider.resolve( + authority([SECRET_REF, SECOND_SECRET_REF], [ENVIRONMENT_BUNDLE_REF]), + ); + assert.deepEqual(first.values, [ + { secretRef: SECRET_REF, value: 'token-1' }, + { secretRef: SECOND_SECRET_REF, value: 'certificate-value' }, + ]); + assert.equal(first.environmentBundles[0].secretRef, ENVIRONMENT_BUNDLE_REF); + assert.match(first.environmentBundles[0].value, /bundle-1/); + await first.dispose(); + await first.dispose(); + assert.equal(maximumActive, 2); + + generation = 2; + const replacement = path.join(directory, 'token.replacement'); + await projectedFile(replacement, 'token-generation-two\n', 0o440); + await rename(replacement, tokenFile); + const second = await provider.resolve(authority()); + assert.deepEqual(second.values, [ + { secretRef: SECRET_REF, value: 'token-2' }, + ]); + await second.dispose(); + assert.equal( + requests.filter(({ path: requestPath }) => + requestPath.endsWith(secretProjectionFileName(SECRET_REF)), + ).length, + 2, + ); + assert.equal(requests.at(-2).token, 'token-generation-two'); + assert.equal( + requests.some(({ path: requestPath }) => + requestPath.includes(encodeURIComponent(SECRET_REF)), + ), + false, + ); +}); + +test('preserves an authorized empty Secret value', async (t) => { + const { options } = await fixture(t); + const provider = new ClusterVaultKvSecretProvider(options, { + request(request) { + return Promise.resolve( + request.path === '/v1/auth/token/lookup-self' + ? lookup() + : envelope(SECRET_REF, ''), + ); + }, + }); + const resolution = await provider.resolve(authority()); + assert.deepEqual(resolution.values, [{ secretRef: SECRET_REF, value: '' }]); + await resolution.dispose(); +}); + +test('fails closed for broad tokens, unsafe projections and untrusted material', async (t) => { + const { options, tokenFile } = await fixture(t); + const broad = new ClusterVaultKvSecretProvider(options, { + request(request) { + return Promise.resolve( + request.path === '/v1/auth/token/lookup-self' + ? lookup('ql3-worker-secret-read', { + policies: ['default', 'ql3-worker-secret-read'], + }) + : envelope(SECRET_REF, 'not-reached'), + ); + }, + }); + await assert.rejects( + broad.verify(), + (error) => + error instanceof ClusterVaultKvSecretProviderError && + error.reason === 'authentication_unavailable', + ); + + const mismatched = new ClusterVaultKvSecretProvider(options, { + request(request) { + return Promise.resolve( + request.path === '/v1/auth/token/lookup-self' + ? lookup() + : envelope(SECRET_REF, 'private-value', { + secretRefDigest: 'f'.repeat(64), + }), + ); + }, + }); + await assert.rejects( + mismatched.resolve(authority()), + (error) => + error instanceof ClusterVaultKvSecretProviderError && + error.reason === 'material_unavailable' && + error.message.includes('private-value') === false, + ); + + await chmod(tokenFile, 0o666); + const unsafe = new ClusterVaultKvSecretProvider(options, { + request() { + throw new Error('unsafe token must fail before transport'); + }, + }); + await assert.rejects( + unsafe.verify(), + (error) => + error instanceof ClusterVaultKvSecretProviderError && + error.reason === 'trust_unavailable', + ); + await assert.rejects( + mismatched.resolve({ ...authority(), projectId: 'other-project' }), + ClusterVaultKvSecretProviderError, + ); +}); diff --git a/packages/ql3-cluster-control/test/vaultKvWorkerIngressConfig.test.cjs b/packages/ql3-cluster-control/test/vaultKvWorkerIngressConfig.test.cjs new file mode 100644 index 00000000..98cb9cb5 --- /dev/null +++ b/packages/ql3-cluster-control/test/vaultKvWorkerIngressConfig.test.cjs @@ -0,0 +1,86 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + ClusterWorkerIngressConfigError, + loadClusterWorkerIngressConfig, +} = require('@qinglong/cluster-control/worker-ingress-config'); + +const FIXTURES = path.join(__dirname, 'fixtures', 'mtls'); +const BASE_ENV = Object.freeze({ + QL3_WORKER_INGRESS_ENABLED: 'true', + QL_DEPLOYMENT_PROFILE: 'cluster-control', + QL3_POSTGRES_WORKER_INGRESS_URL: + 'postgresql://ql3_worker_ingress:secret@database.internal:5432/qinglong', + QL3_WORKER_INGRESS_POSTGRES_TLS_SERVERNAME: 'database.internal', + QL3_WORKER_CREDENTIAL_PEPPER: 'A'.repeat(43), + QL3_WORKER_ARTIFACT_S3_BUCKET: 'qinglong-worker-artifacts', + QL3_WORKER_ARTIFACT_S3_REGION: 'us-east-1', + QL3_WORKER_INGRESS_TLS_PRIVATE_KEY_FILE: path.join( + FIXTURES, + 'server-key.pem', + ), + QL3_WORKER_INGRESS_TLS_CERTIFICATE_FILE: path.join( + FIXTURES, + 'server-cert.pem', + ), + QL3_WORKER_INGRESS_TLS_CLIENT_CA_FILE: path.join(FIXTURES, 'ca-cert.pem'), +}); + +test('loads the explicit Vault KV v2 Worker Secret provider', () => { + const config = loadClusterWorkerIngressConfig({ + ...BASE_ENV, + QL3_WORKER_SECRET_PROVIDER: 'vault-kv-v2', + QL3_WORKER_SECRET_VAULT_ENDPOINT: 'https://vault.internal:8200', + QL3_WORKER_SECRET_VAULT_CA_FILE: '/run/vault/trust/ca.pem', + QL3_WORKER_SECRET_VAULT_TOKEN_FILE: '/run/vault/auth/token', + QL3_WORKER_SECRET_VAULT_KV_MOUNT: 'worker-secrets', + QL3_WORKER_SECRET_VAULT_PATH_PREFIX: 'values/production', + QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY: 'ql3-worker-secret-read', + QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS: '600', + QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS: '4000', + QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY: '3', + QL3_WORKER_SECRET_VAULT_NAMESPACE: 'organization/team-a', + }); + assert.deepEqual(config.secret, { + provider: 'vault-kv-v2', + endpoint: 'https://vault.internal:8200/', + caFile: '/run/vault/trust/ca.pem', + tokenFile: '/run/vault/auth/token', + kvMount: 'worker-secrets', + pathPrefix: 'values/production', + expectedPolicy: 'ql3-worker-secret-read', + maximumTokenTtlSeconds: 600, + requestTimeoutMs: 4000, + maximumConcurrency: 3, + namespace: 'organization/team-a', + }); +}); + +test('rejects incomplete or non-HTTPS Vault Worker Secret configuration', () => { + const base = { + ...BASE_ENV, + QL3_WORKER_SECRET_PROVIDER: 'vault-kv-v2', + QL3_WORKER_SECRET_VAULT_ENDPOINT: 'https://vault.internal:8200', + QL3_WORKER_SECRET_VAULT_CA_FILE: '/run/vault/trust/ca.pem', + QL3_WORKER_SECRET_VAULT_TOKEN_FILE: '/run/vault/auth/token', + QL3_WORKER_SECRET_VAULT_KV_MOUNT: 'worker-secrets', + QL3_WORKER_SECRET_VAULT_PATH_PREFIX: 'values/production', + QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY: 'ql3-worker-secret-read', + }; + for (const environment of [ + { ...base, QL3_WORKER_SECRET_VAULT_ENDPOINT: 'http://vault.internal' }, + { ...base, QL3_WORKER_SECRET_VAULT_CA_FILE: 'relative.pem' }, + { ...base, QL3_WORKER_SECRET_VAULT_TOKEN_FILE: undefined }, + { ...base, QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS: '3601' }, + { ...base, QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY: '9' }, + ]) { + assert.throws( + () => loadClusterWorkerIngressConfig(environment), + ClusterWorkerIngressConfigError, + ); + } +}); diff --git a/scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs b/scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs new file mode 100644 index 00000000..74161c00 --- /dev/null +++ b/scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs @@ -0,0 +1,188 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +function finding(code, detail) { + return Object.freeze({ code, detail }); +} + +function load(readFile, root, relativePath) { + return yaml.load(readFile(path.join(root, relativePath), 'utf8')); +} + +function loadDocuments(source) { + const documents = []; + yaml.loadAll(source, (document) => { + if (document) documents.push(document); + }); + return documents; +} + +function named(values, name) { + return (values ?? []).find((value) => value?.name === name); +} + +function auditVaultKvWorkerSecretDeployment(options = {}) { + const root = path.resolve(options.root ?? path.join(__dirname, '..')); + const readFile = options.readFile ?? fs.readFileSync; + const findings = []; + try { + const directory = 'deploy/kubernetes/ql3-cluster/vault-kv-worker-secret'; + const kustomization = load( + readFile, + root, + `${directory}/kustomization.yaml`, + ); + const patch = load(readFile, root, `${directory}/deployment-patch.yaml`); + const credentials = loadDocuments( + readFile(path.join(root, directory, 'credentials.example.yaml'), 'utf8'), + ); + const readme = readFile(path.join(root, directory, 'README.md'), 'utf8'); + if ( + kustomization?.apiVersion !== 'kustomize.config.k8s.io/v1beta1' || + kustomization?.kind !== 'Kustomization' || + JSON.stringify(kustomization?.resources) !== + JSON.stringify(['../base']) || + JSON.stringify(kustomization?.patches) !== + JSON.stringify([{ path: 'deployment-patch.yaml' }]) + ) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_KUSTOMIZATION_INVALID', + 'the Vault overlay must patch only the reviewed Cluster base', + ), + ); + } + const pod = patch?.spec?.template?.spec; + const container = named(pod?.containers, 'cluster-control'); + const env = new Map( + (container?.env ?? []).map((entry) => [entry.name, entry]), + ); + const expectedEnvironment = new Map([ + ['QL3_WORKER_SECRET_PROVIDER', 'vault-kv-v2'], + [ + 'QL3_WORKER_SECRET_VAULT_ENDPOINT', + 'https://vault.vault.svc.cluster.local:8200', + ], + [ + 'QL3_WORKER_SECRET_VAULT_CA_FILE', + '/var/run/secrets/qinglong3/worker-vault-trust/ca.pem', + ], + [ + 'QL3_WORKER_SECRET_VAULT_TOKEN_FILE', + '/var/run/secrets/qinglong3/worker-vault-auth/token', + ], + ['QL3_WORKER_SECRET_VAULT_KV_MOUNT', 'worker-secrets'], + ['QL3_WORKER_SECRET_VAULT_PATH_PREFIX', 'values/production'], + ['QL3_WORKER_SECRET_VAULT_EXPECTED_POLICY', 'ql3-worker-secret-read'], + ['QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS', '900'], + ['QL3_WORKER_SECRET_VAULT_REQUEST_TIMEOUT_MS', '5000'], + ['QL3_WORKER_SECRET_VAULT_MAX_CONCURRENCY', '4'], + ]); + if ( + env.size !== expectedEnvironment.size + 1 || + env.get('QL3_WORKER_SECRET_ROOT_DIRECTORY')?.$patch !== 'delete' || + [...expectedEnvironment].some( + ([name, value]) => env.get(name)?.value !== value, + ) + ) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_ENVIRONMENT_INVALID', + 'the overlay must select exact Vault KV v2 authority and bounds while deleting the mounted-value root', + ), + ); + } + const mounts = container?.volumeMounts ?? []; + const valuesMount = named(mounts, 'worker-secret-values'); + const trustMount = named(mounts, 'worker-vault-trust'); + const authMount = named(mounts, 'worker-vault-auth'); + const volumes = pod?.volumes ?? []; + const valuesVolume = named(volumes, 'worker-secret-values'); + const trustVolume = named(volumes, 'worker-vault-trust'); + const authVolume = named(volumes, 'worker-vault-auth'); + if ( + mounts.length !== 3 || + valuesMount?.$patch !== 'delete' || + trustMount?.mountPath !== + '/var/run/secrets/qinglong3/worker-vault-trust' || + trustMount?.readOnly !== true || + authMount?.mountPath !== '/var/run/secrets/qinglong3/worker-vault-auth' || + authMount?.readOnly !== true || + volumes.length !== 3 || + valuesVolume?.$patch !== 'delete' || + trustVolume?.secret?.secretName !== 'ql3-cluster-worker-vault-trust' || + trustVolume?.secret?.defaultMode !== 0o444 || + JSON.stringify(trustVolume?.secret?.items) !== + JSON.stringify([{ key: 'ca.pem', path: 'ca.pem' }]) || + authVolume?.secret?.secretName !== 'ql3-cluster-worker-vault-auth' || + authVolume?.secret?.defaultMode !== 0o440 || + JSON.stringify(authVolume?.secret?.items) !== + JSON.stringify([{ key: 'token', path: 'token' }]) + ) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_PROJECTION_INVALID', + 'only read-only Vault trust and short-lived auth projections are allowed; the value projection must be deleted', + ), + ); + } + if ( + credentials.length !== 2 || + credentials[0]?.kind !== 'Secret' || + credentials[0]?.metadata?.name !== 'ql3-cluster-worker-vault-trust' || + credentials[0]?.stringData?.['ca.pem'] !== + 'REPLACE_WITH_PRIVATE_VAULT_CA_PEM' || + credentials[1]?.kind !== 'Secret' || + credentials[1]?.metadata?.name !== 'ql3-cluster-worker-vault-auth' || + credentials[1]?.stringData?.token !== + 'REPLACE_WITH_SHORT_LIVED_ORPHAN_VAULT_TOKEN' + ) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_EXAMPLE_INVALID', + 'credential examples must contain only explicit non-production placeholders', + ), + ); + } + if ( + !readme.includes('auth/token/lookup-self') || + !readme.includes('capabilities = ["read"]') || + !/must not project\s+the actual Worker Secret values/.test(readme) || + !readme.includes('default-deny') + ) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_OPERATIONS_INVALID', + 'operations guidance must preserve exact policy, direct custody and explicit egress boundaries', + ), + ); + } + } catch (error) { + findings.push( + finding( + 'QL3_VAULT_KV_WORKER_SECRET_DEPLOYMENT_AUDIT_UNAVAILABLE', + error instanceof Error ? error.message : String(error), + ), + ); + } + return Object.freeze({ + schemaVersion: 1, + provider: 'vault-kv-v2', + mountedValueProjection: false, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +if (require.main === module) { + const report = auditVaultKvWorkerSecretDeployment(); + process.stdout.write(`${JSON.stringify(report)}\n`); + if (!report.compatible) process.exitCode = 1; +} + +module.exports = { auditVaultKvWorkerSecretDeployment }; diff --git a/scripts/ql3-vault-kv-worker-secret-live-audit.cjs b/scripts/ql3-vault-kv-worker-secret-live-audit.cjs new file mode 100644 index 00000000..0377f31c --- /dev/null +++ b/scripts/ql3-vault-kv-worker-secret-live-audit.cjs @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const FIXTURE = 'qinglong/vault-kv-worker-secret-direct-custody-live@v1'; +const IMAGE = + 'docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569'; +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const VERSION = /^\d+\.\d+\.\d+(?:[+-][A-Za-z0-9.-]+)?$/; +const FORBIDDEN_KEY = + /(secretRef|secretValue|clientToken|rootToken|accessor|endpoint|pathPrefix|tokenFile|caFile|materialValue|materialBytes|privateKey)/i; +const REQUIRED_GATES = Object.freeze([ + 'digestPinnedVaultImage', + 'nativeVaultArchitecture', + 'tls13WithExplicitPrivateCa', + 'untrustedCaRejected', + 'initializedWithThreeOfTwoSealAuthority', + 'kvV2ExternalCustody', + 'oneExactReadOnlyPolicy', + 'shortLivedOrphanNonRenewableToken', + 'tokenRevalidatedPerResolution', + 'digestDerivedPathsOnly', + 'normalSecretBoundPreserved', + 'opaqueEnvironmentBundleBoundPreserved', + 'valueRotationObservedWithoutControlRestart', + 'tokenRotationObservedWithoutControlRestart', + 'revokedTokenRemoved', + 'missingMaterialFailsClosed', + 'sealedVaultFailsClosed', + 'thresholdUnsealRestoresResolution', + 'persistentValuesSurviveContainerReplacement', + 'reportIsContentFree', + 'passed', +]); + +function exact(value, keys) { + return Boolean( + value && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...keys].sort()), + ); +} + +function scan(value, findings, location = 'report') { + if ( + typeof value === 'string' && + /(hvs\.[A-Za-z0-9_-]{16,}|-----BEGIN (?:RSA |EC )?PRIVATE KEY-----|qlsecret:v1:|vault-private-|bundle-private-)/.test( + value, + ) + ) { + findings.push(`${location} contains sensitive material`); + return; + } + if (Array.isArray(value)) { + value.forEach((entry, index) => + scan(entry, findings, `${location}[${index}]`), + ); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, entry] of Object.entries(value)) { + if (FORBIDDEN_KEY.test(key)) { + findings.push(`${location}.${key} is forbidden`); + } + scan(entry, findings, `${location}.${key}`); + } +} + +function validateVaultKvWorkerSecretLiveReport(report) { + const findings = []; + if ( + !exact(report, [ + 'schemaVersion', + 'fixture', + 'platform', + 'custody', + 'gates', + 'limitations', + ]) || + report?.schemaVersion !== 1 || + report?.fixture !== FIXTURE + ) { + findings.push('report envelope is invalid'); + } + if ( + !exact(report?.platform, [ + 'architecture', + 'vaultImage', + 'vaultImageId', + 'vaultVersion', + 'transport', + 'storage', + ]) || + !['amd64', 'arm64'].includes(report?.platform?.architecture) || + report?.platform?.vaultImage !== IMAGE || + !SHA256.test(report?.platform?.vaultImageId ?? '') || + !VERSION.test(report?.platform?.vaultVersion ?? '') || + report?.platform?.transport !== 'TLSv1.3 with an explicit private CA' || + report?.platform?.storage !== 'persistent file barrier fixture' + ) { + findings.push('platform evidence is invalid'); + } + if ( + !exact(report?.custody, [ + 'provider', + 'kvVersion', + 'policyCount', + 'maximumTokenTtlSeconds', + 'tokenLeaseSeconds', + 'secretCount', + 'environmentBundleCount', + 'observedVersions', + 'containerReplacements', + ]) || + report?.custody?.provider !== 'vault-kv-v2' || + report?.custody?.kvVersion !== 2 || + report?.custody?.policyCount !== 1 || + report?.custody?.maximumTokenTtlSeconds !== 900 || + report?.custody?.tokenLeaseSeconds !== 600 || + report?.custody?.secretCount !== 2 || + report?.custody?.environmentBundleCount !== 1 || + JSON.stringify(report?.custody?.observedVersions) !== + JSON.stringify([1, 2]) || + report?.custody?.containerReplacements !== 1 + ) { + findings.push('custody evidence is invalid'); + } + if ( + !exact(report?.gates, REQUIRED_GATES) || + REQUIRED_GATES.some((gate) => report?.gates?.[gate] !== true) + ) { + findings.push('one or more required gates are false or missing'); + } + if ( + !Array.isArray(report?.limitations) || + report.limitations.length !== 3 || + report.limitations.some( + (value) => + typeof value !== 'string' || value.length < 32 || value.length > 512, + ) + ) { + findings.push('limitations are invalid'); + } + scan(report, findings); + return Object.freeze({ + schemaVersion: 1, + fixture: FIXTURE, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function main(argv) { + if (argv.length !== 1 || !argv[0].startsWith('--report=/')) { + throw new Error( + 'usage: ql3-vault-kv-worker-secret-live-audit --report=/absolute/report.json', + ); + } + const reportPath = argv[0].slice('--report='.length); + if (path.resolve(reportPath) !== reportPath) { + throw new Error('report path is invalid'); + } + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const result = validateVaultKvWorkerSecretLiveReport(report); + process.stdout.write(`${JSON.stringify(result)}\n`); + if (!result.compatible) process.exitCode = 1; +} + +if (require.main === module) { + try { + main(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `QL3 Vault KV Worker Secret live audit failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = { + FIXTURE, + IMAGE, + REQUIRED_GATES, + validateVaultKvWorkerSecretLiveReport, +}; diff --git a/scripts/ql3-vault-kv-worker-secret-live-contract.cjs b/scripts/ql3-vault-kv-worker-secret-live-contract.cjs new file mode 100644 index 00000000..904e8cfb --- /dev/null +++ b/scripts/ql3-vault-kv-worker-secret-live-contract.cjs @@ -0,0 +1,814 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const { randomBytes } = require('node:crypto'); +const { + chmodSync, + mkdirSync, + mkdtempSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} = require('node:fs'); +const https = require('node:https'); +const { isIP } = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const { + createSecretRef, +} = require('../packages/ql3-runtime-core/dist/secret/secretReference.js'); +const { + secretProjectionFileName, +} = require('../packages/ql3-runtime-core/dist/secret/secretProjection.js'); +const { + ClusterVaultKvSecretProviderError, + createClusterVaultKvSecretProvider, +} = require('../packages/ql3-cluster-control/dist/remote-execution/vaultKvSecretProvider.js'); + +const IMAGE = + 'docker.io/hashicorp/vault@sha256:4e33b126a59c0c333b76fb4e894722462659a6bec7c48c9ee8cea56fccfd2569'; +const FIXTURE = 'qinglong/vault-kv-worker-secret-direct-custody-live@v1'; +const MAX_RESPONSE_BYTES = 256 * 1024; +const VAULT_TIMEOUT_MS = 10_000; +const POLICY = 'ql3-worker-secret-read'; +const MOUNT = 'worker-secrets'; +const PREFIX = 'values/production'; + +function docker(args, options = {}) { + const result = spawnSync('docker', args, { + encoding: 'utf8', + timeout: options.timeoutMs ?? 120_000, + maxBuffer: 4 * 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`Docker command failed: ${args[0] ?? 'unknown'}`); + } + return { + status: result.status, + stdout: result.stdout.trim(), + stderr: result.stderr.trim(), + }; +} + +function privateFile(directory, name, value, mode = 0o440) { + const target = path.join(directory, name); + writeFileSync(target, value, { mode: 0o600, flag: 'wx' }); + chmodSync(target, mode); + return target; +} + +function openssl(args) { + const result = spawnSync('openssl', args, { + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 1024 * 1024, + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error('OpenSSL command failed'); +} + +function generateTlsAuthority(directory) { + const tlsDirectory = path.join(directory, 'tls'); + mkdirSync(tlsDirectory, { mode: 0o700 }); + const caKeyFile = path.join(tlsDirectory, 'ca-key.pem'); + const caFile = path.join(tlsDirectory, 'ca.pem'); + const serverKeyFile = path.join(tlsDirectory, 'server-key.pem'); + const serverRequestFile = path.join(tlsDirectory, 'server.csr'); + const serverCertificateFile = path.join(tlsDirectory, 'server.pem'); + const untrustedCaKeyFile = path.join(tlsDirectory, 'untrusted-ca-key.pem'); + const untrustedCaFile = path.join(tlsDirectory, 'untrusted-ca.pem'); + const extensionsFile = privateFile( + tlsDirectory, + 'server-extensions.cnf', + [ + 'basicConstraints=critical,CA:FALSE', + 'keyUsage=critical,digitalSignature,keyEncipherment', + 'extendedKeyUsage=serverAuth', + 'subjectAltName=IP:127.0.0.1,DNS:localhost', + '', + ].join('\n'), + 0o400, + ); + openssl([ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-nodes', + '-days', + '2', + '-subj', + '/CN=QingLong 3 Vault KV Live Root', + '-addext', + 'basicConstraints=critical,CA:TRUE,pathlen:0', + '-addext', + 'keyUsage=critical,keyCertSign,cRLSign', + '-keyout', + caKeyFile, + '-out', + caFile, + ]); + openssl([ + 'req', + '-new', + '-newkey', + 'rsa:2048', + '-sha256', + '-nodes', + '-subj', + '/CN=127.0.0.1', + '-keyout', + serverKeyFile, + '-out', + serverRequestFile, + ]); + openssl([ + 'x509', + '-req', + '-sha256', + '-days', + '2', + '-in', + serverRequestFile, + '-CA', + caFile, + '-CAkey', + caKeyFile, + '-CAcreateserial', + '-extfile', + extensionsFile, + '-out', + serverCertificateFile, + ]); + openssl([ + 'req', + '-x509', + '-newkey', + 'rsa:2048', + '-sha256', + '-nodes', + '-days', + '2', + '-subj', + '/CN=QingLong 3 Untrusted Vault KV Root', + '-addext', + 'basicConstraints=critical,CA:TRUE,pathlen:0', + '-addext', + 'keyUsage=critical,keyCertSign,cRLSign', + '-keyout', + untrustedCaKeyFile, + '-out', + untrustedCaFile, + ]); + chmodSync(caKeyFile, 0o400); + chmodSync(caFile, 0o440); + chmodSync(serverKeyFile, 0o400); + chmodSync(serverRequestFile, 0o400); + chmodSync(serverCertificateFile, 0o440); + chmodSync(untrustedCaKeyFile, 0o400); + chmodSync(untrustedCaFile, 0o440); + return Object.freeze({ + tlsDirectory, + caFile, + serverCertificateFile, + serverKeyFile, + untrustedCaFile, + }); +} + +function vaultJson(endpoint, ca, token, method, requestPath, body) { + const bytes = body === undefined ? null : Buffer.from(JSON.stringify(body)); + const target = new URL(requestPath, endpoint); + return new Promise((resolve, reject) => { + const request = https.request( + target, + { + method, + ca, + rejectUnauthorized: true, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', + ...(isIP(target.hostname) === 0 ? { servername: target.hostname } : {}), + headers: { + accept: 'application/json', + ...(bytes === null + ? {} + : { + 'content-type': 'application/json', + 'content-length': String(bytes.byteLength), + }), + ...(token === null ? {} : { 'x-vault-token': token }), + }, + }, + (response) => { + const tlsProtocol = response.socket.getProtocol(); + const peerAuthorized = response.socket.authorized; + const chunks = []; + let length = 0; + response.on('data', (chunk) => { + length += chunk.byteLength; + if (length > MAX_RESPONSE_BYTES) { + response.destroy(new Error('Vault response exceeded live limit')); + return; + } + chunks.push(Buffer.from(chunk)); + }); + response.once('error', reject); + response.on('end', () => { + const responseBytes = Buffer.concat(chunks); + chunks.forEach((chunk) => chunk.fill(0)); + try { + resolve({ + statusCode: response.statusCode, + value: responseBytes.byteLength + ? JSON.parse(responseBytes.toString('utf8')) + : null, + tlsProtocol, + peerAuthorized, + }); + } catch (cause) { + reject(cause); + } finally { + responseBytes.fill(0); + } + }); + }, + ); + request.setTimeout(VAULT_TIMEOUT_MS, () => + request.destroy(new Error('Vault live request timed out')), + ); + request.once('error', reject); + request.once('close', () => bytes?.fill(0)); + request.end(bytes ?? undefined); + }); +} + +async function waitForVault(endpoint, ca, expected) { + let lastError; + for (let attempt = 0; attempt < 120; attempt += 1) { + try { + const health = await vaultJson( + endpoint, + ca, + null, + 'GET', + '/v1/sys/health', + ); + if ( + health.statusCode === expected.statusCode && + health.value?.initialized === expected.initialized && + health.value?.sealed === expected.sealed + ) { + assert.equal(health.tlsProtocol, 'TLSv1.3'); + assert.equal(health.peerAuthorized, true); + return health; + } + } catch (cause) { + lastError = cause; + } + await new Promise((resolve) => setTimeout(resolve, 125)); + } + const detail = + lastError instanceof Error + ? `${ + typeof lastError.code === 'string' ? lastError.code : lastError.name + }:${lastError.message}` + : 'no-health-response'; + throw new Error( + `Vault server did not reach the expected state (${detail.slice(0, 256)})`, + { cause: lastError }, + ); +} + +function startVaultContainer(container, publish, directory, tls) { + const uid = typeof process.getuid === 'function' ? process.getuid() : 100; + const gid = typeof process.getgid === 'function' ? process.getgid() : 1000; + docker([ + 'run', + '--detach', + '--rm', + '--name', + container, + '--publish', + publish, + '--user', + `${uid}:${gid}`, + '--cap-drop', + 'ALL', + '--cap-add', + 'IPC_LOCK', + '--security-opt', + 'no-new-privileges:true', + '--read-only', + '--tmpfs', + '/tmp:rw,noexec,nosuid,size=16m', + '--volume', + `${path.join(directory, 'vault-server.hcl')}:/vault/config/server.hcl:ro`, + '--volume', + `${tls.tlsDirectory}:/vault/tls:ro`, + '--volume', + `${path.join(directory, 'vault-data')}:/vault/file:rw`, + '--entrypoint', + '/bin/vault', + IMAGE, + 'server', + '-config=/vault/config/server.hcl', + ]); + return docker(['inspect', container, '--format', '{{.Id}}']).stdout; +} + +async function unsealVault(endpoint, ca, unsealKeys) { + let result; + for (const key of unsealKeys.slice(0, 2)) { + result = await vaultJson(endpoint, ca, null, 'POST', '/v1/sys/unseal', { + key: key.toString('base64'), + }); + assert.equal(result.statusCode, 200); + } + assert.equal(result.value?.sealed, false); + assert.equal(result.value?.t, 2); + assert.equal(result.value?.n, 3); +} + +function authority(secretRefs, environmentBundleRefs = []) { + return { + workerId: 'worker-vault-live', + workerSessionId: '018f0000-0000-7000-8000-000000000001', + workerGeneration: 1, + runId: 'run-vault-live', + attemptId: 'attempt-vault-live', + projectId: 'project-vault-live', + taskId: 'task-vault-live', + taskRevision: 'revision-vault-live', + executionDigest: 'a'.repeat(64), + offerId: 'offer-vault-live', + leaseGeneration: 1, + leaseVersion: 1, + secretRefs, + environmentBundleRefs, + }; +} + +function kvEnvelope(secretRef, value) { + return { + schemaVersion: 1, + secretRefDigest: secretProjectionFileName(secretRef), + encoding: 'base64', + value: Buffer.from(value).toString('base64'), + }; +} + +async function putSecret(endpoint, ca, rootToken, secretRef, value) { + const digest = secretProjectionFileName(secretRef); + const response = await vaultJson( + endpoint, + ca, + rootToken, + 'POST', + `/v1/${MOUNT}/data/${PREFIX}/${digest}`, + { data: kvEnvelope(secretRef, value) }, + ); + assert.equal(response.statusCode, 200); + assert.equal(Number.isSafeInteger(response.value?.data?.version), true); + return response.value.data.version; +} + +async function createLeastPrivilegeToken(endpoint, ca, rootToken) { + const response = await vaultJson( + endpoint, + ca, + rootToken, + 'POST', + '/v1/auth/token/create-orphan', + { + policies: [POLICY], + no_default_policy: true, + renewable: false, + ttl: '10m', + display_name: 'ql3-worker-secret-live', + }, + ); + assert.equal(response.statusCode, 200); + assert.equal(response.value?.auth?.policies?.length, 1); + assert.equal(response.value.auth.policies[0], POLICY); + assert.equal(response.value.auth.orphan, true); + assert.equal(response.value.auth.renewable, false); + assert.equal(response.value.auth.lease_duration, 600); + assert.equal(typeof response.value.auth.client_token, 'string'); + assert.equal(typeof response.value.auth.accessor, 'string'); + return { + token: response.value.auth.client_token, + accessor: response.value.auth.accessor, + }; +} + +async function main() { + if (process.env.QL3_RUN_VAULT_KV_WORKER_SECRET_LIVE !== 'true') { + throw new Error('QL3_RUN_VAULT_KV_WORKER_SECRET_LIVE=true is required'); + } + docker(['version', '--format', '{{.Server.Version}}']); + docker(['image', 'inspect', IMAGE]); + + const suffix = `${process.pid}-${randomBytes(3).toString('hex')}`; + const container = `ql3-vault-kv-worker-${suffix}`; + const directory = mkdtempSync(path.join(os.tmpdir(), 'ql3-vault-kv-live-')); + chmodSync(directory, 0o700); + const unsealKeys = []; + let ca; + let rootToken; + let firstToken; + let secondToken; + let started = false; + try { + const tls = generateTlsAuthority(directory); + ca = readFileSync(tls.caFile); + const untrustedCa = readFileSync(tls.untrustedCaFile); + mkdirSync(path.join(directory, 'vault-data'), { mode: 0o700 }); + privateFile( + directory, + 'vault-server.hcl', + [ + 'ui = false', + 'disable_mlock = false', + 'api_addr = "https://127.0.0.1:8200"', + 'cluster_addr = "https://127.0.0.1:8201"', + 'storage "file" {', + ' path = "/vault/file"', + '}', + 'listener "tcp" {', + ' address = "0.0.0.0:8200"', + ' cluster_address = "0.0.0.0:8201"', + ' tls_cert_file = "/vault/tls/server.pem"', + ' tls_key_file = "/vault/tls/server-key.pem"', + ' tls_min_version = "tls13"', + ' tls_max_version = "tls13"', + '}', + '', + ].join('\n'), + ); + const firstContainerId = startVaultContainer( + container, + '127.0.0.1::8200', + directory, + tls, + ); + started = true; + const portOutput = docker(['port', container, '8200/tcp']).stdout; + const portMatch = /^127\.0\.0\.1:([1-9][0-9]*)$/.exec(portOutput); + assert.ok(portMatch, 'Vault host port must be loopback-only'); + const hostPort = Number(portMatch[1]); + const endpoint = `https://127.0.0.1:${hostPort}`; + const uninitialized = await waitForVault(endpoint, ca, { + statusCode: 501, + initialized: false, + sealed: true, + }); + const initialization = await vaultJson( + endpoint, + ca, + null, + 'POST', + '/v1/sys/init', + { secret_shares: 3, secret_threshold: 2 }, + ); + assert.equal(initialization.statusCode, 200); + for (const encoded of initialization.value.keys_base64) { + const key = Buffer.from(encoded, 'base64'); + assert.equal(key.toString('base64'), encoded); + unsealKeys.push(key); + } + rootToken = initialization.value.root_token; + initialization.value.keys = []; + initialization.value.keys_base64 = []; + initialization.value.root_token = ''; + await unsealVault(endpoint, ca, unsealKeys); + const initialHealth = await waitForVault(endpoint, ca, { + statusCode: 200, + initialized: true, + sealed: false, + }); + + const mount = await vaultJson( + endpoint, + ca, + rootToken, + 'POST', + `/v1/sys/mounts/${MOUNT}`, + { type: 'kv', options: { version: '2' } }, + ); + assert.equal(mount.statusCode, 204); + const policy = await vaultJson( + endpoint, + ca, + rootToken, + 'PUT', + `/v1/sys/policies/acl/${POLICY}`, + { + policy: [ + `path "${MOUNT}/data/${PREFIX}/*" { capabilities = ["read"] }`, + 'path "auth/token/lookup-self" { capabilities = ["read"] }', + ].join('\n'), + }, + ); + assert.equal(policy.statusCode, 204); + + const secretRef = createSecretRef({ + projectId: 'project-vault-live', + name: 'legacy-token', + version: 2, + }); + const secondSecretRef = createSecretRef({ + projectId: 'project-vault-live', + name: 'legacy-certificate', + version: 1, + }); + const bundleRef = createSecretRef({ + projectId: 'project-vault-live', + name: 'legacy-environment-bundle', + version: 3, + }); + const missingRef = createSecretRef({ + projectId: 'project-vault-live', + name: 'not-provisioned', + }); + const firstValue = `vault-private-generation-one-${suffix}`; + const secondValue = `vault-private-generation-two-${suffix}`; + const certificateValue = `vault-private-certificate-${suffix}`; + const bundleValue = JSON.stringify({ + schema: 'qinglong/environment-bundle@v1', + entries: [{ name: 'LEGACY_ENV', value: `bundle-private-${suffix}` }], + }); + const firstVersion = await putSecret( + endpoint, + ca, + rootToken, + secretRef, + firstValue, + ); + await putSecret(endpoint, ca, rootToken, secondSecretRef, certificateValue); + await putSecret(endpoint, ca, rootToken, bundleRef, bundleValue); + firstToken = await createLeastPrivilegeToken(endpoint, ca, rootToken); + const tokenFile = privateFile( + directory, + 'worker-token', + `${firstToken.token}\n`, + 0o440, + ); + const options = { + endpoint, + caFile: tls.caFile, + tokenFile, + kvMount: MOUNT, + pathPrefix: PREFIX, + expectedPolicy: POLICY, + maximumTokenTtlSeconds: 900, + requestTimeoutMs: 5000, + maximumConcurrency: 2, + }; + const initialLookup = await vaultJson( + endpoint, + ca, + firstToken.token, + 'GET', + '/v1/auth/token/lookup-self', + ); + assert.equal(initialLookup.statusCode, 200); + assert.deepEqual(initialLookup.value?.data?.policies, [POLICY]); + assert.equal(initialLookup.value?.data?.orphan, true); + assert.equal(initialLookup.value?.data?.renewable, false); + assert.equal(initialLookup.value?.data?.type, 'service'); + assert.equal( + Number.isSafeInteger(initialLookup.value?.data?.ttl) && + initialLookup.value.data.ttl > 0 && + initialLookup.value.data.ttl <= 900, + true, + ); + const provider = await createClusterVaultKvSecretProvider(options); + const first = await provider.resolve( + authority([secretRef, secondSecretRef], [bundleRef]), + ); + assert.deepEqual(first.values, [ + { secretRef, value: firstValue }, + { secretRef: secondSecretRef, value: certificateValue }, + ]); + assert.deepEqual(first.environmentBundles, [ + { secretRef: bundleRef, value: bundleValue }, + ]); + await first.dispose(); + + const rotatedVersion = await putSecret( + endpoint, + ca, + rootToken, + secretRef, + secondValue, + ); + assert.equal(rotatedVersion, firstVersion + 1); + const rotated = await provider.resolve(authority([secretRef])); + assert.deepEqual(rotated.values, [{ secretRef, value: secondValue }]); + await rotated.dispose(); + + await assert.rejects( + provider.resolve(authority([missingRef])), + (error) => + error instanceof ClusterVaultKvSecretProviderError && + error.reason === 'material_unavailable', + ); + const untrustedProvider = createClusterVaultKvSecretProvider({ + ...options, + caFile: tls.untrustedCaFile, + }); + await assert.rejects(untrustedProvider, ClusterVaultKvSecretProviderError); + + secondToken = await createLeastPrivilegeToken(endpoint, ca, rootToken); + const tokenReplacement = privateFile( + directory, + 'worker-token.next', + `${secondToken.token}\n`, + 0o440, + ); + renameSync(tokenReplacement, tokenFile); + const revoke = await vaultJson( + endpoint, + ca, + rootToken, + 'POST', + '/v1/auth/token/revoke-accessor', + { accessor: firstToken.accessor }, + ); + assert.equal(revoke.statusCode, 204); + const afterTokenRotation = await provider.resolve(authority([secretRef])); + assert.equal(afterTokenRotation.values[0].value, secondValue); + await afterTokenRotation.dispose(); + + const seal = await vaultJson( + endpoint, + ca, + rootToken, + 'PUT', + '/v1/sys/seal', + ); + assert.equal(seal.statusCode, 204); + await waitForVault(endpoint, ca, { + statusCode: 503, + initialized: true, + sealed: true, + }); + await assert.rejects( + provider.resolve(authority([secretRef])), + ClusterVaultKvSecretProviderError, + ); + await unsealVault(endpoint, ca, unsealKeys); + const postUnseal = await provider.resolve(authority([secretRef])); + assert.equal(postUnseal.values[0].value, secondValue); + await postUnseal.dispose(); + + docker(['rm', '--force', container]); + started = false; + const secondContainerId = startVaultContainer( + container, + `127.0.0.1:${hostPort}:8200`, + directory, + tls, + ); + started = true; + assert.notEqual(secondContainerId, firstContainerId); + const sealedAfterReplacement = await waitForVault(endpoint, ca, { + statusCode: 503, + initialized: true, + sealed: true, + }); + await unsealVault(endpoint, ca, unsealKeys); + const postReplacementHealth = await waitForVault(endpoint, ca, { + statusCode: 200, + initialized: true, + sealed: false, + }); + const postReplacement = await provider.resolve(authority([secretRef])); + assert.equal(postReplacement.values[0].value, secondValue); + await postReplacement.dispose(); + + const imageId = docker([ + 'image', + 'inspect', + IMAGE, + '--format', + '{{.Id}}', + ]).stdout; + const architecture = docker([ + 'image', + 'inspect', + IMAGE, + '--format', + '{{.Architecture}}', + ]).stdout; + const report = { + schemaVersion: 1, + fixture: FIXTURE, + platform: { + architecture, + vaultImage: IMAGE, + vaultImageId: imageId, + vaultVersion: postReplacementHealth.value.version, + transport: 'TLSv1.3 with an explicit private CA', + storage: 'persistent file barrier fixture', + }, + custody: { + provider: 'vault-kv-v2', + kvVersion: 2, + policyCount: 1, + maximumTokenTtlSeconds: 900, + tokenLeaseSeconds: 600, + secretCount: 2, + environmentBundleCount: 1, + observedVersions: [firstVersion, rotatedVersion], + containerReplacements: 1, + }, + gates: { + digestPinnedVaultImage: true, + nativeVaultArchitecture: ['amd64', 'arm64'].includes(architecture), + tls13WithExplicitPrivateCa: + uninitialized.tlsProtocol === 'TLSv1.3' && + initialHealth.tlsProtocol === 'TLSv1.3' && + postReplacementHealth.tlsProtocol === 'TLSv1.3', + untrustedCaRejected: true, + initializedWithThreeOfTwoSealAuthority: true, + kvV2ExternalCustody: true, + oneExactReadOnlyPolicy: true, + shortLivedOrphanNonRenewableToken: true, + tokenRevalidatedPerResolution: true, + digestDerivedPathsOnly: true, + normalSecretBoundPreserved: true, + opaqueEnvironmentBundleBoundPreserved: true, + valueRotationObservedWithoutControlRestart: true, + tokenRotationObservedWithoutControlRestart: true, + revokedTokenRemoved: true, + missingMaterialFailsClosed: true, + sealedVaultFailsClosed: true, + thresholdUnsealRestoresResolution: true, + persistentValuesSurviveContainerReplacement: + sealedAfterReplacement.value.sealed === true, + reportIsContentFree: true, + passed: true, + }, + limitations: [ + 'single-host file storage is not Vault integrated-storage HA or an HSM seal quorum', + 'the short-lived private CA and service tokens are live fixture authorities rather than enterprise PKI or workload identity', + 'the live gate proves direct external custody resolution and rotation, not fixed physical Edge storage behavior', + ], + }; + const serialized = JSON.stringify(report); + for (const forbidden of [ + rootToken, + firstToken.token, + secondToken.token, + firstValue, + secondValue, + certificateValue, + bundleValue, + directory, + endpoint, + ]) { + assert.equal(serialized.includes(forbidden), false); + } + const outputPath = process.env.QL3_VAULT_KV_WORKER_SECRET_REPORT; + if (outputPath !== undefined) { + if (!path.isAbsolute(outputPath)) + throw new Error('report path is invalid'); + writeFileSync(outputPath, `${serialized}\n`, { + flag: 'wx', + mode: 0o600, + }); + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + untrustedCa.fill(0); + } finally { + ca?.fill(0); + unsealKeys.forEach((key) => key.fill(0)); + rootToken = undefined; + firstToken = undefined; + secondToken = undefined; + if (started) docker(['rm', '--force', container], { allowFailure: true }); + rmSync(directory, { recursive: true, force: true }); + } +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `ql3 Vault KV Worker Secret live contract failed: ${ + error instanceof Error ? error.stack ?? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + }); +} + +module.exports = { FIXTURE, IMAGE }; diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs index adf1b267..c19a1622 100644 --- a/test/back/ql3DeploymentLockContract.test.cjs +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -919,7 +919,7 @@ test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity test('source-surface audit freezes every reviewed cluster and worker authority', () => { assert.deepEqual(auditDeploymentImageSurfaces(root), { schemaVersion: 1, - deploymentYamlFiles: 224, + deploymentYamlFiles: 227, imageOccurrences: { control: 2, 'control-ai': 1, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 04205a7e..f120d4b9 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -385,10 +385,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterControl.rootSourceFileRoles, }, { - sourceFiles: 65, + sourceFiles: 66, rootSourceFiles: 2, rootSourceLines: 195, - nestedSourceFiles: 63, + nestedSourceFiles: 64, rootSourceFileRoles: { 'aiCli.ts': 'binary_entry', 'cli.ts': 'binary_entry', diff --git a/test/back/ql3VaultKvWorkerSecretDeploymentAudit.test.cjs b/test/back/ql3VaultKvWorkerSecretDeploymentAudit.test.cjs new file mode 100644 index 00000000..eaa0e21b --- /dev/null +++ b/test/back/ql3VaultKvWorkerSecretDeploymentAudit.test.cjs @@ -0,0 +1,47 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + auditVaultKvWorkerSecretDeployment, +} = require('../../scripts/ql3-vault-kv-worker-secret-deployment-audit.cjs'); + +const ROOT = path.resolve(__dirname, '../..'); + +test('accepts the exact direct Vault KV deployment overlay', () => { + assert.deepEqual(auditVaultKvWorkerSecretDeployment({ root: ROOT }), { + schemaVersion: 1, + provider: 'vault-kv-v2', + mountedValueProjection: false, + findings: [], + compatible: true, + }); +}); + +test('rejects a restored mounted-value projection or broad token TTL', () => { + const readFile = (file, encoding) => { + const source = fs.readFileSync(file, encoding); + if (!file.endsWith('deployment-patch.yaml')) return source; + return source + .replace( + "QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS\n value: '900'", + "QL3_WORKER_SECRET_VAULT_MAX_TOKEN_TTL_SECONDS\n value: '3600'", + ) + .replace( + '- name: worker-secret-values\n $patch: delete', + '- name: worker-secret-values\n mountPath: /run/values', + ); + }; + const result = auditVaultKvWorkerSecretDeployment({ root: ROOT, readFile }); + assert.equal(result.compatible, false); + assert.deepEqual( + result.findings.map(({ code }) => code), + [ + 'QL3_VAULT_KV_WORKER_SECRET_ENVIRONMENT_INVALID', + 'QL3_VAULT_KV_WORKER_SECRET_PROJECTION_INVALID', + ], + ); +}); diff --git a/test/back/ql3VaultKvWorkerSecretLiveAudit.test.cjs b/test/back/ql3VaultKvWorkerSecretLiveAudit.test.cjs new file mode 100644 index 00000000..1b9a3e45 --- /dev/null +++ b/test/back/ql3VaultKvWorkerSecretLiveAudit.test.cjs @@ -0,0 +1,88 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + FIXTURE, + IMAGE, + REQUIRED_GATES, + validateVaultKvWorkerSecretLiveReport, +} = require('../../scripts/ql3-vault-kv-worker-secret-live-audit.cjs'); + +function fixture() { + return { + schemaVersion: 1, + fixture: FIXTURE, + platform: { + architecture: 'arm64', + vaultImage: IMAGE, + vaultImageId: `sha256:${'a'.repeat(64)}`, + vaultVersion: '1.20.2', + transport: 'TLSv1.3 with an explicit private CA', + storage: 'persistent file barrier fixture', + }, + custody: { + provider: 'vault-kv-v2', + kvVersion: 2, + policyCount: 1, + maximumTokenTtlSeconds: 900, + tokenLeaseSeconds: 600, + secretCount: 2, + environmentBundleCount: 1, + observedVersions: [1, 2], + containerReplacements: 1, + }, + gates: Object.fromEntries(REQUIRED_GATES.map((gate) => [gate, true])), + limitations: [ + 'single-host file storage is not Vault integrated-storage HA or an HSM seal quorum', + 'the short-lived private CA and service tokens are fixture authorities rather than enterprise PKI', + 'the live gate proves direct custody resolution rather than physical Edge storage behavior', + ], + }; +} + +test('accepts the exact content-free Vault KV direct custody report', () => { + assert.deepEqual(validateVaultKvWorkerSecretLiveReport(fixture()), { + schemaVersion: 1, + fixture: FIXTURE, + findings: [], + compatible: true, + }); +}); + +test('rejects false, missing or widened Vault KV gates', () => { + const falseGate = fixture(); + falseGate.gates.sealedVaultFailsClosed = false; + assert.equal( + validateVaultKvWorkerSecretLiveReport(falseGate).compatible, + false, + ); + const widened = fixture(); + widened.gates.unreviewed = true; + assert.equal( + validateVaultKvWorkerSecretLiveReport(widened).compatible, + false, + ); + const missing = fixture(); + delete missing.gates.tokenRevalidatedPerResolution; + assert.equal( + validateVaultKvWorkerSecretLiveReport(missing).compatible, + false, + ); +}); + +test('rejects sensitive material or widened report shape', () => { + const sensitive = fixture(); + sensitive.limitations[0] = + 'vault-private-generation-one-must-never-enter-the-report'; + assert.match( + validateVaultKvWorkerSecretLiveReport(sensitive).findings.join('; '), + /sensitive material/, + ); + const widened = fixture(); + widened.endpoint = 'https://vault.private:8200'; + const findings = validateVaultKvWorkerSecretLiveReport(widened).findings; + assert.ok(findings.includes('report envelope is invalid')); + assert.match(findings.join('; '), /endpoint is forbidden/); +}); diff --git a/test/back/ql3VersionTransition.test.cjs b/test/back/ql3VersionTransition.test.cjs index eb9794ef..1b2ef9c9 100644 --- a/test/back/ql3VersionTransition.test.cjs +++ b/test/back/ql3VersionTransition.test.cjs @@ -102,7 +102,7 @@ test('audits one source-derived QingLong 3 release identity', () => { legacyRootExcluded: true, workspacePackageCount: 18, containerRootCount: 4, - deploymentFileCount: 242, + deploymentFileCount: 246, deploymentImageReferences: 32, deploymentVersionOccurrences: 36, compatible: true,