一、给企业做安全检查,为什么非得自己搞一套规则库

聊 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);
    }
}

这段代码的逻辑很直白:找到所有调用 executeQueryexecute 的地方,看第一个参数是不是字符串拼接表达式。如果是一般的参数化查询,参数会是 ? 占位,不会是拼接。当然这只是一个初级规则,实际还要考虑比如 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/astgo/parser,加上一些第三方库比如 golang.org/x/tools/go/analysis 能快速构建自定义检查器。

举例来说,检测 Go 中的 SQL 注入,可以从调用 db.Querydb.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 的思路。整个过程的精髓在于:不要追求一次完美,先跑起来,再逐步调整误报和漏报。只要坚持把每一次线上事故的根因转化成一条新规则,半年后你手头的规则库就会成为团队最宝贵的安全资产。

最后提一句,规则库不是写完就完事了,得有人持续维护。建议把规则当成代码一样管理:版本控制、代码审查、单元测试一样不能少。只有这样才能让它真正在生产环境中发挥作用,而不是一份没人看的文档。