23

我有一个网络应用程序。作为其中的一部分,我需要应用程序的用户能够编写(或复制和粘贴)非常简单的脚本来针对他们的数据运行。

脚本真的可以很简单,性能只是最次要的问题。我的意思是脚本的复杂性示例如下:

ratio = 1.2345678
minimum = 10

def convert(money)
    return money * ratio
end

if price < minimum
    cost = convert(minimum)
else
    cost = convert(price)
end

其中价格和成本是一个全局变量(我可以输入环境并在计算后访问)。

但是,我确实需要保证一些东西。

  1. 运行的任何脚本都无法访问 Python 环境。他们不能导入东西,调用我没有明确为他们公开的方法,读取或写入文件,生成线程等。我需要完全锁定。

  2. 我需要能够对脚本运行的“周期”数量进行硬性限制。循环在这里是一个通用术语。如果语言是字节编译的,则可能是 VM 指令。应用调用 Eval/Apply 循环。或者只是通过一些运行脚本的中央处理循环进行迭代。细节并不像我能够在短时间内停止运行并向所有者发送电子邮件并说“您的脚本似乎不仅仅是将几个数字相加 - 将它们整理出来”那样重要。

  3. 它必须在 Vanilla 未打补丁的 CPython 上运行。

到目前为止,我一直在为这项任务编写自己的 DSL。我能做到。但我想知道我是否可以建立在巨人的肩膀上。是否有一种可用于 Python 的迷你语言可以做到这一点?

有很多 hacky Lisp 变体(甚至是我在 Github 上写的),但我更喜欢使用非专业语法的东西(比如更多的 C 或 Pascal),因为我正在考虑将其作为编码的替代方案一个我自己我想要一些更成熟的东西。

有任何想法吗?

4

8 回答 8

18

这是我对这个问题的看法。要求用户脚本在 vanilla CPython 中运行意味着您需要为您的迷你语言编写解释器,或者将其编译为 Python 字节码(或使用 Python 作为源语言),然后在执行之前“清理”字节码。

我举了一个简单的例子,假设用户可以用 Python 编写他们的脚本,并且源代码和字节码可以通过从解析树中过滤不安全语法和/或从字节码。

解决方案的第二部分要求用户脚本字节码被看门狗任务定期中断,这将确保用户脚本不超过某些操作码限制,并且所有这些都可以在普通 CPython 上运行。

我的尝试总结,主要集中在问题的第二部分。

  • 用户脚本是用 Python 编写的。
  • 使用byteplay过滤和修改字节码。
  • 检测用户的字节码以插入操作码计数器并调用上下文切换到看门狗任务的函数。
  • 使用greenlet执行用户的字节码,在用户脚本和看门狗协程之间进行 yield 切换。
  • 看门狗对在引发错误之前可以执行的操作码数量实施预设限制。

希望这至少朝着正确的方向发展。当您到达解决方案时,我有兴趣了解更多有关您的解决方案的信息。

源代码lowperf.py

# std
import ast
import dis
import sys
from pprint import pprint

# vendor
import byteplay
import greenlet

# bytecode snippet to increment our global opcode counter
INCREMENT = [
    (byteplay.LOAD_GLOBAL, '__op_counter'),
    (byteplay.LOAD_CONST, 1),
    (byteplay.INPLACE_ADD, None),
    (byteplay.STORE_GLOBAL, '__op_counter')
    ]

# bytecode snippet to perform a yield to our watchdog tasklet.
YIELD = [
    (byteplay.LOAD_GLOBAL, '__yield'),
    (byteplay.LOAD_GLOBAL, '__op_counter'),
    (byteplay.CALL_FUNCTION, 1),
    (byteplay.POP_TOP, None)
    ]

def instrument(orig):
    """
    Instrument bytecode.  We place a call to our yield function before
    jumps and returns.  You could choose alternate places depending on 
    your use case.
    """
    line_count = 0
    res = []
    for op, arg in orig.code:
        line_count += 1

        # NOTE: you could put an advanced bytecode filter here.

        # whenever a code block is loaded we must instrument it
        if op == byteplay.LOAD_CONST and isinstance(arg, byteplay.Code):
            code = instrument(arg)
            res.append((op, code))
            continue

        # 'setlineno' opcode is a safe place to increment our global 
        # opcode counter.
        if op == byteplay.SetLineno:
            res += INCREMENT
            line_count += 1

        # append the opcode and its argument
        res.append((op, arg))

        # if we're at a jump or return, or we've processed 10 lines of
        # source code, insert a call to our yield function.  you could 
        # choose other places to yield more appropriate for your app.
        if op in (byteplay.JUMP_ABSOLUTE, byteplay.RETURN_VALUE) \
                or line_count > 10:
            res += YIELD
            line_count = 0

    # finally, build and return new code object
    return byteplay.Code(res, orig.freevars, orig.args, orig.varargs,
        orig.varkwargs, orig.newlocals, orig.name, orig.filename,
        orig.firstlineno, orig.docstring)

def transform(path):
    """
    Transform the Python source into a form safe to execute and return
    the bytecode.
    """
    # NOTE: you could call ast.parse(data, path) here to get an
    # abstract syntax tree, then filter that tree down before compiling
    # it into bytecode.  i've skipped that step as it is pretty verbose.
    data = open(path, 'rb').read()
    suite = compile(data, path, 'exec')
    orig = byteplay.Code.from_code(suite)
    return instrument(orig)

def execute(path, limit = 40):
    """
    This transforms the user's source code into bytecode, instrumenting
    it, then kicks off the watchdog and user script tasklets.
    """
    code = transform(path)
    target = greenlet.greenlet(run_task)

    def watcher_task(op_count):
        """
        Task which is yielded to by the user script, making sure it doesn't
        use too many resources.
        """
        while 1:
            if op_count > limit:
                raise RuntimeError("script used too many resources")
            op_count = target.switch()

    watcher = greenlet.greenlet(watcher_task)
    target.switch(code, watcher.switch)

def run_task(code, yield_func):
    "This is the greenlet task which runs our user's script."
    globals_ = {'__yield': yield_func, '__op_counter': 0}
    eval(code.to_code(), globals_, globals_)

execute(sys.argv[1])

这是一个示例用户脚本user.py

def otherfunc(b):
    return b * 7

def myfunc(a):
    for i in range(0, 20):
        print i, otherfunc(i + a + 3)

myfunc(2)

这是一个示例运行:

% python lowperf.py user.py

0 35
1 42
2 49
3 56
4 63
5 70
6 77
7 84
8 91
9 98
10 105
11 112
Traceback (most recent call last):
  File "lowperf.py", line 114, in <module>
    execute(sys.argv[1])
  File "lowperf.py", line 105, in execute
    target.switch(code, watcher.switch)
  File "lowperf.py", line 101, in watcher_task
    raise RuntimeError("script used too many resources")
RuntimeError: script used too many resources
于 2011-03-04T06:57:52.513 回答
8

吉斯比是完美的选择!

  • 它是 Python 中的 JavaScript 解释器,主要用于在 Python 中嵌入 JS。

  • 值得注意的是,它提供了对递归和循环的检查和限制。正如所需要的那样。

  • 它可以轻松地让您将 Python 函数用于 JavaScript 代码。

  • 默认情况下,它不会暴露主机的文件系统或任何其他敏感元素。

全面披露:

  • Jispy 是我的项目。我显然对此有偏见。
  • 尽管如此,在这里,它似乎确实是完美的选择。

PS:

  • 这个答案是在这个问题被问到大约 3 年后才写的。
  • 这么晚的答案背后的动机很简单:
    鉴于 Jispy 与手头的问题密切相关,未来有类似要求的读者应该能够从中受益。
于 2014-11-17T18:49:16.037 回答
5

试试 Lua。您提到的语法几乎与 Lua 相同。请参阅如何将 Lua 嵌入 Python 3.x?

于 2011-02-24T03:34:00.760 回答
4

我还不知道有什么能真正解决这个问题。

我认为你可以做的最简单的事情就是用 python 编写你自己版本的 python 虚拟机。

我经常考虑在 Cython 之类的东西中这样做,这样你就可以将它作为一个模块导入,并且你可以依靠现有的运行时来处理大部分的难点。

您可能已经能够使用 PyPy 生成一个 python-in-python 解释器,但是 PyPy 的输出是一个运行时,它可以做所有事情,包括为内置类型实现与底层 PyObjects 等价的东西,我认为这对于这种事情。

您真正需要的只是在执行堆栈中像 Frame 一样工作的东西,然后是每个操作码的方法。我认为您甚至不必自己实现它。您可以只编写一个将现有框架对象公开给运行时的模块。

无论如何,然后您只需维护自己的帧对象堆栈并处理字节码,您可以使用每秒字节码或其他方式来限制它。

于 2011-02-24T04:00:59.113 回答
2

我曾将 Python 用作早期项目的“迷你配置语言”。我的方法是获取代码,使用模块解析它parser,然后遍历生成代码的 AST 并排除“不允许的”操作(例如定义类、调用__方法等)。

在我这样做之后,创建了一个合成环境,其中仅包含“允许”的模块和变量,并评估其中的代码以获得我可以运行的东西。

它对我很有效。我不知道它是否是防弹的,特别是如果您想为您的用户提供比我为配置语言所做的更多的权力。

至于时间限制,您可以在单独的线程或进程中运行程序,并在固定时间后终止它。

于 2011-02-24T03:45:53.077 回答
1

为什么不在 pysandbox http://pypi.python.org/pypi/pysandbox/1.0.3中使用 python 代码?

于 2011-02-24T00:37:27.463 回答
1

看看LimPy。它代表 Limited Python,正是为此目的而构建的。

有一个环境,用户需要编写基本逻辑来控制用户体验。我不知道它将如何与运行时限制交互,但我想如果你愿意编写一些代码,你可以做到这一点。

于 2011-02-24T01:01:01.377 回答
-1

制作真正的 DSL 的最简单方法是 ANTLR,它具有一些流行语言的语法模板。

于 2011-02-24T00:42:02.260 回答