Trivy CI 가 매일 SARIF 를 뱉으면서 4 개 백엔드 서비스에 Netty CVE 12 개 가 빨간 줄로 뜨고 있었습니다. 단순한 패치 같았는데 첫 시도(PR #1) 가 실패했고, 두 번째(PR #2) 에서 BOM 패턴으로 잡았습니다. 그 과정 정리.

이 글에서 다루는 것

  • CVE 무엇이었나 — Netty 4.1.119 의 알려진 12 개 취약점
  • PR #1 — constraints { ... } 만으로는 왜 부족했나 (transitive 못 잡음)
  • PR #2 — dependencyManagement { mavenBom("io.netty:netty-bom:...") } 로 일괄 처리
  • Trivy CI 결과로 검증
  • GitHub Actions 의 함정 (대소문자, SARIF 업로드, 푸시 순서)

1. 문제 — 4 개 서비스의 동일 CVE

api-gateway, user-service, catalog-service, media-service — 모두 Spring Boot 3.4.1 + Spring Cloud Gateway. Trivy 결과:

HIGH/CRITICAL CVE in netty-codec-4.1.119.Final.jar
  - CVE-2025-XXXXX (HTTP/2 부분 응답 처리 결함)
  - CVE-2025-XXXXX (Bzip2 디컴프레서 메모리 누수)
  - CVE-2025-XXXXX (DoS via malformed multipart)
  - ... 9 more

Netty 4.1.133 (당시 최신 stable) 에서 다 패치되어 있었습니다. 단순히 4 개 서비스의 의존성 버전을 올리면 끝.


2. PR #1 — constraints { } 만으로 시도 (실패)

처음 시도한 패치:

// services/api-gateway/build.gradle.kts
dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    constraints {
        implementation("io.netty:netty-codec:4.1.133.Final")
        implementation("io.netty:netty-codec-http:4.1.133.Final")
        implementation("io.netty:netty-codec-http2:4.1.133.Final")
        implementation("io.netty:netty-handler:4.1.133.Final")
    }
}

빌드 → 푸시 → Trivy 재스캔. 결과:

HIGH CVE in netty-buffer-4.1.119.Final.jar  ← 안 잡힘
HIGH CVE in netty-common-4.1.119.Final.jar  ← 안 잡힘
HIGH CVE in netty-resolver-4.1.119.Final.jar ← 안 잡힘

constraints 에 적은 4 개는 4.1.133 으로 잡혔는데, transitive 로 깔려오는 다른 netty 모듈 (netty-buffer, netty-common, netty-resolver, netty-transport, …) 은 여전히 4.1.119. Spring Cloud Gateway 가 끌고 오는 의존성 트리가 깊어서 일일이 다 못 잡았습니다.

⚠️ 교훈: constraints 는 “내가 적은 모듈” 만 강제. transitive 의 다른 모듈은 BOM 같은 도구가 필요.


3. PR #2 — Netty BOM 으로 일괄 (성공)

수정:

// services/api-gateway/build.gradle.kts
plugins {
    id("io.spring.dependency-management") version "1.1.7"
}

dependencyManagement {
    imports {
        mavenBom("io.netty:netty-bom:4.1.133.Final")  // ★ 한 줄
    }
}

dependencies {
    implementation("org.springframework.cloud:spring-cloud-starter-gateway")
    // constraints 다 지움
}

netty-bom 이 모든 netty-* 모듈의 버전을 4.1.133 으로 일괄 고정합니다. transitive 든 직접이든 무조건 그 버전. PR #1 처럼 모듈명을 일일이 적을 필요 없음.

같은 작업을 4 개 서비스 build.gradle.kts 에 동일하게 적용 → 푸시.

곁다리 — AWS SDK 도 같이

같은 PR 에서 software.amazon.awssdk:bom:2.29.20 → 2.31.7 도 올렸습니다 (Trivy 가 같이 빨갰음).

dependencyManagement {
    imports {
        mavenBom("io.netty:netty-bom:4.1.133.Final")
        mavenBom("software.amazon.awssdk:bom:2.31.7")
    }
}

BOM 두 개로 30+ 모듈 한 방에 정리.


4. Trivy 검증

이미지 빌드 후 ghcr 에 푸시 → Actions 에서 Trivy 자동 재스캔:

✅ api-gateway:latest    No HIGH/CRITICAL netty CVE
✅ user-service:latest   No HIGH/CRITICAL netty CVE
✅ catalog-service:latest No HIGH/CRITICAL netty CVE
✅ media-service:latest  No HIGH/CRITICAL netty CVE

12 개 CVE × 4 서비스 = 48 개 빨간 줄이 0 으로


5. GitHub Actions 의 함정 — 빠진 것들

CI 자동화 과정에서 4 가지 사고를 만났습니다.

5.1 ghcr 태그 대소문자

# 처음 (실패)
docker push ghcr.io/$/api-gateway:latest
# → ghcr.io/MyoungSoo7/api-gateway:latest
# Error: invalid reference format: repository name must be lowercase

해결: 소문자 하드코딩.

docker push ghcr.io/myoungsoo7/api-gateway:latest

(또는 $ 같은 expression 도 가능 — 환경에 따라 다름)

5.2 Trivy SARIF 업로드 실패 (private repo)

- uses: github/codeql-action/upload-sarif@v3
  with: { sarif_file: trivy-results.sarif }

private repo 는 GitHub Code Scanning 이 유료 → SARIF 업로드 거절됨. 해결: SARIF 업로드 제거, 그냥 Trivy 표 형태 출력만 남김.

- uses: aquasecurity/trivy-action@master
  with:
    image-ref: ghcr.io/myoungsoo7/api-gateway:latest
    format: table        # sarif 대신 table
    severity: HIGH,CRITICAL

5.3 Push 순서 — 스캔이 푸시를 막으면 안 됨

처음 워크플로우:

- name: Trivy scan
  run: trivy image ...   # 실패하면 다음 스텝 안 감

- name: Push image
  run: docker push ...   # ← Trivy 결과로 막힘

이러면 CVE 가 뜬 이미지를 못 올림 → 패치 검증 자체가 안 됨. 순서 뒤집어서:

- name: Push image
  run: docker push ...

- name: Trivy scan
  continue-on-error: true   # 결과는 보지만 빌드는 안 막음
  run: trivy image ...

Trivy 는 모니터링 이지 게이트가 아닙니다. 게이트로 쓰려면 별도 PR 단계에서.

5.4 gradlew 가 repo 에 없음

- run: ./gradlew build
# Error: ./gradlew: No such file or directory

해결: gradle/actions/setup-gradle@v4 로 gradle 자체를 받아서 직접 호출.

- uses: gradle/actions/setup-gradle@v4
- run: gradle build --no-daemon

6. 최종 워크플로우 (요약)

name: build-and-push
on:
  push:
    branches: [main]

jobs:
  backend:
    strategy:
      matrix:
        service: [api-gateway, user-service, catalog-service, media-service]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: gradle/actions/setup-gradle@v4
      - run: gradle :services:$:bootJar --no-daemon

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: myoungsoo7
          password: $

      - run: |
          docker build -t ghcr.io/myoungsoo7/$:latest \
                       -t ghcr.io/myoungsoo7/$:$ \
                       services/$
          docker push --all-tags ghcr.io/myoungsoo7/$

      - uses: aquasecurity/trivy-action@master
        continue-on-error: true
        with:
          image-ref: ghcr.io/myoungsoo7/$:latest
          format: table
          severity: HIGH,CRITICAL

4 개 서비스가 matrix 로 병렬 빌드. PR 한 번 = 4 이미지 동시 푸시 + 4 스캔. 약 6 분.


7. 회고

  • PR #1 가 실패한 게 사실 더 좋은 학습 이었습니다. constraints 와 BOM 의 차이를 몸으로 배움.
  • BOM 패턴은 Spring Boot starter parent 가 이미 쓰는 방식. Spring 진영에서 의존성 관리하는 방법을 그대로 따라가는 게 가장 안전합니다.
  • Trivy 는 “현재 떠 있는 이미지” 가 아니라 “ghcr 에 올라간 이미지” 를 검사합니다. 운영 K3s 이미지가 정말 패치된 버전인지는 Image rollout 이 별도로 필요합니다 (다음 글).

다음 글

  • 운영 K3s 에 새 이미지 롤링 적용 — kubectl rollout 과 ArgoCD image-updater
  • BOM 으로 패치한 이미지를 실제 Pod 에 반영시키는 단계.