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.
@@ -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"