proxy2model 2.0.0__py3-none-any.whl

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 (66) hide show
  1. proxy2model/__init__.py +19 -0
  2. proxy2model/__main__.py +6 -0
  3. proxy2model/atomic.py +68 -0
  4. proxy2model/cli/__init__.py +3 -0
  5. proxy2model/cli/analyze.py +501 -0
  6. proxy2model/cli/app.py +408 -0
  7. proxy2model/cli/browse.py +279 -0
  8. proxy2model/cli/build.py +399 -0
  9. proxy2model/cli/common.py +190 -0
  10. proxy2model/cli/history.py +224 -0
  11. proxy2model/cli/mcp.py +184 -0
  12. proxy2model/cli/ui.py +62 -0
  13. proxy2model/core/__init__.py +1 -0
  14. proxy2model/core/changes.py +124 -0
  15. proxy2model/core/context.py +571 -0
  16. proxy2model/core/diff.py +330 -0
  17. proxy2model/core/domains.py +98 -0
  18. proxy2model/core/graph.py +562 -0
  19. proxy2model/core/methodology.py +343 -0
  20. proxy2model/core/model.py +998 -0
  21. proxy2model/core/stack.py +218 -0
  22. proxy2model/data/fonts/LICENSES.md +16 -0
  23. proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
  24. proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  25. proxy2model/data/fonts/nippo-200.woff2 +0 -0
  26. proxy2model/data/fonts/nippo-300.woff2 +0 -0
  27. proxy2model/data/fonts/nippo-400.woff2 +0 -0
  28. proxy2model/data/fonts/nippo-500.woff2 +0 -0
  29. proxy2model/data/fonts/nippo-700.woff2 +0 -0
  30. proxy2model/data/public_suffix_list.dat +16497 -0
  31. proxy2model/db/__init__.py +1 -0
  32. proxy2model/db/bql.py +710 -0
  33. proxy2model/db/bql_js.py +278 -0
  34. proxy2model/db/store.py +547 -0
  35. proxy2model/export/__init__.py +1 -0
  36. proxy2model/export/openapi.py +248 -0
  37. proxy2model/ingest/__init__.py +1 -0
  38. proxy2model/ingest/browse.py +181 -0
  39. proxy2model/ingest/browser.py +1058 -0
  40. proxy2model/ingest/crawl.py +673 -0
  41. proxy2model/ingest/extract.py +208 -0
  42. proxy2model/ingest/infer.py +75 -0
  43. proxy2model/ingest/journey.py +148 -0
  44. proxy2model/ingest/live.py +96 -0
  45. proxy2model/ingest/parse.py +1275 -0
  46. proxy2model/ingest/redact.py +535 -0
  47. proxy2model/py.typed +0 -0
  48. proxy2model/recon/__init__.py +1 -0
  49. proxy2model/recon/osint.py +607 -0
  50. proxy2model/report/__init__.py +1 -0
  51. proxy2model/report/animate.py +163 -0
  52. proxy2model/report/dash.py +469 -0
  53. proxy2model/report/fonts.py +51 -0
  54. proxy2model/report/html.py +988 -0
  55. proxy2model/report/inventory.py +258 -0
  56. proxy2model/report/map.py +515 -0
  57. proxy2model/report/pages.py +537 -0
  58. proxy2model/report/query.py +306 -0
  59. proxy2model/report/shot.py +55 -0
  60. proxy2model/report/view.py +213 -0
  61. proxy2model-2.0.0.dist-info/METADATA +148 -0
  62. proxy2model-2.0.0.dist-info/RECORD +66 -0
  63. proxy2model-2.0.0.dist-info/WHEEL +5 -0
  64. proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
  65. proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
  66. proxy2model-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,535 @@
1
+ """
2
+ Redaction — the trust core of proxy2model.
3
+
4
+ Burp exports contain live session tokens, cookies, API keys and PII. This
5
+ module masks every *value* before anything is written to disk, while keeping
6
+ the *names and structure* the model needs. That includes URL paths: a reset
7
+ token or an email in `/reset/alice@corp.com/Zx9kQ2pLm7Rt` is a value too.
8
+
9
+ A credential is recorded by a keyed fingerprint (HMAC-SHA256, truncated), its
10
+ length and its Shannon entropy — never the value. The fingerprint is keyed so
11
+ that someone holding the outputs cannot confirm a guess ("is this the hash of
12
+ alice@corp.com?") without also holding the key. The key comes from
13
+ PROXY2MODEL_FP_KEY, from `set_fingerprint_key()` (the CLI keeps one per user,
14
+ outside the output directory), or a random per-process key.
15
+
16
+ Rule: names and shape stay; values die. `password=secret123` becomes
17
+ `password=[REDACTED]`, never a dropped field.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import hashlib
23
+ import hmac
24
+ import math
25
+ import os
26
+ import re
27
+ import urllib.parse
28
+ from dataclasses import dataclass
29
+
30
+ # Header names whose values are always masked (case-insensitive).
31
+ SENSITIVE_HEADERS = {
32
+ "authorization",
33
+ "proxy-authorization",
34
+ "cookie",
35
+ "set-cookie",
36
+ "x-api-key",
37
+ "x-auth-token",
38
+ "x-csrf-token",
39
+ "x-xsrf-token",
40
+ "api-key",
41
+ }
42
+
43
+ # Header names that *contain* any of these are masked too.
44
+ SENSITIVE_HEADER_HINTS = ("token", "secret", "apikey", "api-key", "auth", "session")
45
+
46
+ # Body/query parameter names whose values are masked.
47
+ SENSITIVE_PARAM_HINTS = (
48
+ "password", "passwd", "pwd", "passcode", "secret", "token", "apikey",
49
+ "api_key", "access_key", "refresh_token", "id_token", "client_secret",
50
+ "private_key", "session", "sid", "csrf", "auth", "signature", "sig",
51
+ "code", "otp", "pin", "ssn", "cvv", "cvc", "assertion",
52
+ # security-question answers and recovery phrases are credentials too
53
+ "answer", "passphrase", "totp", "mfa",
54
+ # session and anti-forgery names that do not say "session"/"token": PHPSESSID, ASP.NET_SessionId,
55
+ # WordPress/Django/.NET nonces, __VIEWSTATE and friends
56
+ "sessid", "nonce", "xsrf", "viewstate", "eventvalidation", "verification",
57
+ # a value that is itself a credential, whatever the field is called around it
58
+ "cookie", "credential", "bearer",
59
+ )
60
+
61
+ # Names that are innocuous alone (`new=true`) but are the password fields of a
62
+ # change-password form when they travel together: `current` / `new` / `repeat`
63
+ # (Juice Shop), `old_` / `confirm`, … Two or more of them in one query string,
64
+ # form or JSON object means a credential change, and every one of them is masked.
65
+ _CREDENTIAL_GROUP = {"current", "new", "repeat", "confirm", "old", "retype", "verify",
66
+ "again", "newpass", "oldpass", "confirmation"}
67
+
68
+ # Value-shaped patterns masked wherever they appear (bodies included).
69
+ VALUE_PATTERNS = [
70
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}\b")),
71
+ ("openai_key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
72
+ ("aws_key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
73
+ ("google_key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
74
+ ("slack_token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b")),
75
+ ("github_token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
76
+ ("stripe_key", re.compile(r"\b[rsp]k_(?:live|test)_[A-Za-z0-9]{16,}\b")),
77
+ ("private_key", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
78
+ ("card", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
79
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")),
80
+ ]
81
+
82
+ REDACTED = "[REDACTED]"
83
+
84
+ _KEY: bytes | None = None
85
+
86
+
87
+ def set_fingerprint_key(key: bytes) -> None:
88
+ """Key every fingerprint made from now on (the CLI calls this)."""
89
+ global _KEY
90
+ _KEY = key
91
+
92
+
93
+ def _key() -> bytes:
94
+ global _KEY
95
+ if _KEY is None:
96
+ env = os.environ.get("PROXY2MODEL_FP_KEY")
97
+ _KEY = env.encode("utf-8") if env else os.urandom(32)
98
+ return _KEY
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class SecretFingerprint:
103
+ """A credential recorded without its value."""
104
+ kind: str # jwt, openai_key, authorization, ...
105
+ hmac_12: str # first 12 hex chars of HMAC-SHA256(key, value)
106
+ length: int
107
+ entropy: float # Shannon bits/char
108
+
109
+ def as_dict(self) -> dict:
110
+ return {
111
+ "kind": self.kind,
112
+ "hmac_12": self.hmac_12,
113
+ "length": self.length,
114
+ "entropy": round(self.entropy, 2),
115
+ }
116
+
117
+
118
+ def shannon_entropy(s: str) -> float:
119
+ if not s:
120
+ return 0.0
121
+ counts: dict[str, int] = {}
122
+ for ch in s:
123
+ counts[ch] = counts.get(ch, 0) + 1
124
+ n = len(s)
125
+ return -sum((c / n) * math.log2(c / n) for c in counts.values())
126
+
127
+
128
+ def fingerprint(value: str, kind: str = "value") -> SecretFingerprint:
129
+ digest = hmac.new(_key(), value.encode("utf-8", "replace"), hashlib.sha256).hexdigest()[:12]
130
+ return SecretFingerprint(kind, digest, len(value), shannon_entropy(value))
131
+
132
+
133
+ def _luhn_ok(digits: str) -> bool:
134
+ total = 0
135
+ for i, ch in enumerate(reversed(digits)):
136
+ d = int(ch)
137
+ if i % 2:
138
+ d *= 2
139
+ if d > 9:
140
+ d -= 9
141
+ total += d
142
+ return total % 10 == 0
143
+
144
+
145
+ def _is_sensitive_header(name: str) -> bool:
146
+ low = name.strip().lower()
147
+ if low in SENSITIVE_HEADERS:
148
+ return True
149
+ return any(h in low for h in SENSITIVE_HEADER_HINTS)
150
+
151
+
152
+ def _is_sensitive_param(name: str) -> bool:
153
+ # split camelCase before lowering so "userPin" tokenises as user_pin
154
+ low = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name.strip()).lower()
155
+ # match on name tokens so "code" hits `code`/`auth_code` but not `zipcode`
156
+ tokens = set(re.split(r"[^a-z0-9]+", low)) | {low}
157
+ for h in SENSITIVE_PARAM_HINTS:
158
+ if len(h) <= 4:
159
+ if h in tokens:
160
+ return True
161
+ elif h in low:
162
+ return True
163
+ return False
164
+
165
+
166
+ # `SQLITE_ERROR`, `ERR_BAD_REQUEST`, `ECONNRESET`-style constants are status
167
+ # words, not secrets — `{"code":"SQLITE_ERROR"}` is exactly what an analyst needs.
168
+ _ERROR_CONSTANT_RE = re.compile(r"^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$|^E[A-Z]{4,}$")
169
+
170
+
171
+ def _is_error_constant(value: str) -> bool:
172
+ return bool(_ERROR_CONSTANT_RE.match(value.strip().strip('"')))
173
+
174
+
175
+ def _leaf(name: str) -> str:
176
+ """`$.user.newPass` -> `new_pass`; `a[b]` -> `b` (the field a value sits in)."""
177
+ parts = [p for p in re.split(r"[.\[\]$]+", name.strip()) if p]
178
+ leaf = parts[-1] if parts else ""
179
+ return re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", leaf).lower().replace("_", "")
180
+
181
+
182
+ def _is_credential_group(names) -> bool:
183
+ return len({_leaf(n) for n in names} & _CREDENTIAL_GROUP) >= 2
184
+
185
+
186
+ def redact_headers(headers: list[tuple[str, str]]) -> tuple[list[tuple[str, str]], list[SecretFingerprint]]:
187
+ """Mask sensitive header values; return masked headers + fingerprints."""
188
+ out: list[tuple[str, str]] = []
189
+ prints: list[SecretFingerprint] = []
190
+ for name, value in headers:
191
+ if _is_sensitive_header(name) and value:
192
+ prints.append(fingerprint(value, kind=name.strip().lower()))
193
+ out.append((name, REDACTED))
194
+ else:
195
+ masked, more = redact_value_patterns(value)
196
+ prints.extend(more)
197
+ out.append((name, masked))
198
+ return out, prints
199
+
200
+
201
+ def redact_value_patterns(text: str) -> tuple[str, list[SecretFingerprint]]:
202
+ """Mask value-shaped secrets/PII anywhere in a string."""
203
+ prints: list[SecretFingerprint] = []
204
+ if not text:
205
+ return text, prints
206
+ for kind, pattern in VALUE_PATTERNS:
207
+ def _sub(m: re.Match) -> str:
208
+ val = m.group(0)
209
+ if kind == "card":
210
+ digits = re.sub(r"\D", "", val)
211
+ # 13-19 digits alone is a timestamp as often as a card
212
+ if not (13 <= len(digits) <= 19) or not _luhn_ok(digits):
213
+ return val
214
+ if len(digits) == 13 and digits[0] == "1": # a millisecond timestamp, not a card
215
+ return val
216
+ prints.append(fingerprint(val, kind=kind))
217
+ return f"[REDACTED:{kind}]"
218
+ text = pattern.sub(_sub, text)
219
+ return text, prints
220
+
221
+
222
+ def redact_params(params: list[tuple[str, str]]) -> tuple[list[tuple[str, str]], list[SecretFingerprint]]:
223
+ """Mask sensitive param values by name, then value-shape scan the rest."""
224
+ out: list[tuple[str, str]] = []
225
+ prints: list[SecretFingerprint] = []
226
+ group = _is_credential_group(n for n, _ in params)
227
+ for name, value in params:
228
+ if (_is_sensitive_param(name) or (group and _leaf(name) in _CREDENTIAL_GROUP)) \
229
+ and value and not _is_error_constant(value):
230
+ prints.append(fingerprint(value, kind=name.strip().lower()))
231
+ out.append((name, REDACTED))
232
+ else:
233
+ masked, more = redact_value_patterns(value)
234
+ prints.extend(more)
235
+ out.append((name, masked))
236
+ return out, prints
237
+
238
+
239
+ def redact_body(body: str) -> tuple[str, list[SecretFingerprint]]:
240
+ """Value-shape scan of a raw body (JSON/form/text)."""
241
+ return redact_value_patterns(body)
242
+
243
+
244
+ # Deep, name-based masking for text that is *stored verbatim* (the report's
245
+ # request/response panes). The structured pipeline masks sensitive params by
246
+ # name, but a raw body/header kept as text also needs the value beside a
247
+ # sensitive name masked even when the value has no tell-tale shape
248
+ # (e.g. `password=hunter2`, a `session=` inside a Referer URL). The name is
249
+ # matched generically and judged by _is_sensitive_param, so `new_password`,
250
+ # `accessToken` and `user[password]` are caught, not just the exact hints.
251
+ _KV_ANY = re.compile(r"(^|[?&;,#\s])([A-Za-z0-9_.\-\[\]]{1,64})(\s*=\s*)([^&;#\s\"'<>]+)")
252
+ _JSON_ANY = re.compile(r'"((?:[^"\\]|\\.){1,64})"(\s*:\s*)("(?:[^"\\]|\\.)*"|-?\d[\d.eE+-]*|true|false|null)')
253
+
254
+
255
+ _INPUT_TAG = re.compile(r"<input\b[^>]*>", re.I)
256
+ _ATTR_NAME = re.compile(r"""\bname\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))""", re.I)
257
+ _ATTR_VALUE = re.compile(r"""(\bvalue\s*=\s*)(?:"[^"]*"|'[^']*'|[^\s>]+)""", re.I)
258
+ # Other places a secret sits next to its name: multipart form parts, XML elements, quoted assignments in
259
+ # scripts and config (`const apiKey = "…"`, `{token:'…'}`), YAML lines, JSON arrays of secrets, and JSON that
260
+ # has been percent-encoded into a form field.
261
+ _MULTIPART = re.compile(r'(\bname\s*=\s*"([^"\r\n]{1,64})"[^\r\n]*(?:\r?\n[A-Za-z][A-Za-z\-]*:[^\r\n]*)*\r?\n\r?\n)([^\r\n]+)')
262
+ _XML_EL = re.compile(r"<([A-Za-z_][\w.\-:]{0,63})(\s[^<>]*)?>([^<>]{1,500})</\1>")
263
+ _ASSIGN = re.compile(r'''(["\']?)([A-Za-z_$][\w$.\-]{0,63})\1(\s*[:=]\s*)(["\'])((?:(?!\4)[^\\\r\n]|\\.){4,500})\4''')
264
+ _YAML = re.compile(r'(?m)^([ \t]*)([A-Za-z_][\w.\-]{0,63})([ \t]*:[ \t]*)([^\s"\'#<>{}\[\],][^\r\n#]{5,200})$')
265
+ _JSON_LIST = re.compile(r'"((?:[^"\\]|\\.){1,64})"(\s*:\s*)\[([^\]]{0,2000})\]')
266
+ _ENC_JSON = re.compile(r"(%22([A-Za-z0-9_.\-]{1,64})%22%3[Aa]%22)((?:(?!%22).){1,500})(%22)")
267
+ _TAG = re.compile(r"<[A-Za-z][^<>]{0,2000}>")
268
+ _ATTR_PAIR = re.compile(r"""([A-Za-z_:][\w:.\-]{0,63})(\s*=\s*)("[^"]*"|'[^']*')""")
269
+ _HEADER_LINE = re.compile(
270
+ r"(?i)\b(Cookie|Set-Cookie|Authorization|Proxy-Authorization|X-Auth-Token|X-API-Key|X-CSRF-Token|"
271
+ r"X-XSRF-Token)(\s*:\s*)[^\r\n<]+")
272
+
273
+
274
+ def redact_named(text: str) -> str:
275
+ """Mask values that sit next to a sensitive name in form/query/JSON text.
276
+
277
+ Complements value-shape redaction; assumes shape-based masking already ran.
278
+ Returns text only — fingerprints are recorded by the structured pipeline.
279
+ """
280
+ if not text:
281
+ return text
282
+
283
+ names = [m.group(2) for m in _KV_ANY.finditer(text)] + \
284
+ [m.group(1) for m in _JSON_ANY.finditer(text)]
285
+ group = _is_credential_group(names)
286
+ sens = lambda n: _is_sensitive_param(n) or (group and _leaf(n) in _CREDENTIAL_GROUP)
287
+
288
+ def _kv(m: re.Match) -> str:
289
+ if sens(m.group(2)) and m.group(4) != REDACTED and not _is_error_constant(m.group(4)):
290
+ return m.group(1) + m.group(2) + m.group(3) + REDACTED
291
+ return m.group(0)
292
+
293
+ def _json(m: re.Match) -> str:
294
+ if sens(m.group(1)) and not _is_error_constant(m.group(3)):
295
+ return '"' + m.group(1) + '"' + m.group(2) + '"' + REDACTED + '"'
296
+ return m.group(0)
297
+
298
+ def _input(m: re.Match) -> str:
299
+ tag = m.group(0)
300
+ n = _ATTR_NAME.search(tag)
301
+ name = next((g for g in (n.groups() if n else ()) if g), "")
302
+ if name and sens(name):
303
+ return _ATTR_VALUE.sub(lambda v: v.group(1) + '"' + REDACTED + '"', tag, count=1)
304
+ return tag
305
+
306
+ def _tag(m: re.Match) -> str:
307
+ # any attribute whose name says secret (`data-context-token="…"`), and the `content` of a
308
+ # `<meta name="csrf-token">`, carries a value the same way a hidden field does
309
+ tag = m.group(0)
310
+ if tag[:6].lower() == "<input":
311
+ return tag
312
+ pairs = _ATTR_PAIR.findall(tag)
313
+ meta_secret = tag[:5].lower() == "<meta" and any(
314
+ k.lower() == "name" and sens(v[1:-1]) for k, _, v in pairs)
315
+
316
+ def _attr(a: re.Match) -> str:
317
+ name, eq, val = a.group(1), a.group(2), a.group(3)
318
+ if val[1:-1] in ("", REDACTED) or _is_error_constant(val[1:-1]):
319
+ return a.group(0)
320
+ if sens(name) or (meta_secret and name.lower() == "content"):
321
+ return name + eq + '"' + REDACTED + '"'
322
+ return a.group(0)
323
+ return _ATTR_PAIR.sub(_attr, tag)
324
+
325
+ # an HTML hidden field (`<input name="user_token" value="…">`) is a name/value pair too,
326
+ # and a page that echoes request headers (`Cookie: PHPSESSID=…`) carries them in its body
327
+ text = _INPUT_TAG.sub(_input, text)
328
+ text = _TAG.sub(_tag, text)
329
+ text = _HEADER_LINE.sub(lambda m: m.group(1) + m.group(2) + REDACTED, text)
330
+ text = _KV_ANY.sub(_kv, text)
331
+ text = _JSON_ANY.sub(_json, text)
332
+
333
+ def _live(v: str) -> bool:
334
+ return bool(v) and REDACTED not in v and not _is_error_constant(v)
335
+
336
+ def _part(m: re.Match) -> str:
337
+ return m.group(1) + REDACTED if sens(m.group(2)) and _live(m.group(3)) else m.group(0)
338
+
339
+ def _xml(m: re.Match) -> str:
340
+ if sens(m.group(1)) and _live(m.group(3).strip()):
341
+ return f"<{m.group(1)}{m.group(2) or ''}>{REDACTED}</{m.group(1)}>"
342
+ return m.group(0)
343
+
344
+ def _assign(m: re.Match) -> str:
345
+ if sens(m.group(2)) and _live(m.group(5)):
346
+ return f"{m.group(1)}{m.group(2)}{m.group(1)}{m.group(3)}{m.group(4)}{REDACTED}{m.group(4)}"
347
+ return m.group(0)
348
+
349
+ def _yaml(m: re.Match) -> str:
350
+ if sens(m.group(2)) and _live(m.group(4).strip()):
351
+ return m.group(1) + m.group(2) + m.group(3) + REDACTED
352
+ return m.group(0)
353
+
354
+ def _list(m: re.Match) -> str:
355
+ if sens(m.group(1)) and "REDACTED" not in m.group(3):
356
+ return f'"{m.group(1)}"{m.group(2)}[' + re.sub(r'"(?:[^"\\]|\\.)*"', f'"{REDACTED}"', m.group(3)) + "]"
357
+ return m.group(0)
358
+
359
+ def _enc(m: re.Match) -> str:
360
+ return m.group(1) + REDACTED + m.group(4) if sens(m.group(2)) and _live(m.group(3)) else m.group(0)
361
+
362
+ if "%22" in text:
363
+ text = _ENC_JSON.sub(_enc, text)
364
+ if "form-data" in text.lower():
365
+ text = _MULTIPART.sub(_part, text)
366
+ if "</" in text:
367
+ text = _XML_EL.sub(_xml, text)
368
+ if '"' in text or "'" in text:
369
+ text = _ASSIGN.sub(_assign, text)
370
+ text = _JSON_LIST.sub(_list, text)
371
+ if ":" in text:
372
+ text = _YAML.sub(_yaml, text)
373
+ return text
374
+
375
+
376
+ # ---------------------------------------------------------------- paths ------
377
+
378
+ _UUID_RE = re.compile(
379
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
380
+ _HEX_RE = re.compile(r"^[0-9a-fA-F]{24,}$")
381
+ _HEXPAIR_RE = re.compile(r"^[0-9a-fA-F]{3,8}-[0-9a-fA-F]{8,}$")
382
+ _CODE_RE = re.compile(r"^[A-Z0-9]{8,24}$")
383
+ _TOKENISH_RE = re.compile(r"^[A-Za-z0-9_\-+=~.]{12,}$")
384
+ _B64ISH_RE = re.compile(r"^[A-Za-z0-9_\-+/=]{32,}$")
385
+
386
+
387
+ def classify_segment(seg: str) -> str | None:
388
+ """Placeholder for a path segment that is a value, or None to keep it.
389
+
390
+ Structural placeholders ({id}, {uuid}, {hash}) collapse identifiers;
391
+ secret placeholders ({email}, {jwt}, {token}, ...) hide credentials/PII
392
+ that some apps put in the path (reset links, magic links, signed URLs).
393
+ """
394
+ if not seg or (seg.startswith("{") and seg.endswith("}")):
395
+ return None
396
+ s = urllib.parse.unquote(seg)
397
+ if s.isdigit():
398
+ return "{id}"
399
+ if _UUID_RE.match(s):
400
+ return "{uuid}"
401
+ if _HEX_RE.match(s):
402
+ return "{hash}"
403
+ # `5267-f73dcd000abcc353` — a short hex prefix, a dash, a long hex tail
404
+ # (Juice Shop order ids, many framework-generated ids): an id, not a route
405
+ if _HEXPAIR_RE.match(s) and any(c.isdigit() for c in s):
406
+ return "{id}"
407
+ for kind, pattern in VALUE_PATTERNS:
408
+ m = pattern.search(s)
409
+ if m is None:
410
+ continue
411
+ if kind == "card":
412
+ digits = re.sub(r"\D", "", m.group(0))
413
+ if not _luhn_ok(digits):
414
+ continue
415
+ return "{" + kind + "}"
416
+ # `WMNSDY2019` — a shouting alphanumeric code with letters and digits:
417
+ # a coupon / voucher / invite code, which is a value, not a route name
418
+ if _CODE_RE.match(s) and any(c.isdigit() for c in s) and any(c.isalpha() for c in s):
419
+ return "{code}"
420
+ if _B64ISH_RE.match(s) and shannon_entropy(s) >= 3.5:
421
+ return "{token}"
422
+ if _TOKENISH_RE.match(s) and "." not in s:
423
+ classes = sum(bool(re.search(p, s)) for p in ("[a-z]", "[A-Z]", "[0-9]"))
424
+ digits = sum(c.isdigit() for c in s)
425
+ if classes == 3 and digits >= 2 and shannon_entropy(s) >= 3.0:
426
+ return "{token}"
427
+ return None
428
+
429
+
430
+ def _matrix_split(seg: str):
431
+ """Split a `name;k=v;k2=v2` matrix segment (RFC 3986 path params) or None."""
432
+ if ";" not in seg:
433
+ return None
434
+ base, *parts = seg.split(";")
435
+ return base, [p.partition("=") for p in parts]
436
+
437
+
438
+ def _template_matrix(seg: str) -> str | None:
439
+ """Template a matrix segment: `app;jsessionid=1A2B` → `app;jsessionid={value}`."""
440
+ m = _matrix_split(seg)
441
+ if m is None:
442
+ return None
443
+ base, parts = m
444
+ out = [classify_segment(base) or base]
445
+ for k, eq, v in parts:
446
+ if eq and v and (_is_sensitive_param(k) or classify_segment(v)):
447
+ out.append(f"{k}={{value}}")
448
+ else:
449
+ out.append(k + eq + v)
450
+ return ";".join(out)
451
+
452
+
453
+ # `index-1f38ebe00b8f1bef.js`, `4969.7d2c1afc61e06b53.js`: a build hash in a static asset's name
454
+ # changes on every deploy, so it is a placeholder, or two builds would share no script nodes.
455
+ _HASHED_ASSET = re.compile(r"^(.+?[-._])[0-9a-fA-F]{8,32}(\.(?:js|mjs|css|map|woff2?|png|jpe?g|svg|webp))$")
456
+
457
+
458
+ def template_path(path: str) -> str:
459
+ """Collapse ids and value-like segments to placeholders; drop query/fragment."""
460
+ clean = path.split("?", 1)[0].split("#", 1)[0]
461
+ out = []
462
+ for s in clean.split("/"):
463
+ rep = _template_matrix(s)
464
+ if rep is None:
465
+ rep = classify_segment(s)
466
+ if rep is None:
467
+ hm = _HASHED_ASSET.match(s)
468
+ if hm and not s.endswith((".min.js", ".min.css")) and re.search(r"\d", s[len(hm.group(1)):-len(hm.group(2))] or ""):
469
+ rep = hm.group(1) + "{hash}" + hm.group(2)
470
+ out.append(rep if rep is not None else s)
471
+ templated = "/".join(out)
472
+ return templated or "/"
473
+
474
+
475
+ def redact_path(path: str) -> tuple[str, list[SecretFingerprint]]:
476
+ """Redact a raw request path (segments + query values), keeping its shape."""
477
+ prints: list[SecretFingerprint] = []
478
+ base, sep, query = path.partition("?")
479
+ query = query.split("#", 1)[0]
480
+ segs = []
481
+ for s in base.split("#", 1)[0].split("/"):
482
+ mx = _matrix_split(s)
483
+ if mx is not None:
484
+ mbase, parts = mx
485
+ out_parts = [mbase]
486
+ for k, eq, v in parts:
487
+ if eq and v and (_is_sensitive_param(k) or classify_segment(v)):
488
+ prints.append(fingerprint(urllib.parse.unquote(v), kind="path:" + k.lower()))
489
+ out_parts.append(f"{k}={REDACTED}")
490
+ else:
491
+ out_parts.append(k + eq + v)
492
+ segs.append(";".join(out_parts))
493
+ continue
494
+ rep = classify_segment(s)
495
+ if rep is not None and rep not in ("{id}", "{uuid}", "{hash}"):
496
+ prints.append(fingerprint(urllib.parse.unquote(s), kind="path:" + rep.strip("{}")))
497
+ segs.append(rep)
498
+ else:
499
+ segs.append(s)
500
+ out = "/".join(segs) or "/"
501
+ if sep:
502
+ pairs = urllib.parse.parse_qsl(query, keep_blank_values=True)
503
+ red, more = redact_params(pairs)
504
+ prints.extend(more)
505
+ out += "?" + urllib.parse.urlencode(red, safe="[]:{}")
506
+ return out, prints
507
+
508
+
509
+ # Query parameters that choose WHICH PAGE a front controller renders
510
+ # (`index.php?page=login`, `app.cgi?action=cart`). A path template that drops the
511
+ # query collapses every such page into one node, so these are kept in the template.
512
+ ROUTE_PARAMS = ("page", "action", "view", "module", "route", "section", "tab", "controller",
513
+ "do", "cmd", "mode", "screen", "op", "task", "act", "content", "p")
514
+ _ROUTE_VALUE = re.compile(r"^[A-Za-z0-9_.\-/]{1,60}$")
515
+ _STATIC_PATH = re.compile(r"(?i)\.(?:png|jpe?g|gif|svg|ico|webp|css|js|mjs|map|woff2?|ttf|eot|json|txt|xml|pdf)$")
516
+
517
+
518
+ def route_template(path_with_query: str, base: str) -> str:
519
+ """`base` plus the page-selecting query parameters: /index.php?page=login.php.
520
+
521
+ Numeric values (`?page=2` is pagination), anything shaped like a secret or an id,
522
+ and static assets are left out, so the template stays a small, stable set.
523
+ """
524
+ if _STATIC_PATH.search(base):
525
+ return base
526
+ query = path_with_query.partition("?")[2].split("#", 1)[0]
527
+ keep: dict[str, str] = {}
528
+ for k, v in urllib.parse.parse_qsl(query, keep_blank_values=True):
529
+ k = k.lower()
530
+ if (k in ROUTE_PARAMS and k not in keep and _ROUTE_VALUE.match(v) and not v.isdigit()
531
+ and classify_segment(v) is None and not _is_sensitive_param(k)):
532
+ keep[k] = v
533
+ if not keep:
534
+ return base
535
+ return base + "?" + "&".join(f"{k}={v}" for k, v in sorted(keep.items())[:2])
proxy2model/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """External recon (opt-in)."""