継続的脆弱性検査は層で回す。govulncheckから始めてZAPは最後

OWASP ZAPから始めると続かない。依存・更新・イメージ・静的解析・DASTの5層を頻度を変えて回す設計と、各層の落とし穴。govulncheckの到達性解析がなぜ別格なのか、Dependabotが検知器ではなく更新装置である理由、そしてどの層でも見つからない認可の穴について。

「脆弱性診断をやりたい」という話は、たいてい OWASP ZAP から始まる。そして続かない。

ZAP は一番外側で、一番遅くて、一番ノイズが多い層だ。ここから入ると、 最初の1週間で数百件の alert を前に手が止まる。次の週には誰も見なくなる。

継続的脆弱性検査は「ZAP を回すこと」ではない。速度もノイズ量も守備範囲も違う 5つの層を、違う頻度で回すことだ。そして費用対効果は依存の層が圧倒的に高い。

層で考える

層道具何を見るか頻度所要ノイズ
依存の既知脆弱性govulncheckGo の依存の CVE(到達性つき)PR ごと10〜30秒ほぼゼロ
依存の更新Dependabot / Renovate更新可能なバージョン週次—中
イメージ・設定TrivyOS パッケージ、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つある。

前者は到達性を見ないので、使っていないコードパスの 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
設定 / IaCtrivy config .Terraform・Dockerfile・k8s の危険な設定
シークレット上記に同梱ハードコードされた鍵(既定で有効)

Go の場合、イメージの中身をほぼ空にできるのが効く。FROM scratch や distroless なら OS パッケージがそもそも存在しないので、trivy image の結果は静かなままだ。 Alpine や Debian slim を使っていると、アプリと無関係な CVE が常時数十件出る。

ベースイメージの選択が、検査のノイズ量を決める。

静的リンクできる Go は、この点で他の言語より圧倒的に有利な立場にいる。 そしてこの有利さは Dockerfile を書く段で一度決めたら、あとは効き続ける。 検査を入れる前にベースイメージを見直す方が、baseline を育てるより安い。

回す場所は3つある。

  1. ビルド時(PR / main) — --exit-code 1 --severity HIGH,CRITICAL でゲート
  2. レジストリ側の継続スキャン(ECR の scan on push など)
  3. 定期再スキャン(日次)

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 の典型:

鉄則は1つ。最初から FAIL にしない。

  1. まず件数を見る(continue-on-error か手元で)
  2. 明らかに正しい指摘だけ直す
  3. 残りをルール単位で除外する(理由を同じ行に書く)
  4. そこで初めてゲートにする

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 の違い

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 が返れば正常応答として通過する。

同じ理由で見つからないもの:

これは 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つ。

  1. 除外には必ず理由を同じ行に書く。 無いと半年後に誰も外せなくなり、 「消していいか分からないので残す」が積み重なって baseline は永久に膨らむ
  2. 期限を書く。 Trivy は exp: を解釈してくれる。ZAP にはないのでコメントで
  3. 新規が0件ならジョブは成功して終わる。 新規が出たときだけ通知する

これは API の破壊的変更検査(基準に対する差分だけを見る)と同じ形をしている。 既知の状態との差分を見るという考え方が、継続的検査全般の核心だ。

もう1つ、fail open にしない。「基準ファイルが見つからないので全部スキップして成功」という 検査は、動いていないのに動いているように見える。これは検査が無いより悪い。 比較した件数を必ず出力し、0件なら落とす。

echo "  比較した対象: ${compared} / 全体: ${total}"
if [ "$compared" -eq 0 ]; then
  echo "エラー: ゲートが空振りしている" >&2
  exit 1
fi

この形については fail openを意識する。テストの成功は問題ないことを意味しない に 実測した事例をまとめてある。脆弱性検査は fail open になりやすい種類の検査で、 「何も出ませんでした」と「何も見ていません」が同じ見た目になる。

導入の順番

順やること工数効果
1govulncheck を PR ゲートに30分高。誤検知ほぼゼロ
2Dependabot(grouped、gomod + github-actions)30分高。CVE が出る前に直っている状態を作る
3他人のトークンで全エンドポイントを叩くテスト1〜2時間最高。他のどの道具でも代替できない
4trivy image をビルド時 + 日次再スキャン半日中〜高。ベースイメージ次第
5gosec を golangci-lint 経由で(段階導入)半日中
6ZAP baseline(passive)を夜間半日中
7ZAP API scan(active)を週次1〜2日中。ここまで来れば十分
8CodeQL を週次半日中

3 が 4〜8 より上にあるのが、この順序で一番言いたいことだ。

道具を並べると「入れた感」が出る。バッジも増える。 だが実際に事故になるのは認可の穴で、それはどの既製の道具からも出てこない。 既製品で埋まらない穴を自分のドメイン知識で埋める——そこが一番効く投資先になる。

最後に頻度のまとめ。

頻度回すもの
PR ごとgovulncheck / gosec / trivy fs / 認可テスト
日次trivy image(本番タグの再スキャン)/ ZAP baseline
週次ZAP active / CodeQL
随時Dependabot の PR
四半期人間による設計レビュー・手動探索

検査を増やすより、新規の検出だけが目に入る状態を保つこと。 それができていれば、道具は後からいくらでも足せる。

← Back to all posts