#!/usr/bin/env python3 """ 微信 Hook 接口完整性审计 — 禁止空接口/占位冒充 检查项: 1. WECHAT_ACTIONS ↔ ACTION_TO_RPC ↔ wechat_hook_v2.js rpc.exports 三方对齐 2. ACTION_ALIASES(173 catalog)→ canonical 均在 WECHAT_ACTIONS 3. (可选)真机 hook/execute 探针 / 全量 128 探针 用法: python3 sdk/scripts/wechat_interface_audit.py python3 sdk/scripts/wechat_interface_audit.py --probe -d xgfe65eimrrofyws python3 sdk/scripts/wechat_interface_audit.py --probe-full -d xgfe65eimrrofyws """ from __future__ import annotations import argparse import json import re import sys from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[2] JS = ROOT / "sdk/agent/hook/wechat_hook_v2.js" HE = ROOT / "sdk/agent/hook/hook_executor.py" SKILL = ROOT / "sdk/app/skills/wechat/skill_v2.py" OUT = ROOT / "sdk/tmp" def _load_rpc_keys() -> set[str]: text = JS.read_text(encoding="utf-8", errors="ignore") return set(re.findall(r"^\s+(\w+)\s*:\s*function", text, re.M)) def _load_action_rpc() -> dict[str, str]: text = HE.read_text(encoding="utf-8", errors="ignore") block = text.split("ACTION_TO_RPC:", 1)[1].split("ACTION_ALIASES:", 1)[0] return dict(re.findall(r'"([a-z_]+)"\s*:\s*"(\w+)"', block)) def _load_action_aliases() -> dict[str, str]: text = HE.read_text(encoding="utf-8", errors="ignore") block = text.split("ACTION_ALIASES:", 1)[1].split("\n\n", 1)[0] return dict(re.findall(r'"([a-z_]+)"\s*:\s*"([a-z_]+)"', block)) def _load_wechat_actions() -> dict[str, str]: text = SKILL.read_text(encoding="utf-8", errors="ignore") block = text.split("WECHAT_ACTIONS = {", 1)[1].split("\n}\n", 1)[0] return dict(re.findall(r'"([a-z_]+)"\s*:\s*"(\w+)"', block)) def audit() -> dict: rpc = _load_rpc_keys() action_rpc = _load_action_rpc() aliases = _load_action_aliases() skill_actions = _load_wechat_actions() catalog = sorted(set(action_rpc) | set(aliases)) missing_rpc = sorted(set(action_rpc.values()) - rpc) skill_not_in_executor = sorted(set(skill_actions) - set(action_rpc)) # diag_* 为 Hook 内部联调诊断项(JS 有真实现),不进对外 130 action 清单,豁免对齐 executor_not_in_skill = sorted( a for a in set(action_rpc) - set(skill_actions) if not a.startswith("diag_") ) skill_rpc_mismatch = [ a for a, r in skill_actions.items() if a in action_rpc and action_rpc[a] != r ] alias_unresolved = sorted( a for a, target in aliases.items() if target not in skill_actions ) ok = not ( missing_rpc or skill_not_in_executor or skill_rpc_mismatch or executor_not_in_skill or alias_unresolved ) return { "timestamp": datetime.now().isoformat(), "rpc_exports": len(rpc), "action_to_rpc": len(action_rpc), "wechat_actions": len(skill_actions), "action_aliases": len(aliases), "catalog_actions": len(catalog), "missing_rpc_in_js": missing_rpc, "skill_not_in_executor": skill_not_in_executor, "executor_not_in_skill": executor_not_in_skill, "skill_rpc_mismatch": skill_rpc_mismatch, "alias_unresolved": alias_unresolved, "complete": ok, } def _probe_actions(device_id: str, base: str, actions: list[str], params_map: dict) -> list[dict]: import httpx rows = [] with httpx.Client(timeout=60) as c: for action in actions: params = dict(params_map.get(action, {})) r = c.post( f"{base}/api/v3/hook/execute", json={ "device_id": device_id, "platform": "wechat", "action": action, "params": params, "hook_only": True, }, ) data = r.json() ch = data.get("channel_used") or data.get("_channel_used") or data.get("channel") or "" inner = data.get("data") if isinstance(data.get("data"), dict) else {} ok = bool(inner.get("success") or data.get("success")) and "offline" not in str(ch).lower() if inner.get("success") is False: ok = False rows.append({"action": action, "ok": ok, "channel": ch, "error": inner.get("error") or inner.get("message")}) return rows def probe(device_id: str, base: str, limit: int = 20) -> list[dict]: action_rpc = _load_action_rpc() safe = ["ping", "get_wechat_version", "get_hook_status", "get_contacts", "get_profile", "get_messages"] actions = [a for a in safe if a in action_rpc][:limit] params = { "get_messages": {"conversation_id": "filehelper", "limit": 3}, "get_contacts": {"limit": 5}, } return _probe_actions(device_id, base, actions, params) def probe_full(device_id: str, base: str) -> list[dict]: sys.path.insert(0, str(ROOT / "sdk" / "scripts")) from matrix_hook_catalog_verify import SAFE_PARAMS # type: ignore actions = sorted(_load_wechat_actions().keys()) return _probe_actions(device_id, base, actions, SAFE_PARAMS) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--probe", action="store_true", help="6 项安全探针") parser.add_argument("--probe-full", action="store_true", help="128 canonical 全量探针") parser.add_argument("-d", "--device-id", default="xgfe65eimrrofyws") parser.add_argument("--base", default="http://127.0.0.1:8899") args = parser.parse_args() report = audit() OUT.mkdir(parents=True, exist_ok=True) out = OUT / f"wechat_interface_audit_{int(datetime.now().timestamp())}.json" if args.probe or args.probe_full: rows = probe_full(args.device_id, args.base) if args.probe_full else probe(args.device_id, args.base) report["probe"] = rows report["probe_pass"] = sum(1 for x in rows if x["ok"]) report["probe_total"] = len(rows) out.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") print(f"rpc.exports: {report['rpc_exports']}") print(f"ACTION_TO_RPC: {report['action_to_rpc']}") print(f"WECHAT_ACTIONS: {report['wechat_actions']}") print(f"Catalog(含alias): {report['catalog_actions']}") print(f"缺失 RPC: {len(report['missing_rpc_in_js'])}") print(f"Skill 未映射 executor: {len(report['skill_not_in_executor'])}") print(f"Alias 未解析: {len(report.get('alias_unresolved', []))}") print(f"完整对齐: {'✅' if report['complete'] else '❌'}") print(f"报告: {out}") if args.probe or args.probe_full: print(f"真机探针: {report.get('probe_pass', 0)}/{report.get('probe_total', 0)}") return 0 if report["complete"] else 1 if __name__ == "__main__": sys.exit(main())