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.
- attackmap_analyzer_dotnet-0.1.0/LICENSE +21 -0
- attackmap_analyzer_dotnet-0.1.0/PKG-INFO +91 -0
- attackmap_analyzer_dotnet-0.1.0/README.md +51 -0
- attackmap_analyzer_dotnet-0.1.0/pyproject.toml +74 -0
- attackmap_analyzer_dotnet-0.1.0/setup.cfg +4 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet/__init__.py +3 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet/analyzer.py +501 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet/contracts.py +30 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/PKG-INFO +91 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/SOURCES.txt +13 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/dependency_links.txt +1 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/entry_points.txt +2 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/requires.txt +9 -0
- attackmap_analyzer_dotnet-0.1.0/src/attackmap_analyzer_dotnet.egg-info/top_level.txt +1 -0
- attackmap_analyzer_dotnet-0.1.0/tests/test_dotnet_analyzer.py +420 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attackmap_analyzer_dotnet
|
|
@@ -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)
|