最初に結論を書きます。
「AIに脆弱性を100%特定させる」という主張は撤回します。
ただし、ASTから構造を抽出してAIが推論しやすい粒度に分割するという設計思想は、
実際に試してみたら想定以上に有効でした。
この記事は、間違いを認めながら、でも「そこから何を得たか」を書いたものです。
この記事は、以下の2記事の続編です。
指摘を受けた日のこと
前の記事を公開してすぐ、こんな言葉をもらった。
「全部を一度に処理するのは効率低下が当然起こる。恐らく長さの二乗、あるいはそれ以上の推論が必要になるはず。むしろ同条件の構文単位に分けるべき」
正直、悔しかった。
でも読んでいくうちに、これは完全に正しい とわかった。
理論編での自分は「コードを読ませない」という言葉を前面に出しすぎていた。
本当に必要だったのは、コードを消すことではなく、AIが考えやすい順番に並べ替えることだった。
そして、その設計が一番壊れていたのが「全部を一括でAIへ渡す」という発想だった。
前の理論、どこが甘かったのか
理論編の主張は、こういう流れだった。
- ASTから構造情報を抽出して「Deep Structure Map」に圧縮する
- 生コードの代わりにそのマップをLLMへ渡す
- LLMがSource → Propagator → SinkのTaint Analysisを実行する
- 「理論上、100%特定可能」
この流れには、3つの穴があった。
穴① 「100%」という断言が誤りだった
Taint Analysisの経路探索は、最悪ケースでノード数 $n$ に対して $O(2^n)$ まで膨らむ。
さらに、以下のケースはASTベースでは原理的に捕捉できない。
-
eval()/exec()による動的コード生成 -
getattr()/importlib.import_moduleを使ったリフレクション - 多重継承・Mixinをまたいだポリモーフィズム
- 外部ライブラリの内部に隠れたSink
完全性(全脆弱性を見つける)と健全性(偽陽性を出さない)は原理的にトレードオフだ。
NP困難のクラスに属する問題に対して「保証する」と書いたのは、不誠実だった。
穴② 全体を一括で渡す設計が非効率だった
LLMのAttentionはトークン数 $n$ に対して $O(n^2)$ のコストがかかる。
構造マップが大きいほど、推論コストは二乗で増加する。
「長さの二乗かそれ以上」という指摘は、これをほぼ正確に言い当てていた。
さらに、コンテキストの中央付近に埋まった情報はLLMが正しく参照できなくなることが知られている。
(いわゆる "Lost in the Middle" 問題)
[一括投入した場合のLLMの参照精度(概念図)]
先頭 ██████████░░░░░░░░░░░░░░░░░░░░ 末尾
参照できる 中央は薄れる 参照できる
巨大な構造マップを1回で渡すと、重要な依存関係が中央に埋もれて見落とされるリスクが高い。
穴③ 「構造化」と「可解化」を混同していた
「生コードを1行も読ませない」はキャッチーだったが、本質ではなかった。
ASTで「構造化」してもAIにとって「可解化」されるとは限らない。
情報が整理されていても、量が多ければ推論は難しいままだ。
ASTは「コードの代替物」ではなく、AIへ渡す前の前処理レイヤーとして使うのが正しかった。
問題はコードそのものではなく、AIが一度に処理させられる情報密度だった。
作り直した実装で何を変えたか
指摘を受けた後、実装を v6 へ全面的に書き直した。コード全体はここで確認できる。
変更の核心は4つだ。
変更① Semantic Chunking — Tarjan SCCによる分割
前の実装は、プロジェクト全体の構造マップを単一オブジェクトとして生成していた。
v6では、呼び出しグラフをTarjanのアルゴリズムで強連結成分(SCC)に分割し、
各SCCをひとつの「AIへ渡す解析単位」として扱うようにした。
SCCとは「互いに到達可能な関数群」のこと。
相互に呼び合うメソッドや再帰的な処理がひとつのチャンクにまとまるため、
AIは「このチャンクの中で危険な経路があるか」という局所判定だけに集中できる。
環境変数 MAX_CHUNK_NODES=N(デフォルト30)で粒度を調整できる。
変更② FunctionTaintSummary — 関数間の汚染追跡
前の実装はファイル内のTaint解析にとどまっていた。
v6では FunctionTaintSummary データクラスを導入し、関数シグネチャレベルで汚染状態を記録する。
「関数Aが汚染された値を返し、それを関数Bがシンクへ流す」という
ファイルをまたぐ脆弱性経路を静的解析の範囲内で追跡できるようになった。
変更③ 2パス解析
前の実装は1回の走査で全てを解析しようとしていた。v6では以下の2段階に分けた。
| パス | 内容 |
|---|---|
| パス1 | 全ファイルを走査して FunctionTaintSummary のみ収集 |
| パス2 | パス1のサマリーを注入したうえで、関数間伝播を考慮したTaint解析を実行 |
これにより、「別ファイルで定義されたラッパー関数が汚染を伝播させている」ケースも捕捉できる。
変更④ CrossChunkReducer — チャンク間伝播の検出
SCCで分割すると、チャンクをまたぐ伝播が「見えなくなる」問題が生じる。これを解決するのが CrossChunkReducer だ。
returns_tainted=True の関数が別チャンクのSinkへ流れ込む経路を検出し、
cross_chunk=True フラグ付きの TaintFlow として記録する。
各チャンクのAIプロンプトには「チャンク境界サマリー(セクションD)」としてこの情報が付記される。
AIは全チャンクを同時に見ることなく、クロス伝播の候補を局所的に推論できる設計だ。
AIへの渡し方も変えた
v6では各チャンクのセクションを5つに統一し、チャンクごとに個別でAIへ送信する構成にした。
A. 関数シグネチャサマリー
引数名・戻り値型・サイクロマティック複雑度・テイント状態を1行に集約
B. 呼び出しグラフ(Mermaid形式、チャンク内のみ)
チャンク外の呼び出しはEXTERNALノードへ集約してトークンを節約
C. テイントフロー(Mermaid + テーブル)
Source → Propagator → Sink のトリプルを構造化して表示
D. チャンク境界サマリー
他チャンクとの「接続部」を最小限で記述
E. AIタスク(このチャンク専用の自動生成プロンプト)
チャンクの危険度分布に応じた具体的な質問を自動で組み立てる
汎用の「あなたはセキュリティ専門家です」プロンプトではなく、
「このチャンクにはCRITICALが3件あります。各フローの悪用シナリオを記述してください」
のように、チャンク内容に基づいた具体的なタスクを自動生成する。
6000トークンを超えると冒頭に警告が入り、MAX_CHUNK_NODES を下げるよう促す。
自分のライブラリに当てたら、CRITICALが出た
「設計を変えた」という話だけでは説得力がない。
実際に動かした。対象は自分が開発中の NanaSQLite(自作SQLiteラッパー)だ。
結果:CRITICAL 1件、HIGH 2件、MEDIUM 2件。
自分の手レビューでは気づいていなかった問題が、複数含まれていた。
🔍 発見サマリーテーブル
| ID | 関数 | カテゴリ | 深刻度 | CWE | BLINDカテゴリ |
|---|---|---|---|---|---|
| F-001 | V2Engine.shutdown |
ロジックバグ・データ消失 | 🔴 CRITICAL | CWE-367 | B7 |
| F-002 | UniqueHook.before_write |
状態不整合(制約バイパス) | 🟠 HIGH | CWE-698 | B2 |
| F-003 | NanaSQLite.query_with_pagination |
SQLインジェクション | 🟠 HIGH | CWE-89 | - |
| F-004 | V2Engine.get_dlq |
情報漏洩 | 🟡 MEDIUM | CWE-200 | B6 |
| F-005 | ExpiringDict._evict |
スレッド競合・データ破損 | 🟡 MEDIUM | CWE-362 | B5 |
F-001 — V2Engine.shutdown 🔴 CRITICAL
何が起きるか
atexit 登録によるシャットダウン順序の競合。
_worker.shutdown() でスレッドプールが停止された後に _perform_flush() が呼ばれるケースがあり、
DLQへの再投入がシャットダウン中のキューに対して行われると、
認証ログや監査ログがメモリ上で消失する。
修正方向
def shutdown(self):
if getattr(self, '_is_shutting_down', False): return
self._is_shutting_down = True
try:
self._perform_flush() # ワーカーを止める前に必ずフラッシュ
self._worker.shutdown(wait=True)
finally:
atexit.unregister(self.shutdown)
AIが出した修正骨格には try/finally が抜けていた。
シャットダウン中に例外が出ると _is_shutting_down フラグが残り続けるため、自分で追記した。
AIの出力は、そのまま使えるとは限らない。
F-002 — UniqueHook.before_write 🟠 HIGH
何が起きるか
Hookチェーン実行の途中で別のHookが例外を投げると、
メモリ上の _value_to_key インデックスだけが更新された状態でDB書き込みが中断される。
DBの実態とインデックスが乖離し、正常なデータが「重複」として拒否されるDoS状態や、一意制約のバイパスが発生しうる。
修正方向
applied_hooks = []
try:
for hook in self._hooks:
hook.before_write(self, key, value)
applied_hooks.append(hook)
except Exception:
for hook in reversed(applied_hooks):
if hasattr(hook, 'rollback_write'):
hook.rollback_write(self, key, value)
raise
F-003 — NanaSQLite.query_with_pagination 🟠 HIGH
何が起きるか
order_by / group_by 句はSQLiteの仕様上プレースホルダ化できないため文字列として直接結合される。
_validate_expression をバイパスするペイロードが通ると任意のDB操作が実行可能になる。
修正方向
if order_by:
if not re.match(r'^[a-zA-Z0-9_., ]+(ASC|DESC)?$', order_by):
raise ValueError("Invalid order_by format")
AIが提案した正規表現 ^[a-zA-Z0-9_., ASC]+$ には DESC が含まれていなかった。
AIの出力をレビューして修正するこのプロセスこそが、「AIに全部任せない」 設計の本質だ。
🕳️ BLINDカテゴリ — AIがシグネチャだけから推論した潜在リスク
| カテゴリ | 内容 | 深刻度 |
|---|---|---|
| B1: V2Engine×キャッシュ競合 | staging と DB の間に「データが見えない瞬間」が存在。Read After Write の一貫性が破れる | 🟠 HIGH |
| B2: Hookチェーンの原子性 | F-002にて立証済み | 🟠 HIGH |
| B3: WeakrefのGCハザード | インスタンス破棄とバックグラウンドタスクの生存期間のズレ → Null参照例外 (DoS) | 🟡 MEDIUM |
| B4: lru_cache汚染 |
_sanitize_identifier のモジュールレベルキャッシュを不正キーで埋め尽くすリソース枯渇 |
🟡 MEDIUM |
| B5: Thread×asyncio混在 | F-005にて立証済み | 🟡 MEDIUM |
| B6: DLQ情報漏洩 | F-004にて立証済み | 🟡 MEDIUM |
| B7: atexit競合 | F-001にて立証済み | 🔴 CRITICAL |
📌 AIが「判断できない」と言った2箇所
AIが「ソースコードの確認が必要」と判断を保留した箇所が2件あった。
これは設計通りの動作だ。
静的解析で判断できない箇所を正直に教えてくれることで、人間がレビューすべき場所が絞り込まれる。
-
sql_utils.py—sanitize_sql_for_function_scan
独自サニタイズ処理のエッジケース(ネストされたコメント・不正エンコーディング)に対する
インジェクション耐性が、シグネチャからは判定不能。 -
core.py—NanaSQLite._deserialize
暗号化解除後にorjson.loadsを使用。暗号鍵が外部制御可能・または暗号化OFFの場合、
JSONパーサを通じたオブジェクトインジェクションやメモリ枯渇が起きうる。
まとめ:AIに全部任せない設計がいちばん強かった
今回の結果で、前の記事に自分なりの答えが出た。
- AIは5件のうち、修正骨格に不備があるものを2件含む出力をした(F-001のtry/finally、F-003のDESC欠落)
- それでも、人間が気づいていなかったCRITICAL・HIGH問題を正確に特定した
- 「判断できない」を明示したことで、追加確認が必要な箇所が自然に絞り込まれた
ASTで構造化する発想は正しかった。
でも「全部を一度に渡す」のは間違っていた。
AIは万能な解析器ではなく、候補を絞るための加速装置として使うべきだった。
人間の仕事は、AIが出した候補をレビューして、現実の修正に落とし込むことだ。
前の記事で「100%」と書いた。
実際に役立ったのは、100%の保証ではなく、見落としを減らす設計だった。
AIの仕事は「答えを出すこと」ではなく「人間がレビューすべき経路を絞ること」だった。