Skip to content
Binary file modified .coverage
Binary file not shown.
8 changes: 4 additions & 4 deletions src/qwed_new/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ async def verify_math(

try:
import sympy
from sympy.parsing.sympy_parser import parse_expr
from qwed_new.core.safe_parser import safe_parse_expr
from sympy import simplify, symbols, Eq, solve

expression = request.get("expression")
Expand All @@ -472,8 +472,8 @@ async def verify_math(
left_str, right_str = expression.split("=", 1)

# Parse both sides
left = parse_expr(left_str)
right = parse_expr(right_str)
left = safe_parse_expr(left_str)
right = safe_parse_expr(right_str)

# Simplify and check equivalence
difference = simplify(left - right)
Expand Down Expand Up @@ -501,7 +501,7 @@ async def verify_math(
if re.search(r'/\d+\(', expression.replace(" ", "")):
is_ambiguous = True

parsed = parse_expr(expression_normalized)
parsed = safe_parse_expr(expression_normalized)

# Check for division by zero before simplifying
if "/0" in expression.replace(" ", "") or "/ 0" in expression:
Expand Down
8 changes: 4 additions & 4 deletions src/qwed_new/core/batch.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,14 @@ async def _verify_item(
)

elif item.verification_type == VerificationType.MATH:
from sympy.parsing.sympy_parser import parse_expr
from qwed_new.core.safe_parser import safe_parse_expr
from sympy import simplify

expression = item.query
if "=" in expression:
left, right = expression.split("=", 1)
left_expr = parse_expr(left)
right_expr = parse_expr(right)
left_expr = safe_parse_expr(left)
right_expr = safe_parse_expr(right)
diff = simplify(left_expr - right_expr)
is_valid = diff == 0
return {
Expand All @@ -236,7 +236,7 @@ async def _verify_item(
"message": "Identity verified" if is_valid else "Not equal"
}
else:
parsed = parse_expr(expression)
parsed = safe_parse_expr(expression)
simplified = simplify(parsed)
return {
"is_valid": False,
Expand Down
224 changes: 224 additions & 0 deletions src/qwed_new/core/safe_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,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"
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
depth = _ast_node_depth(tree)
Comment thread
sentry[bot] marked this conversation as resolved.
if depth > _AST_MAX_DEPTH:
raise SafeParserError(
f"Expression AST depth {depth} exceeds limit of {_AST_MAX_DEPTH}"
)
Comment thread
sentry[bot] marked this conversation as resolved.


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,
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated
"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
Comment thread
sentry[bot] marked this conversation as resolved.
return safe
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
_validate_sympy_result(result)
return result
except SafeParserError:
raise
except Exception:
raise SafeParserError("Failed to parse expression") from None
Comment thread
sentry[bot] marked this conversation as resolved.
Outdated


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)
4 changes: 2 additions & 2 deletions src/qwed_new/core/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
3. Evaluable: Can we calculate a numerical result?
"""

from sympy.parsing.sympy_parser import parse_expr
from qwed_new.core.safe_parser import safe_parse_expr
from typing import Dict


Expand Down Expand Up @@ -61,7 +61,7 @@ def validate(self, expression: str) -> Dict[str, any]:

# Check 1: Syntax validation
try:
expr = parse_expr(expression)
expr = safe_parse_expr(expression)
checks_passed.append("syntax")
except Exception as e:
checks_failed.append("syntax")
Expand Down
33 changes: 17 additions & 16 deletions src/qwed_new/core/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
diff, integrate, limit, oo,
simplify, expand
)
from sympy.parsing.sympy_parser import parse_expr, standard_transformations, implicit_multiplication_application
from qwed_new.core.safe_parser import safe_parse_expr, get_safe_symbol
from sympy.parsing.sympy_parser import standard_transformations, implicit_multiplication_application
from typing import Any, Dict, List, Optional
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
Expand Down Expand Up @@ -120,7 +121,7 @@ def verify_math(

try:
# 1. Parse the expression safely
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)

# 2. Evaluate deterministically
if use_decimal:
Expand Down Expand Up @@ -197,8 +198,8 @@ def verify_identity(self, lhs: str, rhs: str) -> Dict[str, Any]:
verify_identity("sin(x)**2 + cos(x)**2", "1") # True
"""
try:
left = parse_expr(lhs, transformations=self.TRANSFORMATIONS)
right = parse_expr(rhs, transformations=self.TRANSFORMATIONS)
left = safe_parse_expr(lhs)
right = safe_parse_expr(rhs)

# Method 1: Simplify difference
diff = simplify(left - right)
Expand Down Expand Up @@ -281,9 +282,9 @@ def verify_derivative(
order: Order of derivative (1 for first, 2 for second, etc.)
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = get_safe_symbol(variable)
expected_expr = safe_parse_expr(expected)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

# Calculate derivative
actual_derivative = diff(expr, var, order)
Expand Down Expand Up @@ -328,14 +329,14 @@ def verify_integral(
upper_bound: Upper bound for definite integral
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = get_safe_symbol(variable)
expected_expr = safe_parse_expr(expected)

if lower_bound is not None and upper_bound is not None:
# Definite integral
lower = parse_expr(lower_bound, transformations=self.TRANSFORMATIONS)
upper = parse_expr(upper_bound, transformations=self.TRANSFORMATIONS)
lower = safe_parse_expr(lower_bound)
upper = safe_parse_expr(upper_bound)
actual_integral = integrate(expr, (var, lower, upper))

# For definite integrals, compare values
Expand Down Expand Up @@ -385,17 +386,17 @@ def verify_limit(
direction: "+" for right, "-" for left, "+-" for both
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = get_safe_symbol(variable)
expected_expr = safe_parse_expr(expected)

# Parse the point (handle infinity)
if point.lower() in ['oo', 'inf', 'infinity']:
pt = oo
elif point.lower() in ['-oo', '-inf', '-infinity']:
pt = -oo
else:
pt = parse_expr(point, transformations=self.TRANSFORMATIONS)
pt = safe_parse_expr(point)

# Calculate limit
actual_limit = limit(expr, var, pt, dir=direction)
Expand Down
Loading
Loading