mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): prove security administration custody live
This commit is contained in:
@@ -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
|
||||
@@ -29,6 +29,7 @@ spec:
|
||||
runAsUser: 10001
|
||||
runAsGroup: 10001
|
||||
fsGroup: 10001
|
||||
fsGroupChangePolicy: OnRootMismatch
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
initContainers:
|
||||
|
||||
@@ -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 切换仍须独立门禁。
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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",
|
||||
|
||||
+13
-2
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user