誰かが.envファイルをコミットする、あるいは動作確認のために有効なAPIキーを設定モジュールに貼り付ける。レビュアーが気づき、次のコミットでキーは削除され、みんな先へ進む。しかしキーはまだそこにあります。Gitはすべてのファイルのすべてのバージョンを保存しており、リポジトリをクローンできる人なら誰でも、それを追加したコミットを読めるからです。
ハードコードされた認証情報はCWE-798として分類されます。本稿では、すでに履歴に入り込んでしまった場合にすべきこと、つまりすべてのシークレットを見つけ、何よりも先にローテーションし、履歴の書き換えに見合う価値があるかを判断し、次を防ぐガードレールを整えることを扱います。
その行を削除するだけでは不十分な理由
シークレットを削除するコミットは、それを含まない新しいスナップショットを追加するだけです。シークレットを含む以前のスナップショットは、依然としてブランチの履歴から参照されています。git log -p はそれを表示しますし、古いコミットをgit checkoutすれば復元されますし、既存のクローンやフォークはすべてコピーを持っています。マージ前にブランチをsquashしても、元のコミットが一度でもプッシュされていれば意味がありません。リモートにまだ残っている可能性があり、フェッチした人のローカルにも残っているからです。
公開リポジトリであれば、シークレットは見られたと考えてください。自動スクレイパーが公開プッシュを監視して認証情報のパターンを探しており、プッシュから最初の悪用までの猶予は非常に短いことがあります。プライベートリポジトリなら露出は小さくなりますが、ゼロではありません。読み取り権限を持つすべての従業員、業務委託先、CIシステム、連携サービスがそれを保持しています。
ステップ1:まずローテーションする
リスクを実際に取り除く唯一の手順がローテーションです。履歴の書き換えは、今後のクローンからシークレットを隠すだけで、すでに存在するコピーには何もしません。ですからリポジトリに手を付ける前に、認証情報を失効させて差し替えてください。
障害を起こさないようローテーションを計画しましょう。多くのプロバイダーは2つのキーを同時に有効にできます。新しいキーを作成し、古いキーが使われていたすべての場所に展開し、トラフィックが移ったことを確認してから古いキーを失効させます。プロバイダーが1つのキーしか許さない場合は、短時間の中断を受け入れてください。完璧な切り替えを調整している間、漏えいが判明した認証情報を有効なままにしておくよりは、数分のダウンタイムのほうがましです。
- プロバイダーのコンソールで新しいキーを発行し、通常の設定経路で展開します。
- 古いキーを失効させます。使うのをやめるだけでは不十分です。使われていない有効なキーも、有効なキーであることに変わりはありません。
- コミット日以降の古いキーによる活動を、プロバイダーの監査ログで確認します。API呼び出し、新規ユーザー、権限の変更、想定外の課金などです。
- シークレットがデータベースのパスワードや署名鍵だった場合、何が解錠されえたかを検討します。JWTの署名シークレットの漏えいは、任意のトークンを偽造されえたことを意味するため、既存のセッションを無効化してください。
- 何がどれだけの期間露出し、何を変更したかを記録します。顧客や監査人に問われたときに必要になります。
ステップ2:漏えいしたものをすべて見つける
コミットされたシークレットが1つあれば、ほかにもあることがよくあります。現在のツリーだけでなく履歴全体を、すべてのブランチとタグを含めて検索してください。
# 履歴のどこかで文字列を追加または削除したコミット
git log -p -S "sk_live_" --all
# 全コミットの差分を対象にした正規表現検索
git log -p -G "AKIA[0-9A-Z]{16}" --all
# 疑わしいパスにかつて存在したファイル
git log --all --oneline -- .env config/production.json
# 専用スキャナーは数百種類の既知の認証情報フォーマットを検査する
gitleaks git -v .
trufflehog git file://. --only-verifiedgitleaksとtrufflehogは、この用途のために作られたオープンソースのスキャナーです。主要プロバイダーのキーの形式を把握しており、trufflehogは見つかった認証情報がまだ有効かどうかを確認できます。古いgitleaksのリリースでは、gitサブコマンドではなくgitleaks detect --source . を使います。スキャナーを履歴全体に対して一度実行し、その後は新しいコミットに対して継続的に実行してください。テスト用フィクスチャやドキュメント内のサンプルキーなど、誤検知はある程度出ます。1つずつ確認し、誤検知と確定したものはスキャナーの許可リストやベースラインファイルに記録して、次回の実行では新しい指摘だけが出るようにしましょう。
リポジトリの周辺も忘れないでください。環境変数を出力したCIのログ、IssueやプルリクエストのコメントやWikiのページ、リポジトリからビルドされたDockerイメージのレイヤー、デバッグ中に共有したGistや貼り付けサービスなどです。
ステップ3:履歴を書き換えるかを判断する
シークレットさえローテーションしてしまえば、それは無価値になるため、履歴の書き換えは封じ込めではなく後片付けです。それでも、リポジトリが公開されている場合、シークレット自体を超える情報(内部ホスト名、顧客名など)が読み取れる場合、コンプライアンス上必要な場合には、やる価値があります。実際のコストもあります。すべての共同作業者が再クローンする必要があり、オープンなプルリクエストは影響を受け、コミットハッシュが変わります。リリースノート、デプロイ記録、Issueのリンク、他リポジトリで固定した依存関係など、ハッシュでコミットを参照しているものはすべて、そのブランチに存在しないコミットを指すことになります。書き換えは事前に周知し、静かな時間帯を選び、オープンなプルリクエストは先にマージするか閉じてください。
これにはgit filter-repoがGitプロジェクトの推奨ツールで、古いgit filter-branchの代わりになります。新しいクローン上で動作し、安全策としてoriginリモートを削除するため、プッシュ前に追加し直す必要があります。
# 新しいクローンから始める
git clone [email protected]:acme/api.git api-clean
cd api-clean
# 方法A:すべてのコミットからファイルを削除する
git filter-repo --invert-paths --path config/production.env
# 方法B:出現するすべての箇所でシークレット文字列を置換する
# replacements.txt には1行に1ルールを記述する。例:
# PASTE_THE_OLD_KEY_HERE==>REDACTED
git filter-repo --replace-text ../replacements.txt
# filter-repoはoriginを削除するので、追加し直して強制プッシュする
git remote add origin [email protected]:acme/api.git
git push --force --all
git push --force --tags書き換えでできないこと
- 既存のクローン、フォーク、CIのキャッシュ、バックアップには届きません。書き換え前にフェッチした人は、古いコミットを保持し続けます。
- GitHubでは、プルリクエスト用に作成される参照は読み取り専用で、古いコミットが到達可能なまま残ることがあります。キャッシュされた表示やそれらの参照を削除するには、GitHubサポートへの連絡が必要です。
- 古いクローンからpullやmergeをした共同作業者が、古い履歴をそのまま押し戻してしまうことがあります。全員に再クローンを依頼し、その間はブランチを保護してください。
- ログ、チケット、チャットのメッセージ、ビルド成果物など、コピーされた他の場所からシークレットを削除することはできません。
だからこそ順序が重要なのです。まずローテーション、次に片付け。有効なキーが残ったまま書き換えられた履歴は、誤った安心感でしかありません。プッシュ後は、新しいクローンに対してスキャナーを実行し、すべてのブランチとタグからシークレットが本当に消えたことを確認してください。
ステップ4:次の1件を防ぐ
目指すのは、シークレットをコミットしにくくし、起きたときは素早く気づけるようにすることです。片方だけを満たす単一の対策は存在しないので、多層に重ねます。
シークレットをコード経路から外す
認証情報は実行時に環境変数かシークレットマネージャーから読み取り、必要なものが揃っているかを起動時に検証してください。プレースホルダーの値を入れた.env.exampleをコミットし、最初のコミットから.envを.gitignoreに入れます。本番環境では、AWS Secrets Manager、Google Secret Manager、HashiCorp Vault、あるいは利用中のプラットフォームの暗号化された環境設定のようなシークレットマネージャーを使えば、アクセス制御と監査ログが得られ、ローテーションも容易になります。
コミットされる前にブロックする
pre-commitフックなら、リモートに届く前に開発者のマシン上で間違いを捕まえられます。pre-commitフレームワークを使えば、すべての貢献者が導入できる数行の設定で済みます。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: vX.Y.Z # 最新のリリースタグに固定する
hooks:
- id: gitleaks
# クローンごとに一度だけ実行
# pip install pre-commit
# pre-commit installフックはローカルのものでスキップもできるため、最後の砦として同じスキャナーをCIでもプッシュのたびに実行してください。GitHubでは、プランが対応していればシークレットスキャンとプッシュ保護を有効にしましょう。プッシュ保護は、既知のプロバイダートークンを含むプッシュをサーバー側で拒否します。
見逃した漏えいの被害を小さくする
- すべてのキーを必要最小限の権限に絞り、プロバイダーが対応していれば特定のIPやリファラーにも限定します。
- 長期間有効な静的キーよりも、CIからクラウドプロバイダーへのOIDCフェデレーションのような短命の認証情報を選びます。
- 環境ごとに別のキーを使い、テスト用キーが漏れても本番に届かないようにします。
- 重要なシークレットごとにローテーション手順書を用意し、緊急時のローテーションが場当たりではなく定型作業になるようにします。
コードレビューの役割
スキャナーは既知の形式との照合は得意ですが、文脈には弱いところがあります。2つの定数から組み立てられるパスワードや、設定のフォールバックとして同梱される初期認証情報などです。そうしたものはコードのレビューが捕まえます。CodeAuditAgentは、公開リポジトリのデフォルトブランチや貼り付けられたスニペットを監査する際、ハードコードされた認証情報をCWE-798として、根拠となるコードの引用とともに指摘します。読み取るのは現在のコードであり、コミット履歴ではないため、過去のコミット用には履歴スキャナーを併用してください。
要点はこうです。コミットされたシークレットは、漏えいしたシークレットです。今日のうちにローテーションし、混乱に見合うなら履歴を整理し、次の漏えいは公開ページ上ではなくpre-commitフックで止まるようにしましょう。