proxy2model 2.0.0__tar.gz → 2.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {proxy2model-2.0.0/src/proxy2model.egg-info → proxy2model-2.0.1}/PKG-INFO +1 -1
  2. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/__init__.py +1 -1
  3. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/browse.py +1 -0
  4. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/live.py +30 -4
  5. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/parse.py +56 -25
  6. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/redact.py +17 -2
  7. {proxy2model-2.0.0 → proxy2model-2.0.1/src/proxy2model.egg-info}/PKG-INFO +1 -1
  8. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/SOURCES.txt +1 -0
  9. proxy2model-2.0.1/tests/test_capture_wide_masking.py +93 -0
  10. {proxy2model-2.0.0 → proxy2model-2.0.1}/LICENSE +0 -0
  11. {proxy2model-2.0.0 → proxy2model-2.0.1}/MANIFEST.in +0 -0
  12. {proxy2model-2.0.0 → proxy2model-2.0.1}/README.md +0 -0
  13. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-ai.svg +0 -0
  14. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-capture.svg +0 -0
  15. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-crawling.svg +0 -0
  16. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-model.svg +0 -0
  17. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-querying.svg +0 -0
  18. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/hero.svg +0 -0
  19. {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/ledger.svg +0 -0
  20. {proxy2model-2.0.0 → proxy2model-2.0.1}/pyproject.toml +0 -0
  21. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/burp-history-admin-sample.xml +0 -0
  22. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/burp-history-sample.xml +0 -0
  23. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-accountant.xml +0 -0
  24. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-admin.xml +0 -0
  25. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-anon.xml +0 -0
  26. {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-user.xml +0 -0
  27. {proxy2model-2.0.0 → proxy2model-2.0.1}/setup.cfg +0 -0
  28. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/__main__.py +0 -0
  29. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/atomic.py +0 -0
  30. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/__init__.py +0 -0
  31. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/analyze.py +0 -0
  32. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/app.py +0 -0
  33. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/build.py +0 -0
  34. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/common.py +0 -0
  35. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/history.py +0 -0
  36. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/mcp.py +0 -0
  37. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/ui.py +0 -0
  38. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/__init__.py +0 -0
  39. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/changes.py +0 -0
  40. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/context.py +0 -0
  41. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/diff.py +0 -0
  42. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/domains.py +0 -0
  43. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/graph.py +0 -0
  44. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/methodology.py +0 -0
  45. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/model.py +0 -0
  46. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/stack.py +0 -0
  47. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/LICENSES.md +0 -0
  48. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  49. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  50. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-200.woff2 +0 -0
  51. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-300.woff2 +0 -0
  52. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-400.woff2 +0 -0
  53. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-500.woff2 +0 -0
  54. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-700.woff2 +0 -0
  55. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/public_suffix_list.dat +0 -0
  56. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/__init__.py +0 -0
  57. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/bql.py +0 -0
  58. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/bql_js.py +0 -0
  59. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/store.py +0 -0
  60. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/export/__init__.py +0 -0
  61. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/export/openapi.py +0 -0
  62. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/__init__.py +0 -0
  63. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/browse.py +0 -0
  64. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/browser.py +0 -0
  65. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/crawl.py +0 -0
  66. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/extract.py +0 -0
  67. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/infer.py +0 -0
  68. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/journey.py +0 -0
  69. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/py.typed +0 -0
  70. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/recon/__init__.py +0 -0
  71. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/recon/osint.py +0 -0
  72. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/__init__.py +0 -0
  73. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/animate.py +0 -0
  74. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/dash.py +0 -0
  75. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/fonts.py +0 -0
  76. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/html.py +0 -0
  77. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/inventory.py +0 -0
  78. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/map.py +0 -0
  79. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/pages.py +0 -0
  80. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/query.py +0 -0
  81. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/shot.py +0 -0
  82. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/view.py +0 -0
  83. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/dependency_links.txt +0 -0
  84. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/entry_points.txt +0 -0
  85. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/requires.txt +0 -0
  86. {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/top_level.txt +0 -0
  87. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_bql_parity.py +0 -0
  88. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_browse.py +0 -0
  89. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_browser_crawl.py +0 -0
  90. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_caido.py +0 -0
  91. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_cli.py +0 -0
  92. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_crawl.py +0 -0
  93. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_diff.py +0 -0
  94. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_domains.py +0 -0
  95. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_gaps.py +0 -0
  96. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_graph.py +0 -0
  97. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_har.py +0 -0
  98. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_hardening.py +0 -0
  99. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_journey.py +0 -0
  100. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_juiceshop_sample.py +0 -0
  101. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_leads.py +0 -0
  102. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_logger_csv.py +0 -0
  103. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_map.py +0 -0
  104. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_mcp.py +0 -0
  105. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_methodology.py +0 -0
  106. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_openapi.py +0 -0
  107. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_proxy2model.py +0 -0
  108. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_recon_cmds.py +0 -0
  109. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_redact_credentials.py +0 -0
  110. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_redaction_realistic.py +0 -0
  111. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_report_ui.py +0 -0
  112. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_routes_areas.py +0 -0
  113. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_stack.py +0 -0
  114. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_store_bql.py +0 -0
  115. {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_v1.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy2model
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Turn captured proxy traffic into an evidence-backed model of a web app.
5
5
  Author: falc0n
6
6
  License-Expression: Apache-2.0
@@ -1,5 +1,5 @@
1
1
  """proxy2model — turn captured proxy traffic into an evidence-backed web-app model."""
2
- __version__ = "2.0.0"
2
+ __version__ = "2.0.1"
3
3
 
4
4
  import os as _os
5
5
 
@@ -226,6 +226,7 @@ def cmd_browse(args) -> int:
226
226
  if recorder.error:
227
227
  print(ui.warn(f"recording stopped early: {recorder.error}"), file=sys.stderr)
228
228
  live.flush()
229
+ live.finish()
229
230
  if not live.saved:
230
231
  print(f"\n nothing in scope was recorded ({', '.join(scope)})")
231
232
  return 1
@@ -2,7 +2,9 @@
2
2
  Live capture: exchanges recorded while you browse are masked as they arrive, appended to
3
3
  `inputs/browse.jsonl`, and the model is rebuilt every few seconds while anything new comes in.
4
4
 
5
- Only the masked record is ever written; a restart continues where the last session stopped.
5
+ Only the masked record is ever written; a restart continues where the last session stopped. The cookie
6
+ and credential values seen during the session are held in memory only, so a value a page script sent
7
+ before its cookie was set is masked when the batch is saved, or at the latest when the session ends.
6
8
  """
7
9
 
8
10
  from __future__ import annotations
@@ -13,7 +15,7 @@ import threading
13
15
  import time
14
16
  from xml.etree.ElementTree import Element
15
17
 
16
- from .parse import Exchange, parse_elements
18
+ from .parse import Exchange, _scrubber, parse_elements
17
19
 
18
20
 
19
21
  class Live:
@@ -27,6 +29,7 @@ class Live:
27
29
  self.stats = {"items": 0, "parsed": 0, "skipped": 0}
28
30
  self.received = 0
29
31
  self.saved = 0
32
+ self.known: set[str] = set() # raw values, never written
30
33
  if os.path.exists(path):
31
34
  with open(path, encoding="utf-8") as f:
32
35
  self.saved = sum(1 for line in f if line.strip() and '"_stats"' not in line[:12])
@@ -34,7 +37,7 @@ class Live:
34
37
  def ingest(self, items: list[Element]) -> tuple[int, int]:
35
38
  """Parse and mask each recorded <item>; returns (accepted, skipped)."""
36
39
  stats: dict = {}
37
- parsed = list(parse_elements(items, source="live", role=self.role, stats=stats))
40
+ parsed = list(parse_elements(items, source="live", role=self.role, stats=stats, known=self.known))
38
41
  skipped = len(items) - len(parsed)
39
42
  with self.lock:
40
43
  for ex in parsed:
@@ -53,7 +56,8 @@ class Live:
53
56
  self.pending, self.stats = [], {"items": 0, "parsed": 0, "skipped": 0}
54
57
  if not batch and not stats["items"]:
55
58
  return 0
56
- lines = [json.dumps(ex.to_record()) for ex in batch]
59
+ mask = self._mask()
60
+ lines = [mask(json.dumps(ex.to_record())) for ex in batch]
57
61
  lines.append(json.dumps({"_stats": stats, "_version": 1}))
58
62
  os.makedirs(os.path.dirname(self.path), exist_ok=True)
59
63
  with open(self.path, "a", encoding="utf-8") as f:
@@ -63,6 +67,28 @@ class Live:
63
67
  self.saved += len(batch)
64
68
  return len(batch)
65
69
 
70
+ def _mask(self):
71
+ """Mask every value learned so far, as it appears inside a JSON-encoded record."""
72
+ with self.lock:
73
+ values = list(self.known)
74
+ return _scrubber({json.dumps(v)[1:-1] for v in values} | set(values))
75
+
76
+ def finish(self) -> None:
77
+ """Mask, in what was saved earlier, the values learned later in the session."""
78
+ if not self.known or not os.path.exists(self.path):
79
+ return
80
+ mask = self._mask()
81
+ with open(self.path, encoding="utf-8") as f:
82
+ text = f.read()
83
+ masked = mask(text)
84
+ if masked != text:
85
+ tmp = self.path + ".tmp"
86
+ with open(tmp, "w", encoding="utf-8") as f:
87
+ f.write(masked)
88
+ f.flush()
89
+ os.fsync(f.fileno())
90
+ os.replace(tmp, self.path)
91
+
66
92
 
67
93
  class Rebuilder(threading.Thread):
68
94
  """Every `interval` seconds, saves what arrived and, if anything did, rebuilds.
@@ -22,6 +22,7 @@ from __future__ import annotations
22
22
 
23
23
  import base64
24
24
  import codecs
25
+ import functools
25
26
  import json
26
27
  import os
27
28
  import re
@@ -455,16 +456,18 @@ def _exchange_secrets(req_headers, resp_headers) -> list[str]:
455
456
  return sorted((x for x in out if len(x) >= 8), key=len, reverse=True)
456
457
 
457
458
 
458
- def _scrubber(values: list[str]):
459
+ def _scrubber(values) -> "callable":
460
+ """Mask every one of `values` wherever it appears in a text: one regex pass, longest first."""
461
+ values = tuple(sorted(set(values), key=len, reverse=True))
459
462
  if not values:
460
463
  return lambda text: text
464
+ return _compiled_scrubber(values)
461
465
 
462
- def scrub(text: str) -> str:
463
- for v in values:
464
- if v in text:
465
- text = text.replace(v, REDACTED)
466
- return text
467
- return scrub
466
+
467
+ @functools.lru_cache(maxsize=8)
468
+ def _compiled_scrubber(values: tuple):
469
+ rx = re.compile("|".join(map(re.escape, values)))
470
+ return lambda text: rx.sub(REDACTED, text) if text else text
468
471
 
469
472
 
470
473
  def _set_cookies(resp_headers) -> list[dict]:
@@ -580,18 +583,28 @@ def parse_items(path: str, role: str | None = None, stats: dict | None = None,
580
583
  stats = {}
581
584
  for k in ("items", "parsed", "skipped"):
582
585
  stats.setdefault(k, 0)
586
+ # A page script can send a value to a vendor (`fbp=…` in a URL, `visUuid="…"` in a body) before
587
+ # the cookie that carries it is ever set. So the file is read twice: first every cookie and
588
+ # credential value is collected from the headers, then each is masked wherever it appears.
589
+ known: set[str] = set()
590
+ for _ in _parse_path(path, role, {"items": 0, "parsed": 0, "skipped": 0}, (), (), known, True):
591
+ pass
592
+ yield from _parse_path(path, role, stats, skip_tools, only_tools, known, False)
593
+
594
+
595
+ def _parse_path(path, role, stats, skip_tools, only_tools, known, collect):
583
596
  if path.lower().endswith(".csv"):
584
597
  caido = _caido_csv_header(path)
585
598
  if caido is not None:
586
- yield from _parse_caido(path, role, stats, skip_tools, only_tools, caido)
599
+ yield from _parse_caido(path, role, stats, skip_tools, only_tools, caido, known, collect)
587
600
  return
588
- yield from _parse_logger_csv(path, role, stats, skip_tools, only_tools)
601
+ yield from _parse_logger_csv(path, role, stats, skip_tools, only_tools, known, collect)
589
602
  return
590
603
  if _looks_like_caido_json(path):
591
- yield from _parse_caido(path, role, stats, skip_tools, only_tools)
604
+ yield from _parse_caido(path, role, stats, skip_tools, only_tools, None, known, collect)
592
605
  return
593
606
  if path.lower().endswith(".har") or _looks_like_har(path):
594
- yield from _parse_har(path, role, stats)
607
+ yield from _parse_har(path, role, stats, known, collect)
595
608
  return
596
609
  _check_no_entities(path)
597
610
  source = os.path.basename(path)
@@ -607,7 +620,7 @@ def parse_items(path: str, role: str | None = None, stats: dict | None = None,
607
620
  continue
608
621
  stats["items"] += 1
609
622
  try:
610
- ex = _parse_item(elem, idx, role)
623
+ ex = _parse_item(elem, idx, role, known, collect)
611
624
  if ex is None:
612
625
  stats["skipped"] += 1
613
626
  else:
@@ -687,7 +700,7 @@ def _b64_text(value: str) -> tuple[str, bool]:
687
700
  return "true", re.sub(r"\s+", "", v)
688
701
 
689
702
 
690
- def _parse_logger_csv(path, role, stats, skip_tools, only_tools):
703
+ def _parse_logger_csv(path, role, stats, skip_tools, only_tools, known=None, collect=False):
691
704
  source = os.path.basename(path)
692
705
  skip = {t.strip().lower() for t in skip_tools if t.strip()}
693
706
  only = {t.strip().lower() for t in only_tools if t.strip()}
@@ -720,7 +733,7 @@ def _parse_logger_csv(path, role, stats, skip_tools, only_tools):
720
733
  # a response that stops at the spreadsheet cell limit and never recovered is cut short
721
734
  if not row.get("_spilled") and _CELL_LIMIT <= len(row.get("Response") or "") < 32768:
722
735
  SubElement(item, "truncated").text = "1"
723
- ex = _parse_item(item, idx, role)
736
+ ex = _parse_item(item, idx, role, known, collect)
724
737
  except Exception:
725
738
  ex = None
726
739
  if ex is None:
@@ -924,17 +937,17 @@ def _har_entries(f, chunk: int = 1 << 20):
924
937
  raise ValueError("not a HAR file (no log.entries)")
925
938
 
926
939
 
927
- def _parse_har(path, role, stats):
940
+ def _parse_har(path, role, stats, known=None, collect=False):
928
941
  """A HAR file (browser DevTools, Hetty, mitmproxy, ZAP, Charles ...), read as if it were a Burp export.
929
942
 
930
943
  Streamed: entries are read and turned into exchanges one at a time, never the whole file at once.
931
944
  """
932
945
  source = os.path.basename(path)
933
946
  with open(path, encoding="utf-8-sig", errors="replace") as f:
934
- yield from _har_exchanges(_har_entries(f), source, role, stats)
947
+ yield from _har_exchanges(_har_entries(f), source, role, stats, known, collect)
935
948
 
936
949
 
937
- def _har_exchanges(entries, source, role, stats):
950
+ def _har_exchanges(entries, source, role, stats, known=None, collect=False):
938
951
  idx = 1
939
952
  item_no = -1
940
953
  while True:
@@ -948,7 +961,7 @@ def _har_exchanges(entries, source, role, stats):
948
961
  stats["items"] += 1
949
962
  try:
950
963
  item = _har_item(entry) if isinstance(entry, dict) else None
951
- ex = _parse_item(item, idx, role) if item is not None else None
964
+ ex = _parse_item(item, idx, role, known, collect) if item is not None else None
952
965
  except Exception:
953
966
  ex = None
954
967
  if ex is None:
@@ -1039,7 +1052,7 @@ def _caido_records(path: str, csv_header: list[str] | None):
1039
1052
  raise ValueError(f"not a Caido export (not a valid JSON array: {e})")
1040
1053
 
1041
1054
 
1042
- def _parse_caido(path, role, stats, skip_tools, only_tools, csv_header=None):
1055
+ def _parse_caido(path, role, stats, skip_tools, only_tools, csv_header=None, known=None, collect=False):
1043
1056
  """A Caido HTTP history export (JSON or CSV), read as if it were a Burp export. Streamed.
1044
1057
 
1045
1058
  `source` (intercept, replay, automate ...) plays the part of Logger++'s Tool column for
@@ -1064,7 +1077,7 @@ def _parse_caido(path, role, stats, skip_tools, only_tools, csv_header=None):
1064
1077
  stats["items"] += 1
1065
1078
  try:
1066
1079
  item = _caido_item(rec)
1067
- ex = _parse_item(item, idx, role) if item is not None else None
1080
+ ex = _parse_item(item, idx, role, known, collect) if item is not None else None
1068
1081
  except Exception:
1069
1082
  ex = None
1070
1083
  if ex is None:
@@ -1076,13 +1089,23 @@ def _parse_caido(path, role, stats, skip_tools, only_tools, csv_header=None):
1076
1089
  yield ex
1077
1090
 
1078
1091
 
1079
- def parse_elements(items, source: str, role: str | None = None, stats: dict | None = None):
1092
+ def parse_elements(items, source: str, role: str | None = None, stats: dict | None = None,
1093
+ known: set | None = None):
1080
1094
  """Like parse_items, for <item> elements already in memory (the crawler's output).
1081
1095
 
1082
1096
  The crawler hands over what it fetched without ever writing a raw
1083
1097
  request/response to disk; redaction still happens here, before an Exchange
1084
- leaves this module.
1098
+ leaves this module. `known` keeps the cookie and credential values seen so far (pass the same
1099
+ set for every batch of one session); the batch is read for them before any of it is masked.
1085
1100
  """
1101
+ if known is None:
1102
+ known = set()
1103
+ items = list(items)
1104
+ for elem in items:
1105
+ try:
1106
+ _parse_item(elem, 0, role, known, True)
1107
+ except Exception:
1108
+ pass
1086
1109
  if stats is None:
1087
1110
  stats = {}
1088
1111
  for k in ("items", "parsed", "skipped"):
@@ -1091,7 +1114,7 @@ def parse_elements(items, source: str, role: str | None = None, stats: dict | No
1091
1114
  for item_no, elem in enumerate(items):
1092
1115
  stats["items"] += 1
1093
1116
  try:
1094
- ex = _parse_item(elem, idx, role)
1117
+ ex = _parse_item(elem, idx, role, known)
1095
1118
  except Exception:
1096
1119
  ex = None
1097
1120
  if ex is None:
@@ -1108,7 +1131,10 @@ def _int(text: str | None, default=None):
1108
1131
  return int(t) if t.isdigit() else default
1109
1132
 
1110
1133
 
1111
- def _parse_item(elem, idx: int, role: str | None) -> Exchange | None:
1134
+ def _parse_item(elem, idx: int, role: str | None, known: set | None = None, collect: bool = False) -> Exchange | None:
1135
+ """One <item> as a masked Exchange. `known` holds cookie and credential values seen anywhere in the
1136
+ capture; each is masked wherever it appears, not only beside its own name. With `collect`, the item
1137
+ only adds its values to `known` and nothing is built."""
1112
1138
  method = (_text(elem.find("method")) or "GET").strip().upper()
1113
1139
  host = (_text(elem.find("host")) or "").strip().lower()
1114
1140
  port = _int(_text(elem.find("port")), 0)
@@ -1134,6 +1160,11 @@ def _parse_item(elem, idx: int, role: str | None) -> Exchange | None:
1134
1160
  resp_head, resp_body_b = _split_http(resp_raw) if resp_raw else ("", b"")
1135
1161
  req_start, req_headers = _parse_headers(req_head)
1136
1162
  resp_start, resp_headers = _parse_headers(resp_head)
1163
+ own_secrets = _exchange_secrets(req_headers, resp_headers)
1164
+ if known is not None:
1165
+ known.update(own_secrets)
1166
+ if collect:
1167
+ return None
1137
1168
  if status is None and resp_start.startswith("HTTP/"):
1138
1169
  status = _int(resp_start.split(" ")[1] if " " in resp_start else None)
1139
1170
 
@@ -1156,7 +1187,7 @@ def _parse_item(elem, idx: int, role: str | None) -> Exchange | None:
1156
1187
  preflight = method == "OPTIONS" and bool(_header(req_headers, "access-control-request-method"))
1157
1188
 
1158
1189
  secrets: list[SecretFingerprint] = []
1159
- scrub = _scrubber(_exchange_secrets(req_headers, resp_headers))
1190
+ scrub = _scrubber(known if known else own_secrets)
1160
1191
  red_path, s0 = redact_path(path)
1161
1192
  red_path = scrub(red_path)
1162
1193
  ptemplate = route_template(red_path, template_path(red_path))
@@ -55,7 +55,10 @@ SENSITIVE_PARAM_HINTS = (
55
55
  # WordPress/Django/.NET nonces, __VIEWSTATE and friends
56
56
  "sessid", "nonce", "xsrf", "viewstate", "eventvalidation", "verification",
57
57
  # a value that is itself a credential, whatever the field is called around it
58
- "cookie", "credential", "bearer",
58
+ "cookie", "credential", "bearer", "hmac",
59
+ # personal data with no shape to catch it by: a phone number is just ten digits
60
+ "phone", "mobile", "msisdn", "tel", "whatsapp", "email", "dob", "birth", "aadhaar", "aadhar",
61
+ "passport", "pan", "gstin", "ssn",
59
62
  )
60
63
 
61
64
  # Names that are innocuous alone (`new=true`) but are the password fields of a
@@ -250,6 +253,8 @@ def redact_body(body: str) -> tuple[str, list[SecretFingerprint]]:
250
253
  # `accessToken` and `user[password]` are caught, not just the exact hints.
251
254
  _KV_ANY = re.compile(r"(^|[?&;,#\s])([A-Za-z0-9_.\-\[\]]{1,64})(\s*=\s*)([^&;#\s\"'<>]+)")
252
255
  _JSON_ANY = re.compile(r'"((?:[^"\\]|\\.){1,64})"(\s*:\s*)("(?:[^"\\]|\\.)*"|-?\d[\d.eE+-]*|true|false|null)')
256
+ # JSON kept inside a script string, its quotes escaped: var cfg = "{\"token\":\"…\"}"
257
+ _JSON_ESC = re.compile(r'\\"([A-Za-z0-9_.$@\-\[\]]{1,64})\\"(\s*:\s*)(\\"(?:(?!\\").){0,500}\\"|-?\d[\d.eE+-]*|true|false|null)')
253
258
 
254
259
 
255
260
  _INPUT_TAG = re.compile(r"<input\b[^>]*>", re.I)
@@ -281,7 +286,8 @@ def redact_named(text: str) -> str:
281
286
  return text
282
287
 
283
288
  names = [m.group(2) for m in _KV_ANY.finditer(text)] + \
284
- [m.group(1) for m in _JSON_ANY.finditer(text)]
289
+ [m.group(1) for m in _JSON_ANY.finditer(text)] + \
290
+ ([m.group(1) for m in _JSON_ESC.finditer(text)] if '\\"' in text else [])
285
291
  group = _is_credential_group(names)
286
292
  sens = lambda n: _is_sensitive_param(n) or (group and _leaf(n) in _CREDENTIAL_GROUP)
287
293
 
@@ -330,6 +336,15 @@ def redact_named(text: str) -> str:
330
336
  text = _KV_ANY.sub(_kv, text)
331
337
  text = _JSON_ANY.sub(_json, text)
332
338
 
339
+ def _json_esc(m: re.Match) -> str:
340
+ val = m.group(3)[2:-2] if m.group(3).startswith('\\"') else m.group(3)
341
+ if sens(m.group(1)) and REDACTED not in val and not _is_error_constant(val):
342
+ return '\\"' + m.group(1) + '\\"' + m.group(2) + '\\"' + REDACTED + '\\"'
343
+ return m.group(0)
344
+
345
+ if '\\"' in text:
346
+ text = _JSON_ESC.sub(_json_esc, text)
347
+
333
348
  def _live(v: str) -> bool:
334
349
  return bool(v) and REDACTED not in v and not _is_error_constant(v)
335
350
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy2model
3
- Version: 2.0.0
3
+ Version: 2.0.1
4
4
  Summary: Turn captured proxy traffic into an evidence-backed model of a web app.
5
5
  Author: falc0n
6
6
  License-Expression: Apache-2.0
@@ -85,6 +85,7 @@ tests/test_bql_parity.py
85
85
  tests/test_browse.py
86
86
  tests/test_browser_crawl.py
87
87
  tests/test_caido.py
88
+ tests/test_capture_wide_masking.py
88
89
  tests/test_cli.py
89
90
  tests/test_crawl.py
90
91
  tests/test_diff.py
@@ -0,0 +1,93 @@
1
+ """A cookie or credential value is masked wherever it appears in the capture, not only beside its name.
2
+
3
+ Found on a real Caido capture: page scripts send tracking ids to vendors (`fbp=…` in a URL,
4
+ `visUuid="…"` in a page) before the cookie that carries them is set, and an OTP request carried a
5
+ phone number inside escaped JSON.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ from xml.etree.ElementTree import Element, SubElement
11
+
12
+ from proxy2model import parse_items
13
+ from proxy2model.cli import main
14
+ from proxy2model.ingest.live import Live
15
+ from proxy2model.ingest.redact import redact_named
16
+
17
+ from test_caido import b64, caido_json, rec
18
+
19
+ FBP = "fb.1.1793913083850.123456789012345"
20
+ VWO = "D2DFAA0B1C2D3E4F5A6B7C8D9E0F1A2B3"
21
+
22
+
23
+ def outputs(out):
24
+ text = ""
25
+ for root, _, files in os.walk(out):
26
+ for f in files:
27
+ text += open(os.path.join(root, f), encoding="utf-8", errors="replace").read()
28
+ return text
29
+
30
+
31
+ CAPTURE = [
32
+ # the vendor gets the id first, in its query string and in the page's inline script
33
+ rec(1, "GET", "/tr", 200, query=f"id=1&fbp={FBP}&ev=PageView"),
34
+ rec(2, "GET", "/", 200, rs_head="Content-Type: text/html\r\n",
35
+ rs_body=f'<html><script>window.VWO.visUuid="{VWO}";</script></html>'),
36
+ # only later does a request carry them as cookies
37
+ rec(3, "GET", "/api/items", 200, rq_head=f"Cookie: _fbp={FBP}; _vwo_uuid_v2={VWO}\r\n"),
38
+ rec(4, "POST", "/v4-sec/notification/send", 200, rq_head="Content-Type: application/json\r\n",
39
+ rq_body=json.dumps({"payload": json.dumps({"recipient": {"callingCode": "91", "mobile": "9990001112"},
40
+ "hmac": "8922d8cd5d40ab410ee25008ec1aa47d"})})),
41
+ ]
42
+
43
+
44
+ def test_a_value_sent_before_its_cookie_is_masked_everywhere(tmp_path, monkeypatch):
45
+ monkeypatch.setenv("PROXY2MODEL_FP_KEY", "ci")
46
+ monkeypatch.setenv("PROXY2MODEL_OFFLINE", "1")
47
+ out = tmp_path / "o"
48
+ assert main([caido_json(tmp_path, CAPTURE), "-w", "shop", "--out", str(out)]) == 0
49
+ text = outputs(out)
50
+ for secret in (FBP, VWO, "9990001112", "8922d8cd5d40ab410ee25008ec1aa47d"):
51
+ assert secret not in text, secret
52
+
53
+
54
+ def test_the_model_is_the_same_apart_from_the_masking(tmp_path):
55
+ got = list(parse_items(caido_json(tmp_path, CAPTURE)))
56
+ assert [e.method for e in got] == ["GET", "GET", "GET", "POST"]
57
+ assert any(k == "fbp" for k, *_ in got[0].query_params) # the name stays, the value goes
58
+
59
+
60
+ def test_escaped_json_in_a_script_is_masked_by_name():
61
+ text = r'var c="{\"token\":\"s3cr3tvalue123456\",\"password\":\"hunter2hunter2\",\"name\":\"bob\"}";'
62
+ out = redact_named(text)
63
+ assert "s3cr3tvalue123456" not in out and "hunter2hunter2" not in out
64
+ assert r'\"name\":\"bob\"' in out # an ordinary field is left alone
65
+
66
+
67
+ def test_personal_fields_are_masked_by_name():
68
+ out = redact_named('{"mobile":"9990001112","phone_no":"9990001113","dob":"1990-01-01","panel":"left"}')
69
+ for v in ("9990001112", "9990001113", "1990-01-01"):
70
+ assert v not in out
71
+ assert '"panel":"left"' in out
72
+
73
+
74
+ def item(path, cookie=""):
75
+ el = Element("item")
76
+ for tag, val in (("method", "GET"), ("host", "shop.test"), ("port", "443"), ("protocol", "https"),
77
+ ("path", path), ("status", "200"), ("mimetype", "")):
78
+ SubElement(el, tag).text = val
79
+ rq = f"GET {path} HTTP/1.1\r\nHost: shop.test\r\n" + (f"Cookie: {cookie}\r\n" if cookie else "") + "\r\n"
80
+ SubElement(el, "request", {"base64": "true"}).text = b64(rq)
81
+ SubElement(el, "response", {"base64": "true"}).text = b64("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n{}")
82
+ return el
83
+
84
+
85
+ def test_browse_masks_what_it_saved_before_the_cookie_appeared(tmp_path):
86
+ live = Live(str(tmp_path / "inputs" / "browse.jsonl"))
87
+ live.ingest([item(f"/tr?fbp={FBP}")])
88
+ live.flush() # saved while the value was unknown
89
+ live.ingest([item("/api/items", cookie=f"_fbp={FBP}")])
90
+ live.flush()
91
+ live.finish()
92
+ text = open(tmp_path / "inputs" / "browse.jsonl").read()
93
+ assert FBP not in text and "fbp" in text
File without changes
File without changes
File without changes
File without changes
File without changes