継続的脆弱性検査は層で回す。govulncheckから始めてZAPは最後
OWASP ZAPから始めると続かない。依存・更新・イメージ・静的解析・DASTの5層を頻度を変えて回す設計と、各層の落とし穴。govulncheckの到達性解析がなぜ別格なのか、Dependabotが検知器ではなく更新装置である理由、そしてどの層でも見つからない認可の穴について。
「脆弱性診断をやりたい」という話は、たいてい OWASP ZAP から始まる。そして続かない。
ZAP は一番外側で、一番遅くて、一番ノイズが多い層だ。ここから入ると、 最初の1週間で数百件の alert を前に手が止まる。次の週には誰も見なくなる。
継続的脆弱性検査は「ZAP を回すこと」ではない。速度もノイズ量も守備範囲も違う 5つの層を、違う頻度で回すことだ。そして費用対効果は依存の層が圧倒的に高い。
層で考える
| 層 | 道具 | 何を見るか | 頻度 | 所要 | ノイズ |
|---|---|---|---|---|---|
| 依存の既知脆弱性 | govulncheck | Go の依存の CVE(到達性つき) | PR ごと | 10〜30秒 | ほぼゼロ |
| 依存の更新 | Dependabot / Renovate | 更新可能なバージョン | 週次 | — | 中 |
| イメージ・設定 | Trivy | OS パッケージ、IaC、シークレット | ビルド時 + 日次 | 10〜60秒 | 中 |
| コードのパターン | gosec / CodeQL | 危険な書き方 | PR / 週次 | 数十秒〜数分 | 高 |
| 動いているアプリ | OWASP ZAP | 実際に撃って返る挙動 | 夜間 / 週次 | 10分〜数時間 | 高 |
この表には順序がある。
- 上の層ほど速く、正確で、直し方が明確(バージョンを上げる)
- 下の層ほど遅く、曖昧で、直し方が設計に及ぶ
だから上から順に入れる。各段階が独立して価値を出すので、途中で止めても損はしない。
govulncheck — Go だけが持っている別格の道具
普通の依存スキャナは「依存ツリーに CVE のあるバージョンが含まれているか」を見る。
govulncheck はその脆弱な関数を実際に呼んでいるかまで解析する。
$ govulncheck ./...
Vulnerability #1: GO-2024-XXXX
...
Your code is affected by this vulnerability.
Vulnerability #2: GO-2024-YYYY
...
Your code is unaffected by this vulnerability.
affected と unaffected が別物として出る。これが何を意味するか——
alert がほぼゼロ件で安定する。
そしてこの性質が決定的だ。ゼロで安定する検査だけが PR ゲートにできる。 常時5件出ている検査は3日で誰も見なくなり、そのジョブは「成功でも失敗でも意味のない飾り」になる。
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...
落とし穴が3つある。
Go のバージョン自体の脆弱性も報告する。 依存を全部上げても消えないことがある。
このとき必要なのは toolchain を上げることで、go.mod をいじっても解決しない。
逆に言えば「ランタイムの CVE を検知する経路」が最初から付いてくる。
マルチモジュール構成では ./... がモジュールを跨がない。 go.work で
ワークスペースを組んでいると、ルートで回しても他モジュールは検査されない。
黙って成功するので気づきにくい。
for dir in $(go list -m -f '{{.Dir}}'); do
(cd "$dir" && govulncheck ./...) || exit 1
done
-mode=binary でビルド済みバイナリも検査できる。 ソースが手元にない成果物や、
コンテナに入っている実物を検査したいときに効く。
Go 以外の言語が混ざるなら osv-scanner を並べる。lockfile を言語横断で読めるので
入り口を1つにできる。ただし到達性解析はないので、govulncheck の代わりにはならない。
補完として置く。
Dependabot / Renovate — 検知器ではなく更新装置
よくある誤解がある。「Dependabot を入れたから依存の脆弱性は見ている」。
Dependabot には性質の違う機能が2つある。
- alerts — 依存グラフから脆弱性を通知する(検知)
- version updates — 更新 PR を定期的に作る(更新)
前者は到達性を見ないので、使っていないコードパスの CVE でも鳴る。 govulncheck と併用すると「alert は鳴っているが govulncheck は unaffected と言う」が 普通に起きる。このとき優先するのは govulncheck の判定だ。呼んでいない関数の CVE を 直すために依存を上げるのは、リスクの配分を間違えている。alert 側は理由を書いて Dismiss する。
本当の価値は後者にある。
脆弱性は「見つける」より「常に新しい状態を保つ」方が効く。
半年更新していないリポジトリで CVE が出ると、その1個を直すために10個のバージョンを 跨ぐことになり、API 変更とビルドエラーの藪に入る。緊急で直さなければならないときに、 最も時間がかかる形で直すことになる。
毎週小さく上げていれば、CVE が公表された時点で既に直っていることすらある。 更新を止めることが、最も確実に脆弱性を溜める方法だ。
設定の肝は、PR の量を人間が処理できる量に抑えることに尽きる。
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
groups:
go-minor:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
grouped updates は必須だ。 無いと週に20本 PR が来て、全部無視されるようになる。 無視される更新装置は無い方がまし——「やっている」という錯覚を生む分だけ有害になる。
github-actions エコシステムを忘れない。 サードパーティの Action は
CI に任意コードを流し込む経路で、しかも普通は誰も更新しない。
actions/checkout@v3 が何年も放置されているリポジトリは多い。
アプリの依存だけ丁寧に見て、CI の依存を放置している状態は珍しくない。
自動マージの線引きは、patch はテストが通れば自動マージ、minor 以上は人間。 ただしこれには前提がある。テストが本当に落ちることだ。テストが薄い状態で 自動マージを有効にするのは、検査ではなく自動デプロイの穴を開けているだけになる。
Renovate との使い分けは、言語が混ざる monorepo なら Renovate、
GitHub だけで完結して設定を増やしたくないなら Dependabot。
Renovate は安定期間(minimumReleaseAge)を書けるので、
公開直後のバージョンを掴まない運用ができる。サプライチェーン対策としてはこれが効く。
Trivy — イメージと設定を1本で
Trivy が見るものは1つではない。
| 対象 | コマンド | 見つかるもの |
|---|---|---|
| コンテナイメージ | trivy image | ベースイメージの OS パッケージ CVE |
| ファイルシステム | trivy fs . | lockfile の依存 CVE |
| 設定 / IaC | trivy config . | Terraform・Dockerfile・k8s の危険な設定 |
| シークレット | 上記に同梱 | ハードコードされた鍵(既定で有効) |
Go の場合、イメージの中身をほぼ空にできるのが効く。FROM scratch や distroless なら
OS パッケージがそもそも存在しないので、trivy image の結果は静かなままだ。
Alpine や Debian slim を使っていると、アプリと無関係な CVE が常時数十件出る。
ベースイメージの選択が、検査のノイズ量を決める。
静的リンクできる Go は、この点で他の言語より圧倒的に有利な立場にいる。 そしてこの有利さは Dockerfile を書く段で一度決めたら、あとは効き続ける。 検査を入れる前にベースイメージを見直す方が、baseline を育てるより安い。
回す場所は3つある。
- ビルド時(PR / main) —
--exit-code 1 --severity HIGH,CRITICALでゲート - レジストリ側の継続スキャン(ECR の scan on push など)
- 定期再スキャン(日次)
3 を忘れやすい。理由は単純で、同じイメージが後から脆弱になるからだ。 ビルド時にクリーンだったイメージは、その翌日に公表された CVE で脆弱になる。 イメージは1バイトも変わっていないのに状態が変わる。
つまりビルド時のスキャンだけでは、いま本番で動いているイメージの現状が分からない。 日次で「本番にあるタグ」を再スキャンする。これは他のどの層にもない、Trivy 特有の運用要件だ。
- uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/org/app:${{ github.sha }}
exit-code: '1'
severity: 'HIGH,CRITICAL'
ignore-unfixed: true
ignore-unfixed: true は事実上必須。 修正版が出ていない CVE で CI を落としても、
できることが何もない。「落ちているが誰も直せない」ゲートは、
次の週には continue-on-error: true を付けられて死ぬ。
gosec / CodeQL — 入れ方で成否が決まる
静的解析は前の2層と性質が違う。誤検知が出る。
gosec の典型:
G104エラー未処理 —defer f.Close()で大量に出るG304変数によるファイルパス — 正当な実装でも出るG115整数変換のオーバーフロー — 既存コードに数百件出ることがある
鉄則は1つ。最初から FAIL にしない。
- まず件数を見る(
continue-on-errorか手元で) - 明らかに正しい指摘だけ直す
- 残りをルール単位で除外する(理由を同じ行に書く)
- そこで初めてゲートにする
golangci-lint に gosec を linter として組み込めるので、
既に golangci-lint があるなら CI ジョブを増やす必要はない。
CodeQL / semgrep はもう一段深い(データフローを追う)ぶん遅い。 PR ごとではなく main への push と週次が現実的だ。 CodeQL は GitHub なら設定がほぼゼロで Go のサポートも良いので、 小さいチームなら「gosec を PR、CodeQL を週次」で十分に足りる。
OWASP ZAP — API に対して正しく回す
DAST はここまでと決定的に違う。動いているアプリが要る。
スパイダーを使わない
ZAP に URL を渡して探索させる形で使うと、REST API はほぼ何も見つからない。
リンクを辿る前提の探索器なので、POST /photos も PATCH /photos/{id} も発見されない。
そして「何も出ませんでした」という結果は、安全の証明ではなく探索の失敗だ。
OpenAPI 仕様を食わせるのが正解。
docker run --rm -v "$PWD:/zap/wrk" ghcr.io/zaproxy/zaproxy:stable \
zap-api-scan.py \
-t /zap/wrk/api/external.openapi.json -f openapi \
-T http://host.docker.internal:8080 \
-c /zap/wrk/.zap/rules.tsv \
-r /zap/wrk/zap-report.html
これで全エンドポイント・全パラメータ・全スキーマを知った状態から始まる。 コードから OpenAPI を生成しているリポジトリは DAST と相性が非常にいい。
逆に手書きの仕様だと、仕様に書き忘れたエンドポイントは検査されない。 つまり一番危ないエンドポイントほど検査から漏れる。 仕様を生成にしておく理由がここにもある。
baseline と active の違い
zap-baseline.py— passive のみ。攻撃を撃たず、通過したレスポンスだけ見る。 1〜2分。ヘッダ欠落、Cookie 属性、エラー本文の情報漏れ。PR ごとに回せるzap-api-scan.py/zap-full-scan.py— active。実際に攻撃を撃つ。 10分〜数時間。夜間 / 週次
passive を PR に、active を夜間に。これが基本形。
認証をどう通すか
未認証の ZAP は、認証の壁を1枚見つけて終わる。トークンを渡す必要がある。
-z "-config replacer.full_list(0).description=auth \
-config replacer.full_list(0).enabled=true \
-config replacer.full_list(0).matchtype=REQ_HEADER \
-config replacer.full_list(0).matchstr=Authorization \
-config replacer.full_list(0).replacement=Bearer\ ${TOKEN}"
ここで何を検査していることになるのかを意識する必要がある。 開発用の擬似トークン発行器を通してスキャンしているなら、 検査しているのは開発用の認証実装であって、本番の OIDC 検証ではない。
- 認証の穴の検出結果は、本番の保証にならない
- 認可(誰が何にアクセスできるか)の検査は有効
だから「ZAP を通ったから安全」ではなく、「ZAP は認可まわりの既知パターンを見た」と読む。 この区別を曖昧にしたまま運用すると、検査が安心の生成装置になる。
使い捨て環境に対してだけ回す
active scan は POST / DELETE を実際に撃つ。データは壊れる前提だ。
CI の service コンテナか、docker compose down -v で毎回作り直す環境に対してだけ回す。
ステージングに向けて回すなら、その後にデータを作り直す手順まで含めて設計する。 「スキャン後に不整合なデータが残り、翌朝のデバッグが始まる」は普通に起きる。
スコープを絞る(やらないと事故る)
見落としやすい事故が2つある。
オブジェクトストレージの presigned URL。 レスポンスに外部ホストの URL が入っていると、 ZAP がそこまで攻撃しに行く。自社の S3 ならまだしも、 他社のサービスを指していたら普通に迷惑行為になる。
外部 API の呼び出し。 アプリが返した URL を辿って、第三者に攻撃を飛ばす。
コンテキストの include を対象ホストだけに限定する。 これは「設定しておくと良い」項目ではなく、やらないと事故る項目だ。
リスナーが複数あるとき
外部向け / 内部向け / 管理用でポートを分けている構成なら、 外部向けだけを ZAP に当てる。内部向けや管理用に当てても得るものは薄い。
「管理用エンドポイントが外から到達できないこと」はネットワーク層の性質であって、 ZAP の仕事ではない。むしろリスナーごとのルート表が混ざっていないことを固定する単体テストの方が、 確実で速くて、壊れたら即座に分かる。
Node が同居するとき
SSR のフロントエンドを立てているなら、Node もサーバとして本番に立つ。 Go 側と同じだけ依存の面倒を見る必要があり、その上で Go にはない面が出てくる。
到達性解析がない前提で設計する。 npm に govulncheck 相当はないので、件数が出る。
pnpm audit は --prod を付けないとビルドツールの CVE でノイズが出るため、
ゲートは --prod、全体は参考情報という線引きが現実的になる。
Node ランタイム自体の CVE も Go の toolchain と同じ問題で、依存を上げても消えない。
インストール時に走るスクリプトは Go にない攻撃面だ。 postinstall は
install した瞬間に第三者のコードが開発機と CI で走る経路で、
近年の npm サプライチェーン攻撃の主経路になっている。
そして依存の CVE を見る検査では一切見つからない。 CVE が付く前に走るし、そもそも「脆弱性」ではなく意図された悪性コードだからだ。 pnpm なら許可リストで止められる。
# pnpm-workspace.yaml
# ビルドスクリプトを走らせてよい依存を列挙する。
# 列挙外の postinstall / preinstall は実行されない。
onlyBuiltDependencies:
- esbuild
- sharp
これは本番ガードを拒否リストではなく許可リストにするのと同じ形だ。 「危ないパッケージを弾く」ではなく「ビルドが必要なものだけ通す」。 新しい依存を入れたときに許可リストの更新が必要になるので、 レビューで目に入るという副作用も効く。
SSR にはもう1つ、道具から見えない穴がある。 loader の返り値は HTML に直列化されてブラウザへ出る。セッションのトークンをサーバー側ストアに置き、 Cookie にはセッション ID だけを載せる設計にしていても、 loader が返してしまえばその努力は無効になる。しかも ZAP からは 200 が返るだけだ。
これは次の節と同じ形の問題で、同じ方法で押さえる。
どの層にも見つけられないもの
ここまでの層を全部入れても、一番怖い穴は見つからない。
GET /photos/{他人の photo_id} → 200 が返る
水平権限昇格(IDOR)。ZAP はこれを判定できない。 「そのレスポンスが返っていいのか」はアプリの business rule であって、 ZAP はそれを知らないからだ。200 が返れば正常応答として通過する。
同じ理由で見つからないもの:
- 下書き・非公開状態のリソースが、詳細エンドポイントから漏れる
- 削除したはずのリソースが、別経路から読める
- アカウント削除でオブジェクトストレージの実体が消えず、URL を知っていれば永久に読める
- SSR のハイドレーション payload にサーバー専用の値が混ざる
これは DAST ではなくテストで押さえる。
// alice のリソースに bob のトークンで全エンドポイントを叩き、
// 403 / 404 以外が返ったら落とす
for _, ep := range allEndpoints {
resp := call(ep, aliceResourceID, bobToken)
if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusNotFound {
t.Errorf("%s leaked to another user: %d", ep, resp.StatusCode)
}
}
肝は allEndpoints の作り方だ。手で列挙すると、列挙漏れがそのまま検査漏れになる。
router から経路を取れるなら(chi なら chi.Walk、大抵の router には同等の機能がある)、
新しいエンドポイントが増えるたびに自動で検査対象になる。
書くのは1〜2時間。ZAP より速く、確実で、代替できない。
既製の検査は「知られた悪いパターン」を見る。自分が書いた保証を確かめるのは、自分のテストの仕事だ。
続けるための条件
道具を何本入れるかより、これで決まる。
検査は毎回同じ結果を出す。 ZAP は同じ alert を同じ数、Trivy は同じ CVE を同じ数、 gosec は同じ指摘を同じ数。人間は3回目から見なくなる。
対策は1つしかない。基準(baseline)を固定し、新規に出たものだけを見る。
# .zap/rules.tsv
10035 IGNORE (HSTS は ALB で付与。オリジンでは付けない)
10038 IGNORE (CSP は API レスポンスには不要)
10015 WARN (Cache-Control、対応予定 #123)
# .trivyignore
CVE-2023-XXXXX # base image 由来。exp:2026-12-31 までに distroless へ移行 #456
規律は3つ。
- 除外には必ず理由を同じ行に書く。 無いと半年後に誰も外せなくなり、 「消していいか分からないので残す」が積み重なって baseline は永久に膨らむ
- 期限を書く。 Trivy は
exp:を解釈してくれる。ZAP にはないのでコメントで - 新規が0件ならジョブは成功して終わる。 新規が出たときだけ通知する
これは API の破壊的変更検査(基準に対する差分だけを見る)と同じ形をしている。 既知の状態との差分を見るという考え方が、継続的検査全般の核心だ。
もう1つ、fail open にしない。「基準ファイルが見つからないので全部スキップして成功」という 検査は、動いていないのに動いているように見える。これは検査が無いより悪い。 比較した件数を必ず出力し、0件なら落とす。
echo " 比較した対象: ${compared} / 全体: ${total}"
if [ "$compared" -eq 0 ]; then
echo "エラー: ゲートが空振りしている" >&2
exit 1
fi
この形については fail openを意識する。テストの成功は問題ないことを意味しない に 実測した事例をまとめてある。脆弱性検査は fail open になりやすい種類の検査で、 「何も出ませんでした」と「何も見ていません」が同じ見た目になる。
導入の順番
| 順 | やること | 工数 | 効果 |
|---|---|---|---|
| 1 | govulncheck を PR ゲートに | 30分 | 高。誤検知ほぼゼロ |
| 2 | Dependabot(grouped、gomod + github-actions) | 30分 | 高。CVE が出る前に直っている状態を作る |
| 3 | 他人のトークンで全エンドポイントを叩くテスト | 1〜2時間 | 最高。他のどの道具でも代替できない |
| 4 | trivy image をビルド時 + 日次再スキャン | 半日 | 中〜高。ベースイメージ次第 |
| 5 | gosec を golangci-lint 経由で(段階導入) | 半日 | 中 |
| 6 | ZAP baseline(passive)を夜間 | 半日 | 中 |
| 7 | ZAP API scan(active)を週次 | 1〜2日 | 中。ここまで来れば十分 |
| 8 | CodeQL を週次 | 半日 | 中 |
3 が 4〜8 より上にあるのが、この順序で一番言いたいことだ。
道具を並べると「入れた感」が出る。バッジも増える。 だが実際に事故になるのは認可の穴で、それはどの既製の道具からも出てこない。 既製品で埋まらない穴を自分のドメイン知識で埋める——そこが一番効く投資先になる。
最後に頻度のまとめ。
| 頻度 | 回すもの |
|---|---|
| PR ごと | govulncheck / gosec / trivy fs / 認可テスト |
| 日次 | trivy image(本番タグの再スキャン)/ ZAP baseline |
| 週次 | ZAP active / CodeQL |
| 随時 | Dependabot の PR |
| 四半期 | 人間による設計レビュー・手動探索 |
検査を増やすより、新規の検出だけが目に入る状態を保つこと。 それができていれば、道具は後からいくらでも足せる。