寻找适合编写静态分析规则的语言
本文分享自华为云社区《寻找适合编写静态分析规则的语言》,作者:Uncle_Tom。
1. 程序静态分析的作用
程序的静态分析是一种在不运行程序的情况下,通过分析程序代码来发现潜在的错误、安全漏洞、性能问题以及不符合编码规范的情况的技术。
程序的静态分析在现代软件安全中扮演着至关重要的角色。以下是静态分析在软件安全中的一些关键作用:
代码质量保证:
静态分析有助于确保代码符合安全编码标准和最佳实践,从而提高代码的质量和安全性。
参考:代码的安全检视
合规性检查:
许多行业标准和法规要求对软件进行安全合规性检查。静态分析工具可以帮助组织确保其软件产品符合这些标准和法规要求。
漏洞检测:
静态分析工具可以在代码编写阶段检测潜在的安全漏洞,如SQL注入、跨站脚本攻击(XSS)、缓冲区溢出等。
参考:2023年最具威胁的25种安全漏洞(CWE TOP 25)
减少开发成本:
通过在开发早期阶段发现问题,静态分析可以减少后期修复的成本和时间,因为后期修复通常成本更高。
2. 静态分析工具的业务痛点
随着现在工程项目的代码量越来越大,同时开发框架的快速迭代和出现。静态分析工具所需要覆盖的场景也随之快速的增加,但静态分析工具所提供的是通用的检查能力,以及静态分析工具有限的迭代速度,无法满足客户不断出现的各种差异化需求。
目前静态分析工具的主要痛点:
2.1. 无法开发自定义规则
大多数静态分析工具由于设计之初多是为了解决特定的编码问题,所以没有考虑到后期的扩展和由用户完成规则的开发。如果需要提供自定义开发能力,需要从架构上重新设计,或者因为检查效率的问题,无法提供通用的检查配置和自定义能力。这将导致无法快速提供客户特定的需求的问题检查。用户只能通过需求反馈的方式,等待工具下个版本的发布,需要的闭环周期很长。
2.2. 对误报和漏报的规则无法快速修改
静态分析工具由于是对代码的静态分析,输入存在不确定性,这些不确定性导致工具在分析策略在上近似(Over-approximation)、下近似(Under-approximation)以及检查效率三者之间寻求某种平衡,这三个因素互相影响、互相制约。
- 上近似是指分析工具可能将一些实际上不会发生的程序行为错误地识别为可能发生的。换句话说,它可能导致分析结果过于宽泛,将一些安全的状态或行为错误地标记为不安全的。这也就导致了误报(false positives),即错误地将安全的代码标记为有问题。
- 下近似是指分析工具可能未能识别出实际上会发生的程序行为。这意味着分析结果可能过于保守,遗漏了一些潜在的问题。这就导致了漏报(false negatives),即未能发现实际存在的安全问题或错误。
- 效率是所有使用者一直追求的因素,快了还想快。但哪里有又想马儿跑得快,又想马儿不吃草的好事情。
由于这些原因,静态分析工具通常提供的是一种通用的检查规则,往往不能覆盖特定的场景,或覆盖场景不适合特定用户的使用条件,这也造成检查工具无法避免误报和漏报。比如说:从文件读对有的用户是危险,但对有的用户是安全的,工具无法识别用户读文件的实际场景,只能将所有从文件读设置为危险的。如果用户无法快速对工具规则进行修改,就会被检查结果中的误报或漏报造成困扰。
2.3. 开发自定义规则有一定的难度
分析引擎提供的自定义开发包,但也需要自定义规则的开发人员掌握静态分析的相关技术,用户上手的难度较大。且由于引擎对API的封装能力,对外提供的检查能力有限,在很大程度上限制了用户自定义规则的实现能力。
基于这些痛点,需要寻找一种适合编写静态分析规则的语言,来降低自定义规则的难度,使用户能够直接开发满足自己需求的规则,用户可以自己在很大程度上来控制和解决误报和漏报。
那么什么才是适合用户的编写静态分析规则的语言呢?
3. 寻找适合编写静态分析规则的语言
为了寻找适合用户的编写静态分析规则的语言,我们来看下我们常见的两种编程范式:声明式语言(Declarative Language)和命令式语言(Imperative Language)。这两者语言在如何描述程序行为和解决问题的方法上存在根本差异, 但同时各有优势和适用场景, 许多现代编程语言支持这两种范式, 允许程序员根据具体问题选择最合适的方法。
问题:
从一个人群中挑出成年人;
具体条件:
选出的人年龄大于等于 18 岁。
命令式语言 – Java 语言
public List<Person> selectAdults(List<Person> persons){ List<Person> result = new ArrayList<>(); for (Person person : persons) { if (person.getAge() >= 18) { result.add(person); } } return result; }
SELECT * FROM Persons WHERE Age >= 18;
从这个例子可以看出来,声明式语言更适合用户的使用,这也是为什么 SQL 语言在短时间内能够迅速的被推广和使用。
声明式语言的特点,也正是我们正在寻找的适合编写静态分析规则的语言。用户只需要关注:“做什么”(What to do),即描述期望的结果或目标状态,而不指定如何达到这个结果的具体步骤或过程。
我们也可以把这个检查语言称为一种领域特定语言(Domain Specific Language,DSL),为特定领域或问题域定制的语言,专注于解决特定类型的问题。这个语言只专注于 – 编写程序静态分析的规则。
这里没有直接使用自然语言,主要是自然语言存在表述上的差异和描述的准确性的问题。当然随着大模型的越来越成熟,直接通过自然语言完成规则的编写,也离我们越来越近了。但不管怎样,在识别到检查条件后,还是需要有一个引擎将这些约束条件转换成具体查询的程序语言,完成问题代码的搜索,这就像 SQL 语言负责描述条件,还需要一个 SQL 的查询引擎,完成 SQL 语言的解析和实施查询。
4. DSL 在程序静态分析中的应用举例
4.1. 编写检查规则
检查问题:
- 生产环境中不应该有调试代码。
问题检查条件:
- 查找所有函数声明
- 并且(And):函数名以"debug"开头
- 并且(And):函数只有一个参数
- 并且(And):参数类型为"java.util.List"
package com.dsl; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.List; /** * 检查问题:生产环境中不应该有调试代码。 * 问题检查条件: * - 查找所有函数声明; * - 并且(And):函数名以"debug"开头; * - 并且(And):函数只有一个参数; * - 并且(And):参数类型为"java.util.List"。 */ public class CheckDebug { private static final Logger LOG = LogManager.getLogger(CheckDebug.class); // 应检查出的问题函数 public void debugFunction(List<String> msgs) { for (String msg : msgs) { LOG.error("print debug info: {}", msg); } } }
编写检查规则
DSL 写的检查规则
/** * 检查问题:生产环境中不应该有调试代码。 * 问题检查条件: * - 查找所有函数声明; * - 并且(And):函数名以"debug"开头; * - 并且(And):函数只有一个参数; * - 并且(And):参数类型为"java.util.List"。 */ functionDeclaration fd where and( fd.name startWith "debug", fd.parameters.size() == 1, fd.parameters[0].type.name == "java.util.List" );
4.1.1. 规则的解读
程序是由空格分隔的字符串组成的序列。在程序分析中,这一个个的字符串被称为"token",是源代码中的最小语法单位,是构成编程语言语法的基本元素。
Token可以分为多种类型,常见的有关键字(如if、while)、标识符(变量名、函数名)、字面量(如数字、字符串)、运算符(如+、-、*、/)、分隔符(如逗号,、分号;)等。
程序在编译过程中,词法分析器(Lexer)读取源代码并将其分解成一系列的token。语法分析器(Parser)会使用这些 token 来构建一个抽象语法树(Abstract Syntax Tree, AST),这个树结构表示了代码的语法结构。这个时候每个 token 也可以称为抽象语法树的节点,树上某个节点的分支就是这个节点的子节点。每个节点都会有节点类型、属性、值。
下面来描述下规则中使用的 DSL 和需求之间的对应关系。
节点类型、属性、值
在规则中,需要查找的是函数声明。这里使用:functionDeclaration 为代码的函数声明节点。在这个节点下有许多的属性,可以通过“.”的方式获取这些属性。
例如函数节点有:函数名(name)、函数的参数(parameters)等子节点。同时每个属性有自己的类型,以及值。例如:函数名(name)为字符串类型,函数的参数(parameters)一个集合类型;
别名
在编写规则时,定义别名可以显著简化规则编写。在遇到复合条件查询时,建议定义别名,方便后面的使用。
例如:函数声明(functionDeclaration)的别名 “fd”,这样后面可以使用 “fd” 方便了后面对这个函数声明的使用;
集合
函数的参数(parameters)就是一个集合,里面可能会存在 0-n 个参数。对于集合类的节点,可以通过指定集合的索引值得到集合下的子节点。
例如:参数的第一个参数,可以表示为:Parameters[0]。 同样通过 “.” 得到这个参数的其他类型或属性;
内置函数
规则中使用了内置字符串函数。
例如:判断字符串以指定字符串开始的函数,startWith(“debug”),表示判断字符串以 “debug” 开始的字符串;
运算符和条件表达式
规则中的 “==” 是运算符,表示等于。通过运算符将代码的节点类型、属性和具体的值联系在了一起,构成了条件表达式。由此构成了规则需要的条件判断,适配我们期望的约束条件。
条件的组合
通常一个规则需要多个约束或限制条件构成。这里通过并且(and)完成了三个子条件组成的复合逻辑条件表达式。
结论
- 通过这个案例我们可以看到,这个 DSL 语言非常接近我们的期望的需求表达式;
- 用户可以通过 DSL 语言快速开发满足需要检查的问题;
- 用户可以更多的关注如何描述需要检查的问题,而不需要关注工具是如何的实现。
4.2. 替换已有工具规则,并增加检查条件
4.2.1. 实现原有检查规则
原有检测问题:
- 继承
java.util.TimerTask
类重写 run 方法,run 方法的实现要有 try-catch 保护。
检查的条件:
- 查找类继承自
java.util.TimerTask
; - 并且(and):重写了 run 方法;
- 并且(and):run 方法中没有 try-catch。
package com.dsl; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.TimerTask; /** * 检查问题:继承 java.util.TimerTask 类重写 run 方法,run 方法的实现要有 try-catch 保护。 * 问题检查条件: * - 查找类继承自 java.util.TimerTask; * - 并且(and):重写了 run 方法; * - 并且(and):run 方法中没有 try-catch。 */ public class CheckTimerTask extends TimerTask { private static final Logger LOG = LogManager.getLogger(CheckTimerTask.class); // 应检查出的问题函数 @Override public void run() { LOG.info("do some thing"); } }
编写检查规则
DSL 写的检查规则
/** * 检查问题:继承 java.util.TimerTask 类重写 run 方法,run 方法的实现要有 try-catch 保护。 * 问题检查条件: * - 查找类继承自 java.util.TimerTask; * - 并且(and):重写了 run 方法; * - 并且(and):run 方法中没有 try-catch。 */ functionDeclaration fd where and( fd.enclosingClass.superTypes contain parType where parType.name == "java.util.TimerTask", fd.name == "run", fd notContain exceptionBlock );
4.2.2. 增加检查条件
基于上面一个例子,用户在使用时发现除了需要有异常捕捉之外,还需要记录错误或警告信息。由此需要对原来的规则进行修改,增加新的约束条件。
检查问题:
- 继承 java.util.TimerTask 类重写 run 方法,run 方法的实现要有 try-catch 保护。
- 在异常处理块中,需要有信息处理的函数: error 或 warn。
问题检查条件:
- 查找类继承自 java.util.TimerTask;
- 并且(and):重写了 run 方法;
- 并且(and):run 方法中没有 try-catch。
- 或者(or): run 方法中有异常处理块;
- 并且(and): 异常处理块中有函数调用;
- 并且(and):函数名为:error 或 warn。
package com.dsl; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.util.TimerTask; /** * 检查问题: * - 继承 java.util.TimerTask 类重写 run 方法,run 方法的实现要有 try-catch 保护。 * - 在异常处理块中,需要有信息处理的函数: error 或 warn。 * 问题检查条件: * - 查找类继承自 java.util.TimerTask; * - 并且(and):重写了 run 方法; * - 并且(and):run 方法中没有 try-catch。 * - 或者(or): run 方法中有异常处理块; * - 并且(and): 异常处理块中有函数调用; * - 并且(and):函数名为:error 或 warn。 */ public class CheckTimerTaskEnhance extends TimerTask { private static final Logger LOG = LogManager.getLogger(CheckTimerTaskEnhance.class); // 应检查出的问题函数 @Override public void run() { try { LOG.info("do some thing"); } catch (Exception e) { LOG.info("do some thing"); } } }
编写检查规则
DSL 写的检查规则
/** * 检查问题: * - 继承 java.util.TimerTask 类重写 run 方法,run 方法的实现要有 try-catch 保护。 * - 在异常处理块中,需要有信息处理的函数: error 或 warn。 * 问题检查条件: * - 查找类继承自 java.util.TimerTask; * - 并且(and):重写了 run 方法; * - 并且(and):run 方法中没有 try-catch。 * - 或者(or): run 方法中有异常处理块; * - 并且(and): 异常处理块中有函数调用; * - 并且(and):函数名为:error 或 warn。 */ functionDeclaration fd where and( fd.enclosingClass.superTypes contain parType where parType.name == "java.util.TimerTask", fd.name == "run", or( fd notContain exceptionBlock, fd contain exceptionBlock eb where eb contain functionCall fc where fc.name notMatch "error|warn" ) );
- 通过这个案例,我们可以看到DSL 可以快速的替换现有工具已有的检查;
- 并可以根据需求增加更多的检查条件,以增加对特殊场景的覆盖,从而减低规则的漏报率,同时也可以通过这个方式,降低工具的误报率,提升规则的检查的准确率。
5. 结论
通过上面两个案例,我们可以看到这个编写自定义规则的 DSL 语言,能够:- 实现规则的编写;
- 实现规则的改进,降低误报率和漏报率;
- 降低了检查规则的开发难度。
- 在 vscode 插件中查询:codenavi 添加插件即可。