forked from QWED-AI/qwed-verification
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsafe_parser.py
More file actions
224 lines (198 loc) · 8.03 KB
/
Copy pathsafe_parser.py
File metadata and controls
224 lines (198 loc) · 8.03 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
"""
Safe SymPy expression parser.
Wraps sympy.parsing.sympy_parser.parse_expr with input validation,
a denylist for dangerous constructs, and a restricted evaluation
namespace. This module is the ONLY approved entry point for parsing
user-supplied math expressions in production code.
Security boundary:
1. Reject known-dangerous Python/OS constructs (denylist).
2. Remove __builtins__ from the eval global dict.
3. Allow-list only expected math symbols, constants, and functions.
4. Enforce basic input validation (type, length, empty check).
CWE-95 mitigation -- see PR #200 for full security analysis.
"""
import ast
import re
from typing import Any, Dict, Optional, Tuple
import sympy
from sympy import (
E, I, Integer, Float, Rational, Symbol, oo, pi,
)
from sympy.parsing.sympy_parser import (
parse_expr,
standard_transformations,
implicit_multiplication_application,
)
__all__ = ["safe_parse_expr", "validate_variable_name", "get_safe_symbol", "SafeParserError"]
MAX_EXPRESSION_LENGTH = 5_000
_AST_MAX_DEPTH = 30
_DENYLIST_PATTERN = re.compile(
r"(?:"
r"__import__|__builtins__|__subclasses__|__globals__|__locals__"
r"|__getattr__|__setattr__|__delattr__|__class__|__bases__|__mro__"
r"|\beval\b|\bexec\b|\bcompile\b|\bgetattr\b|\bsetattr\b|\bdelattr\b"
r"|\bimport\b|\bimportlib\b"
r"|\bos\b|\bsys\b|\bsubprocess\b|\bshutil\b|\bsocket\b"
r"|\bpopen\b|\bsystem\b|\bspawn\b"
r"|\bopen\b|\bfile\b|\bpath\b|\bglob\b"
r"|\bchr\b|\bord\b|\bhex\b|\btype\b|\bvars\b|\bdir\b|\brepr\b"
r"|\binput\b|\bprint\b|\bbreakpoint\b|\bexit\b|\bquit\b"
r"|\bcodecs\b|\bcode\b|\bctypes\b"
r")",
re.IGNORECASE,
)
_SAFE_GLOBAL_DICT_TEMPLATE: Dict[str, Any] = {"__builtins__": {}}
def _check_ast_depth(expression: str) -> None:
"""Reject expressions whose AST exceeds max depth (DoS defence)."""
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError:
raise SafeParserError(
"Expression uses syntax that cannot be validated for safety"
)
depth = _ast_node_depth(tree)
if depth > _AST_MAX_DEPTH:
raise SafeParserError(
f"Expression AST depth {depth} exceeds limit of {_AST_MAX_DEPTH}"
)
def _ast_node_depth(node: ast.AST, current: int = 0) -> int:
max_depth = current
for child in ast.iter_child_nodes(node):
child_depth = _ast_node_depth(child, current + 1)
if child_depth > max_depth:
max_depth = child_depth
return max_depth
def _validate_sympy_result(result: Any) -> None:
"""Ensure parse_expr returned an expected SymPy type."""
import sympy
if not isinstance(result, sympy.Basic):
raise SafeParserError(
f"Parsed result is not a valid SymPy expression, got {type(result).__name__}"
)
def _build_safe_local_dict(
extra_symbols: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
safe: Dict[str, Any] = {
"x": Symbol("x"), "y": Symbol("y"), "z": Symbol("z"),
"a": Symbol("a"), "b": Symbol("b"), "c": Symbol("c"),
"d": Symbol("d"), "f": Symbol("f"), "g": Symbol("g"),
"h": Symbol("h"), "k": Symbol("k"), "m": Symbol("m"),
"n": Symbol("n", integer=True, positive=True),
"p": Symbol("p"), "q": Symbol("q"), "r": Symbol("r"),
"s": Symbol("s"), "t": Symbol("t"), "u": Symbol("u"),
"v": Symbol("v"), "w": Symbol("w"),
"alpha": Symbol("alpha"), "beta": Symbol("beta"),
"gamma": Symbol("gamma"), "delta": Symbol("delta"),
"epsilon": Symbol("epsilon"), "zeta": Symbol("zeta"),
"eta": Symbol("eta"), "theta": Symbol("theta"),
"iota": Symbol("iota"), "kappa": Symbol("kappa"),
"mu": Symbol("mu"), "nu": Symbol("nu"),
"xi": Symbol("xi"), "omicron": Symbol("omicron"),
"rho": Symbol("rho"), "sigma": Symbol("sigma"),
"tau": Symbol("tau"), "phi": Symbol("phi"),
"chi": Symbol("chi"), "psi": Symbol("psi"),
"omega": Symbol("omega"),
"pi": pi, "e": E, "E": E, "I": I, "oo": oo,
"sin": sympy.sin, "cos": sympy.cos, "tan": sympy.tan,
"cot": sympy.cot, "sec": sympy.sec, "csc": sympy.csc,
"asin": sympy.asin, "acos": sympy.acos, "atan": sympy.atan,
"atan2": sympy.atan2,
"sinh": sympy.sinh, "cosh": sympy.cosh, "tanh": sympy.tanh,
"log": sympy.log, "ln": sympy.log, "exp": sympy.exp,
"sqrt": sympy.sqrt, "cbrt": sympy.cbrt,
"abs": sympy.Abs, "Abs": sympy.Abs,
"factorial": sympy.factorial, "binomial": sympy.binomial,
"Integer": Integer, "Float": Float, "Rational": Rational,
# Symbol is required because SymPy standard_transformations may emit
# Symbol('name') during evaluation. This allows users to create symbols
# with arbitrary names — the denylist and stripped builtins mitigate
# downstream attribute-access risks on resulting objects.
"Symbol": Symbol,
}
if extra_symbols:
for key, value in extra_symbols.items():
if not isinstance(value, (Symbol, sympy.Basic)):
raise SafeParserError(
f"extra_symbols[{key!r}] must be a SymPy Symbol or Basic, "
f"got {type(value).__name__}"
)
safe[key] = value
return safe
class SafeParserError(ValueError):
pass
def safe_parse_expr(
expression: str,
*,
extra_symbols: Optional[Dict[str, Any]] = None,
transformations: Optional[Tuple] = None,
) -> Any:
if not isinstance(expression, str):
raise SafeParserError(
f"Expression must be a string, got {type(expression).__name__}"
)
stripped = expression.strip()
if not stripped:
raise SafeParserError("Expression is empty")
if len(stripped) > MAX_EXPRESSION_LENGTH:
raise SafeParserError(
f"Expression exceeds maximum length of {MAX_EXPRESSION_LENGTH} characters"
)
match = _DENYLIST_PATTERN.search(stripped)
if match:
raise SafeParserError(
f"Expression contains disallowed construct: {match.group()!r}"
)
_check_ast_depth(stripped)
local_dict = _build_safe_local_dict(extra_symbols)
if transformations is None:
transformations = standard_transformations + (
implicit_multiplication_application,
)
global_dict = dict(_SAFE_GLOBAL_DICT_TEMPLATE)
try:
result = parse_expr(
stripped,
local_dict=local_dict,
global_dict=global_dict,
transformations=transformations,
)
_validate_sympy_result(result)
return result
except SafeParserError:
raise
except Exception:
raise SafeParserError("Failed to parse expression") from None
def validate_variable_name(variable: str) -> str:
if not isinstance(variable, str):
raise SafeParserError(
f"Variable name must be a string, got {type(variable).__name__}"
)
stripped = variable.strip()
if not stripped:
raise SafeParserError("Variable name is empty")
if len(stripped) > 50:
raise SafeParserError("Variable name is too long")
if not re.match(r"^[A-Za-z][A-Za-z0-9_]*$", stripped):
raise SafeParserError(
f"Invalid variable name: {stripped!r}. "
"Must start with a letter and contain only alphanumeric characters."
)
match = _DENYLIST_PATTERN.search(stripped)
if match:
raise SafeParserError(
f"Variable name contains disallowed construct: {match.group()!r}"
)
return stripped
def get_safe_symbol(name: str) -> Symbol:
"""Return a Symbol consistent with safe_parse_expr's namespace.
Ensures calculus operation variables match any special assumptions
(e.g. Symbol(\"n\", integer=True, positive=True)) applied during parsing,
preventing symbol mismatch in diff/integrate/limit.
"""
name = validate_variable_name(name)
safe = _build_safe_local_dict()
if name in safe:
sym = safe[name]
if isinstance(sym, Symbol):
return sym
return Symbol(name)