ballpython 2.0.0__py3-none-any.whl → 2.0.3__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/py.typed +0 -0
- ballpython-2.0.3.dist-info/METADATA +103 -0
- ballpython-2.0.3.dist-info/RECORD +34 -0
- pycleaner/__init__.py +39 -2
- pycleaner/baseline.py +127 -0
- pycleaner/cache.py +282 -0
- pycleaner/cli.py +644 -43
- pycleaner/complexity_analyzer.py +16 -28
- pycleaner/config.py +8 -1
- pycleaner/dead_code_detector.py +275 -38
- pycleaner/dependency_auditor.py +175 -75
- pycleaner/discovery.py +206 -0
- pycleaner/explanations.py +323 -0
- pycleaner/frameworks/__init__.py +115 -0
- pycleaner/frameworks/plugins.py +290 -0
- pycleaner/modernizer.py +349 -0
- pycleaner/pipeline.py +208 -20
- pycleaner/py.typed +0 -0
- pycleaner/security_scanner.py +64 -38
- pycleaner/syntax_healer.py +188 -10
- pycleaner/taint_engine.py +8 -8
- pycleaner/test_generator.py +124 -35
- pycleaner/type_checker.py +29 -33
- pycleaner/verifier.py +495 -0
- ballpython-2.0.0.dist-info/METADATA +0 -322
- ballpython-2.0.0.dist-info/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.3.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.3.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.3.dist-info}/top_level.txt +0 -0
pycleaner/pipeline.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Cleanup pipeline coordinating syntax healing,
|
|
2
|
+
Cleanup pipeline coordinating syntax healing, code modernization, dead-code pruning,
|
|
3
|
+
import resolution, linting, and formatting.
|
|
3
4
|
"""
|
|
4
5
|
|
|
5
6
|
from __future__ import annotations
|
|
@@ -12,9 +13,18 @@ from dataclasses import dataclass, field
|
|
|
12
13
|
from pathlib import Path
|
|
13
14
|
from typing import TYPE_CHECKING, Any
|
|
14
15
|
|
|
16
|
+
from pycleaner.cache import ContentAddressableCache
|
|
17
|
+
from pycleaner.dead_code_detector import DeadCodeFixer
|
|
15
18
|
from pycleaner.import_resolver import ImportResolver
|
|
16
19
|
from pycleaner.linter_formatter import LinterFormatter
|
|
20
|
+
from pycleaner.modernizer import Modernizer
|
|
17
21
|
from pycleaner.syntax_healer import SyntaxHealer
|
|
22
|
+
from pycleaner.verifier import (
|
|
23
|
+
CounterExample,
|
|
24
|
+
IsolatedDifferentialVerifier,
|
|
25
|
+
ProofReceipt,
|
|
26
|
+
VerificationTier,
|
|
27
|
+
)
|
|
18
28
|
|
|
19
29
|
if TYPE_CHECKING:
|
|
20
30
|
from pycleaner.config import PyCleanerConfig
|
|
@@ -30,12 +40,17 @@ class CleanResult:
|
|
|
30
40
|
changed: bool
|
|
31
41
|
is_valid_python: bool
|
|
32
42
|
syntax_repairs: list[str] = field(default_factory=list)
|
|
43
|
+
modernize_transforms: list[str] = field(default_factory=list)
|
|
44
|
+
dead_code_pruned: list[str] = field(default_factory=list)
|
|
33
45
|
resolved_imports: list[str] = field(default_factory=list)
|
|
34
46
|
unresolved_symbols: list[str] = field(default_factory=list)
|
|
35
47
|
lint_changed: bool = False
|
|
36
48
|
format_changed: bool = False
|
|
37
49
|
error: str | None = None
|
|
38
50
|
diagnostics: list[dict[str, str]] = field(default_factory=list)
|
|
51
|
+
proof_receipts: list[ProofReceipt] = field(default_factory=list)
|
|
52
|
+
refused_changes: list[CounterExample] = field(default_factory=list)
|
|
53
|
+
verification_tier: VerificationTier | None = None
|
|
39
54
|
|
|
40
55
|
@property
|
|
41
56
|
def diff(self) -> str:
|
|
@@ -59,10 +74,17 @@ class PipelineOptions:
|
|
|
59
74
|
"""Configurable feature flags and mappings for CleanPipeline."""
|
|
60
75
|
|
|
61
76
|
enable_syntax_healing: bool = True
|
|
77
|
+
enable_modernizer: bool = True
|
|
78
|
+
enable_dead_code_pruning: bool = True
|
|
62
79
|
enable_import_resolution: bool = True
|
|
63
80
|
enable_lint_fixing: bool = True
|
|
64
81
|
enable_formatting: bool = True
|
|
65
82
|
custom_import_map: dict[str, str] | None = None
|
|
83
|
+
verify_proofs: bool = False
|
|
84
|
+
proof_iterations: int = 50
|
|
85
|
+
proof_seed: int | None = None
|
|
86
|
+
enable_cache: bool = False
|
|
87
|
+
cache_db_path: str = ".pycleaner/cache.db"
|
|
66
88
|
|
|
67
89
|
|
|
68
90
|
class CleanPipeline:
|
|
@@ -76,16 +98,36 @@ class CleanPipeline:
|
|
|
76
98
|
) -> None:
|
|
77
99
|
opts = options or PipelineOptions(
|
|
78
100
|
enable_syntax_healing=kwargs.get("enable_syntax_healing", True),
|
|
101
|
+
enable_modernizer=kwargs.get("enable_modernizer", True),
|
|
102
|
+
enable_dead_code_pruning=kwargs.get("enable_dead_code_pruning", True),
|
|
79
103
|
enable_import_resolution=kwargs.get("enable_import_resolution", True),
|
|
80
104
|
enable_lint_fixing=kwargs.get("enable_lint_fixing", True),
|
|
81
105
|
enable_formatting=kwargs.get("enable_formatting", True),
|
|
82
106
|
custom_import_map=kwargs.get("custom_import_map"),
|
|
107
|
+
verify_proofs=kwargs.get("verify_proofs", False),
|
|
108
|
+
proof_iterations=kwargs.get("proof_iterations", 50),
|
|
109
|
+
proof_seed=kwargs.get("proof_seed"),
|
|
110
|
+
enable_cache=kwargs.get("enable_cache", False),
|
|
111
|
+
cache_db_path=kwargs.get("cache_db_path", ".pycleaner/cache.db"),
|
|
83
112
|
)
|
|
84
113
|
self.enable_syntax_healing = opts.enable_syntax_healing
|
|
114
|
+
self.enable_modernizer = opts.enable_modernizer
|
|
115
|
+
self.enable_dead_code_pruning = opts.enable_dead_code_pruning
|
|
85
116
|
self.enable_import_resolution = opts.enable_import_resolution
|
|
86
117
|
self.enable_lint_fixing = opts.enable_lint_fixing
|
|
87
118
|
self.enable_formatting = opts.enable_formatting
|
|
119
|
+
self.verify_proofs = opts.verify_proofs
|
|
120
|
+
self.proof_iterations = opts.proof_iterations
|
|
121
|
+
self.proof_seed = opts.proof_seed
|
|
122
|
+
self.enable_cache = opts.enable_cache
|
|
123
|
+
self.cache = (
|
|
124
|
+
ContentAddressableCache(opts.cache_db_path) if opts.enable_cache else None
|
|
125
|
+
)
|
|
88
126
|
self.config = config
|
|
127
|
+
self.verifier = IsolatedDifferentialVerifier(
|
|
128
|
+
iterations=self.proof_iterations,
|
|
129
|
+
base_seed=self.proof_seed,
|
|
130
|
+
)
|
|
89
131
|
|
|
90
132
|
import_map = dict(opts.custom_import_map or {})
|
|
91
133
|
custom_map = getattr(config, "custom_import_map", None)
|
|
@@ -93,6 +135,8 @@ class CleanPipeline:
|
|
|
93
135
|
import_map.update(custom_map)
|
|
94
136
|
|
|
95
137
|
self.syntax_healer = SyntaxHealer()
|
|
138
|
+
self.modernizer = Modernizer()
|
|
139
|
+
self.dead_code_fixer = DeadCodeFixer()
|
|
96
140
|
self.import_resolver = ImportResolver(custom_import_map=import_map)
|
|
97
141
|
self.linter_formatter = LinterFormatter()
|
|
98
142
|
|
|
@@ -118,6 +162,32 @@ class CleanPipeline:
|
|
|
118
162
|
)
|
|
119
163
|
return current_code, None
|
|
120
164
|
|
|
165
|
+
def _stage_modernize(
|
|
166
|
+
self, current_code: str, filename: str, modernize_transforms: list[str]
|
|
167
|
+
) -> str:
|
|
168
|
+
"""Execute Stage 2: Code Modernization."""
|
|
169
|
+
if not self.enable_modernizer:
|
|
170
|
+
return current_code
|
|
171
|
+
|
|
172
|
+
mod_res = self.modernizer.modernize(current_code, filename=filename)
|
|
173
|
+
if mod_res.changed:
|
|
174
|
+
modernize_transforms.extend(mod_res.transformations)
|
|
175
|
+
return mod_res.code
|
|
176
|
+
return current_code
|
|
177
|
+
|
|
178
|
+
def _stage_dead_code(
|
|
179
|
+
self, current_code: str, filename: str, dead_code_pruned: list[str]
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Execute Stage 3: Dead Code Pruning."""
|
|
182
|
+
if not self.enable_dead_code_pruning:
|
|
183
|
+
return current_code
|
|
184
|
+
|
|
185
|
+
dc_res = self.dead_code_fixer.fix(current_code, filename=filename)
|
|
186
|
+
if dc_res.changed:
|
|
187
|
+
dead_code_pruned.extend(dc_res.pruned_items)
|
|
188
|
+
return dc_res.code
|
|
189
|
+
return current_code
|
|
190
|
+
|
|
121
191
|
def _stage_resolve_imports(
|
|
122
192
|
self,
|
|
123
193
|
current_code: str,
|
|
@@ -126,7 +196,7 @@ class CleanPipeline:
|
|
|
126
196
|
unresolved_symbols: list[str],
|
|
127
197
|
diagnostics: list[dict[str, str]],
|
|
128
198
|
) -> str:
|
|
129
|
-
"""Execute Stage
|
|
199
|
+
"""Execute Stage 4: Missing Import Resolution."""
|
|
130
200
|
if self.enable_import_resolution:
|
|
131
201
|
import_res = self.import_resolver.resolve(current_code, filename=filename)
|
|
132
202
|
if import_res.resolved_imports:
|
|
@@ -149,7 +219,7 @@ class CleanPipeline:
|
|
|
149
219
|
def _stage_lint_format(
|
|
150
220
|
self, current_code: str, filename: str
|
|
151
221
|
) -> tuple[str, bool, bool]:
|
|
152
|
-
"""Execute Stage
|
|
222
|
+
"""Execute Stage 5: Lint Auto-fixing and Formatting."""
|
|
153
223
|
if not (self.enable_lint_fixing or self.enable_formatting):
|
|
154
224
|
return current_code, False, False
|
|
155
225
|
|
|
@@ -176,6 +246,8 @@ class CleanPipeline:
|
|
|
176
246
|
"""Process in-memory Python source code through the pipeline."""
|
|
177
247
|
current_code = source
|
|
178
248
|
syntax_repairs: list[str] = []
|
|
249
|
+
modernize_transforms: list[str] = []
|
|
250
|
+
dead_code_pruned: list[str] = []
|
|
179
251
|
resolved_imports: list[str] = []
|
|
180
252
|
unresolved_symbols: list[str] = []
|
|
181
253
|
lint_changed = False
|
|
@@ -190,6 +262,13 @@ class CleanPipeline:
|
|
|
190
262
|
)
|
|
191
263
|
if error_msg is not None:
|
|
192
264
|
break
|
|
265
|
+
|
|
266
|
+
current_code = self._stage_modernize(
|
|
267
|
+
current_code, filename, modernize_transforms
|
|
268
|
+
)
|
|
269
|
+
current_code = self._stage_dead_code(
|
|
270
|
+
current_code, filename, dead_code_pruned
|
|
271
|
+
)
|
|
193
272
|
current_code = self._stage_resolve_imports(
|
|
194
273
|
current_code,
|
|
195
274
|
filename,
|
|
@@ -204,39 +283,143 @@ class CleanPipeline:
|
|
|
204
283
|
break
|
|
205
284
|
|
|
206
285
|
is_valid, final_error = self._validate_syntax(current_code, filename, error_msg)
|
|
286
|
+
|
|
287
|
+
proof_receipts: list[ProofReceipt] = []
|
|
288
|
+
refused_changes: list[CounterExample] = []
|
|
289
|
+
verification_tier: VerificationTier | None = None
|
|
290
|
+
|
|
291
|
+
if self.verify_proofs and current_code != source and is_valid:
|
|
292
|
+
target_callables = self._find_modified_callables(source, current_code)
|
|
293
|
+
if not target_callables:
|
|
294
|
+
verification_tier = VerificationTier.TIER_B_SUGGESTED
|
|
295
|
+
else:
|
|
296
|
+
all_proven = True
|
|
297
|
+
for func_name in target_callables:
|
|
298
|
+
receipt = self.verifier.verify_transformation(
|
|
299
|
+
filepath=filename,
|
|
300
|
+
original_source=source,
|
|
301
|
+
transformed_source=current_code,
|
|
302
|
+
target_callable=func_name,
|
|
303
|
+
)
|
|
304
|
+
proof_receipts.append(receipt)
|
|
305
|
+
if receipt.tier == VerificationTier.TIER_C_REFUSED:
|
|
306
|
+
all_proven = False
|
|
307
|
+
if receipt.counterexample:
|
|
308
|
+
refused_changes.append(receipt.counterexample)
|
|
309
|
+
elif receipt.tier != VerificationTier.TIER_A_PROVEN:
|
|
310
|
+
all_proven = False
|
|
311
|
+
|
|
312
|
+
if refused_changes:
|
|
313
|
+
verification_tier = VerificationTier.TIER_C_REFUSED
|
|
314
|
+
# Absolute rollback guarantee on Tier C refusal
|
|
315
|
+
current_code = source
|
|
316
|
+
elif all_proven and proof_receipts:
|
|
317
|
+
verification_tier = VerificationTier.TIER_A_PROVEN
|
|
318
|
+
else:
|
|
319
|
+
verification_tier = VerificationTier.TIER_B_SUGGESTED
|
|
320
|
+
|
|
207
321
|
return CleanResult(
|
|
208
322
|
path=Path(filename),
|
|
209
323
|
original_code=source,
|
|
210
324
|
cleaned_code=current_code,
|
|
211
|
-
changed=current_code != source
|
|
325
|
+
changed=(current_code != source)
|
|
326
|
+
and (verification_tier != VerificationTier.TIER_C_REFUSED),
|
|
212
327
|
is_valid_python=is_valid,
|
|
213
328
|
syntax_repairs=syntax_repairs,
|
|
329
|
+
modernize_transforms=modernize_transforms,
|
|
330
|
+
dead_code_pruned=dead_code_pruned,
|
|
214
331
|
resolved_imports=resolved_imports,
|
|
215
332
|
unresolved_symbols=unresolved_symbols,
|
|
216
333
|
lint_changed=lint_changed,
|
|
217
334
|
format_changed=format_changed,
|
|
218
335
|
error=final_error,
|
|
219
336
|
diagnostics=diagnostics,
|
|
337
|
+
proof_receipts=proof_receipts,
|
|
338
|
+
refused_changes=refused_changes,
|
|
339
|
+
verification_tier=verification_tier,
|
|
220
340
|
)
|
|
221
341
|
|
|
342
|
+
@staticmethod
|
|
343
|
+
def _find_modified_callables(orig_code: str, clean_code: str) -> list[str]:
|
|
344
|
+
"""Identify functions/methods whose AST has changed."""
|
|
345
|
+
try:
|
|
346
|
+
orig_tree = ast.parse(orig_code)
|
|
347
|
+
clean_tree = ast.parse(clean_code)
|
|
348
|
+
except SyntaxError:
|
|
349
|
+
return []
|
|
350
|
+
|
|
351
|
+
orig_funcs: dict[str, str] = {}
|
|
352
|
+
for node in ast.walk(orig_tree):
|
|
353
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
354
|
+
orig_funcs[node.name] = ast.dump(node)
|
|
355
|
+
|
|
356
|
+
modified: list[str] = []
|
|
357
|
+
clean_funcs: list[str] = []
|
|
358
|
+
for node in ast.walk(clean_tree):
|
|
359
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
360
|
+
clean_funcs.append(node.name)
|
|
361
|
+
clean_dump = ast.dump(node)
|
|
362
|
+
if node.name not in orig_funcs or orig_funcs[node.name] != clean_dump:
|
|
363
|
+
if node.name not in modified:
|
|
364
|
+
modified.append(node.name)
|
|
365
|
+
|
|
366
|
+
if not modified and clean_funcs:
|
|
367
|
+
return clean_funcs[:3]
|
|
368
|
+
|
|
369
|
+
return modified
|
|
370
|
+
|
|
222
371
|
def process_file(
|
|
223
372
|
self,
|
|
224
373
|
filepath: Path | str,
|
|
225
374
|
apply_changes: bool = True,
|
|
226
375
|
backup: bool = False,
|
|
227
376
|
) -> CleanResult:
|
|
228
|
-
"""Process a single file on disk and optionally write back updates.
|
|
229
|
-
|
|
230
|
-
Args:
|
|
231
|
-
filepath: Path to the Python file to process.
|
|
232
|
-
apply_changes: If True, write cleaned code back to disk.
|
|
233
|
-
backup: If True, create a .pycleaner.bak file before overwriting.
|
|
234
|
-
"""
|
|
377
|
+
"""Process a single file on disk and optionally write back updates."""
|
|
235
378
|
path = Path(filepath).resolve()
|
|
236
379
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
380
|
+
|
|
381
|
+
if self.cache is not None:
|
|
382
|
+
cached_result = self.cache.get(path, content)
|
|
383
|
+
if cached_result is not None:
|
|
384
|
+
if (
|
|
385
|
+
apply_changes
|
|
386
|
+
and cached_result.changed
|
|
387
|
+
and cached_result.is_valid_python
|
|
388
|
+
and (
|
|
389
|
+
cached_result.verification_tier
|
|
390
|
+
!= VerificationTier.TIER_C_REFUSED
|
|
391
|
+
)
|
|
392
|
+
):
|
|
393
|
+
if backup:
|
|
394
|
+
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")
|
|
397
|
+
return cached_result
|
|
398
|
+
|
|
237
399
|
result = self.process_source(content, filename=str(path))
|
|
238
400
|
|
|
239
|
-
if
|
|
401
|
+
if self.cache is not None:
|
|
402
|
+
self.cache.set(path, content, result)
|
|
403
|
+
if result.changed and apply_changes:
|
|
404
|
+
tier_label = (
|
|
405
|
+
result.verification_tier.value
|
|
406
|
+
if result.verification_tier
|
|
407
|
+
else "UNVERIFIED"
|
|
408
|
+
)
|
|
409
|
+
self.cache.record_provenance(
|
|
410
|
+
file_path=path,
|
|
411
|
+
transformation_type="clean",
|
|
412
|
+
verification_tier=tier_label,
|
|
413
|
+
seed=self.proof_seed,
|
|
414
|
+
diff=result.diff,
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
if (
|
|
418
|
+
apply_changes
|
|
419
|
+
and result.changed
|
|
420
|
+
and result.is_valid_python
|
|
421
|
+
and (result.verification_tier != VerificationTier.TIER_C_REFUSED)
|
|
422
|
+
):
|
|
240
423
|
if backup:
|
|
241
424
|
bak_path = path.with_name(path.name + ".pycleaner.bak")
|
|
242
425
|
shutil.copy2(path, bak_path)
|
|
@@ -251,14 +434,7 @@ class CleanPipeline:
|
|
|
251
434
|
backup: bool = False,
|
|
252
435
|
max_workers: int | None = None,
|
|
253
436
|
) -> list[CleanResult]:
|
|
254
|
-
"""Process multiple files, optionally in parallel.
|
|
255
|
-
|
|
256
|
-
Args:
|
|
257
|
-
filepaths: List of Python file paths.
|
|
258
|
-
apply_changes: Write cleaned code back to disk.
|
|
259
|
-
backup: Create .pycleaner.bak before overwriting.
|
|
260
|
-
max_workers: Max parallel workers. None = sequential. 1+ = parallel.
|
|
261
|
-
"""
|
|
437
|
+
"""Process multiple files, optionally in parallel."""
|
|
262
438
|
if max_workers is not None and max_workers > 1 and len(filepaths) > 1:
|
|
263
439
|
return self._process_parallel(filepaths, apply_changes, backup, max_workers)
|
|
264
440
|
return [
|
|
@@ -307,6 +483,8 @@ class CleanPipeline:
|
|
|
307
483
|
apply_changes=apply_changes,
|
|
308
484
|
backup=backup,
|
|
309
485
|
enable_syntax=self.enable_syntax_healing,
|
|
486
|
+
enable_modernize=self.enable_modernizer,
|
|
487
|
+
enable_dead_code=self.enable_dead_code_pruning,
|
|
310
488
|
enable_imports=self.enable_import_resolution,
|
|
311
489
|
enable_lint=self.enable_lint_fixing,
|
|
312
490
|
enable_format=self.enable_formatting,
|
|
@@ -329,20 +507,30 @@ class _StandaloneWorkerTask:
|
|
|
329
507
|
apply_changes: bool
|
|
330
508
|
backup: bool
|
|
331
509
|
enable_syntax: bool
|
|
510
|
+
enable_modernize: bool
|
|
511
|
+
enable_dead_code: bool
|
|
332
512
|
enable_imports: bool
|
|
333
513
|
enable_lint: bool
|
|
334
514
|
enable_format: bool
|
|
335
515
|
custom_import_map: dict[str, str] | None = None
|
|
516
|
+
verify_proofs: bool = False
|
|
517
|
+
proof_iterations: int = 50
|
|
518
|
+
proof_seed: int | None = None
|
|
336
519
|
|
|
337
520
|
|
|
338
521
|
def _process_file_standalone(task: _StandaloneWorkerTask) -> CleanResult:
|
|
339
522
|
"""Standalone function for ProcessPoolExecutor (must be module-level and picklable)."""
|
|
340
523
|
pipeline = CleanPipeline(
|
|
341
524
|
enable_syntax_healing=task.enable_syntax,
|
|
525
|
+
enable_modernizer=task.enable_modernize,
|
|
526
|
+
enable_dead_code_pruning=task.enable_dead_code,
|
|
342
527
|
enable_import_resolution=task.enable_imports,
|
|
343
528
|
enable_lint_fixing=task.enable_lint,
|
|
344
529
|
enable_formatting=task.enable_format,
|
|
345
530
|
custom_import_map=task.custom_import_map,
|
|
531
|
+
verify_proofs=task.verify_proofs,
|
|
532
|
+
proof_iterations=task.proof_iterations,
|
|
533
|
+
proof_seed=task.proof_seed,
|
|
346
534
|
)
|
|
347
535
|
return pipeline.process_file(
|
|
348
536
|
task.filepath, apply_changes=task.apply_changes, backup=task.backup
|
pycleaner/py.typed
ADDED
|
File without changes
|
pycleaner/security_scanner.py
CHANGED
|
@@ -9,11 +9,13 @@ and assert statements used for input validation.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import ast
|
|
12
|
-
import os
|
|
13
12
|
import re
|
|
13
|
+
from collections.abc import Sequence
|
|
14
14
|
from dataclasses import dataclass, field
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from pycleaner.discovery import collect_project_python_files
|
|
18
|
+
|
|
17
19
|
|
|
18
20
|
@dataclass(slots=True)
|
|
19
21
|
class SecurityFinding:
|
|
@@ -194,6 +196,40 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
194
196
|
return self.source_lines[lineno - 1].strip()
|
|
195
197
|
return ""
|
|
196
198
|
|
|
199
|
+
def _is_suppressed(
|
|
200
|
+
self, lineno: int, category: str, end_lineno: int | None = None
|
|
201
|
+
) -> bool:
|
|
202
|
+
start = max(1, lineno)
|
|
203
|
+
end = max(start, end_lineno or lineno)
|
|
204
|
+
max_line = len(self.source_lines)
|
|
205
|
+
for ln in range(start, min(end + 1, max_line + 1)):
|
|
206
|
+
line = self.source_lines[ln - 1]
|
|
207
|
+
if "#" in line:
|
|
208
|
+
comment = line.split("#", 1)[1].strip().lower()
|
|
209
|
+
if (
|
|
210
|
+
"nosec" in comment
|
|
211
|
+
or "noqa" in comment
|
|
212
|
+
or "pycleaner: ignore" in comment
|
|
213
|
+
or f"ignore[{category.lower()}]" in comment
|
|
214
|
+
):
|
|
215
|
+
return True
|
|
216
|
+
if start > 1:
|
|
217
|
+
prev_line = self.source_lines[start - 2]
|
|
218
|
+
if prev_line.strip().startswith("#"):
|
|
219
|
+
prev_comment = prev_line.strip().lstrip("#").strip().lower()
|
|
220
|
+
if (
|
|
221
|
+
"nosec" in prev_comment
|
|
222
|
+
or "noqa" in prev_comment
|
|
223
|
+
or "pycleaner: ignore" in prev_comment
|
|
224
|
+
or f"ignore[{category.lower()}]" in prev_comment
|
|
225
|
+
):
|
|
226
|
+
return True
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def _add_finding(self, finding: SecurityFinding) -> None:
|
|
230
|
+
if not self._is_suppressed(finding.lineno, finding.category, finding.end_lineno):
|
|
231
|
+
self.findings.append(finding)
|
|
232
|
+
|
|
197
233
|
def visit_Call(self, node: ast.Call) -> None:
|
|
198
234
|
func_name = self._get_call_name(node)
|
|
199
235
|
if func_name:
|
|
@@ -212,7 +248,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
212
248
|
def visit_Assert(self, node: ast.Assert) -> None:
|
|
213
249
|
if self._is_test_path(self.filepath):
|
|
214
250
|
return
|
|
215
|
-
self.
|
|
251
|
+
self._add_finding(
|
|
216
252
|
SecurityFinding(
|
|
217
253
|
filepath=self.filepath,
|
|
218
254
|
lineno=node.lineno,
|
|
@@ -250,7 +286,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
250
286
|
spec = _DANGEROUS_CALL_SPECS.get(func_name)
|
|
251
287
|
if spec:
|
|
252
288
|
severity, category, msg, suggestion = spec
|
|
253
|
-
self.
|
|
289
|
+
self._add_finding(
|
|
254
290
|
SecurityFinding(
|
|
255
291
|
filepath=self.filepath,
|
|
256
292
|
lineno=node.lineno,
|
|
@@ -280,7 +316,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
280
316
|
and isinstance(kw.value, ast.Constant)
|
|
281
317
|
and kw.value.value is True
|
|
282
318
|
):
|
|
283
|
-
self.
|
|
319
|
+
self._add_finding(
|
|
284
320
|
SecurityFinding(
|
|
285
321
|
filepath=self.filepath,
|
|
286
322
|
lineno=node.lineno,
|
|
@@ -319,7 +355,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
319
355
|
and isinstance(kw.value, ast.Constant)
|
|
320
356
|
and kw.value.value is False
|
|
321
357
|
):
|
|
322
|
-
self.
|
|
358
|
+
self._add_finding(
|
|
323
359
|
SecurityFinding(
|
|
324
360
|
filepath=self.filepath,
|
|
325
361
|
lineno=node.lineno,
|
|
@@ -340,7 +376,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
340
376
|
and isinstance(kw.value, ast.Constant)
|
|
341
377
|
and kw.value.value is True
|
|
342
378
|
):
|
|
343
|
-
self.
|
|
379
|
+
self._add_finding(
|
|
344
380
|
SecurityFinding(
|
|
345
381
|
filepath=self.filepath,
|
|
346
382
|
lineno=node.lineno,
|
|
@@ -373,7 +409,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
373
409
|
):
|
|
374
410
|
has_safe_loader = True
|
|
375
411
|
if not has_safe_loader and func_name == "yaml.load":
|
|
376
|
-
self.
|
|
412
|
+
self._add_finding(
|
|
377
413
|
SecurityFinding(
|
|
378
414
|
filepath=self.filepath,
|
|
379
415
|
lineno=node.lineno,
|
|
@@ -407,7 +443,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
407
443
|
return
|
|
408
444
|
|
|
409
445
|
if self._is_dynamic_sql_arg(node.args[0]):
|
|
410
|
-
self.
|
|
446
|
+
self._add_finding(
|
|
411
447
|
SecurityFinding(
|
|
412
448
|
filepath=self.filepath,
|
|
413
449
|
lineno=node.lineno,
|
|
@@ -424,23 +460,6 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
424
460
|
class SecurityScanner:
|
|
425
461
|
"""Scans Python source code for security vulnerabilities."""
|
|
426
462
|
|
|
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
463
|
def __init__(
|
|
445
464
|
self,
|
|
446
465
|
severity_threshold: str = "LOW",
|
|
@@ -479,21 +498,19 @@ class SecurityScanner:
|
|
|
479
498
|
|
|
480
499
|
return SecurityReport(findings=filtered, files_scanned=1)
|
|
481
500
|
|
|
482
|
-
def _discover_project_py_files(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
py_files.append(Path(current_root) / fname)
|
|
491
|
-
return py_files
|
|
492
|
-
|
|
493
|
-
def scan_project(self, root_dir: Path | str) -> SecurityReport:
|
|
501
|
+
def _discover_project_py_files(
|
|
502
|
+
self, root: Path, exclude_patterns: Sequence[str] = ()
|
|
503
|
+
) -> list[Path]:
|
|
504
|
+
return collect_project_python_files(root, exclude_patterns=exclude_patterns)
|
|
505
|
+
|
|
506
|
+
def scan_project(
|
|
507
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
508
|
+
) -> SecurityReport:
|
|
494
509
|
"""Scan all Python files in a project for security issues."""
|
|
495
510
|
root = Path(root_dir).resolve()
|
|
496
|
-
py_files = self._discover_project_py_files(
|
|
511
|
+
py_files = self._discover_project_py_files(
|
|
512
|
+
root, exclude_patterns=exclude_patterns
|
|
513
|
+
)
|
|
497
514
|
all_findings: list[SecurityFinding] = []
|
|
498
515
|
|
|
499
516
|
for fpath in py_files:
|
|
@@ -532,6 +549,15 @@ class SecurityScanner:
|
|
|
532
549
|
filename, stripped.lower()
|
|
533
550
|
):
|
|
534
551
|
continue
|
|
552
|
+
if "#" in line:
|
|
553
|
+
comment = line.split("#", 1)[1].strip().lower()
|
|
554
|
+
if (
|
|
555
|
+
"nosec" in comment
|
|
556
|
+
or "noqa" in comment
|
|
557
|
+
or "pycleaner: ignore" in comment
|
|
558
|
+
or "ignore[hardcoded-secret]" in comment
|
|
559
|
+
):
|
|
560
|
+
continue
|
|
535
561
|
|
|
536
562
|
for name, pattern, suggestion in _SECRET_PATTERNS:
|
|
537
563
|
if pattern.search(line):
|