一句话总结

一次 Git 用户迁移操作,意外暴露了 HTTPS Token 明文存储的安全隐患,最终通过 SSH ED25519 密钥 + git filter-branch 历史重写 + 多层清理,完成从「裸奔」到「可控」的凭证安全加固。


背景:一次看似简单的用户切换

团队接手了一个已有 Git 仓库的招采网络投标系统项目,代码托管在 GitHub。项目本身已经完成了 5 次历史提交,但在检查配置时发现了一个问题:所有历史提交的作者都是上一个维护者的邮箱 old-user@company.com,而我们需要将所有提交归属切换到新的 GitHub 账号下。

这看起来像一个简单的 git config 操作,实际上却引发了一连串安全问题的暴露。


第一阶段:切换用户配置

变更 Git 本地身份

1
2
git config user.name "new-user"
git config user.email "new-user@example.com"

这不是什么复杂操作。两条命令执行后,后续的 commit 都会带上新的身份信息。

问题来了:历史提交怎么办?

1
2
3
4
5
6
$ git log --all --format='%an %ae'
old-user old-user@company.com
old-user old-user@company.com
old-user old-user@company.com
old-user old-user@company.com
old-user old-user@company.com

5 条历史提交,全是旧身份。如果只是继续提交,仓库里会永远保留这两个人的混合记录。对于代码审计和归属确认来说,这是不可接受的。我们需要彻底清除旧用户的所有痕迹


第二阶段:重写 Git 历史

git filter-branch —— 一把手术刀

1
2
3
4
5
6
git filter-branch -f --env-filter '
export GIT_AUTHOR_NAME="new-user"
export GIT_AUTHOR_EMAIL="new-user@example.com"
export GIT_COMMITTER_NAME="new-user"
export GIT_COMMITTER_EMAIL="new-user@example.com"
' -- --all

这个命令遍历了仓库的每一条 commit,把作者和提交者信息全部替换为新账号。-f 是因为之前执行过一次需要强制覆盖备份,--all 确保覆盖所有分支。

清理 filter-branch 的备份引用

执行完毕后,git log --all 仍然显示旧记录。原因在于 filter-branch 会在 refs/original/ 下保留原始引用的备份:

1
2
3
$ git for-each-ref --format='%(refname) %(authoremail)' refs/original/
refs/original/refs/heads/main old-user@company.com
refs/original/refs/remotes/origin/main old-user@company.com

这些备份引用必须手动清除:

1
2
3
4
5
6
7
8
9
# 删除备份引用
git update-ref -d refs/original/refs/heads/main
git update-ref -d refs/original/refs/remotes/origin/main

# 清理 reflog(引用日志也会记录旧身份)
git reflog expire --expire=now --all

# 垃圾回收,彻底清除不可达对象
git gc --prune=now --aggressive

验证结果:

1
2
$ git log --all --format='%an %ae'
new-user new-user@example.com ← 仅剩一条干净记录

至此,历史重写完成,旧用户的所有痕迹从 Git 对象数据库中彻底抹除。


第三阶段:HTTPS Token 推送失败——安全隐患浮出水面

症状:push 超时

准备强制推送到 GitHub 时,遇到了一个奇怪的问题:

1
2
$ git push --force origin main
# 超时,无任何响应

使用 curl 验证网络和认证:

1
2
3
4
5
6
7
# Token 本身有效
$ curl -H "Authorization: token ghp_xxxxx" https://api.github.com/user
200 OK

# 直接访问 GitHub 超时
$ curl -v https://github.com
# 超时,但 token 验证却正常

这说明问题不在 Token 本身,而在网络层面。

切换到 SSH:更安全的替代方案

与其Debug网络问题,不如借机升级认证方式。HTTPS Token 认证有几个固有问题:

HTTPS Token 的缺陷 SSH 密钥的优势
Token 明文存在 ~/.git-credentials 私钥存在 ~/.ssh/,可设密码保护
泄露后攻击者无需任何额外因素即可操作 私钥文件权限 600 阻止其他用户读取
Token 权限通常是 repo 全范围 每个密钥可绑定不同账号,权限隔离
通过网络传输(虽然 HTTPS 加密) 基于公私钥挑战-应答,私钥从不出网

第四阶段:SSH 密钥配置踩坑全记录

生成新的 ED25519 密钥

1
ssh-keygen -t ed25519 -C "new-user@example.com" -f ~/.ssh/id_ed25519_github

选择 ED25519 而非 RSA 的理由:

  • 安全性:256 位 ED25519 ≈ 3072 位 RSA,但抗侧信道攻击能力更强
  • 性能:密钥生成和签名验证比 RSA 快一个数量级
  • 体积:公钥仅 68 字符,私钥仅 48 字节,比 RSA 短得多

将公钥 ~/.ssh/id_ed25519_github.pub 添加到 GitHub → Settings → SSH and GPG keys。

坑:旧密钥抢答

验证 SSH 连接时出现了意外结果:

1
2
$ ssh -T git@github.com
Hi YEI0907! You've successfully authenticated...

明明配的是新密钥,为什么认证成了另一个账号?因为 SSH 客户端默认会逐个尝试 ~/.ssh/ 下的所有私钥,而之前存在一个 id_rsa 私钥,它被优先匹配了。

解决方法:~/.ssh/config + IdentitiesOnly

1
2
3
4
5
6
# GitHub 专用密钥配置
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_github
IdentitiesOnly yes

关键参数说明:

参数 作用
IdentitiesOnly yes 禁止 SSH 使用 config 未列出的密钥文件,强制只使用 IdentityFile 指定的密钥
IdentityFile 精确指定该 Host 使用的私钥路径

坑:Shell ~ 路径转义

在自动化配置中,如果用代码写入 ~/.ssh/config,注意 ~ 可能不会被 Shell 展开。这次就遇到了:配置文件被写入了项目目录下的 ./~/.ssh/config,而不是 $HOME/.ssh/config

教训:在脚本中始终使用 $HOME 而非 ~

1
2
3
4
5
# 错误写法
echo "..." > ~/.ssh/config # ~ 在某些上下文中不被展开

# 正确写法
echo "..." > "$HOME/.ssh/config"

确认配置正确后:

1
2
$ ssh -T git@github.com
Hi new-user! You've successfully authenticated...

切换远程仓库 URL

HTTPS → SSH 的远程地址格式:

1
2
3
4
5
# 从 HTTPS
git remote set-url origin https://github.com/new-user/repo.git

# 改为 SSH
git remote set-url origin git@github.com:new-user/repo.git

第五阶段:强制推送与最终清理

强制推送

1
git push --force origin main

⚠️ --force 是危险操作——它会用本地历史覆盖远程历史。如果有协作者基于旧的远程历史工作,他们的本地仓库会出现冲突。在工业环境中,更推荐 --force-with-lease

1
2
# 更安全的强制推送:如果远程有本地不知道的新提交,拒绝推送
git push --force-with-lease origin main

credential.helper 清理

最容易被忽略的一步——清除残留在 Git 配置中的凭证助手:

1
2
3
4
5
6
7
8
9
10
# 检查当前配置
$ git config --global credential.helper
store

# 这个 "store" 指向 ~/.git-credentials,即使切换到了 SSH,
# Token 仍然以明文形式躺在这个文件里

# 清除
git config --global --unset credential.helper
rm -f ~/.git-credentials

最终验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1. 确认远程 URL 是 SSH
$ git remote -v
origin git@github.com:new-user/repo.git (fetch)
origin git@github.com:new-user/repo.git (push)

# 2. 确认无 credential.helper
$ git config credential.helper
# (无输出)

# 3. 确认 ~/.git-credentials 已删除
$ cat ~/.git-credentials
cat: /home/user/.git-credentials: No such file or directory

# 4. 确认历史干净
$ git log --all --format='%an %ae'
new-user new-user@example.com

工业环境中的 Git 凭证管理最佳实践

1. 永远不要使用 HTTPS Token + credential.helper store

credential.helper = store 会把 Token 明文写入 ~/.git-credentials 文件。任何能读取该文件的程序或用户都能获取完整的仓库操作权限。

1
2
3
# 这等于把钥匙放在门垫下
$ cat ~/.git-credentials
https://ghp_xxxxxxxxxxxxxxxx:@github.com

本次事件中,~/.git-credentials 文件内容被误删后推送失败,从反面证明了这个 Token 确实是唯一的认证凭据——但也同时证明了任何接触到文件的人都可以用它操作仓库。

2. 使用 Token 时的最少权限原则

如果必须使用 Token(CI/CD 环境),务必配置最小 Scope:

Scope 何时需要 不需要时关闭
repo 读/写代码和 PR
workflow 触发 GitHub Actions 纯代码推送不需要
admin:public_key 管理 SSH 密钥 ❌ 永远不要给 CI
delete_repo 删除仓库 ❌ 绝对不要给

查看当前 Token 权限:

1
curl -s -I -H "Authorization: token ghp_xxxxx" https://api.github.com/user | grep x-oauth-scopes

3. CI/CD 环境中的凭证管理

环境 推荐方案
GitHub Actions 使用内置 secrets.GITHUB_TOKEN,自动过期,无需手动管理
Jenkins 使用 Credentials Plugin,不要在 pipeline 脚本中硬编码
Docker 构建 使用 --secret 挂载,避免 Token 残留到镜像层
开发机 SSH 密钥 + IdentitiesOnly yes,不用 credential.helper

4. SSH 密钥的安全加固清单

1
2
3
4
5
6
7
8
9
10
11
12
# ① 为私钥设置密码短语
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_github -N "your-passphrase"

# ② 使用 ssh-agent 避免反复输入密码
eval $(ssh-agent -s)
ssh-add ~/.ssh/id_ed25519_github

# ③ 权限检查
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519_github
chmod 644 ~/.ssh/id_ed25519_github.pub
chmod 600 ~/.ssh/config

5. 历史重写:filter-branch vs git-filter-repo

git filter-branch 虽然能用,但 Git 官方已经不推荐使用它:

维度 filter-branch git-filter-repo
速度 慢(逐 commit 遍历) 快(批量操作)
安全性 可能产生损坏的历史 严格验证
备份清理 需手动 update-ref -d 自动清理
维护状态 基本冻结 活跃维护

现代替代方案:

1
2
3
4
5
6
# 安装
pip install git-filter-repo

# 替换作者信息
git filter-repo --name-callback 'return b"new-user"' \
--email-callback 'return b"new-user@example.com"'

6. 定期凭证轮换

  • Personal Access Token:每 30~90 天轮换一次
  • SSH 密钥:每 6~12 个月轮换,离职人员密钥立即吊销
  • GitHub Action Secrets:随项目阶段(开发/预发/生产)使用不同权限的 Token

学习路线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Git 用户配置

├─→ git filter-branch 历史重写
│ │
│ └─→ refs/original 清理 → reflog 过期 → git gc

├─→ HTTPS Token 认证
│ │
│ ├─→ credential.helper 机制 → .git-credentials 明文风险
│ └─→ OAuth Scope 审计(x-oauth-scopes)

└─→ SSH 密钥认证

├─→ ED25519 vs RSA 密钥算法选择
├─→ ~/.ssh/config Host 匹配规则
├─→ IdentitiesOnly 防止密钥混淆
└─→ ssh-agent 密码缓存

总结

这次操作从「换个用户名」开始,逐步暴露了 Git 认证链路中的多个安全死角:

  1. 明文 Token 存储~/.git-credentials 是最容易被忽略的泄露点
  2. 历史提交的身份归属git filter-branch 能解决,但 git-filter-repo 更好
  3. SSH 密钥的选择和隔离 — ED25519 + IdentitiesOnly 是最佳实践
  4. 清理远比配置重要 — 配置完 SSH 后删除 Token、清除 credential.helper 才是安全闭环

对于任何一个接手已有仓库的开发者来说,这套流程不仅是「改个配置」,而是一次完整的凭证安全检查——你永远不知道上一个维护者在系统里留下了哪些可以操作的入口。