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,1390 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI framework plugin with clean abstractions.
|
|
3
|
+
|
|
4
|
+
This is the refactored FastAPI plugin that uses the abstract service protocols
|
|
5
|
+
from parsing.services instead of directly depending on Python-specific modules.
|
|
6
|
+
|
|
7
|
+
DESIGN PRINCIPLES:
|
|
8
|
+
1. Uses AnalysisContext for all service access (DRY)
|
|
9
|
+
2. Framework-specific logic is here, language-specific parsing is elsewhere
|
|
10
|
+
3. Depends only on abstract protocols, not concrete implementations
|
|
11
|
+
4. Can be tested with mock services
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import contextlib
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
20
|
+
|
|
21
|
+
from ....core.types import (
|
|
22
|
+
AnalysisNote,
|
|
23
|
+
AuthDependencyType,
|
|
24
|
+
AuthSchemeType,
|
|
25
|
+
CodeLocation,
|
|
26
|
+
Confidence,
|
|
27
|
+
Framework,
|
|
28
|
+
HttpMethod,
|
|
29
|
+
Language,
|
|
30
|
+
ParameterLocation,
|
|
31
|
+
QualifiedName,
|
|
32
|
+
)
|
|
33
|
+
from ....parsing.base import ParsedClass, ParsedDecorator, ParsedFile, ParsedFunction
|
|
34
|
+
from ....parsing.services import AnalysisContext
|
|
35
|
+
from ...base import (
|
|
36
|
+
BaseFrameworkPlugin,
|
|
37
|
+
ExtractedAuthDependency,
|
|
38
|
+
ExtractedAuthScheme,
|
|
39
|
+
ExtractedBody,
|
|
40
|
+
ExtractedDependency,
|
|
41
|
+
ExtractedJwtConfig,
|
|
42
|
+
ExtractedMiddleware,
|
|
43
|
+
ExtractedParameter,
|
|
44
|
+
ExtractedResponse,
|
|
45
|
+
ExtractedRoute,
|
|
46
|
+
FrameworkPluginRegistry,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from ....parsing.python.parameter_analyzer import AnalyzedParameter, ParameterAnalyzer
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# =============================================================================
|
|
54
|
+
# FastAPI Constants
|
|
55
|
+
# =============================================================================
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
FASTAPI_ROUTE_DECORATORS = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"get",
|
|
61
|
+
"post",
|
|
62
|
+
"put",
|
|
63
|
+
"patch",
|
|
64
|
+
"delete",
|
|
65
|
+
"head",
|
|
66
|
+
"options",
|
|
67
|
+
"trace",
|
|
68
|
+
"api_route",
|
|
69
|
+
"websocket",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
FASTAPI_IMPORTS = frozenset(
|
|
74
|
+
{
|
|
75
|
+
"fastapi",
|
|
76
|
+
"fastapi.FastAPI",
|
|
77
|
+
"fastapi.APIRouter",
|
|
78
|
+
"fastapi.Depends",
|
|
79
|
+
"fastapi.Query",
|
|
80
|
+
"fastapi.Path",
|
|
81
|
+
"fastapi.Body",
|
|
82
|
+
"fastapi.Header",
|
|
83
|
+
"fastapi.Cookie",
|
|
84
|
+
"fastapi.Form",
|
|
85
|
+
"fastapi.File",
|
|
86
|
+
"fastapi.security",
|
|
87
|
+
"starlette",
|
|
88
|
+
"starlette.middleware",
|
|
89
|
+
}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
HTTP_METHOD_MAP = {
|
|
93
|
+
"get": HttpMethod.GET,
|
|
94
|
+
"post": HttpMethod.POST,
|
|
95
|
+
"put": HttpMethod.PUT,
|
|
96
|
+
"patch": HttpMethod.PATCH,
|
|
97
|
+
"delete": HttpMethod.DELETE,
|
|
98
|
+
"head": HttpMethod.HEAD,
|
|
99
|
+
"options": HttpMethod.OPTIONS,
|
|
100
|
+
"trace": HttpMethod.TRACE,
|
|
101
|
+
"websocket": HttpMethod.WEBSOCKET,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
AUTH_SCHEME_PATTERNS = {
|
|
105
|
+
"OAuth2PasswordBearer": AuthSchemeType.OAUTH2_PASSWORD,
|
|
106
|
+
"OAuth2AuthorizationCodeBearer": AuthSchemeType.OAUTH2_AUTHORIZATION_CODE,
|
|
107
|
+
"HTTPBasic": AuthSchemeType.HTTP_BASIC,
|
|
108
|
+
"HTTPBearer": AuthSchemeType.JWT_BEARER,
|
|
109
|
+
"APIKeyHeader": AuthSchemeType.API_KEY_HEADER,
|
|
110
|
+
"APIKeyQuery": AuthSchemeType.API_KEY_QUERY,
|
|
111
|
+
"APIKeyCookie": AuthSchemeType.API_KEY_COOKIE,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
PARAM_LOCATION_MAP = {
|
|
115
|
+
"path": ParameterLocation.PATH,
|
|
116
|
+
"query": ParameterLocation.QUERY,
|
|
117
|
+
"header": ParameterLocation.HEADER,
|
|
118
|
+
"cookie": ParameterLocation.COOKIE,
|
|
119
|
+
"body": ParameterLocation.BODY,
|
|
120
|
+
"form": ParameterLocation.FORM,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
KNOWN_FORM_TYPES: dict[str, tuple[str, list[str]]] = {
|
|
124
|
+
"OAuth2PasswordRequestForm": (
|
|
125
|
+
"application/x-www-form-urlencoded",
|
|
126
|
+
["username", "password", "scope", "client_id", "client_secret"],
|
|
127
|
+
),
|
|
128
|
+
"OAuth2PasswordRequestFormStrict": (
|
|
129
|
+
"application/x-www-form-urlencoded",
|
|
130
|
+
["username", "password", "scope"],
|
|
131
|
+
),
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# =============================================================================
|
|
136
|
+
# FastAPI Plugin
|
|
137
|
+
# =============================================================================
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class FastAPIPlugin(BaseFrameworkPlugin):
|
|
141
|
+
"""
|
|
142
|
+
FastAPI framework plugin using clean abstractions.
|
|
143
|
+
|
|
144
|
+
This plugin extracts:
|
|
145
|
+
- Routes from decorators, dynamic registration, and CBVs
|
|
146
|
+
- Dependencies (Depends)
|
|
147
|
+
- Authentication schemes and dependencies
|
|
148
|
+
- Middleware
|
|
149
|
+
|
|
150
|
+
It receives an AnalysisContext and uses abstract service protocols
|
|
151
|
+
instead of Python-specific implementations directly.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
FRAMEWORK: ClassVar[Framework] = Framework.FASTAPI
|
|
155
|
+
LANGUAGE: ClassVar[Language] = Language.PYTHON
|
|
156
|
+
DETECTION_IMPORTS: ClassVar[frozenset[str]] = FASTAPI_IMPORTS
|
|
157
|
+
|
|
158
|
+
def __init__(self) -> None:
|
|
159
|
+
"""Initialize the plugin."""
|
|
160
|
+
# Per-file tracking
|
|
161
|
+
self._app_vars: dict[Path, set[str]] = {}
|
|
162
|
+
self._router_vars: dict[Path, set[str]] = {}
|
|
163
|
+
self._import_aliases: dict[Path, dict[str, str]] = {}
|
|
164
|
+
self._known_models: dict[Path, set[str]] = {}
|
|
165
|
+
self._type_aliases: dict[Path, dict[str, str]] = {}
|
|
166
|
+
|
|
167
|
+
# Analysis notes for the current file
|
|
168
|
+
self._notes: list[AnalysisNote] = []
|
|
169
|
+
|
|
170
|
+
# Current analysis context (set per extraction call)
|
|
171
|
+
self._context: AnalysisContext | None = None
|
|
172
|
+
|
|
173
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
174
|
+
"""Detect if FastAPI is used in this file."""
|
|
175
|
+
for imp in parsed_file.imports:
|
|
176
|
+
if imp.module in {"fastapi", "starlette"}:
|
|
177
|
+
return True
|
|
178
|
+
if imp.module and imp.module.startswith(("fastapi.", "starlette.")):
|
|
179
|
+
return True
|
|
180
|
+
for name in imp.names:
|
|
181
|
+
if name in {"FastAPI", "APIRouter", "Depends"}:
|
|
182
|
+
return True
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
# =========================================================================
|
|
186
|
+
# Main Extraction Entry Points
|
|
187
|
+
# =========================================================================
|
|
188
|
+
|
|
189
|
+
def extract_routes(
|
|
190
|
+
self,
|
|
191
|
+
parsed_file: ParsedFile,
|
|
192
|
+
context: AnalysisContext | None = None,
|
|
193
|
+
) -> list[ExtractedRoute]:
|
|
194
|
+
"""
|
|
195
|
+
Extract HTTP routes from FastAPI decorators and dynamic registrations.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
parsed_file: The parsed source file
|
|
199
|
+
context: Analysis context with services (type resolver, path resolver, etc.)
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of extracted routes
|
|
203
|
+
"""
|
|
204
|
+
self._context = context
|
|
205
|
+
self._notes = []
|
|
206
|
+
routes: list[ExtractedRoute] = []
|
|
207
|
+
file_path = parsed_file.path
|
|
208
|
+
|
|
209
|
+
# Initialize per-file tracking
|
|
210
|
+
self._app_vars[file_path] = set()
|
|
211
|
+
self._router_vars[file_path] = set()
|
|
212
|
+
self._import_aliases[file_path] = {}
|
|
213
|
+
self._known_models[file_path] = set()
|
|
214
|
+
self._type_aliases[file_path] = {}
|
|
215
|
+
|
|
216
|
+
# Step 1: Build context from file
|
|
217
|
+
self._build_import_aliases(parsed_file)
|
|
218
|
+
self._identify_app_routers(parsed_file)
|
|
219
|
+
self._register_models(parsed_file)
|
|
220
|
+
self._collect_type_aliases(parsed_file)
|
|
221
|
+
|
|
222
|
+
# Step 2: Create parameter analyzer
|
|
223
|
+
param_analyzer = self._create_parameter_analyzer(file_path)
|
|
224
|
+
|
|
225
|
+
# Step 3: Extract decorator-based routes
|
|
226
|
+
for func in parsed_file.functions:
|
|
227
|
+
route = self._extract_route_from_function(func, parsed_file, param_analyzer)
|
|
228
|
+
if route:
|
|
229
|
+
route = self._apply_router_prefix(route, file_path)
|
|
230
|
+
route = self._apply_router_dependencies(route, file_path)
|
|
231
|
+
route = self._reconcile_path_params(route)
|
|
232
|
+
routes.append(route)
|
|
233
|
+
|
|
234
|
+
# Step 4: Extract routes from class methods
|
|
235
|
+
for cls in parsed_file.classes:
|
|
236
|
+
for method in cls.methods:
|
|
237
|
+
route = self._extract_route_from_function(method, parsed_file, param_analyzer)
|
|
238
|
+
if route:
|
|
239
|
+
route = self._apply_router_prefix(route, file_path)
|
|
240
|
+
route = self._apply_router_dependencies(route, file_path)
|
|
241
|
+
route = self._reconcile_path_params(route)
|
|
242
|
+
routes.append(route)
|
|
243
|
+
|
|
244
|
+
# Step 5: Extract dynamic routes (add_api_route, route tables)
|
|
245
|
+
dynamic_routes = self._extract_dynamic_routes(parsed_file, param_analyzer)
|
|
246
|
+
routes.extend(dynamic_routes)
|
|
247
|
+
|
|
248
|
+
# Step 6: Extract CBV routes
|
|
249
|
+
cbv_routes = self._extract_cbv_routes(parsed_file, param_analyzer)
|
|
250
|
+
routes.extend(cbv_routes)
|
|
251
|
+
|
|
252
|
+
return routes
|
|
253
|
+
|
|
254
|
+
def extract_dependencies(
|
|
255
|
+
self,
|
|
256
|
+
parsed_file: ParsedFile,
|
|
257
|
+
context: AnalysisContext | None = None,
|
|
258
|
+
) -> list[ExtractedDependency]:
|
|
259
|
+
"""Extract dependency definitions."""
|
|
260
|
+
self._context = context
|
|
261
|
+
dependencies: list[ExtractedDependency] = []
|
|
262
|
+
|
|
263
|
+
for func in parsed_file.functions:
|
|
264
|
+
if self._is_dependency_function(func, parsed_file):
|
|
265
|
+
dep = self._create_dependency(func, parsed_file.path)
|
|
266
|
+
dependencies.append(dep)
|
|
267
|
+
|
|
268
|
+
return dependencies
|
|
269
|
+
|
|
270
|
+
def extract_auth_schemes(
|
|
271
|
+
self,
|
|
272
|
+
parsed_file: ParsedFile,
|
|
273
|
+
context: AnalysisContext | None = None,
|
|
274
|
+
) -> list[ExtractedAuthScheme]:
|
|
275
|
+
"""Extract OAuth2/JWT/API key authentication schemes."""
|
|
276
|
+
self._context = context
|
|
277
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
278
|
+
|
|
279
|
+
for assign in parsed_file.assignments:
|
|
280
|
+
scheme = self._extract_auth_scheme_from_assignment(assign, parsed_file.path)
|
|
281
|
+
if scheme:
|
|
282
|
+
schemes.append(scheme)
|
|
283
|
+
|
|
284
|
+
schemes.extend(self._extract_auth_schemes_from_classes(parsed_file, parsed_file.path))
|
|
285
|
+
|
|
286
|
+
seen_names: set[str] = set()
|
|
287
|
+
deduped: list[ExtractedAuthScheme] = []
|
|
288
|
+
for s in schemes:
|
|
289
|
+
if s.name not in seen_names:
|
|
290
|
+
seen_names.add(s.name)
|
|
291
|
+
deduped.append(s)
|
|
292
|
+
return deduped
|
|
293
|
+
|
|
294
|
+
def extract_auth_dependencies(
|
|
295
|
+
self,
|
|
296
|
+
parsed_file: ParsedFile,
|
|
297
|
+
context: AnalysisContext | None = None,
|
|
298
|
+
known_scheme_names: set[str] | None = None,
|
|
299
|
+
all_project_depends_names: set[str] | None = None,
|
|
300
|
+
) -> list[ExtractedAuthDependency]:
|
|
301
|
+
"""Extract authentication dependency functions.
|
|
302
|
+
|
|
303
|
+
``all_project_depends_names`` is the set of all function names that
|
|
304
|
+
appear as arguments to ``Depends()`` *anywhere* in the project (built
|
|
305
|
+
in a pre-pass by ``ProjectAnalyzer._extract_auth_data``). When
|
|
306
|
+
provided it allows detecting auth deps whose definition lives in a
|
|
307
|
+
separate file from where they are used (the standard FastAPI pattern
|
|
308
|
+
of ``deps.py`` + ``routers/*.py``).
|
|
309
|
+
"""
|
|
310
|
+
self._context = context
|
|
311
|
+
auth_deps: list[ExtractedAuthDependency] = []
|
|
312
|
+
|
|
313
|
+
for func in parsed_file.functions:
|
|
314
|
+
auth_dep = self._extract_auth_dependency(
|
|
315
|
+
func,
|
|
316
|
+
parsed_file,
|
|
317
|
+
known_scheme_names or set(),
|
|
318
|
+
all_project_depends_names or set(),
|
|
319
|
+
)
|
|
320
|
+
if auth_dep:
|
|
321
|
+
auth_deps.append(auth_dep)
|
|
322
|
+
|
|
323
|
+
return auth_deps
|
|
324
|
+
|
|
325
|
+
def extract_jwt_config(
|
|
326
|
+
self,
|
|
327
|
+
parsed_file: ParsedFile,
|
|
328
|
+
context: AnalysisContext | None = None,
|
|
329
|
+
) -> ExtractedJwtConfig | None:
|
|
330
|
+
"""Extract JWT configuration.
|
|
331
|
+
|
|
332
|
+
Detects library, algorithms, validation flags (signature, expiry,
|
|
333
|
+
issuer, audience), and secret source (env / config / hardcoded).
|
|
334
|
+
|
|
335
|
+
pyjwt / python-jose defaults: validates signature + expiry unless
|
|
336
|
+
explicitly disabled via options={"verify_signature": False, ...}.
|
|
337
|
+
"""
|
|
338
|
+
import ast
|
|
339
|
+
|
|
340
|
+
self._context = context
|
|
341
|
+
jwt_config = ExtractedJwtConfig(library="", detected=False)
|
|
342
|
+
|
|
343
|
+
jwt_libraries = {"jose", "python-jose", "pyjwt", "jwt", "authlib"}
|
|
344
|
+
for imp in parsed_file.imports:
|
|
345
|
+
if imp.module in jwt_libraries or any(
|
|
346
|
+
imp.module.startswith(f"{lib}.") for lib in jwt_libraries
|
|
347
|
+
):
|
|
348
|
+
jwt_config.detected = True
|
|
349
|
+
jwt_config.library = imp.module.split(".")[0]
|
|
350
|
+
break
|
|
351
|
+
|
|
352
|
+
if not jwt_config.detected:
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
# pyjwt / python-jose: validates signature + expiry by default
|
|
356
|
+
# unless options= explicitly disables them.
|
|
357
|
+
jwt_config.validates_signature = True
|
|
358
|
+
jwt_config.validates_expiry = True
|
|
359
|
+
|
|
360
|
+
# Build a map of module-level string literals so we can detect
|
|
361
|
+
# SECRET = "hardcoded-key" followed by jwt.decode(token, SECRET, ...).
|
|
362
|
+
_module_literals: dict[str, str] = {}
|
|
363
|
+
for assign in parsed_file.assignments:
|
|
364
|
+
if assign.in_function is not None:
|
|
365
|
+
continue
|
|
366
|
+
val = (assign.source_value or "").strip()
|
|
367
|
+
if (val.startswith('"') and val.endswith('"')) or (
|
|
368
|
+
val.startswith("'") and val.endswith("'")
|
|
369
|
+
):
|
|
370
|
+
with contextlib.suppress(ValueError, SyntaxError):
|
|
371
|
+
_module_literals[assign.target] = ast.literal_eval(val)
|
|
372
|
+
|
|
373
|
+
for call in parsed_file.call_sites:
|
|
374
|
+
callee_lower = call.callee_name.lower()
|
|
375
|
+
if "decode" not in callee_lower:
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
jwt_config.locations.append(call.location)
|
|
379
|
+
|
|
380
|
+
for arg in call.arguments:
|
|
381
|
+
# algorithms= → algorithm list.
|
|
382
|
+
# The Python parser stores list literals as their source-code
|
|
383
|
+
# string (e.g. '["HS256"]') with literal_type="list", so we
|
|
384
|
+
# must parse them with ast.literal_eval rather than
|
|
385
|
+
# isinstance(val, list).
|
|
386
|
+
if arg.name == "algorithms" and arg.literal_value is not None:
|
|
387
|
+
self._collect_py_algorithms(arg.literal_value, jwt_config)
|
|
388
|
+
|
|
389
|
+
# key / secret argument (position 1 in jwt.decode(token, key, ...))
|
|
390
|
+
if arg.position == 1 and jwt_config.secret_source is None:
|
|
391
|
+
if arg.is_literal and isinstance(arg.literal_value, str):
|
|
392
|
+
jwt_config.secret_source = "hardcoded"
|
|
393
|
+
elif arg.is_variable and arg.variable_name:
|
|
394
|
+
name_lc = arg.variable_name.lower()
|
|
395
|
+
if any(kw in name_lc for kw in ("getenv", "environ", "env_")):
|
|
396
|
+
jwt_config.secret_source = "env"
|
|
397
|
+
jwt_config.secret_name = arg.variable_name
|
|
398
|
+
elif any(kw in name_lc for kw in ("settings", "config", "cfg", "conf")):
|
|
399
|
+
jwt_config.secret_source = "config"
|
|
400
|
+
jwt_config.secret_name = arg.variable_name
|
|
401
|
+
elif arg.variable_name in _module_literals:
|
|
402
|
+
# Module-level string constant → treat as hardcoded
|
|
403
|
+
jwt_config.secret_source = "hardcoded"
|
|
404
|
+
|
|
405
|
+
# options= dict → explicit verification overrides.
|
|
406
|
+
# Stored as source-code string '{"verify_signature": False}';
|
|
407
|
+
# parse with ast.literal_eval.
|
|
408
|
+
if arg.name == "options" and arg.literal_value is not None:
|
|
409
|
+
opts = self._parse_dict_arg(arg.literal_value)
|
|
410
|
+
if opts is not None:
|
|
411
|
+
if opts.get("verify_signature") is False:
|
|
412
|
+
jwt_config.validates_signature = False
|
|
413
|
+
if opts.get("verify_exp") is False:
|
|
414
|
+
jwt_config.validates_expiry = False
|
|
415
|
+
if opts.get("verify_iss") is False:
|
|
416
|
+
jwt_config.validates_issuer = False
|
|
417
|
+
if opts.get("verify_aud") is False:
|
|
418
|
+
jwt_config.validates_audience = False
|
|
419
|
+
|
|
420
|
+
# issuer= / audience= kwargs in jose / authlib
|
|
421
|
+
if arg.name in ("issuer", "iss") and arg.literal_value:
|
|
422
|
+
jwt_config.validates_issuer = True
|
|
423
|
+
if arg.name in ("audience", "aud") and arg.literal_value:
|
|
424
|
+
jwt_config.validates_audience = True
|
|
425
|
+
|
|
426
|
+
return jwt_config
|
|
427
|
+
|
|
428
|
+
@staticmethod
|
|
429
|
+
def _collect_py_algorithms(val: Any, jwt_config: ExtractedJwtConfig) -> None:
|
|
430
|
+
"""Append algorithm names from an ``algorithms=`` call argument.
|
|
431
|
+
|
|
432
|
+
The Python parser stores list literals as their source-code string
|
|
433
|
+
(``'["HS256"]'``) with ``literal_type="list"``, so we attempt
|
|
434
|
+
``ast.literal_eval`` before falling back to a regex scan.
|
|
435
|
+
"""
|
|
436
|
+
import ast
|
|
437
|
+
|
|
438
|
+
if isinstance(val, list):
|
|
439
|
+
jwt_config.algorithms.extend(v for v in val if isinstance(v, str))
|
|
440
|
+
return
|
|
441
|
+
if not isinstance(val, str):
|
|
442
|
+
return
|
|
443
|
+
stripped = val.strip()
|
|
444
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
445
|
+
with contextlib.suppress(ValueError, SyntaxError):
|
|
446
|
+
items = ast.literal_eval(stripped)
|
|
447
|
+
if isinstance(items, list):
|
|
448
|
+
jwt_config.algorithms.extend(v for v in items if isinstance(v, str))
|
|
449
|
+
return
|
|
450
|
+
# Regex fallback: grab any quoted token inside the brackets
|
|
451
|
+
for name in re.findall(r'"([A-Za-z0-9_-]+)"', stripped):
|
|
452
|
+
jwt_config.algorithms.append(name)
|
|
453
|
+
else:
|
|
454
|
+
jwt_config.algorithms.append(stripped)
|
|
455
|
+
|
|
456
|
+
@staticmethod
|
|
457
|
+
def _parse_dict_arg(val: Any) -> dict | None:
|
|
458
|
+
"""Parse a dict from a ``literal_value`` that may be a string or dict.
|
|
459
|
+
|
|
460
|
+
The Python parser stores dict literals as source-code strings
|
|
461
|
+
(e.g. ``'{"verify_signature": False}'``). ``ast.literal_eval``
|
|
462
|
+
handles this transparently.
|
|
463
|
+
"""
|
|
464
|
+
import ast
|
|
465
|
+
|
|
466
|
+
if isinstance(val, dict):
|
|
467
|
+
return val
|
|
468
|
+
if isinstance(val, str):
|
|
469
|
+
with contextlib.suppress(ValueError, SyntaxError):
|
|
470
|
+
result = ast.literal_eval(val.strip())
|
|
471
|
+
if isinstance(result, dict):
|
|
472
|
+
return result
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
def extract_middleware(
|
|
476
|
+
self,
|
|
477
|
+
parsed_file: ParsedFile,
|
|
478
|
+
context: AnalysisContext | None = None,
|
|
479
|
+
) -> list[ExtractedMiddleware]:
|
|
480
|
+
"""Extract middleware definitions."""
|
|
481
|
+
self._context = context
|
|
482
|
+
middleware: list[ExtractedMiddleware] = []
|
|
483
|
+
|
|
484
|
+
for call in parsed_file.call_sites:
|
|
485
|
+
if call.callee_name.endswith("add_middleware"):
|
|
486
|
+
mw = self._extract_middleware_from_call(call, parsed_file.path)
|
|
487
|
+
if mw:
|
|
488
|
+
middleware.append(mw)
|
|
489
|
+
|
|
490
|
+
for func in parsed_file.functions:
|
|
491
|
+
mw = self._extract_middleware_from_decorator(func, parsed_file.path)
|
|
492
|
+
if mw:
|
|
493
|
+
middleware.append(mw)
|
|
494
|
+
|
|
495
|
+
return middleware
|
|
496
|
+
|
|
497
|
+
# =========================================================================
|
|
498
|
+
# Initialization Helpers
|
|
499
|
+
# =========================================================================
|
|
500
|
+
|
|
501
|
+
def _build_import_aliases(self, parsed_file: ParsedFile) -> None:
|
|
502
|
+
"""Build import alias mapping."""
|
|
503
|
+
file_path = parsed_file.path
|
|
504
|
+
aliases = {}
|
|
505
|
+
|
|
506
|
+
for imp in parsed_file.imports:
|
|
507
|
+
if imp.is_from_import:
|
|
508
|
+
for name in imp.names:
|
|
509
|
+
if imp.alias and len(imp.names) == 1:
|
|
510
|
+
aliases[imp.alias] = name
|
|
511
|
+
else:
|
|
512
|
+
if imp.alias:
|
|
513
|
+
aliases[imp.alias] = imp.module.split(".")[-1]
|
|
514
|
+
|
|
515
|
+
self._import_aliases[file_path] = aliases
|
|
516
|
+
|
|
517
|
+
def _identify_app_routers(self, parsed_file: ParsedFile) -> None:
|
|
518
|
+
"""Identify FastAPI and APIRouter variables."""
|
|
519
|
+
file_path = parsed_file.path
|
|
520
|
+
|
|
521
|
+
for assign in parsed_file.assignments:
|
|
522
|
+
if assign.source_type == "call":
|
|
523
|
+
called = assign.source_call or ""
|
|
524
|
+
resolved = self._resolve_call_name(called, file_path)
|
|
525
|
+
|
|
526
|
+
if resolved in {"FastAPI", "fastapi.FastAPI"}:
|
|
527
|
+
self._app_vars[file_path].add(assign.target)
|
|
528
|
+
elif resolved in {"APIRouter", "fastapi.APIRouter"}:
|
|
529
|
+
self._router_vars[file_path].add(assign.target)
|
|
530
|
+
|
|
531
|
+
def _resolve_call_name(self, name: str, file_path: Path) -> str:
|
|
532
|
+
"""Resolve a call name using import aliases."""
|
|
533
|
+
aliases = self._import_aliases.get(file_path, {})
|
|
534
|
+
parts = name.split(".")
|
|
535
|
+
if parts[0] in aliases:
|
|
536
|
+
parts[0] = aliases[parts[0]]
|
|
537
|
+
return ".".join(parts)
|
|
538
|
+
return name
|
|
539
|
+
|
|
540
|
+
def _register_models(self, parsed_file: ParsedFile) -> None:
|
|
541
|
+
"""Register known model types for the file."""
|
|
542
|
+
file_path = parsed_file.path
|
|
543
|
+
models = set()
|
|
544
|
+
|
|
545
|
+
# Get models from file
|
|
546
|
+
for cls in parsed_file.classes:
|
|
547
|
+
if cls.is_pydantic_model or self._is_pydantic_class(cls):
|
|
548
|
+
models.add(cls.name)
|
|
549
|
+
models.add(cls.qualified_name.full)
|
|
550
|
+
|
|
551
|
+
# Get models from type resolver via context
|
|
552
|
+
if self._context and self._context.type_resolver:
|
|
553
|
+
all_models = self._context.type_resolver.get_all_models()
|
|
554
|
+
|
|
555
|
+
# Build a set of simple names from qualified names for fast suffix lookup
|
|
556
|
+
simple_name_index: dict[str, list[str]] = {}
|
|
557
|
+
for qname in all_models:
|
|
558
|
+
simple = qname.rsplit(".", 1)[-1]
|
|
559
|
+
simple_name_index.setdefault(simple, []).append(qname)
|
|
560
|
+
|
|
561
|
+
for qname in all_models:
|
|
562
|
+
models.add(qname)
|
|
563
|
+
|
|
564
|
+
# Resolve simple names through this file's imports so that
|
|
565
|
+
# `from schemas import GetTestSchema` makes "GetTestSchema"
|
|
566
|
+
# recognisable as a known model in this file's scope.
|
|
567
|
+
for imp in parsed_file.imports:
|
|
568
|
+
if imp.is_from_import:
|
|
569
|
+
for imported_name in imp.names:
|
|
570
|
+
if imported_name in simple_name_index:
|
|
571
|
+
models.add(imported_name)
|
|
572
|
+
else:
|
|
573
|
+
# `import schemas` — the module name itself could match
|
|
574
|
+
module_name = imp.alias or imp.module
|
|
575
|
+
if module_name in simple_name_index:
|
|
576
|
+
models.add(module_name)
|
|
577
|
+
|
|
578
|
+
self._known_models[file_path] = models
|
|
579
|
+
|
|
580
|
+
def _is_pydantic_class(self, cls: ParsedClass) -> bool:
|
|
581
|
+
"""Check if a class is a Pydantic model."""
|
|
582
|
+
pydantic_bases = {"BaseModel", "BaseSettings", "pydantic.BaseModel"}
|
|
583
|
+
return any(base in pydantic_bases for base in cls.base_classes)
|
|
584
|
+
|
|
585
|
+
def _collect_type_aliases(self, parsed_file: ParsedFile) -> None:
|
|
586
|
+
"""Collect type aliases whose RHS is ``Annotated[T, Depends(...)]`` or similar.
|
|
587
|
+
|
|
588
|
+
These are common in FastAPI projects::
|
|
589
|
+
|
|
590
|
+
SessionDep = Annotated[Session, Depends(get_db)]
|
|
591
|
+
CurrentUser: TypeAlias = Annotated[User, Depends(get_current_user)]
|
|
592
|
+
|
|
593
|
+
We store ``alias_name -> rhs_text`` so that
|
|
594
|
+
:class:`TypeAnnotationAnalyzer` can expand them before parsing.
|
|
595
|
+
"""
|
|
596
|
+
file_path = parsed_file.path
|
|
597
|
+
aliases: dict[str, str] = {}
|
|
598
|
+
dependency_keywords = ("Depends(", "Security(")
|
|
599
|
+
|
|
600
|
+
for assign in parsed_file.assignments:
|
|
601
|
+
if assign.in_function is not None:
|
|
602
|
+
continue
|
|
603
|
+
rhs = assign.source_value or ""
|
|
604
|
+
if "Annotated[" in rhs and any(kw in rhs for kw in dependency_keywords):
|
|
605
|
+
aliases[assign.target] = rhs
|
|
606
|
+
|
|
607
|
+
if self._context and self._context.all_parsed_files:
|
|
608
|
+
for other_file in self._context.all_parsed_files:
|
|
609
|
+
if other_file.path == file_path:
|
|
610
|
+
continue
|
|
611
|
+
for assign in other_file.assignments:
|
|
612
|
+
if assign.in_function is not None:
|
|
613
|
+
continue
|
|
614
|
+
rhs = assign.source_value or ""
|
|
615
|
+
if "Annotated[" not in rhs:
|
|
616
|
+
continue
|
|
617
|
+
if not any(kw in rhs for kw in dependency_keywords):
|
|
618
|
+
continue
|
|
619
|
+
exported_name = assign.target
|
|
620
|
+
for imp in parsed_file.imports:
|
|
621
|
+
if imp.is_from_import and exported_name in imp.names:
|
|
622
|
+
aliases[exported_name] = rhs
|
|
623
|
+
break
|
|
624
|
+
|
|
625
|
+
self._type_aliases[file_path] = aliases
|
|
626
|
+
|
|
627
|
+
def _create_parameter_analyzer(self, file_path: Path) -> ParameterAnalyzer:
|
|
628
|
+
"""Create a parameter analyzer with current context."""
|
|
629
|
+
from ....parsing.python.parameter_analyzer import ParameterAnalyzer
|
|
630
|
+
|
|
631
|
+
return ParameterAnalyzer(
|
|
632
|
+
import_aliases=self._import_aliases.get(file_path, {}),
|
|
633
|
+
known_models=self._known_models.get(file_path, set()),
|
|
634
|
+
type_aliases=self._type_aliases.get(file_path, {}),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# =========================================================================
|
|
638
|
+
# Route Extraction
|
|
639
|
+
# =========================================================================
|
|
640
|
+
|
|
641
|
+
def _extract_route_from_function(
|
|
642
|
+
self,
|
|
643
|
+
func: ParsedFunction,
|
|
644
|
+
parsed_file: ParsedFile,
|
|
645
|
+
param_analyzer: ParameterAnalyzer,
|
|
646
|
+
) -> ExtractedRoute | None:
|
|
647
|
+
"""Extract route from a function's decorators."""
|
|
648
|
+
for dec in func.decorators:
|
|
649
|
+
route = self._parse_route_decorator(dec, func, parsed_file, param_analyzer)
|
|
650
|
+
if route:
|
|
651
|
+
return route
|
|
652
|
+
return None
|
|
653
|
+
|
|
654
|
+
def _parse_route_decorator(
|
|
655
|
+
self,
|
|
656
|
+
decorator: ParsedDecorator,
|
|
657
|
+
func: ParsedFunction,
|
|
658
|
+
parsed_file: ParsedFile,
|
|
659
|
+
param_analyzer: ParameterAnalyzer,
|
|
660
|
+
) -> ExtractedRoute | None:
|
|
661
|
+
"""Parse a route decorator into ExtractedRoute."""
|
|
662
|
+
file_path = parsed_file.path
|
|
663
|
+
dec_name = decorator.name.lower()
|
|
664
|
+
full_name = decorator.qualified_name.full if decorator.qualified_name else decorator.name
|
|
665
|
+
|
|
666
|
+
http_method = None
|
|
667
|
+
router_name = None
|
|
668
|
+
|
|
669
|
+
# Direct decorator
|
|
670
|
+
if dec_name in FASTAPI_ROUTE_DECORATORS:
|
|
671
|
+
http_method = HTTP_METHOD_MAP.get(dec_name)
|
|
672
|
+
|
|
673
|
+
# App/router method
|
|
674
|
+
parts = full_name.split(".")
|
|
675
|
+
if len(parts) >= 2:
|
|
676
|
+
var_name = parts[-2]
|
|
677
|
+
method_name = parts[-1].lower()
|
|
678
|
+
|
|
679
|
+
if method_name in FASTAPI_ROUTE_DECORATORS:
|
|
680
|
+
app_vars = self._app_vars.get(file_path, set())
|
|
681
|
+
router_vars = self._router_vars.get(file_path, set())
|
|
682
|
+
|
|
683
|
+
if var_name in app_vars or var_name in router_vars:
|
|
684
|
+
http_method = HTTP_METHOD_MAP.get(method_name)
|
|
685
|
+
router_name = var_name
|
|
686
|
+
|
|
687
|
+
if not http_method:
|
|
688
|
+
return None
|
|
689
|
+
|
|
690
|
+
# Extract path
|
|
691
|
+
path = self._extract_route_path(decorator)
|
|
692
|
+
|
|
693
|
+
# Resolve computed path
|
|
694
|
+
path = self._resolve_computed_path(path, file_path)
|
|
695
|
+
|
|
696
|
+
# Analyze parameters
|
|
697
|
+
analyzed_params = param_analyzer.analyze_function_params(func.parameters, route_path=path)
|
|
698
|
+
|
|
699
|
+
# Convert to extracted parameters
|
|
700
|
+
path_params, query_params, header_params, cookie_params, body, deps = (
|
|
701
|
+
self._convert_analyzed_params(analyzed_params, parsed_file)
|
|
702
|
+
)
|
|
703
|
+
|
|
704
|
+
# Synthesize path params from the URL template that aren't in the handler
|
|
705
|
+
# signature (e.g. {organization} defined at the router prefix level).
|
|
706
|
+
template_param_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", path))
|
|
707
|
+
signature_param_names = {p.name for p in path_params}
|
|
708
|
+
for tpl_name in sorted(template_param_names - signature_param_names):
|
|
709
|
+
path_params.insert(
|
|
710
|
+
0,
|
|
711
|
+
ExtractedParameter(
|
|
712
|
+
name=tpl_name,
|
|
713
|
+
location=ParameterLocation.PATH,
|
|
714
|
+
type_annotation="str",
|
|
715
|
+
required=True,
|
|
716
|
+
constraints={"source": "router_prefix"},
|
|
717
|
+
),
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
# Extract decorator-level dependencies (e.g. dependencies=[Depends(fn)])
|
|
721
|
+
decorator_deps_raw = decorator.arguments.get("dependencies")
|
|
722
|
+
if decorator_deps_raw:
|
|
723
|
+
deps.extend(self._extract_decorator_dependencies(decorator_deps_raw))
|
|
724
|
+
|
|
725
|
+
# Get metadata
|
|
726
|
+
tags = decorator.arguments.get("tags", [])
|
|
727
|
+
if isinstance(tags, str):
|
|
728
|
+
tags = [tags]
|
|
729
|
+
|
|
730
|
+
# Calculate confidence
|
|
731
|
+
confidence = Confidence.HIGH
|
|
732
|
+
if any(p.location_confidence < 0.5 for p in analyzed_params):
|
|
733
|
+
confidence = Confidence.MEDIUM
|
|
734
|
+
|
|
735
|
+
return ExtractedRoute(
|
|
736
|
+
method=http_method,
|
|
737
|
+
path=path,
|
|
738
|
+
handler_function=func.qualified_name,
|
|
739
|
+
handler_location=func.location,
|
|
740
|
+
path_params=path_params,
|
|
741
|
+
query_params=query_params,
|
|
742
|
+
header_params=header_params,
|
|
743
|
+
cookie_params=cookie_params,
|
|
744
|
+
body=body,
|
|
745
|
+
response=self._extract_response_info(func, decorator),
|
|
746
|
+
router_name=router_name,
|
|
747
|
+
tags=tags,
|
|
748
|
+
operation_id=decorator.arguments.get("operation_id"),
|
|
749
|
+
summary=decorator.arguments.get("summary"),
|
|
750
|
+
description=func.docstring,
|
|
751
|
+
deprecated=decorator.arguments.get("deprecated", False),
|
|
752
|
+
dependency_refs=deps,
|
|
753
|
+
confidence=confidence,
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
def _extract_route_path(self, decorator: ParsedDecorator) -> str:
|
|
757
|
+
"""Extract route path from decorator."""
|
|
758
|
+
if decorator.positional_args:
|
|
759
|
+
first = decorator.positional_args[0]
|
|
760
|
+
if isinstance(first, str):
|
|
761
|
+
return first
|
|
762
|
+
|
|
763
|
+
if "path" in decorator.arguments:
|
|
764
|
+
return str(decorator.arguments["path"])
|
|
765
|
+
|
|
766
|
+
if decorator.arguments:
|
|
767
|
+
first_value = next(iter(decorator.arguments.values()), None)
|
|
768
|
+
if isinstance(first_value, str) and first_value.startswith("/"):
|
|
769
|
+
return first_value
|
|
770
|
+
|
|
771
|
+
return "/"
|
|
772
|
+
|
|
773
|
+
_DECORATOR_DEP_RE = re.compile(r"(?:Depends|Security)\s*\(\s*([^),]+)")
|
|
774
|
+
|
|
775
|
+
def _extract_decorator_dependencies(self, raw: Any) -> list[str]:
|
|
776
|
+
"""Extract dependency function names from a decorator ``dependencies`` kwarg.
|
|
777
|
+
|
|
778
|
+
Handles both source-text strings (from ``_extract_literal_or_code``)
|
|
779
|
+
and pre-parsed lists. Works for any ``Depends(fn)`` / ``Security(fn)``
|
|
780
|
+
reference regardless of framework.
|
|
781
|
+
"""
|
|
782
|
+
refs: list[str] = []
|
|
783
|
+
if isinstance(raw, str):
|
|
784
|
+
for m in self._DECORATOR_DEP_RE.finditer(raw):
|
|
785
|
+
dep_name = m.group(1).strip()
|
|
786
|
+
if dep_name:
|
|
787
|
+
refs.append(dep_name)
|
|
788
|
+
elif isinstance(raw, (list, tuple)):
|
|
789
|
+
for item in raw:
|
|
790
|
+
if isinstance(item, str):
|
|
791
|
+
for m in self._DECORATOR_DEP_RE.finditer(item):
|
|
792
|
+
dep_name = m.group(1).strip()
|
|
793
|
+
if dep_name:
|
|
794
|
+
refs.append(dep_name)
|
|
795
|
+
return refs
|
|
796
|
+
|
|
797
|
+
def _resolve_computed_path(self, path: str, file_path: Path) -> str:
|
|
798
|
+
"""Resolve computed path using path resolver from context."""
|
|
799
|
+
# Skip if already simple
|
|
800
|
+
if path.startswith("/") and "{" not in path and "f'" not in path and 'f"' not in path:
|
|
801
|
+
return path
|
|
802
|
+
|
|
803
|
+
if self._context and self._context.path_resolver:
|
|
804
|
+
resolved = self._context.path_resolver.resolve(path, file_path)
|
|
805
|
+
|
|
806
|
+
if resolved.confidence < 1.0:
|
|
807
|
+
self._notes.append(
|
|
808
|
+
AnalysisNote(
|
|
809
|
+
level="warning",
|
|
810
|
+
message=f"Partial path resolution: {path} -> {resolved.path}",
|
|
811
|
+
)
|
|
812
|
+
)
|
|
813
|
+
|
|
814
|
+
return resolved.path
|
|
815
|
+
|
|
816
|
+
return path
|
|
817
|
+
|
|
818
|
+
def _apply_router_prefix(self, route: ExtractedRoute, file_path: Path) -> ExtractedRoute:
|
|
819
|
+
"""Apply router prefix resolution."""
|
|
820
|
+
if not self._context or not self._context.router_registry:
|
|
821
|
+
return route
|
|
822
|
+
|
|
823
|
+
router_name = route.router_name
|
|
824
|
+
if not router_name:
|
|
825
|
+
return route
|
|
826
|
+
|
|
827
|
+
full_path = self._context.router_registry.resolve_path(router_name, route.path, file_path)
|
|
828
|
+
|
|
829
|
+
if full_path != route.path:
|
|
830
|
+
self._notes.append(
|
|
831
|
+
AnalysisNote(
|
|
832
|
+
level="info",
|
|
833
|
+
message=f"Router prefix applied: {route.path} -> {full_path}",
|
|
834
|
+
)
|
|
835
|
+
)
|
|
836
|
+
route.path = full_path
|
|
837
|
+
|
|
838
|
+
return route
|
|
839
|
+
|
|
840
|
+
def _apply_router_dependencies(self, route: ExtractedRoute, file_path: Path) -> ExtractedRoute:
|
|
841
|
+
"""Merge router-level dependencies into the route's dependency_refs.
|
|
842
|
+
|
|
843
|
+
Router-level dependencies declared via
|
|
844
|
+
``app.include_router(router, dependencies=[Depends(get_current_user)])``
|
|
845
|
+
apply to every route under that router. This method propagates them
|
|
846
|
+
so downstream auth analysis can see them.
|
|
847
|
+
"""
|
|
848
|
+
if not self._context or not self._context.router_registry:
|
|
849
|
+
return route
|
|
850
|
+
|
|
851
|
+
router_name = route.router_name
|
|
852
|
+
if not router_name:
|
|
853
|
+
return route
|
|
854
|
+
|
|
855
|
+
inherited = self._context.router_registry.get_router_dependencies(router_name, file_path)
|
|
856
|
+
|
|
857
|
+
if inherited:
|
|
858
|
+
existing = set(route.dependency_refs)
|
|
859
|
+
for dep_name in inherited:
|
|
860
|
+
if dep_name not in existing:
|
|
861
|
+
route.dependency_refs.append(dep_name)
|
|
862
|
+
|
|
863
|
+
return route
|
|
864
|
+
|
|
865
|
+
def _reconcile_path_params(self, route: ExtractedRoute) -> ExtractedRoute:
|
|
866
|
+
"""Ensure all {placeholder} names in the final composed path appear in path_params.
|
|
867
|
+
|
|
868
|
+
After router prefix application the composed path may contain template
|
|
869
|
+
variables (e.g. ``/{organization}/cases``) whose names were never seen by
|
|
870
|
+
the decorator-level parameter analyser. This post-hoc pass:
|
|
871
|
+
|
|
872
|
+
1. Synthesises missing path params from the URL template.
|
|
873
|
+
2. Moves any matching names out of query_params (where they land by
|
|
874
|
+
default when the handler has an unannotated ``name: str`` arg).
|
|
875
|
+
"""
|
|
876
|
+
template_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", route.path))
|
|
877
|
+
existing_path = {p.name for p in route.path_params}
|
|
878
|
+
|
|
879
|
+
for name in sorted(template_names - existing_path):
|
|
880
|
+
route.path_params.insert(
|
|
881
|
+
0,
|
|
882
|
+
ExtractedParameter(
|
|
883
|
+
name=name,
|
|
884
|
+
location=ParameterLocation.PATH,
|
|
885
|
+
type_annotation="str",
|
|
886
|
+
required=True,
|
|
887
|
+
constraints={"source": "router_prefix"},
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
path_names = {p.name for p in route.path_params}
|
|
892
|
+
route.query_params = [q for q in route.query_params if q.name not in path_names]
|
|
893
|
+
return route
|
|
894
|
+
|
|
895
|
+
def _convert_analyzed_params(
|
|
896
|
+
self,
|
|
897
|
+
analyzed_params: list[AnalyzedParameter],
|
|
898
|
+
parsed_file: ParsedFile,
|
|
899
|
+
) -> tuple[
|
|
900
|
+
list[ExtractedParameter],
|
|
901
|
+
list[ExtractedParameter],
|
|
902
|
+
list[ExtractedParameter],
|
|
903
|
+
list[ExtractedParameter],
|
|
904
|
+
ExtractedBody | None,
|
|
905
|
+
list[str],
|
|
906
|
+
]:
|
|
907
|
+
"""Convert analyzed parameters to extracted parameters."""
|
|
908
|
+
path_params: list[ExtractedParameter] = []
|
|
909
|
+
query_params: list[ExtractedParameter] = []
|
|
910
|
+
header_params: list[ExtractedParameter] = []
|
|
911
|
+
cookie_params: list[ExtractedParameter] = []
|
|
912
|
+
body: ExtractedBody | None = None
|
|
913
|
+
deps: list[str] = []
|
|
914
|
+
|
|
915
|
+
for param in analyzed_params:
|
|
916
|
+
if param.default_is_dependency:
|
|
917
|
+
if param.dependency_function:
|
|
918
|
+
deps.append(param.dependency_function)
|
|
919
|
+
elif param.base_type in KNOWN_FORM_TYPES:
|
|
920
|
+
content_type, fields = KNOWN_FORM_TYPES[param.base_type]
|
|
921
|
+
body = ExtractedBody(
|
|
922
|
+
content_type=content_type,
|
|
923
|
+
model_name=param.base_type,
|
|
924
|
+
model_fields=fields,
|
|
925
|
+
)
|
|
926
|
+
continue
|
|
927
|
+
|
|
928
|
+
extracted = ExtractedParameter(
|
|
929
|
+
name=param.name,
|
|
930
|
+
location=PARAM_LOCATION_MAP.get(param.location, ParameterLocation.QUERY),
|
|
931
|
+
type_annotation=param.type_annotation,
|
|
932
|
+
required=not param.has_default and not param.is_optional,
|
|
933
|
+
default_value=param.default_value if param.has_default else None,
|
|
934
|
+
constraints=param.constraints,
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
if param.location == "path":
|
|
938
|
+
path_params.append(extracted)
|
|
939
|
+
elif param.location == "query":
|
|
940
|
+
query_params.append(extracted)
|
|
941
|
+
elif param.location == "header":
|
|
942
|
+
header_params.append(extracted)
|
|
943
|
+
elif param.location == "cookie":
|
|
944
|
+
cookie_params.append(extracted)
|
|
945
|
+
elif param.location == "body":
|
|
946
|
+
body = self._create_body(param, parsed_file)
|
|
947
|
+
elif param.location == "form" and not body:
|
|
948
|
+
body = ExtractedBody(content_type="application/x-www-form-urlencoded")
|
|
949
|
+
|
|
950
|
+
return path_params, query_params, header_params, cookie_params, body, deps
|
|
951
|
+
|
|
952
|
+
_FILE_UPLOAD_TYPES = frozenset({"UploadFile", "bytes", "BinaryIO"})
|
|
953
|
+
|
|
954
|
+
def _create_body(self, param: AnalyzedParameter, parsed_file: ParsedFile) -> ExtractedBody:
|
|
955
|
+
"""Create body from analyzed parameter."""
|
|
956
|
+
model_name = param.base_type
|
|
957
|
+
model_fields: list[str] = []
|
|
958
|
+
model_qn: QualifiedName | None = None
|
|
959
|
+
|
|
960
|
+
# Determine content type: multipart for file uploads, JSON otherwise
|
|
961
|
+
content_type = "application/json"
|
|
962
|
+
if param.marker_type == "File" or (model_name and model_name in self._FILE_UPLOAD_TYPES):
|
|
963
|
+
content_type = "multipart/form-data"
|
|
964
|
+
|
|
965
|
+
# Use type resolver from context to get model fields
|
|
966
|
+
if model_name and self._context and self._context.type_resolver:
|
|
967
|
+
fields = self._context.type_resolver.get_model_fields(model_name, parsed_file.path)
|
|
968
|
+
model_fields = [f.name for f in fields]
|
|
969
|
+
|
|
970
|
+
resolved = self._context.type_resolver.resolve_type(model_name, parsed_file.path)
|
|
971
|
+
if resolved:
|
|
972
|
+
model_qn = QualifiedName(module="", name=resolved.qualified_name)
|
|
973
|
+
|
|
974
|
+
return ExtractedBody(
|
|
975
|
+
content_type=content_type,
|
|
976
|
+
model_name=model_name,
|
|
977
|
+
model_qualified_name=model_qn,
|
|
978
|
+
model_fields=model_fields,
|
|
979
|
+
required=not param.is_optional,
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
def _extract_response_info(
|
|
983
|
+
self,
|
|
984
|
+
func: ParsedFunction,
|
|
985
|
+
decorator: ParsedDecorator,
|
|
986
|
+
) -> ExtractedResponse:
|
|
987
|
+
"""Extract response information."""
|
|
988
|
+
response = ExtractedResponse()
|
|
989
|
+
|
|
990
|
+
if "response_model" in decorator.arguments:
|
|
991
|
+
response.model_name = str(decorator.arguments["response_model"])
|
|
992
|
+
if "status_code" in decorator.arguments:
|
|
993
|
+
with contextlib.suppress(ValueError, TypeError):
|
|
994
|
+
response.status_code = int(decorator.arguments["status_code"])
|
|
995
|
+
|
|
996
|
+
if func.return_type and not response.model_name:
|
|
997
|
+
response.model_name = self._extract_base_type(func.return_type)
|
|
998
|
+
|
|
999
|
+
return response
|
|
1000
|
+
|
|
1001
|
+
def _extract_base_type(self, annotation: str) -> str:
|
|
1002
|
+
"""Extract base type from annotation."""
|
|
1003
|
+
if annotation.startswith("Optional["):
|
|
1004
|
+
annotation = annotation[9:-1]
|
|
1005
|
+
if annotation.startswith("list[") or annotation.startswith("List["):
|
|
1006
|
+
annotation = annotation[5:-1]
|
|
1007
|
+
return annotation
|
|
1008
|
+
|
|
1009
|
+
# =========================================================================
|
|
1010
|
+
# Dynamic & CBV Routes
|
|
1011
|
+
# =========================================================================
|
|
1012
|
+
|
|
1013
|
+
def _extract_dynamic_routes(
|
|
1014
|
+
self,
|
|
1015
|
+
parsed_file: ParsedFile,
|
|
1016
|
+
param_analyzer: ParameterAnalyzer,
|
|
1017
|
+
) -> list[ExtractedRoute]:
|
|
1018
|
+
"""Extract routes from add_api_route() and route tables."""
|
|
1019
|
+
routes: list[ExtractedRoute] = []
|
|
1020
|
+
file_path = parsed_file.path
|
|
1021
|
+
|
|
1022
|
+
# Get dynamic detector from context
|
|
1023
|
+
detector = self._context.get_service("dynamic_route_detector") if self._context else None
|
|
1024
|
+
if not detector:
|
|
1025
|
+
return routes
|
|
1026
|
+
|
|
1027
|
+
dynamic_routes = detector.get_routes_for_file(file_path)
|
|
1028
|
+
|
|
1029
|
+
for dyn_route in dynamic_routes:
|
|
1030
|
+
resolved_path = self._resolve_computed_path(dyn_route.path, file_path)
|
|
1031
|
+
|
|
1032
|
+
for method in dyn_route.methods:
|
|
1033
|
+
http_method = HTTP_METHOD_MAP.get(method.lower())
|
|
1034
|
+
if not http_method:
|
|
1035
|
+
continue
|
|
1036
|
+
|
|
1037
|
+
route = ExtractedRoute(
|
|
1038
|
+
method=http_method,
|
|
1039
|
+
path=resolved_path,
|
|
1040
|
+
handler_function=QualifiedName(module="", name=dyn_route.handler_name),
|
|
1041
|
+
handler_location=CodeLocation(file=file_path, line=dyn_route.line),
|
|
1042
|
+
confidence=Confidence.MEDIUM if dyn_route.is_fully_resolved else Confidence.LOW,
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
route = self._apply_router_prefix(route, file_path)
|
|
1046
|
+
route = self._apply_router_dependencies(route, file_path)
|
|
1047
|
+
route = self._reconcile_path_params(route)
|
|
1048
|
+
routes.append(route)
|
|
1049
|
+
|
|
1050
|
+
return routes
|
|
1051
|
+
|
|
1052
|
+
def _extract_cbv_routes(
|
|
1053
|
+
self,
|
|
1054
|
+
parsed_file: ParsedFile,
|
|
1055
|
+
param_analyzer: ParameterAnalyzer,
|
|
1056
|
+
) -> list[ExtractedRoute]:
|
|
1057
|
+
"""Extract routes from class-based views."""
|
|
1058
|
+
routes: list[ExtractedRoute] = []
|
|
1059
|
+
file_path = parsed_file.path
|
|
1060
|
+
|
|
1061
|
+
# Get CBV extractor from context
|
|
1062
|
+
extractor = self._context.get_service("cbv_extractor") if self._context else None
|
|
1063
|
+
if not extractor:
|
|
1064
|
+
return routes
|
|
1065
|
+
|
|
1066
|
+
cbv_classes = extractor.get_classes_for_file(file_path)
|
|
1067
|
+
|
|
1068
|
+
for cbv in cbv_classes:
|
|
1069
|
+
for cbv_route in cbv.routes:
|
|
1070
|
+
http_method = HTTP_METHOD_MAP.get(cbv_route.method.lower())
|
|
1071
|
+
if not http_method:
|
|
1072
|
+
continue
|
|
1073
|
+
|
|
1074
|
+
path = self._resolve_computed_path(cbv_route.path, file_path)
|
|
1075
|
+
|
|
1076
|
+
route = ExtractedRoute(
|
|
1077
|
+
method=http_method,
|
|
1078
|
+
path=path,
|
|
1079
|
+
handler_function=QualifiedName(
|
|
1080
|
+
module=cbv.qualified_name, name=cbv_route.handler_method
|
|
1081
|
+
),
|
|
1082
|
+
handler_location=CodeLocation(file=file_path, line=cbv_route.line),
|
|
1083
|
+
confidence=Confidence.HIGH if cbv_route.confidence > 0.8 else Confidence.MEDIUM,
|
|
1084
|
+
tags=cbv_route.tags,
|
|
1085
|
+
dependency_refs=cbv_route.dependencies,
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
route = self._apply_router_prefix(route, file_path)
|
|
1089
|
+
route = self._apply_router_dependencies(route, file_path)
|
|
1090
|
+
route = self._reconcile_path_params(route)
|
|
1091
|
+
routes.append(route)
|
|
1092
|
+
|
|
1093
|
+
return routes
|
|
1094
|
+
|
|
1095
|
+
# =========================================================================
|
|
1096
|
+
# Dependency Extraction
|
|
1097
|
+
# =========================================================================
|
|
1098
|
+
|
|
1099
|
+
def _is_dependency_function(
|
|
1100
|
+
self,
|
|
1101
|
+
func: ParsedFunction,
|
|
1102
|
+
parsed_file: ParsedFile,
|
|
1103
|
+
all_project_depends_names: set[str] | None = None,
|
|
1104
|
+
) -> bool:
|
|
1105
|
+
"""Check if a function is ever used as a FastAPI ``Depends()`` argument.
|
|
1106
|
+
|
|
1107
|
+
Checks the same file first (fast path). Falls back to
|
|
1108
|
+
``all_project_depends_names``, a cross-file pre-pass set built by
|
|
1109
|
+
``ProjectAnalyzer._extract_auth_data``, to handle the standard FastAPI
|
|
1110
|
+
pattern where auth deps are defined in ``deps.py`` and used in
|
|
1111
|
+
``routers/*.py``.
|
|
1112
|
+
"""
|
|
1113
|
+
func_name = func.name
|
|
1114
|
+
|
|
1115
|
+
# Same-file check (original logic).
|
|
1116
|
+
for call in parsed_file.call_sites:
|
|
1117
|
+
if call.callee_name == "Depends":
|
|
1118
|
+
for arg in call.arguments:
|
|
1119
|
+
if arg.is_variable and arg.variable_name == func_name:
|
|
1120
|
+
return True
|
|
1121
|
+
if arg.literal_value == func_name:
|
|
1122
|
+
return True
|
|
1123
|
+
|
|
1124
|
+
for other_func in parsed_file.functions:
|
|
1125
|
+
for param in other_func.parameters:
|
|
1126
|
+
if param.default_value and f"Depends({func_name})" in param.default_value:
|
|
1127
|
+
return True
|
|
1128
|
+
|
|
1129
|
+
# Cross-file fallback: was this function name used in Depends() elsewhere?
|
|
1130
|
+
return bool(all_project_depends_names and func_name in all_project_depends_names)
|
|
1131
|
+
|
|
1132
|
+
def _create_dependency(self, func: ParsedFunction, file_path: Path) -> ExtractedDependency:
|
|
1133
|
+
"""Create dependency from function."""
|
|
1134
|
+
depends_on: list[QualifiedName] = []
|
|
1135
|
+
|
|
1136
|
+
for param in func.parameters:
|
|
1137
|
+
if param.default_value and "Depends(" in param.default_value:
|
|
1138
|
+
match = re.search(r"Depends\s*\(\s*(\w+)\s*\)", param.default_value)
|
|
1139
|
+
if match:
|
|
1140
|
+
depends_on.append(QualifiedName(module="", name=match.group(1)))
|
|
1141
|
+
|
|
1142
|
+
return ExtractedDependency(
|
|
1143
|
+
name=func.name,
|
|
1144
|
+
qualified_name=func.qualified_name,
|
|
1145
|
+
location=func.location,
|
|
1146
|
+
dependency_type="function",
|
|
1147
|
+
provides_type=func.return_type,
|
|
1148
|
+
depends_on=depends_on,
|
|
1149
|
+
is_auth_related=self._is_auth_related(func),
|
|
1150
|
+
confidence=Confidence.HIGH,
|
|
1151
|
+
)
|
|
1152
|
+
|
|
1153
|
+
_AUTH_KEYWORDS = frozenset(
|
|
1154
|
+
{
|
|
1155
|
+
"auth",
|
|
1156
|
+
"authenticate",
|
|
1157
|
+
"login",
|
|
1158
|
+
"user",
|
|
1159
|
+
"token",
|
|
1160
|
+
"verify",
|
|
1161
|
+
"permission",
|
|
1162
|
+
"principal",
|
|
1163
|
+
"credential",
|
|
1164
|
+
"identity",
|
|
1165
|
+
"session",
|
|
1166
|
+
"bearer",
|
|
1167
|
+
"api_key",
|
|
1168
|
+
"apikey",
|
|
1169
|
+
"security",
|
|
1170
|
+
"authorize",
|
|
1171
|
+
"authenticated",
|
|
1172
|
+
"current_user",
|
|
1173
|
+
}
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
_JWT_CALL_PATTERNS = frozenset(
|
|
1177
|
+
{
|
|
1178
|
+
"encode",
|
|
1179
|
+
"decode",
|
|
1180
|
+
"jwt_encode",
|
|
1181
|
+
"jwt_decode",
|
|
1182
|
+
"create_access_token",
|
|
1183
|
+
"create_token",
|
|
1184
|
+
"verify_token",
|
|
1185
|
+
}
|
|
1186
|
+
)
|
|
1187
|
+
|
|
1188
|
+
def _is_auth_related(self, func: ParsedFunction) -> bool:
|
|
1189
|
+
"""Check if function is auth-related via name heuristics or call-graph signals."""
|
|
1190
|
+
name_lower = func.name.lower()
|
|
1191
|
+
if any(kw in name_lower for kw in self._AUTH_KEYWORDS):
|
|
1192
|
+
return True
|
|
1193
|
+
|
|
1194
|
+
for call in getattr(func, "call_sites", []):
|
|
1195
|
+
callee_lower = call.callee_name.lower() if hasattr(call, "callee_name") else ""
|
|
1196
|
+
if any(pat in callee_lower for pat in self._JWT_CALL_PATTERNS):
|
|
1197
|
+
return True
|
|
1198
|
+
|
|
1199
|
+
return False
|
|
1200
|
+
|
|
1201
|
+
# =========================================================================
|
|
1202
|
+
# Auth Extraction
|
|
1203
|
+
# =========================================================================
|
|
1204
|
+
|
|
1205
|
+
def _extract_auth_schemes_from_classes(
|
|
1206
|
+
self,
|
|
1207
|
+
parsed_file: ParsedFile,
|
|
1208
|
+
file_path: Path,
|
|
1209
|
+
) -> list[ExtractedAuthScheme]:
|
|
1210
|
+
"""Detect auth schemes defined via class inheritance.
|
|
1211
|
+
|
|
1212
|
+
Catches patterns like ``class RWAPIKeyHeader(APIKeyHeader): ...`` where
|
|
1213
|
+
no module-level assignment instantiates the class.
|
|
1214
|
+
"""
|
|
1215
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
1216
|
+
for cls in parsed_file.classes:
|
|
1217
|
+
for base in cls.base_classes:
|
|
1218
|
+
base_simple = base.rsplit(".", 1)[-1]
|
|
1219
|
+
if base_simple in AUTH_SCHEME_PATTERNS:
|
|
1220
|
+
schemes.append(
|
|
1221
|
+
ExtractedAuthScheme(
|
|
1222
|
+
scheme_type=AUTH_SCHEME_PATTERNS[base_simple],
|
|
1223
|
+
name=cls.name,
|
|
1224
|
+
location=CodeLocation(file=file_path, line=cls.location.line),
|
|
1225
|
+
config={"class": cls.name, "base_class": base},
|
|
1226
|
+
confidence=Confidence.MEDIUM,
|
|
1227
|
+
)
|
|
1228
|
+
)
|
|
1229
|
+
break
|
|
1230
|
+
return schemes
|
|
1231
|
+
|
|
1232
|
+
def _extract_auth_scheme_from_assignment(
|
|
1233
|
+
self, assign, file_path: Path
|
|
1234
|
+
) -> ExtractedAuthScheme | None:
|
|
1235
|
+
"""Extract auth scheme from assignment."""
|
|
1236
|
+
if assign.source_type != "call":
|
|
1237
|
+
return None
|
|
1238
|
+
|
|
1239
|
+
called = assign.source_call or ""
|
|
1240
|
+
resolved = self._resolve_call_name(called, file_path)
|
|
1241
|
+
|
|
1242
|
+
for pattern, scheme_type in AUTH_SCHEME_PATTERNS.items():
|
|
1243
|
+
if pattern in resolved or pattern in called:
|
|
1244
|
+
return ExtractedAuthScheme(
|
|
1245
|
+
scheme_type=scheme_type,
|
|
1246
|
+
name=assign.target,
|
|
1247
|
+
location=CodeLocation(file=file_path, line=assign.location.line),
|
|
1248
|
+
config={"class": called},
|
|
1249
|
+
confidence=Confidence.HIGH,
|
|
1250
|
+
)
|
|
1251
|
+
|
|
1252
|
+
# Fallback: resolve through class inheritance (e.g. class JWTBearer(APIKeyHeader))
|
|
1253
|
+
if self._context and self._context.type_resolver:
|
|
1254
|
+
call_base = called.split("(")[0].strip().rsplit(".", 1)[-1]
|
|
1255
|
+
resolved_type = self._context.type_resolver.resolve_type(call_base, file_path)
|
|
1256
|
+
if resolved_type and getattr(resolved_type, "base_classes", None):
|
|
1257
|
+
for base in resolved_type.base_classes:
|
|
1258
|
+
base_simple = base.rsplit(".", 1)[-1] if "." in base else base
|
|
1259
|
+
if base_simple in AUTH_SCHEME_PATTERNS:
|
|
1260
|
+
return ExtractedAuthScheme(
|
|
1261
|
+
scheme_type=AUTH_SCHEME_PATTERNS[base_simple],
|
|
1262
|
+
name=assign.target,
|
|
1263
|
+
location=CodeLocation(file=file_path, line=assign.location.line),
|
|
1264
|
+
config={"class": called, "base_class": base_simple},
|
|
1265
|
+
confidence=Confidence.MEDIUM,
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
return None
|
|
1269
|
+
|
|
1270
|
+
def _extract_auth_dependency(
|
|
1271
|
+
self,
|
|
1272
|
+
func: ParsedFunction,
|
|
1273
|
+
parsed_file: ParsedFile,
|
|
1274
|
+
known_scheme_names: set[str] | None = None,
|
|
1275
|
+
all_project_depends_names: set[str] | None = None,
|
|
1276
|
+
) -> ExtractedAuthDependency | None:
|
|
1277
|
+
"""Extract auth dependency from function."""
|
|
1278
|
+
if not self._is_auth_related(func):
|
|
1279
|
+
return None
|
|
1280
|
+
|
|
1281
|
+
if not self._is_dependency_function(func, parsed_file, all_project_depends_names):
|
|
1282
|
+
return None
|
|
1283
|
+
|
|
1284
|
+
# Correlate with known auth scheme variables: scan the function's
|
|
1285
|
+
# call sites and parameter defaults for references to scheme names.
|
|
1286
|
+
matched_schemes: list[str] = []
|
|
1287
|
+
if known_scheme_names:
|
|
1288
|
+
for call in getattr(func, "call_sites", []):
|
|
1289
|
+
callee_base = (
|
|
1290
|
+
call.callee_name.rsplit(".", 1)[-1] if hasattr(call, "callee_name") else ""
|
|
1291
|
+
)
|
|
1292
|
+
if callee_base in known_scheme_names:
|
|
1293
|
+
matched_schemes.append(callee_base)
|
|
1294
|
+
for param in func.parameters:
|
|
1295
|
+
if param.default_value:
|
|
1296
|
+
for scheme_name in known_scheme_names:
|
|
1297
|
+
if scheme_name in param.default_value:
|
|
1298
|
+
matched_schemes.append(scheme_name)
|
|
1299
|
+
|
|
1300
|
+
return ExtractedAuthDependency(
|
|
1301
|
+
name=func.name,
|
|
1302
|
+
qualified_name=func.qualified_name,
|
|
1303
|
+
location=func.location,
|
|
1304
|
+
dependency_type=AuthDependencyType.FUNCTION,
|
|
1305
|
+
uses_schemes=list(dict.fromkeys(matched_schemes)),
|
|
1306
|
+
confidence=Confidence.MEDIUM,
|
|
1307
|
+
)
|
|
1308
|
+
|
|
1309
|
+
# =========================================================================
|
|
1310
|
+
# Middleware Extraction
|
|
1311
|
+
# =========================================================================
|
|
1312
|
+
|
|
1313
|
+
# Known class-name substrings that indicate the middleware performs
|
|
1314
|
+
# authentication. Used to flag `add_middleware(AuthMiddleware, ...)` style
|
|
1315
|
+
# registrations so the engine's missing_auth rule can treat all routes in
|
|
1316
|
+
# that app as auth-protected (see Fix 2 / Fix 7).
|
|
1317
|
+
_AUTH_MIDDLEWARE_PATTERNS: ClassVar[tuple[str, ...]] = (
|
|
1318
|
+
"authmiddleware",
|
|
1319
|
+
"authenticationmiddleware",
|
|
1320
|
+
"authbackend",
|
|
1321
|
+
"securitymiddleware",
|
|
1322
|
+
"jwtmiddleware",
|
|
1323
|
+
"sessionmiddleware",
|
|
1324
|
+
"httpbasicauth",
|
|
1325
|
+
"basicauthmiddleware",
|
|
1326
|
+
"bearerauthmiddleware",
|
|
1327
|
+
"oauth2middleware",
|
|
1328
|
+
"tokenauthmiddleware",
|
|
1329
|
+
"apikeymiddleware",
|
|
1330
|
+
"loginrequiredmiddleware",
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
def _extract_middleware_from_call(self, call, file_path: Path) -> ExtractedMiddleware | None:
|
|
1334
|
+
"""Extract middleware from add_middleware call."""
|
|
1335
|
+
middleware_class = None
|
|
1336
|
+
for arg in call.arguments:
|
|
1337
|
+
if arg.position == 0 or arg.keyword is None:
|
|
1338
|
+
middleware_class = arg.variable_name or arg.literal_value or arg.expression_text
|
|
1339
|
+
break
|
|
1340
|
+
|
|
1341
|
+
if not middleware_class:
|
|
1342
|
+
return None
|
|
1343
|
+
|
|
1344
|
+
operations: list[str] = []
|
|
1345
|
+
class_lower = str(middleware_class).lower()
|
|
1346
|
+
|
|
1347
|
+
if "cors" in class_lower:
|
|
1348
|
+
operations.append("cors")
|
|
1349
|
+
if (
|
|
1350
|
+
any(pat in class_lower for pat in self._AUTH_MIDDLEWARE_PATTERNS)
|
|
1351
|
+
or "auth" in class_lower
|
|
1352
|
+
):
|
|
1353
|
+
operations.append("auth")
|
|
1354
|
+
if "gzip" in class_lower:
|
|
1355
|
+
operations.append("compression")
|
|
1356
|
+
|
|
1357
|
+
return ExtractedMiddleware(
|
|
1358
|
+
name=str(middleware_class),
|
|
1359
|
+
location=call.location,
|
|
1360
|
+
middleware_type="middleware",
|
|
1361
|
+
applies_to_all=True,
|
|
1362
|
+
operations=operations,
|
|
1363
|
+
confidence=Confidence.HIGH,
|
|
1364
|
+
)
|
|
1365
|
+
|
|
1366
|
+
def _extract_middleware_from_decorator(
|
|
1367
|
+
self, func: ParsedFunction, file_path: Path
|
|
1368
|
+
) -> ExtractedMiddleware | None:
|
|
1369
|
+
"""Extract middleware from @app.middleware decorator."""
|
|
1370
|
+
for dec in func.decorators:
|
|
1371
|
+
if dec.name == "middleware":
|
|
1372
|
+
return ExtractedMiddleware(
|
|
1373
|
+
name=func.name,
|
|
1374
|
+
qualified_name=func.qualified_name,
|
|
1375
|
+
location=func.location,
|
|
1376
|
+
middleware_type="http",
|
|
1377
|
+
applies_to_all=True,
|
|
1378
|
+
operations=["custom"],
|
|
1379
|
+
confidence=Confidence.HIGH,
|
|
1380
|
+
)
|
|
1381
|
+
return None
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
# =============================================================================
|
|
1385
|
+
# Registration
|
|
1386
|
+
# =============================================================================
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
_fastapi_plugin = FastAPIPlugin()
|
|
1390
|
+
FrameworkPluginRegistry.register(_fastapi_plugin)
|