仓库去痕迹(de-branding / 去个人化)
核心认知:痕迹有六处,grep 工作区只覆盖一处
只跑 grep -r 是不够的。品牌词/口令常以这些形式幸存:
| # | 藏身处 | 为什么容易漏 |
|---|---|---|
| 1 | 工作区文件内容 | 常规 grep 能发现 |
| 2 | 文件 / 目录名 | grep 内容扫不到文件名 |
| 3 | Git 历史 blob | 即使当前文件已改干净,历史提交里原文仍在 |
| 4 | reflog / packed-refs | 已"丢弃"的提交对象仍可读 |
| 5 | 提交信息 | 与文件内容无关,须单独扫 |
| 6 | 远端项目元信息 | description、分支名、标签名、MR 标题 —— 不在代码里 |
另外两类特殊位置:
- 依赖包名 / 常量:第三方 APP 包名(如
com.vendor.app)、品牌化的类名与 fixture 名,属于"标识符级"污染,改起来要连带改引用。 README标题与安装章节:常被忽略,但它决定"外人第一眼怎么看这个项目"。
先判定性质:不是所有命中都该改。业务数据快照(压测日志里的岗位名、测试 fixture 里的样例数据)属于被测系统的真实内容,保留并在报告里说明,别机械替换。
工作流
第 1 步:全量扫描,拿到清单
python scripts/scan_traces.py --local /path/to/repo --terms 品牌A,公司B --secrets
GitLab 远端(不依赖本地 clone,逐 blob 拉取,比 git clone 更可靠):
export GITLAB_HOST=http://gitlab.internal
export GITLAB_TOKEN=... # 需 api 或 read_api scope
python scripts/scan_traces.py --gitlab 123 --terms 品牌A --ref master
退出码 0 = 干净,1 = 有命中。扫描器本身要用真实样本回归过再用 —— 敏感模式的正则很容易漏(漏过 db_password 这类带前缀的键名、漏过键名带引号的写法)。
第 2 步:中性化替换
替换原则:示例就该长得像示例。
- 占位包名用
com.example.app,别留真实第三方包名 - 示例页面/用例用
demo_*前缀,类名DemoXxx - 标识符级改名要连带改:类名 → import → fixture 名 → 用例参数 → 常量名
- 把可变部分收敛到一处配置:例如页面选择器写
SEARCH_INPUT = f"id={APP_PACKAGE}:id/search_edit_text",
换目标应用只需改config里一个值。这同时消除了"标识符级污染"的复发可能
改完立刻回扫:
grep -rInE "品牌A|vendor|旧类名|旧fixture" . --exclude-dir=.git
第 3 步:清理历史
判断提交数再决定策略:
git rev-list --count HEAD
- 只有 1 个提交 → 最干净:
git commit --amend把内容 + 提交信息一起重写,历史里零残留。 - 多个提交(历史必须保留)→
git filter-repo --replace-text(推荐)或git filter-branch;改完所有人必须重新 clone。
推送用显式锚定的强推,比 --force 安全:
ORIG=$(git rev-parse origin/master) # 或 git ls-remote 取
git ls-remote origin refs/heads/master # 推前复核:远端 sha 是否仍等于 ORIG(确认无人推过)
[ "$(git ls-remote origin refs/heads/master | cut -f1)" = "$ORIG" ] || exit 1
git push --force-with-lease=refs/heads/master:$ORIG origin master
本地残留对象:
git reflog expire --expire=now --expire-unreachable=now --all
git gc --prune=now
第 4 步:清理非代码位置
# 项目 description(注意 + 会被 URL 编码吃成空格,必须用 --data-urlencode)
curl -X PUT -H "PRIVATE-TOKEN: $TOKEN" \
--data-urlencode "description=中性描述" \
"$GITLAB_HOST/api/v4/projects/$PID"
顺带检查:分支名、标签名、MR 标题、CI/CD 变量名、项目头像、Wiki。
第 5 步:远端复验
用扫描器的 --gitlab 模式跑一遍(第 1 步同款命令),必须 0 命中。
关键坑
-
force push 后旧提交仍在服务端:
GET /projects/:id/repository/commits/<旧sha>仍返回 200。Gitaly 的 gc 有保留期,POST /projects/:id/housekeeping是异步的、也不保证立即回收。要彻底清除须服务端git gc --prune=now(管理员执行)。向用户如实说明,别宣称"已彻底删除"。 -
单提交仓库优先 amend,不要为"留个记录"而多提交一个清理提交 —— 那样第一个提交里仍带着脏内容,等于没清。
-
别拿
git push --dry-run --force当权限探测:dry-run 不做服务端权限校验,永远显示 up-to-date,没有信息量。 -
某些 Windows 环境下重定向与 git 落盘会"幽灵写入":命令报成功、紧接着读却看不到(
git clone、curl -o、git fetch的远端跟踪引用都可能中招)。对策:远端校验走 API 逐文件拉取;本地 ref 用printf '%s\n' "$SHA" > .git/refs/remotes/origin/<branch>手写(手写的 ref git 能正常识别)。 -
curl -o <file>与> <file>都可能静默失败时,改用管道直传(curl -s ... | python -c "import sys,json; json.load(sys.stdin)"),别用中间文件。 -
改完记得同步记忆/文档:自己的变更记录里如果把旧品牌名当"从 X 改成 Y"写进去,等于把痕迹搬了个家。用"以第三方品牌命名的文件"这类泛化表述。
-
⚠️ 最容易骗过自己的坑:扫描器假阴性。 本次实测踩到两个,且都表现为"扫描通过、零命中",看起来一切正常:
-GET /repository/blobs/<sha>返回的是 JSON 包装{"size":N,"encoding":"base64","content":"<base64>"},不是文件原文。直接当文本扫,扫的是一串 base64 → 永不命中 → 误判"远端干净"。必须json.loads后再base64.b64decode。
- 列表端点分页(默认 20/页,per_page上限 100)。只取第一页 → 大仓库静默漏检。
对策(务必执行):任何"扫描通过"的结论,都要先用一个已知必定命中的词做阳性对照。同一命令、同一目标,只换关键词:
词A(已知存在,如某个真实函数名)→ 必须 FAIL
词B(待验证的品牌词) → 才是可信的 PASS
若词A也是 PASS,说明根本没读到内容,而不是"仓库干净"。同时检查脚本输出的"文件总数"是否为 0、"读取失败"计数是否为 0。
交付前的自检清单
- [ ] 工作区内容、文件名零命中
- [ ] 历史 blob、reflog、提交信息零命中
- [ ] 远端文件内容、提交信息、description、分支/标签零命中
- [ ] 业务数据里的同名词已判定并说明(保留 or 替换)
- [ ] 本地与远端 sha 一致,工作区无未提交改动
- [ ] 服务端孤儿对象状态已向用户说明