Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- Replace unsafe `eval()` with RestrictedPython to prevent arbitrary code execution in API spec evaluation. [#798](https://github.com/scanapi/scanapi/pull/798)

## [2.12.0] - 2025-07-02
### Changed
- Bumped `httpx` to `^0.27.0`, which brings in `httpcore >=1.0.0` and `h11 >=0.15.0`. [#755](https://github.com/scanapi/scanapi/pull/755)
Expand Down
54 changes: 35 additions & 19 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ keywords = ["python", "scanapi", "tests", "end2end", "endtoend", "integration te
scanapi = "scanapi:main"

[tool.poetry.dependencies]
python = ">=3.10,<4.0"
python = ">=3.10,<3.14"
appdirs = "^1.4.4"
curlify2 = "^1.0.1"
MarkupSafe = "2.1.2"
Expand All @@ -24,6 +24,7 @@ Jinja2 = "~3.1.0"
click = ">=8.1.3"
httpx = ">=0.27,<0.29"
packaging = ">=24,<26"
restrictedpython = "^8.0"

[tool.poetry.group.dev.dependencies]
pytest = "^8.3.3"
Expand Down
1 change: 1 addition & 0 deletions scanapi/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ class InvalidPythonCodeError(MalformedSpecError):
"""Raised when python code defined in the API spec raises an error"""

def __init__(self, error_message, code, *args):
self.expression = code
error_message = (
f"Invalid Python code defined in the API spec. "
f"Exception: {error_message}. "
Expand Down
120 changes: 108 additions & 12 deletions scanapi/evaluators/code_evaluator.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
# Available imports to be used dinamically in the API spec
import datetime # noqa: F401
import math # noqa: F401
import random # noqa: F401
import re
import time # noqa: F401
import uuid # noqa: F401
import logging

from RestrictedPython import compile_restricted
from RestrictedPython.Guards import safe_globals, safe_builtins

from scanapi.errors import InvalidPythonCodeError

logger = logging.getLogger(__name__)


class CodeEvaluator:
# Configuration: modules available in API spec evaluation
ALLOWED_MODULES = ["datetime", "math", "random", "re", "time", "uuid"]
python_code_pattern = re.compile(
r"(?P<something_before>\w*)"
r"(?P<start>\${{)"
Expand Down Expand Up @@ -55,12 +57,103 @@ def evaluate(cls, sequence, spec_vars, is_a_test_case=False):
except Exception as e:
raise InvalidPythonCodeError(str(e), code)

@classmethod
def _get_allowed_modules(cls):
"""Dynamically import allowed modules.
Returns:
dict: Dictionary of module names to imported modules
"""
return {name: __import__(name) for name in cls.ALLOWED_MODULES}

@classmethod
def _get_safe_globals(cls, response=None):
"""Create a secure global context for code execution.
Args:
response: Optional response object for test assertions
Returns:
dict: Safe global context with restricted access
"""
safe_context = safe_globals.copy()
safe_context["__builtins__"] = safe_builtins.copy()

# Add iterator functions for generator expressions and comprehensions
safe_context["_iter_unpack_sequence_"] = iter
safe_context["_getiter_"] = iter
safe_context["_getattr_"] = getattr
# Enables obj[key] access
safe_context["_getitem_"] = lambda obj, key: obj[key]
essential_builtins = {
"all": all,
"any": any,
"len": len,
"str": str,
}
safe_context["__builtins__"].update(essential_builtins)

# Add allowed modules via dynamic import
allowed_modules = cls._get_allowed_modules()
safe_context.update(allowed_modules)

# Add response object if provided (for test assertions)
if response is not None:
safe_context["response"] = response

return safe_context

@classmethod
def _safe_eval(cls, code, global_context=None):
"""Safely evaluate Python code using RestrictedPython with mode='eval'.
Args:
code[string]: Python code to evaluate
global_context[dict]: Global context for evaluation
Returns:
Result of code evaluation
Raises:
InvalidPythonCodeError: If code compilation or execution fails
"""
if global_context is None:
global_context = cls._get_safe_globals()

try:
# Strip whitespace to avoid IndentationError in RestrictedPython
clean_code = code.strip()

# Compile the code with restrictions using mode='eval'
compiled_code = compile_restricted(
clean_code, "<string>", mode="eval"
)
if compiled_code is None:
raise InvalidPythonCodeError(
"Failed to compile restricted code", code
)

# Execute the compiled code securely
result = eval(compiled_code, global_context)
return result

except SyntaxError as e:
logger.error(
"Syntax error in Python code: '%s' - %s", clean_code, str(e)
)
raise InvalidPythonCodeError(f"Syntax error in code: {e}", code)
except Exception as e:
logger.error(
"Runtime error in Python code: '%s' - %s", clean_code, str(e)
)
raise InvalidPythonCodeError(str(e), code)

@classmethod
def _assert_code(cls, code, response):
"""Assert a Python code statement.
"""Assert a Python code statement using RestrictedPython.
The eval's global context is enriched with the response to support
comprehensions.
The evaluation's global context is enriched with the response to support
comprehensions using RestrictedPython for security.
Args:
code[string]: python code that ScanAPI needs to assert
Expand All @@ -77,15 +170,18 @@ def _assert_code(cls, code, response):
AssertionError: If python statement evaluates False
"""
global_context = {**globals(), **{"response": response}}
ok = eval(code, global_context) # noqa
global_context = cls._get_safe_globals(response)
ok = cls._safe_eval(code, global_context)
return ok, None if ok else code.strip()

@classmethod
def _evaluate_sequence(cls, sequence, match, code, response):
# To avoid circular imports
from scanapi.evaluators.string_evaluator import StringEvaluator

global_context = cls._get_safe_globals(response)
result = cls._safe_eval(code, global_context)

return StringEvaluator.replace_var_with_value(
sequence, match.group(), str(eval(code))
sequence, match.group(), str(result)
)
Loading