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,732 @@
1
+ """
2
+ Legacy ASP.NET framework plugin (System.Web.Mvc / System.Web.Http).
3
+
4
+ Supports:
5
+ - ASP.NET MVC 5 (System.Web.Mvc.Controller)
6
+ - ASP.NET Web API 2 (System.Web.Http.ApiController)
7
+ - Attribute routing: [RoutePrefix] on class, [Route] on method
8
+ - Conventional routing: /{Controller}/{action} (MVC), /api/{Controller} (Web API)
9
+ - HTTP verb attributes: [HttpGet], [HttpPost], [HttpPut], [HttpDelete], [HttpPatch]
10
+ - Auth: [Authorize], [AllowAnonymous] (class- and method-level)
11
+ - Parameter binding: [FromUri] (query/path), [FromBody], [FromRoute], [FromHeader]
12
+ - Global filter auth: GlobalFilters.Filters.Add(new AuthorizeAttribute())
13
+ - Forms/Windows authentication registration detection
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import re
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, ClassVar
22
+
23
+ from ...core.types import (
24
+ AuthSchemeType,
25
+ CodeLocation,
26
+ Confidence,
27
+ Framework,
28
+ HttpMethod,
29
+ Language,
30
+ ParameterLocation,
31
+ QualifiedName,
32
+ )
33
+ from ...parsing.base import ParsedClass, ParsedFile, ParsedFunction
34
+ from ...parsing.csharp.literals import parse_csharp_string_literal
35
+ from ...parsing.services import AnalysisContext
36
+ from ..base import (
37
+ ExtractedAuthScheme,
38
+ ExtractedBody,
39
+ ExtractedParameter,
40
+ ExtractedRoute,
41
+ FrameworkPluginRegistry,
42
+ )
43
+ from .aspnet_plugin import AspNetCorePlugin
44
+
45
+ if TYPE_CHECKING:
46
+ pass
47
+
48
+ # =============================================================================
49
+ # Constants
50
+ # =============================================================================
51
+
52
+ _LEGACY_IMPORTS: frozenset[str] = frozenset(
53
+ {
54
+ "System.Web.Mvc",
55
+ "System.Web.Http",
56
+ "System.Web.Http.ApiExplorer",
57
+ "System.Web.Http.Cors",
58
+ "System.Web.Http.Filters",
59
+ "System.Web.Http.Results",
60
+ "System.Web.Routing",
61
+ }
62
+ )
63
+
64
+ # ApiController base implies Web API 2; Controller base implies MVC 5
65
+ _WEB_API_BASES: frozenset[str] = frozenset({"ApiController"})
66
+
67
+ # Auth-related global filter / config class name substrings
68
+ _AUTH_CLASS_KEYWORDS: frozenset[str] = frozenset(
69
+ {
70
+ "filterconfig",
71
+ "globalfilter",
72
+ "startup",
73
+ "authconfig",
74
+ "webapiconfiguration",
75
+ "webapiconfig",
76
+ }
77
+ )
78
+
79
+
80
+ # =============================================================================
81
+ # LegacyAspNetPlugin
82
+ # =============================================================================
83
+
84
+
85
+ class LegacyAspNetPlugin(AspNetCorePlugin):
86
+ """
87
+ Framework plugin for legacy ASP.NET (System.Web.Mvc / System.Web.Http).
88
+
89
+ Inherits route-extraction, auth-dep, and parameter-parsing logic from
90
+ AspNetCorePlugin and overrides only the pieces that differ:
91
+
92
+ - Detection: requires System.Web.Mvc or System.Web.Http import
93
+ - Class prefix: [RoutePrefix] takes priority over [Route] on class
94
+ - Conventional fallback: /api/{Controller} for ApiController subclasses
95
+ - Parameter binding: [FromUri] mapped to query/path (Web API convention)
96
+ - Auth registration: FormsAuthentication, Windows, GlobalFilters patterns
97
+ """
98
+
99
+ FRAMEWORK: ClassVar[Framework] = Framework.ASPNET_LEGACY
100
+ LANGUAGE: ClassVar[Language] = Language.CSHARP
101
+ DETECTION_IMPORTS: ClassVar[frozenset[str]] = _LEGACY_IMPORTS
102
+
103
+ # -------------------------------------------------------------------------
104
+ # Detection
105
+ # -------------------------------------------------------------------------
106
+
107
+ def detect(self, parsed_file: ParsedFile) -> bool:
108
+ for imp in parsed_file.imports:
109
+ module = imp.module or ""
110
+ if module.startswith("System.Web.Mvc") or module.startswith("System.Web.Http"):
111
+ return True
112
+ return False
113
+
114
+ # -------------------------------------------------------------------------
115
+ # Route extraction overrides
116
+ # -------------------------------------------------------------------------
117
+
118
+ def extract_routes(
119
+ self,
120
+ parsed_file: ParsedFile,
121
+ context: AnalysisContext | None = None,
122
+ ) -> list[ExtractedRoute]:
123
+ # Orchard CMS modules ship a `Module.txt` manifest beside their
124
+ # `Controllers/` directory and are routed by Orchard's runtime
125
+ # `StandardExtensionRouteProvider` rather than the global MVC
126
+ # route table. When this file is inside an Orchard module, the
127
+ # default Legacy MVC 5 routing convention doesn't apply — divert
128
+ # to the module-aware composition.
129
+ orchard_module = _detect_orchard_module(parsed_file.path)
130
+ if orchard_module is not None:
131
+ return self._extract_orchard_module_routes(
132
+ parsed_file,
133
+ orchard_module,
134
+ context,
135
+ )
136
+
137
+ routes: list[ExtractedRoute] = []
138
+ maproute_overrides = self._project_maproute_overrides(context, parsed_file)
139
+
140
+ for cls in parsed_file.classes:
141
+ if not self._is_controller(cls):
142
+ continue
143
+
144
+ class_prefix = self._get_class_prefix(cls, parsed_file)
145
+
146
+ # Conventional routing applies when the controller has no
147
+ # class-level `[Route]` attribute. Note: `[RoutePrefix]` is NOT
148
+ # excluded here — a prefix is just a prefix, not a full URL, so
149
+ # the downstream Web API 2 path template (`api/{controller}/{id?}`)
150
+ # still applies for methods without their own `[Route]`. E.g.
151
+ # `[RoutePrefix("api/v1/orders")]` + `Get(int id)` (no method
152
+ # `[Route]`) → `/api/v1/orders/{id}`, not `/api/v1/orders`.
153
+ #
154
+ # We also consult `_is_controller` rather than re-checking the
155
+ # narrow base-class set, so the name-suffix heuristic (e.g.
156
+ # `BasePublicController : Controller`) participates too.
157
+ is_conventional = not any(
158
+ d.name == "Route" for d in cls.decorators
159
+ ) and self._is_controller(cls)
160
+
161
+ for method in cls.methods:
162
+ routes.extend(
163
+ self._extract_routes_from_method(
164
+ method,
165
+ class_prefix,
166
+ cls,
167
+ parsed_file,
168
+ context,
169
+ is_conventional_mvc=is_conventional,
170
+ maproute_overrides=maproute_overrides,
171
+ )
172
+ )
173
+
174
+ # Legacy ASP.NET has no Minimal API or IEndpointGroup equivalents
175
+ return routes
176
+
177
+ def _extract_orchard_module_routes(
178
+ self,
179
+ parsed_file: ParsedFile,
180
+ module_name: str,
181
+ context: AnalysisContext | None,
182
+ ) -> list[ExtractedRoute]:
183
+ """
184
+ Emit routes for a controller in an Orchard module.
185
+
186
+ Orchard's `StandardExtensionRouteProvider` registers two URL
187
+ templates per loaded module:
188
+
189
+ admin: /Admin/<displayPath>/{action}/{id}
190
+ front: /<displayPath>/{controller}/{action}/{id}
191
+
192
+ where `displayPath` is the module folder name. Per-module
193
+ `Routes.cs` files (IRouteProvider impls) can register additional
194
+ hand-defined patterns alongside the default convention; those
195
+ are out of scope for this pass and would need their own parser
196
+ — same shape as the WCF programmatic-endpoint discovery.
197
+
198
+ Reference: src/Orchard/Mvc/Routes/StandardExtensionRouteProvider.cs:49
199
+ """
200
+ routes: list[ExtractedRoute] = []
201
+ # One source read for the whole file — used for per-method access
202
+ # modifier inspection. `ParsedFunction` doesn't track access
203
+ # modifiers (only `ParsedField` does); we read the source line
204
+ # for the method declaration and look for `public`.
205
+ source_lines = _read_source_lines(parsed_file.path)
206
+ for cls in parsed_file.classes:
207
+ if not self._is_controller(cls):
208
+ continue
209
+ # Abstract controllers exist only to be subclassed — their
210
+ # methods are infrastructure, not route surface. ASP.NET
211
+ # MVC's runtime routing won't dispatch to an abstract type.
212
+ if cls.is_abstract:
213
+ continue
214
+ # A controller in an Orchard module that declares its own
215
+ # `[RoutePrefix]` or class-level `[Route]` opts out of the
216
+ # module convention — at runtime, attribute routing takes
217
+ # precedence over `StandardExtensionRouteProvider`. Defer
218
+ # to the inherited attribute-routing logic for that class.
219
+ if any(d.name in ("Route", "RoutePrefix") for d in cls.decorators):
220
+ class_prefix = self._get_class_prefix(cls, parsed_file)
221
+ is_conventional = not any(d.name == "Route" for d in cls.decorators)
222
+ for method in cls.methods:
223
+ if not _method_is_public(method, source_lines):
224
+ continue
225
+ routes.extend(
226
+ self._extract_routes_from_method(
227
+ method,
228
+ class_prefix,
229
+ cls,
230
+ parsed_file,
231
+ context,
232
+ is_conventional_mvc=is_conventional,
233
+ maproute_overrides=None,
234
+ )
235
+ )
236
+ continue
237
+ # Web API 2 (`: ApiController`) and MVC 5 (`: Controller`)
238
+ # are routed by DIFFERENT Orchard providers:
239
+ # MVC 5 → StandardExtensionRouteProvider.cs:49
240
+ # /<displayPath>/{controller}/{action}/{id} (front)
241
+ # /Admin/<displayPath>/{action}/{id} (admin)
242
+ # Web API 2 → StandardExtensionHttpRouteProvider.cs:32
243
+ # api/<displayPath>/{controller}/{id} (verb-based, no action)
244
+ is_api_controller = any(self._base_name(b) == "ApiController" for b in cls.base_classes)
245
+ controller_name = cls.name
246
+ if controller_name.endswith("Controller"):
247
+ controller_name = controller_name[: -len("Controller")]
248
+ if is_api_controller:
249
+ class_prefix = f"/api/{module_name}/{controller_name}"
250
+ elif controller_name == "Admin":
251
+ class_prefix = f"/Admin/{module_name}"
252
+ else:
253
+ class_prefix = f"/{module_name}/{controller_name}"
254
+
255
+ for method in cls.methods:
256
+ # Skip non-public methods — MVC routing requires `public`.
257
+ # Internal overloads (e.g. `internal ActionResult
258
+ # EditPOST(int id, string returnUrl)`) and private helpers
259
+ # are implementation, not route surface.
260
+ if not _method_is_public(method, source_lines):
261
+ continue
262
+ method_routes = self._extract_routes_from_method(
263
+ method,
264
+ class_prefix,
265
+ cls,
266
+ parsed_file,
267
+ context,
268
+ is_conventional_mvc=True,
269
+ maproute_overrides=None,
270
+ )
271
+ # Web API 2: verb-based routing — no action segment in URL.
272
+ # Multiple methods with different `[HttpGet]`/`[HttpPost]`
273
+ # attributes share the same URL.
274
+ #
275
+ # MVC 5: force the Orchard URL template
276
+ # `<prefix>/<action>/{id}` — overrides the inherited
277
+ # convention which (a) strips "Index" as the MVC 5
278
+ # default action and (b) doesn't append `/{id}`.
279
+ #
280
+ # `[ActionName("Foo")]` aliases the URL action segment
281
+ # for MVC 5; multiple C# methods can share one URL via
282
+ # this attribute + `[FormValueRequired]` filters.
283
+ if is_api_controller:
284
+ for r in method_routes:
285
+ r.path = f"{class_prefix}/{{id}}"
286
+ else:
287
+ action_name = self._orchard_action_name(method)
288
+ for r in method_routes:
289
+ r.path = f"{class_prefix}/{action_name}/{{id}}"
290
+ routes.extend(method_routes)
291
+
292
+ # Routes.cs files in an Orchard module can implement
293
+ # `IRouteProvider` and register additional `new Route("URL", ...)`
294
+ # patterns alongside the default convention (e.g. `/rss`,
295
+ # `/Admin/Reports`, `/{*path}`). Emit one route per call.
296
+ if any(
297
+ self._base_name(b) == "IRouteProvider"
298
+ for cls in parsed_file.classes
299
+ for b in cls.base_classes
300
+ ):
301
+ routes.extend(self._extract_iroute_provider_routes(parsed_file))
302
+ return routes
303
+
304
+ @staticmethod
305
+ def _base_name(base: str) -> str:
306
+ return base.rsplit(".", 1)[-1]
307
+
308
+ def _extract_iroute_provider_routes(
309
+ self,
310
+ parsed_file: ParsedFile,
311
+ ) -> list[ExtractedRoute]:
312
+ """Parse `new Route("URL", { ... })` calls in an Orchard
313
+ `IRouteProvider` implementation file. Each call becomes one
314
+ emitted route at the URL-string-literal line."""
315
+ if parsed_file.path is None:
316
+ return []
317
+ try:
318
+ text = Path(parsed_file.path).read_text(
319
+ encoding="utf-8",
320
+ errors="replace",
321
+ )
322
+ except OSError:
323
+ return []
324
+
325
+ routes: list[ExtractedRoute] = []
326
+ for match in _NEW_ROUTE_RE.finditer(text):
327
+ parsed = _parse_route_call_args(text, match.end())
328
+ if parsed is None:
329
+ continue
330
+ url_pattern, controller, action, url_offset = parsed
331
+ path = "/" + url_pattern.lstrip("/")
332
+ line_num = text.count("\n", 0, url_offset) + 1
333
+
334
+ handler_label = (
335
+ f"{(controller or 'Home').title()}Controller.{(action or 'Index').title()}"
336
+ )
337
+ handler_qname = QualifiedName(module="", name=handler_label)
338
+
339
+ routes.append(
340
+ ExtractedRoute(
341
+ method=HttpMethod.GET,
342
+ path=path,
343
+ handler_function=handler_qname,
344
+ handler_location=CodeLocation(
345
+ file=Path(parsed_file.path),
346
+ line=line_num,
347
+ ),
348
+ tags=["aspnet_legacy", "orchard_route_provider"],
349
+ confidence=Confidence.MEDIUM,
350
+ kind="http",
351
+ )
352
+ )
353
+ return routes
354
+
355
+ def _orchard_action_name(self, method: ParsedFunction) -> str:
356
+ """Return the URL action segment for an Orchard module action.
357
+
358
+ `[ActionName("Foo")]` aliases the URL action regardless of the
359
+ C# method name; without it the C# method name is used verbatim.
360
+ """
361
+ for dec in method.decorators:
362
+ if dec.name == "ActionName":
363
+ alias = self._dec_path(dec)
364
+ if alias:
365
+ return alias
366
+ return method.name
367
+
368
+ def _get_class_prefix(self, cls: ParsedClass, parsed_file: ParsedFile) -> str:
369
+ """
370
+ Resolve class-level route prefix.
371
+
372
+ Priority order:
373
+ 1. [RoutePrefix("api/values")] — Web API 2 attribute
374
+ 2. [Route("prefix")] — both MVC 5 and Web API 2
375
+ 3. Conventional fallback:
376
+ - ApiController subclass → /api/{ControllerName}
377
+ - Controller subclass → /{ControllerName}
378
+ """
379
+ controller_name = cls.name
380
+ if controller_name.endswith("Controller"):
381
+ controller_name = controller_name[: -len("Controller")]
382
+
383
+ for dec in cls.decorators:
384
+ if dec.name == "RoutePrefix":
385
+ path = self._dec_path(dec)
386
+ if path:
387
+ path = path.replace("[controller]", controller_name)
388
+ path = path.replace("[Controller]", controller_name)
389
+ return ("/" + path.strip("/")).rstrip("/")
390
+
391
+ for dec in cls.decorators:
392
+ if dec.name == "Route":
393
+ path = self._dec_path(dec)
394
+ if path:
395
+ path = path.replace("[controller]", controller_name)
396
+ path = path.replace("[Controller]", controller_name)
397
+ return path.rstrip("/")
398
+
399
+ # Conventional routing fallback. ApiController subclass → /api/{x};
400
+ # any other recognised controller (incl. intermediate-base subclasses
401
+ # like BasePublicController) → /{x}.
402
+ for base in cls.base_classes:
403
+ simple = base.rsplit(".", 1)[-1]
404
+ if simple in _WEB_API_BASES:
405
+ return f"/api/{controller_name}"
406
+ if self._is_controller(cls):
407
+ return f"/{controller_name}"
408
+ return ""
409
+
410
+ def _extract_params(
411
+ self,
412
+ method: ParsedFunction,
413
+ path: str,
414
+ context: AnalysisContext | None,
415
+ parsed_file: ParsedFile,
416
+ ) -> tuple[
417
+ list[ExtractedParameter],
418
+ list[ExtractedParameter],
419
+ list[ExtractedParameter],
420
+ list[ExtractedParameter],
421
+ ExtractedBody | None,
422
+ ]:
423
+ import re
424
+
425
+ path_params, query_params, header_params, cookie_params, body = super()._extract_params(
426
+ method, path, context, parsed_file
427
+ )
428
+
429
+ # [FromUri] is Web API 2's catch-all "bind from URL" attribute.
430
+ # Parameters that appear in the route template are path params; the
431
+ # rest are query params. The parent already handles [FromRoute] and
432
+ # [FromQuery]; we only need to process parameters that the parent left
433
+ # unclassified because it doesn't know [FromUri].
434
+ #
435
+ # We detect them by re-inspecting the raw parameter list: any param
436
+ # with [FromUri] in its metadata that the parent did NOT emit is
437
+ # processed here.
438
+ already_named = (
439
+ {p.name for p in path_params}
440
+ | {p.name for p in query_params}
441
+ | {p.name for p in header_params}
442
+ )
443
+ path_tpl_names = set(re.findall(r"\{([^}:]+)(?::[^}]+)?\}", path))
444
+
445
+ for param in method.parameters:
446
+ if param.name in already_named:
447
+ continue
448
+ meta = param.metadata or {}
449
+ if "FromUri" not in meta:
450
+ continue
451
+ constraints = self._extract_constraints(meta)
452
+ if param.name in path_tpl_names:
453
+ path_params.append(
454
+ ExtractedParameter(
455
+ name=param.name,
456
+ location=ParameterLocation.PATH,
457
+ type_annotation=param.type_annotation,
458
+ required=True,
459
+ constraints=constraints,
460
+ code_location=param.location,
461
+ )
462
+ )
463
+ else:
464
+ query_params.append(
465
+ ExtractedParameter(
466
+ name=param.name,
467
+ location=ParameterLocation.QUERY,
468
+ type_annotation=param.type_annotation,
469
+ required=param.default_value is None and param.default_value != "null",
470
+ default_value=param.default_value,
471
+ constraints=constraints,
472
+ code_location=param.location,
473
+ )
474
+ )
475
+
476
+ return path_params, query_params, header_params, cookie_params, body
477
+
478
+ # -------------------------------------------------------------------------
479
+ # Auth scheme detection overrides
480
+ # -------------------------------------------------------------------------
481
+
482
+ def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
483
+ schemes = super().extract_auth_schemes(parsed_file)
484
+ schemes.extend(self._detect_legacy_auth(parsed_file))
485
+ return schemes
486
+
487
+ def _detect_legacy_auth(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
488
+ """Detect legacy ASP.NET auth patterns not present in ASP.NET Core."""
489
+ schemes: list[ExtractedAuthScheme] = []
490
+
491
+ # FormsAuthentication / WindowsAuthentication usage in code
492
+ for call in parsed_file.call_sites:
493
+ callee_lower = call.callee_name.lower()
494
+ if "formsauthentication" in callee_lower:
495
+ schemes.append(
496
+ ExtractedAuthScheme(
497
+ scheme_type=AuthSchemeType.SESSION_COOKIE,
498
+ name="forms_authentication",
499
+ location=call.location,
500
+ config={"detected_via": call.callee_name},
501
+ confidence=Confidence.HIGH,
502
+ )
503
+ )
504
+ break
505
+ if "windowsauthentication" in callee_lower or "windowsidentity" in callee_lower:
506
+ schemes.append(
507
+ ExtractedAuthScheme(
508
+ scheme_type=AuthSchemeType.CUSTOM,
509
+ name="windows_authentication",
510
+ location=call.location,
511
+ config={"detected_via": call.callee_name, "scheme": "Windows"},
512
+ confidence=Confidence.HIGH,
513
+ )
514
+ )
515
+ break
516
+
517
+ # GlobalFilters.Filters.Add(new AuthorizeAttribute()) — app-wide auth
518
+ for call in parsed_file.call_sites:
519
+ if call.callee_name.split(".")[-1] == "Add" and any(
520
+ a.expression_text and "AuthorizeAttribute" in a.expression_text
521
+ for a in call.arguments
522
+ if hasattr(a, "expression_text") and a.expression_text
523
+ ):
524
+ schemes.append(
525
+ ExtractedAuthScheme(
526
+ scheme_type=AuthSchemeType.CUSTOM,
527
+ name="global_authorize_filter",
528
+ location=call.location,
529
+ config={"detected_via": "GlobalFilters.Filters.Add(AuthorizeAttribute)"},
530
+ confidence=Confidence.MEDIUM,
531
+ )
532
+ )
533
+ break
534
+
535
+ # FilterConfig / WebApiConfig class with auth registration
536
+ for cls in parsed_file.classes:
537
+ if any(kw in cls.name.lower() for kw in _AUTH_CLASS_KEYWORDS):
538
+ for method in cls.methods:
539
+ for call in parsed_file.call_sites:
540
+ callee_lower = call.callee_name.lower()
541
+ if "authorize" in callee_lower and "filter" in callee_lower:
542
+ schemes.append(
543
+ ExtractedAuthScheme(
544
+ scheme_type=AuthSchemeType.CUSTOM,
545
+ name=f"{cls.name}.{method.name}",
546
+ location=call.location,
547
+ config={
548
+ "class": cls.name,
549
+ "method": method.name,
550
+ "framework": "aspnet_legacy",
551
+ },
552
+ confidence=Confidence.MEDIUM,
553
+ )
554
+ )
555
+ return schemes # one entry is enough
556
+
557
+ return schemes
558
+
559
+
560
+ def _read_source_lines(path: Path | None) -> list[str] | None:
561
+ """Read a source file once and return its lines, or None on any
562
+ error. Used for cheap source-line inspection (visibility, decorator
563
+ walks) where the parser doesn't expose enough structure."""
564
+ if path is None:
565
+ return None
566
+ try:
567
+ return Path(path).read_text(encoding="utf-8", errors="replace").splitlines()
568
+ except OSError:
569
+ return None
570
+
571
+
572
+ def _method_is_public(
573
+ method: ParsedFunction,
574
+ source_lines: list[str] | None,
575
+ ) -> bool:
576
+ """
577
+ Best-effort access-modifier detection from the method's signature
578
+ line. `ParsedFunction` doesn't track `access_modifier` (only
579
+ `ParsedField` does), so we read the C# source.
580
+
581
+ The parser sets `method.location.line` at the top of the method's
582
+ AST node — which for decorated methods is the topmost attribute
583
+ line, not the signature line with the access modifier. So we
584
+ scan forward from that line for the first line containing
585
+ `<MethodName>(`, then check for the `public` keyword on that line.
586
+
587
+ C# methods in classes default to `private` when no access modifier
588
+ is present, so absence of `public` (and absence of any non-public
589
+ modifier) is treated as non-routable.
590
+
591
+ Returns True defensively when source isn't readable or the
592
+ signature line can't be located (preserves pre-existing emission
593
+ for cases the parser surfaces unusually).
594
+ """
595
+ if source_lines is None or method.location is None:
596
+ return True
597
+ line_idx = method.location.line - 1
598
+ if line_idx < 0:
599
+ return True
600
+ name_re = re.compile(rf"\b{re.escape(method.name)}\s*\(")
601
+ for i in range(line_idx, min(len(source_lines), line_idx + 15)):
602
+ line = source_lines[i]
603
+ if not name_re.search(line):
604
+ continue
605
+ pre_paren = line.split("(", 1)[0]
606
+ tokens = set(re.findall(r"\b\w+\b", pre_paren))
607
+ if "public" in tokens:
608
+ return True
609
+ if tokens & {"private", "protected", "internal"}:
610
+ return False
611
+ return False # no explicit modifier — C# default is private
612
+ return True # couldn't locate signature line — defensive
613
+
614
+
615
+ # Matches the start of a `new Route(` constructor call. The body is
616
+ # parsed via paren-balanced scanning to handle nested initializers
617
+ # (`new RouteValueDictionary { ... }` lists, nested objects, etc.).
618
+ _NEW_ROUTE_RE = re.compile(r"\bnew\s+Route\s*\(")
619
+
620
+ # Match key/value pairs inside a `RouteValueDictionary { ... }` initializer.
621
+ # Used to pull out the `controller` and `action` defaults for a route.
622
+ _ROUTE_DICT_KV_RE = re.compile(r'\{\s*"([A-Za-z_]\w*)"\s*,\s*"([^"]*)"\s*\}')
623
+
624
+
625
+ def _parse_route_call_args(
626
+ text: str,
627
+ start: int,
628
+ ) -> tuple[str, str | None, str | None, int] | None:
629
+ """
630
+ Parse the argument list of a `new Route(...)` call starting at
631
+ `text[start:]` (positioned just past the opening `(`). Returns
632
+ `(url_pattern, controller, action, url_literal_offset)` or None
633
+ when the URL isn't a plain string literal.
634
+
635
+ Uses paren-balanced scanning to find the call's matching `)`, then
636
+ regex over the args text to extract:
637
+ - First positional string literal → URL pattern
638
+ - `{"controller", "X"}` in the RouteValueDictionary → controller
639
+ - `{"action", "Y"}` in the RouteValueDictionary → action
640
+ """
641
+ depth = 1
642
+ i = start
643
+ in_str = False
644
+ while i < len(text):
645
+ c = text[i]
646
+ if in_str:
647
+ if c == "\\":
648
+ i += 2
649
+ continue
650
+ if c == '"':
651
+ in_str = False
652
+ i += 1
653
+ continue
654
+ if c == '"':
655
+ in_str = True
656
+ i += 1
657
+ continue
658
+ if c == "(":
659
+ depth += 1
660
+ elif c == ")":
661
+ depth -= 1
662
+ if depth == 0:
663
+ args_text = text[start:i]
664
+ # Parse the first positional argument as a C# string
665
+ # literal — plain `"..."`, verbatim `@"..."` (where `""`
666
+ # is an embedded quote), or fail.
667
+ #
668
+ # Interpolated `$"..."` (and the verbatim-interpolated
669
+ # `$@"..."` / `@$"..."`) are intentionally deferred:
670
+ # emitting interpolated templates with `{placeholder}`
671
+ # segments would create routes whose paths can't match a
672
+ # resolved-value GT. Closing that gap is v7 charter
673
+ # work: a constant-resolution pass over `const string`
674
+ # / `static readonly string` fields in scope, followed
675
+ # by GT updates for any newly-emitted resolved URLs.
676
+ parsed_lit = parse_csharp_string_literal(args_text, 0)
677
+ if parsed_lit is None:
678
+ return None
679
+ url_pattern, lit_open_pos, _lit_end = parsed_lit
680
+ url_offset = start + lit_open_pos
681
+ controller: str | None = None
682
+ action: str | None = None
683
+ for kv_match in _ROUTE_DICT_KV_RE.finditer(args_text):
684
+ key, value = kv_match.group(1), kv_match.group(2)
685
+ if key == "controller" and controller is None:
686
+ controller = value
687
+ elif key == "action" and action is None:
688
+ action = value
689
+ return url_pattern, controller, action, url_offset
690
+ i += 1
691
+ return None
692
+
693
+
694
+ def _detect_orchard_module(file_path: Path | None) -> str | None:
695
+ """
696
+ Detect whether `file_path` lives inside an Orchard CMS module.
697
+
698
+ Returns the module name (the directory containing `Module.txt`) or
699
+ None when no enclosing module is found. Walks up from the file's
700
+ directory looking for `Module.txt` — Orchard's per-module manifest
701
+ file. Depth-limited (4 levels) so unrelated controllers in deep
702
+ directory trees don't trigger spurious detection.
703
+
704
+ Reference: Module.txt is checked by Orchard's extension loader
705
+ (`src/Orchard/Environment/Extensions/`) to identify a directory as
706
+ a loadable module.
707
+ """
708
+ if file_path is None:
709
+ return None
710
+ p = Path(file_path)
711
+ try:
712
+ current = p.parent if p.is_file() else p
713
+ except OSError:
714
+ return None
715
+ with contextlib.suppress(OSError):
716
+ current = current.resolve()
717
+ for depth, ancestor in enumerate([current, *current.parents]):
718
+ if depth > 4:
719
+ return None
720
+ try:
721
+ if (ancestor / "Module.txt").is_file():
722
+ return ancestor.name
723
+ except OSError:
724
+ return None
725
+ if ancestor.parent == ancestor:
726
+ return None
727
+ return None
728
+
729
+
730
+ # Self-registration
731
+ _legacy_aspnet_plugin = LegacyAspNetPlugin()
732
+ FrameworkPluginRegistry.register(_legacy_aspnet_plugin)