diff --git a/init/data.json b/init/data.json index 886c678..5d0a4d8 100644 --- a/init/data.json +++ b/init/data.json @@ -1,20 +1,20 @@ { - "appcodes": [ - { - "parentid": "script_type", - "parentname": "脚本类型", - "items": [ - {"k": "0", "v": "Python"}, - {"k": "1", "v": "SQL"} - ] - }, - { - "parentid": "script_status", - "parentname": "脚本状态", - "items": [ - {"k": "0", "v": "停用"}, - {"k": "1", "v": "启用"} - ] - } - ] + "appcodes": [ + { + "parentid": "script_type", + "parentname": "脚本类型", + "items": [ + {"k": "0", "v": "Python"}, + {"k": "1", "v": "SQL"} + ] + }, + { + "parentid": "script_status", + "parentname": "脚本状态", + "items": [ + {"k": "0", "v": "停用"}, + {"k": "1", "v": "启用"} + ] + } + ] } diff --git a/json/script_engine.json b/json/script_engine.json index bf2c14e..6d6830d 100644 --- a/json/script_engine.json +++ b/json/script_engine.json @@ -1,23 +1,38 @@ { - "tblname": "script_engine", - "title": "脚本管理", - "params": { - "sortby": ["created_at desc"], - "new_data_url": "{{entire_url('../api/script_create.dspy')}}", - "update_data_url": "{{entire_url('../api/script_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/script_delete.dspy')}}", - "editable": { - "new_data_url": "{{entire_url('../api/script_create.dspy')}}", - "update_data_url": "{{entire_url('../api/script_update.dspy')}}", - "delete_data_url": "{{entire_url('../api/script_delete.dspy')}}" - }, - "browserfields": { - "exclouded": ["content"], - "alters": { - "script_type": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}"}, - "status": {"uitype": "code", "dataurl": "{{entire_url('../api/get_search_status.dspy')}}"} - } - }, - "editexclouded": ["id", "created_at", "updated_at"] - } + "tblname": "script_engine", + "title": "脚本管理", + "params": { + "sortby": ["created_at desc"], + "browserfields": { + "exclouded": ["id", "content"], + "alters": { + "script_type": { + "uitype": "code", + "dataurl": "{{entire_url('../api/get_search_script_type.dspy')}}" + }, + "status": { + "uitype": "code", + "dataurl": "{{entire_url('../api/get_search_status.dspy')}}" + } + } + }, + "editexclouded": ["id", "created_at", "updated_at"], + "data_filter": { + "AND": [ + {"field": "script_name", "op": "LIKE", "var": "script_name"}, + {"field": "script_type", "op": "=", "var": "script_type"}, + {"field": "status", "op": "=", "var": "status"} + ] + }, + "filter_labels": { + "script_name": "脚本名称", + "script_type": "脚本类型", + "status": "状态" + }, + "editable": { + "new_data_url": "{{entire_url('../api/script_create.dspy')}}", + "update_data_url": "{{entire_url('../api/script_update.dspy')}}", + "delete_data_url": "{{entire_url('../api/script_delete.dspy')}}" + } + } } diff --git a/models/script_engine.json b/models/script_engine.json index 3ffe0eb..c4d947a 100644 --- a/models/script_engine.json +++ b/models/script_engine.json @@ -1,28 +1,94 @@ { - "summary": [ - { - "name": "script_engine", - "title": "脚本表", - "primary": ["id"], - "catelog": "entity" - } - ], - "fields": [ - {"name": "id", "title": "主键ID", "type": "str", "length": 32, "nullable": "no"}, - {"name": "script_name", "title": "脚本名称", "type": "str", "length": 100, "nullable": "no"}, - {"name": "script_type", "title": "脚本类型", "type": "str", "length": 32, "nullable": "no", "default": "0"}, - {"name": "content", "title": "脚本内容", "type": "text", "nullable": "no"}, - {"name": "description", "title": "描述", "type": "str", "length": 255}, - {"name": "status", "title": "状态", "type": "str", "length": 32, "nullable": "no", "default": "1"}, - {"name": "created_at", "title": "创建时间", "type": "timestamp", "nullable": "no"}, - {"name": "updated_at", "title": "更新时间", "type": "timestamp", "nullable": "no"} - ], - "indexes": [ - {"name": "idx_script_name", "idxtype": "index", "idxfields": ["script_name"]}, - {"name": "idx_script_type", "idxtype": "index", "idxfields": ["script_type"]} - ], - "codes": [ - {"field": "script_type", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_type'"}, - {"field": "status", "table": "appcodes_kv", "valuefield": "k", "textfield": "v", "cond": "parentid='script_status'"} - ] + "summary": [ + { + "name": "script_engine", + "title": "脚本表", + "primary": ["id"], + "catelog": "entity" + } + ], + "fields": [ + { + "name": "id", + "title": "主键ID", + "type": "str", + "length": 32, + "nullable": "no" + }, + { + "name": "script_name", + "title": "脚本名称", + "type": "str", + "length": 100, + "nullable": "no" + }, + { + "name": "script_type", + "title": "脚本类型", + "type": "str", + "length": 32, + "nullable": "no", + "default": "0" + }, + { + "name": "content", + "title": "脚本内容", + "type": "text", + "nullable": "no" + }, + { + "name": "description", + "title": "描述", + "type": "str", + "length": 255 + }, + { + "name": "status", + "title": "状态", + "type": "str", + "length": 32, + "nullable": "no", + "default": "1" + }, + { + "name": "created_at", + "title": "创建时间", + "type": "timestamp", + "nullable": "no" + }, + { + "name": "updated_at", + "title": "更新时间", + "type": "timestamp", + "nullable": "no" + } + ], + "indexes": [ + { + "name": "idx_script_name", + "idxtype": "index", + "idxfields": ["script_name"] + }, + { + "name": "idx_script_type", + "idxtype": "index", + "idxfields": ["script_type"] + } + ], + "codes": [ + { + "field": "script_type", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='script_type'" + }, + { + "field": "status", + "table": "appcodes_kv", + "valuefield": "k", + "textfield": "v", + "cond": "parentid='script_status'" + } + ] } diff --git a/script_engine/__init__.py b/script_engine/__init__.py index 76ec880..ddebcc2 100644 --- a/script_engine/__init__.py +++ b/script_engine/__init__.py @@ -1,24 +1,36 @@ -# -*- coding: utf-8 -*- -"""script_engine package: script table CRUD + execute/validate script API. - -Public async functions MUST be imported here so that .dspy files can call them -as pre-loaded globals after load_script_engine() registration. -""" -from .engine import validate, validate_python, validate_sql, execute_python +"""script_engine package: logic programming (script/rule engine) module.""" +from .engine import ( + execute_python, + validate_python_source, + validate_sql, +) from .init import ( create_script, - update_script, + create_scripts, delete_script, + delete_scripts, + execute_script, get_script, list_scripts, - execute_script, - validate_script_api, load_script_engine, + update_script, + update_scripts, + validate_script_api, ) __all__ = [ - 'validate', 'validate_python', 'validate_sql', 'execute_python', - 'create_script', 'update_script', 'delete_script', 'get_script', - 'list_scripts', 'execute_script', 'validate_script_api', + 'create_script', + 'create_scripts', + 'delete_script', + 'delete_scripts', + 'execute_python', + 'execute_script', + 'get_script', + 'list_scripts', 'load_script_engine', + 'update_script', + 'update_scripts', + 'validate_python_source', + 'validate_script_api', + 'validate_sql', ] diff --git a/script_engine/engine.py b/script_engine/engine.py index 33ab864..0c126c4 100644 --- a/script_engine/engine.py +++ b/script_engine/engine.py @@ -1,183 +1,146 @@ # -*- coding: utf-8 -*- """script_engine execution engine. -Restricted script execution for script_type 0=Python / 1=SQL. -Python runs in a whitelisted-builtins namespace; imports, classes, lambdas, -async/await, generators and unsafe attribute access are forbidden. - -Method calls (ast.Attribute) are allowed ONLY when the method name is on the -ALLOWED_METHODS whitelist and is not a dunder (``__x__``) method. This keeps -the natural script pattern ``params.get('key')`` working (Bug -kGZMpApWK3I1ed4OHpKQ0) while still blocking every other method call and any -``__``-prefixed attribute -- sandbox-escape attempts such as -``().__class__.__mro__`` / ``x.__globals__`` are rejected. - -SQL allows read-only statements only (SELECT/SHOW/DESCRIBE/EXPLAIN). +Python scripts run in a restricted namespace: only whitelisted builtin +functions are callable; import / class / lambda / async / await and +object method calls are forbidden (AST validation). +SQL scripts are read-only single statements (SELECT/SHOW/DESCRIBE/EXPLAIN); +write statements, multi-statement and dangerous keywords are rejected. """ import ast +import builtins +import contextlib +import io import re +import traceback -# whitelisted python builtins (safe pure functions only) -ALLOWED_BUILTINS = { - 'abs', 'all', 'any', 'bool', 'dict', 'divmod', 'enumerate', 'filter', - 'float', 'int', 'isinstance', 'len', 'list', 'map', 'max', 'min', 'ord', - 'chr', 'pow', 'range', 'repr', 'round', 'set', 'sorted', 'str', 'sum', - 'tuple', 'type', 'zip', +# whitelisted builtin names callable inside python scripts +PY_WHITELIST = { + 'abs', 'all', 'any', 'bin', 'bool', 'bytearray', 'bytes', 'callable', + 'chr', 'complex', 'dict', 'divmod', 'enumerate', 'filter', 'float', + 'format', 'frozenset', 'hash', 'hex', 'int', 'isinstance', 'issubclass', + 'iter', 'len', 'list', 'map', 'max', 'min', 'next', 'oct', 'ord', 'pow', + 'print', 'range', 'repr', 'reversed', 'round', 'set', 'slice', 'sorted', + 'str', 'sum', 'tuple', 'zip', + 'True', 'False', 'None', } -# whitelisted object methods. The receiver is always an object that already -# lives in the script namespace (a dict/list/str/int/float injected as -# ``params`` or created by the script itself); there is no way to reach -# os/subprocess/import machinery because dunder access is blocked and the -# builtins namespace is restricted to ALLOWED_BUILTINS. Every method NOT in -# this list is rejected (100% coverage enforced in validate_python). -ALLOWED_METHODS = { - # dict / mapping - 'get', 'keys', 'values', 'items', 'pop', 'setdefault', 'update', - 'clear', 'copy', - # str (pure transformations / queries) - 'strip', 'lstrip', 'rstrip', 'lower', 'upper', 'title', 'capitalize', - 'replace', 'split', 'rsplit', 'splitlines', 'join', 'format', - 'format_map', 'startswith', 'endswith', 'find', 'rfind', 'index', - 'rindex', 'count', 'isdigit', 'isalpha', 'isalnum', 'isspace', - 'isupper', 'islower', 'istitle', 'encode', 'decode', 'zfill', - 'center', 'ljust', 'rjust', - # list - 'append', 'extend', 'insert', 'remove', 'pop', 'sort', 'reverse', - 'index', 'count', 'copy', 'clear', - # int / float - 'bit_length', 'is_integer', +# builtins that are never allowed even if listed elsewhere +PY_FORBIDDEN_BUILTINS = { + '__import__', 'eval', 'exec', 'compile', 'open', 'input', 'globals', + 'locals', 'vars', 'dir', 'getattr', 'setattr', 'delattr', 'hasattr', + 'memoryview', 'breakpoint', 'exit', 'quit', 'help', 'type', 'object', } -# ast node types that are never allowed in a script -FORBIDDEN_NODES = ( - ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda, - ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await, - ast.Yield, ast.YieldFrom, ast.Global, ast.Nonlocal, +# AST node types that are forbidden in python scripts +PY_FORBIDDEN_NODE_TYPES = ( + ast.Import, + ast.ImportFrom, + ast.ClassDef, + ast.Lambda, + ast.AsyncFunctionDef, + ast.AsyncFor, + ast.AsyncWith, + ast.Await, + ast.Global, + ast.Nonlocal, + ast.Delete, + ast.Yield, + ast.YieldFrom, ) -# sql keywords that indicate write / dangerous statements -SQL_FORBIDDEN_RE = re.compile( - r'\b(insert|update|delete|drop|alter|create|truncate|grant|revoke|' - r'replace|call|exec|execute|merge|rename|lock|unlock|set|use)\b', - re.IGNORECASE, +# SQL statement starters that are read-only +SQL_READONLY_FIRST = ('SELECT', 'SHOW', 'DESCRIBE', 'DESC', 'EXPLAIN') + +# SQL keywords that indicate write / execution / privilege operations +SQL_FORBIDDEN_KEYWORDS = ( + 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER', 'CREATE', 'TRUNCATE', + 'GRANT', 'REVOKE', 'MERGE', 'REPLACE', 'RENAME', 'CALL', 'LOAD', + 'LOCK', 'UNLOCK', 'SET', 'USE', 'BEGIN', 'COMMIT', 'ROLLBACK', + 'SAVEPOINT', 'INTO', 'OUTFILE', 'INFILE', 'PREPARE', 'EXECUTE', + 'DEALLOCATE', 'PROCEDURE', 'TRIGGER', 'EVENT', 'TEMPORARY', 'PARTITION', ) -SQL_READONLY_RE = re.compile(r'^\s*(select|show|describe|desc|explain)\b', re.IGNORECASE) -def validate_python(content): - """Validate python script syntax and forbidden constructs. +def _has_keyword(text, kw): + return re.search(r'\b' + kw + r'\b', text, re.IGNORECASE) is not None - Safety rules (in order of evaluation): - 1. forbidden AST node kinds: import/import-from/class/lambda/async/ - await/yield/global/nonlocal - 2. dunder (``__x__``) attribute access is ALWAYS rejected -- this is - the sandbox-escape guard (``().__class__``, ``x.__globals__``, ...) - 3. calls: - - bare name -> the name must be in ALLOWED_BUILTINS - - object method (ast.Attribute) -> the method name must be in - ALLOWED_METHODS (and, per rule 2, never dunder); anything else is - rejected (100% interception of non-whitelisted methods) - - any other call expression (subscript call, call result call, ...) - is rejected - 4. attribute assignment / deletion (``obj.attr = ...``, - ``del obj.attr``) is rejected -- scripts must not mutate object - attributes. Subscript assignment on plain dicts/lists stays allowed - because it only touches objects already in the script namespace. - Returns {'code': 0, 'message': 'ok'} on success else - {'code': 1, 'message': ...}. - """ - if not content or not content.strip(): - return {'code': 1, 'message': 'content is required'} +def validate_python_source(content): + """AST-validate python source. Returns (ok, message).""" + if not content or not str(content).strip(): + return False, 'python content is empty' try: - tree = ast.parse(content, mode='exec') + tree = ast.parse(str(content)) except SyntaxError as e: - return {'code': 1, 'message': 'syntax error: %s' % e} - - # rule 1 + 2: forbidden node kinds and dunder attribute access + return False, 'syntax error: %s' % str(e) for node in ast.walk(tree): - if isinstance(node, FORBIDDEN_NODES): - return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__} - if isinstance(node, ast.Attribute) and node.attr.startswith('__'): - return {'code': 1, 'message': 'forbidden attribute access: %s' % node.attr} + if isinstance(node, PY_FORBIDDEN_NODE_TYPES): + return False, 'forbidden python syntax: ' + type(node).__name__ + if isinstance(node, ast.Call): + fn = node.func + if isinstance(fn, ast.Name): + if fn.id in PY_FORBIDDEN_BUILTINS: + return False, 'forbidden builtin: ' + fn.id + if fn.id not in PY_WHITELIST: + return False, 'unknown function: ' + fn.id + elif isinstance(fn, ast.Attribute): + return False, 'object method call is not allowed' + return True, '' - # rule 3: calls - for node in ast.walk(tree): - if not isinstance(node, ast.Call): + +def execute_python(content, params=None): + """Execute python source in a restricted namespace. + + Script may read `params` dict and set `result`; stdout is captured. + Returns {code, message, field, detail, data}. + """ + ok, msg = validate_python_source(content) + if not ok: + return {'code': 1, 'message': msg, 'field': 'content', 'detail': ''} + safe_builtins = {} + for name in PY_WHITELIST: + if name in ('True', 'False', 'None'): continue - func = node.func - if isinstance(func, ast.Name): - if func.id not in ALLOWED_BUILTINS: - return {'code': 1, 'message': 'forbidden call: %s' % func.id} - elif isinstance(func, ast.Attribute): - if func.attr not in ALLOWED_METHODS: - return {'code': 1, 'message': 'forbidden method call: %s' % func.attr} - else: - return {'code': 1, 'message': 'forbidden call expression'} - - # rule 4: attribute assignment / deletion - for node in ast.walk(tree): - targets = [] - if isinstance(node, ast.Assign): - targets = node.targets - elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): - targets = [node.target] - elif isinstance(node, ast.Delete): - targets = node.targets - for t in targets: - for sub in ast.walk(t): - if isinstance(sub, ast.Attribute): - return {'code': 1, 'message': 'attribute assignment is forbidden'} - - return {'code': 0, 'message': 'ok'} + if hasattr(builtins, name): + safe_builtins[name] = getattr(builtins, name) + safe_builtins['__builtins__'] = safe_builtins + ns = {'params': dict(params or {})} + ns.update(safe_builtins) + buf = io.StringIO() + try: + with contextlib.redirect_stdout(buf): + exec(compile(str(content), '', 'exec'), ns) + return { + 'code': 0, + 'message': 'ok', + 'field': '', + 'detail': '', + 'data': {'result': ns.get('result'), 'output': buf.getvalue()}, + } + except Exception as e: + return { + 'code': 1, + 'message': str(e), + 'field': '', + 'detail': traceback.format_exc(), + } -def execute_python(content, input_ns=None): - """Execute python script in a restricted namespace. - - The script may assign a final variable named `result` which is returned. - ``input_ns`` (e.g. {'params': {...}}) is injected into the namespace so - scripts can read business inputs with ``params.get('key')``. - """ - blt = __builtins__ if isinstance(__builtins__, dict) else vars(__builtins__) - builtins_ns = {} - for k in ALLOWED_BUILTINS: - if k in blt: - builtins_ns[k] = blt[k] - namespace = {'__builtins__': builtins_ns} - if input_ns: - for k, v in input_ns.items(): - if k and k != '__builtins__': - namespace[k] = v - code = compile(content, '