pisces-cli 0.1.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.
pisces/scanner.py ADDED
@@ -0,0 +1,677 @@
1
+ """
2
+ pisces.scanner
3
+ ~~~~~~~~~~~~~~
4
+ Deterministic security rule engine.
5
+
6
+ Rules:
7
+ S001 HARDCODED_SECRET — string literals assigned to secret-named vars
8
+ S002 COMMAND_INJECTION — shell=True with string formatting/concatenation
9
+ S003 UNSAFE_SUBPROCESS — any subprocess usage with shell=True
10
+ S004 UNSAFE_DESERIALIZATION — pickle/marshal/yaml.load without safe loader
11
+ S005 TLS_VERIFY_DISABLED — requests/httpx calls with verify=False
12
+ S006 CODE_INJECTION — eval()/exec() with non-literal arguments
13
+ S007 DANGEROUS_FILESYSTEM — shutil.rmtree / os.remove with variable paths
14
+
15
+ Design rules:
16
+ - No LLM calls. Ever. This is pure static analysis.
17
+ - AST-based checks preferred over regex.
18
+ - Regex fallback when AST parse fails.
19
+ - Every finding carries: id, rule, severity, file, line, code,
20
+ description, impact, remediation.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import ast
26
+ import re
27
+ from pathlib import Path
28
+
29
+ from pisces.models import Finding, Severity
30
+
31
+ # ─────────────────────────────────────────────────────────────
32
+ # Rule metadata registry
33
+ # ─────────────────────────────────────────────────────────────
34
+
35
+ RULE_META: dict[str, dict] = {
36
+ "S001_HARDCODED_SECRET": {
37
+ "severity": Severity.HIGH,
38
+ "description": "Hardcoded secret or credential in source code",
39
+ "impact": "Credentials exposed in version control or logs",
40
+ "remediation": (
41
+ "Move secrets to environment variables. "
42
+ "Use os.environ.get('MY_SECRET') or a secrets manager."
43
+ ),
44
+ },
45
+ "S002_COMMAND_INJECTION": {
46
+ "severity": Severity.CRITICAL,
47
+ "description": "Potential command injection via shell=True with dynamic input",
48
+ "impact": "Attacker-controlled input may execute arbitrary OS commands",
49
+ "remediation": (
50
+ "Use shell=False (default) and pass arguments as a list: "
51
+ "subprocess.run(['cmd', arg], shell=False). "
52
+ "Never pass unsanitised user input to shell commands."
53
+ ),
54
+ },
55
+ "S003_UNSAFE_SUBPROCESS": {
56
+ "severity": Severity.HIGH,
57
+ "description": "subprocess call with shell=True",
58
+ "impact": "shell=True expands shell metacharacters — use only with trusted, static strings",
59
+ "remediation": (
60
+ "Replace shell=True with a list of arguments: "
61
+ "subprocess.run(['cmd', '--flag', value], shell=False)"
62
+ ),
63
+ },
64
+ "S004_UNSAFE_DESERIALIZATION": {
65
+ "severity": Severity.HIGH,
66
+ "description": "Unsafe deserialization of untrusted data",
67
+ "impact": "Maliciously crafted data can execute arbitrary code during unpickling",
68
+ "remediation": (
69
+ "Never unpickle data from untrusted sources. "
70
+ "Use JSON/msgpack for serialisation. "
71
+ "For yaml.load, use yaml.safe_load(stream) instead."
72
+ ),
73
+ },
74
+ "S005_TLS_VERIFY_DISABLED": {
75
+ "severity": Severity.MEDIUM,
76
+ "description": "TLS certificate verification disabled (verify=False)",
77
+ "impact": "Disabling TLS verification allows man-in-the-middle attacks",
78
+ "remediation": (
79
+ "Remove verify=False. If using a self-signed cert, "
80
+ "pass verify='/path/to/ca-bundle.crt' instead."
81
+ ),
82
+ },
83
+ "S006_CODE_INJECTION": {
84
+ "severity": Severity.CRITICAL,
85
+ "description": "eval() or exec() called with non-literal (potentially dynamic) argument",
86
+ "impact": "Dynamic code execution — arbitrary Python can be injected and executed",
87
+ "remediation": (
88
+ "Avoid eval()/exec() entirely. "
89
+ "Use ast.literal_eval() for safe constant parsing, "
90
+ "or restructure logic to remove dynamic execution."
91
+ ),
92
+ },
93
+ "S007_DANGEROUS_FILESYSTEM": {
94
+ "severity": Severity.MEDIUM,
95
+ "description": "Potentially dangerous filesystem operation with variable path",
96
+ "impact": "Path traversal or unintended deletion of files/directories",
97
+ "remediation": (
98
+ "Validate and sanitise paths before use. "
99
+ "Use pathlib.Path.resolve() and check the result is within expected root. "
100
+ "Prefer specific targeted operations over recursive deletes."
101
+ ),
102
+ },
103
+ }
104
+
105
+
106
+ # ─────────────────────────────────────────────────────────────
107
+ # Helper utilities
108
+ # ─────────────────────────────────────────────────────────────
109
+
110
+
111
+ def _make_finding(
112
+ rule_id: str,
113
+ file: str,
114
+ line: int,
115
+ code: str,
116
+ override_severity: Severity | None = None,
117
+ ) -> Finding:
118
+ meta = RULE_META[rule_id]
119
+ return Finding(
120
+ rule=rule_id,
121
+ severity=override_severity or meta["severity"],
122
+ file=file,
123
+ line=line,
124
+ code=code.strip()[:200],
125
+ description=meta["description"],
126
+ impact=meta["impact"],
127
+ remediation=meta["remediation"],
128
+ )
129
+
130
+
131
+ def _source_line(source_lines: list[str], lineno: int) -> str:
132
+ """Return the source line for a 1-indexed line number."""
133
+ if 1 <= lineno <= len(source_lines):
134
+ return source_lines[lineno - 1].strip()
135
+ return ""
136
+
137
+
138
+ def _is_literal(node: ast.AST) -> bool:
139
+ """Return True if *node* is a compile-time constant (string/number/bytes)."""
140
+ return isinstance(node, ast.Constant)
141
+
142
+
143
+ def _is_dynamic(node: ast.AST) -> bool:
144
+ """Return True if *node* is not a pure literal (i.e. has runtime dependencies)."""
145
+ return not _is_literal(node)
146
+
147
+
148
+ # ─────────────────────────────────────────────────────────────
149
+ # Rule: S001 — Hardcoded Secrets
150
+ # ─────────────────────────────────────────────────────────────
151
+
152
+ _SECRET_VAR_NAMES = re.compile(
153
+ r"(password|passwd|secret|api_key|apikey|token|auth_token|"
154
+ r"access_token|private_key|secret_key|db_pass|database_pass|"
155
+ r"credentials|credential|auth_secret|client_secret)",
156
+ re.IGNORECASE,
157
+ )
158
+
159
+ # Regex for finding secret-like assignments when AST parse fails
160
+ _SECRET_REGEX = re.compile(
161
+ r"""^\s*(?P<var>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<quote>['"])(?P<val>.{3,}?)(?P=quote)""",
162
+ re.MULTILINE,
163
+ )
164
+
165
+ # High-entropy string heuristic (skip very short or obviously placeholder values)
166
+ _PLACEHOLDER_VALUES = {
167
+ "changeme",
168
+ "password",
169
+ "secret",
170
+ "example",
171
+ "test",
172
+ "your_key_here",
173
+ "xxx",
174
+ "yyy",
175
+ "zzz",
176
+ "<your-key>",
177
+ "",
178
+ "placeholder",
179
+ "todo",
180
+ "fill-me-in",
181
+ }
182
+
183
+
184
+ def _looks_like_real_secret(value: str) -> bool:
185
+ """Heuristic: is this string plausibly a real credential?"""
186
+ v = value.strip()
187
+ if len(v) < 4:
188
+ return False
189
+ # Real secrets tend to have mixed chars or be long.
190
+ return v.lower() not in _PLACEHOLDER_VALUES
191
+
192
+
193
+ def check_hardcoded_secrets_ast(
194
+ tree: ast.AST, source_lines: list[str], rel_path: str
195
+ ) -> list[Finding]:
196
+ findings: list[Finding] = []
197
+
198
+ for node in ast.walk(tree):
199
+ # Pattern: VAR = "string_literal"
200
+ if not isinstance(node, ast.Assign):
201
+ continue
202
+ if not (isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)):
203
+ continue
204
+ value = node.value.value
205
+ if not _looks_like_real_secret(value):
206
+ continue
207
+ for target in node.targets:
208
+ if not isinstance(target, ast.Name):
209
+ continue
210
+ if _SECRET_VAR_NAMES.search(target.id):
211
+ findings.append(
212
+ _make_finding(
213
+ "S001_HARDCODED_SECRET",
214
+ rel_path,
215
+ node.lineno,
216
+ _source_line(source_lines, node.lineno),
217
+ )
218
+ )
219
+ return findings
220
+
221
+
222
+ def check_hardcoded_secrets_regex(source: str, rel_path: str) -> list[Finding]:
223
+ findings: list[Finding] = []
224
+ for m in _SECRET_REGEX.finditer(source):
225
+ var = m.group("var")
226
+ value = m.group("val")
227
+ line = source[: m.start()].count("\n") + 1
228
+ if _SECRET_VAR_NAMES.search(var) and _looks_like_real_secret(value):
229
+ findings.append(
230
+ _make_finding(
231
+ "S001_HARDCODED_SECRET",
232
+ rel_path,
233
+ line,
234
+ m.group(0).strip()[:200],
235
+ )
236
+ )
237
+ return findings
238
+
239
+
240
+ # ─────────────────────────────────────────────────────────────
241
+ # Rule: S002/S003 — Command Injection / Unsafe Subprocess
242
+ # ─────────────────────────────────────────────────────────────
243
+
244
+ _SUBPROCESS_FUNCS = {
245
+ "subprocess.run",
246
+ "subprocess.call",
247
+ "subprocess.Popen",
248
+ "subprocess.check_output",
249
+ "subprocess.check_call",
250
+ }
251
+
252
+
253
+ def _has_shell_true(call_node: ast.Call) -> bool:
254
+ """Return True if the call has shell=True keyword argument."""
255
+ for kw in call_node.keywords:
256
+ if kw.arg == "shell":
257
+ if isinstance(kw.value, ast.Constant) and kw.value.value is True:
258
+ return True
259
+ # shell=True via variable — treat as potentially True
260
+ if isinstance(kw.value, ast.Name):
261
+ return True
262
+ return False
263
+
264
+
265
+ def _first_arg_is_dynamic(call_node: ast.Call) -> bool:
266
+ """Return True if the first arg is not a static string literal."""
267
+ if not call_node.args:
268
+ return False
269
+ arg0 = call_node.args[0]
270
+ # f-string, BinOp (string concat), Name (variable), etc.
271
+ if isinstance(arg0, ast.JoinedStr): # f"..."
272
+ return True
273
+ if isinstance(arg0, ast.BinOp): # "cmd " + var
274
+ return True
275
+ if isinstance(arg0, ast.Name): # variable
276
+ return True
277
+ return isinstance(arg0, ast.Call) # format()/join()
278
+
279
+
280
+ def check_subprocess_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
281
+ findings: list[Finding] = []
282
+
283
+ for node in ast.walk(tree):
284
+ if not isinstance(node, ast.Call):
285
+ continue
286
+
287
+ # Resolve function name
288
+ func = node.func
289
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
290
+ fname = f"{func.value.id}.{func.attr}"
291
+ elif isinstance(func, ast.Name):
292
+ fname = func.id
293
+ else:
294
+ continue
295
+
296
+ if fname not in _SUBPROCESS_FUNCS:
297
+ # Also catch os.system / os.popen
298
+ if fname in ("os.system", "os.popen"):
299
+ code = _source_line(source_lines, node.lineno)
300
+ # os.system is always shell=True effectively
301
+ if node.args and _is_dynamic(node.args[0]):
302
+ findings.append(
303
+ _make_finding("S002_COMMAND_INJECTION", rel_path, node.lineno, code)
304
+ )
305
+ else:
306
+ findings.append(
307
+ _make_finding("S003_UNSAFE_SUBPROCESS", rel_path, node.lineno, code)
308
+ )
309
+ continue
310
+
311
+ if not _has_shell_true(node):
312
+ continue
313
+
314
+ code = _source_line(source_lines, node.lineno)
315
+
316
+ if _first_arg_is_dynamic(node):
317
+ # shell=True + dynamic first arg = command injection risk
318
+ findings.append(_make_finding("S002_COMMAND_INJECTION", rel_path, node.lineno, code))
319
+ else:
320
+ # shell=True with static string — still bad practice
321
+ findings.append(_make_finding("S003_UNSAFE_SUBPROCESS", rel_path, node.lineno, code))
322
+
323
+ return findings
324
+
325
+
326
+ _SUBPROCESS_REGEX = re.compile(r"shell\s*=\s*True", re.IGNORECASE)
327
+ _OS_SYSTEM_REGEX = re.compile(r"os\.(system|popen)\s*\(")
328
+
329
+
330
+ def check_subprocess_regex(source: str, rel_path: str) -> list[Finding]:
331
+ findings: list[Finding] = []
332
+ lines = source.splitlines()
333
+ for i, line in enumerate(lines, 1):
334
+ stripped = line.strip()
335
+ if stripped.startswith("#"):
336
+ continue
337
+ if _SUBPROCESS_REGEX.search(line):
338
+ findings.append(_make_finding("S003_UNSAFE_SUBPROCESS", rel_path, i, stripped))
339
+ elif _OS_SYSTEM_REGEX.search(line):
340
+ findings.append(_make_finding("S002_COMMAND_INJECTION", rel_path, i, stripped))
341
+ return findings
342
+
343
+
344
+ # ─────────────────────────────────────────────────────────────
345
+ # Rule: S004 — Unsafe Deserialization
346
+ # ─────────────────────────────────────────────────────────────
347
+
348
+ _DESER_CALL_NAMES = {
349
+ "pickle.load",
350
+ "pickle.loads",
351
+ "pickle.Unpickler",
352
+ "marshal.load",
353
+ "marshal.loads",
354
+ "shelve.open",
355
+ }
356
+ _YAML_UNSAFE = {"yaml.load"}
357
+
358
+
359
+ def check_deserialization_ast(
360
+ tree: ast.AST, source_lines: list[str], rel_path: str
361
+ ) -> list[Finding]:
362
+ findings: list[Finding] = []
363
+
364
+ for node in ast.walk(tree):
365
+ if not isinstance(node, ast.Call):
366
+ continue
367
+ func = node.func
368
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
369
+ fname = f"{func.value.id}.{func.attr}"
370
+ elif isinstance(func, ast.Name):
371
+ fname = func.id
372
+ else:
373
+ continue
374
+
375
+ if fname in _DESER_CALL_NAMES:
376
+ findings.append(
377
+ _make_finding(
378
+ "S004_UNSAFE_DESERIALIZATION",
379
+ rel_path,
380
+ node.lineno,
381
+ _source_line(source_lines, node.lineno),
382
+ )
383
+ )
384
+
385
+ # yaml.load without Loader=yaml.SafeLoader
386
+ if fname in _YAML_UNSAFE:
387
+ has_safe_loader = any(
388
+ kw.arg == "Loader"
389
+ and (
390
+ (isinstance(kw.value, ast.Attribute) and "Safe" in (kw.value.attr or ""))
391
+ or (isinstance(kw.value, ast.Name) and "Safe" in kw.value.id)
392
+ )
393
+ for kw in node.keywords
394
+ )
395
+ if not has_safe_loader:
396
+ findings.append(
397
+ _make_finding(
398
+ "S004_UNSAFE_DESERIALIZATION",
399
+ rel_path,
400
+ node.lineno,
401
+ _source_line(source_lines, node.lineno),
402
+ override_severity=Severity.HIGH,
403
+ )
404
+ )
405
+
406
+ return findings
407
+
408
+
409
+ _DESER_REGEX = re.compile(
410
+ r"(pickle\.(load|loads|Unpickler)|marshal\.load|yaml\.load\s*\((?![^)]*SafeLoader))"
411
+ )
412
+
413
+
414
+ def check_deserialization_regex(source: str, rel_path: str) -> list[Finding]:
415
+ findings: list[Finding] = []
416
+ lines = source.splitlines()
417
+ for i, line in enumerate(lines, 1):
418
+ stripped = line.strip()
419
+ if stripped.startswith("#"):
420
+ continue
421
+ if _DESER_REGEX.search(line):
422
+ findings.append(_make_finding("S004_UNSAFE_DESERIALIZATION", rel_path, i, stripped))
423
+ return findings
424
+
425
+
426
+ # ─────────────────────────────────────────────────────────────
427
+ # Rule: S005 — TLS Verification Disabled
428
+ # ─────────────────────────────────────────────────────────────
429
+
430
+
431
+ def check_tls_verify_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
432
+ findings: list[Finding] = []
433
+
434
+ for node in ast.walk(tree):
435
+ if not isinstance(node, ast.Call):
436
+ continue
437
+ for kw in node.keywords:
438
+ if (
439
+ kw.arg == "verify"
440
+ and isinstance(kw.value, ast.Constant)
441
+ and kw.value.value is False
442
+ ):
443
+ findings.append(
444
+ _make_finding(
445
+ "S005_TLS_VERIFY_DISABLED",
446
+ rel_path,
447
+ node.lineno,
448
+ _source_line(source_lines, node.lineno),
449
+ )
450
+ )
451
+ break
452
+
453
+ # Also check ssl._create_unverified_context
454
+ for node in ast.walk(tree):
455
+ if not isinstance(node, ast.Call):
456
+ continue
457
+ func = node.func
458
+ name = ""
459
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
460
+ name = f"{func.value.id}.{func.attr}"
461
+ elif isinstance(func, ast.Name):
462
+ name = func.id
463
+ if "unverified" in name.lower() or "no_verify" in name.lower():
464
+ findings.append(
465
+ _make_finding(
466
+ "S005_TLS_VERIFY_DISABLED",
467
+ rel_path,
468
+ node.lineno,
469
+ _source_line(source_lines, node.lineno),
470
+ )
471
+ )
472
+
473
+ return findings
474
+
475
+
476
+ _TLS_REGEX = re.compile(r"verify\s*=\s*False|_create_unverified_context|CERT_NONE")
477
+
478
+
479
+ def check_tls_verify_regex(source: str, rel_path: str) -> list[Finding]:
480
+ findings: list[Finding] = []
481
+ lines = source.splitlines()
482
+ for i, line in enumerate(lines, 1):
483
+ stripped = line.strip()
484
+ if stripped.startswith("#"):
485
+ continue
486
+ if _TLS_REGEX.search(line):
487
+ findings.append(_make_finding("S005_TLS_VERIFY_DISABLED", rel_path, i, stripped))
488
+ return findings
489
+
490
+
491
+ # ─────────────────────────────────────────────────────────────
492
+ # Rule: S006 — Code Injection (eval/exec)
493
+ # ─────────────────────────────────────────────────────────────
494
+
495
+
496
+ def check_code_injection_ast(
497
+ tree: ast.AST, source_lines: list[str], rel_path: str
498
+ ) -> list[Finding]:
499
+ findings: list[Finding] = []
500
+
501
+ for node in ast.walk(tree):
502
+ if not isinstance(node, ast.Call):
503
+ continue
504
+ func = node.func
505
+ if not (isinstance(func, ast.Name) and func.id in ("eval", "exec")):
506
+ continue
507
+ # eval/exec with a non-literal first arg → code injection risk
508
+ if node.args and _is_dynamic(node.args[0]):
509
+ findings.append(
510
+ _make_finding(
511
+ "S006_CODE_INJECTION",
512
+ rel_path,
513
+ node.lineno,
514
+ _source_line(source_lines, node.lineno),
515
+ )
516
+ )
517
+ elif not node.args:
518
+ # eval() with no args (unusual but flag for review)
519
+ pass
520
+
521
+ return findings
522
+
523
+
524
+ _EVAL_REGEX = re.compile(r"\beval\s*\(|\bexec\s*\(")
525
+
526
+
527
+ def check_code_injection_regex(source: str, rel_path: str) -> list[Finding]:
528
+ findings: list[Finding] = []
529
+ lines = source.splitlines()
530
+ for i, line in enumerate(lines, 1):
531
+ stripped = line.strip()
532
+ if stripped.startswith("#"):
533
+ continue
534
+ if _EVAL_REGEX.search(line):
535
+ findings.append(_make_finding("S006_CODE_INJECTION", rel_path, i, stripped))
536
+ return findings
537
+
538
+
539
+ # ─────────────────────────────────────────────────────────────
540
+ # Rule: S007 — Dangerous Filesystem Operations
541
+ # ─────────────────────────────────────────────────────────────
542
+
543
+ _FS_DANGER_FUNCS = {
544
+ "shutil.rmtree",
545
+ "os.remove",
546
+ "os.unlink",
547
+ "os.rmdir",
548
+ "shutil.move",
549
+ "os.rename",
550
+ }
551
+
552
+
553
+ def check_dangerous_fs_ast(tree: ast.AST, source_lines: list[str], rel_path: str) -> list[Finding]:
554
+ findings: list[Finding] = []
555
+
556
+ for node in ast.walk(tree):
557
+ if not isinstance(node, ast.Call):
558
+ continue
559
+ func = node.func
560
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
561
+ fname = f"{func.value.id}.{func.attr}"
562
+ else:
563
+ continue
564
+
565
+ if fname not in _FS_DANGER_FUNCS:
566
+ continue
567
+
568
+ # Only flag if the path argument is dynamic (variable, not literal)
569
+ if node.args and _is_dynamic(node.args[0]):
570
+ findings.append(
571
+ _make_finding(
572
+ "S007_DANGEROUS_FILESYSTEM",
573
+ rel_path,
574
+ node.lineno,
575
+ _source_line(source_lines, node.lineno),
576
+ )
577
+ )
578
+
579
+ return findings
580
+
581
+
582
+ _FS_REGEX = re.compile(r"shutil\.(rmtree|move)|os\.(remove|unlink|rmdir|rename)\s*\(")
583
+
584
+
585
+ def check_dangerous_fs_regex(source: str, rel_path: str) -> list[Finding]:
586
+ findings: list[Finding] = []
587
+ lines = source.splitlines()
588
+ for i, line in enumerate(lines, 1):
589
+ stripped = line.strip()
590
+ if stripped.startswith("#"):
591
+ continue
592
+ if _FS_REGEX.search(line):
593
+ findings.append(_make_finding("S007_DANGEROUS_FILESYSTEM", rel_path, i, stripped))
594
+ return findings
595
+
596
+
597
+ # ─────────────────────────────────────────────────────────────
598
+ # Master Scanner
599
+ # ─────────────────────────────────────────────────────────────
600
+
601
+
602
+ class Scanner:
603
+ """
604
+ Runs all deterministic security rules against a Python source file.
605
+
606
+ Usage:
607
+ scanner = Scanner()
608
+ findings = scanner.scan_file(path, project_root)
609
+ """
610
+
611
+ def scan_file(
612
+ self,
613
+ file_path: Path,
614
+ project_root: Path,
615
+ source: str | None = None,
616
+ ) -> list[Finding]:
617
+ """
618
+ Scan a single .py file and return all findings.
619
+
620
+ *source* can be passed to avoid re-reading from disk (e.g. in watch mode).
621
+ """
622
+ if source is None:
623
+ try:
624
+ source = file_path.read_text(encoding="utf-8", errors="replace")
625
+ except OSError:
626
+ return []
627
+
628
+ rel_path = str(file_path.relative_to(project_root))
629
+ source_lines = source.splitlines()
630
+
631
+ # Try AST parse
632
+ try:
633
+ tree: ast.AST | None = ast.parse(source, filename=rel_path)
634
+ parse_ok = True
635
+ except SyntaxError:
636
+ tree = None
637
+ parse_ok = False
638
+
639
+ findings: list[Finding] = []
640
+
641
+ if parse_ok and tree is not None:
642
+ findings += check_hardcoded_secrets_ast(tree, source_lines, rel_path)
643
+ findings += check_subprocess_ast(tree, source_lines, rel_path)
644
+ findings += check_deserialization_ast(tree, source_lines, rel_path)
645
+ findings += check_tls_verify_ast(tree, source_lines, rel_path)
646
+ findings += check_code_injection_ast(tree, source_lines, rel_path)
647
+ findings += check_dangerous_fs_ast(tree, source_lines, rel_path)
648
+ else:
649
+ # Regex fallback for files with syntax errors
650
+ findings += check_hardcoded_secrets_regex(source, rel_path)
651
+ findings += check_subprocess_regex(source, rel_path)
652
+ findings += check_deserialization_regex(source, rel_path)
653
+ findings += check_tls_verify_regex(source, rel_path)
654
+ findings += check_code_injection_regex(source, rel_path)
655
+ findings += check_dangerous_fs_regex(source, rel_path)
656
+
657
+ # Deduplicate by (rule, file, line) — multiple passes may overlap
658
+ seen: set[tuple] = set()
659
+ deduped: list[Finding] = []
660
+ for f in findings:
661
+ key = (f.rule, f.file, f.line)
662
+ if key not in seen:
663
+ seen.add(key)
664
+ deduped.append(f)
665
+
666
+ return deduped
667
+
668
+ def scan_project(
669
+ self,
670
+ project_root: Path,
671
+ py_files: list[Path],
672
+ ) -> list[Finding]:
673
+ """Scan all *py_files* and return combined findings."""
674
+ all_findings: list[Finding] = []
675
+ for f in py_files:
676
+ all_findings.extend(self.scan_file(f, project_root))
677
+ return all_findings