ローカルAI Gatewayに監査ログを実装しました
前回の記事では、ローカルAI Gatewayに機密情報の検出と制御を実装しました。
https://zenn.dev/hisa_tech_2973/articles/a8a982bc044f5f
今回は、そのゲートウェイに
監査ログ
を追加実装した内容を紹介します。
なぜ監査ログが必要なのか
機密情報の検出と制御を実装しても、
インシデントに気づき、追跡し、対応するためには、
「何が起きたか」を後から確認できる状態にすること
が必要です。
例えば:
- どのリクエストがブロックされたか
- どの機密情報が検出されたか
- どのクライアントから送られたか
これらを確認できない状態では、インシデントが起きても対応できません。
問題は「後から確認できないこと」
前回実装したログは、標準出力(stdout)へ出力されます。
Dockerコンテナ環境では、標準出力のみに出力している場合、
コンテナを再起動するとログを追跡しにくくなることがあります。
また、ログはリクエストとレスポンスが別々のエントリとして出力されるため、
「このリクエストで何が検出されたか」を一目で把握しにくい
という問題があります。
実装したこと
各リクエストの情報を、1行1エントリのJSONファイルとして記録する監査ログを実装しました。
実装したのは次の3つです。
- 全リクエストの情報を構造化してファイルに書き出す
- 検出結果(findings)を同じエントリに含める
- リクエストごとにaction(blocked / forwarded)を記録する
アーキテクチャ
検出結果に関わらず、
すべてのリクエストを監査ログに記録します。
監査ログの内容
各エントリには次のフィールドが含まれます。
| フィールド | 内容 |
|---|---|
request_id |
リクエストの一意なID |
timestamp |
リクエスト受信時刻(ISO 8601) |
client_ip |
送信元IPアドレス |
provider |
転送先プロバイダ(例: openai) |
model |
使用モデル |
path |
リクエストのパス |
action |
blockedまたは forwarded |
forwarded |
実際に転送されたかどうか(bool) |
findings |
検出された機密情報の一覧 |
findingsは前回実装した検出器の出力をそのまま含めるため、
どのリクエストで何が検出されてブロックされたか
を1エントリで確認できます。
ログの出力例
通過したリクエスト(forwarded)
{
"request_id": "dd236fa325819cbf",
"timestamp": "2026-05-22T01:14:47.943348295Z",
"client_ip": "172.20.0.1",
"provider": "openai",
"model": "gpt-4o-mini",
"path": "/v1/gateway/chat",
"action": "forwarded",
"forwarded": true,
"findings": []
}
ブロックされたリクエスト(blocked)
{
"request_id": "53e754c87ccdb9c2",
"timestamp": "2026-05-22T01:16:21.407290077Z",
"client_ip": "172.20.0.1",
"provider": "openai",
"model": "gpt-4o-mini",
"path": "/v1/gateway/chat",
"action": "blocked",
"forwarded": false,
"findings": [
{
"type": "openai_api_key",
"confidence": "high",
"matched": "sk-a***",
"reason": ["regex_match"],
"action": "block"
}
]
}
findingsが空の場合は検出なし、要素がある場合は検出内容と対応するアクションが記録されます。
実装で意識したこと
スレッドセーフな書き込み
Goのnet/httpはリクエストを並行処理します。
同時に複数のリクエストが来た場合でも書き込みが壊れないよう、
mutexで排他制御しています。
type FileWriter struct {
mu sync.Mutex
file *os.File
}
func (w *FileWriter) Write(entry AuditLog) error {
line, err := json.Marshal(entry)
if err != nil {
return err
}
w.mu.Lock()
defer w.mu.Unlock()
_, err = w.file.Write(append(line, '\n'))
return err
}
ファイルのパーミッション
監査ログファイルは
0600(オーナーのみ読み書き可)で作成します。
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
ログファイル自体が情報漏洩の経路にならないための、最低限の措置です。
JSON Lines形式
1リクエスト1行のJSON Lines形式で出力します。
// 実際の実装ではエラーを適切に処理しています。 line, _ := json.Marshal(entry) w.file.Write(append(line, '\n'))
grepや
jqでそのまま検索・抽出できるため、
インシデント時の調査がしやすい形式を選びました。
例えば、ブロックされたリクエストだけを抽出する場合:
grep '"action":"blocked"' logs/audit.log | jq .
SSKの設計パターンとの対応
今回の実装は、SSKにおける次の設計パターンに対応しています。
検出と制御だけでなく、
「何が起きたかを後から確認できる状態にする」
ことが、インシデント対応の前提条件です。
まとめ
- 何が起きたかを後から追跡可能にすることが、インシデント対応の前提条件
- 監査ログとして、検出結果を含む構造化エントリをファイルに記録する
- action(blocked / forwarded)で結果を一目で確認できる
- スレッドセーフな書き込みとファイルパーミッションを意識した
- JSON Lines形式にすることで、インシデント時の調査をしやすくした