104 lines
3.8 KiB
Python

# -*- 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 object method calls are forbidden.
SQL allows read-only statements only (SELECT/SHOW/DESCRIBE/EXPLAIN).
"""
import ast
import re
# 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',
}
# 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,
)
# 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_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.
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'}
try:
tree = ast.parse(content, mode='exec')
except SyntaxError as e:
return {'code': 1, 'message': 'syntax error: %s' % 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.Call):
func = node.func
if isinstance(func, ast.Attribute):
return {'code': 1, 'message': 'object method call is forbidden'}
if isinstance(func, ast.Name) and func.id not in ALLOWED_BUILTINS:
return {'code': 1, 'message': 'forbidden call: %s' % func.id}
return {'code': 0, 'message': 'ok'}
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.
"""
builtins_ns = {}
for k in ALLOWED_BUILTINS:
if k in __builtins__:
builtins_ns[k] = __builtins__[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, '<script>', 'exec')
exec(code, namespace)
return namespace.get('result')
def validate_sql(content):
"""Validate sql script: single read-only statement only.
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'}
sql = content.strip()
if sql.rstrip().endswith(';'):
sql = sql.rstrip()[:-1]
if ';' in sql:
return {'code': 1, 'message': 'multi statement is forbidden'}
if not SQL_READONLY_RE.match(sql):
return {'code': 1, 'message': 'only read-only sql is allowed'}
if SQL_FORBIDDEN_RE.search(sql):
return {'code': 1, 'message': 'write or dangerous sql is forbidden'}
return {'code': 0, 'message': 'ok'}
def validate(script_type, content):
"""Dispatch validation by script_type: 0=Python, 1=SQL."""
if script_type == '0':
return validate_python(content)
if script_type == '1':
return validate_sql(content)
return {'code': 1, 'message': 'invalid script_type, only 0=Python or 1=SQL allowed'}