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.
- apisec_code_bolt/__init__.py +42 -0
- apisec_code_bolt/__main__.py +11 -0
- apisec_code_bolt/analysis/__init__.py +96 -0
- apisec_code_bolt/analysis/analyzer.py +2309 -0
- apisec_code_bolt/analysis/binding_tracker.py +341 -0
- apisec_code_bolt/analysis/call_graph.py +1197 -0
- apisec_code_bolt/analysis/call_graph_types.py +332 -0
- apisec_code_bolt/analysis/call_resolver.py +988 -0
- apisec_code_bolt/analysis/capability_tagger.py +322 -0
- apisec_code_bolt/analysis/config_scanner.py +197 -0
- apisec_code_bolt/analysis/data_flow.py +1883 -0
- apisec_code_bolt/analysis/dependency_extractor.py +959 -0
- apisec_code_bolt/analysis/flow_analysis.py +1406 -0
- apisec_code_bolt/analysis/hof_catalog.py +61 -0
- apisec_code_bolt/analysis/integration_detector.py +1399 -0
- apisec_code_bolt/analysis/literal_scanner.py +300 -0
- apisec_code_bolt/analysis/path_normalizer.py +55 -0
- apisec_code_bolt/analysis/read_site_detector.py +310 -0
- apisec_code_bolt/analysis/request_patterns.py +162 -0
- apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
- apisec_code_bolt/analysis/sink_evidence.py +333 -0
- apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
- apisec_code_bolt/cli/__init__.py +5 -0
- apisec_code_bolt/cli/exit_codes.py +17 -0
- apisec_code_bolt/cli/main.py +1069 -0
- apisec_code_bolt/cloud/__init__.py +1 -0
- apisec_code_bolt/cloud/apisec_client.py +118 -0
- apisec_code_bolt/cloud/client.py +255 -0
- apisec_code_bolt/core/__init__.py +75 -0
- apisec_code_bolt/core/config.py +528 -0
- apisec_code_bolt/core/credentials.py +65 -0
- apisec_code_bolt/core/discovery.py +433 -0
- apisec_code_bolt/core/log_format.py +115 -0
- apisec_code_bolt/core/manifest.py +1009 -0
- apisec_code_bolt/core/repo.py +280 -0
- apisec_code_bolt/core/state.py +59 -0
- apisec_code_bolt/core/telemetry.py +451 -0
- apisec_code_bolt/core/types.py +587 -0
- apisec_code_bolt/fingerprinting/__init__.py +1 -0
- apisec_code_bolt/frameworks/__init__.py +29 -0
- apisec_code_bolt/frameworks/_jwt_common.py +50 -0
- apisec_code_bolt/frameworks/auth_helpers.py +437 -0
- apisec_code_bolt/frameworks/base.py +608 -0
- apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
- apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
- apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
- apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
- apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
- apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
- apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
- apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
- apisec_code_bolt/frameworks/java/__init__.py +6 -0
- apisec_code_bolt/frameworks/java/_annotations.py +167 -0
- apisec_code_bolt/frameworks/java/_constraints.py +128 -0
- apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
- apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
- apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
- apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
- apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
- apisec_code_bolt/frameworks/js/__init__.py +8 -0
- apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
- apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
- apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
- apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
- apisec_code_bolt/frameworks/python/__init__.py +19 -0
- apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
- apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
- apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
- apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
- apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
- apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
- apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
- apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
- apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
- apisec_code_bolt/parsing/__init__.py +62 -0
- apisec_code_bolt/parsing/base.py +554 -0
- apisec_code_bolt/parsing/csharp/__init__.py +5 -0
- apisec_code_bolt/parsing/csharp/language_services.py +203 -0
- apisec_code_bolt/parsing/csharp/literals.py +72 -0
- apisec_code_bolt/parsing/csharp/parser.py +1158 -0
- apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
- apisec_code_bolt/parsing/js/__init__.py +5 -0
- apisec_code_bolt/parsing/js/language_services.py +118 -0
- apisec_code_bolt/parsing/js/parser.py +622 -0
- apisec_code_bolt/parsing/jvm/__init__.py +7 -0
- apisec_code_bolt/parsing/jvm/language_services.py +270 -0
- apisec_code_bolt/parsing/jvm/parser.py +774 -0
- apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
- apisec_code_bolt/parsing/python/__init__.py +150 -0
- apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
- apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
- apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
- apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
- apisec_code_bolt/parsing/python/expression_utils.py +221 -0
- apisec_code_bolt/parsing/python/extraction_types.py +271 -0
- apisec_code_bolt/parsing/python/language_services.py +487 -0
- apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
- apisec_code_bolt/parsing/python/parser.py +719 -0
- apisec_code_bolt/parsing/python/path_resolver.py +576 -0
- apisec_code_bolt/parsing/python/router_registry.py +806 -0
- apisec_code_bolt/parsing/python/type_resolver.py +730 -0
- apisec_code_bolt/parsing/python/visitors.py +1544 -0
- apisec_code_bolt/parsing/services.py +544 -0
- apisec_code_bolt/query/__init__.py +1 -0
- apisec_code_bolt/query/ast_cache.py +182 -0
- apisec_code_bolt/query/executor.py +283 -0
- apisec_code_bolt/query/handlers.py +832 -0
- apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
- apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
- apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
- 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())
|