Argano の松田です。

前回の記事では、pnpm の minimumReleaseAge や Dependabot を使って、npm パッケージをサプライチェーン攻撃から守る方法を紹介しました。

ただ、CI で動く GitHub Actions も外部のコードに依存しています。uses: actions/checkout@v7 と書けば、他人のリポジトリにあるコードが、自分のリポジトリへの権限を持ったジョブの中で実行されます。npm パッケージと同じく、ここも攻撃の入り口になり得ます。

今回は前回紹介した依存関係更新のワークフローを題材に、GitHub Actions をサプライチェーン攻撃から守る方法を紹介します。

GitHub Actions へのサプライチェーン攻撃の例

2025年3月、tj-actions/changed-files という Action が侵害される事件がありました(CVE-2025-30066)。

  • 攻撃者は、Action のリポジトリにある多数のバージョンタグを、悪意のあるコミットを指すように付け替えた
  • uses: tj-actions/changed-files@v45 のようにタグで参照していたワークフローは、次の実行時から悪意のあるコードを動かしてしまった
  • 悪意のあるコードは、ジョブを実行する Runner.Worker プロセスのメモリからシークレットを読み取り、ワークフローのログに出力した
  • 公開リポジトリのログは誰でも閲覧できるため、シークレットが外部から見える状態になった
  • この Action を利用していたリポジトリは 23,000 以上と報告されている

この事件から、次の2点が分かります。

  • タグは後から別のコミットに付け替えられる。@v45 と書いても、中身が前回と同じとは限らない
  • 同じジョブ内で動くコードは、そのジョブに渡されたシークレットやトークンを読み取れる可能性がある

題材にするワークフロー

前回紹介したワークフローの主要部分です。コミットと Pull Request 作成のステップは省略しています。

name: Update dependencies

on:
  schedule:
    # 毎週月曜日の朝 9:00 (日本時間) に実行
    - cron: "0 0 * * 1"
  workflow_dispatch:

jobs:
  update-dependencies:
    runs-on: ubuntu-latest

    permissions:
      contents: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v7

      - uses: pnpm/action-setup@v6
        with:
          version: latest

      - uses: actions/setup-node@v6
        with:
          node-version: 24

      - run: pnpm install

      - run: pnpm update --latest

      # 以降、コミット・プッシュと Pull Request の作成

前回はワークフローの中で実行する pnpm のコマンドに焦点を当てました。今回はワークフローそのものに目を向けます。見直す点は次の4つです。

  • Action をタグで参照している
  • pnpm 本体を version: latest で取得している
  • 書き込み権限を持つジョブの中で、pnpm 本体や依存パッケージなどのサードパーティのコードを実行している
  • actions/checkout が保存した認証情報が、ジョブの終了まで残っている

静的解析ツールで問題を洗い出す

対策の前に、ツールで問題を洗い出してみます。zizmor は GitHub Actions のワークフローを静的解析し、セキュリティ上の問題を検出するツールです。

brew install zizmor

zizmor .github/workflows/update-dependencies.yml

前回の記事のワークフロー全体(省略したステップを含む)を zizmor v1.30.1 で検査した結果です。

help[artipacked]: credential persistence through GitHub Actions artifacts
  --> .github/workflows/update-dependencies.yml:18:9
   |
18 |       - uses: actions/checkout@v7
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^ does not set persist-credentials: false
   |
   = note: audit confidence → Low
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#artipacked

help[template-injection]: code injection via template expansion
  --> .github/workflows/update-dependencies.yml:61:25
   |
57 |         run: |
   |         --- this run block
...
61 |             --head "${{ env.BRANCH_NAME }}" \
   |                         ^^^^^^^^^^^^^^^ may expand into attacker-controllable code
   |
   = note: audit confidence → High
   = note: this finding has an auto-fix
   = help: audit documentation → https://docs.zizmor.sh/audits/#template-injection

error[unpinned-uses]: unpinned action reference
  --> .github/workflows/update-dependencies.yml:18:15
   |
18 |       - uses: actions/checkout@v7
   |               ^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
   |
   = note: audit confidence → High
   = help: audit documentation → https://docs.zizmor.sh/audits/#unpinned-uses

error[unpinned-uses]: unpinned action reference
  --> .github/workflows/update-dependencies.yml:20:15
   |
20 |       - uses: pnpm/action-setup@v6
   |               ^^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
   |
   = note: audit confidence → High
   = help: audit documentation → https://docs.zizmor.sh/audits/#unpinned-uses

error[unpinned-uses]: unpinned action reference
  --> .github/workflows/update-dependencies.yml:24:15
   |
24 |       - uses: actions/setup-node@v6
   |               ^^^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
   |
   = note: audit confidence → High
   = help: audit documentation → https://docs.zizmor.sh/audits/#unpinned-uses

9 findings (4 suppressed, 2 unsafe fixes): 0 informational, 2 low, 0 medium, 3 high

検出された内容と、この記事で扱う対策の対応は次のとおりです。

  • unpinned-uses: Action がコミット SHA で固定されていない。対策1で扱う
  • artipacked: actions/checkout で persist-credentials: false を指定していない。対策3で扱う
  • template-injection: run の中で ${{ env.BRANCH_NAME }} を展開している。修正後のワークフローで扱う

zizmor には「ペルソナ」という設定があり、デフォルトの regular では一部の指摘を表示しません(結果の 4 suppressed)。--persona=auditor を付けるとすべての指摘が表示され、ワークフロー全体に permissions の指定がないこと(excessive-permissions)も指摘されました。これも対策3で扱います。

一方で、version: latest で pnpm を取得していることや、書き込み権限を持つジョブでサードパーティのコードを実行していることは検出されません。ツールの結果だけで判断せず、ワークフローの構成も見直す必要があります。

なお、zizmor はデフォルトではオフラインで動作します。GH_TOKEN 環境変数で GitHub のトークンを渡すと、GitHub API を使う検査も実行できます。

また、構文エラーや run に書いたシェルスクリプトの問題は actionlint で検出できます。zizmor はセキュリティ、actionlint は書き方の正しさと、得意分野が異なるので併用がおすすめです。

対策1: Action をコミット SHA で固定する

タグは付け替えられますが、コミット SHA は変えられません。Action はコミット SHA で参照し、バージョンはコメントで残すのが一般的です。

# 変更前
- uses: actions/checkout@v7

# 変更後
- uses: actions/checkout@<SHA> # v7.x.x

手作業で SHA を調べるのは手間です。Action をタグで指定したまま pinact を実行すると、ワークフロー内の Action をまとめて SHA に変換できます。

pinact run

ただし、SHA で固定できるのはその Action 自身のコードだけです。Action が内部でほかの Action をタグで参照していたり、実行時にツールをダウンロードしていたりする場合、その部分までは固定されません。tj-actions の事件も、内部で使われていた別の Action が侵害されたことが発端とみられています。pnpm/action-setup が実行時に pnpm を取得するのもこの一例で、対策2で扱います。

GitHub 側にも関連する機能があります。

  • 2025年8月から、Organization やリポジトリの Actions のポリシーで SHA での固定を強制できるようになった
    • SHA で固定していない Action を使うワークフローは、実行が失敗する
  • 2025年10月に、イミュータブルリリースが一般提供された
    • Action の作者がこれを有効にすると、それ以降に作成したリリースのタグを付け替えられなくなる
    • @v7 のようなメジャーバージョンのタグは通常リリースに紐づかないため、保護の対象外になる
    • ただし有効にするかどうかは作者次第なので、利用する側は SHA で固定しておく必要がある

Dependabot で SHA を更新する

SHA で固定すると、Action のバージョンは自動では上がらなくなります。更新は Dependabot に任せます。

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 7
  • Dependabot は SHA とバージョンのコメントをまとめて更新する
  • cooldown は、リリースから指定した日数が経つまで Pull Request を作らない設定
    • pnpm の minimumReleaseAge と同じ考え方を、Actions にも適用できる
    • 2026年7月14日から、cooldown を指定しなくても3日間のクールダウンがデフォルトで適用されるようになった。筆者のプロジェクトでは minimumReleaseAge: 10080(7日)にしているので、それに合わせて7日にしている
    • github-actions では日数を default-days でしか指定できず、semver-major-days などは使えない
    • クールダウンはバージョン更新だけが対象だが、SHA で固定した Action には Dependabot alerts が作られないため、セキュリティ更新の Pull Request も作られない。Action の脆弱性の修正も、クールダウン後のバージョン更新で取り込むことになる

前回の記事では、プライベートレジストリの都合で npm の更新を自前のワークフローで行うケースを紹介しました。dependabot.yml はエコシステムごとに設定するので、npm は自前のワークフロー、Actions は Dependabot という使い分けもできます。

対策2: pnpm のバージョンを固定する

pnpm/action-setup の version: latest は、実行のたびに最新の pnpm を取得します。minimumReleaseAge は pnpm がインストールするパッケージには効きますが、pnpm 本体には効きません。

pnpm/action-setup は version を省略すると、package.json の packageManager(または devEngines.packageManager)からバージョンを読み取ります。

{
  "packageManager": "pnpm@11.13.1"
}
- uses: pnpm/action-setup@<SHA> # v6.x.x
  # version は省略し、package.json の packageManager を使う

ローカルと CI で同じバージョンの pnpm を使えるという利点もあります。

対策3: 権限を最小化する

permissions を最小限にする

ワークフロー全体では permissions: {} で権限をなしにし、ジョブごとに必要な権限だけを付与します。

permissions: {}

jobs:
  update:
    permissions:
      contents: read

リポジトリの Settings > Actions > General > Workflow permissions でも、GITHUB_TOKEN のデフォルトの権限を読み取り専用にできます。古いリポジトリや Organization では書き込み権限がデフォルトになっている場合があるので、一度確認しておくと安心です。

キャッシュへのアクセスは permissions とは別に管理されています。schedule や workflow_dispatch で起動したジョブは、キャッシュへの書き込み権限を持ちます。乗っ取られたジョブがキャッシュを汚染すると、同じキャッシュを復元するほかのワークフローにも影響が及びます。2026年9月に追加された cache-mode を使うと、キャッシュへのアクセスを制限できます。題材のワークフローはキャッシュを使わないので、cache-mode: none にします。

サードパーティのコードを動かすジョブと、書き込み権限を持つジョブを分ける

tj-actions の事件のように、同じジョブで動くコードは、そのジョブのトークンを読み取れる可能性があります。

題材のワークフローは、contents: write と pull-requests: write を持つジョブの中で pnpm install や pnpm update を実行しています。pnpm はデフォルトで依存パッケージのライフサイクルスクリプトを実行しませんが、許可したパッケージのスクリプトは実行されます。pnpm 本体や Action 自体も、同じジョブの中で動きます。

そこで、ジョブを2つに分けます。

  • update ジョブ
    • 読み取り権限のみを持つ
    • 依存関係を更新し、変更されたファイルを Artifact としてアップロードする
  • create-pr ジョブ
    • 書き込み権限を持つ
    • Artifact をダウンロードしてコミットし、Pull Request を作成する
    • サードパーティのコードは実行しない

update ジョブが乗っ取られても、トークンに書き込み権限がないので、リポジトリを直接書き換えることはできません。細工できるのは Pull Request の中身までで、最後はマージ前のレビューで防ぐことになります。

ただし、これは create-pr ジョブが update ジョブから受け取った Artifact を信用しないことが前提です。Artifact をワークスペースにそのまま展開すると、.git 配下などの既存ファイルを上書きされるおそれがあります。そのため、Artifact は一時ディレクトリに展開し、想定したファイルだけを取り出します。

checkout の認証情報を残さない

actions/checkout は、デフォルトで GITHUB_TOKEN を認証情報として保存し、後続のステップで git push などを実行できるようにします。v6 以降は保存先が .git/config から $RUNNER_TEMP 配下の別ファイルに変わりましたが、後続のステップから読み取れる点は同じです。

update ジョブでは git push しないので、persist-credentials: false を指定します。create-pr ジョブは git push するので、persist-credentials: true を明示して認証情報を残します。明示しておくと、意図して残していることが読み手と zizmor の両方に伝わります。

修正後のワークフロー

ここまでの対策をまとめたワークフローです。使う場合は、<SHA> # v7.x.x の部分を v7 のようなタグに書き換えてから pinact run を実行し、SHA に変換してください。

name: Update dependencies

on:
  schedule:
    # 毎週月曜日の朝 9:00 (日本時間) に実行
    - cron: "0 0 * * 1"
  workflow_dispatch:

# ワークフロー全体のデフォルトは権限なし
permissions: {}

# このワークフローではキャッシュを使わない
cache-mode: none

jobs:
  # 依存関係を更新するジョブ(サードパーティのコードが動く)
  update:
    runs-on: ubuntu-latest

    permissions:
      contents: read

    outputs:
      changed: ${{ steps.diff.outputs.changed }}

    steps:
      - uses: actions/checkout@<SHA> # v7.x.x
        with:
          # このジョブでは push しないので認証情報を残さない
          persist-credentials: false

      # version は省略し、package.json の packageManager を使う
      - uses: pnpm/action-setup@<SHA> # v6.x.x

      - uses: actions/setup-node@<SHA> # v6.x.x
        with:
          node-version: 24

      - run: pnpm install

      # 依存関係を最新バージョンに更新する
      - run: pnpm update --latest

      - name: Check for changes
        id: diff
        run: |
          if git diff --quiet -- package.json pnpm-lock.yaml; then
            echo "changed=false" >> "$GITHUB_OUTPUT"
          else
            echo "changed=true" >> "$GITHUB_OUTPUT"
          fi          

      - if: steps.diff.outputs.changed == 'true'
        uses: actions/upload-artifact@<SHA> # v7.x.x
        with:
          name: updated-dependencies
          path: |
            package.json
            pnpm-lock.yaml            
          retention-days: 1

  # Pull Request を作成するジョブ(書き込み権限を持つが、サードパーティのコードは動かさない)
  create-pr:
    needs: update
    if: needs.update.outputs.changed == 'true'
    runs-on: ubuntu-latest

    permissions:
      contents: write
      pull-requests: write

    steps:
      - uses: actions/checkout@<SHA> # v7.x.x
        with:
          # push に使うので認証情報を残す
          persist-credentials: true

      - uses: actions/download-artifact@<SHA> # v8.x.x
        with:
          name: updated-dependencies
          # .git を含むワークスペースには直接展開しない
          path: ${{ runner.temp }}/updated-dependencies

      - name: Commit and Push
        env:
          BRANCH_NAME: update-dependencies
        run: |
          # Artifact から想定したファイルだけを取り出す
          cp "$RUNNER_TEMP/updated-dependencies/package.json" package.json
          cp "$RUNNER_TEMP/updated-dependencies/pnpm-lock.yaml" pnpm-lock.yaml

          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"

          git checkout -b "$BRANCH_NAME"

          git add package.json pnpm-lock.yaml
          git commit -m "Update dependencies"
          git push origin "$BRANCH_NAME"          

      - name: Create Pull Request
        env:
          # ghコマンドの認証に必要
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH_NAME: update-dependencies
        run: |
          gh pr create \
            --title "Update dependencies" \
            --body "GitHub Actionsから \`gh\` コマンドを使用して自動生成されたプルリクエストです。" \
            --head "$BRANCH_NAME" \
            --base "main"          

ブランチ名は ${{ env.BRANCH_NAME }} ではなく、シェルの環境変数 $BRANCH_NAME として参照しています。${{ }} はシェルが実行する前の段階で、スクリプトへ文字列として埋め込まれます。そのため、Pull Request のタイトルのような外部から入力される値を ${{ }} で展開すると、スクリプトインジェクションの原因になります。今回の値は固定なので実害はありませんが、run の中では環境変数を経由する習慣をつけておくと安全です。

<SHA> を置き換えた状態で zizmor をデフォルトの設定で実行すると、指摘はなくなります。

まとめ

GitHub Actions も npm パッケージと同じく、外部のコードに依存しています。

  • Action はタグではなくコミット SHA で固定し、更新は Dependabot に任せる。cooldown で公開直後のバージョンを避ける
  • pnpm などのツールのバージョンも固定する
  • 権限は最小限にし、サードパーティのコードを動かすジョブと、書き込み権限を持つジョブを分ける
  • ワークフローを変更したら zizmor で検査する

前回の記事とあわせて、npm パッケージと GitHub Actions の両方の入り口を見直してみてください。