シークレット — 隠すより破棄が速いほうがよい
一言でいうと
シークレット管理の成熟度は、「どれだけうまく隠したか」ではなく、「漏えいしたと仮定したとき、取り消して交換するのに 何分かかるか」で測られます。この観点1つが、優先順位のすべてを変えます。
なぜ必要なのか
標準的なアドバイスはこうです。「コードにハードコードせず環境変数で渡し、.envは.gitignoreに入れてください。」
このアドバイスが防げる脅威は、たった1つです。ソースリポジトリに平文で入ってしまうことです。
実際の漏えい経路はもっとたくさんあります。プロセスのメモリ、クラッシュレポート、CIのログ、イメージのレイヤー、 APMのダッシュボード、誤って公開されたデバッグ用エンドポイント。環境変数に移すだけでは、これらのどれも防げません。
どう動くのか
環境変数は保管場所ではなく受け渡しの方式である
.envファイルを読み込んだあとに削除しても意味がありません。値はすでにカーネルがプロセスごとに保持しており、
Linuxではそれをファイルのように読めます。
tr '\0' '\n' < /proc/2841/environ
同じUIDかrootなら、そのまま読めます。コンテナの中でも変わりません。
同じPodのサイドカー、ノードにアクセスできる人、kubectl debug --target=appで接続したデバッグコンテナが
すべて同じものを見ます。
環境変数が漏れるほかの経路も、整理しておくとよいでしょう。
- 例外ハンドラーが
process.envを丸ごとログにダンプする: 1行ですべてのシークレットがログのインデックスに 永久に保存されます。ログ収集システムは、たいていアプリケーションよりアクセス権限が広いのです。 - 子プロセスへの継承: アプリが画像変換ツールやバックアップスクリプトを実行したとき、そのツールが自分のログに 環境全体をダンプした瞬間に流出します。
- デバッグモード: 例外ページが設定値を描画し、一部のデバッガーは対話型コンソールまで開きます。 ステージングで有効にしたままインターネットに公開された事例が繰り返し出てきます。
- CIのマスキングの回避: マスキングが隠すのは完全に一致する文字列だけです。
echo "$API_TOKEN" | base64とすれば、そのままログに残ります。
KubernetesのSecretの実際の置き場所
dataはbase64エンコードです。キーがなく、コマンド1つで元に戻るので、暗号化ではありません。- デフォルト設定では、etcdに事実上平文で保存されます。
etcdctl get /registry/secrets/... | hexdump -Cで確認できます。 - したがって、etcdのバックアップファイル・スナップショット・ディスクイメージを手に入れた人は、すべてのSecretを手に入れます。
- そしてRBAC。あるネームスペースに
get secretsがあれば、そのネームスペースのすべてのSecretを読めます。 「開発者に利便のために与えた編集権限が、実質的に本番の認証情報の閲覧権限になっていることが非常によくあります。」
保存方式を選ぶ3つの基準
「暗号化されるか」は、よい基準ではありません。代わりに、次の3つを問いましょう。
- 露出する経路がいくつあるか
- 取り消しと交換に何分かかるか
- 誰がいつ読んだかがわかるか
この基準で見ると、保存方式の順序は「暗号化の強さ」の順ではなく、「事故のときの対応時間が短くなる」順 に並びます。
最善は、静的なキーを作らないこと
- ワークロードアイデンティティフェデレーション: クラスターが発行した短命のトークンをクラウドIAMに信頼させて、
保存すべき静的キーをそもそもなくします。ただし、信頼ポリシーの条件句が緩いと危険です。
repo:acme/*のようなワイルドカードは、組織のどのリポジトリ、どのブランチ、どのPRからでもそのロールを 奪取できるようにしてしまい、フォークから上がってきたPRのワークフローが本番デプロイのロールを得る経路ができます。 - 動的な認証情報: リクエストのたびに、寿命の短いアカウントを発行してもらいます。 「静的なキーを完璧に隠そうとするより、寿命を1時間に縮めるほうが、ほとんどの場合より効果的です。」
- projected ServiceAccountトークン: audienceと有効期限を指定して、Podに注入します。
ローテーションは運用手順ではなく、設計上の属性である
最も重要な文です。コードがキーを1つしか想定していなければ、どれだけ優れたシークレットマネージャーを使っても、実際の交換は 起きません。
Webhookの検証がSECRET1つしか見ないなら、キーを変えた瞬間に、古いキーで署名されたリクエストがすべて拒否されます。
送信側と受信側を原子的に同時に変える方法がないため、事実上交換が不可能になり、
そのため誰もローテーションをしなくなります。
解決策は、署名と検証の分離です。署名は現在のキー1つで行い、検証は有効なキーの集合全体に
対して試します。環境変数の名前が単数から複数に変わること(WEBHOOK_SECRET → WEBHOOK_SECRETS)が、
この設計の目印です。JWTなら、kidヘッダー + JWKSが同じ役割を果たします。
発行者がJWKSに新しいキーを先に公開し、検証者が取得したあとに、署名キーを切り替えます。
そして観測です。どのキーで検証が成功したかをkey_indexのようなタグでメトリクスに残せば、
「古いキーで署名されたリクエストが本当になくなったか」を、推測ではなくグラフで確認して、安全に古いキーを削除できます。
検知よりも対応時間が先
コミット前フックはローカルの設定なので、git commit --no-verifyを1回実行すれば回避され、新しく参加した人は
フックをインストールしないまま数日を過ごします。実際の強制力は、サーバー側のプッシュ保護から生まれます。
そして、検知に投資する前に問うべき質問があります。 「このシークレットが露出したと仮定したとき、取り消しと交換に何分かかるか。」 この数字が大きければ、検知をどれだけ細かくしても、事故のときの損失は減りません。
現場での姿
著者がまとめた事故対応の順序が、この観点の決定版です。シークレットがすでにコミットされてしまった状況で、 最もよくある反応は履歴の書き換えですが、順序が間違っています。
公開リポジトリにプッシュされた認証情報は、数秒から数分のうちに自動化されたスキャナーに収集されます。 そして、履歴を消しても残るものがあります。フォークされたリポジトリ、すでにクローンした開発者たちのローカルのコピー、 プラットフォームが保管するPR参照(コミットハッシュを知っていれば、いまだにアクセスできる場合が多いのです)、 検索エンジンやコード検索サービスのキャッシュ、CIのキャッシュとアーティファクト。 さらに、整理したあとで誰かが既存のクローンからそのままプッシュすると、消したコミットが復活します。
そのため順序は、取り消し → 影響の調査 → 履歴の整理です。 AWSのキーなら、新しいキーの作成 → デプロイ → 古いキーをInactiveに → 削除の順で無停止で交換し、 CloudTrailでそのキーがどこで使われたかを調査します。 見慣れないIP、普段使っていないリージョン、予想外のAPI呼び出しが、侵害を判断するシグナルです。 「履歴の書き換えは、漏えいを元に戻す措置ではなく、再発を減らす衛生作業です。」
External Secrets Operatorのようなツールを使っても、クラスターの衛生管理が免除されるわけではないという指摘も重要です。 ExternalSecretのマニフェストには値がないのでGitにコミットしてもかまいませんが、 Operatorが最終的に値をクラスターのSecretとして実体化するため、RBACと保存時の暗号化の問題は そのまま残ります。
次のラボですること
次のラボでは、PSAラベルを自分で付けて、restrictedに違反するPodが拒否されることを確認します。 シークレットそのものはラボよりもクイズで扱いますが、前のモジュールのラボで作成したEncryptionConfigurationが、 まさにここで述べた「etcdの平文保存」問題の解決策であることを、あらためて確認しておいてください。