インフラエンジニアの羅針盤

インフラエンジニア1〜3年目のための技術ガイド

TrivyでCVEスキャンとCIゲート【CKS第13回】

公開

新卒インフラエンジニア向け「Kubernetes 実践教科書 ③ CKS セキュリティ・ハードニング編」(全19回)の第13回です。前回はイメージから無駄を削り、マニフェストを apply する前に検査しました。今回も D5 サプライチェーンセキュリティ(配点 20%) の続きです。

本回の主題は Trivy です。イメージ・ソース・設定ファイルの 3 方向にスキャンをかけ、見つかったものを直すか、理由と期限つきで抑制するか、閾値の外に置くかを仕分けます。最後に第12回の 2 段ゲートを 4 ステージに拡張します。

そして本回は、ツールが 1 度も動かないところから始まります。 Trivy の脆弱性データベースは既定で mirror.gcr.io から取得されますが、本ラボの Squid whitelist にそのホストはありません。しかも第 2 候補へ自動で切り替わりません。 0.058 秒で FATAL になります。この 1 件を解くところが本回の入口です。

  • ラボ Kubernetes v1.36.3
  • CKS 試験環境 v1.35
  • Trivy v0.70.0
  • Kubesec v2.14.2
  • KubeLinter v0.8.3
  • Docker 29.6.2
  • eclipse-temurin 25-jre-alpine
  • Cilium v1.19.6
  • containerd v2.2.6
  • AlmaLinux 10.2
  • ArgoCD v3.4 系
  • 確認日 2026-08-01

執筆時点の Trivy 最新版は v0.72.0(2026-06-30 リリース)ですが、本書は再現性のため v0.70.0(2026-04-17 リリース)をピン留めして使います。 v0.70.0 から v0.72.0 までの間に、本回で使う CLI フラグ・DB の配布先・既定挙動の変更はありません。

目次
  1. 第13回のスコープ・今ここマップ
    1. 既習範囲 / 本回で上書きする観点
  2. この回のゴール
    1. 本回の起点
    2. 本回で行き来するホスト
  3. Trivy は何を見ているのか —— 4 つのスキャン対象と 3 つのデータベース
    1. 4 つのスキャン対象
    2. trivy config が対象にする設定ファイルの種類
    3. 3 つのデータベース
    4. Trivy が手元に無い場合の導入
  4. 準備: 既定のままでは 1 度も動かない —— DB の配布元を選び直す
    1. 手順 1: 既定のまま実行して FATAL を見る
    2. 手順 2: なぜフォールバックしないのかを公式で確認する
    3. 手順 3: 公式が案内している別の配布元へ切り替える
    4. 手順 4: 実際に取得する
    5. 手順 5: trivy –version で DB の鮮度を読む
    6. 手順 6: trivy.yaml に固定する
  5. やってみよう①:イメージの CVE を数え、第12回の予告を回収する
    1. ステップ1:0.4.0(alpine ベース・現行)をスキャンする
    2. ステップ2:0.3.2(Ubuntu ベース・第12回より前)をスキャンして比べる
    3. ステップ3:差の中身を 3 つに分解する
    4. ステップ4:frontend とそのベースも数える
  6. 出力を絞る —— –severity / –ignore-unfixed / –pkg-types / –format
    1. –severity —— スペースを入れない
    2. –exit-code —— 付けなければ落ちない
    3. –ignore-unfixed —— 「無視」ではなく「後回し」
    4. –pkg-types —— OS と言語パッケージを切り分ける
    5. –format —— 誰に見せるかで変える
  7. やってみよう②:trivy fs でソースの依存を見て、pom.xml を直す
    1. ステップ1:trivy image では分からないことがある
    2. ステップ2:どのバージョンへ上げるかを決める
    3. ステップ3:pom.xml を 1 行直す
    4. ステップ4:0.4.1 としてビルドし、push する
    5. ステップ5:消えたことを確認する
  8. やってみよう③:trivy config で Dockerfile と Kubernetes マニフェストを検査する
    1. ステップ1:Dockerfile を検査する
    2. ステップ2:Kubernetes マニフェストを検査する
    3. ステップ3:指摘を 3 つに仕分ける
    4. ステップ4:.trivyignore を書く
  9. 同じ道具でも失敗の作法が違う —— FATAL と埋め込み checks
    1. 実験: checks バンドルのキャッシュを消して、既定のまま実行する
    2. これは仕様です
    3. 3 つの DB の失敗の作法
    4. 第12回・第2回との接続
  10. trivy k8s —— クラスタ丸ごとを対象にする(記法の紹介)
    1. 何ができるのか
    2. 記法
    3. 本書ラボでは実行しない —— その判断
  11. やってみよう④:ゲートを 4 ステージにし、閾値と例外を設計する
    1. ステップ1:第12回のゲートを確認する
    2. ステップ2:何を足すかを設計する
    3. ステップ3:閾値を決める
    4. ステップ4:4 ステージ版 scan-gate.sh を書く
    5. ステップ5:イメージを 0.4.1 に上げて GitOps で反映する
    6. ステップ6:動いていることを確認する
  12. 暗記必須コマンドと 2 つの設定ファイルの型
    1. Trivy のドキュメントは試験中に 1 ページも参照できない
    2. 暗記必須コマンド
    3. 2 つの設定ファイルの型
    4. 設定の優先順位
  13. まとめ
  14. 理解度チェック(○×形式・全 8 問)
  15. 次回予告

第13回のスコープ・今ここマップ

D5 は 4 つのコンピテンシーで構成されます。本回はそのうち 2 つを担当します。

公式コンピテンシー(D5 Supply Chain Security・20%)担当
Minimize base image footprint第12回
Understand your supply chain (e.g. SBOM, CI/CD, artifact repositories)本回(CI/CD・artifact repositories) + 第14回(SBOM)
Secure your supply chain (permitted registries, sign and validate artifacts, etc.)第14回
Perform static analysis of user workloads and container images (e.g. Kubesec, KubeLinter)第12回(user workloads)+ 本回(container images)

第12回は 4 つ目のコンピテンシーの user workloads(マニフェスト)側を担当しました。本回は同じコンピテンシーの container images 側を回収します。 さらに Understand your supply chainCI/CDartifact repositories も本回の担当です。

artifact repositories は言葉だけの話ではありません。 Trivy の脆弱性データベースは OCI アーティファクトとして 4 つのレジストリで配布されており、どこから取るかを自分で決めるのが本回の準備そのものです。

既習範囲 / 本回で上書きする観点

既習どこで本回で上書きする観点
trivy image で CVE をスキャンする第1巻image は 4 つあるスキャン対象の 1 つにすぎません。 config / fs / k8s と守備範囲を分けて使います
静的解析(Kubesec / KubeLinter)と終了コードによる CI ゲート第12回同じゲートに 3 つ目の道具を足します。 対象がマニフェストからイメージへ広がります
「イメージを小さくすると CVE の母数が減る」第12回(予告のみ)0.3.2(Ubuntu ベース)と 0.4.0(alpine ベース)の実件数で回収します
「ツールのルールセットには賞味期限がある」第12回(Kubesec v2.14.2)今度は DB の鮮度の話になります。 --skip-db-update を CI で常用すると、古い DB のまま緑になります
「除外の理由を設定ファイルに書き残す」第12回.kube-linter.yamlexclude.trivyignore で同じことをします。 ただし Trivy には期限(exp:YYYY-MM-DDがあります
whitelist に 1 行足す判断第12回gcr.io を追加)本回は 1 行も足しません。 公式が案内する別の配布元が、既に許可済みのホストにあります
外部からのバイナリ・アーティファクトの取得経路第2回(SHA256 検証)・第7回(外部通信の最小化)DB という「毎日更新されるアーティファクト」をどこから取るか。 経路の選択がサプライチェーンそのものです
GitOps 経由の変更(base/ を編集して push)第9回第12回イメージのタグ更新も Git 経由です。 selfHeal: true があるので、手で kubectl set image しても戻されます

先に 3 つ、本回で覆る前提を予告します。

①「--db-repository の既定値には第 2 候補として ghcr.io が入っているので、1 つ目が駄目でも自動で切り替わる」——切り替わりません。 フォールバックするのは 429 / 5xx などの一時的なエラーのときだけで、403 Forbidden では 0.058 秒で FATAL になります。 ②「エラーが出ていなければスキャン結果は信用できる」——trivy configERROR を出しながら結果を出します。 埋め込み checks へフォールバックしており、そのルールは Trivy をビルドした時点のものです。③「--severity HIGH,CRITICAL を指定すれば HIGH が見つかったときにビルドが止まる」——止まりません。 --exit-code を明示しない限り、Trivy は指摘が何件あっても 0 で終了します。

第3巻 19 回のうち、現在位置は次のとおりです。

第1部 第3巻オリエンテーション
    第1回: 第3巻スコープ + CKA 境界 + 4C/脅威モデル + Kubestronaut

第2部 クラスタ堅牢化(D1/D2)
    第2回: kube-bench + CIS ベンチマーク修復 + バイナリ検証(D1)
    第3回: NetworkPolicy 完全設計 + ノードメタデータ保護 + TLS Ingress(D1)
    第4回: RBAC 監査 + ServiceAccount トークン管理(D2)
    第5回: API Server 堅牢化 + kubeadm アップグレード(D2)

第3部 OS / ノード堅牢化(D3)
    第6回: カーネル層の強制アクセス制御(SELinux/seccomp/capabilities/AppArmor)(D3)
    第7回: ホスト OS の攻撃面最小化と権限管理(D3)

第4部 ワークロード防御(D4)
    第8回: Pod Security Standards + Admission(D4)
    第9回: Secret 管理(etcd 暗号化 / SealedSecrets / ExternalSecrets)(D4)
    第10回: マルチテナンシー分離 + サンドボックス(gVisor/RuntimeClass)(D4)
    第11回: Cilium 透過暗号化 + L7 NetworkPolicy(Calico → Cilium 移行)(D4)

第5部 サプライチェーンセキュリティ(D5)
    第12回: 最小イメージ + 静的解析(Kubesec + KubeLinter)(D5)
  ★ 第13回: Trivy イメージスキャン + CI/CD パイプラインセキュリティ統合(D5)  ← 今ここ
    第14回: SBOM(bom)+ Cosign イメージ署名 + 許可レジストリ制限(D5)

この回のゴール

本回を終えると、次のことができるようになります。到達できたかは記事末の「やってみよう」と「理解度チェック」で確認します。

  • trivy image / config / fs / k8s の 4 つを、対象と検出物で使い分けられる
  • Trivy が使う3 つのデータベース(脆弱性 DB / Java インデックス DB / checks バンドル)の役割と、取得できないときの挙動の違いを説明できる
  • DB の配布元を trivy.yaml で切り替えられる
  • --severity / --exit-code / --ignore-unfixed / --pkg-types / --format を目的に応じて選べる
  • --exit-code を付けない限り Trivy は落ちないことを踏まえ、表示する閾値と落とす閾値を分けてゲートを設計できる
  • trivy fs の指摘を実際に直し、再スキャンで消えたことを確認できる
  • 直さない指摘を .trivyignore に理由と期限つきで記録できる
  • 静的解析(第12回)・イメージスキャン(本回)・Admission(第8回)・ランタイム検知(第15回)の守備範囲の違いを説明できる

本回の起点

起点は第12回の完了状態です。

項目
ノード5/5 Ready・Kubernetes v1.36.3・containerd v2.2.6・AlmaLinux 10.2
Pod111 個・異常 0
fanclubbackend 0.4.0 × 3(2/2)/ frontend 1.0.0 × 2 / db-0 / logcollector 2
HTTPShttps://fanclub.local/api/members200
ArgoCDfanclub-api-prodSynced / Healthy・HEAD は 20faa23
alma-proxy の whitelist44 行本回は 1 行も足しません
ディスク/ は 35G 中 26G 空き
Trivy/usr/local/bin/trivyv0.70.0)が置いてあるだけ。~/.cache/trivy は存在しません

最後の行が本回の出発点です。 バイナリはありますが、DB を 1 度も取っていません。この状態から書き始めます。

HEAD のコミットハッシュは、前回の演習で push した値とは一致しません。

第12回の演習④で push したのは 15e5022 でしたが、本回の起点は 20faa23 です。回と回のあいだに、記事へ載せていない検証用のコミットが入っているためです(本書は各回を素の環境から通し直して実測しており、そのときの操作が履歴に残ります)。

読者の環境でも、ハッシュは手元の値になります。 以降の回の起点表に出てくるリビジョンも同じで、一致すべきなのはハッシュではなく Synced / Healthy という状態のほうです。ハッシュが違うことを異常と読まないでください。

本回で行き来するホスト

ホスト本回での役割
k8s-ops本回の作業のほぼすべて。Trivy の実行・Docker ビルド・Git 操作
k8s-registry新イメージ 0.4.1 の push 先(コマンドは k8s-ops から)
alma-proxy触りません。 whitelist は 44 行のままです

Trivy は何を見ているのか —— 4 つのスキャン対象と 3 つのデータベース

第1巻で trivy image を打ったことがあります。本回はそこから視点を 1 段上げます。「trivy image を打つ」から「対象と DB を選ぶ」へです。

4 つのスキャン対象

サブコマンド対象主に見つかるもの本回での扱い
trivy imageビルド済みのコンテナイメージOS パッケージの CVE / イメージに同梱された言語パッケージ(jar 等)の CVE演習①・演習④
trivy fsローカルのプロジェクトディレクトリ / 単一ファイルロックファイル・依存定義(pom.xml 等)の CVE / 誤設定 / Secret演習②
trivy config設定ファイル(IaC)誤設定(misconfiguration)演習③
trivy k8s稼働中のクラスタ上記を横断(EXPERIMENTAL記法の紹介のみ

似ているが本回では使わないサブコマンドが 2 つあります。 公式ドキュメント(trivy.dev/latest/docs/target/)の記述で区別が決まります。

trivy repo targets lock files such as package-lock.json and does not target artifacts like JAR files, binary files, etc.

Rootfs scanning is for special use cases such as host machine, root filesystem, and unpacked filesystem. You should use trivy fs to scan your local projects in CI/CD.

サブコマンド対象本回で使わない理由
trivy repositoryrepoリモート / ローカルの Git リポジトリ。--branch / --tag / --commit でリビジョン指定jar やバイナリを見ません。 ロックファイルだけが対象です
trivy rootfs展開済みのルートファイルシステム / ホストそのもの公式が「CI/CD ではローカルプロジェクトに trivy fs を使え」と明記しています

CI で使うのは fs です。 名前が似ている 3 つのうち、どれを選ぶかはこの 2 行で決まります。

trivy config が対象にする設定ファイルの種類

--misconfig-scanners の既定値が、そのまま対象一覧になっています。

azure-arm, cloudformation, dockerfile, helm, kubernetes, terraform, terraformplan-json, terraformplan-snapshot, ansible

公式の記述は 「Trivy automatically detects config types and applies relevant checks.」(Trivy は設定ファイルの種別を自動判定し、対応するチェックを当てる)です。Dockerfile と Kubernetes マニフェストが同じディレクトリにあっても、種別を見分けてそれぞれのチェックを当てます。

3 つのデータベース

ここが本回の準備が必要な理由です。 Trivy は 1 つの DB で動いているわけではありません。

データベース使うサブコマンド中身取れないとき
脆弱性 DBtrivy-dbimage / fs / k8sOS ディストリと言語エコシステムのアドバイザリFATAL
Java インデックス DBtrivy-java-dbimage / fs(jar がある場合)jar のハッシュから GAV(groupId / artifactId / version)を引くための索引FATAL
checks バンドルtrivy-checksconfig誤設定チェックの Rego ポリシーERROR を出して埋め込み版で継続

最右列の非対称が本回の主題のひとつです。 同じ「取得できない」でも、片方は即死し、もう片方は結果を出します。実際の挙動は準備の節(FATAL)と、演習③のあとの節(フォールバック)で確認します。

Trivy の 4 つのスキャン対象と 3 つのデータベースの対応を示す図。左に trivy image(ビルド済みイメージ)・trivy fs(プロジェクトディレクトリ)・trivy config(設定ファイル)・trivy k8s(稼働中クラスタ・EXPERIMENTAL)を並べ、中央の Trivy v0.70.0 を経て右に見つかるものを示す。image と fs は脆弱性 DB + Java DB を使い取れないと FATAL、config は checks バンドルを使い取れないと ERROR を出して埋め込み版で継続する。下段に 3 つの実体(脆弱性 DB 1.2GB・24 時間キャッシュ/Java インデックス DB 1.4GB・3 日/checks バンドル 234.65 KiB・24 時間)を並べ、3 つとも既定の配布元が mirror.gcr.io で本ラボの whitelist に無いことを示す
図1:Trivy の 4 つのスキャン対象と 3 つのデータベース

OCI アーティファクトとは。

コンテナレジストリの仕組み(OCI Distribution)を使って配る、コンテナイメージ以外のデータのことです。Trivy の DB はこの形で配られています。

trivy-db の README も 「the database cannot be downloaded using docker pull since it is not a container image」(コンテナイメージではないので docker pull ではダウンロードできない)と明記しています。レジストリはイメージだけを置く場所ではありません。 この理解は第14回(SBOM と署名をレジストリに添付する)にそのままつながります。

Trivy が手元に無い場合の導入

本書ラボの k8s-ops には /usr/local/bin/trivy(v0.70.0)が既に入っています(第1巻で導入したものです)。手元に無い場合は、第12回の Kubesec / KubeLinter と同じく GitHub release からバイナリを取得して /usr/bin に置いてください/usr/local/bin ではなく /usr/bin にするのは、sudosecure_path に含まれるためです(第2回の kube-bench 以来の作法です)。

実行コマンド(k8s-ops・developer):

$ trivy --version

実行結果:

Version: 0.70.0

1 行しか返りません。 あとで見るように、このコマンドは DB を取得すると出力が増えます。いまは何も持っていないので、バージョンだけが出ます。

本番ガードレール①: スキャナ自身もサプライチェーンの一部です。

第2回でプラットフォームバイナリの SHA256 検証を扱いましたが、外部から持ち込むスキャナのバイナリも同じように検証してから使ってください。

「脆弱性を見つける道具」が改ざんされていたら、得られるのは「見つからない」という結果の保証だけです。検査結果を信じる前に、検査する側の出所を確認します。

準備: 既定のままでは 1 度も動かない —— DB の配布元を選び直す

本回の 1 つ目の山です。 演習にはしませんが、手順は全部見せます。6 手順あります。

手順 1: 既定のまま実行して FATAL を見る

実行コマンド(k8s-ops・developer):

$ time trivy image --download-db-only

実行結果:

2026-08-01T10:11:27+09:00	INFO	[vulndb] Need to update DB
2026-08-01T10:11:27+09:00	INFO	[vulndb] Downloading vulnerability DB...
2026-08-01T10:11:27+09:00	INFO	[vulndb] Downloading artifact...	repo="mirror.gcr.io/aquasec/trivy-db:2"
2026-08-01T10:11:27+09:00	FATAL	Fatal error	run error: init error: DB error: failed to download vulnerability DB: OCI artifact error: failed to download vulnerability DB: failed to download artifact from mirror.gcr.io/aquasec/trivy-db:2: OCI repository error: 1 error occurred:
	* Get "https://mirror.gcr.io/v2/": Forbidden

real	0m0.058s

読むべき点が 3 つあります。

  1. mirror.gcr.io は本ラボの whitelist にありません。 第12回で gcr.io を 1 行足しましたが、Squid の acl whitelist dstdomain先頭ドットの有無でサブドメインの扱いが変わります。 44 行目の gcr.io(先頭ドット無し)は gcr.io そのものだけにマッチし、mirror.gcr.io にはマッチしません
  2. 1 error occurred で終わっています。 エラーが 1 件しか記録されていない——つまり第 2 候補を試していません
  3. 所要は 0.058 秒です。待たされてタイムアウトしたのではなく、即座に諦めています

手順 2: なぜフォールバックしないのかを公式で確認する

trivy image --help の文言は OCI repository(ies) to retrieve trivy-db in order of priority です。「優先順に複数試す」としか読めません。しかし公式の Databases ページ(trivy.dev/latest/docs/configuration/db/)にはこう書いてあります。

Trivy will attempt to pull images from the following registries in the order specified: 1. mirror.gcr.io/aquasec 2. ghcr.io/aquasecurity

In case of a transient errors (e.g. status 429 or 5xx), Trivy will fall back to alternative registries in the order specified.

フォールバックするのは 429 や 5xx といった一時的なエラーのときだけです。 Squid が返す 403 Forbidden は一時的なエラーではありませんので、仕様どおり第 2 候補へは進みません。

CLI のヘルプだけでは条件が分かりませんでした。

in order of priority という 4 語からは「1 つ目が駄目なら 2 つ目」としか読めません。ヘルプは要約であり、条件はドキュメント側に書いてあります。

ツールの挙動が期待と違ったとき、ヘルプの文言を疑って一次ソースを 2 箇所読む——これは CKS の実務力そのものです。「バグではないか」と結論する前に、条件が書かれている場所を探してください。

手順 3: 公式が案内している別の配布元へ切り替える

同じ Databases ページには、3 つの DB すべてに 3 つの配布経路が載っています。

DBGHCRDocker HubAWS ECR
脆弱性 DBghcr.io/aquasecurity/trivy-dbaquasec/trivy-dbpublic.ecr.aws/aquasecurity/trivy-db
Java DBghcr.io/aquasecurity/trivy-java-dbaquasec/trivy-java-dbpublic.ecr.aws/aquasecurity/trivy-java-db
checks バンドルghcr.io/aquasecurity/trivy-checksaquasec/trivy-checkspublic.ecr.aws/aquasecurity/trivy-checks

3 つの DB がそれぞれ 3 経路で配られています。 次に、本ラボからどこへ到達できるかを測ります。

実行結果(HTTP ステータスのみ抜粋):

https://ghcr.io/v2/                              401   ← 到達
https://pkg-containers.githubusercontent.com/    400   ← 到達
https://mirror.gcr.io/v2/                        000   ← 遮断
https://public.ecr.aws/v2/                       000   ← 遮断

401400 は到達している証拠です。 サーバが応答を返している以上、プロキシは通しています。000curl が応答を得られなかったことを示します。

ghcr.io には到達できます。しかし本ラボでは実用になりません。

配布元脆弱性 DB(転送 103.69 MiB / 展開後 1.2GB)Java DB(転送 902.85 MiB / 展開後 1.4GB)
mirror.gcr.io(既定)遮断(FATAL遮断(FATAL
ghcr.io成功・4 分 25 秒2 回とも失敗PROTOCOL_ERROR・6 分 12 秒 / 12 分 26 秒)
docker.io成功・8.552 秒成功・21.387 秒

所要は実行のたびに変わります。 同じ Docker Hub からの脆弱性 DB 取得でも、別の日の計測では 4.678 秒でした。比べるべきは秒数の細かい差ではなく、桁です——数秒と数分と失敗の 3 つのうちどれに落ちるかが、経路を選ぶ根拠になります。

「転送量」と「展開後のサイズ」は別の数字です。

ダウンロードの進捗バーが示すのは 103.69 MiB(脆弱性 DB)と 902.85 MiB(Java DB)で、du -sh ~/.cache/trivy/* が返すのは 1.2G1.4G です。秒数が対応しているのは前者のほうです。 8.552 秒を 1.2GB で割ると 140MB/s になり、Squid 越しの経路としては成立しません——103.69 MiB なら約 12 MiB/s で、実測どおりの値になります。第12回CONTENT SIZE(圧縮済み)と docker history(展開後)を混ぜて比べてはいけないと述べたのと、まったく同じ形の取り違えです。

Java DB は ghcr.io から 2 回とも取れませんでした。 6 分 12 秒と 12 分 26 秒かけて、どちらも stream error: stream ID 1; PROTOCOL_ERROR; received from peer で中断しています。脆弱性 DB(転送 103.69 MiB)は同じ ghcr.io から取れているので、ホストの許可の問題ではなく、Squid 越しの長時間 HTTP/2 ストリームが切れているものと見られます。

Docker Hub は第1巻から whitelist に入っていますregistry-1.docker.io / auth.docker.io / index.docker.io / production.cloudflare.docker.com)。したがって本回は whitelist を 1 行も足しません。

Trivy の DB 配布経路と本ラボでの結果を示す図。k8s-ops の Trivy から alma-proxy(Squid・whitelist 44 行)を通り、3 つのレジストリへ分岐する。mirror.gcr.io は whitelist に無く遮断され 0.058 秒で FATAL(403 Forbidden は一時的エラーではないため第2候補の ghcr.io へフォールバックしない)。ghcr.io は whitelist 内で到達するが脆弱性 DB に 4 分 25 秒かかり Java DB は PROTOCOL_ERROR で 2 回とも失敗する。docker.io は whitelist 内で脆弱性 DB 8.552 秒・Java DB 21.387 秒で成功し、第1巻から登録済みのため whitelist を 1 行も足さずに済む
図2:Trivy の DB 配布経路と、本ラボで通った経路・落ちた経路

第12回は distroless を pull するために gcr.io を 1 行足しました。第13回は 1 行も足さずに済みます。 差は「足す前に、公式が案内している他の配布元を調べたかどうか」だけです。「必要なものは足す」の前に「本当に足す必要があるか」を調べる——第7回で削った攻撃面を、演習のたびに戻さないための作法です。

本番ガードレール②: 配布元を変えることは、信頼の預け先を変えることです。

動いたから使うのではなく、そのレジストリが提供元の公式ドキュメントに配布先として記載されているかを確認して選んでください。Trivy の Databases ページには Docker Hub の aquasec/trivy-db が明記されています。

記載のないミラーを見つけて使うのは、脆弱性データベースという「判断の根拠そのもの」を素性の分からない相手から受け取ることになります。 スキャン結果が緑だったとき、その緑は受け取った DB の内容に依存しています。

手順 4: 実際に取得する

まず脆弱性 DB です。

実行コマンド(k8s-ops・developer):

$ time trivy image --download-db-only --db-repository docker.io/aquasec/trivy-db:2

実行結果(所要):

real	0m8.552s

次に Java インデックス DB です。

実行コマンド(k8s-ops・developer):

$ time trivy image --download-java-db-only --java-db-repository docker.io/aquasec/trivy-java-db:1

実行結果(末尾と所要):

2026-08-01T10:11:57+09:00	INFO	[javadb] Java DB is cached for 3 days. If you want to update the database more frequently, "trivy clean --java-db" command clears the DB cache.

real	0m21.387s

ghcr.io から 6〜12 分かけて 2 回とも失敗したものが、Docker Hub からは 21 秒で完了します。 同じアーティファクトでも、経路でここまで結果が変わります。

「DB だけ取る」フラグは 2 つとも v0.70.0 に存在します。

--help の該当行はこうなっています。

      --download-db-only             download/update vulnerability database but don't run a scan
      --download-java-db-only        download/update Java index database but don't run a scan

スキャンを走らせずに DB だけ揃えられます。 準備と検査を分けられるので、「DB が取れないのか、スキャン対象に問題があるのか」を切り分けるときにも使えます。

失敗すると残骸が残ります。

Java DB の取得に失敗すると、~/.cache/trivy/java-db/trivy-java.db が 0 バイトで残ります。 次に実行すると「キャッシュがある」と判断されて中途半端な状態になるため、再試行の前に消す必要があります。

消すには trivy clean --java-db を使います(上の実行結果の最終行が、この案内そのものです)。「失敗したら残骸が残る」ことを知らないと、2 回目以降の挙動が説明できなくなります。

手順 5: trivy --version で DB の鮮度を読む

実行コマンド(k8s-ops・developer):

$ trivy --version

実行結果:

Version: 0.70.0
Vulnerability DB:
  Version: 2
  UpdatedAt: 2026-08-01 00:54:35.863448455 +0000 UTC
  NextUpdate: 2026-08-02 00:54:35.863448154 +0000 UTC
  DownloadedAt: 2026-08-01 01:11:35.610925091 +0000 UTC
Java DB:
  Version: 1
  UpdatedAt: 2026-07-31 01:24:54.777450867 +0000 UTC
  NextUpdate: 2026-08-03 01:24:54.777450627 +0000 UTC
  DownloadedAt: 2026-08-01 01:11:57.041098944 +0000 UTC
Check Bundle:
  Digest: sha256:1583562f8b90ed2a071b99f0e5ffff6b57e4ceb6ca3e4796577b4e6a339eb74c
  DownloadedAt: 2026-08-01 01:11:58.666857586 +0000 UTC

時刻が UTC 表記なので、日本時間とは 9 時間ずれます。 DownloadedAt: 2026-08-01 01:11:35 +0000 UTC は、手順 4 で実行した 10:11:35(JST)のことです。

節が出るのは、キャッシュにあるときだけです。

trivy --versionキャッシュに存在するものだけを表示します。 DB を 1 度も取っていない状態では Version: 0.70.0 の 1 行だけになります(本回の起点がその状態でした)。Java DB を消せば Java DB: の節が消え、checks バンドルを取れば Check Bundle: の節が増えます。「何を持っているか」が 1 コマンドで分かります。

DownloadedAt は実行のたびに変わります。 UpdatedAt / NextUpdate は上流のビルド時刻なので、同じ日に実行すれば一致します。Check BundleDigest は配布元と上流の更新で変わります——同じ日に ghcr.io 経由と Docker Hub 経由で取ると、別のダイジェストが出ます。

NextUpdate の差に注目してください。 脆弱性 DB は 24 時間後、Java DB は 3 日後です。

種別上流の更新頻度クライアント側のキャッシュ
脆弱性 DB6 時間ごとにビルドtrivy-db の README: 「Trivy DB is built every 6 hours.」)24 時間
Java DB3 日(実機メッセージ)
checks バンドル24 時間ごとに更新確認(公式)

Java DB のキャッシュが長いのは、中身が「jar のハッシュ → GAV の索引」であって脆弱性情報そのものではないからです。索引は毎日は変わりません。

手順 6: trivy.yaml に固定する

毎回フラグを打つのは現実的ではありません。 第12回で .kube-linter.yaml をリポジトリに置いたのと同じく、検査設定をリポジトリに置きます。

~/gitops/gitops-fanclub/trivy.yaml

db:
  repository:
    - docker.io/aquasec/trivy-db:2
  java-repository:
    - docker.io/aquasec/trivy-java-db:1
misconfiguration:
  checks-bundle-repository: docker.io/aquasec/trivy-checks:2

3 つとも書く必要があります。 Trivy が取りに行く OCI アーティファクトは 脆弱性 DB / Java インデックス DB / checks バンドルの 3 つで、既定はいずれも mirror.gcr.io です。db の 2 つだけを書き換えると、trivy config だけが遮断された mirror.gcr.io を見続け、毎回埋め込み checks へフォールバックします。 エラーは出ますが結果は出るので、気づきにくい壊れ方をします。

設定を書いたら、checks バンドルが Docker Hub から取れることを確認します。

実行コマンド(k8s-ops・developer):

$ mkdir -p /tmp/cfgtest
$ cp ~/app-src/fanclub-api/Dockerfile /tmp/cfgtest/Dockerfile
$ trivy config --checks-bundle-repository docker.io/aquasec/trivy-checks:2 /tmp/cfgtest/

実行結果(抜粋):

INFO	[checks-client] Need to update the checks bundle
INFO	[checks-client] Downloading the checks bundle...
234.65 KiB / 234.65 KiB [--------------------------------------] 100.00% 800.47 KiB p/s 500ms
INFO	Detected config files	num=1

234.65 KiB・1 秒未満です。 脆弱性 DB(転送 103.69 MiB)や Java DB(転送 902.85 MiB)と比べると、400 分の 1 以下の大きさのアーティファクトです。そして Falling back to embedded checksERROR が出ていません——これが「取れている」ことの証拠になります。

キー名を間違えてもエラーになりません。

Java DB のキーは db.java-repositorydb: の下・リスト)です。java-db: というトップレベルのキーを作ってその下に repository: を書くと、そのキーは黙って無視され、既定の mirror.gcr.io を見に行って FATAL になります。

2026-08-01T08:22:45+09:00	INFO	[javadb] Downloading artifact...	repo="mirror.gcr.io/aquasec/trivy-java-db:1"
2026-08-01T08:22:45+09:00	FATAL	Fatal error	... failed to download artifact from mirror.gcr.io/aquasec/trivy-java-db:1 ...

設定ファイルは、知らないキーを無視します。 「設定を書いたのに効かない」ときは、まず repo= のログで実際にどこを見に行ったかを確認してください。公式リファレンスの db: 配下は download-java-only / download-only / java-repository / java-skip-update / no-progress / repository / skip-update の 7 つで、java-db: というトップレベルキーは存在しません。 なお misconfiguration.checks-bundle-repository単一値で、リストではありません。

使い方はこうなります。

実行コマンド(k8s-ops・developer):

$ trivy --config ~/gitops/gitops-fanclub/trivy.yaml image k8s-registry:5000/fanclub-backend:0.4.0

trivy config--config は別物です。

サブコマンド名とグローバルフラグ名が同じで紛らわしいのですが、前者は「設定ファイルを検査する」、後者は「Trivy 自身の設定を読む」です。trivy config --config trivy.yaml ./base/ という並びは、「Trivy 自身の設定を trivy.yaml から読んだうえで、./base/ の設定ファイルを検査する」という意味になります。

設定の指定方法は 3 つあり、優先順位が決まっています。

順位指定方法
1(最優先)コマンドラインフラグ--db-repository docker.io/aquasec/trivy-db:2
2環境変数TRIVY_DB_REPOSITORY=docker.io/aquasec/trivy-db:2
3設定ファイルtrivy.yamldb.repository

カレントディレクトリに trivy.yaml があれば自動的に読まれます--config は場所を変えるためのフラグです)。

この「自動的に読まれる」には注意が要ります。 実機で確かめました。~/gitops/gitops-fanclubtrivy.yaml がある場所)で --config を付けずに実行すると、設定を読み込んだログ行(Loaded)が出ます。ところが /tmp へ移動して同じコマンドを打つと、その行は出ません。 設定は読まれず、既定の mirror.gcr.io を見に行きます。

つまり、同じコマンドでも cd した場所で挙動が変わります。 手元では通り、CI では落ちる(あるいはその逆)という食い違いの原因になります。スクリプトの中では --config を必ず明示してください。 演習④の scan-gate.sh はそうしています。

同じ性質の設定ファイルがもう 1 つあります。 抑制ファイル .trivyignore も、カレントディレクトリにあれば --ignorefile 無しで読まれます。こちらは検査結果の件数そのものが変わるため、影響がさらに大きくなります。実測は演習③のステップ 4 で示します。

環境変数ではなく trivy.yaml を推す理由は 1 つです。 リポジトリに置けば、誰が実行しても同じ配布元を見ます。 環境変数は実行する人の手元にしか無く、CI と手元で結果が変わる原因になります。

本番ガードレール③: --skip-db-update を CI で常用しないでください。

脆弱性 DB は上流で 6 時間ごとに作られ、クライアントは 24 時間キャッシュします。--skip-db-update は「取得済みのものをそのまま使う」であり、期限を過ぎていても取りに行きません。

オフライン環境で意図して使うのは正しい用法です。ところが「毎回ダウンロードすると遅いから」という理由で常用すると、新しい CVE が出ても永久に緑のままになります。 本書ラボでも、演習中に同じイメージを繰り返しスキャンするときだけ付けます。

やってみよう①:イメージの CVE を数え、第12回の予告を回収する

所要時間の目安: 8 分。4 つのイメージをスキャンして件数を比べます。作業はすべて k8s-ops で行います。

ステップ1:0.4.0(alpine ベース・現行)をスキャンする

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --scanners vuln k8s-registry:5000/fanclub-backend:0.4.0

実行結果(Report Summary 部分):

TargetTypeVulnerabilities
k8s-registry:5000/fanclub-backend:0.4.0 (alpine 3.23.5)alpine15
opt/payara/app.warjar3
opt/payara/payara-micro.jarjar16

Type 列は、その Target を何として解析したかを示します。 alpine は OS パッケージ、jar は Java アーカイブ、pom は Maven の依存定義、gobinary は Go でビルドされた実行ファイルです。Trivy は 1 つのイメージの中で複数の解析器を走らせ、Target ごとに結果を分けて出します。

OS 側の内訳はこうなっています。

Total: 15 (UNKNOWN: 0, LOW: 0, MEDIUM: 11, HIGH: 4, CRITICAL: 0)

jar 側は 2 つ合わせて 19 件(MEDIUM 10 / HIGH 9)です。OS 側 15 件 + jar 側 19 件で 34 件——これが 0.4.0 の全量になります。

ステップ2:0.3.2(Ubuntu ベース・第12回より前)をスキャンして比べる

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --scanners vuln k8s-registry:5000/fanclub-backend:0.3.2

実行結果(Report Summary 部分):

TargetTypeVulnerabilities
k8s-registry:5000/fanclub-backend:0.3.2 (ubuntu 26.04)ubuntu72
opt/payara/app.warjar3
opt/payara/payara-micro.jarjar16
usr/bin/pebblegobinary13

OS 側の内訳はこうです。

Total: 72 (UNKNOWN: 0, LOW: 6, MEDIUM: 66, HIGH: 0, CRITICAL: 0)

jar 側は 0.4.0 と同じ 19 件(MEDIUM 10 / HIGH 9)、usr/bin/pebble は 13 件(UNKNOWN 2 / MEDIUM 6 / HIGH 5)です。

全体の件数を severity 別に集計すると、こうなります。

ベースUNKNOWNLOWMEDIUMHIGH合計
0.3.2ubuntu 26.04268214104
0.4.0alpine 3.23.500211334

104 件から 34 件になりました。 第12回で述べた「イメージを小さくすると CVE の母数が減る」が、数字として確認できます。

ステップ3:差の中身を 3 つに分解する

件数が減ったことより、どこが減ったかが重要です。 上の 2 つの表を並べて読んでください。

観察意味
OS レイヤが 72 → 15 件ベースを替えた効果がそのまま出ています。第12回の「入っていないものにはパッチを当てなくて済む」の定量的な確認です
usr/bin/pebble(gobinary・13 件)が丸ごと消えたUbuntu の rockcraft ベースが同梱していたサービスマネージャで、本アプリは 1 度も使っていません。 使っていないものが CVE を生んでいました
jar 側の 19 件は 1 件も減っていないapp.war(3 件)と payara-micro.jar(16 件)はベースを替えても中身が同じです。第12回の「アプリ側の 98.7MB は 1 バイトも減りません」に対応します

ベースイメージを小さくすることで減るのは OS レイヤの CVE だけです。 アプリが持ち込んだ依存の CVE は、ベースを何に替えても 1 件も減りません。 それを直すにはアプリ側を直すしかありません——次の演習②がそれです。

ステップ4:frontend とそのベースも数える

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --scanners vuln k8s-registry:5000/fanclub-frontend:1.0.0
$ trivy image --skip-db-update --scanners vuln nginx:1.27-alpine

実行結果(Report Summary の Target 行):

イメージCVE 件数読み方
k8s-registry:5000/fanclub-frontend:1.0.0 (alpine 3.21.3)107静的 HTML / JS を足しただけ
nginx:1.27-alpine (alpine 3.21.3)(ベース)107frontend の土台

まったく同じ 107 件です。 frontend が自分で持ち込んだ CVE は 0 件で、107 件はすべてベースの nginx 由来ということになります。静的 HTML と JavaScript を COPY しただけなので、当然といえば当然です。

backend との違いに注目してください。 backend はアプリ層(pom.xml の依存)を自分で直せますが、frontend の 107 件に対して打てる手は「ベースを更新する」しかありません。 直す手段が 1 つしか無いイメージと、「依存を上げる」も選べるイメージでは、取れる対処が違います。

内訳も見てください。 frontend の 107 件は Total: 107 (UNKNOWN: 0, LOW: 26, MEDIUM: 45, HIGH: 34, CRITICAL: 2) です。CRITICAL が 2 件あります。 backend の 0.4.0 にも、このあと作る 0.4.1 にも、CRITICAL は 1 件もありません。

演習④で組むゲートは、この frontend を見ていません。

ステージ④の対象は fanclub-backend:0.4.1 の 1 つだけです。CRITICAL で落とす閾値にしてあるのに、CRITICAL を 2 件持っているイメージがゲートの対象外にいます。

ゲートは「何を通すか」だけでなく「何を見ているか」で決まります。 対象に入っていないものは、閾値をどれだけ厳しくしても止まりません。第12回の non-isolated-podbase/ に NetworkPolicy が無いから誤検知した)と裏表の話です——あちらは見ていないものを咎めた、こちらは見ていないものを見逃す。同じ「検査範囲」の問題が、逆向きに出ます。

本書ラボでは frontend をゲートに入れません。 打てる手がベースの更新だけで、それはイメージの作り直しを伴い、本回の主題(スキャンして仕分けてゲートにする)から外れるためです。ただし「入れていない」ことは知っておいてください。 実務では、ゲートの閾値と同じくらいゲートの対象一覧がレビュー対象になります。

「小さい」と「新しい」は別の話です。

第12回では frontend を 「21MB で既に小さい」として最小化の対象から外しました。 その 21MB のイメージが、いま 107 件を抱えています。backend の 0.4.0(493MB)が 34 件であることと並べると、件数はサイズに比例していません。

原因はベースの世代です。 backend の alpine は 3.23.5、frontend のベース nginx:1.27-alpine の alpine は 3.21.3 です。2 世代古いディストリを土台にしていました。 第12回はサイズを測っていたので、この差は視界に入っていません。サイズを見ていただけでは気づけない——だから測る軸を増やします。

本回では frontend のベース更新までは行いません(nginx のタグ選定とフロントエンドの動作確認が別の作業になるためです)。ただし「見つけた」ことは記録します。 見つけたのに記録しない指摘は、見つけていないのと同じです。

試験ではこう問われる。

指定されたイメージをスキャンし、HIGH 以上の脆弱性の件数を答えよ」「結果を指定のファイルに出力せよ」という粒度で出ます。試験相当の作業(スキャンして数える)なら 5 分です。

手が覚えているべきものは trivy image <image> の 1 コマンドと、--severity / --exit-code / --ignore-unfixed の 3 つです。trivy.dev は試験中に参照できません(CKS の Resources Allowed 8 件に含まれません)ので、フラグ名は暗記対象です。

試験環境では DB は取得済みの想定ですが、取得に時間がかかることがあります。 複数のタスクで Trivy を使うなら、最初のタスクで DB を取っておくと後半が速くなります。

出力を絞る —— --severity / --ignore-unfixed / --pkg-types / --format

演習①では 104 件・34 件という数が出ました。全部を毎回眺めるわけにはいきません。 目的別に絞る道具を揃えます。ここが演習④のゲート設計の下ごしらえです。

--severity —— スペースを入れない

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --severity HIGH,CRITICAL k8s-registry:5000/fanclub-backend:0.4.0

既定値は UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL(全部)です。

--severity HIGH, CRITICAL のように空白を挟むと動きません。

このフラグはカンマ区切りの文字列リストとして解釈されるため、空白の後ろは次の位置引数(スキャン対象)として扱われます。 「イメージ名を 2 つ渡した」ことになり、意図した絞り込みになりません。

試験でタイプする機会が多いフラグです。 スペースを入れない癖をつけてください。

--exit-code —— 付けなければ落ちない

公式ドキュメント(trivy.dev/latest/docs/configuration/others/)の記述です。

By default, Trivy exits with code 0 even when vulnerabilities are detected. Use the --exit-code option if you want to exit with a non-zero exit code.

脆弱性が検出されても、既定では 0 で終了します。 3 通り試して確かめます。

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --severity HIGH,CRITICAL k8s-registry:5000/fanclub-backend:0.4.0 > /dev/null; echo $?
$ trivy image --skip-db-update --severity HIGH,CRITICAL --exit-code 1 k8s-registry:5000/fanclub-backend:0.4.0 > /dev/null; echo $?
$ trivy image --skip-db-update --severity CRITICAL --exit-code 1 k8s-registry:5000/fanclub-backend:0.4.0 > /dev/null; echo $?

実行結果(終了コードのみ):

0
1
0
コマンド終了コード理由
--severity HIGH,CRITICAL--exit-code 無し)0HIGH が 13 件出ていても、既定では落ちません
--severity HIGH,CRITICAL --exit-code 11HIGH が該当するので落ちます
--severity CRITICAL --exit-code 100.4.0 に CRITICAL は 0 件です

第12回との違いが重要です。 Kubesec はスコアがマイナスのときに、指定しなくても非ゼロ(既定 2)を返しました。 KubeLinter も指摘が 1 件でもあれば非ゼロでした。Trivy は違います。明示しない限り落ちません。

同じ「CI ゲート」を組むのに、ツールごとに既定の姿勢が違います。 これを知らないと、組んだつもりのゲートが素通りします。

--ignore-unfixed —— 「無視」ではなく「後回し」

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --ignore-unfixed k8s-registry:5000/fanclub-backend:0.3.2

公式のヘルプは display only fixed vulnerabilities——修正版が公開されているものだけを表示します。

条件(0.3.2件数
なし104
--ignore-unfixed58

約 44% が「修正版がまだ無い」ものでした。 このフラグを付けた瞬間に、その 44% が表示から消えます。

減り方は層によって違います。 OS レイヤ(ubuntu 26.04)は 72 件から 26 件になりました。ディストリのパッケージほど「認識されているが修正版がまだ出ていない」ものが多いということです。

本番ガードレール④: --ignore-unfixed は「無視」ではなく「後回し」です。

いま直せないものを毎日眺めても手は動かないので、ゲートから外す判断そのものは妥当です。

ただし外したものを二度と見ないなら、修正版が出た日に気づけません。--ignore-unfixed 無しの結果を週に 1 度は見る」といった棚卸しの頻度をセットで決めてください。フラグを付けた瞬間に、44% の指摘が視界から消えます。

--pkg-types —— OS と言語パッケージを切り分ける

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --pkg-types os k8s-registry:5000/fanclub-backend:0.4.0
$ trivy image --skip-db-update --pkg-types library k8s-registry:5000/fanclub-backend:0.4.0

既定値は os,library(両方)です。演習①で読み取った「OS レイヤは減ったが jar は減っていない」を、フラグで機械的に分けられます。

指定(0.4.0件数出てくる Target
--pkg-types os15alpine のみ
--pkg-types library19app.warpayara-micro.jar の 2 つ

15 + 19 = 34 で、既定(両方)の件数と一致します。 数が合うことを確認しておくと、「絞り込んだつもりが取りこぼしていた」に気づけます。

DB が取れない環境での逃げ道。

--pkg-types os を指定すると Java インデックス DB が不要になります(jar を解析しないためです)。Java DB がどうしても取得できない環境では、OS レイヤだけでも数えられます。 実測では 2.9 秒で完走しました。

ただしこれは応急処置です。Java アプリのスキャンとしては片手落ち——本ラボでは jar 側だけで 19 件が出ています。「動くようにした」ことと「見えるようにした」ことは別です。

--format —— 誰に見せるかで変える

用途
table(既定)人が読む
json集計・スクリプト処理(jq で severity 別に数える)
sarifコード解析結果のビューア
cyclonedx / spdx-jsonSBOM 形式
template独自テンプレート
github / cosign-vuln各サービス向け

演習①の集計表は、この json 出力から作りました。

実行コマンド(k8s-ops・developer):

$ trivy image --skip-db-update --format json --output /tmp/backend-0.4.0.json k8s-registry:5000/fanclub-backend:0.4.0
$ jq -r '[.Results[].Vulnerabilities[]?.Severity] | group_by(.) | map({(.[0]): length}) | add' /tmp/backend-0.4.0.json

実行結果:

{
  "HIGH": 13,
  "MEDIUM": 21
}

jq は第2巻から /usr/bin/jq に入っています。出力した JSON は 497,083 バイトでした。人が読むには大きすぎ、機械が読むにはちょうどよい——この大きさの差が --format を使い分ける理由そのものです。

--format json のときに 1 行の警告が出ます。

WARN	Using severities from other vendors for some vulnerabilities.

severity は 1 つに決まっている値ではありません。 同じ CVE でも、ディストリのセキュリティチームと NVD とで評価が違うことがあります。Trivy はそのイメージの OS に対応する配布元の評価を優先し、無ければ他の配布元の値を使います——その「他から借りた」ものが混ざったときに出る警告です。

件数を報告するときは、どの基準の severity かを添えてください。 「HIGH が 13 件」は、道具と基準がそろって初めて比較可能な数になります。

Trivy 自身も --format cyclonedx / --format spdx-json で SBOM を出せます。 ただし第14回では bom を使います。 理由は CKS の Resources Allowed に kubernetes-sigs.github.io/bom/cli-reference/ が入っており、試験中に参照できる唯一の SBOM ツールが bom だからです。

本番ガードレール⑤: レポートを出す実行と、ゲートとして落とす実行は分けてください。

Trivy の公式 GitHub Action には「format: sarifoutput: を併用すると --exit-code が無視される」という報告が繰り返し上がっています。

1 回の実行に「見せる」と「落とす」を兼ねさせると、どちらかが静かに壊れます。 本ラボのシェルスクリプトでも同じで、--format json --output した実行の終了コードは当てにしません。 演習④のゲートは、この原則にしたがって 2 回実行する形にします。

やってみよう②:trivy fs でソースの依存を見て、pom.xml を直す

所要時間の目安: 12 分(Maven ビルドの待ち時間を含みます)。スキャンし、1 行直し、再ビルドし、再スキャンで消えたことを確認します。

ステップ1:trivy image では分からないことがある

演習①で opt/payara/app.war に 3 件の指摘が出ました。しかし app.war は Maven がビルドした成果物であり、直す場所はそこではありません。

実行コマンド(k8s-ops・developer):

$ trivy fs --skip-db-update --scanners vuln ~/app-src/fanclub-api

実行結果(Report Summary 部分):

TargetTypeVulnerabilities
pom.xmlpom3(すべて HIGH)

出てくる Target は pom.xml の 1 つだけです。 ~/app-src/fanclub-api の下には Dockerfileentrypoint.shsrc/ もありますが、--scanners vuln で見ているのは「依存を宣言しているファイル」だけだからです。

3 件はいずれも org.postgresql:postgresql(pgJDBC)42.7.4 に対するものです。

CVEFixed Version内容
CVE-2025-4914642.7.7channel binding における不安全な認証
CVE-2026-4219842.7.11SCRAM-SHA-256 認証での PBKDF2 反復回数が無制限になり、クライアント側の CPU が枯渇する DoS
CVE-2026-5429142.7.12SCRAM-SHA-256-PLUS から平文の SCRAM-SHA-256 へ静かにダウングレードされ、中間者防御が失われる

SCRAM-SHA-256 は PostgreSQL のパスワード認証方式、channel binding はその認証を TLS セッションに結びつけて中間者を防ぐ仕組みです。-PLUS が付いた方式名は、channel binding を使う版を指します。

imagefs の違い。

trivy image は「イメージの中に何が入っているか」を見ます。trivy fs は「何を入れると宣言しているか」を見ます。 app.war の中の JDBC ドライバは前者、pom.xmlpostgresql.version は後者です。直す場所は後者にしかありません。

そして fs はビルドする前に走らせられます。 CI では image より先に fs を回すほうが、速くフィードバックを返せます——ビルドに 3 分かかるなら、その 3 分を使う前に落とせます。

ステップ2:どのバージョンへ上げるかを決める

3 件の Fixed Version は 42.7.7 / 42.7.11 / 42.7.12 とばらばらです。 すべてを解消する最小の版は 42.7.12 です。本書は上流の最新パッチ 42.7.13(2026-07-06 リリース)を採ります。

Fixed Version の最大値で足りるのに、なぜ 1 つ先にするのか。 Fixed Version は「この版でその CVE が直った」という情報であって、「この版が今いちばん安全」という意味ではありません。42.7.12 を選ぶと、その次に出たパッチで直った問題を、1 つ残したまま出荷することになります。ツールの指摘に「ちょうど足りる」版を選ぶのは、ツールの視界に合わせて手を抜くことです。

逆に、メジャー / マイナーを飛び越えて上げるのは別の判断になります。ここで上げているのは 42.7.4 → 42.7.13 という同じマイナー内のパッチ更新であり、互換性の前提が変わらない範囲です。

ステップ3:pom.xml を 1 行直す

~/app-src/fanclub-api/pom.xml<properties> ブロックです(変更後)。

    <properties>
        <maven.compiler.release>25</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <postgresql.version>42.7.13</postgresql.version>
    </properties>

変えたのは 15 行目の 1 行だけです42.7.442.7.13)。この値は 40 行目の <version>${postgresql.version}</version> から参照されているので、依存の宣言そのものは触りません。

本番ガードレール⑥: セキュリティ修正は「今まで通っていた接続を通さなくする」方向に効くことがあります。

CVE-2026-54291 の修正内容は、channelBinding=require を指定した接続で、チャネルバインディングを確立できないときに接続を失敗させる(fail closed 化)というものです。

上流の Advisory は「影響を受けるのは channelBinding=require を設定した接続だけで、既定の prefer は影響しない」と明記しています。本ラボの JDBC 接続は channelBinding を設定していないので影響しない見込みですが、「見込み」で本番に出しません。

パッチ版の更新であっても、上げたら必ずアプリを起動して疎通まで確認してください。 本演習でもそこまでを手順に含めます(演習④のステップ 6)。

ステップ4:0.4.1 としてビルドし、push する

Dockerfile は 1 行も変えません。 第12回で確定した alpine 版のままです。変わるのは pom.xml の 1 行だけです。

~/app-src/fanclub-api/Dockerfile(第12回で確定した版・変更なし):

FROM maven:3.9-eclipse-temurin-25 AS build
COPY settings.xml /root/.m2/settings.xml
WORKDIR /app
COPY pom.xml .
RUN mvn -B --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn -B --no-transfer-progress package

# ubuntu:26.04 ベースから alpine ベースへ変更する
FROM eclipse-temurin:25-jre-alpine AS runtime
WORKDIR /opt/payara
ADD --chown=10001:0 https://repo.maven.apache.org/maven2/fish/payara/extras/payara-micro/7.2026.4/payara-micro-7.2026.4.jar payara-micro.jar
COPY --from=build --chown=10001:0 /app/target/fanclub-api.war app.war
COPY --chown=10001:0 --chmod=0755 entrypoint.sh entrypoint.sh
# alpine には useradd が無いので adduser を使う
RUN adduser -D -H -u 10001 -s /sbin/nologin appuser \
    && mkdir -p /opt/payara/tmp \
    && chown 10001:0 /opt/payara/tmp
USER 10001
EXPOSE 8080
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -Djava.io.tmpdir=/opt/payara/tmp"
ENTRYPOINT ["/opt/payara/entrypoint.sh"]

実行コマンド(k8s-ops・developer):

$ cd ~/app-src/fanclub-api
$ docker build -t k8s-registry:5000/fanclub-backend:0.4.1 .
$ docker push k8s-registry:5000/fanclub-backend:0.4.1
$ docker images

ビルドの所要は real 0m30.711s でした。第12回の 0.4.0(ベースイメージが pull 済みの状態で 8.108s)より長くかかります——pom.xml を書き換えたので、dependency:go-offline のレイヤキャッシュが効かず、依存の再解決が走るためです。42.7.13 は Maven Central から解決できました。

push 後のレジストリのタグ一覧です。

["0.2.0","0.3.0","0.3.1","0.3.2","0.4.0","0.4.1"]

6 個そろいました。既存の 5 個はそのままです。 docker images のサイズは DISK 493MB / CONTENT 166MB で、0.4.0 から 1 バイトも変わっていません。 変えたのは JDBC ドライバのパッチバージョンだけなので、これは想定どおりです。

なぜ 0.4.0 を作り直さず、0.4.1 にするのか。

理由は 3 つあります。

① 同じタグで中身を差し替えると、「そのタグを見た」という記録が意味を失います。 第12回で 0.4.0 は「DISK 493MB / CONTENT 166MB」として計測され、記事にも残っています。中身を変えて同じ名前で置き直すと、あとから 0.4.0 を pull した人が、記事と違うものを手にします。

② タグは動きます。ダイジェスト(sha256:...)は動きません。 これは第14回(Cosign による署名検証をダイジェスト指定で行う理由)の中心的な話です。本回で先に体験しておきます。

③ 比較ができなくなります。 0.4.00.4.1ベースイメージが同じ alpine で、違いは pom.xml の 1 行だけです。2 つをスキャンして比べれば、減った分がアプリ層に由来することを分離して示せます。 作り直してしまうと、この比較ができません。

ステップ5:消えたことを確認する

実行コマンド(k8s-ops・developer):

$ trivy fs --skip-db-update --scanners vuln ~/app-src/fanclub-api
$ trivy image --skip-db-update --scanners vuln k8s-registry:5000/fanclub-backend:0.4.1
確認期待実測
trivy fspom.xml3 件 → 0 件0 件
trivy imageopt/payara/app.war3 件 → 0 件0 件
trivy imageopt/payara/payara-micro.jar変わらない(Payara Micro 本体の依存は pom.xml で制御していません)16 件のまま
trivy image の OS レイヤ変わらない(ベースは同じ alpine です)15 件のまま

severity 別の集計でも確認できます。

jq の集計合計DISK / CONTENT
0.4.0{"HIGH":13,"MEDIUM":21}34493MB / 166MB
0.4.1{"HIGH":10,"MEDIUM":21}31493MB / 166MB

HIGH が 3 件減り、サイズは 1 バイトも変わりません。 第12回は「イメージを小さくして OS レイヤの CVE を減らす」でした。本回は「サイズを変えずにアプリ層の CVE を減らす」です。 減らし方が 2 通りあり、減らせる場所が違うことがこの 2 行に出ています。

ここまでで「スキャンして終わり」ではなく「直した」ところまで到達しました。 クラスタへの反映は演習④で行います(GitOps 経由です)。反映後に https://fanclub.local/api/members が 200 を返すところまでが本演習の完了条件です——理由はガードレール⑥に書いたとおりで、pgJDBC の修正は fail closed 方向に効きます。

試験ではこう問われる。

指定されたディレクトリをスキャンし、脆弱な依存を修正済みバージョンへ更新せよ」という粒度です。試験相当の作業(スキャンして依存のバージョンを上げる)なら 5 分です——docker build までは求められないことが多く、「どのファイルのどの行を直すか」まで到達できれば足ります。

手が覚えているべきものは trivy fs <dir> と、出力の Installed Version / Fixed Version の 2 列を読むことです。trivy repo ではなく trivy fs を使ってください——公式が「CI/CD ではローカルプロジェクトに trivy fs を使え」と明記しています。

やってみよう③:trivy config で Dockerfile と Kubernetes マニフェストを検査する

所要時間の目安: 12 分(仕分けの検討を含みます。試験相当の作業=「trivy config を実行して指摘を読む」だけなら 5 分です)。2 種類の設定ファイルを検査し、指摘を 3 つに仕分けます。

ステップ1:Dockerfile を検査する

実行コマンド(k8s-ops・developer):

$ trivy config ~/app-src/fanclub-api

実行結果(抜粋):

Tests: 27 (SUCCESSES: 26, FAILURES: 1)
Failures: 1 (UNKNOWN: 0, LOW: 1, MEDIUM: 0, HIGH: 0, CRITICAL: 0)

DS-0026 (LOW): Add HEALTHCHECK instruction in your Dockerfile

退避ファイルも検査されます。

出力の冒頭は Detected config files num=2 です。Dockerfile だけでなく、第12回で退避した Dockerfile.bak-12 も Dockerfile として検出されています。 そのため 同じ DS-0026 の指摘が 2 回出ます。

trivy config は拡張子ではなく中身で種別を判定します.bak-12 という拡張子は目印になりません)。「使っていないファイル」という人間の都合は、ツールには見えません。

対処は 3 つあります。①退避ファイルを検査対象のディレクトリの外へ移す ②--skip-files / --skip-dirs で除く ③そのまま受け入れる。本書ラボは③のまま進めます——同じ ID の重複であり、抑制の判断も 1 回で済むためです。ただし「なぜ 2 件出るのか」を説明できないまま進めないでください。 なお、演習②で pom.xml を退避しても件数は変わりません(依存定義ファイルは誤設定スキャンの守備範囲ではないためです)。

27 個のチェックのうち 26 個を通っています。 これは第12回で USER 10001 / multi-stage / COPY --chown を整えた成果がそのまま出ています。第12回でやったことが、別のツールで測っても正しかったことが確認できます。

残る 1 件(HEALTHCHECK が無い)については判断が要ります。 Kubernetes 上で動かすコンテナには livenessProbe / readinessProbe / startupProbe が既にあります(base/backend-deployment.yaml)。Dockerfile の HEALTHCHECK は Docker / Compose 向けの仕組みで、kubelet はこれを見ません。 つまり本ラボの文脈では二重管理になります。 この 1 件はステップ 4 で抑制の素材にします。

ステップ2:Kubernetes マニフェストを検査する

実行コマンド(k8s-ops・developer):

$ trivy config ~/gitops/gitops-fanclub/base/

実行結果(ファイル別の件数):

TargetTypeMisconfigurations
backend-deployment.yamlkubernetes6(LOW 5 / MEDIUM 1)
configmap.yamlkubernetes1(MEDIUM 1)
db-sealedsecret.yamlkubernetes1(LOW 1)
frontend-deployment.yamlkubernetes6(LOW 4 / MEDIUM 2)
resourcequota.yamlkubernetes0
services.yamlkubernetes2(LOW 2)

16 件の全量です。 演習④の閾値設計に直結するので、1 件残らず並べます。

ファイルIDSeverityTitle
backend-deployment.yamlKSV-0021 ×3LOWRuns with GID <= 10000
backend-deployment.yamlKSV-0040LOWresource quota usage
backend-deployment.yamlKSV-0110LOWWorkloads in the default namespace
backend-deployment.yamlKSV-0125MEDIUMRestrict container images to trusted registries
configmap.yamlKSV-01010MEDIUMConfigMap with sensitive content
db-sealedsecret.yamlKSV-0040LOWresource quota usage
frontend-deployment.yamlKSV-0020LOWRuns with UID <= 10000
frontend-deployment.yamlKSV-0021LOWRuns with GID <= 10000
frontend-deployment.yamlKSV-0040LOWresource quota usage
frontend-deployment.yamlKSV-0110LOWWorkloads in the default namespace
frontend-deployment.yamlKSV-0117MEDIUMPrevent binding to privileged ports
frontend-deployment.yamlKSV-0125MEDIUMRestrict container images to trusted registries
services.yamlKSV-0040 ×2LOWresource quota usage
resourcequota.yaml0 件

severity 分布は {"LOW": 12, "MEDIUM": 4} で、HIGH 以上は 1 件もありません。 backend-deployment.yamlKSV-0021 が 3 件あるのは、この Deployment にコンテナが 3 つ(wait-for-db / log-shipper / backend)あるためで、コンテナごとに 1 件ずつ出ています。 services.yaml の 2 件も Service が 2 つあるためです。

通ったチェックの数も見てください。

ファイルTestsSUCCESSES / FAILURES
backend-deployment.yaml10498 / 6
configmap.yaml103102 / 1
db-sealedsecret.yaml102101 / 1
frontend-deployment.yaml10296 / 6
services.yaml103101 / 2

1 ファイルあたり 100 件超のチェックが当たっています。 Dockerfile の 27 件と比べると 4 倍近くあり、Kubernetes マニフェストのほうが Trivy のルールが厚いことが分かります。16 件という数字は「100 件超 × 6 ファイルのうちの 16」です——分母を見ずに分子だけを見ると、必要以上に悪く見えます。

合計 16 件です。 第12回で Kubesec を 13 点まで上げ、KubeLinter を 0 件にしたマニフェストに、3 つ目の道具を当てるとまだ指摘が出ます。

第12回の結論は「2 つ当てても両方の盲点は残る」でした。3 つ目を当てても同じことが起きます。 これは道具が悪いのではなく、どの道具も「自分のルールセットが見ている範囲」しか見ていないというだけです。

道具を増やせば増やすほど指摘は増えます。 だから「全部を 0 にする」を目標にすると、いつまでも終わりません。必要なのは、増えた指摘を仕分ける基準です。

ステップ3:指摘を 3 つに仕分ける

すべてを直すのでも、すべてを無視するのでもありません。 1 件ずつ次の 3 つに分類します。

分類判断基準記録の場所
A. 直すマニフェストを変えれば解消し、変えても他の防御を壊さないものGit のコミット
B. 抑制する本ラボ / 本番の構成上、意図してそうしているもの。他のレイヤに代替の防御があるもの.trivyignore に理由コメント + exp: 期限
C. 残す直したいが今は手をつけられないもの。ゲートの閾値の外に置く(LOW 等)ゲートの --severity 設定 + まとめでの言及

仕分けるには、ID と Title だけでは足りません。 判断の材料になるのは Message の本文です。4 件を全文で読んでください。

IDMessage(全文)
KSV-01010ConfigMap 'fanclub-config' in 'default' namespace stores sensitive contents in key(s) or value(s) '{"API_PORT", "DB_PORT"}'
KSV-0125Container backend in deployment fanclub-backend (namespace: default) uses an image from an untrusted registry.
KSV-0110deployment fanclub-backend in default namespace should set metadata.namespace to a non-default namespace
KSV-0040A resource quota policy with hard memory and CPU limits should be configured per namespace

3 件の偽陽性を読み解く

16 件のうち 3 種類は、本ラボの実態と食い違っています。 1 つずつ理由が違うので、順に読みます。

KSV-01010(MEDIUM)—— キー名だけで「機密」と判定しています。 機密扱いされたのは API_PORTDB_PORT の 2 つです。どちらもポート番号であり、秘密ではありません。 名前に PORT を含むことがルールの発火条件になっています。本アプリのパスワードは fanclub-db-secret第9回の SealedSecret 由来)にあり、ConfigMap には 1 件も入っていません。 つまり「本当に危ないもの」は正しく別の場所に置かれているのに、置かれていない場所が指摘されています。

KSV-0110(LOW)—— base/namespace: が書かれていないだけです。 クラスタ上の Deployment は fanclub namespace にいます。namespace を付けているのは overlays/prod 側の Kustomize であり、base/ だけを読んだツールには default に見えます。

これは第12回で Kubesec が Deployment/fanclub-backend.default と表示したのとまったく同じ構図です。 道具が替わっても、「ディレクトリの中しか読まない静的解析は、Kustomize の合成結果を知らない」という制約は同じところに出ます。

KSV-0040(LOW・5 件)—— 隣のファイルを見ていません。 「namespace ごとに ResourceQuota を設定せよ」という指摘ですが、resourcequota.yaml は同じ base/ ディレクトリにあり、しかもそのファイル自身は 0 件で通っています。 それどころか services.yaml(Service を 2 つ書いただけのファイル)にまで同じ指摘が出ます。

これも第12回で見た形です。 KubeLinter の non-isolated-pod(NetworkPolicy が別管理なのに「隔離されていない」と出た)と non-existent-service-account(ServiceAccount が Git の外にあるのに「存在しない」と出た)に続く3 例目で、いずれも「ファイル単位・ディレクトリ単位でしか見ていない」ことに由来します。

偽陽性を見抜くのに必要なのは、ツールの知識ではなく環境の知識です。

KSV-0110 を偽陽性だと判断するには Kustomize の overlay が namespace を付けていることを、KSV-01010 なら パスワードが SealedSecret 側にあることを、KSV-0040 なら resourcequota.yaml が既にあることを知っている必要があります。

だから「指摘を全部信じる」も「指摘を全部無視する」も、どちらも仕事になりません。 静的解析の出力は環境を知っている人が読んで初めて情報になります。 ツールを入れれば安全になるのではなく、ツールの出力を読める人がいて初めて安全に近づきます。

3 つ目の道具も runAsUser: 101 を指した

KSV-0020(Runs with UID <= 10000)は frontend の runAsUser: 101 に対する指摘です。 第12回で Kubesec の RunAsUser ルールが同じ場所を指し、「それでも変えない」と判断した箇所です。

3 つ目の道具も、同じところを指しました。 複数の道具が独立に同じ場所を挙げるのは、その箇所が一般論としては確かに推奨から外れていることを意味します。指摘そのものは正しいのです。

それでも変えません。 理由は第12回と 1 文字も変わりません——nginx:1.27-alpine の nginx ユーザは UID 101 であり、イメージ内のファイルの所有者もそれに合わせて作られています。 スコアを上げるために外から UID を書き換えると、次のベースイメージ更新で前提が変われば壊れます。 直すなら変えるのはマニフェストではなくイメージ側です。

ここで押さえてほしいのは、「多数決で判断を変えない」ということです。 道具が 1 つでも 3 つでも、それぞれが同じルールセットの系譜から同じ一般論を言っているだけで、本ラボの事情を知って言っているわけではありません。指摘の数は、判断を覆す根拠になりません。 覆す根拠になるのは、「変えたら何が壊れるか」を確かめた結果だけです(第12回で使い捨て Pod を作って確かめたのがそれです)。

指摘を A / B / C に割り当てる

base/ の 16 件に、ステップ 1 で見た Dockerfile 側の DS-0026(2 件)を加えた 18 件が対象です。

ID件数Severity分類理由
DS-00262LOWBHEALTHCHECK は Docker / Compose 向けで kubelet は読みません。probe 3 種が同じ役割を果たしています
KSV-01252MEDIUMBk8s-registry:5000 は社内レジストリです。許可レジストリの強制は第14回で Admission 側に実装します
KSV-010101MEDIUMBAPI_PORT / DB_PORT は機密ではありません。パスワードは fanclub-db-secret にあります
KSV-01171MEDIUMBfrontend の containerPort: 80 は nginx イメージの前提です。runAsUser と同じ理由で、外から書き換えません
KSV-00214LOWCGID の指摘。イメージ側の前提に合わせています
KSV-00405LOWC偽陽性③。 resourcequota.yaml は既にあります
KSV-01102LOWC偽陽性②。 namespace は overlay が付けています
KSV-00201LOWC第12回で「変えない」と判断した runAsUser: 101

A(直す)に入るものは 1 件もありませんでした。 手を抜いたのではなく、直せるものは第12回で直し終えているからです。securityContext も probe も resources も serviceAccountName も、そのときに入れました。3 つ目の道具が新しく見つけたのは、「直せないもの」と「直さないと決めたもの」だけだった、というのが実測の結果です。

base/ の 16 件の内訳は、B が 4 件・C が 12 件です。 B の 4 件は MEDIUM 4 件そのもので、これを 1 件残らず B に振り分けないと、演習④のステージ③が緑になりません。 DS-0026 は LOW なのでゲートの閾値の外にありますが、「Dockerfile 側の方針も同じファイルに書き残す」ために抑制の対象に加えます。 結果として .trivyignore に書く ID は 4 種類になります。

仕分けには順序があります。 「直せるものは直す」を先に済ませてから、残ったものだけを抑制の対象にしてください。順序を逆にする——つまり全部を .trivyignore に入れてから直せるものを探す——と、抑制した行が積み上がったまま誰も見返さなくなります。 第12回で .kube-linter.yamlexclude を 1 件だけに絞ったのも同じ理由です。

ここで仕分けた結果が、演習④のゲートを緑にできるかどうかを決めます。 分類の練習で終わらせないでください。

ステップ4:.trivyignore を書く

~/gitops/gitops-fanclub/.trivyignore

# DS-0026: HEALTHCHECK は Docker / Compose 向けの仕組みで kubelet は参照しない。
# base/backend-deployment.yaml の startupProbe / livenessProbe / readinessProbe
# の 3 つで同等以上の監視を行っている。
# 2027-02-01 に、Kubernetes 以外でこのイメージを動かす予定が無いかを再確認する。
DS-0026 exp:2027-02-01

# KSV-0125: k8s-registry:5000 は社内レジストリであり、素性の分からない外部レジストリではない。
# 許可レジストリの強制は第14回で Admission 側に実装する。
# 2027-02-01 に、Admission 側の実装で置き換えられているかを確認する。
KSV-0125 exp:2027-02-01

# KSV-01010: API_PORT / DB_PORT はポート番号であって機密ではない。
# キー名に PORT を含むことだけで機密と判定されている。
# パスワードは fanclub-db-secret(SealedSecret 由来)にあり ConfigMap には無い。
# 2027-02-01 に、ConfigMap へ機密が混入していないかを再確認する。
KSV-01010 exp:2027-02-01

# KSV-0117: frontend の containerPort: 80 は nginx:1.27-alpine 側の前提。
# イメージが前提とする設定を外から書き換えない(runAsUser: 101 と同じ理由)。
# 2027-02-01 に、非特権ポートで待ち受けるベースイメージへ移せるかを検討する。
KSV-0117 exp:2027-02-01

4 件書きます。ステップ 1 で見た DS-0026 の 1 件だけでは足りません。 抑制するのは MEDIUM の 3 種類(KSV-0125 / KSV-01010 / KSV-0117)と、Dockerfile 側の DS-0026 です。MEDIUM を 1 種類でも書き漏らすと、演習④のステージ③が赤のままになります。

書式は公式(trivy.dev/latest/docs/configuration/filtering/)のとおりです。

書式意味
# 始まりの行コメント。理由を書く場所
ID を 1 行 1 件CVE ID / 誤設定 ID(KSV-0110 / DS-0026 形式)/ Secret ルール ID
<ID> exp:YYYY-MM-DD期限付き抑制。その日を過ぎると再び検出されます

ID は出力に出る形で書いてください。

base/--format json でスキャンして ID だけを抜き出すと、次の 7 種類になります。

KSV-0020
KSV-0021
KSV-0040
KSV-01010
KSV-0110
KSV-0117
KSV-0125

接頭辞 AVD- は付きません(JSON の AVDID フィールドは null です)。Dockerfile 側も DS-0026 です。実測では AVD-KSV-0110 と書いても抑制は効きます(どちらでも 16 件が 14 件になりました)。それでも出力に出る形で書いてください——.trivyignore の行を grep で出力と突き合わせるとき、表記が揃っていないと照合できません。

適用を確認します。

実行コマンド(k8s-ops・developer):

$ cd ~/gitops/gitops-fanclub
$ trivy config --ignorefile ./.trivyignore --format json ./base/ | jq -r '[.Results[].Misconfigurations[]?.Severity] | group_by(.) | map({(.[0]): length}) | add'

実行結果:

{
  "LOW": 12
}

16 件が 12 件になり、残りはすべて LOW です。 これで --severity MEDIUM,HIGH,CRITICAL --exit-code 1 という閾値が成立します。仕分けの結果が、そのまま閾値設計の前提になっています。

exp: は期限が切れると抑制が外れる

「期限を書けば再検出される」を実際に確かめます。 KSV-0110 1 件だけを書いたファイルで、日付を変えて比べました。

.trivyignore の内容base/ の件数
(抑制なし)16
KSV-011014
KSV-0110 exp:2027-02-01(コメント付き)14
KSV-0110 exp:2020-01-01(期限切れ)16

期限が過ぎた行は、書いてあっても効きません。 ファイルを書き換えなくても、その日を境に指摘が自分から戻ってきます。 「あとで見直す」を人の記憶ではなく仕組みに載せられるのが、この 1 語の値打ちです。

逆に言えば、期限を書いた日は必ず来ます。 ゲートが突然赤くなったときに「なぜ赤いのか」を説明できるよう、期限の隣には必ず「何を確認したら外す / 延ばすのか」を書いてください。

実行位置で結果が変わる —— .trivyignore の自動読み込み

ここが本回でいちばん事故につながりやすい挙動です。 同じコマンドを 3 通りの条件で実行した結果です。

実行位置--ignorefiletrivy config base/ の件数
~/gitops/gitops-fanclub.trivyignore がある)付けない12(自動で抑制されました)
/tmp.trivyignore が無い)付けない16
/tmp明示12

--ignorefile を書いていないのに 12 件になっています。 カレントディレクトリの .trivyignore が自動的に読まれているためです。そして cd しただけで 16 件に戻ります。 コマンドも対象も 1 文字も変えていないのに、結果が変わります。

手順 6 で見た trivy.yaml とまったく同じ性質です。 Trivy はこの 2 つをカレントディレクトリから自動で拾います。

本番ガードレール⑦: CI では --config--ignorefile を必ず明示してください。

自動読み込みは手元で打つぶんには便利ですが、CI ではジョブの作業ディレクトリがどこになるかを常に把握できるとは限りません。

起きるのは「開発者の手元では緑、CI では赤」あるいはその逆です。 後者のほうが危険で、誰も書いた覚えのない抑制が効いた状態でゲートが緑になります。 どちらの場合も、原因がコマンドラインに現れないので追いにくくなります。

第12回の .kube-linter.yaml とは逆の性質です。 あちらは --config を付けないと設定が読まれず、「効いていない」ことが指摘件数の増加として見えました。 Trivy は付けなくても読まれるので、「効いてしまっている」ことに気づけません。 設定ファイルの探し方は、ツールごとに違います。

--ignorefiletrivy image でも効きます。

抑制ファイルは誤設定 ID 専用ではなく、CVE ID も同じ書式で書けます。 0.4.1 に対して CVE-2026-56131 を 1 行だけ書いたファイルを渡すと、こうなります。

条件(0.4.1件数
抑制なし31
--ignorefile あり30

1 件減りました。 ただし本書ラボの .trivyignore に CVE は書きません。 CVE の抑制は「修正版が出ても抑制が残る」という別の危うさを持ち込むためで、CVE 側は --ignore-unfixed と閾値で扱います(演習④)。同じファイルに書けることと、書くべきことは別です。

構造化された抑制を書ける .trivyignore.yaml もありますid / paths / purls / expired_at / statement)。ただし EXPERIMENTAL であり、--ignorefile .trivyignore.yaml で明示的に指定しないと読まれません。 本書は素の .trivyignore を使います。

第12回との接続を確認してください。 第12回で .kube-linter.yamlexclude に 3 行のコメントで理由を書きました。.trivyignore でも同じことをします。違いは 1 つだけで、Trivy には期限(exp:)があります。

理由の書かれていない除外は、半年後には誰も外せなくなります。 期限があれば、外すかどうかを考え直す日が自動的にやってきます。 第12回の exclude にも、本当は「いつ見直すか」を書いておくべきでした——設定ファイルの書式が、運用の質を決めることがあります。

本番ガードレール⑧: .trivyignore に期限なしの行を書かないでください。

「いま直せないので抑制する」は正しい判断ですが、期限を書かないと「抑制した」という事実だけが残り、判断が凍結されます。 exp: が無い行は、書いた人が異動したら誰も触れなくなります。

抑制するときは「いつまで」「何を確認したら外すか」をコメントに残します。これが Understand your supply chain の実務そのものです——サプライチェーンの管理は、ツールの出力を消すことではなく、消した理由を追跡可能にすることです。

試験ではこう問われる。

指定されたディレクトリの Dockerfile / マニフェストを検査し、指摘された誤設定を修正せよ」という粒度です。手が覚えているべきは trivy config <dir> の 1 コマンドと、trivy config は「設定ファイル」を見るのであって CVE は見ないという守備範囲です。

--scanners misconfigtrivy imagetrivy fs に付けても誤設定スキャンはできますが、設定ファイルだけを見るなら config が短くて済みます。 trivy.dev は試験中に参照できないので、DS- / KSV- といったチェック ID の接頭辞と、代表的な指摘の意味は暗記対象です。

同じ道具でも失敗の作法が違う —— FATAL と埋め込み checks

本回の 2 つ目の山です。 演習③で trivy config が動きました。ここで 1 つ確かめておくことがあります。 準備の節で見たとおり、trivy image は DB が取れないと FATAL で止まりました。trivy config も同じように止まるのでしょうか。

実験: checks バンドルのキャッシュを消して、既定のまま実行する

実行コマンド(k8s-ops・developer):

$ rm -rf ~/.cache/trivy/policy
$ trivy config ~/app-src/fanclub-api

ここでは --config ~/gitops/gitops-fanclub/trivy.yaml を渡しません。 既定の配布元(mirror.gcr.io)を見に行かせるためです。手順 6 で作った trivy.yaml を渡すと Docker Hub から取れてしまい、この実験になりません。

消しているのは checks バンドルのキャッシュだけです。

脆弱性 DB(~/.cache/trivy/db)と Java DB(~/.cache/trivy/java-db)は消しません。消すと再取得に時間がかかります。

同じことは trivy clean --checks-bundle でもできます。 trivy clean のオプションは v0.70.0 では次の 5 つで、消したいものを 1 つずつ選ぶ形になっています。

      --checks-bundle   remove checks bundle
      --java-db         remove Java database
      --scan-cache      remove scan cache (container and VM image analysis results)
      --vex-repo        remove VEX repositories
      --vuln-db         remove vulnerability database

--all というオプションはありません。 全部消したいときは 5 つを並べて指定します。消す前にキャッシュの内訳を見てください。 本ラボの実測は db1.2Gjava-db1.4Gpolicy(checks バンドル)が 2.8M です。取り直しに時間がかかるのは前の 2 つで、この実験で消す policy は 1 秒未満で戻せます。

実行結果(先頭):

2026-08-01T11:13:16+09:00	INFO	[misconfig] Misconfiguration scanning is enabled
2026-08-01T11:13:16+09:00	INFO	[checks-client] Need to update the checks bundle
2026-08-01T11:13:16+09:00	INFO	[checks-client] Downloading the checks bundle...
2026-08-01T11:13:16+09:00	ERROR	[misconfig] Falling back to embedded checks	err="failed to download checks bundle: download error: OCI repository error: 1 error occurred:\n\t* Get \"https://mirror.gcr.io/v2/\": Forbidden\n\n"
2026-08-01T11:13:17+09:00	INFO	Detected config files	num=2

ERROR と出ているのに、処理は続き、結果も出ます。 trivy image のときは 0.058 秒で FATAL でした。同じツールで、同じプロキシに、同じホストへ、同じ 403 で弾かれているのに、挙動が違います。

これは仕様です

公式ドキュメント(trivy.dev/latest/docs/scanner/misconfiguration/check/builtin/)の記述です。

Trivy checks for updates to OPA bundle on GHCR every 24 hours and pulls it if there are any updates.

The Checks bundle is also embedded in the Trivy binary (at build time), and will be used as a fallback if Trivy is unable to download the bundle.

バグでも副作用でもありません。 ネットワークが無い環境でも誤設定スキャンができるように、バイナリにチェック一式が焼き込まれています。

3 つの DB の失敗の作法

データベース既定の取得先取得できないときの挙動
脆弱性 DBmirror.gcr.io →(ghcr.ioFATAL で即死(403 ではフォールバックしません)
Java インデックス DB同上FATAL で即死
checks バンドルmirror.gcr.io のみ(複数指定は不可)ERROR を出して埋め込み版で継続

「エラーが出たのに結果が出ている」ときは、その結果が何を根拠に出たのかが変わっています。

埋め込み checks は Trivy をビルドした時点のルールです。本ラボの v0.70.0 は 2026-04-17 リリースなので、その日以降に追加されたチェックは効きません。 一方、バンドルを取得できていれば公式が 24 時間ごとに更新確認をしている最新のルールで検査されます。

CI では致命的な差になります。 ログを流し読みしていると、「trivy config は通った」と報告されるものの、実際には 4 か月前のルールで検査していたということが起きます。ERROR の行を CI の警告として拾う設計が要ります。

--checks-bundle-repository の既定値。

実機 v0.70.0 の --help の該当行はこうです。

      --checks-bundle-repository string   OCI registry URL to retrieve checks bundle from (default "mirror.gcr.io/aquasec/trivy-checks:2")

タグは :2 です。公式 CLI リファレンス(latest = v0.72 系)は :1 と表記しています。本書は実機 v0.70.0 の値で書きます。 バンドルのメジャー版は変わりうるので、自分の環境の --help を確認してください。

第12回・第2回との接続

第12回で Kubesec v2.14.2 が securityContext.seccompProfile を採点しないことを確認しました(削除済みのアノテーションを見ていました)。第2回では kube-bench が enable_selinux = false を FAIL にしないことを確認しました。本回は 3 例目です。

ただし性質が違います。 第12回と第2回は「ツールのルールが古い / 足りない」でしたが、本回は「ルールは新しいものがあるのに、取りに行けなくて古いほうが使われた」です。ツールを選び直しても直りません。経路を直すしかありません。

本番ガードレール⑨: CI のログで ERROR を素通りさせないでください。

終了コードだけを見るゲートは、「エラーを出しながら成功で返るツール」を検出できません。

trivy config の出力を grep -q 'Falling back to embedded checks' して、該当したらゲートを警告扱いにする——といった 1 行を足せるかどうかで、ゲートの信頼性が変わります。 「終了コードが 0 だった」は「検査が意図どおりに行われた」ではありません。

本書ラボの scan-gate.sh には、この grep を入れません。 trivy.yaml で配布元を Docker Hub に固定してあるので、本ラボでは Falling back to embedded checks が出ない状態を作ってあります。 出ないはずのものを毎回 grep するより、「なぜ出ないのか」を説明できることのほうが先です。

ただし本番の CI では話が変わります。 配布元が変わる・プロキシの設定が変わる・ネットワークが一時的に切れる——「出ないはず」が崩れる経路がいくつもあります。 そのときに気づけるのは、終了コードではなくログを見ている設計だけです。

trivy k8s —— クラスタ丸ごとを対象にする(記法の紹介)

4 つ目のサブコマンドです。記法だけ紹介します。本書ラボでは実行しません。 理由はこの節の最後に書きます。

何ができるのか

trivy k8s稼働中のクラスタに接続して、3 つのカテゴリを横断的に検査します。

カテゴリ対象の例
クラスタインフラAPI Server / kubelet / addon(ノードコンポーネント
クラスタ設定Role / ClusterRole
アプリケーションワークロードDeployment / Pod が使っているイメージ

検出するものは脆弱性 / 誤設定 / 露出した Secret です。出力には NodeComponents/<node> というノードコンポーネント単位の行が出ます。

記法

意味
trivy k8s [flags] [CONTEXT]CONTEXT 省略時は kubeconfig の current-context
trivy k8s --report summary要約レポート
trivy k8s --report all --severity CRITICAL全量 + severity 絞り込み
--include-namespaces / --exclude-namespaces対象 namespace の絞り込み
--include-kinds / --exclude-kindsリソース種別の絞り込み
--skip-imagesイメージの pull とスキャンを省く
--disable-node-collectorノード上に収集用 Job を起動しない
--complianceコンプライアンスレポート

本書ラボでは実行しない —— その判断

理由は 3 つあります。

  1. 公式が EXPERIMENTAL と明記しています。 「This feature might change without preserving backwards compatibility.」——記法が変わりうる機能を暗記対象にするのは割に合いません
  2. --disable-node-collector を付けない限り、ノード上に収集用の Job を起動します。 本ラボは第7回でノードの外部通信を絞り、第8回fanclub に PSA restricted を enforce しています。検査のために環境へ手を入れることになります
  3. CKS が問うのは image / config / fs です。 クラスタ全体の状態を見る手段は、本巻では kube-bench(第2回監査ログ(第16回)6 面の観測(第17回)が担当します

ただし「使えない機能」として片づけません。 クラスタ全体を 1 コマンドで棚卸しできるのは実務では強力で、とくに「どのノードコンポーネントに CVE があるか」は kube-bench では分かりません。 第17回で観測手段を並べるとき、Infrastructure 面の選択肢の 1 つとして再登場します。

「入れない判断」はこれで 4 例目になります。 第10回では gVisor を「代償を数えて撤去」し、第11回では Cilium の mutual authentication を「記法のみ」に留め、第12回では distroless を「実証して採用しない」と判断しました。CKS が問うのは個々の技術を全部入れることではなく、入れない判断を根拠つきで説明できるかどうかです。

試験ではこう問われる。

trivy k8s が問われる可能性は低いと考えてください。CKS の設問は「与えられたイメージ / ファイルを検査せよ」という形が中心で、クラスタ全体スキャンは時間がかかりすぎて 5〜12 分の設問に収まりません。

覚えておくのは「そういうサブコマンドがある」ことと、EXPERIMENTAL であることだけで足ります。

やってみよう④:ゲートを 4 ステージにし、閾値と例外を設計する

所要時間の目安: 12 分(手を動かす時間。これとは別に ArgoCD の収束待ちが約 10 分かかりますが、待っている間は記事の続きを読んでいて構いません)。スクリプトを拡張し、閾値を決め、Git に push して反映を確認します。試験相当の作業(スキャン結果を見て閾値付きのコマンドを組み立てる)なら 5〜8 分です。

ステップ1:第12回のゲートを確認する

~/gitops/gitops-fanclub/scan-gate.sh(第12回で作った 2 段版・567 バイト):

#!/bin/bash
# 静的解析ゲート。1 つでも失敗があれば非ゼロで終了する。
# set -e は使わない(理由は本文)。
set -uo pipefail
shopt -s nullglob

BASE_DIR="$(dirname "$0")/base"
CONFIG="$(dirname "$0")/.kube-linter.yaml"
FAILED=0

echo "=== kubesec ==="
for f in "${BASE_DIR}"/*-deployment.yaml; do
  if ! kubesec scan "$f"; then
    echo "KUBESEC FAILED: $f"
    FAILED=1
  fi
done

echo "=== kube-linter ==="
if ! kube-linter lint --config "${CONFIG}" "${BASE_DIR}"; then
  echo "KUBELINTER FAILED"
  FAILED=1
fi

exit "${FAILED}"

実行コマンド(k8s-ops・developer):

$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(末尾):

gate exit=0

第12回のゲートは緑のままです。 ここに Trivy を足したときに赤くなるなら、それは第12回の 2 つの道具が見ていなかった範囲ということになります。足す前に緑であることを確認しておくと、あとで色が変わった理由を 1 つに絞れます。

ステップ2:何を足すかを設計する

「Trivy を足す」だけでは設計になりません。Trivy には守備範囲の違う 2 つがあります。

ステージ道具対象見るもの
kubesecbase/*-deployment.yamlsecurityContext の点数
kube-linterbase/設計上の抜け
trivy configbase/ と Dockerfile誤設定(IaC)
trivy imagefanclub-backend:0.4.1CVE

道具は 3 つ、ステージは 4 つになります。①〜③はマニフェストや Dockerfile というテキストを見る静的解析、④だけがビルド済みのイメージの中身を見ます。 第12回の「4 段の防御」の図でいえば、①〜④はすべて「apply する前」に収まっています。

4 ステージのゲートと trivy config の指摘 16 件の仕分けを示す図。上段はステージ表で、①kubesec(base/ の Deployment・securityContext の点数)②kube-linter(base/・設計上の抜け・.kube-linter.yaml で例外)③trivy config(base/ と Dockerfile・誤設定・--severity MEDIUM,HIGH,CRITICAL --exit-code 1・.trivyignore で例外)④trivy image(fanclub-backend:0.4.1・CVE・1 回目は --severity HIGH,CRITICAL --exit-code 0 で表示のみ、2 回目は --severity CRITICAL --ignore-unfixed --exit-code 1 で落とす)。4 つとも apply する前に収まる。下段は 16 件の仕分けで、A(直す)0 件/B(.trivyignore で抑制・exp: 付き)4 件(KSV-0125 ×2・KSV-01010 ×1・KSV-0117 ×1・すべて MEDIUM)/C(閾値の外に残す)12 件(KSV-0020 ×1・KSV-0021 ×4・KSV-0040 ×5・KSV-0110 ×2・すべて LOW)。MEDIUM 4 件を 1 件でも書き漏らすとステージ③は赤のままになる
図3:4 ステージのゲートと、trivy config の指摘 16 件の仕分け

ステップ3:閾値を決める

閾値は「今日直せる水準」に置きます。

ステージ表示する閾値落とす閾値理由
trivy configすべて--severity MEDIUM,HIGH,CRITICAL --exit-code 1実測の分布は LOW 12 / MEDIUM 4 で、HIGH 以上は 0 件です。HIGH で落とすと最初から緑で、ゲートとして働きません
trivy image--severity HIGH,CRITICAL --exit-code 0(表示のみ)--severity CRITICAL --ignore-unfixed --exit-code 1HIGH で落とすと 0.4.1 でも緑にできません(OS 側の HIGH と payara-micro.jar が残ります)

この 2 行は実測に基づいて決めています。 ステージ③は、演習③の仕分けで MEDIUM 4 件を .trivyignore に振り分けた結果 12 件(すべて LOW)になっているので、MEDIUM 以上で落とす設計が成立します。仕分けをしていなければ、この閾値では赤のままです。 ステージ④は 0.4.1 に CRITICAL が 0 件・HIGH が 10 件という在庫に対する判断です。

「表示する閾値」と「落とす閾値」を分ける 2 行の型は、公式ドキュメントにそのまま載っています。

$ trivy image --exit-code 0 --severity MEDIUM,HIGH ruby:2.4.0
$ trivy image --exit-code 1 --severity CRITICAL ruby:2.4.0

「MEDIUM / HIGH は表示するが落とさない、CRITICAL だけ落とす」——これが公式が示す CI の型です。

「HIGH で落とさない」ことをごまかさずに書く

0.4.1 にしても HIGH は残ります。 OS レイヤ(alpine)にも、payara-micro.jar(Payara Micro 本体)にも HIGH があります。これらは pom.xml を直しても消えません。 ベースを更新するか、Payara Micro のバージョンを上げるか、どちらも今日はできません。

ここで HIGH で落とす閾値にすると、ゲートは今日から明日もその先も赤いままになります。 そして赤いままのゲートは、必ず外されます。 「とりあえず || true を足しておこう」で終わり、ゲートがあることだけが残って、機能しなくなります。

だから閾値は「今日直せる水準」に置きます。 CRITICAL で落とし、HIGH は毎回表示します。そして「HIGH を減らす」を別の作業として計画に載せます。 閾値を上げるのは、それが達成できてからです。

これはゲートを甘くしているのではなく、ゲートを生かしているのです。 守れないルールを掲げることと、守れるルールを守り続けることは、安全に対する寄与がまったく違います。

「HIGH/CRITICAL で止める」書き方は、別に手が覚えている必要があります。

本回の恒久ゲートは CRITICAL で落としますが、それは本ラボの在庫に対する運用判断であって、「HIGH では止められない」という意味ではありません。

整理すると 3 点です。①止める仕組みは HIGH を含めて学びます——--severity HIGH,CRITICAL --exit-code 1 を先の節で実際に打ち、終了コードが 1 になることを確認しました。②恒久ゲートの閾値だけを CRITICAL に置きます。③HIGH は表示し続けます--exit-code 0 の 1 行目)。見えなくするのではありません。

試験で問われるのは --severity HIGH,CRITICAL --exit-code 1 という書き方のほうです。 運用の閾値設計と、試験で打つコマンドは別物として覚えてください。

ステップ4:4 ステージ版 scan-gate.sh を書く

~/gitops/gitops-fanclub/scan-gate.sh(4 ステージ版・全量):

#!/bin/bash
# 静的解析 + イメージスキャンのゲート。1 つでも失敗があれば非ゼロで終了する。
# set -e は使わない(理由は第12回本文)。
set -uo pipefail
shopt -s nullglob

REPO_DIR="$(cd "$(dirname "$0")" && pwd)"
BASE_DIR="${REPO_DIR}/base"
KL_CONFIG="${REPO_DIR}/.kube-linter.yaml"
TRIVY_CONFIG="${REPO_DIR}/trivy.yaml"
IGNORE_FILE="${REPO_DIR}/.trivyignore"
APP_SRC="${HOME}/app-src/fanclub-api"
IMAGE="k8s-registry:5000/fanclub-backend:0.4.1"
FAILED=0

echo "=== 1/4 kubesec ==="
for f in "${BASE_DIR}"/*-deployment.yaml; do
  if ! kubesec scan "$f"; then
    echo "KUBESEC FAILED: $f"
    FAILED=1
  fi
done

echo "=== 2/4 kube-linter ==="
if ! kube-linter lint --config "${KL_CONFIG}" "${BASE_DIR}"; then
  echo "KUBELINTER FAILED"
  FAILED=1
fi

echo "=== 3/4 trivy config ==="
for d in "${BASE_DIR}" "${APP_SRC}"; do
  if ! trivy config --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
       --severity MEDIUM,HIGH,CRITICAL --exit-code 1 "$d"; then
    echo "TRIVY CONFIG FAILED: $d"
    FAILED=1
  fi
done

echo "=== 4/4 trivy image ==="
trivy image --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
  --severity HIGH,CRITICAL --exit-code 0 "${IMAGE}"
# 2 回目は同じ実行内で DB を二度取りに行かないための --skip-db-update。
# 「更新をさぼるため」の常用とは別物(本文を参照)。
if ! trivy image --config "${TRIVY_CONFIG}" --ignorefile "${IGNORE_FILE}" \
     --skip-db-update --severity CRITICAL --ignore-unfixed --exit-code 1 "${IMAGE}"; then
  echo "TRIVY IMAGE FAILED: ${IMAGE}"
  FAILED=1
fi

exit "${FAILED}"

--skip-db-update の置き場所に注意してください。 ステージ④の 1 回目には付けていません。 本回のガードレール③で「--skip-db-update を CI で常用しない」と書いた直後に、成果物のスクリプトがそれを常用していては筋が通りません。

2 回目に付けている理由は別です。 同じイメージを 2 度スキャンするのに、DB を 2 度取りに行く必要はありません——1 回目で最新の DB を取り、2 回目はそれを使います。「更新をさぼるための --skip-db-update」と「同じ実行の中で二度取りを避けるための --skip-db-update」は別物です。同じフラグでも、置く場所で意味が変わります。

第12回版からの変更点は 4 つです。

  1. REPO_DIR を絶対パス化しましたcd ... && pwd)。第12回は $(dirname "$0") の相対パスでしたが、ステージ③で ~/app-src/fanclub-api という別ディレクトリを扱うため、混同を避けます
  2. --configtrivy.yaml を渡します。 DB の配布元をスクリプト内に散らしません
  3. --ignorefile を明示します。 カレントディレクトリに依存させません
  4. ステージ④は 2 回実行します。 1 回目は表示のみ(--exit-code 0)、2 回目がゲート(--exit-code 1)です。「見せる」と「落とす」を分けます(本番ガードレール⑤)

2 回実行してもゲート全体で 4.328 秒です。

「同じイメージを 2 度スキャンするのは無駄ではないか」「--format json で 1 回だけ出して jq で 2 種類の判定をすべきではないか」——実測してから判断しました。

4 ステージ全部でこの時間なので、書き換える理由がありません。 jq の式を 2 本書けば、読む人が追う対象が 1 つ増えます。 速度のために可読性を落とす判断は、速度が問題になってからで足ります。

実行コマンド(k8s-ops・developer):

$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"

実行結果(各ステージの見出しと末尾):

=== 1/4 kubesec ===
=== 2/4 kube-linter ===
No lint errors found!
=== 3/4 trivy config ===
=== 4/4 trivy image ===
Total: 4 (HIGH: 4, CRITICAL: 0)
Total: 6 (HIGH: 6, CRITICAL: 0)
gate exit=0

real	0m4.328s

4 ステージすべてを通って exit 0 です。 ステージ④が表示している Total: 4(alpine の HIGH)と Total: 6payara-micro.jar の HIGH)は合わせて HIGH 10 件で、これが 1 回目(--exit-code 0)の表示です。CRITICAL は 0 件なので、2 回目のゲートは落ちません。

「HIGH が 10 件出ているのにゲートは緑」——これが閾値設計の結果です。 見えていないのではなく、見えたうえで止めないと決めています。 ログに残っているので、あとから「いつから 10 件だったのか」も追えます。

所要は実行のたびに変わります。 続けてもう一度走らせたときは real 0m2.295s でした。イメージの解析結果がスキャンキャッシュ(~/.cache/trivy/fanal)に残っているかどうかで倍ほど違います。手元の値が一致しなくて構いません——確かめてほしいのは「4 ステージ全部を通しても秒の単位で終わる」ということです。

ステージ③が緑になるのは、演習③で MEDIUM を 1 件残らず「直す」か「抑制する」に振り分けたからです。 仕分けを飛ばすと、ここで赤が残ります。

ステップ5:イメージを 0.4.1 に上げて GitOps で反映する

手で kubectl set image しても戻されます。 ArgoCD の syncPolicy.automatedprune: true / selfHeal: true です(第12回で確認済み)。

変更根拠
image...fanclub-backend:0.4.0:0.4.1演習②で作ったイメージ

他は 1 行も変えません。 resources も probe も securityContext も第12回のままです。変更が 1 行だけなので、反映後に何かが変わったらイメージが原因だと切り分けられます。

変更後の ~/gitops/gitops-fanclub/base/backend-deployment.yaml の全量です。

---
# Source: fanclub-api/templates/backend-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: fanclub-backend
  labels:
    app: fanclub-backend
spec:
  replicas: 1
  selector:
    matchLabels:
      app: fanclub-backend
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: fanclub-backend
    spec:
      serviceAccountName: fanclub-backend
      automountServiceAccountToken: false
      securityContext:
        seccompProfile:
          type: RuntimeDefault
      initContainers:
      - name: wait-for-db
        image: busybox:1.37
        command: ["sh","-c","until nslookup fanclub-db.fanclub.svc.cluster.local >/dev/null 2>&1; do echo waiting; sleep 2; done; echo resolved"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "16Mi"
            cpu: "10m"
          limits:
            memory: "32Mi"
            cpu: "50m"
      - name: log-shipper
        image: busybox:1.37
        restartPolicy: Always
        command: ["sh","-c","echo sidecar; tail -F /var/log/app/app.log 2>/dev/null || sleep infinity"]
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          allowPrivilegeEscalation: false
          readOnlyRootFilesystem: true
          capabilities:
            drop: ["ALL"]
        resources:
          requests:
            memory: "16Mi"
            cpu: "10m"
          limits:
            memory: "32Mi"
            cpu: "50m"
        volumeMounts:
        - name: applog
          mountPath: /var/log/app
      containers:
      - name: backend
        image: k8s-registry:5000/fanclub-backend:0.4.1
        ports:
        - containerPort: 8080
        envFrom:
        - configMapRef:
            name: fanclub-config
        - secretRef:
            name: fanclub-db-secret
        securityContext:
          runAsNonRoot: true
          runAsUser: 10001
          readOnlyRootFilesystem: true
          allowPrivilegeEscalation: false
          capabilities:
            drop: ["ALL"]
        startupProbe:
          httpGet:
            path: /health/started
            port: 8080
          failureThreshold: 30
          periodSeconds: 10
        livenessProbe:
          httpGet:
            path: /health/live
            port: 8080
          periodSeconds: 10
          failureThreshold: 3
        readinessProbe:
          httpGet:
            path: /health/ready
            port: 8080
          periodSeconds: 10
          failureThreshold: 3
        resources:
          requests:
            memory: "512Mi"
            cpu: "250m"
          limits:
            memory: "768Mi"
            cpu: "1000m"
        volumeMounts:
        - name: applog
          mountPath: /var/log/app
        - name: payara-tmp
          mountPath: /opt/payara/tmp
      volumes:
      - name: applog
        emptyDir: {}
      - name: payara-tmp
        emptyDir: {}

戻し方を先に確認してください。

反映後に https://fanclub.local/api/members が 200 を返さない場合は、git revert HEAD && git push origin main0.4.0 の状態へ戻します。

kubectl rollout undo では戻りません——selfHeal: true の ArgoCD が Git の内容へ引き戻すためです。レジストリには 0.2.0 / 0.3.0 / 0.3.1 / 0.3.2 / 0.4.0 が在庫として残っているので、pull は必ず成功します。GitOps 下ではロールバックも Git 操作です。

実行コマンド(k8s-ops・developer):

$ cd ~/gitops/gitops-fanclub
$ git add base/backend-deployment.yaml trivy.yaml .trivyignore scan-gate.sh
$ git commit -m "Fix pgJDBC CVEs (0.4.1) and add Trivy stages to the scan gate"
$ git push origin main

実行結果(抜粋):

[main 7e3e715] Fix pgJDBC CVEs (0.4.1) and add Trivy stages to the scan gate
 4 files changed, 61 insertions(+), 8 deletions(-)
 create mode 100644 .trivyignore
 create mode 100644 trivy.yaml
To http://k8s-registry:3000/developer/gitops-fanclub.git
   20faa23..7e3e715  main -> main

4 ファイル・61 行の追加です。 うち 2 つ(trivy.yaml / .trivyignore)は新規作成、クラスタの状態を変えるのは base/backend-deployment.yaml の 1 行だけです。

退避ファイルは git status に残ります。

第12回版を scan-gate.sh.bak-13 として残した場合、コミットには含めていないので未追跡ファイルとして残り続けます。

演習③で見た Dockerfile.bak-12 と同じ性質です。 退避したつもりのファイルが、ある道具からは検査対象に見え、別の道具からは未追跡の汚れに見えます。 前の版が必要なら Git の履歴から取り出せます(それが履歴の役目です)。作業が終わったら消すか、.gitignore に書くかを、そのときに決めてください。

trivy.yaml / .trivyignore / scan-gate.sh を push してもクラスタには反映されません。 ArgoCD が見ているのは overlays/prod 配下だけ(Application の source.path)です。リポジトリ直下に置いた検査設定とスクリプトは同期対象になりません。

実行コマンド(k8s-ops・developer):

$ kubectl -n argocd get application fanclub-api-prod -w

実行結果(15〜20 秒間隔で観測した抜粋):

t+31s   rev=20faa23 sync=Synced health=Healthy
t+92s   rev=20faa23 sync=Synced health=Healthy
t+112s  rev=7e3e715 sync=Synced health=Progressing
t+575s  rev=7e3e715 sync=Synced health=Progressing
t+595s  rev=7e3e715 sync=Synced health=Healthy
段階所要
push → 新リビジョンの検知約 112 秒
検知 → Healthy へ収束約 483 秒
push → 完全収束約 595 秒(9 分 55 秒)

検知まで約 112 秒かかっています。 第12回の実測は 79 秒 / 85 秒 / 133 秒、本回を別の機会にやり直したときは 259 秒でした。ばらつくのは仕様どおりで、ArgoCD の巡回間隔(timeout.reconciliation: 120s)にジッタ(60s)が乗るためです。ただし 259 秒は 120 + 60 では説明がつきません。巡回を 1 周期取りこぼすと、次の周期まで丸ごと待つためで、実質の上限は 2 周期分(約 360 秒)と考えておくのが安全です。「押した直後に反映されない」ことに驚かないでください。本番で待てないなら Webhook(Gitea から ArgoCD へ push 通知)を足しますが、それは巡回を速くするのではなく巡回を待たずに済ませる別の仕組みです。

検知してから収束するまでに、さらに約 8 分かかっています。 maxUnavailable: 0 の RollingUpdate なので、新しい Pod が readinessProbe を通ってから次の 1 つを落とします。 ステップ 6 で見る Pod の AGE が 2〜3 分ずつずれているのが、その入れ替わりの記録です。止めずに入れ替えると時間がかかります——それが maxUnavailable: 0 の代償です。

ステップ6:動いていることを確認する

実行コマンド(k8s-ops・developer):

$ kubectl -n fanclub get pods
$ kubectl -n fanclub get deploy fanclub-backend -o jsonpath='{.spec.template.spec.containers[0].image}'
$ curl -k --noproxy '*' https://fanclub.local/api/members
$ ~/gitops/gitops-fanclub/scan-gate.sh; echo "gate exit=$?"
確認期待実測
kubectl -n fanclub get pods異常 0・backend が 0.4.12/2 × 33 つとも 2/2 RunningRESTARTS 0
jsonpath で image を確認k8s-registry:5000/fanclub-backend:0.4.1一致
curl で API を叩くHTTP 200 + 会員 3 名の JSON200・会員 3 名
scan-gate.shexit 0exit 0

実行結果(Pod):

NAME                               READY   STATUS    RESTARTS   AGE
fanclub-backend-56875d65c9-47cgs   2/2     Running   0          11m
fanclub-backend-56875d65c9-phfgm   2/2     Running   0          9m1s
fanclub-backend-56875d65c9-szrnd   2/2     Running   0          6m20s

実行結果(イメージと API):

k8s-registry:5000/fanclub-backend:0.4.1

[{"createdAt":"2026-07-25T14:28:03.572932","email":"hanako@example.com","id":1,"name":"鈴木花子","plan":"standard"},{"createdAt":"2026-07-25T14:28:03.572932","email":"ichiro@example.com","id":2,"name":"佐藤一郎","plan":"free"},{"createdAt":"2026-07-25T18:00:53.008964","email":"restore-test@example.com","id":11,"name":"復元テスト太郎","plan":"premium"}]

3 行目が本演習でいちばん重要な確認です。 pgJDBC を 42.7.4 から 42.7.13 へ上げたので、DB への接続が今までどおり成立するかを実際に確かめます(ガードレール⑥)。CVE-2026-54291 の修正は fail closed 方向に効くため、「ビルドが通った」だけでは確認になりません。

会員が 3 名返っています。 このデータは PostgreSQL から読んだものなので、200 が返ったこと自体が「JDBC の接続・認証・クエリがすべて通った」ことの証拠になります。/health/ready が通っただけでは、ここまでは分かりません。

ログ側も確認します。 backend の 3 Pod のログに SEVERE / DatabaseException / connection attempt failed が出ていないことを見ます。

実行コマンド(k8s-ops・developer):

$ kubectl -n fanclub logs -l app=fanclub-backend -c backend --tail=-1 | grep -c -E 'SEVERE|DatabaseException|connection attempt failed'

実行結果:

0

「何も起きなかった」ことを確認するのが手順です。

CVE-2026-54291 の修正は SCRAM-SHA-256-PLUS から平文の SCRAM-SHA-256 へのダウングレードを拒否する方向(fail closed)の変更です。本ラボの PostgreSQL 18 と現行の接続設定では、実際に何も起きませんでした。

そして「起きなかったことを確認したこと自体が、この手順の一部です。」 結果が同じだったことは、確認せずに上げてよい理由にはなりません。 次に上げるドライバが同じ性質だという保証はどこにもなく、確認していなければ、壊れたときに「上げたせいなのか」すら切り分けられません。

依存のパッチ更新は「安全な変更」ではなく「小さい変更」です。 小さいから確認を省いてよいのではなく、小さいから確認が短時間で済みます——ここでやったのは 200 の確認とログの grep の 2 つだけです。

最後に 0.4.00.4.1 を並べます。

項目0.4.00.4.1
trivy fspom.xml3 件(HIGH 3)0 件
opt/payara/app.war3 件0 件
opt/payara/payara-micro.jar16 件16 件
alpine(OS レイヤ)15 件15 件
severity 集計{"HIGH":13,"MEDIUM":21}{"HIGH":10,"MEDIUM":21}
DISK / CONTENT493MB / 166MB493MB / 166MB
ゲートexit 0

減ったのは HIGH 3 件、増えたコストは 0 バイトです。 「スキャンした」から「直して、反映して、動いていることを確かめた」まで到達しました。 ここまでが 1 回分の作業です。

本番ガードレール⑩: ゲートを通すために .trivyignore へ書き足すのは、ゲートを外すのと同じです。

exclude(第12回)でも .trivyignore(本回)でも同じで、「赤いから抑制する」と「意図してそうしているから抑制する」は、書いたときは同じ 1 行に見えて、半年後にまったく違う意味を持ちます。

抑制するときは理由と期限をその場で書いてください。 書けないなら、それは抑制すべきものではありません。ゲートを緑にするために書く 1 行と、判断を記録するために書く 1 行は、目的が逆です。

試験ではこう問われる。

CI で CRITICAL の脆弱性が見つかったらビルドを失敗させるコマンドを書け」「指定のイメージを HIGH 以上でスキャンし、結果をファイルに出せ」という粒度です。試験相当の作業(閾値を決めてコマンドを書く)なら 5 分です。

手が覚えているべきものは trivy image --severity CRITICAL --exit-code 1 <image> と、--exit-code を付けないと落ちないことです。ArgoCD や Git の操作は本書ラボ固有の作法であり、試験では不要です——試験環境ではその場で YAML を直して kubectl apply します。

暗記必須コマンドと 2 つの設定ファイルの型

本回の準備の大半は、本書ラボ固有の作法です。試験では問われません。

該当するのは次の 5 つです。

  • DB の配布元を選び直したこと--db-repository / --java-db-repository / --checks-bundle-repository)—— Squid の whitelist がある環境だから必要でした
  • trivy.yaml をリポジトリに置いたこと--config
  • .trivyignore をリポジトリに置いたこと--ignorefile
  • Gitea への push と no_proxy の指定
  • ArgoCD 経由での反映

試験環境にプロキシはありません。 素の trivy image <イメージ> がそのまま動く前提で出題されます。覚えるのは配布元の選び方ではなく、この節に並べたフラグと出力の読み方です。

ただし「なぜ配布元を選び直せるのか」を知っていることには価値があります。 試験には出ませんが、実務では閉じたネットワークのほうが多いからです。試験のために覚えるものと、仕事のために覚えるものは、重なりますが同じではありません。

Trivy のドキュメントは試験中に 1 ページも参照できない

CKS 試験中に参照できるのは 8 件だけで、うち 3 件はサブパス限定です。 Trivy のドキュメントはその 8 件に入っていません。

参照先可否
kubernetes.io/docs / kubernetes.io/blog
falco.org/docs / etcd.io/docs
kubernetes-sigs.github.io/bom/cli-reference/ / kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/ / docs.cilium.io/en/stable可(サブパス限定・この 3 件)
istio.io/latest/docs/
trivy.dev不可
github.com/aquasecurity/trivy不可
kubesec.io / docs.kubelinter.io / docs.docker.com不可(第12回で確認済み)
helm.sh/docs不可(CKAD / CKA では可・CKS では不可

「サブパス限定」は、そのドメイン全体ではなく表に書いたパスから下だけが許可されるという意味です。 docs.cilium.io/en/stable は許可されていても、docs.cilium.io のトップから辿ることはできません。試験当日にどこまで開けるかは、この 3 件については URL を覚えておくほうが確実です。

Trivy は「暗記必須」側のツールです。 第19回の重点復習に積みます。

暗記必須コマンド

用途コマンド / 記法補足
イメージの CVEtrivy image <image>既定の scanners は vuln,secret
ソース / プロジェクトtrivy fs <dir>CI で使うのはこれrootfs ではありません)
設定ファイル(IaC)trivy config <dir>Dockerfile / K8s / Terraform / Helm / CloudFormation / ARM
クラスタ全体trivy k8s --report summaryEXPERIMENTAL
severity で絞る--severity HIGH,CRITICALスペースを入れません
ビルドを落とす--exit-code 1付けないと落ちません
修正版があるものだけ--ignore-unfixed「無視」ではなく「後回し」
OS / 言語の切り分け--pkg-types os / --pkg-types library既定は os,library
出力形式--format json / sarif / cyclonedx / spdx-jsonSBOM も出せます
DB を取らない--skip-db-updateCI で常用しません
DB だけ取る--download-db-only準備ステップ
配布元を変える--db-repository / --java-db-repository公式の配布先は GHCR / Docker Hub / ECR の 3 経路
抑制ファイル--ignorefile <file>既定は .trivyignore
キャッシュ削除trivy clean --vuln-db / --java-db / --checks-bundle / --scan-cache / --vex-repoこの 5 つだけです。--all はありません
設定ファイル--config trivy.yamlカレントの trivy.yaml は自動で読まれます

スクリプトに書くときだけは、自動読み込みに頼らないでください。

trivy.yaml.trivyignoreどちらもカレントディレクトリから自動で読まれます。 手で打つぶんには便利ですが、CI では --config--ignorefile を明示します。

実測では、実行位置を変えただけで trivy config の件数が 12 件と 16 件で入れ替わりました。 コマンドラインに現れない条件で結果が変わるものは、スクリプトの中では必ず明示に変えてください。

試験では kubectl を打つ場面もあります。 CKS の SSH ホストには alias k=kubectl と bash 補完が設定済みなので、k -n fanclub get pods のように打てます。本文では読みやすさのために kubectl と全綴りで書いていますが、試験ではエイリアスを使ってください——1 タスクあたり数十秒の差が、最後の見直し時間になります。

2 つの設定ファイルの型

trivy.yaml —— 3 つのアーティファクトをどこから取るかを決めます。

db:
  repository:
    - docker.io/aquasec/trivy-db:2
  java-repository:
    - docker.io/aquasec/trivy-java-db:1
misconfiguration:
  checks-bundle-repository: docker.io/aquasec/trivy-checks:2

.trivyignore —— 何を、なぜ、いつまで見逃すかを決めます。

# 理由をここに書く。
# いつ見直すかもここに書く。
DS-0026 exp:2027-02-01

ID は出力に出る形(DS- / KSV-)で書きます。 AVD- 付きでも抑制は効きますが、出力と表記が揃っていないと突き合わせられません。

第12回で .kube-linter.yaml を、本回で trivy.yaml.trivyignore をリポジトリに置きました。 これで「検査の設定がリポジトリに入っている」状態が 3 本そろいました。誰が実行しても同じ結果になり、設定を変えた履歴が Git に残ります。

検査を人の手元に置いたままにすると、「自分の環境では通った」が起きます。 検査設定をコードとして扱うことは、サプライチェーンを理解するうえで最初にやることです。

設定の優先順位

順位指定方法
1コマンドラインフラグ
2環境変数TRIVY_DB_REPOSITORY 等)
3設定ファイルtrivy.yaml

本回で Trivy の DB が OCI アーティファクトとして配られていることを確認しました。 第14回では、自分たちの SBOM と署名を同じ仕組みでレジストリに置きます。レジストリはイメージを置く場所であると同時に、イメージにまつわる情報を置く場所でもあります。

まとめ

  • Trivy は image / fs / config / k8s の 4 つの対象を持ちます。 CI で使うのは fsrootfs ではありません)。repo は jar やバイナリを見ません
  • Trivy は 3 つのデータベースを使います。 脆弱性 DB と Java インデックス DB は取れないと FATAL、checks バンドルは取れないと ERROR を出して埋め込み版で継続します
  • 既定の取得先は mirror.gcr.io で、本ラボの whitelist にはありません。 しかもフォールバックするのは 429 / 5xx のような一時的エラーのときだけなので、403 では第 2 候補へ進まず FATAL になります(0.058 秒で終わります
  • 公式は GHCR / Docker Hub / AWS ECR の 3 経路を配布先として案内しています。 本ラボは Docker Hub(第1巻から whitelist 済み)を使い、whitelist を 1 行も足さずに済みました(44 行のままです)。同じアーティファクト(転送 103.69 MiB・展開すると 1.2GB)が ghcr.io では 4 分 25 秒、Docker Hub では 8.552 秒でした
  • trivy.yaml には 3 つとも書きます。 db.repository / db.java-repository / misconfiguration.checks-bundle-repositoryキー名を間違えても Trivy はエラーを出さず、既定値のまま動きますjava-db: というトップレベルキーは存在しません)
  • 脆弱性 DB は上流で 6 時間ごとに作られ、クライアントは 24 時間キャッシュします。Java DB は 3 日です
  • trivy.yaml.trivyignore も、カレントディレクトリから自動で読まれます。 実測では実行位置を変えただけで trivy config の件数が 12 件と 16 件で入れ替わりました。 CI では --config--ignorefile を必ず明示します——第12回の .kube-linter.yaml(付けないと読まれない)とは逆の壊れ方をします
  • trivy clean のオプションは --vuln-db / --java-db / --checks-bundle / --scan-cache / --vex-repo の 5 つで、--all はありません
  • ベースイメージを小さくして減るのは OS レイヤの CVE だけです。 OS レイヤは 72 → 15 件、全体で 104 → 34 件になり、usr/bin/pebble(gobinary・13 件)は丸ごと消えましたが、jar の 19 件は 1 件も減っていません
  • frontend 1.0.0 とベースの nginx:1.27-alpine は、どちらも 107 件でまったく同じでした。 frontend が自分で持ち込んだ CVE は 0 件です。しかもそのベースの alpine は 3.21.3(backend は 3.23.5)——第12回に「21MB で既に小さい」として触らなかったイメージが、いちばん古い土台を使っていました。「小さい」と「新しい」は別の話です
  • trivy image は「何が入っているか」、trivy fs は「何を入れると宣言しているか」を見ます。 直す場所は後者にしかありません。しかも fs はビルド前に走らせられます
  • pom.xmlpostgresql.version を 1 行上げるだけで、jar 側の指摘が消えました(新タグ 0.4.1)。Fixed Version にちょうど合わせず、上流の最新パッチ(42.7.13)を採りました
  • 同じタグで中身を差し替えません。 タグは動き、ダイジェストは動きません。第14回の署名検証がダイジェスト指定である理由につながります
  • セキュリティ修正は fail closed 方向に効くことがあります。 pgJDBC を上げたら、アプリを起動して疎通まで確認します。実測では https://fanclub.local/api/members200 で会員 3 名を返し、backend のログの SEVERE / DatabaseException / connection attempt failed0 件でした。「起きなかった」ことを確認したこと自体が手順の一部であり、確認せずに上げてよい理由にはなりません
  • --exit-code を付けない限り、Trivy は指摘が何件あっても 0 で終了します。 第12回の Kubesec(スコアがマイナスなら既定で 2)とは既定の姿勢が逆です
  • 閾値は「今日直せる水準」に置きます。 赤いままのゲートは必ず外されます。表示する閾値(HIGH)と落とす閾値(CRITICAL)を分けます——ただし試験で問われるのは --severity HIGH,CRITICAL --exit-code 1 の書き方のほうです
  • .trivyignore には理由(# コメント)と期限(exp:YYYY-MM-DD)を書きます。 期限のない抑制は、判断を凍結させます。期限切れの行は効きません——exp:2020-01-01 にすると件数が 14 件から 16 件へ戻りました。ID は出力と同じ KSV- / DS- 形式で書きますAVD- 付きでも効きますが、照合できなくなります)
  • 本ラボの .trivyignore は 4 件ですDS-0026 / KSV-0125 / KSV-01010 / KSV-0117)。MEDIUM 4 件を 1 件でも書き漏らすと、ゲートのステージ③が緑になりません。 抑制後の分布は {"LOW": 12} で、4 ステージのゲートは 4.328 秒・exit 0 で通りました
  • 第12回で 2 つの道具を当てたマニフェストに 3 つ目を当てると、まだ 16 件出ます(LOW 12 / MEDIUM 4・HIGH 以上は 0)。「全部を 0 にする」ではなく「仕分ける基準を持つ」——直す / 抑制する / 閾値の外に置く、の 3 つです。本ラボでは「直す」に入るものが 1 件もありませんでした——直せるものは第12回で直し終えていたからです
  • 16 件のうち 3 種類は偽陽性でした。 KSV-01010API_PORT / DB_PORT をキー名だけで機密と判定し、KSV-0110base/namespace: が無いことを咎め(第12回の Kubesec の .default 表示と同じ構図)、KSV-0040同じディレクトリにある resourcequota.yaml を見ていません(第12回の non-isolated-pod / non-existent-service-account に続く 3 例目)。偽陽性を見抜くのに要るのはツールの知識ではなく環境の知識です
  • KSV-0020 は、第12回で「変えない」と判断した frontend の runAsUser: 101 です。 3 つ目の道具も同じところを指しました。それでも変えません——指摘の数は判断を覆す根拠になりません。覆す根拠になるのは「変えたら何が壊れるか」を確かめた結果だけです
  • 「エラーが出たのに結果が出ている」ときは、その結果の根拠が変わっています。 埋め込み checks は Trivy をビルドした時点(v0.70.0 = 2026-04-17)のルールです。CI では ERROR の行を拾う設計が要ります

第19回で復習する項目。

trivy.dev が試験中に参照できない以上、Trivy は全体が暗記対象です。第19回に送るのは ①サブコマンド 4 つ(CI では fs)②主要フラグ(--severity はスペース無し・--exit-code は付けないと落ちない)③3 つの DB と失敗の作法④.trivyignore の書式(# / ID / exp:)⑤trivy.yaml のキーと、trivy.yaml / .trivyignore がカレントディレクトリから自動で読まれることの 5 点です。

理解度チェック(○×形式・全 8 問)

次の各文が正しいか(○)誤りか(×)を判断してください。下の「解答と解説」を開くと答え合わせができます。

  1. --db-repository の既定値には第 2 候補として ghcr.io が含まれるので、1 つ目の mirror.gcr.io がプロキシに遮断されても自動的に ghcr.io から取得される
  2. trivy config は checks バンドルを取得できない場合、Trivy のバイナリに埋め込まれた checks へフォールバックして検査を続行する
  3. --severity HIGH,CRITICAL を指定しておけば、該当する脆弱性が 1 件でも見つかったときに Trivy は非ゼロの終了コードを返す
  4. --ignore-unfixed を付けると、修正版がまだ公開されていない脆弱性が表示から除かれる
  5. jar を含むイメージを既定設定でスキャンすると、脆弱性 DB とは別に Java インデックス DB の取得が必要になる
  6. trivy fspom.xml のような依存定義ファイルを対象にするため、イメージをビルドする前の段階で依存の脆弱性を見つけられる
  7. .trivyignore--ignorefile で明示的に指定したときだけ読み込まれるので、同じコマンドを別のディレクトリで実行しても検出件数は変わらない
  8. .trivyignoreCVE-2019-14697 exp:2023-01-01 と書くと、指定した日付を過ぎたあとは再び検出されるようになる
解答と解説

1=×/2=○/3=×/4=○/5=○/6=○/7=×/8=○

  • 問1: 公式の Databases ページは「In case of a transient errors (e.g. status 429 or 5xx), Trivy will fall back to alternative registries」と書いています。プロキシが返す 403 Forbidden は一時的なエラーではありませんので、フォールバックは働かず FATAL になります。CLI ヘルプの in order of priority という文言だけでは条件が分かりません
  • 問2: 公式の Built-in checks ページに「The Checks bundle is also embedded in the Trivy binary (at build time), and will be used as a fallback if Trivy is unable to download the bundle.」と明記されています。ただし埋め込み版は Trivy をビルドした時点のルールなので、ERROR の行を CI で見逃すと、古いルールで検査していることに気づけません
  • 問3: --severity表示するものを絞るだけです。「By default, Trivy exits with code 0 even when vulnerabilities are detected.」——落とすには --exit-code を明示します。 第12回の Kubesec(既定で 2 を返す)とは既定の姿勢が逆です
  • 問4: ヘルプの原文は display only fixed vulnerabilities です。本ラボの 0.3.2 では 104 件が 58 件になり、約 44% が「修正版がまだ無い」ものでした。視界から消えるだけで、無くなったわけではありません
  • 問5: Java インデックス DB は「jar のハッシュから groupId / artifactId / version を引く索引」です。脆弱性情報そのものではないため、キャッシュ期間も 24 時間ではなく 3 日と長くなっています。--pkg-types os を指定すれば jar を解析しないので Java DB は不要になります
  • 問6: trivy image が見る app.war は Maven の成果物であり、どのバージョンに上げれば直るかはソース(pom.xml)を見ないと分かりません。 ビルド前に走らせられるので、CI では image より先に回すほうが速くフィードバックを返せます
  • 問7: カレントディレクトリの .trivyignore は自動的に読まれます。 実測では、.trivyignore のあるディレクトリで --ignorefile 無しに実行すると 12 件/tmp へ移って同じコマンドを打つと 16 件になりました。trivy.yaml も同じ性質です。第12回の .kube-linter.yaml--config を付けないと読まれなかったので、逆の壊れ方をします。 CI では --config--ignorefile を明示してください
  • 問8: exp:YYYY-MM-DD は期限付きの抑制です。その日を過ぎると再び検出されます。 「いつ見直すか」を設定ファイルの側に持たせられるのが、第12回の .kube-linter.yamlexclude との違いです

次回予告

第14回「SBOM(bom)+ Cosign イメージ署名 + 許可レジストリ制限」では、本回で作った fanclub-backend:0.4.1 に対して SBOM を生成し、Cosign で署名し、署名の無いイメージを拒否するところまで進めます。

本回で「Trivy の DB は OCI アーティファクトとして配られている」ことを確認しました。今度は自分たちの SBOM と署名を同じ仕組みでレジストリに置きます。 そして署名の検証はタグではなくダイジェストで行います——本回で 0.4.0 を作り直さず 0.4.1 にした理由が、そこでもう一度出てきます。

→ 詳しくは第14回 SBOM(bom)+ Cosign イメージ署名 + 許可レジストリ制限

前の記事
次の記事