feat(sandbox): add Python 3 code execution sandbox support
This commit is contained in:
4
sandbox/app/core/runners/python/__init__.py
Normal file
4
sandbox/app/core/runners/python/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# -*- coding: UTF-8 -*-
|
||||
# Author: Eternity
|
||||
# @Email: 1533512157@qq.com
|
||||
# @Time : 2026/1/23 11:27
|
||||
50
sandbox/app/core/runners/python/env.py
Normal file
50
sandbox/app/core/runners/python/env.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import get_config
|
||||
from app.core.runners.python.settings import LIB_PATH
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
async def prepare_python_dependencies_env():
|
||||
config = get_config()
|
||||
|
||||
with tempfile.TemporaryDirectory(dir="/") as root_path:
|
||||
root = Path(root_path)
|
||||
|
||||
env_sh = root / "env.sh"
|
||||
with open("script/env.sh") as f:
|
||||
env_sh.write_text(f.read())
|
||||
env_sh.chmod(env_sh.stat().st_mode | stat.S_IXUSR)
|
||||
|
||||
for lib_path in config.python_lib_paths:
|
||||
lib_path = Path(lib_path)
|
||||
|
||||
if not lib_path.exists():
|
||||
logger.warning("python lib path %s is not available", lib_path)
|
||||
continue
|
||||
|
||||
cmd = [
|
||||
"bash",
|
||||
str(env_sh),
|
||||
str(lib_path),
|
||||
str(LIB_PATH),
|
||||
]
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await process.communicate()
|
||||
retcode = process.returncode
|
||||
|
||||
if retcode != 0:
|
||||
logger.error(
|
||||
f"create env error for file {lib_path}: retcode={retcode}, stderr={stderr.decode()}"
|
||||
)
|
||||
56
sandbox/app/core/runners/python/prescript.py
Normal file
56
sandbox/app/core/runners/python/prescript.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import ctypes
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from base64 import b64decode
|
||||
|
||||
|
||||
# Setup exception hook
|
||||
def excepthook(etype, value, tb):
|
||||
sys.stderr.write("".join(traceback.format_exception(etype, value, tb)))
|
||||
sys.stderr.flush()
|
||||
sys.exit(-1)
|
||||
|
||||
|
||||
sys.excepthook = excepthook
|
||||
|
||||
# Load security library if available
|
||||
lib = ctypes.CDLL("./libpython.so")
|
||||
lib.init_seccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]
|
||||
lib.init_seccomp.restype = None
|
||||
|
||||
# Get running path
|
||||
running_path = sys.argv[1]
|
||||
if not running_path:
|
||||
exit(-1)
|
||||
|
||||
# Get decrypt key
|
||||
key = sys.argv[2]
|
||||
if not key:
|
||||
exit(-1)
|
||||
|
||||
key = b64decode(key)
|
||||
|
||||
os.chdir(running_path)
|
||||
|
||||
# Preload code
|
||||
{{preload}}
|
||||
|
||||
# Apply security if library is available
|
||||
lib.init_seccomp({{uid}}, {{gid}}, {{enable_network}})
|
||||
|
||||
# Decrypt and execute code
|
||||
code = b64decode("{{code}}")
|
||||
|
||||
|
||||
def decrypt(code, key):
|
||||
key_len = len(key)
|
||||
code_len = len(code)
|
||||
code = bytearray(code)
|
||||
for i in range(code_len):
|
||||
code[i] = code[i] ^ key[i % key_len]
|
||||
return bytes(code)
|
||||
|
||||
|
||||
code = decrypt(code, key)
|
||||
exec(code)
|
||||
151
sandbox/app/core/runners/python/python_runner.py
Normal file
151
sandbox/app/core/runners/python/python_runner.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Python code runner"""
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from app.config import SANDBOX_USER_ID, SANDBOX_GROUP_ID, get_config
|
||||
from app.core.encryption import generate_key, encrypt_code
|
||||
from app.core.executor import CodeExecutor, ExecutionResult
|
||||
from app.core.runners.python.settings import check_lib_avaiable, release_lib_binary, LIB_PATH
|
||||
from app.models import RunnerOptions
|
||||
|
||||
# Python sandbox prescript template
|
||||
with open("app/core/runners/python/prescript.py") as f:
|
||||
PYTHON_PRESCRIPT = f.read()
|
||||
|
||||
|
||||
class PythonRunner(CodeExecutor):
|
||||
"""Python code runner with security isolation"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
@staticmethod
|
||||
def init_enviroment(code: bytes, preload, options: RunnerOptions) -> tuple[str, str]:
|
||||
if not check_lib_avaiable():
|
||||
release_lib_binary(False)
|
||||
config = get_config()
|
||||
code_file_name = uuid.uuid4().hex.replace("-", "_")
|
||||
|
||||
script = PYTHON_PRESCRIPT.replace("{{uid}}", str(SANDBOX_USER_ID), 1)
|
||||
script = script.replace("{{gid}}", str(SANDBOX_GROUP_ID), 1)
|
||||
script = script.replace(
|
||||
"{{enable_network}}",
|
||||
str(int(options.enable_network and config.enable_network)
|
||||
),
|
||||
1
|
||||
)
|
||||
script = script.replace("{{preload}}", f"{preload}\n", 1)
|
||||
|
||||
key = generate_key(64)
|
||||
|
||||
encoded_code = encrypt_code(code, key)
|
||||
encoded_key = base64.b64encode(key).decode("utf-8")
|
||||
|
||||
script = script.replace("{{code}}", encoded_code, 1)
|
||||
|
||||
code_path = f"{LIB_PATH}/tmp/{code_file_name}.py"
|
||||
try:
|
||||
os.makedirs(os.path.dirname(code_path), mode=0o755, exist_ok=True)
|
||||
with open(code_path, "w", encoding="utf-8") as f:
|
||||
f.write(script)
|
||||
os.chmod(code_path, 0o755)
|
||||
|
||||
except OSError as e:
|
||||
raise RuntimeError(f"Failed to write {code_path}") from e
|
||||
|
||||
return code_path, encoded_key
|
||||
|
||||
async def run(
|
||||
self,
|
||||
code: str,
|
||||
options: RunnerOptions,
|
||||
preload: str = "",
|
||||
timeout: Optional[int] = None
|
||||
) -> ExecutionResult:
|
||||
"""Run Python code in sandbox
|
||||
|
||||
Args:
|
||||
options:
|
||||
code: Base64 encoded encrypted code
|
||||
preload: Preload code to execute before main code
|
||||
timeout: Execution timeout in seconds
|
||||
|
||||
Returns:
|
||||
ExecutionResult with stdout, stderr, and exit code
|
||||
"""
|
||||
config = self.config
|
||||
|
||||
if timeout is None:
|
||||
timeout = config.worker_timeout
|
||||
|
||||
# Check if preload is allowed
|
||||
if not config.enable_preload:
|
||||
preload = ""
|
||||
code = base64.b64decode(code)
|
||||
script_path, encoded_key = self.init_enviroment(code, preload, options=options)
|
||||
|
||||
try:
|
||||
# Setup environment
|
||||
env = {}
|
||||
|
||||
# Add proxy settings if configured
|
||||
if config.proxy.socks5:
|
||||
env["HTTPS_PROXY"] = config.proxy.socks5
|
||||
env["HTTP_PROXY"] = config.proxy.socks5
|
||||
elif config.proxy.https or config.proxy.http:
|
||||
if config.proxy.https:
|
||||
env["HTTPS_PROXY"] = config.proxy.https
|
||||
if config.proxy.http:
|
||||
env["HTTP_PROXY"] = config.proxy.http
|
||||
|
||||
# Add allowed syscalls if configured
|
||||
if config.allowed_syscalls:
|
||||
env["ALLOWED_SYSCALLS"] = ",".join(map(str, config.allowed_syscalls))
|
||||
|
||||
# Execute with Python interpreter
|
||||
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
config.python_path,
|
||||
script_path,
|
||||
LIB_PATH,
|
||||
encoded_key,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=env,
|
||||
cwd=LIB_PATH
|
||||
)
|
||||
|
||||
# Wait for completion with timeout
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
return ExecutionResult(
|
||||
stdout=stdout.decode('utf-8', errors='replace'),
|
||||
stderr=stderr.decode('utf-8', errors='replace'),
|
||||
exit_code=process.returncode
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
# Kill process on timeout
|
||||
try:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
except:
|
||||
pass
|
||||
|
||||
return ExecutionResult(
|
||||
stdout="",
|
||||
stderr="Execution timeout",
|
||||
exit_code=-1,
|
||||
error="Execution timeout"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup temporary file
|
||||
self.cleanup_temp_file(script_path)
|
||||
62
sandbox/app/core/runners/python/settings.py
Normal file
62
sandbox/app/core/runners/python/settings.py
Normal file
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
|
||||
from app.logger import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
RELEASE_LIB_PATH = "./lib/seccomp_python/target/release/libpython.so"
|
||||
LIB_PATH = "/var/sandbox/sandbox-python"
|
||||
LIB_NAME = "libpython.so"
|
||||
|
||||
try:
|
||||
with open(RELEASE_LIB_PATH, "rb") as f:
|
||||
_PYTHON_LIB = f.read()
|
||||
except:
|
||||
logger.critical("failed to load python lib")
|
||||
raise
|
||||
|
||||
|
||||
def check_lib_avaiable():
|
||||
return os.path.exists(os.path.join(LIB_PATH, LIB_NAME))
|
||||
|
||||
|
||||
def release_lib_binary(force_remove: bool):
|
||||
logger.info("init runtime enviroment")
|
||||
lib_file = os.path.join(LIB_PATH, LIB_NAME)
|
||||
if os.path.exists(lib_file):
|
||||
if force_remove:
|
||||
try:
|
||||
os.remove(lib_file)
|
||||
except OSError:
|
||||
logger.critical(f"failed to remove {os.path.join(LIB_PATH, LIB_NAME)}")
|
||||
raise
|
||||
|
||||
try:
|
||||
os.makedirs(LIB_PATH, mode=0o755, exist_ok=True)
|
||||
except OSError:
|
||||
logger.critical(f"failed to create {LIB_PATH}")
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(lib_file, "wb") as f:
|
||||
f.write(_PYTHON_LIB)
|
||||
os.chmod(lib_file, 0o755)
|
||||
except OSError:
|
||||
logger.critical(f"failed to write {lib_file}")
|
||||
raise
|
||||
else:
|
||||
try:
|
||||
os.makedirs(LIB_PATH, mode=0o755, exist_ok=True)
|
||||
except OSError:
|
||||
logger.critical(f"failed to create {LIB_PATH}")
|
||||
raise
|
||||
|
||||
try:
|
||||
with open(lib_file, "wb") as f:
|
||||
f.write(_PYTHON_LIB)
|
||||
os.chmod(lib_file, 0o755)
|
||||
except OSError:
|
||||
logger.critical(f"failed to write {lib_file}")
|
||||
raise
|
||||
|
||||
logger.info("python runner environment initialized")
|
||||
Reference in New Issue
Block a user