☰
AI 编程工具的 Git 仓库被加密传走了?用 marimo + cpolar 做一次本地可核验的代码快照审计
2026/9/27 7:29:03 网站建设 项目流程

AI 编程工具的 Git 仓库被加密传走了?用 marimo + cpolar 做一次本地可核验的代码快照审计

上周在掘金榜上刷到一篇复盘,标题很扎眼:《ZCode 把整个 Git 仓库加密上传到了阿里云 OSS》。作者把客户端扒开看了看,发现它把整个工作区打包加密直传对象存储,其中.git目录自己就占了八成多,而解密用的私钥只在云端——也就是说,本机连自己都被传走了什么都解不开。

我看完第一反应不是"这工具真坏",而是"那我这台机器上,到底有多少东西是值得被这么打包走的?"这个问题没法靠感觉回答。你得真有数字:.git占多大、里面的大文件缓存有多重、reflog 里躺着多少条你以为已经删掉的提交。

所以这篇不聊逆向,也不聊那个客户端的对错。我做的事很朴素:用 marimo 在本机搭一个只读的审计工作台,把某个本地仓库里"一旦被打包外传就亏大了"的部分统计清楚;再用 cpolar 开一条短时隧道,让不在同一个局域网的安全同事也能打开这份脱敏结论页复核一遍,看完立刻关隧道。

先说清楚边界,免得你想歪:

  • 审计对象只有我自己用脚本合成的样例仓库,不碰任何真实公司仓库、真实凭据、真实内网地址。
  • 我不打算、也无从解密第三方客户端上传的密文。这篇只做本地风险自查:提醒你哪些内容值得打包外传。
  • cpolar 隧道只承载脱敏后的聚合统计页(体积、占比、条数这类数字),短时开放,用完即关。

下面所有命令和返回,都是我在本机真实跑出来的。

图:本机 marimo 只读审计页 + cpolar 短时隧道,供异地同事复核脱敏结论的整体链路示意


1 先说清楚:这篇里 marimo 到底负责哪一段

marimo 是一个 reactive Python notebook。官方 README 的定位句很短:"A reactive Python notebook that's reproducible, git-friendly, and deployable as scripts or apps."——可复现、对 git 友好、能作为脚本或应用部署。

很多人的第一反应是"哦,又一个 Jupyter"。但这篇里我不用它画图、不用它做分析,看中的是它的三个性质,恰好一条一条对上审计场景:

第一,它的笔记本存成纯.py文件。Jupyter 的.ipynb是 JSON,git diff出来是一坨转义字符,你根本看不出改了哪行。marimo 的笔记本就是普通 Python 文件,评审的人直接git diff就能看懂检查逻辑有没有被偷改。审计工具本身可信,审计结果才可信。

第二,它能以只读应用模式跑起来。官方给了两条命令:marimo edit用来本地编辑,marimo run用来把笔记本当成只读 web app 启动,而且默认绑定127.0.0.1。这个"只读 + 只绑本机"的组合,正好是我要的:结论页给同事看,但他改不了任何东西,也不从公网侧暴露服务。

第三,它能在无界面的服务器上执行并导出结果。README 里写明 notebook 可以execute as a Python script,官方也提供marimo export系列命令。这意味着整条链路可以没有浏览器、没有账号、不依赖任何 LLM Key 就跑完,验证方式是 CLI 加 HTTP,是确定性的。

一句话总结这一节:marimo 在这篇里不是"数据分析工具",而是"一个可信、可读、可无界面复跑的本地审计工作台"。这个区分很重要,因为它决定了我们敢不敢把自己的仓库交给它去数。

图 1:marimo 的 reactive DAG 执行模型与本文“只读审计应用”定位示意


2 环境准备:装 marimo、确认 cpolar 和 git

这一节要装的东西很少。真正需要的是一个 Python 环境、一个 marimo、一个 git,外加一个已经装好的 cpolar。

先看版本,确认基础工具都在:

python3 --version # Python 3.14.3 git --version # git version 2.39.5 (Apple Git-154) cpolar version # cpolar version 3.3.18

装 marimo。官方 README 给的是最简一行:

pip install marimo

在 macOS 上如果pip指向的是受管理的环境,可以明确装到用户目录:

python3 -m pip install --user marimo # ... # Successfully installed marimo-0.24.2

装完确认一下:

marimo --version # 0.24.2

这里有个小提醒:marimo 的 CLI 会装到用户级 bin 目录(我这边是~/Library/Python/3.14/bin)。如果你敲marimo --version报 command not found,先把那个目录加进 PATH,而不是急着重装。

cpolar 这边,本机是 Homebrew 装的,控制台面板跑在127.0.0.1:9200:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:9200 # 200

9200 能返回 200,说明 cpolar 服务是活的。如果这一步打不开,先别往下走——后面隧道会失败,而你会在错误的地方找半天原因。cpolar 在 macOS 上的安装方式(Homebrew)和面板地址,官方下载页与文档里都写得很清楚,这里不重复搬运。


3 造一个合成样例仓库:审计对象只能是它

审计这件事最怕的就是"顺手扫了真实仓库"。所以第一步不是装工具,而是先造一个干净的、可控的、可以随便折腾的假仓库。

我把它写成一个可重跑的脚本,放在make-synthetic-repo.sh里。它做六件事,对应六种"值得被审计出来"的东西:

#!/usr/bin/env bash set -euo pipefail REPO="${1:-/tmp/audit-demo-repo}" rm -rf "$REPO"; mkdir -p "$REPO"; cd "$REPO" git init -q -b main git config user.email "audit@example.invalid" git config user.name "audit demo" git config commit.gpgsign false # 1) 少量受控源码:这是我们"以为"仓库里只有的东西 mkdir -p src docs for i in $(seq 1 5); do printf 'print("module %s")\n' "$i" > "src/mod_$i.py"; done printf '# demo project\n' > README.md git add -A && git commit -qm "init" # 2) 正常提交历史:写进 reflog 的轨迹 for c in 1 2 3 4 5 6; do printf 'print("rev %s")\n' "$c" >> src/mod_1.py git commit -qam "change $c" done # 3) 被 reset 丢弃的提交:你以为删了,其实还在 reflog 里 for r in $(seq 1 3); do printf 'print("temp %s")\n' "$r" >> src/tmp_rev.py git add -A; git commit -qm "temp $r" git reset -q --hard HEAD~1 done # 4) 模拟 LFS 大对象本地缓存(合成占位,不是真实 LFS 协议) mkdir -p .git/lfs/objects head -c 3000000 /dev/urandom > .git/lfs/objects/blob_a.bin head -c 1500000 /dev/urandom > .git/lfs/objects/blob_b.bin head -c 13000000 /dev/urandom > .git/lfs/objects/blob_c.bin # 5) 一个像样的工作区,让占比统计有意义 # (脚本里用一小段 Python 生成 src/app_*.py、src/assets/img_*.bin、docs/manual.md) # 6) 两个"本地专用、不该外传"的文件 printf 'TOKEN=demo-not-a-real-secret\n' > .env.local printf 'local editor state\n' > .idea-workspace.local

跑起来看一眼:

./make-synthetic-repo.sh /tmp/audit-demo-repo # synthetic repo ready at /tmp/audit-demo-repo cd /tmp/audit-demo-repo && git status --porcelain # ?? .env.local # ?? .idea-workspace.local

这两行输出就是要的效果:工作区里明晃晃躺着两个没被跟踪的本地文件。真实项目里,这类文件经常就是.env、本地配置、编辑器状态——它们不在版本控制里,但打整个目录的包时一定会被带上。

这里是我最想让你停一下的地方。把仓库交给任何"打包上传"的工具之前,先问自己一句:这个目录里,除了git ls-files列出来的东西,还躺着什么?这也是整篇审计脚本第一件要回答的事。

图 2:合成样例仓库的目录结构与 .git 内部布局标注


4 写审计脚本:统计 .git/objects、lfs、reflog

现在开始写真正的审计逻辑。我先用普通 Python 写一版,因为无界面、可断言,比 notebook 更适合先跑通。

脚本核心就三块:受控源码有多大、.git里三块"隐形仓库"有多重、工作区里有没有不该跟包的本地文件。

# audit_cli.py(节选) def audit(repo: Path) -> dict: # 1) 受控源码:git 跟踪的文件 tracked = [f for f in git(repo, "ls-files").splitlines() if f] # 2) 未跟踪但真实存在的本地文件:.env、编辑器状态之类 untracked = [f for f in git(repo, "ls-files", "--others", "--exclude-standard").splitlines() if f] # 3) .git 里的三个"隐形仓库" objects_bytes = sum(f.stat().st_size for f in (repo / ".git" / "objects").rglob("*") if f.is_file()) lfs_bytes = sum(f.stat().st_size for f in (repo / ".git" / "lfs").rglob("*") if f.is_file()) reflog_lines = [l for l in git(repo, "reflog").splitlines() if l] # 4) 占比 git_bytes = du_kb(repo / ".git") * 1024 git_share = round(git_bytes / (git_bytes + worktree_bytes) * 100, 1) lfs_share = round(lfs_bytes / git_bytes * 100, 1) ...

为什么专门盯这四样东西,值得说清楚:

  • .git/objects是 Git 的内容寻址对象库。它存的是所有历史版本,不只是当前代码。你在工作区删掉的文件、改过的旧版本,对象还在里面。
  • .git/lfs是大文件对象的本地缓存。很多人以为"我把那个大视频从仓库里删了",但只要缓存还在,它就会跟着.git一起被打包走。
  • reflog是引用日志。那些被reset --hard丢掉的提交,在工作区里看不见了,但在 reflog 里还能捞回来——对审计方来说,这就是"你以为删了的东西"的清单。
  • 未跟踪的本地文件是最容易被忽略的一类,因为它们根本不在git ls-files里。

跑一遍:

python3 audit_cli.py /tmp/audit-demo-repo --json

输出(原文照抄,已格式化):

{ "tracked_files": 37, "untracked_files": 2, "tracked_bytes": 3120670, "worktree_bytes": 3120718, "local_untracked_bytes": 48, "objects_bytes": 3007188, "lfs_bytes": 17500000, "git_bytes": 20832256, "reflog_lines": 14, "unreachable_objects": 0, "git_share_pct": 87.0, "lfs_share_of_git_pct": 84.0, "flags": [ ".git 占比 87.0%,超过 50% 红线", "LFS 缓存占 .git 的 84.0%,超过 50% 红线", "存在未跟踪的本地文件,打包前必须确认里面没有凭据" ] }

这组数字把问题讲透了:

  • 工作区里真正受控的源码只有3.0 MB。
  • .git目录20.8 MB,占仓库总体积87.0%。
  • .git里 LFS 缓存16.7 MB,占.git的84.0%。
  • reflog 里躺着14 条轨迹,其中三条是"被 reset 丢掉"的临时提交。

也就是说,如果有个工具把"整个工作区"打包走,体积大头根本不是你的代码,而是代码的历史和大文件缓存。这跟那篇掘金复盘里".git占八成多"的观察是一回事——只是我现在用的是自己可控的合成样例,能反复验证。

如果数字跟这里对不上,优先检查两件事:一是du和git命令在你系统上是否都可用;二是你有没有真的跑到合成仓库上,而不是误指向了别的目录。审计脚本最忌讳"扫错对象还浑然不觉"。

图 3:.git内部组成与体积占比——objects / lfs / reflog 三块隐形仓库


5 把审计逻辑搬进 marimo,做成只读应用

命令行脚本已经能出数了,但它不方便给同事看。这一节把它搬进 marimo,做成一个只读、绑本机、能被服务端执行并导出结论的应用。

marimo 笔记本本质上还是一个.py文件,用@app.cell装饰器组织单元格。我把它按"读参数 → 统计 → 出结论"三段来写。关键代码如下(节选):

# audit_snapshot.py(节选) import marimo app = marimo.App(width="medium", app_title="本地仓库快照审计") @app.cell def _(Path, os): import sys # 审计目标:命令行第一个参数;没传就读环境变量,再没有直接报错 if len(sys.argv) > 1: REPO = Path(sys.argv[1]).resolve() else: REPO = Path(os.environ["AUDIT_REPO"]).resolve() REPO return (REPO,) @app.cell def _(REPO, os): # 宁可在这里直接失败,也不要"默认扫到什么算什么" assert REPO.exists(), f"目录不存在:{REPO}" assert (REPO / ".git").exists(), f"不是 Git 仓库(找不到 .git):{REPO}" os.chdir(REPO) return

有个细节我要专门讲:审计目标必须显式传入,脚本不做任何"猜测默认目录"的事。这是我给这个工作台定的一条硬规矩。审计工具一旦会"自己挑一个目录扫",你迟早会在某天发现它扫的不是你以为的那个仓库。

统计部分和 CLI 脚本同源,只是改用 marimo 的mo.md渲染结论。这里踩了一个小坑,顺手记下来:mo.md写在if/else里时,服务端导出拿不到渲染结果。我一开始写成if flags: mo.md(...) else: mo.md(...),导出出来的结论是空的。改成先把结论拼成字符串、再无条件mo.md渲染,导出就稳定了:

@app.cell def _(flags, mo): verdict = ("\n".join(f"- ⚠️ {f}" for f in flags) if flags else "- ✅ 未触发当前阈值") mo.md(f"## 审计结论\n\n{verdict}") return

如果你导出的结论页是空白的,先回想一下是不是也把mo.md塞进了条件分支里。

以只读应用模式启动:

AUDIT_REPO=/tmp/audit-demo-repo \ marimo run audit_snapshot.py --port 2767 --headless

--headless是不弹浏览器。启动后 marimo 只打印一行:

Running audit_snapshot.py ⚡ ➜ URL: http://localhost:2767

注意它绑的是localhost(也就是127.0.0.1),这是 marimo 的默认行为,恰好是我们要的。

图 4:marimo 只读应用的三段式单元格结构与审计目标显式传入的约束


6 无界面验证:curl 断言本地只读应用

应用起来了,怎么在不开浏览器的情况下确认它是对的?靠 curl 加几条确定性断言。

先确认监听:

lsof -iTCP:2767 -sTCP:LISTEN -P -n | grep 127.0.0.1 # → 有输出,且只出现在 127.0.0.1 上

抓页面和健康检查:

curl -s -o run-index.html -w "%{http_code}\n" http://127.0.0.1:2767/ # 200 grep -o "<title>[^<]*</title>" run-index.html # <title>本地仓库快照审计</title> curl -s http://127.0.0.1:2767/health # {"status":"healthy"}

三条都过了,说明应用本身没问题。接着验证"它没有对局域网敞开"——这一步很多人会漏掉:

# 取本机局域网地址,然后从该地址直连 LAN=$(ipconfig getifaddr en0) curl -s -m 3 -o /dev/null -w "%{http_code}\n" "http://$LAN:2767/" # 000 ← 连接失败,没有对外监听

最后看一眼 marimo 自带的 API 入口有没有门:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:2767/api/kernel/status # 401 ← 无凭据被拒

000和401这两条,是整个方案安全边界的地基:服务只在本机可达,且自带的接口入口不是裸奔的。后面用 cpolar 开隧道时,我们只把"页面"这一段透出去,而不是把这台机器的服务整个交出去。

除了 curl,marimo 还有两种无界面复跑方式,都值得一并验证:

# ① 导出成拓扑序的纯 Python 脚本(可以直接 git diff 审阅) marimo export script audit_snapshot.py -o audit_snapshot.script.py -f # ② 服务端执行笔记本,导出单元格结果快照(导出结论的关键) AUDIT_REPO=/tmp/audit-demo-repo \ marimo export session audit_snapshot.py --force-overwrite

export session产出的 JSON 里,我确认了单元格报错数为 0,并且渲染后的结论表就在这里:

| 指标 | 数值 | | 受控源码(tracked) | 3.0 MB | | 工作区总体积(不含 .git) | 3.0 MB | | 本地未跟踪文件体积 | 48.0 B | | .git/objects(对象库) | 2.9 MB | | .git/lfs(大对象缓存) | 16.7 MB | | .git 合计 | 19.9 MB | | reflog 条数 | 14 | | 不可达对象数 | 0 | ## 审计结论 - ⚠️ .git 占比 87.0%,超过 50% 红线 - ⚠️ LFS 缓存占 .git 的 84.0%,超过 50% 红线 - ⚠️ 存在未跟踪的本地文件(48.0 B),打包前必须确认里面没有凭据

到这一步,"审计跑通、结论可信、无浏览器无账号"三个目标都达成了。这一步不是为了测试而测试,而是为了确认后面经隧道透出去的那份结论,跟本机跑出来的是同一份。


7 用 cpolar 开一条短时隧道,让异地同事复核

现在到了这篇文章里 cpolar 出场的地方。

场景很具体:我的审计结论页跑在本机127.0.0.1:2767,异地一位安全同事想亲自看一眼这组数字、顺便复现一下检查命令。但我绝不会把真实仓库路径、更不会把仓库内容递出去,所以只让隧道承载那一页脱敏的聚合结论。

开隧道就一行:

cpolar http -log=stdout 2767

关键 stdout:

level=info msg="[:tunnel server module] Tunnel established at https://6d49fa5e.r3.nas.cpolar.cn"

看到Tunnel established at https://...,就是成功标志。把这条带https的地址发给同事。公网侧验证:

curl -s -o /dev/null -w "%{http_code}\n" https://6d49fa5e.r3.nas.cpolar.cn/ # 200 curl -s https://6d49fa5e.r3.nas.cpolar.cn/ | grep -o "<title>[^<]*</title>" # <title>本地仓库快照审计</title> curl -s -o /dev/null -w "%{http_code}\n" https://6d49fa5e.r3.nas.cpolar.cn/health # 200

同事在自己浏览器打开这个地址,看到的就是那份统计表和三条红线结论,页面是只读的——因为marimo run本来就是只读应用模式,他没有编辑入口。

复核完就关掉:

pkill -f "cpolar http -log=stdout 2767"

关闭后再打一次公网地址,会拿到 404(cpolar 边缘返回"域名不存在"),说明隧道确已下线:

curl -s -o /dev/null -w "%{http_code}\n" https://6d49fa5e.r3.nas.cpolar.cn/ # 404

而本地服务不受影响:

curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:2767/ # 200

这里有两件事必须说清楚。

第一,免费套餐的 cpolar 公网地址是随机临时的,24 小时内会变化。所以这套做法的定位就是"短时复核",不是"长期固定入口"。每次复核重新开一条、拿一个新地址发给同事就行,反而更省心。

第二,隧道只承载脱敏后的聚合结论,不承载仓库本身。我透出去的是".git占 87%、LFS 占 84%、reflog 14 条"这类数字,以及公网同事能自己在本地用同样脚本复现的命令。真实仓库路径、文件内容、凭据,一个都不在隧道里。

如果你希望每次开会都要同一个地址,那就得看固定二级子域名——它需要基础套餐或以上;固定 TCP 地址则需要专业套餐或以上。那是另一个阶段的需求,这篇先把"临时复核"这条路走干净。

7.1 隧道关闭后公网地址不会"立刻"报错,先别慌

这一点是我实测时踩出来的:pkill掉 cpolar 进程后,马上回访那个公网地址,它不会立刻返回 404 或 502,而是会正常 200 一两轮。我一开始还以为没关干净,多试了几次才看明白。

原因不难理解:公网请求先到 cpolar 的边缘节点,边缘到你这台机器的长连接断开、再被边缘感知,本身有个时间差。所以关隧道后的校验不要只打一次,给它一点时间:

for i in $(seq 1 5); do sleep 12 code=$(curl -s -m 15 -o /dev/null -w '%{http_code}' "https://<你的隧道地址>/") echo "try$i: $code" [ "$code" != "200" ] && break done # 实测:大约 12 秒后开始返回 502,最终稳定为 404

我这边实测大概 12 秒后先返回 502,稳定下来是 404。所以判断"关干净了没有"的正确标准是不再返回 200,而不是"必须马上 404"。如果你也拿这个做验收,把这条写进断言里,能少一次误判。


8 一键复跑:把整条链路压成一个脚本

手工敲到这儿,命令有点多了。我把整条链路压成一个verify-audit.sh,从造合成仓库到关隧道,全自动,最后打印 PASS/FAIL 汇总。

核心断言长这样:

#!/usr/bin/env bash set -uo pipefail PORT=2767 REPO=/tmp/audit-demo-repo # 1) 造合成仓库 + 无界面审计 ./make-synthetic-repo.sh "$REPO" ./audit_cli.py "$REPO" --json > audit-result.json # 2) marimo 导出与只读应用 marimo export script audit_snapshot.py -o audit_snapshot.script.py -f AUDIT_REPO="$REPO" marimo export session audit_snapshot.py --force-overwrite AUDIT_REPO="$REPO" marimo run audit_snapshot.py --port "$PORT" --headless & # 3) 本地 HTTP 断言 curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/" # 期望 200 curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:$PORT/api/kernel/status" # 期望 401 # 4) cpolar 短时隧道 + 关闭后对照 cpolar http -log=stdout "$PORT" & # 从 stdout 提取 https 地址,回访 200,再 kill,确认不再返回 200

我这台机器上跑完的汇总:

== 结果:PASS=24 FAIL=0 ==

24 条断言覆盖了:合成仓库生成、审计脚本退出码与统计区间、export script产物、export session报错数与结论渲染、本地监听与标题、/health、局域网不外扩、/api401、cpolar 隧道公网 200 与标题、关闭隧道后不再返回 200、关闭后本地仍 200。

如果你想在自己机器上复跑,跑完FAIL不为 0,先看两件事:一是 cpolar 是否已登录(9200 是否 200);二是 marimo 的 bin 目录是否在 PATH 里。这两条是最常见的失败来源,跟脚本逻辑无关。


9 这套工作流解决的是什么,又没解决什么

把话说全,免得给你造成错误期待。

它解决的,是"我这台机器上的某个本地仓库,哪些内容一旦被打包外传,体积和风险会超出直觉"这个问题。答案是可量化、可复现、可交叉复核的:.git占比、LFS 缓存重量、reflog 条数、未跟踪本地文件清单,全部有数字。

它没解决的,也是必须说明白的:

  • 它不能解密第三方客户端上传的密文,也不做任何针对第三方的逆向。私钥在云端,本机解不开,这是客观事实,不是我这套脚本能变的。
  • 它不接入、不上传任何真实仓库、真实凭据或真实内网地址。所有演示都跑在自建合成样例上。
  • 它不做长期公网暴露。cpolar 隧道是短时通道,只承载脱敏聚合结论,复核完即关。

换句话说,这是一个本地自查工具 + 临时复核通道,不是"拦截上传"或"破解加密"的方案。它的价值在于:在把仓库交给任何工具之前,你先知道自己在交出去的到底是什么。

10 总结

到这里,整条链路是干净闭环的:合成的样例仓库造出来,audit_cli.py给出确定性数字(.git占 87%、LFS 占 84%、reflog 14 条),同一套逻辑搬进 marimo 做成只读应用,marimo run绑在127.0.0.1且/api无凭据返回 401,export session证明结论可无界面复现,再用 cpolar 开一条短时 HTTPS 隧道让异地同事只读复核,核实完关隧道、公网地址立刻 404、本地服务不受影响。

关键步骤浓缩成三步:

  • 数:python3 audit_cli.py <仓库> --json,拿到.git占比、LFS 重量、reflog 条数与未跟踪本地文件清单。
  • 看:marimo run audit_snapshot.py --port 2767 --headless,本机只读页面 +curl断言 200/401,export session确认结论渲染。
  • 传:cpolar http -log=stdout 2767拿临时 HTTPS 地址,只把脱敏结论页发给同事,用完pkill,回测公网不再返回 200。

这套做法最舒服的地方,是职责分得开:marimo 管统计与结论的确定性,cpolar 只管给一条临时通路,隧道里不夹带任何真实仓库内容。安全边界不靠"我相信同事不会乱点",而是靠机制给的——应用只读、只绑本机、接口有门、隧道短时。

等哪天团队真要把这套审计常态化,扩展路径也很清楚:把检查项从体积占比扩到"敏感文件名匹配""提交作者异常""大对象清单导出",再把 cpolar 换成固定二级子域名,让同事每次开同一个地址来复核。但那都是下一步的事了。眼下先把这一版跑通——它至少能回答一个很实在的问题:你正准备打包上传的那个目录,里面到底有什么。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询