Trivy로 CVE 1,400개 알림 폭탄 맞은 후, 우리 팀이 한 일
지난주 화요일 아침, 팀 슬랙의 #security-alert 채널을 열었더니 빨간 메시지가 화면을 가득 채우고 있었다. 멘탈이 잠깐 나갔다. CI 파이프라인에 Trivy 스캔을 새로 붙인 첫날이었고, 우리가 운영하는 서비스 47개 중 31개가 한 번에 fail 처리됐다. 보고된 CVE만 1,400개가 넘었다.
당연히 본부장이 디엠을 보냈다. "이거 다 수정해야 하나요?"
솔직히 말하면 그 순간엔 답을 못 했다. 이 글은 그 1,400개의 알림 폭탄을 정리하면서 우리 팀이 어떻게 신호와 소음을 분리했는지에 대한 회고다. 결론부터 말하면, 진짜로 손대야 했던 건 23개였다.
처음에 뭐가 잘못됐나
일단 우리가 한 게 뭐였냐면, trivy image 기본 옵션으로 모든 이미지를 스캔하고 결과를 그대로 슬랙에 던진 거였다. 정말 단순했다. CI 마지막 스텝에 한 줄 추가했을 뿐이다.
- name: Trivy scan
run: trivy image --exit-code 1 ${{ steps.build.outputs.image }}
이게 왜 망했냐. Trivy 기본 동작은 LOW부터 CRITICAL까지 전부 보고한다. 그리고 우리 베이스 이미지 중 일부가 좀 오래된 node:18-alpine이었는데, glibc 의존성 안 걸려있어도 라이브러리 메타데이터에서 잡히는 CVE가 수두룩했다. 게다가 패치가 나오지 않은 unfixed CVE도 그대로 카운트됐다.
처음 30분간은 "이거 다 막아야 하는 거 아닌가?" 싶어서 진지하게 봤는데, 보면 볼수록 좀 이상했다. 같은 CVE-2023-XXXX가 50개 서비스에서 똑같이 잡히고, fixed_version 칸이 비어있는 게 절반이 넘었다. 패치도 없는 걸 어떻게 막으라는 건지.
일단 멈추고, 분류부터 했다
새벽까지 다 고치겠다고 달려들 뻔했는데 다행히 한 발 물러섰다. 팀 시니어가 한마디 했다. "이거 다 진짜 위협이야?" 아니었다.
그래서 다음 날 아침 회의 잡고 화이트보드에 네 칸 그렸다.
- 패치 있음 + 실제 호출 경로에 있음 → 즉시 수정
- 패치 있음 + 호출 경로에 없음 → 다음 스프린트
- 패치 없음 + 위험도 높음 → 모니터링 + 워크어라운드
- 패치 없음 + 위험도 낮음 → 무시 (문서화)
이걸 기준으로 1,400개를 분류했다. 정확히는 분류 자체를 자동화했다는 게 맞다. 사람이 1,400개를 보는 건 불가능하다.
자동 분류 파이프라인
스캔 결과를 JSON으로 뽑고 jq로 필터링하는 작은 스크립트를 짰다. 전체 코드는 길지만 핵심은 이거다.
trivy image --format json \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--detection-priority precise \
${IMAGE} > scan.json
jq '[.Results[]?.Vulnerabilities[]? | select(.FixedVersion != null)] | length' scan.json
세 가지 옵션이 핵심이었다.
--severity HIGH,CRITICAL — 일단 LOW/MEDIUM은 빼고 본다. 이게 정답이라는 게 아니라, 우리 팀이 1,400개 중 진짜 봐야 할 것부터 보기 위한 1차 필터였다. MEDIUM은 별도 주간 리포트로 돌리기로 했다.
--ignore-unfixed — 패치가 없으면 일단 빼라. 이건 좀 논쟁이 있었다. 혹자는 unfixed CVE도 트래킹해야 한다고 했고 일리가 있다. 그래서 우리는 unfixed는 fail은 안 시키지만 별도 대시보드에 쌓기로 했다.
--detection-priority precise — 이게 좀 신박한 옵션인데, Trivy v0.57부터 들어왔다. 라이브러리 메타데이터만 보고 잡는 false positive를 줄여준다. 실제로 이거 켜고 나니 알림이 30% 정도 더 줄었다.
이렇게 하니까 1,400개에서 약 180개로 줄었다. 여전히 많지만, 이제 사람이 한 번씩 훑어볼 수 있는 양이다.
.trivyignore는 신중하게
180개 중에서도 "이건 우리 환경에선 트리거 안 된다"가 명백한 게 꽤 있었다. 예를 들어 우리 백엔드 컨테이너에 들어있는 libxml2 CVE 같은 거. XML 파싱을 외부 입력에서 한 적이 없는 서비스라 실제 위험은 낮다.
이런 건 .trivyignore 파일에 넣었는데, 한 가지 룰을 정했다. 이유 없이 추가하면 PR 리젝.
# .trivyignore
# CVE-2024-XXXXX
# 사유: libxml2 - 외부 XML 입력 없음 (백엔드 API 명세 확인 완료)
# 검토자: @woogil 2026-04-22
# 재검토일: 2026-07-22
CVE-2024-XXXXX
# CVE-2025-YYYYY
# 사유: glibc - 컨테이너 내부에서 setuid 사용 안 함
# 검토자: @sec-team 2026-04-23
CVE-2025-YYYYY
매 분기 재검토 날짜를 박아놓는 것도 중요하다. 안 그러면 ignore 파일이 영원히 자라기만 한다. 우리 팀에서는 매 분기 첫 주에 만료된 항목을 자동으로 PR로 띄우는 작은 GitHub Actions 워크플로우를 따로 돌리고 있다.
베이스 이미지 정리가 진짜 약발이었다
180개에서 추가로 손댄 건 베이스 이미지 정리였다. 우리 서비스 31개 중 19개가 node:18-alpine 기반이었는데, 이걸 node:22-alpine(당시 LTS) 또는 distroless로 옮기는 PR을 일주일에 걸쳐 진행했다.
그냥 베이스만 바꿨는데 CVE 수가 그 19개 서비스에서 평균 78%가 사라졌다. 이게 가장 큰 약발이었다. 사실 새로운 베이스 이미지를 쓰는 게 .trivyignore 100줄 추가하는 것보다 훨씬 효과적이라는 걸 다시 확인한 셈이다.
distroless로 옮긴 것 중 일부는 디버깅이 좀 까다로워졌는데, kubectl debug --image로 임시 사이드카 띄워서 보는 식으로 우회했다. 이건 또 다른 글에서 다뤄볼까 한다.
그래서 진짜로 손댄 건 23개
최종적으로 다음 스프린트 전까지 즉시 패치한 CVE는 23개였다. 처음 1,400개에서 23개. 비율로 따지면 1.6%다.
이게 의미하는 건, 보안 스캔의 기본값을 그대로 쓰면 노이즈가 너무 커서 진짜 위협을 묻어버린다는 거다. 1,400개 알림이 매일 오면 사람들은 그냥 채널 음소거해버린다. 그러면 정말 중요한 23개도 묻힌다.
지금은 우리 팀 CI에서 Trivy가 다음과 같이 동작한다:
- HIGH/CRITICAL + fixed + precise 모드만 fail
- MEDIUM은 별도 리포트, 주간 회의에서 검토
- unfixed는 대시보드에만 적재
- .trivyignore는 분기마다 만료/재검토
이게 정답이라고 주장하는 건 아니다. 우리 팀 규모와 위험 허용도에 맞춘 거다. 다른 팀에서는 더 엄격하게 가야 할 수도 있고, 더 느슨하게 가도 되는 곳도 있을 거다.
마무리
배운 건 두 가지다. 첫째, 보안 도구를 도입할 때 기본값을 그대로 쓰는 건 보안을 망치는 가장 빠른 길이다. 도구가 던지는 모든 신호를 동등하게 취급하면 결국 아무도 신호를 안 본다. 둘째, 분류 기준을 먼저 합의하고 그다음에 자동화하는 게 순서다. 우린 이 순서를 거꾸로 갈 뻔했다.
다음에는 .trivyignore 자동 만료 워크플로우를 어떻게 짰는지 정리해보려고 한다. 혹시 비슷하게 CVE 노이즈 잡으신 분들 어떻게 하시는지 댓글 남겨주시면 좋겠다.