用 Bandit 做 Python 代码静态安全分析

Bandit 是什么?

Bandit 是一个用来检查 Python 代码中常见安全问题的工具,它会处理各个源代码文件,解析出 AST(抽象语法树),然后对 AST 节点执行一组对应的插件。当 Bandit 完成检查之后,它能生成一封安全报告。

安装说明:参见 GitHub 项目主页

编写自定义的检查

1
2
3
4
5
6
7
8
@bandit.checks('Call')
def prohibit_unsafe_deserialization(context):
if 'unsafe_load' in context.call_function_name_qual:
return bandit.Issue(
severity=bandit.HIGH,
confidence=bandit.HIGH,
text="Unsafe deserialization detected."
)
  • @bandit.checks('Call'): 仅仅检查类型为 Call 的 AST Node
  • return bandit.Issue(...): 返回一个 Security Issue

源码分析

入口在 cli/main.pymain()

先初始化了一堆参数,然后在这里创建了一个关键的 BanditManager 对象,之后的事情都是由它来完成的:

1
2
3
b_mgr = b_manager.BanditManager(b_conf, args.agg_type, args.debug,
profile=profile, verbose=args.verbose,
ignore_nosec=args.ignore_nosec)

扫描文件

紧接着就能看到这行代码:

1
2
# initiate file discovery step within Bandit Manager
b_mgr.discover_files(args.targets, args.recursive, args.excluded_paths)

让我们跟进去,然后看看 discover_files() 都做了些什么:(代码太长就不贴了)

  • 处理 include/exclude 参数
    • 如果有 include 就只看这里面的文件,否则扫描所有文件
    • 如果有 exclude,之后扫描的时候要去掉这些文件
  • 对所有指定的目标进行扫描
    • 如果设置了 recursive 选项,就递归地遍历子目录。

最后把遍历的结果排序并以列表的形式存放在 self.files_list 中。

运行 Tests

回到 main() 函数中,再往下看一点

1
2
# initiate execution of tests within Bandit Manager
b_mgr.run_tests()

看来这里是核心的一步,当然要走进去看看:(代码不贴了)

  • 枚举刚刚列表中的所有文件,读出来、并调用 _parse_file() 处理之。
  • 如果处理失败了,也记下来,最后汇总输出会用到。

继续跟进 _parse_file(),发现只是个包装,进入 _execute_ast_visitor()

1
2
3
4
5
6
7
8
9
def _execute_ast_visitor(self, fname, data, nosec_lines):
score = []
res = b_node_visitor.BanditNodeVisitor(fname, self.b_ma,
self.b_ts, self.debug,
nosec_lines, self.metrics)

score = res.process(data)
self.results.extend(res.tester.results)
return score

这个 BanditNodeVisitor 虽然没有继承标准库里的 ast.NodeVisitor 但实际上做的工作就是那样的——遍历所有 AST Node,同时对各个类型的 Node 执行对应的函数。

在 BanditNodeVisitor 中定义了很多类似 visit_Call, visit_FunctionDef, visit_Str 这样奇怪名字的函数,顾名思义就是对各个类型的 Node 所运行的函数。遍历的逻辑看 visit 函数。

visit_Call 为例:

1
2
3
4
5
6
7
8
9
def visit_Call(self, node):
self.context['call'] = node
qualname = b_utils.get_call_name(node, self.import_aliases)
name = qualname.split('.')[-1]

self.context['qualname'] = qualname
self.context['name'] = name

self.update_scores(self.tester.run_tests(self.context, 'Call'))

其实很简单,把对应的一些上下文信息 extract 出来并存到 self.context,然后用 tester.run_tests 执行所有对应 Call Node 的检查。

所以 run_tests() 的逻辑你应该能猜到个大概了:

  1. 拿到所有类型为 checktype 的检查
  2. 对每个检查,以当前的 context 作为参数做检查,如果检查出问题就存起来

输出结果

再回到 main() 函数中,再往下就是输出:

1
2
3
4
5
6
7
8
# trigger output of results by Bandit Manager
sev_level = constants.RANKING[args.severity - 1]
conf_level = constants.RANKING[args.confidence - 1]
b_mgr.output_results(args.context_lines,
sev_level,
conf_level,
args.output_file,
args.output_format)

Severity 和 Confidence 都是用来过滤的。最后输出到指定的形式。