一种基于Kotlin DSL的静态代码分析AST规则扩展实现
当前,业界针对 静态代码分析工具的结构化或者AST规则 DSL 方式并不多,主流的有 XML 方式(PMD、Klocwork等),自定义DSL(Fortify,CodeQL,Coverity CodeXM等),图数据库DSL(Joern等,将 AST 信息放到 图数据库中,采用类似于 数据查询的方式,进行规则开发)。
本文介绍一种基于 Kotlin DSL 的静态代码分析工具的,AST类型规则的扩展方法实现。其中 AST 为 JavaParser (介绍可以参考本人之前的博客:)构造的。本文的代码原型 参考链接:。
有兴趣的同学,可以基于该框架继续扩展,可以快速实现一个 可以方便进行 规则扩展的 静态代码分析的 AST检查引擎框架(同时,Kotlin DSL可以作为前端,适配各种不同的Kotlin 或者 Java 形式的 AST,比如 JDT 等,也可以适配其他形式的 AST 结构,例如 图数据库形式保存的 AST)。
1. 结构化规则
1.1 什么是结构化规则
结构化规则是针对代码的结构进行检查的一类规则。通常意义上来讲,不需要进行数据流和控制流的分析。例如,非常火的,Java的静态代码分析工具 PMD,就是这样的一类规则的检查工具。这类规则一般检查速度快,在 缺陷模式 设置准确的情况下,误报率还是比较低的。
1.2 结构化规则和AST检查是一致的吗?
很多时候,我们在做这类检查引擎的时候,都是在 AST 上面开发,例如:Java 会有 JavaParser,JDT(还有 PMD 和 SonarQube 等自己生成了 AST 结构)等,C/C++ 会有 CDT,CPPCheck,Clang AST 等。因此,很多时候,将 结构化规则 和 AST检查规则 等同起来。
但是,很多时候,并不是这样的,结构化规则,是针对代码结构的规则,但是并不是说,不需要基于一定基础的检查。 下面看两个例子:
(1) 常量传播问题
看下面的三组代码:
public class ConstantUse {
private static final int NUM = -1;
public static void main(String[] args) {
use(-1);
use(NUM);
int x = 0;
int y = x - 1;
use(y);
}
public static void use(int x) {
// sensitive usage of x, when x < 0, there will be some problem
}
}
如上面的代码,我们关注方法使用,只要入参 < 0,我们就需要报个告警。上面,第4, 5, 9 三行,都有使用方法,此时,根据我们的分析,三个都应该报告警。但是如果我们只在 AST 上面分析,第4行,肯定可以报告警,第5行,还是应该可以识别出来,因为 是个 final 的 static 数据,但是,第9行的缺陷,无法分析出来。
(2) 函数调用(指向分析)问题
public class FunctionCallUse {
public static void main(String[] args) {
FunctionCallUse use = new FunctionCallUse();
use.a().b();
FunctionCallUse use1 = new FunctionCallUse();
FunctionCallUse use2 = use1.a();
use2.b();
}
public FunctionCallUse a() {
return this;
}
public void b() {
// if a() is called first, then call this method will occur some security problem
}
}
如上,我们在 b() 中增加了注释,如果在 b() 之前调用的是 a() 获取的,那么就报告警。例如第4行的使用方式,就会报个缺陷。但是,在第8行,同样调用了 b(),而且根据分析,虽然是 use2 调用了方法 b(),但是 use2 是通过 a() 获取到的,此时,其实在 第8行,也应该报缺陷。但是仅基于AST层面,也很难报出来。就是说,仅依赖 AST检查,结构化规则检查是不充分的,会存在漏报。
1.3 结构化规则和 IR分析
针对上面,在 AST 上面,直接进行检查的缺陷,Fortify 工具的结构化规则,是在 IR 层面进行的分析,而且在 IR 上面,首先完成 常量传播、指向分析、类型分析 等基础分析之后,再进行上面的结构化规则分析,相对漏报会更少。
当然,也存在一些问题,就是 IR 的语法信息,毕竟不如 原始的 AST丰富,因为部分的语法结构,没有在 IR 中体现出来,例如,for 语句 在 IR 中,被处理成 while 语句,因此,结构化规则,就不支持 for 检查,而只能处理成可以识别的 while 语句的检查。
2. 基于Kotlin DSL 的 AST 规则扩展实现机制
首先,如标题所示,本文基于 AST进行扩展,因此,没有提前进行基础的 常量传播、指向分析、类型分析 等,仅仅是纯 AST 层面的分析。下面简单介绍该demo中实现的机制,还有已经扩展的部分规则。
2.1 Kotlin DSL 的原理介绍
2.2 对AST节点基础类扩展
为了更好地进行匹配,我将所有的需要的参数,都提到了 JavaParser 的 AST节点的 根节点Node 上面,例如下面的代码:
val Node.empty: Boolean
get() {
if (this is NodeWithBlockStmt<*>) {
val body = this.body
if (body == null || body.isEmptyStmt || CollectionUtils.isEmpty(body.statements)) {
return true
}
return false
}
throw UnSupportedParameterException("empty", this)
}
该参数用来识别节点是否为空,主要针对的是存在 block 的节点,其 block 是否为空,如果有需要,可以进一步扩展到其他的节点上面。不过,这里我将 empty 属性放到了 基类 Node 上面,对于不支持该属性的AST节点,则会直接抛出异常。
这样,我们所有的操作,都简化到了基类上面的匹配操作,而且规避掉了各类节点的函数或方法调用,只需要记住需要的属性即可,使 自定义规则的扩展 更加容易。
2.3 匹配方法扩展
匹配的方法,主要如下:
infix fun Node.match(block: Node.() -> Boolean): Boolean {
return block(this)
}
infix fun Node.contain(block: Node.() -> Boolean): Boolean {
if (this is NodeWithBlockStmt<*>) {
this.body.statements.forEach {
if (block(it)) {
return true
}
}
}
return false
}
可以看到,针对匹配,只需要使用一个 Node 上面的扩展方法,不需要再对 每种类型的 AST 都定义 match 方法,简化实现。
2.4 AST类型的匹配
实现中,将 AST类型,也抽象为 Node 的一个属性,如下面的代码所示:
val Node.simpleName: String
get() = this.javaClass.simpleName
val Node.qualifiedName: String
get() = this.javaClass.name
2.5 and 和 or 及 && 和 || 的使用
在 逻辑运算符 进行连接时,不同的条件的连接,在 Kotlin DSL 中,可以使用上面两类操作,当然,面向 DSL,肯定是 and 和 or 来得更直观,但是,在 Kotlin DSL 中,and 和 or 没有短路策略,只有 && 和 || 才有,因此,两种方式都行,但是理论上来说,&& 和 || 应该会更好。
3. 基于Kotlin DSL 的 AST规则扩展示例
3.1 基础Kotlin DSL 的 AST规则扩展示例
已经实现的 demo,参考了 Fortify 的自定义规则,也以 Fortify 帮助文档里面的几个例子来进行实现,其实在 自定义规则的 格式上,也参考了 Fortify 的自定义规则(参考链接:https://tech.esvali.com/mf_manuals/html/sca_ssc/hpe_security_fortify_static_code_analyzer-custom_rules_guide.htm)。
Fortify 的自定义规则,可以参考上面的链接的内容(只有 Fortify 17.xx 的版本),下面仅介绍一个 leftover debug 的规则例子,其他的 例子,可以参考代码(完整的规则,可以参考 github demo 的 doc/rule_template.xml)。
规则定制如下:
EC377C0A-3C95-4D91-B4F9-15CE5318FFA1
leftover debug code
leftover debug code
")
}
]]>
如上,根节点是 rule,有一个属性是 language,下面是四个子节点:id,name,description,matcher。其中 id 是 UUID 转大写,name 是规则名,description 是规则描述,matcher 是 规则的 dsl 写法。规则的 dsl 都放在了 里面。
在上面的规则里面,主要有两部分(以最前面的冒号分隔):
(1) MethodDeclaration md,有两个字符串,第一个表示我们关心的 节点类型,此处表示的是 方法定义,第二个表示我们在 后面的 dsl 中,使用的 根节点的 别名;
(2) 右边部分,就是结构匹配部分:
① md match {},都是这样的形式,所有要匹配的条件,都在 match 后面的大括号里面写,但是整体,都应该符合这样的写法;
② 这里包含了三个条件,并且三个条件是 and 的关系,就是需要 全部满足。第一个条件表示属性 methodName 需要以 debug 开头,第二个条件表示 属性 parameterTypes.size 只有一个,第三个条件表示 属性parameterTypes 的第一个元素,是 java.util.List 类型,泛型是任意的。
上面的几个属性的定义如下:
val Node.methodName: String
get() {
if (this is MethodDeclaration) {
return this.name.asString()
}
throw UnSupportedParameterException("methodName", this)
}
val Node.parameterTypes: MutableList
get() {
if (this is MethodDeclaration) {
val parameterTypes = mutableListOf()
this.parameters.forEach {
parameterTypes.add(it.type)
}
return parameterTypes
}
throw UnSupportedParameterException("parameterTypes", this)
}
val Type.resolveType: String
get() = resolveType(this)
fun resolveType(type: Type): String {
val resolvedType = type.resolve()
if (resolvedType != null) {
return resolvedType.describe()
}
return ""
}
整体来说,可以做的,都可以做了。
3.2 匹配嵌套及需要记录参数的场景
这里,在 Fortify 官方文档的6个例子里面,没有一个合适的例子,之前也没有想起来,刚刚写的时候,想起来了,就写个方法,知道怎么扩展。
node match {
prop1 match {
val node2 = this
prop2 match {
// can use node and node2 here
}
}
}
如上的代码,node 需要匹配的条件中,prop1 是抽象到 Node 中的属性,可以表示子节点或者父节点等,因此也可以直接适用 match 方法的嵌套。同时,如果在再下面的匹配中(例如 prop2 的匹配中,需要使用到 prop1 表示的节点),只需要在 匹配的最前面添加 val node2 = this,就给当前节点创建了一个别名,这样在 prop2 的匹配中就可以使用了,这里,明确命名的有 node 和 node2 两个。
4. 性能提升方案
本章节部分,并没有在原型中实现,因为本文只关心一种实现思路,并没有真正完成一种工程化的完整开发,但是本身需要考虑到一些性能问题,在此给出对应的方案(考虑并不完善,因为并没有真正着手做这部分的开发,还飘在天上)。
4.1 Kotlin DSL 预编译
Kotlin DSL 在原型中,是作为脚本语言调用的。脚本语言一般均是解释执行的,相对于编译执行的语言,效率较低一些。当脚本语言需要多次重复执行时,可以先对脚本进行编译,避免重复解析,提高效率(脚本编译需要脚本引擎支持,实现接口)。
事实上,对于每一个脚本,的可能需要重复执行非常多的次数,比如3.1节中,介绍到的对 FunctionDeclaration检查,只要遇到一次 FunctionDeclaration,就需要调用一次。所以预编译就显得很重要。
下面是 Kotlin ScriptEngine 的代码实现:
public abstract class KotlinJsr223JvmScriptEngineBase public constructor(myFactory: javax.script.ScriptEngineFactory) : javax.script.AbstractScriptEngine, javax.script.ScriptEngine, javax.script.Compilable {
// ...
}
class KotlinJsr223ScriptEngineImpl(
factory: ScriptEngineFactory,
baseCompilationConfiguration: ScriptCompilationConfiguration,
baseEvaluationConfiguration: ScriptEvaluationConfiguration,
val getScriptArgs: (context: ScriptContext) -> ScriptArgsWithTypes?
) : KotlinJsr223JvmScriptEngineBase(factory), KotlinJsr223InvocableScriptEngine {
// ...
}
上面,只写了类定义,忽略掉了内容,但是我们可以看出来,有实现接口,所以,我们有理由相信,Kotlin 脚本支持预编译,这样,在性能上面,影响会更小一点儿。
4.2 不同文件检查的并发操作
这应该是一种非常常规的性能提升的操作,因为这类规则,在实践中,是一种结构化规则,对代码结构的检查,并不涉及数据流、信息流的检查,从而不涉及跨文件检查。因此,完全可以实现多文件的并行分析。
5. 总结
本文实现了一种基于 Kotlin DSL + JavaParser 的 面向静态代码分析工具的 AST规则 的扩展方式。主要参考了 Fortify 的自定义规则的定制方式。而且在实现上,也在很大程度上,实现了能够类似于 缺陷描述 的规则定制方式,规避了 方法调用 类型的语法的出现,可读性和可扩展性更好。