はじめに
自作アプリのFirestoreセキュリティルールを、手元のUbuntuノートPCで動くローカルLLM(qwen3.5:9b)に読ませました。返ってきた指摘が本当に穴なのかは、Firebaseエミュレータで一件ずつ試して確かめています。
先に結果を書きます。
- 実ルールに残っていた穴は3件で、見つけたのは自分で書いたプローブでした。管理者ロールだけが通れる経路の話です
- 実ルールに対するLLMの指摘は延べ273件で、穴だと裏付けられたものは0件でした
- 「58件中7件が裏付けられた」という数字もあります。ただしこれは、わざと穴を開けた改変ルールでの結果です。7件の中身を見直すと、改変した箇所に触れていた指摘は1件だけでした
LLMが使えないと言いたいわけではありません。9Bの量子化モデルに、思考オフでブロック単位に読ませたという、かなり狭い条件での記録です。
なお、以前書いたZennの記事にある「58件」などの数字は、この記事のために測り直し、Zenn側にも訂正を追記しています。
LLMの指摘をどう確かめるか
ルールを読ませると、IDOR、権限昇格、入力検証の不足と、それらしい指摘がすぐ返ってきます。読めばどれも一理ある。ただ、本当に穴かどうかはルールを動かすまで分かりませんし、人間が一件ずつ判定していたら先にこちらが疲れます。だから指摘のほうを動かして確かめることにしました。
検証用の環境は、2026年9月11日に最初のコミットをして、LLMの指摘を検証する経路を足したのが9月15日です。マシンはRyzen 5 4600H、RAM 30GiB、GTX 1650(VRAM 4GiB)のノートPCで、OSはUbuntu 24.04.5。LLMはOllama 0.32.0経由の qwen3.5:9b(9.7B、Q4_K_M)を、think:false、temperature:0、num_predict:1100、num_ctx 8192(入力が大きいときは16384)で回しています。エミュレータ側は firebase-tools 15.33.0、@firebase/rules-unit-testing 6.0.0、jest 30.5.2、Node v24.22.2です。
対象は自作アプリ(Kotlin Multiplatform、Android、SwiftUI、Firebase)のルール1プロジェクト分だけ。コレクション名とフィールド名は collectionA のような仮名にしてあります。
流れは5段階です。
- ルールをコレクションごとの
matchブロックに分けます - ブロックを1つずつLLMに渡し、
{line, severity, issue, quote}のJSON配列だけを返させます。迷ったら指摘に回すよう書いた、再現率寄りのプロンプトです -
quoteがブロックに本当にあるかを文字列で照合します。なければ UNGROUNDED_QUOTE - どのブロックを読ませたかでコレクションが決まります。操作(
listgetupdatecreatedelete)は指摘の文面のキーワードから推定して、該当するコレクション:操作のプローブを引きます - 全プローブをエミュレータで走らせて判定します
4がこの仕組みでいちばん雑な部分で、のちほど効いてきます。判定語はコード上の名前のままです。
| 判定 | 意味 |
|---|---|
| CONFIRMED | 対応づけたプローブ(期待値 DENIED)のどれかが ALLOWED になった |
| REFUTED | 対応づけたプローブがすべて DENIED だった(集計表では REJECTED) |
| NO_PROBE | その コレクション:操作 にプローブがない |
REFUTED は、指摘の中身が誤りだと証明されたという意味ではありません。この点は結果のところで具体例を出します。
プローブは全部手で書いた
プローブ(probes.cjs)にLLMは使っていません。最初は52個で、9月16日にファイルを切り出して103個まで増やし、いまは122個あります。内訳は期待値 ALLOWED の対照が38個、期待値 DENIED の不変条件が84個。操作別だと create 41、read 26、update 26、list 19、get 6、delete 4です。
対照を混ぜたのは、DENIED が並んだだけでは、ルールが正しいのか、エミュレータが何でも拒否しているのか区別できないからです。正当なアクターが同じ操作を通れると一緒に確かめておけば、後者は消えます。
// 不変条件: 参加者ではないユーザーは、スレッドのメッセージを list できない
P("P002", "collectionC", "list", "DENIED",
"Non-peserta list pesan thread yang bukan miliknya",
() => attempt(() => listQuery(asOther()).get())),
// 対照: 正当な参加者なら同じ操作が通る
P("P004", "collectionC", "list", "ALLOWED",
"KONTROL: peserta sah list pesan thread-nya",
() => attempt(() => listQuery(asDriver()).get()), true),
プローブの説明文とコメントはインドネシア語で書いています。attempt() は操作を実行して、例外が出なければ {allowed: true}、出れば {allowed: false} を返すだけの関数です。アクターは asAdmin asDriver asOther asExpat asAnon の5種類、初期データは withSecurityRulesDisabled で入れています。allow-list にないフィールドを書き込めるか(add-field)を試すプローブは17個で、15コレクションにまたがり、update 経路が6、create 経路が11です。
見つかった3件
9月17日の朝、08:11のコミットで、add-field を全コレクションに総当たりで試しました。そこで3件が通ってしまいました。見つけたのはLLMの指摘ではなく、この総当たりのプローブです。
修正前のルールで122個を走らせた結果は、119個が期待どおり、3個が逆。
| 対象 | 修正前(3b14626 の親) |
現在の main(ローカル参照 d12e57d) |
|---|---|---|
| ユーザープロフィール系コレクションへの update | ALLOWED | DENIED |
| 同コレクションへの create | ALLOWED | DENIED |
| お知らせ系コレクションへの update | ALLOWED | DENIED |
3件とも管理者でないと通れない経路で、ドライバーなど他のロールからは突けません。多層防御の穴で、深刻度は高くないと見ています。
修正は9月20日にブランチで書き、9月24日の13:12(+07:00)にマージしました。同時に足した13個のテストを修正前のルールに当てると、5個が落ちて8個が通ります。通る8個のうち7個は正当な経路が通り続けることの確認で、残り1個は元のルールでも守られていた拒否テストです。10月7日には、既存フィールドの変更検出を changedKeys() から affectedKeys() に直す追加修正(4つの update 経路)も入れました。
ルールの単体テスト(jest)は、修正前が16スイート278テスト、修正後が17スイート291テスト、ローカルの main が22スイート413テストで、どれも全通過です。main でプローブを回すと121/122になります。落ちる1個は、ユーザーが自分の通知トークンを更新できる経路を10月20日の変更で閉じたため、期待値が古くなったプローブでした。
実ルールにLLMをかけた結果
同じ実ルールをLLMに読ませた記録が20回分あります。延べ273件の指摘のうち、CONFIRMED は0件、REFUTED が215件、NO_PROBE が58件でした。
273は重複を除いた数ではありません。同じルールを何度も読ませているので、1回で返ってくる指摘は0件の回もあれば73件の回もあります。回を重ねるごとにプローブが7個から122個に増えたせいで、初期の回ほど NO_PROBE が多くなりました。ユニーク件数は数えていません。
外れ方の例を1つ。
LLMの指摘(要約、HIGH): 自分のUIDが参加者に載っているメッセージなら、所有者でなくても読めてしまうIDOR
参加者として載っている人が読めるのは設計どおりなので、この指摘は文面からしてかみ合っていません。対応づけたプローブ P002(参加者ではないユーザーの list)は DENIED、対照の P004(正当な参加者の list)は ALLOWED。ルールはちゃんと評価されていて、期待どおりに分かれています。
言えるのは、qwen3.5:9b を Q4_K_M、思考オフ、temperature 0 でブロック単位に読ませたとき、このルールからは裏付けのある指摘が出なかった、というところまでです。LLM一般の力は何も測っていません。
穴をわざと開けたルールでは
プローブが穴をちゃんと拾えるかも確かめたくて、改変ルールを12個作りました(9月17日の08:18)。1つのコレクションの add-field ガードだけを外したもので、差分は1行です。
- && helperHasOnly(request.resource.data.keys(), [ ...許可する9フィールド... ])
+ && true /* MUTAN: guard add-field dilucuti */
12個それぞれで、DENIED から ALLOWED に反転したプローブはちょうど1個。反転は改変したコレクションの中だけで、対照は毎回38/38でした。12/12検出です。この12は改変ルールの数で、対照テストの数ではありません。
そのあと、LLMに12個を1つずつ読ませました。08:20から08:46までの約26分で、各1回です。
指摘は合わせて58件で、3つのコレクションは0件。内訳は CONFIRMED 7、REFUTED 49、NO_PROBE 2です。この58件は、12個の改変ルールそれぞれに読ませた結果の合計で、実ルールの監査結果とは別物です。CONFIRMED の7件は5つの改変コレクションに散らばっていて、当日の検証メモには LLM-confirmed 5/12 とあります。
この読み方は甘すぎました。測り直して7件の中身を読むと、理由が3つ出てきます。
まずヒントです。改変ルールには MUTAN: guard add-field dilucuti というコメントを残していて、それがLLMに渡すブロックにそのまま入っていました。CONFIRMED のうち add-field を話題にしているのは F04 の1件だけで、引用元もこのコメントです。LLMはロジックから穴を見つけたのではなく、書いてあるヒントを読んでいます。
次に判定の雑さ。残り6件は、指摘の中身が別の話(フィールドの検証など)なのに、同じ コレクション:操作 の add-field プローブが改変で ALLOWED になったせいで CONFIRMED になっていました。当たってはいても、理由が合っていません。逆の例もあって、F42 は add-field ガードの欠落を文面ではっきり書いているのに、操作が read と推定されて REFUTED でした。キーワードがなければ read になる仕様で、58件のうち22件がこの既定値に落ち、22件とも REFUTED です。
add-field に触れた指摘を機械的に数えると8件、4コレクションにありました。キーワードでの判定なので、改変箇所と内容が合っているかまでは見ていません。8件のうち CONFIRMED は1件です。
この実験の穴
改変ルールにヒントのコメントが残っているので、LLMの検出力を測る実験としては成立していません。コメントを消してやり直す必要があります。対応づけの粗いキーのせいで、CONFIRMED にも REFUTED にも誤判定が混ざっているのも前述のとおり。LLMは1モデル1設定で、同じ設定で繰り返したときのぶれは測れていません。
プローブにも抜けがあります。値の型や長さの検証、クライアントが送るタイムスタンプ、collection group はプローブが0個。値の範囲や必須フィールドの検証は4個以上です。Storage ルールはこのプロジェクトでは使っていません。Cloud Functions はプローブの外で、別のjestで見ています。
3件の修正が本番のルールに載っているかは、書いている時点で確認できていません。言えるのは、リポジトリの main にあるルールまでです。参照している main もローカルの古い状態で、そのあとのルール変更は見ていません。
再現用のメモ
測定は2026年11月4日の夜(WIB)、エミュレータだけで行いました。本番のFirebaseには触れていません。
LAB_RULES_FILE=<rulesファイル> LLM_REVIEW_FILE=<空のJSON> \
firebase emulators:exec --only firestore --project <テスト用> \
--config <firebase.lab.json> "node adjudicate.cjs"
122プローブの実行は1回およそ11秒。入力のSHA-256は次のとおりです。
| 対象 | SHA-256 |
|---|---|
| 修正前のルール |
16faf3a4cdea8eba…(先頭16桁) |
| main のルール(ローカル参照) |
8324188a22f8339c…(先頭16桁) |
adjudicate.cjs |
755d1c66570484027880e0550e6bba400134105cc6309ae6c33a9fe33573f598 |
probes.cjs |
88be4880e260ea0c4ad42c8273c96829df642920e3fc27c53212414880beae1d |
リポジトリは非公開なので、同じ手順をそのまま再現することはできません。載せているのは抜粋とハッシュで、同じ入力を使ったかを照合する材料です。
おわりに
実ルールの穴を見つけたのは、手書きのプローブでした。LLMの指摘は、実ルールでは273件すべてに裏付けがなく、穴を開けたルールでも検出力を言えるデータになっていません。
次は、ヒントのコメントを消した改変ルールで、さらに操作の推定をやめて指摘ごとに手でプローブを対応づける形で、実験をやり直します。