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.
- {proxy2model-2.0.0/src/proxy2model.egg-info → proxy2model-2.0.1}/PKG-INFO +1 -1
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/__init__.py +1 -1
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/browse.py +1 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/live.py +30 -4
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/parse.py +56 -25
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/redact.py +17 -2
- {proxy2model-2.0.0 → proxy2model-2.0.1/src/proxy2model.egg-info}/PKG-INFO +1 -1
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/SOURCES.txt +1 -0
- proxy2model-2.0.1/tests/test_capture_wide_masking.py +93 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/LICENSE +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/MANIFEST.in +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/README.md +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-ai.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-capture.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-crawling.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-model.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/doc-querying.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/hero.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/assets/readme/ledger.svg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/pyproject.toml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/burp-history-admin-sample.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/burp-history-sample.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-accountant.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-admin.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-anon.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/samples/juiceshop-user.xml +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/setup.cfg +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/__main__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/atomic.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/analyze.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/app.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/build.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/common.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/history.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/mcp.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/cli/ui.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/changes.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/context.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/diff.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/domains.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/graph.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/methodology.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/model.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/core/stack.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/LICENSES.md +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-200.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-300.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-400.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-500.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/nippo-700.woff2 +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/public_suffix_list.dat +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/bql.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/bql_js.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/db/store.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/export/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/export/openapi.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/browse.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/browser.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/extract.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/infer.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/ingest/journey.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/py.typed +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/recon/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/recon/osint.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/__init__.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/animate.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/dash.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/fonts.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/html.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/inventory.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/map.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/pages.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/query.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/shot.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/report/view.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/dependency_links.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/entry_points.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/requires.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model.egg-info/top_level.txt +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_bql_parity.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_browse.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_browser_crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_caido.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_cli.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_crawl.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_diff.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_domains.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_gaps.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_graph.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_har.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_hardening.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_journey.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_juiceshop_sample.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_leads.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_logger_csv.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_map.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_mcp.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_methodology.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_openapi.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_proxy2model.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_recon_cmds.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_redact_credentials.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_redaction_realistic.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_report_ui.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_routes_areas.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_stack.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_store_bql.py +0 -0
- {proxy2model-2.0.0 → proxy2model-2.0.1}/tests/test_v1.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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(
|
|
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
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{proxy2model-2.0.0 → proxy2model-2.0.1}/src/proxy2model/data/fonts/inter-latin-wght-normal.woff2
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|