- はじめに — Critical 九件、全体 1,247 件のレポートを前にして
- スキャナーが実際にしていること — 一覧とデータベースの突き合わせ
- 誤検知の構造 — ベンダーのバックポートパッチ
- スキャナーに見えないもの
- CVSS だけを見てはいけない理由 — EPSS と KEV
- 1,247 件を消すのはパッチではなくベースイメージの入れ替え
- CI に入れるとき — ゲートの基準と期限付きの例外
- おわりに — レポートの数字ではなく対処可能な項目を見てください
はじめに — Critical 九件、全体 1,247 件のレポートを前にして
イメージスキャナーをパイプラインに初めて付けると、たいていこういう出力を見ることになります。
trivy image --scanners vuln registry.acme.com/payments:1.4.2
registry.acme.com/payments:1.4.2 (debian 12.6)
Total: 1247 (UNKNOWN: 0, LOW: 812, MEDIUM: 289, HIGH: 137, CRITICAL: 9)
node-pkg (node-pkg)
Total: 6 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 2, CRITICAL: 0)
このレポートをそのままチームのチャンネルに貼ると、結果は二つに一つです。全員が無視するか、誰も手を付けられないままリリースが止まるかです。どちらの場合もセキュリティは良くなりません。
問題は数字ではなく解釈です。1,247 件のうち実際に対処が必要なのはたいてい一桁で、その一桁を見つける方法は深刻度フィルタではありません。そして残りの 1,200 件あまりを消す方法は、一つずつパッチを当てることではありません。
この記事ではスキャナーが何を見て何を見られないのか、どの指標で絞るべきか、そして実務でこの数字を実際に二桁以下へ落とす措置が何なのかを扱います。
スキャナーが実際にしていること — 一覧とデータベースの突き合わせ
イメージスキャナーはイメージを実行もしませんし、コードを解析もしません。していることは二段階です。
第一に、イメージのレイヤーを展開してインストール済みパッケージの一覧を作ります。Debian 系なら /var/lib/dpkg/status、RPM 系なら RPM データベース、言語パッケージならロックファイルやメタデータのディレクトリを読みます。
trivy image --list-all-pkgs --format json node:22 \
| jq '[.Results[].Packages // [] | length] | add'
432
第二に、その一覧の名前とバージョンを脆弱性データベースと突き合わせます。それで全部です。
trivy image --format json node:22 \
| jq -r '.Results[].Vulnerabilities[]? | [.PkgName, .InstalledVersion, .VulnerabilityID, .Severity] | @tsv' \
| head -5
libssl3 3.0.14-1~deb12u2 CVE-2024-5535 CRITICAL
libc6 2.36-9+deb12u8 CVE-2025-0395 MEDIUM
perl-base 5.36.0-7+deb12u1 CVE-2023-31486 HIGH
zlib1g 1:1.2.13.dfsg-1 CVE-2023-45853 CRITICAL
tar 1.34+dfsg-1.2 CVE-2005-2541 HIGH
この構造を理解すると、スキャナーの性質はすべてそこから導かれます。
パッケージのメタデータがなければ見えません。ソースから直接ビルドして入れたライブラリ、curl でダウンロードしてコピーしたバイナリ、静的リンクされた依存は一覧に現れないので、脆弱性も報告されません。スキャン結果がきれいであることが安全を意味しない第一の理由です。
逆に一覧にあれば、実行されるかどうかと無関係に報告されます。tar がイメージの中にインストールされていれば、アプリケーションが一度もそれを呼ばなくても脆弱性一覧に上がってきます。上の出力の最後の行、2005 年に登録された CVE がいまだに出てくるのも同じ理由です。
誤検知の構造 — ベンダーのバックポートパッチ
最もよく出てくる誤検知の類型がバックポートです。ディストリビューションは安定性のためにバージョン番号を上げず、セキュリティ修正だけを既存のバージョンへ移植します。
docker run --rm debian:12-slim bash -lc 'dpkg -l | grep -E "^ii\s+openssl"'
ii openssl 3.0.14-1~deb12u2 amd64 Secure Sockets Layer toolkit
アップストリーム基準では 3.0.14 は特定の CVE に対して脆弱です。ところが Debian は deb12u2 リビジョンに修正を移植しています。確認できます。
docker run --rm debian:12-slim bash -lc \
'apt-get -qq update && apt-get -qq changelog openssl 2>/dev/null | head -12'
openssl (3.0.14-1~deb12u2) bookworm-security; urgency=medium
* Fix CVE-2024-5535: SSL_select_next_proto buffer overread
* Fix CVE-2024-4741: use-after-free in SSL_free_buffers
-- Debian Security Team Mon, 08 Jul 2026 21:14:02 +0000
バージョン文字列だけを見てアップストリームのデータベースの範囲と突き合わせるツールは、これを脆弱だと報告します。そこでスキャナー選定の基準が一つ生まれます。ディストリビューションのセキュリティ勧告をデータソースとして使うスキャナーを使うべきです。アップストリームのデータベースのバージョン範囲だけを参照するツールは、Debian、Ubuntu、RHEL のイメージで大量の誤検知を生みます。
データソースがきちんと設定されていれば、ステータスのフィールドが出てきます。
trivy image --format json debian:12-slim \
| jq -r '.Results[].Vulnerabilities[]? | .Status' | sort | uniq -c | sort -rn
58 will_not_fix
23 affected
6 fixed
2 end_of_life
will_not_fix は、ディストリビューションのセキュリティチームが検討したうえで、このリリースでは修正しないと判定した項目です。多くは実際の影響がないか、エクスプロイトの条件が成立しない場合です。affected は認めているものの、まだ修正版がない状態です。
ここで実務的に最も有用なオプションが出てきます。
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
Total: 4 (HIGH: 3, CRITICAL: 1)
1,247 件が 4 件になりました。何も安全にはなっていませんが、いま対処できる項目だけが残りました。直す方法がない項目を待ち行列に入れておくことは、待ち行列を無意味にします。
スキャナーに見えないもの
スキャンを通過したからといってイメージが安全なわけではありません。構造上見られないものがあります。
アプリケーションコードです。自分たちが書いたコードのインジェクション、認可の抜け、安全でないデシリアライズはイメージスキャナーの対象ではありません。実際の侵害事故で侵入口になる割合は、むしろこちらが大きいのです。
設定です。root で実行されるコンテナ、過剰な権限、ホストパスのマウント、書き込み可能なルートファイルシステムは CVE ではないので脆弱性一覧にありません。別のスキャナーで見る必要があります。
trivy image --scanners vuln,secret,misconfig registry.acme.com/payments:1.4.2
payments:1.4.2 (secrets)
HIGH: AWS Access Key ID
Dockerfile:22
ENV AWS_ACCESS_KEY_ID=AKIA****************
payments:1.4.2 (dockerfile)
HIGH: DS002 Image user should not be root
Specify at least 1 USER command with non-root user
メタデータなしで入ってきたファイルも見えません。次はスキャンに絶対に引っかからない脆弱なバイナリです。
RUN curl -fsSL https://example.com/tools/legacy-cli-1.2.0.tar.gz \
| tar xz -C /usr/local/bin
この経路で入ってきたものは SBOM にも、スキャン結果にもありません。イメージに何かを入れるときはパッケージマネージャを通すほうが、観測可能性の面で得です。
最後に、スキャナーはそのライブラリが実行経路にあるかどうかを知りません。インストールされていれば報告します。次のコマンドで実際の使用状況をおおまかに見積もれます。
# プロセスが実際に開いている共有ライブラリ
docker run --rm --entrypoint sh registry.acme.com/payments:1.4.2 -c \
'ldd /usr/local/bin/node | awk "{print \$1}" | sort'
libc.so.6
libcrypto.so.3
libdl.so.2
libgcc_s.so.1
libm.so.6
libpthread.so.0
libssl.so.3
libstdc++.so.6
八個です。イメージにインストールされた 432 個のパッケージのうち、アプリケーションが実際にリンクしているのはこれだけです。残りはほとんどがベースイメージに付いてきたもので、この観察が後で扱う結論につながります。
CVSS だけを見てはいけない理由 — EPSS と KEV
優先順位を CVSS のスコアだけで決めると失敗します。CVSS は脆弱性が悪用されたときの影響と難易度を技術的に評価した値であって、実際に悪用される確率ではありません。そして登録された脆弱性のうち実際の悪用が観測される割合は数パーセント程度です。
二つの指標を併せて見る必要があります。EPSS は今後 30 日以内に悪用が観測される確率の推定値で、KEV は実際の悪用が確認された一覧です。
curl -s 'https://api.first.org/data/v1/epss?cve=CVE-2021-44228,CVE-2023-45853,CVE-2024-5535' \
| jq -r '.data[] | [.cve, .epss, .percentile] | @tsv'
CVE-2021-44228 0.944400 0.99970
CVE-2023-45853 0.004120 0.74331
CVE-2024-5535 0.001960 0.58207
一つ目は悪用確率 94 パーセント、残りの二つは 1 パーセント未満です。三つとも CVSS では Critical 等級です。深刻度だけを見ると三つは同じに見えますが、対応の緊急度はまったく違います。
KEV との突き合わせはもっと単純です。
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
| jq -r '.vulnerabilities[].cveID' | sort -u > /tmp/kev.txt
trivy image --format json registry.acme.com/payments:1.4.2 \
| jq -r '.Results[].Vulnerabilities[]?.VulnerabilityID' | sort -u > /tmp/found.txt
comm -12 /tmp/found.txt /tmp/kev.txt
CVE-2023-38545
1,247 件のうち実際の悪用が確認されたのは一件です。この一件が今日処理すべき項目であり、残りは定期サイクルに回して構いません。
まとめると、優先順位の判断は三つの軸の積です。悪用可能性(KEV、EPSS)、露出の有無(インターネットから到達可能なサービスか)、そして到達可能性(そのコードが実行経路にあるか)です。CVSS はこのうち一つも答えてくれません。
1,247 件を消すのはパッチではなくベースイメージの入れ替え
ここがこの記事の実務的な核心です。脆弱性の数を減らす最も効果的な措置は、個別のパッケージを上げることではなく、そのパッケージたちがそもそもイメージに入っていない状態を作ることです。
同じアプリケーションを複数のベースの上に載せてスキャンしてみると、差は明白です。
| ベースイメージ | イメージサイズ | 検出パッケージ | 修正可能な CRITICAL | 修正可能な HIGH | シェルとパッケージマネージャ |
|---|---|---|---|---|---|
| node:22 | 1.12 GB | 432 | 9 | 137 | 含む |
| node:22-slim | 231 MB | 118 | 2 | 24 | 含む |
| node:22-alpine | 148 MB | 41 | 0 | 3 | 含む |
| gcr.io/distroless/nodejs22-debian12 | 187 MB | 19 | 0 | 1 | なし |
| 静的バイナリと scratch | 24 MB | 0 | 0 | 0 | なし |
数字の絶対値はスキャンの時点によって変わりますが、傾向は常に同じです。イメージにファイルが少ないほど脆弱性は少なくなります。当たり前のようですが、実務ではこの結論にたどり着くまでにたいてい数十時間を個別 CVE の対応に使います。
移行はマルチステージビルドで行います。
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --omit=dev
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]
ビルド段階にはコンパイラもヘッダも全部あって構いません。最終イメージに入らないからです。結果を比べます。
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.5.0
payments:1.4.2 (debian 12.6)
Total: 4 (HIGH: 3, CRITICAL: 1)
payments:1.5.0 (debian 12.6)
Total: 0 (HIGH: 0, CRITICAL: 0)
payments:1.5.0 (node-pkg)
Total: 2 (HIGH: 2, CRITICAL: 0)
OS パッケージの脆弱性が 0 になり、残った二件は自分たちのアプリケーションの npm 依存です。これで対応対象が明確になりました。
ディストロレスへ移すときに実務で引っかかる箇所を三つ、あらかじめ知っておくと良いです。
シェルがないので kubectl exec で入れません。デバッグは一時コンテナを付ける方式に変わります。
kubectl debug -it pod/payments-7d9f -c debugger --image=busybox --target=app
CMD をシェル形式で書くと動きません。exec 形式でのみ書く必要があります。そしてヘルスチェックに curl や wget を使っていた設定は全部変えなければなりません。言語ランタイムで実装するか、gRPC のヘルスチェックへ移します。
CA 証明書が必要な場合は、イメージに含まれているか確認しなければなりません。nonroot タグのディストロレスベースには入っていますが、scratch には入っていません。
CI に入れるとき — ゲートの基準と期限付きの例外
スキャンを CI に入れた瞬間、ポリシーの決定が必要になります。どの条件でビルドを失敗させるのか、です。
Critical のすべてで失敗させると開発が止まります。失敗させなければ誰も見ません。実務で安定して維持できる基準は次の組み合わせです。
trivy image \
--scanners vuln \
--severity CRITICAL,HIGH \
--ignore-unfixed \
--ignorefile .trivyignore.yaml \
--exit-code 1 \
registry.acme.com/payments:1.5.0
修正版が存在する Critical と High だけをゲートの対象にします。つまり「いま直せるのに直していないもの」でのみ失敗します。ここに KEV 一覧にある項目は深刻度と無関係に即失敗として扱うルールを足せば、実際のリスクを取り逃がしません。
if comm -12 /tmp/found.txt /tmp/kev.txt | grep -q .; then
echo "KEV 一覧に含まれる脆弱性があります。デプロイを中止します。"
comm -12 /tmp/found.txt /tmp/kev.txt
exit 1
fi
例外処理で最も重要なルールは一つです。無期限の無視を禁じることです。例外ファイルには理由と期限が必ず必要です。
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2024-45491
paths:
- usr/lib/x86_64-linux-gnu/libexpat.so.1.8.10
statement: このサービスは信頼できない XML をパースしない。8 月のベースイメージ入れ替えで解消予定。
expired_at: 2026-09-30
- id: CVE-2023-45853
statement: zlib は静的リンクされておらず呼び出し経路がない。Debian は will_not_fix と判定。
expired_at: 2026-10-31
期限が過ぎればスキャナーが再び失敗させます。例外が静かに恒久化するのを防ぐ唯一の方法です。例外ファイルをコードレビューの対象に置き、期限が近い項目を定期的に報告すれば管理できます。
python3 - <<'PY'
import datetime, yaml
data = yaml.safe_load(open(".trivyignore.yaml"))
today = datetime.date.today()
for v in data.get("vulnerabilities", []):
due = v["expired_at"]
left = (due - today).days
if left < 30:
print(f"{v['id']} 残り {left} 日で期限切れ {v['statement'][:40]}")
PY
最後の段階はスキャン結果を強制力に変えることです。CI で通過したイメージに署名を付け、クラスタが署名済みイメージだけを受け入れるようにします。
cosign sign --yes registry.acme.com/payments:1.5.0
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json registry.acme.com/payments:1.5.0
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: check-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- registry.acme.com/*
attestors:
- entries:
- keyless:
subject: https://github.com/acme/payments/.github/workflows/release.yaml@refs/heads/main
issuer: https://token.actions.githubusercontent.com
このポリシーがあれば、パイプラインを迂回して手でプッシュしたイメージはクラスタに入れません。スキャンゲートが実際に強制されるのはこの時点です。それまでは迂回可能な勧告に近いものです。
おわりに — レポートの数字ではなく対処可能な項目を見てください
スキャン結果を読む順序は三段階に整理できます。
まず --ignore-unfixed を付けます。修正版がない項目は今日できることがなく、一覧に残しておくと残りを覆い隠します。このオプション一つでたいてい数字は二桁以下に落ちます。
次に KEV と EPSS で優先順位を決めます。CVSS Critical 九件のうち実際の悪用が確認されたものが一件なら、今日の作業はその一件です。深刻度の等級は並べ替えの基準に使うには情報量が少なすぎます。
そして残った項目を個別にパッチする前にベースイメージを見ます。アプリケーションが実際にリンクしている共有ライブラリが八個なのにイメージにパッケージが 432 個入っているなら、問題は脆弱性ではなくイメージの構成です。ディストロレスや最小イメージへ移す一日が、個別 CVE 対応の数週間を置き換える場合はよくあります。
最後に、例外には必ず期限を入れてください。理由もなく永久に無視された項目が積み上がると、例外ファイルそのものが新しい死角になります。スキャン結果の価値は見つけた件数ではなく、閉じた件数で測られます。
현재 단락 (1/197)
イメージスキャナーをパイプラインに初めて付けると、たいていこういう出力を見ることになります。