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.
- skylos/__init__.py +9 -3
- skylos/analyzer.py +674 -168
- skylos/cfg_visitor.py +60 -0
- skylos/cli.py +719 -235
- skylos/codemods.py +277 -0
- skylos/config.py +50 -0
- skylos/constants.py +78 -0
- skylos/gatekeeper.py +147 -0
- skylos/linter.py +18 -0
- skylos/rules/base.py +20 -0
- skylos/rules/danger/calls.py +119 -0
- skylos/rules/danger/danger.py +157 -0
- skylos/rules/danger/danger_cmd/cmd_flow.py +75 -0
- skylos/rules/danger/danger_fs/__init__.py +0 -0
- skylos/rules/danger/danger_fs/path_flow.py +79 -0
- skylos/rules/danger/danger_net/__init__.py +0 -0
- skylos/rules/danger/danger_net/ssrf_flow.py +80 -0
- skylos/rules/danger/danger_sql/__init__.py +0 -0
- skylos/rules/danger/danger_sql/sql_flow.py +245 -0
- skylos/rules/danger/danger_sql/sql_raw_flow.py +96 -0
- skylos/rules/danger/danger_web/__init__.py +0 -0
- skylos/rules/danger/danger_web/xss_flow.py +170 -0
- skylos/rules/danger/taint.py +110 -0
- skylos/rules/quality/__init__.py +0 -0
- skylos/rules/quality/complexity.py +95 -0
- skylos/rules/quality/logic.py +96 -0
- skylos/rules/quality/nesting.py +101 -0
- skylos/rules/quality/structure.py +99 -0
- skylos/rules/secrets.py +325 -0
- skylos/server.py +554 -0
- skylos/visitor.py +502 -90
- skylos/visitors/__init__.py +0 -0
- skylos/visitors/framework_aware.py +437 -0
- skylos/visitors/test_aware.py +74 -0
- skylos-2.5.2.dist-info/METADATA +21 -0
- skylos-2.5.2.dist-info/RECORD +42 -0
- {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/WHEEL +1 -1
- {skylos-1.0.10.dist-info → skylos-2.5.2.dist-info}/top_level.txt +0 -1
- skylos-1.0.10.dist-info/METADATA +0 -8
- skylos-1.0.10.dist-info/RECORD +0 -21
- test/compare_tools.py +0 -604
- test/diagnostics.py +0 -364
- test/sample_repo/app.py +0 -13
- test/sample_repo/sample_repo/commands.py +0 -81
- test/sample_repo/sample_repo/models.py +0 -122
- test/sample_repo/sample_repo/routes.py +0 -89
- test/sample_repo/sample_repo/utils.py +0 -36
- test/test_skylos.py +0 -456
- test/test_visitor.py +0 -220
- {test → skylos/rules}/__init__.py +0 -0
- {test/sample_repo → skylos/rules/danger}/__init__.py +0 -0
- {test/sample_repo/sample_repo → skylos/rules/danger/danger_cmd}/__init__.py +0 -0
- {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
|