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

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