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,1059 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Micronaut framework plugin.
|
|
3
|
+
|
|
4
|
+
Extracts HTTP routes, auth schemes, dependencies, and middleware from
|
|
5
|
+
Micronaut applications using annotation-based detection.
|
|
6
|
+
|
|
7
|
+
Supports:
|
|
8
|
+
- @Controller with @Get, @Post, @Put, @Delete, @Patch, @Head, @Options
|
|
9
|
+
- @PathVariable, @QueryValue, @Body, @Header, @CookieValue, @Part
|
|
10
|
+
- produces / consumes content-type on mapping annotations
|
|
11
|
+
- @Status for custom response codes
|
|
12
|
+
- Bean Validation: @NotNull, @Size, @Min, @Max, @Pattern, @Email, @Valid
|
|
13
|
+
- @Secured / @Requires for method-level access control
|
|
14
|
+
- @Introspected model type detection
|
|
15
|
+
- @Singleton, @Prototype, @Context, @Infrastructure dependency detection
|
|
16
|
+
- @Filter HTTP server filter detection
|
|
17
|
+
- @Client declarative HTTP client (outbound call surface, kind="micronaut_client")
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
25
|
+
|
|
26
|
+
from ...core.types import (
|
|
27
|
+
AuthDependencyType,
|
|
28
|
+
AuthSchemeType,
|
|
29
|
+
CodeLocation,
|
|
30
|
+
Confidence,
|
|
31
|
+
Framework,
|
|
32
|
+
HttpMethod,
|
|
33
|
+
Language,
|
|
34
|
+
ParameterLocation,
|
|
35
|
+
)
|
|
36
|
+
from ...parsing.base import ParsedClass, ParsedDecorator, ParsedFile, ParsedFunction
|
|
37
|
+
from ...parsing.services import AnalysisContext
|
|
38
|
+
from ..base import (
|
|
39
|
+
BaseFrameworkPlugin,
|
|
40
|
+
ExtractedAuthDependency,
|
|
41
|
+
ExtractedAuthScheme,
|
|
42
|
+
ExtractedBody,
|
|
43
|
+
ExtractedDependency,
|
|
44
|
+
ExtractedJwtConfig,
|
|
45
|
+
ExtractedMiddleware,
|
|
46
|
+
ExtractedParameter,
|
|
47
|
+
ExtractedResponse,
|
|
48
|
+
ExtractedRoute,
|
|
49
|
+
FrameworkPluginRegistry,
|
|
50
|
+
)
|
|
51
|
+
from .jwt_config_extractor import JavaJwtConfigExtractor
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# =============================================================================
|
|
60
|
+
# Constants
|
|
61
|
+
# =============================================================================
|
|
62
|
+
|
|
63
|
+
# Micronaut HTTP annotation → HTTP method
|
|
64
|
+
_MAPPING_TO_METHOD: dict[str, HttpMethod] = {
|
|
65
|
+
"Get": HttpMethod.GET,
|
|
66
|
+
"Post": HttpMethod.POST,
|
|
67
|
+
"Put": HttpMethod.PUT,
|
|
68
|
+
"Delete": HttpMethod.DELETE,
|
|
69
|
+
"Patch": HttpMethod.PATCH,
|
|
70
|
+
"Head": HttpMethod.HEAD,
|
|
71
|
+
"Options": HttpMethod.OPTIONS,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
_CONTROLLER_ANNOTATIONS: frozenset[str] = frozenset({"Controller"})
|
|
75
|
+
|
|
76
|
+
# Micronaut security annotations
|
|
77
|
+
_SECURITY_ANNOTATIONS: frozenset[str] = frozenset(
|
|
78
|
+
{
|
|
79
|
+
"Secured",
|
|
80
|
+
"Requires",
|
|
81
|
+
"PermitAll",
|
|
82
|
+
"DenyAll",
|
|
83
|
+
"RolesAllowed",
|
|
84
|
+
"ApisecSecured",
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Micronaut DI scope annotations
|
|
89
|
+
_BEAN_ANNOTATIONS: frozenset[str] = frozenset(
|
|
90
|
+
{
|
|
91
|
+
"Singleton",
|
|
92
|
+
"Prototype",
|
|
93
|
+
"Context",
|
|
94
|
+
"Infrastructure",
|
|
95
|
+
"RequestScope",
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
_AUTH_RELATED_NAMES: frozenset[str] = frozenset(
|
|
100
|
+
{
|
|
101
|
+
"auth",
|
|
102
|
+
"authentication",
|
|
103
|
+
"authorization",
|
|
104
|
+
"security",
|
|
105
|
+
"jwt",
|
|
106
|
+
"token",
|
|
107
|
+
"user",
|
|
108
|
+
"principal",
|
|
109
|
+
"credential",
|
|
110
|
+
"login",
|
|
111
|
+
"filter",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Imports that identify a Micronaut file
|
|
116
|
+
_MICRONAUT_IMPORTS: frozenset[str] = frozenset(
|
|
117
|
+
{
|
|
118
|
+
"io.micronaut.http.annotation",
|
|
119
|
+
"io.micronaut.security",
|
|
120
|
+
"io.micronaut.context.annotation",
|
|
121
|
+
"io.micronaut.http.client",
|
|
122
|
+
"io.micronaut.http.filter",
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Micronaut HttpStatus enum name → numeric code
|
|
127
|
+
_HTTP_STATUS_MAP: dict[str, int] = {
|
|
128
|
+
"OK": 200,
|
|
129
|
+
"CREATED": 201,
|
|
130
|
+
"ACCEPTED": 202,
|
|
131
|
+
"NO_CONTENT": 204,
|
|
132
|
+
"MOVED_PERMANENTLY": 301,
|
|
133
|
+
"NOT_MODIFIED": 304,
|
|
134
|
+
"BAD_REQUEST": 400,
|
|
135
|
+
"UNAUTHORIZED": 401,
|
|
136
|
+
"FORBIDDEN": 403,
|
|
137
|
+
"NOT_FOUND": 404,
|
|
138
|
+
"METHOD_NOT_ALLOWED": 405,
|
|
139
|
+
"CONFLICT": 409,
|
|
140
|
+
"GONE": 410,
|
|
141
|
+
"UNPROCESSABLE_ENTITY": 422,
|
|
142
|
+
"TOO_MANY_REQUESTS": 429,
|
|
143
|
+
"INTERNAL_SERVER_ERROR": 500,
|
|
144
|
+
"NOT_IMPLEMENTED": 501,
|
|
145
|
+
"BAD_GATEWAY": 502,
|
|
146
|
+
"SERVICE_UNAVAILABLE": 503,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Bean Validation annotation names (same JSR-303/380 as Spring)
|
|
150
|
+
_CONSTRAINT_ANNOTATIONS: frozenset[str] = frozenset(
|
|
151
|
+
{
|
|
152
|
+
"NotNull",
|
|
153
|
+
"NonNull",
|
|
154
|
+
"NotEmpty",
|
|
155
|
+
"NotBlank",
|
|
156
|
+
"Null",
|
|
157
|
+
"Size",
|
|
158
|
+
"Length",
|
|
159
|
+
"Min",
|
|
160
|
+
"Max",
|
|
161
|
+
"DecimalMin",
|
|
162
|
+
"DecimalMax",
|
|
163
|
+
"Pattern",
|
|
164
|
+
"Email",
|
|
165
|
+
"URL",
|
|
166
|
+
"Positive",
|
|
167
|
+
"PositiveOrZero",
|
|
168
|
+
"Negative",
|
|
169
|
+
"NegativeOrZero",
|
|
170
|
+
"Valid",
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# =============================================================================
|
|
176
|
+
# MicronautPlugin
|
|
177
|
+
# =============================================================================
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class MicronautPlugin(BaseFrameworkPlugin):
|
|
181
|
+
"""
|
|
182
|
+
Framework plugin for Micronaut applications.
|
|
183
|
+
|
|
184
|
+
Extracts routes, auth, dependencies, and middleware from
|
|
185
|
+
Micronaut annotation patterns.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
FRAMEWORK: ClassVar[Framework] = Framework.MICRONAUT
|
|
189
|
+
LANGUAGE: ClassVar[Language] = Language.JAVA
|
|
190
|
+
DETECTION_IMPORTS: ClassVar[frozenset[str]] = _MICRONAUT_IMPORTS
|
|
191
|
+
|
|
192
|
+
# -------------------------------------------------------------------------
|
|
193
|
+
# Detection
|
|
194
|
+
# -------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
197
|
+
"""Detect Micronaut usage via imports."""
|
|
198
|
+
for imp in parsed_file.imports:
|
|
199
|
+
module = imp.module or ""
|
|
200
|
+
if module.startswith("io.micronaut"):
|
|
201
|
+
return True
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
# -------------------------------------------------------------------------
|
|
205
|
+
# Route extraction
|
|
206
|
+
# -------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _has_http_annotations(self, cls: ParsedClass) -> bool:
|
|
209
|
+
"""True if any method carries a Micronaut HTTP verb annotation."""
|
|
210
|
+
return any(
|
|
211
|
+
dec.name in _MAPPING_TO_METHOD for method in cls.methods for dec in method.decorators
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def extract_routes(
|
|
215
|
+
self,
|
|
216
|
+
parsed_file: ParsedFile,
|
|
217
|
+
context: AnalysisContext | None = None,
|
|
218
|
+
) -> list[ExtractedRoute]:
|
|
219
|
+
routes: list[ExtractedRoute] = []
|
|
220
|
+
|
|
221
|
+
for cls in parsed_file.classes:
|
|
222
|
+
is_ctrl = self._is_controller(cls)
|
|
223
|
+
# Micronaut operation-interface pattern: HTTP annotations live on the
|
|
224
|
+
# interface (*Operations.java) and the @Controller class inherits them.
|
|
225
|
+
# When scanning only the interface subdir (e.g. service-api/), we must
|
|
226
|
+
# extract from the interface directly.
|
|
227
|
+
# Guard: only when the class has no class-level annotations — interfaces
|
|
228
|
+
# have none, but @Singleton/@Service service classes do, which prevents
|
|
229
|
+
# false positives on misplaced @Get annotations in non-route beans.
|
|
230
|
+
is_operation_iface = (
|
|
231
|
+
not is_ctrl and not cls.decorators and self._has_http_annotations(cls)
|
|
232
|
+
)
|
|
233
|
+
if not is_ctrl and not is_operation_iface:
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
class_prefix = self._get_class_prefix(cls) if is_ctrl else ""
|
|
237
|
+
|
|
238
|
+
# Class-level @Secured / @Requires applies to all methods
|
|
239
|
+
class_auth_refs = self._class_auth_refs(cls)
|
|
240
|
+
|
|
241
|
+
for method in cls.methods:
|
|
242
|
+
results = self._extract_routes_from_method(
|
|
243
|
+
method,
|
|
244
|
+
class_prefix,
|
|
245
|
+
cls,
|
|
246
|
+
parsed_file,
|
|
247
|
+
context,
|
|
248
|
+
class_auth_refs=class_auth_refs,
|
|
249
|
+
)
|
|
250
|
+
routes.extend(results)
|
|
251
|
+
|
|
252
|
+
# @Client interfaces (outbound call surface)
|
|
253
|
+
for cls in parsed_file.classes:
|
|
254
|
+
if not any(dec.name == "Client" for dec in cls.decorators):
|
|
255
|
+
continue
|
|
256
|
+
prefix = self._get_client_prefix(cls)
|
|
257
|
+
for method in cls.methods:
|
|
258
|
+
results = self._extract_routes_from_method(
|
|
259
|
+
method,
|
|
260
|
+
prefix,
|
|
261
|
+
cls,
|
|
262
|
+
parsed_file,
|
|
263
|
+
context,
|
|
264
|
+
kind="micronaut_client",
|
|
265
|
+
confidence=Confidence.MEDIUM,
|
|
266
|
+
)
|
|
267
|
+
routes.extend(results)
|
|
268
|
+
|
|
269
|
+
return routes
|
|
270
|
+
|
|
271
|
+
def _is_controller(self, cls: ParsedClass) -> bool:
|
|
272
|
+
return any(dec.name in _CONTROLLER_ANNOTATIONS for dec in cls.decorators)
|
|
273
|
+
|
|
274
|
+
def _get_class_prefix(self, cls: ParsedClass) -> str:
|
|
275
|
+
"""Return the URI prefix from @Controller("/prefix")."""
|
|
276
|
+
for dec in cls.decorators:
|
|
277
|
+
if dec.name == "Controller":
|
|
278
|
+
path = self._annotation_path(dec)
|
|
279
|
+
if path:
|
|
280
|
+
return path.rstrip("/")
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
def _get_client_prefix(self, cls: ParsedClass) -> str:
|
|
284
|
+
"""Return the base URI from @Client("/prefix") or @Client(id)."""
|
|
285
|
+
for dec in cls.decorators:
|
|
286
|
+
if dec.name == "Client":
|
|
287
|
+
# @Client("/products") or @Client("product-service")
|
|
288
|
+
path = self._annotation_path(dec)
|
|
289
|
+
if path and path.startswith("/"):
|
|
290
|
+
return path.rstrip("/")
|
|
291
|
+
return ""
|
|
292
|
+
|
|
293
|
+
def _class_auth_refs(self, cls: ParsedClass) -> list[str]:
|
|
294
|
+
"""Return auth refs from class-level @Secured/@Requires.
|
|
295
|
+
|
|
296
|
+
The ref must match the ``name`` of the class-level auth dependency
|
|
297
|
+
emitted by ``extract_auth_dependencies`` (which uses ``cls.name``).
|
|
298
|
+
"""
|
|
299
|
+
for dec in cls.decorators:
|
|
300
|
+
if dec.name in _SECURITY_ANNOTATIONS:
|
|
301
|
+
return [cls.name]
|
|
302
|
+
return []
|
|
303
|
+
|
|
304
|
+
def _extract_routes_from_method(
|
|
305
|
+
self,
|
|
306
|
+
method: ParsedFunction,
|
|
307
|
+
class_prefix: str,
|
|
308
|
+
cls: ParsedClass,
|
|
309
|
+
parsed_file: ParsedFile,
|
|
310
|
+
context: AnalysisContext | None,
|
|
311
|
+
kind: str = "http",
|
|
312
|
+
confidence: Confidence = Confidence.HIGH,
|
|
313
|
+
class_auth_refs: list[str] | None = None,
|
|
314
|
+
) -> list[ExtractedRoute]:
|
|
315
|
+
"""Extract all routes from a single Micronaut handler method."""
|
|
316
|
+
routes: list[ExtractedRoute] = []
|
|
317
|
+
|
|
318
|
+
response_status = self._method_response_status(method)
|
|
319
|
+
method_auth_refs = self._method_auth_refs(cls, method)
|
|
320
|
+
auth_refs = list(class_auth_refs or []) + method_auth_refs
|
|
321
|
+
|
|
322
|
+
for dec in method.decorators:
|
|
323
|
+
http_method = _MAPPING_TO_METHOD.get(dec.name)
|
|
324
|
+
if http_method is None:
|
|
325
|
+
continue
|
|
326
|
+
|
|
327
|
+
local_paths = self._annotation_paths(dec)
|
|
328
|
+
if not local_paths:
|
|
329
|
+
local_paths = [""]
|
|
330
|
+
|
|
331
|
+
produces = self._annotation_str(dec, "produces")
|
|
332
|
+
consumes = self._annotation_str(dec, "consumes")
|
|
333
|
+
|
|
334
|
+
for local_path in local_paths:
|
|
335
|
+
full_path = self._join_paths(class_prefix, local_path)
|
|
336
|
+
|
|
337
|
+
path_params, query_params, header_params, cookie_params, body = (
|
|
338
|
+
self._extract_method_params(method, full_path, context, parsed_file)
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
if consumes:
|
|
342
|
+
if body is not None:
|
|
343
|
+
body = ExtractedBody(
|
|
344
|
+
content_type=consumes,
|
|
345
|
+
model_name=body.model_name,
|
|
346
|
+
model_fields=body.model_fields,
|
|
347
|
+
required=body.required,
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
body = ExtractedBody(content_type=consumes)
|
|
351
|
+
|
|
352
|
+
response = ExtractedResponse(
|
|
353
|
+
model_name=method.return_type,
|
|
354
|
+
status_code=response_status,
|
|
355
|
+
content_type=produces if produces else None,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
routes.append(
|
|
359
|
+
ExtractedRoute(
|
|
360
|
+
method=http_method,
|
|
361
|
+
path=full_path,
|
|
362
|
+
handler_function=method.qualified_name,
|
|
363
|
+
handler_location=method.location,
|
|
364
|
+
path_params=list(path_params),
|
|
365
|
+
query_params=list(query_params),
|
|
366
|
+
header_params=list(header_params),
|
|
367
|
+
cookie_params=list(cookie_params),
|
|
368
|
+
body=body,
|
|
369
|
+
response=response,
|
|
370
|
+
tags=[cls.name],
|
|
371
|
+
dependency_refs=list(auth_refs),
|
|
372
|
+
confidence=confidence,
|
|
373
|
+
kind=kind,
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
return routes
|
|
378
|
+
|
|
379
|
+
def _method_response_status(self, method: ParsedFunction) -> int:
|
|
380
|
+
"""Extract HTTP status from @Status on a method."""
|
|
381
|
+
for ann in method.decorators:
|
|
382
|
+
if ann.name == "Status":
|
|
383
|
+
return self._extract_status_code(ann)
|
|
384
|
+
return 200
|
|
385
|
+
|
|
386
|
+
def _method_auth_refs(self, cls: ParsedClass, method: ParsedFunction) -> list[str]:
|
|
387
|
+
for ann in method.decorators:
|
|
388
|
+
if ann.name in _SECURITY_ANNOTATIONS:
|
|
389
|
+
return [f"{cls.name}.{method.name}"]
|
|
390
|
+
return []
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def _unpack_annotation_alias(val: Any, param_name: str) -> tuple[str, bool, Any]:
|
|
394
|
+
"""Unpack a parameter annotation value into (name, required, default).
|
|
395
|
+
|
|
396
|
+
Handles both the bare-string positional form (``@QueryValue("q")``)
|
|
397
|
+
and the named-attribute dict form (``@QueryValue(value="q", defaultValue="0")``).
|
|
398
|
+
Booleans may be stored as the strings ``"true"``/``"false"``.
|
|
399
|
+
"""
|
|
400
|
+
if isinstance(val, dict):
|
|
401
|
+
alias = val.get("name") or val.get("value") or param_name
|
|
402
|
+
req_raw = val.get("required", "true")
|
|
403
|
+
required = req_raw.lower() != "false" if isinstance(req_raw, str) else bool(req_raw)
|
|
404
|
+
default_value = val.get("defaultValue")
|
|
405
|
+
return alias, required, default_value
|
|
406
|
+
if isinstance(val, str) and val:
|
|
407
|
+
return val, True, None
|
|
408
|
+
return param_name, True, None
|
|
409
|
+
|
|
410
|
+
# -------------------------------------------------------------------------
|
|
411
|
+
# Parameter extraction
|
|
412
|
+
# -------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _extract_method_params(
|
|
415
|
+
self,
|
|
416
|
+
method: ParsedFunction,
|
|
417
|
+
path: str,
|
|
418
|
+
context: AnalysisContext | None,
|
|
419
|
+
parsed_file: ParsedFile,
|
|
420
|
+
) -> tuple[
|
|
421
|
+
list[ExtractedParameter],
|
|
422
|
+
list[ExtractedParameter],
|
|
423
|
+
list[ExtractedParameter],
|
|
424
|
+
list[ExtractedParameter],
|
|
425
|
+
ExtractedBody | None,
|
|
426
|
+
]:
|
|
427
|
+
path_params: list[ExtractedParameter] = []
|
|
428
|
+
query_params: list[ExtractedParameter] = []
|
|
429
|
+
header_params: list[ExtractedParameter] = []
|
|
430
|
+
cookie_params: list[ExtractedParameter] = []
|
|
431
|
+
body: ExtractedBody | None = None
|
|
432
|
+
|
|
433
|
+
path_template_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", path))
|
|
434
|
+
|
|
435
|
+
for param in method.parameters:
|
|
436
|
+
metadata = param.metadata or {}
|
|
437
|
+
constraints = self._extract_constraints(metadata)
|
|
438
|
+
|
|
439
|
+
# @PathVariable (also accepted in Micronaut)
|
|
440
|
+
if "PathVariable" in metadata:
|
|
441
|
+
alias = metadata["PathVariable"]
|
|
442
|
+
name, _required, _default = self._unpack_annotation_alias(alias, param.name)
|
|
443
|
+
path_params.append(
|
|
444
|
+
ExtractedParameter(
|
|
445
|
+
name=name,
|
|
446
|
+
location=ParameterLocation.PATH,
|
|
447
|
+
type_annotation=param.type_annotation,
|
|
448
|
+
required=True,
|
|
449
|
+
constraints=constraints,
|
|
450
|
+
code_location=param.location,
|
|
451
|
+
)
|
|
452
|
+
)
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# @QueryValue
|
|
456
|
+
if "QueryValue" in metadata:
|
|
457
|
+
val = metadata["QueryValue"]
|
|
458
|
+
name, required, _default = self._unpack_annotation_alias(val, param.name)
|
|
459
|
+
if constraints.get("not_null"):
|
|
460
|
+
required = True
|
|
461
|
+
query_params.append(
|
|
462
|
+
ExtractedParameter(
|
|
463
|
+
name=name,
|
|
464
|
+
location=ParameterLocation.QUERY,
|
|
465
|
+
type_annotation=param.type_annotation,
|
|
466
|
+
required=required,
|
|
467
|
+
constraints=constraints,
|
|
468
|
+
code_location=param.location,
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
# @Header
|
|
474
|
+
if "Header" in metadata:
|
|
475
|
+
val = metadata["Header"]
|
|
476
|
+
name, _required, _default = self._unpack_annotation_alias(val, param.name)
|
|
477
|
+
header_params.append(
|
|
478
|
+
ExtractedParameter(
|
|
479
|
+
name=name,
|
|
480
|
+
location=ParameterLocation.HEADER,
|
|
481
|
+
type_annotation=param.type_annotation,
|
|
482
|
+
required=True,
|
|
483
|
+
constraints=constraints,
|
|
484
|
+
code_location=param.location,
|
|
485
|
+
)
|
|
486
|
+
)
|
|
487
|
+
continue
|
|
488
|
+
|
|
489
|
+
# @Body
|
|
490
|
+
if "Body" in metadata:
|
|
491
|
+
model_name = param.type_annotation
|
|
492
|
+
model_fields: list[str] = []
|
|
493
|
+
|
|
494
|
+
if context and context.type_resolver and model_name:
|
|
495
|
+
resolved_fields = context.type_resolver.get_model_fields(
|
|
496
|
+
model_name, parsed_file.path
|
|
497
|
+
)
|
|
498
|
+
model_fields = [f.name for f in resolved_fields]
|
|
499
|
+
|
|
500
|
+
body = ExtractedBody(
|
|
501
|
+
content_type="application/json",
|
|
502
|
+
model_name=model_name,
|
|
503
|
+
model_fields=model_fields,
|
|
504
|
+
required=True,
|
|
505
|
+
)
|
|
506
|
+
continue
|
|
507
|
+
|
|
508
|
+
# @CookieValue
|
|
509
|
+
if "CookieValue" in metadata:
|
|
510
|
+
val = metadata["CookieValue"]
|
|
511
|
+
name, _required, _default = self._unpack_annotation_alias(val, param.name)
|
|
512
|
+
cookie_params.append(
|
|
513
|
+
ExtractedParameter(
|
|
514
|
+
name=name,
|
|
515
|
+
location=ParameterLocation.COOKIE,
|
|
516
|
+
type_annotation=param.type_annotation,
|
|
517
|
+
required=True,
|
|
518
|
+
constraints=constraints,
|
|
519
|
+
code_location=param.location,
|
|
520
|
+
)
|
|
521
|
+
)
|
|
522
|
+
continue
|
|
523
|
+
|
|
524
|
+
# @Part (multipart)
|
|
525
|
+
if "Part" in metadata:
|
|
526
|
+
val = metadata["Part"]
|
|
527
|
+
name, _required, _default = self._unpack_annotation_alias(val, param.name)
|
|
528
|
+
if body is None:
|
|
529
|
+
body = ExtractedBody(
|
|
530
|
+
content_type="multipart/form-data",
|
|
531
|
+
model_name=param.type_annotation,
|
|
532
|
+
required=True,
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
query_params.append(
|
|
536
|
+
ExtractedParameter(
|
|
537
|
+
name=name,
|
|
538
|
+
location=ParameterLocation.QUERY,
|
|
539
|
+
type_annotation=param.type_annotation,
|
|
540
|
+
required=True,
|
|
541
|
+
constraints=constraints,
|
|
542
|
+
code_location=param.location,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
# No annotation — infer from path template
|
|
548
|
+
if param.name in path_template_names:
|
|
549
|
+
path_params.append(
|
|
550
|
+
ExtractedParameter(
|
|
551
|
+
name=param.name,
|
|
552
|
+
location=ParameterLocation.PATH,
|
|
553
|
+
type_annotation=param.type_annotation,
|
|
554
|
+
required=True,
|
|
555
|
+
constraints=constraints,
|
|
556
|
+
code_location=param.location,
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
# Skip framework-injected types
|
|
562
|
+
skip_types = {
|
|
563
|
+
"HttpRequest",
|
|
564
|
+
"HttpResponse",
|
|
565
|
+
"Principal",
|
|
566
|
+
"Authentication",
|
|
567
|
+
"ServerRequestContext",
|
|
568
|
+
}
|
|
569
|
+
if param.type_annotation in skip_types:
|
|
570
|
+
continue
|
|
571
|
+
|
|
572
|
+
# Remaining scalars default to query params
|
|
573
|
+
if param.type_annotation and not self._is_complex_type(param.type_annotation):
|
|
574
|
+
query_params.append(
|
|
575
|
+
ExtractedParameter(
|
|
576
|
+
name=param.name,
|
|
577
|
+
location=ParameterLocation.QUERY,
|
|
578
|
+
type_annotation=param.type_annotation,
|
|
579
|
+
required=False,
|
|
580
|
+
constraints=constraints,
|
|
581
|
+
code_location=param.location,
|
|
582
|
+
)
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
return path_params, query_params, header_params, cookie_params, body
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _to_number(val: Any) -> int | float | None:
|
|
589
|
+
if isinstance(val, (int, float)) and not isinstance(val, bool):
|
|
590
|
+
return val
|
|
591
|
+
if isinstance(val, str):
|
|
592
|
+
try:
|
|
593
|
+
return int(val)
|
|
594
|
+
except ValueError:
|
|
595
|
+
pass
|
|
596
|
+
try:
|
|
597
|
+
return float(val)
|
|
598
|
+
except ValueError:
|
|
599
|
+
pass
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
def _extract_constraints(self, metadata: dict[str, Any]) -> dict[str, Any]:
|
|
603
|
+
"""Build constraints dict from Bean Validation annotations."""
|
|
604
|
+
constraints: dict[str, Any] = {}
|
|
605
|
+
|
|
606
|
+
if "NotNull" in metadata or "NonNull" in metadata:
|
|
607
|
+
constraints["not_null"] = True
|
|
608
|
+
if "NotEmpty" in metadata:
|
|
609
|
+
constraints["not_empty"] = True
|
|
610
|
+
if "NotBlank" in metadata:
|
|
611
|
+
constraints["not_blank"] = True
|
|
612
|
+
|
|
613
|
+
for ann in ("Size", "Length"):
|
|
614
|
+
if ann in metadata:
|
|
615
|
+
val = metadata[ann]
|
|
616
|
+
if isinstance(val, dict):
|
|
617
|
+
if "min" in val:
|
|
618
|
+
n = self._to_number(val["min"])
|
|
619
|
+
if n is not None:
|
|
620
|
+
constraints["min_length"] = n
|
|
621
|
+
if "max" in val:
|
|
622
|
+
n = self._to_number(val["max"])
|
|
623
|
+
if n is not None:
|
|
624
|
+
constraints["max_length"] = n
|
|
625
|
+
break
|
|
626
|
+
|
|
627
|
+
for attr, key in (
|
|
628
|
+
("Min", "min"),
|
|
629
|
+
("Max", "max"),
|
|
630
|
+
("DecimalMin", "decimal_min"),
|
|
631
|
+
("DecimalMax", "decimal_max"),
|
|
632
|
+
):
|
|
633
|
+
if attr in metadata:
|
|
634
|
+
val = metadata[attr]
|
|
635
|
+
raw = val.get("value", val) if isinstance(val, dict) else val
|
|
636
|
+
n = self._to_number(raw)
|
|
637
|
+
if n is not None:
|
|
638
|
+
constraints[key] = n
|
|
639
|
+
|
|
640
|
+
if "Pattern" in metadata:
|
|
641
|
+
val = metadata["Pattern"]
|
|
642
|
+
if isinstance(val, dict):
|
|
643
|
+
constraints["pattern"] = val.get("regexp", val.get("value", ""))
|
|
644
|
+
elif isinstance(val, str):
|
|
645
|
+
constraints["pattern"] = val
|
|
646
|
+
|
|
647
|
+
if "Email" in metadata:
|
|
648
|
+
constraints["format"] = "email"
|
|
649
|
+
if "URL" in metadata:
|
|
650
|
+
constraints["format"] = "url"
|
|
651
|
+
|
|
652
|
+
if "Positive" in metadata:
|
|
653
|
+
constraints["min"] = 1
|
|
654
|
+
if "PositiveOrZero" in metadata:
|
|
655
|
+
constraints["min"] = 0
|
|
656
|
+
if "Negative" in metadata:
|
|
657
|
+
constraints["max"] = -1
|
|
658
|
+
if "NegativeOrZero" in metadata:
|
|
659
|
+
constraints["max"] = 0
|
|
660
|
+
|
|
661
|
+
return constraints
|
|
662
|
+
|
|
663
|
+
def _is_complex_type(self, type_name: str) -> bool:
|
|
664
|
+
simple_types = {
|
|
665
|
+
"String",
|
|
666
|
+
"string",
|
|
667
|
+
"int",
|
|
668
|
+
"Integer",
|
|
669
|
+
"long",
|
|
670
|
+
"Long",
|
|
671
|
+
"boolean",
|
|
672
|
+
"Boolean",
|
|
673
|
+
"double",
|
|
674
|
+
"Double",
|
|
675
|
+
"float",
|
|
676
|
+
"Float",
|
|
677
|
+
"byte",
|
|
678
|
+
"Byte",
|
|
679
|
+
"char",
|
|
680
|
+
"Character",
|
|
681
|
+
"short",
|
|
682
|
+
"Short",
|
|
683
|
+
}
|
|
684
|
+
return type_name not in simple_types
|
|
685
|
+
|
|
686
|
+
# -------------------------------------------------------------------------
|
|
687
|
+
# Dependency extraction
|
|
688
|
+
# -------------------------------------------------------------------------
|
|
689
|
+
|
|
690
|
+
def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
|
|
691
|
+
"""Extract Micronaut-managed bean definitions."""
|
|
692
|
+
deps: list[ExtractedDependency] = []
|
|
693
|
+
|
|
694
|
+
for cls in parsed_file.classes:
|
|
695
|
+
ann_names = {d.name for d in cls.decorators}
|
|
696
|
+
|
|
697
|
+
# Standard DI beans
|
|
698
|
+
if ann_names & _BEAN_ANNOTATIONS:
|
|
699
|
+
is_auth = any(kw in cls.name.lower() for kw in _AUTH_RELATED_NAMES)
|
|
700
|
+
deps.append(
|
|
701
|
+
ExtractedDependency(
|
|
702
|
+
name=cls.name,
|
|
703
|
+
qualified_name=cls.qualified_name,
|
|
704
|
+
location=cls.location,
|
|
705
|
+
dependency_type="class",
|
|
706
|
+
provides_type=cls.name,
|
|
707
|
+
is_auth_related=is_auth,
|
|
708
|
+
confidence=Confidence.HIGH,
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
# @Client interfaces (declarative HTTP clients)
|
|
713
|
+
if "Client" in ann_names:
|
|
714
|
+
deps.append(
|
|
715
|
+
ExtractedDependency(
|
|
716
|
+
name=cls.name,
|
|
717
|
+
qualified_name=cls.qualified_name,
|
|
718
|
+
location=cls.location,
|
|
719
|
+
dependency_type="micronaut_client",
|
|
720
|
+
provides_type=cls.name,
|
|
721
|
+
is_auth_related=False,
|
|
722
|
+
confidence=Confidence.HIGH,
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return deps
|
|
727
|
+
|
|
728
|
+
# -------------------------------------------------------------------------
|
|
729
|
+
# Auth scheme extraction
|
|
730
|
+
# -------------------------------------------------------------------------
|
|
731
|
+
|
|
732
|
+
def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
|
|
733
|
+
"""Detect Micronaut Security configuration."""
|
|
734
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
735
|
+
|
|
736
|
+
for cls in parsed_file.classes:
|
|
737
|
+
ann_names = {d.name for d in cls.decorators}
|
|
738
|
+
|
|
739
|
+
# @EnableJwt or class name patterns
|
|
740
|
+
if (
|
|
741
|
+
"EnableJwt" in ann_names
|
|
742
|
+
or "SecurityConfig" in cls.name
|
|
743
|
+
or "SecurityConfiguration" in cls.name
|
|
744
|
+
):
|
|
745
|
+
schemes.append(
|
|
746
|
+
ExtractedAuthScheme(
|
|
747
|
+
scheme_type=AuthSchemeType.JWT_BEARER,
|
|
748
|
+
name=cls.name,
|
|
749
|
+
location=cls.location,
|
|
750
|
+
config={"class": cls.name},
|
|
751
|
+
confidence=Confidence.MEDIUM,
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
# Scan @Bean methods for JWT / OAuth2 patterns
|
|
756
|
+
for method in cls.methods:
|
|
757
|
+
name_lower = method.name.lower()
|
|
758
|
+
ret = (method.return_type or "").lower()
|
|
759
|
+
if "jwt" in name_lower or "jwt" in ret:
|
|
760
|
+
schemes.append(
|
|
761
|
+
ExtractedAuthScheme(
|
|
762
|
+
scheme_type=AuthSchemeType.JWT_BEARER,
|
|
763
|
+
name=method.name,
|
|
764
|
+
location=method.location,
|
|
765
|
+
config={"method": method.name, "return_type": method.return_type},
|
|
766
|
+
confidence=Confidence.MEDIUM,
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
elif "oauth" in name_lower or "oauth" in ret:
|
|
770
|
+
schemes.append(
|
|
771
|
+
ExtractedAuthScheme(
|
|
772
|
+
scheme_type=AuthSchemeType.OAUTH2_AUTHORIZATION_CODE,
|
|
773
|
+
name=method.name,
|
|
774
|
+
location=method.location,
|
|
775
|
+
config={"method": method.name},
|
|
776
|
+
confidence=Confidence.MEDIUM,
|
|
777
|
+
)
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
# Import-based detection for Micronaut Security
|
|
781
|
+
for imp in parsed_file.imports:
|
|
782
|
+
module = imp.module or ""
|
|
783
|
+
if "micronaut.security" in module:
|
|
784
|
+
if not schemes:
|
|
785
|
+
schemes.append(
|
|
786
|
+
ExtractedAuthScheme(
|
|
787
|
+
scheme_type=AuthSchemeType.SPRING_SECURITY, # closest generic
|
|
788
|
+
name="micronaut_security_from_imports",
|
|
789
|
+
location=CodeLocation(file=parsed_file.path, line=0),
|
|
790
|
+
config={"detected_via": "import", "framework": "micronaut"},
|
|
791
|
+
confidence=Confidence.LOW,
|
|
792
|
+
)
|
|
793
|
+
)
|
|
794
|
+
break
|
|
795
|
+
|
|
796
|
+
return schemes
|
|
797
|
+
|
|
798
|
+
# -------------------------------------------------------------------------
|
|
799
|
+
# Auth dependency extraction
|
|
800
|
+
# -------------------------------------------------------------------------
|
|
801
|
+
|
|
802
|
+
def extract_auth_dependencies(
|
|
803
|
+
self,
|
|
804
|
+
parsed_file: ParsedFile,
|
|
805
|
+
known_scheme_names: set[str] | None = None,
|
|
806
|
+
**kwargs: Any,
|
|
807
|
+
) -> list[ExtractedAuthDependency]:
|
|
808
|
+
"""Extract @Secured / @Requires guards."""
|
|
809
|
+
auth_deps: list[ExtractedAuthDependency] = []
|
|
810
|
+
|
|
811
|
+
for cls in parsed_file.classes:
|
|
812
|
+
# Class-level security annotations apply to all methods
|
|
813
|
+
class_has_security = any(dec.name in _SECURITY_ANNOTATIONS for dec in cls.decorators)
|
|
814
|
+
class_roles = self._collect_roles_from_annotations(cls.decorators)
|
|
815
|
+
|
|
816
|
+
if self._is_auth_class(cls):
|
|
817
|
+
auth_deps.append(
|
|
818
|
+
ExtractedAuthDependency(
|
|
819
|
+
name=cls.name,
|
|
820
|
+
qualified_name=cls.qualified_name,
|
|
821
|
+
location=cls.location,
|
|
822
|
+
dependency_type=AuthDependencyType.FILTER
|
|
823
|
+
if "filter" in cls.name.lower()
|
|
824
|
+
else AuthDependencyType.CLASS,
|
|
825
|
+
confidence=Confidence.MEDIUM,
|
|
826
|
+
)
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
# Class-level @Secured / @Requires — emit a single dependency
|
|
830
|
+
# keyed by the class name so routes whose handler methods have no
|
|
831
|
+
# method-level guard still have a matching auth_dep entry.
|
|
832
|
+
if class_has_security:
|
|
833
|
+
auth_deps.append(
|
|
834
|
+
ExtractedAuthDependency(
|
|
835
|
+
name=cls.name,
|
|
836
|
+
qualified_name=cls.qualified_name,
|
|
837
|
+
location=cls.location,
|
|
838
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
839
|
+
requires_roles=class_roles,
|
|
840
|
+
confidence=Confidence.HIGH,
|
|
841
|
+
)
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
for method in cls.methods:
|
|
845
|
+
method_has_ann = any(dec.name in _SECURITY_ANNOTATIONS for dec in method.decorators)
|
|
846
|
+
# Only emit a method-level dependency when the method itself
|
|
847
|
+
# carries a security annotation. The class-level dep above
|
|
848
|
+
# already covers handlers that inherit the class-level guard.
|
|
849
|
+
if method_has_ann:
|
|
850
|
+
method_roles = self._collect_roles_from_annotations(method.decorators)
|
|
851
|
+
all_roles = class_roles + method_roles
|
|
852
|
+
auth_deps.append(
|
|
853
|
+
ExtractedAuthDependency(
|
|
854
|
+
name=f"{cls.name}.{method.name}",
|
|
855
|
+
qualified_name=method.qualified_name,
|
|
856
|
+
location=method.location,
|
|
857
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
858
|
+
requires_roles=all_roles,
|
|
859
|
+
confidence=Confidence.HIGH,
|
|
860
|
+
)
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
return auth_deps
|
|
864
|
+
|
|
865
|
+
def _is_auth_class(self, cls: ParsedClass) -> bool:
|
|
866
|
+
name_lower = cls.name.lower()
|
|
867
|
+
if any(kw in name_lower for kw in _AUTH_RELATED_NAMES):
|
|
868
|
+
return True
|
|
869
|
+
for base in cls.base_classes:
|
|
870
|
+
if any(
|
|
871
|
+
iface in base
|
|
872
|
+
for iface in [
|
|
873
|
+
"HttpServerFilter",
|
|
874
|
+
"OncePerRequestFilter",
|
|
875
|
+
"TokenValidator",
|
|
876
|
+
"AuthenticationProvider",
|
|
877
|
+
]
|
|
878
|
+
):
|
|
879
|
+
return True
|
|
880
|
+
return False
|
|
881
|
+
|
|
882
|
+
def _collect_roles_from_annotations(self, decorators: list[ParsedDecorator]) -> list[str]:
|
|
883
|
+
"""Extract role names from @Secured / @Requires / @RolesAllowed."""
|
|
884
|
+
roles: list[str] = []
|
|
885
|
+
for dec in decorators:
|
|
886
|
+
if dec.name not in _SECURITY_ANNOTATIONS:
|
|
887
|
+
continue
|
|
888
|
+
|
|
889
|
+
# @Secured({"ROLE_ADMIN", "ROLE_USER"})
|
|
890
|
+
for val in dec.positional_args:
|
|
891
|
+
if isinstance(val, list):
|
|
892
|
+
roles.extend(str(v) for v in val)
|
|
893
|
+
elif isinstance(val, str):
|
|
894
|
+
# SecurityRule constants: IS_AUTHENTICATED, IS_ANONYMOUS
|
|
895
|
+
if val.startswith("SecurityRule."):
|
|
896
|
+
roles.append(val.split(".")[-1])
|
|
897
|
+
else:
|
|
898
|
+
roles.append(val)
|
|
899
|
+
|
|
900
|
+
# @Requires(roles = "ROLE_ADMIN")
|
|
901
|
+
val = dec.arguments.get("roles")
|
|
902
|
+
if val:
|
|
903
|
+
if isinstance(val, list):
|
|
904
|
+
roles.extend(str(v) for v in val)
|
|
905
|
+
elif isinstance(val, str):
|
|
906
|
+
roles.append(val)
|
|
907
|
+
|
|
908
|
+
return roles
|
|
909
|
+
|
|
910
|
+
# -------------------------------------------------------------------------
|
|
911
|
+
# Middleware extraction
|
|
912
|
+
# -------------------------------------------------------------------------
|
|
913
|
+
|
|
914
|
+
def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
|
|
915
|
+
"""Detect Micronaut @Filter classes."""
|
|
916
|
+
middleware: list[ExtractedMiddleware] = []
|
|
917
|
+
|
|
918
|
+
for cls in parsed_file.classes:
|
|
919
|
+
ann_names = {d.name for d in cls.decorators}
|
|
920
|
+
|
|
921
|
+
# @Filter("/**") implements HttpServerFilter or ServerFilter
|
|
922
|
+
is_filter = "Filter" in ann_names or any(
|
|
923
|
+
base in ("HttpServerFilter", "ServerFilter", "HttpClientFilter")
|
|
924
|
+
or base.endswith("Filter")
|
|
925
|
+
for base in cls.base_classes
|
|
926
|
+
)
|
|
927
|
+
if is_filter:
|
|
928
|
+
ops = self._infer_filter_operations(cls)
|
|
929
|
+
pattern = self._filter_pattern(cls)
|
|
930
|
+
middleware.append(
|
|
931
|
+
ExtractedMiddleware(
|
|
932
|
+
name=cls.name,
|
|
933
|
+
qualified_name=cls.qualified_name,
|
|
934
|
+
location=cls.location,
|
|
935
|
+
middleware_type="filter",
|
|
936
|
+
applies_to_all=pattern in ("/**", "/*", ""),
|
|
937
|
+
applies_to_patterns=[pattern] if pattern else [],
|
|
938
|
+
operations=ops,
|
|
939
|
+
confidence=Confidence.HIGH,
|
|
940
|
+
)
|
|
941
|
+
)
|
|
942
|
+
|
|
943
|
+
return middleware
|
|
944
|
+
|
|
945
|
+
def _filter_pattern(self, cls: ParsedClass) -> str:
|
|
946
|
+
"""Extract the URL pattern from @Filter("/**")."""
|
|
947
|
+
for dec in cls.decorators:
|
|
948
|
+
if dec.name == "Filter":
|
|
949
|
+
p = self._annotation_path(dec)
|
|
950
|
+
if p:
|
|
951
|
+
return p
|
|
952
|
+
return "/**"
|
|
953
|
+
|
|
954
|
+
def _infer_filter_operations(self, cls: ParsedClass) -> list[str]:
|
|
955
|
+
ops: list[str] = []
|
|
956
|
+
combined = cls.name.lower() + " " + " ".join(b.lower() for b in cls.base_classes)
|
|
957
|
+
if any(kw in combined for kw in ["auth", "jwt", "token", "security", "login"]):
|
|
958
|
+
ops.append("auth")
|
|
959
|
+
if "cors" in combined:
|
|
960
|
+
ops.append("cors")
|
|
961
|
+
if "log" in combined:
|
|
962
|
+
ops.append("logging")
|
|
963
|
+
if "rate" in combined or "throttl" in combined:
|
|
964
|
+
ops.append("rate_limiting")
|
|
965
|
+
return ops or ["custom"]
|
|
966
|
+
|
|
967
|
+
# -------------------------------------------------------------------------
|
|
968
|
+
# Annotation helpers
|
|
969
|
+
# -------------------------------------------------------------------------
|
|
970
|
+
|
|
971
|
+
def _annotation_paths(self, dec: ParsedDecorator) -> list[str]:
|
|
972
|
+
"""Extract all path values (handles single and array forms)."""
|
|
973
|
+
if dec.positional_args:
|
|
974
|
+
val = dec.positional_args[0]
|
|
975
|
+
if isinstance(val, str):
|
|
976
|
+
return [val]
|
|
977
|
+
if isinstance(val, list):
|
|
978
|
+
return [str(v) for v in val if v]
|
|
979
|
+
for key in ("value", "uri", "uris"):
|
|
980
|
+
val = dec.arguments.get(key)
|
|
981
|
+
if val is not None:
|
|
982
|
+
if isinstance(val, str):
|
|
983
|
+
return [val]
|
|
984
|
+
if isinstance(val, list):
|
|
985
|
+
return [str(v) for v in val if v]
|
|
986
|
+
return []
|
|
987
|
+
|
|
988
|
+
def _annotation_path(self, dec: ParsedDecorator) -> str | None:
|
|
989
|
+
paths = self._annotation_paths(dec)
|
|
990
|
+
return paths[0] if paths else None
|
|
991
|
+
|
|
992
|
+
def _annotation_str(self, dec: ParsedDecorator, key: str) -> str | None:
|
|
993
|
+
val = dec.arguments.get(key)
|
|
994
|
+
if val is None:
|
|
995
|
+
return None
|
|
996
|
+
if isinstance(val, str):
|
|
997
|
+
return val
|
|
998
|
+
if isinstance(val, list) and val:
|
|
999
|
+
return str(val[0])
|
|
1000
|
+
return None
|
|
1001
|
+
|
|
1002
|
+
def _extract_status_code(self, dec: ParsedDecorator) -> int:
|
|
1003
|
+
"""Extract HTTP status code from @Status."""
|
|
1004
|
+
candidates: list[Any] = list(dec.positional_args)
|
|
1005
|
+
for attr in ("code", "value"):
|
|
1006
|
+
v = dec.arguments.get(attr)
|
|
1007
|
+
if v is not None:
|
|
1008
|
+
candidates.append(v)
|
|
1009
|
+
for val in candidates:
|
|
1010
|
+
if isinstance(val, int):
|
|
1011
|
+
return val
|
|
1012
|
+
if isinstance(val, str):
|
|
1013
|
+
s = val.split(".")[-1].upper()
|
|
1014
|
+
code = _HTTP_STATUS_MAP.get(s)
|
|
1015
|
+
if code is not None:
|
|
1016
|
+
return code
|
|
1017
|
+
return 200
|
|
1018
|
+
|
|
1019
|
+
@staticmethod
|
|
1020
|
+
def _strip_query_template(path: str) -> str:
|
|
1021
|
+
"""Remove Micronaut URI template query expansions from a path.
|
|
1022
|
+
|
|
1023
|
+
Micronaut supports {?param1,param2} (form-style query expansion) inline
|
|
1024
|
+
in @Get annotations, e.g. "/articles{?tag,author,limit}". These are NOT
|
|
1025
|
+
part of the HTTP path — they are query parameters. Strip only the
|
|
1026
|
+
query/fragment/reserved expansion blocks (those starting with ?, &, +, #)
|
|
1027
|
+
while leaving normal path params ({slug}, {id}) intact.
|
|
1028
|
+
"""
|
|
1029
|
+
return re.sub(r"\{[?&+#][^}]*\}", "", path)
|
|
1030
|
+
|
|
1031
|
+
def _join_paths(self, prefix: str, path: str) -> str:
|
|
1032
|
+
prefix = prefix.rstrip("/")
|
|
1033
|
+
path = self._strip_query_template(path)
|
|
1034
|
+
if path and not path.startswith("/"):
|
|
1035
|
+
path = "/" + path
|
|
1036
|
+
result = prefix + path
|
|
1037
|
+
return result if result else "/"
|
|
1038
|
+
|
|
1039
|
+
# -------------------------------------------------------------------------
|
|
1040
|
+
# JWT config extraction
|
|
1041
|
+
# -------------------------------------------------------------------------
|
|
1042
|
+
|
|
1043
|
+
_jwt_extractor = JavaJwtConfigExtractor()
|
|
1044
|
+
|
|
1045
|
+
def extract_jwt_config(
|
|
1046
|
+
self,
|
|
1047
|
+
parsed_file: ParsedFile,
|
|
1048
|
+
context: AnalysisContext | None = None,
|
|
1049
|
+
) -> ExtractedJwtConfig | None:
|
|
1050
|
+
"""Extract JWT library, algorithms, validation flags, and secret source."""
|
|
1051
|
+
return self._jwt_extractor.extract(parsed_file)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
# =============================================================================
|
|
1055
|
+
# Registration
|
|
1056
|
+
# =============================================================================
|
|
1057
|
+
|
|
1058
|
+
_micronaut_plugin = MicronautPlugin()
|
|
1059
|
+
FrameworkPluginRegistry.register(_micronaut_plugin)
|