skylos 2.2.4__py3-none-any.whl → 2.4.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.

Potentially problematic release.


This version of skylos might be problematic. Click here for more details.

skylos/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- __version__ = "2.2.4"
1
+ __version__ = "2.4.0"
2
2
 
3
3
  def analyze(*args, **kwargs):
4
4
  from .analyzer import analyze as _analyze
skylos/analyzer.py CHANGED
@@ -9,7 +9,7 @@ from skylos.visitor import Visitor
9
9
  from skylos.constants import ( PENALTIES, AUTO_CALLED )
10
10
  from skylos.visitors.test_aware import TestAwareVisitor
11
11
  from skylos.rules.secrets import scan_ctx as _secrets_scan_ctx
12
- from skylos.rules.dangerous import scan_ctx as scan_dangerous
12
+ from skylos.rules.danger.danger import scan_ctx as scan_danger
13
13
  import os
14
14
  import traceback
15
15
  from skylos.visitors.framework_aware import FrameworkAwareVisitor, detect_framework_usage
@@ -239,7 +239,7 @@ class Skylos:
239
239
  if method.simple_name == "format" and cls.endswith("Formatter"):
240
240
  method.references += 1
241
241
 
242
- def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False, enable_dangerous = False):
242
+ def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False, enable_danger = False):
243
243
  files, root = self._get_python_files(path, exclude_folders)
244
244
 
245
245
  if not files:
@@ -288,13 +288,15 @@ class Skylos:
288
288
  except Exception:
289
289
  pass
290
290
 
291
- if enable_dangerous and scan_dangerous is not None:
291
+ if enable_danger and scan_danger is not None:
292
292
  try:
293
- findings = scan_dangerous(root, [file])
293
+ findings = scan_danger(root, [file])
294
294
  if findings:
295
295
  all_dangers.extend(findings)
296
- except Exception:
297
- pass
296
+ except Exception as e:
297
+ logger.error(f"Error scanning {file} for dangerous code: {e}")
298
+ if os.getenv("SKYLOS_DEBUG"):
299
+ logger.error(traceback.format_exc())
298
300
 
299
301
  self._mark_refs()
300
302
  self._apply_heuristics()
@@ -331,9 +333,9 @@ class Skylos:
331
333
  result["secrets"] = all_secrets
332
334
  result["analysis_summary"]["secrets_count"] = len(all_secrets)
333
335
 
334
- if enable_dangerous and all_dangers:
335
- result["dangerous"] = all_dangers
336
- result["analysis_summary"]["dangerous_count"] = len(all_dangers)
336
+ if enable_danger and all_dangers:
337
+ result["danger"] = all_dangers
338
+ result["analysis_summary"]["danger_count"] = len(all_dangers)
337
339
 
338
340
  for u in unused:
339
341
  if u["type"] in ("function", "method"):
@@ -386,75 +388,104 @@ def proc_file(file_or_args, mod=None):
386
388
 
387
389
  return [], [], set(), set(), dummy_visitor, dummy_framework_visitor
388
390
 
389
- def analyze(path, conf=60, exclude_folders=None, enable_secrets=False, enable_dangerous=False):
390
- return Skylos().analyze(path,conf, exclude_folders, enable_secrets, enable_dangerous)
391
+ def analyze(path, conf=60, exclude_folders=None, enable_secrets=False, enable_danger=False):
392
+ return Skylos().analyze(path,conf, exclude_folders, enable_secrets, enable_danger)
391
393
 
392
394
  if __name__ == "__main__":
393
- if len(sys.argv)>1:
394
- p = sys.argv[1]
395
-
396
- if len(sys.argv) > 2:
397
- confidence = int(sys.argv[2])
398
- else:
399
- confidence = 60
395
+ enable_secrets = ("--secrets" in sys.argv)
396
+ enable_danger = ("--danger" in sys.argv)
400
397
 
401
- result = analyze(p,confidence)
402
-
403
- data = json.loads(result)
404
- print("\n Python Static Analysis Results")
405
- print("===================================\n")
406
-
407
- total_items = 0
408
- for key, items in data.items():
409
- if key.startswith("unused_") and isinstance(items, list):
410
- total_items += len(items)
411
-
412
- print("Summary:")
413
- if data["unused_functions"]:
414
- print(f" * Unreachable functions: {len(data['unused_functions'])}")
415
- if data["unused_imports"]:
416
- print(f" * Unused imports: {len(data['unused_imports'])}")
417
- if data["unused_classes"]:
418
- print(f" * Unused classes: {len(data['unused_classes'])}")
419
- if data["unused_variables"]:
420
- print(f" * Unused variables: {len(data['unused_variables'])}")
421
-
422
- if data["unused_functions"]:
423
- print("\n - Unreachable Functions")
424
- print("=======================")
425
- for i, func in enumerate(data["unused_functions"], 1):
426
- print(f" {i}. {func['name']}")
427
- print(f" └─ {func['file']}:{func['line']}")
428
-
429
- if data["unused_imports"]:
430
- print("\n - Unused Imports")
431
- print("================")
432
- for i, imp in enumerate(data["unused_imports"], 1):
433
- print(f" {i}. {imp['simple_name']}")
434
- print(f" └─ {imp['file']}:{imp['line']}")
435
-
436
- if data["unused_classes"]:
437
- print("\n - Unused Classes")
438
- print("=================")
439
- for i, cls in enumerate(data["unused_classes"], 1):
440
- print(f" {i}. {cls['name']}")
441
- print(f" └─ {cls['file']}:{cls['line']}")
442
-
443
- if data["unused_variables"]:
444
- print("\n - Unused Variables")
445
- print("==================")
446
- for i, var in enumerate(data["unused_variables"], 1):
447
- print(f" {i}. {var['name']}")
448
- print(f" └─ {var['file']}:{var['line']}")
449
-
450
- print("\n" + "─" * 50)
451
- print(f"Found {total_items} dead code items. Add this badge to your README:")
452
- print(f"```markdown")
453
- print(f"![Dead Code: {total_items}](https://img.shields.io/badge/Dead_Code-{total_items}_detected-orange?logo=codacy&logoColor=red)")
454
- print(f"```")
398
+ positional = [a for a in sys.argv[1:] if not a.startswith("--")]
399
+
400
+ if not positional:
401
+ print("Usage: python Skylos.py <path> [confidence_threshold] [--secrets] [--danger]")
402
+ sys.exit(2)
403
+
404
+ p = positional[0]
405
+ confidence = int(positional[1]) if len(positional) > 1 else 60
406
+
407
+ result = analyze(p, confidence, enable_secrets=enable_secrets, enable_danger=enable_danger)
455
408
 
456
- print("\nNext steps:")
457
- print(" * Use --interactive to select specific items to remove")
458
- print(" * Use --dry-run to preview changes before applying them")
409
+ data = json.loads(result)
410
+ print("\n Python Static Analysis Results")
411
+ print("===================================\n")
412
+
413
+ total_dead = 0
414
+ for key, items in data.items():
415
+ if key.startswith("unused_") and isinstance(items, list):
416
+ total_dead += len(items)
417
+
418
+ danger_count = data.get("analysis_summary", {}).get("danger_count", 0) if enable_danger else 0
419
+ secrets_count = data.get("analysis_summary", {}).get("secrets_count", 0) if enable_secrets else 0
420
+
421
+ print("Summary:")
422
+ if data["unused_functions"]:
423
+ print(f" * Unreachable functions: {len(data['unused_functions'])}")
424
+ if data["unused_imports"]:
425
+ print(f" * Unused imports: {len(data['unused_imports'])}")
426
+ if data["unused_classes"]:
427
+ print(f" * Unused classes: {len(data['unused_classes'])}")
428
+ if data["unused_variables"]:
429
+ print(f" * Unused variables: {len(data['unused_variables'])}")
430
+ if enable_danger:
431
+ print(f" * Security issues: {danger_count}")
432
+ if enable_secrets:
433
+ print(f" * Secrets found: {secrets_count}")
434
+
435
+ if data["unused_functions"]:
436
+ print("\n - Unreachable Functions")
437
+ print("=======================")
438
+ for i, func in enumerate(data["unused_functions"], 1):
439
+ print(f" {i}. {func['name']}")
440
+ print(f" └─ {func['file']}:{func['line']}")
441
+
442
+ if data["unused_imports"]:
443
+ print("\n - Unused Imports")
444
+ print("================")
445
+ for i, imp in enumerate(data["unused_imports"], 1):
446
+ print(f" {i}. {imp['simple_name']}")
447
+ print(f" └─ {imp['file']}:{imp['line']}")
448
+
449
+ if data["unused_classes"]:
450
+ print("\n - Unused Classes")
451
+ print("=================")
452
+ for i, cls in enumerate(data["unused_classes"], 1):
453
+ print(f" {i}. {cls['name']}")
454
+ print(f" └─ {cls['file']}:{cls['line']}")
455
+
456
+ if data["unused_variables"]:
457
+ print("\n - Unused Variables")
458
+ print("==================")
459
+ for i, var in enumerate(data["unused_variables"], 1):
460
+ print(f" {i}. {var['name']}")
461
+ print(f" └─ {var['file']}:{var['line']}")
462
+
463
+ if enable_danger and data.get("danger"):
464
+ print("\n - Security Issues")
465
+ print("================")
466
+ for i, f in enumerate(data["danger"], 1):
467
+ print(f" {i}. {f['message']} [{f['rule_id']}] ({f['file']}:{f['line']}) Severity: {f['severity']}")
468
+
469
+ if enable_secrets and data.get("secrets"):
470
+ print("\n - Secrets")
471
+ print("==========")
472
+ for i, s in enumerate(data["secrets"], 1):
473
+ rid = s.get("rule_id", "SECRET")
474
+ msg = s.get("message", "Potential secret")
475
+ file = s.get("file")
476
+ line = s.get("line", 1)
477
+ sev = s.get("severity", "HIGH")
478
+ print(f" {i}. {msg} [{rid}] ({file}:{line}) Severity: {sev}")
479
+
480
+ print("\n" + "─" * 50)
481
+ if enable_danger:
482
+ print(f"Found {total_dead} dead code items and {danger_count} security flaws. Add this badge to your README:")
459
483
  else:
460
- print("Usage: python Skylos.py <path> [confidence_threshold]")
484
+ print(f"Found {total_dead} dead code items. Add this badge to your README:")
485
+ print("```markdown")
486
+ print(f"![Dead Code: {total_dead}](https://img.shields.io/badge/Dead_Code-{total_dead}_detected-orange?logo=codacy&logoColor=red)")
487
+ print("```")
488
+
489
+ print("\nNext steps:")
490
+ print(" * Use --interactive to select specific items to remove")
491
+ print(" * Use --dry-run to preview changes before applying them")
skylos/cli.py CHANGED
@@ -159,19 +159,25 @@ def interactive_selection(logger, unused_functions, unused_imports):
159
159
 
160
160
  return selected_functions, selected_imports
161
161
 
162
- def print_badge(dead_code_count: int, logger):
162
+ def print_badge(dead_code_count, logger, *, danger_enabled = False, danger_count = 0):
163
163
  logger.info(f"\n{Colors.GRAY}{'─' * 50}{Colors.RESET}")
164
164
 
165
- if dead_code_count == 0:
166
- logger.info(f" Your code is 100% dead code free! Add this badge to your README:")
165
+ if dead_code_count == 0 and (not danger_enabled or danger_count == 0):
166
+ logger.info(" Your code is 100% dead code free! Add this badge to your README:")
167
167
  logger.info("```markdown")
168
168
  logger.info("![Dead Code Free](https://img.shields.io/badge/Dead_Code-Free-brightgreen?logo=moleculer&logoColor=white)")
169
169
  logger.info("```")
170
+ return
171
+
172
+ if danger_enabled:
173
+ logger.info(f"Found {dead_code_count} dead code items and {danger_count} security flaws. Add this badge to your README:")
170
174
  else:
171
175
  logger.info(f"Found {dead_code_count} dead code items. Add this badge to your README:")
172
- logger.info("```markdown")
173
- logger.info(f"![Dead Code: {dead_code_count}](https://img.shields.io/badge/Dead_Code-{dead_code_count}_detected-orange?logo=codacy&logoColor=red)")
174
- logger.info("```")
176
+
177
+ logger.info("```markdown")
178
+ logger.info(f"![Dead Code: {dead_code_count}](https://img.shields.io/badge/Dead_Code-{dead_code_count}_detected-orange?logo=codacy&logoColor=red)")
179
+ logger.info("```")
180
+
175
181
 
176
182
  def main():
177
183
  if len(sys.argv) > 1 and sys.argv[1] == 'run':
@@ -188,6 +194,12 @@ def main():
188
194
  )
189
195
  parser.add_argument("path", help="Path to the Python project")
190
196
 
197
+ parser.add_argument(
198
+ "--table",
199
+ action="store_true",
200
+ help="Show findings in table"
201
+ )
202
+
191
203
  parser.add_argument(
192
204
  "--version",
193
205
  action="version",
@@ -307,7 +319,8 @@ def main():
307
319
  logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
308
320
 
309
321
  try:
310
- result_json = run_analyze(args.path, conf=args.confidence, enable_secrets=bool(args.secrets), exclude_folders=list(final_exclude_folders))
322
+ result_json = run_analyze(args.path, conf=args.confidence, enable_secrets=bool(args.secrets),
323
+ enable_danger=bool(args.danger), exclude_folders=list(final_exclude_folders))
311
324
 
312
325
  if args.json:
313
326
  print(result_json)
@@ -318,6 +331,121 @@ def main():
318
331
  except Exception as e:
319
332
  logger.error(f"Error during analysis: {e}")
320
333
  sys.exit(1)
334
+
335
+ if args.table:
336
+ ELLIPSIS = "…"
337
+
338
+ def clip(text, max_length):
339
+ if not text:
340
+ text = ""
341
+
342
+ if len(text) <= max_length:
343
+ return text
344
+
345
+ truncated_end = max(0, max_length - 1)
346
+ return text[:truncated_end] + ELLIPSIS
347
+
348
+ def severity_color(severity):
349
+ severity_upper = (severity or "").upper()
350
+
351
+ if severity_upper in ("HIGH", "CRITICAL"):
352
+ return Colors.RED
353
+ elif severity_upper == "MEDIUM":
354
+ return Colors.YELLOW
355
+ else:
356
+ return Colors.GRAY
357
+
358
+ print(f"\n{Colors.CYAN}{Colors.BOLD}Unused {Colors.RESET}")
359
+ print(f"{Colors.CYAN}{'='*18}{Colors.RESET}")
360
+
361
+ print(f"{Colors.BOLD}{'kind':9} {'name':28} {'where'}{Colors.RESET}")
362
+ print(f"{'-'*9} {'-'*28} {'-'*36}")
363
+
364
+ unused_categories = [
365
+ ("unused_functions", "function"),
366
+ ("unused_imports", "import"),
367
+ ("unused_classes", "class"),
368
+ ("unused_variables", "variable"),
369
+ ("unused_parameters", "parameter"),
370
+ ]
371
+
372
+ for bucket, kind in unused_categories:
373
+ items = result.get(bucket, [])
374
+ for item in items:
375
+ name = item.get("name") or item.get("simple_name") or ""
376
+ file_path = item.get('file', '?')
377
+ line_num = item.get('line', item.get('lineno', '?'))
378
+ where = f"{file_path}:{line_num}"
379
+
380
+ clipped_name = clip(name, 28)
381
+ clipped_where = clip(where, 36)
382
+ print(f"{kind:9} {clipped_name:28} {clipped_where}")
383
+
384
+ secrets = result.get("secrets", []) or []
385
+ if secrets:
386
+ print(f"\n{Colors.RED}{Colors.BOLD}Secrets{Colors.RESET}")
387
+ print(f"{Colors.RED}{'=' * 7}{Colors.RESET}")
388
+ print(f"{Colors.BOLD}{'provider':12} {'message':22} {'preview':24} {'where'}{Colors.RESET}")
389
+ print(f"{'-' * 12} {'-' * 22} {'-' * 24} {'-' * 36}")
390
+
391
+ for secret in secrets[:100]:
392
+ provider = clip(secret.get("provider") or "generic", 12)
393
+ message = clip(secret.get("message") or "Secret detected", 22)
394
+ preview = clip(secret.get("preview") or "****", 24)
395
+
396
+ file_path = secret.get('file', '?')
397
+ line_num = secret.get('line', '?')
398
+ location = f"{file_path}:{line_num}"
399
+ clipped_location = clip(location, 36)
400
+
401
+ print(f"{Colors.MAGENTA}{provider:12}{Colors.RESET} {message:22} {preview:24} {clipped_location}")
402
+
403
+
404
+ security_issues = result.get("danger", [])
405
+ if security_issues:
406
+ print(f"\n{Colors.RED}{Colors.BOLD}Security issues{Colors.RESET}")
407
+ print(f"{Colors.RED}{'=' * 15}{Colors.RESET}")
408
+ print(f"{Colors.BOLD}{'rule_id':10} {'sev':5} {'message':38} {'where'}{Colors.RESET}")
409
+ print(f"{'-' * 10} {'-' * 5} {'-' * 38} {'-' * 36}")
410
+
411
+ for issue in security_issues[:100]:
412
+ rule_id = clip(issue.get("rule_id") or "", 10)
413
+ severity = (issue.get("severity") or "").upper()
414
+ message = clip(issue.get("message") or "", 38)
415
+
416
+ file_path = issue.get('file', '?')
417
+ line_num = issue.get('line', '?')
418
+ location = f"{file_path}:{line_num}"
419
+ clipped_location = clip(location, 36)
420
+
421
+ severity_color_code = severity_color(severity)
422
+ clipped_severity = clip(severity, 5)
423
+
424
+ print(f"{rule_id:10} {severity_color_code}{clipped_severity:5}{Colors.RESET} {message:38} {clipped_location}")
425
+
426
+ summ = result.get("analysis_summary", {})
427
+ unused_keys = [
428
+ "unused_functions",
429
+ "unused_imports",
430
+ "unused_classes",
431
+ "unused_variables",
432
+ "unused_parameters"
433
+ ]
434
+
435
+ total_unused = 0
436
+ for k in unused_keys:
437
+ total_unused += len(result.get(k, []))
438
+
439
+ print(f"\n{Colors.BOLD}Summary{Colors.RESET}")
440
+
441
+ print("=======")
442
+ print(f"files analyzed : {summ.get('total_files','?')}")
443
+ print(f"unused items : {total_unused}")
444
+ if "secrets_count" in summ:
445
+ print(f"secrets : {summ['secrets_count']}")
446
+ if "danger_count" in summ:
447
+ print(f"security issues: {summ['danger_count']}")
448
+ return
321
449
 
322
450
  if args.json:
323
451
  lg = logging.getLogger('skylos')
@@ -335,6 +463,7 @@ def main():
335
463
  unused_variables = result.get("unused_variables", [])
336
464
  unused_classes = result.get("unused_classes", [])
337
465
  secrets_findings = result.get("secrets", [])
466
+ danger_findings = result.get("danger", [])
338
467
 
339
468
  logger.info(f"{Colors.CYAN}{Colors.BOLD} Python Static Analysis Results{Colors.RESET}")
340
469
  logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
@@ -347,6 +476,8 @@ def main():
347
476
  logger.info(f" * Unused classes: {Colors.YELLOW}{len(unused_classes)}{Colors.RESET}")
348
477
  if secrets_findings:
349
478
  logger.info(f" * Secrets: {Colors.RED}{len(secrets_findings)}{Colors.RESET}")
479
+ if danger_findings:
480
+ logger.info(f" * Security issues: {Colors.RED}{len(danger_findings)}{Colors.RESET}")
350
481
 
351
482
  if args.interactive and (unused_functions or unused_imports):
352
483
  logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
@@ -477,10 +608,27 @@ def main():
477
608
  prev = s.get("preview", "****")
478
609
  msg = s.get("message", "Secret detected")
479
610
  logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{msg} [{provider}] {Colors.GRAY}({where}){Colors.RESET} -> {prev}")
611
+
612
+ if danger_findings:
613
+ logger.info(f"\n{Colors.RED}{Colors.BOLD} - Security Issues{Colors.RESET}")
614
+ logger.info(f"{Colors.RED}{'=' * 16}{Colors.RESET}")
615
+ for i, d in enumerate(danger_findings[:20], 1):
616
+ rule_id = d.get("rule_id", "unknown_rule")
617
+ severity = d.get("severity", "UNKNOWN").upper()
618
+ message = d.get("message", "Issue detected")
619
+ file = d.get("file", "?")
620
+ line = d.get("line", "?")
621
+ logger.info(f"{Colors.GRAY}{i:2d}. {Colors.RESET}{message} [{rule_id}] {Colors.GRAY}({file}:{line}){Colors.RESET} Severity: {severity}")
480
622
 
481
623
  dead_code_count = len(unused_functions) + len(unused_imports) + len(unused_variables) + len(unused_classes) + len(unused_parameters)
482
624
 
483
- print_badge(dead_code_count, logger)
625
+ danger_count = len(danger_findings) if args.danger else 0
626
+ print_badge(
627
+ dead_code_count,
628
+ logger,
629
+ danger_enabled=bool(args.danger),
630
+ danger_count=danger_count,
631
+ )
484
632
 
485
633
  if unused_functions or unused_imports:
486
634
  logger.info(f"\n{Colors.BOLD}Next steps:{Colors.RESET}")
File without changes
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+ import ast
3
+ import sys
4
+ from pathlib import Path
5
+ from .danger_sql.sql_flow import scan as scan_sql
6
+ from .danger_cmd.cmd_flow import scan as scan_cmd
7
+ from .danger_sql.sql_raw_flow import scan as scan_sql_raw
8
+ from .danger_net.ssrf_flow import scan as scan_ssrf
9
+ from .danger_fs.path_flow import scan as scan_path
10
+ from .danger_web.xss_flow import scan as scan_xss
11
+
12
+ ALLOWED_SUFFIXES = (".py", ".pyi", ".pyw")
13
+
14
+ DANGEROUS_CALLS = {
15
+ "eval": ("SKY-D201", "HIGH", "Use of eval()"),
16
+ "exec": ("SKY-D202", "HIGH", "Use of exec()"),
17
+ "pickle.load": ("SKY-D203", "CRITICAL", "Untrusted deserialization via pickle.load"),
18
+ "pickle.loads": ("SKY-D204", "CRITICAL", "Untrusted deserialization via pickle.loads"),
19
+ "yaml.load": ("SKY-D205", "HIGH", "yaml.load without SafeLoader"),
20
+ "hashlib.md5": ("SKY-D206", "MEDIUM", "Weak hash (MD5)"),
21
+ "hashlib.sha1": ("SKY-D207", "MEDIUM", "Weak hash (SHA1)"),
22
+ "requests.*": ("SKY-D208", "HIGH", "requests call with verify=False",
23
+ {"kw_equals": {"verify": False}}),
24
+ }
25
+
26
+ def _matches_rule(name, rule_key):
27
+ if not name:
28
+ return False
29
+ if rule_key.endswith(".*"):
30
+ return name.startswith(rule_key[:-2] + ".")
31
+ return name == rule_key
32
+
33
+ def _kw_equals(node: ast.Call, requirements):
34
+ if not requirements:
35
+ return True
36
+ kw_map = {}
37
+ for kw in (node.keywords or []):
38
+ if kw.arg:
39
+ kw_map[kw.arg] = kw.value
40
+
41
+ for key, expected in requirements.items():
42
+ val = kw_map.get(key)
43
+ if not isinstance(val, ast.Constant):
44
+ return False
45
+ if val.value != expected:
46
+ return False
47
+ return True
48
+
49
+ def qualified_name_from_call(node: ast.Call):
50
+ func = node.func
51
+ parts = []
52
+ while isinstance(func, ast.Attribute):
53
+ parts.append(func.attr)
54
+ func = func.value
55
+ if isinstance(func, ast.Name):
56
+ parts.append(func.id)
57
+ parts.reverse()
58
+ return ".".join(parts)
59
+ return None
60
+
61
+ def _yaml_load_without_safeloader(node: ast.Call):
62
+ name = qualified_name_from_call(node)
63
+ if name != "yaml.load":
64
+ return False
65
+
66
+ for kw in (node.keywords or []):
67
+ if kw.arg == "Loader":
68
+ text = ast.unparse(kw.value)
69
+ return "SafeLoader" not in text
70
+ return True
71
+
72
+ def _add_finding(findings,
73
+ file_path: Path,
74
+ node: ast.AST,
75
+ rule_id,
76
+ severity,
77
+ message):
78
+ findings.append({
79
+ "rule_id": rule_id,
80
+ "severity": severity,
81
+ "message": message,
82
+ "file": str(file_path),
83
+ "line": getattr(node, "lineno", 1),
84
+ "col": getattr(node, "col_offset", 0),
85
+ })
86
+
87
+ def _scan_file(file_path: Path, findings):
88
+ src = file_path.read_text(encoding="utf-8", errors="ignore")
89
+ tree = ast.parse(src)
90
+
91
+ scan_sql(tree, file_path, findings)
92
+ scan_cmd(tree, file_path, findings)
93
+ scan_sql_raw(tree, file_path, findings)
94
+ scan_ssrf(tree, file_path, findings)
95
+ scan_path(tree, file_path, findings)
96
+ scan_xss(tree, file_path, findings)
97
+
98
+ for node in ast.walk(tree):
99
+ if not isinstance(node, ast.Call):
100
+ continue
101
+
102
+ name = qualified_name_from_call(node)
103
+ if not name:
104
+ continue
105
+
106
+ for rule_key, tup in DANGEROUS_CALLS.items():
107
+ rule_id = tup[0]
108
+ severity = tup[1]
109
+ message = tup[2]
110
+
111
+ if len(tup) > 3:
112
+ opts = tup[3]
113
+ else:
114
+ opts = None
115
+
116
+ if not _matches_rule(name, rule_key):
117
+ continue
118
+
119
+ if rule_key == "yaml.load":
120
+ if not _yaml_load_without_safeloader(node):
121
+ continue
122
+ if opts and "kw_equals" in opts:
123
+ if not _kw_equals(node, opts["kw_equals"]):
124
+ continue
125
+
126
+ _add_finding(findings, file_path, node, rule_id, severity, message)
127
+ break
128
+
129
+ def scan_ctx( _, files):
130
+ findings = []
131
+
132
+ for file_path in files:
133
+ if file_path.suffix.lower() not in ALLOWED_SUFFIXES:
134
+ continue
135
+
136
+ try:
137
+ _scan_file(file_path, findings)
138
+ except Exception as e:
139
+ print(f"Scan failed for {file_path}: {e}", file=sys.stderr)
140
+
141
+ return findings
File without changes