投稿

個人リポジトリ全部の GitHub Actions を監査した — 本丸ゼロでも 2 件直した話

個人リポジトリ全部の GitHub Actions を監査した — 本丸ゼロでも 2 件直した話

まとめ

  • 個人アカウントの全リポジトリ(ワークフローを持つのは 60 本ほど)を調べたが、いちばん危ない pull_request_target / workflow_run は 1 件も無かった。
  • ただし別種の弱点が 2 件出た。ひとつは github-script の JS ソースに PR タイトルを直接埋め込む書き方、もうひとつはサードパーティ action を @master で参照する書き方。
  • 深刻度は「誰が踏めるか」で決まる。トリガーが pull_request なら fork の他人は secrets に触れないので、実際に踏めるのは書き込み権を持つ人だけになる。
  • コード検索 API のヒットはあてにならなかった。全ワークフローをデコードして自分で grep するのが結局いちばん速くて確実だった。
  • おまけで、npm から消えたパッケージのせいで CI が死んでいるリポジトリも見つかった。復旧元は手元の node_modules だった。

検索 API を信じず、全ファイルをデコードして読む

pull_request_target の危険性は前に一度書いた。base 側の権限で走るワークフローの中で PR 側のコードを checkout して実行すると、fork から出された他人のコードが secrets 付きで動く、というやつだ。

理屈は知っている。では、自分のリポジトリはどうなのか。一度も確かめたことがなかった。60 本近いワークフローを一つずつ目視する気にはなれず、放っていた。今回まとめて見た。

最初は横着して gh search codepull_request_target を引いた。0 件。安心しかけて、ヒットの中身を見たら全部ノイズだった。workflow_run のヒットは Go のコードや API レスポンスの JSON フィールド名で、ワークフローのトリガーとは無関係だ。

コード検索は private リポや未インデックスのファイルを取りこぼす。0 件が「無い」を意味しない。

やり方を変えて、全リポジトリの .github/workflows/ をリスト化し、各 YAML を base64 でデコードしてから grep した。

for r in $(gh repo list <owner> --limit 200 --json name --jq '.[].name'); do
  files=$(gh api "repos/<owner>/$r/contents/.github/workflows" --jq '.[].name' 2>/dev/null) || continue
  for f in $files; do
    gh api "repos/<owner>/$r/contents/.github/workflows/$f" --jq '.content' 2>/dev/null | base64 -d \
      | grep -qE '^\s*(pull_request_target|workflow_run)\s*:' && echo "HIT: $r/$f"
  done
done

これで pull_request_targetworkflow_run は全リポで 0 件だと確認できた。本丸は無かった。

ここで終わってもよかったのだが、grep の条件を変えて二次的な経路も見た。ひとつは $$run: や script に展開している箇所。もうひとつは pull_request で発火するのに secrets を参照しているワークフロー。両方に引っかかったファイルが 2 本あった。

表1: 見つかった弱点

リポジトリの用途 種別 踏める相手 対応
静的サイトのデプロイ github-script への値の埋め込み 書き込み権を持つ人 env 経由に変更
CSV 変換ツール 可変 ref の action 参照 action の乗っ取り時 コミット SHA に固定

弱点その1:github-script に PR タイトルをそのまま埋める

Cloudflare Pages へ Preview デプロイするワークフローに、こういう箇所があった。

- uses: actions/github-script@v7
  with:
    script: |
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `PR: #$ - $
               ブランチ: \`$\``
      })

$ は YAML の解釈時に文字列として展開される。展開後の文字列がそのまま JavaScript のソースになる。つまり PR タイトルやブランチ名が、コメント本文の中身ではなくコードの一部として評価される。

タイトルにバッククォートを混ぜてテンプレートリテラルを抜け、その後ろに任意の JS を書けば、それがランナー上で動く。PR タイトルは攻撃者が自由に決められる値だ。

直し方は、値を env: 経由でランナーに渡し、スクリプトの中では process.env から読む。

- uses: actions/github-script@v7
  env:
    PR_TITLE: $
    HEAD_REF: $
  with:
    script: |
      const { PR_TITLE, HEAD_REF } = process.env;
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: `PR: #${context.issue.number} - ${PR_TITLE}\nブランチ: \`${HEAD_REF}\``
      })

env: で渡した値は実行時のデータとして扱われ、ソースコードに焼き込まれない。だから中身に何が入っていてもコードにならない。

同じワークフローの Slack 通知にも同じ $ があったが、そちらは触らなかった。action-slack に渡すのは文字列の入力で、シェルや JS として評価されない。RCE の経路ではないので、直す対象を絞った。

弱点その2:@master で action を参照する

CSV 変換ツールのほうは、S3 へアップロードする action の書き方だった。

- uses: jakejarvis/s3-sync-action@master
  env:
    AWS_ACCESS_KEY_ID: $
    AWS_SECRET_ACCESS_KEY: $

@master は常に最新の master を取りに行く。この action のリポジトリが乗っ取られて master に悪意あるコードが入れば、env で渡している AWS 認証情報が次のビルドで抜かれる。自分のコードは一行も変わっていないのに、だ。

対策はコミット SHA での固定。タグも書き換えられるので、指すべきは動かない SHA になる。

- uses: jakejarvis/s3-sync-action@be0c4ab89158cac4278689ebedd8407dd5f35a83 # v0.5.1

ついでに actions/checkout@v2setup-node@v2 も古かったので、v4 系の SHA に上げた。

深刻度は「誰が踏めるか」で決める

指摘が 2 件出たとき、慌てて「脆弱性 2 件」と数えるのは早い。どちらもトリガーは pull_request であって pull_request_target ではない。

pull_request では、fork から出された PR に secrets は渡らないし、GITHUB_TOKEN も読み取り専用になる。だから github-script のインジェクションを踏めるのは、fork の他人ではなく、リポジトリに直接ブランチを push できる人(=コラボレータ)だけだ。一人で持っている private リポなら、実際に踏む主体がいない。

action 乗っ取りのほうも、fork PR では creds が空になるので、その経路では漏れない。将来リポジトリを公開したり人を追加したときに効いてくる予防、という位置づけになる。

つまり本丸(外部の他人が secrets を抜ける)はゼロで、残ったのは「条件が揃えば踏める」二次的なものだった。それでも直したのは、条件は後から勝手に揃うからだ。public 化やコラボレータ追加は、そのとき security のことなど考えずに実行する。

おまけ:npm から消えたパッケージで CI が死んでいた

action の SHA を固定した PR の CI が落ちた。中身を見たら、修正とは無関係の場所で npm install が 404 になっていた。

npm ERR! 404 Not Found - GET https://registry.npmjs.org/@datagridxl/datagridxl2/-/datagridxl2-2.0.20.tgz

依存していたグリッド表示のパッケージが、npm レジストリから unpublish されていた。レジストリの manifest を見ると time.unpublished が 2024-08-19 で記録されている。つまりこのリポの CI は、そのあとずっと壊れたまま放置されていたわけだ。誰も走らせていなかったので気づかなかった。

ミラー(npmmirror、jsdelivr)も当たったが、tarball の実体は消えていた。復旧できたのは、たまたま手元のマシンにインストール済みのコピーが残っていたからだ。

npm pack ~/code/<repo>/node_modules/@datagridxl/datagridxl2

npm pack は、インストール済みのディレクトリからでも publish 時と同じ tarball を作り直せる。これを vendor/ に置き、package.json の依存を file:vendor/....tgz に切り替えた。lockfile を作り直して npm installnpm run build が通り、CI も緑になった。

手元に消えたバージョンが残っていなければ、代替ライブラリへの移行しか無かった。依存先が消える可能性は、ふだんまったく意識していなかった。

持ち帰り

一度も監査していないワークフローは、理屈を知っていることとは別に、実際どうなっているか分からない。分からないものを「たぶん大丈夫」で放置していた。

やってみて効いたのは 2 つだ。コード検索 API のヒットは中身を開くまで信じないこと。そして深刻度を「誰が踏めるか」で判定して、本丸と二次的なものを分けて考えること。全部を同じ緊急度で扱うと、直す気力が続かない。

依存が消えて CI が壊れていたのは、完全に想定の外だった。CI を長く走らせていないリポジトリは、次に触ったとき静かに動かなくなっている。これも一度、手元のコピーで救えたから助かっただけで、運が良かったと思っている。

トレンドのタグ