pycheck-neuron 1.0.0__tar.gz → 1.0.1__tar.gz
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.
- {pycheck_neuron-1.0.0/pycheck_neuron.egg-info → pycheck_neuron-1.0.1}/PKG-INFO +1 -1
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/__init__.py +1 -1
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/analyzer.py +9 -5
- pycheck_neuron-1.0.1/pycheck/flow.py +547 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/rules.py +90 -12
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1/pycheck_neuron.egg-info}/PKG-INFO +1 -1
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pyproject.toml +1 -1
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/tests/test_rules.py +135 -0
- pycheck_neuron-1.0.0/pycheck/flow.py +0 -265
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/LICENSE +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/README.md +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/__main__.py +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/cli.py +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/SOURCES.txt +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/entry_points.txt +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/requires.txt +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/top_level.txt +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/setup.cfg +0 -0
- {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/tests/test_cli.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycheck-neuron
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: 32-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
|
|
5
5
|
Author: NEURON Team
|
|
6
6
|
Author-email: Fayo Ibrahim <fayo@neuron-lab.org>
|
|
@@ -3,7 +3,7 @@ PyCheck — NEURON ML Safety Analyzer
|
|
|
3
3
|
Catches temporal leaks, causal confusion, and uncertainty bugs in Python ML scripts.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
__version__ = "0.
|
|
6
|
+
__version__ = "1.0.1"
|
|
7
7
|
|
|
8
8
|
from pycheck.analyzer import analyze_file, format_output
|
|
9
9
|
from pycheck.rules import ALL_RULES, RULES_BY_CODE
|
|
@@ -109,14 +109,18 @@ def analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
|
|
|
109
109
|
source_lines = source.split('\n')
|
|
110
110
|
tree = ast.parse(source, filename=filepath)
|
|
111
111
|
|
|
112
|
-
# Phase 1:
|
|
113
|
-
analyzer = PyCheckAnalyzer(source_lines, filepath)
|
|
114
|
-
analyzer.visit(tree)
|
|
115
|
-
|
|
116
|
-
# Phase 2: Data flow taint analysis
|
|
112
|
+
# Phase 1: Data flow taint & provenance analysis
|
|
117
113
|
tracker = TaintTracker()
|
|
118
114
|
flow_diagnostics = tracker.analyze(tree, source_lines)
|
|
119
115
|
|
|
116
|
+
# Phase 2: Rule-based AST analysis (with provenance populated in ctx)
|
|
117
|
+
analyzer = PyCheckAnalyzer(source_lines, filepath)
|
|
118
|
+
analyzer.ctx.tainted_vars = {k: v.reason for k, v in tracker.tainted_vars.items()}
|
|
119
|
+
analyzer.ctx.train_vars = tracker.train_vars
|
|
120
|
+
analyzer.ctx.test_vars = tracker.test_vars
|
|
121
|
+
analyzer.ctx.test_contaminated_vars = tracker.test_contaminated_vars
|
|
122
|
+
analyzer.visit(tree)
|
|
123
|
+
|
|
120
124
|
# Combine and deduplicate
|
|
121
125
|
all_diagnostics = analyzer.diagnostics + flow_diagnostics
|
|
122
126
|
|
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCheck Data Flow Engine — Taint Propagation Analysis
|
|
3
|
+
Tracks how data flows through assignments, function returns, and method chains
|
|
4
|
+
to detect when future-tainted data reaches training/prediction sinks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from typing import Dict, Set, List, Optional, Tuple
|
|
9
|
+
from pycheck.rules import Diagnostic
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TaintSource:
|
|
13
|
+
"""Represents a source of tainted (future-leaked) data."""
|
|
14
|
+
def __init__(self, var_name: str, reason: str, line: int, col: int):
|
|
15
|
+
self.var_name = var_name
|
|
16
|
+
self.reason = reason
|
|
17
|
+
self.line = line
|
|
18
|
+
self.col = col
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TaintTracker:
|
|
22
|
+
"""
|
|
23
|
+
Tracks taint propagation and partition data provenance through Python AST.
|
|
24
|
+
|
|
25
|
+
Taint sources: shift(-n), bfill(), future column access, etc.
|
|
26
|
+
Provenance sources: train_test_split, temporal slicing, cross-validation splits.
|
|
27
|
+
Contamination: concatenating or reassigning train data with test partitions.
|
|
28
|
+
Sinks: .fit(), .fit_transform(), model training calls.
|
|
29
|
+
|
|
30
|
+
Emits:
|
|
31
|
+
- F001: Future-tainted data flows into sinks
|
|
32
|
+
- F002: Test partition or contaminated data flows into training sinks
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.tainted_vars: Dict[str, TaintSource] = {}
|
|
37
|
+
self.diagnostics: List[Diagnostic] = []
|
|
38
|
+
self.constants: Dict[str, any] = {}
|
|
39
|
+
|
|
40
|
+
# Partition provenance tracking
|
|
41
|
+
self.train_vars: Set[str] = set()
|
|
42
|
+
self.test_vars: Set[str] = set()
|
|
43
|
+
self.test_contaminated_vars: Dict[str, str] = {}
|
|
44
|
+
self.aliases: Dict[str, str] = {}
|
|
45
|
+
self.functions: Dict[str, ast.FunctionDef] = {}
|
|
46
|
+
self.train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train_df', 'train_data', 'train_set')
|
|
47
|
+
self.test_markers = ('_test', 'test_', 'x_test', 'y_test', 'x_val', 'y_val', '_val', 'val_', '_eval', 'eval_', 'holdout', 'validation', 'test_df', 'test_data', 'test_set')
|
|
48
|
+
|
|
49
|
+
def analyze(self, tree: ast.AST, source_lines: List[str]) -> List[Diagnostic]:
|
|
50
|
+
"""Run taint and provenance analysis on a full AST."""
|
|
51
|
+
self.tainted_vars.clear()
|
|
52
|
+
self.diagnostics.clear()
|
|
53
|
+
self.constants.clear()
|
|
54
|
+
self.train_vars.clear()
|
|
55
|
+
self.test_vars.clear()
|
|
56
|
+
self.test_contaminated_vars.clear()
|
|
57
|
+
self.aliases.clear()
|
|
58
|
+
self.functions.clear()
|
|
59
|
+
|
|
60
|
+
# Two-pass analysis:
|
|
61
|
+
# Pass 1: Identify taint & provenance sources, follow reassignments and combinations
|
|
62
|
+
# Pass 2: Detect sensitive sinks
|
|
63
|
+
self._collect_taints(tree)
|
|
64
|
+
self._check_sinks(tree)
|
|
65
|
+
|
|
66
|
+
return self.diagnostics
|
|
67
|
+
|
|
68
|
+
def _iter_statements(self, node: ast.AST):
|
|
69
|
+
"""Yield statements recursively in source code execution order."""
|
|
70
|
+
if hasattr(node, 'body') and isinstance(node.body, list):
|
|
71
|
+
for stmt in node.body:
|
|
72
|
+
yield stmt
|
|
73
|
+
yield from self._iter_statements(stmt)
|
|
74
|
+
if hasattr(node, 'orelse') and isinstance(node.orelse, list):
|
|
75
|
+
for stmt in node.orelse:
|
|
76
|
+
yield stmt
|
|
77
|
+
yield from self._iter_statements(stmt)
|
|
78
|
+
if hasattr(node, 'handlers') and isinstance(node.handlers, list):
|
|
79
|
+
for h in node.handlers:
|
|
80
|
+
yield from self._iter_statements(h)
|
|
81
|
+
if hasattr(node, 'finalbody') and isinstance(node.finalbody, list):
|
|
82
|
+
for stmt in node.finalbody:
|
|
83
|
+
yield stmt
|
|
84
|
+
yield from self._iter_statements(stmt)
|
|
85
|
+
|
|
86
|
+
def _extract_constant(self, expr):
|
|
87
|
+
if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)):
|
|
88
|
+
return expr.value
|
|
89
|
+
if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
|
|
90
|
+
if isinstance(expr.operand, ast.Constant) and isinstance(expr.operand.value, (int, float)):
|
|
91
|
+
return -expr.operand.value
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
def _is_negative_period(self, expr) -> bool:
|
|
95
|
+
if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
|
|
96
|
+
return True
|
|
97
|
+
if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)) and expr.value < 0:
|
|
98
|
+
return True
|
|
99
|
+
if isinstance(expr, ast.Name):
|
|
100
|
+
val = self.constants.get(expr.id)
|
|
101
|
+
if val is not None and isinstance(val, (int, float)) and val < 0:
|
|
102
|
+
return True
|
|
103
|
+
return False
|
|
104
|
+
|
|
105
|
+
def _collect_taints(self, tree: ast.AST):
|
|
106
|
+
"""Pass 1: Walk the AST, index helper functions, track taint and provenance."""
|
|
107
|
+
# Index helper functions defined in the AST
|
|
108
|
+
for node in ast.walk(tree):
|
|
109
|
+
if isinstance(node, ast.FunctionDef):
|
|
110
|
+
self.functions[node.name] = node
|
|
111
|
+
|
|
112
|
+
# Extract literal constants
|
|
113
|
+
for node in ast.walk(tree):
|
|
114
|
+
if isinstance(node, ast.Assign):
|
|
115
|
+
for target in node.targets:
|
|
116
|
+
if isinstance(target, ast.Name):
|
|
117
|
+
val = self._extract_constant(node.value)
|
|
118
|
+
if val is not None:
|
|
119
|
+
self.constants[target.id] = val
|
|
120
|
+
|
|
121
|
+
# Linear traversal of statements for data-flow & provenance propagation
|
|
122
|
+
for stmt in self._iter_statements(tree):
|
|
123
|
+
if isinstance(stmt, ast.Assign):
|
|
124
|
+
# 1. Check for split calls (train_test_split)
|
|
125
|
+
if isinstance(stmt.value, ast.Call):
|
|
126
|
+
call_name = self._get_call_name(stmt.value)
|
|
127
|
+
if call_name == 'train_test_split':
|
|
128
|
+
for target in stmt.targets:
|
|
129
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
130
|
+
names = [self._get_var_name(e) for e in target.elts]
|
|
131
|
+
if len(names) >= 4:
|
|
132
|
+
if names[0]: self.train_vars.add(names[0])
|
|
133
|
+
if names[1]: self.test_vars.add(names[1])
|
|
134
|
+
if names[2]: self.train_vars.add(names[2])
|
|
135
|
+
if names[3]: self.test_vars.add(names[3])
|
|
136
|
+
elif len(names) >= 2:
|
|
137
|
+
if names[0]: self.train_vars.add(names[0])
|
|
138
|
+
if names[1]: self.test_vars.add(names[1])
|
|
139
|
+
|
|
140
|
+
# 2. Check for temporal slicing splits: df[:split_point] vs df[split_point:]
|
|
141
|
+
if isinstance(stmt.value, ast.Subscript):
|
|
142
|
+
sl = stmt.value.slice
|
|
143
|
+
if isinstance(sl, ast.Slice):
|
|
144
|
+
for target in stmt.targets:
|
|
145
|
+
t_name = self._get_var_name(target)
|
|
146
|
+
if t_name:
|
|
147
|
+
if sl.lower is None or (isinstance(sl.lower, ast.Constant) and sl.lower.value == 0):
|
|
148
|
+
if sl.upper is not None:
|
|
149
|
+
self.train_vars.add(t_name)
|
|
150
|
+
elif sl.upper is None and sl.lower is not None:
|
|
151
|
+
self.test_vars.add(t_name)
|
|
152
|
+
elif isinstance(sl, ast.Name):
|
|
153
|
+
for target in stmt.targets:
|
|
154
|
+
t_name = self._get_var_name(target)
|
|
155
|
+
if t_name:
|
|
156
|
+
if sl.id in self.train_vars or any(m in sl.id.lower() for m in self.train_markers):
|
|
157
|
+
self.train_vars.add(t_name)
|
|
158
|
+
elif sl.id in self.test_vars or any(m in sl.id.lower() for m in self.test_markers):
|
|
159
|
+
self.test_vars.add(t_name)
|
|
160
|
+
|
|
161
|
+
# 3. Register naming evidence
|
|
162
|
+
for target in stmt.targets:
|
|
163
|
+
t_name = self._get_var_name(target)
|
|
164
|
+
if t_name:
|
|
165
|
+
t_lower = t_name.lower()
|
|
166
|
+
if any(m in t_lower for m in self.train_markers) and t_name not in self.test_contaminated_vars:
|
|
167
|
+
self.train_vars.add(t_name)
|
|
168
|
+
elif any(m in t_lower for m in self.test_markers):
|
|
169
|
+
self.test_vars.add(t_name)
|
|
170
|
+
|
|
171
|
+
# 4. Standard future-shift / bfill taint check
|
|
172
|
+
self._check_assignment_taint(stmt)
|
|
173
|
+
|
|
174
|
+
# 5. Check test contamination propagation
|
|
175
|
+
contamination = self._expr_contains_test(stmt.value)
|
|
176
|
+
for target in stmt.targets:
|
|
177
|
+
t_name = self._get_var_name(target)
|
|
178
|
+
if not t_name:
|
|
179
|
+
continue
|
|
180
|
+
if contamination:
|
|
181
|
+
self.test_contaminated_vars[t_name] = contamination
|
|
182
|
+
self.train_vars.discard(t_name)
|
|
183
|
+
else:
|
|
184
|
+
# Follow aliases
|
|
185
|
+
src_name = self._get_var_name(stmt.value)
|
|
186
|
+
if src_name:
|
|
187
|
+
self.aliases[t_name] = src_name
|
|
188
|
+
if src_name in self.test_contaminated_vars:
|
|
189
|
+
self.test_contaminated_vars[t_name] = f"alias of contaminated '{src_name}' ({self.test_contaminated_vars[src_name]})"
|
|
190
|
+
self.train_vars.discard(t_name)
|
|
191
|
+
elif src_name in self.test_vars:
|
|
192
|
+
self.test_vars.add(t_name)
|
|
193
|
+
elif src_name in self.train_vars and t_name not in self.test_contaminated_vars:
|
|
194
|
+
self.train_vars.add(t_name)
|
|
195
|
+
|
|
196
|
+
elif isinstance(stmt, ast.AugAssign):
|
|
197
|
+
self._check_augassign_taint(stmt)
|
|
198
|
+
contamination = self._expr_contains_test(stmt.value)
|
|
199
|
+
t_name = self._get_var_name(stmt.target)
|
|
200
|
+
if t_name and contamination:
|
|
201
|
+
self.test_contaminated_vars[t_name] = f"augmented assignment ({type(stmt.op).__name__}) with {contamination}"
|
|
202
|
+
self.train_vars.discard(t_name)
|
|
203
|
+
|
|
204
|
+
elif isinstance(stmt, ast.For):
|
|
205
|
+
if isinstance(stmt.iter, ast.Call) and self._get_call_name(stmt.iter) in ('split',):
|
|
206
|
+
if isinstance(stmt.target, (ast.Tuple, ast.List)) and len(stmt.target.elts) == 2:
|
|
207
|
+
idx0 = self._get_var_name(stmt.target.elts[0])
|
|
208
|
+
idx1 = self._get_var_name(stmt.target.elts[1])
|
|
209
|
+
if idx0: self.train_vars.add(idx0)
|
|
210
|
+
if idx1: self.test_vars.add(idx1)
|
|
211
|
+
|
|
212
|
+
def _check_assignment_taint(self, node: ast.Assign):
|
|
213
|
+
"""Check if an assignment creates or propagates a taint."""
|
|
214
|
+
# Get the taint from the RHS
|
|
215
|
+
taint_reason = self._expr_taint(node.value)
|
|
216
|
+
if taint_reason is None:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Mark all LHS targets as tainted
|
|
220
|
+
for target in node.targets:
|
|
221
|
+
var_name = self._get_var_name(target)
|
|
222
|
+
if var_name:
|
|
223
|
+
self.tainted_vars[var_name] = TaintSource(
|
|
224
|
+
var_name, taint_reason, node.lineno, node.col_offset)
|
|
225
|
+
|
|
226
|
+
def _check_augassign_taint(self, node: ast.AugAssign):
|
|
227
|
+
"""Check if an augmented assignment (+=, etc.) propagates taint."""
|
|
228
|
+
taint_reason = self._expr_taint(node.value)
|
|
229
|
+
if taint_reason:
|
|
230
|
+
var_name = self._get_var_name(node.target)
|
|
231
|
+
if var_name:
|
|
232
|
+
self.tainted_vars[var_name] = TaintSource(
|
|
233
|
+
var_name, taint_reason, node.lineno, node.col_offset)
|
|
234
|
+
|
|
235
|
+
def _expr_taint(self, node: ast.AST) -> Optional[str]:
|
|
236
|
+
"""
|
|
237
|
+
Determine if an expression produces tainted data.
|
|
238
|
+
Returns the taint reason string, or None if clean.
|
|
239
|
+
"""
|
|
240
|
+
if isinstance(node, ast.Call):
|
|
241
|
+
return self._call_taint(node)
|
|
242
|
+
|
|
243
|
+
if isinstance(node, ast.Name):
|
|
244
|
+
# Propagation: if RHS is a tainted variable, the assignment is tainted
|
|
245
|
+
if node.id in self.tainted_vars:
|
|
246
|
+
return f"derived from tainted variable '{node.id}' ({self.tainted_vars[node.id].reason})"
|
|
247
|
+
|
|
248
|
+
if isinstance(node, ast.BinOp):
|
|
249
|
+
# Binary operations propagate taint from either operand
|
|
250
|
+
left_taint = self._expr_taint(node.left)
|
|
251
|
+
right_taint = self._expr_taint(node.right)
|
|
252
|
+
return left_taint or right_taint
|
|
253
|
+
|
|
254
|
+
if isinstance(node, ast.Subscript):
|
|
255
|
+
# Subscript propagates taint from the value being indexed
|
|
256
|
+
val_taint = self._expr_taint(node.value)
|
|
257
|
+
if val_taint:
|
|
258
|
+
return val_taint
|
|
259
|
+
# Check if indexing a tainted column name
|
|
260
|
+
if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
|
|
261
|
+
col_lower = node.slice.value.lower()
|
|
262
|
+
# Only flag column names that unambiguously refer to future data.
|
|
263
|
+
# 'target' and 'label' are intentionally excluded — they are standard
|
|
264
|
+
# ML label column names and produce false positives when accessed on a
|
|
265
|
+
# correctly split train/test dataset.
|
|
266
|
+
future_cols = {'future_price', 'future_return', 'next_close', 'next_price',
|
|
267
|
+
'next_day', 'tomorrow', 'price_change_next_day', 'future_avg_price'}
|
|
268
|
+
if any(f in col_lower for f in future_cols):
|
|
269
|
+
return f"accessing future-named column '{node.slice.value}'"
|
|
270
|
+
|
|
271
|
+
if isinstance(node, ast.Attribute):
|
|
272
|
+
return self._expr_taint(node.value)
|
|
273
|
+
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
def _call_taint(self, node: ast.Call) -> Optional[str]:
|
|
277
|
+
"""Check if a function call produces tainted output."""
|
|
278
|
+
if isinstance(node.func, ast.Attribute):
|
|
279
|
+
method = node.func.attr
|
|
280
|
+
|
|
281
|
+
# Direct taint sources
|
|
282
|
+
if method == 'shift':
|
|
283
|
+
for arg in node.args:
|
|
284
|
+
if self._is_negative_period(arg):
|
|
285
|
+
return "shift() with negative period (future access)"
|
|
286
|
+
for kw in node.keywords:
|
|
287
|
+
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
288
|
+
return "shift() with negative period (future access)"
|
|
289
|
+
|
|
290
|
+
if method in ('bfill', 'backfill'):
|
|
291
|
+
return f".{method}() backfills from future values"
|
|
292
|
+
|
|
293
|
+
if method == 'fillna':
|
|
294
|
+
for kw in node.keywords:
|
|
295
|
+
if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
|
|
296
|
+
if kw.value.value in ('bfill', 'backfill'):
|
|
297
|
+
return f".fillna(method='{kw.value.value}') backfills from future"
|
|
298
|
+
|
|
299
|
+
if method == 'diff':
|
|
300
|
+
for arg in node.args:
|
|
301
|
+
if self._is_negative_period(arg):
|
|
302
|
+
return ".diff() with negative period (future access)"
|
|
303
|
+
for kw in node.keywords:
|
|
304
|
+
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
305
|
+
return ".diff() with negative period (future access)"
|
|
306
|
+
|
|
307
|
+
# Taint propagation through method chains
|
|
308
|
+
obj_taint = self._expr_taint(node.func.value)
|
|
309
|
+
if obj_taint:
|
|
310
|
+
# Most method calls on tainted data produce tainted output
|
|
311
|
+
non_taint_methods = {'shape', 'dtype', 'dtypes', 'columns', 'index',
|
|
312
|
+
'describe', 'info', 'head', 'tail', 'len', '__len__'}
|
|
313
|
+
if method not in non_taint_methods:
|
|
314
|
+
return obj_taint
|
|
315
|
+
|
|
316
|
+
if isinstance(node.func, ast.Name):
|
|
317
|
+
# Check if calling a function with tainted arguments
|
|
318
|
+
for arg in node.args:
|
|
319
|
+
arg_taint = self._expr_taint(arg)
|
|
320
|
+
if arg_taint:
|
|
321
|
+
return f"function receives tainted input: {arg_taint}"
|
|
322
|
+
|
|
323
|
+
# R2-9 fix: Detect getattr(obj, 'shift')(-1) and similar patterns
|
|
324
|
+
# Pattern: call(getattr(obj, 'shift'), -1) => obj.shift(-1)
|
|
325
|
+
if isinstance(node.func, ast.Call):
|
|
326
|
+
inner_call = node.func
|
|
327
|
+
if isinstance(inner_call.func, ast.Name) and inner_call.func.id == 'getattr':
|
|
328
|
+
if len(inner_call.args) >= 2 and isinstance(inner_call.args[1], ast.Constant):
|
|
329
|
+
attr_name = inner_call.args[1].value
|
|
330
|
+
if attr_name == 'shift':
|
|
331
|
+
for arg in node.args:
|
|
332
|
+
if self._is_negative_period(arg):
|
|
333
|
+
return "getattr(..., 'shift')() with negative period (future access)"
|
|
334
|
+
for kw in node.keywords:
|
|
335
|
+
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
336
|
+
return "getattr(..., 'shift')() with negative period (future access)"
|
|
337
|
+
elif attr_name == 'diff':
|
|
338
|
+
for arg in node.args:
|
|
339
|
+
if self._is_negative_period(arg):
|
|
340
|
+
return "getattr(..., 'diff')() with negative period (future access)"
|
|
341
|
+
for kw in node.keywords:
|
|
342
|
+
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
343
|
+
return "getattr(..., 'diff')() with negative period (future access)"
|
|
344
|
+
elif attr_name in ('bfill', 'backfill'):
|
|
345
|
+
return f"getattr(..., '{attr_name}')() backfills from future values"
|
|
346
|
+
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def _check_sinks(self, tree: ast.AST):
|
|
350
|
+
"""Pass 2: Check if tainted or test-contaminated data flows into sensitive sinks."""
|
|
351
|
+
for node in ast.walk(tree):
|
|
352
|
+
if not isinstance(node, ast.Call):
|
|
353
|
+
continue
|
|
354
|
+
|
|
355
|
+
if isinstance(node.func, ast.Attribute):
|
|
356
|
+
method = node.func.attr
|
|
357
|
+
sinks = {'fit', 'fit_transform', 'fit_predict', 'train',
|
|
358
|
+
'partial_fit', 'predict', 'score'}
|
|
359
|
+
|
|
360
|
+
if method in sinks:
|
|
361
|
+
# Check positional arguments for future taint (F001)
|
|
362
|
+
for arg in node.args:
|
|
363
|
+
arg_taint = self._expr_taint(arg)
|
|
364
|
+
if arg_taint:
|
|
365
|
+
self.diagnostics.append(Diagnostic(
|
|
366
|
+
node.lineno, node.col_offset, "error", "F001",
|
|
367
|
+
f"Tainted data flows into .{method}(): {arg_taint}",
|
|
368
|
+
"Ensure training/prediction data is free of future information"))
|
|
369
|
+
# Check keyword arguments for future taint (F001)
|
|
370
|
+
for kw in node.keywords:
|
|
371
|
+
kw_taint = self._expr_taint(kw.value)
|
|
372
|
+
if kw_taint:
|
|
373
|
+
kw_name = kw.arg if kw.arg else "**kwargs"
|
|
374
|
+
self.diagnostics.append(Diagnostic(
|
|
375
|
+
node.lineno, node.col_offset, "error", "F001",
|
|
376
|
+
f"Tainted data flows into .{method}() via keyword '{kw_name}': {kw_taint}",
|
|
377
|
+
"Ensure training/prediction data is free of future information"))
|
|
378
|
+
|
|
379
|
+
# Check F002: Data contamination & test partitions in training sinks
|
|
380
|
+
training_sinks = {'fit', 'fit_transform', 'fit_predict', 'train', 'partial_fit'}
|
|
381
|
+
if method in training_sinks:
|
|
382
|
+
obj_name = self._get_obj_name(node.func.value)
|
|
383
|
+
safe_objects = {'fig', 'figure', 'ax', 'axes', 'plt', 'layout',
|
|
384
|
+
'canvas', 'widget', 'app', 'db', 'session', 'socket',
|
|
385
|
+
'parser', 'formatter', 'logger', 'handler'}
|
|
386
|
+
if obj_name and obj_name.lower() in safe_objects:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
for arg in node.args:
|
|
390
|
+
var_name = self._get_var_name(arg)
|
|
391
|
+
if var_name:
|
|
392
|
+
base_var = var_name.split('.')[0]
|
|
393
|
+
if base_var in self.test_contaminated_vars:
|
|
394
|
+
reason = self.test_contaminated_vars[base_var]
|
|
395
|
+
self.diagnostics.append(Diagnostic(
|
|
396
|
+
node.lineno, node.col_offset, "error", "F002",
|
|
397
|
+
f"Data contamination: '{base_var}' contains test partition data ({reason}) but is passed to .{method}()",
|
|
398
|
+
"Ensure data passed to .{method}() contains only genuine training data without test partitions"))
|
|
399
|
+
elif base_var in self.test_vars or (base_var not in self.train_vars and any(m in base_var.lower() for m in self.test_markers)):
|
|
400
|
+
self.diagnostics.append(Diagnostic(
|
|
401
|
+
node.lineno, node.col_offset, "error", "F002",
|
|
402
|
+
f"Test data passed to training sink: '{base_var}' is a test/validation partition but is passed to .{method}()",
|
|
403
|
+
"Call .{method}() only on training data, use .transform() or .predict() on test data"))
|
|
404
|
+
|
|
405
|
+
for kw in node.keywords:
|
|
406
|
+
var_name = self._get_var_name(kw.value)
|
|
407
|
+
if var_name:
|
|
408
|
+
base_var = var_name.split('.')[0]
|
|
409
|
+
kw_name = kw.arg if kw.arg else "**kwargs"
|
|
410
|
+
if base_var in self.test_contaminated_vars:
|
|
411
|
+
reason = self.test_contaminated_vars[base_var]
|
|
412
|
+
self.diagnostics.append(Diagnostic(
|
|
413
|
+
node.lineno, node.col_offset, "error", "F002",
|
|
414
|
+
f"Data contamination: keyword '{kw_name}' receives '{base_var}' which contains test partition data ({reason}) in .{method}()",
|
|
415
|
+
"Ensure data passed to .{method}() contains only genuine training data without test partitions"))
|
|
416
|
+
elif base_var in self.test_vars or (base_var not in self.train_vars and any(m in base_var.lower() for m in self.test_markers)):
|
|
417
|
+
self.diagnostics.append(Diagnostic(
|
|
418
|
+
node.lineno, node.col_offset, "error", "F002",
|
|
419
|
+
f"Test data passed to training sink: keyword '{kw_name}' receives test partition '{base_var}' in .{method}()",
|
|
420
|
+
"Call .{method}() only on training data, use .transform() or .predict() on test data"))
|
|
421
|
+
|
|
422
|
+
def _expr_contains_test(self, expr: Optional[ast.AST]) -> Optional[str]:
|
|
423
|
+
"""Check if an expression incorporates test partition data or contaminated variables."""
|
|
424
|
+
if expr is None:
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
# 1. Direct variable name
|
|
428
|
+
if isinstance(expr, ast.Name):
|
|
429
|
+
var_id = expr.id
|
|
430
|
+
if var_id in self.test_contaminated_vars:
|
|
431
|
+
return f"contaminated variable '{var_id}' ({self.test_contaminated_vars[var_id]})"
|
|
432
|
+
if var_id in self.test_vars:
|
|
433
|
+
return f"test partition '{var_id}'"
|
|
434
|
+
if var_id not in self.train_vars and any(m in var_id.lower() for m in self.test_markers):
|
|
435
|
+
return f"test partition '{var_id}'"
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
# 2. Function / Method Calls
|
|
439
|
+
if isinstance(expr, ast.Call):
|
|
440
|
+
call_name = self._get_call_name(expr)
|
|
441
|
+
# Concatenation: pd.concat([X_train, X_test]), np.concatenate, np.vstack
|
|
442
|
+
if call_name in ('concat', 'concatenate', 'vstack', 'hstack'):
|
|
443
|
+
if expr.args:
|
|
444
|
+
arg0 = expr.args[0]
|
|
445
|
+
if isinstance(arg0, (ast.List, ast.Tuple)):
|
|
446
|
+
for elt in arg0.elts:
|
|
447
|
+
reason = self._expr_contains_test(elt)
|
|
448
|
+
if reason:
|
|
449
|
+
return f"concatenation ({call_name}) including {reason}"
|
|
450
|
+
else:
|
|
451
|
+
reason = self._expr_contains_test(arg0)
|
|
452
|
+
if reason:
|
|
453
|
+
return f"concatenation ({call_name}) including {reason}"
|
|
454
|
+
|
|
455
|
+
# Method calls: .append(), .merge(), .join()
|
|
456
|
+
if isinstance(expr.func, ast.Attribute):
|
|
457
|
+
method = expr.func.attr
|
|
458
|
+
if method in ('append', 'merge', 'join'):
|
|
459
|
+
obj_reason = self._expr_contains_test(expr.func.value)
|
|
460
|
+
if obj_reason:
|
|
461
|
+
return f".{method}() on {obj_reason}"
|
|
462
|
+
for arg in expr.args:
|
|
463
|
+
arg_reason = self._expr_contains_test(arg)
|
|
464
|
+
if arg_reason:
|
|
465
|
+
return f".{method}() with {arg_reason}"
|
|
466
|
+
|
|
467
|
+
# Helper functions defined in this module
|
|
468
|
+
if isinstance(expr.func, ast.Name):
|
|
469
|
+
func_name = expr.func.id
|
|
470
|
+
if func_name in self.functions:
|
|
471
|
+
f_def = self.functions[func_name]
|
|
472
|
+
param_names = [a.arg for a in f_def.args.args]
|
|
473
|
+
contaminated_params = []
|
|
474
|
+
for idx, arg in enumerate(expr.args):
|
|
475
|
+
reason = self._expr_contains_test(arg)
|
|
476
|
+
if reason:
|
|
477
|
+
p_name = param_names[idx] if idx < len(param_names) else f"arg{idx}"
|
|
478
|
+
contaminated_params.append((p_name, reason))
|
|
479
|
+
|
|
480
|
+
if contaminated_params:
|
|
481
|
+
# Check return statements in f_def
|
|
482
|
+
ret_nodes = [n for n in ast.walk(f_def) if isinstance(n, ast.Return) and n.value is not None]
|
|
483
|
+
for p_name, reason in contaminated_params:
|
|
484
|
+
for ret in ret_nodes:
|
|
485
|
+
ret_names = {n.id for n in ast.walk(ret.value) if isinstance(n, ast.Name)}
|
|
486
|
+
if p_name in ret_names:
|
|
487
|
+
return f"helper function '{func_name}()' returning {reason}"
|
|
488
|
+
p_name, reason = contaminated_params[0]
|
|
489
|
+
return f"helper function '{func_name}()' receiving {reason}"
|
|
490
|
+
|
|
491
|
+
# 3. Binary operations: a + b
|
|
492
|
+
if isinstance(expr, ast.BinOp):
|
|
493
|
+
left_r = self._expr_contains_test(expr.left)
|
|
494
|
+
if left_r:
|
|
495
|
+
return left_r
|
|
496
|
+
return self._expr_contains_test(expr.right)
|
|
497
|
+
|
|
498
|
+
# 4. List / Tuple: [a, b]
|
|
499
|
+
if isinstance(expr, (ast.List, ast.Tuple)):
|
|
500
|
+
for elt in expr.elts:
|
|
501
|
+
r = self._expr_contains_test(elt)
|
|
502
|
+
if r:
|
|
503
|
+
return r
|
|
504
|
+
|
|
505
|
+
# 5. Subscript: obj[...]
|
|
506
|
+
if isinstance(expr, ast.Subscript):
|
|
507
|
+
val_r = self._expr_contains_test(expr.value)
|
|
508
|
+
if val_r:
|
|
509
|
+
return val_r
|
|
510
|
+
slice_r = self._expr_contains_test(expr.slice)
|
|
511
|
+
if slice_r:
|
|
512
|
+
return f"indexed by {slice_r}"
|
|
513
|
+
|
|
514
|
+
# 6. Attribute: obj.attr
|
|
515
|
+
if isinstance(expr, ast.Attribute):
|
|
516
|
+
return self._expr_contains_test(expr.value)
|
|
517
|
+
|
|
518
|
+
return None
|
|
519
|
+
|
|
520
|
+
def _get_call_name(self, node: ast.AST) -> str:
|
|
521
|
+
if isinstance(node, ast.Call):
|
|
522
|
+
if isinstance(node.func, ast.Name):
|
|
523
|
+
return node.func.id
|
|
524
|
+
if isinstance(node.func, ast.Attribute):
|
|
525
|
+
return node.func.attr
|
|
526
|
+
return ""
|
|
527
|
+
|
|
528
|
+
def _get_obj_name(self, node: ast.AST) -> Optional[str]:
|
|
529
|
+
if isinstance(node, ast.Name):
|
|
530
|
+
return node.id
|
|
531
|
+
if isinstance(node, ast.Attribute):
|
|
532
|
+
return node.attr
|
|
533
|
+
return None
|
|
534
|
+
|
|
535
|
+
def _get_var_name(self, node: ast.AST) -> Optional[str]:
|
|
536
|
+
"""Extract variable name from an assignment target or expression."""
|
|
537
|
+
if isinstance(node, ast.Name):
|
|
538
|
+
return node.id
|
|
539
|
+
if isinstance(node, ast.Subscript):
|
|
540
|
+
base = self._get_var_name(node.value)
|
|
541
|
+
if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
|
|
542
|
+
return f"{base}.{node.slice.value}" if base else node.slice.value
|
|
543
|
+
return base
|
|
544
|
+
if isinstance(node, ast.Attribute):
|
|
545
|
+
base = self._get_var_name(node.value)
|
|
546
|
+
return f"{base}.{node.attr}" if base else node.attr
|
|
547
|
+
return None
|
|
@@ -49,6 +49,9 @@ class AnalysisContext:
|
|
|
49
49
|
self.loop_var: Optional[str] = None
|
|
50
50
|
self.assignments: Dict[str, ast.AST] = {} # var_name -> assigned node
|
|
51
51
|
self.constants: Dict[str, any] = {} # var_name -> literal constant value
|
|
52
|
+
self.train_vars: Set[str] = set()
|
|
53
|
+
self.test_vars: Set[str] = set()
|
|
54
|
+
self.test_contaminated_vars: Dict[str, str] = {}
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
# ═══════════════════════════════════════════════════════════════
|
|
@@ -311,15 +314,25 @@ class T010_FitBeforeSplit(Rule):
|
|
|
311
314
|
severity = "error"
|
|
312
315
|
category = "TemporalLeak"
|
|
313
316
|
|
|
314
|
-
def _is_train_data(self, node):
|
|
317
|
+
def _is_train_data(self, node, ctx=None):
|
|
315
318
|
"""Recursively check if an AST node represents training data."""
|
|
319
|
+
var_name = self._get_obj_name(node)
|
|
320
|
+
if var_name and ctx is not None:
|
|
321
|
+
base_var = var_name.split('.')[0]
|
|
322
|
+
if hasattr(ctx, 'test_contaminated_vars') and base_var in ctx.test_contaminated_vars:
|
|
323
|
+
return False
|
|
324
|
+
if hasattr(ctx, 'test_vars') and base_var in ctx.test_vars:
|
|
325
|
+
return False
|
|
326
|
+
if hasattr(ctx, 'train_vars') and base_var in ctx.train_vars:
|
|
327
|
+
return True
|
|
328
|
+
|
|
316
329
|
train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train')
|
|
317
330
|
if isinstance(node, ast.Name):
|
|
318
331
|
return any(m in node.id.lower() for m in train_markers)
|
|
319
332
|
if isinstance(node, ast.Subscript):
|
|
320
|
-
return self._is_train_data(node.value)
|
|
333
|
+
return self._is_train_data(node.value, ctx)
|
|
321
334
|
if isinstance(node, ast.Attribute):
|
|
322
|
-
return self._is_train_data(node.value)
|
|
335
|
+
return self._is_train_data(node.value, ctx)
|
|
323
336
|
return False
|
|
324
337
|
|
|
325
338
|
def check_node(self, node, ctx):
|
|
@@ -334,10 +347,17 @@ class T010_FitBeforeSplit(Rule):
|
|
|
334
347
|
'parser', 'formatter', 'logger', 'handler'}
|
|
335
348
|
if obj_name and obj_name.lower() in safe_objects:
|
|
336
349
|
return []
|
|
350
|
+
|
|
351
|
+
# If arguments are contaminated, Flow engine F002 emits specific data contamination diagnostics
|
|
352
|
+
if ctx is not None and hasattr(ctx, 'test_contaminated_vars'):
|
|
353
|
+
for a in node.args:
|
|
354
|
+
v_name = self._get_obj_name(a)
|
|
355
|
+
if v_name and v_name.split('.')[0] in ctx.test_contaminated_vars:
|
|
356
|
+
return []
|
|
337
357
|
|
|
338
358
|
# Suppress if all positional arguments are clearly train-only variables
|
|
339
359
|
# (e.g. X_train, y_train, train_df[features], X_tr)
|
|
340
|
-
if node.args and all(self._is_train_data(a) for a in node.args):
|
|
360
|
+
if node.args and all(self._is_train_data(a, ctx) for a in node.args):
|
|
341
361
|
return []
|
|
342
362
|
|
|
343
363
|
if node.func.attr == 'fit':
|
|
@@ -524,6 +544,8 @@ class T017_TargetLaundering(Rule):
|
|
|
524
544
|
def check_node(self, node, ctx):
|
|
525
545
|
target_col_names = {'target', 'success', 'outcome', 'label', 'labels', 'y',
|
|
526
546
|
'profit', 'pnl', 'win', 'return', 'returns', 'future_price'}
|
|
547
|
+
train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train')
|
|
548
|
+
test_markers = ('_test', 'test_', 'x_test', 'y_test', 'x_val', 'y_val', '_val', 'val_', '_eval', 'eval_', 'holdout', 'validation')
|
|
527
549
|
|
|
528
550
|
# 1. Detect target encoding / aggregation: df.groupby(...)['target'].transform('mean')
|
|
529
551
|
if isinstance(node, ast.Call):
|
|
@@ -547,10 +569,23 @@ class T017_TargetLaundering(Rule):
|
|
|
547
569
|
|
|
548
570
|
if col_name and any(t in col_name for t in target_col_names):
|
|
549
571
|
if isinstance(groupby_call, ast.Call) and isinstance(groupby_call.func, ast.Attribute) and groupby_call.func.attr == 'groupby':
|
|
572
|
+
# Check the caller of groupby:
|
|
573
|
+
caller_obj = groupby_call.func.value
|
|
574
|
+
caller_name = ""
|
|
575
|
+
if isinstance(caller_obj, ast.Name):
|
|
576
|
+
caller_name = caller_obj.id.lower()
|
|
577
|
+
elif isinstance(caller_obj, ast.Attribute):
|
|
578
|
+
caller_name = caller_obj.attr.lower()
|
|
579
|
+
|
|
580
|
+
# If the groupby is computed strictly within a training fold,
|
|
581
|
+
# this is a VALID fold-local transformation! Do NOT flag it.
|
|
582
|
+
if any(m in caller_name for m in train_markers):
|
|
583
|
+
return []
|
|
584
|
+
|
|
550
585
|
return [Diagnostic(
|
|
551
586
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
552
|
-
f"Target laundering: grouping by target/outcome column '{col_name}' leaks future labels into features",
|
|
553
|
-
"Compute regime/group statistics strictly
|
|
587
|
+
f"Target laundering: grouping by target/outcome column '{col_name}' across the un-split dataset leaks future test labels into features",
|
|
588
|
+
"Compute regime/group target statistics strictly within the training fold (e.g. train_df.groupby()), and map to test fold with .fillna() for unseen regimes")]
|
|
554
589
|
|
|
555
590
|
# 2. Detect assignment creating laundered columns: df['regime_success_rate'] = ...
|
|
556
591
|
if isinstance(node, ast.Assign):
|
|
@@ -558,6 +593,33 @@ class T017_TargetLaundering(Rule):
|
|
|
558
593
|
if isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant) and isinstance(target.slice.value, str):
|
|
559
594
|
col = target.slice.value.lower()
|
|
560
595
|
if any(marker in col for marker in ('success_rate', 'target_enc', 'target_mean', 'regime_success')):
|
|
596
|
+
target_df = target.value
|
|
597
|
+
target_df_name = ""
|
|
598
|
+
if isinstance(target_df, ast.Name):
|
|
599
|
+
target_df_name = target_df.id.lower()
|
|
600
|
+
elif isinstance(target_df, ast.Attribute):
|
|
601
|
+
target_df_name = target_df.attr.lower()
|
|
602
|
+
|
|
603
|
+
# Case A: Assigning to a training fold dataframe -> valid fold-local transformation!
|
|
604
|
+
if any(m in target_df_name for m in train_markers):
|
|
605
|
+
return []
|
|
606
|
+
|
|
607
|
+
# Case B: Assigning to test/inference fold dataframe (e.g. test_df['regime_success_rate'] = ...)
|
|
608
|
+
if any(m in target_df_name for m in test_markers):
|
|
609
|
+
# Check if unseen regimes at inference time are handled via .fillna()
|
|
610
|
+
has_fillna = False
|
|
611
|
+
if isinstance(node.value, ast.Call):
|
|
612
|
+
if isinstance(node.value.func, ast.Attribute) and node.value.func.attr == 'fillna':
|
|
613
|
+
has_fillna = True
|
|
614
|
+
|
|
615
|
+
if not has_fillna:
|
|
616
|
+
return [Diagnostic(
|
|
617
|
+
node.lineno, node.col_offset, "warning", self.code,
|
|
618
|
+
f"Mapping target statistics to test data '{target_df_name}' without .fillna(): unseen categories/regimes at inference time will produce NaNs",
|
|
619
|
+
"Add .fillna(train_prior_mean) to safely handle unseen categories or market regimes at inference time")]
|
|
620
|
+
return []
|
|
621
|
+
|
|
622
|
+
# Case C: Assigning to un-split / full dataset dataframe (df['regime_success_rate'] = ...)
|
|
561
623
|
return [Diagnostic(
|
|
562
624
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
563
625
|
f"Target laundering: column '{target.slice.value}' encodes target outcomes across full dataset",
|
|
@@ -595,19 +657,35 @@ class C002_TargetInFeatures(Rule):
|
|
|
595
657
|
def check_node(self, node, ctx):
|
|
596
658
|
if not isinstance(node, ast.Subscript):
|
|
597
659
|
return []
|
|
660
|
+
train_markers = ('_train', 'train_', 'x_train', 'y_train', 'x_tr', 'y_tr', '_trn', 'trn_', 'train')
|
|
661
|
+
caller_name = ""
|
|
662
|
+
if isinstance(node.value, ast.Name):
|
|
663
|
+
caller_name = node.value.id.lower()
|
|
664
|
+
elif isinstance(node.value, ast.Attribute):
|
|
665
|
+
caller_name = node.value.attr.lower()
|
|
666
|
+
|
|
598
667
|
# Detect df[['feature1', 'target']] where target-like names are used as features
|
|
599
668
|
if isinstance(node.slice, ast.List):
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
669
|
+
raw_target_names = {'target', 'y', 'label', 'labels', 'outcome', 'y_true',
|
|
670
|
+
'future_price', 'future_return', 'next_close', 'next_price'}
|
|
671
|
+
laundered_target_names = {'regime_success_rate', 'success_rate'}
|
|
603
672
|
for elt in node.slice.elts:
|
|
604
673
|
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
605
674
|
val = elt.value.lower()
|
|
606
|
-
if val in
|
|
675
|
+
if val in raw_target_names:
|
|
676
|
+
return [Diagnostic(
|
|
677
|
+
node.lineno, node.col_offset, self.severity, self.code,
|
|
678
|
+
f"Column '{elt.value}' appears to be a target variable used as a feature",
|
|
679
|
+
"Remove target/outcome columns from feature inputs to avoid data leakage")]
|
|
680
|
+
if val in laundered_target_names or val.endswith('_success_rate') or val.endswith('_target_enc'):
|
|
681
|
+
# If caller is training data (e.g. train_df[['volatility', 'regime_success_rate']]),
|
|
682
|
+
# this is a valid fold-local target encoding feature! Do NOT flag it.
|
|
683
|
+
if any(m in caller_name for m in train_markers):
|
|
684
|
+
continue
|
|
607
685
|
return [Diagnostic(
|
|
608
686
|
node.lineno, node.col_offset, self.severity, self.code,
|
|
609
|
-
f"Column '{elt.value}' appears to be a target or target-laundered variable used as a feature",
|
|
610
|
-
"
|
|
687
|
+
f"Column '{elt.value}' appears to be a target or target-laundered variable used as a feature across full dataset",
|
|
688
|
+
"Compute target-encoded features strictly fold-locally on training data")]
|
|
611
689
|
return []
|
|
612
690
|
|
|
613
691
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pycheck-neuron
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.1
|
|
4
4
|
Summary: 32-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies.
|
|
5
5
|
Author: NEURON Team
|
|
6
6
|
Author-email: Fayo Ibrahim <fayo@neuron-lab.org>
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pycheck-neuron"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.1"
|
|
8
8
|
description = "32-rule static AST analyzer catching temporal lookahead bias, causal confusion, and uncertainty bugs in Python ML code. Zero dependencies."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
@@ -515,4 +515,139 @@ X = df[['volatility', 'regime_success_rate']]
|
|
|
515
515
|
assert any(d.code == "T017" for d in diags), "Target groupby aggregation must trigger T017"
|
|
516
516
|
assert any(d.code == "C002" for d in diags), "regime_success_rate feature selection must trigger C002"
|
|
517
517
|
|
|
518
|
+
def test_provenance_concat_contamination(self):
|
|
519
|
+
code = """
|
|
520
|
+
import pandas as pd
|
|
521
|
+
from sklearn.preprocessing import StandardScaler
|
|
522
|
+
|
|
523
|
+
X_train = pd.DataFrame({'a': [1, 2]})
|
|
524
|
+
X_test = pd.DataFrame({'a': [3, 4]})
|
|
525
|
+
|
|
526
|
+
X_train = pd.concat([X_train, X_test])
|
|
527
|
+
scaler = StandardScaler()
|
|
528
|
+
scaler.fit_transform(X_train)
|
|
529
|
+
"""
|
|
530
|
+
tree = ast.parse(code)
|
|
531
|
+
tracker = TaintTracker()
|
|
532
|
+
results = tracker.analyze(tree, code.split('\n'))
|
|
533
|
+
assert any(d.code == "F002" for d in results), "Reassigned X_train with concatenated test data must trigger F002"
|
|
534
|
+
f002 = [d for d in results if d.code == "F002"][0]
|
|
535
|
+
assert "X_train" in f002.message
|
|
536
|
+
assert "test partition" in f002.message
|
|
537
|
+
|
|
538
|
+
def test_provenance_alias_and_reassignment(self):
|
|
539
|
+
code = """
|
|
540
|
+
import pandas as pd
|
|
541
|
+
from sklearn.preprocessing import StandardScaler
|
|
542
|
+
|
|
543
|
+
X_eval = X_test
|
|
544
|
+
X_train = pd.concat([X_train, X_eval])
|
|
545
|
+
scaler = StandardScaler()
|
|
546
|
+
scaler.fit(X_train)
|
|
547
|
+
"""
|
|
548
|
+
tree = ast.parse(code)
|
|
549
|
+
tracker = TaintTracker()
|
|
550
|
+
results = tracker.analyze(tree, code.split('\n'))
|
|
551
|
+
assert any(d.code == "F002" for d in results), "Alias of test data in concat must trigger F002"
|
|
552
|
+
|
|
553
|
+
def test_provenance_helper_function(self):
|
|
554
|
+
code_leaky = """
|
|
555
|
+
import pandas as pd
|
|
556
|
+
from sklearn.preprocessing import StandardScaler
|
|
557
|
+
|
|
558
|
+
def combine_folds(tr, te):
|
|
559
|
+
return pd.concat([tr, te])
|
|
560
|
+
|
|
561
|
+
X_train = combine_folds(X_train, X_test)
|
|
562
|
+
scaler = StandardScaler()
|
|
563
|
+
scaler.fit_transform(X_train)
|
|
564
|
+
"""
|
|
565
|
+
tree_leaky = ast.parse(code_leaky)
|
|
566
|
+
tracker = TaintTracker()
|
|
567
|
+
results_leaky = tracker.analyze(tree_leaky, code_leaky.split('\n'))
|
|
568
|
+
assert any(d.code == "F002" for d in results_leaky), "Helper function combining test data must trigger F002"
|
|
569
|
+
|
|
570
|
+
code_clean = """
|
|
571
|
+
from sklearn.preprocessing import StandardScaler
|
|
572
|
+
|
|
573
|
+
def clean_data(data):
|
|
574
|
+
return data.dropna()
|
|
575
|
+
|
|
576
|
+
clean_train = clean_data(X_train)
|
|
577
|
+
scaler = StandardScaler()
|
|
578
|
+
scaler.fit_transform(clean_train)
|
|
579
|
+
"""
|
|
580
|
+
tree_clean = ast.parse(code_clean)
|
|
581
|
+
tracker_clean = TaintTracker()
|
|
582
|
+
results_clean = tracker_clean.analyze(tree_clean, code_clean.split('\n'))
|
|
583
|
+
assert not any(d.code == "F002" for d in results_clean), "Clean helper function on training data must not trigger F002"
|
|
584
|
+
|
|
585
|
+
def test_t017_fold_local_target_encoding_clean(self):
|
|
586
|
+
code = """
|
|
587
|
+
# Legitimate fold-local target encoding
|
|
588
|
+
regime_stats = train_df.groupby('regime')['target'].mean()
|
|
589
|
+
global_prior = train_df['target'].mean()
|
|
590
|
+
|
|
591
|
+
train_df['regime_success_rate'] = train_df['regime'].map(regime_stats)
|
|
592
|
+
test_df['regime_success_rate'] = test_df['regime'].map(regime_stats).fillna(global_prior)
|
|
593
|
+
X_train = train_df[['volatility', 'regime_success_rate']]
|
|
594
|
+
"""
|
|
595
|
+
tree = ast.parse(code)
|
|
596
|
+
from pycheck.rules import T017_TargetLaundering, C002_TargetInFeatures
|
|
597
|
+
t017 = T017_TargetLaundering()
|
|
598
|
+
c002 = C002_TargetInFeatures()
|
|
599
|
+
diags = []
|
|
600
|
+
for node in ast.walk(tree):
|
|
601
|
+
diags.extend(t017.check_node(node, None))
|
|
602
|
+
diags.extend(c002.check_node(node, None))
|
|
603
|
+
assert not any(d.code == "T017" for d in diags), f"Fold-local target encoding must not trigger T017: {diags}"
|
|
604
|
+
assert not any(d.code == "C002" for d in diags), f"Fold-local target feature on train_df must not trigger C002: {diags}"
|
|
605
|
+
|
|
606
|
+
def test_t017_global_target_encoding_flagged(self):
|
|
607
|
+
code = """
|
|
608
|
+
# Global target laundering across un-split dataset
|
|
609
|
+
df['regime_success_rate'] = df.groupby('regime')['target'].transform('mean')
|
|
610
|
+
"""
|
|
611
|
+
tree = ast.parse(code)
|
|
612
|
+
from pycheck.rules import T017_TargetLaundering
|
|
613
|
+
t017 = T017_TargetLaundering()
|
|
614
|
+
diags = []
|
|
615
|
+
for node in ast.walk(tree):
|
|
616
|
+
diags.extend(t017.check_node(node, None))
|
|
617
|
+
assert any(d.code == "T017" for d in diags), "Global groupby transform on target must trigger T017"
|
|
618
|
+
|
|
619
|
+
def test_t017_unseen_regimes_inference_warning(self):
|
|
620
|
+
code = """
|
|
621
|
+
regime_stats = train_df.groupby('regime')['target'].mean()
|
|
622
|
+
# Missing .fillna() for unseen regimes at test inference time
|
|
623
|
+
test_df['regime_success_rate'] = test_df['regime'].map(regime_stats)
|
|
624
|
+
"""
|
|
625
|
+
tree = ast.parse(code)
|
|
626
|
+
from pycheck.rules import T017_TargetLaundering
|
|
627
|
+
t017 = T017_TargetLaundering()
|
|
628
|
+
diags = []
|
|
629
|
+
for node in ast.walk(tree):
|
|
630
|
+
diags.extend(t017.check_node(node, None))
|
|
631
|
+
assert any(d.code == "T017" and d.severity == "warning" for d in diags), "Mapping to test_df without fillna must trigger unseen regimes warning"
|
|
632
|
+
|
|
633
|
+
def test_provenance_full_pipeline_analyze_file(self, tmp_path):
|
|
634
|
+
test_file = tmp_path / "leaky_provenance.py"
|
|
635
|
+
test_file.write_text("""
|
|
636
|
+
import pandas as pd
|
|
637
|
+
from sklearn.preprocessing import StandardScaler
|
|
638
|
+
|
|
639
|
+
X_train = pd.DataFrame({'a': [1, 2]})
|
|
640
|
+
X_test = pd.DataFrame({'a': [3, 4]})
|
|
641
|
+
|
|
642
|
+
X_train = pd.concat([X_train, X_test])
|
|
643
|
+
scaler = StandardScaler()
|
|
644
|
+
scaler.fit_transform(X_train)
|
|
645
|
+
""", encoding='utf-8')
|
|
646
|
+
diagnostics, _ = analyze_file(str(test_file))
|
|
647
|
+
assert any(d['code'] == "F002" for d in diagnostics), f"Full analyze_file must detect F002, got: {diagnostics}"
|
|
648
|
+
f002 = [d for d in diagnostics if d['code'] == "F002"][0]
|
|
649
|
+
assert "X_train" in f002['message']
|
|
650
|
+
assert "test partition" in f002['message']
|
|
651
|
+
|
|
652
|
+
|
|
518
653
|
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
PyCheck Data Flow Engine — Taint Propagation Analysis
|
|
3
|
-
Tracks how data flows through assignments, function returns, and method chains
|
|
4
|
-
to detect when future-tainted data reaches training/prediction sinks.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import ast
|
|
8
|
-
from typing import Dict, Set, List, Optional, Tuple
|
|
9
|
-
from pycheck.rules import Diagnostic
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
class TaintSource:
|
|
13
|
-
"""Represents a source of tainted (future-leaked) data."""
|
|
14
|
-
def __init__(self, var_name: str, reason: str, line: int, col: int):
|
|
15
|
-
self.var_name = var_name
|
|
16
|
-
self.reason = reason
|
|
17
|
-
self.line = line
|
|
18
|
-
self.col = col
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
class TaintTracker:
|
|
22
|
-
"""
|
|
23
|
-
Tracks taint propagation through Python AST.
|
|
24
|
-
|
|
25
|
-
Taint sources: shift(-n), bfill(), future column access, etc.
|
|
26
|
-
Taint sinks: .fit(), .predict(), model training calls.
|
|
27
|
-
|
|
28
|
-
When tainted data flows into a sink, a diagnostic is emitted.
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
def __init__(self):
|
|
32
|
-
self.tainted_vars: Dict[str, TaintSource] = {}
|
|
33
|
-
self.diagnostics: List[Diagnostic] = []
|
|
34
|
-
self.constants: Dict[str, any] = {}
|
|
35
|
-
|
|
36
|
-
def analyze(self, tree: ast.AST, source_lines: List[str]) -> List[Diagnostic]:
|
|
37
|
-
"""Run taint analysis on a full AST."""
|
|
38
|
-
self.tainted_vars.clear()
|
|
39
|
-
self.diagnostics.clear()
|
|
40
|
-
self.constants.clear()
|
|
41
|
-
|
|
42
|
-
# Two-pass analysis:
|
|
43
|
-
# Pass 1: Identify all taint sources (assignments from leaky operations)
|
|
44
|
-
# Pass 2: Track propagation and detect sinks
|
|
45
|
-
self._collect_taints(tree)
|
|
46
|
-
self._check_sinks(tree)
|
|
47
|
-
|
|
48
|
-
return self.diagnostics
|
|
49
|
-
|
|
50
|
-
def _extract_constant(self, expr):
|
|
51
|
-
if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)):
|
|
52
|
-
return expr.value
|
|
53
|
-
if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
|
|
54
|
-
if isinstance(expr.operand, ast.Constant) and isinstance(expr.operand.value, (int, float)):
|
|
55
|
-
return -expr.operand.value
|
|
56
|
-
return None
|
|
57
|
-
|
|
58
|
-
def _is_negative_period(self, expr) -> bool:
|
|
59
|
-
if isinstance(expr, ast.UnaryOp) and isinstance(expr.op, ast.USub):
|
|
60
|
-
return True
|
|
61
|
-
if isinstance(expr, ast.Constant) and isinstance(expr.value, (int, float)) and expr.value < 0:
|
|
62
|
-
return True
|
|
63
|
-
if isinstance(expr, ast.Name):
|
|
64
|
-
val = self.constants.get(expr.id)
|
|
65
|
-
if val is not None and isinstance(val, (int, float)) and val < 0:
|
|
66
|
-
return True
|
|
67
|
-
return False
|
|
68
|
-
|
|
69
|
-
def _collect_taints(self, tree: ast.AST):
|
|
70
|
-
"""Pass 1: Walk the AST and mark variables that receive tainted data."""
|
|
71
|
-
for node in ast.walk(tree):
|
|
72
|
-
if isinstance(node, ast.Assign):
|
|
73
|
-
for target in node.targets:
|
|
74
|
-
if isinstance(target, ast.Name):
|
|
75
|
-
val = self._extract_constant(node.value)
|
|
76
|
-
if val is not None:
|
|
77
|
-
self.constants[target.id] = val
|
|
78
|
-
|
|
79
|
-
for node in ast.walk(tree):
|
|
80
|
-
if isinstance(node, ast.Assign):
|
|
81
|
-
self._check_assignment_taint(node)
|
|
82
|
-
elif isinstance(node, ast.AugAssign):
|
|
83
|
-
self._check_augassign_taint(node)
|
|
84
|
-
|
|
85
|
-
def _check_assignment_taint(self, node: ast.Assign):
|
|
86
|
-
"""Check if an assignment creates or propagates a taint."""
|
|
87
|
-
# Get the taint from the RHS
|
|
88
|
-
taint_reason = self._expr_taint(node.value)
|
|
89
|
-
if taint_reason is None:
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
# Mark all LHS targets as tainted
|
|
93
|
-
for target in node.targets:
|
|
94
|
-
var_name = self._get_var_name(target)
|
|
95
|
-
if var_name:
|
|
96
|
-
self.tainted_vars[var_name] = TaintSource(
|
|
97
|
-
var_name, taint_reason, node.lineno, node.col_offset)
|
|
98
|
-
|
|
99
|
-
def _check_augassign_taint(self, node: ast.AugAssign):
|
|
100
|
-
"""Check if an augmented assignment (+=, etc.) propagates taint."""
|
|
101
|
-
taint_reason = self._expr_taint(node.value)
|
|
102
|
-
if taint_reason:
|
|
103
|
-
var_name = self._get_var_name(node.target)
|
|
104
|
-
if var_name:
|
|
105
|
-
self.tainted_vars[var_name] = TaintSource(
|
|
106
|
-
var_name, taint_reason, node.lineno, node.col_offset)
|
|
107
|
-
|
|
108
|
-
def _expr_taint(self, node: ast.AST) -> Optional[str]:
|
|
109
|
-
"""
|
|
110
|
-
Determine if an expression produces tainted data.
|
|
111
|
-
Returns the taint reason string, or None if clean.
|
|
112
|
-
"""
|
|
113
|
-
if isinstance(node, ast.Call):
|
|
114
|
-
return self._call_taint(node)
|
|
115
|
-
|
|
116
|
-
if isinstance(node, ast.Name):
|
|
117
|
-
# Propagation: if RHS is a tainted variable, the assignment is tainted
|
|
118
|
-
if node.id in self.tainted_vars:
|
|
119
|
-
return f"derived from tainted variable '{node.id}' ({self.tainted_vars[node.id].reason})"
|
|
120
|
-
|
|
121
|
-
if isinstance(node, ast.BinOp):
|
|
122
|
-
# Binary operations propagate taint from either operand
|
|
123
|
-
left_taint = self._expr_taint(node.left)
|
|
124
|
-
right_taint = self._expr_taint(node.right)
|
|
125
|
-
return left_taint or right_taint
|
|
126
|
-
|
|
127
|
-
if isinstance(node, ast.Subscript):
|
|
128
|
-
# Subscript propagates taint from the value being indexed
|
|
129
|
-
val_taint = self._expr_taint(node.value)
|
|
130
|
-
if val_taint:
|
|
131
|
-
return val_taint
|
|
132
|
-
# Check if indexing a tainted column name
|
|
133
|
-
if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
|
|
134
|
-
col_lower = node.slice.value.lower()
|
|
135
|
-
# Only flag column names that unambiguously refer to future data.
|
|
136
|
-
# 'target' and 'label' are intentionally excluded — they are standard
|
|
137
|
-
# ML label column names and produce false positives when accessed on a
|
|
138
|
-
# correctly split train/test dataset.
|
|
139
|
-
future_cols = {'future_price', 'future_return', 'next_close', 'next_price',
|
|
140
|
-
'next_day', 'tomorrow', 'price_change_next_day', 'future_avg_price'}
|
|
141
|
-
if any(f in col_lower for f in future_cols):
|
|
142
|
-
return f"accessing future-named column '{node.slice.value}'"
|
|
143
|
-
|
|
144
|
-
if isinstance(node, ast.Attribute):
|
|
145
|
-
return self._expr_taint(node.value)
|
|
146
|
-
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
def _call_taint(self, node: ast.Call) -> Optional[str]:
|
|
150
|
-
"""Check if a function call produces tainted output."""
|
|
151
|
-
if isinstance(node.func, ast.Attribute):
|
|
152
|
-
method = node.func.attr
|
|
153
|
-
|
|
154
|
-
# Direct taint sources
|
|
155
|
-
if method == 'shift':
|
|
156
|
-
for arg in node.args:
|
|
157
|
-
if self._is_negative_period(arg):
|
|
158
|
-
return "shift() with negative period (future access)"
|
|
159
|
-
for kw in node.keywords:
|
|
160
|
-
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
161
|
-
return "shift() with negative period (future access)"
|
|
162
|
-
|
|
163
|
-
if method in ('bfill', 'backfill'):
|
|
164
|
-
return f".{method}() backfills from future values"
|
|
165
|
-
|
|
166
|
-
if method == 'fillna':
|
|
167
|
-
for kw in node.keywords:
|
|
168
|
-
if kw.arg == 'method' and isinstance(kw.value, ast.Constant):
|
|
169
|
-
if kw.value.value in ('bfill', 'backfill'):
|
|
170
|
-
return f".fillna(method='{kw.value.value}') backfills from future"
|
|
171
|
-
|
|
172
|
-
if method == 'diff':
|
|
173
|
-
for arg in node.args:
|
|
174
|
-
if self._is_negative_period(arg):
|
|
175
|
-
return ".diff() with negative period (future access)"
|
|
176
|
-
for kw in node.keywords:
|
|
177
|
-
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
178
|
-
return ".diff() with negative period (future access)"
|
|
179
|
-
|
|
180
|
-
# Taint propagation through method chains
|
|
181
|
-
obj_taint = self._expr_taint(node.func.value)
|
|
182
|
-
if obj_taint:
|
|
183
|
-
# Most method calls on tainted data produce tainted output
|
|
184
|
-
non_taint_methods = {'shape', 'dtype', 'dtypes', 'columns', 'index',
|
|
185
|
-
'describe', 'info', 'head', 'tail', 'len', '__len__'}
|
|
186
|
-
if method not in non_taint_methods:
|
|
187
|
-
return obj_taint
|
|
188
|
-
|
|
189
|
-
if isinstance(node.func, ast.Name):
|
|
190
|
-
# Check if calling a function with tainted arguments
|
|
191
|
-
for arg in node.args:
|
|
192
|
-
arg_taint = self._expr_taint(arg)
|
|
193
|
-
if arg_taint:
|
|
194
|
-
return f"function receives tainted input: {arg_taint}"
|
|
195
|
-
|
|
196
|
-
# R2-9 fix: Detect getattr(obj, 'shift')(-1) and similar patterns
|
|
197
|
-
# Pattern: call(getattr(obj, 'shift'), -1) => obj.shift(-1)
|
|
198
|
-
if isinstance(node.func, ast.Call):
|
|
199
|
-
inner_call = node.func
|
|
200
|
-
if isinstance(inner_call.func, ast.Name) and inner_call.func.id == 'getattr':
|
|
201
|
-
if len(inner_call.args) >= 2 and isinstance(inner_call.args[1], ast.Constant):
|
|
202
|
-
attr_name = inner_call.args[1].value
|
|
203
|
-
if attr_name == 'shift':
|
|
204
|
-
for arg in node.args:
|
|
205
|
-
if self._is_negative_period(arg):
|
|
206
|
-
return "getattr(..., 'shift')() with negative period (future access)"
|
|
207
|
-
for kw in node.keywords:
|
|
208
|
-
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
209
|
-
return "getattr(..., 'shift')() with negative period (future access)"
|
|
210
|
-
elif attr_name == 'diff':
|
|
211
|
-
for arg in node.args:
|
|
212
|
-
if self._is_negative_period(arg):
|
|
213
|
-
return "getattr(..., 'diff')() with negative period (future access)"
|
|
214
|
-
for kw in node.keywords:
|
|
215
|
-
if kw.arg == 'periods' and self._is_negative_period(kw.value):
|
|
216
|
-
return "getattr(..., 'diff')() with negative period (future access)"
|
|
217
|
-
elif attr_name in ('bfill', 'backfill'):
|
|
218
|
-
return f"getattr(..., '{attr_name}')() backfills from future values"
|
|
219
|
-
|
|
220
|
-
return None
|
|
221
|
-
|
|
222
|
-
def _check_sinks(self, tree: ast.AST):
|
|
223
|
-
"""Pass 2: Check if tainted data flows into sensitive sinks."""
|
|
224
|
-
for node in ast.walk(tree):
|
|
225
|
-
if not isinstance(node, ast.Call):
|
|
226
|
-
continue
|
|
227
|
-
|
|
228
|
-
if isinstance(node.func, ast.Attribute):
|
|
229
|
-
method = node.func.attr
|
|
230
|
-
sinks = {'fit', 'fit_transform', 'fit_predict', 'train',
|
|
231
|
-
'partial_fit', 'predict', 'score'}
|
|
232
|
-
|
|
233
|
-
if method in sinks:
|
|
234
|
-
# Check if any positional argument is tainted
|
|
235
|
-
for arg in node.args:
|
|
236
|
-
arg_taint = self._expr_taint(arg)
|
|
237
|
-
if arg_taint:
|
|
238
|
-
self.diagnostics.append(Diagnostic(
|
|
239
|
-
node.lineno, node.col_offset, "error", "F001",
|
|
240
|
-
f"Tainted data flows into .{method}(): {arg_taint}",
|
|
241
|
-
"Ensure training/prediction data is free of future information"))
|
|
242
|
-
# R2-8 fix: Check keyword arguments for taint as well
|
|
243
|
-
for kw in node.keywords:
|
|
244
|
-
kw_taint = self._expr_taint(kw.value)
|
|
245
|
-
if kw_taint:
|
|
246
|
-
kw_name = kw.arg if kw.arg else "**kwargs"
|
|
247
|
-
self.diagnostics.append(Diagnostic(
|
|
248
|
-
node.lineno, node.col_offset, "error", "F001",
|
|
249
|
-
f"Tainted data flows into .{method}() via keyword '{kw_name}': {kw_taint}",
|
|
250
|
-
"Ensure training/prediction data is free of future information"))
|
|
251
|
-
|
|
252
|
-
def _get_var_name(self, node: ast.AST) -> Optional[str]:
|
|
253
|
-
"""Extract variable name from an assignment target."""
|
|
254
|
-
if isinstance(node, ast.Name):
|
|
255
|
-
return node.id
|
|
256
|
-
if isinstance(node, ast.Subscript):
|
|
257
|
-
# df['col_name'] — return as df.col_name
|
|
258
|
-
base = self._get_var_name(node.value)
|
|
259
|
-
if isinstance(node.slice, ast.Constant) and isinstance(node.slice.value, str):
|
|
260
|
-
return f"{base}.{node.slice.value}" if base else node.slice.value
|
|
261
|
-
return base
|
|
262
|
-
if isinstance(node, ast.Attribute):
|
|
263
|
-
base = self._get_var_name(node.value)
|
|
264
|
-
return f"{base}.{node.attr}" if base else node.attr
|
|
265
|
-
return None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|