Skip to content

如何编写自定义规则检查器

Mengting Chen edited this page Jan 22, 2024 · 17 revisions

注:在此文档中,将使用//来表示仓库的根目录。

创建自定义规则集

创建自定义规则集的过程如下:

  1. 创建规则集的基础文件和相关改动
  2. 编写自定义规则检查器(checker)
  3. 集成到容器镜像

创建规则集的基础文件和相关改动

  1. 在仓库的根目录新建一个以规则集命名的文件夹(比如//toy_rules),并在其中添加.gitignore(参考//toy_rules/.gitignore

  2. 为这个规则集新增Run函数(参考//toy_rules/analyzer/run.go),并在//misra/analyzer/cmd/main.go中调用它:

    func selectRun(rulePrefix string) (runFuncType, error) {
    	switch rulePrefix {
    		...
    		case "toy_rules":
    			return toy_rules.Run, nil
    		...
    	}
    }
  3. //misra/analyzer/cmd/main.goruleSets里添加新建的规则集及其对应的语言(C/C++/both),它让镜像能找到这个新增的规则集。

  4. 如果需要用go test测试,在//cruleslib/testlib/testlib.gocheckingStandards里添加新建的规则集,它让testlib能找到这个新增的规则集。

编写自定义规则检查器(checker)

//toy_rules/rule_1为例,可以看到它的文件夹结构类似:

rule_1
├── _bad0001
│   ├── bad.cc
│   ├── expected.textproto
│   └── Makefile
├── _good0001
├── libtooling
├── rule_1_test.go
└── rule_1.go

其中,_bad0001_good0001是存放测试用例的文件夹,_bad0001是违规测试用例,_good0001是合规测试用例。expected.textproto是期望的测试结果,指定违规出现的位置与报错信息内容,由开发者编写,格式类似:

results {
    path: "bad.cc"
    line_number: 6
    error_message: "NULL不得用作整型值"
}

在测试过程中,如果运行结果和期望测试结果完全一致,则测试通过。注意,即使是合规测试数据,也需要一个空的expected.textproto

libtooling文件夹中存放的是该规则的 libtooling 实现。如果没有 libtooling 实现,则无此文件夹。

rule_1.go中是调用 checker binary/script 的逻辑,它将调用//cruleslib/runner/runner.go中不同工具的 runner(如RunLibtoolingRunCppcheck等)来指定所要运行检查的 checker。

rule_1_test.go中是go test测试的逻辑,比如

func TestBad0001(t *testing.T) {
	tc := testcase.NewWithSystemHeader(t, "_bad0001")
	tc.ExpectOK(testlib.ToTestResult(Analyze(tc.Srcdir, tc.Options)))
}
}

ExpectOK即代表Analyze函数的返回结果和expected.textproto中的内容一致,ExpectFailure则代表不一致。NewWithSystemHeader函数是为了新建一个测试用例并添加一些系统库的路径。

集成到容器镜像

  1. //podman_image/bigmain/BUILD里添加toy_rules_depsbigmain_toy_rules
cc_library(
    name = "toy_rules_deps",
    deps = [
        "//toy_rules/rule_1/libtooling:rule_1_lib",
        # 可以继续添加新的自定义规则
    ],
)
cc_binary(
    name = "bigmain_toy_rules",
    srcs = ["main.cc"],
    deps = [
        ":rule",
        ":toy_rules_deps",
        "//libtooling_includes:cmd_options",
        "@com_github_google_glog//:glog",
        "@com_google_absl//absl/strings:str_format",
    ],
)
  1. //podman_image/bigmain_symlink里添加
mkdir /opt/naivesystems/toy_rules
ln -s /opt/naivesystems/bigmain /opt/naivesystems/toy_rules/rule_1
  1. 新增一个文件//podman_image/Containerfile.toyrules。如果新增的规则集只检查 C 语言,则基于misrac中创建镜像,如果要检查 C++,则基于misracpp中创建,再将bigmain_toy_rules拷入镜像。如果需要中文镜像,这里base_tagdev而不是dev_en
ARG base_tag=dev
FROM naive.systems/analyzer/misracpp:${base_tag}
COPY "bigmain_toy_rules" "/opt/naivesystems/bigmain"
  1. 最后在//podman_image/Makefile中添加bigmain_toy_rulesbuild-toy-rules-en目标来生成镜像。

经过这些步骤之后,在//podman_image下运行make build-toy-rules-en,便可以得到一个包含新规则集的容器镜像。在待测试项目的.naivesystems/check_rules文件中,添加toy_rules/rule_1,便可运行以下指令,使用所生成的镜像进行代码静态分析:

podman run -v $PWD:/src:O -v $PWD/.naivesystems:/config:Z \
  -v $PWD/output:/output:Z -w /src \
  naive.systems/analyzer/toyrules:dev_en \
  /opt/naivesystems/misra_analyzer -show_results -alsologtostderr

检测的所有结果将保存至所测试项目的output/results.nsa_results

如何写好一个新的 checker

一个 checker 需要选择合适的检查工具并实现,然后对其结果进行相应的处理,生成符合需求的 resultsList。

根据问题类型确定检查工具

NaiveSytems Analyze 是用来检查项目中代码是否违规,包括但不限于资源泄漏、内存越界、栈地址逃逸等等。比如对于下列代码

long l = 100000;
int8_t i = 0;
i = l;

该代码可能存在精度丢失的问题,再比如对于下列代码

int i = 8 / 0;

该代码中存在除零错误。

我们一般将需要检查的问题分成两类,一类是 STU(single translation unit), 即只在单个翻译单元中就能查出来的错误,比如上面的精度缺失问题。另一类是 CTU(cross translation unit), 即需要跨多个翻译单元才能检查出来的错误,比如对于除零错误,可能有如下例子:

// test.h
int getDiv(int a, int b);

// test.cc
int getDiv(int a, int b) {
    return a / b;
}

// main.cc
#include "test.h"

int main() {
    getDiv(10, 0);
    return 0;
}

这就需要test.ccmain.cc两个翻译单元一起看,才能检查出来错误。

从另一个角度,我们还可以将问题分成两种,一种是能直接在 AST 上看出来的问题,一种是需要深度分析才能解决的问题。比如对于精度缺失问题:

void test(void)
{
    long l = 100000;
    int i = 1;
    i = l;
}

我们用 Clang dump 出 AST

$ clang -Xclang -ast-dump -fsyntax-only test.c
`-FunctionDecl 0x1208e23e8 <test.c:1:1, line:6:1> line:1:6 test 'void (void)'
  `-CompoundStmt 0x1208e26e8 <line:2:1, line:6:1>
    |-DeclStmt 0x1208e2588 <line:3:5, col:20>
    | `-VarDecl 0x1208e24e8 <col:5, col:14> col:10 used l 'long' cinit
    |   `-ImplicitCastExpr 0x1208e2570 <col:14> 'long' <IntegralCast>
    |     `-IntegerLiteral 0x1208e2550 <col:14> 'int' 100000
    |-DeclStmt 0x1208e2640 <line:4:5, col:14>
    | `-VarDecl 0x1208e25b8 <col:5, col:13> col:9 used i 'int' cinit
    |   `-IntegerLiteral 0x1208e2620 <col:13> 'int' 1
    `-BinaryOperator 0x1208e26c8 <line:5:5, col:9> 'int' '='
      |-DeclRefExpr 0x1208e2658 <col:5> 'int' lvalue Var 0x1208e25b8 'i' 'int'
      `-ImplicitCastExpr 0x1208e26b0 <col:9> 'int' <IntegralCast>
        `-ImplicitCastExpr 0x1208e2698 <col:9> 'long' <LValueToRValue>
          `-DeclRefExpr 0x1208e2678 <col:9> 'long' lvalue Var 0x1208e24e8 'l' 'long'

从 AST 上我们可以看出,有一些ImplicitCastExpr是从longint,这代表代码违规。这类问题的主要特征是可以从代码结构上直接看出代码违规。

但对于除零问题,除数可能是一个通过复杂计算得到的结果,比如:

int d = 5;
int i = 10 / (d - d);

这里我们需要简单算一下d-d的结果,才能检查到错误。对于更复杂的情况,这个计算可能非常复杂,也可能是从另外一个地方得到的结果,需要使用深度分析去解决问题。

对于可以直接从 AST 上得出结论的问题,我们一般使用 libtooling、cppcheck、ClangSema、ClangTidy 等。对于需要深度分析才能解决的问题,我们一般使用 CSA 和 Infer。

libtooling checker

libtooling 是 Clang 官方的工具,可以理解为 Clang 的一种插件。使用者通过编写 AST Matcher,匹配到想要 的 AST 节点,然后会自动调用预先设定好的回调(Callback)函数进行处理。

比如对于 MISRA C++:2008 4.10.1 NULL 不得用作整型值,我们所编写的代码如下:

class Callback : public MatchFinder::MatchCallback {
 public:
  void Init(ResultsList* results_list, MatchFinder* finder) {
    results_list_ = results_list;
    finder->addMatcher(
        implicitCastExpr(hasSourceExpression(expr(gnuNullExpr())),
                         hasImplicitDestinationType(isInteger()),
                         unless(isExpansionInSystemHeader()))
            .bind("cast"),
        this);
  }

  void run(const MatchFinder::MatchResult& result) override {
    const Expr* expr = result.Nodes.getNodeAs<Expr>("cast");
    string error_message = "NULL不得用作整型值";
    string path = GetFilename(expr, result.SourceManager);
    int line = GetLine(expr, result.SourceManager);
    AddResultToResultsList(results_list_, path, line, error_message);
  }

 private:
  ResultsList* results_list_;
};

这段代码匹配了不在系统库中的,所有从gnuNullExprsourceExpressionisIntegerimplicitDestinationTypeimplicitCastExpr,匹配到后会自动调用回调函数run,这里直接报错,结果添加至 resultsList 里。

它是 STU 类型的问题,所以调用时使用checker_integration.Libtooling_STU指定类型:

runner.RunLibtooling(srcdir, "misra_cpp_2008/rule_4_10_1", checker_integration.Libtooling_STU, opts)

完整代码参考//misra_cpp_2008/rule_4_10_1

更多关于如何编写 libtooling checker 的细节,请查看 libtooling 的官方文档相关教程

关于 libtooling 文件夹的结构,以//toy_rules/rule_1为例:

rule_1
├── libtooling
│   ├── BUILD
│   ├── checker.cc
│   ├── checker.h
│   ├── lib.h
│   ├── main.cc
│   └── rule_1.cc
  • checker.hchecker.cc中存放的是 checker 的具体实现
  • BUILD存放的是 Bazel 的定义
  • main.cc中包含调用 libtooling checker 的逻辑、参数的解析和 checker 分析结果写入指定文件的逻辑

除了 checker 的具体实现逻辑以外,其他的部分基本上所有实现都是差不多的。开发时,可以在此文件夹运行bazel build rule_1来生成可供调用的 binary。

cppcheck checker

cppcheck 是一个开源工具,它已经被 check in 到我们的代码库中://third_party/cppcheck。当我们编译后,会在该文件夹下生成一个名为cppcheck的 binary。

我们分析代码的时候,会先用这个 binary 生成一个 dumpfile, 该文件是一个类似于 Clang AST 的文件信息结构,最后使用一个脚本去分析这个 dumpfile。比如假设我们有代码

#include <clocale> // Non-compliant

int main() {
    return 0;
}

先生成 dumpfile

~/analyze/third_party/cppcheck/cppcheck --dump main.cpp

然后使用命令

~/analyze/third_party/cppcheck/cppcheck --abspath --dump --std=c99 --dump-file=main.cpp.c99.dump main.cpp
~/analyze/third_party/cppcheck/addons/misra.py --check_rules=misra_c_2012/rule_15_1 --output_dir output bad1.cpp.c99.dump

就能得到分析结果。这些命令一般用于开发,实际调用时被包装在了 runner 里:

runner.RunCppcheck(srcdir, "misra_c_2012/rule_15_1", checker_integration.Cppcheck_STU, opts)

需要注意的是,cppcheck 对于只有 directives 的文件不能正常生成 dumpfile, 编写测试用例时需要再加入一个main函数。

我们的 cppcheck 实现都在//third_party/cppcheck/addons/misra.py里,可以搜索相关规则的名字(如misra_15_1)来查看对应 checker 的内容。

//toy_rules/rule_2为例,在//third_party/cppcheck/addons/misra.py需要添加一些调用的逻辑:

假如 toy_rules 是一个从未在misra.py中出现过的规则集,需要

  1. 新建一个executeToyRuleCheck函数用来执行 checker
    def executeToyRuleCheck(self, check_function, *args):
        check_function(*args)
  1. 新建一个ToyRuleResult类用于封装 checker 的结果
class ToyRuleResult:
    def __init__(self, path, line_num, err_msg, other_locations = None):
        self.path = path
        self.line_number = line_num
        self.error_message = f'{err_msg}'
        self.locations = [ErrorLocation(path, line_num)]
        if other_locations is not None:
            for loc in other_locations:
                self.locations.append(ErrorLocation(loc.file, loc.linenr))
  1. 新建一个reportToyRuleError用于向 JSON list 添加结果和向 stdout/stderr 输出结果。error_message 具体格式可根据需求自行定义,此处只传入一个 error_id。
    def reportToyRuleError(self, location, rule_num, other_locations = None):
        if self.settings.verify:
            self.verify_actual.append('%s:%d %d.%d.%d' % (location.file, location.linenr, rule_num))
        else:
            error_id = f"Rule-{rule_num}"
            toyrule_severity = 'Required'
            this_violation = '{}-{}-{}-{}'.format(location.file, location.linenr, location.column, rule_num)
            # If this is new violation then record it and show it. If not then
            # skip it since it has already been displayed.
            if not this_violation in self.existing_violations:
                self.existing_violations.add(this_violation)
                self.current_json_list.append(ToyRuleResult(location.file, location.linenr, error_id, other_locations))
                cppcheckdata.reportError(location, toyrule_severity, "", "toy", error_id)
                if toyrule_severity not in self.violations:
                    self.violations[toyrule_severity] = []
                self.violations[toyrule_severity].append('toy' + "-" + error_id)

结果的 JSON list 将存在//toy_rules/rule_2/_bad0001/output/tmp/test_run/test_run-*/cppcheck_out.json中,内容大致为:

[
    {
        "path": "/home/username/analyze/toy_rules/rule_2/_bad0001/bad.c",
        "line_number": 3,
        "error_message": "Rule-2",
        "locations": [
            {
                "path": "/home/username/analyze/toy_rules/rule_2/_bad0001/bad.c",
                "line_number": 3
            }
        ]
    }
]

假如规则集已存在,将其中某条规则的 checker 实现添加至规则集:

  1. 添加实现的函数toy_rule_2(参考 MISRA C:2012 15.1 不应使用goto语句)
    def toy_rule_2(self, data):
        for token in data.tokenlist:
            if token.str == "goto":
                self.reportToyRuleError(token, 2)
  1. 将函数toy_rule_2添加至parseDump列表,参数根据规则需要传入cfgdata.rawTokens或者dumpfile
            if "toy_rules/rule_2" in rules_list or check_rules == "all":
                self.executeToyRuleCheck(self.toy_rule_2, cfg)

之后runner便可用toy_rules/rule_2调用其对应的 cppcheck 实现并检查其输出:

runner.RunCppcheck(srcdir, "toy_rules/rule_2", checker_integration.Cppcheck_STU, opts)

ClangSema checker

ClangSema 是我们利用 Clang diagnostic flags 来检查一些问题的工具,一般来说,如果 vscode 的 Clang 插件提示代码有问题或者在编译时报了 warning,以 AUTOSAR A-5-3-3 Pointers to incomplete class types shall not be deleted. 为例,编译时它会报 warning:

bad1.cpp:6:13: warning: deleting pointer to incomplete type 'C::Impl' may cause undefined behavior [-Wdelete-incomplete]
            delete pimpl;
            ^      ~~~~~

我们就可以在 Analyze 里直接以 -Wdelete-incomplete 这个 diagnostic flag 调用 ClangSema,并根据其返回的 err 里是否有相关关键词来检查:

results, err := runner.RunClangSema(srcdir, "-Wdelete-incomplete", opts)

完整示例在//autosar/rule_A5_3_3

ClangSema 能检查的问题列表在 DiagnosticsReference

ClangTidy checker

ClangTidy 也是 Clang 官方提供的工具, 它内部也是用 libtooling 实现的,相当于他帮我们写好了一些 libtooling 的 checker, 我们可以直接使用:

runner.RunClangTidy(srcdir, args, opts)

其中 args 是一个 string list,可以同时用多个 checker 检查同一个规则,比如示例//autosar/rule_A7_5_2

ClangTidy 能检查的问题列表在 https://clang.llvm.org/extra/clang-tidy/checks/list.html

CSA checker

请先阅读 CSA(Clang Static Analyzer)的官方文档。总的来说,CSA 是一个基于符号执行技术的路径敏感的过程间分析工具,我们用它来解决需要依赖状态的问题,比如上面的除零问题。

我们同样将 CSA check in 到了我们的代码库当中。你可以在//third_party/llvm-project/clang/lib/StaticAnalyzer/Checkers中找到当前已经实现的 checker,其中一部分是 Clang 自带的,带有规则集名字的是我们自己实现的。

DivZeroChecker这个 checker 为例,文件中的checkPreStmt是一个 callback 函数,CSA 会在每次遇到新的Stmt的时候自动调用这个函数,这时我们检查BinaryOperator是不是除法,如果是的话,拿到他的 rhs,也就是除数。通过ConstraintManager来判断 rhs 有没有为 0 的可能。其中ConstraintManagerCheckerContext会存储变量和符号的状态,如果可能为 0 则报错。

具体的 checker 实现后,可以用-analyzer-checker来调用相关的 CSA checker,可以同时调用多个 CSA checker,用,分隔。

runner.RunCSA(srcdir, "-analyzer-checker=core.DivideZero", opts)

相关示例在//autosar/rule_A5_6_1//autosar/rule_A0_4_4

Infer checker

Infer 是一个类似于 CSA 的工具,它能检查的问题列表在 all-issue-types

我们用对应的 issue type 来调用

runner.RunInfer(srcdir, "--liveness", opts)

相关示例在//misra_c_2012_crules/rule_2_2

Go checker

还有一些问题我们只需要对文件进行字符串处理就可以解决,比如//autosar/rule_A13_6_1

Semgrep checker

Semgrep 是一个基于模式(Pattern)匹配来识别代码中的特定结构或模式,从而进行静态分析的工具,允许用户更灵活地自定义检测规则。它支持多种编程语言,包括但不限于 Python、JavaScript、Go、Java、C/C++ 和 Ruby。

想要自定义 Semgrep 规则,请按照以下步骤进行:

  1. 根据 规则语法,在.naivesystems编写*.semgrep文件,它将描述所自定义规则的所有相关信息。以下为示例//semgrep/double_free.semgrep
rules:
  - id: double-free
    patterns:
      - pattern-not: |
          free($VAR);
          ...
          $VAR = NULL;
          ...
          free($VAR);
      - pattern-not: |
          free($VAR);
          ...
          $VAR = malloc(...);
          ...
          free($VAR);
      - pattern-inside: |
          free($VAR);
          ...
          $FREE($VAR);
      - metavariable-pattern:
          metavariable: $FREE
          pattern: free
      - focus-metavariable: $FREE
    message: Variable '$VAR' was freed twice. This can lead to undefined behavior.
    languages:
      - c
    severity: ERROR

其中,各个模式之间的逻辑关系由各个 运算符 定义:

  • pattern-not查找与其表达式不匹配的代码
  • pattern-inside保留在其表达式内的匹配结果,一般用于在代码块(block)中查找代码
  • metavariable-pattern将元变量与模式公式进行匹配,一般用于根据元变量的值过滤结果
  • focus-metavariable将焦点置于单个元变量或元变量列表

patterns的评估顺序不受声明顺序影响,将按正模式(pattern-insidepattern等)-> 负模式(pattern-not等)-> 条件(metavariable-pattern等) -> 焦点元变量(focus-metavariable)的顺序进行。

模式的编写需要遵循 模式语法

  • free()匹配名为 free 的 函数调用
  • ... 省略号 匹配零个或多个item的序列,例如参数、语句、参数、字段、字符
  • $ 匹配 元变量,即事先不知道值或内容时匹配代码的抽象,可用于跟踪特定代码范围内的值,包括变量、函数、参数、类、对象方法、导入、异常等

除了模式,此文件中必须指定的字段还包括 idmessagelanguages severity

Playground 进行更多的尝试,查看更多的 示例

  1. .naivesystems新建文件semgrep_rules,将编写好的*.semgrep规则文件的相对路径(或者绝对路径)列在里面:
double_free.semgrep
  1. 运行镜像得到 Semgrep 的规则结果,将同样保存至output/results.nsa_results

  2. 如果需要go test测试,在*test.go里引用runner.RunSemgrep

func TestBad0001(t *testing.T) {
	tc := testcase.NewWithoutOptions(t, "_bad0001")
	tc.ExpectOK(testlib.ToTestResult(runner.RunSemgrep(tc.Srcdir, filepath.Dir(tc.Srcdir), testlib.GetSemgrepBinPath())))
}

Coccinelle checker

Coccinelle 是一个程序匹配和转换引擎,它基于 SmPL(Semantic Patch Language),用于在 C 代码中寻找指定的匹配和转换。Coccinelle 最初是被用来进行代码修改,比如重命名函数、修改函数参数、重新组织数据结构等,但由于它的特性,现在也被用于寻找代码中的错误。

想要自定义 Coccinelle 规则,请按照以下步骤进行:

  1. 根据 SmPL 语法 ,在 .naivesystems 编写 *.cocci 文件,用以匹配指定的代码模式。以下为示例 //coccinelle/find_alloca.cocci:
@@ expression E; @@

-alloca(E)
+malloc(E)

SmPL 格式上有些类似 diff 文件,以上模式只会匹配 alloca() 的调用,而不会匹配到注释中出现的 alloca()

  1. .naivesystems 新建文件 cocci_rules,将编写好的 *.cocci 规则文件的相对路径(或者绝对路径)列在里面。另外,如果需要自定义错误信息,可以以 json 的形式加在路径之后,如以下示例:
find_alloca.cocci {"error-message": "alloca() should not be used"}
  1. 运行镜像得到 Coccinelle 的规则结果,将同样保存至 output/results.nsa_results

  2. 如果需要 go test测试,在 *test.go 里引用 runner.RunCoccinelle

func TestBad0001(t *testing.T) {
	tc := testcase.NewWithoutOptions(t, "_bad0001")
	tc.ExpectOK(testlib.ToTestResult(runner.RunCoccinelle(tc.Srcdir, filepath.Dir(tc.Srcdir))))
}

AST-GREP (SG) checker

AST-GREP 是一个基于 AST 模式用于结构搜索和替换的代码分析工具,可用于代码结构搜索、lint、大规模重写,支持 C、Golang、Python 等多种编程语言。该工具无需对项目进行编译,可以自动检测项目代码的语言。用户可以使用直观的语法,轻松地添加新的自定义规则。

想要自定义 AST-GREP 规则,请按照以下步骤进行:

  1. .naivesystems 编写 *.yaml 文件,用以匹配指定的代码模式。以下为示例 //ast-grep/method_receiver/method_receiver.yaml:
id: method_receiver
message: Rewrite method to function call
language: c
rule:
  pattern: $R.$METHOD($$$ARGS)

与官网示例 Rewrite Method to Function Call 相比,注意这里只需要指定 rule ,而不需要指定 transformfix,因为我们只进行 linting。其中的 pattern 遵循 Pattern 语法 编写,可以查看更多的 示例

  1. .naivesystems 新建文件 ast_grep_rules,将编写好的 *.yaml 规则文件的相对路径(或者绝对路径)列在里面,如以下示例:
method_receiver.yaml
  1. 运行镜像得到 AST-GREP 的规则结果,将同样保存至 output/results.nsa_results

  2. 如果需要 go test测试,在 *test.go 里引用 runner.RunAstGrep

func TestBad0001(t *testing.T) {
	tc := testcase.NewWithoutOptions(t, "_bad0001")
	tc.ExpectOK(testlib.ToTestResult(runner.RunAstGrep(tc.Srcdir, filepath.Dir(tc.Srcdir), testlib.GetAstGrepBinPath())))
}

其它工具

我们还集成了一些其它的工具,比如可以根据 Clang(RunClangForErrorsOrWarnings)、GCC(RunGCC)、Cpplint(RunCpplint)的报错来分析,具体可参考//cruleslib/runner/runner.go

多种 checker 合并

如果只有一种 checker,一般 runner 输出的 resultsList 便已符合要求。如果需要使用多种 checker 来检查同一条规则,只需要最后将所有 checker 产生的 results 合并起来即可,相关示例在//misra_c_2012_crules/rule_2_2