2025年3月、よく使われていたアクションtj-actions/changed-filesで、複数のバージョンタグが悪意あるコミットを指すように書き換えられました。GitHubのセキュリティアドバイザリによると、影響を受けたリポジトリは2万3,000以上。タグで指定していたワークフローは、書き換えられた瞬間から別のコードを動かし、Runnerのメモリにあった秘密情報をログへ書き出していました。
GitHubが10月28日に一般提供を始めたworkflow lockfileは、この穴をふさぐ仕組みです。.github/workflows/actions.lockに、使っているアクションと、その内側で呼ばれるアクションのコミットまでを記録し、実行時はそのコミットだけを動かします。FreeプランとTeamでも使えます。
私の結論は、シークレットを渡しているワークフローがあるなら今日入れてよい、です。ただし入れただけでは足りません。後から追加したワークフローは自動では守られず、エラーのたびにlockfileを消して作り直すと、守りはその場で消えます。導入手順に加えて、この2つの落とし穴と、エラーが出たときの判断の仕方を書いていきます。
タグを書いたままでも、実行されるのはlockfileに記録したコミット
lockfileの中身は、ワークフローごとの依存の一覧と、依存ごとのref(タグやブランチ)、commit、リポジトリIDです。拡張機能を配布しているgithub/gh-actions-lockのリポジトリ自身も使っていて、そのファイルから一部を抜き出すと次のようになっています。
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'cli/gh-extension-precompile@v2.2.0':
ref: 'v2.2.0'
commit: 'sha1-76961aa3bd1123d0a6fd42d0a41aca0696937c39'
owner_id: 59704711
repo_id: 421583656
uses:
- 'actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32'
- 'actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c'
見てほしいのはusesの行です。gh-extension-precompileが内部で呼んでいるアクションまで、コミットで固定されています。ワークフローに40桁のSHAを書く従来のpinningでは、自分が書いた1段目しか固定できませんでした。リポジトリIDも持っているので、元のリポジトリが削除されて同じ名前を他人に取られた場合も、別物として検出されます。
| 比べる点 | タグ指定 | SHA pinning | lockfile |
|---|---|---|---|
| 内側で呼ばれるアクション | 固定されない | 固定されない | 固定される |
| ワークフローの見た目 | @v4などのタグ | 40桁のSHA | @v4.2.1などのタグ |
| タグが書き換えられたとき | 新しい中身が動く | 影響なし | 記録したコミットが動き続ける |
| 更新の手間 | なし | SHAの書き換え | Dependabotが同じPRで更新 |
導入で実行するコマンドは2つ。差分ではバージョンの書き換えを見る
作業はGitHub CLIにログインした状態で、リポジトリのルートで行います。対応しているのはgithub.comとdata residency版のGitHub Enterprise Cloudで、GitHub Enterprise Serverには対応していません。
- 拡張機能を入れる。
gh actions-lockを実行する。.github/workflows/の全ワークフローを読み、lockfileを作ります。- 差分を見る。
@v4のような部分的なバージョン指定は、その時点で最新の完全なバージョン(例:@v4.2.1)に書き換わります。 - ワークフローとlockfileを同じコミットに入れる。
gh extension install github/gh-actions-lock
cd your-repo
gh actions-lock
git diff
git add .github/workflows/
git commit -m "Lock GitHub Actions dependencies"
いきなり書き換えるのが不安なら、先にgh actions-lock --no-fixを実行します。ファイルを変えずに、指摘だけを表示する読み取り専用のモードです。
確認の範囲を書いておきます。私は10月31日に最新のv0.4.0を取得し、--helpの内容と公式文書、公式リポジトリの実ファイルを読みました。自分のリポジトリでlockfileを生成するところまでは試していません。ヘルプによると、部分バージョンを書き換えるのは、完全なバージョンのタグはほぼ動かず、ロックしたコミットが長く有効なままになるからです。元の書き方を残したいときは--no-narrowを付けます。
実際に試したDevelopersIOの検証では、登録済みのワークフローにactions/cacheを足してlockfileを更新し忘れたところ、ジョブが始まる前にstartup_failureで止まりました。表示されたのは「Invalid lockfile: .github/workflows/actions.lock#L1 The lockfile could not be validated.」で始まり、gh actions-lockでの再生成を促すメッセージです。アクションのバージョンを上げたら、lockfileの再生成までを1回の変更と考えてください。
./で呼んでいるローカルアクションは$/に書き換える
uses: ./.github/actions/buildのような書き方は、lockfileの対象外です。./は実行時の作業ディレクトリを指すので、前の手順のcheckoutやスクリプトで中身が変わりえます。そのローカルアクションが外部のアクションを使っていると、gh actions-lockはエラーにします。
直し方はgh actions-lock --migrate-local-actionsで、./を$/に書き換えることです。$/は常にワークフロー自身のコミットにあるファイルを使う書き方で、中で使うアクションもロックされます。書き換えた差分は、コミットする前に目で確かめます。
後から足したワークフローは、何もしなければ素通りする
lockfileへの登録はワークフロー単位です。公式文書も、依存がlockfileに必要になるのは登録(オンボード)した後のワークフローだと説明しています。裏返すと、新しく追加したワークフローは、登録するまで従来どおりタグで動きます。Zennで公開されている検証メモにも、新しいワークフローを追加してロックしなかったら普通に通った、と記録されています。
これを防ぐ方法は2つあります。1つは、CIでgh actions-lock --verifyを回すことです。未登録のワークフローは「not-pinned」として失敗するので、PRの段階で気づけます。もう1つは、Settings → Actions → Policiesで「Require lockfile」ポリシーを有効にし、未登録のワークフローの実行そのものを止めることです。ただし、影響を見るだけのEvaluateモードはGitHub Enterpriseに限られ、個人アカウントのリポジトリで設定できるかは公式文書に明記がありませんでした。
CIのひな形には、公式リポジトリのverify-lockfile.ymlが使えます。次は拡張機能のバージョンだけを最新に書き換えたものです。私の環境では動かしていません。
name: verify-lockfile
on:
pull_request:
push:
branches:
- main
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- run: gh extension install github/gh-actions-lock --pin v0.4.0
- run: gh actions-lock --verify
--pinは外さないでください。lockfileが固定するのはuses:で呼ぶアクションだけで、run:の中で入れる拡張機能は対象外です。検証役の道具が知らないうちに入れ替わっては意味がありません。手元の拡張機能を上げたときは、ここのバージョンも合わせます。新しい拡張機能で作ったlockfileを古い拡張機能が読むと、終了コード2で失敗するためです。このワークフロー自体もcheckoutを使うので、追加したらgh actions-lockで登録してからコミットします。
エラーが出たら、lockfileを消さずに種類で判断する
一番やってはいけないのは、エラーが消えないからとactions.lockを削除して作り直すことです。公式のトラブル対応文書が理由をはっきり書いています。記録済みの参照は普段は取り直されませんが、削除すると全部の参照が「今日指している先」で解決し直されます。悪意で動かされたタグも、警告なしで受け入れてしまう。tj-actionsのような書き換えに気づく機会を、自分で捨てることになります。
出たエラーは、表示されるカテゴリ名で3つに分けて考えると迷いません。
作り直してよい:not-pinned、ref-changed、stale
自分がワークフローを変えたことが原因のエラーです。gh actions-lockをもう一度実行し、ワークフローとlockfileを同じコミットに入れれば解消します。
立ち止まる:unreachable-pin、tag-commit-mismatch
ロックしたコミットがタグやブランチの履歴から外れた、またはタグが別のコミットに動いたという知らせです。上流が履歴を書き換えたのか、攻撃なのか、誰かがlockfileを手で編集したのか。拡張機能には区別がつかないので、安全側に倒して止まります。
まず既定ブランチのlockfileと比べ、手で編集されていないかを見ます。戻すときは次のようにします。
git fetch origin
git checkout origin/main -- .github/workflows/actions.lock
gh actions-lock
次に、アクションのリリースノートと履歴を読みます。新しいコミットが正当だと判断できたときだけ、unreachable-pinならgh actions-lock --accept-moved、tag-commit-mismatchなら--relockで受け入れます。公式文書は、--accept-movedを自動化に組み込まないよう求めています。
信用しない:repo-replaced、misleading-sha
repo-replacedは、アクションの名前が別のリポジトリのものに変わったという意味です。元のリポジトリが消され、空いた名前を他人が取った可能性があります。再ロックでは直らず、正しい移転先を自分で探してuses:を書き換えるしかありません。misleading-shaは、コミットSHAに見せかけた名前のタグやブランチです。どちらも、そのアクションを使い続けるかどうかから考え直す場面です。
lockfileでは守れないもの。いちばん危ないのは書き込み権限を持つ相手
GitHub自身が発表の中で、lockfileは予期しない依存の変化から守るものの、その依存が安全かどうかは判定しないと書いています。ほかにも、次のものは対象外です。
- 他のリポジトリにあるreusable workflow。定義している側のリポジトリのlockfileで守られます。
docker://alpine:3のようにタグで指定したコンテナイメージ。@sha256:で始まるdigestで指定します。- ワークフローに渡す入力値やシークレット、ランナーのイメージ。
run:の中でcurlやnpm installを使って取ってくるもの。
見落としやすいのは、リポジトリに書き込める人には効かないことです。LiteLLM 1.107.0の改ざんでは、ghのトークンを手に入れた攻撃者がワークフローそのものを書き換えました。同じ立場の攻撃者なら、lockfileも一緒に作り直せます。公式の導入手順がactions.lockをCODEOWNERSに加えてレビュー必須にするよう勧めているのは、このためだと私は読んでいます。トークンの有効期限やブランチ保護と組み合わせて、ようやく効く仕組みです。
Dependabotは同じPRでlockfileも直す。cooldownは通知に使われるだけ
アクションの更新は、これまでどおりDependabotに任せられます。登録済みのワークフローなら、Dependabotが開く更新PRの中でlockfileのエントリも作り直されます。GitHubのドキュメントによると、追加の設定は要りません。
誤解しやすいのがDependabotのcooldownです。gh-actions-lockはこの値を読みますが、使い道は「公開から日が浅いタグをロックした」という通知だけで、新しいリリースの取り込みを遅らせはしません。設定がなければ、公開から3日以内のタグが通知の対象です。出たばかりのリリースを避けたいなら、Dependabot側のcooldownで更新PRそのものを遅らせます。公式リポジトリは7日にしています。
今日入れるリポジトリ、後回しでよいリポジトリ
私の判断基準は、そのワークフローに秘密情報が渡っているかどうかです。デプロイ用の鍵、クラウドの認証情報、パッケージ公開用のトークン、AIのAPIキー。どれかを使うワークフローが1本でもあれば、今日入れる価値があります。tj-actionsの件で狙われたのも、Runnerに渡っていたこうした値でした。
反対に、外部のアクションを使わずrun:だけで完結するワークフローや、シークレットを渡さない練習用のリポジトリなら、急ぐ理由は薄いです。チームや組織では、いきなりRequire lockfileを全体に掛けず、いくつかのリポジトリで--verifyのCIを先に回し、エラーの出方を見てから強制に進む。これは公式が勧める順番でもあります。
手始めに、手元でgh actions-lock --no-fixを1回実行してみてください。ファイルは変わらず、タグ指定や./のローカルアクションがどこに残っているかが指摘として出ます。それを見れば、導入にかかる手間はだいたい見積もれます。

コメント