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
attackmap/authz.py
ADDED
|
@@ -0,0 +1,1695 @@
|
|
|
1
|
+
"""Broken object-level authorization (BOLA / IDOR) detection (#69).
|
|
2
|
+
|
|
3
|
+
OWASP API Security #1. This is the detection AttackMap can do that a
|
|
4
|
+
generic linter doesn't emphasize: it composes signals that already exist
|
|
5
|
+
in a ``ScanResult`` — routes, per-route auth attribution, datastore
|
|
6
|
+
hints, and taint reachability — into a single authorization finding.
|
|
7
|
+
|
|
8
|
+
## Heuristic
|
|
9
|
+
|
|
10
|
+
A route is a BOLA/IDOR candidate when ALL of:
|
|
11
|
+
|
|
12
|
+
1. It takes a **resource identifier** in the path (``/users/{id}``,
|
|
13
|
+
``/orders/:orderId``, ``/docs/<int:doc_id>``), and
|
|
14
|
+
2. It reaches a **datastore** — a DB hint in the same file/module, or a
|
|
15
|
+
taint chain from this route to a ``sql_execute`` sink, and
|
|
16
|
+
3. There is **no object-level authorization check in the handler** — no
|
|
17
|
+
ownership comparison, caller-scoped query, or policy call inside the
|
|
18
|
+
route's own handler span (``handler_span``), nor in a function the handler
|
|
19
|
+
delegates to (one hop: a same-file helper or an imported service function).
|
|
20
|
+
|
|
21
|
+
Authentication is not authorization (#242): ``@login_required``,
|
|
22
|
+
``requireAuth`` or a ``req.user`` reference prove the caller is *logged in*,
|
|
23
|
+
not that they may touch *this* object, so they never suppress a candidate.
|
|
24
|
+
They raise its confidence instead — an authenticated route that loads an
|
|
25
|
+
object by a client-supplied id with no object check is the classic IDOR.
|
|
26
|
+
Import-edge and name heuristics are imprecise, so findings are evidence, not
|
|
27
|
+
proof.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import posixpath
|
|
35
|
+
import re
|
|
36
|
+
from dataclasses import dataclass, field
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
from .handler_span import span_text
|
|
40
|
+
from .models import AttackSurface, BolaCandidate, Route, ScanResult
|
|
41
|
+
from .route_auth_fusion import repo_has_java_global_auth, resolve_control
|
|
42
|
+
from .safe_fs import contained_file, is_unsafe_link
|
|
43
|
+
from .srcpaths import JS_TS_SUFFIXES, is_test_file, is_vendored_file
|
|
44
|
+
|
|
45
|
+
# Path parameter forms across frameworks:
|
|
46
|
+
# {id} {user_id} {int:id} Flask / FastAPI / Starlette
|
|
47
|
+
# :id :orderId Express / Koa / Rails
|
|
48
|
+
# <id> <int:doc_id> Flask converters
|
|
49
|
+
_PATH_PARAM_RE = re.compile(r"\{([^{}]+)\}|:([A-Za-z_]\w*)|<(?:[^:<>]+:)?([^<>]+)>")
|
|
50
|
+
|
|
51
|
+
# Which extracted params look like a resource identifier an attacker
|
|
52
|
+
# would enumerate. Matches `id`, `user_id`, `orderId`, `uuid`, `slug`,
|
|
53
|
+
# `*_key`, `guid`, Django's `pk`, but not generic words like `format` or `lang`.
|
|
54
|
+
_ID_PARAM_RE = re.compile(
|
|
55
|
+
r"(?:^|_)id$|^id$|_id$|(?:^|[a-z])Id$|^pk$|uuid|guid|slug|(?:^|_)key$|hash$|token$|ref$",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# RPC-method scoping (#139). XRPC methods (`/xrpc/<nsid>`) and tRPC procedures
|
|
60
|
+
# (`trpc:<name>`) encode an object operation in the route path. An operation is
|
|
61
|
+
# an id-bearing object reference when it names an object-access verb and an
|
|
62
|
+
# object noun (`getRecord`, `describeRepo`, `updateProfile`) or a by-id form.
|
|
63
|
+
_RPC_OBJECT_VERB_RE = re.compile(
|
|
64
|
+
r"^(get|fetch|load|read|update|delete|put|patch|remove|describe|resolve)", re.IGNORECASE
|
|
65
|
+
)
|
|
66
|
+
_RPC_OBJECT_NOUN_RE = re.compile(
|
|
67
|
+
r"record|blob|profile|repo|post|account|user|doc|order|item|resource|handle"
|
|
68
|
+
r"|did|actor|thread|message|file|node|entity|comment|session|member|invoice",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
)
|
|
71
|
+
_RPC_BY_ID_RE = re.compile(r"[Bb]y_?[Ii]d\b")
|
|
72
|
+
|
|
73
|
+
# GraphQL auth is expressed as schema directives. Their presence on an
|
|
74
|
+
# id-bearing field means authorization is enforced → suppress (like the
|
|
75
|
+
# ownership markers do for HTTP handlers).
|
|
76
|
+
_GRAPHQL_AUTH_DIRECTIVE_RE = re.compile(
|
|
77
|
+
r"@(?:auth|authenticated|isAuthenticated|authorized|hasRole|hasScopes?"
|
|
78
|
+
r"|requiresScopes?|requireAuth|requiresAuth|hasPermission|policy|private|acl)\b",
|
|
79
|
+
re.IGNORECASE,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# Query-parameter scoping (#139). In real framework code the id arrives in the
|
|
83
|
+
# handler body, not the registered path — so scan the route's file for an
|
|
84
|
+
# id-shaped query-parameter *access* (`req.query.userId`, `request.args.get('id')`,
|
|
85
|
+
# `orderId: str = Query(...)`, `searchParams.get('docId')`, …).
|
|
86
|
+
_QUERY_ACCESS_RES = (
|
|
87
|
+
re.compile(r"\b(?:req|request|ctx|c)\s*\.\s*(?:query|args|GET|query_params)\s*\.\s*(\w+)"),
|
|
88
|
+
re.compile(r"""\b(?:req|request|ctx|c)\s*\.\s*(?:query|args|GET|query_params)\s*\[\s*['"](\w+)['"]"""),
|
|
89
|
+
re.compile(r"""\b(?:req|request|ctx|c)\s*\.\s*(?:query|args|GET|query_params)\s*\.\s*get\s*\(\s*['"](\w+)['"]"""),
|
|
90
|
+
re.compile(r"""\bsearchParams\s*\.\s*get\s*\(\s*['"](\w+)['"]"""),
|
|
91
|
+
re.compile(r"""\b(\w+)\s*:\s*[\w\[\]|. ]+?=\s*Query\s*\("""), # FastAPI `id: str = Query(...)`
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# The caller's identity as handler code spells it (not `get_current_user`).
|
|
95
|
+
_PRINCIPAL = (
|
|
96
|
+
r"(?<!\w)(?:current_user|currentUser|request\.user|req\.user|g\.user|ctx\.(?:state\.)?user"
|
|
97
|
+
r"|self\.request\.user|auth\.user|principal|session\[['\"]user\w*['\"]\]"
|
|
98
|
+
r"|session\.user\w*|user\.id\b|me\.id\b)"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Authentication / identity markers (#242): evidence the caller is logged in.
|
|
102
|
+
# They say nothing about whether this caller may touch *this* object, so they
|
|
103
|
+
# never suppress a candidate; they raise its confidence.
|
|
104
|
+
AUTHN_MARKERS = re.compile(
|
|
105
|
+
r"""
|
|
106
|
+
login_required | jwt_required | token_required | auth_required | requires?_auth\b
|
|
107
|
+
| requires?Auth\b | requireLogin | requireUser | isAuthenticated | is_authenticated
|
|
108
|
+
| ensureAuth\w* | ensureLoggedIn | @authenticated\b | passport\.authenticate
|
|
109
|
+
| authGuard | AuthGuard | @UseGuards | \bguard\b | verifyToken | verifyJwt | checkJwt
|
|
110
|
+
| get_current_user | get_current_active_user | Depends\(\s*\w*(?:auth|user)\w*
|
|
111
|
+
| current_user | currentUser | \brequest\.user\b | \breq\.user\b
|
|
112
|
+
| \bctx\.(?:state\.)?user\b | \bg\.user\b | \bself\.user\b
|
|
113
|
+
| @PreAuthorize | @Secured | @RolesAllowed | @Authenticated\b
|
|
114
|
+
""",
|
|
115
|
+
re.IGNORECASE | re.VERBOSE,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
# Object-level authorization markers (#242): the handler compares the record
|
|
119
|
+
# with the caller, scopes the query by the caller, or asks a policy. Evaluated
|
|
120
|
+
# within the handler span (plus one delegated hop), never the whole file.
|
|
121
|
+
OBJECT_AUTHZ_MARKERS = re.compile(
|
|
122
|
+
rf"""
|
|
123
|
+
# a record field compared with / scoped to the caller
|
|
124
|
+
(?:owner|user|tenant|account|org|organization|created_by|author)(?:_?id|Id)?
|
|
125
|
+
\s*(?:===?|!==?|=|:|__exact\s*=)\s*[^,;\n)]{{0,40}}?{_PRINCIPAL}
|
|
126
|
+
| {_PRINCIPAL}[^\n]{{0,60}}?(?:===?|!==?)\s*[\w.\[\]'"]*(?:owner|user|author|tenant|account)(?:_?id|Id)\b
|
|
127
|
+
# an ownership field compared with anything
|
|
128
|
+
| \b(?:owner|author)(?:_?id|Id)?\s*(?:!==?|===?)
|
|
129
|
+
| \b(?:\w+\.)?user_?[iI]d\s*(?:!==?|===?)
|
|
130
|
+
# SQL scoping column in a WHERE / AND clause
|
|
131
|
+
| (?:WHERE|AND)\s+[\w.]*(?:owner|user|tenant|account|org)_id\s*=
|
|
132
|
+
# a datastore lookup that carries the caller's identity (a call, not the
|
|
133
|
+
# handler's own `def get_x(id, user=Depends(get_current_user))` signature)
|
|
134
|
+
| (?<!def\s)(?<!function\s)\b(?:find|get|fetch|load|query|select|filter|where|lookup)\w*\s*\([^)\n]*{_PRINCIPAL}
|
|
135
|
+
# relation-scoped access through the caller (current_user.orders.find(id))
|
|
136
|
+
| {_PRINCIPAL}\s*\.\s*\w+?(?:_set)?\s*\.\s*(?:find|get|filter|where|objects|query|first|all)\b
|
|
137
|
+
# explicit authorization / policy calls
|
|
138
|
+
| \bauthorize[!\w]*\s*\( | \bauthorize!?\s+@?\w | \$this->authorize\s*\(
|
|
139
|
+
| Gate::(?:allows|authorize|denies|check) | ->can\s*\( | \.can\s*\( | \bcan\?\s*\(
|
|
140
|
+
| \bcannot\?\s*\( | \bpolic(?:y|ies)\s*[.(] | policy_scope | accessible_by | visible_to
|
|
141
|
+
| owned_by | \bscope_to | scoped_to | for_user | belongs_to
|
|
142
|
+
| can_access | canAccess | has_access | hasAccess | check_access | checkAccess
|
|
143
|
+
| verify_?[oO]wner | assert_?[oO]wner | ensure_?[oO]wner | is_?[oO]wner | check_?[oO]wner\w*
|
|
144
|
+
| has_object_permission | check_object_permissions | require[s]?_permission
|
|
145
|
+
| permission_required | \bhas_perm\b | hasPermission
|
|
146
|
+
| @PreAuthorize\([^)]*(?:\#\w|returnObject|principal|authentication|@\w+)
|
|
147
|
+
| @PostAuthorize | @PostFilter | @PreFilter
|
|
148
|
+
| findBy\w*And(?:Owner|User|Tenant|Account)\w*
|
|
149
|
+
# (?<!\w) + possessive: start once per word, never re-scan it (#236)
|
|
150
|
+
| (?<!\w)\w++(?:(?<=ByOwner)|(?<=ByUser)|(?<=ByTenant)|(?<=ByAccount)
|
|
151
|
+
|(?<=ByOwnerId)|(?<=ByUserId)|(?<=ByTenantId)|(?<=ByAccountId))\s*\(
|
|
152
|
+
""",
|
|
153
|
+
re.IGNORECASE | re.VERBOSE,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Function-level authorization (#242, BFLA): a role / permission check, as
|
|
157
|
+
# opposed to "is logged in". Evaluated within the handler span, the delegated
|
|
158
|
+
# hop, and the route's own middleware chain.
|
|
159
|
+
ROLE_MARKERS = re.compile(
|
|
160
|
+
r"""
|
|
161
|
+
\bis_?[aA]dmin\b | \bisSuper\w* | \bis_staff\b | \bis_superuser\b
|
|
162
|
+
| \bhas_?[rR]oles?\b | hasAnyRole | hasAuthority | hasAnyAuthority
|
|
163
|
+
| \brequires?_?[rR]oles?\b | \broles?_required\b | \badmin_required\b | \bsuperuser_required\b
|
|
164
|
+
| \brequires?_?[aA]dmin\w* | \badmin_?[oO]nly\b | \bonlyAdmin\b | \bensureAdmin\b
|
|
165
|
+
| \bcheckAdmin\b | \bverifyAdmin\b | \bisAdminMiddleware\b
|
|
166
|
+
| \bIsAdminUser\b | @staff_member_required | \buser_passes_test\b
|
|
167
|
+
| permission_classes\s*=\s*[\[(][^\])]*(?:Admin|Staff|Superuser|Perm|Role)
|
|
168
|
+
| @Secured | @RolesAllowed
|
|
169
|
+
| @PreAuthorize\([^)]*(?:hasRole|hasAuthority|hasAnyRole|hasAnyAuthority|hasPermission)
|
|
170
|
+
| \bpermission_required\b | \bhas_perm\b | \bhasPermission\b | \brequires?_?[pP]ermissions?\b
|
|
171
|
+
| \bcheck_?[pP]ermissions?\b | \bcan\?\s*\( | \.can\s*\( | ->can\s*\( | \bauthorize(?!_?roles?)[!\w]*\s*\(
|
|
172
|
+
| Gate:: | \.roles?\s*(?:!==?|===?|\bin\b|\bnot\s+in\b) | \brole\s*(?:!==?|===?)\s*['"]
|
|
173
|
+
| \broles\s*\.\s*(?:includes|indexOf|contains)\s*\( | ['"]admin['"]\s+(?:not\s+)?in\s+[\w.]*roles
|
|
174
|
+
| ->userIsAllowed\s*\(
|
|
175
|
+
| (?:\$|->)(?:get)?(?:acl|permissions?|authorization|authz|auth)(?:\(\))?\s*->\s*isAllowed\s*\(
|
|
176
|
+
| \bRolePolicy\s*\( | \bPermissionPolicy\s*\(
|
|
177
|
+
""",
|
|
178
|
+
re.IGNORECASE | re.VERBOSE,
|
|
179
|
+
)
|
|
180
|
+
# Symfony authorization (#343). A grant counts unless its attribute is one
|
|
181
|
+
# every authenticated session (or every visitor) has. Parsed rather than
|
|
182
|
+
# matched with a lookahead, so `IsGranted( 'ROLE_USER' )` and
|
|
183
|
+
# `IsGranted(attribute: 'IS_AUTHENTICATED_FULLY')` can't slip through.
|
|
184
|
+
_SYMFONY_GRANT_RE = re.compile(
|
|
185
|
+
r"\b(?:IsGranted|isGranted|denyAccessUnlessGranted|is_granted)\s*+\(\s*+(?:attribute\s*+:\s*+)?"
|
|
186
|
+
)
|
|
187
|
+
_SYMFONY_SECURITY_RE = re.compile(r"(?:#\[\s*+|@)(?:[\w\\]{0,200}\\)?Security\s*+\(")
|
|
188
|
+
_GRANT_ARG_RE = re.compile(r"""'([^'\n]{0,200})'|"([^"\n]{0,200})"|(?:[\w\\]{1,200}::)?(\w+)""")
|
|
189
|
+
_ROLE_CONST_RE = re.compile(r"\bROLE_[A-Z][A-Z0-9_]*")
|
|
190
|
+
_SYMFONY_NON_ROLES = frozenset(
|
|
191
|
+
{
|
|
192
|
+
"IS_AUTHENTICATED", "IS_AUTHENTICATED_FULLY", "IS_AUTHENTICATED_REMEMBERED",
|
|
193
|
+
"IS_AUTHENTICATED_ANONYMOUSLY", "IS_ANONYMOUS", "IS_REMEMBERED", "IS_IMPERSONATOR",
|
|
194
|
+
"PUBLIC_ACCESS", "ROLE_USER", "ROLE_ANONYMOUS", "ROLE_PREVIOUS_ADMIN",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
# Symfony `access_control` rules a plugin declares as a guard
|
|
198
|
+
# (`access_control ^/admin: ROLE_ADMIN`); read from Route.guards only.
|
|
199
|
+
_ACCESS_CONTROL_GUARD_RE = re.compile(r"\baccess_control\b")
|
|
200
|
+
_SYMFONY_SCAN = 400
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _symfony_role_check(text: str) -> bool:
|
|
204
|
+
"""A Symfony grant of a role or voter permission other than the ones
|
|
205
|
+
every session has: ``#[IsGranted(...)]``, ``isGranted(...)``,
|
|
206
|
+
``denyAccessUnlessGranted(...)``, or a ``#[Security(...)]`` expression
|
|
207
|
+
naming such a role."""
|
|
208
|
+
if "ranted" not in text and "Security" not in text and "is_granted" not in text:
|
|
209
|
+
return False
|
|
210
|
+
for match in _SYMFONY_GRANT_RE.finditer(text):
|
|
211
|
+
arg = _GRANT_ARG_RE.match(text, match.end(), match.end() + _SYMFONY_SCAN)
|
|
212
|
+
if arg is None:
|
|
213
|
+
return True # an expression, array or variable: still an authorization call
|
|
214
|
+
value = next(group for group in arg.groups() if group is not None)
|
|
215
|
+
if value not in _SYMFONY_NON_ROLES:
|
|
216
|
+
return True
|
|
217
|
+
for match in _SYMFONY_SECURITY_RE.finditer(text):
|
|
218
|
+
body = text[match.end() : match.end() + _SYMFONY_SCAN]
|
|
219
|
+
if any(role not in _SYMFONY_NON_ROLES for role in _ROLE_CONST_RE.findall(body)):
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def has_role_check(text: str, *, python: bool = False) -> bool:
|
|
225
|
+
"""A role or permission check in ``text`` (handler code or guards);
|
|
226
|
+
``python`` masks Python comments and docstrings for the role-call check."""
|
|
227
|
+
return bool(ROLE_MARKERS.search(text)) or _symfony_role_check(text) or _role_call_check(text, python=python)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# Guard *names* (as plugins put them in Route.guards, #349) that are a role or
|
|
231
|
+
# permission check. Matched against the whole name, with any `@` and call
|
|
232
|
+
# arguments stripped, so these generic words (`permit`, `acl`, `rbac`) only
|
|
233
|
+
# count as a guard, never as a word in handler code (`params.permit(...)`).
|
|
234
|
+
# A plugin can also mark any guard explicitly with a `role:` prefix.
|
|
235
|
+
#
|
|
236
|
+
# Arguments matter (an allow-list): a role-list guard counts only when every
|
|
237
|
+
# listed role is a literal naming a privileged role (`_PRIVILEGED_ROLES`). `guard.check(...)` and
|
|
238
|
+
# `require(d)Scopes(...)` are left out: in common frameworks they check token
|
|
239
|
+
# scopes any authenticated client may hold.
|
|
240
|
+
_ROLE_GUARD_NAMES = frozenset(
|
|
241
|
+
name.lower()
|
|
242
|
+
for name in (
|
|
243
|
+
"restrictTo", "permit", "checkRole", "checkRoles", "rbac", "acl", "allowRoles", "allowedRoles",
|
|
244
|
+
"authorizeRoles", "PermissionsGuard", "PermissionGuard", "PoliciesGuard", "PolicyGuard", "Permissions",
|
|
245
|
+
"CheckPolicies", "MembershipRoles", "Pbac", "Roles", "RolesGuard", "RoleGuard",
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
_GUARD_NAME_RE = re.compile(r"^\s*+@?\s*+([\w.$]{1,120}+)\s*+(\()?")
|
|
249
|
+
# Role names that exclude ordinary signed-in users (case-insensitive, `ROLE_`
|
|
250
|
+
# prefix stripped). An allow-list: anything else is not proof of a role check.
|
|
251
|
+
_PRIVILEGED_ROLES = frozenset(
|
|
252
|
+
{
|
|
253
|
+
"admin", "administrator", "owner", "superuser", "superadmin", "systemadmin", "sysadmin",
|
|
254
|
+
"staff", "root", "moderator", "manager",
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
_ROLE_LITERAL_RE = re.compile(r"'([^'\n]{1,80})'" r'|"([^"\n]{1,80})"')
|
|
258
|
+
# An enum-like member (`Role.ADMIN`, `UserRole.Admin`, `ROLES.OWNER`,
|
|
259
|
+
# `Prisma.Role.ADMIN`): the member's owner is a capitalised name about roles
|
|
260
|
+
# or permissions, so `doc.owner`, `User.admin` or `Config.roles.owner` don't count.
|
|
261
|
+
_ROLE_MEMBER_RE = re.compile(
|
|
262
|
+
r"(?:[A-Z][\w$]{0,80}+\.)?([A-Z][\w$]{0,80}+)\.([A-Za-z_$][\w$]{0,80}+)"
|
|
263
|
+
)
|
|
264
|
+
# The owner's name ends in a role word: `Role`, `UserRole`, `ROLES`,
|
|
265
|
+
# `Permission`, `AccessLevel` (not `GroupChat`).
|
|
266
|
+
_ROLE_ENUM_RE = re.compile(r"(?i)(?:role|perm|permission|privilege|authority|authorities|accesslevel|usergroup)s?$")
|
|
267
|
+
# A bare ALL_CAPS constant (`ADMIN`, `ROLE_ADMIN`).
|
|
268
|
+
_ROLE_CONSTANT_RE = re.compile(r"[A-Z][A-Z0-9_]{0,80}")
|
|
269
|
+
# Role-list calls in handler or chain code: `restrictTo('admin')`,
|
|
270
|
+
# `@MembershipRoles([MembershipRole.OWNER])`, `@Roles(Role.Admin)`. Judged by
|
|
271
|
+
# their arguments. Metadata guards (`RolesGuard`, `PermissionsGuard`) are not
|
|
272
|
+
# role checks on their own: they enforce this metadata, and allow all without it.
|
|
273
|
+
_ROLE_CALL_RE = re.compile(
|
|
274
|
+
r"(?:\brestrictTo|\bcheck_?[rR]oles?|\ballow(?:ed)?_?[rR]oles|\bauthorize_?[rR]oles?(?:For|To)?|@Permissions|@CheckPolicies|@MembershipRoles|@Pbac|@Roles)\s*+\("
|
|
275
|
+
)
|
|
276
|
+
_MAX_ROLE_ARGS = 400
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _privileged_args(args: str) -> bool:
|
|
280
|
+
"""An allow-list: every listed role is a string literal, an enum-like
|
|
281
|
+
member (`Role.ADMIN`) or an ALL_CAPS constant (`ADMIN`) naming a
|
|
282
|
+
privileged role. Other identifiers, members of lower-case receivers
|
|
283
|
+
(`doc.owner`, `req.query.admin`), spreads and `Object.values(...)` don't count."""
|
|
284
|
+
inner = args.strip()
|
|
285
|
+
while inner.startswith("[") and inner.endswith("]"):
|
|
286
|
+
inner = inner[1:-1].strip()
|
|
287
|
+
elements = [e.strip() for e in inner.split(",") if e.strip()]
|
|
288
|
+
if not elements:
|
|
289
|
+
return False
|
|
290
|
+
for element in elements:
|
|
291
|
+
literal = _ROLE_LITERAL_RE.fullmatch(element)
|
|
292
|
+
member = _ROLE_MEMBER_RE.fullmatch(element) if literal is None else None
|
|
293
|
+
if literal is not None:
|
|
294
|
+
value = literal.group(1) or literal.group(2)
|
|
295
|
+
elif member is not None and _ROLE_ENUM_RE.search(member.group(1)):
|
|
296
|
+
value = member.group(2)
|
|
297
|
+
elif _ROLE_CONSTANT_RE.fullmatch(element):
|
|
298
|
+
value = element
|
|
299
|
+
else:
|
|
300
|
+
return False
|
|
301
|
+
if _normalized_role(value) not in _PRIVILEGED_ROLES:
|
|
302
|
+
return False
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _normalized_role(value: str) -> str:
|
|
307
|
+
"""Lower-case, `ROLE_` stripped, separators dropped (`SUPER_ADMIN`, `super-admin`)."""
|
|
308
|
+
value = value.strip().lower()
|
|
309
|
+
value = value[5:] if value.startswith("role_") else value
|
|
310
|
+
return value.replace("_", "").replace("-", "").replace(" ", "")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _call_close(code: str, open_paren: int, limit: int = _MAX_ROLE_ARGS) -> int | None:
|
|
314
|
+
"""Offset of the `)` closing the call whose `(` is at ``open_paren`` (bounded)."""
|
|
315
|
+
depth = 0
|
|
316
|
+
for i in range(open_paren, min(len(code), open_paren + limit)):
|
|
317
|
+
if code[i] == "(":
|
|
318
|
+
depth += 1
|
|
319
|
+
elif code[i] == ")":
|
|
320
|
+
depth -= 1
|
|
321
|
+
if depth == 0:
|
|
322
|
+
return i
|
|
323
|
+
return None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_QUOTES = "'\"`"
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _mask_code(text: str, *, python: bool = False) -> str:
|
|
330
|
+
"""``text`` with comments (`//`, `/* */`, `# ` lines; any `#` and
|
|
331
|
+
triple-quoted strings with ``python``), string-literal contents and JS
|
|
332
|
+
regex-literal bodies blanked, offsets and newlines kept. Approximate; used
|
|
333
|
+
only to tell a call from a mention of one. A regex literal (`/` after an
|
|
334
|
+
operator or bracket) can't start a string or comment that hides code."""
|
|
335
|
+
out = list(text)
|
|
336
|
+
n = len(text)
|
|
337
|
+
i = 0
|
|
338
|
+
prev = "" # last non-space character outside literals
|
|
339
|
+
while i < n:
|
|
340
|
+
c = text[i]
|
|
341
|
+
if not python and c == "/" and prev in _REGEX_PREV and text[i + 1 : i + 2] not in ("/", "*"):
|
|
342
|
+
j = i + 1
|
|
343
|
+
in_class = False
|
|
344
|
+
while j < n and text[j] != "\n":
|
|
345
|
+
if text[j] == "\\":
|
|
346
|
+
j += 2
|
|
347
|
+
continue
|
|
348
|
+
if text[j] == "[":
|
|
349
|
+
in_class = True
|
|
350
|
+
elif text[j] == "]":
|
|
351
|
+
in_class = False
|
|
352
|
+
elif text[j] == "/" and not in_class:
|
|
353
|
+
break
|
|
354
|
+
j += 1
|
|
355
|
+
for k in range(i + 1, min(j, n)):
|
|
356
|
+
out[k] = " " # `/{/g`: its braces aren't code
|
|
357
|
+
i, prev = j + 1, "/"
|
|
358
|
+
continue
|
|
359
|
+
if python and text.startswith(('"""', "'''"), i):
|
|
360
|
+
j = text.find(text[i : i + 3], i + 3)
|
|
361
|
+
j = n if j < 0 else j
|
|
362
|
+
for k in range(i + 3, j):
|
|
363
|
+
if out[k] != "\n":
|
|
364
|
+
out[k] = " "
|
|
365
|
+
i, prev = j + 3, c
|
|
366
|
+
continue
|
|
367
|
+
if c in _QUOTES:
|
|
368
|
+
j = i + 1
|
|
369
|
+
while j < n and text[j] != c:
|
|
370
|
+
if text[j] == "\\":
|
|
371
|
+
j += 1
|
|
372
|
+
elif text[j] == "\n" and c != "`":
|
|
373
|
+
break
|
|
374
|
+
j += 1
|
|
375
|
+
for k in range(i + 1, min(j, n)):
|
|
376
|
+
if out[k] != "\n":
|
|
377
|
+
out[k] = " "
|
|
378
|
+
i, prev = j + 1, c
|
|
379
|
+
continue
|
|
380
|
+
if (not python and text.startswith("//", i)) or (
|
|
381
|
+
c == "#" and (python or ((i == 0 or text[i - 1] in " \t\n") and text[i + 1 : i + 2] in (" ", "\t")))
|
|
382
|
+
):
|
|
383
|
+
j = text.find("\n", i)
|
|
384
|
+
j = n if j < 0 else j
|
|
385
|
+
for k in range(i, j):
|
|
386
|
+
out[k] = " "
|
|
387
|
+
i = j
|
|
388
|
+
continue
|
|
389
|
+
if not python and text.startswith("/*", i):
|
|
390
|
+
j = text.find("*/", i + 2)
|
|
391
|
+
j = n if j < 0 else j + 2
|
|
392
|
+
for k in range(i, j):
|
|
393
|
+
if out[k] != "\n":
|
|
394
|
+
out[k] = " "
|
|
395
|
+
i = j
|
|
396
|
+
continue
|
|
397
|
+
if not c.isspace():
|
|
398
|
+
prev = c
|
|
399
|
+
i += 1
|
|
400
|
+
return "".join(out)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# A `/` after one of these (or at the start) begins a regex literal, not a division.
|
|
404
|
+
# (Not `<` / `>`: in JSX `</p>` is a closing tag.)
|
|
405
|
+
_REGEX_PREV = frozenset(["", *"(,=:[!&|?{};+-*%~^"])
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _role_call_check(text: str, *, python: bool = False) -> bool:
|
|
409
|
+
# Matched on masked code (a call in a string or comment doesn't count);
|
|
410
|
+
# the arguments, string literals included, are read from the original.
|
|
411
|
+
# Most text has no role call at all: mask only when there is one.
|
|
412
|
+
if _ROLE_CALL_RE.search(text) is None:
|
|
413
|
+
return False
|
|
414
|
+
code = _mask_code(text, python=python)
|
|
415
|
+
for count, match in enumerate(_ROLE_CALL_RE.finditer(code)):
|
|
416
|
+
if count >= _MAX_CALLS:
|
|
417
|
+
break
|
|
418
|
+
close = _call_close(code, match.end() - 1)
|
|
419
|
+
if close is not None and _privileged_args(text[match.end() : close]):
|
|
420
|
+
return True
|
|
421
|
+
return False
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _role_guard_name(guard: str) -> bool:
|
|
425
|
+
stripped = guard.strip()
|
|
426
|
+
if stripped.lower().startswith("role:"):
|
|
427
|
+
# `role:admin`: an explicit privileged role name.
|
|
428
|
+
return _normalized_role(stripped[5:]) in _PRIVILEGED_ROLES
|
|
429
|
+
match = _GUARD_NAME_RE.match(guard)
|
|
430
|
+
if match is None or match.group(1).lower() not in _ROLE_GUARD_NAMES:
|
|
431
|
+
return False
|
|
432
|
+
if match.group(2) is None:
|
|
433
|
+
# Only the name proves nothing: a role-list call needs its arguments,
|
|
434
|
+
# and a metadata-driven guard (`RolesGuard`, `PermissionsGuard`)
|
|
435
|
+
# enforces whatever `@Roles(...)` lists, and allows everyone when
|
|
436
|
+
# none is set (NestJS). The privileged `@Roles('admin')` entry counts
|
|
437
|
+
# on its own.
|
|
438
|
+
return False
|
|
439
|
+
close = _call_close(guard, match.end() - 1)
|
|
440
|
+
return close is not None and _privileged_args(guard[match.end() : close])
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _guards_role_check(route: Route) -> bool:
|
|
444
|
+
"""Plugin-declared guards with role semantics, including Symfony
|
|
445
|
+
``access_control`` rules naming a role other than ROLE_USER & co."""
|
|
446
|
+
if any(_role_guard_name(guard) for guard in route.guards):
|
|
447
|
+
return True
|
|
448
|
+
text = " ".join(route.guards) + " " + (route.guard_evidence or "")
|
|
449
|
+
if has_role_check(text):
|
|
450
|
+
return True
|
|
451
|
+
return any(
|
|
452
|
+
_ACCESS_CONTROL_GUARD_RE.search(guard)
|
|
453
|
+
and any(role not in _SYMFONY_NON_ROLES for role in _ROLE_CONST_RE.findall(guard))
|
|
454
|
+
for guard in route.guards
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
# The `(` of `router.use(`; its arguments are read with balanced parentheses,
|
|
459
|
+
# so `router.use(restrictTo('admin'))` keeps its inner call.
|
|
460
|
+
# `router.use(` or `this.router.use(` (class-based controllers); the receiver
|
|
461
|
+
# is the name after any `this.`.
|
|
462
|
+
_JS_USE_RE = re.compile(r"(?<![\w$.])(?:this\s*+\.\s*+)?+([A-Za-z_$][\w$]{0,80}+)\s*+\.\s*+use\s*+\(")
|
|
463
|
+
_JS_ROUTE_RECEIVER_RE = re.compile(
|
|
464
|
+
r"(?<![\w$.])(?:this\s*+\.\s*+)?+([A-Za-z_$][\w$]{0,80}+)\s*+\.\s*+(?:get|post|put|patch|delete|del|all|options|head|route)\s*+\("
|
|
465
|
+
)
|
|
466
|
+
_CHAIN_HEAD_RE = re.compile(r"\s*+([A-Za-z_$][\w$]{0,80}+)\s*+")
|
|
467
|
+
_MAX_CHAIN_LINES = 8
|
|
468
|
+
_MAX_ROUTER_ARGS = 2_000
|
|
469
|
+
_MAX_CALLS = 200
|
|
470
|
+
_PY_ROUTER_DEPS_RE = re.compile(
|
|
471
|
+
r"(?<![\w.])(?P<router>[A-Za-z_]\w{0,80}+)\s*+(?::[^=\n]{0,80}+)?=\s*+(?:fastapi\s*+\.\s*+)?APIRouter\s*+\("
|
|
472
|
+
)
|
|
473
|
+
_PY_ROUTE_DECORATOR_RE = re.compile(r"(?<!\s)\s*+@([A-Za-z_]\w{0,80}+)\s*+\.\s*+(?:get|post|put|patch|delete|api_route|route|head|options)\s*+\(")
|
|
474
|
+
_AUTH_MIDDLEWARE_NAME_RE = re.compile(
|
|
475
|
+
r"(?i)auth|protect|login|session|passport|jwt|guard|verify|require|ensure|check|token"
|
|
476
|
+
r"|admin|role|staff|perm|owner|super|rbac|acl|polic|restrict|only|allow"
|
|
477
|
+
r"|cors|helmet|compression|morgan|logger|parser|limit|csrf|cookie|static|json|urlencoded"
|
|
478
|
+
)
|
|
479
|
+
# A name that ends in a path word (not `Route`: `protectRoute` is middleware).
|
|
480
|
+
_PATH_NAME_RE = re.compile(r"(?i)(?:path|prefix|base|baseurl|url|mount|mountpoint)$")
|
|
481
|
+
# The last `use` argument when it is a bare router name: `app.use('/admin', adminRouter)`.
|
|
482
|
+
_MOUNTED_ROUTER_RE = re.compile(r"[,(]\s*+([A-Za-z_$][\w$]{0,80}+)\s*+\)")
|
|
483
|
+
# A middleware-shaped `use` argument: a (dotted) name, optionally called.
|
|
484
|
+
_MIDDLEWARE_ARG_RE = re.compile(r"([A-Za-z_$][\w$]{0,80}+(?:\s*+\.\s*+[A-Za-z_$][\w$]{0,80}+){0,4}+)\s*+(?:\([^()]{0,400}+\))?+")
|
|
485
|
+
_CONSTANT_RE = re.compile(r"[A-Z][A-Z0-9_]{0,80}+")
|
|
486
|
+
_ROOT_PATH_RE = re.compile(r"(?<!\s)\s*+(['\"`])/\1\s*+,")
|
|
487
|
+
_METHOD_ROLE_META_RE = re.compile(r"@(?:Roles|Permissions|CheckPolicies|MembershipRoles|Pbac)\s*+\(")
|
|
488
|
+
_MAX_DECORATOR_LINES = 12
|
|
489
|
+
_MAX_CLASS_HEAD = 2_000
|
|
490
|
+
_METHOD_SIGNATURE_RE = re.compile(
|
|
491
|
+
r"(?:(?:public|private|protected|static|async|readonly)\s++){0,4}+[A-Za-z_$][\w$]{0,80}+\s*+(?:<[^>\n]{0,80}+>)?+\s*+\("
|
|
492
|
+
)
|
|
493
|
+
_CLASS_DECL_RE = re.compile(r"\b(?:class|interface)\s+\w+")
|
|
494
|
+
# Route declarations that live in configuration rather than code (#343):
|
|
495
|
+
# data formats, and PHP files that are a returned config array (Laminas /
|
|
496
|
+
# Omeka `routes.config.php`, Mezzio `routes.php`). The handler (a
|
|
497
|
+
# controller) is elsewhere, so the route's span there is not its handler.
|
|
498
|
+
_CONFIG_ROUTE_SUFFIXES = frozenset({".yaml", ".yml", ".xml", ".json", ".ini", ".neon", ".toml"})
|
|
499
|
+
_PHP_OPEN_RE = re.compile(r"<\?php\b")
|
|
500
|
+
_PHP_PREAMBLE_RE = re.compile(
|
|
501
|
+
r"\s+|//[^\n]*|#(?!\[)[^\n]*|/\*|declare\s*\([^)]{0,200}\)\s*;|namespace\s+[\w\\]+\s*;|use\s+[^;]{1,500};"
|
|
502
|
+
)
|
|
503
|
+
_PHP_RETURN_ARRAY_RE = re.compile(r"return\s*(?:\[|array\s*\()", re.IGNORECASE)
|
|
504
|
+
# A handler written inline in the config (a closure) is visible after all.
|
|
505
|
+
_INLINE_HANDLER_RE = re.compile(r"\bfunction\s*\(|\bfn\s*\(")
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# A Symfony YAML/XML route's controller: `controller: App\Controller\X::y`,
|
|
509
|
+
# `_controller: ...` under `defaults`, `controller="..."`, or
|
|
510
|
+
# `<default key="_controller">...</default>`.
|
|
511
|
+
_CONFIG_CONTROLLER_RE = re.compile(
|
|
512
|
+
r"""(?:\b_?controller['"]?\s*+[:=]|\bkey\s*+=\s*+['"]_controller['"]\s*+>)\s*+['"]?\\?"""
|
|
513
|
+
r"""([A-Za-z_][\w\\]{0,300})(?:::(\w+))?"""
|
|
514
|
+
)
|
|
515
|
+
_PHP_FUNCTION_RE = re.compile(r"\bfunction\s++&?\s*+(\w+)\s*+\(")
|
|
516
|
+
_CONTROLLER_ATTR_LINES = 15
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
class _ConfigControllerResolver:
|
|
520
|
+
"""Resolve a config-declared Symfony route to its controller method via
|
|
521
|
+
the repo's composer PSR-4 map, and return the method's text plus the
|
|
522
|
+
attributes on it and on its class (#343). Files are read through
|
|
523
|
+
``contained_file``; nothing outside the repo is followed."""
|
|
524
|
+
|
|
525
|
+
def __init__(self, root: Path, cache: dict[str, str | None]):
|
|
526
|
+
self.root = root
|
|
527
|
+
self.cache = cache
|
|
528
|
+
self._psr4: list[tuple[str, str]] | None = None
|
|
529
|
+
|
|
530
|
+
def _autoload(self) -> list[tuple[str, str]]:
|
|
531
|
+
if self._psr4 is None:
|
|
532
|
+
self._psr4 = []
|
|
533
|
+
text = _read_file_content("composer.json", self.root, self.cache)
|
|
534
|
+
try:
|
|
535
|
+
data = json.loads(text) if text else {}
|
|
536
|
+
except ValueError:
|
|
537
|
+
data = {}
|
|
538
|
+
psr4 = data.get("autoload", {}).get("psr-4", {}) if isinstance(data, dict) else {}
|
|
539
|
+
if isinstance(psr4, dict):
|
|
540
|
+
for prefix, dirs in list(psr4.items())[:64]:
|
|
541
|
+
for directory in [dirs] if isinstance(dirs, str) else (dirs if isinstance(dirs, list) else []):
|
|
542
|
+
if isinstance(prefix, str) and isinstance(directory, str):
|
|
543
|
+
self._psr4.append((prefix, directory))
|
|
544
|
+
self._psr4.sort(key=lambda item: -len(item[0]))
|
|
545
|
+
return self._psr4
|
|
546
|
+
|
|
547
|
+
def resolve(self, config_file: str, span: str) -> tuple[str, str, str, str] | None:
|
|
548
|
+
"""``(controller file, its content, method + attributes text,
|
|
549
|
+
"Class::method")`` or None when the span names no resolvable
|
|
550
|
+
controller (only data-format route files are followed)."""
|
|
551
|
+
if Path(config_file).suffix.lower() not in _CONFIG_ROUTE_SUFFIXES:
|
|
552
|
+
return None
|
|
553
|
+
match = _CONFIG_CONTROLLER_RE.search(span)
|
|
554
|
+
if match is None:
|
|
555
|
+
return None
|
|
556
|
+
cls, method = match.group(1).strip("\\"), match.group(2) or "__invoke"
|
|
557
|
+
rel_file = next(
|
|
558
|
+
(
|
|
559
|
+
directory.rstrip("/") + "/" + cls[len(prefix) :].replace("\\", "/") + ".php"
|
|
560
|
+
for prefix, directory in self._autoload()
|
|
561
|
+
if cls.startswith(prefix)
|
|
562
|
+
),
|
|
563
|
+
None,
|
|
564
|
+
)
|
|
565
|
+
if rel_file is None:
|
|
566
|
+
return None
|
|
567
|
+
rel_file = posixpath.normpath(rel_file)
|
|
568
|
+
if rel_file.startswith(".."):
|
|
569
|
+
return None
|
|
570
|
+
content = _read_file_content(rel_file, self.root, self.cache)
|
|
571
|
+
if not content:
|
|
572
|
+
return None
|
|
573
|
+
lines = content.splitlines()
|
|
574
|
+
# (0-based line, name) of each function, counting newlines once.
|
|
575
|
+
starts: list[tuple[int, str]] = []
|
|
576
|
+
line_no = pos = 0
|
|
577
|
+
for m in _PHP_FUNCTION_RE.finditer(content):
|
|
578
|
+
line_no += content.count("\n", pos, m.start())
|
|
579
|
+
pos = m.start()
|
|
580
|
+
starts.append((line_no, m.group(1)))
|
|
581
|
+
index = next((i for i, (_, name) in enumerate(starts) if name == method), None)
|
|
582
|
+
if index is None:
|
|
583
|
+
return None
|
|
584
|
+
first = starts[index][0]
|
|
585
|
+
if index + 1 < len(starts):
|
|
586
|
+
# Stop above the next method's own attributes / docblock: they
|
|
587
|
+
# describe it, not this one.
|
|
588
|
+
last = starts[index + 1][0]
|
|
589
|
+
last -= len(_leading_attributes(lines, last))
|
|
590
|
+
else:
|
|
591
|
+
last = len(lines)
|
|
592
|
+
body = _leading_attributes(lines, first) + lines[first : min(last, first + _AUTHZ_SPAN)]
|
|
593
|
+
class_line = next((i for i, line in enumerate(lines[:first]) if _CLASS_DECL_RE.search(line)), None)
|
|
594
|
+
class_attrs = _leading_attributes(lines, class_line) if class_line is not None else []
|
|
595
|
+
return rel_file, content, "\n".join(class_attrs + body), f"{cls}::{method}"
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _leading_attributes(lines: list[str], index: int) -> list[str]:
|
|
599
|
+
"""The attribute / docblock lines directly above ``lines[index]``,
|
|
600
|
+
stopping at the previous member (a line ending in ``;``, ``{`` or ``}``)."""
|
|
601
|
+
start = index
|
|
602
|
+
while start > 0 and index - start < _CONTROLLER_ATTR_LINES:
|
|
603
|
+
previous = lines[start - 1].rstrip()
|
|
604
|
+
if previous.endswith((";", "{", "}")):
|
|
605
|
+
break
|
|
606
|
+
start -= 1
|
|
607
|
+
return lines[start:index]
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def is_config_route_file(file: str, content: str) -> bool:
|
|
611
|
+
"""True when ``file`` declares routes as configuration: a data format,
|
|
612
|
+
or a PHP file whose first statement is ``return [...]`` /
|
|
613
|
+
``return array(...)`` (after ``namespace`` / ``use`` / ``declare`` and
|
|
614
|
+
comments)."""
|
|
615
|
+
suffix = Path(file).suffix.lower()
|
|
616
|
+
if suffix in _CONFIG_ROUTE_SUFFIXES:
|
|
617
|
+
return True
|
|
618
|
+
if suffix != ".php":
|
|
619
|
+
return False
|
|
620
|
+
opener = _PHP_OPEN_RE.match(content.lstrip("\ufeff"))
|
|
621
|
+
if not opener:
|
|
622
|
+
return False
|
|
623
|
+
text = content.lstrip("\ufeff")
|
|
624
|
+
pos = opener.end()
|
|
625
|
+
# Each step consumes at least one character, so this is linear.
|
|
626
|
+
for _ in range(10_000):
|
|
627
|
+
match = _PHP_PREAMBLE_RE.match(text, pos)
|
|
628
|
+
if not match:
|
|
629
|
+
break
|
|
630
|
+
if match.group() == "/*":
|
|
631
|
+
end = text.find("*/", match.end())
|
|
632
|
+
if end < 0:
|
|
633
|
+
return False
|
|
634
|
+
pos = end + 2
|
|
635
|
+
else:
|
|
636
|
+
pos = match.end()
|
|
637
|
+
return _PHP_RETURN_ARRAY_RE.match(text, pos) is not None
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def find_bfla_routes(
|
|
641
|
+
scan: ScanResult, surfaces: list[AttackSurface], root: str | Path | None = None
|
|
642
|
+
) -> list[tuple[Route, str]]:
|
|
643
|
+
"""Privileged (``admin``) routes that resolve an authentication control
|
|
644
|
+
but no role or permission check (#242, OWASP API5 BFLA), each with its
|
|
645
|
+
evidence line. Routes with no authentication at all are left to the
|
|
646
|
+
unauthenticated-route findings."""
|
|
647
|
+
root_path = Path(root or scan.root).resolve()
|
|
648
|
+
admin_keys = {(s.method, s.route, s.file) for s in surfaces if s.category == "admin"}
|
|
649
|
+
if not admin_keys:
|
|
650
|
+
return []
|
|
651
|
+
content_cache: dict[str, str | None] = {}
|
|
652
|
+
hop = _HopResolver(root_path, content_cache)
|
|
653
|
+
route_lines: dict[str, list[int]] = {}
|
|
654
|
+
for r in scan.routes:
|
|
655
|
+
if r.line is not None:
|
|
656
|
+
route_lines.setdefault(r.file, []).append(r.line)
|
|
657
|
+
java_global_auth: bool | None = None
|
|
658
|
+
lines_cache: dict[str, list[str]] = {}
|
|
659
|
+
chain_role_cache: dict[str, bool | _ChainRoles] = {}
|
|
660
|
+
config_cache: dict[str, bool] = {}
|
|
661
|
+
controllers = _ConfigControllerResolver(root_path, content_cache)
|
|
662
|
+
out: list[tuple[Route, str]] = []
|
|
663
|
+
seen: set[tuple[str, str, str]] = set()
|
|
664
|
+
for route in scan.routes:
|
|
665
|
+
key = (route.method, route.path, route.file)
|
|
666
|
+
if key not in admin_keys or key in seen:
|
|
667
|
+
continue
|
|
668
|
+
seen.add(key)
|
|
669
|
+
if route.auth == "anonymous":
|
|
670
|
+
continue
|
|
671
|
+
content = _read_file_content(route.file, root_path, content_cache)
|
|
672
|
+
if not content:
|
|
673
|
+
continue
|
|
674
|
+
if (
|
|
675
|
+
java_global_auth is None
|
|
676
|
+
and route.auth != "required"
|
|
677
|
+
and Path(route.file).suffix.lower() in _JAVA_SUFFIXES
|
|
678
|
+
):
|
|
679
|
+
java_global_auth = repo_has_java_global_auth(scan, root_path, content_cache)
|
|
680
|
+
if route.auth == "required":
|
|
681
|
+
authn = "authentication declared" + (f" by {route.source_analyzer}" if route.source_analyzer else "")
|
|
682
|
+
else:
|
|
683
|
+
authn = resolve_control(route, content, bool(java_global_auth)) or ""
|
|
684
|
+
if not authn:
|
|
685
|
+
continue
|
|
686
|
+
if _guards_role_check(route):
|
|
687
|
+
continue # the plugin resolved a role check
|
|
688
|
+
if route.file not in lines_cache:
|
|
689
|
+
lines_cache[route.file] = content.splitlines()
|
|
690
|
+
chain_role_cache[route.file] = _chain_roles(route.file, content)
|
|
691
|
+
handler = _own_span(
|
|
692
|
+
route.file,
|
|
693
|
+
span_text(route.line, lines_cache[route.file], route_lines.get(route.file, []), max_span=_AUTHZ_SPAN),
|
|
694
|
+
)
|
|
695
|
+
where = f"{route.file}:{route.line}" if route.line else route.file
|
|
696
|
+
if route.file not in config_cache:
|
|
697
|
+
config_cache[route.file] = is_config_route_file(route.file, content)
|
|
698
|
+
if config_cache[route.file] and not _INLINE_HANDLER_RE.search(handler):
|
|
699
|
+
# Declared in config (#343): the span here is configuration, not
|
|
700
|
+
# the handler. Follow a Symfony YAML/XML `controller` reference
|
|
701
|
+
# to the controller method; when there is none to follow
|
|
702
|
+
# (Laminas / Omeka PHP arrays), finding no role check in the
|
|
703
|
+
# config says nothing, so the route isn't flagged.
|
|
704
|
+
resolved = controllers.resolve(route.file, handler)
|
|
705
|
+
if resolved is None:
|
|
706
|
+
continue
|
|
707
|
+
ctrl_file, ctrl_content, ctrl_text, target = resolved
|
|
708
|
+
if has_role_check(ctrl_text) or has_role_check(hop.delegated_text(ctrl_file, ctrl_content, ctrl_text)):
|
|
709
|
+
continue
|
|
710
|
+
out.append(
|
|
711
|
+
(route, f"{route.method} {route.path} in {where} (handler {target}) — {authn}, but no role or permission check")
|
|
712
|
+
)
|
|
713
|
+
continue
|
|
714
|
+
if (
|
|
715
|
+
_chain_role_check(route, content, chain_role_cache[route.file])
|
|
716
|
+
or has_role_check(handler, python=route.file.endswith(".py"))
|
|
717
|
+
or has_role_check(hop.delegated_text(route.file, content, handler), python=route.file.endswith(".py"))
|
|
718
|
+
):
|
|
719
|
+
continue
|
|
720
|
+
out.append(
|
|
721
|
+
(route, f"{route.method} {route.path} in {where} — {authn}, but no role or permission check")
|
|
722
|
+
)
|
|
723
|
+
return out
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@dataclass
|
|
727
|
+
class _ChainRoles:
|
|
728
|
+
"""A file's router- and class-level role checks, scoped to the routes they cover."""
|
|
729
|
+
|
|
730
|
+
lines: list[str]
|
|
731
|
+
code_lines: list[str] = field(default_factory=list) # ``lines`` masked
|
|
732
|
+
uses: list[tuple[str, int]] = field(default_factory=list) # JS `receiver.use(<role check>)`: (receiver, line)
|
|
733
|
+
classes: list[tuple[int, int]] = field(default_factory=list) # role-decorated classes: [first, last] lines
|
|
734
|
+
routers: set[str] = field(default_factory=set) # FastAPI `name = APIRouter(dependencies=[<role check>])`
|
|
735
|
+
mounts: list[tuple[str, str, int]] = field(default_factory=list) # JS `parent.use([path,] child)`: (child, parent, line)
|
|
736
|
+
covered: set[str] = field(default_factory=set) # JS routers mounted behind a role check
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _chain_roles(file: str, content: str) -> bool | _ChainRoles:
|
|
740
|
+
"""The file's router-level role checks: JS ``router.use(<role check>)``
|
|
741
|
+
calls, NestJS classes decorated with a privileged ``@Roles(...)``, and
|
|
742
|
+
FastAPI routers created with a role dependency; for Java, whether a
|
|
743
|
+
class-level annotation checks a role."""
|
|
744
|
+
suffix = Path(file).suffix.lower()
|
|
745
|
+
if suffix in JS_TS_SUFFIXES:
|
|
746
|
+
code = _mask_code(content)
|
|
747
|
+
info = _ChainRoles(content.split("\n"))
|
|
748
|
+
for count, m in enumerate(_JS_USE_RE.finditer(code)):
|
|
749
|
+
if count >= _MAX_CALLS:
|
|
750
|
+
break
|
|
751
|
+
close = _call_close(code, m.end() - 1)
|
|
752
|
+
if close is None:
|
|
753
|
+
continue
|
|
754
|
+
args = content[m.end() : close]
|
|
755
|
+
line = code.count("\n", 0, m.start()) + 1
|
|
756
|
+
# Only the call's own last argument: `restrictTo('admin', role)` mounts nothing.
|
|
757
|
+
mounted = _MOUNTED_ROUTER_RE.search(code, max(m.end() - 1, close - 90), close + 1)
|
|
758
|
+
if mounted is not None and mounted.end() == close + 1 and not has_role_check(mounted.group(1)):
|
|
759
|
+
info.mounts.append((mounted.group(1), m.group(1), line))
|
|
760
|
+
if has_role_check(args):
|
|
761
|
+
# `app.use('/admin', requireAdmin, adminRouter)`: the check guards the mount.
|
|
762
|
+
info.covered.add(mounted.group(1))
|
|
763
|
+
if _path_scoped(code[m.end() : close], args):
|
|
764
|
+
continue # `router.use('/billing', …)`: scoped to a prefix
|
|
765
|
+
if has_role_check(args):
|
|
766
|
+
info.uses.append((m.group(1), line))
|
|
767
|
+
# A router mounted on a parent after the parent's role `use`:
|
|
768
|
+
# `app.use(restrictTo('admin')); app.use('/admin', adminRouter)`.
|
|
769
|
+
first_use: dict[str, int] = {}
|
|
770
|
+
for receiver, line in info.uses:
|
|
771
|
+
first_use[receiver] = min(line, first_use.get(receiver, line))
|
|
772
|
+
for child, parent, mounted_at in info.mounts:
|
|
773
|
+
if first_use.get(parent, mounted_at) < mounted_at:
|
|
774
|
+
info.covered.add(child)
|
|
775
|
+
code_lines = info.code_lines = code.split("\n")
|
|
776
|
+
braces: dict[int, int] | None = None
|
|
777
|
+
for count, i in enumerate(i for i, line in enumerate(code_lines) if _CLASS_DECL_RE.search(line)):
|
|
778
|
+
if count >= _MAX_CALLS:
|
|
779
|
+
break
|
|
780
|
+
decorators = _decorators_above(info.lines, code_lines, i)
|
|
781
|
+
if not has_role_check(decorators):
|
|
782
|
+
continue
|
|
783
|
+
if braces is None:
|
|
784
|
+
braces = _brace_pairs(code)
|
|
785
|
+
last = _block_end_line(code, code_lines, i, braces)
|
|
786
|
+
if last is not None:
|
|
787
|
+
info.classes.append((i + 1, last))
|
|
788
|
+
return info
|
|
789
|
+
if suffix == ".py":
|
|
790
|
+
code = _mask_code(content, python=True)
|
|
791
|
+
info = _ChainRoles(content.split("\n"))
|
|
792
|
+
for count, m in enumerate(_PY_ROUTER_DEPS_RE.finditer(code)):
|
|
793
|
+
if count >= _MAX_CALLS:
|
|
794
|
+
break
|
|
795
|
+
close = _call_close(code, m.end() - 1, _MAX_ROUTER_ARGS)
|
|
796
|
+
args = content[m.end() : close] if close is not None else ""
|
|
797
|
+
if close is None or "dependencies" not in code[m.end() : close]:
|
|
798
|
+
continue
|
|
799
|
+
# `include_router(r, dependencies=[…])` covers the included router's
|
|
800
|
+
# routes, which live in another file: it says nothing about this one's.
|
|
801
|
+
if "dependencies" in args and has_role_check(args):
|
|
802
|
+
info.routers.add(m.group("router"))
|
|
803
|
+
return info
|
|
804
|
+
if suffix in _JAVA_SUFFIXES:
|
|
805
|
+
lines = content.splitlines()
|
|
806
|
+
for i, line in enumerate(lines):
|
|
807
|
+
if _CLASS_DECL_RE.search(line):
|
|
808
|
+
return bool(ROLE_MARKERS.search("\n".join(lines[max(0, i - 6) : i])))
|
|
809
|
+
return False
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _decorators_above(lines: list[str], code_lines: list[str], i: int) -> str:
|
|
813
|
+
"""The class line and the decorators directly above it."""
|
|
814
|
+
return "\n".join([*_decorator_block(lines, code_lines, i, stop=-1), lines[i]])
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _decorator_block(lines: list[str], code_lines: list[str], i: int, stop: int) -> list[str]:
|
|
818
|
+
"""The decorator lines directly above line ``i`` (0-based), not past
|
|
819
|
+
``stop``. A line belongs to the block if it starts with `@`, or if it
|
|
820
|
+
closes more parentheses than it opens (the tail of a multi-line
|
|
821
|
+
decorator, `})`), or while a decorator's arguments are still open below
|
|
822
|
+
it. Blank and comment-only lines are skipped. Anything else (`remove() {}`,
|
|
823
|
+
`}`, `const x = restrictTo('admin')`) ends the block."""
|
|
824
|
+
out: list[str] = []
|
|
825
|
+
tentative: list[str] = [] # lines inside a multi-line decorator, kept once its `@` is reached
|
|
826
|
+
pending = 0 # `)` seen below that no `(` has matched yet
|
|
827
|
+
for j in range(i - 1, max(stop, i - 1 - _MAX_DECORATOR_LINES), -1):
|
|
828
|
+
code = code_lines[j].strip()
|
|
829
|
+
if not code:
|
|
830
|
+
continue # blank, or a comment (`// @Roles('admin') -- removed`)
|
|
831
|
+
net = code.count(")") - code.count("(")
|
|
832
|
+
if not (code.startswith("@") or pending > 0 or net > 0):
|
|
833
|
+
break
|
|
834
|
+
pending = max(0, pending + net)
|
|
835
|
+
tentative.append(lines[j])
|
|
836
|
+
if code.startswith("@") and pending == 0:
|
|
837
|
+
out.extend(tentative)
|
|
838
|
+
tentative = []
|
|
839
|
+
# A `)`-heavy tail that never reached its `@` is the end of a statement
|
|
840
|
+
# (`const x = configure(\n restrictTo('admin'))`), not a decorator.
|
|
841
|
+
return list(reversed(out))
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _class_body_open(code: str, offset: int) -> int:
|
|
845
|
+
"""The `{` opening a class body declared at ``offset``: the first one
|
|
846
|
+
outside type arguments (`class A extends B<{ x: string }> {`)."""
|
|
847
|
+
angle = 0
|
|
848
|
+
for k in range(offset, min(len(code), offset + _MAX_CLASS_HEAD)):
|
|
849
|
+
ch = code[k]
|
|
850
|
+
if ch == "<":
|
|
851
|
+
angle += 1
|
|
852
|
+
elif ch == ">" and angle:
|
|
853
|
+
angle -= 1
|
|
854
|
+
elif ch == "{" and angle == 0:
|
|
855
|
+
return k
|
|
856
|
+
return -1
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _brace_pairs(code: str) -> dict[int, int]:
|
|
860
|
+
"""`{` offset → its `}` offset, in one pass (unbalanced ones omitted)."""
|
|
861
|
+
pairs: dict[int, int] = {}
|
|
862
|
+
stack: list[int] = []
|
|
863
|
+
for k, ch in enumerate(code):
|
|
864
|
+
if ch == "{":
|
|
865
|
+
stack.append(k)
|
|
866
|
+
elif ch == "}" and stack:
|
|
867
|
+
pairs[stack.pop()] = k
|
|
868
|
+
return pairs
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _block_end_line(code: str, code_lines: list[str], i: int, braces: dict[int, int]) -> int | None:
|
|
872
|
+
"""1-based line of the `}` closing the class declared on line ``i``."""
|
|
873
|
+
offset = sum(len(line) + 1 for line in code_lines[:i])
|
|
874
|
+
open_at = _class_body_open(code, offset)
|
|
875
|
+
close = braces.get(open_at) if open_at >= 0 else None
|
|
876
|
+
return code.count("\n", 0, close) + 1 if close is not None else None
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def _path_scoped(masked_args: str, original_args: str) -> bool:
|
|
880
|
+
"""`use(path, …)`: the first of several arguments is a path rather than
|
|
881
|
+
middleware. Only a name (or call) that reads as middleware (`requireAuth`,
|
|
882
|
+
`protect`, `cors()`) and `'/'` are unscoped; anything else (strings,
|
|
883
|
+
arrays, `API_PREFIX + '/x'`, `path.join(…)`, `PATHS.ADMIN`, `apiV1`) is
|
|
884
|
+
taken as a path. When in doubt the `use` doesn't count, and the finding stays."""
|
|
885
|
+
if len(_split_top_level(masked_args)) < 2:
|
|
886
|
+
return False # `router.use(restrictTo('admin'))`: no path
|
|
887
|
+
first = _split_top_level(masked_args)[0].strip()
|
|
888
|
+
if _ROOT_PATH_RE.match(original_args):
|
|
889
|
+
return False # `use('/', …)` covers every route
|
|
890
|
+
callee = _MIDDLEWARE_ARG_RE.fullmatch(first)
|
|
891
|
+
if callee is None:
|
|
892
|
+
return True
|
|
893
|
+
name = callee.group(1)
|
|
894
|
+
last = name.rsplit(".", 1)[-1]
|
|
895
|
+
if _CONSTANT_RE.fullmatch(last) or _PATH_NAME_RE.search(last):
|
|
896
|
+
return True # `ADMIN_ROUTE`, `PATHS.BILLING_ADMIN`, `adminPath`
|
|
897
|
+
return not _AUTH_MIDDLEWARE_NAME_RE.search(name)
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _split_top_level(text: str) -> list[str]:
|
|
901
|
+
"""``text`` split on commas outside brackets."""
|
|
902
|
+
parts: list[str] = []
|
|
903
|
+
depth = 0
|
|
904
|
+
start = 0
|
|
905
|
+
for k, ch in enumerate(text):
|
|
906
|
+
if ch in "([{":
|
|
907
|
+
depth += 1
|
|
908
|
+
elif ch in ")]}":
|
|
909
|
+
depth -= 1
|
|
910
|
+
elif ch == "," and depth == 0:
|
|
911
|
+
parts.append(text[start:k])
|
|
912
|
+
start = k + 1
|
|
913
|
+
parts.append(text[start:])
|
|
914
|
+
return [part for part in parts if part.strip()]
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _own_span(file: str, handler: str) -> str:
|
|
918
|
+
"""A JS/TS handler span without the next member's decorators: in a NestJS
|
|
919
|
+
class the span runs to the next route line, past the next method's
|
|
920
|
+
`@Permissions('admin')`, which isn't this route's check."""
|
|
921
|
+
if Path(file).suffix.lower() not in JS_TS_SUFFIXES:
|
|
922
|
+
return handler
|
|
923
|
+
lines = handler.split("\n")
|
|
924
|
+
seen_code = False
|
|
925
|
+
for k, line in enumerate(lines):
|
|
926
|
+
stripped = line.strip()
|
|
927
|
+
if stripped.startswith("@"):
|
|
928
|
+
if seen_code:
|
|
929
|
+
return "\n".join(lines[:k])
|
|
930
|
+
elif stripped and not stripped.startswith(("//", "*", "/*")) and not seen_code:
|
|
931
|
+
# The first non-decorator line (the signature) starts the body;
|
|
932
|
+
# lines inside a multi-line decorator start with `)`/`}` or args.
|
|
933
|
+
seen_code = _METHOD_SIGNATURE_RE.match(stripped) is not None or stripped.endswith("{")
|
|
934
|
+
return handler
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _route_receiver(lines: list[str], line: int) -> str | None:
|
|
938
|
+
"""The router a registration on ``line`` is called on, following a
|
|
939
|
+
chain written across lines (`router\\n .route('/x')\\n .delete(h)`)."""
|
|
940
|
+
i = line - 1
|
|
941
|
+
for _ in range(_MAX_CHAIN_LINES):
|
|
942
|
+
if not 0 <= i < len(lines):
|
|
943
|
+
return None
|
|
944
|
+
text = lines[i]
|
|
945
|
+
stripped = text.lstrip()
|
|
946
|
+
# The registration heads the line; `db.delete(` inside a handler doesn't count.
|
|
947
|
+
registration = _JS_ROUTE_RECEIVER_RE.match(text, len(text) - len(stripped))
|
|
948
|
+
if registration is not None:
|
|
949
|
+
return registration.group(1)
|
|
950
|
+
if not stripped.startswith("."):
|
|
951
|
+
head = _CHAIN_HEAD_RE.fullmatch(text)
|
|
952
|
+
return head.group(1) if head is not None and i < line - 1 else None
|
|
953
|
+
i -= 1
|
|
954
|
+
return None
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _chain_role_check(route: Route, content: str, info: bool | _ChainRoles) -> bool:
|
|
958
|
+
"""A role check on the route's router/controller (see :func:`_chain_roles`).
|
|
959
|
+
A JS ``use`` counts only on the route's own router and before the route:
|
|
960
|
+
Express doesn't apply middleware to routes registered earlier. A class's
|
|
961
|
+
roles don't apply to a method with its own role metadata (NestJS takes the
|
|
962
|
+
handler's over the class's)."""
|
|
963
|
+
if not isinstance(info, _ChainRoles):
|
|
964
|
+
return info
|
|
965
|
+
if route.line is None or not 1 <= route.line <= len(info.lines):
|
|
966
|
+
return False
|
|
967
|
+
for first, last in info.classes:
|
|
968
|
+
if first < route.line <= last:
|
|
969
|
+
if not _METHOD_ROLE_META_RE.search(_method_decorators(info.lines, info.code_lines, route.line, first)):
|
|
970
|
+
return True
|
|
971
|
+
if info.routers:
|
|
972
|
+
decorator = _py_route_receiver(info.lines, route.line)
|
|
973
|
+
if decorator is not None and decorator in info.routers:
|
|
974
|
+
return True
|
|
975
|
+
if not info.uses and not info.covered:
|
|
976
|
+
return False
|
|
977
|
+
receiver = _route_receiver(info.lines, route.line)
|
|
978
|
+
if receiver is None:
|
|
979
|
+
return False
|
|
980
|
+
return receiver in info.covered or any(r == receiver and line < route.line for r, line in info.uses)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _method_decorators(lines: list[str], code_lines: list[str], line: int, class_line: int) -> str:
|
|
984
|
+
"""The decorator block around the route's decorator on ``line``: up to
|
|
985
|
+
the previous member, down to the method signature (inside the class)."""
|
|
986
|
+
out = _decorator_block(lines, code_lines, line - 1, stop=class_line - 1)
|
|
987
|
+
depth = 0 # open parentheses of a multi-line decorator (`@UseInterceptors(\n FileInterceptor('f'),\n)`)
|
|
988
|
+
for j in range(line - 1, min(len(lines), line - 1 + _MAX_DECORATOR_LINES)):
|
|
989
|
+
out.append(lines[j])
|
|
990
|
+
code = code_lines[j].strip() if j < len(code_lines) else lines[j].strip()
|
|
991
|
+
if depth == 0 and not code.startswith("@") and _METHOD_SIGNATURE_RE.match(code):
|
|
992
|
+
break
|
|
993
|
+
depth = max(0, depth + code.count("(") - code.count(")"))
|
|
994
|
+
return "\n".join(out)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _py_route_receiver(lines: list[str], line: int) -> str | None:
|
|
998
|
+
"""The router of a FastAPI `@router.delete(...)` decorator on or just above ``line``."""
|
|
999
|
+
for i in range(line - 1, max(-1, line - 1 - _MAX_DECORATOR_LINES), -1):
|
|
1000
|
+
m = _PY_ROUTE_DECORATOR_RE.match(lines[i])
|
|
1001
|
+
if m is not None:
|
|
1002
|
+
return m.group(1)
|
|
1003
|
+
stripped = lines[i].strip()
|
|
1004
|
+
if stripped.startswith("@") or (stripped and i < line - 1 and not stripped.startswith(("def ", "async def "))):
|
|
1005
|
+
return None # another decorator (`@pub.websocket`) or code: not ours to guess
|
|
1006
|
+
return None
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
# GraphQL resolver files keep the original whole-file marker set (#139):
|
|
1010
|
+
# fields aren't routes, so there is no handler span to scope to yet.
|
|
1011
|
+
_GRAPHQL_RESOLVER_AUTH = re.compile(
|
|
1012
|
+
r"""
|
|
1013
|
+
current_user | currentUser | \brequest\.user\b | \breq\.user\b | \bctx\.user\b
|
|
1014
|
+
| \bowner\b | owner_id | ownerId | user_id\s*(?:==|===|=|:) | userId
|
|
1015
|
+
| account_id | tenant_id | org_id | \bself\.user\b
|
|
1016
|
+
| authorize | authoriz | \.can\s*\( | cannot | \bpolicy\b | \bpolicies\b
|
|
1017
|
+
| \bguard\b | can_access | canAccess | has_access | hasAccess
|
|
1018
|
+
| check_access | verify_owner | assert_owner | ensure_owner
|
|
1019
|
+
| require[s]?_permission | permission_required | requiresAuth | requireAuth
|
|
1020
|
+
| login_required | @authenticated | ensureAuthenticated | is_authorized
|
|
1021
|
+
| filter_by\s*\(\s*user | \.where\([^)]*user | WHERE\s+\w*user
|
|
1022
|
+
| scope_to | scoped_to | for_user | belongs_to
|
|
1023
|
+
""",
|
|
1024
|
+
re.IGNORECASE | re.VERBOSE,
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
# A handler span long enough to hold a whole handler body; the anomaly
|
|
1028
|
+
# detector's 40-line default would miss a check deep in a long handler and
|
|
1029
|
+
# turn it into a false positive.
|
|
1030
|
+
_AUTHZ_SPAN = 150
|
|
1031
|
+
# One-hop delegation budget per route (#242).
|
|
1032
|
+
_MAX_HOPS = 6
|
|
1033
|
+
_MAX_HOP_LINES = 80
|
|
1034
|
+
_MAX_IMPORTS = 40
|
|
1035
|
+
|
|
1036
|
+
_WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
1037
|
+
|
|
1038
|
+
_MAX_FILE_BYTES = 1_000_000 # skip absurdly large files
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def analyze_authz(scan: ScanResult, root: str | Path | None = None) -> list[BolaCandidate]:
|
|
1042
|
+
"""Return BOLA/IDOR candidates for routes in ``scan``.
|
|
1043
|
+
|
|
1044
|
+
Only genuine candidates are returned: routes with an ID param that
|
|
1045
|
+
reach a datastore AND lack an object-level authorization check in their
|
|
1046
|
+
handler. Routes that HAVE one, or don't reach a datastore, are dropped.
|
|
1047
|
+
Candidates on authenticated routes are ``high`` confidence (#242).
|
|
1048
|
+
"""
|
|
1049
|
+
root_path = Path(root or scan.root).resolve()
|
|
1050
|
+
|
|
1051
|
+
# DB reachability sources.
|
|
1052
|
+
db_files = {_norm(db.file) for db in scan.databases}
|
|
1053
|
+
db_modules = {_module_key(db.file) for db in scan.databases}
|
|
1054
|
+
taint_sql_routes = {
|
|
1055
|
+
(c.route_method, c.route_path)
|
|
1056
|
+
for c in scan.taint_chains
|
|
1057
|
+
# Speculative recall chains (#148a) are unconfirmed leads — they must
|
|
1058
|
+
# not manufacture an asserted HIGH BOLA finding that could trip the
|
|
1059
|
+
# baseline gate.
|
|
1060
|
+
if c.sink_kind == "sql_execute" and not c.speculative
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
# Cache per-file content (read at most once per route file), and the
|
|
1064
|
+
# file-level query-param scan: it depends only on the file, and redoing it
|
|
1065
|
+
# per route made a many-route file quadratic (#218).
|
|
1066
|
+
content_cache: dict[str, str | None] = {}
|
|
1067
|
+
query_param_cache: dict[str, str | None] = {}
|
|
1068
|
+
lines_cache: dict[str, list[str]] = {}
|
|
1069
|
+
chain_role_cache: dict[str, bool | _ChainRoles] = {}
|
|
1070
|
+
hop = _HopResolver(root_path, content_cache)
|
|
1071
|
+
|
|
1072
|
+
# Handler spans end at the next route declared in the same file.
|
|
1073
|
+
route_lines: dict[str, list[int]] = {}
|
|
1074
|
+
for r in scan.routes:
|
|
1075
|
+
if r.line is not None:
|
|
1076
|
+
route_lines.setdefault(r.file, []).append(r.line)
|
|
1077
|
+
java_global_auth: bool | None = None # resolved lazily: it walks the repo
|
|
1078
|
+
|
|
1079
|
+
seen: set[tuple[str, str, str, str]] = set()
|
|
1080
|
+
candidates: list[BolaCandidate] = []
|
|
1081
|
+
|
|
1082
|
+
for route in scan.routes:
|
|
1083
|
+
content = _read_file_content(route.file, root_path, content_cache)
|
|
1084
|
+
|
|
1085
|
+
# Path-encoded surfaces (RPC method, path template param) first; then
|
|
1086
|
+
# fall back to an id-shaped query-parameter access in the handler body.
|
|
1087
|
+
surface = _path_based_surface(route.path)
|
|
1088
|
+
if surface is None and content:
|
|
1089
|
+
if route.file not in query_param_cache:
|
|
1090
|
+
query_param_cache[route.file] = _query_param_in_handler(content)
|
|
1091
|
+
qp = query_param_cache[route.file]
|
|
1092
|
+
if qp is not None:
|
|
1093
|
+
surface = ("query_param", qp)
|
|
1094
|
+
if surface is None:
|
|
1095
|
+
continue
|
|
1096
|
+
surface_kind, id_param = surface
|
|
1097
|
+
|
|
1098
|
+
# RPC methods are object-access operations by definition — they read/
|
|
1099
|
+
# write a specific object by id — so the "reaches a datastore" condition
|
|
1100
|
+
# is satisfied intrinsically. HTTP path/query surfaces still require an
|
|
1101
|
+
# observed datastore link (#139).
|
|
1102
|
+
if surface_kind == "rpc_method":
|
|
1103
|
+
reaches_db, db_evidence = True, "RPC object-access method"
|
|
1104
|
+
else:
|
|
1105
|
+
reaches_db, db_evidence = _db_reachability(
|
|
1106
|
+
route, db_files, db_modules, taint_sql_routes
|
|
1107
|
+
)
|
|
1108
|
+
if not reaches_db:
|
|
1109
|
+
continue
|
|
1110
|
+
|
|
1111
|
+
key = (route.method, route.path, route.file, id_param)
|
|
1112
|
+
if key in seen:
|
|
1113
|
+
continue
|
|
1114
|
+
seen.add(key)
|
|
1115
|
+
|
|
1116
|
+
handler = ""
|
|
1117
|
+
if content:
|
|
1118
|
+
if route.file not in lines_cache:
|
|
1119
|
+
lines_cache[route.file] = content.splitlines()
|
|
1120
|
+
handler = _own_span(
|
|
1121
|
+
route.file,
|
|
1122
|
+
span_text(route.line, lines_cache[route.file], route_lines.get(route.file, []), max_span=_AUTHZ_SPAN),
|
|
1123
|
+
)
|
|
1124
|
+
delegated = hop.delegated_text(route.file, content, handler) if handler else ""
|
|
1125
|
+
scope = handler + "\n" + delegated
|
|
1126
|
+
if OBJECT_AUTHZ_MARKERS.search(scope):
|
|
1127
|
+
# Object-level authorization is (probably) enforced — not a candidate.
|
|
1128
|
+
continue
|
|
1129
|
+
if content and route.file not in chain_role_cache:
|
|
1130
|
+
chain_role_cache[route.file] = _chain_roles(route.file, content)
|
|
1131
|
+
if has_role_check(scope, python=route.file.endswith(".py")) or (
|
|
1132
|
+
content and _chain_role_check(route, content, chain_role_cache[route.file])
|
|
1133
|
+
):
|
|
1134
|
+
# A role-gated route (admin tooling) reaches any object by design.
|
|
1135
|
+
continue
|
|
1136
|
+
|
|
1137
|
+
if (
|
|
1138
|
+
java_global_auth is None
|
|
1139
|
+
and route.auth != "required"
|
|
1140
|
+
and Path(route.file).suffix.lower() in _JAVA_SUFFIXES
|
|
1141
|
+
):
|
|
1142
|
+
java_global_auth = repo_has_java_global_auth(scan, root_path, content_cache)
|
|
1143
|
+
authn = _authn_evidence(route, content, handler, bool(java_global_auth))
|
|
1144
|
+
|
|
1145
|
+
candidates.append(
|
|
1146
|
+
BolaCandidate(
|
|
1147
|
+
route_path=route.path,
|
|
1148
|
+
route_method=route.method,
|
|
1149
|
+
route_file=route.file,
|
|
1150
|
+
route_line=route.line,
|
|
1151
|
+
id_param=id_param,
|
|
1152
|
+
surface=surface_kind, # type: ignore[arg-type]
|
|
1153
|
+
reaches_db=True,
|
|
1154
|
+
db_evidence=db_evidence,
|
|
1155
|
+
has_ownership_check=False,
|
|
1156
|
+
authn_evidence=authn,
|
|
1157
|
+
confidence="high" if authn else "medium",
|
|
1158
|
+
source_analyzer="authz",
|
|
1159
|
+
)
|
|
1160
|
+
)
|
|
1161
|
+
|
|
1162
|
+
# GraphQL fields aren't modeled as routes — scan the repo's SDL directly.
|
|
1163
|
+
candidates.extend(_graphql_bola_candidates(root_path, seen))
|
|
1164
|
+
|
|
1165
|
+
candidates.sort(
|
|
1166
|
+
key=lambda c: (c.route_method not in _WRITE_METHODS, c.route_file, c.route_path)
|
|
1167
|
+
)
|
|
1168
|
+
return candidates
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
# --- Internals -------------------------------------------------------------
|
|
1172
|
+
|
|
1173
|
+
_JAVA_SUFFIXES = {".java", ".kt"}
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
def _authn_evidence(route: Route, content: str | None, handler: str, java_global_auth: bool) -> str:
|
|
1177
|
+
"""How the route is known to be authenticated, or "" when it isn't."""
|
|
1178
|
+
if route.auth == "required":
|
|
1179
|
+
return "authentication declared" + (f" by {route.source_analyzer}" if route.source_analyzer else "")
|
|
1180
|
+
if route.auth == "anonymous":
|
|
1181
|
+
return ""
|
|
1182
|
+
if content:
|
|
1183
|
+
control = resolve_control(route, content, java_global_auth)
|
|
1184
|
+
if control:
|
|
1185
|
+
return control
|
|
1186
|
+
match = AUTHN_MARKERS.search(handler)
|
|
1187
|
+
if match:
|
|
1188
|
+
return f"authenticated handler (`{match.group(0).strip()}`)"
|
|
1189
|
+
return ""
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _resource_id_param(path: str) -> str | None:
|
|
1193
|
+
"""Return the first resource-id-shaped path parameter, or None."""
|
|
1194
|
+
# Ignore any query string when scanning path-template params.
|
|
1195
|
+
template = path.split("?", 1)[0]
|
|
1196
|
+
for match in _PATH_PARAM_RE.finditer(template):
|
|
1197
|
+
raw = next((g for g in match.groups() if g), None)
|
|
1198
|
+
if not raw:
|
|
1199
|
+
continue
|
|
1200
|
+
# Flask converter form `int:id` → take the name after the colon.
|
|
1201
|
+
name = raw.split(":")[-1].strip()
|
|
1202
|
+
if _ID_PARAM_RE.search(name):
|
|
1203
|
+
return name
|
|
1204
|
+
return None
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _path_based_surface(path: str) -> tuple[str, str] | None:
|
|
1208
|
+
"""Object-identifier surfaces encoded in the route path (#139): an RPC
|
|
1209
|
+
method (XRPC/tRPC) or a path-template parameter. Query parameters are
|
|
1210
|
+
resolved from the handler body separately (they aren't in the path)."""
|
|
1211
|
+
rpc = _rpc_method_surface(path)
|
|
1212
|
+
if rpc is not None:
|
|
1213
|
+
return ("rpc_method", rpc)
|
|
1214
|
+
path_param = _resource_id_param(path)
|
|
1215
|
+
if path_param is not None:
|
|
1216
|
+
return ("path_param", path_param)
|
|
1217
|
+
return None
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def _rpc_method_surface(path: str) -> str | None:
|
|
1221
|
+
"""An XRPC NSID (`/xrpc/<nsid>`) or tRPC procedure (`trpc:<name>`) whose
|
|
1222
|
+
operation is an id-bearing object reference. Returns the method name."""
|
|
1223
|
+
if path.startswith("/xrpc/"):
|
|
1224
|
+
method = path[len("/xrpc/"):].split("?", 1)[0]
|
|
1225
|
+
elif path.startswith("trpc:"):
|
|
1226
|
+
method = path[len("trpc:"):].split("?", 1)[0]
|
|
1227
|
+
else:
|
|
1228
|
+
return None
|
|
1229
|
+
op = method.rsplit(".", 1)[-1] # terminal operation segment
|
|
1230
|
+
if _RPC_BY_ID_RE.search(op):
|
|
1231
|
+
return method
|
|
1232
|
+
if _RPC_OBJECT_VERB_RE.match(op) and _RPC_OBJECT_NOUN_RE.search(op):
|
|
1233
|
+
return method
|
|
1234
|
+
return None
|
|
1235
|
+
|
|
1236
|
+
|
|
1237
|
+
def _query_param_in_handler(content: str) -> str | None:
|
|
1238
|
+
"""An id-shaped query parameter read in the handler file, if any — e.g.
|
|
1239
|
+
`req.query.userId`, `request.args.get('doc_id')`, `id: str = Query(...)`."""
|
|
1240
|
+
for regex in _QUERY_ACCESS_RES:
|
|
1241
|
+
for match in regex.finditer(content):
|
|
1242
|
+
name = match.group(1)
|
|
1243
|
+
if name and _ID_PARAM_RE.search(name):
|
|
1244
|
+
return name
|
|
1245
|
+
return None
|
|
1246
|
+
|
|
1247
|
+
|
|
1248
|
+
def _read_file_content(rel_file: str, root: Path, cache: dict[str, str | None]) -> str | None:
|
|
1249
|
+
if rel_file in cache:
|
|
1250
|
+
return cache[rel_file]
|
|
1251
|
+
content: str | None = None
|
|
1252
|
+
try:
|
|
1253
|
+
path = contained_file(root, rel_file)
|
|
1254
|
+
if path is not None and path.stat().st_size <= _MAX_FILE_BYTES:
|
|
1255
|
+
content = path.read_text(encoding="utf-8", errors="ignore")
|
|
1256
|
+
except (OSError, ValueError):
|
|
1257
|
+
content = None
|
|
1258
|
+
cache[rel_file] = content
|
|
1259
|
+
return content
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _db_reachability(
|
|
1263
|
+
route: Route,
|
|
1264
|
+
db_files: set[str],
|
|
1265
|
+
db_modules: set[str],
|
|
1266
|
+
taint_sql_routes: set[tuple[str, str]],
|
|
1267
|
+
) -> tuple[bool, str]:
|
|
1268
|
+
route_file = _norm(route.file)
|
|
1269
|
+
if route_file in db_files:
|
|
1270
|
+
return True, "datastore hint in the route's file"
|
|
1271
|
+
if _module_key(route.file) in db_modules:
|
|
1272
|
+
return True, "datastore hint in the route's module"
|
|
1273
|
+
if (route.method, route.path) in taint_sql_routes:
|
|
1274
|
+
return True, "taint chain from this route reaches a SQL execute sink"
|
|
1275
|
+
return False, ""
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
# --- One-hop delegation (#242) ---------------------------------------------
|
|
1279
|
+
|
|
1280
|
+
_IDENT_RE = re.compile(r"\b([A-Za-z_$][\w$]*)\b")
|
|
1281
|
+
# Names never worth resolving as a delegated function.
|
|
1282
|
+
_HOP_SKIP = frozenset(
|
|
1283
|
+
{
|
|
1284
|
+
"if", "for", "while", "return", "await", "async", "def", "function", "const", "let",
|
|
1285
|
+
"var", "new", "class", "self", "this", "req", "res", "request", "response", "ctx",
|
|
1286
|
+
"next", "print", "len", "str", "int", "dict", "list", "json", "app", "router",
|
|
1287
|
+
"get", "post", "put", "patch", "delete", "route", "db", "jsonify", "abort",
|
|
1288
|
+
}
|
|
1289
|
+
)
|
|
1290
|
+
_PY_FROM_IMPORT_RE = re.compile(
|
|
1291
|
+
r"^[ \t]*from\s+(?P<mod>\.*[\w.]*)\s+import\s+(?P<names>\([^)]*\)|[^\n#]+)", re.MULTILINE
|
|
1292
|
+
)
|
|
1293
|
+
_PY_IMPORT_RE = re.compile(r"^[ \t]*import\s+(?P<mod>[\w.]+)(?:\s+as\s+(?P<alias>\w+))?", re.MULTILINE)
|
|
1294
|
+
_JS_IMPORT_FROM_RE = re.compile(r"""import\s+(?P<clause>[^;'"]+?)\s+from\s+["'](?P<spec>\.[^"']*)["']""")
|
|
1295
|
+
_JS_REQUIRE_RE = re.compile(
|
|
1296
|
+
r"""(?:const|let|var)\s+(?P<clause>\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\(\s*["'](?P<spec>\.[^"']*)["']\s*\)"""
|
|
1297
|
+
)
|
|
1298
|
+
_JS_PROBE_SUFFIXES = (".js", ".ts", ".mjs", ".cjs", ".jsx", ".tsx")
|
|
1299
|
+
_TS_FOR_JS = {".js": (".ts", ".tsx"), ".jsx": (".tsx",), ".mjs": (".mts",), ".cjs": (".cts",)}
|
|
1300
|
+
_CALL_OR_REF_RE = re.compile(r"(?<!\s)\s*[(,)]") # anchored: linear under search() (#236)
|
|
1301
|
+
_ARROW_EXPR_RE = re.compile(r"=>\s*[^\s{]")
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
# A function, method or class definition; whichever group matched names it.
|
|
1305
|
+
_DEF_RE = re.compile(
|
|
1306
|
+
r"^[ \t]*(?:async\s+)?def\s+(?P<a>\w+)\s*\("
|
|
1307
|
+
r"|^[ \t]*class\s+(?P<b>[A-Za-z_$][\w$]*)"
|
|
1308
|
+
r"|^[ \t]*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(?P<c>[A-Za-z_$][\w$]*)\s*\("
|
|
1309
|
+
r"|^[ \t]*(?:export\s+)?(?:const|let|var)\s+(?P<d>[A-Za-z_$][\w$]*)\s*=\s*(?:async\s*)?(?:function\b|\(|[A-Za-z_$][\w$]*\s*=>)"
|
|
1310
|
+
r"|^[ \t]*(?:exports|module\.exports)\.(?P<e>[A-Za-z_$][\w$]*)\s*="
|
|
1311
|
+
r"|^[ \t]*(?:(?:public|private|protected|static|async)\s+)*(?P<f>[A-Za-z_$][\w$]*)\s*\([^)\n]*\)\s*(?::\s*[^{\n]+)?\{"
|
|
1312
|
+
r"|^[ \t]*(?:public|private|protected)\s+[\w<>\[\], ?]+\s+(?P<g>\w+)\s*\(",
|
|
1313
|
+
re.MULTILINE,
|
|
1314
|
+
)
|
|
1315
|
+
_NOT_DEFS = frozenset({"if", "for", "while", "switch", "catch", "function", "return", "with"})
|
|
1316
|
+
|
|
1317
|
+
|
|
1318
|
+
def _definition_index(text: str) -> dict[str, int]:
|
|
1319
|
+
"""Defined name → 0-based line of its first definition in ``text``."""
|
|
1320
|
+
index: dict[str, int] = {}
|
|
1321
|
+
for m in _DEF_RE.finditer(text):
|
|
1322
|
+
name = next(g for g in m.groups() if g)
|
|
1323
|
+
if name not in _NOT_DEFS and name not in index:
|
|
1324
|
+
index[name] = text.count("\n", 0, m.start())
|
|
1325
|
+
return index
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
_INLINE_STRING_RE = re.compile(r"""'(?:\\.|[^'\\\n])*+'|"(?:\\.|[^"\\\n])*+"|`(?:\\.|[^`\\\n])*+`""")
|
|
1329
|
+
|
|
1330
|
+
|
|
1331
|
+
# A body that opens and closes on one line, after a parameter list or arrow:
|
|
1332
|
+
# `f(x): boolean { return x; }`, `function h($r) { return $next($r); }`.
|
|
1333
|
+
_ONE_LINE_BODY_RE = re.compile(r"(?:\)|=>)\s*+(?::\s*+[^{=;]{1,80}?)?\s*+\{[^{}]*+\}")
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
def _block_from(lines: list[str], start: int) -> str:
|
|
1337
|
+
"""The definition starting at ``lines[start]``: an indented Python block,
|
|
1338
|
+
or a brace-balanced block, capped at ``_MAX_HOP_LINES``."""
|
|
1339
|
+
head = lines[start]
|
|
1340
|
+
end = min(len(lines), start + _MAX_HOP_LINES)
|
|
1341
|
+
if head.rstrip().endswith(":") or re.match(r"\s*(?:async\s+)?def\b|\s*class\b", head):
|
|
1342
|
+
indent = len(head) - len(head.lstrip())
|
|
1343
|
+
out = [head]
|
|
1344
|
+
for line in lines[start + 1 : end]:
|
|
1345
|
+
if line.strip() and len(line) - len(line.lstrip()) <= indent and not line.lstrip().startswith(")"):
|
|
1346
|
+
break
|
|
1347
|
+
out.append(line)
|
|
1348
|
+
return "\n".join(out)
|
|
1349
|
+
if "{" not in head and (_ARROW_EXPR_RE.search(head) or head.rstrip().endswith(";")):
|
|
1350
|
+
return head # `const f = (id) => Doc.findById(id);` — a one-line definition
|
|
1351
|
+
depth = 0
|
|
1352
|
+
parens = 0
|
|
1353
|
+
opened = False
|
|
1354
|
+
out = []
|
|
1355
|
+
for i, line in enumerate(lines[start:end]):
|
|
1356
|
+
out.append(line)
|
|
1357
|
+
# Braces inside string and template literals don't open or close the
|
|
1358
|
+
# body (`\`${s.slice(0, -1)},"k":${v}}\`` closed it early) (#366).
|
|
1359
|
+
code = _INLINE_STRING_RE.sub('""', line)
|
|
1360
|
+
depth += code.count("{") - code.count("}")
|
|
1361
|
+
parens += code.count("(") - code.count(")")
|
|
1362
|
+
# A body is open once the braces stay open past a line: braces that
|
|
1363
|
+
# open and close on one line (`({ secret }: { secret: string }) =>`,
|
|
1364
|
+
# a curried middleware factory's parameters) aren't the body (#366).
|
|
1365
|
+
if not opened and depth <= 0 and _ONE_LINE_BODY_RE.search(code):
|
|
1366
|
+
# `canActivate() { return true; }`: the body opened and closed on
|
|
1367
|
+
# this line; don't run on into the next member.
|
|
1368
|
+
break
|
|
1369
|
+
opened = opened or depth > 0
|
|
1370
|
+
if opened and depth <= 0:
|
|
1371
|
+
break
|
|
1372
|
+
if not opened and i >= 3 and parens <= 0:
|
|
1373
|
+
# Never found a body (a parameter list still open doesn't count:
|
|
1374
|
+
# `const f = (\n a,\n b,\n c,\n) => {`); don't swallow what follows.
|
|
1375
|
+
return head
|
|
1376
|
+
return "\n".join(out)
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
class _HopResolver:
|
|
1380
|
+
"""Resolve the functions a handler delegates to — a same-file helper or a
|
|
1381
|
+
function in a module the route file imports — and return their bodies.
|
|
1382
|
+
|
|
1383
|
+
Bounded: one hop, ``_MAX_HOPS`` definitions per handler, ``_MAX_HOP_LINES``
|
|
1384
|
+
lines each, and only intra-repo modules reached through ``contained_file``.
|
|
1385
|
+
"""
|
|
1386
|
+
|
|
1387
|
+
def __init__(self, root: Path, cache: dict[str, str | None]):
|
|
1388
|
+
self.root = root
|
|
1389
|
+
self.cache = cache
|
|
1390
|
+
self._imports: dict[str, dict[str, str]] = {}
|
|
1391
|
+
self._defs: dict[str, tuple[list[str], dict[str, int]]] = {}
|
|
1392
|
+
|
|
1393
|
+
def delegated_text(
|
|
1394
|
+
self,
|
|
1395
|
+
rel_file: str,
|
|
1396
|
+
content: str | None,
|
|
1397
|
+
handler: str,
|
|
1398
|
+
located: list[tuple[str, int, str]] | None = None,
|
|
1399
|
+
) -> str:
|
|
1400
|
+
"""The bodies ``handler`` delegates to, joined. ``located``, when given,
|
|
1401
|
+
also receives ``(file, 1-based start line, body)`` for each one."""
|
|
1402
|
+
if not content:
|
|
1403
|
+
return ""
|
|
1404
|
+
imports = self._import_map(rel_file, content)
|
|
1405
|
+
bodies: list[str] = []
|
|
1406
|
+
tried: set[tuple[str, str]] = set()
|
|
1407
|
+
for match in _IDENT_RE.finditer(handler):
|
|
1408
|
+
if len(bodies) >= _MAX_HOPS:
|
|
1409
|
+
break
|
|
1410
|
+
name = match.group(1)
|
|
1411
|
+
if name in _HOP_SKIP:
|
|
1412
|
+
continue
|
|
1413
|
+
# `svc.get_invoice(…)` → look `get_invoice` up in whatever `svc` names.
|
|
1414
|
+
after = handler[match.end() : match.end() + 64]
|
|
1415
|
+
member = re.match(r"\s*\.\s*([A-Za-z_$][\w$]*)", after)
|
|
1416
|
+
if name in imports and member is not None:
|
|
1417
|
+
target, func = imports[name], member.group(1)
|
|
1418
|
+
elif member is None and _CALL_OR_REF_RE.match(after):
|
|
1419
|
+
# A call, or a handler passed by reference (`router.get(p, getDoc)`);
|
|
1420
|
+
# a bare mention (`Order.query…`, a variable) is not delegation.
|
|
1421
|
+
target, func = imports.get(name, rel_file), name
|
|
1422
|
+
else:
|
|
1423
|
+
continue
|
|
1424
|
+
if (target, func) in tried:
|
|
1425
|
+
continue
|
|
1426
|
+
tried.add((target, func))
|
|
1427
|
+
body = self._definition(target, func)
|
|
1428
|
+
if body:
|
|
1429
|
+
bodies.append(body)
|
|
1430
|
+
if located is not None:
|
|
1431
|
+
located.append((target, self._defs[target][1][func] + 1, body))
|
|
1432
|
+
return "\n".join(bodies)
|
|
1433
|
+
|
|
1434
|
+
def _definition(self, rel_file: str, name: str) -> str:
|
|
1435
|
+
if rel_file not in self._defs:
|
|
1436
|
+
text = _read_file_content(rel_file, self.root, self.cache) or ""
|
|
1437
|
+
# `\n` only, like `_definition_index`: `splitlines()` would also split
|
|
1438
|
+
# on U+2028 / `\f` / a lone `\r` and return another function's body.
|
|
1439
|
+
self._defs[rel_file] = (text.split("\n"), _definition_index(text))
|
|
1440
|
+
lines, index = self._defs[rel_file]
|
|
1441
|
+
start = index.get(name)
|
|
1442
|
+
return _block_from(lines, start) if start is not None else ""
|
|
1443
|
+
|
|
1444
|
+
def _import_map(self, rel_file: str, content: str) -> dict[str, str]:
|
|
1445
|
+
"""Binding name → intra-repo module (rel path) for the route file."""
|
|
1446
|
+
if rel_file in self._imports:
|
|
1447
|
+
return self._imports[rel_file]
|
|
1448
|
+
out: dict[str, str] = {}
|
|
1449
|
+
base = Path(_norm(rel_file)).parent
|
|
1450
|
+
if Path(rel_file).suffix == ".py":
|
|
1451
|
+
for m in _PY_FROM_IMPORT_RE.finditer(content):
|
|
1452
|
+
mod = m.group("mod")
|
|
1453
|
+
names = m.group("names").strip().strip("()").replace("\n", " ")
|
|
1454
|
+
for part in names.split(","):
|
|
1455
|
+
bits = part.strip().split(" as ")
|
|
1456
|
+
real, alias = bits[0].strip(), bits[-1].strip()
|
|
1457
|
+
if not alias.isidentifier():
|
|
1458
|
+
continue
|
|
1459
|
+
# `from pkg import module` binds a module; `from mod import func` a symbol.
|
|
1460
|
+
target = self._py_module(base, f"{mod}.{real}" if not mod.endswith(".") else f"{mod}{real}")
|
|
1461
|
+
target = target or self._py_module(base, mod)
|
|
1462
|
+
if target:
|
|
1463
|
+
out.setdefault(alias, target)
|
|
1464
|
+
if len(out) >= _MAX_IMPORTS:
|
|
1465
|
+
break
|
|
1466
|
+
for m in _PY_IMPORT_RE.finditer(content):
|
|
1467
|
+
target = self._py_module(base, m.group("mod"))
|
|
1468
|
+
if target:
|
|
1469
|
+
out.setdefault(m.group("alias") or m.group("mod").split(".")[0], target)
|
|
1470
|
+
else:
|
|
1471
|
+
for regex in (_JS_IMPORT_FROM_RE, _JS_REQUIRE_RE):
|
|
1472
|
+
for m in regex.finditer(content):
|
|
1473
|
+
target = self._js_module(base, m.group("spec"))
|
|
1474
|
+
if target is None:
|
|
1475
|
+
continue
|
|
1476
|
+
clause = m.group("clause")
|
|
1477
|
+
for name in re.findall(r"[A-Za-z_$][\w$]*", clause.replace(" as ", " ")):
|
|
1478
|
+
if name not in {"as", "type"}:
|
|
1479
|
+
out.setdefault(name, target)
|
|
1480
|
+
if len(out) >= _MAX_IMPORTS:
|
|
1481
|
+
break
|
|
1482
|
+
self._imports[rel_file] = out
|
|
1483
|
+
return out
|
|
1484
|
+
|
|
1485
|
+
def _py_module(self, base: Path, module: str) -> str | None:
|
|
1486
|
+
dots = len(module) - len(module.lstrip("."))
|
|
1487
|
+
parts = [p for p in module.lstrip(".").split(".") if p]
|
|
1488
|
+
if not parts:
|
|
1489
|
+
return None
|
|
1490
|
+
if dots:
|
|
1491
|
+
anchors = [base.joinpath(*([".."] * (dots - 1)))]
|
|
1492
|
+
else:
|
|
1493
|
+
anchors = [Path(""), base]
|
|
1494
|
+
for anchor in anchors:
|
|
1495
|
+
for cand in (anchor.joinpath(*parts).with_suffix(".py"), anchor.joinpath(*parts, "__init__.py")):
|
|
1496
|
+
rel = self._existing(cand)
|
|
1497
|
+
if rel:
|
|
1498
|
+
return rel
|
|
1499
|
+
return None
|
|
1500
|
+
|
|
1501
|
+
def _js_module(self, base: Path, spec: str) -> str | None:
|
|
1502
|
+
target = base / spec
|
|
1503
|
+
cands = [target] if target.suffix in _JS_PROBE_SUFFIXES else []
|
|
1504
|
+
# TypeScript ESM imports name the emitted file: `from './webhook.js'`
|
|
1505
|
+
# is `webhook.ts` in the source tree (#366).
|
|
1506
|
+
cands += [target.with_suffix(sfx) for sfx in _TS_FOR_JS.get(target.suffix, ())]
|
|
1507
|
+
cands += [target.parent / (target.name + sfx) for sfx in _JS_PROBE_SUFFIXES]
|
|
1508
|
+
cands += [target / f"index{sfx}" for sfx in _JS_PROBE_SUFFIXES]
|
|
1509
|
+
for cand in cands:
|
|
1510
|
+
rel = self._existing(cand)
|
|
1511
|
+
if rel:
|
|
1512
|
+
return rel
|
|
1513
|
+
return None
|
|
1514
|
+
|
|
1515
|
+
def _existing(self, rel: Path) -> str | None:
|
|
1516
|
+
norm = os.path.normpath(str(rel)).replace("\\", "/")
|
|
1517
|
+
if norm.startswith("../") or norm == "..":
|
|
1518
|
+
return None
|
|
1519
|
+
try:
|
|
1520
|
+
if contained_file(self.root, norm) is None:
|
|
1521
|
+
return None
|
|
1522
|
+
except (OSError, ValueError):
|
|
1523
|
+
return None
|
|
1524
|
+
return norm
|
|
1525
|
+
|
|
1526
|
+
|
|
1527
|
+
# --- GraphQL SDL scoping (#139) --------------------------------------------
|
|
1528
|
+
|
|
1529
|
+
_GRAPHQL_EXTS = {".graphql", ".gql"}
|
|
1530
|
+
_GRAPHQL_CODE_EXTS = JS_TS_SUFFIXES | {".py", ".go", ".rb"}
|
|
1531
|
+
_GRAPHQL_SKIP_DIRS = {".git", "node_modules", "__pycache__", "dist", "build", ".venv", "venv", "vendor"}
|
|
1532
|
+
_GRAPHQL_MAX_DEPTH = 6
|
|
1533
|
+
_GRAPHQL_MAX_FILES = 2000
|
|
1534
|
+
|
|
1535
|
+
# One field definition: `name(args): ReturnType [@directives]`.
|
|
1536
|
+
# (?<!\w) + possessive \w++: never restart mid-word, so a long word can't
|
|
1537
|
+
# trigger quadratic backtracking (#236).
|
|
1538
|
+
_GRAPHQL_FIELD_RE = re.compile(
|
|
1539
|
+
r"(?<!\w)(?P<name>\w++)\s*+(?:\((?P<args>[^)]*+)\))?\s*+:\s*(?P<rest>[^\n]*)"
|
|
1540
|
+
)
|
|
1541
|
+
# `schema { query: RootQuery mutation: RootMutation }` custom root types.
|
|
1542
|
+
_GRAPHQL_SCHEMA_BLOCK_RE = re.compile(r"schema\s*\{(?P<body>[^}]*)\}", re.DOTALL)
|
|
1543
|
+
_GRAPHQL_SCHEMA_ROOT_RE = re.compile(r"\b(query|mutation)\s*:\s*(\w+)", re.IGNORECASE)
|
|
1544
|
+
_GRAPHQL_SDL_MARKER_RE = re.compile(r"type\s+(?:Query|Mutation)\b|schema\s*\{")
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _graphql_bola_candidates(root: Path, seen: set) -> list[BolaCandidate]:
|
|
1548
|
+
"""Scan the repo's GraphQL SDL for id-bearing Query/Mutation fields that
|
|
1549
|
+
lack an authorization check (#139). GraphQL fields aren't routes, so
|
|
1550
|
+
they're modeled here as ``graphql_field`` surfaces. Authorization can be an
|
|
1551
|
+
SDL directive on the field OR an ownership check in the resolver code."""
|
|
1552
|
+
files = list(_iter_graphql_files(root))
|
|
1553
|
+
# Resolver-side auth: files carrying an ownership marker (`current_user`,
|
|
1554
|
+
# `authorize`, …). A field defined as a resolver in such a file is treated
|
|
1555
|
+
# as authorized even without an SDL directive.
|
|
1556
|
+
auth_resolver_texts = [text for _rel, text in files if _GRAPHQL_RESOLVER_AUTH.search(text)]
|
|
1557
|
+
|
|
1558
|
+
candidates: list[BolaCandidate] = []
|
|
1559
|
+
for rel, text in files:
|
|
1560
|
+
for type_name, method in _graphql_root_types(text).items():
|
|
1561
|
+
for body in _graphql_type_bodies(text, type_name):
|
|
1562
|
+
for field_match in _GRAPHQL_FIELD_RE.finditer(body):
|
|
1563
|
+
name = field_match.group("name")
|
|
1564
|
+
args = field_match.group("args") or ""
|
|
1565
|
+
rest = field_match.group("rest") or ""
|
|
1566
|
+
arg_id = _graphql_id_arg(args)
|
|
1567
|
+
if arg_id is None:
|
|
1568
|
+
continue
|
|
1569
|
+
# Suppress when an SDL directive OR a resolver enforces auth.
|
|
1570
|
+
if _GRAPHQL_AUTH_DIRECTIVE_RE.search(args + " " + rest):
|
|
1571
|
+
continue
|
|
1572
|
+
if _graphql_resolver_authorizes(name, auth_resolver_texts):
|
|
1573
|
+
continue
|
|
1574
|
+
route_path = f"graphql:{name}"
|
|
1575
|
+
key = (method, route_path, rel, arg_id)
|
|
1576
|
+
if key in seen:
|
|
1577
|
+
continue
|
|
1578
|
+
seen.add(key)
|
|
1579
|
+
candidates.append(
|
|
1580
|
+
BolaCandidate(
|
|
1581
|
+
route_path=route_path,
|
|
1582
|
+
route_method=method,
|
|
1583
|
+
route_file=rel,
|
|
1584
|
+
id_param=arg_id,
|
|
1585
|
+
surface="graphql_field",
|
|
1586
|
+
reaches_db=True,
|
|
1587
|
+
db_evidence=f"GraphQL {method.lower()} field resolves an object by `{arg_id}`",
|
|
1588
|
+
has_ownership_check=False,
|
|
1589
|
+
source_analyzer="authz",
|
|
1590
|
+
)
|
|
1591
|
+
)
|
|
1592
|
+
return candidates
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def _graphql_root_types(text: str) -> dict[str, str]:
|
|
1596
|
+
"""Map GraphQL root type names → method (`QUERY`/`MUTATION`). Includes the
|
|
1597
|
+
conventional `Query`/`Mutation` plus any custom roots from a `schema {…}`
|
|
1598
|
+
declaration (`schema { query: RootQuery }`)."""
|
|
1599
|
+
roots = {"Query": "QUERY", "Mutation": "MUTATION"}
|
|
1600
|
+
schema = _GRAPHQL_SCHEMA_BLOCK_RE.search(text)
|
|
1601
|
+
if schema:
|
|
1602
|
+
for op, type_name in _GRAPHQL_SCHEMA_ROOT_RE.findall(schema.group("body")):
|
|
1603
|
+
roots[type_name] = "MUTATION" if op.lower() == "mutation" else "QUERY"
|
|
1604
|
+
return roots
|
|
1605
|
+
|
|
1606
|
+
|
|
1607
|
+
def _graphql_type_bodies(text: str, type_name: str):
|
|
1608
|
+
"""Yield the field-block body of each `type <type_name> { … }` (SDL field
|
|
1609
|
+
blocks don't nest braces, so a non-greedy match to the first `}` works)."""
|
|
1610
|
+
pattern = re.compile(
|
|
1611
|
+
r"(?:extend\s+)?type\s+" + re.escape(type_name) + r"\b[^{]*\{(.*?)\}",
|
|
1612
|
+
re.DOTALL,
|
|
1613
|
+
)
|
|
1614
|
+
for match in pattern.finditer(text):
|
|
1615
|
+
yield match.group(1)
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def _graphql_resolver_authorizes(field_name: str, auth_resolver_texts: list[str]) -> bool:
|
|
1619
|
+
"""True if the field is defined as a resolver (`name(` / `name:`) in a file
|
|
1620
|
+
that also contains an ownership marker — i.e. auth lives in the resolver."""
|
|
1621
|
+
resolver_ref = re.compile(r"\b" + re.escape(field_name) + r"\s*[(:]")
|
|
1622
|
+
return any(resolver_ref.search(text) for text in auth_resolver_texts)
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _graphql_id_arg(args: str) -> str | None:
|
|
1626
|
+
"""Return an id-bearing argument name from a GraphQL field's arg list —
|
|
1627
|
+
an id-shaped name (`id`, `userId`) or an `ID`-typed argument."""
|
|
1628
|
+
for part in args.split(","):
|
|
1629
|
+
part = part.strip()
|
|
1630
|
+
if not part or ":" not in part:
|
|
1631
|
+
continue
|
|
1632
|
+
arg_name, _, arg_type = part.partition(":")
|
|
1633
|
+
arg_name = arg_name.strip()
|
|
1634
|
+
if _ID_PARAM_RE.search(arg_name):
|
|
1635
|
+
return arg_name
|
|
1636
|
+
if arg_type.strip().lstrip("[").startswith("ID"):
|
|
1637
|
+
return arg_name
|
|
1638
|
+
return None
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
def _iter_graphql_files(root: Path):
|
|
1642
|
+
"""Yield ``(rel_path, text)`` for files relevant to GraphQL authz: `.graphql`
|
|
1643
|
+
/ `.gql` files, and code files that either contain SDL (`type Query`, a
|
|
1644
|
+
`schema {…}`) or an ownership marker (candidate resolver). Test/fixture and
|
|
1645
|
+
vendored files are excluded per the repo's source-quality policy (#139)."""
|
|
1646
|
+
count = 0
|
|
1647
|
+
|
|
1648
|
+
def walk(directory: Path, depth: int):
|
|
1649
|
+
nonlocal count
|
|
1650
|
+
try:
|
|
1651
|
+
entries = sorted(directory.iterdir())
|
|
1652
|
+
except (OSError, PermissionError):
|
|
1653
|
+
return
|
|
1654
|
+
for entry in entries:
|
|
1655
|
+
if count >= _GRAPHQL_MAX_FILES:
|
|
1656
|
+
return
|
|
1657
|
+
if entry.name in _GRAPHQL_SKIP_DIRS or is_unsafe_link(root, entry):
|
|
1658
|
+
continue
|
|
1659
|
+
if entry.is_dir():
|
|
1660
|
+
if depth < _GRAPHQL_MAX_DEPTH:
|
|
1661
|
+
yield from walk(entry, depth + 1)
|
|
1662
|
+
continue
|
|
1663
|
+
suffix = entry.suffix.lower()
|
|
1664
|
+
if suffix not in _GRAPHQL_EXTS and suffix not in _GRAPHQL_CODE_EXTS:
|
|
1665
|
+
continue
|
|
1666
|
+
rel = _norm(str(entry.relative_to(root)))
|
|
1667
|
+
if is_test_file(rel) or is_vendored_file(rel):
|
|
1668
|
+
continue
|
|
1669
|
+
try:
|
|
1670
|
+
if entry.stat().st_size > _MAX_FILE_BYTES:
|
|
1671
|
+
continue
|
|
1672
|
+
text = entry.read_text(encoding="utf-8", errors="ignore")
|
|
1673
|
+
except (OSError, ValueError):
|
|
1674
|
+
continue
|
|
1675
|
+
count += 1
|
|
1676
|
+
if suffix in _GRAPHQL_CODE_EXTS and not (
|
|
1677
|
+
_GRAPHQL_SDL_MARKER_RE.search(text) or _GRAPHQL_RESOLVER_AUTH.search(text)
|
|
1678
|
+
):
|
|
1679
|
+
continue
|
|
1680
|
+
yield rel, text
|
|
1681
|
+
|
|
1682
|
+
yield from walk(root, 1)
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def _norm(rel: str) -> str:
|
|
1686
|
+
return rel.replace("\\", "/")
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _module_key(rel: str) -> str:
|
|
1690
|
+
"""Directory of the file — a coarse 'same module' bucket."""
|
|
1691
|
+
norm = _norm(rel)
|
|
1692
|
+
return norm.rsplit("/", 1)[0] if "/" in norm else ""
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
__all__ = ["AUTHN_MARKERS", "OBJECT_AUTHZ_MARKERS", "ROLE_MARKERS", "analyze_authz", "find_bfla_routes"]
|