apisec-code-bolt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,545 @@
1
+ """
2
+ .NET JWT configuration extractor for ASP.NET Core framework plugin.
3
+
4
+ Mirrors the structure of JavaJwtConfigExtractor so the two can be maintained
5
+ in parallel. Detection strategy:
6
+
7
+ 1. Library — identified from using/import prefixes.
8
+ 2. Algorithms — extracted from SecurityAlgorithms.HmacSha256 references,
9
+ TokenValidationParameters.ValidAlgorithms assignments, and
10
+ string literals matching known JWA names.
11
+ 3. Validation flags — inferred from TokenValidationParameters property
12
+ assignments (ValidateIssuerSigningKey, ValidateLifetime,
13
+ ValidateIssuer, ValidateAudience).
14
+ 4. Secret source — "env" from Environment.GetEnvironmentVariable(),
15
+ "config" from IConfiguration["jwt:secret"] / appsettings keys,
16
+ "hardcoded" from literal strings passed to signing helpers or
17
+ stored in string fields whose names contain a secret keyword.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from pathlib import Path
24
+
25
+ from ...parsing.base import ParsedFile
26
+ from .._jwt_common import add_algorithm as _add_algorithm
27
+ from .._jwt_common import normalize_algorithm as _normalize_alg
28
+ from ..base import ExtractedJwtConfig
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Library detection
32
+ # ---------------------------------------------------------------------------
33
+
34
+ _JWT_IMPORT_PREFIXES: dict[str, str] = {
35
+ "Microsoft.IdentityModel.Tokens": "microsoft-identity-model",
36
+ "System.IdentityModel.Tokens.Jwt": "system-identitymodel-jwt",
37
+ "Microsoft.AspNetCore.Authentication.JwtBearer": "aspnetcore-jwt-bearer",
38
+ "Microsoft.AspNetCore.Authorization": "aspnetcore-authorization",
39
+ "JWT": "jwt-net", # JWT.net (joakimskoog/jwt)
40
+ "Jose": "jose-jwt", # jose-jwt
41
+ "System.Security.Cryptography": "dotnet-crypto",
42
+ }
43
+
44
+ _JWT_CLASS_NAMES: frozenset[str] = frozenset(
45
+ {
46
+ "JwtSecurityToken",
47
+ "JwtSecurityTokenHandler",
48
+ "SecurityToken",
49
+ "TokenValidationParameters",
50
+ "JwtBearerOptions",
51
+ "JwtRegisteredClaimNames",
52
+ "SigningCredentials",
53
+ "SymmetricSecurityKey",
54
+ }
55
+ )
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Algorithm aliases (canonical JWA set lives in frameworks/_jwt_common.py)
59
+ # ---------------------------------------------------------------------------
60
+
61
+ # SecurityAlgorithms constants → JWA name
62
+ _ALGORITHM_ALIASES: dict[str, str] = {
63
+ # HMAC-SHA variants
64
+ "HMACSHA256": "HS256",
65
+ "HMACSHA384": "HS384",
66
+ "HMACSHA512": "HS512",
67
+ # SecurityAlgorithms static field names
68
+ "HMACSHA256SIGNATURE": "HS256",
69
+ "HMACSHA384SIGNATURE": "HS384",
70
+ "HMACSHA512SIGNATURE": "HS512",
71
+ "RSASHA256": "RS256",
72
+ "RSASHA384": "RS384",
73
+ "RSASHA512": "RS512",
74
+ "RSASSHA256": "RS256",
75
+ "ECDSASHA256": "ES256",
76
+ "ECDSASHA384": "ES384",
77
+ "ECDSASHA512": "ES512",
78
+ # String forms sometimes used directly
79
+ "HTTP://WWW.W3.ORG/2001/04/XMLDSIG-MORE#HMAC-SHA256": "HS256",
80
+ }
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Call-site keyword sets
84
+ # ---------------------------------------------------------------------------
85
+
86
+ _PARSE_KEYWORDS: frozenset[str] = frozenset(
87
+ {
88
+ "validatetoken",
89
+ "readtoken",
90
+ "readjwttoken",
91
+ "canreadtoken",
92
+ "validatejwt",
93
+ "decode",
94
+ }
95
+ )
96
+
97
+ _SIGN_KEYWORDS: frozenset[str] = frozenset(
98
+ {
99
+ "signingcredentials",
100
+ "addsigningkey",
101
+ "withalgorithm",
102
+ "setsigningkey",
103
+ "signingkey",
104
+ }
105
+ )
106
+
107
+ _EXPIRY_KEYWORDS: frozenset[str] = frozenset(
108
+ {
109
+ "validatelifetime",
110
+ "clockskew",
111
+ "setexpiry",
112
+ "notbefore",
113
+ "expires",
114
+ "expiresat",
115
+ }
116
+ )
117
+
118
+ _ISSUER_KEYWORDS: frozenset[str] = frozenset(
119
+ {
120
+ "validateissuer",
121
+ "validissuer",
122
+ "validissuers",
123
+ "issuer",
124
+ "setissuer",
125
+ }
126
+ )
127
+
128
+ _AUDIENCE_KEYWORDS: frozenset[str] = frozenset(
129
+ {
130
+ "validateaudience",
131
+ "validaudience",
132
+ "validaudiences",
133
+ "audience",
134
+ "setaudience",
135
+ }
136
+ )
137
+
138
+ # ---------------------------------------------------------------------------
139
+ # Secret detection
140
+ # ---------------------------------------------------------------------------
141
+
142
+ _SECRET_KEYWORDS: frozenset[str] = frozenset(
143
+ {
144
+ "jwt",
145
+ "secret",
146
+ "signing",
147
+ "signkey",
148
+ "token",
149
+ "key",
150
+ "password",
151
+ "credential",
152
+ }
153
+ )
154
+
155
+ _VALUE_RE = re.compile(r'["\']((?:jwt|secret|signing|key|token)[^"\']{0,40})["\']', re.IGNORECASE)
156
+ _CONFIG_RE = re.compile(
157
+ r'(?:configuration|config|_config|IConfiguration)\s*(?:\[|\.GetValue[^(]*\()\s*["\']([^"\']+)["\']',
158
+ re.IGNORECASE,
159
+ )
160
+ _GETENV_RE = re.compile(r'GetEnvironmentVariable\s*\(\s*["\']([^"\']+)["\']\s*\)')
161
+
162
+
163
+ def _normalize_algorithm(raw: str) -> str | None:
164
+ """Return JWA algorithm name or None if unrecognised."""
165
+ return _normalize_alg(raw, _ALGORITHM_ALIASES)
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Main extractor
170
+ # ---------------------------------------------------------------------------
171
+
172
+
173
+ class DotNetJwtConfigExtractor:
174
+ """
175
+ Extracts JWT configuration from a single parsed C# file.
176
+
177
+ ASP.NET Core plugin instantiates this once and calls ``extract(parsed_file)``.
178
+ """
179
+
180
+ EXTRA_IMPORT_PREFIXES: dict[str, str] = {}
181
+
182
+ def extract(self, parsed_file: ParsedFile) -> ExtractedJwtConfig | None: # noqa: C901
183
+ jwt_config = ExtractedJwtConfig(library="", detected=False)
184
+
185
+ # ── 1. Library detection ──────────────────────────────────────────
186
+ all_prefixes = {**_JWT_IMPORT_PREFIXES, **self.EXTRA_IMPORT_PREFIXES}
187
+
188
+ for imp in parsed_file.imports:
189
+ module = imp.module or ""
190
+ for prefix, lib_name in all_prefixes.items():
191
+ if module.startswith(prefix):
192
+ jwt_config.detected = True
193
+ jwt_config.library = jwt_config.library or lib_name
194
+ break
195
+
196
+ for name in imp.names:
197
+ if name in _JWT_CLASS_NAMES and not jwt_config.detected:
198
+ jwt_config.detected = True
199
+ jwt_config.library = jwt_config.library or "aspnetcore-jwt-bearer"
200
+
201
+ if not jwt_config.detected:
202
+ return None
203
+
204
+ # ASP.NET Core JWT bearer validates signature + expiry by default
205
+ if "aspnetcore" in jwt_config.library or "microsoft-identity" in jwt_config.library:
206
+ jwt_config.validates_signature = True
207
+ jwt_config.validates_expiry = True
208
+
209
+ # ── 2. Call-site analysis ─────────────────────────────────────────
210
+ for call in parsed_file.call_sites:
211
+ callee = call.callee_name.lower()
212
+
213
+ # Parse / validate → JWT is consumed here
214
+ if any(kw in callee for kw in _PARSE_KEYWORDS):
215
+ jwt_config.locations.append(call.location)
216
+ jwt_config.validates_signature = True
217
+
218
+ # Algorithm detection via method name
219
+ if any(kw in callee for kw in _SIGN_KEYWORDS):
220
+ # Collect algo from callee suffix (e.g. "SecurityAlgorithms.HmacSha256")
221
+ algo = _normalize_algorithm(call.callee_name.split(".")[-1])
222
+ if algo:
223
+ _add_algorithm(algo, jwt_config)
224
+
225
+ # SecurityAlgorithms.HmacSha256 used as a standalone reference
226
+ algo_from_callee = _normalize_algorithm(call.callee_name.split(".")[-1])
227
+ if algo_from_callee:
228
+ _add_algorithm(algo_from_callee, jwt_config)
229
+
230
+ # Validation flags
231
+ if any(kw in callee for kw in _EXPIRY_KEYWORDS):
232
+ jwt_config.validates_expiry = True
233
+ if any(kw in callee for kw in _ISSUER_KEYWORDS):
234
+ jwt_config.validates_issuer = True
235
+ if any(kw in callee for kw in _AUDIENCE_KEYWORDS):
236
+ jwt_config.validates_audience = True
237
+
238
+ # Environment variable secret
239
+ if "getenvironmentvariable" in callee and jwt_config.secret_source is None:
240
+ pass # handled in field scan below via regex
241
+
242
+ # ── 3. Field / property annotation scan ──────────────────────────
243
+ if jwt_config.secret_source is None:
244
+ self._scan_fields(parsed_file, jwt_config)
245
+
246
+ # ── 4. Static / const string fields with literal values ───────────
247
+ if jwt_config.secret_source is None:
248
+ self._scan_static_fields(parsed_file, jwt_config)
249
+
250
+ # ── 5. TokenValidationParameters property assignments in field text ─
251
+ self._scan_validation_assignments(parsed_file, jwt_config)
252
+
253
+ # ── 6. SecurityAlgorithms member references (field/property, not calls) ─
254
+ self._scan_security_algorithms_refs(parsed_file, jwt_config)
255
+
256
+ # ── 7. AddJwtBearer(config => { ... }) lambda bodies ────────────────
257
+ # In minimal-hosting Program.cs the JWT config sits at module scope,
258
+ # outside any class. The C# parser captures the AddJwtBearer call
259
+ # site but doesn't expose the lambda body — read it from disk and
260
+ # apply the same regex patterns the in-method scan uses.
261
+ self._scan_addjwtbearer_lambda_bodies(parsed_file, jwt_config)
262
+
263
+ return jwt_config
264
+
265
+ # ------------------------------------------------------------------
266
+ # Helpers
267
+ # ------------------------------------------------------------------
268
+
269
+ def _scan_fields(self, parsed_file: ParsedFile, jwt_config: ExtractedJwtConfig) -> None:
270
+ """
271
+ Scan field default values for:
272
+ - Environment.GetEnvironmentVariable("JWT_SECRET")
273
+ - configuration["jwt:secret"] / _config.GetValue<string>("...")
274
+ """
275
+ for cls in parsed_file.classes:
276
+ for f in cls.fields:
277
+ dv = getattr(f, "default_value", None) or ""
278
+
279
+ # Environment variable
280
+ m = _GETENV_RE.search(dv)
281
+ if m:
282
+ val = m.group(1)
283
+ if any(kw in val.lower() for kw in _SECRET_KEYWORDS):
284
+ jwt_config.secret_source = "env"
285
+ jwt_config.secret_name = val
286
+ return
287
+
288
+ # Configuration key
289
+ m2 = _CONFIG_RE.search(dv)
290
+ if m2:
291
+ key = m2.group(1)
292
+ if any(kw in key.lower() for kw in _SECRET_KEYWORDS):
293
+ jwt_config.secret_source = "config"
294
+ jwt_config.secret_name = key
295
+ return
296
+
297
+ # Also check [FromConfiguration] / [SecretName] decorators on fields
298
+ for f in cls.fields:
299
+ for dec in getattr(f, "decorators", None) or []:
300
+ if dec.name in ("ConfigurationValue", "SecretName", "Value"):
301
+ raw = (
302
+ dec.positional_args[0]
303
+ if dec.positional_args
304
+ else (dec.arguments.get("value") or dec.arguments.get("Value"))
305
+ )
306
+ if raw and isinstance(raw, str):
307
+ if any(kw in raw.lower() for kw in _SECRET_KEYWORDS):
308
+ jwt_config.secret_source = "config"
309
+ jwt_config.secret_name = raw
310
+ return
311
+
312
+ def _scan_static_fields(self, parsed_file: ParsedFile, jwt_config: ExtractedJwtConfig) -> None:
313
+ """Detect private const/static string fields with literal secret values.
314
+
315
+ The C# parser strips quote characters from string literals (via
316
+ _string_literal_value), so default_value contains the bare content
317
+ without surrounding quotes. We accept both quoted (Java-style raw
318
+ default_value) and unquoted (C# parser output) forms.
319
+
320
+ A value is treated as a literal — not an expression — when it contains
321
+ no parentheses (which would indicate a function call) and no ``${``
322
+ (Spring-style placeholder).
323
+ """
324
+ _quoted_re = re.compile(r'^["\'](.{4,})["\']$')
325
+ for cls in parsed_file.classes:
326
+ for f in cls.fields:
327
+ if not any(kw in (f.name or "").lower() for kw in _SECRET_KEYWORDS):
328
+ continue
329
+ dv = (getattr(f, "default_value", None) or "").strip()
330
+ if not dv or len(dv) < 4:
331
+ continue
332
+ # Quoted form (Java parser keeps surrounding quotes)
333
+ if _quoted_re.match(dv):
334
+ jwt_config.secret_source = "hardcoded"
335
+ jwt_config.secret_name = f.name
336
+ return
337
+ # Unquoted form (C# parser already stripped quotes) — exclude
338
+ # anything that looks like an expression (function call, property
339
+ # access placeholder, etc.)
340
+ if "(" not in dv and "${" not in dv:
341
+ jwt_config.secret_source = "hardcoded"
342
+ jwt_config.secret_name = f.name
343
+ return
344
+
345
+ # SecurityAlgorithms.HmacSha256 / SecurityAlgorithms.RsaSha256 are field
346
+ # references, not method calls — they don't appear in call_sites.
347
+ _SECURITY_ALGO_RE = re.compile(
348
+ r"SecurityAlgorithms\."
349
+ r"(Hmac(?:Sha256|Sha384|Sha512)"
350
+ r"|Rsa(?:Sha256|Sha384|Sha512)"
351
+ r"|EcDsa(?:Sha256|Sha384|Sha512)"
352
+ r"|(?:HS|RS|ES|PS)(?:256|384|512))",
353
+ re.IGNORECASE,
354
+ )
355
+
356
+ # Human-readable SecurityAlgorithms field name → JWA
357
+ _SEC_ALGO_MAP: dict[str, str] = {
358
+ "hmacsha256": "HS256",
359
+ "hmacsha384": "HS384",
360
+ "hmacsha512": "HS512",
361
+ "rsasha256": "RS256",
362
+ "rsasha384": "RS384",
363
+ "rsasha512": "RS512",
364
+ "ecdsasha256": "ES256",
365
+ "ecdsasha384": "ES384",
366
+ "ecdsasha512": "ES512",
367
+ }
368
+
369
+ def _scan_security_algorithms_refs(
370
+ self, parsed_file: ParsedFile, jwt_config: ExtractedJwtConfig
371
+ ) -> None:
372
+ """
373
+ Scan field initializers and method call names for SecurityAlgorithms.*
374
+ references that indicate which signing algorithm is in use.
375
+
376
+ ``SecurityAlgorithms.HmacSha256`` is a constant string field, not a
377
+ method — it won't appear in ``call_sites``. We match it with a regex
378
+ over field default_values and the call site callee names.
379
+ """
380
+ # Check call_sites: new SigningCredentials(key, SecurityAlgorithms.HmacSha256)
381
+ # appears as callee "SecurityAlgorithms.HmacSha256" when captured via
382
+ # object_creation_expression arguments (future enhancement) or via the
383
+ # callee of an invocation. Also check for the pattern in callee names.
384
+ for call in parsed_file.call_sites:
385
+ m = self._SECURITY_ALGO_RE.search(call.callee_name)
386
+ if m:
387
+ raw = m.group(1).lower().replace("_", "")
388
+ algo = self._SEC_ALGO_MAP.get(raw) or _normalize_algorithm(raw)
389
+ if algo:
390
+ _add_algorithm(algo, jwt_config)
391
+
392
+ # Check field default values (e.g. const string algo = SecurityAlgorithms.HmacSha256)
393
+ for cls in parsed_file.classes:
394
+ for f in cls.fields:
395
+ dv = getattr(f, "default_value", None) or ""
396
+ m = self._SECURITY_ALGO_RE.search(dv)
397
+ if m:
398
+ raw = m.group(1).lower().replace("_", "")
399
+ algo = self._SEC_ALGO_MAP.get(raw) or _normalize_algorithm(raw)
400
+ if algo:
401
+ _add_algorithm(algo, jwt_config)
402
+
403
+ def _scan_validation_assignments(
404
+ self, parsed_file: ParsedFile, jwt_config: ExtractedJwtConfig
405
+ ) -> None:
406
+ """
407
+ Look for TokenValidationParameters property assignments in method bodies.
408
+
409
+ Example patterns we detect:
410
+ ValidateIssuerSigningKey = true
411
+ ValidateLifetime = false
412
+ ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 }
413
+ """
414
+ _bool_assign_re = re.compile(
415
+ r"(Validate(?:IssuerSigningKey|Lifetime|Issuer|Audience|Actor))\s*=\s*(true|false)",
416
+ re.IGNORECASE,
417
+ )
418
+ _algo_assign_re = re.compile(
419
+ r"ValidAlgorithms\s*=\s*new[^{]*\{([^}]+)\}",
420
+ re.IGNORECASE,
421
+ )
422
+
423
+ for cls in parsed_file.classes:
424
+ for method in cls.methods:
425
+ body = getattr(method, "body_source", None) or ""
426
+ if not body:
427
+ continue
428
+
429
+ for match in _bool_assign_re.finditer(body):
430
+ prop = match.group(1).lower()
431
+ val = match.group(2).lower() == "true"
432
+ if "issuerSigningKey" in prop or "issuersigningkey" in prop.lower():
433
+ if val:
434
+ jwt_config.validates_signature = True
435
+ elif "lifetime" in prop:
436
+ if val:
437
+ jwt_config.validates_expiry = True
438
+ else:
439
+ jwt_config.validates_expiry = False
440
+ elif "issuer" in prop:
441
+ if val:
442
+ jwt_config.validates_issuer = True
443
+ elif "audience" in prop:
444
+ if val:
445
+ jwt_config.validates_audience = True
446
+
447
+ m2 = _algo_assign_re.search(body)
448
+ if m2:
449
+ for token in re.split(r'[\s,."]+', m2.group(1)):
450
+ algo = _normalize_algorithm(token)
451
+ if algo:
452
+ _add_algorithm(algo, jwt_config)
453
+
454
+ # Brace-balanced extraction of the lambda body for AddJwtBearer(config => { ... }).
455
+ _SYMMETRIC_KEY_RE = re.compile(r"new\s+SymmetricSecurityKey\s*\(\s*([A-Za-z_][\w\.]*)\s*\)")
456
+ _BOOL_PROP_RE = re.compile(
457
+ r"(Validate(?:IssuerSigningKey|Lifetime|Issuer|Audience|Actor))\s*=\s*(true|false)",
458
+ re.IGNORECASE,
459
+ )
460
+
461
+ def _scan_addjwtbearer_lambda_bodies(
462
+ self, parsed_file: ParsedFile, jwt_config: ExtractedJwtConfig
463
+ ) -> None:
464
+ """
465
+ Find every `AddJwtBearer(config => { ... })` call and parse the lambda
466
+ body for validation flags and signing-key references.
467
+
468
+ Minimal-hosting Program.cs has the JWT config at module scope —
469
+ outside any class — so the in-method body scan in
470
+ _scan_validation_assignments misses it entirely. We read the file
471
+ from disk, walk forward from the call's line, and brace-match to
472
+ extract the lambda body.
473
+ """
474
+ # Only fire when we have AddJwtBearer call sites
475
+ jwt_calls = [
476
+ c for c in parsed_file.call_sites if c.callee_name.rsplit(".", 1)[-1] == "AddJwtBearer"
477
+ ]
478
+ if not jwt_calls:
479
+ return
480
+
481
+ try:
482
+ source = Path(parsed_file.path).read_text(encoding="utf-8", errors="ignore")
483
+ except OSError:
484
+ return
485
+
486
+ for call in jwt_calls:
487
+ body = self._extract_lambda_body(source, call.location.line)
488
+ if not body:
489
+ continue
490
+
491
+ # Boolean validation flags inside TokenValidationParameters initializer.
492
+ for m in self._BOOL_PROP_RE.finditer(body):
493
+ prop = m.group(1).lower()
494
+ val = m.group(2).lower() == "true"
495
+ if "issuersigningkey" in prop:
496
+ if val:
497
+ jwt_config.validates_signature = True
498
+ elif "lifetime" in prop:
499
+ jwt_config.validates_expiry = val
500
+ elif "issuer" in prop:
501
+ jwt_config.validates_issuer = val
502
+ elif "audience" in prop:
503
+ jwt_config.validates_audience = val
504
+
505
+ # IssuerSigningKey = new SymmetricSecurityKey(key) — capture the
506
+ # referenced variable name so downstream tooling can trace it back
507
+ # to its declaration (often `var key = Encoding.ASCII.GetBytes(...)`).
508
+ if jwt_config.secret_source is None:
509
+ m = self._SYMMETRIC_KEY_RE.search(body)
510
+ if m:
511
+ jwt_config.secret_source = "code"
512
+ jwt_config.secret_name = m.group(1)
513
+
514
+ @staticmethod
515
+ def _extract_lambda_body(source: str, start_line: int) -> str:
516
+ """
517
+ Return the lambda body of an `AddJwtBearer(config => { ... })` call
518
+ located at-or-after `start_line` (1-indexed).
519
+
520
+ The parser records the line of the *first* statement in a chained
521
+ invocation (`builder.Services.AddAuthentication(...).AddJwtBearer(...)`
522
+ gets line=55, the AddAuthentication line, not the AddJwtBearer line).
523
+ We compensate by locating the literal `AddJwtBearer` token in the
524
+ forward chunk first, then brace-matching from there.
525
+ """
526
+ lines = source.splitlines()
527
+ if start_line < 1 or start_line > len(lines):
528
+ return ""
529
+ chunk = "\n".join(lines[start_line - 1 : start_line - 1 + 200])
530
+ marker = chunk.find("AddJwtBearer")
531
+ if marker == -1:
532
+ return ""
533
+ # Find the opening brace of the lambda body that follows.
534
+ brace = chunk.find("{", marker)
535
+ if brace == -1:
536
+ return ""
537
+ depth = 0
538
+ for i, ch in enumerate(chunk[brace:], start=brace):
539
+ if ch == "{":
540
+ depth += 1
541
+ elif ch == "}":
542
+ depth -= 1
543
+ if depth == 0:
544
+ return chunk[brace : i + 1]
545
+ return ""