Skip to content
8 changes: 4 additions & 4 deletions src/qwed_new/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@

Request body:
{
"code": "import os\\nos.system('ls')",

Check notice on line 402 in src/qwed_new/api/main.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

os.system() shell execution primitive detected. Context=LITERAL_STRING. Decision reason: Pattern detected in a non-executable context.
"language": "python" (optional, default: python)
}
"""
Expand Down Expand Up @@ -457,7 +457,7 @@

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 @@
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 @@
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
199 changes: 199 additions & 0 deletions src/qwed_new/core/safe_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
"""
Safe wrapper around sympy's parse_expr to prevent code execution.

sympy.parsing.sympy_parser.parse_expr() uses Python eval() internally.

Check notice on line 4 in src/qwed_new/core/safe_parser.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

Dangerous eval() call can execute untrusted code. Context=LITERAL_STRING. Decision reason: Pattern detected in a non-executable context.
Without restrictions on local_dict/global_dict, an attacker can execute
arbitrary code via crafted math expressions (CWE-95).

This module provides safe_parse_expr() which:
1. Validates input against a denylist of dangerous patterns
2. Restricts the eval namespace to only known-safe sympy objects
3. Strips Python builtins from the global namespace

Usage:
from qwed_new.core.safe_parser import safe_parse_expr
expr = safe_parse_expr("x**2 + 2*x + 1")
"""

import re
import logging
from typing import Optional, Dict, Any

import sympy
from sympy import (
Symbol, Integer, Float, Rational,
pi, E, oo, I,
)
from sympy.parsing.sympy_parser import (
parse_expr,
standard_transformations,
implicit_multiplication_application,
)

logger = logging.getLogger(__name__)

# Transformations that enable natural math input (e.g. "2x" → "2*x")
SAFE_TRANSFORMATIONS = standard_transformations + (implicit_multiplication_application,)

# Patterns that must never appear in math expressions.
# Checked before the string reaches parse_expr / eval.
_DANGEROUS_PATTERNS = re.compile(

Check warning on line 40 in src/qwed_new/core/safe_parser.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=RUNTIME_CODE. Decision reason: Executable runtime path contains a risky but non-blocking pattern.
r"__\w+__|" # dunder attributes (__import__, __class__, …)
r"\bimport\b|" # import keyword
r"\bexec\b|" # exec()

Check notice on line 43 in src/qwed_new/core/safe_parser.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

Dangerous exec() call can execute untrusted code. Context=COMMENT. Decision reason: Pattern detected in a non-executable context.
r"\beval\b|" # eval()

Check notice on line 44 in src/qwed_new/core/safe_parser.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

Dangerous eval() call can execute untrusted code. Context=COMMENT. Decision reason: Pattern detected in a non-executable context.
r"\bgetattr\b|" # getattr()
r"\bsetattr\b|" # setattr()
r"\bdelattr\b|" # delattr()
r"\bglobals\b|" # globals()
r"\blocals\b|" # locals()
r"\bcompile\b|" # compile()

Check notice on line 50 in src/qwed_new/core/safe_parser.py

View check run for this annotation

QWED Security / QWED Security

QWED: pattern_scan

compile() can be part of dynamic code generation. Context=COMMENT. Decision reason: Pattern detected in a non-executable context.
r"\bopen\b|" # open()
r"\bbreakpoint\b|" # breakpoint()
r"\bprint\b|" # print()
r"\binput\b|" # input()
r"\bvars\b|" # vars()
r"\bdir\b|" # dir()
r"\btype\b|" # type()
r"\bsuper\b|" # super()
r"\bsubclasses\b|" # __subclasses__()
r"\bmro\b|" # mro()
r"\bbases\b|" # __bases__
r"\bos\b|" # os module
r"\bsys\b|" # sys module
r"\bsubprocess\b", # subprocess module
re.IGNORECASE,
)


def _build_safe_local_dict(extra_symbols: Optional[Dict[str, Any]] = None) -> dict:
"""
Build the allow-listed local namespace for parse_expr.

Only mathematical symbols, constants, functions, and the internal
sympy types that parse_expr's transformations emit are included.
"""
safe = {
# Common symbolic variables
"x": Symbol("x"),
"y": Symbol("y"),
"z": Symbol("z"),
"a": Symbol("a"),
"b": Symbol("b"),
"c": Symbol("c"),
"n": Symbol("n", integer=True, positive=True),
"t": Symbol("t"),
"r": Symbol("r"),
"k": Symbol("k"),
"m": Symbol("m"),
"p": Symbol("p"),
"q": Symbol("q"),
"u": Symbol("u"),
"v": Symbol("v"),
"w": Symbol("w"),
# Mathematical constants
"pi": pi,
"e": E,
"E": E,
"I": I,
"oo": oo,
# Trigonometric functions
"sin": sympy.sin,
"cos": sympy.cos,
"tan": sympy.tan,
"cot": sympy.cot,
"sec": sympy.sec,
"csc": sympy.csc,
# Inverse trigonometric
"asin": sympy.asin,
"acos": sympy.acos,
"atan": sympy.atan,
"atan2": sympy.atan2,
# Hyperbolic
"sinh": sympy.sinh,
"cosh": sympy.cosh,
"tanh": sympy.tanh,
# Logarithmic / exponential
"log": sympy.log,
"ln": sympy.log,
"exp": sympy.exp,
# Roots and absolute value
"sqrt": sympy.sqrt,
"cbrt": sympy.cbrt,
"abs": sympy.Abs,
"Abs": sympy.Abs,
# Combinatorial
"factorial": sympy.factorial,
"binomial": sympy.binomial,
# Sympy internal types emitted by standard_transformations
"Integer": Integer,
"Float": Float,
"Rational": Rational,
"Symbol": Symbol,
}

if extra_symbols:
# Only allow Symbol instances or sympy types as overrides
for key, value in extra_symbols.items():
if isinstance(value, (Symbol, sympy.Basic)):
safe[key] = value

return safe
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.


# Pre-built global dict that strips builtins
_SAFE_GLOBAL_DICT: dict = {"__builtins__": {}}


def safe_parse_expr(
expression: str,
*,
transformations=SAFE_TRANSFORMATIONS,
extra_symbols: Optional[Dict[str, Any]] = None,
) -> sympy.Basic:
"""
Safely parse a mathematical expression string into a sympy expression.

Raises ValueError if the expression contains dangerous patterns or
cannot be parsed.

Args:
expression: The math expression string to parse.
transformations: sympy transformations to apply (default includes
implicit multiplication).
extra_symbols: Additional Symbol mappings to include in the
local namespace.

Returns:
A sympy expression object.

Raises:
ValueError: If the expression is rejected by safety checks or
cannot be parsed.
"""
if not isinstance(expression, str):
raise ValueError("Expression must be a string")

stripped = expression.strip()
if not stripped:
raise ValueError("Expression must not be empty")

# Length limit to prevent resource exhaustion
if len(stripped) > 5000:
raise ValueError("Expression too long (max 5000 characters)")

# Deny-list check: reject expressions with dangerous patterns
if _DANGEROUS_PATTERNS.search(stripped):
raise ValueError("Expression contains disallowed constructs")

local_dict = _build_safe_local_dict(extra_symbols)

try:
return parse_expr(
stripped,
local_dict=local_dict,
global_dict=_SAFE_GLOBAL_DICT,
transformations=transformations,
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
except Exception as exc:
raise ValueError(f"Failed to parse expression: {exc}") from exc
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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
28 changes: 14 additions & 14 deletions src/qwed_new/core/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
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, SAFE_TRANSFORMATIONS
from typing import Any, Dict, List, Optional
from decimal import Decimal, ROUND_HALF_UP
from dataclasses import dataclass
Expand Down Expand Up @@ -52,7 +52,7 @@ class VerificationEngine:
"""

# Parsing transformations for more natural input
TRANSFORMATIONS = standard_transformations + (implicit_multiplication_application,)
TRANSFORMATIONS = SAFE_TRANSFORMATIONS
MAX_VERIFY_MATH_TOLERANCE_RATIO = Decimal("0.01")
MIN_VERIFY_MATH_TOLERANCE_CAP = Decimal("0.01")

Expand Down Expand Up @@ -120,7 +120,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 +197,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 +281,9 @@ def verify_derivative(
order: Order of derivative (1 for first, 2 for second, etc.)
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
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 +328,14 @@ def verify_integral(
upper_bound: Upper bound for definite integral
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
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 +385,17 @@ def verify_limit(
direction: "+" for right, "-" for left, "+-" for both
"""
try:
expr = parse_expr(expression, transformations=self.TRANSFORMATIONS)
expr = safe_parse_expr(expression)
var = Symbol(variable)
expected_expr = parse_expr(expected, transformations=self.TRANSFORMATIONS)
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