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,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
|
+
]
|