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.
- proxy2model/__init__.py +19 -0
- proxy2model/__main__.py +6 -0
- proxy2model/atomic.py +68 -0
- proxy2model/cli/__init__.py +3 -0
- proxy2model/cli/analyze.py +501 -0
- proxy2model/cli/app.py +408 -0
- proxy2model/cli/browse.py +279 -0
- proxy2model/cli/build.py +399 -0
- proxy2model/cli/common.py +190 -0
- proxy2model/cli/history.py +224 -0
- proxy2model/cli/mcp.py +184 -0
- proxy2model/cli/ui.py +62 -0
- proxy2model/core/__init__.py +1 -0
- proxy2model/core/changes.py +124 -0
- proxy2model/core/context.py +571 -0
- proxy2model/core/diff.py +330 -0
- proxy2model/core/domains.py +98 -0
- proxy2model/core/graph.py +562 -0
- proxy2model/core/methodology.py +343 -0
- proxy2model/core/model.py +998 -0
- proxy2model/core/stack.py +218 -0
- proxy2model/data/fonts/LICENSES.md +16 -0
- proxy2model/data/fonts/inter-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- proxy2model/data/fonts/nippo-200.woff2 +0 -0
- proxy2model/data/fonts/nippo-300.woff2 +0 -0
- proxy2model/data/fonts/nippo-400.woff2 +0 -0
- proxy2model/data/fonts/nippo-500.woff2 +0 -0
- proxy2model/data/fonts/nippo-700.woff2 +0 -0
- proxy2model/data/public_suffix_list.dat +16497 -0
- proxy2model/db/__init__.py +1 -0
- proxy2model/db/bql.py +710 -0
- proxy2model/db/bql_js.py +278 -0
- proxy2model/db/store.py +547 -0
- proxy2model/export/__init__.py +1 -0
- proxy2model/export/openapi.py +248 -0
- proxy2model/ingest/__init__.py +1 -0
- proxy2model/ingest/browse.py +181 -0
- proxy2model/ingest/browser.py +1058 -0
- proxy2model/ingest/crawl.py +673 -0
- proxy2model/ingest/extract.py +208 -0
- proxy2model/ingest/infer.py +75 -0
- proxy2model/ingest/journey.py +148 -0
- proxy2model/ingest/live.py +96 -0
- proxy2model/ingest/parse.py +1275 -0
- proxy2model/ingest/redact.py +535 -0
- proxy2model/py.typed +0 -0
- proxy2model/recon/__init__.py +1 -0
- proxy2model/recon/osint.py +607 -0
- proxy2model/report/__init__.py +1 -0
- proxy2model/report/animate.py +163 -0
- proxy2model/report/dash.py +469 -0
- proxy2model/report/fonts.py +51 -0
- proxy2model/report/html.py +988 -0
- proxy2model/report/inventory.py +258 -0
- proxy2model/report/map.py +515 -0
- proxy2model/report/pages.py +537 -0
- proxy2model/report/query.py +306 -0
- proxy2model/report/shot.py +55 -0
- proxy2model/report/view.py +213 -0
- proxy2model-2.0.0.dist-info/METADATA +148 -0
- proxy2model-2.0.0.dist-info/RECORD +66 -0
- proxy2model-2.0.0.dist-info/WHEEL +5 -0
- proxy2model-2.0.0.dist-info/entry_points.txt +2 -0
- proxy2model-2.0.0.dist-info/licenses/LICENSE +201 -0
- 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)."""
|