何を作っているか

RIKKA M&A は事業やWebサイトを売買するプラットフォームです。その中に「技術デューデリジェンス(技術DD)」という機能があり、売り手のGitHubリポジトリを解析して、買い手が引き継ぐ前に知っておくべき技術的なリスクを洗い出しています。

構成はこうなっています。

GitHub API
   └→ ファイル選定
        ├→ 決定論的パターンチェック ───────────────────┐
        └→ LLM: findings生成 → LLM: critic(敵対的再検証) ┴→ レポート

この記事は、この機能の検出品質を測ろうとして分かったことの記録です。結論を先に書きます。LLMの脆弱性検出は実行ごとに当たったり外れたりします。そして、その不安定さを測る基盤がないと原因を誤診します。 実際に私たちは誤診しました。

発端: 既知の脆弱性が見つからない

品質保証のために検証セット(corpus)を作りました。60件のリポジトリを、役割別に分類しています。

role 内容
vulnerable 意図的に脆弱性を含む教材リポジトリ
app_clean 健全な実運用アプリ
framework_body フレームワーク本体
cve_patched CVEの修正コミット前後をピン留めした対

各エントリには must_find(見つけるべきもの)と must_not_find(出してはいけないもの)を、機械判定できる形で持たせています。前者は見逃し(過小報告)を、後者は誤検知(過大評価)を検知するためのものです。

このうち「必ず見つけるべき」根幹にあたる19件を流したところ、大半が must_find 未達でした。既知の脆弱性を、それが在ると分かっているリポジトリで、見つけられていない。

最初の誤診: 「criticが握りつぶしている」

エンジンには critic というパスがあります。findings生成が出した指摘を、別のLLM呼び出しが敵対的に再検証し、根拠が薄いものを落としたり深刻度を下げたりします。過大評価を防ぐための仕組みです。

見つからないなら critic が消しているのではないか。そう考えて、criticをスキップするオプション(ablation)を実装し、ON/OFFを比較しました。

そして「criticが実在する指摘を握りつぶしている」と結論しました。

これが誤りでした。

なぜ誤診したか: 差分に2つの原因が混ざる

ablationの実装はこうなっていました。

// ON  : critic あり
$reportOn  = $this->analyzeRepo($repo, skipCritic: false);
// OFF : critic なし
$reportOff = $this->analyzeRepo($repo, skipCritic: true);

ONとOFFは、完全に独立した2回の実行です。findings生成プロンプトは skipCritic に依存しないので、生成部分は「同じ入力・同じ設定での2回の独立実行」になります。

LLMは temperature を下げても、同じ入力に同じ出力を返しません。つまりON/OFFの差分には、

  1. criticが落とした分(測りたいもの)
  2. 生成そのものがブレた分(測りたくないもの)

の両方が混ざります。差分だけを見て「criticが落とした」と言うことは、構造上できませんでした。

:::message ON/OFF比較(ablation)は、対象が決定論的なら成立します。非決定的なコンポーネントを挟んだ瞬間、ペア差分は「操作した変数の効果」を意味しなくなります。切り分けたいなら、単一実行の内部で両方を記録する設計にする必要があります。 :::

そもそも、捨てた情報を記録していなかった

もっと基本的な問題がありました。criticが落としたfindingは、watchdogログに出るだけで、どこにも残っていませんでした。何を落としたのか、なぜ落としたのかが、実行後に分からない。

これでは「criticが正しい指摘を誤って落とした」ことを事後に監査できません。ON/OFF差分から推測するしかなく、その推測は上記の理由で成立しない。原理的に測定不能な状態で推測していたわけです。

そこで、落としたfindingの全文と判定根拠をDBに永続化しました。

// 落とした finding とその根拠を result_detail テーブルに記録する
$this->recordResultDetail($repoKey, [
  'findings_json' => $findings,
  'dropped_json'  => $dropped,   // ← これが無かった
]);

記録した状態で8回の独立実行を回した結果は、dropped = 0件。criticは一度も、何も落としていませんでした。

最初の診断は間違いでした。criticは無実で、問題はもっと上流の生成側にありました。

本当の問題: 生成が不安定

測り直すと、検出は「たまに当たる」ものでした。

対象 脆弱性クラス 検出
A: 実運用アプリのCVE(修正前をピン留め) ディレクトリトラバーサル 11回中 0回
B: LLMアプリの教材 プロンプトインジェクション 8回中 3回(37.5%)
C: Javaの教材 SQLインジェクション 3回中 1回 → 追加2回でも0回

Aについては、そもそも該当ファイルが解析対象に選ばれていない可能性を疑い、ファイル選定ロジックも改修しました。選定は直りましたが、検出率は0%のまま変わりませんでした。プロンプトを調整しても変わりませんでした。

ここで、見逃しには質の違う2種類があることが分かります。

  • 型A: 一度も認識しない(Aのケース)。「パス正規化なしのプレフィックス一致」という構造を、モデルが脆弱性として認識していない。何回試しても出ない。
  • 型B: 時々しか認識しない(B・Cのケース)。単発では3〜4割。複数回やれば、いずれかで当たる。

この区別は打ち手が全く違うので重要です。

union戦略を実装した。が、検証できなかった

型Bには素直な対策があります。生成をN回独立実行して、fingerprint単位で和集合を取る。

// 同一 fingerprint は最も重大な severity を採用して束ねる(純粋関数)
TechDdFingerprint::mergeFindingSamples($samples);

手元の8回分の過去データで事前検証したところ、型Bは「3回やればいずれかで当たっていたはず」でした。追加コストゼロで、理論上は解決すると確認できた。

実際に --samples=3 を回しました。

  • 型A: 単発8回 + samples=3 の計11回試行で、検出率0%のまま。unionでも解決しない(予想どおり)
  • 型B: 1回試して、外れた

単発ヒット率37.5%なら、3回中1回以上当たる確率は約76%です。しかし外れる確率も18.5%あります。つまり n=1の試行では、効果の有無を証明も反証もできません

統計的に結論を出すには --samples=3 を何度も繰り返す必要があります。1回の実行が約55円、samples=3で約92円。有意な差を見るには数千円かかると分かった時点で、この実証は打ち切りました。機能はコード資産として残っていますが、本番導入は保留のままです。

:::message alert 非決定的なシステムの改善効果は、着手前に「何回試せば結論が出るか」と「その総コスト」を見積もるべきでした。実装してから気づくと、コードだけ残って効果不明のまま保留になります。 :::

分かったこと

1. 捨てた情報を記録していないシステムは、原因調査ができない

これが全ての起点でした。droppedを記録していれば、最初の誤診は起きませんでした。ログに出すだけでは足りません。後から機械的に集計できる形で残す必要があります。LLMを判定に使うなら、「採用した結果」だけでなく「捨てた候補とその理由」を最初から永続化しておく。後から足すと、それまでの実行が全て診断不能なまま流れていきます。

2. 非決定的なコンポーネントを挟んだON/OFF比較は成立しない

差分に何が混ざるかを先に考える。ここを飛ばすと、もっともらしい因果を組み立てて、しばらく気づきません。

3. 見逃しには質の違う2種類がある

「時々見つける」と「一度も見つけない」は別の問題です。後者にサンプリングを足しても永久に解決しません。機械判定できる事実はLLMから剥がす——決定論的なパターンマッチで検出すべき領域です。非決定性の影響を受けず、100%再現します。

4. 抑制ルールばかり積むと、網羅性が落ちる(仮説)

これは実証していない仮説ですが、思い当たる節があります。

過大評価を潰す過程で、プロンプトには「根拠がなければ指摘しない」「実証できないものは出さない」といった抑制ルールが積み重なりました。一方で「網羅的に探す」ための構造——脆弱性クラスを1つずつ検討してから結論を出す、といった発散ステップ——はほとんど入っていません。

過大評価と過小報告はトレードオフです。片方を潰すともう片方に振れる。実際、過去にも「過大評価を是正したら過小報告に振れた」ことがありました。なので今は、プロンプトや閾値を変えるたびに、両側のゲート(出してはいけないもの/見つけるべきもの)を両方確認してからでないとcommitしない工程にしています。片側だけ良くなった変更は通しません。

いまやっていること

型Aには決定論的パターンマッチの拡充から着手します。効果が確実に検証でき、100%再現するからです。型Bにはプロンプトの構造改訂(抑制一辺倒から、発散→収束の2段階へ)を先に試し、N-runの本番導入はその効果を見てから判断します。

測定基盤ができただけで、まだ診断であって治療ではありません。ユーザーが「見逃しが減った」と体感できるところには、まだ到達していません。


この記事は、事業・Webサイトの売買プラットフォーム RIKKA M&A を開発する中で得た知見です。RIKKA M&A は GitHub リポジトリを解析する技術デューデリジェンスを備え、Drupal 11 / PHP / LLM で構築・運用しています。

技術DDは誰でも無料で試せます(GitHubリポジトリをその場で診断)。 RIKKA M&A の技術デューデリジェンスを試す