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,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)
|