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 の脆弱性の修正も、クールダウン後のバージョン更新で取り込むことになる
- pnpm の
前回の記事では、プライベートレジストリの都合で 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 の両方の入り口を見直してみてください。
