attackmap-analyzer-dotnet 0.1.0__tar.gz

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,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,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,51 @@
1
+ # attackmap-analyzer-dotnet
2
+
3
+ C# / .NET (ASP.NET Core) ecosystem analyzer for [AttackMap](https://github.com/mlaify/AttackMap).
4
+
5
+ This analyzer extracts structured signals from .NET solutions and projects:
6
+
7
+ - **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)
8
+ - **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)
9
+ - **Auth packages** — `AddJwtBearer` (Microsoft.AspNetCore.Authentication.JwtBearer), `AddOpenIdConnect`, ASP.NET Identity (`UserManager`, `SignInManager`, `IdentityUser`, `PasswordHasher`), `[Authorize]` attribute, Duende IdentityServer, BCrypt.Net, Argon2
10
+ - **HTTP clients (external calls)** — `HttpClient.GetAsync` / `PostAsync` / `SendAsync`, `HttpRequestMessage`, `RestClient` (RestSharp), `new Uri(...)`
11
+ - **Secrets** — `Environment.GetEnvironmentVariable("...")`, `IConfiguration["..."]` / `Configuration["..."]` / `builder.Configuration["..."]` with secret-shaped keys, `GetConnectionString(...)`
12
+ - **Service hints** — `<RootNamespace>` and `<AssemblyName>` from `.csproj`
13
+
14
+ All emissions populate AttackMap's Signal v2 fields (line numbers, evidence snippets, confidence scores) so downstream insights can cite `path/to/file.cs:NN`.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pip install git+https://github.com/mlaify/attackmap-analyzer-dotnet.git
20
+ ```
21
+
22
+ The analyzer is auto-discovered by AttackMap via the `attackmap.analyzers` entry-point group.
23
+
24
+ ## Usage with AttackMap
25
+
26
+ ```bash
27
+ # Auto-discovered when installed:
28
+ attackmap analyze /path/to/dotnet/repo
29
+
30
+ # Or invoke explicitly:
31
+ attackmap analyze /path/to/dotnet/repo --module dotnet
32
+ ```
33
+
34
+ ## Detection
35
+
36
+ `detect()` returns true when any of the following are present, ignoring `bin/`, `obj/`, `.vs/`, `.idea/`, `.git/`, `node_modules/`, `packages/`, `TestResults/`, and `publish/`:
37
+
38
+ - A `.csproj`, `.fsproj`, or `.sln` file anywhere in the tree
39
+ - A `.cs` file anywhere in the tree
40
+
41
+ ## Coverage notes
42
+
43
+ - **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.
44
+ - **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.
45
+ - **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.
46
+ - **F# (.fs) projects** are detected via `.fsproj` but route extraction is not yet implemented (Giraffe / Saturn).
47
+ - **Razor Pages** (`@page` directives in `.cshtml` / `.razor`) are not yet covered. Most security-critical APIs use minimal APIs or controllers.
48
+
49
+ ## License
50
+
51
+ MIT
@@ -0,0 +1,74 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "attackmap-analyzer-dotnet"
7
+ version = "0.1.0"
8
+ description = "C# / .NET (ASP.NET Core) ecosystem analyzer plugin for AttackMap (minimal APIs, attribute routing, EF Core, Identity, JwtBearer)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "Matthew Davis", email = "matthewd@matthewd.xyz"},
14
+ {name = "AttackMap Contributors"},
15
+ ]
16
+ maintainers = [
17
+ {name = "Matthew Davis", email = "matthewd@matthewd.xyz"},
18
+ ]
19
+ keywords = [
20
+ "security",
21
+ "static-analysis",
22
+ "appsec",
23
+ "defensive-security",
24
+ "attackmap",
25
+ "attackmap-plugin",
26
+ ]
27
+ classifiers = [
28
+ "Development Status :: 4 - Beta",
29
+ "Intended Audience :: Developers",
30
+ "Intended Audience :: Information Technology",
31
+ "Intended Audience :: System Administrators",
32
+ "License :: OSI Approved :: MIT License",
33
+ "Operating System :: OS Independent",
34
+ "Programming Language :: Python :: 3",
35
+ "Programming Language :: Python :: 3.11",
36
+ "Programming Language :: Python :: 3.12",
37
+ "Programming Language :: Python :: 3.13",
38
+ "Programming Language :: Python :: 3 :: Only",
39
+ "Topic :: Security",
40
+ "Topic :: Software Development :: Quality Assurance",
41
+ "Typing :: Typed",
42
+ ]
43
+ dependencies = [
44
+ "pydantic>=2.7.0",
45
+ ]
46
+
47
+ [project.optional-dependencies]
48
+ core = [
49
+ "attackmap>=0.1.0",
50
+ ]
51
+ dev = [
52
+ "pytest>=8.0.0",
53
+ "build>=1.2.0",
54
+ "attackmap>=0.1.0",
55
+ ]
56
+
57
+ [project.urls]
58
+ Homepage = "https://github.com/mlaify/attackmap-analyzer-dotnet"
59
+ Repository = "https://github.com/mlaify/attackmap-analyzer-dotnet"
60
+ Issues = "https://github.com/mlaify/attackmap-analyzer-dotnet/issues"
61
+ Changelog = "https://github.com/mlaify/attackmap-analyzer-dotnet/blob/main/CHANGELOG.md"
62
+ "AttackMap core" = "https://github.com/mlaify/AttackMap"
63
+
64
+ [project.entry-points."attackmap.analyzers"]
65
+ dotnet = "attackmap_analyzer_dotnet:DotnetAnalyzer"
66
+
67
+ [tool.setuptools]
68
+ package-dir = {"" = "src"}
69
+
70
+ [tool.setuptools.packages.find]
71
+ where = ["src"]
72
+
73
+ [tool.pytest.ini_options]
74
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/attackmap_analyzer_dotnet/__init__.py
5
+ src/attackmap_analyzer_dotnet/analyzer.py
6
+ src/attackmap_analyzer_dotnet/contracts.py
7
+ src/attackmap_analyzer_dotnet.egg-info/PKG-INFO
8
+ src/attackmap_analyzer_dotnet.egg-info/SOURCES.txt
9
+ src/attackmap_analyzer_dotnet.egg-info/dependency_links.txt
10
+ src/attackmap_analyzer_dotnet.egg-info/entry_points.txt
11
+ src/attackmap_analyzer_dotnet.egg-info/requires.txt
12
+ src/attackmap_analyzer_dotnet.egg-info/top_level.txt
13
+ tests/test_dotnet_analyzer.py
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ dotnet = attackmap_analyzer_dotnet:DotnetAnalyzer
@@ -0,0 +1,9 @@
1
+ pydantic>=2.7.0
2
+
3
+ [core]
4
+ attackmap>=0.1.0
5
+
6
+ [dev]
7
+ pytest>=8.0.0
8
+ build>=1.2.0
9
+ attackmap>=0.1.0
@@ -0,0 +1,420 @@
1
+ """Tests for the DotnetAnalyzer plugin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from attackmap_analyzer_dotnet import DotnetAnalyzer
10
+
11
+
12
+ # ---------- detect() ----------
13
+
14
+
15
+ def test_detect_picks_up_csproj(tmp_path: Path) -> None:
16
+ (tmp_path / "demo.csproj").write_text("<Project></Project>", encoding="utf-8")
17
+ assert DotnetAnalyzer().detect(tmp_path) is True
18
+
19
+
20
+ def test_detect_picks_up_solution(tmp_path: Path) -> None:
21
+ (tmp_path / "demo.sln").write_text("Microsoft Visual Studio Solution File\n", encoding="utf-8")
22
+ assert DotnetAnalyzer().detect(tmp_path) is True
23
+
24
+
25
+ def test_detect_picks_up_bare_cs_file(tmp_path: Path) -> None:
26
+ (tmp_path / "Program.cs").write_text("class Program {}\n", encoding="utf-8")
27
+ assert DotnetAnalyzer().detect(tmp_path) is True
28
+
29
+
30
+ def test_detect_skips_bin_obj(tmp_path: Path) -> None:
31
+ (tmp_path / "bin" / "Debug").mkdir(parents=True)
32
+ (tmp_path / "bin" / "Debug" / "leftover.cs").write_text("class X {}\n", encoding="utf-8")
33
+ assert DotnetAnalyzer().detect(tmp_path) is False
34
+
35
+
36
+ # ---------- Minimal API routes ----------
37
+
38
+
39
+ def test_minimal_api_map_methods_extracted(tmp_path: Path) -> None:
40
+ (tmp_path / "Program.cs").write_text(
41
+ 'using Microsoft.AspNetCore.Builder;\n'
42
+ '\n'
43
+ 'var builder = WebApplication.CreateBuilder(args);\n'
44
+ 'var app = builder.Build();\n'
45
+ '\n'
46
+ 'app.MapGet("/hello", () => "world");\n'
47
+ 'app.MapPost("/users", (User u) => Results.Ok());\n'
48
+ 'app.MapDelete("/users/{id:int}", (int id) => Results.NoContent());\n'
49
+ '\n'
50
+ 'app.Run();\n',
51
+ encoding="utf-8",
52
+ )
53
+ result = DotnetAnalyzer().analyze(tmp_path)
54
+ pairs = sorted({(r.path, r.method) for r in result.routes})
55
+ assert ("/hello", "GET") in pairs
56
+ assert ("/users", "POST") in pairs
57
+ assert ("/users/{id:int}", "DELETE") in pairs
58
+
59
+ hello = next(r for r in result.routes if r.path == "/hello")
60
+ assert hello.line == 6
61
+
62
+
63
+ def test_minimal_api_map_methods_with_explicit_methods(tmp_path: Path) -> None:
64
+ (tmp_path / "Program.cs").write_text(
65
+ 'using Microsoft.AspNetCore.Builder;\n'
66
+ 'var app = WebApplication.CreateBuilder(args).Build();\n'
67
+ 'app.MapMethods("/admin/maintenance", new[] { "GET", "POST" }, () => Results.Ok());\n',
68
+ encoding="utf-8",
69
+ )
70
+ result = DotnetAnalyzer().analyze(tmp_path)
71
+ pairs = {(r.path, r.method) for r in result.routes}
72
+ assert ("/admin/maintenance", "GET") in pairs
73
+ assert ("/admin/maintenance", "POST") in pairs
74
+
75
+
76
+ # ---------- Attribute routing ----------
77
+
78
+
79
+ def test_attribute_routing_with_class_route_and_controller_token(tmp_path: Path) -> None:
80
+ src = tmp_path / "Controllers" / "UsersController.cs"
81
+ src.parent.mkdir(parents=True)
82
+ src.write_text(
83
+ 'using Microsoft.AspNetCore.Mvc;\n'
84
+ '\n'
85
+ 'namespace Demo.Api;\n'
86
+ '\n'
87
+ '[ApiController]\n'
88
+ '[Route("api/[controller]")]\n'
89
+ 'public class UsersController : ControllerBase\n'
90
+ '{\n'
91
+ ' [HttpGet("{id:int}")]\n'
92
+ ' public IActionResult Get(int id) => Ok();\n'
93
+ '\n'
94
+ ' [HttpPost]\n'
95
+ ' public IActionResult Create([FromBody] User u) => Ok();\n'
96
+ '\n'
97
+ ' [HttpDelete("{id:int}")]\n'
98
+ ' public IActionResult Delete(int id) => NoContent();\n'
99
+ '}\n',
100
+ encoding="utf-8",
101
+ )
102
+ result = DotnetAnalyzer().analyze(tmp_path)
103
+ pairs = sorted({(r.path, r.method) for r in result.routes})
104
+ assert ("api/Users/{id:int}", "GET") in pairs
105
+ assert ("api/Users", "POST") in pairs
106
+ assert ("api/Users/{id:int}", "DELETE") in pairs
107
+
108
+
109
+ def test_attribute_routing_with_explicit_class_path(tmp_path: Path) -> None:
110
+ src = tmp_path / "OrdersController.cs"
111
+ src.write_text(
112
+ 'using Microsoft.AspNetCore.Mvc;\n'
113
+ '\n'
114
+ '[ApiController]\n'
115
+ '[Route("api/orders/v2")]\n'
116
+ 'public class OrdersController : ControllerBase\n'
117
+ '{\n'
118
+ ' [HttpGet]\n'
119
+ ' public IActionResult List() => Ok();\n'
120
+ '\n'
121
+ ' [HttpPost("refund")]\n'
122
+ ' public IActionResult Refund() => Ok();\n'
123
+ '}\n',
124
+ encoding="utf-8",
125
+ )
126
+ result = DotnetAnalyzer().analyze(tmp_path)
127
+ pairs = {(r.path, r.method) for r in result.routes}
128
+ assert ("api/orders/v2", "GET") in pairs
129
+ assert ("api/orders/v2/refund", "POST") in pairs
130
+
131
+
132
+ def test_attribute_routing_handles_two_controllers_in_one_file(tmp_path: Path) -> None:
133
+ src = tmp_path / "MultipleControllers.cs"
134
+ src.write_text(
135
+ 'using Microsoft.AspNetCore.Mvc;\n'
136
+ '\n'
137
+ '[Route("api/a")]\n'
138
+ 'public class AController : ControllerBase {\n'
139
+ ' [HttpGet("x")]\n'
140
+ ' public IActionResult X() => Ok();\n'
141
+ '}\n'
142
+ '\n'
143
+ '[Route("api/b")]\n'
144
+ 'public class BController : ControllerBase {\n'
145
+ ' [HttpGet("y")]\n'
146
+ ' public IActionResult Y() => Ok();\n'
147
+ '}\n',
148
+ encoding="utf-8",
149
+ )
150
+ result = DotnetAnalyzer().analyze(tmp_path)
151
+ pairs = {(r.path, r.method) for r in result.routes}
152
+ assert ("api/a/x", "GET") in pairs
153
+ assert ("api/b/y", "GET") in pairs
154
+ assert ("api/a/y", "GET") not in pairs
155
+ assert ("api/b/x", "GET") not in pairs
156
+
157
+
158
+ # ---------- Databases ----------
159
+
160
+
161
+ def test_efcore_use_npgsql_emits_postgresql(tmp_path: Path) -> None:
162
+ (tmp_path / "DbStartup.cs").write_text(
163
+ 'using Microsoft.EntityFrameworkCore;\n'
164
+ 'public class Startup {\n'
165
+ ' public void ConfigureServices(IServiceCollection services) {\n'
166
+ ' services.AddDbContext<MyContext>(opt => opt.UseNpgsql("..."));\n'
167
+ ' }\n'
168
+ '}\n',
169
+ encoding="utf-8",
170
+ )
171
+ result = DotnetAnalyzer().analyze(tmp_path)
172
+ assert any(d.kind == "postgresql" for d in result.databases)
173
+
174
+
175
+ def test_mongo_redis_each_emit_distinct_kinds(tmp_path: Path) -> None:
176
+ (tmp_path / "Mongo.cs").write_text(
177
+ 'using MongoDB.Driver;\npublic class M { IMongoDatabase Db; }\n',
178
+ encoding="utf-8",
179
+ )
180
+ (tmp_path / "Redis.cs").write_text(
181
+ 'using StackExchange.Redis;\n'
182
+ 'public class R { void X() { ConnectionMultiplexer.Connect("localhost"); } }\n',
183
+ encoding="utf-8",
184
+ )
185
+ result = DotnetAnalyzer().analyze(tmp_path)
186
+ kinds = {d.kind for d in result.databases}
187
+ assert "mongodb" in kinds
188
+ assert "redis" in kinds
189
+
190
+
191
+ def test_dapper_emits_sql_hint(tmp_path: Path) -> None:
192
+ (tmp_path / "Repo.cs").write_text(
193
+ 'using Dapper;\n'
194
+ 'using System.Data;\n'
195
+ 'public class Repo { public Repo(IDbConnection conn) { } }\n',
196
+ encoding="utf-8",
197
+ )
198
+ result = DotnetAnalyzer().analyze(tmp_path)
199
+ assert any(d.kind == "sql" for d in result.databases)
200
+
201
+
202
+ # ---------- Auth ----------
203
+
204
+
205
+ def test_jwt_bearer_setup(tmp_path: Path) -> None:
206
+ (tmp_path / "Program.cs").write_text(
207
+ 'using Microsoft.AspNetCore.Authentication.JwtBearer;\n'
208
+ 'var builder = WebApplication.CreateBuilder(args);\n'
209
+ 'builder.Services.AddAuthentication().AddJwtBearer(options => { });\n',
210
+ encoding="utf-8",
211
+ )
212
+ result = DotnetAnalyzer().analyze(tmp_path)
213
+ assert any(h.hint == "jwt" for h in result.auth_hints)
214
+
215
+
216
+ def test_authorize_attribute_emits_hint(tmp_path: Path) -> None:
217
+ (tmp_path / "Sec.cs").write_text(
218
+ 'using Microsoft.AspNetCore.Authorization;\n'
219
+ 'using Microsoft.AspNetCore.Mvc;\n'
220
+ '\n'
221
+ '[Authorize(Roles = "Admin")]\n'
222
+ 'public class AdminController : ControllerBase { }\n',
223
+ encoding="utf-8",
224
+ )
225
+ result = DotnetAnalyzer().analyze(tmp_path)
226
+ assert any(h.hint == "authorize_attribute" for h in result.auth_hints)
227
+
228
+
229
+ def test_identity_signals(tmp_path: Path) -> None:
230
+ (tmp_path / "Auth.cs").write_text(
231
+ 'using Microsoft.AspNetCore.Identity;\n'
232
+ 'public class Service {\n'
233
+ ' private readonly UserManager<IdentityUser> _userManager;\n'
234
+ ' public Service(UserManager<IdentityUser> um) { _userManager = um; }\n'
235
+ '}\n',
236
+ encoding="utf-8",
237
+ )
238
+ result = DotnetAnalyzer().analyze(tmp_path)
239
+ assert any(h.hint == "aspnet_identity" for h in result.auth_hints)
240
+
241
+
242
+ # ---------- Secrets ----------
243
+
244
+
245
+ def test_environment_getenv_secrets(tmp_path: Path) -> None:
246
+ (tmp_path / "Cfg.cs").write_text(
247
+ 'public class Cfg {\n'
248
+ ' string s = Environment.GetEnvironmentVariable("JWT_SECRET");\n'
249
+ ' string p = Environment.GetEnvironmentVariable("DATABASE_PASSWORD");\n'
250
+ ' string a = Environment.GetEnvironmentVariable("STRIPE_API_KEY");\n'
251
+ '}\n',
252
+ encoding="utf-8",
253
+ )
254
+ result = DotnetAnalyzer().analyze(tmp_path)
255
+ names = {s.name for s in result.secret_hints}
256
+ assert "JWT_SECRET" in names
257
+ assert "DATABASE_PASSWORD" in names
258
+ assert "STRIPE_API_KEY" in names
259
+
260
+ jwt = next(s for s in result.secret_hints if s.name == "JWT_SECRET")
261
+ assert jwt.line == 2
262
+
263
+
264
+ def test_iconfiguration_secret_keys(tmp_path: Path) -> None:
265
+ (tmp_path / "Cfg.cs").write_text(
266
+ 'public class Cfg {\n'
267
+ ' public Cfg(IConfiguration cfg) {\n'
268
+ ' var s = cfg["Auth:JwtSecret"];\n'
269
+ ' var k = builder.Configuration["Stripe:ApiKey"];\n'
270
+ ' }\n'
271
+ '}\n',
272
+ encoding="utf-8",
273
+ )
274
+ result = DotnetAnalyzer().analyze(tmp_path)
275
+ names_lower = {s.name.lower() for s in result.secret_hints}
276
+ assert any("jwtsecret" in n.replace(":", "") for n in names_lower)
277
+ assert any("apikey" in n.replace(":", "") for n in names_lower)
278
+
279
+
280
+ def test_connection_string_extracted(tmp_path: Path) -> None:
281
+ (tmp_path / "Cfg.cs").write_text(
282
+ 'public class Startup {\n'
283
+ ' public void Configure(IConfiguration cfg) {\n'
284
+ ' var conn = cfg.GetConnectionString("DefaultConnection");\n'
285
+ ' }\n'
286
+ '}\n',
287
+ encoding="utf-8",
288
+ )
289
+ result = DotnetAnalyzer().analyze(tmp_path)
290
+ names = {s.name for s in result.secret_hints}
291
+ assert "DefaultConnection" in names
292
+
293
+
294
+ # ---------- External calls ----------
295
+
296
+
297
+ def test_httpclient_getasync_extracted(tmp_path: Path) -> None:
298
+ (tmp_path / "Client.cs").write_text(
299
+ 'using System.Net.Http;\n'
300
+ 'public class Client {\n'
301
+ ' private readonly HttpClient _httpClient;\n'
302
+ ' public async Task Fetch() {\n'
303
+ ' await _httpClient.GetAsync("https://api.stripe.com/v1/charges");\n'
304
+ ' }\n'
305
+ '}\n',
306
+ encoding="utf-8",
307
+ )
308
+ result = DotnetAnalyzer().analyze(tmp_path)
309
+ targets = {e.target for e in result.external_calls}
310
+ assert "https://api.stripe.com/v1/charges" in targets
311
+
312
+
313
+ # ---------- Frameworks + entrypoints ----------
314
+
315
+
316
+ def test_aspnetcore_application_markers(tmp_path: Path) -> None:
317
+ (tmp_path / "Program.cs").write_text(
318
+ 'using Microsoft.AspNetCore.Builder;\n'
319
+ '\n'
320
+ 'var builder = WebApplication.CreateBuilder(args);\n'
321
+ 'var app = builder.Build();\n'
322
+ 'app.MapGet("/", () => "hello");\n'
323
+ 'app.Run();\n',
324
+ encoding="utf-8",
325
+ )
326
+ result = DotnetAnalyzer().analyze(tmp_path)
327
+ fw = {f.hint for f in result.framework_hints}
328
+ assert "aspnetcore" in fw
329
+
330
+ ep = {e.hint for e in result.entrypoint_hints}
331
+ assert "webapplication_builder" in ep
332
+ assert "webapp_run" in ep
333
+
334
+
335
+ # ---------- Build metadata → service hints ----------
336
+
337
+
338
+ def test_csproj_root_namespace_picked_up(tmp_path: Path) -> None:
339
+ (tmp_path / "Demo.Api.csproj").write_text(
340
+ "<Project Sdk=\"Microsoft.NET.Sdk\">\n"
341
+ " <PropertyGroup>\n"
342
+ " <RootNamespace>Acme.Billing.Api</RootNamespace>\n"
343
+ " <AssemblyName>Acme.Billing.Api</AssemblyName>\n"
344
+ " </PropertyGroup>\n"
345
+ "</Project>\n",
346
+ encoding="utf-8",
347
+ )
348
+ (tmp_path / "Program.cs").write_text("class P {}\n", encoding="utf-8")
349
+ result = DotnetAnalyzer().analyze(tmp_path)
350
+ hints = {h.hint for h in result.service_hints}
351
+ assert "namespace:Acme.Billing.Api" in hints
352
+ assert "assembly:Acme.Billing.Api" in hints
353
+
354
+
355
+ # ---------- End-to-end sanity ----------
356
+
357
+
358
+ def test_full_aspnetcore_service_signal_set(tmp_path: Path) -> None:
359
+ (tmp_path / "Demo.csproj").write_text(
360
+ "<Project><PropertyGroup><RootNamespace>Demo</RootNamespace></PropertyGroup></Project>\n",
361
+ encoding="utf-8",
362
+ )
363
+
364
+ (tmp_path / "Program.cs").write_text(
365
+ 'using Microsoft.AspNetCore.Authentication.JwtBearer;\n'
366
+ 'using Microsoft.AspNetCore.Builder;\n'
367
+ 'using Microsoft.EntityFrameworkCore;\n'
368
+ '\n'
369
+ 'var builder = WebApplication.CreateBuilder(args);\n'
370
+ 'builder.Services.AddDbContext<MyContext>(opt => opt.UseNpgsql("..."));\n'
371
+ 'builder.Services.AddAuthentication().AddJwtBearer();\n'
372
+ 'var app = builder.Build();\n'
373
+ 'app.MapGet("/health", () => "ok");\n'
374
+ 'app.Run();\n',
375
+ encoding="utf-8",
376
+ )
377
+
378
+ src = tmp_path / "Controllers" / "OrdersController.cs"
379
+ src.parent.mkdir()
380
+ src.write_text(
381
+ 'using Microsoft.AspNetCore.Mvc;\n'
382
+ 'using Microsoft.AspNetCore.Authorization;\n'
383
+ '\n'
384
+ '[ApiController]\n'
385
+ '[Route("api/[controller]")]\n'
386
+ '[Authorize]\n'
387
+ 'public class OrdersController : ControllerBase\n'
388
+ '{\n'
389
+ ' [HttpGet("{id:int}")]\n'
390
+ ' public IActionResult Get(int id) => Ok();\n'
391
+ '\n'
392
+ ' [HttpPost("admin/refund")]\n'
393
+ ' public IActionResult Refund() => Ok();\n'
394
+ '\n'
395
+ ' private readonly HttpClient _httpClient;\n'
396
+ ' public async Task Charge() {\n'
397
+ ' var key = Environment.GetEnvironmentVariable("STRIPE_API_KEY");\n'
398
+ ' await _httpClient.GetAsync("https://api.stripe.com/v1/charges");\n'
399
+ ' }\n'
400
+ '}\n',
401
+ encoding="utf-8",
402
+ )
403
+
404
+ result = DotnetAnalyzer().analyze(tmp_path)
405
+
406
+ pairs = {(r.path, r.method) for r in result.routes}
407
+ assert ("/health", "GET") in pairs
408
+ assert ("api/Orders/{id:int}", "GET") in pairs
409
+ assert ("api/Orders/admin/refund", "POST") in pairs
410
+
411
+ assert any(d.kind == "postgresql" for d in result.databases)
412
+ assert any(h.hint == "jwt" for h in result.auth_hints)
413
+ assert any(h.hint == "authorize_attribute" for h in result.auth_hints)
414
+ assert "STRIPE_API_KEY" in {s.name for s in result.secret_hints}
415
+ assert any(e.target == "https://api.stripe.com/v1/charges" for e in result.external_calls)
416
+ assert any(f.hint == "aspnetcore" for f in result.framework_hints)
417
+ assert any(e.hint == "webapp_run" for e in result.entrypoint_hints)
418
+ assert any(h.hint == "namespace:Demo" for h in result.service_hints)
419
+
420
+ assert all(r.line is not None for r in result.routes)