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,531 @@
1
+ """A small C# reader for ASP.NET Core route extraction (#2).
2
+
3
+ This is a lexer-level reader, not a parser. It masks comments and
4
+ preprocessor lines, skips string literals (regular, verbatim, interpolated
5
+ and raw) so brackets inside them don't confuse it, reads attribute sections
6
+ (`[HttpGet("{id}", Name = "GetUser")]`, `[HttpGet, Authorize]`), groups them
7
+ into per-declaration blocks, finds class bodies by brace matching, and parses
8
+ fluent call chains (`app.MapGroup("/api").MapGet("/x", h).RequireAuthorization()`).
9
+ Anything it can't read, such as a route template held in a constant, yields
10
+ no route rather than a guessed one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ from dataclasses import dataclass, field
17
+
18
+ _WORD_RE = re.compile(r"[A-Za-z_]\w*")
19
+ _CLASS_DECL_RE = re.compile(r"(?<![\w.@])(?:class|record|struct|interface)\s+(?:class\s+|struct\s+)?([A-Za-z_]\w*)")
20
+ _MODIFIERS = frozenset(
21
+ {
22
+ "public", "private", "protected", "internal", "static", "sealed", "abstract",
23
+ "partial", "virtual", "override", "async", "readonly", "new", "unsafe",
24
+ "extern", "file", "required", "ref", "volatile", "const",
25
+ }
26
+ )
27
+ _CLASS_KEYWORDS = frozenset({"class", "record", "struct", "interface"})
28
+ # Words that can follow `record` etc. in ordinary code (`foreach (var record in x)`).
29
+ _NOT_TYPE_NAMES = frozenset({"in", "is", "as", "where", "when", "and", "or", "not", "with"})
30
+ # A `[` opens an attribute section only at a declaration position: after one
31
+ # of these (or at the start of the file), never after an expression.
32
+ _ATTRIBUTE_PRECEDERS = frozenset("{};],(")
33
+
34
+
35
+ # ---------- Lexing ----------
36
+
37
+
38
+ def skip_literal(text: str, i: int) -> int | None:
39
+ """If a string or char literal starts at ``i``, return the index past it."""
40
+ c = text[i]
41
+ if c not in "\"'@$":
42
+ return None
43
+ if c == "'":
44
+ j = i + 1
45
+ while j < len(text) and j < i + 12:
46
+ if text[j] == "\\":
47
+ j += 2
48
+ continue
49
+ if text[j] == "'":
50
+ return j + 1
51
+ if text[j] == "\n":
52
+ break
53
+ j += 1
54
+ return None
55
+ j = i
56
+ prefix = ""
57
+ while j < len(text) and text[j] in "@$" and len(prefix) < 4:
58
+ prefix += text[j]
59
+ j += 1
60
+ if j >= len(text) or text[j] != '"':
61
+ return None
62
+ if text.startswith('"""', j):
63
+ end = text.find('"""', j + 3)
64
+ if end == -1:
65
+ return len(text)
66
+ end += 3
67
+ while end < len(text) and text[end] == '"':
68
+ end += 1
69
+ return end
70
+ j += 1
71
+ verbatim = "@" in prefix
72
+ while j < len(text):
73
+ ch = text[j]
74
+ if verbatim:
75
+ if ch == '"':
76
+ if j + 1 < len(text) and text[j + 1] == '"':
77
+ j += 2
78
+ continue
79
+ return j + 1
80
+ else:
81
+ if ch == "\\":
82
+ j += 2
83
+ continue
84
+ if ch == '"' or ch == "\n":
85
+ return j + 1
86
+ j += 1
87
+ return len(text)
88
+
89
+
90
+ def mask_comments(content: str) -> str:
91
+ """``content`` with comments and preprocessor lines blanked (offsets kept)."""
92
+ out = list(content)
93
+ i, n = 0, len(content)
94
+ line_start = True
95
+ while i < n:
96
+ c = content[i]
97
+ if c == "\n":
98
+ line_start = True
99
+ i += 1
100
+ continue
101
+ if line_start and c in " \t":
102
+ i += 1
103
+ continue
104
+ if line_start and c == "#":
105
+ end = content.find("\n", i)
106
+ end = n if end == -1 else end
107
+ for j in range(i, end):
108
+ out[j] = " "
109
+ i = end
110
+ continue
111
+ line_start = False
112
+ skipped = skip_literal(content, i)
113
+ if skipped is not None:
114
+ i = skipped
115
+ continue
116
+ if content.startswith("//", i):
117
+ end = content.find("\n", i)
118
+ end = n if end == -1 else end
119
+ for j in range(i, end):
120
+ out[j] = " "
121
+ i = end
122
+ elif content.startswith("/*", i):
123
+ end = content.find("*/", i + 2)
124
+ end = n if end == -1 else end + 2
125
+ for j in range(i, end):
126
+ if out[j] != "\n":
127
+ out[j] = " "
128
+ i = end
129
+ else:
130
+ i += 1
131
+ return "".join(out)
132
+
133
+
134
+ def match_bracket(text: str, open_at: int) -> int | None:
135
+ """Index just past the bracket closing the one at ``open_at`` (None if unbalanced)."""
136
+ pairs = {"(": ")", "{": "}", "[": "]"}
137
+ stack = [pairs[text[open_at]]]
138
+ i = open_at + 1
139
+ n = len(text)
140
+ while i < n:
141
+ skipped = skip_literal(text, i)
142
+ if skipped is not None:
143
+ i = skipped
144
+ continue
145
+ c = text[i]
146
+ if c in pairs:
147
+ stack.append(pairs[c])
148
+ elif c in ")}]":
149
+ if c != stack[-1]:
150
+ return None
151
+ stack.pop()
152
+ if not stack:
153
+ return i + 1
154
+ i += 1
155
+ return None
156
+
157
+
158
+ def split_top_level(text: str, sep: str = ",") -> list[str]:
159
+ parts: list[str] = []
160
+ depth = 0
161
+ start = 0
162
+ i = 0
163
+ n = len(text)
164
+ while i < n:
165
+ skipped = skip_literal(text, i)
166
+ if skipped is not None:
167
+ i = skipped
168
+ continue
169
+ c = text[i]
170
+ if c in "({[":
171
+ depth += 1
172
+ elif c in ")}]":
173
+ depth -= 1
174
+ elif c == sep and depth == 0:
175
+ parts.append(text[start:i])
176
+ start = i + 1
177
+ i += 1
178
+ parts.append(text[start:])
179
+ return [p.strip() for p in parts if p.strip()]
180
+
181
+
182
+ def string_literal(value: str) -> str | None:
183
+ """The value of a C# string literal expression, or None if it isn't one."""
184
+ v = value.strip()
185
+ m = re.fullmatch(r'"((?:[^"\\\n]|\\.)*)"', v)
186
+ if m:
187
+ return m.group(1)
188
+ m = re.fullmatch(r'@"((?:[^"]|"")*)"', v)
189
+ if m:
190
+ return m.group(1).replace('""', '"')
191
+ m = re.fullmatch(r'"""(.*?)"""', v, re.DOTALL)
192
+ if m:
193
+ return m.group(1).strip()
194
+ return None
195
+
196
+
197
+ def string_literals(value: str) -> list[str]:
198
+ """Every string literal element of an array-ish value (`new[] { "GET" }`, `["GET"]`)."""
199
+ return [m.group(1) for m in re.finditer(r'"((?:[^"\\\n]|\\.)*)"', value)]
200
+
201
+
202
+ def _skip_ws(text: str, i: int) -> int:
203
+ n = len(text)
204
+ while i < n and text[i].isspace():
205
+ i += 1
206
+ return i
207
+
208
+
209
+ # ---------- Arguments ----------
210
+
211
+
212
+ @dataclass
213
+ class Args:
214
+ positional: list[str] = field(default_factory=list)
215
+ named: dict[str, str] = field(default_factory=dict)
216
+
217
+ def first_string(self, *names: str) -> str | None:
218
+ """The first positional argument, or a named one, as a string literal.
219
+
220
+ Returns None if absent or not a literal.
221
+ """
222
+ for name in names:
223
+ if name in self.named:
224
+ return string_literal(self.named[name])
225
+ if self.positional:
226
+ return string_literal(self.positional[0])
227
+ return None
228
+
229
+ def has_template(self, *names: str) -> bool:
230
+ return bool(self.positional) or any(name in self.named for name in names)
231
+
232
+
233
+ def parse_args(text: str | None) -> Args:
234
+ args = Args()
235
+ if text is None:
236
+ return args
237
+ for part in split_top_level(text):
238
+ # `Name = value` (attribute property) or `name: value` (named argument).
239
+ m = re.match(r"([A-Za-z_]\w*)\s*(?:=(?![=>])|:(?!:))\s*(.*)\Z", part, re.DOTALL)
240
+ if m:
241
+ args.named[m.group(1)] = m.group(2).strip()
242
+ else:
243
+ args.positional.append(part)
244
+ return args
245
+
246
+
247
+ # ---------- Attributes ----------
248
+
249
+
250
+ @dataclass
251
+ class Attribute:
252
+ name: str # last segment, without an `Attribute` suffix
253
+ start: int
254
+ args: Args
255
+
256
+
257
+ @dataclass
258
+ class AttributeBlock:
259
+ """Attribute sections on one declaration."""
260
+
261
+ attributes: list[Attribute]
262
+ start: int
263
+ end: int
264
+ class_keyword_at: int | None = None
265
+
266
+ def get(self, name: str) -> list[Attribute]:
267
+ return [a for a in self.attributes if a.name == name]
268
+
269
+ def has(self, name: str) -> bool:
270
+ return any(a.name == name for a in self.attributes)
271
+
272
+
273
+ def _previous_significant(masked: str, i: int) -> str:
274
+ j = i - 1
275
+ while j >= 0 and masked[j].isspace():
276
+ j -= 1
277
+ return masked[j] if j >= 0 else ""
278
+
279
+
280
+ def _parse_section(masked: str, open_at: int, close: int) -> list[Attribute]:
281
+ inner = masked[open_at + 1 : close - 1]
282
+ inner_offset = open_at + 1
283
+ # Optional target: `[return: X]`, `[assembly: X]`.
284
+ m = re.match(r"\s*(?:assembly|module|return|method|field|property|param|type|event)\s*:(?!:)", inner)
285
+ skip = m.end() if m else 0
286
+ attributes: list[Attribute] = []
287
+ pos = skip
288
+ for part in split_top_level(inner[skip:]):
289
+ name_match = re.match(r"(?:global::)?([A-Za-z_][\w.]*)\s*(\(.*\))?\Z", part, re.DOTALL)
290
+ if name_match is None:
291
+ return [] # not an attribute list (a collection expression, an index)
292
+ name = name_match.group(1).rsplit(".", 1)[-1]
293
+ if name.endswith("Attribute") and name != "Attribute":
294
+ name = name[: -len("Attribute")]
295
+ arg_text = name_match.group(2)
296
+ args = parse_args(arg_text[1:-1] if arg_text else None)
297
+ found = inner.find(part, pos)
298
+ start = inner_offset + (found if found != -1 else pos)
299
+ pos = found + len(part) if found != -1 else pos
300
+ attributes.append(Attribute(name=name, start=start, args=args))
301
+ return attributes
302
+
303
+
304
+ def attribute_blocks(masked: str) -> list[AttributeBlock]:
305
+ """Every attribute block in ``masked``, in file order."""
306
+ blocks: list[AttributeBlock] = []
307
+ current: list[Attribute] = []
308
+ current_start = current_end = -1
309
+ i = 0
310
+ n = len(masked)
311
+ while i < n:
312
+ skipped = skip_literal(masked, i)
313
+ if skipped is not None:
314
+ i = skipped
315
+ continue
316
+ if masked[i] != "[":
317
+ i += 1
318
+ continue
319
+ prev = _previous_significant(masked, i)
320
+ if prev and prev not in _ATTRIBUTE_PRECEDERS:
321
+ i += 1
322
+ continue
323
+ close = match_bracket(masked, i)
324
+ if close is None:
325
+ i += 1
326
+ continue
327
+ attributes = _parse_section(masked, i, close)
328
+ if not attributes:
329
+ i += 1
330
+ continue
331
+ if current and masked[current_end:i].strip() == "":
332
+ current.extend(attributes)
333
+ else:
334
+ if current:
335
+ blocks.append(_finish_block(masked, current, current_start, current_end))
336
+ current = list(attributes)
337
+ current_start = i
338
+ current_end = close
339
+ i = close
340
+ if current:
341
+ blocks.append(_finish_block(masked, current, current_start, current_end))
342
+ return blocks
343
+
344
+
345
+ def _finish_block(masked: str, attributes: list[Attribute], start: int, end: int) -> AttributeBlock:
346
+ i = end
347
+ n = len(masked)
348
+ while True:
349
+ i = _skip_ws(masked, i)
350
+ m = _WORD_RE.match(masked, i) if i < n else None
351
+ if m is None:
352
+ return AttributeBlock(attributes, start, end)
353
+ word = m.group(0)
354
+ if word in _CLASS_KEYWORDS:
355
+ return AttributeBlock(attributes, start, end, class_keyword_at=i)
356
+ if word not in _MODIFIERS:
357
+ return AttributeBlock(attributes, start, end)
358
+ i = m.end()
359
+
360
+
361
+ def member_name(masked: str, end: int) -> str | None:
362
+ """Name of the method declared after an attribute block ending at ``end``."""
363
+ window = masked[end : end + 400]
364
+ m = re.search(r"([A-Za-z_]\w*)\s*(?:<[^<>()]*>)?\s*\(", window)
365
+ return m.group(1) if m else None
366
+
367
+
368
+ # ---------- Class scopes ----------
369
+
370
+
371
+ @dataclass
372
+ class ClassScope:
373
+ name: str
374
+ keyword_at: int
375
+ body_start: int
376
+ body_end: int
377
+
378
+
379
+ def class_scopes(masked: str) -> list[ClassScope]:
380
+ scopes: list[ClassScope] = []
381
+ for m in _CLASS_DECL_RE.finditer(masked):
382
+ name = m.group(1)
383
+ if name in _NOT_TYPE_NAMES or name in _CLASS_KEYWORDS:
384
+ continue
385
+ body_open = _find_body_open(masked, m.end())
386
+ if body_open is None:
387
+ continue
388
+ close = match_bracket(masked, body_open)
389
+ scopes.append(ClassScope(name, m.start(), body_open, close if close is not None else len(masked)))
390
+ return scopes
391
+
392
+
393
+ def _find_body_open(masked: str, i: int) -> int | None:
394
+ n = len(masked)
395
+ while i < n:
396
+ skipped = skip_literal(masked, i)
397
+ if skipped is not None:
398
+ i = skipped
399
+ continue
400
+ c = masked[i]
401
+ if c == "{":
402
+ return i
403
+ if c in "([":
404
+ close = match_bracket(masked, i)
405
+ if close is None:
406
+ return None
407
+ i = close
408
+ continue
409
+ if c in ";}=":
410
+ return None
411
+ i += 1
412
+ return None
413
+
414
+
415
+ def innermost_scope(scopes: list[ClassScope], offset: int) -> ClassScope | None:
416
+ best: ClassScope | None = None
417
+ for scope in scopes:
418
+ if scope.body_start < offset < scope.body_end:
419
+ if best is None or scope.body_start > best.body_start:
420
+ best = scope
421
+ return best
422
+
423
+
424
+ # ---------- Fluent call chains ----------
425
+
426
+
427
+ @dataclass
428
+ class Call:
429
+ name: str
430
+ name_at: int
431
+ generic: str | None
432
+ args: Args
433
+ raw_args: str
434
+
435
+
436
+ @dataclass
437
+ class Chain:
438
+ base: str
439
+ start: int
440
+ end: int
441
+ calls: list[Call]
442
+ assigned_to: str | None
443
+
444
+
445
+ _GENERIC_RE = re.compile(r"\s*<([^<>()]*(?:<[^<>()]*>[^<>()]*)*)>")
446
+
447
+
448
+ def parse_chains(masked: str, first_call: re.Pattern[str]) -> list[Chain]:
449
+ """Fluent chains `base.Call(...).Call(...)` whose first call matches ``first_call``.
450
+
451
+ ``first_call`` is matched against the member name right after `base.`.
452
+ """
453
+ chains: list[Chain] = []
454
+ base_re = re.compile(r"(?<![\w.$@\"])([A-Za-z_]\w*)\s*\.\s*(?=([A-Za-z_]\w*))")
455
+ pos = 0
456
+ n = len(masked)
457
+ while pos < n:
458
+ m = base_re.search(masked, pos)
459
+ if m is None:
460
+ break
461
+ if not first_call.fullmatch(m.group(2)):
462
+ pos = m.end()
463
+ continue
464
+ calls: list[Call] = []
465
+ i = m.end(1)
466
+ while True:
467
+ dot = _skip_ws(masked, i)
468
+ if dot >= n or masked[dot] != ".":
469
+ break
470
+ name_at = _skip_ws(masked, dot + 1)
471
+ name_match = _WORD_RE.match(masked, name_at)
472
+ if name_match is None:
473
+ break
474
+ j = name_match.end()
475
+ generic = None
476
+ g = _GENERIC_RE.match(masked, j)
477
+ if g:
478
+ generic = g.group(1).strip()
479
+ j = g.end()
480
+ paren = _skip_ws(masked, j)
481
+ if paren >= n or masked[paren] != "(":
482
+ break
483
+ close = match_bracket(masked, paren)
484
+ if close is None:
485
+ break
486
+ raw = masked[paren + 1 : close - 1]
487
+ calls.append(Call(name_match.group(0), name_at, generic, parse_args(raw), raw))
488
+ i = close
489
+ if calls:
490
+ chains.append(Chain(m.group(1), m.start(1), i, calls, _assignment_target(masked, m.start(1))))
491
+ pos = i
492
+ else:
493
+ pos = m.end()
494
+ return chains
495
+
496
+
497
+ def _assignment_target(masked: str, base_at: int) -> str | None:
498
+ line_start = masked.rfind("\n", 0, base_at) + 1
499
+ # Look back over the current statement (a declaration can wrap).
500
+ stmt_start = max(masked.rfind(";", 0, base_at), masked.rfind("{", 0, base_at), masked.rfind("}", 0, base_at)) + 1
501
+ before = masked[min(stmt_start, line_start) : base_at]
502
+ m = re.search(r"([A-Za-z_]\w*)\s*=\s*\Z", before)
503
+ if m is None:
504
+ return None
505
+ eq = m.end(1)
506
+ rest = before[eq:].lstrip()
507
+ if rest.startswith("==") or rest.startswith("=>"):
508
+ return None
509
+ return m.group(1)
510
+
511
+
512
+ __all__ = [
513
+ "Args",
514
+ "Attribute",
515
+ "AttributeBlock",
516
+ "Call",
517
+ "Chain",
518
+ "ClassScope",
519
+ "attribute_blocks",
520
+ "class_scopes",
521
+ "innermost_scope",
522
+ "mask_comments",
523
+ "match_bracket",
524
+ "member_name",
525
+ "parse_args",
526
+ "parse_chains",
527
+ "skip_literal",
528
+ "split_top_level",
529
+ "string_literal",
530
+ "string_literals",
531
+ ]