GitHub Actions で外部 PR を安全に扱う:pull_request、権限、ワークフローの分離

GitHub Actions で外部 Pull Request を扱う際の権限境界を解説し、pull_request_target、GITHUB_TOKEN、ワークフロー分離の安全な設定例を紹介します。

GitHub Actions に自動ラベル、歓迎コメント、レビュー bot を追加すると、通常の pull_request ワークフローではリポジトリのシークレットや書き込み権限を利用できず、pull_request_target に切り替えたくなることがあります。このイベントは対象リポジトリの特権コンテキストで実行されます。その状態でコントリビューターのコードを checkout して実行すると、悪意ある変更によってシークレットの読み取り、リポジトリの変更、キャッシュの汚染が起こる可能性があります。

安全設計の要点は、イベント名だけで安全性を判断せず、信頼できないコードの実行と特権操作を分離することです。イベントの違い、最小権限、危険な組み合わせ、設定例、確認手順を説明します。

イベントのコンテキストとコードの出所を分けて考える

GitHub Actions のトリガーは、ワークフローから見えるイベント情報、トークン権限、シークレットを決めます。外部コントリビューターの Pull Request では、主に pull_request と pull_request_target を使います。PR にコメントできるかだけで選ばず、実行する作業に合わせて選択してください。

トリガー デフォルトで checkout されるコード fork PR の一般的な権限 適した作業
pull_request テスト用のマージコミット 通常は制限され、fork PR からリポジトリのシークレットにアクセスできない ビルド、テスト、静的解析
pull_request_target ベースブランチのコンテキスト 対象リポジトリの権限。書き込みやシークレットへのアクセスを含む場合がある PR メタデータの参照、ラベル、コメント
workflow_run 別のワークフロー完了後に後続ワークフローを起動 後続側の権限が高い場合がある 制御された後処理

pull_request が実行するコードには、コントリビューターが変更できるテスト、ビルドスクリプト、依存関係の設定が含まれます。Runner は信頼できない入力を処理するものとして扱いましょう。権限の制限は安全設計の一部です。テストを簡単にするために長期シークレットを追加しないでください。

pull_request_target のワークフロー定義は対象リポジトリのデフォルトブランチから取得され、対象リポジトリのコンテキストで実行されます。PR メタデータを扱えるため、ラベルやコメントには適しています。しかし書き込みトークンやリポジトリのシークレットを使える場合があるため、fork のブランチを checkout してコードを実行してはいけません。

workflow_run も自動的に安全な「第 2 段階」になるわけではありません。後続ワークフローが高い権限で動く一方、前段が生成した Artifact、キャッシュ、ファイルは信頼できないコードの影響を受ける可能性があります。後続では定義済みの検証済みデータだけを読み取り、上流 Artifact を信頼できるスクリプトとして実行しないでください。

テスト用ワークフローのトークンを読み取り専用にする

リポジトリ設定で 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 設定に保存することを防ぎます。後続の push が不要なテストに適しています。例では読みやすさのため Action のバージョンタグを使っています。本番ではポリシーに合わせて完全なコミット SHA に固定し、定期的に更新してください。

GitHub API を必要としない Action なら、リポジトリの既定トークン権限が制限されていることを確認したうえで、Job レベルの permissions を省略できます。ワークフロー全体を contents: write にするより、既定を読み取り専用にし、書き込みが必要な Job だけ個別に権限を与える方が監査しやすくなります。

外部 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 ID トークンの要求を許可するもので、リポジトリ内容への書き込み権限ではありません。クラウド側でも信頼対象を想定する組織、リポジトリ、ブランチ、または Environment に制限します。audience と subject の条件はプロバイダーの公式手順に従って設定してください。

テストは通常、本番デプロイの資格情報を必要としません。外部 PR ではシークレットなしでテストし、保護ブランチにマージした後のデプロイ処理で Environment の承認を通じて資格情報を利用します。

PR メタデータへの書き込みに pull_request_target を使う

PR 番号、タイトル、ラベルなどイベントのメタデータだけを扱い、PR の内容を checkout も実行もしない場合は pull_request_target を利用できます。信頼できない値を 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 コードを checkout しない

特権トリガーと次の設定を組み合わせないでください。

 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

この設定は外部コミットを checkout し、リポジトリ権限を持つワークフローで依存関係のインストールとテストスクリプトを実行します。悪意ある依存スクリプトが環境変数を読み、資格情報を盗み、リポジトリへ書き込む可能性があります。トークンを別のステップに隠しても解決しません。同じ Job のステップは実行環境を共有しており、プロセス環境やファイルから取得される可能性があります。

persist-credentials: false は Git 設定に checkout 資格情報が残るリスクを減らしますが、特権ワークフローをサンドボックス化したり、環境変数で与えられた権限を取り消したりはしません。これを理由に pull_request_target で信頼できないコードを実行しないでください。

pull_request_target で fork の head を checkout してコマンドを実行しないでください。ワークフローに書き込み権限やシークレットがあると、悪意あるコードがそれらへアクセスできる可能性があります。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、Artifact の出所を検証してください。後続 Job の権限を最小化し、上流 PR を checkout したり、Artifact のコードを同じ Job で実行したりしないでください。高リスクの書き込みはメンテナーが手動で起動するか、Environment の承認を必要にします。

Artifact の出所と内容を確実に検証できない場合は、書き込み権限を自動で付与しないでください。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 のコードを checkout しないでください。

サードパーティ Action とキャッシュを確認する

uses: で参照する各 Action は Runner 上で実行されます。入力、環境変数、トークンコンテキストにアクセスできる可能性があります。YAML で GITHUB_TOKEN を明示的に渡していなくても、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 の固定状況を確認する方法もあります。静的解析はリスクを示せますが、不信頼コードの実行や不信頼 Artifact の利用を人が確認する作業の代わりにはなりません。

マージ前のチェックリスト

ワークフロー変更をマージする前に確認します。

  • fork PR のテストは pull_request を使い、本番シークレットを持ち込んでいないか。
  • 既定の GITHUB_TOKEN 権限は読み取り専用で、Job ごとに必要範囲へ制限されているか。
  • pull_request_target はメタデータだけを扱い、PR コードを checkout・実行していないか。
  • 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、ビルドスクリプトも確認します。YAML では 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 コードを checkout するか、ロールバック方法を記録できます。権限を誤設定した場合、まず不要な書き込み権限を取り消すかワークフローを無効にし、次にトークンと Environment シークレットのローテーションが必要かを判断します。

よくある失敗と対処

fork PR のワークフローから Secret を読めない

これは外部からの信頼できないコードを保護する GitHub の動作です。Secret をワークフローに書いたり、pull_request_target に変更して PR コードを実行したりしないでください。資格情報が必要な処理を保護ブランチの後続フローへ移すか、テストには機密情報を含まない設定を用意します。

PR にラベルを付けると 403 になる

permissions に操作に必要な pull-requests: write や issues: write があるか、組織やリポジトリのポリシーで Actions の権限が制限されていないか確認します。API が必要とする権限だけを追加し、全権限トークンを使った切り分けは避けます。

Actions が push した後に次のワークフローが起動しない

GITHUB_TOKEN を使う多くの書き込み操作は、再帰的な起動を防ぐため新しいワークフローを開始しません。自動連鎖が必要な設計を見直し、追加権限が必要なら GitHub App などを検討します。再帰、権限、監査のリスクを評価してください。

個人アクセストークンでこの制限を回避するのは避けます。PAT は権限範囲が広く、長期間有効で、個人アカウントに結びつくことがあります。追加権限には、対象リポジトリと権限を絞れる GitHub App のインストールトークンを検討します。

組織ポリシーにより書き込み権限を使えない

リポジトリの permissions で組織ポリシーを上書きすることはできません。組織の Actions 設定、リポジトリポリシー、ブランチ保護を確認します。変更が必要なら管理者が適切な設定箇所を更新してください。トリガーの変更で制限を回避しないでください。

自己ホスト Runner で外部 PR を実行する

自己ホスト Runner には作業領域、キャッシュ、内部ネットワークへの接続、ほかの資格情報が残る場合があります。公開リポジトリでは、不信頼 PR を内部ネットワークや長期シークレットにアクセスできる Runner で実行しないでください。

GitHub ホストの一時 Runner を優先します。自己ホストが必要なら Runner グループを分離し、利用できるリポジトリとワークフローを制限し、不要な資格情報やネットワークアクセスを除き、実行後に環境を清掃します。作業ディレクトリだけの削除ではシステムの信頼性を保証できません。

作業内容に応じてトリガーを選ぶ

目的 推奨トリガー トークン範囲 避ける操作
外部 PR のテスト pull_request contents の読み取りのみ 本番シークレットの投入、Git 資格情報の保持
PR にラベルやコメントを付ける pull_request_target 必要な PR/Issue 権限のみ PR head の checkout、PR スクリプトの実行
信頼済みブランチのデプロイ 保護ブランチへの push contents の読み取りと必要な OIDC 権限 デプロイ Job で未レビューの PR コードを実行
前段ワークフローの結果集約 厳密に検証した workflow_run 必要最小限の書き込み 上流 Artifact の直接実行、共有キャッシュの無条件信頼

コントリビューターのコード実行と対象リポジトリへの書き込みが両方必要なら、2 つの信頼段階に分けます。第 1 段階では不信頼コードに必要なテストだけを行います。第 2 段階は管理された条件のもとでメタデータを書き込むかデプロイします。段階間で渡す内容を小さく、検証可能にし、不信頼データとして解析してください。

参考資料