サプライチェーンセキュリティの自動化:依存関係アップデーターとバージョン固定システム
前提知識
- ›第1回:アーキテクチャ(versions.json の背景理解のため)
- ›セマンティックバージョニング(semver)の基礎知識
- ›GitHub API の基本的な使い方
サプライチェーンセキュリティの自動化:依存関係アップデーターとバージョン固定システム
第1〜3回では、versions.json を所与のものとして扱ってきました。Dockerfile が上流依存関係のクローンと検証に使う「信頼できる唯一の情報源」として。しかし、そのバージョン情報を常に最新に保つのは誰の(あるいは何の)仕事でしょうか?
手作業の場合、4つの上流リポジトリを新リリースのたびに監視する必要があります。異なるタグ形式を解析し、更新がダウングレードでないかを確認し、versions.json と versions.env の両方を更新したうえで、差分リンクとともにプルリクエストを作成しなければなりません。
dependency_updater は、このパイプライン全体を自動化する Go コマンドラインツールです。GitHub Actions 経由で毎日実行され、GitHub Tags API をページネーションつきで叩きます。トラッキングモードでバージョンを絞り込み、ダウングレード防止を適用し、PR を自動生成します。バージョン解析ロジックだけで4つの異なるタグ形式に対応しており、上流リポジトリが採用するバージョニング規則の多様さを物語っています。
versions.json:信頼できる唯一の情報源
まずは versions.json の構造をよく見てみましょう。4つの依存関係それぞれに、明確なスキーマが定義されています。
{
"op_node": {
"tag": "op-node/v1.16.11",
"commit": "cba7aba0c98aae22720b21c3a023990a486cb6e0",
"tagPrefix": "op-node",
"owner": "ethereum-optimism",
"repo": "optimism",
"tracking": "release"
}
}
| フィールド | 役割 |
|---|---|
tag |
クローン対象の Git タグ — 人間が読めるバージョン識別子 |
commit |
そのタグが指すべき SHA — サプライチェーン検証に使用 |
tagPrefix |
モノレポ用タグのプレフィックス(例:op-node/v1.16.11 の op-node) |
owner / repo |
GitHub の組織名とリポジトリ名 |
tracking |
バージョン選択の戦略:release、tag、または branch |
tracking フィールドは、アップデーターがどの上流バージョンを有効とみなすかを制御します。
release: 安定版のみ(プレリリースサフィックスを含まない)。現在の4つの依存関係すべてで使用。tag: 安定版に加えてリリース候補(-rc1、-rc.2)も含む。プレリリース系列を追いかけたいときに便利。branch: タグを無視して、特定ブランチの最新コミットを追跡する。どのブランチを追うかはbranchフィールドで指定。
flowchart TD
T{"tracking mode"} -->|release| R["Only stable versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ❌<br/>v0.7.1-synctest.0 ❌"]
T -->|tag| RC["Stable + RC versions<br/>v0.7.0 ✅<br/>v0.7.1-rc1 ✅<br/>v0.7.1-synctest.0 ❌"]
T -->|branch| BR["Latest commit on branch<br/>No tag filtering<br/>Tracks HEAD of branch"]
4つの形式に対応するバージョン解析
4つの上流依存関係は、それぞれ異なるバージョニング規則を採用しています。version.go はノーマライズパイプラインを通じて、これらすべてを統一的に扱います。
| 依存関係 | タグの例 | 形式 |
|---|---|---|
| base_reth_node | v0.7.0 |
v プレフィックスつき標準 semver |
| nethermind | 1.36.2 |
プレフィックスなしの bare semver |
| op_geth | v1.101702.0 |
v プレフィックスつき・マイナーバージョンが特殊 |
| op_node | op-node/v1.16.11 |
モノレポプレフィックスつきタグ |
ParseVersion 関数は、この多様性を3つのステップで処理します。
flowchart LR
A["Input:<br/>op-node/v1.16.11-rc1"] -->|"Step 1: Strip prefix"| B["v1.16.11-rc1"]
B -->|"Step 2: Normalize RC"| C["v1.16.11-rc.1"]
C -->|"Step 3: Parse semver"| D["Version{1, 16, 11, rc.1}"]
ステップ1:タグプレフィックスを除去する。 tagPrefix が設定されている場合、それに続くスラッシュも含めて取り除きます。op-node/v1.16.11 は v1.16.11 になります。
ステップ2:RC 形式をノーマライズする。 normalizeRCFormat 関数は正規表現を使い、あらゆる RC 形式のバリエーションを semver 互換の形式に変換します。
var rcPattern = regexp.MustCompile(`(?i)-rc[.-]?(\d+)`)
func normalizeRCFormat(version string) string {
return rcPattern.ReplaceAllString(version, "-rc.$1")
}
この正規表現1つで -rc1、-rc.1、-rc-1、-RC1 のすべてを -rc.1 に統一します。(?i) フラグで大文字小文字を区別しません。これらのバリエーションはすべて version_test.go のテストでカバーされています。
ステップ3:Masterminds/semver でパースする。 ノーマライズ済みの文字列を Masterminds ライブラリの semver.NewVersion() に渡します。このライブラリは v プレフィックスを自動で処理し、構造化されたバージョンオブジェクトを返します。
補足: op-geth の
v1.101702.0という形式は一見奇妙に見えますが、有効な semver です。マイナーバージョン101702には Optimism プロトコルのバージョン情報がエンコードされています。semver ライブラリはこれを通常の整数として扱うので、特別な処理は不要です。
バージョン比較とダウングレード防止
バージョンのパースが完了したら、次に確認すべきは「新しいバージョンが本当にアップグレードかどうか」です。ValidateVersionUpgrade 関数は、バージョンが常に前進する方向にのみ動くことを厳密に保証します。
func ValidateVersionUpgrade(currentTag, newTag, tagPrefix string) error {
if currentTag == "" {
_, err := ParseVersion(newTag, tagPrefix)
return err
}
currentVersion, err := ParseVersion(currentTag, tagPrefix)
if err != nil {
_, newErr := ParseVersion(newTag, tagPrefix)
return newErr
}
newVersion, err := ParseVersion(newTag, tagPrefix)
if err != nil {
return fmt.Errorf("new version %q is not a valid semver: %w", newTag, err)
}
if newVersion.LessThan(currentVersion) {
return fmt.Errorf("version downgrade detected: %s -> %s", currentTag, newTag)
}
return nil
}
ダウングレード防止のロジックはシンプルですが、いくつかのエッジケースが丁寧に処理されています。
- 現在バージョンが空の場合:有効な新バージョンであれば無条件に受け入れる(初回セットアップ用)
- 現在バージョンがパース不能な場合:現在タグを解析できなくても、新しいタグが有効な semver であれば更新を許可する
- RC のソート順:RC バージョンは対応する安定版リリースよりも低いとみなされる(
v0.3.0-rc2 < v0.3.0)
flowchart TD
A["v0.2.2 (current)"] --> B{"Is v0.3.0-rc1 an upgrade?"}
B -->|"0.3.0-rc1 > 0.2.2 ✅"| C["Valid upgrade"]
C --> D{"Is v0.3.0-rc2 an upgrade?"}
D -->|"0.3.0-rc2 > 0.3.0-rc1 ✅"| E["Valid upgrade"]
E --> F{"Is v0.3.0 an upgrade?"}
F -->|"0.3.0 > 0.3.0-rc2 ✅"| G["Valid: RC → stable"]
G --> H{"Is v0.3.0-rc2 an upgrade?"}
H -->|"0.3.0-rc2 < 0.3.0 ❌"| I["BLOCKED: downgrade!"]
style I fill:#ff6666
version_test.go の71〜122行目 のテストスイートは非常に網羅的です。アップグレードパスやダウングレード、パース不能なバージョンのエッジケースを検証しています。さらにクロスプレフィックスの検証(rollup-boost/v0.7.11 を websocket-proxy/v0.0.2 への「アップグレード」とみなさないことの確認)まで含んでいます。
更新フロー:GitHub API から PR 作成まで
更新処理のメインロジックは dependency_updater.go に実装されています。各依存関係の処理は次の流れで進みます。
flowchart TD
A["Read versions.json"] --> B["For each dependency"]
B --> C{"tracking mode?"}
C -->|"release/tag"| D["Paginate GitHub Tags API"]
C -->|"branch"| E["Get latest commit on branch"]
D --> F["Filter by tagPrefix"]
F --> G["Filter by tracking mode<br/>(release-only or release+RC)"]
G --> H["Validate: is this an upgrade?"]
H --> I["Find maximum valid version"]
I --> J{"Version changed?"}
J -->|Yes| K["Update versions.json"]
J -->|No| L["Skip dependency"]
E --> M{"Commit changed?"}
M -->|Yes| K
M -->|No| L
K --> N["Regenerate versions.env"]
N --> O["Create commit with diff URL"]
getVersionAndCommit のタグベース更新ロジックは特によく設計されています。最初に見つかった有効なアップグレード候補をそのまま採用するのではなく、全ページにわたってすべての有効なタグを収集してから、その中の最大バージョンを選び出します。
// Collect all valid tags across all pages, then find the max version
var validTags []*github.RepositoryTag
for {
tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, options)
// ... filter by prefix, tracking mode, upgrade validation ...
if resp.NextPage == 0 {
break
}
options.Page = resp.NextPage
}
// Find the maximum version among valid tags
for _, tag := range validTags {
if selectedTag == nil {
selectedTag = tag
continue
}
cmp, _ := CompareVersions(*tag.Name, *selectedTag.Name, tagPrefix)
if cmp > 0 {
selectedTag = tag
}
}
この「全ページ取得してから最大値を選ぶ」アプローチが重要なのは、GitHub Tags API がタグをセマンティックバージョン順で返さないためです。返却順は時系列またはレキシコグラフィック(辞書)順であるため、最初のページだけを見ると最新バージョンを見落とす可能性があります。
この関数はコミットメッセージ用の差分 URL も生成します。形式は https://github.com/{owner}/{repo}/compare/{old_tag}...{new_tag} で、PR レビュアーが上流の変更内容を直接確認できるようになっています。
branch トラッキングモード(284〜307行目)のロジックはよりシンプルです。指定ブランチの最新コミットを取得して SHA ハッシュを比較し、変化があれば旧コミットと新コミットの差分 URL を生成します。
すべての API 呼び出しは Optimism SDK の retry.Do0 でラップされており(100行目)、1秒固定の間隔で最大3回リトライします。GitHub API の一時的な障害を透過的に処理するための仕組みです。
versions.env の生成
versions.json の更新後、アップデーターは createVersionsEnv 関数で versions.env を再生成します。
func createVersionsEnv(repoPath string, dependencies Dependencies) error {
envLines := []string{}
for dependency := range dependencies {
repoUrl := generateGithubRepoUrl(dependencies, dependency) + ".git"
dependencyPrefix := strings.ToUpper(dependency)
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "TAG", dependencies[dependency].Tag))
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "COMMIT", dependencies[dependency].Commit))
envLines = append(envLines, fmt.Sprintf("export %s_%s=%s",
dependencyPrefix, "REPO", repoUrl))
}
slices.Sort(envLines)
// ... write to file
}
命名規則は決定論的です。依存関係のキーを大文字化し(op_geth → OP_GETH)、末尾に _TAG、_COMMIT、_REPO を付加します。ファイルへの書き込み前にアルファベット順でソートすることで、1つの依存関係だけが変わった場合の差分が読みやすくなります。
flowchart LR
VJ["versions.json<br/>op_geth.tag = v1.101702.0<br/>op_geth.commit = d0734fd..."] -->|createVersionsEnv| VE["versions.env<br/>export OP_GETH_TAG=v1.101702.0<br/>export OP_GETH_COMMIT=d0734fd...<br/>export OP_GETH_REPO=https://..."]
VE -->|"COPY into Dockerfile"| DF["RUN . /tmp/versions.env && ..."]
注意:
versions.jsonとversions.envはどちらもリポジトリにコミットします。.envファイルは.jsonファイルから導出される成果物です。両者が同期ずれを起こした場合は、アップデーターを実行すれば解消されます。versions.envを直接編集してはいけません。常にversions.jsonを変更してから再生成してください。
Docker ビルドにおけるコミットハッシュ検証
各タグと一緒に保存されるコミットハッシュは、参照用のメタデータではありません。第3回で見たように、すべての Dockerfile はクローンしたリポジトリの HEAD が期待するコミットと一致することを検証します。検証チェーン全体を追ってみましょう。
sequenceDiagram
participant GH as GitHub Tags API
participant UP as dependency_updater
participant VJ as versions.json
participant DF as Dockerfile (build time)
participant REPO as Upstream Repo
UP->>GH: ListTags(owner, repo)
GH-->>UP: tags with commit SHAs
UP->>UP: Find max valid version
UP->>VJ: Write tag + commit SHA
Note over DF: At build time...
DF->>REPO: git clone --branch TAG
DF->>DF: git rev-parse HEAD
DF->>DF: Compare HEAD == expected COMMIT
alt Match
DF->>DF: Proceed to build
else Mismatch
DF->>DF: BUILD FAILS
end
アップデーターが GitHub Tags API を叩くと、各タグオブジェクトにはそのタグが指すコミットの SHA が含まれています。アップデーターはこの SHA を versions.json に保存します。ビルド時には Dockerfile がそのタグでクローンを行い、HEAD と保存済み SHA を照合します。一致しなければビルドは即座に失敗します。
これは特定の脅威に対する防御です。上流のメンテナー(またはアカウントが侵害された場合)が、タグを別のコミットに向け直す可能性があります。SHA チェックがなければ、Dockerfile はそのタグが現在指しているコードを何も疑わずにビルドしてしまいます。SHA チェックがあれば、ビルドが直ちに失敗し、オペレーターに異常を知らせることができます。
GitHub Actions による日次自動化
更新パイプライン全体は update-dependencies.yml によって自動的に実行されます。
on:
schedule:
- cron: '0 13 * * *' # Daily at 1 PM UTC
workflow_dispatch:
ワークフロー自体はシンプルです。リポジトリをチェックアウトし、Go のアップデーターをビルドし、実行して、バージョンが変わっていれば PR を作成します。
flowchart TD
A["Daily cron at 1 PM UTC"] --> B["Checkout repository"]
B --> C["cd dependency_updater && go build"]
C --> D["Run updater with --github-action"]
D --> E{"Any versions updated?"}
E -->|Yes| F["Updater writes TITLE and DESC<br/>to GITHUB_OUTPUT"]
F --> G["peter-evans/create-pull-request<br/>creates PR with diff links"]
E -->|No| H["Workflow exits cleanly"]
GitHub Actions モード(--github-action true)で実行すると、アップデーターはコミットのタイトルと説明を GitHub のマルチライン出力構文を使って GITHUB_OUTPUT に書き出します(385〜407行目)。その後 peter-evans/create-pull-request アクションがその出力を使って PR を作成します。
- name: create pull request
if: ${{ steps.run_dependency_updater.outputs.TITLE != '' }}
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
title: ${{ steps.run_dependency_updater.outputs.TITLE }}
body: "${{ steps.run_dependency_updater.outputs.DESC }}"
branch: run-dependency-updater
delete-branch: true
PR のタイトルは chore: updated op-geth, op-node のような形式になり、本文には更新された依存関係ごとの差分リンクが含まれます。delete-branch: true によってマージ後にブランチが自動で削除されます。
if 条件にも注目してください。アップデーターの TITLE 出力が空の場合、つまりすべての依存関係がすでに最新の場合は、PR は作成されません。
サプライチェーンパイプラインの全体像
少し引いて見ると、サプライチェーンセキュリティパイプラインの全体像はこのようになっています。
- 毎日:GitHub Actions が依存関係アップデーターを起動する
- アップデーター:GitHub API を叩いて最新バージョンを特定 → アップグレードを検証 → コミット SHA を取得 → ファイルを更新 → PR を作成する
- PR レビュー:人間が差分リンクを確認して承認する
- マージ:更新された
versions.jsonとversions.envがmainに取り込まれる - CI ビルド:Docker ビルドが固定タグでクローンしてコミットハッシュを検証する
- 実行時:検証済みのバイナリを積んだコンテナが動く
このチェーンのすべてのリンクに検証ステップが組み込まれています。アップデーターはダウングレードでないことを確認し、Dockerfile はコミットハッシュの一致を検証し、CI パイプラインは自身のサプライチェーンセキュリティのためにアクションの SHA をピン固定(バージョンタグではなく)で使用します。そしてシステム全体が毎日実行されることで、Base ノードが上流の変更に確実に追従できる仕組みになっています。
次回予告
依存関係アップデーターは PR を作成しますが、その PR はマージ前に検証される必要があります。第5回では、3つの実行クライアントを2つの CPU アーキテクチャ向けに Docker イメージをビルドする CI/CD パイプラインを詳しく見ていきます。「ビルド → ダイジェスト出力 → マニフェストのマージ」という洗練されたパターン、asm-keccak のようなプラットフォーム固有の最適化の条件適用、そしてアクションの SHA ピン固定と harden-runner による多層防御の仕組みを解説します。