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.
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
- content = path.read_text(encoding="utf-8", errors="replace")
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
- if (
413
+ can_apply_cached = (
385
414
  apply_changes
386
415
  and cached_result.changed
387
- and cached_result.is_valid_python
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
- shutil.copy2(path, bak_path)
396
- path.write_text(cached_result.cleaned_code, encoding="utf-8")
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
- if (
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
- shutil.copy2(path, bak_path)
426
- path.write_text(result.cleaned_code, encoding="utf-8")
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
 
@@ -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_]{36}"),
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
- "HIGH",
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 not has_safe_loader and func_name == "yaml.load":
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="yaml.load() without SafeLoader can execute arbitrary Python objects",
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 scan_project(
545
- self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
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 py_files:
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=len(py_files))
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: