#!/usr/bin/env bash # ============================================================ # 清除 git 历史中的明文凭据 # # 背景(2026-09-14 实测确认已入库并推送到公网可达远程): # 泄露位置见 docs/deployment/cicd/03-credential-incident.md 第 2 节 # # ⚠️ 强制推送会重写远程历史: # - 所有协作者必须重新 clone(旧本地仓库 push 会把泄露内容带回来) # - 若 Gitea 上有未合入的 PR / fork,会失效 # - 建议:先轮换凭据(rotate-credentials.sh),再清史,最后强制推送 # # 前置:pip install git-filter-repo (或 brew install git-filter-repo) # # ⚠️ 本脚本【不】内置泄露明文。曾踩坑:把泄露值硬编码进本脚本的注释与 # 替换规则里,一旦提交等于把泄露物重新入库一次,清史变成滚雪球。 # 替换规则从仓库外文件读取(默认 ~/.novalon-purge-secrets.txt)。 # # 用法: # SECRETS_FILE=~/.novalon-purge-secrets.txt \ # ./purge-git-history.sh --dry-run # 只扫描并列出命中,不改写 # ./purge-git-history.sh # 执行清史(会先做 mirror 备份) # ./purge-git-history.sh --no-push # 清史但不强推,留自己复核 # ============================================================ set -Eeuo pipefail DRY_RUN=0 NO_PUSH=0 for a in "$@"; do case "$a" in --dry-run) DRY_RUN=1 ;; --no-push) NO_PUSH=1 ;; esac done REPO_DIR="${REPO_DIR:-$(cd "$(dirname "$0")/../../.." && pwd)}" BACKUP_DIR="${BACKUP_DIR:-$REPO_DIR/.workbuddy/tmp/git-purge-backup}" REMOTE="${REMOTE:-origin}" BRANCH="${BRANCH:-$(git -C "$REPO_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || echo main)}" SECRETS_FILE="${SECRETS_FILE:-$HOME/.novalon-purge-secrets.txt}" # 需要整体删除的文件(含明文配置,且本身不该入库) FILES_TO_DROP=( "scripts/setup-cicd/cicd.config" ) log() { echo "[purge] $*"; } die() { echo "[purge] ❌ $*" >&2; exit 1; } cd "$REPO_DIR" || die "无法进入仓库: $REPO_DIR" [[ -d .git ]] || die "不是 git 仓库: $REPO_DIR" # ============================================================ # 读取替换规则(格式: 明文==>***REMOVED*** ,每行一条) # ============================================================ if [[ ! -s "$SECRETS_FILE" ]]; then cat >&2 < $SECRETS_FILE <<'EOF' <泄露明文1>==>***REMOVED*** <泄露明文2>==>***REMOVED*** <泄露明文3>==>***REMOVED*** EOF chmod 600 $SECRETS_FILE 具体哪些值:见 docs/deployment/cicd/03-credential-incident.md 第 2 节列出的 文件与变量名(cicd.config 的三个值 + setup-registry-auth.sh 的 gto_ secret)。 创建后重新运行本脚本。 EOF exit 1 fi # 提取明文值用于扫描命中 mapfile -t SECRET_VALUES < <(sed 's/==>.*$//' "$SECRETS_FILE" | grep -v '^[[:space:]]*$') if (( ${#SECRET_VALUES[@]} == 0 )); then die "替换规则文件为空或格式错误: $SECRETS_FILE" fi log "已加载 ${#SECRET_VALUES[@]} 条替换规则(值不回显)" # 构造 git grep 的 -e 参数数组 GREP_ARGS=() for v in "${SECRET_VALUES[@]}"; do GREP_ARGS+=( -e "$v" ) done scan_hits() { # shellcheck disable=SC2086 git grep -l -F "${GREP_ARGS[@]}" $(git rev-list --all) -- 2>/dev/null | wc -l | tr -d ' ' } # ---------- 扫描命中 ---------- log "----- 扫描历史中的命中 -----" git log --all --oneline -- "${FILES_TO_DROP[@]}" scripts/setup-registry-auth.sh 2>/dev/null | head -20 || true HIT_COUNT=$(scan_hits) log "历史中含泄露凭据的 blob 数: $HIT_COUNT" if (( DRY_RUN == 1 )); then log "dry-run 结束,未做任何改写。" exit 0 fi if [[ "$HIT_COUNT" == "0" ]]; then log "未发现命中,无需清史。" exit 0 fi # ---------- 备份 ---------- mkdir -p "$BACKUP_DIR" MIRROR="$BACKUP_DIR/$(basename "$REPO_DIR")-$(date +%Y%m%d_%H%M%S).git" log "----- 备份镜像到 $MIRROR -----" git clone --mirror "$REPO_DIR" "$MIRROR" || die "备份失败,已中止(不冒险改写)" log "✅ 备份完成" # ---------- 工具检查 ---------- if command -v git-filter-repo >/dev/null 2>&1; then TOOL="filter-repo" elif python3 -c "import git_filter_repo" >/dev/null 2>&1; then TOOL="python-module" else die "未找到 git-filter-repo。安装: pip install git-filter-repo 或 brew install git-filter-repo" fi # ---------- 二次确认 ---------- echo echo "⚠️ 即将重写 git 历史并强制推送到 $REMOTE。" echo "⚠️ 所有协作者必须重新 clone,未合入的 PR 可能失效。" read -r -p "输入 'PURGE' 继续: " C if [[ "$C" != "PURGE" ]]; then log "未确认,已中止。" exit 1 fi # ---------- 执行清史 ---------- log "----- 执行清史(工具: $TOOL) -----" if [[ "$TOOL" == "filter-repo" ]]; then git filter-repo --replace-text "$SECRETS_FILE" \ $(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \ --force else python3 -m git_filter_repo --replace-text "$SECRETS_FILE" \ $(printf -- '--path %s --invert-paths ' "${FILES_TO_DROP[@]}") \ --force fi log "✅ 历史已重写" # ---------- 本地清理 ---------- log "----- 本地 GC -----" git reflog expire --expire=now --all git gc --prune=now --aggressive log "✅ 本地对象已清理" # ---------- 校验 ---------- log "----- 校验:重新扫描是否还有残留 -----" REMAIN=$(scan_hits) if [[ "$REMAIN" != "0" ]]; then die "仍有 $REMAIN 处残留!请检查 .git/packed-refs 或其他分支。备份在: $MIRROR" fi log "✅ 无残留" # ---------- 推送 ---------- if (( NO_PUSH == 1 )); then log "--no-push:已跳过强制推送。复核后手动执行:" log " git push origin --force --all && git push origin --force --tags" else read -r -p "确认强制推送到 $REMOTE ? (yes/N): " P if [[ "$P" == "yes" ]]; then git push "$REMOTE" --force --all git push "$REMOTE" --force --tags log "✅ 已强制推送" cat <