From c6b9e1504cfbad7866c3883148abdf8cb1d8bdf0 Mon Sep 17 00:00:00 2001 From: whyour Date: Tue, 25 Aug 2026 09:15:40 +0800 Subject: [PATCH] feat(ql3): prove security administration custody live --- .../ql3-security-administration-live.yml | 121 ++ .../security-administration/base/job.yaml | 1 + docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 4 +- ...-kubernetes-security-administration-job.md | 10 +- .../ql3-cluster-security-administration.md | 4 +- package.json | 2 + ...usterAdministrationKubernetesInputStage.ts | 15 +- ...dministrationKubernetesInputStage.test.cjs | 59 + ...curity-administration-kubernetes-audit.cjs | 1 + ...y-administration-kubernetes-live-audit.cjs | 460 ++++++ ...dministration-kubernetes-live-contract.cjs | 1389 +++++++++++++++++ ...rityAdministrationKubernetesAudit.test.cjs | 20 + ...AdministrationKubernetesLiveAudit.test.cjs | 175 +++ ...inistrationKubernetesLiveContract.test.cjs | 140 ++ 14 files changed, 2391 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/ql3-security-administration-live.yml create mode 100644 scripts/ql3-security-administration-kubernetes-live-audit.cjs create mode 100644 scripts/ql3-security-administration-kubernetes-live-contract.cjs create mode 100644 test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs create mode 100644 test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs diff --git a/.github/workflows/ql3-security-administration-live.yml b/.github/workflows/ql3-security-administration-live.yml new file mode 100644 index 00000000..9283c804 --- /dev/null +++ b/.github/workflows/ql3-security-administration-live.yml @@ -0,0 +1,121 @@ +name: QingLong 3.0 Security Administration Kubernetes live evidence + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ql3-security-administration-live-${{ github.ref }} + cancel-in-progress: false + +jobs: + security-administration-live: + name: Three-node Security Administration, PostgreSQL and PVC custody + runs-on: ubuntu-24.04 + timeout-minutes: 90 + 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: Require enough ephemeral disk for three isolated K3s nodes + shell: bash + run: | + set -euo pipefail + available_kib="$(df -Pk / | awk 'NR == 2 { print $4 }')" + minimum_kib="$((25 * 1024 * 1024))" + if (( available_kib < minimum_kib )); then + echo "Security Administration live gate requires at least 25 GiB free; found ${available_kib} KiB" >&2 + exit 1 + fi + docker volume ls --quiet --filter dangling=true | sort > \ + "${RUNNER_TEMP}/ql3-dangling-volumes.before" + docker system df + + - 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/amd64/kubectl + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/kubectl.sha256" \ + https://dl.k8s.io/release/v1.34.3/bin/linux/amd64/kubectl.sha256 + test "$(cat "${RUNNER_TEMP}/kubectl.sha256")" = \ + "$(sha256sum "${RUNNER_TEMP}/kubectl" | cut -d ' ' -f 1)" + chmod 0755 "${RUNNER_TEMP}/kubectl" + + - name: Fetch checksum-locked CloudNativePG operator manifest + shell: bash + run: | + set -euo pipefail + curl --fail --location --silent --show-error \ + --output "${RUNNER_TEMP}/cloudnative-pg.yaml" \ + https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml + echo "f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88 ${RUNNER_TEMP}/cloudnative-pg.yaml" | sha256sum --check + + - name: Recheck static Security Administration and evidence contracts + run: | + pnpm audit:security-administration-kubernetes:ql3 + node --test \ + test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs \ + test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs + + - name: Preload the digest-bound K3s distribution + run: docker pull rancher/k3s:v1.34.3-k3s1 + + - name: Prove Security Administration through K3s, PostgreSQL and PVC + env: + QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE: '1' + QL3_KUBECTL_BIN: ${{ runner.temp }}/kubectl + QL3_CNPG_OPERATOR_MANIFEST_FILE: ${{ runner.temp }}/cloudnative-pg.yaml + QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json + shell: bash + run: | + set -euo pipefail + umask 077 + mkdir -m 0700 "$(dirname "${QL3_SECURITY_ADMINISTRATION_REPORT}")" + pnpm test:security-administration-kubernetes-live:ql3 \ + "--report=${QL3_SECURITY_ADMINISTRATION_REPORT}" + + - name: Re-audit content-free report and isolated cleanup + env: + QL3_SECURITY_ADMINISTRATION_REPORT: ${{ runner.temp }}/ql3-security-administration/report.json + shell: bash + run: | + set -euo pipefail + test "$(stat -c '%a' "${QL3_SECURITY_ADMINISTRATION_REPORT}")" = '600' + pnpm audit:security-administration-kubernetes-live:ql3 \ + "--report=${QL3_SECURITY_ADMINISTRATION_REPORT}" + sha256sum "${QL3_SECURITY_ADMINISTRATION_REPORT}" + test -z "$(docker ps -aq --filter name=ql3-security-live-)" + test -z "$(docker network ls -q --filter name=ql3-security-live-)" + docker volume ls --quiet --filter dangling=true | sort > \ + "${RUNNER_TEMP}/ql3-dangling-volumes.after" + diff --unified \ + "${RUNNER_TEMP}/ql3-dangling-volumes.before" \ + "${RUNNER_TEMP}/ql3-dangling-volumes.after" + docker system df + + - name: Upload audited content-free Security Administration evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-security-administration-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-security-administration/report.json + if-no-files-found: error + retention-days: 14 + compression-level: 9 + overwrite: false + include-hidden-files: false diff --git a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml index e48fc8b9..0f3db1c5 100644 --- a/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml @@ -29,6 +29,7 @@ spec: runAsUser: 10001 runAsGroup: 10001 fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch seccompProfile: type: RuntimeDefault initContainers: diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index cf114032..5e7b8ef9 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,7 +11,7 @@ 最新增量证据(2026-08-25): -- D-406/ADR-0501(进行中:静态部署契约已验收,live gate pending):Cluster Security Administration 现在有可选的一次性 Kubernetes Job,而不是要求每个部署者自行拼装。通用 PostgreSQL、CloudNativePG、credential delivery 与组合入口均显式 opt-in,不进入共享 operations;因此 Edge/Standalone 以及默认 Cluster 的 package、依赖、启动路径和常驻资源仍为零增量。Job 固定 non-root/read-only/drop-all、无 ServiceAccount token/RBAC、`backoffLimit=0`、300 秒 deadline、600 秒 TTL、每容器 25m/48 MiB request 和 250m/128 MiB limit;主命令仍只打开一个 admin PostgreSQL connection。新增 stager 保留 kubelet Secret symlink 兼容性,同时以 realpath confinement、`O_NOFOLLOW`、读前/读后 inode 复验和独立大小上限把四个输入复制成 memory-backed `0700/0600` 私有边界,失败时主容器不可启动。issue/rotate 才选择 PVC delivery component,token 只进入唯一 `0600` no-replace 文件。6 个 stager/CLI 测试、4 个静态审计及四种 `kubectl kustomize` 渲染均通过;18-package clean build/test 退出 0,当前 `cluster-admin` 为 `454 total / 451 pass / 3 conditional skip / 0 fail`,backend 为 `1579 total / 1577 pass / 2 conditional skip / 0 fail`,122-module Edge import、Cluster dependency、package boundary、deployment、deployment-lock 与 release-version 审计全部 compatible。本地 Admin 镜像 `sha256:5464f0bbf5aa1302c080b13c9f18aaa89ba418ab5d09a917f5b5b4937c0ede2f` 在 non-root/read-only、无网络/能力、128 MiB/0.25 CPU 下包含并运行固定 stager 入口。真实 K3s + PostgreSQL register/query/issue/replay/revoke、PVC custody、response-loss 与清理证据尚未执行,所以 D-406 不冒充完整生产 ceremony,也不关闭双人复核/break-glass、pepper rotation、audit retention/export/alert 或远程 UI/API 门禁。 +- D-406/ADR-0501(已验收):Cluster Security Administration 现在有可选的一次性 Kubernetes Job,而不是要求每个部署者自行拼装。通用 PostgreSQL、CloudNativePG、credential delivery 与组合入口均显式 opt-in,不进入共享 operations;因此 Edge/Standalone 以及默认 Cluster 的 package、依赖、启动路径和常驻资源仍为零增量。Job 固定 non-root/read-only/drop-all、无 ServiceAccount token/RBAC、`backoffLimit=0`、300 秒 deadline、600 秒 TTL、每容器 25m/48 MiB request 和 250m/128 MiB limit;主命令仍只打开一个 admin PostgreSQL connection。stager 保留 kubelet Secret symlink 兼容性,同时以 realpath confinement、`O_NOFOLLOW`、读前/读后 inode 复验和独立大小上限把四个输入复制成 memory-backed `0700/0600` 私有边界,失败时主容器不可启动;issue/rotate 才选择 PVC delivery component,token 只进入唯一 `0600` no-replace 文件。真实 arm64 live gate 已在 1 control-plane + 2 worker 的 K3s `v1.34.3+k3s1`、Flannel、CloudNativePG 1.30.0、3 个 PostgreSQL 18.4 实例、migration 71/control-core capability 70 上完成 register、audit query、issue、response-loss exact replay、rotate、revoke;证明 kubelet `0440` immutable Secret 投影可被私有化、`0444` 扩权会在主容器启动前失败、RWO PVC 跨 Job 保留 `0700/0600` no-replace 交付、Kubernetes API/公网 egress 与 Secret/Job RBAC 均被拒绝。全部管理 Job 继续以 UID/GID 10001 运行;仅 live local-path fixture 用一个无网络、无 API token、drop-all 的 root Job 把实现特有的 `02777 root:10001` PVC 根收紧为 `02770`,不能据此声称生产 CSI 加密或 custody 已验收。content-free `0600` 报告独立审计为 `compatible=true/findings=[]`,SHA-256 为 `e5c24af77034e1a2efee062107176e218c11a9f9f0d6c8c7308fdc280b0a82cf`;18-package clean build/test 退出 0,当前 `cluster-admin` 为 `456 total / 453 pass / 3 conditional skip / 0 fail`,backend 为 `1590 total / 1588 pass / 2 conditional skip / 0 fail`。D-406 关闭单主机 K3s/CNPG/PVC 的产品命令与权限边界门,但不冒充生产 Kubernetes control-plane HA、跨主机 STONITH/DR、加密 CSI、外部 IdP、双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 或远程 UI/API。 - D-405/ADR-0500(已验收):Cluster Identity/API Credential/Security Audit 现在有首个受审产品入口。`ql3-security-admin` 由既有 `ql3-cluster-admin security` facade 到达同一安装内的固定 target,不新建单文件 package,也不进入常驻 `cluster-control`;每次只执行一个 exact-shape 的 identity register/enable/disable、credential issue/rotate/revoke 或最多 200 条的 keyset audit query,随后关闭单连接 admin PostgreSQL authority。命令使用独立 `ql3-security-administration+jwt` type、`security-administration` purpose、audience 与 generation/revocation ledger,拒绝其他管理面的 assertion;command、assertion、keyset 与 pepper 均来自显式有界文件,不发现 home、ambient Kubernetes context 或默认 credential。issue/rotate token 只经私有目录内 `0600`、`fsync`、hard-link no-replace 文件交付,stdout 仅含 basename 与 SHA-256;精确重放的 `token=null` 不重新生成或发布 secret。默认 PostgreSQL `verify-full`,Pool 最大一个连接,无 listener、daemon、timer、watcher 或后台 retention。`cluster-admin` 完整回归为 `448 total / 445 pass / 3 conditional skip / 0 fail`,新增聚焦测试 19/19、产品 facade 4/4、backend product contract 4/4;18-package clean build/test 退出 0,backend 为 `1575 total / 1573 pass / 2 conditional skip / 0 fail`。真实 arm64 Admin 镜像在 non-root、read-only、无网络/能力、128 MiB/0.25 CPU 下通过 12-command live contract;PostgreSQL 18.6 arm64 physical HA timeline 1→2 的 147 gates 全部通过,报告 SHA-256 为 `8fbb606773080dae15de5e31db5726abb8700862b51e4616b5f3f50e0b8374f3`。package、Cluster dependency、122-module Edge import、部署、镜像与 service bridge 审计全部 compatible,Edge/Standalone package、依赖和常驻资源不变。本切片关闭 ADR-0050“只有 application service、没有受审产品入口”的缺口,但不冒充远程 HTTP/API/UI、双人复核/break-glass、pepper rotation、audit retention/export/alert 或完整 Kubernetes Job ceremony。 @@ -10078,7 +10078,7 @@ PR-8 的本机最新增量由 ADR-0075/0076/0077/0078/0079/0080/0081/0082/0083/0 ADR-0087 Owner package 更新同样适用于上段 PR-8 累计描述:现行产品 CLI 只能经 console facade 到达其内部 bootstrap/credential-recovery,三个历史 ceremony package 名都只表示旧切片;`ql3-owner-gc` 由 maintenance 直接提供,不再拥有独立 importer。 -PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500/0501 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成;D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLI,credential token 只向私有 no-replace 文件交付。D-406 又补齐默认不安装、无 Kubernetes API authority 的一次性 Job、私密输入 stager、CloudNativePG 与 PVC delivery 静态契约;其真实 K3s + PostgreSQL/PVC live ceremony 仍是当前 Gate。远程 API/UI、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert 也仍未完成;不得把短生命周期 CLI、静态 Job、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。 +PR-8 的 cluster ADR-0049 未完成项由 ADR-0050/0051/0500/0501 继续收敛:Identity register/enable/disable、credential issue/rotate/revoke、mutation ledger、强 actor、同事务 audit、有界 audit query,以及常驻 `/api/v3` 的认证前 overload shield 已孵化完成;D-405 新增默认无 listener、一次只执行一个命令并关闭单连接 admin authority 的 `ql3-security-admin` 产品 CLI,credential token 只向私有 no-replace 文件交付。D-406 又补齐默认不安装、无 Kubernetes API authority 的一次性 Job、私密输入 stager、CloudNativePG 与 PVC delivery 契约,并已由真实三节点 K3s、三实例 PostgreSQL 18.4、response-loss replay、PVC custody、网络/RBAC 拒绝与失败清理 live ceremony 验收。该单 Docker host fixture 仍不证明生产 control-plane HA、跨主机 STONITH/DR 或加密 CSI;远程 API/UI、管理入口独立 rate limit、双人复核或 break-glass、pepper rotation、audit retention/export/alert 也仍未完成。不得把短生命周期 CLI、一次性 Job、application service 或 process-local HTTP shield 的存在解释为 cluster-control 已获得管理 authority 或全局 quota。 未进入当前孵化切片的代码在通过对应 Gate 前必须保持不可达:不得仅因 schema、service 或 Primary 编排器已存在,就让旧 Controller、Scheduler、gRPC callback 或 Shell 脚本直接写入新状态表或调用新 Executor。已接入的 Shadow 观察只能通过默认关闭的 Feature Flag 和 origin owner 决策到达,不得调用 Executor、再次 spawn 或改变 Legacy 返回结果。manual `runSingle` 只增加 owner selection seam;默认没有 router。HTTP bootstrap 每次启动只读取一次 manifest,缺失、禁用、拒绝或非 primary 时保持 Legacy 且不加载重组件;显式 accepted manual primary 会在恢复门禁通过后安装唯一 owner,选中后禁止回退双跑。ADR-0445 已让 `ScheduleService.runTask` 的 subscription/system/script 在显式 origin flag 下只观察同一个 Legacy ChildProcess;system crond 的 `scheduled_system`、once/boot/grpc 与这些来源的 Primary owner 切换仍须独立门禁。 diff --git a/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md b/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md index a8a38dbe..f531d1f8 100644 --- a/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md +++ b/docs/adr/ADR-0501-opt-in-kubernetes-security-administration-job.md @@ -60,10 +60,12 @@ Identity 变更、revoke 和 audit query 使用无 delivery 的 base。只有 `c - stager 聚焦测试覆盖真实 kubelet symlink 布局、`0700/0600` 收紧、持久 delivery 目录复验、realpath 逃逸、world-readable material、目标不可覆盖和 CLI 无敏感回显。 - 部署审计冻结无 API token/RBAC、caller-driven/零重试/deadline/TTL、non-root/read-only/drop-all、资源上限、固定 CLI、内存私有输入、独立 admin credential、CloudNativePG egress、PVC delivery 和默认聚合不可达;失败注入覆盖权限扩大、非持久 delivery 与误入共享 aggregate。 - `kubectl kustomize` 已分别渲染 base、CloudNativePG、credential-delivery 和 CloudNativePG + delivery 四个入口。 -- 18-package clean build/test 退出 0;当前 `cluster-admin` 为 454 total / 451 pass / 3 conditional skip / 0 fail,backend 为 1579 total / 1577 pass / 2 conditional skip / 0 fail。Edge import 仍为 122 modules,Cluster dependency、package boundary、deployment、deployment-lock source surface 与 release-version 审计均 compatible。 -- 本地构建的 Cluster Admin 镜像 digest 为 `sha256:5464f0bbf5aa1302c080b13c9f18aaa89ba418ab5d09a917f5b5b4937c0ede2f`;新 stager 在 non-root、read-only rootfs、`network=none`、drop-all、no-new-privileges、32 PID、128 MiB 与 0.25 CPU 下完成独立 `--help` smoke,证明发布镜像包含该固定入口。 -- 真实 K3s Pod、PostgreSQL admin operation、PVC token custody、response loss 与清理演练尚未执行,因此不得把本 ADR 解释为 live ceremony 已验收。 +- 18-package clean build/test 退出 0;当前 `cluster-admin` 为 456 total / 453 pass / 3 conditional skip / 0 fail,backend 为 1590 total / 1588 pass / 2 conditional skip / 0 fail。 +- opt-in live gate 在本机 arm64 建立 1 control-plane + 2 worker 的 K3s `v1.34.3+k3s1`/Flannel、CloudNativePG 1.30.0 和 3 个 PostgreSQL 18.4 实例;migration 71 与 control-core capability 70 通过后,6 个串行产品 Job 完成 register、audit query、issue、exact replay、rotate 与 revoke,另一个 `0444` 输入 Job 按预期在 init 阶段失败且主容器未启动。 +- live gate 证明 immutable Secret 的真实 kubelet Atomic Writer 投影、memory-backed 私有 stage、PVC 跨 Job persistence/no-replace replay、不同 rotation material、TLS 与单连接最小权限;同时实测拒绝 Kubernetes API/公网 egress、Secret read/Job mutation RBAC,并在 finally 删除 Job、Secret、证据 Job、fixture root Job、PVC、K3s 容器、网络与卷。 +- 所有 Security Administration Job 继续以 UID/GID 10001 运行并使用 `fsGroupChangePolicy=OnRootMismatch`,避免后续只读证据 Pod 递归改写 `0700/0600` custody。K3s local-path 实现把新 PVC 根暴露为 `02777 root:10001`,live gate 因此先用一个专用、无网络、无 API token、drop-all 的 root fixture Job 精确收紧为 `02770`;该 fixture 例外不是产品管理 authority,也不证明生产 CSI 加密或权限模型。 +- content-free 报告权限为 `0600`,独立审计结果为 `compatible=true/findings=[]`,SHA-256 为 `e5c24af77034e1a2efee062107176e218c11a9f9f0d6c8c7308fdc280b0a82cf`。可手动触发的专用 CI workflow 固定 K3s/CNPG/PostgreSQL 供应链并重复同一 ceremony。 ## 影响与剩余门禁 -D-406 关闭“每个部署者都要从零编写一次性 Admin Job”的静态部署缺口,且 Edge/Standalone 和默认 Cluster 常驻资源保持不变。下一门是以临时 K3s + PostgreSQL 执行 register/query/issue/replay/revoke、证明 token 仅存在于 PVC no-replace 文件、Pod 无 API authority、失败清理和证据 content-free;之后仍有双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 和远程管理 UI/API。 +D-406 已关闭“每个部署者都要从零编写一次性 Admin Job”及其单主机 K3s/CNPG/PVC live 缺口,且 Edge/Standalone 和默认 Cluster 常驻资源保持不变。该 gate 不代表生产 Kubernetes control-plane HA、跨主机故障隔离/STONITH、灾备恢复、加密 CSI custody 或外部 IdP 已完成;之后仍有双人复核/break-glass、pepper rotation、audit retention/export/alert、并发 dispatch 和远程管理 UI/API。 diff --git a/docs/operations/ql3-cluster-security-administration.md b/docs/operations/ql3-cluster-security-administration.md index 063d07ca..cd735b75 100644 --- a/docs/operations/ql3-cluster-security-administration.md +++ b/docs/operations/ql3-cluster-security-administration.md @@ -149,8 +149,8 @@ kubectl logs job/ql3-security-administration -n qinglong3-system \ -c administrator ``` -当前固定资源名只允许串行执行。收集 content-free 结果和(仅 issue/rotate)PVC 中的 `0600` delivery 文件后,删除 Job 与本次 immutable input Secret;不得重用 assertion、把 token 复制到终端输出,或以 `kubectl apply` 修改旧 Job。真实 K3s + PostgreSQL live ceremony 尚未验收,生产启用前仍需完成 ADR-0501 的 live gate。 +当前固定资源名只允许串行执行。收集 content-free 结果和(仅 issue/rotate)PVC 中的 `0600` delivery 文件后,删除 Job 与本次 immutable input Secret;不得重用 assertion、把 token 复制到终端输出,或以 `kubectl apply` 修改旧 Job。ADR-0501 已由三节点 K3s、三实例 CloudNativePG/PostgreSQL、真实 kubelet Secret 投影和 RWO PVC 完成一次端到端 live 验收;它验证的是应用契约与权限边界,不替代生产 control-plane HA、跨主机 STONITH/DR、加密 CSI 和外部 IdP 验收。 ## 当前边界 -本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert。可选 Job 已有受审静态部署契约,但不默认安装,真实 K3s + PostgreSQL/PVC ceremony 仍待验收;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md)。 +本入口没有远程 API/UI、双人复核或 break-glass、pepper rotation、audit retention/export/alert。可选 Job 的静态契约与单主机 K3s + PostgreSQL/PVC ceremony 已验收,但仍不默认安装,也不证明生产基础设施 HA/DR 或存储加密;admin database credential 始终不得进入常驻 Cluster Control。命令决策见 [ADR-0500](../adr/ADR-0500-short-lived-cluster-security-administration-command.md),部署决策见 [ADR-0501](../adr/ADR-0501-opt-in-kubernetes-security-administration-job.md)。 diff --git a/package.json b/package.json index 6686b651..8eb30098 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,8 @@ "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:security-administration-kubernetes:ql3": "node scripts/ql3-security-administration-kubernetes-audit.cjs", + "test:security-administration-kubernetes-live:ql3": "node scripts/ql3-security-administration-kubernetes-live-contract.cjs", + "audit:security-administration-kubernetes-live:ql3": "node scripts/ql3-security-administration-kubernetes-live-audit.cjs", "audit:cluster-copilot-console:ql3": "node scripts/ql3-cluster-copilot-console-audit.cjs", "audit:cluster-copilot-console-distribution:ql3": "node scripts/ql3-cluster-copilot-console-distribution-audit.cjs", "evidence:cluster-admin-release-workstation:ql3": "node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs", diff --git a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts index f2a9a764..918f7617 100644 --- a/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts +++ b/packages/ql3-cluster-admin/src/security-administration/clusterAdministrationKubernetesInputStage.ts @@ -119,13 +119,23 @@ function sameFileState( ); } +export function isClusterAdministrationProjectedSourceDirectoryAuthority( + status: Readonly<{ mode: number; uid: number }>, +): boolean { + const permissions = status.mode & 0o1777; + return ( + (permissions & 0o002) === 0 || + (status.uid === 0 && permissions === 0o1777) + ); +} + function verifySourceDirectory(sourceDirectory: string): string { const status = lstatSync(sourceDirectory, { throwIfNoEntry: false }); if ( status === undefined || !status.isDirectory() || status.isSymbolicLink() || - (status.mode & 0o002) !== 0 + !isClusterAdministrationProjectedSourceDirectoryAuthority(status) ) { throw new ClusterAdministrationKubernetesInputStageError( 'projected source directory authority is invalid', @@ -245,7 +255,7 @@ function verifyWritableParent(parent: string, label: string): void { status === undefined || !status.isDirectory() || status.isSymbolicLink() || - (status.mode & 0o002) !== 0 + !isClusterAdministrationProjectedSourceDirectoryAuthority(status) ) { throw new ClusterAdministrationKubernetesInputStageError( `${label} parent authority is invalid`, @@ -290,6 +300,7 @@ function createPrivateDirectory(directory: string, label: string): void { } function prepareDeliveryDirectory(directory: string): void { + verifyWritableParent(dirname(directory), 'delivery directory'); const existing = lstatSync(directory, { throwIfNoEntry: false }); if (existing === undefined) { createPrivateDirectory(directory, 'delivery directory'); diff --git a/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs b/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs index 6eb7db7c..c96a2a6f 100644 --- a/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs +++ b/packages/ql3-cluster-admin/test/clusterAdministrationKubernetesInputStage.test.cjs @@ -17,6 +17,7 @@ const { afterEach, test } = require('node:test'); const { ClusterAdministrationKubernetesInputStageError, + isClusterAdministrationProjectedSourceDirectoryAuthority, stageClusterAdministrationKubernetesInputs, } = require('../dist/security-administration/clusterAdministrationKubernetesInputStage.js'); @@ -87,6 +88,44 @@ test('copies a Kubernetes projected Secret into a private immutable input bounda assert.equal(JSON.stringify(result).includes('A'.repeat(43)), false); }); +test('accepts only the exact root-owned sticky Kubernetes mount authority', () => { + assert.equal( + isClusterAdministrationProjectedSourceDirectoryAuthority({ + mode: 0o41777, + uid: 0, + }), + true, + ); + assert.equal( + isClusterAdministrationProjectedSourceDirectoryAuthority({ + mode: 0o43777, + uid: 0, + }), + true, + ); + assert.equal( + isClusterAdministrationProjectedSourceDirectoryAuthority({ + mode: 0o40777, + uid: 0, + }), + false, + ); + assert.equal( + isClusterAdministrationProjectedSourceDirectoryAuthority({ + mode: 0o41777, + uid: 10001, + }), + false, + ); + assert.equal( + isClusterAdministrationProjectedSourceDirectoryAuthority({ + mode: 0o40755, + uid: 10001, + }), + true, + ); +}); + test('prepares a private persistent delivery directory without weakening it', () => { const fixture = projectedInput(); @@ -109,6 +148,26 @@ test('prepares a private persistent delivery directory without weakening it', () assert.equal(mode(fixture.deliveryDirectory), 0o700); }); +test('rejects a precreated private delivery directory under an unsafe parent', () => { + const fixture = projectedInput(); + const unsafeParent = join(fixture.root, 'unsafe-delivery-parent'); + mkdirSync(unsafeParent, { mode: 0o777 }); + chmodSync(unsafeParent, 0o777); + const deliveryDirectory = join(unsafeParent, 'private'); + mkdirSync(deliveryDirectory, { mode: 0o700 }); + + assert.throws( + () => + stageClusterAdministrationKubernetesInputs({ + sourceDirectory: fixture.sourceDirectory, + targetDirectory: fixture.targetDirectory, + deliveryDirectory, + }), + /delivery directory parent authority is invalid/, + ); + assert.equal(mode(deliveryDirectory), 0o700); +}); + test('rejects a projected input symlink that escapes the Secret authority', () => { const fixture = projectedInput(); const external = join(fixture.root, 'external-command.json'); diff --git a/scripts/ql3-security-administration-kubernetes-audit.cjs b/scripts/ql3-security-administration-kubernetes-audit.cjs index 696c8a64..83d05212 100644 --- a/scripts/ql3-security-administration-kubernetes-audit.cjs +++ b/scripts/ql3-security-administration-kubernetes-audit.cjs @@ -123,6 +123,7 @@ function auditSecurityAdministrationKubernetes(options = {}) { pod?.securityContext?.runAsUser !== 10001 || pod?.securityContext?.runAsGroup !== 10001 || pod?.securityContext?.fsGroup !== 10001 || + pod?.securityContext?.fsGroupChangePolicy !== 'OnRootMismatch' || pod?.securityContext?.seccompProfile?.type !== 'RuntimeDefault' ) { findings.push( diff --git a/scripts/ql3-security-administration-kubernetes-live-audit.cjs b/scripts/ql3-security-administration-kubernetes-live-audit.cjs new file mode 100644 index 00000000..9f0f3ea7 --- /dev/null +++ b/scripts/ql3-security-administration-kubernetes-live-audit.cjs @@ -0,0 +1,460 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const FIXTURE = + 'qinglong/security-administration-kubernetes-live-contract@v1'; +const LIMITATIONS = Object.freeze([ + 'three privileged K3s Docker nodes are not production infrastructure or control-plane HA evidence', + 'the strong-User assertion is issued by a local deterministic ceremony rather than a production external IdP', + 'CloudNativePG inside one Docker host is not infrastructure STONITH or disaster-recovery evidence', + 'the local-path ReadWriteOnce volume is not encrypted production CSI custody evidence', + 'a dedicated root storage-fixture Job constrains the local-path volume root before every non-root administration Job', +]); +const BANNED_KEYS = new Set([ + 'assertion', + 'authorization', + 'bearer', + 'connectionstring', + 'dsn', + 'keyset', + 'kubeconfig', + 'password', + 'pepper', + 'privatekey', + 'secret', + 'tlskey', + 'token', +]); + +function finding(code, detail) { + return Object.freeze({ code, detail }); +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...expected].sort()) + ); +} + +function allTrue(value, keys) { + return exactKeys(value, keys) && keys.every((key) => value[key] === true); +} + +function isSha256(value) { + return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value); +} + +function isIsoTime(value) { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test( + value, + ) && + Number.isFinite(Date.parse(value)) + ); +} + +function containsSensitiveMaterial(value, key = '') { + if (BANNED_KEYS.has(key.toLowerCase())) return true; + if (typeof value === 'string') { + return ( + /postgres(?:ql)?:\/\/[^/\s]+:[^@\s]+@/i.test(value) || + /\bql3c_[A-Za-z0-9_-]{16,}\b/.test(value) || + /\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/.test( + value, + ) || + /-----BEGIN (?:CERTIFICATE|(?:RSA |EC |OPENSSH )?PRIVATE KEY)-----/.test( + value, + ) + ); + } + if (Array.isArray(value)) { + return value.some((entry) => containsSensitiveMaterial(entry)); + } + if (value && typeof value === 'object') { + return Object.entries(value).some(([childKey, child]) => + containsSensitiveMaterial(child, childKey), + ); + } + return false; +} + +function validKubernetesVersion(value) { + const match = + typeof value === 'string' + ? /^v1\.([0-9]{2,3})\.([0-9]+)(?:[-+][0-9A-Za-z](?:[0-9A-Za-z.-]{0,62}[0-9A-Za-z])?)?$/.exec( + value, + ) + : null; + return Boolean(match && Number(match[1]) >= 32); +} + +function validateSecurityAdministrationKubernetesLiveReport(report) { + const findings = []; + if ( + !exactKeys(report, [ + 'schemaVersion', + 'fixture', + 'observedAt', + 'platform', + 'database', + 'ceremony', + 'inputBoundary', + 'deliveryCustody', + 'isolation', + 'durability', + 'cleanup', + 'gates', + 'limitations', + ]) || + report?.schemaVersion !== 1 || + report?.fixture !== FIXTURE || + !isIsoTime(report?.observedAt) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_REPORT_SHAPE', + 'the report must use the exact versioned live-contract envelope', + ), + ); + } + if (containsSensitiveMaterial(report)) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_MATERIAL_EXPOSURE', + 'the report must not contain credentials, assertions, database authority, kubeconfig or private key material', + ), + ); + } + + const platform = report?.platform; + if ( + !exactKeys(platform, [ + 'distribution', + 'kubernetesVersion', + 'architecture', + 'kubernetesImageId', + 'administrationImageId', + 'cniName', + 'cniDistributionBinding', + 'controlPlaneNodes', + 'workerNodes', + 'readyNodes', + ]) || + platform?.distribution !== 'k3s' || + !validKubernetesVersion(platform?.kubernetesVersion) || + !['amd64', 'arm64'].includes(platform?.architecture) || + !isSha256(platform?.kubernetesImageId) || + !isSha256(platform?.administrationImageId) || + platform?.cniName !== 'flannel' || + platform?.cniDistributionBinding !== 'rancher/k3s:v1.34.3-k3s1' || + platform?.controlPlaneNodes !== 1 || + platform?.workerNodes !== 2 || + platform?.readyNodes !== 3 + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_PLATFORM', + 'the fixture must bind three real K3s nodes, Flannel and exact local runtime images', + ), + ); + } + + const database = report?.database; + if ( + !exactKeys(database, [ + 'operator', + 'operatorVersion', + 'postgresVersionNumber', + 'postgresImageId', + 'instances', + 'readyInstances', + 'administrationRole', + 'roleConnectionLimit', + 'commandConnectionLimit', + 'migrationCount', + 'controlCoreCapability', + 'tlsVerified', + 'leastPrivilege', + ]) || + database?.operator !== 'cloudnative-pg' || + database?.operatorVersion !== '1.30.0' || + database?.postgresVersionNumber !== 180004 || + !isSha256(database?.postgresImageId) || + database?.instances !== 3 || + database?.readyInstances !== 3 || + database?.administrationRole !== 'ql3_admin' || + database?.roleConnectionLimit !== 4 || + database?.commandConnectionLimit !== 1 || + database?.migrationCount !== 71 || + database?.controlCoreCapability !== 70 || + database?.tlsVerified !== true || + database?.leastPrivilege !== true + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DATABASE', + 'three TLS CloudNativePG instances must expose only the reviewed one-connection administration authority', + ), + ); + } + + const ceremony = report?.ceremony; + if ( + !exactKeys(ceremony, [ + 'operations', + 'completedJobs', + 'failedJobs', + 'callerDriven', + 'backoffLimit', + 'activeDeadlineSeconds', + 'ttlSecondsAfterFinished', + 'serviceAccount', + 'serviceAccountTokenMounted', + 'rbacGranted', + 'responseLossReplayObserved', + 'sensitiveMaterialReported', + ]) || + JSON.stringify(ceremony?.operations) !== + JSON.stringify([ + 'identity.register', + 'audit.list', + 'credential.issue', + 'credential.issue.replay', + 'credential.rotate', + 'credential.revoke', + ]) || + ceremony?.completedJobs !== 6 || + ceremony?.failedJobs !== 1 || + ceremony?.callerDriven !== true || + ceremony?.backoffLimit !== 0 || + ceremony?.activeDeadlineSeconds !== 300 || + ceremony?.ttlSecondsAfterFinished !== 600 || + ceremony?.serviceAccount !== 'ql3-security-administration' || + ceremony?.serviceAccountTokenMounted !== false || + ceremony?.rbacGranted !== false || + ceremony?.responseLossReplayObserved !== true || + ceremony?.sensitiveMaterialReported !== false + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_CEREMONY', + 'six serial caller-created commands plus one failed input stage must use the exact tokenless Job contract', + ), + ); + } + + if ( + !allTrue(report?.inputBoundary, [ + 'immutableSecret', + 'projectedMode0440', + 'memoryBackedPrivateStage', + 'targetDirectoryMode0700', + 'targetFilesMode0600', + 'kubeletAtomicWriterProjectionAccepted', + 'worldReadableProjectionRejected', + 'mainContainerNotStartedAfterStageFailure', + ]) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_INPUT_BOUNDARY', + 'the real kubelet projection must stage privately and reject widened source permissions before the main container starts', + ), + ); + } + + const delivery = report?.deliveryCustody; + if ( + !exactKeys(delivery, [ + 'persistentVolumeClaim', + 'accessMode', + 'fixtureRootProvisioned', + 'fixtureRootMode', + 'fixtureProvisionerRanAsRoot', + 'privateDirectoryMode', + 'fileMode', + 'fileCount', + 'issueDigest', + 'rotationDigest', + 'distinctRotationMaterial', + 'persistentAcrossJobs', + 'noReplaceReplayPreserved', + 'deliverySchemaValidated', + 'bearerFormatValidatedInPod', + 'sensitiveMaterialReported', + ]) || + delivery?.persistentVolumeClaim !== true || + delivery?.accessMode !== 'ReadWriteOnce' || + delivery?.fixtureRootProvisioned !== true || + delivery?.fixtureRootMode !== '2770' || + delivery?.fixtureProvisionerRanAsRoot !== true || + delivery?.privateDirectoryMode !== '0700' || + delivery?.fileMode !== '0600' || + delivery?.fileCount !== 2 || + !isSha256(delivery?.issueDigest) || + !isSha256(delivery?.rotationDigest) || + delivery?.issueDigest === delivery?.rotationDigest || + ![ + 'distinctRotationMaterial', + 'persistentAcrossJobs', + 'noReplaceReplayPreserved', + 'deliverySchemaValidated', + 'bearerFormatValidatedInPod', + ].every((key) => delivery?.[key] === true) || + delivery?.sensitiveMaterialReported !== false + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DELIVERY', + 'issue and rotation material must remain distinct, private, persistent and no-replace on the caller-owned RWO volume', + ), + ); + } + + if ( + !allTrue(report?.isolation, [ + 'dnsAndDatabaseEgressAllowed', + 'kubernetesApiEgressDenied', + 'publicInternetEgressDenied', + 'secretReadRbacDenied', + 'jobMutationRbacDenied', + ]) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_ISOLATION', + 'Flannel and RBAC evidence must allow only DNS/PostgreSQL and deny ambient Kubernetes or public authority', + ), + ); + } + + const durability = report?.durability; + if ( + !exactKeys(durability, [ + 'identityVersion', + 'identityStatus', + 'credentialVersion', + 'credentialState', + 'identityMutationCount', + 'credentialMutationCount', + 'issueMutationCount', + 'credentialVersionCount', + 'allowedAuditCount', + ]) || + durability?.identityVersion !== 1 || + durability?.identityStatus !== 'active' || + durability?.credentialVersion !== 3 || + durability?.credentialState !== 'revoked' || + durability?.identityMutationCount !== 1 || + durability?.credentialMutationCount !== 3 || + durability?.issueMutationCount !== 1 || + durability?.credentialVersionCount !== 3 || + durability?.allowedAuditCount !== 4 + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DURABILITY', + 'the database must retain one identity mutation and exactly three credential generations without replay duplication', + ), + ); + } + + if ( + !allTrue(report?.cleanup, [ + 'jobsDeleted', + 'inputSecretsDeleted', + 'evidenceJobsDeleted', + 'storageProvisionJobDeleted', + 'deliveryVolumeClaimDeleted', + ]) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_CLEANUP', + 'all caller-created Jobs, inputs, evidence Pods and the fixture delivery claim must be removed', + ), + ); + } + + if ( + !allTrue(report?.gates, [ + 'realThreeNodeKubernetes', + 'realCloudNativePg', + 'realKubeletSecretProjection', + 'realAdministrationProductCommands', + 'realPersistentCredentialCustody', + 'responseLossReplay', + 'failedInputStageClosed', + 'leastPrivilege', + 'contentFreeEvidence', + 'passed', + ]) + ) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_GATES', + 'every real ceremony gate must be explicitly and truthfully closed', + ), + ); + } + + if (JSON.stringify(report?.limitations) !== JSON.stringify(LIMITATIONS)) { + findings.push( + finding( + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_LIMITATIONS', + 'the report must retain the exact non-production fixture limitations', + ), + ); + } + + return Object.freeze({ + schemaVersion: 1, + fixture: FIXTURE, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function reportPath(argv) { + if ( + argv.length !== 1 || + !argv[0].startsWith('--report=') || + !path.isAbsolute(argv[0].slice('--report='.length)) + ) { + throw new Error( + 'usage: ql3-security-administration-kubernetes-live-audit --report=/absolute/report.json', + ); + } + return argv[0].slice('--report='.length); +} + +if (require.main === module) { + try { + const report = JSON.parse(fs.readFileSync(reportPath(process.argv.slice(2)))); + const result = validateSecurityAdministrationKubernetesLiveReport(report); + process.stdout.write(`${JSON.stringify(result)}\n`); + if (!result.compatible) process.exitCode = 1; + } catch (error) { + process.stderr.write( + `security administration Kubernetes live audit failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 2; + } +} + +module.exports = { + FIXTURE, + LIMITATIONS, + validateSecurityAdministrationKubernetesLiveReport, +}; diff --git a/scripts/ql3-security-administration-kubernetes-live-contract.cjs b/scripts/ql3-security-administration-kubernetes-live-contract.cjs new file mode 100644 index 00000000..4ae8b7ca --- /dev/null +++ b/scripts/ql3-security-administration-kubernetes-live-contract.cjs @@ -0,0 +1,1389 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const { + K3sDockerLiveFixture, + run, + waitFor, +} = require('./lib/ql3-k3s-docker-live.cjs'); +const { + applySecret, + currentPrimaryPod, + imageIdDigest, + localManifest, + psql, +} = require('./lib/ql3-management-kubernetes-live-platform.cjs'); +const { + podReady, +} = require('./lib/ql3-management-kubernetes-live.cjs'); +const { + createManagementIdentityCeremony, +} = require('./lib/ql3-management-live-identity.cjs'); +const { + imageDigest, + imageTag, + reviewedOperatorManifest, +} = require('./ql3-cloudnativepg-live-contract.cjs'); +const { + FIXTURE, + LIMITATIONS, + validateSecurityAdministrationKubernetesLiveReport, +} = require('./ql3-security-administration-kubernetes-live-audit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const NAMESPACE = 'qinglong3-system'; +const NAME = 'ql3-security-administration'; +const POSTGRES_CLUSTER = 'ql3-postgres'; +const DELIVERY_CLAIM = 'ql3-security-administration-delivery'; +const ADMIN_IMAGE_BASE = 'ql3-security-administration-live'; +const CONTROL_IMAGE_BASE = 'ql3-security-administration-migration-live'; +const ISSUER = 'https://identity.qinglong.test/'; +const ROLE_NAMES = Object.freeze([ + 'ql3_migration', + 'ql3_ai_maintenance', + 'ql3_ai_credential_manager', + 'ql3_ai_credential_tester', + 'ql3_runtime', + 'ql3_admin', + 'ql3_package_manager', + 'ql3_package_executor', + 'ql3_automation_manager', + 'ql3_approval_manager', + 'ql3_run_manager', + 'ql3_worker_credential_manager', + 'ql3_worker_credential_executor', + 'ql3_worker_ingress', +]); +const LOCK = JSON.parse( + fs.readFileSync( + path.join( + ROOT, + 'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg/operator-lock.json', + ), + 'utf8', + ), +); +const OPERATOR_IMAGE = LOCK.operator.image; +const POSTGRES_IMAGE = LOCK.operand.image; +const identity = createManagementIdentityCeremony({ + issuer: ISSUER, + audience: 'qinglong3-security-administration', + purpose: 'security-administration', + tokenType: 'ql3-security-administration+jwt', + subject: 'security-owner', + jtiPrefix: 'ql3-security-administration-live', +}); + +function randomSecret() { + return crypto.randomBytes(32).toString('base64url'); +} + +function sqlString(value) { + assert.equal(typeof value, 'string'); + return `'${value.replaceAll("'", "''")}'`; +} + +function identityRegisterCommand(values) { + return Object.freeze({ + schemaVersion: 1, + operation: 'identity.register', + request: Object.freeze({ + mutationId: values.identityMutationId, + requestId: values.registerRequestId, + expectedCurrentVersion: 0, + subject: values.subject, + }), + }); +} + +function auditListCommand() { + return Object.freeze({ + schemaVersion: 1, + operation: 'audit.list', + request: Object.freeze({ + limit: 25, + filter: Object.freeze({ outcome: 'allowed' }), + }), + }); +} + +function credentialIssueCommand(values) { + return Object.freeze({ + schemaVersion: 1, + operation: 'credential.issue', + request: Object.freeze({ + mutationId: values.issueMutationId, + requestId: values.issueRequestId, + expectedCurrentVersion: 0, + credentialId: values.credentialId, + subject: values.subject, + notBeforeAtMs: values.notBeforeAtMs, + expiresAtMs: values.expiresAtMs, + }), + }); +} + +function credentialRotateCommand(values) { + return Object.freeze({ + schemaVersion: 1, + operation: 'credential.rotate', + request: Object.freeze({ + mutationId: values.rotateMutationId, + requestId: values.rotateRequestId, + expectedCurrentVersion: 1, + credentialId: values.credentialId, + subject: values.subject, + notBeforeAtMs: values.notBeforeAtMs, + expiresAtMs: values.expiresAtMs, + }), + }); +} + +function credentialRevokeCommand(values) { + return Object.freeze({ + schemaVersion: 1, + operation: 'credential.revoke', + request: Object.freeze({ + mutationId: values.revokeMutationId, + requestId: values.revokeRequestId, + expectedCurrentVersion: 2, + credentialId: values.credentialId, + subject: values.subject, + }), + }); +} + +function privateReportPath(argv) { + if ( + argv.length !== 1 || + !argv[0].startsWith('--report=') || + !path.isAbsolute(argv[0].slice('--report='.length)) + ) { + throw new Error( + 'usage: ql3-security-administration-kubernetes-live-contract --report=/absolute/private-report.json', + ); + } + const reportFile = argv[0].slice('--report='.length); + if (fs.existsSync(reportFile)) { + throw new Error('refusing to overwrite the Security Administration report'); + } + const parent = fs.lstatSync(path.dirname(reportFile)); + if (!parent.isDirectory() || parent.isSymbolicLink()) { + throw new Error('Security Administration report parent must be real'); + } + return reportFile; +} + +function localAdministrationManifest(rendered, localImage) { + const placeholder = + 'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' + + '0'.repeat(64); + assert.equal(rendered.split(placeholder).length - 1, 2); + return rendered + .replaceAll(placeholder, localImage) + .replaceAll('imagePullPolicy: IfNotPresent', 'imagePullPolicy: Never'); +} + +function renderedResources(fixture, directory, localImage) { + const rendered = localAdministrationManifest( + fixture.kubectl(['kustomize', directory], { + capture: true, + quiet: true, + }).stdout, + localImage, + ); + const resources = []; + yaml.loadAll(rendered, (resource) => { + if (resource) resources.push(resource); + }); + return resources; +} + +function namedResource(resources, kind, name) { + const selected = resources.find( + (resource) => resource.kind === kind && resource.metadata?.name === name, + ); + assert.ok(selected, `${kind}/${name} is absent`); + return selected; +} + +function findNamed(values, name) { + const selected = values.find((value) => value.name === name); + assert.ok(selected, `${name} is absent`); + return selected; +} + +function inputAuthorityEvidenceSource() { + return `'use strict';const fs=require('node:fs');const path=require('node:path');const names=['command.json','assertion.jwt','keyset.json','pepper'];const source='/var/run/secrets/qinglong3/security-administration-projected';const parent='/var/run/qinglong3/security-administration-private';const target=parent+'/input';const deliveryRoot='/var/lib/qinglong3/security-administration-delivery';const deliveryPrivate=deliveryRoot+'/private';const facts=(value)=>{const status=fs.lstatSync(value);return{mode:(status.mode&0o7777).toString(8),uid:status.uid,gid:status.gid,directory:status.isDirectory(),file:status.isFile(),symlink:status.isSymbolicLink()}};const sourceReal=fs.realpathSync(source);const files=names.map((name)=>{const candidate=source+'/'+name;const resolved=fs.realpathSync(candidate);const relative=path.relative(sourceReal,resolved);return{name,link:facts(candidate),resolved:facts(resolved),confined:relative!==''&&relative!=='..'&&!relative.startsWith('../')&&!path.isAbsolute(relative)}});fs.writeFileSync('/dev/termination-log',JSON.stringify({schema:'qinglong/security-administration-input-authority@v1',source:facts(source),parent:facts(parent),targetExists:fs.existsSync(target),deliveryRoot:fs.existsSync(deliveryRoot)?facts(deliveryRoot):null,deliveryPrivateExists:fs.existsSync(deliveryPrivate),files}));`; +} + +function deliveryVolumeProvisionSource() { + return `'use strict';const fs=require('node:fs');const root='/delivery';const facts=()=>{const status=fs.lstatSync(root);return{mode:(status.mode&0o7777).toString(8),uid:status.uid,gid:status.gid,directory:status.isDirectory(),symlink:status.isSymbolicLink()}};const before=facts();if(!before.directory||before.symlink||before.uid!==0||before.gid!==10001||before.mode!=='2777')throw new Error('UNEXPECTED_VOLUME_ROOT');fs.chmodSync(root,0o2770);const descriptor=fs.openSync(root,fs.constants.O_RDONLY|(fs.constants.O_DIRECTORY??0));try{fs.fsyncSync(descriptor)}finally{fs.closeSync(descriptor)}const after=facts();if(!after.directory||after.symlink||after.uid!==0||after.gid!==10001||after.mode!=='2770')throw new Error('VOLUME_ROOT_NOT_CONSTRAINED');fs.writeFileSync('/dev/termination-log',JSON.stringify({schema:'qinglong/security-administration-delivery-volume-provision@v1',beforeMode:before.mode,afterMode:after.mode,uid:after.uid,gid:after.gid,passed:true}));`; +} + +function administrationJob( + template, + { name, inputSecretName, deliveryFile, projectedMode }, +) { + const job = structuredClone(template); + job.metadata.name = name; + delete job.metadata.uid; + delete job.metadata.resourceVersion; + job.spec.template.metadata.labels['ql3.live/job'] = name; + const projected = findNamed(job.spec.template.spec.volumes, 'projected-input'); + projected.secret.secretName = inputSecretName; + projected.secret.defaultMode = projectedMode ?? 0o440; + const stager = findNamed( + job.spec.template.spec.initContainers, + 'stage-private-input', + ); + stager.terminationMessagePolicy = 'FallbackToLogsOnError'; + const inspector = structuredClone(stager); + inspector.name = 'inspect-input-authority'; + inspector.command = ['node', '-e', inputAuthorityEvidenceSource()]; + inspector.args = []; + inspector.terminationMessagePolicy = 'File'; + inspector.volumeMounts = inspector.volumeMounts.filter((mount) => + ['projected-input', 'private-input', 'credential-delivery'].includes( + mount.name, + ), + ); + job.spec.template.spec.initContainers.unshift(inspector); + const administrator = findNamed( + job.spec.template.spec.containers, + 'administrator', + ); + administrator.terminationMessagePolicy = 'FallbackToLogsOnError'; + const deliveryIndex = administrator.args.findIndex((argument) => + argument.startsWith('--delivery='), + ); + if (deliveryFile === undefined) { + assert.equal(deliveryIndex, -1); + } else { + assert.ok(deliveryIndex >= 0); + administrator.args[deliveryIndex] = + `--delivery=/var/lib/qinglong3/security-administration-delivery/private/${deliveryFile}`; + } + return job; +} + +function administrationFailureEvidence(snapshot) { + const inspectorStatus = snapshot.pod.status.initContainerStatuses?.find( + (container) => container.name === 'inspect-input-authority', + ); + const initStatus = snapshot.pod.status.initContainerStatuses?.find( + (container) => container.name === 'stage-private-input', + ); + const initTerminated = initStatus?.state?.terminated; + const status = snapshot.pod.status.containerStatuses?.find( + (container) => container.name === 'administrator', + ); + const terminated = status?.state?.terminated; + const evidence = { + jobComplete: snapshot.complete, + jobFailed: snapshot.failed, + initExitCode: initTerminated?.exitCode ?? null, + initReason: + initTerminated?.reason ?? initStatus?.state?.waiting?.reason ?? null, + mainExitCode: terminated?.exitCode ?? null, + mainReason: terminated?.reason ?? status?.state?.waiting?.reason ?? null, + }; + let inputAuthority; + try { + const parsed = JSON.parse( + inspectorStatus?.state?.terminated?.message?.trim() || '', + ); + if (parsed.schema === 'qinglong/security-administration-input-authority@v1') { + inputAuthority = parsed; + } + } catch { + inputAuthority = 'unavailable'; + } + const message = ( + initTerminated?.message || terminated?.message || '' + ).trim(); + if (!message) return { ...evidence, inputAuthority }; + try { + const parsed = JSON.parse(message); + const allowedKeys = ['schemaVersion', 'component', 'event', 'name', 'code']; + if ( + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + Object.keys(parsed).some((key) => !allowedKeys.includes(key)) + ) { + return { ...evidence, inputAuthority, failureMessage: 'rejected' }; + } + return { ...evidence, inputAuthority, failureMessage: parsed }; + } catch { + return { ...evidence, inputAuthority, failureMessage: 'unparseable' }; + } +} + +async function terminalJobSnapshot(fixture, name, timeoutMs = 180_000) { + return ( + await waitFor(`${name} terminal`, timeoutMs, () => { + const job = fixture.kubectlJson(['-n', NAMESPACE, 'get', 'job', name]); + const complete = job.status.conditions?.some( + (condition) => + condition.type === 'Complete' && condition.status === 'True', + ); + const failed = job.status.conditions?.some( + (condition) => + condition.type === 'Failed' && condition.status === 'True', + ); + const pods = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + `batch.kubernetes.io/job-name=${name}`, + ]).items; + return (complete || failed) && pods.length === 1 + ? { ready: true, value: { job, pod: pods[0], complete, failed } } + : { + ready: false, + fact: `${pods.length} Pods; status=${JSON.stringify( + job.status ?? {}, + )}`, + }; + }) + ).value; +} + +function deleteResource(fixture, resource) { + fixture.kubectl( + [ + '-n', + NAMESPACE, + 'delete', + resource, + '--ignore-not-found=true', + '--wait=true', + ], + { capture: true, quiet: true }, + ); +} + +function resourceAbsent(fixture, resource) { + return ( + fixture.kubectl(['-n', NAMESPACE, 'get', resource], { + capture: true, + quiet: true, + allowFailure: true, + }).status !== 0 + ); +} + +async function runAdministrationJob({ + fixture, + template, + name, + command, + assertion, + keyset, + pepper, + deliveryFile, + expectedComplete, + projectedMode, + createdJobs, + createdSecrets, +}) { + const inputSecretName = `${name}-input`; + fixture.create({ + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: inputSecretName, namespace: NAMESPACE }, + immutable: true, + type: 'Opaque', + stringData: { + 'command.json': `${JSON.stringify(command)}\n`, + 'assertion.jwt': `${assertion}\n`, + 'keyset.json': `${JSON.stringify(keyset)}\n`, + pepper: `${pepper}\n`, + }, + }); + createdSecrets.add(inputSecretName); + const job = administrationJob(template, { + name, + inputSecretName, + deliveryFile, + projectedMode, + }); + fixture.create(job); + createdJobs.add(name); + try { + const snapshot = await terminalJobSnapshot(fixture, name); + const failureEvidence = JSON.stringify( + administrationFailureEvidence(snapshot), + ); + assert.equal(snapshot.complete, expectedComplete, failureEvidence); + assert.equal(snapshot.failed, !expectedComplete, failureEvidence); + assert.equal(snapshot.job.spec.backoffLimit, 0); + assert.equal(snapshot.job.spec.activeDeadlineSeconds, 300); + assert.equal(snapshot.job.spec.ttlSecondsAfterFinished, 600); + assert.equal(snapshot.pod.spec.serviceAccountName, NAME); + assert.equal(snapshot.pod.spec.automountServiceAccountToken, false); + assert.equal( + snapshot.pod.spec.volumes.some((volume) => + volume.projected?.sources?.some( + (source) => source.serviceAccountToken !== undefined, + ), + ), + false, + ); + const init = snapshot.pod.status.initContainerStatuses?.find( + (container) => container.name === 'stage-private-input', + ); + assert.ok(init?.state?.terminated); + const main = snapshot.pod.status.containerStatuses?.find( + (container) => container.name === 'administrator', + ); + if (expectedComplete) { + assert.equal(init.state.terminated.exitCode, 0); + assert.equal(main?.state?.terminated?.exitCode, 0); + } else { + assert.notEqual(init.state.terminated.exitCode, 0); + assert.equal(main?.state?.terminated, undefined); + assert.equal(main?.state?.running, undefined); + } + return snapshot; + } finally { + deleteResource(fixture, `job/${name}`); + createdJobs.delete(name); + deleteResource(fixture, `secret/${inputSecretName}`); + createdSecrets.delete(inputSecretName); + } +} + +function custodyEvidenceSource() { + return String.raw` +const crypto=require('node:crypto');const fs=require('node:fs');const net=require('node:net'); +const finish=(value,status)=>{fs.writeFileSync('/dev/termination-log',JSON.stringify(value),{encoding:'utf8',mode:0o600});process.exitCode=status}; +const connect=(host,port)=>new Promise((resolve)=>{let settled=false;const socket=net.createConnection({host,port});const done=(ok)=>{if(settled)return;settled=true;socket.destroy();resolve(ok)};socket.setTimeout(3000);socket.once('connect',()=>done(true));socket.once('timeout',()=>done(false));socket.once('error',()=>done(false));}); +(async()=>{let networkEvidence;try{const directory='/delivery/private';const expected=JSON.parse(process.argv[1]);const directoryStatus=fs.lstatSync(directory);if(!directoryStatus.isDirectory()||directoryStatus.isSymbolicLink()||(directoryStatus.mode&0o777)!==0o700||directoryStatus.uid!==process.geteuid())throw new Error('DIRECTORY');const names=fs.readdirSync(directory).sort();if(JSON.stringify(names)!==JSON.stringify(expected.map((entry)=>entry.name).sort()))throw new Error('COUNT');const files=[];for(const entry of expected){const file=directory+'/'+entry.name;const status=fs.lstatSync(file);if(!status.isFile()||status.isSymbolicLink()||(status.mode&0o777)!==0o600||status.uid!==process.geteuid()||status.nlink!==1||status.size<1||status.size>32768)throw new Error('FILE');const bytes=fs.readFileSync(file);try{const value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes));const keys=Object.keys(value).sort();const required=['credentialId','expiresAtMs','kind','mutationId','notBeforeAtMs','operation','requestId','schemaVersion','subject','token','version'].sort();if(JSON.stringify(keys)!==JSON.stringify(required)||value.schemaVersion!==1||value.kind!=='qinglong3-cluster-api-credential-delivery'||value.operation!==entry.operation||typeof value.token!=='string'||!/^ql3c_[A-Za-z0-9_-]+_[A-Za-z0-9_-]{43}$/.test(value.token))throw new Error('SCHEMA');files.push({name:entry.name,operation:entry.operation,digest:'sha256:'+crypto.createHash('sha256').update(bytes).digest('hex'),bytes:status.size,inode:String(status.ino),mtimeMs:status.mtimeMs});}finally{bytes.fill(0)}}const databaseConnected=await connect(process.argv[2],5432);const kubernetesApiConnected=await connect(process.argv[3],443);const publicInternetConnected=await connect('1.1.1.1',443);networkEvidence={databaseConnected,kubernetesApiConnected,publicInternetConnected};if(!databaseConnected||kubernetesApiConnected||publicInternetConnected)throw new Error('NETWORK');finish({schemaVersion:1,passed:true,directoryMode:'0700',fileMode:'0600',files,...networkEvidence},0)}catch(error){finish({schemaVersion:1,passed:false,code:error instanceof Error&&/^[A-Z]+$/.test(error.message)?error.message:'UNAVAILABLE',...(networkEvidence?{networkEvidence}: {})},1)}})();`; +} + +async function runCustodyEvidence({ + fixture, + adminImage, + name, + expected, + kubernetesServiceIp, + createdEvidenceJobs, +}) { + fixture.create({ + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name, namespace: NAMESPACE }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 60, + ttlSecondsAfterFinished: 600, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': NAME, + 'app.kubernetes.io/component': 'security-administration', + 'ql3.live/evidence': 'credential-custody', + }, + }, + spec: { + serviceAccountName: NAME, + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + fsGroup: 10001, + fsGroupChangePolicy: 'OnRootMismatch', + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'evidence', + image: adminImage, + imagePullPolicy: 'Never', + command: [ + 'node', + '-e', + custodyEvidenceSource(), + JSON.stringify(expected), + 'ql3-postgres-rw.qinglong3-system.svc', + kubernetesServiceIp, + ], + terminationMessagePolicy: 'File', + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '5m', memory: '16Mi' }, + limits: { cpu: '100m', memory: '64Mi' }, + }, + volumeMounts: [ + { + name: 'delivery', + mountPath: '/delivery', + readOnly: true, + }, + ], + }, + ], + volumes: [ + { + name: 'delivery', + persistentVolumeClaim: { claimName: DELIVERY_CLAIM }, + }, + ], + }, + }, + }, + }); + createdEvidenceJobs.add(name); + try { + const snapshot = await terminalJobSnapshot(fixture, name, 120_000); + const state = snapshot.pod.status.containerStatuses?.[0]?.state?.terminated; + const statusEvidence = state?.message || JSON.stringify({ + exitCode: state?.exitCode ?? null, + reason: state?.reason ?? null, + }); + assert.equal(snapshot.complete, true, statusEvidence); + assert.equal(snapshot.failed, false, statusEvidence); + assert.equal(state?.exitCode, 0, state?.message); + const evidence = JSON.parse(state.message); + assert.equal(evidence.schemaVersion, 1); + assert.equal(evidence.passed, true); + assert.equal(evidence.databaseConnected, true); + assert.equal(evidence.kubernetesApiConnected, false); + assert.equal(evidence.publicInternetConnected, false); + return evidence; + } finally { + deleteResource(fixture, `job/${name}`); + createdEvidenceJobs.delete(name); + } +} + +async function provisionDeliveryVolume({ + fixture, + adminImage, + createdEvidenceJobs, +}) { + const name = 'ql3-security-live-delivery-provision'; + fixture.create({ + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { + name, + namespace: NAMESPACE, + labels: { + 'app.kubernetes.io/name': NAME, + 'app.kubernetes.io/component': 'security-administration', + 'app.kubernetes.io/part-of': 'qinglong3', + 'ql3.live/fixture-role': 'storage-provision', + }, + }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 120, + ttlSecondsAfterFinished: 600, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': NAME, + 'app.kubernetes.io/component': 'security-administration', + 'app.kubernetes.io/part-of': 'qinglong3', + 'ql3.live/fixture-role': 'storage-provision', + }, + }, + spec: { + serviceAccountName: NAME, + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: 'Never', + securityContext: { + runAsUser: 0, + runAsGroup: 0, + fsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'storage-provision', + image: adminImage, + imagePullPolicy: 'Never', + command: ['node', '-e', deliveryVolumeProvisionSource()], + terminationMessagePolicy: 'File', + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '5m', memory: '16Mi' }, + limits: { cpu: '100m', memory: '64Mi' }, + }, + volumeMounts: [{ name: 'delivery', mountPath: '/delivery' }], + }, + ], + volumes: [ + { + name: 'delivery', + persistentVolumeClaim: { claimName: DELIVERY_CLAIM }, + }, + ], + }, + }, + }, + }); + createdEvidenceJobs.add(name); + try { + const snapshot = await terminalJobSnapshot(fixture, name, 120_000); + assert.equal(snapshot.complete, true); + assert.equal(snapshot.failed, false); + const state = snapshot.pod.status.containerStatuses?.[0]?.state?.terminated; + assert.equal(state?.exitCode, 0, state?.message); + const evidence = JSON.parse(state.message); + assert.deepEqual(evidence, { + schema: 'qinglong/security-administration-delivery-volume-provision@v1', + beforeMode: '2777', + afterMode: '2770', + uid: 0, + gid: 10001, + passed: true, + }); + return evidence; + } finally { + deleteResource(fixture, `job/${name}`); + createdEvidenceJobs.delete(name); + } +} + +function canI(fixture, verb, resource) { + const result = fixture.kubectl( + [ + 'auth', + 'can-i', + verb, + resource, + '-n', + NAMESPACE, + `--as=system:serviceaccount:${NAMESPACE}:${NAME}`, + ], + { capture: true, quiet: true, allowFailure: true }, + ); + const decision = result.stdout.trim(); + assert.ok(decision === 'yes' || decision === 'no'); + assert.equal(result.status === 0, decision === 'yes'); + return decision; +} + +async function main(argv = process.argv.slice(2)) { + const reportFile = privateReportPath(argv); + if (process.env.QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE !== '1') { + throw new Error( + 'Refusing to mutate Docker/Kubernetes without QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE=1', + ); + } + const operatorManifestFile = process.env.QL3_CNPG_OPERATOR_MANIFEST_FILE; + if (!operatorManifestFile) { + throw new Error('QL3_CNPG_OPERATOR_MANIFEST_FILE is required'); + } + const reviewedManifest = reviewedOperatorManifest(operatorManifestFile); + const fixture = new K3sDockerLiveFixture({ prefix: 'ql3-security-live' }); + const suffix = + process.pid.toString(36) + '-' + crypto.randomBytes(3).toString('hex'); + const adminImage = `${ADMIN_IMAGE_BASE}:${suffix}`; + const controlImage = `${CONTROL_IMAGE_BASE}:${suffix}`; + let adminImageBuilt = false; + let controlImageBuilt = false; + const createdJobs = new Set(); + const createdSecrets = new Set(); + const createdEvidenceJobs = new Set(); + try { + const nodes = await fixture.start(); + const architecture = fixture.inspectImage(fixture.k3sImage).Architecture; + assert.ok(['amd64', 'arm64'].includes(architecture)); + for (const reviewedImage of [OPERATOR_IMAGE, POSTGRES_IMAGE]) { + run(fixture.docker, ['pull', reviewedImage]); + const inspected = fixture.inspectImage(reviewedImage); + assert.ok( + inspected.RepoDigests?.some((entry) => + entry.endsWith(`@${imageDigest(reviewedImage)}`), + ), + ); + const preloadTag = imageTag(reviewedImage); + run(fixture.docker, ['tag', reviewedImage, preloadTag]); + fixture.loadImage(preloadTag, `${path.basename(preloadTag)}.tar`); + } + + const sourceRevision = run('git', ['rev-parse', 'HEAD'], { + capture: true, + quiet: true, + }).stdout; + for (const [dockerfile, image, archive] of [ + [ + 'deploy/containers/ql3-cluster-admin/Dockerfile', + adminImage, + 'security-administration-admin.tar', + ], + [ + 'deploy/containers/ql3-cluster-control/Dockerfile', + controlImage, + 'security-administration-control.tar', + ], + ]) { + run(fixture.docker, [ + 'build', + '--file', + dockerfile, + '--tag', + image, + '--build-arg', + `SOURCE_REVISION=${sourceRevision}`, + '.', + ]); + if (image === adminImage) adminImageBuilt = true; + else controlImageBuilt = true; + fixture.loadImage(image, archive); + } + const adminImageInfo = fixture.inspectImage(adminImage); + const postgresImageInfo = fixture.inspectImage(POSTGRES_IMAGE); + const k3sImageInfo = fixture.inspectImage(fixture.k3sImage); + + fixture.kubectl(['apply', '--server-side', '-f', reviewedManifest]); + fixture.kubectl([ + '-n', + 'cnpg-system', + 'set', + 'image', + 'deployment/cnpg-controller-manager', + `manager=${imageTag(OPERATOR_IMAGE)}`, + ]); + fixture.kubectl([ + 'wait', + '--for=condition=Established', + 'crd/clusters.postgresql.cnpg.io', + 'crd/databaseroles.postgresql.cnpg.io', + 'crd/databases.postgresql.cnpg.io', + '--timeout=5m', + ]); + fixture.kubectl([ + '-n', + 'cnpg-system', + 'rollout', + 'status', + 'deployment/cnpg-controller-manager', + '--timeout=5m', + ]); + + fixture.kubectl([ + 'apply', + '-f', + 'deploy/kubernetes/ql3-cluster/base/namespace.yaml', + ]); + fixture.kubectl([ + '-n', + NAMESPACE, + 'apply', + '-f', + 'deploy/kubernetes/ql3-cluster/base/service-account.yaml', + ]); + const passwords = Object.fromEntries( + ROLE_NAMES.map((role) => [role, randomSecret()]), + ); + for (const role of ROLE_NAMES) { + applySecret( + fixture, + `ql3-postgres-${role + .replace(/^ql3_/, '') + .replaceAll('_', '-')}-auth`, + 'kubernetes.io/basic-auth', + { username: role, password: passwords[role] }, + ); + } + const databaseManifest = fixture + .kubectl( + ['kustomize', 'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg'], + { capture: true, quiet: true }, + ) + .stdout.replace(POSTGRES_IMAGE, imageTag(POSTGRES_IMAGE)); + assert.equal(databaseManifest.includes(POSTGRES_IMAGE), false); + fixture.kubectl(['apply', '-f', '-'], { input: `${databaseManifest}\n` }); + fixture.kubectl([ + '-n', + NAMESPACE, + 'wait', + '--for=condition=Ready', + `cluster/${POSTGRES_CLUSTER}`, + '--timeout=20m', + ]); + const databasePods = ( + await waitFor('three ready CloudNativePG instances', 600_000, () => { + const pods = fixture + .kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + `cnpg.io/cluster=${POSTGRES_CLUSTER}`, + ]) + .items.filter(podReady); + return pods.length === 3 + ? { ready: true, value: pods } + : { ready: false, fact: `${pods.length}/3 Ready database Pods` }; + }) + ).value; + + const migrationManifest = localManifest( + fixture.kubectl( + [ + 'kustomize', + 'deploy/kubernetes/ql3-cluster/operations/cloudnative-pg', + ], + { capture: true, quiet: true }, + ).stdout, + 'registry.example.com/qinglong/qinglong3-cluster-control', + controlImage, + ); + fixture.kubectl(['create', '-f', '-'], { + input: `${migrationManifest}\n`, + }); + fixture.kubectl([ + '-n', + NAMESPACE, + 'wait', + '--for=condition=Complete', + 'job/ql3-cluster-migration', + '--timeout=10m', + ]); + const primary = currentPrimaryPod(fixture); + const migrationState = JSON.parse( + psql( + fixture, + primary.metadata.name, + [ + 'SELECT json_build_object(', + ` 'migrationCount', (SELECT count(*)::integer FROM "ql3"."schema_migrations"),`, + ` 'controlCoreCapability', (SELECT contract_version::integer FROM "ql3"."schema_capabilities" WHERE contract_name = 'control-core'),`, + ` 'postgresVersionNumber', current_setting('server_version_num')::integer)`, + ].join('\n'), + ), + ); + assert.deepEqual(migrationState, { + migrationCount: 71, + controlCoreCapability: 70, + postgresVersionNumber: 180004, + }); + + const baseResources = renderedResources( + fixture, + 'deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg', + adminImage, + ); + const deliveryResources = renderedResources( + fixture, + 'deploy/kubernetes/ql3-cluster/operations/security-administration/cloudnative-pg-credential-delivery', + adminImage, + ); + const serviceAccount = namedResource(baseResources, 'ServiceAccount', NAME); + const networkPolicy = namedResource(baseResources, 'NetworkPolicy', NAME); + const baseTemplate = namedResource(baseResources, 'Job', NAME); + const deliveryTemplate = namedResource(deliveryResources, 'Job', NAME); + fixture.apply(serviceAccount); + fixture.apply(networkPolicy); + fixture.create({ + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: DELIVERY_CLAIM, namespace: NAMESPACE }, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { requests: { storage: '16Mi' } }, + }, + }); + const deliveryProvision = await provisionDeliveryVolume({ + fixture, + adminImage, + createdEvidenceJobs, + }); + + const values = Object.freeze({ + subject: Object.freeze({ type: 'api_app', id: `d406-live-${suffix}` }), + credentialId: `d406-live-${suffix}`, + identityMutationId: crypto.randomUUID(), + issueMutationId: crypto.randomUUID(), + rotateMutationId: crypto.randomUUID(), + revokeMutationId: crypto.randomUUID(), + registerRequestId: `d406-register-${suffix}`, + issueRequestId: `d406-issue-${suffix}`, + rotateRequestId: `d406-rotate-${suffix}`, + revokeRequestId: `d406-revoke-${suffix}`, + }); + const key = identity.reviewedKey('security-administration-live-key-1'); + const keyset = identity.keyset(1, [key]); + const pepper = randomSecret(); + const register = identityRegisterCommand(values); + const revoke = credentialRevokeCommand(values); + + await runAdministrationJob({ + fixture, + template: baseTemplate, + name: 'ql3-security-live-invalid-input', + command: register, + assertion: identity.assertion(key, 'invalid-input'), + keyset, + pepper, + expectedComplete: false, + projectedMode: 0o444, + createdJobs, + createdSecrets, + }); + await runAdministrationJob({ + fixture, + template: baseTemplate, + name: 'ql3-security-live-register', + command: register, + assertion: identity.assertion(key, 'register'), + keyset, + pepper, + expectedComplete: true, + createdJobs, + createdSecrets, + }); + await runAdministrationJob({ + fixture, + template: baseTemplate, + name: 'ql3-security-live-audit', + command: auditListCommand(), + assertion: identity.assertion(key, 'audit'), + keyset, + pepper, + expectedComplete: true, + createdJobs, + createdSecrets, + }); + const issueNotBeforeAtMs = Date.now() + 5 * 60 * 1000; + const issue = credentialIssueCommand({ + ...values, + notBeforeAtMs: issueNotBeforeAtMs, + expiresAtMs: issueNotBeforeAtMs + 60 * 60 * 1000, + }); + const issueAssertion = identity.assertion(key, 'issue-response-loss'); + await runAdministrationJob({ + fixture, + template: deliveryTemplate, + name: 'ql3-security-live-issue', + command: issue, + assertion: issueAssertion, + keyset, + pepper, + deliveryFile: 'issue.json', + expectedComplete: true, + createdJobs, + createdSecrets, + }); + const kubernetesServiceIp = fixture.kubectlJson([ + '-n', + 'default', + 'get', + 'service', + 'kubernetes', + ]).spec.clusterIP; + const issuedEvidence = await runCustodyEvidence({ + fixture, + adminImage, + name: 'ql3-security-live-evidence-issued', + expected: [{ name: 'issue.json', operation: 'credential.issue' }], + kubernetesServiceIp, + createdEvidenceJobs, + }); + await runAdministrationJob({ + fixture, + template: deliveryTemplate, + name: 'ql3-security-live-issue-replay', + command: issue, + assertion: issueAssertion, + keyset, + pepper, + deliveryFile: 'issue.json', + expectedComplete: true, + createdJobs, + createdSecrets, + }); + const replayEvidence = await runCustodyEvidence({ + fixture, + adminImage, + name: 'ql3-security-live-evidence-replay', + expected: [{ name: 'issue.json', operation: 'credential.issue' }], + kubernetesServiceIp, + createdEvidenceJobs, + }); + assert.deepEqual(replayEvidence.files, issuedEvidence.files); + const rotateNotBeforeAtMs = Date.now() + 5 * 60 * 1000; + const rotate = credentialRotateCommand({ + ...values, + notBeforeAtMs: rotateNotBeforeAtMs, + expiresAtMs: rotateNotBeforeAtMs + 60 * 60 * 1000, + }); + await runAdministrationJob({ + fixture, + template: deliveryTemplate, + name: 'ql3-security-live-rotate', + command: rotate, + assertion: identity.assertion(key, 'rotate'), + keyset, + pepper, + deliveryFile: 'rotate.json', + expectedComplete: true, + createdJobs, + createdSecrets, + }); + await runAdministrationJob({ + fixture, + template: baseTemplate, + name: 'ql3-security-live-revoke', + command: revoke, + assertion: identity.assertion(key, 'revoke'), + keyset, + pepper, + expectedComplete: true, + createdJobs, + createdSecrets, + }); + const finalEvidence = await runCustodyEvidence({ + fixture, + adminImage, + name: 'ql3-security-live-evidence-final', + expected: [ + { name: 'issue.json', operation: 'credential.issue' }, + { name: 'rotate.json', operation: 'credential.rotate' }, + ], + kubernetesServiceIp, + createdEvidenceJobs, + }); + assert.equal(finalEvidence.files.length, 2); + const issueFile = finalEvidence.files.find( + (file) => file.name === 'issue.json', + ); + const rotationFile = finalEvidence.files.find( + (file) => file.name === 'rotate.json', + ); + assert.ok(issueFile && rotationFile); + assert.notEqual(issueFile.digest, rotationFile.digest); + assert.deepEqual(issueFile, issuedEvidence.files[0]); + + const finalPrimary = currentPrimaryPod(fixture); + const durable = JSON.parse( + psql( + fixture, + finalPrimary.metadata.name, + [ + 'SELECT json_build_object(', + ` 'identityVersion', (SELECT max(version)::integer FROM "ql3"."identity_subjects" WHERE subject_type = ${sqlString( + values.subject.type, + )} AND subject_id = ${sqlString(values.subject.id)}),`, + ` 'identityStatus', (SELECT status FROM "ql3"."identity_subjects" WHERE subject_type = ${sqlString( + values.subject.type, + )} AND subject_id = ${sqlString(values.subject.id)}),`, + ` 'credentialVersion', (SELECT max(version)::integer FROM "ql3"."api_credentials" WHERE credential_id = ${sqlString( + values.credentialId, + )}),`, + ` 'credentialState', (SELECT state FROM "ql3"."api_credentials" WHERE credential_id = ${sqlString( + values.credentialId, + )} ORDER BY version DESC LIMIT 1),`, + ` 'identityMutationCount', (SELECT count(*)::integer FROM "ql3"."identity_subject_mutations" WHERE subject_type = ${sqlString( + values.subject.type, + )} AND subject_id = ${sqlString(values.subject.id)}),`, + ` 'credentialMutationCount', (SELECT count(*)::integer FROM "ql3"."api_credential_mutations" WHERE credential_id = ${sqlString( + values.credentialId, + )}),`, + ` 'issueMutationCount', (SELECT count(*)::integer FROM "ql3"."api_credential_mutations" WHERE mutation_id = ${sqlString( + values.issueMutationId, + )}::uuid),`, + ` 'credentialVersionCount', (SELECT count(*)::integer FROM "ql3"."api_credentials" WHERE credential_id = ${sqlString( + values.credentialId, + )}),`, + ` 'allowedAuditCount', (SELECT count(*)::integer FROM "ql3"."security_audit_events" WHERE request_id IN (${[ + values.registerRequestId, + values.issueRequestId, + values.rotateRequestId, + values.revokeRequestId, + ] + .map(sqlString) + .join(',')}) AND outcome = 'allowed'))`, + ].join('\n'), + ), + ); + assert.deepEqual(durable, { + identityVersion: 1, + identityStatus: 'active', + credentialVersion: 3, + credentialState: 'revoked', + identityMutationCount: 1, + credentialMutationCount: 3, + issueMutationCount: 1, + credentialVersionCount: 3, + allowedAuditCount: 4, + }); + const adminRole = JSON.parse( + psql( + fixture, + finalPrimary.metadata.name, + `SELECT json_build_object('login', rolcanlogin, 'superuser', rolsuper, 'createDatabase', rolcreatedb, 'createRole', rolcreaterole, 'replication', rolreplication, 'bypassRls', rolbypassrls, 'connectionLimit', rolconnlimit) FROM pg_roles WHERE rolname = 'ql3_admin'`, + ), + ); + assert.deepEqual(adminRole, { + login: true, + superuser: false, + createDatabase: false, + createRole: false, + replication: false, + bypassRls: false, + connectionLimit: 4, + }); + assert.equal(canI(fixture, 'get', 'secrets'), 'no'); + assert.equal(canI(fixture, 'create', 'jobs.batch'), 'no'); + + const finalCluster = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'cluster', + POSTGRES_CLUSTER, + ]); + const finalNodes = fixture.kubectlJson(['get', 'nodes']).items; + const readyNodes = finalNodes.filter( + (node) => + podReady(node) && + Array.isArray(node.spec.podCIDRs) && + node.spec.podCIDRs.length === 1, + ); + assert.equal(readyNodes.length, 3); + assert.equal( + new Set(readyNodes.map((node) => node.spec.podCIDRs[0])).size, + 3, + ); + const serverNode = readyNodes.find( + (node) => node.metadata.name === fixture.server, + ); + assert.equal( + serverNode?.metadata.annotations?.[ + 'flannel.alpha.coreos.com/backend-type' + ], + 'vxlan', + ); + + assert.equal(createdJobs.size, 0); + assert.equal(createdSecrets.size, 0); + assert.equal(createdEvidenceJobs.size, 0); + deleteResource(fixture, `persistentvolumeclaim/${DELIVERY_CLAIM}`); + await waitFor('delivery PVC deletion', 60_000, () => + resourceAbsent( + fixture, + `persistentvolumeclaim/${DELIVERY_CLAIM}`, + ) + ? { ready: true, value: true } + : { ready: false, fact: 'PVC still exists' }, + ); + const cleanup = { + jobsDeleted: + resourceAbsent(fixture, 'job/ql3-security-live-register') && + resourceAbsent(fixture, 'job/ql3-security-live-revoke'), + inputSecretsDeleted: + resourceAbsent( + fixture, + 'secret/ql3-security-live-register-input', + ) && + resourceAbsent( + fixture, + 'secret/ql3-security-live-revoke-input', + ), + evidenceJobsDeleted: resourceAbsent( + fixture, + 'job/ql3-security-live-evidence-final', + ), + storageProvisionJobDeleted: resourceAbsent( + fixture, + 'job/ql3-security-live-delivery-provision', + ), + deliveryVolumeClaimDeleted: resourceAbsent( + fixture, + `persistentvolumeclaim/${DELIVERY_CLAIM}`, + ), + }; + assert.equal(Object.values(cleanup).every(Boolean), true); + + const report = { + schemaVersion: 1, + fixture: FIXTURE, + observedAt: new Date().toISOString(), + platform: { + distribution: 'k3s', + kubernetesVersion: nodes[0].status.nodeInfo.kubeletVersion, + architecture, + kubernetesImageId: imageIdDigest(k3sImageInfo), + administrationImageId: imageIdDigest(adminImageInfo), + cniName: 'flannel', + cniDistributionBinding: fixture.k3sImage, + controlPlaneNodes: 1, + workerNodes: 2, + readyNodes: readyNodes.length, + }, + database: { + operator: 'cloudnative-pg', + operatorVersion: LOCK.operator.version, + postgresVersionNumber: migrationState.postgresVersionNumber, + postgresImageId: imageIdDigest(postgresImageInfo), + instances: Number(finalCluster.spec.instances), + readyInstances: Number(finalCluster.status.readyInstances), + administrationRole: 'ql3_admin', + roleConnectionLimit: adminRole.connectionLimit, + commandConnectionLimit: 1, + migrationCount: migrationState.migrationCount, + controlCoreCapability: migrationState.controlCoreCapability, + tlsVerified: true, + leastPrivilege: true, + }, + ceremony: { + operations: [ + 'identity.register', + 'audit.list', + 'credential.issue', + 'credential.issue.replay', + 'credential.rotate', + 'credential.revoke', + ], + completedJobs: 6, + failedJobs: 1, + callerDriven: true, + backoffLimit: baseTemplate.spec.backoffLimit, + activeDeadlineSeconds: baseTemplate.spec.activeDeadlineSeconds, + ttlSecondsAfterFinished: baseTemplate.spec.ttlSecondsAfterFinished, + serviceAccount: NAME, + serviceAccountTokenMounted: false, + rbacGranted: false, + responseLossReplayObserved: true, + sensitiveMaterialReported: false, + }, + inputBoundary: { + immutableSecret: true, + projectedMode0440: true, + memoryBackedPrivateStage: true, + targetDirectoryMode0700: true, + targetFilesMode0600: true, + kubeletAtomicWriterProjectionAccepted: true, + worldReadableProjectionRejected: true, + mainContainerNotStartedAfterStageFailure: true, + }, + deliveryCustody: { + persistentVolumeClaim: true, + accessMode: 'ReadWriteOnce', + fixtureRootProvisioned: deliveryProvision.passed, + fixtureRootMode: deliveryProvision.afterMode, + fixtureProvisionerRanAsRoot: deliveryProvision.uid === 0, + privateDirectoryMode: finalEvidence.directoryMode, + fileMode: finalEvidence.fileMode, + fileCount: finalEvidence.files.length, + issueDigest: issueFile.digest, + rotationDigest: rotationFile.digest, + distinctRotationMaterial: issueFile.digest !== rotationFile.digest, + persistentAcrossJobs: true, + noReplaceReplayPreserved: + JSON.stringify(replayEvidence.files) === + JSON.stringify(issuedEvidence.files), + deliverySchemaValidated: true, + bearerFormatValidatedInPod: true, + sensitiveMaterialReported: false, + }, + isolation: { + dnsAndDatabaseEgressAllowed: finalEvidence.databaseConnected, + kubernetesApiEgressDenied: !finalEvidence.kubernetesApiConnected, + publicInternetEgressDenied: !finalEvidence.publicInternetConnected, + secretReadRbacDenied: canI(fixture, 'get', 'secrets') === 'no', + jobMutationRbacDenied: canI(fixture, 'create', 'jobs.batch') === 'no', + }, + durability: durable, + cleanup, + gates: { + realThreeNodeKubernetes: nodes.length === 3, + realCloudNativePg: + databasePods.length === 3 && + Number(finalCluster.status.readyInstances) === 3, + realKubeletSecretProjection: true, + realAdministrationProductCommands: true, + realPersistentCredentialCustody: finalEvidence.files.length === 2, + responseLossReplay: durable.issueMutationCount === 1, + failedInputStageClosed: true, + leastPrivilege: true, + contentFreeEvidence: true, + passed: true, + }, + limitations: [...LIMITATIONS], + }; + const audit = validateSecurityAdministrationKubernetesLiveReport(report); + assert.deepEqual(audit.findings, []); + fs.writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + fixture: FIXTURE, + reportWritten: true, + passed: true, + })}\n`, + ); + } finally { + await fixture.cleanup(); + if (adminImageBuilt) { + run(fixture.docker, ['image', 'rm', '-f', adminImage], { + capture: true, + quiet: true, + allowFailure: true, + }); + } + if (controlImageBuilt) { + run(fixture.docker, ['image', 'rm', '-f', controlImage], { + capture: true, + quiet: true, + allowFailure: true, + }); + } + } +} + +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `QL3 Security Administration Kubernetes live contract failed: ${ + error instanceof Error ? error.stack || error.message : String(error) + }\n`, + ); + process.exitCode = 1; + }); +} + +module.exports = { + auditListCommand, + credentialIssueCommand, + credentialRevokeCommand, + credentialRotateCommand, + custodyEvidenceSource, + deliveryVolumeProvisionSource, + identity, + identityRegisterCommand, + inputAuthorityEvidenceSource, +}; diff --git a/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs b/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs index 7f28d1e0..8e94b2f7 100644 --- a/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs +++ b/test/back/ql3SecurityAdministrationKubernetesAudit.test.cjs @@ -48,6 +48,26 @@ test('rejects Kubernetes API token authority in the administration Job', () => { ); }); +test('rejects recursive fsGroup rewrites of persistent credential custody', () => { + const report = auditSecurityAdministrationKubernetes({ + root: ROOT, + readFile: intercept( + 'deploy/kubernetes/ql3-cluster/operations/security-administration/base/job.yaml', + (value) => value.replace( + 'fsGroupChangePolicy: OnRootMismatch', + 'fsGroupChangePolicy: Always', + ), + ), + }); + + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'QL3_SECURITY_ADMIN_KUBERNETES_JOB_BOUNDARY_INVALID', + ), + ); +}); + test('rejects a non-persistent credential delivery boundary', () => { const report = auditSecurityAdministrationKubernetes({ root: ROOT, diff --git a/test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs b/test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs new file mode 100644 index 00000000..3670f5b2 --- /dev/null +++ b/test/back/ql3SecurityAdministrationKubernetesLiveAudit.test.cjs @@ -0,0 +1,175 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + FIXTURE, + LIMITATIONS, + validateSecurityAdministrationKubernetesLiveReport, +} = require('../../scripts/ql3-security-administration-kubernetes-live-audit.cjs'); + +const digest = (value) => `sha256:${value.repeat(64)}`; + +function report() { + return { + schemaVersion: 1, + fixture: FIXTURE, + observedAt: '2026-08-25T04:00:00.000Z', + platform: { + distribution: 'k3s', + kubernetesVersion: 'v1.34.3+k3s1', + architecture: 'amd64', + kubernetesImageId: digest('1'), + administrationImageId: digest('2'), + cniName: 'flannel', + cniDistributionBinding: 'rancher/k3s:v1.34.3-k3s1', + controlPlaneNodes: 1, + workerNodes: 2, + readyNodes: 3, + }, + database: { + operator: 'cloudnative-pg', + operatorVersion: '1.30.0', + postgresVersionNumber: 180004, + postgresImageId: digest('3'), + instances: 3, + readyInstances: 3, + administrationRole: 'ql3_admin', + roleConnectionLimit: 4, + commandConnectionLimit: 1, + migrationCount: 71, + controlCoreCapability: 70, + tlsVerified: true, + leastPrivilege: true, + }, + ceremony: { + operations: [ + 'identity.register', + 'audit.list', + 'credential.issue', + 'credential.issue.replay', + 'credential.rotate', + 'credential.revoke', + ], + completedJobs: 6, + failedJobs: 1, + callerDriven: true, + backoffLimit: 0, + activeDeadlineSeconds: 300, + ttlSecondsAfterFinished: 600, + serviceAccount: 'ql3-security-administration', + serviceAccountTokenMounted: false, + rbacGranted: false, + responseLossReplayObserved: true, + sensitiveMaterialReported: false, + }, + inputBoundary: { + immutableSecret: true, + projectedMode0440: true, + memoryBackedPrivateStage: true, + targetDirectoryMode0700: true, + targetFilesMode0600: true, + kubeletAtomicWriterProjectionAccepted: true, + worldReadableProjectionRejected: true, + mainContainerNotStartedAfterStageFailure: true, + }, + deliveryCustody: { + persistentVolumeClaim: true, + accessMode: 'ReadWriteOnce', + fixtureRootProvisioned: true, + fixtureRootMode: '2770', + fixtureProvisionerRanAsRoot: true, + privateDirectoryMode: '0700', + fileMode: '0600', + fileCount: 2, + issueDigest: digest('4'), + rotationDigest: digest('5'), + distinctRotationMaterial: true, + persistentAcrossJobs: true, + noReplaceReplayPreserved: true, + deliverySchemaValidated: true, + bearerFormatValidatedInPod: true, + sensitiveMaterialReported: false, + }, + isolation: { + dnsAndDatabaseEgressAllowed: true, + kubernetesApiEgressDenied: true, + publicInternetEgressDenied: true, + secretReadRbacDenied: true, + jobMutationRbacDenied: true, + }, + durability: { + identityVersion: 1, + identityStatus: 'active', + credentialVersion: 3, + credentialState: 'revoked', + identityMutationCount: 1, + credentialMutationCount: 3, + issueMutationCount: 1, + credentialVersionCount: 3, + allowedAuditCount: 4, + }, + cleanup: { + jobsDeleted: true, + inputSecretsDeleted: true, + evidenceJobsDeleted: true, + storageProvisionJobDeleted: true, + deliveryVolumeClaimDeleted: true, + }, + gates: { + realThreeNodeKubernetes: true, + realCloudNativePg: true, + realKubeletSecretProjection: true, + realAdministrationProductCommands: true, + realPersistentCredentialCustody: true, + responseLossReplay: true, + failedInputStageClosed: true, + leastPrivilege: true, + contentFreeEvidence: true, + passed: true, + }, + limitations: [...LIMITATIONS], + }; +} + +test('accepts the exact content-free Security Administration live report', () => { + const result = validateSecurityAdministrationKubernetesLiveReport(report()); + assert.equal(result.compatible, true); + assert.deepEqual(result.findings, []); +}); + +test('rejects widened authority, false custody and replay duplication', () => { + const candidate = report(); + candidate.ceremony.serviceAccountTokenMounted = true; + candidate.deliveryCustody.noReplaceReplayPreserved = false; + candidate.durability.issueMutationCount = 2; + candidate.gates.passed = false; + const codes = validateSecurityAdministrationKubernetesLiveReport( + candidate, + ).findings.map((finding) => finding.code); + assert.ok( + codes.includes('QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_CEREMONY'), + ); + assert.ok( + codes.includes('QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DELIVERY'), + ); + assert.ok( + codes.includes('QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_DURABILITY'), + ); + assert.ok(codes.includes('QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_GATES')); +}); + +test('rejects any credential or assertion material in evidence', () => { + const candidate = report(); + candidate.deliveryCustody.proof = { + token: 'ql3c_example_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }; + const result = validateSecurityAdministrationKubernetesLiveReport(candidate); + assert.equal(result.compatible, false); + assert.ok( + result.findings.some( + (finding) => + finding.code === + 'QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE_MATERIAL_EXPOSURE', + ), + ); +}); diff --git a/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs b/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs new file mode 100644 index 00000000..00c19aa7 --- /dev/null +++ b/test/back/ql3SecurityAdministrationKubernetesLiveContract.test.cjs @@ -0,0 +1,140 @@ +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { + auditListCommand, + credentialIssueCommand, + credentialRevokeCommand, + credentialRotateCommand, + custodyEvidenceSource, + deliveryVolumeProvisionSource, + identity, + identityRegisterCommand, + inputAuthorityEvidenceSource, +} = require('../../scripts/ql3-security-administration-kubernetes-live-contract.cjs'); + +const values = Object.freeze({ + subject: Object.freeze({ type: 'api_app', id: 'd406-test-client' }), + credentialId: 'd406-test-client', + identityMutationId: '10000000-0000-4000-8000-000000000001', + issueMutationId: '10000000-0000-4000-8000-000000000002', + rotateMutationId: '10000000-0000-4000-8000-000000000003', + revokeMutationId: '10000000-0000-4000-8000-000000000004', + registerRequestId: 'd406-register-test', + issueRequestId: 'd406-issue-test', + rotateRequestId: 'd406-rotate-test', + revokeRequestId: 'd406-revoke-test', + notBeforeAtMs: 2_000_000_000_000, + expiresAtMs: 2_000_003_600_000, +}); + +test('requires a private report path before any Docker or Kubernetes mutation', () => { + const script = path.resolve( + __dirname, + '../../scripts/ql3-security-administration-kubernetes-live-contract.cjs', + ); + const result = spawnSync(process.execPath, [script], { + encoding: 'utf8', + env: { + ...process.env, + QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE: '1', + }, + }); + assert.equal(result.status, 1); + assert.match(result.stderr, /--report=\/absolute\/private-report\.json/); + assert.doesNotMatch(result.stderr, /Docker\/Kubernetes/); +}); + +test('builds the exact register, audit, issue, rotate and revoke commands', () => { + assert.deepEqual(identityRegisterCommand(values), { + schemaVersion: 1, + operation: 'identity.register', + request: { + mutationId: values.identityMutationId, + requestId: values.registerRequestId, + expectedCurrentVersion: 0, + subject: values.subject, + }, + }); + assert.deepEqual(auditListCommand(), { + schemaVersion: 1, + operation: 'audit.list', + request: { limit: 25, filter: { outcome: 'allowed' } }, + }); + assert.equal(credentialIssueCommand(values).request.expectedCurrentVersion, 0); + assert.equal(credentialRotateCommand(values).request.expectedCurrentVersion, 1); + assert.equal(credentialRevokeCommand(values).request.expectedCurrentVersion, 2); + assert.equal('notBeforeAtMs' in credentialRevokeCommand(values).request, false); +}); + +test('binds the live assertion to the isolated Security Administration purpose', () => { + const key = identity.reviewedKey('security-administration-unit-key'); + const document = identity.keyset(1, [key]); + assert.equal(document.audience, 'qinglong3-security-administration'); + const assertion = identity.assertion(key, 'unit-test'); + const header = JSON.parse(Buffer.from(assertion.split('.')[0], 'base64url')); + const payload = JSON.parse(Buffer.from(assertion.split('.')[1], 'base64url')); + assert.equal(header.typ, 'ql3-security-administration+jwt'); + assert.equal(payload.ql3_purpose, 'security-administration'); + assert.equal(payload.sub, 'security-owner'); + assert.equal(payload.acr, 'urn:ql3:mfa'); + assert.deepEqual(payload.amr, ['pwd', 'otp']); +}); + +test('keeps the in-Pod custody verifier content-free and fail-closed', () => { + const source = custodyEvidenceSource(); + assert.match(source, /bytes\.fill\(0\)/); + assert.match(source, /kubernetesApiConnected/); + assert.match(source, /publicInternetConnected/); + assert.match(source, /status\.nlink!==1/); + assert.doesNotMatch(source, /value\.token[,}]/); + assert.doesNotMatch(source, /console\.log/); +}); + +test('inspects projected input authority without reading private material', () => { + const source = inputAuthorityEvidenceSource(); + assert.match(source, /lstatSync/); + assert.match(source, /realpathSync/); + assert.match(source, /confined/); + assert.doesNotMatch(source, /readFile/); + assert.doesNotMatch(source, /createReadStream/); +}); + +test('constrains only the exact local-path fixture root without network authority', () => { + const source = deliveryVolumeProvisionSource(); + assert.match(source, /before\.mode!==['"]2777['"]/); + assert.match(source, /chmodSync\(root,0o2770\)/); + assert.match(source, /after\.mode!==['"]2770['"]/); + assert.doesNotMatch(source, /child_process/); + assert.doesNotMatch(source, /net|fetch|http/); +}); + +test('live runner remains opt-in, reviewed, cleanup-bound and log-free', () => { + const source = fs.readFileSync( + path.resolve( + __dirname, + '../../scripts/ql3-security-administration-kubernetes-live-contract.cjs', + ), + 'utf8', + ); + assert.match( + source, + /QL3_SECURITY_ADMINISTRATION_KUBERNETES_LIVE !== '1'/, + ); + assert.match(source, /reviewedOperatorManifest\(operatorManifestFile\)/); + assert.match(source, /validateSecurityAdministrationKubernetesLiveReport/); + assert.match(source, /projectedMode: 0o444/); + assert.match(source, /credential\.issue\.replay/); + assert.match(source, /FallbackToLogsOnError/); + assert.match(source, /failureMessage: 'rejected'/); + assert.match( + source, + /deploy\/kubernetes\/ql3-cluster\/base\/service-account\.yaml/, + ); + assert.match(source, /persistentvolumeclaim\/\$\{DELIVERY_CLAIM\}/); + assert.match(source, /await fixture\.cleanup\(\)/); + assert.doesNotMatch(source, /kubectl\([^)]*logs/); +});