mirror of
https://github.com/OpenByteInc/QuantDinger.git
synced 2026-09-28 23:32:55 +08:00
697 lines
27 KiB
Python
697 lines
27 KiB
Python
"""Safe execution helpers for user-provided strategy and indicator code."""
|
|
import signal
|
|
import sys
|
|
import os
|
|
import threading
|
|
import traceback
|
|
import builtins as _builtins_mod
|
|
from typing import Dict, Any, Optional, Tuple, Set
|
|
from contextlib import contextmanager
|
|
|
|
from app.utils.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class TimeoutError(Exception):
|
|
"""Raised when sandboxed code execution exceeds its time limit."""
|
|
pass
|
|
|
|
|
|
# Whitelisted builtins (strict)
|
|
# Only pure computational builtins. No I/O, no introspection, no code gen.
|
|
_BUILTINS_WHITELIST: Set[str] = {
|
|
# Types / constructors
|
|
'bool', 'int', 'float', 'complex', 'str', 'bytes', 'bytearray',
|
|
'list', 'tuple', 'dict', 'set', 'frozenset',
|
|
'range', 'slice', 'memoryview',
|
|
# Math / comparison
|
|
'abs', 'round', 'pow', 'divmod', 'min', 'max', 'sum',
|
|
# Iteration
|
|
'len', 'enumerate', 'zip', 'map', 'filter', 'sorted', 'reversed',
|
|
'iter', 'next', 'all', 'any',
|
|
# String / repr
|
|
'repr', 'ascii', 'chr', 'ord', 'format', 'bin', 'hex', 'oct',
|
|
'hash', 'id',
|
|
# Type checking (safe, no mutation)
|
|
'isinstance', 'issubclass', 'hasattr', 'callable',
|
|
# Conversion
|
|
'print',
|
|
# Exceptions (needed for try/except in user code)
|
|
'Exception', 'ValueError', 'TypeError', 'KeyError', 'IndexError',
|
|
'AttributeError', 'ZeroDivisionError', 'StopIteration',
|
|
'RuntimeError', 'OverflowError', 'ArithmeticError',
|
|
'NotImplementedError', 'NameError', 'ImportError',
|
|
# Constants
|
|
'True', 'False', 'None',
|
|
'Ellipsis', 'NotImplemented',
|
|
# Functional
|
|
'staticmethod', 'classmethod', 'property', 'super',
|
|
'object',
|
|
}
|
|
|
|
# Modules allowed in user code via `import xxx`
|
|
# operator is excluded: attrgetter/itemgetter enable dunder introspection escapes.
|
|
SAFE_IMPORT_MODULES: Set[str] = {
|
|
'numpy', 'pandas', 'math', 'json', 'datetime', 'time',
|
|
'collections', 'functools', 'itertools', 'statistics',
|
|
'decimal', 'fractions', 'copy',
|
|
}
|
|
|
|
# Dunder names reachable via string-built attribute access (e.g. operator.attrgetter).
|
|
_FORBIDDEN_DUNDER_SUFFIXES: Set[str] = {
|
|
'builtins__', 'import__', 'class__', 'bases__', 'subclasses__', 'mro__',
|
|
'globals__', 'code__', 'func__', 'dict__', 'module__', 'getattribute__',
|
|
'setattr__', 'delattr__', 'init__', 'reduce__', 'getstate__', 'setstate__',
|
|
'call__', 'getitem__', 'setitem__', 'delitem__', 'iter__', 'next__',
|
|
# Frame / traceback / closure chains used to reach the un-sandboxed scope.
|
|
'traceback__', 'closure__', 'defaults__', 'kwdefaults__',
|
|
}
|
|
|
|
_OPERATOR_ACCESSOR_NAMES: Set[str] = {'attrgetter', 'itemgetter', 'methodcaller'}
|
|
|
|
# Method names that read/write files, evaluate strings, or pivot to other
|
|
# processes; reject them on ANY receiver (df.to_csv, np.array().tofile,
|
|
# pd.read_csv, etc.) because numpy and pandas are intentionally whitelisted.
|
|
_DANGEROUS_METHOD_NAMES: Set[str] = {
|
|
# pandas read_*: arbitrary file read or SSRF via URL / pickle deser RCE.
|
|
'read_csv', 'read_table', 'read_fwf', 'read_excel', 'read_xml',
|
|
'read_html', 'read_json', 'read_pickle', 'read_parquet', 'read_orc',
|
|
'read_feather', 'read_hdf', 'read_sql', 'read_sql_query',
|
|
'read_sql_table', 'read_clipboard', 'read_gbq', 'read_sas',
|
|
'read_spss', 'read_stata',
|
|
# pandas to_* / ndarray.tofile: arbitrary file write / pickle write.
|
|
'to_csv', 'to_excel', 'to_xml', 'to_html', 'to_json', 'to_pickle',
|
|
'to_parquet', 'to_orc', 'to_feather', 'to_hdf', 'to_sql',
|
|
'to_clipboard', 'to_gbq', 'to_stata', 'to_latex',
|
|
'tofile',
|
|
# numpy IO: arbitrary read / write / pickle deser.
|
|
'save', 'savez', 'savez_compressed', 'savetxt',
|
|
'load', 'loadtxt', 'genfromtxt', 'fromfile', 'memmap',
|
|
# String-expression evaluators that execute attacker-controlled code.
|
|
'eval', 'query',
|
|
# Frame / introspection accessors should never be invoked.
|
|
'getframe', 'currentframe', 'stack', 'getouterframes',
|
|
# pandas.io.common: file/URL IO bypassing blocked read_* entry points.
|
|
'urlopen', '_urlopen', 'get_filepath_or_buffer', '_get_filepath_or_buffer',
|
|
'file_exists', 'file_open', 'open_url',
|
|
}
|
|
|
|
# Attribute names whose access leaks frames / closures / code objects, even
|
|
# without dunder syntax.
|
|
_DANGEROUS_FRAME_ATTRS: Set[str] = {
|
|
'gi_frame', 'gi_code', 'gi_yieldfrom',
|
|
'cr_frame', 'cr_code', 'cr_await',
|
|
'ag_frame', 'ag_code', 'ag_await',
|
|
'f_globals', 'f_locals', 'f_back', 'f_builtins',
|
|
'f_code', 'f_trace', 'f_lasti', 'f_lineno',
|
|
'tb_frame', 'tb_next', 'tb_lasti', 'tb_lineno',
|
|
'func_globals', 'func_code', 'func_closure', 'func_dict',
|
|
}
|
|
|
|
# Sub-modules of whitelisted packages that expose C/native escapes.
|
|
_DANGEROUS_SUBMODULE_ATTRS: Set[str] = {
|
|
'ctypeslib', 'distutils', 'f2py',
|
|
}
|
|
|
|
# pandas / numpy module roots and internal sub-packages that bypass top-level IO bans.
|
|
_PANDAS_NUMPY_ROOTS: Set[str] = {'pd', 'pandas', 'np', 'numpy'}
|
|
_DANGEROUS_PD_NUMPY_ATTRS: Set[str] = {
|
|
'io', 'compat', 'util', 'core', 'arrays', 'plotting', 'errors',
|
|
'testing', 'tseries', 'api', 'conftest', 'lib',
|
|
}
|
|
|
|
|
|
def _dangerous_pd_numpy_import(name: str) -> Optional[str]:
|
|
"""Return the blocked import path when a pandas/numpy submodule is unsafe."""
|
|
parts = [p for p in str(name or '').split('.') if p]
|
|
if len(parts) < 2:
|
|
return None
|
|
root, attrs = parts[0], parts[1:]
|
|
alias_root = {'pandas': 'pd', 'numpy': 'np'}.get(root, root)
|
|
internal = _dangerous_pd_numpy_internal(alias_root, attrs)
|
|
if internal:
|
|
return name
|
|
for attr in attrs:
|
|
if attr in _DANGEROUS_SUBMODULE_ATTRS:
|
|
return name
|
|
return None
|
|
|
|
|
|
def _is_safe_import_name(name: str) -> Tuple[bool, Optional[str]]:
|
|
"""Validate import names with package-root and dangerous-submodule checks."""
|
|
root = str(name or '').split('.')[0]
|
|
if root not in SAFE_IMPORT_MODULES:
|
|
return False, f"Import not allowed: {name}"
|
|
blocked = _dangerous_pd_numpy_import(str(name or ''))
|
|
if blocked:
|
|
return False, f"Import not allowed: dangerous pandas/numpy submodule {blocked}"
|
|
return True, None
|
|
|
|
|
|
def _make_safe_import():
|
|
"""Create a restricted __import__ that only allows whitelisted modules."""
|
|
def safe_import(name, *args, **kwargs):
|
|
ok, err = _is_safe_import_name(name)
|
|
if ok:
|
|
return _builtins_mod.__import__(name, *args, **kwargs)
|
|
raise ImportError(err or f"Import not allowed: {name}")
|
|
return safe_import
|
|
|
|
|
|
def build_safe_builtins(extra_allowed: Optional[Set[str]] = None) -> Dict[str, Any]:
|
|
"""
|
|
Build a restricted __builtins__ dict for sandboxed exec().
|
|
|
|
Only includes computational builtins from the whitelist.
|
|
Dangerous capabilities (eval, exec, open, getattr, type, __import__, etc.)
|
|
are excluded by default.
|
|
|
|
Args:
|
|
extra_allowed: additional builtin names to include (use with caution)
|
|
"""
|
|
allowed = _BUILTINS_WHITELIST | (extra_allowed or set())
|
|
safe = {}
|
|
for name in allowed:
|
|
val = getattr(_builtins_mod, name, None)
|
|
if val is not None:
|
|
safe[name] = val
|
|
safe['__import__'] = _make_safe_import()
|
|
return safe
|
|
|
|
|
|
# Timeout (cross-platform)
|
|
|
|
@contextmanager
|
|
def timeout_context(seconds: int):
|
|
"""Bound user-code execution time.
|
|
|
|
Uses SIGALRM on Unix main threads and a timer-based async exception
|
|
fallback elsewhere.
|
|
"""
|
|
is_main_thread = threading.current_thread() is threading.main_thread()
|
|
|
|
# Strategy 1: Unix SIGALRM (most reliable, main thread only)
|
|
if sys.platform != 'win32' and is_main_thread:
|
|
def timeout_handler(signum, frame):
|
|
raise TimeoutError(f"Code execution timed out after {seconds} seconds")
|
|
try:
|
|
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
|
signal.alarm(seconds)
|
|
try:
|
|
yield
|
|
finally:
|
|
signal.alarm(0)
|
|
signal.signal(signal.SIGALRM, old_handler)
|
|
return
|
|
except ValueError:
|
|
pass # fall through to timer strategy
|
|
|
|
# Strategy 2: threading.Timer + ctypes async exception (cross-platform)
|
|
target_tid = threading.current_thread().ident
|
|
timed_out = threading.Event()
|
|
|
|
def _inject_timeout():
|
|
timed_out.set()
|
|
try:
|
|
import ctypes
|
|
exc = ctypes.py_object(TimeoutError)
|
|
ret = ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
ctypes.c_ulong(target_tid), exc
|
|
)
|
|
if ret == 0:
|
|
logger.warning("timeout inject: invalid thread id")
|
|
elif ret > 1:
|
|
ctypes.pythonapi.PyThreadState_SetAsyncExc(
|
|
ctypes.c_ulong(target_tid), ctypes.py_object(0)
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f"timeout inject failed: {e}")
|
|
|
|
timer = threading.Timer(seconds, _inject_timeout)
|
|
timer.daemon = True
|
|
timer.start()
|
|
try:
|
|
yield
|
|
finally:
|
|
timer.cancel()
|
|
if timed_out.is_set():
|
|
raise TimeoutError(f"Code execution timed out after {seconds} seconds")
|
|
|
|
|
|
# Core execution
|
|
|
|
def safe_exec_code(
|
|
code: str,
|
|
exec_globals: Dict[str, Any],
|
|
exec_locals: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 30,
|
|
max_memory_mb: Optional[int] = None
|
|
) -> Dict[str, Any]:
|
|
"""Execute Python code in the current process with sandbox timeout guards.
|
|
|
|
Args:
|
|
code: Python code to execute.
|
|
exec_globals: globals dictionary.
|
|
exec_locals: locals dictionary; defaults to exec_globals.
|
|
timeout: timeout in seconds.
|
|
max_memory_mb: memory limit in MB when RLIMIT is enabled.
|
|
"""
|
|
if exec_locals is None:
|
|
exec_locals = exec_globals
|
|
|
|
if max_memory_mb is None:
|
|
max_memory_mb = 500
|
|
|
|
try:
|
|
if sys.platform != 'win32' and os.getenv('SAFE_EXEC_ENABLE_RLIMIT', 'false').lower() == 'true':
|
|
try:
|
|
import resource
|
|
max_memory_bytes = max_memory_mb * 1024 * 1024
|
|
resource.setrlimit(resource.RLIMIT_AS, (max_memory_bytes, max_memory_bytes))
|
|
except (ImportError, ValueError, OSError) as e:
|
|
logger.warning(f"Failed to set memory limit: {e}")
|
|
|
|
with timeout_context(timeout):
|
|
exec(code, exec_globals, exec_locals)
|
|
|
|
return {'success': True, 'error': None, 'result': None}
|
|
|
|
except MemoryError:
|
|
error_msg = f"Code execution exceeded the {max_memory_mb}MB memory limit"
|
|
logger.error(f"Code execution out of memory (limit={max_memory_mb}MB)")
|
|
return {'success': False, 'error': error_msg, 'result': None}
|
|
except TimeoutError as e:
|
|
logger.error(f"Code execution timed out (timeout={timeout}s)")
|
|
return {'success': False, 'error': str(e), 'result': None}
|
|
except Exception as e:
|
|
error_msg = f"Code execution error: {str(e)}\n{traceback.format_exc()}"
|
|
logger.error(f"Code execution error: {e}")
|
|
return {'success': False, 'error': error_msg, 'result': None}
|
|
|
|
|
|
def safe_exec_with_validation(
|
|
code: str,
|
|
exec_globals: Dict[str, Any],
|
|
exec_locals: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 60,
|
|
max_memory_mb: Optional[int] = None,
|
|
pre_import: str = "import numpy as np\nimport pandas as pd\n",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Validate + execute user code in one call.
|
|
|
|
1. Runs validate_code_safety(); rejects unsafe code.
|
|
2. Injects build_safe_builtins() if __builtins__ is not already set.
|
|
3. Executes pre_import, then user code via safe_exec_code().
|
|
|
|
Returns same dict as safe_exec_code().
|
|
"""
|
|
is_safe, err = validate_code_safety(code)
|
|
if not is_safe:
|
|
return {'success': False, 'error': f"Unsafe code rejected: {err}", 'result': None}
|
|
|
|
if '__builtins__' not in exec_globals:
|
|
exec_globals['__builtins__'] = build_safe_builtins()
|
|
|
|
if pre_import:
|
|
try:
|
|
exec(pre_import, exec_globals)
|
|
except Exception as e:
|
|
return {'success': False, 'error': f"Pre-import failed: {e}", 'result': None}
|
|
|
|
return safe_exec_code(
|
|
code=code,
|
|
exec_globals=exec_globals,
|
|
exec_locals=exec_locals,
|
|
timeout=timeout,
|
|
max_memory_mb=max_memory_mb,
|
|
)
|
|
|
|
|
|
# Subprocess isolation (medium-term)
|
|
|
|
def safe_exec_isolated(
|
|
code: str,
|
|
input_data: Optional[Dict[str, Any]] = None,
|
|
timeout: int = 60,
|
|
max_memory_mb: int = 500,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Execute user code in an isolated subprocess.
|
|
|
|
Data is serialized via pickle through pipes. The subprocess has its own
|
|
memory space; a crash or infinite loop only kills the child.
|
|
|
|
Args:
|
|
code: Python code to execute
|
|
input_data: dict of variable names to inject (must be picklable)
|
|
timeout: max seconds
|
|
max_memory_mb: memory limit (Linux only, via RLIMIT_AS)
|
|
|
|
Returns:
|
|
dict with 'success', 'error', 'result' (the child's exec_env after run)
|
|
"""
|
|
import multiprocessing
|
|
import pickle
|
|
|
|
is_safe, err = validate_code_safety(code)
|
|
if not is_safe:
|
|
return {'success': False, 'error': f"Unsafe code rejected: {err}", 'result': None}
|
|
|
|
def _worker(code, input_data, max_memory_mb, result_pipe):
|
|
try:
|
|
if sys.platform != 'win32':
|
|
try:
|
|
import resource
|
|
mem = max_memory_mb * 1024 * 1024
|
|
resource.setrlimit(resource.RLIMIT_AS, (mem, mem))
|
|
except Exception:
|
|
pass
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
exec_env = {
|
|
'__builtins__': build_safe_builtins(),
|
|
'np': np,
|
|
'pd': pd,
|
|
}
|
|
if input_data:
|
|
exec_env.update(input_data)
|
|
|
|
pre_import = "import numpy as np\nimport pandas as pd\n"
|
|
exec(pre_import, exec_env)
|
|
exec(code, exec_env)
|
|
|
|
# Extract only picklable, non-module results
|
|
output = {}
|
|
for k, v in exec_env.items():
|
|
if k.startswith('_') or k in ('np', 'pd', '__builtins__'):
|
|
continue
|
|
try:
|
|
pickle.dumps(v)
|
|
output[k] = v
|
|
except Exception:
|
|
pass
|
|
|
|
result_pipe.send({'success': True, 'error': None, 'result': output})
|
|
except Exception as e:
|
|
result_pipe.send({
|
|
'success': False,
|
|
'error': f"{type(e).__name__}: {e}",
|
|
'result': None,
|
|
})
|
|
finally:
|
|
result_pipe.close()
|
|
|
|
parent_conn, child_conn = multiprocessing.Pipe(duplex=False)
|
|
|
|
proc = multiprocessing.Process(
|
|
target=_worker,
|
|
args=(code, input_data, max_memory_mb, child_conn),
|
|
daemon=True,
|
|
)
|
|
proc.start()
|
|
child_conn.close()
|
|
|
|
proc.join(timeout=timeout)
|
|
|
|
if proc.is_alive():
|
|
proc.kill()
|
|
proc.join(timeout=5)
|
|
return {
|
|
'success': False,
|
|
'error': f"Code execution timed out after {timeout} seconds; subprocess terminated",
|
|
'result': None,
|
|
}
|
|
|
|
if proc.exitcode != 0 and not parent_conn.poll():
|
|
return {
|
|
'success': False,
|
|
'error': f"Subprocess exited abnormally (exit code: {proc.exitcode})",
|
|
'result': None,
|
|
}
|
|
|
|
try:
|
|
if parent_conn.poll(timeout=1):
|
|
return parent_conn.recv()
|
|
return {'success': False, 'error': "Subprocess returned no result", 'result': None}
|
|
except Exception as e:
|
|
return {'success': False, 'error': f"Failed to read subprocess result: {e}", 'result': None}
|
|
finally:
|
|
parent_conn.close()
|
|
|
|
|
|
# Static validation
|
|
|
|
def _fold_string_constant(node: Any) -> Optional[str]:
|
|
"""Resolve compile-time string concatenation for sandbox static checks."""
|
|
import ast
|
|
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
return node.value
|
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
left = _fold_string_constant(node.left)
|
|
right = _fold_string_constant(node.right)
|
|
if left is not None and right is not None:
|
|
return left + right
|
|
return None
|
|
|
|
|
|
def _string_has_forbidden_dunder(text: str) -> bool:
|
|
"""Reject string literals that name introspection / escape dunders."""
|
|
if not text or '__' not in text:
|
|
return False
|
|
lowered = text.lower()
|
|
for suffix in _FORBIDDEN_DUNDER_SUFFIXES:
|
|
if suffix in lowered:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_operator_accessor_call(node: Any) -> bool:
|
|
import ast
|
|
|
|
if not isinstance(node, ast.Call):
|
|
return False
|
|
func = node.func
|
|
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
|
return func.value.id == 'operator' and func.attr in _OPERATOR_ACCESSOR_NAMES
|
|
if isinstance(func, ast.Name):
|
|
return func.id in _OPERATOR_ACCESSOR_NAMES
|
|
return False
|
|
|
|
|
|
def _attribute_access_chain(node: Any) -> Tuple[Optional[str], list]:
|
|
"""Return (root_name, [attr, ...]) for Name / Attribute chains."""
|
|
import ast
|
|
|
|
attrs: list = []
|
|
cur = node
|
|
while isinstance(cur, ast.Attribute):
|
|
attrs.insert(0, cur.attr)
|
|
cur = cur.value
|
|
root = cur.id if isinstance(cur, ast.Name) else None
|
|
return root, attrs
|
|
|
|
|
|
def _dangerous_pd_numpy_internal(root: Optional[str], attrs: list) -> Optional[str]:
|
|
"""Detect pd.io / pd._libs / np.lib style internal access."""
|
|
if root not in _PANDAS_NUMPY_ROOTS or not attrs:
|
|
return None
|
|
for idx, attr in enumerate(attrs):
|
|
if not isinstance(attr, str):
|
|
continue
|
|
if attr in _DANGEROUS_PD_NUMPY_ATTRS or attr.startswith('_'):
|
|
return f"{root}.{'.'.join(attrs[:idx + 1])}"
|
|
return None
|
|
|
|
|
|
def validate_code_safety(code: str) -> Tuple[bool, Optional[str]]:
|
|
"""Validate code safety with regex and AST checks."""
|
|
import ast
|
|
import re
|
|
|
|
dangerous_patterns = [
|
|
r'\bos\.system\b', r'\bos\.popen\b', r'\bos\.spawn\b',
|
|
r'\bos\.exec\b', r'\bos\.fork\b', r'\bos\.environ\b',
|
|
r'\bos\.getenv\b', r'\bos\.putenv\b',
|
|
r'\bos\.remove\b', r'\bos\.unlink\b', r'\bos\.rmdir\b',
|
|
r'\bos\.makedirs\b', r'\bos\.mkdir\b',
|
|
r'\bos\.listdir\b', r'\bos\.walk\b', r'\bos\.scandir\b',
|
|
r'\bos\.path\b',
|
|
r'\bsubprocess\b', r'\bcommands\b',
|
|
r'\b__import__\s*\(', r'\beval\s*\(', r'\bexec\s*\(',
|
|
r'\bcompile\s*\(', r'\bopen\s*\(', r'\bfile\s*\(',
|
|
r'\b__builtins__\b',
|
|
r'\bimport\s+os\b', r'\bimport\s+sys\b',
|
|
r'\bimport\s+subprocess\b', r'\bimport\s+shutil\b',
|
|
r'\bimport\s+pymysql\b', r'\bimport\s+sqlite3\b',
|
|
r'\bimport\s+psycopg\b', r'\bimport\s+sqlalchemy\b',
|
|
r'\bimport\s+requests\b', r'\bimport\s+urllib\b',
|
|
r'\bimport\s+http\b', r'\bimport\s+socket\b',
|
|
r'\bimport\s+ftplib\b', r'\bimport\s+telnetlib\b',
|
|
r'\bimport\s+smtplib\b', r'\bimport\s+ssl\b',
|
|
r'\bimport\s+pickle\b', r'\bimport\s+cpickle\b',
|
|
r'\bimport\s+marshal\b', r'\bimport\s+shelve\b',
|
|
r'\bimport\s+ctypes\b', r'\bimport\s+cffi\b',
|
|
r'\bimport\s+multiprocessing\b', r'\bimport\s+threading\b',
|
|
r'\bimport\s+concurrent\b', r'\bimport\s+asyncio\b',
|
|
r'\bimport\s+signal\b', r'\bimport\s+resource\b',
|
|
r'\bimport\s+importlib\b', r'\bimport\s+imp\b',
|
|
r'\bimport\s+builtins\b', r'\bimport\s+code\b',
|
|
r'\bimport\s+codeop\b', r'\bimport\s+runpy\b',
|
|
r'\bimport\s+tempfile\b', r'\bimport\s+glob\b',
|
|
r'\bimport\s+pathlib\b', r'\bimport\s+io\b',
|
|
r'\bimport\s+operator\b',
|
|
r'\boperator\.(attrgetter|itemgetter|methodcaller)\b',
|
|
r'\bgetattr\s*\(', r'\bsetattr\s*\(', r'\bdelattr\s*\(',
|
|
r'\b__getattribute__\b', r'\b__setattr__\b', r'\b__delattr__\b',
|
|
r'\b__dict__\b', r'\b__class__\b', r'\b__bases__\b',
|
|
r'\b__subclasses__\b', r'\b__mro__\b', r'\b__module__\b',
|
|
r'\b__globals__\b', r'\b__code__\b', r'\b__func__\b',
|
|
r'\bglobals\s*\(', r'\bvars\s*\(', r'\bdir\s*\(',
|
|
r'\bbreakpoint\s*\(',
|
|
r'\b__builtins__\s*[\[.]', r'\b__import__\s*\(',
|
|
r'\bimportlib\b',
|
|
# pandas / numpy IO and eval: arbitrary file r/w, SSRF, pickle RCE,
|
|
# or string-expression evaluation. numpy and pandas are intentionally
|
|
# whitelisted modules, so each dangerous method must be banned by name.
|
|
r'\.(read_csv|read_table|read_fwf|read_excel|read_xml|read_html|'
|
|
r'read_json|read_pickle|read_parquet|read_orc|read_feather|read_hdf|'
|
|
r'read_sql|read_sql_query|read_sql_table|read_clipboard|read_gbq|'
|
|
r'read_sas|read_spss|read_stata)\s*\(',
|
|
r'\.(to_csv|to_excel|to_xml|to_html|to_json|to_pickle|to_parquet|'
|
|
r'to_orc|to_feather|to_hdf|to_sql|to_clipboard|to_gbq|to_stata|'
|
|
r'to_latex|tofile)\s*\(',
|
|
r'\b(np|numpy)\.(save|savez|savez_compressed|savetxt|load|loadtxt|'
|
|
r'genfromtxt|fromfile|memmap|DataSource)\s*\(',
|
|
r'\.(eval|query)\s*\(',
|
|
# Frame / traceback / closure chains used to break out of the sandbox.
|
|
r'\.(gi_frame|gi_code|cr_frame|cr_code|ag_frame|ag_code|'
|
|
r'f_globals|f_locals|f_back|f_builtins|f_code|f_trace|'
|
|
r'tb_frame|tb_next|func_globals|func_code|func_closure)\b',
|
|
# numpy sub-packages that expose C/native escape hatches.
|
|
r'\b(np|numpy)\.(ctypeslib|distutils|f2py)\b',
|
|
# pandas internal IO: bypasses blocked read_csv / read_pickle entry points.
|
|
r'\b(pd|pandas)\.(io|compat|_libs|_testing)\b',
|
|
r'\b(np|numpy)\.lib\b',
|
|
r'\.(urlopen|_urlopen|get_filepath_or_buffer|_get_filepath_or_buffer)\s*\(',
|
|
# sys.settrace / inspect.* could also pivot; block by name.
|
|
r'\b(sys\._getframe|inspect\.(currentframe|stack|getouterframes|getframeinfo))\b',
|
|
]
|
|
|
|
for pattern in dangerous_patterns:
|
|
if re.search(pattern, code):
|
|
return False, f"Unsafe code pattern detected: {pattern}"
|
|
|
|
try:
|
|
tree = ast.parse(code)
|
|
except SyntaxError as e:
|
|
logger.warning(f"Code syntax validation failed: {e}")
|
|
return False, "Code syntax error"
|
|
except Exception as e:
|
|
# AST parse failure: reject fail-closed, not fail-open.
|
|
logger.exception("AST parse failed, rejecting code")
|
|
return False, "Code parse failed"
|
|
|
|
# NOTE: these names are checked on attribute calls like `mod.func(...)`.
|
|
# Names that doubly serve as common user variables (signal/code/io/pickle/
|
|
# ssl/http) are intentionally excluded here; the `import xxx` regex above
|
|
# already blocks them from ever being a real module reference, so any
|
|
# `signal.xxx(...)` call must be a user variable (e.g. MACD `signal`).
|
|
dangerous_modules = {
|
|
'os', 'sys', 'subprocess', 'shutil', 'resource', 'operator',
|
|
'pymysql', 'sqlite3', 'psycopg2', 'sqlalchemy',
|
|
'requests', 'urllib', 'socket', 'ftplib', 'telnetlib', 'smtplib',
|
|
'marshal', 'shelve',
|
|
'ctypes', 'cffi',
|
|
'multiprocessing', 'threading', 'concurrent', 'asyncio',
|
|
'importlib', 'imp', 'builtins', 'codeop', 'runpy',
|
|
'tempfile', 'glob', 'pathlib',
|
|
}
|
|
|
|
dangerous_call_names = {
|
|
'eval', 'exec', 'compile', '__import__',
|
|
'getattr', 'setattr', 'delattr',
|
|
'globals', 'vars', 'dir', 'breakpoint',
|
|
'open', 'input', 'exit', 'quit',
|
|
}
|
|
|
|
dangerous_dunder_attrs = {
|
|
'__builtins__', '__import__', '__class__', '__bases__',
|
|
'__subclasses__', '__mro__', '__globals__', '__code__',
|
|
'__func__', '__dict__', '__module__',
|
|
}
|
|
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
|
if _string_has_forbidden_dunder(node.value):
|
|
return False, "Unsafe dunder string literal detected"
|
|
|
|
if isinstance(node, ast.Import):
|
|
for alias in node.names:
|
|
ok, err = _is_safe_import_name(alias.name)
|
|
if not ok:
|
|
return False, f"Import not allowed: '{alias.name}'. Allowed modules: {', '.join(sorted(SAFE_IMPORT_MODULES))}"
|
|
|
|
elif isinstance(node, ast.ImportFrom):
|
|
if node.module:
|
|
ok, err = _is_safe_import_name(node.module)
|
|
if not ok:
|
|
return False, f"Import not allowed: '{node.module}'. Allowed modules: {', '.join(sorted(SAFE_IMPORT_MODULES))}"
|
|
|
|
for alias in node.names:
|
|
if alias.name == '*':
|
|
return False, "Wildcard imports are not allowed"
|
|
ok, err = _is_safe_import_name(f"{node.module}.{alias.name}")
|
|
if not ok:
|
|
return False, err or f"Import not allowed: {node.module}.{alias.name}"
|
|
|
|
elif isinstance(node, ast.Call):
|
|
if _is_operator_accessor_call(node):
|
|
return False, "operator.attrgetter/itemgetter/methodcaller are not allowed"
|
|
if isinstance(node.func, ast.Name) and node.func.id in dangerous_call_names:
|
|
return False, f"Unsafe function call detected: {node.func.id}()"
|
|
if isinstance(node.func, ast.Attribute):
|
|
if isinstance(node.func.value, ast.Name) and node.func.value.id in dangerous_modules:
|
|
return False, f"Unsafe module call detected: {node.func.value.id}.{node.func.attr}"
|
|
root, attrs = _attribute_access_chain(node.func)
|
|
internal = _dangerous_pd_numpy_internal(root, attrs)
|
|
if internal:
|
|
return False, f"Unsafe pandas/numpy internal access detected: {internal}"
|
|
# Block dangerous methods on any receiver. pandas/numpy are
|
|
# whitelisted modules, so we cannot tell statically whether
|
|
# `x.to_csv(...)` targets a DataFrame or some local object.
|
|
# Treat the *method name* itself as poisoned everywhere.
|
|
if isinstance(node.func.attr, str) and node.func.attr in _DANGEROUS_METHOD_NAMES:
|
|
return False, f"Unsafe method call detected: .{node.func.attr}()"
|
|
for arg in node.args:
|
|
folded = _fold_string_constant(arg)
|
|
if folded is not None and _string_has_forbidden_dunder(folded):
|
|
return False, "Unsafe dunder string argument detected"
|
|
|
|
elif isinstance(node, ast.Attribute):
|
|
if isinstance(node.attr, str) and node.attr in dangerous_dunder_attrs:
|
|
return False, f"Unsafe attribute access detected: .{node.attr}"
|
|
if isinstance(node.attr, str) and node.attr in _DANGEROUS_FRAME_ATTRS:
|
|
return False, f"Unsafe frame/closure attribute access detected: .{node.attr}"
|
|
if isinstance(node.attr, str) and node.attr in _DANGEROUS_SUBMODULE_ATTRS:
|
|
if isinstance(node.value, ast.Name) and node.value.id in {'np', 'numpy'}:
|
|
return False, f"Unsafe submodule access detected: {node.value.id}.{node.attr}"
|
|
root, attrs = _attribute_access_chain(node)
|
|
internal = _dangerous_pd_numpy_internal(root, attrs)
|
|
if internal:
|
|
return False, f"Unsafe pandas/numpy internal access detected: {internal}"
|
|
folded = _fold_string_constant(node)
|
|
if folded is not None and _string_has_forbidden_dunder(folded):
|
|
return False, "Unsafe dunder attribute access detected"
|
|
|
|
elif isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
|
folded = _fold_string_constant(node)
|
|
if folded is not None and _string_has_forbidden_dunder(folded):
|
|
return False, "Unsafe dunder string concatenation detected"
|
|
|
|
return True, None
|