parse_saz.py 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. """Parse a Fiddler .saz capture and extract request/response pairs.
  2. A .saz file is just a ZIP archive. Inside, each captured session has:
  3. raw/<n>_c_request.txt — client request (request line + headers + body)
  4. raw/<n>_s_response.txt — server response (status line + headers + body)
  5. raw/<n>_c_mclient.txt — metadata (client IP, timing)
  6. Usage:
  7. python scripts/parse_saz.py <file.saz> # list all sessions (method URL status)
  8. python scripts/parse_saz.py <file.saz> --kw sms # only sessions whose req/resp mentions 'sms'
  9. python scripts/parse_saz.py <file.saz> --kw phone --dump # also print full req/resp bodies
  10. python scripts/parse_saz.py <file.saz> --index 42 --dump # dump one session by number
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import io
  15. import re
  16. import sys
  17. import zipfile
  18. from pathlib import Path
  19. # Windows GBK console cannot print \ufffd etc. — force UTF-8 with replacement.
  20. if hasattr(sys.stdout, "reconfigure"):
  21. sys.stdout.reconfigure(encoding="utf-8", errors="replace")
  22. sys.stderr.reconfigure(encoding="utf-8", errors="replace")
  23. def parse_request(text: bytes) -> dict:
  24. """Split a raw HTTP request into method/url/headers/body."""
  25. # Split headers from body at first blank line
  26. parts = text.split(b"\r\n\r\n", 1)
  27. head = parts[0].decode("utf-8", errors="replace")
  28. body = parts[1] if len(parts) > 1 else b""
  29. lines = head.split("\r\n")
  30. request_line = lines[0] if lines else ""
  31. m = re.match(r"([A-Z]+)\s+(\S+)\s+HTTP", request_line)
  32. method, url = (m.group(1), m.group(2)) if m else ("?", request_line)
  33. headers: dict[str, str] = {}
  34. for ln in lines[1:]:
  35. if ":" in ln:
  36. k, v = ln.split(":", 1)
  37. headers[k.strip().lower()] = v.strip()
  38. return {"method": method, "url": url, "headers": headers, "body": body}
  39. def parse_response(text: bytes) -> dict:
  40. """Split a raw HTTP response into status/headers/body."""
  41. parts = text.split(b"\r\n\r\n", 1)
  42. head = parts[0].decode("utf-8", errors="replace")
  43. body = parts[1] if len(parts) > 1 else b""
  44. lines = head.split("\r\n")
  45. status_line = lines[0] if lines else ""
  46. m = re.match(r"HTTP/[\d.]+\s+(\d+)", status_line)
  47. status = int(m.group(1)) if m else 0
  48. headers: dict[str, str] = {}
  49. for ln in lines[1:]:
  50. if ":" in ln:
  51. k, v = ln.split(":", 1)
  52. headers[k.strip().lower()] = v.strip()
  53. # Handle chunked encoding crudely: strip chunk size lines
  54. if headers.get("transfer-encoding", "").lower() == "chunked":
  55. body = dechunk(body)
  56. return {"status": status, "headers": headers, "body": body}
  57. def dechunk(body: bytes) -> bytes:
  58. """Remove HTTP chunked-encoding frame markers."""
  59. out = io.BytesIO()
  60. pos = 0
  61. while pos < len(body):
  62. nl = body.find(b"\r\n", pos)
  63. if nl < 0:
  64. out.write(body[pos:])
  65. break
  66. try:
  67. size = int(body[pos:nl], 16)
  68. except ValueError:
  69. out.write(body[pos:])
  70. break
  71. if size == 0:
  72. break
  73. out.write(body[nl + 2: nl + 2 + size])
  74. pos = nl + 2 + size + 2 # skip chunk + trailing CRLF
  75. return out.getvalue()
  76. def maybe_decompress(resp: dict) -> bytes:
  77. body = resp["body"]
  78. enc = resp["headers"].get("content-encoding", "")
  79. if "gzip" in enc:
  80. import gzip
  81. try:
  82. return gzip.decompress(body)
  83. except Exception:
  84. return body
  85. if "br" in enc:
  86. try:
  87. import brotli
  88. return brotli.decompress(body)
  89. except ImportError:
  90. return body # brotli lib missing; raw bytes shown as-is
  91. except Exception:
  92. return body
  93. if "deflate" in enc:
  94. import zlib
  95. try:
  96. return zlib.decompress(body)
  97. except Exception:
  98. return zlib.decompress(body, -zlib.MAX_WBITS)
  99. return body
  100. def load_sessions(saz_path: Path) -> list[dict]:
  101. """Load all sessions from a .saz zip.
  102. Fiddler uses two naming conventions depending on version:
  103. raw/<n>_c_request.txt + raw/<n>_s_response.txt (newer)
  104. raw/<n>_c.txt + raw/<n>_s.txt (older / this capture)
  105. Detect which one exists and use it.
  106. """
  107. sessions: list[dict] = []
  108. with zipfile.ZipFile(saz_path) as zf:
  109. names = set(zf.namelist())
  110. # Detect naming convention
  111. req_files = sorted(
  112. (n for n in names if re.search(r"raw/(\d+)_c(_request)?\.txt$", n)),
  113. key=lambda n: int(re.search(r"(\d+)", n).group(1)),
  114. )
  115. for req_name in req_files:
  116. num = int(re.search(r"(\d+)", req_name).group(1))
  117. # Find the matching response file (either convention)
  118. resp_name = next(
  119. (n for n in names
  120. if re.search(rf"raw/{num:03d}_s(_response)?\.txt$", n)
  121. or re.search(rf"raw/{num}_s(_response)?\.txt$", n)),
  122. None,
  123. )
  124. if resp_name is None:
  125. continue
  126. try:
  127. req = parse_request(zf.read(req_name))
  128. resp = parse_response(zf.read(resp_name))
  129. except Exception as e:
  130. print(f" [warn] session {num}: parse error {e}", file=sys.stderr)
  131. continue
  132. resp_body = maybe_decompress(resp)
  133. sessions.append({
  134. "num": num,
  135. "method": req["method"],
  136. "url": req["url"],
  137. "req_headers": req["headers"],
  138. "req_body": req["body"],
  139. "status": resp["status"],
  140. "resp_headers": resp["headers"],
  141. "resp_body": resp_body,
  142. })
  143. return sessions
  144. def body_text(b: bytes, limit: int = 2000) -> str:
  145. try:
  146. return b.decode("utf-8", errors="replace")[:limit]
  147. except Exception:
  148. return repr(b[:limit])
  149. def main() -> int:
  150. parser = argparse.ArgumentParser(description="Parse Fiddler .saz capture")
  151. parser.add_argument("saz", help="Path to .saz file")
  152. parser.add_argument("--kw", default=None,
  153. help="Only show sessions where url or bodies contain this keyword")
  154. parser.add_argument("--dump", action="store_true",
  155. help="Print full request/response bodies for matched sessions")
  156. parser.add_argument("--index", type=int, default=None,
  157. help="Dump a single session by number (ignores --kw)")
  158. parser.add_argument("--limit", type=int, default=30,
  159. help="Max sessions to list (default 30)")
  160. args = parser.parse_args()
  161. saz = Path(args.saz)
  162. if not saz.exists():
  163. print(f"FAIL: {saz} not found")
  164. return 1
  165. sessions = load_sessions(saz)
  166. print(f"loaded {len(sessions)} sessions from {saz.name}\n")
  167. def matches(s: dict) -> bool:
  168. if not args.kw:
  169. return True
  170. kw = args.kw.lower()
  171. hay = (s["url"] + " " +
  172. body_text(s["req_body"], 4000) + " " +
  173. body_text(s["resp_body"], 4000)).lower()
  174. return kw in hay
  175. shown = 0
  176. for s in sessions:
  177. if args.index is not None and s["num"] != args.index:
  178. continue
  179. if not matches(s):
  180. continue
  181. shown += 1
  182. if shown > args.limit and not args.dump:
  183. print(f" ... (more hidden, use --limit)")
  184. break
  185. print(f"[{s['num']:>4}] {s['method']:<6} {s['status']:>3} {s['url'][:110]}")
  186. if args.dump or args.index is not None:
  187. print(f" --- request headers ---")
  188. for k, v in s["req_headers"].items():
  189. print(f" {k}: {v[:120]}")
  190. if s["req_body"]:
  191. print(f" --- request body ({len(s['req_body'])} bytes) ---")
  192. print(f" {body_text(s['req_body'], 1500)}")
  193. print(f" --- response body ({len(s['resp_body'])} bytes) ---")
  194. print(f" {body_text(s['resp_body'], 1500)}")
  195. print()
  196. if args.kw and shown == 0:
  197. print(f"[!] no sessions matched keyword {args.kw!r}")
  198. return 0
  199. if __name__ == "__main__":
  200. sys.exit(main())