skylos 1.0.10__py3-none-any.whl → 2.5.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.
Files changed (53) hide show
  1. skylos/__init__.py +9 -3
  2. skylos/analyzer.py +674 -168
  3. skylos/cfg_visitor.py +60 -0
  4. skylos/cli.py +719 -235
  5. skylos/codemods.py +277 -0
  6. skylos/config.py +50 -0
  7. skylos/constants.py +78 -0
  8. skylos/gatekeeper.py +147 -0
  9. skylos/linter.py +18 -0
  10. skylos/rules/base.py +20 -0
  11. skylos/rules/danger/calls.py +119 -0
  12. skylos/rules/danger/danger.py +157 -0
  13. skylos/rules/danger/danger_cmd/cmd_flow.py +75 -0
  14. skylos/rules/danger/danger_fs/__init__.py +0 -0
  15. skylos/rules/danger/danger_fs/path_flow.py +79 -0
  16. skylos/rules/danger/danger_net/__init__.py +0 -0
  17. skylos/rules/danger/danger_net/ssrf_flow.py +80 -0
  18. skylos/rules/danger/danger_sql/__init__.py +0 -0
  19. skylos/rules/danger/danger_sql/sql_flow.py +245 -0
  20. skylos/rules/danger/danger_sql/sql_raw_flow.py +96 -0
  21. skylos/rules/danger/danger_web/__init__.py +0 -0
  22. skylos/rules/danger/danger_web/xss_flow.py +170 -0
  23. skylos/rules/danger/taint.py +110 -0
  24. skylos/rules/quality/__init__.py +0 -0
  25. skylos/rules/quality/complexity.py +95 -0
  26. skylos/rules/quality/logic.py +96 -0
  27. skylos/rules/quality/nesting.py +101 -0
  28. skylos/rules/quality/structure.py +99 -0
  29. skylos/rules/secrets.py +325 -0
  30. skylos/server.py +554 -0
  31. skylos/visitor.py +502 -90
  32. skylos/visitors/__init__.py +0 -0
  33. skylos/visitors/framework_aware.py +437 -0
  34. skylos/visitors/test_aware.py +74 -0
  35. skylos-2.5.2.dist-info/METADATA +21 -0
  36. skylos-2.5.2.dist-info/RECORD +42 -0
  37. {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/WHEEL +1 -1
  38. {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/top_level.txt +0 -1
  39. skylos-1.0.10.dist-info/METADATA +0 -8
  40. skylos-1.0.10.dist-info/RECORD +0 -21
  41. test/compare_tools.py +0 -604
  42. test/diagnostics.py +0 -364
  43. test/sample_repo/app.py +0 -13
  44. test/sample_repo/sample_repo/commands.py +0 -81
  45. test/sample_repo/sample_repo/models.py +0 -122
  46. test/sample_repo/sample_repo/routes.py +0 -89
  47. test/sample_repo/sample_repo/utils.py +0 -36
  48. test/test_skylos.py +0 -456
  49. test/test_visitor.py +0 -220
  50. {test → skylos/rules}/__init__.py +0 -0
  51. {test/sample_repo → skylos/rules/danger}/__init__.py +0 -0
  52. {test/sample_repo/sample_repo → skylos/rules/danger/danger_cmd}/__init__.py +0 -0
  53. {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,119 @@
1
+ import ast
2
+ from skylos.rules.base import SkylosRule
3
+
4
+ DANGEROUS_CALLS = {
5
+ "eval": ("SKY-D201", "HIGH", "Use of eval()"),
6
+ "exec": ("SKY-D202", "HIGH", "Use of exec()"),
7
+ "pickle.load": (
8
+ "SKY-D203",
9
+ "CRITICAL",
10
+ "Untrusted deserialization via pickle.load",
11
+ ),
12
+ "pickle.loads": (
13
+ "SKY-D204",
14
+ "CRITICAL",
15
+ "Untrusted deserialization via pickle.loads",
16
+ ),
17
+ "yaml.load": ("SKY-D205", "HIGH", "yaml.load without SafeLoader"),
18
+ "hashlib.md5": ("SKY-D206", "MEDIUM", "Weak hash (MD5)"),
19
+ "hashlib.sha1": ("SKY-D207", "MEDIUM", "Weak hash (SHA1)"),
20
+ "requests.*": (
21
+ "SKY-D208",
22
+ "HIGH",
23
+ "requests call with verify=False",
24
+ {"kw_equals": {"verify": False}},
25
+ ),
26
+ }
27
+
28
+
29
+ def _qualified_name_from_call(node: ast.Call):
30
+ func = node.func
31
+ parts = []
32
+ while isinstance(func, ast.Attribute):
33
+ parts.append(func.attr)
34
+ func = func.value
35
+ if isinstance(func, ast.Name):
36
+ parts.append(func.id)
37
+ parts.reverse()
38
+ return ".".join(parts)
39
+ return None
40
+
41
+
42
+ def _matches_rule(name, rule_key):
43
+ if not name:
44
+ return False
45
+ if rule_key.endswith(".*"):
46
+ return name.startswith(rule_key[:-2] + ".")
47
+ return name == rule_key
48
+
49
+
50
+ def _kw_equals(node: ast.Call, requirements):
51
+ if not requirements:
52
+ return True
53
+ kw_map = {}
54
+ for kw in node.keywords or []:
55
+ if kw.arg:
56
+ if isinstance(kw.value, (ast.Constant, ast.NameConstant)):
57
+ kw_map[kw.arg] = kw.value.value
58
+ elif isinstance(kw.value, ast.Name) and kw.value.id in ("True", "False"):
59
+ kw_map[kw.arg] = kw.value.id == "True"
60
+
61
+ for key, expected in requirements.items():
62
+ val = kw_map.get(key)
63
+ if val != expected:
64
+ return False
65
+ return True
66
+
67
+
68
+ def _yaml_load_without_safeloader(node: ast.Call):
69
+ for kw in node.keywords or []:
70
+ if kw.arg == "Loader":
71
+ if "SafeLoader" in ast.dump(kw.value):
72
+ return False
73
+ return True
74
+
75
+
76
+ class DangerousCallsRule(SkylosRule):
77
+ rule_id = "SKY-D200"
78
+ name = "Dangerous Function Calls"
79
+
80
+ def visit_node(self, node, context):
81
+ if not isinstance(node, ast.Call):
82
+ return None
83
+
84
+ name = _qualified_name_from_call(node)
85
+ if not name:
86
+ return None
87
+
88
+ findings = []
89
+
90
+ for rule_key, tup in DANGEROUS_CALLS.items():
91
+ if not _matches_rule(name, rule_key):
92
+ continue
93
+
94
+ rule_id = tup[0]
95
+ severity = tup[1]
96
+ message = tup[2]
97
+ opts = tup[3] if len(tup) > 3 else None
98
+
99
+ if rule_key == "yaml.load":
100
+ if not _yaml_load_without_safeloader(node):
101
+ continue
102
+
103
+ if opts and "kw_equals" in opts:
104
+ if not _kw_equals(node, opts["kw_equals"]):
105
+ continue
106
+
107
+ findings.append(
108
+ {
109
+ "rule_id": rule_id,
110
+ "severity": severity,
111
+ "message": message,
112
+ "file": context.get("filename"),
113
+ "line": node.lineno,
114
+ "col": node.col_offset,
115
+ }
116
+ )
117
+ break
118
+
119
+ return findings if findings else None
@@ -0,0 +1,157 @@
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": (
18
+ "SKY-D203",
19
+ "CRITICAL",
20
+ "Untrusted deserialization via pickle.load",
21
+ ),
22
+ "pickle.loads": (
23
+ "SKY-D204",
24
+ "CRITICAL",
25
+ "Untrusted deserialization via pickle.loads",
26
+ ),
27
+ "yaml.load": ("SKY-D205", "HIGH", "yaml.load without SafeLoader"),
28
+ "hashlib.md5": ("SKY-D206", "MEDIUM", "Weak hash (MD5)"),
29
+ "hashlib.sha1": ("SKY-D207", "MEDIUM", "Weak hash (SHA1)"),
30
+ "requests.*": (
31
+ "SKY-D208",
32
+ "HIGH",
33
+ "requests call with verify=False",
34
+ {"kw_equals": {"verify": False}},
35
+ ),
36
+ }
37
+
38
+
39
+ def _matches_rule(name, rule_key):
40
+ if not name:
41
+ return False
42
+ if rule_key.endswith(".*"):
43
+ return name.startswith(rule_key[:-2] + ".")
44
+ return name == rule_key
45
+
46
+
47
+ def _kw_equals(node: ast.Call, requirements):
48
+ if not requirements:
49
+ return True
50
+ kw_map = {}
51
+ for kw in node.keywords or []:
52
+ if kw.arg:
53
+ kw_map[kw.arg] = kw.value
54
+
55
+ for key, expected in requirements.items():
56
+ val = kw_map.get(key)
57
+ if not isinstance(val, ast.Constant):
58
+ return False
59
+ if val.value != expected:
60
+ return False
61
+ return True
62
+
63
+
64
+ def qualified_name_from_call(node: ast.Call):
65
+ func = node.func
66
+ parts = []
67
+ while isinstance(func, ast.Attribute):
68
+ parts.append(func.attr)
69
+ func = func.value
70
+ if isinstance(func, ast.Name):
71
+ parts.append(func.id)
72
+ parts.reverse()
73
+ return ".".join(parts)
74
+ return None
75
+
76
+
77
+ def _yaml_load_without_safeloader(node: ast.Call):
78
+ name = qualified_name_from_call(node)
79
+ if name != "yaml.load":
80
+ return False
81
+
82
+ for kw in node.keywords or []:
83
+ if kw.arg == "Loader":
84
+ text = ast.unparse(kw.value)
85
+ return "SafeLoader" not in text
86
+ return True
87
+
88
+
89
+ def _add_finding(findings, file_path: Path, node: ast.AST, rule_id, severity, message):
90
+ findings.append(
91
+ {
92
+ "rule_id": rule_id,
93
+ "severity": severity,
94
+ "message": message,
95
+ "file": str(file_path),
96
+ "line": getattr(node, "lineno", 1),
97
+ "col": getattr(node, "col_offset", 0),
98
+ }
99
+ )
100
+
101
+
102
+ def _scan_file(file_path: Path, findings):
103
+ src = file_path.read_text(encoding="utf-8", errors="ignore")
104
+ tree = ast.parse(src)
105
+
106
+ scan_sql(tree, file_path, findings)
107
+ scan_cmd(tree, file_path, findings)
108
+ scan_sql_raw(tree, file_path, findings)
109
+ scan_ssrf(tree, file_path, findings)
110
+ scan_path(tree, file_path, findings)
111
+ scan_xss(tree, file_path, findings)
112
+
113
+ for node in ast.walk(tree):
114
+ if not isinstance(node, ast.Call):
115
+ continue
116
+
117
+ name = qualified_name_from_call(node)
118
+ if not name:
119
+ continue
120
+
121
+ for rule_key, tup in DANGEROUS_CALLS.items():
122
+ rule_id = tup[0]
123
+ severity = tup[1]
124
+ message = tup[2]
125
+
126
+ if len(tup) > 3:
127
+ opts = tup[3]
128
+ else:
129
+ opts = None
130
+
131
+ if not _matches_rule(name, rule_key):
132
+ continue
133
+
134
+ if rule_key == "yaml.load":
135
+ if not _yaml_load_without_safeloader(node):
136
+ continue
137
+ if opts and "kw_equals" in opts:
138
+ if not _kw_equals(node, opts["kw_equals"]):
139
+ continue
140
+
141
+ _add_finding(findings, file_path, node, rule_id, severity, message)
142
+ break
143
+
144
+
145
+ def scan_ctx(_, files):
146
+ findings = []
147
+
148
+ for file_path in files:
149
+ if file_path.suffix.lower() not in ALLOWED_SUFFIXES:
150
+ continue
151
+
152
+ try:
153
+ _scan_file(file_path, findings)
154
+ except Exception as e:
155
+ print(f"Scan failed for {file_path}: {e}", file=sys.stderr)
156
+
157
+ return findings
@@ -0,0 +1,75 @@
1
+ from __future__ import annotations
2
+ import ast
3
+ import sys
4
+ from skylos.rules.danger.taint import TaintVisitor
5
+
6
+
7
+ def _qualified_name(node):
8
+ func = node.func
9
+ parts = []
10
+ while isinstance(func, ast.Attribute):
11
+ parts.append(func.attr)
12
+ func = func.value
13
+
14
+ if isinstance(func, ast.Name):
15
+ parts.append(func.id)
16
+ parts.reverse()
17
+ return ".".join(parts)
18
+
19
+ return None
20
+
21
+
22
+ class _CmdFlowChecker(TaintVisitor):
23
+ OS_SYSTEM = "os.system"
24
+ SUBPROC_PREFIX = "subprocess."
25
+
26
+ def visit_Call(self, node):
27
+ qn = _qualified_name(node)
28
+ if not qn:
29
+ self.generic_visit(node)
30
+ return
31
+
32
+ if qn == self.OS_SYSTEM and node.args:
33
+ if self.is_tainted(node.args[0]):
34
+ self.findings.append(
35
+ {
36
+ "rule_id": "SKY-D212",
37
+ "severity": "CRITICAL",
38
+ "message": "Possible command injection (os.system): tainted input.",
39
+ "file": str(self.file_path),
40
+ "line": node.lineno,
41
+ "col": node.col_offset,
42
+ }
43
+ )
44
+
45
+ if qn.startswith(self.SUBPROC_PREFIX):
46
+ shell_true = False
47
+ for kw in node.keywords:
48
+ if (
49
+ kw.arg == "shell"
50
+ and isinstance(kw.value, ast.Constant)
51
+ and kw.value.value is True
52
+ ):
53
+ shell_true = True
54
+
55
+ if shell_true and self.is_tainted(node):
56
+ self.findings.append(
57
+ {
58
+ "rule_id": "SKY-D212",
59
+ "severity": "CRITICAL",
60
+ "message": "Possible command injection (subprocess shell=True): tainted input.",
61
+ "file": str(self.file_path),
62
+ "line": node.lineno,
63
+ "col": node.col_offset,
64
+ }
65
+ )
66
+
67
+ self.generic_visit(node)
68
+
69
+
70
+ def scan(tree, file_path, findings):
71
+ try:
72
+ checker = _CmdFlowChecker(file_path, findings)
73
+ checker.visit(tree)
74
+ except Exception as e:
75
+ print(f"CMD flow failed for {file_path}: {e}", file=sys.stderr)
File without changes
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+ import ast
3
+ import sys
4
+ from skylos.rules.danger.taint import TaintVisitor
5
+
6
+
7
+ def _qualified_name(node):
8
+ func = node.func
9
+ parts = []
10
+ while isinstance(func, ast.Attribute):
11
+ parts.append(func.attr)
12
+ func = func.value
13
+ if isinstance(func, ast.Name):
14
+ parts.append(func.id)
15
+ parts.reverse()
16
+ return ".".join(parts)
17
+ if isinstance(func, ast.Name):
18
+ return func.id
19
+ return None
20
+
21
+
22
+ def _is_interpolated_string(node):
23
+ if isinstance(node, ast.JoinedStr):
24
+ return True
25
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
26
+ return True
27
+ if (
28
+ isinstance(node, ast.Call)
29
+ and isinstance(node.func, ast.Attribute)
30
+ and node.func.attr == "format"
31
+ ):
32
+ return True
33
+ return False
34
+
35
+
36
+ class _PathFlowChecker(TaintVisitor):
37
+ FILE_OPEN_FUNCS = {"open"}
38
+ OS_FILE_FUNCS = {"open", "unlink", "remove", "mkdir", "rmdir", "makedirs"}
39
+ SHUTIL_FUNCS = {"copy", "copy2", "copytree", "move", "rmtree"}
40
+
41
+ def _flag_if_tainted_path(self, node, path_expr):
42
+ is_interp = _is_interpolated_string(path_expr)
43
+ is_tainted = self.is_tainted(path_expr)
44
+
45
+ if is_interp or is_tainted:
46
+ self.findings.append(
47
+ {
48
+ "rule_id": "SKY-D215",
49
+ "severity": "HIGH",
50
+ "message": "Possible path traversal: tainted filesystem path",
51
+ "file": str(self.file_path),
52
+ "line": node.lineno,
53
+ "col": node.col_offset,
54
+ }
55
+ )
56
+
57
+ def visit_Call(self, node: ast.Call):
58
+ qn = _qualified_name(node)
59
+
60
+ if qn and qn in self.FILE_OPEN_FUNCS and node.args:
61
+ self._flag_if_tainted_path(node, node.args[0])
62
+
63
+ if qn and "." in qn:
64
+ mod, func = qn.split(".", 1)
65
+ if mod == "os" and func in self.OS_FILE_FUNCS and node.args:
66
+ self._flag_if_tainted_path(node, node.args[0])
67
+
68
+ if mod == "shutil" and func in self.SHUTIL_FUNCS and node.args:
69
+ self._flag_if_tainted_path(node, node.args[0])
70
+
71
+ self.generic_visit(node)
72
+
73
+
74
+ def scan(tree, file_path, findings):
75
+ try:
76
+ checker = _PathFlowChecker(file_path, findings)
77
+ checker.visit(tree)
78
+ except Exception as e:
79
+ print(f"Path traversal analysis failed for {file_path}: {e}", file=sys.stderr)
File without changes
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+ import ast
3
+ import sys
4
+ from skylos.rules.danger.taint import TaintVisitor
5
+
6
+
7
+ def _qualified_name_from_call(node):
8
+ func = node.func
9
+ parts = []
10
+ while isinstance(func, ast.Attribute):
11
+ parts.append(func.attr)
12
+ func = func.value
13
+ if isinstance(func, ast.Name):
14
+ parts.append(func.id)
15
+ parts.reverse()
16
+ return ".".join(parts)
17
+ if isinstance(func, ast.Name):
18
+ return func.id
19
+ return None
20
+
21
+
22
+ def _is_interpolated_string(node):
23
+ if isinstance(node, ast.JoinedStr):
24
+ return True
25
+ if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
26
+ return True
27
+ if (
28
+ isinstance(node, ast.Call)
29
+ and isinstance(node.func, ast.Attribute)
30
+ and node.func.attr == "format"
31
+ ):
32
+ return True
33
+ return False
34
+
35
+
36
+ class _SSRFFlowChecker(TaintVisitor):
37
+ HTTP_METHODS = {"get", "post", "put", "delete", "head", "options", "request"}
38
+
39
+ def visit_Call(self, node):
40
+ qn = _qualified_name_from_call(node)
41
+
42
+ if qn and "." in qn:
43
+ _, func = qn.split(".", 1)
44
+ if func in self.HTTP_METHODS and node.args:
45
+ url_arg = node.args[0]
46
+ if _is_interpolated_string(url_arg) or self.is_tainted(url_arg):
47
+ self.findings.append(
48
+ {
49
+ "rule_id": "SKY-D216",
50
+ "severity": "CRITICAL",
51
+ "message": "Possible SSRF: tainted URL passed to HTTP client.",
52
+ "file": str(self.file_path),
53
+ "line": node.lineno,
54
+ "col": node.col_offset,
55
+ }
56
+ )
57
+
58
+ if qn and qn.endswith(".urlopen") and node.args:
59
+ url_arg = node.args[0]
60
+ if _is_interpolated_string(url_arg) or self.is_tainted(url_arg):
61
+ self.findings.append(
62
+ {
63
+ "rule_id": "SKY-D216",
64
+ "severity": "CRITICAL",
65
+ "message": "Possible SSRF: tainted URL passed to HTTP client.",
66
+ "file": str(self.file_path),
67
+ "line": node.lineno,
68
+ "col": node.col_offset,
69
+ }
70
+ )
71
+
72
+ self.generic_visit(node)
73
+
74
+
75
+ def scan(tree, file_path, findings):
76
+ try:
77
+ checker = _SSRFFlowChecker(file_path, findings)
78
+ checker.visit(tree)
79
+ except Exception as e:
80
+ print(f"SSRF flow analysis failed for {file_path}: {e}", file=sys.stderr)
File without changes