实现了渠道插件的全生命周期管理能力,包括: 1. 插件清单的加载、解析与存储 2. 依赖校验与拓扑排序安装/卸载 3. 插件发现与安全沙箱执行环境 4. 插件注册中心与目录管理能力
553 lines
16 KiB
Python
553 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import ast
|
||
import builtins
|
||
import importlib.util
|
||
import logging
|
||
import types
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class SandboxViolationError(Exception):
|
||
"""插件代码违反沙箱安全策略时抛出。"""
|
||
|
||
|
||
class PluginSandbox:
|
||
"""插件代码沙箱 — 限制动态加载的插件代码只能访问安全的内置函数和模块。
|
||
|
||
通过 AST 静态分析和运行时全局变量限制,防止插件执行危险操作:
|
||
- 禁止文件系统访问(open, file 等)
|
||
- 禁止系统命令执行(os.system, subprocess 等)
|
||
- 禁止网络访问(socket, urllib 等)
|
||
- 禁止导入未授权模块
|
||
- 禁止访问私有属性(__import__ 等)
|
||
"""
|
||
|
||
# 允许的内置函数白名单
|
||
ALLOWED_BUILTINS: set[str] = {
|
||
# 基础类型
|
||
"bool",
|
||
"bytearray",
|
||
"bytes",
|
||
"complex",
|
||
"dict",
|
||
"enumerate",
|
||
"filter",
|
||
"float",
|
||
"frozenset",
|
||
"int",
|
||
"list",
|
||
"map",
|
||
"memoryview",
|
||
"object",
|
||
"range",
|
||
"set",
|
||
"slice",
|
||
"str",
|
||
"tuple",
|
||
"zip",
|
||
# 常用函数
|
||
"abs",
|
||
"all",
|
||
"any",
|
||
"ascii",
|
||
"bin",
|
||
"callable",
|
||
"chr",
|
||
"classmethod",
|
||
"compile",
|
||
"delattr",
|
||
"dir",
|
||
"divmod",
|
||
"format",
|
||
"getattr",
|
||
"hasattr",
|
||
"hash",
|
||
"hex",
|
||
"id",
|
||
"isinstance",
|
||
"issubclass",
|
||
"iter",
|
||
"len",
|
||
"max",
|
||
"min",
|
||
"next",
|
||
"oct",
|
||
"ord",
|
||
"pow",
|
||
"property",
|
||
"repr",
|
||
"reversed",
|
||
"round",
|
||
"sorted",
|
||
"staticmethod",
|
||
"sum",
|
||
"vars",
|
||
# 异常处理
|
||
"BaseException",
|
||
"Exception",
|
||
"TypeError",
|
||
"ValueError",
|
||
"AttributeError",
|
||
"KeyError",
|
||
"IndexError",
|
||
"RuntimeError",
|
||
"NotImplementedError",
|
||
"StopIteration",
|
||
"GeneratorExit",
|
||
"SystemExit",
|
||
"ArithmeticError",
|
||
"LookupError",
|
||
"AssertionError",
|
||
"BufferError",
|
||
"EOFError",
|
||
"ImportError",
|
||
"ModuleNotFoundError",
|
||
"NameError",
|
||
"OSError",
|
||
"OverflowError",
|
||
"RecursionError",
|
||
"ReferenceError",
|
||
"SyntaxError",
|
||
"TimeoutError",
|
||
"ZeroDivisionError",
|
||
# 其他安全函数
|
||
"help",
|
||
"print",
|
||
"input",
|
||
"open", # 允许但会在模块级别重写为安全版本
|
||
"__build_class__",
|
||
"__name__",
|
||
"__doc__",
|
||
"__package__",
|
||
"__spec__",
|
||
"__annotations__",
|
||
"__dict__",
|
||
"__class__",
|
||
"__bases__",
|
||
"__mro__",
|
||
"__subclasses__",
|
||
"__init__",
|
||
"__new__",
|
||
"__setattr__",
|
||
"__getattribute__",
|
||
"__delattr__",
|
||
"__str__",
|
||
"__repr__",
|
||
"__eq__",
|
||
"__ne__",
|
||
"__lt__",
|
||
"__le__",
|
||
"__gt__",
|
||
"__ge__",
|
||
"__hash__",
|
||
"__bool__",
|
||
"__len__",
|
||
"__iter__",
|
||
"__next__",
|
||
"__contains__",
|
||
"__getitem__",
|
||
"__setitem__",
|
||
"__delitem__",
|
||
"__enter__",
|
||
"__exit__",
|
||
"__await__",
|
||
"__aiter__",
|
||
"__anext__",
|
||
"__call__",
|
||
}
|
||
|
||
# 禁止导入的模块黑名单
|
||
FORBIDDEN_MODULES: set[str] = {
|
||
"os",
|
||
"sys",
|
||
"subprocess",
|
||
"socket",
|
||
"urllib",
|
||
"urllib.request",
|
||
"urllib.parse",
|
||
"http",
|
||
"http.client",
|
||
"ftplib",
|
||
"smtplib",
|
||
"poplib",
|
||
"imaplib",
|
||
"telnetlib",
|
||
"ctypes",
|
||
"mmap",
|
||
"resource",
|
||
"signal",
|
||
"pty",
|
||
"pickle",
|
||
"cPickle",
|
||
"marshal",
|
||
"imp",
|
||
"builtins",
|
||
"importlib",
|
||
"importlib.util",
|
||
"importlib.machinery",
|
||
"types",
|
||
"inspect",
|
||
"code",
|
||
"codeop",
|
||
"compileall",
|
||
"py_compile",
|
||
"shutil",
|
||
"pathlib",
|
||
"tempfile",
|
||
"glob",
|
||
"fnmatch",
|
||
"linecache",
|
||
"traceback",
|
||
"warnings",
|
||
"contextlib",
|
||
"atexit",
|
||
"gc",
|
||
"sysconfig",
|
||
"platform",
|
||
"pwd",
|
||
"grp",
|
||
"spwd",
|
||
"crypt",
|
||
"termios",
|
||
"tty",
|
||
"fcntl",
|
||
"msvcrt",
|
||
"winreg",
|
||
"winsound",
|
||
"posix",
|
||
"nt",
|
||
"java",
|
||
"org",
|
||
"sun",
|
||
"com",
|
||
"net",
|
||
}
|
||
|
||
# 允许导入的模块白名单(如果为空则使用黑名单模式)
|
||
ALLOWED_MODULES: set[str] = {
|
||
"abc",
|
||
"array",
|
||
"base64",
|
||
"binascii",
|
||
"bisect",
|
||
"calendar",
|
||
"collections",
|
||
"collections.abc",
|
||
"copy",
|
||
"csv",
|
||
"dataclasses",
|
||
"datetime",
|
||
"decimal",
|
||
"enum",
|
||
"fractions",
|
||
"functools",
|
||
"heapq",
|
||
"html",
|
||
"html.entities",
|
||
"html.parser",
|
||
"io",
|
||
"itertools",
|
||
"json",
|
||
"keyword",
|
||
"math",
|
||
"numbers",
|
||
"operator",
|
||
"random",
|
||
"re",
|
||
"string",
|
||
"struct",
|
||
"textwrap",
|
||
"time",
|
||
"typing",
|
||
"unicodedata",
|
||
"uuid",
|
||
"weakref",
|
||
"xml.etree.ElementTree",
|
||
"xml.dom.minidom",
|
||
"xml.sax",
|
||
"xml.parsers.expat",
|
||
"zoneinfo",
|
||
"hashlib",
|
||
"hmac",
|
||
"secrets",
|
||
"statistics",
|
||
"stringprep",
|
||
"tokenize",
|
||
"trace",
|
||
"types",
|
||
"warnings",
|
||
"contextlib",
|
||
"copyreg",
|
||
"dis",
|
||
"opcode",
|
||
"pickletools",
|
||
"quopri",
|
||
"reprlib",
|
||
"shelve",
|
||
"symtable",
|
||
"tabnanny",
|
||
"timeit",
|
||
"token",
|
||
"turtle",
|
||
"webbrowser",
|
||
"xxlimited",
|
||
"xxlimited_35",
|
||
"_thread",
|
||
"_warnings",
|
||
"_weakref",
|
||
"_weakrefset",
|
||
"_io",
|
||
"_collections",
|
||
"_collections_abc",
|
||
"_functools",
|
||
"_operator",
|
||
"_stat",
|
||
"_string",
|
||
"_symtable",
|
||
"_typing",
|
||
}
|
||
|
||
def __init__(self) -> None:
|
||
self._violation_log: list[str] = []
|
||
|
||
@property
|
||
def violations(self) -> list[str]:
|
||
return list(self._violation_log)
|
||
|
||
def _safe_import(self, name: str, globals_: dict | None = None, locals_: dict | None = None, fromlist: tuple = (), level: int = 0) -> Any:
|
||
"""安全的 __import__ 替代函数,限制模块导入。"""
|
||
# 解析完整模块名
|
||
if level > 0 and globals_ is not None:
|
||
package = globals_.get("__package__", "")
|
||
if package:
|
||
if name:
|
||
full_name = f"{package}.{name}"
|
||
else:
|
||
full_name = package
|
||
else:
|
||
full_name = name
|
||
else:
|
||
full_name = name
|
||
|
||
# 检查是否命中黑名单
|
||
for forbidden in self.FORBIDDEN_MODULES:
|
||
if full_name == forbidden or full_name.startswith(forbidden + "."):
|
||
msg = f"Import of forbidden module '{full_name}' is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
# 检查是否在白名单中(如果白名单非空)
|
||
if self.ALLOWED_MODULES:
|
||
allowed = any(
|
||
full_name == mod or full_name.startswith(mod + ".")
|
||
for mod in self.ALLOWED_MODULES
|
||
)
|
||
if not allowed:
|
||
msg = f"Import of module '{full_name}' is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
# 使用原始 import 但限制在允许范围内
|
||
return __import__(name, globals_, locals_, fromlist, level)
|
||
|
||
def _safe_open(self, *args: Any, **kwargs: Any) -> Any:
|
||
"""安全的 open 替代函数,禁止文件系统访问。"""
|
||
msg = "File operations (open) are not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
def _safe_eval(self, *args: Any, **kwargs: Any) -> Any:
|
||
"""禁止 eval。"""
|
||
msg = "eval() is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
def _safe_exec(self, *args: Any, **kwargs: Any) -> Any:
|
||
"""禁止 exec。"""
|
||
msg = "exec() is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
def _safe_compile(self, *args: Any, **kwargs: Any) -> Any:
|
||
"""禁止 compile。"""
|
||
msg = "compile() is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
|
||
@staticmethod
|
||
def _is_private_attr_name(name: str) -> bool:
|
||
"""检查属性名是否为私有属性(双下划线前缀或 Python name mangling 模式)。"""
|
||
if name.startswith("__") and not name.endswith("__"):
|
||
return True
|
||
if "__" in name and not name.startswith("__") and not name.endswith("__"):
|
||
return True
|
||
return False
|
||
|
||
def _safe_getattr(self, obj: Any, name: str, default: Any = None) -> Any:
|
||
"""安全的 getattr,禁止访问双下划线私有属性及 name mangling 绕过。"""
|
||
if self._is_private_attr_name(name):
|
||
msg = f"Access to private attribute '{name}' is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
return getattr(obj, name, default)
|
||
|
||
def _safe_setattr(self, obj: Any, name: str, value: Any) -> None:
|
||
"""安全的 setattr,禁止修改双下划线私有属性及 name mangling 绕过。"""
|
||
if self._is_private_attr_name(name):
|
||
msg = f"Modification of private attribute '{name}' is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
setattr(obj, name, value)
|
||
|
||
def _safe_delattr(self, obj: Any, name: str) -> None:
|
||
"""安全的 delattr,禁止删除双下划线私有属性及 name mangling 绕过。"""
|
||
if self._is_private_attr_name(name):
|
||
msg = f"Deletion of private attribute '{name}' is not allowed in sandbox"
|
||
self._violation_log.append(msg)
|
||
raise SandboxViolationError(msg)
|
||
delattr(obj, name)
|
||
|
||
def _create_restricted_globals(self) -> dict[str, Any]:
|
||
"""创建受限的全局命名空间。"""
|
||
restricted_builtins = {}
|
||
for name in self.ALLOWED_BUILTINS:
|
||
if hasattr(builtins, name):
|
||
restricted_builtins[name] = getattr(builtins, name)
|
||
|
||
# 替换危险函数
|
||
restricted_builtins["__import__"] = self._safe_import
|
||
restricted_builtins["open"] = self._safe_open
|
||
restricted_builtins["eval"] = self._safe_eval
|
||
restricted_builtins["exec"] = self._safe_exec
|
||
restricted_builtins["compile"] = self._safe_compile
|
||
restricted_builtins["getattr"] = self._safe_getattr
|
||
restricted_builtins["setattr"] = self._safe_setattr
|
||
restricted_builtins["delattr"] = self._safe_delattr
|
||
|
||
return {"__builtins__": restricted_builtins}
|
||
|
||
@staticmethod
|
||
def validate_source(source: str, filename: str = "<sandbox>") -> ast.AST:
|
||
"""静态分析源代码 AST,检测危险语法结构(不依赖沙箱实例)。
|
||
|
||
可用于在任何动态代码执行前进行安全预检,无需创建沙箱实例。
|
||
仅执行 AST 级别的静态检查,不涉及运行时全局变量限制。
|
||
|
||
Args:
|
||
source: 要验证的 Python 源代码
|
||
filename: 用于错误报告的源文件名
|
||
|
||
Returns:
|
||
解析后的 AST 树
|
||
|
||
Raises:
|
||
SandboxViolationError: 代码包含危险语法结构时
|
||
"""
|
||
try:
|
||
tree = ast.parse(source, filename=filename)
|
||
except SyntaxError as e:
|
||
raise SandboxViolationError(f"Syntax error in source code: {e}") from e
|
||
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Call):
|
||
if isinstance(node.func, ast.Name) and node.func.id == "__import__":
|
||
raise SandboxViolationError(
|
||
"Direct __import__() calls are not allowed in sandbox"
|
||
)
|
||
|
||
if isinstance(node, ast.ImportFrom):
|
||
if node.names[0].name == "*":
|
||
raise SandboxViolationError(
|
||
"'from ... import *' is not allowed in sandbox"
|
||
)
|
||
|
||
return tree
|
||
|
||
def _validate_ast(self, source: str, filename: str = "<sandbox>") -> ast.AST:
|
||
"""静态分析 AST,检测危险语法结构(实例方法,会记录违规日志)。"""
|
||
try:
|
||
tree = self.validate_source(source, filename)
|
||
except SandboxViolationError as e:
|
||
self._violation_log.append(str(e))
|
||
raise
|
||
return tree
|
||
|
||
def execute(self, source: str, filename: str = "<sandbox>", extra_globals: dict[str, Any] | None = None) -> dict[str, Any]:
|
||
"""在沙箱中执行 Python 源代码,返回执行后的全局命名空间。
|
||
|
||
Args:
|
||
source: 要执行的 Python 源代码
|
||
filename: 用于错误报告的源文件名
|
||
extra_globals: 额外注入到全局命名空间的安全对象
|
||
|
||
Returns:
|
||
执行后的全局命名空间字典
|
||
|
||
Raises:
|
||
SandboxViolationError: 代码违反安全策略时
|
||
"""
|
||
# AST 静态分析
|
||
tree = self._validate_ast(source, filename)
|
||
|
||
# 编译为代码对象
|
||
code = compile(tree, filename, "exec")
|
||
|
||
# 创建受限全局环境
|
||
restricted_globals = self._create_restricted_globals()
|
||
if extra_globals:
|
||
# 只允许注入安全的额外对象
|
||
for key, value in extra_globals.items():
|
||
if not key.startswith("__") or key in ("__name__", "__file__", "__doc__", "__package__", "__spec__"):
|
||
restricted_globals[key] = value
|
||
|
||
# 执行代码
|
||
exec(code, restricted_globals) # noqa: S102
|
||
|
||
return restricted_globals
|
||
|
||
def load_module_from_source(self, source: str, module_name: str, filename: str = "<sandbox>") -> types.ModuleType:
|
||
"""在沙箱中从源代码创建模块。
|
||
|
||
Args:
|
||
source: 模块源代码
|
||
module_name: 模块名称
|
||
filename: 用于错误报告的源文件名
|
||
|
||
Returns:
|
||
创建的模块对象
|
||
"""
|
||
tree = self._validate_ast(source, filename)
|
||
code = compile(tree, filename, "exec")
|
||
|
||
module = types.ModuleType(module_name)
|
||
module.__dict__.update(self._create_restricted_globals())
|
||
module.__name__ = module_name
|
||
module.__file__ = filename
|
||
|
||
exec(code, module.__dict__) # noqa: S102
|
||
|
||
return module
|
||
|
||
def load_module_from_file(self, file_path: str | Path, module_name: str | None = None) -> types.ModuleType:
|
||
"""在沙箱中从文件加载模块。
|
||
|
||
Args:
|
||
file_path: 文件路径
|
||
module_name: 模块名称(默认为文件名)
|
||
|
||
Returns:
|
||
创建的模块对象
|
||
"""
|
||
file_path = Path(file_path)
|
||
if not file_path.exists():
|
||
raise FileNotFoundError(f"Plugin file not found: {file_path}")
|
||
|
||
source = file_path.read_text(encoding="utf-8")
|
||
name = module_name or file_path.stem
|
||
|
||
return self.load_module_from_source(source, name, str(file_path))
|