先に結論を書きます。.env を .gitignore に書いてリポジトリから追い出しても、シークレットは3つの別経路で普通に漏れます。私が今回検証したのは、Shell履歴、CI/CDのログ、そしてIDE(VSCode)のワークスペース保存領域の3つです。すべて、.env を git に触らせないつもりで運用している私自身のマシンから、実際に APIキー相当の文字列を拾えました。
「Docker イメージに焼き込まれる」話 — つまり .env をビルド時に COPY してしまう問題 — は以前から言われてきました。もう一段手前、.gitignore だけを頼りにしている運用にも、同じ深さの穴があります。この記事はその実演記録です。
この記事の位置づけ
線を引いておきます。世の中には「.env が Docker イメージに焼き込まれ、誰でも抜き出せる」という有名な話があります。あれはビルドコンテキストと COPY の話でした。この記事はそこには触れません。
扱うのは、Docker とは無関係な、開発者のローカルマシンとCIランナー、そしてエディタです。
- 経路1:Shell履歴 (
~/.zsh_history/~/.bash_history) - 経路2:CIログ (GitHub Actionsのジョブ出力)
- 経路3:IDEのワークスペース保存領域 (VSCodeの
workspaceStorage)
どれも .gitignore の範囲外です。守っているつもりの防御線が、シークレットの実体をどこで踏み外すかを見ていきます。
以下の実演はすべて 自分の環境 で確認したものです。他人のマシンや業務環境で同じ操作をするのは避けてください。grep の対象と検索文字列は、ご自身のダミー環境で置き換えて実行してください。
経路1:Shell履歴に残る export
一番よく使うシークレットの渡し方は、たぶん export です。ローカルで手早く試すとき、私もこう書きます。
export OPENAI_API_KEY="sk-proj-xxxxxxxxxxxxxxxxxxxx"
python run_eval.py
.env ファイルは触っていない、git にも入っていない。これで安心と思いがちですが、実際にはこの1行が丸ごと Shell の履歴ファイルに書き出されます。zsh の私の環境で試すとこうなります。
$ tail -5 ~/.zsh_history
: 1782090011:0;export OPENAI_API_KEY="sk-proj-xxxxxxxxxxxxxxxxxxxx"
: 1782090035:0;python run_eval.py
: 1782090201:0;curl -H "Authorization: Bearer $STRIPE_KEY" https://api.stripe.com/v1/charges
: 1782091188:0;AWS_ACCESS_KEY_ID=AKIA... aws s3 ls
: 1782091502:0;env | grep -i token
秘密の文字列が、日付付きでそのまま残っています。しかもこのファイルはユーザ権限で読めるので、あとでこのマシンにマルウェアが1つ入っただけで、過去に叩いたシークレットが全部持って行かれます。
抽出は grep 1本で足ります。
grep -E "sk-|AKIA|ghp_|Bearer " ~/.zsh_history
このコマンドの怖いところは、正規表現を少し足せば、他人の環境でも Slack トークンやGitHub PAT、AWS のアクセスキーIDまで一気に釣れる点です。私は自分のマシンで試して、想定より多くの当たりが出て青くなりました。
対策の骨子は3つに絞れます。
-
HISTIGNORE(bash) や zsh のHIST_IGNORE_SPACEを使い、シークレットを含む行を履歴から外す。行頭にスペースを入れると履歴に残らない設定にしておく -
.envをdirenvや1Password CLI(op run --env-file) 経由で読み込み、exportを手で打たない - 履歴ファイル自体を定期的に洗う。zsh なら
fc -RIで読み直したうえで sensitive 行を消す
2026年時点で、1Password CLI の op run は .env を仮想的に注入して子プロセスにだけ渡してくれます。Shell履歴には環境変数の中身が残りません。私はローカル開発をこれに寄せてから、履歴からシークレットが完全に消えました。
経路2:CI/CDのログに写る echo と set -x
次はサーバサイドの話です。GitHub Actions のワークフローで、こう書きたくなる瞬間があります。
- name: Debug env
run: |
echo "API_KEY=$OPENAI_API_KEY"
curl -X POST -H "Authorization: Bearer $OPENAI_API_KEY" https://api.openai.com/v1/models
GitHub Actions は Secrets を扱う値を出力からマスクしてくれます。ただしマスクは 完全一致 に近い挙動で、ちょっと加工されると崩れます。私が実際にログで漏れを確認した典型パターンはこの3つでした。
-
base64エンコードしたキーをそのまま echo (元の値と一致しないのでマスクされない) - キーを
jqで JSON に変換した中間結果を出力 (改行が挟まると片方が素通りする) -
set -xを有効にしたスクリプト内で、シークレットを引数に取るcurlを実行 (+ curl -H 'Authorization: Bearer sk-...'がそのまま出る)
再現手順は誰でも組めます。ダミーのワークフローを一度走らせて、生ログを保存された成果物 (Artifact) からダウンロードすれば、ワーカーが吐いた行が全部読めます。GitHub の Web UI 上ではマスクされた *** に見えても、Artifact 側のログにはマスクが効いていないケースが実在します。
対策は「シークレットを stdout に流さない」を徹底することに尽きます。
-
set -xを使うスクリプトでは、シークレットを含む変数を先に別変数に退避してからset +xの区間に隠す -
curlの-Hはテンプレート文字列で組み立てず、--configファイル経由で渡す - CI 上で
envやprintenvを吐くデバッグを PR のまま残さない - Artifact のアップロード対象に生ログを含めない (
.gitignoreではなくpaths-ignoreとretention-daysで守る)
CIのログはリポジトリの .gitignore の管轄外です。git に入っていないから安全、という直感がここでは通用しません。
経路3:VSCode の workspaceStorage にキャッシュされる
一番私が驚いたのがこれでした。VSCode は、開いたワークスペースに関する各種メタデータをローカルに保存します。パスは Linux ならこう。
~/.config/Code/User/workspaceStorage/
拡張機能によっては、ここに .env の中身の一部や、環境変数の名前と値のペアが残ることがあります。私が試したのは以下の3種の拡張です。
- Python 拡張:
launch.jsonのenvFileパスと、直近のデバッグセッションの一部 - REST Client 系:
.httpファイルに書いた{{apiKey}}の解決済み値がキャッシュされることがある - Copilot 系:ワークスペース検索インデックスの中に
.envの内容が入り、後続の候補生成の材料になる
再現の grep はこう書きました。
grep -rE "sk-|AKIA|ghp_" ~/.config/Code/User/workspaceStorage/ 2>/dev/null | head
自分の環境で当たりが出たときは、対象の拡張の設定を見直しました。特に「ワークスペースを開くたびに .env を読ませる」タイプの拡張は、キャッシュを持つ確率が高いです。
対策は次のとおりです。
-
.envを直接読ませる拡張を最小化する。代替として、envFileに指すのはローカル専用のダミーだけにする - ワークスペースを閉じたら
workspaceStorage/の該当フォルダを削除する運用にする - 機密プロジェクトは別プロファイルで開き、拡張の共有を切る
- 業務PCとプライベートPCを分けるだけでは足りない。同じPC上のワークスペース間でも漏れる前提で運用する
「拡張がキャッシュを持たないはず」という前提でシークレットを扱うと、後から拡張の仕様変更で漏れます。実装は外部リポジトリのコードなので、私たちには制御できません。「持たれても困らない値だけを渡す」設計に寄せるのが安全です。
3経路の共通点:git の外側で守る発想が必要
3経路を並べると、共通の構造が見えます。
| 経路 | 保存場所 |
.gitignore の効果 |
対策の軸 |
|---|---|---|---|
| Shell履歴 |
~/.zsh_history 等 |
なし | シークレットを手で打たない |
| CIログ | GitHub Actions Artifact | なし | シークレットを stdout に流さない |
| IDEインデックス | workspaceStorage/ |
なし | シークレットをファイルに書かない |
3つとも、gitリポジトリの 外側 です。.gitignore はコミット対象を絞る仕組みなので、コミット対象になったことがないファイルには一切効きません。
守るべき境界は「gitに入るかどうか」ではなく、「シークレット文字列がプレーンテキストで残る場所を作らないこと」です。ここが直感と一番ずれる点でした。私自身、以前は .gitignore にさえ入れれば安心だと素朴に思っていました。今回の3経路で、その思い込みは撤回しました。
2026年時点のシークレット管理ツールとの噛み合わせ
3経路を全部塞ぐには、単発の対策ではなく仕組みで守る必要があります。2026年時点で私が実際に使っているのは次の組み合わせです。
-
1Password CLI (
op run):ローカル開発。Shell履歴に値が残らない - HashiCorp Vault または AWS Secrets Manager:サーバサイド。CIから直接引く
- GitHub Actions の OIDC + AWS IAM Role:短寿命クレデンシャルで、そもそも長寿命の秘密をCIに渡さない
-
gitleaks+trufflehog:コミット前と過去履歴のスキャン。追加のフックとして走らせる
.env を扱わない設計に寄せると、上の3経路のほとんどが自動で塞がります。手元で export を打たなくなり、CI に長期のキーを渡さなくなり、IDEに読ませる .env そのものが縮みます。単発の tips ではなく、この構造を作れるかが分かれ目でした。
この記事のスコープ外
正直に書いておきます。今回検証していないのは次の経路です。
- Docker イメージへの焼き込み (別記事の範囲)
- Slack や Chatwork にキーを貼ってしまうケース
- dotfiles のリポジトリに
.zshrcごと push してしまうケース - クラウドサービス側で保存されるログ (Sentry, Datadog 等)
これらはこれからの記事で順に扱う予定です。読者の側で「うちだとどこが漏れそうか」を1つ思い浮かべたら、それは高確率でクリティカルです。手を動かして自分の環境で grep してみてください。私はそこで青くなりました。
まとめ
-
.envを.gitignoreに入れても、Shell履歴・CIログ・IDEインデックスの3経路で漏れる - 3経路とも git の外側に保存されるので、
.gitignoreの効果はゼロ - Shell履歴は
1Password CLIなどでexportを手打ちしない設計に寄せる - CIログは
set -xとechoを封じ、OIDC + IAM Role で長期キーを渡さない - IDEは
workspaceStorage/を「値が残る前提」で扱い、機密プロジェクトは分離する - 単発のtipsではなく、「シークレット文字列がプレーンテキストで残る場所を作らない」構造で守る
私は今回の検証で、自分のマシンから想定より多くの秘密文字列を釣り上げました。.gitignore の内側だけを気にする運用は、そろそろ卒業したほうがよさそうです。手元で grep -E "sk-|AKIA|ghp_" ~/.zsh_history を1回叩くだけで、話が変わります。面白くいきましょう。
MCP経由でLLMに外部ツールをつなぐと、シークレットの通り道はさらに増えます。設計側の考え方は拙著にまとめてあります。
MCPセキュリティ実践