attackmap-analyzer-dotnet 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.
Potentially problematic release.
This version of attackmap-analyzer-dotnet might be problematic. Click here for more details.
- attackmap_analyzer_dotnet/__init__.py +3 -0
- attackmap_analyzer_dotnet/analyzer.py +501 -0
- attackmap_analyzer_dotnet/contracts.py +30 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/METADATA +91 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/RECORD +9 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_dotnet-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
"""C# / .NET ASP.NET Core ecosystem analyzer for AttackMap.
|
|
2
|
+
|
|
3
|
+
Coverage (v0.1):
|
|
4
|
+
- Web frameworks: ASP.NET Core minimal APIs (`app.MapGet`, `app.MapPost`, ...),
|
|
5
|
+
ASP.NET Core attribute routing (`[HttpGet]`, `[HttpPost]`, with class-level
|
|
6
|
+
`[Route]` prefix joining and `[controller]` token substitution)
|
|
7
|
+
- Databases: Entity Framework Core (DbContext + UseSqlServer/UseNpgsql/UseSqlite/
|
|
8
|
+
UseMySql), Dapper, System.Data.SqlClient / Microsoft.Data.SqlClient (SQL Server),
|
|
9
|
+
Npgsql (Postgres), MySql.Data / MySqlConnector, MongoDB.Driver, StackExchange.Redis,
|
|
10
|
+
AWS SDK (S3, DynamoDB)
|
|
11
|
+
- Auth: Microsoft.AspNetCore.Authentication.JwtBearer (AddJwtBearer), Identity
|
|
12
|
+
(UserManager, SignInManager, IdentityUser, PasswordHasher), `[Authorize]` attribute,
|
|
13
|
+
AddOpenIdConnect, Duende IdentityServer
|
|
14
|
+
- HTTP clients (external calls): HttpClient (GetAsync/PostAsync/SendAsync), RestSharp
|
|
15
|
+
- Secrets: Environment.GetEnvironmentVariable, IConfiguration["..."] / Configuration
|
|
16
|
+
bindings with secret-shaped keys
|
|
17
|
+
- Entrypoints: WebApplication.CreateBuilder + .Run(), Host.CreateDefaultBuilder
|
|
18
|
+
- Service hints: <RootNamespace> / <AssemblyName> from .csproj
|
|
19
|
+
|
|
20
|
+
Class-level [Route("api/[controller]")] is parsed and the `[controller]` token
|
|
21
|
+
is substituted with the controller class name (minus the "Controller" suffix)
|
|
22
|
+
to produce the final route path.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import re
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .contracts import (
|
|
31
|
+
AnalyzerMetadata,
|
|
32
|
+
AuthHint,
|
|
33
|
+
DatabaseHint,
|
|
34
|
+
EntrypointHint,
|
|
35
|
+
ExternalCall,
|
|
36
|
+
FrameworkHint,
|
|
37
|
+
Route,
|
|
38
|
+
ScanResult,
|
|
39
|
+
SecretHint,
|
|
40
|
+
ServiceHint,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
CODE_SUFFIXES = {".cs"}
|
|
44
|
+
CONFIG_FILES = {"appsettings.json", "appsettings.Development.json"}
|
|
45
|
+
PROJECT_FILES = {".csproj", ".fsproj", ".sln"}
|
|
46
|
+
SKIP_DIRS = {
|
|
47
|
+
"bin",
|
|
48
|
+
"obj",
|
|
49
|
+
".vs",
|
|
50
|
+
".idea",
|
|
51
|
+
".git",
|
|
52
|
+
"node_modules",
|
|
53
|
+
"packages",
|
|
54
|
+
"TestResults",
|
|
55
|
+
"publish",
|
|
56
|
+
}
|
|
57
|
+
_SNIPPET_MAX_CHARS = 160
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------- Patterns ----------
|
|
61
|
+
|
|
62
|
+
# Minimal APIs: app.MapGet("/x", handler), app.MapPost(...), etc.
|
|
63
|
+
MINIMAL_API_PATTERN = re.compile(
|
|
64
|
+
r'\b\w+\.Map(Get|Post|Put|Delete|Patch|Head|Options)\s*\(\s*"([^"]+)"',
|
|
65
|
+
)
|
|
66
|
+
# app.MapMethods("/x", new[] { "GET", "POST" }, handler)
|
|
67
|
+
MINIMAL_API_METHODS_PATTERN = re.compile(
|
|
68
|
+
r'\b\w+\.MapMethods\s*\(\s*"([^"]+)"\s*,\s*new\[\]\s*\{\s*([^}]+)\s*\}',
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Attribute routing on controllers
|
|
72
|
+
HTTP_ATTRIBUTE_PATTERN = re.compile(
|
|
73
|
+
r'\[\s*Http(Get|Post|Put|Delete|Patch|Head|Options)\s*(?:\(\s*"([^"]*)"\s*\))?\s*\]',
|
|
74
|
+
)
|
|
75
|
+
# Class-level [Route("api/[controller]")] or [Route("api/users")]
|
|
76
|
+
# `[^\{]*?` allows other attributes ([ApiController], [Authorize], etc.) between
|
|
77
|
+
# the [Route] and the class declaration, but not a class body start `{`.
|
|
78
|
+
CLASS_ROUTE_ATTRIBUTE_PATTERN = re.compile(
|
|
79
|
+
r'\[\s*Route\s*\(\s*"([^"]+)"\s*\)\s*\][^\{]*?\bclass\s+(\w+)',
|
|
80
|
+
re.DOTALL,
|
|
81
|
+
)
|
|
82
|
+
# Method-level [Route("/x")] (less common, but valid)
|
|
83
|
+
METHOD_ROUTE_ATTRIBUTE_PATTERN = re.compile(
|
|
84
|
+
r'\[\s*Route\s*\(\s*"([^"]+)"\s*\)\s*\]',
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# External HTTP calls. The receiver-name regex is intentionally permissive (any
|
|
88
|
+
# identifier can be a HttpClient field, often `_httpClient`); we anchor on the
|
|
89
|
+
# call shape (.GetAsync/.PostAsync/...) and require an http(s) URL literal.
|
|
90
|
+
OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
|
|
91
|
+
re.compile(r'\.(?:Get|Post|Put|Delete|Patch|Send)(?:Async|Json|String)?(?:Async)?\s*\(\s*"(https?://[^"]+)"', re.IGNORECASE),
|
|
92
|
+
re.compile(r'\bnew\s+HttpRequestMessage\s*\(\s*HttpMethod\.\w+\s*,\s*"(https?://[^"]+)"'),
|
|
93
|
+
re.compile(r'\bnew\s+RestClient\s*\(\s*"(https?://[^"]+)"'),
|
|
94
|
+
re.compile(r'\bnew\s+Uri\s*\(\s*"(https?://[^"]+)"'),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Database libs
|
|
98
|
+
DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
99
|
+
(re.compile(r'\.UseSqlServer\s*\('), "sqlserver"),
|
|
100
|
+
(re.compile(r'\.UseNpgsql\s*\(|\bNpgsql\b'), "postgresql"),
|
|
101
|
+
(re.compile(r'\.UseMySql\s*\(|\bMySqlConnector\b|\bMySql\.Data\b'), "mysql"),
|
|
102
|
+
(re.compile(r'\.UseSqlite\s*\(|\bMicrosoft\.Data\.Sqlite\b'), "sqlite"),
|
|
103
|
+
(re.compile(r'\.UseInMemoryDatabase\s*\('), "in_memory"),
|
|
104
|
+
(re.compile(r'\bDbContext\b|\bDbSet<'), "sql"),
|
|
105
|
+
(re.compile(r'\busing\s+Dapper\b|\bIDbConnection\b'), "sql"),
|
|
106
|
+
(re.compile(r'\bSqlConnection\s*\('), "sqlserver"),
|
|
107
|
+
(re.compile(r'\bMongoClient\s*\(|\bIMongoDatabase\b|\bMongoDB\.Driver\b'), "mongodb"),
|
|
108
|
+
(re.compile(r'\bConnectionMultiplexer\.Connect\s*\(|\bStackExchange\.Redis\b'), "redis"),
|
|
109
|
+
(re.compile(r'\bAmazonS3Client\b|\bAWSSDK\.S3\b'), "object_storage"),
|
|
110
|
+
(re.compile(r'\bAmazonDynamoDBClient\b|\bAWSSDK\.DynamoDBv2\b'), "dynamodb"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
# Auth-related signals
|
|
114
|
+
AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
|
|
115
|
+
(re.compile(r'\.AddJwtBearer\s*\(|\bMicrosoft\.AspNetCore\.Authentication\.JwtBearer\b'), "jwt", 0.85),
|
|
116
|
+
(re.compile(r'\.AddOpenIdConnect\s*\(|\bOpenIdConnect\b'), "oidc", 0.85),
|
|
117
|
+
(re.compile(r'\.AddOAuth\s*\(|\bOAuth\b', re.IGNORECASE), "oauth", 0.85),
|
|
118
|
+
(re.compile(r'\bUserManager<|\bSignInManager<|\bIdentityUser\b'), "aspnet_identity", 0.85),
|
|
119
|
+
(re.compile(r'\bPasswordHasher<|\bIPasswordHasher\b'), "password_hasher", 0.85),
|
|
120
|
+
(re.compile(r'\bBCrypt\.Net\b|\bBCryptPasswordHasher\b'), "bcrypt", 0.9),
|
|
121
|
+
(re.compile(r'\bArgon2\b'), "argon2", 0.9),
|
|
122
|
+
(re.compile(r'\[\s*Authorize\b'), "authorize_attribute", 0.85),
|
|
123
|
+
(re.compile(r'\.AddAuthorization\s*\('), "authorization_setup", 0.85),
|
|
124
|
+
(re.compile(r'\bDuende\.IdentityServer\b|\bIdentityServer4\b'), "identityserver", 0.85),
|
|
125
|
+
(re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
|
|
126
|
+
(re.compile(r'\bBearer\b'), "bearer_token", 0.6),
|
|
127
|
+
(re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
131
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Builder\b|\bWebApplication\.CreateBuilder\b'), "aspnetcore"),
|
|
132
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Mvc\b|\[ApiController\]|\[Controller\]'), "aspnetcore-mvc"),
|
|
133
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Mvc\.RazorPages\b'), "aspnetcore-razor"),
|
|
134
|
+
(re.compile(r'\bMicrosoft\.EntityFrameworkCore\b'), "efcore"),
|
|
135
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.SignalR\b'), "signalr"),
|
|
136
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Authentication\b'), "aspnetcore-auth"),
|
|
137
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Identity\b'), "aspnetcore-identity"),
|
|
138
|
+
(re.compile(r'\bGrpc\.Net\.Client\b|\bGrpc\.AspNetCore\b'), "grpc"),
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
142
|
+
(re.compile(r'\bWebApplication\.CreateBuilder\s*\(|\bWebApplicationBuilder\b'), "webapplication_builder"),
|
|
143
|
+
(re.compile(r'\bapp\.Run\s*\(\s*\)'), "webapp_run"),
|
|
144
|
+
(re.compile(r'\bHost\.CreateDefaultBuilder\s*\('), "generic_host"),
|
|
145
|
+
(re.compile(r'\bUseStartup<'), "use_startup"),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
# Secrets — env vars + IConfiguration accesses with secret-shaped keys
|
|
149
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
150
|
+
re.compile(
|
|
151
|
+
r'\bEnvironment\.GetEnvironmentVariable\s*\(\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
|
|
152
|
+
),
|
|
153
|
+
# IConfiguration indexer access — receiver name varies (cfg, _cfg, configuration,
|
|
154
|
+
# builder.Configuration, etc.); we anchor on the secret-shaped key inside the brackets.
|
|
155
|
+
re.compile(
|
|
156
|
+
r'\b\w+(?:\.\w+)?\s*\[\s*"([A-Za-z0-9_:.]*(?:secret|token|key|password|pass|pwd)[A-Za-z0-9_:.]*)"',
|
|
157
|
+
re.IGNORECASE,
|
|
158
|
+
),
|
|
159
|
+
re.compile(
|
|
160
|
+
r'\bGetConnectionString\s*\(\s*"([^"]+)"',
|
|
161
|
+
),
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _line_of(content: str, offset: int) -> int:
|
|
166
|
+
if offset <= 0:
|
|
167
|
+
return 1
|
|
168
|
+
return content.count("\n", 0, offset) + 1
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
172
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
173
|
+
line_end = content.find("\n", offset)
|
|
174
|
+
if line_end == -1:
|
|
175
|
+
line_end = len(content)
|
|
176
|
+
line = content[line_start:line_end].strip()
|
|
177
|
+
if len(line) > max_chars:
|
|
178
|
+
line = line[: max_chars - 1] + "…"
|
|
179
|
+
return line
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _join_paths(prefix: str, suffix: str) -> str:
|
|
183
|
+
p = prefix.strip()
|
|
184
|
+
s = suffix.strip()
|
|
185
|
+
if not p:
|
|
186
|
+
return s or "/"
|
|
187
|
+
if not s:
|
|
188
|
+
return p or "/"
|
|
189
|
+
if p == "/":
|
|
190
|
+
return s if s.startswith("/") else "/" + s
|
|
191
|
+
if s == "/":
|
|
192
|
+
return p
|
|
193
|
+
return p.rstrip("/") + "/" + s.lstrip("/")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _substitute_controller_token(route_template: str, controller_name: str) -> str:
|
|
197
|
+
"""Replace [controller] in route template with controller class name minus 'Controller'."""
|
|
198
|
+
name = controller_name
|
|
199
|
+
if name.endswith("Controller"):
|
|
200
|
+
name = name[: -len("Controller")]
|
|
201
|
+
return route_template.replace("[controller]", name).replace("[Controller]", name)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _class_routes_in_file(content: str) -> list[tuple[int, str, str]]:
|
|
205
|
+
"""Return [(start_offset, route_template_after_substitution, controller_class), ...]
|
|
206
|
+
for every class-level [Route(...)] annotation in the file.
|
|
207
|
+
"""
|
|
208
|
+
results: list[tuple[int, str, str]] = []
|
|
209
|
+
for match in CLASS_ROUTE_ATTRIBUTE_PATTERN.finditer(content):
|
|
210
|
+
template, class_name = match.group(1), match.group(2)
|
|
211
|
+
substituted = _substitute_controller_token(template, class_name)
|
|
212
|
+
results.append((match.start(), substituted, class_name))
|
|
213
|
+
return results
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _active_class_route(prefixes: list[tuple[int, str, str]], offset: int) -> str:
|
|
217
|
+
"""The most recent class-level [Route] preceding the offset, post-substitution."""
|
|
218
|
+
active = ""
|
|
219
|
+
for start, template, _ in prefixes:
|
|
220
|
+
if start < offset:
|
|
221
|
+
active = template
|
|
222
|
+
else:
|
|
223
|
+
break
|
|
224
|
+
return active
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _extract_csproj_meta(csproj_path: Path) -> tuple[str | None, str | None]:
|
|
228
|
+
"""Return (root_namespace, assembly_name) from a .csproj file."""
|
|
229
|
+
try:
|
|
230
|
+
text = csproj_path.read_text(encoding="utf-8")
|
|
231
|
+
except UnicodeDecodeError:
|
|
232
|
+
return None, None
|
|
233
|
+
rn = re.search(r"<RootNamespace>([^<]+)</RootNamespace>", text)
|
|
234
|
+
an = re.search(r"<AssemblyName>([^<]+)</AssemblyName>", text)
|
|
235
|
+
return (rn.group(1).strip() if rn else None, an.group(1).strip() if an else None)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
class DotnetAnalyzer:
|
|
239
|
+
metadata = AnalyzerMetadata(
|
|
240
|
+
name="dotnet",
|
|
241
|
+
display_name="C# / .NET Analyzer",
|
|
242
|
+
version="0.1.0",
|
|
243
|
+
description="ASP.NET Core analyzer covering minimal APIs, attribute routing, EF Core, Identity, and JwtBearer.",
|
|
244
|
+
scope=".NET solutions and projects (.csproj, .sln). Detects ASP.NET Core minimal APIs and controllers.",
|
|
245
|
+
targets=["dotnet", "csharp", "aspnetcore"],
|
|
246
|
+
languages=["csharp"],
|
|
247
|
+
priority=20,
|
|
248
|
+
experimental=False,
|
|
249
|
+
enabled_by_default=True,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def name(self) -> str:
|
|
254
|
+
return self.metadata.name
|
|
255
|
+
|
|
256
|
+
# ---------- Public entry points ----------
|
|
257
|
+
|
|
258
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
259
|
+
root = Path(repo_path).resolve()
|
|
260
|
+
if not root.exists() or not root.is_dir():
|
|
261
|
+
return False
|
|
262
|
+
# Project / solution markers
|
|
263
|
+
for path in root.rglob("*"):
|
|
264
|
+
if any(part in SKIP_DIRS for part in path.parts):
|
|
265
|
+
continue
|
|
266
|
+
if not path.is_file():
|
|
267
|
+
continue
|
|
268
|
+
if path.suffix in {".csproj", ".sln", ".fsproj"}:
|
|
269
|
+
return True
|
|
270
|
+
if path.suffix in CODE_SUFFIXES:
|
|
271
|
+
return True
|
|
272
|
+
return False
|
|
273
|
+
|
|
274
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
275
|
+
root = Path(repo_path).resolve()
|
|
276
|
+
result = ScanResult(root=str(root))
|
|
277
|
+
if not root.exists() or not root.is_dir():
|
|
278
|
+
return result
|
|
279
|
+
|
|
280
|
+
# Service-name hints from .csproj files
|
|
281
|
+
for csproj in root.rglob("*.csproj"):
|
|
282
|
+
if any(part in SKIP_DIRS for part in csproj.parts):
|
|
283
|
+
continue
|
|
284
|
+
root_ns, assembly = _extract_csproj_meta(csproj)
|
|
285
|
+
relative = str(csproj.relative_to(root))
|
|
286
|
+
if root_ns:
|
|
287
|
+
self._append_unique_service(result, f"namespace:{root_ns}", relative)
|
|
288
|
+
if assembly:
|
|
289
|
+
self._append_unique_service(result, f"assembly:{assembly}", relative)
|
|
290
|
+
|
|
291
|
+
for file_path in root.rglob("*"):
|
|
292
|
+
if not file_path.is_file():
|
|
293
|
+
continue
|
|
294
|
+
if any(part in SKIP_DIRS for part in file_path.parts):
|
|
295
|
+
continue
|
|
296
|
+
if file_path.suffix not in CODE_SUFFIXES:
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
result.files_scanned += 1
|
|
300
|
+
if "csharp" not in result.languages:
|
|
301
|
+
result.languages.append("csharp")
|
|
302
|
+
|
|
303
|
+
try:
|
|
304
|
+
content = file_path.read_text(encoding="utf-8")
|
|
305
|
+
except UnicodeDecodeError:
|
|
306
|
+
continue
|
|
307
|
+
|
|
308
|
+
relative = str(file_path.relative_to(root))
|
|
309
|
+
self._extract_routes(content, relative, result)
|
|
310
|
+
self._extract_databases(content, relative, result)
|
|
311
|
+
self._extract_auth(content, relative, result)
|
|
312
|
+
self._extract_secrets(content, relative, result)
|
|
313
|
+
self._extract_external_calls(content, relative, result)
|
|
314
|
+
self._extract_frameworks(content, relative, result)
|
|
315
|
+
self._extract_entrypoints(content, relative, result)
|
|
316
|
+
self._infer_service_role(content, relative, result)
|
|
317
|
+
|
|
318
|
+
result.languages.sort()
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
# ---------- Extractors ----------
|
|
322
|
+
|
|
323
|
+
def _extract_routes(self, content: str, relative: str, result: ScanResult) -> None:
|
|
324
|
+
# Minimal APIs: app.MapGet("/x", handler), etc.
|
|
325
|
+
for match in MINIMAL_API_PATTERN.finditer(content):
|
|
326
|
+
method, path = match.group(1).upper(), match.group(2)
|
|
327
|
+
self._append_unique_route(result, path, method, relative, _line_of(content, match.start()))
|
|
328
|
+
|
|
329
|
+
# Minimal APIs: app.MapMethods("/x", new[] { "GET", "POST" }, handler)
|
|
330
|
+
for match in MINIMAL_API_METHODS_PATTERN.finditer(content):
|
|
331
|
+
path = match.group(1)
|
|
332
|
+
verbs_raw = match.group(2)
|
|
333
|
+
verbs = re.findall(r'"([A-Za-z]+)"', verbs_raw)
|
|
334
|
+
line = _line_of(content, match.start())
|
|
335
|
+
for verb in verbs:
|
|
336
|
+
self._append_unique_route(result, path, verb.upper(), relative, line)
|
|
337
|
+
|
|
338
|
+
# Attribute routing: class-level [Route("api/[controller]")] + method-level [HttpGet]
|
|
339
|
+
class_routes = _class_routes_in_file(content)
|
|
340
|
+
for match in HTTP_ATTRIBUTE_PATTERN.finditer(content):
|
|
341
|
+
method = match.group(1).upper()
|
|
342
|
+
method_path = match.group(2) or ""
|
|
343
|
+
prefix = _active_class_route(class_routes, match.start())
|
|
344
|
+
full_path = _join_paths(prefix, method_path) if prefix else (method_path or "/")
|
|
345
|
+
self._append_unique_route(result, full_path, method, relative, _line_of(content, match.start()))
|
|
346
|
+
|
|
347
|
+
# Method-level [Route("/x")] — emit with method ANY (rare but legal)
|
|
348
|
+
for match in METHOD_ROUTE_ATTRIBUTE_PATTERN.finditer(content):
|
|
349
|
+
# Skip class-level routes — those are handled as prefixes above.
|
|
350
|
+
if any(start == match.start() for start, _, _ in class_routes):
|
|
351
|
+
continue
|
|
352
|
+
method_path = match.group(1)
|
|
353
|
+
prefix = _active_class_route(class_routes, match.start())
|
|
354
|
+
# Skip if the [HttpX(...)] attribute regex already would have matched this
|
|
355
|
+
# (i.e. the regex is part of an [HttpGet("/x")] form). We detect that by
|
|
356
|
+
# checking whether this offset's match overlaps with any HTTP_ATTRIBUTE_PATTERN
|
|
357
|
+
# match — but cheaper: Routes inside [Http*] aren't matched by METHOD_ROUTE_ATTRIBUTE
|
|
358
|
+
# because the latter requires "[Route(" specifically.
|
|
359
|
+
full_path = _join_paths(prefix, method_path) if prefix else (method_path or "/")
|
|
360
|
+
self._append_unique_route(result, full_path, "ANY", relative, _line_of(content, match.start()))
|
|
361
|
+
|
|
362
|
+
def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
|
|
363
|
+
for pattern, kind in DB_PATTERNS:
|
|
364
|
+
match = pattern.search(content)
|
|
365
|
+
if match is None:
|
|
366
|
+
continue
|
|
367
|
+
self._append_unique_database(
|
|
368
|
+
result, kind, relative,
|
|
369
|
+
_line_of(content, match.start()),
|
|
370
|
+
_line_snippet(content, match.start()),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
|
|
374
|
+
for pattern, hint, confidence in AUTH_PATTERNS:
|
|
375
|
+
match = pattern.search(content)
|
|
376
|
+
if match is None:
|
|
377
|
+
continue
|
|
378
|
+
self._append_unique_auth(
|
|
379
|
+
result, hint, relative,
|
|
380
|
+
_line_of(content, match.start()),
|
|
381
|
+
_line_snippet(content, match.start()),
|
|
382
|
+
confidence,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
386
|
+
for pattern in SECRET_PATTERNS:
|
|
387
|
+
for match in pattern.finditer(content):
|
|
388
|
+
groups = match.groups()
|
|
389
|
+
name = groups[0] if groups and groups[0] else "unknown"
|
|
390
|
+
self._append_unique_secret(
|
|
391
|
+
result, name, relative,
|
|
392
|
+
_line_of(content, match.start()),
|
|
393
|
+
_line_snippet(content, match.start()),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
397
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
398
|
+
for match in pattern.finditer(content):
|
|
399
|
+
target = match.group(1)
|
|
400
|
+
if not (target.startswith("http://") or target.startswith("https://")):
|
|
401
|
+
continue
|
|
402
|
+
self._append_unique_external(
|
|
403
|
+
result, target, relative,
|
|
404
|
+
_line_of(content, match.start()),
|
|
405
|
+
_line_snippet(content, match.start()),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
|
|
409
|
+
for pattern, name in FRAMEWORK_PATTERNS:
|
|
410
|
+
match = pattern.search(content)
|
|
411
|
+
if match is None:
|
|
412
|
+
continue
|
|
413
|
+
self._append_unique_framework(
|
|
414
|
+
result, name, relative,
|
|
415
|
+
_line_of(content, match.start()),
|
|
416
|
+
_line_snippet(content, match.start()),
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
420
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
421
|
+
match = pattern.search(content)
|
|
422
|
+
if match is None:
|
|
423
|
+
continue
|
|
424
|
+
self._append_unique_entrypoint(
|
|
425
|
+
result, hint, relative,
|
|
426
|
+
_line_of(content, match.start()),
|
|
427
|
+
_line_snippet(content, match.start()),
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
def _infer_service_role(self, content: str, relative: str, result: ScanResult) -> None:
|
|
431
|
+
haystack = (relative + " " + content[:500]).lower()
|
|
432
|
+
role: str | None = None
|
|
433
|
+
if any(token in haystack for token in ("worker", "consumer", "queue", "background", "hostedservice")):
|
|
434
|
+
role = "worker"
|
|
435
|
+
elif any(token in haystack for token in ("controller", "api", "endpoint", "router")):
|
|
436
|
+
role = "api"
|
|
437
|
+
elif any(token in haystack for token in ("client", "sdk")):
|
|
438
|
+
role = "client"
|
|
439
|
+
if role:
|
|
440
|
+
self._append_unique_service(result, f"service_role:{role}", relative)
|
|
441
|
+
|
|
442
|
+
# ---------- Append helpers ----------
|
|
443
|
+
|
|
444
|
+
@staticmethod
|
|
445
|
+
def _append_unique_route(result: ScanResult, path: str, method: str, file: str, line: int | None) -> None:
|
|
446
|
+
key = (path, method, file)
|
|
447
|
+
if any((item.path, item.method, item.file) == key for item in result.routes):
|
|
448
|
+
return
|
|
449
|
+
result.routes.append(Route(path=path, method=method, file=file, line=line))
|
|
450
|
+
|
|
451
|
+
@staticmethod
|
|
452
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
453
|
+
key = (kind, file)
|
|
454
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
455
|
+
return
|
|
456
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
460
|
+
key = (hint, file)
|
|
461
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
462
|
+
return
|
|
463
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
467
|
+
key = (name, file)
|
|
468
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
469
|
+
return
|
|
470
|
+
result.secret_hints.append(SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85))
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
474
|
+
key = (target, file)
|
|
475
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
476
|
+
return
|
|
477
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
478
|
+
|
|
479
|
+
@staticmethod
|
|
480
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
481
|
+
key = (hint, file)
|
|
482
|
+
if any((item.hint, item.file) == key for item in result.framework_hints):
|
|
483
|
+
return
|
|
484
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
485
|
+
|
|
486
|
+
@staticmethod
|
|
487
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
488
|
+
key = (hint, file)
|
|
489
|
+
if any((item.hint, item.file) == key for item in result.entrypoint_hints):
|
|
490
|
+
return
|
|
491
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
495
|
+
key = (hint, file)
|
|
496
|
+
if any((item.hint, item.file) == key for item in result.service_hints):
|
|
497
|
+
return
|
|
498
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
__all__ = ["DotnetAnalyzer"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from attackmap.sdk.contracts import AnalyzerMetadata, AnalyzerProtocol
|
|
4
|
+
from attackmap.sdk.models import (
|
|
5
|
+
AuthHint,
|
|
6
|
+
DatabaseHint,
|
|
7
|
+
EntrypointHint,
|
|
8
|
+
ExternalCall,
|
|
9
|
+
FrameworkHint,
|
|
10
|
+
Route,
|
|
11
|
+
ScanResult,
|
|
12
|
+
SecretHint,
|
|
13
|
+
ServiceHint,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
AttackMapAnalyzerProtocol = AnalyzerProtocol
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AnalyzerMetadata",
|
|
20
|
+
"AttackMapAnalyzerProtocol",
|
|
21
|
+
"Route",
|
|
22
|
+
"ExternalCall",
|
|
23
|
+
"DatabaseHint",
|
|
24
|
+
"AuthHint",
|
|
25
|
+
"EntrypointHint",
|
|
26
|
+
"FrameworkHint",
|
|
27
|
+
"ServiceHint",
|
|
28
|
+
"SecretHint",
|
|
29
|
+
"ScanResult",
|
|
30
|
+
]
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attackmap-analyzer-dotnet
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: C# / .NET (ASP.NET Core) ecosystem analyzer plugin for AttackMap (minimal APIs, attribute routing, EF Core, Identity, JwtBearer).
|
|
5
|
+
Author: AttackMap Contributors
|
|
6
|
+
Author-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
7
|
+
Maintainer-email: Matthew Davis <matthewd@matthewd.xyz>
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-dotnet
|
|
10
|
+
Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-dotnet
|
|
11
|
+
Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-dotnet/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-dotnet/blob/main/CHANGELOG.md
|
|
13
|
+
Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
|
|
14
|
+
Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Intended Audience :: System Administrators
|
|
19
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
20
|
+
Classifier: Operating System :: OS Independent
|
|
21
|
+
Classifier: Programming Language :: Python :: 3
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Requires-Dist: pydantic>=2.7.0
|
|
33
|
+
Provides-Extra: core
|
|
34
|
+
Requires-Dist: attackmap>=0.1.0; extra == "core"
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
38
|
+
Requires-Dist: attackmap>=0.1.0; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# attackmap-analyzer-dotnet
|
|
42
|
+
|
|
43
|
+
C# / .NET (ASP.NET Core) ecosystem analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
|
|
44
|
+
|
|
45
|
+
This analyzer extracts structured signals from .NET solutions and projects:
|
|
46
|
+
|
|
47
|
+
- **Web frameworks** — ASP.NET Core minimal APIs (`app.MapGet`, `app.MapPost`, `app.MapMethods`), attribute routing on controllers (`[HttpGet]`, `[HttpPost]`, with class-level `[Route]` prefix joining and `[controller]` token substitution)
|
|
48
|
+
- **Databases** — Entity Framework Core (`UseSqlServer` / `UseNpgsql` / `UseMySql` / `UseSqlite`), Dapper, System.Data.SqlClient / Microsoft.Data.SqlClient, Npgsql, MySql.Data / MySqlConnector, MongoDB.Driver, StackExchange.Redis, AWS SDK (S3, DynamoDB)
|
|
49
|
+
- **Auth packages** — `AddJwtBearer` (Microsoft.AspNetCore.Authentication.JwtBearer), `AddOpenIdConnect`, ASP.NET Identity (`UserManager`, `SignInManager`, `IdentityUser`, `PasswordHasher`), `[Authorize]` attribute, Duende IdentityServer, BCrypt.Net, Argon2
|
|
50
|
+
- **HTTP clients (external calls)** — `HttpClient.GetAsync` / `PostAsync` / `SendAsync`, `HttpRequestMessage`, `RestClient` (RestSharp), `new Uri(...)`
|
|
51
|
+
- **Secrets** — `Environment.GetEnvironmentVariable("...")`, `IConfiguration["..."]` / `Configuration["..."]` / `builder.Configuration["..."]` with secret-shaped keys, `GetConnectionString(...)`
|
|
52
|
+
- **Service hints** — `<RootNamespace>` and `<AssemblyName>` from `.csproj`
|
|
53
|
+
|
|
54
|
+
All emissions populate AttackMap's Signal v2 fields (line numbers, evidence snippets, confidence scores) so downstream insights can cite `path/to/file.cs:NN`.
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install git+https://github.com/mlaify/attackmap-analyzer-dotnet.git
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The analyzer is auto-discovered by AttackMap via the `attackmap.analyzers` entry-point group.
|
|
63
|
+
|
|
64
|
+
## Usage with AttackMap
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Auto-discovered when installed:
|
|
68
|
+
attackmap analyze /path/to/dotnet/repo
|
|
69
|
+
|
|
70
|
+
# Or invoke explicitly:
|
|
71
|
+
attackmap analyze /path/to/dotnet/repo --module dotnet
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Detection
|
|
75
|
+
|
|
76
|
+
`detect()` returns true when any of the following are present, ignoring `bin/`, `obj/`, `.vs/`, `.idea/`, `.git/`, `node_modules/`, `packages/`, `TestResults/`, and `publish/`:
|
|
77
|
+
|
|
78
|
+
- A `.csproj`, `.fsproj`, or `.sln` file anywhere in the tree
|
|
79
|
+
- A `.cs` file anywhere in the tree
|
|
80
|
+
|
|
81
|
+
## Coverage notes
|
|
82
|
+
|
|
83
|
+
- **Class-level `[Route]` prefix joining**: a controller annotated with `[Route("api/[controller]")]` or `[Route("api/orders")]` causes its method-level `[HttpGet("{id:int}")]` to emit as `api/Orders/{id:int}` (with `[controller]` substituted with the class name minus the `Controller` suffix). Multiple controllers per file are tracked correctly.
|
|
84
|
+
- **Minimal API + controller routing in the same project**: both extractors run on every `.cs` file. The minimal-API regex looks for `app.Map*("...", handler)`; the controller regex looks for `[HttpX("...")]` attributes. They don't overlap.
|
|
85
|
+
- **Connection strings as secrets**: `GetConnectionString("DefaultConnection")` is treated as a secret reference because the connection string itself is a credential. The named key (`DefaultConnection`) is stored as the secret name.
|
|
86
|
+
- **F# (.fs) projects** are detected via `.fsproj` but route extraction is not yet implemented (Giraffe / Saturn).
|
|
87
|
+
- **Razor Pages** (`@page` directives in `.cshtml` / `.razor`) are not yet covered. Most security-critical APIs use minimal APIs or controllers.
|
|
88
|
+
|
|
89
|
+
## License
|
|
90
|
+
|
|
91
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
attackmap_analyzer_dotnet/__init__.py,sha256=f1bi8uNYh64jIVR1RDyYSHffcPEMY0I_4SRV1XKREmE,67
|
|
2
|
+
attackmap_analyzer_dotnet/analyzer.py,sha256=YIm5MmY4x27Bu_xjO0cSzZgBa98IcHb2zf_fwb0_oKI,22191
|
|
3
|
+
attackmap_analyzer_dotnet/contracts.py,sha256=dsveFe7szz7xceev0PIfyc5jQlNiES0whsdxwu9D7YI,578
|
|
4
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
|
|
5
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/METADATA,sha256=GoqM-DLmwtuUy8g6CcwygciEHC3KkX-dJsBni4duzek,5230
|
|
6
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
7
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/entry_points.txt,sha256=qBxh2pQOnk8Xf2XkaIf6W6nHS-2g85WAdYjRvN9umek,72
|
|
8
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/top_level.txt,sha256=aTWCBOzn0gq6IFqHRxi8KM0clBVrKAadizEL0I17KGw,26
|
|
9
|
+
attackmap_analyzer_dotnet-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Davis and AttackMap Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attackmap_analyzer_dotnet
|