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.
@@ -0,0 +1,3 @@
1
+ from .analyzer import DotnetAnalyzer
2
+
3
+ __all__ = ["DotnetAnalyzer"]
@@ -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"]