attackmap-analyzer-java-spring 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,278 @@
1
+ """Same-repo string constants for Spring Security arguments (#11).
2
+
3
+ Real configurations name roles and paths through constants:
4
+ `hasAuthority(AuthoritiesConstants.ADMIN)`,
5
+ `@PreAuthorize("hasAuthority(\\"" + AuthoritiesConstants.ADMIN + "\\")")`,
6
+ `@Secured(Roles.ADMIN)`. This module records every member declaration of every
7
+ class, interface, enum, record and Kotlin object (companion objects count as
8
+ their class), keyed by `ClassName.NAME`. A key resolves only when every
9
+ declaration of it in the repo is a string constant with the same plain-literal
10
+ value:
11
+
12
+ - Java: a `static final String` field (implicitly static final in an
13
+ interface) initialised with string literals joined by `+`;
14
+ - Kotlin: `const val NAME[: String] = ` literals joined by `+`, on one line.
15
+
16
+ Any other declaration of the same `Class.NAME` poisons the key: a non-final or
17
+ non-String field, a field without a literal initialiser, a text block or raw
18
+ string, a `$template`, an enum constant, a `val`/`var`, or a class with the same
19
+ simple name in another package or nested elsewhere that declares the name
20
+ differently. So does hitting a size cap, which makes every lookup fail.
21
+ Unresolved references leave the caller's decision `unknown`.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import re
27
+
28
+ from .annotations import BracketIndex, _find_body_open, split_top_level
29
+
30
+ MAX_CONSTANTS = 50_000
31
+ MAX_DECLARATIONS_PER_FILE = 5_000
32
+ MAX_TYPES_PER_FILE = 1_000
33
+ MAX_VALUE_CHARS = 256
34
+ MAX_EXPRESSION_CHARS = 2_000
35
+ MAX_PIECES = 32
36
+
37
+ _TYPE_DECL_RE = re.compile(r"(?<![\w.$@])(class|interface|enum|record|object)\s+([A-Za-z_]\w*)")
38
+ _ENUM_BEFORE_RE = re.compile(r"\benum\s*\Z")
39
+ _COMPANION_RE = re.compile(r"\bcompanion\s+object\b[^;{}]*\Z")
40
+ _ANNOTATION_USE_RE = re.compile(r'@[A-Za-z_][\w.]*(?:\s*\((?:[^()"]|"(?:[^"\\\n]|\\.)*")*\))?')
41
+ _IDENT_RE = re.compile(r"[A-Za-z_]\w*")
42
+ _KOTLIN_PROPERTY_RE = re.compile(r"\b(val|var)\s+([A-Za-z_]\w*)")
43
+ _KOTLIN_CONST_RE = re.compile(r"(?:^|\s)const\s+val\s+[A-Za-z_]\w*\s*(?::\s*(?:kotlin\.)?String\s*)?=(.*)\Z", re.DOTALL)
44
+ _STRING_LITERAL_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
45
+ # `AuthoritiesConstants.ADMIN` or `com.example.AuthoritiesConstants.ADMIN`.
46
+ _QUALIFIED_REF_RE = re.compile(r"(?:[A-Za-z_]\w*\.){0,16}([A-Za-z_]\w*)\.([A-Za-z_]\w*)")
47
+ _ESCAPE_RE = re.compile(r"\\(.)")
48
+ _ESCAPES = {"n": "\n", "t": "\t", "r": "\r"}
49
+ _JAVA_MODIFIERS = frozenset(
50
+ {"public", "private", "protected", "static", "final", "transient", "volatile", "abstract", "default"}
51
+ )
52
+ _POISON = object()
53
+
54
+
55
+ def unescape(value: str) -> str:
56
+ return _ESCAPE_RE.sub(lambda m: _ESCAPES.get(m.group(1), m.group(1)), value)
57
+
58
+
59
+ class ConstantTable:
60
+ """`ClassName.NAME` -> every value declared for it (or a poison marker)."""
61
+
62
+ def __init__(self) -> None:
63
+ self._values: dict[str, set[object]] = {}
64
+ self._count = 0
65
+ # A cap was hit: declarations were dropped and could conflict.
66
+ self.overflow = False
67
+
68
+ def add(self, key: str, value: object) -> None:
69
+ if self._count >= MAX_CONSTANTS:
70
+ self.overflow = True
71
+ return
72
+ if isinstance(value, str) and len(value) > MAX_VALUE_CHARS:
73
+ value = _POISON
74
+ self._count += 1
75
+ self._values.setdefault(key, set()).add(value)
76
+
77
+ def poison(self, key: str) -> None:
78
+ self.add(key, _POISON)
79
+
80
+ def lookup(self, key: str) -> str | None:
81
+ """The value of ``key``, or None when it is unknown, ambiguous or poisoned."""
82
+ if self.overflow:
83
+ return None
84
+ values = self._values.get(key)
85
+ if values is None or len(values) != 1:
86
+ return None
87
+ value = next(iter(values))
88
+ return value if isinstance(value, str) else None
89
+
90
+ def collect(self, masked: str, *, kotlin: bool = False) -> None:
91
+ """Record the member declarations of every type in one comment-masked file."""
92
+ brackets = BracketIndex(masked)
93
+ budget = [MAX_DECLARATIONS_PER_FILE]
94
+ for count, m in enumerate(_TYPE_DECL_RE.finditer(masked)):
95
+ if count >= MAX_TYPES_PER_FILE:
96
+ self.overflow = True
97
+ return
98
+ open_at = _find_body_open(masked, m.end())
99
+ if open_at is None:
100
+ continue
101
+ close = brackets.close(open_at)
102
+ if close is None:
103
+ self.overflow = True # unbalanced: we can't tell what's declared where
104
+ return
105
+ kind = m.group(1)
106
+ is_enum = kind == "enum" or (kotlin and _ENUM_BEFORE_RE.search(masked, max(0, m.start() - 40), m.start()))
107
+ statements = _member_statements(masked, open_at + 1, close - 1, brackets, kotlin)
108
+ self._record(m.group(2), statements, kotlin=kotlin, interface=kind == "interface" and not kotlin,
109
+ enum=bool(is_enum), budget=budget)
110
+ if budget[0] <= 0:
111
+ self.overflow = True
112
+ return
113
+
114
+ def _record(
115
+ self, cls: str, statements: list[str], *, kotlin: bool, interface: bool, enum: bool, budget: list[int]
116
+ ) -> None:
117
+ for index, statement in enumerate(statements):
118
+ budget[0] -= 1
119
+ if budget[0] <= 0:
120
+ return
121
+ if enum and index == 0:
122
+ # Enum constants: `ADMIN, USER("x")` are members named like constants.
123
+ for element in split_top_level(statement):
124
+ ident = _IDENT_RE.match(element.strip())
125
+ if ident is not None:
126
+ self.poison(f"{cls}.{ident.group(0)}")
127
+ continue
128
+ if kotlin:
129
+ self._record_kotlin(cls, statement)
130
+ else:
131
+ self._record_java(cls, statement, interface)
132
+
133
+ def _record_kotlin(self, cls: str, statement: str) -> None:
134
+ prop = _KOTLIN_PROPERTY_RE.search(statement)
135
+ if prop is None:
136
+ return
137
+ key = f"{cls}.{prop.group(2)}"
138
+ const = _KOTLIN_CONST_RE.search(statement)
139
+ value = _literal_value(const.group(1)) if const is not None and prop.group(1) == "val" else None
140
+ if value is None:
141
+ self.poison(key)
142
+ else:
143
+ self.add(key, value)
144
+
145
+ def _record_java(self, cls: str, statement: str, interface: bool) -> None:
146
+ text = _ANNOTATION_USE_RE.sub(" ", statement).strip()
147
+ if not text or "(" in text.split("=", 1)[0]:
148
+ return # a method, constructor or initializer, not a field
149
+ declarators = split_top_level(text)
150
+ if not declarators:
151
+ return
152
+ head = declarators[0]
153
+ left, sep, right = _split_assignment(head)
154
+ tokens = _IDENT_RE.findall(left)
155
+ if len(tokens) < 2:
156
+ return # not `Type name` (a statement fragment)
157
+ name = tokens[-1]
158
+ modifiers = set(tokens[:-2])
159
+ type_name = tokens[-2]
160
+ is_constant = (interface or {"static", "final"} <= modifiers) and type_name == "String"
161
+ is_constant = is_constant and modifiers <= _JAVA_MODIFIERS and "[" not in left and "<" not in left
162
+ value = _literal_value(right) if sep and is_constant and len(declarators) == 1 else None
163
+ if value is None:
164
+ self.poison(f"{cls}.{name}")
165
+ else:
166
+ self.add(f"{cls}.{name}", value)
167
+ for other in declarators[1:]:
168
+ # `String A = "x", B = "y"`: every further declarator is unread.
169
+ other_left, _, _ = _split_assignment(other)
170
+ ident = _IDENT_RE.findall(other_left)
171
+ if ident:
172
+ self.poison(f"{cls}.{ident[-1]}")
173
+
174
+
175
+ def _split_assignment(text: str) -> tuple[str, str, str]:
176
+ """Split at the first `=` that isn't part of `==`, `!=`, `<=`, `>=`."""
177
+ for i, c in enumerate(text):
178
+ if c == "=" and (i + 1 >= len(text) or text[i + 1] != "=") and (i == 0 or text[i - 1] not in "=!<>"):
179
+ return text[:i], "=", text[i + 1 :]
180
+ return text, "", ""
181
+
182
+
183
+ def _literal_value(expr: str) -> str | None:
184
+ """String literals joined by `+`, or None (constants, calls, raw strings...)."""
185
+ return resolve_expression(expr, None)
186
+
187
+
188
+ def _member_statements(masked: str, start: int, end: int, brackets: BracketIndex, kotlin: bool) -> list[str]:
189
+ """The member-level statements of a type body.
190
+
191
+ Nested `{...}` blocks (method bodies, nested types, initialisers) end a
192
+ statement and are skipped, except a Kotlin `companion object`, whose
193
+ members belong to the enclosing class. Parentheses and brackets are
194
+ skipped whole, so lambdas in initialisers don't split statements.
195
+ Statements end at `;`, and in Kotlin also at a newline outside brackets.
196
+ """
197
+ statements: list[str] = []
198
+ current: list[str] = []
199
+ i = start
200
+ while i < end:
201
+ c = masked[i]
202
+ if c in "([":
203
+ close = brackets.close(i)
204
+ if close is None or close > end:
205
+ current.append(masked[i:end])
206
+ break
207
+ current.append(masked[i:close])
208
+ i = close
209
+ continue
210
+ if c == '"' or c == "'":
211
+ j = i + 1
212
+ while j < end and masked[j] != c and masked[j] != "\n":
213
+ j += 2 if masked[j] == "\\" else 1
214
+ current.append(masked[i : j + 1])
215
+ i = j + 1
216
+ continue
217
+ if c == "{":
218
+ close = brackets.close(i)
219
+ if close is None or close > end:
220
+ break
221
+ prefix = "".join(current)
222
+ if _COMPANION_RE.search(prefix):
223
+ statements.extend(_member_statements(masked, i + 1, close - 1, brackets, kotlin))
224
+ elif _split_assignment(prefix)[1] == "=":
225
+ current.append(masked[i:close]) # `= { ... }` initializer: keep it in the statement
226
+ i = close
227
+ continue
228
+ current = []
229
+ i = close
230
+ continue
231
+ if c == ";" or (kotlin and c == "\n"):
232
+ text = "".join(current).strip()
233
+ if text:
234
+ statements.append(text)
235
+ current = []
236
+ else:
237
+ current.append(c)
238
+ i += 1
239
+ text = "".join(current).strip()
240
+ if text:
241
+ statements.append(text)
242
+ return statements
243
+
244
+
245
+ EMPTY = ConstantTable()
246
+
247
+
248
+ def resolve_expression(expr: str, table: ConstantTable | None) -> str | None:
249
+ """The string an argument expression evaluates to, or None if it can't be read.
250
+
251
+ The expression is string literals and class-qualified constants from ``table``,
252
+ joined by `+` (`"hasAuthority(\\"" + AuthoritiesConstants.ADMIN + "\\")"`).
253
+ """
254
+ expr = expr.strip()
255
+ if not expr or len(expr) > MAX_EXPRESSION_CHARS:
256
+ return None
257
+ pieces = split_top_level(expr, "+")
258
+ if not pieces or len(pieces) > MAX_PIECES:
259
+ return None
260
+ out: list[str] = []
261
+ for piece in pieces:
262
+ literal = _STRING_LITERAL_RE.fullmatch(piece)
263
+ if literal is not None:
264
+ if "$" in literal.group(1):
265
+ return None # a Kotlin string template
266
+ out.append(unescape(literal.group(1)))
267
+ continue
268
+ ref = _QUALIFIED_REF_RE.fullmatch(piece)
269
+ if ref is None or table is None:
270
+ return None
271
+ value = table.lookup(f"{ref.group(1)}.{ref.group(2)}")
272
+ if value is None:
273
+ return None
274
+ out.append(value)
275
+ return "".join(out)
276
+
277
+
278
+ __all__ = ["EMPTY", "ConstantTable", "resolve_expression", "unescape"]
@@ -0,0 +1,171 @@
1
+ """Spring WebFlux / WebMvc.fn functional endpoints (#3).
2
+
3
+ Shapes read:
4
+
5
+ - `RouterFunctions.route(GET("/x"), h).andRoute(POST("/y"), h)` and
6
+ `RequestPredicates.GET("/x").and(accept(JSON))`;
7
+ - `RouterFunctions.nest(path("/api"), route(...))` and `.andNest(path("/api"), ...)`;
8
+ - the builder: `route().GET("/x", h).POST("/y", accept(JSON), h)`,
9
+ `.path("/api", b -> b.GET(...))` and `.nest(path("/api"), b -> ...)`;
10
+ - Kotlin `router { }` / `coRouter { }` with `GET("/x", h)`, `"/api".nest { }`,
11
+ `path("/api").nest { }` and `accept(JSON).nest { }`.
12
+
13
+ A verb call whose path isn't a string literal (a constant, a Kotlin string
14
+ template) is skipped, and so is everything inside a nest whose prefix can't be
15
+ read, so a route is never reported under the wrong path. The caller gates on
16
+ the functional API being imported.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from dataclasses import dataclass
23
+
24
+ from .annotations import BracketIndex, bracket_end, split_top_level
25
+
26
+ # Imports of the functional API (WebFlux and WebMvc.fn), or its entry points.
27
+ FUNCTIONAL_GATE_RE = re.compile(
28
+ r"\borg\.springframework\.web\.(?:reactive\.function\.server|servlet\.function)\b"
29
+ r"|\bRouterFunctions\b|\bRequestPredicates\b"
30
+ )
31
+ # `GET("/x"`, `.GET("/x"`, `RequestPredicates.GET("/x"` but not `@GET("/x")`.
32
+ _VERB_CALL_RE = re.compile(r"(?<![\w@$])(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s*\(")
33
+ # Java nest calls: `nest(pred, ...)`, `.andNest(pred, ...)`, builder `.nest(pred, b -> ...)`.
34
+ _NEST_CALL_RE = re.compile(r"(?<![\w$])(?:andNest|nest)\s*\(")
35
+ # Builder `.path("/api", b -> ...)`.
36
+ _BUILDER_PATH_RE = re.compile(r"\.\s*path\s*\(")
37
+ # Kotlin DSL `<receiver>.nest {` or infix `<receiver> nest {`.
38
+ _KOTLIN_NEST_RE = re.compile(r"(?<![\w$])nest\s*\{")
39
+ _PATH_PREDICATE_RE = re.compile(r"(?<![\w$])path\s*\(")
40
+ _STRING_LITERAL_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
41
+ _KOTLIN_RECEIVER_PATH_RE = re.compile(r'(?:path\s*\(\s*)?"([^"\\\n$]*)"\s*\)?')
42
+
43
+ MAX_SCOPES = 500
44
+ MAX_ROUTES = 2000
45
+
46
+
47
+ @dataclass
48
+ class FunctionalRoute:
49
+ method: str
50
+ path: str
51
+ offset: int
52
+
53
+
54
+ @dataclass
55
+ class _Scope:
56
+ start: int
57
+ end: int
58
+ # None when the prefix isn't a readable literal: routes inside are skipped.
59
+ prefix: str | None
60
+
61
+
62
+ def _literal(text: str) -> str | None:
63
+ m = _STRING_LITERAL_RE.fullmatch(text.strip())
64
+ if m is None or "$" in m.group(1):
65
+ return None
66
+ return m.group(1)
67
+
68
+
69
+ def _predicate_prefix(predicate: str) -> str | None:
70
+ """Path prefix of a nest predicate: `path("/api")`, `path("/api").and(...)`,
71
+ `accept(JSON)` (no prefix). None when a `path(...)` isn't a literal."""
72
+ m = _PATH_PREDICATE_RE.search(predicate)
73
+ if m is None:
74
+ # A predicate that names a path some other way can't be read.
75
+ return None if _STRING_LITERAL_RE.search(predicate) else ""
76
+ close = bracket_end(predicate, m.end() - 1)
77
+ if close is None:
78
+ return None
79
+ return _literal(predicate[m.end() : close - 1])
80
+
81
+
82
+ def _join(prefix: str, suffix: str) -> str:
83
+ if not prefix:
84
+ path = suffix
85
+ elif not suffix:
86
+ path = prefix
87
+ else:
88
+ path = prefix.rstrip("/") + "/" + suffix.lstrip("/")
89
+ path = path or "/"
90
+ return path if path.startswith("/") else "/" + path
91
+
92
+
93
+ def _scopes(masked: str, brackets: BracketIndex) -> list[_Scope]:
94
+ scopes: list[_Scope] = []
95
+ for count, m in enumerate(_NEST_CALL_RE.finditer(masked)):
96
+ if count >= MAX_SCOPES:
97
+ break
98
+ close = brackets.close(m.end() - 1)
99
+ if close is None:
100
+ continue
101
+ parts = split_top_level(masked[m.end() : close - 1])
102
+ if len(parts) < 2:
103
+ continue
104
+ scopes.append(_Scope(m.end(), close - 1, _predicate_prefix(parts[0])))
105
+ for count, m in enumerate(_BUILDER_PATH_RE.finditer(masked)):
106
+ if count >= MAX_SCOPES:
107
+ break
108
+ close = brackets.close(m.end() - 1)
109
+ if close is None:
110
+ continue
111
+ parts = split_top_level(masked[m.end() : close - 1])
112
+ if len(parts) < 2:
113
+ # `RequestPredicates.path("/x")` is a predicate, not a scope.
114
+ continue
115
+ scopes.append(_Scope(m.end(), close - 1, _literal(parts[0])))
116
+ for count, m in enumerate(_KOTLIN_NEST_RE.finditer(masked)):
117
+ if count >= MAX_SCOPES:
118
+ break
119
+ brace = m.end() - 1
120
+ close = brackets.close(brace)
121
+ if close is None:
122
+ continue
123
+ line_start = masked.rfind("\n", 0, m.start()) + 1
124
+ receiver = masked[line_start : m.start()].strip().rstrip(".").strip()
125
+ scopes.append(_Scope(brace + 1, close - 1, _kotlin_receiver_prefix(receiver)))
126
+ return scopes
127
+
128
+
129
+ def _kotlin_receiver_prefix(receiver: str) -> str | None:
130
+ """`"/api"` or `path("/api")` -> "/api"; a predicate call such as
131
+ `accept(JSON)` -> ""; anything else (a variable that may hold a path
132
+ String, a `"/x" and accept(...)` combination) -> None."""
133
+ m = _KOTLIN_RECEIVER_PATH_RE.fullmatch(receiver)
134
+ if m is not None:
135
+ return m.group(1)
136
+ if '"' in receiver or _PATH_PREDICATE_RE.search(receiver) or not receiver.endswith(")"):
137
+ return None
138
+ return ""
139
+
140
+
141
+ def functional_routes(masked: str) -> list[FunctionalRoute]:
142
+ """Functional routes in a comment-masked file, with nest prefixes applied."""
143
+ brackets = BracketIndex(masked)
144
+ scopes = _scopes(masked, brackets)
145
+ routes: list[FunctionalRoute] = []
146
+ for count, m in enumerate(_VERB_CALL_RE.finditer(masked)):
147
+ if count >= MAX_ROUTES:
148
+ break
149
+ close = brackets.close(m.end() - 1)
150
+ if close is None:
151
+ continue
152
+ parts = split_top_level(masked[m.end() : close - 1])
153
+ if not parts:
154
+ continue
155
+ suffix = _literal(parts[0])
156
+ if suffix is None:
157
+ continue
158
+ enclosing = sorted(
159
+ (s for s in scopes if s.start <= m.start() < s.end),
160
+ key=lambda s: (s.start, -s.end),
161
+ )
162
+ if any(s.prefix is None for s in enclosing):
163
+ continue
164
+ path = ""
165
+ for scope in enclosing:
166
+ path = _join(path, scope.prefix or "")
167
+ routes.append(FunctionalRoute(method=m.group(1), path=_join(path, suffix), offset=m.start()))
168
+ return routes
169
+
170
+
171
+ __all__ = ["FUNCTIONAL_GATE_RE", "FunctionalRoute", "functional_routes"]