什么是静态代码分析?如何使用它?

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

但是,最近,“静态代码分析”一词更常用于指代该技术的一种应用,而不是该技术本身(程序理解)-了解程序并检测其中的问题(从语法错误到类型不匹配的任何内容) ,性能消耗可能是错误,安全漏洞等)。 这就是我们这篇文章中要描述的用法。

概述

我们在这篇文章中介绍了很多内容。 目的是加深对静态代码分析的了解,并为您提供基本理论和正确的工具,以便您可以自己编写分析器。

我们从铺设管道的重要部分开始我们的旅程,编译器遵循该管道以理解一段代码的作用。 我们将了解在此管道中从哪里挖掘点以插入分析仪并提取有意义的信息。 在后半部分,我们将全神贯注,并用Python完全从头开始编写四个这样的静态分析器。

请注意,尽管此处的思想是根据Python进行讨论的,但所有编程语言中的静态代码分析器都是以类似的方式雕刻出来的。 我们选择Python是因为易于使用的ast模块的可用性以及该语言本身的广泛采用。

静态代码分析工作流程

在计算机最终“理解”并执行一段代码之前,它经历了一系列复杂的转换,我们首先看一下流程图:

静态代码分析工作流程
静态代码分析工作流程

正如您在图表中所看到的(记住要放大看!),静态分析器以这些阶段的输出为“食”。 为了更好地理解静态分析技术,让我们更详细地研究以下每个步骤:

扫描

编译器在尝试理解一段代码时要做的第一件事是将其分解为较小的块,也称为令牌。 令牌类似于语言中的单词。

令牌可能由单个字符(例如,(或文字)(例如整数,字符串,例如7,Bob等)或该语言的保留关键字(例如,Python中的def)组成。 扫描程序通常会舍弃针对程序语义的操作,例如尾随空白,注释等。

Python在其标准库中提供了tokenize模块,可让您使用令牌:

// Python code
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)。

“摘要”是因为它抽象出了低级的无关紧要的细节,例如括号,缩进等,从而使用户仅关注程序的逻辑结构,这使其成为进行静态分析的最合适选择。

分析ASTs

语法树可能变得非常庞大和复杂,因此很难编写用于分析它的代码。 值得庆幸的是,由于这是所有编译器(或解释器)自己做的事情,因此通常存在一些简化此过程的工具。

Python随附一个ast模块作为其标准库的一部分,我们稍后在编写分析器时会大量使用该模块。

如果您没有使用AST的经验,则ast模块的工作原理如下:

  • 所有AST节点类型均由ast模块中的相应数据结构表示,例如,for循环的特征是ast.For对象。
  • 为了从源代码构建AST,我们使用ast.parse函数。
  • 为了分析语法树,我们需要一个AST“ walker”(一个促进遍历树的对象)。 astmodule提供了两个walker:
  • ast.NodeVisitor(不允许修改输入树)
  • ast.NodeTransformer(允许修改)
  • 在遍历语法树时,我们通常只对分析几个感兴趣的节点感兴趣,例如如果我们正在编写分析器来警告我们是否有3个以上的嵌套嵌套循环,那么我们只会对访问ast.For节点感兴趣。
  • 为了分析特定的节点类型,步行者需要实现一种特殊的方法。此方法通常称为“访客”方法。术语:访问一个节点,无非就是对该方法的调用。
  • 这些方法被命名为visit_ + ,例如,要为“ for循环”添加访问者,该方法应命名为visit_For。
  • 有一种顶级访问方法,它递归地访问输入节点,即首先访问自身,然后访问其所有子节点,再访问子节点的子节点,依此类推。

只是为了让您了解它的工作原理,让我们编写代码来访问for循环:

import ast
# Demo code to parse
code = """\
sheep = ['Shawn', 'Blanck', 'Truffy']
def get_herd():
    herd = []
    for a_sheep in sheep:
        herd.append(a_sheep)
    return Herd(herd=herd)
class Herd:
    def __init__(self, herd):
        self.herd = herd
    def shave(self, setting='SMOOTH'):
        for sheep in self.herd:
            print(f"Shaving sheep {sheep} on a {setting} setting")
"""
class Example(ast.NodeVisitor):
    def visit_For(self, node):
        print(f"Visiting for loop at line {node.lineno}")
tree = ast.parse(code)
visitor = Example()
visitor.visit(tree)

上面代码输出结果:

Visiting for loop at line 5
Visiting for loop at line 14
  • 我们首先访问顶级ast.Module节点。
  • 由于该节点不存在任何访问者,因此默认情况下,访问者开始访问其子节点-ast.Assign,ast.FunctionDef和ast.ClassDef节点。
  • 由于也没有访客,因此访客再次开始探访所有孩子。
  • 在某个阶段,当最终遇到ast.For循环时,将调用visit_For方法。 请注意,还将节点的副本传递到此方法(该方法包含有关它的所有元数据),子代(如果有),行号,列等。

Python还具有其他几个第三方模块,如astroid,astmonkey,astor,它们提供了其他抽象模块,使我们的生活更轻松。

但是,在这篇文章中,我们将把自己局限于准系统ast模块,以便我们能够看到幕后的真实,丑陋的操作。

示例

尽管这篇文章仅是静态代码分析的介绍,但我们仍将编写脚本来检测在现实情况中非常相关的问题(可能是,如果您违反了IDE,IDE就会警告您)。 这说明了静态代码分析的功能强大,它使您只需很少的代码就能完成以下工作:

  • 检测单引号而不是双引号的任何用法。
  • 检测是否使用list()而不是[]
  • 检测到太多的嵌套循环。
  • 检测文件中未使用的导入。

以下是示例的工作方式:

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

检测单引号

在这里,我们编写了一个脚本,该脚本将在检测到作为输入的Python文件中使用单引号时发出警告。

与其他当今的静态代码分析技术相比,该示例可能被认为是基本的,但由于历史的意义,此处仍包含在此示例中–这几乎就是早期代码分析器的工作方式1。 另一个原因,在这里包含此技术很有意义,因为它被许多流行的静态工具(例如黑色)大量使用。

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来标记该行。
  • 然后,报告方法从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_NAME>()
    checker.check(files)
    checker.report()

大部分代码的工作方式与上一个示例相同,除了:

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

您可能会注意到有一些未使用的导入-稍后将使用它们。 另外,在运行代码时,占位符需要替换为检查器类的实际名称。

检测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)

检测太多嵌套的循环

嵌套超过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循环不是父循环的直接子代,则永远不会访问它,因此不会报告。 但是,让我们的代码在这种极端情况下工作是微妙的,并且超出了本文的范围。

检测未使用的引入(import)

检测未使用的import与之前的案例有所不同,因为我们无法在访问节点时立即标记违规行为-我们没有关于整个模块将使用所有“名称”的完整信息。 因此,我们分两步实施此分析器:

  • 在第一遍中,我们遍历了可以定义导入的所有节点(ast.Import,ast.ImportFrom),收集了所有已导入模块的名称。
  • 在同一遍中,我们还通过实现ast.Name的访问者,用该文件中使用的所有名称填充集合。
  • 在第二遍中,我们看到导入了哪些名称,但未使用。 然后,我们为所有此类名称打印一条错误消息。
class UnusedImportChecker(BaseChecker):
    def __init__(self):
        self.import_map = defaultdict(set)
        self.name_map = defaultdict(set)
    def _add_imports(self, node):
        for import_name in node.names:
            # Store only top-level module name ("os.path" -> "os").
            # We can't easily detect when "os.path" is used.
            name = import_name.name.partition(".")[0]
            self.import_map[self.filename].add((name, node.lineno))
    def visit_Import(self, node):
        self._add_imports(node)
    def visit_ImportFrom(self, node):
        self._add_imports(node)
    def visit_Name(self, node):
        # We only add those nodes for which a value is being read from.
        if isinstance(node.ctx, ast.Load):
            self.name_map[self.filename].add(node.id)
def report(self):
    for path, imports in self.import_map.items():
        for name, line in imports:
            if name not in self.name_map[path]:
                print(f"{path}:{line}: unused import '{name}'")
  • 每当遇到Import或ImportFrom节点时,我们都会将其名称存储在集合中。
  • 为了获取文件中所有正在使用的名称的集合,我们访问ast.Name节点:对于每个这样的节点,我们检查是否从中读取了一个值-这意味着正在引用一个已经存在的名称。 ,而不是创建一个新对象。 (如果是导入名称,则它必须已经存在)—如果是,则将名称添加到集合中。
  • 报表方法遍历文件中所有导入名称的列表,并检查它们是否存在于一组使用的名称中。 如果不是,它将打印一条错误消息,报告违规情况。
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'

请注意,为简洁起见,我选择了最简单的代码版本。 这种选择的副作用是我们的代码无法处理一些棘手的极端情况(例如,当导入使用别名时-将foo导入为bar或从locals()dict中读取名称等)。

下次我们确定一种导致错误的代码模式时,我们可以继续编写脚本来自动检测它。

SO资源郑重声明:
1. 本站所有资源来源于用户上传和网络,因此不包含技术服务请大家谅解!如有侵权请邮件联系客服!3187589@qq.com
2. 本站不保证所提供下载的资源的准确性、安全性和完整性,资源仅供下载学习之用!如有链接无法下载、失效或广告,请联系客服处理,有奖励!
3. 您必须在下载后的24个小时之内,从您的电脑中彻底删除上述内容资源!如用于商业或者非法用途,与本站无关,一切后果请用户自负!

SO资源 » 什么是静态代码分析?如何使用它?