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

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

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

デジタルペンテスト部提供サービス:
ペネトレーションテスト
セキュリティ診断

Opengrep の中身を読む(後編):taint-intrafileオプションは何をしてくれるのか

こんにちは、魚脳です。今回は引き続き静的解析ツールOpengrepを紹介したいと思います。

はじめに

前編では、Opengrep の scan サブコマンドを中心に、ツール全体の構成と処理の流れを整理しました。
scan は、ルールの読み込みからコードのパース、ルールの適用、結果の出力までをつなぐ役割を担っており、pattern ルールや taint ルールといった複数の解析機能をまとめて実行するエントリーポイントになっています。

後編となる本記事では、その中でも テイント解析の拡張機能である taint-intrafile に焦点を当てます。

Opengrep には taint-intrafile という、scan機能のオプションとして提供されている、関数をまたいだテイント解析を行う機能があります。
比較的最近追加された機能でもあり、「どこまで解析してくれるのか」がドキュメントだけでは少し分かりづらいと感じる方もいるかもしれません。

特に “cross-function” という表現から、より広範囲なデータフロー解析を想像すると、実際の挙動との間にギャップが生じる可能性もあります。
こうしたズレは、ツールの問題というよりも、仕組みや前提を十分に理解しないまま使ってしまうことが原因で起きがちです。

本記事では、taint-intrafile を実運用の観点で評価するのではなく、
実装を軽く読み解きながら「何をしている機能なのか」「何を期待しすぎない方がよいのか」を整理します。
Opengrep をこれから触る方や、他の静的解析ツールとの違いが気になっている方の参考になれば幸いです。

テイント解析とは

まず前提として、テイント解析(taint analysis) は、プログラム中で 「汚染されたデータ(tainted data)」がどのように伝播するか を追跡する静的解析手法です。

ここでいう「汚染」とは、ユーザー入力や外部ファイル、ネットワーク経由のデータなど、安全性が保証されていない値を指します。こうした値は、プログラムの内部でそのまま利用されると、意図しない動作や脆弱性につながる可能性があります。

典型的には、解析は次の 3 つの要素を追います。

  • ソース(source)
    汚染されたデータが入り込む場所
  • 伝播
    代入や関数呼び出しによって値が別の変数や戻り値へ移ること
  • シンク(sink)
    汚染された値が渡されると問題になりうる処理 たとえば、次のようなコードを考えます。
def route():  
    data = request.args["name"]  
    return html_output(data)

この場合、request.args["name"] は外部入力なので「ソース」、data への代入は伝播、html_output(data) は 「シンク」 になりえます。
テイント解析は、このような

ソース→ 伝播 → シンク

という流れをプログラム中から見つけ出し、脆弱性につながる可能性のある経路を報告します。

もっとも、実際のコードでは値は 1 つの関数の中だけで完結するとは限りません。
入力を受け取る関数、加工する関数、最終的に出力する関数が分かれていることも珍しくありません。
そのため、関数内だけを見るテイント解析と、関数をまたいで追跡するテイント解析とでは、検出できる範囲に差が出てきます。

今回扱う taint-intrafile は、まさにこの差に関係する機能です。
名前の通り intra-file(ファイル内)という意味で、同一ファイル内の関数呼び出しをまたいで汚染の伝播を追跡するためのオプションです。
つまり、単一の関数の中だけでなく、同じファイルに定義された別の関数を経由するデータフローも解析対象になります。

次の章では、Opengrep において taint-intrafile オプションを有効にすると、実際の検出結果がどのように変化するのかを見ていきます。

taint-intrafile とは何か

taint-intrafileは2025年の後半でOpengrepに追加されたオプションであります*1

OpengrepはSemgrep OSSからフォークして独自に開発を進めているプロジェクトであるため、Semgrep Proが提供されている高度なテイント解析にはいままで対応できていませんでした。

いままでではmode: taintと設定されたルールに対してSemgrep OSSと同様、下記のような一つの関数の中にあるデータの伝播、「関数内のテイント解析」しかできませんでした。

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

一方、下記のようなデータが複数の関数によって処理されるケースに対して、Semgrep Proがそれに対応するオプション--pro-intrafileがありました*2。 今回紹介するOpengrepのオプション--taint-intrafileは推測ですが、おそらくそれに追随するように打ち出された機能だと思われます。これによって、本来検出できなかった単一ファイル内における複数の関数を跨いだテイント解析ができる様になります。

def pass_through(value):
    return value

def get_input():
    return source()

def main():
    input_data = get_input()
    result = pass_through(input_data)
    sink(result)
 opengrep scan -c demo_taint.yaml example1.py --taint-intrafile
...
┌────────────────┐
│ 1 Code Finding │
└────────────────┘

    example1.py
   ❯❯❱ demo-taint
          Taint flow detected from source to sink

           10┆ sink(result)



┌──────────────┐
│ Scan Summary │
└──────────────┘
Some files were skipped or only partially analyzed.
  Scan was limited to files tracked by git.

Ran 1 rule on 1 file: 1 finding.

なお、Semgrep Proにはさらに上位互換となるオプション--proが存在して、ファイル間のテイント解析まで対応できます。一方、執筆した時点Opengrepにはまだ同等の機能は存在しませんが、開発者の話による近いうちに追加されるとのことです*3

重要な手法

taint-intrafileオプションの挙動の前に、まず Opengrep が内部で用いている代表的な解析手法を簡単に整理します。

  • コールグラフ
    • 関数をノード、呼び出し関係を辺として表し、どの関数がどの関数を呼び出すかを静的に示すグラフ。
  • CFG(Control Flow Graph)
    • 関数内部の文や式の実行順序と分岐をノードと辺で表した制御フローのグラフ。
  • (汚染解析などにおける)関数のシグネチャー
    • 引数から戻り値やシンクへの汚染の伝播関係を要約した、関数の振る舞いを表す解析結果。
  • 不動点反復
    • 再帰や循環依存を含む解析において、結果が変化しなくなるまで解析を繰り返して性質を収束させる手法。
  • トポロジカルソート
    • 関数間の依存関係を壊さないよう、呼び出される側から呼び出す側の順に並べるためのグラフ整列手法。

これらの手法と役割については、以降の小節で順に詳しく説明します。

コールグラフ

静的解析で関数をまたいだ影響を追うには、「どの関数がどの関数を呼ぶか」を表す コールグラフ(Call Graph) が必要になります。Opengrepではsrc/tainting/Graph_from_AST.mlbuild_call_graph関数がその役割を担ってくれます。

バージョン1.16.0からサブコマンドshowにオプションdump-intrafile-graphが追加され、簡単にコールグラフを確認することができるようになりました。ソースコード上ではデバッグ向けにコールグラフを別ファイルに書き出すコードもコメントアウトに残されています、そちらからでも同じ結果を得られます。

前章で例として挙げたPythonプログラムのコールグラフは以下のようになります。

digraph G {
  "get_input at l:4 c:4\nexample1.py";
  "main at l:7 c:4\nexample1.py";
  "<top_level> at l:0 c:0\nunknown";
  "pass_through at l:1 c:4\nexample1.py";


  "get_input at l:4 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
  "pass_through at l:1 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";

  }

コールグラフ構造は「双方向に辿れるラベル付き有向グラフ」ConcreteBidirectionalLabeledというものを採用しています。コールグラフ自体は、関数を頂点(node)、呼び出し関係を辺(edge)とする有向グラフとして表現します。プリントされた時にそれぞれ頂点と辺、上下2段に分かれて出力されています。

module G =
  Graph.Imperative.Digraph.ConcreteBidirectionalLabeled
    (struct
      type t = node
      let compare = compare_node
      let hash = hash_node
      let equal = equal_node
    end)
    (struct
      type t = edge
      let compare = compare_edge
      let default = default_edge
    end)

Opengrepはこのコールグラフに基づいて、到達可能関数の探索や影響範囲解析を行っています。

CFG(Control Flow Graph)

コールグラフが「関数間の呼び出し関係」を表すのに対し、CFG(Control Flow Graph)関数内部の制御フローを表すグラフです。

type ('node, 'edge) t = {
  graph : ('node, 'edge) Ograph_extended.ograph_mutable;
  entry : nodei;
  exit : nodei;
  reachable : NodeiSet.t;
}

type ('node, 'edge) cfg = ('node, 'edge) t

ノードは基本的に「文や式などのプログラムの処理単位」で、エッジは「実行が次に進む可能性のある経路」を表します。
実装では、CFG はノードとエッジを持つグラフとして構築されます。
エントリノード (Enter) と終了ノード (Exit) を作成し、そこに文や式などのプログラムの処理単位を接続していきます。

let enteri = g#add_node (IL.mk_node F.Enter)
let exiti = g#add_node (IL.mk_node F.Exit)

関数内の文を順番に処理し、ノードを生成しながらadd_arc系関数を使ってアークを追加していきます。(有向グラフのため、辺のことをアークと呼んでいると思われます)

let newi = state.g#add_node (IL.mk_node new_)
state.g |> add_arc_from_opt (previ, newi)

生成されたCFGを確認する方法も実は用意されています。

_build/default/src/main/Main.bc -cfg_il ~/test/example1.py

自前でコンパイルしたバイトコードならそれを確認するオプション-cfg_ilを使って可視化したソースコードのCFGを確認できます。

画像化したmain関数のCFG

画像はmain関数のCFGとなります、エントリノード (Enter) と終了ノード (Exit)の間にIL(中間言語)に変化された文が繋がれています。テイント解析はまさに画像上の矢印にそって解析を行われます。

関数のシグネチャー

しかし、関数をまたいだ解析を行う場合、毎回呼び出し先の関数のCFGを一から展開して解析するのは効率が悪くなります。 そこで助けになるのが 関数のシグネチャーとなります。 ここでいうシグネチャーは型の情報ではなく、

  • どの引数から汚染が伝播するか
  • 汚染が戻り値に現れるか
  • 汚染が危険な処理(シンク)に到達するか といった 関数の振る舞いを要約した情報を指します。

たとえば、次のような関数を考えます。

def pass_through(value):  
    return value

この関数の内部では単に引数を返しているだけですが、汚染追跡の観点では

引数 value の汚染 → 戻り値

という重要な情報を持っています。
テイント解析において、呼び出し元から見ると、関数内部の細かな文の流れよりも、この「入力が出力にどう対応するか」という要約の方が重要です。

例として、前章扱ったPythonのプログラムをOpengrepのシグネチャーを確認する機能に投げます。

opengrep show dump-taint-signatures demo_taint.yaml example1.py

その結果は以下のようになります。

Taint signatures for rule demo-taint:

get_input (example1.py:4:4): { Shape_and_sig.sig_ =  => {return ({ [source() :l.5] } & _|_ & CTRL:{  })};
  arity = 0 }
main (example1.py:7:4): { Shape_and_sig.sig_ =
   => {[[1> source() :l.5]] ~~~> (sink(result) at l.10 by sink:0:0:rules)};
  arity = 0 }
pass_through (example1.py:1:4): { Shape_and_sig.sig_ =
  value => {return ({ 'arg(value#0) } & '{arg(value#0)} & CTRL:{  })};
  arity = 1 }

これを読むと、それぞれ次のような意味になっています。

  • get_input
    引数は取らず、source() に由来する汚染を戻り値として返す
  • pass_through
    第1引数 value に入った汚染が、そのまま戻り値へ流れる
  • main
    source() に由来する汚染が、最終的に sink(result) へ到達する

このように、シグネチャーは 関数の振る舞いを再利用可能な形で保存したものです。
解析の流れとしては、まず CFG とコールグラフを使って各関数の要約を作り、その後は呼び出し先の本体を毎回展開する代わりに、その要約を参照します。

たとえば pass_through のような補助関数は、関数本体を何度も解析しなくても

引数 → 戻り値

というシグネチャーだけで十分扱えます。
この仕組みにより、関数をまたぐ汚染解析を効率よく進められます。

不動点反復

前の小節で説明したように、関数のシグネチャーは CFG 上の解析結果から計算されます。しかし、その値は必ずしも一度の解析で決まるわけではありません。特に、関数同士が互いに呼び合う場合や、再帰が存在する場合には、解析結果が循環依存を持つことがあります。

例えば次のような再帰関数を用いた階乗を計算する関数考えます。

def factorial(n):  
    if n <= 1:  
        return 1  
    return n * factorial(n-1)

この関数では、factorial の戻り値は再び factorial の呼び出しに依存しています。
解析の観点では、次のような依存関係になります。

factorial → factorial

つまり、関数の性質を求めるためには、その関数自身の結果を参照する必要があります。このような場合、解析は一度の計算では完了しません。

そこで用いられるのが 不動点反復(fixed-point iteration)です。

不動点反復では、まず関数の性質について仮の状態から解析を開始し、その結果を使って解析を繰り返します。結果がそれ以上変化しなくなった時点で計算を停止します。この「結果が変化しなくなった状態」を 不動点(fixed point)と呼びます。

概念的には、次のような流れになります。

初期状態  
↓  
解析  
↓  
結果更新  
↓  
解析  
↓  
結果更新  
↓  
変化なし → 不動点

この仕組みによって、再帰関数や相互再帰がある場合でも、関数のシグネチャーを徐々に更新しながら最終的な結果を求めることができます。

実装では、この反復計算は Dataflow_tainting.fixpoint によって行われます。

let fixpoint_effects, mapping =  
  Dataflow_tainting.fixpoint taint_inst ~in_env:combined_env  
    ?name ?signature_db ?builtin_signature_db ?call_graph func_cfg

この関数は CFG 上のデータフロー解析を繰り返し実行し、関数の 汚染効果が収束するまで更新を続けます。その結果が最終的な関数シグネチャーとして保存されます。

このように、不動点反復は再帰や循環依存を含むプログラムに対しても解析を成立させるための基本的な手法です。静的解析では、CFG やコールグラフと組み合わせて広く用いられているのだそうです。

トポロジカルソート

コールグラフ、CFGが構築できると、次はそのグラフ上で データフロー解析を行います。
その際に重要になるのが トポロジカルソート(Topological Sort)です。 トポロジカルソートとは、有向グラフにおいて 依存関係を壊さない順序でノードを並べるアルゴリズムです。特に、循環を持たない有向グラフ(DAG: Directed Acyclic Graph)に対して定義されます。 例えば次のような依存関係を考えます。

A → B → C

この場合、トポロジカルソートを行うと次のような順序が得られます。

A  
B  
C

この順序では、常に 依存される側のノードが先に処理されるようになっています。

Opengrepでは、このアルゴリズムを コールグラフに適用して関数の解析順序を決定しています。例えば前章で扱ったPythonプログラムのコールグラフは次のようになります。

  "get_input at l:4 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";
  "pass_through at l:1 c:4\nexample1.py" -> "main at l:7 c:4\nexample1.py";

これをトポロジカルソートしたあとの順序はOpengrepのデバッグ情報から確認できます。

SEMGREP_LOG_SRCS="semgrep.tainting" ./_build/install/default/bin/opengrep-core -rules ~/test/demo_taint.yaml -l py ~/test/example1.py -taint_intrafile -debug
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [0] get_input
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [1] pass_through
[00.06][DEBUG](default)(semgrep.tainting): TAINT_TOPO: [2] main

この順序に従って解析を行えば、main を解析する時点で get_inputpass_through のシグネチャーがすでに計算されているため、解析を効率よく進めることができます。 また、関数シグネチャーがない場合、特殊な設定がされていない限り*4*5、Opengrepはその関数のことを汚染の伝搬関数として認識して実行するため、false positiveの結果が出される可能性が高まります。トポロジカルソートにそれを軽減するという効果も含まれていると思われます。

            | None -> (
                let call_taints =
                  if not (propagate_through_functions env) then Taints.empty
                  else
                    (* Otherwise assume that the function will propagate
                     * the taint of its arguments. *)
                    all_args_taints

一方、コールグラフに再帰が含まれる場合、グラフには循環が存在します。 このような場合はトポロジカルソートがそのまま適用できないため、まず 強連結成分(SCC: Strongly Connected Component)に分解します。
強連結成分とは、グラフの中で互いに到達可能なノードの集合のことです。つまり、あるノードから別のノードへ到達でき、さらにそのノードから元のノードへ戻ることができる場合、それらは同じ強連結成分に属します。

例えば次のような関数呼び出し関係があるとします。

f → g  
g → f

この場合、fg は互いに呼び合っているため、同じ強連結成分に属します。解析ではこのような循環部分を 一つのまとまりとして扱います

その後、強連結成分を単一ノードとして縮約すると、グラフは循環を持たない構造(DAG)になります。これに対してトポロジカルソートを適用することで、依存関係に従った解析順序を得ることができます。

循環部分については、前の小節で説明する 不動点反復によって解析結果を収束させます。

taint-intrafileオプションによる挙動の違い

ここまで、コールグラフ、CFG、トポロジカルソート、不動点反復、そして関数シグネチャーといった仕組みを紹介しました。
これらの仕組みは、関数をまたいだ taint 伝播を解析するための基盤になっています。

Opengrep では、この関数間解析を行うかどうかは taint-intrafile オプションによって制御されます。
このオプションの有無によって、解析の挙動は大きく変わります。

              (* Only use signature database if cross-function taint analysis is enabled *)
      let final_signature_db, relevant_graph =
        if taint_inst.options.taint_intrafile then (
          (* ... *)
        )
        else (
          (* Cross-function taint analysis disabled: use main branch behavior *)
          (* ... *)
        )

コメントの部分に言及された通り、オプションが起用されなかった場合、フォーク元であるSemgrep OSSのメインブランチの挙動に準じます。

大まかに言うと、

  • taint-intrafile: false
    • 各関数を独立に解析する
  • taint-intrafile: true
    • 同一ファイル内の関数呼び出し関係を使って、シグネチャーを構築しながら関数間解析を行う

という違いがあります。

taint-intrafileオプション無効

taint-intrafile が無効な場合、check_rule は各関数定義を順に走査し、その場で check_fundef を呼んで解析します。

Visit_function_defs.visit  
  (fun opt_ent fdef ->  
    ...  
    let _flow, fdef_effects, _mapping =  
      check_fundef taint_inst name !ctx ~glob_env  
        ?builtin_signature_db fdef

この経路では、関数ごとの解析は行われますが、

  • コールグラフの構築
  • 関数シグネチャーの抽出
  • トポロジカル順序に基づく解析順の制御

は行われません。

その代わり、Visit_function_defs.visit で関数定義を順に取り出し、各関数に対して直接 check_fundef を適用します。
つまり解析エンジンは、呼び出し先の関数の情報を参照するのではなく、今見ている関数の本体だけを対象に、データの流れを追跡します。

このモードで依存しているのは 関数内の CFG と、その関数に入る時のグローバル環境 (glob_env) です。
そのため、同一関数内で完結する汚染伝播は扱えますが、他関数を経由するデータフローまではカバーできません。

taint-intrafileオプション有効

一方、taint-intrafile を有効にすると、check_rule はまず関数間解析の準備を始めます。

最初に行うのは、各関数の情報収集です。
Visit_function_defs.fold_with_parent_path を使って、関数名、所属クラス、メソッドプロパティ、IL 変換後の CFG などを集め、info_map に保存しています。

let _collected_infos, info_map =  
  Visit_function_defs.fold_with_parent_path  
    ...

続いて、必要ならオブジェクト初期化情報を検出し、シグネチャーデータベースへ反映します。

let object_mappings =  
  Taint_signature_extractor.detect_object_initialization ast  
    taint_inst.lang

コールグラフの構築と絞り込み

その後、同一ファイル内の関数呼び出し関係からコールグラフを構築します。

let call_graph =  
  match shared_call_graph with  
  | Some (graph, _shared_mappings) -> graph  
  | None ->  
      Graph_from_AST.build_call_graph ~lang  
        ~object_mappings:all_object_mappings ast

ただし、ここで作ったグラフ全体をそのまま解析に使うわけではありません。
実装では、ルールに一致した source と sink の位置を起点にして、幅優先探索でそれらに関連する関数だけを取り出した部分グラフを作っています。

let relevant_graph =  
  Graph_reachability.compute_relevant_subgraph call_graph  
    ~sources:source_functions ~sinks:sink_functions

つまり taint-intrafileオプションは、単に「関数間解析をする」だけでなく、そのルールに関連する関数だけに解析対象を絞るようになっています。
これによって不要な関数まで解析するのを避けて、時間の短縮につながると思われます。


トポロジカル順序でシグネチャーを構築する

部分グラフが得られると、次にそのグラフに対してトポロジカル順序を計算します。

let analysis_order =  
  Call_graph.Topo.fold  
    (fun fn acc -> fn :: acc)  
    relevant_graph []  
  |> List.rev

この順序は、呼び出される側を先に、呼び出す側を後に処理するためのものです。
これにより、ある関数を解析するときに、可能な限り呼び出し先のシグネチャーが先に用意されるようになります。

続いて、この順序に従って各関数のシグネチャーを抽出していきます。

let updated_db, _signature =  
  Taint_signature_extractor.extract_signature_with_file_context  
    ~arity ~db ?builtin_signature_db taint_inst ~name:info.name  
    ~method_properties:info.method_properties  
    ~call_graph:(Some relevant_graph) info.cfg ast

ここで得られたシグネチャーは signature_database に追加され、後続の関数解析で参照されます。
つまり taint-intrafileオプションが有効の場合では、

CFG から関数シグネチャーを作る  
↓  
それを signature database に格納する  
↓  
次の関数はそのシグネチャーを参照して解析する

という流れが実際にコードとして実装されています。


シグネチャーを使って関数本体を解析する

シグネチャーを構築した後、各関数に対して check_fundef を実行します。
このときは、先ほど構築した updated_dbrelevant_graph が渡されます。

let _flow, fdef_effects, _mapping =  
  check_fundef taint_inst info.name !ctx ~glob_env  
    ?class_name:info.class_name_str ~signature_db:updated_db  
    ?builtin_signature_db ?call_graph:(Some relevant_graph)  
    info.fdef

この挙動の違いが重要で、taint-intrafileオプションが無効の場合では単に関数を単体で見るだけだったのに対し、taint-intrafileオプションが有効の場合では 既に計算済みの他関数のシグネチャーを参照しながら関数本体を解析できます。

つまり、呼び出し先を毎回展開するのではなく、必要に応じてまとめた情報として再利用できるようになります。

まとめ

taint-intrafileオプションは、同一ファイル内における関数間の汚染伝播を対象とした、軽量な解析機能です。

そのため、Opengrepはファイルをまたいだ複雑なデータフローや、フレームワーク特有の暗黙的な挙動まで網羅的に追跡する用途には現状向いていません。

一方で、単一ファイル内にロジックがまとまっているコードに対して、
「まず怪しい箇所を拾い上げる」目的で使うのであれば、十分に価値のある機能だと考えられます。

また、Semgrep OSSから独立してから約1年、更新頻度は一定水準に保たれている、近い将来に本家を超える可能性すら秘めていると思います。