🚀 요약

SUMMARY

폐쇄망에 이미지를 통째로 넣어 납품하다 보니 “우리가 넣는 이미지에 알려진 취약점(CVE)이 몇 개냐”에 답할 수 있어야 했다. 이미지는 trivy, Go 코드는 govulncheck로 스캔한다. 정작 어려운 건 그다음이라, 무작정 버전을 올리는 대신 공식 업그레이드→하드닝 이미지→직접 빌드→대체 이미지→추적(no-fix)으로 이어지는 대응 사다리를 세우고 결정을 원장에 남겼다.

CVE 하나에 무작정 최신 버전을 올렸다간 breaking change나 라이선스 문제가 튀어나온다.

1. 무엇을 스캔하나

두 층위를 본다.

  • 컨테이너 이미지 — trivy. 배포하는 이미지 목록을 뽑아 각각 trivy image로 스캔한다. OS 패키지(apt/apk)와 언어 패키지(Go 바이너리·npm·pip 등)를 다 본다. 기본은 CRITICAL만, 필요하면 HIGH까지 넓힌다.
  • 내가 만든 Go 코드 — govulncheck. Deck 같은 자체 바이너리는 govulncheck로 본다. “취약한 의존성이 있냐”가 아니라 그 취약 함수를 실제로 호출하는 경로가 있냐까지 봐서 노이즈가 적다. CI의 상시 보안 잡(make vuln)으로 돌린다.

스캔 결과는 시점 산출물이라는 걸 늘 염두에 둔다. CVE 데이터베이스가 갱신되면 어제 깨끗하던 이미지가 오늘 취약으로 뜬다. 그래서 스캔 결과 자체는 회차별로 쌓되 git엔 안 올리고, 대응 결정만 원장에 남긴다.

2. 결과를 읽는 법: fixed가 있냐 없냐

스캔 리포트에서 이미지별 상태는 대략 ok(0건) / vuln(검출) / failed(pull 실패)로 갈린다. failed는 대개 사설 레지스트리라 인증이 필요한 경우다(로그인 후 재시도).

vuln으로 뜬 CVE에서 제일 먼저 확인하는 건 fixed version이 있느냐다.

  • fixed 있음 → 그 버전 이상으로 올리면 해소된다. 대응의 출발점.
  • fixed 없음(no fix) → 업스트림이 아직 안 고친 것. 올려도 안 사라진다. 억지로 뭘 하기보다 추적 대상으로 관리한다.

이 구분이 다음 사다리의 전제가 된다.

3. 대응 사다리

원칙은 하나다. 무작정 최신으로 올리지 않는다. 후보를 정해 각각 trivy로 스캔해서 CVE가 실제로 사라지는 최선 옵션을 고른다. 위에서부터 시도하고, 막히면 한 칸 내려간다.

1. 공식 업그레이드   ── 후보(최신 stable·LTS·동일 메이저 최신 패치)를 스캔 → 해소되면 채택
       │ (breaking / license / no-fix 로 막히면)
2. 하드닝 이미지     ── 벤더가 미리 취약점을 줄여둔 대체 이미지를 스캔 → 해소되면 채택
       │ (미수록이거나 여전히 안 되면)
3. self-built        ── upstream 기반 + 취약 패키지만 패치한 Dockerfile → 빌드 → 재스캔으로 검증
       │ (라이선스·재컴파일 제약으로 막히면)
4. replace           ── 유지보수되는 대체 이미지로 갈아탐 (EOL 이미지 탈출)
       │ (전부 소진되면)
5. no-fix            ── 업스트림 미해결. 근거·잔여 CVE·도달성을 적어 추적

1. 공식 업그레이드. 실험적 버전은 피하고 LTS·동일 메이저 최신 패치를 우선한다. 새 메이저로 점프하면 CVE는 사라져도 breaking change가 딸려오기 쉽다. 후보를 스캔해 해소되고 breaking·라이선스 이슈가 없으면 여기서 끝낸다.

2. 하드닝 이미지. 공식이 안 되면 벤더가 취약점을 미리 깎아 만든 하드닝 이미지(예: Docker Hardened Image)를 검토한다. 다만 이런 이미지는 실행 UID나 볼륨 소유권이 원본과 다른 경우가 있다. 실제로 CRITICAL 0건짜리 대체 이미지가 있었는데, 실행 UID가 달라 기존 데이터 볼륨(PVC) 소유권과 안 맞아 단순 교체가 불가능했다.

3. self-built. 1·2로 안 되면 직접 빌드한다. 이미지 전체를 새로 짜는 게 아니라, upstream 이미지를 베이스로 두고 취약한 바이너리 하나만 걷어내는 식이다. 예를 들어 어떤 공식 DB 이미지의 CVE가 DB 본체가 아니라 동봉된 gosu의 Go stdlib에서 나온 적이 있다. 그럴 땐 본체·entrypoint·UID·볼륨은 그대로 두고, 취약한 gosu만 OS 기본 유틸(setpriv) 기반 호환 wrapper로 교체해 최종 이미지에서 제외했다. 그리고 재스캔으로 CVE가 실제로 사라졌는지 검증한 뒤에야 채택한다.

4. replace. 문제가 이미지 하나가 아니라 계열 전체가 EOL이면 갈아탄다. 널리 쓰이던 어떤 벤더 이미지 묶음이 유지보수 종료(legacy/동결)로 전환된 적이 있는데, 이 경우 개별 CVE를 하나씩 잡는 건 밑 빠진 독이다. 유지보수되는 공식 이미지로 차트째 마이그레이션하는 게 답이었다.

5. no-fix. 사다리를 다 소진해도 안 되는 게 있다. 업스트림이 자체 코드의 CVE를 아직 안 고쳤거나, fix는 있지만 바이너리에 내장된 의존성이라 소스 재컴파일이 필요한데 그게 라이선스(예: AGPL 소스 공개 의무)를 건드리는 경우다. 이럴 땐 억지로 건드리지 않고, 왜 못 고치는지·잔여 CVE·실제 도달 가능성(예: 폐쇄망이라 해당 경로가 노출 안 됨)을 근거와 함께 적어 추적으로 남긴다.

4. 결정을 원장에 남기기

제일 신경 쓴 건 대응 이력을 영구 기록으로 남기는 것이다. 스캔 결과는 시점마다 바뀌지만, “이 CVE를 이렇게 대응했다”는 결정은 남아야 한다. 그래서 이미지 단위의 원장(YAML)을 두고 항목마다 이렇게 적는다.

  • 대응 전 이미지 ref, 사용처(어떤 차트들이 공유하는지)
  • 대상 CVE 목록
  • 대응 방식(upgrade / hardened / self-built / replace / no-fix)과 결과 ref
  • 사유(breaking·마이그레이션·라이선스·잔여 CVE)와 근거 URL

이게 있으면 반년 뒤 같은 이미지가 다시 취약으로 떠도 “저번에 왜 이렇게 했는지”를 다시 조사하지 않아도 된다. 여러 차트가 같은 이미지를 공유할 때 한 번 대응하고 사용처 전부에 반영하는 것도 원장 덕에 놓치지 않는다.

해보고 나서 남은 생각은 하나다. CVE 대응은 “버전 올리기”가 아니라 의사결정이다. 올릴 수 있으면 올리는 게 맞지만, 올릴 수 없거나 올리면 안 되는 경우(breaking·EOL·라이선스)가 생각보다 많다. 어디까지 시도하고 어디서 멈출지를 사다리로 정해두면 매번 헤매지 않고, 멈춘 이유를 원장에 적어두면 나중의 나와 남이 그 결정을 믿을 수 있다.

🔗 참고