Skip to main content

正規表現で同期関数をタイムアウトさせる

blog hero regex async

2023年4月6日

0 分で読めます

コンテナイメージのカスタムタグへの対応は、どれくらい難しいのでしょうか?実際にやってみると……かなり難しいものでした!私たちのチームは、カスタムベースイメージのサポートをSnyk Containerに追加する作業に取り組んでおり、次の課題に直面したからです。タグを解析して各要素を取り出し、ほかの類似タグと比較できるようにすることです。解決するのが楽しい問題だったので、最終的な解決策にたどり着くまでの過程をご紹介します!

正規表現で問題を解決?

私たちは、正規表現、特に名前付きグループ機能を使うことを考えました。たとえば、次の文字列にマッチさせたい場合、

1.82.5_2022_x64_final

次の正規表現を使えます(たまに正規表現の悪夢を見る方には、先にお詫びします)。

/(?<C0>\d+)\.(?<C1>1d+)\.(?<C2>1d+)_(?<C3>\d{4})_(?<M0>.*)(?<M1>.*)/

ここで危険信号 🚩 が点灯した方もいるでしょう。正規表現が危険な場合があるのは周知のとおりです。ユーザーが入力文字列と正規表現の両方を指定できるようにするのは、攻撃を招くようなものです!

ユーザーは、たとえば次のようなベースイメージを簡単に作成できます。

"docker-exploit:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!"

そして、次の文字列を渡すこともできます。

/(a+)+$/

これにより、サービスはほかのすべてのリクエストを処理できなくなってしまいます!AppSecチームが、こんなことを許すはずがありません。

では、最初から考え直す必要があるのでしょうか?幸い、そうではありません!このような攻撃を防ぐために設計されたライブラリ(正規表現パーサーエンジン)があります。Googleが開発したRE2です。一部の正規表現機能には対応していませんが、そもそもそれらは使わないので問題ありません。

ここからが本番です!

私たちは、制御できないバグやソフトウェアサプライチェーン攻撃の可能性がある外部依存関係だけを、唯一の安全策にしたくありませんでした。そこで、処理に時間がかかりすぎる正規表現をタイムアウトさせる方法が必要でした。

Nodeはシングルスレッドなので、並行実行できないコードをタイムアウトさせるのは簡単ではありません。そこで考えた解決策がこちらです。

スレッド 🧵 です!

おそらく、まず思い浮かんだのはこれでしょう。私たちも同じでした。スレッドを停止できるだけでなく、メインのイベントループをブロックしないという利点もあります!

では、コードを書いてみましょう。

メインスレッド

// Main thread
let worker = new Worker("./dist/regex-parsing/regex-worker.js");

async function runRegexSafely(tag: string, regex: RE2) {
 const threadedRegexMatch = new Promise((resolve) => {
   worker.once("message", resolve);
   worker.postMessage({ tag, regex }); // Send the string and regex to the worker
 });
 try {
   return await Promise.race([threadedRegexMatch, timeoutReject(timeoutMs)]);
 } catch (e) {
   await worker.terminate();
   worker = new Worker("./dist/regex-parsing/regex-worker.js");
   throw e;
 }
}

ワーカースクリプト

// Worker script
parentPort.on("message", ({ tag, regex }) => {
 parentPort?.postMessage(tag.match(regex));
});

簡単ですよね?では、コードを実行してみましょう……

非同期の正規表現解析コードでDataCloneErrorが発生し、Promiseの未処理の拒否が起きているターミナル出力。

うーん、このエラーは初めて見ました。MDNの説明を確認してみましょう。

「構造化クローンで機能しないもの」というタイトルのスライド。Functionオブジェクトは複製できず、DataCloneErrorが発生することを説明しています。

また、may not contain native (C++ backed) objects

オブジェクトは参照渡しではなく、複製されることがわかりました。そして、RE2は関数(クラス)であり、C++ベースのオブジェクトへのバインディングにすぎないため、引数として渡すこともできません。

スレッド内でオブジェクトを作成し、正規表現を文字列として渡す方法もあります。しかし、ここでも危険信号 🚩 が点灯しました。RE2を呼び出すたびにmatchオブジェクトを再構築する必要があると、パフォーマンスに無視できない影響が出るかもしれません。

new RE2(/(a+)+$/)

憶測するのはやめて、実際にテストしてみましょう。benchmark.jsを使いました。

パフォーマンスのベンチマーク

⚠️ このようなマイクロベンチマークを実行するのは簡単ではありません。V8の最適化は非常に高度なので、結果は参考程度に捉えてください。マイクロベンチマークについて解説した、こちらのYouTube動画がおすすめです

ベースラインとして、RE2.matchは1秒あたり50万回実行できます。

RegExpオブジェクトは複製可能なので、RE2の代わりにそのままスレッドに送って、パフォーマンスを確認できます。ワーカースレッド内でRegExp.matchを実行し、結果を待つ場合、1秒あたり5万回実行できることがわかりました。90%の低下です!🤩

スレッド内でRE2オブジェクトを作成すると、さらに悪化して1秒あたり1万7,000回となりました。パフォーマンスが97%低下します。😭

スレッドは最適な解決策ではないのかもしれません。幸い、別の方法があります!

vmモジュール

Nodeにはvmというモジュールがあり、スクリプトを別のコンテキストで実行できます。この方法の利点は、タイムアウト機能が組み込まれていることです。

サンプルコードはこちらです。

const vm = require('node:vm');

const context = {
  regex: '(?<C0>.*)\\.(?<C1>.*)',
  tag: 1.2
};

vm.createContext(context);
const script = new vm.Script("regex.exec(tag)");

return script.runInContext(context, {timeout: 1000});

マッチを実行するたびにスクリプトとコンテキストを作成する必要はありません。クロージャを作成して返せばよいのです。

最終的なコード

function getClosure() {
	const context = {
	  regex: '(?<C0>.*)\\.(?<C1>.*)',
	  tag: 1.2
	};

	vm.createContext(context);
	const script = new vm.Script("regex.exec(tag)");

	return function(regex, tag, timeout=0) {
		context.regex = regex;
		context.tag = tag;

		return script.runInContext(context, {timeout});
	}
}

const finalMatchFunction = getClosure();

もう一度ベンチマーク

ベンチマークで実行して、パフォーマンスを確認しましょう。vm内で次の処理を実行すると、許容できる水準の1秒あたり20万回を達成しました。👍

finalMatchFunction(RE2("(?<C0>.*)"), "1")

あっ、タイムアウトを設定するのを忘れていました。タイムアウトを100ミリ秒にして、もう一度ベンチマークを実行しましょう。

finalMatchFunction(RE2("(?<C0>.*)"), "1", 100)

実際にタイムアウトすることはないので、大きな影響はないはずですが……

finalMatchFunction#withtimeout × 10,798 ops/sec ±2.07% (86 runs sampled)

Process finished with exit code 0

ううううううううう😭😭😭

実際には一度も発生しないタイムアウトを追加しただけなのに、スレッドを使う方法よりもパフォーマンスが悪化するのはなぜでしょう!?

Googleで検索してみると、GitHubのissueにたどり着き、そこからPRへ。さらに、ドキュメントページの下部にひっそりと記された注記を見つけました。

⚠️ breakOnSigintオプションまたはタイムアウトを使用すると、新しいイベントループが作成され、それに伴ってスレッドも起動されます。そのため、無視できないパフォーマンスのオーバーヘッドが発生します。

なんてことだ!

まとめ

ワーカースレッドはvmを使う方法よりも複雑です(コードが読みにくく、より多くのリソースを必要とし、利用する関数のシグネチャをすべてasyncに変更する必要があります)。一方で、パフォーマンスは同程度だったため、vmを使う方法を採用しました。将来、ボトルネックになった場合は最適化できます。

今回、この機能をどのように実装したかをご紹介しました。ぜひ、新しいカスタムベースイメージの推奨機能をお試しください。気に入ったら、Twitterで@snyksecに感想をお寄せください。

開発者ファーストのコンテナセキュリティ

Snykは、コンテナイメージとKubernetesワークロードの脆弱性を検出し、自動的に修正します。


カテゴリー:

続きを読む

feature customer snowflake
Article

開発初期からセキュリティを確保:Snowflake Cortex Code向けSnyk Studioインテグレーションを発表

Snyk StudioがSnowflake Cortex Codeと連携し、開発中にAI生成コード、依存関係、コンテナの脆弱性をスキャンします。

Blog

Stadium Summer:Snyk Connect Fan Zone Tour

SnykのFan Zone Tourでは、8都市と3回のオンラインセッションで、AIセキュリティのワークショップやネットワーキング、楽しい競技を開催しました。参加者はスキルを磨き、アイデアを共有し、共に成長しました。

Blog

Snyk VulnBench JS 1.0:LLMは同じバグを2回見つけられるか?

Snyk VulnBench JS 1.0:300回の反復スキャンで、LLMのセキュリティ検出結果は実行ごとに異なる一方、SASTとモデルはそれぞれ異なる脆弱性の見落としを検出することが明らかに。