APIキーを誤ってコミットしてしまった。気づいてすぐ git rm して、.gitignore に足して、force pushで上書きした。GitHubの画面からもう見えない。これで安全。
私はずっとそう思っていました。間違いです。
force pushで履歴から消したつもりのコミットは、多くの場合まだ生きています。SHAを直接叩けば読めます。誰かがForkしていれば、そこにそっくり残ります。GitHubのイベントログにはpushの記録が刻まれていて、消したはずのコミットハッシュが今も引けます。
この記事では、削除したはずの秘密がなぜ残るのかを実演し、git-filter-repoとBFGで履歴から本当に消す手順、そして「消しても安全にならない」という結論まで書き切ります。最後の結論がいちばん大事です。
この記事が扱うのは「git履歴に焼き付いた秘密」です
最初に、隣接するテーマとの違いを明示します。秘密の漏れ方は何種類もあり、混ぜると対策を間違えます。
| 軸 | どこから漏れるか | この記事との関係 |
|---|---|---|
| CI/Actions secret | フォークPRやワークフローのログ | 別軸 |
| commit identity詐称 | Author欄の自己申告 | 別軸 |
| ローカルの窃取 | 開発機の .env を直接抜く |
別軸 |
| git履歴の残存 | 一度commitした秘密が削除後も残る | 本記事 |
本記事が扱うのは一番下です。CIの設定ミスでも、なりすましでも、ローカルからの窃取でもありません。一度コミットしてpushしてしまった秘密が、削除操作のあとも履歴のどこかに生き続ける、という話です。
30秒で肝が冷えた、ある日の話
具体例から入ります。以前、検証用のスクリプトに外部APIのトークンをベタ書きしたまま、うっかりpushしたことがあります。気づいたのはpushの直後でした。
私はすぐ動きました。トークンをベタ書きした行を消し、.gitignore に設定ファイルを足し、git push --force で履歴を上書きしました。所要、数分。GitHubの画面を更新すると、そのトークンはもうどこにも見えません。
このとき私は安心しました。その安心が、いちばん危なかったのです。あとから調べて分かったのは、force pushした「前」のコミットが、SHAを指定すればまだ読める状態だったこと。そして、もし誰かがその数分の間にFork済みだったら、私の力では消せないこと。結局、私がやるべき本当の対応は、履歴の書き換えではありませんでした。トークンの再発行でした。
この体験で学んだのは、「画面から消えた」という見た目を、安全だと取り違えてはいけない、ということです。
なぜ git rm も force push も秘密を消せないのか
そもそも、なぜ削除したのに残るのでしょうか。理由はGitの仕組みそのものにあります。
Gitは、すべてのコミットを内容ハッシュ(SHA)で管理する仕組みです。一度作られたコミットオブジェクトは、ブランチから参照されなくなっても、オブジェクトとしては残り続けます。これを dangling commit(宙ぶらりんのコミット) と呼びます。
git rm でファイルを消し、新しいコミットを積んでも、秘密を含む古いコミットは別オブジェクトとしてそのまま残ります。force pushでブランチの先端を書き換えても、古いコミットへの参照が消えるだけで、オブジェクト本体は消えません。
# 秘密をコミットしてしまう
echo "API_KEY=sk-live-xxxxxxxx" > .env
git add .env && git commit -m "add config"
# 慌てて削除して履歴を書き換える
git rm .env && git commit -m "remove secret"
git push --force
# だが、古いコミットはまだSHAで読める
git cat-file -p <古いコミットのSHA>:.env
# → API_KEY=sk-live-xxxxxxxx
ローカルでは git gc が約30日後にdangling commitを掃除します。ですがGitHub側は、参照されないコミットも数週間は保持します。データ消失からの復旧を許すための仕様で、裏を返せば、消したはずの秘密が数週間は誰でも引ける状態に置かれるということです。
git rm も .gitignore への追加も、未来のコミットを止めるだけです。すでに履歴に入った秘密には何の効果もありません。「消えたように見える」と「消えた」は別物です。
実演:削除したコミットをSHAとForkで復元する
仕組みの話だけでは怖さが伝わりません。実際に復元してみます。
SHA直打ちで読む
force pushした直後でも、古いコミットのSHAさえ分かれば中身は読めます。SHAは、プルリクエストのページ、CIのログ、通知メールなど、あちこちに残っています。攻撃者はそれを拾うだけです。
# 参照が切れたコミットも、ローカルなら reflog から拾える
git reflog --all
# GitHub上でも /commit/<SHA> や API でアクセスできる
curl -s https://api.github.com/repos/owner/repo/commits/<古いSHA>
Forkに残る
ここが一番たちが悪い部分です。誰かがあなたのリポジトリをForkしていた場合、force pushはForkには伝播しません。あなたが必死に消した秘密入りのコミットが、Fork側の履歴に丸ごと残ります。
公開リポジトリなら、Forkは検索もインデックスもされています。秘密を消したつもりの本人だけが「消えた」と安心していて、Fork網には原本が温存されている。この非対称性が、git履歴の秘密漏れを厄介にしています。
イベントログに刻まれる
さらに、GitHubのpublicイベントは外部のアーカイブサービスに記録されています。秘密をpushしてから force pushで消しても、最初のpushイベント自体はアーカイブに残り、そこにはコミットハッシュが含まれます。ハッシュさえ分かれば、まだガベージコレクトされていないコミットを引き戻せます。
攻撃者の手順はシンプルです。公開イベントログやFork、PR履歴からコミットハッシュを集め、片端からアクセスして秘密を抜く。これは自動化されていて、秘密をpushしてから数分で拾われる例もあります。「すぐ消したから大丈夫」は通用しません。
履歴に残った秘密を検出する
消す前に、まず「どこに何が埋まっているか」を全走査で洗い出します。手作業では追いきれないので、ツールを使います。
gitleaks は、リポジトリの全履歴をスキャンして、APIキーやトークンらしき文字列を検出するツールです。
# 全コミット履歴を走査して秘密を検出
gitleaks detect --source . --report-path leaks.json
過去のすべてのコミットを対象にできるので、「いつ・どのコミットで・どんな秘密が入ったか」が一覧で出ます。2025年だけでGitHub上に2,800万件規模の認証情報が漏れたという調査もあり、履歴の秘密混入は例外的な事故ではなく、ありふれた状態です。まず自分のリポジトリを一度スキャンしてみることをおすすめします。
GitHub純正のSecret Scanningも併用できます。ただし注意点があります。Secret Scanningが無料で自動で動くのは公開リポジトリだけです。プライベートリポジトリで使うにはGitHub Advanced Securityの有料契約が要ります。つまり、公開リポジトリは「GitHubが勝手に見つけてくれる」一方、プライベートは自分でgitleaksを回す前提で考えたほうが安全です。公開リポジトリでGitHubから警告が来たら、それは攻撃者も同じ秘密を見つけられる状態だと考えて即対応してください。
履歴から本当に消す:git-filter-repo と BFG
検出できたら、履歴そのものを書き換えて秘密を物理的に取り除きます。普通のコミットでは消せないので、専用ツールを使います。
git-filter-repo
Gitの公式ドキュメントが git filter-branch の後継として推奨しているツールです。特定のファイルやパターンを、全履歴から削除できます。
# .env を全履歴から削除する
git filter-repo --path .env --invert-paths
# 特定の文字列を伏字に置き換える
git filter-repo --replace-text <(echo 'sk-live-xxxxxxxx==>REDACTED')
BFG Repo-Cleaner
git-filter-repoより手軽で高速な選択肢です。大きなファイルや特定の秘密の一括除去に向いています。
# 秘密を含むファイルを全履歴から削除
bfg --delete-files .env
# パスワードリストにマッチする文字列を伏字化
bfg --replace-text passwords.txt
どちらを使っても、書き換え後は履歴のSHAがすべて変わります。そのうえで git push --force し、共同作業者には再cloneを依頼します。ここまでやって、ようやくリポジトリ本体から秘密が消えます。
それでも安全にならない:鍵は必ずローテーションする
ここが、この記事で一番伝えたい部分です。
履歴を書き換えても、あなたの管理下にないコピーまでは消せません。残るものを整理します。
- Fork: force pushは伝播しない。Fork所有者に削除を依頼するか、GitHubサポートにキャッシュパージを申請する
- GitHubのキャッシュ: 一度公開されたコミットは内部キャッシュに残る場合があり、サポート経由でのパージ依頼が要る
- 外部アーカイブ・スクレイパー: 公開リポジトリは第三者が複製している前提で考える
つまり、履歴除去は「これ以上広げない」対策にはなっても、「すでに見られた可能性」は消せません。だから結論はひとつです。
漏れた秘密は、消すのではなく 無効化する。APIキーを再発行し、古い鍵を失効させる。パスワードを変える。トークンをローテーションする。これをやって初めて、漏れた秘密は「ただの文字列」になります。
私は投資もやるのですが、含み損を抱えた銘柄をチャートだけ眺めて祈っても、損は減りません。損切りという確定操作をして初めて、ポジションは健全に戻ります。漏れた鍵の失効も同じで、無効化という確定操作だけが、状況を本当に動かします。
対応の優先順位は明確です。(1)まず鍵をローテーションして無効化する → (2)gitleaksで全履歴を走査する → (3)git-filter-repoかBFGで履歴から除去する → (4)Forkとキャッシュをサポートに依頼する。順番を間違えないでください。除去より失効が先です。
二度と履歴に入れないための運用
漏れたあとの対応は、どれも痛みを伴います。だから本命は「そもそも履歴に入れない」運用です。コミットされる前に止めるほうが、何倍も楽です。
実務で効くのは、コミット時の自動チェックです。gitleaks はpre-commitフックとして組み込めます。秘密らしき文字列を含むコミットを、ローカルの段階で弾けます。
# .pre-commit-config.yaml に gitleaks を登録
# コミットしようとした瞬間に秘密を検出して中断する
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.x
hooks:
- id: gitleaks
加えて、組織のリポジトリならGitHubのpush protectionを有効にしておきます。秘密を含むpushをサーバ側で拒否する機能で、ローカルのフックをすり抜けても最後の砦になります。設定そのものは秘密と無関係なので、組織全体に一律でかけても害がありません。
設計面では、秘密をコードと同じツリーに置かない、という原則が一番効きます。環境変数や秘密管理サービス(Vault、各クラウドのSecrets Manager等)に逃がし、リポジトリには「鍵そのもの」を一切置かない。置いていないものは、漏れようがありません。
まとめ
-
git rm・.gitignore・force pushは、未来のコミットを止めるだけで、履歴の秘密は消えない - 削除したコミットはSHA直打ち・Fork・公開イベントログから復元できる
- 履歴を全走査するなら gitleaks、履歴から除去するなら git-filter-repo か BFG
- 履歴を書き換えてもForkやキャッシュは残る。最優先は鍵のローテーション(失効)
- 「消したから安全」は幻想。安全になるのは、秘密を無効化した瞬間だけ
git履歴は、よくできた記録装置です。便利さの裏返しで、うっかり書いた秘密もきっちり記録します。消す技術より、漏らさない設計と、漏れたら即失効する運用のほうが効きます。面白くいきましょう。
