ballpython 2.0.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.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interprocedural SAST Dataflow and Taint Analysis Engine.
|
|
3
|
+
|
|
4
|
+
Tracks tainted user and external inputs across variable assignments, string
|
|
5
|
+
interpolations, collections, return statements, and function boundaries to
|
|
6
|
+
detect critical security vulnerabilities (Command Injection, Code Injection,
|
|
7
|
+
SQL Injection, Path Traversal, SSRF, Deserialization).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import ClassVar
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class TaintFinding:
|
|
20
|
+
"""A verified or high-confidence dataflow taint vulnerability."""
|
|
21
|
+
|
|
22
|
+
filepath: str
|
|
23
|
+
lineno: int
|
|
24
|
+
col_offset: int
|
|
25
|
+
severity: str # 'CRITICAL', 'HIGH', 'MEDIUM'
|
|
26
|
+
sink_type: str # e.g. 'COMMAND_INJECTION', 'CODE_INJECTION', etc.
|
|
27
|
+
sink_call: str
|
|
28
|
+
source_desc: str
|
|
29
|
+
source_lineno: int
|
|
30
|
+
propagation_path: list[str]
|
|
31
|
+
message: str
|
|
32
|
+
suggestion: str
|
|
33
|
+
param_to_sink: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class TaintReport:
|
|
38
|
+
"""Aggregated results from the taint analysis engine."""
|
|
39
|
+
|
|
40
|
+
findings: list[TaintFinding] = field(default_factory=list)
|
|
41
|
+
files_scanned: int = 0
|
|
42
|
+
sinks_checked: int = 0
|
|
43
|
+
sources_detected: int = 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def count(self) -> int:
|
|
47
|
+
return len(self.findings)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def has_critical(self) -> bool:
|
|
51
|
+
return any(f.severity == "CRITICAL" for f in self.findings)
|
|
52
|
+
|
|
53
|
+
def by_sink_type(self, sink_type: str) -> list[TaintFinding]:
|
|
54
|
+
return [f for f in self.findings if f.sink_type == sink_type]
|
|
55
|
+
|
|
56
|
+
def format_summary(self) -> str:
|
|
57
|
+
lines: list[str] = [
|
|
58
|
+
f"Files scanned: {self.files_scanned}",
|
|
59
|
+
f"Sinks evaluated: {self.sinks_checked}",
|
|
60
|
+
f"Sources detected: {self.sources_detected}",
|
|
61
|
+
f"Total taint vulnerabilities found: {len(self.findings)}",
|
|
62
|
+
]
|
|
63
|
+
if self.findings:
|
|
64
|
+
lines.append("\nVulnerabilities detected:")
|
|
65
|
+
for idx, f in enumerate(self.findings, 1):
|
|
66
|
+
lines.append(
|
|
67
|
+
f" [{idx}] {f.severity}: {f.sink_type} at {f.filepath}:{f.lineno}"
|
|
68
|
+
)
|
|
69
|
+
lines.append(f" Sink: {f.sink_call}")
|
|
70
|
+
lines.append(f" Source: {f.source_desc} (line {f.source_lineno})")
|
|
71
|
+
if f.propagation_path:
|
|
72
|
+
path_str = " -> ".join(f.propagation_path)
|
|
73
|
+
lines.append(f" Path: {path_str}")
|
|
74
|
+
lines.append(f" Fix: {f.suggestion}")
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(slots=True)
|
|
79
|
+
class TaintVariable:
|
|
80
|
+
"""Representation of a variable holding tainted data."""
|
|
81
|
+
|
|
82
|
+
name: str
|
|
83
|
+
source_desc: str
|
|
84
|
+
source_lineno: int
|
|
85
|
+
propagation_path: list[str] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class FunctionTaintSummary:
|
|
90
|
+
"""Interprocedural summary of a function's taint behavior."""
|
|
91
|
+
|
|
92
|
+
func_name: str
|
|
93
|
+
filepath: str
|
|
94
|
+
param_names: list[str] = field(default_factory=list)
|
|
95
|
+
returns_taint: bool = False
|
|
96
|
+
return_source_desc: str = ""
|
|
97
|
+
return_source_lineno: int = 0
|
|
98
|
+
# Maps parameter index/name to sink finding template if param flows to sink
|
|
99
|
+
param_to_sink: dict[str, tuple[str, str, int]] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TaintEngine:
|
|
103
|
+
"""
|
|
104
|
+
Interprocedural static dataflow taint analysis engine.
|
|
105
|
+
|
|
106
|
+
Traces untrusted sources to security-sensitive sinks through intraprocedural
|
|
107
|
+
SSA/CFG variable assignments and interprocedural call chains.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# Known sources of untrusted data
|
|
111
|
+
KNOWN_SOURCES: ClassVar[set[str]] = {
|
|
112
|
+
"input",
|
|
113
|
+
"raw_input",
|
|
114
|
+
"sys.argv",
|
|
115
|
+
"request.args",
|
|
116
|
+
"request.form",
|
|
117
|
+
"request.values",
|
|
118
|
+
"request.data",
|
|
119
|
+
"request.json",
|
|
120
|
+
"request.get_json",
|
|
121
|
+
"request.GET",
|
|
122
|
+
"request.POST",
|
|
123
|
+
"request.body",
|
|
124
|
+
"request.headers",
|
|
125
|
+
"request.cookies",
|
|
126
|
+
"os.environ",
|
|
127
|
+
"os.getenv",
|
|
128
|
+
"socket.recv",
|
|
129
|
+
"conn.recv",
|
|
130
|
+
"f.read",
|
|
131
|
+
"f.readline",
|
|
132
|
+
"f.readlines",
|
|
133
|
+
"file.read",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Sanitizers that neutralize taint
|
|
137
|
+
KNOWN_SANITIZERS: ClassVar[set[str]] = {
|
|
138
|
+
"int",
|
|
139
|
+
"float",
|
|
140
|
+
"bool",
|
|
141
|
+
"shlex.quote",
|
|
142
|
+
"html.escape",
|
|
143
|
+
"re.escape",
|
|
144
|
+
"secrets.compare_digest",
|
|
145
|
+
"uuid.UUID",
|
|
146
|
+
"math.floor",
|
|
147
|
+
"math.ceil",
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Sink configurations: name -> (sink_type, default_severity, suggestion)
|
|
151
|
+
SINK_SPECS: ClassVar[dict[str, tuple[str, str, str]]] = {
|
|
152
|
+
"os.system": (
|
|
153
|
+
"COMMAND_INJECTION",
|
|
154
|
+
"CRITICAL",
|
|
155
|
+
"Use subprocess.run with shell=False and argument list, or validate input.",
|
|
156
|
+
),
|
|
157
|
+
"os.popen": (
|
|
158
|
+
"COMMAND_INJECTION",
|
|
159
|
+
"CRITICAL",
|
|
160
|
+
"Replace os.popen with subprocess.run without shell.",
|
|
161
|
+
),
|
|
162
|
+
"subprocess.run": (
|
|
163
|
+
"COMMAND_INJECTION",
|
|
164
|
+
"CRITICAL",
|
|
165
|
+
"Ensure shell=False and use a list of arguments without string interpolation.",
|
|
166
|
+
),
|
|
167
|
+
"subprocess.Popen": (
|
|
168
|
+
"COMMAND_INJECTION",
|
|
169
|
+
"CRITICAL",
|
|
170
|
+
"Ensure shell=False and avoid concatenating untrusted input into commands.",
|
|
171
|
+
),
|
|
172
|
+
"subprocess.call": (
|
|
173
|
+
"COMMAND_INJECTION",
|
|
174
|
+
"CRITICAL",
|
|
175
|
+
"Avoid shell=True and pass command arguments as a validated list.",
|
|
176
|
+
),
|
|
177
|
+
"subprocess.check_output": (
|
|
178
|
+
"COMMAND_INJECTION",
|
|
179
|
+
"CRITICAL",
|
|
180
|
+
"Pass arguments as a sequence without shell=True.",
|
|
181
|
+
),
|
|
182
|
+
"subprocess.check_call": (
|
|
183
|
+
"COMMAND_INJECTION",
|
|
184
|
+
"CRITICAL",
|
|
185
|
+
"Pass arguments as a sequence without shell=True.",
|
|
186
|
+
),
|
|
187
|
+
"eval": (
|
|
188
|
+
"CODE_INJECTION",
|
|
189
|
+
"CRITICAL",
|
|
190
|
+
"Never evaluate untrusted dynamic code. Use ast.literal_eval for safe literals.",
|
|
191
|
+
),
|
|
192
|
+
"exec": (
|
|
193
|
+
"CODE_INJECTION",
|
|
194
|
+
"CRITICAL",
|
|
195
|
+
"Do not execute dynamically constructed code from untrusted input.",
|
|
196
|
+
),
|
|
197
|
+
"cursor.execute": (
|
|
198
|
+
"SQL_INJECTION",
|
|
199
|
+
"CRITICAL",
|
|
200
|
+
"Use parameterized SQL query placeholders (e.g., 'WHERE id = ?', (val,)) instead of string formatting.",
|
|
201
|
+
),
|
|
202
|
+
"session.execute": (
|
|
203
|
+
"SQL_INJECTION",
|
|
204
|
+
"CRITICAL",
|
|
205
|
+
"Use parameterized SQLAlchemy queries or text(:param).",
|
|
206
|
+
),
|
|
207
|
+
"connection.execute": (
|
|
208
|
+
"SQL_INJECTION",
|
|
209
|
+
"CRITICAL",
|
|
210
|
+
"Use parameterized queries with database driver parameter placeholders.",
|
|
211
|
+
),
|
|
212
|
+
"open": (
|
|
213
|
+
"PATH_TRAVERSAL",
|
|
214
|
+
"HIGH",
|
|
215
|
+
"Validate paths using os.path.abspath and ensure it resides within an allowed directory.",
|
|
216
|
+
),
|
|
217
|
+
"os.remove": (
|
|
218
|
+
"PATH_TRAVERSAL",
|
|
219
|
+
"HIGH",
|
|
220
|
+
"Ensure path is validated and confined to expected sandboxed directory.",
|
|
221
|
+
),
|
|
222
|
+
"os.unlink": (
|
|
223
|
+
"PATH_TRAVERSAL",
|
|
224
|
+
"HIGH",
|
|
225
|
+
"Ensure target path is sanitized and canonicalized within allowed directory.",
|
|
226
|
+
),
|
|
227
|
+
"pickle.loads": (
|
|
228
|
+
"DESERIALIZATION",
|
|
229
|
+
"CRITICAL",
|
|
230
|
+
"Do not unpickle untrusted data. Use JSON or safe structured serialization.",
|
|
231
|
+
),
|
|
232
|
+
"pickle.load": (
|
|
233
|
+
"DESERIALIZATION",
|
|
234
|
+
"CRITICAL",
|
|
235
|
+
"Do not unpickle untrusted data from files.",
|
|
236
|
+
),
|
|
237
|
+
"yaml.load": (
|
|
238
|
+
"DESERIALIZATION",
|
|
239
|
+
"HIGH",
|
|
240
|
+
"Use yaml.safe_load instead of yaml.load with FullLoader/UnsafeLoader.",
|
|
241
|
+
),
|
|
242
|
+
"requests.get": (
|
|
243
|
+
"SSRF",
|
|
244
|
+
"MEDIUM",
|
|
245
|
+
"Validate target URL against a strict whitelist of allowed hosts and protocols.",
|
|
246
|
+
),
|
|
247
|
+
"requests.post": (
|
|
248
|
+
"SSRF",
|
|
249
|
+
"MEDIUM",
|
|
250
|
+
"Validate destination URL against an approved internal/external whitelist.",
|
|
251
|
+
),
|
|
252
|
+
"urllib.request.urlopen": (
|
|
253
|
+
"SSRF",
|
|
254
|
+
"MEDIUM",
|
|
255
|
+
"Validate and restrict URL scheme and host.",
|
|
256
|
+
),
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
def __init__(self) -> None:
|
|
260
|
+
self.function_summaries: dict[str, FunctionTaintSummary] = {}
|
|
261
|
+
|
|
262
|
+
def scan_path(self, target: str | Path) -> TaintReport:
|
|
263
|
+
"""Scan a file or directory for dataflow taint vulnerabilities."""
|
|
264
|
+
path = Path(target)
|
|
265
|
+
if path.is_file():
|
|
266
|
+
files = [path] if path.suffix == ".py" else []
|
|
267
|
+
elif path.is_dir():
|
|
268
|
+
files = sorted(
|
|
269
|
+
f
|
|
270
|
+
for f in path.rglob("*.py")
|
|
271
|
+
if not any(
|
|
272
|
+
part.startswith((".", "build", "dist", "venv", "__pycache__"))
|
|
273
|
+
for part in f.parts
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
return TaintReport()
|
|
278
|
+
|
|
279
|
+
report = TaintReport()
|
|
280
|
+
parsed_files: list[tuple[Path, ast.Module]] = []
|
|
281
|
+
|
|
282
|
+
# Pass 1: Parse ASTs and build interprocedural summaries
|
|
283
|
+
for fpath in files:
|
|
284
|
+
try:
|
|
285
|
+
code = fpath.read_text(encoding="utf-8", errors="replace")
|
|
286
|
+
tree = ast.parse(code, filename=str(fpath))
|
|
287
|
+
parsed_files.append((fpath, tree))
|
|
288
|
+
self._collect_function_summaries(str(fpath), tree)
|
|
289
|
+
except SyntaxError:
|
|
290
|
+
continue
|
|
291
|
+
|
|
292
|
+
# Pass 2: Interprocedural and intraprocedural taint flow analysis
|
|
293
|
+
for fpath, tree in parsed_files:
|
|
294
|
+
file_report = self._analyze_tree(str(fpath), tree)
|
|
295
|
+
report.findings.extend(file_report.findings)
|
|
296
|
+
report.sinks_checked += file_report.sinks_checked
|
|
297
|
+
report.sources_detected += file_report.sources_detected
|
|
298
|
+
report.files_scanned += 1
|
|
299
|
+
|
|
300
|
+
return report
|
|
301
|
+
|
|
302
|
+
def _inspect_return_child(
|
|
303
|
+
self, child: ast.AST, summary: FunctionTaintSummary
|
|
304
|
+
) -> None:
|
|
305
|
+
if isinstance(child, ast.Return) and child.value is not None:
|
|
306
|
+
source_info = self._get_expression_direct_source(child.value)
|
|
307
|
+
if source_info:
|
|
308
|
+
summary.returns_taint = True
|
|
309
|
+
summary.return_source_desc = source_info[0]
|
|
310
|
+
summary.return_source_lineno = source_info[1]
|
|
311
|
+
|
|
312
|
+
def _inspect_call_sink_child(
|
|
313
|
+
self,
|
|
314
|
+
child: ast.AST,
|
|
315
|
+
summary: FunctionTaintSummary,
|
|
316
|
+
param_names: list[str],
|
|
317
|
+
default_lineno: int,
|
|
318
|
+
) -> None:
|
|
319
|
+
if not isinstance(child, ast.Call):
|
|
320
|
+
return
|
|
321
|
+
child_call_name = self._resolve_call_name(child.func)
|
|
322
|
+
spec = self.SINK_SPECS.get(child_call_name)
|
|
323
|
+
if not spec:
|
|
324
|
+
return
|
|
325
|
+
for arg in child.args:
|
|
326
|
+
if isinstance(arg, ast.Name) and arg.id in param_names:
|
|
327
|
+
summary.param_to_sink[arg.id] = (
|
|
328
|
+
spec[0],
|
|
329
|
+
spec[1],
|
|
330
|
+
getattr(child, "lineno", default_lineno),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
def _collect_function_summaries(self, filepath: str, tree: ast.AST) -> None:
|
|
334
|
+
"""Collect top-level and class function signatures and return patterns."""
|
|
335
|
+
for node in ast.walk(tree):
|
|
336
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
337
|
+
param_names = [arg.arg for arg in node.args.args]
|
|
338
|
+
summary = FunctionTaintSummary(
|
|
339
|
+
func_name=node.name,
|
|
340
|
+
filepath=filepath,
|
|
341
|
+
param_names=param_names,
|
|
342
|
+
)
|
|
343
|
+
for child in ast.walk(node):
|
|
344
|
+
self._inspect_return_child(child, summary)
|
|
345
|
+
self._inspect_call_sink_child(
|
|
346
|
+
child, summary, param_names, node.lineno
|
|
347
|
+
)
|
|
348
|
+
self.function_summaries[node.name] = summary
|
|
349
|
+
|
|
350
|
+
def _analyze_tree(self, filepath: str, tree: ast.AST) -> TaintReport:
|
|
351
|
+
"""Analyze a single module AST for dataflow taint flows."""
|
|
352
|
+
visitor = _ModuleTaintVisitor(
|
|
353
|
+
filepath=filepath,
|
|
354
|
+
engine=self,
|
|
355
|
+
function_summaries=self.function_summaries,
|
|
356
|
+
)
|
|
357
|
+
visitor.visit(tree)
|
|
358
|
+
return visitor.report
|
|
359
|
+
|
|
360
|
+
def _get_expression_direct_source(self, node: ast.AST) -> tuple[str, int] | None:
|
|
361
|
+
"""Check if an expression immediately calls or accesses a known untrusted source."""
|
|
362
|
+
if isinstance(node, ast.Call):
|
|
363
|
+
call_name = self._resolve_call_name(node.func)
|
|
364
|
+
if call_name in self.KNOWN_SOURCES or any(
|
|
365
|
+
call_name.startswith(src) for src in self.KNOWN_SOURCES
|
|
366
|
+
):
|
|
367
|
+
return call_name, getattr(node, "lineno", 0)
|
|
368
|
+
elif isinstance(node, ast.Subscript):
|
|
369
|
+
# e.g., sys.argv[1], request.args['q']
|
|
370
|
+
val_name = self._resolve_call_name(node.value)
|
|
371
|
+
if val_name in self.KNOWN_SOURCES or any(
|
|
372
|
+
val_name.startswith(src) for src in self.KNOWN_SOURCES
|
|
373
|
+
):
|
|
374
|
+
return f"{val_name}[...]", getattr(node, "lineno", 0)
|
|
375
|
+
return None
|
|
376
|
+
|
|
377
|
+
@staticmethod
|
|
378
|
+
def _resolve_call_name(node: ast.AST) -> str:
|
|
379
|
+
"""Resolve an AST node to a dot-delimited call or attribute name."""
|
|
380
|
+
if isinstance(node, ast.Name):
|
|
381
|
+
return node.id
|
|
382
|
+
if isinstance(node, ast.Attribute):
|
|
383
|
+
base = TaintEngine._resolve_call_name(node.value)
|
|
384
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
385
|
+
return ""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class _ModuleTaintVisitor(ast.NodeVisitor):
|
|
389
|
+
"""AST visitor that tracks taint flows per scope (module and function)."""
|
|
390
|
+
|
|
391
|
+
def __init__(
|
|
392
|
+
self,
|
|
393
|
+
filepath: str,
|
|
394
|
+
engine: TaintEngine,
|
|
395
|
+
function_summaries: dict[str, FunctionTaintSummary],
|
|
396
|
+
) -> None:
|
|
397
|
+
self.filepath = filepath
|
|
398
|
+
self.engine = engine
|
|
399
|
+
self.function_summaries = function_summaries
|
|
400
|
+
self.report = TaintReport()
|
|
401
|
+
|
|
402
|
+
# Scoped variable environments: stack of dict[var_name, TaintVariable]
|
|
403
|
+
self.env_stack: list[dict[str, TaintVariable]] = [{}]
|
|
404
|
+
|
|
405
|
+
@property
|
|
406
|
+
def current_env(self) -> dict[str, TaintVariable]:
|
|
407
|
+
return self.env_stack[-1]
|
|
408
|
+
|
|
409
|
+
def _get_var(self, name: str) -> TaintVariable | None:
|
|
410
|
+
for env in reversed(self.env_stack):
|
|
411
|
+
if name in env:
|
|
412
|
+
return env[name]
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
def _set_var(self, name: str, taint: TaintVariable) -> None:
|
|
416
|
+
self.current_env[name] = taint
|
|
417
|
+
|
|
418
|
+
def _remove_var(self, name: str) -> None:
|
|
419
|
+
if name in self.current_env:
|
|
420
|
+
del self.current_env[name]
|
|
421
|
+
|
|
422
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
423
|
+
self._handle_function_scope(node)
|
|
424
|
+
|
|
425
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
426
|
+
self._handle_function_scope(node)
|
|
427
|
+
|
|
428
|
+
def _handle_function_scope(
|
|
429
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
430
|
+
) -> None:
|
|
431
|
+
new_env: dict[str, TaintVariable] = {}
|
|
432
|
+
self.env_stack.append(new_env)
|
|
433
|
+
|
|
434
|
+
# Check if function params should be treated as potential sources for intra-function checking
|
|
435
|
+
for stmt in node.body:
|
|
436
|
+
self.visit(stmt)
|
|
437
|
+
|
|
438
|
+
self.env_stack.pop()
|
|
439
|
+
|
|
440
|
+
def _propagate_taint_to_name(
|
|
441
|
+
self, name: str, taint: TaintVariable | None, node_value: ast.AST | None
|
|
442
|
+
) -> None:
|
|
443
|
+
if not taint:
|
|
444
|
+
self._remove_var(name)
|
|
445
|
+
return
|
|
446
|
+
prop_path = list(taint.propagation_path)
|
|
447
|
+
if node_value is not None:
|
|
448
|
+
prop_path.append(f"{name} = {self._node_summary(node_value)}")
|
|
449
|
+
self._set_var(
|
|
450
|
+
name,
|
|
451
|
+
TaintVariable(
|
|
452
|
+
name=name,
|
|
453
|
+
source_desc=taint.source_desc,
|
|
454
|
+
source_lineno=taint.source_lineno,
|
|
455
|
+
propagation_path=prop_path,
|
|
456
|
+
),
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
def _assign_target_taint(
|
|
460
|
+
self, target: ast.AST, taint: TaintVariable | None, value_node: ast.AST
|
|
461
|
+
) -> None:
|
|
462
|
+
if isinstance(target, ast.Name):
|
|
463
|
+
self._propagate_taint_to_name(target.id, taint, value_node)
|
|
464
|
+
elif isinstance(target, (ast.Tuple, ast.List)):
|
|
465
|
+
for elt in target.elts:
|
|
466
|
+
if isinstance(elt, ast.Name):
|
|
467
|
+
self._propagate_taint_to_name(elt.id, taint, None)
|
|
468
|
+
|
|
469
|
+
def visit_Assign(self, node: ast.Assign) -> None:
|
|
470
|
+
self.generic_visit(node.value)
|
|
471
|
+
taint = self._evaluate_taint(node.value)
|
|
472
|
+
for target in node.targets:
|
|
473
|
+
self._assign_target_taint(target, taint, node.value)
|
|
474
|
+
|
|
475
|
+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
|
|
476
|
+
if node.value is not None:
|
|
477
|
+
self.generic_visit(node.value)
|
|
478
|
+
taint = self._evaluate_taint(node.value)
|
|
479
|
+
if isinstance(node.target, ast.Name):
|
|
480
|
+
self._propagate_taint_to_name(node.target.id, taint, node.value)
|
|
481
|
+
|
|
482
|
+
def visit_AugAssign(self, node: ast.AugAssign) -> None:
|
|
483
|
+
self.generic_visit(node.value)
|
|
484
|
+
val_taint = self._evaluate_taint(node.value)
|
|
485
|
+
if isinstance(node.target, ast.Name):
|
|
486
|
+
target_taint = self._get_var(node.target.id)
|
|
487
|
+
active_taint = val_taint or target_taint
|
|
488
|
+
if active_taint:
|
|
489
|
+
prop_path = list(active_taint.propagation_path)
|
|
490
|
+
prop_path.append(
|
|
491
|
+
f"{node.target.id} += {self._node_summary(node.value)}"
|
|
492
|
+
)
|
|
493
|
+
self._set_var(
|
|
494
|
+
node.target.id,
|
|
495
|
+
TaintVariable(
|
|
496
|
+
name=node.target.id,
|
|
497
|
+
source_desc=active_taint.source_desc,
|
|
498
|
+
source_lineno=active_taint.source_lineno,
|
|
499
|
+
propagation_path=prop_path,
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
504
|
+
self.generic_visit(node)
|
|
505
|
+
self._check_call_for_sink(node)
|
|
506
|
+
|
|
507
|
+
def _resolve_sink_spec(self, call_name: str) -> tuple[str, str, str] | None:
|
|
508
|
+
if call_name in self.engine.SINK_SPECS:
|
|
509
|
+
return self.engine.SINK_SPECS[call_name]
|
|
510
|
+
for s_name, spec in self.engine.SINK_SPECS.items():
|
|
511
|
+
if call_name.endswith(s_name) or s_name.endswith(call_name):
|
|
512
|
+
return spec
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
def _check_interprocedural_sink(self, node: ast.Call, call_name: str) -> bool:
|
|
516
|
+
if call_name not in self.function_summaries:
|
|
517
|
+
return False
|
|
518
|
+
summary = self.function_summaries[call_name]
|
|
519
|
+
if not summary.param_to_sink:
|
|
520
|
+
return False
|
|
521
|
+
for idx, call_arg in enumerate(node.args):
|
|
522
|
+
if idx >= len(summary.param_names):
|
|
523
|
+
continue
|
|
524
|
+
pname = summary.param_names[idx]
|
|
525
|
+
if pname in summary.param_to_sink:
|
|
526
|
+
taint = self._evaluate_taint(call_arg)
|
|
527
|
+
if taint:
|
|
528
|
+
stype, ssev, _ = summary.param_to_sink[pname]
|
|
529
|
+
path = list(taint.propagation_path) + [f"{call_name}({pname})"]
|
|
530
|
+
self.report.findings.append(
|
|
531
|
+
TaintFinding(
|
|
532
|
+
filepath=self.filepath,
|
|
533
|
+
lineno=getattr(node, "lineno", 0),
|
|
534
|
+
col_offset=getattr(node, "col_offset", 0),
|
|
535
|
+
severity=ssev,
|
|
536
|
+
sink_type=stype,
|
|
537
|
+
sink_call=f"{call_name}({pname})",
|
|
538
|
+
source_desc=taint.source_desc,
|
|
539
|
+
source_lineno=taint.source_lineno,
|
|
540
|
+
propagation_path=path,
|
|
541
|
+
message=(
|
|
542
|
+
f"Untrusted data from '{taint.source_desc}' (line {taint.source_lineno}) "
|
|
543
|
+
f"flows into parameter '{pname}' of '{call_name}', reaching a sensitive sink."
|
|
544
|
+
),
|
|
545
|
+
suggestion=f"Sanitize argument '{pname}' before passing to '{call_name}'.",
|
|
546
|
+
param_to_sink=pname,
|
|
547
|
+
)
|
|
548
|
+
)
|
|
549
|
+
return True
|
|
550
|
+
|
|
551
|
+
def _emit_sink_finding(
|
|
552
|
+
self,
|
|
553
|
+
node: ast.Call,
|
|
554
|
+
call_name: str,
|
|
555
|
+
sink_spec: tuple[str, str, str],
|
|
556
|
+
arg: ast.AST,
|
|
557
|
+
taint: TaintVariable,
|
|
558
|
+
) -> None:
|
|
559
|
+
sink_type, default_severity, suggestion = sink_spec
|
|
560
|
+
lineno = getattr(node, "lineno", 0)
|
|
561
|
+
col_offset = getattr(node, "col_offset", 0)
|
|
562
|
+
path = list(taint.propagation_path) + [
|
|
563
|
+
f"{call_name}({self._node_summary(arg)})"
|
|
564
|
+
]
|
|
565
|
+
finding = TaintFinding(
|
|
566
|
+
filepath=self.filepath,
|
|
567
|
+
lineno=lineno,
|
|
568
|
+
col_offset=col_offset,
|
|
569
|
+
severity=default_severity,
|
|
570
|
+
sink_type=sink_type,
|
|
571
|
+
sink_call=call_name,
|
|
572
|
+
source_desc=taint.source_desc,
|
|
573
|
+
source_lineno=taint.source_lineno,
|
|
574
|
+
propagation_path=path,
|
|
575
|
+
message=(
|
|
576
|
+
f"Untrusted data from '{taint.source_desc}' (line {taint.source_lineno}) "
|
|
577
|
+
f"flows into sensitive sink '{call_name}'."
|
|
578
|
+
),
|
|
579
|
+
suggestion=suggestion,
|
|
580
|
+
)
|
|
581
|
+
self.report.findings.append(finding)
|
|
582
|
+
|
|
583
|
+
def _check_direct_sink(
|
|
584
|
+
self, node: ast.Call, call_name: str, sink_spec: tuple[str, str, str]
|
|
585
|
+
) -> None:
|
|
586
|
+
self.report.sinks_checked += 1
|
|
587
|
+
if not node.args:
|
|
588
|
+
return
|
|
589
|
+
sink_type = sink_spec[0]
|
|
590
|
+
arg = node.args[0]
|
|
591
|
+
taint = self._evaluate_taint(arg)
|
|
592
|
+
if not taint:
|
|
593
|
+
return
|
|
594
|
+
if sink_type == "SQL_INJECTION" and self._is_safe_sql_param(arg, node):
|
|
595
|
+
return
|
|
596
|
+
self._emit_sink_finding(node, call_name, sink_spec, arg, taint)
|
|
597
|
+
|
|
598
|
+
def _check_call_for_sink(self, node: ast.Call) -> None:
|
|
599
|
+
call_name = self.engine._resolve_call_name(node.func)
|
|
600
|
+
sink_spec = self._resolve_sink_spec(call_name)
|
|
601
|
+
if sink_spec is None:
|
|
602
|
+
self._check_interprocedural_sink(node, call_name)
|
|
603
|
+
else:
|
|
604
|
+
self._check_direct_sink(node, call_name, sink_spec)
|
|
605
|
+
|
|
606
|
+
def _is_safe_sql_param(self, query_arg: ast.AST, call_node: ast.Call) -> bool:
|
|
607
|
+
"""Check if SQL call uses safe parameter binding rather than string concatenation."""
|
|
608
|
+
# If query_arg is a simple string literal, it's safe unless formatted
|
|
609
|
+
if isinstance(query_arg, ast.Constant) and isinstance(query_arg.value, str):
|
|
610
|
+
# Safe static query
|
|
611
|
+
return True
|
|
612
|
+
|
|
613
|
+
# If query is formatted/interpolated with tainted data, it is NOT safe
|
|
614
|
+
if isinstance(query_arg, (ast.JoinedStr, ast.BinOp)):
|
|
615
|
+
return False
|
|
616
|
+
|
|
617
|
+
# If there are subsequent parameter arguments (e.g. cursor.execute("...", (p1, p2)))
|
|
618
|
+
# and query_arg is NOT dynamically concatenated with taint, it's safe
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
def _eval_direct_source(self, node: ast.AST) -> TaintVariable | None:
|
|
622
|
+
direct_source = self.engine._get_expression_direct_source(node)
|
|
623
|
+
if not direct_source:
|
|
624
|
+
return None
|
|
625
|
+
self.report.sources_detected += 1
|
|
626
|
+
src_desc, src_lineno = direct_source
|
|
627
|
+
return TaintVariable(
|
|
628
|
+
name="<source>",
|
|
629
|
+
source_desc=src_desc,
|
|
630
|
+
source_lineno=src_lineno,
|
|
631
|
+
propagation_path=[f"{src_desc}"],
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
def _eval_call_summary_return(
|
|
635
|
+
self, call_name: str, lineno: int
|
|
636
|
+
) -> TaintVariable | None:
|
|
637
|
+
if call_name not in self.function_summaries:
|
|
638
|
+
return None
|
|
639
|
+
summary = self.function_summaries[call_name]
|
|
640
|
+
if not summary.returns_taint:
|
|
641
|
+
return None
|
|
642
|
+
return TaintVariable(
|
|
643
|
+
name="<func_return>",
|
|
644
|
+
source_desc=f"{call_name}() [{summary.return_source_desc}]",
|
|
645
|
+
source_lineno=lineno,
|
|
646
|
+
propagation_path=[
|
|
647
|
+
f"{call_name}() returns taint from {summary.return_source_desc}"
|
|
648
|
+
],
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def _eval_call_taint(self, node: ast.Call) -> TaintVariable | None:
|
|
652
|
+
call_name = self.engine._resolve_call_name(node.func)
|
|
653
|
+
if call_name in self.engine.KNOWN_SANITIZERS or any(
|
|
654
|
+
call_name.endswith(san) for san in self.engine.KNOWN_SANITIZERS
|
|
655
|
+
):
|
|
656
|
+
return None
|
|
657
|
+
|
|
658
|
+
func_ret = self._eval_call_summary_return(call_name, getattr(node, "lineno", 0))
|
|
659
|
+
if func_ret:
|
|
660
|
+
return func_ret
|
|
661
|
+
|
|
662
|
+
for arg in node.args:
|
|
663
|
+
arg_taint = self._evaluate_taint(arg)
|
|
664
|
+
if arg_taint:
|
|
665
|
+
return arg_taint
|
|
666
|
+
return None
|
|
667
|
+
|
|
668
|
+
def _eval_collection_taint(
|
|
669
|
+
self, node: ast.List | ast.Tuple | ast.Set
|
|
670
|
+
) -> TaintVariable | None:
|
|
671
|
+
for elt in node.elts:
|
|
672
|
+
t = self._evaluate_taint(elt)
|
|
673
|
+
if t:
|
|
674
|
+
return t
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
def _eval_composite_taint(self, node: ast.AST) -> TaintVariable | None:
|
|
678
|
+
if isinstance(node, ast.BinOp):
|
|
679
|
+
return self._evaluate_taint(node.left) or self._evaluate_taint(node.right)
|
|
680
|
+
if isinstance(node, ast.JoinedStr):
|
|
681
|
+
for val in node.values:
|
|
682
|
+
if isinstance(val, ast.FormattedValue):
|
|
683
|
+
t = self._evaluate_taint(val.value)
|
|
684
|
+
if t:
|
|
685
|
+
return t
|
|
686
|
+
elif isinstance(node, (ast.List, ast.Tuple, ast.Set)):
|
|
687
|
+
return self._eval_collection_taint(node)
|
|
688
|
+
elif isinstance(node, ast.Subscript):
|
|
689
|
+
return self._evaluate_taint(node.value)
|
|
690
|
+
return None
|
|
691
|
+
|
|
692
|
+
def _evaluate_taint(self, node: ast.AST) -> TaintVariable | None:
|
|
693
|
+
"""Recursively evaluate if an AST expression produces tainted data."""
|
|
694
|
+
if isinstance(node, ast.Name):
|
|
695
|
+
return self._get_var(node.id)
|
|
696
|
+
|
|
697
|
+
source_taint = self._eval_direct_source(node)
|
|
698
|
+
if source_taint:
|
|
699
|
+
return source_taint
|
|
700
|
+
|
|
701
|
+
if isinstance(node, ast.Call):
|
|
702
|
+
return self._eval_call_taint(node)
|
|
703
|
+
|
|
704
|
+
return self._eval_composite_taint(node)
|
|
705
|
+
|
|
706
|
+
@staticmethod
|
|
707
|
+
def _node_summary(node: ast.AST) -> str:
|
|
708
|
+
"""Create a compact human-readable string summary of an AST node."""
|
|
709
|
+
if isinstance(node, ast.Name):
|
|
710
|
+
return node.id
|
|
711
|
+
if isinstance(node, ast.Constant):
|
|
712
|
+
return repr(node.value)
|
|
713
|
+
if isinstance(node, ast.Call):
|
|
714
|
+
func_str = TaintEngine._resolve_call_name(node.func) or "func"
|
|
715
|
+
return f"{func_str}(...)"
|
|
716
|
+
if isinstance(node, ast.BinOp):
|
|
717
|
+
return f"{_ModuleTaintVisitor._node_summary(node.left)} + {_ModuleTaintVisitor._node_summary(node.right)}"
|
|
718
|
+
if isinstance(node, ast.JoinedStr):
|
|
719
|
+
return 'f"..."'
|
|
720
|
+
return "expr"
|