Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

Semgrep detect-non-literal-regexpの誤検知は、到達不能な分岐を型で消して直した

公開 読了時間 約4分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

PAGE_CASESの全エントリが`allowUrl`を宣言しているため`new RegExp(...)`へのフォールバック分岐は到達不能で、Semgrepのdetect-non-literal-regexpはその到達不能な分岐だけを検知していた。

結論

Semgrepのdetect-non-literal-regexpがE2Eテストのnew RegExp(c.path.replace(...))を検知したが、この分岐は実行時に到達しないdead branchだった。 PageCase型のallowUrlをoptionalからrequiredに変え、?? new RegExp(...)というフォールバック式そのものを削除することで、挙動を一切変えずに検知を解消した。

症状

権限マトリクスをロールごとに検証するE2Eテスト(authorization-matrix.spec.ts)に、次のフォールバックがありました。

// 修正前
type PageCase = {
  label: string;
  path: string;
  allow: readonly RoleKey[];
  /** 許可ロールで到達したときに満たすべき URL (省略時は path にそのまま留まる)。 */
  allowUrl?: RegExp;
};
// 修正前
await expect(page).toHaveURL(c.allowUrl ?? new RegExp(c.path.replace(/[/]/g, "\\/")));

このコードが原因で、main ブランチの SAST(Semgrep)が detect-non-literal-regexp の指摘でブロッキングとして赤くなりました。new RegExp(...) に渡す文字列が動的に見えることを検知するルールです。

原因

指摘された new RegExp(c.path.replace(...)) は、c.allowUrl が省略されたときだけ評価されるフォールバック分岐でした。ですが PAGE_CASES に定義されている全エントリは、この修正が入る時点ですでに例外なく allowUrl を宣言しています。つまり c.allowUrlundefined になるケースが存在せず、?? new RegExp(...) 側は実行時に一度も評価されない dead branch でした。

Semgrepのdetect-non-literal-regexpは、静的解析であるため「この分岐が実行時に到達するかどうか」までは追跡しません。new RegExp(...)という構文だけを見て、渡しているc.pathがテスト内の静的なPAGE_CASES配列から来るコンパイル時のリテラルであることも、その分岐自体が到達不能であることも判断できないため、構文としては非リテラルな正規表現構築だが実害はない箇所として検知していました。

直し方

コード側を変更し、Semgrepが追跡している「非リテラルなnew RegExp」そのものを消しました。

// 修正後
type PageCase = {
  label: string;
  path: string;
  allow: readonly RoleKey[];
  /**
   * 許可ロールで到達したときに満たすべき URL の正規表現 (必須)。各ケースが明示的に宣言する。
   * 以前は optional にし expectPageOutcome で `new RegExp(c.path)` へ動的フォールバックしていたが、
   * PAGE_CASES は全件 allowUrl を持つため当該フォールバックは到達不能 (dead branch) であり、
   * 非リテラル `new RegExp(...)` が Semgrep detect-non-literal-regexp の誤検知を生んでいた。
   */
  allowUrl: RegExp;
};
// 修正後
await expect(page).toHaveURL(c.allowUrl);

allowUrlを必須型に変えたことで、?? new RegExp(...)のフォールバック式自体が不要になり削除できます。Semgrepのルールセットやsemgrep ciの設定は一切変更していません。抑制コメント(# nosemgrep等)も使っていません。ルールを弱めるのではなく、ルールが指摘していたコードそのものを消す方向で解消しています。

再発防止

抑制で直す誘惑はありましたが、抑制は「この1箇所の指摘を黙らせる」だけで、allowUrlの宣言忘れというコード側のリスクは残ります。今回のようにallowUrl?: RegExpをrequiredに変えると、将来PAGE_CASESallowUrlを書き忘れたエントリを追加しようとした時点でTypeScriptの型検査自体が落ちます。Semgrepの指摘を「静的解析の限界(到達可能性を追跡できない)」として片付けず、その指摘が刺さっている型の緩さそのものを直すことで、検知の解消と将来の宣言漏れ防止を同時に得られました。

よくある質問

Q1なぜこれが誤検知だと言えるのですか?

detect-non-literal-regexpが検知した`new RegExp(c.path.replace(...))`は、渡している`c.path`がテストコード内の静的なPAGE_CASES配列から来るコンパイル時のリテラルで、しかもPAGE_CASESの全エントリがすでに`allowUrl`を宣言していたため、そのフォールバック自体が実行時に一度も到達しない分岐でした。動的な入力から正規表現を組み立てているわけではありません。

Q2suppressコメントやSemgrepの設定除外で直さなかったのはなぜですか?

抑制はこの1箇所を黙らせるだけで、到達不能な分岐というコード側の問題は残ります。将来PAGE_CASESに`allowUrl`を書き忘れたエントリが増えても、抑制されたルールはそれを検知できません。ルールを弱めるのではなく、分岐そのものを消す直し方を選びました。

Q3具体的にどう直したのですか?

`PageCase`型の`allowUrl?: RegExp`を`allowUrl: RegExp`に変えて必須にし、`c.allowUrl ?? new RegExp(c.path.replace(...))`というフォールバック式から`?? new RegExp(...)`側を削除しました。Semgrepの設定やCIのルール自体は変えていません。

Q4この修正でテストの挙動は変わりましたか?

変わりません。PAGE_CASESは修正前から全エントリが`allowUrl`を宣言していたため、フォールバックは修正前の時点で一度も実行されていませんでした。型を必須にしたのは、実行時の挙動ではなく「各ページは到達後のURLを明示する」という意図をコード上で強制するためです。

確認した環境

  • Semgrep 1.171.0(`semgrep ci`、image: semgrep/semgrep@sha256:bdf7013b...、# 1.171.0固定)
  • Playwright(`@playwright/test`)によるE2Eテスト
  • 2026-06-04 に発生・修正(PR #576)

この記事の根拠

  • TypeScriptファイル 50〜120行目コミット ef020ca
  • YAMLファイル 63〜63行目コミット 48f38e3

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。