reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

@@ -0,0 +1,220 @@
---
name: web-js-env-patcher
description: "面向网页端 JavaScript 的 Node.js 补环境 Skill。用于 env.js/runner.js、缺失环境追踪、RuyiTrace/Proxy 日志分析、Trace API 首轮覆盖与 runtime contract、浏览器对象模型与 native-like、addon/xbs native-first、私有状态泄露阻断、对象形状审计、iframe/Worker/DOM-CSSOM/Performance/WebAPI 行为矩阵、Canvas/WebGL/WebGPU/Audio/字体/DOM 几何真实值回放、XHR/fetch no-send 语义审计、TLS 指纹兼容 Session bridge、curl_cffi 同 Session、动态资源刷新、Cookie 生成链路、source/entry/builder/writer 定位、阶段报告和最终交付门禁。不要用于 App、移动端、小程序、Windows/Native 逆向或纯算重写;默认不主动分析 JSVMP 源码。"
---
# 网页端 JS Node.js 补环境
## 能力边界
本 Skill 只处理网页端浏览器 JavaScript 在 Node.js 中运行所需的环境分析、证据采集、WebAPI 实现和验证。
- 目标是运行原始网页 JS,而不是优先重写算法。
- 不处理 App、Android、iOS、小程序、EXE、DLL、Frida、IDA、JADX、Ghidra 等 Native 任务。
- 默认不主动还原 JSVMP、opcode 或虚拟机解释器;只定位环境依赖、请求链和 writer。需要进入 JSVMP 源码时先暂停并让用户明确改变范围。
- TLS 指纹兼容只用于授权验证浏览器网络栈差异,不得用于绕过登录、验证码、MFA、访问控制或批量访问。
## 工作模型
本 Skill 分三层:
1. `SKILL.md` 负责范围、决策、硬规则和主流程。
2. `references/` 负责场景细节、schema、采样方法和验收标准;只在触发对应场景时读取。
3. `scripts/` 负责确定性采集和门禁;脚本结果不能替代浏览器真实证据或工程判断。
不要一次性加载全部 references。先根据下方路由确定需要读取的文件。
## References 路由
### 任务确认与取证
- 新 case、信息缺失或需要确认模板:读取 `references/intake-template.md`、`references/workflow.md`、`references/delivery-templates.md`。
- 选择 ruyiPage、RuyiTrace、Camoufox、CloakBrowser、手动取证或 AI 决定:读取 `references/browser-acquisition.md`。
- 使用 ruyiPage / RuyiTrace、安装检测、自动捕获或导入 NDJSON:读取 `references/ruyi-tooling.md`。
- 使用 Camoufox / camoufox-reverse-mcp:读取 `references/camoufox-tooling.md`。
- 自动点击、拖拽、键盘、滚动或可能检测 `isTrusted`:读取 `references/trusted-input-and-isTrusted.md`。
- 验证码、challenge、WAF 或 verify 流程:读取 `references/captcha-flow-and-verify-handoff.md`。
- 固化同一 profile、seed、代理、语言、时区、UA、screen、WebGL:读取 `references/fingerprint-baseline-consistency.md`。
- 评估证据可信度或选择实现 / 回放 / 暂缓:读取 `references/trust-matrix.md`、`references/case-patterns.md`。
### 请求、资源与入口定位
- 解析请求、定位加密入口、XHR/fetch Hook 或梳理 `source → entry → builder → writer`:读取 `references/crypto-entry-location.md`、`references/hook-templates.md`。
- HTML、bundle、chunk、challenge JS、403 页面可能过期:读取 `references/dynamic-resource-freshness.md`。
- Cookie/token 失效或需要定位非登录 Cookie 生成链:读取 `references/cookie-generation-analysis.md`。
- 最终请求顺序、Cookie jar、Session 生命周期:读取 `references/session-request-chain.md`。
- UA、Client Hints、Header、TLS/HTTP2 与浏览器 baseline 对齐:读取 `references/tls-request-validation.md`。
### Trace 与环境实现
- 运行目标 JS、Proxy 探测、缺失环境定位:读取 `references/env-debug-loop.md`。
- Trace 首轮 API inventory、覆盖矩阵和计划外新增原因:读取 `references/trace-api-coverage.md`。
- Trace contract、audit-only/no-send 和 runtime 深度一致性:读取 `references/trace-runtime-conformance.md`。
- 选择不使用框架、isolated-vm、`vm` 或 jsEnv:读取 `references/runtime-frameworks.md`。
- 使用随包魔改 xbs isolated-vm:读取 `references/xbs-isolated-vm-api.md`。
- addon.node 或 xbs ABI 不兼容:读取 `references/node-version-recovery.md`。
- addon 新版 API、private API、native collection、plugins/mimeTypes:读取 `references/addon-api.md`。
- 浏览器对象清单、构造函数、原型链、descriptor 和访问器:读取 `references/env-object-model.md`。
- addon-first、toString、`document.all`、brand 和 native-like:读取 `references/env-native-protection.md`。
- Node 全局对象或宿主 WebAPI 泄露、静默失败:读取 `references/node-leakage-and-silent-failure.md`。
- Level 1/2/3 模块划分:读取 `references/env-module-levels.md`。
- 纯 JS、addon 和 xbs 都无法表达真实行为:读取 `references/native-capability-gap.md`。
### WebAPI、对象形状与指纹
- iframe、Worker、PerformanceTimeline、DOM/CSSOM、EventTarget、timer、writer 分支:读取 `references/webapi-env-detection-matrix.md`。
- 对象 ownKeys、descriptor、prototype walk 或 `_` / `__` 私有状态泄露:读取 `references/object-shape-private-state.md`。
- Canvas、WebGL、WebGPU、Audio、字体、DOM 几何等真实值采样和回放:读取 `references/fingerprint-value-replay.md`。
- 浏览器与 Node fixture 对比:读取 `references/fixture-validation.md`。
- Worker、WASM、MessagePort、structuredClone:读取 `references/wasm-worker-postmessage.md`。
- 高强度浏览器完整性检测或复杂行为 diff:读取 `references/high-strength-browser-detection.md`、`references/high-intensity-env-diff.md`。
### XHR/fetch 与最终请求
- XHR/fetch/sendBeacon 需要复用最终 TLS Session:读取 `references/xhr-fetch-session-bridge.md`。
- 请求头、请求体、actor、realm、生命周期、status、responseURL、reload 行为比较:读取 `references/xhr-fetch-semantics-audit.md`。
### 工程质量与交付
- 修改复杂 case 或关键源码:读取 `references/code-change-memory.md`。
- 编写或重构最终补环境代码:读取 `references/code-style.md`。
- 中文阶段报告:读取 `references/stage-markdown-reports.md`。
- 最终总结和环境 / 指纹 API 回放明细:读取 `references/final-project-summary.md`。
- 临时文件和敏感材料清理:读取 `references/cleanup.md`。
- 回归场景与 skill 自测:读取 `references/validation.md`。
## 硬规则
1. **先确认信息**:至少确认目标页面、目标 API、方法、加密参数名与位置、成功请求样本、已知 JS、取证模式、最终 TLS 客户端或“不发真实请求”。信息不足时只输出已知信息、缺失项和下一步,不启动取证、下载 JS、运行旧代码或写 env。
2. **取证模式先于取证动作**:打开网页、抓包、Hook、断点、截图、下载资源或采集 Trace 前,先确认 ruyiPage + RuyiTrace、仅 ruyiPage、Camoufox + MCP、仅 Camoufox、CloakBrowser、手动取证或 AI 决定。不得先用普通 Playwright / Puppeteer / 系统浏览器失败后再切换。
3. **固定 fingerprint baseline**:第一次成功取证后生成 `case/notes/fingerprint-baseline.json` 和 `baselineId`。后续网络、Trace、Hook、指纹和 Node audit 必须使用同一 profile / seed / 代理 / 语言 / 时区 / UA / Client Hints / screen / WebGL。切换工具或 baseline 前必须暂停确认。
4. **验证码和登录前置确认**:任何取证前先判断是否为验证码、风控验证、challenge 或 WAF 接口。需要登录时不索要账号、密码、验证码或 MFA;让用户在已确认工具中完成并回复成功。验证码 Trace 必须覆盖触发、展示、交互、提交和 verify 返回。
5. **请求样本不等于最终 baseline**:cURL/HAR 只提供 URL、方法、结构、参数位置和业务字段线索。若样本浏览器族与取证 baseline 冲突,默认以取证 baseline 的 UA、Client Hints、Header、TLS/HTTP2、Cookie 和指纹为准,并记录冲突。
6. **参数和链路先确认**:列出 Query、Header、Body、Cookie 中全部可疑动态参数,让用户确认本次范围;入口定位必须输出 `source → entry → builder → writer`。非登录 Cookie 失效时先分析生成 / 刷新链,不默认索要新 Cookie。
7. **动态资源不得固定化**:HTML、bundle、chunk、challenge JS、403 页面和内联 seed 默认只是分析快照。记录资源 freshness;影响最终参数时必须在同一 Session 内运行时刷新。
8. **Trace 命中必须首轮规划**:存在 RuyiTrace、Node trace、Hook 环境日志或 `missing-env.json` 时,在第一版 env / signer probe 前生成 `trace-api-inventory.json` 和 `env-coverage-matrix.md`。P0/P1 必须首轮实现、同 baseline 采样、明确暂不挂载或进入 native gap;不得等报错或 writer 分支失败后再补。`missed-from-trace` 必须作为流程缺陷记录。
9. **Trace 必须形成可执行 closure**:第一轮 env、每次环境修改、动态资源刷新或 baseline 变化后,重新生成 v3 Trace runtime contract,并让当前最终入口在 audit-only/no-send 模式输出 Node audit。必须比较 realm、receiver、descriptor、brand、prototype、ownKeys、结果 / 异常和原始状态时间线;不得从分组 observation 反推顺序。自动发现多份 Trace 时显式选择当前 baseline,禁止跨历史采集静默合并。手写 `matched`、同名 target、证据文本、关键词或 Top API 列表都不算观测;P0/P1 mismatch 或网络访问会阻断。
10. **框架由用户决定**:正式补环境前确认不使用框架(默认)、xbs isolated-vm、Node `vm` 或 jsEnv。Trace 复杂度只决定风险和优先级,不自动选择框架。isolated-vm 必须验证随包二进制、Node ABI、`window.xbs` 和 `xbs.dom.createDocument`;jsEnv 未提供真实项目和文档时不得虚构 API。
11. **Node 泄露和 native 能力前置**:运行目标 JS 前隔离 `process`、`Buffer`、`require`、`module`、`global`、Node `navigator`、Storage、performance 和宿主网络 / 消息 WebAPI。补环境初始化即检测 addon.node 或 xbs;可用时函数、访问器、构造函数、集合对象、plugins/mimeTypes、`document.all` 等必须 native-first。ABI 不兼容先按版本恢复流程处理,用户拒绝或恢复失败后才允许有记录的 fallback。
12. **真实浏览器对象模型默认开启**:关键 WebAPI 从第一版就处理 descriptor、getter/setter、构造行为、prototype、`instanceof`、toString、brand 和非法 receiver,不得用普通对象或简单函数糊住。iframe/Worker 必须拥有独立全局、构造器图、公开对象和生命周期;只允许共享有浏览器证据的底层同源状态,不得复用主 Realm wrapper。探测 Proxy 只能用于诊断,最终交付应固化真实对象。
13. **浏览器对象私有状态不得泄露**:目标 JS 可见对象不得通过 `_`、`__` 或自定义 Symbol 自有属性保存内部状态。优先 addon/xbs private API,其次模块级 `WeakMap`。对象形状必须以浏览器 baseline 与 Node audit 逐字段比较,不能只检查属性前缀或 target 名称。
14. **指纹值必须有真实证据**:优先使用同 baseline、未截断的 Trace 值;Trace 缺失、接近 4000/4096 截断、长度未知或 baseline 冲突时,用已确认浏览器工具补采。AI 猜值、随机值、默认值、mock、jsdom、node-canvas、headless-gl 不能作为最终回放值。长值记录完整长度、hash、分片和来源。
15. **XHR/fetch 必须同时满足语义和 Session**:真实请求时,XHR/fetch/sendBeacon 必须通过同一 TLS Session bridge,不能直接使用 Node 宿主网络,也不能返回默认 200 或 fixture 冒充成功。Python `curl_cffi` 场景由 `final.py` 持有唯一 Session 并服务 Node IPC。真实请求前生成完整 `network-transcript/v3` 并完成 no-send diff;URL、Header 实值/脱敏 hash、body/response hash、responseURL、响应头、actor/realm/epoch、全局事件顺序、reload 清理、Session 和多余请求任一缺失都阻断,双方同缺字段也不算 matched。
16. **代码质量和 native gap 不得绕过**:signer、probe、runner、diagnostic 只做编排,WebAPI 主体必须拆到 `src/env/` 或 `src/node-runtime/env/`;isolated-vm 不得用大段 `String.raw` 聚合源码。真实请求前必须通过代码质量与 addon-first 门禁。纯 JS、addon、xbs 都无法可靠实现时,输出 native 能力缺口、建议 API、最小浏览器/native 测试和通过标准,不得伪造成功。
17. **真实请求前统一总门禁**:只要 case 的 Trace、阶段报告、notes、fixtures、transcript 或 runtime 出现浏览器环境/XHR/fetch 信号,必须执行 `check_environment_closure.js --before-real-request`。总门禁主动聚合 Trace-runtime、对象形状、WebAPI/Realm/消息/DOM 行为矩阵、XHR/fetch no-send、同 Session、代码质量和 addon-first;任一失败都不得发送真实请求或交付。
18. **最终项目和清理**:最终项目只能有一个可直接运行入口 `result/final.js` 或 `result/final.py`,内部完成环境安装、Session 创建、动态资源刷新、参数生成、可选请求和 `finally` 销毁。不得包含浏览器自动化、临时 server/bridge、Trace、HAR、Hook、截图、Profile 或测试缓存。每个阶段及时清理临时产物;登录态和 Cookie 等敏感材料删除前确认用户意图。
## 主流程
### A. Intake 与取证确认
1. 确认任务属于网页端 JS Node.js 补环境;否则说明边界并停止。
2. 运行信息完整性检查,列出已知信息、缺失项和全部可疑参数。
3. 确认取证模式、最终 TLS 客户端或不发请求、最终 Session 模式、补环境框架选择时机。
4. 写入 `case/阶段报告/01-需求信息确认.md`;只有用户明确要求不生成报告时才记录豁免。
5. 信息完整后,先确认验证码 / 登录 / 权限交互,再开始取证。
6. 解析 cURL/HAR 与浏览器族,记录与 fingerprint baseline 的冲突。
### B. 证据与首轮环境计划
7. 使用已确认工具从第一次打开页面开始取证,固定 `baselineId`,收集成功请求、相关 JS 和动态资源 freshness。
8. 定位 `source → entry → builder → writer`,确认目标参数真实写入请求的位置。
9. 有 Trace 时先导入并生成 API inventory、覆盖矩阵和复杂度摘要;复杂度不决定框架。
10. 第一版 env 前完成全部 Trace P0/P1 决策;真实值类 API 缺样本时标记采样,不挂空壳。
11. 第一版 env 后立即生成 runtime contract,运行 audit-only/no-send Node audit 和深度 diff。
12. 触发 iframe、Worker、MessagePort、Performance、DOM CRUD/CSSOM、EventTarget、timer、writer、对象 shape 或网络行为时,用同一 probe suite 分别生成含实际 observation 的浏览器 baseline 与 Node audit;证据路径和手工 status 不能替代结果。
### C. 实现与迭代
13. 按用户选择初始化 runtime,执行 Node 泄露阻断,加载 addon/xbs 能力并记录 fallback。
14. 按 Level 1 基础运行层、Level 2 指纹真实性层、Level 3 目标 SDK 层实现;站点逻辑不得污染通用 env。
15. 所有浏览器对象采用模块化、native-first、真实性保护和私有状态隔离;维护 `case/notes/代码变更记忆.md`。
16. 指纹 API 只回放同 baseline 的真实浏览器值;缺失或截断时补采。
17. XHR/fetch 先实现浏览器语义和 no-send transcript,再接入已确认 TLS Session bridge。
18. 每次修改 env、baseline、动态资源或网络桥接后,重跑受影响的 runtime audit 和矩阵,不等新报错再补环境。
### D. 验证、请求与交付
19. 用 fixtures 比较浏览器与 Node 的关键输出;“不报错”不算完成,单独 OPTIONS preflight 不算目标请求成功。
20. 参数或请求不一致时,先排查 baseline、资源 freshness、Cookie/Storage、请求顺序、Header/TLS/Session、Trace 截断和自动化暴露,再修改 env。
21. 真实请求前执行统一 environment closure;真实模式增加 `--require-live` 和实际 `--tls-client`。
22. 只在 closure 通过后,由唯一入口使用同一 Session 执行少量授权验证。
23. 生成阶段报告和 `result/最终项目总结.md`,记录 hashes、mismatch 数、矩阵、回放来源、Session 链、风险和清理结果。
24. 运行最终产物检查,修复所有阻断项,清理临时文件后再交付。
## 统一命令入口
以下命令是主入口;更细的命令参数按对应 reference 使用。
```bash
# Intake
node scripts/check_intake.js --input task.md --markdown
# 真实请求前统一环境闭环
node scripts/check_environment_closure.js --case-dir case --before-real-request --markdown
node scripts/check_environment_closure.js --case-dir case --before-real-request --require-live --tls-client curl_cffi --markdown
# 阶段报告与最终产物
node scripts/check_stage_reports.js --case-dir case --require-dynamic-fields --markdown
node scripts/check_final_artifact.js --case-dir case --markdown
```
Trace 场景的最小闭环:
```bash
node scripts/build_trace_runtime_contract.js --case-dir case --baseline-id <baselineId> --markdown
node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.js --markdown
node scripts/check_trace_api_coverage.js --case-dir case --require-runtime-closure --markdown
```
XHR/fetch 场景的最小闭环:
```bash
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --out case/tmp/xhr-fetch-semantics-audit.json --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --require-live --tls-client curl_cffi --markdown
```
## 必需证据
根据触发场景生成,不得手工填写 `matched` 代替机器观测:
- 基线:`case/notes/fingerprint-baseline.json` 与统一 `baselineId`。
- Trace:`trace-api-inventory.json`、`env-coverage-matrix.md`、`trace-runtime-contract.json`、`node-trace-runtime-audit.json`。
- WebAPI:同 `probeSuiteVersion/probeSourceFile/probeSourceHash` 且含实际 observation 的 `browser-env-detection-baseline.json`、`node-env-detection-audit.json`、`webapi-env-detection-matrix.md`。
- 对象形状:`browser-object-shape-baseline.json`、`node-object-shape-audit.json`、`object-shape-audit.md`。
- 网络:浏览器 / Node network transcript、`xhr-fetch-semantics-audit.json`、`xhr-fetch-session-bridge-audit/v2`。
- 指纹:真实来源、`baselineId`、完整长度、SHA-256、截断状态和回放 key。
- 工程:阶段报告、代码变更记忆、最终总结、唯一入口和最终检查结果。
阶段报告与最终总结至少记录:
- `traceSourceHash`、`contractHash`、`runtimeSourceHash`、`baselineId`、probe version。
- Trace P0/P1 matched/mismatch、network mismatch、额外 Node 请求和 audit-only 网络尝试数。
- XHR/fetch Session 持有者、TLS 客户端、round trip 和 same-session 证明。
- WebAPI / 对象形状矩阵的触发类别、阻断项、真实值来源和 native fallback。
- 本阶段计划内 WebAPI、计划外新增原因、功能、Bug、指纹、测试、风险和清理。
## 交付约束
- 最终入口必须动态生成参数,不得硬编码 cURL、fixture 或历史请求中的加密值。
- 最终请求必须复用取证 baseline 对应的 UA、Client Hints、Header、TLS/HTTP2、代理、Cookie jar 和 Session。
- 自动化工具只用于前置取证,不得进入 `result/`。
- 用户选择不发真实请求时,只输出本地参数并明确未进行网络验证。
- 任一门禁失败时先修复和复测,不得只在总结中声明风险后交付。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "网页 JS 补环境"
short_description: "Trace 可执行闭环、XHR/fetch 同 Session 与对象形状审计"
default_prompt: "使用 $web-js-env-patcher 对网页端 JS 补环境执行 Trace-runtime、XHR/fetch 请求语义、同 Session bridge、对象形状和 WebAPI 矩阵闭环审计,并在真实请求前阻断所有未闭合差异。"

View File

@@ -0,0 +1,133 @@
// 基础 env 模板:提供网页端最小对象,并按描述符、原型链和 native-like 规则固化。
// 复制到 case/result 或 case/env 后,必须根据 fixtures 和 RuyiTrace 证据调整具体字段。
'use strict';
let protect = null;
try { protect = require('./native-protect'); } catch { protect = null; }
function defineValue(obj, key, value, options = {}) {
if (protect && protect.defineValue) return protect.defineValue(obj, key, value, options);
Object.defineProperty(obj, key, {
value,
writable: options.writable ?? false,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return value;
}
function defineGetter(obj, key, getter, options = {}) {
Object.defineProperty(obj, key, {
get: getter,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return getter;
}
function markNativeFunction(fn, name) {
if (protect && protect.markNativeFunction) return protect.markNativeFunction(fn, name);
return fn;
}
function markObjectToString(obj, tag) {
if (protect && protect.markObjectToString) return protect.markObjectToString(obj, tag);
try { Object.defineProperty(obj, Symbol.toStringTag, { value: tag, enumerable: false, configurable: true }); } catch {}
return obj;
}
function defineNativeGetter(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeGetter) return protect.defineNativeGetter(obj, key, impl, options, addon);
return defineGetter(obj, key, impl, options);
}
function defineNativeValue(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeValue) return protect.defineNativeValue(obj, key, impl, options, addon);
return defineValue(obj, key, impl, { writable: true, ...options });
}
function createNativeConstructor(name, length, impl, addon) {
if (protect && protect.createNativeConstructor) return protect.createNativeConstructor(name, length, impl, addon);
function Ctor(...args) { return impl.call(this, new.target ? true : false, ...args); }
try { Object.defineProperty(Ctor, 'name', { value: name, configurable: true }); } catch {}
return markNativeFunction(Ctor, name);
}
function throwIllegalConstructor(name, addon) {
if (protect && protect.throwIllegalConstructor) return protect.throwIllegalConstructor(name, addon);
throw new TypeError(`Failed to construct '${name}': Illegal constructor`);
}
function installBaseEnv(globalObject, fixture = {}, options = {}) {
const addon = options.addon || null;
const browser = fixture.browser || {};
const pageUrl = fixture.pageUrl || fixture.apiUrl || 'https://example.com/';
const url = new URL(pageUrl);
const Window = createNativeConstructor('Window', 0, function Window(isNew) {
return throwIllegalConstructor('Window', addon);
}, addon);
const Location = createNativeConstructor('Location', 0, function Location(isNew) {
return throwIllegalConstructor('Location', addon);
}, addon);
const Navigator = createNativeConstructor('Navigator', 0, function Navigator(isNew) {
return throwIllegalConstructor('Navigator', addon);
}, addon);
defineValue(Window.prototype, 'constructor', Window, { writable: true, enumerable: false, configurable: true });
defineValue(Location.prototype, 'constructor', Location, { writable: true, enumerable: false, configurable: true });
defineValue(Navigator.prototype, 'constructor', Navigator, { writable: true, enumerable: false, configurable: true });
markObjectToString(Window.prototype, 'Window');
markObjectToString(Location.prototype, 'Location');
markObjectToString(Navigator.prototype, 'Navigator');
const location = Object.create(Location.prototype);
const locationState = {
href: url.href,
origin: url.origin,
protocol: url.protocol,
host: url.host,
hostname: url.hostname,
port: url.port,
pathname: url.pathname,
search: url.search,
hash: url.hash,
};
for (const key of ['href', 'origin', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash']) {
defineNativeGetter(Location.prototype, key, function () { return locationState[key]; }, { enumerable: true, configurable: true, name: key }, addon);
}
defineNativeValue(Location.prototype, 'toString', function toString() { return this.href; }, { enumerable: false, configurable: true, writable: true, name: 'toString', length: 0 }, addon);
markObjectToString(location, 'Location');
const navigator = Object.create(Navigator.prototype);
const navState = {
userAgent: browser.userAgent || '',
language: browser.language || 'zh-CN',
languages: browser.languages || ['zh-CN', 'zh'],
platform: browser.platform || 'Win32',
hardwareConcurrency: browser.hardwareConcurrency ?? 8,
deviceMemory: browser.deviceMemory ?? 8,
webdriver: browser.webdriver ?? false,
cookieEnabled: browser.cookieEnabled ?? true,
};
for (const key of Object.keys(navState)) {
defineNativeGetter(Navigator.prototype, key, function () { return navState[key]; }, { enumerable: true, configurable: true, name: key }, addon);
}
markObjectToString(navigator, 'Navigator');
defineValue(globalObject, 'Window', Window, { writable: true, enumerable: false, configurable: true });
defineValue(globalObject, 'Location', Location, { writable: true, enumerable: false, configurable: true });
defineValue(globalObject, 'Navigator', Navigator, { writable: true, enumerable: false, configurable: true });
defineValue(globalObject, 'window', globalObject, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'self', globalObject, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'top', globalObject, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'parent', globalObject, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'location', location, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'navigator', navigator, { writable: false, enumerable: true, configurable: true });
markObjectToString(globalObject, 'Window');
return { Window, Location, Navigator, location, navigator };
}
module.exports = { installBaseEnv };

View File

@@ -0,0 +1,177 @@
// Document 模块模板:提供最小 document / DOM 原型链,并优先用 addon.createUndetectable 处理 document.all。
'use strict';
let protect = null;
try { protect = require('./native-protect'); } catch { protect = null; }
function defineValue(obj, key, value, options = {}) {
if (protect && protect.defineValue) return protect.defineValue(obj, key, value, options);
Object.defineProperty(obj, key, {
value,
writable: options.writable ?? false,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return value;
}
function markNativeFunction(fn, name) {
if (protect && protect.markNativeFunction) return protect.markNativeFunction(fn, name);
return fn;
}
function markObjectToString(obj, tag) {
if (protect && protect.markObjectToString) return protect.markObjectToString(obj, tag);
try { Object.defineProperty(obj, Symbol.toStringTag, { value: tag, enumerable: false, configurable: true }); } catch {}
return obj;
}
function defineNativeGetter(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeGetter) return protect.defineNativeGetter(obj, key, impl, options, addon);
Object.defineProperty(obj, key, { get: impl, enumerable: options.enumerable ?? true, configurable: options.configurable ?? true });
return impl;
}
function defineNativeSetter(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeSetter) return protect.defineNativeSetter(obj, key, impl, options, addon);
Object.defineProperty(obj, key, { set: impl, enumerable: options.enumerable ?? true, configurable: options.configurable ?? true });
return impl;
}
function defineNativeValue(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeValue) return protect.defineNativeValue(obj, key, impl, options, addon);
return defineValue(obj, key, impl, { writable: true, ...options });
}
function createNativeConstructor(name, length, impl, addon) {
if (protect && protect.createNativeConstructor) return protect.createNativeConstructor(name, length, impl, addon);
function Ctor(...args) { return impl.call(this, new.target ? true : false, ...args); }
try { Object.defineProperty(Ctor, 'name', { value: name, configurable: true }); } catch {}
return markNativeFunction(Ctor, name);
}
function throwIllegalConstructor(name, addon) {
if (protect && protect.throwIllegalConstructor) return protect.throwIllegalConstructor(name, addon);
throw new TypeError(`Failed to construct '${name}': Illegal constructor`);
}
function throwConstructorRequiresNew(name, addon) {
if (protect && protect.throwConstructorRequiresNew) return protect.throwConstructorRequiresNew(name, addon);
throw new TypeError(`Failed to construct '${name}': Please use the 'new' operator, this DOM object constructor cannot be called as a function.`);
}
function installDocumentAll(document, addon) {
let value;
let exact = false;
if (protect && typeof protect.createUndetectable === 'function') {
value = protect.createUndetectable(function documentAll() { return undefined; }, addon);
exact = value !== undefined;
} else if (addon && typeof addon.createUndetectable === 'function') {
try {
value = addon.createUndetectable(function documentAll() { return undefined; });
exact = true;
} catch {}
}
if (!exact) value = undefined;
Object.defineProperty(document, 'all', {
value,
writable: false,
enumerable: false,
configurable: true,
});
return { exact, value };
}
function installDocumentEnv(globalObject, fixture = {}, options = {}) {
const addon = options.addon || null;
const pageUrl = fixture.pageUrl || fixture.apiUrl || (globalObject.location && globalObject.location.href) || 'https://example.com/';
const url = new URL(pageUrl);
const cookieJar = new Map();
const initialCookie = (fixture.request && fixture.request.cookie) || fixture.cookie || '';
if (initialCookie) {
for (const pair of String(initialCookie).split(';')) {
const idx = pair.indexOf('=');
if (idx > 0) cookieJar.set(pair.slice(0, idx).trim(), pair.slice(idx + 1).trim());
}
}
const EventTarget = createNativeConstructor('EventTarget', 0, function EventTarget(isNew) {
if (!isNew) return throwConstructorRequiresNew('EventTarget', addon);
}, addon);
const Node = createNativeConstructor('Node', 0, function Node(isNew) {
return throwIllegalConstructor('Node', addon);
}, addon);
const Document = createNativeConstructor('Document', 0, function Document(isNew) {
return throwIllegalConstructor('Document', addon);
}, addon);
const HTMLDocument = createNativeConstructor('HTMLDocument', 0, function HTMLDocument(isNew) {
return throwIllegalConstructor('HTMLDocument', addon);
}, addon);
const Element = createNativeConstructor('Element', 0, function Element(isNew) {
return throwIllegalConstructor('Element', addon);
}, addon);
const HTMLElement = createNativeConstructor('HTMLElement', 0, function HTMLElement(isNew) {
return throwIllegalConstructor('HTMLElement', addon);
}, addon);
const HTMLHtmlElement = createNativeConstructor('HTMLHtmlElement', 0, function HTMLHtmlElement(isNew) {
return throwIllegalConstructor('HTMLHtmlElement', addon);
}, addon);
for (const ctor of [EventTarget, Node, Document, HTMLDocument, Element, HTMLElement, HTMLHtmlElement]) {
defineValue(ctor.prototype, 'constructor', ctor, { writable: true, enumerable: false, configurable: true });
markObjectToString(ctor.prototype, ctor.name);
}
Object.setPrototypeOf(Node.prototype, EventTarget.prototype);
Object.setPrototypeOf(Document.prototype, Node.prototype);
Object.setPrototypeOf(HTMLDocument.prototype, Document.prototype);
Object.setPrototypeOf(Element.prototype, Node.prototype);
Object.setPrototypeOf(HTMLElement.prototype, Element.prototype);
Object.setPrototypeOf(HTMLHtmlElement.prototype, HTMLElement.prototype);
const document = Object.create(HTMLDocument.prototype);
const documentElement = Object.create(HTMLHtmlElement.prototype);
markObjectToString(document, 'HTMLDocument');
markObjectToString(documentElement, 'HTMLHtmlElement');
defineNativeGetter(Document.prototype, 'URL', function () { return url.href; }, { enumerable: true, configurable: true, name: 'URL' }, addon);
defineNativeGetter(Document.prototype, 'documentURI', function () { return url.href; }, { enumerable: true, configurable: true, name: 'documentURI' }, addon);
defineNativeGetter(Document.prototype, 'referrer', function () { return fixture.referrer || ''; }, { enumerable: true, configurable: true, name: 'referrer' }, addon);
defineNativeGetter(Document.prototype, 'hidden', function () { return false; }, { enumerable: true, configurable: true, name: 'hidden' }, addon);
defineNativeGetter(Document.prototype, 'visibilityState', function () { return 'visible'; }, { enumerable: true, configurable: true, name: 'visibilityState' }, addon);
defineNativeGetter(Document.prototype, 'documentElement', function () { return documentElement; }, { enumerable: true, configurable: true, name: 'documentElement' }, addon);
defineNativeGetter(Document.prototype, 'defaultView', function () { return globalObject.window || globalObject; }, { enumerable: true, configurable: true, name: 'defaultView' }, addon);
const getCookie = function () {
return Array.from(cookieJar.entries()).map(([k, v]) => `${k}=${v}`).join('; ');
};
const setCookie = function (value) {
const first = String(value || '').split(';')[0];
const idx = first.indexOf('=');
if (idx > 0) cookieJar.set(first.slice(0, idx).trim(), first.slice(idx + 1).trim());
};
const cookieGetter = protect && protect.createNativeGetter ? protect.createNativeGetter('cookie', getCookie, addon) : getCookie;
const cookieSetter = protect && protect.createNativeSetter ? protect.createNativeSetter('cookie', setCookie, addon) : setCookie;
Object.defineProperty(Document.prototype, 'cookie', { get: cookieGetter, set: cookieSetter, enumerable: true, configurable: true });
defineNativeValue(Document.prototype, 'querySelector', function querySelector() { return null; }, { enumerable: true, configurable: true, writable: true, name: 'querySelector', length: 1 }, addon);
defineNativeValue(Document.prototype, 'getElementById', function getElementById() { return null; }, { enumerable: true, configurable: true, writable: true, name: 'getElementById', length: 1 }, addon);
defineNativeValue(Document.prototype, 'createElement', function createElement(tagName) {
const el = Object.create(HTMLElement.prototype);
defineValue(el, 'tagName', String(tagName || '').toUpperCase(), { writable: false, enumerable: true, configurable: true });
defineValue(el, 'nodeName', String(tagName || '').toUpperCase(), { writable: false, enumerable: true, configurable: true });
markObjectToString(el, 'HTMLElement');
return el;
}, { enumerable: true, configurable: true, writable: true, name: 'createElement', length: 1 }, addon);
const allStatus = installDocumentAll(document, addon);
for (const ctor of [EventTarget, Node, Document, HTMLDocument, Element, HTMLElement, HTMLHtmlElement]) {
defineValue(globalObject, ctor.name, ctor, { writable: true, enumerable: false, configurable: true });
}
defineValue(globalObject, 'document', document, { writable: false, enumerable: true, configurable: true });
return { document, documentElement, allStatus, EventTarget, Node, Document, HTMLDocument, Element, HTMLElement, HTMLHtmlElement };
}
module.exports = { installDocumentEnv, installDocumentAll };

View File

@@ -0,0 +1,349 @@
// 指纹值回放模板:用于 Canvas / WebGL / DOM 几何等终端 API 的真实浏览器采样值回放。
// 使用前复制到 case/result/src/env/ 中,并接入 case/fixtures/fingerprint.fixture.json。
// 目标:回放真实浏览器最终返回值,而不是在 Node.js 中真实模拟渲染。
'use strict';
let native = {};
try { native = require('./native-protect'); } catch {}
const defineValue = native.defineValue || function defineValue(obj, key, value, options = {}) {
Object.defineProperty(obj, key, {
value,
writable: options.writable ?? true,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return value;
};
const defineNativeValue = native.defineNativeValue || function defineNativeValue(obj, key, impl, options = {}) {
return defineValue(obj, key, impl, options);
};
const defineNativeGetter = native.defineNativeGetter || function defineNativeGetter(obj, key, impl, options = {}) {
Object.defineProperty(obj, key, {
get: impl,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return impl;
};
const markObjectToString = native.markObjectToString || function markObjectToString(obj, tag) {
try {
Object.defineProperty(obj, Symbol.toStringTag, {
value: tag,
enumerable: false,
configurable: true,
});
} catch {}
return obj;
};
function stackText() {
try { return String(new Error().stack || ''); } catch { return ''; }
}
function b64ToBytes(value) {
if (!value) return new Uint8ClampedArray();
const buf = Buffer.from(String(value), 'base64');
return new Uint8ClampedArray(buf.buffer, buf.byteOffset, buf.byteLength);
}
function includesStack(match, stack) {
if (!match || !match.stackIncludes) return true;
const needles = Array.isArray(match.stackIncludes) ? match.stackIncludes : [match.stackIncludes];
return needles.some(s => stack.includes(String(s)));
}
function matchScalar(expected, actual) {
if (expected === undefined) return true;
return String(expected) === String(actual);
}
function findReplay(list, ctx, options = {}) {
const items = Array.isArray(list) ? list : [];
const stack = ctx.stack || stackText();
for (const item of items) {
const match = item.match || {};
let ok = includesStack(match, stack);
for (const [key, expected] of Object.entries(match)) {
if (key === 'stackIncludes') continue;
ok = ok && matchScalar(expected, ctx[key]);
}
if (ok) return item.result;
}
if (options.strict !== false) {
const brief = Object.fromEntries(Object.entries(ctx).filter(([k]) => k !== 'stack'));
throw new Error(`缺少浏览器指纹回放样本:${ctx.api || 'unknown'} ${JSON.stringify(brief)}`);
}
return undefined;
}
function createImageData(result) {
const width = Number(result && result.width) || 0;
const height = Number(result && result.height) || 0;
const data = result && result.dataBase64 ? b64ToBytes(result.dataBase64) : new Uint8ClampedArray(width * height * 4);
return markObjectToString({ width, height, data }, 'ImageData');
}
function ensureConstructors(g, options = {}) {
const addon = options.addon;
if (!g.HTMLCanvasElement) {
const HTMLCanvasElement = native.createNativeConstructor ? native.createNativeConstructor('HTMLCanvasElement', 0, function HTMLCanvasElement() {}, addon) : function HTMLCanvasElement() {};
defineValue(g, 'HTMLCanvasElement', HTMLCanvasElement, { enumerable: false, configurable: true });
}
if (!g.CanvasRenderingContext2D) {
const CanvasRenderingContext2D = native.createNativeConstructor ? native.createNativeConstructor('CanvasRenderingContext2D', 0, function CanvasRenderingContext2D() {}, addon) : function CanvasRenderingContext2D() {};
defineValue(g, 'CanvasRenderingContext2D', CanvasRenderingContext2D, { enumerable: false, configurable: true });
}
if (!g.WebGLRenderingContext) {
const WebGLRenderingContext = native.createNativeConstructor ? native.createNativeConstructor('WebGLRenderingContext', 0, function WebGLRenderingContext() {}, addon) : function WebGLRenderingContext() {};
defineValue(g, 'WebGLRenderingContext', WebGLRenderingContext, { enumerable: false, configurable: true });
}
if (!g.DOMRect) {
const DOMRect = native.createNativeConstructor ? native.createNativeConstructor('DOMRect', 4, function DOMRect(isNew, x = 0, y = 0, width = 0, height = 0) {
if (!isNew && !(this instanceof DOMRect)) return new g.DOMRect(x, y, width, height);
defineValue(this, 'x', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'y', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'width', Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'height', Number(height), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'top', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'left', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'right', Number(x) + Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'bottom', Number(y) + Number(height), { enumerable: true, writable: false, configurable: true });
markObjectToString(this, 'DOMRect');
}, addon) : function DOMRect(x = 0, y = 0, width = 0, height = 0) {
defineValue(this, 'x', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'y', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'width', Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'height', Number(height), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'top', Number(y), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'left', Number(x), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'right', Number(x) + Number(width), { enumerable: true, writable: false, configurable: true });
defineValue(this, 'bottom', Number(y) + Number(height), { enumerable: true, writable: false, configurable: true });
markObjectToString(this, 'DOMRect');
};
defineValue(g, 'DOMRect', DOMRect, { enumerable: false, configurable: true });
}
for (const Ctor of [g.HTMLCanvasElement, g.CanvasRenderingContext2D, g.WebGLRenderingContext, g.DOMRect]) {
try {
defineValue(Ctor.prototype, 'constructor', Ctor, { enumerable: false, writable: true, configurable: true });
} catch {}
}
try { markObjectToString(g.HTMLCanvasElement.prototype, 'HTMLCanvasElement'); } catch {}
try { markObjectToString(g.CanvasRenderingContext2D.prototype, 'CanvasRenderingContext2D'); } catch {}
try { markObjectToString(g.WebGLRenderingContext.prototype, 'WebGLRenderingContext'); } catch {}
}
function create2DContext(g, canvas, state) {
const ctx = Object.create(g.CanvasRenderingContext2D.prototype);
ctx.__canvas = canvas;
ctx.__state = state || { font: '10px sans-serif' };
markObjectToString(ctx, 'CanvasRenderingContext2D');
return ctx;
}
function createWebGLContext(g, canvas, state) {
const gl = Object.create(g.WebGLRenderingContext.prototype);
gl.__canvas = canvas;
gl.__state = state || {};
markObjectToString(gl, 'WebGLRenderingContext');
return gl;
}
function installCanvasReplay(g, fixture, options) {
const canvasFx = fixture.canvas || {};
const proto = g.HTMLCanvasElement.prototype;
const ctx2dProto = g.CanvasRenderingContext2D.prototype;
defineNativeValue(proto, 'getContext', function getContext(type) {
const kind = String(type || '').toLowerCase();
if (kind === '2d') return this.__ctx2d || (this.__ctx2d = create2DContext(g, this, { font: '10px sans-serif' }));
if (kind === 'webgl' || kind === 'experimental-webgl' || kind === 'webgl2') return this.__webgl || (this.__webgl = createWebGLContext(g, this, {}));
return null;
}, { name: 'getContext', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'toDataURL', function toDataURL(type = 'image/png', quality) {
const result = findReplay(canvasFx.toDataURL, {
api: 'canvas.toDataURL',
width: this.width,
height: this.height,
type,
quality,
stack: stackText(),
}, options);
return result === undefined ? '' : String(result);
}, { name: 'toDataURL', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'toBlob', function toBlob(callback, type = 'image/png', quality) {
const result = findReplay(canvasFx.toBlob, {
api: 'canvas.toBlob',
width: this.width,
height: this.height,
type,
quality,
stack: stackText(),
}, { ...options, strict: options.strictToBlob ?? options.strict });
if (typeof callback === 'function') {
const blob = markObjectToString({ type, size: result && result.size ? Number(result.size) : 0, __replay: result }, 'Blob');
callback(blob);
}
}, { name: 'toBlob', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeGetter(ctx2dProto, 'font', function getFont() {
return this.__state && this.__state.font || '10px sans-serif';
}, { name: 'font', enumerable: true, configurable: true }, options.addon);
Object.defineProperty(ctx2dProto, 'font', {
get: Object.getOwnPropertyDescriptor(ctx2dProto, 'font').get,
set: function setFont(value) { this.__state.font = String(value); },
enumerable: true,
configurable: true,
});
defineNativeValue(ctx2dProto, 'measureText', function measureText(text) {
const result = findReplay(canvasFx.measureText, {
api: 'canvas.measureText',
text: String(text),
font: this.font,
stack: stackText(),
}, options) || { width: 0 };
return markObjectToString({ ...result }, 'TextMetrics');
}, { name: 'measureText', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(ctx2dProto, 'getImageData', function getImageData(sx, sy, sw, sh) {
const result = findReplay(canvasFx.getImageData, {
api: 'canvas.getImageData',
sx, sy, sw, sh,
stack: stackText(),
}, options);
return createImageData(result || { width: sw, height: sh });
}, { name: 'getImageData', length: 4, enumerable: true, configurable: true }, options.addon);
for (const name of ['fillText', 'strokeText', 'drawImage', 'fillRect', 'clearRect', 'rect', 'arc', 'beginPath', 'closePath', 'stroke', 'fill', 'putImageData']) {
if (!ctx2dProto[name]) {
defineNativeValue(ctx2dProto, name, function noop() {}, { name, length: 0, enumerable: true, configurable: true }, options.addon);
}
}
}
function installWebGLReplay(g, fixture, options) {
const webglFx = fixture.webgl || {};
const proto = g.WebGLRenderingContext.prototype;
defineNativeValue(proto, 'getParameter', function getParameter(pname) {
return findReplay(webglFx.getParameter, { api: 'webgl.getParameter', pname, stack: stackText() }, options);
}, { name: 'getParameter', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getSupportedExtensions', function getSupportedExtensions() {
const configured = webglFx.getSupportedExtensions;
if (configured && Array.isArray(configured.result)) return configured.result.slice();
return findReplay(webglFx.getSupportedExtensions, { api: 'webgl.getSupportedExtensions', stack: stackText() }, options) || [];
}, { name: 'getSupportedExtensions', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getExtension', function getExtension(name) {
const configured = webglFx.getExtension;
const result = findReplay(configured, { api: 'webgl.getExtension', name: String(name), stack: stackText() }, { ...options, strict: false });
return result === undefined ? null : markObjectToString({ name: String(name), ...result }, String(name));
}, { name: 'getExtension', length: 1, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getShaderPrecisionFormat', function getShaderPrecisionFormat(shaderType, precisionType) {
const result = findReplay(webglFx.getShaderPrecisionFormat, {
api: 'webgl.getShaderPrecisionFormat',
shaderType,
precisionType,
stack: stackText(),
}, options) || { rangeMin: 0, rangeMax: 0, precision: 0 };
return markObjectToString({ ...result }, 'WebGLShaderPrecisionFormat');
}, { name: 'getShaderPrecisionFormat', length: 2, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'readPixels', function readPixels(x, y, width, height, format, type, pixels) {
const result = findReplay(webglFx.readPixels, {
api: 'webgl.readPixels',
x, y, width, height, format, type,
stack: stackText(),
}, options);
if (pixels && result && result.dataBase64) {
const bytes = b64ToBytes(result.dataBase64);
for (let i = 0; i < Math.min(pixels.length, bytes.length); i++) pixels[i] = bytes[i];
}
}, { name: 'readPixels', length: 7, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getContextAttributes', function getContextAttributes() {
return { alpha: true, antialias: true, depth: true, desynchronized: false, failIfMajorPerformanceCaveat: false, powerPreference: 'default', premultipliedAlpha: true, preserveDrawingBuffer: false, stencil: false, xrCompatible: false };
}, { name: 'getContextAttributes', length: 0, enumerable: true, configurable: true }, options.addon);
}
function installDomGeometryReplay(g, fixture, options) {
const domFx = fixture.domGeometry || {};
if (!g.Element) return;
const proto = g.Element.prototype;
function selectorOf(el) {
return el.__fingerprintSelector || el.id && `#${el.id}` || el.className && `.${String(el.className).split(/\s+/)[0]}` || String(el.tagName || '').toLowerCase();
}
defineNativeValue(proto, 'getBoundingClientRect', function getBoundingClientRect() {
const result = findReplay(domFx.getBoundingClientRect, {
api: 'dom.getBoundingClientRect',
selector: selectorOf(this),
stack: stackText(),
}, options) || {};
return new g.DOMRect(result.x || result.left || 0, result.y || result.top || 0, result.width || 0, result.height || 0);
}, { name: 'getBoundingClientRect', length: 0, enumerable: true, configurable: true }, options.addon);
defineNativeValue(proto, 'getClientRects', function getClientRects() {
const rect = this.getBoundingClientRect();
return markObjectToString({ 0: rect, length: 1, item(i) { return i === 0 ? rect : null; } }, 'DOMRectList');
}, { name: 'getClientRects', length: 0, enumerable: true, configurable: true }, options.addon);
for (const key of ['offsetWidth', 'offsetHeight', 'scrollWidth', 'scrollHeight', 'clientWidth', 'clientHeight']) {
defineNativeGetter(proto, key, function geometryGetter() {
const result = findReplay(domFx.offset, {
api: `dom.${key}`,
selector: selectorOf(this),
stack: stackText(),
}, { ...options, strict: false });
if (result && result[key] !== undefined) return Number(result[key]);
const rect = this.getBoundingClientRect();
return /height/i.test(key) ? Math.round(rect.height || 0) : Math.round(rect.width || 0);
}, { name: key, enumerable: true, configurable: true }, options.addon);
}
}
function createCanvasElement(globalObj, width = 300, height = 150) {
const g = globalObj || globalThis;
ensureConstructors(g, options);
const canvas = Object.create(g.HTMLCanvasElement.prototype);
defineValue(canvas, 'width', Number(width), { enumerable: true, writable: true, configurable: true });
defineValue(canvas, 'height', Number(height), { enumerable: true, writable: true, configurable: true });
markObjectToString(canvas, 'HTMLCanvasElement');
return canvas;
}
function installFingerprintValueReplay(globalObj, fixture = {}, options = {}) {
const g = globalObj || globalThis;
const replayOptions = { strict: true, ...options };
ensureConstructors(g, replayOptions);
installCanvasReplay(g, fixture, replayOptions);
installWebGLReplay(g, fixture, replayOptions);
installDomGeometryReplay(g, fixture, replayOptions);
if (!g.document) return { createCanvasElement: (w, h) => createCanvasElement(g, w, h) };
if (g.document && typeof g.document.createElement === 'function' && !g.document.__fingerprintCreateElementPatched) {
const rawCreateElement = g.document.createElement;
defineNativeValue(g.document, 'createElement', function createElement(tagName, optionsArg) {
const tag = String(tagName || '').toLowerCase();
if (tag === 'canvas') return createCanvasElement(g);
return rawCreateElement.call(this, tagName, optionsArg);
}, { name: 'createElement', length: 1, enumerable: true, configurable: true }, replayOptions.addon);
defineValue(g.document, '__fingerprintCreateElementPatched', true, { enumerable: false, configurable: true });
}
return { createCanvasElement: (w, h) => createCanvasElement(g, w, h) };
}
module.exports = {
installFingerprintValueReplay,
createCanvasElement,
findReplay,
};

View File

@@ -0,0 +1,92 @@
// Storage 模块模板:按 fixture 注入 localStorage / sessionStorage,并固化原型链、描述符和 native-like 方法。
'use strict';
let protect = null;
try { protect = require('./native-protect'); } catch { protect = null; }
function defineValue(obj, key, value, options = {}) {
if (protect && protect.defineValue) return protect.defineValue(obj, key, value, options);
Object.defineProperty(obj, key, {
value,
writable: options.writable ?? false,
enumerable: options.enumerable ?? true,
configurable: options.configurable ?? true,
});
return value;
}
function markNativeFunction(fn, name) {
if (protect && protect.markNativeFunction) return protect.markNativeFunction(fn, name);
return fn;
}
function markObjectToString(obj, tag) {
if (protect && protect.markObjectToString) return protect.markObjectToString(obj, tag);
try { Object.defineProperty(obj, Symbol.toStringTag, { value: tag, enumerable: false, configurable: true }); } catch {}
return obj;
}
function defineNativeGetter(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeGetter) return protect.defineNativeGetter(obj, key, impl, options, addon);
Object.defineProperty(obj, key, { get: impl, enumerable: options.enumerable ?? true, configurable: options.configurable ?? true });
return impl;
}
function defineNativeValue(obj, key, impl, options = {}, addon) {
if (protect && protect.defineNativeValue) return protect.defineNativeValue(obj, key, impl, options, addon);
return defineValue(obj, key, impl, { writable: true, ...options });
}
function createNativeConstructor(name, length, impl, addon) {
if (protect && protect.createNativeConstructor) return protect.createNativeConstructor(name, length, impl, addon);
function Ctor(...args) { return impl.call(this, new.target ? true : false, ...args); }
try { Object.defineProperty(Ctor, 'name', { value: name, configurable: true }); } catch {}
return markNativeFunction(Ctor, name);
}
function throwIllegalConstructor(name, addon) {
if (protect && protect.throwIllegalConstructor) return protect.throwIllegalConstructor(name, addon);
throw new TypeError(`Failed to construct '${name}': Illegal constructor`);
}
function installStorage(globalObject, fixture = {}, options = {}) {
const addon = options.addon || null;
const request = fixture.request || {};
const Storage = createNativeConstructor('Storage', 0, function Storage(isNew) {
return throwIllegalConstructor('Storage', addon);
}, addon);
defineValue(Storage.prototype, 'constructor', Storage, { writable: true, enumerable: false, configurable: true });
markObjectToString(Storage.prototype, 'Storage');
defineNativeGetter(Storage.prototype, 'length', function () { return this.__data.size; }, { enumerable: true, configurable: true, name: 'length' }, addon);
defineNativeValue(Storage.prototype, 'key', function key(index) { return Array.from(this.__data.keys())[Number(index)] ?? null; }, { enumerable: true, configurable: true, writable: true, name: 'key', length: 1 }, addon);
defineNativeValue(Storage.prototype, 'getItem', function getItem(key) { key = String(key); return this.__data.has(key) ? this.__data.get(key) : null; }, { enumerable: true, configurable: true, writable: true, name: 'getItem', length: 1 }, addon);
defineNativeValue(Storage.prototype, 'setItem', function setItem(key, value) { this.__data.set(String(key), String(value)); }, { enumerable: true, configurable: true, writable: true, name: 'setItem', length: 2 }, addon);
defineNativeValue(Storage.prototype, 'removeItem', function removeItem(key) { this.__data.delete(String(key)); }, { enumerable: true, configurable: true, writable: true, name: 'removeItem', length: 1 }, addon);
defineNativeValue(Storage.prototype, 'clear', function clear() { this.__data.clear(); }, { enumerable: true, configurable: true, writable: true, name: 'clear', length: 0 }, addon);
function createStorage(initial = {}) {
const data = new Map(Object.entries(initial).map(([k, v]) => [String(k), String(v)]));
const storage = Object.create(Storage.prototype);
defineValue(storage, '__data', data, { enumerable: false, configurable: false, writable: false });
markObjectToString(storage, 'Storage');
return storage;
}
const localStorage = createStorage(request.localStorage || fixture.localStorage || {});
const sessionStorage = createStorage(request.sessionStorage || fixture.sessionStorage || {});
defineValue(globalObject, 'Storage', Storage, { writable: true, enumerable: false, configurable: true });
defineValue(globalObject, 'localStorage', localStorage, { writable: false, enumerable: true, configurable: true });
defineValue(globalObject, 'sessionStorage', sessionStorage, { writable: false, enumerable: true, configurable: true });
return { Storage, localStorage, sessionStorage, createStorage };
}
function createStorage(initial = {}, options = {}) {
const context = {};
return installStorage(context, { localStorage: initial }, options).localStorage;
}
module.exports = { createStorage, installStorage };

View File

@@ -0,0 +1,435 @@
// xbs isolated-vm runtime 模板:仅在用户明确选择 isolated-vm 时复制到最终项目。
// 默认加载随 Skill 携带的魔改 isolated-vm 二进制,并在隔离 Context 内使用 window.xbs / globalThis.xbs。
// 不要在该模式下桥接旧 addon.node;xbs API 就是 isolated-vm Context 内的 native-first 能力。
// 补环境源码应保存为真实 .js 文件,通过 runFile/runFiles 读取后注入 Context,避免大段 String.raw 聚合脚本。
'use strict';
const fs = require('fs');
const path = require('path');
const EXPECTED_XBS_APIS = Object.freeze([
'clearProtoChainRegistry',
'createGetter',
'createInterceptor',
'createNativeCollection',
'createNativeFunction',
'createNativeObject',
'createProtoChains',
'createSetter',
'createUndetectable',
'deletePrivate',
'deleteProtoChainRegistryEntry',
'getMimeTypesAndPlugins',
'getPrivate',
'getProtoChainRegistry',
'hasPrivate',
'setPrivate',
'throwTypeError',
]);
function getPlatformKey() {
return process.platform + '-' + process.arch;
}
function toPosixPath(value) {
return String(value || '').replace(/\\/g, '/');
}
function resolveSourceRoot(options = {}) {
const explicit = options.sourceRoot || options.projectRoot || process.env.WEB_JS_ENV_PATCHER_SOURCE_ROOT;
return path.resolve(explicit || process.cwd());
}
function resolveSourceFilePath(sourceRoot, requestedPath, allowOutsideSourceRoot = false) {
if (!requestedPath) throw new Error('runFile 需要传入文件路径');
const resolved = path.resolve(sourceRoot, requestedPath);
const relativePath = path.relative(sourceRoot, resolved);
const escaped = relativePath.startsWith('..') || path.isAbsolute(relativePath);
if (escaped && !allowOutsideSourceRoot) {
throw new Error('runFile 路径逃逸 sourceRoot:' + requestedPath);
}
return resolved;
}
function readSourceFile(sourceRoot, requestedPath, options = {}) {
const filePath = resolveSourceFilePath(sourceRoot, requestedPath, !!options.allowOutsideSourceRoot);
const source = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
const filename = options.filename || toPosixPath(path.relative(sourceRoot, filePath));
return { filePath, filename, source };
}
function getBundledXbsIsolatedVmPath() {
return path.join(__dirname, 'xbs-isolated-vm', getPlatformKey(), 'isolated_vm.node');
}
function resolveXbsIsolatedVmPath(options = {}) {
const explicit = options.xbsIsolatedVmPath || process.env.WEB_JS_ENV_PATCHER_XBS_ISOLATED_VM;
return path.resolve(explicit || getBundledXbsIsolatedVmPath());
}
function isAbiMismatch(error) {
const message = String(error && error.message ? error.message : error || '');
return /NODE_MODULE_VERSION|Module version mismatch|was compiled against a different Node\.js version|ABI/i.test(message);
}
function formatLoadFailure(error, binaryPath) {
const message = String(error && error.message ? error.message : error || '未知错误');
const lines = [
'无法加载随包魔改版 xbs isolated-vm。',
'二进制路径:' + binaryPath,
'当前平台:' + getPlatformKey(),
'当前 Node:' + process.version + ',ABI:' + (process.versions && process.versions.modules ? process.versions.modules : 'unknown'),
'原始错误:' + message,
];
if (!fs.existsSync(binaryPath)) {
lines.push('当前平台目录下未找到 isolated_vm.node;请提供当前平台 / 架构 / Node ABI 匹配的魔改构建产物,或改选不使用框架 / vm / jsEnv。');
} else if (isAbiMismatch(error)) {
lines.push('这是 Node ABI 不匹配问题;请提供与当前 Node ABI 匹配的魔改 xbs isolated-vm 构建产物,或切换到匹配 Node 版本,或改选不使用框架 / vm / jsEnv。');
} else {
lines.push('请确认该二进制来自魔改 xbs isolated-vm,且与当前平台、架构、Node ABI 兼容。不要自动退回 npm 原版 isolated-vm。');
}
return lines.join('\n');
}
function normalizeIvmExport(loadedModule) {
if (loadedModule && typeof loadedModule.Isolate === 'function') return loadedModule;
if (loadedModule && loadedModule.ivm && typeof loadedModule.ivm.Isolate === 'function') return loadedModule.ivm;
if (loadedModule && loadedModule.default && typeof loadedModule.default.Isolate === 'function') return loadedModule.default;
if (loadedModule && loadedModule.default && loadedModule.default.ivm && typeof loadedModule.default.ivm.Isolate === 'function') return loadedModule.default.ivm;
throw new TypeError('魔改 isolated_vm.node 已加载,但未导出可用的 Isolate 构造函数。');
}
function loadXbsIsolatedVm(options = {}) {
const binaryPath = resolveXbsIsolatedVmPath(options);
try {
const loadedModule = require(binaryPath);
const ivm = normalizeIvmExport(loadedModule);
return {
ivm,
binaryPath,
platformKey: getPlatformKey(),
nodeVersion: process.version,
nodeAbi: process.versions && process.versions.modules ? process.versions.modules : '',
expectedApis: EXPECTED_XBS_APIS.slice(),
};
} catch (error) {
const wrapped = new Error(formatLoadFailure(error, binaryPath));
wrapped.cause = error;
wrapped.binaryPath = binaryPath;
wrapped.abiMismatch = isAbiMismatch(error);
throw wrapped;
}
}
async function maybeAwait(value) {
return value && typeof value.then === 'function' ? await value : value;
}
async function createContext(isolate) {
if (typeof isolate.createContextSync === 'function') return isolate.createContextSync();
return maybeAwait(isolate.createContext());
}
async function compileScript(isolate, source, options) {
if (typeof isolate.compileScriptSync === 'function') return isolate.compileScriptSync(String(source), options);
return maybeAwait(isolate.compileScript(String(source), options));
}
async function runCompiledScript(script, context, options) {
if (typeof script.runSync === 'function') return script.runSync(context, options);
return maybeAwait(script.run(context, options));
}
async function runSource(isolate, context, source, options = {}) {
const script = await compileScript(isolate, source, { filename: options.filename || 'anonymous.js' });
return runCompiledScript(script, context, {
timeout: options.timeoutMs || 5000,
copy: options.copy !== false,
});
}
function copyIntoIsolate(ivm, value) {
if (ivm && typeof ivm.ExternalCopy === 'function') {
return new ivm.ExternalCopy(value).copyInto();
}
return value;
}
async function setGlobalValue(ivm, globalRef, name, value) {
const copied = copyIntoIsolate(ivm, value);
if (globalRef && typeof globalRef.setSync === 'function') return globalRef.setSync(name, copied);
if (globalRef && typeof globalRef.set === 'function') return maybeAwait(globalRef.set(name, copied));
throw new Error('isolated-vm global 对象不支持 set:' + name);
}
async function getGlobalValue(globalRef, name) {
if (globalRef && typeof globalRef.getSync === 'function') return globalRef.getSync(name);
if (globalRef && typeof globalRef.get === 'function') return maybeAwait(globalRef.get(name));
throw new Error('isolated-vm global 对象不支持 get:' + name);
}
function buildXbsSelfCheckSource() {
const expectedJson = JSON.stringify(EXPECTED_XBS_APIS.slice().sort());
return '(() => {\n' +
' const expected = ' + expectedJson + ';\n' +
' const api = globalThis.xbs || (typeof window !== "undefined" && window.xbs);\n' +
' const apiNames = api ? Object.keys(api).sort() : [];\n' +
' const missing = expected.filter(name => !apiNames.includes(name));\n' +
' const extra = apiNames.filter(name => !expected.includes(name));\n' +
' const dom = { ok: false, hasDom: false, hasCreateDocument: false };\n' +
' try {\n' +
' dom.hasDom = !!(api && api.dom);\n' +
' dom.hasCreateDocument = !!(api && api.dom && typeof api.dom.createDocument === "function");\n' +
' dom.windowDocumentWasPreinstalled = typeof window !== "undefined" && "document" in window;\n' +
' if (dom.hasCreateDocument) {\n' +
' const document = api.dom.createDocument({\n' +
' url: "https://example.com/path?q=1",\n' +
' html: "<main id=\\"app\\"><span class=\\"hot\\">hello</span><iframe id=\\"f\\"></iframe></main>"\n' +
' });\n' +
' const withoutAll = api.dom.createDocument({ omitApis: ["document.all"] });\n' +
' const iframe = document.getElementById("f");\n' +
' dom.documentCtor = document && document.constructor ? document.constructor.name : "";\n' +
' dom.url = document.URL;\n' +
' dom.documentURI = document.documentURI;\n' +
' dom.text = document.querySelector(".hot").textContent;\n' +
' dom.hasAll = "all" in document;\n' +
' dom.allType = typeof document.all;\n' +
' const allValue = document.all;\n' +
' dom.allLooseNull = document.all == null;\n' +
' dom.allStrictUndefined = document.all === undefined;\n' +
' dom.allBoolean = Boolean(document.all);\n' +
' dom.allLengthType = typeof document.all.length;\n' +
' dom.allItemType = typeof document.all.item;\n' +
' dom.allNamedItemType = typeof document.all.namedItem;\n' +
' dom.allObjectToString = Object.prototype.toString.call(document.all);\n' +
' dom.allOwnLength = Object.hasOwn(document.all, \"length\");\n' +
' dom.allOwnItem = Object.hasOwn(document.all, \"item\");\n' +
' dom.allOwnNamedItem = Object.hasOwn(document.all, \"namedItem\");\n' +
' dom.allSameReference = allValue === document.all;\n' +
' dom.omitAllWorks = !("all" in withoutAll);\n' +
' dom.iframeContentDocument = !!(iframe && iframe.contentDocument && iframe.contentWindow && iframe.contentWindow.document === iframe.contentDocument);\n' +
' dom.ok = dom.documentCtor === "HTMLDocument" && dom.text === "hello" &&\n' +
' dom.url === "https://example.com/path?q=1" && dom.documentURI === dom.url &&\n' +
' dom.hasAll && dom.allType === "undefined" && dom.allLooseNull &&\n' +
' !dom.allStrictUndefined && dom.allBoolean === false &&\n' +
' dom.allLengthType === "number" && dom.allItemType === "function" &&\n' +
' dom.allNamedItemType === "function" && dom.allObjectToString === "[object HTMLAllCollection]" &&\n' +
' dom.allOwnLength === false && dom.allOwnItem === false && dom.allOwnNamedItem === false &&\n' +
' dom.allSameReference && dom.omitAllWorks;\n' +
' }\n' +
' } catch (error) {\n' +
' dom.error = String(error && error.message ? error.message : error);\n' +
' }\n' +
' return {\n' +
' windowIsGlobal: typeof window !== "undefined" && window === globalThis,\n' +
' selfIsWindow: typeof self !== "undefined" && typeof window !== "undefined" && self === window,\n' +
' topIsWindow: typeof top !== "undefined" && typeof window !== "undefined" && top === window,\n' +
' parentIsWindow: typeof parent !== "undefined" && typeof window !== "undefined" && parent === window,\n' +
' hasWindowConstructor: typeof Window === "function",\n' +
' windowInstanceOfWindow: typeof Window === "function" && typeof window !== "undefined" && window instanceof Window,\n' +
' hasXbs: !!api, hasXbsDom: dom.hasDom, hasCreateDocument: dom.hasCreateDocument, apiNames, missing, extra, dom\n' +
' };\n' +
'})()';
}
async function inspectXbsApi(isolate, context, timeoutMs = 5000) {
const result = await runSource(isolate, context, buildXbsSelfCheckSource(), {
filename: 'xbs-self-check.js',
timeoutMs,
copy: true,
});
const ok = !!(
result &&
result.windowIsGlobal &&
result.hasXbs &&
Array.isArray(result.missing) &&
result.missing.length === 0 &&
result.hasXbsDom &&
result.hasCreateDocument &&
result.dom &&
result.dom.ok
);
return Object.assign({ ok }, result || {});
}
function buildXbsSelfCheckError(selfCheck) {
const dom = selfCheck && selfCheck.dom ? selfCheck.dom : null;
return [
'xbs isolated-vm Context 自检失败。',
'window === globalThis:' + (selfCheck && selfCheck.windowIsGlobal ? '是' : '否'),
'是否存在 xbs:' + (selfCheck && selfCheck.hasXbs ? '是' : '否'),
'缺失核心 API:' + (selfCheck && selfCheck.missing && selfCheck.missing.length ? selfCheck.missing.join(', ') : '无'),
'额外 API:' + (selfCheck && selfCheck.extra && selfCheck.extra.length ? selfCheck.extra.join(', ') : '无'),
'是否存在 xbs.dom:' + (selfCheck && selfCheck.hasXbsDom ? '是' : '否'),
'是否存在 xbs.dom.createDocument:' + (selfCheck && selfCheck.hasCreateDocument ? '是' : '否'),
'DOM smoke test:' + (dom && dom.ok ? '通过' : '未通过'),
'DOM 错误:' + (dom && dom.error ? dom.error : '无'),
'请确认加载的是魔改版 xbs isolated-vm,而不是 npm 原版 isolated-vm。',
].join('\n');
}
function buildInstallDomSource() {
return '(() => {\n' +
' const options = Object.assign({}, globalThis.__xbsDomOptions__ || {});\n' +
' const attachToWindow = options.attachToWindow !== false;\n' +
' delete options.enabled;\n' +
' delete options.attachToWindow;\n' +
' const document = xbs.dom.createDocument(options);\n' +
' if (attachToWindow) {\n' +
' Object.defineProperty(window, "document", {\n' +
' value: document,\n' +
' writable: true,\n' +
' enumerable: true,\n' +
' configurable: true\n' +
' });\n' +
' }\n' +
' globalThis.__xbsDocument__ = document;\n' +
' return {\n' +
' enabled: true,\n' +
' attachToWindow,\n' +
' hasWindowDocument: typeof window !== "undefined" && "document" in window,\n' +
' documentCtor: document && document.constructor ? document.constructor.name : "",\n' +
' url: document.URL,\n' +
' documentURI: document.documentURI,\n' +
' hasAll: "all" in document,\n' +
' allType: typeof document.all,\n' +
' allLooseNull: document.all == null,\n' +
' allStrictUndefined: document.all === undefined,\n' +
' allBoolean: Boolean(document.all),\n' +
' allLengthType: typeof document.all.length,\n' +
' allItemType: typeof document.all.item,\n' +
' allNamedItemType: typeof document.all.namedItem,\n' +
' allObjectToString: Object.prototype.toString.call(document.all),\n' +
' allOwnLength: Object.hasOwn(document.all, \"length\"),\n' +
' allOwnItem: Object.hasOwn(document.all, \"item\"),\n' +
' allOwnNamedItem: Object.hasOwn(document.all, \"namedItem\")\n' +
' };\n' +
'})()';
}
function createIsolatedVmRuntime(options = {}) {
const loaded = loadXbsIsolatedVm(options);
const ivm = loaded.ivm;
const installEnvSource = options.installEnvSource || '';
const memoryLimit = options.memoryLimitMb || 128;
const sourceRoot = resolveSourceRoot(options);
const isolate = new ivm.Isolate({ memoryLimit });
let context = null;
let jail = null;
let xbsSelfCheck = null;
let domInstallSummary = null;
return {
name: 'xbs-isolated-vm',
mode: 'isolated-vm',
isolate,
binaryPath: loaded.binaryPath,
nodeAbi: loaded.nodeAbi,
platformKey: loaded.platformKey,
async initialize(fixture = {}) {
context = await createContext(isolate);
jail = context.global;
await setGlobalValue(ivm, jail, '__fixture__', fixture);
xbsSelfCheck = await inspectXbsApi(isolate, context, options.timeoutMs || 5000);
if (options.requireXbs !== false && !xbsSelfCheck.ok) throw new Error(buildXbsSelfCheckError(xbsSelfCheck));
if (options.dom && options.dom.enabled) {
await setGlobalValue(ivm, jail, '__xbsDomOptions__', options.dom);
domInstallSummary = await runSource(isolate, context, buildInstallDomSource(), {
filename: 'xbs-dom-install.js',
timeoutMs: options.timeoutMs || 5000,
copy: true,
});
}
if (installEnvSource) {
await runSource(isolate, context, String(installEnvSource), {
filename: 'install-env.js',
timeoutMs: options.timeoutMs || 5000,
copy: true,
});
}
return { context, jail, xbs: xbsSelfCheck, dom: domInstallSummary, binaryPath: loaded.binaryPath, nodeAbi: loaded.nodeAbi, platformKey: loaded.platformKey };
},
async load(sourceCode, meta = {}) {
if (!context) throw new Error('xbs isolated-vm runtime 尚未初始化');
return runSource(isolate, context, String(sourceCode), {
filename: meta.filename || 'target.js',
timeoutMs: options.timeoutMs || 5000,
copy: meta.copy !== false,
});
},
async runFile(filePath, meta = {}) {
if (!context) throw new Error('xbs isolated-vm runtime 尚未初始化');
const loadedFile = readSourceFile(sourceRoot, filePath, {
filename: meta.filename,
allowOutsideSourceRoot: meta.allowOutsideSourceRoot || options.allowOutsideSourceRoot,
});
return runSource(isolate, context, loadedFile.source, {
filename: loadedFile.filename,
timeoutMs: meta.timeoutMs || options.timeoutMs || 5000,
copy: meta.copy !== false,
});
},
async runFiles(files, meta = {}) {
if (!Array.isArray(files)) throw new TypeError('runFiles 需要传入文件路径数组');
const results = [];
for (const item of files) {
const fileItem = typeof item === 'string' ? { path: item } : Object(item || {});
results.push(await this.runFile(fileItem.path || fileItem.file, Object.assign({}, meta, fileItem.options || {}, {
filename: fileItem.filename || (fileItem.options && fileItem.options.filename),
})));
}
return results;
},
getSourceRoot() {
return sourceRoot;
},
async call(entry, args = []) {
if (!context) throw new Error('xbs isolated-vm runtime 尚未初始化');
const fn = await getGlobalValue(context.global, String(entry));
if (!fn || typeof fn.apply !== 'function') throw new TypeError('入口函数不存在或不可调用:' + String(entry));
const copiedArgs = copyIntoIsolate(ivm, args);
return maybeAwait(fn.apply(undefined, copiedArgs, {
arguments: { copy: true },
result: { copy: true },
timeout: options.timeoutMs || 5000,
}));
},
getXbsApiSummary() {
return xbsSelfCheck ? Object.assign({}, xbsSelfCheck) : null;
},
getDomInstallSummary() {
return domInstallSummary ? Object.assign({}, domInstallSummary) : null;
},
async dispose() {
if (isolate && !isolate.isDisposed && typeof isolate.dispose === 'function') isolate.dispose();
},
};
}
module.exports = {
EXPECTED_XBS_APIS,
buildXbsSelfCheckError,
createIsolatedVmRuntime,
createXbsIsolatedVmRuntime: createIsolatedVmRuntime,
getBundledXbsIsolatedVmPath,
getPlatformKey,
inspectXbsApi,
buildInstallDomSource,
isAbiMismatch,
loadXbsIsolatedVm,
normalizeIvmExport,
readSourceFile,
resolveSourceFilePath,
resolveSourceRoot,
resolveXbsIsolatedVmPath,
toPosixPath,
};

View File

@@ -0,0 +1,60 @@
// jsEnv runtime 适配模板:仅在用户明确选择 jsEnv 且提供项目路径、入口和文档后复制到最终项目。
// 本模板不假设任何具体 jsEnv API;必须由 case 根据用户提供的 jsEnv 文档注入 adapter。
'use strict';
function loadJsEnvAdapter(options = {}) {
if (options.adapter && typeof options.adapter === 'object') return options.adapter;
if (typeof options.adapterFactory === 'function') return options.adapterFactory(options);
if (options.adapterPath) {
// adapterPath 应为最终项目内的相对路径或用户运行时显式提供的路径,不要写入本机绝对路径。
return require(options.adapterPath);
}
throw new Error('已选择 jsEnv,但未提供 adapter、adapterFactory 或 adapterPath;请先根据 jsEnv 文档完成适配。');
}
function assertAdapter(adapter) {
const missing = [];
for (const name of ['initialize', 'load', 'call']) {
if (typeof adapter[name] !== 'function') missing.push(name);
}
if (missing.length) {
throw new Error(`jsEnv adapter 缺少必要方法:${missing.join(', ')}`);
}
}
function createJsEnvRuntime(options = {}) {
const adapter = loadJsEnvAdapter(options);
assertAdapter(adapter);
let runtimeState = null;
return {
name: 'jsEnv',
async initialize(fixture = {}) {
runtimeState = await adapter.initialize({
fixture,
options,
});
return runtimeState;
},
async load(sourceCode, meta = {}) {
if (!runtimeState) throw new Error('jsEnv runtime 尚未初始化');
return adapter.load(runtimeState, String(sourceCode), meta);
},
async call(entry, args = []) {
if (!runtimeState) throw new Error('jsEnv runtime 尚未初始化');
return adapter.call(runtimeState, entry, args);
},
async dispose() {
if (adapter.dispose && runtimeState) {
await adapter.dispose(runtimeState);
}
runtimeState = null;
},
};
}
module.exports = { createJsEnvRuntime };

View File

@@ -0,0 +1,38 @@
// 默认 runtime 模板:不使用 isolated-vm、vm 或 jsEnv 框架。
// 复制到最终项目前,应先完成 Node 泄露阻断,并由 installEnv 安装浏览器环境对象。
'use strict';
function createNoFrameworkRuntime(options = {}) {
const globalObject = options.globalObject || globalThis;
const installEnv = options.installEnv || function installEnv() {};
const loadTarget = options.loadTarget || function loadTarget() {};
return {
name: 'none',
async initialize(fixture = {}) {
// 在当前受控全局对象上安装补环境,字段值来自 fixture 和取证样本
await installEnv(globalObject, fixture, options);
return globalObject;
},
async load(sourceCode, meta = {}) {
// 不使用框架时由调用方提供安全加载逻辑,不在模板中暴露 require/process
return loadTarget(globalObject, sourceCode, meta);
},
async call(entry, args = []) {
const fn = typeof entry === 'function' ? entry : globalObject[entry];
if (typeof fn !== 'function') {
throw new TypeError(`入口函数不存在:${String(entry)}`);
}
return fn.apply(globalObject, args);
},
async dispose() {
// 默认模式没有额外 isolate 资源需要释放
},
};
}
module.exports = { createNoFrameworkRuntime };

View File

@@ -0,0 +1,38 @@
// runtime 工厂模板:根据用户确认的补环境框架选择加载对应 runtime。
// 未选择框架时默认 none;不要因为 Trace 复杂度高而自动切换到 isolated-vm、vm 或 jsEnv。
'use strict';
function normalizeRuntimeMode(mode) {
const value = String(mode || 'none').trim().toLowerCase();
if (value === 'isolated-vm' || value === 'isolated_vm' || value === 'ivm') return 'isolated-vm';
if (value === 'vm' || value === 'node-vm' || value === 'nodejs-vm') return 'vm';
if (value === 'jsenv' || value === 'js-env' || value === 'js_env') return 'jsEnv';
return 'none';
}
function createRuntime(options = {}) {
const mode = normalizeRuntimeMode(options.mode);
if (mode === 'isolated-vm') {
const { createIsolatedVmRuntime } = require('./isolated-vm-runtime');
return createIsolatedVmRuntime(options);
}
if (mode === 'vm') {
const { createVmRuntime } = require('./vm-runtime');
return createVmRuntime(options);
}
if (mode === 'jsEnv') {
const { createJsEnvRuntime } = require('./jsenv-runtime');
return createJsEnvRuntime(options);
}
const { createNoFrameworkRuntime } = require('./no-framework-runtime');
return createNoFrameworkRuntime(options);
}
module.exports = {
createRuntime,
normalizeRuntimeMode,
};

View File

@@ -0,0 +1,54 @@
// Node.js vm runtime 模板:仅在用户明确选择 vm 时复制到最终项目。
// vm 是轻量隔离,不是强安全边界;仍需严格阻断 Node 泄露。
'use strict';
const vm = require('vm');
function createVmRuntime(options = {}) {
const installEnv = options.installEnv || function installEnv() {};
const sandbox = Object.create(null);
const context = vm.createContext(sandbox, {
name: options.name || 'web-js-env-patcher-context',
codeGeneration: options.codeGeneration || {
strings: true,
wasm: false,
},
});
return {
name: 'vm',
context,
sandbox,
async initialize(fixture = {}) {
// 先安装浏览器式环境对象,不向目标上下文注入 process、Buffer、require、module
await installEnv(sandbox, fixture, options);
return sandbox;
},
async load(sourceCode, meta = {}) {
const script = new vm.Script(String(sourceCode), {
filename: meta.filename || 'target.js',
displayErrors: true,
});
return script.runInContext(context, {
timeout: options.timeoutMs || 5000,
displayErrors: true,
});
},
async call(entry, args = []) {
const fn = typeof entry === 'function' ? entry : sandbox[entry];
if (typeof fn !== 'function') {
throw new TypeError(`入口函数不存在:${String(entry)}`);
}
return fn.apply(sandbox, args);
},
async dispose() {
// vm context 没有显式释放 API,敏感状态由外层清理 case 临时目录时处理
},
};
}
module.exports = { createVmRuntime };

View File

@@ -0,0 +1,640 @@
# XBS API 使用说明
本文档说明当前魔改版 `isolated-vm` 中 `window.xbs` 的用法。
`xbs` 只会注入到 isolated-vm 创建出的浏览器式 Context 内,即 `window.xbs` / `globalThis.xbs`,不会污染宿主侧 `require("isolated-vm")` 的原始 API。
## 1. 基本使用方式
```js
const ivm = require("./isolated-vm");
const isolate = new ivm.Isolate();
const context = isolate.createContextSync();
const result = isolate.compileScriptSync(`
({
windowIsGlobal: window === globalThis,
hasXbs: !!window.xbs,
apiNames: Object.keys(window.xbs).sort(),
})
`).runSync(context, { copy: true });
```
在 Context 内可以直接使用:
```js
const fn = xbs.createNativeFunction(false, "demo", 0, function () {
return "ok";
});
fn(); // "ok"
```
当前 Context 默认具备以下基础关系:
```js
window === globalThis; // true
self === window; // true
top === window; // true
parent === window; // true
window instanceof Window; // true
Object.getPrototypeOf(window) === Window.prototype; // true
Object.getPrototypeOf(Window.prototype) === WindowProperties.prototype; // true
Object.getPrototypeOf(WindowProperties.prototype) === EventTarget.prototype; // true
```
## 2. API 总览
当前 `window.xbs` 暴露 17 个 API:
| API | 说明 |
| --- | --- |
| `createNativeObject` | 创建带 native-like 构造函数、实例与原型链的对象。 |
| `createNativeFunction` | 创建 native-like 函数或构造函数。 |
| `createProtoChains` | 批量创建浏览器风格构造函数、原型链、别名、实例工厂与注册表。 |
| `createGetter` | 创建 native-like getter。 |
| `createSetter` | 创建 native-like setter。 |
| `createInterceptor` | 创建带 V8 属性拦截器的对象。 |
| `createNativeCollection` | 创建 `HTMLCollection` / `NodeList` 等浏览器集合风格对象。 |
| `getMimeTypesAndPlugins` | 创建浏览器风格 `navigator.mimeTypes` 与 `navigator.plugins`。 |
| `createUndetectable` | 创建 V8 undetectable 对象,常用于近似 `document.all`。 |
| `getPrivate` | 读取 V8 Private slot。 |
| `setPrivate` | 写入 V8 Private slot。 |
| `hasPrivate` | 判断 V8 Private slot 是否存在。 |
| `deletePrivate` | 删除 V8 Private slot。 |
| `getProtoChainRegistry` | 查看当前 isolate 的原型链注册表。 |
| `clearProtoChainRegistry` | 清空当前 isolate 的原型链注册表。 |
| `deleteProtoChainRegistryEntry` | 删除当前 isolate 注册表中的指定构造函数或别名。 |
| `throwTypeError` | 从 native 层抛出 `TypeError`。 |
## 3. `createNativeFunction(isConstructor, name, length, callback)`
创建 `Function.prototype.toString` 结果为 native-like 的函数。
### 参数
| 参数 | 类型 | 说明 |
| --- | --- | --- |
| `isConstructor` | `boolean` | 是否允许 `new` 调用。 |
| `name` | `string` | 函数名。 |
| `length` | `number` | 函数的 `length`。 |
| `callback` | `function` | 实际 JS 回调。 |
### 普通函数示例
```js
const add = xbs.createNativeFunction(false, "add", 2, function (a, b) {
return a + b;
});
add(1, 2); // 3
Function.prototype.toString.call(add);
// "function add() { [native code] }"
```
### 构造函数示例
构造调用时,`callback` 的第一个参数是 `isNew`,后面才是实际传入参数。构造调用最终返回 `this`。
```js
const Storage = xbs.createNativeFunction(true, "Storage", 0, function (isNew) {
this.createdByNew = isNew;
});
const storage = new Storage();
storage.createdByNew; // true
```
## 4. `createGetter(name, length, callback)`
创建 native-like getter,常配合 `Object.defineProperty` 使用。
```js
const navigator = {};
Object.defineProperty(navigator, "userAgent", {
get: xbs.createGetter("userAgent", 0, function () {
return "Mozilla/5.0";
}),
enumerable: true,
configurable: true,
});
navigator.userAgent; // "Mozilla/5.0"
Function.prototype.toString.call(Object.getOwnPropertyDescriptor(navigator, "userAgent").get);
// "function get userAgent() { [native code] }"
```
## 5. `createSetter(name, length, callback)`
创建 native-like setter。
```js
const location = {};
let href = "https://example.com/";
Object.defineProperty(location, "href", {
get: xbs.createGetter("href", 0, function () {
return href;
}),
set: xbs.createSetter("href", 1, function (value) {
href = String(value);
}),
enumerable: true,
configurable: true,
});
location.href = "https://example.org/";
location.href; // "https://example.org/"
```
## 6. `createProtoChains(descriptors)`
批量创建构造函数、原型对象、继承关系、实例工厂和别名。该 API 是补浏览器对象模型时最常用的基础能力。
### 常用 descriptor 字段
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `name` | `string` | 构造函数名称。必填。 |
| `length` | `number` | 构造函数 `length`。 |
| `constructor` | `function` | 构造函数回调。普通构造函数必填。 |
| `prototypeParent` | `string` | 原型父级构造函数名称。 |
| `constructorParent` | `string` / `null` | 构造函数对象父级。未设置时默认跟随 `prototypeParent`;设为 `null` 可禁用默认继承。 |
| `readOnlyPrototypeProperty` | `boolean` | 是否让构造函数的 `.prototype` 属性只读。 |
| `immutablePrototypeObject` | `boolean` | 是否禁止修改构造函数 `.prototype` 对象的原型。 |
| `immutableInstancePrototype` | `boolean` | 是否禁止修改实例对象的原型。 |
| `hasToStringTag` | `boolean` | 是否在 prototype 上设置 `Symbol.toStringTag`,默认 `true`。 |
| `toStringTag` | `string` | 自定义 `Symbol.toStringTag` 值。 |
| `aliases` | `string[]` | 当前构造函数的别名列表。 |
| `aliasOf` | `string` | 创建指向已有构造函数的别名。 |
| `internalClassName` | `string` | 设置内部类名,用于影响 `Object.prototype.toString`。 |
| `constructorBehavior` | `"allow" \| "throw" \| "illegal"` | 控制 `new Constructor()` 行为。 |
| `callBehavior` | `"allow" \| "throw" \| "illegal"` | 控制 `Constructor()` 直接调用行为。 |
| `constructorErrorMessage` | `string` | 构造调用报错信息。 |
| `callErrorMessage` | `string` | 直接调用报错信息。 |
| `prototypeMethods` | `Array` | 安装到 prototype 的 native-like 方法。 |
| `staticMethods` | `Array` | 安装到构造函数对象上的 native-like 静态方法。 |
| `instanceFactoryName` | `string` | 额外导出一个内部实例工厂。 |
| `instanceInitializer` | `function` | 实例工厂创建对象后的初始化函数。 |
### 创建继承链
```js
const chain = xbs.createProtoChains([
{
name: "EventTarget",
length: 0,
constructor: function () {},
readOnlyPrototypeProperty: true,
immutablePrototypeObject: true,
},
{
name: "Node",
length: 0,
constructor: function () {},
prototypeParent: "EventTarget",
readOnlyPrototypeProperty: true,
immutablePrototypeObject: true,
},
]);
const EventTarget = chain.EventTarget;
const Node = chain.Node;
Object.getPrototypeOf(Node.prototype) === EventTarget.prototype; // true
Object.getPrototypeOf(Node) === EventTarget; // true
new Node() instanceof EventTarget; // true
```
### 非法构造 + 内部实例工厂
适合模拟 `Document`、`Location` 等不能直接 `new`,但框架内部需要创建实例的对象。
```js
const chain = xbs.createProtoChains([
{
name: "Document",
length: 0,
constructor() {},
constructorBehavior: "throw",
constructorErrorMessage: "Illegal constructor",
callBehavior: "throw",
callErrorMessage: "Illegal constructor",
instanceFactoryName: "createDocument",
instanceInitializer(url) {
this.URL = url;
},
},
]);
const Document = chain.Document;
const document = chain.createDocument("https://example.com/");
document instanceof Document; // true
new Document(); // TypeError: Illegal constructor
```
## 7. 原型链注册表 API
`createProtoChains` 创建的构造函数会登记到当前 isolate 的注册表中。注册表按 isolate 隔离,不同 `new ivm.Isolate()` 之间不会互相污染。
```js
const registry = xbs.getProtoChainRegistry();
registry.constructors; // 构造函数名称数组
registry.aliases; // 别名映射对象
xbs.deleteProtoChainRegistryEntry("URL"); // 删除指定构造函数或别名
xbs.clearProtoChainRegistry(); // 清空当前 isolate 的注册表
```
## 8. `createNativeObject(options)`
创建带 native-like 构造函数、实例对象与可选父级原型链的对象。新代码更推荐使用 `createProtoChains`,但该 API 仍可用于兼容旧逻辑。
```js
const result = xbs.createNativeObject({
name: "Navigator",
length: 0,
constructor: function () {},
isReadOnlyPrototype: true,
isImmutableProto: true,
});
result.constructor; // Navigator 构造函数
result.instance; // Navigator 实例
```
## 9. `createInterceptor(options)`
创建带 V8 属性拦截器的对象,用于模拟需要自定义读取、写入、枚举、描述符等行为的宿主对象。
### handlers
| handler | 签名 | 说明 |
| --- | --- | --- |
| `getter` | `(target, property)` | 读取属性时调用。返回 `{ intercept: false }` 可放行默认读取。 |
| `setter` | `(target, property, value)` | 写属性时调用。可返回 `{ intercept: true, value }` 改写保存值。 |
| `query` | `(target, property)` | 属性存在性查询。 |
| `deleter` | `(target, property)` | 删除属性时调用。 |
| `enumerator` | `(target)` | 枚举属性名时调用。 |
| `definer` | `(target, property, descriptor)` | `Object.defineProperty` 时调用。 |
| `descriptor` | `(target, property)` | `Object.getOwnPropertyDescriptor` 时调用。 |
### 示例
```js
const target = { existing: 1 };
const obj = xbs.createInterceptor({
target,
internalClassName: "MagicObject",
handlers: {
getter(targetObject, property) {
if (property === "virtual") {
return { value: 42 };
}
return { intercept: false };
},
setter(targetObject, property, value) {
return { intercept: true, value: String(value) };
},
descriptor(targetObject, property) {
if (property === "virtual") {
return {
value: 42,
writable: true,
enumerable: true,
configurable: true,
};
}
},
enumerator() {
return ["virtual"];
},
},
});
obj.virtual; // 42
obj.existing; // 1
obj.created = 123;
target.created; // "123"
Object.keys(obj); // ["virtual"]
Object.prototype.toString.call(obj); // "[object MagicObject]"
```
## 10. `createNativeCollection(options)`
创建浏览器集合对象,例如 `HTMLCollection`、`NodeList`、`PluginArray` 类似对象。
```js
const first = { id: 1 };
const second = { id: 2 };
const result = xbs.createNativeCollection({
name: "HTMLCollection",
items: [
{ name: "first", value: first },
{ name: "second", value: second },
],
hasToStringTag: false,
internalClassName: "HTMLCollection",
});
const collection = result.collection;
collection.length; // 2
collection[0] === first; // true
collection.first === first; // true
collection.item(1) === second; // true
collection.namedItem("second") === second; // true
collection.namedItem("missing"); // null
[...collection]; // [first, second]
Object.prototype.toString.call(collection); // "[object HTMLCollection]"
```
返回对象通常包含:
- `collection`:集合实例。
- `constructor`:集合构造函数。
- `[name]`:以集合名称命名的构造函数,例如 `result.HTMLCollection`。
## 11. `getMimeTypesAndPlugins([config])`
创建浏览器风格 `PluginArray`、`MimeTypeArray`、`Plugin`、`MimeType`,适合补 `navigator.plugins` 和 `navigator.mimeTypes`。
### 默认用法
```js
const {
mimeTypes,
plugins,
PluginArray,
MimeTypeArray,
MimeType,
Plugin,
} = xbs.getMimeTypesAndPlugins();
navigator.plugins = plugins;
navigator.mimeTypes = mimeTypes;
```
### 参数化配置
```js
const result = xbs.getMimeTypesAndPlugins({
plugins: [
{
name: "Custom PDF Viewer",
filename: "custom-pdf-viewer",
description: "Custom Portable Document Format",
mimeTypes: [
{
type: "application/x-custom-pdf",
suffixes: "cpdf",
description: "Custom PDF",
},
],
},
],
});
result.plugins.length; // 1
result.mimeTypes.length; // 1
result.plugins[0].name; // "Custom PDF Viewer"
result.mimeTypes[0].type; // "application/x-custom-pdf"
result.mimeTypes[0].enabledPlugin === result.plugins[0]; // true
```
## 12. `createUndetectable(callback[, handlers])`
创建 V8 `MarkAsUndetectable()` 对象,常用于近似 `document.all` / HTMLDDA 行为。
```js
const all = xbs.createUndetectable(function () {
return undefined;
});
typeof all; // "undefined"
Boolean(all); // false
all == null; // true
all === undefined; // false
all(); // undefined
```
### handler 行为
`createUndetectable` 支持与 `createInterceptor` 类似的 `handlers`,常用字段如下:
| handler | 签名 | 说明 |
| --- | --- | --- |
| `getter` | `(target, property)` | 读取属性时调用。返回 `{ value }` 可直接返回虚拟值;返回 `{ intercept: false }` 会放行,让 V8 继续查找 backing target 或原型链。 |
| `query` | `(target, property)` | 属性存在性查询。可返回属性 attribute 数值;也可配合 `descriptor` 生成属性描述。 |
| `descriptor` | `(target, property)` | `Object.getOwnPropertyDescriptor` 时调用。可返回标准 descriptor;返回 `{ intercept: false }` 表示放行。 |
| `enumerator` | `(target)` | 枚举属性名时调用。 |
| `setter` / `definer` / `deleter` | 同 `createInterceptor` | 用于写入、`Object.defineProperty` 和删除。 |
注意:当前实现已经支持原型链 fallback。也就是说,如果 handler 不存在,或 handler 返回 `{ intercept: false }`,并且 backing target 没有该自有属性,读取会继续查找对象原型链。这对手动模拟 `document.all.length`、`document.all.item()`、`Symbol.toStringTag` 很重要。
模拟 `document.all` 时,建议 **all 创建都带 handlers**,并把 `length / item / namedItem / Symbol.toStringTag` 放到 `HTMLAllCollection.prototype` 上,避免 `length` 变成 `document.all` 的自有属性:
```js
const document = { _elements: [] };
const chain = xbs.createProtoChains([{
name: "HTMLAllCollection",
length: 0,
constructor() {},
constructorBehavior: "throw",
callBehavior: "throw",
readOnlyPrototypeProperty: true,
hasToStringTag: true,
toStringTag: "HTMLAllCollection",
}]);
const HTMLAllCollection = chain.HTMLAllCollection;
Object.defineProperty(HTMLAllCollection.prototype, "length", {
get: xbs.createGetter("length", 0, function () {
return document._elements.length;
}),
enumerable: true,
configurable: true,
});
Object.defineProperty(HTMLAllCollection.prototype, "item", {
value: xbs.createNativeFunction(false, "item", 1, function (index) {
index = Number(index) >>> 0;
return index < document._elements.length ? document._elements[index] : null;
}),
enumerable: true,
configurable: true,
});
Object.defineProperty(HTMLAllCollection.prototype, "namedItem", {
value: xbs.createNativeFunction(false, "namedItem", 1, function (name) {
name = String(name);
return document._elements.find(el => el.id === name || el.name === name) || null;
}),
enumerable: true,
configurable: true,
});
function isIndex(property) {
return typeof property === "number" || /^(0|[1-9]\d*)$/.test(String(property));
}
function resolveAll(property) {
if (isIndex(property)) {
const index = Number(property) >>> 0;
return index < document._elements.length
? { value: document._elements[index], enumerable: true }
: null;
}
const name = String(property);
if (name === "length" || name === "item" || name === "namedItem") {
return null; // 放行给 HTMLAllCollection.prototype
}
const element = document._elements.find(el => el.id === name || el.name === name);
return element ? { value: element, enumerable: false } : null;
}
function defineAllProperty(target, property) {
const hit = resolveAll(property);
if (!hit) return false;
Object.defineProperty(target, String(property), {
value: hit.value,
writable: false,
enumerable: hit.enumerable,
configurable: true,
});
return true;
}
const all = xbs.createUndetectable(function (value) {
if (arguments.length === 0 || value == null) return null;
const hit = resolveAll(value);
return hit ? hit.value : null;
}, {
getter(target, property) {
if (typeof property === "symbol" || !defineAllProperty(target, property)) {
return { intercept: false };
}
},
query(target, property) {
if (typeof property === "symbol" || !defineAllProperty(target, property)) {
return { intercept: false };
}
},
descriptor(target, property) {
if (typeof property === "symbol" || !defineAllProperty(target, property)) {
return { intercept: false };
}
},
enumerator(target) {
for (let i = 0; i < document._elements.length; i++) {
defineAllProperty(target, String(i));
}
return Object.getOwnPropertyNames(target);
},
});
Object.setPrototypeOf(all, HTMLAllCollection.prototype);
Object.defineProperty(document, "all", {
value: all,
enumerable: true,
configurable: true,
});
Object.hasOwn(document.all, "length"); // false
Object.getOwnPropertyDescriptor(document.all, "length"); // undefined
document.all.length; // 来自 HTMLAllCollection.prototype.length
```
如果要更贴近浏览器,`all` 最好安装在 `Document.prototype` 的 getter 上,而不是直接作为 `document` 自有属性:
```js
Object.defineProperty(Document.prototype, "all", {
get: xbs.createGetter("all", 0, function () {
return all;
}),
enumerable: true,
configurable: true,
});
```
## 13. Private slot API
基于 V8 `Private::ForApi` 实现,适合把内部状态存放到 JS 对象上,同时避免普通属性枚举或直接访问。
```js
const obj = {};
xbs.hasPrivate(obj, "slot"); // false
xbs.setPrivate(obj, "slot", 123); // true
xbs.hasPrivate(obj, "slot"); // true
xbs.getPrivate(obj, "slot"); // 123
xbs.deletePrivate(obj, "slot"); // true
xbs.hasPrivate(obj, "slot"); // false
```
## 14. `throwTypeError(message)`
从 native 层抛出 `TypeError`。
```js
function assertIllegalInvocation(thisValue, Ctor) {
if (!(thisValue instanceof Ctor)) {
xbs.throwTypeError("Illegal invocation");
}
}
```
注意:
- 不传参数会抛出参数数量错误。
- 第一个参数不是字符串会抛出类型错误。
## 15. 推荐实践
1. 所有非 WebAPI 的辅助能力都放在 `window.xbs` 下,不要直接污染 `window`。
2. 对外可见的浏览器对象优先使用 `createProtoChains` 创建构造函数与原型链。
3. 对 `document.all` 这类特殊对象优先使用 `createUndetectable`。
4. 对 `navigator.plugins` / `navigator.mimeTypes` 优先使用 `getMimeTypesAndPlugins`。
5. 对集合对象优先使用 `createNativeCollection`,不要直接用普通数组代替。
6. 对内部状态优先使用 private slot,减少普通属性泄露。
7. 多 isolate 场景下,原型链注册表会按 isolate 隔离;清理 isolate 时会同步清理对应 XBS 注册表。
8. 宿主侧 `isolated-vm` API 保持原样,`xbs` 只存在于创建出的 Context 内。
## 16. 快速自检
```js
const expected = [
"clearProtoChainRegistry",
"createGetter",
"createInterceptor",
"createNativeCollection",
"createNativeFunction",
"createNativeObject",
"createProtoChains",
"createSetter",
"createUndetectable",
"deletePrivate",
"deleteProtoChainRegistryEntry",
"getMimeTypesAndPlugins",
"getPrivate",
"getProtoChainRegistry",
"hasPrivate",
"setPrivate",
"throwTypeError",
];
Object.keys(window.xbs).sort().join("\n") === expected.join("\n");
```
也可以直接运行项目内测试:
```powershell
node --no-node-snapshot tests\xbs-basic.js
```

View File

@@ -0,0 +1,522 @@
# XBS DOM API 使用说明
本文说明当前 `xbsVm` 在 `isolated-vm` Context 内提供的 DOM 能力。入口统一为 `window.xbs.dom`,不会修改 host 侧 `isolated-vm` 的原始 API。
## 1. 当前公开 API
当前 `xbs.dom` 只保留一个公开方法:
```js
xbs.dom.createDocument(options)
```
以下 API 已删除,不再对外暴露:
- `attachDocument()` / `detachDocument()`
- `installConstructors()` / `getConstructors()`
- `createElement()`
- `createIframeDocument()` / `attachIframeDocument()` / `detachIframeDocument()`
- `patchApi()` / `removeApi()`
- `createProfile()` / `getProfile()` / `listProfiles()`
- `snapshotSurface()` / `restoreSurface()`
- `getLastError()`
`createDocument()` 中也不再支持 `profile` 参数。
## 2. 设计原则
- 默认不会把 `document` 挂到 `window` 上;调用方自行决定是否执行 `window.document = document`。
- `createDocument()` 会在返回的 document 对象上安装基础 DOM 属性、方法和原型链。
- DOM 构造函数不会默认挂到 `window` 上;对象仍然具有内部构造函数和原型链,例如 `document.constructor.name === "HTMLDocument"`。
- 删除或禁用某个 DOM API 应在创建 document 前通过配置声明,而不是运行时强删已发布的不可配置属性。
- C++ 文件中不再通过 JS bootstrap 字符串实现 DOM;DOM 相关逻辑位于 `src/xbs/dom/`。
## 3. 快速示例
```js
const ivm = require("isolated-vm");
const isolate = new ivm.Isolate();
const context = isolate.createContextSync();
const result = isolate.compileScriptSync(`
(() => {
const document = xbs.dom.createDocument({
url: "https://example.com/",
html: '<main id="app"><span class="hot">hello</span></main>'
});
return {
hasWindowDocument: "document" in window,
text: document.querySelector(".hot").textContent,
url: document.URL
};
})()
`).runSync(context, { copy: true });
console.log(result);
```
## 4. createDocument(options)
### 参数
```js
const document = xbs.dom.createDocument({
url: "https://example.com/path",
html: '<div id="app">ok</div>',
skeleton: true,
omitApis: ["document.all"]
});
```
支持字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `url` | `string` | 设置 `document.URL`,默认是 `about:blank`。 |
| `html` | `string` | 创建后解析到 `document.body`,当前为简化 HTML 解析器。 |
| `skeleton` | `boolean` | 是否创建 `html/head/body` 骨架,默认 `true`。 |
| `omitApis` | `string[]` | 创建前不安装指定 API。 |
| `disabledApis` | `string[]` | `omitApis` 的兼容别名。 |
| `removedApis` | `string[]` | `omitApis` 的兼容别名。 |
| `features.documentAll` | `boolean` | 为 `false` 时不安装 `document.all`。 |
| `features.iframeContentDocument` | `boolean` | 为 `false` 时不安装 iframe 的 `contentDocument/contentWindow`。 |
## 5. 创建前禁用 API
示例:创建一个不带 `document.all` 的 document。
```js
const document = xbs.dom.createDocument({
omitApis: ["document.all"]
});
console.log("all" in document); // false
```
示例:创建一个不带 `Document.prototype.createComment` 的 document。
```js
const document = xbs.dom.createDocument({
omitApis: ["Document.prototype.createComment"]
});
console.log(typeof document.createComment); // undefined
```
示例:禁用 iframe 的 `contentDocument`。
```js
const document = xbs.dom.createDocument({
omitApis: ["HTMLIFrameElement.prototype.contentDocument"]
});
const iframe = document.createElement("iframe");
console.log(typeof iframe.contentDocument); // undefined
```
也支持通配前缀:
```js
xbs.dom.createDocument({
omitApis: ["Element.prototype.*"]
});
```
## 6. 常用 DOM 能力
当前基础实现包含以下对象和能力:
- `HTMLDocument` / `Document`
- `Node` / `Element` / `HTMLElement` / `HTMLIFrameElement`
- `Text` / `Comment` / `CDATASection` / `ProcessingInstruction` / `DocumentFragment`
- `Attr`
- `DOMTokenList` / `NodeList` / `HTMLCollection`
- `HTMLAllCollection` / `HTMLFormControlsCollection` / `HTMLOptionsCollection` / `RadioNodeList`
- `NamedNodeMap` / `DOMStringMap`
常用示例:
```js
const document = xbs.dom.createDocument();
const div = document.createElement("div");
div.id = "box";
div.className = "a b";
div.classList.add("c");
div.innerHTML = '<span name="s1">text</span>';
document.body.appendChild(div);
console.log(document.querySelector("#box") === div); // true
console.log(div.querySelector("span").textContent); // text
console.log(div.children.namedItem("s1").tagName); // SPAN
```
## 7. 本阶段新增 DOM API
说明:`xbs.dom` 对外仍然只暴露 `createDocument()`。以下新增 API 是通过 `createDocument()` 返回的 `document`、其节点对象和原型链对外可用。
### 7.1 Document 新增属性
```js
const document = xbs.dom.createDocument();
document.documentURI; // 与 document.URL 保持一致
document.embeds; // HTMLCollection
document.plugins; // HTMLCollection
```
当前 `Document` 可用的主要属性包括:
- `documentElement`
- `head`
- `body`
- `title`
- `readyState`
- `URL`
- `documentURI`
- `referrer`
- `characterSet`
- `compatMode`
- `contentType`
- `defaultView`
- `activeElement`
- `doctype`
- `implementation`
- `currentScript`
- `all`
- `scripts`
- `forms`
- `images`
- `links`
- `anchors`
- `embeds`
- `plugins`
- `children`
### 7.2 Document 新增创建方法
```js
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg:rect");
console.log(svg.namespaceURI); // http://www.w3.org/2000/svg
console.log(svg.prefix); // svg
console.log(svg.localName); // rect
const attr = document.createAttribute("data-foo");
attr.value = "bar";
const attrNS = document.createAttributeNS("urn:x", "x:key");
const cdata = document.createCDATASection("abc");
console.log(cdata.nodeType); // 4
const pi = document.createProcessingInstruction("xml-stylesheet", "href=x");
console.log(pi.nodeType); // 7
```
当前 `Document` 可用的主要查询和创建方法包括:
- `getElementById()`
- `querySelector()`
- `querySelectorAll()`
- `getElementsByTagName()`
- `getElementsByClassName()`
- `getElementsByName()`
- `createElement()`
- `createElementNS()`
- `createTextNode()`
- `createComment()`
- `createDocumentFragment()`
- `createAttribute()`
- `createAttributeNS()`
- `createCDATASection()`
- `createProcessingInstruction()`
### 7.3 Node 新增方法
```js
const node = document.createTextNode("hello");
const clone = node.cloneNode(false);
console.log(node.isEqualNode(clone)); // true
```
新增或补齐的方法:
- `isEqualNode(otherNode)`
- `lookupNamespaceURI(prefix)`
- `lookupPrefix(namespaceURI)`
- `isDefaultNamespace(namespaceURI)`
当前 `Node` 可用的主要属性和方法包括:
- 属性:`nodeType`、`nodeName`、`nodeValue`、`textContent`、`parentNode`、`parentElement`、`childNodes`、`firstChild`、`lastChild`、`nextSibling`、`previousSibling`、`ownerDocument`、`isConnected`、`baseURI`
- 方法:`appendChild()`、`removeChild()`、`insertBefore()`、`replaceChild()`、`cloneNode()`、`contains()`、`hasChildNodes()`、`getRootNode()`、`isSameNode()`、`isEqualNode()`、`compareDocumentPosition()`、`normalize()`、`lookupNamespaceURI()`、`lookupPrefix()`、`isDefaultNamespace()`
### 7.4 Element 新增属性和方法
```js
const el = document.createElementNS("http://www.w3.org/2000/svg", "svg:rect");
console.log(el.namespaceURI); // http://www.w3.org/2000/svg
console.log(el.prefix); // svg
console.log(el.localName); // rect
el.setAttributeNS("urn:x", "x:key", "value");
console.log(el.getAttributeNS("urn:x", "key")); // value
console.log(el.hasAttributeNS("urn:x", "key")); // true
const attr = document.createAttribute("data-user-id");
attr.value = "10001";
el.setAttributeNode(attr);
console.log(el.getAttributeNode("data-user-id").ownerElement === el); // true
console.log(el.attributes.getNamedItem("data-user-id").value); // 10001
console.log(el.dataset.userId); // 10001
el.dataset.token = "abc";
console.log(el.getAttribute("data-token")); // abc
delete el.dataset.token;
```
新增或补齐的属性:
- `namespaceURI`
- `prefix`
- `localName`
- `slot`
- `dir`
- `shadowRoot`
- `assignedSlot`
- `attributes`
- `dataset`
新增或补齐的方法:
- `getAttributeNS()`
- `setAttributeNS()`
- `hasAttributeNS()`
- `getAttributeNode()`
- `setAttributeNode()`
- `removeAttributeNode()`
- `webkitMatchesSelector()`
当前 `Element` 可用的主要属性和方法包括:
- 属性:`tagName`、`id`、`className`、`classList`、`attributes`、`innerHTML`、`outerHTML`、`innerText`、`textContent`、`children`、`firstElementChild`、`lastElementChild`、`nextElementSibling`、`previousElementSibling`、`childElementCount`、`namespaceURI`、`prefix`、`localName`、`style`、`dataset`、`slot`、`dir`、`shadowRoot`、`assignedSlot`
- 方法:`getAttribute()`、`setAttribute()`、`hasAttribute()`、`removeAttribute()`、`toggleAttribute()`、`getAttributeNames()`、`getAttributeNS()`、`setAttributeNS()`、`hasAttributeNS()`、`getAttributeNode()`、`setAttributeNode()`、`removeAttributeNode()`、`matches()`、`webkitMatchesSelector()`、`closest()`、`querySelector()`、`querySelectorAll()`、`getElementsByTagName()`、`getElementsByClassName()`、`append()`、`prepend()`、`before()`、`after()`、`remove()`、`replaceWith()`、`replaceChildren()`、`insertAdjacentHTML()`、`insertAdjacentElement()`、`insertAdjacentText()`、`focus()`、`blur()`、`click()`、`getBoundingClientRect()`、`getClientRects()`、`scrollIntoView()`
### 7.5 Attr
`Attr` 表示属性节点,可由 `document.createAttribute()` 或 `document.createAttributeNS()` 创建,也可通过 `element.getAttributeNode()` 取得。
```js
const attr = document.createAttribute("data-id");
attr.value = "1";
console.log(attr.name); // data-id
console.log(attr.value); // 1
console.log(attr.nodeType); // 2
console.log(attr.nodeName); // data-id
console.log(attr.nodeValue); // 1
console.log(attr.textContent); // 1
```
主要属性:
- `name`
- `value`
- `namespaceURI`
- `prefix`
- `localName`
- `specified`
- `ownerElement`
- `nodeType`
- `nodeName`
- `nodeValue`
- `textContent`
### 7.6 NamedNodeMap
`element.attributes` 返回 `NamedNodeMap`,用于访问元素属性节点。
```js
const el = document.createElement("div");
el.setAttribute("id", "box");
console.log(el.attributes.length); // 1
console.log(el.attributes.item(0).name); // id
console.log(el.attributes.getNamedItem("id").value); // box
```
主要 API:
- `length`
- `item(index)`
- `getNamedItem(name)`
- `getNamedItemNS(namespaceURI, localName)`
- `setNamedItem(attr)`
- `setNamedItemNS(attr)`
- `removeNamedItem(name)`
- `removeNamedItemNS(namespaceURI, localName)`
### 7.7 DOMStringMap
`element.dataset` 返回 `DOMStringMap`,会映射到元素的 `data-*` 属性。
```js
const el = document.createElement("div");
el.dataset.userId = "10001";
console.log(el.getAttribute("data-user-id")); // 10001
console.log(el.dataset.userId); // 10001
delete el.dataset.userId;
console.log(el.hasAttribute("data-user-id")); // false
```
### 7.8 集合对象
本阶段新增和补齐的集合对象:
- `HTMLAllCollection`
- 来源:`document.all`
- 特性:保持不可检测语义,`typeof document.all === "undefined"`,但仍可访问 `length`、`item()`、`namedItem()`
- `HTMLFormControlsCollection`
- 预留给表单控件集合能力
- 支持 `length`、`item()`、`namedItem()`
- `HTMLOptionsCollection`
- 预留给 `select.options` 类场景
- 支持 `length`、`selectedIndex`、`item()`、`namedItem()`、`add()`、`remove()`
- `RadioNodeList`
- 预留给同名 radio 控件集合
- 支持 `length`、`value`、`item()`、`namedItem()`
示例:
```js
const document = xbs.dom.createDocument({
html: '<main id="app"><span name="s1">text</span></main>'
});
console.log(document.all.length >= 1); // true
console.log(document.all.item(0)); // html 元素
console.log(document.body.children.namedItem("s1").tagName); // SPAN
```
### 7.9 手动创建 document.all
如果用户不使用 `xbs.dom.createDocument()`,而是自己创建 `document`,再通过 `xbs.createUndetectable()` 创建 `all` 并挂载到 `document` 上,需要注意:
- `xbs.createUndetectable()` 只负责 HTMLDDA/不可检测语义,例如 `typeof all === "undefined"`、`Boolean(all) === false`、`all == null`。
- `document.all.length`、`item()`、`namedItem()`、`[0]`、命名属性、枚举和描述符,需要由 `HTMLAllCollection.prototype` 与 handlers 配合实现。
- all 创建建议都传入 handlers。handler 未命中时返回 `{ intercept: false }`,让 V8 继续查找 backing target 或原型链。
- `length / item / namedItem / constructor` 等原型链已有属性不要定义成 `document.all` 自有属性,否则会和真实浏览器不一致。
最小结构示例:
```js
const all = xbs.createUndetectable(function (value) {
if (arguments.length === 0 || value == null) return null;
// 这里根据自己的 document 元素表处理索引或 id/name 查询
return null;
}, {
getter(target, property) {
// 命中索引或 id/name 时,可 Object.defineProperty(target, property, descriptor)
// 未命中或遇到 length/item/namedItem 等原型属性时必须放行
return { intercept: false };
},
query(target, property) {
return { intercept: false };
},
descriptor(target, property) {
return { intercept: false };
},
enumerator(target) {
return Object.getOwnPropertyNames(target);
},
});
Object.setPrototypeOf(all, HTMLAllCollection.prototype);
```
如果直接挂到 `document`:
```js
Object.defineProperty(document, "all", {
value: all,
enumerable: true,
configurable: true,
});
```
则 `Object.hasOwn(document, "all")` 会是 `true`。如果要更接近真实浏览器,应挂到 `Document.prototype`:
```js
Object.defineProperty(Document.prototype, "all", {
get: xbs.createGetter("all", 0, function () {
return all;
}),
enumerable: true,
configurable: true,
});
```
## 8. 新增 API 的 omitApis 路径
新增 API 仍然遵循创建前禁用规则。常用路径示例:
```js
const document = xbs.dom.createDocument({
omitApis: [
"Document.prototype.createElementNS",
"Document.prototype.createAttribute",
"Document.prototype.createCDATASection",
"Document.prototype.createProcessingInstruction",
"Element.prototype.dataset",
"Element.prototype.getAttributeNS",
"Element.prototype.setAttributeNS",
"Element.prototype.webkitMatchesSelector",
"Node.prototype.isEqualNode",
"Node.prototype.lookupNamespaceURI",
"Attr.prototype.value",
"NamedNodeMap.prototype.getNamedItem"
]
});
```
也可以继续使用通配前缀:
```js
xbs.dom.createDocument({
omitApis: [
"Document.prototype.*",
"Element.prototype.*",
"Node.prototype.*"
]
});
```
## 9. iframe 行为
不再提供 `createIframeDocument()` 或 `attachIframeDocument()`。现在 iframe 的 `contentDocument` 会按需创建。
```js
const document = xbs.dom.createDocument({
html: '<iframe id="f"></iframe>'
});
const iframe = document.getElementById("f");
console.log(iframe.contentDocument.body.tagName); // BODY
console.log(iframe.contentWindow.document === iframe.contentDocument); // true
```
## 10. 当前边界
当前 DOM 是补环境用的基础实现,不是完整浏览器内核:
- HTML parser 为简化实现,适合基础标签、属性、文本和注释。
- selector 目前支持标签、`#id`、`.class`、简单属性选择器和逗号分组。
- 暂不支持完整 HTML5 解析、复杂 CSS selector、真实布局、CSSOM、事件系统、MutationObserver、Canvas、WebGL 等。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,496 @@
# 浏览器取证、ruyiPage / RuyiTrace / Camoufox / CloakBrowser 与登录处理
每次开始新的网页端 JS 补环境任务时读取本文件。不要等确认目标站存在自动化/CDP/JS Hook 检测后才选择工具;必须在任何取证动作前,让用户先确认取证模式。任务需要浏览器交互、用户提到 ruyiPage、RuyiTrace、Camoufox、camoufox-reverse-mcp、CloakBrowser,或出现登录、验证码、MFA 时也读取本文件。
## 取证模式选择触发时机
触发时机是:**新 case 开始后、范围确认通过后、任何取证动作之前**。
这里的“取证动作”包括但不限于:
- 打开目标页面。
- 抓包、导出 cURL / HAR。
- 收集 JS bundle / chunk / sourcemap。
- 注入 Hook、设置 XHR/fetch 断点、读取调用栈。
- 截图、读取页面标题、Cookie、localStorage、sessionStorage。
- 启动 ruyiPage、RuyiTrace、Camoufox、camoufox-reverse-mcp、CloakBrowser、Playwright、Puppeteer 或其他浏览器自动化工具。
- 采集 RuyiTrace NDJSON 日志。
不要把 ruyiPage / RuyiTrace / Camoufox 作为“确认存在自动化检测之后才启用”的补救方案。它们应作为用户从一开始选择的取证路线。用户未选择前,只能做离线文本检查和缺失信息提醒,不能开始浏览器取证。
## 取证模式选择
不要直接替用户决定工具。先给用户选择:
| 模式 | 说明 | 建议 |
|---|---|---|
| ruyiPage + RuyiTrace | ruyiPage 做 Firefox/BiDi 自动化取证,RuyiTrace 采集内核层 NDJSON 环境日志 | 高风控、需要补环境日志时推荐 |
| 仅 ruyiPage | 用 Firefox/BiDi 打开页面、抓包、收集 JS,不采集 RuyiTrace 日志 | 只需要取证,不需要环境日志时 |
| Camoufox + camoufox-reverse-mcp | 用 Camoufox 反指纹浏览器和 MCP 工具完成网络、脚本、Hook、调用栈与可选属性访问追踪 | 目标更适合 Firefox/Camoufox 路线、需要 MCP 工具链时 |
| 仅 Camoufox | 使用 Camoufox 官方 Python API 打开页面、抓包、收集 JS,不依赖 MCP | 用户已有 Camoufox 或只需轻量取证时 |
| CloakBrowser | 使用 Chromium/CloakBrowser 有头 + humanize 方案 | 目标更适合 Chromium 路线或用户已有 CloakBrowser 时 |
| 用户手动取证 | 用户提供 cURL、HAR、JS 文件、调用栈截图、RuyiTrace 日志 | 用户不允许自动化或需要真实登录态时 |
| AI 自行决定 | 根据目标风险、本机工具可用性初选,但必须回报选择结果并在启动前再次确认 | 用户不确定时 |
用户未选择前,不要启动 ruyiPage、RuyiTrace、Camoufox、camoufox-reverse-mcp、CloakBrowser、Playwright 或 Puppeteer。
用户确认后,将其记录为本 case 的“取证模式”。后续所有取证操作必须沿用该模式:
- 已选 ruyiPage + RuyiTrace:用 ruyiPage 做页面/网络/JS 取证,用 RuyiTrace 采集环境日志;不要临时改用普通 Playwright。
- 已选仅 ruyiPage:所有浏览器自动化取证使用 ruyiPage;如后续需要 RuyiTrace 日志,先请求用户确认升级为 ruyiPage + RuyiTrace。
- 已选 Camoufox + camoufox-reverse-mcp:所有浏览器自动化取证使用 camoufox-reverse-mcp 或 Camoufox 官方入口;如 MCP 缺失,不得静默降级为仅 Camoufox,必须先让用户选择安装 / 配置 MCP 或明确降级。
- 已选仅 Camoufox:所有浏览器自动化取证使用 Camoufox 官方 Python API;如后续需要 MCP 的网络、脚本、Hook 或属性访问追踪能力,先请求用户确认升级为 Camoufox + camoufox-reverse-mcp。
- 已选 CloakBrowser:所有浏览器自动化取证使用 CloakBrowser;如需切到 ruyiPage / RuyiTrace / Camoufox,先请求用户确认。
- 已选用户手动取证:不要启动本机浏览器自动化;只让用户提供 cURL、HAR、JS 文件、调用栈截图、RuyiTrace 日志等材料。
- 已选 AI 自行决定:先检测工具并提出将使用的模式;用户确认后才启动。
如果所选工具不可用、路径缺失、runtime 不合格、需要登录、或后续必须更换工具,必须暂停并让用户确认,不得自动 fallback 到普通系统 Firefox、普通 Playwright、Puppeteer 或非 Camoufox 的 Playwright Firefox。
详细 ruyiPage / RuyiTrace 流程见 `ruyi-tooling.md`;详细 Camoufox 流程见 `camoufox-tooling.md`;自动点击、拖拽、键盘、滚动和验证码交互的 `isTrusted` 可信输入规则见 `trusted-input-and-isTrusted.md`。
## 指纹基线一致性硬约束
使用 ruyiPage、RuyiTrace、Camoufox、camoufox-reverse-mcp、CloakBrowser 或用户手动浏览器进行取证时,必须把“同一 case 的指纹一致性”作为前置门禁,而不是等发现指纹冲突后再补救。
- 第一次成功打开目标页并完成基础自检后,立即写入 `case/notes/fingerprint-baseline.json`,生成 `baselineId`。
- 后续抓包、Hook、RuyiTrace、截图、JS 收集、指纹采样和 fixture 对比必须复用同一 profile / userdir / seed / 代理 / locale / timezone / viewport / UA / Client Hints / screen / WebGL 基线。
- 禁止每次启动工具都重新随机指纹;如果工具默认会随机,必须通过持久 profile、固定 seed、固定配置或复用首次输出值来锁定本 case 基线。
- ruyiPage 与 RuyiTrace 如果不是同一浏览器或同一 profile,必须先对 baseline 做 diff;Camoufox / CloakBrowser 与其他工具交叉采样也同理。
- 发现 language、timezone、platform、UA、Client Hints、screen、DPR、WebGL、Canvas、Audio、字体或代理地区不一致时,暂停并写入 `case/notes/fingerprint-baseline-diff.md`;未经用户确认,不得混用样本。
- 用户明确更换代理、地区、profile 或工具时,生成新的 `baselineId`,旧样本只能保留为历史证据。
### WebAPI / 指纹值采样来源硬约束
当补环境需要具体值,例如 `navigator.userAgent`、`navigator.languages`、`screen.width`、`canvas.toDataURL()`、`WebGLRenderingContext.getParameter()`、`document.createElement()` 后的 DOM 几何、字体宽高、Audio / WebGPU 返回值等,必须遵循:
1. 用户选择 / 提供 RuyiTrace 或其他 trace 日志时,先查看 trace 中同一 `baselineId`、同一业务路径、未截断的真实值。
2. trace 未选择、缺失、未覆盖、疑似截断、真实长度为 `unknown` 或 baseline 不一致时,再用当前用户确认的取证工具采样;不得临时改用普通 Playwright、Puppeteer、系统浏览器或另一个随机指纹工具。
3. 自动化采样必须复用当前 case 的固定 profile / seed / 代理 / locale / timezone / UA / Client Hints / screen / WebGL 基线,并把采样结果写入 `case/fixtures/fingerprint.fixture.json` 或对应 fixture。
4. AI 不能根据经验猜值;静态分析只能帮助确定要采样的 API、调用参数和代码位置。
5. 每条采样值都要记录 `baselineId`、`source / capturedBy`、`traceStatus`、是否截断、长度 / hash 和采样时间。
## isTrusted 与原生输入硬约束
当取证阶段需要点击、鼠标移动、拖拽、键盘输入、滚动或验证码交互时,必须先按已确认工具选择可信输入路径:
- ruyiPage:优先 `page.actions` 原生 BiDi 动作链、`human_move`、`human_click`、`drag`;如果必须构造 JS 事件,必须使用 ruyiPage 特定的 `ruyi: true`。
- Camoufox:从启动开始 `humanize=True`,交互使用 Camoufox / Playwright 风格原生输入接口或 MCP 官方交互能力,不把 `page.evaluate(() => dispatchEvent(...))` 作为主路径。
- CloakBrowser:从启动开始 `humanize=True`,使用官方包装器 patch 后的 click / type / drag / scroll 等方法;如果退回 `page.evaluate` 合成事件,视为 `isTrusted=false` 风险并暂停确认。
- 用户手动:登录、MFA、验证码答案和高风险验证优先让用户在已确认取证浏览器中手动完成。
## 高强度自动化痕迹与取证风险
如果目标疑似高强度检测,不允许先用普通 headless Playwright / Puppeteer / Selenium / 系统浏览器探测再切换工具。必须从第一次打开页面起使用用户确认的 ruyiPage、Camoufox、CloakBrowser 或用户手动浏览器,并固定 fingerprint baseline。
取证阶段重点检查并记录:
- `navigator.webdriver` 是否为普通浏览器状态。
- `window._selenium`、`window.callSelenium`、`window.callPhantom`、`window._phantom`、`window.__nightmare`、`window.domAutomation`、`window.domAutomationController` 等自动化 honeypot 是否暴露。
- CDP / DevTools / Runtime 侧信道、console / stack 异常、debugger 检测或页面可见自动化 hook 是否存在。
- UA 中是否含 headless 特征,plugins / mimeTypes / permissions / WebGL / Canvas 是否呈现空值、随机化或隐私插件伪装。
- 鼠标、键盘、拖拽、滚动是否走可信输入;普通 `dispatchEvent` 不作为高风控主路径。
- Profile、Cookie、localStorage、sessionStorage、IndexedDB、权限状态是否属于同一 case;空 profile 或每次随机 profile 必须暂停确认。
普通 `dispatchEvent(new MouseEvent(...))`、`new KeyboardEvent(...)`、`new PointerEvent(...)` 默认是高风险合成事件,不能作为验证码或高风控交互的主路径。无法保证可信输入时,暂停并让用户选择手动完成、切换工具或明确接受风险。
## 验证码接口取证门禁
信息完整并确认任务后、任何取证动作前,先确认目标是否为验证码 / 风控验证 / challenge / WAF 接口。若是,必须读取 `captcha-flow-and-verify-handoff.md` 并让用户选择取证方式:
1. 用户提供从触发到验证的完整流程,AI 使用已确认取证工具自动完成最小必要交互和取证。
2. 用户自己在取证浏览器中完成触发到验证,AI 只负责提前开启网络捕获、Hook、截图或 Trace,并等待用户回复“已经完成触发到验证流程”。
验证码场景不得只打开页面首屏就宣称取证完成;必须覆盖触发、展示、交互、点击验证 / 提交、verify 接口返回和结果回调。需要登录、验证码答案、MFA、账号授权或人工判断时暂停,让用户手动完成,不要尝试绕过。
## ruyiPage / RuyiTrace 定位
根据官方仓库说明:
- ruyiPage 是 Python Firefox 自动化框架,基于 Firefox + WebDriver BiDi,不依赖 CDP,支持网络控制、Cookie、本地存储、拟人动作、Firefox runtime 安装和指纹浏览器配合。
- RuyiTrace 是桌面工具,包含定制 Firefox trace 内核和 Electron 客户端,采集 NDJSON DOM / JS API 运行时调用日志。
- 推荐工作流是:ruyiPage 抓取站点整体轮廓和网络数据 → RuyiTrace 采集运行时日志 → 结合 JS 文件、网络包和日志补环境。
检测:
```bash
node scripts/check_external_tools.js --markdown
node scripts/check_external_tools.js --python python --ruyipage-install-dir <ruyipage-browsers-dir> --markdown
node scripts/check_external_tools.js --python python --ruyipage-browser-path <firefox.exe> --markdown
```
如用户选择 ruyiPage / RuyiTrace,立即检测工具是否已安装;未检测到时要求用户确认是否已安装并提供路径,或确认是否需要安装 / 下载。
如果用户选择的是 **ruyiPage + RuyiTrace**,但仅检测到 ruyiPage、未检测到可用 RuyiTrace,不得直接建议“仅使用 ruyiPage”,也不得静默降级。必须暂停并让用户选择:
- 安装 / 提供 RuyiTrace 路径,并等待安装完成与检测通过。
- 明确降级为“仅 ruyiPage”,后续不再假设存在 RuyiTrace NDJSON。
只有用户明确确认降级后,才可以进入仅 ruyiPage 取证;否则应保持 ruyiPage + RuyiTrace 模式,并先完成 RuyiTrace 安装 / 路径确认。RuyiTrace 检测通过后,默认优先自动捕获 NDJSON:运行 `scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --ruyitrace-home <RuyiTrace-dir> --import-after --markdown` 或等价方式,不要默认等待用户手动采集;只有自动捕获失败、需要登录 / 验证 / 权限交互或用户明确选择手动取证时,才要求用户手动打开 RuyiTrace GUI。
### ruyiPage 定制 Firefox 强制校验
ruyiPage 的价值在于使用 Firefox + WebDriver BiDi,并配合其 managed runtime / 定制 Firefox 降低普通自动化与 CDP 检测风险。执行 ruyiPage 流程前必须确认:
- ruyiPage Python 包可导入。
- `requests` 可导入,或用户已提供 `smart_fingerprint(manual_geo=...)` 所需地理信息;不要在智能指纹失败时静默降级。
- `check_external_tools.js` 输出“定制 Firefox runtime 是否通过验证:是”。
- 如果默认解析路径不是定制 Firefox,但检测到了已验证 runtime,启动示例必须显式 `set_browser_path("<verified-ruyipage-managed-firefox>")`。
- 如果只检测到系统 Firefox fallback,判定为不合格,不启动 ruyiPage;先询问用户是否已经安装定制 Firefox。
选择 ruyiPage 后,从第一次打开目标页开始就必须使用 ruyiPage 启动硬约束:
- 有头模式,不使用 headless。
- 专用临时 Profile,不复用脏 profile;同一 case 后续取证复用该 profile / userdir,不得每次随机新 profile。
- `opts.smart_fingerprint(...)` 成功,并在创建页面后执行 `ctx.apply_emulation(page)`。
- 第一次成功后固化 `case/notes/fingerprint-baseline.json` 与 `baselineId`,后续复用 `base_dir`、`userdir` 和智能指纹输出。
- geolocation / timezone / locale / viewport 与出口 IP、智能指纹和 fingerprint baseline 保持一致。
- `page.capture.start(...)` 先于 `page.get(...)`。
- 导航后验证 `navigator.webdriver === false`。
- 对跨域接口,不能把单独的 `OPTIONS` preflight 当作业务取证成功。
- 自动交互优先使用 `page.actions` 原生 BiDi / human actions;确需 JS 事件时必须带 `ruyi: true`,普通 `dispatchEvent` 不视为可信输入。
如果任一硬约束失败,暂停并说明原因;不要自动切回普通 Playwright / Puppeteer / 系统 Firefox。
未检测到定制 Firefox 时使用以下提示:
```markdown
当前没有检测到 ruyiPage 定制 Firefox runtime,或 ruyiPage 可能会退回系统 Firefox。系统 Firefox 不视为通过。
请确认:
1. 你是否已经提前安装好 ruyiPage 定制 Firefox?
2. 如果已经安装,请提供 ruyiPage browsers 安装目录或定制 Firefox 可执行文件路径。
3. 如果没有安装,请提供希望安装到的目录;我会先输出安装计划,确认后再安装。
```
## Camoufox / camoufox-reverse-mcp 定位
Camoufox 来源:<https://github.com/daijro/camoufox>,官方文档核对时间:2026-06-20。camoufox-reverse-mcp 来源:<https://github.com/WhiteNightShadow/camoufox-reverse-mcp>,官方 README 核对时间:2026-06-20。
Camoufox 是基于 Firefox 的反指纹浏览器方案,Python API 与 Playwright 风格兼容;官方安装流程不是只安装 Python 包,还必须执行 `python -m camoufox fetch` 下载浏览器本体。camoufox-reverse-mcp 是围绕 Camoufox 的 MCP 工具集,适合在授权范围内进行网络捕获、脚本列表 / 源码保存、关键字搜索、Hook、请求详情、请求 initiator、Cookie / Storage 和可选属性访问追踪。
选择 Camoufox 或 Camoufox + camoufox-reverse-mcp 后,必须在启动任何浏览器前检测:
```bash
node scripts/check_external_tools.js --python python --require-camoufox --markdown
node scripts/check_external_tools.js --python python --require-camoufox --camoufox-install-dir <camoufox-cache-dir> --markdown
node scripts/check_external_tools.js --python python --require-camoufox --require-camoufox-mcp --camoufox-mcp-project-dir <camoufox-reverse-mcp-dir> --json
```
放行条件:
- Camoufox Python 包可导入,并能导入 `camoufox.sync_api.Camoufox`。
- `python -m camoufox path` 返回的浏览器路径存在,或在用户提供 / 默认缓存目录中检测到 Camoufox 浏览器本体。
- 用户选择 MCP 时,`camoufox_reverse_mcp` 可导入;如果用户提供项目目录,应优先通过该目录的 `src/` 检测。
- 用户选择 MCP 但 MCP 不可用时,不得静默降级为仅 Camoufox。必须让用户选择:安装 / 提供 MCP 项目目录,或明确降级为仅 Camoufox。
未检测到 Camoufox 时,使用以下提示:
```markdown
当前没有检测到可用 Camoufox,或只检测到 Python 包但未检测到 `python -m camoufox fetch` 下载的浏览器本体。
请确认:
1. 你是否已经在某个 Python / venv 中安装 Camoufox?如果已安装,请提供 Python 解释器路径或 venv 激活方式。
2. 你是否已经执行过 `python -m camoufox fetch`?如果已执行,请提供 `python -m camoufox path` 输出,或 Camoufox 缓存目录。
3. 如果没有安装,请提供希望使用的 Python / venv 和下载缓存目录;如果你未提供目录,我会说明将使用 Camoufox 默认缓存目录。
```
未检测到 camoufox-reverse-mcp 时,使用以下提示:
```markdown
当前没有检测到 camoufox-reverse-mcp。你选择的是 Camoufox + camoufox-reverse-mcp,因此不能直接降级为仅 Camoufox。
请确认:
1. 是否已经克隆并安装 camoufox-reverse-mcp?如果已安装,请提供项目目录或可导入该包的 Python / venv。
2. 如果未安装,请提供希望克隆到的目录;我会先输出安装计划,确认后再执行 `git clone` 与 `python -m pip install -e .`。
3. 如果你不想安装 MCP,请明确回复“降级为仅 Camoufox”。
```
### Camoufox 启动硬约束
从第一次打开目标页开始就使用 Camoufox 官方入口或 MCP,不得先用普通 Playwright / Puppeteer / 系统浏览器探测。默认约束:
- 使用有头模式:`headless=False`。Linux 无显示环境且用户确认时才可使用官方 `headless="virtual"`。
- 开启拟人鼠标:`humanize=True`。
- 代理场景必须考虑指纹一致性:用户授权代理时优先 `geoip=True`,必要时 `block_webrtc=True`,并让 locale / timezone / geolocation 与出口 IP 和 fingerprint baseline 一致。
- 不要每次启动随机窗口尺寸、字体、WebGL 等指纹值;首次成功取证后必须固化真实样本或工具输出,后续复用同一 baseline。
- 高风险或需要登录态时,优先使用持久上下文,并将 profile 放在 case 临时目录,结束后按敏感 Profile 处理。
- MCP 模式先调用 `check_environment`,再 `launch_browser(headless=false, humanize=true, geoip=true, block_webrtc=true)`;需要属性访问日志时才加 `enable_trace=true`,并记录 trace 文件位置。
- 鼠标、键盘、拖拽和滚动使用 Camoufox / MCP 原生输入路径;不要用 `page.evaluate` 合成事件作为高风控交互主路径。
仅 Camoufox Python API 示例只用于前置取证,不得复制进最终 `result/`:
```python
from camoufox.sync_api import Camoufox
with Camoufox(headless=False, humanize=True, geoip=True, block_webrtc=True) as browser:
page = browser.new_page()
page.goto("https://example.com")
```
MCP 取证顺序:
1. `check_environment`:确认 MCP、Camoufox、浏览器状态。
2. `launch_browser(headless=false, humanize=true, geoip=true, block_webrtc=true)`:从第一次打开页面就启用反检测配置。
3. `network_capture(action="start")`:先于 `navigate` 开始捕获。
4. `navigate(url="目标页面")`:打开页面;需要登录时暂停,让用户手动登录并回复“已经登录成功”。
5. `list_network_requests` / `get_network_request`:筛选目标接口,请求详情只保存授权分析所需字段。
6. `get_request_initiator`:定位请求发起栈。
7. `scripts(action="list|get|save")` / `search_code`:收集相关 JS 文件和关键词命中。
8. `inject_hook_preset("xhr"/"fetch"/"cookie"/"crypto")` 或 `hook_function`:只做最小必要 Hook,并在阶段结束后 `reset_browser_state`。
9. 可选 `trace_property_access(duration=0, mode="summary", collect_values=True)`:当需要浏览器环境访问日志辅助补环境时使用;这不是 RuyiTrace 的替代品,只作为 Camoufox 侧增强。
Camoufox 与 MCP 只用于前置取证、采样和日志收集。最终交付项目不得包含 `Camoufox`、`AsyncCamoufox`、`camoufox_reverse_mcp`、`launch_browser`、`network_capture`、`page.goto`、`browser.new_page` 等浏览器自动化 / 取证代码。
## CloakBrowser 定位
CloakBrowser 来源:<https://github.com/CloakHQ/CloakBrowser>,官方文档核对时间:2026-06-18。
它是带源码层指纹补丁的定制 Chromium,并提供 Python / JavaScript 包装器,官方定位为 Playwright / Puppeteer 的替换式启动入口。它适合在授权范围内,降低普通 Playwright / Puppeteer / CDP 自动化取证时被浏览器指纹、自动化特征或 CDP 行为检测命中的风险。
允许用途:
- 打开目标页面。
- 触发最少量必要业务动作。
- 采集 Network 请求。
- 查看 Initiator 与调用栈。
- 收集 JS bundle / chunk / sourcemap URL。
- 验证加密参数和 JS 文件是否存在。
禁止用途:
- 绕过登录。
- 破解验证码。
- 绕过 MFA。
- 账号滥用、注册滥用、撞库、越权访问。
- 批量采集或高频请求。
CloakBrowser 可以降低很多浏览器指纹和 CDP 检测风险,但不能保证绕过所有风控。IP 信誉、账号风险、行为检测、验证码和服务端自研逻辑仍可能导致阻断。
### CloakBrowser 安装检测必须前置
只要用户选择或要求使用 CloakBrowser 进行取证、采集数据、收集 JS、Hook、断点或截图,就必须在启动任何浏览器前先检测:
```bash
node scripts/check_external_tools.js --require-cloakbrowser --markdown
node scripts/check_external_tools.js --require-cloakbrowser --cloakbrowser-project-dir <node-project-dir> --markdown
node scripts/check_external_tools.js --python python --require-cloakbrowser --cloakbrowser-binary-path <chromium-or-chrome-path> --json
```
判断规则:
- 检测到 Python `cloakbrowser` 包或 Node.js `cloakbrowser` 包,并且检测到 stealth Chromium 二进制,才视为“可直接进入 CloakBrowser 取证”。
- 只检测到二进制但没有 Python / Node.js 包,不得直接用普通 Playwright 指向该二进制启动;应先安装或提供官方包装器环境。
- 只检测到包但没有二进制,正式取证前应让用户确认运行 `python -m cloakbrowser install` / `npx cloakbrowser install`,或提供已下载二进制路径。
- 未检测到包和二进制时,不得继续启动浏览器;先询问用户是否已安装。已安装则让用户提供 Python 解释器、Node 项目目录或 `CLOAKBROWSER_BINARY_PATH`;未安装则引导安装。
未安装时使用以下提示:
```markdown
当前未检测到可用 CloakBrowser 环境。
请确认:
1. 你是否已经提前安装好 CloakBrowser?
2. 如果已经安装,请提供 Python 解释器 / Node 项目目录 / CloakBrowser Chromium 二进制路径。
3. 如果没有安装,请确认希望使用 Python 路线还是 Node.js 路线,并提供安装目录或项目目录;我会先输出安装计划,确认后再安装或预下载二进制。
在 CloakBrowser 检测通过前,我不会改用普通 Playwright / Puppeteer 取证。
```
### 官方安装与预下载命令
Python 路线:
```bash
python -m pip install cloakbrowser playwright --upgrade
python -m cloakbrowser install
python -m cloakbrowser info
python -m cloakbrowser update
python -m cloakbrowser clear-cache
```
Node.js / Playwright 路线:
```bash
npm install cloakbrowser playwright-core
npx cloakbrowser install
npx cloakbrowser info
npx cloakbrowser update
npx cloakbrowser clear-cache
```
Node.js / Puppeteer 路线只有用户明确要求 Puppeteer 风格 API 时才使用:
```bash
npm install cloakbrowser puppeteer-core
```
不要在 Skill 中硬编码某个本机安装目录或二进制版本;每个 case 以检测脚本和 `info` 输出为准。
### CloakBrowser 官方启动硬约束
如果用户选择 CloakBrowser,则从第一次打开目标页开始就必须使用 CloakBrowser,不要先用普通 Playwright / Puppeteer / 系统浏览器探测后再切换。
必须满足:
- 使用官方包装器入口:JavaScript 优先 `import { launch, launchContext, launchPersistentContext } from 'cloakbrowser'`;Python 使用 `from cloakbrowser import launch, launch_context, launch_persistent_context`。
- 默认有头模式:`headless: false` / `headless=False`。
- 默认启用拟人行为:`humanize: true` / `humanize=True`。
- 不直接调用 `chromium.launch()`、`puppeteer.launch()` 或普通 `browserType.launch()`。
- 如用户授权使用代理,才配置 `proxy`;需要让时区、语言、WebRTC 与出口 IP、fingerprint baseline 保持一致时再启用 `geoip: true` / `geoip=True`。
- 高风控、需要登录态、或目标检测空白无痕 Profile 时,优先使用 `launchPersistentContext` / `launch_persistent_context`,并把 Profile 放到 `case/tmp/cloak-profile/`;首次成功后固化并复用该 baseline,不得每次随机新指纹。Profile 可能含登录态,清理前必须询问用户。
- 减少 `page.evaluate`、大量 `waitForTimeout`、异常鼠标轨迹等会增加可见自动化信号的动作;只执行最小必要业务动作。
- 点击、输入、滚动、拖拽必须走 CloakBrowser humanize / 官方交互方法;如果工具退回 `page.evaluate` 合成事件,视为 `isTrusted=false` 风险并暂停确认。
- 出现登录、验证码、MFA、设备验证时暂停,让用户手动完成;不要破解或绕过。
- CloakBrowser 只用于前置取证。最终交付项目中不得包含 CloakBrowser / Playwright / Puppeteer / CDP / WebDriver 自动化代码。
JavaScript 基础有头模式:
```js
import { launch } from 'cloakbrowser';
const browser = await launch({
headless: false,
humanize: true,
});
const page = await browser.newPage();
await page.goto('https://example.com');
await browser.close();
```
JavaScript 持久化 Profile:
```js
import { launchPersistentContext } from 'cloakbrowser';
const ctx = await launchPersistentContext({
userDataDir: './case/tmp/cloak-profile',
headless: false,
humanize: true,
});
const page = await ctx.newPage();
await page.goto('https://example.com');
await ctx.close();
```
Python 基础有头模式:
```python
from cloakbrowser import launch
browser = launch(headless=False, humanize=True)
page = browser.new_page()
page.goto("https://example.com")
browser.close()
```
高风控站点的可选配置,仅在用户授权后考虑:
```js
const browser = await launch({
proxy: 'http://user:pass@residential-proxy:port',
geoip: true,
headless: false,
humanize: true,
});
```
```python
browser = launch(
proxy="http://user:pass@residential-proxy:port",
geoip=True,
headless=False,
humanize=True,
)
```
## CloakBrowser 取证流程
1. 确认授权范围,并确认只做补环境前置取证。
2. 和用户确认取证模式为 CloakBrowser,并记录为本 case 后续唯一浏览器取证路线。
3. 运行 `check_external_tools.js --require-cloakbrowser` 检测包、二进制和用户提供路径。
4. 如果未安装或二进制缺失,暂停并让用户提供路径、确认安装或确认切换取证模式;不得自动 fallback 到普通 Playwright / Puppeteer。
5. 创建临时目录:`case/tmp/browser/`、`case/tmp/cloak-profile/`、`case/tmp/har/`、`case/tmp/screenshots/`,并准备 `case/notes/fingerprint-baseline.json`。
6. 使用 CloakBrowser 官方包装器从第一次导航开始启动,有头模式并启用 `humanize`;首次成功后固化 `baselineId`,后续复用同一 profile / 指纹配置。
7. 如果需要登录,进入手动登录等待流程。
8. 触发最少量必要业务动作。
9. 采集 Network、cURL、HAR、Initiator、调用栈、JS URL。
10. 验证加密参数和 JS 文件是否存在。
11. 输出前置总结,并请求用户确认。
12. 清理普通临时文件;登录态 Profile 单独询问用户处理方式。
## 登录处理
绝不要求用户提供:
- 账号密码。
- 短信或邮箱验证码。
- MFA Token 或 MFA Secret。
- 长期有效 Cookie 或 Authorization Token。
需要登录时,使用以下提示:
```markdown
当前目标站点需要登录。
请你在浏览器中手动完成登录、验证码、MFA 或其他安全验证。
完成后请回复:已经登录成功。
注意:请不要把真实账号密码直接发给我。在你确认登录成功前,我不会继续采集请求或分析接口。
```
用户回复 `已经登录成功` 后,不要立刻继续,先确认:
```markdown
已收到你确认登录成功。我将先检查当前登录态是否能够访问目标接口,然后按以下流程继续。
- 网站 URL:
- 目标页面:
- 目标 API:
- 请求方法:
- 加密参数:
- 参数位置:Query / Header / Body / Cookie
- 登录状态:用户已手动确认登录成功
- 取证模式:ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定
- 已知 JS 文件:
- 是否已有 Copy as cURL:
- 是否已有 HAR:
- 是否允许保存临时 Profile:
- 是否需要任务结束后删除登录态 Profile:
请确认以上信息是否正确。确认后我再继续。
```
## Cookie 过期与登录的区别
不要把所有 Cookie 失效都当作“需要用户重新给一份有效 Cookie”。处理顺序:
1. 先判断 Cookie 是否属于登录态 / 账号授权 / 会话权限,例如 session、SSO、Authorization、账号绑定 token。
2. 如果属于登录态或授权态,按登录流程暂停,让用户手动登录或提供授权样本;不要尝试绕过登录。
3. 如果目标站点不需要登录,或该 Cookie 明显是设备 Cookie、首访 Cookie、风控 Cookie、JS 生成 Cookie、challenge 派生 Cookie,则进入生成链路分析,而不是默认索要新 Cookie。
- 非登录 Cookie 将分析生成 / 刷新链路,并尽量纳入最终入口。
4. 对非登录 Cookie,优先定位写入者:`Set-Cookie`、`document.cookie = ...`、JS 计算、Storage 派生、iframe / Worker / WASM、或服务端 challenge;并将 writer 纳入 `source → entry → builder → writer` 链路。
5. 最终交付时,非登录 Cookie 应由入口脚本生成 / 刷新后再用 Node.js 或 Python 请求客户端发送;不要把浏览器自动化作为最终验证方式。
## 验证码、MFA 与风控验证
如果出现验证码、MFA、设备验证或风险验证:
- 暂停流程。
- 让用户手动完成验证。
- 不自动破解验证码。
- 不调用第三方打码服务。
- 不索要 MFA 密钥。
- 如果用户无法完成验证,降级为离线分析,并要求用户提供 cURL、HAR、JS 文件、Initiator、调用栈样本。
## Profile 敏感性
浏览器 Profile 可能包含 Cookie、localStorage、IndexedDB 和缓存,必须按敏感材料处理。
- 默认不要把登录态 Profile 放入最终交付物。
- 删除或保留登录态 Profile 前必须询问用户。
- 不要把真实 Cookie / token 明文写入公开笔记。
- 优先保存脱敏后的请求样本。

View File

@@ -0,0 +1,149 @@
# Camoufox / camoufox-reverse-mcp 取证参考
本文件只在用户选择或询问 Camoufox / camoufox-reverse-mcp 时读取。Camoufox 用于前置取证、采样、网络捕获、源码定位与日志收集,不得进入最终 `result/` 交付代码。
## 来源与定位
- Camoufox:<https://github.com/daijro/camoufox>,官方文档:<https://camoufox.com/python/installation/>、<https://camoufox.com/python/usage/>。
- camoufox-reverse-mcp:<https://github.com/WhiteNightShadow/camoufox-reverse-mcp>。
Camoufox 官方流程要求先安装 Python 包,再下载浏览器本体:
```bash
python -m pip install -U "camoufox[geoip]"
python -m camoufox fetch
python -m camoufox version
python -m camoufox path
```
`geoip` 在代理场景强烈建议安装,因为它用于根据出口 IP 计算经纬度、时区、国家和 locale。只安装 Python 包但没有执行 `python -m camoufox fetch`,不能视为可用于正式取证。
## 取证模式
| 模式 | 使用条件 | 阻塞条件 |
|---|---|---|
| Camoufox + camoufox-reverse-mcp | 需要 MCP 网络捕获、脚本搜索、Hook、请求 initiator、Cookie / Storage 或可选属性访问追踪 | Camoufox 或 MCP 任一不可用时暂停,不得静默降级 |
| 仅 Camoufox | 只需要用 Camoufox 官方 Python API 打开页面、轻量采样或手工导出材料 | Camoufox 包不存在或浏览器本体未 fetch 时暂停 |
如果用户选择 MCP,但未检测到 MCP:要求用户提供项目目录 / Python 环境,或确认安装;只有用户明确回复“降级为仅 Camoufox”才可以不用 MCP 继续。
## 安装检测
每次启动浏览器前先运行检测脚本:
```bash
node scripts/check_external_tools.js --python python --require-camoufox --markdown
node scripts/check_external_tools.js --python python --require-camoufox --camoufox-install-dir <camoufox-cache-dir> --markdown
node scripts/check_external_tools.js --python python --require-camoufox --require-camoufox-mcp --camoufox-mcp-project-dir <camoufox-reverse-mcp-dir> --json
```
检测通过标准:
- `camoufox` Python 包可导入。
- `from camoufox.sync_api import Camoufox` 可导入。
- `python -m camoufox path` 返回的路径存在,或在用户提供 / 默认缓存目录检测到浏览器本体。
- 选择 MCP 时,`camoufox_reverse_mcp` 可导入,或用户提供的项目目录可通过 `src/` 导入。
## 安装引导
如果用户确认未安装 Camoufox,先让用户提供 Python / venv 和下载缓存目录;如果用户不提供目录,说明将使用 Camoufox 默认缓存目录,然后执行:
```bash
python -m pip install -U "camoufox[geoip]"
python -m camoufox fetch
python -m camoufox version
python -m camoufox path
```
如果用户确认未安装 camoufox-reverse-mcp,先让用户提供克隆目录;如果用户不提供,使用当前项目外的用户确认目录或系统临时下载目录,不要把安装仓库混入最终 case 产物:
```bash
git clone https://github.com/WhiteNightShadow/camoufox-reverse-mcp.git <install-dir>
cd <install-dir>
python -m pip install -e .
python -c "import camoufox_reverse_mcp; print('ok')"
```
MCP 客户端配置示例:
```json
{
"mcpServers": {
"camoufox-reverse": {
"command": "python",
"args": ["-m", "camoufox_reverse_mcp"]
}
}
}
```
带代理且用户授权时,可在 MCP 参数中加入 `--proxy`、`--geoip`、`--humanize`、`--block-webrtc`。不要修改 AiMaMi 或其他本地代理配置;只输出用户可自行应用的配置建议。
## 启动硬约束
从第一次打开目标站开始就使用 Camoufox,不得先使用普通 Playwright、Puppeteer、Selenium、系统 Firefox 或系统 Chrome 试探。
必选约束:
- 默认有头:`headless=False`。Linux 无显示环境且用户确认时才使用官方 `headless="virtual"`。
- 默认拟人:`humanize=True`。
- 代理场景按授权启用 `geoip=True`,必要时 `block_webrtc=True`。
- 不固定窗口、字体、WebGL、locale 等指纹,除非已有真实样本或用户明确要求。
- 登录态场景使用持久上下文时,把 profile 放入 case 临时目录并标记为敏感材料。
- 点击、输入、滚动、拖拽使用 Camoufox / Playwright 原生输入路径或 MCP 官方交互能力;不要使用 `page.evaluate(() => dispatchEvent(...))` 作为高风控交互主路径。
Python API 取证示例:
```python
from camoufox.sync_api import Camoufox
with Camoufox(headless=False, humanize=True, geoip=True, block_webrtc=True) as browser:
page = browser.new_page()
page.goto("https://example.com")
```
该示例只能用于前置取证,不得复制到最终 `result/`。
## isTrusted 与可信输入
Camoufox 可以降低自动化和指纹检测风险,但前置取证仍应避免普通 JS 合成事件。鼠标、键盘、拖拽、滚动优先使用 Camoufox 官方 API 的原生输入路径;如果某个流程只能通过 `page.evaluate` 合成事件完成,应先标记 `isTrusted=false` 风险并暂停,让用户选择手动操作、切换到 ruyiPage 的 native BiDi / `ruyi: true`,或在授权范围内接受该风险。
## MCP 取证流程
1. `check_environment`:确认 MCP、Camoufox、浏览器状态。
2. `launch_browser(headless=false, humanize=true, geoip=true, block_webrtc=true)`:启动时即启用反检测约束。
3. `network_capture(action="start")`:在 `navigate` 前开始捕获。
4. `navigate(url="目标页面")`:打开页面;需要登录时暂停并让用户手动登录。
5. 用户回复“已经登录成功”后,再继续触发最小必要业务动作。
6. `list_network_requests`:按 URL、方法、资源类型筛选目标接口。
7. `get_network_request`:保存请求头、query、body、响应摘要;敏感字段做脱敏记录。
8. `get_request_initiator`:定位请求发起栈。
9. `scripts(action="list|get|save")` 和 `search_code`:收集相关 JS 文件和关键字命中。
10. `inject_hook_preset("xhr"/"fetch"/"cookie"/"crypto")` 或 `hook_function`:最小必要 Hook;阶段结束后 `reset_browser_state`。
11. 可选 `trace_property_access(duration=0, mode="summary", collect_values=True)`:当需要浏览器环境访问日志辅助补环境时使用,结果复制到 case 的 `evidence/` 或 `notes/`,并在阶段结束后清理缓存中的无关大文件。
## 登录处理
不要索要账号、密码、验证码或 MFA。需要登录时:
1. 暂停自动动作。
2. 让用户在当前 Camoufox / MCP 打开的页面中手动登录。
3. 用户回复“已经登录成功”后,复述将继续的取证动作并再次确认。
4. 只采集授权分析所需的最小请求、Cookie / Storage 摘要和 JS 证据。
## 与补环境的关系
Camoufox 采集到的 Network、Initiator、脚本源码、Hook 日志、环境访问日志只作为补环境输入。进入 Node.js 补环境后仍然要遵守本 Skill 的 addon-first、属性描述符、原型链、toString 保护、指纹值回放和 fixture 验证规则。
如果同时存在 RuyiTrace NDJSON,且用户选择了 ruyiPage + RuyiTrace,则补环境优先依据 RuyiTrace NDJSON;Camoufox 的属性访问追踪只作为补充证据。
## 最终交付禁入
最终 `result/` 目录不得包含:
- `Camoufox`、`AsyncCamoufox`、`camoufox_reverse_mcp`。
- `launch_browser`、`network_capture`、`navigate`、`page.goto`、`browser.new_page`。
- Playwright / Puppeteer / Selenium / CDP / WebDriver 启动代码。
- Camoufox profile、trace 缓存、截图、HAR、Hook 临时代码或无关大文件。
最终验证必须由 Node.js / Python TLS 指纹兼容请求客户端发起少量授权模拟请求,或按用户确认只输出本地参数。

View File

@@ -0,0 +1,143 @@
# 验证码接口取证、事件轨迹 fixture 与 web-verify-patcher 交接
当目标 API、Cookie、Header、Body 或参数名体现 `captcha`、`verify`、`challenge`、`waf`、`risk`、`slide`、`track`、`motion`、`validate`、`ticket`、`seccode`、`geetest`、`tcaptcha`、`yidun`、`turnstile` 等验证码 / 风控验证特征,或用户明确说明本次是验证码接口时读取本文件。本文件只约束网页端 JS 补环境与授权取证流程,不提供未授权绕过、批量通过、登录规避、短信 / 邮箱 / MFA / 2FA 处理。
## 1. 取证前验证码接口确认门禁
用户信息完整并确认任务后,启动任何浏览器取证、RuyiTrace 捕获、Hook、断点、JS 下载、动态资源保存或接口重放之前,必须先确认:
```markdown
本次目标接口是否属于验证码 / 风控验证 / challenge / WAF / 人机验证接口?
- 如果不是:按普通 Web JS 补环境流程继续。
- 如果是:请选择验证码流程取证方式:
1. 我提供从触发到验证的完整流程,你按已确认取证工具自动完成取证。
2. 我自己在取证浏览器中完成触发到验证,你只负责提前开启抓包 / Hook / Trace,并在我回复“已经完成触发到验证流程”后继续分析。
```
用户未确认是否为验证码接口,或确认是验证码接口但未选择流程取证方式前,不得开始验证码相关取证。
## 2. 两种验证码流程取证方式
### 方式 A:用户提供流程,AI 自动执行取证
要求用户提供尽量完整的流程信息:
- 入口页面 URL。
- 触发验证码的操作:点击按钮、输入字段、提交表单、滚动、接口失败后弹出等。
- 验证码出现位置:iframe、弹窗、Shadow DOM、内联组件或跳转页面。
- 验证码类型线索:滑块、点选、旋转、文字、无感、WAF challenge、PoW、风险评分等。
- 从验证码展示到点击验证 / 提交的完整操作顺序。
- 目标请求接口名、URL 片段、参数名或响应特征。
- 是否需要登录、人工验证或权限确认。
AI 执行时必须使用用户已确认的取证模式,并从第一次打开目标页开始满足该工具的反检测硬约束和 `isTrusted` 可信输入规则。ruyiPage 优先 native BiDi / human actions,必要 JS 事件使用 `ruyi: true`;Camoufox / CloakBrowser 使用 humanize / 原生输入,不把普通 `dispatchEvent` 作为验证码交互主路径。自动流程只能做最小必要交互和取证,不做批量尝试,不穷举验证码答案,不绕过登录、MFA 或访问控制。
### 方式 B:用户手动完成流程,AI 只负责捕获
在用户开始操作前,先开启已确认取证工具的网络捕获、Hook、截图或 Trace:
- ruyiPage:先 `page.capture.start(...)`,再让用户在页面中完成触发和验证。
- ruyiPage + RuyiTrace:先确认 RuyiTrace 记录已经开始,要求日志覆盖“触发验证码 → 展示 → 交互 → 点击验证 / 提交 → verify 接口返回”的完整时间段。
- Camoufox / MCP:先 `network_capture(action="start")`,需要时启用最小 Hook 或属性访问追踪。
- CloakBrowser:先开启网络捕获 / 事件记录,再让用户操作。
- 用户手动取证:要求用户导出 HAR / cURL / 关键截图 / Trace 日志。
自动取证如需触发事件,必须遵守 `trusted-input-and-isTrusted.md`。如果无法保证可信输入,优先让用户手动完成触发到验证流程。
用户完成后必须明确回复:
```text
已经完成触发到验证流程
```
收到确认后再停止捕获、导入日志、整理请求链路和 JS 证据。未收到确认时,不要假设验证码链路已经覆盖。
## 3. RuyiTrace 验证码日志覆盖要求
验证码场景下,RuyiTrace NDJSON 不能只覆盖页面首屏加载。日志必须覆盖:
1. 触发验证码前的关键操作。
2. 验证码组件初始化。
3. 用户交互事件写入:click、mousemove、pointermove、drag、touch、wheel、keydown 等。
4. 加密参数生成入口。
5. verify / validate / check / challenge 接口发起与 writer 写入。
6. 结果回调或错误处理。
导入日志后,如果 `notes/ruyitrace-summary.md` 没有覆盖上述阶段,先要求补采日志,不得直接进入补环境。长字段仍遵循 RuyiTrace 截断规则:达到或接近 4000 字符时只记录可见长度、最小长度和 hash,真实长度写 `unknown`。
## 4. 验证码补环境中的事件轨迹 fixture
验证码接口的加密参数经常依赖用户事件。`web-js-env-patcher` 的目标是让目标 JS 在 Node.js 中生成加密参数;此阶段允许先使用旧轨迹、旧点击序列或旧事件 fixture,目的只是让 signer 跑通并输出加密参数,不宣称最终验证码验证一定成功。注意:Node.js 中的 `motionTrack` / `eventFixture` 只是参数生成输入,不代表真实浏览器事件的 `isTrusted`。
必须把轨迹设计成可替换入口,不得写死在不可修改的 signer 逻辑中。推荐文件:
```text
case/fixtures/motion.fixture.json
case/fixtures/event.fixture.json
case/result/src/verify/motion-track.js
case/result/src/verify/event-fixture.js
```
推荐 JS 结构:
```javascript
// 验证码事件轨迹输入,当前先使用浏览器取证得到的旧轨迹样本。
// 后续可由 web-verify-patcher 根据识别结果替换坐标和时间间隔。
const motionTrack = [
{ x: 12, y: 18, t: 0, type: 'mousemove' },
{ x: 36, y: 19, t: 18, type: 'mousemove' },
{ x: 88, y: 21, t: 43, type: 'mouseup' }
];
async function makeVerifyParams(requestInput) {
return signer.generate({
request: requestInput,
motionTrack,
eventFixture: { source: 'old-browser-sample' }
});
}
```
中文注释必须 UTF-8 正常显示,不得出现问号、连续问号或乱码。变量名应清晰,例如 `motionTrack`、`eventFixture`、`verifyContext`、`clickPoints`、`dragPath`。阶段报告中要写明:当前轨迹只用于补环境生成参数,最终识别和验证通过率交给 `web-verify-patcher` 继续处理。
## 5. 与 web-verify-patcher 的交接
当验证码接口的加密参数已经能通过补环境生成,且下一步需要识别验证码图片、生成真实轨迹、坐标换算、滑块偏移、点选顺序、旋转角度、验证码提交验证等任务时,应交给 `web-verify-patcher`。
调用前必须先检测是否已安装:
```bash
node scripts/check_web_verify_patcher.js --require --markdown
```
如果已安装,输出交接材料:
- 验证码类型和厂商初判。
- 入口页面、验证码 iframe / 脚本 URL、verify 接口。
- 图片 / 背景 / 滑块 / 点选素材来源。
- DOM 坐标系、设备像素比、容器位置和缩放。
- 旧 `motionTrack` / `eventFixture` 路径。
- 已补好的加密参数入口。
- 网络请求和 RuyiTrace / Hook 证据。
如果未安装,先让用户选择:
1. **自动安装**:用户提供安装目录;若用户未提供目录,说明将使用默认 Skill 目录。克隆 `https://github.com/lwjjike/xbsReverseSkill` 后必须检查仓库中是否存在 `web-verify-patcher/` 目录;如果不存在,不得假装安装成功,必须要求用户提供正确分支、正确仓库、压缩包或本地目录。
2. **自行安装**:告诉用户把 `web-verify-patcher` 文件夹放到 Codex Skill 目录,例如 Windows:`%USERPROFILE%\.codex\skills\web-verify-patcher`,macOS / Linux:`$HOME/.codex/skills/web-verify-patcher`。如当前环境使用 `.agents/skills`,可同步放到 `$HOME/.agents/skills/web-verify-patcher`。安装后重启 / 刷新 Codex,再重新检测。
## 6. 输出模板
```markdown
## 验证码接口取证确认
- 是否验证码 / 风控验证接口:是 / 否 / 待确认
- 验证码流程取证方式:用户提供流程由 AI 自动执行 / 用户自己完成流程,AI 捕获 / 不涉及
- 触发到验证流程是否已覆盖:是 / 否
- RuyiTrace 是否覆盖完整验证码流程:是 / 否 / 未使用
- 事件轨迹 fixture:`case/fixtures/motion.fixture.json` / 未涉及
- 轨迹入口变量:`motionTrack` / `eventFixture` / 其他
- 当前轨迹用途:仅用于补环境生成加密参数 / 用于后续识别验证
- web-verify-patcher 状态:已安装 / 未安装 / 待用户选择安装方式
- 下一步:继续补环境 / 交接 web-verify-patcher / 等待用户完成触发到验证流程
```

View File

@@ -0,0 +1,53 @@
# 常见案例模式
本文件用于识别补环境任务中的常见形态,只作为分析提示,不替代证据。
## Header 签名型
信号:目标参数在 Header 中,例如 `x-sign`、`x-s`、`x-token`。
重点:
- `setRequestHeader` 或 fetch init.headers 是 writer。
- builder 常在 request interceptor 或 SDK request wrapper。
- source 往往包含 URL、Body、时间戳、Cookie、设备标识。
## Query 混淆参数型
信号:目标参数在 URL Query 中,例如 `sign`、`a_bogus`。
重点:
- Hook `URLSearchParams.append/set` 与 XHR/fetch URL。
- 注意 Query 排序、编码方式、空值和数组序列化。
- 常依赖 UA、Referer、时间和随机数。
## Body 签名型
信号:目标参数在 JSON 或表单 Body 中。
重点:
- 保留原始 Body 字符串,避免 JSON 重新序列化造成差异。
- 比对 Content-Type 与空格、顺序、转义。
- Hook `JSON.stringify` 可辅助定位 builder。
## SDK 初始化型
信号:入口函数找到了但直接调用失败,或必须先执行 init。
重点:
- 查 `init`、`config`、`setConfig`、`install`、`use`、`start`。
- 记录全局配置、meta 标签、script 标签参数。
- 先补初始化链,再补环境对象。
## 异步消息型
信号:签名结果通过 Promise、回调、Worker message 返回。
重点:
- 记录消息类型和 payload。
- 区分初始化消息与签名请求消息。
- 用 fixtures 验证最终异步输出。

View File

@@ -0,0 +1,145 @@
# 临时产物清理规范
当创建临时文件、测试文件、浏览器产物、缓存文件、中间产物或 case 目录时,使用本文件约束清理行为。
## 总原则
- **测试完成立即清理**:每次脚本测试、功能验证、浏览器取证小步骤完成后,第一时间清理本步骤产生的测试文件、临时文件、缓存文件、中间产物和空目录,不要等整个项目结束。
- **产物先沉淀再删除**:删除前先把有价值结论写入 `notes/`、`result/` 或最终报告;确认无价值的原始临时文件再清理。
- **临时内容集中存放**:测试文件必须放入系统临时目录或 `case/tmp/` / `case/.tmp/` / `case/cache/` 等可清理目录;不要把 throwaway 文件散落在项目根目录。
- **脚本测试使用 finally**:编写测试命令时使用 `try/finally` 或等价逻辑,确保失败时也删除临时目录。
- **每次交付前复查**:最终回复用户前,必须执行一次清理 dry-run 或说明未执行原因;若 dry-run 发现普通临时产物,应立即 `--force` 清理并再次 dry-run。最终交付还必须运行 `check_env_realism.js` 和 `check_final_artifact.js`,确认补环境真实性 / RuyiTrace 证据完整,且 `result/` 是规范项目目录、只有一个执行入口、没有多余测试文件、临时文件或自动化代码。
- **敏感材料例外**:登录态 Profile、Cookie、localStorage、IndexedDB、Authorization 等敏感材料不自动删除或交付;删除前必须确认用户意图。
## 目录约定
建议 case 结构:
```text
case/
├── js/
│ ├── original/
│ ├── pretty/
│ └── extracted/
├── requests/
├── fixtures/
├── notes/
└── tmp/
```
默认 `tmp/`、`.tmp/`、`temp/`、`.temp/`、`cache/`、`.cache/`、`browser-temp/`、`downloads/failed/`、`__pycache__/`、`.pytest_cache/` 等都是可清理目录。最终产物应尽量简洁,只保留必要内容。
最终 `result/` 目录应是规范项目目录,可以包含必要源码模块和配置模板,但只能有一个执行入口:
```text
result/
├── final.js
├── package.json
├── config.example.json
└── src/
```
或用户明确选择 Python 请求客户端时:
```text
result/
├── final.py
├── requirements.txt
├── config.example.json
└── src/
```
不要把临时 runner、测试脚本、trace、HAR、hook、截图、浏览器 Profile、`server.js`、`bridge.py` 留在最终交付目录。`env`、`signer`、`request` 等代码可以作为 `src/` 下的被调用模块保留,但不得成为第二执行入口。
## 应清理的临时文件
| 类型 | 清理时机 |
|---|---|
| 测试用输入 / 输出文件 | 单个测试命令完成后立即清理 |
| 失败的 JS 下载文件 | JS 文件收集阶段结束后立即清理 |
| 临时格式化文件 | 正式 pretty 文件生成后立即清理 |
| hook 脚本 / 指纹采样 Hook | 调用栈或指纹终端 API 返回值确认并沉淀到 notes / fixtures 后立即清理 |
| 临时 trace / JSONL / 调试日志 | 关键结论写入 notes 后立即清理 |
| 临时 runner / scratch 脚本 | 正式 runner 生成或测试完成后立即清理 |
| 重复样本副本 | fixtures 整理完成后立即清理 |
| cache / `.cache` / `__pycache__` / `.pytest_cache` | 对应测试或脚本执行后立即清理 |
| 空文件 / 空目录 | 每个测试或阶段结束时立即清理 |
| 无登录态浏览器 Profile | 浏览器取证阶段结束后清理 |
## 敏感文件
以下文件按敏感材料处理:
- 包含登录态的浏览器 Profile。
- Cookie / localStorage / sessionStorage 导出。
- Authorization 请求头。
- 含真实 Cookie 或账号标识的 HAR。
- 包含隐私账号信息的截图。
默认不要把这些内容放入最终交付物,除非用户明确要求。优先做脱敏处理。
## 清理检查表
```markdown
## 清理检查
- [ ] 本次测试是否使用系统临时目录或 `case/tmp/`?
- [ ] 单个测试完成后是否立即清理测试文件?
- [ ] 是否删除临时失败下载文件?
- [ ] 是否删除临时 hook 脚本?
- [ ] 是否删除指纹采样 Hook,且只保留必要 fingerprint fixture?
- [ ] 是否删除无用日志?
- [ ] 是否删除缓存目录和中间产物?
- [ ] 是否删除空目录?
- [ ] 是否只保留必要请求样本?
- [ ] 是否只保留必要 JS 文件?
- [ ] 是否已将关键结论写入 notes?
- [ ] 是否确认登录态 Profile 的处理方式?
- [ ] 是否避免 Cookie / token 明文进入最终报告?
- [ ] `result/` 是否是规范项目目录,且只有 `final.js` 或 `final.py` 一个执行入口?
- [ ] 最终项目所有源码是否不包含 ruyiPage / Playwright / Puppeteer / Selenium / CloakBrowser 等自动化代码?
- [ ] 最终请求是否由 Node.js / Python HTTP 客户端实现?
- [ ] 是否已运行 `check_env_realism.js` 检查补环境真实性和 RuyiTrace 证据?
- [ ] 是否已运行 `check_final_artifact.js`?
- [ ] 最终回复前是否重新运行 dry-run 确认无普通临时产物残留?
```
## 脚本用法
先预览:
```bash
node scripts/clean_case.js --case-dir case --dry-run --markdown
```
清理普通临时文件:
```bash
node scripts/clean_case.js --case-dir case --force --markdown
```
只有用户明确确认后,才删除 Profile:
```bash
node scripts/clean_case.js --case-dir case --force --include-profiles --markdown
```
清理后复查:
```bash
node scripts/clean_case.js --case-dir case --dry-run --json
```
期望 `remainingTempLike` 为空;如果不为空,说明仍有普通临时 / 缓存 / 中间产物残留,需要继续处理。
补环境真实性复查:
```bash
node scripts/check_env_realism.js --case-dir case --markdown
```
最终产物复查:
```bash
node scripts/check_final_artifact.js --case-dir case --markdown
```

View File

@@ -0,0 +1,131 @@
# 通用代码变更记忆机制
本文件用于复杂网页端补环境 case 中的所有关键代码修改,不只适用于浏览器 API。它覆盖 `result/` 源码、`src/env/`、`src/signer/`、`src/request/`、TLS 客户端封装、addon helper、路径处理、异步流程、Cookie 生成、参数生成、入口脚本等任何可能反复修改或回退的工程逻辑。
## 目标
避免在多轮修复中反复写回旧错误逻辑。每次修改关键源码时,不把“当前报错消失”草率视为无验证范围的固定结论,而是记录:改了什么、为什么改、旧逻辑为什么错、验证了什么、失败方案不能再写回什么、当前验证范围和遗留风险。
## 触发条件
满足任一条件时必须启用:
- case 难度较高、对话多轮、或已经出现同一函数 / 同一文件反复修改。
- 修改 `case/result/` 下任何最终交付源码。
- 修改 signer、request、env、addon helper、TLS、路径、异步流程、Cookie 生成等关键逻辑。
- 为解决报错而替换一段旧逻辑,存在之后又写回旧逻辑的风险。
- 用户反馈“你又把之前错的逻辑写回去了”或类似问题。
## 文件位置
默认维护:
```text
case/notes/代码变更记忆.md
```
不存在时先创建。可以用脚本初始化:
```bash
node scripts/check_change_memory.js --case-dir case --init --markdown
```
## 修改前必须做
1. 读取 `case/notes/代码变更记忆.md`。
2. 搜索本次准备修改的文件名、函数名、类名、参数名、错误关键词和相关模块名。
3. 如果记忆中存在“禁止回退”或“已失败尝试”,先确认本次修改不会写回这些内容。
4. 如果确实需要覆盖历史结论,必须在新记录中写明新的证据,而不是直接删除旧记录。
## 修改后必须做
每次关键源码修改后立即追加一条记录。不要等最终总结时再补写。
### 记录模板
```md
## 变更 001 - 简短标题
- 时间:YYYY-MM-DD HH:mm
- 涉及文件:result/src/env/install-env.js;result/src/signer/index.js
- 涉及函数 / 模块:installNavigator;buildSign
- 当前状态:临时修复 / 当前验证通过 / 已失败 / 被替代 / 稳定基线
### 修改前逻辑
简述旧代码做了什么,不要只写“旧逻辑错误”。
### 问题证据
记录触发错误的命令、堆栈、fixture 差异、日志、RuyiTrace 证据、接口响应或用户反馈。
### 本次修改
说明实际改了哪些代码、哪些文件、哪些函数,不要只写“优化了”。
### 修改理由
解释为什么选择这次改法。只能把“当前验证通过”写成当前状态,不能写成永久正确。
### 已失败尝试
列出已经证明失败或不可靠的方案,包括失败原因。
### 禁止回退
明确以后不要写回什么,例如“不要再把 userAgent 改成实例 data descriptor;除非有新浏览器样本证明该站点只检测实例属性”。
### 验证命令
```bash
node result/final.js
node scripts/compare_fixture.js --fixture case/fixtures/sample.fixture.json --actual case/tmp/node-output.json --field sign --markdown
```
### 验证结果
记录通过 / 失败、输出摘要、关键字段差异。
### 当前验证范围
说明只验证了哪些 fixture、哪些接口、哪些运行环境、哪些 Node 版本或 TLS 客户端。
### 遗留风险
记录尚未覆盖的检测点、样本不足、环境差异、登录态限制等。
```
## 状态定义
| 状态 | 含义 |
|---|---|
| 临时修复 | 只解决当前报错或部分样本,不能作为稳定基线 |
| 当前验证通过 | 已通过当前列出的验证范围,但仍可能被新检测推翻 |
| 已失败 | 明确失败,后续不得无证据写回 |
| 被替代 | 旧方案被新方案替换,保留原因和替代依据 |
| 稳定基线 | 经多组样本、最终入口和请求验证通过,仍需记录验证范围 |
## 重要原则
- 不使用无验证范围的绝对结论;使用“当前验证通过”“稳定基线”等有范围的状态。
- 不删除失败记录;失败记录是防止回退的证据。
- 不把当前报错消失等同于真实浏览器一致。
- 不把浏览器 API 规则写死成全局真理;同一 API 的放置位置、描述符和行为必须以目标浏览器样本、RuyiTrace、fixture 和检测证据为准。
- 对与补环境无关的工程错误同样记录,例如路径拼接、异步顺序、请求重试、JSON 序列化、TLS 客户端参数、模块导出等。
- 如果修改涉及安全边界、请求次数、登录态或敏感数据,必须在遗留风险中说明授权范围。
## 交付前检查
交付前至少运行:
```bash
node scripts/check_change_memory.js --case-dir case --markdown
```
如果本轮修改过关键源码,还要按文件检查:
```bash
node scripts/check_change_memory.js --case-dir case --changed result/src/env/install-env.js --require-entry --markdown
```
检查未通过时,不要交付最终结果;先补齐记忆记录或说明用户明确豁免。

View File

@@ -0,0 +1,230 @@
# 补环境代码可读性与中文注释规范
每次生成、重构或交付 `case/result/` 中的补环境代码前读取本文件。目标是让最终代码 **简洁、可读、可维护、中文注释正常显示**,并避免生成压缩、堆叠、无注释或乱码代码。
## 硬性原则
1. **先规划目录,再写代码**
写代码前先列出最终目录、每个文件职责和调用关系。不要边写边临时堆文件。
2. **按职责拆模块**
不要把所有补环境逻辑塞进一个 `env.js`。推荐结构:
```text
case/result/
├── final.js
├── 最终项目总结.md
└── src/
├── env/
│ ├── install-env.js
│ ├── native-api.js
│ ├── manifest.js
│ ├── core/
│ │ ├── descriptors.js
│ │ └── cookie-store.js
│ ├── browser-objects/
│ │ ├── window.js
│ │ ├── navigator.js
│ │ ├── document.js
│ │ ├── location.js
│ │ ├── screen.js
│ │ ├── xhr.js
│ │ └── storage.js
│ └── fingerprint/
│ ├── canvas.js
│ ├── webgl.js
│ └── dom-geometry.js
├── target/
│ └── entry.js
├── request/
│ └── client.js
└── utils/
└── normalize.js
```
如果需要 Node 诊断执行器、runtime probe 或 Akamai / Shape / F5 等高强度检测的本地执行器,应继续拆成下列职责,而不是把所有 WebAPI 放进 `src/signer/*probe*.js`:
```text
case/result/src/
├── signer/
│ ├── runtime_signer.py
│ └── akamai_runtime_probe.js # 只做入口和编排,建议少于 150 行
└── node-runtime/
├── bootstrap/
│ ├── read-stdin.js
│ ├── execute-script.js
│ └── result-recorder.js
├── utils/
│ ├── hash.js
│ ├── sanitize-url.js
│ └── body-summary.js
└── env/
├── install-env.js
├── native-api.js
├── node-leakage.js
├── browser-objects/
│ ├── window.js
│ ├── navigator.js
│ ├── document.js
│ ├── location.js
│ ├── history.js
│ ├── screen.js
│ ├── storage.js
│ ├── performance.js
│ └── events.js
├── fingerprint/
│ ├── canvas.js
│ ├── webgl.js
│ ├── audio.js
│ └── dom-geometry.js
└── network/
├── xhr.js
├── fetch.js
└── beacon.js
```
3. **signer / probe 不得承载补环境主体**
`src/signer/`、`src/request/`、`src/resources/`、`runtime_probe.js`、`probe.js`、`runner.js`、`diagnostic.js` 等文件只允许做编排、进程入口、请求封装、资源刷新或安全摘要输出;只要文件内实现了 `navigator`、`document`、`window`、`screen`、`Storage`、`XMLHttpRequest`、`fetch`、`Canvas`、`WebGL`、`Audio`、`performance`、DOM 构造链、事件系统等浏览器 WebAPI 主体,就必须拆入 `src/env/` 或 `src/node-runtime/env/` 下的真实模块。不得因为文件名叫 probe、runtime、signer 或 diagnostic 就豁免模块化要求。
4. **补环境代码必须可读**
禁止压缩代码、单行堆叠多个语句、过度匿名函数、无意义变量名、超长函数和超深嵌套。原始目标 bundle 如必须保留,应放到 `src/target/original/` 或等价目录,并与手写补环境代码分离。
5. **中文注释必须正常显示**
所有交付源码使用 UTF-8 无 BOM。不要使用未指定编码的 PowerShell / cmd 重定向写中文源码。中文注释中不得出现问号、连续问号或替换字符。
6. **WebAPI 实现必须可审计**
补环境源码不得把 WebAPI 写成无法审查的一行堆叠代码。禁止一行连续堆多个 `Object.defineProperty`、`Object.defineProperties`、`Object.assign`、函数定义或对象字面量方法。`install-env.js` 只负责装配,不应成为巨型文件;`navigator`、`screen`、`document`、`storage`、`indexedDB`、`canvas`、`webgl`、`events`、`worker`、`request` 等应按职责拆模块。
7. **普通 WebAPI 函数必须 addon-first**
不要在 WebAPI 主路径中直接写 `ctx.Blob = function(){}`、`ctx.indexedDB = { open() {} }`、`prototype = { getContext() {} }` 或 `ctx.URL.createObjectURL = function(){}`。普通函数也要提取为具名函数,并通过 addon-first helper 包装后再安装 descriptor。addon 不可用时才用 `NativeProtect` / JS fallback,并在注释与阶段报告中说明原因。
8. **浏览器对象私有状态不得泄露**
不要在浏览器可见对象上写 `this.__readyState`、`this._headers`、`__children`、`__parentNode`、`__responseHeaders`、`_store` 等内部字段。non-enumerable 也会被 `Object.getOwnPropertyNames`、`Reflect.ownKeys` 和 descriptor 检测到。内部状态优先使用 addon / xbs private API,其次使用模块级 `WeakMap`;XHR、fetch、Headers、Response、DOM 节点、EventTarget、Storage、Performance 等都适用。
9. **XHR/fetch 必须拆为网络环境模块并桥接 Session**
`XMLHttpRequest` / `fetch` / `Request` / `Response` / `Headers` / `sendBeacon` 的浏览器语义放在 `src/env/network/` 或 `src/node-runtime/env/network/`。需要真实请求时,网络模块只能调用 live session bridge;不得在 env 里直接调用 Node 宿主 `fetch`、`http`、`https`、`axios`、`undici`。fixture/default response 只能保留为 `offline-fixture` 诊断模式。
10. **构造函数错误必须可追溯**
构造函数的报错类型和 message 必须来自目标浏览器采样。不要写泛化 `throw new TypeError('Illegal constructor')`;需要在注释、fixture 或阶段报告中说明该错误来自哪个浏览器、哪个调用方式。
11. **addon 实例不再二次标记对象类型**
`markObjectType` 不是批准 API。addon 构造函数 / `createProtoChains` 实例工厂创建出的实例本身应已处理 `Object.prototype.toString`;只有 JS fallback 普通对象才允许 `markObjectToString` / `Symbol.toStringTag`,并必须写明 fallback 原因。
12. **注释说明“为什么”和“来源”**
注释不是逐行翻译代码,而是说明模块职责、浏览器样本来源、RuyiTrace 证据、fixture 匹配规则、addon-first 决策和 fallback 原因。
13. **isolated-vm 也必须文件化模块交付**
isolated-vm 底层确实需要把源码字符串交给 V8,但最终项目不得把补环境源码写成大段 `String.raw`、`CORE_SCRIPT`、`BROWSER_OBJECTS_SCRIPT`、`*_SCRIPT` 聚合字符串。正确做法是把 `navigator.js`、`document.js`、`window.js`、`canvas.js`、`webgl.js` 等作为真实源码文件保存,由宿主侧 runtime 使用 `fs.readFileSync` 读取,再通过 `compileScriptSync(source, { filename })` 或等价方法注入同一个 isolated-vm Context。只有少量 bootstrap 片段允许字符串形式,且必须少于 40 行、不能承载主要 WebAPI 实现,并写明原因。
## 中文注释要求
必须写中文注释的位置:
- 文件顶部:说明模块职责、输入来源和边界。
- 每个环境对象模块:说明模拟哪个浏览器对象。
- 关键 getter / setter:说明真实值来自 fixture、RuyiTrace、请求样本还是用户配置。
- native-like 函数和构造函数:说明挂载位置、浏览器行为和 addon-first 处理。
- fallback 分支:说明 addon 不可用、ABI 不兼容、API 调用失败或用户明确豁免的原因。
- 指纹值回放函数:说明匹配 key、采样来源和缺失样本时的处理。
- 加密参数生成入口:说明输入、输出和 fixtures 验证方式。
禁止:
```js
// 不合格:用问句解释实现
// 不合格:连续问号乱码示例
// 临时先这样
```
推荐:
```js
// 安装 Navigator 相关环境对象,字段值来自浏览器 fixture
function installNavigatorEnv(globalObject, fixture, nativeApi) {
// Navigator 构造函数错误信息来自 constructor-errors.fixture.json 中的浏览器采样
const Navigator = nativeApi.createNativeConstructor('Navigator', 0, function Navigator(isNew) {
if (isNew) {
return nativeApi.throwIllegalConstructor('Navigator');
}
return nativeApi.throwIllegalConstructor('Navigator');
});
// userAgent 使用真实浏览器采样值,不在 Node.js 中伪造默认值
nativeApi.defineNativeGetter(Navigator.prototype, 'userAgent', function getUserAgent() {
return fixture.browser.userAgent;
});
}
```
## 命名与函数拆分
- 函数名使用业务含义,例如 `installNavigatorEnv`、`createStorageArea`、`replayCanvasToDataURL`。
- 避免 `a`、`b`、`fn1`、`tmp`、`xxx` 作为长期变量名。
- 单函数建议不超过 90 行。
- 单文件建议不超过 500 行。
- 单行建议不超过 180 字符。
- 属性描述符、构造函数 callback、WebAPI 方法安装、`Object.assign`、`try/catch` 较长逻辑不得压成一行。
- 嵌套层级尽量不超过 6 层;复杂逻辑用提前返回或拆函数。
## isolated-vm 文件化加载示例
推荐由 `install-env.js` 只负责装配,由 runtime 顺序读取文件:
```js
const ENV_FILES = [
'src/env/core/descriptors.js',
'src/env/browser-objects/window.js',
'src/env/browser-objects/navigator.js',
'src/env/browser-objects/document.js',
'src/env/fingerprint/canvas.js',
'src/env/fingerprint/webgl.js',
];
function installEnv(runtime, config) {
runtime.setCopy('__ENV_CONFIG__', config);
runtime.runFiles(ENV_FILES);
runtime.runFile('src/env/install-all.js');
}
```
每个文件都应是正常 JavaScript 文件,文件顶部用中文说明模块职责。Context 内不需要也不应该直接 `require('./navigator.js')`;由宿主 runtime 读取文件内容后注入,既保持 isolated-vm 隔离,又保留源码文件结构、中文注释和 filename 栈定位。
## 最终交付前检查
交付前必须运行:
```bash
node scripts/check_code_quality.js --case-dir case --markdown
node scripts/check_object_shape_audit.js --case-dir case --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --markdown
node scripts/check_trace_runtime_conformance.js --case-dir case --markdown
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --out case/tmp/xhr-fetch-semantics-audit.json --markdown
node scripts/check_environment_closure.js --case-dir case --before-real-request --markdown
node scripts/check_webapi_addon_coverage.js --case-dir case --markdown
node scripts/check_env_realism.js --case-dir case --markdown
node scripts/check_final_artifact.js --case-dir case --markdown
```
如果 `check_code_quality.js` 或 `check_webapi_addon_coverage.js` 失败,必须立即停止后续补环境验证、真实请求验证和交付;先重构代码、拆分 signer/probe 中的 WebAPI 主体、修复中文注释、编码问题、普通 WebAPI 函数、普通对象和宿主透传,再重新运行检查。不得以“当前请求能跑通”“只是诊断脚本”“只是 probe”为理由跳过失败项。
## 常见修复方式
| 问题 | 修复方式 |
|---|---|
| 单个 `env.js` 太大 | 按 navigator、document、storage、fingerprint 拆模块 |
| isolated-vm 生成大段 `String.raw` | 改为真实 `.js` 文件,由 runtime.runFile / runtime.runFiles 读取后注入 Context |
| `script-browser-objects.js` / `script-core.js` 承载主要补环境 | 拆成 `browser-objects/navigator.js`、`document.js`、`window.js`、`fingerprint/canvas.js` 等模块 |
| 没有中文注释 | 增加文件头、对象说明、来源说明和 fallback 原因 |
| 中文注释有问号或乱码 | 用 UTF-8 重新写入,避免默认 shell 重定向 |
| 函数过长 | 拆成解析 fixture、创建构造函数、安装属性、安装实例四步 |
| 普通赋值太多 | 改为统一 helper,例如 `defineValue`、`defineNativeGetter` |
| 普通 WebAPI 函数 | 提取具名 callback,用 `addon.createNativeFunction` 或 addon-first helper 包装 |
| 普通 WebAPI 对象 | 用 `createProtoChains` 建立构造函数、prototype 和实例工厂,再安装 descriptor |
| 泛化 `Illegal constructor` | 先采样浏览器错误类型和 message,再用 `throwBrowserTypeError` / `throwIllegalConstructor` 等 helper 复现 |
| `markObjectType` | 删除该调用;addon 实例不需要二次标记,JS fallback 才用 `markObjectToString` 并记录原因 |
| `prototype = {}` | 改为 `createProtoChains` 或 `Object.defineProperties`,保留 `constructor` 和 `Symbol.toStringTag` |
| 直接复用宿主 Web API | 按浏览器样本和目标调用范围重建可控实现,不盲目透传 Node 宿主对象 |
| `this.__readyState` / `_headers` / `_store` | 改用 addon / xbs private API 或模块级 WeakMap |
| XHR/fetch 返回默认 200 / fixture | 标为 offline-fixture;真实请求时改为 live session bridge |
| Python curl_cffi 最终请求但 Node XHR 自己发请求 | 改为 final.py 持有同一 Session,Node 通过 IPC 请求 Python 发送 |
| target bundle 混入手写代码 | 原始 bundle 放 `src/target/original/`,入口包装放 `src/target/entry.js` |

View File

@@ -0,0 +1,83 @@
# Cookie 生成链路与过期处理
当 Cookie/token 过期、不需要登录的网站请求因 Cookie 失败、目标参数位于 Cookie,或需要分析 `Set-Cookie` / `document.cookie` / JS 计算 / Storage 派生 / challenge 生成链路时读取本文件。
## 总原则
- 先判断 Cookie 是否与登录 / 账号授权相关,再决定处理方式。
- 对登录态 Cookie,不绕过登录、不索要账号密码、不破解验证码或 MFA;让用户手动登录或提供授权样本。
- 对非登录 Cookie,不要默认要求用户重新提供新 Cookie;应分析它如何生成、刷新和写入,并尽量纳入补环境或最终请求入口。
- 最终项目只能用 Node.js / Python 请求客户端发送请求;浏览器自动化只用于前置取证。
## Cookie 分类
| 分类 | 常见特征 | 处理策略 |
|---|---|---|
| 登录态 / 会话 Cookie | 与账号、session、SSO、权限、Authorization 绑定 | 用户手动登录或提供授权样本;不复现登录绕过 |
| 服务端首访 Cookie | 首次访问页面或接口时 `Set-Cookie` 下发 | 在最终 Node.js / Python 请求前增加首访 / challenge 请求,维护 Cookie jar |
| 前端写入 Cookie | 通过 `document.cookie = ...` 写入 | Hook setter 或用 RuyiTrace 查 `document.cookie` 调用栈 |
| JS 计算 Cookie | 混淆 JS、SDK、指纹模块生成 | 按 `source → entry → builder → writer` 搬运原始 JS 并补环境 |
| Storage 派生 Cookie | localStorage / sessionStorage / IndexedDB 参与 | 固化必要存储键,或在入口中先生成存储再生成 Cookie |
| 指纹 / challenge Cookie | 依赖 navigator、canvas、WebGL、时间、随机数、server seed | 结合 RuyiTrace / Hook / Node trace 补齐环境和 seed 传递 |
| 一次性服务端状态 | 与服务端临时状态、账号风控或设备校验强绑定 | 说明不可或不应复现,要求授权交互或离线样本 |
## 分析流程
1. **确认是否需要登录**
- 目标页面 / API 是否无需账号即可访问。
- 失败响应是否明确是未登录、权限不足、账号风控、验证码或 MFA。
- 若需要登录,进入用户手动登录流程,不继续尝试复现登录态 Cookie。
2. **定位 Cookie 来源**
- 检查 HAR / cURL / 响应头中是否存在 `Set-Cookie`。
- Hook `document.cookie` setter,记录写入值、调用栈和写入时机。
- 若使用 ruyiPage + RuyiTrace,优先在 NDJSON 摘要和原始日志中搜索 `document.cookie`、`Document.cookie`、Storage、navigator、canvas、WebGL、crypto、performance 等相关调用。
- 检查 localStorage / sessionStorage / IndexedDB 是否参与派生。
- 检查 Worker、iframe、WASM、postMessage 是否参与生成。
3. **梳理四层链路**
| 层级 | Cookie 场景中要回答的问题 |
|---|---|
| source | Cookie 输入来自 URL、Body、响应 seed、时间、随机数、指纹、Storage 还是已有 Cookie |
| entry | 哪个函数、SDK、模块或 challenge 入口生成 Cookie 值 |
| builder | 哪个请求构造 / Cookie 构造函数拼装 name、value、domain、path、expires 等 |
| writer | 最终由 `Set-Cookie`、`document.cookie`、请求头 `Cookie`、fetch/XHR 拦截器或 Cookie jar 写入 |
只找到 Cookie 值或疑似函数,不代表完成;必须确认 writer。
4. **决定补环境方式**
- `Set-Cookie` 可刷新:最终入口先执行首访 / challenge 请求,保存 Cookie jar,再发目标请求。
- `document.cookie` / JS 计算:将目标 JS 与必要环境补齐到 Node.js,入口运行时生成 Cookie。
- Storage 派生:在 `env.js` 或入口初始化阶段准备必要 Storage 值,并记录来源。
- 指纹 / challenge 依赖:优先从 RuyiTrace 确认环境 API,再用 Node trace 补充。
- 登录态 / 一次性服务端状态:不纳入补环境生成,转为手动登录或离线样本。
## 输出模板
```markdown
## Cookie 过期处理判断
- Cookie 名称:
- 当前失败现象:
- 是否需要登录:
- 是否账号 / 授权相关:
- 来源判断:Set-Cookie / document.cookie / JS 计算 / Storage 派生 / challenge / 未确认
- 关键证据:HAR / cURL / RuyiTrace api / stack.file / Hook 调用栈
- 是否可生成或刷新:
- source:
- entry:
- builder:
- writer:
- 是否纳入补环境:
- 最终入口中的处理方式:生成 Cookie / 刷新 Cookie jar / 用户手动登录 / 仅离线样本
- 是否需要用户补充材料:
```
## 不合格做法
- 不区分登录态与非登录 Cookie,直接要求“重新提供有效 Cookie”。
- 对不需要登录的网站,只把 Cookie 当固定样本复制到最终代码。
- 已有 RuyiTrace NDJSON 时,不查看 `document.cookie` / Storage / 指纹相关日志,直接盲补。
- 最终产物通过浏览器自动化生成 Cookie 后再请求。
- 把真实 Cookie / token 明文写入公开报告或最终交付物。

View File

@@ -0,0 +1,106 @@
# 加密入口定位模型
本文件用于补环境前置阶段:先找到加密参数从“数据源”到“写入请求”的完整链路,再决定是否进入 Node.js 补环境。
## 四层链路模型
定位结果必须尽量拆成四层,不要只记录一个疑似函数名。
| 层级 | 含义 | 常见证据 | 输出要求 |
|---|---|---|---|
| source 数据源 | 参与签名的输入材料 | URL、Query、Body、Cookie、localStorage、时间、随机数、指纹 | 列出字段和值来源 |
| entry 加密入口 | 直接返回 sign/token 或中间签名的函数 | 调用栈、搜索参数名、断点命中、sourcemap | 函数名、模块号、文件、行列号 |
| builder 请求构造 | 把入口结果拼到请求对象的位置 | axios/fetch 封装、SDK request 方法、拦截器 | 参数位置与写入前后的对象快照 |
| writer 请求写入 | 最终写入网络请求的位置 | fetch、XMLHttpRequest.send、setRequestHeader、URLSearchParams、cookie | 最终 API、方法、Header/Query/Body/Cookie |
只有记录到 `writer`,才能确认“找到的函数”确实影响目标请求。只有记录到 `source`,才能解释为什么 Node.js 输出和浏览器样本可能不一致。
## 三类常见架构
1. **直接写入型**:业务代码直接调用入口函数,然后把结果拼接到 Query/Header/Body。
2. **拦截器型**:axios/fetch/XHR 封装中统一补签名;业务调用栈上看不到参数名,需要看拦截器或 request wrapper。
3. **SDK 型**:第三方或站点 SDK 初始化后接管请求,入口函数可能藏在 runtime chunk、动态 chunk、WASM 或 Worker 中。
## 推荐定位顺序
1. 从成功请求样本确认 API,并先列出 Query / Header / Body / Cookie 中所有可疑加密参数;让用户确认本次要分析哪些参数。
2. 在 DevTools Network 查看 Initiator,先记录请求发起文件和调用栈。
3. 对 `fetch`、`XMLHttpRequest.open/send/setRequestHeader` 设置断点或 Hook,捕获 `writer`。
4. 搜索参数名、Header 名、API path、接口封装方法名,寻找 `builder`。
5. 沿调用栈向上找返回值来源,确认 `entry`。
6. 回溯入口入参和读取的环境,整理 `source`。
7. 若源码压缩严重,结合 sourcemap、webpack module id、动态 chunk 名、运行时模块缓存定位。
8. 若存在 Worker、WASM、iframe 或 postMessage,读取 `wasm-worker-postmessage.md` 后单独建链。
## 断点与 Hook 组合
优先使用 Hook 快速缩小范围,再用断点确认源码位置:
- `fetch`:看 URL、init、Header、Body。
- `XMLHttpRequest.open/send/setRequestHeader`:看请求方法、URL、Header、Body。
- `URLSearchParams.append/set`:看 Query 参数写入。
- `FormData.append`:看表单 Body 参数写入。
- `document.cookie` setter:看 Cookie 写入。
- `localStorage/sessionStorage.getItem`:看 source 是否来自存储。
- `Date.now`、`performance.now`、`Math.random`、`crypto.getRandomValues`:看时间随机依赖。
- `Function`、`eval`、`setTimeout(string)`:看动态代码和混淆解包。
Hook 模板见 `hook-templates.md`。Hook 只用于授权调试和证据收集,不修改请求、不批量访问。
## 入口定位记录模板
```markdown
## 加密入口定位记录
### 目标请求
- API:
- 方法:
- 参数:
- 参数位置:
### writer 最终写入
- 类型:fetch / XHR / URLSearchParams / Header / Cookie / Body
- 文件与行列号:
- 调用栈:
- 写入前对象快照:
- 写入后对象快照:
### builder 请求构造
- 函数 / 方法:
- 所在模块:
- 输入:
- 输出:
- 是否经过拦截器:
### entry 加密入口
- 函数名 / 表达式:
- 文件 / chunk / module id:
- 行列号:
- 入参:
- 返回:
- 是否需要初始化:
### source 数据源
- URL / Query:
- Body:
- Cookie:
- localStorage / sessionStorage:
- 时间 / 随机数:
- navigator / screen / document / canvas / WebGL:
- 其他:
### 可信度
- 证据来源:Network / Hook / 断点 / sourcemap / 静态搜索 / 推断
- 是否可进入补环境:是 / 否
- 阻塞点:
```
## 进入补环境的最低条件
- 已列出所有可疑加密参数,并已由用户确认本次要分析的目标参数。
- 已确认目标参数在成功请求中存在。
- 已确认 `writer`,知道参数最终写入哪里。
- 已定位至少一个可调用或可追踪的 `entry`。
- 已收集入口所在 JS 文件及依赖 chunk。
- 已整理 `source` 初表,哪怕部分字段未知也要明确标记。
- 已有至少一组浏览器真实 fixtures;动态参数建议至少三组。

View File

@@ -0,0 +1,586 @@
# 最终规范项目交付
本文件只在 fixtures 多样本通过,并准备最终交付时读取。这里的“一体化”不是指整个项目只能有一个文件,而是指:**最终项目只有一个可直接执行的入口文件;执行该入口后,自动完成生成加密参数、发送 Node.js / Python 模拟请求,并输出请求成功/失败结果**。
## 硬性要求
1. **最终项目必须干净**
最终交付目录不得包含临时文件、测试文件、trace、HAR、hook、指纹采样 Hook、截图、缓存、浏览器 Profile、失败响应、调试日志或空目录。可以保留必要源码模块、配置模板和依赖清单。
2. **最终项目必须是规范目录结构**
允许把补环境、目标入口、请求客户端、配置、工具函数拆成模块,避免把所有代码硬塞进一个超大文件。推荐目录见下文。
3. **最终项目只能有一个执行入口**
默认入口为:
```text
case/result/final.js
```
如果用户明确选择 Python 请求客户端,可以使用:
```text
case/result/final.py
```
入口执行后必须完成:
```text
安装/加载补环境 → 调用目标 JS 入口生成加密参数 → 组装请求 → 用 Node.js / Python TLS 指纹兼容 Session 客户端发送模拟请求 → 输出验证结果 → 销毁 session
```
其他文件只能作为被入口调用的模块,不得再提供 `server.js`、`bridge.py`、`runner.js`、`sign.js`、`test.js` 等第二入口或测试入口。
4. **最终项目不能有自动化操作代码**
最终项目内任何源码文件不得出现:
- ruyiPage / RuyiTrace 启动或控制代码。
- Playwright / Puppeteer / Selenium / CloakBrowser / browser-use。
- CDP / WebDriver / Marionette 控制代码。
- `page.goto`、`browser.launch`、`chromium.launch`、`FirefoxPage`、`page.capture` 等自动化取证调用。
ruyiPage / RuyiTrace / CloakBrowser / Playwright / Puppeteer 只能用于前置取证、环境日志采集和指纹采样;不能进入最终项目代码。Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹应由 Node.js 终端 API 值回放实现,不得通过自动化浏览器实时生成。
5. **最终加密参数必须由补环境生成,禁止复用样本值**
cURL / HAR / fixture 中已有的 sign、token、a_bogus、h5st、x-s、x-t、mtgsig、w_rid 等值只能作为浏览器真实样本和 expected fixture。最终项目不得把这些值硬编码到 `final.js`、请求模块、配置文件或 signer 模块中。入口必须调用补环境后的目标 JS 入口 / signer 重新生成加密参数,再组装请求。
禁止:
```javascript
const sign = 'curl 里复制出来的样本值';
headers['x-s'] = '样本 x-s';
url.searchParams.set('a_bogus', '样本 a_bogus');
```
推荐:
```javascript
const params = await signer.generate(requestInput);
headers['x-s'] = params['x-s'];
url.searchParams.set('a_bogus', params.a_bogus);
```
6. **最终请求必须由前置阶段已确认的 Node.js 或 Python Session 客户端完成**
最终验证流程必须是:
```text
创建 session → 生成加密参数 → 用已确认的 TLS 指纹兼容客户端在同一 session 中组装请求 → 发起少量授权验证请求 → 销毁 session
```
可选客户端为 Node.js CycleTLS / impers / curl-cffi-node,或 Python curl_cffi / cffi_curl / cyCronet;如果用户选择“不发真实请求”,入口只输出本地 sign / 参数和组装后的脱敏请求信息。即使只有一个目标 API,也必须使用 Session 模式,动态资源刷新、Cookie / challenge 生成链路和目标 API 复用同一 Cookie jar / Header / UA / Client Hints / TLS 指纹 / fingerprint baseline。
禁止:
```text
生成加密参数 → 再通过浏览器自动化打开页面 / 点击 / 抓包验证
```
7. **项目完成后默认生成最终总结**
最终交付前必须生成:
```text
case/result/最终项目总结.md
```
总结必须读取 `references/final-project-summary.md` 的模板要求,使用 `scripts/write_markdown_utf8.js` 以 UTF-8 写入,并包含 native addon / NativeProtect 使用情况、指纹基线一致性、最终请求 Session 请求链、加密参数生成与样本复用检查、最终交付结构、测试结果和清理结果。只有用户明确要求“不生成最终总结”时才可跳过;跳过时要在阶段输出中记录原因,并在 `check_final_artifact.js` 中显式使用 `--no-require-final-summary`。
8. **最终代码必须简洁可读并带中文注释**
最终补环境代码必须按职责拆分模块,禁止压缩、堆叠、过长函数、过深嵌套和无意义命名。所有手写源码必须有文件头中文职责注释,关键 WebAPI、getter / setter、addon-first、fallback、指纹回放和加密入口必须有中文说明。中文注释必须 UTF-8 正常显示,不得包含问号、连续问号或乱码。原始目标 bundle 如必须保留,应放在 `src/target/original/` 等独立目录,不得和手写补环境代码混在一起。
9. **动态 HTML / JS 必须运行时刷新**
如果 `case/notes/resource-manifest.json` 中存在 `dynamic: true` 且 `requiredForFinal: true` 的资源,最终项目必须包含运行时刷新模块,例如 `src/resources/fetch-runtime-resources.js`。`final.js` / `final.py` 执行顺序必须是:创建请求 session → 刷新当前 HTML / JS / challenge / seed → 更新同一 session 的 Cookie / Storage / runtime context → 加载当前资源运行 signer → 使用已确认 TLS 指纹兼容客户端在同一 session 发送最终请求 → 销毁 session。
不得把 `case/js/snapshots/`、403 / challenge 页面、动态 HTML、动态 chunk 或旧 seed 固定复制到 `result/` 作为 signer 主路径;这些快照只能用于分析、fixture 对比和历史证据。交付前必须运行:
```bash
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
```
10. **验证码接口的轨迹入口必须可替换**
如果目标是验证码 / 风控验证接口,并且加密参数依赖点击、鼠标移动、拖动或触摸事件,最终项目允许保留旧轨迹 fixture 作为参数生成输入,但必须暴露可替换入口,例如 `motionTrack`、`eventFixture`、`verifyContext`、`clickPoints` 或 `dragPath`,并使用 UTF-8 中文注释说明“当前旧轨迹只用于补环境生成加密参数,不保证最终验证通过,后续识别和真实轨迹生成交给 web-verify-patcher”。
不得把旧轨迹硬编码在 signer 内部,不得把验证码最终通过率写成 web-js-env-patcher 的交付结论。调用 `web-verify-patcher` 前必须先运行 `check_web_verify_patcher.js --require`。
11. **必须交付前检查**
交付前运行:
```bash
node scripts/write_markdown_utf8.js --input case/tmp/最终项目总结草稿.md --out case/result/最终项目总结.md --require-chinese-name --markdown
node scripts/check_final_artifact.js --case-dir case --markdown
node scripts/clean_case.js --case-dir case --dry-run --json
```
若检查失败,先修复和清理,再交付。
## 推荐 Node.js 最终目录
```text
case/result/
├── final.js # 唯一执行入口:node final.js
├── 最终项目总结.md # 项目完成后默认生成的 UTF-8 中文命名最终总结
├── package.json # 可选,仅列运行依赖和 start 脚本
├── config.example.json # 可选,脱敏配置模板;真实敏感值由用户本地填写
└── src/
├── env/
│ ├── install-env.js # 安装补环境
│ ├── native-protect.js
│ └── fingerprint-env.js # 可选:指纹终端 API 值回放
├── target/
│ ├── target.js # 目标 JS 片段或入口包装
│ └── entry.js # 暴露 makeEncryptedParams
├── request/
│ └── client.js # Node.js CycleTLS / impers 请求,或用户确认不发真实请求
├── resources/
│ └── fetch-runtime-resources.js # 可选:动态 HTML / JS / challenge 运行时刷新
└── utils/
└── normalize.js
```
要求:
- `final.js` 是唯一可以直接运行的入口。
- `package.json` 的 `scripts.start` 只能指向 `node final.js`。
- `src/` 中模块不能直接启动浏览器、启动服务或发起额外批量请求。
- 不交付 `test/`、`tests/`、`__tests__/`、`tmp/`、`logs/`、`hooks/`、`screenshots/`、`ruyi-trace/`、`browser-profile/`。
## 推荐 Python 最终目录
仅当用户明确选择 Python 请求客户端时使用:
```text
case/result/
├── final.py # 唯一执行入口:python final.py
├── 最终项目总结.md # 项目完成后默认生成的 UTF-8 中文命名最终总结
├── requirements.txt # 可选,仅列运行依赖
├── config.example.json # 可选,脱敏配置模板
└── src/
├── request_client.py # curl_cffi / cffi_curl / cyCronet,或用户确认不发真实请求
├── signer.py # 参数生成入口;仅在逻辑已可靠迁移时使用
└── normalize.py
```
如果目标 JS 必须在 Node.js 补环境里执行,优先交付 Node.js 项目,不要用 Python 调浏览器自动化来完成签名。
## `final.js` 入口职责
`final.js` 不需要包含全部源码,但必须串联完整流程:
```javascript
// 唯一入口:生成加密参数并使用已确认的请求客户端验证结果
'use strict';
let fetchRuntimeResources = null;
try {
({ fetchRuntimeResources } = require('./src/resources/fetch-runtime-resources'));
} catch (_) {
// 无动态资源时可以不提供刷新模块;存在动态资源时必须提供并通过检查。
}
const { makeEncryptedParams } = require('./src/target/entry');
const { createRequestSession } = require('./src/request/client');
// 请求配置来自脱敏后的浏览器成功样本,敏感值由用户本地补充
const CONFIG = {
api: 'https://example.com/api',
method: 'GET',
headers: {
'user-agent': '<从样本脱敏迁移>',
},
query: {},
body: null,
};
async function main() {
const session = await createRequestSession(CONFIG);
try {
// 如果存在动态 HTML / JS / challenge,必须在同一 session 中刷新当前有效资源,旧快照不能作为最终主输入。
const runtimeResources = typeof fetchRuntimeResources === 'function'
? await fetchRuntimeResources(CONFIG, session)
: null;
// 加密参数必须由补环境后的目标入口动态生成,不复用 cURL 样本值。
const params = await makeEncryptedParams({ request: CONFIG, runtimeResources, session });
const response = await session.request({ config: CONFIG, params });
const ok = response.status >= 200 && response.status < 300;
console.log(JSON.stringify({ ok, params, response }, null, 2));
if (!ok) process.exitCode = 2;
} finally {
// 中文说明:无论请求成功还是失败,都销毁 session,清理 Cookie jar 和敏感运行态。
await session.close();
}
}
if (require.main === module) {
main().catch(err => {
console.error(err);
process.exit(1);
});
}
```
`src/request/client.js` 应使用前置阶段已确认的 Node.js CycleTLS / impers / curl-cffi-node 创建 Session 客户端,导出 `createRequestSession()`,并维护 Cookie jar、Header 状态和 `close()`;或在用户明确选择“不发真实请求”时只输出本地参数和脱敏请求。不要临时退回普通 `fetch` / `https.request` 发真实请求,也不要调用任何浏览器自动化。
## `final.py` 入口职责
```python
# 唯一入口:生成加密参数并使用已确认的请求客户端验证结果
from src.signer import make_encrypted_params
from src.request_client import create_request_session, close_request_session
# 请求配置来自脱敏后的浏览器成功样本,敏感值由用户本地补充
CONFIG = {
"api": "https://example.com/api",
"method": "GET",
"headers": {
"user-agent": "<从样本脱敏迁移>",
},
"query": {},
"body": None,
}
def main():
session = create_request_session(CONFIG)
try:
# 加密参数必须由补环境后的目标入口动态生成,不复用 cURL 样本值
params = make_encrypted_params({"request": CONFIG, "session": session})
response = session.request(CONFIG, params)
ok = 200 <= response["status"] < 300
print({"ok": ok, "params": params, "response": response})
if not ok:
raise SystemExit(2)
finally:
# 中文说明:请求结束后销毁 Session,清理 Cookie jar 和敏感运行态。
close_request_session(session)
if __name__ == "__main__":
main()
```
不得在 `final.py` 或 `src/` 中引入 Selenium、Playwright、pyppeteer、ruyiPage 或其他浏览器自动化。
## 交付前检查清单
- [ ] `case/result/` 是规范项目目录,而不是临时文件堆。
- [ ] 只有一个执行入口:`final.js` 或 `final.py`。
- [ ] 执行入口可直接运行,并会生成加密参数、使用 Session 发送模拟请求、输出请求结果并销毁 session。
- [ ] 模块拆分合理,必要源码位于 `src/`。
- [ ] 补环境代码已运行 `check_code_quality.js`,中文注释 UTF-8 正常、无问号、无连续问号、无乱码。
- [ ] 项目内任何源码都不包含 ruyiPage / RuyiTrace / Playwright / Puppeteer / Selenium / CloakBrowser / CDP / WebDriver 自动化代码。
- [ ] 如涉及 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹,最终项目使用真实浏览器采样 fixture + 终端 API 值回放,不依赖 node-canvas / headless-gl / 自动化浏览器。
- [ ] 最终请求由前置阶段已确认的 Node.js / Python TLS 指纹兼容 Session 客户端发起,或用户明确选择不发真实请求;同一请求链复用 session,结束后销毁,不通过浏览器自动化验证。
- [ ] fixtures 已通过;动态参数建议三组以上。
- [ ] 如存在动态 HTML / JS / challenge,已生成 `case/notes/resource-manifest.json`,并运行 `check_dynamic_resources.js --require-runtime-refresh` 通过。
- [ ] 动态快照未复制进 `result/`;最终入口会运行时刷新当前资源。
- [ ] Cookie、token、Authorization、localStorage 等敏感值已脱敏或仅由用户本地配置,不明文写入报告。
- [ ] 临时 trace、hook、日志、HAR、截图、Profile、缓存和测试文件已清理。
- [ ] 已运行 `check_code_quality.js`、`check_env_realism.js`(addon-first 默认强制)、涉及指纹时运行 `check_fingerprint_fixture.js`,已生成 UTF-8 `result/最终项目总结.md`,并运行 `check_final_artifact.js` 和 `clean_case.js --dry-run`。
## 输出模板
```markdown
## 最终交付
- 项目目录:
- 唯一执行入口:
- 运行方式:
- 入口能力:生成加密参数 + 使用 Node.js / Python Session 模拟请求 + 输出请求结果 + 销毁 session
- 请求实现:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / curl-cffi-node / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- 是否包含浏览器自动化代码:否
- 是否包含多余测试/临时文件:否
- fixtures 验证:通过 / 未执行,原因
- 动态资源保鲜检查:无动态资源 / 已在同一 session 运行时刷新 / 未通过,原因
- Session 模式:已创建并复用 / 不发真实请求;销毁方式:close / exit / dispose / Cookie jar 清理
- resource manifest:`case/notes/resource-manifest.json` / 未涉及
- 运行时刷新模块:`result/src/resources/fetch-runtime-resources.js` / 未涉及
- 动态快照是否进入 result:否
- 最终真实请求验证:已执行 / 未执行,原因
- 敏感材料处理:
- 清理检查:
- 最终总结:已生成 `result/最终项目总结.md` / 用户明确要求不生成
```
## 补环境真实性交付检查
交付前执行:
```bash
node scripts/check_env_realism.js --case-dir case --markdown
node scripts/check_env_realism.js --case-dir case --require-document-all --require-ruyitrace --require-fingerprint-fixture --markdown
node scripts/check_fingerprint_fixture.js --case-dir case --require canvas,webgl --markdown
```
要求最终环境代码体现:属性描述符、访问器、原型链、构造函数、函数 toString 保护、访问器 toString 保护、实例对象 toString 保护;创建函数、构造函数、getter、setter、`document.all`、addon 支持的原型链时从补环境初始化阶段就必须 addon-first,addon 不可用或用户明确豁免才 `NativeProtect` fallback 并记录原因;涉及浏览器指纹时必须保留真实采样 fixture 并做终端 API 值回放;使用 RuyiTrace 时必须保留 `notes/ruyitrace-summary.md` 与 `notes/missing-env-priority.md` 证据摘要。

View File

@@ -0,0 +1,125 @@
# 动态 HTML / JS 资源保鲜与运行时刷新
当目标站点的 HTML、JS bundle、动态 chunk、challenge JS、403/风控页面或内联脚本可能随时间、会话、Cookie、seed、nonce、地域、TLS 指纹或点击动作变化时读取本文件。目标是防止把已经过期的本地快照当作最终补环境输入。
## 核心规则
- 下载到本地的动态 HTML / JS 默认只是 **分析快照**,不能直接作为最终产物的固定依赖。
- 最终入口运行时必须先刷新当前有效资源,再加载当前资源生成加密参数并发送请求。
- 如果资源会过期,最终项目必须包含资源刷新模块,例如 `src/resources/fetch-runtime-resources.js` 或等价封装。
- cURL / HAR / fixture 中的旧 HTML、旧 JS、旧 seed、旧 token、旧 Cookie 只能作为证据和 expected,不得硬编码到最终 signer。
- 请求失败或参数不一致时,先检查资源是否过期、JS hash 是否变化、challenge seed 是否变化,再继续补 WebAPI。
## 动态性判定
任一条件命中时,将资源标记为动态或高风险:
| 证据 | 说明 |
|---|---|
| `Cache-Control: no-store/no-cache/private/max-age=0` | 不应长期复用 |
| `Expires` 已过期或 TTL 很短 | 需要运行时刷新 |
| URL 含 `t/ts/timestamp/nonce/rand/random/_` 等 query | 可能每次变化 |
| 同 URL 多次请求 body hash 不一致 | 不能固定本地文件 |
| HTML 内联 `seed/nonce/config/challenge/token` 每次变化 | 必须刷新入口页 |
| 403 / challenge 页面返回 JS | 通常与当前会话绑定 |
| 响应包含 `Set-Cookie` | 可能刷新设备 Cookie / 风控 Cookie |
| JS URL 从当前 HTML 动态拼接 | 需要最终入口重新解析 HTML |
| JS 内容依赖当前 Cookie / Storage / 地域 / TLS | 需要和请求客户端链路绑定 |
## 资源清单
每个已保存资源都记录到 `case/notes/resource-manifest.json`:
```json
{
"resources": [
{
"url": "https://example.com/challenge.js?ts=...",
"type": "js",
"file": "case/js/snapshots/challenge-001.js",
"capturedAt": "2026-06-23T00:00:00.000Z",
"status": 200,
"headers": {
"cache-control": "no-store",
"set-cookie": "脱敏记录"
},
"sha256": "body hash",
"dynamic": true,
"use": "analysis-snapshot",
"requiredForFinal": true,
"runtimeRefresh": true,
"refreshEntry": "result/src/resources/fetch-runtime-resources.js",
"dependsOn": ["Cookie", "HTML seed", "TLS client"]
}
]
}
```
字段要求:
- `dynamic: true`:明确动态资源。
- `use: "analysis-snapshot"`:只用于分析,不进入最终主路径。
- `use: "runtime-refresh"` 或 `runtimeRefresh: true`:最终运行前刷新。
- `requiredForFinal: true`:最终生成参数需要它;此时必须提供运行时刷新方案。
- `refreshEntry`:最终项目中负责重新获取该资源的模块。
## 目录约定
```text
case/
├── js/
│ ├── snapshots/ # 动态资源快照,只用于分析
│ ├── static/ # 已确认可长期复用的静态 bundle
│ └── extracted/
├── notes/
│ └── resource-manifest.json
└── result/
├── final.js
└── src/
├── resources/ # 运行时刷新 HTML / JS / challenge
├── env/
├── signer/
└── request/
```
动态资源可以保存在 `case/js/snapshots/`,但不得原样复制到 `result/` 作为 signer 主输入。
## 最终入口流程
涉及动态 HTML / JS 时,最终入口应按以下顺序执行:
1. 使用用户已确认的 TLS 指纹兼容客户端请求入口页或 challenge 页。
2. 解析最新 HTML,提取当前 seed、nonce、config、动态 JS URL、Set-Cookie。
3. 按当前页面状态请求最新 JS / chunk / challenge 资源。
4. 验证资源 hash、状态码和关键字段,并更新运行时资源上下文。
5. 将当前 JS / seed / Cookie / Storage 注入补环境运行链路。
6. 生成目标加密参数。
7. 用同一会话 / Cookie / TLS 客户端发送最终请求。
不得在最终入口中只执行“加载旧本地 JS 快照 → 生成参数 → 请求”。
## 验证
进入最终交付前运行:
```bash
node scripts/check_dynamic_resources.js --case-dir case --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
```
检查失败时先修复资源刷新链路,再继续补环境或最终请求。
## 输出模板
```markdown
## 动态资源保鲜检查
- 是否存在动态 HTML / JS:
- 动态资源清单:
- 是否只作为分析快照:
- 是否影响最终参数生成:
- 最终入口是否运行时刷新:
- 刷新模块:
- 当前资源 hash 是否与 fixture 一致:
- 失败时是否先排查资源过期:
```

View File

@@ -0,0 +1,351 @@
# Node.js 补环境调试循环
当前置阶段已经完成,并且用户确认进入补环境阶段时读取本文件。
## 目标
补环境阶段不是为了让目标 JS “勉强不报错”,而是为了让目标网页原始 JS 在 Node.js 中生成与浏览器样本一致的结果。
核心原则:
```text
先记录,后补齐;先最小,后扩展;真实性保护和 addon-first 从第一版 env 骨架开始;先样本验证,后交付。
```
对 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何等浏览器指纹,额外遵循:
```text
先采真实浏览器终端 API 返回值,再在 Node.js 中回放;不要强行复刻渲染管线,不要把最终流程改成自动化浏览器。
```
真实性与 addon-first 额外遵循:
```text
先加载 / 记录 addon,再编写 env;toString、属性描述符、访问器、原型链和实例对象保护是默认基线,不等待检测触发。
```
如果本 case 的取证模式是 ruyiPage + RuyiTrace,或用户明确说“已经 trace 好日志 / 已提供 NDJSON”,补环境阶段再增加一条硬规则:
```text
先看 RuyiTrace NDJSON,再跑 Node trace;先用浏览器内核日志定位环境依赖,再用 Node.js 复现和固化。
```
## 进入条件
进入本阶段前必须确认:
- 目标属于网页端 JS。
- 已有目标 API、请求方法、加密参数名和参数位置。
- 至少有一份成功请求样本,最好有多组样本。
- 已经定位或初步定位加密入口。
- 相关 JS 文件已经保存到本地,或确认可以获取。
- 已经整理 `source → entry → builder → writer` 四层链路,至少确认 writer。
- 如用户选择 ruyiPage + RuyiTrace,已导入 NDJSON 日志并生成 `notes/ruyitrace-summary.md`;如果尚未导入,应先暂停要求用户提供或采集日志,除非用户明确确认无法提供。
- 已经检查 Node 泄露阻断,不把 `process/Buffer/require/module/global` 暴露给目标 JS。
- 已经在补环境初始化阶段加载 / 检测 addon,并决定 addon-first 或记录用户明确豁免 / addon 不可用降级原因。
- 用户未明确要求关闭真实性保护;默认会对新增 WebAPI 执行属性描述符、访问器、原型链、函数 / 访问器 / 实例对象 toString 保护。
- 已经执行六项纯计算预检,或明确该目标不依赖相关差异。
- 如果目标访问 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何等指纹 API,已经采集或计划采集真实浏览器终端 API 返回值;缺少样本时不得进入“静默伪造默认值”的交付模式。
- 如果目标是验证码接口,已确认事件轨迹 fixture 或旧轨迹样本来源,并明确该轨迹只用于补环境生成加密参数,不代表最终验证码验证成功。
- 用户已确认进入 Node.js 补环境阶段。
如果缺少任一关键条件,先回到 `workflow.md` 的前置流程,不要直接写 `env.js`。
## 验证码事件轨迹 fixture 门禁
验证码接口补环境时,如果加密参数依赖鼠标、点击、拖动、触摸、滚轮或键盘事件,先使用浏览器取证得到的旧轨迹 fixture 跑通参数生成。该阶段目标是让目标 JS 在 Node.js 中生成加密参数,不要求验证码最终验证通过。
硬性要求:
- 将轨迹设计为可替换入口,例如 `motionTrack`、`eventFixture`、`verifyContext`、`clickPoints`、`dragPath`。
- 推荐保存到 `case/fixtures/motion.fixture.json` 或 `case/result/src/verify/motion-track.js`。
- 代码中必须有 UTF-8 中文注释,说明旧轨迹来源、用途和后续由 `web-verify-patcher` 替换。
- 不得把旧轨迹硬编码成不可替换逻辑,不得宣称旧轨迹能稳定通过验证码。
- 生成验证码加密参数后,如需识别图片、生成真实轨迹、坐标换算或提交验证,先运行 `scripts/check_web_verify_patcher.js --require --markdown`,确认 `web-verify-patcher` 可用后再交接。
## RuyiTrace 优先诊断门禁
当取证模式为 ruyiPage + RuyiTrace 时,进入任何 `env.js` 编写、缺失对象补齐或环境问题排查前,先执行以下门禁:
1. 确认 `case/ruyi-trace/logs/*.ndjson` 或用户指定 NDJSON 存在。
2. 如果尚未导入,先执行:
```bash
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --markdown
```
3. 先阅读 `case/notes/ruyitrace-summary.md`,再按需过滤原始 NDJSON。
4. 针对当前错误或不一致,优先搜索:
- 缺失对象名:如 `navigator`、`document`、`screen`、`localStorage`。
- 缺失方法名:如 `getItem`、`getContext`、`getParameter`、`toDataURL`。
- 目标 JS 文件名、入口函数名、writer 附近调用栈。
- 与目标请求发起时间邻近的 `api` 调用。
5. 把命中证据写入 `notes/missing-env-priority.md`,至少包含 `api`、`stack.file`、`line`、`col`、所属环境模块、补齐优先级和“RuyiTrace 证据 / Node trace 补充 / 推断”分类,再决定 Level 1/2/3 补齐顺序。
6. 只有 NDJSON 缺失、未覆盖该逻辑或结论不足时,才把 `run_with_trace.js` / Proxy trace 作为主要发现来源。
不要在已有可用 RuyiTrace 日志时,直接根据 Node.js 报错盲目补环境。
## 推荐 case 目录
```text
case/
├── js/
│ ├── original/
│ ├── pretty/
│ └── extracted/
├── requests/
├── fixtures/
├── notes/
├── hooks/
├── env/
├── ruyi-trace/
│ └── logs/
├── browser/
│ └── ruyipage/
├── result/
└── tmp/
```
使用脚本创建:
```bash
node scripts/init_env_case.js --case-dir case --target app.js --entry window.makeSign --param sign --api https://example.com/api/search
```
目录含义:
| 目录 | 用途 |
|---|---|
| `js/original/` | 原始 JS、chunk、runtime、sourcemap |
| `js/pretty/` | 格式化后的 JS |
| `js/extracted/` | 抽取出的入口模块或片段 |
| `requests/` | 脱敏后的 cURL、HAR、请求说明 |
| `fixtures/` | 浏览器真实样本和期望输出 |
| `notes/` | 分析笔记、入口定位、环境依赖说明 |
| `hooks/` | 浏览器 Hook 模板和临时断点脚本;调用栈确认并写入 notes 后立即清理或归档 |
| `env/` | 分层 env 模块草稿 |
| `ruyi-trace/logs/` | 用户确认导入的 RuyiTrace NDJSON 原始日志 |
| `browser/ruyipage/` | ruyiPage 取证配置、非敏感脚本和用户确认保留的材料 |
| `result/` | 最终规范项目目录;唯一执行入口为 `final.js` 或 `final.py`,必要模块可放入 `src/`;不要放临时 runner、测试脚本或浏览器自动化代码 |
| `tmp/` | trace、临时 runner、日志、失败产物;每次测试或阶段完成后立即清理 |
## 双模式策略
### 探测模式
探测模式用于发现目标 JS 实际访问了哪些浏览器环境。
允许使用:
- JS `Proxy`。
- getter / setter hook。
- 函数调用记录。
- 构造调用记录。
- trace 文件。
运行示例:
```bash
node scripts/run_with_trace.js \
--target case/js/original/app.js \
--entry window.makeSign \
--fixture case/fixtures/sample.fixture.json \
--trace case/tmp/env-trace.jsonl \
--summary case/tmp/missing-env.json
```
探测模式允许对象“不完全像浏览器”,但必须明确标记其用途只是调试,不要把全量 Proxy 作为最终交付。
### 交付模式
交付模式用于最终稳定运行。
要求:
- 从第一版可交付 env 开始就启用真实性保护,不等检测命中后再补。
- 进入本模式前已经加载 / 记录 addon;addon 可用时所有 native-like 函数、getter、setter、特殊对象和原型链 helper 都必须 addon-first。
- 尽量不用全局 Proxy。
- 将 trace 中发现的访问路径固化为真实对象结构。
- 显式定义属性描述符。
- 建立必要原型链。
- 对构造函数、普通方法、getter、setter 做 native-like `toString` 保护;访问器函数也必须保护。
- 对实例对象设置正确 `Object.prototype.toString` / `Symbol.toStringTag`,并对 `document.all` 等特殊对象优先使用可选 native addon。
- 不输出调试 trace。
- 用 fixtures 验证输出。
## 分层补齐策略
补齐顺序固定为:
1. **Level 1 基础运行层**:window/self/globalThis、location、URLSearchParams、Storage、Date、crypto、console、定时器。
2. **Level 2 指纹与真实性层**:navigator、screen、document、plugins/mimeTypes、Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何终端 API 值回放、属性描述符、原型链、native-like toString。
3. **Level 3 目标 SDK 专用层**:SDK init、动态 chunk、Worker、WASM、postMessage、站点私有缓存。
模块选择细节见 `env-module-levels.md`。输出不一致时先读取 `node-leakage-and-silent-failure.md`,不要盲目继续补对象。
## 运行顺序
```text
1. 创建 case 目录和 fixtures。
2. 如果取证模式为 ruyiPage + RuyiTrace,先导入 / 阅读 NDJSON 摘要,形成环境依赖优先级。
3. 执行 Node 泄露阻断检查和六项纯计算预检。
4. 运行 native addon 优先门禁:加载 / 检测随包 addon;可用时后续 native-like 函数、访问器、`document.all`、原型链等先走 addon,失败时记录降级状态。
5. 用最小浏览器环境运行目标 JS。
6. 捕获 ReferenceError / TypeError / 输出不一致。
7. 如已选择 RuyiTrace,先用 NDJSON 证据解释缺失路径;解释不足时再用 Proxy 记录实际访问路径。
8. 生成缺失环境报告,并用 `analyze_trace.js` 输出模块优先级。
9. 如果 trace 或 NDJSON 显示 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹,先读取 `fingerprint-value-replay.md`,生成采样 Hook,采集 `fixtures/fingerprint.fixture.json`,再接入 `assets/env-modules/fingerprint-env.js` 做终端 API 值回放。
10. 按 RuyiTrace 证据 + Node trace 补充结论,分 Level 1/2/3 补齐环境。
11. 发现 Proxy、toString、descriptor、accessor、prototype、instanceof、constructor.name 或 `document.all` 检测信号时,迁移到真实对象 / addon-first 模式;addon 不可用时才使用 NativeProtect fallback。
12. 如果是验证码接口,先注入可替换的 `motionTrack` / `eventFixture` 旧轨迹 fixture,仅用于生成验证码接口加密参数。
13. 调用加密入口。
14. 和浏览器样本对比;如果缺少指纹样本,补采样而不是改用自动化浏览器。
15. 多样本通过后整理规范项目目录,把补环境、入口调用、参数生成和最终 Node.js / Python 请求逻辑串联到唯一入口 `result/final.js` 或 `result/final.py`;必要模块可放入 `result/src/`。
16. 先运行 `check_env_realism.js --require-addon-first`,确认原型链、属性描述符、访问器、toString 保护、实例对象 toString 保护、addon-first/native fallback 记录、document.all、RuyiTrace 证据和指纹值回放证据;再运行 `check_fingerprint_fixture.js` 与 `check_final_artifact.js`,确认最终主文件不含 ruyiPage / Playwright / Puppeteer / Selenium / CloakBrowser 等自动化代码。
17. 清理 trace、临时日志、失败 runner、测试脚本、指纹采样 Hook 和多余文件。
```
## 错误分类
### ReferenceError
示例:
```text
ReferenceError: XMLHttpRequest is not defined
```
记录为:
```json
{
"type": "missing-global",
"name": "XMLHttpRequest"
}
```
处理:如果选择 ruyiPage + RuyiTrace,先在 NDJSON 中确认浏览器真实调用栈、构造方式和相关属性,再补全 `globalThis.XMLHttpRequest`,必要时建立构造函数和原型。
### TypeError:不是函数
示例:
```text
TypeError: localStorage.getItem is not a function
```
记录为:
```json
{
"type": "missing-method",
"path": "localStorage.getItem"
}
```
处理:如果选择 ruyiPage + RuyiTrace,先在 NDJSON 中确认该方法真实是否被调用、调用参数和调用栈,再补全对应方法。若 addon 可用,优先创建 native-like 函数。
### TypeError:读取 undefined 属性
示例:
```text
Cannot read properties of undefined (reading 'userAgent')
```
处理:优先结合 RuyiTrace NDJSON 判断浏览器真实路径;NDJSON 不足时再结合 Node trace,补齐缺失对象或属性。
### 输出不一致
如果不再报错但签名不一致,且已选择 ruyiPage + RuyiTrace,先回看目标请求前后的 NDJSON 调用,再检查:
- 请求 URL、Query、Body 是否完全一致。
- `Date.now`、`performance.now`。
- `Math.random`、`crypto.getRandomValues`。
- `navigator.userAgent`、语言、时区、屏幕信息。
- `document.cookie`、`localStorage`、`sessionStorage`。
- `location.href`、`origin`、`pathname`。
- 是否启用 native-like `toString`。
- `document.all` 是否走了 native addon 路径。
- `Object.getOwnPropertyDescriptor` 返回的 descriptor 是否与真实浏览器一致。
- getter / setter / 构造函数 / 原型方法的 `Function.prototype.toString.call(...)` 是否为 native-like。
- 实例对象的 `Object.prototype.toString.call(...)`、`constructor.name`、`instanceof` 是否一致。
如果 NDJSON 中没有覆盖目标参数生成时间段,明确标记“RuyiTrace 未覆盖”,再使用 Node trace / Hook / 断点补充。
## trace 输出
推荐临时文件:
```text
case/tmp/
├── env-trace.jsonl
├── missing-env.json
├── runtime-error.log
└── env-access-summary.md
```
`env-trace.jsonl` 示例:
```json
{"type":"get","path":"navigator.userAgent"}
{"type":"get","path":"document.cookie"}
{"type":"call","path":"localStorage.getItem","args":["string"]}
```
`missing-env.json` 示例:
```json
{
"missingGlobals": ["XMLHttpRequest"],
"missingMethods": ["localStorage.getItem"],
"missingProperties": ["navigator.userAgent", "document.cookie"],
"specialObjects": ["document.all"],
"nativeAddon": {
"available": true,
"usedApis": ["createUndetectable", "createNativeFunction", "createGetter"]
},
"proxyRiskSignals": ["Object.getOwnPropertyDescriptor", "Function.prototype.toString"],
"runtimeErrors": []
}
```
## 输出要求
阶段输出应包含:
```markdown
## Node.js 补环境运行结果
- 目标 JS:
- 入口函数:
- fixtures:
- native addon:可用 / 不可用 / 用户明确豁免;必须说明是否在补环境初始化阶段已加载 / 记录
- 真实性检查:已运行 `check_env_realism.js`(默认 addon-first)/ 未运行(需说明原因)
- RuyiTrace NDJSON:已优先分析 / 未提供 / 未覆盖当前问题
- 运行状态:成功 / 失败
## 缺失环境摘要
- RuyiTrace 证据:api、stack.file、line、col、相关时间窗口
- 缺失全局对象:
- 缺失方法:
- 缺失属性:
- 特殊对象:
- Proxy 检测风险:
## 下一轮补齐计划
1. ...
2. ...
## 清理检查
- [ ] 已清理临时 trace。
- [ ] 已清理失败 runner。
- [ ] 已把关键结论写入 notes。
- [ ] 已生成规范项目目录,且唯一执行入口为 `result/final.js` 或 `result/final.py`。
- [ ] 已确认最终项目所有源码不包含浏览器自动化代码。
- [ ] 已确认最终请求由 Node.js / Python HTTP 客户端实现。
- [ ] 已生成 `result/最终项目总结.md`,除非用户明确要求不生成。
```

View File

@@ -0,0 +1,72 @@
# 补环境分层与模块资产
本文件用于把补环境从“一坨 env.js”拆成可验证、可替换、可复用的模块。
## Level 1 / 2 / 3 分层
| 层级 | 目标 | 常见模块 | 进入条件 | 交付要求 |
|---|---|---|---|---|
| Level 1 基础运行层 | 让目标 JS 在隔离 Node.js 运行上下文中安全加载并可调用入口 | window/self/globalThis、location、URLSearchParams、atob/btoa、TextEncoder、Date、console、setTimeout、Storage | 已有入口和 fixtures | 不暴露 Node;能记录缺失环境 |
| Level 2 指纹与对象真实性层 | 让环境更接近浏览器并通过常见检测 | navigator、screen、document、plugins、mimeTypes、crypto、performance、Canvas/WebGL/WebGPU/Audio/字体/DOM 几何值回放、属性描述符、原型链、toString | Level 1 能运行但输出不一致或出现检测 | 有 descriptor/accessor/prototype/native-like/Object.prototype.toString 记录;指纹终端 API 有真实采样值 |
| Level 3 目标 SDK 专用层 | 针对具体站点 SDK 的初始化、缓存、消息和异步行为 | SDK init、动态 chunk、Worker、WASM、postMessage、IndexedDB 摘要、请求封装 | 已确认目标 SDK 依赖 | 写入 notes,避免污染通用 env |
## 模块选择矩阵
| trace / 错误信号 | 优先模块 | 备注 |
|---|---|---|
| `ReferenceError: window` / `self` | base-env | 先建立全局别名 |
| `location.href/origin/pathname` | location-env | 由 fixture.pageUrl/apiUrl 生成 |
| `navigator.userAgent/language/platform` | navigator-env | 使用浏览器真实样本 |
| `document.cookie` | document-cookie-env | 敏感值脱敏记录 |
| `localStorage.getItem` | storage-env | 从 fixture 注入 key/value |
| `crypto.getRandomValues` | crypto-env | 需要固定随机源 |
| `performance.now` | time-env | 与 Date.now 联动 |
| `canvas/WebGL/WebGPU/Audio/字体/DOM 几何` | fingerprint-env | 回放真实浏览器终端 API 返回值,不在 Node.js 中真实模拟渲染 |
| `Object.getOwnPropertyDescriptor` | descriptor-env / native-protect | 检查 enumerable/configurable/writable,区分 data descriptor 与 accessor descriptor |
| `Function.prototype.toString` | native-protect / native-addon | 普通函数、构造函数、getter、setter 都要保护 |
| `document.all` | native-addon | JS 难以完全模拟,优先 addon |
| `Worker/postMessage/WASM` | worker-wasm-env | 单独建消息链 |
## 推荐目录
```text
case/
├── env/
│ ├── base-env.js
│ ├── navigator-env.js
│ ├── document-env.js
│ ├── storage-env.js
│ ├── crypto-time-env.js
│ ├── native-protect.js
│ └── target-sdk-env.js
├── result/
│ ├── final.js # 唯一执行入口;也可在用户选择 Python 请求客户端时为 final.py
│ └── src/ # 最终项目模块:env、signer、request、utils 等
└── notes/
├── entry-chain.md
├── missing-env-priority.md
└── silent-failure-checklist.md
```
Skill 自带模板位于 `assets/env-modules/`,使用前应复制到 case 中按目标调整,不要直接改 Skill 内模板。当前模板包括 `native-protect.js`、`base-env.js`、`storage-env.js`、`document-env.js`、`fingerprint-env.js`。
## 固化规则
- 探测阶段可以用 Proxy;交付阶段逐步替换为真实对象。
- 每补一个模块,只解决 trace 中实际出现的路径,避免大而全。
- 每轮补齐后跑 fixtures,对比目标参数,不以“不报错”为完成。
- 通用模块和目标 SDK 专用模块分开,避免把站点私有逻辑写进通用 env。
- 敏感 Cookie、token、localStorage 值只放在本地 fixture,不写入总结原文。
- 最终交付前运行 `check_env_realism.js`,确认原型链、属性描述符、访问器、函数 / 访问器 / 实例对象 toString 保护、`document.all` 特殊对象处理和指纹值回放均有证据;涉及指纹时再运行 `check_fingerprint_fixture.js`。
- 最终交付时,允许把已验证模块规范整理到 `result/src/`,但只能由 `result/final.js` 或 `result/final.py` 作为唯一入口串联调用;不要把测试脚本、临时 runner 或第二入口留在最终产物目录。
## 模块笔记模板
```markdown
## 环境模块补齐记录
| 模块 | 触发证据 | 补齐内容 | 真实性处理 | 验证结果 |
|---|---|---|---|---|
| base-env | ReferenceError: window | window/self/globalThis | descriptor 已记录 | 通过加载 |
| storage-env | localStorage.getItem | 注入 key 列表 | Storage 原型待补 | sign 仍不一致 |
```

View File

@@ -0,0 +1,610 @@
# 真实性保护、Proxy 风险与 native-like 行为
每次进入 Node.js 补环境阶段、准备编写 env 模块、或修改任何 WebAPI 对象模型时读取本文件。真实性保护不是等目标 JS 检测到再补,而是补环境默认基线;除非用户明确要求不做保护或不使用 addon,否则必须从补环境初始化开始执行。
新版 addon API 的详细调用方式见 `references/addon-api.md`。进入补环境阶段、修改 addon helper、创建构造函数 / 原型链 / 集合对象 / `document.all` / `navigator.plugins` / `navigator.mimeTypes` 时必须同时读取该文件。
如果用户明确选择 `isolated-vm` 补环境框架,则必须同时读取 `references/runtime-frameworks.md` 与 `references/xbs-isolated-vm-api.md`。该模式下不要把旧 `addon.node` 桥接进 isolated-vm;补环境代码运行在 isolated-vm Context 内,addon-first 应体现为 `window.xbs` / `globalThis.xbs` native-first。`xbs.createProtoChains`、`xbs.createNativeFunction`、`xbs.createGetter`、`xbs.createSetter`、`xbs.createNativeCollection`、`xbs.getMimeTypesAndPlugins`、`xbs.createUndetectable` 等 API 与 addon-first 同等作为优先主路径。
## 补环境初始化硬性基线(不等待检测)
进入补环境阶段后,先执行以下基线,再运行目标 JS:
1. 先加载 / 检测 native 能力:普通 Node runtime 运行 `scripts/load_native_addon.js --json` 或等价加载随包 `addon.node`;如果用户选择 `isolated-vm`,运行 `scripts/check_xbs_isolated_vm.js --markdown` 并在 Context 内自检 `window.xbs`。记录 available、path、exports / API、Node ABI、平台和失败原因。
2. 从第一版 env 骨架开始就使用 `assets/env-modules/native-protect.js` 或等价 addon-first helper;不要先用普通函数 / 普通赋值跑通,等检测到 `toString`、descriptor、原型链问题后再补保护。
3. 所有新增 WebAPI 默认使用 `Object.defineProperty` / `defineProperties`,并显式设置 `writable`、`enumerable`、`configurable`。
4. 所有方法、构造函数、getter、setter、实例对象都默认做 native-like / toString / `Symbol.toStringTag` / 原型链保护。
5. native 能力可用时必须优先调用 addon API 或 xbs API;只有用户明确要求不使用 native 能力、addon / xbs 缺失、ABI 不兼容或 API 调用失败时,才允许 `NativeProtect` / JS fallback,并记录豁免或降级原因。
该基线是规范性要求,不以“目标是否已经检测到”为触发条件。
## Proxy 只用于探测
JS `Proxy` 很适合发现缺失环境,但不适合作为最终交付。
目标 JS 可能通过以下方式发现异常:
```js
Object.keys(obj)
Reflect.ownKeys(obj)
Object.getOwnPropertyDescriptor(obj, 'xxx')
Object.getPrototypeOf(obj)
obj instanceof SomeConstructor
Object.prototype.toString.call(obj)
Function.prototype.toString.call(fn)
'xxx' in obj
obj.constructor.name
```
策略:
| 阶段 | 做法 |
|---|---|
| 初次运行 | 可以使用全量 Proxy 探测访问路径 |
| 中期调试 | 已知对象改为真实结构,只对未知分支继续 Proxy |
| 最终交付 | 尽量无 Proxy,用真实对象、描述符、原型链和 native-like 函数 |
即使 trace 暂时没有出现 `ownKeys`、`getOwnPropertyDescriptor`、`getPrototypeOf`、`toString`、`instanceof` 等信号,新增关键 WebAPI 也应按真实对象、描述符、原型链和 native-like 函数实现;当这些信号出现时,应立即把残留 Proxy 迁移到真实对象。
## 补环境阶段默认真实性清单
从补环境阶段开始,不能只补到“不报错”,也不能只在检测命中后才补保护。凡新增、修改或被 RuyiTrace / Node trace / 目标检测命中的 WebAPI,都必须逐项确认:
1. **原型链**:补构造函数、`prototype.constructor`、`Object.create(Constructor.prototype)`、必要的 `Object.setPrototypeOf` 多级链路,并验证 `instanceof`。
2. **属性描述符**:所有关键属性都用 `Object.defineProperty` / `defineProperties`;明确 `writable`、`enumerable`、`configurable`,不要用普通赋值替代。
3. **访问器**:真实浏览器中是 getter / setter 的属性,必须补成 accessor descriptor;不要为了省事改成 data descriptor。
4. **函数 toString 保护**:普通方法、构造函数、原型方法在 addon 可用时必须先用 `createNativeFunction`;addon 不可用或调用失败时才用 `NativeProtect.setNativeFunc` fallback。
5. **访问器 toString 保护**:getter / setter 本身也是函数;addon 可用时必须先用 `createGetter` / `createSetter`,失败时才用 `NativeProtect.setNativeFunc(getter, "get xxx")` / `setNativeFunc(setter, "set xxx")` fallback。
6. **实例对象 toString 保护**:对 `navigator`、`document`、`localStorage`、`screen`、`location` 等实例,优先用 addon 构造函数 / `createProtoChains` 实例工厂创建真实对象和原型链;addon 创建出的实例通常已经具备正确的 `Object.prototype.toString` 行为,不要再叠加 `markObjectType`、`markObjectToString` 或手写 `Symbol.toStringTag` 伪装。只有 addon 不可用、必须创建普通 JS fallback 对象时,才使用 `Symbol.toStringTag`、`NativeProtect.setObjFunc(obj, "Navigator")` / `markObjectToString`,并记录 fallback 原因。
7. **集合对象**:`HTMLCollection`、`NodeList`、`PluginArray`、`MimeTypeArray`、`DOMTokenList` 等集合对象必须优先用 addon `createNativeCollection` 或 `getMimeTypesAndPlugins`,不得以普通数组 / 普通对象作为主路径。
8. **plugins / mimeTypes**:`navigator.plugins` 和 `navigator.mimeTypes` 必须优先使用 addon `getMimeTypesAndPlugins(config)`;真实浏览器插件数据不同则传入 config 生成一致数据,不能手写 `[]` 或普通对象作为主路径。
9. **特殊对象**:`document.all` 这类 HTMLDDA / 不可检测对象必须优先使用 addon `createUndetectable`;JS fallback 只能标记为近似,不得声称完全一致。
10. **指纹终端 API**:Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何等指纹 API 必须优先回放真实浏览器采样值,同时保持 API 所在对象的原型链、描述符和 native-like `toString`;不得因为 Node.js 无法真实渲染就把最终流程改成自动化。
建议交付前运行:
```bash
node scripts/check_env_realism.js --case-dir case --markdown
node scripts/check_env_realism.js --case-dir case --require-document-all --require-ruyitrace --require-fingerprint-fixture --markdown
node scripts/check_fingerprint_fixture.js --case-dir case --require canvas,webgl --markdown
```
## 指纹值回放真实性
即使 `toDataURL`、`getParameter`、`getBoundingClientRect` 等方法只是返回 fixture 采样值,也要像浏览器 native API:
- 方法挂在正确 prototype 上,例如 `HTMLCanvasElement.prototype.toDataURL`。
- 函数名、`length`、`toString()` 尽量 native-like。
- 实例对象满足 `Object.prototype.toString.call(canvas)` / `instanceof HTMLCanvasElement` 等目标检测。
- 缺少采样值时抛出明确错误,不能悄悄返回空字符串、空数组或随机值。
- 不使用 `node-canvas` / `headless-gl` 作为最终指纹一致性方案;这些库可用于离线探索,但不能替代真实浏览器采样值。
## toString / native-like 保护优先级
推荐优先级:
1. 可选 native addon 或 xbs isolated-vm 创建 native-like 函数、getter、setter。
2. JS 层真实对象 + 描述符 + 原型链。
3. `NativeProtect` 作为 addon 不可用时的 fallback;必须同时覆盖普通函数、访问器 getter / setter 和实例对象 `Object.prototype.toString`。
4. 现有能力无法覆盖时,再考虑新增 C++ addon API。
## native 能力缺口闭环
如果目标行为经过真实浏览器采样后确认:纯 JS fallback 无法可靠表达,当前 addon.node API 也无法覆盖,用户选择 isolated-vm 时当前 `window.xbs` / `xbs.dom` API 也无法覆盖,则不要继续硬凑补环境代码。必须读取 `references/native-capability-gap.md`,并按以下方式处理:
1. 先排除实现不完整和已有 API 用法错误。比如没有使用 `createProtoChains`、`createNativeFunction`、`createGetter`、`createSetter`、`createNativeCollection`、`createUndetectable`、`createInterceptor` 或 `xbs.dom.createDocument()` 时,先改为 native-first,不得直接判定为能力缺口。
2. 确认为能力缺口后,写入 `case/notes/native-capability-gap.md`,记录阻塞点、真实浏览器基线、纯 JS / addon / xbs 当前结果、无法解决原因、建议新增 native API、最小行为测试用例和通过标准。
3. 测试用例必须覆盖导致阻塞的关键行为,例如 `typeof`、`Boolean`、`== null`、descriptor、prototype、`Object.prototype.toString`、`instanceof`、DataCloneError 或 Error stack 等,不得只覆盖当前报错消失。
4. 用户更新 addon.node 或 xbs isolated-vm 后,先运行能力缺口测试用例。测试通过后才能把该点标记为已解决;测试不通过时继续阻塞。
5. 用户选择临时 JS workaround 时,只能写成“仅当前样本路径临时兼容”,不得写成稳定浏览器行为。用户拒绝扩展 native 能力且目标参数生成必须依赖该行为时,标记 case 阻塞。
典型场景包括 HTMLDDA / `document.all`、内部槽 brand check、跨 Realm 对象一致性、不可检测对象、structuredClone / postMessage DataCloneError、Error stack 与其他必须依赖 V8 / 浏览器引擎能力的行为。
## addon-first 门禁
进入补环境阶段第一步必须先检测 native 能力;普通 Node runtime 检测 addon,选择 isolated-vm 时检测 xbs isolated-vm;交付前还要复查 addon-first / xbs native-first 证据:
```bash
node scripts/load_native_addon.js --json
node scripts/check_xbs_isolated_vm.js --markdown # 仅用户选择 isolated-vm 时必跑
```
实现规则:
1. `load_native_addon.js` 返回 `{ available, addon, path, exports }`;env helper 必须同时兼容 raw addon 和这个包装对象,不能因为传入 `{ addon }` 而静默跳过 addon。
2. 创建函数、构造函数、getter、setter、集合对象、`navigator.plugins` / `mimeTypes`、`document.all`、`createNativeObject` / `createProtoChains` 支持的对象时,先调用 addon API;如果选择 xbs isolated-vm,则先调用 Context 内的 `xbs` API。
3. 只有用户明确要求不使用 addon、addon 不可用、ABI 不兼容或 API 抛错时,才降级到 `NativeProtect` / JS fallback,并在 `notes`、阶段输出和最终总结中记录豁免或降级原因。
4. 交付前运行 `check_env_realism.js --case-dir case --markdown`;该脚本默认强制 addon-first / xbs native-first。如果源码只出现 `NativeProtect` / `markNativeFunction`,却没有 addon API、`window.xbs` / `globalThis.xbs`、`options.addon` / `options.xbs` 或加载记录,应视为失败。只有用户明确要求不使用 native 能力时,才可传入 `--no-require-addon-first` 并记录原因。
## 构造函数报错必须与浏览器一致
构造函数行为不只包括“能不能 `new`”,还包括错误类型、错误对象构造器、错误信息和调用路径。不要把所有不可构造对象都简单写成:
```js
throw new TypeError('Illegal constructor');
```
要求:
1. 对进入补环境范围的每个构造函数,先用已确认取证浏览器采样:
- `Ctor()` 直接调用的 `error.name`、`error.constructor.name`、`error.message`、`String(error)`、stack 首行。
- `new Ctor()` 构造调用的同样字段。
- 可构造对象的返回实例、`instanceof`、`Object.prototype.toString.call(instance)`。
2. 把采样结果写入 `case/fixtures/constructor-errors.fixture.json`、`case/notes/构造函数行为采样.md` 或阶段报告,不要只凭记忆猜测。
3. 多数 DOM 构造错误是 `TypeError`,但仍以目标浏览器采样为准;如果采样为 `DOMException` 或其他错误类型,必须按样本复现。
4. addon 可用时优先使用 `addon.throwTypeError(message)` 或 addon 提供的等价错误抛出能力;addon 不支持目标错误类型时,才用 JS fallback,并记录差异。
5. 错误信息要精确到浏览器版本和调用方式。例如 Chrome 中“需要 new 调用”和“非法构造”通常不是同一条 message。
推荐采样模板:
```js
function collectConstructorError(name) {
const Ctor = globalThis[name];
const result = { name, call: null, construct: null };
for (const mode of ['call', 'construct']) {
try {
if (mode === 'call') Ctor();
else Reflect.construct(Ctor, []);
result[mode] = { ok: true };
} catch (error) {
result[mode] = {
ok: false,
errorName: error && error.name,
errorConstructor: error && error.constructor && error.constructor.name,
message: error && error.message,
string: String(error),
stackFirstLine: String(error && error.stack || '').split('\n')[0],
};
}
}
return result;
}
```
交付前用 `check_webapi_addon_coverage.js` 检查泛化 `Illegal constructor` 和错误类型风险;发现问题时先补采样,再修复构造函数。
## 可选 native addon
本 Skill 支持可选 `addon.node`,并随包放置在 `assets/native-addon/<platform>-<arch>/addon.node`。脚本会按当前 `process.platform-process.arch` 自动加载;如果当前平台缺失、Node ABI 不兼容或加载失败,必须降级到 JS fallback,并在报告中说明。用户也可以通过 `--addon` 或环境变量 `WEB_JS_ENV_PATCHER_ADDON` 显式覆盖路径,但不要在 Skill 中写入任何本机绝对路径。addon 可用时,“可选”只表示不是跨平台强依赖,不表示补环境时可以跳过;凡 addon 支持的 native-like 能力都必须优先调用 addon,除非用户明确要求不使用 addon。
已知可用 API:
```js
createNativeObject
createNativeFunction
createProtoChains
getProtoChainRegistry
deleteProtoChainRegistryEntry
clearProtoChainRegistry
getPrivate
setPrivate
hasPrivate
deletePrivate
createInterceptor
createNativeCollection
getMimeTypesAndPlugins
createGetter
createSetter
createUndetectable
throwTypeError
```
新版调用约束:
- 新代码创建构造函数、原型链、别名和实例工厂时,优先使用 `createProtoChains(descriptors)`。
- `createNativeObject(options)` 仅作为旧式单对象兼容;不要把 `createNativeObject(tag, proto, properties)` 写入新代码主路径。
- 发现旧式 `createProtoChains(name, chain)` 时要迁移为 descriptors 数组;兼容层只能记录 fallback,不能把旧式形态继续作为 addon 主调用。
- `createUndetectable(callback, handlers)` 支持 `getter`、`setter`、`query`、`deleter`、`enumerator`、`definer`、`descriptor` 等 handlers;处理 `document.all` 时优先使用。
- `createProtoChains` 支持 `constructorBehavior`、`callBehavior`、`constructorErrorMessage`、`callErrorMessage`、`illegalConstructor`、`prototypeMethods`、`staticMethods`、`internalClassName`、`aliases`、`aliasOf`、`instanceFactoryName`、`instanceInitializer`;应一次性规划对象模型,减少后续反复改注册表。
- `getMimeTypesAndPlugins([config])` 是 `navigator.plugins` / `navigator.mimeTypes` 主路径;`createNativeCollection(options)` 是通用集合对象主路径。
检查方式:
```bash
node scripts/load_native_addon.js --json
node scripts/load_native_addon.js --addon <path-to-addon.node> --json
```
常见用途:
| 问题 | 推荐 API |
|---|---|
| `document.all` / HTMLDDA 特殊行为 | `createUndetectable` |
| native-like 函数 | `createNativeFunction` |
| native-like getter | `createGetter` |
| native-like setter | `createSetter` |
| 构造函数、实例、别名和实例工厂 | `createProtoChains(descriptors)` |
| 旧式单对象兼容 | `createNativeObject(options)` |
| `navigator.plugins` / `navigator.mimeTypes` | `getMimeTypesAndPlugins(config)` |
| `HTMLCollection` / `NodeList` / 类数组集合 | `createNativeCollection(options)` |
| named / indexed property interceptor | `createInterceptor(options)` |
| 内部私有状态 | `setPrivate` / `getPrivate` / `hasPrivate` / `deletePrivate` |
| 注册表查看与测试隔离 | `getProtoChainRegistry` / `deleteProtoChainRegistryEntry` / `clearProtoChainRegistry` |
| 浏览器式 TypeError | `throwTypeError` |
## `document.all`
`document.all` 的关键行为:
```js
typeof document.all // 'undefined'
document.all == undefined // true
document.all === undefined // false
Boolean(document.all) // false
'all' in document // true
```
addon 可用时:
```js
Object.defineProperty(document, 'all', {
value: addon.createUndetectable(function () {
return undefined;
}),
enumerable: false,
configurable: true,
});
```
addon 不可用时只能近似:
```js
Object.defineProperty(document, 'all', {
value: undefined,
enumerable: false,
configurable: true,
});
```
近似方案不能满足 `document.all !== undefined`,必须在报告中说明。
## native-like 函数
addon 可用时优先:
```js
const querySelector = addon.createNativeFunction(false, 'querySelector', 1, function (selector) {
return null;
});
querySelector.toString();
// function querySelector() { [native code] }
```
getter / setter:
```js
const getUserAgent = addon.createGetter('userAgent', 0, function () {
return userAgent;
});
Object.defineProperty(Navigator.prototype, 'userAgent', {
get: getUserAgent,
enumerable: true,
configurable: true,
});
```
## 多通道 toString 与 DataCloneError 保护
高强度检测不会只调用 `fn.toString()`。凡进入补环境范围的函数、构造函数、getter、setter 都要按以下通道验证:
- `fn.toString()`
- `Function.prototype.toString.call(fn)`
- 目标 JS 先保存 `const FTS = Function.prototype.toString` 后再 `FTS.call(fn)`
- `String(fn)`
- `fn + ""`
- `fn.toString.toString()`
- `structuredClone(fn)` 抛出的 `DataCloneError` message / stack
- `MessagePort.prototype.postMessage(fn)` 抛出的 `DataCloneError` message / stack
用户实测 addon 创建的 native-like 函数对上述多通道 toString 检测没有问题,因此 addon / xbs native-first 仍是第一选择。只有 fallback 到 `NativeProtect` 时,才使用下方带 `structuredClone` / `postMessage` DataCloneError 改写的版本;旧版只 patch `Function.prototype.toString` 的 NativeProtect 不再作为主推荐。
## `NativeProtect` fallback
addon 不可用、ABI 不兼容或调用失败时,可以在目标 JS 所在运行上下文内使用以下 fallback。必须在加载目标 JS 之前执行。
```js
class NativeProtect {
#map = new Map();
#objMap = new Map();
#clonePatched = false;
static #instance = null;
static getInstance() {
if (!NativeProtect.#instance) {
NativeProtect.#instance = new NativeProtect();
const instance = NativeProtect.#instance;
const _toString = Function.prototype.toString;
const patchedToString = {
toString() {
if (instance.#map.has(this)) {
const name = instance.#map.get(this);
return `function ${name || this.name}() { [native code] }`;
}
return _toString.call(this);
}
}.toString;
Object.defineProperty(Function.prototype, "toString", {
value: patchedToString,
writable: true,
enumerable: false,
configurable: true,
});
instance.#map.set(Function.prototype.toString, "toString");
const _objToString = Object.prototype.toString;
const patchedObjToString = {
toString() {
if (instance.#objMap.has(this)) {
const name = instance.#objMap.get(this);
return `[object ${name}]`;
}
return _objToString.call(this);
}
}.toString;
Object.defineProperty(Object.prototype, "toString", {
value: patchedObjToString,
writable: true,
enumerable: false,
configurable: true,
});
instance.#map.set(Object.prototype.toString, "toString");
instance.#patchCloneErrorLeak();
}
return NativeProtect.#instance;
}
constructor() {
if (NativeProtect.#instance) {
throw new Error("NativeProtect类只能实例化一次");
}
}
setNativeFunc(func, name = "") {
this.#map.set(func, name);
}
setObjFunc(obj, name = "") {
this.#objMap.set(obj, name);
}
#patchCloneErrorLeak() {
if (this.#clonePatched) return;
this.#clonePatched = true;
const rawStructuredClone = globalThis.structuredClone;
const rawMessagePortPostMessage =
typeof MessagePort !== "undefined" &&
MessagePort.prototype &&
MessagePort.prototype.postMessage;
if (typeof rawStructuredClone === "function") {
const self = this;
function structuredClone(value, options) {
try {
return rawStructuredClone.apply(this, arguments);
} catch (err) {
self.#rewriteDataCloneError(err, value);
}
}
this.#copyFunctionMeta(structuredClone, rawStructuredClone, "structuredClone");
const desc =
Object.getOwnPropertyDescriptor(globalThis, "structuredClone") || {
writable: true,
enumerable: true,
configurable: true,
};
Object.defineProperty(globalThis, "structuredClone", {
...desc,
value: structuredClone,
});
this.setNativeFunc(structuredClone, "structuredClone");
}
if (typeof rawMessagePortPostMessage === "function") {
const self = this;
function postMessage(value, transferList) {
try {
return rawMessagePortPostMessage.apply(this, arguments);
} catch (err) {
self.#rewriteDataCloneError(err, value);
}
}
this.#copyFunctionMeta(postMessage, rawMessagePortPostMessage, "postMessage");
const desc =
Object.getOwnPropertyDescriptor(MessagePort.prototype, "postMessage") || {
writable: true,
enumerable: true,
configurable: true,
};
Object.defineProperty(MessagePort.prototype, "postMessage", {
...desc,
value: postMessage,
});
this.setNativeFunc(postMessage, "postMessage");
}
}
#rewriteDataCloneError(err, value) {
if (!err || err.name !== "DataCloneError") {
throw err;
}
const fn = this.#findFunction(value);
if (!fn) {
throw err;
}
const fakeSource = this.#getFunctionSource(fn);
const msg = `${fakeSource} could not be cloned.`;
try {
Object.defineProperty(err, "message", {
value: msg,
configurable: true,
});
} catch (_) {}
try {
if (typeof err.stack === "string") {
const lines = err.stack.split(/\r?\n/);
lines[0] = `${err.name}: ${msg}`;
Object.defineProperty(err, "stack", {
value: lines.join("\n"),
configurable: true,
});
}
} catch (_) {}
throw err;
}
#getFunctionSource(fn) {
try {
return Function.prototype.toString.call(fn);
} catch (_) {
return "function () { [native code] }";
}
}
#findFunction(value, seen = new WeakSet()) {
if (typeof value === "function") return value;
if (value === null || typeof value !== "object") return null;
if (seen.has(value)) return null;
seen.add(value);
if (value instanceof Map) {
for (const [k, v] of value) {
const fk = this.#findFunction(k, seen);
if (fk) return fk;
const fv = this.#findFunction(v, seen);
if (fv) return fv;
}
}
if (value instanceof Set) {
for (const v of value) {
const fv = this.#findFunction(v, seen);
if (fv) return fv;
}
}
for (const key of Reflect.ownKeys(value)) {
let desc;
try {
desc = Object.getOwnPropertyDescriptor(value, key);
} catch (_) {
continue;
}
if (desc && "value" in desc) {
const f = this.#findFunction(desc.value, seen);
if (f) return f;
}
}
return null;
}
#copyFunctionMeta(target, source, name) {
try {
Object.defineProperty(target, "name", {
value: name || source.name,
writable: false,
enumerable: false,
configurable: true,
});
} catch (_) {}
try {
Object.defineProperty(target, "length", {
value: source.length,
writable: false,
enumerable: false,
configurable: true,
});
} catch (_) {}
}
}
```
使用示例:
```js
const nativeProtect = NativeProtect.getInstance();
function getItem(key) {
return storageMap.get(String(key)) ?? null;
}
nativeProtect.setNativeFunc(getItem, 'getItem');
Object.defineProperty(Storage.prototype, 'getItem', {
value: getItem,
writable: true,
enumerable: true,
configurable: true,
});
```
注意:
- 只在目标 JS 所在运行上下文内 patch,不要污染宿主 Node.js 全局环境;该上下文可以是 `vm`、独立 Node 进程或显式隔离的全局对象。
- `NativeProtect` 是 fallback,不是第一选择;如果 addon 可加载但源码直接手写函数再 `setNativeFunc`,应回退修改为 addon-first。
- 使用 `NativeProtect` fallback 时必须使用带 `structuredClone` / `MessagePort.prototype.postMessage` DataCloneError 改写的版本;如果目标会通过 `structuredClone(fn)` 或 `postMessage(fn)` 检测函数源码,旧版 NativeProtect 会暴露非 native 函数。
- 如果目标 JS 提前保存了原始 `Function.prototype.toString`,后 patch 可能失效。
- 如果使用 `vm`,要确保 patch 发生在目标 JS 所在 context 内;如果不用 `vm`,要确保入口文件以独立进程 / 隔离 global 初始化并在结束后不污染其他任务。
## 属性描述符、访问器与原型链
常见检查:
```js
Object.getOwnPropertyDescriptor(navigator, 'userAgent')
Object.getOwnPropertyDescriptor(window, 'navigator')
Object.getPrototypeOf(document)
navigator.constructor.name
```
处理原则:
- 所有关键属性都用 `Object.defineProperty`,禁止用普通赋值替代关键 WebAPI。
- 对不可写、不可枚举、只读 getter 要显式设置;真实浏览器是 accessor 的属性不得降级为 data descriptor。
- getter / setter 的 `Function.prototype.toString.call(descriptor.get)` / `descriptor.set` 也要 native-like。
- `constructor` 通常不可枚举,并且构造函数本身也要做 toString 保护。
- `Symbol.toStringTag` 与 `NativeProtect.setObjFunc(obj, "Xxx")` 只能用于 JS fallback 的 `[object Xxx]`;addon 创建的实例不要再额外调用 `markObjectType` / `markObjectToString`。
- 发现 `instanceof`、`Object.getPrototypeOf`、`constructor.name` 检测时,补构造函数和完整原型链。
## 网络代理检测
如果用户所说“代理检测”是网络代理 / IP 检测,而不是 JS `Proxy`:
- 默认回退到用户真实浏览器手动取证。
- 不默认启用网络代理。
- 如果必须使用代理,必须由用户提供授权代理并确认使用范围。
- 代理被检测时,不尝试绕过风控;应暂停并让用户选择:
1. 换回用户真实浏览器手动取证。
2. 提供 HAR / cURL / JS 文件离线分析。
3. 在授权环境中更换合规网络环境。

View File

@@ -0,0 +1,540 @@
# 浏览器环境对象模型补齐指南
当需要从 RuyiTrace / Node trace / fixtures 编写 `env.js`、`runner.js` 或最终 `result/src/env/*` 时读取本文件。本文件用于决定“补哪些浏览器对象”,但不降低对象真实性要求;真实性保护细节必须同时遵循 `env-native-protection.md` 和 `addon-api.md`。
## 总体原则
核心规则:
```text
最小范围,完整真实性。只减少对象覆盖范围,不降低已补对象质量。
```
不要一开始伪造完整浏览器,但凡某个 WebAPI 进入补环境范围,就必须从第一版实现开始执行:
1. 先加载 / 记录 addon,addon 可用时优先使用 addon API。
2. 先建立构造函数、构造函数非法调用行为、原型链、实例工厂、`prototype.constructor` 和 `Symbol.toStringTag`。
3. 再安装属性描述符、getter / setter、方法、内部状态和真实样本值。
4. getter / setter / 方法 / 构造函数默认 native-like,优先 `createGetter` / `createSetter` / `createNativeFunction`。
5. 实例对象默认要满足 `Object.prototype.toString.call(obj)`、`constructor.name`、`instanceof` 和 descriptor 检查。
6. addon 不可用、ABI 不兼容或 API 调用失败时,才使用 `NativeProtect` / JS fallback,并记录降级原因。
“根据访问路径补最小对象模型”只表示不一次性补全所有 DOM / BOM,不表示可以先用普通对象、普通赋值或普通函数跑通后再补保护。
## 构造函数行为采样
每个进入补环境范围的构造函数都要记录浏览器真实行为:
- `Ctor()` 是否允许直接调用。
- `new Ctor()` 是否允许构造。
- 失败时的 `error.name`、`error.constructor.name`、`error.message`、`String(error)` 和 stack 首行。
- 成功时的实例原型、`instanceof`、`constructor.name` 和 `Object.prototype.toString.call(instance)`。
不要只写:
```js
throw new TypeError('Illegal constructor');
```
也不要把所有构造错误都写成同一个 message。`EventTarget()`、`new Node()`、`new Document()`、`new Blob()`、`DOMRect()` 等在不同浏览器和调用方式下可能有不同表现;必须以本 case 的取证浏览器为准。建议把结果保存到 `case/fixtures/constructor-errors.fixture.json` 或 `case/notes/构造函数行为采样.md`。addon 可用时用 addon 创建构造函数并用 `addon.throwTypeError(message)` 或等价 helper 抛出浏览器式错误;addon 不支持目标错误类型时才 JS fallback。
推荐补齐顺序:
```text
Node 泄露阻断 → addon 加载记录 → 目标对象范围确认 → 构造函数 / 原型链 / 实例工厂 → 属性描述符 / 访问器 / 方法 → 样本值写入 → fixtures 验证
```
对象范围由 RuyiTrace、Node trace、fixtures、请求样本和目标入口决定;对象质量由 addon-first、原型链、描述符、访问器、toString 保护、`Symbol.toStringTag` 和构造函数行为决定。
## 对象补齐硬性清单
每补一个浏览器对象,先检查以下项目:
| 项目 | 要求 |
|---|---|
| addon-first | 进入补环境阶段先加载 addon,可用时优先使用 addon API |
| 构造函数 | 优先 `createProtoChains(descriptors)`,构造函数名称、`length`、`prototype` 描述符要明确 |
| 非法构造行为 | 浏览器不可直接构造的对象要按真实浏览器采样复现错误类型、错误构造器和完整 message,不能统一写泛化 `Illegal constructor` |
| 原型链 | 先建 `Constructor.prototype` 与父级链,再创建实例 |
| `prototype.constructor` | 通常不可枚举,指回构造函数 |
| `Symbol.toStringTag` | 挂在正确 prototype 或实例上,按浏览器样本设置 |
| 属性描述符 | 全部关键属性用 `Object.defineProperty` / `defineProperties` |
| 访问器 | 浏览器中是 getter / setter 的属性,不得降级为 data descriptor |
| 方法 | 优先 `addon.createNativeFunction`,fallback 才用 `NativeProtect.setNativeFunc` |
| 访问器 toString | 优先 `addon.createGetter` / `createSetter`,fallback 才保护 getter / setter 函数 |
| 实例 toString | 优先 addon 原型链 / 实例工厂;addon 创建的实例通常已处理对象 toString,fallback 才用 `Symbol.toStringTag` / `NativeProtect.setObjFunc` |
| 内部状态 | 优先 `addon.setPrivate/getPrivate`,fallback 才用 `WeakMap` |
| 降级记录 | addon 不可用或用户豁免必须写入 notes、阶段报告和最终总结 |
## 全局对象
基础关系可按目标需要安装:
```js
globalThis.window = globalThis;
globalThis.self = globalThis;
globalThis.top = globalThis;
globalThis.parent = globalThis;
```
真实浏览器的 `window` 不是普通对象。只要补 `Window` / `window`,就默认需要考虑:
- `Window` 构造函数和非法构造行为。
- `Object.prototype.toString.call(window)`。
- `window instanceof Window`。
- `window.window === window`、`window.self === window`。
- `window.navigator`、`window.document`、`window.location` 的 descriptor。
- `globalThis`、`self`、`top`、`parent` 的关系和只读程度。
探测模式可以最小化;交付模式不得把普通对象当作最终 `window` 真实性方案。
## iframe / Worker Realm 隔离
iframe 与 Worker 不是“换一个全局对象引用”的浅拷贝。只要目标路径创建 iframe、DedicatedWorker 或 SharedWorker,就必须为每个 Realm 建立独立全局、构造器图、对象实例和生命周期状态。
iframe 最低关系:
```text
iframe.contentWindow !== mainWindow
iframe.contentWindow.self === iframe.contentWindow
iframe.contentWindow.window === iframe.contentWindow
iframe.contentWindow.globalThis === iframe.contentWindow
iframe.contentWindow.parent === mainWindow
iframe.contentWindow.top === mainWindow.top
iframe.contentWindow.frames === iframe.contentWindow
iframe.contentDocument.defaultView === iframe.contentWindow
iframe.contentWindow.document === iframe.contentDocument
iframe.frameElement === iframe
```
主 Window 与同源 iframe 可以共享同源 Cookie、Storage 后端和网络 Session,但公开 wrapper、构造器与实例不能共享 identity。共享底层 origin state 不等于复用 `navigator`、`performance`、`crypto`、`location`、`document`、Storage wrapper、timer、EventTarget、XHR/fetch 或 URL 构造器。
必须验证:
- `frame.Object !== window.Object`、`frame.Function !== window.Function`、`frame.Array !== window.Array`、`frame.Promise !== window.Promise`。
- `frame.Event !== window.Event`、`frame.EventTarget !== window.EventTarget`、`frame.URL !== window.URL`、`frame.Blob !== window.Blob`、`frame.XMLHttpRequest !== window.XMLHttpRequest`。
- frame 创建的对象满足 frame-local `instanceof`,且通常不满足主 Realm 对应构造器的 `instanceof`。
- `srcdoc`、`document.open/write/close`、navigation/reload 后会创建或更新正确 Document/Realm;旧 Realm 的 timer、XHR、MessagePort task 与 observer callback 不得继续写入新文档。
- Worker Realm 不得暴露 Window-only API,Worker 的 `self/globalThis`、navigator、performance、timer、消息队列和构造器必须独立。
发现 `ctx.X = mainWindow.X`、`frame.URL.prototype = URL.prototype`、Worker 直接透传宿主或主 Realm `Event/XHR/Request/Response` 时,默认按 P0/P1 Realm 隔离缺陷处理,不能标为 `accepted-diff`。
## 属性定义工具与模板模块
本 Skill 随包提供可复制模板:
- `assets/env-modules/native-protect.js`:`NativeProtect` 与 addon-first helper。
- `assets/env-modules/base-env.js`:`Window` / `Location` / `Navigator` 原型链、描述符和 getter 保护。
- `assets/env-modules/storage-env.js`:`Storage` 构造函数、实例、方法和 `length` getter。
- `assets/env-modules/document-env.js`:`EventTarget → Node → Document → HTMLDocument` 基础链路、cookie accessor、DOM 方法、`document.all` addon 优先处理。
复制模板后必须按当前目标的 RuyiTrace / fixtures 修改字段值,不要把模板默认值当成真实采集值。不要随意赋值:
```js
navigator.userAgent = 'xxx';
```
关键属性必须统一使用 descriptor,并优先把 getter / setter / 方法交给 addon 创建:
```js
Object.defineProperty(Navigator.prototype, 'userAgent', {
get: addon.createGetter('userAgent', 0, function () {
return fixture.browser.userAgent;
}),
enumerable: true,
configurable: true,
});
```
描述符来源优先级:
1. 用户真实浏览器控制台采集。
2. ruyiPage / Camoufox / CloakBrowser / 真实浏览器取证样本。
3. RuyiTrace 环境访问证据。
4. 常见浏览器行为模板。
5. 目标 JS 检测结果。
## navigator
常见字段:
| 字段 | 来源 |
|---|---|
| `userAgent` | 必须尽量来自真实请求 UA |
| `language` / `languages` | 来自浏览器样本 |
| `platform` | 来自浏览器样本 |
| `hardwareConcurrency` | 来自浏览器样本或用户确认 |
| `deviceMemory` | 来自浏览器样本或用户确认 |
| `webdriver` | 普通浏览器通常应为 `false` 或不存在,取决于目标环境 |
| `plugins` / `mimeTypes` | 优先真实采集,addon 可用时优先 `getMimeTypesAndPlugins()` |
| `userAgentData` | 来自浏览器样本;`brands/fullVersionList/platform/mobile/getHighEntropyValues` 必须与请求头 Client Hints 一致 |
补 `navigator` 时不要先手写普通 `function Navigator(){}` 作为主路径。推荐:
1. 优先用 `addon.createProtoChains(descriptors)` 创建 `Navigator` 构造函数、`Navigator.prototype` 和 `navigator` 实例工厂。
2. `Navigator` 构造函数按浏览器行为模拟非法构造,直接调用或 `new Navigator()` 应抛出合适 TypeError。
3. `Navigator.prototype.constructor`、`Symbol.toStringTag = "Navigator"`、实例原型链在第一版就补齐。
4. `userAgent`、`language`、`languages`、`platform`、`hardwareConcurrency`、`plugins`、`mimeTypes` 等优先用 addon getter。
5. addon 不可用时,才使用 JS 构造函数 + `Object.defineProperty` + `NativeProtect` fallback。
不要只补返回值。getter 的 `Function.prototype.toString.call(descriptor.get)`、实例的 `Object.prototype.toString.call(navigator)`、`navigator.constructor.name` 都属于默认真实性基线。
`navigator.userAgentData` 是高强度检测重点。进入补环境范围时必须采样真实浏览器:
- `brands`、`mobile`、`platform`、`toJSON`。
- `getHighEntropyValues()` 的 Promise 行为、参数校验、返回字段和错误模式。
- `architecture`、`bitness`、`model`、`platformVersion`、`uaFullVersion`、`fullVersionList` 等字段。
- 与最终请求头 `User-Agent`、`sec-ch-ua`、`sec-ch-ua-mobile`、`sec-ch-ua-platform` 保持一致;不一致时先修正 fixture 和请求头,不要只改 JS 层。
## window.chrome 与 Chrome 专有对象
如果目标检测 `window.chrome`,不要只补空对象。按目标浏览器版本采样后再决定是否提供:
- `chrome.app`
- `chrome.csi`
- `chrome.loadTimes`
- `chrome.runtime`
这些属性的 key、descriptor、函数 `name/length/toString`、返回对象结构都可能被检测。旧 Chrome API 不要盲目全补;版本不匹配也会形成指纹。addon / xbs 可用时,相关函数仍优先 native-first;fallback 才用 `NativeProtect`。
## location
`location` 经常参与签名。不要用空字符串猜测。
值应从目标页面 URL 解析:
```js
const u = new URL(fixture.pageUrl);
```
补 `location` 时默认需要:
- 优先建立 `Location` 构造函数和 `Location.prototype`。
- `Location` 构造函数按浏览器行为模拟非法构造。
- `href`、`origin`、`protocol`、`host`、`hostname`、`port`、`pathname`、`search`、`hash` 优先按真实浏览器 descriptor 安装。
- 浏览器中是 getter / setter 的属性保持 accessor,不要降级成普通字段。
- getter / setter 优先 `addon.createGetter` / `createSetter`。
- 内部 URL 状态优先 `addon.setPrivate/getPrivate`,fallback 才用 `WeakMap`。
- 安装 `Symbol.toStringTag = "Location"`,并验证 `Object.prototype.toString.call(location)`。
## document 与 cookie
常见访问:
- `document.cookie`
- `document.referrer`
- `document.URL`
- `document.documentElement`
- `document.createElement`
- `document.querySelector`
- `document.all`
补 `document` 时默认先建立:
```text
EventTarget → Node → Document → HTMLDocument
```
然后再创建 `document` 实例。`Document` / `HTMLDocument` 构造函数、`prototype.constructor`、`Symbol.toStringTag`、实例 `Object.prototype.toString` 和非法构造行为都属于默认真实性基线。
`document.cookie` 必须作为 accessor descriptor 处理。即使当前样本只读取 cookie,也建议同时准备最小 setter,setter 可以只实现当前 case 需要的写入、覆盖和过期策略,但不得把 cookie 做成普通 data 属性。getter / setter 优先 addon;fallback 才用 `NativeProtect` 保护访问器函数。
DOM 方法如 `createElement`、`querySelector`、`querySelectorAll`、`getElementById` 进入补环境范围后,优先用 `addon.createNativeFunction`,并挂在正确 prototype 上。
`document.createElement(tag)` 不能统一返回普通对象。进入补环境范围的 tag 必须映射到正确构造链,例如:
| tag | 期望实例与原型链 |
|---|---|
| `canvas` | `HTMLCanvasElement → HTMLElement → Element → Node → EventTarget → Object` |
| `video` | `HTMLVideoElement → HTMLMediaElement → HTMLElement → Element → Node → EventTarget → Object` |
| `audio` | `HTMLAudioElement → HTMLMediaElement → HTMLElement → Element → Node → EventTarget → Object` |
| `img` / `image` | `HTMLImageElement → HTMLElement → Element → Node → EventTarget → Object` |
| `a` | `HTMLAnchorElement → HTMLElement → Element → Node → EventTarget → Object` |
同时验证 `constructor.name`、`instanceof`、`Object.prototype.toString.call(element)`、`Symbol.toStringTag`、`Object.getPrototypeOf` walk、跨原型方法 brand check,例如 `HTMLElement.prototype.getAttribute.call(video, "src")`。
## `document.all`
`document.all` 是 HTMLDDA / 不可检测特殊对象,不得用普通对象、普通 Proxy 或 `undefined` 声称完整实现。
选择 `isolated-vm` 时,优先使用 `xbs.dom.createDocument()` 自动提供的 `document.all`:
```js
const document = xbs.dom.createDocument({
url: "https://example.com/",
html: '<main id="app"></main>',
});
// 需要禁用时必须在创建前声明,不要运行时强删。
const withoutAll = xbs.dom.createDocument({ omitApis: ["document.all"] });
const withoutAll2 = xbs.dom.createDocument({ features: { documentAll: false } });
```
期望关键行为至少包括:
```js
typeof document.all === 'undefined'
document.all == null
document.all !== undefined
Boolean(document.all) === false
'all' in document
Object.prototype.toString.call(document.all) === '[object HTMLAllCollection]'
typeof document.all.length === 'number'
typeof document.all.item === 'function'
typeof document.all.namedItem === 'function'
```
只有不使用 `xbs.dom.createDocument()`、而是手写 document 时,才手动使用 `xbs.createUndetectable(callback, handlers)`:
```js
const all = xbs.createUndetectable(function (value) {
if (arguments.length === 0 || value == null) return null;
// 中文说明:这里根据索引、id 或 name 返回元素;未命中返回 null。
return null;
}, {
getter(target, property) {
// 中文说明:索引或命名属性可在这里 materialize;未命中必须放行给原型链。
return { intercept: false };
},
query(target, property) {
return { intercept: false };
},
descriptor(target, property) {
return { intercept: false };
},
enumerator(target) {
return Object.getOwnPropertyNames(target);
},
});
Object.setPrototypeOf(all, HTMLAllCollection.prototype);
```
手动模式硬规则:
- `xbs.createUndetectable()` 只负责 `typeof all === "undefined"`、`Boolean(all) === false`、`all == null` 等不可检测语义。
- `length / item / namedItem / [0] / 命名属性 / descriptor / enumerator` 需要由 handlers 与 `HTMLAllCollection.prototype` 配合实现。
- `length / item / namedItem / constructor` 等原型链已有属性不要定义成 `document.all` 自有属性,否则会和真实浏览器不一致。
- 更接近浏览器的安装方式是挂到 `Document.prototype` getter:
```js
Object.defineProperty(Document.prototype, "all", {
get: xbs.createGetter("all", 0, function () {
return all;
}),
enumerable: true,
configurable: true,
});
```
普通 Node + addon 模式也优先使用 addon `createUndetectable(callback, handlers)` 并遵循同样的 handlers / 原型链要求。addon 或 xbs 不可用时只能写明降级近似,并必须在 notes、阶段报告和最终总结中标记真实性不足;不得声称完全一致。
## Storage
实现 `localStorage` / `sessionStorage` 时,不要以普通对象或普通函数作为主路径。推荐:
1. 优先用 `addon.createProtoChains(descriptors)` 创建 `Storage` 构造函数、`Storage.prototype` 和实例工厂。
2. `Storage` 构造函数按浏览器行为模拟非法构造。
3. `localStorage` / `sessionStorage` 由实例工厂创建,并设置正确 `Symbol.toStringTag`、原型链和 `constructor`。
4. `getItem`、`setItem`、`removeItem`、`clear`、`key` 优先 `addon.createNativeFunction`。
5. `length` 优先 `addon.createGetter`,保持 accessor descriptor。
6. 内部键值状态优先 `addon.setPrivate/getPrivate`,fallback 才用 `WeakMap` / `Map`。
如果 addon 不可用,才使用 JS fallback:
```js
function Storage() {
throw new TypeError("Illegal constructor");
}
const localStorage = Object.create(Storage.prototype);
```
fallback 仍必须显式 descriptor、原型链、`Symbol.toStringTag`、方法 toString 和访问器 toString,不得只用普通赋值。
## crypto
`crypto.getRandomValues`、`crypto.subtle`、`crypto.randomUUID` 可能参与签名。
原则:
- 如果签名依赖随机数,fixtures 必须记录对应随机输入或控制随机源。
- 不能随意用真实随机数比较固定期望值。
- `Crypto` / `SubtleCrypto` 构造函数、`crypto` 实例、`getRandomValues`、`randomUUID` 进入补环境范围后,要按 addon-first 建立构造函数、原型链、descriptor 和 native-like 方法。
- `getRandomValues` 在测试模式下可使用 fixture 中的固定字节序列,但函数形态仍要像浏览器 native API。
## performance 与时间
`Date.now()`、`new Date()`、`performance.now()` 经常影响签名。
探测模式可以临时固定:
```js
const fixedNow = fixture.runtime.now;
```
交付模式不要直接写:
```js
Date.now = () => fixedNow;
performance.now = () => fixture.runtime.performanceNow ?? 0;
```
交付模式要求:
- `Date` 构造函数、`Date.now`、`Date.parse`、`Date.UTC` 的 `name`、`length`、`toString` 和调用行为要受保护。
- `new Date()` 与 `Date()` 两种调用路径都要按样本验证。
- `Performance` 构造函数、`Performance.prototype.now`、`timeOrigin`、相关 descriptor 要明确。
- `performance.now` 优先 `addon.createNativeFunction`,不要暴露 Node `performance.nodeTiming/eventLoopUtilization/timerify`。
## Canvas / WebGL / WebGPU / 字体 / DOM 几何指纹
这类指纹不要优先在 Node.js 中真实模拟渲染。真实浏览器的 Skia、GPU、字体、抗锯齿、颜色管理和布局细节很难由 `node-canvas` / `headless-gl` / `jsdom` 精确复现。
处理原则:
- 先读取 `fingerprint-value-replay.md`。
- 用用户确认的取证模式采集终端 API 返回值,例如 `toDataURL`、`getImageData`、`measureText`、`getParameter`、`readPixels`、`getBoundingClientRect`。
- 在 Node.js 中用 `assets/env-modules/fingerprint-env.js` 按调用特征回放采样值。
- 回放函数也要挂在正确 prototype 上,并保持原型链、属性描述符、native-like `toString` 和实例对象 `Object.prototype.toString`。
- 缺少采样值时阻塞并提示补采样,不要静默返回空值或改用自动化浏览器作为最终方案。
示例接入:
```js
const { installFingerprintValueReplay } = require('./fingerprint-env');
const fingerprintFixture = require('../../fixtures/fingerprint.fixture.json');
installFingerprintValueReplay(globalThis, fingerprintFixture, {
strict: true,
addon,
});
```
最终项目中不得包含用于采样的 Hook、Playwright、Puppeteer、CloakBrowser、ruyiPage 或其他浏览器自动化代码。
## 高强度补充 WebAPI 对象清单
从 Cloudflare / Turnstile / Akamai / DataDome / Kasada / Shape / F5 等高强度检测样本抽象出的通用对象范围如下。只有目标 trace / fixture / 取证证据访问到时才补,但一旦补就必须遵循 addon-first / xbs native-first、原型链、描述符、访问器、构造函数行为、`Symbol.toStringTag` 和 native-like 保护。
| 对象 / API | 重点行为 | 补环境要求 |
|---|---|---|
| `navigator.permissions` / `PermissionStatus` | `query()` Promise、`state`、`onchange`、错误类型、权限名校验 | 采样真实浏览器;方法优先 native-like;返回对象原型链和 descriptor 不得用普通对象 |
| `navigator.plugins` / `navigator.mimeTypes` | 长度、索引属性、命名属性、`item()`、`namedItem()`、枚举顺序、Plugin / MimeType 原型 | addon 可用时优先 `getMimeTypesAndPlugins(config)`;禁止空数组或普通数组作为高强度主路径 |
| `speechSynthesis` / `SpeechSynthesisUtterance` | `getVoices()` 列表、异步 voiceschanged、语言、voiceURI、构造函数行为 | 只回放真实采样的 voices 摘要;构造函数、事件属性和方法 toString 需保护 |
| `AudioContext` / `OfflineAudioContext` | 构造限制、采样率、`startRendering()` Promise、AudioBuffer 数据摘要 | 终端值走 `fingerprint-value-replay.md`;不要用 Node 音频模拟库猜值 |
| `DOMRect` / layout dimensions | `getBoundingClientRect()`、`getClientRects()`、offset/client/scroll 尺寸、对象可枚举性 | 按目标元素和调用栈采样;DOMRect 原型链、只读属性、`toJSON` 行为要真实 |
| `CSSStyleDeclaration` / `getComputedStyle` / `matchMedia` | 属性名、索引、`length`、media query 结果、CSS.supports | 使用真实浏览器样本;不要只返回空对象或固定字符串 |
| `navigator.mediaDevices` / WebRTC | `enumerateDevices()`、`getUserMedia()` 错误模式、`RTCPeerConnection` 候选摘要 | 不暴露宿主 Node;需要权限或设备时记录降级,不伪造敏感设备信息 |
| `screen` / viewport / DPR | `width/height/avail*`、`colorDepth`、`devicePixelRatio`、窗口尺寸关系 | 必须与取证工具 viewport、最终请求 UA / Client Hints 和 fingerprint baseline 一致 |
## 常见高风险 WebAPI 的 addon 覆盖规则
以下对象在真实项目中很容易被临时写成普通函数或普通对象,但只要进入补环境范围,就必须从第一版实现开始执行 addon-first、原型链、descriptor、访问器、`Symbol.toStringTag`、非法构造行为和 native-like 保护。
禁止把以下写法作为主路径:
```js
ctx.Blob = function Blob() {};
ctx.screen = { width: 1920, height: 1080 };
ctx.indexedDB = { open() {} };
ctx.URL.createObjectURL = function createObjectURL() {};
HTMLCanvasElement.prototype = { getContext() {} };
Object.assign(ctx, { history: { back() {} } });
ctx.TextEncoder = globalThis.TextEncoder;
```
正确方向:
1. 构造函数优先由 `addon.createProtoChains(descriptors)` 或 `addon.createNativeFunction(true, name, length, callback)` 创建。
2. 普通方法优先由 `addon.createNativeFunction(false, name, length, callback)` 创建,并通过 `Object.defineProperty` 挂到正确 prototype 或静态对象上。
3. getter / setter 优先由 `addon.createGetter` / `addon.createSetter` 创建。
4. 实例对象优先由 `createProtoChains` 的实例工厂创建,禁止直接 `{}`。
5. 内部状态优先 `addon.setPrivate/getPrivate`,fallback 才用 `WeakMap`。
6. 只有 addon 缺失、ABI 不兼容、API 调用失败或用户明确豁免时,才允许 `NativeProtect` / JS fallback,并记录到 notes、阶段报告和最终总结。
重点对象清单:
| 对象 / API | 必须补齐的最低结构 |
|---|---|
| `screen` / `Screen` / `ScreenOrientation` | `Screen` 构造函数、`Screen.prototype`、`screen instanceof Screen`、`Object.prototype.toString.call(screen)`、`width/height/availWidth/availHeight/colorDepth/pixelDepth` descriptor;`orientation` 需要 `ScreenOrientation` 原型链和 `type/angle` 访问器。 |
| `Blob` / `File` | 构造函数可 `new`,`Blob.prototype` / `File.prototype`、`size/type/name/lastModified` descriptor、`slice/text/arrayBuffer/stream` native-like 方法;不可直接返回普通对象。 |
| `FormData` | 构造函数可 `new`,`FormData.prototype.append/delete/get/getAll/has/set/entries/keys/values/forEach` 均用 addon native-like 方法,内部 entries 用私有状态保存。 |
| `Event` / `CustomEvent` / `MessageEvent` | 构造函数、继承链、`type/bubbles/cancelable/defaultPrevented/detail/data/origin/source` descriptor,方法 `preventDefault/stopPropagation/stopImmediatePropagation` native-like。 |
| `XMLHttpRequest` | `EventTarget → XMLHttpRequestEventTarget → XMLHttpRequest` 链路,构造函数可 `new`,`open/send/abort/setRequestHeader/getResponseHeader/getAllResponseHeaders` native-like,`readyState/status/responseText/onreadystatechange` descriptor。 |
| `indexedDB` / `IDBFactory` / `IDBOpenDBRequest` / `IDBKeyRange` | `indexedDB` 必须是 `IDBFactory` 实例;`open/deleteDatabase/cmp/databases` 用 addon native-like 方法;`IDBKeyRange.only/lowerBound/upperBound/bound` 为静态 native-like 方法;请求对象要有 `IDBRequest` / `IDBOpenDBRequest` 原型链。 |
| `URL.createObjectURL` / `URL.revokeObjectURL` | 作为 `URL` 静态方法用 descriptor 安装,函数体用 `createNativeFunction`,不要直接赋值普通 function;`URL` 本体不要盲目透传 Node 宿主构造器。 |
| `CSS.supports` / `CSS.escape` | `CSS` 不能是普通对象字面量;静态方法必须 native-like,必要时建立 `CSS` 命名空间对象的 `Symbol.toStringTag` 与 descriptor。 |
| `MutationObserver` / `IntersectionObserver` / `ResizeObserver` | 构造函数可 `new`,实例方法 `observe/unobserve/disconnect/takeRecords` native-like;回调与 records 可按 fixture 最小实现。 |
| `BroadcastChannel` / `MessageChannel` / `MessagePort` | 构造函数与 `MessagePort` 原型链,`postMessage/start/close/addEventListener/removeEventListener` native-like,`port1/port2` 是 `MessagePort` 实例。 |
| Canvas / WebGL 上下文 | `HTMLCanvasElement`、`CanvasRenderingContext2D`、`WebGLRenderingContext` / `WebGL2RenderingContext` 原型链,`getContext/toDataURL/getImageData/measureText/getParameter/readPixels` 等终端 API 用 addon native-like 包装并按指纹 fixture 回放。 |
| `AudioContext` / `OfflineAudioContext` | 构造函数、`BaseAudioContext` 链路、`createAnalyser/createOscillator/decodeAudioData/startRendering` native-like,指纹输出从采样值回放。 |
| `Image` / `HTMLImageElement` / `Worker` | 不要用普通 function 临时返回普通对象;需要 `HTMLElement → HTMLImageElement`、`EventTarget → Worker` 链路,`postMessage/terminate/addEventListener` native-like。 |
直接复用 Node 宿主对象也属于风险写法。`TextEncoder`、`TextDecoder`、`URL`、`URLSearchParams`、`fetch`、`Headers`、`Request`、`Response`、`WebAssembly`、Streams、Events、`crypto` 等如果参与目标检测,不能简单写成 `ctx.X = globalThis.X`;必须按浏览器样本和目标调用范围建立可控实现,或明确记录不可用原因。
### MutationObserver 行为最低要求
如果目标实际调用 `MutationObserver`,不能只补构造函数空壳。至少按 fixture 支持:
- `new MutationObserver(callback)` 的参数校验。
- `observe(target, options)`、`disconnect()`、`takeRecords()`。
- `attributes`、`attributeOldValue`、`attributeFilter` 的最小行为。
- DOM 属性变化后 callback 的异步触发顺序。
- `MutationRecord` 的 `type`、`target`、`attributeName`、`oldValue`。
- 参数错误时的错误类型、message 和 stack 首行按浏览器采样。
### 媒体能力最低要求
如果目标访问媒体能力,优先采样并回放:
- `HTMLMediaElement.prototype.canPlayType` 对 mp4、webm、ogg、hls 等 MIME / codecs 的返回值:`""`、`"maybe"`、`"probably"`。
- `navigator.mediaSession` 的对象结构、descriptor、`Object.prototype.toString` 和相关方法。
- `AudioContext` / `OfflineAudioContext` 指纹输出仍按真实浏览器采样值回放。
交付前必须运行:
```bash
node scripts/check_webapi_addon_coverage.js --case-dir case --markdown
```
该检查失败时,不得交付最终项目;应先把普通 WebAPI 函数、普通对象、prototype 对象字面量和宿主透传迁移为 addon-first 实现。
## fetch 与 XMLHttpRequest
补环境阶段默认不应让目标 JS 真的发网络请求。
策略:
- 如果目标 JS 只构造请求或计算签名,`fetch` / `XMLHttpRequest` 可以记录调用并返回离线 fixture。
- `fetch`、`Headers`、`Request`、`Response`、`XMLHttpRequest` 一旦进入补环境范围,仍要建立构造函数、原型链、方法、访问器、descriptor 和 native-like 行为。
- 不要直接透传 Node 宿主 `fetch` / undici,也不要把最终验证交给浏览器自动化。
- 如果必须访问网络,先确认用户授权和访问范围;最终真实请求应由已确认的 Node.js / Python TLS 指纹兼容客户端完成。
- 不要把补环境 runner 变成批量请求工具。
## 原型链
原型链不是最后补的附加项,而是每个对象进入补环境范围时的第一步。
以下内容默认要考虑:
```js
navigator instanceof Navigator
document instanceof Document
Object.getPrototypeOf(navigator)
navigator.constructor.name
Object.prototype.toString.call(navigator)
Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent')
Function.prototype.toString.call(Object.getOwnPropertyDescriptor(Navigator.prototype, 'userAgent').get)
```
基础链路示例:
```text
EventTarget → Node → Document → HTMLDocument
EventTarget → XMLHttpRequestEventTarget → XMLHttpRequest
HTMLElement → HTMLCanvasElement
```
优先用 addon `createProtoChains(descriptors)` 一次性定义构造函数、父级、实例工厂、`Symbol.toStringTag`、只读 prototype 和不可变原型设置。只有 addon 不可用时,才用 JS `Object.setPrototypeOf` / `Object.create` fallback,并用 `NativeProtect` 做函数和实例保护。
不要为了“完整”一次性补所有 DOM。只补 RuyiTrace / Node trace / fixtures / 目标检测证明目标 JS 会访问或依赖的部分;但已补的部分必须完整真实性。

View File

@@ -0,0 +1,813 @@
# 最终项目总结模板
项目完成后或准备最终交付时必须读取本文件并默认生成 `case/result/最终项目总结.md`。最终总结 / 项目复盘 / 任务报告不是按用户要求才生成,而是规范性硬性产物;只有用户明确要求不生成最终总结时才可跳过,并需在阶段输出中记录原因。总结报告必须使用 UTF-8 写入,避免 Windows PowerShell / cmd 默认编码把中文写成 “连续问号”。
## 编码硬规则
- 不要使用 `cmd > report.md` 或未指定编码的 PowerShell 重定向写中文 Markdown。
- 推荐使用本 Skill 提供的写入脚本:
```bash
node scripts/write_markdown_utf8.js --out case/result/最终项目总结.md --require-chinese-name --markdown < case/tmp/最终项目总结草稿.md
node scripts/write_markdown_utf8.js --input case/tmp/最终项目总结草稿.md --out case/result/最终项目总结.md --require-chinese-name --markdown
```
- 如果草稿内容已经出现大量 “连续问号” 且没有中文字符,先回到原始结论重新生成,不要覆盖最终报告。
- 报告中不得明文写入 Cookie、Authorization、localStorage、账号标识等敏感内容。
## 章节生成规则
- 必须保留“阶段报告索引”“native addon / NativeProtect 使用情况”“指纹基线一致性”“环境与指纹 API 调用回放明细”“Trace-runtime 可执行闭环”“XHR/fetch Session Bridge”“XHR/fetch 请求语义审计”“对象形状审计矩阵与私有状态泄露”“WebAPI 环境检测矩阵”“高强度环境检测覆盖矩阵”章节。
- 必须保留“动态资源保鲜与运行时刷新”“补环境框架选择与 Trace 复杂度评估”“最终请求 Session 请求链”“加密参数生成与样本复用检查”“代码质量与中文注释”“最终交付结构”“测试结果”“清理结果”章节。
- 必须写入 `case/result/最终项目总结.md`;除非用户明确要求不生成,否则 `check_final_artifact.js` 会默认检查该中文命名文件。
- 只有用户选择 ruyiPage + RuyiTrace、或用户提供 RuyiTrace NDJSON 日志时,才保留 RuyiTrace 章节;否则删除整章。
- 如果用户选择“不发真实请求,只输出本地参数”,TLS 请求验证和 Session 请求链章节需写明该选择和原因。
- 不要把临时 hook、trace、HAR、浏览器 Profile、截图路径写成最终交付物。
- 最终总结必须引用本 case 已生成的 `case/阶段报告/` 中文阶段报告,例如 `01-需求信息确认.md`、`03-请求样本与可疑参数确认.md`,并说明关键决策来自哪个阶段。
- “环境与指纹 API 调用回放明细”必须按类别分组,例如 `window / global`、`navigator`、`document / DOM`、`canvas`、`webgl` 等;每条记录必须精确到访问的属性、调用的方法、构造函数或 getter / setter,不得只写“补了 navigator / canvas”。
- “指纹值回放”必须记录值来源优先级执行情况:是否优先查看 Trace、Trace 是否未截断可用、Trace 缺失 / 未覆盖 / 4000 或 4096 长字段疑似截断时使用了哪个已确认取证工具补采、是否绑定同一 `baselineId`、是否存在 AI 猜值 / 默认值 / 静态推断进入最终 fixture;长指纹值必须记录完整 `valueLength`、完整 `sha256`、分片信息和 `truncated: false`。
- “WebAPI 环境检测矩阵”用于记录 Trace 命中后仍可能导致分支错误的浏览器行为 diff,包括 iframe / Window realm、Worker / MessagePort task queue、PerformanceObserver / PerformanceTimeline、DOM / CSSOM、EventTarget / DataCloneError、clock / timer 和 writer branch;必须写明浏览器 baseline、Node audit、矩阵状态和阻断项。
- “XHR/fetch Session Bridge”用于记录 JS 侧 `XMLHttpRequest` / `fetch` / `sendBeacon` 是 `offline-fixture` 诊断模式还是 `live-session-bridge` 真实请求模式;真实请求时必须写明是否复用同一 TLS Session、Python `curl_cffi` 是否由 `final.py` 持有 session 并服务 Node IPC bridge、Cookie / Set-Cookie / resource timing 是否同步。
- “Trace-runtime 可执行闭环”用于记录原始 Trace 是否已转换为机器契约,当前 runtime 是否在 no-send audit-only 模式逐项通过,必须包含 `traceSourceHash`、`contractHash`、`runtimeSourceHash`、baselineId、probeVersion 和 P0/P1 mismatch 数。
- “XHR/fetch 请求语义审计”用于记录浏览器与 Node network transcript 的逐条 diff,包括请求 actor、realm、navigation epoch、Header 顺序、body byte hash、status/responseURL、事件顺序、reload realm 清理和多余 Node 请求数。
- “对象形状审计矩阵与私有状态泄露”用于记录 `Object.keys`、`Object.getOwnPropertyNames`、`Reflect.ownKeys`、descriptor、`in`、prototype walk 等对象形状 baseline / audit,以及是否存在 `_` / `__` 自有属性泄露;发现泄露时不得交付。
- “高强度环境检测覆盖矩阵”用于记录异常模式、toString 多通道、DataCloneError、Error stack、属性枚举、原型链、MutationObserver、userAgentData、window.chrome、媒体能力、网络 Header / Client Hints 一致性、动态 JS 多版本回归等是否涉及、是否采样、是否已修复和遗留风险。高强度通用排查文档 `high-strength-browser-detection.md` 的入口页优先、指纹随机化风险、自动化痕迹、TLS / Header / Session 一致性和失败排查顺序也必须体现在本矩阵中。
## 模板
```markdown
# Web JS Node.js 补环境项目总结
生成时间:
任务范围:网页端 JS Node.js 补环境
## 1. 目标与边界
- 目标网站 URL:
- 目标页面:
- 目标 API:
- 请求方法:
- 目标加密参数:
- 参数位置:Query / Header / Body / Cookie
- 是否需要登录:
- 取证模式:ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定
- 最终请求 TLS 指纹兼容客户端:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / curl-cffi-node / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- 最终请求 Session 模式:启用 / 不发真实请求
- 补环境框架选择:不使用补环境框架(默认) / isolated-vm(随包魔改 xbs isolated-vm) / Node.js 内置 vm / jsEnv
- 明确排除:App / 移动端 / Windows / Native / 批量爬虫 / 绕过登录或验证码
## 2. 用户提供材料
- 成功请求样本:
- 响应样本:
- 已知 JS 文件:
- HAR / cURL:
- 浏览器真实输出 fixture:
- RuyiTrace NDJSON:有 / 无 / 未使用
- 其他说明:
## 3. 阶段报告索引
- `阶段报告/01-需求信息确认.md`:已生成 / 未生成
- `阶段报告/02-取证方案确认.md`:已生成 / 未生成 / 未涉及
- `阶段报告/03-请求样本与可疑参数确认.md`:已生成 / 未生成 / 未涉及
- `阶段报告/04-JS文件与入口定位.md`:已生成 / 未生成 / 未涉及
- `阶段报告/05-补环境前置分析.md`:已生成 / 未生成 / 未涉及
- `阶段报告/06-补环境实现记录.md`:已生成 / 未生成 / 未涉及
- `阶段报告/07-验证与清理记录.md`:已生成 / 未生成 / 未涉及
- 阶段报告缺失原因或用户豁免:
## 4. 前置校验结果
- 请求样本完整性:
- 加密参数是否存在:
- JS 文件可获取性:
- 是否需要用户手动登录:
- Cookie / token 状态:
- 临时文件清理状态:
## 5. 取证流程与证据来源
- 使用的取证工具:
- 工具安装 / 可用性检查:
- 抓包 / Hook / 断点策略:
- JS 文件收集来源:
- 关键调用栈来源:
- 可信度说明:
<!-- 未使用 RuyiTrace 时删除本章节 -->
## 6. RuyiTrace 日志使用情况
- NDJSON 路径或来源:
- 导入脚本:
- 命中的 WebAPI:
- 关键证据:`api` / `stack.file` / `line` / `col`
- 由日志推导出的环境依赖:
- 后续环境异常排查是否继续参考 NDJSON:
## 7. 动态资源保鲜与运行时刷新
- 是否存在动态 HTML / JS / challenge:有 / 无
- resource manifest:`case/notes/resource-manifest.json` / 未涉及
- 动态资源类型:入口 HTML / JS bundle / 动态 chunk / challenge JS / 403 风控页面 / 内联脚本 / 其他
- 动态性证据:Cache-Control / 短 TTL / 随机 query / hash 变化 / seed / nonce / Set-Cookie / 会话绑定 / 其他
- 是否影响最终参数生成:是 / 否
- 运行时刷新模块:`result/src/resources/fetch-runtime-resources.js` / 未涉及
- 最终入口刷新顺序:刷新当前资源 → 更新 Cookie / Storage / runtime context → 运行 signer → 发送请求
- 动态快照是否进入 result:否
- `check_dynamic_resources.js` 检查结果:通过 / 未执行,原因
## 8. 加密参数定位结论
- source:
- entry:
- builder:
- writer:
- 参数写入位置:
- 关键 JS 文件:
- 关键函数 / 类 / SDK:
- 未确认项:
## 9. Cookie / Storage / Token 分析
- 类型:登录态 / 设备 Cookie / 风控 Cookie / JS 生成 Cookie / 其他
- 生成或刷新链路:
- 是否纳入最终入口:
- 敏感字段脱敏说明:
## 10. 补环境框架选择与 Trace 复杂度评估
- 补环境框架选择:不使用补环境框架(默认) / isolated-vm(随包魔改 xbs isolated-vm) / Node.js 内置 vm / jsEnv
- 选择来源:用户明确选择 / 用户未选择,按默认不使用
- 是否发生二次提醒或切换:
- Trace 是否存在:有 / 无
- Trace 复杂度等级:低 / 中 / 高 / 未知
- 复杂度依据:WebAPI 类别 / 真实性检测 / 指纹 / 异步 / 状态依赖 / 调用栈分散度
- 重要说明:复杂度评估只用于补环境范围、风险点和优先级,不自动决定补环境框架
- 最终项目 runtime 文件:
- xbs isolated-vm 状态(仅选择 isolated-vm 时填写):未选择 / 已加载 / ABI 不兼容 / 平台缺失 / API 自检失败
- xbs isolated-vm 二进制来源:随 Skill 资产 / 用户提供替换产物 / 不适用
- Node 版本 / ABI / 平台:
- `window.xbs` API 自检:通过 / 未通过 / 不适用;缺失 API:
- 未选择框架时是否确认无 isolated-vm / vm / jsEnv runtime、`xbs-isolated-vm/` 或 `isolated_vm.node`:是 / 否 / 不适用
## 11. 补环境实现概览
- 运行隔离方式:不使用框架 / xbs isolated-vm / vm / jsEnv / 独立 Node 进程 / 显式隔离 global
- Level 1 基础环境:
- Level 2 指纹真实性:
- Level 3 目标 SDK 专用环境:
- Node 泄露阻断:
- 属性描述符 / 原型链 / 访问器处理:
- 函数、访问器、实例对象 toString 保护:
## 12. native addon / NativeProtect 使用情况
- native 能力检测结果:addon.node 可用 / xbs isolated-vm 可用 / 不可用 / 用户明确豁免(不得写“未使用”而不说明原因)
- native 加载路径:仅写相对路径或“随 Skill 资产加载”;选择 xbs isolated-vm 时写 `xbs-isolated-vm/<platform>-<arch>/isolated_vm.node`,不要写本机绝对路径
- native 导出 API:addon.node 导出 API 或 `window.xbs` 17 个 API:
- 实际使用的 native API:`createNativeFunction` / `createGetter` / `createSetter` / `createNativeObject` / `createProtoChains` / `createNativeCollection` / `createInterceptor` / `getMimeTypesAndPlugins` / `createUndetectable` / `setPrivate` / `getPrivate` / `hasPrivate` / `deletePrivate` / 注册表管理 / 其他;选择 xbs isolated-vm 时注明调用来源为 `window.xbs`
- NativeProtect fallback:未发生 / 已发生;选择 xbs isolated-vm 时如发生 fallback,需说明为什么没有使用 `window.xbs`
- fallback 原因:用户明确要求不使用 native 能力 / addon 或 xbs 缺失 / ABI 不兼容 / native API 调用失败 / 当前 API 不支持 / 其他
- `document.all` 处理:addon `createUndetectable` / xbs `createUndetectable` / JS 近似 fallback / 未涉及
- `navigator.plugins` / `navigator.mimeTypes` 处理:addon `getMimeTypesAndPlugins(config)` / xbs `getMimeTypesAndPlugins(config)` / JS fallback / 未涉及
- 集合对象处理:addon `createNativeCollection` / xbs `createNativeCollection` / JS fallback / 未涉及
- toString 保护覆盖:函数 / 构造函数 / getter / setter / 实例对象
- 阶段输出或 notes 中的补环境初始化加载记录、用户豁免或降级记录:
## 12.1 native 能力缺口闭环
- 是否发生 native 能力缺口:未发生 / 已发生
- 阻塞 API / 行为:
- 触发位置与证据:RuyiTrace / Hook / Node trace / 目标源码位置 / fixture
- 真实浏览器基线摘要:
- 纯 JS fallback 当前结果与差异:
- addon.node 当前结果与差异:
- xbs isolated-vm 当前结果与差异:未选择 isolated-vm / 已测试 / 不适用
- 建议新增或增强 native API:
- 最小行为测试用例位置:`case/notes/native-capability-gap-test.js` / `case/native-capability-gap/<name>.js` / 不适用
- 测试通过状态:已通过 / 未通过 / 等待用户更新 native 能力 / 用户接受临时 workaround / case 阻塞
- 用户选择与原因:扩展 addon.node / 扩展 xbs isolated-vm / 临时 workaround / 暂停 case / 其他
- 临时 workaround 风险说明:仅当前样本路径临时兼容 / 不涉及
## 13. 指纹基线一致性
- baseline 文件:`case/notes/fingerprint-baseline.json` / 未生成,原因:
- baselineId:
- 取证工具与 profile / seed:
- 是否复用同一 profile / seed / 代理 / 语言 / 时区 / UA / Client Hints / screen / WebGL:是 / 否
- 是否发生 baseline diff:否 / 是,diff 文件:`case/notes/fingerprint-baseline-diff.md`
- 冲突字段与处理方式:
- `fingerprint.fixture.json` 是否绑定同一 baselineId:是 / 否 / 不涉及
- 未涉及指纹采样时说明:未涉及 / 仅记录取证基线
## 14. 环境与指纹 API 调用回放明细
- 明细文件:`case/notes/webapi-call-replay-details.md` / `case/notes/webapi-call-replay-details.json` / 未生成,原因:
- 数据来源:RuyiTrace NDJSON / Node trace / Proxy trace / Hook / fixture / 静态分析 / 手动确认
- 值来源优先级:Trace 未截断值优先 / Trace 缺失或未覆盖已补采 / Trace 疑似截断已补采 / 未涉及具体值
- 禁止猜值检查:是否存在 AI 猜值、默认值、随机值、Node.js 模拟库结果进入最终回放:否 / 是,处理方式:
- 记录规则:按 API 类别分组;每条精确到属性访问、属性写入、getter、setter、方法调用、构造函数调用或静态方法调用
- 敏感值处理:Cookie / Authorization / token / localStorage 等不写明文,只写脱敏摘要
- 长字段处理:超过采集上限或 4000 / 4096 疑似截断的字段只写可见长度、最小长度、hash 和 `真实长度 unknown`;不得作为最终回放值,必须记录补采工具、完整 `valueLength`、完整 `sha256`、分片信息和 `truncated: false`,否则写明阻塞
- API 总数:
- 属性读取数量:
- 属性写入数量:
- getter / setter 数量:
- 方法调用数量:
- 构造函数调用数量:
- 指纹 API 数量:
- 未补齐 API 数量:
- fallback API 数量:
### 14.1 分类汇总
| 分类 | 是否涉及 | 典型 API | 属性读取 | 方法调用 | 构造调用 | 指纹 API | 主要证据来源 | 补齐状态 | 备注 |
|---|---|---|---:|---:|---:|---:|---|---|---|
| window / global | 未涉及 / 已涉及 | `window.innerWidth`、`globalThis` | | | | | | | |
| navigator | 未涉及 / 已涉及 | `navigator.userAgent`、`Navigator.prototype.webdriver` | | | | | | | |
| document / DOM | 未涉及 / 已涉及 | `document.createElement`、`document.cookie` | | | | | | | |
| location / history | 未涉及 / 已涉及 | `location.href`、`history.pushState` | | | | | | | |
| screen | 未涉及 / 已涉及 | `screen.width`、`screen.colorDepth` | | | | | | | |
| storage / cookie | 未涉及 / 已涉及 | `localStorage.getItem`、`document.cookie` | | | | | | | |
| crypto / random | 未涉及 / 已涉及 | `crypto.getRandomValues`、`Math.random` | | | | | | | |
| performance / timing | 未涉及 / 已涉及 | `performance.now`、`Date.now` | | | | | | | |
| network | 未涉及 / 已涉及 | `fetch`、`XMLHttpRequest.prototype.open` | | | | | | | |
| canvas | 未涉及 / 已涉及 | `HTMLCanvasElement.prototype.getContext`、`toDataURL` | | | | | | | |
| webgl | 未涉及 / 已涉及 | `WebGLRenderingContext.prototype.getParameter` | | | | | | | |
| webgpu | 未涉及 / 已涉及 | `navigator.gpu`、`GPUAdapter` | | | | | | | |
| audio | 未涉及 / 已涉及 | `AudioContext`、`AnalyserNode.prototype.getFloatFrequencyData` | | | | | | | |
| font / CSS / DOM geometry | 未涉及 / 已涉及 | `getComputedStyle`、`getBoundingClientRect` | | | | | | | |
| events / trusted input | 未涉及 / 已涉及 | `Event.prototype.isTrusted`、`MouseEvent` | | | | | | | |
| worker / wasm / postMessage | 未涉及 / 已涉及 | `Worker`、`WebAssembly.instantiate`、`postMessage` | | | | | | | |
| 其他 | 未涉及 / 已涉及 | | | | | | | | |
### 14.2 分类明细
> 未涉及的分类写“未涉及”;涉及的分类必须列出明细。来源为推断时必须标记“静态推断 / 未验证”,不得写成事实。
#### window / global
| API 路径 | 类型 | 所属对象 / 原型 | 来源证据 | 调用参数摘要 | 返回值 / 回放值摘要 | 补环境实现 | 真实性保护 | 验证结果 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| 未涉及 | | | | | | | | | |
#### navigator
| API 路径 | 类型 | 所属对象 / 原型 | 来源证据 | 调用参数摘要 | 返回值 / 回放值摘要 | 补环境实现 | 真实性保护 | 验证结果 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| `navigator.userAgent` | getter / 属性读取 | `Navigator.prototype` | RuyiTrace / fixture / Hook | 无 | UA 脱敏摘要 | addon / xbs getter / fallback | descriptor + getter toString | 通过 / 未验证 | 示例行,真实报告中按 case 填写 |
#### document / DOM
| API 路径 | 类型 | 所属对象 / 原型 | 来源证据 | 调用参数摘要 | 返回值 / 回放值摘要 | 补环境实现 | 真实性保护 | 验证结果 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| `Document.prototype.createElement` | 方法调用 | `Document.prototype` | RuyiTrace / Hook | `"canvas"` | `HTMLCanvasElement` 实例摘要 | addon / xbs native function | 原型链 + function toString | 通过 / 未验证 | 示例行,真实报告中按 case 填写 |
#### canvas
| API 路径 | 类型 | 所属对象 / 原型 | 来源证据 | 调用参数摘要 | 返回值 / 回放值摘要 | 补环境实现 | 真实性保护 | 验证结果 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| `HTMLCanvasElement.prototype.getContext` | 方法调用 | `HTMLCanvasElement.prototype` | RuyiTrace / Hook / fixture | `"2d"` | `CanvasRenderingContext2D` 摘要 | addon / xbs native function + fixture replay | native-like + 原型链 | 通过 / 未验证 | 示例行,真实报告中按 case 填写 |
#### webgl
| API 路径 | 类型 | 所属对象 / 原型 | 来源证据 | 调用参数摘要 | 返回值 / 回放值摘要 | 补环境实现 | 真实性保护 | 验证结果 | 备注 |
|---|---|---|---|---|---|---|---|---|---|
| `WebGLRenderingContext.prototype.getParameter` | 方法调用 | `WebGLRenderingContext.prototype` | RuyiTrace / Hook / fixture | 参数枚举值摘要 | 真实浏览器回放值摘要 | addon / xbs native function + fixture replay | native-like + 原型链 | 通过 / 未验证 | 示例行,真实报告中按 case 填写 |
#### 其他分类
按实际涉及的 `location / history`、`screen`、`storage / cookie`、`crypto / random`、`performance / timing`、`network`、`webgpu`、`audio`、`font / CSS / DOM geometry`、`events / trusted input`、`worker / wasm / postMessage` 或其他 WebAPI 分类补充同样表格。
## 14a. Trace-runtime 可执行闭环
- Trace contract:`case/notes/trace-runtime-contract.json` / 未生成,原因:
- Node runtime audit:`case/tmp/node-trace-runtime-audit.json` / 未生成,原因:
- diff:`case/notes/trace-runtime-diff.md` / 未生成,原因:
- baselineId:
- traceSourceHash:
- contractHash:
- runtimeSourceHash:
- probeVersion:
- audit-only 网络模式:no-send / 未执行
- audit-only 真实网络尝试数:
- P0/P1 contract 数 / matched / mismatch:
- `check_trace_runtime_conformance.js` 结果:
- `check_trace_api_coverage.js --require-runtime-closure` 结果:
## 15. XHR/fetch Session Bridge
- 是否涉及 XHR/fetch/sendBeacon:是 / 否,原因:
- 网络模式:offline-fixture / live-session-bridge / 未涉及
- 如果为 offline-fixture:是否已明确只用于诊断,且未宣称真实请求 / TLS 指纹已解决:是 / 否
- 如果为 live-session-bridge:是否复用最终请求同一 Session:是 / 否
- Session 持有者:final.js / final.py / 不适用
- TLS 客户端:CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet / 不发真实请求
- Python curl_cffi 场景:final.py 是否持有唯一 session 并服务 Node IPC bridge:是 / 否 / 不适用
- JS 网络模块:`result/src/env/network/xhr.js` / `result/src/env/network/fetch.js` / 其他 / 未涉及
- 请求客户端模块:`result/src/request/client.js` / `result/src/request/session_client.py` / 其他 / 未涉及
- Cookie / Set-Cookie / document.cookie 同步:
- `getResponseHeader("set-cookie")` / `Response.headers.get("set-cookie")` 语义:
- readyState / Promise / event 顺序验证:
- resource timing 是否涉及:
- `check_xhr_fetch_session_bridge.js` 结果:通过 / 未通过 / 未运行,原因:
## 15a. XHR/fetch 请求语义审计
- 浏览器 transcript:`case/fixtures/browser-network-transcript.ndjson` / 未生成,原因:
- Node no-send transcript:`case/tmp/node-network-transcript.ndjson` / 未生成,原因:
- 语义 audit:`case/tmp/xhr-fetch-semantics-audit.json` / 未生成,原因:
- baselineId:
- runtimeSourceHash:
- 请求 actor / realm / navigation epoch:
- Header 顺序与重复字段 diff:
- body byte length / SHA256 diff:
- status=0 / responseURL 语义:
- readyState / event / Promise 顺序:
- reload/navigation 后旧 realm timer/XHR/task 清理:
- Node 多余 XHR/fetch/navigation/resource 事件数:
- `check_xhr_fetch_semantics.js` 结果:
- `check_environment_closure.js --before-real-request` 结果:
## 16. 对象形状审计矩阵与私有状态泄露
- 是否触发对象形状审计:是 / 否,原因:
- 矩阵文件:`case/notes/object-shape-audit.md` / 未生成,原因:
- 浏览器对象形状 baseline:`case/fixtures/browser-object-shape-baseline.json` / 未生成,原因:
- Node 对象形状 audit:`case/tmp/node-object-shape-audit.json` / 未生成,原因:
- baselineId 一致性:一致 / 不一致 / 未涉及
- 私有状态实现:addon/xbs private API / WeakMap / native / 未涉及
- `_` / `__` 自有属性泄露:无 / 有,具体项:
- `Object.keys` / `Object.getOwnPropertyNames` / `Reflect.ownKeys` / descriptor / `in` / prototype walk 结果:
- `check_object_shape_audit.js` 结果:通过 / 未通过 / 未运行,原因:
| 对象 | 是否涉及 | 浏览器 baseline | Node audit | 状态 | 私有状态策略 | 遗留风险 |
|---|---|---|---|---|---|---|
| XMLHttpRequest instance | 未涉及 / 已涉及 | 已采样 / 未采样 | 已审计 / 未审计 | matched / mismatch / unknown | WeakMap / private API | |
| Headers / Response / Request | 未涉及 / 已涉及 | 已采样 / 未采样 | 已审计 / 未审计 | matched / mismatch / unknown | WeakMap / private API | |
| DOM / EventTarget / Storage / Performance | 未涉及 / 已涉及 | 已采样 / 未采样 | 已审计 / 未审计 | matched / mismatch / unknown | WeakMap / private API | |
## 17. WebAPI 环境检测矩阵
- 是否触发 WebAPI 环境检测矩阵:是 / 否,原因:
- 矩阵文件:`case/notes/webapi-env-detection-matrix.md` / 未生成,原因:
- 浏览器行为基线:`case/fixtures/browser-env-detection-baseline.json` / 未生成,原因:
- Node 行为审计:`case/tmp/node-env-detection-audit.json` / 未生成,原因:
- `check_webapi_env_detection_matrix.js` 结果:通过 / 未通过 / 未运行,原因:
- baselineId 一致性:一致 / 不一致 / 未涉及,说明:
- writer 分支差异:未涉及 / 真实浏览器 = continuation / reload writer / form writer / final writer;Node = continuation / reload writer / form writer / final writer;差异说明:
| 类别 | 是否触发 | 浏览器 baseline | Node audit | 状态 | 证据位置 | 处理结论 |
|---|---|---|---|---|---|---|
| iframe-realm | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| worker-task | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| performance-timeline | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| dom-cssom | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| event-clone-error | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| xhr-fetch-session-bridge | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| object-shape | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| private-state-leakage | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| clock-timer | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
| writer-branch | 否 / 是 | 已采样 / 未采样 | 已审计 / 未审计 | matched / accepted-diff / not-involved / needs-browser-baseline / needs-node-audit / mismatch / native-capability-gap / unknown | | |
- 阻断状态:无 / 有,具体项:
- 后续新增 WebAPI 是否属于矩阵遗漏:否 / 是,若是必须记录 `missed-from-webapi-env-matrix` 流程缺陷和修复动作:
## 18. 高强度环境检测覆盖矩阵
- 是否启用高强度环境行为 diff:是 / 否,原因:
- 浏览器基线文件:`case/fixtures/browser-env-baseline.json` / 未生成,原因:
- Node 补环境审计文件:`case/tmp/node-env-audit.json` / 未生成,原因:
- diff 记录:`case/notes/high-intensity-env-diff.md` / 未生成,原因:
- 是否涉及 JSVMP:否 / 是;处理方式:不主动分析源码,仅围绕环境调用、writer 和行为 diff 推进
- 动态风控 JS 多版本回归:未涉及 / 已执行;样本数量:
| 检测类别 | 是否涉及 | 浏览器基线 | Node 对比 | 修复状态 | 遗留风险 / 说明 |
|---|---|---|---|---|---|
| 异常模式指纹 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| Node 泄露与本机路径 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| toString 多通道 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | addon 优先;NativeProtect fallback 需覆盖多通道 |
| structuredClone / postMessage DataCloneError | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | NativeProtect fallback 需改写 clone error 泄露 |
| Error stack / eval stack | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| 属性描述符与枚举顺序 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| 原型链 walk 与 brand check | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| MutationObserver / Observer 行为 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| navigator.userAgentData / Client Hints | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| window.chrome | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| canPlayType / mediaSession | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| 网络 Header / Client Hints 一致性 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| 动态 JS 多版本回归 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | |
| TLS / JA3 / JA4 / Header 顺序 / Session 链 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | 最终请求与取证 baseline 一致 |
| 自动化 / CDP / Headless / isTrusted 风险 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | 取证阶段从一开始使用已确认高保真工具 |
| Permissions / Plugins / MimeTypes | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | addon-first,禁止普通数组 / 空对象主路径 |
| Canvas / WebGL / Audio / Speech / Fonts / DOM 几何随机化风险 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | 禁止 AI 猜值、默认值和每次随机化 |
| 入口 HTML / 前置 JS / 状态链 | 未涉及 / 已涉及 | 已采样 / 未采样 | 已对比 / 未对比 | 通过 / 部分通过 / 未修复 | 不得只围绕单 API cURL 判断 |
## 19. 指纹值回放
- 是否涉及指纹 API:
- 值来源优先级执行情况:已优先检查 Trace / 未选择 Trace / Trace 缺失 / Trace 未覆盖 / Trace 4000 或 4096 长字段疑似截断 / Trace baseline 冲突 / 不涉及
- Trace 未截断可用值:API、字段路径、证据文件、长度、hash:
- Trace 不可用或疑似截断时的补采工具:ruyiPage / Camoufox / CloakBrowser / 用户手动浏览器 / 其他;采样是否复用同一 `baselineId`:
- 长指纹值完整性:涉及的 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何 API、trace 可见长度、真实长度是否 unknown、是否已补采完整值、`valueLength`、`sha256`、分片数量、`truncated: false`:
- 真实浏览器采样来源:
- 禁止猜值检查:AI 猜值 / 默认值 / 随机值 / Node.js 模拟库结果是否进入最终 fixture:否 / 是,处理方式:
- 覆盖范围:Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何 / navigator / screen / window / document / 其他
- fixture 文件:
- 回放匹配规则:
- 缺失样本处理:
## 20. 加密参数生成与样本复用检查
- cURL / HAR / fixture 中的样本加密值是否只作为 expected:
- 最终入口如何生成参数:目标 JS 入口 / signer / 其他
- 是否发现硬编码样本值:否
- `check_final_artifact.js` 复用检查结果:
## 21. 代码质量与中文注释
- 是否已运行 `check_code_quality.js`:
- 模块拆分情况:
- isolated-vm 文件化模块情况:未选择 isolated-vm / 已按真实文件拆分 / 仍存在字符串脚本,原因:
- isolated-vm 环境模块清单:`src/env/browser-objects/navigator.js` / `document.js` / `window.js` / `src/env/fingerprint/canvas.js` / `webgl.js` / 其他
- 单文件 / 单函数复杂度:
- 中文注释覆盖:文件头 / WebAPI / getter / setter / addon-first / fallback / 指纹回放 / 加密入口
- 中文注释编码:UTF-8 正常 / 存在问题
- 中文注释是否包含问号、连续问号或乱码:否
- 修复过的可读性问题:
## 22. TLS 请求验证与 Session 请求链
- 是否发送真实请求:是 / 否
- 客户端:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / curl-cffi-node / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- Session 模式:已启用 / 不发真实请求
- Session client 文件:`result/src/request/client.js` / `result/src/request_client.py` / 不适用
- 请求链是否共用同一 session:是 / 否;包含:动态资源刷新 / Cookie 生成 / challenge / 目标 API
- Cookie jar 来源与更新点:
- 是否复用同一 UA / Client Hints / Accept-Language / Referer / Origin / proxy / fingerprint baseline:
- Session 销毁方式:close / exit / dispose / Cookie jar 清理 / 不适用
- TLS / JA3 / HTTP2 / Header 顺序策略:
- 请求次数与授权范围:
- 响应状态:
- 业务成功判断:
- 失败原因或限制:
## 23. 最终交付结构
- result 目录:
- 唯一入口:`final.js` / `final.py`
- 必要模块:
- 配置模板:
- 是否包含浏览器自动化代码:否
- 是否包含临时测试文件:否
- isolated-vm 是否包含大段 `String.raw` / `*_SCRIPT` 补环境源码:否 / 不适用
- 最终请求实现:Node.js / Python TLS 指纹兼容 Session 客户端 / 不发真实请求
## 24. 测试结果
- 信息完整性测试:
- addon-first 默认硬性测试:
- 代码质量与中文注释测试:
- UTF-8 Markdown / 默认最终总结生成测试:
- 补环境真实性检查:
- XHR/fetch Session Bridge 检查:
- 对象形状审计与私有状态泄露检查:
- WebAPI 环境检测矩阵检查:
- 高强度环境检测覆盖矩阵检查:
- 指纹基线一致性检查:
- Session 请求链检查:
- 最终产物检查:
- 清理 dry-run:
## 25. 清理结果
- 已删除的临时文件:
- 保留的必要文件:
- 敏感材料处理:
- 仍需用户确认处理的文件:
## 26. 风险与后续建议
- 未确认风险:
- 需要补充样本:
- 可优化点:
- 后续复测建议:
```

View File

@@ -0,0 +1,109 @@
# 指纹基线一致性规则
本文件在每个新 case 的浏览器取证、指纹采样、RuyiTrace / Camoufox / CloakBrowser 日志采集、生成 `fingerprint.fixture.json` 或发现指纹冲突时读取。目标是保证同一 case 内所有浏览器证据来自同一套稳定指纹基线,避免多次随机化导致 navigator、screen、WebGL、语言、时区、UA、Client Hints 等互相冲突。
## 硬性规则
- 同一 case 必须维护一个指纹基线文件:`case/notes/fingerprint-baseline.json`。
- 第一次成功取证后立即固化 `baselineId`,后续抓包、Hook、RuyiTrace、指纹采样、fixture 对比、动态资源刷新取证都必须复用该基线。
- 禁止每次打开 ruyiPage、Camoufox、CloakBrowser 时重新随机一套指纹;不得把不同浏览器、不同 Profile、不同代理地区或不同随机 seed 的样本混用。
- ruyiPage 必须复用同一 case 的 `base_dir`、`userdir`、`smart_fingerprint` 输出和定制 Firefox runtime;RuyiTrace 若不是同一浏览器 / profile,必须先做基线 diff。
- Camoufox / CloakBrowser 必须优先使用持久 profile、固定 seed 或固定配置;只要工具支持随机指纹,就必须在第一次成功取证后把实际值固化,并在后续启动时复用,不得继续随机。
- 指纹 fixture、RuyiTrace 摘要、Hook 采样、最终总结都必须记录 `baselineId`。缺少 `baselineId` 或 baseline 文件时,涉及指纹的 case 不能进入最终交付。
- 如果 UA、Client Hints、language、timezone、platform、viewport、screen、DPR、WebGL vendor/renderer、Canvas、Audio、字体、DOM 几何、proxy/IP 地区之间出现冲突,暂停并重新采样或让用户确认切换基线;不得自动合并。
- 用户明确更换代理、地区、Profile、浏览器工具或登录态容器时,必须生成新的 `baselineId`,旧样本只能作为历史证据,不能和新基线混用。
## 基线文件建议结构
```json
{
"version": 1,
"baselineId": "fp-20260627-001",
"caseId": "",
"mode": "ruyiPage + RuyiTrace / Camoufox / CloakBrowser / 用户手动取证",
"tool": {
"name": "ruyiPage",
"profile": "case/tmp/browser-profile",
"fingerprintSeed": "",
"browserPathDigest": "只写相对路径或脱敏摘要"
},
"network": {
"proxyCountry": "",
"ipCountry": "",
"timezone": "Asia/Shanghai",
"locale": "zh-CN"
},
"navigator": {
"userAgent": "",
"language": "zh-CN",
"languages": ["zh-CN", "zh"],
"platform": "",
"hardwareConcurrency": 0,
"deviceMemory": 0
},
"clientHints": {},
"screen": {},
"viewport": {},
"webgl": {},
"canvas": { "source": "fixture" },
"audio": {},
"font": {},
"createdAt": "",
"locked": true
}
```
## 采样与 diff 流程
1. 用户确认取证模式后,先准备本 case 专用 profile / userdir / fingerprint cache 目录。
2. 第一次成功打开目标页并完成基础自检后,采集 baseline,写入 `case/notes/fingerprint-baseline.json`。
3. 后续每次浏览器取证前读取 baseline;工具启动参数、profile、代理、locale、timezone、viewport 必须与 baseline 一致。
4. 多工具采样或重新启动浏览器后,先对比 baseline:
- 一致:继续采样。
- 不一致:写入 `case/notes/fingerprint-baseline-diff.md`,暂停并说明冲突字段。
5. 指纹 fixture 写入 `baselineId`,并在 `source` 中记录采样工具、页面、UA、时区、语言、采样时间。
6. 最终总结记录 baseline 文件、`baselineId`、是否发生 diff、冲突字段和处理方式。
## 冲突处理
## 用户 cURL 样本与取证 baseline 冲突处理
用户提供的 cURL / HAR 可能来自 Chrome / Chromium,但实际取证可能使用 ruyiPage 定制 Firefox、用户手动 Firefox、Camoufox 或 CloakBrowser。两者浏览器族、UA、Client Hints、TLS / HTTP2 指纹、Header 顺序和 Cookie 会话来源不一致时,不能把它们拼接成同一个最终 baseline。
硬性规则:
- 解析 cURL / HAR 时先识别“用户样本浏览器族”:Chrome / Chromium / Firefox / Safari / Unknown,并记录识别依据,例如 `User-Agent`、`sec-ch-ua`、`sec-ch-ua-platform`、HAR 浏览器信息、TLS / HTTP2 线索。
- 第一次成功取证并生成 `case/notes/fingerprint-baseline.json` 后,识别“取证 baseline 浏览器族”,并与用户 cURL 样本浏览器族对比。
- 如果用户 cURL 是 Chrome / Chromium,而取证 baseline 是 Firefox,默认以取证 baseline 为最终基线;Firefox baseline 下不得沿用 Chrome 的 `sec-ch-ua`、Chrome UA、Chrome TLS profile 或 Chrome HTTP/2 行为。
- cURL 在冲突场景中只作为请求结构线索:目标 URL、方法、Query / Header / Body / Cookie 字段名、参数位置、可疑加密参数、业务字段和历史响应现象;不得把 cURL 中已有的动态加密参数值、冲突 Cookie、冲突 Header 或冲突网络指纹固定到最终产物。
- 最终补环境值、指纹 fixture、Header、TLS / HTTP2、Cookie / Storage 链路和 Session 请求链必须以当前已确认取证 baseline 为准。
- 发现冲突时写入 `case/notes/sample-baseline-conflict.md`,内容至少包括:用户 cURL 浏览器族、取证 baseline 浏览器族、冲突字段、证据来源、默认处理策略、用户是否要求重新取证或接受风险。
- 如果用户坚持以 cURL 的浏览器族为准,必须暂停并建议改用同浏览器族取证工具重新取证,或让用户提供同浏览器族、同 profile、同代理、同登录态的 HAR / cURL;不得自动混用。
示例:
```text
用户 cURL:Chrome/137 + sec-ch-ua + Chrome 风格请求头
实际取证:ruyiPage Firefox 151 baseline
处理结果:目标 API、参数位置、Cookie 字段名和可疑加密参数可参考 cURL;最终 navigator.userAgent、Header、Client Hints、TLS / HTTP2、Cookie 链路和指纹 fixture 均以 Firefox 取证样本为准,并删除 / 不使用 Chrome UA-CH。
```
发现冲突时优先处理顺序:
1. 判断是否更换了代理、地区、profile 或浏览器工具。
2. 如果是误切换,回到原 baseline 的 profile / seed / 配置重新采样。
3. 如果用户确认切换,生成新 baseline,并标记旧 fixture 不再用于当前最终交付。
4. 如果无法确认真实一致性,阻塞最终交付,不要把多个 baseline 的指纹值拼接进同一个 env。
## 输出记录
阶段报告和最终总结至少记录:
- `baselineId`。
- baseline 文件路径。
- 取证工具、profile / seed / 配置来源。
- UA / Client Hints / locale / timezone / viewport / screen / WebGL / Canvas / Audio / 字体 / DOM 几何是否一致。
- 是否发生 baseline diff,冲突字段与处理方式。
- 用户 cURL 样本浏览器族、取证 baseline 浏览器族、是否发生样本基线冲突,以及 `case/notes/sample-baseline-conflict.md` 的处理结论。
- `fingerprint.fixture.json` 是否绑定同一 `baselineId`。

View File

@@ -0,0 +1,258 @@
# 浏览器指纹值采样与回放指南
当目标 JS 访问 Canvas、WebGL、WebGPU、Audio、字体、DOM 几何、CSS 渲染等浏览器指纹 API,或 Node.js 中第三方库模拟结果与真实浏览器不一致时读取本文件。
## 核心原则
补环境的目标是让目标网页原始 JS 在 Node.js 中看到与真实浏览器一致的结果,而不是在 Node.js 中复刻完整浏览器渲染管线。
必须遵循:
- **指纹基线先固定**:采样前先读取 `fingerprint-baseline-consistency.md`,确认 `case/notes/fingerprint-baseline.json` 和 `baselineId` 已创建;所有采样必须来自同一 profile / seed / 代理 / 语言 / 时区 / UA / Client Hints / screen / WebGL 基线。
- **Trace 未截断值优先**:如果用户选择 / 提供了 RuyiTrace 或其他 trace 日志,先使用 trace 中未截断、与当前 `baselineId` 一致、能确认完整性的真实浏览器值。
- **Trace 不可用再采样**:未选择 trace、trace 缺失、trace 未覆盖目标路径、trace 字符串 / 序列化值达到或接近 4000 / 4096 字符疑似截断、真实长度为 `unknown`、或 trace baseline 冲突时,必须使用用户当前确认的取证工具采集真实浏览器最终返回值、调用参数、调用栈和调用顺序。
- **禁止 AI 猜指纹值**:AI 经验、静态分析、默认值、随机值、mock 值、Node.js / jsdom / node-canvas / headless-gl 的结果不能作为最终指纹回放值,只能辅助定位和设计采样点。
- **终端 API 值回放优先**:在 Node.js 中拦截目标真正读取指纹结果的 API,并返回采样值。
- **不真实模拟渲染过程**:不要为了 Canvas / WebGL / 字体 / DOM 几何去强行复现 Skia、GPU、字体栅格化、抗锯齿、颜色管理或浏览器布局。
- **不退回自动化作为最终方案**:Node.js 无法真实渲染时,不得建议把最终生成参数或最终验证改成浏览器自动化;自动化只允许用于前置取证和采样。
- **缺样本即显式阻塞**:没有采到的指纹值不要乱猜,不要静默返回空值;应提示补采样本或降级说明。
- **长指纹值不得用截断片段**:Canvas dataURL、WebGL readPixels、WebGPU adapter / limits / features、Audio channel data、字体 / DOM 几何批量结果等可能远超 4000 / 4096;只要来自会截断的日志字段,就必须视为定位证据而不是最终值,最终 fixture 必须来自同一 baseline 下的真实浏览器完整采样。
错误方向:
```text
node-canvas / headless-gl / 自己实现 DOM 布局 → 结果与真实浏览器仍不一致 → 建议最终用自动化浏览器
```
正确方向:
```text
真实浏览器采集终端 API 返回值 → Node.js 按调用特征匹配并回放 → fixtures 多样本验证
```
## 指纹值来源优先级
采集任何具体 WebAPI / 指纹值时,必须按以下顺序判断,不能跳级猜值:
1. **Trace 未截断真实值**:用户已选择 RuyiTrace / 提供 NDJSON,且字段未达到截断阈值、日志覆盖当前业务路径、调用栈与目标参数生成链路相关、`baselineId` 与当前 case 一致。
2. **同一取证工具补采完整值**:Trace 未选择、缺失、未覆盖、疑似截断或 baseline 不一致时,使用用户已确认的取证模式采样,例如 ruyiPage、Camoufox、CloakBrowser、用户手动浏览器或对应 MCP / Hook。采样必须复用同一 profile / seed / 代理 / locale / timezone / UA / Client Hints / screen / WebGL 基线。
3. **用户提供的真实浏览器材料**:用户明确提供的 HAR、Hook 输出、浏览器控制台输出、完整响应或截图可作为证据,但必须记录来源、时间、页面、baseline 信息和完整性。
4. **静态分析 / AI 推断仅辅助定位**:只能用于判断应该采样哪个 API、哪个调用参数、哪个 JS 位置,不能直接写入最终 fixture 的 `result`。
如果 trace 中字符串达到或接近 4000 / 4096 字符,必须标记为“疑似截断”:`truncated: true`、`realLength: "unknown"`、`visibleLength`、`sha256OfVisible`。该值不得作为最终回放值;必须用同一 case 已确认取证工具补采完整值。
### 长指纹值截断硬门禁
以下规则是硬性要求:
1. RuyiTrace / NDJSON / 普通日志中的长字段达到或接近 4000 / 4096 时,只能说明“至少有这些可见字符”,不能说明真实长度就是 4000 / 4096。
2. 不能把 `visiblePreview`、`visibleSha256`、`sha256OfVisible`、日志可见片段、`...<clipped>`、`...<truncated>`、`__ruyiTraceLongString__` 对象或被日志 sanitization 包装后的字段写成最终 `result`。
3. Canvas `toDataURL`、Canvas `getImageData`、WebGL `readPixels`、WebGPU `adapter.info / limits / features`、Audio `getChannelData / startRendering`、字体和 DOM 几何批量结果,如果 trace 中达到 4000 / 4096,必须由已确认取证工具重新采样完整浏览器真实值。
4. 完整采样值可以直接存储,也可以使用分片格式存储;分片格式必须记录 `encoding: "string-chunks"`、`valueLength`、`chunkSize`、`chunks`、`truncated: false` 和完整值 hash。
5. 没有完整真实值时,阻塞最终交付;不要把 4096 字符当成“刚好完整”,也不要用 Node.js 渲染库补一个近似值。
6. 数值型返回值为 4096 不一定是截断,例如 WebGL `MAX_TEXTURE_SIZE`;本规则针对日志中的长字符串、长数组、base64、序列化对象和二进制编码结果。
每个最终写入 fixture 的样本都要能回答:
- 值来自哪里:RuyiTrace 未截断值 / ruyiPage 采样 / Camoufox 采样 / CloakBrowser 采样 / 用户手动浏览器材料 / 其他真实浏览器证据。
- 是否完整:`truncated: false`,并记录完整 `valueLength` 与 `sha256`;疑似截断值不得用于最终回放。
- 属于哪个基线:`baselineId` 必须与 `case/notes/fingerprint-baseline.json` 一致。
- 为什么不用 Trace:如果没有使用 trace,写明未选择、缺失、未覆盖、截断、baseline 冲突或用户明确降级。
## 过程 API 与终端 API
### 过程 API
过程 API 只描述绘制、布局或状态构造过程。补环境时可记录必要状态,但通常不需要真实执行渲染。
常见过程 API:
- Canvas 2D:`fillText`、`strokeText`、`drawImage`、`arc`、`rect`、`fillRect`、`putImageData`。
- WebGL:`createShader`、`shaderSource`、`compileShader`、`attachShader`、`linkProgram`、`bufferData`。
- DOM:`appendChild`、`insertBefore`、`removeChild`、`style.xxx = ...`、`className = ...`。
- 字体:设置 `ctx.font`、创建 span/div、设置 CSS。
### 终端 API
终端 API 是目标 JS 真正读取指纹结果的位置。这里必须优先回放真实浏览器采样值。
| 类别 | 终端 API |
|---|---|
| Canvas | `HTMLCanvasElement.prototype.toDataURL`、`toBlob`、`CanvasRenderingContext2D.prototype.getImageData`、`measureText` |
| WebGL | `getParameter`、`getSupportedExtensions`、`getExtension`、`getShaderPrecisionFormat`、`readPixels`、`getContextAttributes` |
| WebGPU | `navigator.gpu.requestAdapter`、`adapter.info`、`adapter.limits`、`adapter.features`、`requestDevice` |
| Audio | `OfflineAudioContext.startRendering`、`AudioBuffer.getChannelData`、`AnalyserNode.getFloatFrequencyData` |
| 字体 | `measureText`、`document.fonts.check`、DOM 字体探测元素的宽高 |
| DOM 几何 | `getBoundingClientRect`、`getClientRects`、`offsetWidth`、`offsetHeight`、`scrollWidth`、`scrollHeight`、`clientWidth`、`clientHeight` |
| CSS / 媒体 | `getComputedStyle`、`matchMedia`、`CSS.supports` |
| Speech | `speechSynthesis.getVoices`、`SpeechSynthesisUtterance` 属性、voices 列表顺序与语言 |
| Permissions | `navigator.permissions.query` 的 Promise、`PermissionStatus.state`、事件属性、错误模式 |
| Plugins / MimeTypes | `navigator.plugins`、`navigator.mimeTypes`、`PluginArray` / `MimeTypeArray` 长度、索引、命名属性和描述符 |
| MediaDevices / WebRTC | `navigator.mediaDevices.enumerateDevices`、`getUserMedia` 错误模式、`RTCPeerConnection` 候选信息摘要 |
## 高强度指纹补充与随机化风险
高强度检测中,Canvas / WebGL / Audio / Fonts / DOM geometry / Permissions / Plugins 等结果被随机化或伪装不一致,可能比固定真实值更容易暴露。补环境时必须遵循:
- 不要使用 `Math.random()`、时间戳、机器默认值或 AI 经验生成指纹返回值。
- 不要把 `node-canvas`、`headless-gl`、`jsdom`、系统字体枚举结果直接当成最终浏览器样本。
- 不要每次取证重新生成一套 UA、语言、时区、screen、WebGL、Canvas、Audio 或字体结果;同一 case 只能使用同一 `baselineId`。
- 对 `navigator.plugins` / `navigator.mimeTypes`,addon 可用时优先 `getMimeTypesAndPlugins(config)`;不要用普通数组或空数组临时糊弄。
- 对 Permissions / Speech / MediaDevices 等异步 API,要采样 Promise 行为、错误类型、事件属性、返回对象原型链和 `toStringTag`,不能只补一个同步值。
- 如果 trace 中长字段接近 4000 / 4096 字符,按截断处理并补采完整值,不能把可见片段作为最终 fixture;完整值必须来自同一 baseline 下的真实浏览器采样。
## 采样格式
推荐把指纹样本放入 `case/fixtures/fingerprint.fixture.json`,或合并进业务 fixture 的 `fingerprint` 字段。fixture 必须包含 `baselineId`,并与 `case/notes/fingerprint-baseline.json` 一致。
```json
{
"version": 1,
"baselineId": "fp-20260627-001",
"source": {
"baselineId": "fp-20260627-001",
"mode": "ruyiPage + RuyiTrace / Camoufox / CloakBrowser / 手动取证",
"valuePriority": "trace-untruncated-first",
"traceStatus": "unused / used-untruncated / missing / not-covered / truncated / baseline-conflict",
"traceFallbackReason": "trace 字段疑似截断,已使用 ruyiPage 在同一 baseline 下补采完整值",
"pageUrl": "https://example.com/page",
"userAgent": "",
"timezone": "Asia/Shanghai",
"locale": "zh-CN",
"capturedAt": ""
},
"canvas": {
"toDataURL": [
{
"match": { "width": 300, "height": 150, "type": "image/png", "stackIncludes": "fingerprint.js" },
"source": { "capturedBy": "ruyiPage", "traceStatus": "truncated", "baselineId": "fp-20260627-001", "truncated": false, "valueLength": 123456, "sha256": "..." },
"result": { "encoding": "string-chunks", "valueLength": 123456, "chunkSize": 3000, "chunks": ["data:image/png;base64,..."], "truncated": false, "sha256": "..." }
}
],
"measureText": [
{
"match": { "text": "abcdef", "font": "16px Arial" },
"result": { "width": 123.45, "actualBoundingBoxLeft": 0, "actualBoundingBoxRight": 123.45 }
}
],
"getImageData": [
{
"match": { "sx": 0, "sy": 0, "sw": 16, "sh": 16 },
"result": { "width": 16, "height": 16, "dataBase64": "..." }
}
]
},
"webgl": {
"getParameter": [
{ "match": { "pname": 37445 }, "result": "Google Inc. (NVIDIA)" },
{ "match": { "pname": 37446 }, "result": "ANGLE (...)" }
],
"getSupportedExtensions": { "result": ["ANGLE_instanced_arrays", "WEBGL_debug_renderer_info"] },
"readPixels": [
{ "match": { "x": 0, "y": 0, "width": 16, "height": 16 }, "result": { "dataBase64": "..." } }
]
},
"domGeometry": {
"getBoundingClientRect": [
{ "match": { "selector": "#fp" }, "result": { "x": 0, "y": 0, "width": 100, "height": 20, "top": 0, "left": 0, "right": 100, "bottom": 20 } }
],
"offset": [
{ "match": { "selector": "#font-probe" }, "result": { "offsetWidth": 123, "offsetHeight": 19 } }
]
}
}
```
注意:
- `baselineId` 是硬性字段;缺失或与 baseline 文件不一致时,不能把该 fixture 用于最终交付。
- `source` / `capturedBy` / `traceStatus` / `truncated` 是硬性溯源字段;不能只写“AI 推断”“默认值”“静态分析得到”。
- 如果样本继承全局 `source`,短值可以接受;但长字符串、二进制、Canvas / WebGL / WebGPU / Audio 结果必须写入每条记录自己的 `source`、`valueLength`、`sha256` 和 `truncated: false`,必要时使用 `encoding: "string-chunks"` 分片保存。
- `match` 是调用特征,不是安全边界;只用于选择回放样本。
- `stackIncludes` 可记录目标 JS 文件名、函数名或调用栈片段。
- 对 ArrayBuffer / Uint8ClampedArray / Float32Array 等二进制结果使用 base64 存储。
- 真实 Cookie、账号标识、Authorization 不要写入指纹 fixture。
## 回放匹配顺序
Node.js 交付环境中匹配指纹样本时,按以下顺序:
1. API 名称。
2. 显式参数,例如 `type`、`quality`、`pname`、`text`、`font`、`x/y/w/h`。
3. 对象状态,例如 canvas `width/height`、当前 `ctx.font`、WebGL context 类型。
4. 调用栈特征,例如 `stackIncludes`。
5. 调用顺序,仅作为最后兜底。
如果无法唯一匹配:
- 优先报错并输出缺失的 call key。
- 不要返回随机值。
- 不要为了通过而返回空字符串、空数组或固定默认值。
## 禁止与允许
禁止:
- 最终项目引入 `playwright`、`puppeteer`、`selenium`、`cloakbrowser`、`ruyipage` 来计算指纹。
- 最终项目用自动化打开页面生成指纹,再把结果传回 Node.js。
- 因 `node-canvas` / `headless-gl` / `jsdom` 输出不一致而建议改用自动化作为最终方案。
- 未采样时静默伪造 Canvas / WebGL / DOM 几何结果。
允许:
- 前置取证阶段用用户确认的工具采集指纹 API 参数、返回值和调用栈。
- Node.js 交付环境中用 `assets/env-modules/fingerprint-env.js` 模板回放采样值。
- 对不影响目标参数的 API 做明确降级,但必须在 notes 中说明证据等级。
- 对目标未访问的 API 不补。
## 真实性保护要求
即使终端 API 只是返回采样值,也要保持浏览器对象真实性:
- 用 `Object.defineProperty` 定义属性。
- 建立 `HTMLCanvasElement`、`CanvasRenderingContext2D`、`WebGLRenderingContext`、`DOMRect` 等必要原型链。
- 保护方法、getter、setter 的 `Function.prototype.toString`。
- 设置实例对象 `Symbol.toStringTag` / `Object.prototype.toString`。
- 按浏览器行为抛出 `TypeError` 或 `Illegal invocation`,不要暴露 Node.js 错误。
## 推荐执行流程
1. 先读取 `fingerprint-baseline-consistency.md`,确认或创建 `case/notes/fingerprint-baseline.json` 与 `baselineId`。
2. 用 RuyiTrace NDJSON 或 Hook 找到目标 JS 是否访问指纹 API;如果用户选择 / 提供 RuyiTrace,先判断 trace 中是否已有未截断真实值可用。
3. 如果访问的是 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何,且 trace 没有未截断完整值,或 trace 字段达到 / 接近 4000 / 4096,再生成采样 Hook;采样 Hook 必须完整保存长值或分片保存,不得裁剪为 4096:
```bash
node scripts/generate_fingerprint_hook.js --types canvas,webgl,dom-geometry --out case/hooks/fingerprint-hook.js
```
4. 在用户确认的取证工具中运行采样 Hook,触发最少量业务动作;复制结果前必须执行 `await window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__()`,确保所有分片长值写入完整 `valueLength`、`sha256`、`chunks` 与 `truncated:false`。
5. 把 finalize 后的采样结果保存为 `case/fixtures/fingerprint.fixture.json`;禁止保存 `clip` 片段、`visiblePreview`、`sha256OfVisible`、`__ruyiTraceLongString__` 或任何真实长度 unknown 的 trace 可见片段。
6. 运行覆盖检查:
```bash
node scripts/check_fingerprint_fixture.js --fixture case/fixtures/fingerprint.fixture.json --require canvas,webgl --markdown
```
7. 把 `assets/env-modules/fingerprint-env.js` 复制到最终项目 `result/src/env/`,按 fixture 接入。
8. 对目标 JS 运行 fixtures,若缺样本,补采样而不是改用自动化。
9. 交付前运行:
```bash
node scripts/check_env_realism.js --case-dir case --require-fingerprint-fixture --markdown
node scripts/check_final_artifact.js --case-dir case --markdown
```
## 输出记录要求
补环境报告或 notes 中至少记录:
- `baselineId`、baseline 文件路径和是否发生 baseline diff。
- 哪些指纹 API 被目标 JS 访问。
- 哪些值来自 RuyiTrace 未截断日志,哪些值因为 trace 缺失 / 未覆盖 / 截断 / baseline 冲突而改用自动化取证工具采样。
- 哪些值来自 ruyiPage / Camoufox / CloakBrowser / Hook / 用户手动浏览器材料,并记录采样工具、时间、完整长度和 hash。
- 是否存在疑似截断字段;如果存在,必须写明补采状态,不能把可见长度当成真实长度,也不能把 trace 中 4000 / 4096 字符片段当成最终指纹值。
- 哪些 API 做了近似降级及原因。
- 缺失样本是否阻塞。
- 最终项目是否完全不包含浏览器自动化代码。

View File

@@ -0,0 +1,158 @@
# fixtures 样本验证指南
当需要判断补环境结果是否可交付时读取本文件。
## 完成标准
补环境完成标准不是“不报错”,而是:
```text
Node.js 中生成的目标加密参数 == 浏览器真实请求中的目标加密参数
```
若目标参数包含时间戳、随机数、服务端 token 或一次性 nonce,应先固定或记录这些依赖,否则不能用单次输出直接判断。
发起最终真实请求前必须先通过 fixtures。若普通 HTTP 客户端疑似因 TLS 指纹、ALPN、HTTP/2 或 JA3/JA4 差异失败,再读取 `tls-request-validation.md`,并让用户确认是否使用 CycleTLS / curl_cffi / cffi_curl / cyCronet 做少量验证请求。
## fixture 文件格式
推荐每个样本保存为独立 JSON:
```json
{
"name": "sample-001",
"pageUrl": "https://example.com/search?q=test",
"apiUrl": "https://example.com/api/search",
"method": "POST",
"param": "sign",
"paramLocation": "Header",
"browser": {
"userAgent": "Mozilla/5.0 ...",
"language": "zh-CN",
"timezone": "Asia/Shanghai",
"screen": { "width": 1920, "height": 1080 }
},
"request": {
"query": {},
"headers": { "content-type": "application/json" },
"body": { "keyword": "test" },
"cookies": {}
},
"runtime": {
"now": 1710000000000,
"performanceNow": 123.456,
"randomBytes": [1, 2, 3, 4]
},
"resources": {
"capturedAt": "2026-06-23T00:00:00.000Z",
"htmlSha256": "入口 HTML body hash",
"jsSha256": "关键 JS / challenge body hash",
"resourceManifestRef": "case/notes/resource-manifest.json",
"challengeSeed": "脱敏后的 seed / nonce / config 摘要"
},
"entry": "window.makeSign",
"args": [
{ "keyword": "test" }
],
"expected": {
"sign": "browser-real-sign-value"
}
}
```
最少字段:
```json
{
"entry": "window.makeSign",
"args": ["/api/demo"],
"expected": { "sign": "abc123" }
}
```
## 运行与对比
探测运行:
```bash
node scripts/run_with_trace.js \
--target case/js/original/app.js \
--entry window.makeSign \
--fixture case/fixtures/sample-001.json \
--output case/tmp/node-output.json \
--trace case/tmp/env-trace.jsonl \
--summary case/tmp/missing-env.json
```
对比:
```bash
node scripts/compare_fixture.js \
--fixture case/fixtures/sample-001.json \
--actual case/tmp/node-output.json \
--field sign \
--markdown
```
## 多样本要求
至少建议准备:
| 样本 | 用途 |
|---|---|
| 固定请求重复样本 | 判断随机数 / 时间依赖 |
| Body 改变样本 | 判断请求体是否参与签名 |
| URL 改变样本 | 判断 path / query 是否参与签名 |
| Cookie / token 改变样本 | 判断登录态是否参与签名 |
| 不同 UA / 语言 / 时区样本 | 判断浏览器指纹是否参与签名 |
如果只有一份样本,只能说明“当前样本通过”,不能说明补环境已稳定。
## 动态资源版本记录
如果样本来自动态 HTML、动态 JS、动态 chunk、challenge JS 或 403 / 风控页面,fixture 必须记录资源版本信息:`capturedAt`、入口 `htmlSha256`、关键 `jsSha256`、`resourceManifestRef`、`challengeSeed` 或等价 seed / nonce / config 摘要。
当当前资源 hash 与 fixture 不一致时,不要立即判断补环境失败;先读取 `references/dynamic-resource-freshness.md`,检查资源是否已过期、HTML seed 是否变化、challenge JS 是否更新、Cookie / Set-Cookie 是否刷新,再决定是刷新资源、补采样本还是继续补 WebAPI。旧资源只作为 expected 证据,不得固定到最终 signer。
## 输出报告模板
```markdown
## fixtures 验证结果
- 样本总数:
- 通过数量:
- 失败数量:
- 目标参数:
- 是否固定时间:
- 是否固定随机数:
- 是否依赖 Cookie / localStorage:
| 样本 | 浏览器期望值 | Node.js 输出值 | 结果 | 备注 |
|---|---|---|---|---|
| sample-001 | ... | ... | 通过 / 失败 | ... |
## 结论
- 是否可以交付:是 / 否
- 仍需补齐的环境:
- 仍需补充的样本:
```
## 失败时优先排查
- 入口函数是否正确。
- `args` 是否与浏览器调用一致。
- 请求 URL、Body、Header、Cookie 是否一致。
- 时间、随机数是否固定。
- UA、语言、时区、屏幕、指纹相关字段是否一致。
- `localStorage` / `sessionStorage` 是否缺值。
- `document.cookie` 是否脱敏后影响签名。
- native-like `toString` 是否启用。
- `document.all` 是否被目标 JS 检测。
- 是否还有 Proxy 检测信号。
- 动态 HTML / JS / challenge 是否已经过期,当前资源 hash 是否与 fixture 中记录一致。
- 如果资源 hash 不一致,是否已先运行 `check_dynamic_resources.js --require-runtime-refresh` 并确认最终入口会刷新当前资源。
## 敏感信息处理
fixtures 中的 Cookie、Authorization、账号 ID、手机号、邮箱等信息应脱敏。若脱敏会影响签名,应在本地私有 fixtures 中保留真实值,但不要写入公开报告或最终说明。

View File

@@ -0,0 +1,64 @@
# 高强度环境检测与浏览器行为 diff
当目标出现 Shape / F5 / Akamai / DataDome / Kasada / reese84 / bx-* 等浏览器完整性检测、环境分数异常、302/403 风控跳转、环境 trace 很分散,或出现异常模式、toString、堆栈、属性枚举、原型链、MutationObserver、userAgentData、window.chrome、媒体能力、Client Hints 一致性等问题时读取本文件。
本文件只用于网页端 Node.js 补环境和授权样本验证;遇到 JSVMP / opcode / 字节码解释器时,仍遵循“不主动分析 JSVMP 源码”的边界,只记录其为高强度风控风险,并围绕环境调用、writer、行为 diff 与请求链路推进。
## 核心原则
- 真实浏览器是标准答案;补环境不是只追求“不报错”或“当前 sign 一致”,还要让关键环境行为与取证浏览器一致。
- RuyiTrace / Node trace 用于定位访问了哪些 API;行为是否一致必须通过浏览器基线采样与 Node 补环境输出 diff 验证。
- 高强度检测覆盖要写入阶段报告和最终总结,不能只在调试输出中口头说明。
- 所有 probe、Hook、trace、浏览器自动化脚本只允许出现在前置取证和 notes / tmp 中,不得进入最终 `result/` 交付代码。
## 推荐流程
1. 根据 RuyiTrace / Hook / Node trace 生成环境 API 清单。
2. 在用户确认的真实浏览器取证模式中运行最小行为 probe,生成 `case/fixtures/browser-env-baseline.json`。
3. 在 Node 补环境中运行同一组 probe,生成 `case/tmp/node-env-audit.json`。
4. 输出 `case/notes/high-intensity-env-diff.md`,逐项记录一致 / 不一致 / 未涉及 / 未采样。
5. 按差异修复 env,并把修复内容写入 `case/notes/代码变更记忆.md` 和阶段报告。
6. 交付前把覆盖矩阵写入 `case/result/最终项目总结.md`。
## 高强度检测类别
| 类别 | 需要对比的内容 | 典型证据 |
|---|---|---|
| 异常模式 | `error.name`、`constructor.name`、`message`、`String(error)`、stack 首行;循环 `__proto__`、incompatible receiver、非法 `call/apply` | browser baseline / Node audit |
| Node 泄露 | `process`、`process.env`、`NODE_DISABLE_COLORS`、`NO_COLOR`、`errno`、`error`、`Buffer`、`global`、`require`、`module`、本机路径、`node:internal` | Node 泄露检查 / stack probe |
| toString 多通道 | `fn.toString()`、`Function.prototype.toString.call(fn)`、保存旧 FTS 调用、`String(fn)`、`fn + ""`、`fn.toString.toString()` | addon / NativeProtect 自检 |
| DataCloneError | `structuredClone(fn)`、`MessagePort.prototype.postMessage(fn)` 的错误 message / stack 是否暴露 fallback 函数源码 | browser baseline / Node audit |
| 属性枚举与描述符 | `Object.getOwnPropertyDescriptor`、`__lookupGetter__`、`__lookupSetter__`、`Object.getOwnPropertyNames`、`Object.keys`、`Reflect.ownKeys`、`in`、`hasOwnProperty`、`propertyIsEnumerable`、`for...in` | descriptor baseline |
| 原型链与 brand check | `Object.getPrototypeOf` walk、`__proto__`、`constructor.name`、`Symbol.toStringTag`、`Object.prototype.toString.call`、`instanceof`、跨原型 `call/apply` | prototype baseline |
| DOM 行为 | `document.createElement(tag)` tag 映射、HTML 元素继承链、`MutationObserver.observe` 异步 records、`attributeFilter`、参数错误 | DOM probe |
| Chrome / Navigator | `navigator.userAgentData`、`getHighEntropyValues`、`window.chrome`、`chrome.csi`、`chrome.loadTimes`、`chrome.app`、`navigator.mediaSession` | browser baseline |
| 媒体能力 | `HTMLMediaElement.prototype.canPlayType` MIME 返回表、Audio / Video 构造链、mediaSession | media probe |
| 指纹终端 API | Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何真实采样值与调用参数 | fingerprint fixture |
| 网络一致性 | `User-Agent`、`sec-ch-ua`、`sec-ch-ua-platform`、`sec-fetch-*`、Referer、Origin、Cookie 顺序、Header 顺序、HTTP/2 pseudo-header 顺序 | HAR / echo 服务 / TLS 客户端配置 |
| 动态 JS 多版本 | 多次首访 / challenge / JS bundle 的 hash、seed、nonce、Set-Cookie、writer 是否稳定 | resource manifest / 多 fixture |
## toString 与 DataCloneError 要点
- addon / xbs native-first 创建的函数应作为首选;用户实测 addon 对多通道 toString 检测没有问题。
- NativeProtect 只是 fallback;如果使用 NativeProtect,必须采用带 `structuredClone` / `MessagePort.prototype.postMessage` DataCloneError 改写的版本,避免错误信息暴露 fallback 函数源码。
- 如果当前上下文不存在 `structuredClone` 或 `MessagePort`,记录“不涉及 / 宿主不可用”;不得为了测试而把宿主 Node 实现透传给目标 JS。
## API 与网络指纹一致性
补环境中的 JS 指纹值必须与最终请求头一致:
- `navigator.userAgent` 对齐 `User-Agent`。
- `navigator.userAgentData.brands/fullVersionList/platform/mobile` 对齐 `sec-ch-ua`、`sec-ch-ua-platform`、`sec-ch-ua-mobile`。
- `navigator.language/languages` 对齐 `Accept-Language`。
- `location.href/origin/referrer` 对齐 `Referer`、`Origin`、`sec-fetch-site`。
- TLS 客户端选择不能替代 Header / Client Hints / Fetch Metadata 一致性;两者都要记录。
## 输出要求
阶段报告和最终总结至少记录:
- 是否启用高强度环境行为 diff:是 / 否,原因。
- 浏览器基线文件和 Node audit 文件。
- 已覆盖类别、未涉及类别、未采样类别。
- 每个不一致项的修复状态、fallback 原因和遗留风险。
- 是否存在动态风控 JS 多版本回归,以及回归样本数量。

View File

@@ -0,0 +1,89 @@
# 高强度浏览器环境检测通用排查指南
本文件用于从 Cloudflare / Turnstile / Akamai / DataDome / Kasada / Shape / F5 等高强度检测样本中抽象通用补环境要求。它不是任何厂商的专用绕过流程,也不用于生成或伪造 challenge、验证码、访问控制 Cookie 或第三方防护 token;只用于授权范围内的网页端 Node.js 补环境、取证一致性检查和最终请求链验证。
## 触发条件
出现以下任一现象时读取本文件,并把结论写入阶段报告:
- HTTP 403 / 429 / 503、challenge 页面、security check、bot block、verify、captcha、WAF / risk control 页面。
- HTML 中注入 challenge / detection / telemetry / fingerprint 脚本,或目标 API 依赖入口页 Cookie、Storage、seed、nonce、动态 JS。
- 目标 JS 访问大量环境 API、指纹 API、自动化检测 honeypot,或 trace 中 API 类别分散。
- 单独请求 API 失败,但真实浏览器从入口页访问后成功。
- 请求成功依赖 UA / Client Hints / CookieJar / Storage / TLS / 代理 / 请求顺序一致。
## 非目标
不得把本文件解释为:
- 生成或伪造 `cf_clearance`、`__cf_bm`、验证码 token、Turnstile token、设备校验 token 或访问控制凭证。
- 自动破解验证码、绕过登录、绕过 MFA 或绕过访问控制。
- 把浏览器自动化作为最终交付项目的一部分。
- 主动分析 JSVMP opcode / 字节码源码;遇到 JSVMP 只围绕环境调用、writer、行为 diff 和请求链推进。
## 高强度检测面总览
| 类别 | 重点排查 | 通用处理原则 |
|---|---|---|
| 环境真实性 | 原型链、属性描述符、getter/setter、非法构造、brand check、枚举顺序、Error stack、DataCloneError、toString 多通道 | 按 `env-object-model.md`、`env-native-protection.md`、`addon-api.md`,addon-first / xbs native-first |
| 指纹值 | Canvas、WebGL、WebGPU、Audio、Speech、Fonts、DOM geometry、Permissions、Plugins、MimeTypes、MediaDevices、WebRTC、screen、UA-CH | `fingerprint-value-replay.md`:Trace 未截断优先,截断 / 缺失则真实浏览器采样,禁止 AI 猜值和随机化 |
| 自动化痕迹 | `navigator.webdriver`、Selenium / PhantomJS / NightmareJS / ChromeDriver 痕迹、CDP / DevTools 侧信道、headless 差异、`isTrusted` | 从第一次取证起使用用户确认的 ruyiPage / Camoufox / CloakBrowser / 手动浏览器,固定 baseline |
| 浏览器状态 | Cookie、localStorage、sessionStorage、IndexedDB、Cache、ServiceWorker、权限状态、visibility、focus、history | 入口页优先,记录生成 / 刷新链路,不把过期状态误判为补环境失败 |
| 网络一致性 | TLS JA3/JA4、HTTP/2、Header 顺序、Accept-Encoding、UA、UA-CH、Sec-Fetch、Referer、Origin、Accept-Language、代理地区 | `tls-request-validation.md` + `session-request-chain.md`,最终请求使用 Session + TLS 指纹兼容客户端 |
| 请求链与时序 | 入口 HTML、动态 JS、检测脚本、前置接口、目标 API、请求间隔、Cookie 变化 | 不孤立重放单个 API,保存 request-chain manifest 与失败排查结论 |
## 入口页优先原则
高强度检测下,单个 API cURL 通常不是完整状态来源。进入补环境前必须确认:
1. 入口 HTML URL 与目标 API URL 的关系。
2. 入口页是否注入检测脚本、动态 seed、nonce、challenge 资源或 Set-Cookie。
3. 目标 API 是否依赖入口页产生的 Cookie / Storage / JS runtime 状态。
4. 是否存在动态 HTML / JS 资源过期风险,必要时按 `dynamic-resource-freshness.md` 在最终入口运行时刷新。
5. 如果用户只提供 API cURL 且返回风控 / challenge,应暂停要求入口页、HAR、浏览器取证证据或用户确认无法提供,不得直接盲补 API 参数。
## 指纹值不能随机化
高强度检测中,随机化或伪装不一致的 Canvas / WebGL / Audio / 字体 / DOM 几何结果,可能比固定真实值更容易暴露。硬性规则:
- 不使用 `Math.random()`、时间戳、机器默认值或 AI 经验生成指纹结果。
- 不把 `node-canvas`、`headless-gl`、`jsdom` 等 Node 模拟库结果当最终真实值。
- 不每次取证生成一套新 UA / 语言 / 时区 / screen / WebGL / Canvas / Audio 指纹。
- 优先 RuyiTrace / trace 未截断值;trace 缺失、未覆盖或疑似截断时,用用户确认的取证工具在同一 `baselineId` 下采样。
- 每条 fixture 记录 `source / capturedBy / traceStatus / baselineId / valueLength / hash / truncated`。
## 自动化与 CDP 风险清单
取证阶段出现以下信号时,不要继续普通自动化路径,应回到 `browser-acquisition.md` 重新确认工具和 baseline:
- `navigator.webdriver`、`window._selenium`、`window.callSelenium`、`window.callPhantom`、`window._phantom`、`window.__nightmare`、`window.domAutomation`、`window.domAutomationController`。
- `HeadlessChrome` UA、无插件 / 无 mimeTypes / permissions 异常、WebGL / Canvas 被隐私插件随机化或伪装。
- CDP / DevTools / Runtime 侧信道、异常 `console` / stack 行为、被页面检测到调试器或自动化 hook。
- 普通 `dispatchEvent` 产生 `isTrusted=false`,或鼠标 / 键盘 / 拖拽轨迹不可信。
- Profile 为空导致 Cookie / Storage / 权限状态缺失,或每次启动工具重新随机指纹。
## 高强度失败排查顺序
遇到最终请求失败、参数不一致、403 / 429 / 风控页、静默失败时,按以下顺序排查,不要直接反复改补环境代码:
1. 是否拿到 challenge / 风控页而非业务页。
2. 是否缺少入口 HTML、前置 JS、检测脚本或前置接口请求。
3. Cookie / Storage / device token / challenge 状态是否过期、缺失或不属于同一 session。
4. UA、UA-CH、Accept-Language、timezone、locale、screen、WebGL、Canvas、代理 / IP 是否与取证 baseline 一致。
5. TLS JA3/JA4、HTTP/2、Header 顺序、Sec-Fetch、Referer、Origin 是否与浏览器取证链一致。
6. fingerprint fixture 是否混用 baseline,trace 中长字段是否被截断。
7. Canvas / WebGL / WebGPU / Audio / Speech / Fonts / DOM geometry / Permissions / Plugins / MimeTypes 是否缺失真实样本或被随机化。
8. 取证阶段是否暴露 webdriver / CDP / headless / isTrusted 风险。
9. 请求顺序、请求间隔、Cookie jar 回写、动态资源刷新是否完整。
10. 以上排除后,再判断目标 JS 补环境对象、入口、writer 或 signer 逻辑是否缺失。
## 输出要求
阶段报告、`case/notes/high-intensity-env-diff.md`、`case/notes/final-request-validation.md` 和最终总结至少记录:
- 是否触发高强度检测通用排查,触发证据是什么。
- 入口页、目标 API、动态资源、Cookie / Storage、请求链是否完整。
- 指纹 baseline 与 fixture 来源,是否存在随机化、猜值或 trace 截断。
- 自动化 / CDP / headless / isTrusted 风险检查结果。
- TLS / Header / Client Hints / Session 请求链一致性检查结果。
- 失败排查顺序执行结果,以及最终认为是环境问题、请求链问题、状态过期、取证工具问题还是目标 JS 逻辑问题。

View File

@@ -0,0 +1,234 @@
# 浏览器 Hook 模板
本文件提供授权调试时用于定位加密参数的最小 Hook 模板。原则:只观察、不篡改、不批量请求;命中后用调用栈回到源码确认。
## 使用规则
- 只在用户授权的目标页面中使用。
- Hook 前先记录目标 API、参数名和页面 URL。
- Hook 代码只输出调用栈和关键值,不修改请求内容。
- 命中后尽快移除 Hook,避免影响目标页面逻辑。
- 不用 Hook 绕过登录、验证码、MFA 或风控。
## fetch
```javascript
(() => {
const targetParam = "sign";
const apiKeyword = "/api/";
const rawFetch = window.fetch;
window.fetch = function patchedFetch(input, init) {
const url = typeof input === "string" ? input : (input && input.url) || "";
const body = init && init.body;
if (url.includes(apiKeyword) || String(url).includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[fetch 命中]", url);
console.log("input =", input);
console.log("init =", init);
console.trace("fetch 调用栈");
console.groupEnd();
debugger;
}
return rawFetch.apply(this, arguments);
};
})();
```
## XMLHttpRequest
```javascript
(() => {
const targetParam = "sign";
const apiKeyword = "/api/";
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__reverse_method = method;
this.__reverse_url = String(url || "");
return rawOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
if (String(name).includes(targetParam) || String(value).includes(targetParam)) {
console.group("[XHR Header 命中]", this.__reverse_url);
console.log(name, value);
console.trace("setRequestHeader 调用栈");
console.groupEnd();
debugger;
}
return rawSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function patchedSend(body) {
const url = this.__reverse_url || "";
if (url.includes(apiKeyword) || url.includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[XHR send 命中]", this.__reverse_method, url);
console.log("body =", body);
console.trace("send 调用栈");
console.groupEnd();
debugger;
}
return rawSend.apply(this, arguments);
};
})();
```
## URLSearchParams / FormData
```javascript
(() => {
const targetParam = "sign";
for (const Ctor of [URLSearchParams, FormData].filter(Boolean)) {
for (const method of ["append", "set"]) {
const raw = Ctor.prototype[method];
if (typeof raw !== "function") continue;
Ctor.prototype[method] = function patchedParamWrite(name, value) {
if (String(name) === targetParam || String(value).includes(targetParam)) {
console.group(`[${Ctor.name}.${method} 命中]`, name);
console.log("value =", value);
console.trace("参数写入调用栈");
console.groupEnd();
debugger;
}
return raw.apply(this, arguments);
};
}
}
})();
```
## Cookie
```javascript
(() => {
const targetParam = "sign";
const proto = Document.prototype;
const desc = Object.getOwnPropertyDescriptor(proto, "cookie");
if (!desc || !desc.set || !desc.get) return;
Object.defineProperty(document, "cookie", {
configurable: true,
enumerable: desc.enumerable,
get() {
const value = desc.get.call(document);
if (String(value).includes(targetParam)) {
console.trace("document.cookie 读取命中");
}
return value;
},
set(value) {
if (String(value).includes(targetParam)) {
console.group("[cookie 写入命中]");
console.log(value);
console.trace("cookie 写入调用栈");
console.groupEnd();
debugger;
}
return desc.set.call(document, value);
}
});
})();
```
## localStorage / sessionStorage
```javascript
(() => {
const keys = ["token", "sign", "device", "fingerprint"];
for (const storageName of ["localStorage", "sessionStorage"]) {
const storage = window[storageName];
if (!storage) continue;
for (const method of ["getItem", "setItem", "removeItem"]) {
const raw = storage[method];
storage[method] = function patchedStorage(key, value) {
if (keys.some(k => String(key).includes(k))) {
console.group(`[${storageName}.${method} 命中]`, key);
console.log("value =", value);
console.trace("Storage 调用栈");
console.groupEnd();
}
return raw.apply(this, arguments);
};
}
}
})();
```
## 时间、随机数和动态代码
```javascript
(() => {
for (const [owner, name] of [[Date, "now"], [Math, "random"]]) {
const raw = owner[name];
owner[name] = function patchedTimeRandom() {
const ret = raw.apply(this, arguments);
console.log(`[${name}]`, ret);
console.trace(`${name} 调用栈`);
return ret;
};
}
const rawGetRandomValues = crypto && crypto.getRandomValues;
if (rawGetRandomValues) {
crypto.getRandomValues = function patchedGetRandomValues(arr) {
const ret = rawGetRandomValues.apply(this, arguments);
console.log("[crypto.getRandomValues]", Array.from(arr));
console.trace("crypto 随机数调用栈");
return ret;
};
}
const rawFunction = Function;
window.Function = new Proxy(rawFunction, {
construct(target, args) {
console.log("[new Function]", args);
console.trace("动态函数调用栈");
debugger;
return Reflect.construct(target, args);
},
apply(target, thisArg, args) {
console.log("[Function]", args);
console.trace("动态函数调用栈");
debugger;
return Reflect.apply(target, thisArg, args);
}
});
})();
```
## WebSocket、事件和 postMessage
```javascript
(() => {
const rawWS = window.WebSocket;
if (rawWS) {
window.WebSocket = new Proxy(rawWS, {
construct(target, args) {
console.log("[WebSocket 创建]", args);
console.trace("WebSocket 调用栈");
return Reflect.construct(target, args);
}
});
}
const rawPostMessage = window.postMessage;
if (rawPostMessage) {
window.postMessage = function patchedPostMessage(message, targetOrigin, transfer) {
console.log("[postMessage]", message, targetOrigin);
console.trace("postMessage 调用栈");
return rawPostMessage.apply(this, arguments);
};
}
})();
```
## 证据输出要求
命中 Hook 后至少记录:
- 命中的 API 或参数名。
- 调用栈截图或文本。
- 所在 JS 文件、行列号、chunk 名。
- 写入前后请求对象。
- 是否可复现。
- 是否需要 sourcemap 或动态 chunk。

View File

@@ -0,0 +1,123 @@
# 信息收集模板
当用户没有提供完整信息时,使用本模板让用户补充。标注“必填”的字段缺失时,不要进入正式分析。
```markdown
# Web JS Node.js 补环境任务信息
## 0. 取证模式选择(必填)
请从一开始确认本 case 的取证模式。后续所有抓包、JS 收集、Hook、断点、截图、RuyiTrace 日志采集等取证动作都会沿用该模式;如果后续需要切换工具,我会再次请求确认。
- [ ] ruyiPage + RuyiTrace(推荐)
- [ ] 仅 ruyiPage
- [ ] Camoufox + camoufox-reverse-mcp
- [ ] 仅 Camoufox
- [ ] CloakBrowser
- [ ] 用户手动取证(你提供 cURL / HAR / JS 文件 / 调用栈截图 / RuyiTrace 日志)
- [ ] AI 自行决定(我会先检测工具并给出建议,启动前再次确认)
## 0.5 最终请求 TLS 指纹兼容客户端选择(必填)
如果本任务最终需要发送真实请求或交付 `final.js` / `final.py`,请从一开始选择最终请求客户端;不要等普通 `fetch` / `requests` 失败后再临时切换。
- [ ] Node.js CycleTLS
- [ ] Node.js impers
- [ ] Node.js curl-cffi
- [ ] Python curl_cffi
- [ ] Python cffi_curl
- [ ] Python cyCronet
- [ ] 不发真实请求,只输出本地 sign / 参数
说明:TLS 指纹兼容客户端只用于授权范围内的少量最终验证请求,不用于批量访问、绕过登录、验证码、MFA、付费墙或访问控制。
## 1. 目标说明
- 目标网站 URL(必填):
- 目标页面 URL(建议):
- 是否需要登录(必填):是 / 否 / 不确定
- 是否有授权测试权限(必填):是 / 否 / 不确定
- 目标业务场景(建议):登录 / 搜索 / 列表接口 / 详情接口 / 下单 / 评论 / 其他
## 2. 目标接口
- 目标 API URL(必填):
- 请求方法(必填):GET / POST / PUT / DELETE / 其他
- 目标加密参数名(必填):例如 sign / token / x-s / a_bogus / h5st
- 参数出现位置(必填):Query / Header / Body / Cookie
- 目标接口用途(建议):
## 3. 成功请求样本
请至少提供一份成功请求,优先使用浏览器 DevTools 的 Copy as cURL。
```bash
# 在这里粘贴 Copy as cURL
```
如果有多组样本,请继续粘贴:
```bash
# 样本 2
```
## 4. 成功响应样本(建议)
```json
{
"示例": "响应内容"
}
```
## 5. 已知 JS 文件(建议)
- JS 文件 URL:
- 是否已下载到本地:是 / 否
- 本地文件路径:
- 是否混淆:是 / 否 / 不确定
- 是否有 sourcemap:是 / 否 / 不确定
## 6. DevTools 调试信息(建议)
- Network Initiator 截图或文本:
- 调用栈 Stack Trace:
- 断点位置:
- 可疑函数名:
- 可疑变量名:
- 可疑代码片段:
## 7. 浏览器环境(建议)
- 浏览器:Chrome / Edge / 其他
- 浏览器版本:
- User-Agent:
- 语言:
- 时区:
- 屏幕尺寸:
- 是否依赖 Cookie:是 / 否 / 不确定
- 是否依赖 localStorage:是 / 否 / 不确定
- 是否依赖 sessionStorage:是 / 否 / 不确定
- 是否可能依赖 canvas / WebGL / 浏览器指纹:是 / 否 / 不确定
## 8. 期望产物
- [ ] 只做前置分析
- [ ] 收集 JS 文件
- [ ] 定位加密入口
- [ ] 生成 Node.js 补环境骨架
- [ ] 生成 runner.js
- [ ] 生成测试 fixtures
- [ ] 输出分析笔记
## 9. 范围确认
- [x] 只做网页端 JS
- [x] 只做 Node.js 补环境
- [x] 不做 App / 小程序 / Windows / Native 逆向
- [x] 不做纯算重写
- [x] 不做浏览器自动化批量采集
```
如果必填字段缺失,只要求用户先补齐缺失的必填项,不要让用户一次性补充所有建议项。
特别注意:用户只提供 cURL、Cookie、旧补环境文件或“要求成功请求”时,仍视为未完成信息收集。缺少“取证模式”和“最终请求 TLS 指纹兼容客户端”任一项时,不得开始取证、补环境或发送真实请求。

View File

@@ -0,0 +1,175 @@
# native 能力缺口闭环
本文件用于补环境阶段遇到“纯 JS、addon.node 当前 API、xbs isolated-vm 当前 API 都无法可靠表达目标浏览器行为”的场景。遇到这类问题时,不要继续硬凑 JS fallback,不要假装 addon / xbs 已经解决,也不要把当前报错消失写成稳定结论。
## 读取时机
- 目标检测点涉及浏览器引擎级特殊语义,例如 `document.all` 的 HTMLDDA、内部槽 brand check、不可检测对象、跨 Realm 行为、DataCloneError、Error stack、不可通过普通 JS 控制的 `typeof` / `Boolean` / `== null` 行为。
- 已确认纯 JS fallback 无法满足真实浏览器行为。
- 已确认当前 addon.node 可用但没有对应 API,或已有 API 无法覆盖目标行为。
- 用户选择 `isolated-vm` 时,已确认当前 `window.xbs` / `xbs.dom` 可用但没有对应 API,或已有 API 无法覆盖目标行为。
- 反复修改同一 WebAPI 仍回到失败逻辑,需要给用户一个可验证的 native 扩展需求。
## 判定顺序
先排除以下两类问题,只有排除后才能标记为 native 能力缺口:
1. **当前补环境实现不完整**:原型链、属性描述符、访问器、构造函数行为、toString、`Symbol.toStringTag`、实例工厂、私有状态、指纹回放样本缺失。这类问题继续补代码,不得上升为 native 能力缺口。
2. **已有 native API 使用错误**:addon / xbs 已提供 `createProtoChains`、`createNativeFunction`、`createGetter`、`createSetter`、`createNativeCollection`、`getMimeTypesAndPlugins`、`createUndetectable`、`createInterceptor`、private slot 等能力,但当前代码没有优先使用或参数用错。这类问题先改为 addon-first / xbs native-first。
3. **native 能力缺口**:真实浏览器行为已经采样,纯 JS 无法可靠表达,当前 addon.node 与 xbs isolated-vm 也没有可用 API 或现有 API 行为不够。此时暂停补环境实现,进入能力缺口闭环。
## 必须输出的能力缺口报告
在 `case/notes/native-capability-gap.md` 写入报告,并在阶段报告中引用。报告至少包含:
```markdown
# native 能力缺口报告
## 阻塞点
- API / 行为:
- 触发位置:文件、行列、调用栈或 RuyiTrace 证据
- 目标代码检测表达式:
- 该行为是否为生成目标加密参数的必要路径:是 / 否 / 待确认
## 真实浏览器基线
- 取证工具:ruyiPage / Camoufox / CloakBrowser / 用户手动
- 浏览器版本:
- 采样代码:
- 期望结果摘要:
## 当前后端能力对比
| 后端 | 当前结果 | 差异 | 结论 |
|---|---|---|---|
| 纯 JS fallback | | | 可解决 / 不可靠 / 无法解决 |
| addon.node | | | 可解决 / API 用法错误 / 当前 API 缺失 |
| xbs isolated-vm | | | 可解决 / API 用法错误 / 当前 API 缺失 / 未选择该框架 |
## 无法继续硬补的原因
- 纯 JS 无法控制的语义:
- addon.node 当前缺少或不足的能力:
- xbs isolated-vm 当前缺少或不足的能力:
## 建议新增 native API
- 建议 API 名称:
- 适用后端:addon.node / xbs isolated-vm / 二者都需要
- 输入参数:
- 返回值:
- 需要复现的浏览器语义:
- 需要同时保护的检测点:typeof / Boolean / == null / descriptor / prototype / toString / Object.prototype.toString / instanceof / structuredClone / postMessage / 其他
- 错误类型与错误信息:
## 最小行为测试用例
见下方测试代码。只有新 API 让该测试通过,才能把此阻塞点标记为已解决。
## 用户选择
- 等待或扩展 native API / 接受临时 JS workaround / 改变任务范围 / 暂停 case
- 选择时间:
- 选择原因:
```
## 最小测试用例要求
测试用例是行为契约,不是演示代码。要求:
1. 能在真实浏览器中运行并得到期望结果。
2. 能在目标后端运行,例如普通 Node + addon、或 isolated-vm Context + xbs。
3. 覆盖导致阻塞的关键表达式,不只覆盖当前报错行。
4. 包含 `expected` 与 `actual`,失败时输出具体差异。
5. 不放入最终 `result/` 交付目录。能力未补齐时可保存在 `case/notes/native-capability-gap-test.js` 或 `case/native-capability-gap/<name>.js`,补齐并验证后按清理规则处理,最终总结只保留报告摘要和测试结果。
## 示例:document.all / HTMLDDA 行为契约
```js
function collectDocumentAllBehavior(document) {
const value = document.all;
return {
typeofValue: typeof document.all,
looseNull: document.all == null,
strictUndefined: document.all === undefined,
booleanValue: Boolean(document.all),
hasAllInDocument: 'all' in document,
lengthType: typeof document.all.length,
itemType: typeof document.all.item,
namedItemType: typeof document.all.namedItem,
objectToString: Object.prototype.toString.call(document.all),
ownLength: Object.hasOwn(document.all, 'length'),
ownItem: Object.hasOwn(document.all, 'item'),
ownNamedItem: Object.hasOwn(document.all, 'namedItem'),
sameReference: value === document.all,
};
}
const expected = {
typeofValue: 'undefined',
looseNull: true,
strictUndefined: false,
booleanValue: false,
hasAllInDocument: true,
lengthType: 'number',
itemType: 'function',
namedItemType: 'function',
objectToString: '[object HTMLAllCollection]',
ownLength: false,
ownItem: false,
ownNamedItem: false,
sameReference: true,
};
function assertDocumentAllBehavior(document) {
const actual = collectDocumentAllBehavior(document);
const failed = [];
for (const key of Object.keys(expected)) {
if (actual[key] !== expected[key]) {
failed.push({ key, expected: expected[key], actual: actual[key] });
}
}
return { ok: failed.length === 0, actual, failed };
}
const result = assertDocumentAllBehavior(document);
if (!result.ok) {
throw new Error(JSON.stringify(result.failed, null, 2));
}
result;
```
如果纯 JS、当前 addon `createUndetectable(callback, handlers)`、当前 xbs `createUndetectable(callback, handlers)` 或 `xbs.dom.createDocument()` 都无法让该测试通过,才允许把它标记为 native 能力缺口。标记前必须先排除用法错误:选择 isolated-vm 时应优先使用 `xbs.dom.createDocument()` 默认提供的 `document.all`;手动创建时 handlers 未命中必须返回 `{ intercept: false }`,并把 `length / item / namedItem / Symbol.toStringTag` 放到 `HTMLAllCollection.prototype`,不能把它们做成 `document.all` 自有属性。确认仍无法通过后,再建议新增或修正 API,例如增强 `createUndetectable()` / `xbs.dom.createDocument()` 的 HTMLAllCollection 能力。
## 建议 API 设计输出格式
给用户的建议不能只写“需要改 addon”。必须尽量具体到 API 契约:
```js
// 示例名称仅用于表达需求,真实名称由 native 实现决定。
const all = native.createHTMLDDACollection({
className: 'HTMLAllCollection',
length: elements.length,
items: elements,
itemName: 'item',
namedItemName: 'namedItem',
});
```
需要说明:
- `typeof all` 的期望。
- `Boolean(all)` 的期望。
- `all == null` 与 `all === undefined` 的期望。
- `length`、索引、`item()`、`namedItem()` 的期望。
- 原型链、构造函数、`Object.prototype.toString`、函数 toString、描述符枚举行为。
- 与 `document.all` getter / value descriptor 的安装方式。
## 继续推进规则
- 用户更新 addon.node 或 xbs isolated-vm 后,先运行能力缺口测试用例,再继续补环境。
- 测试未通过时,不得把该点标记为已解决。
- 用户选择临时 JS workaround 时,必须把范围写成“仅当前样本路径临时兼容”,不得写成稳定方案。
- 用户拒绝扩展 native 能力且目标参数生成必须依赖该行为时,标记 case 阻塞,不要伪造成功。
- 阻塞报告、用户选择、测试用例结果必须写入阶段报告和最终总结。

View File

@@ -0,0 +1,164 @@
# Node 泄露阻断与静默失败排查
本文件用于进入 Node.js 补环境前后,防止目标 JS 误读 Node 环境,并排查“不报错但结果不一致”的问题。
## Node 泄露阻断原则
目标网页 JS 应看到浏览器环境,而不是 Node.js 环境。探测脚本和最终 runner 都必须先阻断 Node 能力变量,再安装浏览器式环境对象。
基础 Node 能力泄露清单:
```text
process, Buffer, require, module, exports, global, __dirname, __filename,
setImmediate, clearImmediate, Error.prepareStackTrace, Node 专属堆栈路径
```
新版 Node Web API 兼容层也可能成为泄露面,必须删除、隔离或用浏览器真实样本覆盖:
```text
navigator, localStorage, sessionStorage, performance, fetch, Headers,
Request, Response, FormData, File, Blob, WebSocket, EventSource,
BroadcastChannel, MessageChannel, MessagePort, CompressionStream,
DecompressionStream, URLPattern, CloseEvent, ErrorEvent
```
以下对象在浏览器中也常见,但 Node 的实现、原型、属性描述符、`toString`、异常类型、内部状态或网络栈可能与真实浏览器不同;只要目标 JS 会读取或参与签名,就不要盲目透传宿主实现,应按浏览器样本、RuyiTrace 证据或可控桩函数安装:
```text
AbortController, AbortSignal, Event, EventTarget, CustomEvent, MessageEvent,
DOMException, structuredClone, atob, btoa, URL, URLSearchParams, TextEncoder,
TextDecoder, TextEncoderStream, TextDecoderStream, ReadableStream,
WritableStream, TransformStream, PerformanceEntry, PerformanceMark,
PerformanceMeasure, PerformanceObserver, PerformanceResourceTiming,
Crypto, CryptoKey, SubtleCrypto, WebAssembly, queueMicrotask
```
版本结论以 Node 官方文档为准:
- `Navigator` / `navigator` 是 Node v21.0.0 新增,不是 Node 20 官方新增;`navigator.userAgent` 自 v21.1.0 起可能返回 `Node.js/<major>`。
- `navigator.language` / `languages` / `platform` 自 Node v21.2.0 起反映宿主系统或 ICU,`navigator.locks` 自 Node v24.5.0 起可能存在。
- `localStorage` / `sessionStorage` 是 Node v22.4.0 引入的 Web Storage 兼容层;Node v25+ 行为又有变化,不能当作页面级浏览器 Storage 直接复用。
- Node 的 `fetch` 基于 undici,可通过 `process.versions.undici` 看到宿主实现版本;最终真实请求仍应由已确认的 TLS 指纹兼容客户端完成,不要把宿主 `fetch` 当作浏览器网络栈。
- Node 全局 `performance` 是 `perf_hooks.performance`,可能暴露 `nodeTiming`、`eventLoopUtilization`、`timerify`、`markResourceTiming` 等浏览器没有或语义不同的字段。
特别注意:补环境前一旦检测到这些宿主对象,必须先移除、隔离或显式覆盖,再安装浏览器采样值;不得直接复用宿主对象。若目标真实浏览器也存在同名 API,例如 `navigator.locks`、`crypto`、`ReadableStream`,也应按浏览器样本补其值、描述符和原型链,而不是沿用 Node 宿主对象。
### 运行上下文隔离要求
- 使用 `vm.createContext` 时,不要把宿主函数、宿主数组、宿主类直接塞进目标运行上下文。
- `URL`、`TextEncoder`、`fetch`、`atob`、`console` 等应在目标运行上下文内定义,或确认不会通过 `constructor.constructor` 拿到宿主 `process`。
- 禁止把 `require`、`process`、`Buffer` 作为调试便利变量暴露给目标 JS。
- 禁止直接复用宿主 `navigator`、`performance`、`localStorage`、`sessionStorage`、`fetch`、`WebSocket` 等 Node Web API 兼容层;这些对象必须由 `env.js` 按浏览器样本显式安装。
- 如果最终 runner 无法使用隔离 global,启动后第一步先 `Reflect.deleteProperty(globalThis, "navigator")` 等方式移除宿主 Web API,再用 `Object.defineProperty` 安装浏览器式对象。
- 对 `URL`、`URLSearchParams`、`TextEncoder`、`TextDecoder`、`crypto`、`WebAssembly`、Streams、Events 等“浏览器也有但 Node 也提供”的对象,先判断目标是否会检测原型、描述符、异常或输出;若会检测,使用浏览器采样值或补环境实现,不要直接透传宿主构造器。
- 目标 JS 需要的环境对象要通过 `env.js` 明确安装,不要把 Node 全局对象透传。
- 最终交付前运行 `scripts/check_node_leakage.js`,并在 notes 中记录阻断结论。
### 快速自检表达式
在目标 JS 所在运行上下文中执行以下表达式,期望均不暴露 Node 能力:
```javascript
typeof process === "undefined"
typeof Buffer === "undefined"
typeof require === "undefined"
typeof module === "undefined"
typeof global === "undefined"
Function("return typeof process")() === "undefined"
!/^Node\.js\//.test(String(navigator && navigator.userAgent || ""))
!("nodeTiming" in performance)
!("eventLoopUtilization" in performance)
!("timerify" in performance)
typeof process === "undefined" || !process.versions || !process.versions.undici
```
如果任一表达式暴露 Node 能力,或 `navigator.userAgent` 显示 `Node.js/<major>`,或 `performance` 暴露 `nodeTiming` / `eventLoopUtilization` / `timerify`,先修运行上下文隔离,不要继续补环境。
### Node 21+ navigator 与 Node Web API 覆盖规则
- 检测到 `globalThis.navigator` 存在时,不要假设它是浏览器 navigator;先检查 `navigator.userAgent` 是否以 `Node.js/` 开头。
- 若宿主 Node 提供 `navigator`,补环境初始化前必须删除或遮蔽宿主对象,再按浏览器 fixture 安装 `Navigator.prototype`、getter、`plugins`、`mimeTypes`、`languages` 等。
- 检测到宿主 `performance` 时,必须确认目标上下文不暴露 Node 专属 `nodeTiming`、`eventLoopUtilization`、`timerify`、`markResourceTiming`。
- 检测到宿主 `localStorage` / `sessionStorage` 时,必须替换为浏览器页面级 Storage 语义,并使用请求样本、RuyiTrace 或 fixture 中的键值。
- 检测到宿主 `fetch` / `WebSocket` / `BroadcastChannel` / `MessageChannel` 等时,不要直接透传到目标 JS;探测模式使用桩函数记录调用,最终请求由已确认 TLS 指纹兼容客户端实现。
## 静默失败排查清单
当目标 JS 能跑完但 sign/token 不一致,按以下顺序排查:
1. **请求样本一致性**:URL、Query 排序、Body 字符串、Header 大小写、Content-Type、Referer、Origin 是否一致。
2. **SDK 初始化参数**:appId、版本号、平台、渠道、页面路径、nonce、server seed、初始化时机是否一致。
3. **登录态和存储**:Cookie、localStorage、sessionStorage、IndexedDB 摘要是否一致;敏感值不要写入最终报告。
4. **时间和随机数**:`Date.now`、`new Date()`、`performance.now`、`Math.random`、`crypto.getRandomValues` 是否可复现。
5. **浏览器指纹**:UA、language、timezone、screen、devicePixelRatio、plugins、mimeTypes、canvas、WebGL。
6. **加载顺序**:目标 JS 是否在 env 安装前读取环境;入口模块是否需要先执行 runtime chunk。
7. **toString/native-like**:函数、getter、setter、构造函数是否返回浏览器风格字符串。
8. **属性描述符**:enumerable、configurable、writable、getter/setter 是否与浏览器接近。
9. **原型链**:实例 `constructor`、`instanceof`、`Object.prototype.toString` 是否合理。
10. **特殊对象**:`document.all`、`HTMLAllCollection`、`navigator.plugins`、`mimeTypes` 是否被检测。
11. **动态代码**:eval、new Function、setTimeout 字符串、混淆解包结果是否漏执行。
12. **Worker/WASM**:是否实际在 Worker、iframe、WASM 中生成参数,需要单独搬运消息链。
## Cookie 过期的静默失败排查
Cookie 相关失败先分类,再决定是否让用户补样本:
1. **登录态 / 授权 Cookie**:如果 Cookie 与账号、会话、SSO、Authorization 或权限绑定,不要尝试绕过;让用户在所选取证工具中手动登录,或提供授权样本。
2. **非登录 Cookie**:如果目标不需要登录,或 Cookie 是设备标识、首访标识、风控标识、JS 生成值、challenge 派生值,不要默认要求用户重新提供新 Cookie;应分析生成链路。
3. **来源定位**:
- 服务端 `Set-Cookie`:检查是否需要先发起首访 / challenge 请求,并在最终 Node.js / Python 请求客户端中维护 Cookie jar。
- 前端 `document.cookie = ...`:用 Hook / RuyiTrace 查 writer 和调用栈。
- JS 计算:按 `source → entry → builder → writer` 纳入补环境。
- Storage 派生:检查 localStorage / sessionStorage / IndexedDB 摘要是否与浏览器样本一致。
4. **最终交付**:非登录 Cookie 的生成或刷新应进入最终入口脚本;入口运行后先生成 / 刷新 Cookie,再生成加密参数并发送 Node.js / Python 模拟请求。
不要把“重新拿一份新 Cookie”作为默认答案;它只能用于登录态 / 授权态、不可复现的一次性服务端状态,或用户明确只做离线样本复现的场景。
## 六项纯计算预检
补环境前先证明 Node 与浏览器的基础纯计算差异不会影响结果。若差异明显,先记录,不要直接归咎于环境对象。
| 项目 | 检查内容 | 目的 |
|---|---|---|
| Math | `Math.imul`、浮点精度、三角函数边界 | 排除 JS 引擎数学差异 |
| String/Unicode | UTF-8、emoji、中文、surrogate pair | 排除编码差异 |
| Array/Object | sort、JSON.stringify、属性枚举顺序 | 排除序列化差异 |
| Date/Timezone | 时区、ISO 字符串、时间戳 | 排除时间格式差异 |
| Encoding | atob/btoa、TextEncoder、URLSearchParams | 排除编码和 Query 序列化差异 |
| Random | Math.random、crypto.getRandomValues 是否被依赖 | 判断是否要固定随机源 |
使用:
```bash
node scripts/precheck_runtime.js --markdown
node scripts/precheck_runtime.js --json
```
浏览器侧也运行同类片段,把结果放入 `notes/runtime-precheck-browser.json`,再与 Node 结果比对。
## init 参数检查
很多 SDK 签名失败不是缺环境,而是初始化参数不完整。补环境前必须确认:
- SDK 初始化函数是否已执行。
- appId / tenantId / clientId / channel / version 是否来自页面配置。
- 初始化是否读取 meta 标签、script 标签、window 全局配置或 localStorage。
- 是否需要先加载 runtime chunk、vendor chunk、polyfill chunk。
- 初始化是否异步等待网络、Worker、WASM 或事件。
- 入口函数调用前是否需要触发页面事件或队列 flush。
输出模板:
```markdown
## SDK / 入口初始化检查
- 初始化函数:
- 初始化参数来源:
- 必需全局配置:
- 必需存储值:
- 必需 Cookie:
- 必需加载顺序:
- 当前缺失:
- 是否可以调用入口:是 / 否
```

View File

@@ -0,0 +1,128 @@
# Node 版本兼容恢复、nvm 检测与用户确认流程
当 `addon.node`、随包魔改 `xbs isolated-vm` 或其他 native 产物因为 Node ABI 不兼容无法加载时读取本文件。原则是先让用户确认是否安装 / 切换兼容 Node,再决定是否降级;不得在用户未同意前直接改全局 Node 环境。
## 兼容版本表
| native 组件 | 兼容 Node.js 版本 | 失败后的默认处理 |
|---|---:|---|
| `assets/native-addon/<platform>-<arch>/addon.node` | `v25.8.2` | 先询问是否通过 nvm 安装 / 切换到 `v25.8.2`,用户拒绝后才允许 NativeProtect / JS fallback |
| `assets/runtime-frameworks/xbs-isolated-vm/<platform>-<arch>/isolated_vm.node` | `v26.3.1` | 先询问是否通过 nvm 安装 / 切换到 `v26.3.1`,用户拒绝后才允许改选框架或提供匹配构建产物;不得退回 npm 原版 isolated-vm |
## 触发条件
遇到以下任一情况时启动本流程:
- Node 报错包含 `NODE_MODULE_VERSION`、`was compiled against a different Node.js version`、`Module did not self-register`、`The module was compiled against`。
- `scripts/load_native_addon.js --json` 输出 `abiMismatch: true`。
- `scripts/check_xbs_isolated_vm.js --json` 输出 `status: "abi-mismatch"` 或 `abiMismatch: true`。
- 当前 Node 版本不是对应兼容版本,且 native 组件加载失败。
## 先检测当前 Node 与 nvm
```bash
node scripts/check_node_runtime_compat.js --target addon --markdown
node scripts/check_node_runtime_compat.js --target isolated-vm --markdown
```
也可以手动检测:
```bash
node -v
node -p "process.versions.modules"
```
Windows:
```powershell
where.exe nvm
nvm version
nvm root
```
macOS / Linux:
```bash
bash -lc 'command -v nvm || true; [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm --version || true'
```
## 用户确认模板
### addon ABI 不兼容
```markdown
当前 addon.node 与当前 Node ABI 不兼容。addon-first 是补环境硬性基线,因此不应直接降级到 NativeProtect。
兼容 Node.js 版本:v25.8.2。
请选择:
1. 自动检测 / 安装 nvm,并安装切换 Node.js v25.8.2 后重新加载 addon。
2. 我手动安装 nvm;安装完成后回复“nvm 已安装成功”。
3. 我已经安装 nvm,但当前未检测到;我提供 nvm 路径或可用 shell。
4. 不安装兼容 Node,允许本 case 降级到 NativeProtect / JS fallback,并记录差异。
在你确认前,我不会直接降级,也不会修改全局 Node 版本。
```
### isolated-vm ABI 不兼容
```markdown
当前随包魔改 xbs isolated-vm 与当前 Node ABI 不兼容。你已选择 isolated-vm 补环境框架,因此应先尝试兼容 Node,而不是退回 npm 原版 isolated-vm。
兼容 Node.js 版本:v26.3.1。
请选择:
1. 自动检测 / 安装 nvm,并安装切换 Node.js v26.3.1 后重新运行 xbs isolated-vm 自检。
2. 我手动安装 nvm;安装完成后回复“nvm 已安装成功”。
3. 我已经安装 nvm,但当前未检测到;我提供 nvm 路径或可用 shell。
4. 不安装兼容 Node,改为提供当前 Node ABI 匹配的魔改 isolated_vm.node 构建产物,或改选不使用框架 / vm / jsEnv。
在你确认前,我不会改用 npm 原版 isolated-vm,也不会桥接旧 addon.node。
```
## nvm 安装与切换
只有用户明确选择自动安装时,才输出安装计划并执行。用户选择手动安装时,只给教程并等待用户确认成功。
Windows 推荐 nvm-windows:
```powershell
winget install CoreyButler.NVMforWindows
# 或让用户从 nvm-windows 官方 Release 手动安装
nvm version
```
macOS / Linux 常用 nvm-sh:
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
bash -lc 'source "$HOME/.nvm/nvm.sh" && nvm --version'
```
安装 / 检测 nvm 成功后按目标组件切换:
```bash
# addon.node
nvm install 25.8.2
nvm use 25.8.2
node -v
node -p "process.versions.modules"
node scripts/load_native_addon.js --json
# xbs isolated-vm
nvm install 26.3.1
nvm use 26.3.1
node -v
node -p "process.versions.modules"
node --no-node-snapshot scripts/check_xbs_isolated_vm.js --strict --json
```
## 记录要求
无论最终是否切换成功,都写入阶段报告、`case/notes/代码变更记忆.md` 和最终总结:
- 触发组件:addon.node / xbs isolated-vm。
- 原 Node 版本、原 ABI、平台、架构。
- 用户选择:自动安装、手动安装、提供 nvm 路径、拒绝安装、提供匹配二进制或改选框架。
- 新 Node 版本、新 ABI 和重新检测结果。
- 如果降级,写明 fallback 范围、已知差异和用户确认记录。

View File

@@ -0,0 +1,129 @@
# 对象形状审计与私有状态泄露规则
本文件用于约束浏览器可见对象的内部状态保存方式和对象形状审计。高强度检测常用 `Object.keys`、`Object.getOwnPropertyNames`、`Reflect.ownKeys`、descriptor、`in`、`for...in`、prototype walk、brand check、`Object.prototype.toString` 和 `Function.prototype.toString` 发现 Node 补环境痕迹;非枚举 `_` / `__` 属性也会成为检测点。
## 硬性规则
- 任何目标 JS 能拿到的对象、实例、prototype、构造函数、集合对象、XHR / fetch / Response / Headers / Request / Event / DOM 节点 / Storage / Performance 等,都不得暴露自定义 `_` / `__` 字符串自有属性。
- 禁止 `this.__state = ...`、`this._headers = ...`、`Object.defineProperty(obj, "__readyState", ...)`、`defineValue(obj, "_impl", ...)`、`Reflect.defineProperty(obj, "_x", ...)`、`obj["_x"] = ...` 等写法;即使 `enumerable:false` 也不允许。
- 禁止用自定义 `Symbol("private")`、`Symbol.for("private")` 等在浏览器对象上保存状态,除非真实浏览器 baseline 存在同名可见 symbol。
- 允许的内部状态位置:addon / xbs `setPrivate/getPrivate/hasPrivate/deletePrivate`、浏览器 native 内部槽、模块级 `WeakMap`、闭包私有变量。
- JS fallback 使用 `WeakMap` 时,key 必须是浏览器可见实例,value 不能被挂回实例;销毁对象或 session 时清理 WeakMap 中关联状态。
- 状态字段名可以在 WeakMap value 里使用 `_` / `__`,因为目标 JS 无法通过对象枚举访问;但不得把 value 暴露回浏览器对象。
## 对象形状审计矩阵
触发以下任一情况时,真实请求或最终交付前必须建立对象形状审计矩阵:
- Trace / RuyiTrace / Hook 中出现 `Object.keys`、`Object.getOwnPropertyNames`、`Object.getOwnPropertyDescriptor`、`Reflect.ownKeys`、`for...in`、`hasOwnProperty`、`propertyIsEnumerable`、`__lookupGetter__`、`__lookupSetter__`。
- 目标涉及 iframe / Window realm、Worker、MessagePort、EventTarget、DOM、CSSOM、XHR/fetch、Headers/Response/Request、Performance、Storage、Canvas/WebGL、plugins/mimeTypes。
- 阶段报告或失败 diff 出现属性枚举、descriptor、prototype walk、brand check、`[object Xxx]`、`instanceof`、constructor、toString 多通道差异。
- 代码中新增或修改了浏览器对象内部状态、缓存、headers、readyState、listeners、children、parentNode、ownerDocument、response body、storage map 等。
必须产物:
```text
case/fixtures/browser-object-shape-baseline.json
case/tmp/node-object-shape-audit.json
case/notes/object-shape-audit.md
```
## baseline / audit 最小结构
```json
{
"schemaVersion": "object-shape-audit/v1",
"baselineId": "fp-001",
"targets": [
{
"name": "XMLHttpRequest instance",
"path": "new XMLHttpRequest()",
"objectKeys": [],
"ownPropertyNames": [],
"ownSymbols": [],
"reflectOwnKeys": [],
"descriptors": {},
"inChecks": {},
"forIn": [],
"prototypeChain": [],
"toString": "[object XMLHttpRequest]",
"constructorName": "XMLHttpRequest"
}
]
}
```
`node-object-shape-audit.json` 使用同一 target 名称和 probe 名称,并记录 `status`:`matched` / `accepted-diff` / `not-involved` / `needs-browser-baseline` / `needs-node-audit` / `mismatch` / `native-capability-gap` / `unknown`。
`status` 只用于报告,不能作为通过依据。`check_object_shape_audit.js` 会对每个同名 target 深度比较 `objectKeys`、own names、own symbols、`Reflect.ownKeys`、descriptors、`inChecks`、`forIn`、prototype chain、brand、constructor 和 `instanceof`。Node audit 必须包含 `runtimeSourceHash`、`probeVersion` 与可信 `generatedBy`;只有 target 名称相同但观测字段缺失时检查失败。
## 必查目标
按目标涉及范围选择,不得伪造“未涉及”:
- `window`、`globalThis`、`self`、iframe `contentWindow`。
- `navigator`、`Navigator.prototype`、`plugins`、`mimeTypes`。
- `document`、`Document.prototype`、`Element`、`HTMLElement`、Text、Comment、HTMLCollection、NodeList。
- `XMLHttpRequest` 构造函数、prototype、实例;`fetch`、`Request`、`Response`、`Headers`、`navigator.sendBeacon`。
- `EventTarget`、Event、MouseEvent、KeyboardEvent、MessagePort、Worker。
- `Storage`、`localStorage`、`sessionStorage`、Cookie store。
- `performance`、PerformanceObserver、PerformanceEntry。
- Canvas / WebGL / Audio / CSSStyleDeclaration / DOMRect 等指纹对象。
每个目标至少检查:
- `Object.keys(obj)`。
- `Object.getOwnPropertyNames(obj)`。
- `Object.getOwnPropertySymbols(obj)`。
- `Reflect.ownKeys(obj)`。
- `Object.getOwnPropertyDescriptor(obj, key)`。
- `key in obj`。
- `obj.hasOwnProperty(key)`。
- `obj.propertyIsEnumerable(key)`。
- `for...in` 顺序。
- `Object.getPrototypeOf` 逐层 walk。
- `Object.prototype.toString.call(obj)`。
- `obj.constructor && obj.constructor.name`。
- `obj instanceof Constructor`。
## 修复策略
- XHR 状态:用 `const xhrState = new WeakMap()`,构造时 `xhrState.set(instance, {...})`;getter / method 中 `xhrState.get(this)`。
- Event listener:用 `WeakMap<EventTarget, ListenerStore>`;`addEventListener` / `removeEventListener` 不在实例上创建 `_listeners`。
- DOM 节点关系:用 addon / xbs native DOM 能力优先;JS fallback 用 WeakMap 保存 parent / children / ownerDocument,不能在节点对象上挂 `__children`、`__parentNode`。
- Headers / Response body:用 WeakMap 保存 normalized headers、body buffer、bodyUsed;`Headers` 自身 ownKeys 必须按浏览器 baseline。
- Storage:真实可枚举 key 只应该是 storage 业务 key;内部 map 不得是 `_store` / `__items` own property。
## 阻断规则
- 发现浏览器可见对象存在自定义 `_` / `__` own property,直接阻断。
- `Object.getOwnPropertyNames` / `Reflect.ownKeys` 比 baseline 多出内部字段,直接阻断,除非真实浏览器 baseline 也有该字段。
- baseline 缺失、Node audit 缺失、状态为 `mismatch` / `native-capability-gap` / `unknown` 时,不得继续真实请求验证。
- 需要 native 内部槽而纯 JS / 当前 addon / xbs 均无法表达时,进入 `native-capability-gap.md` 闭环,不得用 non-enumerable 属性硬凑。
## 阶段报告模板
```markdown
## 对象形状审计矩阵
- 矩阵文件:case/notes/object-shape-audit.md
- 浏览器 baseline:case/fixtures/browser-object-shape-baseline.json
- Node audit:case/tmp/node-object-shape-audit.json
- baselineId:
- 私有状态实现:addon/xbs private API / WeakMap / native / 未涉及
- `_` / `__` 自有属性泄露:无 / 列表
- 阻断项:无 / 列表
| 对象 | Probe | 浏览器证据 | Node 证据 | 状态 | 处理 |
|---|---|---|---|---|---|
| XMLHttpRequest instance | Reflect.ownKeys | fixtures/... | tmp/... | matched | 使用 WeakMap 保存 readyState |
```
## 检查命令
```bash
node scripts/check_object_shape_audit.js --case-dir case --markdown
node scripts/check_object_shape_audit.js --case-dir case --require --json
```
检查失败时,下一步只能迁移私有状态到 addon / xbs private API 或 WeakMap、补浏览器 baseline、补 Node audit、修 descriptor / prototype / ownKeys 差异,或进入 native 能力缺口闭环。

View File

@@ -0,0 +1,172 @@
# 补环境框架选择、xbs isolated-vm 与 Trace 复杂度评估
本文件用于进入 Node.js 补环境阶段前读取。核心原则:是否使用补环境框架由用户选择;Trace 复杂度只用于理解补环境范围、风险和优先级,不自动决定框架。
## 核心规则
1. 进入正式补环境前必须提醒用户选择补环境框架。
2. 默认不使用补环境框架;用户未明确选择时按“不使用补环境框架”继续。
3. 可选项只有:不使用、isolated-vm、Node.js 内置 vm、jsEnv。
4. 选择 isolated-vm 时必须使用本 Skill 随包魔改 xbs isolated-vm,并在 Context 内使用 window.xbs / globalThis.xbs。
5. 平台缺失时要求用户提供匹配魔改构建产物或改选框架;Node ABI 不匹配时先读取 `node-version-recovery.md`,提示 xbs isolated-vm 兼容 Node.js v26.3.1,检测 nvm 并征得用户同意是否安装 / 切换兼容 Node,用户拒绝或切换后仍失败时才要求匹配构建产物或改选框架;不要自动 npm 安装或退回 npm 原版 isolated-vm。
6. 不要把旧 addon.node 桥接进 isolated-vm;该模式下 addon-first 表达为 xbs native-first。
7. 最终项目只保留用户选择的 runtime;未选择 isolated-vm 时不得复制 xbs-isolated-vm/ 或 isolated_vm.node。
## 补环境前询问模板
```markdown
请确认本次补环境是否使用补环境框架:
1. 不使用补环境框架,默认
2. 使用 isolated-vm:加载本 Skill 随包魔改 xbs isolated-vm,在隔离 Context 内使用 window.xbs / globalThis.xbs 提供 createProtoChains、createNativeFunction、createGetter、createSetter、createNativeCollection、getMimeTypesAndPlugins、createUndetectable 等 native-first 能力;兼容 Node.js v26.3.1,进入前会自检当前 Node ABI,ABI 不匹配时先询问是否通过 nvm 安装 / 切换 v26.3.1
3. 使用 Node.js 内置 vm:轻量、无需额外安装,但隔离弱于 isolated-vm
4. 使用 jsEnv:请先提供 jsEnv 项目路径、安装方式、入口文件和使用文档;未检测通过前不会复制 jsEnv runtime 或虚构 API
如果你不明确选择,我将按“不使用补环境框架”继续。
说明:Trace 复杂度评估只用于理解补环境范围、风险点和优先级,不会自动决定是否使用补环境框架。
```
记录到 case/notes/补环境框架选择.md、阶段报告和最终项目总结。记录字段至少包括:框架选择、选择来源、Node 版本、Node ABI、平台、xbs 自检结果、是否发生切换、切换原因;如发生 ABI 不兼容,记录 nvm 检测结果、用户是否同意切换 Node.js v26.3.1、重新自检结果或用户拒绝原因。
## Trace 复杂度评估
如果存在 RuyiTrace NDJSON、run_with_trace.js 产生的 JSONL、missing-env.json 或其他环境访问日志,必须用日志辅助理解复杂度。复杂度评估只用于:
- 判断补环境范围。
- 排定 WebAPI 补齐优先级。
- 识别指纹、异步、状态和真实性检测风险。
- 写入阶段报告。
复杂度评估不得用于自动选择 isolated-vm、vm、jsEnv 或“不使用框架”。
```bash
node scripts/analyze_trace_complexity.js --case-dir case --markdown
node scripts/analyze_trace_complexity.js --trace case/ruyi-trace/logs/trace.ndjson --json
node scripts/analyze_trace_complexity.js --trace case/tmp/env-trace.jsonl --markdown
```
## isolated-vm(随包魔改 xbs isolated-vm)
适用:用户明确选择 isolated-vm,或普通上下文后续遇到难以解决的环境检测并且用户确认切换。
必须读取:
- assets/runtime-frameworks/isolated-vm-runtime.js
- references/xbs-isolated-vm-api.md
- scripts/check_xbs_isolated_vm.js
随包二进制路径:assets/runtime-frameworks/xbs-isolated-vm/<platform>-<arch>/isolated_vm.node。
随包二进制导出形态可能是 `require(binaryPath)` 直接返回 isolated-vm API,也可能返回 `{ ivm }`;runtime 必须先归一化为含 `Isolate` / `ExternalCopy` 的对象,不得假设 `require(binaryPath).Isolate` 一定存在。
选择 isolated-vm 后必须先运行;如果 ABI 不兼容,再运行 Node 版本兼容检查并按用户确认结果处理:
```bash
node --no-node-snapshot scripts/check_xbs_isolated_vm.js --markdown
node --no-node-snapshot scripts/check_xbs_isolated_vm.js --strict --json
node scripts/check_node_runtime_compat.js --target isolated-vm --markdown
```
检测内容必须包含:当前 Node 版本、ABI、平台、二进制路径、是否加载成功、window === globalThis、window.xbs 是否存在、17 个 xbs 核心 API 是否齐全、xbs.dom.createDocument 是否可用、基础 DOM smoke test 是否通过。ABI 不兼容时不得直接改选或降级,先询问用户是否通过 nvm 安装 / 切换 Node.js v26.3.1;用户拒绝后才允许提供匹配构建产物或改选不使用框架 / vm / jsEnv。
window.xbs 期望 API:
```text
clearProtoChainRegistry
createGetter
createInterceptor
createNativeCollection
createNativeFunction
createNativeObject
createProtoChains
createSetter
createUndetectable
deletePrivate
deleteProtoChainRegistryEntry
getMimeTypesAndPlugins
getPrivate
getProtoChainRegistry
hasPrivate
setPrivate
throwTypeError
```
补环境代码必须运行在 isolated-vm Context 内,优先使用 xbs.createProtoChains、xbs.createNativeFunction、xbs.createGetter、xbs.createSetter、xbs.createNativeCollection、xbs.getMimeTypesAndPlugins、xbs.createUndetectable、xbs.setPrivate / getPrivate 等 API。
## xbs DOM 入口要求
- `xbs.dom.createDocument(options)` 是当前 xbs DOM 的唯一公开入口;不要使用已删除的 attachDocument、detachDocument、installConstructors、getConstructors、createElement、createIframeDocument、attachIframeDocument、detachIframeDocument、patchApi、removeApi、createProfile、getProfile、listProfiles、snapshotSurface、restoreSurface、getLastError 等旧 API。
- `createDocument()` 支持 `url`、`html`、`skeleton`、`omitApis`、`disabledApis`、`removedApis`、`features.documentAll`、`features.iframeContentDocument`;`profile` 参数已删除,不得继续传入。
- 默认不会把 `document` 挂到 `window`,补环境代码必须按 case 需要自行安装 `window.document`。
- DOM 构造函数不会默认挂到 `window`,但 document / element 实例自身应具备正确内部构造函数和原型链,例如 `document.constructor.name === "HTMLDocument"`。
- 禁用 DOM API 必须在创建 document 前通过 `omitApis` 或 `features` 声明,不要运行时强删已经发布的不可配置属性。
- `document.all` 默认由 `xbs.dom.createDocument()` 提供,期望满足 `typeof document.all === "undefined"`、`Boolean(document.all) === false`、`document.all == null`、`document.all.length`、`item()`、`namedItem()` 与 `[object HTMLAllCollection]` 等行为;禁用时使用 `omitApis: ["document.all"]` 或 `features.documentAll: false`。
- 只有不使用 `createDocument()` 而手写 document 时,才手动使用 `xbs.createUndetectable(callback, handlers)` 创建 `document.all`;此时必须把 `length / item / namedItem / Symbol.toStringTag` 放到 `HTMLAllCollection.prototype`,handlers 未命中时返回 `{ intercept: false }` 放行原型链,不要把这些属性定义为 `document.all` 自有属性。
- iframe 不再使用旧 `createIframeDocument()` / `attachIframeDocument()`;iframe 的 `contentDocument` / `contentWindow` 由 iframe 元素按需创建,禁用时使用 `omitApis: ["HTMLIFrameElement.prototype.contentDocument"]`。
- 当前 DOM 是补环境基础实现,不是完整浏览器内核;真实布局、CSSOM、MutationObserver、Canvas、WebGL 等仍按本 Skill 对应指纹 / 高强度 diff 流程补齐。
Context 内示例:
```js
const chain = xbs.createProtoChains([
{ name: "EventTarget", length: 0, constructorBehavior: "allow" },
{
name: "Navigator",
length: 0,
prototypeParent: "EventTarget",
constructorBehavior: "illegal",
callBehavior: "illegal",
constructorErrorMessage: "Illegal constructor",
callErrorMessage: "Illegal constructor",
instanceFactoryName: "createNavigator",
},
]);
Object.defineProperty(chain.Navigator.prototype, "userAgent", {
get: xbs.createGetter("userAgent", 0, function () {
return __fixture__.navigator.userAgent;
}),
enumerable: true,
configurable: true,
});
globalThis.navigator = chain.createNavigator();
```
## isolated-vm 文件化补环境加载模式
- isolated-vm 底层执行的仍然是源码字符串,但最终交付源码必须是普通文件;宿主 runtime 负责读取文件内容并传给 `compileScriptSync(source, { filename })`。
- 禁止把主要补环境代码集中写入 `String.raw` 大字符串、`CORE_SCRIPT`、`BROWSER_OBJECTS_SCRIPT` 或 `script-browser-objects.js` 这类聚合脚本。
- runtime 必须提供或等价实现 `runFile(relativePath, options)` 和 `runFiles(fileList, options)`,默认从项目根目录或显式 `sourceRoot` 读取 UTF-8 文件,并限制路径不能逃逸 sourceRoot。
- `runFile` 必须给 isolated-vm 编译选项传入可读 `filename`,让错误栈能定位到 `src/env/browser-objects/navigator.js`、`document.js`、`canvas.js` 等具体文件。
- `install-env.js` 只做装配:设置 fixture / config / 宿主桥接引用,按 manifest 顺序调用 `runtime.runFiles([...])`,再执行极小的安装入口。
- Context 内不要直接实现 CommonJS `require` 来加载本地文件;除非用户明确要求复杂模块系统,默认采用宿主按顺序注入文件的方式,简单、安全、可审计。
- 文件组织建议:`src/env/core/` 放 descriptor、cookie-store、native helper;`src/env/browser-objects/` 放 window、navigator、document、location、screen、xhr、storage;`src/env/fingerprint/` 放 canvas、webgl、audio、dom-geometry。
- 质量检查必须运行 `scripts/check_code_quality.js --case-dir case --markdown`;如果检测到大段 `String.raw` WebAPI 脚本或缺少文件化模块,应先重构再交付。
## Node.js 内置 vm
用户明确选择 vm 时才使用 assets/runtime-frameworks/vm-runtime.js。仍需显式构造干净 context,不得暴露 process、Buffer、require、module、global,也不得把 vm 当强安全边界。
## jsEnv
用户明确选择 jsEnv 时才使用 assets/runtime-frameworks/jsenv-runtime.js。必须先确认 jsEnv 项目路径、版本、安装命令、入口模块、初始化函数、如何注入 env、如何加载目标 JS、如何调用 signer、如何释放资源;未提供文档时只能生成待适配模板,不能虚构 API。
## 最终项目复制规则
- 不使用框架:不得复制 vm-runtime.js、isolated-vm-runtime.js、jsenv-runtime.js、xbs-isolated-vm/。
- 选择 vm:复制 runtime-factory.js 和 vm-runtime.js。
- 选择 isolated-vm:复制 runtime-factory.js、isolated-vm-runtime.js 和 xbs-isolated-vm/<platform>-<arch>/isolated_vm.node;用户提供替换二进制时复制到相对结构或通过配置引用,不要把本机绝对路径写入最终产物。
- 选择 jsEnv:复制 runtime-factory.js、jsenv-runtime.js,并根据用户文档补 adapter。
## 二次提醒触发条件
如果默认未使用框架,但后续出现普通上下文无法阻断 Function("return this")()、constructor.constructor、Realm / intrinsic 差异、全局对象污染、多 fixture 互相影响等问题,暂停并询问是否切换。不得自动启用框架。
## 交付检查
- 框架选择已记录。
- Trace 复杂度评估已写入阶段报告(如果存在 Trace)。
- 选择 isolated-vm 时已记录 xbs 自检、Node ABI、平台、二进制来源;如 ABI 不兼容,已记录 nvm + Node.js v26.3.1 恢复流程、用户选择和重新检测结果。
- 选择 isolated-vm 时最终代码在 Context 内优先使用 xbs API,没有桥接旧 addon.node;补环境源码按真实文件模块组织,并由 runtime.runFile / runtime.runFiles 注入 Context,没有大段 `String.raw` 作为主要实现。
- 未选择 isolated-vm 时最终项目不含 xbs-isolated-vm/、isolated_vm.node 或 isolated-vm runtime。
- 未选择框架时最终项目不含 vm-runtime.js、isolated-vm-runtime.js、jsenv-runtime.js。

View File

@@ -0,0 +1,434 @@
# ruyiPage / RuyiTrace 集成流程
当新 case 从一开始选择 ruyiPage / RuyiTrace 作为取证模式,或需要检测、安装、采集、导入 ruyiPage / RuyiTrace 材料时读取本文件。不要等确认目标站点存在自动化、CDP、JS Hook 或浏览器指纹检测后才使用 ruyiPage / RuyiTrace。
## 来源与定位
- ruyiPage:<https://github.com/LoseNine/ruyipage>
- Python Firefox 自动化框架。
- 基于 Firefox + WebDriver BiDi,不走 CDP。
- 支持配套 Firefox runtime、指纹浏览器、网络抓包、请求拦截、Cookie、本地存储、拟人动作等。
- RuyiTrace / 如意 Trace:<https://github.com/LoseNine/Firefox-FingerPrint-Analyzer>
- Windows x64 桌面工具,随包包含定制 Firefox trace 内核。
- 采集 NDJSON 运行时 DOM / JS API 调用日志。
- 探针在浏览器内核层,适合为补环境提供高保真环境访问日志。
仅在用户授权的网页端 JS 补环境、防御性分析、学术研究场景中使用。不要用这些工具绕过登录、验证码、MFA、付费墙、服务条款或业务风控。
## RuyiTrace 优先诊断原则
当用户确认取证模式为 **ruyiPage + RuyiTrace** 时,RuyiTrace NDJSON 不是可选参考,而是补环境阶段的优先证据源:
1. 进入 Node.js 补环境前,必须先确认是否已经采集并导入 RuyiTrace NDJSON。
2. 如果已有 NDJSON,先运行 `import_ruyitrace_log.js` 生成 `notes/ruyitrace-summary.md`,再阅读摘要和必要的原始日志片段。
3. 遇到 ReferenceError、TypeError、输出不一致、缺失指纹对象、静默失败、toString / descriptor / accessor / 原型链 / `document.all` 异常等环境问题时,先回看 NDJSON,而不是直接盲补 `env.js`。
4. 优先按以下证据定位:
- `api` 调用频率和类别。
- 与目标参数生成、请求发起、writer 写入时间邻近的调用。
- `stack.file / line / col` 指向的 JS 文件、模块和函数。
- navigator / screen / document / storage / canvas / WebGL / audio / crypto / performance / worker / iframe 等环境模块分类。
5. 只有在 NDJSON 缺失、未覆盖当前路径、日志时间段不对应、或日志结论不足时,才使用 `run_with_trace.js`、Proxy trace、Hook 或断点作为补充。
6. 输出补环境计划时,必须标明哪些环境依赖来自 RuyiTrace 证据,哪些只是 Node trace / 推断,避免把推断写成事实。
7. RuyiTrace 长字符串字段可能被截断。导入日志后,如果任意字符串字段达到或接近 4000 字符,必须标记为疑似截断:真实长度写 `unknown`,最小长度写可见长度,不能把 4000 或可见长度解释为加密参数或指纹值真实长度。涉及 WebAPI / 指纹具体值时,未截断 RuyiTrace 值优先;RuyiTrace 未选择、缺失、未覆盖或疑似截断时,必须使用当前用户确认的取证工具在同一 fingerprint baseline 下补采完整值,不能由 AI 猜值。
如果用户选择了 ruyiPage + RuyiTrace 但尚无日志,不能默认等待用户手动 trace。应先检测 RuyiTrace 是否已安装;检测通过后优先用 `scripts/capture_ruyitrace_log.js` 自动启动随 RuyiTrace 提供的 trace Firefox 捕获 NDJSON,再导入摘要。只有自动捕获失败、需要登录 / 验证 / 权限交互、目标路径未覆盖、工具不可控,或用户明确选择手动取证时,才暂停并让用户手动协助采集 / 提供 NDJSON;用户明确确认无法提供后,才降级为 ruyiPage 网络证据 + Node trace 流程。
如果用户选择了 ruyiPage + RuyiTrace 但检测到 RuyiTrace 未安装或目录不完整,不得自动改成“仅 ruyiPage”,也不要只建议“仅使用 ruyiPage”。必须先引导用户安装 / 提供 RuyiTrace 路径,或让用户明确确认降级;用户选择安装时,需要等待用户安装完成并再次验证通过后才继续任何依赖 NDJSON 的流程。
## 取证工具选择权必须交给用户
新网页端补环境任务开始后、任何取证动作之前,先让用户选择:
```markdown
请先选择本 case 的取证模式。后续抓包、JS 收集、Hook、断点、截图、RuyiTrace 日志采集等取证操作都会沿用该模式;如果后续需要切换工具,我会再次请求确认。
1. ruyiPage + RuyiTrace(推荐):用 ruyiPage 做 Firefox/BiDi 自动化取证,用 RuyiTrace 采集 NDJSON 环境日志辅助补环境。
2. 仅 ruyiPage:只用 ruyiPage 打开页面、抓包、收集 JS,不采集 RuyiTrace 日志。
3. CloakBrowser:沿用 Chromium/CloakBrowser 方案取证。
4. 用户手动取证:你手动提供 cURL、HAR、JS 文件、调用栈截图、RuyiTrace 日志。
5. AI 自行决定:我根据目标风险和本机工具可用性提出建议,但仍会在启动浏览器前再次确认。
请回复你选择的编号,并说明是否已经安装 ruyiPage / RuyiTrace / CloakBrowser。
```
用户未选择前,不要直接启动浏览器工具;用户选择后,后续所有浏览器取证动作都必须沿用该选择,不能临时 fallback 到普通 Playwright、Puppeteer 或系统 Firefox。
## 验证码场景的 RuyiTrace 覆盖
如果目标是验证码 / 风控验证 / challenge / WAF 接口,RuyiTrace 自动捕获或手动捕获都必须覆盖完整链路:触发验证码、验证码组件初始化、用户交互事件、加密参数生成、verify / validate / challenge 接口发起、结果回调。
- 用户提供完整流程时,自动捕获脚本应按该流程执行;若流程需要人工识别、登录、验证码答案或权限交互,暂停让用户完成。
- 用户选择自己完成流程时,先启动 RuyiTrace 记录,再让用户操作;只有用户回复“已经完成触发到验证流程”后,才停止记录并导入 NDJSON。
- 如果 `notes/ruyitrace-summary.md` 只覆盖页面加载、没有交互事件或 verify 接口附近调用栈,应要求重新采集,不得直接进入补环境。
## 本机工具检测
先运行检测脚本:
```bash
node scripts/check_external_tools.js --markdown
node scripts/check_external_tools.js --json
node scripts/check_external_tools.js --python python --ruyipage-install-dir <ruyipage-browsers-dir> --markdown
node scripts/check_external_tools.js --python python --ruyipage-browser-path <firefox.exe> --markdown
```
**强制要求**:选择 ruyiPage 时,不能只检测 `import ruyipage` 是否成功,也不能把系统 Firefox fallback 当作可用。必须确认:
- ruyiPage Python 包可导入。
- Python 环境具备 `requests`,或已准备 `smart_fingerprint(manual_geo=...)` 所需地理信息;否则默认智能指纹地理探测会失败。
- Firefox 可执行文件来自 ruyiPage managed runtime,或来自用户明确提供且可验证的 ruyiPage 定制 Firefox。
- runtime 根目录存在 `install.json`。
- `install.json.release`、`install.json.asset` 或 runtime 目录名体现 `ruyi` 定制标识,例如官方 manifest 当前使用的 `151-ruyi`。
- `install.json.executable` 指向的 Firefox 文件确实存在。
如果检测结果显示“系统 Firefox fallback / 未验证路径风险”,判定 ruyiPage 绕检测方案 **不通过**,暂停流程并要求用户提供定制 Firefox 路径或安装目录。
如果用户已安装但脚本未检测到,要求用户提供:
- ruyiPage:Python 解释器路径,或确认当前 `python` 可以 `import ruyipage`。
- ruyiPage runtime:`python -m ruyipage path` 输出的 Firefox 路径、`--ruyipage-install-dir` 指向的 managed runtime 根目录,或 `--ruyipage-browser-path` 指向的定制 Firefox 可执行文件路径。
- RuyiTrace:`RuyiTrace.exe` 所在目录,或 `RuyiTrace.exe` 路径;该目录中应保留 `firefox/` 子目录和 `firefox/RUYI_DOMTRACE.txt`。
- 日志目录:RuyiTrace 生成 NDJSON 的目录。
示例:
```bash
node scripts/check_external_tools.js --python python --ruyitrace-home <RuyiTrace-dir> --markdown
node scripts/check_external_tools.js --python python --ruyipage-browser-path <verified-ruyipage-firefox.exe> --markdown
```
## ruyiPage + RuyiTrace 但 RuyiTrace 未安装
当本 case 的取证模式已经确认为 **ruyiPage + RuyiTrace**,检测脚本返回 RuyiTrace 未安装、`RuyiTrace.exe` 不存在、`firefox/` 子目录缺失,或 `firefox/RUYI_DOMTRACE.txt` 缺失时,按以下强制流程处理:
1. **不要自动降级**:不得把取证模式静默切换为“仅 ruyiPage”,也不得继续进入需要 RuyiTrace NDJSON 的补环境分析。
2. **暂停并提示用户选择**:必须让用户在“安装 / 提供 RuyiTrace 路径”和“明确降级为仅 ruyiPage”之间选择。
3. **用户选择安装 / 提供路径时**:
- 若已安装但未检测到,要求用户提供 `RuyiTrace.exe` 路径或所在目录。
- 若未安装,要求用户提供下载 / 安装目录。
- 先用 `download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --dry-run --markdown` 输出下载计划。
- 用户确认后才下载;下载后提示用户解压 / 安装。
- 等用户确认 `RuyiTrace.exe` 可打开、`firefox/` 定制内核目录存在、日志目录可选择后,再重新运行检测。
4. **用户选择降级时**:
- 记录“取证模式已由 ruyiPage + RuyiTrace 经用户确认降级为仅 ruyiPage”。
- 后续不得再假设存在 NDJSON。
- 补环境阶段使用 ruyiPage 网络证据、Hook / 断点证据、Node trace / Proxy trace 作为替代来源,并在输出中标明缺少 RuyiTrace 高保真日志。
提示模板:
```markdown
你选择的是 ruyiPage + RuyiTrace,但当前未检测到可用的 RuyiTrace。该模式需要 RuyiTrace NDJSON 作为补环境优先证据源。
请选择:
1. 安装 / 提供 RuyiTrace 路径:我会先输出下载 / 安装计划,并等待你确认安装完成后再继续。
2. 明确降级为“仅 ruyiPage”:后续不采集 RuyiTrace NDJSON,补环境将使用 ruyiPage 网络证据 + Hook / Node trace 作为替代。
在你确认前,我不会自动降级,也不会继续需要 RuyiTrace 日志的流程。
```
## 未安装时的安装流程
只有当用户明确说未安装,并确认允许下载 / 安装时,才进入本节。
### ruyiPage
先询问用户:
```markdown
未检测到 ruyiPage 定制 Firefox runtime,或当前 ruyiPage 可能会退回系统 Firefox。请确认:
1. 你是否已经提前安装好 ruyiPage 定制 Firefox?
2. 如果已经安装,请提供 ruyiPage browsers 安装目录,或提供定制 Firefox 的可执行文件路径。
3. 如果没有安装,请提供希望安装到的目录。我会先输出安装计划;你确认后再执行安装。
```
推荐让用户自行在 Python 环境中安装:
```bash
python -m pip install ruyiPage --upgrade
python -m pip install requests --upgrade
python -m ruyipage install --install-dir <ruyipage-browsers-dir>
python -m ruyipage doctor --install-dir <ruyipage-browsers-dir>
python -m ruyipage path --install-dir <ruyipage-browsers-dir>
```
如果用户需要 async 支持:
```bash
python -m pip install "ruyiPage[async]" --upgrade
```
也可以使用随包安装脚本输出计划或在用户确认后安装:
```bash
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --markdown
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --install --markdown
```
如果用户已自行准备 Firefox、便携版 Firefox 或 Firefox 指纹浏览器,只有在它确认为 ruyiPage 定制 Firefox 或用户明确说明是等价指纹浏览器时才可跳过 runtime 安装;普通系统 Firefox 不能作为绕自动化检测的 ruyiPage 方案。
### RuyiTrace
RuyiTrace 是桌面工具。安装原则:
1. 先让用户提供下载目录。
2. 仅在用户确认后下载 Release 资产。
3. 下载完成后提示用户解压 / 安装,并保持 `RuyiTrace.exe` 与 `firefox/` 子目录在同一目录。
4. 等用户确认 `RuyiTrace.exe` 可以打开且“定制内核”状态正常后,再继续。
可用脚本查看下载计划或下载:
```bash
node scripts/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --dry-run --markdown
node scripts/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --markdown
```
如只需要 ruyiPage 配套 Firefox runtime,优先使用 `python -m ruyipage install`;需要离线下载时再使用:
```bash
node scripts/download_ruyi_tool.js --tool ruyipage-firefox --dest <download-dir> --dry-run --markdown
```
下载脚本只负责下载,不默认解压、安装或启动桌面程序。
## ruyiPage 取证流程
用户选择 ruyiPage 后:
1. 检查 ruyiPage 包、`requests` 依赖和 Firefox runtime,并确认 runtime 是 ruyiPage 定制 Firefox。
- 如果只检测到系统 Firefox fallback,立即暂停,不启动浏览器。
- 如果已找到定制 Firefox 但不是默认解析路径,启动时必须显式指定 `browser_path` / `set_browser_path`。
- 如果缺少 `requests`,必须安装依赖或让用户提供 `manual_geo`;不要静默跳过智能指纹。
2. 按“ruyiPage 启动硬约束”启动;任一硬约束失败时,停止并报告,不要继续取证。
3. 确认是否需要登录;需要登录时让用户手动完成。
4. 使用有头模式打开页面。
5. 触发最少量必要业务动作。
6. 收集:
- Network / cURL / HAR。
- JS bundle / chunk / sourcemap URL。
- Cookie、本地存储键名、请求头、响应状态。
- source / entry / builder / writer 链路证据。
7. 单个取证动作完成并沉淀必要结论后,立即清理临时截图、失败下载、临时日志和无登录态 profile;登录态 profile 单独询问用户是否保留。
### ruyiPage 启动硬约束
不要把“能启动浏览器”当作 ruyiPage 取证成功。每次 ruyiPage 取证都必须从一开始满足以下约束:
| 约束 | 要求 |
|---|---|
| 定制内核 | 必须显式使用已验证的 ruyiPage 定制 Firefox runtime;不得使用系统 Firefox fallback |
| 有头模式 | 必须 `headless(False)` 或等价有头模式;不要用 headless 做高风控取证 |
| 独立 Profile | 使用本 case 专用临时 `user_dir` / profile,不复用脏 profile |
| 智能指纹 | 默认调用 `opts.smart_fingerprint(require_country=None, base_dir=..., userdir=...)`;如果地理探测失败,要求安装 `requests` 或提供 `manual_geo`,不要静默跳过 |
| 仿真注入 | 如果 `smart_fingerprint()` 返回 `ctx`,创建页面后必须执行 `ctx.apply_emulation(page)` |
| 指纹一致性 | 第一次成功取证后写入 `case/notes/fingerprint-baseline.json` 和 `baselineId`;geolocation、timezone、locale、viewport、UA、Client Hints、screen、WebGL 与 `smart_fingerprint` 输出和出口 IP 保持一致;后续复用同一 `base_dir` / `userdir`,不要每次随机新指纹 |
| 拟人动作 | 设置 `set_human_algorithm("windmouse")` 或 `"bezier"`,优先使用拟人滚动 / 点击触发业务动作 |
| 取证时机 | `page.capture.start(...)` 必须在 `page.get(...)` 之前执行 |
| 自检 | 导航后检查 `navigator.webdriver`,期望为 `false`;若为 `true`,判定当前取证不合格 |
| 验收 | 目标接口必须捕获到非失败响应;对跨域接口不要把单独的 `OPTIONS` preflight 当作业务取证成功 |
| isTrusted | 点击、拖拽、鼠标、键盘、滚动优先使用原生 BiDi / human actions;确需 JS 构造事件时必须带 `ruyi: true`;普通 `dispatchEvent` 不视为可信输入 |
这些约束只能降低普通自动化 / CDP / 指纹检测风险,不能保证绕过所有业务风控、登录、验证码、MFA、设备验证或服务端策略。
## ruyiPage / RuyiTrace 指纹基线固定
- ruyiPage 第一次成功取证后,把 `smart_fingerprint` 输出、profile / userdir、UA、Client Hints、locale、timezone、viewport、screen、WebGL 等写入 `case/notes/fingerprint-baseline.json`。
- RuyiTrace 自动捕获或手动采集前先确认使用同一 case profile / baseline;如果 RuyiTrace 定制内核不能复用同一 profile,必须采样核心字段并写入 `case/notes/fingerprint-baseline-diff.md`,不一致时暂停。
- 后续 Hook、截图、网络抓包、指纹 fixture 采样必须带同一 `baselineId`;缺少 `baselineId` 时不得把样本用于最终 env。
- 如果用户更换代理、地区、语言、profile 或工具,生成新的 baseline,旧样本不能和新样本混用。
### ruyiPage isTrusted 交互规则
高风控点击、拖拽、键盘输入、滚动和验证码交互优先使用 ruyiPage 原生 BiDi / human actions:
```python
page.actions.move_to(page.ele("#btn")).click().perform()
page.actions.drag(page.ele("#source"), page.ele("#target"), duration=640, steps=16).perform()
page.actions.release()
page.actions.human_move(ele, algorithm="windmouse").perform()
page.actions.human_click(ele, algorithm="windmouse").perform()
```
如果必须构造 JS 事件,只允许使用 ruyiPage 的 `ruyi: true` 特定能力,并在取证报告中说明事件类型和参数:
```javascript
new MouseEvent('click', { bubbles: true, clientX: 12, clientY: 24, ruyi: true });
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter', code: 'Enter', ruyi: true });
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, clientX: 3, clientY: 5, ruyi: true });
```
普通 `dispatchEvent(new MouseEvent(...))`、`new KeyboardEvent(...)` 不得作为验证码或高风控交互主路径。无法保证可信输入时,暂停并让用户手动完成或切换工具。
示例骨架:
```python
from ruyipage import FirefoxOptions, FirefoxPage
opts = FirefoxOptions()
opts.set_browser_path("<verified-ruyipage-managed-firefox>")
opts.set_user_dir("<case-browser-profile>")
opts.headless(False)
opts.set_window_size(1366, 900)
opts.set_human_algorithm("windmouse")
ctx = opts.smart_fingerprint(
require_country=None,
# 同一 case 固定该目录,避免每次随机生成不同指纹。
base_dir="<case-tmp-fingerprint-dir>",
# 同一 case 固定该 profile,后续 RuyiTrace / Hook / 指纹采样复用同一基线。
userdir="<case-browser-profile>",
)
page = FirefoxPage(opts)
ctx.apply_emulation(page)
page.capture.start(targets="<target-api-keyword>", collect_bodies=True)
page.get("<target-page-url>")
assert page.run_js("return navigator.webdriver") is False
packets = page.capture.wait(timeout=30, count=1)
```
只有当 `node scripts/check_external_tools.js --markdown` 显示“默认解析路径是否为定制 Firefox:是”时,才可直接 `FirefoxPage()` 或 `launch(headless=False)`。否则必须显式指定已验证的定制 Firefox 路径。
### ruyiPage 取证验收标准
- JD `pc_home_feed` 类接口:至少捕获到 URL 包含 `pc_home_feed` 的 2xx 响应,并能看到请求 URL 中的加密 / 风控参数,例如 `h5st`。
- 美团外卖 `shopList` 类跨域接口:必须区分 `OPTIONS` preflight 与真实业务请求;只有捕获到非 `OPTIONS` 的 2xx `shopList` 响应,才算取证成功。若返回登录 / Yoda / 401 风控信息,应按“需要登录 / 风控验证”流程暂停,不要宣称已绕过。
## RuyiTrace 日志采集流程
用户选择 RuyiTrace 后,采集策略是 **自动捕获优先,手动采集兜底**。
### 自动捕获优先
检测到 `RuyiTrace.exe`、`firefox/` 子目录、`firefox/firefox.exe` 和 `firefox/RUYI_DOMTRACE.txt` 完整后,不要默认让用户手动打开 GUI。优先使用随包脚本自动启动 RuyiTrace 的 trace Firefox,并通过 `MOZ_DOM_TRACE` 环境变量写出 NDJSON:
```bash
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --ruyitrace-home <RuyiTrace-dir> --dry-run --markdown
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --ruyitrace-home <RuyiTrace-dir> --duration 90 --import-after --markdown
```
执行要求:
1. 自动创建或使用 `case/ruyi-trace/logs/` 作为日志目录,使用 `case/tmp/ruyitrace-profile/` 或用户确认的临时 Profile。
2. 使用 RuyiTrace 随包 trace Firefox,而不是普通系统 Firefox、普通 Playwright、Puppeteer 或 ruyiPage 的 Firefox runtime。
3. 设置 `MOZ_DOM_TRACE=1`、`MOZ_DOM_TRACE_FILE=<case trace file>`、`MOZ_DOM_TRACE_LIMIT=<limit>` 和 `MOZ_DISABLE_LAUNCHER_PROCESS=1`。
4. 打开目标页面后触发最少量必要业务动作;如果需要登录、验证码、MFA、设备验证或权限确认,暂停让用户在该 trace Firefox 中手动完成,再继续采集。
5. 自动捕获结束后,立即运行 `import_ruyitrace_log.js` 导入日志、生成 `notes/ruyitrace-summary.md`,并检查长字段截断风险。
6. 如果自动捕获没有生成 NDJSON,先记录失败原因和已执行命令,再进入手动兜底;不要把“没有日志”误写成目标没有环境访问。
自动捕获成功后继续:
```bash
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --truncation-threshold 3900 --markdown
```
### 手动采集兜底
只有在以下情况才要求用户手动采集:
- 自动捕获启动失败或 RuyiTrace trace Firefox 无法写日志。
- 目标必须由用户登录、验证、MFA、设备确认或完成复杂交互。
- 用户明确要求使用 RuyiTrace GUI。
- 自动采集的日志未覆盖目标参数生成路径,需要用户按指定动作重新采集。
手动流程:
1. 打开 `RuyiTrace.exe`。
2. 填写启动页面。
3. 选择日志目录,建议选择当前 case 的 `ruyi-trace/logs/` 或用户指定目录。
4. 点击“开始采集”。
5. 在浏览器中正常浏览并触发目标指纹 / 加密参数生成逻辑。
6. 点击“停止采集”。
7. 找到 `trace_<时间戳>_<PID>.ndjson`。
8. 使用脚本复制到 case 并生成摘要:
```bash
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --markdown
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --truncation-threshold 3900 --markdown
```
高级手动启动方式仅在用户理解环境变量时使用:
```cmd
set MOZ_DOM_TRACE=1
set MOZ_DOM_TRACE_FILE=<trace-output.ndjson>
set MOZ_DOM_TRACE_LIMIT=<max-lines>
set MOZ_DISABLE_LAUNCHER_PROCESS=1
<ruyitrace-firefox.exe> -no-remote -new-instance <target-page-url>
```
可选环境变量:
| 变量 | 用途 |
|---|---|
| `MOZ_DOM_TRACE=1` | 开启 trace |
| `MOZ_DOM_TRACE_FILE=<path>` | 输出路径,PID 自动追加 |
| `MOZ_DOM_TRACE_LIMIT=<n>` | 单进程行数上限 |
| `MOZ_DOM_TRACE_PTYPE=<list>` | 启用 trace 的进程类型 |
| `MOZ_DISABLE_LAUNCHER_PROCESS=1` | Windows 下避免 launcher 提前退出 |
## RuyiTrace 长字段截断保护
RuyiTrace NDJSON 适合作为高保真环境访问日志,但长字符串字段可能因工具显示或记录限制被截断。典型风险是某个加密参数、长 token、长 Cookie、请求 body、dataURL 或大型对象序列化值真实长度为数万字符,但日志中只保留约 4000 字符。
硬性规则:
- 导入 NDJSON 时必须运行带截断检测的脚本,默认阈值为 3900:
```bash
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --truncation-threshold 3900 --markdown
```
- 任何字符串字段长度达到或接近阈值时,统一标记:
- `truncationSuspected: true`
- `visibleLength: <日志中可见长度>`
- `minLength: <日志中可见长度>`
- `actualLength: unknown`
- 不得写“该加密参数长度为 4000”。只能写“RuyiTrace 可见长度为 4000,疑似被截断,真实长度未知,至少 4000”。
- 不得把 RuyiTrace 中的长字段可见值直接作为 fixture 期望值或最终参数值。
- 如果该字段影响签名、指纹回放或最终请求验证,必须从以下来源补采完整值:
1. HAR / cURL / Network 完整请求。
2. ruyiPage `collect_bodies=True` 网络抓包。
3. 专用 Hook 对 writer 或加密入口做分片落盘,并记录完整长度、SHA256、前后片段。
4. 最终 Node.js signer 输出,并与浏览器样本的完整长度或 hash 对比。
- 写入 `notes/missing-env-priority.md`、阶段报告或最终总结时,必须区分“RuyiTrace 未截断可用值”“RuyiTrace 可见但疑似截断值”和“其他来源补采完整值”。
- 对 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何、`navigator`、`screen`、`window`、`document` 等具体值:RuyiTrace 未截断值是优先来源;只要日志缺失、未覆盖或疑似截断,就改用已确认的 ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样,并记录 `baselineId`、`capturedBy`、完整长度和 hash。不得把 AI 猜值、静态推断或 Node.js 模拟库结果写入最终 fixture。
摘要中出现 `## 长字段截断风险` 时,后续分析要先处理完整值补采问题,再判断参数长度、结构、hash、编码或是否可复现。
如果截断字段是指纹 / WebAPI 返回值,例如 dataURL、WebGL 参数、字体探测结果、DOM 序列化结果或 Audio buffer 摘要,不得把 trace 可见片段直接写入 `fingerprint.fixture.json` 的最终 `result`;必须使用同一 case 已确认取证工具补采完整值,或显式阻塞等待用户提供完整真实浏览器材料。
## 根据 RuyiTrace 日志补环境
日志导入后按以下顺序分析。选择 ruyiPage + RuyiTrace 的 case,必须先完成本节,再进入 Node.js 缺失环境追踪:
1. 统计 `api` 调用频率,优先处理高频或和目标参数生成邻近的 API。
2. 按 `stack.file / line / col` 聚合,定位具体 JS 文件和函数。
3. 分类到环境模块:
- Navigator / Screen / Location / Storage。
- Canvas / WebGL / Audio / WebRTC。
- Crypto / Performance / Date / Random。
- DOM / Element / CSS / Layout。
- Worker / Service Worker / iframe。
4. 将日志结论写入:
- `notes/ruyitrace-summary.md`
- `notes/missing-env-priority.md`:必须包含命中的 `api`、`stack.file`、`line`、`col`、环境模块分类、补齐优先级,以及“RuyiTrace 证据 / Node trace 补充 / 推断”标记。
- `notes/entry-chain.md`
5. 再进入 Node.js 缺失环境追踪和 fixtures 验证。
遇到环境错误时的处理顺序:
1. 先在 `notes/ruyitrace-summary.md` 中搜索缺失对象、方法或相关模块,例如 `navigator`、`document.cookie`、`localStorage`、`canvas`、`WebGL`、`performance`。
2. 摘要不足时,在原始 `case/ruyi-trace/logs/*.ndjson` 中按目标 JS 文件名、目标 API 关键词、调用栈行列号或时间窗口过滤。
3. 将命中的 `api`、`stack.file`、`line`、`col`、参数摘要写入 `notes/missing-env-priority.md`。
4. 再用 Node trace 复现缺失路径,确认哪些对象需要在 `env.js` 中固化;固化时要同时处理属性描述符、访问器、原型链、函数 / 访问器 / 实例对象 toString 保护。
5. 如果 RuyiTrace 没有相关证据,明确标记“RuyiTrace 未覆盖”,再使用 Proxy trace / Hook / 断点继续排查。
6. 交付前运行 `node scripts/check_env_realism.js --case-dir case --require-ruyitrace --markdown`;涉及 `document.all` 时加 `--require-document-all`。
日志可能很大。大文件处理原则:
- 不把完整日志直接写入最终报告。
- 先导入并生成摘要。
- 必要时按行分块,优先分析和目标 API / 参数生成时间段相关的片段。
- 原始日志保存在 case 内,任务结束前询问是否保留。

View File

@@ -0,0 +1,122 @@
# 最终请求 Session 模式规则
本文件在每个需要发送真实请求、交付 `final.js` / `final.py`、实现动态资源刷新、Cookie / challenge 生成链路或最终请求验证时读取。最终请求不再区分单请求或请求链:只要进入最终验证或交付入口,必须使用 Session 模式。
## 硬性规则
## cURL 样本与取证 baseline 冲突时的 Session 基线
最终 Session 必须绑定同一套取证 baseline。用户提供的 cURL / HAR 如果与实际取证浏览器族或指纹基线冲突,只能用于请求结构和参数线索,不能作为 Session 的 UA、Client Hints、TLS / HTTP2、Cookie jar 或动态参数来源。
- 创建 Session 前先确认用户 cURL 浏览器族与取证 baseline 浏览器族是否一致。
- 不一致时默认以取证 baseline 创建 Session:Header、UA、Client Hints、Accept-Language、代理、TLS 指纹、HTTP/2 指纹、Cookie jar 和前置请求链均从取证样本或同 baseline 重新采样获得。
- Chrome cURL + Firefox 取证时,Session 中不得保留 Chrome `sec-ch-ua`,不得使用 Chrome TLS / HTTP2 profile;应使用 Firefox 取证 HAR / Network 样本和 Firefox TLS 对齐配置。
- cURL 中已有的 Cookie / token / sign / challenge 参数不能直接固定为 Session 初始状态;需要区分登录态、非登录生成型 Cookie 和一次性 challenge,按生成 / 刷新链路纳入同一 Session。
- 如果用户要求以 cURL 作为最终 Session 基线,必须重新使用同浏览器族工具取证,或记录用户确认风险并阻塞成功结论,不能混用两套 baseline 后宣称验证通过。
- 最终请求一律使用 Session 模式;即使当前看起来只有一个目标 API,也必须创建 session client。
- 前置请求、动态 HTML / JS / challenge 刷新、Cookie / token / 设备参数生成链路、加密参数生成前后的请求、目标 API 请求必须复用同一 session。
- 目标 JS 内部触发的 `XMLHttpRequest` / `fetch` / `sendBeacon` 如果参与动态资源、Cookie、challenge、telemetry、resource timing 或 writer 分支,也必须通过 live session bridge 复用同一 session;不得由 Node 宿主 `fetch` 或 fixture/mock 代替真实发送。
- 同一 session 内保持一致的 Cookie jar、UA、Client Hints、Accept-Language、Referer、Origin、Header 顺序、代理 / IP、TLS 指纹客户端和指纹基线。
- 不得在最终入口中用无状态 `fetch`、`axios`、`requests.request` 或临时单次客户端直接发送真实请求。
- 请求成功、失败或异常退出后都必须销毁 session,关闭底层客户端,并清理 Cookie jar、敏感 header、token、临时响应和运行态缓存。
- 如果用户选择“不发真实请求”,可以不创建真实网络 session,但最终入口仍应明确输出“未发请求”,不得伪造 session 验证成功。
## Node.js 实现要求
`result/src/request/client.js` 应封装 session client,而不是只导出单次 `sendRequest`。建议形态:
```javascript
'use strict';
async function createRequestSession(options) {
// 中文说明:这里封装已确认的 TLS 指纹兼容客户端,并维护同一 Cookie jar。
const state = {
cookieJar: new Map(),
headers: { ...(options.headers || {}) },
closed: false,
};
async function request(input) {
if (state.closed) throw new Error('Session 已销毁,不能继续发送请求');
// 这里调用 CycleTLS / impers / curl-cffi-node 等已确认客户端。
// 请求前合并 Cookie jar,请求后解析 Set-Cookie 写回同一 jar。
throw new Error('请按已确认客户端补齐请求实现');
}
async function close() {
state.closed = true;
state.cookieJar.clear();
// 如果底层客户端有 exit / close / dispose,这里必须调用。
}
return { request, close, state };
}
module.exports = { createRequestSession };
```
注意:CycleTLS / impers / curl-cffi-node 等不同版本的 API 差异较大。实际实现以本机安装版本为准,但必须保留统一 session 封装、Cookie jar 回写和 `close()`。
## Python 实现要求
Python 项目优先使用 `curl_cffi.requests.Session()` 或所选 cffi_curl / cyCronet 的 session / client 形态。建议形态:
```python
from curl_cffi import requests
def create_request_session(headers=None, impersonate="chrome"):
# 中文说明:所有请求复用同一个 Session,保证 Cookie、TLS 指纹和 Header 状态一致。
session = requests.Session(impersonate=impersonate)
session.headers.update(headers or {})
return session
def close_request_session(session):
try:
session.cookies.clear()
finally:
session.close()
```
如 cffi_curl / cyCronet 当前版本没有现成 Session,需要在 `src/request_client.py` 自行维护 Cookie jar 和底层连接生命周期,并提供 `close_request_session(...)`。
## 高强度入口页与请求顺序
如果目标存在 403 / 429 / challenge / bot block / verify / captcha / 风控页、动态 JS、首访 Cookie、检测脚本或 API 依赖入口页状态,最终请求链不能只包含目标 API。必须在同一 session 中记录和必要时复现:
1. 入口 HTML 请求及其响应状态、Set-Cookie、Cache-Control、body hash、动态 seed / nonce。
2. 关键 JS / challenge / telemetry / fingerprint 资源请求,及其是否需要运行时刷新。
3. Cookie / localStorage / sessionStorage / IndexedDB / device token 的生成或刷新点。
4. 业务前置接口与目标 API 的 Referer / Origin / Sec-Fetch 关系。
5. 请求顺序和必要的时间间隔;不要把浏览器中的多步链路压成无状态单请求。
6. 失败时记录是否返回风控页、Cookie 过期、请求链缺失、TLS / Header 不一致,还是目标 JS 参数错误。
最终项目仍不得包含浏览器自动化代码;入口页和动态资源刷新必须由已确认的 Node.js / Python TLS 指纹兼容 Session 客户端完成,或用户明确选择只输出本地参数。
## final 入口顺序
`final.js` / `final.py` 必须采用以下顺序:
1. 读取本地配置和脱敏请求样本。
2. 创建 session client。
3. 在同一 session 中刷新动态资源 / challenge / seed。
4. 在同一 session 状态下调用补环境后的目标 JS 入口生成加密参数;目标 JS 内部 XHR/fetch 如需网络访问,通过 live session bridge 回到同一 session。
5. 在同一 session 中发送目标 API 请求。
6. 输出脱敏结果和业务成功判断。
7. `finally` 中销毁 session 并清理敏感运行态。
## 输出记录
阶段报告、`notes/final-request-validation.md` 和最终总结至少记录:
- Session client 类型:CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet / 不发真实请求。
- 请求链:动态资源刷新、Cookie / challenge 生成、目标 API 请求是否在同一 session。
- XHR/fetch Session Bridge:未涉及 / offline-fixture 仅诊断 / live-session-bridge;若使用 Python curl_cffi,说明 `final.py` 是否持有唯一 session 并服务 Node IPC bridge。
- Cookie jar 来源、更新点和是否已脱敏。
- 是否复用同一 UA / Client Hints / locale / timezone / proxy / 指纹基线。
- 用户 cURL 样本与取证 baseline 是否冲突,Session 是否已按取证 baseline 重建 Header / UA / Client Hints / TLS / HTTP2 / Cookie jar。
- session 销毁方式:`close()` / `exit()` / `dispose()` / Cookie jar 清理。
- 成功或失败后是否存在敏感状态残留。

View File

@@ -0,0 +1,320 @@
# 分阶段中文 Markdown 报告规则
本文件用于约束一个高难度 Web JS Node.js 补环境 case 在多轮对话中的阶段性总结产物。阶段报告不是最终总结的替代品,而是在每个合适的推进节点沉淀当前进展、修改内容、能力增量、WebAPI / 指纹变化、Bug 修复、测试结果、阻塞点、native 能力缺口和下一步计划,方便跨轮对话继续工作。
## 硬性规则
- 所有由 Skill 生成的 Markdown 报告文件名必须包含中文,不能只使用 `final-summary.md`、`stage-1.md`、`report.md` 这类英文文件名。
- 阶段报告统一写入 `case/阶段报告/`,最终总结统一写入 `case/result/最终项目总结.md`。
- Markdown 内容必须 UTF-8 写入;不要使用未指定编码的 shell 重定向写中文。
- 每个合适推进节点结束后立即写入或更新对应阶段报告,不要等项目完成后一次性补写;“合适推进节点”由执行者根据实际任务判断,不局限于固定流程阶段。
- 阶段报告既要写阶段结论、用户确认信息、证据摘要和下一步计划,也要写 Trace 计划内首轮实现 / 调整的 WebAPI、计划外新增 WebAPI 与原因、WebAPI 环境检测矩阵状态、补环境功能、指纹能力、Bug 修复、测试结果和清理状态;不要写入明文 Cookie、Authorization、账号、手机号、完整 token、完整 localStorage 等敏感内容。
- 阶段报告可以记录临时证据路径,但必须标注“临时证据 / 已清理 / 需用户确认保留”,不要把临时 hook、trace、HAR、截图、Profile 当成最终交付物。
- 如果用户明确要求不生成阶段报告,必须在对话和最终总结中记录该豁免;否则默认生成。
## 推荐阶段与中文文件名
| 阶段 | 文件名 | 触发时机 |
|---|---|---|
| 需求信息确认 | `01-需求信息确认.md` | 用户提供 URL、API、参数、样本、取证模式等初始材料后,或发现信息不完整并列出缺失项时 |
| 取证方案确认 | `02-取证方案确认.md` | 用户确认 ruyiPage / RuyiTrace / Camoufox / CloakBrowser / 手动取证 / AI 自行决定后 |
| 请求样本与可疑参数确认 | `03-请求样本与可疑参数确认.md` | 解析 cURL / HAR / 请求样本并列出所有可疑加密参数后 |
| JS文件与入口定位 | `04-JS文件与入口定位.md` | 收集 JS 文件、定位 source / entry / builder / writer 后 |
| 补环境前置分析 | `05-补环境前置分析.md` | 进入 Node.js 补环境前,完成日志、trace、依赖和风险优先级整理后 |
| 补环境实现记录 | `06-补环境实现记录.md` | env / signer / request 模块形成并通过主要 fixture 后 |
| 验证与清理记录 | `07-验证与清理记录.md` | 最终请求验证、代码质量检查、最终产物检查、清理检查完成后 |
| 最终项目总结 | `最终项目总结.md` | 项目完成后,写入 `case/result/最终项目总结.md` |
可以根据实际 case 增加中文命名阶段报告,例如 `08-addon接口更新阶段报告.md`、`09-通用代码变更记忆机制实现报告.md`、`10-WebAPI补齐阶段报告.md`、`11-指纹回放能力阶段报告.md`、`12-二次补样复盘.md`、`13-线上复测记录.md`,但文件名仍必须包含中文。
## 动态阶段报告触发时机
除了固定阶段外,执行者应在以下节点主动生成阶段报告:
- 完成一轮明确修改后,例如更新 Skill 流程、脚本、参考文档、addon helper、env 模块、signer 或 request 客户端。
- 新增、迁移或重构一批 WebAPI 后,例如新增 `Navigator`、`Document`、`Location`、`Storage`、`Canvas`、`WebGL` 等对象或方法;报告必须说明这些 WebAPI 是 Trace 覆盖矩阵中的计划内首轮实现 / 调整,还是 Trace 未覆盖、动态资源新分支、baseline 不一致、Trace 截断、native 能力缺口或前置矩阵遗漏导致的计划外新增。
- 新增或调整 `XMLHttpRequest`、`fetch`、`Request`、`Response`、`Headers`、`navigator.sendBeacon` 后,报告必须说明网络模式是 `offline-fixture` 还是 `live-session-bridge`,真实请求是否复用同一 TLS Session,Python `curl_cffi` 场景是否由 `final.py` 持有 session 并服务 Node IPC bridge。
- 新增或调整任何浏览器对象内部状态后,报告必须说明对象形状审计状态、`_` / `__` 自有属性泄露检查结果,以及内部状态使用 addon / xbs private API 还是 WeakMap。
- 新增或调整 iframe、Worker、PerformanceObserver、DOM/CSSOM、EventTarget、MessagePort、`structuredClone`、timer、writer 分支相关行为后,报告必须说明 `webapi-env-detection-matrix.md` 是否已更新,浏览器 baseline / Node audit 是否存在,哪些检测项仍阻断真实请求。
- 新增或调整指纹能力后,例如 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何的真实值采样与回放,并记录是否绑定同一 `baselineId`。
- 固化或变更 fingerprint baseline 后,例如创建 `case/notes/fingerprint-baseline.json`、发现 baseline diff、切换代理 / profile / 工具。
- 建立或调整最终请求 Session 请求链后,例如改为同一 session 刷新动态资源、生成 Cookie / challenge、发送目标 API 并销毁 session。
- 修复一个关键 Bug 后,例如参数不一致、旧式 addon API 回退、toString 保护缺失、属性描述符错误、原型链错误、TLS 客户端选择错误。
- 发现纯 JS、addon.node 当前 API、xbs isolated-vm 当前 API 都无法覆盖的 native 能力缺口后,需要记录阻塞行为、浏览器基线、建议新增 API、最小行为测试用例、用户选择和通过状态。
- 完成一轮测试后,例如 fixture 对比、addon smoke、RuyiTrace 证据检查、代码质量检查、最终产物检查。
- 发现阻塞点、需要用户确认、需要补样本或需要重新取证时。
- 长时间任务中已经推进较多但尚未最终交付时,主动写入进度快照。
动态阶段报告应优先采用“编号 + 中文主题”的文件名,例如:
```text
case/阶段报告/08-addon接口更新阶段报告.md
case/阶段报告/09-WebAPI补齐阶段报告.md
case/阶段报告/10-指纹回放能力阶段报告.md
case/阶段报告/11-Bug修复与回归测试报告.md
```
## 阶段报告写入命令
优先使用脚本生成或写入,避免编码问题:
```bash
node scripts/write_stage_report.js --case-dir case --stage 需求信息确认 --data case/notes/需求信息.json --markdown
node scripts/write_stage_report.js --case-dir case --stage 请求样本与可疑参数确认 --input case/tmp/可疑参数草稿.md --markdown
node scripts/write_stage_report.js --case-dir case --stage 验证与清理记录 --append --input case/tmp/清理结果.md --markdown
node scripts/write_stage_report.js --case-dir case --stage WebAPI补齐阶段报告 --index 08 --data case/notes/阶段进展.json --markdown
```
写入任意中文命名 Markdown 时使用:
```bash
node scripts/write_markdown_utf8.js --input case/tmp/总结草稿.md --out case/result/最终项目总结.md --require-chinese-name --markdown
```
检查阶段报告:
```bash
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --require-stage 请求样本与可疑参数确认 --json
node scripts/check_stage_reports.js --case-dir case --require-stage WebAPI补齐阶段报告 --require-dynamic-fields --require-capability-report --markdown
```
## 阶段 1:需求信息确认报告内容
当用户刚提供需求信息时,优先生成 `case/阶段报告/01-需求信息确认.md`。内容至少包含:
- 目标网站 URL。
- 目标页面 URL。
- 目标 API。
- 请求方法。
- 用户声明的加密参数。
- 请求样本中发现的可疑加密参数。
- 参数位置:Query / Header / Body / Cookie。
- 取证模式选择:ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定。
- 最终请求 TLS 指纹兼容客户端选择。
- 最终请求 Session 模式:默认启用;session client 类型、Cookie jar 策略和销毁方式。
- 指纹基线状态:未创建 / 已创建 / 待采样;`baselineId` 与 baseline 文件路径。
- 已提供的 JS 文件 / 加密文件 / bundle / chunk / sourcemap。
- 是否需要登录,以及是否等待用户手动登录。
- 已提供材料列表与缺失材料列表。
- 下一步需要用户确认的问题。
如果信息不完整,也要生成该阶段报告,明确“当前不能进入正式分析”的原因和待补充项。
## 阶段报告模板
```markdown
# 阶段报告:需求信息确认
生成时间:
阶段状态:信息完整 / 信息不完整 / 等待用户确认
## 1. 用户已提供信息
- 目标网站 URL:
- 目标页面 URL:
- 目标 API:
- 请求方法:
- 加密参数:
- 参数位置:
- 取证模式:
- 最终请求 TLS 指纹兼容客户端:
- 最终请求 Session 模式:
- 指纹基线状态:
- 已知 JS 文件 / 加密文件:
- 是否需要登录:
## 2. 已提供样本与证据
- cURL / HAR:
- 响应样本:
- 浏览器 fixture:
- RuyiTrace NDJSON:
- Camoufox / CloakBrowser / ruyiPage 取证记录:
## 3. 缺失信息与阻塞点
- 缺失项:
- 阻塞原因:
- 需要用户确认:
## 4. 下一步计划
1.
2.
3.
```
## 动态阶段报告模板
当本阶段涉及代码、能力、WebAPI、指纹或 Bug 修复时,优先使用以下模板。该模板可以用于任意中文阶段名,例如 `WebAPI补齐阶段报告`、`指纹回放能力阶段报告`、`Bug修复与回归测试报告`。
```markdown
# 阶段报告:WebAPI补齐阶段报告
生成时间:
阶段状态:进行中 / 已完成 / 阻塞 / 待用户确认
## 1. 当前阶段目标
- 本阶段要解决的问题:
- 本阶段范围:
- 不在本阶段处理的内容:
## 2. 当前项目进展
- 已完成:
- 进行中:
- 尚未开始:
- 阻塞点:
## 3. 本阶段修改文件
| 文件 | 修改类型 | 修改原因 | 影响范围 |
|---|---|---|---|
| result/src/env/navigator.js | 新增 / 修改 / 删除 | | |
## 4. Trace 计划内首轮实现 / 调整的 WebAPI
| WebAPI | 挂载位置 | 类型 | 实现方式 | 是否 addon-first | Trace 矩阵状态 | 证据来源 | 测试结果 |
|---|---|---|---|---|---|---|---|
| navigator.userAgent | Navigator.prototype | getter | createGetter | 是 | implemented-first-pass | RuyiTrace / 浏览器样本 | 通过 |
## 4a. 计划外新增 WebAPI 与原因
计划外新增只能使用以下原因:`trace-not-covered` / `dynamic-resource-new-branch` / `baseline-mismatch` / `trace-truncated` / `native-gap` / `missed-from-trace`。如果原因是 `missed-from-trace`,必须标为流程缺陷,并补写 `trace-api-inventory.json`、`env-coverage-matrix.md` 和 `notes/代码变更记忆.md` 后再继续。
| WebAPI | 新增原因 | 为什么未在 Trace 覆盖矩阵首轮处理 | 证据 | 处理结果 |
|---|---|---|---|---|
| 无 | - | - | - | - |
## 4b. Trace-runtime 可执行闭环
- contract:case/notes/trace-runtime-contract.json / 未生成,原因:
- Node audit:case/tmp/node-trace-runtime-audit.json / 未生成,原因:
- traceSourceHash:
- contractHash:
- runtimeSourceHash:
- baselineId:
- P0/P1 matched / mismatch:
- audit-only 真实网络尝试数:
- 检查结果:`check_trace_runtime_conformance.js` 通过 / 未通过 / 未运行,原因:
## 4c. XHR/fetch Session Bridge
- 网络模式:offline-fixture / live-session-bridge / 未涉及
- Session 持有者:final.js / final.py / 未涉及
- TLS 客户端:CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet / 不发真实请求
- JS bridge 文件:
- Cookie / Set-Cookie 同步:
- readyState / Promise / event 顺序:
- 检查结果:`check_xhr_fetch_session_bridge.js` 通过 / 未通过 / 未运行,原因:
## 4d. XHR/fetch 请求语义审计
- 浏览器 transcript:case/fixtures/browser-network-transcript.ndjson / 未生成,原因:
- Node no-send transcript:case/tmp/node-network-transcript.ndjson / 未生成,原因:
- runtimeSourceHash:
- Header/body mismatch:
- status=0 / responseURL:
- readyState / event / Promise 顺序:
- reload 后旧 realm 清理:
- 多余 Node 请求数:
- 检查结果:`check_xhr_fetch_semantics.js` 通过 / 未通过 / 未运行,原因:
## 4e. 对象形状审计矩阵
- 矩阵文件:case/notes/object-shape-audit.md / 未触发,原因:
- 浏览器 baseline:case/fixtures/browser-object-shape-baseline.json / 未生成,原因:
- Node audit:case/tmp/node-object-shape-audit.json / 未生成,原因:
- 私有状态实现:addon/xbs private API / WeakMap / native / 未涉及
- `_` / `__` 自有属性泄露:无 / 列表
- 阻断项:无 / 列表
| 对象 | Probe | 浏览器证据 | Node 证据 | 状态 | 处理 |
|---|---|---|---|---|---|
| 未涉及 | - | - | - | not-involved | - |
## 5. 本阶段新增功能
- 新增功能:
- 功能入口:
- 使用方式:
- 对最终产物的影响:
## 5a. WebAPI 环境检测矩阵
- 矩阵文件:case/notes/webapi-env-detection-matrix.md / 未触发,原因:
- 浏览器 baseline:case/fixtures/browser-env-detection-baseline.json / 未生成,原因:
- Node audit:case/tmp/node-env-detection-audit.json / 未生成,原因:
- baselineId:
- 触发类别:iframe-realm / worker-task / performance-timeline / dom-cssom / event-clone-error / xhr-fetch-session-bridge / object-shape / private-state-leakage / clock-timer / writer-branch / 未涉及
- 阻断项:无 / 列表
- writer 分支:真实浏览器 = ;Node =
| 类别 | 检测项 | 浏览器证据 | Node 证据 | 状态 | 处理 |
|---|---|---|---|---|---|
| 未涉及 | - | - | - | not-involved | - |
## 6. 本阶段修复的 Bug
| Bug | 原因 | 修复方式 | 涉及文件 | 验证结果 | 防回退记录 |
|---|---|---|---|---|---|
| | | | | | notes/代码变更记忆.md |
## 7. 本阶段新增 / 修改的指纹能力
| 指纹类型 | API | 实现策略 | 样本来源 | baselineId | 回放方式 | 风险 |
|---|---|---|---|---|---|---|
| Canvas | toDataURL | 真实值回放 | 浏览器采样 | | 按调用参数匹配 | 样本不足 |
## 8. 真实性保护变化
- 函数 toString 保护:
- 访问器 toString 保护:
- 属性描述符:
- 原型链:
- 实例对象 `[object Xxx]`:
- `document.all` / HTMLDDA:
- addon 使用情况:
- fallback 原因:
## 9. Session 请求链与指纹基线
- fingerprint baseline:未涉及 / 已创建 / 已复用 / 发生 diff,文件:
- baselineId:
- 最终请求 Session:未涉及 / 已启用 / 不发真实请求
- session 覆盖请求链:动态资源刷新 / Cookie 生成 / challenge / 目标 API
- session 销毁方式:
## 10. 本阶段测试内容与结果
| 测试项 | 命令 / 方法 | 结果 | 备注 |
|---|---|---|---|
| | | 通过 / 失败 | |
## 11. 清理情况
- 已清理:
- 保留证据:
- 敏感材料处理:
## 12. 风险与遗留问题
- 风险:
- 未覆盖样本:
- 需要用户确认:
## 13. 下一步计划
1.
2.
3.
```
## 最终总结与阶段报告关系
- 阶段报告记录“当时的状态”和“阶段性结论”,允许出现待确认项。
- 动态阶段报告还要记录“本阶段能力增量”,包括 Trace 计划内 WebAPI 实现 / 调整、计划外新增 WebAPI 与原因、Trace-runtime 可执行闭环、XHR/fetch 请求语义审计、功能、指纹、Bug 修复、真实性保护和测试结果。
- `result/最终项目总结.md` 记录最终结论,必须引用阶段报告中的关键决策,但不要重复粘贴所有中间日志。
- 最终交付检查时,应确认 `case/阶段报告/` 中至少存在 `01-需求信息确认.md`,并确认文件名和内容均为 UTF-8 中文正常显示。

View File

@@ -0,0 +1,381 @@
# 最终请求验证与 TLS 指纹兼容
当新 case 需要最终发送真实请求、交付 `final.js` / `final.py`,或用户提到 CycleTLS、impers、curl-cffi-node、curl_cffi、cffi_curl、cyCronet 时读取本文件。TLS 指纹兼容客户端的选择应从前置阶段开始确认,不要等普通 `fetch` / `requests` 失败后才临时切换;最终请求一律使用 Session 模式。
## 使用边界
- 只用于用户授权范围内的网页端 JS 补环境结果验证。
- 只做低频、最小化请求,优先复现用户已提供的成功 cURL / HAR。
- TLS 指纹兼容只解决普通 HTTP 客户端与浏览器在 TLS ClientHello、ALPN、HTTP/2、JA3/JA4、Cronet / curl-impersonate 网络栈上的差异;不能替代登录态、验证码、一次性 token、设备校验或业务授权。
- 最终真实请求必须写入一体化 `final.js` 或 `final.py`,由 Node.js / Python TLS 指纹兼容 Session 客户端直接发起;不得生成加密参数后再使用 ruyiPage、Playwright、Puppeteer、Selenium、CloakBrowser 或其他浏览器自动化验证。
- 如果目标 JS 运行时调用 `XMLHttpRequest` / `fetch` / `sendBeacon` 访问动态资源、Cookie、challenge、telemetry 或目标接口,JS 侧网络 API 也必须通过同一 TLS 指纹兼容 Session 的 live session bridge 发送;fixture/mock/default 200 只能作为离线诊断,不能作为 TLS 验证。
## 前置阶段必须选择客户端
在信息完整性检查和任务确认时,加入以下字段:
```markdown
- 最终请求 TLS 指纹兼容客户端:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / curl-cffi-node / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- 最终请求 Session 模式:一律启用 / 不发真实请求
- 是否已安装:是 / 否 / 待检测
- 若未安装:安装该客户端 / 改选其他客户端 / 不发真实请求
```
选择后立即检测:
```bash
node scripts/check_tls_clients.js --markdown
node scripts/check_tls_clients.js --python python --markdown
```
如果用户选择的库未安装,不要默认退回普通 `fetch` / `requests` 发真实请求。应让用户确认安装、改选其他已安装客户端,或选择“不发真实请求,只输出本地 sign / 参数”。
## 工具选择
| 运行环境 | 可选工具 | 适用场景 |
|---|---|---|
| Node.js | CycleTLS / cycletls / @luminati-io/cycletls | 需要在 Node.js `final.js` 中指定 JA3 / User-Agent / Header 并发起少量验证请求 |
| Node.js | impers | Node.js curl-impersonate 路线;需要浏览器 TLS / JA3 / HTTP2/HTTP3 指纹时优先考虑 |
| Node.js | curl-cffi-node / curl-cffi | Node.js 侧 curl_cffi / curl-impersonate 路线;需要用 Node.js 维持 session 与 Cookie jar 时可选 |
| Python | curl_cffi / cffi_curl | Python curl-impersonate 路线;常用于 Chrome / Firefox / Safari impersonate |
| Python | cyCronet / cycronet | Python Cronet-Cloak / Chromium 网络栈路线;适合需要 Chrome 系网络栈特征时 |
| 不发真实请求 | 无 | 只交付本地参数生成结果,不进行最终接口验证 |
注意:不同机器安装包名、导入名、版本和 API 可能不同。先运行检测脚本,不要在 Skill 中硬编码本机路径或版本。库 API 变动时,以本机安装版本的 README / 官方文档为准。
## 安装提示
只在用户确认后安装;不要在未确认时自动安装依赖。
```bash
# Node.js
npm install cycletls
npm install @luminati-io/cycletls
npm install impers
npm install curl-cffi
# Python
python -m pip install curl_cffi
python -m pip install cffi_curl
python -m pip install cycronet
```
## 高强度网络指纹一致性
## 用户 cURL 与取证 baseline 的网络指纹优先级
用户提供的 cURL / HAR 不一定来自最终取证浏览器。最终请求代码必须优先对齐“已确认取证 baseline”,而不是无条件复用用户 cURL 中的 UA、Client Hints、Header、Cookie 或 TLS 线索。
硬性规则:
- 前置阶段必须识别用户 cURL 样本浏览器族和取证 baseline 浏览器族。
- 如果两者一致,仍需确认 Header、TLS / HTTP2、Cookie jar 和代理 / 地区是否属于同一会话链路。
- 如果两者不一致,默认以取证 baseline 为准;cURL 只作为请求结构、参数位置、业务字段和历史现象线索。
- Chrome cURL 与 Firefox 取证冲突时,不得在 Firefox baseline 的最终请求中保留 Chrome `sec-ch-ua`、`sec-ch-ua-platform`、`sec-ch-ua-mobile`,也不得只修改 UA 后继续使用 Chrome TLS / HTTP2 profile。
- 最终 `final.js` / `final.py` 的 UA、Accept-Language、Client Hints、Header 顺序、TLS / JA3 / JA4、HTTP/2 Akamai 指纹、Cookie jar 和代理必须来自同一取证 baseline 或经过重新取证确认。
- cURL 中已有的 sign、token、Cookie challenge 值只能作为 fixture / 历史线索;最终项目必须通过补环境后的 signer 和同一 session 请求链生成或刷新。
- 冲突记录必须写入 `case/notes/sample-baseline-conflict.md`、`case/notes/final-request-validation.md` 和最终总结。
- 如果用户坚持沿用 cURL 浏览器族,则暂停最终请求验证,要求使用同浏览器族取证工具重新采样 baseline,或让用户明确接受风险;风险确认不得替代成功验证。
高强度检测中,TLS 指纹兼容不是单独开关,而是要与浏览器取证 baseline 一起看。最终请求前必须核对:
- TLS / JA3 / JA4、ALPN、HTTP/2 / HTTP/3 能力与所选 impersonate 浏览器族一致。
- `User-Agent` 与 `navigator.userAgent` 一致。
- `sec-ch-ua`、`sec-ch-ua-platform`、`sec-ch-ua-mobile` 与 `navigator.userAgentData` 一致;取证浏览器是 Firefox 时不要伪造 Chrome Client Hints。
- `Accept-Language` 与 `navigator.language/languages` 一致。
- `Accept-Encoding`、Header 顺序、HTTP/2 pseudo-header 顺序、`sec-fetch-*`、`Referer`、`Origin` 与 HAR / 浏览器请求链一致。
- 代理 / IP / 地区、timezone、locale、WebRTC 策略与 fingerprint baseline 一致。
- Cookie jar 和 Storage 状态来自同一 session 链路,不复制旧 cURL 中已过期的风控状态。
如果以上任一项冲突,先修正请求链和 fixture,不要把失败直接归因于 JS 补环境。
## Firefox baseline 与 curl_cffi TLS 指纹对齐
当取证浏览器 baseline 是 Firefox(例如 ruyiPage 定制 Firefox、用户手动 Firefox 或其他 Firefox 系取证工具),而最终请求客户端选择 Python `curl_cffi` / Node.js `curl-cffi-node` 时,不得仅凭 `impersonate="firefox147"`、`impersonate="firefox"` 或修改 `User-Agent` 就宣称与取证浏览器一致。Firefox 版本标签只是 curl_cffi 支持的预设 profile 名称,真实是否兼容必须以当前 case 的浏览器 TLS / HTTP2 采样证据为准。
### 硬性流程
1. 先按 `fingerprint-baseline-consistency.md` 固化当前 case 的 Firefox 指纹基线,记录 `baselineId`、取证工具、profile / seed、代理、语言、时区、UA、Header 和浏览器路径摘要。
2. 使用已确认的取证工具在同一 baseline 下访问 TLS 指纹检测端点,建议至少采样:
- `https://tls.peet.ws/api/all`
- `https://tls.browserleaks.com/json`
3. 记录真实 Firefox baseline 的 `ja3`、`ja3_hash`、`ja3n_hash`、`ja4`、cipher suites、extension 顺序、supported groups / curves、signature algorithms、delegated credentials、record size limit、key_share、ALPN、HTTP/2 Akamai fingerprint、UA 和请求头。
4. 再用 curl_cffi 的候选 profile(例如 `firefox147` 或当前 `firefox` alias)访问同一端点并对比。
5. 如果裸 profile 与 Firefox baseline 不一致,不允许只改 UA 后继续;必须尝试通过 `ja3`、`akamai`、`extra_fp`、`curl_options` 和 HTTP Header 对齐。
6. 对齐后再次访问检测端点,只有 `ja3_hash`、`ja3n_hash`、`ja4`、HTTP/2 Akamai fingerprint、关键 TLS 字段和 UA / Header 与 baseline 一致或经过用户确认可接受等价时,才允许进入最终请求验证。
7. 对齐配置必须写入 `case/notes/final-request-validation.md` 和最终总结;如果仍不一致,必须暂停、换客户端或记录用户明确接受的风险,不得伪造成功。
### 必须对比的字段
- TLS:`ja3`、`ja3_hash`、`ja3n_hash`、`ja4`、TLS version、cipher suites 列表和顺序、TLS extensions 列表和顺序、supported groups / curves、signature algorithms、delegated credentials、record size limit、key_share 数量和顺序、ALPN、ECH / certificate compression 等扩展摘要。
- HTTP/2:Akamai fingerprint、settings、window update、streams、pseudo-header 顺序。
- HTTP Header:`User-Agent`、`Accept`、`Accept-Language`、`Accept-Encoding`、`Referer`、`Origin`、`Sec-Fetch-*`、Header 顺序和大小写。
- Session 与环境:Cookie jar、代理 / IP、timezone、locale、`navigator.userAgent`、`navigator.language/languages`、Firefox 不应伪造 Chrome UA-CH。
### curl_cffi Firefox 对齐模板
以下模板用于“Firefox baseline 已采样,且确认 curl_cffi 裸 profile 不一致”时的对齐。示例中的具体值来自某次 ruyiPage Firefox 151 baseline 验证,只能作为写法参考;真实 case 必须把 `FIREFOX_BASELINE_JA3`、`FIREFOX_BASELINE_AKAMAI`、`headers`、`extra_fp` 和 `curl_options` 替换为当前 case 采样结果。
```python
from curl_cffi import requests
from curl_cffi.const import CurlOpt
FIREFOX_BASELINE_JA3 = (
"771,4865-4867-4866-49195-49199-52393-52392-49196-49200-"
"49171-49172-156-157-47-53,"
"0-23-65281-10-11-35-16-5-34-18-51-43-13-45-28-27-65037,"
"4588-29-23-24-25-256-257,0"
)
FIREFOX_BASELINE_AKAMAI = "1:65536;2:0;4:131072;5:16384|12517377|0|m,p,a,s"
FIREFOX_HEADERS = {
# 中文说明:UA 必须来自当前 case 的 Firefox baseline,不要沿用 curl_cffi 默认 UA。
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0",
# 中文说明:Accept / Accept-Language / Accept-Encoding 等也要以 HAR 或浏览器样本为准。
}
FIREFOX_EXTRA_FP = {
# 中文说明:signature_algorithms 必须按真实 Firefox baseline 的顺序填写。
"tls_signature_algorithms": [
"ecdsa_secp256r1_sha256",
"ecdsa_secp384r1_sha384",
"ecdsa_secp521r1_sha512",
"rsa_pss_rsae_sha256",
"rsa_pss_rsae_sha384",
"rsa_pss_rsae_sha512",
"rsa_pkcs1_sha256",
"rsa_pkcs1_sha384",
"rsa_pkcs1_sha512",
"rsa_pkcs1_sha1",
],
# 中文说明:delegated credentials 使用冒号分隔;不要把逗号分隔误写为最终配置。
"tls_delegated_credential": "ecdsa_secp256r1_sha256:ecdsa_secp384r1_sha384:ecdsa_secp521r1_sha512",
# 中文说明:record_size_limit 需来自真实浏览器样本,例如 Firefox 151 样本为 0x4001。
"tls_record_size_limit": 0x4001,
}
def create_firefox_aligned_curl_cffi_session():
# 中文说明:最终项目必须使用 Session;动态资源刷新、Cookie 生成和目标 API 都复用同一 Session。
# 中文说明:ja3 / akamai / extra_fp / curl_options 在 Session 初始化时固定,避免单次请求遗漏。
session = requests.Session(
impersonate="firefox147",
ja3=FIREFOX_BASELINE_JA3,
akamai=FIREFOX_BASELINE_AKAMAI,
extra_fp=FIREFOX_EXTRA_FP,
curl_options={
# 中文说明:用于对齐 Firefox key_share 数量;实际值必须通过采样验证。
CurlOpt.TLS_KEY_SHARES_LIMIT: 2,
},
)
session.headers.update(FIREFOX_HEADERS)
return session
def send_with_firefox_aligned_tls(session, method, url, **kwargs):
headers = dict(FIREFOX_HEADERS)
headers.update(kwargs.pop("headers", {}) or {})
return session.request(
method=method,
url=url,
headers=headers,
**kwargs,
)
```
### 对齐验收示例
对齐成功后,检测端点应至少达到以下级别:
```text
ja3_hash: 与 Firefox baseline 一致
ja3n_hash: 与 Firefox baseline 一致
ja4: 与 Firefox baseline 一致
akamai_hash / akamai_text: 与 Firefox baseline 一致
HTTP User-Agent: 与 navigator.userAgent / HAR 一致
HTTP/2: settings、window update、pseudo-header 顺序一致
```
若只修改 UA 后 `ja3_hash`、`ja4`、Akamai HTTP/2 指纹仍不一致,视为未对齐,不得进入最终请求验证。
### cyCronet / cycronet 的边界
`cyCronet` / `cycronet` 更适合 Chromium / Chrome baseline。即使当前版本提供 `add_tls_profile` / `set_tls_profiles`,通常也主要控制 cipher suites、curves 和部分 extensions,底层仍是 Cronet / Chromium 网络栈,HTTP/2 Akamai 指纹、JA4 结构和 Firefox 的 BoringSSL / NSS 行为可能不同。
- 取证 baseline 是 Firefox:优先使用 curl_cffi 的 Firefox 对齐流程;不要把 cyCronet 作为 Firefox 对齐首选。
- 取证 baseline 是 Chrome / Chromium / Camoufox Chromium 类工具:可以考虑 cyCronet,并同样先采样 baseline、再对比 JA3 / JA4 / HTTP2 指纹。
- 如果用户明确要求用 cyCronet 对齐 Firefox,必须先实测;若 JA4 或 HTTP/2 指纹无法一致,暂停并说明能力边界。
## Session 模式硬规则
最终请求不再区分单请求或请求链。只要用户选择发送真实请求或交付可请求的 `final.js` / `final.py`,必须读取 `session-request-chain.md` 并满足:
1. 创建 session client,不使用无状态单次请求。
2. 动态资源刷新、Cookie / challenge 生成、加密参数生成前后请求和目标 API 都走同一 session。
3. 同一 session 复用 Cookie jar、UA、Client Hints、Accept-Language、Referer、Origin、Header 顺序、代理、TLS 指纹和 fingerprint baseline。
4. 成功、失败或异常退出后,在 `finally` 中调用 `close()` / `exit()` / `dispose()`,并清理 Cookie jar、敏感 header、token 和临时响应。
5. 最终总结记录 session client 类型、请求链、Cookie jar 来源、销毁方式和敏感状态清理结果。
## Node.js CycleTLS 模板
CycleTLS 的包名和导出在不同版本中可能不同,先用 `check_tls_clients.js` 确认。最终项目中建议封装成 `result/src/request/client.js`,只由 `result/final.js` 调用。
```javascript
'use strict';
async function createCycleTLSSession({ ja3, userAgent, headers = {} }) {
const initCycleTLS = require('cycletls');
const cycleTLS = await initCycleTLS();
const cookieJar = new Map();
async function request({ url, method = 'GET', headers: extraHeaders = {}, body }) {
const response = await cycleTLS(url, {
method,
headers: { ...headers, ...extraHeaders },
body,
ja3,
userAgent,
}, method.toUpperCase());
// 中文说明:这里按项目需要解析 Set-Cookie 并写回 cookieJar,后续请求复用。
return { status: response.status, headers: response.headers, body: response.body };
}
async function close() {
cookieJar.clear();
if (typeof cycleTLS.exit === 'function') cycleTLS.exit();
}
return { request, close, cookieJar };
}
module.exports = { createCycleTLSSession };
```
## Node.js impers 模板
`impers` 是 Node.js curl-impersonate 路线,官方说明它处于 alpha 状态,API 可能变化。使用时优先按本机安装版本确认导出。常见用法是指定 `impersonate`,例如 `chrome`。
```javascript
// ESM 示例;若最终项目使用 CommonJS,可把请求客户端单独写成 .mjs 或动态 import。
import * as impers from 'impers';
export async function sendRequestWithImpers({ url, method = 'GET', headers = {}, body, impersonate = 'chrome' }) {
const fn = method.toLowerCase();
if (typeof impers[fn] === 'function' && !body) {
return await impers[fn](url, { headers, impersonate });
}
return await impers.request({ url, method, headers, body, impersonate });
}
```
## Python curl_cffi 模板
普通 Chrome / Safari / 已有预设 profile 可使用下列基础模板;如果取证 baseline 是 Firefox 且 curl_cffi 预设 profile 与真实浏览器 TLS / HTTP2 指纹不一致,必须优先按上文“Firefox baseline 与 curl_cffi TLS 指纹对齐”生成专用 Session 封装,不得只使用基础模板或只改 UA。
```python
from curl_cffi import requests
def create_curl_cffi_session(headers=None, impersonate="chrome"):
session = requests.Session(impersonate=impersonate)
session.headers.update(headers or {})
return session
def send_request_with_curl_cffi(session, url, method="GET", data=None, json_data=None, headers=None):
resp = session.request(
method=method,
url=url,
headers=headers or {},
data=data,
json=json_data,
timeout=30,
)
return {"status": resp.status_code, "headers": dict(resp.headers), "text": resp.text}
def close_curl_cffi_session(session):
try:
session.cookies.clear()
finally:
session.close()
```
## Python cffi_curl / cyCronet 模板
`cffi_curl` 与 `cyCronet` / `cycronet` 的 API 版本差异较大,Skill 不应硬编码某个本机版本。`cyCronet` / `cycronet` 默认优先用于 Chrome / Chromium baseline;Firefox baseline 应优先走 curl_cffi 对齐流程,除非已实测 cyCronet 的 JA3 / JA4 / HTTP2 与 Firefox baseline 一致。使用方式:
1. 先运行 `check_tls_clients.js --python python --json` 确认导入名。
2. 在 `result/src/request_client.py` 封装一个统一函数 `send_request(...)`。
3. 只在封装内部处理库差异;`final.py` 不直接散落库调用。
4. 若当前包 API 与模板不一致,以本机包文档为准,并在 `notes/final-request-validation.md` 记录使用版本和参数。
伪代码:
```python
def send_request(url, method="GET", headers=None, body=None):
# 这里根据用户已确认且本机已安装的 cffi_curl / cyCronet API 实现。
# 必须返回 status、headers、text/body。
raise NotImplementedError("请根据本机 cffi_curl / cyCronet 版本补齐请求客户端封装")
```
## 最终验证流程
1. 前置阶段确认最终请求客户端和是否已安装。
2. 确认 fixtures 已通过,Node.js 生成参数与浏览器样本一致。
3. 若目标 JS 依赖 XHR/fetch 网络访问,先按 `xhr-fetch-session-bridge.md` 建立 live session bridge,并确认 Cookie jar / Set-Cookie / resource timing 同步。
4. 读取用户提供的成功 cURL / HAR,不凭空构造请求。
5. 脱敏保存请求样本;真实 Cookie / token 只保存在本地,不写入最终报告。
6. 使用已确认的 CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet 创建 session client,并在同一 session 中仅发起少量验证请求;若用户选择“不发真实请求”,则只输出本地 sign / 参数。
7. 对比:
- HTTP 状态码。
- 响应 JSON 中关键字段。
- 服务端是否接受新生成的加密参数。
- 是否出现风控 / 验证码 / 登录失效。
8. 写入 `notes/final-request-validation.md`。
9. 在 `finally` 中销毁 session,清理 Cookie jar、临时响应、日志和敏感请求副本。
10. 将最终请求逻辑整合进 `result/final.js` 或 `result/final.py`,并运行 `check_xhr_fetch_session_bridge.js` 与 `check_final_artifact.js` 确认不包含浏览器自动化代码和多余产物。
## 输出模板
```markdown
## 最终请求验证
- 是否执行真实请求:是 / 否
- 用户授权:是 / 否
- 前置阶段已选客户端:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / curl-cffi-node / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- 客户端安装状态:已安装 / 未安装改选 / 未安装不发请求
- Session 模式:已创建 session / 不发真实请求
- 请求链是否复用同一 session:是 / 否,原因:
- XHR/fetch Session Bridge:未涉及 / offline-fixture / live-session-bridge;如使用 Python curl_cffi,是否由 final.py 持有同一 session 并服务 Node IPC:
- Session 销毁方式:close / exit / dispose / Cookie jar 清理 / 不适用
- 最终项目入口:final.js / final.py
- 是否包含浏览器自动化代码:否
- 请求来源:cURL / HAR / 用户样本
- TLS 指纹兼容原因:用户前置选择 / 目标接口要求浏览器网络栈一致 / 未启用
- Firefox / curl_cffi 对齐状态:不涉及 / 已对齐 / 未对齐改选 / 用户确认风险
- TLS baseline 来源:ruyiPage / Camoufox / CloakBrowser / 用户手动浏览器 / HAR;baselineId:
- 用户 cURL 浏览器族与取证 baseline 浏览器族:一致 / 冲突 / 未知;冲突处理:以取证 baseline 为准 / 重新取证 / 切换工具 / 用户确认风险
- 是否阻止沿用冲突 cURL 字段:UA / Client Hints / Header / TLS profile / HTTP2 指纹 / Cookie / 动态参数
- 对齐参数摘要:ja3 hash / ja4 / akamai hash / extra_fp / curl_options / Header 是否与 baseline 一致
- 请求次数:
- 状态码:
- 目标加密参数是否被接受:
- 响应关键字段:
- 是否触发验证码 / 风控:
- 敏感材料处理:已脱敏 / 仅本地保存 / 已清理
```
## 排查提示
如果 TLS 指纹兼容客户端仍失败,优先排查:
- Cookie / Authorization 是否过期。
- Header 顺序、大小写、HTTP/2 伪头、Content-Length 是否与样本一致。
- Body 是否保持原始字符串,未被 JSON 重新序列化。
- Query 编码、排序、空值、数组序列化是否一致。
- 时间戳、nonce、随机数、server seed 是否过期。
- IP / 代理 / 地域 / 账号风控是否变化。
- 目标接口是否绑定浏览器会话、Service Worker、一次性 token 或挑战结果。

View File

@@ -0,0 +1,146 @@
# Trace API 覆盖矩阵门禁
本文件用于解决“Trace 已经命中 WebAPI,但补环境阶段仍反复遇错新增 WebAPI”的流程问题。只要存在 RuyiTrace NDJSON、Node trace、`missing-env.json`、Hook 环境访问日志或等价环境日志,进入第一版 Node.js 补环境实现前必须读取本文件。
## 核心原则
- Trace 不是调试参考,而是首轮补环境范围的主输入。
- 已在 Trace 中命中的 WebAPI 不得等到 `ReferenceError`、`undefined.prototype`、writer 不触发或 body 长度异常时再临时补。
- Node probe 只用于验证覆盖、发现 Trace 未覆盖的新动态分支和定位行为差异;不得把 probe 当作主要 WebAPI 发现机制。
- Trace 能确认“访问了什么”,但不一定能确认“真实返回值是什么”。需要真实值或浏览器行为语义的 API 必须在矩阵中标为“需要同 baseline 采样”,不得先挂空壳。
- inventory 不能证明 runtime 正确。第一轮实现后必须继续按 `trace-runtime-conformance.md` 生成可执行行为契约、运行 no-send Node audit 并完成 P0/P1 深度 diff。
- `implemented-first-pass` 不是人工状态;只有实现文件存在、runtime 实际加载且 Trace-runtime audit 通过后才能使用。
## 必须产物
进入第一版 `env` / `node-runtime` / signer probe 前,必须生成并写入:
```text
case/notes/trace-api-inventory.json
case/notes/env-coverage-matrix.md
case/notes/missing-env-priority.md
case/notes/trace-runtime-contract.json
case/tmp/node-trace-runtime-audit.json
case/notes/trace-runtime-diff.md
```
`trace-api-inventory.json` 至少包含:
```json
{
"schemaVersion": "trace-api-coverage/v1",
"source": "RuyiTrace NDJSON / Node trace / Hook",
"baselineId": "fp-example-001",
"apis": [
{
"api": "navigator.userAgent",
"category": "navigator",
"priority": "P0",
"accessTypes": ["get"],
"count": 12,
"evidence": ["stack.file:line:col"],
"traceStatus": "complete",
"implementationStatus": "planned-first-pass",
"implementationFile": "result/src/node-runtime/env/browser-objects/navigator.js",
"samplingRequired": false,
"reason": "Trace 命中,基础 navigator 字段必须首轮实现"
}
]
}
```
`env-coverage-matrix.md` 至少包含:
- Trace 来源、日志覆盖范围、`baselineId`、是否存在 profile / baseline 冲突。
- P0/P1/P2 分类规则。
- 每个 Trace 命中 API 的处理状态。
- 首轮必须实现清单。
- 需要同 baseline 采样后实现清单。
- 暂不挂载清单及原因。
- native 能力缺口清单。
- 计划外新增 WebAPI 记录方式。
## API 状态枚举
每个 Trace 命中的 API 必须选择一个状态:
| 状态 | 含义 | 是否允许进入第一版 env |
|---|---|---|
| `planned-first-pass` | Trace 已命中,首轮必须实现 | 允许,但必须在第一版 env 实现后再 probe |
| `implemented-first-pass` | 已在第一版 env 中实现 | 允许 |
| `needs-baseline-sampling` | 需要同一 `baselineId` 下采样真实值或行为 | 允许作为阻塞采样项,不允许挂空壳 |
| `deferred-not-mounted` | 暂不挂载,且有明确证据说明目标路径不需要或挂载会扰动 writer | 允许,但必须写原因 |
| `native-capability-gap` | 纯 JS / addon / xbs 当前能力都无法可靠表达 | 阻塞,进入 native 能力缺口闭环 |
| `unplanned` | 未规划 | 不允许进入补环境实现 |
| `live-discovered` | Trace 未覆盖,后续 live probe 新分支发现 | 允许后续新增,但必须说明为什么 Trace 未覆盖 |
| `missed-from-trace` | Trace 已命中但前置矩阵遗漏 | 流程缺陷;必须修正矩阵并记录防回退 |
## 优先级规则
P0:影响 JS 能否执行、writer 是否可达、Cookie / Storage / XHR / fetch / sendBeacon / window / document / location / navigator 基础字段 / Function.toString / Node 泄露阻断的 API。
P1:影响高强度检测分支、指纹结构、DOM / CSSOM / 事件 / performance / time / random / screen / Permissions / Plugins / MimeTypes 的 API。
P2:业务页分支、第三方脚本、低频能力、非当前 writer 路径的 API。
P0/P1 中 Trace 已命中的基础 WebAPI 不允许“先不补,遇错再补”。如果暂不实现,必须是 `needs-baseline-sampling`、`deferred-not-mounted` 或 `native-capability-gap`,并写清证据。
进入 signer probe、真实请求或 final writer 判断前,所有 P0/P1 必须进一步通过:
```bash
node scripts/build_trace_runtime_contract.js --case-dir case --baseline-id <baselineId> --markdown
node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.js --markdown
node scripts/check_trace_runtime_conformance.js --case-dir case --markdown
node scripts/check_trace_api_coverage.js --case-dir case --require-runtime-closure --markdown
```
具体契约字段、audit-only 环境变量和哈希失效规则见 `trace-runtime-conformance.md`。
## 真实值类 API
以下 API 不允许先挂空壳或随机值:
- Canvas:`toDataURL`、`getImageData`、`measureText`
- WebGL / WebGPU:`getParameter`、`readPixels`、adapter / renderer 信息
- Audio:`getChannelData`、OfflineAudioContext 渲染结果
- DOM 几何:`getBoundingClientRect`、`offsetWidth`、`clientWidth`、字体测量
- Speech / voices、Storage estimate、Permissions 状态、Plugins / MimeTypes
处理方式:
1. Trace 已命中但值缺失、截断、baseline 不一致时,矩阵标为 `needs-baseline-sampling`。
2. 在同一 `baselineId` 下采样完整值或短行为。
3. 接入 fixture 回放。
4. 输出 `fingerprintReplayMisses` 或等价 miss 明细。
5. miss 非空时不得声明指纹回放闭环。
## 后续阶段新增 WebAPI 规则
阶段报告中不得再笼统写“本阶段新增 / 修改的 WebAPI”而不解释来源。必须拆成:
1. Trace 计划内首轮实现 / 调整的 WebAPI。
2. 计划外新增 WebAPI 与原因。
如果新增 / 调整涉及 `XMLHttpRequest`、`fetch`、`sendBeacon`,阶段报告还必须记录 XHR/fetch Session Bridge 模式;如果涉及浏览器对象内部状态、属性枚举或 descriptor,阶段报告还必须记录对象形状审计矩阵和 `_` / `__` 私有状态泄露检查结果。
计划外新增只能属于以下原因之一:
- `trace-not-covered`:原 Trace 没覆盖该动态分支。
- `dynamic-resource-new-branch`:运行时刷新到新的动态 JS / chunk / challenge 分支。
- `baseline-mismatch`:Trace profile 与 fixed baseline 不一致,需重新采样后实现。
- `trace-truncated`:Trace 字段疑似 4000 / 4096 截断,需补采。
- `native-gap`:前置矩阵已标记为 native 能力缺口。
- `missed-from-trace`:Trace 已命中但矩阵遗漏。该项必须标为流程缺陷,补写矩阵和代码变更记忆后才能继续。
如果计划外新增 WebAPI 的真实原因是 `missed-from-trace`,不得把它写成普通能力增量。
## 检查命令
存在 Trace 的 case 在进入 signer probe 前和交付前都应运行:
```bash
node scripts/check_trace_api_coverage.js --case-dir case --markdown
node scripts/check_trace_api_coverage.js --case-dir case --require-stage-audit --require-runtime-closure --json
```
检查失败时,下一阶段只能修复 Trace 覆盖矩阵或补齐首轮实现,不能继续新增 signer、发送请求或宣称阶段验证通过。

View File

@@ -0,0 +1,141 @@
# Trace-runtime 可执行一致性闭环
本文件用于解决“Trace 已命中并进入 inventory,但当前 runtime 仍存在大量未补、错误返回、错误原型或错误分支”的问题。Trace inventory 只说明 API 被发现;只有本文件定义的行为契约、Node audit 和深度 diff 全部通过,才能说明环境闭环。
## 硬性原则
- Trace 必须转换成机器可验证的 `trace-runtime-contract.json`,不能只生成 API 名称和人工状态。
- `implemented-first-pass` 只有在实现文件存在、runtime 实际加载、audit-only 执行成功且 P0/P1 深度 diff 为零时才成立。
- `matched` 必须由 `check_trace_runtime_conformance.js` 计算,不得由 Markdown 或手写 JSON 自行声明。
- 每次修改 `result/`、刷新动态 JS、切换 baseline 或重新采集 Trace 后,旧 audit 立即失效;必须重新计算 `traceSourceHash`、`contractHash` 和 `runtimeSourceHash`。
- Trace-runtime audit 必须在 `no-send` 模式运行。审计阶段禁止访问目标网络,XHR/fetch 只允许记录准备发送的 transcript。
- 在 P0/P1 runtime closure 完成前,不得运行真实请求、不得继续扩大 POST 次数、不得把 writer 未触发归因于流程或 TLS。
- 状态型 API 必须比较原始 happens-before 时间线。不得把 sequence 排序后只比数量,也不得从按 API 分组的 observations 反推全局顺序。
- 自动发现到多份原始 Trace 时不得跨历史采集静默合并;必须使用 `--trace` 显式选择当前 `baselineId` 的完整 Trace。多文件分片只有在事件 sequence 跨文件全局唯一时才允许合并。
## 强制产物
```text
case/notes/trace-runtime-contract.json
case/tmp/node-trace-runtime-audit.json
case/notes/trace-runtime-diff.md
```
`trace-runtime-contract.json` 必须由以下命令从原始 Trace 生成:
```bash
node scripts/build_trace_runtime_contract.js --case-dir case --trace case/ruyi-trace/logs/current.ndjson --baseline-id <baselineId> --markdown
```
不得手工创建空 contract,也不得只保留 Top 30 API。所有不同的 API、access type、realm、receiver 和 phase 组合都必须进入契约。
## 契约维度
Trace 有证据时必须记录:
- API path、访问类型、receiver、realm、writer/navigation phase。
- owner、descriptor、getter/setter 所在 prototype。
- `Object.prototype.toString` brand、constructor name、`instanceof`。
- `Object.keys`、own names、own symbols、`Reflect.ownKeys`。
- prototype chain,包括 XHR/EventTarget 等中间原型层。
- 参数、返回值、异常和副作用的稳定摘要。
- 调用顺序、stack 证据和 navigation epoch。
- XHR open/send/readyState、reload/navigation、Worker/MessagePort、terminate/close、DOM mutation 和 observer callback 等状态事件的折叠时间线、完整 sequence hash 与来源 Trace。
缺少真实值时应标记为待同 baseline 采样,而不是用空值或默认值生成匹配契约。
## audit-only 入口
最终入口或专用内部审计模式必须支持:
```text
--audit-only
WEB_JS_ENV_PATCHER_AUDIT_ONLY=1
WEB_JS_ENV_PATCHER_NO_NETWORK=1
WEB_JS_ENV_PATCHER_NETWORK_MODE=no-send
WEB_JS_ENV_PATCHER_TRACE_CONTRACT=<contract path>
WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT=<audit path>
```
audit-only 模式:
- 必须安装与正常运行完全相同的 env/runtime。
- 必须执行目标初始化和当前 signer/writer 路径,但网络层只记录请求,不发送目标网络。
- 必须把逐项观测写入 `WEB_JS_ENV_PATCHER_TRACE_AUDIT_OUT`。
- 不得额外安装“只在审计时存在”的假 API。
- 不得通过读取 contract 后直接复制 expected 值生成 audit。
- 必须记录真实网络尝试次数;大于零直接失败。
入口写出的原始 observations 至少使用以下结构:
```json
{
"probeVersion": "case-runtime-audit/v3",
"networkAttempts": 0,
"timeline": [
{"contractId": "contract item id", "sequence": 101},
{"contractId": "another contract item id", "sequence": 102}
],
"observations": [
{
"id": "contract item id",
"api": "XMLHttpRequest",
"accessType": "construct",
"realm": "main",
"receiver": "Window",
"phase": "challenge-init",
"observations": {
"owner": ["XMLHttpRequest"],
"brand": ["[object XMLHttpRequest]"],
"constructorName": ["XMLHttpRequest"],
"prototypeChain": [["XMLHttpRequest", "XMLHttpRequestEventTarget", "EventTarget", "Object"]],
"ownKeys": [[]]
}
}
]
}
```
`id` 应直接使用 contract item id。观测值必须来自当前 runtime 的真实 probe,不得复制 contract 中的 digest;`run_trace_runtime_audit.js` 会统一计算并写入可信元数据。
`timeline` 必须由 runtime 在事件实际发生时追加,且保留严格递增 sequence。只给每个 observation 填写局部 `sequences`、再由审计脚本排序重建,不足以证明交错事件、terminate/close、reload 销毁或 DOM mutation 的真实顺序。
运行:
```bash
node scripts/run_trace_runtime_audit.js --case-dir case --entry case/result/final.js --markdown
node scripts/check_trace_runtime_conformance.js --case-dir case --markdown
```
Python 入口使用 `case/result/final.py`。
## 阻断条件
- contract 或 Node audit 缺失。
- `contractHash`、`traceSourceHash`、`baselineId` 不一致。
- Node audit 缺少 `runtimeSourceHash` 或 `probeVersion`。
- audit-only 阶段发生真实网络访问。
- contract 仍为 v1/v2、Node audit 仍为 v1/v2,或 timeline 不是由原始 runtime event timeline 生成。
- 多份 Trace 无法建立唯一全局顺序,或 browser/Node 折叠状态时间线 hash 不一致。
- 任一 P0/P1 契约缺少 runtime observation。
- owner、descriptor、brand、prototype、ownKeys、返回值、异常或副作用不一致。
- Node 多出 Trace 未记录的宿主 API 观测,且未分类为动态新分支或 Node 泄露。
P2 差异可记录为风险,但涉及当前 writer、请求生成、Cookie、fingerprint 或检测分支时必须提升为 P0/P1。
## 强制循环
```text
原始 Trace
-> build_trace_runtime_contract
-> 第一轮 env
-> run_trace_runtime_audit(no-send)
-> check_trace_runtime_conformance
-> 修复差异
-> 重新 audit
-> P0/P1 差异清零
-> XHR/fetch 请求语义审计
-> 真实请求
```
用户不需要额外提出“把 Trace 和当前环境一一对比”;只要存在 Trace,该循环就是默认流程。

View File

@@ -0,0 +1,27 @@
# 证据可信度矩阵
本文件用于判断某个环境值、入口结论或参数依赖的可信程度,避免把猜测写成事实。
## 可信度等级
| 等级 | 来源 | 使用方式 |
|---|---|---|
| A | 浏览器真实运行样本、DevTools 断点、Hook 命中调用栈 | 可作为 fixtures 和入口证据 |
| B | HAR / cURL / Network 导出、sourcemap 定位 | 可作为强证据,但需复核动态值 |
| C | 静态搜索、格式化代码、webpack module 名称 | 可作为线索,不单独定论 |
| D | 根据常见模式推断 | 只能写入“疑似”,必须继续验证 |
## 环境值采信顺序
1. 浏览器运行时读取到的真实值。
2. 成功请求样本中携带的值。
3. 页面 HTML / script / meta / window 配置中的值。
4. JS 静态常量。
5. 根据 UA 或平台推断的默认值。
## 输出要求
- 每个关键 source 字段都标注来源等级。
- 不确定字段写“未知 / 待验证”,不要用默认值掩盖。
- Cookie、token、localStorage 敏感值只写键名、用途和脱敏摘要。
- 若结果依赖 D 级推断,不能宣称补环境完成。

View File

@@ -0,0 +1,58 @@
# isTrusted 事件检测与可信输入取证规则
当用户选择 ruyiPage、Camoufox、CloakBrowser 自动执行点击、拖拽、鼠标移动、键盘输入、滚动或验证码交互时读取本文件。目标是在前置取证阶段尽量使用浏览器原生输入路径,避免普通 JavaScript 合成事件导致 `event.isTrusted === false` 被目标站检测。
## 核心硬性规则
1. 取证交互从第一次打开目标页开始就使用用户确认的工具原生输入路径,不要先用普通 Playwright、Puppeteer、Selenium、CDP 或 `dispatchEvent` 试探。
2. 普通 `element.dispatchEvent(new MouseEvent(...))`、`new KeyboardEvent(...)`、`new PointerEvent(...)` 默认视为不可信事件风险,不能作为验证码、高风控点击、拖拽、输入的主路径。
3. 如果必须构造事件,只能在工具明确提供可信事件补丁时使用,并在阶段报告中标记工具、事件类型、参数和风险。
4. 用户手动操作天然优先于自动化合成事件。登录、MFA、验证码答案、设备确认和高风险验证优先让用户在已确认取证浏览器中手动完成。
5. 可信输入只用于授权前置取证、最小必要交互和样本采集。最终 `result/` 不得包含浏览器自动化代码。
## ruyiPage
ruyiPage 模式优先使用 Firefox / WebDriver BiDi 原生动作链和拟人动作:
```python
page.actions.move_to(page.ele("#btn")).click().perform()
page.actions.drag(page.ele("#source"), page.ele("#target"), duration=640, steps=16).perform()
page.actions.release()
page.actions.human_move(ele, algorithm="windmouse").perform()
page.actions.human_click(ele, algorithm="windmouse").perform()
```
如果确实必须在页面内构造 JS 事件,必须使用 ruyiPage 支持的 `ruyi: true` 参数,并说明这是 ruyiPage 特定能力,不要迁移到普通浏览器或最终项目:
```javascript
new Event('change', { bubbles: true, ruyi: true });
new InputEvent('input', { bubbles: true, data: 'A', inputType: 'insertText', ruyi: true });
new MouseEvent('click', { bubbles: true, clientX: 12, clientY: 24, ruyi: true });
new KeyboardEvent('keydown', { bubbles: true, key: 'Enter', code: 'Enter', ruyi: true });
new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, clientX: 3, clientY: 5, ruyi: true });
new WheelEvent('wheel', { bubbles: true, deltaY: 120, ruyi: true });
```
ruyiPage 取证报告中要记录:使用的是 native BiDi action、human action、`ruyi: true` 事件,还是用户手动操作。普通 JS 合成事件不能被写成可信操作。
## Camoufox
Camoufox 模式从启动开始使用官方入口,默认 `headless=False` 和 `humanize=True`。鼠标、键盘、拖拽、滚动优先使用 Camoufox / Playwright 风格的原生输入接口或 MCP 的官方交互能力,不要通过 `page.evaluate(() => element.dispatchEvent(...))` 合成事件。
如果某个流程必须依赖页面内合成事件,应先暂停并说明 `isTrusted=false` 风险,让用户选择:
- 改为用户手动操作。
- 切换到 ruyiPage 并使用 native BiDi action 或 `ruyi: true`。
- 在授权范围内接受该风险,仅用于非关键取证。
## CloakBrowser
CloakBrowser 模式必须通过官方包装器启动,并默认 `humanize=True`。点击、输入、滚动、拖拽使用 CloakBrowser humanize patch 后的交互方法,不要用普通 `page.evaluate` 合成事件。
CloakBrowser 源码中的 fallback 到 `page.evaluate` 路径应视为可检测风险。若交互链路无法使用 humanize / 原生输入,应暂停并让用户确认是否手动完成或切换工具。
## 验证码与轨迹 fixture
验证码前置取证的真实浏览器交互必须遵守本文件。旧 `motionTrack` / `eventFixture` 只用于 Node.js 补环境阶段让 signer 生成加密参数,不代表真实浏览器事件的 `isTrusted`,也不保证最终验证码验证通过。
交付给 `web-verify-patcher` 前,必须说明轨迹来源、是否由可信输入采集、是否只是旧样本,以及后续需要重新生成真实轨迹或由用户确认人工轨迹。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
# WASM / Worker / postMessage 处理
本文件用于网页端 JS 补环境中遇到 Worker、WASM、iframe 或 postMessage 的情况。范围仍然限定在网页端 JS 运行链路,不扩展到 Native 逆向。
## 判断信号
- 目标 JS 调用 `new Worker()`、`SharedWorker()`。
- 入口函数返回 Promise,实际签名在异步消息中完成。
- trace 出现 `postMessage`、`onmessage`、`MessageChannel`。
- Network 中额外加载 `.wasm` 或 worker chunk。
- 加密入口只负责封装消息,真正结果从回调返回。
## 处理顺序
1. 记录主线程调用 `postMessage` 的消息体。
2. 收集 Worker JS、动态 chunk、WASM 文件和初始化参数。
3. 确认 Worker 返回消息格式:成功、失败、初始化完成、签名结果。
4. 如果只是消息转发,优先在 Node 中模拟消息通道,不要直接改算法。
5. 如果 WASM 参与网页签名,只记录 JS 调用边界和导出函数名;不要扩大到 Native 工具链。
6. 读取 `webapi-env-detection-matrix.md`,把 Worker / iframe / postMessage 放入 WebAPI 环境检测矩阵;不只验证 API 存在,还要验证浏览器行为。
7. 用 fixtures 验证异步结果,不以 Promise resolved 为完成。
## Worker / postMessage 必查行为
- Worker scope 必须有独立 `performance`、`timeOrigin`、`performance.now()` 序列;不得无证据复用主线程 performance。
- Worker scope 的 `self` / `globalThis` 必须指向 WorkerGlobalScope 自身;`window`、`document`、`top`、`parent`、`frames`、`localStorage` 等 Window-only API 必须按浏览器 baseline 缺失。
- Worker 的 ECMAScript/WebAPI 构造器和对象实例不得直接复用主 Window Realm 的引用。应验证 `Object`、`Function`、`Array`、`Promise`、`Event`、`EventTarget`、`URL`、`Blob`、`Headers`、`Request`、`Response`、`XMLHttpRequest`、`navigator`、`performance`、`crypto` 的 identity 与 `instanceof`。
- Worker scope 的 `setTimeout` / `clearTimeout` 必须绑定 Worker 私有状态;`Worker.prototype.terminate()` 后 pending timer 和延迟 `postMessage` 必须清理或丢弃。
- 同一 turn 内调用 `terminate()` 后,尚未入队与已经入队但未派发的 Worker message 都要按目标浏览器 baseline 处理;至少分别测试“立即 terminate”和“延迟 terminate”。
- `WorkerGlobalScope.self.postMessage()` 与 `MessagePort.prototype.postMessage()` 必须以后续 task 派发;不得同步触发对端 listener。
- `MessagePort.start()`、设置 `onmessage` 的隐式 start、`close()` 的发送端/接收端组合、entangled peer 解绑和 transfer 后原端口失效必须分别检测。
- sender 已 close、receiver 已 close、双方 close、消息已排队后 close、transfer 后再次 postMessage 都必须有浏览器 baseline;不能只检查 peer 是否 closed。
- `addEventListener` / `removeEventListener` / `dispatchEvent` 必须覆盖 listener object、capture、once、passive、stopImmediatePropagation 和 `handleEvent`。
- `MessagePort.prototype.postMessage(fn)`、`structuredClone(fn)` 的 `DataCloneError` name / message / stack 不能暴露 JS fallback 源码。
- iframe 参与消息链时,单独检查 `contentWindow`、`contentDocument`、`defaultView`、`srcdoc`、`document.write/close`、Window/IframeWindow ownKeys / ownNames。
- 如果真实浏览器进入 final writer,而 Node 停在 continuation / reload writer,必须把 Worker / performance / iframe / postMessage 时序列入 writer 分支 diff。
## 记录模板
```markdown
## Worker/WASM 消息链
- 主线程文件:
- Worker 文件:
- WASM 文件:
- 初始化消息:
- 签名请求消息:
- 签名响应消息:
- 依赖的浏览器环境:
- Node 模拟策略:真实 Worker / vm 模拟 / 手动封装消息通道
- 任务队列语义:同步 / 后续 task / microtask / timer
- Worker performance baseline:
- terminate 后 timer / postMessage 行为:
- MessagePort sender/receiver close、start、transfer:
- Worker Realm 构造器与对象 identity:
- iframe realm / ownKeys 状态:
- WebAPI 环境检测矩阵:
- 阻塞点:
```

View File

@@ -0,0 +1,285 @@
# WebAPI 环境检测矩阵门禁
本文件用于解决另一类常见失败:Trace 已经列出了 API,补环境也实现了 API,但目标仍因 iframe、Worker、PerformanceTimeline、DOM/CSSOM、枚举、任务队列或 writer 分支行为不像真实浏览器而进入错误分支。Trace API 覆盖矩阵回答“访问了什么”,本矩阵回答“访问到的浏览器行为是否一致”。
本文件只用于授权范围内的网页端 Node.js 补环境、真实浏览器基线采样和 Node 行为 diff。不得把它解释为厂商专用绕过流程;遇到 JSVMP / opcode / 字节码解释器时仍只记录环境行为、请求链和 writer 分支,不主动分析虚拟机源码。
## 触发条件
出现以下任一信号时,在进入真实请求、长跑验证或 final writer 判定前必须建立 WebAPI 环境检测矩阵:
- Trace / 阶段报告中出现 `iframe`、`srcdoc`、`contentWindow`、`contentDocument`、`defaultView`、`document.write`、`document.close`。
- 出现 `Worker`、`SharedWorker`、`DedicatedWorkerGlobalScope`、`postMessage`、`MessageChannel`、`MessagePort`、`terminate`、Worker 内 timer 或 Worker 内 `performance`。
- 出现 `PerformanceObserver`、`PerformanceObserverEntryList.getEntries`、`PerformanceResourceTiming`、`PerformancePaintTiming`、`performance.mark`、`performance.getEntries*`、XHR / image / favicon resource timing。
- 出现 `XMLHttpRequest`、`fetch`、`Request`、`Response`、`Headers`、`navigator.sendBeacon`,且目标路径依赖动态资源、Cookie、challenge、telemetry、resource timing 或 writer 分支。
- 出现 `Object.keys`、`Object.getOwnPropertyNames`、`Reflect.ownKeys`、descriptor、getter / setter、prototype walk、brand check、`Function.prototype.toString` 多通道。
- 出现 `this.__xxx`、`this._xxx`、`__readyState`、`__headers`、`__children`、`Object.defineProperty(obj, "__xxx")`、`defineValue(obj, "_xxx")` 等浏览器对象私有状态泄露迹象。
- 出现 `structuredClone`、`DataCloneError`、`MessagePort.prototype.postMessage(fn)`、Error stack / message 差异。
- 出现 DOM / CSSOM 完整性检查:`DOMParser`、`innerHTML`、Comment/Text 原型、`HTMLUnknownElement`、`textarea`、`option`、`select`、`HTMLCollection`、`ShadowRoot`、`CSSStyleSheet.insertRule`、`CSSRuleList`、`getComputedStyle`、`document.links/images/forms/title/on*`。
- 出现 writer 分支差异,例如同一链路中 Node 停在 continuation / reload writer,而真实浏览器进入 form writer / final writer。
## 必须产物
触发本门禁后,进入真实请求验证前至少生成:
```text
case/fixtures/browser-env-detection-baseline.json
case/tmp/node-env-detection-audit.json
case/notes/webapi-env-detection-matrix.md
```
如果已经有高强度 diff 文件,也要在 `case/notes/high-intensity-env-diff.md` 中引用本矩阵结论。若暂时不能生成真实浏览器 baseline,必须在矩阵中标记为 `needs-browser-baseline` 并说明阻塞原因,不得把 Node probe 通过当作真实浏览器一致。
矩阵状态不能自行证明一致。浏览器 baseline 与 Node audit 的每个触发类别必须包含同名 probe 和实际观测值;`check_webapi_env_detection_matrix.js` 将深度比较观测结果。空类别数组、只有 `status: matched` 或只有证据路径而没有结果均视为未审计。两端必须记录相同 `probeSuiteVersion` 与 `probeSourceHash`,并用 case 内的 `probeSourceFile` 让门禁重算真实 SHA-256,证明运行的是同一份 probe 源码;Node audit 还必须记录 `runtimeSourceHash`、`probeVersion` 和可信 `generatedBy`,源码变化后旧 audit 失效。
`browser-env-detection-baseline.json` 至少记录:
```json
{
"schemaVersion": "webapi-env-detection-baseline/v2",
"baselineId": "fp-example-001",
"capturedBy": "ruyiPage+RuyiTrace",
"browser": "Firefox",
"probeSuiteVersion": "webapi-realm-lifecycle/v2",
"probeSourceFile": "fixtures/webapi-env-probe-suite.js",
"probeSourceHash": "<same-sha256-on-browser-and-node>",
"categories": {
"iframe-realm": [
{
"id": "global-relations",
"capability": "realm-global-relations",
"observation": {"selfIsWindow": true, "parentIsMain": true, "defaultViewIsFrame": true}
}
],
"worker-task": [
{
"id": "immediate-terminate",
"capability": "worker-terminate-immediate",
"observation": {"messagesAfterTerminate": []}
}
],
"message-lifecycle": [
{
"id": "sender-close",
"capability": "message-port-close-sender",
"observation": {"delivered": false}
}
],
"performance-timeline": [{"id": "resource-entry-order", "observation": {"entries": []}}],
"dom-cssom": [{"id": "current-path-involved", "observation": {"involved": false}}],
"dom-crud": [
{
"id": "valid-combinator-selector",
"capability": "dom-selector-validity",
"observation": {"selector": "div > span", "throws": false}
}
],
"event-clone-error": [{"id": "xhr-brand", "observation": {"brand": "[object XMLHttpRequest]"}}],
"xhr-fetch-session-bridge": [{"id": "network-mode", "observation": {"mode": "live-session-bridge"}}],
"object-shape": [{"id": "xhr-prototype-chain", "observation": {"chain": ["XMLHttpRequest", "XMLHttpRequestEventTarget", "EventTarget", "Object"]}}],
"private-state-leakage": [{"id": "xhr-private-own-keys", "observation": {"keys": []}}],
"clock-timer": [{"id": "current-path-involved", "observation": {"involved": false}}],
"writer-branch": [{"id": "final-writer-path", "observation": {"writer": "form-writer"}}]
}
}
```
`node-env-detection-audit.json` 至少记录同一组类别、同一 probe 名称、同一 `capability` 和 Node 实际观测值,并额外包含 `generatedBy`、`probeVersion`、`probeSuiteVersion`、`probeSourceFile`、`probeSourceHash`、`runtimeSourceHash`。`not-involved` 也必须以明确 probe 观测表达,不能使用空数组。
以下字段才算实际观测:`observation`、`observed`、`result`、`value`、`output` 或 `expected`。`evidence`、`notes`、`status`、文件路径和人工结论不属于实际观测。
## 状态枚举
矩阵中每个检测项必须选择一个状态:
| 状态 | 含义 | 是否允许真实请求 |
|---|---|---|
| `matched` | 浏览器 baseline 与 Node audit 一致 | 允许 |
| `accepted-diff` | 差异已评估为不影响当前路径,并有证据 | 允许,但必须写风险 |
| `not-involved` | 当前 Trace / writer 路径未涉及 | 允许 |
| `needs-browser-baseline` | 缺真实浏览器基线 | 不允许 |
| `needs-node-audit` | 缺 Node 同 probe audit | 不允许 |
| `mismatch` | 存在未修复行为差异 | 不允许 |
| `native-capability-gap` | 当前 JS / addon / xbs 均无法可靠表达 | 不允许,进入 native 能力缺口闭环 |
| `unknown` | 未判断 | 不允许 |
Realm identity、对象形状、私有状态、Worker/MessagePort 生命周期、DOM CRUD、当前网络 writer 和请求语义属于 P0/P1 时不得使用 `accepted-diff`。这些差异必须修复、补采真实 baseline,或进入明确的 native capability gap。
## 强制 capability
触发下列类别时,browser 与 Node 两端都必须包含这些 capability;probe 名称可以按项目细化,但 capability id 不得省略:
| 类别 | capability |
|---|---|
| `iframe-realm` | `realm-global-relations`、`realm-ecma-constructor-isolation`、`realm-webapi-constructor-isolation`、`realm-object-isolation`、`realm-document-relations`、`realm-navigation-lifecycle` |
| `worker-task` | `worker-global-surface`、`worker-constructor-isolation`、`worker-object-isolation`、`worker-message-order`、`worker-terminate-immediate`、`worker-terminate-deferred` |
| `message-lifecycle` | `message-order`、`message-port-start`、`message-port-close-sender`、`message-port-close-receiver`、`message-port-transfer` |
| `dom-crud` | `dom-tree-mutation`、`dom-live-collections`、`dom-selector-validity`、`dom-html-parsing`、`dom-document-relations`、`dom-mutation-observer` |
## 必查类别
### iframe / Window realm
检查:
- `iframe.contentWindow` / `contentDocument` / `defaultView` 关系。
- `srcdoc`、sandbox、`document.write()`、`document.close()`、load 事件顺序。
- 主窗口与 iframe Window 的 `eval`、`fetch`、`location`、`navigator` descriptor。
- 主窗口与 iframe 的 `Object/Function/Array/Promise`、`Event/EventTarget`、`URL/Blob`、`Headers/Request/Response/XHR` 构造器 identity 必须不同。
- iframe 的 `self/window/globalThis/frames` 指向自身,`parent/top` 指向正确上层;`document.defaultView/contentWindow/contentDocument/frameElement` 关系必须闭合。
- 允许共享同源 Cookie/Storage 后端和 TLS Session,但 navigator/performance/crypto/storage wrapper/fetch/timer 等公开对象不能直接复用主 Realm 实例。
- `Object.keys`、`Object.getOwnPropertyNames`、`Reflect.ownKeys` 的数量、顺序和过滤策略。
- `document.all`、`currentScript`、`readyState`、`defaultView` 是否按目标浏览器可见。
禁止为了推进链路全量放开 raw ownNames;只能基于浏览器 baseline 做窄范围 A/B,并记录为什么该项可见。
### Worker / Message task queue
检查:
- Worker scope 是否有独立 `performance`、`timeOrigin`、`performance.now()` 序列和 UA / baseline 选择。
- Worker 的 `self/globalThis`、构造器图和实例必须独立;Window-only API 必须缺失,主 Window 也不得错误暴露 WorkerGlobalScope-only API。
- Worker 内 `setTimeout` / `clearTimeout` 是否绑定 Worker 私有状态。
- `Worker.prototype.terminate()` 后 pending timer、延迟 `postMessage` 是否清理。
- 立即 terminate 与延迟 terminate 必须分开检测。
- `WorkerGlobalScope.self.postMessage()` 与 `MessagePort.prototype.postMessage()` 是否后续 task 派发,而不是同步栈内派发。
- `addEventListener` / `removeEventListener` / `dispatchEvent` 是否支持 listener object、capture、once、passive、stopImmediatePropagation。
Promise resolved、Worker 回包出现或 probe 不报错都不能单独视为完成;必须验证同步 / 异步顺序和 terminate 后行为。
### MessagePort / postMessage 生命周期
检查:
- `postMessage` 相对当前同步栈、Promise microtask、queueMicrotask、timer 的顺序。
- `start()` 与设置 `onmessage` 的隐式启动行为。
- sender close、receiver close、双方 close、消息入队后 close。
- transfer 后原端口是否失效、目标端口是否保持 entangled。
- `close()` 是否清除监听、待派发 task 和 peer 关系;不能只在发送时检查“对端是否 closed”。
### Performance timeline / resource timing
检查:
- `performance.getEntries()`、`getEntriesByType()`、`getEntriesByName()` 的排序。
- `PerformanceObserver.observe()` 对 `buffered` 与非 `buffered` 的差异。
- `PerformanceObserverEntryList.getEntries()` 回调内容和触发时机。
- XHR readyState 4 / load / loadend 前后 resource entry 是否可见。
- image、favicon、Turnstile / third-party script、flow、`/d/`、`/peek` 等资源是否进入正确 timeline。
- `PerformanceEntry.toJSON()`、paint、mark、resource entry 序列化是否一致。
禁止无条件 synthetic 初始 callback;禁止用插入顺序替代浏览器 `startTime` 排序,除非目标 baseline 证明如此。
### DOM / CSSOM 短值行为
检查:
- `document.createElement(tag)` 的 tag 到构造器映射,特别是未知 tag、`html`、`textarea`、`option`、`select`、`form`、`input`、`img`、`iframe`。
- `innerHTML` / `DOMParser.parseFromString()` 后 Text、CDATA、Comment、Element 原型与 `[object Xxx]`。
- `appendChild/removeChild/insertBefore/replaceChild/replaceChildren` 的移动、重复插入、DocumentFragment 展开、错误类型和 ownerDocument/isConnected 更新。
- `childNodes`、`children`、`getElementsBy*` 的 live 语义与 `querySelectorAll` 的 static 语义。
- 合法选择器如 `div > span` 不得错误抛出 `SyntaxError`;非法选择器必须按浏览器 baseline 抛错。
- HTML parser 不得丢弃 Comment/Text;`innerHTML`、clone/import/adopt 后 document/parent/sibling 关系必须一致。
- `MutationObserver` 的 record 内容、合并策略和 microtask 时序。
- `HTMLCollection`、`NodeList`、`HTMLOptionsCollection` 是否 native-like,是否实时或快照符合目标路径。
- `attachShadow()`、`ShadowRoot.host/mode`、`shadowRoot` descriptor。
- `CSSStyleSheet.insertRule()`、`CSSStyleRule.selectorText/style`、`CSSRuleList.item()`、`getComputedStyle()` 对目标选择器的最小一致性。
- `document.links/images/forms/title/on*`、`document.cookie` 可见性与 descriptor。
### Error / clone / native shape
检查:
- `structuredClone(fn)`、`MessagePort.prototype.postMessage(fn)` 的错误 name / message / stack。
- `Function.prototype.toString.call(fn)`、`fn + ""`、`String(fn)`、保存旧 FTS 后调用。
- 构造函数直接调用 / `new` 调用错误差异。
- `Object.getOwnPropertyDescriptor`、`__lookupGetter__`、`__lookupSetter__`、`hasOwnProperty`、`propertyIsEnumerable`、`for...in`。
- `Object.prototype.toString.call`、`Symbol.toStringTag`、`instanceof`、prototype walk。
### XHR / fetch session bridge
检查:
- `XMLHttpRequest.send()` / `fetch()` / `navigator.sendBeacon()` 是否为 `offline-fixture` 诊断模式,还是 `live-session-bridge` 真实请求模式。
- 真实请求模式是否通过同一 CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet Session 发送。
- Python `curl_cffi` 场景是否由 `final.py` 持有唯一 session,并通过 IPC 服务 Node JS runtime 的 XHR/fetch 请求。
- Cookie jar、`document.cookie`、`Set-Cookie`、`getResponseHeader("set-cookie")`、Response headers、body、readyState / Promise / event 顺序是否与浏览器 baseline 一致。
- XHR / fetch 请求是否产生正确的 PerformanceResourceTiming 可见性。
禁止把 fixture / 默认 200 / mock response 写成真实请求成功;禁止在 XHR/fetch 内部直接调用 Node 宿主 `fetch`、`http`、`https`、`axios` 或 Python 普通 `requests`。
### Object shape
检查:
- `Object.keys`、`Object.getOwnPropertyNames`、`Object.getOwnPropertySymbols`、`Reflect.ownKeys` 的数量、顺序和字段名。
- `Object.getOwnPropertyDescriptor`、`hasOwnProperty`、`propertyIsEnumerable`、`for...in`、`in`、`__lookupGetter__`、`__lookupSetter__`。
- 构造函数、prototype、实例对象、集合对象、iframe realm、Worker realm 的 prototype walk。
- `Object.prototype.toString.call(obj)`、`Symbol.toStringTag`、`constructor.name`、`instanceof`。
如对象形状涉及内部状态,应同时生成 `object-shape-audit.md`、`browser-object-shape-baseline.json` 和 `node-object-shape-audit.json`。
### Private state leakage
检查:
- 浏览器可见对象是否存在自定义 `_` / `__` 自有属性,包括 non-enumerable 属性。
- 是否使用 `this.__state`、`this._headers`、`Object.defineProperty(obj, "__readyState")`、`defineValue(obj, "_store")` 或自定义 Symbol 保存内部状态。
- 内部状态是否迁移到 addon / xbs private API、原生内部槽或模块级 `WeakMap`。
发现 `_` / `__` 私有状态泄露时直接阻断;不能用 `enumerable:false` 作为豁免。
### Clock / timer
检查:
- `Date.now()`、`new Date()`、主线程 `performance.now()`、Worker `performance.now()` 是否来自同一 baseline 策略。
- 虚拟 timer driver 是否只运行到期 timer,不因 sleep cap 把 10s / 30s timer 一轮快进。
- `queueMicrotask`、Promise job、timer task、MessagePort task、PerformanceObserver callback 的相对顺序。
### Writer branch
检查:
- 请求链中每个关键响应对应的 writer 类型:continuation program、reload writer、form writer、final writer。
- Node 与真实浏览器在 final writer 前 300 到 1000 个环境事件的类别差异。
- 是否出现 `HTMLFormElement.submit(true)`、hidden input 创建、`cf_chl_rc_ni` 删除、`Location.reload` 等分支证据。
- 不得把拿到 Cookie、Set-Cookie 或 reload writer 响应写成最终成功;必须记录 writer 类型。
## 阻断规则
- 存在 `mismatch`、`needs-browser-baseline`、`needs-node-audit`、`native-capability-gap` 或 `unknown` 的 P0/P1 检测项时,不得执行真实请求验证、不得宣称 final writer 已闭环。
- 只有类别名称或手工 `matched`、没有逐 probe 浏览器值和 Node 值时,直接阻断。
- browser/Node 的 `probeSuiteVersion`、`probeSourceFile` 或 `probeSourceHash` 缺失,hash 不一致,源文件不在 case 内,或声明 hash 与实际文件不一致,直接阻断。
- iframe/Worker 构造器或公开对象复用主 Realm、MessagePort 只实现单侧 close、DOM probe 把合法 selector 当非法、HTML parser 丢 Comment,直接阻断。
- 存在 `xhr-fetch-session-bridge` 阻断项时,不得宣称 TLS 指纹已解决;fixture/mock 只能作为离线诊断结论。
- 存在 `private-state-leakage` 阻断项时,不得交付;必须迁移到 addon / xbs private API 或 WeakMap。
- 如果后续阶段新增 WebAPI 是因为本矩阵遗漏了已触发检测项,必须标为 `missed-from-webapi-env-matrix` 流程缺陷,并补写矩阵与代码变更记忆。
- 如果真实请求失败但矩阵未覆盖 Worker / performance / iframe / DOM/CSS / writer branch,而 Trace 或阶段报告已经出现这些信号,下一步只能补矩阵和离线 diff,不能继续盲目真实请求。
## 阶段报告记录模板
```markdown
## WebAPI 环境检测矩阵
- 矩阵文件:case/notes/webapi-env-detection-matrix.md
- 浏览器 baseline:case/fixtures/browser-env-detection-baseline.json
- Node audit:case/tmp/node-env-detection-audit.json
- baselineId:
- 触发类别:iframe-realm / worker-task / message-lifecycle / performance-timeline / dom-cssom / dom-crud / event-clone-error / xhr-fetch-session-bridge / object-shape / private-state-leakage / clock-timer / writer-branch
- 阻断项:无 / 列表
- writer 分支:真实浏览器 = form writer;Node = reload writer / continuation / form writer
| 类别 | 检测项 | 浏览器证据 | Node 证据 | 状态 | 处理 |
|---|---|---|---|---|---|
| worker-task | postMessage async task | fixtures/... | tmp/... | matched | 保持 |
```
## 检查命令
```bash
node scripts/check_webapi_env_detection_matrix.js --case-dir case --markdown
node scripts/check_webapi_env_detection_matrix.js --case-dir case --require --require-writer-branch --json
```
检查失败时,下一步只能补浏览器 baseline、Node audit、矩阵、native 能力缺口或离线 diff;不能继续真实请求验证。

View File

@@ -0,0 +1,461 @@
# 补环境前置流程
本文件用于指导写补环境代码之前的详细流程。
## 阶段 -1:新 case 第一回复硬门禁
每次用户给出新网站、新接口、新 cURL / HAR、旧补环境文件,或要求“帮我分析 / 补环境 / 成功请求到数据”时,第一回复先做门禁检查,不直接进入取证或补环境。
即使用户提供了很长的 cURL、Cookie、403/200 现象、JS 文件线索、旧代码文件名,仍然不能把它视为已经通过主流程。cURL 只是请求样本,不等于取证模式已确认,也不等于最终请求 TLS 客户端已确认。
第一回复必须输出:
```markdown
## 信息完整性检查
### 已识别信息
- 目标网站 / 页面:
- 目标 API:
- 请求方法:
- 已提供请求样本:
- 已知响应现象:
- 已知旧代码 / JS 文件:
- 用户已明确的目标参数:
### 缺失 / 待确认信息
- 取证模式:未确认 / ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定
- 最终请求 TLS 指纹兼容客户端:未确认 / Node.js CycleTLS / Node.js impers / Node.js curl-cffi / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求
- 是否有授权测试权限:
- 是否需要登录:
- 本次确认要分析的加密 / 风控参数:
- 是否允许创建 case 目录和阶段报告:
### 初步发现的可疑参数候选
| 参数名 | 位置 | 疑似原因 | 是否等待用户确认 |
|---|---|---|---|
| | Query / Header / Body / Cookie | | 是 |
### 暂停说明
在你确认取证模式、最终请求客户端和本次分析参数前,我不会启动浏览器取证、下载 JS、运行 Hook、修改或运行旧补环境代码、复用 cURL 中的动态参数,也不会发送真实请求。
```
如果缺少取证模式或最终请求 TLS 客户端,第一回复必须停在本阶段和阶段 0.5 / 0.6;只能继续做离线整理、列缺失项和生成 `case/阶段报告/01-需求信息确认.md`,不能进入请求验证、JS 收集、入口定位、Node trace 或补环境代码。
## 阶段 0:范围确认
确认以下事项:
- 目标是网页端 / 浏览器端 JavaScript。
- 用户要求的是 Node.js 补环境。
- 排除 App、移动端、小程序、Windows、Native 等方向。
- 当前目标不是纯算重写。
- 浏览器自动化只用于前置取证,不用于批量采集。
- 用户有授权测试范围。
任一条件不满足时,停止并说明 Skill 边界。
## 阶段 0.5:取证模式确认
在信息校验、请求样本验证、JS 文件收集、Hook、断点、截图、RuyiTrace 日志采集等任何取证动作之前,必须先让用户选择取证模式:
1. ruyiPage + RuyiTrace(推荐)。
2. 仅 ruyiPage。
3. Camoufox + camoufox-reverse-mcp。
4. 仅 Camoufox。
5. CloakBrowser。
6. 用户手动取证。
7. AI 自行决定。
确认后把选择记录为本 case 的取证模式,并在后续所有浏览器取证动作中沿用。不要等确认有自动化检测后再启用 ruyiPage / RuyiTrace。不要在用户已选择 ruyiPage / CloakBrowser / 手动取证后,自行改用普通 Playwright、Puppeteer 或系统 Firefox。
如果所选工具不可用或后续需要切换工具,暂停并重新让用户确认。
如果用户选择 ruyiPage + RuyiTrace,但检测到 RuyiTrace 未安装或目录不完整,不能自动降级为仅 ruyiPage。必须先提示用户选择“安装 / 提供 RuyiTrace 路径”或“明确降级为仅 ruyiPage”;用户选择安装时,等待用户完成安装并重新检测通过后,才继续需要 NDJSON 的流程。
如果用户没有明确选择取证模式,不能因为用户提供了 cURL、Cookie、目标站 URL、旧代码或“请成功请求到数据”的要求而默认启动任何浏览器工具。只能给出取证模式选项并等待确认;如果用户选择“AI 自行决定”,也必须先检测工具并回报拟用方案,等待用户确认后再启动。
## 阶段 0.6:最终请求 TLS 指纹兼容客户端确认
如果本 case 最终需要发送真实请求或交付 `final.js` / `final.py`,必须在前置阶段就选择最终请求客户端,不要等普通 `fetch` / `requests` 失败后再临时切换。
可选项:
1. Node.js CycleTLS。
2. Node.js impers。
3. Python curl_cffi。
4. Python cffi_curl。
5. Python cyCronet。
6. 不发真实请求,只输出本地 sign / 参数。
确认后立即运行:
```bash
node scripts/check_tls_clients.js --markdown
node scripts/check_tls_clients.js --python python --markdown
```
如果用户选择的库未安装,先让用户确认安装、改选其他已安装客户端,或选择“不发真实请求”。TLS 指纹兼容仅用于授权范围内少量最终验证请求,不用于批量访问、绕过登录、验证码、MFA、付费墙或访问控制。
## 阶段 0.7:阶段报告初始化
从用户提供第一轮需求信息开始,默认创建 `case/阶段报告/01-需求信息确认.md`,记录目标 URL、API、取证选项、加密参数、加密文件 / JS 文件、已提供样本、缺失项和下一步待确认问题。即使信息不完整,也要生成该阶段报告。
推荐命令:
```bash
node scripts/write_stage_report.js --case-dir case --stage 需求信息确认 --data case/notes/需求信息.json --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --markdown
```
后续阶段结束时继续写入 `02-取证方案确认.md`、`03-请求样本与可疑参数确认.md`、`04-JS文件与入口定位.md`、`05-补环境前置分析.md`、`06-补环境实现记录.md`、`07-验证与清理记录.md`。所有 Markdown 文件名必须包含中文。
## 阶段 1:信息完整性检查
必填信息:
1. 目标网站或目标页面 URL。
2. 目标接口 API URL。
3. 请求方法。
4. 目标加密参数名。
5. 参数位置:Query / Header / Body / Cookie。
6. 至少一份成功请求样本,优先 Copy as cURL。
7. 取证模式:ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定。
8. 最终请求 TLS 指纹兼容客户端:Node.js CycleTLS / Node.js impers / Node.js curl-cffi / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求。
如果信息不完整,列出缺失项,并使用 `intake-template.md` 让用户补充。
如果信息完整,先整理任务摘要并请求用户确认,不要直接进入分析。
强制阻断项:
- 未确认取证模式:不得打开网页、抓包、Hook、截图、下载 JS 或采集 RuyiTrace。
- 未确认最终请求 TLS 客户端且任务要求成功请求:不得发送真实请求,也不得先用普通 HTTP 客户端试错。
- 未确认本次分析参数:不得只盯用户指定参数进入补环境;必须先列出 Query / Header / Body / Cookie 中所有可疑候选并让用户确认。
- 未确认授权 / 登录状态:不得尝试绕过登录、验证码、MFA、风控或访问控制。
## 阶段 2:请求样本检查
检查内容:
| 检查项 | 目的 |
|---|---|
| URL 是否完整 | 是否包含协议、域名、路径 |
| 请求方法是否明确 | GET / POST / PUT 等 |
| 请求头是否完整 | UA、Content-Type、Cookie、Referer、Origin 等 |
| Body 是否完整 | POST / PUT 请求是否包含请求体 |
| 目标参数是否存在 | 是否能在用户声明的位置找到参数 |
| 响应样本是否成功 | 判断样本是否是有效请求 |
| 取证模式是否一致 | 后续抓包、JS 收集、Hook、截图和日志采集是否都沿用用户选择的工具 |
| Cookie/token 是否过期 | 先判断是登录态授权 Cookie,还是非登录生成型 Cookie |
| 最终请求客户端是否已选 | 从一开始确认 CycleTLS / impers / curl_cffi / cffi_curl / cyCronet / 不发真实请求 |
推荐输出:
```markdown
## 请求样本检查结果
- 请求是否完整:
- 目标参数是否存在:
- 参数位置是否一致:
- 是否缺少关键 Header:
- 是否缺少 Cookie / token:
- Cookie/token 是否过期:
- Cookie 分类:登录态 / 非登录生成型 / 未确认
- 最终请求 TLS 指纹兼容客户端:
- 是否可以进入下一步:
```
## 阶段 2.2:可疑加密参数发现与用户确认
在正式补环境前,必须先从 cURL / HAR / 请求样本中列出所有可疑加密参数,并让用户确认本次要分析哪些。不要只复用用户给出的 `--param`,也不要只盯一个参数。
执行建议:
```bash
node scripts/parse_curl.js --input request.curl --json
node scripts/parse_curl.js --input request.curl --markdown
```
必须覆盖的位置:Query、Header、Body、Cookie。重点关注 sign、token、a_bogus、h5st、x-s、x-t、mtgsig、w_rid、_signature、nonce、hash、设备 Cookie、风控 Cookie、JS 生成 Cookie 等。
输出给用户确认:
```markdown
## 发现的可疑加密参数
| 参数名 | 位置 | 样本值摘要 | 疑似原因 | 是否建议分析 |
|---|---|---|---|---|
| | Query/Header/Body/Cookie | 脱敏 | | 是/否 |
请确认本次要分析哪些参数。确认前我不会进入正式补环境。
```
同时写入 `case/阶段报告/03-请求样本与可疑参数确认.md`,记录候选参数、用户确认状态和未确认项。
确认规则:
- 如果用户已经指定目标参数,也要提示其他候选参数,询问是否需要一并分析。
- 用户未确认目标参数列表前,不进入 `env.js` 编写、Node trace 或补环境阶段。
- cURL 中已有参数值只能作为浏览器真实样本 / fixture expected,不能作为最终产物中的固定值。
- 如果某个候选是登录态 token / Authorization,先回到登录与授权边界,不做绕过。
## 阶段 2.5:Cookie 过期与来源分类
当请求失败疑似由 Cookie/token 过期、Cookie 缺失、Cookie 写入失败或参数位置为 Cookie 引起时,先分类,不要默认要求用户重新给一份新 Cookie。
| 类型 | 处理方式 |
|---|---|
| 登录态 / 授权 Cookie | 不绕过登录;让用户手动登录或提供授权样本 |
| 服务端 `Set-Cookie` 首访 Cookie | 分析是否可通过前置 Node.js / Python 请求刷新,并写入最终请求客户端 |
| `document.cookie` 写入 Cookie | Hook setter 或用 RuyiTrace 查 `document.cookie` / `Document.cookie` 调用栈,定位 writer |
| JS 计算生成 Cookie | 按 `source → entry → builder → writer` 定位生成函数,并纳入补环境 |
| Storage / 指纹 / challenge 派生 Cookie | 分析 localStorage / sessionStorage / 指纹对象 / server seed / timestamp 等依赖 |
| 一次性服务端 challenge 或账号绑定 Cookie | 说明不可或不应复现,要求用户授权交互或离线样本 |
输出模板:
```markdown
## Cookie 过期处理判断
- Cookie 名称:
- 是否需要登录:
- 是否账号 / 授权相关:
- 来源判断:Set-Cookie / document.cookie / JS 计算 / Storage 派生 / challenge / 未确认
- 是否可生成或刷新:
- 生成链路 source / entry / builder / writer:
- 是否纳入补环境:
- 是否需要用户手动登录:是 / 否
```
结论规则:
- 对不需要登录的网站,优先分析非登录 Cookie 如何生成 / 刷新。
- 只有 Cookie 明确属于登录态 / 授权态、服务端一次性下发且无法 / 不应复现、或用户确认只做一次离线样本复现时,才要求用户重新提供有效 Cookie。
## 阶段 3:加密参数画像
仅对用户确认要分析的参数做画像。对未确认的候选参数,只保留在 notes 中作为备选证据,不要擅自进入补环境。
在补环境前先描述参数特征:
| 维度 | 要回答的问题 |
|---|---|
| 参数名 | sign / x-s / a_bogus / h5st / token 等 |
| 参数位置 | Query / Header / Body / Cookie |
| 是否动态 | 每次请求是否变化 |
| 是否依赖请求体 | Body 改变后参数是否变化 |
| 是否依赖 URL | path / query 是否参与签名 |
| 是否依赖 Cookie | 是否受登录态影响 |
| 是否依赖存储 | 是否读取 localStorage / sessionStorage |
| 是否依赖浏览器指纹 | navigator / canvas / WebGL / screen 等 |
| 是否依赖时间或随机数 | timestamp / nonce / random / server seed 等 |
| 是否已有 RuyiTrace 日志 | 是否有 NDJSON 环境访问日志可用于补环境优先级 |
推荐输出:
```markdown
## 加密参数画像
- 参数名:
- 位置:
- 是否动态:
- 已知依赖:
- 疑似依赖:
- 需要补充的样本:
```
## 阶段 4:加密入口定位
目标是找到生成加密参数的函数或模块。
定位必须按 `source → entry → builder → writer` 四层链路记录:
| 层级 | 必须回答 |
|---|---|
| source | 签名输入来自 URL、Body、Cookie、Storage、时间随机数、指纹中的哪些字段 |
| entry | 哪个函数或模块生成签名或中间签名 |
| builder | 哪个请求构造函数把签名拼入请求对象 |
| writer | 最终由 fetch/XHR/Header/Query/Body/Cookie 哪个位置写入网络请求 |
如果只找到 `entry` 但无法确认 `writer`,只能标记为“疑似入口”,不能进入正式补环境。阶段结束时写入 `case/阶段报告/04-JS文件与入口定位.md`。
优先方法:
1. 查看 Network Initiator。
2. 使用 XHR / fetch 断点。
3. 搜索参数名。
4. Hook 关键 API 记录调用栈。
5. 分析 Stack Trace。
6. 如果有 sourcemap,优先结合 sourcemap。
7. 定位 webpack / vite / runtime 模块和动态 chunk。
Hook 模板见 `hook-templates.md`。复杂链路见 `crypto-entry-location.md`。
常用搜索词:
```text
sign
token
x-s
a_bogus
h5st
setRequestHeader
fetch(
XMLHttpRequest
JSON.stringify
localStorage.getItem
document.cookie
crypto
```
推荐输出:
```markdown
## 加密入口定位
- 入口函数:
- 所在 JS 文件:
- 行列号:
- 调用链:
- 输入参数:
- 输出参数:
- 是否依赖浏览器环境:
- 是否需要继续补充 DevTools 信息:
```
## 阶段 5:JS 文件收集
只收集加密链路需要的文件:
- 主 bundle。
- 动态 chunk。
- runtime chunk。
- sourcemap。
- WASM 文件仅限网页端 JS 依赖,不扩展到 Native 逆向。
- 加密入口函数所在文件。
建议 case 目录:
```text
case/
├── js/
│ ├── original/
│ ├── pretty/
│ └── extracted/
├── requests/
├── fixtures/
├── notes/
└── tmp/
```
推荐输出:
```markdown
## JS 文件收集结果
- 已获取 JS 文件:
- 未获取 JS 文件:
- 是否存在 sourcemap:
- 是否存在动态 chunk:
- 是否存在 runtime:
- 是否存在 WASM:
- 是否可以进入补环境准备:
```
## 阶段 5.2:动态 HTML / JS 资源保鲜检查
下载或保存 HTML、JS bundle、动态 chunk、challenge JS、403/风控页面和内联脚本后,先判断这些资源是长期静态文件,还是会随时间、会话、Cookie、seed、nonce、地域或 TLS 指纹变化的动态资源。
强制规则:
- 本地动态 HTML / JS 只能作为分析快照,不得直接进入最终产物主路径。
- 对每个资源记录 URL、请求时间、状态码、响应头、Set-Cookie、body sha256、Cache-Control、Expires、ETag、Last-Modified、依赖 Cookie / seed / nonce、动态性判定、最终使用方式。
- 将清单写入 `case/notes/resource-manifest.json`。
- 命中 `no-store/no-cache/private/max-age=0`、短 TTL、随机 query、同 URL 多次 hash 变化、HTML 内 seed / nonce / challenge 变化、403 challenge JS、会话绑定 Cookie 时,最终入口必须实现运行时刷新。
- 最终入口应先重新请求当前 HTML / JS / challenge / seed,再运行补环境生成参数和发送请求。
推荐命令:
```bash
node scripts/check_dynamic_resources.js --case-dir case --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
```
推荐输出:
```markdown
## 动态资源保鲜检查
- 动态 HTML / JS:有 / 无
- 分析快照目录:
- 资源 manifest:
- 影响最终生成参数的动态资源:
- 最终运行时刷新模块:
- 是否允许进入补环境准备:
```
如果动态资源影响目标参数生成,但尚未设计运行时刷新链路,不得进入最终交付;如果因此请求失败,先刷新资源和 seed,再判断是否继续补 WebAPI。
## 阶段 5.5:RuyiTrace NDJSON 优先分析
如果取证模式为 ruyiPage + RuyiTrace,本阶段必须在进入 Node.js 补环境前执行。不要等 Node.js 报错后才临时查看日志。
1. 确认是否已有 RuyiTrace NDJSON。
- 如果 RuyiTrace 未安装或目录不完整,先回到“阶段 0.5:取证模式确认”,要求用户安装 / 提供路径或明确降级;不得直接跳过本阶段进入 Node trace。
2. 尚未导入时先执行:
```bash
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --markdown
```
3. 阅读 `notes/ruyitrace-summary.md`,按目标参数生成、目标 API 请求发起、writer 写入附近的 `api` 和 `stack.file / line / col` 汇总环境依赖。
4. 将结论写入:
- `notes/ruyitrace-summary.md`
- `notes/missing-env-priority.md`
- `notes/entry-chain.md`
5. 只有日志缺失、未覆盖当前逻辑或结论不足时,才把 Node trace / Proxy trace 作为主要发现来源。
遇到后续环境问题时,也先回到本阶段复查 NDJSON,再决定是否补充 Node trace。
推荐输出:
```markdown
## RuyiTrace NDJSON 优先分析
- 日志状态:已导入 / 未提供 / 未覆盖当前逻辑
- 摘要文件:
- 目标参数生成附近 API:
- 关键调用栈:
- 优先补齐环境模块:
- 需要 Node trace 补充的原因:
```
## 阶段 6:前置总结
在真正写 `env.js` 前,再次让用户确认:
```markdown
补环境前置任务已完成,请确认:
- 目标网站 URL:
- 目标 API:
- 发现的可疑加密参数:
- 用户确认要分析的参数:
- 参数位置:
- 加密入口:
- source / entry / builder / writer:
- 相关 JS 文件:
- 样本数量:
- 当前判断的依赖:
- 取证模式:
- 最终请求 TLS 指纹兼容客户端:
- TLS 客户端可用性:已安装 / 未安装需用户确认 / 不发真实请求
- 是否需要切换取证工具:否 / 是,原因:
- RuyiTrace 日志:已导入并优先分析 / 未提供 / 未覆盖当前逻辑 / 不需要
- RuyiTrace 优先证据:api / stack.file / line / col 摘要
- Node 泄露阻断检查:
- 六项纯计算预检:
- 最终交付要求:生成规范项目目录;只有 `result/final.js` 或 `result/final.py` 一个执行入口;入口运行后通过补环境后的目标 JS 入口 / signer 生成加密参数,并使用已确认的 TLS 指纹兼容客户端发送少量授权模拟请求,或按用户选择只输出本地参数;不交付自动化取证代码;不得硬编码或复用 cURL / fixture 中的加密参数样本值。
确认后将进入 Node.js 补环境阶段。
```

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
# XHR/fetch 请求语义与生命周期审计
本文件用于解决“XHR/fetch 已桥接同一 TLS Session,但请求头、body、请求来源、失败语义、responseURL、事件顺序或 reload 生命周期仍错误”的问题。Session Bridge 只证明请求由哪个客户端发送;本审计证明发送的请求和浏览器行为是否正确。
## 核心原则
- 真实请求前必须先执行 `no-send` 审计:Node bridge 记录完整待发送请求,但不得访问目标网络。
- 浏览器成功样本与 Node no-send 结果必须生成可比较的 network transcript。
- document navigation、reload、image、script、XHR、fetch、sendBeacon 必须有明确 actor,禁止根据 URL 猜请求类型。
- 请求和响应的 `matched` 必须由 `check_xhr_fetch_semantics.js` 计算,不能手工填写。
- Header 名称、顺序、重复字段和 body 原始字节属于请求语义,不得只比较对象键值。
- 缺失字段不得在规范化时用 `""`、`0`、`main` 或数组默认值掩盖。浏览器与 Node 同时缺失同一字段也必须失败。
- transcript 中敏感 Header 可以只记录 `{ "redacted": true, "length": N, "sha256": "..." }`;不允许只保留 Header 名称或空字符串。
## 强制产物
```text
case/fixtures/browser-network-transcript.ndjson
case/tmp/node-network-transcript.ndjson
case/tmp/xhr-fetch-semantics-audit.json
case/notes/xhr-fetch-semantics-diff.md
```
两份 transcript 都必须记录 `baselineId`。Node transcript 还必须记录:
```json
{
"type": "meta",
"schemaVersion": "network-transcript/v3",
"generatedBy": "runtime-network-recorder/v3",
"baselineId": "fp-001",
"mode": "no-send",
"runtimeSourceHash": "...",
"networkAttempts": 0
}
```
浏览器 meta 必须使用 `mode: "browser-baseline"`,并记录 `baselineId` 与可信 `generatedBy/capturedBy`。Node meta 必须使用 `mode: "no-send"`,记录当前源码的完整 SHA-256 `runtimeSourceHash`、可信 `generatedBy` 和显式 `networkAttempts: 0`。
## 每个事件共同字段
- `kind`、`pairKey`、`actor`。
- 显式 `realmId`、`navigationEpoch`、`sequence`,不能依赖默认值。
- `sessionId`;同一 transcript 只允许一个 Session。
## 每个请求必须记录
- method、完整 URL、query。
- 有序 Header 数组,保留重复字段。
- credentials、referrer、origin、Cookie 处理策略。
- body 类型、编码、原始 byte length、SHA256。
- Content-Type 和 multipart boundary。
- redirect、cache、timeout、abort 信息。
`POST`、`PUT`、`PATCH` 或任何实际携带 body 的请求,必须存在 `contentType`、原始 `bodyLength` 和完整 `bodySha256`。没有请求体时也不得伪造调试字符串的 hash。
Header 推荐结构:
```json
[
["content-type", "text/plain;charset=UTF-8"],
["cookie", {"redacted": true, "length": 128, "sha256": "..."}]
]
```
Header 数组中的每一项都必须有非空名称和真实值或脱敏摘要。比较时使用名称、顺序、重复次数、byte length 与 SHA-256;不能只比较名称集合。
浏览器与 Node 的原始 request id 可以不同,但必须在规范化阶段生成相同 `pairKey`。推荐使用“writer phase + actor + realm role + navigation epoch + 该 actor 第 N 次请求”,不得只用 URL 配对,因为同一 URL 可能同时用于 document navigation、reload 和 XHR。
## 每个响应必须记录
- status、statusText、responseURL。
- 响应头有序列表和脚本可见头列表。
- body byte length、SHA256、responseType。
- Set-Cookie 是否写入同一 session Cookie jar。
- readyState、readystatechange、load、error、abort、timeout、loadend 顺序。
- Promise resolve/reject 与 microtask/task 顺序。
- PerformanceResourceTiming 可见时机。
即使响应体为空,也必须记录 `bodyLength: 0` 和空字节 SHA-256。`status > 0` 时必须记录最终 `responseURL`;失败、abort、CORS 或网络错误的 `status=0` 必须显式记录空 `responseURL`。
## 硬性浏览器语义
- 网络错误、CORS 拒绝、abort 等 `status=0` 场景,`responseURL` 必须为空,除非同 baseline 浏览器证据明确相反。
- completion 层不得用 request URL 为失败 XHR 回填 `responseURL`。
- document navigation/reload 不得进入 XHR completion。
- 首次 navigation 与 reload 必须创建各自正确的 document/realm 生命周期。
- reload 或 navigation commit 后,旧 realm 的 pending timer、XHR、fetch、MessagePort task 和 observer callback 必须取消或丢弃。
- 真实浏览器没有发出的 XHR/fetch,Node 不得多发;多余事件直接阻断。
- `getResponseHeader("set-cookie")`、Response Headers 的 Set-Cookie 可见性必须以目标浏览器 baseline 为准。
- browser/Node 必须逐事件保持 request、response、reload/navigation lifecycle 的全局顺序;只按 `pairKey` 分组后忽略相对顺序不能通过。
- Node transcript 必须来自真正执行当前 XHR/fetch 实现的 recorder;手写与浏览器相同的 NDJSON、复制浏览器事件或空值占位都不算证据。
## body 类型矩阵
目标路径涉及时至少覆盖:
- `null` / `undefined`。
- string。
- `URLSearchParams`。
- `FormData` 与 multipart boundary。
- Blob。
- ArrayBuffer / TypedArray。
- Document。
- JSON string。
必须比较发送前 body byte length 和 SHA256,不能只比较序列化后的调试文本。
## Header 矩阵
必须检查:
- `setRequestHeader()` 的 trim、合并和重复行为。
- Header 名称大小写与发送顺序。
- Header 实际值或脱敏后的 byte length / SHA-256;只有名称没有值直接失败。
- 自动 Content-Type。
- Cookie、Origin、Referer、Accept、Accept-Language、Client Hints 等由哪一层生成。
- 禁止脚本设置的 Header 是否被正确过滤。
- curl/HAR 与当前浏览器 baseline 冲突时,以当前 baseline 为准。
## 检查命令
```bash
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --out case/tmp/xhr-fetch-semantics-audit.json --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --require-live --tls-client curl_cffi --markdown
node scripts/check_environment_closure.js --case-dir case --before-real-request --require-live --tls-client curl_cffi --markdown
```
只有 no-send 语义审计和 session bridge 运行时审计同时通过,才允许少量授权真实请求。不得等用户提出“比较请求头和请求体”后才执行本流程。

View File

@@ -0,0 +1,142 @@
# XHR/fetch Live Session Bridge
本文件用于约束 Node.js 补环境中的 `XMLHttpRequest`、`fetch`、`Request`、`Response`、`Headers` 与 `navigator.sendBeacon`。当最终项目会发送真实请求,或这些 API 影响动态资源、Cookie、challenge、telemetry、resource timing、writer 分支时,JS 侧网络 API 必须复用最终确认的 TLS 指纹兼容 Session。
## 硬性规则
- `offline-fixture` 只能用于离线诊断:可以返回 fixture / 默认响应,但必须明确标注“不发送真实网络请求”,不得宣称 TLS 指纹问题已解决。
- `live-session-bridge` 用于真实验证:JS `XMLHttpRequest` / `fetch` / `sendBeacon` 必须把请求交给同一个 CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet Session 发送。
- 不得在补环境网络对象内部直接调用 Node 宿主 `fetch`、`http`、`https`、`axios`、`undici` 或 Python 普通 `requests` 绕过已确认 TLS 客户端。
- 如果最终用户选择 Python `curl_cffi`,`final.py` 必须持有唯一 `curl_cffi.requests.Session()`;Node JS runtime 通过 stdio / IPC / 嵌入式 bridge 请求 Python 发送,Node 侧只实现浏览器 API 语义与桥接协议。
- Bridge 只能作为唯一 `final.js` / `final.py` 入口内部的受控模块存在,不得交付另一个手动启动入口、临时 HTTP server、调试 bridge 或常驻本地服务。
- XHR/fetch 对象本身不得通过 `_` / `__` 自有属性保存状态;状态用 addon / xbs private API 或模块级 `WeakMap`。
- Session Bridge 只证明“由哪个客户端发送”,不能证明请求语义正确。真实请求前必须继续按 `xhr-fetch-semantics-audit.md` 比较浏览器与 Node 的 no-send network transcript。
- 代码里出现 `curl_cffi`、`IPC`、`sessionBridge` 等关键词不算验收证据;必须有运行时 `xhr-fetch-bridge-audit.json` 和机器生成的 `xhr-fetch-semantics-audit.json`。
## 推荐架构
```text
目标 JS XMLHttpRequest/fetch
-> src/env/network/xhr.js / fetch.js 浏览器语义层
-> session bridge adapter
-> final.js/final.py 持有的同一 TLS Session
-> 目标网络请求
-> response envelope
-> JS XHR readyState / fetch Response / resource timing / Cookie 同步
```
Python `curl_cffi` 场景:
```text
final.py
create curl_cffi.requests.Session()
spawn Node runtime child process
read NDJSON bridge request from child stdout / pipe
session.request(...)
write NDJSON bridge response back to child stdin / pipe
finally session.close()
```
Node 侧不得自行发目标 URL 请求;只发 bridge envelope。Python 侧不得创建第二个 Session 给 XHR/fetch,必须复用最终请求同一个 session。
## Bridge 请求 envelope
至少记录:
```json
{
"id": "bridge-req-1",
"api": "XMLHttpRequest.send",
"method": "POST",
"url": "https://example.com/api",
"headers": {},
"bodyEncoding": "utf8/base64/bytes/none",
"body": "",
"credentials": "include/same-origin/omit",
"referrer": "",
"mode": "cors/same-origin/no-cors",
"timeoutMs": 30000,
"baselineId": "fp-001"
}
```
Bridge response 至少记录:
```json
{
"id": "bridge-req-1",
"ok": true,
"status": 200,
"statusText": "OK",
"headers": {},
"bodyEncoding": "utf8/base64/bytes",
"body": "",
"timing": {},
"setCookieApplied": true
}
```
## 浏览器语义同步
- `XMLHttpRequest.open()`、`setRequestHeader()`、`send()`、`abort()`、`timeout`、`responseType`、`readyState`、`status`、`responseText`、`responseURL`、`getResponseHeader()`、`getAllResponseHeaders()` 按目标浏览器采样实现。
- `fetch()` 返回 `Promise<Response>`,`Headers`、`Request`、`Response.clone()`、`text()`、`json()`、`arrayBuffer()`、body used 状态应可审计。
- `Set-Cookie` 由 session client 写回 Cookie jar,并同步到 JS cookie store;`xhr.getResponseHeader("set-cookie")` 和 `fetch Response.headers.get("set-cookie")` 默认应为 `null`,除非目标浏览器 baseline 证明可见。
- `document.cookie` 写入必须进入同一 Cookie jar 或在 bridge 请求前合并;服务端 `Set-Cookie` 必须更新同一 jar 和 JS cookie store。
- resource timing 如果被目标检测,必须在 `PerformanceResourceTiming` 中记录与真实浏览器 baseline 一致的最小字段和可见时机。
- CORS、redirect、referrer、credentials、cache、keepalive、sendBeacon 是否完整实现取决于目标路径;未覆盖时在矩阵中标 `needs-browser-baseline` 或 `native-capability-gap`,不得静默 mock。
## 必须产物
真实请求场景至少生成:
```text
case/notes/xhr-fetch-session-bridge.md
case/tmp/xhr-fetch-bridge-audit.json
case/fixtures/browser-network-transcript.ndjson
case/tmp/node-network-transcript.ndjson
case/tmp/xhr-fetch-semantics-audit.json
```
`xhr-fetch-session-bridge.md` 记录:
- 网络模式:`offline-fixture` / `live-session-bridge` / 不涉及。
- 最终 TLS Session 客户端:CycleTLS / impers / curl-cffi-node / curl_cffi / cffi_curl / cyCronet。
- Session 持有者:`final.js` / `final.py`。
- JS bridge 文件:例如 `result/src/env/network/xhr.js`、`result/src/env/network/fetch.js`。
- 请求层文件:例如 `result/src/request/client.js` 或 `result/src/request/session_client.py`。
- Cookie / Set-Cookie 同步策略。
- readyState / Promise / event 顺序验证。
- resource timing 是否涉及。
- 离线 fixture 是否仅用于诊断。
- Session id、运行时 bridge round trip 数、`sameSessionVerified`、`runtimeSourceHash`。
- no-send 请求语义审计结果:请求来源、Header 顺序、body SHA256、status/responseURL、事件顺序、reload realm 清理。
`xhr-fetch-bridge-audit.json` 必须由运行时 bridge 在至少一次受控 round trip 后写出:
```json
{
"schemaVersion": "xhr-fetch-session-bridge-audit/v2",
"generatedBy": "runtime-session-bridge/v2",
"mode": "live-session-bridge",
"tlsClient": "curl_cffi",
"sessionOwner": "final.py",
"sessionId": "session-1",
"transportRoundTrips": 1,
"sameSessionVerified": true,
"runtimeSourceHash": "...",
"cookieSyncVerified": true
}
```
不得在没有实际 bridge round trip 时生成该文件。Node no-send transcript 和最终请求 session 必须记录同一 session identity 或可验证的 session lineage。
## 检查命令
```bash
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --require-live --json
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --tls-client curl_cffi --require-live --markdown
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --out case/tmp/xhr-fetch-semantics-audit.json --markdown
```
检查失败时,下一步只能补 live session bridge、补 Cookie 同步、修正 XHR/fetch 浏览器语义、改为明确不发真实请求,或记录用户豁免。不得继续真实请求验证或写“TLS 指纹已解决”。

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
function parseArgs(argv) {
const args = { trace: '', summary: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--trace') args.trace = argv[++i] || '';
else if (a === '--summary') args.summary = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/analyze_trace.js --trace case/tmp/env-trace.jsonl --summary case/tmp/missing-env.json --markdown
node scripts/analyze_trace.js --trace case/tmp/env-trace.jsonl --json`;
}
function readJsonMaybe(file, fallback) {
if (!file) return fallback;
try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '')); } catch { return fallback; }
}
function readTrace(file) {
if (!file) return [];
const text = fs.readFileSync(file, 'utf8');
return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean).map((line, idx) => {
try { return JSON.parse(line); }
catch (err) { return { type: 'parse-error', path: `line:${idx + 1}`, message: err.message }; }
});
}
function moduleOf(pathText) {
const p = String(pathText || '');
if (/^(window|self|globalThis|top|parent)(\.|$)/.test(p)) return 'base-env';
if (/^location(\.|$)|^URL(SearchParams)?(\.|$)/.test(p)) return 'location-url-env';
if (/^navigator(\.|$)/.test(p)) return 'navigator-env';
if (/^screen(\.|$)|devicePixelRatio/.test(p)) return 'screen-env';
if (/^document\.cookie/.test(p)) return 'document-cookie-env';
if (/^document\.all/.test(p)) return 'document-all-addon';
if (/^document(\.|$)|^HTMLElement|^HTML/.test(p)) return 'document-dom-env';
if (/^(localStorage|sessionStorage)(\.|$)/.test(p)) return 'storage-env';
if (/^crypto(\.|$)|getRandomValues|randomUUID/.test(p)) return 'crypto-env';
if (/^performance(\.|$)|^Date(\.|$)|Date\.now|Math\.random/.test(p)) return 'time-random-env';
if (/fetch|XMLHttpRequest|Headers|Request|Response/.test(p)) return 'network-stub-env';
if (/Canvas|canvas|WebGL|OffscreenCanvas/.test(p)) return 'canvas-webgl-env';
if (/Worker|postMessage|MessageChannel|onmessage/.test(p)) return 'worker-message-env';
if (/WebAssembly|\.wasm|wasm/i.test(p)) return 'wasm-boundary-env';
if (/toString|getOwnPropertyDescriptor|getPrototypeOf|ownKeys|toPrimitive/.test(p)) return 'native-protection';
return 'target-specific-env';
}
function priorityOf(moduleName) {
if (['base-env', 'location-url-env', 'storage-env', 'document-cookie-env'].includes(moduleName)) return 1;
if (['navigator-env', 'time-random-env', 'crypto-env', 'network-stub-env'].includes(moduleName)) return 2;
if (['native-protection', 'document-all-addon', 'canvas-webgl-env'].includes(moduleName)) return 3;
if (['worker-message-env', 'wasm-boundary-env'].includes(moduleName)) return 4;
return 5;
}
function analyze(events, summary) {
const modules = new Map();
const riskSignals = new Set(summary.proxyRiskSignals || []);
const paths = new Set();
for (const e of events) {
const pathText = e.path || e.prop || '';
if (pathText) paths.add(pathText);
const moduleName = moduleOf(pathText || e.type);
if (!modules.has(moduleName)) modules.set(moduleName, { module: moduleName, priority: priorityOf(moduleName), count: 0, examples: [], eventTypes: new Set() });
const item = modules.get(moduleName);
item.count += 1;
item.eventTypes.add(e.type || 'unknown');
if (item.examples.length < 8 && pathText) item.examples.push(pathText);
if (['ownKeys', 'getOwnPropertyDescriptor', 'getPrototypeOf', 'toPrimitive'].includes(e.type)) riskSignals.add(`${e.type}:${pathText}`);
if (String(pathText).endsWith('.toString')) riskSignals.add(`toString:${pathText}`);
}
for (const name of summary.missingGlobals || []) {
const moduleName = moduleOf(name);
if (!modules.has(moduleName)) modules.set(moduleName, { module: moduleName, priority: priorityOf(moduleName), count: 0, examples: [], eventTypes: new Set() });
modules.get(moduleName).examples.push(name);
}
const moduleList = Array.from(modules.values()).map(m => ({ ...m, eventTypes: Array.from(m.eventTypes), examples: Array.from(new Set(m.examples)) }))
.sort((a, b) => a.priority - b.priority || b.count - a.count || a.module.localeCompare(b.module));
return {
eventCount: events.length,
uniquePathCount: paths.size,
modulePriorities: moduleList,
runtimeErrors: summary.runtimeErrors || [],
missingGlobals: summary.missingGlobals || [],
missingMethods: summary.missingMethods || [],
missingProperties: summary.missingProperties || [],
specialObjects: summary.specialObjects || [],
proxyRiskSignals: Array.from(riskSignals).slice(0, 100),
recommendation: moduleList.slice(0, 5).map(m => `优先补齐 ${m.module}`),
};
}
function renderMarkdown(result) {
const lines = ['# trace 分析与补齐优先级', '', `- trace 事件数:${result.eventCount}`, `- 唯一路径数:${result.uniquePathCount}`, `- 运行错误数:${result.runtimeErrors.length}`];
lines.push('', '## 模块优先级');
if (!result.modulePriorities.length) lines.push('- 未发现可归类的环境访问');
for (const m of result.modulePriorities) {
lines.push(`- P${m.priority} ${m.module}:${m.count} 次;示例:${m.examples.slice(0, 5).join(', ') || '无'}`);
}
if (result.proxyRiskSignals.length) {
lines.push('', '## Proxy / native-like 风险');
for (const s of result.proxyRiskSignals.slice(0, 20)) lines.push(`- ${s}`);
}
if (result.runtimeErrors.length) {
lines.push('', '## 运行错误');
for (const e of result.runtimeErrors) lines.push(`- ${e.type || 'error'}:${e.message || JSON.stringify(e)}`);
}
lines.push('', '## 建议下一步');
for (const r of result.recommendation) lines.push(`- ${r}`);
if (!result.recommendation.length) lines.push('- 先确认入口函数是否执行、fixture 是否完整。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
if (!args.trace && !args.summary) throw new Error('必须提供 --trace 或 --summary');
const events = readTrace(args.trace);
const summary = readJsonMaybe(args.summary, {});
const result = analyze(events, summary);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,384 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
traces: [],
maxLines: 200000,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--trace' || arg === '--input' || arg === '-i') args.traces.push(argv[++i] || '');
else if (arg === '--max-lines') args.maxLines = Number(argv[++i] || args.maxLines);
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/analyze_trace_complexity.js --case-dir case --markdown
node scripts/analyze_trace_complexity.js --trace case/ruyi-trace/logs/trace.ndjson --json
node scripts/analyze_trace_complexity.js --trace case/tmp/env-trace.jsonl --markdown
说明:基于 RuyiTrace / Node trace 日志评估补环境复杂度、风险点和优先级。复杂度评估只用于理解项目,不自动决定是否使用 isolated-vm、vm 或 jsEnv。`;
}
function exists(p) {
try {
fs.accessSync(p);
return true;
} catch {
return false;
}
}
function stat(p) {
try {
return fs.statSync(p);
} catch {
return null;
}
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try {
names = fs.readdirSync(p);
} catch {
names = [];
}
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) {
out.push(p);
}
return out;
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function uniq(arr) {
return [...new Set(arr)].filter(Boolean).sort();
}
const CATEGORY_PATTERNS = [
['navigator', /\bnavigator\b|Navigator\.prototype|userAgent|webdriver|plugins|mimeTypes|hardwareConcurrency|deviceMemory/ig],
['document', /\bdocument\b|Document\.prototype|document\.cookie|querySelector|createElement|getElementById|documentElement|document\.all/ig],
['iframe-realm', /iframe|contentWindow|contentDocument|defaultView|srcdoc|document\.write|document\.close/ig],
['location', /\blocation\b|Location\.prototype|href|origin|hostname|pathname|search|hash/ig],
['screen', /\bscreen\b|Screen\.prototype|availWidth|availHeight|colorDepth|pixelDepth|orientation/ig],
['storage', /localStorage|sessionStorage|Storage\.prototype|getItem|setItem|removeItem/ig],
['cookie', /document\.cookie|Set-Cookie|\bcookie\b/ig],
['crypto', /\bcrypto\b|getRandomValues|randomUUID|SubtleCrypto|crypto\.subtle/ig],
['performance-time', /performance|performance\.now|timeOrigin|Date\.now|new Date|setTimeout|requestAnimationFrame/ig],
['canvas', /HTMLCanvasElement|CanvasRenderingContext2D|toDataURL|toBlob|getImageData|measureText|canvas/ig],
['webgl', /WebGLRenderingContext|WebGL2RenderingContext|getParameter|readPixels|getSupportedExtensions|getShaderPrecisionFormat|webgl/ig],
['webgpu', /navigator\.gpu|GPUAdapter|requestAdapter|webgpu/ig],
['audio', /AudioContext|OfflineAudioContext|AnalyserNode|startRendering|getChannelData/ig],
['dom-geometry', /getBoundingClientRect|DOMRect|offsetWidth|offsetHeight|clientWidth|clientHeight|getClientRects/ig],
['dom-cssom', /DOMParser|innerHTML|HTMLUnknownElement|HTMLTextAreaElement|HTMLOptionElement|HTMLSelectElement|HTMLCollection|ShadowRoot|CSSStyleSheet|CSSStyleRule|CSSRuleList|getComputedStyle/ig],
['worker', /\bWorker\b|importScripts|postMessage|MessageChannel|MessagePort|BroadcastChannel/ig],
['wasm', /WebAssembly|instantiateStreaming|compileStreaming|\.wasm/ig],
['network', /\bfetch\b|XMLHttpRequest|Request\.prototype|Response\.prototype|Headers\.prototype|sendBeacon/ig],
['xhr-fetch-session-bridge', /live-session-bridge|offline-fixture|XMLHttpRequest|fetch|sendBeacon|curl_cffi|curl-cffi|CycleTLS|impers/ig],
['object-shape', /Object\.keys|Object\.getOwnPropertyNames|Object\.getOwnPropertyDescriptor|Object\.getOwnPropertySymbols|Reflect\.ownKeys|hasOwnProperty|propertyIsEnumerable|brand check|prototype walk/ig],
['private-state-leakage', /__readyState|__headers|__children|__parentNode|__responseHeaders|Object\.defineProperty\s*\([^)]*['"]_{1,2}[A-Za-z]|\bthis\s*\.\s*_{1,2}[A-Za-z]/ig],
['indexedDB', /indexedDB|IDBFactory|IDBRequest|IDBOpenDBRequest|IDBKeyRange|IDBDatabase/ig],
['history', /\bhistory\b|pushState|replaceState|popstate|referrer/ig],
['event-clone-error', /EventTarget|structuredClone|DataCloneError|ownKeys|ownNames|Reflect\.ownKeys|getOwnPropertyNames/ig],
['clock-timer', /Date\.now|performance\.now|setTimeout|clearTimeout|queueMicrotask|requestAnimationFrame/ig],
['writer-branch', /reload writer|form writer|final writer|HTMLFormElement\.submit|Location\.reload|cf-chl-gen|cf-chl-out|generatedForm/ig],
['performance-timeline', /PerformanceObserver|PerformanceResourceTiming|PerformanceEntry|PerformanceObserverEntryList|getEntries|PerformancePaintTiming|performance\.mark/ig],
];
const REALISM_PATTERNS = [
['Function.prototype.toString', /Function\.prototype\.toString|\.toString\s*\(\)/ig],
['Object.prototype.toString', /Object\.prototype\.toString|\[object\s+[A-Za-z]+]/ig],
['descriptor', /Object\.getOwnPropertyDescriptor|Object\.getOwnPropertyDescriptors|defineProperty|getOwnPropertyDescriptor/ig],
['prototype', /Object\.getPrototypeOf|setPrototypeOf|__proto__|prototype/ig],
['ownKeys', /Reflect\.ownKeys|Object\.keys|getOwnPropertyNames|getOwnPropertySymbols/ig],
['instanceof', /\binstanceof\b/ig],
['constructor.name', /constructor\.name|\.constructor\b/ig],
['Symbol.toStringTag', /Symbol\.toStringTag|@@toStringTag/ig],
];
const ASYNC_PATTERNS = [
['Promise', /\bPromise\b|queueMicrotask|then\(/ig],
['timer', /setTimeout|setInterval|requestAnimationFrame|cancelAnimationFrame/ig],
['message', /postMessage|MessageChannel|BroadcastChannel|message event/ig],
['observer', /MutationObserver|IntersectionObserver|ResizeObserver|PerformanceObserver/ig],
['xhr-fetch', /\bfetch\b|XMLHttpRequest|readystatechange|loadend/ig],
['indexedDB-async', /IDBRequest|onsuccess|onerror|transaction|openCursor/ig],
];
function countMatches(text, pattern) {
pattern.lastIndex = 0;
let count = 0;
while (pattern.exec(text)) count += 1;
return count;
}
function emptySignalMap(items) {
return Object.fromEntries(items.map(([name]) => [name, 0]));
}
function discoverTraceFiles(args) {
const files = [];
for (const item of args.traces) {
const resolved = path.resolve(item);
if (exists(resolved)) files.push(resolved);
}
if (args.caseDir) {
const caseDir = path.resolve(args.caseDir);
const candidates = [
path.join(caseDir, 'ruyi-trace', 'logs'),
path.join(caseDir, 'tmp'),
path.join(caseDir, 'notes'),
];
for (const candidate of candidates) {
for (const file of walk(candidate)) {
if (/\.(ndjson|jsonl|json)$/i.test(file) && /trace|missing-env|ruyi|env/i.test(path.basename(file))) {
files.push(file);
}
}
}
}
return uniq(files);
}
function inspectLine(text, summary) {
for (const [name, pattern] of CATEGORY_PATTERNS) {
summary.categories[name] += countMatches(text, pattern);
}
for (const [name, pattern] of REALISM_PATTERNS) {
summary.realism[name] += countMatches(text, pattern);
}
for (const [name, pattern] of ASYNC_PATTERNS) {
summary.async[name] += countMatches(text, pattern);
}
const stackMatches = text.match(/(?:stack\.file|filename|file|url)["']?\s*[:=]\s*["']?([^"',\s)]+)/ig);
if (stackMatches) {
for (const raw of stackMatches.slice(0, 20)) summary.stackSignals.add(raw.slice(0, 160));
}
}
function analyzeFile(file, args) {
const result = {
file,
lines: 0,
bytes: 0,
truncatedByMaxLines: false,
parseErrors: 0,
};
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
result.bytes = Buffer.byteLength(text);
const lines = text.split(/\r?\n/);
for (const line of lines) {
if (!line.trim()) continue;
if (result.lines >= args.maxLines) {
result.truncatedByMaxLines = true;
break;
}
result.lines += 1;
}
return { result, lines: lines.slice(0, args.maxLines) };
}
function scoreSummary(summary) {
const hitCategories = Object.entries(summary.categories).filter(([, count]) => count > 0).map(([name]) => name);
const hitRealism = Object.entries(summary.realism).filter(([, count]) => count > 0).map(([name]) => name);
const hitAsync = Object.entries(summary.async).filter(([, count]) => count > 0).map(([name]) => name);
const fingerprintHits = ['canvas', 'webgl', 'webgpu', 'audio', 'dom-geometry', 'dom-cssom'].filter(name => summary.categories[name] > 0);
const stateHits = ['storage', 'cookie', 'indexedDB', 'history', 'performance-time', 'performance-timeline', 'crypto', 'clock-timer'].filter(name => summary.categories[name] > 0);
const envMatrixHitMap = [
['iframe-realm', 'iframe-realm'],
['worker', 'worker-task'],
['performance-timeline', 'performance-timeline'],
['dom-cssom', 'dom-cssom'],
['event-clone-error', 'event-clone-error'],
['xhr-fetch-session-bridge', 'xhr-fetch-session-bridge'],
['object-shape', 'object-shape'],
['private-state-leakage', 'private-state-leakage'],
['clock-timer', 'clock-timer'],
['writer-branch', 'writer-branch'],
];
const envMatrixHits = envMatrixHitMap
.filter(([source]) => summary.categories[source] > 0)
.map(([, id]) => id);
let score = 0;
score += hitCategories.length * 2;
score += hitRealism.length * 3;
score += fingerprintHits.length * 4;
score += hitAsync.length * 2;
score += stateHits.length * 2;
score += envMatrixHits.length * 3;
if (summary.stackSignals.size >= 10) score += 6;
else if (summary.stackSignals.size >= 3) score += 3;
let level = '未知';
if (summary.files.length === 0 || summary.totalLines === 0) level = '未知';
else if (score >= 45 || fingerprintHits.length >= 3 || hitRealism.length >= 5) level = '高';
else if (score >= 18 || fingerprintHits.length >= 1 || hitRealism.length >= 2) level = '中';
else level = '低';
return {
score,
level,
hitCategories,
hitRealism,
hitAsync,
fingerprintHits,
stateHits,
envMatrixHits,
stackSignalCount: summary.stackSignals.size,
};
}
function analyze(args) {
const files = discoverTraceFiles(args);
const root = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const summary = {
files: files.map(file => rel(root, file)),
totalLines: 0,
totalBytes: 0,
categories: emptySignalMap(CATEGORY_PATTERNS),
realism: emptySignalMap(REALISM_PATTERNS),
async: emptySignalMap(ASYNC_PATTERNS),
fileSummaries: [],
stackSignals: new Set(),
parseErrors: 0,
};
for (const file of files) {
const { result, lines } = analyzeFile(file, args);
summary.fileSummaries.push({
...result,
file: rel(root, file),
});
summary.totalLines += result.lines;
summary.totalBytes += result.bytes;
for (const line of lines) {
if (!line.trim()) continue;
inspectLine(line, summary);
}
}
const score = scoreSummary(summary);
return {
passed: true,
complexity: score.level,
score: score.score,
source: {
files: summary.files,
totalLines: summary.totalLines,
totalBytes: summary.totalBytes,
fileSummaries: summary.fileSummaries,
},
signals: {
categories: summary.categories,
realism: summary.realism,
async: summary.async,
hitCategories: score.hitCategories,
hitRealism: score.hitRealism,
hitAsync: score.hitAsync,
fingerprintHits: score.fingerprintHits,
stateHits: score.stateHits,
envMatrixHits: score.envMatrixHits,
stackSignalCount: score.stackSignalCount,
},
conclusion: {
frameworkBinding: false,
note: 'Trace 复杂度评估只用于补环境范围、风险点和优先级,不自动决定是否使用 isolated-vm、vm 或 jsEnv。',
},
};
}
function renderMarkdown(result) {
const lines = [];
lines.push('# Trace 复杂度评估报告');
lines.push('');
lines.push(`- 复杂度等级:${result.complexity}`);
lines.push(`- 分数:${result.score}`);
lines.push(`- Trace 文件数:${result.source.files.length}`);
lines.push(`- Trace 总行数:${result.source.totalLines}`);
lines.push('- 与补环境框架关系:复杂度评估不决定框架选择,是否使用 isolated-vm / vm / jsEnv 仍以用户确认为准。');
lines.push('');
if (!result.source.files.length) {
lines.push('未发现可用 Trace 日志,无法基于日志评估复杂度。');
lines.push('');
return lines.join('\n');
}
lines.push('## 命中的 WebAPI 类别');
lines.push('');
lines.push(result.signals.hitCategories.length ? result.signals.hitCategories.map(x => `- ${x}:${result.signals.categories[x]}`).join('\n') : '- 无明显命中');
lines.push('');
lines.push('## 真实性检测信号');
lines.push('');
lines.push(result.signals.hitRealism.length ? result.signals.hitRealism.map(x => `- ${x}:${result.signals.realism[x]}`).join('\n') : '- 无明显命中');
lines.push('');
lines.push('## 指纹、异步和状态风险');
lines.push('');
lines.push(`- 指纹 API:${result.signals.fingerprintHits.join('、') || '无明显命中'}`);
lines.push(`- 异步链路:${result.signals.hitAsync.join('、') || '无明显命中'}`);
lines.push(`- 状态依赖:${result.signals.stateHits.join('、') || '无明显命中'}`);
lines.push(`- WebAPI 环境检测矩阵触发类:${result.signals.envMatrixHits.join('、') || '无明显命中'}`);
lines.push(`- 调用栈分散信号数:${result.signals.stackSignalCount}`);
lines.push('');
lines.push('## 文件摘要');
lines.push('');
lines.push('| 文件 | 行数 | 字节数 | 达到最大行限制 |');
lines.push('|---|---:|---:|---|');
for (const item of result.source.fileSummaries) {
lines.push(`| ${item.file} | ${item.lines} | ${item.bytes} | ${item.truncatedByMaxLines ? '是' : '否'} |`);
}
lines.push('');
lines.push('## 使用说明');
lines.push('');
lines.push('- 该报告用于补环境范围、优先级和阶段报告。');
lines.push('- 不要把复杂度等级写成自动选择补环境框架的依据。');
lines.push('- 补环境框架选择仍然必须由用户确认;用户未选择时默认不使用框架。');
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const result = analyze(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
else console.log(renderMarkdown(result));
}
try {
main();
} catch (error) {
console.error(error && error.message ? error.message : String(error));
process.exitCode = 2;
}

View File

@@ -0,0 +1,398 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const STATEFUL_API_PATTERN = /XMLHttpRequest|fetch|sendBeacon|readyState|readystatechange|loadend|abort|timeout|location\.reload|beforeunload|pagehide|visibilitychange|unload|Worker|SharedWorker|MessagePort|MessageChannel|postMessage|terminate|\.close|appendChild|removeChild|insertBefore|replaceChild|replaceChildren|innerHTML|textContent|querySelector|MutationObserver/i;
const MAX_TIMELINE_SEQUENCE = 10000;
function parseArgs(argv) {
const args = {
caseDir: '',
traces: [],
out: '',
baselineId: '',
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--trace' || arg === '--input' || arg === '-i') args.traces.push(argv[++i] || '');
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--baseline-id') args.baselineId = argv[++i] || '';
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/build_trace_runtime_contract.js --case-dir case --markdown
node scripts/build_trace_runtime_contract.js --trace case/ruyi-trace/logs/trace.ndjson --out case/notes/trace-runtime-contract.json --baseline-id fp-001 --json
说明:从原始 Trace 生成逐 API、realm、receiver、访问类型和行为观测组成的 runtime 契约。该契约是后续 Node audit 的机器可验证输入,不是人工填写的覆盖清单。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function stat(file) {
try { return fs.statSync(file); } catch { return null; }
}
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) walk(path.join(root, name), out);
return out;
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
}
function digestValue(value) {
if (typeof value === 'undefined') return '';
try { return sha256(JSON.stringify(stable(value))); } catch { return sha256(String(value)); }
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function discoverTraceFiles(args, caseDir) {
const files = [];
for (const item of args.traces) {
const file = path.resolve(item);
if (exists(file)) files.push(file);
}
if (!args.traces.length) {
const roots = [
path.join(caseDir, 'ruyi-trace', 'logs'),
path.join(caseDir, 'tmp'),
];
for (const root of roots) {
for (const file of walk(root)) {
const name = path.basename(file);
if (/\.(ndjson|jsonl|json)$/i.test(file) && /trace|ruyi|env/i.test(name) && !/audit|contract|summary/i.test(name)) {
files.push(file);
}
}
}
}
return [...new Set(files.map(file => path.resolve(file)))].sort();
}
function parseTraceFile(file) {
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
if (/\.json$/i.test(file)) {
const raw = JSON.parse(text);
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw.events)) return raw.events;
if (Array.isArray(raw.items)) return raw.items;
if (Array.isArray(raw.logs)) return raw.logs;
return [raw];
}
const events = [];
for (const [index, line] of text.split(/\r?\n/).entries()) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
event.__line = index + 1;
events.push(event);
} catch (err) {
events.push({ type: 'parse-error', api: `line:${index + 1}`, error: { name: 'SyntaxError', message: err.message }, __line: index + 1 });
}
}
return events;
}
function firstValue(object, keys, fallback) {
if (arguments.length < 3) fallback = '';
for (const key of keys) {
if (object
&& Object.prototype.hasOwnProperty.call(object, key)
&& typeof object[key] !== 'undefined'
&& object[key] !== null
&& object[key] !== '') return object[key];
}
return fallback;
}
function normalizeText(value) {
if (typeof value === 'string') return value;
if (value === null || typeof value === 'undefined') return '';
if (typeof value === 'object') {
return firstValue(value, ['class', 'interface', 'name', 'type', 'id', 'path', 'value'], JSON.stringify(stable(value)));
}
return String(value);
}
function normalizeTraceMember(value) {
const text = normalizeText(value);
return text.replace(/^(get|set|call|construct)\s+/i, '').trim();
}
function normalizeStack(event) {
const stack = Array.isArray(event.stack) ? event.stack : [];
const frame = stack.find(item => item && (item.file || item.filename || item.url || item.line || item.lineNumber));
if (!frame) return '';
const file = firstValue(frame, ['file', 'filename', 'url'], '');
const line = firstValue(frame, ['line', 'lineNumber'], '');
const col = firstValue(frame, ['col', 'column', 'columnNumber'], '');
return [file, line, col].filter(value => value !== '').join(':');
}
function priorityOf(api, event) {
const explicit = normalizeText(firstValue(event, ['priority', 'level'], '')).toUpperCase();
if (/^P[0-2]$/.test(explicit)) return explicit;
if (/XMLHttpRequest|fetch|sendBeacon|window|document|location|navigator|cookie|Storage|Function\.prototype\.toString|reload|writer/i.test(api)) return 'P0';
if (/Object\.|Reflect\.|prototype|descriptor|EventTarget|Performance|Worker|Message|Canvas|WebGL|Audio|screen|Permissions|Plugin|MimeType|DOM|CSS/i.test(api)) return 'P1';
return 'P2';
}
function normalizeObservation(event) {
const interfaceName = normalizeText(firstValue(event, ['interface', 'interfaceName'], ''));
const member = normalizeTraceMember(firstValue(event, ['member', 'property', 'method'], ''));
const explicitApi = normalizeText(firstValue(event, ['api', 'path', 'name', 'prop'], ''));
const api = explicitApi || [interfaceName, member].filter(Boolean).join('.');
if (!api) return null;
const accessType = normalizeText(firstValue(event, ['accessType', 'operation', 'op', 'type'], 'unknown'));
const realm = normalizeText(firstValue(event, ['realmId', 'realm', 'contextId', 'frameId', 'workerId'], 'main'));
const receiver = normalizeText(firstValue(event, ['receiver', 'receiverType', 'thisType', 'target', 'object', 'thisObj'], ''));
const phase = normalizeText(firstValue(event, ['phase', 'writer', 'navigationPhase', 'stage'], ''));
const descriptor = firstValue(event, ['descriptor', 'propertyDescriptor'], undefined);
const prototypeChain = firstValue(event, ['prototypeChain', 'prototypes', 'protoChain'], undefined);
const ownKeys = firstValue(event, ['ownKeys', 'reflectOwnKeys'], undefined);
const ownPropertyNames = firstValue(event, ['ownPropertyNames', 'propertyNames'], undefined);
const ownSymbols = firstValue(event, ['ownSymbols', 'ownPropertySymbols'], undefined);
const result = firstValue(event, ['returnValue', 'result', 'value'], undefined);
const args = firstValue(event, ['arguments', 'args', 'parameters'], undefined);
const error = firstValue(event, ['error', 'exception', 'throw'], undefined);
const brand = normalizeText(firstValue(event, ['brand', 'toStringTag', 'objectToString'], ''));
const constructorName = normalizeText(firstValue(event, ['constructorName', 'constructor'], ''));
const owner = normalizeText(firstValue(event, ['owner', 'definedOn', 'prototypeOwner'], ''));
const sideEffects = firstValue(event, ['sideEffects', 'effects', 'mutations'], undefined);
return {
api,
accessType,
realm,
receiver,
phase,
owner,
brand,
constructorName,
descriptor,
prototypeChain,
ownKeys,
ownPropertyNames,
ownSymbols,
argsDigest: digestValue(args),
resultDigest: digestValue(result),
errorDigest: digestValue(error),
sideEffectsDigest: digestValue(sideEffects),
stack: normalizeStack(event),
sequence: Number(firstValue(event, ['sequence', 'seq', 'index', '__line'], 0)) || 0,
};
}
function contractKey(item) {
return [item.api, item.accessType, item.realm, item.receiver, item.phase].join('\u001f');
}
function addDistinct(target, value, limit = 20) {
if (value === '' || typeof value === 'undefined') return;
const digest = digestValue(value);
if (target.some(item => item.digest === digest)) return;
if (target.length < limit) target.push({ digest, value });
}
function orderStatefulTimeline(events) {
const sourceFiles = [...new Set(events.map(event => event.sourceFile))];
if (sourceFiles.length <= 1) {
return [...events].sort((a, b) => a.fileOrder - b.fileOrder);
}
const sequences = events.map(event => event.traceSequence);
const unique = new Set(sequences);
if (sequences.some(sequence => !Number.isFinite(sequence) || sequence <= 0)
|| unique.size !== sequences.length) {
throw new Error('多文件 Trace 无法建立唯一全局时间线;请合并为单一完整 Trace,或确保所有事件包含跨文件唯一递增 sequence。');
}
return [...events].sort((a, b) => a.traceSequence - b.traceSequence);
}
function buildStatefulTimeline(events) {
const ordered = orderStatefulTimeline(events);
const sequence = [];
for (const event of ordered) {
if (sequence[sequence.length - 1] !== event.contractId) sequence.push(event.contractId);
}
return {
schemaVersion: 'trace-runtime-timeline/v1',
required: sequence.length > 0,
eventCount: ordered.length,
collapsedCount: sequence.length,
truncated: sequence.length > MAX_TIMELINE_SEQUENCE,
sourceFiles: [...new Set(ordered.map(event => event.sourceFile))],
sequence: sequence.slice(0, MAX_TIMELINE_SEQUENCE),
sequenceSha256: sha256(JSON.stringify(sequence)),
};
}
function buildContract(args) {
const caseDir = path.resolve(args.caseDir || '.');
const files = discoverTraceFiles(args, caseDir);
if (!files.length) throw new Error('未找到原始 Trace 文件;请使用 --trace 指定,或把日志放入 case/ruyi-trace/logs/。');
if (!args.traces.length && files.length > 1) {
throw new Error(`自动发现 ${files.length} 份原始 Trace;禁止跨历史采集静默合并。请使用 --trace 显式选择当前 baseline 的完整 Trace,分片文件可重复传入 --trace。`);
}
const sourceFiles = [];
const groups = new Map();
const statefulTimelineEvents = [];
let eventCount = 0;
for (const file of files) {
const text = fs.readFileSync(file);
sourceFiles.push({ file: rel(caseDir, file), sha256: sha256(text), bytes: text.length });
const events = parseTraceFile(file);
for (const [fileEventOrder, event] of events.entries()) {
eventCount += 1;
const item = normalizeObservation(event);
if (!item) continue;
const key = contractKey(item);
if (!groups.has(key)) {
groups.set(key, {
id: sha256(key).slice(0, 24),
api: item.api,
accessType: item.accessType,
realm: item.realm,
receiver: item.receiver,
phase: item.phase,
priority: priorityOf(item.api, event),
count: 0,
sequences: [],
stacks: [],
assertions: {
owner: [],
brand: [],
constructorName: [],
descriptor: [],
prototypeChain: [],
ownKeys: [],
ownPropertyNames: [],
ownSymbols: [],
argsDigest: [],
resultDigest: [],
errorDigest: [],
sideEffectsDigest: [],
},
});
}
const group = groups.get(key);
group.count += 1;
if (item.sequence && group.sequences.length < 50) group.sequences.push(item.sequence);
if (item.stack && !group.stacks.includes(item.stack) && group.stacks.length < 10) group.stacks.push(item.stack);
for (const field of Object.keys(group.assertions)) addDistinct(group.assertions[field], item[field]);
if (STATEFUL_API_PATTERN.test(item.api)) {
statefulTimelineEvents.push({
contractId: group.id,
sourceFile: rel(caseDir, file),
fileOrder: fileEventOrder,
traceSequence: item.sequence,
realm: item.realm,
phase: item.phase,
api: item.api,
});
}
}
}
const contracts = [...groups.values()].sort((a, b) =>
a.priority.localeCompare(b.priority) || a.api.localeCompare(b.api) || a.realm.localeCompare(b.realm)
);
const traceSourceHash = sha256(JSON.stringify(sourceFiles.map(item => [item.file, item.sha256])));
const timeline = buildStatefulTimeline(statefulTimelineEvents);
const contract = {
schemaVersion: 'trace-runtime-contract/v3',
generatedBy: 'build_trace_runtime_contract.js',
generatedAt: new Date().toISOString(),
baselineId: args.baselineId || '',
traceSourceHash,
sourceFiles,
eventCount,
contractCount: contracts.length,
contracts,
timeline,
};
contract.contractHash = sha256(JSON.stringify(stable({
schemaVersion: contract.schemaVersion,
baselineId: contract.baselineId,
traceSourceHash: contract.traceSourceHash,
contracts: contract.contracts,
timeline: contract.timeline,
})));
const out = path.resolve(args.out || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(contract, null, 2)}\n`, 'utf8');
return { caseDir, out, contract };
}
function renderMarkdown(result) {
const p0 = result.contract.contracts.filter(item => item.priority === 'P0').length;
const p1 = result.contract.contracts.filter(item => item.priority === 'P1').length;
return [
'# Trace-runtime 行为契约生成结果',
'',
`- 输出:${result.out}`,
`- baselineId:${result.contract.baselineId || '未指定'}`,
`- Trace 文件数:${result.contract.sourceFiles.length}`,
`- Trace 事件数:${result.contract.eventCount}`,
`- 行为契约数:${result.contract.contractCount}`,
`- P0:${p0}`,
`- P1:${p1}`,
`- 状态事件:${result.contract.timeline.eventCount}`,
`- 折叠时间线:${result.contract.timeline.collapsedCount}`,
`- traceSourceHash:${result.contract.traceSourceHash}`,
`- contractHash:${result.contract.contractHash}`,
'',
'下一步必须运行 Node audit 并使用 check_trace_runtime_conformance.js 深度比较;不得手工把契约状态改成 matched。',
].join('\n') + '\n';
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = buildContract(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { buildContract, normalizeObservation, digestValue, stable };

View File

@@ -0,0 +1,284 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
function parseArgs(argv) {
const args = {
url: '',
caseDir: 'case',
outDir: '',
profileDir: '',
ruyitraceHome: '',
ruyitraceExe: '',
duration: 60,
limit: 200000,
dryRun: false,
importAfter: false,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--url') args.url = argv[++i] || '';
else if (a === '--case-dir' || a === '--dir') args.caseDir = argv[++i] || '';
else if (a === '--out-dir') args.outDir = argv[++i] || '';
else if (a === '--profile-dir') args.profileDir = argv[++i] || '';
else if (a === '--ruyitrace-home') args.ruyitraceHome = argv[++i] || '';
else if (a === '--ruyitrace-exe') args.ruyitraceExe = argv[++i] || '';
else if (a === '--duration') args.duration = Number(argv[++i] || '60');
else if (a === '--limit') args.limit = Number(argv[++i] || '200000');
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--import-after') args.importAfter = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
if (!Number.isFinite(args.duration) || args.duration <= 0) args.duration = 60;
if (!Number.isFinite(args.limit) || args.limit <= 0) args.limit = 200000;
return args;
}
function usage() {
return `用法:
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --ruyitrace-home <RuyiTrace-dir> --markdown
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --duration 90 --import-after --markdown
node scripts/capture_ruyitrace_log.js --url <target-page-url> --case-dir case --dry-run --json
说明:确认 RuyiTrace 已安装后,优先使用随 RuyiTrace 提供的 trace Firefox 和 MOZ_DOM_TRACE 环境变量自动捕获 NDJSON。自动捕获失败、需要登录/验证码/权限交互、或目标路径未覆盖时,才要求用户手动协助。`;
}
function exists(p) {
try { return !!p && fs.existsSync(p); } catch { return false; }
}
function isDir(p) {
try { return !!p && fs.statSync(p).isDirectory(); } catch { return false; }
}
function ensureDir(p) {
fs.mkdirSync(p, { recursive: true });
}
function run(cmd, args, timeout = 8000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return { ok: ret.status === 0, stdout: ret.stdout || '', stderr: ret.stderr || '' };
}
function whereCommand(name) {
const cmd = process.platform === 'win32' ? 'where' : 'which';
const ret = run(cmd, [name]);
return ret.ok ? ret.stdout.split(/\r?\n/).map((s) => s.trim()).filter(Boolean) : [];
}
function normalizeTraceHome(args) {
if (args.ruyitraceHome) return path.resolve(args.ruyitraceHome);
if (args.ruyitraceExe) return path.dirname(path.resolve(args.ruyitraceExe));
if (process.env.RUYI_TRACE_HOME) return path.resolve(process.env.RUYI_TRACE_HOME);
if (process.env.RUYITRACE_HOME) return path.resolve(process.env.RUYITRACE_HOME);
const found = whereCommand(process.platform === 'win32' ? 'RuyiTrace.exe' : 'RuyiTrace');
return found.length ? path.dirname(found[0]) : '';
}
function detectRuyiTrace(args) {
const home = normalizeTraceHome(args);
const exeName = process.platform === 'win32' ? 'RuyiTrace.exe' : 'RuyiTrace';
const exe = args.ruyitraceExe ? path.resolve(args.ruyitraceExe) : (home ? path.join(home, exeName) : '');
const firefoxExe = home ? path.join(home, 'firefox', process.platform === 'win32' ? 'firefox.exe' : 'firefox') : '';
const marker = home ? path.join(home, 'firefox', 'RUYI_DOMTRACE.txt') : '';
const installed = exists(exe) && exists(firefoxExe) && exists(marker);
return {
installed,
home,
exe,
exeExists: exists(exe),
firefoxExe,
firefoxExists: exists(firefoxExe),
marker,
markerExists: exists(marker),
reason: installed ? '' : 'RuyiTrace 不完整:需要 RuyiTrace 可执行文件、firefox 可执行文件以及 firefox/RUYI_DOMTRACE.txt',
};
}
function timestamp() {
return new Date().toISOString().replace(/[-:TZ.]/g, '').slice(0, 14);
}
function buildPlan(args, trace) {
const caseDir = path.resolve(args.caseDir || 'case');
const outDir = path.resolve(args.outDir || path.join(caseDir, 'ruyi-trace', 'logs'));
const profileDir = path.resolve(args.profileDir || path.join(caseDir, 'tmp', 'ruyitrace-profile'));
const traceFile = path.join(outDir, `trace-${timestamp()}.ndjson`);
const firefoxArgs = ['-no-remote', '-new-instance', '-profile', profileDir];
if (args.url) firefoxArgs.push(args.url);
return {
caseDir,
outDir,
profileDir,
traceFile,
firefoxExe: trace.firefoxExe,
firefoxArgs,
env: {
MOZ_DOM_TRACE: '1',
MOZ_DOM_TRACE_FILE: traceFile,
MOZ_DOM_TRACE_LIMIT: String(args.limit),
MOZ_DISABLE_LAUNCHER_PROCESS: '1',
},
};
}
function listNdjsonFiles(dir, sinceMs) {
if (!isDir(dir)) return [];
return fs.readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith('.ndjson'))
.map((name) => path.join(dir, name))
.filter((file) => {
try { return fs.statSync(file).mtimeMs >= sinceMs - 1000; } catch { return false; }
})
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
}
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function waitForExit(child, timeoutMs) {
return new Promise((resolve) => {
let done = false;
const finish = (value) => {
if (done) return;
done = true;
resolve(value);
};
child.once('exit', (code, signal) => finish({ exited: true, code, signal }));
setTimeout(() => finish({ exited: false, code: null, signal: null }), timeoutMs).unref();
});
}
function importLog(caseDir, file, markdown) {
const script = path.join(__dirname, 'import_ruyitrace_log.js');
const args = [script, '--input', file, '--case-dir', caseDir, '--truncation-threshold', '3900', markdown ? '--markdown' : '--json'];
const ret = spawnSync(process.execPath, args, { encoding: 'utf8', windowsHide: true });
return { ok: ret.status === 0, status: ret.status, stdout: ret.stdout || '', stderr: ret.stderr || '' };
}
async function capture(args, plan) {
ensureDir(plan.outDir);
ensureDir(plan.profileDir);
const startedAt = Date.now();
const child = spawn(plan.firefoxExe, plan.firefoxArgs, {
env: { ...process.env, ...plan.env },
stdio: 'ignore',
windowsHide: false,
});
const result = {
launched: true,
pid: child.pid,
waitedSeconds: args.duration,
killAttempted: false,
exit: null,
logs: [],
importResult: null,
};
child.on('error', (err) => { result.launchError = err.message || String(err); });
await wait(args.duration * 1000);
const exitBeforeKill = await waitForExit(child, 200);
if (!exitBeforeKill.exited) {
result.killAttempted = true;
try { child.kill(); } catch (err) { result.killError = err.message || String(err); }
result.exit = await waitForExit(child, 3000);
} else {
result.exit = exitBeforeKill;
}
result.logs = listNdjsonFiles(plan.outDir, startedAt);
if (args.importAfter && result.logs.length) {
result.importResult = importLog(plan.caseDir, result.logs[0], args.markdown);
}
return result;
}
function renderMarkdown(obj) {
const { args, trace, plan, result } = obj;
const lines = ['# RuyiTrace 自动捕获日志', ''];
lines.push(`- RuyiTrace 检测结果:${trace.installed ? '通过' : '不通过'}`);
if (trace.home) lines.push(`- RuyiTrace 目录:${trace.home}`);
if (trace.exe) lines.push(`- RuyiTrace 可执行文件:${trace.exeExists ? '存在' : '不存在'} - ${trace.exe}`);
if (trace.firefoxExe) lines.push(`- trace Firefox:${trace.firefoxExists ? '存在' : '不存在'} - ${trace.firefoxExe}`);
if (trace.marker) lines.push(`- trace 标志文件:${trace.markerExists ? '存在' : '不存在'} - ${trace.marker}`);
if (trace.reason) lines.push(`- 原因:${trace.reason}`);
lines.push('', '## 自动捕获计划');
lines.push(`- 目标页面:${args.url || '未提供'}`);
lines.push(`- 输出目录:${plan.outDir}`);
lines.push(`- Profile 目录:${plan.profileDir}`);
lines.push(`- 计划 trace 文件:${plan.traceFile}`);
lines.push(`- 采集时长:${args.duration} 秒`);
lines.push(`- DOM trace 行数上限:${args.limit}`);
lines.push(`- 启动参数:${[plan.firefoxExe].concat(plan.firefoxArgs).join(' ')}`);
lines.push('- 环境变量:MOZ_DOM_TRACE=1,MOZ_DOM_TRACE_FILE=<case trace file>,MOZ_DOM_TRACE_LIMIT=<limit>,MOZ_DISABLE_LAUNCHER_PROCESS=1');
if (args.dryRun) {
lines.push('', '## Dry-run 结果');
lines.push('- 未启动浏览器,未创建日志文件。');
if (trace.installed) {
lines.push('- RuyiTrace 检测通过且用户授权后,应优先执行本脚本自动捕获 NDJSON,而不是默认等待用户手动 trace。');
} else {
lines.push('- RuyiTrace 检测未通过,不能进入自动捕获;应先让用户安装 / 提供 RuyiTrace 路径,或明确确认降级为仅 ruyiPage。');
}
return lines.join('\n') + '\n';
}
lines.push('', '## 捕获结果');
if (result.launchError) lines.push(`- 启动错误:${result.launchError}`);
lines.push(`- 是否已启动:${result.launched ? '是' : '否'}`);
if (result.pid) lines.push(`- 进程 PID:${result.pid}`);
lines.push(`- 是否尝试结束进程:${result.killAttempted ? '是' : '否'}`);
lines.push(`- 发现 NDJSON 数量:${result.logs.length}`);
for (const file of result.logs) lines.push(` - ${file}`);
if (!result.logs.length) {
lines.push('- 未发现 NDJSON:应检查 RuyiTrace trace Firefox 是否能写入日志、目标页面是否触发了环境访问、是否需要登录/验证码/权限交互;自动捕获失败后才要求用户手动协助采集。');
}
if (result.importResult) {
lines.push('', '## 导入结果');
lines.push(`- 导入是否成功:${result.importResult.ok ? '是' : '否'}`);
if (result.importResult.stdout.trim()) lines.push('', '```text', result.importResult.stdout.trim(), '```');
if (result.importResult.stderr.trim()) lines.push('', '```text', result.importResult.stderr.trim(), '```');
}
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
if (!args.url) throw new Error('缺少 --url;自动捕获需要目标页面 URL。');
const trace = detectRuyiTrace(args);
const plan = buildPlan(args, trace);
if (!trace.installed) {
const obj = { args, trace, plan, result: { launched: false, logs: [] } };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
process.exitCode = 2;
return;
}
if (args.dryRun) {
const obj = { args, trace, plan, result: { launched: false, logs: [] } };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
return;
}
const result = await capture(args, plan);
const obj = { args, trace, plan, result };
if (args.json) process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
if (args.markdown) process.stdout.write(renderMarkdown(obj));
if (!result.logs.length) process.exitCode = 3;
}
main().catch((err) => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,283 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const REQUIRED_FIELDS = [
'修改前逻辑',
'问题证据',
'本次修改',
'修改理由',
'已失败尝试',
'禁止回退',
'验证命令',
'验证结果',
'当前验证范围',
'遗留风险',
'当前状态',
];
function parseArgs(argv) {
const args = {
caseDir: 'case',
changed: [],
init: false,
requireEntry: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--changed' || a === '--file') args.changed.push(argv[++i] || '');
else if (a === '--init') args.init = true;
else if (a === '--require-entry') args.requireEntry = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
return args;
}
function usage() {
return `用法:
node scripts/check_change_memory.js --case-dir case --init --markdown
node scripts/check_change_memory.js --case-dir case --markdown
node scripts/check_change_memory.js --case-dir case --changed result/src/env/install-env.js --require-entry --markdown
node scripts/check_change_memory.js --case-dir case --changed result/src/signer/index.js --json
说明:检查复杂 case 是否维护 case/notes/代码变更记忆.md,并确认关键源码修改后记录了修改原因、失败尝试、禁止回退、验证范围和遗留风险。`;
}
function normalizeSlash(v) {
return String(v || '').replace(/\\/g, '/');
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function memoryPath(caseDir) {
return path.join(path.resolve(caseDir), 'notes', '代码变更记忆.md');
}
function createTemplate() {
return `# 代码变更记忆
本文件由通用代码变更记忆机制维护,用于记录复杂 case 中关键源码修改的事实、原因、失败尝试、禁止回退、验证范围和遗留风险。不要把“当前报错消失”直接写成永久结论。
## 使用规则
- 修改关键源码前先读取本文件,并搜索文件名、函数名、错误关键词和参数名。
- 修改关键源码后立即追加一条“变更”记录。
- 不删除失败记录;如历史记录被新证据替代,新增记录说明替代原因。
- 状态只能使用:临时修复 / 当前验证通过 / 已失败 / 被替代 / 稳定基线。
## 变更 001 - 待填写
- 时间:YYYY-MM-DD HH:mm
- 涉及文件:
- 涉及函数 / 模块:
- 当前状态:临时修复
### 修改前逻辑
待填写。
### 问题证据
待填写。
### 本次修改
待填写。
### 修改理由
待填写。
### 已失败尝试
待填写。
### 禁止回退
待填写。
### 验证命令
\`\`\`bash
# 待填写
\`\`\`
### 验证结果
待填写。
### 当前验证范围
待填写。
### 遗留风险
待填写。
`;
}
function initMemory(file) {
fs.mkdirSync(path.dirname(file), { recursive: true });
if (!exists(file)) {
fs.writeFileSync(file, createTemplate(), 'utf8');
return 'created';
}
return 'kept';
}
function hasEntry(text) {
return /^##\s+变更\s+\d+/m.test(text);
}
function fieldMissing(text) {
return REQUIRED_FIELDS.filter(field => !text.includes(field));
}
function changedMatched(text, changedFile) {
const normalized = normalizeSlash(changedFile).replace(/^\.\//, '');
const basename = path.basename(normalized);
const textSlash = normalizeSlash(text);
if (normalized && textSlash.includes(normalized)) return true;
if (basename && textSlash.includes(basename)) return true;
return false;
}
function check(args) {
const caseDir = path.resolve(args.caseDir || 'case');
const file = memoryPath(caseDir);
const problems = [];
const warnings = [];
const actions = [];
if (args.init) actions.push({ action: initMemory(file), path: file });
if (!exists(file)) {
problems.push(`未找到通用代码变更记忆文件:${file}`);
return {
caseDir,
memoryFile: file,
exists: false,
clean: false,
actions,
requiredFields: REQUIRED_FIELDS,
missingFields: REQUIRED_FIELDS.slice(),
changed: args.changed,
matchedChanged: [],
problems,
warnings,
};
}
const text = fs.readFileSync(file, 'utf8');
const missingFields = fieldMissing(text);
if (missingFields.length) problems.push(`代码变更记忆缺少必要字段:${missingFields.join('、')}`);
const entryExists = hasEntry(text);
if (args.requireEntry && !entryExists) problems.push('已要求至少一条变更记录,但未发现 “## 变更 001” 这类记录。');
if (!entryExists) warnings.push('尚未发现正式变更记录;如果已经修改关键源码,请立即追加记录。');
const matchedChanged = [];
for (const changed of args.changed.filter(Boolean)) {
const matched = changedMatched(text, changed);
matchedChanged.push({ file: changed, matched });
if (args.requireEntry && !matched) {
problems.push(`已修改关键文件但代码变更记忆中未提到该文件:${changed}`);
} else if (!matched) {
warnings.push(`代码变更记忆中未提到变更文件:${changed}`);
}
}
if (/正确方案/.test(text)) {
warnings.push('发现“正确方案”表述;建议改为“当前验证通过 / 稳定基线”,并写明验证范围和遗留风险。');
}
return {
caseDir,
memoryFile: file,
exists: true,
clean: problems.length === 0,
actions,
requiredFields: REQUIRED_FIELDS,
missingFields,
hasEntry: entryExists,
changed: args.changed,
matchedChanged,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 通用代码变更记忆检查',
'',
`- case 目录:${result.caseDir}`,
`- 记忆文件:${result.memoryFile}`,
`- 文件存在:${result.exists ? '是' : '否'}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
'',
];
if (result.actions.length) {
lines.push('## 初始化动作');
for (const item of result.actions) {
lines.push(`- ${item.action === 'created' ? '已创建' : '已存在'}:${item.path}`);
}
lines.push('');
}
lines.push('## 必要字段');
for (const field of result.requiredFields) {
lines.push(`- ${field}:${result.missingFields.includes(field) ? '缺失' : '存在'}`);
}
lines.push('');
if (result.matchedChanged && result.matchedChanged.length) {
lines.push('## 变更文件命中');
for (const item of result.matchedChanged) {
lines.push(`- ${item.file}:${item.matched ? '已记录' : '未记录'}`);
}
lines.push('');
}
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown || !args.json) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,516 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
file: '',
maxLineLength: 180,
maxFileLines: 500,
maxFunctionLines: 90,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--dir') args.dir = argv[++i] || '';
else if (a === '--file' || a === '-f') args.file = argv[++i] || '';
else if (a === '--max-line-length') args.maxLineLength = Number(argv[++i] || args.maxLineLength);
else if (a === '--max-file-lines') args.maxFileLines = Number(argv[++i] || args.maxFileLines);
else if (a === '--max-function-lines') args.maxFunctionLines = Number(argv[++i] || args.maxFunctionLines);
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_code_quality.js --case-dir case --markdown
node scripts/check_code_quality.js --dir case/result --json
node scripts/check_code_quality.js --file case/result/src/env/install-env.js --markdown
说明:检查最终补环境代码是否简洁、可读、模块化,并验证中文注释为 UTF-8、无乱码、无连续问号、中文注释不含问号。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function isCodeFile(p) {
return ['.js', '.mjs', '.cjs', '.py'].includes(ext(p));
}
function shouldSkipFile(root, p) {
const r = rel(root, p).toLowerCase();
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(r)) return true;
if (/(^|\/)src\/target\/(original|vendor|bundle|bundles)(\/|$)/.test(r)) return true;
if (/(\.min\.js|bundle\.js|vendor\.js|package-lock\.json)$/i.test(r)) return true;
return false;
}
function readUtf8Strict(file) {
const buf = fs.readFileSync(file);
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf;
const text = buf.toString('utf8');
return { text: text.replace(/^\uFEFF/, ''), hasBom };
}
function hasChinese(s) {
return /[\u4e00-\u9fff]/.test(String(s || ''));
}
function extractJsComments(text) {
const comments = [];
const blockRe = /\/\*[\s\S]*?\*\//g;
let m;
while ((m = blockRe.exec(text))) {
for (const line of m[0].split(/\r?\n/)) comments.push(line.replace(/^\/\*+|\*+\/$/g, '').replace(/^\s*\*\s?/, '').trim());
}
const lineRe = /(^|\s)\/\/(.*)$/gm;
while ((m = lineRe.exec(text))) comments.push((m[2] || '').trim());
return comments.filter(Boolean);
}
function extractPyComments(text) {
const comments = [];
const lineRe = /^\s*#(.*)$/gm;
let m;
while ((m = lineRe.exec(text))) comments.push((m[1] || '').trim());
return comments.filter(Boolean);
}
function extractComments(file, text) {
return ext(file) === '.py' ? extractPyComments(text) : extractJsComments(text);
}
function firstNonEmptyLines(text, count = 8) {
return text.split(/\r?\n/).map(x => x.trim()).filter(Boolean).slice(0, count);
}
function stripJsLine(line) {
return line
.replace(/\/\*.*?\*\//g, '')
.replace(/\/\/.*$/g, '')
.trim();
}
function codeLineCount(file, lines) {
if (ext(file) === '.py') return lines.filter(line => line.trim() && !line.trim().startsWith('#')).length;
return lines.filter(line => stripJsLine(line)).length;
}
function maxJsBraceDepth(lines) {
let depth = 0;
let maxDepth = 0;
for (const raw of lines) {
const line = stripJsLine(raw);
for (const ch of line) {
if (ch === '{') {
depth += 1;
if (depth > maxDepth) maxDepth = depth;
} else if (ch === '}') {
depth = Math.max(0, depth - 1);
}
}
}
return maxDepth;
}
function maxPyIndentDepth(lines) {
let maxDepth = 0;
for (const raw of lines) {
if (!raw.trim() || raw.trim().startsWith('#')) continue;
const spaces = raw.match(/^\s*/)[0].replace(/\t/g, ' ').length;
maxDepth = Math.max(maxDepth, Math.floor(spaces / 2));
}
return maxDepth;
}
function inspectJsFunctions(lines, maxFunctionLines) {
const problems = [];
const warnings = [];
const starters = [
/\bfunction\s+([A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{/,
/\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{/,
/\b(?:async\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*\{/
];
for (let i = 0; i < lines.length; i++) {
const line = stripJsLine(lines[i]);
if (!line.includes('{')) continue;
if (!starters.some(re => re.test(line))) continue;
let depth = 0;
let end = i;
let seenOpen = false;
for (let j = i; j < lines.length; j++) {
const cur = stripJsLine(lines[j]);
for (const ch of cur) {
if (ch === '{') { depth += 1; seenOpen = true; }
else if (ch === '}') depth -= 1;
}
if (seenOpen && depth <= 0) { end = j; break; }
}
const size = end - i + 1;
if (size > maxFunctionLines) problems.push(`第 ${i + 1} 行附近函数过长:${size} 行,建议拆分到 ${maxFunctionLines} 行以内。`);
const prev = lines.slice(Math.max(0, i - 3), i).join('\n');
if (size > 15 && !hasChinese(prev)) warnings.push(`第 ${i + 1} 行附近函数较长但前置中文说明不足,建议补充职责、输入和输出说明。`);
}
return { problems, warnings };
}
function inspectPyFunctions(lines, maxFunctionLines) {
const problems = [];
const warnings = [];
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*)def\s+([A-Za-z_]\w*)\s*\(/);
if (!m) continue;
const baseIndent = m[1].replace(/\t/g, ' ').length;
let end = i;
for (let j = i + 1; j < lines.length; j++) {
if (!lines[j].trim()) { end = j; continue; }
const indent = lines[j].match(/^\s*/)[0].replace(/\t/g, ' ').length;
if (indent <= baseIndent) break;
end = j;
}
const size = end - i + 1;
if (size > maxFunctionLines) problems.push(`第 ${i + 1} 行附近函数过长:${size} 行,建议拆分到 ${maxFunctionLines} 行以内。`);
const prev = lines.slice(Math.max(0, i - 3), i).join('\n');
if (size > 15 && !hasChinese(prev)) warnings.push(`第 ${i + 1} 行附近函数较长但前置中文说明不足,建议补充职责、输入和输出说明。`);
}
return { problems, warnings };
}
function inspectEmbeddedScriptStrings(relFile, text) {
const problems = [];
const warnings = [];
const normalized = relFile.replace(/\\/g, '/');
const fileName = path.basename(normalized).toLowerCase();
const isEnvFile = /(^|\/)src\/env\//i.test(normalized) || /(^|\/)env\//i.test(normalized);
if (!/\.(?:js|mjs|cjs)$/i.test(normalized)) return { problems, warnings };
const scriptRe = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*(?:SCRIPT|SOURCE|CODE)[A-Za-z_$]*)\s*=\s*String\.raw`([\s\S]*?)`/g;
let match;
while ((match = scriptRe.exec(text))) {
const name = match[1];
const body = match[2] || '';
const bodyLines = body.split(/\r?\n/).length;
const looksLikeWebApi = /\b(?:Navigator|Document|Window|Location|History|Screen|XMLHttpRequest|HTMLCanvasElement|WebGL|navigator|document|window|xbs\.|createProtoChains|createNativeFunction|createGetter|createSetter|createNativeCollection|xbs\.dom\.createDocument)\b/.test(body);
if (isEnvFile && (bodyLines > 40 || looksLikeWebApi)) {
problems.push('发现大段 isolated-vm 补环境字符串 `' + name + ' = String.raw 模板字符串`(约 ' + bodyLines + ' 行)。补环境源码必须拆成真实 .js 文件,例如 browser-objects/navigator.js、document.js、fingerprint/canvas.js,再由 runtime.runFile/runFiles 注入 Context。');
} else if (bodyLines > 80) {
warnings.push(`发现较大的 String.raw 字符串 \`${name}\`(约 ${bodyLines} 行),请确认不是补环境 WebAPI 主实现。`);
}
}
if (isEnvFile && /^script-(?:core|browser|.*objects|.*env).*\.js$/i.test(fileName) && /String\.raw`/.test(text)) {
problems.push('发现 script-*.js 聚合字符串补环境文件。isolated-vm 交付应使用真实模块文件,不应把主要 WebAPI 放入 script-core.js 或 script-browser-objects.js。');
}
if (isEnvFile && /\b[A-Z][A-Z0-9_]*SCRIPT\b[\s\S]{0,200}\.join\(\s*['"]\\n['"]\s*\)/.test(text)) {
problems.push('发现把多个 *_SCRIPT 字符串 join 后执行的补环境聚合方式。请改为 runtime.runFiles([...]) 按真实文件注入。');
}
if (isEnvFile && /module\.exports\s*=\s*\{[\s\S]{0,300}\b[A-Z][A-Z0-9_]*SCRIPT\b/.test(text)) {
warnings.push('发现导出 *_SCRIPT 字符串。除极小 bootstrap 外,补环境实现应导出安装函数或通过真实文件加载。');
}
return { problems, warnings };
}
const WEBAPI_DOMAIN_PATTERNS = [
['window/global', /\b(?:window|globalThis|self|top|parent|frames|innerWidth|outerWidth|devicePixelRatio)\b/],
['navigator', /\b(?:navigator|Navigator|PluginArray|MimeTypeArray|permissions|sendBeacon|hardwareConcurrency|webdriver|mimeTypes|plugins)\b/],
['document/dom', /\b(?:document|Document|HTMLDocument|Element|HTMLElement|createElement|createTextNode|currentScript|DOMRect|MutationObserver|HTMLCollection|NodeList)\b/],
['location/history', /\b(?:location|Location|history|History|pushState|replaceState)\b/],
['screen', /\b(?:screen|Screen|availWidth|availHeight|colorDepth|pixelDepth)\b/],
['storage/cookie', /\b(?:localStorage|sessionStorage|Storage|cookie|Cookie|document\.cookie)\b/],
['network', /\b(?:XMLHttpRequest|fetch|Request|Response|Headers|sendBeacon|open\s*\(|setRequestHeader|readyState)\b/],
['canvas', /\b(?:HTMLCanvasElement|CanvasRenderingContext2D|toDataURL|getImageData|measureText|canvas)\b/],
['webgl', /\b(?:WebGLRenderingContext|WebGL2RenderingContext|WebGLShaderPrecisionFormat|getParameter|readPixels|getSupportedExtensions|webgl)\b/],
['audio', /\b(?:AudioContext|OfflineAudioContext|AudioBuffer|HTMLAudioElement|getChannelData|startRendering|audio)\b/],
['performance/timing', /\b(?:performance|Performance|PerformanceNavigationTiming|timeOrigin|navigationStart|requestAnimationFrame)\b/],
['events', /\b(?:EventTarget|Event|MouseEvent|KeyboardEvent|CustomEvent|addEventListener|dispatchEvent|isTrusted)\b/],
['worker/wasm/message', /\b(?:Worker|postMessage|MessageChannel|MessagePort|WebAssembly|BroadcastChannel)\b/],
];
function detectWebApiDomains(text) {
const found = [];
for (const [name, pattern] of WEBAPI_DOMAIN_PATTERNS) {
if (pattern.test(text)) found.push(name);
}
return found;
}
function isEnvModulePath(relFile) {
return /(^|\/)src\/(?:env|node-runtime\/env)\//i.test(relFile) || /(^|\/)env\//i.test(relFile);
}
function inspectWebApiModuleBoundary(relFile, text, lines, args) {
const problems = [];
const warnings = [];
const normalized = relFile.replace(/\\/g, '/');
if (!/\.(?:js|mjs|cjs)$/i.test(normalized)) return { problems, warnings };
const domains = detectWebApiDomains(text);
if (!domains.length) return { problems, warnings };
const envModule = isEnvModulePath(normalized);
const orchestrationDir = /(^|\/)src\/(?:signer|request|resources)\//i.test(normalized);
const signerFile = /(^|\/)src\/signer\//i.test(normalized);
const probeLikeFile = /(^|\/)[^/]*(?:probe|diagnostic|runtime-runner|runtime_probe|runtime-probe)[^/]*\.(?:js|mjs|cjs)$/i.test(normalized);
const codeLines = codeLineCount(normalized, lines);
const implementationLike = /\b(?:installBrowserEnv|install[A-Z][\w$]*Env|create[A-Z][\w$]*(?:Environment|Constructors?|Env)|createNativeHelpers|defineValue\s*\(|defineGetter\s*\(|defineAccessor\s*\(|Object\.defineProperty|Object\.defineProperties|nativeApi\.fn|nativeApi\.getter|HTMLCanvasElement|WebGLRenderingContext|XMLHttpRequest)\b/.test(text);
const domainText = domains.join('、');
if (signerFile && domains.length >= 3 && implementationLike) {
problems.push(`src/signer 文件承载了 ${domains.length} 类 WebAPI 补环境主体(${domainText})。signer 只允许做入口编排、调用目标 signer 和整理输出;请把 WebAPI 安装逻辑拆到 src/env/ 或 src/node-runtime/env/ 下的真实模块。`);
}
if (probeLikeFile && !envModule && domains.length >= 4 && codeLines > 180 && implementationLike) {
problems.push(`probe / runtime 入口文件承载了 ${domains.length} 类 WebAPI 补环境主体(${domainText})。probe 只能是薄入口,建议少于 150 行;请拆分为 browser-objects、fingerprint、network、bootstrap 等模块。`);
}
if (!envModule && domains.length >= 5 && codeLines > args.maxFileLines && implementationLike) {
problems.push(`单个非 env 文件同时实现 ${domains.length} 类 WebAPI 且超过 ${args.maxFileLines} 行(${domainText}),属于补环境主体堆叠。请先规划目录并拆分模块后再继续验证。`);
}
if (orchestrationDir && domains.length >= 2 && codeLines > 240 && implementationLike && !signerFile) {
warnings.push(`编排目录文件包含多类 WebAPI 实现迹象(${domainText})。请确认它只做请求、资源或摘要编排;WebAPI 主体必须放入 src/env/ 或 src/node-runtime/env/。`);
}
return { problems, warnings };
}
function inspectProjectStructure(root, files) {
const problems = [];
const warnings = [];
const codeFiles = files.filter(isCodeFile);
const fileEntries = codeFiles.map(filePath => {
let text = '';
try { text = readUtf8Strict(filePath).text; } catch { text = ''; }
return { filePath, relFile: rel(root, filePath), text };
});
const isolatedVmUsed = fileEntries.some(entry => /\b(?:isolated_vm\.node|xbs-isolated-vm|createXbsRuntime|createIsolatedVmRuntime|window\.xbs|xbs\.dom\.createDocument)\b/.test(entry.text));
if (!isolatedVmUsed) return { problems, warnings };
const envEntries = fileEntries.filter(entry => /(^|\/)src\/env\//i.test(entry.relFile));
const embeddedEnvScripts = envEntries.filter(entry => /String\.raw`/.test(entry.text) && /\b(?:Navigator|Document|Window|XMLHttpRequest|HTMLCanvasElement|WebGL|xbs\.|navigator|document|window)\b/.test(entry.text));
if (embeddedEnvScripts.length) {
problems.push('选择 isolated-vm 时发现补环境 WebAPI 仍以 String.raw 聚合字符串实现:' + embeddedEnvScripts.map(entry => entry.relFile).join('、') + '。请拆分为真实模块文件并用 runtime.runFile/runFiles 加载。');
}
const hasBrowserObjectModule = envEntries.some(entry => /(^|\/)src\/env\/browser-objects\/[^/]+\.js$/i.test(entry.relFile));
const touchesBrowserObjects = envEntries.some(entry => /\b(?:navigator|document|window|location|screen|XMLHttpRequest|history|storage)\b/i.test(entry.text));
if (touchesBrowserObjects && !hasBrowserObjectModule) {
problems.push('选择 isolated-vm 且补了浏览器对象,但未发现 src/env/browser-objects/*.js 模块。请至少按 navigator.js、document.js、window.js 等职责拆分。');
}
const hasRunFiles = fileEntries.some(entry => /\brunFiles\s*\(|\brunFile\s*\(/.test(entry.text));
if (embeddedEnvScripts.length && !hasRunFiles) {
warnings.push('未发现 runtime.runFile/runFiles 文件化加载入口。建议在 xbs runtime 中提供按文件注入 Context 的能力。');
}
return { problems, warnings };
}
function inspectFile(root, file, args) {
const relFile = rel(root, file);
const problems = [];
const warnings = [];
const { text, hasBom } = readUtf8Strict(file);
const lines = text.split(/\r?\n/);
const comments = extractComments(file, text);
const chineseComments = comments.filter(hasChinese);
const codeLines = codeLineCount(file, lines);
const embeddedScriptCheck = inspectEmbeddedScriptStrings(relFile, text);
problems.push(...embeddedScriptCheck.problems);
warnings.push(...embeddedScriptCheck.warnings);
const webApiBoundaryCheck = inspectWebApiModuleBoundary(relFile, text, lines, args);
problems.push(...webApiBoundaryCheck.problems);
warnings.push(...webApiBoundaryCheck.warnings);
if (hasBom) problems.push('文件带 UTF-8 BOM,建议使用无 BOM UTF-8。');
if (/\uFFFD/.test(text)) problems.push('文件包含替换字符,疑似 UTF-8 解码或写入异常。');
if (/\?{6,}/.test(text)) problems.push('文件包含连续问号,疑似中文编码问题。');
for (let i = 0; i < comments.length; i++) {
const c = comments[i];
if (hasChinese(c) && /[??]/.test(c)) problems.push(`中文注释包含问号:第 ${i + 1} 条注释“${c.slice(0, 60)}”。`);
if (/\uFFFD|\?{3,}/.test(c)) problems.push(`注释疑似乱码:第 ${i + 1} 条注释“${c.slice(0, 60)}”。`);
}
const header = firstNonEmptyLines(text, 8).join('\n');
if (!hasChinese(header) || !/^\s*(\/\/|\/\*|\*|#)/m.test(header)) {
problems.push('文件开头缺少中文职责注释;请在文件顶部说明模块用途、数据来源和边界。');
}
if (codeLines > 20 && chineseComments.length === 0) problems.push('代码超过 20 行但没有中文注释。');
if (codeLines > 80 && chineseComments.length < Math.ceil(codeLines / 120)) {
problems.push(`中文注释过少:代码约 ${codeLines} 行,中文注释 ${chineseComments.length} 条。`);
}
if (lines.length > args.maxFileLines) problems.push(`文件过大:${lines.length} 行,建议拆分到 ${args.maxFileLines} 行以内。`);
lines.forEach((line, idx) => {
if (line.length > args.maxLineLength) problems.push(`第 ${idx + 1} 行过长:${line.length} 字符,建议拆分到 ${args.maxLineLength} 字符以内。`);
if (line.length > 320) problems.push(`第 ${idx + 1} 行疑似压缩或堆叠代码。`);
});
const semicolonDense = lines.filter(line => (line.match(/;/g) || []).length >= 6);
if (semicolonDense.length) problems.push(`发现 ${semicolonDense.length} 行包含大量分号,疑似压缩或多语句堆叠。`);
const denseLineProblems = [];
lines.forEach((rawLine, idx) => {
const line = stripJsLine(rawLine);
if (!line) return;
const lineNo = idx + 1;
const defineCount = (line.match(/\b(?:Object\.defineProperty|Object\.defineProperties|defineValue|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor)\s*\(/g) || []).length;
if (defineCount >= 2) {
denseLineProblems.push(`第 ${lineNo} 行同时包含 ${defineCount} 个属性定义调用,应拆成多行并补充 descriptor 说明。`);
}
if (/(?:Object\.defineProperty|Object\.defineProperties)\s*\([^;]+\{[^{}\n]*(?:value|get|set|writable|enumerable|configurable)[^{}\n]*\}[^;]*;?$/.test(line) && line.length > 110) {
denseLineProblems.push(`第 ${lineNo} 行把属性描述符压在一行,建议展开 value/get/set/writable/enumerable/configurable。`);
}
if (/\bObject\.assign\s*\([^;]*\{.*\{.*\}/.test(line)) {
denseLineProblems.push(`第 ${lineNo} 行疑似用 Object.assign 堆叠对象和方法,补环境代码应拆为 createProtoChains 与 defineProperty。`);
}
if (/\b(?:ctx|window|globalThis|globalObject|self)\s*(?:\.|\[).*=.*\{.*(?:function\b|=>|\b[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{)/.test(line)) {
denseLineProblems.push(`第 ${lineNo} 行把全局 WebAPI 对象、方法或函数堆在一行,建议拆成模块化安装函数。`);
}
if (/\b(?:if|for|while|try|catch|finally)\b[^{;\n]*\{[^{}\n]*;[^{}\n]*\}/.test(line) && line.length > 120) {
denseLineProblems.push(`第 ${lineNo} 行存在较长的单行控制流代码块,建议展开为多行。`);
}
if (/\bfunction\b[^{;\n]*\{[^{}\n]*;[^{}\n]*\}/.test(line) && line.length > 120) {
denseLineProblems.push(`第 ${lineNo} 行存在单行函数体,建议提取为具名函数并展开实现。`);
}
});
if (denseLineProblems.length) problems.push(...denseLineProblems.slice(0, 30));
if (denseLineProblems.length > 30) problems.push(`还有 ${denseLineProblems.length - 30} 个疑似单行堆叠问题未逐条展示,请先格式化源码后重新检查。`);
if (/\bdebugger\s*;/.test(text)) problems.push('存在 debugger 语句,最终代码不得保留调试断点。');
if (/TODO|FIXME|临时|随便|测试用|先这样|debug/i.test(text)) warnings.push('发现 TODO/FIXME/临时/调试类标记,交付前建议清理或改为正式说明。');
if (/\b(?:var\s+[a-z]\b|function\s+[a-z]\s*\(|const\s+[a-z]\s*=|let\s+[a-z]\s*=)/.test(text)) warnings.push('发现过短变量或函数名,建议使用表达业务含义的命名。');
const depth = ext(file) === '.py' ? maxPyIndentDepth(lines) : maxJsBraceDepth(lines);
if (depth > 8) problems.push(`嵌套层级过深:最大层级 ${depth},建议拆分函数或提前返回。`);
else if (depth > 6) warnings.push(`嵌套层级偏深:最大层级 ${depth},建议优化结构。`);
const funcs = ext(file) === '.py' ? inspectPyFunctions(lines, args.maxFunctionLines) : inspectJsFunctions(lines, args.maxFunctionLines);
problems.push(...funcs.problems);
warnings.push(...funcs.warnings);
const anonymousCount = (text.match(/\bfunction\s*\(/g) || []).length + (text.match(/=>\s*\{/g) || []).length;
if (anonymousCount > 8) warnings.push(`匿名函数较多:${anonymousCount} 个,建议提取为具名函数提升可读性。`);
return {
file: relFile,
clean: problems.length === 0,
lines: lines.length,
codeLines,
chineseCommentCount: chineseComments.length,
maxDepth: depth,
problems,
warnings,
};
}
function check(args) {
const root = args.file
? path.resolve(path.dirname(args.file))
: args.dir
? path.resolve(args.dir)
: path.join(path.resolve(args.caseDir || 'case'), 'result');
const files = args.file ? [path.resolve(args.file)] : walk(root).filter(p => isCodeFile(p) && !shouldSkipFile(root, p));
const problems = [];
const warnings = [];
if (!files.length) problems.push(`未找到可检查的最终代码文件:${root}`);
const fileResults = files.map(f => inspectFile(root, f, args));
for (const r of fileResults) {
for (const p of r.problems) problems.push(`${r.file}: ${p}`);
for (const w of r.warnings) warnings.push(`${r.file}: ${w}`);
}
const structure = inspectProjectStructure(root, files);
for (const p of structure.problems) problems.push(p);
for (const w of structure.warnings) warnings.push(w);
return {
root,
clean: problems.length === 0,
filesChecked: fileResults.length,
limits: {
maxLineLength: args.maxLineLength,
maxFileLines: args.maxFileLines,
maxFunctionLines: args.maxFunctionLines,
},
problems,
warnings,
fileResults,
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境代码质量检查结果',
'',
`检查范围:${result.root}`,
`是否通过:${result.clean ? '是' : '否'}`,
`检查文件数:${result.filesChecked}`,
'',
'## 质量规则',
`- 单行长度上限:${result.limits.maxLineLength}`,
`- 单文件行数上限:${result.limits.maxFileLines}`,
`- 单函数行数上限:${result.limits.maxFunctionLines}`,
'- 必须有中文职责注释,中文注释不得包含问号、连续问号或乱码。',
'- 禁止压缩代码、过度堆叠语句、调试断点和临时测试标记。',
'- signer / probe / runtime 入口不得承载 navigator、document、canvas、webgl、performance 等多域 WebAPI 补环境主体。',
'- XHR/fetch 实现应拆入 env/network 并通过 live session bridge;浏览器对象私有状态不得以 _ / __ own property 泄露。',
'- isolated-vm 补环境不得以大段 String.raw / *_SCRIPT 字符串作为主要交付形态,必须拆成真实文件模块并通过 runFile/runFiles 注入。',
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## 文件摘要');
for (const f of result.fileResults) {
lines.push(`- ${f.file}:${f.clean ? '通过' : '失败'},总行数 ${f.lines},代码行 ${f.codeLines},中文注释 ${f.chineseCommentCount},最大嵌套 ${f.maxDepth}`);
}
if (!result.fileResults.length) lines.push('- 无');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { check, inspectFile, extractComments, inspectEmbeddedScriptStrings, inspectWebApiModuleBoundary, inspectProjectStructure };

View File

@@ -0,0 +1,335 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function parseArgs(argv) {
const args = {
caseDir: 'case',
manifest: '',
requireRuntimeRefresh: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || 'case';
else if (a === '--manifest') args.manifest = argv[++i] || '';
else if (a === '--require-runtime-refresh') args.requireRuntimeRefresh = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_dynamic_resources.js --case-dir case --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --markdown
node scripts/check_dynamic_resources.js --case-dir case --require-runtime-refresh --json
说明:检查 case/notes/resource-manifest.json 中的动态 HTML / JS / challenge 资源是否只作为分析快照,并确认影响最终参数生成的动态资源已设计运行时刷新模块;同时检查 result/ 中是否混入动态快照文件。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function sha256File(file) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
} catch {
return '';
}
}
function normalizeBool(v) {
return v === true || v === 'true' || v === 1 || v === '1';
}
function isDynamicValue(v) {
if (typeof v === 'boolean') return v;
if (typeof v === 'string') return ['true', 'yes', 'dynamic', 'high-risk', '高风险', '动态'].includes(v.toLowerCase());
return false;
}
function getHash(resource) {
return resource.sha256 || resource.bodySha256 || resource.bodyHash || resource.hash || '';
}
function sanitizeRel(p) {
return String(p || '').replace(/\\/g, '/').replace(/^\.\//, '');
}
function resolveCasePath(caseDir, maybePath) {
const raw = String(maybePath || '').trim();
if (!raw) return [];
if (path.isAbsolute(raw)) return [raw];
const normalized = sanitizeRel(raw);
const parent = path.dirname(caseDir);
const withoutCase = normalized.replace(/^case\//i, '');
const candidates = [
path.join(caseDir, normalized),
path.join(parent, normalized),
path.join(caseDir, withoutCase),
];
if (!/^result\//i.test(normalized)) candidates.push(path.join(caseDir, 'result', normalized));
return [...new Set(candidates.map(p => path.normalize(p)))];
}
function firstExisting(paths) {
return paths.find(p => exists(p)) || '';
}
function readManifest(manifestPath) {
const text = fs.readFileSync(manifestPath, 'utf8').replace(/^\uFEFF/, '');
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return { raw: parsed, resources: parsed };
const resources = Array.isArray(parsed.resources) ? parsed.resources : [];
return { raw: parsed, resources };
}
function finalEntryFiles(resultDir) {
const files = [];
for (const name of ['final.js', 'final.mjs', 'final.cjs', 'final.py']) {
const p = path.join(resultDir, name);
if (exists(p)) files.push(p);
}
return files;
}
function readTextSafe(file) {
try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }
}
function inspect(caseDir, args) {
const problems = [];
const warnings = [];
const manifestPath = args.manifest ? path.resolve(args.manifest) : path.join(caseDir, 'notes', 'resource-manifest.json');
const resultDir = path.join(caseDir, 'result');
const snapshotsDir = path.join(caseDir, 'js', 'snapshots');
const report = {
ok: false,
caseDir,
manifestPath,
manifestExists: exists(manifestPath),
resources: [],
dynamicCount: 0,
requiredDynamicCount: 0,
runtimeRefreshCount: 0,
problems,
warnings,
};
if (!report.manifestExists) {
const hasSnapshots = exists(snapshotsDir) && walk(snapshotsDir).length > 0;
const msg = `未找到资源清单:${rel(caseDir, manifestPath)}`;
if (args.requireRuntimeRefresh || hasSnapshots) problems.push(hasSnapshots ? `${msg};但已存在 js/snapshots 动态快照目录,必须补写 resource-manifest.json。` : `${msg};当前要求强制检查运行时刷新。`);
else warnings.push(`${msg};如果本 case 没有下载 / 保存 HTML、JS 或 challenge 资源,可以忽略。`);
report.ok = problems.length === 0;
return report;
}
let manifest;
try {
manifest = readManifest(manifestPath);
} catch (err) {
problems.push(`resource-manifest.json 解析失败:${err.message}`);
report.ok = false;
return report;
}
if (!Array.isArray(manifest.resources)) {
problems.push('resource-manifest.json 缺少 resources 数组。');
report.ok = false;
return report;
}
const resultFiles = walk(resultDir).filter(p => stat(p)?.isFile());
const resultHashes = new Map();
for (const file of resultFiles) resultHashes.set(file, sha256File(file));
const resultRelList = resultFiles.map(file => rel(caseDir, file));
for (let i = 0; i < manifest.resources.length; i++) {
const r = manifest.resources[i] || {};
const item = {
index: i,
url: r.url || '',
type: r.type || '',
file: r.file || '',
sha256: getHash(r),
dynamic: isDynamicValue(r.dynamic),
requiredForFinal: normalizeBool(r.requiredForFinal),
runtimeRefresh: normalizeBool(r.runtimeRefresh) || r.use === 'runtime-refresh',
refreshEntry: r.refreshEntry || '',
use: r.use || '',
problems: [],
warnings: [],
};
report.resources.push(item);
const prefix = `resources[${i}]`;
if (!item.url) item.problems.push(`${prefix} 缺少 url。`);
if (!item.sha256) item.problems.push(`${prefix} 缺少 sha256 / bodySha256 / hash。`);
if (typeof r.dynamic === 'undefined') item.problems.push(`${prefix} 缺少 dynamic 字段,必须明确 true / false。`);
if (!item.use) item.problems.push(`${prefix} 缺少 use 字段,应标明 analysis-snapshot / runtime-refresh / static 等。`);
if (item.dynamic) report.dynamicCount += 1;
if (item.dynamic && item.requiredForFinal) report.requiredDynamicCount += 1;
if (item.runtimeRefresh) report.runtimeRefreshCount += 1;
if (item.dynamic && item.file && !/analysis-snapshot|runtime-refresh/i.test(item.use)) {
item.warnings.push(`${prefix} 是动态资源,但 use 未标明 analysis-snapshot 或 runtime-refresh。`);
}
if (item.dynamic && item.requiredForFinal) {
if (!item.runtimeRefresh) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但 runtimeRefresh 未设置为 true。`);
if (!item.refreshEntry) item.problems.push(`${prefix} 是影响最终参数生成的动态资源,但缺少 refreshEntry。`);
if (args.requireRuntimeRefresh && item.refreshEntry) {
const refreshPath = firstExisting(resolveCasePath(caseDir, item.refreshEntry));
if (!refreshPath) item.problems.push(`${prefix} 的 refreshEntry 不存在:${item.refreshEntry}。`);
}
}
if (item.dynamic && item.file) {
const resourcePath = firstExisting(resolveCasePath(caseDir, item.file));
const resourceHash = resourcePath ? sha256File(resourcePath) : item.sha256;
const resourceBase = path.basename(item.file).toLowerCase();
for (const file of resultFiles) {
const rr = rel(caseDir, file);
const rrLower = rr.toLowerCase();
const sameHash = resourceHash && resultHashes.get(file) === resourceHash;
const snapshotPath = /(^|\/)result\/(?:.*\/)?(?:js\/)?snapshots\//i.test(rrLower) || /(^|\/)snapshots\//i.test(rrLower);
if (sameHash) item.problems.push(`${prefix} 的动态快照内容疑似被复制到最终产物:${rr}。`);
else if (snapshotPath) item.problems.push(`最终产物包含 snapshots 路径,不应交付动态快照:${rr}。`);
else if (resourceBase && path.basename(file).toLowerCase() === resourceBase && /snapshot|challenge|dynamic/i.test(resourceBase)) {
item.warnings.push(`${prefix} 的文件名与 result/ 中 ${rr} 相同,请确认不是复制动态快照。`);
}
}
}
for (const p of item.problems) problems.push(p);
for (const w of item.warnings) warnings.push(w);
}
if (args.requireRuntimeRefresh && report.requiredDynamicCount > 0) {
const entries = finalEntryFiles(resultDir);
if (entries.length === 0) {
warnings.push('result/ 中未发现 final.js / final.py;无法检查最终入口是否调用运行时刷新模块。');
} else {
const finalText = entries.map(readTextSafe).join('\n');
const refreshNames = report.resources
.filter(r => r.dynamic && r.requiredForFinal && r.refreshEntry)
.map(r => path.basename(r.refreshEntry).replace(/\.[^.]+$/, ''))
.filter(Boolean);
const hasRefreshSignal = /fetchRuntimeResources|refreshRuntimeResources|loadRuntimeResources|resourceManifest|运行时刷新|动态资源/.test(finalText)
|| refreshNames.some(name => new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).test(finalText));
if (!hasRefreshSignal) {
problems.push('最终入口未发现运行时刷新调用痕迹;动态 HTML / JS 影响最终参数生成时,final.js / final.py 必须先刷新当前资源再生成参数。');
}
}
}
if (manifest.resources.length === 0) warnings.push('resource-manifest.json 中 resources 为空;如果已下载 HTML / JS,请补充清单。');
if (resultRelList.some(x => /(^|\/)js\/snapshots\//i.test(x) || /(^|\/)snapshots\//i.test(x))) {
problems.push('最终 result/ 中存在 snapshots 目录或文件;动态快照不得进入最终产物。');
}
report.ok = problems.length === 0;
return report;
}
function toMarkdown(report) {
const lines = [];
lines.push('# 动态资源保鲜检查');
lines.push('');
lines.push(`- 检查结果:${report.ok ? '通过' : '未通过'}`);
lines.push(`- case 目录:${report.caseDir}`);
lines.push(`- manifest:${report.manifestExists ? report.manifestPath : '未找到'}`);
lines.push(`- 资源数量:${report.resources.length}`);
lines.push(`- 动态资源数量:${report.dynamicCount}`);
lines.push(`- 影响最终参数生成的动态资源:${report.requiredDynamicCount}`);
lines.push(`- 已声明运行时刷新的资源:${report.runtimeRefreshCount}`);
lines.push('');
if (report.resources.length) {
lines.push('## 资源概览');
lines.push('');
lines.push('| 序号 | 类型 | 动态 | 最终依赖 | 运行时刷新 | use | refreshEntry | URL |');
lines.push('|---|---|---|---|---|---|---|---|');
for (const r of report.resources) {
lines.push(`| ${r.index} | ${r.type || '-'} | ${r.dynamic ? '是' : '否'} | ${r.requiredForFinal ? '是' : '否'} | ${r.runtimeRefresh ? '是' : '否'} | ${r.use || '-'} | ${r.refreshEntry || '-'} | ${String(r.url || '').replace(/\|/g, '%7C')} |`);
}
lines.push('');
}
if (report.problems.length) {
lines.push('## 必须修复');
lines.push('');
for (const p of report.problems) lines.push(`- ${p}`);
lines.push('');
}
if (report.warnings.length) {
lines.push('## 提醒');
lines.push('');
for (const w of report.warnings) lines.push(`- ${w}`);
lines.push('');
}
if (report.ok) {
lines.push('## 结论');
lines.push('');
lines.push('- 未发现动态资源固定化交付问题。');
if (report.requiredDynamicCount > 0) lines.push('- 影响最终参数生成的动态资源已声明运行时刷新方案。');
else lines.push('- 当前 manifest 未声明影响最终参数生成的动态资源。');
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const caseDir = path.resolve(args.caseDir || 'case');
const report = inspect(caseDir, args);
if (args.json) console.log(JSON.stringify(report, null, 2));
else console.log(toMarkdown(report));
if (!report.ok) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(`动态资源检查失败:${err.message}`);
process.exit(1);
}

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
file: '',
requireDocumentAll: false,
requireRuyiTrace: false,
requireFingerprintFixture: false,
requireAddonFirst: true,
addonFirstOptOut: false,
fingerprintFixture: '',
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--file' || a === '-f') args.file = argv[++i] || '';
else if (a === '--require-document-all') args.requireDocumentAll = true;
else if (a === '--require-ruyitrace') args.requireRuyiTrace = true;
else if (a === '--require-fingerprint-fixture') args.requireFingerprintFixture = true;
else if (a === '--require-addon-first') args.requireAddonFirst = true;
else if (a === '--no-require-addon-first' || a === '--allow-no-addon-first') {
args.requireAddonFirst = false;
args.addonFirstOptOut = true;
}
else if (a === '--fingerprint-fixture') args.fingerprintFixture = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_env_realism.js --case-dir case --markdown
node scripts/check_env_realism.js --case-dir case --no-require-addon-first --markdown
node scripts/check_env_realism.js --case-dir case --require-document-all --require-ruyitrace --require-fingerprint-fixture --require-addon-first --json
node scripts/check_env_realism.js --file case/result/src/env/index.js --markdown
说明:检查补环境交付代码是否体现原型链、属性描述符、访问器、函数 / 访问器 / 实例对象 toString 保护、addon-first/native fallback 规则、document.all 特殊对象处理、指纹终端 API 值回放策略,以及选择 RuyiTrace 时是否沉淀 NDJSON 证据。addon-first 是默认硬性要求;如果选择 xbs isolated-vm,则 window.xbs / globalThis.xbs 视为 native-first 证据;只有用户明确要求不使用 addon / 不做 addon-first 时,才允许传入 --no-require-addon-first 并在总结中记录豁免原因。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function codeFiles(root) {
return walk(root).filter(p => ['.js', '.mjs', '.cjs', '.py'].includes(ext(p)));
}
function has(pattern, text) { return pattern.test(text); }
function any(pattern, files) { return files.some(f => has(pattern, readText(f))); }
function matchingFiles(pattern, files, root) {
return files.filter(f => has(pattern, readText(f))).map(f => rel(root, f));
}
function listNdjson(caseDir) {
const dir = path.join(caseDir, 'ruyi-trace', 'logs');
if (!exists(dir)) return [];
return walk(dir).filter(p => p.toLowerCase().endsWith('.ndjson'));
}
function inspectRuyiTrace(caseDir, requireRuyiTrace) {
const problems = [];
const warnings = [];
const ndjson = listNdjson(caseDir);
const shouldCheck = requireRuyiTrace || ndjson.length > 0;
const summary = path.join(caseDir, 'notes', 'ruyitrace-summary.md');
const priority = path.join(caseDir, 'notes', 'missing-env-priority.md');
const result = {
required: shouldCheck,
ndjson: ndjson.map(p => rel(caseDir, p)),
summary: exists(summary) ? rel(caseDir, summary) : '',
priority: exists(priority) ? rel(caseDir, priority) : '',
};
if (!shouldCheck) return { result, problems, warnings };
if (!ndjson.length) problems.push('已要求 RuyiTrace 优先诊断,但 case/ruyi-trace/logs/ 下未找到 NDJSON 日志。');
if (!exists(summary)) problems.push('已要求 RuyiTrace 优先诊断,但未找到 notes/ruyitrace-summary.md;应先运行 import_ruyitrace_log.js。');
else {
const text = readText(summary);
if (!/API|api|调用栈|stack|类别统计|RuyiTrace/i.test(text)) warnings.push('ruyitrace-summary.md 内容较弱,建议包含 API 统计、stack.file 和环境模块分类。');
}
if (!exists(priority)) problems.push('已选择 / 提供 RuyiTrace 日志,但未找到 notes/missing-env-priority.md;补环境前应把 NDJSON 命中的 api、stack.file、line、col 和补齐优先级写入该文件。');
else {
const text = readText(priority);
if (!/RuyiTrace|NDJSON|api|stack\.file|line|col|证据/.test(text)) {
problems.push('missing-env-priority.md 未体现 RuyiTrace/NDJSON 证据、api 或 stack.file/line/col;不能证明补环境阶段持续参考了 trace 日志。');
}
}
return { result, problems, warnings };
}
function readJsonSafe(file) {
try { return JSON.parse(readText(file)); } catch (err) { return { __error: err.message || String(err) }; }
}
function countArray(v) { return Array.isArray(v) ? v.length : 0; }
function fingerprintFixturePath(caseDir, explicit) {
if (explicit) return path.resolve(explicit);
const candidates = [
path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'),
path.join(caseDir, 'fixtures', 'sample.fixture.json'),
];
return candidates.find(exists) || candidates[0];
}
function inspectFingerprint(caseDir, files, args) {
const problems = [];
const warnings = [];
const allText = files.map(f => readText(f)).join('\n');
const terminalApiMentioned = /toDataURL|getImageData|measureText|getParameter|getSupportedExtensions|getShaderPrecisionFormat|readPixels|getBoundingClientRect|offsetWidth|offsetHeight|requestAdapter|OfflineAudioContext/.test(allText);
const valueReplayMentioned = /fingerprint|回放|replay|fixture|findReplay|installFingerprintValueReplay|fingerprint\.fixture/i.test(allText);
const badRenderLib = /\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas|gl|headless-gl)['"]|from\s+['"](?:canvas|node-canvas|gl|headless-gl)['"]/i.test(allText);
const automationForFingerprint = /\b(playwright|puppeteer|selenium|cloakbrowser|ruyipage|page\.goto|browser\.launch|chromium\.launch)\b/i.test(allText);
const fixtureFile = fingerprintFixturePath(caseDir, args.fingerprintFixture);
let fixtureExists = exists(fixtureFile);
let counts = {};
if (fixtureExists) {
const raw = readJsonSafe(fixtureFile);
if (raw.__error) {
problems.push(`指纹 fixture 解析失败:${raw.__error}`);
} else {
const fp = raw.fingerprint && typeof raw.fingerprint === 'object' ? raw.fingerprint : raw;
counts = {
canvas: countArray(fp.canvas && fp.canvas.toDataURL) + countArray(fp.canvas && fp.canvas.measureText) + countArray(fp.canvas && fp.canvas.getImageData) + countArray(fp.canvas && fp.canvas.toBlob),
webgl: countArray(fp.webgl && fp.webgl.getParameter) + countArray(fp.webgl && fp.webgl.getShaderPrecisionFormat) + countArray(fp.webgl && fp.webgl.readPixels) + (fp.webgl && fp.webgl.getSupportedExtensions ? 1 : 0),
webgpu: countArray(fp.webgpu && fp.webgpu.requestAdapter),
audio: countArray(fp.audio && fp.audio.startRendering) + countArray(fp.audio && fp.audio.getChannelData),
domGeometry: countArray(fp.domGeometry && fp.domGeometry.getBoundingClientRect) + countArray(fp.domGeometry && fp.domGeometry.offset),
};
}
}
if (badRenderLib) problems.push('发现 node-canvas / headless-gl 等渲染库依赖;指纹补环境应优先真实浏览器采样值回放,不要在 Node.js 中强行模拟渲染管线。');
if (automationForFingerprint) problems.push('补环境源码疑似包含浏览器自动化库或 page.goto / browser.launch;自动化只能用于前置取证采样,不能进入最终 env。');
if ((args.requireFingerprintFixture || terminalApiMentioned) && !fixtureExists) {
problems.push(`已涉及或要求指纹终端 API 值回放,但未找到指纹 fixture:${fixtureFile}`);
}
if (terminalApiMentioned && !valueReplayMentioned) {
warnings.push('源码涉及指纹终端 API,但未明显体现 fingerprint fixture / replay;请确认不是静默伪造默认值。');
}
if (args.requireFingerprintFixture && fixtureExists && Object.values(counts).reduce((a, b) => a + b, 0) === 0) {
problems.push('已要求指纹 fixture,但未发现 Canvas / WebGL / WebGPU / Audio / DOM 几何终端 API 采样值。');
}
return {
result: {
required: !!args.requireFingerprintFixture || terminalApiMentioned,
fixture: fixtureExists ? rel(caseDir, fixtureFile) : '',
terminalApiMentioned,
valueReplayMentioned,
badRenderLib,
automationForFingerprint,
counts,
},
problems,
warnings,
};
}
function inspectAddonFirst(files, requireAddonFirst) {
const allText = files.map(f => readText(f)).join('\n');
const result = {
required: !!requireAddonFirst,
usesNativeProtectFallback: /NativeProtect|markNativeFunction\s*\(|markObjectToString\s*\(|setNativeFunc\s*\(|setObjFunc\s*\(|Function\.prototype\.toString|Object\.prototype\.toString/.test(allText),
usesNativeLikeCreation: /\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\(/.test(allText),
directAddonApi: /(?:\b(?:addon|nativeAddon|addonApi)\.|\bxbs\s*\.\s*(?:dom\s*\.\s*)?|\b(?:window|globalThis)\s*\.\s*xbs\s*\.\s*(?:dom\s*\.\s*)?)(?:createNativeFunction|createGetter|createSetter|createNativeObject|createProtoChains|createUndetectable|createInterceptor|createNativeCollection|getMimeTypesAndPlugins|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|getPrivate|setPrivate|hasPrivate|deletePrivate|throwTypeError|createDocument)\s*\(/.test(allText),
addonAwareHelper: /\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\([\s\S]{0,260}\b(?:addon|nativeAddon|options\.addon|addonResult|xbs|options\.xbs|xbsApi)\b/.test(allText),
normalizesAddonResult: /\b(?:normalizeAddon|getAddonApi)\s*\(|\baddonLike\.addon\b|\baddonResult\.addon\b|\baddon\s*&&\s*addon\.addon\b/.test(allText),
loadsAddon: /loadNativeAddon\s*\(|load_native_addon|WEB_JS_ENV_PATCHER_ADDON|WEB_JS_ENV_PATCHER_XBS_ISOLATED_VM|assets[\\/]+native-addon|native-addon|addon\.node|xbs-isolated-vm|isolated_vm\.node|window\.xbs|globalThis\.xbs|require\s*\([^)]*\.node/.test(allText),
acceptsAddonInput: /\boptions\.(?:addon|xbs|xbsIsolatedVmPath)\b|function\s+\w+\s*\([^)]*\b(?:addon|xbs)\b|,\s*(?:addon|xbs)\s*\)|\{\s*(?:addon|xbs)\s*\}/.test(allText),
fallbackDocumented: /fallback|降级|addon 不可用|addon不可用|NativeProtect fallback|JS fallback/.test(allText),
newProtoChainsApi: /\bcreateProtoChains\s*\(\s*\[|\.(?:createProtoChains)\s*\(\s*\[/.test(allText),
oldStyleCreateProtoChains: /\bcreateProtoChains\s*\(\s*['"][^'"]+['"]\s*,/.test(allText),
oldStyleCreateNativeObject: /\bcreateNativeObject\s*\(\s*['"][^'"]+['"]\s*,/.test(allText),
};
result.addonFirstEvidence = result.directAddonApi || result.addonAwareHelper || result.normalizesAddonResult || result.newProtoChainsApi;
result.needsAddonFirst = result.usesNativeProtectFallback || result.usesNativeLikeCreation;
const problems = [];
const warnings = [];
if (requireAddonFirst && result.needsAddonFirst && !result.addonFirstEvidence) {
problems.push('已要求 addon-first,但源码使用 native-like / NativeProtect 相关能力时未发现先尝试 addon.node 或 xbs native API 的证据;创建函数、getter、setter、document.all、原型链等应先走 native API,native 能力不可用时才降级 NativeProtect。');
}
if (requireAddonFirst && result.needsAddonFirst && !result.loadsAddon && !result.acceptsAddonInput) {
problems.push('已要求 addon-first,但源码既未加载 addon.node / xbs isolated-vm,也未显式接收 addon/options.addon/options.xbs;无法保证补环境时优先使用 native API。');
}
if ((requireAddonFirst || result.usesNativeProtectFallback) && result.usesNativeProtectFallback && !result.addonFirstEvidence && !result.fallbackDocumented) {
warnings.push('源码使用 NativeProtect / JS fallback,但未明显记录 addon.node / xbs native API 不可用或调用失败的降级原因;建议输出 native 能力可用性和降级说明。');
}
if (result.oldStyleCreateProtoChains) {
const msg = '发现旧式 createProtoChains(name, chain) 调用;新补环境代码应迁移为 createProtoChains(descriptors),旧式形态只能出现在兼容层并记录迁移 / fallback 原因。';
if (/旧式|兼容|fallback|迁移|历史/.test(allText)) warnings.push(msg);
else problems.push(msg);
}
if (result.oldStyleCreateNativeObject) {
const msg = '发现旧式 createNativeObject(tag, proto, properties) 调用;新补环境代码应使用 createNativeObject(options) 或优先 createProtoChains(descriptors),旧式形态只能出现在兼容层并记录迁移 / fallback 原因。';
if (/旧式|兼容|fallback|迁移|历史/.test(allText)) warnings.push(msg);
else problems.push(msg);
}
return { result, problems, warnings };
}
function check(args) {
if (!args.caseDir && !args.file) throw new Error('必须提供 --case-dir 或 --file');
const caseDir = args.caseDir ? path.resolve(args.caseDir) : path.resolve(path.dirname(args.file), '..', '..');
const root = args.file ? path.dirname(path.resolve(args.file)) : path.join(caseDir, 'result');
const files = args.file ? [path.resolve(args.file)] : codeFiles(root);
const allText = files.map(f => readText(f)).join('\n');
const problems = [];
const warnings = [];
if (!files.length) problems.push(`未找到可检查的补环境源码文件:${root}`);
const checks = {
descriptors: any(/Object\.definePropert(?:y|ies)\s*\(/, files),
prototypeChain: any(/Object\.setPrototypeOf\s*\(|Object\.create\s*\([^\n;]*\.prototype|createProtoChains\s*\(/, files),
functionToString: any(/NativeProtect|Function\.prototype\.toString|createNativeFunction\s*\(|createNativeConstructor\s*\(|markNativeFunction\s*\(|setNativeFunc\s*\(/, files),
accessorToString: any(/createGetter\s*\(|createSetter\s*\(|createNativeGetter\s*\(|createNativeSetter\s*\(|defineNativeGetter\s*\(|defineNativeSetter\s*\(|defineNativeAccessor\s*\(|setNativeFunc\s*\([^\n]*(get|set)\s+/i, files),
instanceToString: any(/setObjFunc\s*\(|markObjectToString\s*\(|Symbol\.toStringTag|createNativeObject\s*\(|createProtoChains\s*\(/, files),
documentAllExact: any(/(?:createUndetectable\s*\(|xbs\s*\.\s*dom\s*\.\s*createDocument\s*\(|\bdom\s*\.\s*createDocument\s*\()/, files),
documentAllMentioned: /document\.all|['"]all['"]/.test(allText),
fingerprintValueReplay: any(/installFingerprintValueReplay|findReplay|fingerprint\.fixture|指纹.*回放|value replay/i, files),
};
if (!checks.descriptors) problems.push('未发现 Object.defineProperty / defineProperties;补环境不能只用普通赋值,必须显式属性描述符。');
if (!checks.prototypeChain) problems.push('未发现 Object.create(...prototype) / Object.setPrototypeOf / createProtoChains;需要按规则补构造函数和原型链。');
if (!checks.functionToString) problems.push('未发现函数 toString 保护(NativeProtect / createNativeFunction / markNativeFunction 等)。');
if (!checks.accessorToString) problems.push('未发现访问器 getter/setter 的 toString 保护(createGetter/createSetter/defineNativeGetter/defineNativeSetter 等)。');
if (!checks.instanceToString) problems.push('未发现实例对象 Object.prototype.toString 保护(Symbol.toStringTag / setObjFunc / createNativeObject 等)。');
if (args.requireDocumentAll && !checks.documentAllExact) problems.push('本 case 要求 document.all,但未发现 xbs.dom.createDocument 或 createUndetectable;document.all 不应仅用普通对象或 undefined 近似,isolated-vm 下优先 xbs.dom.createDocument,手写 document 时才用 xbs/createUndetectable handlers。');
if (!args.requireDocumentAll && checks.documentAllMentioned && !checks.documentAllExact) warnings.push('源码提到 document.all 但未发现 xbs.dom.createDocument 或 createUndetectable;如目标检测 HTMLDDA,isolated-vm 下优先 xbs.dom.createDocument,手写 document 时才用 xbs/createUndetectable handlers。');
const addonFirst = inspectAddonFirst(files, args.requireAddonFirst);
problems.push(...addonFirst.problems);
warnings.push(...addonFirst.warnings);
const ruyi = inspectRuyiTrace(caseDir, args.requireRuyiTrace);
problems.push(...ruyi.problems);
warnings.push(...ruyi.warnings);
const fingerprint = inspectFingerprint(caseDir, files, args);
problems.push(...fingerprint.problems);
warnings.push(...fingerprint.warnings);
return {
caseDir,
checkedRoot: root,
clean: problems.length === 0,
checks,
matching: {
descriptors: matchingFiles(/Object\.definePropert(?:y|ies)\s*\(/, files, caseDir),
prototypeChain: matchingFiles(/Object\.setPrototypeOf\s*\(|Object\.create\s*\([^\n;]*\.prototype|createProtoChains\s*\(/, files, caseDir),
functionToString: matchingFiles(/NativeProtect|Function\.prototype\.toString|createNativeFunction\s*\(|createNativeConstructor\s*\(|markNativeFunction\s*\(|setNativeFunc\s*\(/, files, caseDir),
accessorToString: matchingFiles(/createGetter\s*\(|createSetter\s*\(|createNativeGetter\s*\(|createNativeSetter\s*\(|defineNativeGetter\s*\(|defineNativeSetter\s*\(|defineNativeAccessor\s*\(|setNativeFunc\s*\([^\n]*(get|set)\s+/i, files, caseDir),
instanceToString: matchingFiles(/setObjFunc\s*\(|markObjectToString\s*\(|Symbol\.toStringTag|createNativeObject\s*\(|createProtoChains\s*\(/, files, caseDir),
documentAllExact: matchingFiles(/(?:createUndetectable\s*\(|xbs\s*\.\s*dom\s*\.\s*createDocument\s*\(|\bdom\s*\.\s*createDocument\s*\()/, files, caseDir),
fingerprintValueReplay: matchingFiles(/installFingerprintValueReplay|findReplay|fingerprint\.fixture|指纹.*回放|value replay/i, files, caseDir),
addonFirstEvidence: matchingFiles(/(?:\b(?:addon|nativeAddon|addonApi)\.|\bxbs\s*\.\s*(?:dom\s*\.\s*)?|\b(?:window|globalThis)\s*\.\s*xbs\s*\.\s*(?:dom\s*\.\s*)?)(?:createNativeFunction|createGetter|createSetter|createNativeObject|createProtoChains|createUndetectable|createInterceptor|createNativeCollection|getMimeTypesAndPlugins|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|getPrivate|setPrivate|hasPrivate|deletePrivate|throwTypeError|createDocument)\s*\(|\b(?:createNativeFunction|createNativeConstructor|createNativeGetter|createNativeSetter|defineNativeValue|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createUndetectable|createInterceptor|createNativeObject|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createDocument|getProtoChainRegistry|deleteProtoChainRegistryEntry|clearProtoChainRegistry|setPrivate|getPrivate|hasPrivate|deletePrivate|throwTypeError)\s*\([\s\S]{0,260}\b(?:addon|nativeAddon|options\.addon|addonResult|xbs|options\.xbs|xbsApi)\b|\b(?:normalizeAddon|getAddonApi)\s*\(|\bcreateProtoChains\s*\(\s*\[/, files, caseDir),
},
addonFirst: addonFirst.result,
addonFirstOptOut: args.addonFirstOptOut,
ruyiTrace: ruyi.result,
fingerprint: fingerprint.result,
problems,
warnings,
files: files.map(f => rel(caseDir, f)),
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境真实性与 RuyiTrace 证据检查',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.checkedRoot}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 真实性检查项',
`- 属性描述符:${result.checks.descriptors ? '通过' : '缺失'}`,
`- 原型链 / 构造函数:${result.checks.prototypeChain ? '通过' : '缺失'}`,
`- 函数 toString 保护:${result.checks.functionToString ? '通过' : '缺失'}`,
`- 访问器 toString 保护:${result.checks.accessorToString ? '通过' : '缺失'}`,
`- 实例对象 toString 保护:${result.checks.instanceToString ? '通过' : '缺失'}`,
`- addon-first 规则:${result.addonFirst.required ? (result.addonFirst.addonFirstEvidence ? '已发现先尝试 addon.node / xbs native API 的证据' : '缺失 native 优先证据') : (result.addonFirstOptOut ? '用户明确豁免,未强制检查' : '未强制检查')}`,
` - 直接 addon API:${result.addonFirst.directAddonApi ? '是' : '否'};addon-aware helper:${result.addonFirst.addonAwareHelper ? '是' : '否'};加载 addon/xbs:${result.addonFirst.loadsAddon ? '是' : '否'};接收 addon/xbs 输入:${result.addonFirst.acceptsAddonInput ? '是' : '否'};新版 createProtoChains:${result.addonFirst.newProtoChainsApi ? '是' : '否'}`,
` - 旧式 API:createProtoChains(name, chain)=${result.addonFirst.oldStyleCreateProtoChains ? '发现' : '未发现'};createNativeObject(tag, proto, properties)=${result.addonFirst.oldStyleCreateNativeObject ? '发现' : '未发现'}`,
`- document.all 不可检测对象:${result.checks.documentAllExact ? '已使用 xbs.dom.createDocument / createUndetectable' : (result.checks.documentAllMentioned ? '提到但未精确处理' : '未涉及')}`,
`- 指纹终端 API 值回放:${result.checks.fingerprintValueReplay ? '已体现' : (result.fingerprint.terminalApiMentioned ? '涉及指纹 API 但未明显体现回放' : '未涉及')}`,
'',
'## RuyiTrace 证据检查',
`- 是否要求检查:${result.ruyiTrace.required ? '是' : '否'}`,
`- NDJSON:${result.ruyiTrace.ndjson.length ? result.ruyiTrace.ndjson.join('、') : '未发现'}`,
`- 摘要:${result.ruyiTrace.summary || '未发现'}`,
`- 优先级笔记:${result.ruyiTrace.priority || '未发现'}`,
'',
'## 指纹值回放检查',
`- 是否要求检查:${result.fingerprint.required ? '是' : '否'}`,
`- 指纹 fixture:${result.fingerprint.fixture || '未发现'}`,
`- 是否涉及终端 API:${result.fingerprint.terminalApiMentioned ? '是' : '否'}`,
`- 是否体现 fixture / replay:${result.fingerprint.valueReplayMentioned ? '是' : '否'}`,
`- 是否发现渲染库依赖:${result.fingerprint.badRenderLib ? '是' : '否'}`,
`- 是否发现自动化代码:${result.fingerprint.automationForFingerprint ? '是' : '否'}`,
`- 样本计数:${JSON.stringify(result.fingerprint.counts || {})}`,
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## 检查文件');
for (const f of result.files) lines.push(`- ${f}`);
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,360 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = {
caseDir: '',
requireLive: false,
tlsClient: '',
beforeRealRequest: false,
strictP2: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--require-live') args.requireLive = true;
else if (arg === '--tls-client') args.tlsClient = argv[++i] || '';
else if (arg === '--before-real-request') args.beforeRealRequest = true;
else if (arg === '--strict-p2') args.strictP2 = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_environment_closure.js --case-dir case --before-real-request --markdown
node scripts/check_environment_closure.js --case-dir case --before-real-request --require-live --tls-client curl_cffi --json
说明:统一执行 Trace-runtime、WebAPI 行为、对象形状和 XHR/fetch 请求语义闭环。真实请求前必须使用 --before-real-request,任一组件失败都会阻断。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function stat(file) {
try { return fs.statSync(file); } catch { return null; }
}
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) walk(path.join(root, name), out);
return out;
}
function hasTraceEvidence(caseDir) {
const fixed = [
path.join(caseDir, 'ruyi-trace'),
path.join(caseDir, 'tmp', 'env-trace.jsonl'),
path.join(caseDir, 'tmp', 'missing-env.json'),
path.join(caseDir, 'notes', 'trace-api-inventory.json'),
path.join(caseDir, 'notes', 'trace-runtime-contract.json'),
];
if (fixed.some(exists)) return true;
return walk(caseDir).some(file => {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage|vendor)(\/|$)/i.test(relative)) return false;
return /(?:^|\/)(?:trace|ruyitrace|missing-env)[^/]*\.(?:json|jsonl|ndjson|log|txt)$/i.test(relative);
});
}
function evidenceFiles(caseDir) {
const roots = ['阶段报告', 'notes', 'tmp', 'fixtures', 'ruyi-trace', 'result'];
const files = [];
for (const root of roots) {
for (const file of walk(path.join(caseDir, root))) {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/i.test(relative)) continue;
if (!/\.(?:md|txt|log|json|jsonl|ndjson|js|mjs|cjs|ts|py|html)$/i.test(file)) continue;
files.push(file);
if (files.length >= 2000) return files;
}
}
return files;
}
function scanCaseEvidence(caseDir) {
const resultDir = path.join(caseDir, 'result');
let hasBrowserEnv = false;
let hasNetworkImpl = false;
let networkEvidence = false;
let webapiEvidence = false;
let realmLifecycleEvidence = false;
let domCrudEvidence = false;
let objectShapeEvidence = false;
const hits = {
network: [],
webapi: [],
realmLifecycle: [],
domCrud: [],
objectShape: [],
};
const recordHit = (type, file) => {
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
if (hits[type].length < 8 && !hits[type].includes(relative)) hits[type].push(relative);
};
for (const file of evidenceFiles(caseDir)) {
let text = '';
try { text = fs.readFileSync(file, 'utf8').slice(0, 300000); } catch { continue; }
const relative = (path.relative(caseDir, file) || '.').replace(/\\/g, '/');
const inResult = relative.startsWith('result/');
if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b|network-transcript|xhr-fetch/i.test(text)
|| /(?:browser|node)-network-transcript\.(?:json|ndjson)$/i.test(relative)) {
networkEvidence = true;
recordHit('network', file);
if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasNetworkImpl = true;
}
if (/\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text)
|| /(?:browser|node)-env-detection-(?:baseline|audit)\.json$/i.test(relative)) {
webapiEvidence = true;
recordHit('webapi', file);
if (inResult && /\.(?:js|mjs|cjs|ts|py)$/i.test(file)) hasBrowserEnv = true;
}
if (/iframe|contentWindow|contentDocument|defaultView|\bWorker\b|WorkerGlobalScope|MessageChannel|MessagePort|postMessage|terminate/i.test(text)) {
realmLifecycleEvidence = true;
webapiEvidence = true;
recordHit('realmLifecycle', file);
recordHit('webapi', file);
}
if (/appendChild|removeChild|insertBefore|replaceChild|replaceChildren|cloneNode|importNode|adoptNode|innerHTML|DOMParser|querySelector|MutationObserver|childNodes|getElementsBy/i.test(text)) {
domCrudEvidence = true;
webapiEvidence = true;
recordHit('domCrud', file);
recordHit('webapi', file);
}
if (/Object\.keys|Object\.getOwnPropertyNames|Object\.getOwnPropertyDescriptor|Reflect\.ownKeys|prototypeChain|ownKeys|private-state-leakage/i.test(text)) {
objectShapeEvidence = true;
recordHit('objectShape', file);
}
}
for (const file of walk(resultDir)) {
if (!/\.(js|mjs|cjs|ts|py)$/i.test(file)) continue;
const relative = (path.relative(resultDir, file) || '.').replace(/\\/g, '/');
if (/(^|\/)(node_modules|dist|build|coverage)(\/|$)/i.test(relative)) continue;
let text = '';
try { text = fs.readFileSync(file, 'utf8'); } catch { continue; }
if (/(^|\/)src\/(?:env|node-runtime\/env)\//i.test(relative)
|| /\b(window|document|navigator|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) {
hasBrowserEnv = true;
}
if (/\b(XMLHttpRequest|fetch|sendBeacon|Request|Response|Headers)\b/.test(text)) hasNetworkImpl = true;
}
return {
resultDir,
hasBrowserEnv,
hasNetworkImpl,
networkEvidence,
webapiEvidence,
realmLifecycleEvidence,
domCrudEvidence,
objectShapeEvidence,
hits,
};
}
function collectMetrics(value, out = {}, depth = 0) {
if (!value || typeof value !== 'object' || depth > 6) return out;
const keys = new Set([
'baselineId',
'traceSourceHash',
'contractHash',
'runtimeSourceHash',
'blockingMismatches',
'mismatched',
'networkMismatches',
'extraNodeRequests',
'extraNodeEvents',
'networkAttempts',
'sameSessionVerified',
'roundTripCount',
]);
if (Array.isArray(value)) {
for (const item of value.slice(0, 50)) collectMetrics(item, out, depth + 1);
return out;
}
for (const [key, item] of Object.entries(value)) {
if (keys.has(key) && item !== '' && item !== null && typeof item !== 'undefined') {
if (!out[key]) out[key] = [];
const encoded = typeof item === 'object' ? JSON.stringify(item) : String(item);
if (!out[key].includes(encoded) && out[key].length < 10) out[key].push(encoded);
}
if (item && typeof item === 'object') collectMetrics(item, out, depth + 1);
}
return out;
}
function summarizeComponentData(data) {
if (!data || typeof data !== 'object') return data;
return {
schemaVersion: data.schemaVersion || '',
clean: data.clean,
passed: data.passed,
metrics: collectMetrics(data),
summary: data.summary && typeof data.summary === 'object' ? data.summary : undefined,
problems: Array.isArray(data.problems) ? data.problems.slice(0, 20) : [],
warnings: Array.isArray(data.warnings) ? data.warnings.slice(0, 10) : [],
};
}
function runJson(scriptDir, script, args) {
const command = [path.join(scriptDir, script), ...args, '--json'];
const ret = spawnSync(process.execPath, command, {
encoding: 'utf8',
windowsHide: true,
timeout: 180000,
});
let rawData = null;
try { rawData = JSON.parse(String(ret.stdout || '').trim()); } catch {}
return {
script,
exitCode: ret.status,
clean: ret.status === 0 && rawData && rawData.clean !== false && rawData.passed !== false,
data: summarizeComponentData(rawData),
stderr: String(ret.stderr || '').trim().slice(0, 2000),
stdoutPreview: rawData ? '' : String(ret.stdout || '').trim().slice(0, 2000),
};
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const scriptDir = __dirname;
const signals = scanCaseEvidence(caseDir);
const trace = hasTraceEvidence(caseDir);
const components = [];
if (trace) {
const traceArgs = ['--case-dir', caseDir, '--require-runtime-closure'];
if (args.beforeRealRequest) traceArgs.push('--require-stage-audit');
components.push(runJson(scriptDir, 'check_trace_api_coverage.js', traceArgs));
}
if (signals.hasBrowserEnv
|| signals.webapiEvidence
|| signals.objectShapeEvidence
|| trace
|| args.beforeRealRequest) {
components.push(runJson(scriptDir, 'check_object_shape_audit.js', ['--case-dir', caseDir, '--require']));
components.push(runJson(scriptDir, 'check_webapi_env_detection_matrix.js', ['--case-dir', caseDir, '--require']));
}
if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence || args.beforeRealRequest) {
components.push(runJson(scriptDir, 'check_code_quality.js', ['--case-dir', caseDir]));
}
if (signals.hasBrowserEnv || signals.hasNetworkImpl || signals.webapiEvidence) {
components.push(runJson(scriptDir, 'check_webapi_addon_coverage.js', ['--case-dir', caseDir, '--warnings-as-errors']));
}
if (signals.hasNetworkImpl || signals.networkEvidence || args.requireLive) {
const semanticsArgs = ['--case-dir', caseDir, '--require', '--require-no-send', '--out', path.join(caseDir, 'tmp', 'xhr-fetch-semantics-audit.json')];
components.push(runJson(scriptDir, 'check_xhr_fetch_semantics.js', semanticsArgs));
const bridgeArgs = ['--case-dir', caseDir];
if (args.requireLive || args.beforeRealRequest) bridgeArgs.push('--require-live');
if (args.tlsClient) bridgeArgs.push('--tls-client', args.tlsClient);
components.push(runJson(scriptDir, 'check_xhr_fetch_session_bridge.js', bridgeArgs));
}
const problems = [];
for (const component of components) {
if (!component.clean) {
const detail = component.data && Array.isArray(component.data.problems)
? component.data.problems.slice(0, 20).join(';')
: (component.stderr || component.stdoutPreview || `exit=${component.exitCode}`);
problems.push(`${component.script} 未通过:${detail}`);
}
}
if (args.beforeRealRequest && !components.length) problems.push('真实请求前没有触发任何环境闭环组件,说明 case 结构或检测信号缺失');
return {
schemaVersion: 'environment-closure/v3',
generatedBy: 'check_environment_closure.js',
caseDir,
clean: problems.length === 0,
beforeRealRequest: args.beforeRealRequest,
requireLive: args.requireLive,
tlsClient: args.tlsClient || '',
signals: {
trace,
browserEnv: signals.hasBrowserEnv,
networkImpl: signals.hasNetworkImpl,
networkEvidence: signals.networkEvidence,
webapiEvidence: signals.webapiEvidence,
realmLifecycleEvidence: signals.realmLifecycleEvidence,
domCrudEvidence: signals.domCrudEvidence,
objectShapeEvidence: signals.objectShapeEvidence,
evidenceFiles: signals.hits,
},
components,
problems,
};
}
function renderMarkdown(result) {
const lines = [
'# 补环境闭环总门禁',
'',
`- case:${result.caseDir}`,
`- 真实请求前模式:${result.beforeRealRequest ? '是' : '否'}`,
`- 要求 live session:${result.requireLive ? '是' : '否'}`,
`- TLS 客户端:${result.tlsClient || '未指定'}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
'',
'## 检测信号',
`- Trace:${result.signals.trace ? '是' : '否'}`,
`- 浏览器环境实现:${result.signals.browserEnv ? '是' : '否'}`,
`- XHR/fetch 实现:${result.signals.networkImpl ? '是' : '否'}`,
`- 网络证据:${result.signals.networkEvidence ? '是' : '否'}`,
`- Realm / 消息生命周期证据:${result.signals.realmLifecycleEvidence ? '是' : '否'}`,
`- DOM CRUD 证据:${result.signals.domCrudEvidence ? '是' : '否'}`,
'',
'## 组件',
];
if (!result.components.length) lines.push('- 无');
for (const component of result.components) {
lines.push(`- ${component.script}:${component.clean ? '通过' : '失败'},exit=${component.exitCode}`);
const metrics = component.data && component.data.metrics ? component.data.metrics : {};
const metricText = Object.entries(metrics)
.map(([key, values]) => `${key}=${values.join('|')}`)
.join(';');
if (metricText) lines.push(` ${metricText}`);
}
if (result.problems.length) {
lines.push('', '## 阻断问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
return `${lines.join('\n')}\n`;
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { check };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,632 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: null,
file: null,
requireFinalSummary: true,
finalSummaryOptOut: false,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i];
else if (a === '--file' || a === '-f') args.file = argv[++i];
else if (a === '--no-require-final-summary' || a === '--allow-no-final-summary') {
args.requireFinalSummary = false;
args.finalSummaryOptOut = true;
}
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_final_artifact.js --case-dir case --markdown
node scripts/check_final_artifact.js --case-dir case --no-require-final-summary --markdown
node scripts/check_final_artifact.js --case-dir case --file case/result/final.js --json
说明:检查最终交付项目是否是规范目录、只有一个执行入口、无浏览器自动化代码,并确认入口链路最终由已确认的 TLS 指纹兼容 Node.js / Python Session 请求客户端发起少量模拟请求,或明确不发真实请求;同时检查是否硬编码 / 复用了 cURL 或 fixture 中的样本加密参数值。最终总结 result/最终项目总结.md 是默认硬性要求,且必须包含 Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、对象形状审计矩阵、WebAPI 环境检测矩阵和高强度环境检测覆盖矩阵;只有用户明确要求不生成最终总结时,才允许传入 --no-require-final-summary 并在阶段输出中记录原因。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function readText(p) {
return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
}
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
}
out.push(p);
return out;
}
function rel(root, p) {
return (path.relative(root, p) || '.').replace(/\\/g, '/');
}
function ext(p) {
return path.extname(p).toLowerCase();
}
const AUTOMATION_PATTERNS = [
/\bruyipage\b/i,
/\bFirefoxPage\b/,
/\bRuyiTrace\b/i,
/\bplaywright\b/i,
/\bpuppeteer\b/i,
/\bpyppeteer\b/i,
/\bselenium\b/i,
/\bwebdriver\b/i,
/\bCloakBrowser\b/i,
/\bcloakbrowser\b/i,
/\bcamoufox\b/i,
/\bcamoufox_reverse_mcp\b/i,
/\bCamoufox\b/,
/\bAsyncCamoufox\b/,
/\bbrowser\.new_page\b/i,
/\bbrowser\.newPage\b/i,
/\blaunch_browser\s*\(/i,
/\bnetwork_capture\s*\(/i,
/\bbrowser-use\b/i,
/\bchromium\.launch\b/i,
/\bfirefox\.launch\b/i,
/\bbrowser\.launch\b/i,
/\bpage\.goto\s*\(/i,
/\bpage\.capture\b/i,
/\bCDP\b/,
/\bMarionette\b/i,
];
const JS_REQUEST_PATTERNS = [
/\bcycleTLS\s*\(/i,
/\bCycleTLS\b/,
/\bcycletls\b/i,
/\bimpers\b/i,
/\bimpersFetch\b/i,
/\bcurl-cffi\b/i,
/\bcurl-cffi-node\b/i,
/require\s*\(\s*['"]curl-cffi['"]\s*\)/,
/from\s+['"]curl-cffi['"]/,
/\bCurlSession\b/,
/\bCurlRequest\b/,
/\breq\.request\s*\(/,
];
const REQUEST_SESSION_PATTERNS = [
/\bcreateRequestSession\s*\(/i,
/\bcreate_request_session\s*\(/i,
/\bSession\s*\(/,
/\brequests\.Session\s*\(/,
/\bCurlSession\b/,
/\bCookieJar\b/i,
/\btough-cookie\b/i,
/\bcookieJar\b/,
/\bsession\.request\s*\(/i,
/\bsession\.close\s*\(/i,
/\bjar\.setCookie/i,
/Set-Cookie/i,
/销毁\s*session/i,
/清理\s*Cookie\s*jar/i,
];
const PY_REQUEST_PATTERNS = [
/\bcurl_cffi\b/,
/\bcffi_curl\b/,
/\bcyCronet\b/i,
/\bcycronet\b/i,
];
const NO_REAL_REQUEST_PATTERNS = [
/不发真实请求/,
/不发送真实请求/,
/只输出本地\s*(sign|参数)/i,
/仅输出本地\s*(sign|参数)/i,
/\bnoRealRequest\b/i,
/\bno-real-request\b/i,
/\bdryRunOnly\b/i,
/\blocalSignOnly\b/i,
];
const FINGERPRINT_RENDER_PATTERNS = [
/\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas|gl|headless-gl)['"]|from\s+['"](?:canvas|node-canvas|gl|headless-gl)['"]/i,
/__WEB_JS_ENV_PATCHER_FINGERPRINT__/,
/generate_fingerprint_hook/i,
];
function findMatches(text, patterns) {
const hits = [];
for (const pattern of patterns) {
const m = text.match(pattern);
if (m) hits.push(pattern.toString());
}
return hits;
}
function isTextLikeFile(p) {
return ['.js', '.mjs', '.cjs', '.py', '.json', '.md', '.txt', '.yaml', '.yml', '.curl', '.http', '.har'].includes(ext(p));
}
function isCodeLikeFile(p) {
return ['.js', '.mjs', '.cjs', '.py', '.json'].includes(ext(p));
}
function isTempOrTestFile(p) {
const n = path.basename(p).toLowerCase();
const normalized = String(p).replace(/\\/g, '/').toLowerCase();
if (/(^|\/)(tmp|\.tmp|temp|\.temp|cache|\.cache|browser-profile|firefox-profile|chrome-profile|profile|screenshots|hooks|ruyi-trace|logs|trace)(\/|$)/.test(normalized)) return true;
if (/\.(tmp|temp|log|jsonl|har|png|jpg|jpeg|webp|trace|cache|bak|old|orig|retry|partial|download|crdownload)$/i.test(n)) return true;
if (/^(test-|tmp-|temp-|debug-|scratch-)/i.test(n)) return true;
if (/\b(test|spec|debug|scratch|trace|hook|fixture|mock)\b/i.test(n)) return true;
return false;
}
function isRootSecondEntry(resultDir, p, primary) {
if (path.dirname(p) !== resultDir) return false;
if (path.resolve(p) === path.resolve(primary)) return false;
const n = path.basename(p).toLowerCase();
if (n === 'package.json' || n === 'requirements.txt' || n === 'config.example.json') return false;
return ['.js', '.mjs', '.cjs', '.py'].includes(ext(p));
}
function inspectPackageJson(resultDir, problems, warnings) {
const pkg = path.join(resultDir, 'package.json');
if (!exists(pkg)) return;
let data;
try {
data = JSON.parse(readText(pkg));
} catch (err) {
problems.push(`package.json 解析失败:${err.message}`);
return;
}
const scripts = data.scripts || {};
for (const [name, cmd] of Object.entries(scripts)) {
const lowerName = name.toLowerCase();
const lowerCmd = String(cmd).toLowerCase();
if (lowerName === 'start') {
if (!/\bnode\s+(\.\/)?final\.js\b/.test(lowerCmd)) warnings.push('package.json 的 start 脚本建议只指向 node final.js');
} else if (/(test|debug|dev|server|serve|watch|browser|playwright|puppeteer)/i.test(lowerName + ' ' + lowerCmd)) {
problems.push(`package.json 存在非交付用途脚本:${name}=${cmd}`);
}
}
}
const CRYPTO_PARAM_NAME_RE = /^(?:sign|signature|_signature|x[-_]?sign|x[-_]?s|x[-_]?t|a[_-]?bogus|h5st|mtgsig|w[_-]?rid|x[-_]?bogus|x[-_]?(?:ladon|argus|gorgon|khronos|ss[-_]?stub|mini[-_]?wua|umt)|token|access[-_]?token|auth[-_]?token|x[-_]?token|csrf[-_]?token|csrftoken|verify|digest|hash|hmac|mac|nonce|sig)$/i;
function isPlaceholderValue(value) {
const s = String(value || '');
return !s || s.length < 8 || /^(?:xxx+|test+|example|placeholder|todo|replace|redacted|your[_-]?)/i.test(s) || /^[*]+$/.test(s);
}
function isLikelyConcatenationFragment(value) {
const s = String(value || '');
const t = s.trim();
return /(?:^|\s)\+\s*[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*(?:\s*\+\s*)?$/.test(s)
|| /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)*\s*\+(?:\s|$)/.test(t)
|| (/^\+/.test(t) || /\+$/.test(t)) && /\s/.test(s);
}
function maskCryptoValue(value) {
const s = String(value || '');
if (s.length <= 8) return '*'.repeat(s.length);
return `${s.slice(0, 4)}...${s.slice(-4)}(len=${s.length})`;
}
function addSampleCryptoValue(out, name, value, sourceFile, sourcePath) {
if (!CRYPTO_PARAM_NAME_RE.test(String(name || ''))) return;
if (isPlaceholderValue(value)) return;
const key = `${String(name).toLowerCase()}:${String(value)}`;
if (out.has(key)) return;
out.set(key, { name: String(name), value: String(value), masked: maskCryptoValue(value), sourceFile, sourcePath });
}
function decodeURIComponentSafe(s) { try { return decodeURIComponent(s); } catch { return s; } }
function extractJsonCryptoValues(obj, out, sourceFile, prefix = 'json') {
if (!obj || typeof obj !== 'object') return;
for (const [k, v] of Object.entries(obj)) {
const p = `${prefix}.${k}`;
if (v && typeof v === 'object') extractJsonCryptoValues(v, out, sourceFile, p);
else addSampleCryptoValue(out, k, v, sourceFile, p);
}
}
function extractSampleCryptoValuesFromText(text, sourceFile) {
const out = new Map();
const urlRe = /https?:\/\/[^\s'"<>]+/g;
let m;
while ((m = urlRe.exec(text))) {
try {
const u = new URL(m[0]);
for (const [k, v] of u.searchParams.entries()) addSampleCryptoValue(out, k, v, sourceFile, `query.${k}`);
} catch (_) {}
}
try { extractJsonCryptoValues(JSON.parse(text), out, sourceFile, 'json'); } catch (_) {}
const pairRe = /["']?([A-Za-z_$][\w$-]{0,80})["']?\s*(?::|=)\s*["']([^"'\r\n&;,}\s]{8,})["']/g;
while ((m = pairRe.exec(text))) addSampleCryptoValue(out, m[1], m[2], sourceFile, `pair.${m[1]}`);
const formRe = /(?:^|[?&\s])([A-Za-z_$][\w$-]{0,80})=([^&\s'"<>]{8,})/g;
while ((m = formRe.exec(text))) addSampleCryptoValue(out, m[1], decodeURIComponentSafe(m[2]), sourceFile, `form.${m[1]}`);
const headerRe = /(?:^|\s)(?:-H|--header)\s+['"]([^:'"]{1,100})\s*:\s*([^'"\r\n]+)['"]/g;
while ((m = headerRe.exec(text))) {
const headerName = m[1].trim();
const headerValue = m[2].trim();
addSampleCryptoValue(out, headerName, headerValue, sourceFile, `header.${headerName}`);
if (headerName.toLowerCase() === 'cookie') {
for (const part of headerValue.split(';')) {
const idx = part.indexOf('=');
if (idx !== -1) {
const cookieName = part.slice(0, idx).trim();
const cookieValue = part.slice(idx + 1).trim();
addSampleCryptoValue(out, cookieName, cookieValue, sourceFile, `cookie.${cookieName}`);
}
}
}
}
return [...out.values()];
}
function collectSampleCryptoValues(caseDir) {
const roots = ['requests', 'fixtures'].map(d => path.join(caseDir, d)).filter(exists);
const values = [];
for (const root of roots) {
for (const f of walk(root).filter(p => stat(p) && stat(p).isFile() && isTextLikeFile(p))) {
for (const item of extractSampleCryptoValuesFromText(readText(f), rel(caseDir, f))) values.push(item);
}
}
return values;
}
function inspectReusedSampleCryptoValues(caseDir, resultFiles, textFiles) {
const sampleValues = collectSampleCryptoValues(caseDir);
const reused = [];
for (const sample of sampleValues) {
for (const f of textFiles) {
const text = readText(f);
if (text.includes(sample.value)) reused.push({ file: rel(caseDir, f), name: sample.name, value: sample.masked, source: sample.sourceFile, sourcePath: sample.sourcePath });
}
}
const hardcoded = [];
const hardcodedRe = /["']?([A-Za-z_$][\w$-]{0,80})["']?\s*(?::|=)\s*["']([^"'\r\n]{8,})["']/g;
for (const f of resultFiles.filter(isCodeLikeFile)) {
const text = readText(f);
let m;
while ((m = hardcodedRe.exec(text))) {
const name = m[1];
const value = m[2];
if (CRYPTO_PARAM_NAME_RE.test(name) && !isPlaceholderValue(value) && !isLikelyConcatenationFragment(value)) {
hardcoded.push({ file: rel(caseDir, f), name, value: maskCryptoValue(value) });
}
}
}
const generationEvidence = [];
const generationRe = /install.*env|补环境|signer|makeSign|generate(?:Sign|Token|Params?)|compute(?:Sign|Token|Params?)|build(?:Sign|Token|Params?)|runTarget|target[\\/]entry|src[\\/]signer|vm\.Script|load.*(?:bundle|target|js)|createContext/i;
for (const f of resultFiles.filter(isCodeLikeFile)) {
const text = readText(f);
if (generationRe.test(text)) generationEvidence.push(rel(caseDir, f));
}
return {
sampleValues: sampleValues.map(x => ({ name: x.name, value: x.masked, source: x.sourceFile, sourcePath: x.sourcePath })),
reused,
hardcoded,
generationEvidence,
};
}
function inspectStageReports(caseDir) {
const stageDir = path.join(caseDir, '阶段报告');
const result = { dir: stageDir, present: exists(stageDir), files: [], initialPresent: false, chineseFileNames: true, mojibakeSuspected: false };
const problems = [];
const warnings = [];
if (!result.present) {
problems.push('缺少阶段报告目录 case/阶段报告;至少应在前置阶段生成中文命名阶段报告 01-需求信息确认.md。');
return { result, problems, warnings };
}
let names = [];
try { names = fs.readdirSync(stageDir); } catch { names = []; }
for (const name of names) {
const file = path.join(stageDir, name);
const st = stat(file);
if (!st || !st.isFile() || ext(file) !== '.md') continue;
const item = { file, chineseFileName: /[\u4e00-\u9fff]/.test(name), utf8Readable: false, mojibakeSuspected: false };
if (!item.chineseFileName) result.chineseFileNames = false;
try {
const text = readText(file);
item.utf8Readable = true;
const questionRuns = text.match(/\?{3,}/g) || [];
item.mojibakeSuspected = text.includes('\uFFFD') || (questionRuns.reduce((n, x) => n + x.length, 0) >= 8 && !/[\u4e00-\u9fff]/.test(text));
if (item.mojibakeSuspected) result.mojibakeSuspected = true;
} catch (_) {}
result.files.push(item);
}
result.initialPresent = result.files.some(x => path.basename(x.file) === '01-需求信息确认.md');
if (!result.files.length) problems.push('case/阶段报告 中没有 Markdown 阶段报告。');
if (!result.initialPresent) problems.push('缺少前置阶段报告:case/阶段报告/01-需求信息确认.md。');
if (!result.chineseFileNames) problems.push('阶段报告文件名必须包含中文,不能只使用英文文件名。');
if (result.mojibakeSuspected) problems.push('阶段报告疑似存在中文乱码或连续问号。');
return { result, problems, warnings };
}
const FINAL_SUMMARY_REQUIRED_SECTIONS = [
/阶段报告索引/,
/native addon\s*\/\s*NativeProtect 使用情况/i,
/环境与指纹\s*API\s*调用回放明细/i,
/Trace-runtime 可执行闭环/i,
/XHR\/fetch Session Bridge/i,
/XHR\/fetch 请求语义审计/i,
/对象形状审计矩阵与私有状态泄露/,
/WebAPI 环境检测矩阵/,
/高强度环境检测覆盖矩阵/,
/加密参数生成与样本复用检查/,
/代码质量与中文注释/,
/最终交付结构/,
/指纹基线一致性/,
/Session\s*请求链|Session 模式|TLS 请求验证与 Session 请求链/i,
/测试结果/,
/清理结果/,
];
function inspectFinalSummary(resultDir, requireFinalSummary) {
const finalSummary = path.join(resultDir, '最终项目总结.md');
const legacyFinalSummary = path.join(resultDir, 'final-summary.md');
const result = {
required: !!requireFinalSummary,
file: exists(finalSummary) ? finalSummary : '',
legacyFile: exists(legacyFinalSummary) ? legacyFinalSummary : '',
present: exists(finalSummary),
utf8Readable: false,
mojibakeSuspected: false,
missingSections: [],
};
const problems = [];
const warnings = [];
if (!requireFinalSummary) return { result, problems, warnings };
if (!result.present) {
if (exists(legacyFinalSummary)) problems.push('最终总结必须使用中文文件名 result/最终项目总结.md;当前只检测到旧文件名 result/final-summary.md,请改名或重新写入。');
else problems.push('项目完成后必须默认生成最终总结 result/最终项目总结.md;只有用户明确要求不生成时才可跳过,并需运行检查脚本时传入 --no-require-final-summary。');
return { result, problems, warnings };
}
let text = '';
try {
text = readText(finalSummary);
result.utf8Readable = true;
} catch (err) {
problems.push(`最终总结无法按 UTF-8 读取:${err.message || String(err)}`);
return { result, problems, warnings };
}
if (/\uFFFD/.test(text) || /\?{6,}/.test(text) || (!/[\u4e00-\u9fff]/.test(text) && /[?]{3,}/.test(text))) {
result.mojibakeSuspected = true;
problems.push('最终总结疑似存在中文编码乱码或连续问号,请使用 write_markdown_utf8.js 重新生成 UTF-8 Markdown。');
}
for (const pattern of FINAL_SUMMARY_REQUIRED_SECTIONS) {
if (!pattern.test(text)) result.missingSections.push(pattern.toString());
}
if (result.missingSections.length) {
problems.push(`最终总结缺少必要章节:${result.missingSections.join('、')}。项目完成后的总结必须包含 native addon / NativeProtect 使用情况、指纹基线一致性、环境与指纹 API 调用回放明细、Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、对象形状审计矩阵与私有状态泄露、WebAPI 环境检测矩阵、高强度环境检测覆盖矩阵、最终请求 Session 请求链、加密参数生成与样本复用检查、代码质量与中文注释、最终交付结构、测试结果和清理结果。`);
}
if (!/^#\s+/.test(text.trim())) warnings.push('最终总结建议以一级标题开头。');
return { result, problems, warnings };
}
function check(args) {
if (!args.caseDir && !args.file) throw new Error('必须提供 --case-dir 或 --file');
const caseDir = args.caseDir ? path.resolve(args.caseDir) : path.resolve(path.dirname(args.file), '..');
const resultDir = path.join(caseDir, 'result');
const problems = [];
const warnings = [];
if (!exists(caseDir)) problems.push(`case 目录不存在:${caseDir}`);
if (!exists(resultDir)) problems.push(`结果目录不存在:${resultDir}`);
const candidate = args.file ? path.resolve(args.file) : null;
let primary = candidate;
if (!primary && exists(resultDir)) {
const candidates = ['final.js', 'final.py'].map(name => path.join(resultDir, name)).filter(exists);
if (candidates.length === 1) primary = candidates[0];
else if (candidates.length === 0) problems.push('未找到唯一执行入口:result/final.js 或 result/final.py');
else problems.push('同时存在 final.js 和 final.py;最终项目只能有一个执行入口');
}
const resultFiles = exists(resultDir) ? walk(resultDir).filter(p => stat(p) && stat(p).isFile()) : [];
const textFiles = resultFiles.filter(isTextLikeFile);
const codeFiles = resultFiles.filter(isCodeLikeFile);
if (primary && !exists(primary)) problems.push(`指定执行入口不存在:${primary}`);
if (primary && exists(primary)) {
const primaryExt = ext(primary);
if (!['.js', '.py'].includes(primaryExt)) problems.push('执行入口必须是 final.js 或 final.py');
if (path.dirname(primary) !== resultDir) warnings.push('执行入口不在 case/result/ 目录中,建议移动到 result/final.js 或 result/final.py');
const text = readText(primary);
if (primaryExt === '.js' && !/require\.main\s*===\s*module|import\.meta\.url|main\s*\(\s*\)\.catch|await\s+main\s*\(/.test(text)) {
warnings.push('未检测到清晰的 Node.js 直接运行入口;建议提供 main() 并在命令行运行时调用');
}
if (primaryExt === '.py' && !/if\s+__name__\s*==\s*['"]__main__['"]/.test(text)) {
warnings.push('未检测到 Python 直接运行入口 if __name__ == "__main__"');
}
const rootSecondEntries = resultFiles.filter(p => isRootSecondEntry(resultDir, p, primary));
if (rootSecondEntries.length) {
problems.push(`结果目录根部存在多个疑似执行入口:${rootSecondEntries.map(p => rel(caseDir, p)).join('、')}`);
}
const requestPatterns = primaryExt === '.py' ? PY_REQUEST_PATTERNS : JS_REQUEST_PATTERNS;
const requestSearchFiles = codeFiles.filter(p => primaryExt === '.py' ? ext(p) === '.py' : ['.js', '.mjs', '.cjs'].includes(ext(p)));
const requestHits = [];
const noRealRequestHits = [];
for (const f of requestSearchFiles) {
const text = readText(f);
const hits = findMatches(text, requestPatterns);
if (hits.length) requestHits.push({ file: rel(caseDir, f), hits });
const noRealHits = findMatches(text, NO_REAL_REQUEST_PATTERNS);
if (noRealHits.length) noRealRequestHits.push({ file: rel(caseDir, f), hits: noRealHits });
}
const sessionHits = [];
for (const f of requestSearchFiles) {
const text = readText(f);
const hits = findMatches(text, REQUEST_SESSION_PATTERNS);
if (hits.length) sessionHits.push({ file: rel(caseDir, f), hits });
}
if (!requestHits.length && !noRealRequestHits.length) {
problems.push('未检测到已确认的 TLS 指纹兼容请求客户端(Node.js CycleTLS / impers / curl-cffi-node,或 Python curl_cffi / cffi_curl / cyCronet);最终验证不能依赖普通 fetch/requests 或浏览器自动化。如用户不发真实请求,入口必须明确只输出本地 sign / 参数。');
}
if (requestHits.length && !noRealRequestHits.length && !sessionHits.length) {
problems.push('最终请求必须使用 Session 模式:即使只有一个请求,也要创建 session client,复用 Cookie jar / Header / UA / Client Hints / TLS 指纹,并在成功或失败后销毁 session。未检测到 createRequestSession / requests.Session / CookieJar / session.close 等证据。');
}
}
const automationHits = [];
for (const f of codeFiles) {
const hits = findMatches(readText(f), AUTOMATION_PATTERNS);
if (hits.length) automationHits.push({ file: rel(caseDir, f), hits });
}
if (automationHits.length) {
problems.push(`最终项目源码疑似包含浏览器自动化 / 取证代码:${automationHits.map(x => `${x.file}(${x.hits.join('、')})`).join(';')}`);
}
const fingerprintRenderHits = [];
for (const f of codeFiles) {
const hits = findMatches(readText(f), FINGERPRINT_RENDER_PATTERNS);
if (hits.length) fingerprintRenderHits.push({ file: rel(caseDir, f), hits });
}
if (fingerprintRenderHits.length) {
problems.push(`最终项目源码疑似包含指纹采样 Hook 或 Node.js 渲染库:${fingerprintRenderHits.map(x => `${x.file}(${x.hits.join('、')})`).join(';')}。指纹应由真实浏览器采样 fixture + 终端 API 值回放实现,采样 Hook 不得进入 result/。`);
}
const reuse = exists(resultDir) ? inspectReusedSampleCryptoValues(caseDir, resultFiles, textFiles) : { sampleValues: [], reused: [], hardcoded: [], generationEvidence: [] };
if (reuse.reused.length) {
problems.push(`最终项目疑似直接复用了请求样本 / fixture 中的加密参数值:${reuse.reused.map(x => `${x.file} 中 ${x.name}=${x.value}(来源 ${x.source}:${x.sourcePath})`).join(';')}。这些值只能作为 expected fixture,必须通过补环境重新生成。`);
}
if (reuse.hardcoded.length) {
problems.push(`最终项目疑似硬编码加密参数字面量:${reuse.hardcoded.map(x => `${x.file} 中 ${x.name}=${x.value}`).join(';')}。应调用补环境后的目标 JS 入口或 signer 模块生成,不能写死 cURL 里的值。`);
}
if (reuse.sampleValues.length && !reuse.generationEvidence.length) {
warnings.push('发现请求样本中存在可疑加密参数,但最终项目未明显体现补环境 / signer / 目标 JS 入口调用痕迹;请人工确认不是直接复用样本参数。');
}
const tempLike = resultFiles.filter(p => isTempOrTestFile(rel(resultDir, p)));
if (tempLike.length) problems.push(`结果目录存在临时 / 测试 / 调试产物:${tempLike.map(p => rel(caseDir, p)).join('、')}`);
inspectPackageJson(resultDir, problems, warnings);
const stageReports = inspectStageReports(caseDir);
problems.push(...stageReports.problems);
warnings.push(...stageReports.warnings);
const finalSummary = exists(resultDir) ? inspectFinalSummary(resultDir, args.requireFinalSummary) : {
result: { required: !!args.requireFinalSummary, file: '', present: false, utf8Readable: false, mojibakeSuspected: false, missingSections: [] },
problems: [],
warnings: [],
};
problems.push(...finalSummary.problems);
warnings.push(...finalSummary.warnings);
return {
caseDir,
resultDir,
entryFile: primary || null,
clean: problems.length === 0,
problems,
warnings,
reusedCryptoCheck: reuse,
finalSummary: finalSummary.result,
stageReports: stageReports.result,
finalSummaryOptOut: args.finalSummaryOptOut,
resultFiles: resultFiles.map(p => rel(caseDir, p)),
};
}
function renderMarkdown(result) {
const lines = [
'# 最终项目检查结果',
'',
`case 目录:${result.caseDir}`,
`结果目录:${result.resultDir}`,
`唯一执行入口:${result.entryFile || '未找到'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 检查项',
`- 是否只有一个执行入口:${result.problems.some(p => p.includes('执行入口')) ? '否' : '是'}`,
`- 是否不含浏览器自动化代码:${result.problems.some(p => p.includes('自动化')) ? '否' : '是'}`,
`- 是否检测到 TLS 指纹兼容请求客户端或明确不发真实请求:${result.problems.some(p => p.includes('TLS 指纹兼容请求客户端')) ? '否' : '是'}`,
`- 是否使用 Session 模式并具备销毁逻辑:${result.problems.some(p => p.includes('Session 模式')) ? '否' : '是'}`,
`- 是否不含指纹采样 Hook / Node.js 渲染库:${result.problems.some(p => p.includes('指纹采样 Hook') || p.includes('渲染库')) ? '否' : '是'}`,
`- 是否未复用 cURL / fixture 中的加密参数样本值:${result.reusedCryptoCheck.reused.length || result.reusedCryptoCheck.hardcoded.length ? '否' : '是'}`,
`- 是否已生成中文命名最终总结且包含 API 调用回放明细、Trace-runtime 闭环、XHR/fetch Session Bridge、请求语义审计、对象形状审计矩阵、WebAPI 环境检测矩阵和高强度检测矩阵:${result.finalSummary.required ? (result.finalSummary.present && !result.finalSummary.mojibakeSuspected && !result.finalSummary.missingSections.length ? '是' : '否') : (result.finalSummaryOptOut ? '用户明确豁免' : '未强制检查')}`,
`- result 目录是否无临时 / 测试产物:${result.problems.some(p => p.includes('临时') || p.includes('测试')) ? '否' : '是'}`,
'',
'## 样本加密参数复用检查',
`- 样本中发现的可疑加密参数值数量:${result.reusedCryptoCheck.sampleValues.length}`,
`- 直接复用样本值:${result.reusedCryptoCheck.reused.length ? '是' : '否'}`,
`- 硬编码加密参数字面量:${result.reusedCryptoCheck.hardcoded.length ? '是' : '否'}`,
`- 补环境 / signer 生成痕迹:${result.reusedCryptoCheck.generationEvidence.length ? result.reusedCryptoCheck.generationEvidence.join('、') : '未明显发现'}`,
'',
'## 阶段报告检查',
`- 目录:${result.stageReports.dir}`,
`- 是否存在 01-需求信息确认.md:${result.stageReports.initialPresent ? '是' : '否'}`,
`- 文件名是否均含中文:${result.stageReports.chineseFileNames ? '是' : '否'}`,
`- 疑似乱码:${result.stageReports.mojibakeSuspected ? '是' : '否'}`,
`- 报告数量:${result.stageReports.files.length}`,
'',
'## 最终总结检查',
`- 默认要求:${result.finalSummary.required ? '是' : '否'}`,
`- 文件:${result.finalSummary.file ? rel(result.resultDir, result.finalSummary.file) : '未发现,应为 最终项目总结.md'}`,
`- UTF-8 可读:${result.finalSummary.utf8Readable ? '是' : '否'}`,
`- 疑似乱码:${result.finalSummary.mojibakeSuspected ? '是' : '否'}`,
`- 缺少章节:${result.finalSummary.missingSections.length ? result.finalSummary.missingSections.join('、') : '无'}`,
'',
];
if (result.problems.length) {
lines.push('## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
lines.push('');
}
if (result.warnings.length) {
lines.push('## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
lines.push('');
}
lines.push('## result 文件列表');
if (result.resultFiles.length) for (const f of result.resultFiles) lines.push(`- ${f}`);
else lines.push('- 无');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { check, extractSampleCryptoValuesFromText };

View File

@@ -0,0 +1,518 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { caseDir: '', fixture: '', envFile: '', require: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--fixture') args.fixture = argv[++i] || '';
else if (a === '--env-file') args.envFile = argv[++i] || '';
else if (a === '--require') args.require = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_fingerprint_fixture.js --case-dir case --require canvas,webgl --markdown
node scripts/check_fingerprint_fixture.js --fixture case/fixtures/fingerprint.fixture.json --env-file case/result/src/env/fingerprint-env.js --json
说明:检查浏览器指纹 fixture 是否覆盖 Canvas / WebGL / WebGPU / Audio / DOM 几何等终端 API,是否绑定同一 fingerprint baseline,并检查最终 env 是否避免 node-canvas / headless-gl / 自动化浏览器等错误方向。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function readJson(p) { return JSON.parse(readText(p)); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) walk(path.join(p, name), out);
} else if (st.isFile()) out.push(p);
return out;
}
function countArray(v) { return Array.isArray(v) ? v.length : 0; }
function hasResultObject(v) { return !!v && typeof v === 'object' && Object.prototype.hasOwnProperty.call(v, 'result'); }
function pickBaselineId(obj) {
if (!obj || typeof obj !== 'object') return '';
return String(obj.baselineId || (obj.source && obj.source.baselineId) || '');
}
const BAD_VALUE_SOURCE_RE = /(AI\s*(?:推断|猜|生成|guess)|猜测|编造|伪造|默认值|随机值|mock|fake|placeholder|static\s*analysis|静态分析|inferred|guessed|random\s*value|jsdom|node-canvas|headless-gl)/i;
const REAL_VALUE_SOURCE_RE = /(RuyiTrace|NDJSON|ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
const TRACE_TOOL_RE = /(RuyiTrace|NDJSON)/i;
const AUTOMATION_OR_USER_TOOL_RE = /(ruyiPage|Camoufox|CloakBrowser|手动|真实浏览器|browser|Hook|HAR|MCP|用户提供|console|network)/i;
const LONG_VALUE_THRESHOLD = 3900;
const BAD_TRUNCATED_KEY_RE = /^(visiblePreview|visibleSha256|sha256OfVisible|visibleValue|visibleSnippet|preview|clippedPreview)$/i;
const BAD_TRUNCATED_TEXT_RE = /(\.\.\.<(?:clipped|truncated)>|<clipped>|<truncated>|__ruyiTraceLongString__|sha256OfVisible|visibleSha256|visiblePreview|realLength["']?\s*[:=]\s*["']?unknown)/i;
const CHUNK_ENCODING_RE = /^(string-chunks|base64-chunks)$/;
function jsonText(v) {
try { return JSON.stringify(v || {}); } catch { return String(v || ''); }
}
function sourceLabel(source) {
if (!source || typeof source !== 'object') return '';
return [source.capturedBy, source.tool, source.mode, source.sourceType, source.type, source.origin, source.evidence]
.filter(Boolean).map(String).join(' ');
}
function traceStatusOf(source, record) {
return String((record && record.traceStatus) || (source && source.traceStatus) || '');
}
function hasSourceMeta(source) {
if (!source || typeof source !== 'object') return false;
return !!(source.capturedBy || source.tool || source.mode || source.sourceType || source.type || source.origin || source.evidence);
}
function isTruncatedTraceStatus(status) {
const s = String(status || '');
if (/used-untruncated|untruncated|not-truncated/i.test(s)) return false;
return /(^|[-_\s])truncated($|[-_\s])|疑似截断|截断/i.test(s);
}
function isTraceValueSource(source, record) {
const status = traceStatusOf(source, record);
const label = sourceLabel(source);
if (/used-untruncated/i.test(status)) return true;
if (/truncated|missing|not-covered|baseline-conflict|unused/i.test(status) && AUTOMATION_OR_USER_TOOL_RE.test(label) && !TRACE_TOOL_RE.test(String(source && source.capturedBy || ''))) return false;
return TRACE_TOOL_RE.test(label);
}
function collectStringLengths(value, out = []) {
if (typeof value === 'string') out.push(value.length);
else if (Array.isArray(value)) for (const item of value) collectStringLengths(item, out);
else if (value && typeof value === 'object') for (const item of Object.values(value)) collectStringLengths(item, out);
return out;
}
function isChunkedValue(value) {
return !!(
value &&
typeof value === 'object' &&
CHUNK_ENCODING_RE.test(String(value.encoding || '')) &&
Array.isArray(value.chunks)
);
}
function hasOwn(obj, key) {
return !!(obj && typeof obj === 'object' && Object.prototype.hasOwnProperty.call(obj, key));
}
function hasLengthAndHashOn(value, record, source) {
if (isChunkedValue(value)) {
return Number.isFinite(value.valueLength) && typeof value.sha256 === 'string' && /^[a-f0-9]{64}$/i.test(value.sha256);
}
if (value && typeof value === 'object') {
const metrics = collectValueMetrics(value);
if (metrics.chunked.length) {
return metrics.chunked.every(info =>
Number.isFinite(info.value.valueLength) &&
typeof info.value.sha256 === 'string' &&
/^[a-f0-9]{64}$/i.test(info.value.sha256)
);
}
}
return (
(hasOwn(record, 'valueLength') || hasOwn(source, 'valueLength')) &&
(hasOwn(record, 'sha256') || hasOwn(source, 'sha256'))
);
}
function collectValueMetrics(value, out = { maxLen: 0, chunked: [], badMarkers: [] }, pathParts = []) {
const label = pathParts.join('.') || 'result';
if (typeof value === 'string') {
out.maxLen = Math.max(out.maxLen, value.length);
if (BAD_TRUNCATED_TEXT_RE.test(value)) out.badMarkers.push(`${label} 包含截断标记或可见片段标记`);
} else if (Array.isArray(value)) {
value.forEach((item, idx) => collectValueMetrics(item, out, pathParts.concat(String(idx))));
} else if (value && typeof value === 'object') {
if (isChunkedValue(value)) {
const joined = value.chunks.join('');
out.maxLen = Math.max(out.maxLen, Number(value.valueLength) || joined.length);
out.chunked.push({ path: label, value, joinedLength: joined.length });
}
for (const [key, item] of Object.entries(value)) {
if (BAD_TRUNCATED_KEY_RE.test(key)) out.badMarkers.push(`${label}.${key} 使用了 Trace 可见片段字段,不能作为最终值`);
collectValueMetrics(item, out, pathParts.concat(key));
}
}
return out;
}
function validateChunkedValue(itemPath, chunk) {
const problems = [];
const warnings = [];
const encoding = String(chunk.encoding || '');
const joined = Array.isArray(chunk.chunks) ? chunk.chunks.join('') : '';
if (!CHUNK_ENCODING_RE.test(encoding)) problems.push(`样本 ${itemPath} 的分片 encoding 不合法:${encoding || '未指定'}。`);
if (!Array.isArray(chunk.chunks) || chunk.chunks.some(v => typeof v !== 'string')) problems.push(`样本 ${itemPath} 的 chunks 必须是字符串数组。`);
if (!Number.isFinite(chunk.valueLength)) problems.push(`样本 ${itemPath} 的分片结果缺少数值型 valueLength。`);
if (!Number.isFinite(chunk.chunkSize) || chunk.chunkSize <= 0) problems.push(`样本 ${itemPath} 的分片结果缺少有效 chunkSize。`);
if (chunk.truncated !== false) problems.push(`样本 ${itemPath} 的分片结果必须显式 truncated:false。`);
if (Number.isFinite(chunk.valueLength) && joined.length !== chunk.valueLength) {
problems.push(`样本 ${itemPath} 的 chunks 拼接长度 ${joined.length} 与 valueLength ${chunk.valueLength} 不一致。`);
}
if (Number.isFinite(chunk.chunkSize)) {
const tooLarge = (chunk.chunks || []).find(v => v.length > chunk.chunkSize);
if (tooLarge) warnings.push(`样本 ${itemPath} 存在超过 chunkSize 的分片;请确认分片写入逻辑没有异常。`);
}
if (typeof chunk.sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(chunk.sha256)) {
problems.push(`样本 ${itemPath} 的长指纹分片缺少完整 sha256;必须先在真实浏览器采样 Hook 中 finalize,再写入 fixture。`);
}
if (BAD_TRUNCATED_TEXT_RE.test(joined)) problems.push(`样本 ${itemPath} 的分片拼接结果包含截断标记,不能作为最终值。`);
return { problems, warnings };
}
function collectResultRecords(node, out = [], pathParts = []) {
if (!node || typeof node !== 'object') return out;
if (Object.prototype.hasOwnProperty.call(node, 'result')) {
out.push({ path: pathParts.join('.') || '$', record: node, value: node.result });
}
for (const [key, value] of Object.entries(node)) {
if (key === 'source' || key === 'valueSource' || key === 'match') continue;
if (value && typeof value === 'object') collectResultRecords(value, out, pathParts.concat(key));
}
return out;
}
function validateValueSources(fp) {
const problems = [];
const warnings = [];
const sourceSummary = {
totalRecords: 0,
inheritedSource: 0,
explicitSource: 0,
traceSource: 0,
sampledSource: 0,
longValueRecords: 0,
chunkedLongValueRecords: 0,
blockedTruncatedResultRecords: 0,
badSourceRecords: 0
};
const rootSource = fp && fp.source;
function validateSource(source, label, record, inherited) {
if (!source || typeof source !== 'object') {
problems.push(`${label} 缺少真实值来源 source / capturedBy;必须优先使用未截断 Trace,Trace 不可用时用已确认取证工具采样,不能由 AI 猜值。`);
return;
}
const sourceText = jsonText(source);
const labelText = sourceLabel(source);
const isFixtureRoot = label === 'fixture.source';
if (!hasSourceMeta(source)) problems.push(`${label} 的 source 缺少 mode / capturedBy / tool / sourceType 等来源字段。`);
if (!REAL_VALUE_SOURCE_RE.test(sourceText)) problems.push(`${label} 的 source 未体现真实浏览器证据来源;请记录 RuyiTrace 未截断值,或 ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样来源。`);
if (BAD_VALUE_SOURCE_RE.test(sourceText)) {
if (!isFixtureRoot) sourceSummary.badSourceRecords += 1;
problems.push(`${label} 的 source 疑似为 AI 猜值、静态分析、默认值、随机值、mock 值或 Node.js 模拟库结果,不能作为最终指纹回放值。`);
}
const status = traceStatusOf(source, record);
if (!status) warnings.push(`${label} 缺少 traceStatus;建议记录 used-untruncated / unused / missing / not-covered / truncated / baseline-conflict,明确是否优先检查 Trace。`);
if (!isFixtureRoot && isTraceValueSource(source, record)) sourceSummary.traceSource += 1;
if (!isFixtureRoot && AUTOMATION_OR_USER_TOOL_RE.test(labelText)) sourceSummary.sampledSource += 1;
}
validateSource(rootSource, 'fixture.source', null, false);
const records = collectResultRecords(fp);
sourceSummary.totalRecords = records.length;
for (const item of records) {
const source = item.record.source || item.record.valueSource || rootSource;
const inherited = !(item.record.source || item.record.valueSource);
if (inherited) sourceSummary.inheritedSource += 1;
else sourceSummary.explicitSource += 1;
validateSource(source, `样本 ${item.path}`, item.record, inherited);
const metrics = collectValueMetrics(item.value);
const maxLen = metrics.maxLen;
if (maxLen >= LONG_VALUE_THRESHOLD) sourceSummary.longValueRecords += 1;
if (metrics.chunked.length) sourceSummary.chunkedLongValueRecords += metrics.chunked.length;
for (const marker of metrics.badMarkers) {
sourceSummary.blockedTruncatedResultRecords += 1;
problems.push(`样本 ${item.path} ${marker};不能把 RuyiTrace 可见片段、clip 片段或未知真实长度写入最终 fixture。`);
}
for (const chunkInfo of metrics.chunked) {
const chunkResult = validateChunkedValue(`${item.path}.${chunkInfo.path}`, chunkInfo.value);
problems.push(...chunkResult.problems);
warnings.push(...chunkResult.warnings);
}
const valueIsMarkedTruncated = item.record.truncated === true || (source && source.truncated === true);
const status = traceStatusOf(source, item.record);
if (valueIsMarkedTruncated) problems.push(`样本 ${item.path} 标记为 truncated=true,不能作为最终回放值;请用同一 baseline 下的取证工具补采完整值。`);
if (isTraceValueSource(source, item.record) && (maxLen >= LONG_VALUE_THRESHOLD || isTruncatedTraceStatus(status) || valueIsMarkedTruncated)) {
sourceSummary.blockedTruncatedResultRecords += 1;
problems.push(`样本 ${item.path} 来自 Trace 且疑似长字段截断;不能把 RuyiTrace 4000 / 4096 可见片段作为完整指纹值,请用同一 baseline 下的 ruyiPage / Camoufox / CloakBrowser / 手动浏览器补采完整值。`);
}
if (maxLen >= LONG_VALUE_THRESHOLD && inherited) {
problems.push(`样本 ${item.path} 是长字符串 / 大结果但未记录每条样本自己的 source、valueLength 和 sha256;请补充完整浏览器采样来源,避免误把 Trace 4000 / 4096 字符可见片段当成真实值。`);
}
if (maxLen >= LONG_VALUE_THRESHOLD && !hasLengthAndHashOn(item.value, item.record, source)) {
problems.push(`样本 ${item.path} 长度达到 ${maxLen},但缺少完整 valueLength 与 sha256;长指纹必须通过真实浏览器完整采样或分片保存并记录完整 hash。`);
}
}
return { problems, warnings, sourceSummary };
}
function inspectFixture(file) {
const problems = [];
const warnings = [];
const counts = {
canvasToDataURL: 0,
canvasToBlob: 0,
canvasMeasureText: 0,
canvasGetImageData: 0,
webglGetParameter: 0,
webglGetSupportedExtensions: 0,
webglGetExtension: 0,
webglGetShaderPrecisionFormat: 0,
webglReadPixels: 0,
webgpuRequestAdapter: 0,
audioStartRendering: 0,
audioGetChannelData: 0,
domGetBoundingClientRect: 0,
domOffset: 0,
};
let fixture = null;
if (!file || !exists(file)) {
problems.push(`未找到指纹 fixture:${file || '未指定'}`);
return { fixture, counts, problems, warnings };
}
try { fixture = readJson(file); } catch (err) {
problems.push(`指纹 fixture JSON 解析失败:${err.message}`);
return { fixture, counts, problems, warnings };
}
const fp = fixture.fingerprint && typeof fixture.fingerprint === 'object' ? fixture.fingerprint : fixture;
counts.canvasToDataURL = countArray(fp.canvas && fp.canvas.toDataURL);
counts.canvasToBlob = countArray(fp.canvas && fp.canvas.toBlob);
counts.canvasMeasureText = countArray(fp.canvas && fp.canvas.measureText);
counts.canvasGetImageData = countArray(fp.canvas && fp.canvas.getImageData);
counts.webglGetParameter = countArray(fp.webgl && fp.webgl.getParameter);
counts.webglGetSupportedExtensions = hasResultObject(fp.webgl && fp.webgl.getSupportedExtensions) ? 1 : countArray(fp.webgl && fp.webgl.getSupportedExtensions);
counts.webglGetExtension = countArray(fp.webgl && fp.webgl.getExtension);
counts.webglGetShaderPrecisionFormat = countArray(fp.webgl && fp.webgl.getShaderPrecisionFormat);
counts.webglReadPixels = countArray(fp.webgl && fp.webgl.readPixels);
counts.webgpuRequestAdapter = countArray(fp.webgpu && fp.webgpu.requestAdapter);
counts.audioStartRendering = countArray(fp.audio && fp.audio.startRendering);
counts.audioGetChannelData = countArray(fp.audio && fp.audio.getChannelData);
counts.domGetBoundingClientRect = countArray(fp.domGeometry && fp.domGeometry.getBoundingClientRect);
counts.domOffset = countArray(fp.domGeometry && fp.domGeometry.offset);
const baselineId = pickBaselineId(fp);
if (!baselineId) problems.push('指纹 fixture 缺少 baselineId;必须先创建 case/notes/fingerprint-baseline.json,并让 fixture 绑定同一 baselineId。');
if (!fp.source) problems.push('指纹 fixture 缺少 source 字段;必须记录真实值来源,优先使用未截断 Trace,Trace 不可用时使用已确认取证工具采样。');
const sourceResult = validateValueSources(fp);
problems.push(...sourceResult.problems);
warnings.push(...sourceResult.warnings);
return { fixture: fp, baselineId, counts, problems, warnings, sourceSummary: sourceResult.sourceSummary };
}
function inspectBaseline(caseDir, fixtureResult) {
const problems = [];
const warnings = [];
const baselineFile = path.join(caseDir, 'notes', 'fingerprint-baseline.json');
const result = { file: baselineFile, present: exists(baselineFile), baselineId: '', conflicts: [] };
if (!result.present) {
problems.push('缺少指纹基线文件 case/notes/fingerprint-baseline.json;涉及指纹采样时必须先固定同一 case 的 fingerprint baseline。');
return { result, problems, warnings };
}
let baseline;
try { baseline = readJson(baselineFile); } catch (err) {
problems.push(`指纹基线 JSON 解析失败:${err.message}`);
return { result, problems, warnings };
}
result.baselineId = String(baseline.baselineId || '');
if (!result.baselineId) problems.push('fingerprint-baseline.json 缺少 baselineId。');
if (fixtureResult.baselineId && result.baselineId && fixtureResult.baselineId !== result.baselineId) {
problems.push(`指纹 fixture baselineId 与基线不一致:fixture=${fixtureResult.baselineId},baseline=${result.baselineId}。不得混用不同随机指纹样本。`);
}
const fp = fixtureResult.fixture || {};
const source = fp.source || {};
const checks = [
['userAgent', source.userAgent, baseline.navigator && baseline.navigator.userAgent],
['timezone', source.timezone, baseline.network && baseline.network.timezone],
['locale', source.locale, baseline.network && baseline.network.locale],
];
for (const [name, a, b] of checks) {
if (a && b && String(a) !== String(b)) result.conflicts.push({ field: name, fixture: String(a), baseline: String(b) });
}
if (result.conflicts.length) {
problems.push(`指纹 fixture 与 baseline 核心字段冲突:${result.conflicts.map(x => `${x.field}: fixture=${x.fixture}, baseline=${x.baseline}`).join(';')}。请重新采样或生成新 baseline。`);
}
return { result, problems, warnings };
}
const REQUIRE_RULES = {
canvas: ['canvasToDataURL', 'canvasMeasureText', 'canvasGetImageData'],
webgl: ['webglGetParameter', 'webglGetSupportedExtensions', 'webglGetShaderPrecisionFormat', 'webglReadPixels'],
webgpu: ['webgpuRequestAdapter'],
audio: ['audioStartRendering', 'audioGetChannelData'],
'dom-geometry': ['domGetBoundingClientRect', 'domOffset'],
dom: ['domGetBoundingClientRect', 'domOffset'],
font: ['canvasMeasureText', 'domOffset'],
fonts: ['canvasMeasureText', 'domOffset'],
};
const BAD_RENDER_PATTERNS = [
{ name: 'node-canvas/canvas', pattern: /\b(require|import)\s*\(?\s*['"](?:canvas|node-canvas)['"]|from\s+['"](?:canvas|node-canvas)['"]/i },
{ name: 'headless-gl/gl', pattern: /\b(require|import)\s*\(?\s*['"](?:gl|headless-gl)['"]|from\s+['"](?:gl|headless-gl)['"]/i },
{ name: '浏览器自动化', pattern: /\b(playwright|puppeteer|selenium|cloakbrowser|ruyipage|page\.goto|browser\.launch|chromium\.launch)\b/i },
];
function inspectEnvCode(files, root) {
const problems = [];
const warnings = [];
const hits = [];
for (const file of files) {
if (!exists(file)) continue;
const text = readText(file);
for (const item of BAD_RENDER_PATTERNS) {
if (item.pattern.test(text)) hits.push({ file: rel(root, file), type: item.name });
}
if (/toDataURL|getImageData|measureText|getParameter|getBoundingClientRect/.test(text) && !/fingerprint|回放|replay|fixture/i.test(text)) {
warnings.push(`${rel(root, file)} 涉及指纹终端 API,但未明显体现 fixture / replay;请确认不是在 Node.js 中伪造渲染过程。`);
}
}
if (hits.length) {
problems.push(`发现不推荐的指纹实现方向:${hits.map(h => `${h.file}(${h.type})`).join('、')}。应使用真实浏览器采样值回放,不要在最终项目中依赖渲染库或自动化浏览器计算指纹。`);
}
return { problems, warnings, hits };
}
function defaultFixture(caseDir) {
const candidates = [
path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'),
path.join(caseDir, 'fixtures', 'sample.fixture.json'),
];
return candidates.find(exists) || candidates[0];
}
function defaultEnvFiles(caseDir) {
const result = path.join(caseDir, 'result');
return walk(result).filter(p => ['.js', '.mjs', '.cjs'].includes(ext(p)));
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : (args.fixture ? path.resolve(path.dirname(args.fixture), '..') : process.cwd());
const fixtureFile = args.fixture ? path.resolve(args.fixture) : defaultFixture(caseDir);
const envFiles = args.envFile ? [path.resolve(args.envFile)] : defaultEnvFiles(caseDir);
const required = String(args.require || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const problems = [];
const warnings = [];
const fixtureResult = inspectFixture(fixtureFile);
problems.push(...fixtureResult.problems);
warnings.push(...fixtureResult.warnings);
for (const item of required) {
const rules = REQUIRE_RULES[item] || [];
if (!rules.length) warnings.push(`未知 require 类型:${item}`);
const ok = rules.some(k => fixtureResult.counts[k] > 0);
if (!ok) problems.push(`要求 ${item} 指纹样本,但 fixture 中未发现对应终端 API 返回值。`);
}
const baselineResult = inspectBaseline(caseDir, fixtureResult);
problems.push(...baselineResult.problems);
warnings.push(...baselineResult.warnings);
const envResult = inspectEnvCode(envFiles, caseDir);
problems.push(...envResult.problems);
warnings.push(...envResult.warnings);
return {
caseDir,
fixtureFile,
envFiles: envFiles.map(p => rel(caseDir, p)),
required,
clean: problems.length === 0,
baseline: baselineResult.result,
fixtureBaselineId: fixtureResult.baselineId || '',
counts: fixtureResult.counts,
sourceSummary: fixtureResult.sourceSummary || {
totalRecords: 0,
inheritedSource: 0,
explicitSource: 0,
traceSource: 0,
sampledSource: 0,
longValueRecords: 0,
chunkedLongValueRecords: 0,
blockedTruncatedResultRecords: 0,
badSourceRecords: 0
},
badImplementationHits: envResult.hits,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 指纹 fixture 与回放实现检查',
'',
`case 目录:${result.caseDir}`,
`fixture:${result.fixtureFile}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 指纹基线',
`- baseline 文件:${result.baseline.file}`,
`- baseline 是否存在:${result.baseline.present ? '是' : '否'}`,
`- baselineId:${result.baseline.baselineId || '未发现'}`,
`- fixture baselineId:${result.fixtureBaselineId || '未发现'}`,
`- 核心字段冲突:${result.baseline.conflicts.length ? '是' : '否'}`,
'',
'## 样本覆盖统计',
];
for (const [k, v] of Object.entries(result.counts)) lines.push(`- ${k}:${v}`);
lines.push('', '## 指纹值来源检查');
lines.push(`- result 样本数量:${result.sourceSummary.totalRecords}`);
lines.push(`- 显式 source 样本:${result.sourceSummary.explicitSource}`);
lines.push(`- 继承全局 source 样本:${result.sourceSummary.inheritedSource}`);
lines.push(`- Trace 来源样本:${result.sourceSummary.traceSource}`);
lines.push(`- 自动化 / 手动浏览器采样来源样本:${result.sourceSummary.sampledSource}`);
lines.push(`- 长字段样本:${result.sourceSummary.longValueRecords}`);
lines.push(`- 分片长字段样本:${result.sourceSummary.chunkedLongValueRecords}`);
lines.push(`- 被阻断的截断 / 可见片段样本:${result.sourceSummary.blockedTruncatedResultRecords}`);
lines.push(`- 疑似猜值 / 默认值 / 模拟库来源样本:${result.sourceSummary.badSourceRecords}`);
lines.push('', '## 检查的 env 文件');
if (result.envFiles.length) for (const f of result.envFiles) lines.push(`- ${f}`);
else lines.push('- 未发现 result 下的 JS env 文件');
if (result.problems.length) {
lines.push('', '## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,236 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { input: null, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input' || a === '-i') args.input = argv[++i];
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:\n node scripts/check_intake.js --input task.md --markdown\n node scripts/check_intake.js --input task.md --json\n\n不提供 --input 时会从标准输入读取内容。`;
}
function readInput(input) {
if (input) return fs.readFileSync(input, 'utf8');
return fs.readFileSync(0, 'utf8');
}
function cleanValue(v) {
if (!v) return '';
return v.trim().replace(/^`+|`+$/g, '').trim();
}
function isPlaceholder(v) {
const s = cleanValue(v).toLowerCase();
return !s || ['无', '暂无', '未知', '不确定', 'todo', 'tbd', 'n/a', 'na', 'none', 'null', '-', ':', ':'].includes(s) || /^[.。…]+$/.test(s);
}
function lineValue(text, labels) {
const lines = text.split(/\r?\n/);
for (const line of lines) {
const trimmed = line.trim();
for (const label of labels) {
const pattern = new RegExp(`^(?:[-*+]\\s*)?(?:\\[[ xX]\\]\\s*)?${label}\\s*[::]\\s*(.+?)\\s*$`, 'i');
const m = trimmed.match(pattern);
if (m && !isPlaceholder(m[1])) return cleanValue(m[1]);
}
}
return '';
}
function firstUrlNear(text, labels) {
const direct = lineValue(text, labels);
if (direct) {
const m = direct.match(/https?:\/\/[^\s`'"<>]+/i);
if (m) return m[0];
if (/^[\w.-]+\.[a-z]{2,}/i.test(direct)) return direct;
}
for (const label of labels) {
const re = new RegExp(`${label}[^\n\r]*(https?:\\/\\/[^\\s\`'"<>]+)`, 'i');
const m = text.match(re);
if (m) return m[1];
}
const domain = text.match(/(?:目标|网站|站点|页面|target)[^\n\r]{0,40}?([A-Za-z0-9.-]+\.[A-Za-z]{2,}(?:\/[^\s`'"<>]*)?)/i);
if (domain) return domain[1];
return '';
}
function detectMethod(text) {
const direct = lineValue(text, ['请求方法', 'Method', 'HTTP Method', 'Request Method']);
const src = direct || text;
const m = src.match(/\b(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\b/i);
return m ? m[1].toUpperCase() : '';
}
function detectLocation(text) {
const direct = lineValue(text, ['参数出现位置', '参数位置', '加密参数位置', 'Parameter Location', 'Location']);
const src = direct || text;
const locations = ['Query', 'Header', 'Body', 'Cookie'];
for (const loc of locations) {
if (new RegExp(`\\b${loc}\\b`, 'i').test(src)) return loc;
}
const zh = [ ['query', /查询|URL参数|URL 参数/i], ['header', /请求头|Header/i], ['body', /请求体|Body|表单|JSON/i], ['cookie', /Cookie/i] ];
for (const [name, re] of zh) if (re.test(src)) return name[0].toUpperCase() + name.slice(1);
return '';
}
function detectParam(text) {
const direct = lineValue(text, ['目标加密参数名', '目标加密参数', '加密参数名', '加密参数', '参数名', 'Encrypted Parameter', 'Parameter']);
if (direct) {
const m = direct.match(/[A-Za-z_$][\w$-]{0,80}/);
if (m) return m[0];
return direct;
}
const common = ['__zse_ck', 'zse_ck', 'a_bogus', 'h5st', 'x-s', 'x-t', 'x-sign', 'sign', 'signature', 'token'];
for (const p of common) {
if (new RegExp(`(^|[^\\w-])${p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^\\w-]|$)`, 'i').test(text)) return p;
}
return '';
}
function hasRequestSample(text) {
if (/copy\s+as\s+curl/i.test(text) && /(已提供|提供|粘贴|curl)/i.test(text)) return true;
if (/\bcurl\b\s+['"]?https?:\/\//i.test(text)) return true;
if (/\bcurl\b[\s\S]{0,300}https?:\/\//i.test(text)) return true;
if (/^(GET|POST|PUT|DELETE|PATCH)\s+https?:\/\//im.test(text)) return true;
const v = lineValue(text, ['成功请求样本', '请求样本', 'Request Sample', 'cURL', 'curl']);
return !!v && !/未提供|没有|无/.test(v);
}
function hasResponseSample(text) {
if (/响应样本[::]\s*(已提供|有|是)/i.test(text)) return true;
if (/```json[\s\S]*?```/i.test(text) && /响应|response/i.test(text)) return true;
return false;
}
function hasKnownJs(text) {
if (/https?:\/\/[^\s`'"<>]+\.js(?:\?[^\s`'"<>]*)?/i.test(text)) return true;
if (/JS\s*文件[::]\s*(已提供|有|是)/i.test(text)) return true;
return false;
}
function detectAcquisitionMode(text) {
const direct = lineValue(text, ['取证模式', '浏览器取证模式', '浏览器模式', '取证工具', '浏览器工具', 'Ruyi 工具选择', 'Ruyi工具选择', 'Acquisition Mode']);
const checked = text.match(/^\s*[-*+]\s*\[[xX]\]\s*(ruyiPage\s*\+\s*RuyiTrace|仅\s*ruyiPage|只用\s*ruyiPage|Camoufox\s*\+\s*camoufox-reverse-mcp|仅\s*Camoufox|只用\s*Camoufox|CloakBrowser|用户手动取证|手动取证|AI\s*自行决定)[^\n\r]*/im);
const src = direct || (checked ? checked[1] : '');
if (/ruyiPage\s*\+\s*RuyiTrace|ruyiPage.*RuyiTrace|如意\s*Trace/i.test(src)) return 'ruyiPage + RuyiTrace';
if (/仅\s*ruyiPage|只用\s*ruyiPage|ruyiPage/i.test(src)) return '仅 ruyiPage';
if (/Camoufox\s*\+\s*camoufox-reverse-mcp|Camoufox.*MCP|camoufox-reverse-mcp/i.test(src)) return 'Camoufox + camoufox-reverse-mcp';
if (/仅\s*Camoufox|只用\s*Camoufox|Camoufox/i.test(src)) return '仅 Camoufox';
if (/CloakBrowser/i.test(src)) return 'CloakBrowser';
if (/用户手动取证|手动取证|离线材料|手动提供|手动浏览器/i.test(src)) return '用户手动取证';
if (/AI\s*自行决定|自行决定|你来决定|自动选择/i.test(src)) return 'AI 自行决定';
if (/(选择|使用|采用|取证模式是|用)\s*(ruyiPage\s*\+\s*RuyiTrace|ruyiPage.*RuyiTrace)/i.test(text)) return 'ruyiPage + RuyiTrace';
if (/(选择|使用|采用|取证模式是|用)\s*(仅\s*)?ruyiPage/i.test(text)) return '仅 ruyiPage';
if (/(选择|使用|采用|取证模式是|用).*(Camoufox\s*\+\s*camoufox-reverse-mcp|Camoufox.*MCP|camoufox-reverse-mcp)/i.test(text)) return 'Camoufox + camoufox-reverse-mcp';
if (/(选择|使用|采用|取证模式是|用).*(仅\s*)?Camoufox/i.test(text)) return '仅 Camoufox';
if (/(选择|使用|采用|取证模式是|用)\s*CloakBrowser/i.test(text)) return 'CloakBrowser';
if (/(选择|使用|采用|取证模式是|用).*(用户手动取证|手动取证|离线材料|手动提供)/i.test(text)) return '用户手动取证';
if (/(选择|使用|采用|取证模式是|用).*(AI\s*自行决定|自行决定|你来决定|自动选择)/i.test(text)) return 'AI 自行决定';
return '';
}
function detectTlsClientStrategy(text) {
const direct = lineValue(text, ['最终请求 TLS 指纹兼容客户端', 'TLS 指纹兼容客户端', 'TLS 请求客户端', '最终请求客户端', '请求客户端', 'TLS Strategy', 'TLS Client']);
const src = direct || text;
if (/Node\.?js.*CycleTLS|CycleTLS|cycleTls|cycletls/i.test(src)) return 'Node.js CycleTLS';
if (/Node\.?js.*impers|\bimpers\b/i.test(src)) return 'Node.js impers';
if (/Node\.?js.*curl-cffi|curl-cffi-node|curl-cffi/i.test(src)) return 'Node.js curl-cffi';
if (/Python.*curl_cffi|curl_cffi/i.test(src)) return 'Python curl_cffi';
if (/Python.*cffi_curl|cffi_curl/i.test(src)) return 'Python cffi_curl';
if (/Python.*cyCronet|cyCronet|cycronet/i.test(src)) return 'Python cyCronet';
if (/不发真实请求|不发送真实请求|只输出本地\s*(sign|参数)|仅输出本地\s*(sign|参数)|只做本地验证/i.test(src)) return '不发真实请求,只输出本地 sign / 参数';
return '';
}
function analyze(text) {
const fields = {
targetSiteUrl: firstUrlNear(text, ['目标网站 URL', '目标网站', '目标站点', '目标页面', '网站 URL', 'Target Website', 'Target Site', 'Page URL']),
apiUrl: firstUrlNear(text, ['目标 API URL', '目标 API', '目标接口 API', '目标接口', '接口 API', '请求 URL', 'API URL', 'Request URL', 'Target API']),
method: detectMethod(text),
encryptedParam: detectParam(text),
paramLocation: detectLocation(text),
requestSample: hasRequestSample(text),
responseSample: hasResponseSample(text),
knownJsAssets: hasKnownJs(text),
loginRequired: /需要登录[::]?\s*(是|true|yes)|登录后|已登录|login required/i.test(text),
authScopeMentioned: /授权|测试权限|permission|authorized/i.test(text),
acquisitionMode: detectAcquisitionMode(text),
tlsClientStrategy: detectTlsClientStrategy(text),
};
// If API URL duplicated from target site and a more specific URL exists in curl, use the curl URL as apiUrl.
const curlUrl = (text.match(/\bcurl\b[\s\S]{0,500}?(https?:\/\/[^\s`'"<>]+)/i) || [])[1];
if (curlUrl && (!fields.apiUrl || fields.apiUrl === fields.targetSiteUrl)) fields.apiUrl = curlUrl;
if (fields.apiUrl === fields.targetSiteUrl && !/(目标接口|接口\s*(API|URL)|API\s*(URL|地址)?|请求\s*URL|Request URL|Target API)/i.test(text)) fields.apiUrl = '';
const required = [
['targetSiteUrl', '目标网站/页面 URL'],
['apiUrl', '目标接口 API URL'],
['method', '请求方法'],
['encryptedParam', '目标加密参数名'],
['paramLocation', '参数出现位置 Query/Header/Body/Cookie'],
['requestSample', '成功请求样本(优先 Copy as cURL 或 HAR)'],
['acquisitionMode', '取证模式(ruyiPage + RuyiTrace / 仅 ruyiPage / Camoufox + camoufox-reverse-mcp / 仅 Camoufox / CloakBrowser / 用户手动取证 / AI 自行决定)'],
['tlsClientStrategy', '最终请求 TLS 指纹兼容客户端(Node.js CycleTLS / Node.js impers / Node.js curl-cffi / Python curl_cffi / Python cffi_curl / Python cyCronet / 不发真实请求)'],
];
const missing = required.filter(([key]) => !fields[key]).map(([, label]) => label);
const complete = missing.length === 0;
return { complete, missing, fields };
}
function renderMarkdown(result) {
const f = result.fields;
const yesNo = v => v ? '是' : '否';
const lines = [];
lines.push('# Web JS 补环境信息完整性检查结果');
lines.push('');
lines.push(`- 是否满足开始前置分析的最低要求:${result.complete ? '是' : '否'}`);
lines.push(`- 目标网站/页面 URL:${f.targetSiteUrl || '未提供'}`);
lines.push(`- 目标接口 API URL:${f.apiUrl || '未提供'}`);
lines.push(`- 请求方法:${f.method || '未提供'}`);
lines.push(`- 目标加密参数:${f.encryptedParam || '未提供'}`);
lines.push(`- 参数位置:${f.paramLocation || '未提供'}`);
lines.push(`- 已提供成功请求样本:${yesNo(f.requestSample)}`);
lines.push(`- 已提供成功响应样本:${yesNo(f.responseSample)}`);
lines.push(`- 已知 JS 文件:${yesNo(f.knownJsAssets)}`);
lines.push(`- 是否提到登录:${yesNo(f.loginRequired)}`);
lines.push(`- 取证模式:${f.acquisitionMode || '未提供'}`);
lines.push(`- 最终请求 TLS 指纹兼容客户端:${f.tlsClientStrategy || '未提供'}`);
lines.push('');
if (!result.complete) {
lines.push('## 当前缺少的必要信息');
for (const item of result.missing) lines.push(`- [ ] ${item}`);
lines.push('');
lines.push('请先补充以上必要信息;补齐前不要进入逆向分析或补环境代码阶段。');
} else {
lines.push('## 下一步');
lines.push('请先把以上关键信息、取证模式和最终请求 TLS 指纹兼容客户端整理给用户确认;用户确认后再校验请求样本、加密参数和 JS 文件可获取性。后续所有浏览器取证动作必须沿用用户确认的取证模式;最终真实请求只能使用用户确认的 Node.js / Python 请求客户端。');
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const text = readInput(args.input);
const result = analyze(text);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,230 @@
#!/usr/bin/env node
'use strict';
const NODE_CAPABILITY_GLOBALS = [
'process', 'Buffer', 'global', 'setImmediate', 'clearImmediate',
];
const NODE_WEB_API_GLOBALS = [
'navigator', 'localStorage', 'sessionStorage', 'performance', 'crypto', 'Crypto', 'SubtleCrypto',
'fetch', 'Headers', 'Request', 'Response', 'FormData', 'File', 'Blob', 'WebSocket', 'EventSource',
'BroadcastChannel', 'MessageChannel', 'MessagePort', 'CompressionStream', 'DecompressionStream',
'URLPattern', 'CloseEvent', 'ErrorEvent', 'AbortController', 'AbortSignal', 'Event', 'EventTarget',
'CustomEvent', 'MessageEvent', 'DOMException', 'structuredClone', 'atob', 'btoa', 'URL',
'URLSearchParams', 'TextEncoder', 'TextDecoder', 'TextEncoderStream', 'TextDecoderStream',
'ReadableStream', 'WritableStream', 'TransformStream', 'PerformanceEntry', 'PerformanceMark',
'PerformanceMeasure', 'PerformanceObserver', 'PerformanceResourceTiming', 'CryptoKey',
'WebAssembly', 'queueMicrotask',
];
const MODULE_SCOPE_NAMES = ['require', 'module', 'exports', '__dirname', '__filename'];
const DENY_LIST = [
'process', 'Buffer', 'require', 'module', 'exports', 'global', '__dirname', '__filename',
'setImmediate', 'clearImmediate', 'Error.prepareStackTrace', 'Node 专属堆栈路径',
'宿主 navigator', 'navigator.userAgent=Node.js/<major>', 'navigator.locks',
'宿主 localStorage', '宿主 sessionStorage',
'performance.nodeTiming', 'performance.eventLoopUtilization', 'performance.timerify', 'performance.markResourceTiming',
'宿主 fetch / undici', '宿主 WebSocket', '宿主 BroadcastChannel / MessageChannel',
'宿主 URL / TextEncoder / Streams / Events / crypto 等浏览器兼容层直接透传',
];
function parseArgs(argv) {
const args = { json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_node_leakage.js --markdown
node scripts/check_node_leakage.js --json
说明:检查当前 Node 宿主中常见泄露变量,并给出目标 JS 运行上下文的阻断清单。该上下文可为 vm、独立 Node 进程或显式隔离的 global。`;
}
function safeGet(fn, fallback = undefined) {
try { return fn(); } catch (err) { return fallback === undefined ? `访问失败:${err.message || String(err)}` : fallback; }
}
function descriptorInfo(name) {
const d = safeGet(() => Object.getOwnPropertyDescriptor(globalThis, name), null);
if (!d) return null;
return {
enumerable: !!d.enumerable,
configurable: !!d.configurable,
writable: Object.prototype.hasOwnProperty.call(d, 'writable') ? !!d.writable : undefined,
hasGetter: typeof d.get === 'function',
hasSetter: typeof d.set === 'function',
valueType: Object.prototype.hasOwnProperty.call(d, 'value') ? typeof d.value : undefined,
};
}
function globalStatus(name) {
const type = safeGet(() => typeof globalThis[name], '访问失败');
const accessFailed = String(type).startsWith('访问失败');
return {
name,
visibleOnGlobalThis: type !== 'undefined' && !accessFailed,
type,
descriptor: descriptorInfo(name),
};
}
function collectNavigatorSnapshot() {
if (typeof globalThis.navigator === 'undefined') return { visible: false };
const nav = globalThis.navigator;
const userAgent = safeGet(() => nav.userAgent, '访问失败');
return {
visible: true,
constructorName: safeGet(() => nav.constructor && nav.constructor.name, ''),
userAgent,
isNodeUserAgent: /^Node\.js\//.test(String(userAgent || '')),
platform: safeGet(() => nav.platform, ''),
language: safeGet(() => nav.language, ''),
languages: safeGet(() => Array.isArray(nav.languages) ? Array.from(nav.languages) : nav.languages, ''),
hardwareConcurrency: safeGet(() => nav.hardwareConcurrency, ''),
locksType: safeGet(() => typeof nav.locks, 'undefined'),
prototypeKeys: safeGet(() => Object.getOwnPropertyNames(Object.getPrototypeOf(nav)).sort(), []),
};
}
function collectPerformanceSnapshot() {
if (typeof globalThis.performance === 'undefined') return { visible: false };
const perf = globalThis.performance;
return {
visible: true,
constructorName: safeGet(() => perf.constructor && perf.constructor.name, ''),
hasNodeTiming: safeGet(() => 'nodeTiming' in perf, false),
hasEventLoopUtilization: safeGet(() => 'eventLoopUtilization' in perf, false),
hasTimerify: safeGet(() => 'timerify' in perf, false),
hasMarkResourceTiming: safeGet(() => 'markResourceTiming' in perf, false),
prototypeKeys: safeGet(() => Object.getOwnPropertyNames(Object.getPrototypeOf(perf)).sort(), []),
};
}
function collectStorageSnapshot(name) {
const type = safeGet(() => typeof globalThis[name], '访问失败');
if (type === 'undefined' || String(type).startsWith('访问失败')) return { visible: false, name, type };
const storage = safeGet(() => globalThis[name], null);
return {
visible: true,
name,
type,
constructorName: safeGet(() => storage && storage.constructor && storage.constructor.name, ''),
descriptor: descriptorInfo(name),
};
}
function collect() {
const moduleScope = Object.fromEntries(MODULE_SCOPE_NAMES.map(name => [name, safeGet(() => eval(`typeof ${name}`) !== 'undefined', false)]));
const capabilityGlobals = NODE_CAPABILITY_GLOBALS.map(globalStatus);
const webApiGlobals = NODE_WEB_API_GLOBALS.map(globalStatus);
const navigatorSnapshot = collectNavigatorSnapshot();
const performanceSnapshot = collectPerformanceSnapshot();
const storageSnapshots = [collectStorageSnapshot('localStorage'), collectStorageSnapshot('sessionStorage')];
const hostSignals = [];
if (navigatorSnapshot.visible && navigatorSnapshot.isNodeUserAgent) hostSignals.push(`navigator.userAgent=${navigatorSnapshot.userAgent}`);
if (navigatorSnapshot.visible && navigatorSnapshot.locksType !== 'undefined') hostSignals.push('navigator.locks 可见');
if (performanceSnapshot.hasNodeTiming) hostSignals.push('performance.nodeTiming 可见');
if (performanceSnapshot.hasEventLoopUtilization) hostSignals.push('performance.eventLoopUtilization 可见');
if (performanceSnapshot.hasTimerify) hostSignals.push('performance.timerify 可见');
if (typeof process !== 'undefined' && process.versions && process.versions.undici) hostSignals.push(`process.versions.undici=${process.versions.undici}`);
return {
note: '当前脚本运行在 Node 宿主中,存在这些变量是正常现象;目标网页 JS 所在运行上下文中不应暴露 Node 能力,也不应直接复用宿主 Node Web API 兼容层。',
host: { node: process.version, platform: process.platform, arch: process.arch, v8: process.versions.v8, undici: process.versions.undici || '' },
capabilityGlobals,
webApiGlobals,
moduleScope,
navigator: navigatorSnapshot,
performance: performanceSnapshot,
storage: storageSnapshots,
hostSignals,
denyList: DENY_LIST,
contextExpectations: {
process: 'undefined',
Buffer: 'undefined',
require: 'undefined',
module: 'undefined',
global: 'undefined',
functionProcess: 'Function("return typeof process")() 应为 "undefined"',
navigatorUserAgent: '不能以 Node.js/ 开头,必须来自浏览器采样或 fixture',
navigatorLocks: '不应暴露 Node 的 navigator.locks,除非已按浏览器样本明确实现',
performanceNodeTiming: 'performance.nodeTiming 应不存在',
performanceEventLoopUtilization: 'performance.eventLoopUtilization 应不存在',
performanceTimerify: 'performance.timerify 应不存在',
storage: 'localStorage/sessionStorage 必须是浏览器页面级 Storage 实现,不得复用 Node 宿主 Storage',
network: 'fetch/WebSocket 等不得透传宿主实现,探测模式用桩函数,最终请求用已确认 TLS 指纹客户端',
webCompatGlobals: 'URL/TextEncoder/Streams/Events/crypto/WebAssembly 等浏览器同名 API 如参与检测,也必须按浏览器样本或补环境实现安装,不得盲目透传宿主构造器',
},
recommendations: [
'不要把宿主函数、宿主数组、宿主 URL/TextEncoder 构造器直接塞进目标 JS 运行上下文。',
'Node 官方文档显示 navigator 是 v21.0.0 新增,不是 Node 20;如存在宿主 navigator,先删除或隔离,再安装浏览器式 Navigator。',
'Node 22.4+ 如存在宿主 localStorage/sessionStorage,先删除或隔离,再安装浏览器式 Storage。',
'不要透传宿主 performance;需移除 nodeTiming、eventLoopUtilization、timerify 等 Node 专属能力。',
'Web Streams、Events、URL、TextEncoder、crypto、WebAssembly、queueMicrotask 等同名 API 也要按目标浏览器样本决定是否覆盖,不能默认复用 Node 宿主对象。',
'在目标 JS 运行上下文内部定义 fetch、XHR、atob、console 等函数,避免 constructor.constructor 泄露。',
'目标 JS 运行前检查 Function("return typeof process")()。',
'最终 runner 中不要为了调试暴露 require、process、Buffer。',
],
};
}
function renderGlobalList(lines, title, items) {
lines.push('', title);
for (const g of items) lines.push(`- ${g.name}:${g.visibleOnGlobalThis ? '宿主可见' : '宿主不可见'}(${g.type})`);
}
function renderMarkdown(result) {
const lines = ['# Node 泄露阻断检查', '', `- Node.js:${result.host.node}`, `- 平台:${result.host.platform}-${result.host.arch}`, `- undici:${result.host.undici || '未暴露'}`, '', `说明:${result.note}`];
renderGlobalList(lines, '## 宿主 Node 能力变量状态', result.capabilityGlobals);
renderGlobalList(lines, '## 宿主 Node Web API 兼容层状态', result.webApiGlobals);
lines.push('', '## 模块作用域变量');
for (const [k, v] of Object.entries(result.moduleScope)) lines.push(`- ${k}:${v ? '存在' : '不存在'}`);
lines.push('', '## navigator 宿主信号');
if (!result.navigator.visible) lines.push('- 宿主 navigator 不可见');
else {
lines.push(`- userAgent:${result.navigator.userAgent}`);
lines.push(`- 是否 Node userAgent:${result.navigator.isNodeUserAgent ? '是' : '否'}`);
lines.push(`- platform:${result.navigator.platform}`);
lines.push(`- language:${result.navigator.language}`);
lines.push(`- hardwareConcurrency:${result.navigator.hardwareConcurrency}`);
lines.push(`- locks:${result.navigator.locksType}`);
}
lines.push('', '## performance 宿主信号');
if (!result.performance.visible) lines.push('- 宿主 performance 不可见');
else {
lines.push(`- nodeTiming:${result.performance.hasNodeTiming ? '可见' : '不可见'}`);
lines.push(`- eventLoopUtilization:${result.performance.hasEventLoopUtilization ? '可见' : '不可见'}`);
lines.push(`- timerify:${result.performance.hasTimerify ? '可见' : '不可见'}`);
lines.push(`- markResourceTiming:${result.performance.hasMarkResourceTiming ? '可见' : '不可见'}`);
}
if (result.hostSignals.length) {
lines.push('', '## 已发现宿主特征信号');
for (const item of result.hostSignals) lines.push(`- ${item}`);
}
lines.push('', '## 目标运行上下文阻断清单');
for (const name of result.denyList) lines.push(`- ${name}`);
lines.push('', '## 建议');
for (const item of result.recommendations) lines.push(`- ${item}`);
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = collect();
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
'use strict';
const { execFileSync } = require('child_process');
const os = require('os');
const TARGETS = {
addon: {
label: 'native addon.node',
version: '25.8.2',
nextCheck: 'node scripts/load_native_addon.js --json',
},
'isolated-vm': {
label: 'xbs isolated-vm',
version: '26.3.1',
nextCheck: 'node scripts/check_xbs_isolated_vm.js --strict --json',
},
xbs: {
label: 'xbs isolated-vm',
version: '26.3.1',
nextCheck: 'node scripts/check_xbs_isolated_vm.js --strict --json',
},
};
function parseArgs(argv) {
const args = { target: 'addon', requiredVersion: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--target') args.target = argv[++i] || '';
else if (a === '--required-version') args.requiredVersion = (argv[++i] || '').replace(/^v/i, '');
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error('未知参数:' + a);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return [
'用法:',
' node scripts/check_node_runtime_compat.js --target addon --markdown',
' node scripts/check_node_runtime_compat.js --target isolated-vm --json',
' node scripts/check_node_runtime_compat.js --required-version 25.8.2 --markdown',
'',
'说明:只检测当前 Node / ABI / nvm 可用性并输出中文恢复建议,不会安装或切换 Node。',
].join('\n');
}
function tryExec(command, args, options = {}) {
try {
return {
ok: true,
stdout: execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: options.timeout || 5000,
}).trim(),
error: '',
};
} catch (error) {
return { ok: false, stdout: '', error: String(error && error.message ? error.message : error) };
}
}
function detectNvm() {
if (process.platform === 'win32') {
const where = tryExec('where.exe', ['nvm']);
const version = tryExec('nvm', ['version']);
const root = tryExec('nvm', ['root']);
return {
available: version.ok,
kind: 'nvm-windows',
path: where.stdout.split(/\r?\n/).filter(Boolean)[0] || '',
version: version.stdout,
root: root.stdout,
error: version.ok ? '' : (where.error || version.error),
};
}
const shell = tryExec('bash', ['-lc', 'if command -v nvm >/dev/null 2>&1; then nvm --version; elif [ -s "$NVM_DIR/nvm.sh" ]; then . "$NVM_DIR/nvm.sh" && nvm --version; elif [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh" && nvm --version; else exit 127; fi']);
return {
available: shell.ok,
kind: 'nvm-sh',
path: shell.ok ? '$NVM_DIR/nvm.sh 或 $HOME/.nvm/nvm.sh' : '',
version: shell.stdout,
root: '',
error: shell.error,
};
}
function buildResult(args) {
const target = TARGETS[args.target] || null;
const requiredVersion = args.requiredVersion || (target && target.version) || args.target.replace(/^v/i, '');
const currentVersion = process.version.replace(/^v/i, '');
const nvm = detectNvm();
const label = target ? target.label : '自定义 Node native 组件';
const nextCheck = target ? target.nextCheck : '';
return {
tool: 'check_node_runtime_compat',
target: args.target,
label,
requiredVersion,
currentVersion,
currentVersionWithV: process.version,
versionMatched: currentVersion === requiredVersion,
nodeAbi: process.versions && process.versions.modules ? process.versions.modules : '',
platform: process.platform,
arch: process.arch,
os: os.platform() + '-' + os.arch(),
nvm,
needsUserConfirmation: currentVersion !== requiredVersion,
installCommands: requiredVersion ? [
`nvm install ${requiredVersion}`,
`nvm use ${requiredVersion}`,
'node -v',
'node -p "process.versions.modules"',
nextCheck,
].filter(Boolean) : [],
};
}
function renderMarkdown(result) {
const lines = [];
lines.push('# Node 版本兼容检查');
lines.push('');
lines.push('- 目标组件:' + result.label);
lines.push('- 需要 Node:v' + result.requiredVersion);
lines.push('- 当前 Node:' + result.currentVersionWithV);
lines.push('- 当前 ABI:' + result.nodeAbi);
lines.push('- 平台:' + result.platform + '-' + result.arch);
lines.push('- 版本是否匹配:' + (result.versionMatched ? '是' : '否'));
lines.push('- nvm 是否可用:' + (result.nvm.available ? '是' : '否'));
if (result.nvm.version) lines.push('- nvm 版本:' + result.nvm.version);
if (result.nvm.path) lines.push('- nvm 路径:' + result.nvm.path);
if (result.nvm.root) lines.push('- nvm root:' + result.nvm.root);
if (!result.versionMatched) {
lines.push('');
lines.push('## 处理建议');
lines.push('');
lines.push('- 不要直接降级;先让用户确认是否通过 nvm 安装 / 切换兼容 Node。');
lines.push('- 用户同意后再执行以下命令:');
lines.push('');
lines.push('```bash');
for (const cmd of result.installCommands) lines.push(cmd);
lines.push('```');
if (!result.nvm.available) {
lines.push('');
lines.push('- 当前未检测到 nvm。先让用户选择自动安装 nvm、手动安装 nvm、提供已安装 nvm 路径,或拒绝安装后再进入降级 / 改选流程。');
}
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = buildResult(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (error) {
console.error(error && error.message ? error.message : String(error));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,375 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const BLOCKING_STATUSES = new Set([
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
]);
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
matrix: '',
browserBaseline: '',
nodeAudit: '',
require: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--dir') args.dir = argv[++i] || '';
else if (a === '--matrix') args.matrix = argv[++i] || '';
else if (a === '--browser-baseline') args.browserBaseline = argv[++i] || '';
else if (a === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (a === '--require') args.require = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_object_shape_audit.js --case-dir case --markdown
node scripts/check_object_shape_audit.js --case-dir case --require --json
node scripts/check_object_shape_audit.js --dir case/result --matrix case/notes/object-shape-audit.md --browser-baseline case/fixtures/browser-object-shape-baseline.json --node-audit case/tmp/node-object-shape-audit.json --markdown
说明:检查浏览器对象是否泄露 _ / __ 私有状态,并校验对象形状 baseline、Node audit 与 object-shape-audit.md。`;
}
function exists(p) { try { return fs.existsSync(p); } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function readJson(p) { return JSON.parse(readText(p)); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) walk(path.join(root, name), out);
return out;
}
function isCodeFile(file) {
return ['.js', '.mjs', '.cjs', '.ts'].includes(ext(file));
}
function shouldSkip(root, file) {
const n = rel(root, file).toLowerCase();
return /(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party|src\/target\/original)(\/|$)/.test(n)
|| /(\.min\.js|bundle\.js|vendor\.js)$/i.test(n);
}
function isRelevantBrowserEnvFile(root, file, text) {
const r = rel(root, file);
if (/(^|\/)src\/(?:env|node-runtime\/env)\//i.test(r)) return true;
if (/(^|\/)src\/signer\//i.test(r) && /\b(?:window|document|navigator|XMLHttpRequest|fetch|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) return true;
if (/(?:probe|runtime|runner|diagnostic)/i.test(path.basename(r)) && /\b(?:window|document|navigator|XMLHttpRequest|fetch|EventTarget|Storage|Performance|Element|Node)\b/.test(text)) return true;
return false;
}
const ALLOWED_DOUBLE_UNDERSCORE = new Set([
'__proto__',
'__defineGetter__',
'__defineSetter__',
'__lookupGetter__',
'__lookupSetter__',
'__esModule',
]);
function allowedName(name) {
return ALLOWED_DOUBLE_UNDERSCORE.has(String(name));
}
function stripComments(line) {
return line.replace(/\/\/.*$/g, '').replace(/\/\*.*?\*\//g, '');
}
function inspectPrivateLeaks(root, file) {
const text = readText(file);
if (!isRelevantBrowserEnvFile(root, file, text)) return [];
const leaks = [];
const lines = text.split(/\r?\n/);
const patterns = [
{ id: 'this-dot', re: /\bthis\s*\.\s*(_{1,2}[A-Za-z][\w$]*)\s*=/g, message: '在浏览器实例 this 上写入 _ / __ 私有字段' },
{ id: 'object-dot', re: /\b(?:obj|target|instance|xhr|event|node|element|globalObject|window|document)\s*\.\s*(_{1,2}[A-Za-z][\w$]*)\s*=/g, message: '在浏览器对象上写入 _ / __ 私有字段' },
{ id: 'bracket', re: /\[\s*['"](_{1,2}[A-Za-z][\w$]*)['"]\s*\]\s*=/g, message: '通过 bracket 写入 _ / __ 私有字段' },
{ id: 'define-property', re: /\b(?:Object\.defineProperty|Reflect\.defineProperty)\s*\([^,\n]+,\s*['"](_{1,2}[A-Za-z][\w$]*)['"]/g, message: '通过 defineProperty 暴露 _ / __ 私有字段' },
{ id: 'define-value', re: /\b(?:defineValue|defineNativeValue|defineHidden|defineInternal)\s*\([^,\n]+,\s*['"](_{1,2}[A-Za-z][\w$]*)['"]/g, message: '通过 helper 暴露 _ / __ 私有字段' },
{ id: 'descriptor-object', re: /['"](_{1,2}[A-Za-z][\w$]*)['"]\s*:\s*\{[^}]*\b(?:value|get|set|enumerable|configurable|writable)\b/g, message: '在 descriptor 对象中定义 _ / __ 私有字段' },
];
for (let i = 0; i < lines.length; i++) {
const line = stripComments(lines[i]);
for (const pattern of patterns) {
pattern.re.lastIndex = 0;
let m;
while ((m = pattern.re.exec(line))) {
const name = m[1];
if (allowedName(name)) continue;
leaks.push({
file: rel(root, file),
line: i + 1,
name,
type: pattern.id,
message: pattern.message,
text: lines[i].trim().slice(0, 180),
});
}
}
}
return leaks;
}
function extractStatusesFromMarkdown(text) {
const statuses = [];
for (const status of ['matched', 'accepted-diff', 'not-involved', 'needs-browser-baseline', 'needs-node-audit', 'mismatch', 'native-capability-gap', 'unknown']) {
const matches = text.match(new RegExp(`\\b${status}\\b`, 'g'));
if (matches) statuses.push({ status, count: matches.length });
}
return statuses;
}
function normalizeTargets(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.targets)) return raw.targets.map(item => item.name || item.path || item.target || '').filter(Boolean);
if (Array.isArray(raw.items)) return raw.items.map(item => item.name || item.path || item.target || '').filter(Boolean);
if (raw.targets && typeof raw.targets === 'object') return Object.keys(raw.targets);
return [];
}
function normalizeTargetRecords(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.targets)) return raw.targets;
if (Array.isArray(raw.items)) return raw.items;
if (raw.targets && typeof raw.targets === 'object') {
return Object.entries(raw.targets).map(([name, value]) => ({ name, ...(value && typeof value === 'object' ? value : { value }) }));
}
return [];
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
}
function sameValue(left, right) {
return JSON.stringify(stable(left)) === JSON.stringify(stable(right));
}
const SHAPE_FIELDS = [
'objectKeys',
'ownPropertyNames',
'ownSymbols',
'reflectOwnKeys',
'descriptors',
'inChecks',
'forIn',
'prototypeChain',
'toString',
'constructorName',
'instanceof',
];
function inspectJson(file, label, problems) {
if (!exists(file)) {
problems.push(`缺少 ${label}:${file}`);
return { exists: false, parseOk: false, baselineId: '', targets: [] };
}
try {
const raw = readJson(file);
return {
exists: true,
parseOk: true,
baselineId: raw.baselineId || '',
schemaVersion: raw.schemaVersion || '',
targets: normalizeTargets(raw),
targetRecords: normalizeTargetRecords(raw),
runtimeSourceHash: raw.runtimeSourceHash || '',
generatedBy: raw.generatedBy || '',
probeVersion: raw.probeVersion || '',
};
} catch (err) {
problems.push(`${label} 无法解析为 JSON:${err.message}`);
return { exists: true, parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
}
}
function compareTargetRecords(browser, nodeAudit, problems) {
const nodeByName = new Map(nodeAudit.targetRecords.map(item => [item.name || item.path || item.target, item]));
for (const expected of browser.targetRecords) {
const name = expected.name || expected.path || expected.target;
if (!name) {
problems.push('浏览器对象形状 baseline 存在未命名 target');
continue;
}
const observed = nodeByName.get(name);
if (!observed) {
problems.push(`Node 对象形状 audit 缺少 target:${name}`);
continue;
}
for (const field of SHAPE_FIELDS) {
if (typeof expected[field] === 'undefined') continue;
if (typeof observed[field] === 'undefined') {
problems.push(`${name} 缺少 Node 观测字段:${field}`);
} else if (!sameValue(expected[field], observed[field])) {
problems.push(`${name} 对象形状不一致:${field}`);
}
}
}
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const root = path.resolve(args.dir || path.join(caseDir, 'result'));
const matrixPath = path.resolve(args.matrix || path.join(caseDir, 'notes', 'object-shape-audit.md'));
const browserPath = path.resolve(args.browserBaseline || path.join(caseDir, 'fixtures', 'browser-object-shape-baseline.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-object-shape-audit.json'));
const files = walk(root).filter(file => stat(file) && stat(file).isFile() && isCodeFile(file) && !shouldSkip(root, file));
const problems = [];
const warnings = [];
const leaks = files.flatMap(file => inspectPrivateLeaks(root, file));
for (const leak of leaks) {
problems.push(`${leak.file}:${leak.line} ${leak.message}:${leak.name}`);
}
const requireArtifacts = args.require || leaks.length > 0 || files.some(file => {
try {
const text = readText(file);
return isRelevantBrowserEnvFile(root, file, text) && /Object\.keys|Object\.getOwnPropertyNames|Reflect\.ownKeys|hasOwnProperty|propertyIsEnumerable/.test(text);
} catch {
return false;
}
});
let matrix = { exists: exists(matrixPath), statuses: [], blockingStatuses: [], missingKeywords: [] };
if (requireArtifacts) {
if (!matrix.exists) problems.push(`缺少对象形状审计矩阵:${matrixPath}`);
if (matrix.exists) {
const text = readText(matrixPath);
matrix.statuses = extractStatusesFromMarkdown(text);
matrix.blockingStatuses = matrix.statuses.filter(item => BLOCKING_STATUSES.has(item.status));
for (const item of matrix.blockingStatuses) problems.push(`对象形状审计矩阵存在阻断状态 ${item.status},数量 ${item.count}`);
matrix.missingKeywords = ['Object.keys', 'Object.getOwnPropertyNames', 'Reflect.ownKeys', 'descriptor', '私有状态'].filter(key => !text.includes(key));
for (const key of matrix.missingKeywords) problems.push(`对象形状审计矩阵缺少关键字段:${key}`);
}
}
const browser = requireArtifacts
? inspectJson(browserPath, '浏览器对象形状 baseline', problems)
: { exists: exists(browserPath), parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
const nodeAudit = requireArtifacts
? inspectJson(nodeAuditPath, 'Node 对象形状 audit', problems)
: { exists: exists(nodeAuditPath), parseOk: false, baselineId: '', targets: [], targetRecords: [], runtimeSourceHash: '', generatedBy: '', probeVersion: '' };
if (browser.baselineId && nodeAudit.baselineId && browser.baselineId !== nodeAudit.baselineId) {
problems.push(`对象形状 baselineId 与 Node audit baselineId 不一致:${browser.baselineId} != ${nodeAudit.baselineId}`);
}
if (requireArtifacts && browser.parseOk && nodeAudit.parseOk) {
const missingTargets = browser.targets.filter(target => !nodeAudit.targets.includes(target));
if (missingTargets.length) problems.push(`Node 对象形状 audit 缺少浏览器 baseline target:${missingTargets.join('、')}`);
compareTargetRecords(browser, nodeAudit, problems);
if (!nodeAudit.runtimeSourceHash) problems.push('Node 对象形状 audit 缺少 runtimeSourceHash,无法判断代码修改后审计是否过期');
if (!nodeAudit.probeVersion) problems.push('Node 对象形状 audit 缺少 probeVersion');
if (!/audit|probe|recorder/i.test(nodeAudit.generatedBy)) problems.push(`Node 对象形状 audit generatedBy 不可信:${nodeAudit.generatedBy || '未记录'}`);
}
if (!files.length) problems.push(`未找到可检查的 result 代码文件:${root}`);
if (!leaks.length && !requireArtifacts) warnings.push('未发现对象形状审计触发信号;如目标存在属性枚举 / descriptor 检测,建议使用 --require 强制检查。');
return {
caseDir,
root,
clean: problems.length === 0,
requireArtifacts,
filesChecked: files.length,
leaks,
matrixPath,
browserPath,
nodeAuditPath,
matrix,
browser,
nodeAudit,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# 对象形状审计检查结果',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.root}`,
`是否需要审计产物:${result.requireArtifacts ? '是' : '否'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 文件',
`- 矩阵:${result.matrixPath}`,
`- 浏览器 baseline:${result.browserPath}`,
`- Node audit:${result.nodeAuditPath}`,
'',
'## 摘要',
`- 检查文件数:${result.filesChecked}`,
`- _ / __ 私有状态泄露数量:${result.leaks.length}`,
`- 浏览器 baseline:存在=${result.browser.exists ? '是' : '否'},JSON=${result.browser.parseOk ? '是' : '否'},baselineId=${result.browser.baselineId || '未记录'}`,
`- Node audit:存在=${result.nodeAudit.exists ? '是' : '否'},JSON=${result.nodeAudit.parseOk ? '是' : '否'},baselineId=${result.nodeAudit.baselineId || '未记录'}`,
];
if (result.matrix.exists) {
lines.push(`- 矩阵状态:${result.matrix.statuses.map(item => `${item.status}=${item.count}`).join('、') || '未记录'}`);
}
if (result.leaks.length) {
lines.push('', '## 私有状态泄露');
for (const leak of result.leaks.slice(0, 50)) {
lines.push(`- ${leak.file}:${leak.line} ${leak.name}:${leak.text}`);
}
if (result.leaks.length > 50) lines.push(`- 还有 ${result.leaks.length - 50} 条未展示。`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const warning of result.warnings) lines.push(`- ${warning}`);
}
return `${lines.join('\n')}\n`;
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { check, inspectPrivateLeaks };

View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const STAGE_FILES = {
'需求信息确认': '01-需求信息确认.md',
'intake': '01-需求信息确认.md',
'取证方案确认': '02-取证方案确认.md',
'forensics': '02-取证方案确认.md',
'请求样本与可疑参数确认': '03-请求样本与可疑参数确认.md',
'params': '03-请求样本与可疑参数确认.md',
'JS文件与入口定位': '04-JS文件与入口定位.md',
'entry': '04-JS文件与入口定位.md',
'补环境前置分析': '05-补环境前置分析.md',
'pre-env': '05-补环境前置分析.md',
'补环境实现记录': '06-补环境实现记录.md',
'env': '06-补环境实现记录.md',
'验证与清理记录': '07-验证与清理记录.md',
'validation': '07-验证与清理记录.md',
};
const DYNAMIC_REQUIRED_SECTIONS = [
'当前阶段目标',
'当前项目进展',
'本阶段修改文件',
'Trace 计划内首轮实现 / 调整的 WebAPI',
'计划外新增 WebAPI 与原因',
'Trace-runtime 可执行闭环',
'XHR/fetch Session Bridge',
'XHR/fetch 请求语义审计',
'WebAPI 环境检测矩阵',
'对象形状审计矩阵',
'本阶段新增功能',
'本阶段修复的 Bug',
'本阶段新增 / 修改的指纹能力',
'真实性保护变化',
'本阶段测试内容与结果',
'清理情况',
'风险与遗留问题',
'下一步计划',
];
const CAPABILITY_SECTIONS = [
'Trace 计划内首轮实现 / 调整的 WebAPI',
'计划外新增 WebAPI 与原因',
'Trace-runtime 可执行闭环',
'XHR/fetch Session Bridge',
'XHR/fetch 请求语义审计',
'WebAPI 环境检测矩阵',
'对象形状审计矩阵',
'本阶段新增功能',
'本阶段修复的 Bug',
'本阶段新增 / 修改的指纹能力',
'真实性保护变化',
'本阶段测试内容与结果',
];
function parseArgs(argv) {
const args = { caseDir: '', requiredStages: [], requireDynamicFields: false, requireCapabilityReport: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--require-stage') args.requiredStages.push(argv[++i] || '');
else if (a === '--require-initial') args.requiredStages.push('需求信息确认');
else if (a === '--require-dynamic-fields') args.requireDynamicFields = true;
else if (a === '--require-capability-report') args.requireCapabilityReport = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.requiredStages.length) args.requiredStages.push('需求信息确认');
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage WebAPI补齐阶段报告 --require-dynamic-fields --markdown
node scripts/check_stage_reports.js --case-dir case --require-stage 需求信息确认 --require-stage 请求样本与可疑参数确认 --json
说明:检查阶段报告是否使用中文文件名、UTF-8 编码,并确认必要阶段报告存在。使用 --require-dynamic-fields 时会校验动态阶段报告是否包含 Trace 计划内 WebAPI、计划外新增原因、Trace-runtime 可执行闭环、XHR/fetch Session Bridge、XHR/fetch 请求语义审计、WebAPI 环境检测矩阵、对象形状审计矩阵、功能、Bug、指纹、测试、清理和风险等章节。`;
}
function exists(p) { try { fs.accessSync(p); return true; } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function hasChinese(s) { return /[\u4e00-\u9fff]/.test(String(s || '')); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function escapeRegExp(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function listMarkdown(dir) {
if (!exists(dir)) return [];
const out = [];
for (const name of fs.readdirSync(dir)) {
const p = path.join(dir, name);
const st = stat(p);
if (st && st.isFile() && path.extname(name).toLowerCase() === '.md') out.push(p);
}
return out.sort();
}
function fixedStageFileName(stage) {
const s = String(stage || '').trim();
return STAGE_FILES[s] || '';
}
function stageTitle(stage) {
const fixed = fixedStageFileName(stage);
if (!fixed) return String(stage || '').trim();
return fixed.replace(/^\d+-/, '').replace(/\.md$/i, '');
}
function locateStageReport(stageDir, stage) {
const s = String(stage || '').trim();
if (!s) return '';
const fixed = fixedStageFileName(s);
if (fixed) {
const p = path.join(stageDir, fixed);
if (exists(p)) return p;
return '';
}
if (!hasChinese(s)) return '';
const exact = path.join(stageDir, `${s}.md`);
if (exists(exact)) return exact;
const pattern = new RegExp(`^(\\d{1,3}-)?${escapeRegExp(s)}\\.md$`, 'i');
const files = listMarkdown(stageDir);
return files.find(file => pattern.test(path.basename(file))) || files.find(file => path.basename(file).includes(s)) || '';
}
function hasMojibake(text) {
const questionRuns = text.match(/\?{3,}/g) || [];
return text.includes('\uFFFD') || questionRuns.length > 0;
}
function missingSections(text, sections) {
return sections.filter(section => !text.includes(section));
}
function isDynamicCandidate(name, text) {
if (/WebAPI|指纹|Bug|补环境|addon|能力|修复|回归|阶段报告/i.test(name)) return true;
const sectionHits = DYNAMIC_REQUIRED_SECTIONS.filter(section => text.includes(section)).length;
return sectionHits >= 4;
}
function check(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
const stageDir = path.join(caseDir, '阶段报告');
const reports = listMarkdown(stageDir);
const problems = [];
const warnings = [];
if (!exists(stageDir)) problems.push('缺少阶段报告目录:case/阶段报告');
if (!reports.length) problems.push('缺少阶段报告 Markdown 文件,至少应生成 01-需求信息确认.md');
const reportResults = [];
for (const file of reports) {
const name = path.basename(file);
const item = {
file: rel(caseDir, file),
chineseFileName: hasChinese(name),
utf8Readable: false,
mojibakeSuspected: false,
dynamicCandidate: false,
initialRequiredFieldsMissing: [],
dynamicFieldsMissing: [],
capabilityFieldsMissing: [],
};
if (!item.chineseFileName) problems.push(`阶段报告文件名必须包含中文:${item.file}`);
let text = '';
try { text = readText(file); item.utf8Readable = true; } catch (err) { problems.push(`阶段报告无法按 UTF-8 读取:${item.file}:${err.message}`); }
if (text) {
item.mojibakeSuspected = hasMojibake(text);
if (item.mojibakeSuspected) problems.push(`阶段报告疑似乱码或包含连续问号:${item.file}`);
if (name === '01-需求信息确认.md') {
for (const key of ['目标网站 URL', '目标 API', '取证模式', '加密参数', '已知 JS 文件']) {
if (!text.includes(key)) item.initialRequiredFieldsMissing.push(key);
}
if (item.initialRequiredFieldsMissing.length) problems.push(`需求信息确认报告缺少字段:${item.initialRequiredFieldsMissing.join('、')}`);
}
item.dynamicCandidate = isDynamicCandidate(name, text);
const simpleFixedStage = /^0[1-4]-/.test(name);
const shouldCheckDynamic = item.dynamicCandidate || (args.requireDynamicFields && !simpleFixedStage);
if (shouldCheckDynamic) {
item.dynamicFieldsMissing = missingSections(text, DYNAMIC_REQUIRED_SECTIONS);
if (item.dynamicFieldsMissing.length) problems.push(`动态阶段报告缺少章节:${item.file}:${item.dynamicFieldsMissing.join('、')}`);
}
if (args.requireCapabilityReport && shouldCheckDynamic) {
item.capabilityFieldsMissing = missingSections(text, CAPABILITY_SECTIONS);
if (item.capabilityFieldsMissing.length) problems.push(`阶段报告缺少能力增量章节:${item.file}:${item.capabilityFieldsMissing.join('、')}`);
}
}
reportResults.push(item);
}
for (const stage of args.requiredStages) {
const file = locateStageReport(stageDir, stage);
if (!file) {
const title = stageTitle(stage);
if (!title || !hasChinese(title)) problems.push(`未知必需阶段:${stage}`);
else problems.push(`缺少必需阶段报告:${title}`);
}
}
return { caseDir, stageDir, clean: problems.length === 0, problems, warnings, reports: reportResults };
}
function renderMarkdown(result) {
const lines = ['# 阶段报告检查结果', '', `case 目录:${result.caseDir}`, `阶段报告目录:${result.stageDir}`, `是否通过:${result.clean ? '是' : '否'}`, ''];
lines.push('## 报告列表');
if (result.reports.length) {
for (const r of result.reports) {
lines.push(`- ${r.file}:中文文件名=${r.chineseFileName ? '是' : '否'},UTF-8=${r.utf8Readable ? '是' : '否'},疑似乱码=${r.mojibakeSuspected ? '是' : '否'},动态报告=${r.dynamicCandidate ? '是' : '否'}`);
if (r.dynamicFieldsMissing.length) lines.push(` - 缺少动态章节:${r.dynamicFieldsMissing.join('、')}`);
if (r.capabilityFieldsMissing.length) lines.push(` - 缺少能力章节:${r.capabilityFieldsMissing.join('、')}`);
}
} else lines.push('- 无');
if (result.problems.length) {
lines.push('', '## 问题');
for (const p of result.problems) lines.push(`- ${p}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const w of result.warnings) lines.push(`- ${w}`);
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) { console.error(err.message || String(err)); console.error(usage()); process.exit(1); }

View File

@@ -0,0 +1,165 @@
#!/usr/bin/env node
'use strict';
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = { python: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--python') args.python = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_tls_clients.js --markdown
node scripts/check_tls_clients.js --python python --json
说明:检测最终请求阶段可选的 TLS 指纹兼容客户端:Node.js CycleTLS / impers / curl-cffi-node、Python curl_cffi / cffi_curl / cyCronet;真实请求交付时必须再封装为 Session 模式。`;
}
function run(cmd, args, timeout = 12000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return {
ok: ret.status === 0,
status: ret.status,
stdout: (ret.stdout || '').trim(),
stderr: (ret.stderr || '').trim(),
error: ret.error ? ret.error.message : '',
command: [cmd].concat(args).join(' '),
};
}
function nodeRequire(name) {
const code = `
(async()=>{
const name=${JSON.stringify(name)};
try {
const m=require(name);
console.log(JSON.stringify({ok:true, loader:"require", keys:Object.keys(m).slice(0,20)}));
} catch (e1) {
try {
const m=await import(name);
console.log(JSON.stringify({ok:true, loader:"import", keys:Object.keys(m).slice(0,20)}));
} catch (e2) {
console.log(JSON.stringify({ok:false,error:e1.message + " / " + e2.message}));
process.exit(2);
}
}
})();`;
const ret = run(process.execPath, ['-e', code]);
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { name, installed: !!parsed.ok, loader: parsed.loader || '', keys: parsed.keys || [], error: parsed.error || '' };
} catch {
return { name, installed: false, loader: '', keys: [], error: ret.stderr || ret.error || '检测失败' };
}
}
function pythonCandidates(explicit) {
const out = [];
if (explicit) out.push({ cmd: explicit, prefix: [] });
out.push({ cmd: 'python', prefix: [] });
out.push({ cmd: 'python3', prefix: [] });
out.push({ cmd: 'py', prefix: ['-3'] });
const seen = new Set();
return out.filter(x => {
const k = x.cmd + ' ' + x.prefix.join(' ');
if (seen.has(k)) return false;
seen.add(k);
return true;
});
}
function detectPythonModules(explicitPython) {
const modules = ['curl_cffi', 'cffi_curl', 'cycronet', 'cyCronet'];
const code = [
'import importlib, json',
`mods=${JSON.stringify(modules)}`,
'out={}',
'for name in mods:',
' try:',
' m=importlib.import_module(name)',
' out[name]={"installed": True, "version": getattr(m, "__version__", "")}',
' except Exception as e:',
' out[name]={"installed": False, "error": str(e)}',
'print(json.dumps(out, ensure_ascii=False))',
].join('\n');
for (const c of pythonCandidates(explicitPython)) {
const ret = run(c.cmd, c.prefix.concat(['-c', code]));
if (!ret.ok && !ret.stdout) continue;
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { python: c.cmd, pythonArgsPrefix: c.prefix, modules: parsed };
} catch {}
}
return {
python: '',
pythonArgsPrefix: [],
modules: Object.fromEntries(modules.map(m => [m, { installed: false, error: '未找到可用 Python 或检测失败' }])),
};
}
function detect(args) {
const nodePackages = ['cycletls', '@luminati-io/cycletls', 'cycle-tls', 'cycleTls', 'impers', 'curl-cffi', 'curl-cffi-node'].map(nodeRequire);
const python = detectPythonModules(args.python);
const cycleTlsAvailable = nodePackages.some(p => p.installed && /cycle/i.test(p.name));
const impersAvailable = nodePackages.some(p => p.installed && p.name === 'impers');
const curlCffiNodeAvailable = nodePackages.some(p => p.installed && p.name === 'curl-cffi');
return {
node: {
executable: process.execPath,
packages: nodePackages,
cycleTlsAvailable,
impersAvailable,
curlCffiNodeAvailable,
nodeTlsAvailable: cycleTlsAvailable || impersAvailable || curlCffiNodeAvailable,
},
python,
pythonTlsAvailable: Object.values(python.modules).some(v => v && v.installed),
note: '这些客户端只用于授权范围内的最终低频请求验证;如果本 case 需要最终发送真实请求,应在前置阶段先选择 Node.js CycleTLS/impers/curl-cffi-node 或 Python curl_cffi/cyCronet,并在最终项目中封装为 Session 模式,不要等普通客户端失败后才考虑。',
};
}
function renderMarkdown(result) {
const lines = ['# TLS 指纹兼容客户端检测结果', '', `说明:${result.note}`, '', '## Node.js / CycleTLS / impers'];
lines.push(`- Node.js:${result.node.executable}`);
lines.push(`- 是否检测到 CycleTLS:${result.node.cycleTlsAvailable ? '是' : '否'}`);
lines.push(`- 是否检测到 impers:${result.node.impersAvailable ? '是' : '否'}`);
lines.push(`- 是否检测到 Node.js TLS 指纹兼容客户端:${result.node.nodeTlsAvailable ? '是' : '否'}`);
for (const p of result.node.packages) {
lines.push(`- ${p.name}:${p.installed ? '已安装' : '未安装'}${p.loader ? `(${p.loader})` : ''}${p.error ? `(${p.error})` : ''}`);
}
lines.push('', '## Python / curl_cffi / cffi_curl / cyCronet');
lines.push(`- Python:${result.python.python ? `${result.python.python} ${result.python.pythonArgsPrefix.join(' ')}`.trim() : '未检测到'}`);
lines.push(`- 是否检测到 Python TLS 客户端:${result.pythonTlsAvailable ? '是' : '否'}`);
for (const [name, info] of Object.entries(result.python.modules)) {
lines.push(`- ${name}:${info.installed ? '已安装' : '未安装'}${info.version ? `(版本 ${info.version})` : ''}${info.error && !info.installed ? `(${info.error})` : ''}`);
}
lines.push('', '## 下一步');
if (!result.node.nodeTlsAvailable && !result.pythonTlsAvailable) {
lines.push('- 未检测到可用 TLS 指纹兼容客户端;如果本 case 需要最终发送真实请求,需在前置阶段询问用户安装 CycleTLS / impers / curl_cffi / cyCronet,或改为只输出本地 sign 结果。');
} else {
lines.push('- 在任务确认阶段选择一个最终请求客户端,并限制为少量授权验证请求;最终项目中只保留所选客户端的 Session 请求逻辑。');
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = detect(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,345 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { check: checkRuntimeConformance } = require('./check_trace_runtime_conformance');
const ALLOWED_STATUSES = new Set([
'planned-first-pass',
'implemented-first-pass',
'needs-baseline-sampling',
'deferred-not-mounted',
'native-capability-gap',
'live-discovered',
'missed-from-trace',
]);
const PRIORITIES_REQUIRING_DECISION = new Set(['P0', 'P1']);
function parseArgs(argv) {
const args = {
caseDir: '',
inventory: '',
matrix: '',
contract: '',
nodeAudit: '',
requireRuntimeClosure: false,
requireStageAudit: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--inventory') args.inventory = argv[++i] || '';
else if (a === '--matrix') args.matrix = argv[++i] || '';
else if (a === '--contract') args.contract = argv[++i] || '';
else if (a === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (a === '--require-runtime-closure') args.requireRuntimeClosure = true;
else if (a === '--require-stage-audit') args.requireStageAudit = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_trace_api_coverage.js --case-dir case --markdown
node scripts/check_trace_api_coverage.js --case-dir case --require-stage-audit --require-runtime-closure --json
node scripts/check_trace_api_coverage.js --inventory case/notes/trace-api-inventory.json --matrix case/notes/env-coverage-matrix.md --markdown
说明:检查 Trace API inventory、env coverage matrix、实现文件和 Trace-runtime 行为闭环,以及后续阶段报告中计划外新增 WebAPI 是否说明原因。`;
}
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function readText(p) {
return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
}
function readJson(p) {
return JSON.parse(readText(p));
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function normalizePriority(value) {
const text = String(value || '').trim().toUpperCase();
if (/^P?[0-2]$/.test(text)) return text.startsWith('P') ? text : `P${text}`;
return text || 'UNKNOWN';
}
function normalizeStatus(value) {
return String(value || '').trim();
}
function normalizeInventory(raw) {
if (Array.isArray(raw)) return raw;
if (Array.isArray(raw.apis)) return raw.apis;
if (raw.inventory && Array.isArray(raw.inventory.apis)) return raw.inventory.apis;
if (raw.apiInventory && Array.isArray(raw.apiInventory)) return raw.apiInventory;
return [];
}
function implementationPath(caseDir, file) {
if (!file) return '';
return path.isAbsolute(file) ? file : path.resolve(caseDir, file);
}
function listMarkdown(dir) {
if (!exists(dir)) return [];
const out = [];
for (const name of fs.readdirSync(dir)) {
const file = path.join(dir, name);
try {
if (fs.statSync(file).isFile() && name.toLowerCase().endsWith('.md')) out.push(file);
} catch {}
}
return out.sort();
}
function hasTraceEvidence(caseDir) {
const candidates = [
path.join(caseDir, 'ruyi-trace'),
path.join(caseDir, 'notes', 'ruyitrace-summary.md'),
path.join(caseDir, 'tmp', 'env-trace.jsonl'),
path.join(caseDir, 'tmp', 'missing-env.json'),
];
return candidates.some(exists);
}
function auditMatrixText(text) {
const required = ['Trace', 'baselineId', 'P0', 'P1', '首轮', '采样', '暂不挂载', '计划外'];
return required.filter(key => !text.includes(key));
}
function auditStageReports(caseDir) {
const stageDir = path.join(caseDir, '阶段报告');
const files = listMarkdown(stageDir);
const findings = [];
const reasonPattern = /trace-not-covered|dynamic-resource-new-branch|baseline-mismatch|trace-truncated|native-gap|missed-from-trace|Trace 未覆盖|动态资源新分支|baseline|截断|能力缺口|矩阵遗漏|流程缺陷/;
for (const file of files) {
const text = readText(file);
const mentionsOldHeading = text.includes('本阶段新增 / 修改的 WebAPI');
const hasPlanned = text.includes('Trace 计划内') || text.includes('计划内首轮');
const hasUnplanned = text.includes('计划外新增');
if (mentionsOldHeading && !hasPlanned && !hasUnplanned) {
findings.push({
file: rel(caseDir, file),
problem: '阶段报告仍使用笼统的“本阶段新增 / 修改的 WebAPI”,未拆分 Trace 计划内与计划外新增。',
});
}
if (hasUnplanned) {
const section = text.slice(text.indexOf('计划外新增'));
if (!reasonPattern.test(section)) {
findings.push({
file: rel(caseDir, file),
problem: '计划外新增 WebAPI 缺少原因枚举或证据说明。',
});
}
}
}
return findings;
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const notesDir = path.join(caseDir, 'notes');
const inventoryPath = path.resolve(args.inventory || path.join(notesDir, 'trace-api-inventory.json'));
const matrixPath = path.resolve(args.matrix || path.join(notesDir, 'env-coverage-matrix.md'));
const contractPath = path.resolve(args.contract || path.join(notesDir, 'trace-runtime-contract.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json'));
const problems = [];
const warnings = [];
const traceDetected = hasTraceEvidence(caseDir);
if (traceDetected && !exists(inventoryPath)) problems.push(`存在 Trace 证据,但缺少 ${rel(caseDir, inventoryPath)}`);
if (traceDetected && !exists(matrixPath)) problems.push(`存在 Trace 证据,但缺少 ${rel(caseDir, matrixPath)}`);
let apis = [];
let inventoryMeta = {};
if (exists(inventoryPath)) {
try {
const raw = readJson(inventoryPath);
inventoryMeta = {
schemaVersion: raw.schemaVersion || '',
source: raw.source || '',
baselineId: raw.baselineId || '',
};
apis = normalizeInventory(raw);
if (!apis.length) problems.push('trace-api-inventory.json 没有可识别的 apis 数组');
} catch (err) {
problems.push(`trace-api-inventory.json 无法解析为 JSON:${err.message}`);
}
}
const apiResults = [];
for (const item of apis) {
const api = item.api || item.path || item.name || '(unknown)';
const priority = normalizePriority(item.priority || item.level);
const status = normalizeStatus(item.implementationStatus || item.status || item.coverageStatus);
const reason = String(item.reason || item.decisionReason || '').trim();
const implementationFile = String(item.implementationFile || item.file || '').trim();
const samplingRequired = Boolean(item.samplingRequired || status === 'needs-baseline-sampling');
const resolvedImplementationFile = implementationPath(caseDir, implementationFile);
const result = { api, priority, status, reason, implementationFile, resolvedImplementationFile, samplingRequired, clean: true, problems: [] };
if (!status || status === 'unplanned') {
result.problems.push('缺少实现状态或状态为 unplanned');
} else if (!ALLOWED_STATUSES.has(status)) {
result.problems.push(`未知状态:${status}`);
}
if (PRIORITIES_REQUIRING_DECISION.has(priority) && status === 'planned-first-pass' && !implementationFile) {
result.problems.push('P0/P1 首轮实现项缺少 implementationFile');
}
if (status === 'implemented-first-pass' && (!implementationFile || !exists(resolvedImplementationFile))) {
result.problems.push(`implemented-first-pass 对应实现文件不存在:${implementationFile || '未记录'}`);
}
if (args.requireRuntimeClosure && PRIORITIES_REQUIRING_DECISION.has(priority) && status === 'planned-first-pass') {
result.problems.push('进入 runtime closure 阶段后 P0/P1 不得仍为 planned-first-pass');
}
if (['needs-baseline-sampling', 'deferred-not-mounted', 'native-capability-gap', 'missed-from-trace', 'live-discovered'].includes(status) && !reason) {
result.problems.push(`${status} 必须写明 reason`);
}
if (status === 'missed-from-trace') {
result.problems.push('Trace 已命中但矩阵遗漏,必须作为流程缺陷修复后再继续');
}
if (samplingRequired && status !== 'needs-baseline-sampling' && !reason) {
result.problems.push('samplingRequired=true 时必须说明采样策略或阻塞原因');
}
if (result.problems.length) {
result.clean = false;
for (const p of result.problems) problems.push(`${api}:${p}`);
}
apiResults.push(result);
}
let matrixMissing = [];
if (exists(matrixPath)) {
const text = readText(matrixPath);
matrixMissing = auditMatrixText(text);
if (matrixMissing.length) problems.push(`env-coverage-matrix.md 缺少关键内容:${matrixMissing.join('、')}`);
}
const stageFindings = args.requireStageAudit ? auditStageReports(caseDir) : [];
for (const finding of stageFindings) problems.push(`${finding.file}:${finding.problem}`);
let runtimeConformance = null;
if (args.requireRuntimeClosure) {
runtimeConformance = checkRuntimeConformance({
caseDir,
contract: contractPath,
nodeAudit: nodeAuditPath,
strictP2: false,
});
for (const problem of runtimeConformance.problems) problems.push(`Trace-runtime:${problem}`);
const inventoryApis = new Set(apiResults.map(item => item.api));
if (exists(contractPath)) {
try {
const contractRaw = readJson(contractPath);
const contractItems = normalizeInventory({ apis: contractRaw.contracts || [] });
const missingFromInventory = [...new Set(contractItems.map(item => item.api || item.path || item.name).filter(Boolean))]
.filter(api => !inventoryApis.has(api));
if (missingFromInventory.length) {
problems.push(`trace-api-inventory.json 缺少 runtime contract 中的 API:${missingFromInventory.slice(0, 50).join('、')}`);
}
} catch (err) {
problems.push(`无法核对 runtime contract 与 inventory:${err.message}`);
}
}
}
return {
caseDir,
traceDetected,
inventoryPath,
matrixPath,
contractPath,
nodeAuditPath,
inventoryMeta,
clean: problems.length === 0,
problems,
warnings,
apiCount: apiResults.length,
apiResults,
matrixMissing,
stageFindings,
runtimeConformance,
};
}
function renderMarkdown(result) {
const lines = [
'# Trace API 覆盖矩阵检查结果',
'',
`case 目录:${result.caseDir}`,
`检测到 Trace 证据:${result.traceDetected ? '是' : '否'}`,
`inventory:${result.inventoryPath}`,
`matrix:${result.matrixPath}`,
`runtime contract:${result.contractPath}`,
`Node runtime audit:${result.nodeAuditPath}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## Inventory 摘要',
`- schemaVersion:${result.inventoryMeta.schemaVersion || '未记录'}`,
`- source:${result.inventoryMeta.source || '未记录'}`,
`- baselineId:${result.inventoryMeta.baselineId || '未记录'}`,
`- API 数量:${result.apiCount}`,
];
if (result.apiResults.length) {
lines.push('', '## API 状态');
for (const item of result.apiResults.slice(0, 80)) {
lines.push(`- ${item.clean ? '通过' : '失败'} ${item.priority} ${item.api}:${item.status || '未记录'}${item.implementationFile ? `;文件 ${item.implementationFile}` : ''}`);
for (const problem of item.problems) lines.push(` - ${problem}`);
}
if (result.apiResults.length > 80) lines.push(`- 其余 ${result.apiResults.length - 80} 条略。`);
}
if (result.matrixMissing.length) {
lines.push('', '## Matrix 缺失项');
for (const item of result.matrixMissing) lines.push(`- ${item}`);
}
if (result.stageFindings.length) {
lines.push('', '## 阶段报告问题');
for (const item of result.stageFindings) lines.push(`- ${item.file}:${item.problem}`);
}
if (result.runtimeConformance) {
lines.push('', '## Trace-runtime 闭环');
lines.push(`- 是否通过:${result.runtimeConformance.clean ? '是' : '否'}`);
lines.push(`- 契约项:${result.runtimeConformance.summary.contractCount ?? 0}`);
lines.push(`- 阻断差异:${result.runtimeConformance.summary.blockingMismatches ?? 0}`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (!result.problems.length) lines.push('', '## 结论', '- Trace API 覆盖矩阵检查通过。');
return `${lines.join('\n')}\n`;
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const BLOCKING_PRIORITIES = new Set(['P0', 'P1']);
const ASSERTION_FIELDS = [
'owner',
'brand',
'constructorName',
'descriptor',
'prototypeChain',
'ownKeys',
'ownPropertyNames',
'ownSymbols',
'argsDigest',
'resultDigest',
'errorDigest',
'sideEffectsDigest',
];
function parseArgs(argv) {
const args = {
caseDir: '',
contract: '',
nodeAudit: '',
strictP2: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--contract') args.contract = argv[++i] || '';
else if (arg === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (arg === '--strict-p2') args.strictP2 = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_trace_runtime_conformance.js --case-dir case --markdown
node scripts/check_trace_runtime_conformance.js --contract case/notes/trace-runtime-contract.json --node-audit case/tmp/node-trace-runtime-audit.json --strict-p2 --json
说明:逐项比较 Trace 行为契约与 Node runtime audit。P0/P1 缺失或行为不一致会直接阻断,不能用手工填写 matched 绕过。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function stable(value) {
if (Array.isArray(value)) return value.map(stable);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
}
function digest(value) {
if (typeof value === 'undefined') return '';
if (value && typeof value === 'object' && typeof value.digest === 'string') return value.digest;
if (typeof value === 'string' && /^[a-f0-9]{64}$/i.test(value)) return value.toLowerCase();
try { return sha256(JSON.stringify(stable(value))); } catch { return sha256(String(value)); }
}
function normalizeList(value) {
if (typeof value === 'undefined') return [];
const list = Array.isArray(value) ? value : [value];
return list.map(item => {
if (item && typeof item === 'object' && typeof item.digest === 'string') return item.digest.toLowerCase();
return digest(item);
}).filter(Boolean).sort();
}
function itemKey(item) {
if (item.id) return `id:${item.id}`;
return ['key', item.api || item.path || item.name || '', item.accessType || item.operation || item.type || 'unknown', item.realm || item.realmId || 'main', item.receiver || item.receiverType || '', item.phase || ''].join('\u001f');
}
function normalizeItems(raw) {
if (Array.isArray(raw)) return raw;
for (const key of ['contracts', 'observations', 'items', 'apis', 'events']) {
if (Array.isArray(raw && raw[key])) return raw[key];
}
return [];
}
function contractHash(contract) {
if (contract.contractHash) return String(contract.contractHash);
return sha256(JSON.stringify(stable({
schemaVersion: contract.schemaVersion,
baselineId: contract.baselineId,
traceSourceHash: contract.traceSourceHash,
contracts: normalizeItems(contract),
timeline: contract.timeline || {},
})));
}
function expectedDigests(contractItem, field) {
const assertions = contractItem.assertions || {};
return normalizeList(assertions[field]);
}
function observedDigests(auditItem, field) {
const observations = auditItem.observations || {};
if (typeof observations[field] !== 'undefined') return normalizeList(observations[field]);
if (typeof auditItem[field] !== 'undefined') return normalizeList(auditItem[field]);
return [];
}
function compareItem(contractItem, auditItem) {
const diffs = [];
for (const field of ASSERTION_FIELDS) {
const expected = expectedDigests(contractItem, field);
if (!expected.length) continue;
const observed = observedDigests(auditItem, field);
if (!observed.length) {
diffs.push({ field, type: 'missing-observation', expected, observed });
continue;
}
const missing = expected.filter(item => !observed.includes(item));
if (missing.length) diffs.push({ field, type: 'value-mismatch', expected, observed, missing });
}
if (Number(contractItem.count || 0) > 1) {
const observedSequences = Array.isArray(auditItem.sequences)
? auditItem.sequences
: (typeof auditItem.sequence !== 'undefined' ? [auditItem.sequence] : []);
if (observedSequences.length < Math.min(Number(contractItem.count), 2)) {
diffs.push({
field: 'sequences',
type: 'insufficient-sequence-evidence',
expected: Math.min(Number(contractItem.count), 2),
observed: observedSequences.length,
});
}
}
return diffs;
}
function compareTimeline(contract, audit, problems) {
const expected = contract.timeline || {};
if (!expected.required) return { required: false, clean: true, expectedHash: '', observedHash: '' };
const observed = audit.timeline || {};
if (observed.generatedFrom !== 'runtime-event-timeline') {
problems.push('Node audit timeline 不是由原始 runtime event timeline 生成;分组 observations 不能证明 happens-before 顺序');
}
if (observed.valid !== true) {
problems.push(`Node audit timeline 含无效项或重复 sequence:invalid=${observed.invalidEntries || 0},duplicate=${observed.duplicateSequences || 0}`);
}
const observedSequence = Array.isArray(observed.sequence) ? observed.sequence.map(String) : [];
if (!observedSequence.length) {
problems.push('Trace contract 包含状态型 API 时间线,但 Node audit 没有 timeline.sequence;逐 API matched 不能替代生命周期顺序');
return {
required: true,
clean: false,
expectedHash: expected.sequenceSha256 || '',
observedHash: observed.sequenceSha256 || '',
};
}
const observedHash = sha256(JSON.stringify(observedSequence));
const expectedHash = String(expected.sequenceSha256 || '');
if (!expectedHash) {
problems.push('Trace contract timeline 缺少 sequenceSha256,必须重新生成 v3 contract');
} else if (observedHash !== expectedHash) {
problems.push(`状态型 API 时间线不一致:${observedHash} != ${expectedHash}`);
}
return {
required: true,
clean: Boolean(expectedHash)
&& observed.generatedFrom === 'runtime-event-timeline'
&& observed.valid === true
&& observedHash === expectedHash,
expectedHash,
observedHash,
expectedCount: Number(expected.collapsedCount || 0),
observedCount: observedSequence.length,
};
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const contractPath = path.resolve(args.contract || path.join(caseDir, 'notes', 'trace-runtime-contract.json'));
const auditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-trace-runtime-audit.json'));
const problems = [];
const warnings = [];
if (!exists(contractPath)) problems.push(`缺少 Trace runtime contract:${contractPath}`);
if (!exists(auditPath)) problems.push(`缺少 Node runtime audit:${auditPath}`);
if (problems.length) {
return { caseDir, contractPath, auditPath, clean: false, problems, warnings, results: [], summary: {} };
}
let contract;
let audit;
try { contract = readJson(contractPath); } catch (err) { problems.push(`Trace runtime contract 无法解析:${err.message}`); }
try { audit = readJson(auditPath); } catch (err) { problems.push(`Node runtime audit 无法解析:${err.message}`); }
if (!contract || !audit) return { caseDir, contractPath, auditPath, clean: false, problems, warnings, results: [], summary: {} };
if (contract.schemaVersion !== 'trace-runtime-contract/v3') problems.push(`Trace contract 必须使用 trace-runtime-contract/v3,当前为:${contract.schemaVersion || '未记录'};旧 contract 没有可信状态时间线`);
if (!/^node-trace-runtime-audit\/v3$/.test(String(audit.schemaVersion || ''))) {
problems.push(`Node audit 必须使用 node-trace-runtime-audit/v3,当前为:${audit.schemaVersion || '未记录'}`);
}
if (audit.generatedBy !== 'run_trace_runtime_audit.js' && audit.generatedBy !== 'runtime-audit-recorder/v3') {
problems.push(`Node audit generatedBy 不可信:${audit.generatedBy || '未记录'}`);
}
const expectedContractHash = contractHash(contract);
if (!audit.contractHash || audit.contractHash !== expectedContractHash) {
problems.push(`Node audit contractHash 与当前 contract 不一致:${audit.contractHash || '未记录'} != ${expectedContractHash}`);
}
if (contract.baselineId && audit.baselineId !== contract.baselineId) {
problems.push(`baselineId 不一致:${contract.baselineId} != ${audit.baselineId || '未记录'}`);
}
if (!audit.runtimeSourceHash) problems.push('Node audit 缺少 runtimeSourceHash,无法判断审计是否已因源码变化失效');
if (!audit.probeVersion) problems.push('Node audit 缺少 probeVersion');
if (audit.networkMode !== 'no-send') problems.push(`Trace runtime audit 必须在 no-send 模式执行,当前为:${audit.networkMode || '未记录'}`);
if (!Object.prototype.hasOwnProperty.call(audit, 'networkAttempts') || Number(audit.networkAttempts) !== 0) {
problems.push(`Trace runtime audit 必须明确记录 networkAttempts=0,当前为:${Object.prototype.hasOwnProperty.call(audit, 'networkAttempts') ? audit.networkAttempts : '未记录'}`);
}
const contractItems = normalizeItems(contract);
const auditItems = normalizeItems(audit);
const auditByKey = new Map();
for (const item of auditItems) {
auditByKey.set(itemKey(item), item);
if (item.id) auditByKey.set(`id:${item.id}`, item);
}
const results = [];
for (const contractItem of contractItems) {
const key = contractItem.id ? `id:${contractItem.id}` : itemKey(contractItem);
const auditItem = auditByKey.get(key) || auditByKey.get(itemKey(contractItem));
const priority = String(contractItem.priority || 'P2').toUpperCase();
const blocking = BLOCKING_PRIORITIES.has(priority) || args.strictP2;
const diffs = auditItem ? compareItem(contractItem, auditItem) : [{ field: '*', type: 'missing-runtime-observation' }];
const clean = diffs.length === 0;
if (!clean) {
const message = `${priority} ${contractItem.api || contractItem.name || contractItem.id}:${diffs.map(item => `${item.field}:${item.type}`).join('、')}`;
if (blocking) problems.push(message);
else warnings.push(message);
}
results.push({
id: contractItem.id || '',
api: contractItem.api || contractItem.name || '',
priority,
clean,
blocking,
diffs,
});
}
const unexpectedAuditItems = auditItems.filter(item => {
const key = item.id ? `id:${item.id}` : itemKey(item);
return !contractItems.some(contractItem => (contractItem.id ? `id:${contractItem.id}` : itemKey(contractItem)) === key);
});
if (unexpectedAuditItems.length) warnings.push(`Node audit 出现 ${unexpectedAuditItems.length} 个 Trace contract 未记录的 runtime 观测项,必须分类为新动态分支或宿主泄露`);
const timeline = compareTimeline(contract, audit, problems);
const summary = {
contractCount: contractItems.length,
auditCount: auditItems.length,
matched: results.filter(item => item.clean).length,
mismatched: results.filter(item => !item.clean).length,
blockingMismatches: results.filter(item => !item.clean && item.blocking).length,
unexpectedAuditItems: unexpectedAuditItems.length,
baselineId: contract.baselineId || audit.baselineId || '',
traceSourceHash: contract.traceSourceHash || audit.traceSourceHash || '',
contractHash: expectedContractHash,
runtimeSourceHash: audit.runtimeSourceHash || '',
networkAttempts: audit.networkAttempts,
timeline,
};
return { caseDir, contractPath, auditPath, clean: problems.length === 0, problems, warnings, results, summary };
}
function renderMarkdown(result) {
const lines = [
'# Trace-runtime 一致性检查结果',
'',
`- contract:${result.contractPath}`,
`- Node audit:${result.auditPath}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
];
if (result.summary && typeof result.summary.contractCount !== 'undefined') {
lines.push(`- 契约项:${result.summary.contractCount}`);
lines.push(`- Node audit 项:${result.summary.auditCount}`);
lines.push(`- matched:${result.summary.matched}`);
lines.push(`- mismatch:${result.summary.mismatched}`);
lines.push(`- P0/P1 阻断差异:${result.summary.blockingMismatches}`);
lines.push(`- baselineId:${result.summary.baselineId || '未记录'}`);
lines.push(`- traceSourceHash:${result.summary.traceSourceHash || '未记录'}`);
lines.push(`- runtimeSourceHash:${result.summary.runtimeSourceHash || '未记录'}`);
lines.push(`- 真实网络尝试:${String(result.summary.networkAttempts ?? '未记录')}`);
if (result.summary.timeline && result.summary.timeline.required) {
lines.push(`- 状态时间线:${result.summary.timeline.clean ? 'matched' : 'mismatch'}`);
lines.push(`- 状态时间线项:browser=${result.summary.timeline.expectedCount || 0},Node=${result.summary.timeline.observedCount || 0}`);
}
}
const bad = result.results.filter(item => !item.clean);
if (bad.length) {
lines.push('', '## 差异');
for (const item of bad.slice(0, 100)) {
lines.push(`- ${item.priority} ${item.api || item.id}:${item.diffs.map(diff => `${diff.field}:${diff.type}`).join('、')}`);
}
}
if (result.problems.length) {
lines.push('', '## 阻断问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const warning of result.warnings) lines.push(`- ${warning}`);
}
return `${lines.join('\n')}\n`;
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { check, compareItem, contractHash };

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function parseArgs(argv) {
const args = {
skillDir: '',
skillsRoot: '',
require: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--skill-dir') args.skillDir = argv[++i] || '';
else if (a === '--skills-root') args.skillsRoot = argv[++i] || '';
else if (a === '--require') args.require = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_web_verify_patcher.js --markdown
node scripts/check_web_verify_patcher.js --require --json
node scripts/check_web_verify_patcher.js --skill-dir <web-verify-patcher-dir> --markdown
node scripts/check_web_verify_patcher.js --skills-root <skills-root> --require --markdown
说明:在把验证码识别、轨迹生成或验证提交任务交给 web-verify-patcher 前,检查该 Skill 是否已安装,并输出未安装时的中文安装指引。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function readText(p) {
try { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); } catch { return ''; }
}
function unique(list) {
return [...new Set(list.filter(Boolean).map(p => path.normalize(p)))];
}
function defaultCandidates(args) {
if (args.skillDir) return [path.resolve(args.skillDir)];
if (args.skillsRoot) return [path.join(path.resolve(args.skillsRoot), 'web-verify-patcher')];
const roots = [];
if (process.env.CODEX_HOME) roots.push(path.join(process.env.CODEX_HOME, 'skills'));
if (process.env.AGENTS_HOME) roots.push(path.join(process.env.AGENTS_HOME, 'skills'));
const home = os.homedir();
roots.push(path.join(home, '.codex', 'skills'));
roots.push(path.join(home, '.agents', 'skills'));
roots.push(path.join(process.cwd(), 'skills'));
return unique(roots).map(root => path.join(root, 'web-verify-patcher'));
}
function inspectSkill(dir) {
const skillMd = path.join(dir, 'SKILL.md');
const text = readText(skillMd);
const nameMatch = text.match(/^name:\s*([^\r\n]+)/m);
const descriptionMatch = text.match(/^description:\s*(.+)$/m);
return {
dir,
skillMd,
exists: exists(skillMd),
name: nameMatch ? nameMatch[1].replace(/["']/g, '').trim() : '',
hasExpectedName: /name:\s*web-verify-patcher\b/m.test(text),
description: descriptionMatch ? descriptionMatch[1].trim().slice(0, 160) : '',
};
}
function inspect(args) {
const candidates = defaultCandidates(args).map(inspectSkill);
const installed = candidates.find(c => c.exists && c.hasExpectedName) || null;
const report = {
ok: Boolean(installed),
installed: Boolean(installed),
selected: installed,
candidates,
installRepo: 'https://github.com/lwjjike/xbsReverseSkill',
expectedFolder: 'web-verify-patcher',
instructions: [
'自动安装前先让用户确认安装目录;克隆仓库后必须检查是否存在 web-verify-patcher/ 目录。',
'如果仓库当前分支没有 web-verify-patcher/,不得假装安装成功,要求用户提供正确分支、正确仓库、压缩包或本地目录。',
'自行安装时,将 web-verify-patcher 文件夹放到 $HOME/.codex/skills/web-verify-patcher;Windows 可使用 %USERPROFILE%\\.codex\\skills\\web-verify-patcher。',
'如当前环境使用 .agents/skills,也可同步放到 $HOME/.agents/skills/web-verify-patcher。安装后重启或刷新 Codex 再检测。',
],
};
return report;
}
function toMarkdown(report) {
const lines = [];
lines.push('# web-verify-patcher 安装检测');
lines.push('');
lines.push(`- 检测结果:${report.ok ? '已安装' : '未检测到可用安装'}`);
if (report.selected) lines.push(`- 命中目录:${report.selected.dir}`);
lines.push(`- 预期 Skill 名称:web-verify-patcher`);
lines.push(`- 建议仓库:${report.installRepo}`);
lines.push('');
lines.push('## 检测路径');
lines.push('');
for (const c of report.candidates) {
lines.push(`- ${c.exists ? '存在' : '不存在'}:${c.skillMd}${c.exists ? `,name=${c.name || '未识别'}` : ''}`);
}
if (!report.ok) {
lines.push('');
lines.push('## 安装指引');
lines.push('');
for (const item of report.instructions) lines.push(`- ${item}`);
}
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const report = inspect(args);
if (args.json) console.log(JSON.stringify(report, null, 2));
else console.log(toMarkdown(report));
if (args.require && !report.ok) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(`web-verify-patcher 检测失败:${err.message}`);
process.exit(1);
}

View File

@@ -0,0 +1,738 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
file: '',
json: false,
markdown: false,
warningsAsErrors: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--dir') args.dir = argv[++i] || '';
else if (arg === '--file' || arg === '-f') args.file = argv[++i] || '';
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--warnings-as-errors') args.warningsAsErrors = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_webapi_addon_coverage.js --case-dir case --markdown
node scripts/check_webapi_addon_coverage.js --dir case/result/src/env --json
node scripts/check_webapi_addon_coverage.js --file case/result/src/env/install-env.js --markdown
说明:检查补环境代码是否存在普通函数、普通对象、直接复用宿主 Web API、prototype 对象字面量等绕过 addon-first 的写法。该检查用于交付门禁,失败时不得交付最终项目。`;
}
function exists(filePath) {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
function stat(filePath) {
try {
return fs.statSync(filePath);
} catch {
return null;
}
}
function rel(root, filePath) {
return (path.relative(root, filePath) || '.').replace(/\\/g, '/');
}
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
}
function walk(filePath, out = []) {
if (!exists(filePath)) return out;
const st = stat(filePath);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try {
names = fs.readdirSync(filePath);
} catch {
names = [];
}
for (const name of names) walk(path.join(filePath, name), out);
} else if (st.isFile()) {
out.push(filePath);
}
return out;
}
function isJsFile(filePath) {
return ['.js', '.mjs', '.cjs'].includes(path.extname(filePath).toLowerCase());
}
function shouldSkip(root, filePath) {
const relative = rel(root, filePath).toLowerCase();
if (/(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(relative)) return true;
if (/(^|\/)src\/target\/(original|vendor|bundle|bundles)(\/|$)/.test(relative)) return true;
if (/(\.min\.js|bundle\.js|vendor\.js|package-lock\.json)$/i.test(relative)) return true;
return false;
}
function lineOf(text, index) {
return text.slice(0, index).split(/\r?\n/).length;
}
function lineText(text, line) {
const lines = text.split(/\r?\n/);
return (lines[line - 1] || '').trim();
}
function addIssue(list, severity, file, line, type, message, suggestion, snippet) {
list.push({
severity,
file,
line,
type,
message,
suggestion,
snippet: String(snippet || '').slice(0, 220),
});
}
function targetExpr(name) {
return `(?:ctx|window|self|globalThis|globalObject|global)\\s*(?:\\.\\s*${name}|\\[\\s*['"]${name}['"]\\s*\\])`;
}
function escapedJoin(names) {
return names.map(name => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
}
const CONSTRUCTORS = [
'Blob',
'File',
'FormData',
'Event',
'CustomEvent',
'MessageEvent',
'XMLHttpRequest',
'Worker',
'Image',
'MutationObserver',
'IntersectionObserver',
'ResizeObserver',
'BroadcastChannel',
'MessageChannel',
'MessagePort',
'AudioContext',
'OfflineAudioContext',
'Screen',
'ScreenOrientation',
'URL',
'Headers',
'Request',
'Response',
'WebSocket',
'IDBFactory',
'IDBOpenDBRequest',
'IDBRequest',
'IDBDatabase',
'IDBTransaction',
'IDBObjectStore',
'IDBIndex',
'IDBCursor',
'IDBKeyRange',
'HTMLCanvasElement',
'CanvasRenderingContext2D',
'WebGLRenderingContext',
'WebGL2RenderingContext',
'CSSStyleDeclaration',
'HTMLCollection',
'NodeList',
'PluginArray',
'MimeTypeArray',
'Plugin',
'MimeType',
'DOMTokenList',
'StyleSheetList',
];
const OBJECT_LITERAL_TARGETS = [
'screen',
'orientation',
'indexedDB',
'IDBKeyRange',
'CSS',
'navigator',
'document',
'location',
'localStorage',
'sessionStorage',
'crypto',
'performance',
'history',
'visualViewport',
];
const WEBAPI_METHODS = [
'open',
'deleteDatabase',
'cmp',
'databases',
'createObjectURL',
'revokeObjectURL',
'supports',
'escape',
'getContext',
'toDataURL',
'toBlob',
'getImageData',
'putImageData',
'measureText',
'getParameter',
'readPixels',
'getSupportedExtensions',
'getShaderPrecisionFormat',
'startRendering',
'createElement',
'querySelector',
'querySelectorAll',
'getElementById',
'addEventListener',
'removeEventListener',
'dispatchEvent',
'postMessage',
'close',
'send',
'abort',
'setRequestHeader',
'getResponseHeader',
'getAllResponseHeaders',
'item',
'namedItem',
];
const HOST_REUSE = [
'TextEncoder',
'TextDecoder',
'URL',
'URLSearchParams',
'WebAssembly',
'fetch',
'Headers',
'Request',
'Response',
'Event',
'EventTarget',
'MessageChannel',
'BroadcastChannel',
'crypto',
'ReadableStream',
'WritableStream',
'TransformStream',
'Blob',
'File',
'FormData',
];
const COLLECTION_CONSTRUCTORS = [
'HTMLCollection',
'NodeList',
'PluginArray',
'MimeTypeArray',
'DOMTokenList',
'StyleSheetList',
];
const HELPER_EVIDENCE = /(?:createNativeFunction|createNativeConstructor|createGetter|createSetter|createNativeGetter|createNativeSetter|defineNativeGetter|defineNativeSetter|defineNativeAccessor|createProtoChains|createNativeObject|createNativeCollection|getMimeTypesAndPlugins|createInterceptor|createUndetectable|setPrivate|getPrivate|hasPrivate|deletePrivate|nativeApi|addon\.|xbs\.|window\.xbs\.|globalThis\.xbs\.)\s*\(/;
function scanMatches(text, pattern, onMatch) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text))) {
onMatch(match);
if (match[0].length === 0) pattern.lastIndex += 1;
}
}
function hasConstructorAddonEvidence(text, name) {
const quoted = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const createNative = new RegExp(`createNativeFunction\\s*\\(\\s*true\\s*,\\s*['"]${quoted}['"]`);
const createCtor = new RegExp(`createNativeConstructor\\s*\\(\\s*['"]${quoted}['"]`);
const protoChains = new RegExp(`createProtoChains\\s*\\([\\s\\S]{0,1200}name\\s*:\\s*['"]${quoted}['"]`);
return createNative.test(text) || createCtor.test(text) || protoChains.test(text);
}
function inspectFile(root, filePath) {
const relative = rel(root, filePath);
const text = readText(filePath);
const issues = [];
const warnings = [];
for (const name of CONSTRUCTORS) {
const directFunction = new RegExp(`${targetExpr(name)}\\s*=\\s*(?:async\\s*)?function\\b`, 'g');
scanMatches(text, directFunction, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-constructor-plain-function',
`${name} 被直接赋值为普通 function,绕过了 addon-first 的构造函数与 toString 保护。`,
`使用 addon.createProtoChains([{ name: '${name}', ... }]) 或 addon.createNativeFunction(true, '${name}', length, callback) 创建,并设置 prototype.constructor、Symbol.toStringTag、非法构造行为和描述符。`,
lineText(text, line)
);
});
const classDeclaration = new RegExp(`\\bclass\\s+${name}\\b`, 'g');
scanMatches(text, classDeclaration, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-constructor-plain-class',
`${name} 使用普通 class 实现,默认不具备浏览器 native-like 构造函数行为。`,
`优先使用 addon.createProtoChains 或 addon.createNativeFunction(true, '${name}', ...);addon 不可用时才用 JS fallback,并记录降级原因。`,
lineText(text, line)
);
});
const identifierAssign = new RegExp(`${targetExpr(name)}\\s*=\\s*${name}\\s*;`, 'g');
scanMatches(text, identifierAssign, match => {
if (hasConstructorAddonEvidence(text, name)) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'webapi-constructor-no-addon-evidence',
`${name} 被挂载到全局,但当前文件没有明显的 createProtoChains / createNativeFunction(true, '${name}') 证据。`,
`确认 ${name} 的构造函数来自 addon-first helper;如果由其他模块创建,应在本文件或阶段报告中记录来源,并确保交付前整体运行本检查。`,
lineText(text, line)
);
});
}
for (const name of OBJECT_LITERAL_TARGETS) {
const objectLiteral = new RegExp(`${targetExpr(name)}\\s*=\\s*\\{`, 'g');
scanMatches(text, objectLiteral, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-instance-plain-object',
`${name} 被直接赋值为普通对象,缺少浏览器原型链、实例 toString、descriptor 和 addon-first 证据。`,
`为 ${name} 建立对应构造函数和 prototype,例如 Screen / IDBFactory / CSS 等;使用 createProtoChains 创建实例,再用 defineProperty 安装属性和 native-like 方法。`,
lineText(text, line)
);
});
}
const pluginMimeInstall = /\b(?:plugins|mimeTypes)\b/;
const installsNavigatorPluginMime = /Object\s*\.\s*defineProperty\s*\(\s*(?:navigator|Navigator\s*\.\s*prototype)[\s\S]{0,260}['"](?:plugins|mimeTypes)['"]|(?:navigator|Navigator\s*\.\s*prototype)\s*\.\s*(?:plugins|mimeTypes)\s*=/.test(text);
const hasMimePluginAddon = /\b(?:getMimeTypesAndPlugins|addon\s*\.\s*getMimeTypesAndPlugins|nativeApi\s*\.\s*getMimeTypesAndPlugins|xbs\s*\.\s*getMimeTypesAndPlugins|window\s*\.\s*xbs\s*\.\s*getMimeTypesAndPlugins|globalThis\s*\.\s*xbs\s*\.\s*getMimeTypesAndPlugins)\s*\(/.test(text);
if (pluginMimeInstall && installsNavigatorPluginMime && !hasMimePluginAddon) {
const index = text.search(/(?:plugins|mimeTypes)/);
const line = lineOf(text, index >= 0 ? index : 0);
addIssue(
issues,
'error',
relative,
line,
'navigator-plugins-mimetypes-without-addon',
'发现 navigator.plugins / navigator.mimeTypes 安装逻辑,但未发现 getMimeTypesAndPlugins addon-first 证据。',
'addon.node 或 xbs isolated-vm 可用时必须优先使用 addon.getMimeTypesAndPlugins(config) / xbs.getMimeTypesAndPlugins(config) 或 addon-first helper;真实浏览器插件数据不同则传入 config。只有 native 能力不可用或用户明确禁用时才允许 JS fallback,并记录原因。',
lineText(text, line)
);
}
const pluginMimePlainArray = /(?:plugins|mimeTypes)\s*[:=]\s*\[\s*\]/g;
scanMatches(text, pluginMimePlainArray, match => {
const nearby = text.slice(Math.max(0, match.index - 240), Math.min(text.length, match.index + 240));
if (/fallback|降级|addon 不可用|addon不可用|getMimeTypesAndPlugins/.test(nearby)) return;
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'navigator-plugins-mimetypes-plain-array',
'plugins / mimeTypes 被实现为普通数组,缺少 PluginArray / MimeTypeArray 原型、item / namedItem、索引访问和 native-like 行为。',
'使用 addon.getMimeTypesAndPlugins(config) 或 xbs.getMimeTypesAndPlugins(config);native 能力不可用时至少通过 createNativeCollection fallback 并记录差异。',
lineText(text, line)
);
});
for (const name of COLLECTION_CONSTRUCTORS) {
const mentionsCollection = new RegExp(`\\b${name}\\b`).test(text);
if (!mentionsCollection) continue;
const fromMimePluginApi = ['PluginArray', 'MimeTypeArray', 'Plugin', 'MimeType'].includes(name) && /getMimeTypesAndPlugins\s*\(/.test(text);
const hasCollectionAddon = new RegExp(`createNativeCollection\\s*\\([\\s\\S]{0,800}name\\s*:\\s*['"]${name}['"]`).test(text) || fromMimePluginApi;
const plainCollection = new RegExp(`(?:class\\s+${name}\\b|function\\s+${name}\\s*\\(|${targetExpr(name)}\\s*=\\s*(?:function|class|\\{)|\\b${name}\\s*=\\s*\\[)`, 'g');
scanMatches(text, plainCollection, match => {
if (hasCollectionAddon) return;
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'webapi-collection-without-createNativeCollection',
`${name} 使用普通 JS 结构实现,未体现 createNativeCollection / getMimeTypesAndPlugins addon-first。`,
`浏览器集合对象 ${name} 应优先使用 addon.createNativeCollection({ name: '${name}', ... });PluginArray / MimeTypeArray 应优先由 addon.getMimeTypesAndPlugins(config) 返回。`,
lineText(text, line)
);
});
}
const methodNames = escapedJoin(WEBAPI_METHODS);
const methodAssign = new RegExp(`(?:\\.|['"])(${methodNames})(?:['"])?\\s*(?:=|:)\\s*(?:async\\s*)?(?:function\\b|\\([^)]*\\)\\s*=>|[A-Za-z_$][\\w$]*\\s*\\([^)]*\\)\\s*\\{)`, 'g');
scanMatches(text, methodAssign, match => {
const index = match.index;
const line = lineOf(text, index);
const snippet = lineText(text, line);
if (HELPER_EVIDENCE.test(snippet)) return;
if (/^\s*\/\/|^\s*\*/.test(snippet)) return;
addIssue(
issues,
'error',
relative,
line,
'webapi-method-plain-function',
`WebAPI 方法 ${match[1]} 使用普通函数或对象字面量方法,未体现 addon-first/native-like 创建。`,
`将 ${match[1]} 提取为具名函数,并通过 addon.createNativeFunction(false, '${match[1]}', length, callback) 或 addon-first helper 安装到正确 prototype / 对象描述符上。`,
snippet
);
});
const urlStatic = /(?:ctx|window|self|globalThis|globalObject|global)\s*\.\s*URL\s*\.\s*(createObjectURL|revokeObjectURL)\s*=\s*(?:async\s*)?function\b/g;
scanMatches(text, urlStatic, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'url-static-method-plain-function',
`URL.${match[1]} 被直接赋值为普通 function。`,
`使用 addon.createNativeFunction(false, '${match[1]}', ...) 创建,并用 Object.defineProperty 安装 descriptor。`,
lineText(text, line)
);
});
const prototypeLiteral = /\b(?:[A-Z][A-Za-z0-9_$]*\s*\.\s*)?prototype\s*=\s*\{/g;
scanMatches(text, prototypeLiteral, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'prototype-object-literal',
'发现 prototype 直接替换为对象字面量,容易丢失 constructor、属性描述符、Symbol.toStringTag 和 native-like 方法。',
'使用 addon.createProtoChains 建立原型链,或至少 Object.defineProperties 安装 prototype.constructor、Symbol.toStringTag 和每个 addon-first 方法。',
lineText(text, line)
);
});
const hostNames = escapedJoin(HOST_REUSE);
const hostReuse = new RegExp(`(?:ctx|window|self|globalThis|globalObject|global)\\s*(?:\\.\\s*(${hostNames})|\\[\\s*['"](${hostNames})['"]\\s*\\])\\s*=\\s*(?:globalThis|global|window)\\s*\\.\\s*(?:\\1|\\2)`, 'g');
scanMatches(text, hostReuse, match => {
const name = match[1] || match[2];
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'host-webapi-direct-reuse',
`${name} 直接复用宿主同名 Web API,可能泄露 Node.js / undici / 宿主实现特征。`,
`不要盲目透传宿主 ${name};按浏览器样本、RuyiTrace 证据和 addon-first helper 创建可控实现。`,
lineText(text, line)
);
});
const atobBtoa = /\b(?:atob|btoa)\s*=\s*(?:async\s*)?function[\s\S]{0,240}\bBuffer\s*\.\s*from\b/g;
scanMatches(text, atobBtoa, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'buffer-backed-atob-btoa',
'atob / btoa 使用 Buffer.from 实现,可能暴露 Node.js 语义与错误信息差异。',
'为 atob / btoa 使用浏览器样本校验过的实现,并通过 addon.createNativeFunction 或 addon-first helper 做 native-like 包装;不得在目标上下文暴露 Buffer。',
lineText(text, line)
);
});
const objectAssignWebApi = /Object\s*\.\s*assign\s*\([^)]*\{[\s\S]{0,1200}\b(?:history|indexedDB|CSS|screen)\s*:\s*\{[\s\S]{0,500}\b[A-Za-z_$][\w$]*\s*\([^)]*\)\s*\{/g;
scanMatches(text, objectAssignWebApi, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'object-assign-webapi-methods',
'Object.assign 中发现 WebAPI 对象字面量和普通方法,绕过 descriptor、原型链与 addon-first。',
'拆分为对应模块:先 createProtoChains 创建构造函数和实例,再用 Object.defineProperty 安装 addon native-like 方法。',
lineText(text, line)
);
});
const genericIllegalConstructor = /throw\s+new\s+TypeError\s*\(\s*['"]Illegal constructor['"]\s*\)/g;
scanMatches(text, genericIllegalConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-error-generic-message',
'构造函数报错信息使用了泛化的 Illegal constructor,未证明与目标浏览器的错误类型和错误信息一致。',
'先用已确认取证浏览器采样直接调用和 new 调用的错误:error.name、constructor.name、message、stack 首行;再用 addon.throwTypeError 或 throwBrowserTypeError 复现对应 TypeError / DOMException 等类型和 message。',
lineText(text, line)
);
});
const nonTypeErrorIllegalConstructor = /throw\s+new\s+(?!TypeError\b)([A-Za-z_$][\w$]*)\s*\(\s*['"][^'"]*(?:Illegal constructor|Please use the 'new' operator|constructor cannot be called)/g;
scanMatches(text, nonTypeErrorIllegalConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-error-type-mismatch-risk',
`构造函数相关报错使用 ${match[1]},存在与浏览器 TypeError / DOMException 类型不一致的风险。`,
'按浏览器样本确认错误类型;多数 DOM 构造函数错误应为 TypeError,但必须以目标浏览器采样为准。',
lineText(text, line)
);
});
const hardcodedConstructorMessage = /Failed to construct ['"][^'"]+['"]:\s*(?:Illegal constructor|Please use the ['"]new['"] operator)/g;
const hasConstructorErrorProfile = /constructorErrors|constructor-errors\.fixture|构造函数报错样本|browserErrorProfile|errorProfile/.test(text);
scanMatches(text, hardcodedConstructorMessage, match => {
if (hasConstructorErrorProfile) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'constructor-error-profile-missing',
'源码硬编码了浏览器构造函数错误信息,但未发现构造函数报错样本或 error profile 证据。',
'建议保存 constructor-errors fixture 或在阶段报告中记录目标浏览器采样结果,避免把看似合理的报错信息误当成浏览器一致。',
lineText(text, line)
);
});
const emptyNativeConstructor = /createNativeConstructor\s*\(\s*['"]([A-Z][A-Za-z0-9_$]*)['"]\s*,\s*\d+\s*,\s*function(?:\s+[A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{\s*\}/g;
scanMatches(text, emptyNativeConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-empty-behavior',
`${match[1]} 构造函数 callback 为空,未体现直接调用 / new 调用行为、非法构造报错或实例初始化。`,
'按浏览器采样补齐构造函数行为:可构造对象要初始化实例状态;不可构造对象要复现错误类型和 message。',
lineText(text, line)
);
});
const emptyAddonConstructor = /createNativeFunction\s*\(\s*true\s*,\s*['"]([A-Z][A-Za-z0-9_$]*)['"]\s*,\s*\d+\s*,\s*function(?:\s+[A-Za-z_$][\w$]*)?\s*\([^)]*\)\s*\{\s*\}/g;
scanMatches(text, emptyAddonConstructor, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'constructor-empty-behavior',
`${match[1]} addon 构造函数 callback 为空,无法证明构造行为与浏览器一致。`,
'按浏览器采样实现 new / call 分支、错误类型和 message,或通过实例工厂初始化可构造对象状态。',
lineText(text, line)
);
});
const unknownMarkObjectType = /\b(?:native|nativeApi|protect)?\.?\s*markObjectType\s*\(/g;
scanMatches(text, unknownMarkObjectType, match => {
const line = lineOf(text, match.index);
addIssue(
issues,
'error',
relative,
line,
'unknown-mark-object-type',
'发现 markObjectType 调用;该名称不是本 Skill 批准的 addon-first API,语义不明确。',
'如果对象由 addon.createProtoChains / addon 构造函数实例工厂创建,不要额外标记对象类型;addon 已负责实例 Object.prototype.toString。只有 JS fallback 对象才允许使用 Symbol.toStringTag / markObjectToString,并记录 fallback 原因。',
lineText(text, line)
);
});
const markObjectToStringCall = /\b(?:native|nativeApi|protect)\s*\.\s*markObjectToString\s*\(/g;
scanMatches(text, markObjectToStringCall, match => {
const nearby = text.slice(Math.max(0, match.index - 240), Math.min(text.length, match.index + 240));
if (/fallback|降级|addon 不可用|addon不可用|JS fallback|NativeProtect/.test(nearby)) return;
const line = lineOf(text, match.index);
addIssue(
warnings,
'warning',
relative,
line,
'mark-object-to-string-without-fallback-reason',
'发现 markObjectToString 调用但附近没有 fallback 原因;它只能作为 addon 不可用时的 JS fallback。',
'优先用 addon 构造函数或 createProtoChains 实例工厂创建对象。addon 已创建的实例不应再用 markObjectToString 叠加伪装;只有普通 JS fallback 对象才需要该函数。',
lineText(text, line)
);
});
const hasPlainWebApiPattern = issues.some(issue => issue.file === relative);
const usesAnyNativeHelper = /createNativeFunction|createGetter|createSetter|createProtoChains|createNativeCollection|getMimeTypesAndPlugins|createInterceptor|createUndetectable|setPrivate|getPrivate|hasPrivate|deletePrivate|loadNativeAddon|WEB_JS_ENV_PATCHER_ADDON|WEB_JS_ENV_PATCHER_XBS_ISOLATED_VM|addon\.node|isolated_vm\.node|xbs-isolated-vm|xbs\.|window\.xbs|globalThis\.xbs/.test(text);
if (hasPlainWebApiPattern && !usesAnyNativeHelper) {
addIssue(
warnings,
'warning',
relative,
1,
'file-no-native-helper',
'文件中存在 WebAPI 普通实现问题,且未发现任何 addon-first / native helper 证据。',
'在模块初始化阶段注入 nativeApi / addon,并统一通过 helper 创建函数、访问器、构造函数和实例对象。',
''
);
}
return { issues, warnings };
}
function resolveInputs(args) {
if (args.file) {
const file = path.resolve(args.file);
return { root: path.dirname(file), files: [file] };
}
if (args.dir) {
const root = path.resolve(args.dir);
return { root, files: walk(root).filter(file => isJsFile(file) && !shouldSkip(root, file)) };
}
if (args.caseDir) {
const caseDir = path.resolve(args.caseDir);
const preferred = path.join(caseDir, 'result');
const root = exists(preferred) ? preferred : caseDir;
return { root, files: walk(root).filter(file => isJsFile(file) && !shouldSkip(root, file)) };
}
throw new Error('必须指定 --case-dir、--dir 或 --file。');
}
function renderMarkdown(result) {
const lines = [];
lines.push('# WebAPI addon 覆盖检查报告');
lines.push('');
lines.push(`- 检查结果:${result.passed ? '通过' : '不通过'}`);
lines.push(`- 扫描文件数:${result.scannedFiles.length}`);
lines.push(`- 错误数:${result.issueCount}`);
lines.push(`- 警告数:${result.warningCount}`);
lines.push('');
if (result.issues.length) {
lines.push('## 错误');
lines.push('');
lines.push('| 文件 | 行号 | 类型 | 问题 | 修复建议 |');
lines.push('|---|---:|---|---|---|');
for (const issue of result.issues) {
lines.push(`| ${issue.file} | ${issue.line || ''} | ${issue.type} | ${issue.message} | ${issue.suggestion} |`);
}
lines.push('');
}
if (result.warnings.length) {
lines.push('## 警告');
lines.push('');
lines.push('| 文件 | 行号 | 类型 | 提醒 | 建议 |');
lines.push('|---|---:|---|---|---|');
for (const warning of result.warnings) {
lines.push(`| ${warning.file} | ${warning.line || ''} | ${warning.type} | ${warning.message} | ${warning.suggestion} |`);
}
lines.push('');
}
if (!result.issues.length && !result.warnings.length) {
lines.push('未发现 WebAPI 普通函数、普通对象、宿主透传或 prototype 对象字面量等 addon-first 覆盖问题。');
lines.push('');
}
lines.push('## 交付门禁说明');
lines.push('');
lines.push('- 该检查失败时不得交付最终补环境项目。');
lines.push('- 修复方向不是“删除检查”,而是把 WebAPI 构造函数、普通方法、getter、setter、实例对象、集合对象、plugins/mimeTypes 和特殊对象统一迁移到 addon-first helper。');
lines.push('- addon.node / xbs native API 不可用时才允许 NativeProtect / JS fallback,并必须在阶段报告与最终总结中记录原因。');
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
return;
}
const { root, files } = resolveInputs(args);
const jsFiles = files.filter(file => exists(file) && isJsFile(file));
const issues = [];
const warnings = [];
for (const file of jsFiles) {
const inspected = inspectFile(root, file);
issues.push(...inspected.issues);
warnings.push(...inspected.warnings);
}
const warningCount = warnings.length;
const issueCount = issues.length + (args.warningsAsErrors ? warningCount : 0);
const result = {
passed: issueCount === 0,
root,
scannedFiles: jsFiles.map(file => rel(root, file)),
issueCount,
warningCount,
warningsAsErrors: args.warningsAsErrors,
issues,
warnings,
};
if (args.json) console.log(JSON.stringify(result, null, 2));
else console.log(renderMarkdown(result));
if (!result.passed) process.exitCode = 1;
}
try {
main();
} catch (err) {
console.error(err && err.message ? err.message : String(err));
process.exitCode = 2;
}

View File

@@ -0,0 +1,656 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const CATEGORIES = [
{
id: 'iframe-realm',
label: 'iframe / Window realm',
patterns: [/iframe/i, /contentWindow/i, /contentDocument/i, /defaultView/i, /frameElement/i, /srcdoc/i, /document\.write/i, /document\.close/i],
},
{
id: 'worker-task',
label: 'Worker / Message task queue',
patterns: [/\bWorker\b/i, /\bSharedWorker\b/i, /WorkerGlobalScope/i, /DedicatedWorkerGlobalScope/i, /SharedWorkerGlobalScope/i, /importScripts/i, /terminate/i],
},
{
id: 'message-lifecycle',
label: 'MessagePort / postMessage lifecycle',
patterns: [/postMessage/i, /MessageChannel/i, /MessagePort/i, /entangled/i, /structuredClone/i, /\.close\s*\(/i],
},
{
id: 'performance-timeline',
label: 'Performance timeline',
patterns: [/PerformanceObserver/i, /PerformanceResourceTiming/i, /PerformanceEntry/i, /getEntries/i, /PerformancePaintTiming/i, /performance\.mark/i],
},
{
id: 'dom-cssom',
label: 'DOM / CSSOM',
patterns: [/DOMParser/i, /innerHTML/i, /Comment/i, /ShadowRoot/i, /CSSStyleSheet/i, /CSSRuleList/i, /getComputedStyle/i, /document\.links/i, /document\.images/i, /document\.forms/i],
},
{
id: 'dom-crud',
label: 'DOM CRUD state machine',
patterns: [
/appendChild/i, /removeChild/i, /insertBefore/i, /replaceChild/i, /replaceChildren/i,
/cloneNode/i, /importNode/i, /adoptNode/i, /isConnected/i, /ownerDocument/i,
/childNodes/i, /\bchildren\b/i, /querySelector/i, /getElementById/i,
/getElementsBy/i, /\.matches\s*\(/i, /\.closest\s*\(/i, /MutationObserver/i,
],
},
{
id: 'event-clone-error',
label: 'Event / clone / native shape',
patterns: [/EventTarget/i, /structuredClone/i, /DataCloneError/i, /ownKeys/i, /ownNames/i, /getOwnPropertyNames/i, /Reflect\.ownKeys/i, /toString/i],
},
{
id: 'xhr-fetch-session-bridge',
label: 'XHR / fetch session bridge',
patterns: [/\bXMLHttpRequest\b/i, /\bfetch\s*\(/i, /\bRequest\b/i, /\bResponse\b/i, /\bHeaders\b/i, /\bsendBeacon\b/i, /live-session-bridge/i, /offline-fixture/i, /curl_cffi/i, /curl-cffi/i],
},
{
id: 'object-shape',
label: 'Object shape audit',
patterns: [/Object\.keys/i, /Object\.getOwnPropertyNames/i, /Object\.getOwnPropertyDescriptor/i, /Object\.getOwnPropertySymbols/i, /Reflect\.ownKeys/i, /hasOwnProperty/i, /propertyIsEnumerable/i, /prototype walk/i, /brand check/i],
},
{
id: 'private-state-leakage',
label: 'Private state leakage',
patterns: [/__readyState/i, /__headers/i, /__children/i, /__parentNode/i, /__responseHeaders/i, /private state/i, /私有状态/i, /Object\.defineProperty\s*\([^)]*['"]_{1,2}[A-Za-z]/i, /\bthis\s*\.\s*_{1,2}[A-Za-z]/i],
},
{
id: 'clock-timer',
label: 'Clock / timer',
patterns: [/Date\.now/i, /performance\.now/i, /setTimeout/i, /timer/i, /queueMicrotask/i],
},
{
id: 'writer-branch',
label: 'Writer branch',
patterns: [/reload writer/i, /form writer/i, /final writer/i, /HTMLFormElement\.submit/i, /Location\.reload/i, /generatedForm/i, /cf-chl-gen/i, /cf-chl-out/i],
},
];
const REQUIRED_CAPABILITIES = {
'iframe-realm': [
'realm-global-relations',
'realm-ecma-constructor-isolation',
'realm-webapi-constructor-isolation',
'realm-object-isolation',
'realm-document-relations',
'realm-navigation-lifecycle',
],
'worker-task': [
'worker-global-surface',
'worker-constructor-isolation',
'worker-object-isolation',
'worker-message-order',
'worker-terminate-immediate',
'worker-terminate-deferred',
],
'message-lifecycle': [
'message-order',
'message-port-start',
'message-port-close-sender',
'message-port-close-receiver',
'message-port-transfer',
],
'dom-crud': [
'dom-tree-mutation',
'dom-live-collections',
'dom-selector-validity',
'dom-html-parsing',
'dom-document-relations',
'dom-mutation-observer',
],
};
const OBSERVATION_KEYS = ['observation', 'observed', 'result', 'value', 'output', 'expected'];
const BLOCKING_STATUSES = new Set([
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
]);
const SELF_OUTPUT_FILES = new Set([
'webapi-env-detection-matrix.md',
'node-env-detection-audit.json',
]);
function parseArgs(argv) {
const args = {
caseDir: '',
matrix: '',
browserBaseline: '',
nodeAudit: '',
require: false,
requireWriterBranch: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--matrix') args.matrix = argv[++i] || '';
else if (a === '--browser-baseline') args.browserBaseline = argv[++i] || '';
else if (a === '--node-audit') args.nodeAudit = argv[++i] || '';
else if (a === '--require') args.require = true;
else if (a === '--require-writer-branch') args.requireWriterBranch = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_webapi_env_detection_matrix.js --case-dir case --markdown
node scripts/check_webapi_env_detection_matrix.js --case-dir case --require --require-writer-branch --json
node scripts/check_webapi_env_detection_matrix.js --matrix case/notes/webapi-env-detection-matrix.md --browser-baseline case/fixtures/browser-env-detection-baseline.json --node-audit case/tmp/node-env-detection-audit.json --markdown
说明:检查 WebAPI 环境检测矩阵、真实浏览器 baseline、Node audit,以及 iframe / Worker / Performance / DOM-CSSOM / XHR/fetch session bridge / 对象形状 / 私有状态泄露 / writer 分支等行为 diff 是否闭环。`;
}
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function readText(p) {
return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
}
function readJson(p) {
return JSON.parse(readText(p));
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function rel(root, file) {
return (path.relative(root, file) || '.').replace(/\\/g, '/');
}
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) {
walk(path.join(root, name), out);
}
return out;
}
function listCandidateFiles(caseDir) {
const dirs = ['阶段报告', 'notes', 'tmp', 'ruyi-trace', 'result'];
const files = [];
for (const dir of dirs) {
const full = path.join(caseDir, dir);
for (const file of walk(full)) {
const relative = rel(caseDir, file);
if (/(^|\/)(node_modules|dist|build|coverage)(\/|$)/i.test(relative)) continue;
if (/\.(md|json|jsonl|ndjson|txt|js|mjs|cjs|ts|py|html)$/i.test(file)) files.push(file);
}
}
return files.filter(file => !SELF_OUTPUT_FILES.has(path.basename(file).toLowerCase()));
}
function scanTriggers(caseDir) {
const hits = new Map(CATEGORIES.map(category => [category.id, { ...category, count: 0, files: [] }]));
for (const file of listCandidateFiles(caseDir)) {
let text = '';
try {
text = readText(file).slice(0, 200000);
} catch {
continue;
}
for (const category of CATEGORIES) {
let matched = 0;
for (const pattern of category.patterns) {
const result = text.match(pattern);
if (result) matched += result.length || 1;
}
if (matched) {
const entry = hits.get(category.id);
entry.count += matched;
if (entry.files.length < 8) entry.files.push(rel(caseDir, file));
}
}
}
return [...hits.values()].filter(item => item.count > 0);
}
function stripStatusDocumentation(text) {
const lines = text.split(/\r?\n/);
const kept = [];
let inFence = false;
let skippingStatusEnum = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^```/.test(trimmed)) {
inFence = !inFence;
if (!skippingStatusEnum) kept.push(line);
continue;
}
if (!inFence && /^#{1,6}\s*状态枚举\b/.test(trimmed)) {
skippingStatusEnum = true;
continue;
}
if (skippingStatusEnum && !inFence && /^#{1,6}\s+/.test(trimmed)) {
skippingStatusEnum = false;
}
if (!skippingStatusEnum) kept.push(line);
}
return kept.join('\n');
}
function extractStatuses(text) {
const auditText = stripStatusDocumentation(text);
const statuses = [];
const allowed = [
'matched',
'accepted-diff',
'not-involved',
'needs-browser-baseline',
'needs-node-audit',
'mismatch',
'native-capability-gap',
'unknown',
];
for (const status of allowed) {
const re = new RegExp(`\\b${status}\\b`, 'g');
const matches = auditText.match(re);
if (matches) statuses.push({ status, count: matches.length });
}
return statuses;
}
function normalizeCategoriesFromJson(raw) {
if (!raw || typeof raw !== 'object') return [];
if (Array.isArray(raw.categories)) return raw.categories.map(String);
if (raw.categories && typeof raw.categories === 'object') return Object.keys(raw.categories);
if (raw.checks && typeof raw.checks === 'object') return Object.keys(raw.checks);
if (Array.isArray(raw.items)) return raw.items.map(item => item.category || item.type).filter(Boolean);
return [];
}
function normalizeCategoryRecords(raw) {
if (!raw || typeof raw !== 'object') return {};
if (raw.categories && typeof raw.categories === 'object' && !Array.isArray(raw.categories)) return raw.categories;
if (raw.checks && typeof raw.checks === 'object' && !Array.isArray(raw.checks)) return raw.checks;
if (Array.isArray(raw.items)) {
const out = {};
for (const item of raw.items) {
const category = item.category || item.type;
if (!category) continue;
if (!out[category]) out[category] = [];
out[category].push(item);
}
return out;
}
return {};
}
function stable(value, ignoreRecordMetadata = false) {
if (Array.isArray(value)) return value.map(item => stable(item, ignoreRecordMetadata));
if (!value || typeof value !== 'object') return value;
const ignored = new Set(['status', 'evidence', 'file', 'notes', 'note', 'handling', 'updatedAt', 'capturedAt']);
return Object.fromEntries(
Object.keys(value)
.filter(key => !ignoreRecordMetadata || !ignored.has(key))
.sort()
.map(key => [key, stable(value[key], ignoreRecordMetadata)]),
);
}
function recordId(item, index) {
return String(item && (item.id || item.name || item.probe || item.path || item.api) || index);
}
function recordPayload(item) {
if (!item || typeof item !== 'object') return item;
for (const key of ['observation', 'observed', 'result', 'value', 'output', 'expected']) {
if (typeof item[key] !== 'undefined') return stable(item[key]);
}
return stable(item, true);
}
function assertJsonCategoryCoverage(source, label, expectedCategories, problems, warnings) {
if (!source.parseOk || expectedCategories.length === 0) return;
if (!source.categories.length) {
problems.push(`${label} 未记录 categories / checks / items 类别,无法确认已覆盖触发的 WebAPI 行为类别`);
return;
}
const missing = expectedCategories.filter(id => !source.categories.includes(id));
if (missing.length) problems.push(`${label} 缺少触发类别:${missing.join('、')}`);
}
function checkJsonFile(caseDir, file, label, problems, warnings) {
if (!exists(file)) {
problems.push(`缺少 ${label}:${rel(caseDir, file)}`);
return {
exists: false, parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
}
try {
const raw = readJson(file);
return {
exists: true,
parseOk: true,
baselineId: raw.baselineId || '',
categories: normalizeCategoriesFromJson(raw),
categoryRecords: normalizeCategoryRecords(raw),
schemaVersion: raw.schemaVersion || '',
runtimeSourceHash: raw.runtimeSourceHash || '',
generatedBy: raw.generatedBy || '',
capturedBy: raw.capturedBy || '',
probeVersion: raw.probeVersion || '',
probeSuiteVersion: raw.probeSuiteVersion || '',
probeSourceHash: raw.probeSourceHash || '',
probeSourceFile: raw.probeSourceFile || '',
};
} catch (err) {
problems.push(`${label} 无法解析为 JSON:${rel(caseDir, file)}:${err.message}`);
return {
exists: true, parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
}
}
function actualObservation(item) {
if (!item || typeof item !== 'object') return { present: false, value: undefined };
for (const key of OBSERVATION_KEYS) {
if (typeof item[key] !== 'undefined') return { present: true, value: stable(item[key]), key };
}
return { present: false, value: undefined, key: '' };
}
function recordCapabilities(item) {
if (!item || typeof item !== 'object') return [];
const values = [];
if (Array.isArray(item.capabilities)) values.push(...item.capabilities);
for (const key of ['capability', 'probeKind', 'behavior']) {
if (item[key]) values.push(item[key]);
}
return [...new Set(values.map(String).filter(Boolean))];
}
function validateObservationRecords(source, label, expectedCategories, problems) {
if (!source.parseOk) return;
for (const category of expectedCategories) {
const records = Array.isArray(source.categoryRecords[category]) ? source.categoryRecords[category] : [];
for (const [index, item] of records.entries()) {
const id = recordId(item, index);
if (!actualObservation(item).present) {
problems.push(`${label} 的 ${category}/${id} 只有 probe/status/evidence,没有实际 observation;证据文本不能作为行为一致性证明`);
}
}
}
}
function validateRequiredCapabilities(source, label, expectedCategories, problems) {
if (!source.parseOk) return;
for (const category of expectedCategories) {
const required = REQUIRED_CAPABILITIES[category] || [];
if (!required.length) continue;
const records = Array.isArray(source.categoryRecords[category]) ? source.categoryRecords[category] : [];
const covered = new Set(records.flatMap(recordCapabilities));
const missing = required.filter(capability => !covered.has(capability));
if (missing.length) {
problems.push(`${label} 的 ${category} 缺少强制行为能力:${missing.join('、')}`);
}
}
}
function validateProbeSourceFile(caseDir, source, label, problems) {
if (!source.probeSourceFile) {
problems.push(`${label} 缺少 probeSourceFile`);
return;
}
const file = path.resolve(caseDir, source.probeSourceFile);
const relative = path.relative(caseDir, file);
if (relative.startsWith('..') || path.isAbsolute(relative)) {
problems.push(`${label} probeSourceFile 必须位于 case 内:${source.probeSourceFile}`);
return;
}
if (!exists(file)) {
problems.push(`${label} probeSourceFile 不存在:${source.probeSourceFile}`);
return;
}
const actual = sha256(fs.readFileSync(file));
if (actual !== String(source.probeSourceHash || '').toLowerCase()) {
problems.push(`${label} probeSourceHash 与实际源文件不一致:${source.probeSourceHash || '未记录'} != ${actual}`);
}
}
function validateProbeProvenance(caseDir, browser, nodeAudit, problems) {
if (!browser.parseOk || !nodeAudit.parseOk) return;
if (!browser.probeSuiteVersion) problems.push('浏览器环境 baseline 缺少 probeSuiteVersion');
if (!nodeAudit.probeSuiteVersion) problems.push('Node 环境 audit 缺少 probeSuiteVersion');
if (browser.probeSuiteVersion && nodeAudit.probeSuiteVersion
&& browser.probeSuiteVersion !== nodeAudit.probeSuiteVersion) {
problems.push(`browser/node probeSuiteVersion 不一致:${browser.probeSuiteVersion} != ${nodeAudit.probeSuiteVersion}`);
}
if (!browser.probeSourceHash) problems.push('浏览器环境 baseline 缺少 probeSourceHash,无法证明 browser/node 使用同一 probe 源码');
if (!nodeAudit.probeSourceHash) problems.push('Node 环境 audit 缺少 probeSourceHash,无法证明 browser/node 使用同一 probe 源码');
if (browser.probeSourceHash && nodeAudit.probeSourceHash
&& browser.probeSourceHash !== nodeAudit.probeSourceHash) {
problems.push(`browser/node probeSourceHash 不一致:${browser.probeSourceHash} != ${nodeAudit.probeSourceHash}`);
}
validateProbeSourceFile(caseDir, browser, '浏览器环境 baseline', problems);
validateProbeSourceFile(caseDir, nodeAudit, 'Node 环境 audit', problems);
if (!browser.generatedBy && !browser.capturedBy) {
problems.push('浏览器环境 baseline 缺少 generatedBy/capturedBy');
}
}
function compareCategoryRecords(browser, nodeAudit, expectedCategories, problems) {
for (const category of expectedCategories) {
const expected = Array.isArray(browser.categoryRecords[category]) ? browser.categoryRecords[category] : [];
const observed = Array.isArray(nodeAudit.categoryRecords[category]) ? nodeAudit.categoryRecords[category] : [];
if (!expected.length) {
problems.push(`浏览器环境 baseline 的 ${category} 类别没有 probe 观测值,空数组不能作为已覆盖`);
continue;
}
if (!observed.length) {
problems.push(`Node 环境 audit 的 ${category} 类别没有 probe 观测值`);
continue;
}
const observedById = new Map(observed.map((item, index) => [recordId(item, index), item]));
for (const [index, expectedItem] of expected.entries()) {
const id = recordId(expectedItem, index);
const observedItem = observedById.get(id);
if (!observedItem) {
problems.push(`Node 环境 audit 缺少 ${category}/${id}`);
continue;
}
const expectedObservation = actualObservation(expectedItem);
const observedObservation = actualObservation(observedItem);
if (!expectedObservation.present || !observedObservation.present) continue;
if (JSON.stringify(expectedObservation.value) !== JSON.stringify(observedObservation.value)) {
problems.push(`WebAPI 行为不一致:${category}/${id}`);
}
}
}
}
function check(args) {
const caseDir = args.caseDir ? path.resolve(args.caseDir) : process.cwd();
const matrixPath = path.resolve(args.matrix || path.join(caseDir, 'notes', 'webapi-env-detection-matrix.md'));
const browserPath = path.resolve(args.browserBaseline || path.join(caseDir, 'fixtures', 'browser-env-detection-baseline.json'));
const nodeAuditPath = path.resolve(args.nodeAudit || path.join(caseDir, 'tmp', 'node-env-detection-audit.json'));
const triggered = scanTriggers(caseDir);
const problems = [];
const warnings = [];
const requireMatrix = args.require || triggered.length > 0;
if (requireMatrix && !exists(matrixPath)) {
problems.push(`检测到 WebAPI 环境检测信号,但缺少矩阵:${rel(caseDir, matrixPath)}`);
}
const browser = requireMatrix
? checkJsonFile(caseDir, browserPath, '浏览器环境 baseline', problems, warnings)
: {
exists: exists(browserPath), parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
const nodeAudit = requireMatrix
? checkJsonFile(caseDir, nodeAuditPath, 'Node 环境 audit', problems, warnings)
: {
exists: exists(nodeAuditPath), parseOk: false, baselineId: '', categories: [], categoryRecords: {},
runtimeSourceHash: '', generatedBy: '', capturedBy: '', probeVersion: '',
probeSuiteVersion: '', probeSourceHash: '', probeSourceFile: '',
};
const expectedCategories = triggered.length
? triggered.map(item => item.id)
: [...new Set([...browser.categories, ...nodeAudit.categories])];
if (args.require && !expectedCategories.length) {
problems.push('要求 WebAPI 行为审计,但未发现触发类别,且 baseline/audit 没有 categories;不得用空矩阵通过');
}
let matrix = {
exists: exists(matrixPath),
missingCategories: [],
missingKeywords: [],
statuses: [],
blockingStatuses: [],
};
if (matrix.exists) {
const text = readText(matrixPath);
const requiredKeywords = ['baselineId', '浏览器', 'Node', '状态', '证据'];
matrix.missingKeywords = requiredKeywords.filter(key => !text.includes(key));
for (const key of matrix.missingKeywords) problems.push(`矩阵缺少关键字段:${key}`);
matrix.missingCategories = expectedCategories.filter(id => !text.includes(id));
for (const id of matrix.missingCategories) problems.push(`矩阵缺少触发类别:${id}`);
matrix.statuses = extractStatuses(text);
matrix.blockingStatuses = matrix.statuses.filter(item => BLOCKING_STATUSES.has(item.status));
for (const item of matrix.blockingStatuses) {
problems.push(`矩阵存在阻断状态 ${item.status},数量 ${item.count}`);
}
if (args.requireWriterBranch && !text.includes('writer-branch')) {
problems.push('要求 writer 分支审计,但矩阵缺少 writer-branch');
}
if (args.requireWriterBranch && !/reload writer|form writer|final writer|continuation/i.test(text)) {
problems.push('要求 writer 分支审计,但矩阵缺少 writer 类型证据');
}
}
assertJsonCategoryCoverage(browser, '浏览器环境 baseline', expectedCategories, problems, warnings);
assertJsonCategoryCoverage(nodeAudit, 'Node 环境 audit', expectedCategories, problems, warnings);
if (requireMatrix && browser.parseOk && nodeAudit.parseOk) {
validateProbeProvenance(caseDir, browser, nodeAudit, problems);
validateObservationRecords(browser, '浏览器环境 baseline', expectedCategories, problems);
validateObservationRecords(nodeAudit, 'Node 环境 audit', expectedCategories, problems);
validateRequiredCapabilities(browser, '浏览器环境 baseline', expectedCategories, problems);
validateRequiredCapabilities(nodeAudit, 'Node 环境 audit', expectedCategories, problems);
compareCategoryRecords(browser, nodeAudit, expectedCategories, problems);
if (!nodeAudit.runtimeSourceHash) problems.push('Node 环境 audit 缺少 runtimeSourceHash,代码变化后旧审计不得继续使用');
if (!nodeAudit.probeVersion) problems.push('Node 环境 audit 缺少 probeVersion');
if (!/audit|probe|recorder/i.test(nodeAudit.generatedBy)) problems.push(`Node 环境 audit generatedBy 不可信:${nodeAudit.generatedBy || '未记录'}`);
}
if (browser.baselineId && nodeAudit.baselineId && browser.baselineId !== nodeAudit.baselineId) {
problems.push(`浏览器 baselineId 与 Node audit baselineId 不一致:${browser.baselineId} != ${nodeAudit.baselineId}`);
}
return {
caseDir,
clean: problems.length === 0,
requireMatrix,
matrixPath,
browserPath,
nodeAuditPath,
triggered,
requiredCapabilities: Object.fromEntries(
expectedCategories
.filter(category => REQUIRED_CAPABILITIES[category])
.map(category => [category, REQUIRED_CAPABILITIES[category]]),
),
matrix,
browser,
nodeAudit,
problems,
warnings,
};
}
function renderMarkdown(result) {
const lines = [
'# WebAPI 环境检测矩阵检查结果',
'',
`case 目录:${result.caseDir}`,
`是否需要矩阵:${result.requireMatrix ? '是' : '否'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 文件',
`- 矩阵:${result.matrixPath}`,
`- 浏览器 baseline:${result.browserPath}`,
`- Node audit:${result.nodeAuditPath}`,
'',
'## 触发类别',
];
if (result.triggered.length) {
for (const item of result.triggered) {
lines.push(`- ${item.id}:${item.count};证据 ${item.files.join('、') || '未记录'}`);
}
} else {
lines.push('- 未在阶段报告 / notes / tmp / ruyi-trace 中发现明显触发信号。');
}
lines.push('', '## Baseline 摘要');
lines.push(`- 浏览器 baseline:存在=${result.browser.exists ? '是' : '否'},JSON=${result.browser.parseOk ? '是' : '否'},baselineId=${result.browser.baselineId || '未记录'}`);
lines.push(`- Node audit:存在=${result.nodeAudit.exists ? '是' : '否'},JSON=${result.nodeAudit.parseOk ? '是' : '否'},baselineId=${result.nodeAudit.baselineId || '未记录'}`);
if (result.matrix.exists) {
lines.push('', '## 矩阵摘要');
lines.push(`- 缺少类别:${result.matrix.missingCategories.join('、') || '无'}`);
lines.push(`- 缺少关键字段:${result.matrix.missingKeywords.join('、') || '无'}`);
lines.push(`- 状态:${result.matrix.statuses.map(item => `${item.status}=${item.count}`).join('、') || '未记录'}`);
}
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
} else {
lines.push('', '## 结论', '- WebAPI 环境检测矩阵检查通过。');
}
return `${lines.join('\n')}\n`;
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env node
'use strict';
const path = require('path');
const XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION = '26.3.1';
const {
EXPECTED_XBS_APIS,
createIsolatedVmRuntime,
getBundledXbsIsolatedVmPath,
isAbiMismatch,
resolveXbsIsolatedVmPath,
} = require('../assets/runtime-frameworks/isolated-vm-runtime');
function parseArgs(argv) {
const args = { binary: '', json: false, markdown: false, strict: false, timeoutMs: 5000 };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--binary' || a === '--xbs-isolated-vm' || a === '--path') args.binary = argv[++i] || '';
else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i] || 5000);
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--strict') args.strict = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error('未知参数:' + a);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return [
'用法:',
' node scripts/check_xbs_isolated_vm.js --markdown',
' node scripts/check_xbs_isolated_vm.js --json',
' node scripts/check_xbs_isolated_vm.js --binary <isolated_vm.node> --strict --json',
'',
'说明:检测随包魔改 xbs isolated-vm 是否能在当前 Node / 平台 / ABI 下加载,并在 Context 内自检 window.xbs 的 17 个核心 API、xbs.dom.createDocument 和基础 DOM smoke test。默认遇到平台缺失或 ABI 不匹配时输出中文解释但不崩溃;真实 case 已选择 isolated-vm 时可加 --strict 作为阻断门禁。',
].join('\n');
}
function baseResult(args) {
const binaryPath = path.resolve(args.binary || resolveXbsIsolatedVmPath({}));
return {
tool: 'check_xbs_isolated_vm',
nodeVersion: process.version,
nodeAbi: process.versions && process.versions.modules ? process.versions.modules : '',
platform: process.platform,
arch: process.arch,
platformKey: process.platform + '-' + process.arch,
defaultBinary: getBundledXbsIsolatedVmPath(),
binaryPath,
expectedApis: EXPECTED_XBS_APIS.slice(),
compatibleNodeVersion: XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: false,
loaded: false,
selfCheckPassed: false,
abiMismatch: false,
explainableFailure: false,
status: 'not-run',
error: '',
xbs: null,
dom: null,
};
}
async function runCheck(args) {
const result = baseResult(args);
let runtime = null;
try {
runtime = createIsolatedVmRuntime({ xbsIsolatedVmPath: args.binary || '', timeoutMs: args.timeoutMs, requireXbs: true });
result.loaded = true;
result.binaryPath = runtime.binaryPath || result.binaryPath;
const initialized = await runtime.initialize({});
result.xbs = initialized.xbs || runtime.getXbsApiSummary();
result.dom = result.xbs && result.xbs.dom ? result.xbs.dom : null;
result.selfCheckPassed = !!(result.xbs && result.xbs.ok);
result.status = result.selfCheckPassed ? 'ok' : 'xbs-api-missing';
if (!result.selfCheckPassed) result.error = '已加载二进制,但 Context 内 window.xbs 自检未通过。';
} catch (error) {
result.error = String(error && error.message ? error.message : error || '未知错误');
result.abiMismatch = isAbiMismatch(error) || !!(error && error.abiMismatch);
result.explainableFailure = result.abiMismatch || /未找到 isolated_vm\.node|未找到|no such file|cannot find/i.test(result.error);
result.status = result.abiMismatch ? 'abi-mismatch' : (result.explainableFailure ? 'binary-missing' : 'load-failed');
result.requiresUserConfirmation = !!result.abiMismatch;
if (result.abiMismatch) {
result.recovery = {
rule: '不得直接改用 npm 原版 isolated-vm,也不得桥接旧 addon.node;先询问用户是否通过 nvm 安装 / 切换兼容 Node。',
commands: [
`nvm install ${XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION}`,
`nvm use ${XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION}`,
'node -v',
'node -p "process.versions.modules"',
'node --no-node-snapshot scripts/check_xbs_isolated_vm.js --strict --json',
],
};
}
} finally {
if (runtime && typeof runtime.dispose === 'function') await runtime.dispose().catch(() => {});
}
result.passed = !!(result.loaded && result.selfCheckPassed);
return result;
}
function renderMarkdown(result) {
const lines = [];
lines.push('# xbs isolated-vm 自检报告');
lines.push('');
lines.push('- 检查结果:' + (result.passed ? '通过' : '未通过'));
lines.push('- 状态:' + result.status);
lines.push('- 当前 Node:' + result.nodeVersion);
lines.push('- 当前 ABI:' + result.nodeAbi);
lines.push('- xbs isolated-vm 兼容 Node:v' + (result.compatibleNodeVersion || XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION));
lines.push('- 当前平台:' + result.platformKey);
lines.push('- 二进制路径:' + result.binaryPath);
lines.push('- 是否加载成功:' + (result.loaded ? '是' : '否'));
lines.push('- Context 内 xbs API + DOM 自检:' + (result.selfCheckPassed ? '通过' : '未通过'));
if (result.xbs) {
lines.push('- window === globalThis:' + (result.xbs.windowIsGlobal ? '是' : '否'));
lines.push('- window instanceof Window:' + (result.xbs.windowInstanceOfWindow ? '是' : '否'));
lines.push('- 缺失核心 API:' + (result.xbs.missing && result.xbs.missing.length ? result.xbs.missing.join('、') : '无'));
lines.push('- 额外 API:' + (result.xbs.extra && result.xbs.extra.length ? result.xbs.extra.join('、') : '无'));
lines.push('- xbs.dom:' + (result.xbs.hasXbsDom ? '存在' : '不存在'));
lines.push('- xbs.dom.createDocument:' + (result.xbs.hasCreateDocument ? '可用' : '不可用'));
if (result.xbs.dom) {
lines.push('- DOM smoke test:' + (result.xbs.dom.ok ? '通过' : '未通过'));
lines.push('- document.constructor.name:' + (result.xbs.dom.documentCtor || '未知'));
lines.push('- document 默认预挂载到 window:' + (result.xbs.dom.windowDocumentWasPreinstalled ? '是' : '否'));
lines.push('- document.all 类型:' + (result.xbs.dom.allType || '未知'));
lines.push('- document.all == null:' + (result.xbs.dom.allLooseNull ? '是' : '否'));
lines.push('- document.all === undefined:' + (result.xbs.dom.allStrictUndefined ? '是' : '否'));
lines.push('- Boolean(document.all):' + String(result.xbs.dom.allBoolean));
lines.push('- document.all.length 类型:' + (result.xbs.dom.allLengthType || '未知'));
lines.push('- document.all.item 类型:' + (result.xbs.dom.allItemType || '未知'));
lines.push('- document.all.namedItem 类型:' + (result.xbs.dom.allNamedItemType || '未知'));
lines.push('- Object.prototype.toString.call(document.all):' + (result.xbs.dom.allObjectToString || '未知'));
lines.push('- document.all.length 是自有属性:' + (result.xbs.dom.allOwnLength ? '是' : '否'));
lines.push('- document.all.item 是自有属性:' + (result.xbs.dom.allOwnItem ? '是' : '否'));
lines.push('- document.all.namedItem 是自有属性:' + (result.xbs.dom.allOwnNamedItem ? '是' : '否'));
lines.push('- document.all 引用稳定:' + (result.xbs.dom.allSameReference ? '是' : '否'));
lines.push('- omitApis 禁用 document.all:' + (result.xbs.dom.omitAllWorks ? '通过' : '未通过'));
lines.push('- iframe contentDocument:' + (result.xbs.dom.iframeContentDocument ? '可用' : '未确认'));
}
}
if (result.error) {
lines.push('');
lines.push('## 说明');
lines.push('');
lines.push(result.error.replace(/\n/g, '\n\n'));
}
if (!result.passed && result.explainableFailure) {
lines.push('');
lines.push('## 处理建议');
lines.push('');
if (result.abiMismatch) {
lines.push('- ABI 不兼容时不要直接降级。先让用户确认是否通过 nvm 安装 / 切换 Node.js v' + (result.compatibleNodeVersion || XBS_ISOLATED_VM_COMPATIBLE_NODE_VERSION) + '。');
lines.push('- 用户拒绝切换 Node 时,再让用户提供当前平台 / 架构 / Node ABI 匹配的魔改 xbs isolated-vm 构建产物,或改选不使用框架 / Node.js 内置 vm / jsEnv。');
} else {
lines.push('- 如果用户已选择 isolated-vm,请让用户提供与当前平台 / 架构 / Node ABI 匹配的魔改 xbs isolated-vm 构建产物,或按 Node 兼容恢复流程切换匹配版本。');
}
lines.push('- 不要自动降级到 npm 原版 isolated-vm,也不要桥接旧 addon.node。');
}
if (result.recovery && result.recovery.commands) {
lines.push('');
lines.push('## ABI 恢复命令');
lines.push('');
lines.push(result.recovery.rule);
lines.push('');
lines.push('```bash');
for (const cmd of result.recovery.commands) lines.push(cmd);
lines.push('```');
}
return lines.join('\n') + '\n';
}
(async () => {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
const result = await runCheck(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
if (!result.passed) process.exitCode = args.strict || !result.explainableFailure ? 1 : 0;
})().catch(error => {
console.error(error && error.message ? error.message : String(error));
process.exitCode = 2;
});

View File

@@ -0,0 +1,733 @@
#!/usr/bin/env node
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const AUDIT_SCHEMA = 'xhr-fetch-semantics-audit/v3';
const TRANSCRIPT_SCHEMA = 'network-transcript/v3';
const SHA256_RE = /^[a-f0-9]{64}$/i;
const COMMON_FIELDS = ['actor', 'realmId', 'navigationEpoch', 'sessionId'];
const REQUEST_FIELDS = ['method', 'url', 'credentials', 'referrer'];
const RESPONSE_FIELDS = [
'status',
'statusText',
'responseURL',
'responseType',
'bodyLength',
'bodySha256',
];
const OPTIONAL_REQUEST_FIELDS = [
'taskId',
'origin',
'cookiePolicy',
'bodyType',
'redirect',
'cache',
'timeout',
'withCredentials',
'async',
];
function parseArgs(argv) {
const args = {
caseDir: '',
browser: '',
node: '',
out: '',
require: false,
requireNoSend: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--case-dir' || arg === '--case' || arg === '-d') args.caseDir = argv[++i] || '';
else if (arg === '--browser') args.browser = argv[++i] || '';
else if (arg === '--node') args.node = argv[++i] || '';
else if (arg === '--out') args.out = argv[++i] || '';
else if (arg === '--require') args.require = true;
else if (arg === '--require-no-send') args.requireNoSend = true;
else if (arg === '--json') args.json = true;
else if (arg === '--markdown') args.markdown = true;
else if (arg === '--help' || arg === '-h') args.help = true;
else throw new Error(`未知参数:${arg}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_xhr_fetch_semantics.js --case-dir case --require --require-no-send --markdown
node scripts/check_xhr_fetch_semantics.js --browser case/fixtures/browser-network-transcript.ndjson --node case/tmp/node-network-transcript.ndjson --out case/tmp/xhr-fetch-semantics-audit.json --json
说明:先验证 browser/Node transcript 是否包含完整的真实观测字段,再逐事件比较 XHR/fetch/navigation 的请求、响应、Header 顺序、body 摘要、Session 与生命周期。缺失字段不会被默认值掩盖。`;
}
function exists(file) {
try { return fs.existsSync(file); } catch { return false; }
}
function hasOwn(value, key) {
return Boolean(value && Object.prototype.hasOwnProperty.call(value, key));
}
function firstOwn(value, keys) {
for (const key of keys) {
if (hasOwn(value, key)) return { present: true, key, value: value[key] };
}
return { present: false, key: '', value: undefined };
}
function parseTranscript(file) {
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '');
if (/\.json$/i.test(file)) {
const raw = JSON.parse(text);
return {
meta: raw.meta || raw.metadata || (Array.isArray(raw) ? {} : raw),
events: Array.isArray(raw.events)
? raw.events
: (Array.isArray(raw.items) ? raw.items : (Array.isArray(raw) ? raw : [])),
};
}
const events = [];
let meta = {};
for (const line of text.split(/\r?\n/)) {
if (!line.trim()) continue;
const event = JSON.parse(line);
if (event.type === 'meta' || event.event === 'meta') meta = { ...meta, ...event };
else events.push(event);
}
return { meta, events };
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function normalizeHeaderValue(value) {
if (typeof value === 'string') {
return {
sha256: sha256Text(value),
length: Buffer.byteLength(value, 'utf8'),
redacted: false,
};
}
if (value && typeof value === 'object') {
return {
sha256: String(value.sha256 || '').toLowerCase(),
length: Number(value.length),
redacted: value.redacted === true,
};
}
return {
sha256: '',
length: Number.NaN,
redacted: false,
};
}
function headerEntryParts(item) {
if (Array.isArray(item)) return { name: item[0], value: item[1] };
if (item && typeof item === 'object') {
return {
name: hasOwn(item, 'name') ? item.name : item.key,
value: item.value,
};
}
return { name: undefined, value: undefined };
}
function validateAndNormalizeHeaders(value, label, problems, options = {}) {
if (!Array.isArray(value)) {
problems.push(`${label} 必须是保留顺序与重复字段的 Header 数组`);
return [];
}
if (options.requireNonEmpty && value.length === 0) {
problems.push(`${label} 不能为空;完整 network transcript 必须记录实际发送的 Header`);
}
const normalized = [];
for (const [index, item] of value.entries()) {
const parts = headerEntryParts(item);
const name = typeof parts.name === 'string' ? parts.name.trim().toLowerCase() : '';
if (!name) problems.push(`${label}[${index}] 缺少 Header 名称`);
const headerValue = normalizeHeaderValue(parts.value);
if (typeof parts.value === 'string') {
if (parts.value.length === 0) {
problems.push(`${label}[${index}] ${name || '<unknown>'} 的值为空;应记录真实值或脱敏摘要`);
}
} else if (parts.value && typeof parts.value === 'object') {
if (parts.value.redacted !== true
|| !SHA256_RE.test(String(parts.value.sha256 || ''))
|| !Number.isInteger(Number(parts.value.length))
|| Number(parts.value.length) < 0) {
problems.push(`${label}[${index}] ${name || '<unknown>'} 的脱敏值必须包含 redacted=true、length 和 SHA-256`);
}
} else {
problems.push(`${label}[${index}] ${name || '<unknown>'} 缺少 Header 值`);
}
normalized.push([name, headerValue]);
}
return normalized;
}
function kindValue(event) {
return String(firstOwn(event, ['kind', 'event', 'type']).value || '').toLowerCase();
}
function eventClass(kind) {
if (/^(request|send|network-request)$/.test(kind) || /-request$/.test(kind)) return 'request';
if (/^(response|complete|loadend|network-response)$/.test(kind) || /-response$/.test(kind)) return 'response';
if (/realm-destroyed|navigation|reload|pagehide|unload|lifecycle/.test(kind)) return 'lifecycle';
return 'unknown';
}
function normalizeActor(actor) {
const value = String(actor || '').toLowerCase();
return value === 'xmlhttprequest' ? 'xhr' : value;
}
function normalizeEvent(event, index, normalizedHeaders = {}) {
const body = event.body && typeof event.body === 'object' ? event.body : {};
const navigationEpoch = firstOwn(event, ['navigationEpoch', 'epoch']);
const sequence = firstOwn(event, ['sequence', 'seq']);
return {
index,
pairKey: String(firstOwn(event, ['pairKey', 'requestKey', 'logicalId']).value || ''),
kind: kindValue(event),
actor: normalizeActor(firstOwn(event, ['actor', 'initiator', 'api']).value),
realmId: String(firstOwn(event, ['realmId', 'realm']).value || ''),
navigationEpoch: Number(navigationEpoch.value),
taskId: String(event.taskId || ''),
method: String(event.method || '').toUpperCase(),
url: String(firstOwn(event, ['url', 'requestURL']).value || ''),
credentials: String(event.credentials || ''),
referrer: String(event.referrer || ''),
origin: String(event.origin || ''),
cookiePolicy: String(event.cookiePolicy || ''),
bodyType: String(event.bodyType || body.type || ''),
contentType: String(event.contentType || body.contentType || ''),
bodyLength: Number(firstOwn(event, ['bodyLength']).present ? event.bodyLength : (body.length ?? body.byteLength)),
bodySha256: String(event.bodySha256 || body.sha256 || '').toLowerCase(),
headers: normalizedHeaders.headers || [],
responseHeaders: normalizedHeaders.responseHeaders || [],
status: Number(event.status),
statusText: String(hasOwn(event, 'statusText') ? event.statusText : ''),
responseURL: String(hasOwn(event, 'responseURL') ? event.responseURL : ''),
responseType: String(hasOwn(event, 'responseType') ? event.responseType : ''),
lifecycle: Array.isArray(event.lifecycle) ? event.lifecycle.map(String) : [],
validRealm: event.validRealm !== false,
cancelledReason: String(event.cancelledReason || event.cancelReason || ''),
sessionId: String(event.sessionId || ''),
sequence: Number(sequence.value),
redirect: event.redirect,
cache: event.cache,
timeout: event.timeout,
withCredentials: event.withCredentials,
async: event.async,
};
}
function requireField(event, aliases, label, problems, options = {}) {
const found = firstOwn(event, aliases);
if (!found.present) {
problems.push(`${label} 缺少 ${aliases[0]}`);
return undefined;
}
if (options.nonEmpty && String(found.value || '') === '') {
problems.push(`${label} 的 ${aliases[0]} 不能为空`);
}
return found.value;
}
function validateSha256(value, label, problems) {
if (!SHA256_RE.test(String(value || ''))) problems.push(`${label} 必须是完整 SHA-256`);
}
function validateLength(value, label, problems) {
const number = Number(value);
if (!Number.isInteger(number) || number < 0) problems.push(`${label} 必须是非负整数`);
}
function requestHasBody(event) {
const method = String(event.method || '').toUpperCase();
const body = event.body && typeof event.body === 'object' ? event.body : {};
return /^(POST|PUT|PATCH)$/.test(method)
|| hasOwn(event, 'bodyLength')
|| hasOwn(event, 'bodySha256')
|| hasOwn(event, 'contentType')
|| Object.keys(body).length > 0;
}
function validateEvent(event, index, sourceLabel, problems) {
const label = `${sourceLabel} event[${index}]`;
const problemStart = problems.length;
if (!event || typeof event !== 'object' || Array.isArray(event)) {
problems.push(`${label} 必须是对象`);
const normalized = normalizeEvent({}, index);
normalized.validationProblems = problems.slice(problemStart);
return normalized;
}
const kind = requireField(event, ['kind', 'event'], label, problems, { nonEmpty: true });
const cls = eventClass(String(kind || '').toLowerCase());
if (cls === 'unknown') problems.push(`${label} 的 kind 无法归类为 request/response/lifecycle`);
requireField(event, ['pairKey', 'requestKey', 'logicalId'], label, problems, { nonEmpty: true });
requireField(event, ['actor', 'initiator', 'api'], label, problems, { nonEmpty: true });
requireField(event, ['realmId', 'realm'], label, problems, { nonEmpty: true });
const epoch = requireField(event, ['navigationEpoch', 'epoch'], label, problems);
if (!Number.isInteger(Number(epoch)) || Number(epoch) < 0) problems.push(`${label} 的 navigationEpoch 必须是非负整数`);
const sequence = requireField(event, ['sequence', 'seq'], label, problems);
if (!Number.isFinite(Number(sequence))) problems.push(`${label} 的 sequence 必须是数字`);
requireField(event, ['sessionId'], label, problems, { nonEmpty: true });
const normalizedHeaders = {};
if (cls === 'request') {
requireField(event, ['method'], label, problems, { nonEmpty: true });
requireField(event, ['url', 'requestURL'], label, problems, { nonEmpty: true });
requireField(event, ['credentials'], label, problems, { nonEmpty: true });
requireField(event, ['referrer'], label, problems);
const headers = requireField(event, ['headers', 'requestHeaders'], label, problems);
normalizedHeaders.headers = validateAndNormalizeHeaders(
headers,
`${label}.headers`,
problems,
{ requireNonEmpty: true },
);
if (requestHasBody(event)) {
const bodyLength = requireField(event, ['bodyLength'], label, problems);
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
const contentType = requireField(event, ['contentType'], label, problems);
validateLength(bodyLength, `${label}.bodyLength`, problems);
validateSha256(bodySha256, `${label}.bodySha256`, problems);
if (Number(bodyLength) > 0 && String(contentType || '') === '') {
problems.push(`${label} 有请求体但 contentType 为空`);
}
}
} else if (cls === 'response') {
const status = requireField(event, ['status'], label, problems);
requireField(event, ['statusText'], label, problems);
const responseURL = requireField(event, ['responseURL'], label, problems);
requireField(event, ['responseType'], label, problems);
const bodyLength = requireField(event, ['bodyLength'], label, problems);
const bodySha256 = requireField(event, ['bodySha256'], label, problems);
const responseHeaders = requireField(event, ['responseHeaders'], label, problems);
const lifecycle = requireField(event, ['lifecycle'], label, problems);
if (!Number.isInteger(Number(status)) || Number(status) < 0 || Number(status) > 599) {
problems.push(`${label}.status 必须是 0 到 599 的整数`);
}
if (Number(status) > 0 && String(responseURL || '') === '') {
problems.push(`${label} status>0 时必须记录最终 responseURL`);
}
validateLength(bodyLength, `${label}.bodyLength`, problems);
validateSha256(bodySha256, `${label}.bodySha256`, problems);
normalizedHeaders.responseHeaders = validateAndNormalizeHeaders(
responseHeaders,
`${label}.responseHeaders`,
problems,
);
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
problems.push(`${label}.lifecycle 必须记录 readyState/event/Promise 顺序`);
}
} else if (cls === 'lifecycle') {
const lifecycle = requireField(event, ['lifecycle'], label, problems);
if (!Array.isArray(lifecycle) || lifecycle.length === 0) {
problems.push(`${label}.lifecycle 必须是非空数组`);
}
}
const normalized = normalizeEvent(event, index, normalizedHeaders);
normalized.validationProblems = problems.slice(problemStart);
return normalized;
}
function validateMeta(meta, sourceLabel, options, problems) {
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) {
problems.push(`${sourceLabel} transcript 缺少 meta 对象`);
return;
}
if (meta.schemaVersion !== TRANSCRIPT_SCHEMA) {
problems.push(`${sourceLabel} transcript schemaVersion 必须是 ${TRANSCRIPT_SCHEMA}`);
}
if (!String(meta.baselineId || '')) problems.push(`${sourceLabel} transcript 缺少 baselineId`);
const producer = String(meta.generatedBy || meta.capturedBy || '');
if (!producer) {
problems.push(`${sourceLabel} transcript 缺少 generatedBy/capturedBy`);
} else if (options.browser && !/browser|ruyi|camoufox|cloak|firefox|chrome|chromium|webkit|manual/i.test(producer)) {
problems.push(`${sourceLabel} transcript 生成来源不可识别:${producer}`);
} else if (!options.browser && !/node|runtime|audit|probe|recorder/i.test(producer)) {
problems.push(`${sourceLabel} transcript generatedBy 不可信:${producer}`);
}
if (options.browser) {
if (meta.mode !== 'browser-baseline') {
problems.push(`${sourceLabel} transcript mode 必须是 browser-baseline`);
}
} else {
if (!SHA256_RE.test(String(meta.runtimeSourceHash || ''))) {
problems.push(`${sourceLabel} transcript 缺少有效 runtimeSourceHash`);
}
if (options.requireNoSend) {
if (meta.mode !== 'no-send' && meta.networkMode !== 'no-send') {
problems.push(`${sourceLabel} transcript 必须来自 no-send 模式`);
}
if (!hasOwn(meta, 'networkAttempts') || Number(meta.networkAttempts) !== 0) {
problems.push(`${sourceLabel} transcript 必须明确记录 networkAttempts=0`);
}
}
}
}
function validateTranscript(raw, sourceLabel, options, problems) {
validateMeta(raw.meta, sourceLabel, options, problems);
if (!Array.isArray(raw.events) || raw.events.length === 0) {
problems.push(`${sourceLabel} transcript 没有事件`);
return [];
}
const events = raw.events.map((event, index) => validateEvent(event, index, sourceLabel, problems));
let previous = Number.NEGATIVE_INFINITY;
const seen = new Set();
for (const event of events) {
if (seen.has(event.sequence)) problems.push(`${sourceLabel} transcript sequence 重复:${event.sequence}`);
seen.add(event.sequence);
if (!(event.sequence > previous)) {
problems.push(`${sourceLabel} transcript 事件必须按严格递增 sequence 写入`);
}
previous = event.sequence;
}
return events;
}
function compareField(expected, observed, field, diffs) {
if (!Object.is(expected[field], observed[field])) {
diffs.push({ field, expected: expected[field], observed: observed[field] });
}
}
function compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs) {
const expectedPresent = hasOwn(expectedRaw, field);
const observedPresent = hasOwn(observedRaw, field);
if (!expectedPresent && !observedPresent) return;
if (expectedPresent !== observedPresent || expected[field] !== observed[field]) {
diffs.push({
field,
expected: expectedPresent ? expected[field] : '<missing>',
observed: observedPresent ? observed[field] : '<missing>',
});
}
}
function compareHeaders(expected, observed, field, diffs) {
if (JSON.stringify(expected[field]) !== JSON.stringify(observed[field])) {
diffs.push({ field, expected: expected[field], observed: observed[field] });
}
}
function compareLifecycle(expected, observed, diffs) {
if (JSON.stringify(expected.lifecycle) !== JSON.stringify(observed.lifecycle)) {
diffs.push({ field: 'lifecycle', expected: expected.lifecycle, observed: observed.lifecycle });
}
}
function eventKey(event) {
return `${eventClass(event.kind)}:${event.pairKey}`;
}
function hardInvariants(events) {
const problems = [];
const destroyedRealms = new Map();
for (const event of events) {
const cls = eventClass(event.kind);
if (/realm-destroyed|navigation-commit|reload-commit/.test(event.kind)) {
destroyedRealms.set(`${event.realmId}:${event.navigationEpoch}`, event.sequence);
if (event.actor === 'xhr') {
problems.push(`${event.pairKey}:document navigation/reload 被错误归类为 XHR`);
}
continue;
}
if (cls === 'response' && event.status === 0 && event.responseURL !== '') {
problems.push(`${event.pairKey}:status=0 时 responseURL 必须为空,当前为 ${event.responseURL}`);
}
const destroyedAt = destroyedRealms.get(`${event.realmId}:${event.navigationEpoch}`);
if (typeof destroyedAt !== 'undefined' && event.sequence > destroyedAt && !event.cancelledReason) {
problems.push(`${event.pairKey}:旧 realm ${event.realmId}/${event.navigationEpoch} 销毁后仍产生未取消的 timer/XHR/task`);
}
if (event.validRealm === false && !event.cancelledReason) {
problems.push(`${event.pairKey}:失效 realm 的事件缺少取消原因`);
}
}
return problems;
}
function compareEvent(expectedRaw, observedRaw, expected, observed) {
const diffs = [];
if (expected.validationProblems.length) {
diffs.push({
field: 'browserTranscriptCompleteness',
expected: 'complete',
observed: `${expected.validationProblems.length} problem(s)`,
});
}
if (observed.validationProblems.length) {
diffs.push({
field: 'nodeTranscriptCompleteness',
expected: 'complete',
observed: `${observed.validationProblems.length} problem(s)`,
});
}
for (const field of COMMON_FIELDS) compareField(expected, observed, field, diffs);
const cls = eventClass(expected.kind);
if (cls !== eventClass(observed.kind)) {
diffs.push({ field: 'kindClass', expected: cls, observed: eventClass(observed.kind) });
return diffs;
}
if (cls === 'request') {
for (const field of REQUEST_FIELDS) compareField(expected, observed, field, diffs);
compareHeaders(expected, observed, 'headers', diffs);
const expectedBody = requestHasBody(expectedRaw);
const observedBody = requestHasBody(observedRaw);
if (expectedBody !== observedBody) {
diffs.push({ field: 'bodyPresence', expected: expectedBody, observed: observedBody });
} else if (expectedBody) {
for (const field of ['contentType', 'bodyLength', 'bodySha256']) {
compareField(expected, observed, field, diffs);
}
}
for (const field of OPTIONAL_REQUEST_FIELDS) {
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
}
} else if (cls === 'response') {
for (const field of RESPONSE_FIELDS) compareField(expected, observed, field, diffs);
compareHeaders(expected, observed, 'responseHeaders', diffs);
compareLifecycle(expected, observed, diffs);
} else if (cls === 'lifecycle') {
compareLifecycle(expected, observed, diffs);
for (const field of ['method', 'url']) {
compareOptionalField(expectedRaw, observedRaw, expected, observed, field, diffs);
}
}
return diffs;
}
function emptyResult(caseDir, browserPath, nodePath, problems, warnings = []) {
return {
schemaVersion: AUDIT_SCHEMA,
generatedBy: 'check_xhr_fetch_semantics.js',
clean: false,
caseDir,
browserPath,
nodePath,
problems,
warnings,
results: [],
summary: {},
};
}
function extraEventsByKey(expectedEvents, observedEvents) {
const remaining = new Map();
for (const event of expectedEvents) {
const key = eventKey(event);
remaining.set(key, (remaining.get(key) || 0) + 1);
}
const extra = [];
for (const event of observedEvents) {
const key = eventKey(event);
const count = remaining.get(key) || 0;
if (count > 0) remaining.set(key, count - 1);
else extra.push(event);
}
return extra;
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const browserPath = path.resolve(args.browser || path.join(caseDir, 'fixtures', 'browser-network-transcript.ndjson'));
const nodePath = path.resolve(args.node || path.join(caseDir, 'tmp', 'node-network-transcript.ndjson'));
const problems = [];
const warnings = [];
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(browserPath)) {
problems.push(`缺少浏览器 network transcript:${browserPath}`);
}
if ((args.require || exists(browserPath) || exists(nodePath)) && !exists(nodePath)) {
problems.push(`缺少 Node network transcript:${nodePath}`);
}
if (problems.length) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
if (!exists(browserPath) && !exists(nodePath)) {
return {
...emptyResult(caseDir, browserPath, nodePath, [], ['未触发 network transcript 审计']),
clean: true,
};
}
let browserRaw;
let nodeRaw;
try { browserRaw = parseTranscript(browserPath); } catch (err) { problems.push(`浏览器 transcript 无法解析:${err.message}`); }
try { nodeRaw = parseTranscript(nodePath); } catch (err) { problems.push(`Node transcript 无法解析:${err.message}`); }
if (!browserRaw || !nodeRaw) return emptyResult(caseDir, browserPath, nodePath, problems, warnings);
const browserEvents = validateTranscript(browserRaw, '浏览器', { browser: true }, problems);
const nodeEvents = validateTranscript(
nodeRaw,
'Node',
{ browser: false, requireNoSend: args.requireNoSend },
problems,
);
if (browserRaw.meta.baselineId && nodeRaw.meta.baselineId
&& nodeRaw.meta.baselineId !== browserRaw.meta.baselineId) {
problems.push(`network transcript baselineId 不一致:${browserRaw.meta.baselineId} != ${nodeRaw.meta.baselineId}`);
}
for (const invariant of hardInvariants(browserEvents)) problems.push(`浏览器 transcript:${invariant}`);
for (const invariant of hardInvariants(nodeEvents)) problems.push(`Node transcript:${invariant}`);
const browserTimeline = browserEvents.map(eventKey);
const nodeTimeline = nodeEvents.map(eventKey);
if (JSON.stringify(browserTimeline) !== JSON.stringify(nodeTimeline)) {
problems.push('browser/Node network event timeline 不一致;请求、响应或生命周期顺序发生变化');
}
const results = [];
const count = Math.max(browserEvents.length, nodeEvents.length);
for (let index = 0; index < count; index++) {
const expected = browserEvents[index];
const observed = nodeEvents[index];
if (!expected || !observed) {
const key = expected ? eventKey(expected) : eventKey(observed);
const diffs = [{
field: '*',
expected: expected ? 'browser event' : 'missing',
observed: observed ? 'node event' : 'missing',
}];
results.push({ key, index, clean: false, diffs });
continue;
}
const diffs = compareEvent(
browserRaw.events[index],
nodeRaw.events[index],
expected,
observed,
);
if (diffs.length) {
problems.push(`${eventKey(expected)}:${diffs.map(diff => diff.field).join('、')} 不一致`);
}
results.push({ key: eventKey(expected), index, clean: diffs.length === 0, diffs });
}
const extraNode = extraEventsByKey(browserEvents, nodeEvents);
const missingNode = extraEventsByKey(nodeEvents, browserEvents);
const extraNodeEvents = extraNode.length;
const extraNodeRequests = extraNode.filter(event => eventClass(event.kind) === 'request').length;
if (extraNodeEvents) {
problems.push(`Node transcript 比浏览器多出 ${extraNodeEvents} 个事件,其中 ${extraNodeRequests} 个请求`);
}
if (missingNode.length) {
problems.push(`Node transcript 比浏览器少 ${missingNode.length} 个事件`);
}
const browserSessionIds = [...new Set(browserEvents.map(event => event.sessionId).filter(Boolean))];
const nodeSessionIds = [...new Set(nodeEvents.map(event => event.sessionId).filter(Boolean))];
if (browserSessionIds.length !== 1) {
problems.push(`浏览器 transcript 必须使用一个 sessionId,当前为 ${browserSessionIds.join('、') || '未记录'}`);
}
if (nodeSessionIds.length !== 1) {
problems.push(`Node transcript 必须使用一个 sessionId,当前为 ${nodeSessionIds.join('、') || '未记录'}`);
}
const summary = {
browserEvents: browserEvents.length,
nodeEvents: nodeEvents.length,
matched: results.filter(item => item.clean).length,
mismatched: results.filter(item => !item.clean).length,
networkMismatches: results.filter(item => !item.clean).length,
extraNodeEvents,
extraNodeRequests,
browserSessionIds,
nodeSessionIds,
baselineId: browserRaw.meta.baselineId || nodeRaw.meta.baselineId || '',
runtimeSourceHash: nodeRaw.meta.runtimeSourceHash || '',
networkMode: nodeRaw.meta.mode || nodeRaw.meta.networkMode || '',
networkAttempts: nodeRaw.meta.networkAttempts,
};
return {
schemaVersion: AUDIT_SCHEMA,
generatedBy: 'check_xhr_fetch_semantics.js',
clean: problems.length === 0,
caseDir,
browserPath,
nodePath,
problems,
warnings,
results,
summary,
};
}
function renderMarkdown(result) {
const lines = [
'# XHR/fetch 请求语义审计结果',
'',
`- 浏览器 transcript:${result.browserPath}`,
`- Node transcript:${result.nodePath}`,
`- 是否通过:${result.clean ? '是' : '否'}`,
];
if (result.summary && typeof result.summary.browserEvents !== 'undefined') {
lines.push(`- 浏览器事件:${result.summary.browserEvents}`);
lines.push(`- Node 事件:${result.summary.nodeEvents}`);
lines.push(`- matched:${result.summary.matched}`);
lines.push(`- mismatch:${result.summary.mismatched}`);
lines.push(`- Node 多余事件:${result.summary.extraNodeEvents}`);
lines.push(`- Node 多余请求:${result.summary.extraNodeRequests}`);
lines.push(`- Node sessionId:${result.summary.nodeSessionIds.join('、') || '未记录'}`);
lines.push(`- no-send 网络尝试:${String(result.summary.networkAttempts ?? '未记录')}`);
}
const bad = result.results.filter(item => !item.clean);
if (bad.length) {
lines.push('', '## 逐项差异');
for (const item of bad.slice(0, 100)) {
lines.push(`- ${item.index}/${item.key}:${item.diffs.map(diff => diff.field).join('、')}`);
}
}
if (result.problems.length) {
lines.push('', '## 阻断问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const warning of result.warnings) lines.push(`- ${warning}`);
}
return `${lines.join('\n')}\n`;
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.out) {
const out = path.resolve(args.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`, 'utf8');
}
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = {
check,
eventClass,
hardInvariants,
normalizeEvent,
validateAndNormalizeHeaders,
validateTranscript,
};

View File

@@ -0,0 +1,313 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: '',
dir: '',
tlsClient: '',
requireLive: false,
json: false,
markdown: false,
help: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--case' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--dir') args.dir = argv[++i] || '';
else if (a === '--tls-client') args.tlsClient = argv[++i] || '';
else if (a === '--require-live') args.requireLive = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --markdown
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --require-live --json
node scripts/check_xhr_fetch_session_bridge.js --case-dir case --tls-client curl_cffi --require-live --markdown
说明:检查 XMLHttpRequest / fetch / sendBeacon 是否只是 fixture/mock/default 200,或是否已通过 live session bridge 复用同一 TLS 指纹兼容 Session。`;
}
function exists(p) { try { return fs.existsSync(p); } catch { return false; } }
function stat(p) { try { return fs.statSync(p); } catch { return null; } }
function readText(p) { return fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''); }
function rel(root, p) { return (path.relative(root, p) || '.').replace(/\\/g, '/'); }
function ext(p) { return path.extname(p).toLowerCase(); }
function walk(root, out = []) {
if (!exists(root)) return out;
const st = stat(root);
if (!st) return out;
if (st.isFile()) {
out.push(root);
return out;
}
if (!st.isDirectory()) return out;
for (const name of fs.readdirSync(root)) walk(path.join(root, name), out);
return out;
}
function isCodeFile(file) {
return ['.js', '.mjs', '.cjs', '.py', '.ts'].includes(ext(file));
}
function shouldSkip(file) {
const n = file.replace(/\\/g, '/').toLowerCase();
return /(^|\/)(node_modules|dist|build|coverage|vendor|third_party|third-party)(\/|$)/.test(n)
|| /(\.min\.js|bundle\.js|vendor\.js)$/i.test(n);
}
function findLines(text, pattern, limit = 10) {
const out = [];
const lines = text.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) out.push({ line: i + 1, text: lines[i].trim().slice(0, 180) });
if (out.length >= limit) break;
}
return out;
}
const NETWORK_API_RE = /\b(XMLHttpRequest|fetch|Request|Response|Headers|sendBeacon)\b/;
const XHR_IMPL_RE = /\b(class|function)\s+XMLHttpRequest\b|XMLHttpRequest\.prototype|\bXMLHttpRequest\s*=\s*(?:class|function)|\bopen\s*\([^)]*\)\s*\{[\s\S]{0,400}\bsend\s*\(/;
const FETCH_IMPL_RE = /\b(?:window|globalThis|ctx|self)\.fetch\s*=|\bfunction\s+fetch\s*\(|\basync\s+function\s+fetch\s*\(|\bfetch\s*:\s*(?:async\s*)?function|\binstallFetch\b/i;
const OFFLINE_RE = /offline-fixture|diagnostic-only|fixture|mock|defaultResponse|default response|xhrResponseForRequest|responseForRequest|fake|stub|不发送真实网络请求|不发真实请求|does not send real network|默认\s*200|status\s*[:=]\s*200/i;
const LIVE_BRIDGE_RE = /live-session-bridge|xhr-fetch-session-bridge|sessionBridge|bridgeRequest|sendViaSession|requestThroughSession|bridge envelope|IPC|stdio|process\.send|parentPort|createRequestSession|create_request_session|requests\.Session|curl_cffi|curl-cffi|cffi_curl|cyCronet|cycronet|CycleTLS|cycletls|impers/i;
const ORDINARY_CLIENT_RE = /\b(?:http|https)\.request\s*\(|\baxios\s*\(|\bundici\b|\bnode-fetch\b|\bglobalThis\.fetch\s*\(|\bfetch\s*\(\s*(?:url|requestUrl|targetUrl)|\brequests\.request\s*\(/i;
const PYTHON_SESSION_BRIDGE_RE = /final\.py|create_request_session|requests\.Session|curl_cffi|session\.request\s*\(|subprocess\.Popen|stdin|stdout|IPC|bridge/i;
function inspectFiles(root, files) {
const result = {
networkFiles: [],
offlineHits: [],
liveBridgeHits: [],
ordinaryClientHits: [],
xhrImplHits: [],
fetchImplHits: [],
pythonSessionBridgeHits: [],
};
for (const file of files) {
const text = readText(file);
const r = rel(root, file);
if (!NETWORK_API_RE.test(text) && !/curl_cffi|CycleTLS|impers|sessionBridge|bridge/i.test(text)) continue;
result.networkFiles.push(r);
const offline = findLines(text, OFFLINE_RE, 5);
if (offline.length) result.offlineHits.push({ file: r, lines: offline });
const live = findLines(text, LIVE_BRIDGE_RE, 8);
if (live.length) result.liveBridgeHits.push({ file: r, lines: live });
const ordinary = findLines(text, ORDINARY_CLIENT_RE, 5);
if (ordinary.length) result.ordinaryClientHits.push({ file: r, lines: ordinary });
const xhr = findLines(text, XHR_IMPL_RE, 5);
if (xhr.length) result.xhrImplHits.push({ file: r, lines: xhr });
const fetch = findLines(text, FETCH_IMPL_RE, 5);
if (fetch.length) result.fetchImplHits.push({ file: r, lines: fetch });
const pyBridge = findLines(text, PYTHON_SESSION_BRIDGE_RE, 8);
if (pyBridge.length) result.pythonSessionBridgeHits.push({ file: r, lines: pyBridge });
}
return result;
}
function inspectBridgeNotes(caseDir) {
const notesFile = path.join(caseDir, 'notes', 'xhr-fetch-session-bridge.md');
const auditFile = path.join(caseDir, 'tmp', 'xhr-fetch-bridge-audit.json');
const semanticsFile = path.join(caseDir, 'tmp', 'xhr-fetch-semantics-audit.json');
const notes = {
notesFile,
auditFile,
semanticsFile,
notesPresent: exists(notesFile),
auditPresent: exists(auditFile),
semanticsPresent: exists(semanticsFile),
mode: '',
auditOk: false,
semanticsOk: false,
audit: {},
semantics: {},
};
if (notes.notesPresent) {
const text = readText(notesFile);
if (/live-session-bridge/i.test(text)) notes.mode = 'live-session-bridge';
else if (/offline-fixture/i.test(text)) notes.mode = 'offline-fixture';
}
if (notes.auditPresent) {
try {
const raw = JSON.parse(readText(auditFile));
notes.audit = raw;
notes.auditOk = raw.schemaVersion === 'xhr-fetch-session-bridge-audit/v2'
&& /runtime|bridge/i.test(String(raw.generatedBy || ''))
&& raw.mode === 'live-session-bridge'
&& raw.sameSessionVerified === true
&& Number(raw.transportRoundTrips || 0) > 0
&& Boolean(raw.sessionId)
&& Boolean(raw.runtimeSourceHash);
if (!notes.mode && raw.mode) notes.mode = String(raw.mode);
} catch (_) {
notes.auditOk = false;
}
}
if (notes.semanticsPresent) {
try {
const raw = JSON.parse(readText(semanticsFile));
notes.semantics = raw;
notes.semanticsOk = raw.schemaVersion === 'xhr-fetch-semantics-audit/v2'
&& raw.generatedBy === 'check_xhr_fetch_semantics.js'
&& raw.clean === true
&& raw.summary
&& raw.summary.networkMode === 'no-send'
&& Boolean(raw.summary.runtimeSourceHash);
} catch (_) {
notes.semanticsOk = false;
}
}
return notes;
}
function isCurlCffiClient(tlsClient) {
return /curl[_-]?cffi|cffi_curl/i.test(String(tlsClient || ''));
}
function check(args) {
const caseDir = path.resolve(args.caseDir || '.');
const root = path.resolve(args.dir || path.join(caseDir, 'result'));
const files = walk(root).filter(file => stat(file) && stat(file).isFile() && isCodeFile(file) && !shouldSkip(file));
const problems = [];
const warnings = [];
const evidence = inspectFiles(root, files);
const bridgeNotes = inspectBridgeNotes(caseDir);
const hasNetworkImpl = evidence.xhrImplHits.length > 0 || evidence.fetchImplHits.length > 0;
const hasLiveBridgeCodeEvidence = evidence.liveBridgeHits.length > 0;
const hasLiveBridge = bridgeNotes.auditOk && bridgeNotes.semanticsOk;
const hasOffline = bridgeNotes.mode === 'offline-fixture' || evidence.offlineHits.length > 0;
if (!files.length) problems.push(`未找到可检查的 result 代码文件:${root}`);
if (hasNetworkImpl && evidence.ordinaryClientHits.length) {
problems.push('XHR/fetch 网络对象疑似直接调用普通 Node/Python HTTP 客户端,必须改为 live session bridge 复用已确认 TLS Session。');
}
if (args.requireLive && hasNetworkImpl && !hasLiveBridge) {
problems.push('要求 live session bridge,但缺少可信运行时 bridge audit 或 no-send 请求语义审计;代码关键词和手工 matched 不能作为通过证据。');
}
if (args.requireLive && hasOffline && !hasLiveBridge) {
problems.push('检测到 offline fixture / mock / 默认响应,但缺少 live session bridge。真实请求场景不得把 fixture 响应写成 TLS 已解决。');
}
if (isCurlCffiClient(args.tlsClient) && hasNetworkImpl) {
const audit = bridgeNotes.audit || {};
if (!evidence.pythonSessionBridgeHits.length) {
problems.push('最终 TLS 客户端为 curl_cffi / cffi_curl,但未检测到 final.py 持有 Session 并服务 Node IPC bridge 的代码线索。');
}
if (audit.sessionOwner !== 'final.py' || !/curl[_-]?cffi|cffi_curl/i.test(String(audit.tlsClient || '')) || audit.sameSessionVerified !== true) {
problems.push('curl_cffi 场景缺少可信运行时证明:sessionOwner 必须为 final.py、tlsClient 必须匹配,且 sameSessionVerified=true。');
}
}
if (hasNetworkImpl && !bridgeNotes.notesPresent) {
warnings.push('建议生成 case/notes/xhr-fetch-session-bridge.md,记录 offline-fixture 或 live-session-bridge 模式、Session 持有者和 Cookie 同步策略。');
}
if (!hasNetworkImpl && args.requireLive) {
problems.push('传入了 --require-live,但未检测到明确 XHR/fetch 实现;不得以“可能由外部模块提供”作为豁免。');
}
if (args.requireLive && !bridgeNotes.auditPresent) {
problems.push('真实请求模式缺少 case/tmp/xhr-fetch-bridge-audit.json。');
}
if (args.requireLive && !bridgeNotes.semanticsPresent) {
problems.push('真实请求模式缺少 case/tmp/xhr-fetch-semantics-audit.json。');
}
if (args.requireLive && hasLiveBridgeCodeEvidence && !bridgeNotes.auditOk) {
warnings.push('发现 live bridge 代码关键词,但运行时 bridge audit 未通过;静态关键词不计入验收。');
}
return {
caseDir,
root,
clean: problems.length === 0,
requireLive: args.requireLive,
tlsClient: args.tlsClient || '',
hasNetworkImpl,
hasLiveBridge,
hasLiveBridgeCodeEvidence,
hasOffline,
filesChecked: files.length,
bridgeNotes,
evidence,
problems,
warnings,
};
}
function renderHitList(items) {
if (!items.length) return ['- 无'];
const out = [];
for (const item of items) {
out.push(`- ${item.file}`);
for (const line of item.lines) out.push(` - 第 ${line.line} 行:${line.text}`);
}
return out;
}
function renderMarkdown(result) {
const lines = [
'# XHR/fetch Session Bridge 检查结果',
'',
`case 目录:${result.caseDir}`,
`检查范围:${result.root}`,
`是否要求 live bridge:${result.requireLive ? '是' : '否'}`,
`TLS 客户端:${result.tlsClient || '未指定'}`,
`是否通过:${result.clean ? '是' : '否'}`,
'',
'## 摘要',
`- 检查文件数:${result.filesChecked}`,
`- 检测到 XHR/fetch 实现:${result.hasNetworkImpl ? '是' : '否'}`,
`- 检测到 live session bridge 证据:${result.hasLiveBridge ? '是' : '否'}`,
`- 仅检测到 live bridge 代码关键词:${result.hasLiveBridgeCodeEvidence ? '是' : '否'}`,
`- 检测到 offline fixture / mock 证据:${result.hasOffline ? '是' : '否'}`,
`- bridge 记录:${result.bridgeNotes.notesPresent ? result.bridgeNotes.notesFile : '未发现'}`,
`- bridge audit:${result.bridgeNotes.auditPresent ? result.bridgeNotes.auditFile : '未发现'}`,
`- 请求语义 audit:${result.bridgeNotes.semanticsPresent ? result.bridgeNotes.semanticsFile : '未发现'}`,
'',
'## XHR 实现证据',
...renderHitList(result.evidence.xhrImplHits),
'',
'## fetch 实现证据',
...renderHitList(result.evidence.fetchImplHits),
'',
'## live bridge 证据',
...renderHitList(result.evidence.liveBridgeHits),
'',
'## offline fixture / mock 证据',
...renderHitList(result.evidence.offlineHits),
];
if (result.problems.length) {
lines.push('', '## 问题');
for (const problem of result.problems) lines.push(`- ${problem}`);
}
if (result.warnings.length) {
lines.push('', '## 提醒');
for (const warning of result.warnings) lines.push(`- ${warning}`);
}
return `${lines.join('\n')}\n`;
}
try {
const args = parseArgs(process.argv);
if (args.help) {
console.log(usage());
process.exit(0);
}
const result = check(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
process.exit(result.clean ? 0 : 1);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
module.exports = { check };

View File

@@ -0,0 +1,396 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
caseDir: null,
dryRun: false,
force: false,
includeProfiles: false,
json: false,
markdown: false,
pruneEmpty: true,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i];
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--force') args.force = true;
else if (a === '--include-profiles') args.includeProfiles = true;
else if (a === '--no-prune-empty') args.pruneEmpty = false;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/clean_case.js --case-dir case --dry-run --markdown
node scripts/clean_case.js --case-dir case --force --markdown
node scripts/clean_case.js --case-dir case --force --include-profiles --markdown
node scripts/clean_case.js --case-dir case --force --no-prune-empty --json
说明:清理 case 内测试文件、临时文件、缓存文件、中间产物和空目录。默认不删除疑似登录态 Profile / Cookie / IndexedDB。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function stat(p) {
try { return fs.statSync(p); } catch { return null; }
}
function isDangerousDir(p) {
const root = path.parse(p).root;
const normalized = path.resolve(p);
return normalized === root || normalized.length <= root.length + 2;
}
function normalizeSlash(p) {
return String(p).replace(/\\/g, '/');
}
function relPath(caseDir, p) {
const rel = path.relative(caseDir, p) || '.';
return normalizeSlash(rel);
}
function isInside(parent, child) {
const rel = path.relative(parent, child);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function isFirefoxProfileDir(p) {
const st = stat(p);
if (!st || !st.isDirectory()) return false;
let names = [];
try { names = fs.readdirSync(p).map(name => name.toLowerCase()); } catch { return false; }
const nameSet = new Set(names);
const markerHits = [
'prefs.js',
'cookies.sqlite',
'places.sqlite',
'storage',
'cache2',
'cert9.db',
'key4.db',
'logins.db',
'webappsstore.sqlite',
'parent.lock',
'sessionstore-backups',
].filter(name => nameSet.has(name)).length;
return markerHits >= 2;
}
function isProfilePath(p) {
const lower = normalizeSlash(p).toLowerCase();
if (/(^|\/)(cloak-profile|camoufox-profile|camoufox-user-data|browser-profile|user-data-dir|user-data|firefox-profile|chrome-profile|ruyipage-profile|profile)(\/|$)/.test(lower)
|| /(^|\/)[^/]*profile[^/]*(\/|$)/.test(lower)
|| /(^|\/)(cookies|local storage|indexeddb|session storage)(\/|$)/.test(lower)
|| /\b(cookie|localstorage|sessionstorage|authorization|token)\b/i.test(lower)) return true;
let cur = path.resolve(p);
const st = stat(cur);
if (!st || !st.isDirectory()) cur = path.dirname(cur);
while (true) {
if (isFirefoxProfileDir(cur)) return true;
const parent = path.dirname(cur);
if (parent === cur) break;
cur = parent;
}
return false;
}
function isDisposableDirName(name) {
const n = name.toLowerCase();
return [
'tmp', '.tmp', 'temp', '.temp', 'cache', '.cache',
'__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache',
'browser-temp', 'camoufox-trace', 'camoufox-values', 'failed', '.downloads', '.tmp-downloads',
].includes(n);
}
function isDisposableFileName(name) {
const n = name.toLowerCase();
if (['.ds_store', 'thumbs.db', 'desktop.ini'].includes(n)) return true;
if (/\.(tmp|temp|bak|old|orig|retry|partial|download|crdownload|cache)$/i.test(n)) return true;
if (/^(env-trace\.jsonl|missing-env\.json|node-output\.json|run-output\.json)$/i.test(n)) return true;
if (/^(fingerprint-hook|.*-fingerprint-hook|hook-fingerprint).*\.(js|mjs|cjs)$/i.test(n)) return true;
if (/^(test-|tmp-|temp-|debug-|scratch-).+\.(js|mjs|cjs|json|jsonl|log|txt|md|html|png|jpg|jpeg|webp|har)$/i.test(n)) return true;
if (/(\.test-output|\.debug-output|\.tmp-output)\./i.test(n)) return true;
return false;
}
function listTree(p, out = []) {
if (!exists(p)) return out;
const st = stat(p);
if (!st) return out;
if (st.isDirectory()) {
let names = [];
try { names = fs.readdirSync(p); } catch { names = []; }
for (const name of names) listTree(path.join(p, name), out);
}
out.push(p);
return out;
}
function listDirsDeepFirst(p) {
const out = [];
function visit(dir) {
if (!exists(dir)) return;
let names = [];
try { names = fs.readdirSync(dir); } catch { names = []; }
for (const name of names) {
const child = path.join(dir, name);
const st = stat(child);
if (st && st.isDirectory()) visit(child);
}
out.push(dir);
}
visit(p);
return out.sort((a, b) => b.length - a.length);
}
function hasProfileInside(p) {
return listTree(p).some(isProfilePath);
}
function isProfileProtectedContainer(p, includeProfiles) {
if (includeProfiles) return false;
const st = stat(p);
return !!st && st.isDirectory() && !isProfilePath(p) && hasProfileInside(p);
}
function hasOnlyProfileChildren(dir) {
const st = stat(dir);
if (!st || !st.isDirectory()) return false;
let names = [];
try { names = fs.readdirSync(dir); } catch { return false; }
if (names.length === 0) return false;
return names.every(name => {
const child = path.join(dir, name);
return isProfilePath(child) || hasProfileInside(child);
});
}
function addAction(actions, action, target, reason) {
actions.push({ action, path: target, reason: reason || '' });
}
function removePath(target, dryRun, recursive = true) {
if (dryRun) return;
const st = stat(target);
if (!st) return;
if (st.isDirectory() && recursive) fs.rmSync(target, { recursive: true, force: true });
else if (st.isDirectory()) fs.rmdirSync(target);
else fs.rmSync(target, { force: true });
}
function cleanDisposableDir(caseDir, dir, args, actions) {
if (!exists(dir)) return;
const st = stat(dir);
if (!st || !st.isDirectory()) return;
if (isProfilePath(dir) && !args.includeProfiles) {
addAction(actions, 'skip-profile', dir, '疑似登录态或浏览器 Profile,默认保留');
return;
}
if (isProfileProtectedContainer(dir, args.includeProfiles)) {
addAction(actions, 'keep-profile-container', dir, '目录内包含疑似 Profile,仅清理非敏感子项');
let names = [];
try { names = fs.readdirSync(dir); } catch { names = []; }
for (const name of names) {
const child = path.join(dir, name);
if (isProfilePath(child) || hasProfileInside(child)) {
addAction(actions, 'skip-profile', child, '疑似登录态或浏览器 Profile,默认保留');
continue;
}
addAction(actions, args.dryRun ? 'would-delete' : 'delete', child, '清理临时目录中的非敏感子项');
removePath(child, args.dryRun, true);
}
return;
}
addAction(actions, args.dryRun ? 'would-delete' : 'delete', dir, '清理临时 / 缓存 / 中间产物目录');
removePath(dir, args.dryRun, true);
}
function isTempLikePath(caseDir, p, includeProfiles) {
if (p === caseDir) return false;
if (!includeProfiles && isProfilePath(p)) return false;
const rel = relPath(caseDir, p).toLowerCase();
const base = path.basename(p).toLowerCase();
const tempLike = rel.split('/').some(part => isDisposableDirName(part)) || isDisposableFileName(base);
if (!tempLike) return false;
const st = stat(p);
if (!includeProfiles && st && st.isDirectory() && isProfileProtectedContainer(p, includeProfiles) && hasOnlyProfileChildren(p)) {
return false;
}
return true;
}
function collectRemainingTempLike(caseDir, includeProfiles) {
return listTree(caseDir).filter(p => exists(p) && isTempLikePath(caseDir, p, includeProfiles));
}
function retryRemainingCleanup(caseDir, args, actions) {
if (args.dryRun) return;
for (let round = 0; round < 2; round++) {
const remaining = collectRemainingTempLike(caseDir, args.includeProfiles);
if (!remaining.length) return;
for (const p of remaining.sort((a, b) => b.length - a.length)) {
if (!exists(p) || p === caseDir || !isInside(caseDir, p)) continue;
const st = stat(p);
if (!st) continue;
if (!args.includeProfiles && isProfilePath(p)) continue;
if (!args.includeProfiles && st.isDirectory() && isProfileProtectedContainer(p, args.includeProfiles)) {
cleanDisposableDir(caseDir, p, args, actions);
continue;
}
if (st.isDirectory()) {
addAction(actions, 'delete', p, '二次清理残留临时目录');
removePath(p, false, true);
} else if (isDisposableFileName(path.basename(p)) || relPath(caseDir, p).toLowerCase().split('/').some(part => isDisposableDirName(part))) {
addAction(actions, 'delete', p, '二次清理残留临时文件');
removePath(p, false, false);
}
}
pruneEmptyDirs(caseDir, args, actions);
}
}
function collectDisposableDirs(caseDir) {
const dirs = [];
const direct = [
'tmp', '.tmp', 'temp', '.temp', 'browser-temp', 'cache', '.cache',
'downloads/failed', 'downloads/.tmp', 'downloads/.cache',
'logs/tmp', 'logs/.tmp', 'trace/tmp', 'trace/.tmp',
'ruyi-trace/tmp', 'ruyi-trace/.tmp', 'camoufox-trace', 'camoufox-values',
'camoufox-trace/tmp', 'camoufox-values/tmp', 'screenshots/tmp', 'screenshots/.tmp',
];
for (const rel of direct) dirs.push(path.join(caseDir, rel));
for (const p of listTree(caseDir)) {
const st = stat(p);
if (!st || !st.isDirectory()) continue;
if (p === caseDir) continue;
if (isDisposableDirName(path.basename(p))) dirs.push(p);
}
return Array.from(new Set(dirs.map(p => path.resolve(p)))).sort((a, b) => b.length - a.length);
}
function cleanDisposableFiles(caseDir, args, actions) {
for (const p of listTree(caseDir)) {
if (!exists(p)) continue;
const st = stat(p);
if (!st || !st.isFile()) continue;
if (isProfilePath(p) && !args.includeProfiles) {
addAction(actions, 'skip-sensitive-file', p, '疑似 Cookie / token / 登录态文件,默认保留');
continue;
}
const rel = relPath(caseDir, p).toLowerCase();
const inTempLikeDir = rel.split('/').some(part => isDisposableDirName(part));
const shouldDelete = isDisposableFileName(path.basename(p)) || (st.size === 0 && inTempLikeDir);
if (!shouldDelete) continue;
addAction(actions, args.dryRun ? 'would-delete' : 'delete', p, st.size === 0 ? '清理空临时文件' : '清理临时 / 测试 / 缓存文件');
removePath(p, args.dryRun, false);
}
}
function pruneEmptyDirs(caseDir, args, actions) {
if (!args.pruneEmpty) return;
for (const dir of listDirsDeepFirst(caseDir)) {
if (dir === caseDir) continue;
if (!exists(dir)) continue;
if (isProfilePath(dir) && !args.includeProfiles) continue;
let names = [];
try { names = fs.readdirSync(dir); } catch { continue; }
if (names.length !== 0) continue;
addAction(actions, args.dryRun ? 'would-remove-empty-dir' : 'remove-empty-dir', dir, '清理空目录');
removePath(dir, args.dryRun, false);
}
}
function cleanup(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
if (!exists(caseDir)) throw new Error(`case 目录不存在:${caseDir}`);
const caseStat = stat(caseDir);
if (!caseStat || !caseStat.isDirectory()) throw new Error(`case 路径不是目录:${caseDir}`);
if (isDangerousDir(caseDir)) throw new Error(`拒绝清理危险目录:${caseDir}`);
if (!args.dryRun && !args.force) throw new Error('未提供 --force,拒绝删除;请先使用 --dry-run 预览');
const actions = [];
const disposableDirs = collectDisposableDirs(caseDir);
for (const dir of disposableDirs) {
if (!exists(dir)) continue;
if (dir === caseDir || !isInside(caseDir, dir)) continue;
cleanDisposableDir(caseDir, dir, args, actions);
}
cleanDisposableFiles(caseDir, args, actions);
pruneEmptyDirs(caseDir, args, actions);
retryRemainingCleanup(caseDir, args, actions);
const remainingTempLike = collectRemainingTempLike(caseDir, args.includeProfiles);
return {
caseDir,
dryRun: args.dryRun,
includeProfiles: args.includeProfiles,
pruneEmpty: args.pruneEmpty,
actions,
remainingTempLike,
clean: remainingTempLike.length === 0,
};
}
function renderMarkdown(result) {
const label = {
'skip-profile': '跳过 Profile',
'skip-sensitive-file': '跳过敏感文件',
'keep-profile-container': '保留 Profile 容器目录',
'would-delete': '将删除',
'delete': '已删除',
'would-remove-empty-dir': '将删除空目录',
'remove-empty-dir': '已删除空目录',
};
const lines = ['# 清理结果', '', `case 目录:${result.caseDir}`, `dry-run:${result.dryRun ? '是' : '否'}`, `是否包含 Profile:${result.includeProfiles ? '是' : '否'}`, `是否清理空目录:${result.pruneEmpty ? '是' : '否'}`, ''];
if (result.actions.length) {
lines.push('## 操作列表');
for (const a of result.actions) {
lines.push(`- ${label[a.action] || a.action}:${a.path}${a.reason ? `(${a.reason})` : ''}`);
}
} else {
lines.push('## 操作列表', '- 没有需要清理的内容');
}
lines.push('', '## 清理后检查');
lines.push(`- 是否仍存在普通临时 / 缓存 / 中间产物:${result.clean ? '否' : '是'}`);
if (result.remainingTempLike.length) {
for (const p of result.remainingTempLike) lines.push(` - ${p}`);
}
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = cleanup(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
function parseArgs(argv) {
const args = { fixture: '', actual: '', field: '', expectedPath: '', actualPath: '', json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--fixture') args.fixture = argv[++i] || '';
else if (a === '--actual') args.actual = argv[++i] || '';
else if (a === '--field') args.field = argv[++i] || '';
else if (a === '--expected-path') args.expectedPath = argv[++i] || '';
else if (a === '--actual-path') args.actualPath = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/compare_fixture.js --fixture sample.fixture.json --actual node-output.json --field sign --markdown
node scripts/compare_fixture.js --fixture sample.fixture.json --actual node-output.json --expected-path expected.sign --actual-path output.sign --json`;
}
function readJson(p, label) {
if (!p) throw new Error(`必须提供 ${label}`);
return JSON.parse(fs.readFileSync(p, 'utf8').replace(/^\uFEFF/, ''));
}
function getPath(obj, p) {
if (!p) return undefined;
let cur = obj;
for (const part of p.split('.').filter(Boolean)) {
if (cur == null) return undefined;
cur = cur[part];
}
return cur;
}
function firstDefined(values) {
for (const v of values) if (v !== undefined) return v;
return undefined;
}
function normalize(v) {
if (v === undefined) return undefined;
if (typeof v === 'string') return v;
return JSON.stringify(v);
}
function compare(args) {
const fixture = readJson(args.fixture, '--fixture');
const actual = readJson(args.actual, '--actual');
const field = args.field || fixture.param || '';
const expectedPath = args.expectedPath || (field ? `expected.${field}` : 'expected');
const actualPath = args.actualPath || '';
const expected = firstDefined([getPath(fixture, expectedPath), field ? getPath(fixture, `expected.${field}`) : undefined, fixture.expectedValue]);
const actualValue = actualPath ? getPath(actual, actualPath) : firstDefined([
field ? getPath(actual, `output.${field}`) : undefined,
field ? getPath(actual, field) : undefined,
getPath(actual, 'output'),
getPath(actual, 'result'),
]);
const expectedText = normalize(expected);
const actualText = normalize(actualValue);
const pass = expected !== undefined && actualValue !== undefined && expectedText === actualText;
return { pass, field, expectedPath, actualPath: actualPath || '(自动识别)', expected, actual: actualValue, expectedText, actualText, fixture: args.fixture, actualFile: args.actual };
}
function renderMarkdown(result) {
const lines = ['# fixtures 对比结果', '', `- 结果:${result.pass ? '通过' : '失败'}`, `- 字段:${result.field || '未指定'}`, `- 期望路径:${result.expectedPath}`, `- 实际路径:${result.actualPath}`, `- 浏览器期望值:${result.expectedText === undefined ? '未找到' : result.expectedText}`, `- Node.js 实际值:${result.actualText === undefined ? '未找到' : result.actualText}`];
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = compare(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
if (!result.pass) process.exit(2);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,147 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const https = require('https');
const REPOS = {
ruyitrace: { owner: 'LoseNine', repo: 'Firefox-FingerPrint-Analyzer', asset: /RuyiTrace\.zip$/i },
'ruyipage-firefox': { owner: 'LoseNine', repo: 'ruyipage', asset: null },
};
function parseArgs(argv) {
const args = { tool: '', dest: '', dryRun: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--tool') args.tool = argv[++i] || '';
else if (a === '--dest') args.dest = argv[++i] || '';
else if (a === '--dry-run') args.dryRun = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --dry-run --markdown
node scripts/download_ruyi_tool.js --tool ruyitrace --dest <download-dir> --markdown
node scripts/download_ruyi_tool.js --tool ruyipage-firefox --dest <download-dir> --dry-run --markdown
说明:仅在用户确认后下载。下载后仍需要用户解压 / 安装或确认可执行文件路径。`;
}
function getJson(url) {
return new Promise((resolve, reject) => {
const req = https.get(url, { headers: { 'User-Agent': 'web-js-env-patcher-skill' } }, res => {
let data = '';
res.setEncoding('utf8');
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
getJson(res.headers.location).then(resolve, reject);
return;
}
if (res.statusCode !== 200) reject(new Error(`请求失败 ${res.statusCode}: ${url}`));
else {
try { resolve(JSON.parse(data)); } catch (err) { reject(err); }
}
});
});
req.on('error', reject);
req.setTimeout(60000, () => req.destroy(new Error('请求超时')));
});
}
function pickRuyiPageAsset(assets) {
if (process.platform === 'win32') return assets.find(a => /win64\.zip$/i.test(a.name));
if (process.platform === 'linux' && process.arch === 'x64') return assets.find(a => /linux.*x86_64.*\.tar\.xz$/i.test(a.name));
return assets.find(a => /firefox/i.test(a.name));
}
function selectAsset(tool, assets) {
if (tool === 'ruyipage-firefox') return pickRuyiPageAsset(assets);
const rule = REPOS[tool].asset;
return assets.find(a => rule.test(a.name));
}
function downloadFile(url, file) {
return new Promise((resolve, reject) => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const out = fs.createWriteStream(file);
function request(u) {
https.get(u, { headers: { 'User-Agent': 'web-js-env-patcher-skill' } }, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
request(res.headers.location);
return;
}
if (res.statusCode !== 200) {
out.close();
reject(new Error(`下载失败 ${res.statusCode}: ${u}`));
return;
}
res.pipe(out);
out.on('finish', () => out.close(() => resolve(file)));
}).on('error', err => {
out.close();
reject(err);
});
}
request(url);
});
}
async function plan(args) {
if (!args.tool || !REPOS[args.tool]) throw new Error(`必须提供 --tool,可选:${Object.keys(REPOS).join(', ')}`);
if (!args.dest) throw new Error('必须提供 --dest');
const repo = REPOS[args.tool];
const release = await getJson(`https://api.github.com/repos/${repo.owner}/${repo.repo}/releases/latest`);
const asset = selectAsset(args.tool, release.assets || []);
if (!asset) throw new Error(`未找到适合当前工具 / 平台的 release asset:${args.tool}`);
const destDir = path.resolve(args.dest);
const file = path.join(destDir, asset.name);
const result = {
tool: args.tool,
repo: `${repo.owner}/${repo.repo}`,
releaseName: release.name || '',
tagName: release.tag_name || '',
releaseUrl: release.html_url || '',
assetName: asset.name,
assetSize: asset.size,
downloadUrl: asset.browser_download_url,
destFile: file,
dryRun: args.dryRun,
downloaded: false,
};
if (!args.dryRun) {
await downloadFile(asset.browser_download_url, file);
result.downloaded = true;
}
return result;
}
function renderMarkdown(result) {
const lines = ['# Ruyi 工具下载结果', '', `- 工具:${result.tool}`, `- 仓库:${result.repo}`, `- Release:${result.releaseName || result.tagName}`, `- Release URL:${result.releaseUrl}`, `- 资产:${result.assetName}`, `- 大小:${result.assetSize}`, `- 目标文件:${result.destFile}`, `- dry-run:${result.dryRun ? '是' : '否'}`, `- 是否已下载:${result.downloaded ? '是' : '否'}`];
lines.push('', '## 下一步');
if (result.dryRun) lines.push('- 当前只是下载计划;只有用户确认后再去掉 `--dry-run` 下载。');
else lines.push('- 下载完成。请用户解压 / 安装后,提供工具目录并重新运行检测脚本。');
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
const result = await plan(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
}
main().catch(err => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,488 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = {
types: 'canvas,webgl,dom-geometry',
out: '',
apiPattern: '',
chunkSize: 3000,
deprecatedMaxDataUrlLength: null,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--types') args.types = argv[++i] || args.types;
else if (a === '--out') args.out = argv[++i] || '';
else if (a === '--api-pattern') args.apiPattern = argv[++i] || '';
else if (a === '--chunk-size') args.chunkSize = Number(argv[++i] || args.chunkSize);
else if (a === '--max-data-url-length') {
// 兼容旧参数名:历史版本用它裁剪 dataURL;当前版本严禁裁剪,只把它当作“分片大小”。
args.deprecatedMaxDataUrlLength = Number(argv[++i] || args.chunkSize);
args.chunkSize = args.deprecatedMaxDataUrlLength;
}
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!Number.isFinite(args.chunkSize) || args.chunkSize < 512) args.chunkSize = 3000;
return args;
}
function usage() {
return `用法:
node scripts/generate_fingerprint_hook.js --types canvas,webgl,dom-geometry --out case/hooks/fingerprint-hook.js
node scripts/generate_fingerprint_hook.js --types canvas,webgl,webgpu,audio,dom-geometry --chunk-size 3000
说明:
生成浏览器侧指纹终端 API 采样 Hook。该脚本只用于前置取证,不得进入最终 result/ 交付目录。
长指纹值不会被裁剪为 4000 / 4096 字符;Canvas / WebGL / WebGPU / Audio 等大结果会完整保存或按 chunks 分片保存。
旧参数 --max-data-url-length 已废弃裁剪语义,仅兼容为分片大小,不能用于截断最终值。`;
}
function q(v) { return JSON.stringify(String(v)); }
function header(args) {
return `// 指纹终端 API 采样 Hook,仅用于授权前置取证。
// 使用方式:在用户已确认的取证工具中注入,触发最少量业务动作后执行:
// copy(JSON.stringify(await window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__(), null, 2))
// 如果控制台不支持 await,可先执行:
// window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__().then(v => copy(JSON.stringify(v, null, 2)))
// 注意:长指纹值必须完整保存或分片保存,不得裁剪为 4000 / 4096 字符,也不得把 Trace 可见片段当最终值。
// 不要把本 Hook 放入最终 result/ 交付目录。
(function installFingerprintSampler() {
if (window.__WEB_JS_ENV_PATCHER_FINGERPRINT_HOOKED__) return;
Object.defineProperty(window, "__WEB_JS_ENV_PATCHER_FINGERPRINT_HOOKED__", { value: true, configurable: true });
const chunkSize = ${Number(args.chunkSize) || 3000};
const apiPattern = ${q(args.apiPattern)};
const store = window.__WEB_JS_ENV_PATCHER_FINGERPRINT__ = window.__WEB_JS_ENV_PATCHER_FINGERPRINT__ || {
version: 2,
source: {
pageUrl: location.href,
userAgent: navigator.userAgent,
language: navigator.language,
languages: Array.from(navigator.languages || []),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
capturedAt: new Date().toISOString(),
capturedBy: "真实浏览器指纹采样 Hook",
traceStatus: "browser-complete-sampled",
apiPattern
},
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] }
};
function stack() { try { return new Error().stack || ""; } catch(e) { return ""; } }
function chunkString(value, encoding) {
const text = String(value == null ? "" : value);
const chunks = [];
for (let i = 0; i < text.length; i += chunkSize) chunks.push(text.slice(i, i + chunkSize));
return {
encoding,
valueLength: text.length,
chunkSize,
chunks,
truncated: false,
sha256: "",
sha256Of: encoding === "base64-chunks" ? "base64-string" : "string",
hashStatus: "pending-finalize"
};
}
function b64FromArrayLike(arr) {
try {
const bytes = new Uint8Array(arr.buffer || arr, arr.byteOffset || 0, arr.byteLength || arr.length || 0);
let s = "";
const step = 0x8000;
for (let i = 0; i < bytes.length; i += step) {
const sub = bytes.subarray(i, Math.min(i + step, bytes.length));
s += String.fromCharCode.apply(null, sub);
}
return btoa(s);
} catch(e) { return ""; }
}
function chunkArrayLikeAsBase64(arr) {
const encoded = b64FromArrayLike(arr);
const out = chunkString(encoded, "base64-chunks");
try {
const bytes = new Uint8Array(arr.buffer || arr, arr.byteOffset || 0, arr.byteLength || arr.length || 0);
out.rawByteLength = bytes.byteLength;
} catch(e) {}
return out;
}
function clonePlain(value, depth) {
if (depth <= 0) return String(value);
if (value == null) return value;
if (typeof value === "string") return value.length >= 3900 ? chunkString(value, "string-chunks") : value;
if (typeof value === "number" || typeof value === "boolean") return value;
if (Array.isArray(value)) return value.map(v => clonePlain(v, depth - 1));
if (value instanceof Set) return Array.from(value).map(v => clonePlain(v, depth - 1));
if (value instanceof Map) return Array.from(value.entries()).map(([k, v]) => [clonePlain(k, depth - 1), clonePlain(v, depth - 1)]);
if (typeof value === "object") {
const out = {};
for (const key of Object.keys(value)) {
try { out[key] = clonePlain(value[key], depth - 1); } catch(e) { out[key] = "<read-error>"; }
}
return out;
}
return String(value);
}
function selectorOf(el) {
try {
if (!el) return "";
if (el.id) return "#" + el.id;
if (el.className && typeof el.className === "string") return "." + el.className.trim().split(/\\s+/)[0];
return String(el.tagName || "").toLowerCase();
} catch(e) { return ""; }
}
async function sha256Text(text) {
const input = new TextEncoder().encode(String(text));
const digest = await crypto.subtle.digest("SHA-256", input);
return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, "0")).join("");
}
async function fillChunkHashes(node) {
if (!node || typeof node !== "object") return;
if ((node.encoding === "string-chunks" || node.encoding === "base64-chunks") && Array.isArray(node.chunks)) {
const text = node.chunks.join("");
node.valueLength = text.length;
node.truncated = false;
try {
node.sha256 = await sha256Text(text);
node.hashStatus = "ok";
} catch(e) {
node.hashStatus = "failed:" + (e && e.message || String(e));
}
}
if (Array.isArray(node)) {
for (const item of node) await fillChunkHashes(item);
} else {
for (const key of Object.keys(node)) await fillChunkHashes(node[key]);
}
}
window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__ = async function finalizeFingerprint() {
await fillChunkHashes(store);
store.source.finalizedAt = new Date().toISOString();
store.source.finalizeNote = "长指纹值已完整保存或分片保存,sha256 针对完整字符串或完整 base64 字符串计算,truncated 必须为 false。";
return store;
};
`;
}
function footer() {
return ` console.info("[web-js-env-patcher] 指纹采样 Hook 已安装。触发目标动作后执行:copy(JSON.stringify(await window.__WEB_JS_ENV_PATCHER_FINALIZE_FINGERPRINT__(), null, 2))");
})();`;
}
function canvasSnippet() {
return `
// ===== Canvas 终端 API 采样 =====
if (window.HTMLCanvasElement) {
const rawToDataURL = HTMLCanvasElement.prototype.toDataURL;
if (rawToDataURL && !rawToDataURL.__wjep_hooked__) {
HTMLCanvasElement.prototype.toDataURL = function patchedToDataURL(type, quality) {
const result = rawToDataURL.apply(this, arguments);
store.canvas.toDataURL.push({
match: { width: this.width, height: this.height, type: type || "image/png" },
result: chunkString(result, "string-chunks"),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return result;
};
HTMLCanvasElement.prototype.toDataURL.__wjep_hooked__ = true;
}
const rawToBlob = HTMLCanvasElement.prototype.toBlob;
if (rawToBlob && !rawToBlob.__wjep_hooked__) {
HTMLCanvasElement.prototype.toBlob = function patchedToBlob(callback, type, quality) {
const width = this.width, height = this.height, callStack = stack();
return rawToBlob.call(this, function(blob) {
store.canvas.toBlob.push({
match: { width, height, type: type || (blob && blob.type) || "image/png" },
result: { type: blob && blob.type || type || "", size: blob && blob.size || 0 },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: callStack
});
return callback && callback.apply(this, arguments);
}, type, quality);
};
HTMLCanvasElement.prototype.toBlob.__wjep_hooked__ = true;
}
}
if (window.CanvasRenderingContext2D) {
const p = CanvasRenderingContext2D.prototype;
const rawMeasureText = p.measureText;
if (rawMeasureText && !rawMeasureText.__wjep_hooked__) {
p.measureText = function patchedMeasureText(text) {
const ret = rawMeasureText.apply(this, arguments);
store.canvas.measureText.push({
match: { text: String(text), font: this.font },
result: {
width: ret.width,
actualBoundingBoxLeft: ret.actualBoundingBoxLeft,
actualBoundingBoxRight: ret.actualBoundingBoxRight,
actualBoundingBoxAscent: ret.actualBoundingBoxAscent,
actualBoundingBoxDescent: ret.actualBoundingBoxDescent,
fontBoundingBoxAscent: ret.fontBoundingBoxAscent,
fontBoundingBoxDescent: ret.fontBoundingBoxDescent
},
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.measureText.__wjep_hooked__ = true;
}
const rawGetImageData = p.getImageData;
if (rawGetImageData && !rawGetImageData.__wjep_hooked__) {
p.getImageData = function patchedGetImageData(sx, sy, sw, sh) {
const ret = rawGetImageData.apply(this, arguments);
store.canvas.getImageData.push({
match: { sx, sy, sw, sh },
result: { width: ret.width, height: ret.height, dataBase64: chunkArrayLikeAsBase64(ret.data) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getImageData.__wjep_hooked__ = true;
}
}
`;
}
function webglSnippet() {
return `
// ===== WebGL 终端 API 采样 =====
for (const Ctor of [window.WebGLRenderingContext, window.WebGL2RenderingContext].filter(Boolean)) {
const p = Ctor.prototype;
if (p.getParameter && !p.getParameter.__wjep_hooked__) {
const raw = p.getParameter;
p.getParameter = function patchedGetParameter(pname) {
const ret = raw.apply(this, arguments);
store.webgl.getParameter.push({
match: { pname },
result: clonePlain(ret, 3),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getParameter.__wjep_hooked__ = true;
}
if (p.getSupportedExtensions && !p.getSupportedExtensions.__wjep_hooked__) {
const raw = p.getSupportedExtensions;
p.getSupportedExtensions = function patchedGetSupportedExtensions() {
const ret = raw.apply(this, arguments);
store.webgl.getSupportedExtensions = {
result: Array.from(ret || []),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
};
return ret;
};
p.getSupportedExtensions.__wjep_hooked__ = true;
}
if (p.getExtension && !p.getExtension.__wjep_hooked__) {
const raw = p.getExtension;
p.getExtension = function patchedGetExtension(name) {
const ret = raw.apply(this, arguments);
store.webgl.getExtension.push({
match: { name: String(name) },
result: ret ? { exists: true, keys: Object.keys(ret).slice(0, 50) } : null,
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getExtension.__wjep_hooked__ = true;
}
if (p.getShaderPrecisionFormat && !p.getShaderPrecisionFormat.__wjep_hooked__) {
const raw = p.getShaderPrecisionFormat;
p.getShaderPrecisionFormat = function patchedGetShaderPrecisionFormat(shaderType, precisionType) {
const ret = raw.apply(this, arguments);
store.webgl.getShaderPrecisionFormat.push({
match: { shaderType, precisionType },
result: ret ? { rangeMin: ret.rangeMin, rangeMax: ret.rangeMax, precision: ret.precision } : null,
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getShaderPrecisionFormat.__wjep_hooked__ = true;
}
if (p.readPixels && !p.readPixels.__wjep_hooked__) {
const raw = p.readPixels;
p.readPixels = function patchedReadPixels(x, y, width, height, format, type, pixels) {
const ret = raw.apply(this, arguments);
store.webgl.readPixels.push({
match: { x, y, width, height, format, type },
result: { dataBase64: pixels ? chunkArrayLikeAsBase64(pixels) : chunkString("", "base64-chunks") },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.readPixels.__wjep_hooked__ = true;
}
}
`;
}
function webgpuSnippet() {
return `
// ===== WebGPU 终端 API 采样 =====
if (navigator.gpu && navigator.gpu.requestAdapter && !navigator.gpu.requestAdapter.__wjep_hooked__) {
const rawRequestAdapter = navigator.gpu.requestAdapter.bind(navigator.gpu);
navigator.gpu.requestAdapter = async function patchedRequestAdapter(options) {
const adapter = await rawRequestAdapter(options);
const info = adapter && adapter.info ? clonePlain(adapter.info, 4) : null;
store.webgpu.requestAdapter.push({
match: { options: clonePlain(options || {}, 4) },
result: {
info,
features: adapter && adapter.features ? Array.from(adapter.features) : [],
limits: adapter && adapter.limits ? clonePlain(adapter.limits, 3) : {}
},
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return adapter;
};
navigator.gpu.requestAdapter.__wjep_hooked__ = true;
}
`;
}
function audioSnippet() {
return `
// ===== Audio 指纹终端 API 采样 =====
if (window.AudioBuffer && AudioBuffer.prototype.getChannelData && !AudioBuffer.prototype.getChannelData.__wjep_hooked__) {
const rawGetChannelData = AudioBuffer.prototype.getChannelData;
AudioBuffer.prototype.getChannelData = function patchedGetChannelData(channel) {
const ret = rawGetChannelData.apply(this, arguments);
try {
store.audio.getChannelData.push({
match: { channel, length: this.length, sampleRate: this.sampleRate, numberOfChannels: this.numberOfChannels },
result: { channelBase64: chunkArrayLikeAsBase64(ret) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
} catch(e) {}
return ret;
};
AudioBuffer.prototype.getChannelData.__wjep_hooked__ = true;
}
if (window.OfflineAudioContext && OfflineAudioContext.prototype.startRendering && !OfflineAudioContext.prototype.startRendering.__wjep_hooked__) {
const rawStartRendering = OfflineAudioContext.prototype.startRendering;
OfflineAudioContext.prototype.startRendering = function patchedStartRendering() {
const callStack = stack();
const ret = rawStartRendering.apply(this, arguments);
if (ret && typeof ret.then === "function") {
return ret.then(buffer => {
try {
const data = buffer.getChannelData(0);
store.audio.startRendering.push({
match: { length: buffer.length, sampleRate: buffer.sampleRate, numberOfChannels: buffer.numberOfChannels },
result: { channel0Base64: chunkArrayLikeAsBase64(data) },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: callStack
});
} catch(e) {}
return buffer;
});
}
return ret;
};
OfflineAudioContext.prototype.startRendering.__wjep_hooked__ = true;
}
`;
}
function domSnippet() {
return `
// ===== DOM 几何 / 字体探测终端 API 采样 =====
if (window.Element) {
const p = Element.prototype;
const rawRect = p.getBoundingClientRect;
if (rawRect && !rawRect.__wjep_hooked__) {
p.getBoundingClientRect = function patchedGetBoundingClientRect() {
const ret = rawRect.apply(this, arguments);
store.domGeometry.getBoundingClientRect.push({
match: { selector: selectorOf(this) },
result: { x: ret.x, y: ret.y, width: ret.width, height: ret.height, top: ret.top, left: ret.left, right: ret.right, bottom: ret.bottom },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getBoundingClientRect.__wjep_hooked__ = true;
}
const rawRects = p.getClientRects;
if (rawRects && !rawRects.__wjep_hooked__) {
p.getClientRects = function patchedGetClientRects() {
const ret = rawRects.apply(this, arguments);
store.domGeometry.getClientRects.push({
match: { selector: selectorOf(this) },
result: Array.from(ret || []).map(r => ({ x: r.x, y: r.y, width: r.width, height: r.height, top: r.top, left: r.left, right: r.right, bottom: r.bottom })),
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return ret;
};
p.getClientRects.__wjep_hooked__ = true;
}
for (const key of ["offsetWidth", "offsetHeight", "scrollWidth", "scrollHeight", "clientWidth", "clientHeight"]) {
const desc = Object.getOwnPropertyDescriptor(HTMLElement.prototype, key) || Object.getOwnPropertyDescriptor(Element.prototype, key);
if (!desc || !desc.get || desc.get.__wjep_hooked__) continue;
Object.defineProperty(HTMLElement.prototype, key, {
get: function patchedGeometryGetter() {
const value = desc.get.call(this);
store.domGeometry.offset.push({
match: { selector: selectorOf(this) },
result: { [key]: value },
source: { capturedBy: "真实浏览器 Hook", tool: "用户选择的取证浏览器", traceStatus: "browser-complete-sampled" },
stack: stack()
});
return value;
},
enumerable: desc.enumerable,
configurable: true
});
try { Object.getOwnPropertyDescriptor(HTMLElement.prototype, key).get.__wjep_hooked__ = true; } catch(e) {}
}
}
`;
}
function build(args) {
const types = new Set(String(args.types || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
const parts = [header(args)];
if (types.has('canvas')) parts.push(canvasSnippet());
if (types.has('webgl')) parts.push(webglSnippet());
if (types.has('webgpu')) parts.push(webgpuSnippet());
if (types.has('audio')) parts.push(audioSnippet());
if (types.has('dom') || types.has('dom-geometry') || types.has('font') || types.has('fonts')) parts.push(domSnippet());
parts.push(footer());
return parts.join('\n');
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const code = build(args);
if (args.out) {
const out = path.resolve(args.out);
fs.mkdirSync(path.dirname(out), { recursive: true });
fs.writeFileSync(out, code, 'utf8');
const compatNote = args.deprecatedMaxDataUrlLength ? '\n- 兼容提醒:--max-data-url-length 已按分片大小处理,没有裁剪任何值。' : '';
console.log(`# 指纹采样 Hook 已生成\n- 输出文件:${out}\n- 类型:${args.types}\n- 分片大小:${args.chunkSize}\n- 提醒:该 Hook 只用于前置取证,不得放入最终 result/ 目录;长指纹值必须 finalize 后完整分片保存。${compatNote}`);
} else {
process.stdout.write(code + '\n');
}
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,253 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { param: 'sign', apiPattern: '/api/', types: 'fetch,xhr,urlsearchparams,cookie,storage,time-random,dynamic-code,websocket,postmessage', out: '' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--param') args.param = argv[++i] || 'sign';
else if (a === '--api-pattern') args.apiPattern = argv[++i] || '/api/';
else if (a === '--types') args.types = argv[++i] || args.types;
else if (a === '--out') args.out = argv[++i] || '';
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
return args;
}
function usage() {
return `用法:
node scripts/generate_hook_templates.js --param sign --api-pattern /api/ --types fetch,xhr,cookie,storage
node scripts/generate_hook_templates.js --param a_bogus --api-pattern /web/api --out case/hooks/hooks.js`;
}
function q(v) { return JSON.stringify(String(v)); }
function section(name, code) { return `\n// ===== ${name} =====\n${code.trim()}\n`; }
function snippets(ctx) {
return {
fetch: section('fetch 请求写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
const apiKeyword = ${q(ctx.apiPattern)};
const rawFetch = window.fetch;
window.fetch = function patchedFetch(input, init) {
const url = typeof input === "string" ? input : (input && input.url) || "";
const body = init && init.body;
if (String(url).includes(apiKeyword) || String(url).includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[fetch 命中]", url);
console.log("input =", input);
console.log("init =", init);
console.trace("fetch 调用栈");
console.groupEnd();
debugger;
}
return rawFetch.apply(this, arguments);
};
})();`),
xhr: section('XMLHttpRequest 请求写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
const apiKeyword = ${q(ctx.apiPattern)};
const rawOpen = XMLHttpRequest.prototype.open;
const rawSend = XMLHttpRequest.prototype.send;
const rawSetHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
this.__reverse_method = method;
this.__reverse_url = String(url || "");
return rawOpen.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function patchedSetRequestHeader(name, value) {
if (String(name).includes(targetParam) || String(value).includes(targetParam)) {
console.group("[XHR Header 命中]", this.__reverse_url);
console.log(name, value);
console.trace("setRequestHeader 调用栈");
console.groupEnd();
debugger;
}
return rawSetHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function patchedSend(body) {
const url = this.__reverse_url || "";
if (url.includes(apiKeyword) || url.includes(targetParam) || String(body || "").includes(targetParam)) {
console.group("[XHR send 命中]", this.__reverse_method, url);
console.log("body =", body);
console.trace("send 调用栈");
console.groupEnd();
debugger;
}
return rawSend.apply(this, arguments);
};
})();`),
urlsearchparams: section('URLSearchParams 与 FormData 参数写入定位', `
(() => {
const targetParam = ${q(ctx.param)};
for (const Ctor of [window.URLSearchParams, window.FormData].filter(Boolean)) {
for (const method of ["append", "set"]) {
const raw = Ctor.prototype[method];
if (typeof raw !== "function") continue;
Ctor.prototype[method] = function patchedParamWrite(name, value) {
if (String(name) === targetParam || String(value).includes(targetParam)) {
console.group("[" + Ctor.name + "." + method + " 命中]", name);
console.log("value =", value);
console.trace("参数写入调用栈");
console.groupEnd();
debugger;
}
return raw.apply(this, arguments);
};
}
}
})();`),
cookie: section('Cookie 读写定位', `
(() => {
const targetParam = ${q(ctx.param)};
const desc = Object.getOwnPropertyDescriptor(Document.prototype, "cookie");
if (!desc || !desc.set || !desc.get) return;
Object.defineProperty(document, "cookie", {
configurable: true,
enumerable: desc.enumerable,
get() {
const value = desc.get.call(document);
if (String(value).includes(targetParam)) console.trace("document.cookie 读取命中");
return value;
},
set(value) {
if (String(value).includes(targetParam)) {
console.group("[cookie 写入命中]");
console.log(value);
console.trace("cookie 写入调用栈");
console.groupEnd();
debugger;
}
return desc.set.call(document, value);
}
});
})();`),
storage: section('Storage 读写定位', `
(() => {
const keys = [${q(ctx.param)}, "token", "device", "fingerprint"];
for (const storageName of ["localStorage", "sessionStorage"]) {
const storage = window[storageName];
if (!storage) continue;
for (const method of ["getItem", "setItem", "removeItem"]) {
const raw = storage[method];
storage[method] = function patchedStorage(key, value) {
if (keys.some(k => String(key).includes(k))) {
console.group("[" + storageName + "." + method + " 命中]", key);
console.log("value =", value);
console.trace("Storage 调用栈");
console.groupEnd();
}
return raw.apply(this, arguments);
};
}
}
})();`),
'time-random': section('时间与随机数定位', `
(() => {
const rawDateNow = Date.now;
Date.now = function patchedDateNow() {
const ret = rawDateNow.apply(this, arguments);
console.log("[Date.now]", ret);
console.trace("Date.now 调用栈");
return ret;
};
const rawRandom = Math.random;
Math.random = function patchedMathRandom() {
const ret = rawRandom.apply(this, arguments);
console.log("[Math.random]", ret);
console.trace("Math.random 调用栈");
return ret;
};
if (window.crypto && crypto.getRandomValues) {
const rawGetRandomValues = crypto.getRandomValues;
crypto.getRandomValues = function patchedGetRandomValues(arr) {
const ret = rawGetRandomValues.apply(this, arguments);
console.log("[crypto.getRandomValues]", Array.from(arr));
console.trace("crypto.getRandomValues 调用栈");
return ret;
};
}
})();`),
'dynamic-code': section('动态代码定位', `
(() => {
const rawEval = window.eval;
window.eval = function patchedEval(code) {
console.log("[eval]", String(code).slice(0, 500));
console.trace("eval 调用栈");
debugger;
return rawEval.apply(this, arguments);
};
const rawFunction = window.Function;
window.Function = new Proxy(rawFunction, {
construct(target, args) {
console.log("[new Function]", args);
console.trace("new Function 调用栈");
debugger;
return Reflect.construct(target, args);
},
apply(target, thisArg, args) {
console.log("[Function]", args);
console.trace("Function 调用栈");
debugger;
return Reflect.apply(target, thisArg, args);
}
});
})();`),
websocket: section('WebSocket 定位', `
(() => {
if (!window.WebSocket) return;
const rawWS = window.WebSocket;
window.WebSocket = new Proxy(rawWS, {
construct(target, args) {
console.log("[WebSocket 创建]", args);
console.trace("WebSocket 调用栈");
return Reflect.construct(target, args);
}
});
})();`),
postmessage: section('postMessage 定位', `
(() => {
const rawPostMessage = window.postMessage;
if (!rawPostMessage) return;
window.postMessage = function patchedPostMessage(message, targetOrigin, transfer) {
console.log("[postMessage]", message, targetOrigin);
console.trace("postMessage 调用栈");
return rawPostMessage.apply(this, arguments);
};
})();`),
};
}
function build(args) {
const map = snippets({ param: args.param, apiPattern: args.apiPattern });
const chosen = args.types.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
const unknown = chosen.filter(t => !map[t]);
if (unknown.length) throw new Error(`未知 Hook 类型:${unknown.join(', ')}`);
return `// Web JS 逆向定位 Hook 模板
// 目标参数:${args.param}
// API 关键字:${args.apiPattern}
// 使用后请及时移除;只用于授权调试和调用栈定位。
${chosen.map(t => map[t]).join('\n')}`;
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const content = build(args);
if (args.out) {
fs.mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
fs.writeFileSync(args.out, content, 'utf8');
console.log(`已写入 Hook 模板:${path.resolve(args.out)}`);
} else {
process.stdout.write(content);
}
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,277 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const readline = require('readline');
const crypto = require('crypto');
function parseArgs(argv) {
const args = {
input: '',
caseDir: '',
name: '',
maxExamples: 10,
truncationThreshold: 3900,
maxTruncationExamples: 50,
json: false,
markdown: false,
};
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--input') args.input = argv[++i] || '';
else if (a === '--case-dir' || a === '--dir') args.caseDir = argv[++i] || '';
else if (a === '--name') args.name = argv[++i] || '';
else if (a === '--max-examples') args.maxExamples = Number(argv[++i] || '10');
else if (a === '--truncation-threshold') args.truncationThreshold = Number(argv[++i] || '3900');
else if (a === '--max-truncation-examples') args.maxTruncationExamples = Number(argv[++i] || '50');
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!Number.isFinite(args.truncationThreshold) || args.truncationThreshold < 1) args.truncationThreshold = 3900;
if (!Number.isFinite(args.maxTruncationExamples) || args.maxTruncationExamples < 1) args.maxTruncationExamples = 50;
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --markdown
node scripts/import_ruyitrace_log.js --input <trace.ndjson> --case-dir case --truncation-threshold 3900 --json
说明:复制 RuyiTrace NDJSON 日志到 case/ruyi-trace/logs/,生成 notes/ruyitrace-summary.md,并标记接近 4000 / 4096 字符的字段为“疑似被 RuyiTrace 截断”。`;
}
function exists(p) {
try { return fs.existsSync(p); } catch { return false; }
}
function safeName(name) {
return String(name || '').replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').slice(0, 180);
}
function inc(map, key) {
key = key || '(空)';
map.set(key, (map.get(key) || 0) + 1);
}
function top(map, n) {
return Array.from(map.entries()).sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]))).slice(0, n).map(([key, count]) => ({ key, count }));
}
function classifyApi(api) {
api = String(api || '');
if (/Canvas|CanvasRenderingContext2D|OffscreenCanvas/.test(api)) return 'canvas';
if (/WebGL|GLRenderingContext/.test(api)) return 'webgl';
if (/Audio|Oscillator|Analyser|OfflineAudioContext/.test(api)) return 'audio';
if (/Navigator|navigator/.test(api)) return 'navigator';
if (/Screen|screen/.test(api)) return 'screen';
if (/Crypto|getRandomValues|randomUUID/.test(api)) return 'crypto';
if (/Performance|performance/.test(api)) return 'performance';
if (/Storage|localStorage|sessionStorage|IndexedDB|IDB/.test(api)) return 'storage';
if (/WebRTC|RTCPeerConnection|MediaDevices/.test(api)) return 'webrtc';
if (/Worker|ServiceWorker|postMessage|MessageChannel/.test(api)) return 'worker-message';
if (/Document|Element|Node|CSS|Style|Layout|DOMRect/.test(api)) return 'dom-layout';
return 'other';
}
function visibleHash(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex');
}
function preview(value, side = 80) {
const text = String(value || '');
if (text.length <= side * 2) return text;
return `${text.slice(0, side)}...${text.slice(-side)}`;
}
function stackBrief(evt) {
const stack = Array.isArray(evt && evt.stack) ? evt.stack : [];
const first = stack.find(s => s && (s.file || s.line || s.col));
if (!first) return '';
const loc = [first.file || '', first.line || '', first.col || ''].filter(v => v !== '').join(':');
return loc;
}
function walkStrings(value, visitor, currentPath = '') {
if (typeof value === 'string') {
visitor(currentPath || '$', value);
return;
}
if (!value || typeof value !== 'object') return;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) walkStrings(value[i], visitor, `${currentPath}[${i}]`);
return;
}
for (const [key, child] of Object.entries(value)) {
const nextPath = currentPath ? `${currentPath}.${key}` : key;
walkStrings(child, visitor, nextPath);
}
}
function collectTruncationSignals(evt, lineNo, threshold, maxExamples, state) {
const api = evt.api || evt.name || evt.path || '';
walkStrings(evt, (fieldPath, value) => {
const visibleLength = value.length;
if (visibleLength < threshold) return;
state.totalSuspectedFields++;
state.maxVisibleLength = Math.max(state.maxVisibleLength, visibleLength);
inc(state.byFieldPath, fieldPath);
inc(state.byApi, api || '(空)');
if (state.examples.length >= maxExamples) return;
state.examples.push({
line: lineNo,
api: api || '(空)',
fieldPath,
visibleLength,
minLength: visibleLength,
actualLength: 'unknown',
truncationSuspected: true,
reason: `字段可见长度达到阈值 ${threshold},RuyiTrace 可能已截断长字符串,不能把可见长度当成真实长度。`,
stack: stackBrief(evt),
visibleSha256: visibleHash(value),
visiblePreview: preview(value),
});
});
}
function sanitizeLongStrings(value, threshold, currentPath = '') {
if (typeof value === 'string') {
if (value.length < threshold) return value;
return {
__ruyiTraceLongString__: true,
fieldPath: currentPath || '$',
visibleLength: value.length,
minLength: value.length,
actualLength: 'unknown',
truncationSuspected: true,
visibleSha256: visibleHash(value),
visiblePreview: preview(value),
note: '该字符串接近或超过 RuyiTrace 截断阈值,示例中不保留完整可见内容,真实长度未知。',
};
}
if (!value || typeof value !== 'object') return value;
if (Array.isArray(value)) return value.map((item, index) => sanitizeLongStrings(item, threshold, `${currentPath}[${index}]`));
const out = {};
for (const [key, child] of Object.entries(value)) {
const nextPath = currentPath ? `${currentPath}.${key}` : key;
out[key] = sanitizeLongStrings(child, threshold, nextPath);
}
return out;
}
async function summarizeNdjson(file, options) {
const apiCounts = new Map();
const typeCounts = new Map();
const categoryCounts = new Map();
const fileCounts = new Map();
const examples = [];
const truncationState = { totalSuspectedFields: 0, maxVisibleLength: 0, byFieldPath: new Map(), byApi: new Map(), examples: [] };
let lines = 0, parsed = 0, invalid = 0;
const rl = readline.createInterface({ input: fs.createReadStream(file, { encoding: 'utf8' }), crlfDelay: Infinity });
for await (const raw of rl) {
const line = raw.replace(/^\uFEFF/, '').trim();
if (!line) continue;
lines++;
let evt;
try { evt = JSON.parse(line); parsed++; } catch { invalid++; continue; }
const api = evt.api || evt.name || evt.path || '';
inc(apiCounts, api);
inc(typeCounts, evt.t || evt.type || '');
inc(categoryCounts, classifyApi(api));
const stack = Array.isArray(evt.stack) ? evt.stack : [];
for (const s of stack) if (s && s.file) inc(fileCounts, s.file);
collectTruncationSignals(evt, lines, options.truncationThreshold, options.maxTruncationExamples, truncationState);
if (examples.length < options.maxExamples) examples.push(sanitizeLongStrings(evt, options.truncationThreshold));
}
return {
lines,
parsed,
invalid,
topApis: top(apiCounts, 30),
topTypes: top(typeCounts, 20),
topCategories: top(categoryCounts, 20),
topStackFiles: top(fileCounts, 30),
truncation: {
threshold: options.truncationThreshold,
totalSuspectedFields: truncationState.totalSuspectedFields,
maxVisibleLength: truncationState.maxVisibleLength,
topFieldPaths: top(truncationState.byFieldPath, 20),
topApis: top(truncationState.byApi, 20),
examples: truncationState.examples,
rule: '可见长度达到阈值的字符串一律按疑似截断处理;Canvas / WebGL / WebGPU / Audio 等长指纹值不得使用日志可见片段作为最终值,真实长度为 unknown,只能确认至少达到可见长度。',
},
examples,
};
}
function renderMarkdown(result) {
const lines = ['# RuyiTrace 日志导入摘要', '', `- 原始日志:${result.input}`, `- 复制后日志:${result.copiedTo}`, `- 行数:${result.summary.lines}`, `- 成功解析:${result.summary.parsed}`, `- 解析失败:${result.summary.invalid}`];
lines.push('', '## API 类别统计');
for (const item of result.summary.topCategories) lines.push(`- ${item.key}:${item.count}`);
lines.push('', '## 高频 API');
for (const item of result.summary.topApis.slice(0, 20)) lines.push(`- ${item.key}:${item.count}`);
lines.push('', '## 高频调用栈文件');
if (!result.summary.topStackFiles.length) lines.push('- 未发现 stack.file');
for (const item of result.summary.topStackFiles.slice(0, 20)) lines.push(`- ${item.key}:${item.count}`);
const truncation = result.summary.truncation;
lines.push('', '## 长字段截断风险');
lines.push(`- 检测阈值:${truncation.threshold}`);
lines.push(`- 疑似截断字段数:${truncation.totalSuspectedFields}`);
lines.push(`- 最大可见长度:${truncation.maxVisibleLength}`);
lines.push('- 规则:达到或接近阈值的字符串只能说明“至少达到该可见长度”,真实长度记为 unknown,不能把 4000 或可见长度当成真实长度。');
if (!truncation.examples.length) {
lines.push('- 未发现达到阈值的长字符串字段。');
} else {
lines.push('', '| 行号 | API | 字段路径 | 可见长度 | 真实长度判断 | 调用栈 | 可见值 SHA256 |');
lines.push('|---|---|---|---|---|---|---|');
for (const item of truncation.examples.slice(0, 20)) {
lines.push(`| ${item.line} | ${item.api} | ${item.fieldPath} | ${item.visibleLength} | unknown,疑似被 RuyiTrace 截断 | ${item.stack || '未记录'} | ${item.visibleSha256} |`);
}
lines.push('', '### 长字段补采要求');
lines.push('- 不要根据 RuyiTrace 中的 4000 / 4096 字符可见值判断完整加密参数、长 token、长 Cookie、长 body、Canvas dataURL、WebGL readPixels、WebGPU adapter 信息或 Audio channel data 的真实长度。');
lines.push('- 如果该字段影响签名、补环境验证或指纹回放,必须通过 HAR/cURL、ruyiPage / Camoufox / CloakBrowser / 手动浏览器采样、专用 Hook 分片落盘、或最终 Node.js signer 输出重新确认完整值。');
lines.push('- 写入 `notes/missing-env-priority.md` 时标明 `actualLength: unknown`、`minLength: 可见长度`、`truncationSuspected: true`。');
}
lines.push('', '## 建议下一步');
lines.push('- 将高频 API 映射到 `env-module-levels.md` 的 Level 1/2/3 环境模块。');
lines.push('- 结合 stack.file / line / col 更新 `notes/entry-chain.md` 和 `notes/missing-env-priority.md`。');
lines.push('- 对长字段优先补采完整值或记录 hash / 长度 / 前后片段,避免把 RuyiTrace 的截断值误当作完整值。');
lines.push('- 仅把摘要写入最终报告,原始 NDJSON 作为本地证据文件保存或由用户确认删除。');
return lines.join('\n') + '\n';
}
async function main() {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); return; }
if (!args.input) throw new Error('必须提供 --input');
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const input = path.resolve(args.input);
const caseDir = path.resolve(args.caseDir);
if (!exists(input)) throw new Error(`日志文件不存在:${input}`);
if (!exists(caseDir)) fs.mkdirSync(caseDir, { recursive: true });
const logDir = path.join(caseDir, 'ruyi-trace', 'logs');
const notesDir = path.join(caseDir, 'notes');
fs.mkdirSync(logDir, { recursive: true });
fs.mkdirSync(notesDir, { recursive: true });
const dstName = safeName(args.name || path.basename(input) || `trace-${Date.now()}.ndjson`);
const copiedTo = path.join(logDir, dstName.endsWith('.ndjson') ? dstName : `${dstName}.ndjson`);
fs.copyFileSync(input, copiedTo);
const summary = await summarizeNdjson(copiedTo, args);
const result = { input, copiedTo, summary };
const md = renderMarkdown(result);
fs.writeFileSync(path.join(notesDir, 'ruyitrace-summary.md'), md, 'utf8');
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(md);
}
main().catch(err => {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
});

View File

@@ -0,0 +1,362 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
function parseArgs(argv) {
const args = { caseDir: '', target: '', entry: '', param: '', api: '', force: false, json: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--case-dir' || a === '--dir' || a === '-d') args.caseDir = argv[++i] || '';
else if (a === '--target') args.target = argv[++i] || '';
else if (a === '--entry') args.entry = argv[++i] || '';
else if (a === '--param') args.param = argv[++i] || '';
else if (a === '--api') args.api = argv[++i] || '';
else if (a === '--force') args.force = true;
else if (a === '--json') args.json = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
return args;
}
function usage() {
return `用法:
node scripts/init_env_case.js --case-dir case --target app.js --entry window.makeSign --param sign --api https://example.com/api/search
说明:已有文件默认不覆盖;如需覆盖模板文件,请显式添加 --force。`;
}
function isDangerousDir(p) {
const resolved = path.resolve(p);
const root = path.parse(resolved).root;
return resolved === root || resolved.length <= root.length + 2;
}
function ensureDir(p, actions) {
if (!fs.existsSync(p)) {
fs.mkdirSync(p, { recursive: true });
actions.push({ action: 'create-dir', path: p });
} else {
actions.push({ action: 'keep-dir', path: p });
}
}
function writeFileIfNeeded(p, content, force, actions) {
if (fs.existsSync(p) && !force) {
actions.push({ action: 'keep-file', path: p });
return;
}
fs.mkdirSync(path.dirname(p), { recursive: true });
const existed = fs.existsSync(p);
fs.writeFileSync(p, content, 'utf8');
actions.push({ action: existed ? 'write-file' : 'create-file', path: p });
}
function initCase(args) {
if (!args.caseDir) throw new Error('必须提供 --case-dir');
const caseDir = path.resolve(args.caseDir);
if (isDangerousDir(caseDir)) throw new Error(`拒绝在危险目录中初始化:${caseDir}`);
const actions = [];
const dirs = ['js/original', 'js/pretty', 'js/extracted', 'requests', 'fixtures', 'notes', 'hooks', 'env', 'ruyi-trace/logs', 'browser/ruyipage', 'result', 'tmp'];
ensureDir(caseDir, actions);
for (const d of dirs) ensureDir(path.join(caseDir, d), actions);
const fixture = {
name: 'sample-001',
pageUrl: '',
apiUrl: args.api || '',
method: '',
param: args.param || '',
paramLocation: '',
browser: {
userAgent: '',
language: 'zh-CN',
timezone: 'Asia/Shanghai',
screen: { width: 1920, height: 1080 },
},
request: { query: {}, headers: {}, body: {}, cookies: {}, localStorage: {}, sessionStorage: {} },
runtime: { now: null, performanceNow: null, randomBytes: [] },
fingerprint: {
source: { mode: '', pageUrl: '', userAgent: '', timezone: '', locale: '', capturedAt: '' },
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] },
},
entry: args.entry || '',
args: [],
expected: args.param ? { [args.param]: '' } : {},
};
writeFileIfNeeded(path.join(caseDir, 'fixtures', 'sample.fixture.json'), JSON.stringify(fixture, null, 2) + '\n', args.force, actions);
const fingerprintFixture = {
version: 1,
source: { mode: '', pageUrl: '', userAgent: '', timezone: '', locale: '', capturedAt: '' },
canvas: { toDataURL: [], toBlob: [], measureText: [], getImageData: [] },
webgl: { getParameter: [], getSupportedExtensions: null, getExtension: [], getShaderPrecisionFormat: [], readPixels: [] },
webgpu: { requestAdapter: [] },
audio: { startRendering: [], getChannelData: [] },
domGeometry: { getBoundingClientRect: [], getClientRects: [], offset: [] },
};
writeFileIfNeeded(path.join(caseDir, 'fixtures', 'fingerprint.fixture.json'), JSON.stringify(fingerprintFixture, null, 2) + '\n', args.force, actions);
const notes = `# Node.js 补环境任务笔记
## 基本信息
- 目标 API:${args.api || ''}
- 目标 JS:${args.target || ''}
- 入口函数:${args.entry || ''}
- 目标参数:${args.param || ''}
## 前置材料检查
- [ ] 请求样本已确认
- [ ] 已列出请求样本中的全部可疑加密参数
- [ ] 用户已确认本次要分析哪些加密参数
- [ ] 加密参数已在请求中确认
- [ ] 已输出 source / entry / builder / writer 四层链路
- [ ] 目标 JS 文件已保存
- [ ] 加密入口已定位
- [ ] 浏览器取证模式已由用户选择:ruyiPage + RuyiTrace / ruyiPage / CloakBrowser / 手动取证 / AI 自行决定
- [ ] 如选择 RuyiTrace,NDJSON 日志已导入或确认不需要
- [ ] Node 泄露阻断已检查
- [ ] 补环境初始化阶段已加载 / 检测 addon,并记录可用性、导出 API 或降级原因
- [ ] 默认启用 addon-first;如用户明确要求不使用 addon,已记录豁免原因
- [ ] 默认启用 toString / 属性描述符 / 原型链 / 访问器 / 实例对象保护;如用户明确要求关闭,已记录原因
- [ ] 已读取 code-style.md,并先规划 result 目录、文件职责和调用关系
- [ ] 补环境代码默认要求简洁、模块化、具名函数清晰,并写入中文注释
- [ ] 中文注释使用 UTF-8 正常显示,没有问号、连续问号或乱码
- [ ] 已创建 / 读取 notes/代码变更记忆.md;修改关键源码前已搜索历史记录,修改后已记录失败尝试、禁止回退、验证范围和遗留风险
- [ ] 六项纯计算预检已执行或明确不需要
- [ ] 如目标访问 Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹,已采集真实浏览器终端 API 返回值并写入 fixtures/fingerprint.fixture.json
- [ ] 用户已确认进入补环境阶段
## 缺失环境追踪
- trace 文件:tmp/env-trace.jsonl
- 缺失环境摘要:tmp/missing-env.json
- trace 分析:notes/missing-env-priority.md
## 可疑加密参数确认
| 参数名 | 位置 | 样本值摘要 | 疑似原因 | 用户是否确认分析 |
|---|---|---|---|---|
> cURL / HAR 中已有的加密参数值只能作为 fixture expected,最终产物必须通过补环境重新生成,不能直接复用。
## 补齐记录
| 轮次 | 缺失环境 | 处理方式 | 验证结果 |
|---|---|---|---|
## 通用代码变更记忆
- 记录文件:notes/代码变更记忆.md
- [ ] 修改关键源码前已读取该文件
- [ ] 修改关键源码后已追加本轮变更记录
- [ ] 已记录修改前逻辑、问题证据、本次修改、修改理由、已失败尝试、禁止回退、验证命令、验证结果、当前验证范围、遗留风险和当前状态
- [ ] 已运行 scripts/check_change_memory.js --case-dir case --markdown
## 代码质量
- [ ] 已运行 scripts/check_code_quality.js --case-dir case --markdown
- [ ] 已修复超长行、超长函数、压缩堆叠代码和过深嵌套
- [ ] 已确认每个手写源码文件顶部都有中文职责注释
- [ ] 已确认关键 WebAPI、getter / setter、addon-first、fallback、指纹回放和加密入口有中文说明
- [ ] 已确认中文注释没有问号、连续问号或乱码
## 指纹值回放
- [ ] 已确认哪些指纹终端 API 被目标 JS 访问
- [ ] 已使用真实浏览器采样值回放,而不是在 Node.js 中真实模拟渲染
- [ ] 未把浏览器自动化代码放入最终 result 目录
- [ ] 缺失指纹样本时已阻塞并提示补采样,没有静默伪造默认值
## 最终请求验证
- [ ] fixtures 已通过
- [ ] 用户已确认是否发起真实请求
- [ ] 如普通客户端疑似 TLS 指纹不一致,已确认是否使用 CycleTLS / curl_cffi / cffi_curl / cyCronet
- [ ] 请求次数、状态码、响应关键字段已记录
- [ ] 临时响应和敏感请求副本已清理
## 最终总结
- [ ] 已从前置阶段开始生成 case/阶段报告/01-需求信息确认.md 等中文命名阶段报告
- [ ] 项目完成后已默认生成 result/最终项目总结.md
- [ ] 最终总结使用 UTF-8 写入,中文正常显示
- [ ] 总结包含 native addon / NativeProtect 使用情况、加密参数生成与样本复用检查、最终交付结构、测试结果和清理结果
- [ ] 如用户明确要求不生成最终总结,已记录原因
## 清理检查
- [ ] 已删除临时 trace / 日志
- [ ] 已删除失败 runner
- [ ] 已保留必要 fixtures 和 notes
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'env-task.md'), notes, args.force, actions);
const codeChangeMemory = `# 代码变更记忆
本文件由通用代码变更记忆机制维护,用于记录复杂 case 中关键源码修改的事实、原因、失败尝试、禁止回退、验证范围和遗留风险。不要把“当前报错消失”直接写成永久结论。
## 使用规则
- 修改关键源码前先读取本文件,并搜索文件名、函数名、错误关键词和参数名。
- 修改关键源码后立即追加一条“变更”记录。
- 不删除失败记录;如历史记录被新证据替代,新增记录说明替代原因。
- 状态只能使用:临时修复 / 当前验证通过 / 已失败 / 被替代 / 稳定基线。
## 变更 001 - 待填写
- 时间:YYYY-MM-DD HH:mm
- 涉及文件:
- 涉及函数 / 模块:
- 当前状态:临时修复
### 修改前逻辑
待填写。
### 问题证据
待填写。
### 本次修改
待填写。
### 修改理由
待填写。
### 已失败尝试
待填写。
### 禁止回退
待填写。
### 验证命令
\`\`\`bash
# 待填写
\`\`\`
### 验证结果
待填写。
### 当前验证范围
待填写。
### 遗留风险
待填写。
`;
writeFileIfNeeded(path.join(caseDir, 'notes', '代码变更记忆.md'), codeChangeMemory, args.force, actions);
const entryChain = `# 加密入口链路记录
## writer 最终写入
- 类型:fetch / XHR / URLSearchParams / Header / Cookie / Body
- 文件与行列号:
- 调用栈:
- 写入前对象快照:
- 写入后对象快照:
## builder 请求构造
- 函数 / 方法:
- 所在模块:
- 输入:
- 输出:
- 是否经过拦截器:
## entry 加密入口
- 函数名 / 表达式:${args.entry || ''}
- 文件 / chunk / module id:${args.target || ''}
- 行列号:
- 入参:
- 返回:
- 是否需要初始化:
## source 数据源
- URL / Query:
- Body:
- Cookie:
- localStorage / sessionStorage:
- 时间 / 随机数:
- navigator / screen / document / canvas / WebGL:
- Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何指纹 fixture:
- 其他:
## 可信度
- 证据来源:
- 是否可进入补环境:否
- 阻塞点:
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'entry-chain.md'), entryChain, args.force, actions);
const silentChecklist = `# 静默失败排查清单
当目标 JS 不报错但 ${args.param || '目标参数'} 不一致时逐项检查。
- [ ] 请求 URL、Query、Body、Header 与浏览器样本一致
- [ ] SDK 初始化参数、版本、appId、页面配置一致
- [ ] Cookie、localStorage、sessionStorage 已按 fixture 注入
- [ ] Date.now / new Date / performance.now 可复现
- [ ] Math.random / crypto.getRandomValues 可复现
- [ ] Canvas / WebGL / WebGPU / Audio / 字体 / DOM 几何等指纹终端 API 返回值来自真实浏览器采样并可回放
- [ ] navigator / screen / timezone / language 与浏览器样本一致
- [ ] JS 加载顺序、runtime chunk、动态 chunk 完整
- [ ] Function.prototype.toString / Object.prototype.toString 已处理
- [ ] 属性描述符与原型链已记录
- [ ] document.all 等特殊对象已处理或确认未触发
- [ ] Worker / WASM / postMessage 已排查
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'silent-failure-checklist.md'), silentChecklist, args.force, actions);
const trustMatrix = `# 证据可信度矩阵
| 项目 | 当前值 / 结论 | 来源 | 等级 A/B/C/D | 待验证 |
|---|---|---|---|---|
| 目标 API | ${args.api || ''} | 用户输入 | B | |
| 目标参数 | ${args.param || ''} | 用户输入 | B | |
| 加密入口 | ${args.entry || ''} | 用户输入 / 待验证 | C | |
| JS 文件 | ${args.target || ''} | 用户输入 / 待验证 | C | |
| Cookie / token | 仅记录键名和脱敏摘要 | fixture | A/B | |
`;
writeFileIfNeeded(path.join(caseDir, 'notes', 'trust-matrix.md'), trustMatrix, args.force, actions);
return { caseDir, actions };
}
function render(result) {
const label = { 'create-dir': '创建目录', 'keep-dir': '保留目录', 'create-file': '创建文件', 'write-file': '写入文件', 'keep-file': '保留已有文件' };
const lines = ['# 补环境 case 初始化结果', '', `- case 目录:${result.caseDir}`];
for (const a of result.actions) lines.push(`- ${label[a.action] || a.action}:${a.path}`);
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = initCase(args);
if (args.json) console.log(JSON.stringify(result, null, 2));
else process.stdout.write(render(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,187 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
function parseArgs(argv) {
const args = { python: 'python', installDir: '', install: false, installPackage: false, json: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--python') args.python = argv[++i] || 'python';
else if (a === '--install-dir') args.installDir = argv[++i] || '';
else if (a === '--install') args.install = true;
else if (a === '--install-package') args.installPackage = true;
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --markdown
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --install --markdown
node scripts/install_ruyipage_runtime.js --python python --install-dir <ruyipage-browsers-dir> --install-package --install --markdown
说明:默认只输出安装计划,不执行下载 / 安装。只有用户明确确认并提供安装目录后,才添加 --install。
注意:--install-package 会修改当前 Python 环境,必须先获得用户确认。`;
}
function run(cmd, args, timeout = 120000) {
const ret = spawnSync(cmd, args, { encoding: 'utf8', timeout, windowsHide: true });
return {
ok: ret.status === 0,
status: ret.status,
stdout: (ret.stdout || '').trim(),
stderr: (ret.stderr || '').trim(),
error: ret.error ? ret.error.message : '',
command: [cmd].concat(args.map(quoteIfNeeded)).join(' '),
};
}
function quoteIfNeeded(s) {
s = String(s);
return /\s/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s;
}
function exists(p) {
try { return !!p && fs.existsSync(p); } catch { return false; }
}
function checkPackage(python) {
const code = 'import ruyipage, json; print(json.dumps({"ok": True, "version": getattr(ruyipage, "__version__", "")}, ensure_ascii=False))';
const ret = run(python, ['-c', code], 20000);
if (!ret.ok) return { ok: false, version: '', error: ret.stderr || ret.error || ret.stdout };
try {
const parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, ''));
return { ok: true, version: parsed.version || '', error: '' };
} catch (err) {
return { ok: false, version: '', error: err.message || String(err) };
}
}
function assertInstallDir(dir) {
if (!dir) throw new Error('必须提供 --install-dir;不要在未确认目录时安装 ruyiPage 定制 Firefox runtime。');
const resolved = path.resolve(dir);
const parsed = path.parse(resolved);
if (resolved === parsed.root) throw new Error('安装目录不能是磁盘根目录。请提供专用目录,例如 <工作目录>/tools/ruyipage-browsers。');
return resolved;
}
function verifyAfterInstall(python, installDir) {
const script = path.join(__dirname, 'check_external_tools.js');
const ret = run(process.execPath, [script, '--python', python, '--ruyipage-install-dir', installDir, '--json'], 60000);
let parsed = null;
try { parsed = JSON.parse(ret.stdout.replace(/^\uFEFF/, '')); } catch { parsed = null; }
return { command: ret.command, ok: ret.ok, stdout: ret.stdout, stderr: ret.stderr || ret.error, parsed };
}
function commandPlan(args, installDir, pkg) {
const commands = [];
if (!pkg.ok) commands.push(`${args.python} -m pip install ruyiPage requests --upgrade`);
else commands.push(`${args.python} -m pip install requests --upgrade`);
commands.push(`${args.python} -m ruyipage install --install-dir "${installDir}"`);
commands.push(`node scripts/check_external_tools.js --python ${args.python} --ruyipage-install-dir "${installDir}" --markdown`);
return commands;
}
function main() {
const args = parseArgs(process.argv);
if (args.help) return { help: usage() };
const installDir = assertInstallDir(args.installDir);
const pkgBefore = checkPackage(args.python);
const result = {
dryRun: !args.install,
python: args.python,
installDir,
packageBefore: pkgBefore,
packageInstallAttempted: false,
runtimeInstallAttempted: false,
commands: commandPlan(args, installDir, pkgBefore),
steps: [],
verify: null,
success: false,
};
if (!args.install) {
result.steps.push('当前为安装计划,不执行下载或安装。请用户确认后再添加 --install。');
result.success = true;
return result;
}
fs.mkdirSync(installDir, { recursive: true });
let pkg = pkgBefore;
if (!pkg.ok && args.installPackage) {
result.packageInstallAttempted = true;
const pip = run(args.python, ['-m', 'pip', 'install', 'ruyiPage', 'requests', '--upgrade'], 180000);
result.steps.push(`安装 ruyiPage Python 包与 requests 依赖:${pip.ok ? '成功' : '失败'}\n${pip.stdout || pip.stderr || pip.error}`.trim());
pkg = checkPackage(args.python);
} else if (pkg.ok && args.installPackage) {
result.packageInstallAttempted = true;
const pip = run(args.python, ['-m', 'pip', 'install', 'requests', '--upgrade'], 180000);
result.steps.push(`安装 / 更新 smart_fingerprint requests 依赖:${pip.ok ? '成功' : '失败'}\n${pip.stdout || pip.stderr || pip.error}`.trim());
}
if (!pkg.ok) {
result.steps.push('未安装 ruyiPage Python 包,已停止 runtime 安装。若用户确认允许修改 Python 环境,请重新添加 --install-package。');
result.verify = verifyAfterInstall(args.python, installDir);
result.success = false;
return result;
}
result.runtimeInstallAttempted = true;
const install = run(args.python, ['-m', 'ruyipage', 'install', '--install-dir', installDir], 900000);
result.steps.push(`安装 ruyiPage 定制 Firefox runtime:${install.ok ? '成功' : '失败'}\n${install.stdout || install.stderr || install.error}`.trim());
result.verify = verifyAfterInstall(args.python, installDir);
result.success = !!(result.verify && result.verify.parsed && result.verify.parsed.ruyiPage && result.verify.parsed.ruyiPage.managedRuntimeVerified);
return result;
}
function renderMarkdown(result) {
if (result.help) return result.help + '\n';
const lines = ['# ruyiPage 定制 Firefox runtime 安装流程', ''];
lines.push(`- dry-run:${result.dryRun ? '是' : '否'}`);
lines.push(`- Python:${result.python}`);
lines.push(`- 安装目录:${result.installDir}`);
lines.push(`- ruyiPage Python 包:${result.packageBefore.ok ? '已安装' : '未检测到'}`);
if (result.packageBefore.version) lines.push(`- ruyiPage 版本:${result.packageBefore.version}`);
lines.push('', '## 将执行 / 已执行的命令');
for (const cmd of result.commands) lines.push('- `' + cmd + '`');
if (result.steps.length) {
lines.push('', '## 执行记录');
for (const step of result.steps) lines.push(`- ${step.replace(/\n/g, '\n ')}`);
}
if (result.verify) {
lines.push('', '## 安装后验证');
lines.push('- 验证命令:`' + result.verify.command + '`');
lines.push(`- 验证命令是否成功:${result.verify.ok ? '是' : '否'}`);
const rp = result.verify.parsed && result.verify.parsed.ruyiPage;
if (rp) {
lines.push(`- 定制 Firefox runtime 是否通过验证:${rp.managedRuntimeVerified ? '是' : '否'}`);
if (rp.runtimeExecutable) lines.push(`- 已验证 Firefox:${rp.runtimeExecutable}`);
if (rp.conclusion) lines.push(`- 结论:${rp.conclusion}`);
} else if (result.verify.stderr) {
lines.push(`- 验证错误:${result.verify.stderr}`);
}
}
lines.push('', `## 结果:${result.success ? '通过' : '未通过 / 待用户确认'}`);
if (result.dryRun) lines.push('- 当前仅输出计划;若用户确认未安装并提供该目录,再运行带 --install 的命令。');
return lines.join('\n') + '\n';
}
try {
const args = parseArgs(process.argv);
const result = args.help ? { help: usage() } : main();
if (args.json) console.log(JSON.stringify(result, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(result));
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}

View File

@@ -0,0 +1,196 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ADDON_COMPATIBLE_NODE_VERSION = '25.8.2';
const REQUIRED_EXPORTS = [
'createNativeObject',
'createNativeFunction',
'createProtoChains',
'getProtoChainRegistry',
'deleteProtoChainRegistryEntry',
'clearProtoChainRegistry',
'getPrivate',
'setPrivate',
'hasPrivate',
'deletePrivate',
'createInterceptor',
'createNativeCollection',
'getMimeTypesAndPlugins',
'createGetter',
'createSetter',
'createUndetectable',
'throwTypeError',
];
function parseArgs(argv) {
const args = { addon: '', json: false, strict: false, markdown: false };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--addon' || a === '--path') args.addon = argv[++i] || '';
else if (a === '--json') args.json = true;
else if (a === '--markdown') args.markdown = true;
else if (a === '--strict') args.strict = true;
else if (a === '--help' || a === '-h') args.help = true;
else throw new Error(`未知参数:${a}`);
}
if (!args.json && !args.markdown) args.markdown = true;
return args;
}
function usage() {
return `用法:
node scripts/load_native_addon.js --json
node scripts/load_native_addon.js --addon <path-to-addon.node> --json
说明:不指定 --addon 时,会尝试从当前 Skill 的 assets/native-addon/<platform>-<arch>/addon.node 加载;也可用 WEB_JS_ENV_PATCHER_ADDON 显式覆盖。`;
}
function exists(p) {
try { fs.accessSync(p); return true; } catch { return false; }
}
function resolvePortablePath(input, skillDir) {
if (!input) return '';
if (path.isAbsolute(input)) return path.resolve(input);
const cwdPath = path.resolve(input);
if (exists(cwdPath)) return cwdPath;
return path.resolve(skillDir, input);
}
function candidatePaths(explicitPath) {
const out = [];
const skillDir = path.resolve(__dirname, '..');
if (explicitPath) out.push(resolvePortablePath(explicitPath, skillDir));
if (process.env.WEB_JS_ENV_PATCHER_ADDON) out.push(resolvePortablePath(process.env.WEB_JS_ENV_PATCHER_ADDON, skillDir));
const platformArch = `${process.platform}-${process.arch}`;
out.push(path.join(skillDir, 'assets', 'native-addon', platformArch, 'addon.node'));
out.push(path.join(skillDir, 'assets', 'native-addon', platformArch, `addon-${platformArch}.node`));
out.push(path.join(skillDir, 'assets', 'native-addon', 'addon.node'));
return [...new Set(out)];
}
function isAbiMismatchMessage(message) {
return /NODE_MODULE_VERSION|different Node\.js version|compiled against|Module did not self-register|The module was compiled/i.test(String(message || ''));
}
function loadNativeAddon(options = {}) {
const paths = candidatePaths(options.addon || options.path || '');
const attempts = [];
for (const p of paths) {
if (!exists(p)) {
attempts.push({ path: p, ok: false, reason: '文件不存在' });
continue;
}
try {
const addon = require(p);
const exports = Object.keys(addon).sort();
const missingExports = REQUIRED_EXPORTS.filter(k => !(k in addon));
return {
available: true,
addon,
path: p,
exports,
missingExports,
platform: process.platform,
arch: process.arch,
node: process.version,
modules: process.versions.modules,
abiMismatch: false,
compatibleNodeVersion: ADDON_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: false,
attempts,
};
} catch (err) {
const reason = err && err.message ? err.message : String(err);
attempts.push({ path: p, ok: false, reason, abiMismatch: isAbiMismatchMessage(reason) });
}
}
const abiMismatch = attempts.some(a => a.abiMismatch || isAbiMismatchMessage(a.reason));
return {
available: false,
addon: null,
path: '',
exports: [],
missingExports: REQUIRED_EXPORTS.slice(),
platform: process.platform,
arch: process.arch,
node: process.version,
modules: process.versions.modules,
abiMismatch,
compatibleNodeVersion: ADDON_COMPATIBLE_NODE_VERSION,
requiresUserConfirmation: abiMismatch,
attempts,
reason: abiMismatch
? `addon.node 与当前 Node ABI 不兼容;兼容 Node.js 版本为 v${ADDON_COMPATIBLE_NODE_VERSION}`
: '未找到可加载的 addon.node,或当前平台缺少随包产物',
recovery: abiMismatch ? {
rule: '不得直接降级;先询问用户是否通过 nvm 安装 / 切换兼容 Node。用户拒绝后才允许 NativeProtect / JS fallback。',
commands: [
`nvm install ${ADDON_COMPATIBLE_NODE_VERSION}`,
`nvm use ${ADDON_COMPATIBLE_NODE_VERSION}`,
'node -v',
'node -p "process.versions.modules"',
'node scripts/load_native_addon.js --json',
],
} : null,
};
}
function publicResult(result) {
const { addon, ...rest } = result;
return rest;
}
function renderMarkdown(result) {
const lines = [];
lines.push('# native addon 加载检查');
lines.push('');
lines.push(`- 是否可用:${result.available ? '是' : '否'}`);
lines.push(`- 平台:${result.platform}-${result.arch}`);
lines.push(`- Node.js:${result.node}`);
lines.push(`- Node ABI:${result.modules}`);
if (result.path) lines.push(`- 加载路径:${result.path}`);
if (result.reason) lines.push(`- 原因:${result.reason}`);
lines.push(`- addon 兼容 Node:v${result.compatibleNodeVersion || ADDON_COMPATIBLE_NODE_VERSION}`);
if (result.abiMismatch) lines.push('- ABI 不兼容:是,必须先征得用户同意是否通过 nvm 安装 / 切换兼容 Node,不得直接降级');
if (result.exports && result.exports.length) lines.push(`- 导出 API:${result.exports.join(', ')}`);
if (result.missingExports && result.missingExports.length) lines.push(`- 缺少预期 API:${result.missingExports.join(', ')}`);
if (result.attempts && result.attempts.length) {
lines.push('');
lines.push('## 尝试记录');
for (const a of result.attempts) lines.push(`- ${a.path}:${a.reason}${a.abiMismatch ? '(疑似 ABI 不兼容)' : ''}`);
}
if (result.recovery && result.recovery.commands) {
lines.push('');
lines.push('## ABI 恢复流程');
lines.push('');
lines.push(result.recovery.rule);
lines.push('');
lines.push('```bash');
for (const cmd of result.recovery.commands) lines.push(cmd);
lines.push('```');
}
return lines.join('\n') + '\n';
}
if (require.main === module) {
try {
const args = parseArgs(process.argv);
if (args.help) { console.log(usage()); process.exit(0); }
const result = loadNativeAddon({ addon: args.addon });
const output = publicResult(result);
if (args.json) console.log(JSON.stringify(output, null, 2));
if (args.markdown) process.stdout.write(renderMarkdown(output));
if (args.strict && !result.available) process.exit(2);
} catch (err) {
console.error(err.message || String(err));
console.error(usage());
process.exit(1);
}
}
module.exports = { loadNativeAddon, REQUIRED_EXPORTS, ADDON_COMPATIBLE_NODE_VERSION, isAbiMismatchMessage };

Some files were not shown because too many files have changed in this diff Show More