特定の関数やメソッドの呼び出しを検知するLinterを作った

January 20, 2020

はじめに

先日、以下のようにsyumaiさんが社内SlackのGo部屋で特定の関数の呼び出しを検知するLinterが無いかと問いかけたところ、knsh14が14分で作ってASTの魔術師の称号を得ていました。

シュッと作れるknsh14は流石だなと思いつつも、検出できないパターンを見つけたので、私なりに改善したものを作ってみました。

作ったものは以下のリポジトリに公開してありますので、チェックしてみてください。

抽象構文木と型情報では分からないこと

knsh14が作ったバージョンはざっくり説明すると以下の手順で行っていました。

  • 対象のコードがインポートしているパッケージの中から該当の関数が定義されたパッケージを探す
  • 抽象構文木(AST)から関数呼び出しを探す
  • 呼び出されている関数が該当のパッケージの関数かパッケージ情報と関数名で照合する

パッケージ情報は型情報から取得し、関数名は抽象構文木から取得していました。

しかし、この方法だと関数名を変えて関数を呼び出してしまうと検出できません。 例えば、log.Fatal関数の呼び出しを検出したい場合、 以下のように変数に代入しておくと検出を回避できてしまいます。

f := log.Fatal
f("Error")

「こんなコード書くやつが悪い」で済みそうなコードですが、検出できることに越したことはありません。

抽象構文木と型情報だけでは、どの値がどの変数に代入されているという情報を追うことができません。 静的単一代入形式(SSA)という形式に変換することにより、変数への代入を追うことができます。

なお、Goの静的解析においてどのフェーズでどのような情報が取得できるのかといった話は、builderscon tokyo 2019で発表したソースコードを堪能せよという資料に書いてあります。

静的単一代入形式を用いて解析を行う

Analyzerで静的単一代入形式を用いるには、golang.org/x/tools/go/analysis/passes/buildssaパッケージを用いれば良いでしょう。以下のようにRequiresフィールドに設定すれば静的単一代入形式が使えるようになります。

var Analyzer = &analysis.Analyzer{
	Name: "called",
	Doc:  Doc,
	Run:  run,
	Requires: []*analysis.Analyzer{
		buildssa.Analyzer,
	},
}

Google Cloud Spannerのセッションリークを静的解析で防ぐという記事で書いたように、静的単一代入形式で表されたGoの関数は有向グラフとして考えることができます。

このとき、有向グラフのノードは基本ブロックとなり、エッジは基本ブロック間の遷移を表します。基本ブロックはssa.BasicBlock構造体で表されます。

基本ブロックはいくつかの命令の集まりで、各命令はssa.Instructionインタフェースで表されます。ssa.Instructionインタフェースを実装した型には、関数呼び出しを表す*ssa.Call型や条件分岐を表す*ssa.If型があります。

静的単一代入形式で表されたコードから関数呼び出しを見つけるためには、以下のようにすべての関数の基本ブロックに対して、その基本ブロックが持つ全ての命令を調べていき、*ssa.Call型の値を見つければ良いことになります。

// すべての関数
srcFuncs := pass.ResultOf[buildssa.Analyzer].(*buildssa.SSA).SrcFuncs
for _, sf := range srcFuncs {
	// すべての基本ブロックを調べる
	for _, b := range sf.Blocks {
		// すべての命令を調べる
		for _, instr := range b.Instrs {
			// 変数fsは検出したい関数のスライス
			for _, f := range fs {
				// 対象の命令が関数fを呼び出しているか調べる
				if analysisutil.Called(instr, nil, f) {
					pass.Reportf(instr.Pos(), "%s must not be called", f.FullName())
					break
				}
			}
		}
	}
}

変数fs*types.Func型のスライスです。types.Func型は型情報上の関数を表す型です。analysis.Called関数に対象の命令と*types.Func型の値を渡すことで、この命令で渡した関数を呼び出しているか調べることができます。

変数fsは以下のようなrestrictedFuncs関数によって求められます。 log.Fatal(*encoding/json.Encoder).Encodeのような文字列から該当の関数やメソッドを表す*types.Func型の値を取得しています。

func restrictedFuncs(pass *analysis.Pass, names string) []*types.Func {
	var fs []*types.Func
	for _, fn := range strings.Split(names, ",") {
		ss := strings.Split(strings.TrimSpace(fn), ".")

		// package function: pkgname.Func
		if len(ss) < 2 {
			continue
		}
		f, _ := analysisutil.ObjectOf(pass, ss[0], ss[1]).(*types.Func)
		if f != nil {
			fs = append(fs, f)
			continue
		}

		// method: (*pkgname.Type).Method
		if len(ss) < 3 {
			continue
		}
		pkgname := strings.TrimLeft(ss[0], "(")
		typename := strings.TrimRight(ss[1], ")")
		if pkgname != "" && pkgname[0] == '*' {
			pkgname = pkgname[1:]
			typename = "*" + typename
		}

		typ := analysisutil.TypeOf(pass, pkgname, typename)
		if typ == nil {
			continue
		}

		m := analysisutil.MethodOf(typ, ss[2])
		if m != nil {
			fs = append(fs, m)
		}
	}

	return fs
}

おわりに

静的単一代入形式を用いた静的解析は一見難しそうですが、コードが静的単一代入形式でどのようなデータ構造で表されているのかということを理解すれば該当の条件にマッチするコードを探すのは非常に簡単です。スライスを先頭から見ていったり、グラフ構造を深さ優先探索で探索したりするだけです。

静的単一代入形式をもう少し学びたい方は、以下の書籍やスライドを一度読んでみると良いでしょう。

SSAの魔術師になるのも夢ではありません。