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.
- attackmap/__init__.py +2 -0
- attackmap/analyzer.py +449 -0
- attackmap/analyzer_contracts.py +194 -0
- attackmap/analyzers.py +1003 -0
- attackmap/anomalies.py +612 -0
- attackmap/asset_model.py +245 -0
- attackmap/attack_taxonomy.py +133 -0
- attackmap/auth_protocol.py +702 -0
- attackmap/authz.py +1695 -0
- attackmap/bench.py +467 -0
- attackmap/cli.py +1474 -0
- attackmap/config_scanner.py +389 -0
- attackmap/context_pack.py +82 -0
- attackmap/contracts.py +201 -0
- attackmap/control_model.py +280 -0
- attackmap/crossrepo.py +356 -0
- attackmap/crypto.py +240 -0
- attackmap/cve.py +717 -0
- attackmap/defensive_review.py +647 -0
- attackmap/detection_opportunities.py +1221 -0
- attackmap/detections/__init__.py +491 -0
- attackmap/detections/bola.yml +57 -0
- attackmap/detections/cloud.yml +609 -0
- attackmap/detections/injection.yml +167 -0
- attackmap/detections/secrets.yml +48 -0
- attackmap/detections/sigma.py +511 -0
- attackmap/detections/ssrf.yml +98 -0
- attackmap/detections/weakness.yml +62 -0
- attackmap/detections/webhook.yml +45 -0
- attackmap/detections/workflow.yml +121 -0
- attackmap/diagrams.py +237 -0
- attackmap/diff.py +301 -0
- attackmap/exploitability.py +493 -0
- attackmap/fingerprint.py +91 -0
- attackmap/fleet.py +370 -0
- attackmap/gitref.py +129 -0
- attackmap/graph.py +26 -0
- attackmap/handler_auth.py +639 -0
- attackmap/handler_span.py +62 -0
- attackmap/hunt_harness.py +505 -0
- attackmap/insights.py +701 -0
- attackmap/llm_review.py +846 -0
- attackmap/lockfiles.py +521 -0
- attackmap/mass_assignment.py +543 -0
- attackmap/md.py +175 -0
- attackmap/merge.py +333 -0
- attackmap/models.py +1284 -0
- attackmap/plugins_lock.py +114 -0
- attackmap/popular_packages.py +193 -0
- attackmap/progress.py +258 -0
- attackmap/recon_models.py +31 -0
- attackmap/recon_to_analysis.py +149 -0
- attackmap/redact.py +220 -0
- attackmap/report.py +424 -0
- attackmap/review_eval.py +335 -0
- attackmap/review_json.py +308 -0
- attackmap/review_prompts.py +796 -0
- attackmap/route_auth_fusion.py +474 -0
- attackmap/safe_fs.py +285 -0
- attackmap/sarif.py +373 -0
- attackmap/sbom.py +418 -0
- attackmap/scanner.py +1379 -0
- attackmap/sdk/__init__.py +278 -0
- attackmap/sdk/contracts.py +17 -0
- attackmap/sdk/fs.py +156 -0
- attackmap/sdk/models.py +59 -0
- attackmap/secrets_history.py +161 -0
- attackmap/security_overlay.py +71 -0
- attackmap/srcpaths.py +301 -0
- attackmap/suggest.py +394 -0
- attackmap/supply_chain.py +688 -0
- attackmap/suppress.py +740 -0
- attackmap/taint.py +1540 -0
- attackmap/taint_flow.py +1419 -0
- attackmap/taint_sinks.py +431 -0
- attackmap/taint_sinks.yaml +308 -0
- attackmap/taint_sources.py +396 -0
- attackmap/taxonomy.py +465 -0
- attackmap/threat_model.py +3441 -0
- attackmap/topology.py +316 -0
- attackmap/triage.py +151 -0
- attackmap/weaknesses.py +365 -0
- attackmap/webhardening.py +479 -0
- attackmap/webhook_verification.py +1067 -0
- attackmap/workflow_scanner.py +964 -0
- attackmap-0.6.4.dist-info/METADATA +226 -0
- attackmap-0.6.4.dist-info/RECORD +91 -0
- attackmap-0.6.4.dist-info/WHEEL +5 -0
- attackmap-0.6.4.dist-info/entry_points.txt +2 -0
- attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
- 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"]
|