har_audit.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """Audit HAR files for service_api client drift.
  2. Filters captured browser traffic down to the API calls we care about, compares
  3. identifiers (GQL hashes, version headers) against current client constants, and
  4. lists any operations that weren't captured — with a hint for what action
  5. triggers each one.
  6. Usage:
  7. uv run python scripts/har_audit.py path/to/*.har
  8. One or more HAR files may be passed. Platform is detected from request domains;
  9. a single HAR may contain both platforms.
  10. """
  11. import json
  12. import re
  13. import sys
  14. from pathlib import Path
  15. # ── Airbnb manifest ────────────────────────────────────────────────────────
  16. # Sync with _airbnb.py: _API_KEY, _CLIENT_VERSION, _HASHES
  17. _AIRBNB_DOMAIN = 'www.airbnb.com'
  18. _AIRBNB_API_KEY = 'd306zoyjsyarp7ifhu67rjxn52tv0t20'
  19. _AIRBNB_CLIENT_VERSION = '4e93a4878415297ab95d22665c666b6602dcfcbb'
  20. _AIRBNB_OPS: dict[str, dict] = {
  21. 'getDLSHostCalendar': {
  22. 'hash': '048e22675ffa53121628d2d07b519b9b71f5dab342a1ceb75aa72d8814a00ea7',
  23. 'hint': 'load the multicalendar page',
  24. },
  25. 'EditPanelPricingSettingsMutation': {
  26. 'hash': 'e27fed4f08dd4ab4c0267c5d6c0a7e4920730deadf48f92feba9b0d08befafa4',
  27. 'hint': 'change a price on any date in the multicalendar',
  28. },
  29. 'EditPanelAvailabilityMutation': {
  30. 'hash': '2e056e7f98f8dc410a294c649adf719452980eb4d8ea95540fb1297af2637f67',
  31. 'hint': 'block or unblock a date in the multicalendar',
  32. },
  33. 'StayHostingDetailsQuery': {
  34. 'hash': '6da83b8bc9ae1b434f13938a55435118347b0e8ceb838e04c1feb84ecb587e6d',
  35. 'hint': 'open any reservation detail page',
  36. },
  37. 'getNightlyPriceTips': {
  38. 'hash': '07538bf1a5afc8171f1e7443f0007d7f020142fc7ab60df065e7e1edd1a0b29f',
  39. 'hint': 'load the multicalendar page (fires automatically)',
  40. },
  41. 'HostCalendarListingEventsQuery': {
  42. 'hash': '8bfe5f5572a25fdaff5b5790604e1de3aec5f37731fc505684022d590d06e0a8',
  43. 'hint': 'load the multicalendar page (fires automatically)',
  44. },
  45. 'ViaductGetThreadAndDataQuery': {
  46. 'hash': 'dd7478b376ad0a9709c7201b7e43cf402df011ec9d2fe4c1dff2ac418bcd9fb3',
  47. 'hint': 'open any guest message thread',
  48. },
  49. 'CreateBulkMessagesMutation': {
  50. 'hash': '94ac2c4bd07edace539dbf2b9665d9030b6dee479db345ba8a8bbc234b3bbfa3',
  51. 'hint': 'send a message in any thread',
  52. },
  53. }
  54. # ── Booking.com manifest ───────────────────────────────────────────────────
  55. # Sync with _booking.py: _gql_headers(), _msg_headers()
  56. _BOOKING_DOMAIN = 'admin.booking.com'
  57. _BOOKING_ENDPOINTS: dict[str, dict] = {
  58. 'inventory/fetch': {
  59. 'url_fragment': 'fresa/extranet/inventory/fetch',
  60. 'hint': 'load the monthly calendar',
  61. },
  62. 'inventory/update': {
  63. 'url_fragment': 'fresa/extranet/inventory/update',
  64. 'hint': 'change a price on any date',
  65. },
  66. 'get_reservations': {
  67. 'url_fragment': 'fresa/extranet/calendar/get_reservations',
  68. 'hint': 'load the reservations list',
  69. },
  70. 'updateRoomDateMutation': {
  71. 'url_fragment': 'dml/graphql.json',
  72. 'operation_name': 'updateRoomDateMutation',
  73. 'hint': 'block or unblock a date on the calendar',
  74. 'headers': {
  75. 'apollographql-client-version': 'EQSXMHMY',
  76. 'x-envoy-expected-rq-timeout-ms': '15000',
  77. },
  78. },
  79. 'conversationMessages': {
  80. 'url_fragment': 'dml/graphql.json',
  81. 'operation_name': 'conversationMessages',
  82. 'hint': 'open any guest message thread',
  83. 'headers': {
  84. 'apollographql-client-version': 'ABJaNSUc',
  85. },
  86. },
  87. 'postFreeTextMessage': {
  88. 'url_fragment': 'dml/graphql.json',
  89. 'operation_name': 'postFreeTextMessage',
  90. 'hint': 'send a message to a guest',
  91. 'headers': {
  92. 'apollographql-client-version': 'ABJaNSUc',
  93. },
  94. },
  95. }
  96. # ── HAR helpers ────────────────────────────────────────────────────────────
  97. def _req_headers(entry: dict) -> dict[str, str]:
  98. return {h['name'].lower(): h['value'] for h in entry['request']['headers']}
  99. def _req_body(entry: dict) -> dict | None:
  100. text = (entry['request'].get('postData') or {}).get('text', '')
  101. try:
  102. return json.loads(text) if text else None
  103. except (json.JSONDecodeError, ValueError):
  104. return None
  105. def _short(h: str) -> str:
  106. """Truncate a hash/token for display: first 16 chars + ellipsis."""
  107. return h[:16] + '...' if len(h) > 20 else h
  108. # ── Airbnb ─────────────────────────────────────────────────────────────────
  109. def _collect_airbnb(entries: list[dict]) -> dict[str, list[dict]]:
  110. """Return {operation_name: [entry, ...]} for Airbnb /api/v3 calls."""
  111. found: dict[str, list[dict]] = {}
  112. for e in entries:
  113. url = e['request']['url']
  114. m = re.search(r'/api/v3/([A-Za-z]+)/([0-9a-f]{64})', url)
  115. if m:
  116. op = m.group(1)
  117. found.setdefault(op, []).append(e)
  118. return found
  119. def _audit_airbnb(entries: list[dict], har_name: str) -> bool:
  120. found = _collect_airbnb(entries)
  121. if not found:
  122. return False
  123. print(f'\n{"═" * 70}')
  124. print(f' Airbnb | {har_name}')
  125. print(f'{"═" * 70}')
  126. # Operations
  127. print('\n Operations:\n')
  128. col = max(len(op) for op in _AIRBNB_OPS) + 2
  129. all_ok = True
  130. for op, spec in _AIRBNB_OPS.items():
  131. if op not in found:
  132. print(f' — {op:<{col}} not captured')
  133. print(f' {"":>{col}} → {spec["hint"]}')
  134. continue
  135. entry = found[op][0]
  136. url = entry['request']['url']
  137. m = re.search(r'/api/v3/[A-Za-z]+/([0-9a-f]{64})', url)
  138. har_hash = m.group(1) if m else '?'
  139. expected = spec['hash']
  140. if har_hash == expected:
  141. print(f' ✓ {op:<{col}} hash matches')
  142. else:
  143. all_ok = False
  144. print(f' ✗ {op:<{col}} STALE hash')
  145. print(f' {"client:":<{col+5}} {expected}')
  146. print(f' {"HAR:":<{col+5}} {har_hash} ← update _HASHES[\'{op}\']')
  147. # Global headers (from any /api/v3 call)
  148. sample_entry = next(iter(found.values()))[0]
  149. hdrs = _req_headers(sample_entry)
  150. print('\n Global headers:\n')
  151. hcol = max(len('X-Airbnb-API-Key'), len('X-Client-Version')) + 2
  152. api_key = hdrs.get('x-airbnb-api-key', '')
  153. if api_key == _AIRBNB_API_KEY:
  154. print(f' ✓ {"X-Airbnb-API-Key":<{hcol}} matches')
  155. else:
  156. all_ok = False
  157. print(f' ✗ {"X-Airbnb-API-Key":<{hcol}} MISMATCH')
  158. print(f' client: {_AIRBNB_API_KEY}')
  159. print(f' HAR: {api_key} ← update _API_KEY')
  160. cv = hdrs.get('x-client-version', '')
  161. if cv == _AIRBNB_CLIENT_VERSION:
  162. print(f' ✓ {"X-Client-Version":<{hcol}} matches')
  163. else:
  164. all_ok = False
  165. print(f' ✗ {"X-Client-Version":<{hcol}} stale')
  166. print(f' client: {_AIRBNB_CLIENT_VERSION}')
  167. print(f' HAR: {cv} ← update _CLIENT_VERSION')
  168. missing = [op for op in _AIRBNB_OPS if op not in found]
  169. if not missing:
  170. print('\n All operations captured.')
  171. else:
  172. print(f'\n {len(missing)} operation(s) not captured — to get full coverage:')
  173. printed_hints: set[str] = set()
  174. for op in missing:
  175. hint = _AIRBNB_OPS[op]['hint']
  176. if hint not in printed_hints:
  177. print(f' • {hint}')
  178. printed_hints.add(hint)
  179. return True
  180. # ── Booking.com ────────────────────────────────────────────────────────────
  181. def _collect_booking(entries: list[dict]) -> dict[str, list[dict]]:
  182. """Return {endpoint_key: [entry, ...]} for Booking.com API calls."""
  183. found: dict[str, list[dict]] = {}
  184. for e in entries:
  185. url = e['request']['url']
  186. if _BOOKING_DOMAIN not in url:
  187. continue
  188. body = _req_body(e)
  189. body_op = (body or {}).get('operationName')
  190. for key, spec in _BOOKING_ENDPOINTS.items():
  191. if spec['url_fragment'] not in url:
  192. continue
  193. op_name = spec.get('operation_name')
  194. if op_name and body_op != op_name:
  195. continue
  196. found.setdefault(key, []).append(e)
  197. return found
  198. def _audit_booking(entries: list[dict], har_name: str) -> bool:
  199. found = _collect_booking(entries)
  200. if not found:
  201. return False
  202. print(f'\n{"═" * 70}')
  203. print(f' Booking.com | {har_name}')
  204. print(f'{"═" * 70}')
  205. print('\n Endpoints:\n')
  206. col = max(len(k) for k in _BOOKING_ENDPOINTS) + 2
  207. all_ok = True
  208. for key, spec in _BOOKING_ENDPOINTS.items():
  209. if key not in found:
  210. print(f' — {key:<{col}} not captured')
  211. print(f' {"":>{col}} → {spec["hint"]}')
  212. continue
  213. entry = found[key][0]
  214. hdrs = _req_headers(entry)
  215. expected_headers = spec.get('headers', {})
  216. mismatches: list[tuple[str, str, str]] = []
  217. for hname, hexpected in expected_headers.items():
  218. hactual = hdrs.get(hname, '')
  219. if hactual != hexpected:
  220. mismatches.append((hname, hexpected, hactual))
  221. if not mismatches:
  222. print(f' ✓ {key:<{col}} found')
  223. else:
  224. all_ok = False
  225. print(f' ✗ {key:<{col}} found with stale headers')
  226. for hname, hexpected, hactual in mismatches:
  227. print(f' {hname}')
  228. print(f' client: {hexpected}')
  229. src = '_gql_headers()' if 'availability' in key.lower() or 'room' in key.lower() else '_msg_headers()'
  230. print(f' HAR: {hactual} ← update {src}')
  231. missing = [k for k in _BOOKING_ENDPOINTS if k not in found]
  232. if not missing:
  233. print('\n All endpoints captured.')
  234. else:
  235. print(f'\n {len(missing)} endpoint(s) not captured — to get full coverage:')
  236. printed_hints: set[str] = set()
  237. for key in missing:
  238. hint = _BOOKING_ENDPOINTS[key]['hint']
  239. if hint not in printed_hints:
  240. print(f' • {hint}')
  241. printed_hints.add(hint)
  242. return True
  243. # ── Entry point ────────────────────────────────────────────────────────────
  244. def audit_har(path: Path) -> None:
  245. try:
  246. har = json.loads(path.read_text(encoding='utf-8'))
  247. except Exception as e:
  248. print(f'Error reading {path}: {e}', file=sys.stderr)
  249. return
  250. entries = har.get('log', {}).get('entries', [])
  251. if not entries:
  252. print(f'No entries in {path.name}', file=sys.stderr)
  253. return
  254. # Detect which platforms appear in this HAR
  255. domains = {
  256. e['request']['url'].split('/')[2]
  257. for e in entries
  258. if e['request']['url'].startswith('http')
  259. }
  260. name = path.name
  261. found_any = False
  262. if _AIRBNB_DOMAIN in domains:
  263. found_any |= _audit_airbnb(entries, name)
  264. if _BOOKING_DOMAIN in domains:
  265. found_any |= _audit_booking(entries, name)
  266. if not found_any:
  267. known = f'{_AIRBNB_DOMAIN}, {_BOOKING_DOMAIN}'
  268. print(f'\n{path.name}: no recognised API calls (expected domains: {known})')
  269. def main() -> None:
  270. paths = sys.argv[1:]
  271. if not paths:
  272. print(__doc__)
  273. sys.exit(1)
  274. for p in paths:
  275. audit_har(Path(p))
  276. print()
  277. if __name__ == '__main__':
  278. main()