attackmap-analyzer-php-laminas 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,447 @@
1
+ """A small, bounded reader for PHP array literals (mlaify/attackmap-analyzer-php-laminas#2).
2
+
3
+ Laminas config is a nested PHP array (`'router' => ['routes' => [...]]`), so a
4
+ flat `'route' => '...'` regex can't tell a child route from a top-level one.
5
+ This module tokenizes PHP source well enough to walk those arrays
6
+ structurally:
7
+
8
+ - `[` / `]` and `array(` / `)` are balanced; `(`, `{` inside values are skipped
9
+ as opaque expressions (closures, `__DIR__ . '/x'`, function calls);
10
+ - single/double-quoted strings, heredoc/nowdoc and `//`, `#`, `/* */`
11
+ comments are recognized, so brackets inside them don't count.
12
+
13
+ It never raises on garbage, truncated or hostile input: every loop advances,
14
+ nesting deeper than ``MAX_DEPTH`` becomes an opaque expression, and a file
15
+ yields at most ``MAX_TOKENS`` tokens.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from dataclasses import dataclass, field
22
+ from typing import Union
23
+
24
+ MAX_DEPTH = 64
25
+ MAX_TOKENS = 500_000
26
+
27
+ # Token kinds.
28
+ STR = "str"
29
+ OPEN = "open" # `[` or `array(`: an array literal
30
+ CLOSE = "close" # `]` or `)`
31
+ LPAREN = "lparen" # `(` that is not `array(`
32
+ LBRACE = "lbrace"
33
+ RBRACE = "rbrace"
34
+ ARROW = "arrow"
35
+ COMMA = "comma"
36
+ WORD = "word" # identifiers, `Foo\Bar::class`, numbers, `$vars`
37
+ OP = "op" # anything else (`.`, `;`, `=`, `->`, ...)
38
+
39
+ _WORD_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_\\$:")
40
+ _HEREDOC_START = re.compile(r"<<<[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
41
+ _DQ_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", "\\": "\\", '"': '"', "$": "$"}
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Token:
46
+ kind: str
47
+ text: str
48
+ offset: int
49
+
50
+
51
+ class TokenList(list):
52
+ """Tokens, plus ``truncated``: True when ``max_tokens`` stopped the
53
+ tokenizer before the end of the source (``end_offset`` is where)."""
54
+
55
+ truncated: bool = False
56
+ end_offset: int = 0
57
+
58
+
59
+ def tokenize(source: str, start: int = 0, max_tokens: int = MAX_TOKENS) -> TokenList:
60
+ """Tokenize PHP source from ``start``. Unterminated strings/comments run to EOF."""
61
+ tokens = TokenList()
62
+ n = len(source)
63
+ i = start
64
+ while i < n and len(tokens) < max_tokens:
65
+ c = source[i]
66
+ if c in " \t\r\n\f\v":
67
+ i += 1
68
+ continue
69
+ if c == "/" and source.startswith("//", i) or c == "#" and not source.startswith("#[", i):
70
+ end = source.find("\n", i)
71
+ i = n if end < 0 else end + 1
72
+ continue
73
+ if c == "/" and source.startswith("/*", i):
74
+ end = source.find("*/", i + 2)
75
+ i = n if end < 0 else end + 2
76
+ continue
77
+ if c == "'":
78
+ value, i_next = _single_quoted(source, i)
79
+ tokens.append(Token(STR, value, i))
80
+ i = i_next
81
+ continue
82
+ if c == '"':
83
+ value, i_next = _double_quoted(source, i)
84
+ tokens.append(Token(STR, value, i))
85
+ i = i_next
86
+ continue
87
+ if c == "<" and source.startswith("<<<", i):
88
+ heredoc = _heredoc(source, i)
89
+ if heredoc is not None:
90
+ value, i_next = heredoc
91
+ tokens.append(Token(STR, value, i))
92
+ i = i_next
93
+ continue
94
+ if c == "[":
95
+ tokens.append(Token(OPEN, c, i))
96
+ i += 1
97
+ continue
98
+ if c in "])":
99
+ tokens.append(Token(CLOSE, c, i))
100
+ i += 1
101
+ continue
102
+ if c == "(":
103
+ tokens.append(Token(LPAREN, c, i))
104
+ i += 1
105
+ continue
106
+ if c == "{":
107
+ tokens.append(Token(LBRACE, c, i))
108
+ i += 1
109
+ continue
110
+ if c == "}":
111
+ tokens.append(Token(RBRACE, c, i))
112
+ i += 1
113
+ continue
114
+ if c == ",":
115
+ tokens.append(Token(COMMA, c, i))
116
+ i += 1
117
+ continue
118
+ if c == "=" and source.startswith("=>", i):
119
+ tokens.append(Token(ARROW, "=>", i))
120
+ i += 2
121
+ continue
122
+ if c in _WORD_CHARS:
123
+ j = i + 1
124
+ while j < n and source[j] in _WORD_CHARS:
125
+ j += 1
126
+ word = source[i:j]
127
+ # `array(` opens an array literal, like `[`.
128
+ k = j
129
+ while k < n and source[k] in " \t\r\n":
130
+ k += 1
131
+ if word.lower() == "array" and k < n and source[k] == "(":
132
+ tokens.append(Token(OPEN, "array(", i))
133
+ i = k + 1
134
+ continue
135
+ tokens.append(Token(WORD, word, i))
136
+ i = j
137
+ continue
138
+ tokens.append(Token(OP, c, i))
139
+ i += 1
140
+ tokens.truncated = i < n
141
+ tokens.end_offset = i
142
+ return tokens
143
+
144
+
145
+ def _single_quoted(source: str, i: int) -> tuple[str, int]:
146
+ out: list[str] = []
147
+ j = i + 1
148
+ n = len(source)
149
+ while j < n:
150
+ c = source[j]
151
+ if c == "\\" and j + 1 < n and source[j + 1] in "'\\":
152
+ out.append(source[j + 1])
153
+ j += 2
154
+ continue
155
+ if c == "'":
156
+ return "".join(out), j + 1
157
+ out.append(c)
158
+ j += 1
159
+ return "".join(out), n
160
+
161
+
162
+ def _double_quoted(source: str, i: int) -> tuple[str, int]:
163
+ out: list[str] = []
164
+ j = i + 1
165
+ n = len(source)
166
+ while j < n:
167
+ c = source[j]
168
+ if c == "\\" and j + 1 < n:
169
+ nxt = source[j + 1]
170
+ if nxt in _DQ_ESCAPES:
171
+ out.append(_DQ_ESCAPES[nxt])
172
+ j += 2
173
+ continue
174
+ out.append(c)
175
+ j += 1
176
+ continue
177
+ if c == '"':
178
+ return "".join(out), j + 1
179
+ out.append(c)
180
+ j += 1
181
+ return "".join(out), n
182
+
183
+
184
+ def _heredoc(source: str, i: int) -> tuple[str, int] | None:
185
+ match = _HEREDOC_START.match(source, i)
186
+ if match is None:
187
+ return None
188
+ ident = match.group(2)
189
+ body_start = source.find("\n", match.end())
190
+ if body_start < 0:
191
+ return "", len(source)
192
+ pos = body_start + 1
193
+ n = len(source)
194
+ while pos < n:
195
+ line_end = source.find("\n", pos)
196
+ line = source[pos : n if line_end < 0 else line_end]
197
+ stripped = line.lstrip(" \t")
198
+ if stripped.startswith(ident) and not (
199
+ len(stripped) > len(ident) and (stripped[len(ident)].isalnum() or stripped[len(ident)] == "_")
200
+ ):
201
+ end = pos + (len(line) - len(stripped)) + len(ident)
202
+ return source[body_start + 1 : max(body_start + 1, pos - 1)], end
203
+ if line_end < 0:
204
+ break
205
+ pos = line_end + 1
206
+ return source[body_start + 1 :], n
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Values
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ @dataclass
215
+ class PhpString:
216
+ value: str
217
+ offset: int
218
+
219
+
220
+ @dataclass
221
+ class PhpExpr:
222
+ """Anything that isn't a plain string or array: `Foo::class`, `true`,
223
+ `null`, `__DIR__ . '/x'`, a closure. ``text`` is the space-joined tokens."""
224
+
225
+ text: str
226
+ offset: int
227
+
228
+
229
+ @dataclass
230
+ class PhpArray:
231
+ offset: int
232
+ items: list[tuple["PhpValue | None", "PhpValue"]] = field(default_factory=list)
233
+
234
+ def get(self, key: str) -> "PhpValue | None":
235
+ """The value of string key ``key`` (last one wins, as in PHP)."""
236
+ found = None
237
+ for item_key, value in self.items:
238
+ if isinstance(item_key, PhpString) and item_key.value == key:
239
+ found = value
240
+ return found
241
+
242
+ def entries(self) -> list[tuple[str, "PhpValue"]]:
243
+ """``(key, value)`` pairs whose key is a string literal."""
244
+ return [(k.value, v) for k, v in self.items if isinstance(k, PhpString)]
245
+
246
+
247
+ PhpValue = Union[PhpString, PhpExpr, PhpArray]
248
+
249
+
250
+ class _Parser:
251
+ def __init__(self, tokens: list[Token]) -> None:
252
+ self.tokens = tokens
253
+ self.n = len(tokens)
254
+
255
+ def parse_array(self, i: int, depth: int = 0) -> tuple[PhpArray, int]:
256
+ """Parse the array whose OPEN token is at ``i``; return it and the index after its CLOSE."""
257
+ array = PhpArray(offset=self.tokens[i].offset)
258
+ i += 1
259
+ while i < self.n:
260
+ tok = self.tokens[i]
261
+ if tok.kind == CLOSE or tok.kind == RBRACE:
262
+ return array, i + 1
263
+ if tok.kind == COMMA:
264
+ i += 1
265
+ continue
266
+ first, j = self.parse_value(i, depth)
267
+ if j < self.n and self.tokens[j].kind == ARROW:
268
+ value, j = self.parse_value(j + 1, depth)
269
+ array.items.append((first, value))
270
+ else:
271
+ array.items.append((None, first))
272
+ # Skip anything unexpected up to the next separator.
273
+ while j < self.n and self.tokens[j].kind not in (COMMA, CLOSE, RBRACE):
274
+ j += 1
275
+ i = max(j, i + 1)
276
+ return array, self.n
277
+
278
+ def parse_value(self, i: int, depth: int) -> tuple[PhpValue, int]:
279
+ """Parse one value starting at ``i``; stops before a top-level `,`, `=>` or closer."""
280
+ start = i
281
+ if i >= self.n:
282
+ return PhpExpr("", self.tokens[-1].offset if self.tokens else 0), i
283
+ tok = self.tokens[i]
284
+ if tok.kind == OPEN and depth < MAX_DEPTH:
285
+ array, j = self.parse_array(i, depth + 1)
286
+ if self._at_value_end(j):
287
+ return array, j
288
+ # `[...]` followed by more expression (`[...] + $x`): opaque.
289
+ i = j
290
+ elif tok.kind == STR and self._at_value_end(i + 1):
291
+ return PhpString(tok.text, tok.offset), i + 1
292
+ parts: list[str] = []
293
+ level = 0
294
+ while i < self.n:
295
+ tok = self.tokens[i]
296
+ if tok.kind in (OPEN, LPAREN, LBRACE):
297
+ level += 1
298
+ elif tok.kind in (CLOSE, RBRACE):
299
+ if level == 0:
300
+ break
301
+ level -= 1
302
+ elif level == 0 and tok.kind in (COMMA, ARROW):
303
+ break
304
+ if len(parts) < 64:
305
+ parts.append(tok.text)
306
+ i += 1
307
+ # May return ``start`` itself (an empty value before a closer); callers
308
+ # guarantee progress.
309
+ return PhpExpr(" ".join(parts), self.tokens[start].offset), i
310
+
311
+ def _at_value_end(self, j: int) -> bool:
312
+ return j >= self.n or self.tokens[j].kind in (COMMA, ARROW, CLOSE, RBRACE)
313
+
314
+
315
+ def parse_array_at(tokens: list[Token], index: int) -> PhpArray | None:
316
+ """Parse the array literal whose OPEN token is ``tokens[index]``."""
317
+ if not (0 <= index < len(tokens)) or tokens[index].kind != OPEN:
318
+ return None
319
+ array, _ = _Parser(tokens).parse_array(index)
320
+ return array
321
+
322
+
323
+ def parse_array_span(tokens: list[Token], index: int) -> tuple[PhpArray | None, int]:
324
+ """Like :func:`parse_array_at`, plus the token index after the array."""
325
+ if not (0 <= index < len(tokens)) or tokens[index].kind != OPEN:
326
+ return None, index + 1
327
+ return _Parser(tokens).parse_array(index)
328
+
329
+
330
+ def parse_call_args(tokens: list[Token], index: int) -> tuple[list[PhpValue], int]:
331
+ """Parse the arguments of a call whose `(` is ``tokens[index]``; return
332
+ them and the token index after the closing `)`."""
333
+ if not (0 <= index < len(tokens)) or tokens[index].kind not in (LPAREN, OPEN):
334
+ return [], index + 1
335
+ parser = _Parser(tokens)
336
+ args: list[PhpValue] = []
337
+ i = index + 1
338
+ while i < len(tokens):
339
+ tok = tokens[i]
340
+ if tok.kind in (CLOSE, RBRACE):
341
+ return args, i + 1
342
+ if tok.kind in (COMMA, ARROW):
343
+ i += 1
344
+ continue
345
+ value, j = parser.parse_value(i, 0)
346
+ args.append(value)
347
+ i = max(j, i + 1)
348
+ return args, len(tokens)
349
+
350
+
351
+ def keyed_arrays(tokens: list[Token], key: str) -> list[PhpArray]:
352
+ """Every array literal that is the value of string key ``key`` (`'key' => [...]`)."""
353
+ found: list[PhpArray] = []
354
+ parser = _Parser(tokens)
355
+ i = 0
356
+ while i < len(tokens) - 2:
357
+ tok = tokens[i]
358
+ if tok.kind == STR and tok.text == key and tokens[i + 1].kind == ARROW and tokens[i + 2].kind == OPEN:
359
+ array, end = parser.parse_array(i + 2)
360
+ found.append(array)
361
+ # Continue after it: a nested `'key' => [...]` was already parsed
362
+ # as part of this one, and re-parsing would be quadratic.
363
+ i = max(end, i + 1)
364
+ continue
365
+ i += 1
366
+ return found
367
+
368
+
369
+ def keyed_array_spans(tokens: list[Token], key: str) -> list[tuple[int, int]]:
370
+ """Source ``(start, end)`` offsets of every outermost array literal that is
371
+ the value of string key ``key``. An unterminated array runs to the last token."""
372
+ spans: list[tuple[int, int]] = []
373
+ parser = _Parser(tokens)
374
+ n = len(tokens)
375
+ i = 0
376
+ while i < n - 2:
377
+ tok = tokens[i]
378
+ if tok.kind == STR and tok.text == key and tokens[i + 1].kind == ARROW and tokens[i + 2].kind == OPEN:
379
+ _, end = parser.parse_array(i + 2)
380
+ last = tokens[min(end, n) - 1]
381
+ spans.append((tokens[i + 2].offset, last.offset + len(last.text)))
382
+ i = max(end, i + 1)
383
+ continue
384
+ i += 1
385
+ return spans
386
+
387
+
388
+ # ---------------------------------------------------------------------------
389
+ # PHP names
390
+ # ---------------------------------------------------------------------------
391
+
392
+ _NAMESPACE_DECL = re.compile(r"^[ \t]*namespace[ \t]+([A-Za-z_\\][A-Za-z0-9_\\]*+)[ \t]*[;{]", re.MULTILINE)
393
+ _USE_DECL = re.compile(
394
+ r"^[ \t]*use[ \t]+([A-Za-z_\\][A-Za-z0-9_\\]*+)(?:[ \t]+as[ \t]+([A-Za-z_][A-Za-z0-9_]*+))?[ \t]*;",
395
+ re.MULTILINE | re.IGNORECASE,
396
+ )
397
+
398
+
399
+ @dataclass
400
+ class NameContext:
401
+ """A file's `namespace` and `use` imports, for resolving `Foo::class`."""
402
+
403
+ namespace: str = ""
404
+ uses: dict[str, str] = field(default_factory=dict)
405
+
406
+ @classmethod
407
+ def of(cls, source: str) -> "NameContext":
408
+ ns = _NAMESPACE_DECL.search(source)
409
+ uses: dict[str, str] = {}
410
+ for match in _USE_DECL.finditer(source):
411
+ name = match.group(1).strip("\\")
412
+ alias = match.group(2) or name.rsplit("\\", 1)[-1]
413
+ uses.setdefault(alias, name)
414
+ return cls(namespace=ns.group(1).strip("\\") if ns else "", uses=uses)
415
+
416
+ def resolve(self, name: str) -> str:
417
+ """Fully-qualify a class name written in this file (no leading `\\`)."""
418
+ if name.startswith("\\"):
419
+ return name.lstrip("\\")
420
+ head, sep, tail = name.partition("\\")
421
+ if head in self.uses:
422
+ return self.uses[head] + (sep + tail if sep else "")
423
+ if head.lower() == "namespace" and sep:
424
+ return f"{self.namespace}\\{tail}" if self.namespace else tail
425
+ return f"{self.namespace}\\{name}" if self.namespace else name
426
+
427
+
428
+ def class_name(value: PhpValue | None, names: NameContext) -> str | None:
429
+ """`'Foo\\Bar'` or `Foo\\Bar::class` as a fully-qualified class name, else None."""
430
+ if isinstance(value, PhpString):
431
+ text = value.value.strip()
432
+ return text.lstrip("\\") or None if _is_class_like(text) else None
433
+ if isinstance(value, PhpExpr):
434
+ text = value.text.strip()
435
+ if text.endswith("::class") and " " not in text:
436
+ base = text[: -len("::class")]
437
+ if _is_class_like(base):
438
+ return names.resolve(base)
439
+ return None
440
+
441
+
442
+ def _is_class_like(text: str) -> bool:
443
+ return bool(text) and all(c.isalnum() or c in "_\\" for c in text) and not text[0].isdigit()
444
+
445
+
446
+ def is_true(value: PhpValue | None) -> bool:
447
+ return isinstance(value, PhpExpr) and value.text.strip().lower() == "true"