Skip to content
Published on

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

공유하기
Authors

들어가며 — 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 대응 몇 주를 대체하는 경우가 흔합니다.

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