服务器/仓库明文密钥 3 步自查:找出来、查 Git 历史、先换再删
作者: OpsMate 团队
更新于
命令请在你有权限的服务器和仓库上执行,路径与服务名按环境替换。
简短回答:先在服务器上只列出位置和配置项名字(不打印值):.env 和配置文件、Shell 历史、docker inspect 里的环境变量、compose 文件、crontab 和 systemd 服务。再用 gitleaks 或 trufflehog 扫一遍 Git 仓库和全部历史。查到的密钥先换新的、再删旧的;已经泄露过的密钥,删提交、改写历史都不能让它失效,只有更换才行。
数据库密码写在 .env 里,部署 token 留在 Shell 历史里,云账号 AccessKey 曾经提交进仓库又被删掉:这些都是小团队服务器上常见的明文密钥。本文给出一个 3 步自查:找出来、查清 Git 历史、按正确顺序处置,最后一节讲把日志和终端输出发给任何 AI 工具前怎么脱敏。
使用前先看这几条:
- 每条命令都有标注。【只读】 的命令只看不改;【改动】 的命令会修改文件、权限或仓库,每一处都写了回退办法,请一条一条来。第 1、2 步全部是只读。
- 检查命令都设计成只显示文件位置、行号和配置项名字,不显示值。不要为了“确认一下”把
.env整个cat出来,尤其不要在共享屏幕、录屏或开着 AI 对话的终端里。 - 把
/path/to/app换成你应用的实际目录,YOUR_CONTAINER换成容器名。 - 文中出现的
EXAMPLE_PASSWORD、CHANGE_ME_EXAMPLE之类都是示例占位,不是真实密钥。
1. 服务器上:明文密钥藏在哪
【只读】 .env 文件和配置项名字:
sudo find /root /home /srv /opt /var/www /etc -xdev \( -name '.env' -o -name '.env.*' -o -name '*.env' \) -type f -not -path '*/node_modules/*' 2>/dev/null
sudo grep -oE '^(export +)?[A-Za-z_][A-Za-z0-9_]*=' /path/to/app/.env
- 第一条列出
.env类文件的位置。以.example或.sample结尾的一般只是模板,但也要确认里面没有真值。 - 第二条只打印某个文件里的配置项名字,比如
DB_PASSWORD=,不显示值。
【只读】 配置文件里写死的密码和 token(只列文件名):
sudo rg -l -i --hidden --no-ignore -g '!node_modules' -g '!*.log' '(password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[:=]' /etc /srv /opt /var/www /home /root 2>/dev/null
sudo grep -rIlEi --exclude-dir=node_modules '(password|passwd|secret|token|api[_-]?key|access[_-]?key)[[:space:]]*[:=]' /etc /srv /opt /var/www /home /root 2>/dev/null
两条做同一件事,有 rg(ripgrep)用第一条,更快;没有就用第二条。-l 只列出命中的文件名,不打印内容。命中很多是正常的,比如配置模板和注释;重点看你自己应用的目录。想看某个文件命中了哪些配置项名字,而不看值:
sudo grep -noiE '[A-Za-z0-9_]*(password|passwd|secret|token|api_?key)[A-Za-z0-9_]*' /path/to/app/config.yml
【只读】 私钥和各类工具的凭证文件:
sudo grep -rlE -- '-----BEGIN ([A-Z]+ )?PRIVATE KEY-----' /root /home /srv /opt /var/www /etc 2>/dev/null
sudo ls -la /root/.git-credentials /root/.netrc /root/.aws/credentials /root/.docker/config.json /home/*/.git-credentials /home/*/.netrc /home/*/.aws/credentials /home/*/.docker/config.json 2>/dev/null
- 第一条列出含私钥的文件。
/etc/ssl/private下的网站证书私钥、/etc/ssh/ssh_host_*_key主机私钥是正常的,看权限对不对就行;放在网站目录或应用目录里的私钥要查清楚。 - 第二条看常见的凭证文件在不在、谁能读:
.git-credentials是明文保存的 Git 密码或 token,.netrc也是明文,.docker/config.json里可能有镜像仓库的登录信息。
【只读】 Shell 历史里敲过的密码(只看文件名和行号):
sudo grep -nEi '(password|passwd|secret|token|api[_-]?key)=|mysql .*-p[^[:space:]]|curl .*(-u |authorization:)|export [A-Z_]*(KEY|TOKEN|SECRET|PASSWORD)' /root/.bash_history /home/*/.bash_history /root/.zsh_history /home/*/.zsh_history 2>/dev/null | cut -d: -f1,2
cut -d: -f1,2 只保留“文件名:行号”,不显示命令本身。常见来源:mysql -pXXX、curl -u user:XXX、export API_TOKEN=XXX。
【只读】 Docker 容器的环境变量(只看名字):
for c in $(docker ps -aq); do
echo "== $(docker inspect --format '{{.Name}}' "$c")"
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$c" | cut -d= -f1 | grep -Ei 'pass|secret|token|key|dsn|url'
done
传给容器的环境变量,任何能执行 docker inspect 的人都能看到明文值,能用 docker 命令基本等于有 root 权限。同理,构建镜像时用 ENV 或 ARG 写进去的密钥,会留在镜像层里,docker history --no-trunc 能看到,这条的输出不要外传。
【只读】 compose 文件里直接写的值:
sudo find /root /home /srv /opt -xdev \( -name 'docker-compose*.yml' -o -name 'docker-compose*.yaml' -o -name 'compose.yml' -o -name 'compose.yaml' \) -not -path '*/node_modules/*' 2>/dev/null
sudo grep -Ei '(password|secret|token|api_?key)[A-Za-z0-9_]*[[:space:]]*[:=]' /path/to/app/docker-compose.yml | grep -vc '\${'
第二条数一数“带密码类名字、又没有用 ${变量} 引用”的行数。结果是 0 说明值都从 .env 或别处引用;大于 0 说明有值直接写在 compose 文件里,而 compose 文件往往会进 Git 仓库。
【只读】 crontab 和 systemd 服务(只看文件名和行号):
sudo grep -nEi '(password|passwd|secret|token|api_?key|authorization)' /etc/crontab /etc/cron.d/* /var/spool/cron/crontabs/* /var/spool/cron/* 2>/dev/null | cut -d: -f1,2
sudo grep -rnEi '^(Environment=.*(pass|secret|token|key)|EnvironmentFile=)' /etc/systemd/system /lib/systemd/system 2>/dev/null | cut -d: -f1,2
- 定时任务里常见
curl -H 'Authorization: ...'或带密码的数据库备份命令。 - systemd 服务文件里的
Environment=,系统上的任何用户都能用systemctl show看到值。EnvironmentFile=指向的文件本身没问题,要看的是那个文件的权限。
【只读】 谁能读这些文件:
sudo find /root /home /srv /opt /var/www -xdev \( -name '.env' -o -name '.env.*' -o -name '*.pem' -o -name '*.key' \) -type f \( -perm -o=r -o -perm -g=r \) -exec ls -l {} + 2>/dev/null
列出同组或所有人可读的密钥类文件。权限显示为 -rw-r--r-- 的 .env,意味着服务器上任何账号、任何被攻破的进程都能读到它。还要确认 .env 不在网站根目录下(例如 /var/www/html/.env),否则可能直接被人从浏览器下载。
这一步不能说明:密钥有没有已经被别人看到或拷走。它只告诉你密钥现在放在哪、谁能读。
2. 代码仓库:现在的文件和全部历史
在服务器上部署用的仓库目录里,以及你自己电脑上的仓库里,都做一遍。
【只读】 哪些敏感文件被 Git 跟踪了:
cd /path/to/app
git ls-files | grep -Ei '(^|/)\.env($|\.)|\.pem$|\.key$|id_rsa|id_ed25519|credentials|\.p12$'
git check-ignore -v .env || echo ".env 没有被 .gitignore 忽略"
- 第一条列出已经提交进仓库的敏感文件。
.env.example这类模板可以留,但要确认里面是占位值。 - 第二条检查
.env是否被.gitignore忽略。注意:.gitignore只对还没被跟踪的文件生效,已经提交过的文件,加进.gitignore也照样留在仓库和历史里。
【只读】 用扫描工具查当前文件和全部历史(推荐):
gitleaks git --redact -v .
gitleaks dir --redact -v .
trufflehog git file://. --no-verification
gitleaks git扫描全部提交历史,gitleaks dir扫描当前目录里的文件(包括没提交的)。--redact让输出里不显示密钥本身,只显示文件、提交和规则名。- trufflehog 默认会拿查到的密钥去对应服务商的接口验证是否有效,这会产生对外网络请求。只想在本机扫描时加
--no-verification;想知道哪些还有效、必须马上换,再去掉这个参数。 - 两个工具都要先安装,见文末参考链接。工具有漏报也有误报,结果是线索,不是结论。
【只读】 不装工具时,用 git log 粗查:
git log --all -i -G '(password|secret|token|api_?key)[[:space:]]*[:=]' --format='%h %ad %an %s' --date=short
git log --all --format='%h %ad %s' --date=short -- .env '*.pem' '*.key'
git show --stat COMMIT_ID
- 第一条列出“改动内容里出现过密码类赋值”的提交,只显示提交号、日期、作者和说明,不显示内容。
- 第二条列出动过
.env、.pem、.key文件的提交,包括后来已经删除的。 git show --stat看某个提交改了哪些文件。要看具体内容可以用git log -p --all -- 文件名,但输出里就是明文值,只在你自己的终端里看,不要截图、粘贴或发给 AI。
如果仓库在 GitHub 等平台上,同时看一下平台自带的密钥扫描提醒,以及流水线(CI)的变量和日志里有没有打印过密钥。
3. 处置:先换新的,再删旧的
第 1、2 步找到的每一个密钥,都按这个顺序处理。本步的命令除特别标注外都是 【改动】。
第一件事:更换(轮换)密钥。 在服务商或数据库里生成新的密码或 token,填到应用用它的地方,重启应用,确认功能正常,最后停用旧的。旧的停用之前,随时可以切回去。
- 已经泄露过的密钥,只能靠更换解决。 提交进过仓库(哪怕后来删了)、出现在共享屏幕或聊天记录里、被前同事或外包看过,都算泄露。删掉提交、改写 Git 历史,都不能让一个已经被别人拷走的密钥失效;仓库的克隆、fork、CI 缓存里都可能还有。
- 一次只换一个,挑业务少的时段。换会话密钥、签名密钥通常会让所有用户重新登录;支付、短信这类第三方密钥换错了,回调会失败。哪些密钥换完还要改哪里,可以参考 外包或前员工走了,服务器权限怎么收回? 第 5 步的表格。
第二件事:收紧权限。 先记下原来的权限,方便回退:
stat -c '%a %U:%G %n' /path/to/app/.env
sudo chown APP_USER:APP_USER /path/to/app/.env
sudo chmod 600 /path/to/app/.env
- 第一条是 【只读】,记下原来的权限和属主,例如
644 root:root。 - 后两条是 【改动】:把文件交给运行应用的账号(
APP_USER),并改成只有它能读写。 - 改完重启应用并确认它还能读到配置。回退: 用第一条记下的值执行
sudo chmod 644 …和sudo chown root:root …。 .git-credentials、.netrc、私钥文件同样改成600。
第三件事:把密钥从会进仓库、会被人看到的地方挪走。
- 【改动】 已经提交的
.env:先把.env写进.gitignore,再执行git rm --cached .env,从此不再跟踪(本地文件不会被删除),然后提交。回退:git add -f .env。这只影响以后的提交,历史里仍然有,所以里面的密钥必须已经换过。 - compose 文件里直接写的值,改成从
.env引用${DB_PASSWORD},或者用 compose 的secrets。 - systemd 服务里的
Environment=改成EnvironmentFile=指向一个600权限的文件,或者用LoadCredential=。改完执行sudo systemctl daemon-reload再重启服务。回退: 把原来的服务文件备份放回去,再daemon-reload。 - 团队规模大一些,可以用云厂商的密钥管理服务或 Vault 这类工具,应用启动时再取。
- Shell 历史里的密码:密码先换掉,再用编辑器删掉那几行。还开着的 Shell 退出时会把内存里的历史写回文件,所以先退出其他会话再改。以后需要临时输入密码,用
read -s VAR交互输入,而不是写在命令里。
最后(可选):清理 Git 历史。 密钥都换过之后,可以用 git filter-repo 把敏感文件从历史里移除。这一步会改写全部提交号,需要强制推送,所有协作者要重新克隆,做之前先备份整个仓库。它是清理,不是补救:做不做都必须先完成更换。 具体做法见文末 GitHub 的官方说明。
把日志和终端输出发给 AI 之前,先脱敏
不管用哪个 AI 工具,发出去的日志和命令输出就离开了你的服务器。排查时最常带出密钥的是:错误日志里的数据库连接串、开了调试模式打出的请求头、env / printenv / docker inspect 的输出,以及 set -x 的脚本日志。
要去掉的内容:
- 连接串里的密码:
postgres://app:EXAMPLE_PASSWORD@db.example.com/app - 名字带
PASSWORD、SECRET、TOKEN、API_KEY、ACCESS_KEY的配置项的值 Authorization: Bearer …/Basic …请求头- 私钥块(
-----BEGIN … PRIVATE KEY-----到END) - JWT(以
eyJ开头、由两个点分成三段的长字符串)、各家服务商格式的 token - 客户邮箱、手机号,以及内网地址、主机名、业务表名
【只读,另存一份新文件,不改原文件】 一个 sed 例子,把上面几类替换成占位:
sed -E \
-e '/-----BEGIN [A-Z ]*PRIVATE KEY-----/,/-----END [A-Z ]*PRIVATE KEY-----/c [REDACTED_PRIVATE_KEY]' \
-e 's#(://[^:/@[:space:]]+:)[^@[:space:]]+@#\1[REDACTED]@#g' \
-e 's/(Authorization:[[:space:]]*(Bearer|Basic|Token)[[:space:]]+)[^[:space:]]+/\1[REDACTED]/Ig' \
-e 's/((password|passwd|secret|token|api[_-]?key|access[_-]?key)[A-Za-z0-9_]*"?[[:space:]]*[:=][[:space:]]*"?)[^",}[:space:]]+/\1[REDACTED]/Ig' \
-e 's/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/[REDACTED_JWT]/g' \
-e 's/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/[EMAIL]/g' \
-e 's/\b([0-9]{1,3}\.){3}[0-9]{1,3}\b/[IP]/g' \
app-error.log > app-error.redacted.log
效果示例:DB_PASSWORD=CHANGE_ME_EXAMPLE 变成 DB_PASSWORD=[REDACTED],postgres://app:EXAMPLE_PASSWORD@db.example.com/app 变成 postgres://app:[REDACTED]@db.example.com/app。这是 GNU sed 的写法(常见 Linux 发行版自带);把最后一行换成管道也可以,例如 docker logs --since 30m YOUR_CONTAINER 2>&1 | sed -E …。
脱敏完再自查一遍,下面这条没有输出才算干净:
grep -nEi 'password|secret|token|api_?key|BEGIN .*PRIVATE KEY|eyJ' app-error.redacted.log | grep -v REDACTED
正则只能兜底,不能保证一个不漏:自定义格式的密钥、写在别处的客户信息它都认不出。最可靠的办法是一开始就用第 1 步那种“只显示名字”的命令,不让值出现在屏幕上。
如果你用的是 OpsMate:每次让 AI 帮忙看,终端里最近的输出都会一起发到云端 AI,敏感信息请先去掉。
注意:clear 不会清掉发给 AI 的内容,要清就断开再重连终端。常见格式的密钥在发送前会被自动替换,但不能只靠它。最好的办法是一开始就别把密钥打在终端里。
在 OpsMate 里怎么做
OpsMate(itops.sh)把 SSH 终端和 AI 放在同一个服务器页面,本文的命令都可以直接在终端里手敲。未通过安全检查的命令会被拦截且不会执行。低风险修复默认自动执行,每一步都会记录。每次让 AI 帮忙看,终端里最近的输出都会一起发到云端 AI,敏感信息请先去掉。OpsMate 自己会做什么、不会做什么,以官网 常见问题 为准。
说明性示例
说明性示例(不是真实客户案例):一个三人团队的 VPS 上跑着 Node 应用。第 1 步查到 /srv/app/.env 权限是 644,Shell 历史里有两行 mysql -p…,compose 文件里有 1 行直接写的 POSTGRES_PASSWORD。第 2 步 gitleaks git 报出一年前的一个提交里有 .env,后来已经删除。团队先在数据库里给应用账号设新密码、更新 .env 和 compose 引用、重启、下单测试;再在邮件服务商后台生成新 API 密钥替换旧的,最后停用旧密钥。然后把 .env 改成 600、属主改成应用账号,删掉 Shell 历史里那两行。历史里那个旧 .env 里的密钥都已失效,团队决定暂不改写 Git 历史。这些不能证明:旧密钥在失效之前没有被别人用过。要查有没有被滥用,得看数据库和服务商那边的访问记录。
AI 排查提示词
我在自查这台服务器和 /path/to/app 仓库里有没有明文密钥。请先只做检查、不做任何改动:列出 .env 和配置文件、Shell 历史、Docker 容器环境变量、compose 文件、crontab、systemd 服务里可能含密码、token、API 密钥的位置,以及这些文件的权限和属主;再检查 Git 是否跟踪了 .env、私钥这类文件,历史里有没有出现过。只输出文件路径、行号和配置项名字,绝不输出任何值。不要修改、移动或删除文件,不要改权限,不要执行改写 Git 历史的命令。如果需要处置,只把建议步骤、影响和回退方法列给我,由我自己执行。
常见问题
密钥不小心提交到 GitHub 了,删掉那次提交够吗?
不够。只要仓库公开过、被别人克隆过,或者推送到平台后有人看过,就要当作已经泄露:先在服务商那边更换密钥,再考虑清理历史。删提交、改写历史只能防止以后有人在仓库里看到它,不能让已经被拷走的旧密钥失效。
密钥放在服务器的 .env 文件里安全吗?
对小团队来说是常见做法,前提是:权限为 600、属主是运行应用的账号、不在网站根目录下、不进 Git 仓库。团队变大、服务变多时,再考虑云厂商的密钥管理服务。权限怎么查、怎么改,见本文第 1 步和第 3 步。
把日志发给 AI 帮忙排查之前,要去掉哪些信息?
至少去掉密码、token、API 密钥、连接串里的密码、Authorization 请求头、私钥,以及客户邮箱、手机号、内网地址。可以用本文的 sed 命令先替换一遍,再人工过一眼。
试用
每月免费 500 次 AI 调用,服务器数量不限。桌面端默认把 SSH 凭证保留在本机。
参考
- Gitleaks — 扫描 Git 仓库和目录里的密钥
- TruffleHog — 扫描并(可选)验证泄露的凭证
- GitHub:从仓库中删除敏感数据 — git filter-repo 的用法和注意事项
- Docker Compose:使用 secrets — 不把密码直接写进 compose 文件
- systemd.exec(5) — EnvironmentFile= 与 LoadCredential=
相关指南
需要帮助理解排查结果?
OpsMate(itops.sh)帮助开发者和运维人员用 AI 辅助排查,由你核对证据。每次让 AI 帮忙看,终端里最近的输出都会一起发到云端 AI,敏感信息请先去掉。
免费开始 本地凭证桌面端