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