GiteePushWatcher:给私有 Gitee 仓库装一道代码安检门——推送即拉取、提交即扫描

封面由 AI 生成,主题为「代码安检门」:一面盾牌正在扫描一条由代码构成的 git 分支。
TLDR : Read here
最近在折腾一个挺实际的需求:
怎么在有人往私有 Gitee 仓库推代码之后,自动把对应分支拉下来,再检查一下最近的提交里有没有混进恶意代码?
这件事听起来简单,真做起来有三个坑:
- Gitee 的私有仓库,API 和网页在你没登录时一律返回 404,不是真 404,是「你没权限」的 404;
- 「监控推送」到底是轮询还是 Webhook,选错了后面一堆事都白干;
- 静态扫恶意代码这件事,光扫 diff 不够,还得防有人
git push --force把历史改写了。
我最后做出来的是一套大概 400 行的 Python 脚本,叫它 GiteePushWatcher。它干三件事:监控、拉取、扫描。本文把这个方案怎么实现、怎么用、有什么优缺点都讲清楚,代码我放在文章同级的 git_watcher.py / config.json / patterns.json 里了。
先说结论,后面慢慢展开。
一、为什么会有这个需求
先讲清楚场景。
如果你的仓库是开源的、你自己一个人写、而且你对自己的键盘绝对信任,那这事没必要做。
但现实往往不是这样:
- 仓库是团队私有的,成员不止一个,谁哪天手滑或者账号被盗,推一段
eval($_GET['x'])上来你不一定立刻发现; - CI 跑得再全,也未必会对「新提交里是否藏了 webshell / 反弹 shell / 危险函数调用」做专门告警;
- 等你在生产环境炸了才回溯,成本远高于「推送那一秒就扫一遍」。
所以这个工具的价值,不是替代安全审计,而是把「最便宜的第一道防线」放在推送发生的地方:
在恶意代码进入你的构建链路之前,先在提交这一关拦一下。
它的定位很明确——轻量级、低门槛、可复核的第一道告警,不是 WAF,不是 SAST 平台,不是代码评审的替代品。
二、方案全景:监控、拉取、扫描三段式
整条链路就三段,画出来是这样:
┌────────────┐ push ┌─────────────────┐ git fetch ┌────────────┐
│ Gitee 仓库 │ ───────▶ │ 监控触发 │ ────────────▶ │ 本地仓库 │
│ (私有) │ │ (轮询/Webhook) │ │ (对应分支) │
└────────────┘ └────────┬────────┘ └─────┬──────┘
│ diff before..after │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ 静态规则扫描 │ ◀────────── │ 新提交区间/整树 │
│ patterns.json │ │ (防 force-push) │
└────────┬────────┘ └─────────────────┘
▼
┌─────────────────┐
│ 报告 + 高危送审 │
│ reports/*.json │
└─────────────────┘拆开看每一段负责什么:
- 监控:发现「哪个分支、SHA 从 A 变成了 B」,这是触发点;
- 拉取:把变化实际落到本地,
fetch+checkout到对应分支; - 扫描:算
before..after的 diff,只对新增的代码行跑规则,命中了就进报告。
三个环节解耦,任何一个挂了都不影响另外两个的独立性——比如监控用 Webhook,扫描逻辑完全不变。
三、两种触发方式:轮询 vs Webhook
监控这一步,本质上只有两种选法。我先给一张表,再分别讲。
| 维度 | 轮询(Polling) | Webhook |
|---|---|---|
| 触发时机 | 每隔 N 秒查一次 API | Gitee 一推送就 POST 过来 |
| 实时性 | 有延迟,取决于轮询间隔 | 秒级,推送即触发 |
| 需要的权限 | 能读仓库即可 | 能读仓库 + 需要公网回调地址 |
| 部署难度 | 低,本机就能跑 | 中,服务要能被 Gitee 访问到 |
| API 限流 | 会吃 Gitee API 配额 | 基本不吃 |
| 漏推风险 | 间隔内发生又回滚可能漏 | 几乎不漏 |
我的建议很直接:
如果只是为了「别让我漏掉可疑提交」,轮询完全够用,而且本机就能跑,零部署成本。
如果你要秒级响应、或者仓库很活跃、不想被 Gitee API 限流烦,那就上 Webhook,代价是要解决公网可达。
GiteePushWatcher 两种都支持,而且可以共存:平时 Webhook 当主力,轮询当兜底。
四、先把仓库「读」出来:私有仓库的 token 问题
这是第一个、也是最容易被卡住的坑。
我一开始直接拿 Gitee 的公开 API 去查 miao-team/miaoyuyin-php,结果返回 404。我以为路径写错了,换了几个接口还是 404。最后用浏览器打开网页,才发现页面提示「您的访问受限(需登录)」。
真相是:
Gitee 对私有仓库不会返回 401「未授权」,而是直接返回 404「不存在」。
所以「API 404」在这里不等于「仓库不存在」,而等于「你没带着能看的凭证」。
解决办法只有一个:去 Gitee 申请一个个人访问令牌(Access Token),勾上 projects 的读权限。
拿到 token 之后有两处会用到,而且它们是两个完全不同的东西,千万别混:
| 密钥 | 方向 | 用途 |
|---|---|---|
gitee_token | 你的脚本 → Gitee | clone / fetch 私有仓库时做授权 |
webhook_secret | Gitee → 你的脚本 | 校验推送请求是不是 Gitee 真发的 |
gitee_token 是必选项(仓库私有,没它连 clone 都做不到);webhook_secret 是强烈建议项(不设的话,谁都能往你的 /webhook 发请求骗你扫描)。
token 我推荐走环境变量,别写进 config.json 落盘:
export GIT_WATCHER_TOKEN=你的_gitee_token
python git_watcher.py --check # 先验证 token 能不能列分支--check 模式只干一件事:列出所有分支和各自的最新 SHA。能列出来,就说明 token 有效、网络通、仓库路径对;列不出来,问题就在这三样里。
五、轮询模式怎么实现
轮询模式的骨架非常朴素:
loop every poll_interval_seconds:
对每个分支:
sha = 调 Gitee API 取最新 commit
if sha != state[branch]:
本地 git fetch
checkout 到该分支
算旧 sha .. 新 sha 的 diff
扫 diff 新增行
更新 state[branch] = sha
落报告state.json 是核心——它记住「每个分支上一次看到的 SHA 是什么」。下一轮比对时,发现 SHA 变了,就知道有新的提交要处理。
配置里几个关键项:
{
"repo": "miao-team/miaoyuyin-php",
"gitee_token_env": "GIT_WATCHER_TOKEN",
"poll_interval_seconds": 900,
"branches": [],
"scan_extensions": ["php", "html", "js", "sh"],
"baseline_full_scan": true,
"reports_dir": "reports"
}branches 留空表示监控所有分支;scan_extensions 控制只扫哪些后缀,PHP 项目自然把 php 放最前面;baseline_full_scan 为 true 时,首次运行会对整棵树做一次基线扫描,而不是只看增量。
跑起来就两条命令:
python git_watcher.py # 常驻守护,按间隔循环
python git_watcher.py --once # 单次检查后退出(适合被调度器调用)--once 这个模式特别适合丢给系统定时任务或者自动化平台,跑完就退,不占进程。
六、Webhook 模式怎么实现
Webhook 模式是一个常驻的小 HTTP 服务,监听一个路径(默认 /webhook)。
Gitee 后台配置(仓库 → 管理 → WebHooks → 添加):
- URL:
https://你的公网地址/webhook(端口默认 9000,路径可在config.json改webhook_path) - 密码:填和
webhook_secret一样的字符串 - 触发事件:勾选「推送事件 / Push」
- 保存后 Gitee 会发一次测试,看是不是返回 200
服务收到推送后,从请求体里取出 ref(分支)和 before / after(新旧 SHA),校验密码通过后就立刻走「拉取 + 扫描」流程。
我本地实测过路由和鉴权:
错误 token → HTTP 403
缺少 token → HTTP 403
正确 token → HTTP 200
错误路径 → HTTP 404也就是说,没带正确 webhook_secret 的请求一律被挡在门外,不会触发任何克隆或扫描。
启动命令:
python git_watcher.py --serve关于公网可达——这是 Webhook 唯一的硬门槛。 Gitee 必须能访问到你的服务,127.0.0.1 不行。三种办法按成本排序:
- 把
--serve部署到一台有公网 IP 的服务器(systemd / nohup 常驻); - 本机用隧道暴露:
ngrok http 9000或cloudflared tunnel --url http://localhost:9000,把生成的 HTTPS 地址填到 Gitee; - 用 Nginx / Caddy 做反代,终结 HTTPS 后转发到本地 9000。
我的观点是:
如果你只是想先验证整条链路能不能跑通,ngrok 一把梭最快;真要长期用,还是丢到一台小服务器上常驻更稳,毕竟 ngrok 免费版地址会变。
七、扫描逻辑:不只扫 diff,还要防 force-push
扫描这一段的坑,比前面都深。
最 naive 的做法是「扫 before..after 的 diff 新增行」。但有个问题:如果有人 git push --force,把历史改写了,旧的 before SHA 可能根本不再是新提交的祖先。这时候再用 before..after 算区间,要么算错、要么直接报错。
所以脚本里加了一道判断:
拿到旧 sha 和新 ref 之后:
先跑 git merge-base --is-ancestor 旧sha 新ref
如果返回非 0:
→ 历史被改写, 降级为「整树扫描」
否则:
→ 正常算 旧sha..新ref 的增量 diff还有一个细节:基线(baseline_full_scan)那一轮,本地还没有任何旧 SHA,所以第一次必然是整树扫描,把当前所有文件的命中项都记下来,避免以后每次都把「历史存量」当成「新增恶意」误报。
这层防护的意义在于:恶意提交不一定只藏在「最新一次 push」里,force-push 完全可以抹掉中间某次提交、再把带毒的代码塞进去。宁可多扫一遍整树,也不能让改写历史成了扫描的盲区。
八、patterns.json:恶意特征规则长什么样
规则库是一份 JSON,目前放了 32 条 PHP 方向的静态特征,大致分几类:
- 危险函数直接调用:
eval、system、exec、shell_exec、passthru、proc_open - 动态包含 / 变量函数:
include $x、$$var、create_function - 已知 webshell 特征:某些写在注释里或变量里的后门标记
- 可疑的网络外联:
fsockopen反弹、curl把数据往外发且地址来自用户输入 - 编码混淆:
base64_decode套eval、gzinflate套base64这种经典一句话变形
每条规则大概长这样:
{
"id": "PHP-EVAL-DIRECT",
"severity": "high",
"pattern": "\\beval\\s*\\(",
"description": "直接调用 eval 执行动态代码"
}扫描时只对 diff 的新增行逐条匹配(整树模式下扫全文件),命中后记录:文件名、行号、匹配到的那一行、命中的规则 id 和严重级别。
需要说清楚的是,这是「特征匹配」不是「语义分析」:
它能非常可靠地抓住「明显写死的恶意写法」,但抓不住「逻辑上巧妙构造的恶意」。
比如它看到eval(就会报,但它分不清这是你故意留的后门,还是某个老框架里确实在用的一句话。
所以报告里我特意留了一个字段 llm_review_context,把命中的上下文(前后若干行 diff)打包好,直接贴给大模型就能做一轮人工语义复核——这也是为什么标题里我说「提交即扫描」,但真正的「判断是否恶意」可以交给人和模型一起看。
九、报告长什么样,以及怎么送审
每次扫描结束,会在 reports/ 下产出两份:
report_YYYYMMDD_HHMMSS.json—— 机器可读,含分支、提交列表、命中明细、是否整树扫描、是否历史改写report_YYYYMMDD_HHMMSS.md—— 人读版,直接打开就能看
报告里高危项的样子大概是这样:
[high] PHP-EVAL-DIRECT @ src/runtime/loader.php:42
命中行: eval(base64_decode($_POST['c']));
上下文已写入 llm_review_context,可直接送审「送审」这一步现在是半自动的:你看到高危,把 llm_review_context 贴给我(或任意大模型),让它判断「这是真后门,还是正常业务代码」。
如果想全自动,也可以把 llm_review 打开、配一个模型端点,命中高危就自动把上下文发过去拿结论。但这块我建议先跑一段时间人工看,确认误报率你能接受,再考虑全自动化——不然容易被误报淹没了真告警。
十、这套东西的优缺点,老实说
我不太喜欢把自制的工具吹成银弹,所以把优缺点摊开讲。
优点
- 门槛极低:一个 Python 标准库脚本 + 一份 JSON 配置,不需要 Docker、不需要数据库、不需要接第三方 SaaS;
- 私有仓库可用:靠 token 解决了 Gitee 私有仓库的读取问题;
- 双触发:轮询兜底、Webhook 实时,部署灵活;
- 防 force-push 盲区:历史改写时自动降级整树扫描;
- 可复核:报告结构化,高危项天然适合丢给大模型做第二轮判断;
- 零侵入:不修改目标仓库,只在本地克隆副本上操作。
缺点(同样重要)
- 特征匹配有盲区:换种写法、用白名单函数拼出来、或者逻辑型后门,它大概率扫不到;
- 误报不可避免:
eval/exec在不少框架里是正常用法,需要人工或模型二次判断; - Webhook 的公网门槛:没有公网地址就只能用轮询,而轮询有延迟、吃 API 配额;
- token 落盘风险:用 token 克隆后,token 会写进本地
repo/.git/config,这台机器得只你自己用; - 不是实时防护:它是在「推送之后」告警,不是「推送之前」拦截,真要拦在 CI 合并前,得接到 PR 流水线里;
- 只扫代码文本:不跑、不执行,所以「运行时才暴露」的恶意它看不到。
一张表收一下:
| 它能做到 | 它做不到 |
|---|---|
| 推送后秒级/分钟级告警 | 推送前拦截合并 |
| 抓住明显写死的恶意写法 | 抓住逻辑型、变形型后门 |
| 私有仓库自动拉取扫描 | 替代专业 SAST / 人工审计 |
| 生成可复核的结构化报告 | 零误报 |
一句话定位:它是「便宜的第一道防线」,不是「全部防线」。把它放在推送这一关,专门干「先响一声、再让人来看」的活,性价比最高。
十一、如果你想自己搭
最小步骤:
# 1. 申请 Gitee Access Token(projects 读权限)
export GIT_WATCHER_TOKEN=你的token
# 2. 改 config.json:repo、branches、scan_extensions
# 3. 验证连通性
python git_watcher.py --check
# 4. 跑
python git_watcher.py --serve # 有公网就 Webhook
python git_watcher.py # 没公网就轮询守护config.json 管「监控谁、怎么触发、扫哪些后缀」,patterns.json 管「拿什么规则扫」——这两个文件就是你能调的全部旋钮,按自己项目增删规则即可。
十二、如果只记住一句话
GiteePushWatcher 可以简单理解成:
Gitee 推送
= 触发信号
git fetch + checkout
= 把变化拉到本地
patterns.json 扫 diff 新增行
= 第一道告警
report + llm_review_context
= 让人/模型来判是不是真恶意它解决的核心问题只有一个:在恶意代码溜进你的构建链路之前,先在你的提交这一关,响一声。
如果你也想给自己的私有仓库加这么一道门,照着上面的步骤把 git_watcher.py 跑起来就行——它不完美,但比「完全没人看」强太多。