211 lines
6.9 KiB
Python
211 lines
6.9 KiB
Python
"""解析验证码 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", "<MOBILE>", s)
|
||
s = re.sub(r"((?:code|verifyCode|验证码)[=:\"]?)(\d{4,8})", r"\1<CODE>", s, flags=re.I)
|
||
if SECRET_RE.search(key) and s:
|
||
return f"<MASK len={len(s)}>"
|
||
if len(s) > 180:
|
||
return f"{s[:80]}...<len={len(s)}>...{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())
|