104 lines
3.8 KiB
Python
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'}
|