- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 点一下,令牌就没了
- VS Code 一键令牌窃取的攻击链
- 令牌在开发环境里住在哪儿
- PAT 权限设计 — classic vs fine-grained
- 令牌寿命与轮换
- GitHub Secret Scanning 与 Push Protection
- 防止 .env 泄漏的三重防御
- git-credential helper 的安全配置
- IDE 扩展的供应链风险
- CI 密钥 — 用 OIDC 消灭长期令牌
- 事故响应 runbook
- 检查清单
- 陷阱与反论
- 结语
- 参考资料
引言 — 点一下,令牌就没了
2026 年 6 月,Hacker News 上有一篇引发热议的安全分析文章。内容是:利用 VS Code 的一个漏洞,只需受害者点击链接这一个动作,就能窃取到 GitHub 令牌。安全研究者 Ammar Askar 公开的这份分析,再次唤醒了"我的 IDE 可能会背叛我"这个令人不适的事实。
核心在于,IDE、浏览器与 OAuth 流程之间的边界,比我们想象的要脆弱得多。我们总以为令牌"藏好就行了",但实际上,令牌会从大量我们根本没意识到的路径漏出去。明文配置文件、环境变量、Shell 历史,以及像这次事件一样,连 IDE 的 URL 处理器也算在内。
本文分为两部分。先在概念层面解剖 VS Code 令牌窃取事件的攻击链,然后以配置示例为中心,整理开发者在日常中就能落地的密钥卫生策略。先把结论说在前面:比起把令牌藏好,缩小令牌的权限、缩短它的寿命、乃至干脆不要令牌,这个方向要强得多。
VS Code 一键令牌窃取的攻击链
这里不给出具体的利用代码,而是在概念层面说明是哪些结构性缺陷叠加起来才让事故成为可能。因为想理解防御,就得先知道攻击长什么样。
[1] 受害者点击恶意链接(邮件、聊天、网页)
|
v
[2] 通过自定义 URL scheme (vscode://) 把 IDE 唤到前台
|
v
[3] IDE 扩展或内置处理器没有充分校验 URL 参数
|
v
[4] OAuth 回调/认证流程被引导到攻击者控制的重定向
|
v
[5] GitHub 认证令牌被送到攻击者的端点
|
v
[6] 攻击者用该令牌访问受害者的仓库
在这条链路里,值得注意的结构性弱点有三个。
第一,自定义 URL scheme 很强大,但也很危险。vscode:// 这类 scheme 会让浏览器经由操作系统直接唤起 IDE。此时如果 IDE 一侧信任了传进来的参数,外部就能操控 IDE 的内部行为。
第二,OAuth 重定向校验的漏洞。OAuth 会在认证之后把令牌回传给特定的重定向 URI,如果这个 URI 的校验很宽松(子串匹配、允许通配符等),攻击者就能把令牌导向自己的端点。
第三,把用户交互降到最少,反而是危险的。为了"一键"的便利而省掉确认步骤,受害者就失去了意识到自己批准了什么的机会。
这次事件的教训很明确。任何令牌只要暴露过一次,就必须立刻视为已被入侵;因此我们要发放和运营的,是那种"即便泄漏,损失也很小"的令牌。
令牌在开发环境里住在哪儿
先直面现实。把一台普通开发者机器上令牌的存放位置列出来,大致是这样。
| 存放位置 | 安全级别 | 风险 |
|---|---|---|
| 明文配置文件 (.npmrc, .git-credentials) | 非常低 | 通过磁盘访问、备份、同步泄漏 |
| 环境变量 (.bashrc, .zshrc export) | 低 | 传播给子进程、在日志中暴露 |
| .env 文件 | 低 | 误提交、被打进 Docker 镜像 |
| 操作系统钥匙串(macOS Keychain 等) | 高 | 依赖应用权限模型 |
| 密钥管理器 (Vault、云 KMS) | 非常高 | 运维复杂度 |
大部分泄漏事故都发生在上表靠上的三行。其中最常见的,就是把令牌 export 到环境变量这个习惯。
# 反模式 — 千万不要这么做
export GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx
export AWS_SECRET_ACCESS_KEY=xxxxxxxxxxxx
这种做法的问题在于,令牌会自动被所有子进程继承。随手执行的脚本、npm 包的 install script,甚至已经被入侵的 CLI 工具,都能读到这个令牌。前面讲过的 npm 供应链攻击之所以把环境变量当作主要窃取目标,原因正在于此。
基本原则是这样:令牌只注入给需要它的进程、只在需要的时刻注入,并且尽可能委托给操作系统钥匙串或密钥管理器。
PAT 权限设计 — classic vs fine-grained
GitHub Personal Access Token 有两种。理解这两者的差别,是令牌安全的起点。
| 分类 | Classic PAT | Fine-grained PAT |
|---|---|---|
| 权限粒度 | 按 scope(repo、workflow 等) | 按仓库 + 细分权限 |
| 覆盖仓库 | 账号下的所有仓库 | 仅所选仓库 |
| 过期 | 可设置(也允许永不过期) | 最长 1 年,必须设置过期 |
| 组织审批 | 有限 | 可通过组织策略管控 |
| 推荐度 | 不推荐(遗留) | 推荐 |
Classic PAT 的 repo scope 实际上等于给出账号下所有仓库的读写权限。一个令牌泄漏,所有仓库就都暴露了。相比之下,Fine-grained PAT 可以明确写出"对这个仓库,只给这些权限"。
权限设计的原则很简单。
1. 最小权限:只授予完成任务所必需的最小权限
2. 最小范围:显式选择目标仓库
3. 短寿命:尽可能短的过期时间(例如 7 天、30 天)
4. 单一用途:一个令牌只用于一个用途
5. 可追溯:给令牌起一个能看出用途的名字
举例来说,如果是 CI 中往某个仓库推送发布产物的令牌,那么只选 contents:write 和那一个仓库就够了。对 issues 或其他仓库的权限完全不需要。
令牌寿命与轮换
令牌安全里最被低估的就是寿命。永不过期的令牌是定时炸弹。连什么时候泄漏的都不知道、却持续有效数年的令牌,是事故的常客。
把轮换 (rotation) 策略整理如下。
- 个人 PAT:90 天以内过期 + 每季度轮换
- CI/自动化令牌:30 天以内,或改用 OIDC 替代(后述)
- 服务账号令牌:通过密钥管理器自动轮换
- 怀疑泄漏时:立即吊销 + 重新签发,没有例外
轮换如果不自动化,最后就是没人会做。用 GitHub CLI 可以放一个脚本,周期性地检查令牌状态。
# 确认当前认证状态与令牌 scope
gh auth status
# 把令牌委托给钥匙串等安全存储(避开明文文件)
gh auth login --hostname github.com --git-protocol https
GitHub Secret Scanning 与 Push Protection
GitHub 会自动检测推送到仓库的代码中已知形式的密钥。核心是 push protection。一旦提交里包含密钥,就直接拒绝推送本身,把泄漏挡在发生之前。
除了在组织或仓库层面开启之外,若想在本地也获得同样的保护,最好并行使用事前拦截工具。下面是展示 GitHub 的 push protection 如何工作的概念图。
git push
|
v
[GitHub 服务器] -- 在推送的 diff 中检测密钥模式
|
+-- 发现密钥 --> 拒绝推送,向开发者提示位置
|
+-- 干净 --> 推送成功
对于已经泄漏的情况,GitHub 也提供了部分支持。如果在公开仓库中发现了合作伙伴计划中登记的令牌格式(例如云厂商密钥),GitHub 会通知对应的服务提供方,推动其自动吊销。但这是最后一道安全网,不是第一道防线。密钥本来就不应该被提交上去。
防止 .env 泄漏的三重防御
.env 文件是开发者密钥泄漏的头号路径。我们用三重防御把它堵住。
第 1 层 — gitignore
最基本,但也最常被漏掉。
# .gitignore
.env
.env.*
!.env.example
*.pem
*.key
.git-credentials
倒数第二行的否定模式很重要。.env.example 要提交上去,好让团队知道需要哪些变量;而所有含有真实值的文件都要挡住。
第 2 层 — 用 pre-commit hook 事前拦截
在提交时扫描并拦截密钥。gitleaks 事实上就是标准。
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
安装之后用下面的方式注册钩子。
pip install pre-commit
pre-commit install
# 从现在起每次提交都会自动运行 gitleaks
第 3 层 — gitleaks 自定义配置
除了默认规则集,还可以补充公司内部的令牌格式。
# .gitleaks.toml
title = "公司内部 gitleaks 配置"
[extend]
useDefault = true
[[rules]]
id = "internal-api-key"
description = "公司内部 API 密钥模式"
regex = '''myco_(live|test)_[0-9a-zA-Z]{32}'''
tags = ["key", "internal"]
[allowlist]
description = "允许测试夹具"
paths = [
'''test/fixtures/.*''',
]
在 CI 中也扫描完整历史,做双重检查。
# .github/workflows/gitleaks.yml
name: Secret Scan
on: [pull_request]
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }}
git-credential helper 的安全配置
git 如何保管凭据,取决于 credential.helper 的配置。最危险的是 store 这个 helper。
# 危险 — 把凭据以明文写入 ~/.git-credentials
git config --global credential.helper store
这个配置会把令牌以明文记录在家目录里。备份、云同步、其他进程的磁盘访问,都会让它原样泄漏。取而代之,应该用各操作系统上安全的 helper。
# macOS — 委托给 Keychain
git config --global credential.helper osxkeychain
# Windows — Credential Manager
git config --global credential.helper manager
# Linux — libsecret(GNOME Keyring 等)
git config --global credential.helper libsecret
# 或者只用缓存(短暂驻留内存,不写磁盘)
git config --global credential.helper 'cache --timeout=3600'
此外,把凭据按主机分开,即使一个主机被入侵,其他主机也仍然安全。
# ~/.gitconfig
[credential "https://github.com"]
helper = osxkeychain
[credential "https://gitlab.internal.example.com"]
helper = osxkeychain
IDE 扩展的供应链风险
VS Code 事件的本质,同时也是扩展生态的信任模型问题。扩展是以 IDE 的全部权限运行的。文件系统读写、网络通信、执行 Shell 命令,甚至访问其他扩展保管的密钥,都做得到。
把扩展权限模型的现实整理出来是这样。
已安装的 VS Code 扩展的能力:
- 读取工作区的所有文件(包括 .env)
- 发送任意网络请求
- 在集成终端中执行命令
- 访问通过 SecretStorage API 保管的令牌(扩展之间有隔离,但并不完备)
- 自动更新 — 昨天还安全的扩展,今天可能就是恶意的
实务指南如下。
[ ] 只安装发布者 (publisher) 身份已验证的扩展
[ ] 确认下载量、更新频率,以及是否开源
[ ] 启用工作区信任 (Workspace Trust) 功能 — 在不受信任的文件夹中限制扩展
[ ] 不用的扩展要停用或删除
[ ] 敏感项目在单独的配置文件或单独的机器上进行
[ ] 关闭扩展自动更新并审查变更内容(高敏感环境)
工作区信任尤其重要。当你打开一个 clone 下来的陌生仓库时,它能阻止该文件夹的配置自动执行代码。
CI 密钥 — 用 OIDC 消灭长期令牌
到这里为止讲的是如何安全地处理令牌,现在来看怎样干脆不要令牌。CI 往云上部署时,传统做法是把长期访问密钥存进 CI 密钥里。这个密钥一旦泄漏,就完了。
OIDC 联邦 (OpenID Connect federation) 从根本上解决了这个问题。每次 CI 运行时,由云服务商签发短期凭据,作业结束后即过期。不存在被保存下来的长期机密。
[传统方式 — 长期密钥]
在 CI 密钥中保存 AWS 访问密钥 --> 泄漏即永久沦陷
[OIDC 方式 — 短期令牌]
GitHub Actions 签发 OIDC 令牌
|
v
云端校验 OIDC 令牌的签发者/仓库/分支
|
v
校验通过则签发短期凭据(数十分钟过期)
|
v
作业结束的同时凭据消亡
下面是在 GitHub Actions 中用 OIDC 连接 AWS 的配置示例。
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # 签发 OIDC 令牌所必需
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: ap-northeast-2
# 没有访问密钥 — 通过 OIDC 直接扮演角色
- run: aws s3 sync ./dist s3://my-bucket/
在云一侧,则用信任策略限制哪个仓库、哪个分支可以扮演这个角色。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
}
}
}
]
}
这里的 sub 条件才是核心防御。只有在特定仓库的特定分支上运行的工作流,才能扮演这个角色。来自 fork 的 PR 或别的仓库拿不到凭据。条件如果设得太松(例如所有分支通配),就毫无意义了,务必注意。
GCP 和 Azure 也提供同样的模式 (Workload Identity Federation、federated credentials)。如果是新项目,正确的做法是在签发长期密钥之前,先确认能不能用 OIDC。
事故响应 runbook
这是怀疑令牌泄漏时的处理流程。平时不必背下来,但必须写在某个地方。
[立即] 吊销
- 立刻 revoke 泄漏或可疑的令牌(GitHub Settings、云控制台)
- 吊销要排在签发新令牌之前 — 把暴露窗口压到最小
[5 分钟] 掌握影响范围
- 确认该令牌的权限范围(涉及哪些仓库、能做哪些操作)
- 在 GitHub 审计日志 / 云端 CloudTrail 中查询异常活动
- 列出该令牌所能实施的行为(push、发布包、创建资源)
[30 分钟] 遏制与排查
- 在所有用过同一令牌的系统上更换令牌
- 识别并审查可疑的提交/发布/部署
- 排查是否新增了 SSH 密钥、deploy key(后门)
[事后] 防止复发
- 移除明文保管路径,迁移到钥匙串或密钥管理器
- 能换成 OIDC 的令牌就换掉
- 确认已引入 gitleaks pre-commit + CI 扫描
- 如果密钥留在了 git 历史里,考虑重写历史
最后一项需要注意。密钥一旦进入提交历史,即便再追加一个删掉该文件的新提交,它在过去的提交里依然原样存在。这时需要重写历史(git filter-repo 等);而最重要的是,已经暴露的令牌无论是否重写历史,都必须优先吊销。
检查清单
个人开发者:
[ ] 禁止把长期令牌 export 到环境变量
[ ] 把 git credential.helper 设为操作系统钥匙串而非 store
[ ] PAT 使用 fine-grained,最小权限 + 设置过期
[ ] .gitignore 中包含 .env、*.pem、*.key、.git-credentials
[ ] 安装 gitleaks pre-commit hook
[ ] 启用 VS Code 工作区信任,扩展保持最少
[ ] 每季度检查并轮换令牌
组织:
[ ] 对全部仓库启用 secret scanning + push protection
[ ] 把 CI 的长期密钥替换为 OIDC 联邦
[ ] 制定 fine-grained PAT 策略 + 限制 classic PAT
[ ] 把 gitleaks 登记为 PR 的必需检查
[ ] 把令牌泄漏事故响应 runbook 写成文档
[ ] 在开发者入职培训中加入密钥卫生教育
陷阱与反论
第一,钥匙串也不是万能的。钥匙串只在操作系统处于锁定状态时提供保护。当机器处于解锁状态而恶意代码得以执行时,它可以用正常应用的权限读取钥匙串。钥匙串比明文文件好得多,但机器本身被入侵时,它是有极限的。
第二,OIDC 也不是万能的。如果 sub 条件设得很松(分支通配、不指定环境),fork 的 PR 或非预期的工作流也能拿到凭据。OIDC 的安全性完全取决于信任策略的严格程度。比起"已经引入了 OIDC"这个事实,条件是否被精确收窄才重要。
第三,gitleaks 是基于模式的。已知格式的密钥它抓得很好,但格式不规则的公司内部机密,或者用 base64 编码绕过的值,都可能漏掉。就算加上自定义规则也不完美,所以检测工具是最后一道防线,不是免责金牌。
第四,便利性与安全性之间的张力是永恒的。令牌寿命缩短,轮换负担就上升;权限收窄,工作就会被卡住。无视这种张力的策略只会催生绕过行为。用自动化(自动轮换、OIDC、与密钥管理器联动)降低安全的成本,才是唯一可持续的路。
第五,正如这次 VS Code 事件所展示的,我们所信赖的工具本身就是攻击面。IDE、扩展、CLI、包管理器全都会接触令牌。减少接触令牌的工具数量,并缩小每个工具的权限,才是根本方向。
结语
VS Code 的 1-click 令牌窃取事件说明,"该把令牌藏在哪里"这个问题本身就问错了。正确的问题是"要怎么设计,才能让这个令牌漏出去也损失很小"。
归纳成三条。
- 缩小权限:fine-grained PAT、最小 scope、单一用途。
- 缩短寿命:短过期、定期轮换、泄漏时立即吊销。
- 消灭令牌:CI 改用 OIDC,保管交给钥匙串或密钥管理器。
这三个方向最终都指向同一处。既然无法完美阻止令牌被暴露,那就从结构上把暴露之后的损失做小。这就是一键时代的密钥卫生。
参考资料
- Ammar Askar — GitHub token stealing 分析:https://blog.ammaraskar.com/github-token-stealing/
- GitHub Secret Scanning 文档:https://docs.github.com/en/code-security/secret-scanning
- gitleaks: https://github.com/gitleaks/gitleaks
- GitHub Fine-grained PAT 文档:https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- GitHub Actions OIDC 文档:https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
- aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials
- git-credential 文档:https://git-scm.com/docs/gitcredentials
- pre-commit 框架:https://pre-commit.com/
- VS Code Workspace Trust 文档:https://code.visualstudio.com/docs/editor/workspace-trust
- Hacker News: https://news.ycombinator.com/
- GeekNews: https://news.hada.io/