ballpython 2.0.0__py3-none-any.whl → 2.0.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/py.typed +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/METADATA +13 -7
- ballpython-2.0.2.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 +59 -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/RECORD +0 -24
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/WHEEL +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.dist-info}/entry_points.txt +0 -0
- {ballpython-2.0.0.dist-info → ballpython-2.0.2.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,35 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
194
196
|
return self.source_lines[lineno - 1].strip()
|
|
195
197
|
return ""
|
|
196
198
|
|
|
199
|
+
def _is_suppressed(self, lineno: int, category: str) -> bool:
|
|
200
|
+
if 1 <= lineno <= len(self.source_lines):
|
|
201
|
+
line = self.source_lines[lineno - 1]
|
|
202
|
+
if "#" in line:
|
|
203
|
+
comment = line.split("#", 1)[1].strip().lower()
|
|
204
|
+
if (
|
|
205
|
+
"nosec" in comment
|
|
206
|
+
or "noqa" in comment
|
|
207
|
+
or "pycleaner: ignore" in comment
|
|
208
|
+
or f"ignore[{category.lower()}]" in comment
|
|
209
|
+
):
|
|
210
|
+
return True
|
|
211
|
+
if lineno > 1:
|
|
212
|
+
prev_line = self.source_lines[lineno - 2]
|
|
213
|
+
if prev_line.strip().startswith("#"):
|
|
214
|
+
prev_comment = prev_line.strip().lstrip("#").strip().lower()
|
|
215
|
+
if (
|
|
216
|
+
"nosec" in prev_comment
|
|
217
|
+
or "noqa" in prev_comment
|
|
218
|
+
or "pycleaner: ignore" in prev_comment
|
|
219
|
+
or f"ignore[{category.lower()}]" in prev_comment
|
|
220
|
+
):
|
|
221
|
+
return True
|
|
222
|
+
return False
|
|
223
|
+
|
|
224
|
+
def _add_finding(self, finding: SecurityFinding) -> None:
|
|
225
|
+
if not self._is_suppressed(finding.lineno, finding.category):
|
|
226
|
+
self.findings.append(finding)
|
|
227
|
+
|
|
197
228
|
def visit_Call(self, node: ast.Call) -> None:
|
|
198
229
|
func_name = self._get_call_name(node)
|
|
199
230
|
if func_name:
|
|
@@ -212,7 +243,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
212
243
|
def visit_Assert(self, node: ast.Assert) -> None:
|
|
213
244
|
if self._is_test_path(self.filepath):
|
|
214
245
|
return
|
|
215
|
-
self.
|
|
246
|
+
self._add_finding(
|
|
216
247
|
SecurityFinding(
|
|
217
248
|
filepath=self.filepath,
|
|
218
249
|
lineno=node.lineno,
|
|
@@ -250,7 +281,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
250
281
|
spec = _DANGEROUS_CALL_SPECS.get(func_name)
|
|
251
282
|
if spec:
|
|
252
283
|
severity, category, msg, suggestion = spec
|
|
253
|
-
self.
|
|
284
|
+
self._add_finding(
|
|
254
285
|
SecurityFinding(
|
|
255
286
|
filepath=self.filepath,
|
|
256
287
|
lineno=node.lineno,
|
|
@@ -280,7 +311,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
280
311
|
and isinstance(kw.value, ast.Constant)
|
|
281
312
|
and kw.value.value is True
|
|
282
313
|
):
|
|
283
|
-
self.
|
|
314
|
+
self._add_finding(
|
|
284
315
|
SecurityFinding(
|
|
285
316
|
filepath=self.filepath,
|
|
286
317
|
lineno=node.lineno,
|
|
@@ -319,7 +350,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
319
350
|
and isinstance(kw.value, ast.Constant)
|
|
320
351
|
and kw.value.value is False
|
|
321
352
|
):
|
|
322
|
-
self.
|
|
353
|
+
self._add_finding(
|
|
323
354
|
SecurityFinding(
|
|
324
355
|
filepath=self.filepath,
|
|
325
356
|
lineno=node.lineno,
|
|
@@ -340,7 +371,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
340
371
|
and isinstance(kw.value, ast.Constant)
|
|
341
372
|
and kw.value.value is True
|
|
342
373
|
):
|
|
343
|
-
self.
|
|
374
|
+
self._add_finding(
|
|
344
375
|
SecurityFinding(
|
|
345
376
|
filepath=self.filepath,
|
|
346
377
|
lineno=node.lineno,
|
|
@@ -373,7 +404,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
373
404
|
):
|
|
374
405
|
has_safe_loader = True
|
|
375
406
|
if not has_safe_loader and func_name == "yaml.load":
|
|
376
|
-
self.
|
|
407
|
+
self._add_finding(
|
|
377
408
|
SecurityFinding(
|
|
378
409
|
filepath=self.filepath,
|
|
379
410
|
lineno=node.lineno,
|
|
@@ -407,7 +438,7 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
407
438
|
return
|
|
408
439
|
|
|
409
440
|
if self._is_dynamic_sql_arg(node.args[0]):
|
|
410
|
-
self.
|
|
441
|
+
self._add_finding(
|
|
411
442
|
SecurityFinding(
|
|
412
443
|
filepath=self.filepath,
|
|
413
444
|
lineno=node.lineno,
|
|
@@ -424,23 +455,6 @@ class _DangerousCallDetector(ast.NodeVisitor):
|
|
|
424
455
|
class SecurityScanner:
|
|
425
456
|
"""Scans Python source code for security vulnerabilities."""
|
|
426
457
|
|
|
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
458
|
def __init__(
|
|
445
459
|
self,
|
|
446
460
|
severity_threshold: str = "LOW",
|
|
@@ -479,21 +493,19 @@ class SecurityScanner:
|
|
|
479
493
|
|
|
480
494
|
return SecurityReport(findings=filtered, files_scanned=1)
|
|
481
495
|
|
|
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:
|
|
496
|
+
def _discover_project_py_files(
|
|
497
|
+
self, root: Path, exclude_patterns: Sequence[str] = ()
|
|
498
|
+
) -> list[Path]:
|
|
499
|
+
return collect_project_python_files(root, exclude_patterns=exclude_patterns)
|
|
500
|
+
|
|
501
|
+
def scan_project(
|
|
502
|
+
self, root_dir: Path | str, exclude_patterns: Sequence[str] = ()
|
|
503
|
+
) -> SecurityReport:
|
|
494
504
|
"""Scan all Python files in a project for security issues."""
|
|
495
505
|
root = Path(root_dir).resolve()
|
|
496
|
-
py_files = self._discover_project_py_files(
|
|
506
|
+
py_files = self._discover_project_py_files(
|
|
507
|
+
root, exclude_patterns=exclude_patterns
|
|
508
|
+
)
|
|
497
509
|
all_findings: list[SecurityFinding] = []
|
|
498
510
|
|
|
499
511
|
for fpath in py_files:
|
|
@@ -532,6 +544,15 @@ class SecurityScanner:
|
|
|
532
544
|
filename, stripped.lower()
|
|
533
545
|
):
|
|
534
546
|
continue
|
|
547
|
+
if "#" in line:
|
|
548
|
+
comment = line.split("#", 1)[1].strip().lower()
|
|
549
|
+
if (
|
|
550
|
+
"nosec" in comment
|
|
551
|
+
or "noqa" in comment
|
|
552
|
+
or "pycleaner: ignore" in comment
|
|
553
|
+
or "ignore[hardcoded-secret]" in comment
|
|
554
|
+
):
|
|
555
|
+
continue
|
|
535
556
|
|
|
536
557
|
for name, pattern, suggestion in _SECRET_PATTERNS:
|
|
537
558
|
if pattern.search(line):
|