fix(chv-muse-round12): 移植 round-11 的 Page.aria_snapshot() 纠正到 CDP broker
Playwright 1.64.1 没有 page.accessibility(Puppeteer 的 API), broker 的 snapshot 调它必走异常分支。改调 Page.aria_snapshot() (字符串);guard 的 truncate_a11y 换成字符串版 truncate_aria (179a86d 原样),detect_injection/detect_form_risk 加字符串 分支(dict 分支保留);两边 fake 与 P4 文档同步改。a11y-only 契约不变:不读原始 DOM、不走 JS eval。 测试:test_chv_muse_p2 + test_chv_muse_p4 114 OK;全量 2523 OK (skipped=10)。
This commit is contained in:
1 parent
9da74c2a78
commit
114b444554
6 files changed
+209
-129
No files matched your search
+10
-10
@@ -6,9 +6,9 @@ chv-browser.py(c61484a),P4 在此基础上重构——子代理不再直
|
||||
Playwright/Chromium,Chromium 的所有权在单元外 CDP broker
|
||||
(chv-cdp-broker.py)手里;本进程只是 broker 的客户端。
|
||||
|
||||
子代理只向单元返回页面无障碍树快照(Playwright
|
||||
accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
|
||||
在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
|
||||
子代理只向单元返回页面无障碍快照(Playwright
|
||||
Page.aria_snapshot() 的 ARIA 快照字符串,经 broker 取回),不见
|
||||
原始 DOM,不能在页面上下文跑 JS,不暴露交互操作,不开 DevTools。
|
||||
|
||||
操作(Unix socket /run/chv-browser/browser.sock,JSON 行):
|
||||
open {url} -> 经 broker 导航并返回 {title, url, a11y,
|
||||
@@ -29,7 +29,7 @@ accessibility.snapshot,经 broker 取回),不见原始 DOM,不能
|
||||
|
||||
URL 校验、快照截断、错误消毒、禁用操作表都在
|
||||
chv_browser_guard.py(纯函数模块,两脚本共用)。本层再做一次
|
||||
validate_url 与 truncate_a11y(纵深防御),回包先过泄露检查。
|
||||
validate_url 与 truncate_aria(纵深防御),回包先过泄露检查。
|
||||
|
||||
只用标准库。运行身份:系统用户 chv-browser,socket 0600
|
||||
(broker 以 root 连,不受模式限制)。
|
||||
@@ -129,10 +129,10 @@ class BrowserAgent:
|
||||
if not ok:
|
||||
return {"ok": False, "error": err}
|
||||
resp = self._call({"op": "open", "url": url})
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
|
||||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), str):
|
||||
aria, truncated = self._guard.truncate_aria(resp["a11y"])
|
||||
resp = dict(resp)
|
||||
resp["a11y"] = tree
|
||||
resp["a11y"] = aria
|
||||
resp["truncated"] = bool(resp.get("truncated")
|
||||
or truncated)
|
||||
return self._sync_paused(resp)
|
||||
@@ -141,10 +141,10 @@ class BrowserAgent:
|
||||
if self._paused:
|
||||
return self._blocked_while_paused()
|
||||
resp = self._call({"op": "snapshot"})
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), dict):
|
||||
tree, truncated = self._guard.truncate_a11y(resp["a11y"])
|
||||
if resp.get("ok") and isinstance(resp.get("a11y"), str):
|
||||
aria, truncated = self._guard.truncate_aria(resp["a11y"])
|
||||
resp = dict(resp)
|
||||
resp["a11y"] = tree
|
||||
resp["a11y"] = aria
|
||||
resp["truncated"] = bool(resp.get("truncated")
|
||||
or truncated)
|
||||
return self._sync_paused(resp)
|
||||
|
||||
@@ -32,7 +32,7 @@ socket 说话,绝不直连浏览器、绝不见 CDP 端点。
|
||||
DevTools 地址、原始 HTML。
|
||||
|
||||
Playwright 调用白名单:launch/goto/wait_for_load_state/title/
|
||||
url/accessibility.snapshot/screenshot/close + 只读监听
|
||||
url/aria_snapshot/screenshot/close + 只读监听
|
||||
on(request/download)。evaluate/add_script_tag/add_init_script/
|
||||
expose_function/set_content 等脚本注入面不在代码里出现,
|
||||
测试用 AST 扫本文件钉住。
|
||||
@@ -212,16 +212,23 @@ class CdpBroker:
|
||||
try:
|
||||
title = self._page.title()
|
||||
url = self._page.url
|
||||
tree = self._page.accessibility.snapshot()
|
||||
# round-12 移植 round-11 的 179a86d 纠正:Playwright
|
||||
# 没有 page.accessibility(Puppeteer 的 API),原生
|
||||
# 快照是 Page.aria_snapshot()(字符串);a11y 字段仍
|
||||
# 叫 a11y,值改为 ARIA 快照字符串(截断到 200 节点
|
||||
# 行/64KB)。合入 round-12 时这处遗漏,仍调不存在的
|
||||
# accessibility.snapshot(),真机上 snapshot 必走异常
|
||||
# 分支返回错误。
|
||||
aria = self._page.aria_snapshot()
|
||||
except Exception as e:
|
||||
return {"ok": False,
|
||||
"error": self._guard.sanitize_error(e)}
|
||||
tree, truncated = self._guard.truncate_a11y(tree or {})
|
||||
flags = self._guard.detect_injection(tree)
|
||||
self._form_risk = self._guard.detect_form_risk(tree)
|
||||
aria, truncated = self._guard.truncate_aria(aria or "")
|
||||
flags = self._guard.detect_injection(aria)
|
||||
self._form_risk = self._guard.detect_form_risk(aria)
|
||||
self._current_url = url
|
||||
return {"ok": True, "title": title, "url": url,
|
||||
"a11y": tree, "truncated": truncated,
|
||||
"a11y": aria, "truncated": truncated,
|
||||
"untrusted_input": True, "injection_flags": flags,
|
||||
"form_risk": self._form_risk}
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@
|
||||
validate_url P2 的 URL 规则:只 http/https,拒 localhost/
|
||||
私有地址字面量(DNS 重绑定是 Sentinel 的事)。
|
||||
check_blocklist 静态恶意站名单(browser-blocklist.txt)后缀匹配。
|
||||
truncate_a11y P2 的无障碍树截断(200 节点/64KB)。
|
||||
truncate_aria ARIA 快照字符串截断(200 节点行/64KB)。
|
||||
sanitize_error 错误信息消毒:去 HTML 标签、截断 200 字符、
|
||||
仍含尖括号即只留异常类型名——快照路径上不许有
|
||||
原始 DOM/HTML 外泄口子。
|
||||
detect_injection DOM 内提示注入启发式(中英文模式表,命中返标签;
|
||||
是规则,不是模型分类器)。
|
||||
detect_form_risk 无障碍树扫密码/支付字段。
|
||||
detect_form_risk ARIA 快照字符串(或旧 dict 树)扫密码/支付字段。
|
||||
scan_download 下载扫描钩子:一条可执行扩展名规则。
|
||||
enqueue/list/resolve_ask 文件型待审批队列(与 P3 ApprovalQueue
|
||||
同语义:pending/allowed/denied)。
|
||||
@@ -93,7 +93,7 @@ INJECTION_PATTERNS = (
|
||||
("zh_no_limit", r"(无视|绕过).{0,20}(限制|规则|审查)"),
|
||||
)
|
||||
|
||||
# 表单风险:无障碍树节点名/autocomplete 里的敏感词(小写匹配)。
|
||||
# 表单风险:ARIA 快照行里的字段名/autocomplete 里的敏感词(小写匹配)。
|
||||
PASSWORD_HINTS = ("password", "密码", "current-password",
|
||||
"new-password", "passwd", "pass word")
|
||||
PAYMENT_HINTS = ("payment", "支付", "信用卡", "银行卡", "卡号",
|
||||
@@ -152,42 +152,33 @@ def check_blocklist(host, entries):
|
||||
return False
|
||||
|
||||
|
||||
def truncate_a11y(node, max_nodes=MAX_A11Y_NODES,
|
||||
def truncate_aria(text, max_nodes=MAX_A11Y_NODES,
|
||||
max_chars=MAX_A11Y_CHARS):
|
||||
"""截断无障碍树:节点数超上限时剪掉多余子树。返回 (tree, truncated)。
|
||||
P2 原样搬入(两脚本共用,不再各存一份)。"""
|
||||
state = {"count": 0, "truncated": False}
|
||||
|
||||
def walk(n):
|
||||
if not isinstance(n, dict):
|
||||
return n
|
||||
state["count"] += 1
|
||||
if state["count"] > max_nodes:
|
||||
state["truncated"] = True
|
||||
return None
|
||||
out = {}
|
||||
for key, value in n.items():
|
||||
if key == "children" and isinstance(value, list):
|
||||
children = []
|
||||
for child in value:
|
||||
walked = walk(child)
|
||||
if walked is None:
|
||||
state["truncated"] = True
|
||||
break
|
||||
children.append(walked)
|
||||
out[key] = children
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
tree = walk(node)
|
||||
text = json.dumps(tree, ensure_ascii=False)
|
||||
if len(text) > max_chars:
|
||||
state["truncated"] = True
|
||||
tree = {"role": (node or {}).get("role"),
|
||||
"name": (node or {}).get("name"),
|
||||
"truncated_chars": True}
|
||||
return tree, state["truncated"]
|
||||
"""截断 ARIA 快照字符串:节点行(行首 "- ")超上限时剪掉多余
|
||||
行,字符超上限时截断。返回 (text, truncated)。纯函数,可单测。
|
||||
round-11 复测纠正(第二轮,179a86d):Playwright 根本没有
|
||||
page.accessibility(那是 Puppeteer 的 API),原生快照是
|
||||
Page.aria_snapshot() 返回的字符串;旧的 dict 版 truncate_a11y
|
||||
随之废止。round-12 合入时从 chv-browser.py 移植到共享 guard
|
||||
(P4 架构里快照走 chv-cdp-broker.py)。a11y 字段仍叫 a11y,
|
||||
值改为 ARIA 快照字符串。"""
|
||||
text = text or ""
|
||||
lines = text.splitlines()
|
||||
kept = []
|
||||
nodes = 0
|
||||
truncated = False
|
||||
for line in lines:
|
||||
if line.lstrip().startswith("- "):
|
||||
nodes += 1
|
||||
if nodes > max_nodes:
|
||||
truncated = True
|
||||
break
|
||||
kept.append(line)
|
||||
out = "\n".join(kept)
|
||||
if len(out) > max_chars:
|
||||
out = out[:max_chars]
|
||||
truncated = True
|
||||
return out, truncated
|
||||
|
||||
|
||||
def sanitize_error(exc):
|
||||
@@ -225,10 +216,15 @@ def _collect_text(node, out):
|
||||
def detect_injection(tree):
|
||||
"""DOM 内提示注入启发式:扫描快照全部文本,返回命中的模式标签
|
||||
列表(去重、按模式表顺序)。无命中即空列表。规则而已,不是
|
||||
模型分类器——命中只标注,不拦截。"""
|
||||
texts = []
|
||||
_collect_text(tree or {}, texts)
|
||||
blob = "\n".join(texts)
|
||||
模型分类器——命中只标注,不拦截。输入是 ARIA 快照字符串
|
||||
(round-12 移植后 broker 传的就是它);旧 dict 树仍接受,
|
||||
收集全部字符串值后同样扫描。"""
|
||||
if isinstance(tree, str):
|
||||
blob = tree
|
||||
else:
|
||||
texts = []
|
||||
_collect_text(tree or {}, texts)
|
||||
blob = "\n".join(texts)
|
||||
hits = []
|
||||
for label, pattern in INJECTION_PATTERNS:
|
||||
if re.search(pattern, blob, re.IGNORECASE):
|
||||
@@ -259,12 +255,38 @@ def _walk_form_risk(node, found):
|
||||
_walk_form_risk(child, found)
|
||||
|
||||
|
||||
_ARIA_FIELD_RE = re.compile(
|
||||
r"^\s*-\s+(textbox|textfield|combobox)\b", re.IGNORECASE)
|
||||
_ARIA_NAME_RE = re.compile(r'"([^"]{0,40})"')
|
||||
|
||||
|
||||
def _form_risk_from_aria(text, found):
|
||||
"""ARIA 快照字符串版表单风险扫描:每行行首 "- textbox/
|
||||
textfield/combobox" 的字段行,整行小写匹配敏感词(字段名与
|
||||
[autocomplete=...] 都在行内);字段名取行内第一个引号串。"""
|
||||
for line in (text or "").splitlines():
|
||||
if not _ARIA_FIELD_RE.match(line):
|
||||
continue
|
||||
blob = line.lower()
|
||||
m = _ARIA_NAME_RE.search(line)
|
||||
name = m.group(1) if m else ""
|
||||
if any(h in blob for h in PASSWORD_HINTS):
|
||||
found.setdefault("password", []).append(name)
|
||||
elif any(h in blob for h in PAYMENT_HINTS):
|
||||
found.setdefault("payment", []).append(name)
|
||||
|
||||
|
||||
def detect_form_risk(tree):
|
||||
"""扫无障碍树里的密码/支付字段。返回 {level, fields}:level 是
|
||||
"""扫快照里的密码/支付字段。返回 {level, fields}:level 是
|
||||
none/password/payment(支付优先于密码),fields 是命中的字段名
|
||||
(最多 5 个,供审批记录看,不进模型上下文原样只作标注)。"""
|
||||
(最多 5 个,供审批记录看,不进模型上下文原样只作标注)。
|
||||
输入是 ARIA 快照字符串(round-12 移植后 broker 传的就是它);
|
||||
旧 dict 树仍接受,走原来的节点遍历。"""
|
||||
found = {}
|
||||
_walk_form_risk(tree or {}, found)
|
||||
if isinstance(tree, str):
|
||||
_form_risk_from_aria(tree, found)
|
||||
else:
|
||||
_walk_form_risk(tree or {}, found)
|
||||
if found.get("payment"):
|
||||
return {"level": "payment",
|
||||
"fields": found["payment"][:5]}
|
||||
|
||||
Reference in new issue
Block a user