apisec-code-bolt 0.1.0__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 (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,867 @@
1
+ """
2
+ Django + Django REST Framework plugin.
3
+
4
+ Extracts HTTP routes from Django URL configurations:
5
+ - path() / re_path() / url() calls in urls.py files
6
+ - APIView subclasses with get/post/put/patch/delete methods
7
+ - ViewSet / ModelViewSet / ReadOnlyModelViewSet classes
8
+ - @api_view decorators on function-based views
9
+ - Router.register() for ViewSet routing
10
+ - Cross-file CBV class resolution via context.all_parsed_files
11
+
12
+ Always uses call site location (urls.py line) as handler_location for
13
+ benchmark accuracy.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import re
20
+ from typing import TYPE_CHECKING, Any
21
+
22
+ from ...core.types import (
23
+ AuthDependencyType,
24
+ AuthSchemeType,
25
+ CodeLocation,
26
+ Confidence,
27
+ Framework,
28
+ HttpMethod,
29
+ Language,
30
+ ParameterLocation,
31
+ QualifiedName,
32
+ )
33
+ from ...parsing.base import ParsedCallSite, ParsedClass, ParsedFile, ParsedFunction
34
+ from ..base import (
35
+ BaseFrameworkPlugin,
36
+ ExtractedAuthDependency,
37
+ ExtractedAuthScheme,
38
+ ExtractedDependency,
39
+ ExtractedMiddleware,
40
+ ExtractedParameter,
41
+ ExtractedRoute,
42
+ FrameworkPluginRegistry,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from ...parsing.services import AnalysisContext
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Django/DRF imports that indicate this is a Django file
51
+ _DJANGO_IMPORTS = frozenset(
52
+ {
53
+ "django",
54
+ "django.urls",
55
+ "django.urls.path",
56
+ "django.urls.re_path",
57
+ "django.conf.urls",
58
+ "rest_framework",
59
+ "rest_framework.views",
60
+ "rest_framework.viewsets",
61
+ "rest_framework.decorators",
62
+ "rest_framework.routers",
63
+ }
64
+ )
65
+
66
+ # ViewSet CRUD actions → HTTP methods
67
+ _VIEWSET_ACTIONS: list[tuple[HttpMethod, str]] = [
68
+ (HttpMethod.GET, "list"),
69
+ (HttpMethod.POST, "create"),
70
+ (HttpMethod.GET, "retrieve"),
71
+ (HttpMethod.PUT, "update"),
72
+ (HttpMethod.PATCH, "partial_update"),
73
+ (HttpMethod.DELETE, "destroy"),
74
+ ]
75
+
76
+ _READONLY_VIEWSET_ACTIONS: list[tuple[HttpMethod, str]] = [
77
+ (HttpMethod.GET, "list"),
78
+ (HttpMethod.GET, "retrieve"),
79
+ ]
80
+
81
+ # Router action map for standard ViewSet names
82
+ _ROUTER_LIST_ACTIONS: list[tuple[HttpMethod, str]] = [
83
+ (HttpMethod.GET, "list"),
84
+ (HttpMethod.POST, "create"),
85
+ ]
86
+ _ROUTER_DETAIL_ACTIONS: list[tuple[HttpMethod, str]] = [
87
+ (HttpMethod.GET, "retrieve"),
88
+ (HttpMethod.PUT, "update"),
89
+ (HttpMethod.PATCH, "partial_update"),
90
+ (HttpMethod.DELETE, "destroy"),
91
+ ]
92
+
93
+ # Django path converter regex: <int:pk> → {pk}, <slug:slug> → {slug}, <pk> → {pk}
94
+ _DJANGO_PATH_PARAM_RE = re.compile(r"<(?:(?:[\w]+):)?(\w+)>")
95
+ # re_path regex param: (?P<pk>[0-9]+) → {pk}
96
+ _DJANGO_REGEX_PARAM_RE = re.compile(r"\(\?P<(\w+)>[^)]*\)")
97
+
98
+ # ── Auth scheme detection ─────────────────────────────────────────────────────
99
+
100
+ # DRF authentication classes → AuthSchemeType
101
+ _DRF_AUTH_CLASS_TO_SCHEME: dict[str, AuthSchemeType] = {
102
+ "SessionAuthentication": AuthSchemeType.SESSION_COOKIE,
103
+ "BasicAuthentication": AuthSchemeType.HTTP_BASIC,
104
+ "TokenAuthentication": AuthSchemeType.API_KEY_HEADER,
105
+ "JSONWebTokenAuthentication": AuthSchemeType.JWT_BEARER,
106
+ "JWTAuthentication": AuthSchemeType.JWT_BEARER,
107
+ "JWTStatelessUserAuthentication": AuthSchemeType.JWT_BEARER,
108
+ "RemoteUserAuthentication": AuthSchemeType.HTTP_BASIC,
109
+ }
110
+
111
+ # Substrings in class names that imply JWT
112
+ _JWT_AUTH_CLASS_HINTS = ("jwt", "jwtauth", "jsonwebtoken", "bearerauth")
113
+
114
+ # DRF permission classes that imply authentication is required
115
+ _PERMISSION_REQUIRES_AUTH = frozenset(
116
+ {
117
+ "IsAuthenticated",
118
+ "IsAdminUser",
119
+ "IsAuthenticatedOrReadOnly",
120
+ "DjangoModelPermissions",
121
+ "DjangoModelPermissionsOrAnonReadOnly",
122
+ "DjangoObjectPermissions",
123
+ }
124
+ )
125
+
126
+ # Permission classes that imply no auth required
127
+ _PERMISSION_ANONYMOUS = frozenset({"AllowAny", "IsAuthenticatedOrReadOnly"})
128
+
129
+ # Builtins to skip (not real routes)
130
+ _BUILTIN_SKIP_PATTERNS = (
131
+ "admin.site.urls",
132
+ "static(",
133
+ "i18n_patterns(",
134
+ "DebugToolbarSetup",
135
+ )
136
+
137
+
138
+ def _django_path_to_manifest(path: str) -> str:
139
+ """Convert Django URL path format to OpenAPI-style path."""
140
+ path = _DJANGO_PATH_PARAM_RE.sub(r"{\1}", path)
141
+ path = _DJANGO_REGEX_PARAM_RE.sub(r"{\1}", path)
142
+ return path
143
+
144
+
145
+ def _build_inline_prefix_map(parsed_file: ParsedFile) -> dict[int, str]:
146
+ """
147
+ Build a line→prefix map for same-file inline include() nesting.
148
+
149
+ Detects path("segment/", include([...])) patterns and maps each nested
150
+ leaf path() call's line number to its accumulated inline prefix segment.
151
+
152
+ Uses call site end_line to determine containment — a leaf call at line L
153
+ is nested inside a wrapper whose start_line < L <= end_line.
154
+
155
+ Example:
156
+ path("session/", include([path("login/", ...), path("logout/", ...)]))
157
+ → login and logout calls both get inline prefix "session/"
158
+
159
+ path("upload/", include([path("direct/", include([path("start/", ...)]))]))
160
+ → start gets inline prefix "upload/direct/"
161
+ """
162
+ # Collect include wrappers: (segment, start_line, end_line)
163
+ wrappers: list[tuple[str, int, int]] = []
164
+ for call in parsed_file.call_sites:
165
+ if call.callee_name not in ("path", "re_path", "url"):
166
+ continue
167
+ if not call.arguments or len(call.arguments) < 2:
168
+ continue
169
+ path_arg = call.arguments[0]
170
+ if not path_arg.is_literal or not isinstance(path_arg.literal_value, str):
171
+ continue
172
+ view_arg = call.arguments[1]
173
+ view_text = (
174
+ view_arg.expression_text
175
+ if view_arg.is_expression
176
+ else (view_arg.variable_name if view_arg.is_variable else "")
177
+ ) or ""
178
+ if not view_text.startswith("include("):
179
+ continue
180
+ loc = call.location
181
+ if loc and loc.line and loc.end_line and loc.end_line > loc.line:
182
+ wrappers.append((path_arg.literal_value, loc.line, loc.end_line))
183
+
184
+ if not wrappers:
185
+ return {}
186
+
187
+ # For each leaf call site, find all wrappers that contain it (by line range)
188
+ # and compose the accumulated inline prefix.
189
+ inline_map: dict[int, str] = {}
190
+ for call in parsed_file.call_sites:
191
+ if call.callee_name not in ("path", "re_path", "url"):
192
+ continue
193
+ if not call.arguments or len(call.arguments) < 2:
194
+ continue
195
+ path_arg = call.arguments[0]
196
+ if not path_arg.is_literal or not isinstance(path_arg.literal_value, str):
197
+ continue
198
+ view_arg = call.arguments[1]
199
+ view_text = (
200
+ view_arg.expression_text
201
+ if view_arg.is_expression
202
+ else (view_arg.variable_name if view_arg.is_variable else "")
203
+ ) or ""
204
+ if view_text.startswith("include("):
205
+ continue # Skip wrappers; only map leaf calls
206
+ loc = call.location
207
+ if not loc or not loc.line:
208
+ continue
209
+ line = loc.line
210
+ # Find containing wrappers, sorted outermost-first (smallest start_line)
211
+ containing = sorted(
212
+ [(seg, s, e) for seg, s, e in wrappers if s < line <= e],
213
+ key=lambda x: x[1],
214
+ )
215
+ if not containing:
216
+ continue
217
+ prefix = ""
218
+ for seg, _, _ in containing:
219
+ prefix = (prefix.rstrip("/") + "/" + seg.lstrip("/")) if prefix else seg
220
+ inline_map[line] = prefix
221
+
222
+ return inline_map
223
+
224
+
225
+ def _extract_path_params(path: str) -> list[ExtractedParameter]:
226
+ """Extract {param} names from a path string."""
227
+ return [
228
+ ExtractedParameter(name=m.group(1), location=ParameterLocation.PATH)
229
+ for m in re.finditer(r"\{(\w+)\}", path)
230
+ ]
231
+
232
+
233
+ class DjangoPlugin(BaseFrameworkPlugin):
234
+ """
235
+ Framework plugin for Django + Django REST Framework.
236
+
237
+ Detects Django usage and extracts routes from URL patterns.
238
+ """
239
+
240
+ FRAMEWORK = Framework.DJANGO
241
+ LANGUAGE = Language.PYTHON
242
+ DETECTION_IMPORTS: frozenset[str] = _DJANGO_IMPORTS
243
+
244
+ def detect(self, parsed_file: ParsedFile) -> bool:
245
+ """Detect Django by looking for django or rest_framework imports."""
246
+ for imp in parsed_file.imports:
247
+ if imp.module == "django" or imp.module.startswith("django."):
248
+ return True
249
+ if imp.module == "rest_framework" or imp.module.startswith("rest_framework."):
250
+ return True
251
+ return False
252
+
253
+ def extract_routes(
254
+ self,
255
+ parsed_file: ParsedFile,
256
+ context: AnalysisContext | None = None,
257
+ ) -> list[ExtractedRoute]:
258
+ """Extract Django URL routes from call sites."""
259
+ routes: list[ExtractedRoute] = []
260
+
261
+ # Build class index from this file + all parsed files in context
262
+ class_index: dict[str, ParsedClass] = {}
263
+ func_index: dict[str, ParsedFunction] = {}
264
+
265
+ files_to_search: list[ParsedFile] = [parsed_file]
266
+ if context and context.all_parsed_files:
267
+ files_to_search.extend(context.all_parsed_files)
268
+
269
+ for pf in files_to_search:
270
+ for cls in pf.classes:
271
+ class_index[cls.name] = cls
272
+ for func in pf.functions:
273
+ func_index[func.name] = func
274
+
275
+ # Cross-file prefix from url_prefix_map (populated by url_prefix_resolver)
276
+ cross_file_prefix = ""
277
+ if context and context.language_services:
278
+ prefix_map = context.language_services.get("_url_prefix_map")
279
+ if prefix_map:
280
+ file_key = str(parsed_file.path.resolve())
281
+ prefixes = prefix_map.get(file_key, [])
282
+ if prefixes:
283
+ cross_file_prefix = prefixes[0]
284
+
285
+ # Same-file inline include() prefix map: line → accumulated segment prefix
286
+ inline_prefix_map = _build_inline_prefix_map(parsed_file)
287
+
288
+ # Process path()/re_path()/url() calls
289
+ for call in parsed_file.call_sites:
290
+ if call.callee_name not in ("path", "re_path", "url"):
291
+ continue
292
+
293
+ if not call.arguments:
294
+ continue
295
+
296
+ # First arg is the URL pattern
297
+ path_arg = call.arguments[0]
298
+ if not path_arg.is_literal or not isinstance(path_arg.literal_value, str):
299
+ continue
300
+
301
+ raw_path = path_arg.literal_value
302
+
303
+ # Second arg is the view
304
+ if len(call.arguments) < 2:
305
+ continue
306
+
307
+ view_arg = call.arguments[1]
308
+ view_text = ""
309
+ if view_arg.is_variable and view_arg.variable_name:
310
+ view_text = view_arg.variable_name
311
+ elif view_arg.is_expression and view_arg.expression_text:
312
+ view_text = view_arg.expression_text
313
+ elif view_arg.is_literal:
314
+ view_text = str(view_arg.literal_value)
315
+
316
+ # Skip builtins
317
+ if any(skip in view_text for skip in _BUILTIN_SKIP_PATTERNS):
318
+ continue
319
+
320
+ # Skip include() wrappers — their nested calls are handled by
321
+ # inline_prefix_map; the wrapper itself is not a route.
322
+ if view_text.startswith("include("):
323
+ continue
324
+
325
+ # Compose full prefix: cross-file + inline (same-file include nesting)
326
+ inline_prefix = ""
327
+ if call.location and call.location.line:
328
+ inline_prefix = inline_prefix_map.get(call.location.line, "")
329
+
330
+ full_prefix = cross_file_prefix
331
+ if inline_prefix:
332
+ full_prefix = (
333
+ (full_prefix.rstrip("/") + "/" + inline_prefix.lstrip("/"))
334
+ if full_prefix
335
+ else inline_prefix
336
+ )
337
+
338
+ # Convert path format and apply prefix
339
+ path = _django_path_to_manifest(raw_path)
340
+ if full_prefix:
341
+ path = full_prefix.rstrip("/") + "/" + path.lstrip("/")
342
+ if not path.startswith("/"):
343
+ path = "/" + path
344
+
345
+ # Resolve the view
346
+ extracted = self._resolve_view(
347
+ view_text, path, call, class_index, func_index, parsed_file
348
+ )
349
+ routes.extend(extracted)
350
+
351
+ return routes
352
+
353
+ def _resolve_view(
354
+ self,
355
+ view_text: str,
356
+ path: str,
357
+ call: ParsedCallSite,
358
+ class_index: dict[str, ParsedClass],
359
+ func_index: dict[str, ParsedFunction],
360
+ parsed_file: ParsedFile,
361
+ ) -> list[ExtractedRoute]:
362
+ """Resolve a view reference and return extracted routes."""
363
+ routes: list[ExtractedRoute] = []
364
+ path_params = _extract_path_params(path)
365
+ location = call.location
366
+
367
+ # Pattern: SomeView.as_view()
368
+ as_view_match = re.match(r"(\w+)\.as_view\b", view_text)
369
+ if as_view_match:
370
+ cls_name = as_view_match.group(1)
371
+ cls = class_index.get(cls_name)
372
+ if cls:
373
+ routes.extend(
374
+ self._routes_from_class(cls, path, path_params, location, parsed_file)
375
+ )
376
+ else:
377
+ # Can't resolve class — emit a generic GET
378
+ routes.append(
379
+ self._make_route(
380
+ HttpMethod.GET, path, path_params, cls_name, location, parsed_file
381
+ )
382
+ )
383
+ return routes
384
+
385
+ # Pattern: ViewSet registered via Router.register (view_text is ViewSet class name)
386
+ # Check if the view_text is a class that's a ViewSet
387
+ cls = class_index.get(view_text)
388
+ if cls:
389
+ routes.extend(self._routes_from_class(cls, path, path_params, location, parsed_file))
390
+ return routes
391
+
392
+ # Pattern: FBV decorated with @api_view
393
+ func = func_index.get(view_text)
394
+ if func:
395
+ routes.extend(
396
+ self._routes_from_function(func, path, path_params, location, parsed_file)
397
+ )
398
+ return routes
399
+
400
+ # Dotted reference: views.MyView.as_view() → try last part
401
+ if "." in view_text:
402
+ last = view_text.rsplit(".", 1)[-1]
403
+ if last.endswith("()"):
404
+ last = last[:-2]
405
+ cls = class_index.get(last)
406
+ if cls:
407
+ routes.extend(
408
+ self._routes_from_class(cls, path, path_params, location, parsed_file)
409
+ )
410
+ return routes
411
+ func = func_index.get(last)
412
+ if func:
413
+ routes.extend(
414
+ self._routes_from_function(func, path, path_params, location, parsed_file)
415
+ )
416
+ return routes
417
+
418
+ # Fallback: emit a single GET route
419
+ handler_name = view_text.split(".")[0] if "." in view_text else view_text
420
+ routes.append(
421
+ self._make_route(HttpMethod.GET, path, path_params, handler_name, location, parsed_file)
422
+ )
423
+ return routes
424
+
425
+ def _routes_from_class(
426
+ self,
427
+ cls: ParsedClass,
428
+ path: str,
429
+ path_params: list[ExtractedParameter],
430
+ location: CodeLocation | None,
431
+ parsed_file: ParsedFile,
432
+ ) -> list[ExtractedRoute]:
433
+ """Extract routes from a CBV class."""
434
+ routes: list[ExtractedRoute] = []
435
+
436
+ # Check for ViewSet patterns
437
+ base_names = {b.lower() for b in cls.base_classes}
438
+ is_viewset = any("viewset" in b for b in base_names)
439
+ is_readonly = any("readonlymodelviewset" in b for b in base_names)
440
+ is_modelviewset = (
441
+ any("modelviewset" in b or b == "viewset" for b in base_names) and not is_readonly
442
+ )
443
+
444
+ if is_readonly:
445
+ # ReadOnlyModelViewSet: list + retrieve
446
+ base_path = path.rstrip("/")
447
+ routes.append(
448
+ self._make_route(
449
+ HttpMethod.GET,
450
+ base_path or "/",
451
+ path_params,
452
+ f"{cls.name}.list",
453
+ location,
454
+ parsed_file,
455
+ )
456
+ )
457
+ detail_path = base_path + "/{pk}"
458
+ routes.append(
459
+ self._make_route(
460
+ HttpMethod.GET,
461
+ detail_path,
462
+ path_params + [ExtractedParameter(name="pk", location=ParameterLocation.PATH)],
463
+ f"{cls.name}.retrieve",
464
+ location,
465
+ parsed_file,
466
+ )
467
+ )
468
+ return routes
469
+
470
+ if is_viewset or is_modelviewset:
471
+ # Full CRUD ViewSet
472
+ base_path = path.rstrip("/")
473
+ for method, action in _ROUTER_LIST_ACTIONS:
474
+ routes.append(
475
+ self._make_route(
476
+ method,
477
+ base_path or "/",
478
+ path_params,
479
+ f"{cls.name}.{action}",
480
+ location,
481
+ parsed_file,
482
+ )
483
+ )
484
+ detail_path = base_path + "/{pk}"
485
+ for method, action in _ROUTER_DETAIL_ACTIONS:
486
+ routes.append(
487
+ self._make_route(
488
+ method,
489
+ detail_path,
490
+ path_params
491
+ + [ExtractedParameter(name="pk", location=ParameterLocation.PATH)],
492
+ f"{cls.name}.{action}",
493
+ location,
494
+ parsed_file,
495
+ )
496
+ )
497
+ return routes
498
+
499
+ # APIView: look for http method handlers (get, post, put, patch, delete)
500
+ http_methods = {
501
+ "get": HttpMethod.GET,
502
+ "post": HttpMethod.POST,
503
+ "put": HttpMethod.PUT,
504
+ "patch": HttpMethod.PATCH,
505
+ "delete": HttpMethod.DELETE,
506
+ "head": HttpMethod.HEAD,
507
+ "options": HttpMethod.OPTIONS,
508
+ }
509
+
510
+ found_methods = []
511
+ for method in cls.methods:
512
+ if method.name.lower() in http_methods:
513
+ found_methods.append((http_methods[method.name.lower()], method.name))
514
+
515
+ # Check for permission_classes attribute (for auth detection)
516
+ # ... handled at route level by auth extraction
517
+
518
+ if found_methods:
519
+ for http_method, method_name in found_methods:
520
+ routes.append(
521
+ self._make_route(
522
+ http_method,
523
+ path,
524
+ path_params,
525
+ f"{cls.name}.{method_name}",
526
+ location,
527
+ parsed_file,
528
+ )
529
+ )
530
+ else:
531
+ # Generic class-based view — emit GET
532
+ routes.append(
533
+ self._make_route(HttpMethod.GET, path, path_params, cls.name, location, parsed_file)
534
+ )
535
+
536
+ return routes
537
+
538
+ def _routes_from_function(
539
+ self,
540
+ func: ParsedFunction,
541
+ path: str,
542
+ path_params: list[ExtractedParameter],
543
+ location: CodeLocation | None,
544
+ parsed_file: ParsedFile,
545
+ ) -> list[ExtractedRoute]:
546
+ """Extract routes from a function-based view."""
547
+ routes: list[ExtractedRoute] = []
548
+
549
+ # Check for @api_view decorator
550
+ api_view_dec = None
551
+ for dec in func.decorators:
552
+ if dec.name == "api_view":
553
+ api_view_dec = dec
554
+ break
555
+
556
+ if api_view_dec:
557
+ # Methods are in the first positional arg: ['GET', 'POST']
558
+ methods_arg = api_view_dec.positional_args[0] if api_view_dec.positional_args else None
559
+ if methods_arg and isinstance(methods_arg, str):
560
+ # May be a list-like string or list
561
+ method_names = re.findall(r"['\"]([A-Z]+)['\"]", str(methods_arg))
562
+ if not method_names:
563
+ method_names = ["GET"]
564
+ else:
565
+ method_names = ["GET"]
566
+
567
+ method_map = {
568
+ "GET": HttpMethod.GET,
569
+ "POST": HttpMethod.POST,
570
+ "PUT": HttpMethod.PUT,
571
+ "PATCH": HttpMethod.PATCH,
572
+ "DELETE": HttpMethod.DELETE,
573
+ "HEAD": HttpMethod.HEAD,
574
+ "OPTIONS": HttpMethod.OPTIONS,
575
+ }
576
+ for method_name in method_names:
577
+ http_method = method_map.get(method_name.upper(), HttpMethod.GET)
578
+ routes.append(
579
+ self._make_route(
580
+ http_method, path, path_params, func.name, location, parsed_file
581
+ )
582
+ )
583
+ else:
584
+ # Plain FBV — emit GET
585
+ routes.append(
586
+ self._make_route(
587
+ HttpMethod.GET, path, path_params, func.name, location, parsed_file
588
+ )
589
+ )
590
+
591
+ return routes
592
+
593
+ def _make_route(
594
+ self,
595
+ method: HttpMethod,
596
+ path: str,
597
+ path_params: list[ExtractedParameter],
598
+ handler_name: str,
599
+ location: CodeLocation | None,
600
+ parsed_file: ParsedFile,
601
+ ) -> ExtractedRoute:
602
+ """Build an ExtractedRoute using the call site location."""
603
+ loc = location or CodeLocation(file=parsed_file.path, line=0)
604
+ return ExtractedRoute(
605
+ method=method,
606
+ path=path,
607
+ handler_function=QualifiedName(
608
+ module=parsed_file.path.stem,
609
+ name=handler_name,
610
+ ),
611
+ handler_location=loc,
612
+ path_params=path_params,
613
+ )
614
+
615
+ def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
616
+ return []
617
+
618
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
619
+ """
620
+ Detect DRF authentication scheme definitions.
621
+
622
+ Covers:
623
+ - Known DRF auth class imports (SessionAuthentication, TokenAuthentication, etc.)
624
+ - Third-party JWT auth imports (rest_framework_jwt, simplejwt)
625
+ - Custom BaseAuthentication subclasses whose name contains a scheme hint
626
+ - authentication_classes assignments referencing known classes
627
+ """
628
+ schemes: list[ExtractedAuthScheme] = []
629
+ seen: set[str] = set()
630
+
631
+ def _add(name: str, scheme_type: AuthSchemeType, loc: CodeLocation) -> None:
632
+ if name not in seen:
633
+ seen.add(name)
634
+ schemes.append(
635
+ ExtractedAuthScheme(
636
+ scheme_type=scheme_type,
637
+ name=name,
638
+ location=loc,
639
+ confidence=Confidence.HIGH,
640
+ )
641
+ )
642
+
643
+ file_loc = CodeLocation(file=parsed_file.path, line=1)
644
+
645
+ # ── 1. Import-based detection ────────────────────────────────────────
646
+ for imp in parsed_file.imports:
647
+ for name in imp.names:
648
+ if name in _DRF_AUTH_CLASS_TO_SCHEME:
649
+ _add(name, _DRF_AUTH_CLASS_TO_SCHEME[name], file_loc)
650
+ elif name.lower().endswith("authentication") or name.lower().endswith(
651
+ "authenticator"
652
+ ):
653
+ hint = name.lower()
654
+ if any(kw in hint for kw in _JWT_AUTH_CLASS_HINTS):
655
+ _add(name, AuthSchemeType.JWT_BEARER, file_loc)
656
+ elif "session" in hint:
657
+ _add(name, AuthSchemeType.SESSION_COOKIE, file_loc)
658
+ elif "token" in hint:
659
+ _add(name, AuthSchemeType.API_KEY_HEADER, file_loc)
660
+ elif "basic" in hint:
661
+ _add(name, AuthSchemeType.HTTP_BASIC, file_loc)
662
+
663
+ # ── 2. Class definition-based detection ─────────────────────────────
664
+ for cls in parsed_file.classes:
665
+ hint = cls.name.lower()
666
+ scheme_type = None
667
+ if any(kw in hint for kw in _JWT_AUTH_CLASS_HINTS):
668
+ scheme_type = AuthSchemeType.JWT_BEARER
669
+ elif "session" in hint and "auth" in hint:
670
+ scheme_type = AuthSchemeType.SESSION_COOKIE
671
+ elif "token" in hint and "auth" in hint:
672
+ scheme_type = AuthSchemeType.API_KEY_HEADER
673
+ elif "basic" in hint and "auth" in hint:
674
+ scheme_type = AuthSchemeType.HTTP_BASIC
675
+
676
+ # Only emit if this looks like a custom auth class (inherits from known base)
677
+ base_names = {b.lower() for b in cls.base_classes}
678
+ is_auth_class = any(
679
+ b in ("baseauthentication", "sessionauthentication", "baseauthenticationbackend")
680
+ for b in base_names
681
+ )
682
+ if scheme_type and is_auth_class:
683
+ loc = CodeLocation(
684
+ file=parsed_file.path, line=cls.location.line if cls.location else 1
685
+ )
686
+ _add(cls.name, scheme_type, loc)
687
+
688
+ return schemes
689
+
690
+ def extract_auth_dependencies(
691
+ self,
692
+ parsed_file: ParsedFile,
693
+ known_scheme_names: set[str] | None = None,
694
+ **kwargs: Any,
695
+ ) -> list[ExtractedAuthDependency]:
696
+ """
697
+ Detect DRF authentication and permission requirements on views.
698
+
699
+ Covers:
700
+ - authentication_classes = [...] on APIView subclasses and mixins
701
+ - permission_classes = (...) on APIView subclasses and mixins
702
+ - @login_required / @permission_required decorators on FBVs
703
+ - Mixin classes that carry authentication_classes / permission_classes
704
+ (so ApiAuthMixin itself is emitted as a dependency)
705
+ """
706
+ deps: list[ExtractedAuthDependency] = []
707
+
708
+ # Auth and permission class names imported in this file
709
+ file_auth_classes, file_perm_classes = _auth_imports_in_file(parsed_file)
710
+
711
+ # Collect auth mixin/base class names defined in this file so we can
712
+ # recognise them as parents of view classes below.
713
+ local_auth_mixins: set[str] = set()
714
+
715
+ for cls in parsed_file.classes:
716
+ # Strategy A: class_variables approach (works when parser captures them)
717
+ has_auth_classes = "authentication_classes" in cls.class_variables
718
+ has_perm_classes = "permission_classes" in cls.class_variables
719
+
720
+ # Strategy B: class name heuristic — catches ApiAuthMixin, AuthMixin, etc.
721
+ # where the Python parser doesn't capture type-annotated class attributes
722
+ cls_low = cls.name.lower()
723
+ is_auth_mixin = (
724
+ (
725
+ "auth" in cls_low
726
+ or "permission" in cls_low
727
+ or "security" in cls_low
728
+ or "secure" in cls_low
729
+ )
730
+ and (
731
+ has_auth_classes
732
+ or has_perm_classes
733
+ or "mixin" in cls_low
734
+ or not cls.base_classes # pure mixin — no base classes
735
+ )
736
+ and (file_auth_classes or file_perm_classes)
737
+ )
738
+
739
+ # Strategy C: inherits from a known auth mixin/base in this file
740
+ base_names_low = {b.lower() for b in cls.base_classes}
741
+ inherits_auth = bool(local_auth_mixins & set(cls.base_classes)) or any(
742
+ "auth" in b or "permission" in b or "security" in b for b in base_names_low
743
+ )
744
+
745
+ if not (has_auth_classes or has_perm_classes or is_auth_mixin or inherits_auth):
746
+ continue
747
+
748
+ if is_auth_mixin:
749
+ local_auth_mixins.add(cls.name)
750
+
751
+ auth_classes = file_auth_classes if (has_auth_classes or is_auth_mixin) else []
752
+ perm_classes = file_perm_classes if (has_perm_classes or is_auth_mixin) else []
753
+ if inherits_auth and not auth_classes and not perm_classes:
754
+ auth_classes = file_auth_classes
755
+ perm_classes = file_perm_classes
756
+
757
+ # Map auth classes to scheme types
758
+ uses_schemes: list[str] = []
759
+ for ac in auth_classes:
760
+ if ac in _DRF_AUTH_CLASS_TO_SCHEME or any(
761
+ kw in ac.lower() for kw in _JWT_AUTH_CLASS_HINTS
762
+ ):
763
+ uses_schemes.append(ac)
764
+
765
+ # Map permission classes to role requirements
766
+ requires_roles: list[str] = []
767
+ for pc in perm_classes:
768
+ if pc in _PERMISSION_REQUIRES_AUTH:
769
+ requires_roles.append(pc)
770
+ elif pc not in _PERMISSION_ANONYMOUS:
771
+ # Custom permission class — record its name
772
+ requires_roles.append(pc)
773
+
774
+ loc = CodeLocation(file=parsed_file.path, line=cls.location.line if cls.location else 1)
775
+ deps.append(
776
+ ExtractedAuthDependency(
777
+ name=cls.name,
778
+ qualified_name=QualifiedName(
779
+ module=parsed_file.path.stem,
780
+ name=cls.name,
781
+ ),
782
+ location=loc,
783
+ dependency_type=AuthDependencyType.CLASS,
784
+ uses_schemes=uses_schemes,
785
+ requires_roles=requires_roles,
786
+ confidence=Confidence.HIGH,
787
+ )
788
+ )
789
+
790
+ # ── FBV decorators ───────────────────────────────────────────────────
791
+ for func in parsed_file.functions:
792
+ roles: list[str] = []
793
+ for dec in func.decorators:
794
+ if dec.name == "login_required":
795
+ roles.append("authenticated")
796
+ elif dec.name == "permission_required":
797
+ perm = dec.positional_args[0] if dec.positional_args else None
798
+ if perm:
799
+ roles.append(str(perm))
800
+
801
+ if roles:
802
+ loc = CodeLocation(
803
+ file=parsed_file.path, line=func.location.line if func.location else 1
804
+ )
805
+ deps.append(
806
+ ExtractedAuthDependency(
807
+ name=func.name,
808
+ qualified_name=QualifiedName(
809
+ module=parsed_file.path.stem,
810
+ name=func.name,
811
+ ),
812
+ location=loc,
813
+ dependency_type=AuthDependencyType.DECORATOR,
814
+ uses_schemes=[],
815
+ requires_roles=roles,
816
+ confidence=Confidence.HIGH,
817
+ )
818
+ )
819
+
820
+ return deps
821
+
822
+ def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
823
+ return []
824
+
825
+
826
+ def _auth_imports_in_file(parsed_file: ParsedFile) -> tuple[list[str], list[str]]:
827
+ """
828
+ Return (auth_class_names, permission_class_names) found in the file's imports.
829
+
830
+ The Python parser gives us class_variables names only, not their values.
831
+ When a class declares `authentication_classes = [...]`, the classes inside
832
+ are typically imported at the top of the file. We scan those imports and
833
+ classify each name as an auth class or a permission class.
834
+ """
835
+ auth_names: list[str] = []
836
+ perm_names: list[str] = []
837
+ seen: set[str] = set()
838
+
839
+ for imp in parsed_file.imports:
840
+ module = imp.module
841
+ is_drf_auth = module.startswith("rest_framework") and "authentication" in module
842
+ is_drf_perm = module.startswith("rest_framework") and "permission" in module
843
+ # Only count jwt imports from authentication modules, not from views modules
844
+ is_jwt_auth = any(kw in module for kw in ("jwt", "simplejwt", "rest_framework_jwt")) and (
845
+ "authentication" in module or "auth" in module.split(".")[-1].lower()
846
+ )
847
+
848
+ for name in imp.names:
849
+ if name in seen:
850
+ continue
851
+ seen.add(name)
852
+ low = name.lower()
853
+ # Exclude view/viewset classes — those are routing, not auth classes
854
+ if low.endswith("view") or low.endswith("viewset"):
855
+ continue
856
+ if name in _DRF_AUTH_CLASS_TO_SCHEME or is_drf_auth or is_jwt_auth:
857
+ if any(kw in low for kw in ("auth", "jwt", "token", "session", "basic")):
858
+ auth_names.append(name)
859
+ elif is_drf_perm or "permission" in low or name in _PERMISSION_REQUIRES_AUTH:
860
+ perm_names.append(name)
861
+ elif any(kw in low for kw in _JWT_AUTH_CLASS_HINTS):
862
+ auth_names.append(name)
863
+
864
+ return auth_names, perm_names
865
+
866
+
867
+ FrameworkPluginRegistry.register(DjangoPlugin())