attackmap-analyzer-dotnet 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- attackmap_analyzer_dotnet/__init__.py +3 -0
- attackmap_analyzer_dotnet/analyzer.py +524 -0
- attackmap_analyzer_dotnet/csharp.py +531 -0
- attackmap_analyzer_dotnet/routes.py +494 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/METADATA +102 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/RECORD +10 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_dotnet-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,524 @@
|
|
|
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
|
+
Routes (see `routes.py`): attribute routing with ASP.NET template semantics
|
|
21
|
+
(absolute `/` and `~/` templates, `[controller]`/`[action]`/`[area]` tokens,
|
|
22
|
+
class prefixes scoped to the class body), minimal APIs with nested `MapGroup`
|
|
23
|
+
prefixes, `MapHub`, `MapControllerRoute` and Razor Pages `@page`. Every path
|
|
24
|
+
starts with `/`.
|
|
25
|
+
|
|
26
|
+
Per-route auth: each route's effective state (`[Authorize]`/`[AllowAnonymous]`,
|
|
27
|
+
`.RequireAuthorization()`/`.AllowAnonymous()`, fallback policies) is computed.
|
|
28
|
+
Core has no auth field on `Route` yet (mlaify/AttackMap#256); it attributes
|
|
29
|
+
`auth_hints` to routes by file and line, so a route that requires auth gets an
|
|
30
|
+
`aspnet_authorize:<METHOD> <path>` auth hint on its own line, and an
|
|
31
|
+
explicitly anonymous route gets an `aspnet_allow_anonymous:<METHOD> <path>`
|
|
32
|
+
entrypoint hint and no auth hint.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from attackmap.sdk import (
|
|
41
|
+
DEFAULT_SKIP_DIRS,
|
|
42
|
+
AnalyzerMetadata,
|
|
43
|
+
AuthHint,
|
|
44
|
+
DatabaseHint,
|
|
45
|
+
EntrypointHint,
|
|
46
|
+
ExternalCall,
|
|
47
|
+
FrameworkHint,
|
|
48
|
+
iter_repo_files,
|
|
49
|
+
line_of,
|
|
50
|
+
ProtocolHint,
|
|
51
|
+
read_source,
|
|
52
|
+
rel,
|
|
53
|
+
Route,
|
|
54
|
+
ScanResult,
|
|
55
|
+
SecretHint,
|
|
56
|
+
ServiceHint,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
from .routes import ANONYMOUS, REQUIRED, UNKNOWN, RouteSpec, extract_cs_routes, extract_razor_page
|
|
60
|
+
|
|
61
|
+
CODE_SUFFIXES = {".cs"}
|
|
62
|
+
RAZOR_SUFFIXES = {".cshtml"}
|
|
63
|
+
# appsettings.json, appsettings.Development.json, appsettings.Production.json, ...
|
|
64
|
+
CONFIG_FILE_RE = re.compile(r"^appsettings(?:\.[\w-]+)?\.json$", re.IGNORECASE)
|
|
65
|
+
PROJECT_FILES = {".csproj", ".fsproj", ".sln"}
|
|
66
|
+
# .NET-specific directories on top of the SDK defaults. Matched against
|
|
67
|
+
# directory names inside the repo only (mlaify/AttackMap#253).
|
|
68
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS | {"bin", "obj", ".vs", ".idea", "packages", "TestResults", "publish"}
|
|
69
|
+
_SNIPPET_MAX_CHARS = 160
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------- Patterns ----------
|
|
73
|
+
|
|
74
|
+
# External HTTP calls. The receiver-name regex is intentionally permissive (any
|
|
75
|
+
# identifier can be a HttpClient field, often `_httpClient`); we anchor on the
|
|
76
|
+
# call shape (.GetAsync/.PostAsync/...) and require an http(s) URL literal.
|
|
77
|
+
OUTBOUND_PATTERNS: list[re.Pattern[str]] = [
|
|
78
|
+
re.compile(r'\.(?:Get|Post|Put|Delete|Patch|Send)(?:Async|Json|String)?(?:Async)?\s*\(\s*"(https?://[^"]+)"', re.IGNORECASE),
|
|
79
|
+
re.compile(r'\bnew\s+HttpRequestMessage\s*\(\s*HttpMethod\.\w+\s*,\s*"(https?://[^"]+)"'),
|
|
80
|
+
re.compile(r'\bnew\s+RestClient\s*\(\s*"(https?://[^"]+)"'),
|
|
81
|
+
re.compile(r'\bnew\s+Uri\s*\(\s*"(https?://[^"]+)"'),
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
# Database libs
|
|
85
|
+
DB_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
86
|
+
(re.compile(r'\.UseSqlServer\s*\('), "sqlserver"),
|
|
87
|
+
(re.compile(r'\.UseNpgsql\s*\(|\bNpgsql\b'), "postgresql"),
|
|
88
|
+
(re.compile(r'\.UseMySql\s*\(|\bMySqlConnector\b|\bMySql\.Data\b'), "mysql"),
|
|
89
|
+
(re.compile(r'\.UseSqlite\s*\(|\bMicrosoft\.Data\.Sqlite\b'), "sqlite"),
|
|
90
|
+
(re.compile(r'\.UseInMemoryDatabase\s*\('), "in_memory"),
|
|
91
|
+
(re.compile(r'\bDbContext\b|\bDbSet<'), "sql"),
|
|
92
|
+
(re.compile(r'\busing\s+Dapper\b|\bIDbConnection\b'), "sql"),
|
|
93
|
+
(re.compile(r'\bSqlConnection\s*\('), "sqlserver"),
|
|
94
|
+
(re.compile(r'\bMongoClient\s*\(|\bIMongoDatabase\b|\bMongoDB\.Driver\b'), "mongodb"),
|
|
95
|
+
(re.compile(r'\bConnectionMultiplexer\.Connect\s*\(|\bStackExchange\.Redis\b'), "redis"),
|
|
96
|
+
(re.compile(r'\bAmazonS3Client\b|\bAWSSDK\.S3\b'), "object_storage"),
|
|
97
|
+
(re.compile(r'\bAmazonDynamoDBClient\b|\bAWSSDK\.DynamoDBv2\b'), "dynamodb"),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
# Auth-related signals
|
|
101
|
+
AUTH_PATTERNS: list[tuple[re.Pattern[str], str, float]] = [
|
|
102
|
+
(re.compile(r'\.AddJwtBearer\s*\(|\bMicrosoft\.AspNetCore\.Authentication\.JwtBearer\b'), "jwt", 0.85),
|
|
103
|
+
(re.compile(r'\.AddOpenIdConnect\s*\(|\bOpenIdConnect\b'), "oidc", 0.85),
|
|
104
|
+
(re.compile(r'\.AddOAuth\s*\(|\bOAuth\b', re.IGNORECASE), "oauth", 0.85),
|
|
105
|
+
(re.compile(r'\bUserManager<|\bSignInManager<|\bIdentityUser\b'), "aspnet_identity", 0.85),
|
|
106
|
+
(re.compile(r'\bPasswordHasher<|\bIPasswordHasher\b'), "password_hasher", 0.85),
|
|
107
|
+
(re.compile(r'\bBCrypt\.Net\b|\bBCryptPasswordHasher\b'), "bcrypt", 0.9),
|
|
108
|
+
(re.compile(r'\bArgon2\b'), "argon2", 0.9),
|
|
109
|
+
(re.compile(r'\.AddAuthorization\s*\('), "authorization_setup", 0.85),
|
|
110
|
+
(re.compile(r'\bDuende\.IdentityServer\b|\bIdentityServer4\b'), "identityserver", 0.85),
|
|
111
|
+
(re.compile(r'\bAuthorization\b'), "authorization_header", 0.6),
|
|
112
|
+
(re.compile(r'\bBearer\b'), "bearer_token", 0.6),
|
|
113
|
+
(re.compile(r'\bapi[_-]?key\b', re.IGNORECASE), "api_key", 0.6),
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
FRAMEWORK_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
117
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Builder\b|\bWebApplication\.CreateBuilder\b'), "aspnetcore"),
|
|
118
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Mvc\b|\[ApiController\]|\[Controller\]'), "aspnetcore-mvc"),
|
|
119
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Mvc\.RazorPages\b'), "aspnetcore-razor"),
|
|
120
|
+
(re.compile(r'\bMicrosoft\.EntityFrameworkCore\b'), "efcore"),
|
|
121
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.SignalR\b'), "signalr"),
|
|
122
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Authentication\b'), "aspnetcore-auth"),
|
|
123
|
+
(re.compile(r'\bMicrosoft\.AspNetCore\.Identity\b'), "aspnetcore-identity"),
|
|
124
|
+
(re.compile(r'\bGrpc\.Net\.Client\b|\bGrpc\.AspNetCore\b'), "grpc"),
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
ENTRYPOINT_PATTERNS: list[tuple[re.Pattern[str], str]] = [
|
|
128
|
+
(re.compile(r'\bWebApplication\.CreateBuilder\s*\(|\bWebApplicationBuilder\b'), "webapplication_builder"),
|
|
129
|
+
(re.compile(r'\bapp\.Run\s*\(\s*\)'), "webapp_run"),
|
|
130
|
+
(re.compile(r'\bHost\.CreateDefaultBuilder\s*\('), "generic_host"),
|
|
131
|
+
(re.compile(r'\bUseStartup<'), "use_startup"),
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
# Secrets — env vars + IConfiguration accesses with secret-shaped keys
|
|
135
|
+
SECRET_PATTERNS: list[re.Pattern[str]] = [
|
|
136
|
+
re.compile(
|
|
137
|
+
r'\bEnvironment\.GetEnvironmentVariable\s*\(\s*"([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|PASS|PWD)[A-Z0-9_]*)"',
|
|
138
|
+
),
|
|
139
|
+
# IConfiguration indexer access — receiver name varies (cfg, _cfg, configuration,
|
|
140
|
+
# builder.Configuration, etc.); we anchor on the secret-shaped key inside the brackets.
|
|
141
|
+
re.compile(
|
|
142
|
+
r'\b\w+(?:\.\w+)?\s*\[\s*"([A-Za-z0-9_:.]*(?:secret|token|key|password|pass|pwd)[A-Za-z0-9_:.]*)"',
|
|
143
|
+
re.IGNORECASE,
|
|
144
|
+
),
|
|
145
|
+
]
|
|
146
|
+
# GetConnectionString("Default") names a connection string; it isn't a secret
|
|
147
|
+
# (#2). Literal passwords in appsettings*.json ConnectionStrings are reported
|
|
148
|
+
# instead, see _extract_config_secrets.
|
|
149
|
+
_CONNECTION_STRINGS_RE = re.compile(r'"ConnectionStrings"\s*:\s*\{')
|
|
150
|
+
_CONNECTION_ENTRY_RE = re.compile(r'"([^"\\]+)"\s*:\s*"((?:[^"\\]|\\.)*)"')
|
|
151
|
+
_CONNECTION_PASSWORD_RE = re.compile(r"(?:^|;)\s*(?:password|pwd)\s*=\s*([^;]*)", re.IGNORECASE)
|
|
152
|
+
_PLACEHOLDER_RE = re.compile(r"^(?:\$?\{.*\}|<.*>|%.*%|#\{.*\}#?|__\w+__|\*+)$")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# Kept rather than attackmap.sdk.line_snippet: this takes a match offset and
|
|
156
|
+
# splits on "\n" only, so it stays consistent with line_of() on files that
|
|
157
|
+
# contain form feeds or other str.splitlines() separators, and it costs
|
|
158
|
+
# O(line) per match instead of O(file).
|
|
159
|
+
def _line_snippet(content: str, offset: int, *, max_chars: int = _SNIPPET_MAX_CHARS) -> str:
|
|
160
|
+
line_start = content.rfind("\n", 0, offset) + 1
|
|
161
|
+
line_end = content.find("\n", offset)
|
|
162
|
+
if line_end == -1:
|
|
163
|
+
line_end = len(content)
|
|
164
|
+
line = content[line_start:line_end].strip()
|
|
165
|
+
if len(line) > max_chars:
|
|
166
|
+
line = line[: max_chars - 1] + "…"
|
|
167
|
+
return line
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _connection_password(value: str) -> bool:
|
|
171
|
+
"""True when a connection string carries a literal, non-placeholder password."""
|
|
172
|
+
m = _CONNECTION_PASSWORD_RE.search(value)
|
|
173
|
+
if m is None:
|
|
174
|
+
return False
|
|
175
|
+
password = m.group(1).strip().strip("'\"")
|
|
176
|
+
return bool(password) and _PLACEHOLDER_RE.match(password) is None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _extract_csproj_meta(csproj_path: Path, root: Path | None = None) -> tuple[str | None, str | None]:
|
|
180
|
+
"""Return (root_namespace, assembly_name) from a .csproj file."""
|
|
181
|
+
text = read_source(csproj_path, root=root)
|
|
182
|
+
if text is None:
|
|
183
|
+
return None, None
|
|
184
|
+
rn = re.search(r"<RootNamespace>([^<]+)</RootNamespace>", text)
|
|
185
|
+
an = re.search(r"<AssemblyName>([^<]+)</AssemblyName>", text)
|
|
186
|
+
return (rn.group(1).strip() if rn else None, an.group(1).strip() if an else None)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class DotnetAnalyzer:
|
|
190
|
+
metadata = AnalyzerMetadata(
|
|
191
|
+
name="dotnet",
|
|
192
|
+
display_name="C# / .NET Analyzer",
|
|
193
|
+
version="0.2.0",
|
|
194
|
+
description="ASP.NET Core analyzer covering minimal APIs, attribute routing, EF Core, Identity, and JwtBearer.",
|
|
195
|
+
scope=".NET solutions and projects (.csproj, .sln). Detects ASP.NET Core minimal APIs and controllers.",
|
|
196
|
+
targets=["dotnet", "csharp", "aspnetcore"],
|
|
197
|
+
languages=["csharp"],
|
|
198
|
+
priority=20,
|
|
199
|
+
experimental=False,
|
|
200
|
+
enabled_by_default=True,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def name(self) -> str:
|
|
205
|
+
return self.metadata.name
|
|
206
|
+
|
|
207
|
+
# ---------- Public entry points ----------
|
|
208
|
+
|
|
209
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
210
|
+
root = Path(repo_path).resolve()
|
|
211
|
+
if not root.exists() or not root.is_dir():
|
|
212
|
+
return False
|
|
213
|
+
# Project / solution markers
|
|
214
|
+
markers = CODE_SUFFIXES | PROJECT_FILES
|
|
215
|
+
return next(iter_repo_files(root, suffixes=markers, skip_dirs=SKIP_DIRS), None) is not None
|
|
216
|
+
|
|
217
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
218
|
+
root = Path(repo_path).resolve()
|
|
219
|
+
result = ScanResult(root=str(root))
|
|
220
|
+
if not root.exists() or not root.is_dir():
|
|
221
|
+
return result
|
|
222
|
+
|
|
223
|
+
# Service-name hints from .csproj files
|
|
224
|
+
for csproj in iter_repo_files(root, suffixes={".csproj"}, skip_dirs=SKIP_DIRS):
|
|
225
|
+
root_ns, assembly = _extract_csproj_meta(csproj, root)
|
|
226
|
+
relative = rel(csproj, root)
|
|
227
|
+
if root_ns:
|
|
228
|
+
self._append_unique_service(result, f"namespace:{root_ns}", relative)
|
|
229
|
+
if assembly:
|
|
230
|
+
self._append_unique_service(result, f"assembly:{assembly}", relative)
|
|
231
|
+
|
|
232
|
+
pending: list[tuple[RouteSpec, str, str]] = [] # (route, file, content)
|
|
233
|
+
defaults = {"controllers": False, "razor": False, "fallback": False}
|
|
234
|
+
|
|
235
|
+
for file_path in iter_repo_files(
|
|
236
|
+
root, suffixes=CODE_SUFFIXES | RAZOR_SUFFIXES | {".json"}, skip_dirs=SKIP_DIRS
|
|
237
|
+
):
|
|
238
|
+
suffix = file_path.suffix.lower()
|
|
239
|
+
if suffix == ".json" and not CONFIG_FILE_RE.match(file_path.name):
|
|
240
|
+
continue
|
|
241
|
+
content = read_source(file_path, root=root)
|
|
242
|
+
if content is None:
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
result.files_scanned += 1
|
|
246
|
+
relative = rel(file_path, root)
|
|
247
|
+
if suffix == ".json":
|
|
248
|
+
self._extract_config_secrets(content, relative, result)
|
|
249
|
+
continue
|
|
250
|
+
if "csharp" not in result.languages:
|
|
251
|
+
result.languages.append("csharp")
|
|
252
|
+
if suffix in RAZOR_SUFFIXES:
|
|
253
|
+
spec = extract_razor_page(content, relative)
|
|
254
|
+
if spec is not None:
|
|
255
|
+
pending.append((spec, relative, content))
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
self._extract_routes(content, relative, result, pending, defaults)
|
|
259
|
+
self._extract_databases(content, relative, result)
|
|
260
|
+
self._extract_auth(content, relative, result)
|
|
261
|
+
self._extract_secrets(content, relative, result)
|
|
262
|
+
self._extract_external_calls(content, relative, result)
|
|
263
|
+
self._extract_frameworks(content, relative, result)
|
|
264
|
+
self._extract_entrypoints(content, relative, result)
|
|
265
|
+
self._infer_service_role(content, relative, result)
|
|
266
|
+
|
|
267
|
+
self._emit_routes(pending, defaults, result)
|
|
268
|
+
result.languages.sort()
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
# ---------- Extractors ----------
|
|
272
|
+
|
|
273
|
+
def _extract_routes(
|
|
274
|
+
self,
|
|
275
|
+
content: str,
|
|
276
|
+
relative: str,
|
|
277
|
+
result: ScanResult,
|
|
278
|
+
pending: list[tuple[RouteSpec, str, str]],
|
|
279
|
+
defaults: dict[str, bool],
|
|
280
|
+
) -> None:
|
|
281
|
+
found = extract_cs_routes(content)
|
|
282
|
+
pending.extend((spec, relative, content) for spec in found.routes)
|
|
283
|
+
defaults["controllers"] |= found.controllers_require_auth
|
|
284
|
+
defaults["razor"] |= found.razor_pages_require_auth
|
|
285
|
+
defaults["fallback"] |= found.fallback_policy_requires_auth
|
|
286
|
+
# An [Authorize] that guards no extracted route (a class without
|
|
287
|
+
# actions, a Razor PageModel, a hub) stays a file-level hint.
|
|
288
|
+
for offset in found.unattached_authorize:
|
|
289
|
+
self._append_unique_auth(
|
|
290
|
+
result, "authorize_attribute", relative,
|
|
291
|
+
line_of(content, offset), _line_snippet(content, offset), 0.85,
|
|
292
|
+
)
|
|
293
|
+
for hint, offset in found.protocol_hints:
|
|
294
|
+
self._append_unique_protocol(
|
|
295
|
+
result, hint, relative, line_of(content, offset), _line_snippet(content, offset),
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
def _emit_routes(
|
|
299
|
+
self,
|
|
300
|
+
pending: list[tuple[RouteSpec, str, str]],
|
|
301
|
+
defaults: dict[str, bool],
|
|
302
|
+
result: ScanResult,
|
|
303
|
+
) -> None:
|
|
304
|
+
"""Append routes plus their per-route auth signals.
|
|
305
|
+
|
|
306
|
+
Runs after every file is read, because a fallback policy or
|
|
307
|
+
`MapControllers().RequireAuthorization()` in Program.cs changes the
|
|
308
|
+
default for routes declared in other files.
|
|
309
|
+
"""
|
|
310
|
+
for spec, relative, content in pending:
|
|
311
|
+
if spec.auth == UNKNOWN:
|
|
312
|
+
if defaults["fallback"]:
|
|
313
|
+
spec.auth, spec.auth_evidence = REQUIRED, "authorization fallback policy"
|
|
314
|
+
elif defaults["controllers"] and spec.kind in ("attribute", "conventional"):
|
|
315
|
+
spec.auth, spec.auth_evidence = REQUIRED, "MapControllers().RequireAuthorization()"
|
|
316
|
+
elif defaults["razor"] and spec.kind == "razor":
|
|
317
|
+
spec.auth, spec.auth_evidence = REQUIRED, "MapRazorPages().RequireAuthorization()"
|
|
318
|
+
line = line_of(content, spec.offset)
|
|
319
|
+
# Route.auth / guards / guard_evidence (AttackMap#256): core trusts
|
|
320
|
+
# this over its own resolution. An older core ignores the fields;
|
|
321
|
+
# the hints below stay for one release for those cores.
|
|
322
|
+
guards = [spec.auth_evidence or "[Authorize]"] if spec.auth == REQUIRED else []
|
|
323
|
+
if not self._append_unique_route(
|
|
324
|
+
result, spec.path, spec.method, relative, line,
|
|
325
|
+
auth=spec.auth, guards=guards, guard_evidence=spec.auth_evidence,
|
|
326
|
+
):
|
|
327
|
+
continue
|
|
328
|
+
label = f"{spec.method} {spec.path}"
|
|
329
|
+
evidence = f"{label}: {spec.auth_evidence}" if spec.auth_evidence else _line_snippet(content, spec.offset)
|
|
330
|
+
if spec.auth == REQUIRED:
|
|
331
|
+
self._append_unique_auth(result, f"aspnet_authorize:{label}", relative, line, evidence, 0.9)
|
|
332
|
+
elif spec.auth == ANONYMOUS:
|
|
333
|
+
self._append_unique_entrypoint(result, f"aspnet_allow_anonymous:{label}", relative, line, evidence)
|
|
334
|
+
|
|
335
|
+
def _extract_config_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
336
|
+
"""Literal passwords in an appsettings*.json `ConnectionStrings` section."""
|
|
337
|
+
section = _CONNECTION_STRINGS_RE.search(content)
|
|
338
|
+
if section is None:
|
|
339
|
+
return
|
|
340
|
+
end = content.find("}", section.end())
|
|
341
|
+
body_end = len(content) if end == -1 else end
|
|
342
|
+
for entry in _CONNECTION_ENTRY_RE.finditer(content, section.end(), body_end):
|
|
343
|
+
if not _connection_password(entry.group(2)):
|
|
344
|
+
continue
|
|
345
|
+
# The evidence names the connection string, never the value.
|
|
346
|
+
self._append_unique_secret(
|
|
347
|
+
result, "connection_string_password", relative,
|
|
348
|
+
line_of(content, entry.start()),
|
|
349
|
+
f"ConnectionStrings:{entry.group(1)} contains a literal Password=",
|
|
350
|
+
kind="config_literal",
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def _extract_databases(self, content: str, relative: str, result: ScanResult) -> None:
|
|
354
|
+
for pattern, kind in DB_PATTERNS:
|
|
355
|
+
match = pattern.search(content)
|
|
356
|
+
if match is None:
|
|
357
|
+
continue
|
|
358
|
+
self._append_unique_database(
|
|
359
|
+
result, kind, relative,
|
|
360
|
+
line_of(content, match.start()),
|
|
361
|
+
_line_snippet(content, match.start()),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def _extract_auth(self, content: str, relative: str, result: ScanResult) -> None:
|
|
365
|
+
for pattern, hint, confidence in AUTH_PATTERNS:
|
|
366
|
+
match = pattern.search(content)
|
|
367
|
+
if match is None:
|
|
368
|
+
continue
|
|
369
|
+
self._append_unique_auth(
|
|
370
|
+
result, hint, relative,
|
|
371
|
+
line_of(content, match.start()),
|
|
372
|
+
_line_snippet(content, match.start()),
|
|
373
|
+
confidence,
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
def _extract_secrets(self, content: str, relative: str, result: ScanResult) -> None:
|
|
377
|
+
for pattern in SECRET_PATTERNS:
|
|
378
|
+
for match in pattern.finditer(content):
|
|
379
|
+
groups = match.groups()
|
|
380
|
+
name = groups[0] if groups and groups[0] else "unknown"
|
|
381
|
+
self._append_unique_secret(
|
|
382
|
+
result, name, relative,
|
|
383
|
+
line_of(content, match.start()),
|
|
384
|
+
_line_snippet(content, match.start()),
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def _extract_external_calls(self, content: str, relative: str, result: ScanResult) -> None:
|
|
388
|
+
for pattern in OUTBOUND_PATTERNS:
|
|
389
|
+
for match in pattern.finditer(content):
|
|
390
|
+
target = match.group(1)
|
|
391
|
+
if not (target.startswith("http://") or target.startswith("https://")):
|
|
392
|
+
continue
|
|
393
|
+
self._append_unique_external(
|
|
394
|
+
result, target, relative,
|
|
395
|
+
line_of(content, match.start()),
|
|
396
|
+
_line_snippet(content, match.start()),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def _extract_frameworks(self, content: str, relative: str, result: ScanResult) -> None:
|
|
400
|
+
for pattern, name in FRAMEWORK_PATTERNS:
|
|
401
|
+
match = pattern.search(content)
|
|
402
|
+
if match is None:
|
|
403
|
+
continue
|
|
404
|
+
self._append_unique_framework(
|
|
405
|
+
result, name, relative,
|
|
406
|
+
line_of(content, match.start()),
|
|
407
|
+
_line_snippet(content, match.start()),
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
def _extract_entrypoints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
411
|
+
for pattern, hint in ENTRYPOINT_PATTERNS:
|
|
412
|
+
match = pattern.search(content)
|
|
413
|
+
if match is None:
|
|
414
|
+
continue
|
|
415
|
+
self._append_unique_entrypoint(
|
|
416
|
+
result, hint, relative,
|
|
417
|
+
line_of(content, match.start()),
|
|
418
|
+
_line_snippet(content, match.start()),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
def _infer_service_role(self, content: str, relative: str, result: ScanResult) -> None:
|
|
422
|
+
haystack = (relative + " " + content[:500]).lower()
|
|
423
|
+
role: str | None = None
|
|
424
|
+
if any(token in haystack for token in ("worker", "consumer", "queue", "background", "hostedservice")):
|
|
425
|
+
role = "worker"
|
|
426
|
+
elif any(token in haystack for token in ("controller", "api", "endpoint", "router")):
|
|
427
|
+
role = "api"
|
|
428
|
+
elif any(token in haystack for token in ("client", "sdk")):
|
|
429
|
+
role = "client"
|
|
430
|
+
if role:
|
|
431
|
+
self._append_unique_service(result, f"service_role:{role}", relative)
|
|
432
|
+
|
|
433
|
+
# ---------- Append helpers ----------
|
|
434
|
+
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _append_unique_route(
|
|
437
|
+
result: ScanResult,
|
|
438
|
+
path: str,
|
|
439
|
+
method: str,
|
|
440
|
+
file: str,
|
|
441
|
+
line: int | None,
|
|
442
|
+
*,
|
|
443
|
+
auth: str = UNKNOWN,
|
|
444
|
+
guards: list[str] | None = None,
|
|
445
|
+
guard_evidence: str | None = None,
|
|
446
|
+
) -> bool:
|
|
447
|
+
key = (path, method, file)
|
|
448
|
+
if any((item.path, item.method, item.file) == key for item in result.routes):
|
|
449
|
+
return False
|
|
450
|
+
result.routes.append(
|
|
451
|
+
Route(
|
|
452
|
+
path=path, method=method, file=file, line=line,
|
|
453
|
+
auth=auth, guards=list(guards or []), guard_evidence=guard_evidence,
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def _append_unique_database(result: ScanResult, kind: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
460
|
+
key = (kind, file)
|
|
461
|
+
if any((item.kind, item.file) == key for item in result.databases):
|
|
462
|
+
return
|
|
463
|
+
result.databases.append(DatabaseHint(kind=kind, file=file, line=line, evidence_text=evidence))
|
|
464
|
+
|
|
465
|
+
@staticmethod
|
|
466
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None, confidence: float) -> None:
|
|
467
|
+
key = (hint, file)
|
|
468
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
469
|
+
return
|
|
470
|
+
result.auth_hints.append(AuthHint(hint=hint, file=file, line=line, evidence_text=evidence, confidence=confidence))
|
|
471
|
+
|
|
472
|
+
@staticmethod
|
|
473
|
+
def _append_unique_secret(
|
|
474
|
+
result: ScanResult, name: str, file: str, line: int | None, evidence: str | None, kind: str = "env_reference"
|
|
475
|
+
) -> None:
|
|
476
|
+
# Env references dedup per file; config literals per line, so two
|
|
477
|
+
# connection strings with passwords in one file both show up.
|
|
478
|
+
if kind == "env_reference":
|
|
479
|
+
duplicate = any((item.name, item.file) == (name, file) for item in result.secret_hints)
|
|
480
|
+
else:
|
|
481
|
+
duplicate = any((item.name, item.file, item.line) == (name, file, line) for item in result.secret_hints)
|
|
482
|
+
if duplicate:
|
|
483
|
+
return
|
|
484
|
+
result.secret_hints.append(
|
|
485
|
+
SecretHint(name=name, file=file, line=line, evidence_text=evidence, confidence=0.85, kind=kind)
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
@staticmethod
|
|
489
|
+
def _append_unique_protocol(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
490
|
+
key = (hint, file)
|
|
491
|
+
if any((item.hint, item.file) == key for item in result.protocol_hints):
|
|
492
|
+
return
|
|
493
|
+
result.protocol_hints.append(ProtocolHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
494
|
+
|
|
495
|
+
@staticmethod
|
|
496
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
497
|
+
key = (target, file)
|
|
498
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
499
|
+
return
|
|
500
|
+
result.external_calls.append(ExternalCall(target=target, file=file, line=line, evidence_text=evidence))
|
|
501
|
+
|
|
502
|
+
@staticmethod
|
|
503
|
+
def _append_unique_framework(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
504
|
+
key = (hint, file)
|
|
505
|
+
if any((item.hint, item.file) == key for item in result.framework_hints):
|
|
506
|
+
return
|
|
507
|
+
result.framework_hints.append(FrameworkHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
508
|
+
|
|
509
|
+
@staticmethod
|
|
510
|
+
def _append_unique_entrypoint(result: ScanResult, hint: str, file: str, line: int | None, evidence: str | None) -> None:
|
|
511
|
+
key = (hint, file)
|
|
512
|
+
if any((item.hint, item.file) == key for item in result.entrypoint_hints):
|
|
513
|
+
return
|
|
514
|
+
result.entrypoint_hints.append(EntrypointHint(hint=hint, file=file, line=line, evidence_text=evidence))
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _append_unique_service(result: ScanResult, hint: str, file: str) -> None:
|
|
518
|
+
key = (hint, file)
|
|
519
|
+
if any((item.hint, item.file) == key for item in result.service_hints):
|
|
520
|
+
return
|
|
521
|
+
result.service_hints.append(ServiceHint(hint=hint, file=file))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
__all__ = ["DotnetAnalyzer"]
|