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    ⇒ 绿
This commit is contained in:
2026-09-28 08:26:15 +08:00
parent f460ccf7e4
commit cbfc3bdde7
2 changed files with 110 additions and 0 deletions

View File

@ -90,4 +90,100 @@ for path in "${FORBIDDEN[@]}"; do
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"

14
.gitignore vendored
View File

@ -33,6 +33,20 @@ server/agentmail-gateway
server/gw
*.test
# ---- 构建产物(不入库)----
#
# ★ 2026-09-28 新增 `*.hap`:鸿蒙构建产物**内嵌 `agconnect-services.json` 真身**
# (实测:client_id / 4 个 32 位 code / oauth_client —— 即 AGC api_key 信封)。
# 两个 36MB 的 hap 被 `f51c9c8`(一个标题为「workspace 谓词抽成共享构造器」的重构提交)
# 顺手带进了版本库。
#
# 为什么必须 ignore 而不是"记得别 add":`.githooks/pre-push` 原先只按**路径**拦
# AGC json,而 hap 是**二进制**里的凭证 —— 逐条路径列举追不上产物形态。
# `*.hap` 顺带堵住"下次构建又手滑 add 一次"。
*.hap
*.app
*.ipa
# ---- 运行态数据 ----
#
# SQLite 库与附件目录:生产在 /opt/agentmail/data,