GitHub Actions 安全處理外部 PR:pull_request、權限與工作流程隔離

了解 GitHub Actions 處理外部 Pull Request 時的權限界線,並參考 pull_request_target、GITHUB_TOKEN 與工作流程隔離的安全設定。

在 GitHub Actions 加入自動標籤、歡迎留言或審查機器人時,可能會遇到權限問題:一般的 pull_request 工作流程無法取得某些儲存庫密鑰或寫入權限,因此有人改用 pull_request_target。這會讓工作流程在目標儲存庫的特權情境中執行;若接著檢出並執行貢獻者提交的程式碼,惡意變更可能讀取密鑰、修改儲存庫或污染快取。

設定安全界線的重點,是把不受信任程式碼的執行與高權限操作分開。本文說明事件差異、最小權限、危險組合、可用設定範例與驗證步驟。

區分事件情境與程式碼來源

GitHub Actions 觸發條件會決定工作流程能看到的事件情境、權杖權限與密鑰。處理外部貢獻者的 Pull Request 時,常見選擇是 pull_request 與 pull_request_target;應依照工作內容選擇,而非只看是否需要在 PR 留言。

觸發條件 預設檢出的程式碼 fork PR 的典型權限 適合的工作
pull_request 測試用的合併提交 通常受限;fork 情境不會取得儲存庫密鑰 建置、測試、靜態檢查
pull_request_target 基礎分支的工作流程情境 目標儲存庫權限,可能可寫入並存取密鑰 讀取 PR 中繼資料、加標籤、留言
workflow_run 前一工作流程完成後啟動後續工作流程 後續流程可能有較高權限 受控的後續處理

pull_request 執行的程式碼可能包含貢獻者控制的測試、建置指令碼與相依設定,因此應將執行環境視為不受信任。受限權限是安全設計的一部分,不應為了方便測試而加入長期密鑰。

pull_request_target 的工作流程定義取自目標儲存庫的預設分支,並在目標儲存庫情境中執行。它能讀取 PR 中繼資料,適合加標籤或留言;但它可能持有寫入權杖並存取儲存庫密鑰,因此不可檢出 fork 分支並執行其中的程式碼。

workflow_run 也不會自動成為安全的「第二階段」。後續流程可能具有較高權限,而前一流程產生的成品、快取與檔案可能受到不受信任程式碼影響。後續流程只應讀取經過定義與驗證的資料,不要把上游成品當成可信任指令碼執行。

為測試流程明確設定唯讀權杖

先在儲存庫設定中將 GitHub Actions 的預設 GITHUB_TOKEN 權限設為唯讀,再於各工作流程或 Job 宣告實際需要的權限,避免新流程意外繼承寬泛的寫入權限。

以下工作流程只執行 CI 檢查,不會寫入儲存庫:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: CI

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Run tests
        run: ./scripts/test.sh

contents: read 只允許讀取儲存庫內容。persist-credentials: false 可避免 checkout 將權杖保存在本機 Git 設定中,適合不需要推送變更的測試工作。範例中的 Action 版本便於閱讀;正式儲存庫可依政策固定完整提交 SHA,並持續更新。

若 Action 不需要 GitHub API,可省略 Job 層級的 permissions,但要先確認儲存庫的預設權限已收緊。將流程預設設為唯讀,只替確實需要寫入的 Job 增加權限,比整個流程都授予 contents: write 更容易稽核。

不要讓外部 PR 測試取得密鑰

測試通常不應依賴正式部署憑證。可依用途拆分流程:外部 PR 執行不含密鑰的測試;變更合併到受保護分支後,再由部署流程透過 Environment 審核取得部署憑證。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - name: Authenticate to cloud provider
        run: ./scripts/cloud-login.sh
      - name: Deploy
        run: ./scripts/deploy.sh

若雲端服務支援 OIDC,優先使用短效身分憑證,而非把長期雲端密鑰存放在儲存庫。id-token: write 允許申請 OIDC 身分權杖,不代表可寫入儲存庫內容。雲端端仍須將信任條件限制在預期的組織、儲存庫、分支或 Environment,並依雲端文件設定 audience 與 subject 條件。

使用 pull_request_target 更新 PR 中繼資料

若工作只處理事件中的 PR 編號、標題或標籤等中繼資料,可使用 pull_request_target。前提是工作不檢出、不執行 PR 內容,也不將不受信任欄位插入 Shell 指令。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
name: Label pull request

on:
  pull_request_target:
    types: [opened, reopened]

permissions:
  pull-requests: write
  issues: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Add triage label
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          REPOSITORY: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
        run: gh pr edit "$PR_NUMBER" --repo "$REPOSITORY" --add-label "needs-triage"

權杖權限限縮為標籤操作所需的 pull-requests: write 與 issues: write。不同 API 操作需要的權限可能不同;請查閱 GitHub API 文件,只補上明確缺少的權限,不要直接開放所有權限。

將運算式結果先放入環境變數,再以引號參數傳入,可避免把貢獻者控制的文字插入 Shell 程式。避免使用以下寫法:

1
- run: echo "PR title: ${{ github.event.pull_request.title }}"

PR 標題由貢獻者控制,直接插入指令可能造成命令注入。即使只是輸出文字,也應視為不受信任資料。顯示時使用安全的 API 或專用 Action,並限制傳入指令碼的值。

不要在 pull_request_target 中檢出 PR 程式碼

不要將特權觸發條件與以下設定搭配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
on:
  pull_request_target:

jobs:
  unsafe:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
      - run: npm install && npm test

這會檢出外部提交,並在具有儲存庫權限的流程中執行其相依安裝與測試指令碼。惡意相依指令碼可能讀取環境變數、竊取可存取的憑證,或對儲存庫執行寫入操作。把權杖藏在另一個步驟也無法解決問題,因為同一 Job 的步驟共用執行環境。

persist-credentials: false 能降低 checkout 憑證留在 Git 設定中的風險,但不會將特權流程變成沙箱,也不會撤銷環境變數提供給處理程序的權限。不可用這項設定作為在 pull_request_target 執行不受信任程式碼的理由。

不可在 pull_request_target 中檢出 fork 的 head 並執行指令。特權流程中的惡意程式碼可能存取寫入權限或密鑰。persist-credentials: false 無法把特權工作轉變為安全沙箱。

必須檢查 PR 檔案時拆分工作流程

某些自動化需要分析 PR 差異或建置結果,然後將結果發布到 PR。應先讓不含密鑰、唯讀的工作流程處理不受信任程式碼,再由獨立且受限制的後續流程讀取明確定義的結果。

較安全的設計是在 pull_request 中執行測試,只上傳小型、格式簡單的結果檔,第二個流程驗證條件後才讀取。後續流程不可執行前一流程上傳的指令碼、二進位檔或未驗證的序列化物件。

以下範例用來說明資料流隔離,並非可直接套用的權限升級範本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
name: PR checks

on:
  pull_request:
    types: [opened, synchronize, reopened]

permissions:
  contents: read

jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: ./scripts/test.sh
      - name: Write summary data
        if: always()
        run: printf '{"result":"%s"}\n' "${{ job.status }}" > result.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: check-result
          path: result.json
          retention-days: 1

Artifact 名稱、PR 與執行 ID 的關聯、來源、檔案大小及 JSON 解析都必須驗證。檔名叫 result.json 不代表內容可信;PR 流程上傳的資料可能受到貢獻者程式碼影響。

若後續使用 workflow_run,應嚴格限定 workflows 與 types,確認觸發執行來自預期的工作流程與儲存庫,並驗證 PR 編號、提交 SHA 和成品來源。後續 Job 應採最小權限,不要檢出上游 PR,也不要執行成品程式碼。高風險寫入操作可改由維護者手動觸發,或要求 Environment 審核。

若無法可靠驗證成品來源與內容,就不要自動授予寫入權限。保留唯讀 CI,並由維護者在檢查通過後手動留言或合併,是可接受的替代方式。

將寫入操作拆到獨立 Job 並要求審核

不要讓所有 CI 步驟共用寫入權杖。可將測試、建置、發布狀態與部署拆成不同 Job;只有確實需要寫入權限的 Job 才宣告該權限,並依賴前置檢查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4
        with:
          persist-credentials: false
      - run: ./scripts/test.sh

  publish-status:
    needs: test
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      statuses: write
    steps:
      - name: Publish status
        run: ./scripts/publish-status.sh

needs 會等待前置 Job 成功,但它本身不是信任驗證。條件運算式仍須與觸發條件及分支保護規則一起檢查,不可讓 fork 工作流程用可控輸入偽造可信分支判斷。

正式部署時,在儲存庫設定 production Environment,指定必要審核者、允許部署的分支與環境密鑰。審核通過後,部署 Job 才能存取密鑰。Environment 審核只保護環境存取,不會隔離同一 Job 執行的不受信任程式碼,因此部署 Job 不得檢出 fork PR 程式碼。

審查第三方 Action 與快取

每個 uses: 引用的 Action 都會在 Runner 執行。它可能讀取輸入、環境變數及權杖情境。即使 YAML 未明確傳入 GITHUB_TOKEN,Action 仍可能透過 GitHub 情境取得權杖。請檢查 Action 原始碼與版本,並將權杖權限降到最低。

對重要工作流程,將第三方 Action 固定到完整提交 SHA,並從來源儲存庫核對 SHA。@v4 等標籤較易閱讀,但可能被移動;SHA 固定可提高可重現性,代價是必須持續追蹤安全更新。若儲存庫採用 GitHub 支援的工作流程相依鎖定檔,應透過該機制審查與更新相依項目。

快取也可能跨越信任界線。不可讓特權流程執行不受信任 PR 流程寫入的快取內容,也不可讓不受信任流程覆寫受信任分支之後會使用的關鍵快取。快取鍵應包含鎖定檔或可信提交識別,還原後仍須驗證內容,不能把快取當成可信建置成品。

使用 pull_request_target 或 workflow_run 時,尤其要留意共用快取。GitHub 官方文件指出,這些特權觸發條件可能與基礎分支共用快取;若不受信任程式碼污染了後續特權流程使用的快取,風險便會跨越工作流程界線。

檢查執行時的權杖權限

設定合併後,不要只靠 YAML 推測實際權限。可在測試分支觸發工作流程,查看執行頁面顯示的權限,也可加入只輸出必要中繼資料的檢查步驟;切勿輸出權杖本身。

例如,使用 GitHub CLI 檢查儲存庫中繼資料,不公開憑證:

1
2
3
4
5
6
7
8
9
- name: Inspect repository and event
  env:
    GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    REPOSITORY: ${{ github.repository }}
    EVENT_NAME: ${{ github.event_name }}
  run: |
    printf 'Repository: %s\n' "$REPOSITORY"
    printf 'Event: %s\n' "$EVENT_NAME"
    gh api "repos/$REPOSITORY" --jq '{full_name, visibility}'

此請求可驗證讀取能力,不會顯示權杖。若要確認寫入操作是否可行,請在測試儲存庫以最小權限呼叫對應 API 並檢查回應;不要為了通過診斷而長期擴大正式儲存庫權限。

也可使用 GitHub Actions CodeQL 分析或 OpenSSF Scorecard 檢查危險模式、權杖權限與未固定版本的 Action。靜態分析可以指出風險,但不能取代人工確認流程是否執行了不受信任程式碼或消費了不受信任成品。

合併前檢查清單

修改工作流程後,逐項確認:

  • fork PR 測試是否使用 pull_request,且沒有正式環境密鑰?
  • GITHUB_TOKEN 預設權限是否為唯讀,Job 權限是否分別限縮?
  • pull_request_target 是否只處理中繼資料,沒有 checkout 或執行 PR 程式碼?
  • PR 標題、分支名稱與提交訊息是否透過環境變數或安全 API 參數傳遞,而非插入 Shell 原始碼?
  • workflow_run 是否驗證上游流程、執行 ID、PR 與提交 SHA?
  • 後續 Job 是否只讀取受限且已驗證的資料,沒有執行上傳檔案?
  • 第三方 Action 是否已審查並固定版本或提交 SHA?
  • 自架 Runner 是否可能執行來自不受信任分支的工作流程?
  • 快取是否跨越不受信任與特權工作流程的界線?
  • 寫入權限與環境密鑰是否只提供給確實需要的 Job?

保護工作流程檔案本身

工作流程定義位於 .github/workflows/。若外部貢獻者可以修改這些檔案,而維護者只依賴測試通過就自動合併,PR 仍可能繞過權限設計。可為工作流程目錄設定 CODEOWNERS,要求維護者審查觸發條件、權限、Action 來源、運算式與 Runner 選擇。

1
2
# .github/CODEOWNERS
/.github/workflows/ @your-org/security-reviewers

將範例團隊換成實際負責審查的帳號或團隊。確認分支保護規則已要求 Code Owner 審查,並限制可略過審查的人員;只新增 CODEOWNERS 檔案而未啟用保護規則,不會自動要求核准。

也要檢查 action.yml、可重用工作流程、複合 Action 與建置指令碼。工作流程表面上可能只執行 npm test,但儲存庫的 package.json 可以將測試命令對應到任意指令碼。應追蹤完整執行鏈,而非只搜尋 YAML 中明顯的 curl 或 bash。

將事件中繼資料限制在預期的儲存庫與分支

若自動化只處理預設分支上的 PR,可明確列出基礎分支,並確認事件情境來自預期儲存庫。分支篩選可以減少誤觸發,但不構成權限隔離:外部 PR 仍是不受信任程式碼,不可因此在特權流程中執行。

1
2
3
4
on:
  pull_request_target:
    branches: [main]
    types: [opened, reopened, labeled]

事件類型也應盡量精簡。若只需在 PR 建立時加標籤,就不必在每次同步、關閉或留言事件都重新啟動高權限流程。減少觸發次數可降低誤操作機會,讓稽核紀錄更容易理解。

檢查 github.event.pull_request.head.repo.full_name 時,應考慮同一儲存庫分支與 fork PR 的差異。來源分支刪除後,欄位可能為空;必要欄位缺漏時應安全停止,而不是退回寬泛權限。所有事件 JSON 字串都應視為資料,不要串接成 Shell 命令或檔案路徑。

若工作流程只應對特定儲存庫執行,也要確認 base.repo.full_name 與目前儲存庫相符。對可重用工作流程,應檢查呼叫方傳入的參數及權限宣告,不要假設呼叫方必定可信。必要時將允許清單放在受保護的預設分支設定中,並審查其變更。

發送留言或標籤前,可驗證 PR 編號為正整數、儲存庫名稱符合 owner/repo 格式,且標籤屬於固定清單。驗證失敗就停止流程,記錄不含敏感資訊的原因。標籤與命令選項使用固定清單,可避免事件字串被解讀為 CLI 參數。

檢查執行記錄與權限變更

安全審查不應只在初次設定時進行。定期檢查 Actions 執行記錄,特別留意新增寫入權限、密鑰使用、第三方 Action 升級、pull_request_target 觸發條件與 Runner 標籤變更。發現異常執行時,先暫停相關寫入流程,再核對事件、提交 SHA、呼叫的 Action 與日誌輸出。

若密鑰出現在日誌中,應刪除日誌或限制其存取,並立即輪替憑證。自動遮罩無法保證涵蓋所有情況;編碼、串接或截斷後的密鑰可能不會匹配原始值。不要將真實權杖貼進除錯命令、PR 留言或工單,可在臨時測試儲存庫使用無權限的佔位值重現問題。

若團隊由多位維護者共同審查特權流程,可記錄變更目的、權杖權限的必要性、不受信任的輸入、是否會檢出 PR 程式碼,以及回復方式。權限設定錯誤時,先撤銷不必要的寫入權限或停用流程,再評估是否需要輪替權杖與環境密鑰。

常見失敗與處理方式

fork PR 的工作流程讀不到 Secret

這是 GitHub 對外部不受信任貢獻的保護機制。不要把 Secret 寫進工作流程檔案,也不要改用 pull_request_target 執行 PR 內容。將依賴憑證的步驟移到受保護分支的後續流程,或提供不含敏感資料的測試設定。

替 PR 加標籤時收到 403

確認 permissions 包含操作所需的 pull-requests: write 或 issues: write,並檢查儲存庫或組織政策是否進一步限制 Actions。只新增 API 明確要求的權限,不要用全權限權杖排除問題。

Actions 推送後沒有啟動下一個工作流程

為避免遞迴觸發,使用 GITHUB_TOKEN 執行的大多數儲存庫寫入操作不會啟動新的工作流程。若流程依賴自動串接,應重新檢視設計。確實需要額外權限時,可評估明確授權的 GitHub App,並考慮遞迴、權限與稽核風險。

不要只為了繞過限制而改用個人存取權杖。PAT 的權限範圍通常較廣、有效期間較長,且連結個人帳號。若需要額外存取,考慮將 GitHub App 安裝權杖限縮到所需儲存庫與權限。

組織政策阻止寫入權限

儲存庫工作流程的 permissions 無法覆蓋組織層級政策。檢查組織 Actions 設定、儲存庫政策及分支保護,確認實際權限。需要修改時應由管理員在適當層級調整,不要透過更換觸發條件繞過政策。

在自架 Runner 上執行外部 PR

自架 Runner 可能在工作之間保留工作目錄、快取、網路連線與其他憑證。尤其是公開儲存庫,不要讓不受信任的 PR 在可連線內部網路或取得長期密鑰的 Runner 上執行。

優先使用 GitHub 託管的暫時 Runner。若必須自架,請隔離 Runner 群組、限制可使用它的儲存庫與工作流程、移除無關憑證和內部網路存取,並在每次工作後清理環境。只刪除工作目錄無法保證系統重新可信。

依工作目的選擇觸發條件

目標 建議觸發條件 權杖範圍 應避免的動作
測試外部 PR pull_request 內容唯讀 注入正式密鑰、保留 Git 憑證
為 PR 加標籤或留言 pull_request_target 僅必要的 PR/Issue 權限 檢出 PR head、執行 PR 指令碼
部署可信任分支 對受保護分支使用 push 內容唯讀及所需 OIDC 權限 在部署 Job 執行未審查的 PR 程式碼
彙整前一流程結果 嚴格驗證的 workflow_run 最小寫入權限 直接執行上游成品或信任共用快取

若自動化同時需要執行貢獻者程式碼並寫入目標儲存庫,請拆成兩個信任階段。第一階段只執行不受信任程式碼所需的測試;第二階段只在受控條件下寫入中繼資料或部署。階段間傳遞的內容應盡量精簡、可驗證,並以不受信任資料解析。

參考資料