需求背景

最近在工作上, 遇到了一个比较特殊的需求:

为了安全, 设计一个函数或者装饰器, 然后用户在 "定义/调用" 函数时, 只能访问到我们允许的内置变量和全局变量

通过例子来这解释下上面的需求:

a = 123
def func():
    print  a
    print id(a)

func()   

# 输出
123
32081168

函数功能简单明了, 对于结果, 大家应该也不会有太大的异议: func 分别是取得全局命名空间中 a 的值和使用内置命名空间中的函数 id 获取了 a 的地址. 熟悉 Python 的童鞋, 对于 LEGB 肯定也是不陌生的,也正是因为 LEGB 才让函数 func 输出正确的结果. 但是这个只是一个常规例子, 只是用来抛砖引玉而已. 我们真正想要讨论的是下面的例子:

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    f()

a = 123

# 用户自定义函数
def func():
    import os
    print os.listdir('.')

wrap(func)
# 输出
['1.yml', '2.py', '2.txt', '2.yml', 'ftp', 'ftp.rar', 'test', 'tmp', '__init__.py']

潜在危险因素

在上面的例子可以看出, 如果在 func 中, 引入别的模块, 然后再执行模块中的方法, 也是可行的! 而且这还是一个非常方便的功能! 但是除了方便, 更多的是一种潜在的危险.在日常使用, 或许我们不会考虑这些, 但是如果在模块与模块之间的协同作用时, 特别是多人参与的情况下, 这种危险的因素, 就不得不让我们认真对待!

或许有很多同学会觉得这些担忧是过多的, 是没必要的, 但是请思考一种场景: 我们有个主模块, 暂时称为 main.py, 它允许用户动态加载模块, 也就是说只要用户将对应的模块放到对应的目录, 然后利用消息机制去通知 main.py, 告诉它应该加载新模块了, 并且执行新模块里面的 b 函数, 那在这种情况下, main.py 肯定不能直接傻傻的就去执行, 因为我们不能相信每个用户都是诚实善良的, 也不能相信每个用户编写的模块或者函数是符合我们的行为标准规范. 所以我们得有些措施去防范这些事情, 我们能做的大概也就下面几种方式:

  1. 在用户通知main.py时有新模块加入并且要求执行函数时, 先对模块的代码做检查, 不符合标准或者带有危险代码的拒绝加载.
  2. 控制好内置命名空间全局命名空间, 使其只能用允许使用的内容

在方案1, 其实也是我们最容易想到的方法, 但是这个方法的成本还是比较高, 因为我们需要将可能出现的错误代码或者关键词,全部写成一套规则, 而且这套规则还很大可能会误伤, 不过也可能业界已经有类似的成熟的方案, 只是我还没接触到而已.
所以我们只能用方案2的方法, 这种方法在我们看来, 是成本比较低的, 也比较容易控制的, 因为这就和防火墙一样, 我们只放行我们允许的事物.

具体实现

实现方案2最大的问题就是, 如何控制内置命名空间 和全局命名空间
我们第一个想法肯定就是覆盖它们, 因为我们都知道不管是内置命名空间还是全局命名空间, 都是通过字典的形式在维护:

print globals()
print globals()['__builtins__'].__dict__

# 输出
# 全局命名空间
{'__builtins__': , '__name__': '__main__', '__file__': 'D:/Python_project/ftp/2.py', '__doc__': None, '__package__': None}

#内置命名空间
{'bytearray': , 'IndexError': 

注: globals 函数 是用来打印当前全局命名空间的函数, 同样, 也能通过修改这个函数返回的字典对应的 key, 实现全局命名空间的修改.例如:

s = globals()
print s
s['a'] = 3
print s
print a

# 输出
{'__builtins__': , '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}
{'a': 3, '__builtins__': , '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}
3

可以看出, 我们并没有定义变量 a, 只是在 globals 的返回值上面增加了 key-value, 就变相实现了我们定义的操作, 这其实也能用于很多希望能够动态赋值的需求场景! 比如说, 我不确定有多少个变量, 希望通过一个变量名列表, 动态生成这些变量, 在这种情况下, 就能参考这种方法, 不过还是希望谨慎使用, 因为修改了这个, 就是就修改了全局命名空间.

好了, 回归到本文, 我们已经知道通过 globals 函数能够代表全局命名空间, 但是为什么内置命名空间要用 globals()['builtins'].dict来表示? 其实这个和 python 自身的机制有关, 因为模块在编译和初始化的过程中, 内置命名空间就是以这种形式,寄放在全局命名空间:

static void
initmain(void)
{
    PyObject *m, *d;
    m = PyImport_AddModule("__main__");
    if (m == NULL)
        Py_FatalError("can't create __main__ module");
    d = PyModule_GetDict(m);
    if (PyDict_GetItemString(d, "__builtins__") == NULL) {
        PyObject *bimod = PyImport_ImportModule("__builtin__");
        if (bimod == NULL ||
            PyDict_SetItemString(d, "__builtins__", bimod) != 0)
            Py_FatalError("can't add __builtins__ to __main__");
        Py_XDECREF(bimod);
    }
}

从上面代码可以看出, 在初始化 main 时, 会有一个获取 builtins 的动作, 如果这个结果是 NULL, 那么就会用之前初始化好的 builtin 去存进去, 这些代码具体可以看 Pythonrun.c, 在这不详细展开了.

既然内置命名空间 (builtins) 和全局命名空间 (globals()) 都已经找到对应对象了, 那我们下一步就应该是想法将这两个空间替换成我们想要的.

# coding: utf8
# 修改全局命名空间
test_var = 123  # 测试变量

tmp = globals().keys()
print globals()
print test_var
for i in tmp:
    del globals()[i]
print globals()
print test_var
print id(2)

# 输出

{'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], '__builtins__': , '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 'test_var': 123, '__name__': '__main__', '__doc__': None}
123
{'tmp': ['__builtins__', '__file__', '__package__', 'test_var', '__name__', '__doc__'], 'i': '__doc__'}
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 10, in 
    print test_var
NameError: name 'test_var' is not defined

在上面的输出可以看到, 在删除前后, 通过 print globals() 可以看到全局命名空间确实已经被修改了, 因为 test_var 已经无法打印了, 触发了 NameError, 这样的话, 就有办法能够限制全局命令空间了:

# 伪代码

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    .... 修改全局命名空间
    f()
    .... 还原全局命名空间

a = 123

# 用户自定义函数
def func():
    import os
    print os.listdir('.')

wrap(func)

为什么我只写伪代码, 因为我发现这个功能实现起来是非常蛋疼! 原因就是, 在实现之前, 我们必须要解决几个问题:

1.全局命名空间对应了一个字典, 所以如果我们想要修改, 只能从修改这个字典本身, 于是先清空再定义成我们约束的, 调用完之后, 又得反过来恢复, 这些操作是十分之蛋疼.
2.涉及到共享的问题, 如果这个用户函数处理很久, 而且是多线程的, 那么整个模块都会变得很不稳定, 甚至称为"污染"
那就先撇开不讲, 讲讲内置命名空间, 刚才我们已经找到了能代表内置命名空间的对象, 很幸运的是, 这个是"真的能够摸得到"的, 那我们试下直接就赋值个空字典, 看会怎样:

s = globals()
print s['__builtins__']  # __builtins__检查是否存在
s['__builtins__'] = {}
print s['__builtins__']  # __builtins__检查是否存在
print id(3)              # 试下内置函数能否使用
print globals()

# 输出

{}
32602360
{'__builtins__': {}, '__file__': 'D:/Python_project/ftp/2.py', '__package__': None, 's': {...}, '__name__': '__main__', '__doc__': None}

结果有点尴尬, 似乎没啥用, 但是其实这个builtins只是一个表现, 真正的内置命名空间是在它所指向的字典对象, 也就是: globals()['builtins'].dict!

print globals()['__builtins__'].__dict__

# 输出
{'bytearray': , 'IndexError': ....} # 省略

所以我们真正要覆盖的, 是这个字典才对, 所以上面的代码要改成:

s = globals()
s['__builtins__'].__dict__ = {}   # 覆盖真正的内置命名空间
print s['__builtins__'].__dict__  # __builtins__检查是否存在

# 输出
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 3, in 
    s['__builtins__'].__dict__ = {}
TypeError: readonly attribute

失败了...原来这个内置命名空间是只读的, 所以我们上面的方法都失败了..那难道真的没法解决了吗? 一般这样问, 通常都有解决方案滴~

完美方案

这个解决方法, 需要一个库的帮忙~, 那就是 inspect 库, 这个库是干嘛呢? 简单来说就是用来自省. 它提供四种用处:

1.对是否是模块,框架,函数等进行类型检查。
2.获取源码
3.获取类或函数的参数的信息
4.解析堆栈
在这里, 我们需要用到第二个功能, 其余的功能, 感兴趣的童鞋可以去谷歌学习哦, 也可以参考: https://my.oschina.net/taisha...
除了 inspect, 我们还需要用到 exec, 这也是一大杀器, 可以先参考这个学习下: http://www.mojidong.com/pytho...

方法大致的过程就是以下几步:

1.根据用户传入的 func 对象, 利用 inspect 取出对应的源码
2.通过 exec利用源码并且传入全局命名空间, 重新编译
代码:

# coding: utf8
import inspect

# 装饰函数
def wrap(f):
    # 调用用户传入的函数
    source = inspect.getsource(f)   # 获取源码
    exec('%s \n%s()' % (source,  f.func_name), {'a': 'this is inspect', '__builtins__': {}})  # 重新编译, 并且重新构造全局命名空间

a = 123

# 用户自定义函数
def func():
    print a
    import os
    print os.listdir('.')

wrap(func)

# 输出
this is inspect
Traceback (most recent call last):
  File "D:/Python_project/ftp/2.py", line 19, in 
    wrap(func)
  File "D:/Python_project/ftp/2.py", line 8, in wrap
    exec('%s \nfunc()' % source, {'a': 'this is inspect', '__builtins__': {}})
  File "", line 6, in 
  File "", line 3, in func
ImportError: __import__ not found

虽然上面报错了, 但那不就我们求之不得结果吗? 我们可以正确的输出 a 的值 this is inspe, 而且当 func 想 import 时, 直接报错! 这样就能满足我们的变态欲望了~ 嘿嘿!,

关于代码运行原理, 其实在关键部位的代码, 都已经加了注释, 可能在 exec 那部分会比较迷惑, 但其实大家将对应的变量代入字符串就能懂了, 替换之后, 其实也就是函数的定义+执行, 可以通过 print '%s \n%s()' % (source, f.func_name) 帮助理解.而后面的字典, 也就是我们一直很纠结的全局命名空间, 其中内置命名空间也被人为定义了, 所以能够达到我们想要的效果了!

这种只是一种抛砖引玉, 让有类似场景需求的童鞋, 有个参考的方向, 也欢迎分享你们实现的方案, 嘿嘿!

作者:Lin_R
出处:http://t.cn/RoVFtWl

51Reboot 2019 最新课程招生信息

Python 零基础入门课程
此课程为面授班和网络班,一共 15 个课时,每周上一个全天,历时4个月。附加:录播视频+笔记+答疑2019-6月份开课

Python 自动化运维进阶课程
此课程为面授班和网络班,一共 15 个课时,每周上一个全天,历时4个月。附加:录播视频+笔记+答疑2019-4月份开课

Docker+K8s 课程
此课程为网络班,一共 150个课时,每周上一个全天,历时4个月。附加:录播视频+笔记+答疑现已开课
现在报名即可享受早鸟价

您可以添加我们的小助手WeChat:17812796384 咨询。