Claude Codeでセキュリティ修正のPRをレビューする手順 — 回帰テストと抜け道の確認
脆弱性修正のPRは、修正前に落ちる回帰テストと、別経路に残る同じ欠陥の探索で確かめます。テストを先に書かせ、レビュー役のサブエージェントに抜け道を探させる流れを、手元で動かした例つきで説明します。
脆弱性を直すPRは、「直ったように見える」だけでは承認できません。見る点は2つです。修正前の再現入力で落ち、修正後に通る回帰テストがあること。そして、同じ欠陥が別のエンドポイントや別のエンコードに残っていないことです。Claude Codeでは、テストを先に書かせる工程と、別コンテキストのレビュー役に抜け道を探させる工程に分けると、この2点を確かめやすくなります。
セキュリティ修正のPRで先に見るのは何か
通常のPRは「意図した動作になったか」を見ます。セキュリティ修正のPRは、さらに「攻撃者が別の入力で同じ結果を得られないか」を見る必要があります。
見る点は3つに絞れます。
修正PRを承認する前の3点
- 修正前のコードで落ち、修正後のコードで通るテストが入っている
- 修正が効くのは1つの入力形だけで、エンコードや区切り文字を変えた入力が残っていない
- 同じ関数を呼ぶ別の入口(別のエンドポイント、別の呼び出し元)が同じ欠陥を抱えていない
差分を読むだけのレビューでは、2つ目と3つ目が抜けやすくなります。修正された行は差分に出ますが、直されなかった別の入口は差分に出ないからです。
既存の記事との分担も書いておきます。ブランチの差分から脆弱性を検知する流れはClaude Code security-reviewでブランチの脆弱性を検知するにあります。スキャナーの結果を再スキャンで閉じる流れはClaude CodeでTrivyを回して脆弱性を直す手順の担当です。この記事は、修正が入ったあとに「その修正は抜けていないか」を確かめる段階を扱います。
修正前に落ちる回帰テストを先に書かせる
最初にやるのは、修正コードに触る前のテスト作成です。順序が逆だと、Claudeは修正後のコードに合わせて通るテストを書きがちです。通るテストは、修正が無くても通る可能性があります。
指示文の骨格は次のとおりです。
パストラバーサルの修正PRをレビューします。まだ修正コードには触らないでください。
1. 現在のベースブランチのコードに対して、攻撃入力で失敗するテストだけを書く
2. 入力は「../secret.txt」だけでなく、URLエンコード・二重エンコード・
バックスラッシュ区切りの変形も含める
3. テストを実行し、すべて失敗することを出力で示す3番目が肝心です。失敗を実際に見せさせることで、「テストが何も検証していない」状態を避けられます。テストの書き方そのもの(既存パターンへの合わせ方、エッジケースの足し方)はClaude Codeで単体テストを書かせる手順に詳しいので、ここでは攻撃入力の選び方に集中します。
修正前のコードで落ちることを確かめる
テストを書いたら、修正を含まないコミットで走らせて赤になることを確かめます。作業ツリーを汚さずに試すなら、ベースのコミットを別のworktreeに出すのが手軽です。
git worktree add ../base-check origin/main
cp tests/path-traversal.test.js ../base-check/tests/
cd ../base-check && npm test赤にならないテストは、攻撃入力を再現できていません。その場合はテストを直してから先へ進みます。修正PRの側では逆に、同じテストが全部緑になることを確かめます。
「../を拒否する」修正は何を通すのか — 手元で動かした例
どんな抜けが起きるのかを、小さな関数で試しました。Node.js 20.18.0の node:test で動かした結果です。
初回の修正は、入力に ../ を含むと拒否するだけの関数です。2つ目は、デコードしたうえで正規化し、結果のパスが公開ディレクトリの配下かを判定する関数です。
const path = require('node:path');
const ROOT = path.resolve('public');
// 初回の修正: "../" を含む入力を拒否する
function resolveNaive(name) {
if (name.includes('../')) throw new Error('forbidden');
return path.join(ROOT, name);
}
// 修正案2: デコード後に正規化し、ルート配下か確かめる
function resolveSafe(name) {
const decoded = decodeURIComponent(name);
const full = path.resolve(ROOT, decoded);
if (full !== ROOT && !full.startsWith(ROOT + path.sep)) throw new Error('forbidden');
return full;
}5つの入力を両方に渡し、拒否されれば成功、パスを返したら失敗とするテストを走らせた結果が次の表です。
| 入力 | 初回の修正 | 修正案2 |
|---|---|---|
../secret.txt | 初回の修正拒否 | 修正案2拒否 |
a/../../secret.txt | 初回の修正拒否 | 修正案2拒否 |
..%2fsecret.txt | 初回の修正パスを返す | 修正案2拒否 |
%2e%2e%2fsecret.txt | 初回の修正パスを返す | 修正案2拒否 |
..\secret.txt | 初回の修正パスを返す | 修正案2パスを返す |
初回の修正は、素直な ../ を2通りとも止めます。単体では「直った」ように見えるのはこのためです。ところが %2f や %2e で書き換えた入力は、この関数の中では ../ という文字列になっていないので素通りします。ルーターやWebサーバーが後段で1段デコードする構成なら、その先で ../ に戻ります。
最後の行は、見方に注意が要ります。バックスラッシュ区切りの入力は、修正案2でもパスを返しました。macOSやLinuxでは \ はファイル名の一部なので public の中の別名として扱われ、外へは出ません。区切り文字が \ にもなるWindowsでは話が変わります。つまりこのテストは、実行するOSによって意味が変わります。レビュー時には「どのOSの挙動を前提にしたテストか」を確認する価値があります。
抜け道を探す入力の型
「別エンコード」と一口に言っても、探す入力にはいくつかの型があります。パストラバーサルの変形としてOWASPが挙げているものを、レビューの探索リストに使えます。
| 型 | 例 | 初回の修正を通る理由 |
|---|---|---|
| 単純なURLエンコード | 例%2e%2e%2f、..%2f | 初回の修正を通る理由文字列検査がデコード前に行われる |
| 二重エンコード | 例%252e%252e%255c | 初回の修正を通る理由1段デコード後もまだ記号が残る |
| 過長なUTF-8表現 | 例..%c0%af | 初回の修正を通る理由検査側と実際に解釈する側でデコードの扱いが違う |
| 区切り文字の違い | 例..\ | 初回の修正を通る理由UNIX系は /、Windowsは / と \ の両方 |
| ヌルバイト | 例secret.doc%00.pdf | 初回の修正を通る理由検証側は拡張子 .pdf を見て、OS側は .doc を開く |
OWASPのUnicode Encodingのページは、検査がエンコードを考慮しないと、禁止している ../ の並びを別の表現で書いた入力が通り抜けると説明しています。ここに書かれているのは攻撃の型であって、特定のコードベースで通るかどうかは別の問題です。だからこそ、自分のコードに対して実際に入力を流して確かめます。
OWASPが示す対策にも、修正案2の方向と重なる点があります。ユーザー入力は無害化(sanitize)せず、許可できるものだけを受け入れること。入力をファイル操作のAPIに渡す前に、normalize() のような正規化を通すことです。「危険な文字列を消す」修正より「正規化してから許可範囲に入るか判定する」修正のほうが、変形入力に強い理由はここにあります。
別コンテキストのレビュー役にサブエージェントを使う
修正を書いたのと同じ会話で「抜け道がないか確認して」と頼むと、会話の流れに引っ張られます。修正の意図を知っているぶん、「ここは直したから大丈夫」と考えやすいのです。そこで、探索だけを別のコンテキストに任せます。
サブエージェントは新しいコンテキストウィンドウで始まり、元の会話の履歴を見ません。Claudeが書いた委任メッセージと、サブエージェント自身のシステムプロンプトから作業します。修正の経緯を引きずらない点が、このレビューには向いています。
ただし注意点が1つあります。サブエージェントにも、CLAUDE.mdの階層は読み込まれます。プロジェクトの規約を共有したくない場合は、frontmatterの omitClaudeMd: true で外せます(v2.1.271以降)。
読み取り専用のレビュー役を定義する
.claude/agents/ に次のファイルを置きます。例として、公式のcode-reviewerの定義を、セキュリティ修正向けに組み替えた形です。
---
name: fix-bypass-reviewer
description: Reviews a security-fix diff for bypasses. Use after a vulnerability fix is written, before merging.
tools: Read, Grep, Glob, Bash
disallowedTools: Write, Edit
model: inherit
---
あなたは脆弱性修正の抜け道を探すレビュー役です。修正コードは書き換えません。
手順:
1. git diff で修正の範囲を確認する
2. 修正された関数の呼び出し元を Grep で洗い出し、同じ検査を通っていない入口を探す
3. 修正が効く入力の形を特定し、エンコード違い・区切り文字違い・大文字小文字違いで
同じ結果になる入力を列挙する
4. 既存の回帰テストが列挙した入力を覆っているか確かめ、足りないものを報告する
報告は「抜け道の候補」「根拠(ファイルと行)」「再現する入力」の3項目で返す。tools は許可リスト、disallowedTools は拒否リストです。両方を指定すると、先に disallowedTools が適用され、残ったものから tools が解決されます。ここでは Write と Edit を外すことで、レビュー役が修正を黙って書き換えない形にしています。Bash は残してあるので、テストの実行やgitの閲覧はできます。Bash から書き込む操作まで禁じたい場合は、Bash も外して Read・Grep・Glob だけにするか、フックで制限する方法があります。
呼び出し方
呼び出しは自然な言葉で名前を挙げるだけで足ります。確実に動かしたい場合は、@ に続けてサブエージェントを選ぶ@-mentionを使うと、そのサブエージェントが1回の作業で必ず動きます。
fix-bypass-reviewer に、いまのブランチの修正を見てもらってください。
対象はパス検証の関数です。呼び出し元のエンドポイントを全部洗い出して、
同じ検査を通っていないものと、通る変形入力を報告してください。返ってくるのは、探索の要約です。調べた過程の長い出力はサブエージェント側に残り、メインの会話には要約だけが戻ります。大量のgrep結果や読んだファイルでメインのコンテキストを使い切らずに済むのは、サブエージェントを使う利点の1つです。
抜け道の探索で見るべき範囲
レビュー役に渡す観点は、入力の変形に限りません。次のように、欠陥が別の入口に残るパターンを分けて探させると取りこぼしが減ります。
修正が届いていない場所の探し方
別のエンドポイント
同じ処理を呼ぶ別のルートが、修正した関数を通らずに自前でパスを組み立てていないかを調べます。
別の呼び出し元
修正した関数の呼び出し元をすべて列挙し、入力が検査の前に加工されていないかを見ます。
別のエンコード
URLエンコード、二重エンコード、過長なUTF-8表現を、前の節の表に沿ってテストへ足します。
別のOS・実行環境
区切り文字が違う環境での挙動をテストに含めます。実行するOSで意味が変わる入力は、テスト名にその前提を書きます。
見つかった抜け道は、必ず回帰テストに変えます。「レビューで指摘された」というコメントだけを残すと、次の修正で再び壊れたときに誰も気づきません。レビュー役が報告した再現入力をそのままテストの入力に使えば、報告と回帰テストが同じ根拠になります。
つまずきやすい点
通るだけのテストが紛れ込む
Claudeにテストを書かせると、修正後のコードを読んでから書くため、修正後の挙動をなぞるテストになることがあります。修正前のコミットで走らせて赤になるか、という確認を省かないことが対策です。赤にならない場合、そのテストは攻撃入力を再現できていません。
同じ会話の自己レビューで済ませてしまう
修正を書いた会話で「ほかに問題はない?」と聞くと、書いた本人の見方で答えが返ります。サブエージェントのような別コンテキストを使うのは、見方を切り離すためです。ただし、サブエージェントが見つけられるのは、レビュー役の指示に書いた観点の範囲までです。観点の抜けは、指示を書く人間の側に残ります。
レビュー役の報告をそのまま信じる
抜け道の候補は、あくまで候補です。報告された入力を、実際にテストに入れて流して確かめます。流してみて通らなかった入力は、誤検知として報告に残します。
小さな修正に大きな仕組みを使う
サブエージェントは、最初から文脈を集め直すので、短い修正には手間が見合わないことがあります。素早い一点の修正や、往復が多い作業ではメインの会話のほうが向いています。逆に、出力が長くなる探索や、権限を絞りたい作業はサブエージェントに任せる判断になります。
まとめ
修正PRの承認は、「差分が正しいか」と「抜け道が残っていないか」を別々に確かめる作業です。前者は人間とClaudeが差分を読めば足りますが、後者は修正されなかった場所を探す作業なので、書いた会話とは別のコンテキストに任せるほうが向いています。
手元で試した例では、../ を拒否するだけの修正は、変形入力を3つ通しました。正規化して配下判定する修正は、同じ入力を止めました。それでも区切り文字の違う1件は、実行するOSに応じて意味が変わります。テストは入力を並べるだけでなく、「どの環境で何を保証するか」まで書いておくと、レビュー役の報告と突き合わせやすくなります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。