attackmap-analyzer-c 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,1737 @@
1
+ """Native (C/C++) memory-safety and command-exec sinks (AttackMap#189).
2
+
3
+ Emits ``CodeWeakness`` records for five kinds, decided inside one function:
4
+
5
+ - ``unbounded_copy``: ``gets``, ``scanf``-family ``%s``/``%[`` without a field
6
+ width, and ``strcpy``/``strcat``/``sprintf`` whose source (or ``%s``
7
+ argument) is traced to an input in the same function.
8
+ - ``format_string``: a printf-family call whose format is not a constant.
9
+ - ``native_command_exec``: ``system``/``popen`` with a non-constant command,
10
+ ``exec*`` with an input-derived argument or a non-constant ``sh -c`` line.
11
+ - ``tainted_copy_length``: ``memcpy``/``memmove``/``strncpy``/``strncat``
12
+ whose length is traced to an input and not bounds-checked first.
13
+ - ``alloc_size_overflow``: ``malloc``/``realloc``/``alloca``/``new T[]`` of a
14
+ product with an input-derived factor and no overflow check.
15
+
16
+ Sources (per function): request accessors of the supported web frameworks
17
+ (civetweb, mongoose, libmicrohttpd, cpp-httplib, Crow, Drogon, Pistache,
18
+ Boost.Beast, oat++, restbed) and parameters of their request types; socket
19
+ reads (``recv``, ``SSL_read``, ...); stdin (``fgets(..., stdin)``,
20
+ ``std::cin``); ``argv``/``optarg`` in a function that takes ``argv``;
21
+ ``getenv``; and file reads. Taint follows assignments, out-parameters and
22
+ copy functions in textual order; it is never cleared. A comparison
23
+ (``<``/``>``) on the value, a ``MIN``/``std::min`` clamp, or an allocation
24
+ sized from the same value counts as a bound.
25
+
26
+ Severity and confidence come from the traced source: request/network input
27
+ -> high/high, local input (stdin, argv, env, file) -> medium/medium, nothing
28
+ traced -> low/low (reported only for non-constant formats and commands).
29
+ Code under example/demo/sample directories is capped at low/low. Test and
30
+ fuzz code is skipped (``ATTACKMAP_INCLUDE_TESTS`` opts back in).
31
+
32
+ This module is mirrored verbatim in attackmap-analyzer-c and
33
+ attackmap-analyzer-cpp. Change both together.
34
+
35
+ Every scanner here is linear or bounded: comments/strings are masked with one
36
+ regex pass, brace and paren matching are capped, and all patterns are
37
+ module-level with no nested quantifiers.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import bisect
43
+ import os
44
+ import re
45
+ from dataclasses import dataclass, field
46
+ from pathlib import PurePosixPath
47
+
48
+ from attackmap.sdk import line_of
49
+
50
+ try: # attackmap >= the release that exports CodeWeakness (AttackMap#189)
51
+ from attackmap.sdk import CodeWeakness
52
+ except ImportError: # older core: the plugin still loads, emits no weaknesses
53
+ CodeWeakness = None # type: ignore[assignment,misc]
54
+
55
+ _SNIPPET_MAX_CHARS = 160
56
+ # Bounds for the hand-written scanners. A call or parameter list longer than
57
+ # this is treated as unparseable rather than scanned further.
58
+ _MAX_ARGS_SPAN = 4000
59
+ _MAX_PARAMS_SPAN = 2000
60
+ _MAX_RHS_SPAN = 1500
61
+ _COMPARE_WINDOW = 120
62
+ _MAX_OWNER_WALK = 2000
63
+ _MAX_INHERIT_DEPTH = 8
64
+
65
+ # ---------- Masking ----------
66
+
67
+ # One left-to-right pass over the source: comments, preprocessor directives
68
+ # (with continuation lines), ``#if 0`` blocks and string/char/raw-string
69
+ # literals. Comments and directives become spaces; literals keep their quotes
70
+ # and lose their contents. Newlines are preserved so offsets and line numbers
71
+ # don't move. Each construct is consumed up to its terminator, or to the end of
72
+ # the file when it has none, so the scan is linear even on unterminated input.
73
+ _MASK_START_RE = re.compile(
74
+ r"(?P<if0>^[ \t]*\#[ \t]*if[ \t]+0\b)"
75
+ r"|(?P<pp>^[ \t]*\#)"
76
+ r"|(?P<line>//)"
77
+ r"|(?P<block>/\*)"
78
+ r"|(?P<raw>(?<![\w])(?:u8|[uUL])?R\"(?P<delim>[^()\\\s\"]{0,16})\()"
79
+ r"|(?P<str>(?<![\w])(?:u8|[uUL])?\"|\")"
80
+ r"|(?P<chr>')",
81
+ re.MULTILINE,
82
+ )
83
+ _IF0_END_RE = re.compile(r"^[ \t]*\#[ \t]*(?:endif|else|elif)\b[^\n]*", re.MULTILINE)
84
+ _STRING_BODY_RE = re.compile(r"(?:[^\"\\\n]|\\[\s\S])*")
85
+ _CHAR_BODY_RE = re.compile(r"(?:[^'\\\n]|\\[\s\S]){0,8}'")
86
+
87
+
88
+ def _blank(text: str) -> str:
89
+ return re.sub(r"[^\n]", " ", text)
90
+
91
+
92
+ def mask_source(text: str) -> str:
93
+ """Same-length copy of ``text`` with comments, directives and literal
94
+ contents blanked (quotes kept, newlines kept)."""
95
+ out: list[str] = []
96
+ pos = 0
97
+ n = len(text)
98
+ while pos < n:
99
+ match = _MASK_START_RE.search(text, pos)
100
+ if match is None:
101
+ break
102
+ start = match.start()
103
+ out.append(text[pos:start])
104
+ kind = match.lastgroup if match.lastgroup != "delim" else "raw"
105
+ if match.group("raw") is not None:
106
+ kind = "raw"
107
+ if kind == "if0":
108
+ end_match = _IF0_END_RE.search(text, match.end())
109
+ end = end_match.end() if end_match else n
110
+ out.append(_blank(text[start:end]))
111
+ elif kind == "pp":
112
+ end = match.end()
113
+ while True:
114
+ newline = text.find("\n", end)
115
+ if newline == -1:
116
+ end = n
117
+ break
118
+ if text[newline - 1] == "\\":
119
+ end = newline + 1
120
+ continue
121
+ end = newline
122
+ break
123
+ out.append(_blank(text[start:end]))
124
+ elif kind == "line":
125
+ newline = text.find("\n", start)
126
+ end = n if newline == -1 else newline
127
+ out.append(_blank(text[start:end]))
128
+ elif kind == "block":
129
+ close = text.find("*/", match.end())
130
+ end = n if close == -1 else close + 2
131
+ out.append(_blank(text[start:end]))
132
+ elif kind == "raw":
133
+ terminator = ")" + match.group("delim") + '"'
134
+ close = text.find(terminator, match.end())
135
+ if close == -1:
136
+ out.append(text[start : match.end()] + _blank(text[match.end() :]))
137
+ end = n
138
+ else:
139
+ out.append(text[start : match.end()] + _blank(text[match.end() : close + len(terminator) - 1]) + '"')
140
+ end = close + len(terminator)
141
+ elif kind == "str":
142
+ body = _STRING_BODY_RE.match(text, match.end())
143
+ body_end = body.end() if body else match.end()
144
+ out.append(text[start : match.end()] + _blank(text[match.end() : body_end]))
145
+ end = body_end
146
+ if end < n and text[end] == '"':
147
+ out.append('"')
148
+ end += 1
149
+ else: # char literal
150
+ body = _CHAR_BODY_RE.match(text, match.end())
151
+ if body is None:
152
+ out.append("'")
153
+ end = match.end()
154
+ else:
155
+ out.append("'" + _blank(text[match.end() : body.end() - 1]) + "'")
156
+ end = body.end()
157
+ pos = end
158
+ out.append(text[pos:])
159
+ return "".join(out)
160
+
161
+
162
+ # ---------- Calls, functions, arguments ----------
163
+
164
+ _CONTROL_WORDS = frozenset(
165
+ {
166
+ "if", "for", "while", "switch", "catch", "return", "sizeof", "alignof", "decltype",
167
+ "else", "do", "case", "typeof", "__typeof__", "defined", "_Generic", "static_assert",
168
+ }
169
+ )
170
+ _TRAILING_QUALIFIERS = frozenset({"const", "noexcept", "override", "final", "mutable", "volatile", "throw"})
171
+ _BRACE_RE = re.compile(r"[{}]")
172
+ _WORD_BEFORE_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*$")
173
+ _TRAILING_RETURN_RE = re.compile(r"->\s*[\w:<>,\s*&]{1,80}$")
174
+ _IDENT_RE = re.compile(r"[A-Za-z_]\w*")
175
+ _SIZEOF_RE = re.compile(r"\bsizeof\s*(?:\([^()]{0,200}\)|[A-Za-z_]\w*)")
176
+
177
+
178
+ @dataclass
179
+ class _Scope:
180
+ start: int # offset of the opening brace
181
+ end: int # offset of the closing brace
182
+ name: str
183
+ params: str # masked parameter text
184
+ params_orig: str
185
+ param_names: set[str] = field(default_factory=set)
186
+ parent: int | None = None
187
+
188
+
189
+ def _skip_ws_back(text: str, i: int) -> int:
190
+ while i >= 0 and text[i] in " \t\r\n":
191
+ i -= 1
192
+ return i
193
+
194
+
195
+ def _match_open_paren_back(text: str, close: int, limit: int) -> int:
196
+ """Index of the ``(`` matching the ``)`` at ``close``, or -1."""
197
+ depth = 0
198
+ lo = max(-1, close - limit)
199
+ i = close
200
+ while i > lo:
201
+ c = text[i]
202
+ if c == ")":
203
+ depth += 1
204
+ elif c == "(":
205
+ depth -= 1
206
+ if depth == 0:
207
+ return i
208
+ elif c in "{};":
209
+ return -1
210
+ i -= 1
211
+ return -1
212
+
213
+
214
+ def _function_header(masked: str, brace: int) -> tuple[str, int, int] | None:
215
+ """If the ``{`` at ``brace`` opens a function or lambda body, return
216
+ ``(name, params_open, params_close)``."""
217
+ i = _skip_ws_back(masked, brace - 1)
218
+ # Trailing qualifiers / trailing return type.
219
+ for _ in range(4):
220
+ if i < 0:
221
+ return None
222
+ if masked[i] == ")":
223
+ break
224
+ tail = masked[max(0, i - 100) : i + 1]
225
+ arrow = _TRAILING_RETURN_RE.search(tail)
226
+ if arrow is not None:
227
+ i = _skip_ws_back(masked, i - (len(tail) - arrow.start()))
228
+ continue
229
+ word = _WORD_BEFORE_RE.search(tail)
230
+ if word is None or word.group(1) not in _TRAILING_QUALIFIERS:
231
+ return None
232
+ i = _skip_ws_back(masked, i - len(word.group(0)))
233
+ if i < 0 or masked[i] != ")":
234
+ return None
235
+ open_paren = _match_open_paren_back(masked, i, _MAX_PARAMS_SPAN)
236
+ if open_paren < 0:
237
+ return None
238
+ j = _skip_ws_back(masked, open_paren - 1)
239
+ if j < 0:
240
+ return None
241
+ if masked[j] == "]":
242
+ return "<lambda>", open_paren, i
243
+ word = _WORD_BEFORE_RE.search(masked[max(0, j - 80) : j + 1])
244
+ if word is None or word.group(1) in _CONTROL_WORDS:
245
+ return None
246
+ return word.group(1), open_paren, i
247
+
248
+
249
+ def _scopes(masked: str, orig: str) -> list[_Scope]:
250
+ scopes: list[_Scope] = []
251
+ stack: list[int] = []
252
+ for match in _BRACE_RE.finditer(masked):
253
+ if match.group(0) == "{":
254
+ stack.append(match.start())
255
+ continue
256
+ if not stack:
257
+ continue
258
+ start = stack.pop()
259
+ header = _function_header(masked, start)
260
+ if header is None:
261
+ continue
262
+ name, p_open, p_close = header
263
+ params = masked[p_open + 1 : p_close]
264
+ scopes.append(
265
+ _Scope(
266
+ start=start,
267
+ end=match.start(),
268
+ name=name,
269
+ params=params,
270
+ params_orig=orig[p_open + 1 : p_close],
271
+ param_names=_param_names(params),
272
+ )
273
+ )
274
+ scopes.sort(key=lambda s: s.start)
275
+ # Parent links (the enclosing function of a lambda).
276
+ stack: list[int] = []
277
+ for index, scope in enumerate(scopes):
278
+ while stack and scopes[stack[-1]].end < scope.start:
279
+ stack.pop()
280
+ scope.parent = stack[-1] if stack else None
281
+ stack.append(index)
282
+ return scopes
283
+
284
+
285
+ def _param_names(params: str) -> set[str]:
286
+ names: set[str] = set()
287
+ for piece in _split_top(params, 0, len(params)):
288
+ text = params[piece[0] : piece[1]].split("=", 1)[0]
289
+ text = re.sub(r"\[[^\]]*\]", "", text)
290
+ idents = _IDENT_RE.findall(text)
291
+ if idents:
292
+ names.add(idents[-1])
293
+ return names
294
+
295
+
296
+ def _split_top(text: str, lo: int, hi: int) -> list[tuple[int, int]]:
297
+ """Top-level comma-separated spans of ``text[lo:hi]``."""
298
+ spans: list[tuple[int, int]] = []
299
+ depth = 0
300
+ start = lo
301
+ for i in range(lo, hi):
302
+ c = text[i]
303
+ if c in "([{":
304
+ depth += 1
305
+ elif c in ")]}":
306
+ depth -= 1
307
+ elif c == "," and depth == 0:
308
+ spans.append((start, i))
309
+ start = i + 1
310
+ if hi > start or spans:
311
+ spans.append((start, hi))
312
+ return spans
313
+
314
+
315
+ def _call_args(masked: str, open_paren: int) -> tuple[list[tuple[int, int]], int] | None:
316
+ """Argument spans of the call whose ``(`` is at ``open_paren`` and the
317
+ index of its ``)``; None when unbalanced within the bound."""
318
+ depth = 0
319
+ hi = min(len(masked), open_paren + _MAX_ARGS_SPAN)
320
+ for i in range(open_paren, hi):
321
+ c = masked[i]
322
+ if c in "([{":
323
+ depth += 1
324
+ elif c in ")]}":
325
+ depth -= 1
326
+ if depth == 0:
327
+ if c != ")":
328
+ return None
329
+ if i == open_paren + 1 or not masked[open_paren + 1 : i].strip():
330
+ return [], i
331
+ return _split_top(masked, open_paren + 1, i), i
332
+ elif c == ";" and depth == 1:
333
+ return None
334
+ return None
335
+
336
+
337
+ # ---------- Catalogs ----------
338
+
339
+ # Sinks, by family. Values are argument indexes.
340
+ _UNBOUNDED_COPY = {"strcpy": (0, 1), "strcat": (0, 1), "wcscpy": (0, 1), "wcscat": (0, 1), "stpcpy": (0, 1),
341
+ "lstrcpy": (0, 1), "lstrcat": (0, 1), "lstrcpyA": (0, 1), "lstrcatA": (0, 1)}
342
+ _SPRINTF = {"sprintf": 1, "vsprintf": 1}
343
+ # Format argument index for printf-family calls.
344
+ _FORMAT_FUNCS = {
345
+ "printf": 0, "vprintf": 0, "wprintf": 0, "vwprintf": 0,
346
+ "fprintf": 1, "vfprintf": 1, "fwprintf": 1,
347
+ "sprintf": 1, "vsprintf": 1, "asprintf": 1, "vasprintf": 1,
348
+ "snprintf": 2, "vsnprintf": 2, "swprintf": 2,
349
+ "syslog": 1, "vsyslog": 1,
350
+ # civetweb / mongoose response writers take a printf format.
351
+ "mg_printf": 1, "mg_http_reply": 3,
352
+ }
353
+ _VA_FORMAT_FUNCS = frozenset(n for n in _FORMAT_FUNCS if n.startswith("v"))
354
+ _SCANF = {"scanf": 0, "fscanf": 1, "sscanf": 1, "wscanf": 0, "fwscanf": 1, "swscanf": 1}
355
+ _SHELL_COMMAND = {"system": 0, "popen": 0, "_popen": 0, "_wsystem": 0, "_wpopen": 0}
356
+ _EXEC = frozenset({"execl", "execlp", "execle", "execv", "execvp", "execvpe", "execve", "posix_spawn", "posix_spawnp"})
357
+ # (dst, src, length) for bounded copies whose length is the risk.
358
+ _LENGTH_COPY = {"memcpy": (0, 1, 2), "memmove": (0, 1, 2), "strncpy": (0, 1, 2), "strncat": (0, 1, 2),
359
+ "wmemcpy": (0, 1, 2), "wmemmove": (0, 1, 2), "bcopy": (1, 0, 2), "CopyMemory": (0, 1, 2),
360
+ "RtlCopyMemory": (0, 1, 2)}
361
+ _ALLOC = {"malloc": 0, "alloca": 0, "_alloca": 0, "realloc": 1, "xmalloc": 0, "xrealloc": 1}
362
+
363
+ # Out-parameter sources: function -> (dst arg index, source kind or a
364
+ # resolver keyword). "stream:<n>" means the kind follows the stream at
365
+ # argument n (stdin vs file); "fd:<n>" the descriptor at argument n.
366
+ _OUT_PARAM_SOURCES: dict[str, tuple[int, str]] = {
367
+ "recv": (1, "network"), "recvfrom": (1, "network"), "SSL_read": (1, "network"),
368
+ "SSL_read_ex": (1, "network"), "BIO_read": (1, "network"), "mbedtls_ssl_read": (1, "network"),
369
+ "gnutls_record_recv": (1, "network"), "WSARecv": (1, "network"),
370
+ "read": (1, "fd:0"), "pread": (1, "fd:0"),
371
+ "fgets": (0, "stream:2"), "fgetws": (0, "stream:2"), "fread": (0, "stream:3"),
372
+ "getline": (0, "stream:2"), "getdelim": (0, "stream:3"), "gets": (0, "stdin"),
373
+ # civetweb
374
+ "mg_read": (1, "request"), "mg_get_var": (3, "request"), "mg_get_var2": (3, "request"),
375
+ "mg_get_cookie": (2, "request"), "mg_get_form_value": (2, "request"),
376
+ # mongoose
377
+ "mg_http_get_var": (2, "request"),
378
+ }
379
+ # Calls that copy taint from source argument(s) to a destination argument.
380
+ # Value: (dst index, first source index, all-following?)
381
+ _PROPAGATORS: dict[str, tuple[int, int, bool]] = {
382
+ "strcpy": (0, 1, False), "strncpy": (0, 1, False), "strcat": (0, 1, False), "strncat": (0, 1, False),
383
+ "stpcpy": (0, 1, False), "strlcpy": (0, 1, False), "strlcat": (0, 1, False),
384
+ "wcscpy": (0, 1, False), "wcscat": (0, 1, False),
385
+ "memcpy": (0, 1, False), "memmove": (0, 1, False),
386
+ "sprintf": (0, 1, True), "vsprintf": (0, 1, True), "snprintf": (0, 2, True), "vsnprintf": (0, 2, True),
387
+ "mg_url_decode": (2, 0, False), "mg_snprintf": (2, 3, True),
388
+ }
389
+
390
+ # Expressions that are a source wherever they appear (masked text).
391
+ _DIRECT_SOURCES: tuple[tuple[str, re.Pattern[str]], ...] = (
392
+ ("env", re.compile(r"(?<![\w.>])(?:std::)?(?:secure_getenv|getenv|_wgetenv)\s*\(")),
393
+ # civetweb / mongoose / libmicrohttpd request accessors
394
+ ("request", re.compile(r"\bmg_get_(?:header|request_info|request_link)\s*\(")),
395
+ ("request", re.compile(r"\bmg_http_(?:get_header|get_header_var|var)\s*\(")),
396
+ ("request", re.compile(r"\bMHD_lookup_connection_value(?:_n)?\s*\(")),
397
+ # cpp-httplib / Crow / Drogon / Pistache / restbed accessors
398
+ ("request", re.compile(r"\.\s*get_(?:param|header|file)_value\s*\(")),
399
+ ("request", re.compile(r"\.\s*url_params\b")),
400
+ )
401
+ # Parameter types whose variable is request data. Qualified framework types
402
+ # always count; bare names only in a file that uses that framework (gates).
403
+ _REQUEST_TYPE_RE = re.compile(
404
+ r"\bhttplib::Request\b|\bcrow::request\b|\b(?:drogon::)?HttpRequestPtr\b|\bdrogon::HttpRequest\b"
405
+ r"|\bPistache::(?:Rest|Http)::Request\b|\b(?:boost::)?beast::http::request\s*<"
406
+ r"|\brestbed::(?:Session|Request)\b"
407
+ )
408
+ _MG_REQUEST_TYPE_RE = re.compile(r"\bstruct\s+(?:mg_http_message|mg_request_info)\b")
409
+ _STRUCT_REQUEST_RE = re.compile(r"\bstruct\s+\w+")
410
+ _FRAMEWORK_GATES: tuple[tuple[str, re.Pattern[str]], ...] = (
411
+ ("httplib", re.compile(r"\bhttplib\b")),
412
+ ("pistache", re.compile(r"\b[Pp]istache\b")),
413
+ ("beast", re.compile(r"\bbeast\b")),
414
+ ("restbed", re.compile(r"\brestbed\b")),
415
+ )
416
+ _GATED_REQUEST_TYPES: tuple[tuple[str, re.Pattern[str]], ...] = (
417
+ ("httplib", re.compile(r"(?<![\w:])Request\b")),
418
+ ("pistache", re.compile(r"\b(?:Rest|Http)::Request\b")),
419
+ ("beast", re.compile(r"\b(?:http::)?request\s*<")),
420
+ ("restbed", re.compile(r"\bshared_ptr\s*<\s*(?:const\s+)?(?:restbed::)?(?:Session|Request)\s*>")),
421
+ )
422
+ _MG_LOCAL_DECL_RE = re.compile(r"\bstruct\s+(?:mg_http_message|mg_request_info)\s*\*\s*(?:const\s+)?([A-Za-z_]\w*)")
423
+ # libmicrohttpd access-handler parameters that carry the request.
424
+ _MHD_HANDLER_RE = re.compile(r"\bstruct\s+MHD_Connection\b")
425
+ _MHD_REQUEST_PARAMS = frozenset({"url", "method", "version", "upload_data", "upload_data_size"})
426
+ # oat++ ENDPOINT parameter macros.
427
+ _OATPP_PARAM_RE = re.compile(r"\b(?:QUERY|PATH|HEADER|BODY_STRING|BODY_DTO)\s*\(\s*[^,()]{1,120},\s*([A-Za-z_]\w*)")
428
+ _ARGV_PARAM_RE = re.compile(r"\bargv\b")
429
+ _ARGV_USE_RE = re.compile(r"\bargv\s*\[|\boptarg\b")
430
+ # `std::string t(req.target());` / `std::string s{buf};`: direct-initialized
431
+ # strings take their argument's data like an assignment.
432
+ _STRING_CTOR_DECL_RE = re.compile(
433
+ r"\b(?:std::)?(?:string|wstring|string_view|basic_string\s*<[^<>;\n]{0,40}>)\s+([A-Za-z_]\w*)\s*[({]"
434
+ )
435
+ _CIN_RE = re.compile(r"\b(?:std::)?(?:cin|wcin)\s*>>\s*([A-Za-z_]\w*)")
436
+ _GETLINE_CIN_RE = re.compile(r"\b(?:std::)?getline\s*\(\s*(?:std::)?(?:cin|wcin)\s*,\s*([A-Za-z_]\w*)")
437
+
438
+ _STDIN_ARG_RE = re.compile(r"^\s*(?:stdin|0|STDIN_FILENO|std::cin)\s*$")
439
+ _SOCKET_NAME_RE = re.compile(r"(?i)sock|conn|client|peer|remote|accept")
440
+
441
+ # Conversions that make a value numeric (taint kept, string-ness dropped).
442
+ _NUMERIC_RHS_RE = re.compile(
443
+ r"^\s*(?:\(\s*(?:unsigned\s+|signed\s+)?[A-Za-z_][\w:]*\s*\)\s*)?"
444
+ r"(?:(?:std::)?(?:atoi|atol|atoll|atof|strtol|strtoul|strtoll|strtoull|strtoumax|strtoimax|strtod|"
445
+ r"stoi|stol|stoul|stoll|stoull|stod)|ntohs|ntohl|be16toh|be32toh|be64toh|le16toh|le32toh|le64toh|"
446
+ r"strlen|wcslen|strnlen)\s*\("
447
+ )
448
+ _INT_DECL_RE = re.compile(
449
+ r"\b(?:(?:unsigned|signed|long|short|int|size_t|ssize_t|off_t|u?int(?:8|16|32|64)_t|DWORD|uint|ulong|"
450
+ r"std::size_t|socklen_t)\s+){1,4}(?:const\s+)?([A-Za-z_]\w*)\s*[=;,\[)]"
451
+ )
452
+ _CLAMP_RE = re.compile(r"\b(?:MIN|min|std::min|MG_MIN|g_min|qMin)\s*[(<]")
453
+ _ALLOC_ASSIGN_RE = re.compile(
454
+ r"(?<!\w)([A-Za-z_]\w*)\s*(?:\[[^\]\n]{0,80}\])?\s*=\s*(?:\([^()\n]{1,60}\)\s*)?"
455
+ r"(?:(?:std::)?(?:malloc|calloc|realloc|alloca|_alloca|xmalloc|xcalloc|xrealloc|mg_malloc|mg_calloc|"
456
+ r"mg_realloc|mg_malloc_ctx|mg_calloc_ctx|g_malloc|g_malloc0|OPENSSL_malloc)\s*\("
457
+ r"|new\s+[\w:]+(?:\s*<[^<>;\n]{0,60}>)?\s*\[)"
458
+ )
459
+ _RESIZE_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*(?:\.|->)\s*(?:resize|reserve|assign)\s*\(")
460
+ _ASSIGN_RE = re.compile(
461
+ r"(?<![\w.>])([A-Za-z_]\w*(?:\s*(?:\.|->)\s*[A-Za-z_]\w*){0,4})\s*(?:\[[^\]\n]{0,80}\])?\s*"
462
+ r"(?:[+\-*/|&^%]|<<|>>)?=(?!=)"
463
+ )
464
+ _DECL_STRING_LITERAL_RE = re.compile(
465
+ r"\b(?:const\s+)?(?:char|wchar_t|TCHAR|std::string|string)\s*(?:const\s*)?\*?\s*(?:const\s+)?"
466
+ r"([A-Za-z_]\w*)\s*(?:\[\s*\w*\s*\])?\s*(?:=\s*\{?|\{)\s*(?:u8|[uUL])?\"[^\"\n]*\"\s*[;,)}]"
467
+ )
468
+ # `char buf[256], small[16];`: declared byte/char arrays with a literal size.
469
+ _ARRAY_DECL_START_RE = re.compile(r"\b(?:char|wchar_t|TCHAR|u?int8_t|BYTE|uchar)\s+(?=[*\s]*[A-Za-z_])")
470
+ _ARRAY_DECLARATOR_RE = re.compile(r"(?:^|,)\s*(?:\*+\s*)?([A-Za-z_]\w*)\s*\[\s*(\d{1,9})\s*\]")
471
+ # `x.c_str()` / `p->data()`: a call that only exposes a variable's data.
472
+ _DATA_CALL_RE = re.compile(r"(?:\.|->)\s*(?:c_str|data|get|str|toStdString|constData|toUtf8)\s*\(\s*\)\s*$")
473
+ # A call to a free function (not a method): `f(`, `ns::f(`, `f<T>(`.
474
+ _FREE_CALL_RE = re.compile(r"(?<![\w.>:])((?:[A-Za-z_]\w*::){0,4}[A-Za-z_]\w*)\s*(?:<[^<>;()\n]{0,60}>\s*)?\(")
475
+ # Free functions whose result still carries their argument's data: sources,
476
+ # string views/copies/searches, numeric conversions and casts.
477
+ _PASSTHROUGH_CALLS = frozenset(
478
+ {
479
+ "getenv", "secure_getenv", "_wgetenv", "mg_get_header", "mg_get_request_info", "mg_get_request_link",
480
+ "mg_http_get_header", "mg_http_get_header_var", "mg_http_var", "MHD_lookup_connection_value",
481
+ "MHD_lookup_connection_value_n",
482
+ "strdup", "strndup", "_strdup", "wcsdup", "mg_strdup", "mg_strndup", "mg_strdup_ctx", "mg_strndup_ctx",
483
+ "strchr", "strrchr", "strstr", "strcasestr", "strpbrk", "strtok", "strtok_r", "strsep", "memchr",
484
+ "mg_str", "mg_str_n", "mg_strdup", "basename", "dirname", "realpath",
485
+ "string", "string_view", "basic_string", "move", "forward", "data", "c_str",
486
+ "atoi", "atol", "atoll", "atof", "strtol", "strtoul", "strtoll", "strtoull", "strtoumax", "strtoimax",
487
+ "strtod", "stoi", "stol", "stoul", "stoll", "stoull", "stod", "ntohs", "ntohl", "htons", "htonl",
488
+ "be16toh", "be32toh", "be64toh", "le16toh", "le32toh", "le64toh", "strlen", "wcslen", "strnlen",
489
+ "static_cast", "reinterpret_cast", "const_cast", "size_t", "int", "long", "unsigned",
490
+ }
491
+ )
492
+ _LEADING_CAST_RE = re.compile(r"^\(\s*(?:unsigned\s+|signed\s+|const\s+)?[A-Za-z_][\w:]*\s*\**\s*\)\s*")
493
+ _INDEXED_IDENT_RE = re.compile(r"(?<!\w)([A-Za-z_]\w*)\s*\[[^\]]{0,60}\]")
494
+ # Copies that bound the destination (its content stays input, its length
495
+ # doesn't grow past the destination).
496
+ _BOUNDED_PROPAGATORS = frozenset(
497
+ {"strncpy", "strlcpy", "strlcat", "strncat", "snprintf", "vsnprintf", "memcpy", "memmove", "mg_url_decode",
498
+ "mg_snprintf"}
499
+ )
500
+ _DEFINE_STRING_RE = re.compile(r"^[ \t]*#[ \t]*define[ \t]+([A-Za-z_]\w*)[ \t]+(?:u8|[uUL])?\"", re.MULTILINE)
501
+ _COMPARE_OP_RE = re.compile(r"(?<![-<>=!])(?:<=|>=|<|>)(?![<>=])")
502
+ _OVERFLOW_GUARD_RE = re.compile(r"\b(?:SIZE_MAX|INT_MAX|UINT_MAX|LONG_MAX|SSIZE_MAX|PTRDIFF_MAX|__builtin_mul_overflow|"
503
+ r"ckd_mul|std::numeric_limits)\b")
504
+ # A binary multiplication or left shift (an operand before it, so `*p` and
505
+ # `(char *)` casts don't count).
506
+ _MUL_RE = re.compile(r"[\w)\]]\s*(?:\*(?![*/=)])|<<(?!=))")
507
+ _GETTEXT_RE = re.compile(r"^\s*(?:_|N_|gettext|dgettext|_T|TEXT|L|tr|QObject::tr)\s*\(")
508
+ _ALL_CAPS_RE = re.compile(r"^\s*[A-Z][A-Z0-9_]*\s*$")
509
+ # Literals only, possibly concatenated with macros (`"%" PRIu64 "\n"`);
510
+ # `"ping " + host` is not constant. A macro name is matched possessively and
511
+ # never as a string prefix (`u8""` is a literal, not the macro `u8` then `""`),
512
+ # so no input can be split more than one way.
513
+ _LITERAL_ONLY_RE = re.compile(r"^\s*(?:u8|[uUL])?\"[^\"\n]*\"(?:\s*(?:(?:u8|[uUL])?\"[^\"\n]*\"|\b(?!(?:u8|[uUL])\")[A-Za-z_]\w*+))*\s*$")
514
+ _LITERAL_START_RE = re.compile(r"^\s*(?:u8|[uUL])?\"")
515
+ _CONVERSION_RE = re.compile(
516
+ r"%(?:%|(?P<flags>[-+ #0']*)(?P<width>\*|\d+)?(?:\.(?P<prec>\*|\d+))?"
517
+ r"(?P<len>hh|h|ll|l|L|q|j|z|Z|t|I64|I32)?(?P<conv>[diouxXeEfFgGaAcspnCS]|\[))"
518
+ )
519
+ _SCANF_CONVERSION_RE = re.compile(
520
+ r"%(?:%|(?P<suppress>\*)?(?P<width>\d+)?(?P<alloc>m)?(?P<len>hh|h|ll|l|L|q|j|z|t)?"
521
+ r"(?P<conv>[diouxXeEfFgGaAcspn]|\[\^?\]?[^\]]{0,64}\]))"
522
+ )
523
+ _SH_C_RE = re.compile(r"^\s*\"-c\"\s*$")
524
+
525
+ _ALL_CALLS = sorted(
526
+ set(_UNBOUNDED_COPY) | set(_FORMAT_FUNCS) | set(_SCANF) | set(_SHELL_COMMAND) | _EXEC
527
+ | set(_LENGTH_COPY) | set(_ALLOC) | set(_OUT_PARAM_SOURCES) | set(_PROPAGATORS) | {"gets", "getline"},
528
+ key=lambda n: (-len(n), n),
529
+ )
530
+ _SINK_CALLS = frozenset(
531
+ set(_UNBOUNDED_COPY) | set(_FORMAT_FUNCS) | set(_SCANF) | set(_SHELL_COMMAND) | _EXEC | set(_LENGTH_COPY)
532
+ | set(_ALLOC) | {"gets"}
533
+ )
534
+ _CALL_RE = re.compile(r"(?<![\w.>:$])(?:(?:std)?::)?(" + "|".join(_ALL_CALLS) + r")\s*\(")
535
+ _NEW_ARRAY_RE = re.compile(r"\bnew\s+(?:[\w:]+(?:\s*<[^<>;\n]{0,60}>)?)\s*\[")
536
+
537
+ _REMOTE = frozenset({"request", "network"})
538
+ _DOWNRANK_SEGMENTS = frozenset({"example", "examples", "sample", "samples", "demo", "demos", "contrib", "doc", "docs",
539
+ "tutorial", "tutorials"})
540
+ _TEST_SEGMENTS = frozenset({"test", "tests", "testing", "unittest", "unittests", "unit_test", "unit_tests", "fuzz",
541
+ "fuzztest", "fuzzing", "fuzzer", "fuzzers", "bench", "benchmark", "benchmarks", "mock",
542
+ "mocks", "spec", "specs", "__tests__", "fixtures", "gtest", "googletest"})
543
+ _TEST_FILE_RE = re.compile(r"(?i)(?:^test_|_test\.|_tests\.|_unittest\.|_fuzzer\.|^fuzz_|\.test\.)")
544
+
545
+
546
+ def is_test_path(relative: str) -> bool:
547
+ """Test, fuzz and benchmark code: skipped (``ATTACKMAP_INCLUDE_TESTS`` opts in)."""
548
+ if os.environ.get("ATTACKMAP_INCLUDE_TESTS"):
549
+ return False
550
+ parts = PurePosixPath(relative.replace("\\", "/")).parts
551
+ if any(p.lower() in _TEST_SEGMENTS for p in parts[:-1]):
552
+ return True
553
+ return bool(parts) and _TEST_FILE_RE.search(parts[-1]) is not None
554
+
555
+
556
+ def _is_downranked(relative: str) -> bool:
557
+ parts = PurePosixPath(relative.replace("\\", "/")).parts
558
+ return any(p.lower() in _DOWNRANK_SEGMENTS for p in parts[:-1])
559
+
560
+
561
+ # ---------- Taint state ----------
562
+
563
+
564
+ @dataclass(frozen=True)
565
+ class _Taint:
566
+ kind: str
567
+ line: int
568
+ numeric: bool = False
569
+ # A string whose length is capped (a sized read, a width-limited %s);
570
+ # `max_len` is the cap when known (the destination array's size).
571
+ bounded: bool = False
572
+ max_len: int | None = None
573
+
574
+
575
+ class _State:
576
+ """Per-function taint state. Facts are kept with prefix-best arrays so a
577
+ lookup is a bisect, not a scan of every earlier fact."""
578
+
579
+ def __init__(self) -> None:
580
+ # name -> (positions, best taint up to each position, best string taint)
581
+ self._facts: dict[str, tuple[list[int], list[_Taint | None], list[_Taint | None]]] = {}
582
+ self.constant_assigned: dict[str, bool] = {}
583
+ self.allocs: dict[str, list[tuple[int, str]]] = {}
584
+ self.resized: dict[str, list[int]] = {}
585
+ # `n = strlen(s)` / `n = s.size()`: n measures s, so checking n bounds s.
586
+ self.length_of: dict[str, set[str]] = {}
587
+ self.measured_by: dict[str, set[str]] = {}
588
+ # First offset where a call wrote into a variable (out-parameter, copy).
589
+ self.written_first: dict[str, int] = {}
590
+ # Offsets where a variable was clamped (`m = MIN(n, cap)`).
591
+ self.clamped: dict[str, list[int]] = {}
592
+ self._raw: dict[str, list[object]] = {}
593
+
594
+ def write(self, name: str, pos: int) -> None:
595
+ if pos < self.written_first.get(name, pos + 1):
596
+ self.written_first[name] = pos
597
+
598
+ def written_before(self, name: str, pos: int) -> bool:
599
+ return self.written_first.get(name, pos) < pos
600
+
601
+ def measure(self, alias: str, measured: set[str]) -> None:
602
+ self.length_of.setdefault(alias, set()).update(measured)
603
+ for name in measured:
604
+ self.measured_by.setdefault(name, set()).add(alias)
605
+
606
+ def add(self, name: str, pos: int, taint: _Taint) -> None:
607
+ self._record(name, pos, taint)
608
+
609
+ def clear(self, name: str, pos: int) -> None:
610
+ """A reassignment to a constant drops the taint from here on."""
611
+ if name in self._facts:
612
+ self._record(name, pos, _CLEAR)
613
+
614
+ def _record(self, name: str, pos: int, fact: object) -> None:
615
+ positions, best_any, best_str = self._facts.setdefault(name, ([], [], []))
616
+ raw = self._raw.setdefault(name, [])
617
+ index = bisect.bisect_right(positions, pos)
618
+ positions.insert(index, pos)
619
+ raw.insert(index, fact)
620
+ best_any.insert(index, None)
621
+ best_str.insert(index, None)
622
+ for i in range(index, len(positions)):
623
+ prev_any = best_any[i - 1] if i > 0 else None
624
+ prev_str = best_str[i - 1] if i > 0 else None
625
+ item = raw[i]
626
+ if item is _CLEAR:
627
+ best_any[i], best_str[i] = None, None
628
+ else:
629
+ best_any[i] = _better(prev_any, item)
630
+ best_str[i] = prev_str if item.numeric else _better(prev_str, item)
631
+
632
+ def get(self, name: str, before: int, *, want_string: bool) -> _Taint | None:
633
+ entry = self._facts.get(name)
634
+ if entry is None:
635
+ return None
636
+ index = bisect.bisect_left(entry[0], before) - 1
637
+ if index < 0:
638
+ return None
639
+ return (entry[2] if want_string else entry[1])[index]
640
+
641
+
642
+ _CLEAR = object()
643
+
644
+
645
+ def _rank(taint: _Taint) -> int:
646
+ """Remote input beats local; an unbounded string beats a bounded one."""
647
+ return (4 if taint.kind in _REMOTE else 2) + (0 if taint.bounded else 1)
648
+
649
+
650
+ def _better(a: _Taint | None, b: _Taint | None) -> _Taint | None:
651
+ if a is None:
652
+ return b
653
+ if b is None:
654
+ return a
655
+ return a if _rank(a) >= _rank(b) else b
656
+
657
+
658
+ def _base_ident(expr: str) -> str | None:
659
+ """The variable an lvalue/argument expression names: ``&buf[2]`` ->
660
+ ``buf``, ``(char *) p + 1`` -> ``p``, ``s.data()`` -> ``s``."""
661
+ text = expr.strip()
662
+ text = re.sub(r"^\(\s*[^()]{0,60}\)\s*", "", text) # leading cast
663
+ text = text.lstrip("&*( ")
664
+ match = _IDENT_RE.match(text)
665
+ if match is None:
666
+ return None
667
+ name = match.group(0)
668
+ if name in ("std", "static_cast", "reinterpret_cast", "const_cast"):
669
+ inner = re.search(r"\(\s*&?\s*([A-Za-z_]\w*)", text)
670
+ return inner.group(1) if inner else None
671
+ return name
672
+
673
+
674
+ @dataclass
675
+ class _Ctx:
676
+ masked: str
677
+ orig: str
678
+ scope: _Scope
679
+ state: _State
680
+ request_vars: dict[str, int] # name -> line where it enters the function
681
+ argv_scope: bool
682
+ int_vars: set[str]
683
+ constants: set[str]
684
+ int_decl_positions: dict[str, int]
685
+ array_sizes: dict[str, int] = field(default_factory=dict)
686
+ # name -> sorted offsets of an upper-bound check on it (precomputed).
687
+ bounds: dict[str, list[int]] = field(default_factory=dict)
688
+ overflow_guards: list[int] = field(default_factory=list)
689
+
690
+
691
+ def _opaque_calls_blanked(text: str) -> str:
692
+ """``text`` with the arguments of calls to unknown free functions blanked:
693
+ ``gethostbyname(argv[1])`` or ``sanitize(name)`` returns something else,
694
+ so its argument's taint doesn't carry over. Method calls, sources and the
695
+ known pass-through functions (``strdup``, ``strchr``, ``atoi``, casts...)
696
+ keep theirs."""
697
+ if "(" not in text:
698
+ return text
699
+ out = list(text)
700
+ for match in _FREE_CALL_RE.finditer(text):
701
+ name = match.group(1).rsplit("::", 1)[-1]
702
+ if name in _PASSTHROUGH_CALLS or name in _CONTROL_WORDS:
703
+ continue
704
+ depth = 0
705
+ for i in range(match.end() - 1, min(len(text), match.end() - 1 + _MAX_ARGS_SPAN)):
706
+ c = text[i]
707
+ if c == "(":
708
+ depth += 1
709
+ elif c == ")":
710
+ depth -= 1
711
+ if depth == 0:
712
+ for j in range(match.end(), i):
713
+ if out[j] != "\n":
714
+ out[j] = " "
715
+ break
716
+ return "".join(out)
717
+
718
+
719
+ def _expr_taint(ctx: _Ctx, lo: int, hi: int, *, want_string: bool) -> _Taint | None:
720
+ """Taint of the expression ``masked[lo:hi]`` as of offset ``lo``."""
721
+ text = _opaque_calls_blanked(ctx.masked[lo:hi])
722
+ if not text.strip():
723
+ return None
724
+ found: _Taint | None = None
725
+ line = line_of(ctx.orig, lo)
726
+ for kind, pattern in _DIRECT_SOURCES:
727
+ if pattern.search(text):
728
+ found = _better(found, _Taint(kind, line))
729
+ if ctx.argv_scope and _ARGV_USE_RE.search(text):
730
+ found = _better(found, _Taint("argv", line))
731
+ stripped = _SIZEOF_RE.sub(" ", text)
732
+ for match in _IDENT_RE.finditer(stripped):
733
+ start = match.start()
734
+ before = stripped[max(0, start - 2) : start]
735
+ if before.endswith(".") or before == "->":
736
+ continue
737
+ name = match.group(0)
738
+ if name in ctx.request_vars:
739
+ found = _better(found, _Taint("request", ctx.request_vars[name]))
740
+ continue
741
+ taint = ctx.state.get(name, lo, want_string=want_string)
742
+ if taint is not None:
743
+ found = _better(found, taint)
744
+ return found
745
+
746
+
747
+ def _is_numeric_expr(ctx: _Ctx, text: str) -> bool:
748
+ if _NUMERIC_RHS_RE.match(text):
749
+ return True
750
+ stripped = text.strip()
751
+ return stripped.endswith((".size()", ".length()", ".len", "->len", "_len", ".size", "->size"))
752
+
753
+
754
+ def _is_constant_expr(ctx: _Ctx, lo: int, hi: int, depth: int = 0) -> bool:
755
+ """A string literal (possibly concatenated with macros), an ALL_CAPS macro,
756
+ a gettext-wrapped literal, a literal ternary, or a variable that is only
757
+ ever assigned constants."""
758
+ text = ctx.masked[lo:hi].strip()
759
+ if not text or depth > 3:
760
+ return False
761
+ if _LITERAL_ONLY_RE.match(text) or _ALL_CAPS_RE.match(text):
762
+ return True
763
+ if text.startswith("(") and text.endswith(")"):
764
+ inner_lo = ctx.masked.index("(", lo) + 1
765
+ inner_hi = ctx.masked.rindex(")", lo, hi)
766
+ if _is_constant_expr(ctx, inner_lo, inner_hi, depth + 1):
767
+ return True
768
+ gettext = _GETTEXT_RE.match(text)
769
+ if gettext is not None and text.endswith(")"):
770
+ inner_lo = lo + ctx.masked[lo:hi].index("(") + 1
771
+ inner_hi = ctx.masked.rindex(")", lo, hi)
772
+ return _is_constant_expr(ctx, inner_lo, inner_hi, depth + 1)
773
+ question = _top_level_index(text, "?")
774
+ if question >= 0:
775
+ colon = _top_level_index(text, ":", question + 1)
776
+ if colon > question:
777
+ offset = lo + (len(ctx.masked[lo:hi]) - len(ctx.masked[lo:hi].lstrip()))
778
+ return _is_constant_expr(ctx, offset + question + 1, offset + colon, depth + 1) and _is_constant_expr(
779
+ ctx, offset + colon + 1, offset + len(text), depth + 1
780
+ )
781
+ data_call = _DATA_CALL_RE.search(text)
782
+ if data_call is not None and depth < 3:
783
+ # `cmd.c_str()` / `fmt.data()`: as constant as the variable itself.
784
+ base = text[: data_call.start()].strip()
785
+ ident = _IDENT_RE.fullmatch(base)
786
+ if ident is None:
787
+ return False
788
+ text = base
789
+ ident = _IDENT_RE.fullmatch(text) or _INDEXED_IDENT_RE.fullmatch(text)
790
+ if ident is not None:
791
+ name = ident.group(1) if ident.re is _INDEXED_IDENT_RE else ident.group(0)
792
+ if ctx.state.written_before(name, lo):
793
+ return False # `char cmd[64] = ""; sscanf(tag, "%63s", cmd);`
794
+ if ctx.state.constant_assigned.get(name) is not None:
795
+ return ctx.state.constant_assigned[name]
796
+ return name in ctx.constants
797
+ return False
798
+
799
+
800
+ def _top_level_index(text: str, char: str, start: int = 0) -> int:
801
+ depth = 0
802
+ for i in range(start, len(text)):
803
+ c = text[i]
804
+ if c in "([{":
805
+ depth += 1
806
+ elif c in ")]}":
807
+ depth -= 1
808
+ elif c == char and depth == 0:
809
+ if char == ":" and (text[i - 1 : i] == ":" or text[i + 1 : i + 2] == ":"):
810
+ continue
811
+ return i
812
+ return -1
813
+
814
+
815
+ def _measured_names(ctx: _Ctx, names: set[str]) -> set[str]:
816
+ """``names`` plus the variables that measure them (``n = strlen(s)``)."""
817
+ out = set(names)
818
+ for name in names:
819
+ out |= ctx.state.measured_by.get(name, set())
820
+ return out
821
+
822
+
823
+ def _any_before(index: dict[str, list[int]], names: set[str], before: int) -> bool:
824
+ for name in names:
825
+ positions = index.get(name)
826
+ if positions and positions[0] < before:
827
+ return True
828
+ return False
829
+
830
+
831
+ def _compared_before(ctx: _Ctx, names: set[str], before: int) -> bool:
832
+ """An upper-bound check (``n < sizeof buf``, ``if (n > cap) return``,
833
+ ``ck_assert_int_lt(n, cap)``) on any of ``names``, or on a variable that
834
+ measures one of them, before ``before``. Checks against 0/1/NULL
835
+ (``n < 0``, ``strlen(s) > 0``) don't bound anything."""
836
+ return bool(names) and _any_before(ctx.bounds, _measured_names(ctx, names), before)
837
+
838
+
839
+ def _clamped(ctx: _Ctx, names: set[str], before: int) -> bool:
840
+ """A ``m = MIN(n, cap)`` / ``m = n > cap ? cap : n`` clamp before ``before``."""
841
+ return _any_before(ctx.state.clamped, names, before)
842
+
843
+
844
+ def _is_clamp_expr(text: str) -> bool:
845
+ """``MIN(n, cap)`` or a ternary whose condition compares (``n > cap ? cap : n``)."""
846
+ if _CLAMP_RE.search(text):
847
+ return True
848
+ question = _top_level_index(text, "?")
849
+ return question > 0 and _COMPARE_OP_RE.search(text, 0, question) is not None
850
+
851
+
852
+ def _expr_names(ctx: _Ctx, lo: int, hi: int) -> set[str]:
853
+ stripped = _SIZEOF_RE.sub(" ", ctx.masked[lo:hi])
854
+ out: set[str] = set()
855
+ for match in _IDENT_RE.finditer(stripped):
856
+ before = stripped[max(0, match.start() - 2) : match.start()]
857
+ if before.endswith(".") or before == "->":
858
+ continue
859
+ out.add(match.group(0))
860
+ return out
861
+
862
+
863
+ def _sized_by(ctx: _Ctx, dst: str | None, names: set[str], before: int) -> bool:
864
+ """``dst`` was allocated or resized from one of ``names`` before
865
+ ``before`` (``p = malloc(len + 1); memcpy(p, s, len)``)."""
866
+ if dst is None:
867
+ return False
868
+ measured = _measured_names(ctx, names)
869
+ for pos, expr in ctx.state.allocs.get(dst, ()):
870
+ if pos < before and (_names_in(expr) & measured or "strlen" in expr):
871
+ return True
872
+ return any(pos < before for pos in ctx.state.resized.get(dst, ()))
873
+
874
+
875
+ def _names_in(text: str) -> set[str]:
876
+ return set(_IDENT_RE.findall(_SIZEOF_RE.sub(" ", text)))
877
+
878
+
879
+ # ---------- Bounds index ----------
880
+
881
+ # Template argument lists (`static_cast<int>(...)`, `std::vector<char>(n)`):
882
+ # their angle brackets are not comparisons.
883
+ _TEMPLATE_RE = re.compile(
884
+ r"\b(?:std::\w+|static_cast|reinterpret_cast|const_cast|dynamic_cast|vector|array|basic_string|unique_ptr|"
885
+ r"shared_ptr|make_shared|make_unique|numeric_limits|map|unordered_map|set|unordered_set|pair|tuple|function|"
886
+ r"optional|span|string_view|list|deque|initializer_list|atomic|request|response)\s*<[^<>;()\n]{0,80}>"
887
+ )
888
+ # Only short operands are tested (`_is_trivial_bound`): a trivial bound is a
889
+ # few characters, and the leading/trailing space runs would otherwise
890
+ # backtrack quadratically on a long one.
891
+ _TRIVIAL_BOUND_MAX = 120
892
+ _TRIVIAL_BOUND_RE = re.compile(
893
+ r"^[\s(]*(?:\(\s*[A-Za-z_][\w:\s*]{0,40}\)\s*)?(?:[-+]?\s*[01](?:[uUlL]{0,3})|0x0+|NULL|nullptr|true|false|'\s*'|EOF)[\s)]*$"
894
+ )
895
+ _BOUND_CHECK_CALL_RE = re.compile(
896
+ r"\b(?:\w*_(?:lt|le|gt|ge)|\w*(?:EXPECT|ASSERT|CHECK|REQUIRE|DCHECK)_(?:LT|LE|GT|GE))\s*\("
897
+ )
898
+ _OPERAND_STOP = frozenset(";,?:{}")
899
+
900
+
901
+ def _left_operand(masked: str, op: int, floor: int) -> str:
902
+ depth = 0
903
+ i = op - 1
904
+ lo = max(floor, op - _COMPARE_WINDOW)
905
+ while i >= lo:
906
+ c = masked[i]
907
+ if c in ")]":
908
+ depth += 1
909
+ elif c in "([":
910
+ if depth == 0:
911
+ break
912
+ depth -= 1
913
+ elif depth == 0 and (c in _OPERAND_STOP or masked[i - 1 : i + 1] in ("&&", "||") or (c == "=" and masked[i - 1 : i] not in ("=", "!", "<", ">"))):
914
+ break
915
+ i -= 1
916
+ text = masked[i + 1 : op]
917
+ return text[text.rfind("return") + 6 :] if "return" in text else text
918
+
919
+
920
+ def _right_operand(masked: str, op_end: int, ceiling: int) -> str:
921
+ depth = 0
922
+ i = op_end
923
+ hi = min(ceiling, op_end + _COMPARE_WINDOW)
924
+ while i < hi:
925
+ c = masked[i]
926
+ if c in "([":
927
+ depth += 1
928
+ elif c in ")]":
929
+ if depth == 0:
930
+ break
931
+ depth -= 1
932
+ elif depth == 0 and (c in _OPERAND_STOP or masked[i : i + 2] in ("&&", "||")):
933
+ break
934
+ i += 1
935
+ return masked[op_end:i]
936
+
937
+
938
+ def _operand_names(text: str) -> list[str]:
939
+ stripped = _SIZEOF_RE.sub(" ", text)
940
+ out = []
941
+ for match in _IDENT_RE.finditer(stripped):
942
+ before = stripped[max(0, match.start() - 2) : match.start()]
943
+ if not (before.endswith(".") or before == "->"):
944
+ out.append(match.group(0))
945
+ return out
946
+
947
+
948
+ def _is_trivial_bound(text: str) -> bool:
949
+ return len(text) <= _TRIVIAL_BOUND_MAX and _TRIVIAL_BOUND_RE.match(text) is not None
950
+
951
+
952
+ def _bound_entries(masked: str, op: re.Match[str], floor: int, ceiling: int) -> list[tuple[str, int]]:
953
+ left = _left_operand(masked, op.start(), floor)
954
+ right = _right_operand(masked, op.end(), ceiling)
955
+ out: list[tuple[str, int]] = []
956
+ if right.strip() and not _is_trivial_bound(right):
957
+ out.extend((name, op.start()) for name in _operand_names(left))
958
+ if left.strip() and not _is_trivial_bound(left):
959
+ out.extend((name, op.start()) for name in _operand_names(right))
960
+ return out
961
+
962
+
963
+ def _check_call_entries(masked: str, match: re.Match[str]) -> list[tuple[str, int]]:
964
+ parsed = _call_args(masked, match.end() - 1)
965
+ if parsed is None or len(parsed[0]) < 2:
966
+ return []
967
+ (a_lo, a_hi), (b_lo, b_hi) = parsed[0][0], parsed[0][1]
968
+ first, second = masked[a_lo:a_hi], masked[b_lo:b_hi]
969
+ out: list[tuple[str, int]] = []
970
+ if not _is_trivial_bound(second):
971
+ out.extend((n, match.start()) for n in _operand_names(first))
972
+ if not _is_trivial_bound(first):
973
+ out.extend((n, match.start()) for n in _operand_names(second))
974
+ return out
975
+
976
+
977
+ # ---------- Scope analysis ----------
978
+
979
+
980
+ @dataclass
981
+ class _Hit:
982
+ kind: str
983
+ pos: int
984
+ taint: _Taint | None
985
+ traced_direct: bool = False
986
+
987
+
988
+ @dataclass
989
+ class _ScopeData:
990
+ """Everything one pass over the file found inside one function/lambda
991
+ (its own body only; nested lambdas have their own)."""
992
+
993
+ events: list[tuple[int, int, re.Match[str]]] = field(default_factory=list)
994
+ int_vars: set[str] = field(default_factory=set)
995
+ int_decl_positions: dict[str, int] = field(default_factory=dict)
996
+ constants: set[str] = field(default_factory=set)
997
+ array_sizes: dict[str, int] = field(default_factory=dict)
998
+ mg_locals: dict[str, int] = field(default_factory=dict)
999
+ resized: dict[str, list[int]] = field(default_factory=dict)
1000
+ bounds: dict[str, list[int]] = field(default_factory=dict)
1001
+ overflow_guards: list[int] = field(default_factory=list)
1002
+ has_sink: bool = False
1003
+
1004
+
1005
+ def _request_param(decl: str, gates: frozenset[str]) -> bool:
1006
+ """A parameter declared with a known framework request type. Bare names
1007
+ (`Request`, `Rest::Request`, `request<...>`, `shared_ptr<Session>`) count
1008
+ only in a file that uses that framework; `struct Request` never does."""
1009
+ if _STRUCT_REQUEST_RE.search(decl):
1010
+ return _MG_REQUEST_TYPE_RE.search(decl) is not None
1011
+ if _REQUEST_TYPE_RE.search(decl):
1012
+ return True
1013
+ return any(gate in gates and pattern.search(decl) for gate, pattern in _GATED_REQUEST_TYPES)
1014
+
1015
+
1016
+ def _scope_hits(masked: str, orig: str, scope: _Scope, data: _ScopeData, inherited: _ScopeData,
1017
+ gates: frozenset[str]) -> list[_Hit]:
1018
+ state = _State()
1019
+ request_vars: set[str] = set()
1020
+ for piece in _split_top(scope.params, 0, len(scope.params)):
1021
+ decl = scope.params[piece[0] : piece[1]]
1022
+ if _request_param(decl, gates):
1023
+ names = _IDENT_RE.findall(re.sub(r"<[^<>]*>", " ", decl.split("=", 1)[0]))
1024
+ if names:
1025
+ request_vars.add(names[-1])
1026
+ if _MHD_HANDLER_RE.search(scope.params):
1027
+ request_vars |= scope.param_names & _MHD_REQUEST_PARAMS
1028
+ if scope.name == "ENDPOINT":
1029
+ request_vars |= set(_OATPP_PARAM_RE.findall(scope.params_orig))
1030
+ header_line = line_of(orig, scope.start)
1031
+ request_lines = {name: header_line for name in request_vars}
1032
+ for name, line in data.mg_locals.items():
1033
+ request_lines.setdefault(name, line)
1034
+
1035
+ int_vars = set(_param_int_vars(scope.params)) | data.int_vars | inherited.int_vars
1036
+ state.resized = data.resized
1037
+ ctx = _Ctx(
1038
+ masked=masked,
1039
+ orig=orig,
1040
+ scope=scope,
1041
+ state=state,
1042
+ request_vars=request_lines,
1043
+ argv_scope="argv" in scope.param_names or bool(_ARGV_PARAM_RE.search(scope.params)),
1044
+ int_vars=int_vars,
1045
+ constants=data.constants | inherited.constants,
1046
+ int_decl_positions=data.int_decl_positions,
1047
+ array_sizes={**inherited.array_sizes, **data.array_sizes},
1048
+ bounds=data.bounds,
1049
+ overflow_guards=data.overflow_guards,
1050
+ )
1051
+ hits: list[_Hit] = []
1052
+ for _pos, etype, match in data.events:
1053
+ if etype == 0:
1054
+ _on_assign(ctx, match)
1055
+ elif etype == 1:
1056
+ hits.extend(_on_call(ctx, match))
1057
+ elif etype == 2:
1058
+ state.add(match.group(1), match.end(), _Taint("stdin", line_of(orig, match.start())))
1059
+ elif etype == 4:
1060
+ _on_string_ctor(ctx, match)
1061
+ else:
1062
+ hit = _on_new_array(ctx, match)
1063
+ if hit is not None:
1064
+ hits.append(hit)
1065
+ return hits
1066
+
1067
+
1068
+ def _param_int_vars(params: str) -> list[str]:
1069
+ return [m.group(1) for m in _INT_DECL_RE.finditer(params)]
1070
+
1071
+
1072
+ def _rhs_span(masked: str, lo: int, hi_limit: int) -> int:
1073
+ depth = 0
1074
+ end = min(hi_limit, lo + _MAX_RHS_SPAN)
1075
+ for i in range(lo, end):
1076
+ c = masked[i]
1077
+ if c in "([{":
1078
+ depth += 1
1079
+ elif c in ")]}":
1080
+ if depth == 0:
1081
+ return i
1082
+ depth -= 1
1083
+ elif c in ",;" and depth == 0:
1084
+ return i
1085
+ return end
1086
+
1087
+
1088
+ def _on_assign(ctx: _Ctx, match: re.Match[str]) -> None:
1089
+ lhs = match.group(1)
1090
+ name = _IDENT_RE.match(lhs).group(0) # type: ignore[union-attr]
1091
+ if name in _CONTROL_WORDS or name in ("operator",):
1092
+ return
1093
+ rhs_lo = match.end()
1094
+ rhs_hi = _rhs_span(ctx.masked, rhs_lo, ctx.scope.end)
1095
+ rhs = ctx.masked[rhs_lo:rhs_hi]
1096
+ stripped = rhs.strip()
1097
+ # Constant-only assignment tracking (for format / command arguments). An
1098
+ # element write (`fmt[2] = '0' + n`) doesn't make a literal non-constant.
1099
+ indexed = "[" in ctx.masked[match.end(1) : match.end()]
1100
+ if stripped and not indexed:
1101
+ is_const = _is_constant_expr(ctx, rhs_lo, rhs_hi)
1102
+ previous = ctx.state.constant_assigned.get(name)
1103
+ ctx.state.constant_assigned[name] = is_const if previous is None else (previous and is_const)
1104
+ if is_const:
1105
+ ctx.state.clear(name, rhs_lo) # `u = "default";` drops earlier input
1106
+ return
1107
+ alloc = _ALLOC_ASSIGN_RE.match(ctx.masked, match.start())
1108
+ if alloc is not None:
1109
+ ctx.state.allocs.setdefault(name, []).append((match.end(), rhs))
1110
+ # The return value of a read is a byte count bounded by the caller's
1111
+ # buffer, not attacker data (the buffer itself is tainted by the call).
1112
+ call = _CALL_RE.match(_LEADING_CAST_RE.sub("", stripped, count=1))
1113
+ if call is not None and call.group(1) in _OUT_PARAM_SOURCES and call.group(1) not in ("gets",):
1114
+ return
1115
+ if _is_clamp_expr(rhs):
1116
+ ctx.state.clamped.setdefault(name, []).append(rhs_lo)
1117
+ return
1118
+ numeric_rhs = _is_numeric_expr(ctx, rhs)
1119
+ if numeric_rhs:
1120
+ measured = _expr_names(ctx, rhs_lo, rhs_hi) - {name}
1121
+ if measured:
1122
+ ctx.state.measure(name, measured)
1123
+ taint = _expr_taint(ctx, rhs_lo, rhs_hi, want_string=False)
1124
+ if taint is None:
1125
+ return
1126
+ numeric = taint.numeric or name in ctx.int_vars or numeric_rhs
1127
+ ctx.state.add(name, rhs_lo, _Taint(taint.kind, taint.line, numeric, taint.bounded, taint.max_len))
1128
+
1129
+
1130
+ def _on_string_ctor(ctx: _Ctx, match: re.Match[str]) -> None:
1131
+ open_index = match.end() - 1
1132
+ close_char = ")" if ctx.masked[open_index] == "(" else "}"
1133
+ depth = 0
1134
+ for i in range(open_index, min(len(ctx.masked), open_index + _MAX_RHS_SPAN)):
1135
+ c = ctx.masked[i]
1136
+ if c in "({[":
1137
+ depth += 1
1138
+ elif c in ")}]":
1139
+ depth -= 1
1140
+ if depth == 0:
1141
+ if c != close_char:
1142
+ return
1143
+ taint = _expr_taint(ctx, open_index + 1, i, want_string=True)
1144
+ if taint is not None:
1145
+ ctx.state.add(match.group(1), i, taint)
1146
+ return
1147
+
1148
+
1149
+ def _resolve_kind(spec: str, ctx: _Ctx, args: list[tuple[int, int]]) -> str | None:
1150
+ if ":" not in spec:
1151
+ return spec
1152
+ resolver, index_text = spec.split(":", 1)
1153
+ index = int(index_text)
1154
+ if index >= len(args):
1155
+ return None
1156
+ arg = ctx.masked[args[index][0] : args[index][1]]
1157
+ if _STDIN_ARG_RE.match(arg):
1158
+ return "stdin"
1159
+ if resolver == "fd" and _SOCKET_NAME_RE.search(arg):
1160
+ return "network"
1161
+ return "file"
1162
+
1163
+
1164
+ def _on_call(ctx: _Ctx, match: re.Match[str]) -> list[_Hit]:
1165
+ name = match.group(1)
1166
+ if _is_definition(ctx.masked, match.start()):
1167
+ return []
1168
+ parsed = _call_args(ctx.masked, match.end() - 1)
1169
+ if parsed is None:
1170
+ return []
1171
+ args, _close = parsed
1172
+ pos = match.start()
1173
+ hits: list[_Hit] = []
1174
+
1175
+ if name == "gets" and len(args) == 1:
1176
+ hits.append(_Hit("unbounded_copy", pos, _Taint("stdin", line_of(ctx.orig, pos)), traced_direct=True))
1177
+ if name in _UNBOUNDED_COPY and len(args) == 2:
1178
+ hit = _check_strcpy(ctx, args, pos)
1179
+ if hit is not None:
1180
+ hits.append(hit)
1181
+ if name in _SPRINTF:
1182
+ hit = _check_sprintf(ctx, args, _SPRINTF[name], pos)
1183
+ if hit is not None:
1184
+ hits.append(hit)
1185
+ if name in _SCANF:
1186
+ hit = _check_scanf(ctx, name, args, pos)
1187
+ if hit is not None:
1188
+ hits.append(hit)
1189
+ if name in _FORMAT_FUNCS:
1190
+ hit = _check_format(ctx, name, args, pos)
1191
+ if hit is not None:
1192
+ hits.append(hit)
1193
+ if name in _SHELL_COMMAND:
1194
+ hit = _check_shell(ctx, args, _SHELL_COMMAND[name], pos)
1195
+ if hit is not None:
1196
+ hits.append(hit)
1197
+ if name in _EXEC:
1198
+ hit = _check_exec(ctx, name, args, pos)
1199
+ if hit is not None:
1200
+ hits.append(hit)
1201
+ if name in _LENGTH_COPY:
1202
+ hit = _check_length_copy(ctx, args, _LENGTH_COPY[name], pos)
1203
+ if hit is not None:
1204
+ hits.append(hit)
1205
+ if name in _ALLOC:
1206
+ index = _ALLOC[name]
1207
+ if index < len(args):
1208
+ hit = _check_alloc(ctx, args[index], pos)
1209
+ if hit is not None:
1210
+ hits.append(hit)
1211
+
1212
+ # Taint effects of the call, visible after it.
1213
+ after = match.end()
1214
+ if name in _OUT_PARAM_SOURCES:
1215
+ dst_index, spec = _OUT_PARAM_SOURCES[name]
1216
+ if dst_index < len(args):
1217
+ kind = _resolve_kind(spec, ctx, args)
1218
+ dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
1219
+ if dst is not None:
1220
+ ctx.state.write(dst, after)
1221
+ if kind is not None and dst is not None:
1222
+ # Every read here but `gets` takes a size: the result is
1223
+ # bounded by the destination.
1224
+ bounded = name != "gets"
1225
+ ctx.state.add(dst, after, _Taint(kind, line_of(ctx.orig, pos), False, bounded, ctx.array_sizes.get(dst)))
1226
+ if name in _PROPAGATORS:
1227
+ dst_index, src_index, rest = _PROPAGATORS[name]
1228
+ if dst_index < len(args) and src_index < len(args):
1229
+ src_spans = args[src_index:] if rest else [args[src_index]]
1230
+ taint: _Taint | None = None
1231
+ for lo, hi in src_spans:
1232
+ taint = _better(taint, _expr_taint(ctx, lo, hi, want_string=True))
1233
+ dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
1234
+ if dst is not None:
1235
+ ctx.state.write(dst, after)
1236
+ if taint is not None and dst is not None:
1237
+ if name in _BOUNDED_PROPAGATORS:
1238
+ ctx.state.add(dst, after, _Taint(taint.kind, taint.line, False, True, ctx.array_sizes.get(dst)))
1239
+ else:
1240
+ ctx.state.add(dst, after, _Taint(taint.kind, taint.line, False, taint.bounded, taint.max_len))
1241
+ if name in _SCANF and len(args) > _SCANF[name] + 1:
1242
+ for lo, hi in args[_SCANF[name] + 1 :]:
1243
+ dst = _base_ident(ctx.masked[lo:hi])
1244
+ if dst is not None:
1245
+ ctx.state.write(dst, after)
1246
+ if name in ("sscanf", "swscanf"):
1247
+ source = _expr_taint(ctx, args[0][0], args[0][1], want_string=True)
1248
+ else:
1249
+ kind = "stdin" if name in ("scanf", "wscanf") else _resolve_kind("stream:0", ctx, args)
1250
+ source = _Taint(kind, line_of(ctx.orig, pos)) if kind is not None else None
1251
+ if source is not None:
1252
+ _taint_scanf_outputs(ctx, args, _SCANF[name], source, after)
1253
+ return hits
1254
+
1255
+
1256
+ def _taint_scanf_outputs(ctx: _Ctx, args: list[tuple[int, int]], fmt_index: int, source: _Taint, after: int) -> None:
1257
+ """Taint each scanf destination with the source, numeric for numeric
1258
+ conversions and bounded for a width-limited ``%s``/``%[``."""
1259
+ fmt = _format_literal(ctx, args[fmt_index])
1260
+ convs = [c for c in _SCANF_CONVERSION_RE.finditer(fmt) if c.group(0) != "%%" and not c.group("suppress")] if fmt else []
1261
+ for i, (lo, hi) in enumerate(args[fmt_index + 1 :]):
1262
+ dst = _base_ident(ctx.masked[lo:hi])
1263
+ if dst is None:
1264
+ continue
1265
+ conv = convs[i] if i < len(convs) else None
1266
+ if conv is None:
1267
+ taint = _Taint(source.kind, source.line, dst in ctx.int_vars, source.bounded, source.max_len)
1268
+ elif conv.group("conv") == "s" or conv.group("conv").startswith("["):
1269
+ width = conv.group("width")
1270
+ taint = _Taint(source.kind, source.line, False, width is not None or source.bounded,
1271
+ int(width) + 1 if width else source.max_len)
1272
+ else:
1273
+ taint = _Taint(source.kind, source.line, True)
1274
+ ctx.state.add(dst, after, taint)
1275
+
1276
+
1277
+ def _is_definition(masked: str, start: int) -> bool:
1278
+ """``int printf(const char *fmt, ...)``: a declaration, not a call."""
1279
+ i = _skip_ws_back(masked, start - 1)
1280
+ if i < 0:
1281
+ return False
1282
+ c = masked[i]
1283
+ if c == "*" or c == "&":
1284
+ j = _skip_ws_back(masked, i - 1)
1285
+ return j >= 0 and (masked[j].isalnum() or masked[j] == "_")
1286
+ if c.isalnum() or c == "_":
1287
+ word = _WORD_BEFORE_RE.search(masked[max(0, i - 40) : i + 1])
1288
+ return word is not None and word.group(1) not in _CONTROL_WORDS and word.group(1) not in ("else", "new", "delete", "co_await", "co_return", "throw")
1289
+ return False
1290
+
1291
+
1292
+ def _string_taint(ctx: _Ctx, span: tuple[int, int]) -> _Taint | None:
1293
+ return _expr_taint(ctx, span[0], span[1], want_string=True)
1294
+
1295
+
1296
+ def _direct(ctx: _Ctx, span: tuple[int, int]) -> bool:
1297
+ text = ctx.masked[span[0] : span[1]]
1298
+ return any(p.search(text) for _, p in _DIRECT_SOURCES) or (ctx.argv_scope and bool(_ARGV_USE_RE.search(text)))
1299
+
1300
+
1301
+ def _check_strcpy(ctx: _Ctx, args: list[tuple[int, int]], pos: int) -> _Hit | None:
1302
+ if _is_constant_expr(ctx, *args[1]):
1303
+ return None
1304
+ taint = _string_taint(ctx, args[1])
1305
+ if taint is None:
1306
+ return None
1307
+ src_names = _expr_names(ctx, *args[1])
1308
+ dst = _base_ident(ctx.masked[args[0][0] : args[0][1]])
1309
+ if not _overflows(ctx, taint, dst, _base_ident(ctx.masked[args[1][0] : args[1][1]])):
1310
+ return None
1311
+ if _compared_before(ctx, src_names, pos) or _sized_by(ctx, dst, src_names, pos):
1312
+ return None
1313
+ return _Hit("unbounded_copy", pos, taint, _direct(ctx, args[1]))
1314
+
1315
+
1316
+ def _overflows(ctx: _Ctx, taint: _Taint, dst: str | None, src: str | None = None) -> bool:
1317
+ """Can a copy of ``taint`` overflow ``dst``? Not when both are declared
1318
+ arrays and ``dst`` is at least as large as ``src``. Otherwise an
1319
+ unbounded string can; a bounded one only into a declared array smaller
1320
+ than its cap."""
1321
+ size = ctx.array_sizes.get(dst) if dst else None
1322
+ src_size = ctx.array_sizes.get(src) if src else None
1323
+ if size is not None and src_size is not None and size >= src_size:
1324
+ return False
1325
+ if not taint.bounded:
1326
+ return True
1327
+ return size is not None and taint.max_len is not None and size < taint.max_len
1328
+
1329
+
1330
+ def _format_literal(ctx: _Ctx, span: tuple[int, int]) -> str | None:
1331
+ """The concatenated literal text of a constant format argument; macro
1332
+ pieces between literals (``"%" PRIu64``) become an integer conversion."""
1333
+ masked = ctx.masked[span[0] : span[1]]
1334
+ if not _LITERAL_START_RE.match(masked):
1335
+ return None
1336
+ pieces: list[str] = []
1337
+ i = 0
1338
+ text = ctx.orig[span[0] : span[1]]
1339
+ while i < len(masked):
1340
+ if masked[i] == '"':
1341
+ j = masked.find('"', i + 1)
1342
+ if j < 0:
1343
+ return None
1344
+ pieces.append(text[i + 1 : j])
1345
+ i = j + 1
1346
+ elif masked[i].isalpha() or masked[i] == "_":
1347
+ ident = _IDENT_RE.match(masked, i)
1348
+ if ident is None:
1349
+ break
1350
+ if ident.group(0) not in ("u8", "u", "U", "L"):
1351
+ pieces.append("d")
1352
+ i = ident.end()
1353
+ else:
1354
+ i += 1
1355
+ return "".join(pieces)
1356
+
1357
+
1358
+ def _check_sprintf(ctx: _Ctx, args: list[tuple[int, int]], fmt_index: int, pos: int) -> _Hit | None:
1359
+ if fmt_index >= len(args):
1360
+ return None
1361
+ fmt = _format_literal(ctx, args[fmt_index])
1362
+ if fmt is None:
1363
+ return None # non-constant format: reported by the format-string check
1364
+ dst = _base_ident(ctx.masked[args[0][0] : args[0][1]])
1365
+ arg_index = fmt_index + 1
1366
+ best: _Hit | None = None
1367
+ for conv in _CONVERSION_RE.finditer(fmt):
1368
+ if conv.group(0) == "%%":
1369
+ continue
1370
+ if conv.group("width") == "*":
1371
+ arg_index += 1
1372
+ if conv.group("prec") == "*":
1373
+ arg_index += 1
1374
+ if conv.group("conv") in ("s", "S") and conv.group("prec") is None and arg_index < len(args):
1375
+ span = args[arg_index]
1376
+ taint = _string_taint(ctx, span)
1377
+ names = _expr_names(ctx, *span)
1378
+ if (
1379
+ taint is not None
1380
+ and _overflows(ctx, taint, dst, _base_ident(ctx.masked[span[0] : span[1]]))
1381
+ and not _compared_before(ctx, names, pos)
1382
+ and not _sized_by(ctx, dst, names, pos)
1383
+ and (best is None or _rank(taint) > _rank(best.taint)) # type: ignore[arg-type]
1384
+ ):
1385
+ best = _Hit("unbounded_copy", pos, taint, _direct(ctx, span))
1386
+ arg_index += 1
1387
+ return best
1388
+
1389
+
1390
+ def _check_scanf(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
1391
+ fmt_index = _SCANF[name]
1392
+ if fmt_index >= len(args):
1393
+ return None
1394
+ fmt = _format_literal(ctx, args[fmt_index])
1395
+ if fmt is None:
1396
+ return None
1397
+ unbounded = any(
1398
+ c.group(0) != "%%"
1399
+ and not c.group("suppress")
1400
+ and not c.group("width")
1401
+ and not c.group("alloc")
1402
+ and (c.group("conv") == "s" or c.group("conv").startswith("["))
1403
+ for c in _SCANF_CONVERSION_RE.finditer(fmt)
1404
+ )
1405
+ if not unbounded:
1406
+ return None
1407
+ line = line_of(ctx.orig, pos)
1408
+ if name in ("scanf", "wscanf"):
1409
+ return _Hit("unbounded_copy", pos, _Taint("stdin", line), True)
1410
+ if name in ("fscanf", "fwscanf"):
1411
+ kind = _resolve_kind("stream:0", ctx, args) or "file"
1412
+ return _Hit("unbounded_copy", pos, _Taint(kind, line), True)
1413
+ taint = _string_taint(ctx, args[0])
1414
+ if taint is None:
1415
+ return None
1416
+ return _Hit("unbounded_copy", pos, taint, _direct(ctx, args[0]))
1417
+
1418
+
1419
+ def _check_format(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
1420
+ index = _FORMAT_FUNCS[name]
1421
+ if index >= len(args):
1422
+ return None
1423
+ span = args[index]
1424
+ if _is_constant_expr(ctx, *span):
1425
+ return None
1426
+ base = _base_ident(ctx.masked[span[0] : span[1]])
1427
+ variadic = "..." in ctx.scope.params or "va_list" in ctx.scope.params
1428
+ if base is not None and base in ctx.scope.param_names and (variadic or name in _VA_FORMAT_FUNCS):
1429
+ return None # a format wrapper forwarding its caller's format
1430
+ if name in _VA_FORMAT_FUNCS and variadic:
1431
+ return None
1432
+ taint = _string_taint(ctx, span)
1433
+ if taint is None and _is_call_result(ctx, span):
1434
+ return None # e.g. a function returning a constant format
1435
+ return _Hit("format_string", pos, taint, taint is not None and _direct(ctx, span))
1436
+
1437
+
1438
+ def _is_call_result(ctx: _Ctx, span: tuple[int, int]) -> bool:
1439
+ text = ctx.masked[span[0] : span[1]].strip()
1440
+ return text.endswith(")") and not text.startswith("(") and _DATA_CALL_RE.search(text) is None
1441
+
1442
+
1443
+ def _check_shell(ctx: _Ctx, args: list[tuple[int, int]], index: int, pos: int) -> _Hit | None:
1444
+ if index >= len(args) or _is_constant_expr(ctx, *args[index]):
1445
+ return None
1446
+ if not ctx.masked[args[index][0] : args[index][1]].strip():
1447
+ return None
1448
+ taint = _string_taint(ctx, args[index])
1449
+ if taint is None and _is_call_result(ctx, args[index]):
1450
+ return None
1451
+ return _Hit("native_command_exec", pos, taint, taint is not None and _direct(ctx, args[index]))
1452
+
1453
+
1454
+ def _check_exec(ctx: _Ctx, name: str, args: list[tuple[int, int]], pos: int) -> _Hit | None:
1455
+ taint: _Taint | None = None
1456
+ direct = False
1457
+ for span in args:
1458
+ t = _string_taint(ctx, span)
1459
+ if t is not None and _better(taint, t) is t:
1460
+ taint, direct = t, _direct(ctx, span)
1461
+ if taint is not None:
1462
+ return _Hit("native_command_exec", pos, taint, direct)
1463
+ # An untraced but non-constant `sh -c <cmd>` line.
1464
+ if name.startswith("execl"):
1465
+ for i, span in enumerate(args[:-1]):
1466
+ if _SH_C_RE.match(ctx.orig[span[0] : span[1]]):
1467
+ cmd = args[i + 1]
1468
+ if not _is_constant_expr(ctx, *cmd) and ctx.masked[cmd[0] : cmd[1]].strip() not in ("NULL", "0", "nullptr"):
1469
+ return _Hit("native_command_exec", pos, None)
1470
+ return None
1471
+
1472
+
1473
+ def _check_length_copy(ctx: _Ctx, args: list[tuple[int, int]], spec: tuple[int, int, int], pos: int) -> _Hit | None:
1474
+ dst_index, _src_index, len_index = spec
1475
+ if len_index >= len(args):
1476
+ return None
1477
+ span = args[len_index]
1478
+ text = ctx.masked[span[0] : span[1]]
1479
+ if _is_clamp_expr(text):
1480
+ return None # `n > sizeof b ? sizeof b : n`
1481
+ taint = _expr_taint(ctx, span[0], span[1], want_string=False)
1482
+ if taint is None:
1483
+ return None
1484
+ names = _expr_names(ctx, *span)
1485
+ dst = _base_ident(ctx.masked[args[dst_index][0] : args[dst_index][1]])
1486
+ if _compared_before(ctx, names, pos) or _clamped(ctx, names, pos) or _sized_by(ctx, dst, names, pos):
1487
+ return None
1488
+ return _Hit("tainted_copy_length", pos, taint, _direct(ctx, span))
1489
+
1490
+
1491
+ def _product_terms(masked: str, lo: int, hi: int) -> list[tuple[int, int]]:
1492
+ """Top-level ``+``/``-`` terms of ``masked[lo:hi]`` that multiply or shift."""
1493
+ terms: list[tuple[int, int]] = []
1494
+ depth = 0
1495
+ start = lo
1496
+ for i in range(lo, hi + 1):
1497
+ c = masked[i] if i < hi else "+"
1498
+ if c in "([{":
1499
+ depth += 1
1500
+ elif c in ")]}":
1501
+ depth -= 1
1502
+ elif depth == 0 and c in "+-" and masked[i + 1 : i + 2] not in ("+", "-", ">", "=") and masked[i - 1 : i] not in ("+", "-"):
1503
+ if _MUL_RE.search(masked, start, i):
1504
+ terms.append((start, i))
1505
+ start = i + 1
1506
+ return terms
1507
+
1508
+
1509
+ def _check_alloc(ctx: _Ctx, span: tuple[int, int], pos: int) -> _Hit | None:
1510
+ terms = _product_terms(ctx.masked, span[0], span[1])
1511
+ if not terms:
1512
+ return None
1513
+ if ctx.overflow_guards and ctx.overflow_guards[0] < pos:
1514
+ return None
1515
+ taint: _Taint | None = None
1516
+ names: set[str] = set()
1517
+ direct = False
1518
+ for lo, hi in terms:
1519
+ t = _expr_taint(ctx, lo, hi, want_string=False)
1520
+ if t is not None:
1521
+ if _better(taint, t) is t:
1522
+ direct = _direct(ctx, (lo, hi))
1523
+ taint = _better(taint, t)
1524
+ names |= _expr_names(ctx, lo, hi)
1525
+ if taint is None:
1526
+ return None
1527
+ if _compared_before(ctx, names, pos) or _clamped(ctx, names, pos):
1528
+ return None
1529
+ return _Hit("alloc_size_overflow", pos, taint, direct)
1530
+
1531
+
1532
+ def _on_new_array(ctx: _Ctx, match: re.Match[str]) -> _Hit | None:
1533
+ open_bracket = match.end() - 1
1534
+ depth = 0
1535
+ hi = min(len(ctx.masked), open_bracket + 400)
1536
+ for i in range(open_bracket, hi):
1537
+ c = ctx.masked[i]
1538
+ if c == "[":
1539
+ depth += 1
1540
+ elif c == "]":
1541
+ depth -= 1
1542
+ if depth == 0:
1543
+ return _check_alloc(ctx, (open_bracket + 1, i), match.start())
1544
+ return None
1545
+
1546
+
1547
+ # ---------- Public entry point ----------
1548
+
1549
+
1550
+ def _severity(taint: _Taint | None, downranked: bool) -> tuple[str, str]:
1551
+ if downranked:
1552
+ return "low", "low"
1553
+ if taint is None:
1554
+ return "low", "low"
1555
+ if taint.kind in _REMOTE:
1556
+ return "high", "high"
1557
+ return "medium", "medium"
1558
+
1559
+
1560
+ def _snippet(content: str, offset: int) -> str:
1561
+ line_start = content.rfind("\n", 0, offset) + 1
1562
+ line_end = content.find("\n", offset)
1563
+ if line_end == -1:
1564
+ line_end = len(content)
1565
+ line = content[line_start:line_end].strip()
1566
+ if len(line) > _SNIPPET_MAX_CHARS:
1567
+ line = line[: _SNIPPET_MAX_CHARS - 1] + "…"
1568
+ return line
1569
+
1570
+
1571
+ def _owner_index(scopes: list[_Scope], starts: list[int], pos: int) -> int | None:
1572
+ """Index of the innermost scope containing ``pos``: walking back from the
1573
+ last scope that starts before it, the first one still open at ``pos``."""
1574
+ i = bisect.bisect_left(starts, pos) - 1
1575
+ for _ in range(_MAX_OWNER_WALK):
1576
+ if i < 0:
1577
+ return None
1578
+ if scopes[i].end > pos:
1579
+ return i
1580
+ i -= 1
1581
+ return None
1582
+
1583
+
1584
+ def _scope_data(masked: str, orig: str, scopes: list[_Scope]) -> list[_ScopeData]:
1585
+ """One pass per pattern over the whole file, each match filed under its
1586
+ innermost scope, so nested lambdas aren't rescanned by their parents."""
1587
+ starts = [s.start for s in scopes]
1588
+ data = [_ScopeData() for _ in scopes]
1589
+
1590
+ def owner(pos: int) -> _ScopeData | None:
1591
+ index = _owner_index(scopes, starts, pos)
1592
+ return None if index is None else data[index]
1593
+
1594
+ def owner_index(pos: int) -> int | None:
1595
+ return _owner_index(scopes, starts, pos)
1596
+
1597
+ for match in _ASSIGN_RE.finditer(masked):
1598
+ d = owner(match.end() - 1)
1599
+ if d is not None:
1600
+ d.events.append((match.end() - 1, 0, match))
1601
+ for match in _CALL_RE.finditer(masked):
1602
+ d = owner(match.start())
1603
+ if d is not None:
1604
+ d.events.append((match.start(), 1, match))
1605
+ d.has_sink = d.has_sink or match.group(1) in _SINK_CALLS
1606
+ for pattern in (_CIN_RE, _GETLINE_CIN_RE):
1607
+ for match in pattern.finditer(masked):
1608
+ d = owner(match.start())
1609
+ if d is not None:
1610
+ d.events.append((match.start(), 2, match))
1611
+ for match in _NEW_ARRAY_RE.finditer(masked):
1612
+ d = owner(match.start())
1613
+ if d is not None:
1614
+ d.events.append((match.start(), 3, match))
1615
+ d.has_sink = True
1616
+ for match in _STRING_CTOR_DECL_RE.finditer(masked):
1617
+ d = owner(match.end() - 1)
1618
+ if d is not None:
1619
+ d.events.append((match.end() - 1, 4, match))
1620
+ for match in _RESIZE_RE.finditer(masked):
1621
+ d = owner(match.start())
1622
+ if d is not None:
1623
+ d.resized.setdefault(match.group(1), []).append(match.start())
1624
+ for match in _INT_DECL_RE.finditer(masked):
1625
+ d = owner(match.start())
1626
+ if d is not None:
1627
+ d.int_vars.add(match.group(1))
1628
+ d.int_decl_positions.setdefault(match.group(1), match.start())
1629
+ for match in _DECL_STRING_LITERAL_RE.finditer(masked):
1630
+ d = owner(match.start())
1631
+ if d is not None:
1632
+ d.constants.add(match.group(1))
1633
+ for match in _ARRAY_DECL_START_RE.finditer(masked):
1634
+ d = owner(match.start())
1635
+ if d is None:
1636
+ continue
1637
+ end = masked.find(";", match.end(), match.end() + 400)
1638
+ if end > 0:
1639
+ for decl in _ARRAY_DECLARATOR_RE.finditer(masked[match.end() : end]):
1640
+ d.array_sizes.setdefault(decl.group(1), int(decl.group(2)))
1641
+ for match in _MG_LOCAL_DECL_RE.finditer(masked):
1642
+ d = owner(match.start())
1643
+ if d is not None:
1644
+ d.mg_locals.setdefault(match.group(1), line_of(orig, match.start()))
1645
+ for match in _OVERFLOW_GUARD_RE.finditer(masked):
1646
+ d = owner(match.start())
1647
+ if d is not None:
1648
+ d.overflow_guards.append(match.start())
1649
+ template_brackets: set[int] = set()
1650
+ for match in _TEMPLATE_RE.finditer(masked):
1651
+ template_brackets.add(masked.index("<", match.start()))
1652
+ template_brackets.add(match.end() - 1)
1653
+ for match in _COMPARE_OP_RE.finditer(masked):
1654
+ if match.start() in template_brackets:
1655
+ continue
1656
+ index = owner_index(match.start())
1657
+ if index is None:
1658
+ continue
1659
+ scope = scopes[index]
1660
+ for name, pos in _bound_entries(masked, match, scope.start, scope.end):
1661
+ data[index].bounds.setdefault(name, []).append(pos)
1662
+ for match in _BOUND_CHECK_CALL_RE.finditer(masked):
1663
+ d = owner(match.start())
1664
+ if d is not None:
1665
+ for name, pos in _check_call_entries(masked, match):
1666
+ d.bounds.setdefault(name, []).append(pos)
1667
+ for d in data:
1668
+ d.events.sort(key=lambda e: (e[0], e[1]))
1669
+ for positions in d.bounds.values():
1670
+ positions.sort()
1671
+ return data
1672
+
1673
+
1674
+ def _inherited(scopes: list[_Scope], data: list[_ScopeData], index: int) -> _ScopeData:
1675
+ """Declarations a lambda sees from its enclosing scopes (a few levels)."""
1676
+ out = _ScopeData()
1677
+ parent = scopes[index].parent
1678
+ for _ in range(_MAX_INHERIT_DEPTH):
1679
+ if parent is None:
1680
+ break
1681
+ d = data[parent]
1682
+ out.int_vars |= d.int_vars
1683
+ out.constants |= d.constants
1684
+ for name, size in d.array_sizes.items():
1685
+ out.array_sizes.setdefault(name, size)
1686
+ parent = scopes[parent].parent
1687
+ return out
1688
+
1689
+
1690
+ def find_native_weaknesses(content: str, relative: str) -> list[CodeWeakness]:
1691
+ """C/C++ memory-safety and command-exec weaknesses in one file."""
1692
+ if CodeWeakness is None or is_test_path(relative):
1693
+ return []
1694
+ masked = mask_source(content)
1695
+ if _CALL_RE.search(masked) is None and _NEW_ARRAY_RE.search(masked) is None:
1696
+ return []
1697
+ file_constants = set(_DEFINE_STRING_RE.findall(content))
1698
+ gates = frozenset(gate for gate, pattern in _FRAMEWORK_GATES if pattern.search(content))
1699
+ downranked = _is_downranked(relative)
1700
+ scopes = _scopes(masked, content)
1701
+ data = _scope_data(masked, content, scopes)
1702
+ # File-scope string constants (`static const char *usage = "..."`).
1703
+ starts = [s.start for s in scopes]
1704
+ for match in _DECL_STRING_LITERAL_RE.finditer(masked):
1705
+ if _owner_index(scopes, starts, match.start()) is None:
1706
+ file_constants.add(match.group(1))
1707
+ out: list[CodeWeakness] = []
1708
+ seen: set[tuple[str, int]] = set()
1709
+ for index, scope in enumerate(scopes):
1710
+ if not data[index].has_sink:
1711
+ continue
1712
+ inherited = _inherited(scopes, data, index)
1713
+ inherited.constants |= file_constants
1714
+ for hit in _scope_hits(masked, content, scope, data[index], inherited, gates):
1715
+ line = line_of(content, hit.pos)
1716
+ key = (hit.kind, line)
1717
+ if key in seen:
1718
+ continue
1719
+ seen.add(key)
1720
+ severity, confidence = _severity(hit.taint, downranked)
1721
+ out.append(
1722
+ CodeWeakness(
1723
+ kind=hit.kind, # type: ignore[arg-type]
1724
+ file=relative,
1725
+ line=line,
1726
+ evidence_text=_snippet(content, hit.pos),
1727
+ severity=severity, # type: ignore[arg-type]
1728
+ confidence=confidence, # type: ignore[arg-type]
1729
+ source_kind=hit.taint.kind if hit.taint else None,
1730
+ source_line=hit.taint.line if hit.taint else None,
1731
+ )
1732
+ )
1733
+ out.sort(key=lambda w: (w.line or 0, w.kind))
1734
+ return out
1735
+
1736
+
1737
+ __all__ = ["find_native_weaknesses", "is_test_path", "mask_source"]