pycheck-neuron 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycheck/__init__.py +10 -0
- pycheck/__main__.py +3 -0
- pycheck/analyzer.py +198 -0
- pycheck/cli.py +191 -0
- pycheck/flow.py +265 -0
- pycheck/rules.py +938 -0
- pycheck_neuron-1.0.0.dist-info/METADATA +296 -0
- pycheck_neuron-1.0.0.dist-info/RECORD +12 -0
- pycheck_neuron-1.0.0.dist-info/WHEEL +5 -0
- pycheck_neuron-1.0.0.dist-info/entry_points.txt +2 -0
- pycheck_neuron-1.0.0.dist-info/licenses/LICENSE +21 -0
- pycheck_neuron-1.0.0.dist-info/top_level.txt +1 -0
pycheck/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCheck — NEURON ML Safety Analyzer
|
|
3
|
+
Catches temporal leaks, causal confusion, and uncertainty bugs in Python ML scripts.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
|
|
8
|
+
from pycheck.analyzer import analyze_file, format_output
|
|
9
|
+
from pycheck.rules import ALL_RULES, RULES_BY_CODE
|
|
10
|
+
from pycheck.flow import TaintTracker
|
pycheck/__main__.py
ADDED
pycheck/analyzer.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyCheck Analyzer — ML Safety Static Analysis Engine
|
|
3
|
+
Uses a rule registry + taint flow analysis to detect temporal leaks,
|
|
4
|
+
causal confusion, and uncertainty bugs in Python ML scripts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
from pycheck.rules import ALL_RULES, RULES_BY_CODE, AnalysisContext, Diagnostic
|
|
13
|
+
from pycheck.flow import TaintTracker
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PyCheckAnalyzer(ast.NodeVisitor):
|
|
17
|
+
"""
|
|
18
|
+
AST visitor that runs all registered rules against each node.
|
|
19
|
+
Also tracks loop context for loop-aware rules.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, source_lines: List[str], filepath: str):
|
|
23
|
+
self.ctx = AnalysisContext(source_lines, filepath)
|
|
24
|
+
self.diagnostics: List[Diagnostic] = []
|
|
25
|
+
self.rules = ALL_RULES
|
|
26
|
+
|
|
27
|
+
def _run_rules(self, node: ast.AST):
|
|
28
|
+
"""Run all rules against a single AST node."""
|
|
29
|
+
for rule in self.rules:
|
|
30
|
+
results = rule.check_node(node, self.ctx)
|
|
31
|
+
self.diagnostics.extend(results)
|
|
32
|
+
|
|
33
|
+
def generic_visit(self, node):
|
|
34
|
+
"""Override to run rules on every node."""
|
|
35
|
+
self._run_rules(node)
|
|
36
|
+
super().generic_visit(node)
|
|
37
|
+
|
|
38
|
+
def visit_Assign(self, node):
|
|
39
|
+
"""Track literal constant assignments for constant folding / alias analysis."""
|
|
40
|
+
for target in node.targets:
|
|
41
|
+
if isinstance(target, ast.Name):
|
|
42
|
+
val = None
|
|
43
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, (int, float)):
|
|
44
|
+
val = node.value.value
|
|
45
|
+
elif isinstance(node.value, ast.UnaryOp) and isinstance(node.value.op, ast.USub):
|
|
46
|
+
if isinstance(node.value.operand, ast.Constant) and isinstance(node.value.operand.value, (int, float)):
|
|
47
|
+
val = -node.value.operand.value
|
|
48
|
+
if val is not None:
|
|
49
|
+
self.ctx.constants[target.id] = val
|
|
50
|
+
self.ctx.assignments[target.id] = node.value
|
|
51
|
+
self._run_rules(node)
|
|
52
|
+
self.generic_visit(node)
|
|
53
|
+
|
|
54
|
+
def visit_For(self, node):
|
|
55
|
+
"""Track loop context for rules that need it."""
|
|
56
|
+
old_in_loop = self.ctx.in_loop
|
|
57
|
+
old_loop_var = self.ctx.loop_var
|
|
58
|
+
|
|
59
|
+
self.ctx.in_loop = True
|
|
60
|
+
if isinstance(node.target, ast.Name):
|
|
61
|
+
self.ctx.loop_var = node.target.id
|
|
62
|
+
|
|
63
|
+
self._run_rules(node)
|
|
64
|
+
# Visit body with loop context
|
|
65
|
+
for child in ast.iter_child_nodes(node):
|
|
66
|
+
self.visit(child)
|
|
67
|
+
|
|
68
|
+
self.ctx.in_loop = old_in_loop
|
|
69
|
+
self.ctx.loop_var = old_loop_var
|
|
70
|
+
|
|
71
|
+
def visit_While(self, node):
|
|
72
|
+
"""Track loop context."""
|
|
73
|
+
old_in_loop = self.ctx.in_loop
|
|
74
|
+
self.ctx.in_loop = True
|
|
75
|
+
|
|
76
|
+
self._run_rules(node)
|
|
77
|
+
for child in ast.iter_child_nodes(node):
|
|
78
|
+
self.visit(child)
|
|
79
|
+
|
|
80
|
+
self.ctx.in_loop = old_in_loop
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def analyze_file(filepath: str) -> Tuple[List[dict], List[str]]:
|
|
84
|
+
"""
|
|
85
|
+
Analyze a Python (.py) or Jupyter notebook (.ipynb) file for ML safety issues.
|
|
86
|
+
Returns (diagnostics_list, source_lines).
|
|
87
|
+
"""
|
|
88
|
+
if filepath.endswith('.ipynb'):
|
|
89
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
90
|
+
nb = json.load(f)
|
|
91
|
+
code_lines = []
|
|
92
|
+
for cell in nb.get('cells', []):
|
|
93
|
+
if cell.get('cell_type') == 'code':
|
|
94
|
+
raw = cell.get('source', [])
|
|
95
|
+
if isinstance(raw, str):
|
|
96
|
+
raw = raw.splitlines(True)
|
|
97
|
+
for line in raw:
|
|
98
|
+
s_line = line.strip()
|
|
99
|
+
if s_line.startswith('%') or s_line.startswith('!'):
|
|
100
|
+
code_lines.append(f"# {line}")
|
|
101
|
+
else:
|
|
102
|
+
code_lines.append(line)
|
|
103
|
+
code_lines.append('\n')
|
|
104
|
+
source = "".join(code_lines)
|
|
105
|
+
else:
|
|
106
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
107
|
+
source = f.read()
|
|
108
|
+
|
|
109
|
+
source_lines = source.split('\n')
|
|
110
|
+
tree = ast.parse(source, filename=filepath)
|
|
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
|
|
117
|
+
tracker = TaintTracker()
|
|
118
|
+
flow_diagnostics = tracker.analyze(tree, source_lines)
|
|
119
|
+
|
|
120
|
+
# Combine and deduplicate
|
|
121
|
+
all_diagnostics = analyzer.diagnostics + flow_diagnostics
|
|
122
|
+
|
|
123
|
+
# Deduplicate by (line, code)
|
|
124
|
+
seen = set()
|
|
125
|
+
unique = []
|
|
126
|
+
for d in all_diagnostics:
|
|
127
|
+
key = (d.line, d.code)
|
|
128
|
+
if key not in seen:
|
|
129
|
+
seen.add(key)
|
|
130
|
+
unique.append(d)
|
|
131
|
+
|
|
132
|
+
# Sort by line number
|
|
133
|
+
unique.sort(key=lambda d: (d.line, d.col))
|
|
134
|
+
|
|
135
|
+
return [d.to_dict() for d in unique], source_lines
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def format_output(filepath: str, diagnostics: List[dict], source_lines: List[str],
|
|
139
|
+
show_info: bool = False):
|
|
140
|
+
"""Format diagnostics as human-readable output."""
|
|
141
|
+
|
|
142
|
+
# Filter by severity
|
|
143
|
+
if not show_info:
|
|
144
|
+
diagnostics = [d for d in diagnostics if d['severity'] != 'info']
|
|
145
|
+
|
|
146
|
+
errors = [d for d in diagnostics if d['severity'] == 'error']
|
|
147
|
+
warnings = [d for d in diagnostics if d['severity'] == 'warning']
|
|
148
|
+
infos = [d for d in diagnostics if d['severity'] == 'info']
|
|
149
|
+
|
|
150
|
+
def safe_print(text):
|
|
151
|
+
try:
|
|
152
|
+
print(text)
|
|
153
|
+
except UnicodeEncodeError:
|
|
154
|
+
print(text.encode('ascii', errors='replace').decode('ascii'))
|
|
155
|
+
|
|
156
|
+
header_sep = "=" * 65
|
|
157
|
+
line_sep = "-" * 65
|
|
158
|
+
|
|
159
|
+
safe_print(f"\n{header_sep}")
|
|
160
|
+
safe_print(f" PyCheck — NEURON ML Safety Analyzer")
|
|
161
|
+
safe_print(f" Scanning: {filepath}")
|
|
162
|
+
safe_print(f" Rules: {len(ALL_RULES)} active")
|
|
163
|
+
safe_print(f"{header_sep}\n")
|
|
164
|
+
|
|
165
|
+
if not diagnostics:
|
|
166
|
+
safe_print(" [OK] No issues found.\n")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
safe_print(f" {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)\n")
|
|
170
|
+
|
|
171
|
+
for d in diagnostics:
|
|
172
|
+
line_num = d['line']
|
|
173
|
+
|
|
174
|
+
# Color-like severity prefix
|
|
175
|
+
severity_label = d['severity'].upper()
|
|
176
|
+
safe_print(f" {severity_label}[{d['code']}]: {d['message']}")
|
|
177
|
+
safe_print(f" --> {filepath}:{line_num}:{d['col']}")
|
|
178
|
+
|
|
179
|
+
if 0 < line_num <= len(source_lines):
|
|
180
|
+
line_text = source_lines[line_num - 1]
|
|
181
|
+
safe_print(f" {line_num:>3} | {line_text}")
|
|
182
|
+
pointer_start = d['col']
|
|
183
|
+
safe_print(f" {' ' * pointer_start}^^^^^^^^^^^")
|
|
184
|
+
|
|
185
|
+
if 'help' in d:
|
|
186
|
+
safe_print(f" help: {d['help']}")
|
|
187
|
+
|
|
188
|
+
safe_print("")
|
|
189
|
+
|
|
190
|
+
safe_print(f"{line_sep}")
|
|
191
|
+
safe_print(f" Summary: {len(errors)} error(s), {len(warnings)} warning(s), {len(infos)} info(s)")
|
|
192
|
+
if errors:
|
|
193
|
+
safe_print(f" These {len(errors)} error(s) would be COMPILE-TIME ERRORS in NEURON")
|
|
194
|
+
elif warnings:
|
|
195
|
+
safe_print(f" These {len(warnings)} temporal issue(s) are guarded at compile-time in NEURON")
|
|
196
|
+
safe_print(f" Explore the zero-leakage language: https://github.com/neuronlabs-ai/neuron-lang")
|
|
197
|
+
safe_print(f" Python detected: 0 of these issues at runtime")
|
|
198
|
+
safe_print(f"{line_sep}\n")
|
pycheck/cli.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import hashlib
|
|
5
|
+
from pycheck.analyzer import analyze_file, format_output
|
|
6
|
+
from pycheck.rules import ALL_RULES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
args = sys.argv[1:]
|
|
11
|
+
|
|
12
|
+
if not args or '--help' in args or '-h' in args:
|
|
13
|
+
print("PyCheck — NEURON ML Safety Analyzer")
|
|
14
|
+
print(f" {len(ALL_RULES)} rules for temporal leaks, causal confusion, and uncertainty bugs\n")
|
|
15
|
+
print("Usage: pycheck <path> [options]\n")
|
|
16
|
+
print("Options:")
|
|
17
|
+
print(" --json Output diagnostics as JSON")
|
|
18
|
+
print(" --gitlab Output diagnostics in GitLab Code Quality JSON format")
|
|
19
|
+
print(" -o, --output <file> Write JSON / GitLab report directly to specified file")
|
|
20
|
+
print(" --info Include info-level diagnostics")
|
|
21
|
+
print(" --quiet Only show errors (no warnings)")
|
|
22
|
+
print(" --list List all available rules")
|
|
23
|
+
print(" -h, --help Show this help message")
|
|
24
|
+
sys.exit(0)
|
|
25
|
+
|
|
26
|
+
if '--list' in args:
|
|
27
|
+
print(f"\nPyCheck Rules ({len(ALL_RULES)} total):\n")
|
|
28
|
+
current_cat = ""
|
|
29
|
+
for rule in ALL_RULES:
|
|
30
|
+
if rule.category != current_cat:
|
|
31
|
+
current_cat = rule.category
|
|
32
|
+
print(f"\n [{current_cat}]")
|
|
33
|
+
print(f" {rule.code} {rule.severity:<8} {rule.name}")
|
|
34
|
+
print()
|
|
35
|
+
sys.exit(0)
|
|
36
|
+
|
|
37
|
+
show_info = '--info' in args
|
|
38
|
+
is_quiet = '--quiet' in args
|
|
39
|
+
is_json = '--json' in args
|
|
40
|
+
is_gitlab = '--gitlab' in args
|
|
41
|
+
|
|
42
|
+
output_file = None
|
|
43
|
+
if '--output' in args:
|
|
44
|
+
idx = args.index('--output')
|
|
45
|
+
if idx + 1 < len(args):
|
|
46
|
+
output_file = args[idx + 1]
|
|
47
|
+
elif '-o' in args:
|
|
48
|
+
idx = args.index('-o')
|
|
49
|
+
if idx + 1 < len(args):
|
|
50
|
+
output_file = args[idx + 1]
|
|
51
|
+
|
|
52
|
+
# Get target path (first non-flag argument not consumed by an option)
|
|
53
|
+
filepath = None
|
|
54
|
+
skip_next = False
|
|
55
|
+
for a in args:
|
|
56
|
+
if skip_next:
|
|
57
|
+
skip_next = False
|
|
58
|
+
continue
|
|
59
|
+
if a in ('--output', '-o'):
|
|
60
|
+
skip_next = True
|
|
61
|
+
continue
|
|
62
|
+
if not a.startswith('-'):
|
|
63
|
+
filepath = a
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
if not filepath:
|
|
67
|
+
filepath = "."
|
|
68
|
+
|
|
69
|
+
if not os.path.exists(filepath):
|
|
70
|
+
print(f"Error: Path not found: {filepath}")
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
# Collect target files
|
|
74
|
+
files_to_scan = []
|
|
75
|
+
if os.path.isdir(filepath):
|
|
76
|
+
ignored_dirs = {'.git', '__pycache__', '.pytest_cache', '.venv', 'venv', 'env', 'node_modules', 'dist', 'build', '.egg-info'}
|
|
77
|
+
for root, dirs, files in os.walk(filepath):
|
|
78
|
+
dirs[:] = [d for d in dirs if d not in ignored_dirs and not d.startswith('.')]
|
|
79
|
+
for f in files:
|
|
80
|
+
if f.endswith('.py') or f.endswith('.ipynb'):
|
|
81
|
+
files_to_scan.append(os.path.join(root, f))
|
|
82
|
+
else:
|
|
83
|
+
files_to_scan = [filepath]
|
|
84
|
+
|
|
85
|
+
if not files_to_scan:
|
|
86
|
+
if not is_json and not is_gitlab:
|
|
87
|
+
print(f"No Python (.py) or notebook (.ipynb) files found in {filepath}")
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
total_errors = 0
|
|
91
|
+
total_warnings = 0
|
|
92
|
+
files_with_issues = 0
|
|
93
|
+
all_json_results = {}
|
|
94
|
+
gitlab_issues = []
|
|
95
|
+
|
|
96
|
+
for f_path in files_to_scan:
|
|
97
|
+
try:
|
|
98
|
+
diagnostics, source_lines = analyze_file(f_path)
|
|
99
|
+
except SyntaxError as e:
|
|
100
|
+
if not is_json and (not is_gitlab or output_file):
|
|
101
|
+
print(f"Syntax error in {f_path}: {e}")
|
|
102
|
+
total_errors += 1
|
|
103
|
+
continue
|
|
104
|
+
except Exception as e:
|
|
105
|
+
if not is_json and (not is_gitlab or output_file):
|
|
106
|
+
print(f"Error analyzing {f_path}: {e}")
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
if is_quiet:
|
|
110
|
+
diagnostics = [d for d in diagnostics if d['severity'] == 'error']
|
|
111
|
+
elif not show_info:
|
|
112
|
+
diagnostics = [d for d in diagnostics if d['severity'] != 'info']
|
|
113
|
+
|
|
114
|
+
err_count = sum(1 for d in diagnostics if d['severity'] == 'error')
|
|
115
|
+
warn_count = sum(1 for d in diagnostics if d['severity'] == 'warning')
|
|
116
|
+
total_errors += err_count
|
|
117
|
+
total_warnings += warn_count
|
|
118
|
+
|
|
119
|
+
if diagnostics:
|
|
120
|
+
files_with_issues += 1
|
|
121
|
+
if is_json:
|
|
122
|
+
all_json_results[f_path] = diagnostics
|
|
123
|
+
elif is_gitlab:
|
|
124
|
+
rel_path = os.path.relpath(f_path).replace("\\", "/")
|
|
125
|
+
for d in diagnostics:
|
|
126
|
+
sev_map = {
|
|
127
|
+
'error': 'critical',
|
|
128
|
+
'warning': 'major',
|
|
129
|
+
'info': 'info'
|
|
130
|
+
}
|
|
131
|
+
gl_sev = sev_map.get(d.get('severity', 'warning'), 'major')
|
|
132
|
+
fp = hashlib.md5(f"{rel_path}:{d.get('line', 1)}:{d.get('code', 'P000')}".encode('utf-8')).hexdigest()
|
|
133
|
+
gitlab_issues.append({
|
|
134
|
+
"description": f"[{d.get('code', 'PYCHECK')}] {d.get('message', '')}. Guarded at compile-time in NEURON: https://github.com/neuronlabs-ai/neuron-lang",
|
|
135
|
+
"check_name": d.get('code', 'pycheck'),
|
|
136
|
+
"fingerprint": fp,
|
|
137
|
+
"severity": gl_sev,
|
|
138
|
+
"location": {
|
|
139
|
+
"path": rel_path,
|
|
140
|
+
"lines": {
|
|
141
|
+
"begin": d.get('line', 1)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
if output_file:
|
|
146
|
+
format_output(f_path, diagnostics, source_lines, show_info=show_info)
|
|
147
|
+
else:
|
|
148
|
+
format_output(f_path, diagnostics, source_lines, show_info=show_info)
|
|
149
|
+
|
|
150
|
+
if is_gitlab:
|
|
151
|
+
gl_content = json.dumps(gitlab_issues, indent=2)
|
|
152
|
+
if output_file:
|
|
153
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
154
|
+
f.write(gl_content)
|
|
155
|
+
else:
|
|
156
|
+
print(gl_content)
|
|
157
|
+
elif is_json:
|
|
158
|
+
json_content = json.dumps(all_json_results, indent=2)
|
|
159
|
+
if output_file:
|
|
160
|
+
with open(output_file, 'w', encoding='utf-8') as f:
|
|
161
|
+
f.write(json_content)
|
|
162
|
+
else:
|
|
163
|
+
print(json_content)
|
|
164
|
+
|
|
165
|
+
# Print summary if human output or if output was redirected to a file
|
|
166
|
+
if (not is_json and not is_gitlab) or output_file:
|
|
167
|
+
if len(files_to_scan) > 1 or output_file:
|
|
168
|
+
clean_files = len(files_to_scan) - files_with_issues
|
|
169
|
+
print("=================================================================")
|
|
170
|
+
print(" PyCheck Directory Summary")
|
|
171
|
+
print(f" Scanned: {len(files_to_scan)} files | Clean: {clean_files} | Flagged: {files_with_issues}")
|
|
172
|
+
print(f" Total: {total_errors} error(s), {total_warnings} warning(s)")
|
|
173
|
+
if output_file:
|
|
174
|
+
report_type = "GitLab Code Quality" if is_gitlab else "JSON"
|
|
175
|
+
print(f" {report_type} Report written to: {output_file}")
|
|
176
|
+
if files_with_issues > 0:
|
|
177
|
+
print("-----------------------------------------------------------------")
|
|
178
|
+
if total_errors > 0:
|
|
179
|
+
print(f" These {total_errors} error(s) would be COMPILE-TIME ERRORS in NEURON")
|
|
180
|
+
elif total_warnings > 0:
|
|
181
|
+
print(f" These {total_warnings} temporal issue(s) are guarded at compile-time in NEURON")
|
|
182
|
+
print(" Explore the zero-leakage language: https://github.com/neuronlabs-ai/neuron-lang")
|
|
183
|
+
print(" Python detected: 0 of these issues at runtime")
|
|
184
|
+
print("=================================================================\n")
|
|
185
|
+
|
|
186
|
+
if total_errors > 0:
|
|
187
|
+
sys.exit(1)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
if __name__ == '__main__':
|
|
191
|
+
main()
|
pycheck/flow.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
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
|