From 4f62a86d15cc66c5998653f9d90c3f207f3a226d Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 07:07:32 +0800 Subject: [PATCH] feat(ql3): export redacted cluster evidence bundle --- deploy/console/ql3-cluster-copilot/README.md | 24 + docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...-local-redacted-cluster-evidence-bundle.md | 47 ++ docs/adr/README.md | 1 + packages/ql3-cluster-admin/README.md | 9 + .../assets/copilot-console/app.css | 30 + .../assets/copilot-console/app.js | 133 +++- .../assets/copilot-console/evidence-bundle.js | 641 ++++++++++++++++++ .../assets/copilot-console/index.html | 16 +- .../src/copilot-console/assets.ts | 14 +- .../src/copilot-console/server.ts | 18 +- .../test/copilotConsole.test.cjs | 18 + .../copilotConsoleEvidenceBundle.test.cjs | 256 +++++++ ...l3-cluster-admin-product-live-contract.cjs | 34 +- scripts/ql3-cluster-copilot-console-audit.cjs | 55 +- ...l3ClusterAdminProductLiveContract.test.cjs | 2 + .../ql3ClusterCopilotConsoleAudit.test.cjs | 34 +- 17 files changed, 1308 insertions(+), 25 deletions(-) create mode 100644 docs/adr/ADR-0422-browser-local-redacted-cluster-evidence-bundle.md create mode 100644 packages/ql3-cluster-admin/assets/copilot-console/evidence-bundle.js create mode 100644 packages/ql3-cluster-admin/test/copilotConsoleEvidenceBundle.test.cjs diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index c2507e24..b2434927 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -112,6 +112,30 @@ next-page read only when the upstream cursor says more data exists. There is no automatic cascade from a list to details, steps or events, so each authority read remains visible and intentional. +## Export a redacted evidence bundle + +After at least one successful read, **Export redacted bundle** creates one +UTF-8 JSON file entirely in browser memory. Export performs zero BFF or Cluster +requests and includes only the evidence already visible in the current page. +The ledger retains at most the newest sixteen entries and 8 MiB of canonical +raw facts; reaching either limit removes the oldest visible and in-memory +entry. The generated file is capped at 512 KiB. + +The fixed allowlist keeps operation, local observation time, reviewed status +enums, bounded numeric/boolean facts, pagination state and typed aliases. +Project, Run, Task, Workflow, Package, Step, Artifact, request and digest values +become per-bundle aliases without an exported mapping. Free text, names, +paths/URLs, commands, inputs/outputs, environment, errors/messages, credentials, +tokens, authorization, unknown fields and Copilot model text are omitted. A +canonical SHA-256 for each omitted raw fact allows a later local comparison +without embedding that fact. + +The top-level SHA-256 detects changes to the redacted JSON, but it is not a +server signature, durable audit, origin attestation or action authority. Review +the file before sharing it. Generation uses no upload, clipboard/share API, +browser storage, worker, timer or service-side temporary file. **Clear page** +removes the current in-memory ledger without sending a request. + ## Run the verified image Create a dedicated Docker network whose egress is restricted by the host diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 7004cb9c..0d7a16b7 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- D-330/ADR-0422(已接受):同一 loopback-only Cluster field ledger 现可由用户显式导出纯浏览器本地的脱敏 evidence bundle。导出只消费本页已逐次读取的最近 16 条、最多 8 MiB canonical fact,不调用 upstream/BFF、补读详情/分页、轮询、上传或持久化;固定 sanitizer 只保留 operation、非权威本机观察时间、安全枚举/boolean/有界 number、结构计数/分页事实、per-bundle typed alias 与原始 fact canonical byte count/SHA-256,自由文本、名称、路径/URL/command/input/output/environment、reason/error/message、credential/token/session/authorization、未知字段及 Copilot model text 一律省略。bundle 固定为 UTF-8 `qinglong/cluster-console-redacted-evidence-bundle@v1` JSON、最大 512 KiB,顶层 self-digest 明确不是 server signature/audit/action authority;生成器作为第 4 个 digest-bound asset 留在既有 `@qinglong/cluster-admin`,不增加 package、依赖、Cluster/BFF route、数据库、对象存储、Kubernetes workload 或 Edge/Standalone closure。定向门 24/24,Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSON,upstream 计数仍为 3,390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,1,199 个源码中 1,181 个位于职责目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。 - D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operation;browser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`,workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。 - D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none`;`check` 不 publish,`serve` 唯一映射为 `127.0.0.1::/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stop,compact 为 192 MiB/0.25 CPU/32 PIDs,standard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30;release/OCI/SBOM 定向门 75/75,package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 仍为 4,493,043/4,493,175 bytes,MCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。 - D-327/ADR-0419(已接受):QingLong 3.0 首个 Cluster 浏览器产品面已冻结为独立的 operator-workstation、loopback-only、只读 Copilot Console,而不是继续扩展 2.x Umi `src/pages`、legacy session 与 `/api` proxy。实现内聚在既有 `@qinglong/cluster-admin/copilot-console`,workspace 仍为 18 个 package;统一产品 façade 增加第十个静态命令 `copilot-console`。BFF 只监听 `127.0.0.1` ephemeral port,启动前复验包内 HTML/CSS/JS 的路径、realpath、类型、UTF-8、大小与固定 SHA-256;三项资源合计 24,150 bytes,无外部 asset/font/CDN。Cluster `ql3c_` credential 始终留在服务端 owner-private `0600` 文件且每次上游调用重新读取;浏览器只使用另一份 exact 256-bit session key,服务端只保存 domain-separated digest,页面只保存在内存,不进入 cookie、URL、argv、environment、local/session storage。Browser BFF 仅接受 exact `inspect|output`,复用 D-324 共享 TypeScript client,不执行 CLI 子进程、不直连数据库/application capability,并明确没有 diagnose/cancel、poller、WebSocket/SSE、ServiceWorker、queue/retry/cache 或后台 timer。Host、Origin、单 Authorization、route/operation 和 JSON framing 必须 exact;第三个并发 read 立即 `429`,固定 4 KiB request、约 2 MiB response、2 in-flight、16 connections 和 2 秒 shutdown ceiling。响应全为 `no-store` 且使用 default-deny CSP;模型文本只通过 `textContent` 显示并持续标记为 untrusted/no-action-authority。部署手册固定受信运维工作站生命周期,禁止 Kubernetes workload、Ingress、sidecar、共享 LAN 和容器 `0.0.0.0`;Edge/Standalone、Local MCP、Cluster Control/AI closure 均不导入 Console。npm pack dry-run 确认 245 files、258,012-byte tarball、1,614,503-byte unpacked,包含三项静态资源与全部 BFF/CLI 编译产物;独立审计还发现并修正真实 Admin Dockerfile 原先遗漏 assets 的发布缺陷,并把生产 files 白名单精确收窄到 `assets/copilot-console/*`。真实 Playwright 现场门覆盖 session 解锁、status read、显式 output reveal、390px 响应式布局和键盘路径;含 ` @@ -109,7 +110,7 @@ @@ -119,12 +120,19 @@

Explicit read ledger

本页证据账本

- 等待读取 +
+ 等待读取 + 0/16 条 · 0 KiB 原始事实 +
+ + +
+

一次读取,一条可复核事实

-

先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。

+

先解锁页面并选择坐标。最近 16 条、最多 8 MiB 原始事实只保留在当前内存,reload 后清空。

@@ -133,7 +141,7 @@
Loopback only · explicit reads · zero polling - QingLong 3.0 incubation / D-329 + QingLong 3.0 incubation / D-330
diff --git a/packages/ql3-cluster-admin/src/copilot-console/assets.ts b/packages/ql3-cluster-admin/src/copilot-console/assets.ts index 33127647..65e34399 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/assets.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/assets.ts @@ -6,6 +6,7 @@ import { TextDecoder } from 'node:util'; export interface ClusterCopilotConsoleAssets { readonly html: string; readonly css: string; + readonly evidenceBundle: string; readonly javascript: string; } @@ -23,19 +24,25 @@ const ASSETS = Object.freeze([ name: 'index.html', field: 'html', maximumBytes: 32 * 1024, - digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42', + digest: '5d452c947a9f1266e4920cf48e7d5116b3f5ef8f9120f681124ed61f0217f5ff', }), Object.freeze({ name: 'app.css', field: 'css', maximumBytes: 64 * 1024, - digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f', + digest: '5cf82b0a88920d106530603a7d407f852312138e5b7af5c422b3bccee785f144', + }), + Object.freeze({ + name: 'evidence-bundle.js', + field: 'evidenceBundle', + maximumBytes: 32 * 1024, + digest: '6ecb14d2f59d872b889bb42c22bf0c0d2c150c90ea708fb1662d47f17f2e2095', }), Object.freeze({ name: 'app.js', field: 'javascript', maximumBytes: 32 * 1024, - digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0', + digest: 'f109c5b0491ba9a473e3129e35773edf38ac745b403e1f547f8252aa2932cdff', }), ] as const); @@ -131,6 +138,7 @@ export function loadClusterCopilotConsoleAssets( return Object.freeze({ html: result.html!, css: result.css!, + evidenceBundle: result.evidenceBundle!, javascript: result.javascript!, }); } diff --git a/packages/ql3-cluster-admin/src/copilot-console/server.ts b/packages/ql3-cluster-admin/src/copilot-console/server.ts index 26932d12..84846676 100644 --- a/packages/ql3-cluster-admin/src/copilot-console/server.ts +++ b/packages/ql3-cluster-admin/src/copilot-console/server.ts @@ -321,7 +321,12 @@ export async function startClusterCopilotConsoleServer( optionKeys.push('networkBoundary'); } const record = exactObject(options, optionKeys); - const assets = exactObject(record.assets, ['css', 'html', 'javascript']); + const assets = exactObject(record.assets, [ + 'css', + 'evidenceBundle', + 'html', + 'javascript', + ]); const networkBoundary = record.networkBoundary === undefined ? 'host-loopback' @@ -331,6 +336,8 @@ export async function startClusterCopilotConsoleServer( assets.html.length < 1 || typeof assets.css !== 'string' || assets.css.length < 1 || + typeof assets.evidenceBundle !== 'string' || + assets.evidenceBundle.length < 1 || typeof assets.javascript !== 'string' || assets.javascript.length < 1 || !record.executor || @@ -379,6 +386,15 @@ export async function startClusterCopilotConsoleServer( ); return; } + if (request.url === '/evidence-bundle.js') { + send( + response, + 200, + 'text/javascript; charset=utf-8', + assets.evidenceBundle as string, + ); + return; + } } const operation = targetPath(request); diff --git a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs index f7e768f5..f64427d2 100644 --- a/packages/ql3-cluster-admin/test/copilotConsole.test.cjs +++ b/packages/ql3-cluster-admin/test/copilotConsole.test.cjs @@ -374,8 +374,14 @@ test('loads only digest-bound packaged assets and rejects drift', async (t) => { const assets = loadClusterCopilotConsoleAssets(moduleDirectory); assert.match(assets.html, /沿着证据读,不替集群做决定/); assert.match(assets.css, /prefers-reduced-motion/); + assert.match( + assets.evidenceBundle, + /qinglong\/cluster-console-redacted-evidence-bundle@v1/, + ); + assert.match(assets.evidenceBundle, /createClusterConsoleEvidenceBundle/); assert.match(assets.javascript, /output\.textContent = JSON\.stringify/); assert.match(assets.javascript, /run_event_list/); + assert.match(assets.javascript, /createClusterConsoleEvidenceBundle/); assert.doesNotMatch( assets.javascript, /localStorage|sessionStorage|innerHTML/, @@ -412,9 +418,21 @@ test('serves an immutable same-origin shell with a closed browser policy', async assert.match(html.text, /Cluster field ledger/); const css = await request(server.origin, { path: '/app.css' }); + const evidenceBundle = await request(server.origin, { + path: '/evidence-bundle.js', + }); const javascript = await request(server.origin, { path: '/app.js' }); assert.equal(css.statusCode, 200); + assert.equal(evidenceBundle.statusCode, 200); assert.equal(javascript.statusCode, 200); + assert.equal( + evidenceBundle.headers['content-type'], + 'text/javascript; charset=utf-8', + ); + assert.match( + evidenceBundle.text, + /qinglong\/cluster-console-redacted-evidence-bundle@v1/, + ); assert.equal( javascript.headers['content-type'], 'text/javascript; charset=utf-8', diff --git a/packages/ql3-cluster-admin/test/copilotConsoleEvidenceBundle.test.cjs b/packages/ql3-cluster-admin/test/copilotConsoleEvidenceBundle.test.cjs new file mode 100644 index 00000000..b780f213 --- /dev/null +++ b/packages/ql3-cluster-admin/test/copilotConsoleEvidenceBundle.test.cjs @@ -0,0 +1,256 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { webcrypto } = require('node:crypto'); +const test = require('node:test'); + +const { + ClusterConsoleEvidenceBundleError, + createClusterConsoleEvidenceBundle, + limits, + measureClusterConsoleEvidenceRecord, + schema, + serializeClusterConsoleEvidenceBundle, + verifyClusterConsoleEvidenceBundle, +} = require('../assets/copilot-console/evidence-bundle.js'); + +const requestSchema = 'qinglong/cluster-copilot-console-read-request@v1'; + +function runRecord(overrides = {}) { + return { + operation: 'run_read', + observedAtMs: 1_700_000_000_000, + request: { + schema: requestSchema, + operation: 'run_read', + projectId: 'project-customer-production', + requestId: 'console-request-sensitive', + runId: 'run-customer-production', + }, + fact: { + schema: 'qinglong/bounded-run-projection@v1', + schemaVersion: 1, + status: 'succeeded', + projectId: 'project-customer-production', + runId: 'run-customer-production', + createdAtMs: 1_700_000_000_000, + finalizedAtMs: 1_700_000_001_000, + outputAvailable: true, + name: 'customer-production-nightly', + message: 'ql3c_console_do_not_export', + path: '/private/customer/run.log', + unknownField: '', + }, + ...overrides, + }; +} + +function outputRecord() { + return { + operation: 'output', + observedAtMs: 1_700_000_002_000, + request: { + schema: requestSchema, + operation: 'output', + projectId: 'project-customer-production', + requestId: 'diagnosis-request-sensitive', + sourceRunId: 'run-customer-production', + }, + fact: { + schema: + 'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1', + schemaVersion: 1, + status: 'available', + projectId: 'project-customer-production', + sourceRunId: 'run-customer-production', + diagnosisRunId: 'diagnosis-run-sensitive', + reference: { + artifactId: 'artifact-sensitive', + artifactDigest: 'a'.repeat(64), + contentDigest: 'b'.repeat(64), + outputBytes: 71, + sealedAtMs: 1_700_000_001_000, + }, + result: { + text: '', + command: 'curl https://attacker.invalid', + token: 'ql3c_console_do_not_export', + finishReason: 'stop', + }, + usage: { + inputTokens: 20, + outputTokens: 10, + totalTokens: 30, + costMicros: 42, + }, + }, + }; +} + +test('creates one self-verifiable redacted bundle with per-bundle correlated aliases', async () => { + const records = [runRecord(), outputRecord()]; + assert.ok(measureClusterConsoleEvidenceRecord(records[0]) > 1); + + const bundle = await createClusterConsoleEvidenceBundle( + records, + 1_700_000_003_000, + webcrypto, + ); + const encoded = serializeClusterConsoleEvidenceBundle(bundle); + const parsed = JSON.parse(encoded); + + assert.equal(bundle.schema, schema); + assert.equal(bundle.generatedBy, 'browser_local'); + assert.equal(bundle.actionAuthority, 'none'); + assert.equal(bundle.attestation, 'none'); + assert.equal(bundle.source.entryCount, 2); + assert.equal(bundle.redaction.freeTextIncluded, false); + assert.equal(bundle.redaction.copilotOutputIncluded, false); + assert.equal(bundle.entries[0].target.projectId, 'project-001'); + assert.equal(bundle.entries[1].target.projectId, 'project-001'); + assert.equal(bundle.entries[0].target.runId, 'run-001'); + assert.equal(bundle.entries[1].target.sourceRunId, 'run-001'); + assert.equal(bundle.entries[0].fact.runId, 'run-001'); + assert.equal(bundle.entries[1].fact.sourceRunId, 'run-001'); + assert.equal(bundle.entries[1].fact.result, undefined); + assert.equal(bundle.entries[1].fact.reference.artifactId, 'artifact-001'); + assert.match(bundle.entries[1].rawFact.sha256, /^[0-9a-f]{64}$/); + assert.match(bundle.contentDigest, /^[0-9a-f]{64}$/); + assert.equal( + await verifyClusterConsoleEvidenceBundle(parsed, webcrypto), + true, + ); + + for (const forbidden of [ + 'project-customer-production', + 'run-customer-production', + 'diagnosis-run-sensitive', + 'artifact-sensitive', + 'customer-production-nightly', + 'ql3c_console_do_not_export', + '/private/customer/run.log', + '', + '', + 'attacker.invalid', + ]) { + assert.doesNotMatch(encoded, new RegExp(forbidden.replaceAll('/', '\\/'))); + } + + parsed.entries[0].fact.status = 'failed'; + assert.equal( + await verifyClusterConsoleEvidenceBundle(parsed, webcrypto), + false, + ); +}); + +test('resets the undisclosed alias table for every bundle', async () => { + const first = await createClusterConsoleEvidenceBundle( + [runRecord()], + 1_700_000_003_000, + webcrypto, + ); + const second = await createClusterConsoleEvidenceBundle( + [ + runRecord({ + request: { + schema: requestSchema, + operation: 'run_read', + projectId: 'another-project', + requestId: 'another-request', + runId: 'another-run', + }, + }), + ], + 1_700_000_004_000, + webcrypto, + ); + + assert.equal(first.entries[0].target.runId, 'run-001'); + assert.equal(second.entries[0].target.runId, 'run-001'); + assert.notEqual(first.contentDigest, second.contentDigest); + assert.equal(JSON.stringify(first).includes('another-run'), false); + assert.equal( + JSON.stringify(second).includes('run-customer-production'), + false, + ); +}); + +test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => { + const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' }; + assert.throws(() => measureClusterConsoleEvidenceRecord(null), error); + assert.throws( + () => measureClusterConsoleEvidenceRecord({ ...runRecord(), session: 'x' }), + error, + ); + assert.throws( + () => + measureClusterConsoleEvidenceRecord({ + ...runRecord(), + operation: 'cancel', + }), + error, + ); + assert.throws( + () => + measureClusterConsoleEvidenceRecord({ + ...runRecord(), + request: { ...runRecord().request, endpoint: 'https://example.test' }, + }), + error, + ); + assert.throws( + () => + measureClusterConsoleEvidenceRecord({ + ...runRecord(), + fact: { items: Array.from({ length: 65 }, () => ({})) }, + }), + error, + ); + const cyclic = runRecord(); + cyclic.fact.loop = cyclic.fact; + assert.throws(() => measureClusterConsoleEvidenceRecord(cyclic), error); + assert.throws( + () => + measureClusterConsoleEvidenceRecord({ + ...runRecord(), + fact: { message: 'x'.repeat(limits.maximumEntryFactBytes + 1) }, + }), + error, + ); + + await assert.rejects( + createClusterConsoleEvidenceBundle([], 1_700_000_003_000, webcrypto), + ClusterConsoleEvidenceBundleError, + ); + await assert.rejects( + createClusterConsoleEvidenceBundle( + Array.from({ length: limits.maximumRecords + 1 }, () => runRecord()), + 1_700_000_003_000, + webcrypto, + ), + ClusterConsoleEvidenceBundleError, + ); + const largeRecords = Array.from({ length: 5 }, (_, index) => + runRecord({ + observedAtMs: 1_700_000_000_000 + index, + fact: { message: String(index) + 'x'.repeat(2 * 1024 * 1024 - 64) }, + }), + ); + await assert.rejects( + createClusterConsoleEvidenceBundle( + largeRecords, + 1_700_000_003_000, + webcrypto, + ), + ClusterConsoleEvidenceBundleError, + ); + assert.throws( + () => + serializeClusterConsoleEvidenceBundle({ + schema, + contentDigest: 'a'.repeat(64), + padding: 'x'.repeat(limits.maximumBundleBytes), + }), + error, + ); +}); diff --git a/scripts/ql3-cluster-admin-product-live-contract.cjs b/scripts/ql3-cluster-admin-product-live-contract.cjs index 9ef62ed6..e31b3013 100644 --- a/scripts/ql3-cluster-admin-product-live-contract.cjs +++ b/scripts/ql3-cluster-admin-product-live-contract.cjs @@ -260,14 +260,22 @@ child.stdout.on('data', (chunk) => { response.on('data', (chunk) => chunks.push(chunk)); response.once('end', () => { const body = Buffer.concat(chunks).toString('utf8'); - if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; } - child.once('close', (status, signal) => { - if (status !== 0 || signal !== null) { finish(46); return; } - settled = true; - clearTimeout(timeout); - process.stdout.write(JSON.stringify({ loopback: true, assets: true, cleanShutdown: true })); - }); - child.kill('SIGTERM'); + if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/evidence-bundle.js') || !body.includes('/app.js')) { finish(45); return; } + get(started.origin + '/evidence-bundle.js', (assetResponse) => { + const assetChunks = []; + assetResponse.on('data', (chunk) => assetChunks.push(chunk)); + assetResponse.once('end', () => { + const asset = Buffer.concat(assetChunks).toString('utf8'); + if (assetResponse.statusCode !== 200 || !asset.includes('qinglong/cluster-console-redacted-evidence-bundle@v1') || !asset.includes('createClusterConsoleEvidenceBundle')) { finish(48); return; } + child.once('close', (status, signal) => { + if (status !== 0 || signal !== null) { finish(46); return; } + settled = true; + clearTimeout(timeout); + process.stdout.write(JSON.stringify({ loopback: true, assets: true, evidenceBundle: true, cleanShutdown: true })); + }); + child.kill('SIGTERM'); + }); + }).once('error', () => finish(49)); }); }).once('error', () => finish(47)); }); @@ -307,6 +315,7 @@ child.stdout.on('data', (chunk) => { if ( result?.loopback !== true || result?.assets !== true || + result?.evidenceBundle !== true || result?.cleanShutdown !== true ) { fail('Console live contract drifted'); @@ -442,13 +451,17 @@ process.once('SIGINT', () => child.kill('SIGINT')); process.execPath, [ '-e', - "require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field ledger'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));", + "const h=require('node:http');h.get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field ledger')||!b.includes('/evidence-bundle.js'))process.exit(2);h.get(process.argv[1]+'/evidence-bundle.js',(a)=>{const d=[];a.on('data',(x)=>d.push(x));a.on('end',()=>{const s=Buffer.concat(d).toString('utf8');if(a.statusCode!==200||!s.includes('createClusterConsoleEvidenceBundle'))process.exit(4);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js'),evidenceBundle:true}));});}).on('error',()=>process.exit(5));});}).on('error',()=>process.exit(3));", origin, ], { encoding: 'utf8', timeout: 5_000 }, ); const probeFact = JSON.parse(probe); - if (probeFact.status !== 200 || probeFact.assets !== true) { + if ( + probeFact.status !== 200 || + probeFact.assets !== true || + probeFact.evidenceBundle !== true + ) { fail('published Console host read drifted'); } @@ -536,6 +549,7 @@ function main() { contextReadiness: true, consoleLoopback: true, consoleAssets: true, + consoleEvidenceBundle: true, consolePublishedHostAddress: '127.0.0.1', consoleDistributionEmbedded: true, isolation: Object.freeze({ diff --git a/scripts/ql3-cluster-copilot-console-audit.cjs b/scripts/ql3-cluster-copilot-console-audit.cjs index b2ae93fd..91c81231 100644 --- a/scripts/ql3-cluster-copilot-console-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-audit.cjs @@ -15,6 +15,7 @@ const REQUIRED_FILES = Object.freeze([ CLIENT_FILE, ASSET_ROOT + '/index.html', ASSET_ROOT + '/app.css', + ASSET_ROOT + '/evidence-bundle.js', ASSET_ROOT + '/app.js', DEPLOYMENT_ROOT + '/README.md', DEPLOYMENT_ROOT + '/client-config.example.json', @@ -130,6 +131,8 @@ function auditClusterCopilotConsole(options = {}) { "'/api/v1/observe/run-list': 'run_list'", "'/api/v1/observe/task-list': 'task_list'", "'/api/v1/observe/workflow-list': 'workflow_list'", + "request.url === '/evidence-bundle.js'", + 'assets.evidenceBundle as string', "default-src 'none'", "frame-ancestors 'none'", "'cache-control': 'no-store'", @@ -168,12 +171,17 @@ function auditClusterCopilotConsole(options = {}) { '读取 Workflow Runs', '显式读取诊断内容', '模型文本是不可信内容', + '导出脱敏包', + '/evidence-bundle.js', ]); expectFragments(ASSET_ROOT + '/app.js', [ "credentials: 'omit'", "cache: 'no-store'", 'output.textContent = JSON.stringify(fact, null, 2)', 'nextPage(operation, request, fact)', + 'measureClusterConsoleEvidenceRecord(record)', + 'createClusterConsoleEvidenceBundle', + 'URL.revokeObjectURL(objectUrl)', "sessionToken = ''", ]); rejectFragments(ASSET_ROOT + '/app.js', [ @@ -188,6 +196,35 @@ function auditClusterCopilotConsole(options = {}) { 'cancel', 'http://', 'https://', + 'navigator.', + 'setTimeout(', + ]); + expectFragments(ASSET_ROOT + '/evidence-bundle.js', [ + 'qinglong/cluster-console-redacted-evidence-bundle@v1', + 'maximumRecords: 16', + 'maximumRawBytes: 8 * 1024 * 1024', + 'maximumBundleBytes: 512 * 1024', + "generatedBy: 'browser_local'", + "actionAuthority: 'none'", + "attestation: 'none'", + "policy: 'fixed_allowlist_v1'", + 'freeTextIncluded: false', + 'copilotOutputIncluded: false', + 'createClusterConsoleEvidenceBundle', + 'verifyClusterConsoleEvidenceBundle', + ]); + rejectFragments(ASSET_ROOT + '/evidence-bundle.js', [ + 'fetch(', + 'XMLHttpRequest', + 'WebSocket', + 'EventSource', + 'navigator.', + 'localStorage', + 'sessionStorage', + 'setTimeout(', + 'setInterval(', + 'http://', + 'https://', ]); expectFragments(ASSET_ROOT + '/app.css', [ '@media (max-width: 520px)', @@ -202,6 +239,11 @@ function auditClusterCopilotConsole(options = {}) { '--port=0', 'TLS 1.3 `GET /readyz`', 'excluded from small router Edge/Standalone artifacts', + 'Export a redacted evidence bundle', + 'Export performs zero BFF or Cluster', + '8 MiB', + '512 KiB', + 'server signature', ]); rejectFragments(DEPLOYMENT_ROOT + '/README.md', [ '--host=0.0.0.0', @@ -222,6 +264,8 @@ function auditClusterCopilotConsole(options = {}) { 'runPublishedConsoleContract(image);', 'consoleLoopback: true', 'consoleAssets: true', + 'consoleEvidenceBundle: true', + "started.origin + '/evidence-bundle.js'", "consolePublishedHostAddress: '127.0.0.1'", 'consoleDistributionEmbedded: true', ]); @@ -341,7 +385,16 @@ function auditClusterCopilotConsole(options = {}) { ]), legacyUiCoupled: false, kubernetesResident: false, - assetCount: 3, + assetCount: 4, + evidenceBundle: Object.freeze({ + lifecycle: 'browser-local-explicit-export', + maximumRecords: 16, + maximumRawBytes: 8 * 1024 * 1024, + maximumBundleBytes: 512 * 1024, + upstreamReadsOnExport: 0, + attestation: 'none', + actionAuthority: 'none', + }), sourceFileCount: 4, findings: Object.freeze(findings), compatible: findings.length === 0, diff --git a/test/back/ql3ClusterAdminProductLiveContract.test.cjs b/test/back/ql3ClusterAdminProductLiveContract.test.cjs index f65b35a9..94513352 100644 --- a/test/back/ql3ClusterAdminProductLiveContract.test.cjs +++ b/test/back/ql3ClusterAdminProductLiveContract.test.cjs @@ -57,6 +57,8 @@ test('binds the live image gate to native and container-published loopback', () assert.match(source, /runConsoleContract\(image\);/); assert.match(source, /consoleLoopback: true/); assert.match(source, /consoleAssets: true/); + assert.match(source, /consoleEvidenceBundle: true/); + assert.match(source, /createClusterConsoleEvidenceBundle/); assert.match(source, /function runPublishedConsoleContract\(image\)/); assert.match( source, diff --git a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs index 5db1ef1d..2ceea7f8 100644 --- a/test/back/ql3ClusterCopilotConsoleAudit.test.cjs +++ b/test/back/ql3ClusterCopilotConsoleAudit.test.cjs @@ -42,7 +42,16 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => { ], legacyUiCoupled: false, kubernetesResident: false, - assetCount: 3, + assetCount: 4, + evidenceBundle: { + lifecycle: 'browser-local-explicit-export', + maximumRecords: 16, + maximumRawBytes: 8 * 1024 * 1024, + maximumBundleBytes: 512 * 1024, + upstreamReadsOnExport: 0, + attestation: 'none', + actionAuthority: 'none', + }, sourceFileCount: 4, findings: [], compatible: true, @@ -128,6 +137,29 @@ test('rejects browser persistence, dynamic rendering and product drift', () => { ); }); +test('rejects evidence export network, persistence and authority widening', () => { + for (const injected of [ + 'fetch(', + 'navigator.share(', + 'localStorage', + 'setTimeout(', + ]) { + const report = auditClusterCopilotConsole({ + root, + readFile: intercept( + 'packages/ql3-cluster-admin/assets/copilot-console/evidence-bundle.js', + (source) => source + '\n// ' + injected + '\n', + ), + }); + assert.equal(report.compatible, false); + assert.ok( + report.findings.some( + ({ code }) => code === 'CLUSTER_COPILOT_CONSOLE_AUTHORITY_WIDENED', + ), + ); + } +}); + test('rejects coupling into the legacy UI or Kubernetes workloads', () => { const legacyTarget = 'src/pages/login/index.tsx'; const legacy = auditClusterCopilotConsole({