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.
Files changed (20) hide show
  1. {pycheck_neuron-1.0.0/pycheck_neuron.egg-info → pycheck_neuron-1.0.1}/PKG-INFO +1 -1
  2. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/__init__.py +1 -1
  3. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/analyzer.py +9 -5
  4. pycheck_neuron-1.0.1/pycheck/flow.py +547 -0
  5. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/rules.py +90 -12
  6. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1/pycheck_neuron.egg-info}/PKG-INFO +1 -1
  7. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pyproject.toml +1 -1
  8. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/tests/test_rules.py +135 -0
  9. pycheck_neuron-1.0.0/pycheck/flow.py +0 -265
  10. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/LICENSE +0 -0
  11. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/README.md +0 -0
  12. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/__main__.py +0 -0
  13. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck/cli.py +0 -0
  14. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/SOURCES.txt +0 -0
  15. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/dependency_links.txt +0 -0
  16. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/entry_points.txt +0 -0
  17. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/requires.txt +0 -0
  18. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/pycheck_neuron.egg-info/top_level.txt +0 -0
  19. {pycheck_neuron-1.0.0 → pycheck_neuron-1.0.1}/setup.cfg +0 -0
  20. {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.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.2.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: Rule-based AST analysis
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 on training data after splitting")]
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
- target_names = {'target', 'y', 'label', 'labels', 'outcome', 'y_true',
601
- 'future_price', 'future_return', 'next_close', 'next_price',
602
- 'regime_success_rate', 'success_rate'}
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 target_names or val.endswith('_success_rate') or val.endswith('_target_enc'):
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
- "Remove target/outcome and target-laundered columns from feature inputs to avoid data leakage")]
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.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.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