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,474 @@
|
|
|
1
|
+
"""Fuse routes + middleware/guard chains + auth_hints into precise findings (#140).
|
|
2
|
+
|
|
3
|
+
`scan.auth_hints` are raw signals — a large monorepo emits hundreds. They're
|
|
4
|
+
useful evidence, but reviewers want conclusions. This pass resolves, *per
|
|
5
|
+
route*, whether an authentication/authorization control guards it, and emits a
|
|
6
|
+
single precise finding for the state-changing routes that have none — each
|
|
7
|
+
carrying the resolved middleware/guard chain as evidence.
|
|
8
|
+
|
|
9
|
+
Why not reuse `AttackSurface.auth_signals`? That attribution is file-windowed
|
|
10
|
+
(±40 lines), so in a compact file every route inherits a neighbor's auth code.
|
|
11
|
+
This module resolves the control on the route's *own* registration chain:
|
|
12
|
+
|
|
13
|
+
- **Express / Koa / Fastify (JS/TS)** — an auth middleware in the route's
|
|
14
|
+
argument list (`app.post('/x', requireAuth, handler)`) or a global
|
|
15
|
+
`app.use(requireAuth)` / `router.use(...)`.
|
|
16
|
+
- **FastAPI / Flask (Python)** — an auth decorator (`@login_required`,
|
|
17
|
+
`@jwt_required`, …), a `dependencies=[Depends(...)]` on the route decorator,
|
|
18
|
+
`Depends(<auth>)` / `Security(...)` in the handler signature, or a
|
|
19
|
+
router-level dependency.
|
|
20
|
+
- **Spring (Java)** — `@PreAuthorize` / `@PostAuthorize` / `@Secured` /
|
|
21
|
+
`@RolesAllowed` on the method or controller, or a global security config that
|
|
22
|
+
requires authentication for every request.
|
|
23
|
+
|
|
24
|
+
Scope: public, state-changing (`POST/PUT/PATCH/DELETE`) `public_api` routes,
|
|
25
|
+
plus privileged routes — `admin`, and `internal` routes whose own path says
|
|
26
|
+
`/internal` or `/debug` (#242). The admin/internal category findings are
|
|
27
|
+
file-windowed advisories; this pass gives those routes the same precise
|
|
28
|
+
per-route answer. Webhook, upload and auth routes keep their dedicated
|
|
29
|
+
findings. `auth_hints` emission is untouched; this only consumes them.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import re
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from .handler_auth import handler_body, mask, session_gate
|
|
38
|
+
from .safe_fs import read_repo_text, walk_repo
|
|
39
|
+
from .srcpaths import JS_TS_SUFFIXES, split_lines
|
|
40
|
+
from .models import AttackSurface, AttackTechnique, Finding, FindingLocation, Route, ScanResult
|
|
41
|
+
|
|
42
|
+
_MUTATING = {"POST", "PUT", "PATCH", "DELETE"}
|
|
43
|
+
# An `internal` surface counts only when the route's own path says so; one
|
|
44
|
+
# classified internal from worker/event-handler metadata is not HTTP-reachable.
|
|
45
|
+
_INTERNAL_PATH = re.compile(r"/(?:internal|debug)(?:/|$)", re.IGNORECASE)
|
|
46
|
+
_JS_SUFFIXES = JS_TS_SUFFIXES
|
|
47
|
+
_PY_SUFFIXES = {".py"}
|
|
48
|
+
_JAVA_SUFFIXES = {".java", ".kt"}
|
|
49
|
+
|
|
50
|
+
# Identifiers whose presence in a route's chain means it is guarded. Broad
|
|
51
|
+
# enough to catch common conventions, specific enough not to match ordinary
|
|
52
|
+
# handler code.
|
|
53
|
+
_JS_AUTH_TOKEN = re.compile(
|
|
54
|
+
r"\b(?:require(?:Auth|Login|User|Token|Admin)|requiresAuth|isAuthenticated|"
|
|
55
|
+
r"ensureAuth\w*|ensureLoggedIn|ensureAuthenticated|authenticat\w*|authGuard|"
|
|
56
|
+
r"authMiddleware|authenticateJWT|checkAuth|checkJwt|verify(?:Token|Jwt|Auth)|"
|
|
57
|
+
r"jwtMiddleware|passport|protect(?:ed)?Route|withAuth|guard)\b",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
)
|
|
60
|
+
_PY_AUTH_DECORATOR = re.compile(
|
|
61
|
+
r"@(?:\w+\.)*(?:login_required|jwt_required|token_required|auth_required|"
|
|
62
|
+
r"requires_auth|require_auth|authenticated|permission_required|"
|
|
63
|
+
r"require_(?:oauth|api_key|token)|protected)\b",
|
|
64
|
+
re.IGNORECASE,
|
|
65
|
+
)
|
|
66
|
+
_PY_DEPENDS_AUTH = re.compile(
|
|
67
|
+
r"(?:Security|Depends)\(\s*(?:[\w.]*)?(?:oauth2_scheme|get_current_user|"
|
|
68
|
+
r"current_user|current_active_user|verify_token|require_\w+|auth\w*|"
|
|
69
|
+
r"authenticate\w*|HTTPBearer|HTTPBasic|JWTBearer|get_user|require_user)",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
72
|
+
_PY_SECURITY_CALL = re.compile(r"\bSecurity\s*\(") # fastapi.Security is always auth
|
|
73
|
+
_JAVA_AUTH_ANNOTATION = re.compile(
|
|
74
|
+
r"@(?:PreAuthorize|PostAuthorize|Secured|RolesAllowed|RoleAllowed|"
|
|
75
|
+
r"Authenticated|HasRole|HasAuthority)\b"
|
|
76
|
+
)
|
|
77
|
+
# A quoted string literal. A backslash is consumed only by the escape branch,
|
|
78
|
+
# so an unterminated quote followed by many backslashes can't backtrack
|
|
79
|
+
# exponentially (CodeQL py/redos).
|
|
80
|
+
_STRING_LITERAL_RE = re.compile(r"""(['"`])(?:\\.|(?!\1)[^\\])*\1""")
|
|
81
|
+
_JAVA_GLOBAL_AUTH = re.compile(
|
|
82
|
+
r"\.(?:anyRequest|anyExchange)\s*\(\s*\)\s*\.(?:authenticated|hasRole|hasAuthority|hasAnyRole)"
|
|
83
|
+
r"|\.authorize(?:HttpRequests|Requests|Exchange)\b",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def unauthenticated_routes(
|
|
88
|
+
scan: ScanResult, surfaces: list[AttackSurface]
|
|
89
|
+
) -> list[tuple[Route, str]]:
|
|
90
|
+
"""The in-scope state-changing routes with no resolved authentication
|
|
91
|
+
control, each with its control-chain evidence."""
|
|
92
|
+
root = Path(scan.root)
|
|
93
|
+
surface_by_key: dict[tuple[str, str, str], AttackSurface] = {}
|
|
94
|
+
for s in surfaces:
|
|
95
|
+
surface_by_key.setdefault((s.method, s.route, s.file), s)
|
|
96
|
+
|
|
97
|
+
content_cache: dict[str, str | None] = {}
|
|
98
|
+
# Only pay the Spring-config scan when there are Java routes to attribute —
|
|
99
|
+
# otherwise this is a no-op that would needlessly walk the whole tree.
|
|
100
|
+
has_java_routes = any(
|
|
101
|
+
Path(r.file).suffix.lower() in _JAVA_SUFFIXES for r in scan.routes
|
|
102
|
+
)
|
|
103
|
+
java_global_auth = (
|
|
104
|
+
repo_has_java_global_auth(scan, root, content_cache) if has_java_routes else False
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
line_starts: dict[str, tuple[list[int], str]] = {}
|
|
108
|
+
hop_box: list = []
|
|
109
|
+
|
|
110
|
+
flagged: list[tuple[Route, str]] = []
|
|
111
|
+
for route in scan.routes:
|
|
112
|
+
if route.method.upper() not in _MUTATING:
|
|
113
|
+
continue
|
|
114
|
+
surface = surface_by_key.get((route.method, route.path, route.file))
|
|
115
|
+
if surface is None or not _in_scope(surface, route):
|
|
116
|
+
continue
|
|
117
|
+
# Trust a plugin-declared state (#256); resolve ourselves only when the
|
|
118
|
+
# plugin couldn't tell.
|
|
119
|
+
if route.auth == "required":
|
|
120
|
+
continue
|
|
121
|
+
if route.auth == "anonymous":
|
|
122
|
+
flagged.append((route, _declared_anonymous_evidence(route)))
|
|
123
|
+
continue
|
|
124
|
+
content = _read(root, route.file, content_cache)
|
|
125
|
+
if content is None:
|
|
126
|
+
continue
|
|
127
|
+
control = resolve_control(route, content, java_global_auth)
|
|
128
|
+
if control is not None:
|
|
129
|
+
continue # guarded — not a finding
|
|
130
|
+
if Path(route.file).suffix.lower() in _JS_SUFFIXES and _handler_session_gate(
|
|
131
|
+
route, content, root, content_cache, line_starts, hop_box
|
|
132
|
+
):
|
|
133
|
+
continue # the handler rejects a request without a session (#348)
|
|
134
|
+
flagged.append((route, _chain_evidence(route, content)))
|
|
135
|
+
return flagged
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
_REGISTRATION_CALL = re.compile(r"\.\s*(?P<verb>get|post|put|patch|delete|all|route|on)\s*\(", re.IGNORECASE)
|
|
139
|
+
_NEWLINE = re.compile(r"\n")
|
|
140
|
+
_READ_ONLY_MIDDLEWARE = re.compile(
|
|
141
|
+
r"(?:[A-Za-z_$][\w$]{0,40}\.)?"
|
|
142
|
+
r"(?:(?:[a-z]{1,3}V|v)alidat(?:or|e|ion)(?:Body|Query|Params|Request|Input|Schema|Json|Middleware)?"
|
|
143
|
+
r"|json|urlencoded|text|raw|cors|helmet|rateLimit(?:er)?|[A-Za-z_$]{0,20}[Ll]imiter)"
|
|
144
|
+
r"\s*\([^;]{0,400}\)"
|
|
145
|
+
)
|
|
146
|
+
_CHAIN_CONTINUES = re.compile(r"[ \t\r\n]{0,200}\.")
|
|
147
|
+
_PATH_LITERAL = re.compile(r"(?<!\s)\s*+['\"`/]")
|
|
148
|
+
_SOLE_REF = re.compile(r"([A-Za-z_$][\w$]{0,80}(?:\.[A-Za-z_$][\w$]{0,80})?)")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _top_level_args(text: str) -> list[tuple[int, str]]:
|
|
152
|
+
"""``text`` split on commas outside brackets, as ``(offset, argument)``;
|
|
153
|
+
a trailing comma adds no empty argument."""
|
|
154
|
+
args: list[tuple[int, str]] = []
|
|
155
|
+
depth = 0
|
|
156
|
+
start = 0
|
|
157
|
+
for i, ch in enumerate(text):
|
|
158
|
+
if ch in "([{":
|
|
159
|
+
depth += 1
|
|
160
|
+
elif ch in ")]}":
|
|
161
|
+
depth -= 1
|
|
162
|
+
elif ch == "," and depth == 0:
|
|
163
|
+
args.append((start, text[start:i]))
|
|
164
|
+
start = i + 1
|
|
165
|
+
if text[start:].strip():
|
|
166
|
+
args.append((start, text[start:]))
|
|
167
|
+
return args
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _handler_session_gate(
|
|
171
|
+
route: Route,
|
|
172
|
+
content: str,
|
|
173
|
+
root: Path,
|
|
174
|
+
cache: dict[str, str | None],
|
|
175
|
+
line_starts: dict[str, tuple[list[int], str]],
|
|
176
|
+
hop_box: list,
|
|
177
|
+
) -> str | None:
|
|
178
|
+
"""A session check that rejects (see :mod:`handler_auth`) in the route's
|
|
179
|
+
own handler: the inline handler, clamped to its closing brace, or the one
|
|
180
|
+
function the registration passes by reference (one hop)."""
|
|
181
|
+
if route.line is None:
|
|
182
|
+
return None
|
|
183
|
+
# Route.line counts `\n` only; `splitlines()` would also split on
|
|
184
|
+
# U+2028, `\f` or a lone `\r` and shift the handler to another route.
|
|
185
|
+
# The file is masked once and shared by all its routes.
|
|
186
|
+
if route.file not in line_starts:
|
|
187
|
+
line_starts[route.file] = ([0] + [m.end() for m in _NEWLINE.finditer(content)], mask(content))
|
|
188
|
+
starts, file_code = line_starts[route.file]
|
|
189
|
+
if not 1 <= route.line <= len(starts):
|
|
190
|
+
return None
|
|
191
|
+
offset = starts[route.line - 1]
|
|
192
|
+
text = content[offset : offset + 20_000]
|
|
193
|
+
code = file_code[offset : offset + 20_000]
|
|
194
|
+
first_line = code[: code.find("\n") + 1 if "\n" in code else len(code)]
|
|
195
|
+
calls = list(_REGISTRATION_CALL.finditer(first_line))
|
|
196
|
+
if not calls:
|
|
197
|
+
if _CHAIN_CONTINUES.match(code, len(first_line)):
|
|
198
|
+
return None # `router\n .route('/x')\n .post(mw, h)`: a chain we don't follow
|
|
199
|
+
# Not a call registration (a decorator, a function header): the
|
|
200
|
+
# handler body is the first function body that follows.
|
|
201
|
+
return session_gate(text, code)
|
|
202
|
+
# Several registrations on one line (`.get(a).post(b)`, `app.get(…); app.post(…)`):
|
|
203
|
+
# only the one for this route's method, and only if it is unambiguous.
|
|
204
|
+
same_method = [c for c in calls if c.group("verb").lower() == route.method.lower()]
|
|
205
|
+
if len(same_method) == 1:
|
|
206
|
+
call = same_method[0]
|
|
207
|
+
elif len(calls) == 1 and not same_method and calls[0].group("verb").lower() in ("all", "route", "on"):
|
|
208
|
+
call = calls[0]
|
|
209
|
+
else:
|
|
210
|
+
return None
|
|
211
|
+
open_paren = call.end() - 1
|
|
212
|
+
depth = 0
|
|
213
|
+
reg_close = None
|
|
214
|
+
for i in range(open_paren, len(code)):
|
|
215
|
+
if code[i] == "(":
|
|
216
|
+
depth += 1
|
|
217
|
+
elif code[i] == ")":
|
|
218
|
+
depth -= 1
|
|
219
|
+
if depth == 0:
|
|
220
|
+
reg_close = i
|
|
221
|
+
break
|
|
222
|
+
if reg_close is None:
|
|
223
|
+
return None
|
|
224
|
+
args = _top_level_args(code[open_paren + 1 : reg_close])
|
|
225
|
+
# Middleware: every argument between the path (when the first argument is
|
|
226
|
+
# one; `.route('/x').post(mw, h)` has none) and the handler. A gate in the
|
|
227
|
+
# handler runs after it (multer, a writer) has already acted. Validators,
|
|
228
|
+
# body parsers, cors and rate limiters only read the request.
|
|
229
|
+
has_path = bool(args) and _PATH_LITERAL.match(args[0][1]) is not None
|
|
230
|
+
middleware = args[1 if has_path else 0 : -1]
|
|
231
|
+
if not all(_READ_ONLY_MIDDLEWARE.fullmatch(text[open_paren + 1 + o : open_paren + 1 + o + len(a)].strip()) for o, a in middleware):
|
|
232
|
+
return None
|
|
233
|
+
# An inline handler: clamp to the registration, so a one-line route costs
|
|
234
|
+
# its own length. The body must open in the last argument, not in a
|
|
235
|
+
# callback passed to middleware (`validate({ custom: (req) => { … } })`).
|
|
236
|
+
last_start = open_paren + 1 + args[-1][0] if args else reg_close
|
|
237
|
+
inline_text, inline_code = text[: reg_close + 1], code[: reg_close + 1]
|
|
238
|
+
body = handler_body(inline_text[last_start:], inline_code[last_start:])
|
|
239
|
+
if body is not None:
|
|
240
|
+
return session_gate(inline_text[last_start:], inline_code[last_start:])
|
|
241
|
+
ref = _SOLE_REF.fullmatch(args[-1][1].strip()) if args else None
|
|
242
|
+
if ref is None:
|
|
243
|
+
return None
|
|
244
|
+
if not hop_box:
|
|
245
|
+
from .authz import _HopResolver # local import: authz imports this module
|
|
246
|
+
|
|
247
|
+
# The resolver, and (file, function) → gate shared by every route using it.
|
|
248
|
+
hop_box.extend([_HopResolver(root, cache), {}])
|
|
249
|
+
hop, gates = hop_box
|
|
250
|
+
owner, _, func = ref.group(1).rpartition(".")
|
|
251
|
+
imports = hop._import_map(route.file, content)
|
|
252
|
+
if owner and owner not in imports:
|
|
253
|
+
return None # `ctl.create` with `ctl` not imported: don't guess a local `create`
|
|
254
|
+
target = imports.get(owner or func, route.file)
|
|
255
|
+
key = (target, func)
|
|
256
|
+
if key not in gates:
|
|
257
|
+
definition = hop._definition(target, func)
|
|
258
|
+
gates[key] = session_gate(definition) if definition else None
|
|
259
|
+
return gates[key]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _in_scope(surface: AttackSurface, route: Route) -> bool:
|
|
263
|
+
if surface.category == "public_api":
|
|
264
|
+
return surface.exposure == "public"
|
|
265
|
+
if surface.category == "admin":
|
|
266
|
+
return True
|
|
267
|
+
return surface.category == "internal" and bool(_INTERNAL_PATH.search(route.path))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def synthesize_unauthenticated_routes(
|
|
271
|
+
scan: ScanResult,
|
|
272
|
+
surfaces: list[AttackSurface],
|
|
273
|
+
flagged: list[tuple[Route, str]] | None = None,
|
|
274
|
+
) -> list[Finding]:
|
|
275
|
+
"""Return at most one finding aggregating unauthenticated state-changing
|
|
276
|
+
routes, with the resolved control chain per route as evidence. Pass
|
|
277
|
+
``flagged`` (from :func:`unauthenticated_routes`) to reuse a resolution."""
|
|
278
|
+
if flagged is None:
|
|
279
|
+
flagged = unauthenticated_routes(scan, surfaces)
|
|
280
|
+
if not flagged:
|
|
281
|
+
return []
|
|
282
|
+
|
|
283
|
+
evidence = [chain for _, chain in flagged[:10]]
|
|
284
|
+
if len(flagged) > 10:
|
|
285
|
+
evidence.append(f"+{len(flagged) - 10} more state-changing route(s) with no resolved auth control")
|
|
286
|
+
return [
|
|
287
|
+
Finding(
|
|
288
|
+
title="State-changing routes are reachable without an authentication control",
|
|
289
|
+
rule_id="unauth-state-change",
|
|
290
|
+
severity="high",
|
|
291
|
+
evidence=evidence,
|
|
292
|
+
locations=[FindingLocation(file=route.file, line=route.line) for route, _ in flagged],
|
|
293
|
+
mitigation=(
|
|
294
|
+
"Require authentication (and server-side authorization) on every "
|
|
295
|
+
"state-changing endpoint — attach an auth middleware/guard to the "
|
|
296
|
+
"route or its router, a dependency/decorator on the handler, or a "
|
|
297
|
+
"global policy that defaults to deny. Confirm the control actually "
|
|
298
|
+
"runs before the handler mutates state."
|
|
299
|
+
),
|
|
300
|
+
confidence="medium",
|
|
301
|
+
tags=["exposed-endpoint", "auth-missing", "state-changing"],
|
|
302
|
+
attack_techniques=[
|
|
303
|
+
AttackTechnique(
|
|
304
|
+
technique_id="T1190",
|
|
305
|
+
name="Exploit Public-Facing Application",
|
|
306
|
+
tactic="Initial Access",
|
|
307
|
+
url="https://attack.mitre.org/techniques/T1190/",
|
|
308
|
+
)
|
|
309
|
+
],
|
|
310
|
+
)
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _declared_anonymous_evidence(route: Route) -> str:
|
|
315
|
+
where = f"{route.file}:{route.line}" if route.line else route.file
|
|
316
|
+
source = f" by {route.source_analyzer}" if route.source_analyzer else ""
|
|
317
|
+
basis = f" ({route.guard_evidence})" if route.guard_evidence else ""
|
|
318
|
+
return f"{route.method} {route.path} ({where}) — declared anonymous{source}{basis}"
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _read(root: Path, rel: str, cache: dict[str, str | None]) -> str | None:
|
|
322
|
+
if rel not in cache:
|
|
323
|
+
try:
|
|
324
|
+
cache[rel] = read_repo_text(root, rel, errors="replace")
|
|
325
|
+
except OSError:
|
|
326
|
+
cache[rel] = None
|
|
327
|
+
return cache[rel]
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def resolve_control(route: Route, content: str, java_global_auth: bool) -> str | None:
|
|
331
|
+
"""Return a short description of the control guarding `route`, or None."""
|
|
332
|
+
suffix = Path(route.file).suffix.lower()
|
|
333
|
+
lines = split_lines(content)
|
|
334
|
+
if suffix in _JS_SUFFIXES:
|
|
335
|
+
return _resolve_js(route, content, lines)
|
|
336
|
+
if suffix in _PY_SUFFIXES:
|
|
337
|
+
return _resolve_py(route, lines)
|
|
338
|
+
if suffix in _JAVA_SUFFIXES:
|
|
339
|
+
return _resolve_java(route, lines, java_global_auth)
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _line_index(route: Route) -> int | None:
|
|
344
|
+
return (route.line - 1) if route.line else None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _resolve_js(route: Route, content: str, lines: list[str]) -> str | None:
|
|
348
|
+
# Global middleware: app.use(requireAuth) / router.use(passport...).
|
|
349
|
+
for m in re.finditer(r"\b(?:app|router|server)\s*\.\s*use\s*\(([^)]*)\)", content):
|
|
350
|
+
if _JS_AUTH_TOKEN.search(m.group(1)):
|
|
351
|
+
return f"global middleware: {m.group(0).strip()[:80]}"
|
|
352
|
+
|
|
353
|
+
idx = _line_index(route)
|
|
354
|
+
if idx is None:
|
|
355
|
+
return None
|
|
356
|
+
# The route registration may span a few lines; take up to the start of the
|
|
357
|
+
# handler body so we inspect the middleware list, not the handler code.
|
|
358
|
+
region = "\n".join(lines[idx : idx + 4])
|
|
359
|
+
handler_start = re.search(r"=>|function\b|\(\s*req\b|\(\s*request\b", region)
|
|
360
|
+
mw_region = region[: handler_start.start()] if handler_start else region
|
|
361
|
+
# Drop the path string literal so a path like '/authenticate' can't match.
|
|
362
|
+
mw_region = _STRING_LITERAL_RE.sub("", mw_region, count=1)
|
|
363
|
+
if _JS_AUTH_TOKEN.search(mw_region):
|
|
364
|
+
return f"route middleware on {route.path}"
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _resolve_py(route: Route, lines: list[str]) -> str | None:
|
|
369
|
+
idx = _line_index(route)
|
|
370
|
+
if idx is None:
|
|
371
|
+
return None
|
|
372
|
+
# Decorators can sit above or below the route decorator; scan a small
|
|
373
|
+
# window above, then the handler signature below.
|
|
374
|
+
start = max(0, idx - 4)
|
|
375
|
+
# Find the handler `def`/`async def` at or after the route decorator, then
|
|
376
|
+
# read through the end of its signature (the line with the closing `):`).
|
|
377
|
+
def_line = None
|
|
378
|
+
for i in range(idx, min(len(lines), idx + 6)):
|
|
379
|
+
if re.match(r"\s*(?:async\s+)?def\s+\w+\s*\(", lines[i]):
|
|
380
|
+
def_line = i
|
|
381
|
+
break
|
|
382
|
+
sig_end = def_line if def_line is not None else idx
|
|
383
|
+
if def_line is not None:
|
|
384
|
+
depth = 0
|
|
385
|
+
for i in range(def_line, min(len(lines), def_line + 30)):
|
|
386
|
+
depth += lines[i].count("(") - lines[i].count(")")
|
|
387
|
+
sig_end = i
|
|
388
|
+
if depth <= 0 and "(" in "".join(lines[def_line : i + 1]):
|
|
389
|
+
break
|
|
390
|
+
window = "\n".join(lines[start : sig_end + 1])
|
|
391
|
+
if _PY_AUTH_DECORATOR.search(window):
|
|
392
|
+
return f"auth decorator on {route.path}"
|
|
393
|
+
if _PY_DEPENDS_AUTH.search(window) or _PY_SECURITY_CALL.search(window):
|
|
394
|
+
return f"auth dependency on {route.path} handler"
|
|
395
|
+
# Router-level dependency (applies to every route on the router).
|
|
396
|
+
return _py_router_dependency(lines)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _py_router_dependency(lines: list[str]) -> str | None:
|
|
400
|
+
text = "\n".join(lines)
|
|
401
|
+
for m in re.finditer(r"(?:APIRouter|include_router)\s*\(([^)]*)\)", text, re.DOTALL):
|
|
402
|
+
if "dependencies" in m.group(1) and _PY_DEPENDS_AUTH.search(m.group(1)):
|
|
403
|
+
return "router-level auth dependency"
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_java(route: Route, lines: list[str], java_global_auth: bool) -> str | None:
|
|
408
|
+
if java_global_auth:
|
|
409
|
+
return "global Spring Security policy requires authentication"
|
|
410
|
+
idx = _line_index(route)
|
|
411
|
+
if idx is None:
|
|
412
|
+
return None
|
|
413
|
+
# Method-level annotations sit just above the mapping (after any preceding
|
|
414
|
+
# method's closing brace). Only scan up to that boundary so a *different*
|
|
415
|
+
# method's @PreAuthorize doesn't leak onto this route.
|
|
416
|
+
method_start = 0
|
|
417
|
+
for i in range(idx - 1, -1, -1):
|
|
418
|
+
if "}" in lines[i] or re.search(r"\b(?:public|private|protected)\b.*\)", lines[i]):
|
|
419
|
+
method_start = i + 1
|
|
420
|
+
break
|
|
421
|
+
window = "\n".join(lines[method_start : idx + 1])
|
|
422
|
+
if _JAVA_AUTH_ANNOTATION.search(window):
|
|
423
|
+
return f"method security annotation on {route.path}"
|
|
424
|
+
# Class-level annotations: only those directly above the class declaration.
|
|
425
|
+
for i, line in enumerate(lines):
|
|
426
|
+
if re.search(r"\b(?:class|interface)\s+\w+", line):
|
|
427
|
+
class_head = "\n".join(lines[max(0, i - 6) : i])
|
|
428
|
+
if _JAVA_AUTH_ANNOTATION.search(class_head):
|
|
429
|
+
return "class-level security annotation"
|
|
430
|
+
break
|
|
431
|
+
return None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def repo_has_java_global_auth(
|
|
435
|
+
scan: ScanResult, root: Path, cache: dict[str, str | None]
|
|
436
|
+
) -> bool:
|
|
437
|
+
"""True when a Spring Security config requires auth for every request —
|
|
438
|
+
then per-route method annotations aren't needed and routes aren't flagged."""
|
|
439
|
+
java_files = {r.file for r in scan.routes if Path(r.file).suffix.lower() in _JAVA_SUFFIXES}
|
|
440
|
+
# Config usually lives elsewhere; check route files plus any *Security*.java.
|
|
441
|
+
candidates = set(java_files)
|
|
442
|
+
for path in (p for p in walk_repo(root) if p.suffix == ".java" and "Security" in p.name):
|
|
443
|
+
try:
|
|
444
|
+
candidates.add(str(path.relative_to(root)))
|
|
445
|
+
except ValueError:
|
|
446
|
+
continue
|
|
447
|
+
for rel in candidates:
|
|
448
|
+
content = _read(root, rel, cache)
|
|
449
|
+
if content and "anyRequest" in content and _JAVA_GLOBAL_AUTH.search(content):
|
|
450
|
+
# Require an explicit authenticated()/hasRole() to avoid matching a
|
|
451
|
+
# config that permits all.
|
|
452
|
+
if re.search(r"\.(?:authenticated|hasRole|hasAuthority|hasAnyRole|fullyAuthenticated)\s*\(", content):
|
|
453
|
+
return True
|
|
454
|
+
return False
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _chain_evidence(route: Route, content: str) -> str:
|
|
458
|
+
"""Describe what the control-chain resolution found (i.e. nothing)."""
|
|
459
|
+
loc = f"{route.file}:{route.line}" if route.line else route.file
|
|
460
|
+
suffix = Path(route.file).suffix.lower()
|
|
461
|
+
if suffix in _JS_SUFFIXES:
|
|
462
|
+
has_any = bool(_JS_AUTH_TOKEN.search(content))
|
|
463
|
+
elif suffix in _PY_SUFFIXES:
|
|
464
|
+
has_any = bool(_PY_AUTH_DECORATOR.search(content) or _PY_DEPENDS_AUTH.search(content))
|
|
465
|
+
elif suffix in _JAVA_SUFFIXES:
|
|
466
|
+
has_any = bool(_JAVA_AUTH_ANNOTATION.search(content))
|
|
467
|
+
else:
|
|
468
|
+
has_any = False
|
|
469
|
+
tail = (
|
|
470
|
+
"auth controls exist in the file but none guard this route"
|
|
471
|
+
if has_any
|
|
472
|
+
else "no authentication/authorization control found on the route's chain"
|
|
473
|
+
)
|
|
474
|
+
return f"{route.method} {route.path} in {loc} — {tail}"
|