Gitに秘密鍵をコミットしたとき — キーのローテーションとfilter-repoでの履歴整理

読了 7分

.envファイルを丸ごとコミットしてしまった、あるいはコードにハードコードしたAWSアクセスキーやAPIトークンがコミットに紛れ込み、すでにpushまで終わっている状況です。この事故で最も多い最初の反応は「早く履歴から消そう」です。ところが対応の順序は逆です。最優先は履歴の整理ではなくキーの無効化です。この記事ではまずその理由を確認し、状況別の整理手順と予防の仕組みまで順に整理します。

最優先はキーの無効化 #

pushされた秘密鍵は、まだ消せる情報ではなくすでに漏えいした情報として扱うべきです。

  • 公開リポジトリならボットが分単位で収集します。GitHubの公開イベントをリアルタイムでスキャンして認証情報を収集する自動化ツールが常時動いています。pushして数分のうちに他人の手に渡ったと想定するのが安全です。
  • コピーは元の整理では消えません。フォーク、同僚のクローン、CIが取得したチェックアウトやログには、すでにコピーが存在します。元のリポジトリの履歴を書き換えても、これらのコピーには何の影響もありません。

そこで、どんな整理作業よりも先に、発行元で該当の認証情報を破棄して再発行します。AWSアクセスキーはIAMで無効化してから削除し、GitHubトークンやサードパーティのAPIキーは各サービスでrevokeし、DBパスワードならパスワード自体を変更します。キーが無効化された瞬間から、履歴に残る文字列はもう危険のない死んだ値になります。

注記
クラウドの認証情報なら、無効化とあわせて悪用の有無も確認します。AWSならCloudTrailで該当キーの直近の呼び出し履歴を点検します。漏えい時点より後に、見覚えのないリージョンでのリソース作成のような履歴があれば、キーの破棄だけでは終わらないインシデント対応の案件です。

状況の判断 — push前か後か #

キーの無効化の次は履歴の整理です。手順は、コミットがリモートに上がったかどうかで完全に変わります。

  • まだpush前 — 自分のコンピュータにしかないコミットなので、ローカルで書き換えれば終わりです。
  • push後 — リモートとすべてのコピーに広がった状態なので、専用ツールで履歴を書き換え、チーム全体の協力が必要です。

push前なら — ローカルで整理 #

直前のコミットに秘密鍵が入ったのなら、2つのコマンドで整理できます。

最後のコミットからファイルを除去
git rm --cached .env          # 追跡だけ解除し、ファイルは作業ディレクトリに残る
echo ".env" >> .gitignore
git add .gitignore
git commit --amend --no-edit  # 最後のコミットを新しいものに置き換える

git rm --cachedはファイルを消さず、Gitの追跡から外すだけです。続けて--amendでコミットを置き換えれば、秘密鍵のないコミットが最後のコミットに代わります。

秘密鍵が数コミット前に入ったのなら、interactive rebaseで該当コミットを直します。

以前のコミットから除去
git rebase -i <秘密鍵コミットの直前のコミット>
# todoリストで該当コミットを edit に変更

git rm --cached .env
git commit --amend --no-edit
git rebase --continue

この場合、キーのローテーションは判断の領域です。コミットが自分のコンピュータの外に出たことがないなら漏えいはなかったことになりますが、確信が持てないなら(たとえば個人のリモートに一度でも上げたなら)ローテーションする側が安全です。

push後なら — filter-repoで履歴整理 #

リモートに上がったコミットには、履歴全体を書き換えるツールが必要です。標準のツールはgit filter-repoです。かつて使われていたgit filter-branchは遅くて落とし穴が多く、Gitの公式ドキュメントもfilter-repoを推奨しています。

filter-repoのインストール
pip install git-filter-repo
# または macOS: brew install git-filter-repo

filter-repoは、作業中のリポジトリを誤って壊すのを防ぐため、新しくcloneしたきれいなリポジトリで実行することを前提にしています。整理専用のクローンをひとつ新しく取得します。

ファイル単位の除去 — .env を履歴全体から削除
git clone https://github.com/example/my-service.git cleanup
cd cleanup
git filter-repo --invert-paths --path .env

--pathで指定したファイルを残すのが既定の動作なので、--invert-pathsを付けて逆に、そのファイルだけを履歴全体から除去します。ファイルは残しつつコード内のキー文字列だけを置き換えるなら、置換ルールのファイルを使います。

expressions.txt — 置換ルール
AKIAIOSFODNN7EXAMPLE==>***REMOVED***
regex:AKIA[0-9A-Z]{16}==>***REMOVED***
文字列置換の実行
git filter-repo --replace-text expressions.txt

整理が終わった履歴は、すべてのコミットのハッシュが変わった完全に新しい履歴です。filter-repoは安全のためにリモート設定を外しておくので、リモートをつなぎ直して強制的に上げます。

整理した履歴を上げる
git remote add origin https://github.com/example/my-service.git
git push --force --all
git push --force --tags

最後がいちばん重要です。チーム全員に、既存のクローンを捨てて新しくcloneするよう告知します。古い履歴を持つクローンからpullやpushをすると、消したはずのコミットがリモートによみがえる経路になります。履歴の書き換えは、ツールではなくこの協力の手続きまで終わって完了です。

ヒント
同じ用途の代替ツールにBFG Repo-Cleanerがあります。ルールがシンプルな分、細かい制御はfilter-repoの方が優れています。どちらを使うにしても、fresh cloneでの作業、force push、re-cloneの告知という手順は同じです。

消しても残る場所 #

元のリポジトリを完璧に整理しても、GitHubのプラットフォームには痕跡が残ることがあります。

  • すでに作られたフォークの履歴は、元の整理と無関係に維持されます。
  • PRのdiff画面や、コミットハッシュで直接アクセスするキャッシュされたビューは、リポジトリの履歴からコミットが消えた後もしばらく開けます。

この領域はリポジトリの所有者が直接消すことはできず、GitHub Supportにキャッシュの削除とフォークの処理を依頼して初めて整理されます。裏を返せば、履歴の整理をどれだけうまくやっても完全な削除は保証されず、これがキーのローテーションが最優先である2つ目の理由です。

予防の仕組み #

同じ事故を繰り返さない仕組みは、コミット前の段階から何層にも重ねられます。

  • .gitignoreの先行登録.env*.pemのようなファイルは、作る前に登録する順序を習慣にします。Git基礎 #2で扱った原則そのままです。
  • GitHubのpush protection — リポジトリのsecret scanning設定でpush protectionを有効にしておくと、既知の形式の認証情報を含むpushをGitHubが受信段階でブロックします。
  • pre-commitフック — gitleaksのようなスキャナーをpre-commitフックに掛けておくと、コミットが作られる前にローカルで弾かれます。
  • キーをコードの外へ — そもそもコードとリポジトリにキーが存在しないよう、環境変数とシークレットマネージャー(AWS Secrets Managerなど)で注入し、リポジトリには値が空の.env.exampleだけを置きます。

まとめ #

事故対応の順序をあらためて整理します。

  1. ローテーション — 発行元で即時に破棄して再発行します。pushされたキーはすでに漏えいしたものとみなします。
  2. 整理 — push前ならamendやrebaseで、push後ならfresh cloneでfilter-repoを使って履歴を書き換えます。
  3. 告知 — force push後、チーム全員がre-cloneするよう案内します。フォークとキャッシュはGitHub Supportへの依頼で処理します。
  4. 予防 — .gitignoreの先行登録、push protection、pre-commitスキャナー、シークレットマネージャーで再発を防ぎます。

履歴の整理は目に見える痕跡を消す仕上げの作業であり、事故の実際の収束はキーの無効化が担います。順序さえ守れば、この事故は収拾可能な出来事で終わります。

X