.env 文件——6个导致你的密钥出现在 GitHub 上的错误
大多数 .env 文件泄露并非来自黑客——而是来自开发者在设置 .gitignore 之前就提交了文件,或在发布时将包含真实值的 .env.example 文件送出,或让框架默默地将服务器密钥打包到客户端 JS 中。以下是实际上发生的6个错误。
GitGuardian 2023 年“机密泄露”报告发现,有超过 1200 万个机密被提交到了公共 GitHub 仓库中。其中大多数并非被盗,而是由开发者误以为自己已经妥善处理而上传的。这些就是导致此类问题的模式。
1. 在首次提交后添加 .gitignore 文件
.gitignore 仅阻止 未被跟踪 文件将被纳入暂存状态。一旦文件被跟踪——哪怕只是短暂地——它就会进入 git 历史记录。如果你已经创建了 .env,运行了 git add . && git commit,然后在之后添加了 .env 到 .gitignore ,那么该文件仍会出现在所有在该变更之前的提交中。
检查它是否已存在于历史记录中:
git log --all -- .env
如果返回了提交记录,说明机密已存在于历史记录中。首先轮换凭证。然后使用 git-filter-repo ( git filter-branch):
pip install git-filter-repo
git filter-repo --path .env --invert-paths
的推荐替代方案)
向所有远程仓库强制推送,并通知团队成员重新克隆。在清除操作之前,所有已克隆的副本中都包含这些提交——包括任何从仓库检出的自动化 CI 系统。
2. 将 .env 复制到 .env.example 但未清除值 .env 标准工作流程:创建一个包含真实值的 .env.example ,然后将其复制到
cp .env .env.example ,以向团队成员展示项目所需的密钥。问题就出在复制步骤上。 且 会复制所有内容——包括密钥 .env.example 和值。而 .env.example 本意就是被提交到仓库中的。真实值在
是故意放入仓库的。
DATABASE_URL=postgres://admin:supersecretpassword@prod-db.example.com/appdb
STRIPE_SECRET_KEY=sk_live_51AbcDefGhiJklMnopQrstUvwx...
JWT_SECRET=my-actual-production-jwt-secret
❌ 最终进入 git 的内容: .env.example ✅ 正确的内容应为:
DATABASE_URL=postgres://user:password@localhost:5432/appdb
STRIPE_SECRET_KEY=sk_live_YOUR_KEY_HERE
JWT_SECRET=generate-a-random-secret-min-32-chars
创造 .env.example 先使用占位符值创建文件,提交它,然后将其复制到 .env 并填写真实凭证——而不是反过来。
3. 在错误处理器中记录 process.env
这种情况最初只是在事件期间进行“快速调试”,之后从未被移除。或者它存在于看似无害的通用错误中间件中。
// Classic debug line that makes it to production
console.log('Starting with config:', process.env);
// Generic error handler that dumps everything
app.use((err, req, res, next) => {
logger.error({ config: process.env, error: err.message });
res.status(500).json({ error: 'Internal server error' });
});
process.env 运行时包含所有由 dotenv 加载的变量以及系统变量。将完整的对象传递给日志记录器意味着它会进入你的日志聚合器、错误追踪服务(如 Sentry、Datadog、Rollbar)以及可能的错误通知邮件或 Webhook 中。许多这些服务会将其转发到第三方存储,且具有各自访问控制权限。
仅记录诊断所需的特定值:
logger.error({
nodeEnv: process.env.NODE_ENV,
appVersion: process.env.APP_VERSION,
error: err.message,
stack: err.stack
});
4. 将密钥烘焙进 Docker 镜像层
两种模式会永久将密钥嵌入 Docker 镜像历史中:
# Pattern 1: COPY bakes the entire .env into a layer
COPY .env .
# Pattern 2: ARG/ENV burns values into build metadata
ARG DATABASE_URL
ENV DATABASE_URL=$DATABASE_URL
即使你在后续层中删除了该文件(RUN rm .env),其值仍可从镜像历史中读取。任何拥有镜像拉取权限的人都可以运行:
docker history --no-trunc your-image:tag
并恢复构建时使用的 ARG 值。Docker BuildKit 的密钥是正确的工具——它在构建过程中挂载密钥,而不会将其写入任何镜像层:
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=db_url DATABASE_URL=$(cat /run/secrets/db_url) ./setup.sh
对于运行时配置,通过 docker run -e 或 environment: 在 Docker Compose 中注入环境变量——引用主机环境变量,而不是硬编码值,也绝不要使用 COPY‘d 密钥文件。
5. 使用弱占位符密钥并发布到生产环境
JWT_SECRET=secret, SESSION_SECRET=keyboard cat, APP_KEY=changeme, ENCRYPTION_KEY=1234567890abcdef。这些密钥最初是作为开发环境的占位符,有时甚至从未被替换。攻击者在暴力破解 JWT 签名时会主动尝试这些字符串——它们出现在特定的词典中,因为它们在 GitHub 搜索中频繁出现。
使用 HS256 和弱密钥签名的 JWT 可以通过像 c-jwt-cracker这样的工具在离线环境中被破解。仅拦截到一个有效的令牌就足以暴力破解密钥并生成任意令牌。
正确的密钥应具有加密随机性,长度至少为 32 字节。在需要时提前生成它们—— 环境密钥生成器 在 IO Tools 上将生成适用于常见 .env 密钥(如 JWT 密钥、会话密钥、API 密钥)的正确随机值,无需任何配置。从一开始就设置它们;不要使用占位符并计划在“上线前修复”。
6. 框架环境变量命名约定将密钥暴露给客户端
多个流行框架使用变量命名前缀来判断客户端与服务器的可见性。如果处理不当,就会将密钥以明文形式打包到 JavaScript 文件中,发送给加载应用的每个浏览器。
- Next.js: 仅在根目录
NEXT_PUBLIC_以 -prefixed 变量会被打包到客户端。但当通过getServerSidePropsprops 传递时,服务器端密钥会泄露——任何在props中返回的值都会被序列化到页面 HTML 中,且可在源码中被读取。 - Vite: 以
VITE_前缀的变量会被打包到客户端 JS 中。使用VITE_DATABASE_URL“方便起见”是开发者实际犯下的错误。 - Create React App: 全部
REACT_APP_变量最终会出现在客户端包中,没有例外。不存在服务器端的 CRA 运行时——所有加载内容都会发送到浏览器。
构建完成后,搜索输出目录以查找已知密钥值:
grep -r "sk_live_" ./dist
grep -r "sk_live_" ./.next/static
如果返回任何匹配项,说明这些密钥存在于每个浏览器标签页中。立即轮换它们,并检查还有哪些内容被打包。
一个值得从一开始就养成的习惯
在创建任何将存储密钥的文件之前,先设置 .gitignore ——而不是事后补救。任何新仓库的首次提交应为 .gitignore 且 .env.example ,并使用占位符值。将 .gitignore 生成器 将在一分钟内生成一个完整的、针对特定框架的忽略文件。
上述六种错误在任何代码编写之前都是可以避免的。一旦密钥暴露,唯一的修复方式是全面轮换——包括服务提供商、所有曾拥有副本的环境,以及可能在日志中缓存该值的系统。
