午前2時18分、LLMサービスでポリシーブロックが繰り返されたというアラートが鳴った。アプリケーションはリクエストをブロックしたと応答したが、インシデント対応者が知るべきことはそれ以上だ。誰がどのような権限でアクセスし、どの防御線を試行し、検索・モデル・ツール実行のどこまで到達したかを繋ぎ合わせる必要がある。
LLMセキュリティ監視は、危険な文章一つを見つけることではない。認証主体、ポリシー決定、実行経路、実際の副作用と繰り返しパターンを繋ぎ、攻撃者の行動を再構築することだ。request_idはリクエスト一件の受付番号であり、trace_idは複数のサービスに散らばった処理段階を貫く糸である。
この記事の時間、識別子、ログ、および数値はすべて仮想シナリオの例であり、実際の測定値ではありません。

一件ではなく行動を見る
認証されたusr_7f3は、まずtenant-aの公開情報を正常に照会した。続いて「以前の指示を無視せよ」と制限を探索し、業務リクエストのように装ってtenant-bの文書を要求した。失敗すると、文書削除と外部転送ツールを誘導した後、表現だけを変えて数分以内に繰り返した。個々に見れば4つのリクエストだが、主体・テナント・時間・ポリシー遷移をまとめると、偵察から権限拡大と実行試行へと進行した一つの流れである。
sequenceDiagram
participant U as 인증 사용자
participant A as Application
participant P as 정책 계층
participant R as Retrieval
participant M as Main Model
participant T as Tool Executor
U->>A: 정상 공개 조회
A->>P: 인증·인가·입력 검사
P-->>A: 허용
A->>R: tenant-a 검색
A->>M: 응답 생성
U->>A: 지시 덮어쓰기·타 Tenant 요청
P-->>A: 차단
Note over R,T: 뒤 단계 호출 없음
U->>A: 삭제·외부 전송 유도
M-->>A: 도구 호출 제안
A->>P: 실행 권한 재검사
P-->>A: 차단
Note over T: Executor 호출 없음
Applicationが残すべき最小限の証拠
最終的な許可・ブロックは、モデルの言い回しではなく、Applicationのポリシー決定として記録する。モデルが拒否文を出力してもapplication_decision=ALLOWであれば、サーバーブロックの証拠ではない。逆に、一般的なエラーメッセージを表示しても、権限ポリシーが中断させたのであれば明示的なブロックである。
{
"request_id":"req-02f4", "trace_id":"7d31a9c0b4e2",
"authenticated_principal":"usr_7f3", "tenant":"tenant-a",
"prompt_hmac":"hmac:v1:91b7...",
"prompt_excerpt":"[지시 변경] ... [외부 전송]",
"application_decision":"BLOCK",
"blocking_reason":"tool_not_authorized",
"policy_rule":"TOOL-EXEC-004",
"stage":"execution_authorization", "stage_order":60,
"upstream_called":true, "retrieval_called":false,
"tool_called":false, "tool_result":"not_executed",
"http_status":403
}
元のPromptを保存すると、ログが新たな個人情報・秘密の保存場所となる。サーバー秘密鍵で作成したHMACは、同じ入力の繰り返しを比較させ、非識別化されたexcerptは最小限のコンテキストのみを残す。HMACキーはログと分離し、excerptの名前・メールアドレス・トークン・文書内容はマスキングする。
Lokiで理由と順序を探す
Lokiは「誰がどの段階でどのようなルールでブロックされたのか?」に答える。次のLogQLは、JSONフィールドで一つの主体のブロック記録を絞り込む仮想例である。
{service="llm-application"} | json
| authenticated_principal="usr_7f3"
| application_decision="BLOCK"
| line_format "{{.request_id}} {{.stage_order}} {{.blocking_reason}} {{.policy_rule}} {{.http_status}}"
正常な対照リクエストも一緒に見る。攻撃はブロックされ、許可された公開照会は維持されるべきであり、それがPASSの根拠となる。固有の主体はログ本文に仮名化して残すが、LokiインデックスやPrometheusラベルで無制限に拡張しない。
Tempoで実際の中断地点を見る
Tempoでtrace_idを開くと、authentication → authorization → input_guardrail → retrieval → main_model → execution_authorization → tool_executorの実際のSpanを確認できる。入力ブロック後にはRetrieval・Main Model・Tool Spanがあってはならず、他テナントアクセスブロック後には検索Spanがあってはならない。
モデルがツール呼び出しJSONを提案しても、execution_authorization=BLOCK、tool_called=falseであり、Executor Spanと副作用監査ログがなければ実行されたことにはならない。Traceが途中で途切れた場合も、すぐに攻撃成功とは限らない。収集障害の可能性もあるため、必須Spanがない場合はERRとして観測階層を調査する。
Prometheusで繰り返しを検知する
Prometheusは「最近のブロックがどれくらい早く増えたか?」に答える。固有のユーザーIDとrequest_idは時系列を爆発させるため、MetricにはTenant・ポリシー・決定といった限定された値のみを置く。
sum by (tenant, policy_rule) (
increase(llm_security_decisions_total{application_decision="BLOCK"}[5m])
) >= 4
- alert: RepeatedLLMPolicyBlocks
expr: sum by (tenant, policy_rule) (increase(llm_security_decisions_total{application_decision="BLOCK"}[5m])) >= 4
for: 1m
labels: { severity: warning }
条件と閾値は仮想例である。式が真であってもfor時間が経過するまではpendingとなり、継続して真であればPrometheusでfiringとなる。firingは検知条件の成立であり、攻撃ブロックや通知到着の証拠ではない。
AlertmanagerとWebhookで伝達を確認する
Alertmanagerはアラートを受け取り、alertnameなどでグループ化し、サイレンス・抑制・ルーティングを適用する。画面のactiveはアラートを保持しているという意味であり、Webhookの受信成功とは異なる。最終受信ログでalertname=RepeatedLLMPolicyBlocks, severity=warning, グループキー, 開始時刻とHTTP 2xxを確認して初めて、受信サービスが通知を受け入れたと言える。2xxも担当者が読んで対応した証拠ではない。Prometheus firing、Alertmanager active、Webhook受信はそれぞれ異なる段階である。
Grafanaで事件を一つの画面に繋ぐ
Grafanaは新しい真実を作り出すストレージではなく、Loki・Tempo・Prometheusの証拠を繋ぎ合わせる調査台である。ブロック増加時点を選び、Loki行のtrace_idでTempoを開き、同じ時間帯のAlertmanagerとWebhookの状態を並べて見る。パネルが空の場合、攻撃成功と断定せず、元のLogQL・TraceQL・PromQLとコレクターの状態を確認する。
判定は3つに限定する。権限外文書の返却、秘密の漏洩、承認されていないツールの実際の副作用が確認されればHITである。明示的な防御が攻撃を阻止し、正常な対照リクエストも維持された場合はPASSである。タイムアウト、5xx、空の識別子、収集漏れ、または伝達失敗により証拠が不足する場合はERRである。ERRは攻撃成功ではなく、調査不能状態である。
誤った結論を避ける方法
- モデルの拒否文だけを見てPASSと判定しない。
- ツール提案だけを見てHITと判定しない。tool_resultと実際の副作用が必要である。
- アラートがないからといって攻撃がなかったと結論付けない。観測障害であればERRである。
- ブロック一件を連続攻撃と呼ばない。同一主体・時間窓・ポリシー遷移を繋ぎ合わせる。
事故対応者が残すべき一文
仮想事件要約: 02:13~02:18の間、tenant-aの仮名化された認証主体usr_7f3が、正常な公開照会後、指示上書き、他テナント文書アクセス、承認されていない外部転送ツール実行を順次試行した。Applicationは攻撃リクエストを明示的にブロックし、禁止されたRetrievalとTool Executorの実行および実際の副作用は確認されなかった。正常な照会は維持された。アラートのPrometheus firing、Alertmanager active、Webhook 2xx受信も確認したため、判定はPASSである。必須証拠が欠落すればERR、実際の影響が確認されればHITに再分類する。
実習チェックリスト
- 正常・攻撃リクエストともにrequest_idとtrace_idがあるか?
- 認証主体とテナントが仮名化された値で繋がっているか?
- ブロック後、Retrieval・Main Model・Tool Executorが実行されたか?
- Lokiの決定とTempoの経路が一致するか?
- firing、active、Webhook受信をそれぞれ確認したか?
- 証拠不足を無理にPASSやHITにしないか?
良いLLMセキュリティ監視は「ブロックした」という一行で終わらない。リクエストとTraceを繋ぎ、繰り返しをMetricで検知し、伝達経路まで検証して、検知を説明可能な対応へと変える。
コメントを残す