Claude CodeでGitHub Actionsの脆弱性を点検する — zizmorで検出して直す
pull_request_target、PRタイトルの式展開、権限の広げすぎ、tag指定のaction。GitHub Actionsのworkflowに潜むこの4つを、zizmorで検出してClaude Codeに直させる手順です。
GitHub Actionsのworkflowは、書いた本人が気づかないまま脆弱になります。構文が正しくても、pull_request_target に外部の入力が混ざれば、リポジトリのシークレットに届く経路ができるからです。
zizmorは、この種の問題を静的に見つけるツールです。Claude Codeには、指摘を読んで直し、もう一度検査するところまで任せられます。この記事では、検出する4つの型と、直し方、Claude Codeに回させる設定、CIへの組み込みを順に書きます。
構文の誤りを見つける検査はClaude Codeのactionlint活用にあります。zizmorが見るのは構文ではなく、「この書き方は攻撃に使えるか」です。
zizmorは何を見つけるツールか
zizmorは、GitHub ActionsやDependabot、pre-commitの設定を対象にした、CI/CD向けの静的解析ツールです。リポジトリのREADMEは、見つけるものとして次を挙げています。
- 攻撃者が制御できる値によるコード実行につながるテンプレートインジェクション
- 認証情報の意図しない保持と漏えい
- runnerへの過剰な権限付与
- 別のコミットを装うなりすまし参照
この記事で扱うのは、そのうち効果の大きい次の4つです。ルール名はzizmorの監査名で、自動修正の欄はルール一覧の表記に沿っています。
| ルール | 見つけるもの | 自動修正 | 直し方の軸 |
|---|---|---|---|
dangerous-triggers | 見つけるものpull_request_target などの危険なトリガー | 自動修正なし | 直し方の軸トリガーを替える、または検証経路を変える |
template-injection | 見つけるもの${{ }} を run: に直接展開する書き方 | 自動修正unsafeのみ | 直し方の軸環境変数を経由する |
excessive-permissions | 見つけるもの広すぎる permissions | 自動修正なし | 直し方の軸permissions: {} から必要分だけ足す |
unpinned-uses | 見つけるものtagやbranchで指定したaction | 自動修正unsafeのみ | 直し方の軸コミットSHAで固定する |
「unsafeのみ」は、--fix の既定(safe)では直らず、--fix=all で適用される修正です。dangerous-triggers と excessive-permissions には自動修正がないので、人かClaude Codeが書き換えます。
危険な型: pull_request_targetとPRタイトルの式展開
pull_request_target は、forkからのPRに対して、ベースリポジトリ側のシークレットと権限でworkflowを走らせるトリガーです。zizmorのドキュメントは、これを workflow_run や issue_comment と並ぶ「根本的に危険なトリガー」に数えています。
危険な組み合わせは次のように書けます。PRのタイトルと、PRのheadのチェックアウトが、両方入っています。
# 危険な例(検出される側)
on: pull_request_target
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: echo "Title is ${{ github.event.pull_request.title }}"問題は2か所あります。
run:の中の${{ github.event.pull_request.title }}が、シェルが読む前に文字列として展開されます。タイトルは外部の人が自由に決められる値なので、"; curl ... #のような文字列を入れられると、そのままコマンドになります。ref:でPR head(外部の人のコード)をチェックアウトしているので、後続のステップがその中身を読み込みます。
zizmorのドキュメントは、「PRのコードを実行しないから安全」という説明を明確に否定しています。引数インジェクション(xargs など)、環境変数インジェクション(LD_PRELOAD)、ローカルファイルの取り込みといった経路で、コードを実行されることがあるという立場です。
このうちテンプレートインジェクションの説明では github.event.issue.title が例に出ています。PRのタイトルも、作成者が自由に決められる点で同じ性質の値です。
Claude Codeにzizmorを回させる
1. インストールして1回実行する
zizmorはHomebrew、PyPI、cargo、Dockerなどで入ります。
brew install zizmor
# または: uv tool install zizmor
zizmor --version
zizmor .github/workflows/引数にディレクトリを渡すと、その中のworkflowを集めて検査します。workflowだけに絞るなら --collect=workflows を付けます。
指摘は、重大度に応じた終了コードでも返ります。
| 終了コード | 意味 |
|---|---|
| 0 | 意味指摘なし |
| 11 | 意味最も重い指摘がinformational |
| 12 | 意味最も重い指摘がlow |
| 13 | 意味最も重い指摘がmedium |
| 14 | 意味最も重い指摘がhigh |
| 1 / 2 / 3 | 意味実行中のエラー / 引数の誤り / 検査対象が見つからない |
終了コード3はv1.21.0以降の意味です。「0になるまで直す」という繰り返しを、Claude Codeに頼めます。
2. 直す手順をClaude Codeに伝える
検査の結果をそのまま貼って「直して」と頼むだけでも動きます。繰り返し使うなら、CLAUDE.mdに規約として書いておくと、毎回の説明が要りません。
## GitHub Actionsのworkflow
- `.github/workflows/` を変更したら、完了前に `zizmor .github/workflows/` を実行する
- 終了コードが0でなければ、指摘のルール名ごとに直し、もう一度実行する
- `pull_request_target` と `workflow_run` は新しく足さない。必要な場合は理由を説明し、確認を取る
- `run:` の中に `${{ github.event.* }}` を直接書かない。`env:` で渡し、シェル変数で参照する
- `permissions: {}` をworkflowの先頭に置き、必要な権限はjobごとに足す
- 指摘を `# zizmor: ignore[ルール名]` で黙らせる前に、理由を報告する最後の1行が重要です。無視コメントはzizmorの仕様上、行ごとに付けられます。Claude Codeが指摘を消す近道としてこれを使わないよう、先に決めておきます。
3. 自動修正は範囲を決めて使う
zizmorには --fix があります。自動で直せる指摘には、出力に「この指摘は自動修正できる」という注記が付きます。
zizmor --fix=all .github/workflows/ci.yml
git diff--fix は既定では安全な修正(safe)だけを適用します。上の4つのうち、template-injection と unpinned-uses の修正はunsafe扱いなので、--fix=all か --fix=unsafe-only を指定しないと適用されません。unsafeの修正は人の確認が前提で、ドキュメントも意味の正しさを手で確かめるよう求めています。制約も知っておく必要があります。
- ファイルをその場で書き換える(リモートの入力には使えない)
- コメントやインデントを保とうとするが、完全には揃わないことがある
unpinned-usesの修正は最新のコミットSHAを取りに行くため、オンラインのGitHubトークンが要る--fixで指摘がすべて直ると、終了コード11以上は使われない
Claude Codeには「--fix=all を実行し、git diff で変更の意味を確かめ、直らなかった指摘は手で直す」と頼みます。差分を読む工程を、手順に含めておく形です。
4つの指摘を直す
template-injection: 環境変数を経由する
直し方は、式をシェル変数に置き換えることです。zizmorのドキュメントの例を、そのまま示します。
# 修正前
- name: Check title
run: |
title="${{ github.event.issue.title }}"
if [[ ! $title =~ ^.*:\ .*$ ]]; then
echo "Bad issue title"
exit 1
fi
# 修正後
- name: Check title
run: |
title="${ISSUE_TITLE}"
if [[ ! $title =~ ^.*:\ .*$ ]]; then
echo "Bad issue title"
exit 1
fi
env:
ISSUE_TITLE: ${{ github.event.issue.title }}注意点は2つあります。env: に渡したあと、run: の中で ${{ env.ISSUE_TITLE }} と書いてはいけません。それも式展開なので、危険は残ります。${ISSUE_TITLE} とシェル自身に展開させます。もう1つは、Windowsのrunnerの既定シェルがPowerShellで、${env:ISSUE_TITLE} と書く点です。shell: bash を明示すれば、書き方が1つで済みます。
excessive-permissions: 空から始める
permissions を書かないworkflowは、リポジトリや組織の設定によって広い権限を持つことがあります。ドキュメントの方針は、workflowの先頭を permissions: {} にして、必要な権限をjobごとに足すことです。
name: release
permissions: {}
jobs:
publish:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- run: echo "publish"権限が必要な場所を絞る狙いです。Claude Codeに頼むときは、「このworkflowの各jobが実際に使う権限を、ステップから読み取って足して」と書きます。必要な権限の見極めには、GitHubSecurityLabの actions-permissions が助けになるとzizmorのドキュメントが紹介しています。
unpinned-uses: コミットSHAで固定する
uses: actions/checkout@v4 のようなtag指定は、上流のリポジトリがtagを付け替えると、中身が変わります。zizmor v1.20.0以降の既定では、公式のactionも含めてすべて、コミットSHAでの固定が求められます。
- uses: actions/checkout@<40桁のコミットSHA> # v6SHAの横にバージョンをコメントで残すと、あとで人が読めます。固定したあとの更新は、DependabotかRenovateに任せるのが現実的です。固定作業を手伝うツールとして、pinactやfrizbeeなどがドキュメントに挙がっています。zizmor自身の --fix=all も、GitHubトークンがあれば固定できます。
dangerous-triggers: トリガーを替える
直し方は事情で分かれます。ドキュメントにある指針を、状況別に並べます。
| 状況 | 方針 |
|---|---|
| fork以外のPRしか来ない | 方針pull_request_target を pull_request に替える |
| forkからのPRに、コメントなど書き込みが要る | 方針pull_request_target を残すが、PRのコードを実行しない |
workflow_run を使っている | 方針workflow_call の再利用可能workflowへ作り替える |
issue_comment で動かしている | 方針権限のある人がラベルを付けたときに動かす形にする |
| どうしても残す | 方針ブランチ指定と github.repository == ... の確認を付ける |
pull_request_target は、PRのベースブランチ側のworkflowファイルを使います。ブランチ指定で対象のブランチを絞れば、古いブランチに残った脆弱なworkflowが使われる余地を減らせます。リポジトリ名の確認は、fork側でActionsを有効にした人を、古い脆弱なコピーから守ります。workflow_run はつねにベースリポジトリで動くため、この確認は効きません。
Claude Code Actionのworkflowを点検する
Claude Codeを呼び出すworkflow自体も、点検の対象です。ここでは、公式のGitHub Actionsドキュメントにある例と、zizmorのルールを突き合わせます。
ドキュメントの @claude 応答用のworkflowは、issue_comment と pull_request_review_comment で起動します。そのため、zizmor v1.31.0以降では dangerous-triggers の対象になります(同バージョンから issue_comment も検査される)。ただし、指摘が出たことと、すぐに悪用できることは別です。
Claude Code Actionは、issueとPRのイベントで、起動した人に書き込み権限があるかを確かめ、無い場合は失敗します。botが起動したときも、allowed_bots に載せたものだけを許します。ドキュメントはこの2つを、実行前の確認として記載しています。指摘を見たら、まずこの確認が効く構成になっているかを調べます。
同じworkflowの actions/checkout@v6 はtag指定なので、既定の設定なら unpinned-uses の対象です。permissions はjobに書かれているため、excessive-permissions の観点では、workflow全体に permissions: {} を置く余地が残ります。
アクション側のセキュリティドキュメントが書いていること
claude-code-actionのリポジトリには、セキュリティの説明があります。点検の判断材料になるのは、次の記述です。
pull_request_targetとworkflow_runは、ベースリポジトリのシークレットで動く。PRのheadをワークスペースの根にチェックアウトしてからactionを呼ぶと、Claudeはその内容を作業ディレクトリとして使う- 信頼できないrefを、actionの前にワークスペースの根へチェックアウトしない。ベースのブランチをチェックアウトし、PRの内容が要るなら別のパスに置いて
--add-dirで渡す
- uses: actions/checkout@<コミットSHA> # v6
- uses: actions/checkout@<コミットSHA> # v6
with:
ref: ${{ github.event.pull_request.head.sha }}
path: pr-head
- uses: anthropics/claude-code-action@v1
with:
claude_args: "--add-dir pr-head"同じ文書は、PRの上でactionを動かすと、.claude/、.mcp.json、CLAUDE.md などの設定をベースのブランチから復元する、と説明しています。PRが持ち込んだ設定ファイルでClaudeの動作を変えられないようにするためです。一方で package.json やMakefileはPR側のままなので、hookがそれらを経由するコマンドを呼ぶと、PRの内容が効きます。hookのコマンドは自己完結させる、という注意が同じ文書にあります。
さらに、allowed_non_write_users は書き込み権限のない人を通す設定で、文書は「大きなリスク」と明記しています。issues: write だけのラベル付けのような、権限の小さいworkflowに限る使い方です。github_token には ${{ secrets.GITHUB_TOKEN }} を渡し、個人のアクセストークンは使わないよう書かれています。allowed_bots に '*' を使う場合も、permissions を最小にします。
パラメータの全体像はGitHub Actionsのパラメータとclaude_argsの渡し方にあります。
CIで検査を続ける
手元で直しても、次のPRで戻ります。検査をworkflowに入れておくと、戻りが止まります。
name: zizmor
on:
pull_request:
paths:
- ".github/workflows/**"
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@<コミットSHA> # v6
with:
persist-credentials: false
- uses: actions/setup-python@<コミットSHA> # v6
with:
python-version: "3.13"
- run: pip install zizmor
- run: zizmor --format=github .github/workflows/ポイントは3つあります。トリガーは pull_request にして、この検査自体が危険なトリガーにならないようにします。persist-credentials: false で、チェックアウトの認証情報が後続に残らないようにします。出力形式の --format=github は、指摘をGitHubの注釈として出します。SARIF形式(--format=sarif)も選べます。この形式のときは、指摘があっても終了コードが0になります。
zizmor の行の pip install はバージョンを指定していません。固定したい場合は zizmor==<バージョン> の形にします。ツール自体の更新で、昨日まで通っていた検査が今日落ちる、という事態を避けられます。
検出が多すぎるときの調整
既定の検査は、指摘の質を重く見た設定です。ドキュメントによると、検査の感度は3つのペルソナで選べます。
| ペルソナ | 用途 |
|---|---|
regular(既定) | 用途実用的な指摘に絞る。ローカルでもCIでも向く |
pedantic | 用途コードの臭いも含める |
auditor | 用途誤検出の可能性が高いものも含め、すべて出す |
--persona=pedantic や --min-severity=medium のように、フラグで絞り込めます。誤検出や意図的な設計は、行コメントで無視できます。
- run: echo "${{ github.event.issue.title }}" # zizmor: ignore[template-injection]ファイル全体や特定のルールを外したいときは、設定ファイル zizmor.yml に書きます。unpinned-uses の方針も、設定ファイルで変えられます。"example/*": hash-pin のように、パターンごとにSHA固定を必須にするか、tag指定を許すかを決められます。
つまずきやすい点
| 症状 | 原因 | 対処 |
|---|---|---|
--fix=all で固定できない | 原因GitHubトークンが無い | 対処GH_TOKEN を設定して再実行する |
リモートのリポジトリに --fix が効かない | 原因その場書き換えの仕様 | 対処ローカルにcloneして実行する |
| 指摘が消えない | 原因${{ env.X }} に置き換えた | 対処${X} とシェル変数に直す |
| Claude Codeが無視コメントで黙らせた | 原因CLAUDE.mdに禁止が無い | 対処無視の前に理由を報告させる |
| CIで指摘が出ないのに脆弱 | 原因zizmorは静的解析で、実行時の値は見ない | 対処PRの外から入る値の流れは、人も読む |
| 古いバージョンで新しいルールが出ない | 原因バージョン差 | 対処zizmor --version を確かめる |
zizmorの出力が「問題なし」でも、安全が保証されるわけではありません。ツールは書き方のパターンを見つけます。workflowが何を信頼しているか、という設計の判断は残ります。
まとめ
pull_request_target、式展開、広い権限、tag指定。この4つはどれも、読めば分かる書き方で、読み落とされてきた型です。zizmorに検出を任せ、Claude Codeに「ルール名ごとに直して、もう一度回す」ループを任せると、人は設計の判断に集中できます。
最初の一歩は、既存のworkflowに zizmor .github/workflows/ を1回かけることです。unpinned-uses と excessive-permissions は書き換え方が決まっているので、ここから片付けると進みます。dangerous-triggers は、トリガーを替えられるかを先に考えます。直せない場合は、claude-code-actionのセキュリティ文書にある、ベースのブランチを根にチェックアウトする形が出発点になります。
workflowの作り方そのものはGitHub Actionsのワークフロー作成をClaude Codeで進める手順に、actionの導入はClaude CodeをGitHub Actionsに組み込むにあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。