ラック・セキュリティごった煮ブログ

セキュリティエンジニアがエンジニアの方に向けて、 セキュリティやIT技術に関する情報を発信していくアカウントです。

株式会社ラックのセキュリティエンジニアが、 エンジニアの方向けにセキュリティやIT技術に関する情報を発信するブログです。(編集:株式会社ラック・デジタルペンテスト部)
/ セキュリティブログ / セキュリティエンジニア /
当ウェブサイトをご利用の際には、こちらの「サイトのご利用条件」をご確認ください。

デジタルペンテスト部提供サービス:ペネトレーションテスト

Opengrep の中身を読む(前編):scan コマンドと AST マッチングの仕組み

こんにちは、魚脳です。今回は静的解析ツール(SAST: Static Application Security Testing)の一種Opengrepを紹介したいと思います。

はじめに

静的解析ツール(SAST: Static Application Security Testing)は、ソースコードを実行せずに解析し、バグや脆弱性の兆候を検出する仕組みです。
CI に組み込んで早い段階で問題を見つけたり、コードレビューの補助として使われたりと、さまざまな場面で活用されています。

SAST の代表的なツールとしては、Semgrep や CodeQL などが知られています。
Semgrep は AST ベースのパターンマッチを中心とした比較的軽量な解析が特徴で、CodeQL はコードをデータベース化し、クエリで解析することで、より広範なデータフローを扱える点が特徴です。用途や目的に応じて、これらのツールが使い分けられています。

その Semgrep ですが、2024 年末にエンジン本体は引き続きオープンソースとして提供される一方で、Semgrep が管理するルールセットのライセンス条件が変更される、というアップデートが公式に発表されました。

“Semgrep OSS is now Semgrep Community Edition. Semgrep-maintained rules are now licensed under Semgrep Rules License v.1.0, so that they’re available only for internal, non-competing, and non-SaaS contexts.”
ー Semgrep blog: Important updates to Semgrep OSS

*1

こうした提供形態や運営方針の変化を背景に、オープンな形でツールとエコシステムを継続的に発展させることを目的として、Opengrep は Semgrep から独立したプロジェクトとして開発が進められています。

本記事では、この Opengrep の中でも、実際に解析を実行するエントリーポイントである scan 機能に注目し、「内部でどのような処理が行われているのか」をソースコードを交えながら整理します。
性能評価やツール比較を目的とするのではなく、あくまで仕組みの理解に焦点を当てた内容とします。後編では、taint 解析の拡張機能である taint-intrafile について、もう少し詳しく見ていく予定です。


Opengrep とは

Opengrep は、Semgrep と互換性のあるルールフォーマットと実行モデルを採用した、オープンソースの静的解析ツールです。
基本的な使い方や考え方は Semgrep と共通しており、「ルールを用意してコードを解析し、マッチした箇所を検出する」というワークフローで利用します。

実装面にて、Opengrep は基本的にSemgrep の OSS 版エンジンをベースにしつつ、独自の改良を加えたエンジンを採用しています。
そのため、既存の Semgrep ルールやドキュメントを活用しやすい一方で、Opengrep 側で追加された機能や改善点もこの一年を通じて少しずつ追加されています。

現行機能と使い方

Opengrep を使う際の基本は、scan サブコマンドです。
Opengrep には複数のサブコマンドがありますが、実際の解析処理は基本的にこの scan から実行されます。

opengrep --help
Usage: opengrep [OPTIONS] COMMAND [ARGS]...

  To get started quickly, run `opengrep scan --config auto`

  Run `opengrep SUBCOMMAND --help` for more information on each subcommand

  If no subcommand is passed, will run `scan` subcommand by default

Options:
  -h, --help  Show this message and exit.

Commands:
  lsp                  Start the Opengrep LSP server (useful for IDEs)
  scan                 Run Opengrep rules on local folders or files
  show                 Show various types of information
  test                 Test the rules
  validate             Validate the rules

最もシンプルな使い方は、ルールと解析対象のパスを指定して scan を実行するだけです。

opengrep scan -c rules.yaml path/to/project

このコマンドは、rules.yaml に定義されたルールを使って path/to/project 以下のソースコードを解析し、 ルールにマッチした箇所を結果として出力します。

ルール

Opengrep で利用できるルールは、大きく分けて テイント解析用ルールパターンルール の 2 種類です。

どちらも scan から同じように指定して実行できますが、内部で行われる解析の性質は少し異なります。

パターンベースのルールは、AST(抽象構文木:Abstract Syntax Tree)に基づくパターンマッチによって、特定のコード構造を検出します。
例えば「特定の API 呼び出しが使われている箇所」や「好ましくない書き方をしているコード」を探す用途に向いています。
grep に近い感覚で使えますが、構文構造を理解した上でマッチングされる点が特徴です。

rules:
- id: test-rule
  message: |
    test rule
  languages:
  - python
  severity: WARNING
  pattern-either:
  - pattern: |
      foo($_)
  - pattern: |
      bar($X, $X)

上記のルールでは次の Python ソースコードに対して、任意の引数を含むfoo関数 と 値が2つの引数が同じ値になる関数 bar を探すこととなります。

# Match:
foo(1)

# Match:
bar(1, 1)

# Not Match:
bar(1, 2)

# Not Match:
bar(2, 3)

一方、テイント解析のルールは、まず AST に基づくパターンマッチによって外部入力など「値が入り込む起点(ソース)」と、その値が到達すると問題になりうる処理(シンク)を検出します。
その上で、ソースからシンクまで値がそのまま、あるいは加工されながら伝播していないかを追跡します。

rules:
  - id: taint-example
    languages:
      - python
    message: Found dangerous HTML output
    mode: taint
    pattern-sources:
      - pattern: get_user_input(...)
    pattern-sinks:
      - pattern: html_output(...)
    severity: WARNING

上記のルールでは関数get_user_input由来のデータが関数html_outputに使われるところを探すことを意味し、マッチングするソースコード例は以下のようになります。

def route2():
    data = get_user_input()
    # ruleid: taint-example
    return html_output(data)

さらに、このテイント解析には拡張機能として --taint-intrafile という新しいオプションが最近追加されました。
これは、同一ファイル内で関数をまたいだデータの伝播を扱うための機能で、通常のテイント解析より一歩踏み込んだ解析を対象にしています。

後編では、この --taint-intrafile オプションを取り上げ、公式ドキュメントの説明と実装を照らし合わせながら、具体的に何をしているのかを整理する予定です。

scan機能の実装を追う:全体フローと責務

scan 機能は、大きく分けて以下の3段階による処理が進みます。

  • scan
    • ソースコードをASTに変換
      • 一旦固有言語ASTに変換してから、汎用ASTに変換
    • ルールをASTに変換
    • ソースコードの汎用ASTを走査しながらルールのASTとのマッチングを行う

この章では、scan がどのようにルールを基にマッチングを行い、結果を出力するのかを、実装の流れに沿って追っていきます。

ソースコードをASTに変換

Opengrep では、多言語対応やルール・解析エンジンの共通化のため、いったん言語固有 AST に変換した後、汎用 AST に変換する設計になっています。

ソースコードファイルに対する処理はsrc/target/Xtarget.mlに入っていますが、具体的な処理は/src/parsing_languages/Parse_target2.mlがメインとなります。 *2

  | Lang.Python3 ->
      let parsing_mode = lang_to_python_parsing_mode lang in
      run file
        [
          Pfff (throw_tokens (Parse_python.parse ~parsing_mode));
          TreeSitter Parse_python_tree_sitter.parse;
        ]
        Python_to_generic.program

ソースコードに対しては一度tree-sitterPfff*3によって一度ソースコードから言語固有のASTにパースされてから*4Python_to_generic言語専用の変換モジュールによって汎用ASTに変換されます。

ソースコード側の AST は サブコマンドshow のオプションdump-ast で確認できます。今回のPythonコードの場合、以下のようになります。

opengrep show dump-ast py ~/test/test-rule.py
Pr(
  [ExprStmt(
     Call(
       N(
         Id(("foo", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
       [Arg(L(Int((Some(1), ()))))]), ());
   ExprStmt(
     Call(
       N(
         Id(("bar", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
       [Arg(L(Int((Some(1), ())))); Arg(L(Int((Some(1), ()))))]), ());
   ExprStmt(
     Call(
       N(
         Id(("bar", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
       [Arg(L(Int((Some(1), ())))); Arg(L(Int((Some(2), ()))))]), ());
   ExprStmt(
     Call(
       N(
         Id(("bar", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
       [Arg(L(Int((Some(2), ())))); Arg(L(Int((Some(3), ()))))]), ())])

ルールをASTに変換

比較するためにルールもソースコードと同様にASTに変換されます。yaml形式のファイルがsrc/parsing/Parse_rule.mlに読み込まれ、languages/yaml/generic/yaml_to_generic.ml*5中の関数parseによって汎用ASTにパースされます。 ルール側のASTもサブコマンドshow のオプションdump-patterns-of-rule で確認できます。前節で扱ったルールだと以下のような結果になります。

opengrep show dump-patterns-of-rule ~/test/test-rule.yaml
E(
  Call(
    N(
      Id(("foo", ()),
        {id_flags=Ref(0); id_resolved_alternative=Ref([]);
         id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
    [Arg(
       N(
         Id(("$_", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })))]))
E(
  Call(
    N(
      Id(("bar", ()),
        {id_flags=Ref(0); id_resolved_alternative=Ref([]);
         id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })),
    [Arg(
       N(
         Id(("$X", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })));
     Arg(
       N(
         Id(("$X", ()),
           {id_flags=Ref(0); id_resolved_alternative=Ref([]);
            id_resolved=Ref(None); id_type=Ref(None); id_svalue=Ref(None); })))]))

ソースコードの汎用ASTを走査しながらルールのASTとマッチング

ソースコードの走査とルールとの比較は、主に src/matching/Generic_vs_generic.ml が担当します。
基本的には、ソースコード側の AST ノードとパターン側の AST ノードの型や内容に応じて分岐し、再帰的に AST の部分木を降りていきます。ただし、この説明だけではイメージしにくいと思うので、前小節で扱ったテスト用ルールとソースコードが実際にマッチングされる様子を追いながら説明します。

まず、前小節で扱ったルールとソースコードを scan にかけると、次のような結果になります。

┌─────────────────┐
│ 2 Code Findings │
└─────────────────┘

    test-rule.py
    ❯❱ test-rule
          test rule


            3┆ foo(1)
            ⋮┆----------------------------------------
            6┆ bar(1, 1)

この結果から分かるように、foo($_) は任意の引数を取る関数呼び出し foo(1) にマッチし、bar($X, $X) は 2 つの引数が同一である bar(1, 1) にマッチしています。

ここでは例として、foo($_)foo(1) がマッチするまでの過程を、OCaml 用のデバッガを使って追ってみます。

set arguments -j 1 -debugger -rules ~/test/test-rule.yaml -l py ~/test/test-rule.py
break @ Generic_vs_generic 815
break @ Generic_vs_generic 956
break @ Generic_vs_generic 1734

デバッガを起動し、必要なパラメータを設定した上で、ソースコード Generic_vs_generic.ml の 815行目*6、956 行目*7、1734 行目*8にブレークポイントを仕掛けます。

and m_expr ?(is_root = false) ?(arguments_have_changed = true) a b =
  Trace_matching.(if on then print_expr_pair a b);(* 815行目 *)
(* ... *)
and m_arguments a b =
  Trace_matching.(if on then print_arguments_pair a b);(* 1734行目 *)
  match (a, b) with
  | a, b -> m_bracket m_list__m_argument a b

815 行目と 1734 行目に設置したブレークポイントは、それぞれ「式(expr)」と「引数(arguments)」を比較する入口に対応しています。
ここで止めることで、マッチング処理が AST のどのノードに注目しながら降りていくのかを追えます。

  | G.N (G.Id ((str, tok), _id_info)), _b when Mvar.is_metavar_name str ->
      envf (str, tok) (MV.E b)(* 956行目 *)

一方、956 行目に設置したブレークポイントは、パターン側のノードがメタ変数($_$X)だった場合に入る分岐です。
つまり、foo($_)$_ がターゲット側の実引数 1 と結び付けられる(束縛される)瞬間を観測するために置いています。

runprint a / print b を繰り返して実行すると、最初に停止した時点では、ab の値は前小節で示した AST の内容とほぼ一致していることが分かります。

print a の結果

a: G.expr =
  {G.e =
    G.Call
     ({G.e =
        G.N
         (G.Id
           (("foo",
             Tok.OriginTok
              {Tok.str = "foo";
               pos = {Pos.bytepos = 0; line = 1; column = 0; file = <abstr>}}),
           {G.id_resolved = {contents = None};
            id_resolved_alternatives = {contents = []};
            id_type = {contents = None}; id_svalue = {contents = None};
            id_flags = {contents = <abstr>}}));
       e_id = 0; e_range = None; is_implicit_return = false; facts = []},
     (Tok.OriginTok
       {Tok.str = "(";
        pos = {Pos.bytepos = 3; line = 1; column = 3; file = <abstr>}},
      [G.Arg
        {G.e =
          G.N
           (G.Id
             (("$_",
               Tok.OriginTok
                {Tok.str = "$_";
                 pos =
                  {Pos.bytepos = 4; line = 1; column = 4; file = <abstr>}}),
             {G.id_resolved = {contents = None};
              id_resolved_alternatives = {contents = []};
              id_type = {contents = None}; id_svalue = {contents = None};
              id_flags = {contents = <abstr>}}));
         e_id = 0; e_range = None; is_implicit_return = false; facts = []}],
      Tok.OriginTok
       {Tok.str = ")";
        pos = {Pos.bytepos = 6; line = 1; column = 6; file = <abstr>}}));
   e_id = 0; e_range = None; is_implicit_return = false; facts = []}

print b の結果

b: B.expr =
  {B.e =
    B.Call
     ({B.e =
        B.N
         (B.Id
           (("foo",
             Tok.OriginTok
              {Tok.str = "foo";
               pos = {Pos.bytepos = 10; line = 3; column = 0; file = <abstr>}}),
           {B.id_resolved = {contents = None};
            id_resolved_alternatives = {contents = []};
            id_type = {contents = None}; id_svalue = {contents = None};
            id_flags = {contents = <abstr>}}));
       e_id = 0;
       e_range =
        Some
         ({Tok.str = "foo";
           pos = {Pos.bytepos = 10; line = 3; column = 0; file = <abstr>}},
          {Tok.str = "foo";
           pos = {Pos.bytepos = 10; line = 3; column = 0; file = <abstr>}});
       is_implicit_return = false; facts = []},
     (Tok.OriginTok
       {Tok.str = "(";
        pos = {Pos.bytepos = 13; line = 3; column = 3; file = <abstr>}},
      [B.Arg
        {B.e =
          B.L
           (B.Int
             (Some 1L,
              Tok.OriginTok
               {Tok.str = "1";
                pos =
                 {Pos.bytepos = 14; line = 3; column = 4; file = <abstr>}}));
         e_id = 0;
         e_range =
          Some
           ({Tok.str = "1";
             pos = {Pos.bytepos = 14; line = 3; column = 4; file = <abstr>}},
            {Tok.str = "1";
             pos = {Pos.bytepos = 14; line = 3; column = 4; file = <abstr>}});
         is_implicit_return = false; facts = []}],
      Tok.OriginTok
       {Tok.str = ")";
        pos = {Pos.bytepos = 15; line = 3; column = 5; file = <abstr>}}));
   e_id = 0;
   e_range =
    Some
     ({Tok.str = "foo";
       pos = {Pos.bytepos = 10; line = 3; column = 0; file = <abstr>}},
      {Tok.str = ")";
       pos = {Pos.bytepos = 15; line = 3; column = 5; file = <abstr>}});
   is_implicit_return = false; facts = []}

これらの構造をざっくり書くと、次のようになります。

Expr
 └─ Call
     ├─ callee: Expr
     │    └─ N
     │        └─ Id("foo")
     └─ args:
          └─ Arg
              └─ Expr
                  └─ L
                      └─ Int(1)

特に関数呼び出し周りのノード構造は、次のように整理できます。

  • Call:呼び出し全体
    • Id:関数名(の一形態)
    • Arg:引数リスト(中に Expr を持つ)

最初にブレークポイントで処理が停止する時は、Call という「式」のノードが Expr という大枠のカテゴリに属するため、1つ目のブレークポイントである関数m_expr に入ります。

2回目停止する時は、「式」ノードから「関数名」ノードへと降り、1 回目よりも構造的に一段深い位置を処理します。

-a: G.expr =
-  {G.e =
-    G.Call
-     ({G.e =
-        G.N
-         (G.Id
-           (("foo",
+a: G.expr =
+  {G.e =
+    G.N
+     (G.Id
+       (("foo",
+         Tok.OriginTok
-b: B.expr =
-  {B.e =
-    B.Call
-     ({B.e =
-        B.N
-         (B.Id
-           (("foo",
+b: B.expr =
+  {B.e =
+    B.N
+     (B.Id
+       (("foo",

3回目は、「関数名」ノードと同じレベルにある「引数リスト」ノードに進み、2 つ目のブレークポイントである m_arguments に入ります。

-     (Tok.OriginTok
-       {Tok.str = "(";
-        pos = {Pos.bytepos = 3; line = 1; column = 3; file = <abstr>}},
-      [G.Arg
-        {G.e =
-          G.N
-           (G.Id
-             (("$_",
+a: G.arguments =
+  (Tok.OriginTok
+    {Tok.str = "(";
+     pos = {Pos.bytepos = 3; line = 1; column = 3; file = <abstr>}},
+   [G.Arg
+     {G.e =
+       G.N
+        (G.Id
+          (("$_",
-     (Tok.OriginTok
-       {Tok.str = "(";
-        pos = {Pos.bytepos = 13; line = 3; column = 3; file = <abstr>}},
-      [B.Arg
-        {B.e =
-          B.L
-           (B.Int
-             (Some 1L,
+b: B.arguments =
+  (Tok.OriginTok
+    {Tok.str = "(";
+     pos = {Pos.bytepos = 13; line = 3; column = 3; file = <abstr>}},
+   [B.Arg
+     {B.e =
+       B.L
+        (B.Int
+          (Some 1L,

4回目は、引数リストの中に Expr が含まれているため、再び m_expr に入ります。

-        {G.e =
-          G.N
-           (G.Id
-             (("$_",
+a: G.expr =
+  {G.e =
+    G.N
+     (G.Id
+       (("$_",
-        {B.e =
-          B.L
-           (B.Int
-             (Some 1L,
+b: B.expr =
+  {B.e =
+    B.L
+     (B.Int
+       (Some 1L,

最終的には、次の分岐に入り、envf によってマッチするかどうかが判定されます。

  | G.N (G.Id ((str, tok), _id_info)), _b when Mvar.is_metavar_name str ->
      envf (str, tok) (MV.E b)   

今回の例では、パターン foo($_) に対して、最初の検査対象は foo(1) です。
このとき、匿名引数 $_ と数値 1envf の引数として渡されます。envf は内部で check_and_add_metavar_binding を呼び出し、ルールパターンで指定されたメタ変数と、ターゲット側の実際の値との対応関係を確認します。

今回は初めてのバインドなので、数値 1$_ に対応付けられ、check_and_add_metavar_bindingSome を返します。その結果、envf は失敗ルートに入らず、この箇所はマッチしたと判定されます。

let check_and_add_metavar_binding ((mvar : MV.mvar), valu) (tin : tin) =
  match Common2.assoc_opt mvar tin.mv with
  | Some valu' ->
      (* Should we use generic_vs_generic itself for comparing the code?
       * Hmmm, we can't because it leads to a circular dependencies.
       * Moreover here we know both valu and valu' are regular code,
       * not patterns, so we can just use the generic '=' of OCaml.
       *)
      if equal_ast_bound_code tin.config valu valu' then Some tin
        (* valu remains the metavar witness *)
      else None
  | None ->
      (* first time the metavar is bound, just add it to the environment *)
      Some (add_mv_capture mvar valu tin)

let (envf : MV.mvar G.wrap -> MV.mvalue -> tin -> tout) =
 fun (mvar, _imvar) any tin ->
  match check_and_add_metavar_binding (mvar, any) tin with
  | None ->
      Log.debug (fun m -> m "envf: fail, %s (%s)" mvar (MV.str_of_mval any));
      fail tin
  | Some new_binding ->
      Log.debug (fun m -> m "envf: success, %s (%s)" mvar (MV.str_of_mval any));
      return new_binding

逆に、ルールパターンbar($X, $X) に対してターゲットが bar(1, 2) の場合、1 回目の引数で $X は数値 1 にバインドされます。
しかし 2 回目では、すでにバインドされている値 1 と、比較対象である第 2 引数の数値 2 が一致しません。そのため、check_and_add_metavar_bindingNone を返し、このパターンはマッチ失敗となります。

まとめ

本記事では、Opengrep の位置づけを整理した上で、メイン機能のscan サブコマンドの使い方と内部の処理の流れを簡単に確認しました。

Opengrep では、scan が解析のエントリーポイントとなり、pattern ルールや taint ルールを用いた検査が実行されます。
実装を見ると、scan はルールの読み込みから結果の出力までの一連の処理をつないで実行する役割を担っていることが分かります。

後編では、今回触れた taint 解析の拡張である taint-intrafile に焦点を当て、公式ドキュメントと実装を手がかりに、その挙動をもう少し詳しく見ていく予定です。

引用