DevOps

Renovate の Auto-merge 対象から Digest を外した

  • Renovate
  • GitHub Actions
  • Security
  • CI/CD
  • DevOps

GitHub Actions を commit SHA で Pin していても、その SHA を書き換える Renovate の PR が人の目を通らずにマージされるなら、Pin は攻撃を防がず、次の Renovate 実行まで遅らせるだけです。

ラビーで共有している Renovate の Preset(LabeeHive/renovate-config)の automerge 対象から Digest を外しました。残したのは minorpatchpin の3種類です。

{
  packageRules: [
    {
      matchUpdateTypes: ["minor", "patch", "pin"],
      automerge: true,
    },
  ],
}
GitHubGitHub - LabeeHive/renovate-config: Org-wide Renovate inheritance config and shared preset for LabeeHiveOrg-wide Renovate inheritance config and shared preset for LabeeHive - LabeeHive/renovate-config

SHA Pin とフル semver のコメントが揃っていれば、通常のリリースは Patch や Minor の更新として届き、新しい SHA もその中に含まれます。Digest 更新が単独で来るのは、リリース済みのタグが別のコミットへ動いたときだけです。それは必ず人が確認すべきことなので、Digest は最初から automerge の対象に入れるべきではありませんでした。

前に書いた2本の記事は、SHA Pin と Renovate の automerge をセットで勧めていました。この記事はその続きで、どこに穴があったか、minimumReleaseAge ではなぜ止められないか、Pin のコメントの書き方で何が変わるか、先に試した例外ルールをなぜ採らなかったかを記録します。

ブログGitHub Actions の workflow を 4 項目で安全側に倒したorg 配下の workflow を `defaults.run.shell: bash`、`actions/checkout` の `persist-credentials: false`、サードパーティアクションの SHA pin、`permissions` 最小化の4項目で揃えた経緯と、`git push` を含む `release.yml` や `submodules: true` を併用する workflow で「適用しない」を選んだ判断軸を記録します。 ブログRenovateでCloudflare Workers SDKのパッケージをグルーピングするCloudflare Workers SDKはモノレポで管理されていますが、Renovateのデフォルト設定ではパッケージごとに個別のPRが作られます。wrangler、@cloudflare/vite-plugin、miniflareを1つのPRにまとめるpackageRulesの設定と、monorepo presetへの提案で浮上したimmortal PRs問題を紹介します。

前の2本が勧めていたこと

1本目では、サードパーティアクションを commit SHA で Pin し、横に # v7.0.1 のようにバージョンをコメントで残し、更新は Renovate に任せると書きました。

2本目では、その Renovate の設定を載せています。minorpatchpindigest の4種類を automerge の対象にしていました。設定の置き場所は当時の LabeeHive/.github から LabeeHive/renovate-config に移っていますが、このルールはそのまま引き継がれていました。

どちらも単体では間違いではありません。2つを重ねると「SHA Pin を書き換える PR は CI さえ通れば誰も見ずにマージされる」状態になります。

1本目の中でも、SHA Pin の節では「人がレビューしてからマージできます」と書き、Renovate の節では「CI が通れば自動マージされます」と書いていて、同じ記事の中で食い違っていました。Pin は参照を固定しますが、参照を書き換える側までは縛っていません。

張り替えられたタグは Renovate の PR になって入ってくる

2025年3月の tj-actions/changed-files の事件では、既存のバージョンタグがまとめて悪意あるコミットに張り替えられました。タグ参照で使っていたリポジトリは、次の実行でそのコミットを取り込みます。SHA Pin なら、その時点では古いコミットを指したままなので影響を受けません。

ただし Renovate が動くと話が変わります。Renovate は Pin された SHA の横にあるコメント(# v7# v7.0.1)を現在のバージョンとして読み、そのタグが今どのコミットを指しているかを GitHub に問い合わせ、違っていれば新しい SHA に書き換える PR を作ります。これが Digest 更新です。

メンテナーが v7 を新しいリリースに進めた場合も、攻撃者が v7 を別のコミットに張り替えた場合も、Renovate から見ればどちらも同じ Digest 更新で、両者を見分ける材料がありません。

その先で何が起きるかは、2段階で決まります。1段目は Pin のコメントです。# v7.0.1 のようにフル semver で書いてあれば、v7.0.2 のリリースは Patch 更新として届き、v7 タグが動いただけでは何も来ません。# v7 のようにメジャーだけなら、リリースも張り替えも同じ Digest 更新になります。仕組みは後の節で追います。

2段目は、その Digest 更新を automerge するかどうかです。前の記事の設定では Digest も automerge の対象だったので、張り替えられたタグは次の Renovate の実行で、CI が通りさえすればデフォルトブランチに入っていました。

同じ「v7 タグが別のコミットを指す」出来事が、Pin のコメントと Digest 更新の automerge 設定でどう扱われるか。# v7.0.1 なら Patch 更新として届き、これまで通り automerge される。# v7 なら Digest 更新になり、Digest も automerge する前の設定では CI が通れば誰も見ずにマージされ、Digest を automerge しない今回の設定では人がレビューしてからマージする

SHA Pin は攻撃を防いでおらず、次の実行まで遅らせているだけでした。残るゲートは CI だけです。

その CI も、:skipStatusChecksextends に入れていたリポジトリがいくつかあり、そこでは CI の結果を待たずにマージされていました。こちらは先に外しています。

minimumReleaseAge では止められない

automerge から外す前に、Digest 更新を minimumReleaseAge: "7 days" のように寝かせれば足りるかどうかを確かめました。足りません。

Renovate の minimumReleaseAge の解説によれば、Digest 更新は現在値に一致するバージョンのリリース時刻からの経過時間で判定されます(2026年7月30日リリースの 44.3.1 から)。問題はその「リリース時刻」の出どころです。

github-tags データソースが返すのは、タグが今指しているコミットの日付(注釈付きタグならタグを作った人の日付)で、タグが Push された時刻ではありません。コミットの日付は作る側が決める値なので、張り替えた当日でも7日の条件を素通りします。同じドキュメントが、Force Push されたタグの Digest 更新は minimumReleaseAge を即座に通り得る、と明記しています。

Docker Hub は tag_last_pushed を返すので、タグを Push し直せば7日の数え直しになります。同じ minimumReleaseAge でも、データソースによって守れる範囲が違います。

minimumReleaseAge が Digest 更新の時刻として読む値の違い。github-tags はタグが今指すコミットの日付を読み、それはコミットを作る側が決める値なので、張り替えた当日でも Digest 更新が7日の条件を素通りして PR になり得る。Docker Hub はタグを Push した時刻 tag_last_pushed を読むので、Push し直すと7日の数え直しになる

ラビーの Preset では、GitHub Actions の更新を寝かせる設定がそもそもありません。extends している config:best-practicesminimumReleaseAge を設定するのは npm だけ(security:minimumReleaseAgeNpm、3日)です。日数を設定しても上の理由で Digest 更新には効かないので、automerge から外すのが答えになります。

Pin のコメントの粒度で updateType が変わる

Digest 更新を automerge から外すと決めたあと、影響範囲を洗い出す前に確かめておくべきことがありました。どの更新が Digest に分類されるかは、SHA ではなく横のコメントで決まります。Renovate のソースで追うと、次の3段です。

github-actions Manager は、uses: の参照がフル SHA なら、横のコメントを currentValue、SHA を currentDigest として取り出します。コメントが無い Pin は currentValue が決まらず unversioned-reference として更新対象から外れるので、コメントは飾りではなく、これがあるから Pin を更新できます。

lookup は、currentDigest がある依存に対して、バージョン更新の候補とは別に updateType: 'digest' の候補を必ず積みます。候補ごとに newDigest を解決し、newValuenewDigest も変わっていない候補は最後に捨てられます。

バージョンの比較には GitHub Actions 専用の Versioning を使います。その getNewValue() は、現在値が v7 のようにメジャーだけで、新しい版が同じメジャーの v7.0.2 なら、v7 をそのまま返します。

返ってきた値が現在値と同じなので、lookup はそのバージョン更新を捨て、残るのは v7 タグが指す先が変わったときの Digest 更新だけです。ドキュメントの例もこの形で、# v7 のまま SHA だけが書き換わります。

# コメントがフル semver なら、v7.0.2 のリリースは patch 更新になる
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# コメントがメジャーだけなら、v7 タグが動いたときの digest 更新しか来ない
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

コメントが v7.0.1 なら、getNewValue()v7.0.2 を返し、更新は Patch に分類されて、新しい SHA も同じ更新に newDigest として含まれます。2つの書き方で、同じ出来事がどう見えるかを並べるとこうなります。

コメントv7.0.2 がリリースされ、v7 タグもそこへ進んだv7.0.1 タグが別のコミットに張り替えられたv7 タグが別のコミットに張り替えられた
# v7.0.1Patch 更新Digest 更新何も起きない
# v7Digest 更新何も起きないDigest 更新

フル semver のコメントでは、Digest 更新が来るのは「リリース済みのバージョンタグが動いた」ときだけです。リリースタグは動かないはずなので、これは必ず人が確認すべきもので、手動レビューに回す判断と噛み合います。

メジャーだけのコメントでは、通常のリリースも張り替えも同じ Digest 更新になります。Digest を automerge から外すと、そのアクションは メジャー以外の更新が全部手動マージ になります。

Digest を automerge から外す前提は、Pin のコメントがフル semver で揃っていることです。揃っていなければ、外した分だけ手動マージが増えます。# v7# v7.0.1 に直す作業は、SHA が実際にどの semver タグに対応するかをタグ一覧から引いてコメントだけを書き換えるもので、SHA 自体も CI の挙動も変わりません。

Renovate には helpers:pinGitHubActionDigestsToSemver という Preset があり、この作業を肩代わりしてくれそうに見えますが、使っていません。extractVersionvX.Y.Z に一致するタグだけを候補にするので、DeterminateSystems/nix-installer-action のようにメジャータグしか公開していないアクションは候補がゼロになり、PR が来なくなります。Renovate のログには「バージョンに見える結果が無い」と残りますが、PR が来ないこと自体に気づく仕組みはありません。

そうしたアクションは、メジャーだけのコメントのまま、更新が全部手動マージになることを受け入れています。バージョン更新とタグの張り替えを原理的に区別できないケースなので、まさに人が見るべき対象です。

先に試した例外ルールと、採らなかった理由

ラビーの Preset を変える前に、個人アカウント側の Preset で、GitHub Actions の Digest 更新だけを automerge から外す例外ルールを試しました。

{
  packageRules: [
    {
      matchUpdateTypes: ["minor", "patch", "pin", "digest"],
      automerge: true,
    },
    {
      matchManagers: ["github-actions"],
      matchDepTypes: ["action"],
      matchUpdateTypes: ["digest"],
      automerge: false,
    },
  ],
}

matchDepTypes: ["action"] を付けているのは、github-actions Manager が uses: 以外も扱うからです。jobs.<id>.container.imageservices のイメージも同じ Manager が読み、depType はそれぞれ containerservice になります。Manager だけでマッチさせると、E2E テストで使うコンテナーイメージまで巻き込みます。

この形にしたのは、Docker イメージの Digest 更新を automerge に残したかったからです。タグを張り替えられるという理屈は Docker にも当てはまりますが、実証できた仕組みは github-tags の時刻の取り方に固有のもので、範囲を広げるとコンテナー更新のレビューの手間が増えます。

ラビーの Preset では、この例外ルールを持ち込まず、automerge の対象から Digest を外しました。Pin のコメントをフル semver にしてあれば、Digest 更新が単独で来るのはリリース済みのタグが動いたときだけです。

リリース済みのタグが動いたら Manager を問わず人が見る、という線引きにしたので、Manager ごとに例外を書く理由がありません。Docker イメージの Digest 更新も automerge されなくなります。

自分のタグも張り替えられないようにした

自分が公開している側のタグも、同じ張り替えの対象です。タグを対象にした Ruleset を入れました。ルールは updatedeletionnon_fast_forward の3つで、creation は入れていません。

creation を入れなかったのは、新規タグの作成を禁じるとリリースが打てなくなり、バイパスできるのはリポジトリ管理者、つまりオーナー自身だけになるからです。オーナーのトークンを持っている攻撃者は creation で止まらないので、入れても守れる範囲が増えません。

合わせて、リポジトリ設定の sha_pinning_required を有効にしました。すでに全 Pin が SHA になっているので、現状を固定するだけで副作用はありません。設定は API から確認できます。

gh api repos/<owner>/<repo>/actions/permissions --jq '.sha_pinning_required'

この設定がリポジトリ内のローカルアクション(uses: ./)を弾くかどうかは、ドキュメントに書かれていません。uses: ./ を使う CI で有効にしたあとに走らせて確かめたところ、Ubuntu と macOS の両方でジョブは成功しています。実測の結果であってドキュメント上の保証ではないので、GitHub 側が挙動を変えれば止まります。