ballpython 2.1.0__py3-none-any.whl → 2.1.2__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-2.1.0.dist-info → ballpython-2.1.2.dist-info}/METADATA +1 -3
- ballpython-2.1.2.dist-info/RECORD +34 -0
- pycleaner/__init__.py +1 -1
- pycleaner/cli.py +202 -36
- pycleaner/complexity_analyzer.py +11 -8
- pycleaner/dead_code_detector.py +156 -43
- pycleaner/dependency_auditor.py +26 -5
- pycleaner/discovery.py +17 -0
- pycleaner/frameworks/__init__.py +8 -1
- pycleaner/linter_formatter.py +23 -4
- pycleaner/pipeline.py +99 -11
- pycleaner/security_scanner.py +149 -14
- pycleaner/syntax_healer.py +208 -43
- pycleaner/taint_engine.py +19 -15
- pycleaner/type_checker.py +128 -25
- pycleaner/verifier.py +12 -4
- ballpython-2.1.0.dist-info/RECORD +0 -34
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/WHEEL +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.1.0.dist-info → ballpython-2.1.2.dist-info}/top_level.txt +0 -0
pycleaner/pipeline.py
CHANGED
|
@@ -18,6 +18,7 @@ from pycleaner.dead_code_detector import DeadCodeFixer
|
|
|
18
18
|
from pycleaner.import_resolver import ImportResolver
|
|
19
19
|
from pycleaner.linter_formatter import LinterFormatter
|
|
20
20
|
from pycleaner.modernizer import Modernizer
|
|
21
|
+
from pycleaner.security_scanner import SecurityFinding, SecurityScanner
|
|
21
22
|
from pycleaner.syntax_healer import SyntaxHealer
|
|
22
23
|
from pycleaner.verifier import (
|
|
23
24
|
CounterExample,
|
|
@@ -48,6 +49,7 @@ class CleanResult:
|
|
|
48
49
|
format_changed: bool = False
|
|
49
50
|
error: str | None = None
|
|
50
51
|
diagnostics: list[dict[str, str]] = field(default_factory=list)
|
|
52
|
+
security_findings: list[SecurityFinding] = field(default_factory=list)
|
|
51
53
|
proof_receipts: list[ProofReceipt] = field(default_factory=list)
|
|
52
54
|
refused_changes: list[CounterExample] = field(default_factory=list)
|
|
53
55
|
verification_tier: VerificationTier | None = None
|
|
@@ -139,6 +141,7 @@ class CleanPipeline:
|
|
|
139
141
|
self.dead_code_fixer = DeadCodeFixer()
|
|
140
142
|
self.import_resolver = ImportResolver(custom_import_map=import_map)
|
|
141
143
|
self.linter_formatter = LinterFormatter()
|
|
144
|
+
self.security_scanner = SecurityScanner(severity_threshold="HIGH")
|
|
142
145
|
|
|
143
146
|
def _stage_heal(
|
|
144
147
|
self, current_code: str, filename: str, syntax_repairs: list[str]
|
|
@@ -318,6 +321,21 @@ class CleanPipeline:
|
|
|
318
321
|
else:
|
|
319
322
|
verification_tier = VerificationTier.TIER_B_SUGGESTED
|
|
320
323
|
|
|
324
|
+
# Scan for dangerous calls and security findings
|
|
325
|
+
sec_report = self.security_scanner.scan_source(current_code, filename=filename)
|
|
326
|
+
security_findings = list(sec_report.findings)
|
|
327
|
+
for sf in security_findings:
|
|
328
|
+
diagnostics.append(
|
|
329
|
+
{
|
|
330
|
+
"file": filename,
|
|
331
|
+
"line": str(sf.lineno),
|
|
332
|
+
"severity": sf.severity,
|
|
333
|
+
"category": sf.category,
|
|
334
|
+
"message": sf.message,
|
|
335
|
+
"suggestion": sf.suggestion,
|
|
336
|
+
}
|
|
337
|
+
)
|
|
338
|
+
|
|
321
339
|
return CleanResult(
|
|
322
340
|
path=Path(filename),
|
|
323
341
|
original_code=source,
|
|
@@ -334,6 +352,7 @@ class CleanPipeline:
|
|
|
334
352
|
format_changed=format_changed,
|
|
335
353
|
error=final_error,
|
|
336
354
|
diagnostics=diagnostics,
|
|
355
|
+
security_findings=security_findings,
|
|
337
356
|
proof_receipts=proof_receipts,
|
|
338
357
|
refused_changes=refused_changes,
|
|
339
358
|
verification_tier=verification_tier,
|
|
@@ -376,24 +395,58 @@ class CleanPipeline:
|
|
|
376
395
|
) -> CleanResult:
|
|
377
396
|
"""Process a single file on disk and optionally write back updates."""
|
|
378
397
|
path = Path(filepath).resolve()
|
|
379
|
-
|
|
398
|
+
try:
|
|
399
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
400
|
+
except OSError as err:
|
|
401
|
+
return CleanResult(
|
|
402
|
+
path=path,
|
|
403
|
+
original_code="",
|
|
404
|
+
cleaned_code="",
|
|
405
|
+
changed=False,
|
|
406
|
+
is_valid_python=False,
|
|
407
|
+
error=f"Permission denied (read-only?): {err}",
|
|
408
|
+
)
|
|
380
409
|
|
|
381
410
|
if self.cache is not None:
|
|
382
411
|
cached_result = self.cache.get(path, content)
|
|
383
412
|
if cached_result is not None:
|
|
384
|
-
|
|
413
|
+
can_apply_cached = (
|
|
385
414
|
apply_changes
|
|
386
415
|
and cached_result.changed
|
|
387
|
-
and
|
|
416
|
+
and (
|
|
417
|
+
cached_result.is_valid_python
|
|
418
|
+
or bool(cached_result.syntax_repairs)
|
|
419
|
+
)
|
|
388
420
|
and (
|
|
389
421
|
cached_result.verification_tier
|
|
390
422
|
!= VerificationTier.TIER_C_REFUSED
|
|
391
423
|
)
|
|
392
|
-
)
|
|
424
|
+
)
|
|
425
|
+
if can_apply_cached:
|
|
393
426
|
if backup:
|
|
394
427
|
bak_path = path.with_name(path.name + ".pycleaner.bak")
|
|
395
|
-
|
|
396
|
-
|
|
428
|
+
try:
|
|
429
|
+
shutil.copy2(path, bak_path)
|
|
430
|
+
except OSError as err:
|
|
431
|
+
return CleanResult(
|
|
432
|
+
path=path,
|
|
433
|
+
original_code=content,
|
|
434
|
+
cleaned_code=content,
|
|
435
|
+
changed=False,
|
|
436
|
+
is_valid_python=cached_result.is_valid_python,
|
|
437
|
+
error=f"Permission denied creating backup (read-only?): {err}",
|
|
438
|
+
)
|
|
439
|
+
try:
|
|
440
|
+
path.write_text(cached_result.cleaned_code, encoding="utf-8")
|
|
441
|
+
except OSError as err:
|
|
442
|
+
return CleanResult(
|
|
443
|
+
path=path,
|
|
444
|
+
original_code=content,
|
|
445
|
+
cleaned_code=content,
|
|
446
|
+
changed=False,
|
|
447
|
+
is_valid_python=cached_result.is_valid_python,
|
|
448
|
+
error=f"Permission denied (read-only?): {err}",
|
|
449
|
+
)
|
|
397
450
|
return cached_result
|
|
398
451
|
|
|
399
452
|
result = self.process_source(content, filename=str(path))
|
|
@@ -414,16 +467,51 @@ class CleanPipeline:
|
|
|
414
467
|
diff=result.diff,
|
|
415
468
|
)
|
|
416
469
|
|
|
417
|
-
|
|
470
|
+
can_apply = (
|
|
418
471
|
apply_changes
|
|
419
472
|
and result.changed
|
|
420
|
-
and result.is_valid_python
|
|
473
|
+
and (result.is_valid_python or bool(result.syntax_repairs))
|
|
421
474
|
and (result.verification_tier != VerificationTier.TIER_C_REFUSED)
|
|
422
|
-
)
|
|
475
|
+
)
|
|
476
|
+
if can_apply:
|
|
423
477
|
if backup:
|
|
424
478
|
bak_path = path.with_name(path.name + ".pycleaner.bak")
|
|
425
|
-
|
|
426
|
-
|
|
479
|
+
try:
|
|
480
|
+
shutil.copy2(path, bak_path)
|
|
481
|
+
except OSError as err:
|
|
482
|
+
return CleanResult(
|
|
483
|
+
path=path,
|
|
484
|
+
original_code=content,
|
|
485
|
+
cleaned_code=content,
|
|
486
|
+
changed=False,
|
|
487
|
+
is_valid_python=result.is_valid_python,
|
|
488
|
+
error=f"Permission denied creating backup (read-only?): {err}",
|
|
489
|
+
syntax_repairs=result.syntax_repairs,
|
|
490
|
+
modernize_transforms=result.modernize_transforms,
|
|
491
|
+
dead_code_pruned=result.dead_code_pruned,
|
|
492
|
+
resolved_imports=result.resolved_imports,
|
|
493
|
+
unresolved_symbols=result.unresolved_symbols,
|
|
494
|
+
diagnostics=result.diagnostics,
|
|
495
|
+
security_findings=result.security_findings,
|
|
496
|
+
)
|
|
497
|
+
try:
|
|
498
|
+
path.write_text(result.cleaned_code, encoding="utf-8")
|
|
499
|
+
except OSError as err:
|
|
500
|
+
return CleanResult(
|
|
501
|
+
path=path,
|
|
502
|
+
original_code=content,
|
|
503
|
+
cleaned_code=content,
|
|
504
|
+
changed=False,
|
|
505
|
+
is_valid_python=result.is_valid_python,
|
|
506
|
+
error=f"Permission denied (read-only?): {err}",
|
|
507
|
+
syntax_repairs=result.syntax_repairs,
|
|
508
|
+
modernize_transforms=result.modernize_transforms,
|
|
509
|
+
dead_code_pruned=result.dead_code_pruned,
|
|
510
|
+
resolved_imports=result.resolved_imports,
|
|
511
|
+
unresolved_symbols=result.unresolved_symbols,
|
|
512
|
+
diagnostics=result.diagnostics,
|
|
513
|
+
security_findings=result.security_findings,
|
|
514
|
+
)
|
|
427
515
|
|
|
428
516
|
return result
|
|
429
517
|
|
pycleaner/security_scanner.py
CHANGED
|
@@ -103,7 +103,7 @@ _SECRET_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
|
|
|
103
103
|
),
|
|
104
104
|
(
|
|
105
105
|
"GitHub Token",
|
|
106
|
-
re.compile(r"gh[ps]_[A-Za-z0-9_]{
|
|
106
|
+
re.compile(r"gh[ps]_[A-Za-z0-9_]{20,}"),
|
|
107
107
|
"Use environment variables or GitHub's OIDC",
|
|
108
108
|
),
|
|
109
109
|
(
|
|
@@ -111,9 +111,49 @@ _SECRET_PATTERNS: list[tuple[str, re.Pattern[str], str]] = [
|
|
|
111
111
|
re.compile(r"xox[bpras]-[A-Za-z0-9\-]{10,}"),
|
|
112
112
|
"Use environment variables for Slack tokens",
|
|
113
113
|
),
|
|
114
|
+
(
|
|
115
|
+
"Stripe Secret Key",
|
|
116
|
+
re.compile(r"sk_(?:live|test)_[A-Za-z0-9]{20,}"),
|
|
117
|
+
"Use environment variables or Stripe's secret manager",
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
"SendGrid API Key",
|
|
121
|
+
re.compile(r"SG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}"),
|
|
122
|
+
"Use environment variables for SendGrid keys",
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
"Credentialed Database URL",
|
|
126
|
+
re.compile(
|
|
127
|
+
r"(?i)(?:postgres|postgresql|mysql|mongodb|redis|amqp)://[^:]+:[^@]+@[^/\s]+"
|
|
128
|
+
),
|
|
129
|
+
"Never embed credentials in connection strings; use secret stores",
|
|
130
|
+
),
|
|
114
131
|
]
|
|
115
132
|
|
|
116
133
|
|
|
134
|
+
def is_secret_literal(value: str) -> bool:
|
|
135
|
+
"""Check if a string literal matches any known secret pattern."""
|
|
136
|
+
if not isinstance(value, str) or len(value) < 8:
|
|
137
|
+
return False
|
|
138
|
+
for _, pattern, _ in _SECRET_PATTERNS:
|
|
139
|
+
if pattern.search(value):
|
|
140
|
+
return True
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def is_secret_ast_node(node: ast.AST | None) -> bool:
|
|
145
|
+
"""Recursively check if an AST node contains any hardcoded secret string."""
|
|
146
|
+
if node is None:
|
|
147
|
+
return False
|
|
148
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
149
|
+
return is_secret_literal(node.value)
|
|
150
|
+
for child in ast.walk(node):
|
|
151
|
+
if isinstance(child, ast.Constant) and isinstance(child.value, str):
|
|
152
|
+
if is_secret_literal(child.value):
|
|
153
|
+
return True
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
|
|
117
157
|
_DANGEROUS_CALL_SPECS: dict[str, tuple[str, str, str, str]] = {
|
|
118
158
|
"eval": (
|
|
119
159
|
"CRITICAL",
|
|
@@ -176,7 +216,7 @@ _DANGEROUS_CALL_SPECS: dict[str, tuple[str, str, str, str]] = {
|
|
|
176
216
|
"Use json.loads() for data interchange",
|
|
177
217
|
),
|
|
178
218
|
"os.system": (
|
|
179
|
-
"
|
|
219
|
+
"CRITICAL",
|
|
180
220
|
"shell-injection",
|
|
181
221
|
"os.system() passes commands through the shell and is vulnerable to injection",
|
|
182
222
|
"Use subprocess.run() with a list of arguments (no shell=True)",
|
|
@@ -432,7 +472,12 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
432
472
|
)
|
|
433
473
|
):
|
|
434
474
|
has_safe_loader = True
|
|
435
|
-
if
|
|
475
|
+
if func_name == "yaml.unsafe_load" or not has_safe_loader:
|
|
476
|
+
msg = (
|
|
477
|
+
"yaml.unsafe_load() can execute arbitrary Python objects"
|
|
478
|
+
if func_name == "yaml.unsafe_load"
|
|
479
|
+
else "yaml.load() without SafeLoader can execute arbitrary Python objects"
|
|
480
|
+
)
|
|
436
481
|
self._add_finding(
|
|
437
482
|
SecurityFinding(
|
|
438
483
|
filepath=self.filepath,
|
|
@@ -440,7 +485,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
440
485
|
end_lineno=getattr(node, "end_lineno", None),
|
|
441
486
|
severity="CRITICAL",
|
|
442
487
|
category="insecure-deserialization",
|
|
443
|
-
message=
|
|
488
|
+
message=msg,
|
|
444
489
|
suggestion="Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader)",
|
|
445
490
|
code_snippet=self._get_snippet(node.lineno),
|
|
446
491
|
)
|
|
@@ -494,6 +539,52 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
494
539
|
)
|
|
495
540
|
|
|
496
541
|
|
|
542
|
+
_FALLBACK_DANGEROUS_PATTERNS: list[tuple[re.Pattern[str], str, str, str, str]] = [
|
|
543
|
+
(
|
|
544
|
+
re.compile(r"\beval\s*\("),
|
|
545
|
+
"CRITICAL",
|
|
546
|
+
"dangerous-eval",
|
|
547
|
+
"eval() executes arbitrary Python code",
|
|
548
|
+
"Avoid eval() with untrusted input; use safe alternatives",
|
|
549
|
+
),
|
|
550
|
+
(
|
|
551
|
+
re.compile(r"\bexec\s*\("),
|
|
552
|
+
"CRITICAL",
|
|
553
|
+
"dangerous-exec",
|
|
554
|
+
"exec() executes arbitrary Python code",
|
|
555
|
+
"Avoid exec() with untrusted input; use safe alternatives",
|
|
556
|
+
),
|
|
557
|
+
(
|
|
558
|
+
re.compile(r"\bos\.system\s*\("),
|
|
559
|
+
"CRITICAL",
|
|
560
|
+
"shell-injection",
|
|
561
|
+
"os.system() passes commands through the shell and is vulnerable to injection",
|
|
562
|
+
"Use subprocess.run() with a list of arguments (no shell=True)",
|
|
563
|
+
),
|
|
564
|
+
(
|
|
565
|
+
re.compile(r"\b(?:pickle|cPickle)\.(?:loads?)\s*\("),
|
|
566
|
+
"CRITICAL",
|
|
567
|
+
"insecure-deserialization",
|
|
568
|
+
"pickle.loads() deserializes arbitrary objects and can execute arbitrary code",
|
|
569
|
+
"Use json.loads() or a restricted deserializer instead",
|
|
570
|
+
),
|
|
571
|
+
(
|
|
572
|
+
re.compile(r"\byaml\.unsafe_load\s*\("),
|
|
573
|
+
"CRITICAL",
|
|
574
|
+
"insecure-deserialization",
|
|
575
|
+
"yaml.unsafe_load() can execute arbitrary Python objects",
|
|
576
|
+
"Use yaml.safe_load() instead",
|
|
577
|
+
),
|
|
578
|
+
(
|
|
579
|
+
re.compile(r"\byaml\.load\s*\("),
|
|
580
|
+
"CRITICAL",
|
|
581
|
+
"insecure-deserialization",
|
|
582
|
+
"yaml.load() without SafeLoader can execute arbitrary Python objects",
|
|
583
|
+
"Use yaml.safe_load() or yaml.load(data, Loader=yaml.SafeLoader)",
|
|
584
|
+
),
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
|
|
497
588
|
class SecurityScanner:
|
|
498
589
|
"""Scans Python source code for security vulnerabilities."""
|
|
499
590
|
|
|
@@ -526,6 +617,7 @@ class SecurityScanner:
|
|
|
526
617
|
except SyntaxError:
|
|
527
618
|
# Code with syntax errors cannot be AST-parsed; regex checks still run
|
|
528
619
|
logging.getLogger(__name__).debug("Suppressed exception", exc_info=True)
|
|
620
|
+
findings.extend(self._detect_dangerous_calls_fallback(source, filename))
|
|
529
621
|
|
|
530
622
|
findings.extend(self._detect_secrets(source, filename))
|
|
531
623
|
|
|
@@ -536,26 +628,59 @@ class SecurityScanner:
|
|
|
536
628
|
|
|
537
629
|
return SecurityReport(findings=filtered, files_scanned=1)
|
|
538
630
|
|
|
631
|
+
def _detect_dangerous_calls_fallback(
|
|
632
|
+
self, source: str, filename: str
|
|
633
|
+
) -> list[SecurityFinding]:
|
|
634
|
+
"""Regex-based fallback for dangerous calls when AST cannot be parsed."""
|
|
635
|
+
findings: list[SecurityFinding] = []
|
|
636
|
+
for i, line in enumerate(source.splitlines(), 1):
|
|
637
|
+
stripped = line.strip()
|
|
638
|
+
if stripped.startswith("#"):
|
|
639
|
+
continue
|
|
640
|
+
if "#" in line:
|
|
641
|
+
comment = line.split("#", 1)[1].strip().lower()
|
|
642
|
+
if (
|
|
643
|
+
"nosec" in comment
|
|
644
|
+
or "noqa" in comment
|
|
645
|
+
or "pycleaner: ignore" in comment
|
|
646
|
+
):
|
|
647
|
+
continue
|
|
648
|
+
|
|
649
|
+
for pat, sev, cat, msg, sugg in _FALLBACK_DANGEROUS_PATTERNS:
|
|
650
|
+
if pat.search(line):
|
|
651
|
+
if cat == "insecure-deserialization" and "yaml.load" in pat.pattern:
|
|
652
|
+
if "SafeLoader" in line or "BaseLoader" in line:
|
|
653
|
+
continue
|
|
654
|
+
findings.append(
|
|
655
|
+
SecurityFinding(
|
|
656
|
+
filepath=filename,
|
|
657
|
+
lineno=i,
|
|
658
|
+
end_lineno=i,
|
|
659
|
+
severity=sev,
|
|
660
|
+
category=cat,
|
|
661
|
+
message=msg,
|
|
662
|
+
suggestion=sugg,
|
|
663
|
+
code_snippet=stripped[:120],
|
|
664
|
+
)
|
|
665
|
+
)
|
|
666
|
+
return findings
|
|
667
|
+
|
|
539
668
|
def _discover_project_py_files(
|
|
540
669
|
self, root: Path, exclude_patterns: Sequence[str] = ()
|
|
541
670
|
) -> list[Path]:
|
|
542
671
|
return collect_project_python_files(root, exclude_patterns=exclude_patterns)
|
|
543
672
|
|
|
544
|
-
def
|
|
545
|
-
|
|
546
|
-
) -> SecurityReport:
|
|
547
|
-
"""Scan all Python files in a project for security issues."""
|
|
548
|
-
root = Path(root_dir).resolve()
|
|
549
|
-
py_files = self._discover_project_py_files(
|
|
550
|
-
root, exclude_patterns=exclude_patterns
|
|
551
|
-
)
|
|
673
|
+
def scan_files(self, target_files: Sequence[Path]) -> SecurityReport:
|
|
674
|
+
"""Scan a specific sequence of Python files for security issues."""
|
|
552
675
|
all_findings: list[SecurityFinding] = []
|
|
676
|
+
scanned_count = 0
|
|
553
677
|
|
|
554
|
-
for fpath in
|
|
678
|
+
for fpath in target_files:
|
|
555
679
|
try:
|
|
556
680
|
content = fpath.read_text(encoding="utf-8", errors="replace")
|
|
557
681
|
report = self.scan_source(content, filename=str(fpath))
|
|
558
682
|
all_findings.extend(report.findings)
|
|
683
|
+
scanned_count += 1
|
|
559
684
|
except OSError:
|
|
560
685
|
continue
|
|
561
686
|
|
|
@@ -566,7 +691,17 @@ class SecurityScanner:
|
|
|
566
691
|
f.lineno,
|
|
567
692
|
)
|
|
568
693
|
)
|
|
569
|
-
return SecurityReport(findings=all_findings, files_scanned=
|
|
694
|
+
return SecurityReport(findings=all_findings, files_scanned=scanned_count)
|
|
695
|
+
|
|
696
|
+
def scan_project(
|
|
697
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
698
|
+
) -> SecurityReport:
|
|
699
|
+
"""Scan all Python files in a project for security issues."""
|
|
700
|
+
root = Path(root_dir).resolve()
|
|
701
|
+
py_files = self._discover_project_py_files(
|
|
702
|
+
root, exclude_patterns=exclude_patterns
|
|
703
|
+
)
|
|
704
|
+
return self.scan_files(py_files)
|
|
570
705
|
|
|
571
706
|
@staticmethod
|
|
572
707
|
def _is_test_placeholder_line(filename: str, line_lower: str) -> bool:
|