在Python中嵌入低性能脚本语言

24

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

脚本确实可以非常简单,性能只是一个次要问题。我所说的脚本复杂性的示例如下:

ratio = 1.2345678
minimum = 10

def convert(money)
    return money * ratio
end

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

其中price和cost是全局变量(我可以在计算后将其提供到环境中并访问)。

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

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

  2. 我需要能够对脚本运行的“循环”次数设置硬限制。这里的“循环”是一个通用术语。如果语言字节编译,则可以是VM指令;对于Eval/Apply循环的Apply-calls;或者只是通过运行脚本的某个中心处理循环进行迭代。细节不太重要,重要的是我能够在短时间内停止运行某个程序并向所有者发送电子邮件,并说“您的脚本似乎做了更多的事情而不仅仅是加几个数字-请排序它们。”

  3. 它必须在原始的CPython上运行,未经修改。

到目前为止,我一直在为此任务编写自己的DSL。我可以做到。但我想知道是否可以借鉴其他人的经验。有没有可用于Python的迷你语言可以做到这一点?

有很多hacky Lisp变体(甚至我在Github上写过一个),但我更喜欢更多非专业语法(更像C或Pascal),而且考虑到这是编写自己脚本的替代方案,我希望有一个更成熟的东西。

有什么想法吗?


9
"嘿,老兄,我听说你喜欢脚本语言。" - Glenn Maynard
感谢迄今为止的回答。但我会再开放一段时间,并添加赏金,以查看是否有更加关键的内容。我想强调的是,在解析器生成的所有工具中,构建解析器并不是构建语言的难点。因此,我希望鼓励回答涉及问题的核心。谢谢! - Ian
8个回答

18
这是我对这个问题的看法。要求用户脚本在vanilla CPython中运行意味着你需要为你的迷你语言编写解释器,或将其编译为Python字节码(或使用Python作为源语言),然后在执行之前“清理”字节码。
我假设用户可以用Python编写他们的脚本,并且源代码和字节码可以通过从解析树过滤不安全的语法和/或从字节码中删除不安全的操作码的某种组合来得到足够的净化。
解决方案的第二部分要求用户脚本字节码被一个看门狗任务周期性地中断,以确保用户脚本不超过一些操作码限制,并且所有这些都在vanilla 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

1
有一个努力正在进行中,旨在在CPython中恢复受限执行。这个努力的详情可以在以下链接中找到:http://tav.espians.com/a-challenge-to-break-python-security.html 和 http://tav.espians.com/paving-the-way-to-securing-the-python-interpreter.html - samplebias
非常感谢您的回答。我之前没有接触过byteplay,这是一种非常好的方法,真正解决了问题的难点。我会试着使用一下。这可能与YouGov LimPy项目(由chmullig建议)很好地结合起来,以提供受限制的Python子集。我感觉我不仅仅是错过了已经存在的东西,所以我会接受这个答案。 - Ian

8

Jispy非常适合!

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

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

  • 它轻松地允许您使Python函数可用于JavaScript代码。

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

全面披露:

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

PS:

  • 这个答案是在这个问题被问了大约3年后写的。
  • 这样做的动机很简单:
    鉴于Jispy紧密遵守手头的问题,未来有类似需求的读者应该能够从中受益。

5

1
这将是理想的。Lunatic Python我已经为此目的进行了审核。但是它不可行,因为你无法控制虚拟机,糟糕的lua代码可能会导致python崩溃。但我会看一下LuaPy。谢谢。 - Ian

4

我不知道目前有什么能真正解决这个问题的东西。

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

我经常想用像Cython这样的工具来完成,这样你只需将其作为模块导入,并且你可以利用现有的运行时来处理大部分难点。

你可能已经能够使用PyPy生成一个Python-in-Python解释器,但是PyPy的输出是一个运行时,它包括实现内置类型和所有底层PyObjects的等效内容,我认为这对于这种事情来说有些过度。

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

无论如何,你只需要维护自己的frame对象堆栈并处理字节码,你可以通过每秒字节码或其他方式来控制它。


1
现在这是一个有趣的想法。我之前没有考虑过使用现有的虚拟机规范,并利用已经针对它的工具。好主意。我会试一试,看看可能会有什么影响。 - Ian

2

我曾经在一个早期项目中将Python用作“小型配置语言”。我的方法是使用parser模块解析代码,然后遍历生成的代码的AST并删除“不允许”的操作(例如定义类,调用__方法等)。

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

对我来说效果很好。如果您想为配置语言提供比我更多的功能,则不知道它是否牢固可靠。

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


我可以看出这样做是可行的,但我担心自己不知道是否错过了泄漏的构造。甚至可能没有语法方法来确定这种情况。听起来很危险。至于在线程中运行,是的,我认为那将是备选方案。理想情况下,我希望它不受服务器负载的影响 - 所以周期比绝对时间更好。 - Ian
那种可能性是存在的。我曾经处于一种非常严格的情况下,除了我注入的变量之外,没有其他变量,也没有函数调用等。 - Noufal Ibrahim

1

因为据我所知,它未能满足我的第二个标准。而且它可能也无法满足标准1。至少,对于pysandbox的安全性存在更多的疑虑,这让我感到不安。最后,你可以使其崩溃,这对我来说是不可接受的。 - Ian

1

来看看LimPy。它是Limited Python的缩写,专门为此目的而构建。

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


感谢您的建议,LimPy 是一个很不错的项目。不幸的是,它只是一个 Python 子集解析器,而不是一门语言。它没有执行语义。解析 DSL 很容易。像 ANTLR 这样的工具,甚至是我自己的 Sparkle(https://github.com/idmillington/sparkle)Python 解析器生成器都能轻松实现。运行解析后的代码才是难点! - Ian

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

1
我不想表现得很讨厌,但我在问题中确实说过我已经实现了DSL,但正在寻找一个更成熟的现有系统。 - Ian
1
@Ian,我的意思是,你不必重新发明它,只需拿一些示例代码。我只提供了我知道的最简单的方法。 - vissi
1
是的,抱歉,我真的不是故意要表现得很讨厌,我知道你是在帮忙 :) 我了解ANTLR,它是构建DSL的好方法。但那不是我想问的问题。 - Ian

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接