静态代码分析的实际操作介绍

2020-05-05 01:59:55

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

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

“迅速发现错误的技术的改进和其他任何技术一样,都是我们所说的科学的标志。”

我们在这个帖子里涉及了很多领域。其目的是建立对静态代码分析的理解,并为您提供基本理论和合适的工具,以便您能够自己编写分析器。

我们从铺设流水线的基本部分开始我们的旅程,编译器跟随这些部分来理解一段代码的功能。我们了解在这条管道的什么地方插入我们的分析器并提取有意义的信息。在后半部分,我们尝试了一下,完全从头开始用Python编写了四个这样的静态分析器。

请注意,尽管这里的想法是根据Python进行讨论的,但是所有编程语言的静态代码分析器都是按照类似的思路划分出来的。我们选择Python是因为它有一个易于使用的AST模块,并且语言本身得到了广泛的采用。

在计算机最终“理解”并执行一段代码之前,它会经历一系列复杂的转换:

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

编译器在试图理解一段代码时所做的第一件事就是将其分解成更小的块,也称为令牌。记号类似于语言中的单词。

令牌可以由单个字符(如()或文字(如整数、字符串,例如7、Bob等)或该语言的保留关键字(例如Python中的def)组成。扫描程序通常会丢弃对程序的特性没有贡献的字符,如尾随空格、注释等。

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

import io导入令牌化code=b";color=input(';输入您最喜欢的颜色:)";用于令牌化中的令牌。标记化(io.。BytesIO(代码)。READLINE):打印(令牌)。

TokenInfo(type=62(编码),string=';utf-8&39;)TokenInfo(type=1(Name),string=';color';)TokenInfo(type=54(Op),string=';=';)TokenInfo(type=1(Name),string=';input';)TokenInfo(type=54(Op),string=';(&#。)TokenInfo(type=54(Op),string=';)';)TokenInfo(type=4(换行),string=';)TokenInfo(type=0(ENDMARKER),String=';)。

(请注意,为了可读性起见,我在上面的结果中省略了几列-元数据,如起始索引、结束索引、令牌所在行的副本等。)。

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

解析器获取这些令牌,验证它们出现的顺序是否符合语法,并将它们组织成树状结构,表示程序的高级结构。它被恰当地称为抽象语法树(AST)。

“抽象”是因为它将诸如括号、缩进等低级无关紧要的细节抽象出来,允许用户只关注程序的逻辑结构-这使得它成为对其进行静态分析的最合适的选择。

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

Python附带了一个AST模块,作为其标准库的一部分,我们将在稍后编写分析器时大量使用它。

如果您以前没有使用AST的经验,下面是astmodule的工作原理:

所有AST节点类型都由AST模块中的相应数据结构表示,例如,For循环由ast.For对象表征。

为了分析语法树,我们需要一个AST“Walker”-一个便于遍历树的对象。AST模块提供两个助行器:

在遍历语法树时,我们通常只对几个感兴趣的节点进行分析,例如,如果我们要编写一个分析器来警告我们是否有超过3个嵌套的for循环,那么我们只会对访问ast.for节点感兴趣。

为了分析特定的节点类型,遍历程序需要实现一个特殊的方法。此方法通常称为“访问者”方法。术语:那么,访问节点只不过是对此方法的调用。

这些方法被命名为access_+<;node_type>;,例如,要添加访问者for“for loops”,方法应该命名为access_for。

有一种顶级访问方法,它递归地访问输入节点,即首先访问其自身,然后访问其所有子节点,然后访问子节点的子节点,依此类推。

为了让您对其工作原理有一个了解,让我们编写用于访问所有for循环的代码:

导入ast#Demo代码以解析代码=";";";\SHEEP=[';Shawn';,';Blanck';,';Truffy';]def get_herd():对于SHEEP:herd.append(A_SHEEP)返回herd(herd=herd)类herd:def_init__(self,herd):herd=[]。Smooth';):对于自身中的羊。羊群:打印(f";剃毛{SHEEP}在{setting}设置上)";";";";类示例(ast.。nodeVisitor):def access_for(self,node):print(f";vising for loop at line{node.lineno}";)tree=ast。解析(代码)访问者=示例()访问者。访问(树)。

由于该节点不存在访问者,因此默认情况下,访问者开始访问其子节点-ast.Assign、ast.FunctionDef和ast.ClassDef节点。

因为他们也没有访客,所以访客又开始探望他们所有的孩子。

在某些阶段,当最终遇到ast.For循环时,将调用Access_Formethod。请注意,节点的副本也被传递到此方法-它包含有关它的所有元数据-子节点(如果有的话)、行号、列等。

Python还有其他几个第三方模块,如Asterid、AstMoney、Astor,它们提供了额外的抽象模块,让我们的工作变得更容易。

但是,在这篇文章中,我们将把自己限制在最基本的模块中,这样我们就可以看到真实的、丑陋的幕后操作。

虽然这篇博客仅仅是对静态代码分析的介绍,但是我们正在编写脚本来检测与实际场景高度相关的问题(如果您违反了一个规则,您的IDE很可能已经警告您了)。这显示了静态代码分析的功能有多强大,以及它能让您用这么少的代码做些什么:

要分析的文件的名称在运行脚本时指定为命令行参数。

如果检测到问题,脚本应在屏幕上打印相应的错误消息。

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

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

导入sys import tokenize类DoubleQuotesChecker:msg=";检测到单引号,请改用双引号";def__init__(Self):self。Violations=[]def find_Violations(self,filename,tokens):对于TOKEN_TYPE,TOKEN,(LINE,COLE),_,_IN令牌:IF(TOKEN_TYPE==TOKENIZE)。字符串和(标记。以(";';';';";)或令牌开头。以(";';";)开头:Self。违规行为。append((filename,line,column))def check(self,files):对于文件中的文件名:with tokenize。打开(文件名)为fd:tokens=tokenize。生成令牌(FD)(_T)。READLINE)自我。Find_Violations(文件名,令牌)定义报告(SELF):针对SELF中的违规。违规:文件名,行,列=违规打印(f";{filename}:{line}:{ol}:{self.msg}";)if__name__==';__main__';:files=sys。argv[1:]检查器=DoubleQuotesChecker()检查器。检查(文件)检查器。报告()。

这些文件名将传递给check方法,该方法为每个文件生成令牌,并将它们传递给find_Violations方法。

find_Violations方法遍历标记列表并查找值为';';';或';的“字符串类型”标记。如果找到,它会通过将该行附加到self.Violations后来标记该行。

然后,Report方法读取self.Violations中的所有问题,并将其打印出来,同时显示一条有用的错误消息。

def SIMPLICAL_QUOTE_WARNING():';';';文档字符串故意使用单引号。';';';如果是实例(Shawn,';绵羊';):打印(';Shawn the Sheet!';)。

example.py:2:4:检测到单引号,使用双引号代替xample.py:5:25:检测到单引号,使用双引号代替xample.py:6:14:检测到单引号,改用双引号。

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

上一个示例是我们直接使用令牌的唯一示例。对于所有其他组件,我们将仅将交互限制在生成的AST上。

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

从集合导入ast导入defaultdict import sys import tokenize def read_file(Filename):with tokenize。打开(文件名)为fd:返回fd。Read()类BaseChecker(ast.。nodeVisitor):def__init__(Self):self。Violations=[]def check(self,path):对于路径中的文件路径:self。文件名=文件路径树=ast。解析(read_file(Filepath))self。访问(树)定义报告(SELF):针对SELF中的违规行为。违规:filename,lineno,msg=违规打印(f";{filename}:{lineno}:{msg}";)if__name__==';__main__';:files=sys。argv[1:]CHECKER=<;CHECKER_NAME>;()CHECKER。检查(文件)检查器。报告()

大多数代码的工作方式与我们在上一个示例中看到的相同,只是:

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

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

建议对空列表使用空的文本[]而不是list(),因为它往往比较慢-在调用名称列表之前必须在全局范围内查找它。此外,如果名称列表重新绑定到另一个对象,可能会导致错误。

list()作为ast.Call节点驻留。因此,我们首先为新的ListDefinitionChecker类定义access_call方法:

Class ListDefinitionChecker(BaseChecker):msg=";检测到';list()';的用法,请改用';[]';def access_call(self,node):name=getattr(node。函数,";id";,无),如果name and name==list。__名称__,而不是节点。参数:赛尔夫。违规行为。追加((Self.。文件名、节点。利诺,赛尔夫。味精))。

当访问调用节点时,我们首先尝试获取被调用的函数的名称。

如果是,我们现在可以确定呼叫列表(.)。正在制作中。

此后,我们确保没有参数传递给listfunction,即正在进行的调用确实是list()。如果是,我们通过添加问题来标记此行。

在一些示例代码上运行此文件(确保已将样板中的<;Checker_Name>;更新为ListDefinitionChecker):

嵌套超过3层的“for循环”看起来很不舒服,大脑很难理解,而且至少是令人头疼的。

因此,让我们编写一个检查,以便在遇到3个以上级别的嵌套ForLoop时进行检测。

下面是我们要做的:只要遇到ast.For节点,我们就开始计数。我们还将此节点标记为“父”节点。然后,我们检查它的任何子节点是否也是ast.for节点。如果是,我们将递增计数,并再次对子节点重复相同的过程。

类TooManyForLoopChecker(BaseChecker):msg=";太多嵌套的for循环";def access_for(self,node,Parent=True):如果Parent:Self。CURRENT_LOOP_DEPTH=1 ELSE:SELF。节点中的子项的CURRENT_LOOP_Depth+=1。Body:if type(子项)==ast。致:赛尔夫。如果Parent和Self,则访问_FOR(子级,父级=FALSE)。CURRENT_LOOP_Depth>;3:SELF。违规行为。追加((Self.。文件名、节点。利诺,赛尔夫。味精))自我。Current_Loop_Depth=0。

工作流程乍看起来可能有点偏差,但我们基本上是这样做的:

当(从BaseChecker类)调用access方法时,它开始在AST中查找任何ast.for节点。一旦找到,它就会使用默认关键字参数parent=True调用Method access_for。

我们使用变量PARENT作为标志来跟踪最外层的循环-在这种情况下,我们将self.current_LOOP_Depth初始化为1,否则,我们只将其值递增1。

我们检查此循环的主体,以递归方式查找任何as.Fornode子级。如果我们找到了一个,我们调用PARENT=FALSE的VISITE_FOR。

当我们完成遍历时,我们评估循环深度是否达到3以上。如果是,我们报告违规并再次将循环深度重置为0。

FOR_IN Range(10):For_In Range(5):For_In Range(3):For_In Range(1):Print(";Baa,Baa,Black Sheet";)FOR_In Range(4):For_In Range(3):Print(";您有羊毛吗?";)For_In Range(10):For_In Range(5):For_In Range(3):If True:For_In Range(3):Print(";是,先生,是,先生!";)。

你注意到这里的警告了吗?如果嵌套的for循环不是父循环的直接子循环,则永远不会访问它,因此不会报告它。然而,让我们的代码在这种边缘情况下工作是有细微差别的,这超出了本文的讨论范围。

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

在第一个过程中,我们遍历可以定义导入的所有节点(ast.Import、ast.ImportFrom),收集已导入的所有模块的名称。

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

在第二个过程中,我们看到哪些名称已导入,但未使用。然后,我们为所有此类名称打印一条错误消息。

类UnusedImportChecker(BaseChecker):def__init__(Self):self。import_map=defaultdict(设置)self。name_map=defaultdict(Set)def_add_Imports(self,node):表示节点中的import_name。名称:#仅存储顶级模块名称(";os.path";->;";os";)。#我们无法轻松检测到何时使用了";os.path";。名称=IMPORT_NAME。名字。分区(";.";)[0]自身。import_map[自身。文件名]。添加((名称,节点。lineno))def access_Import(self,node):self。_add_Imports(Node)def access_ImportFrom(self,node):self。_add_Imports(Node)def access_name(self,node):#我们只添加要从中读取值的节点。如果是实例(节点。CTX,最后。加载):自我。name_map[自身。文件名]。添加(节点。id)def Report(Self):对于路径,导入Self。IMPORT_MAP。Items():对于名称,在导入中为行:如果名称不在自身中。name_map[路径]:打印(f";{path}:{line}:未使用的导入';{name}';";)。

要获得文件中正在使用的所有名称的集合,我们访问ast.Namenodes:对于每个这样的节点,我们检查是否正在从其中读取值-这意味着正在引用已经存在的名称,而不是创建新对象。(如果是导入名称,则它必须已经存在)-如果存在,则将该名称添加到集合中。

Report方法遍历文件中所有导入名称的列表,并检查它们是否出现在使用的名称集中。如果没有,它会打印一条错误消息,报告违规。

请注意,为了简洁起见,我选择了尽可能简单的代码版本。这种选择有一个副作用,即我们的代码不会处理棘手的边角情况(例如,当导入是别名时-将foo导入为bar,或者当从locals()字典中读取名称时,等等)。

哟!这是一个完整的清单,用来缠绕你的头脑。但是,我们的奖励是,当我们下次识别导致错误的代码模式时,我们可以直接前进,并编写一个脚本来自动检测它。

他们扫描代码,寻找对strcpy()等函数的调用,这些函数很容易被误用,应该作为手动源代码审查的一部分进行检查。↩︎。

DeepSource帮助您在代码检查期间自动查找并修复代码中的问题,例如错误风险、反模式、性能问题和安全缺陷。使用GitHub或GitLab帐户设置需要几分钟,并且支持Python、Go和Ruby。JavaScript即将问世。

注册DeepSource