ballpython 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ballpython/__init__.py +9 -0
- ballpython/__main__.py +8 -0
- ballpython/cli.py +7 -0
- ballpython-2.0.0.dist-info/METADATA +322 -0
- ballpython-2.0.0.dist-info/RECORD +24 -0
- ballpython-2.0.0.dist-info/WHEEL +5 -0
- ballpython-2.0.0.dist-info/entry_points.txt +3 -0
- ballpython-2.0.0.dist-info/top_level.txt +2 -0
- pycleaner/__init__.py +53 -0
- pycleaner/__main__.py +8 -0
- pycleaner/cli.py +1548 -0
- pycleaner/complexity_analyzer.py +473 -0
- pycleaner/config.py +254 -0
- pycleaner/dead_code_detector.py +515 -0
- pycleaner/dependency_auditor.py +331 -0
- pycleaner/import_resolver.py +832 -0
- pycleaner/linter_formatter.py +590 -0
- pycleaner/pipeline.py +349 -0
- pycleaner/security_scanner.py +563 -0
- pycleaner/syntax_healer.py +577 -0
- pycleaner/taint_engine.py +720 -0
- pycleaner/test_generator.py +444 -0
- pycleaner/type_checker.py +989 -0
- pycleaner/typeshed_resolver.py +395 -0
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Static security pattern scanner for Python source code.
|
|
3
|
+
|
|
4
|
+
Detects dangerous function calls (eval, exec, pickle), hardcoded secrets,
|
|
5
|
+
SQL injection patterns, subprocess shell injection, insecure defaults,
|
|
6
|
+
and assert statements used for input validation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class SecurityFinding:
|
|
20
|
+
"""A single security issue detected in source code."""
|
|
21
|
+
|
|
22
|
+
filepath: str
|
|
23
|
+
lineno: int
|
|
24
|
+
end_lineno: int | None
|
|
25
|
+
severity: str # 'CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'
|
|
26
|
+
category: str
|
|
27
|
+
message: str
|
|
28
|
+
suggestion: str
|
|
29
|
+
code_snippet: str = ""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class SecurityReport:
|
|
34
|
+
"""Full security scan results."""
|
|
35
|
+
|
|
36
|
+
findings: list[SecurityFinding] = field(default_factory=list)
|
|
37
|
+
files_scanned: int = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def count(self) -> int:
|
|
41
|
+
return len(self.findings)
|
|
42
|
+
|
|
43
|
+
def by_severity(self, severity: str) -> list[SecurityFinding]:
|
|
44
|
+
return [f for f in self.findings if f.severity == severity]
|
|
45
|
+
|
|
46
|
+
def by_category(self, category: str) -> list[SecurityFinding]:
|
|
47
|
+
return [f for f in self.findings if f.category == category]
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def critical_count(self) -> int:
|
|
51
|
+
return len(self.by_severity("CRITICAL"))
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def high_count(self) -> int:
|
|
55
|
+
return len(self.by_severity("HIGH"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Regex patterns for detecting hardcoded secrets
|
|
59
|
+
_SECRET_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
|
|
60
|
+
(
|
|
61
|
+
"AWS Access Key",
|
|
62
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
63
|
+
"Use environment variables or a secrets manager",
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"AWS Secret Key",
|
|
67
|
+
re.compile(
|
|
68
|
+
r"(?i)(?:aws_secret|secret_key|secret_access)\s*=\s*['\"][A-Za-z0-9/+=]{20,}['\"]"
|
|
69
|
+
),
|
|
70
|
+
"Use environment variables or AWS credentials file",
|
|
71
|
+
),
|
|
72
|
+
(
|
|
73
|
+
"Generic API Key",
|
|
74
|
+
re.compile(
|
|
75
|
+
r"(?i)(?:api_key|apikey|api_secret)\s*=\s*['\"][A-Za-z0-9_\-]{16,}['\"]"
|
|
76
|
+
),
|
|
77
|
+
"Use environment variables or a secrets manager",
|
|
78
|
+
),
|
|
79
|
+
(
|
|
80
|
+
"Generic Password",
|
|
81
|
+
re.compile(r"(?i)(?:password|passwd|pwd)\s*=\s*['\"][^'\"]{4,}['\"]"),
|
|
82
|
+
"Use environment variables or a secrets manager",
|
|
83
|
+
),
|
|
84
|
+
(
|
|
85
|
+
"Generic Secret",
|
|
86
|
+
re.compile(
|
|
87
|
+
r"(?i)(?:secret|token|bearer)\s*=\s*['\"][A-Za-z0-9_\-\.]{16,}['\"]"
|
|
88
|
+
),
|
|
89
|
+
"Use environment variables or a secrets manager",
|
|
90
|
+
),
|
|
91
|
+
(
|
|
92
|
+
"JWT Token",
|
|
93
|
+
re.compile(r"eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
|
|
94
|
+
"Never hardcode JWT tokens; load from secure storage",
|
|
95
|
+
),
|
|
96
|
+
(
|
|
97
|
+
"Private Key Header",
|
|
98
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----"),
|
|
99
|
+
"Store private keys in files with restricted permissions, not in source code",
|
|
100
|
+
),
|
|
101
|
+
(
|
|
102
|
+
"GitHub Token",
|
|
103
|
+
re.compile(r"gh[ps]_[A-Za-z0-9_]{36}"),
|
|
104
|
+
"Use environment variables or GitHub's OIDC",
|
|
105
|
+
),
|
|
106
|
+
(
|
|
107
|
+
"Slack Token",
|
|
108
|
+
re.compile(r"xox[bpras]-[A-Za-z0-9\-]{10,}"),
|
|
109
|
+
"Use environment variables for Slack tokens",
|
|
110
|
+
),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_DANGEROUS_CALL_SPECS: dict[str, tuple[str, str, str, str]] = {
|
|
115
|
+
"eval": (
|
|
116
|
+
"CRITICAL",
|
|
117
|
+
"dangerous-eval",
|
|
118
|
+
"eval() executes arbitrary Python code",
|
|
119
|
+
"Avoid eval() with untrusted input; use safe alternatives",
|
|
120
|
+
),
|
|
121
|
+
"exec": (
|
|
122
|
+
"CRITICAL",
|
|
123
|
+
"dangerous-exec",
|
|
124
|
+
"exec() executes arbitrary Python code",
|
|
125
|
+
"Avoid exec() with untrusted input; use safe alternatives",
|
|
126
|
+
),
|
|
127
|
+
"compile": (
|
|
128
|
+
"HIGH",
|
|
129
|
+
"dangerous-compile",
|
|
130
|
+
"compile() can enable code execution",
|
|
131
|
+
"Avoid compile() with untrusted input; use safe alternatives",
|
|
132
|
+
),
|
|
133
|
+
"__import__": (
|
|
134
|
+
"MEDIUM",
|
|
135
|
+
"dynamic-import",
|
|
136
|
+
"__import__() enables dynamic module loading",
|
|
137
|
+
"Avoid __import__() with untrusted input; use safe alternatives",
|
|
138
|
+
),
|
|
139
|
+
"pickle.loads": (
|
|
140
|
+
"CRITICAL",
|
|
141
|
+
"insecure-deserialization",
|
|
142
|
+
"pickle.loads() deserializes arbitrary objects and can execute arbitrary code",
|
|
143
|
+
"Use json.loads() or a restricted deserializer instead",
|
|
144
|
+
),
|
|
145
|
+
"pickle.load": (
|
|
146
|
+
"CRITICAL",
|
|
147
|
+
"insecure-deserialization",
|
|
148
|
+
"pickle.load() deserializes arbitrary objects and can execute arbitrary code",
|
|
149
|
+
"Use json.loads() or a restricted deserializer instead",
|
|
150
|
+
),
|
|
151
|
+
"cPickle.loads": (
|
|
152
|
+
"CRITICAL",
|
|
153
|
+
"insecure-deserialization",
|
|
154
|
+
"cPickle.loads() deserializes arbitrary objects and can execute arbitrary code",
|
|
155
|
+
"Use json.loads() or a restricted deserializer instead",
|
|
156
|
+
),
|
|
157
|
+
"cPickle.load": (
|
|
158
|
+
"CRITICAL",
|
|
159
|
+
"insecure-deserialization",
|
|
160
|
+
"cPickle.load() deserializes arbitrary objects and can execute arbitrary code",
|
|
161
|
+
"Use json.loads() or a restricted deserializer instead",
|
|
162
|
+
),
|
|
163
|
+
"marshal.loads": (
|
|
164
|
+
"HIGH",
|
|
165
|
+
"insecure-deserialization",
|
|
166
|
+
"marshal.loads() can crash the interpreter with malformed input",
|
|
167
|
+
"Use json.loads() for data interchange",
|
|
168
|
+
),
|
|
169
|
+
"marshal.load": (
|
|
170
|
+
"HIGH",
|
|
171
|
+
"insecure-deserialization",
|
|
172
|
+
"marshal.load() can crash the interpreter with malformed input",
|
|
173
|
+
"Use json.loads() for data interchange",
|
|
174
|
+
),
|
|
175
|
+
"os.system": (
|
|
176
|
+
"HIGH",
|
|
177
|
+
"shell-injection",
|
|
178
|
+
"os.system() passes commands through the shell and is vulnerable to injection",
|
|
179
|
+
"Use subprocess.run() with a list of arguments (no shell=True)",
|
|
180
|
+
),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class _DangerousCallDetector(ast.NodeVisitor):
|
|
185
|
+
"""Detects dangerous function calls and insecure patterns in Python AST."""
|
|
186
|
+
|
|
187
|
+
def __init__(self, filepath: str, source_lines: list[str]) -> None:
|
|
188
|
+
self.filepath = filepath
|
|
189
|
+
self.source_lines = source_lines
|
|
190
|
+
self.findings: list[SecurityFinding] = []
|
|
191
|
+
|
|
192
|
+
def _get_snippet(self, lineno: int) -> str:
|
|
193
|
+
if 1 <= lineno <= len(self.source_lines):
|
|
194
|
+
return self.source_lines[lineno - 1].strip()
|
|
195
|
+
return ""
|
|
196
|
+
|
|
197
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
198
|
+
func_name = self._get_call_name(node)
|
|
199
|
+
if func_name:
|
|
200
|
+
self._check_dangerous_calls(node, func_name)
|
|
201
|
+
self._check_insecure_defaults(node, func_name)
|
|
202
|
+
self._check_sql_injection(node, func_name)
|
|
203
|
+
self._check_subprocess_shell(node, func_name)
|
|
204
|
+
self._check_yaml_unsafe_load(node, func_name)
|
|
205
|
+
self.generic_visit(node)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _is_test_path(filepath: str) -> bool:
|
|
209
|
+
parts = [p.lower() for p in Path(filepath).parts[:-1]]
|
|
210
|
+
return any(p in ("tests", "test", "testing") for p in parts)
|
|
211
|
+
|
|
212
|
+
def visit_Assert(self, node: ast.Assert) -> None:
|
|
213
|
+
if self._is_test_path(self.filepath):
|
|
214
|
+
return
|
|
215
|
+
self.findings.append(
|
|
216
|
+
SecurityFinding(
|
|
217
|
+
filepath=self.filepath,
|
|
218
|
+
lineno=node.lineno,
|
|
219
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
220
|
+
severity="LOW",
|
|
221
|
+
category="assert-in-production",
|
|
222
|
+
message="'assert' used for validation; assert statements are stripped when Python runs with -O flag",
|
|
223
|
+
suggestion="Use 'if not condition: raise ValueError(...)' for input validation",
|
|
224
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
self.generic_visit(node)
|
|
228
|
+
|
|
229
|
+
def _get_call_name(self, node: ast.Call) -> str | None:
|
|
230
|
+
if isinstance(node.func, ast.Name):
|
|
231
|
+
return node.func.id
|
|
232
|
+
if isinstance(node.func, ast.Attribute):
|
|
233
|
+
value_name = self._get_dotted_name(node.func.value)
|
|
234
|
+
if value_name:
|
|
235
|
+
return f"{value_name}.{node.func.attr}"
|
|
236
|
+
return node.func.attr
|
|
237
|
+
return None
|
|
238
|
+
|
|
239
|
+
@staticmethod
|
|
240
|
+
def _get_dotted_name(node: ast.AST) -> str | None:
|
|
241
|
+
if isinstance(node, ast.Name):
|
|
242
|
+
return node.id
|
|
243
|
+
if isinstance(node, ast.Attribute):
|
|
244
|
+
parent = _DangerousCallDetector._get_dotted_name(node.value)
|
|
245
|
+
if parent:
|
|
246
|
+
return f"{parent}.{node.attr}"
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
def _check_dangerous_calls(self, node: ast.Call, func_name: str) -> None:
|
|
250
|
+
spec = _DANGEROUS_CALL_SPECS.get(func_name)
|
|
251
|
+
if spec:
|
|
252
|
+
severity, category, msg, suggestion = spec
|
|
253
|
+
self.findings.append(
|
|
254
|
+
SecurityFinding(
|
|
255
|
+
filepath=self.filepath,
|
|
256
|
+
lineno=node.lineno,
|
|
257
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
258
|
+
severity=severity,
|
|
259
|
+
category=category,
|
|
260
|
+
message=msg,
|
|
261
|
+
suggestion=suggestion,
|
|
262
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _check_subprocess_shell(self, node: ast.Call, func_name: str) -> None:
|
|
267
|
+
subprocess_funcs = {
|
|
268
|
+
"subprocess.call",
|
|
269
|
+
"subprocess.run",
|
|
270
|
+
"subprocess.Popen",
|
|
271
|
+
"subprocess.check_output",
|
|
272
|
+
"subprocess.check_call",
|
|
273
|
+
}
|
|
274
|
+
if func_name not in subprocess_funcs:
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
for kw in node.keywords:
|
|
278
|
+
if (
|
|
279
|
+
kw.arg == "shell"
|
|
280
|
+
and isinstance(kw.value, ast.Constant)
|
|
281
|
+
and kw.value.value is True
|
|
282
|
+
):
|
|
283
|
+
self.findings.append(
|
|
284
|
+
SecurityFinding(
|
|
285
|
+
filepath=self.filepath,
|
|
286
|
+
lineno=node.lineno,
|
|
287
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
288
|
+
severity="HIGH",
|
|
289
|
+
category="shell-injection",
|
|
290
|
+
message=f"{func_name}(shell=True) is vulnerable to shell injection",
|
|
291
|
+
suggestion="Pass arguments as a list without shell=True",
|
|
292
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
293
|
+
)
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
def _check_insecure_tls(self, node: ast.Call, func_name: str) -> None:
|
|
297
|
+
http_funcs = {
|
|
298
|
+
"requests.get",
|
|
299
|
+
"requests.post",
|
|
300
|
+
"requests.put",
|
|
301
|
+
"requests.delete",
|
|
302
|
+
"requests.patch",
|
|
303
|
+
"requests.head",
|
|
304
|
+
"requests.options",
|
|
305
|
+
"requests.request",
|
|
306
|
+
"httpx.get",
|
|
307
|
+
"httpx.post",
|
|
308
|
+
"httpx.put",
|
|
309
|
+
"httpx.delete",
|
|
310
|
+
"httpx.patch",
|
|
311
|
+
"httpx.head",
|
|
312
|
+
"httpx.options",
|
|
313
|
+
"httpx.request",
|
|
314
|
+
}
|
|
315
|
+
if func_name in http_funcs:
|
|
316
|
+
for kw in node.keywords:
|
|
317
|
+
if (
|
|
318
|
+
kw.arg == "verify"
|
|
319
|
+
and isinstance(kw.value, ast.Constant)
|
|
320
|
+
and kw.value.value is False
|
|
321
|
+
):
|
|
322
|
+
self.findings.append(
|
|
323
|
+
SecurityFinding(
|
|
324
|
+
filepath=self.filepath,
|
|
325
|
+
lineno=node.lineno,
|
|
326
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
327
|
+
severity="HIGH",
|
|
328
|
+
category="insecure-tls",
|
|
329
|
+
message=f"{func_name}(verify=False) disables TLS certificate verification",
|
|
330
|
+
suggestion="Remove verify=False or use a custom CA bundle",
|
|
331
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
332
|
+
)
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
def _check_debug_mode(self, node: ast.Call, func_name: str) -> None:
|
|
336
|
+
if func_name in ("app.run", "application.run"):
|
|
337
|
+
for kw in node.keywords:
|
|
338
|
+
if (
|
|
339
|
+
kw.arg == "debug"
|
|
340
|
+
and isinstance(kw.value, ast.Constant)
|
|
341
|
+
and kw.value.value is True
|
|
342
|
+
):
|
|
343
|
+
self.findings.append(
|
|
344
|
+
SecurityFinding(
|
|
345
|
+
filepath=self.filepath,
|
|
346
|
+
lineno=node.lineno,
|
|
347
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
348
|
+
severity="MEDIUM",
|
|
349
|
+
category="debug-mode",
|
|
350
|
+
message="Running with debug=True exposes debugger and stack traces in production",
|
|
351
|
+
suggestion="Set debug=False for production deployments",
|
|
352
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
def _check_insecure_defaults(self, node: ast.Call, func_name: str) -> None:
|
|
357
|
+
self._check_insecure_tls(node, func_name)
|
|
358
|
+
self._check_debug_mode(node, func_name)
|
|
359
|
+
|
|
360
|
+
def _check_yaml_unsafe_load(self, node: ast.Call, func_name: str) -> None:
|
|
361
|
+
if func_name in ("yaml.load", "yaml.unsafe_load"):
|
|
362
|
+
has_safe_loader = False
|
|
363
|
+
for kw in node.keywords:
|
|
364
|
+
if kw.arg == "Loader" and (
|
|
365
|
+
(
|
|
366
|
+
isinstance(kw.value, ast.Attribute)
|
|
367
|
+
and kw.value.attr in ("SafeLoader", "FullLoader", "BaseLoader")
|
|
368
|
+
)
|
|
369
|
+
or (
|
|
370
|
+
isinstance(kw.value, ast.Name)
|
|
371
|
+
and kw.value.id in ("SafeLoader", "FullLoader", "BaseLoader")
|
|
372
|
+
)
|
|
373
|
+
):
|
|
374
|
+
has_safe_loader = True
|
|
375
|
+
if not has_safe_loader and func_name == "yaml.load":
|
|
376
|
+
self.findings.append(
|
|
377
|
+
SecurityFinding(
|
|
378
|
+
filepath=self.filepath,
|
|
379
|
+
lineno=node.lineno,
|
|
380
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
381
|
+
severity="CRITICAL",
|
|
382
|
+
category="insecure-deserialization",
|
|
383
|
+
message="yaml.load() without SafeLoader can execute arbitrary Python objects",
|
|
384
|
+
suggestion="Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader)",
|
|
385
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
@staticmethod
|
|
390
|
+
def _is_dynamic_sql_arg(arg: ast.AST) -> bool:
|
|
391
|
+
if isinstance(arg, ast.JoinedStr):
|
|
392
|
+
return True
|
|
393
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Add):
|
|
394
|
+
return True
|
|
395
|
+
if isinstance(arg, ast.Call):
|
|
396
|
+
return isinstance(arg.func, ast.Attribute) and arg.func.attr == "format"
|
|
397
|
+
if isinstance(arg, ast.BinOp) and isinstance(arg.op, ast.Mod):
|
|
398
|
+
return isinstance(arg.left, ast.Constant) and isinstance(
|
|
399
|
+
arg.left.value, str
|
|
400
|
+
)
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
def _check_sql_injection(self, node: ast.Call, func_name: str) -> None:
|
|
404
|
+
sql_methods = {"execute", "executemany", "executescript"}
|
|
405
|
+
method_name = func_name.split(".")[-1] if "." in func_name else ""
|
|
406
|
+
if method_name not in sql_methods or not node.args:
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
if self._is_dynamic_sql_arg(node.args[0]):
|
|
410
|
+
self.findings.append(
|
|
411
|
+
SecurityFinding(
|
|
412
|
+
filepath=self.filepath,
|
|
413
|
+
lineno=node.lineno,
|
|
414
|
+
end_lineno=getattr(node, "end_lineno", None),
|
|
415
|
+
severity="CRITICAL",
|
|
416
|
+
category="sql-injection",
|
|
417
|
+
message="SQL query built with string formatting is vulnerable to SQL injection",
|
|
418
|
+
suggestion="Use parameterized queries: cursor.execute('SELECT * FROM t WHERE id=?', (id,))",
|
|
419
|
+
code_snippet=self._get_snippet(node.lineno),
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class SecurityScanner:
|
|
425
|
+
"""Scans Python source code for security vulnerabilities."""
|
|
426
|
+
|
|
427
|
+
IGNORE_DIRS = frozenset(
|
|
428
|
+
{
|
|
429
|
+
".git",
|
|
430
|
+
".venv",
|
|
431
|
+
"venv",
|
|
432
|
+
"env",
|
|
433
|
+
"__pycache__",
|
|
434
|
+
"build",
|
|
435
|
+
"dist",
|
|
436
|
+
".tox",
|
|
437
|
+
".mypy_cache",
|
|
438
|
+
".pytest_cache",
|
|
439
|
+
".ruff_cache",
|
|
440
|
+
"site-packages",
|
|
441
|
+
}
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
def __init__(
|
|
445
|
+
self,
|
|
446
|
+
severity_threshold: str = "LOW",
|
|
447
|
+
ignore_rules: set[str] | None = None,
|
|
448
|
+
) -> None:
|
|
449
|
+
self.severity_threshold = severity_threshold
|
|
450
|
+
self.ignore_rules = ignore_rules or set()
|
|
451
|
+
self._severity_order = {
|
|
452
|
+
"CRITICAL": 4,
|
|
453
|
+
"HIGH": 3,
|
|
454
|
+
"MEDIUM": 2,
|
|
455
|
+
"LOW": 1,
|
|
456
|
+
"INFO": 0,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
def scan_source(self, source: str, filename: str = "<unknown>") -> SecurityReport:
|
|
460
|
+
"""Scan a single source string for security issues."""
|
|
461
|
+
findings: list[SecurityFinding] = []
|
|
462
|
+
|
|
463
|
+
try:
|
|
464
|
+
tree = ast.parse(source, filename=filename)
|
|
465
|
+
source_lines = source.splitlines()
|
|
466
|
+
detector = _DangerousCallDetector(filename, source_lines)
|
|
467
|
+
detector.visit(tree)
|
|
468
|
+
findings.extend(detector.findings)
|
|
469
|
+
except SyntaxError:
|
|
470
|
+
# Code with syntax errors cannot be AST-parsed; regex checks still run
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
findings.extend(self._detect_secrets(source, filename))
|
|
474
|
+
|
|
475
|
+
filtered = self._filter_findings(findings)
|
|
476
|
+
filtered.sort(
|
|
477
|
+
key=lambda f: (self._severity_order.get(f.severity, 0) * -1, f.lineno)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
return SecurityReport(findings=filtered, files_scanned=1)
|
|
481
|
+
|
|
482
|
+
def _discover_project_py_files(self, root: Path) -> list[Path]:
|
|
483
|
+
py_files: list[Path] = []
|
|
484
|
+
for current_root, dirs, filenames in os.walk(root):
|
|
485
|
+
dirs[:] = [
|
|
486
|
+
d for d in dirs if d not in self.IGNORE_DIRS and not d.startswith(".")
|
|
487
|
+
]
|
|
488
|
+
for fname in filenames:
|
|
489
|
+
if fname.endswith(".py"):
|
|
490
|
+
py_files.append(Path(current_root) / fname)
|
|
491
|
+
return py_files
|
|
492
|
+
|
|
493
|
+
def scan_project(self, root_dir: Path | str) -> SecurityReport:
|
|
494
|
+
"""Scan all Python files in a project for security issues."""
|
|
495
|
+
root = Path(root_dir).resolve()
|
|
496
|
+
py_files = self._discover_project_py_files(root)
|
|
497
|
+
all_findings: list[SecurityFinding] = []
|
|
498
|
+
|
|
499
|
+
for fpath in py_files:
|
|
500
|
+
try:
|
|
501
|
+
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
502
|
+
report = self.scan_source(content, filename=str(fpath))
|
|
503
|
+
all_findings.extend(report.findings)
|
|
504
|
+
except OSError:
|
|
505
|
+
continue
|
|
506
|
+
|
|
507
|
+
all_findings.sort(
|
|
508
|
+
key=lambda f: (
|
|
509
|
+
self._severity_order.get(f.severity, 0) * -1,
|
|
510
|
+
f.filepath,
|
|
511
|
+
f.lineno,
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
return SecurityReport(findings=all_findings, files_scanned=len(py_files))
|
|
515
|
+
|
|
516
|
+
@staticmethod
|
|
517
|
+
def _is_test_placeholder_line(filename: str, line_lower: str) -> bool:
|
|
518
|
+
if "test" not in filename.lower():
|
|
519
|
+
return False
|
|
520
|
+
placeholders = ("mock", "fake", "dummy", "example")
|
|
521
|
+
return any(p in line_lower for p in placeholders)
|
|
522
|
+
|
|
523
|
+
def _detect_secrets(self, source: str, filename: str) -> list[SecurityFinding]:
|
|
524
|
+
"""Detect hardcoded secrets using regex patterns."""
|
|
525
|
+
if _DangerousCallDetector._is_test_path(filename):
|
|
526
|
+
return []
|
|
527
|
+
|
|
528
|
+
findings: list[SecurityFinding] = []
|
|
529
|
+
for i, line in enumerate(source.splitlines(), 1):
|
|
530
|
+
stripped = line.strip()
|
|
531
|
+
if stripped.startswith("#") or self._is_test_placeholder_line(
|
|
532
|
+
filename, stripped.lower()
|
|
533
|
+
):
|
|
534
|
+
continue
|
|
535
|
+
|
|
536
|
+
for name, pattern, suggestion in _SECRET_PATTERNS:
|
|
537
|
+
if pattern.search(line):
|
|
538
|
+
findings.append(
|
|
539
|
+
SecurityFinding(
|
|
540
|
+
filepath=filename,
|
|
541
|
+
lineno=i,
|
|
542
|
+
end_lineno=i,
|
|
543
|
+
severity="HIGH",
|
|
544
|
+
category="hardcoded-secret",
|
|
545
|
+
message=f"Potential {name} found hardcoded in source",
|
|
546
|
+
suggestion=suggestion,
|
|
547
|
+
code_snippet=stripped[:120],
|
|
548
|
+
)
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
return findings
|
|
552
|
+
|
|
553
|
+
def _filter_findings(
|
|
554
|
+
self, findings: list[SecurityFinding]
|
|
555
|
+
) -> list[SecurityFinding]:
|
|
556
|
+
"""Filter findings by severity threshold and ignored rules."""
|
|
557
|
+
threshold = self._severity_order.get(self.severity_threshold, 0)
|
|
558
|
+
return [
|
|
559
|
+
f
|
|
560
|
+
for f in findings
|
|
561
|
+
if self._severity_order.get(f.severity, 0) >= threshold
|
|
562
|
+
and f.category not in self.ignore_rules
|
|
563
|
+
]
|