静态代码分析的实践介绍

500 阅读14分钟

静态代码分析是指接近程序的运行时行为的技术。换句话说,它是在不实际执行程序的情况下预测其输出的过程。

然而,最近,"静态代码分析 "这个术语更多的是用来指这种技术的应用之一,而不是技术本身--程序理解--理解程序并检测其中的问题(从语法错误到类型不匹配,性能障碍可能是错误,安全漏洞,等等)。这就是我们在这篇文章中提到的用法。

"及时发现错误的技术的完善与其他任何技术一样,都是我们所说的科学的标志。"

概要

我们在这篇文章中涵盖了很多内容。目的是建立对静态代码分析的理解,让你掌握基本的理论和正确的工具,以便你可以自己编写分析器。

我们从编译器为理解一段代码所做的工作而遵循的管道的基本部分开始我们的旅程。我们学习如何在这个管道中挖掘出一些点,以便插入我们的分析器并提取有意义的信息。在后半部分中,我们开始行动,用Python完全从头开始编写四个这样的静态分析器。

请注意,尽管这里的想法是根据Python来讨论的,但所有编程语言的静态代码分析器都是按照类似的思路刻画的。我们选择Python是因为它有一个易于使用的ast 模块,以及该语言本身的广泛采用。

这一切是如何进行的?

在计算机能够最终 "理解 " 和执行一段代码之前,它要经过一系列复杂的转换。

正如你在图中看到的那样(继续,放大它!),静态分析器以这些阶段的输出为依据。为了能够更好地理解静态分析技术,让我们更详细地看一下这些步骤中的每一个。

扫描

当编译器试图理解一段代码时,它所做的第一件事就是把它分解成更小的块,也就是所谓的令牌。令牌类似于语言中的单词。

一个令牌可能由单个字符组成,如( ,或字面意义(如整数、字符串,如7Bob ,等等),或该语言的保留关键字(如 Python 中的def )。对程序的语义没有贡献的字符,如尾部的空白、注释等,通常会被扫描器丢弃。

Python在它的标准库中提供了tokenize 模块,让你可以玩弄这些标记。

import io
import tokenize

code = b"color = input('Enter your favourite color: ')"

for token in tokenize.tokenize(io.BytesIO(code).readline):
    print(token)
TokenInfo(type=62 (ENCODING),  string='utf-8')
TokenInfo(type=1  (NAME),      string='color')
TokenInfo(type=54 (OP),        string='=')
TokenInfo(type=1  (NAME),      string='input')
TokenInfo(type=54 (OP),        string='(')
TokenInfo(type=3  (STRING),    string="'Enter your favourite color: '")
TokenInfo(type=54 (OP),        string=')')
TokenInfo(type=4  (NEWLINE),   string='')
TokenInfo(type=0  (ENDMARKER), string='')

(注意,为了便于阅读,我从上面的结果中省略了几列--元数据,如开始索引、结束索引、一个标记出现的行的副本,等等。)

解析

在这个阶段,我们只有语言的词汇,但标记本身并不反映语言的语法。这就是解析器发挥作用的地方。

解析器接收这些标记,验证它们出现的顺序是否符合语法,并将它们组织成一个树状结构,代表程序的高级结构。这被恰当地称为抽象语法树(AST)。

"抽象 "是因为它抽象了低级别的无关紧要的细节,如小括号、缩进等,允许用户只关注程序的逻辑结构--这就是它最适合进行静态分析的原因。

分析AST

语法树可以变得相当庞大和复杂,因此很难编写代码来分析它。值得庆幸的是,由于这是所有编译器(或解释器)自己做的事情,一般都有一些工具来简化这个过程。

Python有一个ast 模块,作为其标准库的一部分,我们在后面编写分析器时将大量使用它。

如果你以前没有使用AST的经验,下面是ast 模块的工作原理。

  • 所有的AST节点类型都由ast 模块中相应的数据结构来表示,例如,for 循环由ast.For 对象来描述。
  • 对于从源代码构建AST,我们使用ast.parse 函数。
  • 为了分析一个语法树,我们需要一个AST "行走器"--一个促进树的遍历的对象。ast 模块提供了两种步行器:
    • ast.NodeVisitor (不允许修改输入树)
    • ast.NodeTransformer (允许修改)
  • 当遍历一棵语法树时,我们一般只对分析几个感兴趣的节点感兴趣,例如,如果我们要写一个分析器来警告我们是否有超过3个嵌套的for循环,我们就只对访问ast.For 节点感兴趣。
  • 为了分析一个特定的节点类型,walker需要实现一个特殊的方法。这个方法通常被称为 "访问者 "方法。术语:那么,要访问一个节点,无非就是调用这个方法。
  • 这些方法被命名为visit_ + ,例如,要为 "for loops "添加一个访问者,这个方法应该被命名为visit_For
  • 有一个顶层的visit 方法,它递归地访问输入节点,也就是说,它首先访问自己,然后是所有的子节点,然后是子节点的子节点,以此类推。

只是为了让你感觉到这是如何工作的,让我们写一下访问所有for循环的代码。

这样输出:

Visiting for loop at line 5
Visiting for loop at line 14
  • 我们首先访问顶层的ast.Module 节点。
  • 由于该节点不存在访问者,默认情况下,访问者开始访问其子节点--ast.Assign,ast.FunctionDefast.ClassDef 节点。
  • 由于它们也不存在访问者,访问者再次开始访问它们的所有子节点。
  • 在某个阶段,当最终遇到一个ast.For 循环时,visit_For 方法被调用。注意,node 的副本也被传递给这个方法--它包含了所有关于它的元数据--子节点(如果有的话)、行号、列,等等。

Python 还有一些其他的第三方模块,如astroid,astmonkey,astor ,它们提供了额外的抽象模块,使我们的生活更容易。

但是,在这篇文章中,我们将局限于光秃秃的ast 模块,这样我们就可以看到幕后真正的、丑陋的操作。

例子

虽然这篇博文只是对静态代码分析的介绍,但我们将编写脚本来检测与现实世界场景高度相关的问题(如果你违反了一个问题,你的IDE可能已经警告你了)。这表明静态代码分析是多么强大,以及它能让你用这么少的代码做什么。

  • 检测任何单引号而不是双引号的使用。
  • 检测是否使用了list() ,而没有使用双引号。
  • 检测太多的嵌套for循环。
  • 检测一个文件中未使用的导入。

以下是这些例子的工作方式:

  • 要分析的文件的名称在运行脚本时作为命令行参数指定。
  • 如果检测到一个问题,脚本应该在屏幕上打印一个适当的错误信息。

检测单引号

在这里,我们写了一个脚本,当它检测到输入的Python文件中使用了单引号时就会发出警告。

与其他现代静态代码分析技术相比,这个例子可能被认为是很简陋的,但由于历史意义,它仍然包括在这里--这几乎是早期代码分析器的工作方式。另一个原因是,将这种技术包括在这里是有意义的,因为它被许多流行的静态工具大量使用,如black

import sys
import tokenize


class DoubleQuotesChecker:
    msg = "single quotes detected, use double quotes instead"
    def __init__(self):
        self.violations = []

    def find_violations(self, filename, tokens):
        for token_type, token, (line, col), _, _ in tokens:
            if (
                token_type == tokenize.STRING
                and (
                    token.startswith("'''")
                    or token.startswith("'")
                )
            ):
                self.violations.append((filename, line, col))

    def check(self, files):
        for filename in files:
            with tokenize.open(filename) as fd:
                tokens = tokenize.generate_tokens(fd.readline)
                self.find_violations(filename, tokens)

    def report(self):
        for violation in self.violations:
            filename, line, col = violation
            print(f"{filename}:{line}:{col}: {self.msg}")


if __name__ == '__main__':
    files = sys.argv[1:]
    checker = DoubleQuotesChecker()
    checker.check(files)
    checker.report()

下面是正在发生的事情的细目:

  • 输入文件名作为命令行参数被读取。
  • 这些文件名被传递给check 方法,该方法为每个文件生成令牌,并将它们传递给find_violations 方法。
  • find_violations 方法遍历标记列表,寻找 "字符串类型 "的标记,其值要么是''' ,要么是' 。如果找到一个,它就将其附加到self.violations ,以此来标记该行。
  • 然后,report 方法从self.violations 读取所有的问题,并将它们打印出来,同时提供有用的错误信息。
def simulate_quote_warning():
    '''
    The docstring intentionally uses single quotes.
    '''
    if isinstance(shawn, 'sheep'):
        print('Shawn the sheep!')
example.py:2:4: single quotes detected, use double quotes instead
example.py:5:25: single quotes detected, use double quotes instead
example.py:6:14: single quotes detected, use double quotes instead

请注意,为了简洁起见,这些例子中完全省略了错误处理,但不用说,它们是任何生产系统的一个重要组成部分。


其他例子的模板

前面的例子是唯一一个我们直接使用令牌的例子。对于所有其他的例子,我们只把我们的互动限制在生成的AST上。

由于很多代码会在这些检查器中重复使用,而且这篇文章已经很长了,让我们先把一些模板代码准备好,以后我们可以在所有的例子中重复使用。一次性定义模板代码还可以让我在每个检查器下只讨论相关的细节,并一次性摆脱所有的业务逻辑。

import ast
from collections import defaultdict
import sys
import tokenize


def read_file(filename):
    with tokenize.open(filename) as fd:
        return fd.read()

class BaseChecker(ast.NodeVisitor):
    def __init__(self):
        self.violations = []

    def check(self, paths):
        for filepath in paths:
            self.filename = filepath
            tree = ast.parse(read_file(filepath))
            self.visit(tree)

    def report(self):
        for violation in self.violations:
            filename, lineno, msg = violation
            print(f"{filename}:{lineno}: {msg}")

if __name__ == '__main__':
    files = sys.argv[1:]
    checker = ()
    checker.check(files)
    checker.report()

大部分代码的工作方式与我们在前一个例子中看到的相同,除了:

  • 我们有一个新的函数read_file ,用来读取给定文件的内容。
  • check 方法,而不是标记化,而是逐一读取所有文件路径的内容,然后使用ast.parse 方法解析其AST。然后,它使用visit 方法访问顶层节点(一个ast.Module ),从而递归访问其所有的子节点。它还将self.filename 的值设置为当前正在分析的文件--这样,当我们以后发现违规时,就可以在错误信息中添加文件名。

你可能会注意到,有几个未使用的导入--它们将在后面使用。另外,占位符 ,在运行代码时需要用检查器类的实际名称来代替。

检测使用list()

建议使用空的字面意思[] ,而不是list() ,因为它往往会比较慢--在调用它之前必须在全局范围内查找名称list 。另外,如果list 这个名字被反弹到另一个对象上,可能会导致一个错误。

list() 驻留在一个 ast.Call 节点上。因此,我们开始为我们的新 ListDefinitionChecker类定义 visit_Call方法。

class ListDefinitionChecker(BaseChecker):
    msg = "usage of 'list()' detected, use '[]' instead"

    def visit_Call(self, node):
        name = getattr(node.func, "id", None)
        if name and name == list.__name__ and not node.args:
            self.violations.append((self.filename, node.lineno, self.msg))

下面简要介绍一下我们要做的事情:

  • 当访问一个Call 节点时,我们首先尝试获得被调用的函数的名称。
  • 如果它存在,我们检查它是否等于list.__name__
  • 如果是,我们现在就可以确定正在调用list(...)
  • 此后,我们确保没有参数被传递给list ,也就是说,正在进行的调用确实是list() 。如果是这样,我们通过添加一个问题来标记这一行。

在一些示例代码上运行这个文件(确保你已经将模板中的 更新为ListDefinitionChecker )。

def build_herd():
    herd = list()
    for a_sheep in sheep:
        herd.append(a_sheep)
    return Herd(herd)
example.py:2: usage of 'list()' detected, use '[]' instead

检测过多的嵌套for循环

嵌套超过3层的 "For循环 "让人看了很不舒服,大脑很难理解,至少在维护上也让人头痛。

因此,让我们写一个检查,在遇到超过3层的嵌套for循环时进行检测。

以下是我们要做的。一旦遇到一个ast.For 节点,我们就开始计数。我们也把这个节点标记为 "父 "节点。然后,我们检查它的任何子节点是否也是ast.For 。如果是,我们就递增计数,并对子节点再次重复同样的程序。

class TooManyForLoopChecker(BaseChecker):
    msg = "too many nested for loops"

    def visit_For(self, node, parent=True):
        if parent:
            self.current_loop_depth = 1
        else:
            self.current_loop_depth += 1

        for child in node.body:
            if type(child) == ast.For:
                self.visit_For(child, parent=False)

        if parent and self.current_loop_depth > 3:
            self.violations.append((self.filename, node.lineno, self.msg))
            self.current_loop_depth = 0

这个工作流程一开始可能看起来有点歪,但这里基本上是我们正在做的事情。

  • visit 方法被调用时(来自BaseChecker 类),它开始在AST中寻找任何ast.For 节点。一旦找到一个,它就会调用带有默认关键字参数parent=True 的方法visit_For
  • 我们使用变量parent 作为标志来跟踪最外层的循环--在这种情况下,我们将self.current_loop_depth 初始化为1,否则,我们只是将其值增加1。
  • 我们检查这个循环的主体,递归地寻找任何子节点ast.For 。如果我们找到一个,我们就用parent=False 来调用visit_For
  • 当我们完成遍历后,我们评估循环深度是否超过3。如果是这样,我们就报告违规,并将循环深度再次重置为0。

让我们在一些例子上运行我们的脚本。

for _ in range(10):
    for _ in range(5):
        for _ in range(3):
            for _ in range(1):
                print("Baa, Baa, black sheep")

for _ in range(4):
    for _ in range(3):
        print("Have you any wool?")

for _ in range(10):
    for _ in range(5):
        for _ in range(3):
            if True:
                for _ in range(3):
                    print("Yes, sir, yes, sir!")
example.py:1: too many nested for loops

你注意到这里的注意事项了吗?如果嵌套的for循环不是父循环的直接子女,它就不会被访问,因此也不会被报告。然而,让我们的代码在这种边缘情况下工作是有细微差别的,而且不在这篇文章的范围之内。

检测未使用的导入

检测未使用的导入与之前的情况不同,因为我们不能在访问节点时立即标记违规行为--我们没有关于整个模块中要使用的所有 "名字 "的完整信息。因此,我们分两次来实现这个分析器:

  • 在第一遍中,我们通过所有可能定义导入的节点(ast.Import,ast.ImportFrom ),收集所有被导入的模块的名称。

  • 在同一过程中,我们还通过实现ast.Name 的访问者,将所有在该文件中使用的名称填充到一个集合中。

  • 在第二遍中,我们看到哪些名字被导入,但没有被使用。然后我们为所有这些名字打印一个错误信息。

  • 每当遇到一个ImportImportFrom 节点,我们就把它的名字存储在一个集合中。

  • 为了得到一个文件中正在使用的所有名字的集合,我们访问ast.Name 节点:对于每个这样的节点,我们检查是否正在从它那里读取一个值--这意味着正在对一个已经存在的名字进行引用,而不是创建一个新对象。(如果它是一个进口名称,它必须已经存在)--如果是,我们将该名称添加到集合中。

  • report 方法遍历文件中所有导入名称的列表,并检查它们是否存在于已用名称集合中。如果没有,它就会打印出一条错误信息,报告违规情况。

让我们继续在几个例子上运行这个脚本。

import antigravity
import os.path.join
import sys
import this

tmpdir = os.path.join(sys.path[0], 'tmp')
example.py:1: unused import 'antigravity'
example.py:4: unused import 'this'

请注意,为了简洁起见,我采用了最简单的代码版本。这个选择有一个副作用,那就是我们的代码没有处理一些棘手的角落情况(例如,当导入被别名时--import foo as bar ,或者当名称从locals() dict中读取时,等等)。

吁!这是个完整的清单。这是个需要用脑子去思考的整整一串东西。但是,我们的收获是,下一次我们发现一个导致错误的代码模式时,我们可以直接写一个脚本来自动检测它。