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,1293 @@
1
+ """
2
+ Spring Boot framework plugin.
3
+
4
+ Extracts HTTP routes, auth schemes, dependencies, and middleware from
5
+ Spring Boot applications using annotation-based detection.
6
+
7
+ Supports:
8
+ - @RestController / @Controller with @RequestMapping
9
+ - @GetMapping, @PostMapping, @PutMapping, @DeleteMapping, @PatchMapping
10
+ - Multi-value path arrays: @GetMapping({"/v1/users", "/v2/users"})
11
+ - @RequestMapping at method level with or without method=
12
+ - @PathVariable, @RequestParam, @RequestBody, @RequestHeader, @CookieValue,
13
+ @RequestPart, @MatrixVariable
14
+ - produces= / consumes= content-type negotiation on mapping annotations
15
+ - @ResponseStatus for custom response codes
16
+ - Bean Validation: @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Valid
17
+ - Spring Security: @PreAuthorize, @Secured, @RolesAllowed
18
+ - @EnableWebSecurity / @EnableMethodSecurity / JWT / OAuth2 bean detection
19
+ - SecurityFilterChain bean (structural detection)
20
+ - @Component, @Service, @Repository, @Bean dependency detection
21
+ - Filter / HandlerInterceptor middleware detection
22
+ - @CrossOrigin CORS policy detection (class and method level)
23
+ - @ControllerAdvice global exception handler detection
24
+ - @FeignClient declarative HTTP client (outbound call surface, kind="feign")
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import fnmatch
30
+ import logging
31
+ import re
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import TYPE_CHECKING, Any, ClassVar
35
+
36
+ from ...core.types import (
37
+ AuthDependencyType,
38
+ AuthSchemeType,
39
+ CodeLocation,
40
+ Confidence,
41
+ Framework,
42
+ HttpMethod,
43
+ Language,
44
+ ParameterLocation,
45
+ )
46
+ from ...parsing.base import ParsedClass, ParsedDecorator, ParsedFile, ParsedFunction
47
+ from ...parsing.services import AnalysisContext
48
+ from ..base import (
49
+ BaseFrameworkPlugin,
50
+ ExtractedAuthDependency,
51
+ ExtractedAuthScheme,
52
+ ExtractedBody,
53
+ ExtractedDependency,
54
+ ExtractedJwtConfig,
55
+ ExtractedMiddleware,
56
+ ExtractedParameter,
57
+ ExtractedResponse,
58
+ ExtractedRoute,
59
+ FrameworkPluginRegistry,
60
+ extract_path_template_names,
61
+ infer_middleware_operations,
62
+ is_auth_related_name,
63
+ )
64
+ from . import _annotations
65
+ from ._constraints import extract_constraints as _extract_bean_constraints
66
+ from ._constraints import is_complex_type as _is_complex_java_type
67
+ from .jwt_config_extractor import JavaJwtConfigExtractor
68
+
69
+ if TYPE_CHECKING:
70
+ pass
71
+
72
+ logger = logging.getLogger(__name__)
73
+
74
+
75
+ # =============================================================================
76
+ # Constants
77
+ # =============================================================================
78
+
79
+ # Spring annotation → HTTP method (for convenience aliases)
80
+ _MAPPING_TO_METHOD: dict[str, HttpMethod] = {
81
+ "GetMapping": HttpMethod.GET,
82
+ "PostMapping": HttpMethod.POST,
83
+ "PutMapping": HttpMethod.PUT,
84
+ "DeleteMapping": HttpMethod.DELETE,
85
+ "PatchMapping": HttpMethod.PATCH,
86
+ "HeadMapping": HttpMethod.HEAD,
87
+ "OptionsMapping": HttpMethod.OPTIONS,
88
+ }
89
+
90
+ # RequestMethod enum values
91
+ _REQUEST_METHOD_MAP: dict[str, HttpMethod] = {
92
+ "GET": HttpMethod.GET,
93
+ "POST": HttpMethod.POST,
94
+ "PUT": HttpMethod.PUT,
95
+ "DELETE": HttpMethod.DELETE,
96
+ "PATCH": HttpMethod.PATCH,
97
+ "HEAD": HttpMethod.HEAD,
98
+ "OPTIONS": HttpMethod.OPTIONS,
99
+ "TRACE": HttpMethod.TRACE,
100
+ }
101
+
102
+ _CONTROLLER_ANNOTATIONS: frozenset[str] = frozenset(
103
+ {
104
+ "RestController",
105
+ "Controller",
106
+ }
107
+ )
108
+
109
+ _SECURITY_ANNOTATIONS: frozenset[str] = frozenset(
110
+ {
111
+ "PreAuthorize",
112
+ "Secured",
113
+ "RolesAllowed",
114
+ "PermitAll",
115
+ "DenyAll",
116
+ "RequiresAuthentication",
117
+ }
118
+ )
119
+
120
+ _BEAN_ANNOTATIONS: frozenset[str] = frozenset(
121
+ {
122
+ "Component",
123
+ "Service",
124
+ "Repository",
125
+ "Bean",
126
+ "Configuration",
127
+ }
128
+ )
129
+
130
+ # Auth-related-name heuristic now lives in ``frameworks.base.AUTH_NAME_KEYWORDS``
131
+ # (imported as ``is_auth_related_name``).
132
+
133
+ _SPRING_IMPORTS: frozenset[str] = frozenset(
134
+ {
135
+ "org.springframework.web.bind.annotation",
136
+ "org.springframework.boot",
137
+ "org.springframework.stereotype",
138
+ "org.springframework.security",
139
+ "org.springframework.context.annotation",
140
+ "org.springframework.web.filter",
141
+ }
142
+ )
143
+
144
+ # HttpStatus enum name → numeric code (covers the common Spring subset)
145
+ _HTTP_STATUS_MAP: dict[str, int] = {
146
+ "CONTINUE": 100,
147
+ "SWITCHING_PROTOCOLS": 101,
148
+ "OK": 200,
149
+ "CREATED": 201,
150
+ "ACCEPTED": 202,
151
+ "NON_AUTHORITATIVE_INFORMATION": 203,
152
+ "NO_CONTENT": 204,
153
+ "RESET_CONTENT": 205,
154
+ "PARTIAL_CONTENT": 206,
155
+ "MULTIPLE_CHOICES": 300,
156
+ "MOVED_PERMANENTLY": 301,
157
+ "FOUND": 302,
158
+ "SEE_OTHER": 303,
159
+ "NOT_MODIFIED": 304,
160
+ "TEMPORARY_REDIRECT": 307,
161
+ "PERMANENT_REDIRECT": 308,
162
+ "BAD_REQUEST": 400,
163
+ "UNAUTHORIZED": 401,
164
+ "PAYMENT_REQUIRED": 402,
165
+ "FORBIDDEN": 403,
166
+ "NOT_FOUND": 404,
167
+ "METHOD_NOT_ALLOWED": 405,
168
+ "NOT_ACCEPTABLE": 406,
169
+ "REQUEST_TIMEOUT": 408,
170
+ "CONFLICT": 409,
171
+ "GONE": 410,
172
+ "LENGTH_REQUIRED": 411,
173
+ "PRECONDITION_FAILED": 412,
174
+ "PAYLOAD_TOO_LARGE": 413,
175
+ "URI_TOO_LONG": 414,
176
+ "UNSUPPORTED_MEDIA_TYPE": 415,
177
+ "UNPROCESSABLE_ENTITY": 422,
178
+ "LOCKED": 423,
179
+ "TOO_MANY_REQUESTS": 429,
180
+ "INTERNAL_SERVER_ERROR": 500,
181
+ "NOT_IMPLEMENTED": 501,
182
+ "BAD_GATEWAY": 502,
183
+ "SERVICE_UNAVAILABLE": 503,
184
+ "GATEWAY_TIMEOUT": 504,
185
+ }
186
+
187
+ # Bean Validation (JSR-303/380) annotation names
188
+ _CONSTRAINT_ANNOTATIONS: frozenset[str] = frozenset(
189
+ {
190
+ "NotNull",
191
+ "NonNull",
192
+ "NotEmpty",
193
+ "NotBlank",
194
+ "Null",
195
+ "Size",
196
+ "Length",
197
+ "Min",
198
+ "Max",
199
+ "DecimalMin",
200
+ "DecimalMax",
201
+ "Digits",
202
+ "Pattern",
203
+ "Email",
204
+ "URL",
205
+ "Positive",
206
+ "PositiveOrZero",
207
+ "Negative",
208
+ "NegativeOrZero",
209
+ "AssertTrue",
210
+ "AssertFalse",
211
+ "Past",
212
+ "PastOrPresent",
213
+ "Future",
214
+ "FutureOrPresent",
215
+ "Valid", # cascade validation
216
+ }
217
+ )
218
+
219
+
220
+ @dataclass
221
+ class FilterChainPolicy:
222
+ """Global Spring Security filter-chain auth policy extracted from a SecurityFilterChain bean.
223
+
224
+ any_request_auth=True means .anyRequest().authenticated() (or equivalent) is present,
225
+ so every route NOT matching a permit_all_patterns entry requires authentication.
226
+ """
227
+
228
+ any_request_auth: bool = False
229
+ permit_all_patterns: list[str] = field(default_factory=list)
230
+
231
+ def requires_auth(self, route_path: str) -> bool:
232
+ """Return True if this policy requires auth for route_path."""
233
+ if not self.any_request_auth:
234
+ return False
235
+ # Strip GraphQL operation suffix for matching: /graphql:Query.x → /graphql
236
+ normalized = route_path.split(":")[0] if ":" in route_path else route_path
237
+ for pattern in self.permit_all_patterns:
238
+ if "**" in pattern:
239
+ prefix = pattern.split("**")[0].rstrip("/")
240
+ if normalized == prefix or normalized.startswith(prefix + "/"):
241
+ return False
242
+ elif fnmatch.fnmatch(normalized, pattern):
243
+ return False
244
+ return True
245
+
246
+
247
+ # =============================================================================
248
+ # SpringBootPlugin
249
+ # =============================================================================
250
+
251
+
252
+ class SpringBootPlugin(BaseFrameworkPlugin):
253
+ """
254
+ Framework plugin for Spring Boot applications.
255
+
256
+ Extracts routes, auth schemes, dependencies, and middleware
257
+ from Spring Boot annotation patterns.
258
+ """
259
+
260
+ FRAMEWORK: ClassVar[Framework] = Framework.SPRING_BOOT
261
+ LANGUAGE: ClassVar[Language] = Language.JAVA
262
+ DETECTION_IMPORTS: ClassVar[frozenset[str]] = _SPRING_IMPORTS
263
+ DETECTION_IMPORT_PREFIXES: ClassVar[tuple[str, ...]] = ("org.springframework",)
264
+
265
+ # `detect()` inherited from BaseFrameworkPlugin — prefix match on
266
+ # ``org.springframework`` plus exact match against `_SPRING_IMPORTS`.
267
+
268
+ # -------------------------------------------------------------------------
269
+ # Route extraction
270
+ # -------------------------------------------------------------------------
271
+
272
+ def extract_routes(
273
+ self,
274
+ parsed_file: ParsedFile,
275
+ context: AnalysisContext | None = None,
276
+ ) -> list[ExtractedRoute]:
277
+ routes: list[ExtractedRoute] = []
278
+
279
+ # Build a simple name → prefix map for base-class @RequestMapping inheritance.
280
+ # Only covers same-file parent classes (cross-file inheritance requires type
281
+ # resolution and is handled separately when a full context is available).
282
+ prefix_by_class: dict[str, str] = {}
283
+ for cls in parsed_file.classes:
284
+ p = self._get_class_prefix(cls)
285
+ if p:
286
+ prefix_by_class[cls.name] = p
287
+
288
+ for cls in parsed_file.classes:
289
+ if not self._is_controller(cls):
290
+ continue
291
+
292
+ class_prefix = self._get_class_prefix(cls)
293
+ # Inherit @RequestMapping prefix from a same-file parent class when the
294
+ # subclass doesn't declare its own.
295
+ if not class_prefix:
296
+ for base in cls.base_classes:
297
+ simple = base.rsplit(".", 1)[-1]
298
+ if simple in prefix_by_class:
299
+ class_prefix = prefix_by_class[simple]
300
+ break
301
+ # Collect @PathVariable params from @ModelAttribute methods. These
302
+ # run before every handler and implicitly bind path variables that
303
+ # don't appear in the handler's own parameter list.
304
+ model_attr_params = self._collect_model_attr_path_params(cls)
305
+
306
+ for method in cls.methods:
307
+ results = self._extract_routes_from_method(
308
+ method, class_prefix, cls, parsed_file, context, model_attr_params
309
+ )
310
+ routes.extend(results)
311
+
312
+ # --- @FeignClient interfaces (outbound HTTP call surface) ---
313
+ for cls in parsed_file.classes:
314
+ if not any(dec.name == "FeignClient" for dec in cls.decorators):
315
+ continue
316
+
317
+ feign_prefix = self._get_feign_prefix(cls)
318
+
319
+ for method in cls.methods:
320
+ feign_routes = self._extract_routes_from_method(
321
+ method, feign_prefix, cls, parsed_file, context
322
+ )
323
+ for route in feign_routes:
324
+ # Re-emit with kind="feign" and lower confidence
325
+ routes.append(
326
+ ExtractedRoute(
327
+ method=route.method,
328
+ path=route.path,
329
+ handler_function=route.handler_function,
330
+ handler_location=route.handler_location,
331
+ path_params=route.path_params,
332
+ query_params=route.query_params,
333
+ header_params=route.header_params,
334
+ cookie_params=route.cookie_params,
335
+ body=route.body,
336
+ response=route.response,
337
+ tags=route.tags,
338
+ dependency_refs=route.dependency_refs,
339
+ confidence=Confidence.MEDIUM,
340
+ kind="feign",
341
+ )
342
+ )
343
+
344
+ return routes
345
+
346
+ def _is_controller(self, cls: ParsedClass) -> bool:
347
+ """Check if class is annotated as a Spring controller."""
348
+ return any(dec.name in _CONTROLLER_ANNOTATIONS for dec in cls.decorators)
349
+
350
+ def _get_class_prefix(self, cls: ParsedClass) -> str:
351
+ """Get class-level @RequestMapping path prefix."""
352
+ for dec in cls.decorators:
353
+ if dec.name == "RequestMapping":
354
+ path = self._annotation_path(dec)
355
+ if path:
356
+ return path.rstrip("/")
357
+ return ""
358
+
359
+ def _get_feign_prefix(self, cls: ParsedClass) -> str:
360
+ """Get URL prefix from @FeignClient(path=) or @RequestMapping on the interface."""
361
+ for dec in cls.decorators:
362
+ if dec.name == "FeignClient":
363
+ path = self._annotation_str(dec, "path") or self._annotation_str(dec, "url") or ""
364
+ if path and path.startswith("/"):
365
+ return path.rstrip("/")
366
+ if dec.name == "RequestMapping":
367
+ p = self._annotation_path(dec)
368
+ if p:
369
+ return p.rstrip("/")
370
+ return ""
371
+
372
+ def _collect_model_attr_path_params(self, cls: ParsedClass) -> list[tuple[str, str | None]]:
373
+ """Return (name, type) for every @PathVariable on a @ModelAttribute method.
374
+
375
+ Spring calls @ModelAttribute methods before every handler in the same
376
+ controller. Any @PathVariable they declare is therefore implicitly
377
+ bound on every route, even if the handler method itself does not list
378
+ that parameter. We collect them here so they can be merged into the
379
+ per-route path_params list.
380
+ """
381
+ params: list[tuple[str, str | None]] = []
382
+ seen: set[str] = set()
383
+ for method in cls.methods:
384
+ if not any(dec.name == "ModelAttribute" for dec in method.decorators):
385
+ continue
386
+ for param in method.parameters:
387
+ metadata = param.metadata or {}
388
+ if "PathVariable" not in metadata:
389
+ continue
390
+ alias = metadata["PathVariable"]
391
+ name, _required, _default = self._unpack_annotation_alias(alias, param.name)
392
+ if name and name not in seen:
393
+ seen.add(name)
394
+ params.append((name, param.type_annotation))
395
+ return params
396
+
397
+ @staticmethod
398
+ def _unpack_annotation_alias(val: Any, param_name: str) -> tuple[str, bool, Any]:
399
+ return _annotations.unpack_annotation_alias(val, param_name)
400
+
401
+ def _extract_routes_from_method(
402
+ self,
403
+ method: ParsedFunction,
404
+ class_prefix: str,
405
+ cls: ParsedClass,
406
+ parsed_file: ParsedFile,
407
+ context: AnalysisContext | None,
408
+ model_attr_params: list[tuple[str, str | None]] | None = None,
409
+ ) -> list[ExtractedRoute]:
410
+ """Extract all routes from a single handler method.
411
+
412
+ Returns a list because:
413
+ - @GetMapping({"/v1/users", "/v2/users"}) → 2 routes
414
+ - @RequestMapping (no method=) → one route per HTTP verb
415
+ - normally returns a single-element list
416
+ """
417
+ routes: list[ExtractedRoute] = []
418
+
419
+ # Collect cross-cutting annotations once (shared across all paths)
420
+ response_status = self._method_response_status(method)
421
+ auth_refs = self._method_auth_refs(cls, method)
422
+
423
+ for dec in method.decorators:
424
+ http_method = _MAPPING_TO_METHOD.get(dec.name)
425
+
426
+ if dec.name == "RequestMapping":
427
+ explicit_methods = self._get_request_methods(dec)
428
+ # Spring's actual default when no method= is ALL methods.
429
+ # Surface all common verbs so no attack surface is hidden.
430
+ http_methods_to_emit = (
431
+ explicit_methods
432
+ if explicit_methods
433
+ else [
434
+ HttpMethod.GET,
435
+ HttpMethod.POST,
436
+ HttpMethod.PUT,
437
+ HttpMethod.DELETE,
438
+ HttpMethod.PATCH,
439
+ ]
440
+ )
441
+ elif http_method is not None:
442
+ http_methods_to_emit = [http_method]
443
+ else:
444
+ continue
445
+
446
+ local_paths = self._annotation_paths(dec)
447
+ if not local_paths:
448
+ local_paths = [""]
449
+
450
+ # Content-type negotiation from annotation
451
+ consumes = self._annotation_str(dec, "consumes")
452
+ produces = self._annotation_str(dec, "produces")
453
+
454
+ for local_path in local_paths:
455
+ full_path = self._join_paths(class_prefix, local_path)
456
+
457
+ path_params, query_params, header_params, cookie_params, body = (
458
+ self._extract_method_params(method, full_path, context, parsed_file)
459
+ )
460
+
461
+ # Merge implicit @PathVariables from @ModelAttribute methods.
462
+ # Only add a param if (a) the name appears in the path template
463
+ # and (b) it wasn't already captured from the handler itself.
464
+ if model_attr_params:
465
+ existing_path_names = {p.name for p in path_params}
466
+ path_tpl_names = extract_path_template_names(full_path)
467
+ for ma_name, ma_type in model_attr_params:
468
+ if ma_name not in existing_path_names and ma_name in path_tpl_names:
469
+ path_params.append(
470
+ ExtractedParameter(
471
+ name=ma_name,
472
+ location=ParameterLocation.PATH,
473
+ type_annotation=ma_type,
474
+ required=True,
475
+ )
476
+ )
477
+
478
+ # Apply consumes= override to body content type
479
+ if consumes:
480
+ if body is not None:
481
+ body = ExtractedBody(
482
+ content_type=consumes,
483
+ model_name=body.model_name,
484
+ model_fields=body.model_fields,
485
+ required=body.required,
486
+ )
487
+ else:
488
+ body = ExtractedBody(content_type=consumes)
489
+
490
+ response = ExtractedResponse(
491
+ model_name=method.return_type,
492
+ status_code=response_status,
493
+ content_type=produces if produces else None,
494
+ )
495
+
496
+ for hm in http_methods_to_emit:
497
+ routes.append(
498
+ ExtractedRoute(
499
+ method=hm,
500
+ path=full_path,
501
+ handler_function=method.qualified_name,
502
+ handler_location=method.location,
503
+ path_params=list(path_params),
504
+ query_params=list(query_params),
505
+ header_params=list(header_params),
506
+ cookie_params=list(cookie_params),
507
+ body=body,
508
+ response=response,
509
+ tags=[cls.name],
510
+ dependency_refs=list(auth_refs),
511
+ confidence=Confidence.HIGH,
512
+ kind="http",
513
+ )
514
+ )
515
+
516
+ return routes
517
+
518
+ def _method_response_status(self, method: ParsedFunction) -> int:
519
+ """Extract HTTP response status from @ResponseStatus on a method."""
520
+ for ann in method.decorators:
521
+ if ann.name == "ResponseStatus":
522
+ return self._extract_status_code(ann)
523
+ return 200
524
+
525
+ def _method_auth_refs(self, cls: ParsedClass, method: ParsedFunction) -> list[str]:
526
+ """Collect auth dependency refs from security annotations on a method.
527
+
528
+ Falls back to class-level security annotations when the method itself
529
+ carries none — Spring's @PreAuthorize / @Secured at the class level
530
+ applies to every handler in the controller. A method-level @PermitAll
531
+ opens the method back up and clears the inherited guard.
532
+ """
533
+ method_ann_names = {ann.name for ann in method.decorators}
534
+ # @PermitAll on a method explicitly disables class-level guards.
535
+ if "PermitAll" in method_ann_names:
536
+ return []
537
+ for ann in method.decorators:
538
+ if ann.name in _SECURITY_ANNOTATIONS:
539
+ return [f"{cls.name}.{method.name}"]
540
+ # No method-level guard — fall back to the class.
541
+ for dec in cls.decorators:
542
+ if dec.name in _SECURITY_ANNOTATIONS:
543
+ return [cls.name]
544
+ return []
545
+
546
+ # -------------------------------------------------------------------------
547
+ # Parameter extraction
548
+ # -------------------------------------------------------------------------
549
+
550
+ def _extract_method_params(
551
+ self,
552
+ method: ParsedFunction,
553
+ path: str,
554
+ context: AnalysisContext | None,
555
+ parsed_file: ParsedFile,
556
+ ) -> tuple[
557
+ list[ExtractedParameter], # path_params
558
+ list[ExtractedParameter], # query_params
559
+ list[ExtractedParameter], # header_params
560
+ list[ExtractedParameter], # cookie_params
561
+ ExtractedBody | None,
562
+ ]:
563
+ path_params: list[ExtractedParameter] = []
564
+ query_params: list[ExtractedParameter] = []
565
+ header_params: list[ExtractedParameter] = []
566
+ cookie_params: list[ExtractedParameter] = []
567
+ body: ExtractedBody | None = None
568
+
569
+ # Extract {param} names from the path template
570
+ path_template_names = extract_path_template_names(path)
571
+
572
+ for param in method.parameters:
573
+ metadata = param.metadata or {}
574
+ constraints = self._extract_constraints(metadata)
575
+
576
+ # @PathVariable
577
+ if "PathVariable" in metadata:
578
+ alias = metadata["PathVariable"]
579
+ name, _required, _default = self._unpack_annotation_alias(alias, param.name)
580
+ path_params.append(
581
+ ExtractedParameter(
582
+ name=name,
583
+ location=ParameterLocation.PATH,
584
+ type_annotation=param.type_annotation,
585
+ required=True,
586
+ constraints=constraints,
587
+ code_location=param.location,
588
+ )
589
+ )
590
+ continue
591
+
592
+ # @RequestParam
593
+ if "RequestParam" in metadata:
594
+ val = metadata["RequestParam"]
595
+ name, required, _default = self._unpack_annotation_alias(val, param.name)
596
+ # @NotNull / @NonNull implies required
597
+ if constraints.get("not_null"):
598
+ required = True
599
+ query_params.append(
600
+ ExtractedParameter(
601
+ name=name,
602
+ location=ParameterLocation.QUERY,
603
+ type_annotation=param.type_annotation,
604
+ required=required,
605
+ constraints=constraints,
606
+ code_location=param.location,
607
+ )
608
+ )
609
+ continue
610
+
611
+ # @RequestHeader
612
+ if "RequestHeader" in metadata:
613
+ val = metadata["RequestHeader"]
614
+ name, _required, _default = self._unpack_annotation_alias(val, param.name)
615
+ header_params.append(
616
+ ExtractedParameter(
617
+ name=name,
618
+ location=ParameterLocation.HEADER,
619
+ type_annotation=param.type_annotation,
620
+ required=True,
621
+ constraints=constraints,
622
+ code_location=param.location,
623
+ )
624
+ )
625
+ continue
626
+
627
+ # @RequestBody
628
+ if "RequestBody" in metadata:
629
+ model_name = param.type_annotation
630
+ model_fields: list[str] = []
631
+ _has_valid = "Valid" in metadata # noqa: F841 — reserved for future manifest field
632
+
633
+ if context and context.type_resolver and model_name:
634
+ resolved_fields = context.type_resolver.get_model_fields(
635
+ model_name, parsed_file.path
636
+ )
637
+ model_fields = [f.name for f in resolved_fields]
638
+
639
+ body = ExtractedBody(
640
+ content_type="application/json",
641
+ model_name=model_name,
642
+ model_fields=model_fields,
643
+ required=True,
644
+ )
645
+ # @Valid marks the body for bean-validation; no dedicated manifest
646
+ # field yet — noted here for future extension.
647
+ continue
648
+
649
+ # @CookieValue
650
+ if "CookieValue" in metadata:
651
+ val = metadata["CookieValue"]
652
+ name, _required, _default = self._unpack_annotation_alias(val, param.name)
653
+ cookie_params.append(
654
+ ExtractedParameter(
655
+ name=name,
656
+ location=ParameterLocation.COOKIE,
657
+ type_annotation=param.type_annotation,
658
+ required=True,
659
+ constraints=constraints,
660
+ code_location=param.location,
661
+ )
662
+ )
663
+ continue
664
+
665
+ # @RequestPart (multipart form file/field)
666
+ if "RequestPart" in metadata:
667
+ val = metadata["RequestPart"]
668
+ name, _required, _default = self._unpack_annotation_alias(val, param.name)
669
+ # Represent multipart parts as query params with FORM location;
670
+ # treat as body if it's the only complex param.
671
+ if body is None:
672
+ body = ExtractedBody(
673
+ content_type="multipart/form-data",
674
+ model_name=param.type_annotation,
675
+ required=True,
676
+ )
677
+ else:
678
+ query_params.append(
679
+ ExtractedParameter(
680
+ name=name,
681
+ location=ParameterLocation.QUERY,
682
+ type_annotation=param.type_annotation,
683
+ required=True,
684
+ constraints=constraints,
685
+ code_location=param.location,
686
+ )
687
+ )
688
+ continue
689
+
690
+ # @MatrixVariable
691
+ if "MatrixVariable" in metadata:
692
+ val = metadata["MatrixVariable"]
693
+ name, _required, _default = self._unpack_annotation_alias(val, param.name)
694
+ path_params.append(
695
+ ExtractedParameter(
696
+ name=name,
697
+ location=ParameterLocation.PATH,
698
+ type_annotation=param.type_annotation,
699
+ required=False,
700
+ alias=f";{name}",
701
+ constraints=constraints,
702
+ code_location=param.location,
703
+ )
704
+ )
705
+ continue
706
+
707
+ # No annotation — infer from path template
708
+ if param.name in path_template_names:
709
+ path_params.append(
710
+ ExtractedParameter(
711
+ name=param.name,
712
+ location=ParameterLocation.PATH,
713
+ type_annotation=param.type_annotation,
714
+ required=True,
715
+ constraints=constraints,
716
+ code_location=param.location,
717
+ )
718
+ )
719
+ continue
720
+
721
+ # Default: skip framework-injected types (HttpServletRequest, etc.)
722
+ skip_types = {
723
+ "HttpServletRequest",
724
+ "HttpServletResponse",
725
+ "HttpSession",
726
+ "Principal",
727
+ "Authentication",
728
+ "Model",
729
+ "ModelAndView",
730
+ "BindingResult",
731
+ "Errors",
732
+ }
733
+ if param.type_annotation in skip_types:
734
+ continue
735
+
736
+ # Treat remaining scalar params as query params (Spring default)
737
+ if param.type_annotation and not self._is_complex_type(param.type_annotation):
738
+ query_params.append(
739
+ ExtractedParameter(
740
+ name=param.name,
741
+ location=ParameterLocation.QUERY,
742
+ type_annotation=param.type_annotation,
743
+ required=False,
744
+ constraints=constraints,
745
+ code_location=param.location,
746
+ )
747
+ )
748
+
749
+ return path_params, query_params, header_params, cookie_params, body
750
+
751
+ def _extract_constraints(self, metadata: dict[str, Any]) -> dict[str, Any]:
752
+ return _extract_bean_constraints(metadata)
753
+
754
+ def _is_complex_type(self, type_name: str) -> bool:
755
+ return _is_complex_java_type(type_name)
756
+
757
+ # -------------------------------------------------------------------------
758
+ # Dependency extraction
759
+ # -------------------------------------------------------------------------
760
+
761
+ def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
762
+ """Extract Spring-managed bean definitions."""
763
+ deps: list[ExtractedDependency] = []
764
+
765
+ for cls in parsed_file.classes:
766
+ ann_names = {d.name for d in cls.decorators}
767
+
768
+ if not ann_names & _BEAN_ANNOTATIONS:
769
+ continue
770
+
771
+ # Detect auth-related beans
772
+ is_auth = is_auth_related_name(cls.name)
773
+
774
+ deps.append(
775
+ ExtractedDependency(
776
+ name=cls.name,
777
+ qualified_name=cls.qualified_name,
778
+ location=cls.location,
779
+ dependency_type="class",
780
+ provides_type=cls.name,
781
+ is_auth_related=is_auth,
782
+ confidence=Confidence.HIGH,
783
+ )
784
+ )
785
+
786
+ # Also extract @Bean methods
787
+ for method in cls.methods:
788
+ if any(d.name == "Bean" for d in method.decorators):
789
+ method_is_auth = is_auth_related_name(method.name)
790
+ deps.append(
791
+ ExtractedDependency(
792
+ name=method.name,
793
+ qualified_name=method.qualified_name,
794
+ location=method.location,
795
+ dependency_type="function",
796
+ provides_type=method.return_type,
797
+ is_auth_related=method_is_auth,
798
+ confidence=Confidence.HIGH,
799
+ )
800
+ )
801
+
802
+ # --- @FeignClient interfaces ---
803
+ for cls in parsed_file.classes:
804
+ if not any(d.name == "FeignClient" for d in cls.decorators):
805
+ continue
806
+ deps.append(
807
+ ExtractedDependency(
808
+ name=cls.name,
809
+ qualified_name=cls.qualified_name,
810
+ location=cls.location,
811
+ dependency_type="feign_client",
812
+ provides_type=cls.name,
813
+ is_auth_related=False,
814
+ confidence=Confidence.HIGH,
815
+ )
816
+ )
817
+
818
+ return deps
819
+
820
+ # -------------------------------------------------------------------------
821
+ # Auth scheme extraction
822
+ # -------------------------------------------------------------------------
823
+
824
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
825
+ """Detect Spring Security configuration and JWT setup."""
826
+ schemes: list[ExtractedAuthScheme] = []
827
+
828
+ for cls in parsed_file.classes:
829
+ ann_names = {d.name for d in cls.decorators}
830
+
831
+ # @EnableWebSecurity or class name contains "SecurityConfig"
832
+ if (
833
+ "EnableWebSecurity" in ann_names
834
+ or "EnableMethodSecurity" in ann_names
835
+ or "EnableGlobalMethodSecurity" in ann_names
836
+ or "SecurityConfig" in cls.name
837
+ or "WebSecurityConfig" in cls.name
838
+ or "SecurityConfiguration" in cls.name
839
+ ):
840
+ schemes.append(
841
+ ExtractedAuthScheme(
842
+ scheme_type=AuthSchemeType.SPRING_SECURITY,
843
+ name=cls.name,
844
+ location=cls.location,
845
+ config={"class": cls.name},
846
+ confidence=Confidence.HIGH,
847
+ )
848
+ )
849
+
850
+ # @Bean SecurityFilterChain — detects security config in any @Configuration class
851
+ for method in cls.methods:
852
+ if any(d.name == "Bean" for d in method.decorators):
853
+ if "SecurityFilterChain" in (method.return_type or ""):
854
+ schemes.append(
855
+ ExtractedAuthScheme(
856
+ scheme_type=AuthSchemeType.SPRING_SECURITY,
857
+ name=method.name,
858
+ location=method.location,
859
+ config={"bean": method.name, "return_type": method.return_type},
860
+ confidence=Confidence.HIGH,
861
+ )
862
+ )
863
+
864
+ # Scan for JWT-related method names / return types
865
+ for method in cls.methods:
866
+ if any(d.name == "Bean" for d in method.decorators):
867
+ name_lower = method.name.lower()
868
+ ret = (method.return_type or "").lower()
869
+
870
+ if "jwt" in name_lower or "jwt" in ret:
871
+ schemes.append(
872
+ ExtractedAuthScheme(
873
+ scheme_type=AuthSchemeType.JWT_BEARER,
874
+ name=method.name,
875
+ location=method.location,
876
+ config={"bean": method.name, "return_type": method.return_type},
877
+ confidence=Confidence.MEDIUM,
878
+ )
879
+ )
880
+ elif "oauth" in name_lower or "oauth" in ret:
881
+ schemes.append(
882
+ ExtractedAuthScheme(
883
+ scheme_type=AuthSchemeType.OAUTH2_AUTHORIZATION_CODE,
884
+ name=method.name,
885
+ location=method.location,
886
+ config={"bean": method.name},
887
+ confidence=Confidence.MEDIUM,
888
+ )
889
+ )
890
+
891
+ # Scan for bearer token / API key patterns in imports
892
+ import_names = {n for imp in parsed_file.imports for n in imp.names}
893
+ if any("JwtDecoder" in n or "JwtEncoder" in n for n in import_names):
894
+ if not any(s.scheme_type == AuthSchemeType.JWT_BEARER for s in schemes):
895
+ schemes.append(
896
+ ExtractedAuthScheme(
897
+ scheme_type=AuthSchemeType.JWT_BEARER,
898
+ name="jwt_from_imports",
899
+ location=CodeLocation(file=parsed_file.path, line=0),
900
+ config={"detected_via": "import"},
901
+ confidence=Confidence.MEDIUM,
902
+ )
903
+ )
904
+
905
+ return schemes
906
+
907
+ # -------------------------------------------------------------------------
908
+ # Filter-chain policy extraction (global, cross-file)
909
+ # -------------------------------------------------------------------------
910
+
911
+ def get_filter_chain_policy(
912
+ self, all_parsed_files: list[ParsedFile]
913
+ ) -> FilterChainPolicy | None:
914
+ """Scan all project files for a SecurityFilterChain @Bean and extract
915
+ its permit-all URL patterns plus the anyRequest() auth requirement.
916
+
917
+ Returns None if no SecurityFilterChain bean is found.
918
+ """
919
+ for parsed_file in all_parsed_files:
920
+ for cls in parsed_file.classes:
921
+ for method in cls.methods:
922
+ if not any(d.name == "Bean" for d in method.decorators):
923
+ continue
924
+ if "SecurityFilterChain" not in (method.return_type or ""):
925
+ continue
926
+ return self._parse_filter_chain_method(parsed_file.path, method.location.line)
927
+ return None
928
+
929
+ def _parse_filter_chain_method(
930
+ self, file_path: str | Path, start_line: int
931
+ ) -> FilterChainPolicy:
932
+ """Read the SecurityFilterChain bean body and extract security rules."""
933
+ try:
934
+ source_lines = Path(file_path).read_text(errors="replace").splitlines()
935
+ except OSError:
936
+ return FilterChainPolicy()
937
+
938
+ # Grab from the method's start line up to a reasonable window (150 lines).
939
+ body = "\n".join(source_lines[max(0, start_line - 1) : start_line + 150])
940
+
941
+ # Patterns like: .requestMatchers("/api/login/**", "/api/public/**").permitAll()
942
+ # or: .requestMatchers(antMatcher("/actuator/**")).permitAll()
943
+ permit_all_patterns: list[str] = []
944
+ for m in re.finditer(
945
+ r"requestMatchers\s*\(([^)]+)\)\s*(?:\.[^.]+)*?\.\s*permitAll\s*\(\s*\)",
946
+ body,
947
+ ):
948
+ args_text = m.group(1)
949
+ for pattern in re.findall(r'"([^"]+)"', args_text):
950
+ permit_all_patterns.append(pattern)
951
+
952
+ # Also capture PathRequest.toStaticResources() / toH2Console() idioms
953
+ if re.search(r"PathRequest\s*\.", body) and re.search(r"permitAll", body):
954
+ permit_all_patterns.append("/h2-console/**")
955
+ permit_all_patterns.append("/static/**")
956
+
957
+ any_request_auth = bool(
958
+ re.search(
959
+ r"anyRequest\s*\(\s*\)\s*(?:\.[^.]+)*?\.\s*"
960
+ r"(authenticated|fullyAuthenticated|hasRole|hasAuthority)\s*\(",
961
+ body,
962
+ )
963
+ )
964
+
965
+ return FilterChainPolicy(
966
+ any_request_auth=any_request_auth,
967
+ permit_all_patterns=permit_all_patterns,
968
+ )
969
+
970
+ # -------------------------------------------------------------------------
971
+ # Auth dependency extraction
972
+ # -------------------------------------------------------------------------
973
+
974
+ def extract_auth_dependencies(
975
+ self,
976
+ parsed_file: ParsedFile,
977
+ known_scheme_names: set[str] | None = None,
978
+ **kwargs: Any,
979
+ ) -> list[ExtractedAuthDependency]:
980
+ """Extract auth guards: @PreAuthorize, @Secured, auth filter/service classes."""
981
+ auth_deps: list[ExtractedAuthDependency] = []
982
+
983
+ for cls in parsed_file.classes:
984
+ # Auth-related classes (filter, service, guard)
985
+ if self._is_auth_class(cls):
986
+ auth_deps.append(
987
+ ExtractedAuthDependency(
988
+ name=cls.name,
989
+ qualified_name=cls.qualified_name,
990
+ location=cls.location,
991
+ dependency_type=AuthDependencyType.FILTER
992
+ if "filter" in cls.name.lower()
993
+ else AuthDependencyType.CLASS,
994
+ confidence=Confidence.MEDIUM,
995
+ )
996
+ )
997
+
998
+ # Class-level @PreAuthorize / @Secured / @RolesAllowed.
999
+ # Applies to every handler in the controller, so emit a single
1000
+ # dependency keyed by the class name. Routes whose methods
1001
+ # carry no method-level guard reference this entry via
1002
+ # `_method_auth_refs`.
1003
+ class_roles: list[str] = []
1004
+ class_has_security = False
1005
+ for dec in cls.decorators:
1006
+ if dec.name in _SECURITY_ANNOTATIONS:
1007
+ class_has_security = True
1008
+ class_roles.extend(self._extract_roles_from_security_ann(dec))
1009
+ if class_has_security:
1010
+ auth_deps.append(
1011
+ ExtractedAuthDependency(
1012
+ name=cls.name,
1013
+ qualified_name=cls.qualified_name,
1014
+ location=cls.location,
1015
+ dependency_type=AuthDependencyType.ANNOTATION,
1016
+ requires_roles=class_roles,
1017
+ confidence=Confidence.HIGH,
1018
+ )
1019
+ )
1020
+
1021
+ # Methods with @PreAuthorize / @Secured.
1022
+ # Collect ALL security annotations on a method (a method may have
1023
+ # both @PreAuthorize and @Secured simultaneously) and merge their
1024
+ # roles rather than stopping at the first match.
1025
+ for method in cls.methods:
1026
+ all_roles: list[str] = []
1027
+ has_security_ann = False
1028
+ for dec in method.decorators:
1029
+ if dec.name in _SECURITY_ANNOTATIONS:
1030
+ has_security_ann = True
1031
+ all_roles.extend(self._extract_roles_from_security_ann(dec))
1032
+ if has_security_ann:
1033
+ auth_deps.append(
1034
+ ExtractedAuthDependency(
1035
+ name=f"{cls.name}.{method.name}",
1036
+ qualified_name=method.qualified_name,
1037
+ location=method.location,
1038
+ dependency_type=AuthDependencyType.ANNOTATION,
1039
+ requires_roles=all_roles,
1040
+ confidence=Confidence.HIGH,
1041
+ )
1042
+ )
1043
+
1044
+ return auth_deps
1045
+
1046
+ def _is_auth_class(self, cls: ParsedClass) -> bool:
1047
+ if is_auth_related_name(cls.name):
1048
+ return True
1049
+ # Implements Spring Security interfaces
1050
+ for base in cls.base_classes:
1051
+ if any(
1052
+ iface in base
1053
+ for iface in [
1054
+ "UserDetailsService",
1055
+ "AuthenticationProvider",
1056
+ "OncePerRequestFilter",
1057
+ "GenericFilterBean",
1058
+ "BasicAuthenticationFilter",
1059
+ "UsernamePasswordAuthenticationFilter",
1060
+ ]
1061
+ ):
1062
+ return True
1063
+ return False
1064
+
1065
+ def _extract_roles_from_security_ann(self, dec: ParsedDecorator) -> list[str]:
1066
+ """Extract role names from @PreAuthorize / @Secured values.
1067
+
1068
+ For @PreAuthorize SpEL expressions, only emit a role when the
1069
+ expression contains ``hasRole(...)`` / ``hasAuthority(...)``. Other
1070
+ guard expressions (``isAuthenticated()``, ``principal != null``, …)
1071
+ are still protective — the route's non-empty ``dependency_refs``
1072
+ captures that — but they don't translate to a role name, so we
1073
+ return nothing for them rather than leak the raw SpEL string
1074
+ downstream as if it were a role.
1075
+ """
1076
+ roles: list[str] = []
1077
+ is_pre_authorize = dec.name == "PreAuthorize"
1078
+
1079
+ def _collect_from_string(val: str) -> None:
1080
+ found = re.findall(r"hasRole\(['\"]([^'\"]+)['\"]\)", val)
1081
+ found += re.findall(r"hasAuthority\(['\"]([^'\"]+)['\"]\)", val)
1082
+ if found:
1083
+ roles.extend(found)
1084
+ elif not is_pre_authorize:
1085
+ # @Secured / @RolesAllowed values are plain role strings.
1086
+ roles.append(val)
1087
+ # else: SpEL expression with no role/authority — emit nothing.
1088
+
1089
+ # @Secured({"ROLE_ADMIN", "ROLE_USER"}) or @PreAuthorize("hasRole(...)")
1090
+ for val in dec.positional_args:
1091
+ if isinstance(val, list):
1092
+ for v in val:
1093
+ if isinstance(v, str):
1094
+ _collect_from_string(v)
1095
+ else:
1096
+ roles.append(str(v))
1097
+ elif isinstance(val, str):
1098
+ _collect_from_string(val)
1099
+
1100
+ val = dec.arguments.get("value")
1101
+ if val:
1102
+ if isinstance(val, list):
1103
+ for v in val:
1104
+ if isinstance(v, str):
1105
+ _collect_from_string(v)
1106
+ else:
1107
+ roles.append(str(v))
1108
+ elif isinstance(val, str):
1109
+ _collect_from_string(val)
1110
+
1111
+ return roles
1112
+
1113
+ # -------------------------------------------------------------------------
1114
+ # Middleware extraction
1115
+ # -------------------------------------------------------------------------
1116
+
1117
+ def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
1118
+ """Detect Spring filters, interceptors, CORS policies, and exception handlers."""
1119
+ middleware: list[ExtractedMiddleware] = []
1120
+
1121
+ for cls in parsed_file.classes:
1122
+ ann_names = {d.name for d in cls.decorators}
1123
+
1124
+ # Standard filter / interceptor detection
1125
+ mw_type = self._classify_middleware(cls)
1126
+ if mw_type is not None:
1127
+ operations = self._infer_middleware_operations(cls)
1128
+ middleware.append(
1129
+ ExtractedMiddleware(
1130
+ name=cls.name,
1131
+ qualified_name=cls.qualified_name,
1132
+ location=cls.location,
1133
+ middleware_type=mw_type,
1134
+ applies_to_all=True,
1135
+ operations=operations,
1136
+ confidence=Confidence.MEDIUM,
1137
+ )
1138
+ )
1139
+ continue # Don't double-count as CORS / ControllerAdvice
1140
+
1141
+ # @CrossOrigin on a @RestController / @Controller class
1142
+ # Emitted as a dedicated "cors" middleware scoped to that controller.
1143
+ if "CrossOrigin" in ann_names and any(a in ann_names for a in _CONTROLLER_ANNOTATIONS):
1144
+ cors_dec = next(d for d in cls.decorators if d.name == "CrossOrigin")
1145
+ origins = self._annotation_str_list(cors_dec, "origins") or ["*"]
1146
+ middleware.append(
1147
+ ExtractedMiddleware(
1148
+ name=f"CrossOrigin({cls.name})",
1149
+ qualified_name=cls.qualified_name,
1150
+ location=cls.location,
1151
+ middleware_type="cors",
1152
+ applies_to_all=False,
1153
+ applies_to_patterns=[f"{cls.name}.*"],
1154
+ operations=["cors"],
1155
+ confidence=Confidence.HIGH,
1156
+ )
1157
+ )
1158
+ logger.debug("CORS policy on %s: origins=%s", cls.name, origins)
1159
+
1160
+ # @ControllerAdvice — global exception handler
1161
+ if "ControllerAdvice" in ann_names or "RestControllerAdvice" in ann_names:
1162
+ middleware.append(
1163
+ ExtractedMiddleware(
1164
+ name=cls.name,
1165
+ qualified_name=cls.qualified_name,
1166
+ location=cls.location,
1167
+ middleware_type="exception_handler",
1168
+ applies_to_all=True,
1169
+ operations=["error_handling"],
1170
+ confidence=Confidence.HIGH,
1171
+ )
1172
+ )
1173
+
1174
+ return middleware
1175
+
1176
+ def _classify_middleware(self, cls: ParsedClass) -> str | None:
1177
+ """Return middleware type string or None if not a middleware class."""
1178
+ for base in cls.base_classes:
1179
+ # Use exact Spring class names to avoid false positives from
1180
+ # unrelated classes whose names happen to contain "Filter"
1181
+ # (e.g. ImageFilter, ColorFilter, StreamFilter).
1182
+ if any(
1183
+ base == pattern or base.endswith("." + pattern)
1184
+ for pattern in [
1185
+ "OncePerRequestFilter",
1186
+ "GenericFilterBean",
1187
+ "BasicAuthenticationFilter",
1188
+ "UsernamePasswordAuthenticationFilter",
1189
+ "GenericFilter",
1190
+ "HttpFilter",
1191
+ ]
1192
+ ):
1193
+ return "filter"
1194
+ # Also accept any base whose simple name ends in "Filter" and
1195
+ # starts with a Spring-related prefix to avoid over-matching.
1196
+ if base.startswith("org.springframework") and base.endswith("Filter"):
1197
+ return "filter"
1198
+ if "HandlerInterceptor" in base or base.endswith("Interceptor"):
1199
+ return "interceptor"
1200
+
1201
+ # @Component class that looks like a filter by name
1202
+ ann_names = {d.name for d in cls.decorators}
1203
+ if "Component" in ann_names:
1204
+ name_lower = cls.name.lower()
1205
+ if "filter" in name_lower:
1206
+ return "filter"
1207
+ if "interceptor" in name_lower:
1208
+ return "interceptor"
1209
+
1210
+ return None
1211
+
1212
+ def _infer_middleware_operations(self, cls: ParsedClass) -> list[str]:
1213
+ """Infer middleware operations from class name + base classes.
1214
+
1215
+ Spring extends the cross-framework keyword set with ``compression``,
1216
+ which only really shows up in Spring's compression-filter ecosystem.
1217
+ """
1218
+ return infer_middleware_operations(
1219
+ cls.name,
1220
+ cls.base_classes,
1221
+ extra={"compression": ("compress", "gzip")},
1222
+ )
1223
+
1224
+ # -------------------------------------------------------------------------
1225
+ # Annotation helpers
1226
+ # -------------------------------------------------------------------------
1227
+
1228
+ def _annotation_paths(self, dec: ParsedDecorator) -> list[str]:
1229
+ return _annotations.annotation_paths(dec)
1230
+
1231
+ def _annotation_path(self, dec: ParsedDecorator) -> str | None:
1232
+ return _annotations.annotation_path(dec)
1233
+
1234
+ def _annotation_str(self, dec: ParsedDecorator, key: str) -> str | None:
1235
+ return _annotations.annotation_str(dec, key)
1236
+
1237
+ def _annotation_str_list(self, dec: ParsedDecorator, key: str) -> list[str]:
1238
+ return _annotations.annotation_str_list(dec, key)
1239
+
1240
+ def _extract_status_code(self, dec: ParsedDecorator) -> int:
1241
+ return _annotations.extract_status_code(dec, _HTTP_STATUS_MAP)
1242
+
1243
+ def _get_request_methods(self, dec: ParsedDecorator) -> list[HttpMethod]:
1244
+ """Extract HTTP method(s) from @RequestMapping(method = ...).
1245
+
1246
+ Supports both the single-value form (``method = RequestMethod.GET``)
1247
+ and the array form (``method = {RequestMethod.GET, RequestMethod.POST}``).
1248
+ Returns an empty list when ``method=`` was not specified at all.
1249
+ """
1250
+ method_val = dec.arguments.get("method")
1251
+ if method_val is None:
1252
+ return []
1253
+
1254
+ if isinstance(method_val, str):
1255
+ raw = method_val.split(".")[-1].upper()
1256
+ mapped = _REQUEST_METHOD_MAP.get(raw)
1257
+ return [mapped] if mapped is not None else []
1258
+
1259
+ if isinstance(method_val, list):
1260
+ result: list[HttpMethod] = []
1261
+ for entry in method_val:
1262
+ raw = str(entry).split(".")[-1].upper()
1263
+ mapped = _REQUEST_METHOD_MAP.get(raw)
1264
+ if mapped is not None and mapped not in result:
1265
+ result.append(mapped)
1266
+ return result
1267
+
1268
+ return []
1269
+
1270
+ def _join_paths(self, prefix: str, path: str) -> str:
1271
+ return _annotations.join_paths(prefix, path)
1272
+
1273
+ # -------------------------------------------------------------------------
1274
+ # JWT config extraction
1275
+ # -------------------------------------------------------------------------
1276
+
1277
+ _jwt_extractor = JavaJwtConfigExtractor()
1278
+
1279
+ def extract_jwt_config(
1280
+ self,
1281
+ parsed_file: ParsedFile,
1282
+ context: AnalysisContext | None = None,
1283
+ ) -> ExtractedJwtConfig | None:
1284
+ """Extract JWT library, algorithms, validation flags, and secret source."""
1285
+ return self._jwt_extractor.extract(parsed_file)
1286
+
1287
+
1288
+ # =============================================================================
1289
+ # Registration
1290
+ # =============================================================================
1291
+
1292
+ _spring_plugin = SpringBootPlugin()
1293
+ FrameworkPluginRegistry.register(_spring_plugin)