reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
21
vendor-new/xbsReverseSkill/LICENSE
Normal file
21
vendor-new/xbsReverseSkill/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Daniel lwj
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
82
vendor-new/xbsReverseSkill/README.md
Normal file
82
vendor-new/xbsReverseSkill/README.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# xbsReverseSkill
|
||||
|
||||
这是一个面向 Web/JS 逆向分析的 skill 仓库,当前主要包含 3 个方向的能力模块,分别覆盖 AST 反混淆、纯算/协议逆向,以及浏览器补环境。
|
||||
|
||||
## 注意
|
||||
使用最新web-js-env-patcher补环境skill,使用前关闭其他3个skill防止冲突
|
||||
|
||||
## 使用
|
||||
1. codex安装
|
||||
将ast-deobfuscation、web-reverse-algorithm和web-reverse-env三个文件夹复制到`C:\Users\用户名\.codex\skills`目录下即可使用
|
||||
2. claude cli使用
|
||||
将ast-deobfuscation、web-reverse-algorithm和web-reverse-env三个文件夹复制到`C:\Users\用户名\.claude\skills`目录下即可使用,没有skills目录手动创建一个即可,创建完成之后重新打开claude cli并输入/skills来验证是否已经识别到
|
||||
|
||||
## Claude Cli安装教程
|
||||
|
||||
Node和git先安装(node -v 和 git -v 查看是否安装成功)
|
||||
|
||||
1. npm install -g @anthropic-ai/claude-code
|
||||
2. 配置用户环境变量CLAUDE_CODE_GIT_BASH_PATH, 值为C:\Program Files\Git\bin\bash.exe (此处为你实际bash.exe文件位置,需要自行查找)
|
||||
3. 打开cmd,输入claude即可使用
|
||||
4. 如果是第三方中转接口, cmd中运行以下命令配置环境变量ANTHROPIC_AUTH_TOKEN和ANTHROPIC_BASE_URL
|
||||
```
|
||||
setx ANTHROPIC_AUTH_TOKEN "第三方接口token(sk开头的那个)"
|
||||
setx ANTHROPIC_BASE_URL "第三方接口地址"
|
||||
```
|
||||
|
||||
## Skill 简介
|
||||
|
||||
### web-js-env-patcher(最新补环境skill)
|
||||
使用该skill前可将ast-deobfuscation和web-js-env-patcher和web-reverse-env关闭,防止冲突,使用模板如下:
|
||||
```
|
||||
请帮我分析网站: https://hk.trip.com/hotels/list?city=30&provinceId=0&countryId=1&checkIn=2026-06-18&checkOut=2026-06-19&lat=0&lon=0&districtId=0&barCurr=HKD&searchType=CT&searchValue=___&crn=1&adult=2&children=0&searchBoxArg=t&ctm_ref=ix_sb_dl&travelPurpose=0&domestic=true
|
||||
以下是curl命令
|
||||
curl 'https://hk.trip.com/hotels/list?city=30&provinceId=0&countryId=1&checkIn=2026-06-18&checkOut=2026-06-19&lat=0&lon=0&districtId=0&barCurr=HKD&searchType=CT&searchValue=___&crn=1&adult=2&children=0&searchBoxArg=t&ctm_ref=ix_sb_dl&travelPurpose=0&domestic=true' \
|
||||
-H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \
|
||||
-H 'accept-language: zh-CN,zh;q=0.9' \
|
||||
-b 'GUID=09034081319500581032; GUID.sig=bVwVBDoosWTSu71M1dbr9TPEbEE9Zya3Zt4t_YyvoP0; UBT_VID=1781795784225.c4793L4Ganwm; ibu_online_jump_site_result={"isShowSuggestion":false,"isRedirect":false}; Union=AllianceID=8035980&SID=304032758&OUID=ctag.hash.iTzLk8GoVRkH&Expires=1784387784228&createtime=1781795784; ibusite=HK; ibugroup=trip; ibulanguage=HK; ibulocale=zh_hk; cookiePricesDisplayed=HKD; ibu_country=HK; ibu_cookie_strict=0; ubtc_trip_pwa=2; _tp_search_latest_channel_name=hotels; _bfa=1.1781795784225.c4793L4Ganwm.1.1781795797589.1781795797589.1.1.10320668088; _gcl_au=1.1.721846105.1781795801; _gcl_gs=2.1.k1$i1781795787$u30159398; _twpid=tw.1781795802116.378472634907363858; _uetsid=b60b95006b2811f1828149a4e7bbd0c1; _uetvid=b60bbf506b2811f1bbb8d3c0ba1df125; _fwb=236OiQ7bfHBqDlWPVbCu8SH.1781795802319; wcs_bt=s_33fb334966e9:1781795802; _abtest_userid=ec82b5ce-cfd1-480f-8526-3087c7d0c7b1; g_state={"i_l":0,"i_ll":1781795805787,"i_b":"Naf6TWfvBArLXQMe9vg1rTAq5MshZC8ukyyQMHV5JYQ","i_e":{"enable_itp_optimization":24},"i_et":1781795805781}; _RGUID=3b8edd5f-4678-4b59-86e5-e8711744fb29' \
|
||||
-H 'priority: u=0, i' \
|
||||
-H 'referer: https://hk.trip.com/?business=seo&sendmsg=%E6%97%85%E8%A1%8C%20cn&Allianceid=8035980&SID=304032758&trip_sub1=hk33&trip_sub3=D15280785&gad_source=1' \
|
||||
-H 'sec-ch-ua: "Chromium";v="137", "Not/A)Brand";v="24"' \
|
||||
-H 'sec-ch-ua-mobile: ?0' \
|
||||
-H 'sec-ch-ua-platform: "Windows"' \
|
||||
-H 'sec-fetch-dest: document' \
|
||||
-H 'sec-fetch-mode: navigate' \
|
||||
-H 'sec-fetch-site: same-origin' \
|
||||
-H 'sec-fetch-user: ?1' \
|
||||
-H 'upgrade-insecure-requests: 1' \
|
||||
-H 'user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36'
|
||||
请求成功响应状态码为200
|
||||
请求响应体内容为一个html数据,内容如下:
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-HK" data-cargo="locale:zh-HK,language:hk,currency:HKD,contextType:online,site:HK,group:Trip,country:HK" dir="ltr">
|
||||
<head>
|
||||
<meta charSet="utf-8"/>
|
||||
<link rel="preconnect" href="https://ak-d.tripcdn.com/"/>
|
||||
<link rel="preconnect" href="https://aw-d.tripcdn.com/"/>
|
||||
<link rel="preconnect" href="https://aw-s.tripcdn.com/"/>
|
||||
<link rel="preconnect" href="https://ak-s.tripcdn.com/"/>
|
||||
<link rel="dns-prefetch" href="//webresource.tripcdn.com"/>
|
||||
<link rel="dns-prefetch" href="//pic.tripcdn.com"/>
|
||||
<link rel="dns-prefetch" href="//www.trip.com"/>
|
||||
<link rel="dns-prefetch" href="//static.tripcdn.com"/>......
|
||||
帮我使用补环境的方式得到响应的加密参数并且能正常请求到结果
|
||||
```
|
||||
以上为提示词模板,如果curl内容过多,可以将curl内容放到文件中,然后告诉curl文件路径即可,以上只是提示词模板,如果模板不符合规范,skill也会提醒用户补充必要的信息
|
||||
|
||||
### ast-deobfuscation
|
||||
|
||||
使用 Babel AST 对 JavaScript 做分层、可回退的定向反混淆。
|
||||
适合处理 `_0x` 标识符、字符串表、自执行解码包装、dispatcher 对象、虚假分支、`while/for + switch` 控制流平坦化,以及 `if (literal === opcode)` 分发链等场景。对于 reese84、顶象、极验4、同花顺、网易易盾、小红书、OB 变种等站点或混淆家族,也提供了专门的模式识别与流水线脚本。
|
||||
|
||||
### web-reverse-algorithm
|
||||
|
||||
面向 Web/JS 逆向中的纯算与协议分析场景。
|
||||
主要用于复杂 header/cookie 签名、混合加密、JSVMP/VMP、Wasm、PoW、响应解密、验证码参数还原、challenge/verify 流程分析,以及从最终请求或最终输出反推 writer、builder、entry、source 的完整链路。适合将逆向结果进一步沉淀为 solver、SDK、脚本或服务。
|
||||
|
||||
### web-reverse-env(已废弃)
|
||||
|
||||
面向浏览器补环境与运行时修补场景。
|
||||
覆盖 Proxy 吐环境、原型链修复、native `toString` 保护、描述符保护,以及 `navigator`、`document`、`storage`、`canvas`、`WebGL`、`crypto`、`performance`、`WebRTC`、`Worker` 等模块化环境构建。适合处理浏览器环境缺失、反检测、指纹对齐和高强度风控环境修补问题。
|
||||
|
||||
|
||||
57
vendor-new/xbsReverseSkill/ast-deobfuscation/SKILL.md
Normal file
57
vendor-new/xbsReverseSkill/ast-deobfuscation/SKILL.md
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: ast-deobfuscation
|
||||
description: 使用 Babel AST 对 JavaScript 做分层、可回退的定向反混淆。适用于 `_0x` 标识符、字符串表、自执行解码包装、dispatcher 对象、虚假常量分支、`while/for + switch` 控制流平坦化、`if (literal === opcode)` 分发链,以及需要按站点或混淆家族命中特征切换专用脚本的场景。用户明确提到 reese84、顶象、极验4、同花顺、网易易盾、小红书、OB 变种或类似站点适配时也使用本 skill。
|
||||
---
|
||||
|
||||
# AST 反混淆
|
||||
|
||||
优先使用分层入口,而不是继续把站点特有逻辑堆进通用脚本。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 先运行 `scripts/detect-patterns.js <input.js> [hint]` 做模式检测。
|
||||
2. 只读取命中的站点或混淆家族规则文档。
|
||||
3. 运行 `scripts/run-pipeline.js <input.js> <output-dir> [hint]` 执行选中的流水线。
|
||||
4. 对照参考产物或原始混淆症状,判断是否还需要新增专用适配脚本。
|
||||
|
||||
## 设计规则
|
||||
|
||||
- 通用脚本只保留低风险、可复用的改写,例如结构标准化、虚假分支清理、dispatcher 内联、控制流拍平和 `if -> switch`。
|
||||
- 一旦某个模式明显是站点特有的,就同时补齐四部分:规则文档、检测器命中项、专用适配脚本、流水线配置项。
|
||||
- 对于高开销步骤,优先按家族跳过或重排,不要强行要求所有样本走同一套顺序。像 `reese84` 这种大样本,如果太晚执行 `inline-literals`,很容易卡住。
|
||||
- 专用适配脚本应当保持窄而准。如果某条规则没有在多个无关样本中复用,不要急着回灌到通用脚本。
|
||||
- 结构性改写后要重新 parse,并保证每一阶段都可以独立运行和排查。
|
||||
|
||||
## 入口脚本
|
||||
|
||||
- `scripts/detect-patterns.js`
|
||||
根据文件路径、可选 hint 和源码症状判断最可能命中的站点或混淆家族。
|
||||
- `scripts/run-pipeline.js`
|
||||
把样本复制到测试目录,执行选中的步骤,记录耗时并输出流水线报告。
|
||||
- `scripts/collect-residue-metrics.js`
|
||||
统计仍未解开的症状,例如 `split('|')`、直接 `loop/switch` 平坦化、opcode `if` 链、dispatcher wrapper 和 `_0x` 标识符。
|
||||
- `scripts/compare-with-reference.js`
|
||||
将最新流水线输出与 `decode.js` 对比,汇总剩余差距。
|
||||
|
||||
## 参考文档
|
||||
|
||||
- 新增或调整适配器时,先读 `references/pattern-layering.md`。
|
||||
- 任何逻辑想放进通用脚本前,先读 `references/safe-rewrite-rules.md`。
|
||||
- 处理字符串表、解码 stub、最小运行时求值时,读 `references/string-array-and-minimal-eval.md`。
|
||||
- 处理控制流平坦化、opcode 分发器和 VM 类 handler 时,读 `references/control-flow-and-opcode-patterns.md`。
|
||||
- 处理逗号表达式、IIFE、语句提升时,读 `references/sequence-normalization.md`。
|
||||
- 检测器命中后,只读取对应的一份站点规则文档:
|
||||
- `references/patterns/reese84.md`
|
||||
- `references/patterns/dingxiang.md`
|
||||
- `references/patterns/geetest4.md`
|
||||
- `references/patterns/tonghuashun.md`
|
||||
- `references/patterns/yidun.md`
|
||||
- `references/patterns/xiaohongshu.md`
|
||||
- `references/patterns/cn-bidding-ob.md`
|
||||
- `references/patterns/mps-ob.md`
|
||||
|
||||
## 校验
|
||||
|
||||
- 每个新增或改过的 JavaScript 辅助脚本,在接入流水线前都要至少过一次 `node --check` 或等价的加载校验。
|
||||
- 修改 skill 后,运行 `C:\Users\25198\.codex\skills\.system\skill-creator\scripts\quick_validate.py C:\Users\25198\.codex\skills\ast-deobfuscation`。
|
||||
- 测试案例时,把中间产物、检测结果、对比结果和流水线耗时都保留在案例目录里,方便定位慢步骤和失败步骤。
|
||||
@@ -0,0 +1,6 @@
|
||||
interface:
|
||||
display_name: "AST Deobfuscation"
|
||||
short_description: "Layered Babel AST deobfuscation with detectors, reusable passes, and site-specific adapters."
|
||||
default_prompt: "Use $ast-deobfuscation to detect the obfuscation family first, then run the layered pipeline with generic passes and any matching site adapter."
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
@@ -0,0 +1,55 @@
|
||||
# 控制流平坦化与 Opcode 模式
|
||||
|
||||
这份文档用于处理控制流平坦化、opcode `if` 链、虚拟机解释器主分发器,以及测试位噪声分支。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- `for (...; ![];) { switch (...) { ... } }`
|
||||
- `while (true) { switch (...) { ... } }`
|
||||
- `"3|1|2".split('|')` 一类数组或顺序表驱动的平坦化
|
||||
- 大量 `if (0x31 === opcode) ... else if ...`
|
||||
- 虚拟机解释器里围绕同一个 opcode 变量做长链分发
|
||||
|
||||
## 控制流展平原则
|
||||
|
||||
- 优先写定向 visitor,不要一开始追求通用大而全。
|
||||
- 先验证 `init`、`test`、`update`、`body` 结构是否稳定。
|
||||
- 只要结构不匹配,就直接跳过,不要强行展平。
|
||||
- 拼接 case 时同步清理仅用于跳转的赋值、无意义 `break` 和明显噪声语句。
|
||||
|
||||
## `if` 链改写为 `switch`
|
||||
|
||||
仅在以下条件同时满足时改写:
|
||||
|
||||
- 整条链都在判断同一个判别表达式。
|
||||
- 每个分支都拿这个表达式与字面量比较。
|
||||
- 分支数量足够长,改写后可读性明显提升。
|
||||
|
||||
## 测试位噪声与哨兵比较
|
||||
|
||||
某些样本会在测试条件里塞入噪声,例如:
|
||||
|
||||
```js
|
||||
cond && fn(...) !== {}
|
||||
cond && fn(...) === {}
|
||||
```
|
||||
|
||||
只在以下前提下清理:
|
||||
|
||||
- 它位于 `if`、`for.test` 或 `while.test` 位置。
|
||||
- 右侧确实只是混淆器注入的空对象比较噪声。
|
||||
- 删除后不会影响左侧真实条件的副作用顺序。
|
||||
|
||||
## 虚拟机解释器建议
|
||||
|
||||
- 第一轮只求看清主分发器、栈变量、opcode 读取器。
|
||||
- 清掉虚假分支后,再把 `if` 链改成 `switch`。
|
||||
- 第二轮再考虑简化 case 内的局部结构。
|
||||
|
||||
## 停止条件
|
||||
|
||||
- 主 opcode 分发器已可读。
|
||||
- 关键 case 已经可以逐个分析。
|
||||
- 继续静态展开开始明显增加误改写风险。
|
||||
|
||||
到这里就可以转入人工分析或结合运行时证据继续逆向。
|
||||
@@ -0,0 +1,19 @@
|
||||
# 模式分层
|
||||
|
||||
将这个 skill 保持为三层结构:
|
||||
|
||||
1. 通用变换层
|
||||
只处理稳定、低风险、能跨多个样本复用的改写。
|
||||
2. 检测与流水线选择层
|
||||
先命中混淆家族或站点,再决定步骤顺序。
|
||||
3. 家族或站点适配层
|
||||
当某种残留可以稳定复现,但又不适合泛化时,新增窄范围脚本和配套规则文档。
|
||||
|
||||
新增一个适配器时,要一起更新这些位置:
|
||||
|
||||
- `references/patterns/<pattern>.md`
|
||||
- `scripts/patterns/<pattern>.js`
|
||||
- `scripts/pipeline-config.js`
|
||||
- `scripts/detect-patterns.js` 对应的配置命中项
|
||||
|
||||
除非某条规则已经在多个无关样本中重复出现,而且安全边界依然清晰,否则不要把站点规则回灌进通用脚本。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 变种OB混淆
|
||||
|
||||
当样本是OB变种混淆时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- 仍然能看到 `_0x` 风格的标识符。
|
||||
- 经过通用处理后,OB 家族的包装层依旧存在。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 将 OB 家族逻辑保留在专用适配器里,只给 OB 类样本复用。
|
||||
- 适配器运行后,直接把 `loop/switch` 残留与参考 `decode` 结果对比。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 仍然残留的直接 `loop/switch` 平坦化结构。
|
||||
- 任何高于参考基线的 OB 风格命名或包装层痕迹。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 顶象
|
||||
|
||||
当样本属于顶象或非常接近的混淆家族时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- IIFE 的参数会喂给后续平坦化阶段使用的数组或查找对象。
|
||||
- 清理 dispatcher 会有帮助,但更关键的是恢复由数组承载的执行顺序。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 先解析由参数驱动的数组,再判断 `loop/switch` 平坦化是否仍然存在。
|
||||
- 平坦化完成后保持流程保守,默认不要再追加第二轮高开销的字面量内联。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 相对 `decode.js` 仍然存在的结构噪音。
|
||||
- 在直接 `loop/switch` 块已经清零后,依然残留的 dispatcher 包装层差距。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 极验4
|
||||
|
||||
当样本属于极验4,或属于相近的 guarded-switch VM 家族时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- `loop/switch` 平坦化外层还包着 guarded `if` 结构。
|
||||
- 即使通用平坦化器已经能处理较简单的 handler,输出与参考结果仍然差很多。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 将 guarded-switch 适配器作为家族专用步骤运行,不要塞进通用平坦化器内部。
|
||||
- 优先压低直接 `loop/switch` 残留,再考虑命名或可读性层面的清理。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 仍然残留的直接 `loop/switch` 热点。
|
||||
- 仍然比 `decode.js` 更嘈杂的 guarded opcode dispatcher 结构。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 中华人民共和国公安部
|
||||
|
||||
当样本属于中华人民共和国公安部,并且符合 OB 家族特征时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- 存在 `_0x` 风格标识符以及 OB 家族的包装结构。
|
||||
- 虽然与招投标样本属于同一家族,但 `loop/switch` 残留画像并不相同。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 复用共享的 OB 适配器,但为这种残留画像保留独立规则文档。
|
||||
- 以直接 `loop/switch` 块是否降到参考基线作为成功判断标准。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 经过 OB 适配器后依然存在的 `loop/switch` 平坦化残留。
|
||||
- 持续存在的 `_0x` 命名或包装层差距。
|
||||
@@ -0,0 +1,18 @@
|
||||
# reese84
|
||||
|
||||
当文件路径、hint 或样本标签明确指向 `reese84` 时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- 样本体量大,呈现明显的 VM 风格,并且 dispatcher 残留较重。
|
||||
- 完成结构性处理后,`inline-literals` 的开销会显著升高。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 优先采用 `normalize -> prune -> inline-dispatchers -> flatten -> if-to-switch` 这条顺序。
|
||||
- 除非在更小的裁剪样本上已经验证安全,否则跳过后置的 `inline-literals`。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 仍然存在的 dispatcher 包装层。
|
||||
- 相对 `decode.js` 的最终行数差距。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 同花顺
|
||||
|
||||
当样本属于同花顺,或属于相关的 split-order wrapper 家族时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- 残留的 `split('|')` 顺序字符串会驱动 `loop/switch` 平坦化。
|
||||
- 在抵达真正的顺序字符串之前,包装对象还会层层指向其他包装对象。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 在 `flatten-array-control-flow.js` 之前插入专门的顺序源适配器。
|
||||
- 不要默认把 `inline-literals` 当成这里的瓶颈解法。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 尚未解开的 `split('|')` 顺序数组。
|
||||
- 由多层包装对象驱动的剩余 `loop/switch` 代码块。
|
||||
@@ -0,0 +1,17 @@
|
||||
# 小红书
|
||||
|
||||
当样本属于小红书时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- wrapper 清理相对较轻,剩余平坦化也不算多。
|
||||
- 可读性提升主要依赖结构规范化,而不是更深的求值或执行。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 让适配器保持轻量。
|
||||
- 优先做规范化和包装层清理,而不是激进的常量传播。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 仍然与 `decode.js` 存在差异的小型 dispatcher 或包装层残留。
|
||||
@@ -0,0 +1,18 @@
|
||||
# 网易易盾
|
||||
|
||||
当样本属于网易易盾时,使用这份规则。
|
||||
|
||||
识别信号:
|
||||
|
||||
- 通用 dispatcher 处理之后,wrapper 仍然比较密集。
|
||||
- 做一轮字面量传播通常仍然有帮助,而且不至于把文件推向病态体积。
|
||||
|
||||
流水线建议:
|
||||
|
||||
- 让 dispatcher 适配器尽量贴近 `inline-dispatchers` 这一步。
|
||||
- 允许执行一轮 `inline-literals`,但不要盲目重复。
|
||||
|
||||
当前残留重点:
|
||||
|
||||
- 相对 `decode.js` 的 dispatcher 包装层数量差距。
|
||||
- 主平坦化已经消除后,仍然存在的少量结构差距。
|
||||
@@ -0,0 +1,41 @@
|
||||
# 安全改写规则
|
||||
|
||||
这份文档只回答一个问题:当前节点是否“确定可还原”。
|
||||
|
||||
只有在答案明确为“是”时,才做 AST 替换。
|
||||
|
||||
## 可以直接替换的典型场景
|
||||
|
||||
- 数组字面量下标访问,且下标是 `NumericLiteral`,数组内容本身也稳定。
|
||||
- 已建立最小解密环境后,调用参数全是数字字面量或纯字面量。
|
||||
- `['a', 'b', 'c'].join('')`、`String.fromCharCode(...)`、`split('').reverse().join('')` 这类纯计算表达式。
|
||||
- 明确的字符串或数字别名链,例如 `var a = 'x'; var b = a;`。
|
||||
- 可以静态确认结果的恒真或恒假比较,例如 `if ('a' === 'a')`。
|
||||
|
||||
## 不要直接替换的高风险场景
|
||||
|
||||
- 依赖 `this`、`window`、闭包外部变量或浏览器环境的调用。
|
||||
- 含 getter、setter、代理对象、动态属性访问、副作用函数调用的表达式。
|
||||
- 不能确认 binding 是否稳定的别名链。
|
||||
- 顺序很关键的逻辑表达式、逗号表达式、条件测试表达式。
|
||||
- 需要跨作用域推断执行结果的节点。
|
||||
|
||||
## 默认判断顺序
|
||||
|
||||
1. 先看节点结果是否只依赖字面量和局部稳定绑定。
|
||||
2. 再看求值过程中是否存在副作用。
|
||||
3. 再看是否依赖执行顺序、宿主环境或外部状态。
|
||||
4. 任意一步无法确认,就不要替换。
|
||||
|
||||
## 常见误区
|
||||
|
||||
- 不要把所有 `CallExpression` 都拿去局部求值。
|
||||
- 不要把 `LogicalExpression` 默认折叠成左值或右值,尤其是含函数调用时。
|
||||
- 不要把 `StringLiteral`、`NumericLiteral`、`BooleanLiteral` 再次纳入“循环 evaluate 并回写”的流程。
|
||||
- 不要为了追求完全还原而牺牲稳定性。
|
||||
|
||||
## 推荐做法
|
||||
|
||||
- 先得到“结构可读、关键字符串已恢复、主控制流已展开”的结果。
|
||||
- 把不能静态确认的区域保留下来,等后续结合运行时证据再处理。
|
||||
- 每完成一批替换后重新 parse 一次,确保输出仍然稳定。
|
||||
@@ -0,0 +1,61 @@
|
||||
# 逗号表达式与结构标准化
|
||||
|
||||
这份文档用于处理 `SequenceExpression`、表达式语句、`if`/`for` 中的逗号表达式,以及部分可安全收敛的结构标准化。
|
||||
|
||||
## 总原则
|
||||
|
||||
- 不要把所有逗号表达式都统一拆成多条语句。
|
||||
- 必须先看父节点,再决定改写方式。
|
||||
- 必须保证执行顺序和副作用顺序不变。
|
||||
|
||||
## 按父节点判断
|
||||
|
||||
### `ExpressionStatement`
|
||||
|
||||
- 通常可以直接拆成多条表达式语句。
|
||||
- 但如果末尾结果还被后续逻辑隐式依赖,先确认再拆。
|
||||
|
||||
### `IfStatement.test`
|
||||
|
||||
- `if (a(), b(), test)` 可以改写为:
|
||||
1. 先把 `a()`、`b()` 提到 `if` 之前。
|
||||
2. 保留 `if (test)`。
|
||||
|
||||
### `ForStatement.init`
|
||||
|
||||
- `for (a(), b(), c(); test; update)` 中的 `init` 逗号表达式最适合先拆,因为它只执行一次。
|
||||
|
||||
### `ForStatement.test`
|
||||
|
||||
- `for (init; a(), b(), test; update)` 可以改写为:
|
||||
1. 保留循环头里的稳定部分。
|
||||
2. 把 `a()`、`b()` 放到循环体开头。
|
||||
3. 在顶部加基于 `test` 的 `break` 守卫。
|
||||
|
||||
### `ForStatement.update`
|
||||
|
||||
- 只在循环体中不存在 `continue` 风险时再拆。
|
||||
- 如果存在 `continue`,把更新表达式搬到循环体尾部可能会改变行为,应保守跳过。
|
||||
|
||||
### `ReturnStatement` 与 `VariableDeclarator`
|
||||
|
||||
- 这两类需要分别处理,不能简单套用表达式语句的拆法。
|
||||
- 如果暂时没有足够稳定的模板,宁可不动。
|
||||
|
||||
## 可顺手清理的结构
|
||||
|
||||
- 零参数、立即执行、纯包裹的 IIFE。
|
||||
- 表达式语句里的简单三元噪声。
|
||||
- 可静态确认且不改变副作用顺序的简单逻辑表达式语句。
|
||||
|
||||
## 不要做的事
|
||||
|
||||
- 不要把复杂 `LogicalExpression` 默认改写成 `if`。
|
||||
- 不要在不知道父节点语义的情况下使用批量 `replaceWithMultiple`。
|
||||
- 不要只因为“文本更好看”就重排表达式执行顺序。
|
||||
|
||||
## 验证建议
|
||||
|
||||
- 结构标准化后立即重新 parse。
|
||||
- 对 `for.test` 和 `for.update` 的改写,优先用代表性样本回归一次。
|
||||
- 如果改写后只提升了少量可读性,却增加了行为不确定性,就回退。
|
||||
@@ -0,0 +1,53 @@
|
||||
# 字符串表与最小求值
|
||||
|
||||
这份文档用于处理字符串表、解码包装、局部别名链和最小引导 `eval`。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 文件开头几条语句就在初始化解码器。
|
||||
- 顶层 IIFE 里传入数组、对象或字符串表。
|
||||
- 源码里大量出现 `fn(12)`、`arr[123]`、`String.fromCharCode(...)`。
|
||||
- 第一层去壳之后,函数内部还存在二次字符串恢复。
|
||||
|
||||
## 推荐顺序
|
||||
|
||||
1. 先定位数组字面量、解码函数和别名链。
|
||||
2. 尽量优先做静态恢复,例如数组下标、纯字面量拼接、纯数字参数调用。
|
||||
3. 只有静态无法推进时,才执行最小引导代码。
|
||||
4. 删除已经消费掉的引导声明或包装层。
|
||||
5. 重新 parse 一次,再进入控制流还原阶段。
|
||||
|
||||
## 最小引导 `eval` 原则
|
||||
|
||||
- 只执行前几条足以构造解码器的代码。
|
||||
- 不要直接执行整份源文件。
|
||||
- 不要在未隔离的环境里执行明显依赖 DOM、`window`、定时器或网络的逻辑。
|
||||
- 求值目标应尽量窄,例如“只恢复数字参数到字符串的函数调用”。
|
||||
|
||||
## 常见模式
|
||||
|
||||
### 顶层 IIFE 带数组参数
|
||||
|
||||
- 从顶层调用提取 `formalParam -> actualArg` 映射。
|
||||
- 只替换明确命中的数组下标访问。
|
||||
- 处理包装函数里直接可展开的语句块。
|
||||
|
||||
### 前几条语句就是解密引导
|
||||
|
||||
- 只截取前 2 到 5 条关键语句。
|
||||
- 建立最小运行时后,恢复只带字面量参数的调用点。
|
||||
- 删除已消费的引导语句。
|
||||
|
||||
### 去掉第一层后,局部函数还有二次恢复
|
||||
|
||||
- 先展开顶层 IIFE。
|
||||
- 重新 parse。
|
||||
- 再识别函数内部那组“构造器 + 成员表达式 + 数字下标”的模式。
|
||||
|
||||
## 停止条件
|
||||
|
||||
- 主要字符串表已经恢复。
|
||||
- 核心解码器入口已经清楚。
|
||||
- 剩余部分不再适合纯静态恢复。
|
||||
|
||||
满足以上条件后,应切换到控制流或结构标准化阶段。
|
||||
@@ -0,0 +1,271 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const parser = require("@babel/parser");
|
||||
const traverse = require("@babel/traverse").default;
|
||||
const generator = require("@babel/generator").default;
|
||||
const t = require("@babel/types");
|
||||
|
||||
function countMatches(text, regex) {
|
||||
const matches = text.match(regex);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
function parseText(text) {
|
||||
return parser.parse(text, {
|
||||
sourceType: "unambiguous",
|
||||
plugins: ["jsx"]
|
||||
});
|
||||
}
|
||||
|
||||
function printNode(node) {
|
||||
return generator(node, {
|
||||
compact: true,
|
||||
comments: false,
|
||||
jsescOption: { minimal: true }
|
||||
}).code;
|
||||
}
|
||||
|
||||
function isLiteralLike(node) {
|
||||
return (
|
||||
t.isStringLiteral(node) ||
|
||||
t.isNumericLiteral(node) ||
|
||||
t.isBooleanLiteral(node) ||
|
||||
t.isNullLiteral(node) ||
|
||||
t.isBigIntLiteral(node) ||
|
||||
t.isIdentifier(node, { name: "undefined" })
|
||||
);
|
||||
}
|
||||
|
||||
function isOpcodeDiscriminant(node) {
|
||||
return t.isIdentifier(node) || t.isMemberExpression(node);
|
||||
}
|
||||
|
||||
function getOpcodeIfDescriptor(node) {
|
||||
if (!t.isIfStatement(node) || node.alternate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const test = node.test;
|
||||
if (!t.isBinaryExpression(test) || !["==", "===", "!=", "!=="].includes(test.operator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLiteralLike(test.left) && isOpcodeDiscriminant(test.right)) {
|
||||
return {
|
||||
discriminantKey: printNode(test.right),
|
||||
literalKey: printNode(test.left)
|
||||
};
|
||||
}
|
||||
|
||||
if (isLiteralLike(test.right) && isOpcodeDiscriminant(test.left)) {
|
||||
return {
|
||||
discriminantKey: printNode(test.left),
|
||||
literalKey: printNode(test.right)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function countOpcodeIfChains(ast) {
|
||||
const statementLists = [];
|
||||
|
||||
traverse(ast, {
|
||||
Program(path) {
|
||||
statementLists.push(path.node.body);
|
||||
},
|
||||
BlockStatement(path) {
|
||||
statementLists.push(path.node.body);
|
||||
},
|
||||
SwitchCase(path) {
|
||||
statementLists.push(path.node.consequent);
|
||||
}
|
||||
});
|
||||
|
||||
let count = 0;
|
||||
|
||||
statementLists.forEach((statements) => {
|
||||
for (let index = 0; index < statements.length; index += 1) {
|
||||
const firstDescriptor = getOpcodeIfDescriptor(statements[index]);
|
||||
if (!firstDescriptor) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const seenLiterals = new Set([firstDescriptor.literalKey]);
|
||||
let chainLength = 1;
|
||||
let cursor = index + 1;
|
||||
while (cursor < statements.length) {
|
||||
const nextDescriptor = getOpcodeIfDescriptor(statements[cursor]);
|
||||
if (!nextDescriptor || nextDescriptor.discriminantKey !== firstDescriptor.discriminantKey) {
|
||||
break;
|
||||
}
|
||||
seenLiterals.add(nextDescriptor.literalKey);
|
||||
chainLength += 1;
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
if (chainLength >= 2 && seenLiterals.size >= 2) {
|
||||
count += chainLength;
|
||||
index = cursor - 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function getReturnArgument(fnNode) {
|
||||
if (t.isArrowFunctionExpression(fnNode) && !t.isBlockStatement(fnNode.body)) {
|
||||
return fnNode.body;
|
||||
}
|
||||
|
||||
if (!t.isFunctionExpression(fnNode) && !t.isArrowFunctionExpression(fnNode)) {
|
||||
return null;
|
||||
}
|
||||
if (!t.isBlockStatement(fnNode.body) || fnNode.body.body.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [statement] = fnNode.body.body;
|
||||
return t.isReturnStatement(statement) ? statement.argument : null;
|
||||
}
|
||||
|
||||
function collectReferencedParameterNames(node) {
|
||||
const names = new Set();
|
||||
if (!node) {
|
||||
return names;
|
||||
}
|
||||
|
||||
traverse(t.file(t.program([t.expressionStatement(t.cloneNode(node, true))])), {
|
||||
noScope: true,
|
||||
Identifier(path) {
|
||||
if (!path.isReferencedIdentifier()) {
|
||||
return;
|
||||
}
|
||||
names.add(path.node.name);
|
||||
}
|
||||
});
|
||||
return names;
|
||||
}
|
||||
|
||||
function isThinWrapperFunction(fnNode) {
|
||||
const returnArgument = getReturnArgument(fnNode);
|
||||
if (!returnArgument) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paramNames = new Set(
|
||||
fnNode.params.filter((param) => t.isIdentifier(param)).map((param) => param.name)
|
||||
);
|
||||
if (paramNames.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const referencedNames = collectReferencedParameterNames(returnArgument);
|
||||
for (const name of referencedNames) {
|
||||
if (!paramNames.has(name)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (t.isBinaryExpression(returnArgument) || t.isLogicalExpression(returnArgument)) {
|
||||
return true;
|
||||
}
|
||||
if (t.isUnaryExpression(returnArgument) && t.isIdentifier(returnArgument.argument)) {
|
||||
return true;
|
||||
}
|
||||
if (t.isCallExpression(returnArgument)) {
|
||||
return t.isIdentifier(returnArgument.callee) || t.isMemberExpression(returnArgument.callee);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function countDispatcherWrappers(ast) {
|
||||
let count = 0;
|
||||
|
||||
traverse(ast, {
|
||||
ObjectExpression(path) {
|
||||
let thinWrapperCount = 0;
|
||||
let literalPropertyCount = 0;
|
||||
|
||||
path.get("properties").forEach((propertyPath) => {
|
||||
if (!propertyPath.isObjectProperty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const valuePath = propertyPath.get("value");
|
||||
if (valuePath.isStringLiteral() || valuePath.isNumericLiteral() || valuePath.isBooleanLiteral() || valuePath.isNullLiteral()) {
|
||||
literalPropertyCount += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (isThinWrapperFunction(valuePath.node)) {
|
||||
thinWrapperCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
const looksLikeDispatcherObject =
|
||||
thinWrapperCount >= 2 || (thinWrapperCount >= 1 && literalPropertyCount >= 2);
|
||||
if (looksLikeDispatcherObject) {
|
||||
count += thinWrapperCount;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
function collectMetrics(text) {
|
||||
let opcodeIfChainCount = countMatches(text, /if\s*\(\s*(?:0x[\da-f]+|\d+|["'][^"'\\]*(?:\\.[^"'\\]*)*["'])\s*={2,3}\s*[A-Za-z_$][\w$]*\s*\)/gi);
|
||||
let dispatcherWrapperCount = countMatches(text, /:\s*function\s*\([^)]*\)\s*\{\s*return\b/g);
|
||||
|
||||
try {
|
||||
const ast = parseText(text);
|
||||
opcodeIfChainCount = countOpcodeIfChains(ast);
|
||||
dispatcherWrapperCount = countDispatcherWrappers(ast);
|
||||
} catch (error) {
|
||||
// Keep regex fallbacks when the file is not parseable.
|
||||
}
|
||||
|
||||
return {
|
||||
lineCount: text.split(/\r?\n/).length,
|
||||
splitPipeCount: countMatches(text, /\.split\(\s*["']\|["']\s*\)/g),
|
||||
loopSwitchCount: countMatches(text, /(?:while|for)\s*\([^)]*\)\s*\{[\s\S]{0,120}?switch\s*\(/g),
|
||||
opcodeIfChainCount,
|
||||
dispatcherWrapperCount,
|
||||
hexIdentifierCount: countMatches(text, /\b_0x[a-f0-9]+\b/gi)
|
||||
};
|
||||
}
|
||||
|
||||
function collectMetricsFromFile(filePath) {
|
||||
const text = fs.readFileSync(filePath, "utf8");
|
||||
return {
|
||||
filePath: path.resolve(filePath),
|
||||
...collectMetrics(text)
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , inputPath, outputPath = ""] = process.argv;
|
||||
if (!inputPath) {
|
||||
console.error("Usage: node collect-residue-metrics.js <input.js> [output.json]");
|
||||
process.exit(1);
|
||||
}
|
||||
const result = collectMetricsFromFile(inputPath);
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
if (outputPath) {
|
||||
fs.writeFileSync(outputPath, json, "utf8");
|
||||
} else {
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
collectMetrics,
|
||||
collectMetricsFromFile
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { collectMetricsFromFile } = require("./collect-residue-metrics");
|
||||
|
||||
function pushGap(gaps, condition, message) {
|
||||
if (condition) {
|
||||
gaps.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
function compareOutputs(caseId, oursPath, referencePath) {
|
||||
const ours = collectMetricsFromFile(oursPath);
|
||||
const reference = collectMetricsFromFile(referencePath);
|
||||
const gaps = [];
|
||||
|
||||
pushGap(gaps, ours.splitPipeCount > reference.splitPipeCount, "still contains unresolved split('|') order sources");
|
||||
pushGap(gaps, ours.loopSwitchCount > reference.loopSwitchCount, "still contains direct loop/switch flattening");
|
||||
pushGap(gaps, ours.opcodeIfChainCount > reference.opcodeIfChainCount, "still contains opcode if-chain residue");
|
||||
pushGap(gaps, ours.dispatcherWrapperCount > reference.dispatcherWrapperCount, "still contains uninlined dispatcher wrappers");
|
||||
pushGap(gaps, ours.hexIdentifierCount > reference.hexIdentifierCount, "still contains more _0x-style identifiers than the reference");
|
||||
pushGap(gaps, ours.lineCount > reference.lineCount * 1.2, "output is still significantly noisier than the reference");
|
||||
|
||||
return {
|
||||
caseId,
|
||||
ours,
|
||||
reference,
|
||||
gaps,
|
||||
status: gaps.length === 0 ? "matched-or-better-on-heuristics" : "residue-remains"
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , oursPath, referencePath, outputPath = "", caseId = "unknown"] = process.argv;
|
||||
if (!oursPath || !referencePath) {
|
||||
console.error("Usage: node compare-with-reference.js <ours.js> <reference.js> [output.json] [caseId]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = compareOutputs(caseId, oursPath, referencePath);
|
||||
const json = JSON.stringify(result, null, 2);
|
||||
if (outputPath) {
|
||||
fs.writeFileSync(outputPath, json, "utf8");
|
||||
} else {
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareOutputs
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { detectPatterns } = require("./pipeline-config");
|
||||
|
||||
function main() {
|
||||
const [, , inputPath, hint = "", outputPath = ""] = process.argv;
|
||||
if (!inputPath) {
|
||||
console.error("Usage: node detect-patterns.js <input.js> [hint] [output.json]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourceText = fs.readFileSync(inputPath, "utf8");
|
||||
const result = detectPatterns({
|
||||
inputPath: path.resolve(inputPath),
|
||||
hint,
|
||||
sourceText
|
||||
});
|
||||
|
||||
const serializable = {
|
||||
inputPath: path.resolve(inputPath),
|
||||
hint,
|
||||
bestId: result.bestId,
|
||||
selected: {
|
||||
id: result.selected.id,
|
||||
displayName: result.selected.displayName,
|
||||
reference: result.selected.reference,
|
||||
families: result.selected.families,
|
||||
notes: result.selected.notes
|
||||
},
|
||||
detections: result.detections
|
||||
};
|
||||
|
||||
const json = JSON.stringify(serializable, null, 2);
|
||||
if (outputPath) {
|
||||
fs.writeFileSync(outputPath, json, "utf8");
|
||||
} else {
|
||||
process.stdout.write(`${json}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
const generator = require("@babel/generator").default;
|
||||
const { clone, parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function getCaseStatements(casePath) {
|
||||
return casePath.get("consequent").filter(stmt => !stmt.isContinueStatement() && !stmt.isBreakStatement()).map(stmt => clone(stmt.node));
|
||||
}
|
||||
|
||||
function hasLexicalBindings(nodes) {
|
||||
return nodes.some((node) => t.isVariableDeclaration(node) && node.kind !== "var");
|
||||
}
|
||||
|
||||
function isSimpleDiscriminant(node) {
|
||||
return t.isIdentifier(node) || t.isLiteral(node);
|
||||
}
|
||||
|
||||
function normalizeCaseConsequent(nodes) {
|
||||
const statements = nodes.map((node) => clone(node));
|
||||
if (statements.length === 0) {
|
||||
return [t.continueStatement()];
|
||||
}
|
||||
|
||||
const last = statements[statements.length - 1];
|
||||
if (t.isBreakStatement(last)) {
|
||||
statements.pop();
|
||||
statements.push(t.continueStatement());
|
||||
return statements;
|
||||
}
|
||||
if (t.isContinueStatement(last) || t.isReturnStatement(last) || t.isThrowStatement(last)) {
|
||||
return statements;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildIfChainFromSwitch(switchNode) {
|
||||
if (!isSimpleDiscriminant(switchNode.discriminant)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let root = null;
|
||||
let current = null;
|
||||
let defaultBlock = null;
|
||||
|
||||
for (const caseNode of switchNode.cases) {
|
||||
const normalized = normalizeCaseConsequent(caseNode.consequent);
|
||||
if (!normalized || hasLexicalBindings(normalized)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (caseNode.test === null) {
|
||||
defaultBlock = t.blockStatement(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
const test = t.logicalExpression(
|
||||
"&&",
|
||||
t.binaryExpression("===", clone(switchNode.discriminant), clone(caseNode.test)),
|
||||
t.booleanLiteral(true)
|
||||
);
|
||||
const nextIf = t.ifStatement(test, t.blockStatement(normalized));
|
||||
|
||||
if (!root) {
|
||||
root = nextIf;
|
||||
} else {
|
||||
current.alternate = nextIf;
|
||||
}
|
||||
current = nextIf;
|
||||
}
|
||||
|
||||
if (!root) {
|
||||
return null;
|
||||
}
|
||||
if (current && defaultBlock) {
|
||||
current.alternate = defaultBlock;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function replaceLoopSwitchWithIf(path) {
|
||||
const body = path.get("body");
|
||||
if (!body.isBlockStatement()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const statements = body.get("body");
|
||||
if (statements.length !== 1 || !statements[0].isSwitchStatement()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ifChain = buildIfChainFromSwitch(statements[0].node);
|
||||
if (!ifChain) {
|
||||
return false;
|
||||
}
|
||||
|
||||
body.replaceWith(t.blockStatement([ifChain]));
|
||||
return true;
|
||||
}
|
||||
|
||||
const UNRESOLVED = Symbol("unresolved");
|
||||
|
||||
function getResolvedFunctionCallPath(functionPath) {
|
||||
if (functionPath.parentPath.isCallExpression() && functionPath.parentKey === "callee") {
|
||||
return functionPath.parentPath;
|
||||
}
|
||||
if (
|
||||
functionPath.parentPath.isUnaryExpression() &&
|
||||
functionPath.parentPath.parentPath &&
|
||||
functionPath.parentPath.parentPath.isCallExpression() &&
|
||||
functionPath.parentPath.parentKey === "callee"
|
||||
) {
|
||||
return functionPath.parentPath.parentPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveObjectPropertyFromBinding(objectPath, propertyValue, seenBindings = new Set()) {
|
||||
if (!objectPath || !objectPath.node || !objectPath.isIdentifier()) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
const binding = objectPath.scope.getBinding(objectPath.node.name);
|
||||
if (!binding || seenBindings.has(binding)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
seenBindings.add(binding);
|
||||
|
||||
const declaratorPath = getDeclaratorForBinding(binding);
|
||||
if (!declaratorPath) {
|
||||
seenBindings.delete(binding);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
const initPath = declaratorPath.get("init");
|
||||
if (!initPath.node) {
|
||||
seenBindings.delete(binding);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
if (initPath.isIdentifier()) {
|
||||
const resolved = resolveObjectPropertyFromBinding(initPath, propertyValue, seenBindings);
|
||||
seenBindings.delete(binding);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
if (!initPath.isObjectExpression()) {
|
||||
seenBindings.delete(binding);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
for (const propertyPath of initPath.get("properties")) {
|
||||
if (!propertyPath.isObjectProperty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const keyPath = propertyPath.get("key");
|
||||
const key = propertyPath.node.computed ? resolveStatic(keyPath, seenBindings) : keyPath.isIdentifier() ? keyPath.node.name : resolveStatic(keyPath, seenBindings);
|
||||
if (key === UNRESOLVED || String(key) !== String(propertyValue)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolved = resolveStatic(propertyPath.get("value"), seenBindings);
|
||||
seenBindings.delete(binding);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
seenBindings.delete(binding);
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
function resolveStatic(path, seenBindings = new Set()) {
|
||||
if (!path || !path.node) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
if (path.isStringLiteral() || path.isNumericLiteral() || path.isBooleanLiteral()) {
|
||||
return path.node.value;
|
||||
}
|
||||
if (path.isNullLiteral()) {
|
||||
return null;
|
||||
}
|
||||
if (path.isTemplateLiteral() && path.node.expressions.length === 0) {
|
||||
return path.node.quasis[0].value.cooked;
|
||||
}
|
||||
if (path.isIdentifier({ name: "undefined" })) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (path.isArrayExpression()) {
|
||||
const values = [];
|
||||
for (const elementPath of path.get("elements")) {
|
||||
if (!elementPath.node) {
|
||||
values.push(undefined);
|
||||
continue;
|
||||
}
|
||||
const value = resolveStatic(elementPath, seenBindings);
|
||||
if (value === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
if (path.isObjectExpression()) {
|
||||
const out = Object.create(null);
|
||||
for (const propPath of path.get("properties")) {
|
||||
if (!propPath.isObjectProperty()) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const keyPath = propPath.get("key");
|
||||
const key = propPath.node.computed ? resolveStatic(keyPath, seenBindings) : keyPath.isIdentifier() ? keyPath.node.name : resolveStatic(keyPath, seenBindings);
|
||||
if (key === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
const value = resolveStatic(propPath.get("value"), seenBindings);
|
||||
if (value === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (path.isIdentifier()) {
|
||||
const binding = path.scope.getBinding(path.node.name);
|
||||
if (!binding || seenBindings.has(binding)) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
seenBindings.add(binding);
|
||||
|
||||
let resolved = UNRESOLVED;
|
||||
if (binding.path.parentPath && binding.path.parentPath.isVariableDeclarator()) {
|
||||
resolved = resolveStatic(binding.path.parentPath.get("init"), seenBindings);
|
||||
} else if (binding.kind === "param" && binding.path.parentPath && binding.path.parentPath.isFunction()) {
|
||||
const callPath = getResolvedFunctionCallPath(binding.path.parentPath);
|
||||
if (callPath && typeof binding.path.key === "number") {
|
||||
resolved = resolveStatic(callPath.get(`arguments.${binding.path.key}`), seenBindings);
|
||||
}
|
||||
}
|
||||
|
||||
seenBindings.delete(binding);
|
||||
if (resolved !== UNRESOLVED) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
if (path.isMemberExpression()) {
|
||||
const propertyValue = path.node.computed
|
||||
? resolveStatic(path.get("property"), seenBindings)
|
||||
: path.get("property").isIdentifier()
|
||||
? path.node.property.name
|
||||
: resolveStatic(path.get("property"), seenBindings);
|
||||
if (propertyValue === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
const directPropertyValue = resolveObjectPropertyFromBinding(path.get("object"), propertyValue, seenBindings);
|
||||
if (directPropertyValue !== UNRESOLVED) {
|
||||
return directPropertyValue;
|
||||
}
|
||||
|
||||
const objectValue = resolveStatic(path.get("object"), seenBindings);
|
||||
if (objectValue === UNRESOLVED) {
|
||||
return UNRESOLVED;
|
||||
}
|
||||
if (Array.isArray(objectValue)) {
|
||||
const index = Number(propertyValue);
|
||||
return Number.isInteger(index) ? objectValue[index] : UNRESOLVED;
|
||||
}
|
||||
if (objectValue && typeof objectValue === "object" && propertyValue in objectValue) {
|
||||
return objectValue[propertyValue];
|
||||
}
|
||||
return UNRESOLVED;
|
||||
}
|
||||
|
||||
if (
|
||||
path.isCallExpression() &&
|
||||
path.get("callee").isMemberExpression() &&
|
||||
(
|
||||
path.get("callee.property").isIdentifier({ name: "split" }) ||
|
||||
path.get("callee.property").isStringLiteral({ value: "split" })
|
||||
) &&
|
||||
path.get("arguments.0").isStringLiteral()
|
||||
) {
|
||||
const source = resolveStatic(path.get("callee.object"), seenBindings);
|
||||
if (typeof source === "string") {
|
||||
return source.split(path.node.arguments[0].value);
|
||||
}
|
||||
}
|
||||
|
||||
const evaluated = path.evaluate();
|
||||
return evaluated.confident ? evaluated.value : UNRESOLVED;
|
||||
}
|
||||
|
||||
function getLiteralValue(path) {
|
||||
const resolved = resolveStatic(path);
|
||||
if (typeof resolved === "string" || typeof resolved === "number") {
|
||||
return resolved;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeKey(node) {
|
||||
return generator(node, {
|
||||
compact: true,
|
||||
comments: false,
|
||||
jsescOption: { minimal: true }
|
||||
}).code;
|
||||
}
|
||||
|
||||
function getCaseKey(casePath) {
|
||||
const value = getLiteralValue(casePath.get("test"));
|
||||
return value === null ? normalizeKey(casePath.node.test) : value;
|
||||
}
|
||||
|
||||
function getOrderFromInit(initPath) {
|
||||
if (initPath.isArrayExpression()) {
|
||||
const values = [];
|
||||
for (const elementPath of initPath.get("elements")) {
|
||||
if (!elementPath.node) {
|
||||
return null;
|
||||
}
|
||||
const value = getLiteralValue(elementPath);
|
||||
values.push(value === null ? normalizeKey(elementPath.node) : value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
if (
|
||||
initPath.isCallExpression() &&
|
||||
initPath.get("callee").isMemberExpression() &&
|
||||
(
|
||||
initPath.get("callee.property").isIdentifier({ name: "split" }) ||
|
||||
initPath.get("callee.property").isStringLiteral({ value: "split" })
|
||||
) &&
|
||||
initPath.get("arguments.0").isStringLiteral()
|
||||
) {
|
||||
const source = getLiteralValue(initPath.get("callee.object"));
|
||||
if (typeof source === "string") {
|
||||
return source.split(initPath.node.arguments[0].value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrderFromResolvedValue(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const order = [];
|
||||
for (const item of value) {
|
||||
if (typeof item === "string" || typeof item === "number") {
|
||||
order.push(item);
|
||||
continue;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
function getDeclaratorForBinding(binding) {
|
||||
if (!binding || !binding.path) {
|
||||
return null;
|
||||
}
|
||||
if (binding.path.isVariableDeclarator()) {
|
||||
return binding.path;
|
||||
}
|
||||
if (binding.path.parentPath && binding.path.parentPath.isVariableDeclarator()) {
|
||||
return binding.path.parentPath;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOrderFromBinding(binding) {
|
||||
const declaratorPath = getDeclaratorForBinding(binding);
|
||||
if (!declaratorPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const initPath = declaratorPath.get("init");
|
||||
if (!initPath.node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return getOrderFromInit(initPath) || getOrderFromResolvedValue(resolveStatic(initPath));
|
||||
}
|
||||
|
||||
function getOrderFromSplitCallWithObjectTable(initPath, loopPath) {
|
||||
if (
|
||||
!initPath.isCallExpression() ||
|
||||
!initPath.get("callee").isMemberExpression() ||
|
||||
!(
|
||||
initPath.get("callee.property").isIdentifier({ name: "split" }) ||
|
||||
initPath.get("callee.property").isStringLiteral({ value: "split" })
|
||||
) ||
|
||||
!initPath.get("arguments.0").isStringLiteral()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourcePath = initPath.get("callee.object");
|
||||
if (!sourcePath.isMemberExpression() || !sourcePath.get("object").isIdentifier()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectName = sourcePath.get("object").node.name;
|
||||
const propertyKey = sourcePath.node.computed ? getStaticPropertyKey(sourcePath.node.property) : getStaticPropertyKey(sourcePath.node.property);
|
||||
if (propertyKey === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = collectObjectPipeStringCandidates(loopPath, objectName).find((candidate) => candidate.key === propertyKey);
|
||||
return match ? match.order : null;
|
||||
}
|
||||
|
||||
function getOrderFromDiscriminant(discriminantPath) {
|
||||
if (!discriminantPath.isMemberExpression()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const objectPath = discriminantPath.get("object");
|
||||
const directOrder = getOrderFromResolvedValue(resolveStatic(objectPath));
|
||||
if (directOrder) {
|
||||
return directOrder;
|
||||
}
|
||||
|
||||
if (!objectPath.isIdentifier()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binding = objectPath.scope.getBinding(objectPath.node.name);
|
||||
return getOrderFromBinding(binding);
|
||||
}
|
||||
|
||||
function findNearbyOrderDeclaration(loopPath, maxSiblings = 3) {
|
||||
let siblingPath = loopPath.getPrevSibling();
|
||||
for (let seen = 0; siblingPath && siblingPath.node && seen < maxSiblings; seen += 1) {
|
||||
if (!siblingPath.isVariableDeclaration()) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const declaratorPath of siblingPath.get("declarations")) {
|
||||
if (!declaratorPath.node || !declaratorPath.get("init").node) {
|
||||
continue;
|
||||
}
|
||||
const order =
|
||||
getOrderFromInit(declaratorPath.get("init")) ||
|
||||
getOrderFromSplitCallWithObjectTable(declaratorPath.get("init"), loopPath) ||
|
||||
getOrderFromResolvedValue(resolveStatic(declaratorPath.get("init")));
|
||||
if (order) {
|
||||
return {
|
||||
order,
|
||||
declarationPath: siblingPath
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
siblingPath = siblingPath.getPrevSibling();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBlocksFromSwitchOrder(switchStmt, order) {
|
||||
if (!order) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const caseMap = new Map();
|
||||
switchStmt.get("cases").forEach(casePath => {
|
||||
if (!casePath.node.test) {
|
||||
return;
|
||||
}
|
||||
caseMap.set(getCaseKey(casePath), getCaseStatements(casePath));
|
||||
});
|
||||
|
||||
const blocks = [];
|
||||
order.forEach(key => {
|
||||
const stmts = caseMap.get(key);
|
||||
if (stmts) {
|
||||
blocks.push(...stmts);
|
||||
}
|
||||
});
|
||||
|
||||
return blocks.length > 0 ? blocks : null;
|
||||
}
|
||||
|
||||
function getStaticPropertyKey(node) {
|
||||
if (t.isIdentifier(node)) {
|
||||
return node.name;
|
||||
}
|
||||
if (t.isStringLiteral(node) || t.isNumericLiteral(node)) {
|
||||
return String(node.value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveObjectAliasNames(loopPath, objectName) {
|
||||
const names = new Set();
|
||||
let currentName = objectName;
|
||||
|
||||
while (currentName) {
|
||||
if (names.has(currentName)) {
|
||||
break;
|
||||
}
|
||||
names.add(currentName);
|
||||
|
||||
const binding = loopPath.scope.getBinding(currentName);
|
||||
const declaratorPath = getDeclaratorForBinding(binding);
|
||||
if (!declaratorPath || !declaratorPath.get("init").isIdentifier()) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentName = declaratorPath.get("init").node.name;
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function splitPipeString(value) {
|
||||
if (typeof value !== "string" || !value.includes("|")) {
|
||||
return null;
|
||||
}
|
||||
const parts = value.split("|");
|
||||
return parts.length > 1 ? parts : null;
|
||||
}
|
||||
|
||||
function getPipeOrderFromValuePath(valuePath) {
|
||||
if (!valuePath || !valuePath.node) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (valuePath.isStringLiteral()) {
|
||||
return splitPipeString(valuePath.node.value);
|
||||
}
|
||||
|
||||
const resolved = resolveStatic(valuePath);
|
||||
return splitPipeString(resolved);
|
||||
}
|
||||
|
||||
function getCaseKeys(switchStmt) {
|
||||
const keys = [];
|
||||
switchStmt.get("cases").forEach(casePath => {
|
||||
if (!casePath.node.test) {
|
||||
return;
|
||||
}
|
||||
keys.push(getCaseKey(casePath));
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
function sameSequenceKeySet(order, caseKeys) {
|
||||
if (!order || order.length !== caseKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const counts = new Map();
|
||||
caseKeys.forEach((key) => counts.set(String(key), (counts.get(String(key)) || 0) + 1));
|
||||
for (const item of order) {
|
||||
const key = String(item);
|
||||
const count = counts.get(key);
|
||||
if (!count) {
|
||||
return false;
|
||||
}
|
||||
if (count === 1) {
|
||||
counts.delete(key);
|
||||
} else {
|
||||
counts.set(key, count - 1);
|
||||
}
|
||||
}
|
||||
return counts.size === 0;
|
||||
}
|
||||
|
||||
function getAncestorBodyStatements(loopPath) {
|
||||
const bodies = [];
|
||||
const seen = new Set();
|
||||
let current = loopPath;
|
||||
|
||||
while (current) {
|
||||
if (current.isProgram()) {
|
||||
const key = `program:${current.node.start || 0}`;
|
||||
if (!seen.has(key)) {
|
||||
bodies.push(current.get("body"));
|
||||
seen.add(key);
|
||||
}
|
||||
} else if (current.isBlockStatement() && Array.isArray(current.node.body)) {
|
||||
const key = `block:${current.node.start || 0}`;
|
||||
if (!seen.has(key)) {
|
||||
bodies.push(current.get("body"));
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
current = current.parentPath;
|
||||
}
|
||||
|
||||
return bodies;
|
||||
}
|
||||
|
||||
function collectObjectPipeStringCandidates(loopPath, objectName) {
|
||||
if (!objectName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const objectNames = resolveObjectAliasNames(loopPath, objectName);
|
||||
const candidates = [];
|
||||
const loopStart = loopPath.node.start || 0;
|
||||
getAncestorBodyStatements(loopPath).forEach((statements) => {
|
||||
statements.forEach((stmtPath) => {
|
||||
if (!stmtPath.node || (stmtPath.node.start || 0) >= loopStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (stmtPath.isVariableDeclaration()) {
|
||||
stmtPath.get("declarations").forEach((declPath) => {
|
||||
if (!declPath.get("id").isIdentifier() || !objectNames.has(declPath.get("id").node.name) || !declPath.get("init").isObjectExpression()) {
|
||||
return;
|
||||
}
|
||||
declPath.get("init.properties").forEach((propPath) => {
|
||||
if (!propPath.isObjectProperty()) {
|
||||
return;
|
||||
}
|
||||
const keyPath = propPath.get("key");
|
||||
const key = propPath.node.computed ? getStaticPropertyKey(keyPath.node) : getStaticPropertyKey(keyPath.node);
|
||||
const valuePath = propPath.get("value");
|
||||
const pipeOrder = getPipeOrderFromValuePath(valuePath);
|
||||
if (pipeOrder) {
|
||||
candidates.push({ key, order: pipeOrder, start: stmtPath.node.start || 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stmtPath.isExpressionStatement()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exprPath = stmtPath.get("expression");
|
||||
if (!exprPath.isAssignmentExpression({ operator: "=" })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const leftPath = exprPath.get("left");
|
||||
const rightPath = exprPath.get("right");
|
||||
if (!leftPath.isMemberExpression() || !leftPath.get("object").isIdentifier() || !objectNames.has(leftPath.get("object").node.name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = leftPath.node.computed ? getStaticPropertyKey(leftPath.node.property) : getStaticPropertyKey(leftPath.node.property);
|
||||
if (key === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pipeOrder = getPipeOrderFromValuePath(rightPath);
|
||||
if (pipeOrder) {
|
||||
candidates.push({ key, order: pipeOrder, start: stmtPath.node.start || 0 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
candidates.sort((a, b) => b.start - a.start);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function getOrderFromObjectStringTable(loopPath, switchStmt) {
|
||||
const discriminant = switchStmt.get("discriminant");
|
||||
if (!discriminant.isMemberExpression() || !discriminant.get("object").isIdentifier()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = collectObjectPipeStringCandidates(loopPath, discriminant.get("object").node.name);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const caseKeys = getCaseKeys(switchStmt);
|
||||
const match = candidates.find((candidate) => sameSequenceKeySet(candidate.order, caseKeys));
|
||||
return match ? match.order : null;
|
||||
}
|
||||
|
||||
function flatten(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
ForStatement(path) {
|
||||
const body = path.get("body");
|
||||
if (!body.isBlockStatement() || body.get("body").length === 0) {
|
||||
return;
|
||||
}
|
||||
const firstStmt = body.get("body.0");
|
||||
if (firstStmt.isSwitchStatement() && path.get("init").isVariableDeclaration() && path.node.update === null) {
|
||||
let order = null;
|
||||
const leftovers = [];
|
||||
path.get("init.declarations").forEach(decl => {
|
||||
if (decl.node.init === null) {
|
||||
leftovers.push(t.variableDeclaration("var", [clone(decl.node)]));
|
||||
return;
|
||||
}
|
||||
const candidate =
|
||||
getOrderFromInit(decl.get("init")) ||
|
||||
getOrderFromSplitCallWithObjectTable(decl.get("init"), path) ||
|
||||
getOrderFromResolvedValue(resolveStatic(decl.get("init")));
|
||||
if (candidate) {
|
||||
order = candidate;
|
||||
}
|
||||
});
|
||||
if (order) {
|
||||
const caseMap = new Map();
|
||||
firstStmt.get("cases").forEach(casePath => {
|
||||
if (!casePath.node.test) {
|
||||
return;
|
||||
}
|
||||
caseMap.set(getCaseKey(casePath), getCaseStatements(casePath));
|
||||
});
|
||||
const blocks = [...leftovers];
|
||||
order.forEach(key => {
|
||||
const stmts = caseMap.get(key);
|
||||
if (stmts) {
|
||||
blocks.push(...stmts);
|
||||
}
|
||||
});
|
||||
if (blocks.length > 0) {
|
||||
path.replaceWithMultiple(blocks);
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replaceLoopSwitchWithIf(path)) {
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
WhileStatement(path) {
|
||||
if (path.get("test").isUnaryExpression() && path.get("body").isBlockStatement()) {
|
||||
const switchStmt = path.get("body.body.0");
|
||||
if (switchStmt.isSwitchStatement()) {
|
||||
const nearby = findNearbyOrderDeclaration(path);
|
||||
const order =
|
||||
(nearby && nearby.order) ||
|
||||
getOrderFromDiscriminant(switchStmt.get("discriminant")) ||
|
||||
getOrderFromObjectStringTable(path, switchStmt);
|
||||
const blocks = buildBlocksFromSwitchOrder(switchStmt, order);
|
||||
if (blocks) {
|
||||
path.replaceWithMultiple(blocks);
|
||||
if (nearby && nearby.declarationPath && nearby.declarationPath.node) {
|
||||
nearby.declarationPath.remove();
|
||||
}
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (replaceLoopSwitchWithIf(path)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = flatten(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,256 @@
|
||||
const generator = require("@babel/generator").default;
|
||||
const { clone, parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function normalizeCode(node) {
|
||||
return generator(node, {
|
||||
compact: true,
|
||||
comments: false,
|
||||
jsescOption: { minimal: true }
|
||||
}).code;
|
||||
}
|
||||
|
||||
function getCaseInfo(testNode) {
|
||||
if (!t.isBinaryExpression(testNode) || !["==", "==="].includes(testNode.operator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftLiteral = t.isLiteral(testNode.left);
|
||||
const rightLiteral = t.isLiteral(testNode.right);
|
||||
if (leftLiteral === rightLiteral) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (leftLiteral) {
|
||||
return {
|
||||
discriminant: testNode.right,
|
||||
caseValue: testNode.left
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
discriminant: testNode.left,
|
||||
caseValue: testNode.right
|
||||
};
|
||||
}
|
||||
|
||||
function toCaseStatements(node) {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
if (t.isBlockStatement(node)) {
|
||||
return node.body.map((stmt) => clone(stmt));
|
||||
}
|
||||
return [clone(node)];
|
||||
}
|
||||
|
||||
function unwrapAlternate(path) {
|
||||
if (!path.node) {
|
||||
return { kind: "empty", path: null, node: null };
|
||||
}
|
||||
if (path.isIfStatement()) {
|
||||
return { kind: "if", path, node: path.node };
|
||||
}
|
||||
if (path.isBlockStatement()) {
|
||||
const body = path.get("body");
|
||||
if (body.length === 1 && body[0].isIfStatement()) {
|
||||
return { kind: "if", path: body[0], node: body[0].node };
|
||||
}
|
||||
return { kind: "default", path, node: path.node };
|
||||
}
|
||||
return { kind: "default", path, node: path.node };
|
||||
}
|
||||
|
||||
function isTerminalStatement(node) {
|
||||
return t.isReturnStatement(node) || t.isThrowStatement(node) || t.isBreakStatement(node) || t.isContinueStatement(node);
|
||||
}
|
||||
|
||||
function collectIfChain(path) {
|
||||
if (path.parentPath.isIfStatement() && path.key === "alternate") {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentPath = path;
|
||||
let discriminantCode = null;
|
||||
let discriminantNode = null;
|
||||
const cases = [];
|
||||
let defaultStatements = null;
|
||||
|
||||
while (currentPath && currentPath.isIfStatement()) {
|
||||
const info = getCaseInfo(currentPath.node.test);
|
||||
if (!info) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentCode = normalizeCode(info.discriminant);
|
||||
if (discriminantCode === null) {
|
||||
discriminantCode = currentCode;
|
||||
discriminantNode = clone(info.discriminant);
|
||||
} else if (currentCode !== discriminantCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cases.push({
|
||||
test: clone(info.caseValue),
|
||||
statements: toCaseStatements(currentPath.node.consequent)
|
||||
});
|
||||
|
||||
const alternate = unwrapAlternate(currentPath.get("alternate"));
|
||||
if (alternate.kind === "empty") {
|
||||
break;
|
||||
}
|
||||
if (alternate.kind === "if") {
|
||||
currentPath = alternate.path;
|
||||
continue;
|
||||
}
|
||||
defaultStatements = toCaseStatements(alternate.node);
|
||||
break;
|
||||
}
|
||||
|
||||
if (cases.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
discriminant: discriminantNode,
|
||||
cases,
|
||||
defaultStatements
|
||||
};
|
||||
}
|
||||
|
||||
function buildSwitchStatementFromChain(chain) {
|
||||
const switchCases = chain.cases.map((entry) => {
|
||||
const statements = entry.statements.slice();
|
||||
if (statements.length === 0 || !isTerminalStatement(statements[statements.length - 1])) {
|
||||
statements.push(t.breakStatement());
|
||||
}
|
||||
return t.switchCase(entry.test, statements);
|
||||
});
|
||||
|
||||
if (chain.defaultStatements) {
|
||||
switchCases.push(t.switchCase(null, chain.defaultStatements));
|
||||
}
|
||||
|
||||
return t.switchStatement(chain.discriminant, switchCases);
|
||||
}
|
||||
|
||||
function collectConsecutiveIfCases(statementPaths, startIndex) {
|
||||
const firstPath = statementPaths[startIndex];
|
||||
if (!firstPath || !firstPath.isIfStatement() || firstPath.node.alternate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstInfo = getCaseInfo(firstPath.node.test);
|
||||
if (!firstInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const discriminantCode = normalizeCode(firstInfo.discriminant);
|
||||
const cases = [{
|
||||
test: clone(firstInfo.caseValue),
|
||||
statements: toCaseStatements(firstPath.node.consequent)
|
||||
}];
|
||||
|
||||
let endIndex = startIndex + 1;
|
||||
while (endIndex < statementPaths.length) {
|
||||
const nextPath = statementPaths[endIndex];
|
||||
if (!nextPath.isIfStatement() || nextPath.node.alternate) {
|
||||
break;
|
||||
}
|
||||
|
||||
const info = getCaseInfo(nextPath.node.test);
|
||||
if (!info || normalizeCode(info.discriminant) !== discriminantCode) {
|
||||
break;
|
||||
}
|
||||
|
||||
cases.push({
|
||||
test: clone(info.caseValue),
|
||||
statements: toCaseStatements(nextPath.node.consequent)
|
||||
});
|
||||
endIndex += 1;
|
||||
}
|
||||
|
||||
if (cases.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startIndex,
|
||||
endIndex,
|
||||
switchNode: buildSwitchStatementFromChain({
|
||||
discriminant: clone(firstInfo.discriminant),
|
||||
cases,
|
||||
defaultStatements: null
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function rewriteStatementList(statementPaths) {
|
||||
if (!Array.isArray(statementPaths) || statementPaths.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextBody = [];
|
||||
let changed = false;
|
||||
|
||||
for (let index = 0; index < statementPaths.length;) {
|
||||
const run = collectConsecutiveIfCases(statementPaths, index);
|
||||
if (!run) {
|
||||
nextBody.push(statementPaths[index].node);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextBody.push(run.switchNode);
|
||||
index = run.endIndex;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed ? nextBody : null;
|
||||
}
|
||||
|
||||
function rewrite(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
IfStatement(path) {
|
||||
const chain = collectIfChain(path);
|
||||
if (!chain) {
|
||||
return;
|
||||
}
|
||||
|
||||
path.replaceWith(buildSwitchStatementFromChain(chain));
|
||||
changed = true;
|
||||
},
|
||||
"Program|BlockStatement": {
|
||||
exit(path) {
|
||||
const nextBody = rewriteStatementList(path.get("body"));
|
||||
if (nextBody) {
|
||||
path.node.body = nextBody;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
SwitchCase: {
|
||||
exit(path) {
|
||||
const nextConsequent = rewriteStatementList(path.get("consequent"));
|
||||
if (nextConsequent) {
|
||||
path.node.consequent = nextConsequent;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = rewrite(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,130 @@
|
||||
const { clone, parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function collectDispatcherValue(node, dispatcherMap) {
|
||||
if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node)) {
|
||||
return { type: "literal", value: clone(node) };
|
||||
}
|
||||
if (t.isMemberExpression(node) && t.isIdentifier(node.object) && !node.computed && t.isIdentifier(node.property)) {
|
||||
return { type: "member", objectName: node.object.name, key: node.property.name };
|
||||
}
|
||||
if (t.isFunctionExpression(node) && node.body.body.length === 1 && t.isReturnStatement(node.body.body[0])) {
|
||||
const arg = node.body.body[0].argument;
|
||||
if (t.isBinaryExpression(arg) && t.isIdentifier(arg.left) && t.isIdentifier(arg.right)) {
|
||||
return { type: "binary", operator: arg.operator };
|
||||
}
|
||||
if (t.isLogicalExpression(arg) && t.isIdentifier(arg.left) && t.isIdentifier(arg.right)) {
|
||||
return { type: "logical", operator: arg.operator };
|
||||
}
|
||||
if (t.isCallExpression(arg) && t.isIdentifier(arg.callee)) {
|
||||
return { type: "call" };
|
||||
}
|
||||
if (t.isCallExpression(arg) && t.isMemberExpression(arg.callee) && t.isIdentifier(arg.callee.object) && !arg.callee.computed && t.isIdentifier(arg.callee.property)) {
|
||||
const target = dispatcherMap[arg.callee.object.name] && dispatcherMap[arg.callee.object.name][arg.callee.property.name];
|
||||
if (target && (target.type === "binary" || target.type === "logical" || target.type === "call")) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inlineDispatchers(ast) {
|
||||
let changed = false;
|
||||
const dispatcherMap = Object.create(null);
|
||||
const removableEntries = [];
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier() || !path.get("init").isObjectExpression()) {
|
||||
return;
|
||||
}
|
||||
const name = path.node.id.name;
|
||||
const collected = dispatcherMap[name] || Object.create(null);
|
||||
let localChanged = false;
|
||||
let supportedPropertyCount = 0;
|
||||
let collectedPropertyCount = 0;
|
||||
path.get("init.properties").forEach(propertyPath => {
|
||||
if (!propertyPath.isObjectProperty()) {
|
||||
return;
|
||||
}
|
||||
supportedPropertyCount += 1;
|
||||
const keyNode = propertyPath.node.key;
|
||||
const key = t.isIdentifier(keyNode) ? keyNode.name : keyNode.value;
|
||||
const value = collectDispatcherValue(propertyPath.node.value, dispatcherMap);
|
||||
if (value) {
|
||||
collected[key] = value;
|
||||
localChanged = true;
|
||||
collectedPropertyCount += 1;
|
||||
}
|
||||
});
|
||||
if (localChanged) {
|
||||
dispatcherMap[name] = collected;
|
||||
removableEntries.push({
|
||||
path,
|
||||
removable: supportedPropertyCount > 0 && supportedPropertyCount === collectedPropertyCount
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
traverse(ast, {
|
||||
CallExpression(path) {
|
||||
if (!path.get("callee").isMemberExpression()) {
|
||||
return;
|
||||
}
|
||||
const objectName = path.node.callee.object.name;
|
||||
const key = path.node.callee.computed ? path.node.callee.property.value : path.node.callee.property.name;
|
||||
const entry = dispatcherMap[objectName] && dispatcherMap[objectName][key];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
const args = path.node.arguments.map(arg => clone(arg));
|
||||
if (entry.type === "binary" && args.length >= 2) {
|
||||
path.replaceWith(t.binaryExpression(entry.operator, args[0], args[1]));
|
||||
changed = true;
|
||||
} else if (entry.type === "logical" && args.length >= 2) {
|
||||
path.replaceWith(t.logicalExpression(entry.operator, args[0], args[1]));
|
||||
changed = true;
|
||||
} else if (entry.type === "call" && args.length >= 1) {
|
||||
path.replaceWith(t.callExpression(args[0], args.slice(1)));
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
MemberExpression(path) {
|
||||
if (!path.get("object").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const objectName = path.node.object.name;
|
||||
const key = path.node.computed ? path.node.property.value : path.node.property.name;
|
||||
const entry = dispatcherMap[objectName] && dispatcherMap[objectName][key];
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
if (entry.type === "literal") {
|
||||
path.replaceWith(clone(entry.value));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
removableEntries.forEach(({ path, removable }) => {
|
||||
if (removable && !path.removed) {
|
||||
path.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = inlineDispatchers(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,190 @@
|
||||
const { clone, isPrimitiveNode, parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function getReplacementTarget(path) {
|
||||
let current = path;
|
||||
while (current.parentPath.isMemberExpression() && current.key === "object") {
|
||||
current = current.parentPath;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isUnsafeReplacementTarget(path) {
|
||||
const target = getReplacementTarget(path);
|
||||
if (target.parentPath.isUpdateExpression() && target.key === "argument") {
|
||||
return true;
|
||||
}
|
||||
if ((target.parentPath.isAssignmentExpression() || target.parentPath.isAssignmentPattern()) && target.key === "left") {
|
||||
return true;
|
||||
}
|
||||
if ((target.parentPath.isForInStatement() || target.parentPath.isForOfStatement()) && target.key === "left") {
|
||||
return true;
|
||||
}
|
||||
if (target.parentPath.isUnaryExpression({ operator: "delete" }) && target.key === "argument") {
|
||||
return true;
|
||||
}
|
||||
if (target.parentPath.isObjectProperty() && target.key === "key" && !target.parent.computed) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function replaceSafely(path, node) {
|
||||
if (isUnsafeReplacementTarget(path)) {
|
||||
return false;
|
||||
}
|
||||
path.replaceWith(node);
|
||||
return true;
|
||||
}
|
||||
|
||||
function allRefsAfter(binding, position) {
|
||||
return binding.referencePaths.every(refPath => typeof refPath.node.start !== "number" || refPath.node.start > position);
|
||||
}
|
||||
|
||||
function inlineLiterals(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
MemberExpression(path) {
|
||||
if (t.isArrayExpression(path.node.object) && path.node.object.elements.length === 0 && t.isArrayExpression(path.node.property) && path.node.property.elements.length === 0) {
|
||||
if (replaceSafely(path, t.identifier("undefined"))) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const name = path.node.id.name;
|
||||
const init = path.node.init;
|
||||
const binding = path.scope.getBinding(name);
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
if (t.isStringLiteral(init) || t.isNumericLiteral(init) || t.isBooleanLiteral(init)) {
|
||||
if (!binding.constant) {
|
||||
return;
|
||||
}
|
||||
let replacedAll = binding.referencePaths.length > 0;
|
||||
let replacedAny = false;
|
||||
binding.referencePaths.forEach(refPath => {
|
||||
if (!replaceSafely(refPath, clone(init))) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
replacedAny = true;
|
||||
});
|
||||
if (replacedAny) {
|
||||
changed = true;
|
||||
}
|
||||
if (replacedAll) {
|
||||
path.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (t.isSequenceExpression(init) && init.expressions.length > 0 && init.expressions.every(expr => t.isStringLiteral(expr))) {
|
||||
path.get("init").replaceWith(clone(init.expressions[init.expressions.length - 1]));
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
if (t.isArrayExpression(init) && init.elements.length > 0 && init.elements.every(el => el && isPrimitiveNode(el))) {
|
||||
let replacedAll = binding.referencePaths.length > 0;
|
||||
let replacedCount = 0;
|
||||
binding.referencePaths.forEach(refPath => {
|
||||
const parent = refPath.parentPath;
|
||||
if (!parent.isMemberExpression({ object: refPath.node }) || !parent.get("property").isNumericLiteral()) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
const index = parent.node.property.value;
|
||||
if (!init.elements[index]) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
if (!replaceSafely(parent, clone(init.elements[index]))) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
replacedCount += 1;
|
||||
changed = true;
|
||||
});
|
||||
if (replacedAll && replacedCount > 0) {
|
||||
path.remove();
|
||||
}
|
||||
}
|
||||
},
|
||||
AssignmentExpression(path) {
|
||||
if (!path.get("left").isIdentifier() || path.node.operator !== "=") {
|
||||
return;
|
||||
}
|
||||
if (!path.parentPath.isExpressionStatement()) {
|
||||
return;
|
||||
}
|
||||
const binding = path.scope.getBinding(path.node.left.name);
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
if (typeof path.node.start === "number" && !allRefsAfter(binding, path.node.start)) {
|
||||
return;
|
||||
}
|
||||
if (path.get("right").isStringLiteral() || path.get("right").isNumericLiteral() || path.get("right").isBooleanLiteral()) {
|
||||
const right = clone(path.node.right);
|
||||
let replacedAll = binding.referencePaths.length > 0;
|
||||
let replacedAny = false;
|
||||
binding.referencePaths.forEach(refPath => {
|
||||
if (!replaceSafely(refPath, clone(right))) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
replacedAny = true;
|
||||
});
|
||||
if (replacedAny) {
|
||||
changed = true;
|
||||
}
|
||||
if (replacedAll) {
|
||||
path.remove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (path.get("right").isArrayExpression() && path.node.right.elements.every(el => el && isPrimitiveNode(el))) {
|
||||
const elements = path.node.right.elements;
|
||||
let replacedAll = binding.referencePaths.length > 0;
|
||||
let replacedCount = 0;
|
||||
binding.referencePaths.forEach(refPath => {
|
||||
const parent = refPath.parentPath;
|
||||
if (!parent.isMemberExpression({ object: refPath.node }) || !parent.get("property").isNumericLiteral()) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
const index = parent.node.property.value;
|
||||
if (!elements[index]) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
if (!replaceSafely(parent, clone(elements[index]))) {
|
||||
replacedAll = false;
|
||||
return;
|
||||
}
|
||||
replacedCount += 1;
|
||||
changed = true;
|
||||
});
|
||||
if (replacedAll && replacedCount > 0) {
|
||||
path.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = inlineLiterals(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,211 @@
|
||||
const { parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function toStatements(expressions) {
|
||||
return expressions.map(expr => t.expressionStatement(expr));
|
||||
}
|
||||
|
||||
function hasUnsafeContinue(bodyPath) {
|
||||
let found = false;
|
||||
bodyPath.traverse({
|
||||
Function(path) {
|
||||
path.skip();
|
||||
},
|
||||
ContinueStatement(path) {
|
||||
found = true;
|
||||
path.stop();
|
||||
}
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
function ensureBlock(path) {
|
||||
if (path.isBlockStatement()) {
|
||||
return path;
|
||||
}
|
||||
path.replaceWith(t.blockStatement([path.node]));
|
||||
return path.parentPath.get(path.key);
|
||||
}
|
||||
|
||||
function replaceStatement(path, node) {
|
||||
if (path.inList) {
|
||||
path.replaceWith(node);
|
||||
return;
|
||||
}
|
||||
path.replaceWith(t.blockStatement([node]));
|
||||
}
|
||||
|
||||
function replaceStatementWithStatements(path, statements) {
|
||||
if (statements.length === 0) {
|
||||
path.remove();
|
||||
return;
|
||||
}
|
||||
if (path.inList) {
|
||||
path.replaceWithMultiple(statements);
|
||||
return;
|
||||
}
|
||||
if (statements.length === 1) {
|
||||
path.replaceWith(statements[0]);
|
||||
return;
|
||||
}
|
||||
path.replaceWith(t.blockStatement(statements));
|
||||
}
|
||||
|
||||
function normalize(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
UnaryExpression(path) {
|
||||
if (path.node.operator === "void" && path.parentPath.isExpressionStatement()) {
|
||||
path.replaceWith(path.node.argument);
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
SequenceExpression(path) {
|
||||
if (path.parentPath.isExpressionStatement()) {
|
||||
replaceStatementWithStatements(path.parentPath, toStatements(path.node.expressions));
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isReturnStatement()) {
|
||||
if (!path.parentPath.inList) {
|
||||
return;
|
||||
}
|
||||
const exprs = path.node.expressions.slice();
|
||||
const returnExpr = exprs.pop();
|
||||
path.parentPath.insertBefore(toStatements(exprs));
|
||||
path.replaceWith(returnExpr);
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isIfStatement() && path.key === "test") {
|
||||
if (!path.parentPath.inList) {
|
||||
return;
|
||||
}
|
||||
const exprs = path.node.expressions.slice();
|
||||
const testExpr = exprs.pop();
|
||||
path.parentPath.insertBefore(toStatements(exprs));
|
||||
path.replaceWith(testExpr);
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isForStatement() && path.key === "init") {
|
||||
if (!path.parentPath.inList) {
|
||||
return;
|
||||
}
|
||||
path.parentPath.insertBefore(toStatements(path.node.expressions));
|
||||
path.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isForStatement() && path.key === "test") {
|
||||
const exprs = path.node.expressions.slice();
|
||||
const testExpr = exprs.pop();
|
||||
const forPath = path.parentPath;
|
||||
const bodyPath = ensureBlock(forPath.get("body"));
|
||||
bodyPath.unshiftContainer(
|
||||
"body",
|
||||
t.ifStatement(t.unaryExpression("!", testExpr, true), t.blockStatement([t.breakStatement()]))
|
||||
);
|
||||
bodyPath.unshiftContainer("body", toStatements(exprs));
|
||||
path.remove();
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isForStatement() && path.key === "update") {
|
||||
const forPath = path.parentPath;
|
||||
const bodyPath = ensureBlock(forPath.get("body"));
|
||||
if (hasUnsafeContinue(bodyPath)) {
|
||||
return;
|
||||
}
|
||||
bodyPath.pushContainer("body", toStatements(path.node.expressions));
|
||||
path.remove();
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
ConditionalExpression(path) {
|
||||
if (path.parentPath.isExpressionStatement()) {
|
||||
replaceStatement(
|
||||
path.parentPath,
|
||||
t.ifStatement(
|
||||
path.node.test,
|
||||
t.blockStatement([t.expressionStatement(path.node.consequent)]),
|
||||
t.blockStatement([t.expressionStatement(path.node.alternate)])
|
||||
)
|
||||
);
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
LogicalExpression(path) {
|
||||
if (!path.parentPath.isExpressionStatement()) {
|
||||
return;
|
||||
}
|
||||
const body = t.blockStatement([t.expressionStatement(path.node.right)]);
|
||||
if (path.node.operator === "&&") {
|
||||
replaceStatement(path.parentPath, t.ifStatement(path.node.left, body, null));
|
||||
} else if (path.node.operator === "||") {
|
||||
replaceStatement(path.parentPath, t.ifStatement(t.unaryExpression("!", path.node.left, true), body, null));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
changed = true;
|
||||
},
|
||||
AssignmentExpression(path) {
|
||||
if (!path.parentPath.isExpressionStatement() || !t.isConditionalExpression(path.node.right)) {
|
||||
return;
|
||||
}
|
||||
const test = path.node.right.test;
|
||||
const consequent = t.expressionStatement(
|
||||
t.assignmentExpression(path.node.operator, t.cloneNode(path.node.left, true), path.node.right.consequent)
|
||||
);
|
||||
const alternate = t.expressionStatement(
|
||||
t.assignmentExpression(path.node.operator, t.cloneNode(path.node.left, true), path.node.right.alternate)
|
||||
);
|
||||
replaceStatement(path.parentPath, t.ifStatement(test, t.blockStatement([consequent]), t.blockStatement([alternate])));
|
||||
changed = true;
|
||||
},
|
||||
CallExpression(path) {
|
||||
if (!path.parentPath.isExpressionStatement()) {
|
||||
return;
|
||||
}
|
||||
if (path.get("callee").isFunctionExpression() && path.node.arguments.length === 0) {
|
||||
replaceStatementWithStatements(path.parentPath, path.node.callee.body.body);
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
path.get("callee").isMemberExpression() &&
|
||||
path.get("callee.object").isFunctionExpression() &&
|
||||
path.get("callee.property").isIdentifier({ name: "call" }) &&
|
||||
path.node.arguments.length === 1 &&
|
||||
path.get("arguments.0").isThisExpression()
|
||||
) {
|
||||
replaceStatementWithStatements(path.parentPath, path.node.callee.object.body.body);
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
BlockStatement(path) {
|
||||
if (path.parentPath.isSwitchCase()) {
|
||||
path.replaceWithMultiple(path.node.body);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = normalize(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,81 @@
|
||||
const { clone } = require("../shared");
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { getTopLevelIifeInfo, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function dingxiangArrayPass(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
Program(programPath) {
|
||||
const info = getTopLevelIifeInfo(programPath);
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actualArgs = info.argPaths.map((argPath) => argPath.node);
|
||||
const formalParams = info.calleePath.node.params;
|
||||
const paramElementMap = Object.create(null);
|
||||
|
||||
formalParams.forEach((paramNode, index) => {
|
||||
if (!t.isIdentifier(paramNode)) {
|
||||
return;
|
||||
}
|
||||
const actualNode = actualArgs[index];
|
||||
if (t.isArrayExpression(actualNode)) {
|
||||
paramElementMap[paramNode.name] = actualNode.elements.map((element) => (element ? clone(element) : t.identifier("undefined")));
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(paramElementMap).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
info.statementPath.traverse({
|
||||
UnaryExpression(path) {
|
||||
const callPath = path.get("argument");
|
||||
if (
|
||||
!callPath.isCallExpression() ||
|
||||
!callPath.get("callee").isFunctionExpression() ||
|
||||
callPath.node.arguments.length !== 2 ||
|
||||
!callPath.get("arguments").every((argPath) => argPath.isArrayExpression())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
callPath.get("callee.params").forEach((paramPath) => {
|
||||
if (!paramPath.isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const elements = paramElementMap[paramPath.node.name];
|
||||
if (!elements) {
|
||||
return;
|
||||
}
|
||||
const binding = paramPath.scope.getBinding(paramPath.node.name);
|
||||
if (!binding || !binding.constant) {
|
||||
return;
|
||||
}
|
||||
binding.referencePaths.forEach((refPath) => {
|
||||
const parent = refPath.parentPath;
|
||||
if (!parent.isMemberExpression({ object: refPath.node }) || !parent.get("property").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
const index = parent.node.property.value;
|
||||
if (!Number.isInteger(index) || index < 0 || index >= elements.length) {
|
||||
return;
|
||||
}
|
||||
parent.replaceWith(clone(elements[index]));
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
|
||||
path.replaceWithMultiple(callPath.get("callee.body").node.body.map((node) => clone(node)));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(dingxiangArrayPass);
|
||||
@@ -0,0 +1,113 @@
|
||||
const { clone } = require("../shared");
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { createSandbox, evaluateExpression, evaluateNodes, generateCode, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function geetest4GuardedPass(ast) {
|
||||
let changed = false;
|
||||
const body = ast.program.body;
|
||||
|
||||
if (body.length >= 5) {
|
||||
const sandbox = createSandbox();
|
||||
const decriptPropName = body[2] && body[2].expression && body[2].expression.left ? generateCode(body[2].expression.left) : "";
|
||||
const ctrlVarName = body[3] && body[3].expression && body[3].expression.left ? generateCode(body[3].expression.left) : "";
|
||||
|
||||
if (decriptPropName && evaluateNodes(body.slice(0, 5), sandbox)) {
|
||||
traverse(ast, {
|
||||
VariableDeclaration(path) {
|
||||
if (
|
||||
path.node.declarations.length !== 3 ||
|
||||
!path.get("declarations.0.init").node ||
|
||||
generateCode(path.get("declarations.0.init").node) !== decriptPropName
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next1 = path.getNextSibling();
|
||||
const next2 = next1 && next1.getNextSibling();
|
||||
if (!next1 || !next2 || !next1.node || !next2.node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateNames = [0, 2]
|
||||
.map((index) => path.get(`declarations.${index}.id`))
|
||||
.filter((idPath) => idPath && idPath.isIdentifier())
|
||||
.map((idPath) => idPath.node.name);
|
||||
|
||||
candidateNames.forEach((name) => {
|
||||
const binding = path.scope.getBinding(name);
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
binding.referencePaths.forEach((refPath) => {
|
||||
const parent = refPath.parentPath;
|
||||
if (!parent.isCallExpression() || parent.node.arguments.length !== 1 || !parent.get("arguments.0").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
const argValue = parent.node.arguments[0].value;
|
||||
const result = evaluateExpression(`${decriptPropName}(${argValue})`, sandbox);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
parent.replaceWith(t.valueToNode(result.value));
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
|
||||
path.remove();
|
||||
next1.remove();
|
||||
next2.remove();
|
||||
changed = true;
|
||||
},
|
||||
ForStatement(path) {
|
||||
if (
|
||||
path.node.init !== null ||
|
||||
path.node.update !== null ||
|
||||
!path.get("test").isBinaryExpression() ||
|
||||
!generateCode(path.get("test.right").node).includes(ctrlVarName) ||
|
||||
!path.get("body").isBlockStatement() ||
|
||||
path.get("body.body").length !== 1 ||
|
||||
!path.get("body.body.0").isSwitchStatement()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testNamePath = path.get("test.left");
|
||||
if (!testNamePath.isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testName = testNamePath.node.name;
|
||||
const statements = [];
|
||||
path.get("body.body.0.cases").forEach((casePath) => {
|
||||
casePath.get("consequent").forEach((stmtPath) => {
|
||||
if (stmtPath.isBreakStatement()) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
stmtPath.isExpressionStatement() &&
|
||||
stmtPath.get("expression").isAssignmentExpression() &&
|
||||
stmtPath.get("expression.left").isIdentifier({ name: testName })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
statements.push(clone(stmtPath.node));
|
||||
});
|
||||
});
|
||||
|
||||
if (statements.length > 0) {
|
||||
path.replaceWithMultiple(statements);
|
||||
const prev = path.getPrevSibling();
|
||||
if (prev && prev.node) {
|
||||
prev.remove();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(geetest4GuardedPass);
|
||||
@@ -0,0 +1,352 @@
|
||||
const vm = require("vm");
|
||||
const { clone } = require("../shared");
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { createSandbox, evaluateExpression, evaluateNodes, evaluatePreludeStatements, foldWrapperObjects, generateCode, literalNodeFromValue, replaceCallsFromSandbox, replaceMembersFromSandbox, replacePrimitiveIdentifiers, seedSandboxFromAliases, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function inlineAssignmentObjects(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclaration(path) {
|
||||
if (path.node.declarations.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const declaration = path.get("declarations.0");
|
||||
if (!declaration.get("id").isIdentifier() || !declaration.get("init").isObjectExpression({ properties: [] })) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = declaration.node.id.name;
|
||||
const binding = path.scope.getBinding(name);
|
||||
if (!binding) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectEntries = Object.create(null);
|
||||
binding.referencePaths.forEach((refPath) => {
|
||||
const memberPath = refPath.parentPath;
|
||||
const assignmentPath = memberPath && memberPath.parentPath;
|
||||
if (
|
||||
!memberPath ||
|
||||
!memberPath.isMemberExpression({ object: refPath.node }) ||
|
||||
!assignmentPath ||
|
||||
!assignmentPath.isAssignmentExpression({ left: memberPath.node, operator: "=" })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = memberPath.node.computed ? String(memberPath.node.property.value) : memberPath.node.property.name;
|
||||
objectEntries[key] = clone(assignmentPath.node.right);
|
||||
assignmentPath.remove();
|
||||
changed = true;
|
||||
});
|
||||
|
||||
const properties = Object.entries(objectEntries).map(([key, valueNode]) => t.objectProperty(t.stringLiteral(key), valueNode));
|
||||
if (properties.length > 0) {
|
||||
declaration.get("init").replaceWith(t.objectExpression(properties));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function getLeadingPreludeNodes(ast, maxStatements = 12) {
|
||||
const collected = [];
|
||||
for (const node of ast.program.body) {
|
||||
if (collected.length >= maxStatements) {
|
||||
break;
|
||||
}
|
||||
if (t.isFunctionDeclaration(node) || t.isVariableDeclaration(node)) {
|
||||
collected.push(node);
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return collected;
|
||||
}
|
||||
|
||||
function findRotationIifeNode(ast, preludeCount) {
|
||||
const node = ast.program.body[preludeCount];
|
||||
if (!node || !t.isExpressionStatement(node)) {
|
||||
return null;
|
||||
}
|
||||
const expr = node.expression;
|
||||
if (t.isCallExpression(expr) && (t.isFunctionExpression(expr.callee) || t.isArrowFunctionExpression(expr.callee))) {
|
||||
return node;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findPxSetterFunctionName(ast) {
|
||||
for (const node of ast.program.body) {
|
||||
if (!t.isFunctionDeclaration(node) || !node.id) {
|
||||
continue;
|
||||
}
|
||||
let matched = false;
|
||||
traverse(t.file(t.program([clone(node)])), {
|
||||
AssignmentExpression(path) {
|
||||
if (path.get("left").isIdentifier({ name: "PX" })) {
|
||||
matched = true;
|
||||
path.stop();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (matched) {
|
||||
return node.id.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findDecoderFunctionName(ast) {
|
||||
for (const node of ast.program.body) {
|
||||
if (t.isFunctionDeclaration(node) && node.id && node.id.name) {
|
||||
return node.id.name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectValidationTargets(ast) {
|
||||
const targets = [];
|
||||
const seen = new Set();
|
||||
|
||||
traverse(ast, {
|
||||
CallExpression(path) {
|
||||
const firstArgPath = path.get("arguments.0");
|
||||
if (
|
||||
!path.get("callee").isMemberExpression() ||
|
||||
!firstArgPath ||
|
||||
!firstArgPath.node ||
|
||||
!firstArgPath.isStringLiteral({ value: "|" })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertyPath = path.get("callee.property");
|
||||
if (!propertyPath.isCallExpression() || !propertyPath.get("arguments.0").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const argValue = propertyPath.get("arguments.0").node.value;
|
||||
const objectPath = path.get("callee.object");
|
||||
if (!objectPath.isCallExpression() || !objectPath.get("arguments.0").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderArgValue = objectPath.get("arguments.0").node.value;
|
||||
const token = `split:${argValue}:order:${orderArgValue}`;
|
||||
if (seen.has(token)) {
|
||||
return;
|
||||
}
|
||||
|
||||
seen.add(token);
|
||||
targets.push({
|
||||
argValue,
|
||||
expected: "split",
|
||||
orderArgValue
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function runCodeInSandbox(code, sandbox, timeoutMs) {
|
||||
try {
|
||||
vm.runInNewContext(code, sandbox, { timeout: timeoutMs });
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function buildDynamicDecoderSandbox(preludeCode, rotationCode, pxSetterName, pxValue) {
|
||||
const sandbox = createSandbox();
|
||||
if (!runCodeInSandbox(preludeCode, sandbox, 5000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
sandbox[pxSetterName] = function patchedPxSetter() {
|
||||
sandbox.PX = pxValue;
|
||||
return "nBfa";
|
||||
};
|
||||
|
||||
// OB variants can spend noticeably longer in the bootstrap rotation loop.
|
||||
if (!runCodeInSandbox(rotationCode, sandbox, 3000)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sandbox;
|
||||
}
|
||||
|
||||
function validateDynamicDecoderSandbox(sandbox, decoderName, targets) {
|
||||
if (!sandbox || typeof sandbox[decoderName] !== "function" || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return targets.every((target) => {
|
||||
if (sandbox[decoderName](target.argValue) !== target.expected) {
|
||||
return false;
|
||||
}
|
||||
if (typeof target.orderArgValue !== "number") {
|
||||
return true;
|
||||
}
|
||||
const orderValue = sandbox[decoderName](target.orderArgValue);
|
||||
return typeof orderValue === "string" && orderValue.includes("|");
|
||||
});
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function bootstrapDynamicDecoderSandbox(ast) {
|
||||
const preludeNodes = getLeadingPreludeNodes(ast);
|
||||
const rotationNode = findRotationIifeNode(ast, preludeNodes.length);
|
||||
const pxSetterName = findPxSetterFunctionName(ast);
|
||||
const decoderName = findDecoderFunctionName(ast);
|
||||
const targets = collectValidationTargets(ast);
|
||||
const preludeCode = preludeNodes.map((node) => generateCode(node)).join(";\n");
|
||||
const rotationCode = rotationNode ? generateCode(rotationNode) : "";
|
||||
|
||||
if (!rotationNode || !pxSetterName || !decoderName || targets.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pxCandidates = [];
|
||||
for (let pxValue = 256; pxValue <= 512; pxValue += 1) {
|
||||
pxCandidates.push(pxValue);
|
||||
}
|
||||
for (let pxValue = 0; pxValue < 256; pxValue += 1) {
|
||||
pxCandidates.push(pxValue);
|
||||
}
|
||||
|
||||
for (const pxValue of pxCandidates) {
|
||||
const sandbox = buildDynamicDecoderSandbox(preludeCode, rotationCode, pxSetterName, pxValue);
|
||||
if (!validateDynamicDecoderSandbox(sandbox, decoderName, targets)) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
sandbox,
|
||||
decoderName,
|
||||
pxValue
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function obVariantPass(ast) {
|
||||
let changed = false;
|
||||
const body = ast.program.body;
|
||||
|
||||
if (body.length >= 3 && body[2].type === "VariableDeclaration" && body[2].declarations.length > 0 && t.isIdentifier(body[2].declarations[0].id)) {
|
||||
const sandbox = createSandbox();
|
||||
const decoderName = body[2].declarations[0].id.name;
|
||||
if (evaluateNodes(body.slice(0, 3), sandbox) && typeof sandbox[decoderName] === "function") {
|
||||
ast.program.body = body.slice(3).map((node) => clone(node));
|
||||
changed = true;
|
||||
|
||||
traverse(ast, {
|
||||
CallExpression(path) {
|
||||
if (!path.get("callee").isIdentifier({ name: decoderName })) {
|
||||
return;
|
||||
}
|
||||
const result = evaluateExpression(path.toString(), sandbox);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
path.replaceWith(t.valueToNode(result.value));
|
||||
changed = true;
|
||||
},
|
||||
BinaryExpression: {
|
||||
exit(path) {
|
||||
const evaluated = path.evaluate();
|
||||
if (evaluated.confident) {
|
||||
path.replaceWith(t.valueToNode(evaluated.value));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const dynamicDecoder = bootstrapDynamicDecoderSandbox(ast);
|
||||
const prelude = dynamicDecoder ? null : evaluatePreludeStatements(ast, {
|
||||
sandbox: createSandbox(),
|
||||
maxStatements: 8,
|
||||
timeoutMs: 1500
|
||||
});
|
||||
const sandbox = dynamicDecoder ? dynamicDecoder.sandbox : prelude.sandbox;
|
||||
if (dynamicDecoder || prelude.executedCount > 0) {
|
||||
if (seedSandboxFromAliases(ast, sandbox)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (replaceCallsFromSandbox(ast, sandbox, {
|
||||
timeoutMs: 800,
|
||||
allowPredicate: (path) => path.node.arguments.every((arg) => t.isLiteral(arg))
|
||||
})) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (replaceMembersFromSandbox(ast, sandbox)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
const initPath = path.get("init");
|
||||
if (!initPath.node) {
|
||||
return;
|
||||
}
|
||||
const result = evaluateExpression(generateCode(initPath.node), sandbox, 800);
|
||||
const node = result.ok ? literalNodeFromValue(result.value) : null;
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
initPath.replaceWith(node);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (replacePrimitiveIdentifiers(ast, sandbox)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (replaceMembersFromSandbox(ast, sandbox)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (replaceCallsFromSandbox(ast, sandbox, {
|
||||
timeoutMs: 800,
|
||||
allowPredicate: (path) => path.node.arguments.every((arg) => t.isLiteral(arg))
|
||||
})) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (inlineAssignmentObjects(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
let localChanged = false;
|
||||
do {
|
||||
({ changed: localChanged } = foldWrapperObjects(ast, {
|
||||
sandbox,
|
||||
timeoutMs: 800
|
||||
}));
|
||||
if (localChanged) {
|
||||
changed = true;
|
||||
}
|
||||
} while (localChanged);
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(obVariantPass);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { inlineStringMethodCalls, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function pruneUnusedTopLevel(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
Program(path) {
|
||||
path.get("body").forEach((stmtPath) => {
|
||||
if (stmtPath.isVariableDeclaration()) {
|
||||
const removable = [];
|
||||
stmtPath.get("declarations").forEach((declPath) => {
|
||||
if (!declPath.get("id").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const binding = declPath.scope.getBinding(declPath.node.id.name);
|
||||
if (binding && !binding.referenced) {
|
||||
removable.push(declPath);
|
||||
}
|
||||
});
|
||||
if (removable.length === stmtPath.node.declarations.length && removable.length > 0) {
|
||||
stmtPath.remove();
|
||||
changed = true;
|
||||
} else if (removable.length > 0) {
|
||||
removable.forEach((declPath) => declPath.remove());
|
||||
changed = true;
|
||||
}
|
||||
} else if (stmtPath.isFunctionDeclaration() && stmtPath.node.id) {
|
||||
const binding = stmtPath.scope.getBinding(stmtPath.node.id.name);
|
||||
if (binding && !binding.referenced) {
|
||||
stmtPath.remove();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function reese84HeavyPass(ast) {
|
||||
let changed = false;
|
||||
if (inlineStringMethodCalls(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
traverse(ast, {
|
||||
BinaryExpression: {
|
||||
exit(path) {
|
||||
if (path.get("left").isStringLiteral() && path.get("right").isStringLiteral()) {
|
||||
const evaluated = path.evaluate();
|
||||
if (evaluated.confident) {
|
||||
path.replaceWith(t.valueToNode(evaluated.value));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (pruneUnusedTopLevel(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(reese84HeavyPass);
|
||||
@@ -0,0 +1,17 @@
|
||||
const { parseArgs, parseFile, reparse, saveAst } = require("../shared");
|
||||
|
||||
function runPatternPass(transform) {
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
const result = typeof transform === "function" ? transform(ast) || { ast, changed: false } : { ast, changed: false };
|
||||
if (result.changed) {
|
||||
ast = reparse(result.ast || ast);
|
||||
} else if (result.ast) {
|
||||
ast = result.ast;
|
||||
}
|
||||
saveAst(ast, outputPath);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
runPatternPass
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const {
|
||||
createSandbox,
|
||||
evaluateNodes,
|
||||
foldWrapperObjects,
|
||||
inlineStringMethodCalls,
|
||||
seedSandboxFromAliases,
|
||||
traverse,
|
||||
t
|
||||
} = require("./pattern-utils");
|
||||
|
||||
function getDecoderPreludeNodes(ast) {
|
||||
const topLevelDeclarator = ast.program.body
|
||||
.filter((node) => t.isVariableDeclaration(node))
|
||||
.flatMap((node) => node.declarations)
|
||||
.find((decl) =>
|
||||
t.isIdentifier(decl.id) &&
|
||||
(
|
||||
t.isFunctionExpression(decl.init) ||
|
||||
(t.isCallExpression(decl.init) && t.isFunctionExpression(decl.init.callee))
|
||||
)
|
||||
);
|
||||
|
||||
const rootFunction = !topLevelDeclarator ? null :
|
||||
t.isFunctionExpression(topLevelDeclarator.init) ? topLevelDeclarator.init :
|
||||
t.isCallExpression(topLevelDeclarator.init) && t.isFunctionExpression(topLevelDeclarator.init.callee) ? topLevelDeclarator.init.callee :
|
||||
null;
|
||||
|
||||
if (!rootFunction || !t.isBlockStatement(rootFunction.body)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes = [];
|
||||
for (const stmt of rootFunction.body.body) {
|
||||
if (t.isFunctionDeclaration(stmt) && stmt.id && (stmt.id.name === "t" || stmt.id.name === "u")) {
|
||||
nodes.push(stmt);
|
||||
continue;
|
||||
}
|
||||
if (t.isVariableDeclaration(stmt) && stmt.declarations.every((decl) => t.isIdentifier(decl.id) && decl.init == null)) {
|
||||
nodes.push(stmt);
|
||||
continue;
|
||||
}
|
||||
if (t.isExpressionStatement(stmt)) {
|
||||
nodes.push(stmt);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function tonghuashunOrderPass(ast) {
|
||||
let changed = false;
|
||||
let localChanged = false;
|
||||
const sandbox = createSandbox();
|
||||
const preludeNodes = getDecoderPreludeNodes(ast);
|
||||
if (preludeNodes.length > 0 && evaluateNodes(preludeNodes, sandbox, 2000)) {
|
||||
while (seedSandboxFromAliases(ast, sandbox)) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const decoderAliasNames = collectDecoderAliasNames(ast, ["t"]);
|
||||
do {
|
||||
localChanged = false;
|
||||
|
||||
if (replaceDecoderAliasCalls(ast, sandbox, decoderAliasNames)) {
|
||||
localChanged = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (inlineStringMethodCalls(ast)) {
|
||||
localChanged = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const foldResult = foldWrapperObjects(ast, {
|
||||
ignoreDynamicUsage: true
|
||||
});
|
||||
if (foldResult.changed) {
|
||||
localChanged = true;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (inlineStringMethodCalls(ast)) {
|
||||
localChanged = true;
|
||||
changed = true;
|
||||
}
|
||||
} while (localChanged);
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
function collectDecoderAliasNames(ast, seedNames) {
|
||||
const aliasNames = new Set(seedNames);
|
||||
let expanded = false;
|
||||
|
||||
do {
|
||||
expanded = false;
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier() || !path.get("init").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const sourceName = path.get("init").node.name;
|
||||
const aliasName = path.get("id").node.name;
|
||||
if (!aliasNames.has(sourceName) || aliasNames.has(aliasName)) {
|
||||
return;
|
||||
}
|
||||
aliasNames.add(aliasName);
|
||||
expanded = true;
|
||||
},
|
||||
AssignmentExpression(path) {
|
||||
if (!path.get("left").isIdentifier() || !path.get("right").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const sourceName = path.get("right").node.name;
|
||||
const aliasName = path.get("left").node.name;
|
||||
if (!aliasNames.has(sourceName) || aliasNames.has(aliasName)) {
|
||||
return;
|
||||
}
|
||||
aliasNames.add(aliasName);
|
||||
expanded = true;
|
||||
}
|
||||
});
|
||||
} while (expanded);
|
||||
|
||||
return aliasNames;
|
||||
}
|
||||
|
||||
function nodeFromPrimitiveValue(value) {
|
||||
if (typeof value === "undefined") {
|
||||
return t.identifier("undefined");
|
||||
}
|
||||
if (value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||
return t.valueToNode(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function replaceDecoderAliasCalls(ast, sandbox, aliasNames) {
|
||||
if (typeof sandbox.t !== "function" || aliasNames.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
traverse(ast, {
|
||||
CallExpression(path) {
|
||||
const calleePath = path.get("callee");
|
||||
let isDecoderAliasCall = false;
|
||||
|
||||
if (calleePath.isIdentifier()) {
|
||||
isDecoderAliasCall = aliasNames.has(calleePath.node.name);
|
||||
} else if (
|
||||
calleePath.isAssignmentExpression({ operator: "=" }) &&
|
||||
calleePath.get("left").isIdentifier() &&
|
||||
calleePath.get("right").isIdentifier()
|
||||
) {
|
||||
isDecoderAliasCall = aliasNames.has(calleePath.get("right").node.name);
|
||||
}
|
||||
|
||||
if (!isDecoderAliasCall) {
|
||||
return;
|
||||
}
|
||||
if (path.node.arguments.length !== 1 || !path.get("arguments.0").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let value;
|
||||
try {
|
||||
value = sandbox.t(path.get("arguments.0").node.value);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const literalNode = nodeFromPrimitiveValue(value);
|
||||
if (!literalNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
path.replaceWith(literalNode);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
runPatternPass(tonghuashunOrderPass);
|
||||
@@ -0,0 +1,257 @@
|
||||
const { clone } = require("../shared");
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { createSandbox, evaluateExpression, evaluateNodes, foldWrapperObjects, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function getOpcodeTestInfo(testPath) {
|
||||
if (!testPath.isBinaryExpression()) {
|
||||
return null;
|
||||
}
|
||||
if (!["===", "=="].includes(testPath.node.operator)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (testPath.get("left").isNumericLiteral() && testPath.get("right").isIdentifier()) {
|
||||
return {
|
||||
discriminant: clone(testPath.get("right").node),
|
||||
discriminantName: testPath.get("right").node.name,
|
||||
caseValue: clone(testPath.get("left").node)
|
||||
};
|
||||
}
|
||||
|
||||
if (testPath.get("right").isNumericLiteral() && testPath.get("left").isIdentifier()) {
|
||||
return {
|
||||
discriminant: clone(testPath.get("left").node),
|
||||
discriminantName: testPath.get("left").node.name,
|
||||
caseValue: clone(testPath.get("right").node)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function blockify(node) {
|
||||
if (!node) {
|
||||
return [];
|
||||
}
|
||||
if (t.isBlockStatement(node)) {
|
||||
return node.body.map((stmt) => clone(stmt));
|
||||
}
|
||||
return [clone(node)];
|
||||
}
|
||||
|
||||
function withTrailingBreak(statements) {
|
||||
if (statements.length === 0) {
|
||||
return [t.breakStatement()];
|
||||
}
|
||||
const last = statements[statements.length - 1];
|
||||
if (t.isBreakStatement(last) || t.isReturnStatement(last) || t.isThrowStatement(last) || t.isContinueStatement(last)) {
|
||||
return statements;
|
||||
}
|
||||
return [...statements, t.breakStatement()];
|
||||
}
|
||||
|
||||
function collectOpcodeCasesFromAlternate(node, discriminantName, cases) {
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.isIfStatement(node)) {
|
||||
const info = getOpcodeTestInfo({
|
||||
isBinaryExpression: () => t.isBinaryExpression(node.test),
|
||||
node: node.test,
|
||||
get(key) {
|
||||
if (key === "left") {
|
||||
return {
|
||||
isNumericLiteral: () => t.isNumericLiteral(node.test.left),
|
||||
isIdentifier: () => t.isIdentifier(node.test.left),
|
||||
node: node.test.left
|
||||
};
|
||||
}
|
||||
return {
|
||||
isNumericLiteral: () => t.isNumericLiteral(node.test.right),
|
||||
isIdentifier: () => t.isIdentifier(node.test.right),
|
||||
node: node.test.right
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (info && info.discriminantName === discriminantName) {
|
||||
cases.push(t.switchCase(info.caseValue, withTrailingBreak(blockify(node.consequent))));
|
||||
collectOpcodeCasesFromAlternate(node.alternate, discriminantName, cases);
|
||||
return;
|
||||
}
|
||||
|
||||
cases.push(t.switchCase(null, withTrailingBreak(blockify(node))));
|
||||
return;
|
||||
}
|
||||
|
||||
if (t.isBlockStatement(node)) {
|
||||
node.body.forEach((stmt) => {
|
||||
if (t.isIfStatement(stmt)) {
|
||||
const info = getOpcodeTestInfo({
|
||||
isBinaryExpression: () => t.isBinaryExpression(stmt.test),
|
||||
node: stmt.test,
|
||||
get(key) {
|
||||
if (key === "left") {
|
||||
return {
|
||||
isNumericLiteral: () => t.isNumericLiteral(stmt.test.left),
|
||||
isIdentifier: () => t.isIdentifier(stmt.test.left),
|
||||
node: stmt.test.left
|
||||
};
|
||||
}
|
||||
return {
|
||||
isNumericLiteral: () => t.isNumericLiteral(stmt.test.right),
|
||||
isIdentifier: () => t.isIdentifier(stmt.test.right),
|
||||
node: stmt.test.right
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
if (info && info.discriminantName === discriminantName) {
|
||||
cases.push(t.switchCase(info.caseValue, withTrailingBreak(blockify(stmt.consequent))));
|
||||
collectOpcodeCasesFromAlternate(stmt.alternate, discriminantName, cases);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cases.push(t.switchCase(null, withTrailingBreak([clone(stmt)])));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
cases.push(t.switchCase(null, withTrailingBreak([clone(node)])));
|
||||
}
|
||||
|
||||
function convertOpcodeIfChains(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
IfStatement(path) {
|
||||
const info = getOpcodeTestInfo(path.get("test"));
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cases = [
|
||||
t.switchCase(info.caseValue, withTrailingBreak(blockify(path.node.consequent)))
|
||||
];
|
||||
collectOpcodeCasesFromAlternate(path.node.alternate, info.discriminantName, cases);
|
||||
path.replaceWith(t.switchStatement(info.discriminant, cases));
|
||||
path.skip();
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function stripPseudoForLoops(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
ForStatement(path) {
|
||||
const testPath = path.get("test");
|
||||
if (
|
||||
!testPath.isBinaryExpression() ||
|
||||
path.node.update !== null ||
|
||||
!(
|
||||
testPath.get("left").isNumericLiteral() ||
|
||||
testPath.get("right").isNumericLiteral()
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.node.init) {
|
||||
path.replaceWith(clone(path.node.init));
|
||||
} else {
|
||||
path.remove();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function xiaohongshuWrapperPass(ast) {
|
||||
let changed = false;
|
||||
const body = ast.program.body;
|
||||
|
||||
if (
|
||||
body.length >= 3 &&
|
||||
body[1].type === "VariableDeclaration" &&
|
||||
body[1].declarations.length > 0 &&
|
||||
t.isIdentifier(body[1].declarations[0].id)
|
||||
) {
|
||||
const sandbox = createSandbox();
|
||||
const decoderName = body[1].declarations[0].id.name;
|
||||
|
||||
if (evaluateNodes(body.slice(0, 3), sandbox) && typeof sandbox[decoderName] === "function") {
|
||||
ast.program.body = body.slice(3).map((node) => clone(node));
|
||||
changed = true;
|
||||
|
||||
const aliasNames = new Set([decoderName]);
|
||||
let progress = false;
|
||||
do {
|
||||
progress = false;
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier() || !path.get("init").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
if (aliasNames.has(path.node.init.name) && !aliasNames.has(path.node.id.name)) {
|
||||
aliasNames.add(path.node.id.name);
|
||||
progress = true;
|
||||
}
|
||||
},
|
||||
CallExpression(path) {
|
||||
if (!path.get("callee").isIdentifier() || !aliasNames.has(path.node.callee.name) || path.node.arguments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
const result = evaluateExpression(`${decoderName}(${path.get("arguments.0").toString()})`, sandbox);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
path.replaceWith(t.valueToNode(result.value));
|
||||
progress = true;
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
} while (progress);
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier() || !path.get("init").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
if (aliasNames.has(path.node.id.name) && aliasNames.has(path.node.init.name)) {
|
||||
path.remove();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let localChanged = false;
|
||||
do {
|
||||
({ changed: localChanged } = foldWrapperObjects(ast));
|
||||
if (localChanged) {
|
||||
changed = true;
|
||||
}
|
||||
} while (localChanged);
|
||||
|
||||
if (convertOpcodeIfChains(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (stripPseudoForLoops(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(xiaohongshuWrapperPass);
|
||||
@@ -0,0 +1,202 @@
|
||||
const { clone } = require("../shared");
|
||||
const { runPatternPass } = require("./shared-pattern-pass");
|
||||
const { createSandbox, evaluateExpression, evaluateNodes, generateCode, traverse, t } = require("./pattern-utils");
|
||||
|
||||
function unwrapTopLevelIife(ast) {
|
||||
const first = ast.program.body[0];
|
||||
if (!first || first.type !== "ExpressionStatement" || first.expression.type !== "CallExpression") {
|
||||
return false;
|
||||
}
|
||||
const callee = first.expression.callee;
|
||||
if (callee.type !== "FunctionExpression") {
|
||||
return false;
|
||||
}
|
||||
ast.program.body = callee.body.body.map((node) => clone(node));
|
||||
return true;
|
||||
}
|
||||
|
||||
function expandSequenceExpressions(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
SequenceExpression(path) {
|
||||
if (path.parentPath.isExpressionStatement()) {
|
||||
path.replaceWithMultiple(path.node.expressions.map((expr) => t.expressionStatement(clone(expr))));
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isReturnStatement()) {
|
||||
const expressions = path.node.expressions.map((expr) => clone(expr));
|
||||
const last = expressions.pop();
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
path.parentPath.replaceWithMultiple([
|
||||
...expressions.map((expr) => t.expressionStatement(expr)),
|
||||
t.returnStatement(last)
|
||||
]);
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.parentPath.isVariableDeclarator()) {
|
||||
const declaratorPath = path.parentPath;
|
||||
const declarationPath = declaratorPath.parentPath;
|
||||
if (!declarationPath || !declarationPath.isVariableDeclaration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expressions = path.node.expressions.map((expr) => clone(expr));
|
||||
const last = expressions.pop();
|
||||
if (!last) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inserted = [
|
||||
...expressions.map((expr) => t.expressionStatement(expr)),
|
||||
t.variableDeclaration(declarationPath.node.kind, [
|
||||
t.variableDeclarator(clone(declaratorPath.node.id), last)
|
||||
])
|
||||
];
|
||||
|
||||
declarationPath.insertAfter(inserted);
|
||||
declaratorPath.remove();
|
||||
if (declarationPath.get("declarations").length === 0) {
|
||||
declarationPath.remove();
|
||||
}
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function unwrapUnaryIifes(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
UnaryExpression(path) {
|
||||
if (
|
||||
path.node.operator !== "!" ||
|
||||
!path.get("argument").isCallExpression() ||
|
||||
path.get("argument.arguments").length !== 0 ||
|
||||
!path.get("argument.callee").isFunctionExpression() ||
|
||||
path.get("argument.callee.params").length !== 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
path.replaceWithMultiple(path.get("argument.callee.body").node.body.map((node) => clone(node)));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
function yidunDispatcherPass(ast) {
|
||||
let changed = false;
|
||||
|
||||
if (unwrapTopLevelIife(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (expandSequenceExpressions(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (unwrapUnaryIifes(ast)) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const body = ast.program.body;
|
||||
if (body.length >= 2 && body[1].type === "FunctionDeclaration" && body[1].id && body[1].params.length === 2) {
|
||||
const sandbox = createSandbox();
|
||||
const decoderName = body[1].id.name;
|
||||
if (evaluateNodes(body.slice(0, 2), sandbox) && typeof sandbox[decoderName] === "function") {
|
||||
traverse(ast, {
|
||||
CallExpression(path) {
|
||||
if (!path.get("callee").isIdentifier({ name: decoderName }) || path.node.arguments.length !== 1 || !path.get("arguments.0").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
const result = evaluateExpression(path.toString(), sandbox);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
path.replaceWith(t.valueToNode(result.value));
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
ast.program.body = ast.program.body.slice(2).map((node) => clone(node));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclaration(path) {
|
||||
if (
|
||||
path.node.declarations.length !== 5 ||
|
||||
!path.get("declarations").every((declarationPath) => {
|
||||
return (
|
||||
declarationPath.get("init").isMemberExpression() &&
|
||||
declarationPath.get("init.object").isNewExpression() &&
|
||||
declarationPath.get("init.property").isStringLiteral()
|
||||
);
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prev = path.getPrevSibling();
|
||||
const next1 = path.getNextSibling();
|
||||
const next2 = next1 && next1.getNextSibling();
|
||||
if (!prev || !next1 || !next2 || !prev.node || !next1.node || !next2.node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = createSandbox();
|
||||
if (!evaluateNodes([prev.node, path.node, next1.node, next2.node], sandbox)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const names = []
|
||||
.concat(next1.node.declarations || [])
|
||||
.concat(next2.node.declarations || [])
|
||||
.map((declaration) => declaration.id)
|
||||
.filter((idNode) => t.isIdentifier(idNode))
|
||||
.map((idNode) => idNode.name);
|
||||
|
||||
names.forEach((name) => {
|
||||
const binding = path.scope.getBinding(name);
|
||||
if (!binding || !binding.constant) {
|
||||
return;
|
||||
}
|
||||
binding.referencePaths.forEach((refPath) => {
|
||||
const parent = refPath.parentPath;
|
||||
if (!parent.isMemberExpression({ object: refPath.node }) || !parent.get("property").isNumericLiteral()) {
|
||||
return;
|
||||
}
|
||||
const result = evaluateExpression(generateCode(parent.node), sandbox);
|
||||
if (!result.ok) {
|
||||
return;
|
||||
}
|
||||
parent.replaceWith(t.valueToNode(result.value));
|
||||
changed = true;
|
||||
});
|
||||
});
|
||||
|
||||
prev.remove();
|
||||
path.remove();
|
||||
next1.remove();
|
||||
next2.remove();
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
runPatternPass(yidunDispatcherPass);
|
||||
@@ -0,0 +1,304 @@
|
||||
const path = require("path");
|
||||
|
||||
function step(id, script, options = {}) {
|
||||
return {
|
||||
id,
|
||||
script,
|
||||
timeoutMs: 120000,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
const STEP_LIBRARY = {
|
||||
normalize: step("normalize", "normalize-structure.js", { timeoutMs: 60000 }),
|
||||
prune: step("prune", "prune-fake-branches.js", { timeoutMs: 60000 }),
|
||||
inlineDispatchers: step("inline_dispatchers", "inline-dispatchers.js", { timeoutMs: 120000 }),
|
||||
flatten: step("flatten", "flatten-array-control-flow.js", { timeoutMs: 180000 }),
|
||||
ifToSwitch: step("if_to_switch", "if-chain-to-switch.js", { timeoutMs: 90000 }),
|
||||
inlineLiterals: step("inline_literals", "inline-literals.js", { timeoutMs: 90000 }),
|
||||
rename: step("rename_identifiers", "rename-identifiers.js", { timeoutMs: 120000 }),
|
||||
reese84HeavyPass: step("reese84_heavy_pass", "patterns/reese84-heavy-pass.js", { timeoutMs: 30000 }),
|
||||
dingxiangArrayPass: step("dingxiang_array_pass", "patterns/dingxiang-array-pass.js", { timeoutMs: 30000 }),
|
||||
geetest4GuardedPass: step("geetest4_guarded_pass", "patterns/geetest4-guarded-pass.js", { timeoutMs: 30000 }),
|
||||
tonghuashunOrderPass: step("tonghuashun_order_pass", "patterns/tonghuashun-order-pass.js", { timeoutMs: 30000 }),
|
||||
yidunDispatcherPass: step("yidun_dispatcher_pass", "patterns/yidun-dispatcher-pass.js", { timeoutMs: 30000 }),
|
||||
xiaohongshuWrapperPass: step("xiaohongshu_wrapper_pass", "patterns/xiaohongshu-wrapper-pass.js", { timeoutMs: 30000 }),
|
||||
obVariantPass: step("ob_variant_pass", "patterns/ob-variant-pass.js", { timeoutMs: 360000 })
|
||||
};
|
||||
|
||||
const BASE_PIPELINE = [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize
|
||||
];
|
||||
|
||||
const PATTERNS = [
|
||||
{
|
||||
id: "generic",
|
||||
displayName: "generic layered pipeline",
|
||||
reference: null,
|
||||
hintTokens: [],
|
||||
contentRegexes: [],
|
||||
families: ["generic"],
|
||||
notes: [
|
||||
"Use only the generic passes.",
|
||||
"Escalate to a site adapter when residue is stable and reproducible."
|
||||
],
|
||||
steps: BASE_PIPELINE
|
||||
},
|
||||
{
|
||||
id: "reese84",
|
||||
displayName: "reese84",
|
||||
reference: "references/patterns/reese84.md",
|
||||
hintTokens: ["reese84"],
|
||||
contentRegexes: [/reese84/i],
|
||||
families: ["vm-heavy", "dispatcher-heavy"],
|
||||
notes: [
|
||||
"Skip late inline-literals by default because large reese84 samples can stall there.",
|
||||
"Prefer comparing dispatcher residue and flattened handlers against the reference decode."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.reese84HeavyPass,
|
||||
STEP_LIBRARY.rename
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "dingxiang",
|
||||
displayName: "dingxiang",
|
||||
reference: "references/patterns/dingxiang.md",
|
||||
hintTokens: ["\u9876\u8c61", "dingxiang", "dx"],
|
||||
contentRegexes: [/dingxiang/i],
|
||||
families: ["param-array", "dispatcher-heavy"],
|
||||
notes: [
|
||||
"Resolve IIFE parameter backed arrays before judging the flattener result.",
|
||||
"Do not force a second inline-literals pass unless the file stays small."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.dingxiangArrayPass,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "geetest4",
|
||||
displayName: "geetest4",
|
||||
reference: "references/patterns/geetest4.md",
|
||||
hintTokens: ["\u6781\u9a8c4", "geetest4", "geetest"],
|
||||
contentRegexes: [/geetest/i],
|
||||
families: ["guarded-switch", "vm-heavy"],
|
||||
notes: [
|
||||
"Keep the guarded-if adapter outside the generic flattener unless the pattern becomes broadly reusable.",
|
||||
"Current focus is reducing direct loop-switch residue before touching naming."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.geetest4GuardedPass,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "tonghuashun",
|
||||
displayName: "tonghuashun",
|
||||
reference: "references/patterns/tonghuashun.md",
|
||||
hintTokens: ["\u540c\u82b1\u987a", "tonghuashun", "10jqka"],
|
||||
contentRegexes: [/10jqka/i, /split\(\s*["']\|["']\s*\)/],
|
||||
families: ["split-order-source", "dispatcher-wrapper"],
|
||||
notes: [
|
||||
"Add order-source adapters around nested wrapper objects instead of bloating the generic flattener.",
|
||||
"This pipeline intentionally skips inline-literals because it is not the main blocker here."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.tonghuashunOrderPass,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "yidun",
|
||||
displayName: "yidun",
|
||||
reference: "references/patterns/yidun.md",
|
||||
hintTokens: ["\u7f51\u6613\u6613\u76fe", "yidun", "163"],
|
||||
contentRegexes: [/yidun/i],
|
||||
families: ["dispatcher-heavy"],
|
||||
notes: [
|
||||
"One inline-literals pass can still help here, but keep it early enough to avoid blowups.",
|
||||
"Compare remaining dispatcher wrappers against the reference decode."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.yidunDispatcherPass,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.inlineLiterals,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.rename
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "xiaohongshu",
|
||||
displayName: "xiaohongshu",
|
||||
reference: "references/patterns/xiaohongshu.md",
|
||||
hintTokens: ["\u5c0f\u7ea2\u4e66", "xiaohongshu", "xhs"],
|
||||
contentRegexes: [/xiaohongshu/i, /\bxhs\b/i],
|
||||
families: ["wrapper-light"],
|
||||
notes: [
|
||||
"Treat this as a lighter wrapper cleanup path.",
|
||||
"Prefer readability and structural cleanup over aggressive evaluation."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.xiaohongshuWrapperPass,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.xiaohongshuWrapperPass,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.rename
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "cn-bidding-ob",
|
||||
displayName: "cn-bidding-ob",
|
||||
reference: "references/patterns/cn-bidding-ob.md",
|
||||
hintTokens: ["\u4e2d\u56fd\u62db\u6807\u6295\u6807\u516c\u5171\u670d\u52a1\u5e73\u53f0", "\u62db\u6807\u6295\u6807", "ob"],
|
||||
contentRegexes: [/\b_0x[a-f0-9]+\b/i],
|
||||
families: ["ob-variant"],
|
||||
notes: [
|
||||
"Keep OB-family tweaks in a dedicated adapter so the common pipeline stays conservative.",
|
||||
"Track remaining loop-switch residue after the OB adapter stage."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.obVariantPass,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.obVariantPass,
|
||||
STEP_LIBRARY.normalize
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "mps-ob",
|
||||
displayName: "mps-ob",
|
||||
reference: "references/patterns/mps-ob.md",
|
||||
hintTokens: ["\u4e2d\u534e\u4eba\u6c11\u5171\u548c\u56fd\u516c\u5b89\u90e8", "\u516c\u5b89\u90e8", "mps", "ob"],
|
||||
contentRegexes: [/\b_0x[a-f0-9]+\b/i],
|
||||
families: ["ob-variant"],
|
||||
notes: [
|
||||
"Share the OB-family adapter but keep a separate rule document because the residue profile is different from the bidding sample.",
|
||||
"Compare remaining loop-switch blocks directly against the reference decode."
|
||||
],
|
||||
steps: [
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.inlineDispatchers,
|
||||
STEP_LIBRARY.obVariantPass,
|
||||
STEP_LIBRARY.flatten,
|
||||
STEP_LIBRARY.ifToSwitch,
|
||||
STEP_LIBRARY.prune,
|
||||
STEP_LIBRARY.obVariantPass,
|
||||
STEP_LIBRARY.normalize,
|
||||
STEP_LIBRARY.rename
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function normalizeHint(value) {
|
||||
return String(value || "").toLowerCase();
|
||||
}
|
||||
|
||||
function collectDetection(pattern, inputPath, hint, sourceText) {
|
||||
let score = 0;
|
||||
const reasons = [];
|
||||
const haystacks = [normalizeHint(inputPath), normalizeHint(hint)];
|
||||
for (const token of pattern.hintTokens) {
|
||||
const normalized = normalizeHint(token);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
if (haystacks.some((value) => value.includes(normalized))) {
|
||||
score += 10;
|
||||
reasons.push(`hint:${token}`);
|
||||
}
|
||||
}
|
||||
for (const regex of pattern.contentRegexes) {
|
||||
if (regex.test(sourceText)) {
|
||||
score += 3;
|
||||
reasons.push(`content:${regex}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: pattern.id,
|
||||
score,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function detectPatterns({ inputPath, hint = "", sourceText }) {
|
||||
const detections = PATTERNS
|
||||
.filter((pattern) => pattern.id !== "generic")
|
||||
.map((pattern) => collectDetection(pattern, inputPath, hint, sourceText))
|
||||
.filter((entry) => entry.score > 0)
|
||||
.sort((left, right) => right.score - left.score || left.id.localeCompare(right.id));
|
||||
|
||||
const bestId = detections.length > 0 ? detections[0].id : "generic";
|
||||
return {
|
||||
bestId,
|
||||
detections,
|
||||
selected: getPattern(bestId)
|
||||
};
|
||||
}
|
||||
|
||||
function getPattern(id) {
|
||||
return PATTERNS.find((pattern) => pattern.id === id) || PATTERNS[0];
|
||||
}
|
||||
|
||||
function buildPipeline(patternId) {
|
||||
const pattern = getPattern(patternId);
|
||||
return pattern.steps.map((entry) => ({
|
||||
...entry,
|
||||
scriptPath: path.join(__dirname, entry.script)
|
||||
}));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BASE_PIPELINE,
|
||||
PATTERNS,
|
||||
STEP_LIBRARY,
|
||||
buildPipeline,
|
||||
detectPatterns,
|
||||
getPattern
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
const { parseArgs, parseFile, reparse, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
function isLiteralLike(node) {
|
||||
return (
|
||||
t.isStringLiteral(node) ||
|
||||
t.isNumericLiteral(node) ||
|
||||
t.isBooleanLiteral(node) ||
|
||||
t.isNullLiteral(node) ||
|
||||
t.isIdentifier(node, { name: "undefined" })
|
||||
);
|
||||
}
|
||||
|
||||
function evaluateBinary(node) {
|
||||
if (!t.isBinaryExpression(node) || !["==", "===", "!=", "!=="].includes(node.operator)) {
|
||||
return null;
|
||||
}
|
||||
if (!isLiteralLike(node.left) || !isLiteralLike(node.right)) {
|
||||
return null;
|
||||
}
|
||||
const left = t.isIdentifier(node.left, { name: "undefined" }) ? undefined : node.left.value;
|
||||
const right = t.isIdentifier(node.right, { name: "undefined" }) ? undefined : node.right.value;
|
||||
switch (node.operator) {
|
||||
case "==":
|
||||
return left == right;
|
||||
case "===":
|
||||
return left === right;
|
||||
case "!=":
|
||||
return left != right;
|
||||
case "!==":
|
||||
return left !== right;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function replaceStatement(path, node) {
|
||||
if (!node) {
|
||||
path.remove();
|
||||
return;
|
||||
}
|
||||
if (t.isBlockStatement(node)) {
|
||||
path.replaceWithMultiple(node.body);
|
||||
return;
|
||||
}
|
||||
path.replaceWith(node);
|
||||
}
|
||||
|
||||
function simplify(ast) {
|
||||
let changed = false;
|
||||
|
||||
traverse(ast, {
|
||||
IfStatement: {
|
||||
exit(path) {
|
||||
let decision = evaluateBinary(path.node.test);
|
||||
if (decision === null) {
|
||||
const evaluated = path.get("test").evaluate();
|
||||
if (evaluated.confident && typeof evaluated.value === "boolean") {
|
||||
decision = evaluated.value;
|
||||
}
|
||||
}
|
||||
if (decision === null) {
|
||||
return;
|
||||
}
|
||||
replaceStatement(path, decision ? path.node.consequent : path.node.alternate);
|
||||
changed = true;
|
||||
}
|
||||
},
|
||||
ConditionalExpression(path) {
|
||||
let decision = evaluateBinary(path.node.test);
|
||||
if (decision === null) {
|
||||
const evaluated = path.get("test").evaluate();
|
||||
if (evaluated.confident && typeof evaluated.value === "boolean") {
|
||||
decision = evaluated.value;
|
||||
}
|
||||
}
|
||||
if (decision === null) {
|
||||
return;
|
||||
}
|
||||
path.replaceWith(decision ? path.node.consequent : path.node.alternate);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { ast, changed };
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
let ast = parseFile(inputPath);
|
||||
let changed = false;
|
||||
do {
|
||||
({ ast, changed } = simplify(ast));
|
||||
if (changed) {
|
||||
ast = reparse(ast);
|
||||
}
|
||||
} while (changed);
|
||||
saveAst(ast, outputPath);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
const { parseArgs, parseFile, saveAst, t, traverse } = require("./shared");
|
||||
|
||||
const typeAlias = {
|
||||
ArrayExpression: "array",
|
||||
AssignmentExpression: "assign",
|
||||
AwaitExpression: "await",
|
||||
BinaryExpression: "bin",
|
||||
BooleanLiteral: "bool",
|
||||
CallExpression: "call",
|
||||
ConditionalExpression: "cond",
|
||||
FunctionExpression: "func",
|
||||
Identifier: "id",
|
||||
LogicalExpression: "logic",
|
||||
MemberExpression: "member",
|
||||
NewExpression: "new",
|
||||
NullLiteral: "null",
|
||||
NumericLiteral: "num",
|
||||
ObjectExpression: "obj",
|
||||
SequenceExpression: "seq",
|
||||
StringLiteral: "str",
|
||||
ThisExpression: "this",
|
||||
UnaryExpression: "unary"
|
||||
};
|
||||
|
||||
function shouldRename(name) {
|
||||
return /^_0x/i.test(name) || /^[a-zA-Z]$/.test(name);
|
||||
}
|
||||
|
||||
function buildName(scopeTag, binding, init) {
|
||||
const fromInit = init && typeAlias[init.type];
|
||||
const fromRef = binding && binding.referencePaths[0] && typeAlias[binding.referencePaths[0].parentPath.type];
|
||||
const base = fromInit || fromRef || "value";
|
||||
return `${scopeTag}_${base}`;
|
||||
}
|
||||
|
||||
const { inputPath, outputPath } = parseArgs();
|
||||
const ast = parseFile(inputPath);
|
||||
|
||||
traverse(ast, {
|
||||
VariableDeclarator(path) {
|
||||
if (!path.get("id").isIdentifier()) {
|
||||
return;
|
||||
}
|
||||
const name = path.node.id.name;
|
||||
if (!shouldRename(name)) {
|
||||
return;
|
||||
}
|
||||
const binding = path.scope.getBinding(name);
|
||||
const scopeTag = path.scope.block.type === "Program" ? "glb" : "loc";
|
||||
const nextName = path.scope.generateUidIdentifier(buildName(scopeTag, binding, path.node.init)).name;
|
||||
path.scope.rename(name, nextName);
|
||||
},
|
||||
CatchClause(path) {
|
||||
if (path.node.param && t.isIdentifier(path.node.param)) {
|
||||
path.scope.rename(path.node.param.name, path.scope.generateUidIdentifier("error_msg").name);
|
||||
}
|
||||
},
|
||||
"FunctionDeclaration|FunctionExpression|ArrowFunctionExpression": {
|
||||
exit(path) {
|
||||
if (path.node.id && t.isIdentifier(path.node.id) && shouldRename(path.node.id.name)) {
|
||||
path.scope.rename(path.node.id.name, path.scope.generateUidIdentifier("fn").name);
|
||||
}
|
||||
path.node.params.forEach((param, index) => {
|
||||
if (t.isIdentifier(param) && shouldRename(param.name)) {
|
||||
path.scope.rename(param.name, `arg_${index + 1}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
saveAst(ast, outputPath);
|
||||
@@ -0,0 +1,122 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
const { buildPipeline, detectPatterns, getPattern } = require("./pipeline-config");
|
||||
const { collectMetricsFromFile } = require("./collect-residue-metrics");
|
||||
|
||||
function sanitizeStepName(name) {
|
||||
return String(name).replace(/[^a-z0-9_]+/gi, "_");
|
||||
}
|
||||
|
||||
function runStep(nodePath, step, currentPath, nextPath) {
|
||||
const start = Date.now();
|
||||
const result = spawnSync(nodePath, [step.scriptPath, currentPath, nextPath], {
|
||||
encoding: "utf8",
|
||||
env: process.env,
|
||||
timeout: step.timeoutMs
|
||||
});
|
||||
const durationMs = Date.now() - start;
|
||||
const stdout = result.stdout ? result.stdout.trim() : "";
|
||||
const stderr = result.stderr ? result.stderr.trim() : "";
|
||||
const timedOut = Boolean(result.error && result.error.code === "ETIMEDOUT");
|
||||
return {
|
||||
id: step.id,
|
||||
script: step.script,
|
||||
outputFile: path.basename(nextPath),
|
||||
timeoutMs: step.timeoutMs,
|
||||
durationMs,
|
||||
status: timedOut ? "timed_out" : result.status === 0 ? "ok" : "failed",
|
||||
exitCode: typeof result.status === "number" ? result.status : null,
|
||||
stdout,
|
||||
stderr,
|
||||
error: result.error ? String(result.error.message || result.error) : ""
|
||||
};
|
||||
}
|
||||
|
||||
function copyFile(fromPath, toPath) {
|
||||
fs.copyFileSync(fromPath, toPath);
|
||||
}
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, , inputPath, outputDir, hint = ""] = process.argv;
|
||||
if (!inputPath || !outputDir) {
|
||||
console.error("Usage: node run-pipeline.js <input.js> <output-dir> [hint]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const absoluteInput = path.resolve(inputPath);
|
||||
const absoluteOutputDir = path.resolve(outputDir);
|
||||
ensureDir(absoluteOutputDir);
|
||||
|
||||
const sourceText = fs.readFileSync(absoluteInput, "utf8");
|
||||
const detection = detectPatterns({
|
||||
inputPath: absoluteInput,
|
||||
hint,
|
||||
sourceText
|
||||
});
|
||||
const pattern = getPattern(detection.bestId);
|
||||
const pipeline = buildPipeline(pattern.id);
|
||||
|
||||
const report = {
|
||||
inputPath: absoluteInput,
|
||||
outputDir: absoluteOutputDir,
|
||||
hint,
|
||||
selectedPattern: {
|
||||
id: pattern.id,
|
||||
displayName: pattern.displayName,
|
||||
reference: pattern.reference,
|
||||
families: pattern.families,
|
||||
notes: pattern.notes
|
||||
},
|
||||
detections: detection.detections,
|
||||
steps: [],
|
||||
status: "ok",
|
||||
failedStep: null,
|
||||
lastGoodFile: "00_source.js",
|
||||
finalMetrics: null
|
||||
};
|
||||
|
||||
const sourceCopyPath = path.join(absoluteOutputDir, "00_source.js");
|
||||
copyFile(absoluteInput, sourceCopyPath);
|
||||
|
||||
let currentPath = sourceCopyPath;
|
||||
for (let index = 0; index < pipeline.length; index += 1) {
|
||||
const step = pipeline[index];
|
||||
const stepName = `${String(index + 1).padStart(2, "0")}_${sanitizeStepName(step.id)}.js`;
|
||||
const nextPath = path.join(absoluteOutputDir, stepName);
|
||||
const outcome = runStep(process.execPath, step, currentPath, nextPath);
|
||||
report.steps.push(outcome);
|
||||
if (outcome.status !== "ok") {
|
||||
report.status = outcome.status;
|
||||
report.failedStep = step.id;
|
||||
break;
|
||||
}
|
||||
currentPath = nextPath;
|
||||
report.lastGoodFile = stepName;
|
||||
}
|
||||
|
||||
const finalPath = path.join(absoluteOutputDir, "final.js");
|
||||
copyFile(currentPath, finalPath);
|
||||
report.finalMetrics = collectMetricsFromFile(finalPath);
|
||||
|
||||
fs.writeFileSync(path.join(absoluteOutputDir, "selected-patterns.json"), JSON.stringify({
|
||||
bestId: detection.bestId,
|
||||
detections: detection.detections,
|
||||
selectedPattern: report.selectedPattern
|
||||
}, null, 2), "utf8");
|
||||
fs.writeFileSync(path.join(absoluteOutputDir, "pipeline-report.json"), JSON.stringify(report, null, 2), "utf8");
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
status: report.status,
|
||||
selectedPattern: report.selectedPattern.id,
|
||||
failedStep: report.failedStep,
|
||||
lastGoodFile: report.lastGoodFile
|
||||
})}\n`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
const fs = require("fs");
|
||||
const parser = require("@babel/parser");
|
||||
const traverse = require("@babel/traverse").default;
|
||||
const generator = require("@babel/generator").default;
|
||||
const t = require("@babel/types");
|
||||
|
||||
function parseFile(filePath) {
|
||||
return parser.parse(fs.readFileSync(filePath, "utf8"), {
|
||||
sourceType: "unambiguous",
|
||||
plugins: ["jsx"]
|
||||
});
|
||||
}
|
||||
|
||||
function printAst(ast) {
|
||||
return generator(ast, {
|
||||
compact: false,
|
||||
comments: false,
|
||||
jsescOption: { minimal: true }
|
||||
}).code;
|
||||
}
|
||||
|
||||
function saveAst(ast, filePath) {
|
||||
fs.writeFileSync(filePath, printAst(ast), "utf8");
|
||||
}
|
||||
|
||||
function reparse(ast) {
|
||||
return parser.parse(printAst(ast), {
|
||||
sourceType: "unambiguous",
|
||||
plugins: ["jsx"]
|
||||
});
|
||||
}
|
||||
|
||||
function clone(node) {
|
||||
return t.cloneNode(node, true);
|
||||
}
|
||||
|
||||
function isPrimitiveNode(node) {
|
||||
return t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node) || t.isNullLiteral(node) || t.isIdentifier(node, { name: "undefined" });
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const [, , inputPath, outputPath] = process.argv;
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("Usage: node <script> <input.js> <output.js>");
|
||||
process.exit(1);
|
||||
}
|
||||
return { inputPath, outputPath };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clone,
|
||||
isPrimitiveNode,
|
||||
parseArgs,
|
||||
parseFile,
|
||||
printAst,
|
||||
reparse,
|
||||
saveAst,
|
||||
t,
|
||||
traverse
|
||||
};
|
||||
220
vendor-new/xbsReverseSkill/web-js-env-patcher/SKILL.md
Normal file
220
vendor-new/xbsReverseSkill/web-js-env-patcher/SKILL.md
Normal 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/`。
|
||||
- 用户选择不发真实请求时,只输出本地参数并明确未进行网络验证。
|
||||
- 任一门禁失败时先修复和复测,不得只在总结中声明风险后交付。
|
||||
@@ -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 矩阵闭环审计,并在真实请求前阻断所有未闭合差异。"
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 };
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 等。
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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 明文写入公开笔记。
|
||||
- 优先保存脱敏后的请求样本。
|
||||
@@ -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 指纹兼容请求客户端发起少量授权模拟请求,或按用户确认只输出本地参数。
|
||||
@@ -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 / 等待用户完成触发到验证流程
|
||||
```
|
||||
@@ -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 验证最终异步输出。
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
|
||||
检查未通过时,不要交付最终结果;先补齐记忆记录或说明用户明确豁免。
|
||||
@@ -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` |
|
||||
@@ -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 明文写入公开报告或最终交付物。
|
||||
@@ -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;动态参数建议至少三组。
|
||||
@@ -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` 证据摘要。
|
||||
|
||||
|
||||
|
||||
@@ -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 一致:
|
||||
- 失败时是否先排查资源过期:
|
||||
```
|
||||
@@ -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`,除非用户明确要求不生成。
|
||||
```
|
||||
@@ -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 仍不一致 |
|
||||
```
|
||||
@@ -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. 在授权环境中更换合规网络环境。
|
||||
|
||||
@@ -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 会访问或依赖的部分;但已补的部分必须完整真实性。
|
||||
@@ -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. 风险与后续建议
|
||||
|
||||
|
||||
|
||||
- 未确认风险:
|
||||
|
||||
- 需要补充样本:
|
||||
|
||||
- 可优化点:
|
||||
|
||||
- 后续复测建议:
|
||||
|
||||
```
|
||||
|
||||
@@ -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`。
|
||||
@@ -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 做了近似降级及原因。
|
||||
- 缺失样本是否阻塞。
|
||||
- 最终项目是否完全不包含浏览器自动化代码。
|
||||
@@ -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 中保留真实值,但不要写入公开报告或最终说明。
|
||||
@@ -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 多版本回归,以及回归样本数量。
|
||||
@@ -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 逻辑问题。
|
||||
@@ -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。
|
||||
@@ -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 指纹兼容客户端”任一项时,不得开始取证、补环境或发送真实请求。
|
||||
@@ -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 阻塞,不要伪造成功。
|
||||
- 阻塞报告、用户选择、测试用例结果必须写入阶段报告和最终总结。
|
||||
@@ -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:
|
||||
- 必需加载顺序:
|
||||
- 当前缺失:
|
||||
- 是否可以调用入口:是 / 否
|
||||
```
|
||||
@@ -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 范围、已知差异和用户确认记录。
|
||||
@@ -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 能力缺口闭环。
|
||||
@@ -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。
|
||||
@@ -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 内,任务结束前询问是否保留。
|
||||
@@ -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 清理。
|
||||
- 成功或失败后是否存在敏感状态残留。
|
||||
@@ -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 中文正常显示。
|
||||
@@ -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 或挑战结果。
|
||||
@@ -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、发送请求或宣称阶段验证通过。
|
||||
@@ -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,该循环就是默认流程。
|
||||
@@ -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 级推断,不能宣称补环境完成。
|
||||
@@ -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
@@ -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 环境检测矩阵:
|
||||
- 阻塞点:
|
||||
```
|
||||
@@ -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;不能继续真实请求验证。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user