- Published on
シークレット管理、.env ファイルだけでは足りない理由 — 環境変数が漏れる経路とローテーション設計
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- はじめに — .env を .gitignore に入れたから安全なのか
- 環境変数が実際に漏れていく経路
- シークレットがすでにコミットされていたら — 失効が第一、履歴は第二
- 保存方式の比較 — そして Kubernetes Secret の base64 は暗号化ではありません
- 静的な鍵をなくす構成 — 短命な資格情報と OIDC フェデレーション
- ローテーションを可能にする設計 — 二つの有効な鍵
- 検知 — プリコミットフックとリポジトリスキャンにできないこと
- おわりに — 隠したかではなく何分で変えられるか
はじめに — .env を .gitignore に入れたから安全なのか
シークレット管理についての標準的なアドバイスは短いものです。コードにハードコードせず環境変数に出しなさい、.env ファイルは .gitignore に入れなさい。ほとんどのドキュメントが扱う範囲はここまでです。
問題は、このアドバイスがたった一つの脅威しか防がないことです。ソースコードリポジトリに平文で入ってしまうこと。ところが実際のインシデントレポートを読むと、漏洩経路ははるかに多様です。プロセスメモリ、クラッシュレポート、CI ログ、コンテナイメージのレイヤー、APM ダッシュボード、そして誤って公開されたデバッグエンドポイントです。
さらに重要な問題はその次にあります。シークレットが漏れたと分かったとき、数分以内にその鍵を無効化して新しい鍵に差し替えられますか。多くのチームでこの問いへの答えは「分からない」です。どこに何本コピーされているかを把握しておらず、差し替えたら何が止まるのかも分からないからです。
この記事では二つのことを扱います。環境変数が実際にどこまで漏れるのか、そして漏れても 5 分以内に対応できる構成をどう作るのかです。
環境変数が実際に漏れていく経路
環境変数はプロセスの属性です。ファイルではなく、カーネルがプロセスごとに保持している文字列の配列であり、Linux ではそれをファイルのように読み取れます。
ps -eo pid,user,comm | grep -E 'node|python'
2841 app node
3102 app python3
# 同じ UID または root ならプロセスの環境変数をまるごと読み取れる
tr '\0' '\n' < /proc/2841/environ | grep -iE 'key|token|secret|password'
DATABASE_URL=postgres://app:pr0d-Db-Pass@db.internal:5432/app
STRIPE_SECRET_KEY=sk_live_51NxbQ2Lk9vHc0pMz7RtYaWq
JWT_SIGNING_KEY=8f2a1c9d4e7b6a305f18c2d9e0b7a4f1
ここで重要な事実は、このファイルがプロセスの生きているあいだずっと読み取られ続けることです。.env ファイルをロードしたあとに削除しても意味がありません。値はすでにカーネルの中にあります。
コンテナの中でも同じです。同じ Pod のサイドカー、ノードにアクセスできる人、そしてデバッグコンテナがすべて同じものを見ます。
kubectl debug -it deploy/payments --image=busybox --target=app -- sh
# デバッグコンテナから
tr '\0' '\n' < /proc/1/environ
二つ目の経路は子プロセスです。環境変数は継承されます。アプリケーションが画像変換ツールやバックアップスクリプトのような外部ツールを実行すると、そのツールが自分のログに環境全体をダンプした瞬間、シークレットはログ収集基盤へ流れ込みます。
三つ目はクラッシュレポートとデバッグページです。次は実際によく見かけるアンチパターンです。
// 絶対に書いてはいけないコード
process.on('uncaughtException', (err) => {
logger.error({ err, env: process.env }, 'fatal error, dumping context')
process.exit(1)
})
この一行ですべてのシークレットがログインデックスに永久保存されます。ログ収集システムはたいていアプリケーションよりアクセス権限が広いものです。フレームワークのデバッグページも同じ危険を抱えています。Django のデバッグモードは例外ページに設定値をレンダリングしますし、Werkzeug のデバッガは対話型コンソールまで開いてくれます。ステージングで有効にしたままインターネットに露出していた事例が繰り返し出てきます。
四つ目は CI ログです。シークレットのマスキング機能があっても、変形されれば突破されます。
# マスキングは完全に一致する文字列しか隠さない
set -x
curl -H "Authorization: Bearer $API_TOKEN" https://api.example.com/deploy
echo "$API_TOKEN" | base64 # base64 でエンコードするとマスキングを素通りする
+ curl -H 'Authorization: Bearer ***' https://api.example.com/deploy
+ base64
c2tfbGl2ZV81MU54YlEyTGs5dkhjMHBNejdSdFlhV3E=
最後はコンテナイメージです。ビルド引数で渡した値はイメージ履歴にそのまま残ります。
docker build --build-arg NPM_TOKEN=npm_9f3aQ2v8Lx -t myapp:1.4.2 .
docker history --no-trunc myapp:1.4.2 | grep -o 'NPM_TOKEN=[A-Za-z0-9_]*'
NPM_TOKEN=npm_9f3aQ2v8Lx
RUN の段階でファイルを消しても以前のレイヤーには残ります。レジストリにプッシュしていれば、そのイメージを取得したすべての人が読めます。ビルド時のシークレットは必ずマウント方式で渡さなければなりません。
# syntax=docker/dockerfile:1
FROM node:22-slim
RUN \
npm ci --omit=dev
docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp:1.4.2 .
シークレットがすでにコミットされていたら — 失効が第一、履歴は第二
Git 履歴の中に鍵を見つけたときの最も一般的な反応は履歴の書き換えです。これは順序が違います。正しい順序は失効、影響調査、履歴の整理です。
理由は単純です。公開リポジトリにプッシュされた資格情報は、数秒から数分で自動化されたスキャナーに収集されます。どれだけきれいに履歴を消しても、すでにコピーされた値は取り戻せません。そして履歴の書き換えだけでは次のものが残ります。
フォークされたリポジトリ、すでにクローン済みの開発者のローカルコピー、プラットフォームが保持するプルリクエストの参照(コミットハッシュを知っていれば依然としてアクセスできる場合が多いです)、検索エンジンやコード検索サービスのキャッシュ、CI キャッシュとアーティファクトです。つまり、履歴の書き換えは漏洩を取り消す措置ではなく、再発を減らすための衛生作業です。
第一優先は無効化です。
# 新しい鍵を先に作ってデプロイしたうえで、旧鍵を無効化して削除する
aws iam create-access-key --user-name ci-deployer
aws iam update-access-key --access-key-id AKIAIOSFODNN7EXAMPLE \
--status Inactive --user-name ci-deployer
aws iam delete-access-key --access-key-id AKIAIOSFODNN7EXAMPLE --user-name ci-deployer
第二優先は影響調査です。その鍵がいつからどこで使われていたのかを確認します。
aws cloudtrail lookup-events \
--lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIAIOSFODNN7EXAMPLE \
--start-time 2026-07-01T00:00:00Z \
--query 'Events[].[EventTime,EventName,Username,CloudTrailEvent]' --output text | head -20
見慣れない IP、普段使わないリージョン、想定外の API 呼び出しが見えたら、侵害対応へ切り替えなければなりません。
第三優先が履歴の整理です。まずどのコミットに入ったのかを探します。
git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE'
7c19ae4 chore: add deploy script
2f80b13 fix: correct region in deploy script
整理には git filter-repo が標準です。filter-branch は遅くてミスしやすく、もはや推奨されません。
cat > replacements.txt <<'EOF'
AKIAIOSFODNN7EXAMPLE==>REDACTED
pr0d-Db-Pass==>REDACTED
EOF
git filter-repo --replace-text replacements.txt --force
git push --force-with-lease --all
git push --force-with-lease --tags
そのあとはすべての協力者に改めてクローンし直すよう周知しなければなりません。既存のクローンからそのままプッシュすると、消したコミットが復活します。
保存方式の比較 — そして Kubernetes Secret の base64 は暗号化ではありません
シークレット管理ツールを選ぶ基準は「暗号化されるか」ではありません。露出経路がいくつあるか、失効と差し替えに何分かかるか、誰がいつ読んだのか分かるかです。
| 保存方式 | 実際の露出経路 | ローテーションのコスト | アクセス監査 |
|---|---|---|---|
| ソースにハードコード | リポジトリ、フォーク、クローン、コード検索キャッシュ | 再デプロイが必要、事実上不可能 | なし |
| .env ファイルと環境変数 | プロセス環境、子プロセス、クラッシュレポート、ログ | 手動デプロイ、漏れが発生 | なし |
| イメージに焼き込む(ENV、ARG) | 上のすべて、加えてイメージレイヤーとレジストリ利用者全員 | イメージ再ビルドと全体ロールアウト | なし |
| CI 変数 | ビルドログ、フォーク PR のワークフロー、アーティファクト | コンソールで差し替え、消費先の追跡が困難 | 限定的 |
| Kubernetes Secret のデフォルト | etcd の平文、get secrets 権限者全員、Pod の環境 | 再起動が必要 | API 監査ログ |
| Kubernetes Secret と KMS エンベロープ | get secrets 権限者、Pod の環境 | 再起動が必要 | API 監査と KMS |
| シークレットマネージャの静的値 | アプリケーションのメモリ、キャッシュ TTL のあいだ | コンソールまたは API 呼び出し一回 | 読み取り単位で記録 |
| シークレットマネージャの動的資格情報 | 短命な資格情報のみ、リース満了後に自動失効 | 自動、人間の介在なし | リース単位で記録 |
| ワークロードアイデンティティ(OIDC) | 保存された静的な鍵はなく、トークンが寿命のあいだだけ | ローテーションの対象自体が存在しない | トークン発行の記録 |
下に行くほど良いというわけではなく、下に行くほどインシデント時の対応時間が短くなる、ということです。この表で実務者が最も誤解しやすい行が Kubernetes Secret なので、まずそこから押さえます。
Secret のマニフェストを見ると値が base64 でエンコードされているので、暗号化されているように見えます。base64 はエンコードであって暗号化ではありません。鍵がなく、元に戻すのにコマンド一つで足ります。
kubectl get secret app-db -o jsonpath='{.data.password}' | base64 -d; echo
pr0d-Db-Pass
デフォルト設定では Secret は etcd に平文で保存されます。etcd のバックアップファイル、スナップショット、ディスクイメージを手に入れた人はすべての Secret を読めます。確認してみます。
ETCDCTL_API=3 etcdctl \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
get /registry/secrets/default/app-db | hexdump -C | head -4
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000010 73 2f 64 65 66 61 75 6c 74 2f 61 70 70 2d 64 62 |s/default/app-db|
00000020 0a 6b 38 73 00 0a 0c 0a 02 76 31 12 06 53 65 63 |.k8s.....v1..Sec|
00000030 72 65 74 12 8e 01 0a 6f 0a 06 61 70 70 2d 64 62 |ret....o..app-db|
平文がそのまま見えます。保存時の暗号化を有効にすると値の前にプロバイダの接頭辞が付き、本文は読めなくなります。
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
apiVersion: v2
name: cloud-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
- identity: {}
00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|
00000020 0a 6b 38 73 3a 65 6e 63 3a 6b 6d 73 3a 76 32 3a |.k8s:enc:kms:v2:|
identity は必ずリストの最後に置かなければなりません。前に来ると平文保存に戻ります。設定を変えても既存の Secret は書き直すまで平文のまま残るので、全体を一度更新する必要があります。
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
二つ目の誤解は RBAC です。ネームスペースに get secrets 権限を持つ人は、そのネームスペースのすべての Secret を読めます。開発者に便宜として与えた編集権限が、事実上プロダクションの資格情報を閲覧する権限になっている、というケースは非常によくあります。
kubectl auth can-i get secrets --namespace payments --as dev@example.com
yes
実務でよく使われる折衷案が外部シークレットオペレータです。実際の値はシークレットマネージャに置き、クラスタには参照だけをコミットします。
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-db
namespace: payments
spec:
refreshInterval: 15m
secretStoreRef:
name: aws-secretsmanager
kind: ClusterSecretStore
target:
name: app-db
data:
- secretKey: password
remoteRef:
key: prod/payments/db
property: password
このマニフェストは Git にコミットしても構いません。値が入っていないからです。ただしオペレータが値をクラスタの Secret として実体化するので、先ほどの RBAC と保存時暗号化の問題はそのまま残ります。シークレットマネージャを使ったからといって、クラスタ側の衛生が免除されるわけではありません。
静的な鍵をなくす構成 — 短命な資格情報と OIDC フェデレーション
ここまでの議論はすべて「静的な鍵をどこに置くか」でした。より良い答えは、静的な鍵を作らないことです。
CI からクラウドにアクセスするとき、以前のやり方はアクセスキーを作って CI 変数に入れることでした。この鍵は期限切れにならず、誰がコピーしたか分からず、ローテーションは人間が覚えていなければなりません。今はランナーが発行を受けた OIDC トークンをクラウドの資格情報に交換します。
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
aws-region: ap-northeast-2
- run: aws sts get-caller-identity
{
"UserId": "AROAY3EXAMPLEID:GitHubActions",
"Account": "123456789012",
"Arn": "arn:aws:sts::123456789012:assumed-role/gha-deploy/GitHubActions"
}
保存された鍵がありません。資格情報はジョブ実行時間のあいだだけ有効です。
ここで最もよく出る設定ミスが信頼ポリシーの条件節です。次は危険な設定です。
{
"Condition": {
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:acme/*"
}
}
}
この条件は、組織のどのリポジトリからでも、どのブランチからでも、どのプルリクエストからでもこのロールを引き受けられるようにします。フォークから上がってきた PR のワークフローがプロダクションのデプロイロールを得る経路が生まれます。ブランチや環境まで固定しなければなりません。
{
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/payments:environment:production"
}
}
}
Kubernetes の Pod も同じ構成を使います。projected なサービスアカウントトークンは対象と有効期限を指定でき、クラウドの STS がそれを検証します。
spec:
serviceAccountName: payments
volumes:
- name: aws-token
projected:
sources:
- serviceAccountToken:
audience: sts.amazonaws.com
expirationSeconds: 3600
path: token
データベースの資格情報も動的に作れます。Vault のデータベースシークレットエンジンはリクエスト時点でユーザーを作成し、リースが終わると削除します。
vault read database/creds/payments-readonly
Key Value
--- -----
lease_id database/creds/payments-readonly/9tKq2xL0
lease_duration 1h
lease_renewable true
password A1a-3mQx9Zr7Kt2Vb0Ns
username v-token-payments-readonly-8Xk1qP
この資格情報は一時間後に自ら消えます。漏れても窓が狭く、ログにユーザー名が残るのでどのリクエストで発行されたものかを追跡できます。静的な鍵を完璧に隠そうと努力するより、寿命を一時間に縮めるほうがほとんど常により効果的です。
ローテーションを可能にする設計 — 二つの有効な鍵
ローテーションが失敗する理由は、たいてい保存先の問題ではありません。コードが「有効な鍵はちょうど一つ」と仮定しているからです。この仮定のもとでは鍵の差し替えは必ず瞬間的な不一致の区間を作り、だから誰もローテーションをしなくなります。
解決策は検証と署名を分けることです。署名は常に現在の鍵一つで行い、検証は有効な鍵の集合全体に対して実施します。
Webhook の署名検証を例にすると、脆い実装はこうなります。
import hmac, hashlib, os
SECRET = os.environ["WEBHOOK_SECRET"]
def verify(body: bytes, signature: str) -> bool:
expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
鍵を変えた瞬間、以前の鍵で署名されたリクエストがすべて拒否されます。送信側と受信側を原子的に同時に変える方法がないので、事実上差し替えが不可能です。
集合に変えると差し替えが可能になります。
import hmac, hashlib, os
# カンマ区切りの有効な鍵のリスト。先頭が現在の署名鍵。
KEYS = [k.strip() for k in os.environ["WEBHOOK_SECRETS"].split(",") if k.strip()]
def sign(body: bytes) -> str:
return hmac.new(KEYS[0].encode(), body, hashlib.sha256).hexdigest()
def verify(body: bytes, signature: str) -> bool:
for key in KEYS:
expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, signature):
return True
return False
これで差し替え手順が無停止で成立します。
第1段階 新しい鍵を有効リストの末尾に追加してデプロイする → 旧鍵で署名、両方で検証
第2段階 新しい鍵をリストの先頭へ移してデプロイする → 新鍵で署名、両方で検証
第3段階 旧鍵で署名されたリクエストが消えたか確認する → 指標で確認
第4段階 旧鍵をリストから取り除いてデプロイする → 新鍵だけが有効
第5段階 旧鍵を発行元で失効させる
第3段階を目で確認するには、どの鍵がマッチしたのかを指標として残す必要があります。
def verify(body: bytes, signature: str) -> bool:
for index, key in enumerate(KEYS):
expected = hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(expected, signature):
metrics.increment("webhook.signature.verified", tags=[f"key_index:{index}"])
return True
metrics.increment("webhook.signature.failed")
return False
key_index:1 のカウンタが 0 になれば、旧鍵を消しても安全だという意味です。ローテーションを推測ではなく観測で進められるようになります。
JWT であれば同じ原理が kid ヘッダと JWKS で実装されます。発行者は JWKS に新しい鍵を先に公開し、検証者がそれを取得したあとで署名鍵を変えます。データベースアカウントはユーザーを二つ交互に使う方式が標準です。偶数回目には app_a、奇数回目には app_b を更新し、アプリケーションはシークレットマネージャから現在アクティブなユーザーを読み取ります。どの瞬間にも有効な資格情報が一つ以上存在するので、停止がありません。
核心はこれです。ローテーションは運用手順ではなく設計上の性質です。コードが鍵を一つだけ仮定するなら、どれだけ良いシークレットマネージャを使っても実際の差し替えは起こりません。
検知 — プリコミットフックとリポジトリスキャンにできないこと
最後の層が検知です。ツールは成熟しています。
# ワーキングツリーと履歴全体をスキャン
gitleaks detect --source . --redact --report-format json --report-path leaks.json
# コミット直前のステージされた変更だけを検査
gitleaks protect --staged --redact
Finding: STRIPE_SECRET_KEY=sk_live_REDACTED
Secret: REDACTED
RuleID: stripe-access-token
File: deploy/staging.env
Line: 14
Commit: 7c19ae4a2f3b18c0d95e7f4a6b2c1d80e3f9a5b6
プリコミットフックとして仕掛けておけば開発者のローカルで捕まります。
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
検証機能を持つスキャナーはノイズを大きく減らしてくれます。見つけた値で実際に API を呼び出し、生きている鍵だけを報告する方式です。
trufflehog git file://. --only-verified --json | jq -r '.DetectorName + " " + .Verified'
ここまでがツールのしてくれることで、次が限界です。
プリコミットフックはローカル設定なので迂回されます。git commit --no-verify の一回で終わりですし、新しく合流した人はフックを入れないまま何日も過ごします。実際の強制力はサーバー側のプッシュ保護から出てきます。フックは利便性の仕掛け、サーバー検査は統制の仕組みとして区別しなければなりません。
検知ルールは形の明確な値にしかよく効きません。AKIA で始まるアクセスキーや sk_live_ の接頭辞が付いたトークンは正確に捕まりますが、データベースのパスワードやプライベートなサービスの API キーのように形のない値はエントロピー推定に頼ることになります。エントロピールールは誤検知が多いので閾値を上げることになり、そうすると本物のパスワードを取り逃がします。
そしてスキャナーはコードしか見ません。Wiki、Issue の添付ファイル、チャットの記録、スプレッドシート、ローカルのメモにコピーされた複製は対象外です。実際のインシデントでは漏洩地点がリポジトリでない場合がかなりの数あります。
なので検知に投資する前に確認することが一つあります。このシークレットが露出したと仮定したとき、失効と差し替えに何分かかるのか。この数字が大きければ、どれだけ検知を細かくしてもインシデント時の損失は減りません。逆にこの数字が小さければ、検知が遅れても被害は限定されます。優先順位はいつでも対応時間の短縮が先です。
おわりに — 隠したかではなく何分で変えられるか
まとめると三つの文です。
環境変数は保存場所ではなく受け渡しの方式です。プロセス環境は同じホストから読まれ、ログやクラッシュレポートやイメージレイヤーへ漏れ続けます。.gitignore はそのうち一つの経路しか塞ぎません。
シークレットが露出したときの順序は失効、調査、履歴の整理です。逆にすると、すでに複製された値をそのままにして整理作業に時間を使うことになります。
そして最も確実な改善は静的な鍵を減らすことです。CI は OIDC フェデレーションで、データベースは動的な資格情報で、残る静的な鍵は有効な鍵の集合の設計でいつでも差し替え可能にします。
チームのシークレット管理の成熟度を測る問いは一つで十分です。今この資格情報がインターネットに公開されたと仮定したとき、失効させて新しい値に差し替えサービスを正常化するまでに何分かかりますか。その答えが時間単位なら、ツールを買い足すよりローテーションの経路を先に作るべきです。