hackscan 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,808 @@
1
+ """Pass 2: intra-procedural taint analysis over candidate sinks.
2
+
3
+ For each function (and the module and class bodies) the statements are interpreted
4
+ abstractly in order. Every variable maps to a `Taint` value; branches are joined, loops
5
+ iterate to a fixpoint. Within a statement, sub-expressions are scanned in evaluation
6
+ order, so `:=` bindings and container mutations take effect exactly where Python
7
+ applies them. When a candidate sink is reached, its argument is evaluated in the current
8
+ environment (joined over every visit).
9
+
10
+ Outcome per candidate (only for classes the engine models, see `TAINT_CLASSES`):
11
+ - provably constant -> suppressed `taint:constant_input`
12
+ - sanitized for the finding class -> suppressed `taint:sanitized`
13
+ - reaches an untrusted source -> confirmed, with a source trace
14
+ - otherwise (parameters, unknown calls, closures, imports) -> stays a candidate
15
+
16
+ Soundness stance: suppression only when *every* path yields constant/sanitized values.
17
+ Mutable values are tracked conservatively: a mutable object that is aliased, mutated,
18
+ passed to an unmodeled call, stored elsewhere or captured by a nested scope may change
19
+ behind the analysis' back, so it is joined with `unknown` at that point.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from collections.abc import Iterable, Iterator
26
+ from dataclasses import dataclass, replace
27
+
28
+ from hackscan.core.models import Evidence, Finding, Status
29
+ from hackscan.core.taxonomy import CMDI, TAINT_CLASSES
30
+ from hackscan.plugins.base import FileContext, Match
31
+ from hackscan.plugins.scopes import COMPREHENSION_TYPES, FUNCTION_TYPES
32
+ from hackscan.taint.sanitizers import QUOTING_SANITIZERS, SANITIZERS
33
+ from hackscan.taint.sources import (
34
+ REQUEST_PARAM_ATTRS,
35
+ REQUEST_PARAM_NAMES,
36
+ SOURCE_NAMES,
37
+ flask_request_source,
38
+ is_route_handler,
39
+ )
40
+
41
+ PRODUCER = "taint"
42
+ CONSTANT_INPUT = "taint:constant_input"
43
+ SANITIZED = "taint:sanitized"
44
+ CONFIRMED_CONFIDENCE_BOOST = 35
45
+ POSIX_QUOTED_CONFIDENCE = 20
46
+ MAX_LOOP_ITERATIONS = 5
47
+
48
+ # Calls that neither mutate nor retain their arguments.
49
+ NON_ESCAPING_CALLS = frozenset(
50
+ {
51
+ "str",
52
+ "repr",
53
+ "format",
54
+ "ascii",
55
+ "bytes",
56
+ "len",
57
+ "print",
58
+ "isinstance",
59
+ "id",
60
+ "hash",
61
+ "bool",
62
+ "int",
63
+ "float",
64
+ "sorted",
65
+ "any",
66
+ "all",
67
+ "min",
68
+ "max",
69
+ "sum",
70
+ "tuple",
71
+ "list",
72
+ "set",
73
+ "frozenset",
74
+ "dict",
75
+ }
76
+ )
77
+ # Builtins whose result is derived only from their arguments.
78
+ PURE_BUILTINS = frozenset(
79
+ {"str", "repr", "format", "ascii", "bytes", "tuple", "sorted", "frozenset"}
80
+ )
81
+ # Builtins returning a fresh mutable container built from their arguments.
82
+ MUTABLE_BUILTINS = frozenset({"list", "dict", "set", "bytearray"})
83
+ # String methods: result is a new immutable string derived from receiver and args.
84
+ STRING_METHODS = frozenset(
85
+ {
86
+ "join",
87
+ "format",
88
+ "format_map",
89
+ "strip",
90
+ "lstrip",
91
+ "rstrip",
92
+ "lower",
93
+ "upper",
94
+ "title",
95
+ "capitalize",
96
+ "casefold",
97
+ "replace",
98
+ "encode",
99
+ "decode",
100
+ "removeprefix",
101
+ "removesuffix",
102
+ "zfill",
103
+ "center",
104
+ "ljust",
105
+ "rjust",
106
+ "expandtabs",
107
+ "swapcase",
108
+ }
109
+ )
110
+ # String-building methods whose receiver is a template (quote context applies).
111
+ TEMPLATE_METHODS = frozenset({"join", "format", "format_map"})
112
+ # Methods on a mutable receiver known not to modify it.
113
+ READONLY_METHODS = frozenset(
114
+ {"count", "index", "copy", "get", "keys", "values", "items", "__len__", "__contains__"}
115
+ )
116
+ SHELL_QUOTE_CHARS = frozenset("'\"`")
117
+
118
+
119
+ @dataclass(frozen=True, order=True)
120
+ class Source:
121
+ line: int
122
+ description: str
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class Taint:
127
+ sources: frozenset[Source] = frozenset()
128
+ unknown: bool = False
129
+ safe_for: frozenset[str] = TAINT_CLASSES
130
+ # May refer to a mutable object (list/dict/set/...), which can change in place.
131
+ mutable: bool = False
132
+ # Text is known not to open a shell quote context: literals without quote characters,
133
+ # numbers, or output of a quoting sanitizer (which is self-balanced).
134
+ shell_plain: bool = True
135
+ # Command-injection safety relies on a quoting sanitizer (context-sensitive).
136
+ quote_sanitized: bool = False
137
+
138
+ @property
139
+ def is_constant(self) -> bool:
140
+ return not self.sources and not self.unknown
141
+
142
+
143
+ CONST = Taint()
144
+ UNKNOWN = Taint(unknown=True, safe_for=frozenset(), mutable=True, shell_plain=False)
145
+
146
+
147
+ def combine(values: Iterable[Taint]) -> Taint:
148
+ """Value derived from all of `values` (also the join of alternative paths)."""
149
+ sources: set[Source] = set()
150
+ unknown = mutable = quote_sanitized = False
151
+ shell_plain = True
152
+ safe = set(TAINT_CLASSES)
153
+ for v in values:
154
+ sources |= v.sources
155
+ unknown = unknown or v.unknown
156
+ safe &= v.safe_for
157
+ mutable = mutable or v.mutable
158
+ shell_plain = shell_plain and v.shell_plain
159
+ quote_sanitized = quote_sanitized or v.quote_sanitized
160
+ return Taint(
161
+ frozenset(sources), unknown, frozenset(safe), mutable, shell_plain, quote_sanitized
162
+ )
163
+
164
+
165
+ def concat(values: list[Taint]) -> Taint:
166
+ """A string assembled from `values` (f-string, `+`, `%`, `.format`, `.join`).
167
+
168
+ Shell quoting is context-sensitive: `'echo "' + shlex.quote(x) + '"'` puts the
169
+ quoted value inside double quotes where `$(...)` still expands. If any part could
170
+ open a quote context, quoting-based command-injection safety is dropped.
171
+ """
172
+ result = combine(values)
173
+ if not result.shell_plain and any(v.quote_sanitized for v in values):
174
+ result = replace(result, safe_for=result.safe_for - {CMDI})
175
+ return replace(result, mutable=False)
176
+
177
+
178
+ def unquote(value: Taint) -> Taint:
179
+ """Any transformation of a shell-quoted value (strip, replace, slicing, repr...) may
180
+ break the quoting, so quoting-based safety does not survive it."""
181
+ if not value.quote_sanitized:
182
+ return value
183
+ return replace(value, safe_for=value.safe_for - {CMDI}, quote_sanitized=False)
184
+
185
+
186
+ def literal(value: object) -> Taint:
187
+ if isinstance(value, (str, bytes)):
188
+ text = value if isinstance(value, str) else value.decode("latin-1")
189
+ return Taint(shell_plain=not (SHELL_QUOTE_CHARS & set(text)))
190
+ return CONST
191
+
192
+
193
+ def source(description: str, node: ast.AST) -> Taint:
194
+ return Taint(
195
+ frozenset({Source(getattr(node, "lineno", 0), description)}),
196
+ safe_for=frozenset(),
197
+ shell_plain=False,
198
+ )
199
+
200
+
201
+ Env = dict[str, Taint]
202
+
203
+
204
+ def join_env(a: Env | None, b: Env | None) -> Env | None:
205
+ if a is None:
206
+ return None if b is None else dict(b)
207
+ if b is None:
208
+ return dict(a)
209
+ out = dict(a)
210
+ for k, v in b.items():
211
+ out[k] = combine((out[k], v)) if k in out else v
212
+ return out
213
+
214
+
215
+ # -- public API ---------------------------------------------------------------------------
216
+
217
+
218
+ def apply_taint(ctx: FileContext, candidates: list[tuple[Finding, Match]]) -> list[Finding]:
219
+ """Return findings updated with taint results (same order; never drops any)."""
220
+ targets = {
221
+ id(m.node): m
222
+ for f, m in candidates
223
+ if f.vuln_class in TAINT_CLASSES and m.arg is not None and f.status is Status.CANDIDATE
224
+ }
225
+ if not targets:
226
+ return [f for f, _ in candidates]
227
+ engine = _FileEngine(ctx, targets)
228
+ results = engine.run()
229
+ return [_decide(f, results.get(id(m.node))) for f, m in candidates]
230
+
231
+
232
+ def _decide(finding: Finding, taint: Taint | None) -> Finding:
233
+ if taint is None or finding.vuln_class not in TAINT_CLASSES:
234
+ return finding
235
+ if taint.is_constant:
236
+ return replace(
237
+ finding,
238
+ status=Status.SUPPRESSED,
239
+ suppression=CONSTANT_INPUT,
240
+ confidence=0,
241
+ evidence=(
242
+ *finding.evidence,
243
+ Evidence(PRODUCER, "taint_verdict", "Every path supplies a constant value."),
244
+ ),
245
+ )
246
+ if finding.vuln_class == CMDI and CMDI in taint.safe_for and taint.quote_sanitized:
247
+ # shlex.quote protects POSIX shells only; under Windows cmd.exe `&`, `|` still
248
+ # chain commands. The target platform is unknown, so keep it visible.
249
+ return replace(
250
+ finding,
251
+ confidence=min(finding.confidence, POSIX_QUOTED_CONFIDENCE),
252
+ evidence=(
253
+ *finding.evidence,
254
+ Evidence(
255
+ PRODUCER,
256
+ "taint_verdict",
257
+ "Shell-quoted with POSIX quoting (shlex.quote); safe on POSIX shells only.",
258
+ ),
259
+ ),
260
+ )
261
+ if finding.vuln_class in taint.safe_for:
262
+ return replace(
263
+ finding,
264
+ status=Status.SUPPRESSED,
265
+ suppression=SANITIZED,
266
+ evidence=(
267
+ *finding.evidence,
268
+ Evidence(PRODUCER, "taint_verdict", "Every path is sanitized for this sink."),
269
+ ),
270
+ )
271
+ if taint.sources:
272
+ steps = tuple(
273
+ Evidence(PRODUCER, "taint_step", f"Untrusted {s.description} at line {s.line}.")
274
+ for s in sorted(taint.sources)
275
+ )
276
+ return replace(
277
+ finding,
278
+ status=Status.CONFIRMED,
279
+ confidence=min(100, finding.confidence + CONFIRMED_CONFIDENCE_BOOST),
280
+ evidence=(
281
+ *finding.evidence,
282
+ *steps,
283
+ Evidence(PRODUCER, "taint_verdict", "Untrusted input reaches the sink."),
284
+ ),
285
+ )
286
+ return replace(
287
+ finding,
288
+ evidence=(
289
+ *finding.evidence,
290
+ Evidence(
291
+ PRODUCER,
292
+ "taint_verdict",
293
+ "Value origin not established within the function (parameter, call or "
294
+ "non-local name); left for review.",
295
+ ),
296
+ ),
297
+ )
298
+
299
+
300
+ # -- engine -------------------------------------------------------------------------------
301
+
302
+
303
+ class _FileEngine:
304
+ def __init__(self, ctx: FileContext, targets: dict[int, Match]) -> None:
305
+ self.ctx = ctx
306
+ self.targets = targets
307
+ self.results: dict[int, Taint] = {}
308
+ self.module_constants: frozenset[str] = frozenset()
309
+
310
+ def run(self) -> dict[int, Taint]:
311
+ module = _FunctionEngine(self, self.ctx.tree, request_params=frozenset())
312
+ module_env = module.run({})
313
+ self.module_constants = self._constant_globals(module_env or {})
314
+ for func in self._functions():
315
+ if isinstance(func, ast.ClassDef): # class body runs once, top to bottom
316
+ _FunctionEngine(self, func, frozenset()).run({})
317
+ continue
318
+ params, request_params = self._initial_env(func)
319
+ engine = _FunctionEngine(self, func, request_params)
320
+ if isinstance(func, ast.Lambda):
321
+ engine.visit_expression(func.body, params)
322
+ else:
323
+ engine.run(params)
324
+ return self.results
325
+
326
+ def record(self, call: ast.AST, value: Taint) -> None:
327
+ prev = self.results.get(id(call))
328
+ self.results[id(call)] = value if prev is None else combine((prev, value))
329
+
330
+ def _functions(self) -> Iterator[ast.AST]:
331
+ for node in ast.walk(self.ctx.tree):
332
+ if isinstance(node, (*FUNCTION_TYPES, ast.ClassDef)):
333
+ yield node
334
+
335
+ def _constant_globals(self, module_env: Env) -> frozenset[str]:
336
+ """Module names bound only to immutable constants, never rebound via `global`."""
337
+ rebound = {
338
+ name
339
+ for node in ast.walk(self.ctx.tree)
340
+ if isinstance(node, ast.Global)
341
+ for name in node.names
342
+ }
343
+ module_scope = self.ctx.scopes.module
344
+ constants = set()
345
+ for name, value in module_env.items():
346
+ kinds = module_scope.bindings.get(name, set())
347
+ if (
348
+ value.is_constant
349
+ and not value.mutable
350
+ and name not in rebound
351
+ and all(k == "local" for k, _ in kinds)
352
+ ):
353
+ constants.add(name)
354
+ return frozenset(constants)
355
+
356
+ def _initial_env(self, func: ast.AST) -> tuple[Env, frozenset[str]]:
357
+ args = func.args # type: ignore[attr-defined]
358
+ names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)]
359
+ names += [a.arg for a in (args.vararg, args.kwarg) if a is not None]
360
+ route = is_route_handler(func)
361
+ fname = getattr(func, "name", "<lambda>")
362
+ env: Env = {}
363
+ for name in names:
364
+ if route and name not in {"self", "cls"} and name not in REQUEST_PARAM_NAMES:
365
+ env[name] = source(f"route parameter `{name}` of handler `{fname}`", func)
366
+ else:
367
+ env[name] = UNKNOWN
368
+ request_params = frozenset(n for n in names if n in REQUEST_PARAM_NAMES)
369
+ return env, request_params
370
+
371
+
372
+ class _FunctionEngine:
373
+ def __init__(self, file: _FileEngine, scope: ast.AST, request_params: frozenset[str]) -> None:
374
+ self.file = file
375
+ self.ctx = file.ctx
376
+ self.scope = scope
377
+ self.request_params = request_params
378
+ self.is_module = isinstance(scope, ast.Module)
379
+ # Flow-insensitive alias groups among names that may share a mutable object.
380
+ self.groups: dict[str, set[str]] = {}
381
+ self.closure_loads, self.closure_stores = _closure_names(scope)
382
+ # States at which an exception may be raised / control may leave a `try` early;
383
+ # one collector per active `try` statement.
384
+ self.try_states: list[list[Env]] = []
385
+ # States at `break` / `continue`, one collector per active loop.
386
+ self.break_states: list[list[Env]] = []
387
+ self.continue_states: list[list[Env]] = []
388
+
389
+ # -- statements ------------------------------------------------------------------------
390
+
391
+ def run(self, env: Env) -> Env | None:
392
+ return self.exec_block(self.scope.body, dict(env)) # type: ignore[attr-defined]
393
+
394
+ def visit_expression(self, expr: ast.AST, env: Env) -> None:
395
+ self._scan(expr, env)
396
+
397
+ def exec_block(self, stmts: list[ast.stmt], env: Env | None) -> Env | None:
398
+ for stmt in stmts:
399
+ if env is None:
400
+ return None
401
+ env = self.exec_stmt(stmt, env)
402
+ return env
403
+
404
+ def exec_stmt(self, stmt: ast.stmt, env: Env) -> Env | None:
405
+ if self.try_states: # any statement may raise (or return) from here
406
+ self.try_states[-1].append(dict(env))
407
+ for node in _header_nodes(stmt):
408
+ self._scan(node, env)
409
+ if self.try_states and isinstance(stmt, (ast.Return, ast.Raise)):
410
+ self.try_states[-1].append(dict(env)) # after evaluating e.g. `return x := ...`
411
+
412
+ if isinstance(stmt, ast.Assign):
413
+ value = self.eval(stmt.value, env)
414
+ for target in stmt.targets:
415
+ self._assign(target, value, env, stmt.value)
416
+ elif isinstance(stmt, ast.AnnAssign):
417
+ if stmt.value is not None:
418
+ self._assign(stmt.target, self.eval(stmt.value, env), env, stmt.value)
419
+ elif isinstance(stmt, ast.AugAssign):
420
+ current = self.eval(stmt.target, env)
421
+ value = combine((current, self.eval(stmt.value, env)))
422
+ if current.mutable and isinstance(stmt.target, ast.Name):
423
+ self._weak_update(stmt.target.id, value, env) # in-place `+=` on lists
424
+ else:
425
+ self._assign(stmt.target, value, env, None)
426
+ elif isinstance(stmt, (ast.Return, ast.Raise)):
427
+ return None
428
+ elif isinstance(stmt, ast.Break):
429
+ if self.break_states:
430
+ self.break_states[-1].append(dict(env))
431
+ return None # statements after `break` do not run on this path
432
+ elif isinstance(stmt, ast.Continue):
433
+ if self.continue_states:
434
+ self.continue_states[-1].append(dict(env))
435
+ return None
436
+ elif isinstance(stmt, ast.If):
437
+ then = self.exec_block(stmt.body, dict(env))
438
+ other = self.exec_block(stmt.orelse, dict(env))
439
+ return join_env(then, other)
440
+ elif isinstance(stmt, (ast.For, ast.AsyncFor)):
441
+ return self._loop(stmt, env, target=stmt.target, iter_=stmt.iter)
442
+ elif isinstance(stmt, ast.While):
443
+ return self._loop(stmt, env, target=None, iter_=None)
444
+ elif isinstance(stmt, (ast.With, ast.AsyncWith)):
445
+ for item in stmt.items:
446
+ if item.optional_vars is not None:
447
+ value = self.eval(item.context_expr, env)
448
+ self._assign(item.optional_vars, value, env, item.context_expr)
449
+ return self.exec_block(stmt.body, env)
450
+ elif isinstance(stmt, ast.Try) or type(stmt).__name__ == "TryStar":
451
+ return self._try(stmt, env)
452
+ elif isinstance(stmt, ast.Match):
453
+ subject = self.eval(stmt.subject, env)
454
+ out: Env | None = dict(env) # no case matched
455
+ for case in stmt.cases:
456
+ case_env = dict(env)
457
+ for node in ast.walk(case.pattern):
458
+ name = getattr(node, "name", None)
459
+ if isinstance(name, str):
460
+ case_env[name] = subject
461
+ if case.guard is not None:
462
+ self._scan(case.guard, case_env)
463
+ out = join_env(out, self.exec_block(case.body, case_env))
464
+ return out
465
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
466
+ env[stmt.name] = UNKNOWN
467
+ elif isinstance(stmt, (ast.Import, ast.ImportFrom)):
468
+ for alias in stmt.names:
469
+ if alias.name != "*":
470
+ env[(alias.asname or alias.name).split(".")[0]] = UNKNOWN
471
+ elif isinstance(stmt, ast.Delete):
472
+ for target in stmt.targets:
473
+ if isinstance(target, ast.Name):
474
+ env.pop(target.id, None)
475
+ return env
476
+
477
+ def _loop(self, stmt: ast.stmt, env: Env, target: ast.AST | None, iter_: ast.AST | None):
478
+ entry = dict(env)
479
+ current: Env = dict(env)
480
+ breaks: list[Env] = []
481
+ for _ in range(MAX_LOOP_ITERATIONS):
482
+ body_env = dict(current)
483
+ if target is not None and iter_ is not None:
484
+ self._assign(target, self.eval(iter_, body_env), body_env, iter_)
485
+ self.break_states.append([])
486
+ self.continue_states.append([])
487
+ try:
488
+ out = self.exec_block(stmt.body, body_env) # type: ignore[attr-defined]
489
+ finally:
490
+ breaks.extend(self.break_states.pop())
491
+ continues = self.continue_states.pop()
492
+ for state in continues: # `continue` jumps back to the loop head
493
+ out = join_env(out, state)
494
+ if isinstance(stmt, ast.While) and out is not None:
495
+ self._scan(stmt.test, out) # condition re-evaluated each iteration
496
+ merged = join_env(current, out)
497
+ if merged == current:
498
+ break
499
+ current = merged or current
500
+ else: # no fixpoint within the budget: give up precision, stay sound
501
+ current = {k: combine((v, UNKNOWN)) for k, v in current.items()}
502
+ # Normal exit (condition false / iterator exhausted) runs `else`; `break` skips it.
503
+ normal_exit = join_env(entry, current)
504
+ out_env = self.exec_block(stmt.orelse, normal_exit) # type: ignore[attr-defined]
505
+ for state in breaks:
506
+ out_env = join_env(out_env, state)
507
+ return out_env
508
+
509
+ def _try(self, stmt: ast.stmt, env: Env) -> Env | None:
510
+ # Handlers can be entered from any point in the body, so they see the join of
511
+ # every state the body passed through (including just before a raise/return).
512
+ self.try_states.append([dict(env)])
513
+ body_out = self.exec_block(stmt.body, dict(env)) # type: ignore[attr-defined]
514
+ raise_points = list(self.try_states[-1])
515
+ outs: Env | None = self.exec_block(stmt.orelse, body_out) # type: ignore[attr-defined]
516
+ handler_entry: Env | None = None
517
+ for state in [*raise_points, body_out]:
518
+ handler_entry = join_env(handler_entry, state)
519
+ for handler in stmt.handlers: # type: ignore[attr-defined]
520
+ h_env = dict(handler_entry or env)
521
+ if handler.name:
522
+ h_env[handler.name] = UNKNOWN
523
+ outs = join_env(outs, self.exec_block(handler.body, h_env))
524
+ leave_points = self.try_states.pop() # body + orelse + handlers
525
+ if self.try_states: # an enclosing try sees these states too
526
+ self.try_states[-1].extend(leave_points)
527
+ # `finally` runs on normal exit and on every early exit (raise/return/break).
528
+ final_in: Env | None = outs
529
+ for state in leave_points:
530
+ final_in = join_env(final_in, state)
531
+ final = self.exec_block(stmt.finalbody, final_in or dict(env)) # type: ignore[attr-defined]
532
+ if self.try_states and final is not None:
533
+ # An exception propagating out of this try carries the post-`finally` state
534
+ # to the enclosing handler.
535
+ self.try_states[-1].append(dict(final))
536
+ return final if outs is not None else None
537
+
538
+ def _assign(self, target: ast.AST, value: Taint, env: Env, value_expr: ast.AST | None):
539
+ if isinstance(target, ast.Name):
540
+ env[target.id] = value
541
+ if value_expr is not None:
542
+ for name in self._mutable_names_in(value_expr, env):
543
+ self._union(target.id, name) # alias = parts / x = [parts] / row = rows[0]
544
+ elif isinstance(target, (ast.Tuple, ast.List)):
545
+ for elt in target.elts:
546
+ self._assign(elt, value, env, value_expr)
547
+ elif isinstance(target, ast.Starred):
548
+ self._assign(target.value, value, env, value_expr)
549
+ elif isinstance(target, (ast.Attribute, ast.Subscript)):
550
+ base = _base_name(target)
551
+ if base is not None: # obj.x = v / d[k] = v: weak update of the container
552
+ self._weak_update(base, value, env)
553
+ if value_expr is not None: # storing a mutable elsewhere lets it escape
554
+ for name in self._mutable_names_in(value_expr, env):
555
+ self._weak_update(name, UNKNOWN, env)
556
+
557
+ # -- in-order scan: sinks, walrus, mutation and escape -------------------------------
558
+
559
+ def _scan(self, node: ast.AST, env: Env) -> None:
560
+ if isinstance(node, (*FUNCTION_TYPES, ast.ClassDef)):
561
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
562
+ for part in _header_nodes(node):
563
+ self._scan(part, env)
564
+ return
565
+ for child in ast.iter_child_nodes(node):
566
+ self._scan(child, env)
567
+ if isinstance(node, ast.NamedExpr):
568
+ self._assign(node.target, self.eval(node.value, env), env, node.value)
569
+ elif isinstance(node, ast.Call):
570
+ match = self.file.targets.get(id(node))
571
+ if match is not None and match.arg is not None:
572
+ self.file.record(node, self.eval(match.arg, env))
573
+ self._call_effects(node, env)
574
+
575
+ def _call_effects(self, call: ast.Call, env: Env) -> None:
576
+ args = _call_args(call)
577
+ func = call.func
578
+ # Mutating method on a mutable receiver: `parts.append(x)`, `d.setdefault(k, x)`.
579
+ if isinstance(func, ast.Attribute) and func.attr not in READONLY_METHODS:
580
+ base = _base_name(func.value)
581
+ if base is not None and self._lookup(base, func.value, env).mutable:
582
+ self._weak_update(base, combine(self.eval(a, env) for a in args), env)
583
+ # Mutable arguments escaping into code we do not model may be changed by it.
584
+ names = self.ctx.call_names(call)
585
+ is_string_method = (
586
+ isinstance(func, ast.Attribute)
587
+ and func.attr in STRING_METHODS
588
+ and not self.eval(func.value, env).mutable
589
+ )
590
+ if names & (NON_ESCAPING_CALLS | SANITIZERS.keys()) or is_string_method:
591
+ return
592
+ for arg in args:
593
+ for name in self._mutable_names_in(arg, env):
594
+ self._weak_update(name, UNKNOWN, env)
595
+
596
+ # -- aliasing ------------------------------------------------------------------------
597
+
598
+ def _group(self, name: str) -> set[str]:
599
+ return self.groups.get(name, {name})
600
+
601
+ def _union(self, a: str, b: str) -> None:
602
+ merged = self._group(a) | self._group(b)
603
+ for n in merged:
604
+ self.groups[n] = merged
605
+
606
+ def _weak_update(self, name: str, value: Taint, env: Env) -> None:
607
+ for n in self._group(name):
608
+ if n in env:
609
+ env[n] = combine((env[n], value))
610
+
611
+ def _mutable_names_in(self, expr: ast.AST, env: Env) -> list[str]:
612
+ """Local names anywhere in `expr` that may hold a mutable object. Calls are included:
613
+ `dict(cmd=parts)` or `wrap(parts)` may keep a reference to `parts`."""
614
+ out = []
615
+ stack = [expr]
616
+ while stack:
617
+ node = stack.pop()
618
+ if isinstance(node, ast.Name) and node.id in env and env[node.id].mutable:
619
+ out.append(node.id)
620
+ elif not isinstance(node, FUNCTION_TYPES):
621
+ stack.extend(ast.iter_child_nodes(node))
622
+ return out
623
+
624
+ # -- expressions -----------------------------------------------------------------------
625
+
626
+ def eval(self, node: ast.AST | None, env: Env) -> Taint:
627
+ if node is None:
628
+ return CONST
629
+ if isinstance(node, ast.Constant):
630
+ return literal(node.value)
631
+ if isinstance(node, ast.Name):
632
+ return self._lookup(node.id, node, env)
633
+ if isinstance(node, ast.Attribute):
634
+ return self._attribute(node, env)
635
+ if isinstance(node, ast.Subscript):
636
+ return unquote(self.eval(node.value, env)) # slicing can strip the quotes
637
+ if isinstance(node, ast.Call):
638
+ return self._call(node, env)
639
+ if isinstance(node, ast.Compare) or (
640
+ isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not)
641
+ ):
642
+ return CONST # booleans cannot carry injection payloads
643
+ if isinstance(node, ast.IfExp):
644
+ return combine((self.eval(node.body, env), self.eval(node.orelse, env)))
645
+ if isinstance(node, ast.NamedExpr):
646
+ return self.eval(node.value, env)
647
+ if isinstance(node, ast.JoinedStr):
648
+ return concat([self.eval(v, env) for v in node.values])
649
+ if isinstance(node, ast.FormattedValue):
650
+ return self.eval(node.value, env)
651
+ if isinstance(node, ast.BinOp):
652
+ left, right = self.eval(node.left, env), self.eval(node.right, env)
653
+ if isinstance(node.op, (ast.Add, ast.Mod)) and not (left.mutable or right.mutable):
654
+ return concat([left, right]) # string building (or arithmetic: harmless)
655
+ return combine((left, right))
656
+ if isinstance(node, COMPREHENSION_TYPES):
657
+ parts = [node.key, node.value] if isinstance(node, ast.DictComp) else [node.elt]
658
+ value = combine(self.eval(p, env) for p in parts)
659
+ return replace(value, mutable=not isinstance(node, ast.GeneratorExp) or value.mutable)
660
+ if isinstance(node, (ast.List, ast.Set, ast.Dict)):
661
+ children = [c for c in ast.iter_child_nodes(node) if isinstance(c, ast.expr)]
662
+ return replace(combine(self.eval(c, env) for c in children), mutable=True)
663
+ if isinstance(node, (ast.Lambda, ast.Yield, ast.YieldFrom)):
664
+ return UNKNOWN
665
+ if isinstance(
666
+ node, (ast.BoolOp, ast.UnaryOp, ast.Tuple, ast.Starred, ast.Await, ast.Slice)
667
+ ):
668
+ children = [c for c in ast.iter_child_nodes(node) if isinstance(c, ast.expr)]
669
+ return combine(self.eval(c, env) for c in children) if children else CONST
670
+ return UNKNOWN
671
+
672
+ def _lookup(self, name: str, node: ast.AST, env: Env) -> Taint:
673
+ if name in env:
674
+ value = env[name]
675
+ if name in self.closure_stores or (name in self.closure_loads and value.mutable):
676
+ return combine((value, UNKNOWN)) # a nested scope may change it
677
+ return value
678
+ comp = self._comprehension_binding(name, node, env)
679
+ if comp is not None:
680
+ return comp
681
+ for qualified in self.ctx.scopes.resolve_name(name, node):
682
+ if qualified in SOURCE_NAMES:
683
+ return source(SOURCE_NAMES[qualified], node)
684
+ if not self.is_module and name in self.file.module_constants:
685
+ return CONST
686
+ return UNKNOWN
687
+
688
+ def _comprehension_binding(self, name: str, node: ast.AST, env: Env) -> Taint | None:
689
+ scope = self.ctx.scopes.scope_of(node)
690
+ while scope is not None and scope.is_comprehension:
691
+ if name in scope.bindings:
692
+ for gen in scope.node.generators: # type: ignore[attr-defined]
693
+ if any(isinstance(t, ast.Name) and t.id == name for t in ast.walk(gen.target)):
694
+ return self.eval(gen.iter, env)
695
+ return UNKNOWN
696
+ scope = scope.parent
697
+ return None
698
+
699
+ def _attribute(self, node: ast.Attribute, env: Env) -> Taint:
700
+ for qualified in self.ctx.resolve_all(node):
701
+ description = flask_request_source(qualified) or SOURCE_NAMES.get(qualified)
702
+ if description:
703
+ return source(description, node)
704
+ base = node.value
705
+ if node.attr in REQUEST_PARAM_ATTRS and self._is_request_object(base):
706
+ return source(f"request data (`{self.ctx.segment(node)}`)", node)
707
+ return self.eval(base, env)
708
+
709
+ def _is_request_object(self, expr: ast.AST) -> bool:
710
+ """`request` parameter, or `self.request` (Django/DRF class-based views)."""
711
+ if isinstance(expr, ast.Name):
712
+ return expr.id in self.request_params
713
+ return (
714
+ isinstance(expr, ast.Attribute)
715
+ and expr.attr in REQUEST_PARAM_NAMES
716
+ and isinstance(expr.value, ast.Name)
717
+ and expr.value.id in {"self", "cls"}
718
+ )
719
+
720
+ def _call(self, node: ast.Call, env: Env) -> Taint:
721
+ names = self.ctx.call_names(node)
722
+ args = [self.eval(a, env) for a in _call_args(node)]
723
+ for qualified in names:
724
+ if qualified in SOURCE_NAMES:
725
+ return source(SOURCE_NAMES[qualified], node)
726
+ safe: set[str] = set()
727
+ quoting = False
728
+ for qualified in names:
729
+ safe |= SANITIZERS.get(qualified, frozenset())
730
+ quoting = quoting or qualified in QUOTING_SANITIZERS
731
+ if safe:
732
+ value = combine(args) if args else CONST
733
+ return Taint(
734
+ value.sources,
735
+ value.unknown,
736
+ frozenset(value.safe_for | safe),
737
+ mutable=False,
738
+ shell_plain=True if quoting else value.shell_plain,
739
+ quote_sanitized=quoting or value.quote_sanitized,
740
+ )
741
+ func = node.func
742
+ if isinstance(func, ast.Attribute):
743
+ # Methods derive their result from the receiver and arguments
744
+ # (`request.args.get("q")`, `" ".join(parts)`, `template.format(x)`).
745
+ receiver = self._attribute(func, env)
746
+ if func.attr in TEMPLATE_METHODS:
747
+ return concat([receiver, *args])
748
+ if func.attr in STRING_METHODS:
749
+ value = replace(combine((receiver, *args)), mutable=False)
750
+ return value if func.attr in {"encode", "decode"} else unquote(value)
751
+ return unquote(combine((receiver, *args)))
752
+ if names & MUTABLE_BUILTINS:
753
+ return replace(combine(args) if args else CONST, mutable=True)
754
+ if names & PURE_BUILTINS:
755
+ value = combine(args) if args else CONST
756
+ return value if names == {"str"} else unquote(value) # repr() adds quotes
757
+ # Unknown function: taint flows through, but the result is not provably constant.
758
+ return combine((UNKNOWN, *args))
759
+
760
+
761
+ # -- helpers ------------------------------------------------------------------------------
762
+
763
+
764
+ def _call_args(node: ast.Call) -> list[ast.expr]:
765
+ return [*node.args, *(k.value for k in node.keywords)]
766
+
767
+
768
+ def _base_name(node: ast.AST) -> str | None:
769
+ while isinstance(node, (ast.Attribute, ast.Subscript, ast.Starred)):
770
+ node = node.value
771
+ return node.id if isinstance(node, ast.Name) else None
772
+
773
+
774
+ def _closure_names(scope: ast.AST) -> tuple[frozenset[str], frozenset[str]]:
775
+ """Names nested scopes inside `scope` read, and names they rebind (nonlocal/global)."""
776
+ loads: set[str] = set()
777
+ stores: set[str] = set()
778
+ for node in ast.walk(scope):
779
+ if node is scope or not isinstance(node, (*FUNCTION_TYPES, ast.ClassDef)):
780
+ continue
781
+ for inner in ast.walk(node):
782
+ if isinstance(inner, ast.Name):
783
+ loads.add(inner.id)
784
+ elif isinstance(inner, ast.Nonlocal) or (
785
+ isinstance(inner, ast.Global) and isinstance(scope, ast.Module)
786
+ ):
787
+ stores.update(inner.names)
788
+ return frozenset(loads), frozenset(stores)
789
+
790
+
791
+ def _header_nodes(stmt: ast.stmt) -> list[ast.AST]:
792
+ """Parts of `stmt` evaluated before any nested block runs."""
793
+ if isinstance(stmt, (ast.If, ast.While)):
794
+ return [stmt.test]
795
+ if isinstance(stmt, (ast.For, ast.AsyncFor)):
796
+ return [stmt.iter]
797
+ if isinstance(stmt, (ast.With, ast.AsyncWith)):
798
+ return [i.context_expr for i in stmt.items]
799
+ if isinstance(stmt, ast.Match):
800
+ return [stmt.subject]
801
+ if isinstance(stmt, ast.Try) or type(stmt).__name__ == "TryStar":
802
+ return []
803
+ if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
804
+ args = stmt.args
805
+ return [*stmt.decorator_list, *args.defaults, *(d for d in args.kw_defaults if d)]
806
+ if isinstance(stmt, ast.ClassDef):
807
+ return [*stmt.decorator_list, *stmt.bases, *(k.value for k in stmt.keywords)]
808
+ return [stmt]