本記事は、QualiArts Advent Calendar 2025 の3日目の記事です。
バックエンドエンジニアのfoltです。
この記事では、Go公式の脆弱性スキャナであるgovulncheckを題材にしながら、実際にCIに組み込む際の基本的な使い方や出力の見方について紹介します。
動機
近年、サプライチェーン攻撃が増えており、依存関係の脆弱性を早期に検知して対応することが重要になってきています。
Goの脆弱性スキャナを探してみるとgovulncheckというツールが公式で提供されていることがわかります。
この記事ではgovulncheckをプロジェクトに組み込むための基本的な使い方を紹介します。
想定する読者
- Goの脆弱性スキャナについて知りたい方
- govulncheckの名前を聞いたことがあるけれど、まだ触ったことがない方
- すでに何らかの脆弱性スキャンを使っているが「ノイズが多くてつらい」と感じている方
- CIにセキュリティチェックを組み込みたいが、どこから手を付けるか悩んでいる方
この記事では、これらの方に向けて基本的な使い方を紹介します。
govulncheckがしてくれること
govulncheckは、Go公式の脆弱性スキャナツールです。ツールの特徴は以下の通りです。
- vuln.go.dev(Goの脆弱性DB)を参照してスキャンを行う
- プロジェクトの標準ライブラリやモジュールに既知の脆弱性があるかを検知する
- コードがその脆弱な関数に到達しうるか(到達可能性)を検知する
単に依存関係に脆弱性があるかどうかを見るだけでなく、
- 「依存関係に脆弱性はあるけど、あなたのコードからはそこに到達していない」
- 「あなたのコードから脆弱な関数に到達している」
を区別して検知してくれるのがポイントです。
プロジェクトのサイズが大きくなるほど、依存関係が複雑になり、脆弱性が検知される数が多くなります。
到達性を考慮しない脆弱性スキャナでは、実際には到達せず実害のない脆弱性もすべて検知してしまうため、件数が膨大になりがちです。
これらの通知が多すぎて疲れてしまった経験がある方ほど、govulncheckの「到達可能性でノイズを減らす」というアプローチとの相性はいいと思います。
インストール
govulncheckはGo公式のツールであり、go install でインストールできます。
ここでは例としてGo 1.25.1環境でインストールしてみます。
ツール自体に対するサプライチェーン攻撃を防ぐためにCIに組み込む際にはlatest指定ではなく、具体的なバージョンを指定することを推奨します。
go install golang.org/x/vuln/cmd/govulncheck@latest
正しくインストールされているか確認するために、バージョンを表示してみます。
PATHが通っている場所にインストールされていれば以下のようなコマンドでバージョンを表示できます。
govulncheck -version
実行してみると以下のような出力になります。
Go: go1.25.1
Scanner: govulncheck@v1.1.4
DB: https://vuln.go.dev
DB updated: 2025-10-23 16:25:09 +0000 UTC
No vulnerabilities found.
ここには次のような情報が表示されてます。
- どのGoバージョンで動いているか
- govulncheckのバージョン
- 参照している脆弱性DB(vuln.go.dev)のURLと更新日時
特殊な環境でない限りは直近の最新の脆弱性DBを参照しているはずです。
CIの出力結果を見る際にはこれらの情報をざっと確認しておくと「どの時点のデータに基づいたスキャン結果か」が把握しやすくなります。
まずはシンプルなプロジェクトをスキャンしてみる
govulncheckの使い方を理解するために、まずはほとんど空のプロジェクトを1つ用意してスキャンしてみます。
package main
func main() {
}
module example
go 1.25.1
ついでにGoのバージョンを管理するための.go-versionファイルも用意しておきます。
インストールされるgovulncheckの動作上は最新のバージョンで問題ありませんが、インストールの際にGoのバージョンと一致していないとエラーになるので揃えておきます。
1.25.1
プロジェクトルートで、次のように実行します。
govulncheck ./...
./...を指定することでプロジェクト全体をスキャンします。
コード自体は空のため脆弱性は検知されず、以下のような出力になるはずです。
No vulnerabilities found.
「何も見つからない」パターンでこのような出力になることを覚えておくと、あとで結果の違いが分かりやすくなります。
Goのバージョンを下げて差分を見てみる
より理解を深めるために、コード側で参照しているGoのバージョンをわざと古くしてみます。
さきほどの go.modのGoのバージョンを1.24.0に変えて、もう一度スキャンしてみます。
module example
go 1.24.0
go.modのGoのバージョン指定はあくまでもコードが参照するGoのバージョンを指定するものです。
govulncheckの実行時にはGOTOOLCHAIN環境変数を使って実行を想定するGoのバージョンを指定します。
GOTOOLCHAIN=go1.24.0 govulncheck ./...
コード自体は空のため、ここでは意図的に「脆弱性が含まれているが、あなたのコードからは到達しない」状態を作っています。
その結果として、次のような出力になります。
=== Symbol Results ===
No vulnerabilities found.
Your code is affected by 0 vulnerabilities.
This scan also found 0 vulnerabilities in packages you import and 6
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.
出力を読み解いてみます。
以下がポイントです。
-
「No vulnerabilities found.」
- 脆弱性は検知されなかった
-
「Your code is affected by 0 vulnerabilities.」
- あなたのコードが実際に影響を受ける脆弱性は0件
-
「This scan also found 0 vulnerabilities in packages you import and 6 vulnerabilities in modules you require, but your code doesn't appear to call these vulnerabilities.」
- あなたのコードのパッケージに脆弱性のimportが0件ある
- あなたのコードの必要なモジュール内に脆弱性が6件ある
- あなたのコードからはそれらの脆弱性に到達していない
-
「Use '-show verbose' for more details.」
- 詳細な情報を表示するには
-show verboseを付ける必要がある
- 詳細な情報を表示するには
要するにあなたのコードからは実害のある脆弱性は検知されなかったということです。
脆弱性のスキャンをしたことがある方ならおわかりかと思いますが、この出力を見るだけでも単純な依存関係に基づくスキャナの出力と比べて判断コストがかなり減っていることがわかります。
-show=verbose で詳しく見てみる
さらにくわしく見たい場合は -show=verbose を付けて実行します。
GOTOOLCHAIN=go1.24.0 govulncheck -show=verbose ./...
出力は以下のようになります。
Fetching vulnerabilities from the database...
Checking the code against the vulnerabilities...
The package pattern matched the following root package:
example
Govulncheck scanned the following 1 modules and the go1.24.0 standard library:
example
=== Symbol Results ===
No vulnerabilities found.
=== Package Results ===
No other vulnerabilities found.
=== Module Results ===
Vulnerability #1: GO-2025-3956
Unexpected paths returned from LookPath in os/exec
More info: https://pkg.go.dev/vuln/GO-2025-3956
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.6
Vulnerability #2: GO-2025-3849
Incorrect results returned from Rows.Scan in database/sql
More info: https://pkg.go.dev/vuln/GO-2025-3849
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.6
Vulnerability #3: GO-2025-3751
Sensitive headers not cleared on cross-origin redirect in net/http
More info: https://pkg.go.dev/vuln/GO-2025-3751
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.4
Vulnerability #4: GO-2025-3750
Inconsistent handling of O_CREATE|O_EXCL on Unix and Windows in os in
syscall
More info: https://pkg.go.dev/vuln/GO-2025-3750
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.4
Platforms: windows
Vulnerability #5: GO-2025-3749
Usage of ExtKeyUsageAny disables policy validation in crypto/x509
More info: https://pkg.go.dev/vuln/GO-2025-3749
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.4
Vulnerability #6: GO-2025-3563
Request smuggling due to acceptance of invalid chunked data in net/http
More info: https://pkg.go.dev/vuln/GO-2025-3563
Standard library
Found in: stdlib@go1.24
Fixed in: stdlib@go1.24.2
Your code is affected by 0 vulnerabilities.
This scan also found 0 vulnerabilities in packages you import and 6
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
詳細出力では以下のような情報が表示されます。
- どのモジュールをスキャンしたか
- 見つかった脆弱性と概要
- それぞれの脆弱性の影響バージョン、修正バージョン
この情報を見ると、この脆弱性があなたのコードにどのような影響を与えるのかを判断する材料が揃ってきます。
- この脆弱性がどのような脆弱性か
- この脆弱性に対策するにはどのバージョンまで上げればよいか
実際に脆弱性を含むコードを書いて検知させてみる
ここまでの例は基本的に脆弱性があなたのコードから到達しない状態でした。
しかし、このツールを実際に使っていくために脆弱性が検知される場合の挙動を確認しておきます。
実際に「脆弱性のあるコード」を書いて、それをgovulncheckに検知させてみます。
またこの例を通して「到達可能性」という概念を説明します。
ここでは、CVE-2025-47906(GO-2025-3956)に関するProof of Concept(PoC)コードを使わせてもらいます。
PoCは脆弱性の再現を目的としたコードです。
Goを1.24.5に固定した状態で次のようなコードを書きます。
package main
import (
"log"
"os"
"os/exec"
)
func main() {
// CVE-2025-47906(GO-2025-3956)の脆弱性を含むコード(PoCより抜粋)
// PATH にバイナリが混入しているケースを模した設定
os.Setenv("PATH", "/bin/date")
// 本来はエラーを返してほしいパターンだが、脆弱性により /bin/date が実行されうる
p, err := exec.LookPath("")
if err != nil {
log.Fatal(err)
}
cmd := exec.Command(p)
cmd.Stdout = os.Stdout
cmd.Run()
}
この状態で、Goのツールチェーンのバージョンを1.24.5とした状態でgovulncheckを実行します。
GOTOOLCHAIN=go1.24.5 govulncheck ./...
すると、次のような出力になります(重要なところだけ抜粋)。
=== Symbol Results ===
Vulnerability #1: GO-2025-3956
Unexpected paths returned from LookPath in os/exec
More info: https://pkg.go.dev/vuln/GO-2025-3956
Standard library
Found in: os/exec@go1.24.5
Fixed in: os/exec@go1.24.6
Example traces found:
#1: main.go:16:25: example.main calls exec.LookPath
Your code is affected by 1 vulnerability from the Go standard library.
ここで注目したいのは「Your code is affected by 1 vulnerability from the Go standard library.」と「Example traces found:」の部分です。
前者はあなたのコードが1つの脆弱性に影響を受けていることを示しています。
後者はあなたのコードからどのような経路でその脆弱性に到達しているかを示しています。
main.goの16行25列目からexec.LookPathに到達しているようです。
ここで注意点が1点あります。govulncheckはシンボルの出現に基づいて脆弱性を検知しています。
あなたのコードからそのシンボルが呼び出されていれば脆弱性が検知されます。
つまり、たとえ実行時に脆弱性の対象でない引数で実行されているとしても、そのシンボルが呼び出されていれば検知対象となってしまいます。
(裏を返せば、引数や条件分岐に左右されずに検知できるため、見逃しが少ないという点は強みとも言えます)
CIに組み込む
ここまではgovulncheckの使い方を紹介しました。
ここからは実際にCIに組み込む際のイメージを紹介します。
出力形式:テキスト / SARIF / JSON
govulncheckでは、いくつかの出力形式から用途に応じたものを選べます。
これらの出力形式を使い分けることで、CIの出力をより読みやすくしたり、その後の処理をより効率的に行えるようになります。
-
デフォルト(テキスト)
- 人間が読み取ったり、簡易な解析を行う場合に便利です。
-
SARIF(
-format=sarif)- 静的解析の標準フォーマット。
-
JSON(
-format=json)- 行ごとに独立したJSONオブジェクト(NDJSON)が出力されるストリーミング形式です。
- ストリーミング処理に適していますが、多くの場合はテキストまたはSARIF形式で十分です。
これまでの例はデフォルトのテキスト形式でしたが、SARIF形式の出力も見てみます。
SARIF形式の出力例
SARIFは静的解析結果を統一フォーマットで扱うためのJSONベースの仕様です。
ツールで結果を統合したり、重複を抑制したり、ダッシュボードで可視化することが可能な共通フォーマットです。
GitHub Code Scanningなどにアップロードすることで一覧表示できます。
コマンドは以下のようになります。
govulncheck -format=sarif ./... > result.sarif
SARIFの中身はかなり長いので、ごく一部だけ抜粋してみます。
{
"version": "2.1.0",
"runs": [
{
"tool": {
"driver": {
"name": "govulncheck",
"rules": [
{
"id": "GO-2025-3956",
"shortDescription": {
"text": "[GO-2025-3956] Unexpected paths returned from LookPath in os/exec"
},
"helpUri": "https://pkg.go.dev/vuln/GO-2025-3956"
}
]
}
},
"results": [
{
"ruleId": "GO-2025-3956",
"level": "error",
"message": {
"text": "Your code calls vulnerable functions in 1 package (os/exec)."
},
"locations": [
{
"physicalLocation": {
"artifactLocation": {
"uri": "main.go",
"uriBaseId": "%SRCROOT%"
},
"region": {
"startLine": 16,
"startColumn": 25
}
}
}
]
}
]
}
]
}
出力の構造は以下のようになっています。
-
rulesに脆弱性(GO-YYYY-NNNN)単位の定義 -
resultsに「どこで検知されたか」「レベル(note / warning / error)」
このような情報が含まれているためツールで結果を統合したり、重複を抑制したり、ダッシュボードで可視化したりできます。
CI内では既知の脆弱性以外の脆弱性を検知した場合にCIを落とすようにしたり、その後の処理の材料として使うことができます。
CIに組み込んでみる
CIに組み込む方法はプロジェクトによってかなり差が出るところなので、ここではごくシンプルな設定例を紹介します。
GitHub Actionsを例にして、govulncheckを実行して脆弱性が検知された場合にCIを落とすようにしてみます。
name: security-scan
on:
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: '.go-version' # or 固定バージョンでもOK
- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest
- name: Run govulncheck (fail on findings)
run: $(go env GOPATH)/bin/govulncheck ./...
この例では以下の2つのことを行っています。
- プルリクエストではgovulncheckを実行して脆弱性を検知させる
- 脆弱性が検知されたらCIを落とす
このようにすることで「新しい変更で脆弱なシンボルを呼び出していないか」を早めに気付けるようになります。
最後に
この記事ではgovulncheckの基本的な使い方に焦点を絞って紹介しました。
依存関係やコンテナまで含めたすべてを対象にしたスキャンをいきなり取り入れると通知過多になりチームの疲弊を招きます。
まずはgovulncheckを使って「脆弱性に到達しうるシンボル」のスキャンからはじめ、チーム内でトリアージを行い、徐々に他のツール(Dependabot / Trivy・OSV Scannerなど)を組み合わせていくという進め方が現実的だと思います。
私が参加しているSGE Go コミュニティの新刊では今回の題材を深掘りしたものを執筆しました。
よろしければ手に取っていただけると幸いです。