attackmap 0.6.4__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 (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,702 @@
1
+ """Token-authentication protocol checks (#243, part 1: JWT).
2
+
3
+ Per-file finder, like ``weaknesses``, for JWT handling that the library calls
4
+ make unsafe. Every check is gated on the file importing the library it judges,
5
+ so ``jwt.decode`` is only read with ``jsonwebtoken`` / PyJWT / python-jose /
6
+ authlib / golang-jwt semantics in a file that imports that library.
7
+
8
+ jwt_alg_confusion verification without a pinned algorithm list
9
+ (jsonwebtoken ``verify`` without ``algorithms``,
10
+ PyJWT ``decode`` without ``algorithms=``, a
11
+ golang-jwt keyfunc that never checks ``t.Method``)
12
+ jwt_unverified_decode identity claims read off a token that the file
13
+ only decodes and never verifies (jsonwebtoken
14
+ ``decode``, jose ``decodeJwt``, python-jose
15
+ ``get_unverified_claims``, golang-jwt
16
+ ``ParseUnverified``)
17
+ jwt_claims_unchecked expiry/claims validation switched off
18
+ (``ignoreExpiration: true``, ``verify_exp: False``,
19
+ ``WithoutClaimsValidation``) or authlib claims
20
+ never ``validate()``d
21
+ jwt_header_trust the token header's ``kid`` built into a file path or
22
+ SQL string, or its ``jku``/``x5u`` URL fetched
23
+ jwt_weak_secret a short HMAC secret hard-coded at the sign/verify
24
+ call
25
+
26
+ The explicit cases (``alg: none``, signature verification switched off) stay
27
+ with ``jwt_weakness`` in ``weaknesses``; nothing here re-flags them.
28
+
29
+ A verifying call with a pinned algorithm list (or jose ``jwtVerify``, which
30
+ binds the algorithm to the key type) is returned as a ``jwt_verified`` auth
31
+ hint. That hint, and only that, credits the "JWT verification" control.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import re
37
+ from pathlib import PurePosixPath
38
+
39
+ from .models import AuthHint, CodeWeakness
40
+ from .srcpaths import JS_TS_SUFFIXES, line_number
41
+
42
+ # HMAC secrets shorter than this are brute-forceable offline from one token.
43
+ _MIN_SECRET_LEN = 32
44
+ _CALL_SCAN_LIMIT = 4000
45
+
46
+
47
+ def _rx(pattern: str, flags: int = 0) -> re.Pattern[str]:
48
+ return re.compile(pattern, re.MULTILINE | flags)
49
+
50
+
51
+ # --- library imports ---------------------------------------------------------
52
+
53
+ _JS_JWT_DEFAULT = _rx(
54
+ r"(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['\"]jsonwebtoken['\"]\s*\)"
55
+ r"|import\s+(?:\*\s+as\s+)?(\w+)\s*(?:,\s*\{[^}]*\})?\s*from\s+['\"]jsonwebtoken['\"]"
56
+ )
57
+ _JS_JWT_NAMED = _rx(
58
+ r"(?:const|let|var)\s*\{([^}]*)\}\s*=\s*require\(\s*['\"]jsonwebtoken['\"]\s*\)"
59
+ r"|import\s+(?:\w+\s*,\s*)?\{([^}]*)\}\s*from\s+['\"]jsonwebtoken['\"]"
60
+ )
61
+ _JS_JOSE = _rx(r"(?:from\s+|require\(\s*)['\"]jose['\"]")
62
+
63
+ # Which library a name is bound to decides the rules: saleor imports
64
+ # `PyJWTError` from PyJWT but calls authlib's `jwt.decode`.
65
+ _PY_JWT_MODULE = _rx(r"^\s*import\s+jwt\s*(?:#.*)?$|^\s*from\s+jose\s+import\s+[^\n]*\bjwt\b")
66
+ _PY_JWT_ALIAS = _rx(r"^\s*import\s+jwt\s+as\s+(\w+)")
67
+ _PY_AUTHLIB_JWT = _rx(r"^\s*from\s+authlib\.jose\s+import\s+[^\n]*\bjwt\b|^\s*jwt\s*=\s*JsonWebToken\s*\(")
68
+ _PY_JWT_NAMED = _rx(r"^\s*from\s+jwt\s+import\s+\(?([^\n)]*)")
69
+ _PY_JOSE = _rx(r"^\s*from\s+jose\s+import\s+[^\n]*\bjwt\b")
70
+
71
+ _GO_JWT = _rx(
72
+ r"^\s*(?:import\s+)?(\w+\s+)?\"github\.com/(?:golang-jwt/jwt|dgrijalva/jwt-go|form3tech-oss/jwt-go)[^\"]*\""
73
+ )
74
+
75
+ # --- shared patterns ---------------------------------------------------------
76
+
77
+ # `kid` / `jku` / `x5u` read off a header. In a file that imports a JWT library
78
+ # any `.kid` counts; elsewhere (a key-lookup helper) only a header-qualified
79
+ # read, so a `user.kid` child record isn't mistaken for a key id.
80
+ _KID_ANY = r"(?:\.kid\b|\[\s*['\"]kid['\"]\s*\])"
81
+ _KID_HEADER = r"(?:\b\w*[hH]eader\s*(?:\.kid\b|\[\s*['\"]kid['\"]\s*\]))"
82
+ _JKU_ANY = r"(?:\.(?:jku|x5u)\b|\[\s*['\"](?:jku|x5u)['\"]\s*\])"
83
+ _JKU_HEADER = r"(?:\b\w*[hH]eader\s*(?:\.(?:jku|x5u)\b|\[\s*['\"](?:jku|x5u)['\"]\s*\]))"
84
+
85
+
86
+ def _header_trust_patterns(kid: str, jku: str) -> tuple[re.Pattern[str], ...]:
87
+ return (
88
+ # kid into a file read / path join.
89
+ _rx(
90
+ r"(?:readFileSync|readFile|createReadStream|path\.(?:join|resolve)|\bopen"
91
+ r"|os\.path\.join|filepath\.Join|os\.ReadFile|ioutil\.ReadFile|os\.Open)\s*\([^\n]*" + kid
92
+ ),
93
+ # kid interpolated into SQL (a bound parameter is fine, so require
94
+ # interpolation: ${...kid}, f"...{...kid}", "..." + ...kid).
95
+ _rx(
96
+ r"\b(?:SELECT|DELETE|UPDATE|INSERT)\b[^\n]*?"
97
+ r"(?:\$\{[^}\n]*" + kid + r"|\{[^}\n]*" + kid + r"[^}\n]*\}|['\"]\s*\+\s*[\w.\[\]'\"]*" + kid + r")",
98
+ re.IGNORECASE,
99
+ ),
100
+ _rx(r"Sprintf\s*\(\s*\"[^\"\n]*\b(?:SELECT|DELETE|UPDATE|INSERT)\b[^\n]*" + kid, re.IGNORECASE),
101
+ # jku / x5u URL fetched or handed to a JWKS client.
102
+ _rx(
103
+ r"(?:\bfetch|\baxios(?:\.get)?|\bgot|\brequests\.get|\burlopen|\bhttp\.Get|\bjwksUri\s*:"
104
+ r"|\bJwksClient|\bjwksClient|\bcreateRemoteJWKSet|\bPyJWKClient)\s*\(?[^\n]*" + jku
105
+ ),
106
+ )
107
+
108
+
109
+ _HEADER_TRUST = _header_trust_patterns(_KID_ANY, _JKU_ANY)
110
+ _HEADER_TRUST_STRICT = _header_trust_patterns(_KID_HEADER, _JKU_HEADER)
111
+
112
+ # Claims that carry identity or privilege, read off a decoded token: `x.sub`,
113
+ # `x?.role`, `x["user_id"]`, `x.Claims.GetSubject()`.
114
+ _CLAIMS = r"(?:sub|subject|role|roles|user|userId|user_id|uid|email|username|scope|scopes|permissions|isAdmin|is_admin|admin)"
115
+ _IDENTITY_READ = (
116
+ r"(?:\??\.(?:Claims\.)?(?:" + _CLAIMS + r"\b|GetSubject\s*\()|\[\s*['\"]" + _CLAIMS + r"['\"]\s*\])"
117
+ )
118
+ _IDENTITY_WINDOW = 1500
119
+
120
+ _GO_METHOD_CHECK = re.compile(r"\.Method\b|SigningMethod|\.Alg\s*\(|WithValidMethods")
121
+ _GO_SHORT_BYTES = _rx(r"\[\]byte\(\s*\"([^\"\n]*)\"\s*\)")
122
+ _GO_SECRET_CONTEXT = re.compile(r"SignedString|\breturn\b|key|secret", re.IGNORECASE)
123
+ _JOSE_SHORT_KEY = _rx(r"new\s+TextEncoder\(\s*\)\s*\.encode\(\s*(['\"`])([^'\"`\n$]*)\1\s*\)")
124
+ _JS_FUNCTION_ARG = re.compile(r"^(?:async\s+)?(?:function\b|\([^)]*\)\s*=>|\w+\s*=>)")
125
+ _PY_STRING = re.compile(r"^[rRbBuU]{0,2}(['\"])(.*)\1$", re.DOTALL)
126
+ _JS_STRING = re.compile(r"^(['\"`])(.*)\1$", re.DOTALL)
127
+
128
+
129
+ _LINE_COMMENT = re.compile(r"(?<!:)//")
130
+
131
+
132
+ class _Collector:
133
+ def __init__(self, content: str, rel_file: str, comment: str) -> None:
134
+ self.content = content
135
+ self.rel_file = rel_file
136
+ self.comment = comment
137
+ self.weaknesses: list[CodeWeakness] = []
138
+ self.verified: list[AuthHint] = []
139
+ self._seen: set[tuple[str, int]] = set()
140
+
141
+ def commented(self, offset: int) -> bool:
142
+ """True if ``offset`` follows a line comment marker on its line. A
143
+ ``//`` right after ``:`` is a URL scheme (``https://``), not a comment."""
144
+ line_start = self.content.rfind("\n", 0, offset) + 1
145
+ prefix = self.content[line_start:offset]
146
+ if self.comment == "//":
147
+ return _LINE_COMMENT.search(prefix) is not None
148
+ return self.comment in prefix
149
+
150
+ def weakness(self, kind: str, severity: str, offset: int, note: str | None = None) -> None:
151
+ line = line_number(self.content, offset)
152
+ if (kind, line) in self._seen:
153
+ return
154
+ self._seen.add((kind, line))
155
+ snippet = _snippet(self.content, offset)
156
+ self.weaknesses.append(
157
+ CodeWeakness(
158
+ kind=kind, # type: ignore[arg-type]
159
+ file=self.rel_file,
160
+ line=line,
161
+ evidence_text=f"{snippet} ({note})" if note else snippet,
162
+ severity=severity, # type: ignore[arg-type]
163
+ source_analyzer="auth_protocol",
164
+ )
165
+ )
166
+
167
+ def unverified_decode(self, offset: int) -> None:
168
+ """Report a decode with no verify in the file, when the claims it
169
+ returns are read for identity nearby. Reading `iat`/`exp` off the
170
+ server's own stored token (juice-shop's user list) is not trust."""
171
+ if _reads_identity(self.content, offset):
172
+ self.weakness("jwt_unverified_decode", "high", offset, "decoded but never verified in this file")
173
+
174
+ def verified_at(self, offset: int) -> None:
175
+ if self.verified:
176
+ return # one hint per file is enough to credit the control
177
+ self.verified.append(
178
+ AuthHint(
179
+ hint="jwt_verified",
180
+ file=self.rel_file,
181
+ line=line_number(self.content, offset),
182
+ evidence_text=_snippet(self.content, offset),
183
+ confidence=0.9,
184
+ source_analyzer="auth_protocol",
185
+ )
186
+ )
187
+
188
+ def header_trust(self, patterns: tuple[re.Pattern[str], ...] = _HEADER_TRUST) -> None:
189
+ for pattern in patterns:
190
+ for match in pattern.finditer(self.content):
191
+ if not self.commented(match.start()):
192
+ self.weakness("jwt_header_trust", "high", match.start())
193
+
194
+
195
+ def find_jwt_issues(content: str, rel_file: str) -> tuple[list[CodeWeakness], list[AuthHint]]:
196
+ """JWT weaknesses in one file, plus a ``jwt_verified`` hint when the file
197
+ verifies tokens with a pinned algorithm."""
198
+ suffix = PurePosixPath(rel_file).suffix.lower()
199
+ if suffix in JS_TS_SUFFIXES:
200
+ collector = _Collector(content, rel_file, "//")
201
+ _check_js(collector)
202
+ elif suffix == ".py":
203
+ collector = _Collector(content, rel_file, "#")
204
+ _check_python(collector)
205
+ elif suffix == ".go":
206
+ collector = _Collector(content, rel_file, "//")
207
+ _check_go(collector)
208
+ else:
209
+ return [], []
210
+ return collector.weaknesses, collector.verified
211
+
212
+
213
+ # --- Node: jsonwebtoken + jose ------------------------------------------------
214
+
215
+
216
+ def _check_js(c: _Collector) -> None:
217
+ content = c.content
218
+ defaults = {m.group(1) or m.group(2) for m in _JS_JWT_DEFAULT.finditer(content)}
219
+ named: dict[str, str] = {}
220
+ for m in _JS_JWT_NAMED.finditer(content):
221
+ for part in (m.group(1) or m.group(2) or "").split(","):
222
+ pieces = re.split(r"\s+as\s+|\s*:\s*", part.strip())
223
+ if pieces and pieces[0] in {"verify", "decode", "sign"}:
224
+ named[pieces[-1]] = pieces[0]
225
+ uses_jose = _JS_JOSE.search(content) is not None
226
+ if not defaults and not named and not uses_jose:
227
+ c.header_trust(_HEADER_TRUST_STRICT)
228
+ return
229
+
230
+ calls: list[tuple[str, int, list[str]]] = [] # (method, offset, args)
231
+ if defaults:
232
+ member = _rx(r"\b(?:" + "|".join(map(re.escape, sorted(defaults))) + r")\.(verify|decode|sign)\s*\(")
233
+ for m in member.finditer(content):
234
+ calls.append((m.group(1), m.start(), _call_args(content, m.end())))
235
+ if named:
236
+ bare = _rx(r"(?<![\w.])(" + "|".join(map(re.escape, sorted(named))) + r")\s*\(")
237
+ for m in bare.finditer(content):
238
+ calls.append((named[m.group(1)], m.start(), _call_args(content, m.end())))
239
+ calls = [call for call in calls if not c.commented(call[1])]
240
+
241
+ verifies = [call for call in calls if call[0] == "verify"]
242
+ for method, offset, args in calls:
243
+ if method in {"verify", "sign"} and len(args) > 1 and _short_literal(args[1], _JS_STRING):
244
+ c.weakness("jwt_weak_secret", "high", offset, f"{method} with a hard-coded secret under {_MIN_SECRET_LEN} chars")
245
+ if method != "verify" or len(args) < 2:
246
+ continue
247
+ options = args[2] if len(args) > 2 and not _JS_FUNCTION_ARG.match(args[2]) else ""
248
+ if options.startswith("{"):
249
+ pinned = re.search(r"\balgorithms\s*:", options) is not None
250
+ elif options:
251
+ # Options passed by name: give the benefit of the doubt when the
252
+ # file pins algorithms anywhere.
253
+ pinned = re.search(r"\balgorithms\s*:", content) is not None
254
+ else:
255
+ pinned = False
256
+ if pinned:
257
+ c.verified_at(offset)
258
+ elif not _looks_like_hmac_secret(args[1], _JS_STRING):
259
+ # An HMAC secret can't be swapped for a public key, so confusion
260
+ # only applies to keys that may be asymmetric.
261
+ c.weakness("jwt_alg_confusion", "high", offset, "no `algorithms` allow-list")
262
+
263
+ jose_verify = _rx(r"(?<![\w.])jwtVerify\s*\(") if uses_jose else None
264
+ jose_verifies = [m for m in jose_verify.finditer(content) if not c.commented(m.start())] if jose_verify else []
265
+ for m in jose_verifies:
266
+ c.verified_at(m.start())
267
+
268
+ if not verifies and not jose_verifies:
269
+ for method, offset, _ in calls:
270
+ if method == "decode":
271
+ c.unverified_decode(offset)
272
+ if uses_jose:
273
+ for m in _rx(r"(?<![\w.])decodeJwt\s*\(").finditer(content):
274
+ if not c.commented(m.start()):
275
+ c.unverified_decode(m.start())
276
+
277
+ if defaults or named:
278
+ for m in _rx(r"\bignoreExpiration\s*:\s*true\b").finditer(content):
279
+ if not c.commented(m.start()):
280
+ c.weakness("jwt_claims_unchecked", "medium", m.start(), "expiry not checked")
281
+ if uses_jose:
282
+ for m in _JOSE_SHORT_KEY.finditer(content):
283
+ if len(m.group(2)) < _MIN_SECRET_LEN and not c.commented(m.start()):
284
+ c.weakness("jwt_weak_secret", "high", m.start(), f"hard-coded secret under {_MIN_SECRET_LEN} chars")
285
+ c.header_trust()
286
+
287
+
288
+ # --- Python: PyJWT, python-jose, authlib -------------------------------------
289
+
290
+
291
+ def _check_python(c: _Collector) -> None:
292
+ content = c.content
293
+ names = {m.group(1) for m in _PY_JWT_ALIAS.finditer(content)}
294
+ uses_authlib = _PY_AUTHLIB_JWT.search(content) is not None
295
+ if _PY_JWT_MODULE.search(content) and not uses_authlib:
296
+ names.add("jwt")
297
+ bare: dict[str, str] = {}
298
+ for m in _PY_JWT_NAMED.finditer(content):
299
+ for part in m.group(1).split(","):
300
+ pieces = part.strip().split(" as ")
301
+ if pieces[0].strip() in {"decode", "encode"}:
302
+ bare[pieces[-1].strip()] = pieces[0].strip()
303
+ if not names and not bare and not uses_authlib:
304
+ c.header_trust(_HEADER_TRUST_STRICT)
305
+ return
306
+ if uses_authlib:
307
+ _check_authlib(c)
308
+ if not names and not bare:
309
+ c.header_trust()
310
+ return
311
+
312
+ calls: list[tuple[str, int, list[str]]] = []
313
+ if names:
314
+ member = _rx(r"\b(?:" + "|".join(map(re.escape, sorted(names))) + r")\.(decode|encode)\s*\(")
315
+ for m in member.finditer(content):
316
+ calls.append((m.group(1), m.start(), _call_args(content, m.end())))
317
+ if bare:
318
+ for m in _rx(r"(?<![\w.])(" + "|".join(map(re.escape, sorted(bare))) + r")\s*\(").finditer(content):
319
+ calls.append((bare[m.group(1)], m.start(), _call_args(content, m.end())))
320
+
321
+ verifies = False
322
+ for method, offset, args in calls:
323
+ if c.commented(offset):
324
+ continue
325
+ positional = [a for a in args if not re.match(r"^\*{0,2}\w+\s*=(?!=)|^\*\*", a)]
326
+ keywords = {a.split("=", 1)[0].strip(): a.split("=", 1)[1].strip() for a in args if re.match(r"^\w+\s*=(?!=)", a)}
327
+ key = positional[1] if len(positional) > 1 else keywords.get("key", "")
328
+ if key and _short_literal(key, _PY_STRING):
329
+ c.weakness("jwt_weak_secret", "high", offset, f"{method} with a hard-coded secret under {_MIN_SECRET_LEN} chars")
330
+ if method != "decode":
331
+ continue
332
+ call_text = ", ".join(args)
333
+ if re.search(r"verify\s*=\s*False|verify_signature['\"]?\s*:\s*False", call_text):
334
+ continue # jwt_weakness already reports it
335
+ verifies = True
336
+ if re.search(r"['\"]verify_exp['\"]\s*:\s*False", call_text):
337
+ c.weakness("jwt_claims_unchecked", "medium", offset, "expiry not checked")
338
+ if "algorithms" in keywords:
339
+ c.verified_at(offset)
340
+ elif not any(a.startswith("**") for a in args) and not _looks_like_hmac_secret(key, _PY_STRING):
341
+ c.weakness("jwt_alg_confusion", "high", offset, "no `algorithms=` allow-list")
342
+
343
+ if _PY_JOSE.search(content) and not verifies:
344
+ for m in _rx(r"\bjwt\.get_unverified_claims\s*\(").finditer(content):
345
+ if not c.commented(m.start()):
346
+ c.unverified_decode(m.start())
347
+ c.header_trust()
348
+
349
+
350
+ def _check_authlib(c: _Collector) -> None:
351
+ """authlib's ``jwt.decode`` checks the signature but not the claims: they
352
+ are only validated by an explicit ``claims.validate()``."""
353
+ content = c.content
354
+ decodes = [m for m in _rx(r"\bjwt\.decode\s*\(").finditer(content) if not c.commented(m.start())]
355
+ if not decodes:
356
+ return
357
+ if re.search(r"\.validate\s*\(", content):
358
+ c.verified_at(decodes[0].start())
359
+ return
360
+ for m in decodes:
361
+ c.weakness("jwt_claims_unchecked", "medium", m.start(), "authlib claims never validate()d")
362
+
363
+
364
+ # --- Go: golang-jwt / jwt-go --------------------------------------------------
365
+
366
+
367
+ def _check_go(c: _Collector) -> None:
368
+ content = c.content
369
+ imports = list(_GO_JWT.finditer(content))
370
+ if not imports:
371
+ c.header_trust(_HEADER_TRUST_STRICT)
372
+ return
373
+ names = {(m.group(1) or "jwt").strip() for m in imports}
374
+ alias = "|".join(map(re.escape, sorted(names)))
375
+
376
+ parses = [
377
+ m for m in _rx(r"\b(?:" + alias + r")\.(Parse|ParseWithClaims)\s*\(").finditer(content)
378
+ if not c.commented(m.start())
379
+ ]
380
+ for m in parses:
381
+ args = _call_args(content, m.end())
382
+ # An inline keyfunc is judged on its own body; a named one (or options
383
+ # like WithValidMethods) could be anywhere in the file.
384
+ keyfunc = next((a for a in args if a.startswith("func")), None)
385
+ if _GO_METHOD_CHECK.search(keyfunc or content) or "WithValidMethods" in ", ".join(args):
386
+ c.verified_at(m.start())
387
+ else:
388
+ c.weakness("jwt_alg_confusion", "high", m.start(), "keyfunc never checks token.Method")
389
+ # A parser built with WithValidMethods and used as p.Parse(...).
390
+ valid_methods = re.search(r"\bWithValidMethods\s*\(", content)
391
+ if not parses and valid_methods:
392
+ c.verified_at(valid_methods.start())
393
+
394
+ if not parses and not valid_methods:
395
+ for m in _rx(r"\.ParseUnverified\s*\(").finditer(content):
396
+ if not c.commented(m.start()):
397
+ c.unverified_decode(m.start())
398
+ for m in _rx(r"\bWithoutClaimsValidation\s*\(|\bSkipClaimsValidation\s*:\s*true\b").finditer(content):
399
+ if not c.commented(m.start()):
400
+ c.weakness("jwt_claims_unchecked", "medium", m.start(), "claims validation disabled")
401
+ for m in _GO_SHORT_BYTES.finditer(content):
402
+ if len(m.group(1)) >= _MIN_SECRET_LEN or c.commented(m.start()):
403
+ continue
404
+ line_start = content.rfind("\n", 0, m.start()) + 1
405
+ line_end = content.find("\n", m.end())
406
+ line = content[line_start : line_end if line_end != -1 else len(content)]
407
+ if _GO_SECRET_CONTEXT.search(line):
408
+ c.weakness("jwt_weak_secret", "high", m.start(), f"hard-coded secret under {_MIN_SECRET_LEN} chars")
409
+ c.header_trust()
410
+
411
+
412
+ # --- OAuth clients (#243, part 2) ---------------------------------------------
413
+
414
+ # A hand-built authorization request: `response_type=` in a URL, or a
415
+ # `response_type` key in an object / dict / PHP array / Go url.Values. A
416
+ # keyword argument (`response_type="code"`, a library call that manages state
417
+ # itself) and a read (`args.get("response_type")`) are not constructions.
418
+ _AUTHZ_REQUEST = _rx(
419
+ r"[?&]response_type=(?P<url>[\w%+]+)"
420
+ r"|\bresponse_type['\"]?\s*:\s*(?P<q1>['\"`])(?P<obj>[^'\"`\n]*)(?P=q1)"
421
+ r"|['\"]response_type['\"]\s*=>\s*(?P<q2>['\"])(?P<php>[^'\"\n]*)(?P=q2)"
422
+ r"|\b(?:Set|Add)\(\s*\"response_type\"\s*,\s*\"(?P<go>[^\"\n]*)\""
423
+ )
424
+ _CLIENT_ID = re.compile(r"client_id|clientId|client-id|ClientID", re.IGNORECASE)
425
+ # `state` as a request parameter, not React's useState / this.state.
426
+ _STATE_PARAM = re.compile(r"['\"]state['\"]|[?&]state=|\bstate\s*[:=](?!=)|[{,]\s*state\s*[,}]")
427
+ _CODE_CHALLENGE = re.compile(r"code_challenge|codeChallenge", re.IGNORECASE)
428
+ _CLIENT_SECRET = re.compile(r"client_secret|clientSecret|ClientSecret", re.IGNORECASE)
429
+ _BROWSER_CLIENT = re.compile(
430
+ r"\bwindow\.location|\blocation\.(?:href|assign|replace)\b|\bLinking\.openURL|openAuthSessionAsync"
431
+ r"|\bchrome\.identity|\bwindow\.open\s*\("
432
+ )
433
+ _PKCE_DISABLED = _rx(
434
+ r"\busePKCE\s*:\s*false\b|\bpkce\s*:\s*false\b"
435
+ r"|code_challenge_method['\"]?\s*[:=]\s*['\"]plain['\"]|code_challenge_method=plain(?=[&'\"`#\s]|$)",
436
+ re.IGNORECASE,
437
+ )
438
+ _REQUEST_WINDOW_LINES = 8
439
+
440
+ # passport OAuth2 strategies take `state: true` (or a state store); without it
441
+ # passport-oauth2 sends no state. OAuth1 strategies use consumerKey, not
442
+ # clientID, and OIDC/SAML strategies manage state themselves.
443
+ _PASSPORT_STRATEGY = _rx(r"\bnew\s+(\w*Strategy)\s*\(\s*\{")
444
+ _PASSPORT_IMPORT = _rx(r"['\"]passport-[\w-]+['\"]")
445
+ _PASSPORT_SKIP = re.compile(r"openid|oidc|saml|local|jwt|bearer|anonymous|headerapikey", re.IGNORECASE)
446
+
447
+ # Prefix / suffix / substring checks on a redirect URI, and wildcard
448
+ # registrations. Matching must be exact (RFC 9700 §4.1.3).
449
+ _REDIRECT_VAR = r"(?<![\w.$])[\w.$]*?redirect_?ur[il]\w*"
450
+ _REDIRECT_LOOSE = (
451
+ _rx(_REDIRECT_VAR + r"\s*\.\s*(?:startsWith|startswith|endsWith|endswith|includes|indexOf|contains)\s*\(", re.IGNORECASE),
452
+ _rx(r"['\"][^'\"\n]*['\"]\s+in\s+" + _REDIRECT_VAR + r"\b", re.IGNORECASE),
453
+ _rx(r"\bstrings\.(?:HasPrefix|HasSuffix|Contains)\s*\(\s*" + _REDIRECT_VAR, re.IGNORECASE),
454
+ _rx(r"\b(?:str_starts_with|str_ends_with|str_contains|strpos|stripos)\s*\(\s*" + _REDIRECT_VAR, re.IGNORECASE),
455
+ _rx(r"\bredirect_?uris?['\"]?\s*[:=]\s*\[?\s*['\"][^'\"\n]*\*", re.IGNORECASE),
456
+ )
457
+
458
+ # `ALLOWED_REDIRECT_URIS.includes(uri)` / `'x' in REGISTERED_REDIRECT_URIS` is
459
+ # exact membership in a collection, the correct check.
460
+ _MEMBERSHIP_IN_COLLECTION = re.compile(
461
+ r"(?:ur[il]s|list|set|allowed\w*|registered\w*|allowlist|whitelist)\b\s*(?:\.\s*(?:includes|indexOf|contains)\s*\(|$)",
462
+ re.IGNORECASE,
463
+ )
464
+
465
+ def find_oauth_session_issues(content: str, rel_file: str) -> tuple[list[CodeWeakness], bool]:
466
+ """OAuth client and session weaknesses in one file, plus whether the file
467
+ sets up a server-side session store (``session_fixation`` in JS only
468
+ applies when one exists somewhere in the repo; the scanner filters)."""
469
+ suffix = PurePosixPath(rel_file).suffix.lower()
470
+ comment = "#" if suffix == ".py" else "//"
471
+ c = _Collector(content, rel_file, comment)
472
+ if suffix in JS_TS_SUFFIXES | {".py", ".go", ".php"}:
473
+ _check_oauth(c, suffix)
474
+ server_store = False
475
+ if suffix in JS_TS_SUFFIXES:
476
+ server_store = _check_js_session(c)
477
+ elif suffix == ".py":
478
+ _check_flask_session(c)
479
+ elif suffix == ".go":
480
+ _check_gorilla_session(c)
481
+ elif suffix == ".php":
482
+ _check_php_session(c)
483
+ return c.weaknesses, server_store
484
+
485
+
486
+ def _check_oauth(c: _Collector, suffix: str) -> None:
487
+ content = c.content
488
+ lines: list[str] | None = None
489
+ public_client = _CLIENT_SECRET.search(content) is None and (
490
+ suffix in {".jsx", ".tsx"} or _BROWSER_CLIENT.search(content) is not None
491
+ )
492
+ for m in _AUTHZ_REQUEST.finditer(content):
493
+ if c.commented(m.start()):
494
+ continue
495
+ if lines is None:
496
+ lines = content.split("\n")
497
+ line = content.count("\n", 0, m.start())
498
+ window = "\n".join(lines[max(0, line - _REQUEST_WINDOW_LINES) : line + _REQUEST_WINDOW_LINES + 1])
499
+ if not _CLIENT_ID.search(window):
500
+ continue # `response_type` outside an OAuth client request
501
+ value = m.group("url") or m.group("obj") or m.group("php") or m.group("go") or ""
502
+ words = set(re.split(r"%20|\+|\s+", value.lower()))
503
+ if not _STATE_PARAM.search(window):
504
+ c.weakness("oauth_missing_state", "medium", m.start(), "authorization request without `state`")
505
+ if "token" in words:
506
+ c.weakness("oauth_implicit_flow", "medium", m.start(), "access token returned in the redirect")
507
+ elif "code" in words and public_client and not _CODE_CHALLENGE.search(window):
508
+ c.weakness("oauth_missing_pkce", "medium", m.start(), "public client without `code_challenge`")
509
+ for m in _PKCE_DISABLED.finditer(content):
510
+ if not c.commented(m.start()):
511
+ c.weakness("oauth_missing_pkce", "medium", m.start(), "PKCE disabled or `plain`")
512
+ if suffix in JS_TS_SUFFIXES and _PASSPORT_IMPORT.search(content):
513
+ for m in _PASSPORT_STRATEGY.finditer(content):
514
+ if c.commented(m.start()) or _PASSPORT_SKIP.search(m.group(1)):
515
+ continue
516
+ options = (_call_args(content, m.end() - 1) or [""])[0]
517
+ if (
518
+ re.search(r"\bclientID\s*:", options)
519
+ and not re.search(r"\bstate\s*:\s*true\b|\bstore\s*:|\bpkce\s*:\s*true\b", options)
520
+ ):
521
+ c.weakness("oauth_missing_state", "medium", m.start(), f"{m.group(1)} without `state: true`")
522
+ for pattern in _REDIRECT_LOOSE:
523
+ for m in pattern.finditer(content):
524
+ if c.commented(m.start()) or _MEMBERSHIP_IN_COLLECTION.search(m.group(0)):
525
+ continue
526
+ c.weakness("oauth_redirect_uri_loose", "high", m.start(), "redirect URI not matched exactly")
527
+
528
+
529
+ # --- sessions (#243, part 2) ----------------------------------------------------
530
+
531
+ _JS_SESSION_LIB = r"(?:express-session|cookie-session|@fastify/session|@fastify/secure-session|@fastify/cookie|koa-session|koa-generic-session)"
532
+ _JS_SESSION_IMPORT = _rx(
533
+ r"(?:const|let|var)\s+(\w+)\s*=\s*require\(\s*['\"](?P<lib1>" + _JS_SESSION_LIB + r")['\"]\s*\)"
534
+ r"|import\s+(\w+)\s+from\s+['\"](?P<lib2>" + _JS_SESSION_LIB + r")['\"]"
535
+ )
536
+ _JS_SERVER_STORE = _rx(r"['\"](?:express-session|@fastify/session|koa-generic-session)['\"]")
537
+ _JS_SESSION_SECRET = _rx(r"\b(?:secret|keys)\s*:\s*\[?\s*(['\"`])([^'\"`$\n]*)\1")
538
+ _JS_SESSION_LOGIN = _rx(
539
+ r"\breq\.session\.(?:user|userId|user_id|uid|account|accountId|authenticated|loggedIn|logged_in"
540
+ r"|isAuthenticated|isLoggedIn|username|email)\s*=(?!=)(?!\s*(?:null\b|undefined\b|false\b|''|\"\"))"
541
+ )
542
+ _PHP_SESSION_LOGIN = _rx(
543
+ r"\$_SESSION\s*\[\s*['\"](?:user|user_id|userid|uid|username|logged_in|loggedin|is_logged_in|auth"
544
+ r"|authenticated|account_id|email)['\"]\s*\]\s*=(?!=)(?!\s*(?:null\b|false\b|''|\"\"))",
545
+ re.IGNORECASE,
546
+ )
547
+ _FLASK = _rx(r"^\s*(?:from\s+flask\b|import\s+flask\b)")
548
+ _FLASK_SECRET = _rx(
549
+ r"\.secret_key\s*=\s*[rb]?(['\"])([^'\"\n]*)\1"
550
+ r"|\bSECRET_KEY['\"]?\s*[:=]\s*[rb]?(['\"])([^'\"\n]*)\3"
551
+ r"|environ\.get\(\s*['\"](?:FLASK_)?SECRET_KEY['\"]\s*,\s*[rb]?(['\"])([^'\"\n]*)\5"
552
+ r"|getenv\(\s*['\"](?:FLASK_)?SECRET_KEY['\"]\s*,\s*[rb]?(['\"])([^'\"\n]*)\7"
553
+ )
554
+ _GORILLA = _rx(r"\"github\.com/gorilla/sessions\"")
555
+ _GORILLA_SECRET = _rx(r"\bsessions\.New(?:Cookie|Filesystem)Store\s*\(\s*\[\]byte\(\s*\"([^\"\n]*)\"")
556
+
557
+
558
+ def _check_js_session(c: _Collector) -> bool:
559
+ content = c.content
560
+ imports = list(_JS_SESSION_IMPORT.finditer(content))
561
+ calls: list[int] = []
562
+ if imports:
563
+ names = sorted({m.group(1) or m.group(3) for m in imports})
564
+ for m in _rx(r"(?<![\w.])(?:" + "|".join(map(re.escape, names)) + r")\s*\(").finditer(content):
565
+ calls.append(m.end())
566
+ for m in _rx(r"\bregister\s*\(\s*(?:require\(\s*)?['\"]?@fastify/(?:secure-)?session").finditer(content):
567
+ calls.append(content.find(",", m.end()) + 1)
568
+ for offset in calls:
569
+ if offset <= 0 or c.commented(offset - 1):
570
+ continue
571
+ options = ", ".join(_call_args(content, offset))
572
+ for s in _JS_SESSION_SECRET.finditer(options):
573
+ if len(s.group(2)) < _MIN_SECRET_LEN:
574
+ c.weakness("session_weak_secret", "high", offset - 1, f"hard-coded session secret under {_MIN_SECRET_LEN} chars")
575
+ break
576
+ if not re.search(r"\.regenerate\s*\(", content):
577
+ for m in _JS_SESSION_LOGIN.finditer(content):
578
+ if not c.commented(m.start()):
579
+ c.weakness("session_fixation", "medium", m.start(), "login stored in the session without `regenerate()`")
580
+ return _JS_SERVER_STORE.search(content) is not None
581
+
582
+
583
+ def _check_flask_session(c: _Collector) -> None:
584
+ if not _FLASK.search(c.content):
585
+ return
586
+ for m in _FLASK_SECRET.finditer(c.content):
587
+ value = next(g for g in (m.group(2), m.group(4), m.group(6), m.group(8)) if g is not None)
588
+ if len(value) < _MIN_SECRET_LEN and not c.commented(m.start()):
589
+ c.weakness("session_weak_secret", "high", m.start(), f"Flask secret key under {_MIN_SECRET_LEN} chars")
590
+
591
+
592
+ def _check_gorilla_session(c: _Collector) -> None:
593
+ if not _GORILLA.search(c.content):
594
+ return
595
+ for m in _GORILLA_SECRET.finditer(c.content):
596
+ if len(m.group(1)) < _MIN_SECRET_LEN and not c.commented(m.start()):
597
+ c.weakness("session_weak_secret", "high", m.start(), f"session key under {_MIN_SECRET_LEN} chars")
598
+
599
+
600
+ def _check_php_session(c: _Collector) -> None:
601
+ if re.search(r"\bsession_regenerate_id\s*\(", c.content):
602
+ return
603
+ for m in _PHP_SESSION_LOGIN.finditer(c.content):
604
+ if not c.commented(m.start()):
605
+ c.weakness("session_fixation", "medium", m.start(), "login stored in $_SESSION without session_regenerate_id()")
606
+
607
+
608
+ # --- helpers ------------------------------------------------------------------
609
+
610
+
611
+ def _reads_identity(content: str, offset: int) -> bool:
612
+ """True if identity claims are read off the decode call at ``offset``:
613
+ chained on the call itself, or off the variable it is assigned to."""
614
+ open_paren = content.find("(", offset)
615
+ args_end = open_paren + 1 + len(", ".join(_call_args(content, open_paren + 1)))
616
+ close = content.find(")", args_end)
617
+ if close != -1 and re.match(_IDENTITY_READ, content[close + 1 : close + 40]):
618
+ return True
619
+ var = _assigned_name(content[content.rfind("\n", 0, offset) + 1 : offset])
620
+ if var is None:
621
+ return False
622
+ var = re.escape(var)
623
+ window = content[offset : offset + _IDENTITY_WINDOW]
624
+ return re.search(r"\b" + var + _IDENTITY_READ, window) is not None
625
+
626
+
627
+ def _assigned_name(prefix: str) -> str | None:
628
+ """The first name assigned from the expression ending ``prefix``:
629
+ ``claims`` in ``const claims = jwt.``, ``token`` in ``token, _, err :=
630
+ jwt.NewParser().``. A right-to-left scan, not a regex: an anchored pattern
631
+ retried at every offset of a long minified line is quadratic (#236)."""
632
+ i = len(prefix)
633
+ while i and (prefix[i - 1].isalnum() or prefix[i - 1] in "_.() \t"):
634
+ i -= 1 # the receiver chain, `await`, spaces
635
+ if prefix[i - 1 : i] != "=" or prefix[i - 2 : i - 1] in {"=", "!", "<", ">"}:
636
+ return None
637
+ i -= 2 if prefix[i - 2 : i - 1] == ":" else 1
638
+ lhs = prefix[:i].rstrip().rsplit(";", 1)[-1]
639
+ first = lhs.split(",")[0].split()
640
+ name = first[-1] if first else ""
641
+ return name if name.isidentifier() else None
642
+
643
+
644
+ def _call_args(content: str, offset: int) -> list[str]:
645
+ """Top-level arguments of the call whose ``(`` ends at ``offset``. Bounded,
646
+ and string-aware enough that commas in literals don't split."""
647
+ args: list[str] = []
648
+ depth = 0
649
+ quote: str | None = None
650
+ start = offset
651
+ end = min(len(content), offset + _CALL_SCAN_LIMIT)
652
+ i = offset
653
+ while i < end:
654
+ ch = content[i]
655
+ if quote:
656
+ if ch == "\\":
657
+ i += 2
658
+ continue
659
+ if ch == quote:
660
+ quote = None
661
+ elif ch in "'\"`":
662
+ quote = ch
663
+ elif ch in "([{":
664
+ depth += 1
665
+ elif ch in ")]}":
666
+ if depth == 0:
667
+ break
668
+ depth -= 1
669
+ elif ch == "," and depth == 0:
670
+ args.append(content[start:i].strip())
671
+ start = i + 1
672
+ i += 1
673
+ tail = content[start:i].strip()
674
+ if tail:
675
+ args.append(tail)
676
+ return args
677
+
678
+
679
+ def _literal(arg: str, pattern: re.Pattern[str]) -> str | None:
680
+ m = pattern.match(arg.strip())
681
+ if m is None or "${" in m.group(2) or "\n" in m.group(2):
682
+ return None
683
+ return m.group(2)
684
+
685
+
686
+ def _short_literal(arg: str, pattern: re.Pattern[str]) -> bool:
687
+ value = _literal(arg, pattern)
688
+ return value is not None and len(value) < _MIN_SECRET_LEN
689
+
690
+
691
+ def _looks_like_hmac_secret(arg: str, pattern: re.Pattern[str]) -> bool:
692
+ return _literal(arg, pattern) is not None or re.search(r"secret", arg, re.IGNORECASE) is not None
693
+
694
+
695
+ def _snippet(content: str, offset: int, radius: int = 120) -> str:
696
+ start = content.rfind("\n", 0, offset) + 1
697
+ end = content.find("\n", offset)
698
+ line = content[start : end if end != -1 else len(content)].strip()
699
+ return line[:radius] + ("…" if len(line) > radius else "")
700
+
701
+
702
+ __all__ = ["find_jwt_issues", "find_oauth_session_issues"]