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,245 @@
|
|
|
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
|
+
|
|
11
|
+
while isinstance(func, ast.Attribute):
|
|
12
|
+
parts.append(func.attr)
|
|
13
|
+
func = func.value
|
|
14
|
+
if isinstance(func, ast.Name):
|
|
15
|
+
parts.append(func.id)
|
|
16
|
+
parts.reverse()
|
|
17
|
+
return ".".join(parts)
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _is_interpolated_string(node):
|
|
22
|
+
if isinstance(node, ast.JoinedStr):
|
|
23
|
+
return True
|
|
24
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
25
|
+
return True
|
|
26
|
+
if (
|
|
27
|
+
isinstance(node, ast.Call)
|
|
28
|
+
and isinstance(node.func, ast.Attribute)
|
|
29
|
+
and node.func.attr == "format"
|
|
30
|
+
):
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_passthrough_return(node: ast.AST, param_names):
|
|
36
|
+
if isinstance(node, ast.Name) and node.id in param_names:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
if isinstance(node, ast.JoinedStr):
|
|
40
|
+
for v in node.values:
|
|
41
|
+
if (
|
|
42
|
+
isinstance(v, ast.FormattedValue)
|
|
43
|
+
and isinstance(v.value, ast.Name)
|
|
44
|
+
and v.value.id in param_names
|
|
45
|
+
):
|
|
46
|
+
return True
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
isinstance(node, ast.Call)
|
|
51
|
+
and isinstance(node.func, ast.Attribute)
|
|
52
|
+
and node.func.attr == "format"
|
|
53
|
+
):
|
|
54
|
+
return True
|
|
55
|
+
if isinstance(node, ast.BinOp):
|
|
56
|
+
return True
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _func_name(node):
|
|
61
|
+
return node.name
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_query_expression(call: ast.Call, names=("sql", "query", "statement")):
|
|
65
|
+
expression = None
|
|
66
|
+
if call.args and len(call.args) > 0:
|
|
67
|
+
expression = call.args[0]
|
|
68
|
+
if expression is None:
|
|
69
|
+
for keyword in call.keywords or []:
|
|
70
|
+
if keyword.arg in names and keyword.value is not None:
|
|
71
|
+
expression = keyword.value
|
|
72
|
+
break
|
|
73
|
+
return expression
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_parameterized_query(call: ast.Call, query_expr: ast.AST):
|
|
77
|
+
if _is_interpolated_string(query_expr):
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
if len(call.args) >= 2:
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
for keyword in call.keywords or []:
|
|
84
|
+
if keyword.arg in {"params", "parameters"}:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def is_sqlalchemy_text(expr: ast.AST):
|
|
90
|
+
if not isinstance(expr, ast.Call):
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
func = expr.func
|
|
94
|
+
|
|
95
|
+
if isinstance(func, ast.Attribute) and func.attr == "text":
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
if isinstance(func, ast.Name) and func.id == "text":
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _SQLFlowChecker(TaintVisitor):
|
|
104
|
+
RULE_ID_SQLI = "SKY-D211"
|
|
105
|
+
SEVERITY_CRITICAL = "CRITICAL"
|
|
106
|
+
SEVERITY_HIGH = "HIGH"
|
|
107
|
+
DBAPI_SQL_SINK_SUFFIXES = (".execute", ".executemany", ".executescript")
|
|
108
|
+
|
|
109
|
+
def __init__(self, file_path, findings):
|
|
110
|
+
super().__init__(file_path, findings)
|
|
111
|
+
self.passthrough_functions = set()
|
|
112
|
+
|
|
113
|
+
def visit_FunctionDef(self, node):
|
|
114
|
+
self._push()
|
|
115
|
+
|
|
116
|
+
param_names = {a.arg for a in node.args.args}
|
|
117
|
+
for statement in node.body:
|
|
118
|
+
if isinstance(statement, ast.Return) and statement.value is not None:
|
|
119
|
+
if _is_passthrough_return(statement.value, param_names):
|
|
120
|
+
self.passthrough_functions.add(_func_name(node))
|
|
121
|
+
break
|
|
122
|
+
|
|
123
|
+
self.generic_visit(node)
|
|
124
|
+
self._pop()
|
|
125
|
+
|
|
126
|
+
def visit_AsyncFunctionDef(self, node):
|
|
127
|
+
self._push()
|
|
128
|
+
|
|
129
|
+
param_names = {a.arg for a in node.args.args}
|
|
130
|
+
for statement in node.body:
|
|
131
|
+
if isinstance(statement, ast.Return) and statement.value is not None:
|
|
132
|
+
if _is_passthrough_return(statement.value, param_names):
|
|
133
|
+
self.passthrough_functions.add(_func_name(node))
|
|
134
|
+
break
|
|
135
|
+
|
|
136
|
+
self.generic_visit(node)
|
|
137
|
+
self._pop()
|
|
138
|
+
|
|
139
|
+
def visit_Call(self, node):
|
|
140
|
+
qual_name = _qualified_name_from_call(node)
|
|
141
|
+
|
|
142
|
+
if qual_name and qual_name in self.passthrough_functions:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
if qual_name and qual_name.endswith(self.DBAPI_SQL_SINK_SUFFIXES):
|
|
146
|
+
query_expr = get_query_expression(node, names=("sql", "query", "statement"))
|
|
147
|
+
|
|
148
|
+
if query_expr is not None:
|
|
149
|
+
if _is_interpolated_string(query_expr) or self.is_tainted(query_expr):
|
|
150
|
+
self.findings.append(
|
|
151
|
+
{
|
|
152
|
+
"rule_id": self.RULE_ID_SQLI,
|
|
153
|
+
"severity": self.SEVERITY_CRITICAL,
|
|
154
|
+
"message": "Possible SQL injection: tainted or string-built query.",
|
|
155
|
+
"file": str(self.file_path),
|
|
156
|
+
"line": node.lineno,
|
|
157
|
+
"col": node.col_offset,
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
is_literal = isinstance(query_expr, ast.Constant) and isinstance(
|
|
162
|
+
query_expr.value, str
|
|
163
|
+
)
|
|
164
|
+
if not is_literal and not is_parameterized_query(node, query_expr):
|
|
165
|
+
self.findings.append(
|
|
166
|
+
{
|
|
167
|
+
"rule_id": self.RULE_ID_SQLI,
|
|
168
|
+
"severity": self.SEVERITY_HIGH,
|
|
169
|
+
"message": "Likely unparameterized SQL execution.",
|
|
170
|
+
"file": str(self.file_path),
|
|
171
|
+
"line": node.lineno,
|
|
172
|
+
"col": node.col_offset,
|
|
173
|
+
}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
self.generic_visit(node)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
if qual_name and (
|
|
180
|
+
qual_name.endswith(".read_sql") or qual_name.endswith(".read_sql_query")
|
|
181
|
+
):
|
|
182
|
+
query_expr = get_query_expression(node, names=("sql", "query"))
|
|
183
|
+
|
|
184
|
+
if query_expr is not None and (
|
|
185
|
+
_is_interpolated_string(query_expr) or self.is_tainted(query_expr)
|
|
186
|
+
):
|
|
187
|
+
self.findings.append(
|
|
188
|
+
{
|
|
189
|
+
"rule_id": self.RULE_ID_SQLI,
|
|
190
|
+
"severity": self.SEVERITY_CRITICAL,
|
|
191
|
+
"message": "Possible SQL injection in read_sql.",
|
|
192
|
+
"file": str(self.file_path),
|
|
193
|
+
"line": node.lineno,
|
|
194
|
+
"col": node.col_offset,
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
self.generic_visit(node)
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "execute":
|
|
201
|
+
statement_expression = get_query_expression(
|
|
202
|
+
node, names=("statement", "sql", "query")
|
|
203
|
+
)
|
|
204
|
+
if statement_expression is not None:
|
|
205
|
+
if _is_interpolated_string(statement_expression) or self.is_tainted(
|
|
206
|
+
statement_expression
|
|
207
|
+
):
|
|
208
|
+
self.findings.append(
|
|
209
|
+
{
|
|
210
|
+
"rule_id": self.RULE_ID_SQLI,
|
|
211
|
+
"severity": self.SEVERITY_CRITICAL,
|
|
212
|
+
"message": "Possible SQL injection: tainted statement passed to execute().",
|
|
213
|
+
"file": str(self.file_path),
|
|
214
|
+
"line": node.lineno,
|
|
215
|
+
"col": node.col_offset,
|
|
216
|
+
}
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
self.generic_visit(node)
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
if is_sqlalchemy_text(node):
|
|
223
|
+
for argument in node.args:
|
|
224
|
+
if _is_interpolated_string(argument) or self.is_tainted(argument):
|
|
225
|
+
self.findings.append(
|
|
226
|
+
{
|
|
227
|
+
"rule_id": self.RULE_ID_SQLI,
|
|
228
|
+
"severity": self.SEVERITY_CRITICAL,
|
|
229
|
+
"message": "Possible SQL injection: tainted string used in sqlalchemy.text().",
|
|
230
|
+
"file": str(self.file_path),
|
|
231
|
+
"line": node.lineno,
|
|
232
|
+
"col": node.col_offset,
|
|
233
|
+
}
|
|
234
|
+
)
|
|
235
|
+
break
|
|
236
|
+
|
|
237
|
+
self.generic_visit(node)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def scan(tree, file_path, findings):
|
|
241
|
+
try:
|
|
242
|
+
checker = _SQLFlowChecker(file_path, findings)
|
|
243
|
+
checker.visit(tree)
|
|
244
|
+
except Exception as e:
|
|
245
|
+
print(f"SQL flow analysis failed for {file_path}: {e}", file=sys.stderr)
|
|
@@ -0,0 +1,96 @@
|
|
|
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: ast.Call):
|
|
8
|
+
f = node.func
|
|
9
|
+
parts = []
|
|
10
|
+
|
|
11
|
+
while isinstance(f, ast.Attribute):
|
|
12
|
+
parts.append(f.attr)
|
|
13
|
+
f = f.value
|
|
14
|
+
|
|
15
|
+
if isinstance(f, ast.Name):
|
|
16
|
+
parts.append(f.id)
|
|
17
|
+
parts.reverse()
|
|
18
|
+
return ".".join(parts)
|
|
19
|
+
|
|
20
|
+
if isinstance(f, ast.Name):
|
|
21
|
+
return f.id
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_interpolated_string(n: ast.AST):
|
|
26
|
+
if isinstance(n, ast.JoinedStr):
|
|
27
|
+
return True
|
|
28
|
+
if isinstance(n, ast.BinOp) and isinstance(n.op, (ast.Add, ast.Mod)):
|
|
29
|
+
return True
|
|
30
|
+
if (
|
|
31
|
+
isinstance(n, ast.Call)
|
|
32
|
+
and isinstance(n.func, ast.Attribute)
|
|
33
|
+
and n.func.attr == "format"
|
|
34
|
+
):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _SQLRawFlowChecker(TaintVisitor):
|
|
40
|
+
def visit_Call(self, node: ast.Call):
|
|
41
|
+
qn = _qualified_name(node)
|
|
42
|
+
if not qn:
|
|
43
|
+
self.generic_visit(node)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if qn.endswith(".text") and node.args:
|
|
47
|
+
sql = node.args[0]
|
|
48
|
+
if _is_interpolated_string(sql) or self.is_tainted(sql):
|
|
49
|
+
self.findings.append(
|
|
50
|
+
{
|
|
51
|
+
"rule_id": "SKY-D217",
|
|
52
|
+
"severity": "CRITICAL",
|
|
53
|
+
"message": "Possible SQL injection: tainted SQL passed to sqlalchemy.text().",
|
|
54
|
+
"file": str(self.file_path),
|
|
55
|
+
"line": node.lineno,
|
|
56
|
+
"col": node.col_offset,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if (qn.endswith(".read_sql") or qn.endswith(".read_sql_query")) and node.args:
|
|
61
|
+
sql = node.args[0]
|
|
62
|
+
if _is_interpolated_string(sql) or self.is_tainted(sql):
|
|
63
|
+
self.findings.append(
|
|
64
|
+
{
|
|
65
|
+
"rule_id": "SKY-D217",
|
|
66
|
+
"severity": "CRITICAL",
|
|
67
|
+
"message": "Possible SQL injection: tainted SQL passed to pandas.read_sql().",
|
|
68
|
+
"file": str(self.file_path),
|
|
69
|
+
"line": node.lineno,
|
|
70
|
+
"col": node.col_offset,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if qn.endswith(".objects.raw") and node.args:
|
|
75
|
+
sql = node.args[0]
|
|
76
|
+
if _is_interpolated_string(sql) or self.is_tainted(sql):
|
|
77
|
+
self.findings.append(
|
|
78
|
+
{
|
|
79
|
+
"rule_id": "SKY-D217",
|
|
80
|
+
"severity": "CRITICAL",
|
|
81
|
+
"message": "Possible SQL injection: tainted SQL passed to Django .raw().",
|
|
82
|
+
"file": str(self.file_path),
|
|
83
|
+
"line": node.lineno,
|
|
84
|
+
"col": node.col_offset,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
self.generic_visit(node)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def scan(tree: ast.AST, file_path, findings):
|
|
92
|
+
try:
|
|
93
|
+
checker = _SQLRawFlowChecker(file_path, findings)
|
|
94
|
+
checker.visit(tree)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"Raw SQL flow analysis failed for {file_path}: {e}", file=sys.stderr)
|
|
File without changes
|
|
@@ -0,0 +1,170 @@
|
|
|
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: ast.Call):
|
|
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: ast.AST):
|
|
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
|
+
def _const_str_value(node: ast.AST):
|
|
37
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
38
|
+
return node.value
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _const_contains_html(node: ast.AST):
|
|
43
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
44
|
+
s = node.value
|
|
45
|
+
return ("<" in s) and (">" in s)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _XSSFlowChecker(TaintVisitor):
|
|
50
|
+
SAFE_MARK_FUNCS = {"Markup", "mark_safe"}
|
|
51
|
+
|
|
52
|
+
def _template_is_unsafe_literal(self, node: ast.AST):
|
|
53
|
+
s = _const_str_value(node)
|
|
54
|
+
if not s:
|
|
55
|
+
return False
|
|
56
|
+
low = s.lower()
|
|
57
|
+
if "|safe" in low:
|
|
58
|
+
return True
|
|
59
|
+
if "{% autoescape false %}" in low:
|
|
60
|
+
return True
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
def _binop_has_html_const(self, node: ast.AST):
|
|
64
|
+
if _const_contains_html(node):
|
|
65
|
+
return True
|
|
66
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
67
|
+
return self._binop_has_html_const(node.left) or self._binop_has_html_const(
|
|
68
|
+
node.right
|
|
69
|
+
)
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
def _html_built_with_taint(self, node: ast.AST):
|
|
73
|
+
if isinstance(node, ast.JoinedStr):
|
|
74
|
+
has_html = False
|
|
75
|
+
for v in node.values:
|
|
76
|
+
if isinstance(v, ast.Constant) and _const_contains_html(v):
|
|
77
|
+
has_html = True
|
|
78
|
+
break
|
|
79
|
+
if not has_html:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
for v in node.values:
|
|
83
|
+
if isinstance(v, ast.FormattedValue) and self.is_tainted(v.value):
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
88
|
+
left_html = _const_contains_html(node.left)
|
|
89
|
+
right_html = _const_contains_html(node.right)
|
|
90
|
+
any_html = (
|
|
91
|
+
left_html
|
|
92
|
+
or right_html
|
|
93
|
+
or self._binop_has_html_const(node.left)
|
|
94
|
+
or self._binop_has_html_const(node.right)
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if not any_html:
|
|
98
|
+
return False
|
|
99
|
+
return self.is_tainted(node.left) or self.is_tainted(node.right)
|
|
100
|
+
|
|
101
|
+
if (
|
|
102
|
+
isinstance(node, ast.Call)
|
|
103
|
+
and isinstance(node.func, ast.Attribute)
|
|
104
|
+
and node.func.attr == "format"
|
|
105
|
+
):
|
|
106
|
+
base = node.func.value
|
|
107
|
+
if _const_contains_html(base):
|
|
108
|
+
for a in node.args:
|
|
109
|
+
if self.is_tainted(a):
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
def visit_Call(self, node: ast.Call):
|
|
115
|
+
qn = _qualified_name_from_call(node)
|
|
116
|
+
|
|
117
|
+
if qn and node.args:
|
|
118
|
+
func_name = qn.split(".")[-1]
|
|
119
|
+
if func_name in self.SAFE_MARK_FUNCS:
|
|
120
|
+
arg0 = node.args[0]
|
|
121
|
+
if _is_interpolated_string(arg0) or self.is_tainted(arg0):
|
|
122
|
+
self.findings.append(
|
|
123
|
+
{
|
|
124
|
+
"rule_id": "SKY-D226",
|
|
125
|
+
"severity": "CRITICAL",
|
|
126
|
+
"message": "Possible XSS: untrusted content marked safe",
|
|
127
|
+
"file": str(self.file_path),
|
|
128
|
+
"line": node.lineno,
|
|
129
|
+
"col": node.col_offset,
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if qn and qn.split(".")[-1] == "render_template_string" and node.args:
|
|
134
|
+
tmpl = node.args[0]
|
|
135
|
+
if self._template_is_unsafe_literal(tmpl):
|
|
136
|
+
self.findings.append(
|
|
137
|
+
{
|
|
138
|
+
"rule_id": "SKY-D227",
|
|
139
|
+
"severity": "HIGH",
|
|
140
|
+
"message": "Possible XSS: unsafe inline template disables escaping",
|
|
141
|
+
"file": str(self.file_path),
|
|
142
|
+
"line": node.lineno,
|
|
143
|
+
"col": node.col_offset,
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self.generic_visit(node)
|
|
148
|
+
|
|
149
|
+
def visit_Return(self, node: ast.Return):
|
|
150
|
+
if node.value is not None:
|
|
151
|
+
if self._html_built_with_taint(node.value):
|
|
152
|
+
self.findings.append(
|
|
153
|
+
{
|
|
154
|
+
"rule_id": "SKY-D228",
|
|
155
|
+
"severity": "HIGH",
|
|
156
|
+
"message": "XSS (HTML built from unescaped user input)",
|
|
157
|
+
"file": str(self.file_path),
|
|
158
|
+
"line": node.lineno,
|
|
159
|
+
"col": node.col_offset,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
self.generic_visit(node)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def scan(tree, file_path, findings):
|
|
166
|
+
try:
|
|
167
|
+
checker = _XSSFlowChecker(file_path, findings)
|
|
168
|
+
checker.visit(tree)
|
|
169
|
+
except Exception as e:
|
|
170
|
+
print(f"XSS analysis failed for {file_path}: {e}", file=sys.stderr)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import ast
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class TaintVisitor(ast.NodeVisitor):
|
|
5
|
+
def __init__(self, file_path, findings):
|
|
6
|
+
self.file_path = file_path
|
|
7
|
+
self.findings = findings
|
|
8
|
+
self.env_stack = [{}]
|
|
9
|
+
self.sources = {"input", "request"}
|
|
10
|
+
self.request_obj = "request"
|
|
11
|
+
|
|
12
|
+
def _push(self):
|
|
13
|
+
self.env_stack.append({})
|
|
14
|
+
|
|
15
|
+
def _pop(self):
|
|
16
|
+
if self.env_stack:
|
|
17
|
+
self.env_stack.pop()
|
|
18
|
+
|
|
19
|
+
def _set(self, name, tainted):
|
|
20
|
+
if not self.env_stack:
|
|
21
|
+
self._push()
|
|
22
|
+
self.env_stack[-1][name] = bool(tainted)
|
|
23
|
+
|
|
24
|
+
def _get(self, name):
|
|
25
|
+
for env in reversed(self.env_stack):
|
|
26
|
+
if name in env:
|
|
27
|
+
return env[name]
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
def is_tainted(self, node):
|
|
31
|
+
if node is None:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
if isinstance(node, ast.JoinedStr):
|
|
35
|
+
return any(
|
|
36
|
+
isinstance(v, ast.FormattedValue) and self.is_tainted(v.value)
|
|
37
|
+
for v in node.values
|
|
38
|
+
)
|
|
39
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
|
|
40
|
+
return self.is_tainted(node.left) or self.is_tainted(node.right)
|
|
41
|
+
if (
|
|
42
|
+
isinstance(node, ast.Call)
|
|
43
|
+
and isinstance(node.func, ast.Attribute)
|
|
44
|
+
and node.func.attr == "format"
|
|
45
|
+
):
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
if (
|
|
49
|
+
isinstance(node, ast.Call)
|
|
50
|
+
and isinstance(node.func, ast.Name)
|
|
51
|
+
and node.func.id in self.sources
|
|
52
|
+
):
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
if isinstance(node, (ast.Attribute, ast.Subscript)):
|
|
56
|
+
base = node.value
|
|
57
|
+
while isinstance(base, (ast.Attribute, ast.Subscript)):
|
|
58
|
+
base = base.value
|
|
59
|
+
if isinstance(base, ast.Name) and base.id == self.request_obj:
|
|
60
|
+
return True
|
|
61
|
+
return self.is_tainted(node.value)
|
|
62
|
+
|
|
63
|
+
if isinstance(node, ast.Name):
|
|
64
|
+
return self._get(node.id)
|
|
65
|
+
|
|
66
|
+
if isinstance(node, ast.Call):
|
|
67
|
+
if isinstance(node.func, ast.Attribute):
|
|
68
|
+
if self.is_tainted(node.func.value):
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
if any(self.is_tainted(arg) for arg in node.args):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
if any(self.is_tainted(k.value) for k in node.keywords):
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
def generic_visit(self, node):
|
|
80
|
+
for field, value in ast.iter_fields(node):
|
|
81
|
+
if isinstance(value, list):
|
|
82
|
+
for item in value:
|
|
83
|
+
if isinstance(item, ast.AST):
|
|
84
|
+
self.visit(item)
|
|
85
|
+
elif isinstance(value, ast.AST):
|
|
86
|
+
self.visit(value)
|
|
87
|
+
|
|
88
|
+
def visit_FunctionDef(self, node):
|
|
89
|
+
self._push()
|
|
90
|
+
self.generic_visit(node)
|
|
91
|
+
self._pop()
|
|
92
|
+
|
|
93
|
+
def visit_AsyncFunctionDef(self, node):
|
|
94
|
+
self._push()
|
|
95
|
+
self.generic_visit(node)
|
|
96
|
+
self._pop()
|
|
97
|
+
|
|
98
|
+
def visit_Assign(self, node):
|
|
99
|
+
t = self.is_tainted(node.value)
|
|
100
|
+
for tgt in node.targets:
|
|
101
|
+
if isinstance(tgt, ast.Name):
|
|
102
|
+
self._set(tgt.id, t)
|
|
103
|
+
self.generic_visit(node)
|
|
104
|
+
|
|
105
|
+
def visit_AnnAssign(self, node):
|
|
106
|
+
if node.value:
|
|
107
|
+
t = self.is_tainted(node.value)
|
|
108
|
+
if isinstance(node.target, ast.Name):
|
|
109
|
+
self._set(node.target.id, t)
|
|
110
|
+
self.generic_visit(node)
|
|
File without changes
|