Files
novalon-website/infra/cicd/security/purge-git-history.sh
T
zhangxiang 4ab2f3cd8e chore(infra): 新增 Gitea+Jenkins CI/CD 部署与凭据整改
- infra/cicd:docker-compose(gitea/jenkins)、JCasC(凭据统一 ${ENV} 注入,无硬编码)、
  备份/恢复、健康检查、凭据轮换与 git 历史清除脚本
- docs/deployment/cicd:安装、高可用备份监控、凭据事故复盘
- .env.example 仅为占位模板;真实 .env 由 .gitignore 排除
2026-09-20 10:37:57 +08:00

190 lines
6.7 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<EOF
[purge] ❌ 未找到替换规则文件: $SECRETS_FILE
为避免把泄露明文写进仓库,本脚本不内置规则。请在【仓库外】创建(不要入 git):
cat > $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 <<EOF
================ 收尾(必做) =================
1. 通知所有协作者:rm -rf 本地仓库后重新 clone(旧仓库 push 会带回泄露内容)
2. 在 Gitea 上执行「仓库设置 → 维护 → 运行垃圾回收」,删除远程的旧对象
(否则旧 blob 仍可通过 commit sha 直接访问)
3. 检查是否有 fork / 镜像 / CI 缓存仍持有旧历史
4. 确认 rotate-credentials.sh 已执行完毕(清史不能替代轮换!)
5. 加 pre-commit 钩子防复发:gitleaks / trufflehog
6. 处置完毕后删除 $SECRETS_FILE
===============================================
EOF
else
log "已跳过推送。备份镜像保留在: $MIRROR"
fi
fi