apisec-code-bolt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,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)