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.
@@ -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
+ ]