一、给企业做安全检查,为什么非得自己搞一套规则库
聊 SAST(静态应用安全测试)之前,先说说大多数公司碰到的实际情况。市面上已经有 Fortify、Checkmarx、SonarQube 这些商业工具,价格不便宜,而且里面的规则库是黑盒子——你没法修改它的检测逻辑,遇到自家框架的特殊写法,它就傻眼了。开源的工具比如 SpotBugs、FindSecBugs 虽然能看源码,但规则库更新慢,很多时候还是得自己写。
自己搭建规则库的好处很明显:你对自家代码了如指掌,什么方言写法、什么配置习惯,只有你最清楚。而且有了自己的规则库,就能在每次提交代码时自动扫一遍,把问题扼杀在摇篮里。说白了,就是做一套“量身定做”的安检门。
当然,从头自己造轮子也有代价:需要理解抽象语法树(AST)、需要懂各种漏洞模式、需要反复调阈值降低误报。但一旦建成了,收益远大于投入。下面咱们就拿 Java 和 Go 这两种常用语言,结合 OWASP Top 10 里的几个典型漏洞,一步步说说怎么提炼模式、编写规则。
二、规则库到底是什么东西
2.1 规则 ≠ 简单的关键词匹配
很多人以为规则就是写个正则去代码里搜 key,比如搜索“password”关键字就算发现硬编码。其实完全不够,因为真实的代码千奇百怪:“pwd”可以,“pass”也行,而且变量名可能被混淆。真正的 SAST 规则是建立在“代码结构”之上的,也就是把源码解析成一棵树(AST),然后在这棵树上找特定的节点组合。
2.2 一条规则长什么样
拿一个最简单的伪代码模板来说:
- 漏洞类型:比如 SQL 注入
- 触发条件:调用某个方法(executeQuery)时,参数是通过字符串拼接得到的
- 检测代码:在 AST 里找到所有方法调用节点,检查其参数是否是字符串拼接表达式
- 严重级别:高
- 建议修复:换成预编译或者 ORM
多数自研规则的框架都包含这样几个字段:ID、类别、描述、匹配模式、严重性、修复建议。匹配模式是核心,通常用树形查询语言(例如 XPath 或自定义 DSL)来描述。
三、从 OWASP Top 10 里挑几个典型漏洞练手
OWASP Top 10 每几年更新一次,但有几个老生常谈的漏洞一直稳居前列。我们不需要全部覆盖,先挑三个最常见且容易通过静态分析发现的来搭建规则的雏形。
3.1 注入类漏洞(Injection)
最典型的就是 SQL 注入。在 Java 代码里,如果直接用 Statement 执行拼接的字符串,基本就是送人头。在 Go 中,database/sql 的普通模式需要手动拼接,也容易出事。检测的模式就是:方法调用 + 参数是变量拼接或直接传用户输入。
3.2 跨站脚本(XSS)
虽然在 Java 后端,XSS 更多是输出时候的问题。但通过检测直接输出用户输入到页面的代码(比如在 JSP 中用 <%= request.getParameter("name") %>,或者在 Go 的模板中用了不安全的方法),可以抓住一部分。
3.3 敏感数据泄露
硬编码密码、密钥、Token,是很多企业代码里的常客。检测模式就是:字符串字面量赋值给变量名包含 password、secret、key 等关键词的变量,或者直接作为方法参数传入加密库。
四、用 Java 写规则:一个完整的实操例子
因为 Java 的语法相对稳定,社区也有比较成熟的 AST 工具(比如 JavaParser、SpotBugs 的 OPcode 分析),这里我们统一用 Java 作为示例语言来演示怎么写检测规则。注意,下面所有代码都是 Java 的,用 JavaParser 库来分析源码。
4.1 准备工作:引入 JavaParser 依赖
假设你用 Maven 管理依赖,在 pom.xml 里加上:
<!-- JavaParser 核心库 -->
<dependency>
<groupId>com.github.javaparser</groupId>
<artifactId>javaparser-symbol-solver-core</artifactId>
<version>3.25.5</version>
</dependency>
然后写一个最简单的规则引擎骨架:读入一个 Java 文件,遍历所有方法调用,如果发现某个方法名是 executeQuery 且参数是一个 BinaryExpr(拼接表达式),就报漏洞。
4.2 示例1:检测 SQL 注入(直接拼接)
// 技术栈:Java (使用 JavaParser)
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.expr.BinaryExpr;
import com.github.javaparser.ast.expr.MethodCallExpr;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
import java.io.FileInputStream;
public class SqlInjectionDetector {
public static void main(String[] args) throws Exception {
// 加载一个Java源文件
FileInputStream in = new FileInputStream("src/main/java/example/UserDao.java");
CompilationUnit cu = StaticJavaParser.parse(in);
// 访问所有方法调用
cu.accept(new VoidVisitorAdapter<Void>() {
@Override
public void visit(MethodCallExpr n, Void arg) {
super.visit(n, arg);
// 检查调用的方法名是不是 executeQuery 或 execute
String methodName = n.getNameAsString();
if ("executeQuery".equals(methodName) || "execute".equals(methodName)) {
// 获取第一个参数(如果是SQL拼接通常第一个参数就是SQL字符串)
if (n.getArguments().size() > 0) {
var firstArg = n.getArguments().get(0);
// 如果这个参数是一个二元表达式(比如 a + b),说明它是拼接的
if (firstArg instanceof BinaryExpr) {
System.out.println("发现潜在的SQL注入风险:" + n.getRange().get().begin);
System.out.println("修复建议:使用PreparedStatement或参数化查询");
}
}
}
}
}, null);
}
}
这段代码的逻辑很直白:找到所有调用 executeQuery 或 execute 的地方,看第一个参数是不是字符串拼接表达式。如果是一般的参数化查询,参数会是 ? 占位,不会是拼接。当然这只是一个初级规则,实际还要考虑比如 String.format 或者 builder 模式,但作为起步足够。
4.3 示例2:检测硬编码密码
很多人习惯在代码里写 String password = "abc123"; 或者 final String SECRET_KEY = "my-secret";。我们可以通过检查变量声明和赋值来发现这类问题。
// 技术栈:Java (使用 JavaParser)
import com.github.javaparser.StaticJavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.VariableDeclarator;
import com.github.javaparser.ast.expr.StringLiteralExpr;
import com.github.javaparser.ast.visitor.VoidVisitorAdapter;
import java.io.FileInputStream;
import java.util.List;
public class HardcodedSecretDetector {
// 常见的关键字列表(可以配置化)
public static void main(String[] args) throws Exception {
FileInputStream in = new FileInputStream("src/main/java/example/Config.java");
CompilationUnit cu = StaticJavaParser.parse(in);
cu.accept(new VoidVisitorAdapter<Void>() {
@Override
public void visit(VariableDeclarator n, Void arg) {
super.visit(n, arg);
// 获取变量名(小写)
String varName = n.getNameAsString().toLowerCase();
// 查看变量名是不是包含关键字
if (matchesKeyword) {
// 检查初始化值是不是字符串字面量
if (n.getInitializer().isPresent() && n.getInitializer().get() instanceof StringLiteralExpr) {
StringLiteralExpr literal = (StringLiteralExpr) n.getInitializer().get();
// 简单的判断:字面量长度大于4且不是空字符串,避免误报
if (literal.getValue().length() > 4) {
System.out.println("检测到硬编码敏感信息:" + n.getNameAsString() + " = " + literal.getValue());
System.out.println("建议:将敏感信息放入环境变量或配置文件加密存储");
}
}
}
}
}, null);
}
}
这里的要点是:只报变量名包含关键字且初始值是字符串字面量的情况。对于通过函数返回值赋值的(比如 password = getPasswordFromVault())就不报,因为那是安全的。
五、Go 语言中的模式提炼思路(文字说明)
虽然不写 Go 代码,但分析思路可以通用。Go 的静态分析也有标准库 go/ast 和 go/parser,加上一些第三方库比如 golang.org/x/tools/go/analysis 能快速构建自定义检查器。
举例来说,检测 Go 中的 SQL 注入,可以从调用 db.Query 或 db.Exec 入手,检查其第一个参数是否包含格式字符串中的 %s 或者直接是拼接得到。因为 Go 的 database/sql 库要求占位符用 ? 而不是拼字符串,所以只要看到 "SELECT * FROM users WHERE name = " + userInput 这种写法,基本就是高危。
对于硬编码检测,Go 里最典型的是 const secret = "xxx" 这种全局变量,或者 var apiToken = "..."。通过遍历 GenDecl 节点就能发现。
六、搭建规则库的完整步骤(从零到能用)
6.1 收集漏洞模式
别凭空想象,去翻 OWASP 官方文档、自家的线上事故记录、以及 GitHub 上别人已经公开的规则库(比如 FindSecBugs 的规则定义)。每发现一种新写法,就记下来形成一条模式。
6.2 编写规则代码
上面 JavaParser 的示例已经展示了一种方式。更成熟的自研规则库通常会设计一个 JSON 或者 YAML 配置,把匹配模式和修复建议外置,引擎统一读取。例如:
[
{
"id": "JAVA-SQL-INJECTION-001",
"type": "SQL_Injection",
"severity": "high",
"description": "检测直接拼接SQL语句",
"match": {
"methodPattern": "executeQuery|execute",
"argumentCondition": "第一个参数是字符串拼接表达式"
},
"fix": "使用PreparedStatement参数化查询"
}
]
当然这只是个简化方案,实际匹配需要把 AST 节点类型编码进去。
6.3 测试与迭代
拿自己公司代码库跑一遍,肯定会有大量误报。最典型的就是 MyBatis 等 ORM 框架本来就用拼接(虽然框架内部安全),这时候就需要添加白名单或者减少规则触发条件。建议准备一个测试用例集,包含正例(安全代码)和反例(漏洞代码),持续回归。
6.4 集成到 CI/CD
写好规则后,包装成一个 Maven 插件或者 Gradle 任务,每次 push 代码时自动运行。如果扫描出高危漏洞,直接阻断合并请求。这一步的细节很多,比如增量扫描、缓存、结果报告,但核心是把规则跑起来并且产生可读的输出。
七、技术优缺点与实际场景分析
7.1 优点
- 高度定制:自家框架的特殊写法(比如封装了一层 DAO 工具类)都能准确定位。
- 开源免费:不用额外买商业工具,维护成本可控。
- 可扩展:发现新漏洞模式后,加上一条规则就能快速响应。
7.2 缺点
- 规则编写门槛高:需要懂 AST 和漏洞原理,不是随便一个开发就能写的。
- 误报率难控制:规则太松,漏报多;规则太严,误报满天飞,开发者会麻木。
- 维护成本随着时间增长:随着代码库增长和框架版本升级,规则需要不断更新。
- 跨语言支持麻烦:每种语言的分析引擎不一样,可能需要维护多套规则。
7.3 应用场景
- 金融、政务等安全要求高的行业:合规检查必备,自建规则可覆盖行业特色漏洞。
- 初创公司或中型企业:没有预算买商业工具,但希望有基础的安全检测。
- 大型公司内部 DevSecOps 流水线:把规则作为代码审查的补充,甚至提前到编写阶段。
7.4 注意事项
- 性能问题:AST 解析和遍历很耗内存,对于几百万行级别的仓库,需要做增量扫描或分布式扫描。
- 可解释性:报漏洞时一定要给出修复建议和触发位置的上下文,否则开发会不信任扫描工具。
- 规则冲突:可能同一行代码触发多条规则(比如既是 SQL 注入又是硬编码),需要去重或者合并显示。
- 不要依赖单一检测方式:SAST 只能发现“写法上的问题”,真正的逻辑漏洞(如权限绕过、业务逻辑错误)很难通过模式匹配发现,需要结合 DAST 和人工审计。
八、总结
从零搭建企业级 SAST 规则库听起来挺吓人,但拆开来看就是把“漏洞模式”翻译成“代码结构查询”。我们从 OWASP Top 10 里挑了注入、硬编码两个典型,用 Java 的 JavaParser 库写了两条规则,顺便理清了 Go 的思路。整个过程的精髓在于:不要追求一次完美,先跑起来,再逐步调整误报和漏报。只要坚持把每一次线上事故的根因转化成一条新规则,半年后你手头的规则库就会成为团队最宝贵的安全资产。
最后提一句,规则库不是写完就完事了,得有人持续维护。建议把规则当成代码一样管理:版本控制、代码审查、单元测试一样不能少。只有这样才能让它真正在生产环境中发挥作用,而不是一份没人看的文档。
Comments