0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kong AI Gateway】Betterleaks を Go で API サーバー化してクレデンシャル検出のガードレールを作る

0
Posted at

はじめに

Kong AI Gateway で、LLM に送るプロンプトから API キーやトークンを検出し、これらを含むリクエストを遮断するガードレールを作成します。

今回はクレデンシャルを検出するために、Betterleaks を利用した API サーバー betterleaks-server を Go で作成し、AI Custom Guardrail から呼び出します。
あわせて、Betterleaks を直接組み込んだ Go プラグインで同じ検査を行う方法も紹介します。
作成したサーバーの実装や API の詳細は、以下のリポジトリで公開しています。

Betterleaks とは

Betterleaks は設定されたルールに基づいてクレデンシャルを検出するツールです。
今回作成した betterleaks-server は、Betterleaks を Go パッケージとして組み込み、テキストの検査とマスキングを行う API サーバーです。

検証環境

  • WSL(Ubuntu 26.04.1 LTS)
  • Go 1.26.8
  • Betterleaks v1.9.0
  • Kong Go PDK v0.11.2
  • Kong Gateway Enterprise 3.14.0.16
  • Ollama

プラグイン開発キット(PDK: Plugin Development Kit)は、Kong のリクエストやレスポンスを Go から操作するために使用します。
今回はサーバー単体の動作に加え、Ollama を接続先として AI Custom Guardrail と Go プラグインの両方式を Kong 経由で検証しています。

AI Custom Guardrail は Kong Gateway 3.14 以降に対応し、AI ライセンスが必要です。
また、AI Proxy または AI Proxy Advanced の設定が前提になります。

今回の構成

今回の構成は以下のとおりです。

検査対象のテキストは betterleaks-server にも送信されます。

Betterleaks API サーバー

サーバーの起動

git clone https://github.com/Kai17-a/betterleaks-server.git
cd betterleaks-server
go run . -addr 127.0.0.1:18080

検査のリクエストとレスポンス

POST /v1/scan に、検査対象の文字列を content に入れた JSON を送信します。

以下のトークンはサーバーのテストコードでも使っている検証用のダミー値です。
実在のクレデンシャルは使用していません。

curl -sS http://127.0.0.1:18080/v1/scan \
  -H 'Content-Type: application/json' \
  -d '{
    "content": "検証専用のダミー値 ghp_x9Kd2LmQ7vRt4Yp8Zs1Wn6Bc3Hf5Jg0Aa2Ee です。",
    "redact": true
  }' | jq '{detected, redacted_content, count, rules: [.findings[].rule_id]}'

実行結果(検出結果から必要なフィールドを抜粋)

{
  "detected": true,
  "redacted_content": "検証専用のダミー値 [REDACTED:github-pat] です。",
  "count": 1,
  "rules": ["github-pat"]
}

detected は検出が 1 件以上あれば true になります。
ただし、detected: false は本文にクレデンシャルがないことの保証にはなりません。
redacted_content には検出箇所を [REDACTED:<ルールID>] に置き換えた本文が入ります。

Kong AI Gateway からの呼び出し

AI Custom Guardrail の設定

以下は AI Proxy を設定済みの chat ルートにプラグインを追加する設定例です。
request.url は、前節で起動した betterleaks-server を指しています。

kong.yaml
_format_version: "3.0"
plugins:
  - name: ai-custom-guardrail
    route: chat
    config:
      guarding_mode: INPUT
      text_source: concatenate_all_content
      allow_masking: false
      stop_on_error: true
      timeout: 35000
      request:
        url: http://127.0.0.1:18080/v1/scan
        headers:
          Content-Type: application/json
        body:
          content: "$(content)"
          redact: "true"
      response:
        block: "$(resp.detected)"
        block_message: "credentials detected"

request.body は値を文字列または Lua 式として設定するマップです。
文字列の値は JSON としてデコードされるため、redact: "true" はサーバーへ送る本文では真偽値になります。
content は Kong が抽出した検査対象テキストです。
text_source: concatenate_all_content で全メッセージのコンテンツを結合して検査対象にします。

遮断の判定には response.block: "$(resp.detected)" を使います。
detected は文字列ではなく真偽値なので、そのまま遮断フラグに対応づけます。
stop_on_error: true は検査でエラーが起きたときに処理を停止する設定です。
timeout はミリ秒単位で、35000 は 35 秒です。
lua-resty-http の set_timeout() に渡され、接続・送信・読み取りのソケット操作に適用されます。
リクエスト全体の所要時間を制限する設定ではありません。

起動中の Kong のスキーマでは、接続タイムアウトと説明されています。

curl -s http://localhost:8001/schemas/plugins/ai-custom-guardrail | jq '.fields[] | select(.config) | .config.fields[] | select(.timeout)'

実行結果

{
  "timeout": {
    "type": "number",
    "default": 10000,
    "description": "Connection timeout with the guardrail service",
    "required": true
  }
}

AI Proxy を設定済みの kong.yaml に上記の設定を追加し、Kong に反映します。

deck gateway sync --kong-addr http://localhost:8001 kong.yaml

Kong 経由の検証リクエスト

betterleaks-server を起動した状態で、/chat に対応する AI Proxy のルートへ、同じダミー値を含むリクエストを送ります。
モデルは AI Proxy 側に設定しています。

curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{
    "stream": false,
    "messages": [
      {
        "role": "user",
        "content": "検証専用のダミー値 ghp_x9Kd2LmQ7vRt4Yp8Zs1Wn6Bc3Hf5Jg0Aa2Ee です。"
      }
    ]
  }'

実行結果(抜粋)

HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
X-Kong-Response-Latency: 4

{"error":{"message":"credentials detected"}}

続いて、通常文を送ります。

curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"stream":false,"messages":[{"role":"user","content":"こんにちは。短く挨拶してください。"}]}'

実行結果(抜粋)

HTTP/1.1 200 OK
Content-Type: application/json
X-Kong-LLM-Model: ollama/gemma4:31b-cloud
X-Kong-Upstream-Latency: 399
X-Kong-Proxy-Latency: 6

{"object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"こんにちは!よろしくお願いします。","role":"assistant"}}],"model":"gemma4:31b","id":"chatcmpl-2c8e8f1f47c21c7c71a4cc571ff98805","usage":{"completion_tokens":5,"total_tokens":25,"prompt_tokens":20},"created":1793413115}

通常文では 200 とモデルの回答が返り、X-Kong-Upstream-Latency が記録されました。
ダミー値を含むリクエストは 400 で遮断され、このヘッダーがありません。
このヘッダーの比較から、今回の遮断リクエストは LLM に転送されていないと判断できます。

betterleaks-server 停止時の動作

betterleaks-server を停止した状態で、同じルートにダミー値と通常文を送ります。

curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"stream":false,"messages":[{"role":"user","content":"検証専用のダミー値 ghp_x9Kd2LmQ7vRt4Yp8Zs1Wn6Bc3Hf5Jg0Aa2Ee です。"}]}'
curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"stream":false,"messages":[{"role":"user","content":"こんにちは。短く挨拶してください。"}]}'

実行結果(抜粋)

HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
X-Kong-Response-Latency: 1

{"error":{"message":"failed to sanitize request: failed to invoke custom guardrail service: request failed: connection refused"}}

HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
X-Kong-Response-Latency: 2

{"error":{"message":"failed to sanitize request: failed to invoke custom guardrail service: request failed: connection refused"}}

stop_on_error: true の構成では、どちらも接続拒否による 500 で停止します。

Go PDK での Kong プラグイン作成

外部プラグインの構成

Betterleaks は Go パッケージなので、外部 API サーバーを介さず、Go の Kong プラグインに組み込む方法もあります。
Go プラグインは Kong 本体とは別プロセスで起動し、Unix ドメインソケットを通じて Kong と通信します。
既存のカスタムプラグインの起動処理の記事と同様に、実行ファイル、起動コマンド、メタ情報の取得コマンドを設定する構成です。

クレデンシャル検出の実装例

以下は、messages[].content が空でない文字列のチャットリクエストを対象にした実装例です。
画像を含む配列形式の content は受け付けず、ツール呼び出しの引数など、それ以外のフィールドは検査しません。
本文は 16 KiB までとし、検出処理は同時に 1 件ずつ実行します。
API サーバーが行う、本文を一部重複させた範囲に分けて検査する処理やマスキング処理は、この例には組み込んでいません。

main.go
package main

import (
	"context"
	"encoding/json"
	"log"
	"strings"
	"sync"
	"time"

	"github.com/Kong/go-pdk"
	"github.com/Kong/go-pdk/server"
	"github.com/betterleaks/betterleaks/config"
	"github.com/betterleaks/betterleaks/detect"
	"github.com/betterleaks/betterleaks/sources"
)

type Config struct{}

var detector *detect.Detector
var scanMu sync.Mutex

func New() interface{} { return &Config{} }

func reject(kong *pdk.PDK, status int, message string) {
	body, _ := json.Marshal(map[string]string{"message": message})
	kong.Response.Exit(status, body, map[string][]string{
		"Content-Type": {"application/json"},
	})
}

func (conf *Config) Access(kong *pdk.PDK) {
	raw, err := kong.Request.GetRawBody()
	if err != nil {
		reject(kong, 400, "cannot read request body")
		return
	}
	if len(raw) > 16*1024 {
		reject(kong, 413, "request body too large")
		return
	}
	var req struct {
		Messages []struct {
			Content string `json:"content"`
		} `json:"messages"`
	}
	if json.Unmarshal(raw, &req) != nil || len(req.Messages) == 0 {
		reject(kong, 400, "text chat messages are required")
		return
	}
	texts := make([]string, 0, len(req.Messages))
	for _, message := range req.Messages {
		if message.Content == "" {
			reject(kong, 400, "non-empty text content is required")
			return
		}
		texts = append(texts, message.Content)
	}
	// 検出処理は同時に 1 件ずつ実行し、2 秒で打ち切る
	scanMu.Lock()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	// 全メッセージを改行で結合した 1 つのテキストとして検出する
	findings := detector.DetectContext(ctx, sources.Fragment{
		Raw: strings.Join(texts, "\n"),
	})
	// 打ち切られた場合の結果は途中までなので、検出なしとは扱わない
	scanErr := ctx.Err()
	cancel()
	scanMu.Unlock()
	if scanErr != nil {
		reject(kong, 503, "credential scan failed")
		return
	}
	// 1 件でも検出したら上流へ送らずに遮断する
	if len(findings) > 0 {
		reject(kong, 403, "credentials detected")
		return
	}
}

func main() {
	// Betterleaks に同梱されたデフォルトの検出ルールを読み込む
	cfg, err := config.Default()
	if err != nil {
		log.Fatal("cannot load detection rules")
	}
	// ValidationOptions を空にし、検出した値を外部サービスに問い合わせて確認する検証を無効にする
	detector = detect.NewDetectorContext(context.Background(), cfg, detect.ValidationOptions{})
	// 本文に gitleaks:allow を書いて検出を回避できないようにする
	detector.IgnoreGitleaksAllow = true
	// Base64 などでエンコードされた値は、最大 5 回まで繰り返しデコードして検出する
	detector.MaxDecodeDepth = 5
	if err := server.StartServer(New, "0.1.0", 1000); err != nil {
		log.Fatal("plugin server failed")
	}
}

Access() はリクエストを上流へ送信する前に実行する処理です。
クレデンシャルを検出したときには 403、検査の制限時間を超えたときには 503 を返すコードにしています。
タイムアウトによる途中結果を「検出なし」と扱わないよう、検出後に ctx.Err() も確認します。

Priority に相当する値は 1000 にしています。
Kong は優先度の高いプラグインから実行するため、優先度 770 の AI Proxy より先に検査する指定です。

DetectContext() は Betterleaks v1.9.0 では非推奨です。
代わりに案内されている Run() は検出器の状態をロックせずに更新し、並行するリクエストで共有できないため、betterleaks-server でも DetectContext() を使っています。

ビルドとメタ情報の確認

空の作業ディレクトリに main.go を保存し、以下を実行します。

go mod init example.com/betterleaks-guard
go get github.com/Kong/go-pdk@v0.11.2
go get github.com/betterleaks/betterleaks@v1.9.0
go mod tidy
go build -o betterleaks-guard .
go vet ./...
./betterleaks-guard -dump

-dump は、Kong が読み込むプラグイン名や実装フェーズを出力するオプションです。

実行結果(抜粋)

{
  "Name": "betterleaks-guard",
  "Phases": ["access"],
  "Version": "0.1.0",
  "Priority": 1000
}

Kong への読み込み設定

ビルドした実行ファイルを Kong の /usr/local/bin/betterleaks-guard に配置し、以下の内容で設定します。

kong.conf
plugins = bundled,betterleaks-guard
pluginserver_names = betterleaks-guard
pluginserver_betterleaks_guard_socket = /usr/local/kong/betterleaks-guard.socket
pluginserver_betterleaks_guard_start_cmd = /usr/local/bin/betterleaks-guard
pluginserver_betterleaks_guard_query_cmd = /usr/local/bin/betterleaks-guard -dump

AI Proxy を設定した chat ルートでプラグインを有効にします。
この例は API サーバー方式とは別構成として使用します。

kong.yaml
_format_version: "3.0"
plugins:
  - name: betterleaks-guard
    route: chat
    config: {}

設定を Kong に反映します。

deck gateway sync --kong-addr http://localhost:8001 kong.yaml

以下のコマンドで、プラグインが有効になっているかを確認できます。

curl -s http://localhost:8001/plugins/enabled | jq '{"betterleaks-guard": (.enabled_plugins | index("betterleaks-guard") != null)}'

実行結果

{
  "betterleaks-guard": true
}

Go プラグインの動作確認

ダミー値を含むリクエストを送ります。

curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"stream":false,"messages":[{"role":"user","content":"検証専用のダミー値 ghp_x9Kd2LmQ7vRt4Yp8Zs1Wn6Bc3Hf5Jg0Aa2Ee です。"}]}'

実行結果(抜粋)

HTTP/1.1 403 Forbidden
Content-Type: application/json
X-Kong-Response-Latency: 2

{"message":"credentials detected"}

続いて、通常文を送ります。

curl -i http://localhost:8000/chat \
  -H 'Content-Type: application/json' \
  -d '{"stream":false,"messages":[{"role":"user","content":"こんにちは。短く挨拶してください。"}]}'

実行結果(抜粋)

HTTP/1.1 200 OK
Content-Type: application/json
X-Kong-LLM-Model: ollama/gemma4:31b-cloud
X-Kong-Upstream-Latency: 442
X-Kong-Proxy-Latency: 4

{"object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"こんにちは!よろしくお願いします。","role":"assistant"}}],"model":"gemma4:31b","id":"chatcmpl-a711b902c1a6a21652745d62b352971e","usage":{"completion_tokens":5,"total_tokens":25,"prompt_tokens":20},"created":1793413140}

通常文では 200 とモデルの回答が返り、X-Kong-Upstream-Latency が記録されました。
ダミー値を含むリクエストは 403 で遮断され、このヘッダーがありません。
このヘッダーの比較から、Go プラグインによる検査で LLM への転送前に遮断できたと判断できます。

GitHub 以外のクレデンシャルの検出

GitHub のトークン以外の形式でも、ランダムに生成したダミー値を /chat に送り、AI Custom Guardrail と Go プラグインのそれぞれで遮断されるかを確認しました。
1 行を 1 リクエストとして送り、HTTP ステータスコードを表示します。

while IFS= read -r line; do
  code=$(jq -n --arg c "$line" '{stream: false, messages: [{role: "user", content: $c}]}' |
    curl -s -o /dev/null -w '%{http_code}' http://localhost:8000/chat \
      -H 'Content-Type: application/json' -d @-)
  echo "${line%%: *}: $code"
done <<'EOF'
AWS: aws_access_key_id = AKIAIXP7YFZUCAEOPVAF aws_secret_access_key = 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
Slack: xoxb-360545212038-7914569241777-Qhlw4auzXeLbH4rPvIB8Qh2j
OpenAI: sk-proj-NNh3872xsMRUeemRRpEeT3BlbkFJU4lxAzYLlBUswiwxFrlj
Anthropic: sk-ant-api03-YJ61uTaTfH1HP0UBFVHMKDeig5MQdrsPg8yKUU0n7TVglNAimYZQCKe9zbDKB5muuQwLfg84cAcyQ1JJXVAnCZ07LXAt0AA
Stripe: sk_live_Q9cTx4UD6LEIa4a8t7zBNW0w
Google Cloud: AIzaMedWhewq3TC0ge1BAuN8joSq2JVeBoja3C9
GitLab: glpat-S1HXxWIY4LvKldfhutC2
JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkdW1teSJ9.3TEz4Qe3x0gSMv5ZRo9TQl048LYC1FkDExTslBfMF74
password: password = "8dPasnbZADes48Ke"
文中のパスワード: DB のパスワードは zIeUHLeR8XB6 です。
EOF

AI Custom Guardrail を有効にした場合の実行結果

AWS: 400
Slack: 400
OpenAI: 400
Anthropic: 400
Stripe: 400
Google Cloud: 400
GitLab: 400
JWT: 400
password: 400
文中のパスワード: 200

Go プラグインを有効にした場合の実行結果

AWS: 403
Slack: 403
OpenAI: 403
Anthropic: 403
Stripe: 403
Google Cloud: 403
GitLab: 403
JWT: 403
password: 403
文中のパスワード: 200

どちらの方式でも、文中に書いたパスワードは検出されず、LLM に転送されました。

まとめ

  • Betterleaks を API サーバー化すると、AI Custom Guardrail から呼び出し、detected の値でクレデンシャルを含むリクエストを遮断できます。
  • Betterleaks を Go PDK のプラグインに組み込めば、API サーバーを介さずに Kong の中で同じ検査ができます。
  • 検証では、両方式ともダミー値を含むリクエストを LLM に転送する前に遮断し、通常文は通過することを確認しました。

ただし、Betterleaks はルールに一致する形式のクレデンシャルを検出する仕組みのため、すべてのクレデンシャルを検出できるわけではありません。
形式に特徴のないパスワードや社内独自の形式のクレデンシャルは検出できないことがあり、detected: false もクレデンシャルがないことの保証にはなりません。
それでも、API キーやトークンをプロンプトに貼り付けたまま LLM に送ってしまうミスは、利用者ごとの対策に頼らず Kong で止められます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?