skylos 2.2.3__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.3"
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,6 +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.danger.danger import scan_ctx as scan_danger
12
13
  import os
13
14
  import traceback
14
15
  from skylos.visitors.framework_aware import FrameworkAwareVisitor, detect_framework_usage
@@ -238,7 +239,7 @@ class Skylos:
238
239
  if method.simple_name == "format" and cls.endswith("Formatter"):
239
240
  method.references += 1
240
241
 
241
- def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False):
242
+ def analyze(self, path, thr=60, exclude_folders= None, enable_secrets = False, enable_danger = False):
242
243
  files, root = self._get_python_files(path, exclude_folders)
243
244
 
244
245
  if not files:
@@ -262,6 +263,7 @@ class Skylos:
262
263
  modmap[f] = self._module(root, f)
263
264
 
264
265
  all_secrets = []
266
+ all_dangers = []
265
267
  for file in files:
266
268
  mod = modmap[file]
267
269
  defs, refs, dyn, exports, test_flags, framework_flags = proc_file(file, mod)
@@ -276,13 +278,25 @@ class Skylos:
276
278
 
277
279
  if enable_secrets and _secrets_scan_ctx is not None:
278
280
  try:
279
- src_lines = Path(file).read_text(encoding="utf-8", errors="ignore").splitlines(True)
280
- ctx = {"relpath": str(file), "lines": src_lines, "tree": None}
281
+ src = Path(file).read_text(encoding="utf-8", errors="ignore")
282
+ src_lines = src.splitlines(True)
283
+ rel = str(Path(file).relative_to(root))
284
+ ctx = {"relpath": rel, "lines": src_lines, "tree": None}
281
285
  findings = list(_secrets_scan_ctx(ctx))
282
286
  if findings:
283
287
  all_secrets.extend(findings)
284
288
  except Exception:
285
289
  pass
290
+
291
+ if enable_danger and scan_danger is not None:
292
+ try:
293
+ findings = scan_danger(root, [file])
294
+ if findings:
295
+ all_dangers.extend(findings)
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())
286
300
 
287
301
  self._mark_refs()
288
302
  self._apply_heuristics()
@@ -296,7 +310,6 @@ class Skylos:
296
310
  for d in sorted(self.defs.values(), key=def_sort_key):
297
311
  if shown >= 50:
298
312
  break
299
- print(f" type={d.type} refs={d.references} conf={d.confidence} exported={d.is_exported} line={d.line} name={d.name}")
300
313
  shown += 1
301
314
 
302
315
  unused = []
@@ -318,7 +331,12 @@ class Skylos:
318
331
 
319
332
  if enable_secrets and all_secrets:
320
333
  result["secrets"] = all_secrets
334
+ result["analysis_summary"]["secrets_count"] = len(all_secrets)
321
335
 
336
+ if enable_danger and all_dangers:
337
+ result["danger"] = all_dangers
338
+ result["analysis_summary"]["danger_count"] = len(all_dangers)
339
+
322
340
  for u in unused:
323
341
  if u["type"] in ("function", "method"):
324
342
  result["unused_functions"].append(u)
@@ -370,70 +388,104 @@ def proc_file(file_or_args, mod=None):
370
388
 
371
389
  return [], [], set(), set(), dummy_visitor, dummy_framework_visitor
372
390
 
373
- def analyze(path, conf=60, exclude_folders=None, enable_secrets=False):
374
- return Skylos().analyze(path,conf, exclude_folders, enable_secrets)
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)
375
393
 
376
394
  if __name__ == "__main__":
377
- if len(sys.argv)>1:
378
- p = sys.argv[1]
379
- confidence = int(sys.argv[2]) if len(sys.argv) >2 else 60
380
- result = analyze(p,confidence)
381
-
382
- data = json.loads(result)
383
- print("\n Python Static Analysis Results")
384
- print("===================================\n")
385
-
386
- total_items = 0
387
- for key, items in data.items():
388
- if key.startswith("unused_") and isinstance(items, list):
389
- total_items += len(items)
390
-
391
- print("Summary:")
392
- if data["unused_functions"]:
393
- print(f" * Unreachable functions: {len(data['unused_functions'])}")
394
- if data["unused_imports"]:
395
- print(f" * Unused imports: {len(data['unused_imports'])}")
396
- if data["unused_classes"]:
397
- print(f" * Unused classes: {len(data['unused_classes'])}")
398
- if data["unused_variables"]:
399
- print(f" * Unused variables: {len(data['unused_variables'])}")
400
-
401
- if data["unused_functions"]:
402
- print("\n - Unreachable Functions")
403
- print("=======================")
404
- for i, func in enumerate(data["unused_functions"], 1):
405
- print(f" {i}. {func['name']}")
406
- print(f" └─ {func['file']}:{func['line']}")
407
-
408
- if data["unused_imports"]:
409
- print("\n - Unused Imports")
410
- print("================")
411
- for i, imp in enumerate(data["unused_imports"], 1):
412
- print(f" {i}. {imp['simple_name']}")
413
- print(f" └─ {imp['file']}:{imp['line']}")
414
-
415
- if data["unused_classes"]:
416
- print("\n - Unused Classes")
417
- print("=================")
418
- for i, cls in enumerate(data["unused_classes"], 1):
419
- print(f" {i}. {cls['name']}")
420
- print(f" └─ {cls['file']}:{cls['line']}")
421
-
422
- if data["unused_variables"]:
423
- print("\n - Unused Variables")
424
- print("==================")
425
- for i, var in enumerate(data["unused_variables"], 1):
426
- print(f" {i}. {var['name']}")
427
- print(f" └─ {var['file']}:{var['line']}")
428
-
429
- print("\n" + "─" * 50)
430
- print(f"Found {total_items} dead code items. Add this badge to your README:")
431
- print(f"```markdown")
432
- print(f"![Dead Code: {total_items}](https://img.shields.io/badge/Dead_Code-{total_items}_detected-orange?logo=codacy&logoColor=red)")
433
- print(f"```")
395
+ enable_secrets = ("--secrets" in sys.argv)
396
+ enable_danger = ("--danger" in sys.argv)
397
+
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)
434
408
 
435
- print("\nNext steps:")
436
- print(" * Use --interactive to select specific items to remove")
437
- 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:")
438
483
  else:
439
- 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
@@ -43,7 +43,7 @@ def setup_logger(output_file=None):
43
43
 
44
44
  formatter = CleanFormatter()
45
45
 
46
- console_handler = logging.StreamHandler(sys.stdout)
46
+ console_handler = logging.StreamHandler(sys.stderr)
47
47
  console_handler.setFormatter(formatter)
48
48
  logger.addHandler(console_handler)
49
49
 
@@ -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",
@@ -270,6 +282,9 @@ def main():
270
282
  parser.add_argument("--secrets", action="store_true",
271
283
  help="Scan for API keys. Off by default.")
272
284
 
285
+ parser.add_argument("--danger", action="store_true",
286
+ help="Scan for security issues. Off by default.")
287
+
273
288
  args = parser.parse_args()
274
289
 
275
290
  if args.list_default_excludes:
@@ -304,16 +319,143 @@ def main():
304
319
  logger.info(f"{Colors.GREEN}📁 No folders excluded{Colors.RESET}")
305
320
 
306
321
  try:
307
- 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))
324
+
325
+ if args.json:
326
+ print(result_json)
327
+ return
328
+
308
329
  result = json.loads(result_json)
309
330
 
310
331
  except Exception as e:
311
332
  logger.error(f"Error during analysis: {e}")
312
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
313
449
 
314
450
  if args.json:
315
- logger.info(result_json)
451
+ lg = logging.getLogger('skylos')
452
+ for h in list(lg.handlers):
453
+ if isinstance(h, logging.StreamHandler):
454
+ lg.removeHandler(h)
455
+ print(result_json)
316
456
  return
457
+
458
+ result = json.loads(result_json)
317
459
 
318
460
  unused_functions = result.get("unused_functions", [])
319
461
  unused_imports = result.get("unused_imports", [])
@@ -321,6 +463,7 @@ def main():
321
463
  unused_variables = result.get("unused_variables", [])
322
464
  unused_classes = result.get("unused_classes", [])
323
465
  secrets_findings = result.get("secrets", [])
466
+ danger_findings = result.get("danger", [])
324
467
 
325
468
  logger.info(f"{Colors.CYAN}{Colors.BOLD} Python Static Analysis Results{Colors.RESET}")
326
469
  logger.info(f"{Colors.CYAN}{'=' * 35}{Colors.RESET}")
@@ -333,6 +476,8 @@ def main():
333
476
  logger.info(f" * Unused classes: {Colors.YELLOW}{len(unused_classes)}{Colors.RESET}")
334
477
  if secrets_findings:
335
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}")
336
481
 
337
482
  if args.interactive and (unused_functions or unused_imports):
338
483
  logger.info(f"\n{Colors.BOLD}Interactive Mode:{Colors.RESET}")
@@ -463,10 +608,27 @@ def main():
463
608
  prev = s.get("preview", "****")
464
609
  msg = s.get("message", "Secret detected")
465
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}")
466
622
 
467
623
  dead_code_count = len(unused_functions) + len(unused_imports) + len(unused_variables) + len(unused_classes) + len(unused_parameters)
468
624
 
469
- 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
+ )
470
632
 
471
633
  if unused_functions or unused_imports:
472
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