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,559 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gRPC framework plugin (server-side).
|
|
3
|
+
|
|
4
|
+
Detects gRPC service implementations built on top of:
|
|
5
|
+
- `Grpc.Core` — classic gRPC for .NET (Grpc.Core.Server)
|
|
6
|
+
- `Grpc.AspNetCore.Server` — ASP.NET Core integration (AddGrpc / MapGrpcService)
|
|
7
|
+
|
|
8
|
+
A gRPC service is a class that inherits from a `.proto`-generated
|
|
9
|
+
abstract base whose name ends in `Base` (e.g. `Greeter.GreeterBase`).
|
|
10
|
+
Each RPC is an `override` method that accepts a `ServerCallContext`.
|
|
11
|
+
|
|
12
|
+
Routes are emitted with:
|
|
13
|
+
- `method = POST` — gRPC is always POST over HTTP/2
|
|
14
|
+
- `path = /{package}.{Service}/{Method}` (wire path)
|
|
15
|
+
- `kind = grpc_unary | grpc_server_stream | grpc_client_stream | grpc_bidi_stream`
|
|
16
|
+
- `tags = ["grpc"]`
|
|
17
|
+
|
|
18
|
+
Streaming is inferred from parameter types:
|
|
19
|
+
- `IAsyncStreamReader<TReq>` parameter → client streaming
|
|
20
|
+
- `IServerStreamWriter<TRes>` parameter → server streaming
|
|
21
|
+
- Both → bidirectional streaming
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import ClassVar
|
|
29
|
+
|
|
30
|
+
from ...core.types import (
|
|
31
|
+
AuthDependencyType,
|
|
32
|
+
AuthSchemeType,
|
|
33
|
+
CodeLocation,
|
|
34
|
+
Confidence,
|
|
35
|
+
Framework,
|
|
36
|
+
HttpMethod,
|
|
37
|
+
Language,
|
|
38
|
+
QualifiedName,
|
|
39
|
+
)
|
|
40
|
+
from ...parsing.base import ParsedClass, ParsedDecorator, ParsedFile, ParsedFunction
|
|
41
|
+
from ...parsing.services import AnalysisContext
|
|
42
|
+
from ..base import (
|
|
43
|
+
BaseFrameworkPlugin,
|
|
44
|
+
ExtractedAuthDependency,
|
|
45
|
+
ExtractedAuthScheme,
|
|
46
|
+
ExtractedBody,
|
|
47
|
+
ExtractedDependency,
|
|
48
|
+
ExtractedMiddleware,
|
|
49
|
+
ExtractedResponse,
|
|
50
|
+
ExtractedRoute,
|
|
51
|
+
FrameworkPluginRegistry,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# =============================================================================
|
|
55
|
+
# Constants
|
|
56
|
+
# =============================================================================
|
|
57
|
+
|
|
58
|
+
_GRPC_IMPORTS: frozenset[str] = frozenset(
|
|
59
|
+
{
|
|
60
|
+
"Grpc.Core",
|
|
61
|
+
"Grpc.Core.Interceptors",
|
|
62
|
+
"Grpc.AspNetCore.Server",
|
|
63
|
+
"Grpc.AspNetCore.Server.ClientFactory",
|
|
64
|
+
"Google.Protobuf",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Parameter type names that mark a method as a gRPC RPC override.
|
|
69
|
+
_SERVER_CALL_CONTEXT = "ServerCallContext"
|
|
70
|
+
|
|
71
|
+
# Generic-typed streaming parameter base names (before `<...>`).
|
|
72
|
+
_CLIENT_STREAM_READER = "IAsyncStreamReader"
|
|
73
|
+
_SERVER_STREAM_WRITER = "IServerStreamWriter"
|
|
74
|
+
|
|
75
|
+
# Methods inherited from Object that may also be `override` — never RPCs.
|
|
76
|
+
_OBJECT_METHOD_NAMES: frozenset[str] = frozenset(
|
|
77
|
+
{
|
|
78
|
+
"Equals",
|
|
79
|
+
"GetHashCode",
|
|
80
|
+
"ToString",
|
|
81
|
+
"Finalize",
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
_GENERIC_INNER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_\.]*<(.+)>\??$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extract_authorize_roles(dec: ParsedDecorator) -> list[str]:
|
|
89
|
+
"""Extract comma-separated role names from [Authorize(Roles = "Admin,User")].
|
|
90
|
+
|
|
91
|
+
The C# parser stores named arguments in dec.arguments (a dict), not in
|
|
92
|
+
dec.positional_args. Mirrors aspnet_plugin._extract_roles().
|
|
93
|
+
"""
|
|
94
|
+
roles_val = dec.arguments.get("Roles", dec.arguments.get("roles", ""))
|
|
95
|
+
if not roles_val or not isinstance(roles_val, str):
|
|
96
|
+
return []
|
|
97
|
+
return [r.strip() for r in roles_val.split(",") if r.strip()]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# Parsing `package foo.bar;` and `service Greeter { ... }` from .proto files.
|
|
101
|
+
_PROTO_PACKAGE_RE = re.compile(r"^\s*package\s+([\w\.]+)\s*;", re.MULTILINE)
|
|
102
|
+
_PROTO_SERVICE_RE = re.compile(r"^\s*service\s+(\w+)\s*\{", re.MULTILINE)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# =============================================================================
|
|
106
|
+
# GrpcPlugin
|
|
107
|
+
# =============================================================================
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class GrpcPlugin(BaseFrameworkPlugin):
|
|
111
|
+
"""Framework plugin for server-side gRPC services in C#."""
|
|
112
|
+
|
|
113
|
+
FRAMEWORK: ClassVar[Framework] = Framework.GRPC
|
|
114
|
+
LANGUAGE: ClassVar[Language] = Language.CSHARP
|
|
115
|
+
DETECTION_IMPORTS: ClassVar[frozenset[str]] = _GRPC_IMPORTS
|
|
116
|
+
DETECTION_IMPORT_PREFIXES: ClassVar[tuple[str, ...]] = ("Grpc.",)
|
|
117
|
+
|
|
118
|
+
def __init__(self) -> None:
|
|
119
|
+
# Maps str(project_root) → {service_name: proto_package}.
|
|
120
|
+
# Populated lazily on first extract_routes call per project, so
|
|
121
|
+
# back-to-back scans don't re-read every .proto file.
|
|
122
|
+
self._proto_packages_cache: dict[str, dict[str, str]] = {}
|
|
123
|
+
|
|
124
|
+
# -------------------------------------------------------------------------
|
|
125
|
+
# Detection
|
|
126
|
+
# -------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def detect(self, parsed_file: ParsedFile) -> bool:
|
|
129
|
+
for imp in parsed_file.imports:
|
|
130
|
+
module = imp.module or ""
|
|
131
|
+
if module.startswith("Grpc."):
|
|
132
|
+
return True
|
|
133
|
+
# Attribute-free fallback: a class extending *Base with an override
|
|
134
|
+
# method accepting ServerCallContext is almost certainly a gRPC service.
|
|
135
|
+
return any(self._is_grpc_service_by_shape(cls) for cls in parsed_file.classes)
|
|
136
|
+
|
|
137
|
+
# -------------------------------------------------------------------------
|
|
138
|
+
# Route extraction
|
|
139
|
+
# -------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def extract_routes(
|
|
142
|
+
self,
|
|
143
|
+
parsed_file: ParsedFile,
|
|
144
|
+
context: AnalysisContext | None = None,
|
|
145
|
+
) -> list[ExtractedRoute]:
|
|
146
|
+
routes: list[ExtractedRoute] = []
|
|
147
|
+
proto_map = self._load_proto_packages(context)
|
|
148
|
+
|
|
149
|
+
for cls in parsed_file.classes:
|
|
150
|
+
if not self._is_grpc_service(cls):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
package, service = self._extract_service_id(cls, parsed_file, proto_map)
|
|
154
|
+
|
|
155
|
+
for method in cls.methods:
|
|
156
|
+
if not self._is_rpc_method(method):
|
|
157
|
+
continue
|
|
158
|
+
route = self._extract_rpc_route(method, cls, package, service)
|
|
159
|
+
if route is not None:
|
|
160
|
+
routes.append(route)
|
|
161
|
+
|
|
162
|
+
return routes
|
|
163
|
+
|
|
164
|
+
# -------------------------------------------------------------------------
|
|
165
|
+
# .proto-file discovery
|
|
166
|
+
# -------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _load_proto_packages(self, context: AnalysisContext | None) -> dict[str, str]:
|
|
169
|
+
"""
|
|
170
|
+
Build `{service_name: proto_package}` by scanning the project for
|
|
171
|
+
`.proto` files. Cached per project_root because extract_routes is
|
|
172
|
+
called once per C# file but the proto layout is shared across them.
|
|
173
|
+
"""
|
|
174
|
+
if context is None or context.project_root is None:
|
|
175
|
+
return {}
|
|
176
|
+
key = str(context.project_root)
|
|
177
|
+
if key not in self._proto_packages_cache:
|
|
178
|
+
self._proto_packages_cache[key] = _scan_proto_files(context.project_root)
|
|
179
|
+
return self._proto_packages_cache[key]
|
|
180
|
+
|
|
181
|
+
def extract_auth_schemes(self, parsed_file: ParsedFile) -> list[ExtractedAuthScheme]:
|
|
182
|
+
"""
|
|
183
|
+
Detect gRPC auth schemes.
|
|
184
|
+
|
|
185
|
+
gRPC services on ASP.NET Core use the same [Authorize] attribute as
|
|
186
|
+
regular controllers:
|
|
187
|
+
- AddJwtBearer calls → JWT_BEARER
|
|
188
|
+
- AddAuthentication / AddBearerToken calls → CUSTOM
|
|
189
|
+
- [Authorize] on service classes → CUSTOM
|
|
190
|
+
"""
|
|
191
|
+
schemes: list[ExtractedAuthScheme] = []
|
|
192
|
+
seen: set[str] = set()
|
|
193
|
+
|
|
194
|
+
for call in parsed_file.call_sites:
|
|
195
|
+
name_lower = call.callee_name.lower()
|
|
196
|
+
line = call.location.line if call.location else 1
|
|
197
|
+
if name_lower == "addjwtbearer":
|
|
198
|
+
if "JwtBearer" not in seen:
|
|
199
|
+
seen.add("JwtBearer")
|
|
200
|
+
schemes.append(
|
|
201
|
+
ExtractedAuthScheme(
|
|
202
|
+
scheme_type=AuthSchemeType.JWT_BEARER,
|
|
203
|
+
name="JwtBearer",
|
|
204
|
+
location=CodeLocation(file=parsed_file.path, line=line),
|
|
205
|
+
confidence=Confidence.HIGH,
|
|
206
|
+
)
|
|
207
|
+
)
|
|
208
|
+
elif name_lower in ("addauthentication", "addbearertoken"):
|
|
209
|
+
# AddBearerToken is the .NET 8 opaque bearer-token API, not JWT
|
|
210
|
+
if "Authentication" not in seen:
|
|
211
|
+
seen.add("Authentication")
|
|
212
|
+
schemes.append(
|
|
213
|
+
ExtractedAuthScheme(
|
|
214
|
+
scheme_type=AuthSchemeType.CUSTOM,
|
|
215
|
+
name="GrpcAuthentication",
|
|
216
|
+
location=CodeLocation(file=parsed_file.path, line=line),
|
|
217
|
+
confidence=Confidence.MEDIUM,
|
|
218
|
+
)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# [Authorize] on gRPC service class → auth is active
|
|
222
|
+
for cls in parsed_file.classes:
|
|
223
|
+
if self._is_grpc_service(cls):
|
|
224
|
+
if any(d.name == "Authorize" for d in cls.decorators):
|
|
225
|
+
if "Authorize" not in seen:
|
|
226
|
+
seen.add("Authorize")
|
|
227
|
+
schemes.append(
|
|
228
|
+
ExtractedAuthScheme(
|
|
229
|
+
scheme_type=AuthSchemeType.CUSTOM,
|
|
230
|
+
name="GrpcAuthorize",
|
|
231
|
+
location=cls.location
|
|
232
|
+
or CodeLocation(file=parsed_file.path, line=1),
|
|
233
|
+
confidence=Confidence.HIGH,
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return schemes
|
|
238
|
+
|
|
239
|
+
def extract_auth_dependencies(
|
|
240
|
+
self, parsed_file: ParsedFile, known_scheme_names: set[str] | None = None, **kwargs
|
|
241
|
+
) -> list[ExtractedAuthDependency]:
|
|
242
|
+
"""
|
|
243
|
+
Extract [Authorize] and [AllowAnonymous] on gRPC service RPC methods.
|
|
244
|
+
|
|
245
|
+
Uses dec.arguments (named dict) not positional_args — the C# parser
|
|
246
|
+
stores [Authorize(Roles = "Admin,User")] as arguments["Roles"], not as
|
|
247
|
+
a positional arg. Mirrors the pattern in aspnet_plugin._extract_roles().
|
|
248
|
+
Only emits deps for methods that pass _is_rpc_method() to exclude
|
|
249
|
+
lifecycle / helper overrides.
|
|
250
|
+
"""
|
|
251
|
+
deps: list[ExtractedAuthDependency] = []
|
|
252
|
+
|
|
253
|
+
for cls in parsed_file.classes:
|
|
254
|
+
if not self._is_grpc_service(cls):
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Class-level [Authorize] / [AllowAnonymous]
|
|
258
|
+
class_roles: list[str] = []
|
|
259
|
+
class_has_authorize = False
|
|
260
|
+
class_is_anonymous = any(d.name == "AllowAnonymous" for d in cls.decorators)
|
|
261
|
+
if not class_is_anonymous:
|
|
262
|
+
for dec in cls.decorators:
|
|
263
|
+
if dec.name == "Authorize":
|
|
264
|
+
class_has_authorize = True
|
|
265
|
+
class_roles.extend(_extract_authorize_roles(dec))
|
|
266
|
+
|
|
267
|
+
for method in cls.methods:
|
|
268
|
+
# Only RPC methods, not lifecycle overrides
|
|
269
|
+
if not self._is_rpc_method(method):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
# Method-level [AllowAnonymous] takes precedence over class [Authorize]
|
|
273
|
+
if any(d.name == "AllowAnonymous" for d in method.decorators):
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
method_roles = list(class_roles)
|
|
277
|
+
has_method_authorize = False
|
|
278
|
+
for dec in method.decorators:
|
|
279
|
+
if dec.name == "Authorize":
|
|
280
|
+
has_method_authorize = True
|
|
281
|
+
method_roles.extend(_extract_authorize_roles(dec))
|
|
282
|
+
|
|
283
|
+
# Skip if neither class nor method has any [Authorize]
|
|
284
|
+
if not class_has_authorize and not has_method_authorize:
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
handler = f"{cls.name}.{method.name}"
|
|
288
|
+
deps.append(
|
|
289
|
+
ExtractedAuthDependency(
|
|
290
|
+
name=handler,
|
|
291
|
+
qualified_name=QualifiedName(module=parsed_file.path.stem, name=handler),
|
|
292
|
+
location=method.location or CodeLocation(file=parsed_file.path, line=1),
|
|
293
|
+
dependency_type=AuthDependencyType.ANNOTATION,
|
|
294
|
+
uses_schemes=["GrpcAuthorize"],
|
|
295
|
+
requires_roles=method_roles,
|
|
296
|
+
confidence=Confidence.HIGH,
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
return deps
|
|
301
|
+
|
|
302
|
+
def extract_dependencies(self, parsed_file: ParsedFile) -> list[ExtractedDependency]:
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
def extract_middleware(self, parsed_file: ParsedFile) -> list[ExtractedMiddleware]:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
# -------------------------------------------------------------------------
|
|
309
|
+
# Service-class detection
|
|
310
|
+
# -------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
def _is_grpc_service(self, cls: ParsedClass) -> bool:
|
|
313
|
+
"""A non-abstract class inheriting from a `*Base` parent."""
|
|
314
|
+
if not cls.base_classes:
|
|
315
|
+
return False
|
|
316
|
+
for base in cls.base_classes:
|
|
317
|
+
simple = base.rsplit(".", 1)[-1]
|
|
318
|
+
if simple.endswith("Base") and simple != "Base":
|
|
319
|
+
return True
|
|
320
|
+
return False
|
|
321
|
+
|
|
322
|
+
def _is_grpc_service_by_shape(self, cls: ParsedClass) -> bool:
|
|
323
|
+
"""Detection fallback used when no `using Grpc.*` is present."""
|
|
324
|
+
if not self._is_grpc_service(cls):
|
|
325
|
+
return False
|
|
326
|
+
return any(self._is_rpc_method(m) for m in cls.methods)
|
|
327
|
+
|
|
328
|
+
def _is_rpc_method(self, method: ParsedFunction) -> bool:
|
|
329
|
+
if method.name in _OBJECT_METHOD_NAMES:
|
|
330
|
+
return False
|
|
331
|
+
# gRPC overrides always take ServerCallContext as their final parameter.
|
|
332
|
+
for param in method.parameters:
|
|
333
|
+
base = (param.type_annotation or "").split("<")[0].strip().rstrip("?")
|
|
334
|
+
if base.rsplit(".", 1)[-1] == _SERVER_CALL_CONTEXT:
|
|
335
|
+
return True
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
# -------------------------------------------------------------------------
|
|
339
|
+
# Service-ID extraction
|
|
340
|
+
# -------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
def _extract_service_id(
|
|
343
|
+
self,
|
|
344
|
+
cls: ParsedClass,
|
|
345
|
+
parsed_file: ParsedFile,
|
|
346
|
+
proto_map: dict[str, str] | None = None,
|
|
347
|
+
) -> tuple[str, str]:
|
|
348
|
+
"""
|
|
349
|
+
Derive (proto_package, service_name) for emission of the wire path.
|
|
350
|
+
|
|
351
|
+
Strategy:
|
|
352
|
+
1. Inspect the base-class spelling. `Helloworld.Greeter.GreeterBase`
|
|
353
|
+
→ package=Helloworld, service=Greeter.
|
|
354
|
+
`Greeter.GreeterBase` (no package) → package="", service=Greeter.
|
|
355
|
+
2. Fall back to a service-name lookup in the `.proto`-derived package
|
|
356
|
+
map: that's where the *actual* wire-path package lives whenever
|
|
357
|
+
the .proto uses `option csharp_namespace = "...";` to decouple
|
|
358
|
+
C# from the proto package.
|
|
359
|
+
3. Last resort: file's C# namespace.
|
|
360
|
+
"""
|
|
361
|
+
for base in cls.base_classes:
|
|
362
|
+
parts = base.split(".")
|
|
363
|
+
if not parts[-1].endswith("Base") or parts[-1] == "Base":
|
|
364
|
+
continue
|
|
365
|
+
|
|
366
|
+
# The conventional shape is `<Package>.<Service>.<Service>Base` or
|
|
367
|
+
# `<Service>.<Service>Base`. Strip the trailing `Base` from the
|
|
368
|
+
# last part to recover the service name; everything before the
|
|
369
|
+
# second-to-last part is the package.
|
|
370
|
+
service_with_base = parts[-1]
|
|
371
|
+
service = service_with_base[: -len("Base")]
|
|
372
|
+
|
|
373
|
+
if len(parts) >= 2:
|
|
374
|
+
# `...<Service>.<Service>Base` (canonical) — strip both the
|
|
375
|
+
# trailing `*Base` and its preceding `<Service>` segment.
|
|
376
|
+
# Anything else keeps whatever's before the Base part.
|
|
377
|
+
preceding = parts[-2]
|
|
378
|
+
package = ".".join(parts[:-2]) if preceding == service else ".".join(parts[:-1])
|
|
379
|
+
else:
|
|
380
|
+
package = ""
|
|
381
|
+
|
|
382
|
+
if not package and proto_map:
|
|
383
|
+
package = proto_map.get(service, "")
|
|
384
|
+
|
|
385
|
+
if not package:
|
|
386
|
+
package = self._csharp_namespace(parsed_file) or ""
|
|
387
|
+
|
|
388
|
+
return package, service
|
|
389
|
+
|
|
390
|
+
return "", cls.name
|
|
391
|
+
|
|
392
|
+
def _csharp_namespace(self, parsed_file: ParsedFile) -> str | None:
|
|
393
|
+
ns = getattr(parsed_file, "namespace", None)
|
|
394
|
+
if isinstance(ns, str) and ns:
|
|
395
|
+
return ns
|
|
396
|
+
return None
|
|
397
|
+
|
|
398
|
+
# -------------------------------------------------------------------------
|
|
399
|
+
# Per-RPC emission
|
|
400
|
+
# -------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
def _extract_rpc_route(
|
|
403
|
+
self,
|
|
404
|
+
method: ParsedFunction,
|
|
405
|
+
cls: ParsedClass,
|
|
406
|
+
package: str,
|
|
407
|
+
service: str,
|
|
408
|
+
) -> ExtractedRoute | None:
|
|
409
|
+
request_type, response_type, kind = self._classify_streaming(method)
|
|
410
|
+
|
|
411
|
+
path = f"/{package}.{service}/{method.name}" if package else f"/{service}/{method.name}"
|
|
412
|
+
|
|
413
|
+
body: ExtractedBody | None = None
|
|
414
|
+
if request_type:
|
|
415
|
+
body = ExtractedBody(
|
|
416
|
+
content_type="application/grpc",
|
|
417
|
+
model_name=request_type,
|
|
418
|
+
required=True,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
response = ExtractedResponse(
|
|
422
|
+
status_code=200,
|
|
423
|
+
model_name=response_type,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
handler_qname = method.qualified_name or QualifiedName(
|
|
427
|
+
module=cls.qualified_name.module if cls.qualified_name else "",
|
|
428
|
+
name=f"{cls.name}.{method.name}",
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
return ExtractedRoute(
|
|
432
|
+
method=HttpMethod.POST,
|
|
433
|
+
path=path,
|
|
434
|
+
handler_function=handler_qname,
|
|
435
|
+
handler_location=method.location,
|
|
436
|
+
path_params=[],
|
|
437
|
+
query_params=[],
|
|
438
|
+
header_params=[],
|
|
439
|
+
cookie_params=[],
|
|
440
|
+
body=body,
|
|
441
|
+
response=response,
|
|
442
|
+
tags=["grpc"],
|
|
443
|
+
dependency_refs=[],
|
|
444
|
+
confidence=Confidence.HIGH,
|
|
445
|
+
kind=kind,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
def _classify_streaming(self, method: ParsedFunction) -> tuple[str | None, str | None, str]:
|
|
449
|
+
"""
|
|
450
|
+
Return (request_model, response_model, kind) from method signature.
|
|
451
|
+
|
|
452
|
+
The four gRPC method shapes:
|
|
453
|
+
- Unary: TRes Method(TReq req, ServerCallContext ctx)
|
|
454
|
+
- Server streaming: Task Method(TReq req, IServerStreamWriter<TRes> rs, ServerCallContext ctx)
|
|
455
|
+
- Client streaming: Task<TRes> Method(IAsyncStreamReader<TReq> rs, ServerCallContext ctx)
|
|
456
|
+
- Bidi streaming: Task Method(IAsyncStreamReader<TReq> rr, IServerStreamWriter<TRes> rw, ServerCallContext ctx)
|
|
457
|
+
"""
|
|
458
|
+
client_stream_req: str | None = None
|
|
459
|
+
server_stream_res: str | None = None
|
|
460
|
+
first_data_param_type: str | None = None
|
|
461
|
+
|
|
462
|
+
for param in method.parameters:
|
|
463
|
+
ty = (param.type_annotation or "").strip()
|
|
464
|
+
if not ty:
|
|
465
|
+
continue
|
|
466
|
+
base = ty.split("<")[0].strip()
|
|
467
|
+
simple = base.rsplit(".", 1)[-1]
|
|
468
|
+
|
|
469
|
+
if simple == _SERVER_CALL_CONTEXT:
|
|
470
|
+
continue
|
|
471
|
+
if simple == _CLIENT_STREAM_READER:
|
|
472
|
+
client_stream_req = _generic_inner(ty)
|
|
473
|
+
continue
|
|
474
|
+
if simple == _SERVER_STREAM_WRITER:
|
|
475
|
+
server_stream_res = _generic_inner(ty)
|
|
476
|
+
continue
|
|
477
|
+
if first_data_param_type is None:
|
|
478
|
+
first_data_param_type = ty
|
|
479
|
+
|
|
480
|
+
# Response: prefer the stream writer's type; otherwise unwrap Task<T>.
|
|
481
|
+
response_type = server_stream_res or _unwrap_task(method.return_type)
|
|
482
|
+
|
|
483
|
+
# Request: streaming reader's type wins; otherwise the first non-context param.
|
|
484
|
+
request_type = client_stream_req or first_data_param_type
|
|
485
|
+
|
|
486
|
+
if client_stream_req is not None and server_stream_res is not None:
|
|
487
|
+
kind = "grpc_bidi_stream"
|
|
488
|
+
elif client_stream_req is not None:
|
|
489
|
+
kind = "grpc_client_stream"
|
|
490
|
+
elif server_stream_res is not None:
|
|
491
|
+
kind = "grpc_server_stream"
|
|
492
|
+
else:
|
|
493
|
+
kind = "grpc_unary"
|
|
494
|
+
|
|
495
|
+
return request_type, response_type, kind
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
# =============================================================================
|
|
499
|
+
# Module-level helpers
|
|
500
|
+
# =============================================================================
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _scan_proto_files(root: Path) -> dict[str, str]:
|
|
504
|
+
"""
|
|
505
|
+
Walk `root` for `*.proto` files and return `{service_name: package}`.
|
|
506
|
+
|
|
507
|
+
A service name is the identifier in `service Foo { ... }`; the
|
|
508
|
+
package is the value of the first `package …;` declaration in the
|
|
509
|
+
same file (or empty if none). When a service name appears in more
|
|
510
|
+
than one proto, the first match wins — duplicates are rare in
|
|
511
|
+
practice (the canonical case is a `.proto` checked into both the
|
|
512
|
+
server and a client project; the package matches either way).
|
|
513
|
+
"""
|
|
514
|
+
mapping: dict[str, str] = {}
|
|
515
|
+
try:
|
|
516
|
+
proto_paths = sorted(root.rglob("*.proto"))
|
|
517
|
+
except (OSError, ValueError):
|
|
518
|
+
return mapping
|
|
519
|
+
|
|
520
|
+
for proto in proto_paths:
|
|
521
|
+
try:
|
|
522
|
+
text = proto.read_text(encoding="utf-8", errors="ignore")
|
|
523
|
+
except OSError:
|
|
524
|
+
continue
|
|
525
|
+
pkg_match = _PROTO_PACKAGE_RE.search(text)
|
|
526
|
+
package = pkg_match.group(1) if pkg_match else ""
|
|
527
|
+
for service in _PROTO_SERVICE_RE.findall(text):
|
|
528
|
+
if service not in mapping:
|
|
529
|
+
mapping[service] = package
|
|
530
|
+
return mapping
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _generic_inner(type_annotation: str) -> str | None:
|
|
534
|
+
"""Return `T` from `Foo<T>` (handles trailing nullable `?`)."""
|
|
535
|
+
m = _GENERIC_INNER_RE.match(type_annotation.strip())
|
|
536
|
+
if m:
|
|
537
|
+
return m.group(1).strip()
|
|
538
|
+
return None
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _unwrap_task(return_type: str | None) -> str | None:
|
|
542
|
+
"""`Task<T>` / `ValueTask<T>` → `T`; `Task` / `void` → None."""
|
|
543
|
+
if not return_type:
|
|
544
|
+
return None
|
|
545
|
+
ret = return_type.strip()
|
|
546
|
+
for wrapper in ("Task<", "ValueTask<"):
|
|
547
|
+
if ret.startswith(wrapper) and ret.endswith(">"):
|
|
548
|
+
return ret[len(wrapper) : -1].strip() or None
|
|
549
|
+
if ret in ("Task", "ValueTask", "void"):
|
|
550
|
+
return None
|
|
551
|
+
return ret
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
# =============================================================================
|
|
555
|
+
# Self-registration
|
|
556
|
+
# =============================================================================
|
|
557
|
+
|
|
558
|
+
_grpc_plugin = GrpcPlugin()
|
|
559
|
+
FrameworkPluginRegistry.register(_grpc_plugin)
|