接手一个老项目时,我习惯先翻一遍打包产物。那次顺手搜了一下AKIA开头的一串字符,结果直接搜出了一组 AWS AccessKey,而且就在经过压缩混淆的 JavaScript bundle 里,还能清晰定位到它。再往下查,Git 历史里还躺着好几组已经过期和仍然有效的外卖平台密钥。
这不是个例。我过去一年参与过的几个前端项目体检里,JavaScript 打包文件中出现敏感凭证几乎是高概率事件,只是很多人还没意识到这个问题的严重性。这篇文章就围绕这件事展开:敏感凭证是怎么混进打包文件的、实战中怎么自查和定位、以及如何用工程化手段从源头堵住这条路。适合前端开发、DevOps、以及团队里负责安全合规的同学一起看。
1. 问题全貌:敏感凭证是怎么混进前端产物的
1.1 先给“敏感凭证”画个像
聊这个问题之前,得先明确前端语境下到底哪些东西算“敏感凭证”。我按实际项目里出现过的类型整理了一下:
- 云厂商密钥:AWS AccessKey ID / Secret Access Key、阿里云 AccessKey、腾讯云 SecretId / SecretKey,这类通常是权限最大的。
- 第三方平台凭证:支付渠道的 AppSecret、推送服务的 API Key、地图服务的 Key、GitHub 的 Personal Access Token。
- 内部系统凭据:数据库连接串、Redis 地址加密码、内网接口的签名密钥。
- 用户相关令牌:JWT 私钥、会话加密密钥、管理后台的初始密码。
这些凭证一旦出现在打包文件里,就意味着任何能打开浏览器控制台、能下载 JS 文件的人都能拿到。更麻烦的是,很多凭证不是只存在于当前版本,而是从项目早期就进入仓库,之后每一次构建都跟着产物走一遍,等于把密钥反复公开发布了好几年。
1.2 “前端密钥天然可见”不是借口
有个说法很流行:“前端代码本来就是对用户透明的,密钥放进去也没办法。”这个说法对一半。前端代码确实透明,所以高权限凭证压根就不应该出现在前端运行环境里,这和“客户端可见”是完全两码事。
我见过不少项目,把数据库密码写到config.js里,后来整站被拖库;也见过把对象存储的 AccessKey 直接写死在打包产物里,被人薅走几十 TB 流量。这些东西的问题不在于“前端可被逆向”,而在于设计上就没有把“前端环境视为不可信环境”。你可以在浏览器里运行 JavaScript,但这不代表你要把保险柜钥匙也一起塞进浏览器。
1.3 泄露之后的真实影响
密钥泄露之后,短期是成本损失,长期是安全和合规风险。做个简单枚举:
- 账户盗用:攻击者用云厂商密钥创建高价资源,一夜之间产生巨额账单。
- 数据爬取:数据库连接串泄露后,业务数据被批量拉取。
- 供应链污染:攻击者拿到 npm 发布凭证后,可以往公共库推送恶意版本。
- 合规处罚:等保、GDPR、行业安全规范里都有“敏感信息保护”要求,一旦被通报或被监管抽检发现问题,代价很大。
这些影响对中小团队来说几乎是致命的,但修复起来往往不需要特别高深的技术,关键在于意识到位和流程到位。
2. 从提交到构建:六条泄露路径全过程拆解
2.1 Git 仓库成了团队密码本
最常见的入口就是 Git。项目早期为了联调方便,开发同学把.env、config.js、application.yml这类配置文件直接提交进仓库,里面躺着各种密钥。只要提交过一次,即使后来删掉,Git 历史里依然能翻出来。
我在一次排查里遇到过一个真实案例:一个项目的.env文件里存了生产数据库密码,提交时间比项目上线还早两周。整个团队一年半里都没人注意到,直到我用git log --diff-filter=A -- .env查文件引入记录,再配合字符串搜索,才发现问题。
这里要特别强调一点:Git 历史不是垃圾桶,删除工作区文件不等于删除历史记录。任何推送过的提交,一旦被其他人 clone 过,就等于永久传播了。后面会讲怎么处理历史,但最理想的做法是从一开始就不让凭证进入仓库。
2.2 构建时变量注入带来的“假安全”
现在很多前端项目用 Vite、Webpack 构建,习惯上会把环境变量通过define或DefinePlugin注入到代码里。比如 Vite 里写import.meta.env.VITE_API_KEY,构建时会被替换成真实值。
问题就出在这个“替换”上。很多人以为用了环境变量就安全了,但实际上define是纯文本替换。只要你在构建时传入了VITE_AWS_ACCESS_KEY_ID=AKIA...,最终产物里就会原样出现AKIA...这串字符。我见过一个项目,构建脚本里从服务器环境读取了 AccessKey,然后通过 Vite 注入到前端,理由是“这样代码里就没有明文密钥了”。但构建完一搜,密钥一个不落全在 bundle 里。
正确理解是:打包时注入到前端的变量,无论叫什么名字,最终都会变成客户端可见的静态字符串。这类变量只能放非敏感的配置项,比如接口地址、埋点 ID、可公开的标识符。
2.3 把服务器端 SDK 错误引进了浏览器
云厂商为了照顾不同场景,会同时提供服务端 SDK 和前端 SDK。服务端 SDK 的设计前提是“运行在可信环境”,所以它内部会自动读取环境变量里的 AccessKey,或者让开发者直接传入凭证。
有些项目图方便,在前端项目里引入了服务端 SDK。比如在 React 项目里安装aws-sdk并写new AWS.S3({ accessKeyId: process.env.AWS_ACCESS_KEY_ID }),再配合构建时注入,密钥就跟着进了打包文件。更隐蔽的是,某些 SDK 会自动从运行环境读取配置,而打包时这些配置恰好被写死进了 bundle。
识别方法很直观:打包文件体积异常膨胀,或者 bundle 里搜得到AWS、Secret、PrivateKey之类的类名和字段名。通过source-map-explorer或webpack-bundle-analyzer看一下依赖结构,能很快发现这些本不该出现的服务端库。
2.4 sourcemap 把源码完整上交
Sourcemap 原本是开发调试用的,生产环境一旦暴露,就等于把未混淆的源码直接公开。源码里写死的密钥、注释里的密码、内部域名,全都可以被还原。
我在一次项目检查中发现,某个线上环境直接能通过 URL 访问到.js.map文件。用浏览器 DevTools 打开 Sources 面板,源码结构一目了然,连团队内部的接口设计文档路径都写在注释里。更麻烦的是,有些打包工具会把.map文件和 JS 文件部署在同一个目录,而 Nginx 默认配置又不禁止访问。
生产环境到底要不要 sourcemap,业界一直有争论。我的建议是:如果一定要用,就放在内网单独的日志平台或错误监控系统里,不要和静态资源一起放在公网可达的 CDN 或对象存储上。
2.5 第三方包和构建插件里埋了“定时炸弹”
第三方依赖是另一个大入口。有些 npm 包本身是开源的,但维护者在源码里留了测试用的 API Key;还有些包会请求远程配置,配置里包含团队内部的推送凭证。这类问题最难自查,因为你不一定知道依赖的源码里写了什么。
除了仓库依赖,构建链路上的插件同样要警惕。我见过一个内部构建插件,为了在 CI 上自动上传产物,直接把云厂商密钥写死在插件代码里。结果团队里所有用到这个插件的项目,打包产物里都带着同一把“万能钥匙”。最离谱的是,插件还发布到了私有 npm 仓库,等于把所有项目的密钥打包分发了一遍。
这类问题没有太好的自动化排查手段,最有效的还是依赖治理:锁定版本、做依赖审计、主流包优先选择维护活跃的,同时对“能访问网络、能执行脚本”的构建插件保持克制。
2.6 监控上报与调试日志里的“额外赠品”
最后一条常见路径是监控和调试代码。为了排查线上问题,很多项目会在打包产物里保留console.log、内部调用链信息甚至完整的请求体。如果请求体里带了用户 Token,或者调试日志里打印了加密用的密钥,这些信息也会跟着 bundle 流出去。
我排查过一个支付相关的 H5 项目,代码里有一段console.log('request signed with key:', signingKey),这行日志在开发时方便,上线时忘了删。结果任何人打开控制台,都能看到签名密钥。这类问题靠肉眼检查很难发现,需要把“禁止敏感信息落日志”写进代码规范,同时在 CI 里做针对性扫描。
3. 自查与定位:怎么把打包文件里的凭证挖出来
3.1 从产物反查:静态扫描基本盘
拿到一个项目,我习惯先构建一遍,然后对产物做一轮字符串扫描。工具上最顺手的是grep或rg,配合正则表达式。下面这段命令可以直接跑:
# 构建产物后执行,扫描常见敏感凭证模式 rg -n --hidden -i -g '!node_modules' -g '*.js' \ -e 'AKIA[0-9A-Z]{16}' \ -e 'sk_live_[0-9a-zA-Z]{24,}' \ -e 'ghp_[0-9a-zA-Z]{36}' \ -e 'AIza[0-9A-Za-z_-]{35}' \ -e '-----BEGIN (RSA|EC|OPENSSH) PRIVATE KEY-----' \ -e '(password|passwd|secret|token)\s*[:=]\s*["'"'][^"'"']{8,}["'"']' \ dist/这类扫描不求一次覆盖全部,但能快速定位明显问题。云厂商密钥都有固定前缀,比如 AWS 的AKIA、Google 的AIza、GitHub 的ghp_,按前缀搜基本不会漏。对于自定义的内部密钥,可以在代码库里维护一份正则库,把团队用到的密钥格式加进去。
需要注意一点:扫描出来的结果里会有误报,比如示例数据、测试用例里的假密钥。不要因为误报多就不扫,而是要给结果分类,建立一份团队白名单,把经过确认的安全样本排除掉。
3.2 从 Git 历史里翻旧账
当前产物排查干净不算完,历史提交里可能还埋着更早的密钥。用git log做字符串搜索是最直接的方式:
# 在全部历史提交中搜索包含指定字符串的提交 git log --all --oneline -S 'AKIA[0-9A-Z]\{16\}' --pickaxe-all # 查看某个文件的历史变更记录 git log --all --follow -- .env如果是全仓库历史扫描,推荐直接用工具。我用得比较多的是gitleaks和trufflehog。前者适合做本地和 CI 扫描,规则丰富;后者擅长从 Git 历史里挖出隐藏的凭证。下面是一个 gitleaks 的快速执行示例:
# 扫描当前目录下的 git 仓库 gitleaks detect --source . --report-format json --report-path gitleaks-report.json # 只扫描历史提交中的敏感信息 gitleaks detect --source . --log-opts="--all"这类工具本身不能修复问题,但能帮你把家底摸清楚。扫描结果出来后,按严重程度排序,优先处理仍有效的密钥。
3.3 用打包分析器看清依赖构成
字符串扫描的盲区是“不知道某段敏感字符串是从哪个依赖里带进来的”。这时候可以用打包分析器看依赖树。以 Vite 项目为例:
# 安装并执行打包分析 npm i -D source-map-explorer npm run build source-map-explorer dist/assets/*.js分析结果会以列表或地图形式展示每个模块占的体积。如果某个跟云厂商、支付、推送相关的 SDK 出现在列表里,但项目本身并没用它的功能,十有八九是误引或者过度引用了。再进一步,可以直接在 bundle 输出目录里搜索 SDK 名称,定位到具体代码片段,确认密钥藏在哪个模块里。
3.4 建立自动化的“门禁”
人工排查永远赶不上代码变更的速度。我建议在 CI 流程里加一道扫描任务,每次 push 或发版前自动跑一遍。大概思路是这样:
# .gitlab-ci.yml 或 GitHub Actions 的简化版 security-scan: stage: test script: - npm ci - npm run build - gitleaks detect --source . --redact --verbose # 如果构建产物里出现密钥模式,这里再跑一次自研脚本 - node scripts/scan-dist.mjs only: - main - merge_requests自研的scan-dist.mjs可以做成几百行的 Node 脚本,读取正则库,对dist/目录做全量扫描,命中即非零退出,从而阻断合并或发版。这一步做完,团队里再有人误把密钥写进代码,流程会直接拦住,而不是等上线后再补救。
4. 工程化防控:建立一套不依赖人的密钥防护机制
4.1 分级分类:先弄清楚哪些凭证不该出现在前端
做防护的第一步是把凭证分级。我的建议是分成三类:
| 类别 | 典型例子 | 能否出现在前端 bundle | 存放位置 |
|---|---|---|---|
| 可公开标识 | 地图 Key、埋点 AppID、CDN 签名标识 | 允许,但建议做域名白名单和额度限制 | 环境变量,构建时注入 |
| 低敏凭证 | 临时 Token、短期有效的 STS 凭证 | 允许,但必须短时效、最小权限、可撤销 | 由后端接口动态下发 |
| 高敏凭证 | 云厂商 AccessKey、数据库密码、签名私钥 | 绝对禁止 | 只存在于后端服务或密钥管理服务中 |
这个表格我建议直接贴在团队文档里。实操时最有效的判断标准是问一句:“如果这个凭证被人公开,公司损失有多大?”如果答案是“很大”,那它就不该出现在前端代码和打包产物里。
4.2 换一个思路:让后端来保管和签发
很多场景看起来“必须把密钥放前端”,但实际上都有替代方案。以对象存储直传为例,以前的做法是前端拿着 AccessKey 直连 OSS 或 S3,密钥自然要写进代码里。现在更安全的做法是引入后端签发临时凭证:
- 前端先请求自己的后端接口,例如
/api/upload/token。 - 后端用高敏凭证向云厂商申请一个短期、限定路径、限定操作的临时凭证(比如 AWS STS 或阿里云 STS)。
- 后端把临时凭证返回给前端。
- 前端拿着临时凭证去上传文件。
这样做的核心收益是:即使临时凭证被抓包拿到,它的有效期只有几十分钟,权限也被限制在某个目录,泄露风险可控。这类设计在云厂商官方文档里都有标准方案,不需要自己发明。
4.3 构建与发布环节的硬性约束
构建阶段需要有意识地做几件事:
- 严格区分“构建时变量”和“运行时配置”。能通过接口请求拿到的配置,不要编译进产物。
- 生产构建关闭 sourcemap。如果调试需要,单独把 sourcemap 传到内网平台,并设置访问权限。
- 在构建脚本里加产物敏感词扫描。扫描结果记录到构建日志,发现异常直接失败。
- 对外发布的静态资源目录,禁止包含
.env、.map、*.pem、*.key等后缀文件。Nginx 或 CDN 层面可以做一层后缀拦截。
我见过一个团队用了一个比较“笨”但很有效的方法:每次构建后,脚本会搜索产物里是否包含当前环境变量中真实密钥的值,如果匹配,构建自动失败。这把“密钥是否被打进去”变成了机器检查项,而不是人肉记忆。
4.4 Git 仓库防线的三个关键点
第一,配置完善的.gitignore,覆盖常见的环境变量文件和密钥文件:
# 环境变量与密钥文件 .env .env.* !.env.example *.pem *.key *.p12 *.pfx secrets.* config/production.json第二,给 Git 客户端加上 pre-commit 钩子。可以用husky配合lint-staged,在提交前对变更文件做一遍正则扫描,发现疑似密钥就阻止提交。
第三,定期轮换密钥,尤其是那些曾经进入过仓库的。不要觉得“当时删掉了就没事”,Git 历史是删不干净的。稳妥的做法是:只要密钥在历史里出现过,就把它当作已泄露,立即在云控制台禁用并换新。
4.5 真出了问题:应急处理的正确顺序
如果还是漏了出去,别慌,按顺序做这几件事:
- 立刻在对应平台吊销该凭证。AWS 在 IAM 里删除 AccessKey,GitHub 在 Settings 里撤销 Token,这一步优先于一切分析。
- 确认凭证权限和调用记录。到云平台的审计日志里查一下这个密钥最近有没有异常调用。
- 轮换所有受影响系统的密钥。如果同一个 AccessKey 绑定在多个服务上,全部换掉。
- 清理代码和仓库历史。Git 历史清理用
git filter-repo或 BFG Repo-Cleaner,处理后再强制推送并通知所有协作者重新 clone。 - 复盘问题是怎么进来的,把对应的检查项补进 CI。
关于“清理 Git 历史”,我要提醒一句:它只能防止新 clone 的人再拿到旧历史,已经 clone 过的人手里还是有一份完整历史。所以核心仍然是第 1 步的吊销,历史清理是给未来铺路,不是给过去善后。
5. 常见问题与排查技巧实录
5.1 前端必须调用某个第三方 API,但密钥只能由服务端保存怎么办
这个问题的标准答案是加一层“代理或 BFF”。让前端请求自己的后端,后端保存第三方密钥,再代为调用第三方 API,把结果返回给前端。如果担心后端压力,可以在后端加缓存或限流。
以地图服务为例,前端把经纬度发给自己的后端,后端请求地图厂商接口,再把 POI 数据返回。整个过程前端不接触地图密钥。这个方案多花一点后端开发时间,但能避免密钥泄露的所有后续麻烦。
5.2 发现历史提交里的密钥已经用了很久,怎么评估影响
先不要凭感觉判断“好像没出事”。去云平台的控制台或审计系统拉一下这个密钥的调用记录,看有没有非预期来源 IP、非业务时段的请求、异常的资源创建行为。
同时,检查这个密钥是否有写权限。如果是读写权限,要重点排查存储桶、数据库、函数计算等资源有没有被改动过。评估完成后,不管有没有异常,都把这个密钥吊销换新。
5.3 用 Vite 的 define 注入密钥,真会写进产物吗
会。define的本质是构建阶段的文本替换。比如定义了__API_KEY__: JSON.stringify(process.env.API_KEY),产物里所有__API_KEY__的位置会被替换成环境变量里的真实字符串。这不是“运行时读取”,而是“编译时写死”,所以必然在 bundle 里可见。
验证方法很简单:跑完构建,直接搜索产物里的密钥字符串,一搜一个准。类似的机制还有 Webpack 的 DefinePlugin、Rollup 的replace插件,原理都一样。
5.4 扫描工具误报太多,怎么处理
误报确实存在,因为示例代码、测试数据、文档片段都可能匹配到敏感字符串。不要因为误报就放弃扫描,而是要建立一套处理机制:
- 维护白名单文件,记录已确认安全的字符串和原因。
- 在扫描工具里配置允许的路径或文件,比如
test/fixtures/目录。 - 对扫描结果分级:高置信度模式(私钥、云厂商密钥前缀)直接阻断;低置信度模式(password 关键字)做提示,人工复核。
我曾经遇到过一个情况:项目里有一段模拟数据,字符串长得特别像 AWS AccessKey,导致扫描任务每周报警一次,后来团队把这段测试数据改掉,并加入白名单,才算安静下来。白名单不是“掩盖问题”,而是“标记已知安全项”,需要有对应的说明和责任人。
5.5 一个容易被忽略的地方:构建日志和发布记录
密钥不一定只在 bundle 里,构建日志、部署日志、错误上报系统里同样可能残留敏感信息。比如 CI 脚本里执行过echo $ACCESS_KEY、部署工具的日志里打印了完整的请求头,这些都等于把凭证写在墙上。
排查时可以把构建日志下载下来,用同样的正则扫一遍。后续在 CI 脚本里,对敏感变量做掩码处理,避免直接输出到日志平台。
5.6 我踩过的几个坑
第一次做全仓库扫描时,我只扫了dist/目录,没扫 Git 历史,结果当前版本是干净的,但历史里躺着一把生产密钥,直到做安全审计时才翻出来。现在我的检查顺序固定是:先扫当前工作区和产物,再扫 Git 历史,最后扫依赖目录。
还有一次,我在排查时发现某个验证用的 Token 出现在打包产物里,但怎么都找不到代码位置,后来发现是构建插件里写死的。从那次以后,我对“构建插件”这个东西多留了个心眼,凡是会自动请求网络或注入变量的插件,都要先看看源码。
最后想说的是,敏感凭证泄露这个事,技术上没有太多“银弹”,更多是流程和习惯的对抗。把检查脚本写进 CI、把密钥分级写进文档、把吊销权限第一时间握在手里,哪怕偶尔有疏漏,也能快速止血,而不是让问题在仓库里发酵好几年。
从实际效果看,最能立竿见影的一步就是:给现在的项目跑一次产物扫描,再跑一次 Git 历史扫描。大概率会有惊喜,但早发现总比晚发现好。