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,479 @@
1
+ """Web-hardening gap detection (#71).
2
+
3
+ A per-file pass (invoked from the built-in scanner's existing read loop)
4
+ that flags *positively-present* web misconfigurations rather than
5
+ hard-to-judge absences:
6
+
7
+ cors_wildcard_credentials wildcard/reflected origin WITH credentials
8
+ cors_wildcard_origin wildcard/reflected origin, no credentials (low)
9
+ cors_untrusted_origin origin allow-list that admits attacker origins:
10
+ unanchored regex, suffix/substring match, or
11
+ `null` (high with credentials, else medium)
12
+ csrf_disabled CSRF explicitly disabled or exempted
13
+ csrf_unprotected_session cookie-session auth with no CSRF middleware or
14
+ SameSite=Strict/Lax anywhere in the repo (#244)
15
+ insecure_cookie httpOnly/secure=false, SameSite=None, etc.
16
+ weak_csp CSP allowing 'unsafe-inline' / 'unsafe-eval'
17
+ debug_enabled debug mode / actuator wildcard exposure
18
+
19
+ Pure-absence checks ("no CSP header anywhere") are intentionally out of
20
+ scope — they're noisy without whole-app reasoning. Everything here keys
21
+ off a concrete risky construct in the source.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import bisect
27
+ import re
28
+
29
+ from pathlib import Path
30
+
31
+ from .models import WebHardeningIssue
32
+ from .safe_fs import is_oversized, walk_repo
33
+ from .srcpaths import is_skipped_dir, is_test_file, is_vendored_file, line_number
34
+
35
+
36
+ def _rx(pattern: str) -> re.Pattern[str]:
37
+ # MULTILINE so `^`-anchored patterns (e.g. Django's `DEBUG = True`)
38
+ # match at each line start, not just the start of the file.
39
+ return re.compile(pattern, re.IGNORECASE | re.MULTILINE)
40
+
41
+
42
+ # CORS is a two-signal model (#244): the origin policy (wildcard, reflected,
43
+ # unanchored regex, suffix/substring match, `null`, or an exact allow-list)
44
+ # times whether credentials are allowed. Detected via file-level co-occurrence
45
+ # so argument order and framework don't matter.
46
+ _CORS_WILD_ORIGIN = _rx(
47
+ r"access-control-allow-origin['\"]?\s*\]?\s*[:,=]\s*['\"]?\*"
48
+ r"|origins?\s*[:=]\s*['\"]\*['\"]"
49
+ r"|origin\s*:\s*true\b"
50
+ r"|allow_origins\s*=\s*[\[(]\s*['\"]\*['\"]"
51
+ r"|^\s*CORS_(?:ORIGIN_ALLOW_ALL|ALLOW_ALL_ORIGINS)\s*=\s*True"
52
+ )
53
+ # The request's own Origin echoed back, or an origin callback that accepts
54
+ # everything. Guarded forms (an allow-list check nearby) are filtered below.
55
+ _CORS_REFLECTED = _rx(
56
+ r"access-control-allow-origin['\"]?\s*\]?\s*[,:=]\s*(?:req|request|ctx|c)\b[^\n;]{0,60}?origin"
57
+ r"|\borigin\s*:\s*(?:async\s+)?(?:function\b|\(|\w+\s*=>)[^\n]{0,80}?\(\s*null\s*,\s*true\s*\)"
58
+ )
59
+ _CORS_CREDENTIALS = _rx(
60
+ r"access-control-allow-credentials['\"]?\s*\]?\s*[:,=]\s*['\"]?true"
61
+ r"|credentials\s*:\s*true"
62
+ r"|supports_credentials\s*=\s*True"
63
+ r"|allow_credentials\s*=\s*True"
64
+ r"|^\s*CORS_ALLOW_CREDENTIALS\s*=\s*True"
65
+ )
66
+ # An allow-list check near a reflected origin: `if (allowed.includes(o))`.
67
+ _ORIGIN_GUARD = re.compile(r"\bif\b|\?(?![.:])|\.includes\s*\(|\.has\s*\(|\.test\s*\(|indexOf\s*\(|\bin\s+\w", re.IGNORECASE)
68
+ # Origin values that admit attacker-controlled origins.
69
+ _CORS_JS_ORIGIN_VALUE = _rx(r"\borigin\s*:\s*(\[[^\]\n]{0,300}\]?|/[^\n]{0,300})")
70
+ _JS_REGEX_LITERAL = re.compile(r"/((?:\\.|[^/\\\n]){1,200})/[a-z]{0,6}")
71
+ _QUOTED = re.compile(r"'[^'\n]{0,300}'|\"[^\"\n]{0,300}\"")
72
+ _CORS_DJANGO_REGEXES = _rx(r"^\s*CORS_(?:ALLOWED_ORIGIN_REGEXES|ORIGIN_REGEX_WHITELIST)\s*=\s*[\[(]([^\])]{0,1000})")
73
+ _CORS_STARLETTE_REGEX = _rx(r"\ballow_origin_regex\s*=\s*r?(?:'([^'\n]{1,300})'|\"([^\"\n]{1,300})\")")
74
+ _ORIGIN_PARTIAL_MATCH = _rx(
75
+ r"\b\w{0,20}?origin(?:header)?\s*\??\.\s*(endsWith|endswith|includes|indexOf|contains)\s*\(\s*(?:'([^'\n]{1,100})'|\"([^\"\n]{1,100})\")"
76
+ r"|(?:'([^'\n]{1,100})'|\"([^\"\n]{1,100})\")\s+in\s+(?:request\.headers\.get\(\s*['\"]origin['\"]\s*\)|origin\b)"
77
+ )
78
+ _CORS_NULL_ORIGIN = _rx(
79
+ r"\b(?:origins?|allow_origins|CORS_ALLOWED_ORIGINS|CORS_ORIGIN_WHITELIST|allowedOrigins)['\"]?\s*[:=]\s*[\[(][^\])]{0,300}?['\"]null['\"]"
80
+ r"|access-control-allow-origin['\"]?\s*\]?\s*[,:=]\s*['\"]null['\"]"
81
+ )
82
+ # `.*example\.com` / `.+example` — a wildcard run straight into a label, so
83
+ # `evilexample.com` matches.
84
+ _WILDCARD_INTO_LABEL = re.compile(r"\.[*+]\??[A-Za-z0-9]")
85
+
86
+ # CSRF inference (#244): cookie-session auth with no CSRF defence. A file
87
+ # configuring session-cookie auth is a candidate; the scanner drops all
88
+ # candidates when any file carries a CSRF marker (`has_csrf_protection`), and
89
+ # the finding needs a state-changing route.
90
+ _SESSION_COOKIE_AUTH = _rx(
91
+ r"require\s*\(\s*['\"](?:express-session|cookie-session)['\"]\s*\)"
92
+ r"|\bfrom\s+['\"](?:express-session|cookie-session)['\"]"
93
+ r"|\bimport\s+\w+\s*=\s*require\s*\(\s*['\"](?:express-session|cookie-session)['\"]"
94
+ r"|['\"]django\.contrib\.sessions\.middleware\.SessionMiddleware['\"]"
95
+ r"|\bLoginManager\s*\("
96
+ )
97
+ _CSRF_PROTECTION = _rx(
98
+ r"\bcsurf\b|\blusca\b|csrf-csrf|tiny-csrf|@fastify/csrf|csrf-sync|\bdoubleCsrf\b|\bcsrfSync\b"
99
+ r"|\bcsrf\s*\(|CsrfViewMiddleware|\bcsrf_protect\b|\bCSRFProtect\b|\bCsrfProtect\b|\bCSRFMiddleware\b"
100
+ r"|\bFlaskForm\b|\bcsrf_token\b|\bcsrfToken\b"
101
+ r"|same_?site['\"]?\s*[:=]\s*(?:['\"]?(?:strict|lax)\b|true\b)"
102
+ r"|SESSION_COOKIE_SAMESITE\s*=\s*['\"](?:Strict|Lax)"
103
+ )
104
+
105
+
106
+ def has_csrf_protection(content: str) -> bool:
107
+ """True if ``content`` carries a CSRF defence (token middleware, a CSRF
108
+ decorator, or a SameSite=Strict/Lax session cookie) outside a comment —
109
+ a commented-out `CsrfViewMiddleware` is the classic way to disable it.
110
+ The scanner uses this to drop `csrf_unprotected_session` candidates
111
+ repo-wide."""
112
+ for match in _CSRF_PROTECTION.finditer(content):
113
+ start = content.rfind("\n", 0, match.start()) + 1
114
+ if not content[start:match.start()].lstrip().startswith(("#", "//")):
115
+ return True
116
+ return False
117
+
118
+
119
+ # Spring Security turning CSRF protection off. The builder forms are method
120
+ # calls on the security config (`csrf().disable()`, `csrf(c -> c.disable())`,
121
+ # `csrf(AbstractHttpConfigurer::disable)`, Kotlin `.csrf { it.disable() }`);
122
+ # the Kotlin DSL `csrf { disable() }` (#341) only counts inside an `http { }`
123
+ # block. Core's per-file pass doesn't read .java/.kt files, so
124
+ # `find_jvm_csrf_disabled` runs these over them.
125
+ _SPRING_CSRF_BUILDER = _rx(
126
+ r"\.csrf\s*\(\s*\)\s*\.\s*disable\s*\("
127
+ r"|csrf\s*\(\s*(?:[\w.]+\s*->\s*[\w.]+\s*\.\s*disable\s*\(\)|[\w.:]+::disable)\s*\)"
128
+ r"|\.\s*csrf\s*\{\s*(?:it|\w+\s*->\s*\w+)\s*\.\s*disable\s*\(\s*\)\s*\}"
129
+ )
130
+ _KOTLIN_CSRF_DSL = _rx(r"\bcsrf\s*\{\s*disable\s*\(\s*\)\s*\}")
131
+ _SPRING_CSRF_DISABLED = _rx(_SPRING_CSRF_BUILDER.pattern + "|" + _KOTLIN_CSRF_DSL.pattern)
132
+ # The opener of a Kotlin security DSL block: `http {` (HttpSecurity and
133
+ # ServerHttpSecurity both use it) or `http.invoke {`.
134
+ _HTTP_DSL_OPENER = re.compile(r"\bhttp\s*(?:\.\s*invoke\s*)?\Z")
135
+ _JVM_SUFFIXES = frozenset({".java", ".kt"})
136
+ _STATELESS_SESSION = _rx(r"SessionCreationPolicy\s*\.\s*STATELESS\b")
137
+
138
+
139
+ # Single-pattern families: (kind, severity, pattern).
140
+ _PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
141
+ # --- CSRF explicitly disabled ---------------------------------------
142
+ (
143
+ "csrf_disabled",
144
+ "medium",
145
+ _rx(
146
+ r"@?csrf_exempt\b"
147
+ r"|WTF_CSRF_ENABLED\s*=\s*False"
148
+ r"|\bcsrf\s*:\s*false"
149
+ ),
150
+ ),
151
+ (
152
+ "csrf_disabled",
153
+ "medium",
154
+ _SPRING_CSRF_DISABLED,
155
+ ),
156
+ # --- insecure cookie flags ------------------------------------------
157
+ (
158
+ "insecure_cookie",
159
+ "medium",
160
+ _rx(
161
+ r"httpOnly\s*:\s*false"
162
+ r"|\bsecure\s*:\s*false"
163
+ r"|sameSite\s*:\s*['\"]?none"
164
+ r"|SESSION_COOKIE_SECURE\s*=\s*False"
165
+ r"|SESSION_COOKIE_HTTPONLY\s*=\s*False"
166
+ r"|SESSION_COOKIE_SAMESITE\s*=\s*['\"]?None"
167
+ r"|;\s*samesite=none(?!.*;\s*secure)"
168
+ ),
169
+ ),
170
+ # --- weak CSP -------------------------------------------------------
171
+ (
172
+ "weak_csp",
173
+ "medium",
174
+ # 'unsafe-inline' / 'unsafe-eval' only appear in a CSP context.
175
+ _rx(r"['\"]unsafe-(?:inline|eval)['\"]|(?<![\w-])unsafe-(?:inline|eval)(?![\w-])"),
176
+ ),
177
+ # --- debug enabled --------------------------------------------------
178
+ (
179
+ "debug_enabled",
180
+ "medium",
181
+ _rx(
182
+ r"app\.run\s*\([^)]*debug\s*=\s*True"
183
+ r"|^\s*DEBUG\s*=\s*True"
184
+ r"|\bapp\.debug\s*=\s*true"
185
+ r"|FLASK_DEBUG\s*=\s*1"
186
+ r"|consider_all_requests_local\s*=\s*true"
187
+ r"|management\.endpoints\.web\.exposure\.include\s*=\s*\*"
188
+ r"|exposure\.include['\"]?\s*[:=]\s*['\"]\*['\"]"
189
+ ),
190
+ ),
191
+ )
192
+
193
+
194
+ def find_web_hardening_issues(content: str, rel_file: str) -> list[WebHardeningIssue]:
195
+ """Return web-hardening issues in one file's ``content`` (deduped by
196
+ (kind, line))."""
197
+ seen: set[tuple[str, int]] = set()
198
+ out: list[WebHardeningIssue] = []
199
+
200
+ def _add(kind: str, severity: str, offset: int) -> None:
201
+ line = line_number(content, offset)
202
+ key = (kind, line)
203
+ if key in seen:
204
+ return
205
+ seen.add(key)
206
+ out.append(
207
+ WebHardeningIssue(
208
+ kind=kind, # type: ignore[arg-type]
209
+ file=rel_file,
210
+ line=line,
211
+ evidence_text=_snippet(content, offset),
212
+ severity=severity, # type: ignore[arg-type]
213
+ source_analyzer="webhardening",
214
+ )
215
+ )
216
+
217
+ # CORS (#244): origin policy x credentials.
218
+ credentials = _CORS_CREDENTIALS.search(content) is not None
219
+ permissive = _permissive_origin_offset(content)
220
+ if permissive is not None:
221
+ _add(
222
+ "cors_wildcard_credentials" if credentials else "cors_wildcard_origin",
223
+ "high" if credentials else "low",
224
+ permissive,
225
+ )
226
+ for offset in _untrusted_origin_offsets(content):
227
+ _add("cors_untrusted_origin", "high" if credentials else "medium", offset)
228
+
229
+ # CSRF inference (#244): session-cookie auth without a CSRF defence in
230
+ # this file. Cross-file defences are applied by the scanner.
231
+ session = _SESSION_COOKIE_AUTH.search(content)
232
+ if session is not None and not has_csrf_protection(content):
233
+ _add("csrf_unprotected_session", "medium", session.start())
234
+
235
+ for kind, severity, pattern in _PATTERNS:
236
+ for match in pattern.finditer(content):
237
+ _add(kind, severity, match.start())
238
+
239
+ return out
240
+
241
+
242
+ def mask_jvm_comments_and_strings(text: str, *, nested_comments: bool = False) -> str:
243
+ """``text`` with Java/Kotlin comments, string and char literals replaced
244
+ by spaces (newlines kept), so offsets and line numbers are unchanged.
245
+ Handles `//`, `/* */` (nested only with ``nested_comments``, for Kotlin:
246
+ in Java `/** Permits /api/** … */` is one comment), `"…"` with escapes,
247
+ `\"\"\"…\"\"\"` text blocks and `'c'`. Never raises; an unterminated
248
+ construct masks to the end of the text."""
249
+ out = list(text)
250
+ n = len(text)
251
+ i = 0
252
+
253
+ def blank(a: int, b: int) -> None:
254
+ for k in range(a, min(b, n)):
255
+ if out[k] != "\n":
256
+ out[k] = " "
257
+
258
+ while i < n:
259
+ ch = text[i]
260
+ nxt = text[i + 1] if i + 1 < n else ""
261
+ if ch == "/" and nxt == "/":
262
+ end = text.find("\n", i)
263
+ end = n if end == -1 else end
264
+ blank(i, end)
265
+ i = end
266
+ elif ch == "/" and nxt == "*":
267
+ depth, j = 1, i + 2
268
+ while j < n and depth:
269
+ if nested_comments and text.startswith("/*", j):
270
+ depth, j = depth + 1, j + 2
271
+ elif text.startswith("*/", j):
272
+ depth, j = depth - 1, j + 2
273
+ else:
274
+ j += 1
275
+ blank(i, j)
276
+ i = j
277
+ elif text.startswith('"""', i):
278
+ end = text.find('"""', i + 3)
279
+ end = n if end == -1 else end + 3
280
+ blank(i, end)
281
+ i = end
282
+ elif ch in "\"'":
283
+ j = i + 1
284
+ while j < n and text[j] != ch and text[j] != "\n":
285
+ j += 2 if text[j] == "\\" else 1
286
+ end = min(j + 1, n)
287
+ blank(i, end)
288
+ i = end
289
+ else:
290
+ i += 1
291
+ return "".join(out)
292
+
293
+
294
+ class _Blocks:
295
+ """The `{ … }` blocks of a masked file, indexed once (#341 review): each
296
+ block's span, its parent, and whether it or an ancestor is an
297
+ `http { }` DSL block. Each query is a bisect plus a walk up the parents,
298
+ instead of a scan back through the text per match."""
299
+
300
+ def __init__(self, masked: str) -> None:
301
+ self.opens: list[int] = []
302
+ self.closes: list[int] = []
303
+ self.parents: list[int] = []
304
+ self.under_http: list[bool] = []
305
+ stack: list[int] = []
306
+ n = len(masked)
307
+ for k, ch in enumerate(masked):
308
+ if ch == "{":
309
+ index = len(self.opens)
310
+ parent = stack[-1] if stack else -1
311
+ http = _HTTP_DSL_OPENER.search(masked, max(0, k - 40), k) is not None
312
+ self.opens.append(k)
313
+ self.closes.append(n)
314
+ self.parents.append(parent)
315
+ self.under_http.append(http or (parent >= 0 and self.under_http[parent]))
316
+ stack.append(index)
317
+ elif ch == "}" and stack:
318
+ self.closes[stack.pop()] = k + 1
319
+
320
+ def innermost_each(self, offsets: list[int]) -> list[int]:
321
+ """Innermost block index (or -1) for each of ``offsets`` (ascending),
322
+ in one sweep with a stack of open blocks: O(blocks + offsets) however
323
+ deep the nesting or many the matches."""
324
+ out: list[int] = []
325
+ stack: list[int] = []
326
+ next_open = 0
327
+ total = len(self.opens)
328
+ for offset in offsets:
329
+ while next_open < total and self.opens[next_open] < offset:
330
+ while stack and self.closes[stack[-1]] <= self.opens[next_open]:
331
+ stack.pop()
332
+ stack.append(next_open)
333
+ next_open += 1
334
+ while stack and self.closes[stack[-1]] <= offset:
335
+ stack.pop()
336
+ out.append(stack[-1] if stack else -1)
337
+ return out
338
+
339
+
340
+ def find_jvm_csrf_disabled(root: str | Path) -> list[WebHardeningIssue]:
341
+ """`csrf_disabled` in Spring Security config written in Java or Kotlin
342
+ (#341). Whole-repo pass: core's per-file scan covers Python/JS/TS/Go/PHP
343
+ only, so without it the Spring forms above were never checked. Test and
344
+ vendored files are skipped; comments and string literals are masked
345
+ before matching; the Kotlin DSL form needs an enclosing `http { }`."""
346
+ root_path = Path(root)
347
+ out: list[WebHardeningIssue] = []
348
+ for path in walk_repo(root_path, prune=is_skipped_dir):
349
+ if path.suffix not in _JVM_SUFFIXES or is_oversized(path):
350
+ continue
351
+ rel = path.relative_to(root_path).as_posix()
352
+ if is_test_file(rel) or is_vendored_file(rel):
353
+ continue
354
+ try:
355
+ content = path.read_text(encoding="utf-8", errors="replace")
356
+ except OSError:
357
+ continue
358
+ if "csrf" not in content:
359
+ continue
360
+ out.extend(_jvm_csrf_issues(content, rel))
361
+ return out
362
+
363
+
364
+ def _jvm_csrf_issues(content: str, rel: str) -> list[WebHardeningIssue]:
365
+ masked = mask_jvm_comments_and_strings(content, nested_comments=rel.endswith(".kt"))
366
+ blocks = _Blocks(masked)
367
+ builder = [m.start() for m in _SPRING_CSRF_BUILDER.finditer(masked)]
368
+ dsl = [m.start() for m in _KOTLIN_CSRF_DSL.finditer(masked)]
369
+ dsl_blocks = blocks.innermost_each(dsl)
370
+ offsets = sorted(builder + [o for o, b in zip(dsl, dsl_blocks) if b >= 0 and blocks.under_http[b]])
371
+ enclosing = blocks.innermost_each(offsets)
372
+ stateless_offsets = [m.start() for m in _STATELESS_SESSION.finditer(masked)] # ascending
373
+ out: list[WebHardeningIssue] = []
374
+ seen: set[int] = set()
375
+ for offset, block in zip(offsets, enclosing):
376
+ line = line_number(content, offset)
377
+ if line in seen:
378
+ continue
379
+ seen.add(line)
380
+ # A stateless session policy in the same config block (method body or
381
+ # DSL block), not merely somewhere in the file: one bisect.
382
+ lo, hi = (0, len(masked)) if block < 0 else (blocks.opens[block], blocks.closes[block])
383
+ first = bisect.bisect_left(stateless_offsets, lo)
384
+ stateless = first < len(stateless_offsets) and stateless_offsets[first] < hi
385
+ out.append(
386
+ WebHardeningIssue(
387
+ kind="csrf_disabled",
388
+ file=rel,
389
+ line=line,
390
+ evidence_text=_snippet(content, offset)
391
+ + (" [stateless session policy in this config]" if stateless else ""),
392
+ severity="low" if stateless else "medium",
393
+ source_analyzer="webhardening",
394
+ )
395
+ )
396
+ return out
397
+
398
+
399
+ def _line_at(content: str, offset: int) -> tuple[int, int]:
400
+ start = content.rfind("\n", 0, offset) + 1
401
+ end = content.find("\n", offset)
402
+ return start, (len(content) if end == -1 else end)
403
+
404
+
405
+ def _permissive_origin_offset(content: str) -> int | None:
406
+ """Offset of a wildcard or reflected origin, or None. A reflected origin
407
+ with an allow-list check on its line or the 3 lines before is guarded."""
408
+ wild = _CORS_WILD_ORIGIN.search(content)
409
+ if wild is not None:
410
+ return wild.start()
411
+ for match in _CORS_REFLECTED.finditer(content):
412
+ start, end = _line_at(content, match.start())
413
+ window_start = start
414
+ for _ in range(3):
415
+ if window_start == 0:
416
+ break
417
+ window_start = content.rfind("\n", 0, window_start - 1) + 1
418
+ if _ORIGIN_GUARD.search(content, window_start, end):
419
+ continue
420
+ return match.start()
421
+ return None
422
+
423
+
424
+ def _js_regex_unsafe(body: str) -> bool:
425
+ """A JS origin regex used with `.test()` (unanchored): unsafe without a
426
+ trailing `$`, when it starts straight into a domain label without `^`,
427
+ or when a wildcard runs into a label."""
428
+ if not body.endswith("$"):
429
+ return True
430
+ if not body.startswith("^") and body[:1].isalnum():
431
+ return True
432
+ return _WILDCARD_INTO_LABEL.search(body) is not None
433
+
434
+
435
+ def _untrusted_origin_offsets(content: str) -> list[int]:
436
+ out: list[int] = []
437
+ for match in _CORS_JS_ORIGIN_VALUE.finditer(content):
438
+ value = _QUOTED.sub("''", match.group(1))
439
+ if any(_js_regex_unsafe(rx.group(1)) for rx in _JS_REGEX_LITERAL.finditer(value)):
440
+ out.append(match.start())
441
+ for match in _CORS_DJANGO_REGEXES.finditer(content):
442
+ # django-cors-headers uses re.match: anchored at the start only.
443
+ for quoted in _QUOTED.finditer(match.group(1)):
444
+ body = quoted.group(0)[1:-1]
445
+ if not body.endswith("$") or _WILDCARD_INTO_LABEL.search(body):
446
+ out.append(match.start())
447
+ break
448
+ for match in _CORS_STARLETTE_REGEX.finditer(content):
449
+ # Starlette uses re.fullmatch, so only a wildcard into a label leaks.
450
+ if _WILDCARD_INTO_LABEL.search(match.group(1) or match.group(2) or ""):
451
+ out.append(match.start())
452
+ for match in _ORIGIN_PARTIAL_MATCH.finditer(content):
453
+ method = (match.group(1) or "").lower()
454
+ arg = match.group(2) or match.group(3) or match.group(4) or match.group(5) or ""
455
+ if "." not in arg:
456
+ continue # not a host name
457
+ if method == "endswith" and arg.startswith("."):
458
+ continue # `.endsWith('.example.com')` is a sound subdomain check
459
+ out.append(match.start())
460
+ for match in _CORS_NULL_ORIGIN.finditer(content):
461
+ out.append(match.start())
462
+ return out
463
+
464
+
465
+ def _snippet(content: str, offset: int, radius: int = 120) -> str:
466
+ start = max(0, content.rfind("\n", 0, offset) + 1)
467
+ end = content.find("\n", offset)
468
+ if end == -1:
469
+ end = len(content)
470
+ line = content[start:end].strip()
471
+ return line[:radius] + ("…" if len(line) > radius else "")
472
+
473
+
474
+ __all__ = [
475
+ "find_jvm_csrf_disabled",
476
+ "find_web_hardening_issues",
477
+ "has_csrf_protection",
478
+ "mask_jvm_comments_and_strings",
479
+ ]