attackmap 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. attackmap/__init__.py +2 -0
  2. attackmap/analyzer.py +449 -0
  3. attackmap/analyzer_contracts.py +194 -0
  4. attackmap/analyzers.py +1003 -0
  5. attackmap/anomalies.py +612 -0
  6. attackmap/asset_model.py +245 -0
  7. attackmap/attack_taxonomy.py +133 -0
  8. attackmap/auth_protocol.py +702 -0
  9. attackmap/authz.py +1695 -0
  10. attackmap/bench.py +467 -0
  11. attackmap/cli.py +1474 -0
  12. attackmap/config_scanner.py +389 -0
  13. attackmap/context_pack.py +82 -0
  14. attackmap/contracts.py +201 -0
  15. attackmap/control_model.py +280 -0
  16. attackmap/crossrepo.py +356 -0
  17. attackmap/crypto.py +240 -0
  18. attackmap/cve.py +717 -0
  19. attackmap/defensive_review.py +647 -0
  20. attackmap/detection_opportunities.py +1221 -0
  21. attackmap/detections/__init__.py +491 -0
  22. attackmap/detections/bola.yml +57 -0
  23. attackmap/detections/cloud.yml +609 -0
  24. attackmap/detections/injection.yml +167 -0
  25. attackmap/detections/secrets.yml +48 -0
  26. attackmap/detections/sigma.py +511 -0
  27. attackmap/detections/ssrf.yml +98 -0
  28. attackmap/detections/weakness.yml +62 -0
  29. attackmap/detections/webhook.yml +45 -0
  30. attackmap/detections/workflow.yml +121 -0
  31. attackmap/diagrams.py +237 -0
  32. attackmap/diff.py +301 -0
  33. attackmap/exploitability.py +493 -0
  34. attackmap/fingerprint.py +91 -0
  35. attackmap/fleet.py +370 -0
  36. attackmap/gitref.py +129 -0
  37. attackmap/graph.py +26 -0
  38. attackmap/handler_auth.py +639 -0
  39. attackmap/handler_span.py +62 -0
  40. attackmap/hunt_harness.py +505 -0
  41. attackmap/insights.py +701 -0
  42. attackmap/llm_review.py +846 -0
  43. attackmap/lockfiles.py +521 -0
  44. attackmap/mass_assignment.py +543 -0
  45. attackmap/md.py +175 -0
  46. attackmap/merge.py +333 -0
  47. attackmap/models.py +1284 -0
  48. attackmap/plugins_lock.py +114 -0
  49. attackmap/popular_packages.py +193 -0
  50. attackmap/progress.py +258 -0
  51. attackmap/recon_models.py +31 -0
  52. attackmap/recon_to_analysis.py +149 -0
  53. attackmap/redact.py +220 -0
  54. attackmap/report.py +424 -0
  55. attackmap/review_eval.py +335 -0
  56. attackmap/review_json.py +308 -0
  57. attackmap/review_prompts.py +796 -0
  58. attackmap/route_auth_fusion.py +474 -0
  59. attackmap/safe_fs.py +285 -0
  60. attackmap/sarif.py +373 -0
  61. attackmap/sbom.py +418 -0
  62. attackmap/scanner.py +1379 -0
  63. attackmap/sdk/__init__.py +278 -0
  64. attackmap/sdk/contracts.py +17 -0
  65. attackmap/sdk/fs.py +156 -0
  66. attackmap/sdk/models.py +59 -0
  67. attackmap/secrets_history.py +161 -0
  68. attackmap/security_overlay.py +71 -0
  69. attackmap/srcpaths.py +301 -0
  70. attackmap/suggest.py +394 -0
  71. attackmap/supply_chain.py +688 -0
  72. attackmap/suppress.py +740 -0
  73. attackmap/taint.py +1540 -0
  74. attackmap/taint_flow.py +1419 -0
  75. attackmap/taint_sinks.py +431 -0
  76. attackmap/taint_sinks.yaml +308 -0
  77. attackmap/taint_sources.py +396 -0
  78. attackmap/taxonomy.py +465 -0
  79. attackmap/threat_model.py +3441 -0
  80. attackmap/topology.py +316 -0
  81. attackmap/triage.py +151 -0
  82. attackmap/weaknesses.py +365 -0
  83. attackmap/webhardening.py +479 -0
  84. attackmap/webhook_verification.py +1067 -0
  85. attackmap/workflow_scanner.py +964 -0
  86. attackmap-0.6.4.dist-info/METADATA +226 -0
  87. attackmap-0.6.4.dist-info/RECORD +91 -0
  88. attackmap-0.6.4.dist-info/WHEEL +5 -0
  89. attackmap-0.6.4.dist-info/entry_points.txt +2 -0
  90. attackmap-0.6.4.dist-info/licenses/LICENSE +21 -0
  91. attackmap-0.6.4.dist-info/top_level.txt +1 -0
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"]