"""解析验证码 Frida 日志,提取 705 验证页与后续请求链。 默认终端只打印脱敏摘要;完整事件写入本地 JSON,便于后续对接 CLI。 """ from __future__ import annotations import argparse import json import re import sys from collections import Counter from pathlib import Path from typing import Any from urllib.parse import parse_qsl, urlsplit ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) SECRET_RE = re.compile( r"(sig|token|ticket|key|captcha|verify|session|security|cookie|egid|did|odid|rdid|mobile|phone|visitor_st|(?:^|[_-])st(?:$|[_-]))", re.I, ) def _latest_log() -> Path: patterns = ["probe_captcha_*.log", "probe_*captcha*.log"] seen: list[Path] = [] for pat in patterns: seen.extend((ROOT / "out").glob(pat)) logs = sorted(set(seen), key=lambda p: p.stat().st_mtime, reverse=True) if not logs: raise FileNotFoundError("out 下没有 probe_captcha_*.log") return logs[0] def _read_text(path: Path) -> str: raw = path.read_bytes() if raw.startswith(b"\xff\xfe") or raw.startswith(b"\xfe\xff"): return raw.decode("utf-16", errors="replace") return raw.decode("utf-8", errors="replace") def _mask_scalar(key: str, value: Any) -> str: s = "" if value is None else str(value) s = re.sub(r"\b1\d{10}\b", "", s) s = re.sub(r"((?:code|verifyCode|验证码)[=:\"]?)(\d{4,8})", r"\1", s, flags=re.I) if SECRET_RE.search(key) and s: return f"" if len(s) > 180: return f"{s[:80]}......{s[-30:]}" return s def _mask_obj(obj: Any) -> Any: if isinstance(obj, dict): return {str(k): _mask_obj_by_key(str(k), v) for k, v in obj.items()} if isinstance(obj, list): return [_mask_obj(v) for v in obj] return _mask_scalar("", obj) def _mask_obj_by_key(key: str, value: Any) -> Any: if isinstance(value, dict): return {str(k): _mask_obj_by_key(str(k), v) for k, v in value.items()} if isinstance(value, list): return [_mask_obj_by_key(key, v) for v in value] return _mask_scalar(key, value) def _extract_urls(text: str) -> list[dict[str, Any]]: urls: list[dict[str, Any]] = [] for m in re.finditer(r"https?://[^\s\"'<>\\]+", text): raw_url = m.group(0).rstrip("),;]") try: u = urlsplit(raw_url) query = dict(parse_qsl(u.query, keep_blank_values=True)) urls.append( { "url": raw_url, "host": u.netloc, "path": u.path, "query": query, "query_keys": list(query.keys()), } ) except Exception: urls.append({"url": raw_url}) return urls def _interesting_event(ev: dict[str, Any]) -> bool: text = json.dumps(ev, ensure_ascii=False) return bool( re.search( r"captcha|verify|705|challenge|slider|captchaToken|verifyToken|captchaTicket|verifyTicket|quickLoginToken|verifyCode|mobileVerifyCode|requestMobileCode|defaultLogin|@@CAPTCHA_JS", text, re.I, ) ) def parse_log(log_path: Path) -> dict[str, Any]: events: list[dict[str, Any]] = [] tag_counts: Counter[str] = Counter() url_counts: Counter[str] = Counter() parse_errors = 0 for line_no, line in enumerate(_read_text(log_path).splitlines(), 1): if not line.startswith("@@CAPTCHA "): continue try: ev = json.loads(line[len("@@CAPTCHA ") :]) except json.JSONDecodeError: parse_errors += 1 continue tag_counts[str(ev.get("tag", ""))] += 1 if not _interesting_event(ev): continue text = json.dumps(ev, ensure_ascii=False) urls = _extract_urls(text) for u in urls: if "path" in u: url_counts[u["path"]] += 1 events.append( { "line": line_no, "tag": ev.get("tag", ""), "seq": ev.get("seq"), "ts": ev.get("ts"), "event": ev, "urls": urls, } ) return { "source_log": str(log_path), "event_count": len(events), "parse_errors": parse_errors, "tag_counts": dict(tag_counts), "url_counts": dict(url_counts), "events": events, } def masked_summary(parsed: dict[str, Any], limit: int = 80) -> dict[str, Any]: events = parsed.get("events", []) compact = [] for item in events[:limit]: ev = item.get("event", {}) compact.append( { "line": item.get("line"), "seq": item.get("seq"), "tag": item.get("tag"), "ts": item.get("ts"), "urls": _mask_obj(item.get("urls", [])), "event": _mask_obj(ev), } ) return { "source_log": parsed.get("source_log"), "event_count": parsed.get("event_count"), "parse_errors": parsed.get("parse_errors"), "top_tags": Counter(parsed.get("tag_counts", {})).most_common(40), "top_paths": Counter(parsed.get("url_counts", {})).most_common(40), "events_head": compact, } def build_parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(description="解析 Frida 验证码链日志") p.add_argument("--log", default="", help="日志路径;不传则取 out/probe_captcha_*.log 最新") p.add_argument("--out", default="out/captcha_flow_latest.json", help="完整事件 JSON 输出路径") p.add_argument("--summary-out", default="out/captcha_flow_latest.summary.json", help="脱敏摘要输出路径") p.add_argument("--show", action="store_true", help="终端打印完整事件;默认打印脱敏摘要") return p def main() -> int: args = build_parser().parse_args() log_path = Path(args.log) if args.log else _latest_log() if not log_path.is_absolute(): log_path = ROOT / log_path parsed = parse_log(log_path) out_path = Path(args.out) if not out_path.is_absolute(): out_path = ROOT / out_path out_path.parent.mkdir(parents=True, exist_ok=True) out_path.write_text(json.dumps(parsed, ensure_ascii=False, indent=2), encoding="utf-8") summary = masked_summary(parsed) summary_path = Path(args.summary_out) if not summary_path.is_absolute(): summary_path = ROOT / summary_path summary_path.parent.mkdir(parents=True, exist_ok=True) summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") print(f"[OK] full={out_path}") print(f"[OK] summary={summary_path}") print(json.dumps(parsed if args.show else summary, ensure_ascii=False, indent=2)[:12000]) return 0 if __name__ == "__main__": raise SystemExit(main())