tech.guitarrapc.cóm

Technical updates

OSSリポジトリで外部PRをどこまで信頼せずに設計するか

2025年のtj-actions侵害に続き、2026年もTanStack侵害があり、GitHub Actionsを経由した攻撃が継続して報告されています。よくも悪くもGitHubとGitHub ActionsはOSSリポジトリのCI/CDのデファクトスタンダードになっているため、攻撃者から見ても魅力的な攻撃対象になっていると感じます。CircleCIなどの外部CIと比べても、GitHub Actionsは権限や処理を追いかけやすく、ログも含めて攻撃しやすい面があります。

本記事は、2026年時点でも攻撃の起点になっている外部コントリビューターからのPR(以降、外部PR)にどう向き合うのがよいのかを整理したメモです。

外部PRをどう扱うのが現実的かを軸に、pull_request_targetやPRレビューの限界、侵入されたときの緩和策、GitHub Actions以外を併用する選択肢に焦点を当てます。

長いのでまとめ

記事が長いので、先に要点をまとめます。

  • 外部PRを受け付けるか決める
    • 外部PRを受け付けないなら、Settings > General > FeaturesのPull requestsをCollaborators onlyにする
    • 外部PRを受け付けるなら、Settings > ActionsRequire approval for all external contributors を有効にする
  • pull_request_target は使わない、pull_request で代替することを前提にする
    • どうしてもpull_request_targetを使うなら、actions/checkoutactions/cacheは使わない、権限はreadにとどめる
    • workflow_runイベントも同様に危ういので可能なら使わない
  • issuesissue_comment 起点のワークフローで、write 権限やスクリプト実行が混ざっていないか見直す
  • 外部PRでGitHub Actionsファイルの変更がPRに含まれているなら原則リジェクトする
  • GITHUB_TOKEN のデフォルト権限を最小化し、Allow GitHub Actions to create and approve pull requestsはオフにする
  • レビューにCopilot Code Review、CodeQL、Lintの補助を入れる
  • Immutable Release、Environmentを使った承認、OIDCトークン短命化で侵入後の被害を小さくする
  • npm stageは、これまでのリリース侵害に対するよい緩和策で他のパッケージマネージャーにも相当するものが欲しい

なかなか大変ですね。

外部PRはどう扱うべきか

外部PRをどこまで信頼できるかよりも「フォークPRに対してCIを実行すること自体がリスク」と前提を置いています。tj-actionsやTanStackをはじめとして、発生しているサプライチェイン問題の多くは、外部PRでCI(GitHub Actions)を動かすことが起点になっています。1

フォークPRに対してCIを実行することは、PRを通した任意のコードの挿入を許可するリスクととらえられます。npmに代表されるパッケージシステムによるサプライチェインが広がった現状において、任意コードを挿入された汚染パッケージは利用者に一気に波及するため、攻撃者から見ると任意コードを差し込む動機とリターンが高いといえます。GitHub Actionsのリモートアクションも同様にサプライチェインが広がっており、1つが侵害されると波及的に影響を広げます。

OSSプロジェクトの運営者としては、多数の外部PRから攻撃を見分ける難しさ、マージした後に気づくのが難しいこと、一度でも見逃すと影響が大きいこと、などが積み重なり頑張らないといけないという状況です。残念ながら一度でも悪意ある仕込みが入った外部PRをマージすると、デフォルトブランチが侵害されます。最初に一見すると害がない外部PRをマージさせて少しだけ安全性を緩めて、さらなるPRで間隙を突く手法もあります。デフォルトブランチの安全性が失われると、リリースにまで影響する可能性が高くなります。気づかずにリリース(パッケージ配布)まで到達すれば、攻撃者は本来の狙いに到達するまでサプライチェイン侵害を広げ続けます。

セキュリティ全般に言えることですが、一度侵入されたときのリスクが高いため、外部PRをありがたく受け入れることとリスクのバランスは崩れつつあると感じています。

内部コントリビューターの話を割愛する理由

あえて外部コントリビューターとしましたが、中にはXZ Utilsのバックドアのように、プロジェクトのコントリビューターとして信頼を積み重ねた上で、悪意あるコードを仕込む攻撃もあります。このケースを持ち出すと、OSSプロジェクト自体の信頼性の問題に話が飛んでしまうので、ここではあえて外部コントリビューターに限定して話を進めます。

この話は、ネットワークにおける境界防御の限界と、ポリシーをベースにしたゼロトラストセキュリティの必要性に似た面があると感じます。ただ、異なるコンテキストが混じるので、ここでは触れないことにします。

外部PRをどこまで信頼するかは、プロジェクトの性質や運営方針によって異なりますが、少なくとも外部PRに対してCIを実行すること自体がリスクであることを認識しておく必要があります。2026年5月現時点では、外部PRをそもそも受け付けない、あるいは受け付けるにしてもワークフロー実行の承認を慎重にする選択肢が考えられます。LLMによるコード生成の普及もあり外部PRがなくともLLMでコードを用意できうるシーンが増えてきていることもあり、外部PRを重視する比重に変化があるのは事実でしょう。

外部PRを受け付けないという選択

そもそも外部PRを受け付けないという選択肢もあります。GitHubは2026年2月にPull Requestの受付を内部コラボレーターに制限するPull request permissionsオプションを追加しました。デフォルトはAll usersです。

Pull request permissionsのデフォルトはAll users

Collaborators onlyを選択すると外部PRは受け付けなくできます。

Collaborators only

コードの変更は内部コラボレーターのみで行い、パブリックにコードやリリース、Issueは公開というスタイルに向いています。外部PRを受け付けないことで、CI経由の攻撃リスクを低減できます。コードを書き換えられる最も大きな口を塞げるというのは選択肢としては十分に魅力的でしょう。外部PRを受け付けないといっても、Issue(場合によってはDiscordなどの外部コミュニケーションチャンネル)を通じて、外部からのフィードバックやバグ報告を受け付けることはできるので、コントリビューションができなくなるわけではありません。

ただ、IssueやIssue Comment経由で起動するGitHub Actionsがあるかは注意しましょう。contents: writepull-requests: writeなどの権限を持っていると攻撃する口になります。たとえば、次のようなワークフローは一見よくありそうですが、危険の塊です。

on:
  issues:
    types: [opened, edited]  # <- 攻撃で使いやすい
  issue_comment:
    types: [created, edited] # <- さらに攻撃で使いやすい

jobs:
  example:
    permissions:
      contents: write        # コードの書き換えpushを許容してしまう
      pull-requests: write   # PRの作成や更新を許容してしまう
    runs-on: ubuntu-24.04
    steps:
      - name: Do something when an issue is opened or commented
        run: echo "Issue #${{ github.event.issue.number }} was ${{ github.event.action }}"
      - name: Do something when an issue is opened or commented  # <- Issueタイトル経由でスクリプトインジェクション攻撃できる
        run: echo "Issue ${{ github.event.issue.title }} "
      - name: Do something when an issue is opened or commented  # <- envに入れてダブルクォートで参照することでインジェクションを防げる
        run: echo "Issue ${ISSUE_TITLE} "
        env:
          ISSUE_TITLE: ${{ github.event.issue.title }}           # <- ここで環境変数に入れる

Issue/Issueコメントをスクリプトで直接解釈すると、攻撃者が任意のコードを差し込める余地があります。そもそも、外部PRを受け付ける受け付けないに関わらず、ワークフローでむやみとIssue/Issue Commentを使わないようにしましょう。

外部PRを受け付けるならワークフロー実行を慎重に許可する

GitHubには、フォークPRからのPRでワークフローを実行するためのいくつかのオプションがあります。これを使うと、外部PRに対してGitHub Actionsが実行される前に承認を設けるかを調整できます。Organization(リポジトリ)の Settings > Actions に次のオプションがあります2

  • Require approval for first-time contributors who are new to GitHub: GitHubに新規で、かつこのリポジトリでコミット/PRもマージされたことがないユーザーだけがワークフロー実行の承認を必要とする
  • Require approval for first-time contributors: このリポジトリでコミット/PRもマージされたことがないユーザーだけがワークフロー実行の承認を必要とする (デフォルト)
  • Require approval for all external contributors: 外部コントリビューター全員がワークフロー実行の承認を必要とする

Approval for running fork pull request

現実的にはRequire approval for all external contributorsを選択するしかないでしょう。外部PRを信頼することは難しいという前提に立つと、「一度でもPRでCI実行を許可した後は、その人の以後のコミットでCIが実行される」というのはあまりにも無防備だからです。依然としてPRの内容を十分確認せずに承認してしまうリスクは残りますが、一度PRをマージさせた後に悪意あるPRを投げてくるようなパターンに対して、意図せずCIが実行されるリスクは減らせます。

後述しますがこのオプションを有効にしていてもpull_request_targetイベントを利用しているワークフローは「初回承認」をバイパスするため、あらゆる外部PRの承認なしCI実行を防ぐオプションにはなりません。

なぜpull_request_targetは避けたいのか

私は、pull_request_targetイベントは、事故を起こさないように使うにはあまりに難しすぎると考えています。たとえLLMでワークフローの内容を検証したとしても、継続的に事故が起こらないように使い続けられる自信がありませんし、リモートアクションを使っていたらなおさらです。

pull_request_targetイベントは、PRのRequire approvalをバイパスして実行されるためワークフローの書き方次第で事故が起きます。permissionsでwrite権限を使っていたり、キャッシュを使っていても問題になります。キャッシュのコンテンツ内容を監査しているOSSは果たしていくつあるかも疑問です。permissionsでreadを徹底するのは抜けやすいことを考えると、pull_request_targetは使わないほうがいいです。3

# ↓ そもそも pull_request_targetイベントは使わないほうがいい。代わりにpull_requestイベントを使う。
on:
  pull_request:
    branches: [main]

これまでに判明しているGitHub Actionsを経由した攻撃の多くは、pull_request_targetイベントを利用しています。個人的には、pull_request_targetイベントを利用しているワークフローはpull_requestイベントに今すぐ差し替えることを強くおすすめします。ほんとうに。

pull_request_targetイベントは次のような特徴があるため、攻撃をする側から見ると「リポジトリオーナーが認識できていない経路」で攻撃しやすいです。

  • 先に紹介した外部PRに対する承認(Require approval)がバイパスして実行される
  • pull_request_targetイベント + actions/checkoutの組み合わせでフォーク先のコードが無条件でチェックアウトされる
  • contents:writeでコード書き換え、actions/cacheでキャッシュポイゾニングが可能 (キャッシュ経由でデフォルトブランチまで侵入できる)

これまでにもpull_request_targetイベントを利用した攻撃が複数回報告されていることから、pull_request_targetイベントが危ない認識は十分広がっており、contents: writepull-requests:writeのようなwrite書き込みをしないことは共通認識でしょう。

しかし2026年5月11日に発生したTanStackインシデントはバンドルサイズを計測するためにpull_request_targetイベントを使っており攻撃を防ぐことができませんでした。経過の詳細は記事を読んでいただきたいですが、トリガーがpull_request_targetイベント、汚染の起点はactions/checkoutactions/cacheでした。キャッシュは本当に制御が難しいです。

TanStackインシデントにみるpull_request_targetの難しさ

TanStackが攻撃されたときのワークフローとアクションは過去のコミットから確認できます。経過を軽く見てみましょう。

攻撃者はTanStack/routerをフォーク。TanStack/routerにPRを作成して、pull_request_targetイベントを利用しているbundle-size.ymlワークフローのactions/checkoutでフォーク先コードを展開、pnpm install + pnpm nx run @benchmarks/bundle-size:buildを実行、vite_setup.mjsを実行してactions/cacheのキャッシュを汚染しました。キャッシュ汚染後、PRのHEADをmain HEADにforce pushしてファイル変更0にしつつPRをクローズ、ブランチが自動削除されています。あとは、正規の開発フローで汚染済みのキャッシュが展開、リリースに混入するのを待つだけです。実際、リリース時に汚染済みのキャッシュが展開され、npmへTrusted Publishing経由で汚染パッケージが公開されました。

PRとワークフローを見てみましょう。

まず、攻撃者はPRを作ります。起点は、TanStack/router/.github/workflows/bundle-size.ymlワークフローのpull_request_targetイベントです。このワークフローはactions/checkoutでフォーク先のコードをチェックアウトし、さらにOrg内部のアクションtanstack/config/.github/setup@mainを呼び出しています。

name: Bundle Size
on:
  # We use `pull_request_target` to split trust boundaries across jobs:
  # - `benchmark-pr` checks out PR merge code and runs it as untrusted with read-only permissions.
  # - `comment-pr` runs trusted base-repo code with limited write access to upsert the PR comment.
  pull_request_target:
  # ... 省略

permissions:
  contents: read

# ... 省略

jobs:
  benchmark-pr:
    name: Benchmark PR
    if: github.event_name == 'pull_request_target'
    runs-on: ubuntu-latest
    outputs:
      current_json_b64: ${{ steps.capture.outputs.current_json_b64 }}
    steps:
      - name: Checkout
        uses: actions/checkout@v6.0.1
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          fetch-depth: 0
          persist-credentials: false
      - name: Setup Tools
        uses: tanstack/config/.github/setup@main
      - name: Measure Bundle Size
        run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache

# ... 省略

呼び出されたTanstack/config/.github/setup/action.ymlアクション(同OrganizationのAction用リポジトリ)でactions/cacheを実行していたため、フォーク元(TanStack/router)のキャッシュがフォーク先コードで汚染されました。

name: Setup Tools
description: Action that sets up Node, pnpm, and caching
runs:
  using: composite
  steps:
    - name: Setup pnpm
      uses: pnpm/action-setup@v4.4.0
    - name: Setup Node
      uses: actions/setup-node@v6.3.0
      with:
        node-version-file: .nvmrc
        package-manager-cache: false
    - name: Get pnpm store directory
      shell: bash
      run: |
        echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
    - name: Setup pnpm cache
      uses: actions/cache@v5.0.4
      with:
        path: ${{ env.STORE_PATH }}
        key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
        restore-keys: |
          ${{ runner.os }}-pnpm-store-
    - name: Install dependencies
      shell: bash
      run: pnpm install --frozen-lockfile

PRでforce pushを多用してpull_request_targetを利用した多重トリガーしたり、最後に差分をなくすなど、攻撃が発覚しにくいように工夫しています。汚染のためにgit force pushを繰り返し行っているのも攻撃にしばしばみられる特徴です。攻撃者のフォークリポジトリは残っているものの、アクティビティを見てもforce pushコミットの内容は確認できなくなっています。残念ながら、第三者が攻撃の内容を追いかけるのは難しい状況です。

インシデントを受けたTanStackは、pull_request_targetからpull_requestへ変更、かつSetup toolsのactions/cacheをやめています。この対応は個人的に納得できるものです。

TanStackの対応

変更後のBundle Size処理を見ると、actions/cacheの代わりに$GITHUB_OUTPUT経由でバンドルサイズを渡してPRへレポートしている。

name: Bundle Size

on:
  # <- もうpull_request_targetは使っていない
  pull_request:
    paths:
      - 'packages/**'
      - 'benchmarks/**'
  # ここでは省略しているがpushトリガーもある

# 省略

jobs:
  benchmark-pr:
    name: Benchmark PR
    if: github.event_name == 'pull_request' # <- イベントを縛っている
    runs-on: ubuntu-latest
    outputs:
      current_json_b64: ${{ steps.capture.outputs.current_json_b64 }} # comment-prに結果を渡す
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
          fetch-depth: 0
          persist-credentials: false

      - name: Setup Tools              # <- もうactions/cacheをしていない
        uses: TanStack/config/.github/setup@e4b48f16568324f76f467aa4c2aac2f05db632c3 # main

      - name: Measure Bundle Size
        run: pnpm nx run @benchmarks/bundle-size:build --outputStyle=stream --skipRemoteCache

      - name: Capture Benchmark Outputs # <- バンドルサイズをエンコードしている
        id: capture
        run: |
          {
            echo "current_json_b64=$(base64 -w 0 < benchmarks/bundle-size/results/current.json)"
          } >> "$GITHUB_OUTPUT"

  comment-pr:
    name: Upsert PR Comment
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    needs: benchmark-pr    # <- benchmark-prの結果を受け取る
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Restore Benchmark Outputs
        env:
          CURRENT_JSON_B64: ${{ needs.benchmark-pr.outputs.current_json_b64 }} # benchmark-prからバンドルサイズの結果を受け取る
        run: |
          mkdir -p benchmarks/bundle-size/results
          node -e "const fs=require('node:fs'); fs.writeFileSync('benchmarks/bundle-size/results/current.json', Buffer.from(process.env.CURRENT_JSON_B64 || '', 'base64'))"

      - name: Read Historical Data (if available)                # <- ベースラインのバンドルサイズを取得
        run: |
          mkdir -p benchmarks/bundle-size/results
          if git fetch --depth=1 origin gh-pages; then
            if git show origin/gh-pages:benchmarks/bundle-size/data.js > benchmarks/bundle-size/results/history-data.js 2>/dev/null; then
              echo "Loaded bundle-size history from gh-pages."
            else
              rm -f benchmarks/bundle-size/results/history-data.js
              echo "No bundle-size history found on gh-pages yet."
            fi
          fi

      - name: Build PR Report
        env:
          BASE_SHA: ${{ github.event.pull_request.base.sha }}
          REPOSITORY_NAME: ${{ github.event.repository.name }}
          REPOSITORY_OWNER: ${{ github.repository_owner }}
        run: |
          node scripts/benchmarks/bundle-size/pr-report.mjs \
            --current benchmarks/bundle-size/results/current.json \
            --history benchmarks/bundle-size/results/history-data.js \
            --output benchmarks/bundle-size/results/pr-comment.md \
            --base-sha "$BASE_SHA" \
            --dashboard-url "https://${REPOSITORY_OWNER}.github.io/${REPOSITORY_NAME}/benchmarks/bundle-size/"

      - name: Upsert Sticky PR Comment                              # <- PRにコメント
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          node scripts/benchmarks/common/upsert-pr-comment.mjs \
            --pr "${{ github.event.pull_request.number }}" \
            --body-file benchmarks/bundle-size/results/pr-comment.md

pull_request_targetをどうしても使いたい

ここまでの内容を踏まえて、なおpull_request_targetを使うならactions/checkoutは避けましょう。GitHub Actions Permissionsはread権限にとどめ、actions/cacheも禁止にしましょう。可能な限りフォーク先のコードを経由せず、何かを操作できる経路を減らします。

name: Bundle Size

on:
  pull_request_target:
    branches: [main]

jobs:
  example:
    permissions:
      # contents: write      # <- これはダメ
      contents: read         # <- どうしてもチェックアウトするならreadにとどめる
      issues: read           # <- gh issue listで読み取りに使う
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      # - uses: actions/checkout@v6.0.1 <- これはフォーク側をチェックアウトするので避ける
      #   with:
      #     persist-credentials: false
      # - name: Cache                   <- これは絶対避ける
      #   uses: actions/cache@v5.0.4
      #   key: ${{ runner.os }}-foo-bar-${{ hashFiles('**/some-lock.yaml') }}
      - name: list issues without checkout or cache
        run: gh issue list .... # 書き込みとか、チェックアウトしない処理だけにしたい
        env:
          GH_TOKEN: ${{ github.token }}

workflow_runイベントという代替手段の難しさ

GitHubは、pull_request_targetより安全な代替案としてpull_requestworkflow_runイベントの組み合わせを紹介しています。

pull_requestとworkflow_runイベントを組み合わせる例

pull_requestイベントでビルドして、workflow_runイベントでSecrets.FOOなどシークレットが必要な処理を行えば、PRのCIで攻撃されるリスクを減らしつつ、マージ後のリリースで攻撃されるリスクも減らせるという建付けです。

name: Bundle Size

on:
  pull_request:
    branches: [main]

jobs:
  build:
    permissions: {}           # <- 権限をミニマムにする
    runs-on: ubuntu-24.04
    timeout-minutes: 10
    steps:
      - name: Save PR number
        env:
          PR_NUMBER: ${{ github.event.number }}
        run: |
          mkdir -p ./pr
          echo $PR_NUMBER > ./pr/pr_number
      - uses: actions/upload-artifact@v4    # <- workflow_runイベントに渡すためのデータをアップロード
        with:
          name: pr_number
          path: pr/

Bundle Sizeが正常完了したら、別のワークフローworkflow_runイベントをトリガーしてビルド結果のアーティファクトをダウンロードします。これで、PRが起点となりつつも、直接フォーク先のコードをチェックアウトすることなく、ビルド結果だけを利用できます。

on:
  workflow_run:
    workflows: ["Bundle Size"] # 上のワークフロー名
    types: [completed]

jobs:
  download:
    runs-on: ubuntu-latest
    steps:
      - name: 'Download artifact'           # <- 呼び出し元のアーティファクトをダウンロードするためにgithub-scriptを使う
        uses: actions/github-script@v8
        with:
          script: |
            let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
               owner: context.repo.owner,
               repo: context.repo.repo,
               run_id: context.payload.workflow_run.id,
            });
            let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
              return artifact.name == "pr_number"
            })[0];
            let download = await github.rest.actions.downloadArtifact({
               owner: context.repo.owner,
               repo: context.repo.repo,
               artifact_id: matchArtifact.id,
               archive_format: 'zip',
            });
            const fs = require('fs');
            const path = require('path');
            const temp = '${{ runner.temp }}/artifacts';
            if (!fs.existsSync(temp)){
              fs.mkdirSync(temp);
            }
            fs.writeFileSync(path.join(temp, 'pr_number.zip'), Buffer.from(download.data));

      - name: 'Unzip artifact'
        run: unzip "${{ runner.temp }}/artifacts/pr_number.zip" -d "${{ runner.temp }}/artifacts"

      - run: 何かしらの処理     # <- ここでdist/の中身を利用する

とはいえ、このワークフローはdist/を受け渡しているためdist/にビルド結果以外が仕込まれたりすると、dist/経由で攻撃するリスクがあります。この線引きとリスクのバランスをどうするかはプロジェクトの性質や運営方針によるでしょう。

ただ、workflow_runイベントもpull_request_target同様に権限が強すぎて同様に危うさがあります。例えば、workflowがメインブランチ以外もトリガーできるため、トリガーがどこだったか追いにくいという面は制御が難しくなりやすいです。利用は慎重に、可能なら使わないほうがいいでしょう。

on:
  workflow_run:
    workflows: ["Bundle Size"] # <- ワークフロー名を名前で指定できるため、意図しないワークフローもトリガーできてしまう。ワークフローIDのほうが安全そうなのに...
    types: [completed]         # <- ワークフローが完了したときに呼ばれる。フォークPRを起点としたワークフローか、などの制御がない
    # 必要ならブランチも指定できるので、メインブランチだけという保証もない

PRレビューだけで防げるのか

「見落としのないPRレビューは難しい」、これはレビュー経験がある多くの人が同意するところじゃないでしょうか。私自身、baseNN(base64など)エンコードされた文字列、不可視文字をファイルに紛れ込ませたPRを見落とさない自信はありませんし、とても怖いです。

不可視文字を使ったコード侵入といえばGlassWormサプライチェイン攻撃があります。何が書かれているか気づけなかったときを考えると外部PRでCIを承認したくないものの、現実的にはCIで検知するしかないでしょう。一般的なコーディングにおいて不可視文字やエンコーディング文字列を使うシーンが限定されるはずなので、それ自体をはじくのも有効になりえます。もちろん誤検知もあるでしょうが、外部PRの内容を機械的にチェックする仕組みで人間の見落としを減らしましょう。

対応例:

  • 行差分に対して異様な長さの文字列をはじく
  • 不可視文字に相当するUnicodeレンジチェック
  • base64などのエンコードされた文字列、不可視文字列の検出にLLMを併用して解釈ヒント
  • Copilot Code ReviewのようなLLMによるコードレビューをハーネスとして用意する
  • CodeQLを有効にする

少し前までは、CIではビルドやリグレッションテスト、パフォーマンス悪化を確認する程度が一般的でした。しかし、妙なコードが紛れていることを前提にチェックしていくというのも今後増えることでしょう。CIでビルドしかチェックしない牧歌的なやり方の終焉を予感させます。

正直なところ、外部コードをチェックアウトしたくない気持ちがあるため、ユーザー側で外部PRに変な文字列がないか検知するのはあまりやりたくない、というのが本音です。できることならGitHub側でうまく検知して弾けるようにしてほしいです。CodeQLなどGitHub提供のセキュリティ機能で怪しい攻撃パターンへのサポートが強化されると、運用負荷はかなり減るはずです4

フォークコミットを拾える仕組みの問題

GitHubはフォーク元からフォーク先のコミットを拾えるのですが、これがかなり厄介です。

TanStack/routerでフォーク先のコミットを見られるか試しましょう。フォーク先であるsprusr/routerにコミット https://github.com/sprusr/router/commit/60d63821f2a3cc2b9f808da9467ee9618c83e198 があります。 フォーク元のTanStack/routerでこのコミットはまだマージされていないので、本来 https://github.com/TanStack/router/commit/60d63821f2a3cc2b9f808da9467ee9618c83e198 はアクセスできないでほしいのですが、実際はフォーク先のコミットにTanStack/routerリポジトリのコミットURLからアクセスできます。

フォーク先のコミットをフォーク元リポジトリから参照できる

これが問題になるのが、GitHub ActionsのリモートアクションをSHAピンニングするプラクティスです。GitHub Actionsのリモートアクションは、actions/checkout@v6のような書き方ではなくactions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 のようにSHAでピンニングして参照することが推奨されています。バージョンタグで指定しているとバージョン側に不意の変更が入った時に意図せず参照するため、SHAでピンニングして影響を緩和するのが目的です。5

しかしリモートアクションをSHAでピンニングしても、フォーク先のコミットはアクセスできます。攻撃者から見ると、攻撃したいリポジトリで利用しているアクションをフォークして、コードをプッシュ。攻撃したいリポジトリのアクションSHAピンを書き換えるという流れで任意のアクションを呼ばせる余地があります。うかつに承認すれば、任意のコードが差し込めそうですね。

jobs:
  example:
    runs-on: ubuntu-latest
    steps:
      - uses: example/actions@0123456789abcdefg # imposter commitの例 (example/actionsではなく example111/actionsというフォークコミットを指しているとする)

ユーザーができる対策は2つあります。

  • Linterによる検知
  • GitHub Actionsファイルの変更を許容しない

そもそも外部PRでGitHub Actionsファイルを変更できるのが問題なので、外部PRでGitHub Actionsファイルの変更を許容しないのは妥当でしょう。触らせないは大事。怪しいコミットを検出Linterとして、私が作っているguitarrapc/seitonzizmorがあります。

  • seiton: Imposter CommitとしてアクションのSHAが、アクションオーナーのコミットであるかを検査するルールを提供
  • zizmor: Imposter CommitとしてアクションのSHAが、アクションオーナーのコミットであるかを検査するルールを提供

検知に限界はあるものの、ほとんどのケースでは有効に検知するはずです。

侵入されたときの被害をどう小さくするか

侵入されないのが一番ですが、侵入される前提で被害を小さくしておくことも大事です。コードやアカウントへの侵入が起きたときのリスクを減らすために、次のような対策が考えられます。

  • デフォルトブランチをforce push禁止にして保護する
  • TOTPからFIDO2/WebAuthnなどのハードウェアキーへの移行を検討する
  • GPG/SSH鍵によるコミット署名を導入して改ざんされたコミットを検知する
  • パッケージマネージャーへのリリース時はTrusted Publishingを利用する
  • VS Code拡張は最低限のもの以外外し、見覚えのないものがないか定期チェック

汚染されたパッケージがリリースされたときに備えて、次のような対策も考えられます。

  • Copilot Code ReviewのようなLLMによるコードレビューをハーネスとして用意する
  • SBOMを有効、公開して、パッケージに何が含まれるかを明示する
  • Attestationを有効にして、リリースされたパッケージがどのようなビルドプロセスを経て作られたかを明示する
  • デフォルトのGITHUB_TOKEN権限を最小にする
  • GitHubのImmutable Releaseを導入して、過去のリリース内容を改ざんできないようにする
  • パッケージ公開フローにGitHub Environmentを使ったレビュー承認を入れる
  • OIDCトークンの利用時間を最小にする

いくつかピックアップして紹介します。

GitHub Copilot Code Reviewを有効にする

GitHub Copilot Code Reviewは、PRをCopilotがレビューしてくれる機能です。単純に自分以外のレビュアーがいると考えてもいいでしょう。PRコードをLLMがレビューしてくれるので、自身が見落としたコードの問題点を指摘してくれる可能性がありますし、プロンプトで重視するポイントをカスタマイズできるので、セキュリティ的な観点を重視するように促すこともできます。

今なら、リポジトリ > Settings > Copilot > Code reviewからManage copilot code review automationsを開き、Copilot code reviewsを自動有効化するブランチルールをワンクリックで設定できます。

Manage copilot code review automations

Branch rules

デフォルトのGITHUB_TOKEN権限を最小にする

リポジトリのGITHUB_TOKENにはデフォルト権限があります。デフォルトでcontents/packages読み取り権限しか持ちませんが、2023年以前に作られたリポジトリはデフォルトでRead and write permissionsの権限がついています。

OrganizationやリポジトリのSettings > Actions > General > Workflow permissionsを見てRead repository contents permissionsに設定しましょう。また、Allow GitHub Actions to create and approve pull requestsもオフにしておくのがいいでしょう。これで、デフォルトGITHUB_TOKENの権限を最小にできます。

Workflow permissions

GitHubのImmutable Releaseは有効にしよう

リポジトリのSettings > GeneralにあるImmutable Releaseはデフォルト無効ですが有効にしましょう。これはかなり強力なので有効にできないか強く検討しましょう。たとえアカウント侵入に成功してGitHubリリース/タグを消すことはできても、同名GitHubリリース/タグを作れなくなります。過去リリースを汚染パッケージでリリースし直すのはよくみられる攻撃手口ですが、Immutable Releaseを入れておけばそれもできなくなります。

Immutable Releaseの設定

Immutable Releaseを有効にすると、次のような動作になります。

  • リリースがあるとき、リリースに紐づくタグは削除できない
  • リリースを消すとタグは削除できる
  • 一度リリースを消すと、同名リリースは作れない
  • 一度タグを削除すると、同名タグは作れない

ちなみにImmutable Releaseを有効にしてから出したリリースは、リポジトリ設定でImmutable Releaseを無効にしてもリリースを上書きできません。動作確認してて嬉しかったことです。素晴らしい。

GitHub Environmentを使ったpublishのレビュー承認

アカウントを乗っ取られると、リリースフローをトリガーされて誰も見てない間にリリースされる可能性があります。これに対抗するためGitHub Environmentを使ってリリースフローにレビュー承認を入れることを検討しましょう。

リポジトリのSettings > Environmentでリリース用Environmentを作ってReview Requiredを設定 + Prevent self-reviewを有効にすることで、リリースワークフローが止まり自分以外の承認がないと進みません。承認する人は、意図したリリースか確認してからリリースを承認するという流れです。

review required

release stop by review required

難点として、GitHub Actionsで完結しているので、1人開発だとGitHubアカウントが乗っ取られたときは突破されます。GitHubから分離されているという面で後述するnpm stageの方が安全なので、npmにリリースする場合はそちらを優先するのがいいでしょう。

OIDCのトークンは使う直前に発行して使用後にExpireさせる

パッケージマネージャーへのOIDCトークンレスパブリッシュは必ずしも安全を担保するものではありません。PATを発行する必要がないので、長期間PATとその管理リスクは減りますが、OIDCで発行したトークン自体は発行からジョブ終了までの間は有効で、攻撃者がトークンを詐取して利用するリスクがあります。

OIDCで発行したトークンは時限的で、job終了時に自動expireするはずなのでトークンを詐取されてもリスクは時間的に限定されます。これがPATではなくTrusted Publishingを使う動機です。とはいえ、トークン発行からジョブ終了まで時間がかかる処理では、その間に詐取したトークンを使われる余地があります。トークン発行は利用直前に行い、「パブリッシュ処理が終わったらすぐにjobを終了させる」とより安全です。

jobs:
  publish:
    permissions:
      id-token: write             # <- OIDCトークンを発行するための権限
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/download-artifact@v4 # <- パッケージをダウンロードしておいて
        with:
          name: my-package
      - name: NuGet Login         # <- パッケージ公開処理の直前でOIDCでトークン発行する
        uses: NuGet/login@v1
        with:
          user: my-nuget-username
      - name: Push package        # <- OIDCトークンを利用してパッケージを公開
        run: dotnet nuget push my-package/*.nupkg --api-key "${{ steps.login.outputs.NUGET_API_KEY }}" --source https://www.nuget.org/api/v3/index.json
      # ジョブ終了でトークンもExpireされる

GitHub Actions以外を併用する選択

現時点では、GitHub Actionsで外部PRのCIを実行すること自体がリスクで、うまく制御し続けるのはかなり難しいと感じます。そう考えると、GitHub Actionsだけで完結させず、インターナルなCIを併用する選択肢も十分ありえます。

GitHubリポジトリを分離する案

例えば、リポジトリ(Foo)とは別にCI専用のリポジトリ(Foo-CI)を用意します。public repositoryは無権限でcloneできることを利用して、Fooリポジトリをクローンし、ビルドだけする仕組みのほうが安全といわれればそうです。

  • FooリポジトリではCIしない
  • Foo-CIリポジトリはwriteを一切除去、Foo-CIリポジトリでFooリポジトリをクローンしてビルドだけする

なぜ安全かというと、仮に外部PR経由でFoo-CIリポジトリに侵入、GitHubトークンが漏れてもFoo-CIリポジトリしかreadできず、そもそもFooリポジトリへアクセスができません。トークン詐取によるFooリポジトリのリリース/パッケージ公開もできないので影響をかなり封じ込められます。

問題は、どうやって外部リポジトリであるFoo-CIをFooからトリガーするかです。GitHub Actionsでgh cliからワークフローを実行するのは避けたいところで、やりやすいのはWebhookでしょう。ただ、それはJenkinsと同じようなことをやっているつらさがあります。JenkinsがCI第一世代、GitHub Actionsが第二世代だとすると、やろうとしているのは1.5世代のCIに見える感覚です。

仮にWebhookで組んだとしても、トリガーが追跡しやすいかどうかは別問題です。インシデント発生時にどこから侵入されたかを特定することは、被害範囲を見積もるうえで重要なので、トリガーはできるだけGitHub上で完結しているものが望ましいでしょう。GitHub ActionsのトリガーはPRやpushなどGitHub上で完結するものが多い一方、webhookやRepository Dispatchを使うと経路分析は難しくなりがちです。

CodeBuildなどクラウドCIと分離する案

別のやり方として、GitHub ActionsからはAWS CodeBuild/CodePipelineなどのクラウドCIを呼び出すだけにして、クラウドCI側でリポジトリをチェックアウトしてビルドするというやり方もあるでしょう。GitHub ActionsはクラウドCIを呼び出すだけなので、GitHub Actionsのリスクを減らせます。クラウドCIでどのような処理をしているか公開しなければ、攻撃者から見える情報も減らせます。

プライベートリポジトリや企業内部では以前から見られる方法ですが、OSSでこれを導入することも選択肢の1つです。個人的にはOSSでこのスタイルはとりたくないものの、有効ではあるでしょう。一方で、コラボレーターがクラウドCIへどうアクセスできるようにするかなど、運用の複雑さは問題になります。クラウドCIに詳しい人が限られ、LLMに頼らないと把握しにくいような状態になると、「やっていることがコラボレーター内ですらブラックボックスになる」という別のリスクも生まれます。

セルフホストランナーは避けたほうがいい

インターナルなCIという案を聞くと、セルフホストランナーを立てることを思いつきますが避けましょう。これはGitHubドキュメントでも示されている通り、Hosted Runnerと違ってセルフホストランナーは環境リセットされる保証がなく「ワークフロー内の信頼できないコードによって継続的に侵害される可能性が高い」ためです。Ephemeral Runnerをうまくコンテナで制御できるなら選択肢になりえますが、ほとんどの場合OSSでこの運用負荷に耐えるのは難しいでしょう。

GitHubやパッケージマネージャーに欲しいこと

外部PRや攻撃のリスクを利用者側だけで吸収するのには限界があるので、プラットフォーム側で対策が進んでほしいと感じます。

ビルドとリリースの分離

ビルド(GitHub Actions)とリリース(GitHub Release)やパブリッシュ(id-token)が一緒になっているため、トークンの範囲が広がりやすいのは問題を局所化できない一因です。ビルドはビルド、リリースはリリース、パブリッシュはパブリッシュと影響範囲を分けられるような仕組みがあれば、GitHubアカウントを乗っ取られても、汚染パッケージの公開には至らないように影響を封じ込める余地があります。

この観点で、先日発表されたnpm stageは、まさに欲しかった対応です。ドキュメントを見ると、CIからはステージまでしかリリースできず、ステージから本番リリースするにはTOTP(2要素認証)の入力が必要です。GitHub Actions(CI)はステージまでしかアクセスできないので、GitHubアカウントが乗っ取られただけではnpmにパッケージを展開できません。汚染されたパッケージを展開するには、npmアカウントの乗っ取りとTOTPフィッシングの両方が必要です。

  • CIが関与できる範囲: GitHub Actions (CI) -> npm stage (Trusted Publishing)
  • npm(リリース)で関与する範囲: npm publish (with 2FA)

現時点でステージングパブリッシュができるのはnpmぐらいで、他のパッケージマネージャーやサイトはそもそもステージという概念を持っていないようです6。ビルドとリリースを分離するには、パッケージマネージャー側にステージ + ステージから本番リリース + 本番リリース時に強めの本人認証、という仕組みが必要でしょう。npm stageがうまくいくことと、他言語やパッケージマネージャーに必要性が伝ってほしいです。

OSSのGitHub Actionsは無料のままなのか

GitHub Actionsは、リリースから2026年5月現時点まで、OSSに対しては無料で提供されてきました。めちゃめちゃお世話になっているものの、今後もOSSに対して無料で提供される動機はなくなりつつあると感じます。LLMを使った開発の進展もありますが、CI/CDが普遍的なインフラになりきり、GitHub自身がDependabotやCodeQL実行はユーザーのGitHub Actionsで実行されるようにシフトしています。

GitHubを多用している一人としては、ユーザー自身で独自にCIを準備する未来も想定しています。できれば、GitHubはOSSに対しては引き続き無料でGitHub Actionsを提供してほしいです。望みすぎな気もします。

まとめ

2026年時点では、外部PRを信頼できるかと聞かれた場合、私は「信頼しない前提で扱うしかない」と答えます。外部PRを受け付ける場合、CIを実行するか、どのイベントを許容するか、侵入されたときにどこまで被害を閉じ込められるかもセットで考えましょう。

私自身OSSにコントリビューションをしているので、コントリビューションが来ることは本当にありがたいですし大事にしています。しかしコントリビューションを利用する攻撃が存在する以上は、CI/CDの信頼境界を明確にする方がいいでしょう。ふわっとOSSをするなら、外部コントリビューションを受け付けない割り切りも効果的です。

GitHubやパッケージマネージャー側の改善にも期待していますが、目先のOSS運用としては「外部PRはuntrusted」を前提に考えるのがサプライチェイン攻撃に対する基本姿勢です。LLMの助けも借りつつ、仕組みでなんとかしていきましょう。

参照

ドキュメント

インシデント

ブログ

リポジトリ


  1. GitHub的には仕組みはGitHub Actions v2より仕組みが大きく変わっておらず元よりこのリスクはあったものの、ここ2年程で現実としてリスクを検討するようになったと捉えています。
  2. リポジトリならhttps://github.com/<owner>/<repo>/settings/actions参照
  3. 欲しいのは、権限拡張のないけど、デフォルトブランチ状態で実行できる何かだけど、そんなものはない
  4. CodeQLでこういうのを検知できたことがないのですが
  5. SHAピンニングが叫ばれたのは、tj-actionsにおけるリモートアクションのバージョンタグの書き換えにセキュリティインシデントがきっかけでした。
  6. すくなくともcrate.ioやNuGetは持っていません。