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.
- hackscan/__init__.py +3 -0
- hackscan/analyzers/__init__.py +1 -0
- hackscan/analyzers/ast_pass.py +154 -0
- hackscan/analyzers/suppressions.py +63 -0
- hackscan/analyzers/taint_pass.py +808 -0
- hackscan/cli.py +326 -0
- hackscan/config.py +184 -0
- hackscan/core/__init__.py +1 -0
- hackscan/core/dedupe.py +147 -0
- hackscan/core/fingerprint.py +76 -0
- hackscan/core/models.py +270 -0
- hackscan/core/pipeline.py +164 -0
- hackscan/core/redact.py +84 -0
- hackscan/core/taxonomy.py +111 -0
- hackscan/importers/__init__.py +1 -0
- hackscan/importers/common.py +198 -0
- hackscan/importers/gitleaks.py +104 -0
- hackscan/importers/runner.py +161 -0
- hackscan/importers/sarif.py +164 -0
- hackscan/plugins/__init__.py +5 -0
- hackscan/plugins/base.py +261 -0
- hackscan/plugins/loader.py +98 -0
- hackscan/plugins/rules/__init__.py +1 -0
- hackscan/plugins/rules/cmdi.py +80 -0
- hackscan/plugins/rules/codei.py +50 -0
- hackscan/plugins/rules/sqli.py +127 -0
- hackscan/plugins/rules/weak_crypto.py +53 -0
- hackscan/plugins/scopes.py +263 -0
- hackscan/sarif/__init__.py +1 -0
- hackscan/sarif/generator.py +205 -0
- hackscan/taint/__init__.py +1 -0
- hackscan/taint/sanitizers.py +37 -0
- hackscan/taint/sources.py +99 -0
- hackscan-0.1.0.dist-info/METADATA +181 -0
- hackscan-0.1.0.dist-info/RECORD +38 -0
- hackscan-0.1.0.dist-info/WHEEL +4 -0
- hackscan-0.1.0.dist-info/entry_points.txt +2 -0
- hackscan-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|