📝

go.modとgo.sumの読み方

2021/09/29に公開

はじめに

先日、利用しているモジュール(外部ライブラリー)に脆弱性があることが発覚しました。その対処を行った際にgo moduleや go.mod, go.sumの理解がやや曖昧で記述内容や挙動に振り回されたことがあったので色々調べた知見を忘れないようメモしておこうと思います。

この記事に書かないこと

下記のような事柄は既存の記事が沢山あるので省略します。

  • Goのライブラリー管理の歴史(Vendoringとかその辺)
  • go moduleの基本的な使い方
  • go moduleでのライブラリー管理のあり方

前提となる環境

  • Go1.17

go.mod

主にモジュールのインポートパスとバージョン情報を書いておくためのファイルで、いくつかのディレクティブを使ってアプリケーションがどのような依存関係を持っているか記述しておきます。

go mod tidy等を実行するとこのファイルを元に依存先を取得し次項で解説するgo.sumを生成します。

サンプル

module github.com/ryo-yamaoka/sample-lib

go 1.17

require github.com/ryo-yamaoka/direct-dependent-lib v0.0.2

require github.com/ryo-yamaoka/indirect-dependent-lib v0.0.4 // indirect

exclude github.com/ryo-yamaoka/direct-dependent-lib v0.0.1

replace github.com/xxx/abandoned => github.com/ryo-yamaoka/forked

retract (
    // include vulnerability CVE-xxxx
    v0.0.1
    // has fatal bug xxx
    v0.0.2
)

上記サンプルからは以下のことが読み取れます。

  • このモジュール名は github.com/ryo-yamaoka/sample-lib
  • Go1.17で書かれている
  • github.com/ryo-yamaoka/direct-dependent-libv0.0.2 に依存している
  • github.com/ryo-yamaoka/indirect-dependent-libv0.0.4 に間接的に依存している
    • 直接依存が1つしかないので、そのモジュールの依存先であると思われる
  • github.com/ryo-yamaoka/direct-dependent-libv0.0.1 の利用を避けている
  • github.com/xxx/abandoned というモジュールを github.com/ryo-yamaoka/forked に置き換えている
  • このモジュールの v0.0.1v0.0.2 の公開を撤回している(利用自体は可能)
    • v0.0.1 には脆弱性が含まれている
    • v0.0.2 は致命的なバグがある

directives

module

自分自身のモジュール名を定義するためのディレクティブで、通常 go mod init した際に自動で設定され特に弄る機会は無いでしょう。

ちなみに以下のようにコメントで先頭に Deprecated: と記述するとDeprecatedとなりGo1.17以降で go get した場合に警告を出すことができます。

// Deprecated: use github.com/ryo-yamaoka/gomod-test-3/v2
module github.com/ryo-yamaoka/gomod-test-3
$ go get -u github.com/ryo-yamaoka/gomod-test-3@v0.0.3
go: module github.com/ryo-yamaoka/gomod-test-3 is deprecated: use github.com/ryo-yamaoka/gomod-test-3/v2 instead.

go

モジュールの記述を想定しているGoのバージョンで、ここに指定したバージョン以降に導入された言語機能を使うとビルドエラーになります。例えばGo1.13で導入された 1_000_000 の記法を使っている状態で go 1.12 へと変更すると以下のようになります。

module github.com/ryo-yamaoka/gomod-test-1

go 1.12
package main

import "fmt"

func main() {
	fmt.Println(1_000_000)
}
$ go run main.go
# command-line-arguments
./main.go:6:14: underscores in numeric literals requires go1.13 or later (-lang was set to go1.12; check go.mod)

またgoコマンドの一部はこのディレクティブを元に挙動が変わる[1]のでバージョンを変更した際に予期しないエラーが出た場合はチェックしてみるとよいでしょう。

require

go.modファイルの中心的な表記となるディレクティブで、依存先モジュールのインポートパスやバージョンを指定します。通常は go get で追記・バージョン変更等を行うことが多いかと思いますが、人間にも読みやすい記述なのでちょっとバージョンを変えるという程度であれば直接編集した方が楽かもしれません。

ここで指定するバージョンはgitのtagでありcommitハッシュを一意に特定できるためgo.modファイルのみで依存関係を定義できます(後からタグを付け替える等の野蛮な行為が無い限り……)。言い換えると所謂ロックファイルの類はGo modulesには存在しません。また依存先のバージョンアップによってビルドが破壊されないよう指定されたバージョンを下限として最も古いものを使用します[2]

Go1.17からはrequireディレクティブが2つ書かれることがありますが、これは間接的依存先(=依存先の依存先)が自動的に追記されるようになったためで上段が直接の依存先、下段が間接的な依存先です。また間接的な依存先には // indirect が付きます。

時折バージョンに +incompatible とついているものがありますが、これはメジャーバージョンとモジュール名のサフィックスが一致しない場合に表示されます。具体的な例としては v2.0.0 というタグがついているのにモジュール末尾が github.com/xxx/yyy となっているケース等です。Go moduleのバージョン管理ルールではバージョンが v2.x.y であれば github.com/xxx/yyy/v2 となっていなければなりません。

exclude

このディレクティブにパスとバージョンを書くと特定のバージョンを除外し使わないようにできます。これによりバグや脆弱性のあるバージョンを意図せず使ってしまうことを防止できますが、制御の対象となるのは直接依存しているモジュールのみで間接依存先が使うものは制御できません。

以下はv0.0.1をexcludeしv0.0.2をrequireに指定している状態でv0.0.1にダウングレードを試みた場合の例です。excludeしたバージョンを使わなくなっています。requireのバージョンをv0.0.1と書き換えて go mod tidy した場合も同様の挙動になります。

module github.com/ryo-yamaoka/gomod-test-2

go 1.17

exclude github.com/ryo-yamaoka/gomod-test-3 v0.0.1

require github.com/ryo-yamaoka/gomod-test-3 v0.0.2
$ go get -u github.com/ryo-yamaoka/gomod-test-3@v0.0.1
go get: github.com/ryo-yamaoka/gomod-test-3@v0.0.1: excluded by go.mod

replace

モジュールのインポートパスを別の場所に置き換えるためのディレクティブで以下のような書き方をします。

github.com/ryo-yamaoka/repo => github.com/another/repo

メンテされなくなったモジュールを既存コードを変更せずFork先に切り替える、等といった使い方ができます。またモジュールそのものだけでなくバージョンを指定することもでき、その場合以下のような挙動となります。

  • 左側にバージョンがあるとそのバージョンのモジュールだけが置き換えられる
  • 左側のバージョンを省略するとモジュールの全てのバージョンが置き換えられる

またこのディレクティブも直接依存のモジュールのみに適用されます。

retract

Go1.16から導入された一度公開したモジュールを撤回するためのディレクティブで、間違えて公開したり脆弱性が発覚したりして利用を差止めたいといった場合に使用します。Gitのタグを削除したり付け替えたりすることは破壊的変更が発生し既にimportしてしまったビルドが壊れることになるので避けるべきです。

以下の例ではv0.0.2をretractしています。

retract (
    v0.0.2 // Contains vulnerability CVE-xxxx
)

この状態でv0.0.3以降を公開するとv0.0.2はretractedとなり go list -m -version の結果や go get の対象になりません。また既にimport済みの場合は引き続きビルドすることができgo mod tidy 等で自動的にアップグレードされることもありません。利用を差止めるという面でmoduleディレクティブのdeprecatedと似ていますが、deprecatedはモジュールそのものが対象であるのに対し、retractはあくまで特定のバージョンのみが対象なことです。

go.sum

直接・間接を問わず依存先モジュールのハッシュを記録するためのファイルです。go.modと共にリポジトリにpushされビルド再現性のために利用されますが、モジュールの取得はgo.modのrequireディレクティブにある情報で完結できるためgo.sum自体は無くとも原則としてビルド再現性は得られます。

ではなんのためにgo.sumがあるのかというと、go.modを元に取得したモジュールが本当にgo.sum生成時のものと一致しているかのチェックのためです。バージョンはgitのタグを元に管理されますがその気になれば付け替えること自体は可能です。やもすると悪意の第三者がリポジトリやGitHubアカウントを乗っ取って同じバージョンタグでマルウェアを仕込んだりするかもしれません。こういったケースでは取得してきたモジュールのハッシュとgo.sumに記録されたハッシュが食い違うためエラーが発生し検知することが可能になります。

サンプル

module github.com/ryo-yamaoka/sample-lib

go 1.17

require github.com/ryo-yamaoka/direct-dependent-lib v0.0.2

require github.com/ryo-yamaoka/indirect-dependent-lib v0.0.4 // indirect
github.com/ryo-yamaoka/direct-dependent-lib v0.0.2 h1:HX+Ss19qS8yqPO6JH9AOLdd1RdoyT8CPIKM5SBxtLDQ=
github.com/ryo-yamaoka/direct-dependent-lib v0.0.2/go.mod h1:6WQcqXRvvJ9N9wxo8l/l2C2cBMiJcC9fMUkUn7euNzg=
github.com/ryo-yamaoka/indirect-dependent-lib v0.0.4 h1:HX+Ss19qS8yqPO6JH9AOLdd1RdoyT8CPIKM5SBxtLDQ=
github.com/ryo-yamaoka/indirect-dependent-lib v0.0.4/go.mod h1:6WQcqXRvvJ9N9wxo8l/l2C2cBMiJcC9fMUkUn7euNzg=

実際に見て貰うとわかるようにgo.sumから得られる情報で人間にとって有用なものはあまりないでしょう。特に1.17以降では間接依存先がgo.modに書かれるようになったため益々見る理由は減りましたが、私個人の経験の範囲では脆弱性対応等で特定のモジュールバージョンが除外できているかをチェックする際に役立ったことがありました。go.modを編集後 go mod tidy をかけてgo.sumから消えていれば間違いなく利用されなくなっています。

ちなみにモジュールの利用を止めた場合でも明示的に削除するか go mod tidy で未使用モジュールの削除処理を走らせない限り残り続けますが、これは再利用した際にもハッシュが変わっていないかチェックするという意図があります。

おわりに

go moduleの仕組みは複雑なところやMVS等独特なルールがありますが、脆弱性対応等でgoのモジュール管理に踏み込む場合に備えてある程度は理解しておくとイザというときに役立つかと思います。

脚注
  1. go directive ↩︎

  2. Minimal version selection (MVS) ↩︎

Discussion