Zhang's Notes

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

cover

封面由 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 平台,不是代码评审的替代品。


二、方案全景:监控、拉取、扫描三段式

整条链路就三段,画出来是这样:

text
┌────────────┐   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 秒查一次 APIGitee 一推送就 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你的脚本 → Giteeclone / fetch 私有仓库时做授权
webhook_secretGitee → 你的脚本校验推送请求是不是 Gitee 真发的

gitee_token 是必选项(仓库私有,没它连 clone 都做不到);webhook_secret 是强烈建议项(不设的话,谁都能往你的 /webhook 发请求骗你扫描)。

token 我推荐走环境变量,别写进 config.json 落盘:

bash
export GIT_WATCHER_TOKEN=你的_gitee_token
python git_watcher.py --check   # 先验证 token 能不能列分支

--check 模式只干一件事:列出所有分支和各自的最新 SHA。能列出来,就说明 token 有效、网络通、仓库路径对;列不出来,问题就在这三样里。


五、轮询模式怎么实现

轮询模式的骨架非常朴素:

text
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 变了,就知道有新的提交要处理。

配置里几个关键项:

json
{
  "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 时,首次运行会对整棵树做一次基线扫描,而不是只看增量。

跑起来就两条命令:

bash
python git_watcher.py            # 常驻守护,按间隔循环
python git_watcher.py --once     # 单次检查后退出(适合被调度器调用)

--once 这个模式特别适合丢给系统定时任务或者自动化平台,跑完就退,不占进程。


六、Webhook 模式怎么实现

Webhook 模式是一个常驻的小 HTTP 服务,监听一个路径(默认 /webhook)。

Gitee 后台配置(仓库 → 管理 → WebHooks → 添加):

  1. URL:https://你的公网地址/webhook(端口默认 9000,路径可在 config.json 改 webhook_path)
  2. 密码:填和 webhook_secret 一样的字符串
  3. 触发事件:勾选「推送事件 / Push」
  4. 保存后 Gitee 会发一次测试,看是不是返回 200

服务收到推送后,从请求体里取出 ref(分支)和 before / after(新旧 SHA),校验密码通过后就立刻走「拉取 + 扫描」流程。

我本地实测过路由和鉴权:

text
错误 token     → HTTP 403
缺少 token     → HTTP 403
正确 token     → HTTP 200
错误路径       → HTTP 404

也就是说,没带正确 webhook_secret 的请求一律被挡在门外,不会触发任何克隆或扫描。

启动命令:

bash
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 算区间,要么算错、要么直接报错。

所以脚本里加了一道判断:

text
拿到旧 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 这种经典一句话变形

每条规则大概长这样:

json
{
  "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 —— 人读版,直接打开就能看

报告里高危项的样子大概是这样:

text
[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 / 人工审计
生成可复核的结构化报告零误报

一句话定位:它是「便宜的第一道防线」,不是「全部防线」。把它放在推送这一关,专门干「先响一声、再让人来看」的活,性价比最高。


十一、如果你想自己搭

最小步骤:

bash
# 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 可以简单理解成:

text
Gitee 推送
    = 触发信号

git fetch + checkout
    = 把变化拉到本地

patterns.json 扫 diff 新增行
    = 第一道告警

report + llm_review_context
    = 让人/模型来判是不是真恶意

它解决的核心问题只有一个:在恶意代码溜进你的构建链路之前,先在你的提交这一关,响一声。

如果你也想给自己的私有仓库加这么一道门,照着上面的步骤把 git_watcher.py 跑起来就行——它不完美,但比「完全没人看」强太多。