個人リポジトリ全部の 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 code で pull_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_target と workflow_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@v2 や setup-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/datagridxl2npm pack は、インストール済みのディレクトリからでも publish 時と同じ tarball を作り直せる。これを vendor/ に置き、package.json の依存を file:vendor/....tgz に切り替えた。lockfile を作り直して npm install と npm run build が通り、CI も緑になった。
手元に消えたバージョンが残っていなければ、代替ライブラリへの移行しか無かった。依存先が消える可能性は、ふだんまったく意識していなかった。
持ち帰り
一度も監査していないワークフローは、理屈を知っていることとは別に、実際どうなっているか分からない。分からないものを「たぶん大丈夫」で放置していた。
やってみて効いたのは 2 つだ。コード検索 API のヒットは中身を開くまで信じないこと。そして深刻度を「誰が踏めるか」で判定して、本丸と二次的なものを分けて考えること。全部を同じ緊急度で扱うと、直す気力が続かない。
依存が消えて CI が壊れていたのは、完全に想定の外だった。CI を長く走らせていないリポジトリは、次に触ったとき静かに動かなくなっている。これも一度、手元のコピーで救えたから助かっただけで、運が良かったと思っている。