pisces-cli 0.1.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.
- pisces/__init__.py +9 -0
- pisces/__main__.py +4 -0
- pisces/ast_visitor.py +347 -0
- pisces/cli.py +245 -0
- pisces/config_cmd.py +234 -0
- pisces/config_manager.py +208 -0
- pisces/context_manager.py +333 -0
- pisces/explain_cmd.py +608 -0
- pisces/fix_cmd.py +479 -0
- pisces/history_cmd.py +313 -0
- pisces/init_cmd.py +193 -0
- pisces/judge.py +131 -0
- pisces/llm_client.py +263 -0
- pisces/models.py +413 -0
- pisces/patch_engine.py +266 -0
- pisces/scan_cmd.py +230 -0
- pisces/scanner.py +677 -0
- pisces/tui.py +539 -0
- pisces/watcher.py +244 -0
- pisces_cli-0.1.0.dist-info/METADATA +448 -0
- pisces_cli-0.1.0.dist-info/RECORD +24 -0
- pisces_cli-0.1.0.dist-info/WHEEL +4 -0
- pisces_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pisces_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pisces/scanner.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pisces.scanner
|
|
3
|
+
~~~~~~~~~~~~~~
|
|
4
|
+
Deterministic security rule engine.
|
|
5
|
+
|
|
6
|
+
Rules:
|
|
7
|
+
S001 HARDCODED_SECRET — string literals assigned to secret-named vars
|
|
8
|
+
S002 COMMAND_INJECTION — shell=True with string formatting/concatenation
|
|
9
|
+
S003 UNSAFE_SUBPROCESS — any subprocess usage with shell=True
|
|
10
|
+
S004 UNSAFE_DESERIALIZATION — pickle/marshal/yaml.load without safe loader
|
|
11
|
+
S005 TLS_VERIFY_DISABLED — requests/httpx calls with verify=False
|
|
12
|
+
S006 CODE_INJECTION — eval()/exec() with non-literal arguments
|
|
13
|
+
S007 DANGEROUS_FILESYSTEM — shutil.rmtree / os.remove with variable paths
|
|
14
|
+
|
|
15
|
+
Design rules:
|
|
16
|
+
- No LLM calls. Ever. This is pure static analysis.
|
|
17
|
+
- AST-based checks preferred over regex.
|
|
18
|
+
- Regex fallback when AST parse fails.
|
|
19
|
+
- Every finding carries: id, rule, severity, file, line, code,
|
|
20
|
+
description, impact, remediation.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import ast
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from pisces.models import Finding, Severity
|
|
30
|
+
|
|
31
|
+
# ─────────────────────────────────────────────────────────────
|
|
32
|
+
# Rule metadata registry
|
|
33
|
+
# ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
RULE_META: dict[str, dict] = {
|
|
36
|
+
"S001_HARDCODED_SECRET": {
|
|
37
|
+
"severity": Severity.HIGH,
|
|
38
|
+
"description": "Hardcoded secret or credential in source code",
|
|
39
|
+
"impact": "Credentials exposed in version control or logs",
|
|
40
|
+
"remediation": (
|
|
41
|
+
"Move secrets to environment variables. "
|
|
42
|
+
"Use os.environ.get('MY_SECRET') or a secrets manager."
|
|
43
|
+
),
|
|
44
|
+
},
|
|
45
|
+
"S002_COMMAND_INJECTION": {
|
|
46
|
+
"severity": Severity.CRITICAL,
|
|
47
|
+
"description": "Potential command injection via shell=True with dynamic input",
|
|
48
|
+
"impact": "Attacker-controlled input may execute arbitrary OS commands",
|
|
49
|
+
"remediation": (
|
|
50
|
+
"Use shell=False (default) and pass arguments as a list: "
|
|
51
|
+
"subprocess.run(['cmd', arg], shell=False). "
|
|
52
|
+
"Never pass unsanitised user input to shell commands."
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
"S003_UNSAFE_SUBPROCESS": {
|
|
56
|
+
"severity": Severity.HIGH,
|
|
57
|
+
"description": "subprocess call with shell=True",
|
|
58
|
+
"impact": "shell=True expands shell metacharacters — use only with trusted, static strings",
|
|
59
|
+
"remediation": (
|
|
60
|
+
"Replace shell=True with a list of arguments: "
|
|
61
|
+
"subprocess.run(['cmd', '--flag', value], shell=False)"
|
|
62
|
+
),
|
|
63
|
+
},
|
|
64
|
+
"S004_UNSAFE_DESERIALIZATION": {
|
|
65
|
+
"severity": Severity.HIGH,
|
|
66
|
+
"description": "Unsafe deserialization of untrusted data",
|
|
67
|
+
"impact": "Maliciously crafted data can execute arbitrary code during unpickling",
|
|
68
|
+
"remediation": (
|
|
69
|
+
"Never unpickle data from untrusted sources. "
|
|
70
|
+
"Use JSON/msgpack for serialisation. "
|
|
71
|
+
"For yaml.load, use yaml.safe_load(stream) instead."
|
|
72
|
+
),
|
|
73
|
+
},
|
|
74
|
+
"S005_TLS_VERIFY_DISABLED": {
|
|
75
|
+
"severity": Severity.MEDIUM,
|
|
76
|
+
"description": "TLS certificate verification disabled (verify=False)",
|
|
77
|
+
"impact": "Disabling TLS verification allows man-in-the-middle attacks",
|
|
78
|
+
"remediation": (
|
|
79
|
+
"Remove verify=False. If using a self-signed cert, "
|
|
80
|
+
"pass verify='/path/to/ca-bundle.crt' instead."
|
|
81
|
+
),
|
|
82
|
+
},
|
|
83
|
+
"S006_CODE_INJECTION": {
|
|
84
|
+
"severity": Severity.CRITICAL,
|
|
85
|
+
"description": "eval() or exec() called with non-literal (potentially dynamic) argument",
|
|
86
|
+
"impact": "Dynamic code execution — arbitrary Python can be injected and executed",
|
|
87
|
+
"remediation": (
|
|
88
|
+
"Avoid eval()/exec() entirely. "
|
|
89
|
+
"Use ast.literal_eval() for safe constant parsing, "
|
|
90
|
+
"or restructure logic to remove dynamic execution."
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
"S007_DANGEROUS_FILESYSTEM": {
|
|
94
|
+
"severity": Severity.MEDIUM,
|
|
95
|
+
"description": "Potentially dangerous filesystem operation with variable path",
|
|
96
|
+
"impact": "Path traversal or unintended deletion of files/directories",
|
|
97
|
+
"remediation": (
|
|
98
|
+
"Validate and sanitise paths before use. "
|
|
99
|
+
"Use pathlib.Path.resolve() and check the result is within expected root. "
|
|
100
|
+
"Prefer specific targeted operations over recursive deletes."
|
|
101
|
+
),
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ─────────────────────────────────────────────────────────────
|
|
107
|
+
# Helper utilities
|
|
108
|
+
# ─────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _make_finding(
|
|
112
|
+
rule_id: str,
|
|
113
|
+
file: str,
|
|
114
|
+
line: int,
|
|
115
|
+
code: str,
|
|
116
|
+
override_severity: Severity | None = None,
|
|
117
|
+
) -> Finding:
|
|
118
|
+
meta = RULE_META[rule_id]
|
|
119
|
+
return Finding(
|
|
120
|
+
rule=rule_id,
|
|
121
|
+
severity=override_severity or meta["severity"],
|
|
122
|
+
file=file,
|
|
123
|
+
line=line,
|
|
124
|
+
code=code.strip()[:200],
|
|
125
|
+
description=meta["description"],
|
|
126
|
+
impact=meta["impact"],
|
|
127
|
+
remediation=meta["remediation"],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _source_line(source_lines: list[str], lineno: int) -> str:
|
|
132
|
+
"""Return the source line for a 1-indexed line number."""
|
|
133
|
+
if 1 <= lineno <= len(source_lines):
|
|
134
|
+
return source_lines[lineno - 1].strip()
|
|
135
|
+
return ""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _is_literal(node: ast.AST) -> bool:
|
|
139
|
+
"""Return True if *node* is a compile-time constant (string/number/bytes)."""
|
|
140
|
+
return isinstance(node, ast.Constant)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _is_dynamic(node: ast.AST) -> bool:
|
|
144
|
+
"""Return True if *node* is not a pure literal (i.e. has runtime dependencies)."""
|
|
145
|
+
return not _is_literal(node)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ─────────────────────────────────────────────────────────────
|
|
149
|
+
# Rule: S001 — Hardcoded Secrets
|
|
150
|
+
# ─────────────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
_SECRET_VAR_NAMES = re.compile(
|
|
153
|
+
r"(password|passwd|secret|api_key|apikey|token|auth_token|"
|
|
154
|
+
r"access_token|private_key|secret_key|db_pass|database_pass|"
|
|
155
|
+
r"credentials|credential|auth_secret|client_secret)",
|
|
156
|
+
re.IGNORECASE,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Regex for finding secret-like assignments when AST parse fails
|
|
160
|
+
_SECRET_REGEX = re.compile(
|
|
161
|
+
r"""^\s*(?P<var>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<quote>['"])(?P<val>.{3,}?)(?P=quote)""",
|
|
162
|
+
re.MULTILINE,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# High-entropy string heuristic (skip very short or obviously placeholder values)
|
|
166
|
+
_PLACEHOLDER_VALUES = {
|
|
167
|
+
"changeme",
|
|
168
|
+
"password",
|
|
169
|
+
"secret",
|
|
170
|
+
"example",
|
|
171
|
+
"test",
|
|
172
|
+
"your_key_here",
|
|
173
|
+
"xxx",
|
|
174
|
+
"yyy",
|
|
175
|
+
"zzz",
|
|
176
|
+
"<your-key>",
|
|
177
|
+
"",
|
|
178
|
+
"placeholder",
|
|
179
|
+
"todo",
|
|
180
|
+
"fill-me-in",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _looks_like_real_secret(value: str) -> bool:
|
|
185
|
+
"""Heuristic: is this string plausibly a real credential?"""
|
|
186
|
+
v = value.strip()
|
|
187
|
+
if len(v) < 4:
|
|
188
|
+
return False
|
|
189
|
+
# Real secrets tend to have mixed chars or be long.
|
|
190
|
+
return v.lower() not in _PLACEHOLDER_VALUES
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def check_hardcoded_secrets_ast(
|
|
194
|
+
tree: ast.AST, source_lines: list[str], rel_path: str
|
|
195
|
+
) -> list[Finding]:
|
|
196
|
+
findings: list[Finding] = []
|
|
197
|
+
|
|
198
|
+
for node in ast.walk(tree):
|
|
199
|
+
# Pattern: VAR = "string_literal"
|
|
200
|
+
if not isinstance(node, ast.Assign):
|
|
201
|
+
continue
|
|
202
|
+
if not (isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)):
|
|
203
|
+
continue
|
|
204
|
+
value = node.value.value
|
|
205
|
+
if not _looks_like_real_secret(value):
|
|
206
|
+
continue
|
|
207
|
+
for target in node.targets:
|
|
208
|
+
if not isinstance(target, ast.Name):
|
|
209
|
+
continue
|
|
210
|
+
if _SECRET_VAR_NAMES.search(target.id):
|
|
211
|
+
findings.append(
|
|
212
|
+
_make_finding(
|
|
213
|
+
"S001_HARDCODED_SECRET",
|
|
214
|
+
rel_path,
|
|
215
|
+
node.lineno,
|
|
216
|
+
_source_line(source_lines, node.lineno),
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
return findings
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def check_hardcoded_secrets_regex(source: str, rel_path: str) -> list[Finding]:
|
|
223
|
+
findings: list[Finding] = []
|
|
224
|
+
for m in _SECRET_REGEX.finditer(source):
|
|
225
|
+
var = m.group("var")
|
|
226
|
+
value = m.group("val")
|
|
227
|
+
line = source[: m.start()].count("\n") + 1
|
|
228
|
+
if _SECRET_VAR_NAMES.search(var) and _looks_like_real_secret(value):
|
|
229
|
+
findings.append(
|
|
230
|
+
_make_finding(
|
|
231
|
+
"S001_HARDCODED_SECRET",
|
|
232
|
+
rel_path,
|
|
233
|
+
line,
|
|
234
|
+
m.group(0).strip()[:200],
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
return findings
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ─────────────────────────────────────────────────────────────
|
|
241
|
+
# Rule: S002/S003 — Command Injection / Unsafe Subprocess
|
|
242
|
+
# ─────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
_SUBPROCESS_FUNCS = {
|
|
245
|
+
"subprocess.run",
|
|
246
|
+
"subprocess.call",
|
|
247
|
+
"subprocess.Popen",
|
|
248
|
+
"subprocess.check_output",
|
|
249
|
+
"subprocess.check_call",
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _has_shell_true(call_node: ast.Call) -> bool:
|
|
254
|
+
"""Return True if the call has shell=True keyword argument."""
|
|
255
|
+
for kw in call_node.keywords:
|
|
256
|
+
if kw.arg == "shell":
|
|
257
|
+
if isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
258
|
+
return True
|
|
259
|
+
# shell=True via variable — treat as potentially True
|
|
260
|
+
if isinstance(kw.value, ast.Name):
|
|
261
|
+
return True
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _first_arg_is_dynamic(call_node: ast.Call) -> bool:
|
|
266
|
+
"""Return True if the first arg is not a static string literal."""
|
|
267
|
+
if not call_node.args:
|
|
268
|
+
return False
|
|
269
|
+
arg0 = call_node.args[0]
|
|
270
|
+
# f-string, BinOp (string concat), Name (variable), etc.
|
|
271
|
+
if isinstance(arg0, ast.JoinedStr): # f"..."
|
|
272
|
+
return True
|
|
273
|
+
if isinstance(arg0, ast.BinOp): # "cmd " + var
|
|
274
|
+
return True
|
|
275
|
+
if isinstance(arg0, ast.Name): # variable
|
|
276
|
+
return True
|
|
277
|
+
return isinstance(arg0, ast.Call) # format()/join()
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def check_subprocess_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
|
|
281
|
+
findings: list[Finding] = []
|
|
282
|
+
|
|
283
|
+
for node in ast.walk(tree):
|
|
284
|
+
if not isinstance(node, ast.Call):
|
|
285
|
+
continue
|
|
286
|
+
|
|
287
|
+
# Resolve function name
|
|
288
|
+
func = node.func
|
|
289
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
290
|
+
fname = f"{func.value.id}.{func.attr}"
|
|
291
|
+
elif isinstance(func, ast.Name):
|
|
292
|
+
fname = func.id
|
|
293
|
+
else:
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
if fname not in _SUBPROCESS_FUNCS:
|
|
297
|
+
# Also catch os.system / os.popen
|
|
298
|
+
if fname in ("os.system", "os.popen"):
|
|
299
|
+
code = _source_line(source_lines, node.lineno)
|
|
300
|
+
# os.system is always shell=True effectively
|
|
301
|
+
if node.args and _is_dynamic(node.args[0]):
|
|
302
|
+
findings.append(
|
|
303
|
+
_make_finding("S002_COMMAND_INJECTION", rel_path, node.lineno, code)
|
|
304
|
+
)
|
|
305
|
+
else:
|
|
306
|
+
findings.append(
|
|
307
|
+
_make_finding("S003_UNSAFE_SUBPROCESS", rel_path, node.lineno, code)
|
|
308
|
+
)
|
|
309
|
+
continue
|
|
310
|
+
|
|
311
|
+
if not _has_shell_true(node):
|
|
312
|
+
continue
|
|
313
|
+
|
|
314
|
+
code = _source_line(source_lines, node.lineno)
|
|
315
|
+
|
|
316
|
+
if _first_arg_is_dynamic(node):
|
|
317
|
+
# shell=True + dynamic first arg = command injection risk
|
|
318
|
+
findings.append(_make_finding("S002_COMMAND_INJECTION", rel_path, node.lineno, code))
|
|
319
|
+
else:
|
|
320
|
+
# shell=True with static string — still bad practice
|
|
321
|
+
findings.append(_make_finding("S003_UNSAFE_SUBPROCESS", rel_path, node.lineno, code))
|
|
322
|
+
|
|
323
|
+
return findings
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
_SUBPROCESS_REGEX = re.compile(r"shell\s*=\s*True", re.IGNORECASE)
|
|
327
|
+
_OS_SYSTEM_REGEX = re.compile(r"os\.(system|popen)\s*\(")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def check_subprocess_regex(source: str, rel_path: str) -> list[Finding]:
|
|
331
|
+
findings: list[Finding] = []
|
|
332
|
+
lines = source.splitlines()
|
|
333
|
+
for i, line in enumerate(lines, 1):
|
|
334
|
+
stripped = line.strip()
|
|
335
|
+
if stripped.startswith("#"):
|
|
336
|
+
continue
|
|
337
|
+
if _SUBPROCESS_REGEX.search(line):
|
|
338
|
+
findings.append(_make_finding("S003_UNSAFE_SUBPROCESS", rel_path, i, stripped))
|
|
339
|
+
elif _OS_SYSTEM_REGEX.search(line):
|
|
340
|
+
findings.append(_make_finding("S002_COMMAND_INJECTION", rel_path, i, stripped))
|
|
341
|
+
return findings
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# ─────────────────────────────────────────────────────────────
|
|
345
|
+
# Rule: S004 — Unsafe Deserialization
|
|
346
|
+
# ─────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
_DESER_CALL_NAMES = {
|
|
349
|
+
"pickle.load",
|
|
350
|
+
"pickle.loads",
|
|
351
|
+
"pickle.Unpickler",
|
|
352
|
+
"marshal.load",
|
|
353
|
+
"marshal.loads",
|
|
354
|
+
"shelve.open",
|
|
355
|
+
}
|
|
356
|
+
_YAML_UNSAFE = {"yaml.load"}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def check_deserialization_ast(
|
|
360
|
+
tree: ast.AST, source_lines: list[str], rel_path: str
|
|
361
|
+
) -> list[Finding]:
|
|
362
|
+
findings: list[Finding] = []
|
|
363
|
+
|
|
364
|
+
for node in ast.walk(tree):
|
|
365
|
+
if not isinstance(node, ast.Call):
|
|
366
|
+
continue
|
|
367
|
+
func = node.func
|
|
368
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
369
|
+
fname = f"{func.value.id}.{func.attr}"
|
|
370
|
+
elif isinstance(func, ast.Name):
|
|
371
|
+
fname = func.id
|
|
372
|
+
else:
|
|
373
|
+
continue
|
|
374
|
+
|
|
375
|
+
if fname in _DESER_CALL_NAMES:
|
|
376
|
+
findings.append(
|
|
377
|
+
_make_finding(
|
|
378
|
+
"S004_UNSAFE_DESERIALIZATION",
|
|
379
|
+
rel_path,
|
|
380
|
+
node.lineno,
|
|
381
|
+
_source_line(source_lines, node.lineno),
|
|
382
|
+
)
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
# yaml.load without Loader=yaml.SafeLoader
|
|
386
|
+
if fname in _YAML_UNSAFE:
|
|
387
|
+
has_safe_loader = any(
|
|
388
|
+
kw.arg == "Loader"
|
|
389
|
+
and (
|
|
390
|
+
(isinstance(kw.value, ast.Attribute) and "Safe" in (kw.value.attr or ""))
|
|
391
|
+
or (isinstance(kw.value, ast.Name) and "Safe" in kw.value.id)
|
|
392
|
+
)
|
|
393
|
+
for kw in node.keywords
|
|
394
|
+
)
|
|
395
|
+
if not has_safe_loader:
|
|
396
|
+
findings.append(
|
|
397
|
+
_make_finding(
|
|
398
|
+
"S004_UNSAFE_DESERIALIZATION",
|
|
399
|
+
rel_path,
|
|
400
|
+
node.lineno,
|
|
401
|
+
_source_line(source_lines, node.lineno),
|
|
402
|
+
override_severity=Severity.HIGH,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
return findings
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
_DESER_REGEX = re.compile(
|
|
410
|
+
r"(pickle\.(load|loads|Unpickler)|marshal\.load|yaml\.load\s*\((?![^)]*SafeLoader))"
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def check_deserialization_regex(source: str, rel_path: str) -> list[Finding]:
|
|
415
|
+
findings: list[Finding] = []
|
|
416
|
+
lines = source.splitlines()
|
|
417
|
+
for i, line in enumerate(lines, 1):
|
|
418
|
+
stripped = line.strip()
|
|
419
|
+
if stripped.startswith("#"):
|
|
420
|
+
continue
|
|
421
|
+
if _DESER_REGEX.search(line):
|
|
422
|
+
findings.append(_make_finding("S004_UNSAFE_DESERIALIZATION", rel_path, i, stripped))
|
|
423
|
+
return findings
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
# ─────────────────────────────────────────────────────────────
|
|
427
|
+
# Rule: S005 — TLS Verification Disabled
|
|
428
|
+
# ─────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def check_tls_verify_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
|
|
432
|
+
findings: list[Finding] = []
|
|
433
|
+
|
|
434
|
+
for node in ast.walk(tree):
|
|
435
|
+
if not isinstance(node, ast.Call):
|
|
436
|
+
continue
|
|
437
|
+
for kw in node.keywords:
|
|
438
|
+
if (
|
|
439
|
+
kw.arg == "verify"
|
|
440
|
+
and isinstance(kw.value, ast.Constant)
|
|
441
|
+
and kw.value.value is False
|
|
442
|
+
):
|
|
443
|
+
findings.append(
|
|
444
|
+
_make_finding(
|
|
445
|
+
"S005_TLS_VERIFY_DISABLED",
|
|
446
|
+
rel_path,
|
|
447
|
+
node.lineno,
|
|
448
|
+
_source_line(source_lines, node.lineno),
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
break
|
|
452
|
+
|
|
453
|
+
# Also check ssl._create_unverified_context
|
|
454
|
+
for node in ast.walk(tree):
|
|
455
|
+
if not isinstance(node, ast.Call):
|
|
456
|
+
continue
|
|
457
|
+
func = node.func
|
|
458
|
+
name = ""
|
|
459
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
460
|
+
name = f"{func.value.id}.{func.attr}"
|
|
461
|
+
elif isinstance(func, ast.Name):
|
|
462
|
+
name = func.id
|
|
463
|
+
if "unverified" in name.lower() or "no_verify" in name.lower():
|
|
464
|
+
findings.append(
|
|
465
|
+
_make_finding(
|
|
466
|
+
"S005_TLS_VERIFY_DISABLED",
|
|
467
|
+
rel_path,
|
|
468
|
+
node.lineno,
|
|
469
|
+
_source_line(source_lines, node.lineno),
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
return findings
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
_TLS_REGEX = re.compile(r"verify\s*=\s*False|_create_unverified_context|CERT_NONE")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def check_tls_verify_regex(source: str, rel_path: str) -> list[Finding]:
|
|
480
|
+
findings: list[Finding] = []
|
|
481
|
+
lines = source.splitlines()
|
|
482
|
+
for i, line in enumerate(lines, 1):
|
|
483
|
+
stripped = line.strip()
|
|
484
|
+
if stripped.startswith("#"):
|
|
485
|
+
continue
|
|
486
|
+
if _TLS_REGEX.search(line):
|
|
487
|
+
findings.append(_make_finding("S005_TLS_VERIFY_DISABLED", rel_path, i, stripped))
|
|
488
|
+
return findings
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
# ─────────────────────────────────────────────────────────────
|
|
492
|
+
# Rule: S006 — Code Injection (eval/exec)
|
|
493
|
+
# ─────────────────────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def check_code_injection_ast(
|
|
497
|
+
tree: ast.AST, source_lines: list[str], rel_path: str
|
|
498
|
+
) -> list[Finding]:
|
|
499
|
+
findings: list[Finding] = []
|
|
500
|
+
|
|
501
|
+
for node in ast.walk(tree):
|
|
502
|
+
if not isinstance(node, ast.Call):
|
|
503
|
+
continue
|
|
504
|
+
func = node.func
|
|
505
|
+
if not (isinstance(func, ast.Name) and func.id in ("eval", "exec")):
|
|
506
|
+
continue
|
|
507
|
+
# eval/exec with a non-literal first arg → code injection risk
|
|
508
|
+
if node.args and _is_dynamic(node.args[0]):
|
|
509
|
+
findings.append(
|
|
510
|
+
_make_finding(
|
|
511
|
+
"S006_CODE_INJECTION",
|
|
512
|
+
rel_path,
|
|
513
|
+
node.lineno,
|
|
514
|
+
_source_line(source_lines, node.lineno),
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
elif not node.args:
|
|
518
|
+
# eval() with no args (unusual but flag for review)
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
return findings
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
_EVAL_REGEX = re.compile(r"\beval\s*\(|\bexec\s*\(")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def check_code_injection_regex(source: str, rel_path: str) -> list[Finding]:
|
|
528
|
+
findings: list[Finding] = []
|
|
529
|
+
lines = source.splitlines()
|
|
530
|
+
for i, line in enumerate(lines, 1):
|
|
531
|
+
stripped = line.strip()
|
|
532
|
+
if stripped.startswith("#"):
|
|
533
|
+
continue
|
|
534
|
+
if _EVAL_REGEX.search(line):
|
|
535
|
+
findings.append(_make_finding("S006_CODE_INJECTION", rel_path, i, stripped))
|
|
536
|
+
return findings
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
# ─────────────────────────────────────────────────────────────
|
|
540
|
+
# Rule: S007 — Dangerous Filesystem Operations
|
|
541
|
+
# ─────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
_FS_DANGER_FUNCS = {
|
|
544
|
+
"shutil.rmtree",
|
|
545
|
+
"os.remove",
|
|
546
|
+
"os.unlink",
|
|
547
|
+
"os.rmdir",
|
|
548
|
+
"shutil.move",
|
|
549
|
+
"os.rename",
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def check_dangerous_fs_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
|
|
554
|
+
findings: list[Finding] = []
|
|
555
|
+
|
|
556
|
+
for node in ast.walk(tree):
|
|
557
|
+
if not isinstance(node, ast.Call):
|
|
558
|
+
continue
|
|
559
|
+
func = node.func
|
|
560
|
+
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
561
|
+
fname = f"{func.value.id}.{func.attr}"
|
|
562
|
+
else:
|
|
563
|
+
continue
|
|
564
|
+
|
|
565
|
+
if fname not in _FS_DANGER_FUNCS:
|
|
566
|
+
continue
|
|
567
|
+
|
|
568
|
+
# Only flag if the path argument is dynamic (variable, not literal)
|
|
569
|
+
if node.args and _is_dynamic(node.args[0]):
|
|
570
|
+
findings.append(
|
|
571
|
+
_make_finding(
|
|
572
|
+
"S007_DANGEROUS_FILESYSTEM",
|
|
573
|
+
rel_path,
|
|
574
|
+
node.lineno,
|
|
575
|
+
_source_line(source_lines, node.lineno),
|
|
576
|
+
)
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
return findings
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
_FS_REGEX = re.compile(r"shutil\.(rmtree|move)|os\.(remove|unlink|rmdir|rename)\s*\(")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def check_dangerous_fs_regex(source: str, rel_path: str) -> list[Finding]:
|
|
586
|
+
findings: list[Finding] = []
|
|
587
|
+
lines = source.splitlines()
|
|
588
|
+
for i, line in enumerate(lines, 1):
|
|
589
|
+
stripped = line.strip()
|
|
590
|
+
if stripped.startswith("#"):
|
|
591
|
+
continue
|
|
592
|
+
if _FS_REGEX.search(line):
|
|
593
|
+
findings.append(_make_finding("S007_DANGEROUS_FILESYSTEM", rel_path, i, stripped))
|
|
594
|
+
return findings
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
# ─────────────────────────────────────────────────────────────
|
|
598
|
+
# Master Scanner
|
|
599
|
+
# ─────────────────────────────────────────────────────────────
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
class Scanner:
|
|
603
|
+
"""
|
|
604
|
+
Runs all deterministic security rules against a Python source file.
|
|
605
|
+
|
|
606
|
+
Usage:
|
|
607
|
+
scanner = Scanner()
|
|
608
|
+
findings = scanner.scan_file(path, project_root)
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
def scan_file(
|
|
612
|
+
self,
|
|
613
|
+
file_path: Path,
|
|
614
|
+
project_root: Path,
|
|
615
|
+
source: str | None = None,
|
|
616
|
+
) -> list[Finding]:
|
|
617
|
+
"""
|
|
618
|
+
Scan a single .py file and return all findings.
|
|
619
|
+
|
|
620
|
+
*source* can be passed to avoid re-reading from disk (e.g. in watch mode).
|
|
621
|
+
"""
|
|
622
|
+
if source is None:
|
|
623
|
+
try:
|
|
624
|
+
source = file_path.read_text(encoding="utf-8", errors="replace")
|
|
625
|
+
except OSError:
|
|
626
|
+
return []
|
|
627
|
+
|
|
628
|
+
rel_path = str(file_path.relative_to(project_root))
|
|
629
|
+
source_lines = source.splitlines()
|
|
630
|
+
|
|
631
|
+
# Try AST parse
|
|
632
|
+
try:
|
|
633
|
+
tree: ast.AST | None = ast.parse(source, filename=rel_path)
|
|
634
|
+
parse_ok = True
|
|
635
|
+
except SyntaxError:
|
|
636
|
+
tree = None
|
|
637
|
+
parse_ok = False
|
|
638
|
+
|
|
639
|
+
findings: list[Finding] = []
|
|
640
|
+
|
|
641
|
+
if parse_ok and tree is not None:
|
|
642
|
+
findings += check_hardcoded_secrets_ast(tree, source_lines, rel_path)
|
|
643
|
+
findings += check_subprocess_ast(tree, source_lines, rel_path)
|
|
644
|
+
findings += check_deserialization_ast(tree, source_lines, rel_path)
|
|
645
|
+
findings += check_tls_verify_ast(tree, source_lines, rel_path)
|
|
646
|
+
findings += check_code_injection_ast(tree, source_lines, rel_path)
|
|
647
|
+
findings += check_dangerous_fs_ast(tree, source_lines, rel_path)
|
|
648
|
+
else:
|
|
649
|
+
# Regex fallback for files with syntax errors
|
|
650
|
+
findings += check_hardcoded_secrets_regex(source, rel_path)
|
|
651
|
+
findings += check_subprocess_regex(source, rel_path)
|
|
652
|
+
findings += check_deserialization_regex(source, rel_path)
|
|
653
|
+
findings += check_tls_verify_regex(source, rel_path)
|
|
654
|
+
findings += check_code_injection_regex(source, rel_path)
|
|
655
|
+
findings += check_dangerous_fs_regex(source, rel_path)
|
|
656
|
+
|
|
657
|
+
# Deduplicate by (rule, file, line) — multiple passes may overlap
|
|
658
|
+
seen: set[tuple] = set()
|
|
659
|
+
deduped: list[Finding] = []
|
|
660
|
+
for f in findings:
|
|
661
|
+
key = (f.rule, f.file, f.line)
|
|
662
|
+
if key not in seen:
|
|
663
|
+
seen.add(key)
|
|
664
|
+
deduped.append(f)
|
|
665
|
+
|
|
666
|
+
return deduped
|
|
667
|
+
|
|
668
|
+
def scan_project(
|
|
669
|
+
self,
|
|
670
|
+
project_root: Path,
|
|
671
|
+
py_files: list[Path],
|
|
672
|
+
) -> list[Finding]:
|
|
673
|
+
"""Scan all *py_files* and return combined findings."""
|
|
674
|
+
all_findings: list[Finding] = []
|
|
675
|
+
for f in py_files:
|
|
676
|
+
all_findings.extend(self.scan_file(f, project_root))
|
|
677
|
+
return all_findings
|