Semgrepという構文木ベースのgrep/sed的なツールを使ったリファクタリングをした話です。

Semgrep

Semgrepr2cという会社/サービスが開発しているツールです。 Semgrepの特徴としてTree-sitterでコードをパースしたConcrete Syntax Tree(CST)の構文木をベースにして検索や置換ができます。

コードをCSTにパースした構文木に対して検索/置換することで、ただの文字列検索/置換に比べてミスマッチしない検索/置換ができます。 例えば、次のa.js、b.js、c.jsはそれぞれevalを使っていますが、スタイルは違いますが意味はほとんど同じです。

// a.js
eval("const a = 1, b = 2; eval(a + b);");
// b.js
eval('const a = 1, b = 2;\
eval(a + b);');
// c.js
eval(`const a = 1, b = 2;
eval(a + b);`);

これらにeval(引数)にマッチ(内側の文字列evalではない)する正規表現を書くのは結構難しいですが、Semgrepではeval(...)というパターンで検索できます。

$ semgrep --pattern 'eval(...)' --lang=js *
a.js
1:eval("const a = 1, b = 2; eval(a + b);");

b.js
1:eval('const a = 1, b = 2;\
2:eval(a + b);');

c.js
1:eval(`
2:const a = 1, b = 2;
3:eval(a + b);
4:`);

d.js
5:eval(userInput);

この構文木を使ったアプローチの欠点としては、パースできる言語しか対応できない点ですが、 SemgrepはGo、Java、JavaScript/TypeScript、Python、Ruby、Ocaml、PHP、Cなど主要な言語に対応しています。(ステータスは言語ごとに異なる)

このある程度抽象化されたマッチと置換処理をあわせて、コードのリファクタリングをしていくというのがこの記事の目的です。

📝 この記事だとCST(Concrete Syntax Tree)とAST(Abstract Syntax Tree)をあまり区別してません。 CSTはスペースなどプログラム的に意味がない情報の位置もちゃんと構文木として持つというのがASTとの違いとしてあることが多いです。(ASTでもtoken的に持ってることはある気がする) 多分元となってるpfffがOcamlで書かれててTree-sitter使ってたからCSTという感じなのかも。r2cとかはLintとかそっち系のサービスなのでASTよりも詳細なCSTレベルの情報が必要になるユースケースがあるのかもしれない。

Semgrepの基本的な使い方

インストール

SemgrepのREADMEに従って好きな方法でsemgrepコマンドをインストールできます。

# For macOS
$ brew install semgrep

# For Ubuntu/WSL/Linux/macOS
$ python3 -m pip install semgrep

# To try Semgrep without installation run via Docker
$ docker run --rm -v "${PWD}:/src" returntocorp/semgrep --help

CLI

例えば、semgrepを使ってJavaScriptのeval()を使っているコードを検索したい場合は、次のような--patternを指定すれば検索できます。

eval(...)eval 関数を0コ以上の引数で呼び出している箇所にマッチします。

$ semgrep --pattern 'eval(...)' --lang=js *.js
more information.
a.js
1:eval("const a = 1, b = 2; eval(a + b);");

b.js
1:eval('const a = 1, b = 2;\
2:eval(a + b);');

c.js
1:eval(`
2:const a = 1, b = 2;
3:eval(a + b);
4:`);
ran 1 rules on 3 files: 3 findings

文字列検索と違って、クオートの違いや文字列末尾での\の扱いなどを吸収して検索できています。 また、eval関数の中に書かれた文字列のeval(a + b)関数にはマッチしてないことが分かります。

パターンにはif($X){ ... } でif文とブロックへのマッチといった表現もできます。 (この $X はメタデータとして置換時にも利用できます)

requests.get("=~/dev\./i") のように正規表現を組み合わせたマッチもできます。 コードの見た目に近いパターンマッチができるのが特徴です。

詳しくは次のドキュメントで解説されています。

Config

Semgrepではyaml形式で書いたconfigファイルを使って、複数のパターンやもっと複雑な組み合わせも表現できます。

たとえば、次のようにeval関数の引数がハードコードされた文字列の場合は問題ないので無視したいけど、 eval関数の引数が変数(ユーザー入力かもしれない)場合は良くないので、 そのような箇所を見つけたいとします。

// OK
eval("1+1");
// NG
eval(userInputVariable);

パターンには先ほどもつかった eval(...)eval関数を呼び出している箇所が見つけられます。 このマッチ結果から eval("...") でのマッチ結果を除外すれば求めるeval関数に変数を渡している箇所が見つけられそうです。

このパターンは次のようにpatternpattern-notの組み合わせで表現できます。

rules:
- id: insecure-eval-use
  patterns:
  - pattern: eval(...)
  - pattern-not: eval("...")
  message: Calling 'eval' with user input
  languages: [javascript]
  severity: WARNING

semgrep --config でこのconfig fileを指定すれば、そのルールで検索できます。

$ semgrep --config ./semgrep.yml
running 1 rules...
d.js
severity:warning rule:insecure-eval-use: Calling 'eval' with user input
5:eval(userInput);
ran 1 rules on 4 files: 1 findings

semgrepのconfigにはマッチしたときのメッセージやseverityなどが指定できるので、ESLintなどのようなLintツールとして使うことも目的になっているようです。

このパターンには、ANDやOR、特定のパターンの内側、外側といったいろいろな表現ができるので結構柔軟なマッチができます。 また、pattern-regexという正規表現でのマッチもできるのでかなり自由です。

詳しくはルールの構文のドキュメントで解説されています。

Semgrepで置換

Semgrepは検索だけではなくて、マッチ内容の置換にも対応しています。 2020-12-04時点ではExperimentalなので上手く置換できないケースもあります。

例えば、eval(...)new Function(...) に置換するルールを定義したい場合は次のように書けます。 ... はマッチするだけなので、$X のように$アルファベット を変数として利用できます。 この変数を置換後の fix: に入れれば、元のマッチ内容をキャプチャして置換に使えます。

rules:
  - id: use-new-function
    languages:
      - javascript
    message: |
      Use `new Function` instead of `eval`
    pattern: eval($X)
    fix: new Function($X)
    severity: WARNING

置換をする場合は--autofixという引数とともに呼び出すとファイルを書き換えてくれます。

$ semgrep --config fix.yml --autofix d.js --verbose
running 1 rules...
successfully modified 1 file.
d.js
severity:warning rule:use-new-function: Use `new Function` instead of `eval`

5:new Function(userInput)
autofix: new Function(userInput)
ran 1 rules on 1 files: 1 findings

このautofix機能を使ってリファクタリングをしたというのが本題です。

Semgrepでリファクタリング

ここからタイトルにあるリファクタリングの本題です。

HonKitというドキュメントや書籍を作成するツールをつくっています。

HonKitのFork元であるGitBookはJavaScriptで書かれていたのですが、HonKitではコードをベースをTypeScriptにマイグレーションしました。 JavaScriptからTypeScriptへの移行はts-migratecommonjs-to-es-module-codemodを使いほぼ機械的に行いました。

リファクタリングの対象

TypeScriptへ移行はできたのですが、元コードがJavaScriptのprototypeをベタ書きしているため、まともに型が効いてない状態でした。 たとえば、次のmodels/book.tsImmutable.jsを使ったモデルに対して、prototypeなどで直接インスタンスメソッドとスタティックメソッドを定義している状態でした。

この場合、Book#getFS() などを呼び出そうとして、TypeScriptの型には入ってないので、// @ts-expect-error でignoreして呼び出すといった状態でした。

import Immutable from "immutable";
import Logger from "../utils/logger";
const Book = Immutable.Record({
    logger: Logger(),
    // ...
});
// Instance Method
Book.prototype.getLogger = function () {
    return this.get("logger");
};

Book.prototype.getFS = function () {
    return this.get("fs");
};
/// ...

Book.createFromParent = function createFromParent(parent, language) {
    // Static Method
};

export default Book;

これを型を効く状態にするには、Immutable.jsで定義したモデルをclassとして継承して、そのクラスにメソッドを定義する方法が取れそうでした。

つまり次のようなコードに変換していけば、TypeScrip的にも型が効く状態になります。

import Immutable from "immutable";
import Logger from "../utils/logger";
class Book extends Immutable.Record({
    logger: Logger(),
    // ...
}){
    // Instance Method
    getLogger = function () {
        return this.get("logger");
    };

    getFS = function () {
        return this.get("fs");
    };
    /// ...
    static createFromParent = function createFromParent(parent, language) {
    // Static Method
    };
}
export default Book;

jscodeshiftなどASTベースのマイグレーションツールを使えばできそうですが、 マイグレーションのコードを書くのが面倒そうです。 またギリギリ文字列置換でもできそうな雰囲気もありますが、文字列マッチだと誤検知したりして面倒そうでした。

そこで、Semgrepを使ってこの置換をルールとして定義して、リファクタリングしていくことにしました。 実際にやることを書いたIssueは次のIssueです。

Semgrepの置換でリファクタリング

このリファクタリングで使ったsemgrep.ymlは次のような内容です。

少し長いですが、4つの置換ルールを定義しています。

  • const $X = Immutable.Record(...);class $X extends Immutable.Record(...){
    • Immutable.jsのモデルを継承したクラスを定義する
  • $X.prototype.$Y = function(...){ ... }$Y(...){ ... }
    • prototypeへの代入をクラスのインスタンスメソッドに変換
  • $X.$Y = function(...){ ... }static $Y(...){ ... }
    • Staticメソッドに変換
  • export default $X;"}\nexport default $X;"
    • class {} のカッコの対を補完
# Migrate Immutable.js model to class-based
# https://github.com/honkit/honkit/issues/40#issuecomment-735353069
rules:
  - id: model
    languages:
      - typescript
    message: |
      use class
    pattern: "const $X = Immutable.Record(...);"
    fix-regex:
      regex: 'const (\w+) = ([\s\S]+);'
      replacement: 'class \1 extends \2{'
    severity: WARNING
  - id: instance-method
    languages:
      - typescript
    message: |
      prototype method to class instance methods
    patterns:
      - pattern: "$X.prototype.$Y = function(...){ ... }"
      - pattern: "$X.prototype.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*)\.prototype\.(.*) = function.*\('
      replacement: '\2('
    severity: WARNING
  - id: static-method
    languages:
      - typescript
    message: |
      static method to class statics methods
    patterns:
      - pattern: "$X.$Y = function(...){ ... }"
      - pattern: "$X.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*?)\.(.*?) = function.*\('
      replacement: 'static \2('
    severity: WARNING
  - id: export
    languages:
      - typescript
    message: |
      fix export
    pattern: "export default $X;"
    fix-regex:
      regex: 'export default'
      replacement: '}\nexport default'
    severity: WARNING

このルールファイルを使って semgrep --autofix をすると次のように置換できます。 (実際にはスペースなどはズレますが、今どきはPrettierなどのFormatterがあるので気にする必要はありません)

@@ -1,21 +1,20 @@
 import Immutable from "immutable";
 import Logger from "../utils/logger";
-const Book = Immutable.Record({
+class Book extends Immutable.Record({
     logger: Logger(),
     // ...
-});
-// Instance Method
-Book.prototype.getLogger = function () {
-    return this.get("logger");
-};
+}){
+    // Instance Method
+    getLogger = function () {
+        return this.get("logger");
+    };

-Book.prototype.getFS = function () {
-    return this.get("fs");
-};
-/// ...
-
-Book.createFromParent = function createFromParent(parent, language) {
+    getFS = function () {
+        return this.get("fs");
+    };
+    /// ...
+    static createFromParent = function createFromParent(parent, language) {
     // Static Method
-};
-
+    };
+}

ルールの解説

Semgrepのルールは特殊な構文が少ないので、見た目で分かりやすいです。 このルールではどのようなことをやっているかの解説です。

# Migrate Immutable.js model to class-based
# https://github.com/honkit/honkit/issues/40#issuecomment-735353069
rules:
  - id: model
    languages:
      - typescript
    message: |
      use class
    pattern: "const $X = Immutable.Record(...);"
    fix-regex:
      regex: 'const (\w+) = ([\s\S]+);'
      replacement: 'class \1 extends \2{'
    severity: WARNING
  - id: instance-method
    languages:
      - typescript
    message: |
      prototype method to class instance methods
    patterns:
      - pattern: "$X.prototype.$Y = function(...){ ... }"
      - pattern: "$X.prototype.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*)\.prototype\.(.*) = function.*\('
      replacement: '\2('
    severity: WARNING
  - id: static-method
    languages:
      - typescript
    message: |
      static method to class statics methods
    patterns:
      - pattern: "$X.$Y = function(...){ ... }"
      - pattern: "$X.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*?)\.(.*?) = function.*\('
      replacement: 'static \2('
    severity: WARNING
  - id: export
    languages:
      - typescript
    message: |
      fix export
    pattern: "export default $X;"
    fix-regex:
      regex: 'export default'
      replacement: '}\nexport default'
    severity: WARNING

CSTでのマッチと正規表現での置換

最初(id: model)と最後(id: export)のルールでモジュール全体をclass { ... } で包むように置換しています。 Semgrepの面白いところは、置換に正規表現が使えるところです。 また、この正規表現での置換結果は、構文的に正しくないコードへと変換できる点が結構自由度をあげています。

最初のルール(id: model)であるconst $X = Immutable.Record(...);class $X extends Immutable.Record(...){という置換です。 このルールの変換結果を単独で見ると class { に対する } が存在しないので構文的におかしいコードを生成しています。 しかし、マッチまではCSTを使った構文木で行い、置換はfix-regexでの正規表現をしています。

そのためSemgrepは次のようなイメージで置換処理ができます。

  1. CSTでのマッチ pattern
  2. 1の中でさらに置換範囲を正規表現でフィルター fix-regex.regex
  3. 2のマッチから実際に置換 fix-regex.replacement

このような感じで大雑把に構文木を使ったマッチをして、正規表現で細かいところを取り出して置換する形ができます。 すべてをASTでマッチと置換する場合は覚える構文増えるので難しくなりがちですが、Semgrepは細かいところは正規表現を使ってできるのでコスパが良い感じです。

似たような仕組みは、以前nlp-pattern-matchというライブラリで書いたことがあります。 nlp-pattern-matchは英語や日本を品詞分解してパターンマッチするライブラリです。 最初に品詞を使ったマッチ(ASTにあたる)をして、そのマッチした内容が正規表現で再チェックしてから、正規表現で置換するという仕組みです。

jscodeshiftといったASTでのマイグレーションは、この操作を基本的にASTの書き換えで行います。 ASTで書き換えまで行うと、生成されるコードは構文として壊れてないことが保証できますが、変換するコードはかなり冗長になります。

jscodeshiftなどを使ったマイグレーションは次の記事やスライドで紹介しています。

patternの組み合わせ

次のルールはそれぞれインスタンスメソッドと静的メソッドへの置換に対応しています。

  - id: instance-method
    languages:
      - typescript
    message: |
      prototype method to class instance methods
    patterns:
      - pattern: "$X.prototype.$Y = function(...){ ... }"
      - pattern: "$X.prototype.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*)\.prototype\.(.*) = function.*\('
      replacement: '\2('
    severity: WARNING
  - id: static-method
    languages:
      - typescript
    message: |
      static method to class statics methods
    patterns:
      - pattern: "$X.$Y = function(...){ ... }"
      - pattern: "$X.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }
    fix-regex:
      regex: '^(.*?)\.(.*?) = function.*\('
      replacement: 'static \2('
    severity: WARNING

基本的には文字列置換とあまり変わりませんが、pattern-not-insideを使って誤検知を減らしています。 pattern-not-insideは特定のパターンの内側ではないことをAND条件に足せます。

例えば次の静的メソッドのパターンは関数の内側ではないという条件をpattern-not-insideで追加しています。

    patterns:
      - pattern: "$X.$Y = function(...){ ... }"
      - pattern: "$X.$Y = function $Y(...){ ... }"
      - pattern-not-inside: function (...){ ... }

このpattern-not-insideによって、次のようなケースが除外できます。 そのまま、$X.$Y = function(...){ ... } にマッチするからといって置換するとおかしなことになるのを避ける例外ルール的なものです。

Book.prototype.init = function(...){
    this.onLoad = function(...){ ... }
}

ASTでやる場合は、マッチした要素のparentが関数ではないみたいなチェックを書いたりするのですが、 pattern-not-insidepattern-insideで同様のことがフラットな感じでルールとして表現できるのが結構便利でした。

まとめ

Semgrepは、正規表現では難しくASTツールでは面倒なチェックや変換がある程度簡単に書けるのが特徴です。

今はメンテナンスされていませんが、JavaScriptではgraspという似たASTベースの検索や置換を行うツールがありました。

Graspではequerysqueryのようにクエリ言語を少し覚える必要がありました。 Semgrepではワイルドカード的なパターンマッチとAND/OR/NOTの組合わせでマッチング範囲を絞り込んで、 そのマッチした中から正規表現で細かい処理をするといった段階的なアプローチをとれます。 そのため、学習コストは少なくて文字列置換に近いけど、より正確な置換ができるのが便利なところです。

SemgrepのComparisonsを見ても、Linterなどに比べて簡単にルールを作れるところ推している感じがします。

ESLintなどのLinterはASTベースでチェックするルールを書きますが、ちょっとしたチェックもASTベースで書く必要があるので、ルールを足すのが気軽という感じではありません。

たとえば、Node.jsでchild_process.execに変数を渡してるケースを見つけたいとします。 この変数がユーザー入力だったらCommand Injectionの脆弱性になります。

chilre_proecss.exec(input);
chilre.exec(input);
exec(input);

これをASTでまともにやろうとすると少し面倒ですが、Semgrepだとpatterns:を乱雑に並べていくだけでとりあえず検知できます。 このようにざっくりとしたルールを定義しやすいのが特徴的です。一方でもっといろいろなケースを想定した場合は、ESLintのようなルールのほうが誤検知はしにくいと思います。

rules:
  - id: command-inection-check
    languages:
      - javascript
    message: |
      Disallow to use exec(val)
    patterns:
      - pattern: exec(...)
      - pattern-not: exec("...")
      - pattern: child.exec(...)
      - pattern-not: child.exec("...")
      - pattern: child_process.exec(...)
      - pattern-not: child_process.exec("...")
    severity: WARNING

CodeQLとの比較もありますが、CodeQLはデータフロー解析をするツールです。(GitHubが買収したSemmleが作ったエンジン) データフロー解析によって特定のデータを汚染されたデータ(Taint)として、そのデータがどこから来て(Source)、どこに行く(Sink)かを解析できるので、Semgrepに比べると深いスキャンができます。たとえば、ユーザからHTTP リクエストを経由して渡ってきたURLをTaintデータとして扱って、そのURLに対してサーバからリクエストを送ったらSSRFの脆弱性だよねって感じでチェックできます。

データフローを解析したり著名な脆弱性をチェックするクエリなどが揃っていたりするので便利ですが、事前にコードをデータベース化したり結構スキャンに時間がかかるといった違いがあります。そのため、Semgrepとの比較対象としては適当ではない感じがします。(似たようなことはできるけど)

Semgrepはまだ開発中という感じのツールですが、 文字列置換だと不安だけど、ASTを使うツール書くのは面倒くさいという需要を満たしてくれるちょうどいい感じのツールです。 覚えておくと便利なときがある感じがするツールでした。