approve: 修复当前迭代 1 个功能 Bug(元景项目-初始迭代)
This commit is contained in:
parent
3c53fc93c7
commit
91bb3be8a6
@ -3,7 +3,15 @@
|
|||||||
|
|
||||||
Restricted script execution for script_type 0=Python / 1=SQL.
|
Restricted script execution for script_type 0=Python / 1=SQL.
|
||||||
Python runs in a whitelisted-builtins namespace; imports, classes, lambdas,
|
Python runs in a whitelisted-builtins namespace; imports, classes, lambdas,
|
||||||
async/await, generators and object method calls are forbidden.
|
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).
|
SQL allows read-only statements only (SELECT/SHOW/DESCRIBE/EXPLAIN).
|
||||||
"""
|
"""
|
||||||
import ast
|
import ast
|
||||||
@ -17,11 +25,35 @@ ALLOWED_BUILTINS = {
|
|||||||
'tuple', 'type', 'zip',
|
'tuple', 'type', 'zip',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 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',
|
||||||
|
}
|
||||||
|
|
||||||
# ast node types that are never allowed in a script
|
# ast node types that are never allowed in a script
|
||||||
FORBIDDEN_NODES = (
|
FORBIDDEN_NODES = (
|
||||||
ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda,
|
ast.Import, ast.ImportFrom, ast.ClassDef, ast.Lambda,
|
||||||
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await,
|
ast.AsyncFunctionDef, ast.AsyncFor, ast.AsyncWith, ast.Await,
|
||||||
ast.Yield, ast.YieldFrom, ast.Global,
|
ast.Yield, ast.YieldFrom, ast.Global, ast.Nonlocal,
|
||||||
)
|
)
|
||||||
|
|
||||||
# sql keywords that indicate write / dangerous statements
|
# sql keywords that indicate write / dangerous statements
|
||||||
@ -36,7 +68,25 @@ SQL_READONLY_RE = re.compile(r'^\s*(select|show|describe|desc|explain)\b', re.IG
|
|||||||
def validate_python(content):
|
def validate_python(content):
|
||||||
"""Validate python script syntax and forbidden constructs.
|
"""Validate python script syntax and forbidden constructs.
|
||||||
|
|
||||||
Returns {'code': 0, 'message': 'ok'} on success else {'code': 1, 'message': ...}.
|
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():
|
if not content or not content.strip():
|
||||||
return {'code': 1, 'message': 'content is required'}
|
return {'code': 1, 'message': 'content is required'}
|
||||||
@ -44,15 +94,42 @@ def validate_python(content):
|
|||||||
tree = ast.parse(content, mode='exec')
|
tree = ast.parse(content, mode='exec')
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
return {'code': 1, 'message': 'syntax error: %s' % e}
|
return {'code': 1, 'message': 'syntax error: %s' % e}
|
||||||
|
|
||||||
|
# rule 1 + 2: forbidden node kinds and dunder attribute access
|
||||||
for node in ast.walk(tree):
|
for node in ast.walk(tree):
|
||||||
if isinstance(node, FORBIDDEN_NODES):
|
if isinstance(node, FORBIDDEN_NODES):
|
||||||
return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__}
|
return {'code': 1, 'message': 'forbidden construct: %s' % type(node).__name__}
|
||||||
if isinstance(node, ast.Call):
|
if isinstance(node, ast.Attribute) and node.attr.startswith('__'):
|
||||||
func = node.func
|
return {'code': 1, 'message': 'forbidden attribute access: %s' % node.attr}
|
||||||
if isinstance(func, ast.Attribute):
|
|
||||||
return {'code': 1, 'message': 'object method call is forbidden'}
|
# rule 3: calls
|
||||||
if isinstance(func, ast.Name) and func.id not in ALLOWED_BUILTINS:
|
for node in ast.walk(tree):
|
||||||
|
if not isinstance(node, ast.Call):
|
||||||
|
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}
|
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'}
|
return {'code': 0, 'message': 'ok'}
|
||||||
|
|
||||||
|
|
||||||
@ -60,11 +137,14 @@ def execute_python(content, input_ns=None):
|
|||||||
"""Execute python script in a restricted namespace.
|
"""Execute python script in a restricted namespace.
|
||||||
|
|
||||||
The script may assign a final variable named `result` which is returned.
|
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 = {}
|
builtins_ns = {}
|
||||||
for k in ALLOWED_BUILTINS:
|
for k in ALLOWED_BUILTINS:
|
||||||
if k in __builtins__:
|
if k in blt:
|
||||||
builtins_ns[k] = __builtins__[k]
|
builtins_ns[k] = blt[k]
|
||||||
namespace = {'__builtins__': builtins_ns}
|
namespace = {'__builtins__': builtins_ns}
|
||||||
if input_ns:
|
if input_ns:
|
||||||
for k, v in input_ns.items():
|
for k, v in input_ns.items():
|
||||||
|
|||||||
@ -218,7 +218,12 @@ async def validate_script_api(request, ns):
|
|||||||
|
|
||||||
|
|
||||||
async def execute_script(request, ns):
|
async def execute_script(request, ns):
|
||||||
"""Execute a script by id OR by inline content+script_type."""
|
"""Execute a script by id OR by inline content+script_type.
|
||||||
|
|
||||||
|
Python scripts receive the business params (everything except control
|
||||||
|
fields) as a ``params`` dict in the namespace, so scripts can read input
|
||||||
|
via ``params.get('key')``.
|
||||||
|
"""
|
||||||
data = _clean_ns(ns)
|
data = _clean_ns(ns)
|
||||||
dbname = _dbname()
|
dbname = _dbname()
|
||||||
sid = (data.get('id') or '').strip()
|
sid = (data.get('id') or '').strip()
|
||||||
@ -246,7 +251,9 @@ async def execute_script(request, ns):
|
|||||||
return _err(1, v['message'], 'content')
|
return _err(1, v['message'], 'content')
|
||||||
if stype == '0':
|
if stype == '0':
|
||||||
try:
|
try:
|
||||||
out = engine.execute_python(content)
|
ctrl = {'id', 'content', 'script_type', 'page', 'rows', 'keyword'}
|
||||||
|
params = {k: v for k, v in data.items() if k not in ctrl}
|
||||||
|
out = engine.execute_python(content, input_ns={'params': params})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
exception('script_engine.execute_script python error: %s' % e)
|
exception('script_engine.execute_script python error: %s' % e)
|
||||||
return _err(1, 'execute failed: %s' % e, 'content')
|
return _err(1, 'execute failed: %s' % e, 'content')
|
||||||
|
|||||||
113
script_engine/tests/test_validate_python.py
Normal file
113
script_engine/tests/test_validate_python.py
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""Regression tests for script_engine.validate_python (Bug kGZMpApWK3I1ed4OHpKQ0).
|
||||||
|
|
||||||
|
Validates that:
|
||||||
|
* params.get() and other whitelisted method calls now PASS validation
|
||||||
|
* every non-whitelisted method call is rejected (100% interception)
|
||||||
|
* dunder attribute access (sandbox escapes) is rejected
|
||||||
|
* attribute assignment / deletion is rejected
|
||||||
|
* forbidden constructs (import/class/lambda/async/...) are still rejected
|
||||||
|
|
||||||
|
Run (from modules/script_engine):
|
||||||
|
python script_engine/tests/test_validate_python.py
|
||||||
|
or:
|
||||||
|
python -m pytest script_engine/tests/test_validate_python.py -v
|
||||||
|
|
||||||
|
The engine module is loaded by file path so this test does not require the
|
||||||
|
ahserver/sqlor runtime environment.
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
|
||||||
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
_ENGINE_PATH = os.path.join(os.path.dirname(_HERE), 'engine.py')
|
||||||
|
_spec = importlib.util.spec_from_file_location('_se_engine', _ENGINE_PATH)
|
||||||
|
_se_engine = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(_se_engine)
|
||||||
|
|
||||||
|
validate_python = _se_engine.validate_python
|
||||||
|
execute_python = _se_engine.execute_python
|
||||||
|
|
||||||
|
_PASS = 0
|
||||||
|
_FAIL = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(content, expect_ok, why):
|
||||||
|
"""Assert validate_python(content) == expect_ok and print the outcome."""
|
||||||
|
global _PASS, _FAIL
|
||||||
|
r = validate_python(content)
|
||||||
|
ok = r.get('code') == 0
|
||||||
|
status = 'PASS' if ok == expect_ok else 'FAIL'
|
||||||
|
if ok == expect_ok:
|
||||||
|
_PASS += 1
|
||||||
|
else:
|
||||||
|
_FAIL += 1
|
||||||
|
print('%-4s expect_ok=%-5s %-52s -> %s' % (
|
||||||
|
status, expect_ok, why, r.get('message')))
|
||||||
|
|
||||||
|
|
||||||
|
def test_positive():
|
||||||
|
# the reported bug: params.get() must pass validation
|
||||||
|
check("result = params.get('age', 0) + 1", True, 'params.get')
|
||||||
|
check("result = params.get('name', '').strip().upper()", True, 'str chain on params.get')
|
||||||
|
check("result = sum(params.get('nums', []))", True, 'sum + params.get')
|
||||||
|
check("r = 0\nfor i in range(5):\n r += i\nresult = r", True, 'loop + range')
|
||||||
|
check("d = {'a': 1}\nd['b'] = 2\nresult = d.get('b')", True, 'dict subscript assign')
|
||||||
|
check("result = [x * 2 for x in range(3)]", True, 'list comprehension')
|
||||||
|
check("result = params.get('a', '').replace('x', 'y')", True, 'str.replace whitelisted')
|
||||||
|
check("result = params.get('l', []).append(1) or params.get('l')", True, 'list.append whitelisted')
|
||||||
|
|
||||||
|
|
||||||
|
def test_negative():
|
||||||
|
# forbidden constructs (must STILL be rejected)
|
||||||
|
check("import os\nresult = 1", False, 'import')
|
||||||
|
check("from os import system\nresult = 1", False, 'from-import')
|
||||||
|
check("class X:\n pass", False, 'class def')
|
||||||
|
check("f = lambda x: x", False, 'lambda')
|
||||||
|
check("async def f():\n pass", False, 'async def')
|
||||||
|
check("def g():\n yield 1", False, 'yield')
|
||||||
|
# non-whitelisted builtin calls
|
||||||
|
check("result = open('/etc/passwd')", False, 'open not whitelisted')
|
||||||
|
check("result = os.system('id')", False, 'os.system (name call)')
|
||||||
|
check("result = eval('1+1')", False, 'eval not whitelisted')
|
||||||
|
check("result = exec('x=1')", False, 'exec not whitelisted')
|
||||||
|
check("result = __import__('os')", False, '__import__ not whitelisted')
|
||||||
|
# dunder attribute access (sandbox escapes)
|
||||||
|
check("result = params.__class__", False, 'dunder read __class__')
|
||||||
|
check("result = ().__class__.__mro__", False, 'dunder escape chain')
|
||||||
|
check("result = params.get.__self__", False, 'dunder __self__')
|
||||||
|
check("result = (lambda: 1).__globals__", False, 'dunder __globals__')
|
||||||
|
# non-whitelisted object methods (100% interception)
|
||||||
|
check("result = params.popitem()", False, 'popitem not whitelisted')
|
||||||
|
check("result = params.foo()", False, 'arbitrary method foo')
|
||||||
|
check("result = params.get('a').isidentifier()", False, 'isidentifier not whitelisted')
|
||||||
|
check("result = [].index.__call__()", False, '__call__ dunder')
|
||||||
|
# attribute assignment / deletion
|
||||||
|
check("obj = params\nobj.x = 1", False, 'attribute assign')
|
||||||
|
check("obj = params\nobj.x += 1", False, 'attribute aug-assign')
|
||||||
|
check("obj = params\ndel obj.x", False, 'attribute delete')
|
||||||
|
# other call expressions
|
||||||
|
check("result = params['__class__']()", False, 'subscript call expression')
|
||||||
|
|
||||||
|
|
||||||
|
def test_execute():
|
||||||
|
out = execute_python("result = params.get('a', 0) + 10", {'params': {'a': 5}})
|
||||||
|
assert out == 15, 'execute_python params.get -> %r' % out
|
||||||
|
print('PASS execute_python params.get({a:5})+10 ->', out)
|
||||||
|
global _PASS
|
||||||
|
_PASS += 1
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
test_positive()
|
||||||
|
test_negative()
|
||||||
|
test_execute()
|
||||||
|
print('-' * 78)
|
||||||
|
print('TOTAL: %d passed, %d failed' % (_PASS, _FAIL))
|
||||||
|
if _FAIL:
|
||||||
|
raise SystemExit(1)
|
||||||
|
print('ALL TESTS PASSED')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Loading…
x
Reference in New Issue
Block a user