Read OSS

サプライチェーンセキュリティの自動化:依存関係アップデーターとバージョン固定システム

上級

前提知識

  • 第1回:アーキテクチャ(versions.json の背景理解のため)
  • セマンティックバージョニング(semver)の基礎知識
  • GitHub API の基本的な使い方

サプライチェーンセキュリティの自動化:依存関係アップデーターとバージョン固定システム

第1〜3回では、versions.json を所与のものとして扱ってきました。Dockerfile が上流依存関係のクローンと検証に使う「信頼できる唯一の情報源」として。しかし、そのバージョン情報を常に最新に保つのは誰の(あるいは何の)仕事でしょうか?

手作業の場合、4つの上流リポジトリを新リリースのたびに監視する必要があります。異なるタグ形式を解析し、更新がダウングレードでないかを確認し、versions.jsonversions.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.11op-node
owner / repo GitHub の組織名とリポジトリ名
tracking バージョン選択の戦略:releasetag、または 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.11v1.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.11websocket-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_gethOP_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.jsonversions.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 は作成されません。

サプライチェーンパイプラインの全体像

少し引いて見ると、サプライチェーンセキュリティパイプラインの全体像はこのようになっています。

  1. 毎日:GitHub Actions が依存関係アップデーターを起動する
  2. アップデーター:GitHub API を叩いて最新バージョンを特定 → アップグレードを検証 → コミット SHA を取得 → ファイルを更新 → PR を作成する
  3. PR レビュー:人間が差分リンクを確認して承認する
  4. マージ:更新された versions.jsonversions.envmain に取り込まれる
  5. CI ビルド:Docker ビルドが固定タグでクローンしてコミットハッシュを検証する
  6. 実行時:検証済みのバイナリを積んだコンテナが動く

このチェーンのすべてのリンクに検証ステップが組み込まれています。アップデーターはダウングレードでないことを確認し、Dockerfile はコミットハッシュの一致を検証し、CI パイプラインは自身のサプライチェーンセキュリティのためにアクションの SHA をピン固定(バージョンタグではなく)で使用します。そしてシステム全体が毎日実行されることで、Base ノードが上流の変更に確実に追従できる仕組みになっています。

次回予告

依存関係アップデーターは PR を作成しますが、その PR はマージ前に検証される必要があります。第5回では、3つの実行クライアントを2つの CPU アーキテクチャ向けに Docker イメージをビルドする CI/CD パイプラインを詳しく見ていきます。「ビルド → ダイジェスト出力 → マニフェストのマージ」という洗練されたパターン、asm-keccak のようなプラットフォーム固有の最適化の条件適用、そしてアクションの SHA ピン固定と harden-runner による多層防御の仕組みを解説します。