Skip to content

Split View: 컨테이너 이미지 취약점 스캔 결과 읽는 법 — Critical 수백 개를 0으로 만드는 실제 방법

✨ Learn with Quiz
|

컨테이너 이미지 취약점 스캔 결과 읽는 법 — Critical 수백 개를 0으로 만드는 실제 방법

들어가며 — 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여 건을 없애는 방법은 하나씩 패치하는 것이 아닙니다.

이 글은 스캐너가 무엇을 보고 무엇을 못 보는지, 어떤 지표로 걸러야 하는지, 그리고 실무에서 이 숫자를 실제로 두 자리 이하로 떨어뜨리는 조치가 무엇인지 다룹니다.

스캐너가 실제로 하는 일 — 목록과 데이터베이스의 대조

이미지 스캐너는 이미지를 실행하지도, 코드를 분석하지도 않습니다. 하는 일은 두 단계입니다.

첫째, 이미지 레이어를 풀어 설치된 패키지 목록을 만듭니다. 데비안 계열이면 /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에 취약합니다. 그런데 데비안은 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

버전 문자열만 보고 상위 데이터베이스의 범위와 대조하는 도구는 이것을 취약하다고 보고합니다. 그래서 스캐너 선택 기준이 하나 생깁니다. 배포판의 보안 권고를 데이터 소스로 쓰는 스캐너를 써야 합니다. 상위 데이터베이스의 버전 범위만 참조하는 도구는 데비안, 우분투, 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건이 됐습니다. 아무것도 안전해지지 않았지만, 지금 조치할 수 있는 항목만 남았습니다. 고칠 방법이 없는 항목을 대기열에 넣어 두는 것은 대기열을 무의미하게 만듭니다.

스캐너가 보지 못하는 것

스캔이 통과했다고 이미지가 안전한 것은 아닙니다. 구조상 볼 수 없는 것들이 있습니다.

애플리케이션 코드입니다. 우리가 직접 작성한 코드의 인젝션, 인가 누락, 안전하지 않은 역직렬화는 이미지 스캐너의 대상이 아닙니다. 실제 침해 사고에서 진입점이 되는 비중은 오히려 이쪽이 큽니다.

설정입니다. 루트로 실행되는 컨테이너, 과도한 권한, 호스트 경로 마운트, 쓰기 가능한 루트 파일 시스템은 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:221.12 GB4329137포함
node:22-slim231 MB118224포함
node:22-alpine148 MB4103포함
gcr.io/distroless/nodejs22-debian12187 MB1901없음
정적 바이너리 + scratch24 MB000없음

숫자의 절대값은 스캔 시점에 따라 달라지지만 경향은 항상 같습니다. 이미지에 파일이 적을수록 취약점이 적습니다. 당연한 말 같지만, 실무에서 이 결론에 도달하기 전까지는 대개 수십 시간을 개별 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 --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /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은 정적 링크되지 않으며 호출 경로 없음. 데비안 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 대응 몇 주를 대체하는 경우가 흔합니다.

마지막으로 예외에는 반드시 만료일을 넣으십시오. 이유 없이 영구히 무시된 항목이 쌓이면 예외 파일 자체가 새로운 사각지대가 됩니다. 스캔 결과의 가치는 발견한 건수가 아니라 닫힌 건수로 측정됩니다.

How to Read Container Image Vulnerability Scan Results — The Real Way to Turn Hundreds of Criticals Into Zero

Introduction — Standing in Front of a Report With Nine Criticals and 1,247 Findings

Attach an image scanner to your pipeline for the first time and this is usually the output you see.

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)

Paste this report into the team channel as is and one of two things follows. Everyone ignores it, or the release is blocked with nobody able to touch it. In neither case does security improve.

The problem is not the number but the interpretation. Of the 1,247 findings, the ones that actually need action are usually a single digit, and the way to find that single digit is not a severity filter. And the way to remove the remaining 1,200 or so is not patching them one at a time.

This post covers what a scanner sees and what it cannot see, which indicators you should filter by, and what action in practice actually drops this number to double digits or below.

What a Scanner Really Does — Diffing a List Against a Database

An image scanner neither runs the image nor analyzes the code. What it does happens in two steps.

First, it unpacks the image layers and builds a list of installed packages. For Debian derivatives it reads /var/lib/dpkg/status, for RPM derivatives the RPM database, and for language packages a lockfile or a metadata directory.

trivy image --list-all-pkgs --format json node:22 \
  | jq '[.Results[].Packages // [] | length] | add'
432

Second, it diffs the names and versions in that list against a vulnerability database. That is all.

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

Understand this structure and every property of a scanner follows from it.

If there is no package metadata, it is invisible. A library you built from source and dropped in, a binary pulled with curl and copied, a statically linked dependency: none of these appear in the list, so no vulnerability is reported for them. That is the first reason a clean scan result does not mean safe.

Conversely, if it is on the list it gets reported regardless of whether it runs. If tar is installed in the image, it shows up in the vulnerability list even when the application never calls it once. The last line of the output above, a CVE registered back in 2005 still appearing, has the same cause.

The Structure of False Positives — Vendor Backport Patches

The most frequent kind of false positive is the backport. For stability, a distribution ports only the security fix into the existing version without bumping the version number.

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

By upstream standards, 3.0.14 is vulnerable to a certain CVE. But Debian ported the fix into the deb12u2 revision. You can confirm it.

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

A tool that only looks at the version string and diffs it against the ranges in an upstream database reports this as vulnerable. So one criterion for choosing a scanner emerges. You have to use a scanner that consumes the distribution security advisories as a data source. A tool that references only the version ranges of the upstream database produces false positives in bulk on Debian, Ubuntu and RHEL images.

When the data source is wired up properly, a status field appears.

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 means the distribution security team reviewed the item and decided not to fix it in this release. In most cases there is no real impact, or the exploitation preconditions do not hold. affected means it is acknowledged but there is no fix yet.

This is where the most practically useful option comes in.

trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
Total: 4 (HIGH: 3, CRITICAL: 1)

1,247 findings became 4. Nothing got safer, but only the items you can act on right now remain. Keeping items with no available fix in the queue is what makes the queue meaningless.

What a Scanner Cannot See

A passing scan does not make an image safe. There are things it structurally cannot see.

Application code. Injection, missing authorization and unsafe deserialization in the code we wrote ourselves are not what an image scanner looks at. In real breaches, this side is actually the larger share of entry points.

Configuration. A container running as root, excessive privileges, host path mounts and a writable root file system are not CVEs, so they are not in the vulnerability list. They need a separate scanner.

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

Files that came in without metadata are also invisible. The following is a vulnerable binary that a scan will never catch.

RUN curl -fsSL https://example.com/tools/legacy-cli-1.2.0.tar.gz \
  | tar xz -C /usr/local/bin

Things that arrive by this path are in neither the SBOM nor the scan results. When you put something into an image, going through a package manager pays off in observability.

Finally, a scanner does not know whether the library is on an execution path. If it is installed, it gets reported. The following command gives you a rough sense of what is actually used.

# The shared libraries the process actually opens
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

Eight of them. Out of the 432 packages installed in the image, this is how many the application actually links against. Most of the rest came along with the base image, and this observation leads to the conclusion covered further down.

Why CVSS Alone Is Not Enough — EPSS and KEV

Setting priorities by CVSS score alone fails. CVSS is a technical assessment of the impact and difficulty if a vulnerability were exploited, not the probability that it actually will be. And the share of registered vulnerabilities for which exploitation is actually observed is on the order of a few percent.

Two indicators have to be read alongside it. EPSS is an estimate of the probability that exploitation will be observed within the next 30 days, and KEV is the list of vulnerabilities where exploitation has been confirmed.

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

The first has a 94 percent exploitation probability; the other two are under 1 percent. All three are rated Critical by CVSS. By severity alone the three look identical, but the urgency of the response is completely different.

Checking against KEV is even simpler.

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

Of the 1,247 findings, exactly one has confirmed exploitation. That one is the item to handle today; the rest can be pushed to a regular cycle.

To sum up, a priority judgment is the product of three axes. Exploitability (KEV, EPSS), exposure (is this a service reachable from the internet), and reachability (is that code on an execution path). CVSS answers none of the three.

Removing 1,247 Findings Is Not Patching but Replacing the Base Image

This is the practical core of the post. The most effective action for reducing the vulnerability count is not upgrading individual packages but making sure those packages are not in the image in the first place.

Put the same application on several bases and scan them, and the difference is obvious.

Base imageImage sizePackages detectedFixable CRITICALFixable HIGHShell and package manager
node:221.12 GB4329137included
node:22-slim231 MB118224included
node:22-alpine148 MB4103included
gcr.io/distroless/nodejs22-debian12187 MB1901none
static binary plus scratch24 MB000none

The absolute numbers vary with the scan date, but the trend is always the same. The fewer files in the image, the fewer vulnerabilities. It sounds obvious, but in practice teams usually spend dozens of hours on individual CVEs before arriving at this conclusion.

The transition is done with a multi-stage build.

# 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 --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]

The build stage can keep compilers and headers, because they do not go into the final image. Compare the results.

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 package vulnerabilities went to 0, and the two remaining findings are npm dependencies of our own application. The target for action is now unambiguous.

It helps to know in advance the three places where moving to distroless snags in practice.

There is no shell, so you cannot get in with kubectl exec. Debugging changes into attaching an ephemeral container.

kubectl debug -it pod/payments-7d9f -c debugger --image=busybox --target=app

Writing CMD in shell form does not work. It has to be exec form only. And any configuration that used curl or wget for health checks has to be changed. Implement it in the language runtime or move to a gRPC health check.

If you need CA certificates, you have to check whether they are included in the image. The nonroot tag of the distroless base has them; scratch does not.

Putting It in CI — Gate Criteria and Exceptions With Expiry Dates

The moment you put a scan into CI, a policy decision is required: under what condition do you fail the build.

Fail on every Critical and development stops. Do not fail at all and nobody looks. The criterion that stays stable in practice is the following combination.

trivy image \
  --scanners vuln \
  --severity CRITICAL,HIGH \
  --ignore-unfixed \
  --ignorefile .trivyignore.yaml \
  --exit-code 1 \
  registry.acme.com/payments:1.5.0

Only Criticals and Highs that have a fix available are subject to the gate. In other words, you fail only on "something you could fix right now and did not." Add on top of that a rule that treats any item on the KEV list as an immediate failure regardless of severity, and you will not miss the real risk.

if comm -12 /tmp/found.txt /tmp/kev.txt | grep -q .; then
  echo "A vulnerability on the KEV list is present. Halting the deployment."
  comm -12 /tmp/found.txt /tmp/kev.txt
  exit 1
fi

There is one rule that matters most in exception handling: forbid indefinite suppression. An exception file must always carry a reason and an expiry date.

# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2024-45491
    paths:
      - usr/lib/x86_64-linux-gnu/libexpat.so.1.8.10
    statement: This service does not parse untrusted XML. To be resolved by the August base image swap.
    expired_at: 2026-09-30

  - id: CVE-2023-45853
    statement: zlib is not statically linked and there is no call path. Debian verdict is will_not_fix.
    expired_at: 2026-10-31

Once the expiry date passes, the scanner fails again. It is the only way to keep an exception from quietly becoming permanent. Put the exception file under code review and report items nearing expiry periodically, and it stays manageable.

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']}  expires in {left} days  {v['statement'][:40]}")
PY

The last step is turning scan results into enforcement. Sign the images that passed CI, and make the cluster accept only signed images.

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

With this policy in place, an image pushed by hand around the pipeline cannot get into the cluster. This is the point at which the scan gate is actually enforced. Before that it is closer to an advisory you can bypass.

Wrapping Up — Look at the Actionable Items, Not the Number in the Report

Reading scan results comes down to three steps.

First, add --ignore-unfixed. For items with no fix there is nothing you can do today, and leaving them in the list hides everything else. This one option usually drops the number to double digits or below.

Next, set priorities with KEV and EPSS. If one of nine CVSS Criticals has confirmed exploitation, today's work is that one. A severity rating carries far too little information to be used as a sort key.

And before patching what remains one by one, look at the base image. If the application actually links against eight shared libraries while the image contains 432 packages, the problem is not the vulnerabilities but the image composition. One day of moving to distroless or a minimal image often replaces weeks of individual CVE work.

Finally, always put an expiry date on exceptions. Once items suppressed permanently without a reason pile up, the exception file itself becomes a new blind spot. The value of scan results is measured not by the number of findings opened but by the number closed.