先日、Anthropic が大規模な不正アクセス試行(数千の不正アカウントで API の能力に到達しようとしたもの)を指摘した、というニュースを読みました。私の手は震えませんでしたが、心当たりが一つありました。自分の自動投稿パイプラインで使っている API キーは、平文のファイルに置かれ、定期実行のたびに読み込まれています。もしそのキーが一度どこかに転がり出たら、私が次にダッシュボードを開くまでの数時間、誰かが私の請求枠を好きなだけ使えてしまう、ということです。
問題は「漏らさないこと」だけではありません。漏れたあとに、どれだけ早く・どれだけ小さく被害を止められるか。個人開発で無人のパイプラインを回していると、人の目が常時あるわけではないので、ここの設計が請求額の桁を左右します。この記事は、漏洩を起きる前提として扱い、被害半径(blast radius)を縮めるための具体的な3層 — キーの分離・無停止ローテーション・使用量の番犬 — を、動くコードまで落として組み立てた記録です。
漏洩は「起きる前提」で被害半径を設計する
セキュリティの議論は「どうやって漏らさないか」に寄りがちですが、無人運用では「漏れたあとに何が起きるか」を先に決めておくほうが効きます。一枚のキーが組織全体の請求枠とすべてのワークスペースに通じているなら、漏洩=全損です。逆に、キーごとに権限と上限が絞られていれば、漏れても被害はそのキーの守備範囲に閉じます。
私が採った方針はシンプルな3層です。第1層で被害の面積をあらかじめ小さく区切り(スコープ分離)、第2層でいつでも素早く無効化できる状態を保ち(無停止ローテーション)、第3層で異常を人の目より先に捕まえます(使用量の番犬)。どれか一つではなく、重ねることに意味があります。スコープ分離は被害の上限を決め、ローテーションは被害の時間を縮め、番犬は気づくまでの遅延を縮めるからです。
ワークスペース単位でキーを分け、最小権限にする
最初にやるべきは、用途ごとにキーを割ることです。Anthropic のコンソールではワークスペースを作り、ワークスペース単位で API キーを発行できます。私はこれを「ジョブの種類 × 環境」で切りました。本番の記事生成、検証用のドラフト生成、手元の実験。同じキーを使い回さないだけで、一枚漏れたときに止めるべき範囲が明確になります。
割り当ては台帳にしておくと、いざというとき迷いません。どのキーがどのジョブに紐づき、どこに保管され、どの上限を持つか。下のような対応を、コードのコメントではなくドキュメントとして持っておきます。
ワークスペース 用途 月次上限の目安 漏洩時に止める対象
prod-publish 本番記事生成(定期実行) 厳しめに固定 該当ワークスペースのキーのみ失効
staging-draft 検証・プレビュー生成 本番の1/5程度 本番に影響なく単独失効
dev-sandbox 手元の実験・調査 最小 即失効しても運用は止まらない
ポイントは、ワークスペースに 支出上限(spend limit) を設定しておくことです。番犬が気づくより先に、組織のハードリミットが請求を頭打ちにしてくれます。番犬は「早く気づくための層」、上限は「最悪でもここで止まる層」。役割が違うので両方置きます。キーをファイルから完全に外す方向に進めるなら、キーレス運用への移行(ワークロードアイデンティティ連携) も検討に値します。本記事は「それでも平文キーを扱う現実」を前提に被害半径を縮める話です。
無停止でキーをローテーションする手順
スコープを分けても、定期的に・あるいは漏洩の疑いが出た瞬間に、キーを差し替えられなければ意味がありません。難しいのは、本番を止めずに差し替えることです。古いキーを消した瞬間に走っているジョブがあれば、それは 401 で落ちます。
私は「重なり合う2枚」で解きました。常にプライマリとセカンダリの2枚を有効にしておき、ローテーションは状態の遷移として扱います。新しいキーをセカンダリとして発行し、クライアントが優先的に新キーを掴むよう切り替え、十分な猶予のあと古いキーを失効させる。この猶予の間は両方が有効なので、走行中のジョブが落ちません。
# key_state.py — 重なり合う2枚でキーを無停止ローテーションする状態機械
# 状態ファイル(JSON)に「いまどちらを優先して使うか」を持たせる。
import json
import os
from pathlib import Path
STATE_PATH = Path(os.environ.get( "KEY_STATE_PATH" , "/secure/key_state.json" ))
def load_state () -> dict :
# 初回は primary を優先。secondary は空でもよい。
if not STATE_PATH .exists():
return { "active" : "primary" , "primary_env" : "ANTHROPIC_API_KEY_PRIMARY" ,
"secondary_env" : "ANTHROPIC_API_KEY_SECONDARY" }
return json.loads( STATE_PATH .read_text())
def active_api_key () -> str :
"""いま優先すべきキーを環境変数から解決して返す。
優先側が未設定なら、もう一方にフォールバックする(猶予期間中の保険)。"""
st = load_state()
primary = os.environ.get(st[ "primary_env" ], "" )
secondary = os.environ.get(st[ "secondary_env" ], "" )
prefer = primary if st[ "active" ] == "primary" else secondary
fallback = secondary if st[ "active" ] == "primary" else primary
key = prefer or fallback
if not key:
raise RuntimeError ( "有効なAPIキーが環境にありません(両方未設定)" )
return key
def promote_secondary () -> None :
"""新キーをセカンダリに入れた後に呼ぶ。優先を secondary 側へ倒す。
この時点では primary もまだ有効なので、走行中ジョブは落ちない。"""
st = load_state()
st[ "active" ] = "secondary" if st[ "active" ] == "primary" else "primary"
STATE_PATH .write_text(json.dumps(st, ensure_ascii = False , indent = 2 ))
print ( f "優先キーを { st[ 'active' ] } に切り替えました。旧キーは猶予後に失効してください。" )
if __name__ == "__main__" :
# 動作確認: 環境変数を仮に与えて、解決されるキーを確かめる
os.environ.setdefault( "ANTHROPIC_API_KEY_PRIMARY" , "sk-ant-PRIMARY-PLACEHOLDER" )
os.environ.setdefault( "ANTHROPIC_API_KEY_SECONDARY" , "sk-ant-SECONDARY-PLACEHOLDER" )
print ( "active:" , active_api_key()) # → primary 側のプレースホルダ
promote_secondary()
print ( "active:" , active_api_key()) # → secondary 側のプレースホルダ
運用の流れは、次の4手順に分けて回します。
新しいキーをコンソールで発行し、ANTHROPIC_API_KEY_SECONDARY に入れます。
promote_secondary() で優先を新キー側へ切り替えます。
30〜60分(自分の最長ジョブより長く)待ちます。この間は旧キーも有効です。
コンソールで旧キーを失効させ、ANTHROPIC_API_KEY_PRIMARY を新キーの値で上書きし、状態を primary に戻します。
漏洩が疑われる緊急時は、手順3の猶予を捨てて即失効し、走行中ジョブの 401 は再試行に任せます。なぜ状態をファイルに持たせるかというと、定期実行のプロセスは毎回新しく起動するからです。プロセス内のメモリでは「いまどちらか」を共有できません。
なお、キーを読むのは番犬や本番ジョブだけにし、サンドボックスで動く生成コードからは読めないようにしておくと、被害面はさらに小さくなります。Claude Code 側で同じ発想を入れるならサンドボックスから認証ファイルを読ませない設定 が対応します。
使用量の異常を見張る番犬を置く
スコープとローテーションは「被害を小さく・短く」する層でした。最後は「早く気づく」層です。漏れたキーの典型的な兆候は、いつもと違う時間帯・桁違いのトークン消費です。私の本番ジョブは1日およそ20回、各回の入出力トークンはだいたい決まった範囲に収まります。だからこそ、平常からの逸脱は検知しやすい。
固定しきい値(「1時間に100万トークンを超えたら警告」など)は、平常が変わると途端に使えなくなります。私は ローリングベースライン + 中央絶対偏差(MAD) にしました。直近の履歴から中央値とばらつきを取り、いまの値がそこから何「MAD」離れているか(ロバストz)で判定します。平均と標準偏差ではなく中央値と MAD を使うのは、過去のスパイク自体に基準を引っ張られない(汚染に強い)ためです。
# usage_watchdog.py — 使用量のスパイクをロバストに検知する番犬(標準ライブラリのみ)
from statistics import median
def robust_z_scores (series):
"""各点が中央値から何MAD離れているかを返す。
MAD=0(全点同値)の窒息を避けるため微小な下駄をはかせる。"""
if len (series) < 3 :
return [ 0.0 ] * len (series)
med = median(series)
abs_dev = [ abs (x - med) for x in series]
mad = median(abs_dev)
scale = 1.4826 * mad if mad > 0 else 1e-9 # 正規分布でσに一致させる係数
return [(x - med) / scale for x in series]
def detect_spikes (usage_by_bucket, z_threshold = 6.0 , min_tokens = 50_000 ):
"""usage_by_bucket: [(bucket_label, tokens), ...] 時系列順
返り値: 警告すべきバケットのリスト。
min_tokens 未満の小さな値は、比率が暴れても無視する(誤報抑制)。"""
series = [t for _, t in usage_by_bucket]
zs = robust_z_scores(series)
alerts = []
for (label, tokens), z in zip (usage_by_bucket, zs):
if z >= z_threshold and tokens >= min_tokens:
alerts.append({ "bucket" : label, "tokens" : tokens, "robust_z" : round (z, 1 )})
return alerts
if __name__ == "__main__" :
# 平常は8万トークン前後。最後の1時間だけ漏洩で跳ねた、という想定データ。
data = [
( "06-27T00" , 78_000 ), ( "06-27T01" , 81_000 ), ( "06-27T02" , 79_500 ),
( "06-27T03" , 77_000 ), ( "06-27T04" , 82_000 ), ( "06-27T05" , 80_000 ),
( "06-27T06" , 1_900_000 ), # ← 漏洩した第三者の利用を模した急騰
]
for a in detect_spikes(data):
print ( f "⚠️ スパイク検知: { a[ 'bucket' ] } tokens= { a[ 'tokens' ] :, } robust_z= { a[ 'robust_z' ] } " )
# 期待出力:
# ⚠️ スパイク検知: 06-27T06 tokens=1,900,000 robust_z=... (大きな正の値)
このスクリプトは外部依存なしでそのまま走ります。手元で実行すると、平常の6点が基準を作り、最後の急騰だけが大きなロバストzで弾かれます。この例では最後の値が平常のおよそ24倍に達しており、ロバストzは600超という極端な値になります。固定しきい値だと「平常が8万から12万に上がった」だけで誤報しますが、MAD ベースは平常の底上げに自動で追従します。実データに繋ぐ際は、usage_by_bucket を Admin の使用量・コストレポート(時間バケット粒度)から作ります。レポート API の使い方は使用量・コストAPIで消費を可視化する に通しているので、取得部分はそちらに寄せられます。
番犬を定期実行に組み込む
検知ロジックができたら、人の目より高い頻度で回します。私は本番ジョブとは別の軽いタスクとして、1時間ごとに使用量レポートを引いて detect_spikes に通し、警告が出たら通知する形にしました。通知先は何でもよいのですが、無人運用なので「自分が即座に気づける経路」(私はチャット通知)に出すのが肝心です。
# watchdog_run.py — 1時間ごとに回す番犬の本体(擬似化したレポート取得を実装に差し替える)
import json
import urllib.request
from usage_watchdog import detect_spikes
def fetch_usage_buckets (admin_key: str ):
"""Admin の使用量レポートを時間バケットで取得して
[(bucket_label, total_tokens), ...] に整形する。
※エンドポイントとレスポンス形は最新の公式ドキュメントで確認すること。"""
# 実装イメージ(要・公式仕様確認):
# req = urllib.request.Request(USAGE_REPORT_URL, headers={"x-api-key": admin_key})
# raw = json.loads(urllib.request.urlopen(req, timeout=30).read())
# return [(b["starting_at"], b["input_tokens"] + b["output_tokens"]) for b in raw["data"]]
raise NotImplementedError ( "レポート取得を実装に差し替える" )
def notify (message: str ):
"""無人運用では「自分が即座に気づける経路」に出すのが要。"""
print (message) # 実運用ではチャット通知などへ
def main ():
import os
admin_key = os.environ[ "ANTHROPIC_ADMIN_KEY" ] # 番犬専用の最小権限キー
buckets = fetch_usage_buckets(admin_key)
alerts = detect_spikes(buckets, z_threshold = 6.0 , min_tokens = 50_000 )
if alerts:
lines = [ f " { a[ 'bucket' ] } : { a[ 'tokens' ] :, } tokens (z= { a[ 'robust_z' ] } )" for a in alerts]
notify( "API使用量スパイク検知。漏洩疑いとしてキー失効を検討: \n " + " \n " .join(lines))
else :
print ( "使用量は平常範囲です。" )
if __name__ == "__main__" :
main()
しきい値の決め方は、過去2週間のバケットを robust_z_scores に通して、平常がどのくらいのzに収まるかを見てから決めます。私の場合、平常は概ね ±2 以内に入っていたので、z_threshold=6.0 は「明らかにおかしい」だけを拾う安全側の設定です。番犬の頻度と、定期実行どうしが上限を食い合わない配慮については共有スケジュールでのレート上限ヘッドルーム設計 の考え方がそのまま使えます。
つまずきやすい落とし穴
実際に組んでみて、いくつか踏みました。共有しておきます。
ローテーションの猶予をケチると、走行中の長いジョブが 401 で落ちます。私は最初、切替直後に旧キーを失効させてしまい、ちょうど走っていた生成が途中で死にました。本番運用でこの 401 を回避するには、猶予を「自分の最長ジョブ + バッファ」で取ることをお勧めします。ここを短くしてよいのは緊急失効のときだけです。
番犬のベースラインを、自分の正当なスパイクで汚染しないこと。月初にまとめて生成を流すような運用だと、その日のバケットが基準を引き上げます。MAD はそれでも頑健ですが、明らかに性質の違う「計画的な大量実行」は、別系列として扱うか番犬を一時的に黙らせるほうが誤報が減ります。
通知の出しすぎは、無視の習慣を生みます。私は最初しきい値を低くしすぎて、平常の揺らぎで何度も鳴り、結局通知を見なくなりました。番犬は「滅多に鳴らないが、鳴ったら本物」を目指して、z_threshold と min_tokens の二段で誤報を削ります。
最後に、いちばん地味で大事な点。キーをログに出さないことです。デバッグのつもりで print(active_api_key()) を残すと、ログ基盤に平文キーが流れ込みます。被害半径を縮める設計をしていても、観測系から漏れては元も子もありません。番犬のコードでも、キーそのものは決して出力に混ぜないようにしています。
まとめ
まず一つだけやるなら、いま一番重い本番ジョブのキーを専用ワークスペースに切り出し、そのワークスペースに支出上限を入れてください。スコープを分けて上限を置くだけで、漏洩時の最大損失額が一気に下がります。番犬とローテーションは、その土台の上に少しずつ重ねていけば十分です。
私自身、無人で API を叩き続ける運用は便利な反面、気づかないうちに被害が広がる怖さと隣り合わせだと感じています。漏らさない努力と同じくらい、漏れたあとを設計しておく — その視点が、一人で複数のパイプラインを回す上での安心につながっています。お読みいただきありがとうございました。