- Published on
密钥(secret)管理 — 只靠 .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 的 sidecar、能访问节点的人,以及调试容器,看到的都是同一份东西。
kubectl debug -it deploy/payments --image=busybox --target=app -- sh
# 在调试容器里
tr '\0' '\n' < /proc/1/environ
第二条路径是子进程。环境变量是会被继承的。当应用执行图片转换器或备份脚本这类外部工具时,只要那个工具把整个环境 dump 进自己的日志,密钥就流进了日志收集系统。
第三条是崩溃报告和调试页面。下面是实际中经常见到的反模式。
// 绝对不能写的代码
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 历史里发现密钥时,最常见的反应是重写历史。这个顺序是错的。正确的顺序是吊销、调查影响、清理历史。
原因很简单。推送到公开仓库的凭证,在几秒到几分钟之内就会被自动化扫描器收走。无论把历史清理得多干净,已经被复制走的值都收不回来。而且只做历史重写,下面这些还会留着。
被 fork 的仓库、已经克隆过的开发者本地副本、平台保留的 pull request 引用(很多情况下只要知道 commit 哈希就依然能访问)、搜索引擎和代码搜索服务的缓存,以及 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 并不是加密
挑选密钥管理工具的标准不是"是否加密"。而是暴露路径有几条、吊销与更换要花几分钟、能不能知道谁在什么时候读过。
| 存储方式 | 真实的暴露路径 | 轮换成本 | 访问审计 |
|---|---|---|---|
| 硬编码在源码里 | 仓库、fork、克隆、代码搜索缓存 | 需要重新部署,实际上不可行 | 没有 |
| .env 文件加环境变量 | 进程环境、子进程、崩溃报告、日志 | 手动部署,容易遗漏 | 没有 |
| 烤进镜像(ENV、ARG) | 以上全部,再加上镜像层与所有能访问仓库的人 | 重建镜像并全量滚动 | 没有 |
| CI 变量 | 构建日志、fork 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 变量。这把密钥不会过期,你不知道谁复制过它,轮换全靠人记得。现在的做法是让 runner 把签发到的 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/*"
}
}
}
这个条件会让组织里的任何仓库、任何分支、任何 pull request 都能取走这个角色。于是就出现了一条路径:从 fork 上来的 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 的计数器归零,就意味着删掉旧密钥是安全的。轮换从此可以靠观测而不是靠猜测来推进。
如果是 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 联邦,数据库改用动态凭证,剩下的静态密钥则用有效密钥集合的设计让它随时可换。
衡量一个团队密钥管理成熟度的问题,一个就够了。假设此刻这份凭证被公开到了互联网上,把它吊销、换成新值并让服务恢复正常,要花几分钟。如果答案是以小时计,那么比起再买工具,应该先把轮换路径建起来。