Skip to content
Published on

密钥(secret)管理 — 只靠 .env 文件为什么不够,环境变量泄漏的路径与轮换设计

分享
Authors

开篇 — 把 .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 --mount=type=secret,id=npmrc,target=/root/.npmrc \
    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-repofilter-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 联邦,数据库改用动态凭证,剩下的静态密钥则用有效密钥集合的设计让它随时可换。

衡量一个团队密钥管理成熟度的问题,一个就够了。假设此刻这份凭证被公开到了互联网上,把它吊销、换成新值并让服务恢复正常,要花几分钟。如果答案是以小时计,那么比起再买工具,应该先把轮换路径建起来。