はじめに
go.mod と go.sum
最近、Goの脆弱性チェックで落ちていてモジュールのバージョンを更新しろとCIに怒られたのをキッカケに改めて整理しました。
1. バージョンを決めるのは go.mod と MVS
go.mod はモジュールのバージョンを管理するファイル。
中にある、require ブロックは、「どのモジュールの、どのバージョンを下限として要求するか」を宣言しています。
module gosumdemo
go 1.26.4
require golang.org/x/text v0.38.0 // indirect
// indirect というコメントが付いた行は、自分のコードが直接 import していない「間接依存(依存先のライブラリがさらに依存しているライブラリ)」を表します。
Goにロックファイルはない
Node.js(npm)などでは、package.json に「許容するバージョン範囲(例: ^1.2.0)」を書き、実際にインストールされた正確なバージョンを package-lock.json に書きます1。
対して、Goは go.mod でバージョンが確定させます。
バージョン指定には、MVS(Minimal Version Selection:最小バージョン選択)という仕組みが使われています2。
「依存しているモジュールの中で要求されたバージョンのうち、最大のものを選択する」 というだけのシンプルなアルゴリズムです。
モジュールA「C は v1.3 以上でよろ」
モジュールB「C は v1.4 以上で。」
MVS「じゃ、C は v1.4 ね」
みたいな感じで、仮に C モジュールの最新版が v1.6 だったとしても、
誰もそれを求めていなければ、Goは勝手に v1.6 を使いません。
要求を満たす範囲で、最小のバージョンが選ばれます。
バージョンを動かすのは「明示的なコマンド」のみ
Goでは、新しいバージョンが出たからといって勝手に上がることはなく、コマンドで明示的にバージョンを上げます。
| コマンド | 何ができるか |
|---|---|
go get example.com/mod@v1.2.3 |
指定したバージョンを明示的に取得・更新する |
go mod tidy |
コード内で使っていない依存の削除と、足りない依存の追加を行い、go.mod/go.sumを整理する |
2. go.sum は「同じバージョン名で違う中身」を検知する
go mod tidy で謎に更新され、また謎の文字列が含まれるgo.sum は改竄検知用のファイルです。
go.sum には、1モジュールにつき2行のハッシュが記録されます。
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
h1: は SHA-256 ベースのハッシュ形式です3。
Goはモジュールをダウンロードするたびに中身のハッシュを計算し、go.sum の記録と照合し、異なればビルドを強制ストップします。
おかげで、以下のような致命的な事態を防いでくれます。
- 改竄検知:モジュールの置き場である GitHub などのリポジトリや、配信を中継するプロキシサーバが乗っ取られ、悪意あるコードが含まれたみたいなケース。
- タグの付け直しミス:ライブラリ作者が「v1.0.0」として公開したあと、バグに気づいてこっそりコミットを修正し、同じ「v1.0.0」タグを付け直したみたいなケース4。
初回ダウンロード時のチェックサム・データベース
「自分が一番最初にダウンロードした時点で、すでに改ざんされていたら?」
調べたところ、Goは以下の仕組みで対応しているようです。
-
公開データベースとの照合: デフォルトでGoogleが運営している
sum.golang.orgと照合し、ハッシュの正当性を確認する5。 -
一度公開されたら取り消せない: ミラーサーバー(
proxy.golang.org)にキャッシュされるため、後から作者がリポジトリを消去・改変しても利用者のビルドは問題なく行える5。
「そんなに堅牢なら、手元の go.sum は要らなくない?」と思ったのですが、
どうやらsum.golang.org に問い合わせるのは初回ダウンロードの瞬間だけで、以降のビルドは手元の go.sum とのローカル照合で完結するみたいです(毎回 Google に聞きに行くわけではない)。
3. 再現
実際に go.sum の検知が働く瞬間を再現してみました(Go 1.26で確認)。
# 作業用ディレクトリを作ってモジュールを初期化する
mkdir /tmp/gosumdemo && cd /tmp/gosumdemo
go mod init gosumdemo
# 依存を1つ追加する(go.mod と go.sum が生成される)
go get golang.org/x/text@v0.38.0
動作確認用の最小コード(main.go)を作成します。
package main
import (
"fmt"
"golang.org/x/text/language"
)
func main() {
// BCP 47 の言語タグをパースして表示するだけの確認用コード
tag := language.MustParse("ja-JP")
fmt.Println(tag)
}
一度実行してみます。
go mod tidy # 依存関係を整理
go run . # => ja-JP と表示されれば成功
ここで攻撃者になったつもりで、go.sum を直接書き換えてみます。
エディタで go.sum を開き、1行目のハッシュ h1:sXmwo9... の先頭の s を X に変えて保存。
その状態で実行(ビルド)すると、以下のように怒られました。
SECURITY ERROR でビルドが停止しました。
壊してしまった go.sum は消して作り直せばすぐに復旧します(再生成されるハッシュも sum.golang.org(公開チェックサムデータベース)との照合で検証されるため)。
rm go.sum
go mod tidy # go.sum を再生成する
go build . # 今度は通る
まとめ
-
バージョンを決めるのは
go.modと MVS。 要求の中から「最大のものを一つ選ぶ」という deterministic(決定的)なアルゴリズム。 go.sumは改ざん検知用。-
公開済みタグは変更してはならない。 モジュール利用者の
go.sumとハッシュが食い違い、全員のビルドが失敗する。
Gitのコンフリクト解消時などに、go.sum のコンフリクトを真面目に手作業で直さず「一度消して go mod tidy で作り直す」という運用ができるのも、こういう事情があるからなのですね。
参考文献
-
渋川よしき、辻大志郎、真野隼記、後藤玲雄『実用 Go言語 第2版』オライリー・ジャパン、7.2節 ↩
