Files
MailUI4Agents/.githooks/pre-push
JianFeeeee cbfc3bdde7 fix(安全): 构建产物出库 + pre-push 加**按内容**的第二道闸
两个 36MB 的 .hap 被 f51c9c8(一个标题为「workspace 谓词抽成共享构造器」的
重构提交)顺手带进版本库,而它们**内嵌 AGC 配置真身**。

## 实测确认(不是推测)

用 python zipfile 打开两个 hap,逐字段核对形状(不打印值):
  resources/rawfile/agconnect-services.json
    client.client_id         HEX len=19
    code.code1..code4        HEX len=32  ×4   ← api_key 信封
    oauth_client.client_id   HEX len=19
    app_info.app_id          HEX len=19

`git merge-base --is-ancestor f51c9c8 origin-https/main` 判否
⇒ **仅本地、尚未推送**(本仓镜像是 public / 匿名可 clone)。

## 为什么只 rm --cached 不够

`git rm --cached` 只把文件移出 index,**blob 仍躺在未推送的提交里** ——
下一次 `git push` 会连它一起发出去。所以必须同时堵"下一次"。

## 改法

① .gitignore 加 `*.hap` / `*.app` / `*.ipa`
② .githooks/pre-push 加第二道闸:**按内容**查,不按路径
   —— 原先只按路径拦 agconnect-services.json,而 hap 里它只是
      一个 zip 条目,逐条列举路径追不上产物形态。

判据形态:本次推送范围内**新增或修改**的构建产物(`*.hap/*.app/*.ipa`)
          → 内容里出现 `agconnect-services.json` 这个**条目名** ⇒ 拦。
用条目名而非凭证值:值会变而条目名稳定,且搜值需把凭证读进内存。

## ★ 这一格踩了三个坑,每个都让闸**静默放行**(都实测过)

① `git diff "a..b"` 取不到"该范围新增的文件" —— 它的语义是
   「工作区 vs b」,**干净工作区上恒为空**。第一版这么写,
   实测含真凭证的 hap 被放行、push exit=0。
   ⇒ 改 `git log --diff-filter=AM --name-only`(天生吃范围)。
② `git rev-parse "a..b:path"` 输出**两行**(blob sha + `^a`),
   赋进变量是多行值 ⇒ 后续 `cat-file` 失败 ⇒ 被 `|| blob_sha=""`
   兼掉 ⇒ 静默跳过。
   `git ls-tree "a..b"` 也报错(不接受范围)。
   ⇒ 正确形状:rev-list 取 tip → ls-tree <tip> 取 blob。
③ ★★ `grep -q` 接管道 + `set -o pipefail`:grep 命中即退出 ⇒ 上游
   `git cat-file` 收 SIGPIPE 退 **141** ⇒ pipefail 取各段合取 ⇒
   整条管道 141 ⇒ `if` 判假。**"找到凭证"被读成"没找到"。**
   实测 PIPESTATUS=141 0。
   失效方向恰好是**放行**,与本钩子"宁可推不出去"相反。
   ⇒ 去掉 `-q`,让 grep 读完整条流(最后一段自然是它的码)。

## 验证(四态,都实测过)

  含真凭证的 hap   ⇒ 拦,裸仓没收到 commit
  干净的 hap        ⇒ 放行(只有 example.json)
  AGC json 路径     ⇒ 仍拦(第一道没坏)
  删分支            ⇒ 不误拦
  commit-hygiene    ⇒ 绿
2026-09-28 08:26:15 +08:00

190 lines
11 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
#
# pre-push:**推送前**拦住"不该进版本库的文件"。
#
# ★ 为什么要有这个钩子,而不是只靠 `criteria-hygiene` 里那条判据:
# 那条判据读的是 **index**(`git ls-files`),它守的是"**不会被再加回来**",
# 不是"**不会被推出去**"。而 blob 恰恰躺在**未推送的提交**里 ——
# 也就是说 `git rm --cached` 之后,东西已经从 index 里消失了,
# 可是**历史里还有**,下一次 `git push` 会连它一起发出去,而**没有任何东西会红**。
# ⇒ "先 ignore + `rm --cached`、再 push"这条顺序**只活在提交信息里**,
# 而"只活在文字里的规则等于没有规则"正是我们加那条判据的理由。
# 判据是**泄露之后**响的闹钟;这个钩子是**不让它泄露**。两件事都要有。
#
# 这个钩子由 `deploy/install.sh --git-hooks` 装(`core.hooksPath` 指向 `.githooks`),
# 所以它**跟着仓库走**:换一台机器 clone 下来,装一次就都装上了。
#
# 退出码:0 放行,1 拦下(git 会中止 push)。**绝不返回 2** ——
# 钩子里非 0 一律中止推送,所以"钩子自己坏了"与"真的有违规"都会拦下来,
# 这是**故意的方向**:宁可推不出去,也不要静默推出去。
set -uo pipefail
# 不许进版本库的路径(**加新路径就加在这里**,别写进判据里到处复制)
FORBIDDEN=(
"client/harmony/entry/src/main/resources/rawfile/agconnect-services.json"
)
# 生效范围:推送的所有 ref 区间。钩子从 stdin 收到:
# <local ref> <local sha> <remote ref> <remote sha>
# 远端 sha 全 0 = 新分支 ⇒ 要查**整个可达历史**。
zero="0000000000000000000000000000000000000000"
declare -a RANGES=()
while read -r _lref lsha _rref rsha; do
# ★ **删除**用**全零** sha 表示,不是空串(原来是 `[ -z "${lsha:-}" ] && continue`)。
# 少了这一支的后果:删除时 lsha=全零、rsha=旧值 ⇒ 走 else ⇒ 拼出
# `旧sha..0000…` ⇒ `git log` 解析不了 ⇒ **exit 1 中止**。
# 实测(真 bare 仓):
# $ git push /tmp/bare-del.git --delete tmp
# pre-push: 无法解析范围 '6702cc2f…..0000000000000000000000000000000000000000' —— 中止推送
#
# 为什么这不是小事:**删除不发布任何东西**,所以这是**纯误红**;
# 而它恰恰因为"宁可推不出去"这条**正确的**方向而不可见 —— 看起来就像设计好的安全行为。
# 同族形状:**一道闸消费了不属于它管辖的东西**("到期闸把汇总一起吃掉"也是这个)。
# 附带效果:`install.sh --check` 会一直说"钩子已接",而实际上删分支/删 tag 全被挡。
#
# ★ 我第一步只验了"会泄露的范围 ⇒ 1、已推过的范围 ⇒ 0"——
# **两个方向都没覆盖"没有东西被发布"这一支**,而它正是坏的那一支。
# 和"只判下界"是同一条:**把边界少判了一侧**。
if [ -z "${lsha:-}" ] || [ "$lsha" = "$zero" ]; then
continue # 没有东西被发布(删除 / 空 ref)—— 放行
fi
if [ "$rsha" = "$zero" ]; then
# 新分支/新 tag:从根开始查(`--not --remotes` 会漏掉已被别的远端包含的情况)
RANGES+=("$lsha")
else
RANGES+=("${rsha}..${lsha}")
fi
done
if [ "${#RANGES[@]}" -eq 0 ]; then
exit 0 # 没有任何 ref 要推(例如只删远端分支)—— 放行
fi
problems=0
for path in "${FORBIDDEN[@]}"; do
for range in "${RANGES[@]}"; do
# `--all` 覆盖"这次推送带出去的所有提交里,这个路径是否被改动过"
if ! hits="$(git log --format=' %h %s' "$range" -- "$path" 2>/dev/null)"; then
echo "pre-push: 无法解析范围 '$range' —— 中止推送(宁可推不出去,也不要盲推)" >&2
exit 1
fi
if [ -n "$hits" ]; then
problems=1
{
echo ""
echo "✗ 这次推送会把 **$path** 的历史一起发出去。"
echo " 它是 AGC 客户端凭证,而本仓的镜像是**匿名可 clone 的公开项目**。"
echo " 涉及这些提交:"
echo "$hits"
echo ""
echo " 怎么处置(按顺序):"
echo " 1) 如果这些提交**已经**推上去过:按\"已泄露\"处理 —— 去 AGC 轮换,"
echo " 并且**先**在本地把这些提交改掉(rebase/filter-repo),再推。"
echo " 2) 如果还没推过:把涉及它的提交从待推范围里摘掉,或者先把历史里的 blob 清掉。"
echo " 3) 确认它**不在**要推的范围里之后,再 push。"
echo ""
echo " (这条钩子的存在理由:判据读的是 index,只证明\"不会再被加回来\";"
echo " 而 blob 还躺在未推送的提交里,只有推送前拦得住。)"
} >&2
fi
done
done
# ── 第二道:构建产物里内嵌 AGC 真身(2026-09-28 新增)────────────────────
#
# 为什么不能只靠上面的路径清单:**hap/ipa 之类的打包产物会把
# `agconnect-services.json` 原封不动打进去**,而它在产物里只是一个 zip 条目。
# 逐条列举路径追不上产物形态(下次换个构建目标又漏一次)。
#
# 实测(`git ls-files` 抓到的):两个 36MB 的 .hap,内含
# `resources/rawfile/agconnect-services.json`,其中 client.client_id /
# code.code1..4(4 个 32 位十六进制)/ oauth_client.client_id 全是真值。
#
# 口径:只看**本次推送范围内被新增/修改的二进制产物**,不扫全历史 ——
# ① 全历史扫描在大仓上很慢,而这道闸的目标是"拦住下一次手滑";
# ② 已经推出去的那部分由 `criteria-hygiene` 那条报警负责(泄露之后响的闹钟)。
#
# ★ 这里用 `git log --diff-filter=A --name-only` 而不是 `git diff`(2026-09-28 实测踩坑):
# `RANGES` 里的元素是「`rsha..lsha`」或(新分支时)**单个 `lsha`**。
# 把单个 sha 传给 `git diff` 并不会得到"这个范围里新增的文件"—— 它是
# 「工作区 vs 该 sha」的差异,**在干净工作区上恒为空** ⇒ 判据**静默失效**,
# 而失效的判据与通过的长得一模一样。(第一版就这么写的,实测:含凭证的 hap 被放行,
# push exit=0。)
# `git log` 才是"这些提交里动过什么"的那个读数,与第一道检查同源。
#
# 判据形态:**新增或修改的构建产物** + 在该提交的文件内容里搜
# `agconnect-services.json` 这个**条目名**。用条目名而不是凭证值本身,
# 因为值会变而条目名稳定,且搜值需要把凭证读进内存。
for range in "${RANGES[@]}"; do
if ! bins="$(git log --diff-filter=AM --name-only --format= "$range" -- '*.hap' '*.app' '*.ipa' 2>/dev/null)"; then
echo "pre-push: 无法解析范围 '$range'(查构建产物)—— 中止推送" >&2
exit 1
fi
bins="$(printf '%s\n' "$bins" | sort -u | grep -v '^$')"
[ -z "$bins" ] && continue
while IFS= read -r b; do
[ -n "$b" ] || continue
# ★★ 取 blob 必须先把范围解析成**单个 commit**,再 `ls-tree`(2026-09-28 实测两次踩坑):
#
# ① `git rev-parse "a..b:path"` 输出**两行**(blob sha + `^a`),
# 赋进变量是多行值,`cat-file` 失败而被 `||` 兼掉 ⇒ 静默放行。
# (手工喂真实待推范围给钩子,exit=0;而同一范围内 `git log` 明确列出了两个 hap。
# 判据"看起来在检查"而实际没执行 —— 与本仓 python-probe-shadowing 那笔同族。)
# ② 改用 `ls-tree` **也报错**:它不接受范围
# (`fatal: Not a valid object name a..b`)。
#
# ⇒ 正确形状:先取范围内**最后一个** commit(`git rev-list --max-count=1`),
# 再 `ls-tree <那个 commit>`。第一道检查用的是 `git log`(天生吃范围),
# 所以它没踩这个坑 —— **同一条数据,git 子命令的输入契约不一样**。
tip="$(git rev-list --max-count=1 "$range" 2>/dev/null | head -1)"
if [ -z "$tip" ]; then
echo "pre-push: 范围 '$range' 解析不出 commit(查构建产物)—— 中止推送" >&2
exit 1
fi
blob_sha="$(git ls-tree "$tip" -- "$b" 2>/dev/null | awk 'NR==1{print $3}')"
if [ -z "$blob_sha" ]; then
# 该 tip 上没有这个文件(可能在范围内被删了)—— 读不到就不当违规,但**说明**,
# 因为"没检查到"与"没问题"不是一回事。
echo "pre-push: 读不到 $b 在待推范围里的内容(可能已被删除)—— 未据此判红" >&2
continue
fi
# ★★ **必须扫全文件**(2026-09-28 实测修正):原先只取头尾各 4MB,
# 理由是"zip 的中央目录在尾部"。实测本仓真 hap 里那个条目在
# **byte 28,239,718 与 35,958,749**(文件共 35MB)—— 头 4MB 扫不到、
# 尾 4MB 也只勉强够到第二处。**第一版会把这个含真凭证的 hap 放过去。**
# ⇒ 改全量流式扫(grep -a 直接吃管道,不进内存)。
#
# 代价:每个 35MB 产物要读一遍。推送路径上、非热路径,可以接受。
# 真的嫌慢就改成 `grep -a -m1`(找到一个就停),语义不变且更快。
# ★★★ **不能用 `grep -q` 接管道**(2026-09-28 实测,方向与直觉相反):
# `grep -q` 找到就**立即退出**,于是上游 `git cat-file` 在写管道时收到 SIGPIPE
# ⇒ 它的退出码是 **141**。而本钩子开头有 `set -uo pipefail`(`pipefail` 把管道
# 判成取**各段非零的合取**)⇒ 整条管道变成 141 ⇒ `if` 判为**假**。
#
# 实测读数:命中时 `PIPESTATUS=141 0`(cat-file=141, grep=0)⇒ `if` 不成立。
# **即"找到了凭证"被读成"没找到"** —— 而失效方向恰好是**放行**,
# 与"宁可推不出去"的正确方向相反,是最坏的那种失效。
#
# ⇒ 不用 `-q`:让 grep 读完整条流、正常退出 0,最后一个命令自然是 grep 的码。
# (代价是扫完整个 35MB 而不是命中即停;推送路径上可接受。)
if git cat-file -p "$blob_sha" 2>/dev/null | grep -a 'agconnect-services\.json' >/dev/null; then
problems=1
{
echo ""
echo "✗ 这次推送会带上构建产物 **$b**,而它内嵌了 agconnect-services.json。"
echo " 那是 AGC 客户端凭证(api_key 信封),而本仓镜像是**匿名可 clone 的公开项目**。"
echo ""
echo " 怎么处置:"
echo " 1) 构建前把 AGC 真身从打包输入里去掉(用 .example.json 占位),"
echo " 或在构建产物生成后立刻 git rm --cached 掉。"
echo " 2) 已经进了要推的提交:用 filter-repo / rebase 把该 blob 清掉,"
echo " 确认这些提交**没有**被推过;推过了就按已泄露处理 —— 去 AGC 轮换。"
echo ""
echo " (这一格是**按内容**查的,不是按路径 —— 产物名会变,凭证的容器也会变。)"
} >&2
fi
done <<<"$bins"
done
exit "$problems"