Semgrepを使った構文木ベースの検索と置換でコードのリファクタリングをする
Semgrepという構文木ベースのgrep/sed的なツールを使ったリファクタリングをした話です。
Semgrep
Semgrepはr2cという会社/サービスが開発しているツールです。 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
関数に変数を渡している箇所が見つけられそうです。
このパターンは次のようにpattern
とpattern-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-migrateとcommonjs-to-es-module-codemodを使いほぼ機械的に行いました。
リファクタリングの対象
TypeScriptへ移行はできたのですが、元コードがJavaScriptのprototypeをベタ書きしているため、まともに型が効いてない状態でした。 たとえば、次のmodels/book.tsはImmutable.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は次のようなイメージで置換処理ができます。
- CSTでのマッチ
pattern
- 1の中でさらに置換範囲を正規表現でフィルター
fix-regex.regex
- 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-inside
やpattern-inside
で同様のことがフラットな感じでルールとして表現できるのが結構便利でした。
まとめ
Semgrepは、正規表現では難しくASTツールでは面倒なチェックや変換がある程度簡単に書けるのが特徴です。
今はメンテナンスされていませんが、JavaScriptではgraspという似たASTベースの検索や置換を行うツールがありました。
Graspではequeryやsqueryのようにクエリ言語を少し覚える必要がありました。 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を使うツール書くのは面倒くさいという需要を満たしてくれるちょうどいい感じのツールです。 覚えておくと便利なときがある感じがするツールでした。
- Semgrep
- 公式サイト
- Semgrep - Live Editor
- ブラウザで試せるエディタ
- https://github.com/azu/semgrep-demo
- 記事中のサンプルコード
お知らせ欄
JavaScript Primerの書籍版がAmazonで購入できます。
JavaScriptに関する最新情報は週一でJSer.infoを更新しています。
GitHub Sponsorsでの支援を募集しています。