Skip to content

Split View: 운영 이미지를 Trivy 로 검사했더니 — 768건 중 실제로 손댈 것은 세 패키지와 Dockerfile 한 줄이었다

|

운영 이미지를 Trivy 로 검사했더니 — 768건 중 실제로 손댈 것은 세 패키지와 Dockerfile 한 줄이었다

왜 검사했나

이 사이트는 파이썬 백엔드 하나가 로그인·블로그·언어 학습·실습 채점을 다 합니다. 의존성은 requirements.lock 에 해시까지 고정되어 있어 "무엇이 들어 있는지" 는 명확하지만, "그 안에 알려진 취약점이 있는지" 는 한 번도 재 본 적이 없었습니다. Trivy 로 두 곳을 검사했습니다.

  • 저장소requirements.txt 의 파이썬 의존성, 쿠버네티스 매니페스트, 비밀 문자열
  • 운영 이미지 — 실제로 떠 있는 컨테이너의 파일시스템(OS 패키지 + 설치된 파이썬 패키지)

둘째 것이 핵심입니다. 저장소만 보면 OS 패키지가 빠지고, 이미지만 보면 어디서 고쳐야 하는지가 안 보입니다.

운영 이미지 안에서 검사하는 방법

이미지 레지스트리(Harbor)는 인증이 필요하고 파드에는 pull secret 이 없습니다(노드가 자격증명을 가집니다). 그래서 레지스트리에서 이미지를 꺼내는 대신, 운영 이미지 자체를 컨테이너로 띄우고 init 컨테이너로 Trivy 바이너리를 넣어 그 안에서 루트 파일시스템을 검사했습니다.

initContainers:
  - name: get-trivy
    image: docker.io/aquasec/trivy:0.58.0
    command: ["cp", "/usr/local/bin/trivy", "/tools/trivy"]
    volumeMounts: [{ name: tools, mountPath: /tools }]
containers:
  - name: scan
    image: 192.168.219.202/labhub/backend:build-634      # 운영과 같은 이미지
    command: ["sh", "-c", "/tools/trivy rootfs --scanners vuln --format json /"]

자격증명 없이 운영 이미지를 검사할 수 있고, 노드가 이미지를 이미 갖고 있어 빠릅니다. 처음에는 runAsUser: 0 을 넣었다가 CronJob 템플릿의 runAsNonRoot 정책에 걸려 파드가 Pending 으로 멈췄습니다 — 빼니 됐습니다. 루트 파일시스템은 일반 사용자로도 읽힙니다.

첫 결과: 935건 — 그중 167건은 검사 도구 자신

{'LOW': 207, 'HIGH': 348, 'MEDIUM': 340, 'CRITICAL': 12, 'UNKNOWN': 28}
대상별: debian 13.6 → 720 · Python → 48 · tools/trivy → 167

세 번째 줄을 보십시오. tools/trivy 167건 — 제가 init 컨테이너로 넣은 Trivy 바이너리(Go 프로그램)의 의존성입니다. 검사 도구가 검사 대상 안에 있으니 자기 자신을 세었습니다. CRITICAL 12건 중 4건(go-git, x/crypto, grpc, Go stdlib)이 그것이었습니다. 빼고 다시 세면 이렇습니다.

검사 도구 제외: {'LOW': 197, 'HIGH': 275, 'MEDIUM': 264, 'CRITICAL': 8, 'UNKNOWN': 24}  = 768건
그중 고칠 판이 있는 것: {'CRITICAL': 1, 'HIGH': 53, 'MEDIUM': 55, 'LOW': 32}          = 141건

768건이라는 숫자는 겁을 주지만, 조치가 정해지는 것은 "고칠 판이 있는가" 로 갈랐을 때입니다. 141건이 손댈 수 있는 것이고, 나머지 627건은 데비안에 아직 수정판이 없는 것입니다(그 안에 CRITICAL 7건 — glib, mbedtls, libxml2, perl-base).

고칠 수 있는 141건은 결국 세 덩어리

심각도어디무엇지금 → 고친 판
CRITICAL파이썬authlib1.4.0 → 1.6.12 (10건)
HIGH파이썬Pillow11.3.0 → 12.3.0 (18건)
HIGH파이썬python-multipart0.0.20 → 0.0.31 (7건)
HIGH파이썬starlette0.41.3 → 0.49.1 이상 (7건, fastapi 가 끌어옴)
HIGHOSutil-linux 계열 9개2.41-5 → 2.41.5-0+deb13u1 (13건)
HIGHOSopenssl 계열 3개3.5.6 → 3.5.7 (10건)
MEDIUM파이썬pip25.0.1 → 26.x (6건)

authlib — 로그인의 서명 검증 우회

가장 먼저 봐야 할 것은 CRITICAL 하나입니다. CVE-2026-27962, JWS 의 JWK 헤더 주입입니다. key=None 으로 토큰을 검증하면 라이브러리가 토큰 안에 들어 있는 jwk 헤더의 키로 서명을 확인합니다. 공격자가 자기 키로 서명하고 그 키를 헤더에 넣으면 통과합니다. 1.6.9 에서 고쳐졌습니다.

이 사이트가 그 경로를 타는지 확인했습니다. authlib 을 부르는 곳은 한 군데입니다.

token = await oauth.google.authorize_access_token(request)

starlette 클라이언트는 구글의 JWKS 로 받은 키를 명시해 ID 토큰을 검증하므로 key=None 경로를 직접 타지는 않습니다. 그래도 그 라이브러리를 그 판으로 두고 있을 이유가 없어 올렸습니다.

OS 패키지 — 다이제스트 고정의 뒷면

util-linux 와 openssl 은 데비안에 수정판이 있는데 왜 이미지에 없었을까요. Dockerfile 이 이렇게 시작했기 때문입니다.

FROM python:3.12-slim@sha256:2c941e86…      # 다이제스트로 고정
RUN apt-get update && apt-get install -y --no-install-recommends fonts-nanum ffmpeg

다이제스트로 고정하면 빌드가 재현되지만, OS 보안 수정이 영원히 들어오지 않습니다. apt-get install 은 새 패키지를 넣을 뿐 이미 있는 것을 올리지 않습니다. 두 줄을 바꿨습니다 — 다이제스트를 지금 것으로 올리고, apt-get upgrade -y 를 넣어 빌드마다 그 시점의 수정을 받게 했습니다.

고친 뒤 다시 재기

같은 Dockerfile 로 로컬에서 빌드해 같은 방법으로 다시 검사했습니다.

고치기 전고친 뒤
전체(검사 도구 제외)768640
고칠 판이 있는 것14113
그중 CRITICAL10
그중 HIGH533
util-linux2.41-52.41.5-0+deb13u1
openssl3.5.6-1~deb13u23.5.7-1~deb13u2

남은 HIGH 3건은 starlette 1.x 에서만 고쳐지는 것이라 fastapi 판올림과 함께 따로 다룹니다. 남은 CRITICAL 7건은 데비안에 아직 수정이 없어 이 이미지로는 닿지 않습니다 — 그것은 "고쳤다" 가 아니라 "기다린다" 로 적어 두어야 합니다.

판올림이 무해하다는 것을 어떻게 아나

세 패키지를 올리고 lock 을 다시 만들면 다른 것도 따라 움직일 수 있습니다. 운영 빌드와 같은 python:3.12 + pip-tools 7.5.1 로 lock 을 다시 만들었더니 바뀐 것은 세 패키지뿐이었습니다.

그 lock 을 --require-hashes 로 설치한 컨테이너에서 전체 시험 2,014개를 돌렸습니다. 4개가 실패했습니다. 여기서 멈추면 "판올림이 무언가를 깼다" 로 읽힐 수 있습니다. 그래서 같은 컨테이너에 원래 lock 을 설치하고 같은 4개를 다시 돌렸습니다 — 똑같이 실패합니다. 로컬 스택(실행 중인 서비스, 훅, 채점 도구)이 필요한 시험들이었습니다. 대조군이 없으면 "무관하다" 는 말은 추정입니다.

Pillow 는 큰 판 하나를 건너뛰는 것이라 사용처를 따로 봤습니다. Image.open, ImageDraw, ImageFont.truetype, ImageCms, ImageOps — 전부 12.x 에 그대로 있습니다.

비밀 탐지: 12건, 실제 유출 0건

Trivy 의 비밀 탐지는 12건을 냈습니다. 하나씩 열어 봤습니다.

탐지실체
RSA 개인키 2개실습 재료 — gen-tokens.py 가 만드는 데모 키(demo-key-id-001)
JWT 토큰 3개실습 본문의 예시 토큰
Slack 웹훅도구 안의 예시 문자열(curl 빌더의 정규식)

12건 모두 실습 교재이거나 예시입니다. 다만 "실습 재료라서 괜찮다" 는 판단은 파일을 열어 본 뒤에만 할 수 있습니다. 목록만 보고 넘기면 진짜가 섞여 있어도 모릅니다.

정리

  • 저장소와 운영 이미지를 둘 다 검사한다. 저장소만 보면 OS 가 빠지고, 이미지만 보면 고칠 자리가 안 보인다.
  • 검사 도구를 컨테이너 안에 넣었다면 결과에서 그 도구 자신을 뺀다. 167건이 그것이었다.
  • 숫자를 겁내지 말고 "고칠 판이 있는가" 로 가른다. 768건 중 141건, 실제 조치는 세 패키지와 Dockerfile 두 줄.
  • 다이제스트로 고정한 베이스 이미지에는 apt-get upgrade 가 없으면 OS 수정이 영원히 안 들어온다.
  • 판올림 뒤 실패한 시험은 원래 판으로 같은 시험을 다시 돌려 대조한다.
  • 비밀 탐지는 열어 보기 전까지 판단하지 않는다.

Scanning the production image with Trivy — 768 findings, and the real work was three packages and one Dockerfile line

Why scan

One Python backend runs this whole site — login, blog, language learning, lab grading. Dependencies are pinned with hashes in requirements.lock, so what is inside is clear; whether any of it has known vulnerabilities had never been measured. I ran Trivy over two targets.

  • The repository — Python dependencies in requirements.txt, Kubernetes manifests, secret strings
  • The production image — the filesystem of the container that is actually running (OS packages + installed Python packages)

The second one matters most. The repository alone misses OS packages; the image alone does not show where to fix things.

Scanning inside the production image

The registry (Harbor) requires authentication and the pods carry no pull secret (the nodes hold the credentials). So instead of pulling the image out of the registry, I ran the production image itself as a container, copied the Trivy binary in with an init container, and scanned the root filesystem from inside.

initContainers:
  - name: get-trivy
    image: docker.io/aquasec/trivy:0.58.0
    command: ["cp", "/usr/local/bin/trivy", "/tools/trivy"]
    volumeMounts: [{ name: tools, mountPath: /tools }]
containers:
  - name: scan
    image: 192.168.219.202/labhub/backend:build-634      # same image as production
    command: ["sh", "-c", "/tools/trivy rootfs --scanners vuln --format json /"]

No credentials needed, and the node already has the image so it is fast. My first attempt set runAsUser: 0, which collided with the CronJob template's runAsNonRoot policy and left the pod Pending — removing it fixed that. The root filesystem is readable as an ordinary user.

First result: 935 findings — 167 of them were the scanner

{'LOW': 207, 'HIGH': 348, 'MEDIUM': 340, 'CRITICAL': 12, 'UNKNOWN': 28}
by target: debian 13.6 → 720 · Python → 48 · tools/trivy → 167

Look at the third line. tools/trivy, 167 findings — the dependencies of the Trivy binary (a Go program) I had copied in with the init container. The scanner was inside the scan target, so it counted itself. Four of the twelve CRITICALs (go-git, x/crypto, grpc, the Go stdlib) were exactly that. Excluding it:

without the scanner: {'LOW': 197, 'HIGH': 275, 'MEDIUM': 264, 'CRITICAL': 8, 'UNKNOWN': 24}  = 768
with a fixed version available: {'CRITICAL': 1, 'HIGH': 53, 'MEDIUM': 55, 'LOW': 32}          = 141

768 is a frightening number, but action only becomes clear once you split by "is there a fixed version?" 141 can be acted on; the other 627 have no fix in Debian yet (including 7 CRITICALs — glib, mbedtls, libxml2, perl-base).

The 141 fixable ones come down to three groups

SeverityWhereWhatNow → fixed
CRITICALPythonauthlib1.4.0 → 1.6.12 (10)
HIGHPythonPillow11.3.0 → 12.3.0 (18)
HIGHPythonpython-multipart0.0.20 → 0.0.31 (7)
HIGHPythonstarlette0.41.3 → 0.49.1+ (7, pulled in by fastapi)
HIGHOSutil-linux family (9 pkgs)2.41-5 → 2.41.5-0+deb13u1 (13)
HIGHOSopenssl family (3 pkgs)3.5.6 → 3.5.7 (10)
MEDIUMPythonpip25.0.1 → 26.x (6)

authlib — signature verification bypass in login

The single CRITICAL comes first. CVE-2026-27962, JWK header injection in JWS. If a token is verified with key=None, the library takes the key from the token's own jwk header to check the signature. An attacker signs with their own key, puts that key in the header, and it passes. Fixed in 1.6.9.

I checked whether this site takes that path. authlib is called in exactly one place:

token = await oauth.google.authorize_access_token(request)

The starlette client verifies the ID token against keys fetched from Google's JWKS, so it does not hit the key=None path directly. There was still no reason to keep the library at that version, so it was bumped.

OS packages — the other side of digest pinning

util-linux and openssl had fixes in Debian. Why were they missing from the image? Because the Dockerfile started like this:

FROM python:3.12-slim@sha256:2c941e86…      # pinned by digest
RUN apt-get update && apt-get install -y --no-install-recommends fonts-nanum ffmpeg

Pinning by digest makes builds reproducible, but OS security fixes never arrive. apt-get install adds new packages; it does not upgrade the ones already there. Two lines changed — the digest was bumped to the current one and apt-get upgrade -y was added, so every build picks up the fixes available at that moment.

Measuring again after the fix

I built the same Dockerfile locally and scanned it the same way.

BeforeAfter
Total (scanner excluded)768640
With a fixed version available14113
of which CRITICAL10
of which HIGH533
util-linux2.41-52.41.5-0+deb13u1
openssl3.5.6-1~deb13u23.5.7-1~deb13u2

The remaining 3 HIGH are fixed only in starlette 1.x and are handled separately together with a fastapi upgrade. The remaining 7 CRITICALs have no Debian fix yet, so this image cannot reach them — that has to be recorded as "waiting", not "fixed".

How do you know the upgrades are harmless

Bumping three packages and regenerating the lock can move other things too. Regenerated with the same python:3.12 + pip-tools 7.5.1 as the production build, only those three packages changed.

I installed that lock with --require-hashes in a container and ran the whole suite of 2,014 tests. Four failed. Stopping there would read as "the upgrade broke something". So I installed the original lock in the same container and ran the same four again — they fail identically. They are tests that need the local stack (running services, hooks, grading tools). Without a control group, "unrelated" is a guess.

Pillow skips a major version, so its call sites were checked separately: Image.open, ImageDraw, ImageFont.truetype, ImageCms, ImageOps — all still present in 12.x.

Secret detection: 12 hits, 0 real leaks

Trivy's secret scanner reported 12 hits. I opened every one.

DetectedWhat it actually was
2 RSA private keysLab fixtures — demo keys generated by gen-tokens.py (demo-key-id-001)
3 JWT tokensExample tokens in lab text
1 Slack webhookAn example string inside a tool (the curl builder's regex)

All 12 are teaching material or examples. But "it's lab material, so it's fine" is a judgment you can only make after opening the file. Skim the list and a real one could hide among them.

Takeaways

  • Scan both the repository and the production image. The repo misses the OS; the image hides where to fix.
  • If you put the scanner inside the container, subtract the scanner itself from the results. That was 167 findings.
  • Do not be scared by the count; split by "is there a fixed version?" 768 became 141, and the real work was three packages and two Dockerfile lines.
  • A digest-pinned base image never receives OS fixes unless the build runs apt-get upgrade.
  • When tests fail after an upgrade, run the same tests on the original version as a control.
  • Never judge a secret-detection hit without opening the file.