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.

@@ -0,0 +1,3 @@
1
+ from .analyzer import DotnetAnalyzer
2
+
3
+ __all__ = ["DotnetAnalyzer"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ dotnet = attackmap_analyzer_dotnet:DotnetAnalyzer
@@ -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