본문 바로가기
삵
Cloud Computing & MSA

CI에서 Trivy를 digest로 핀해 돌리기

oopennova·2026년 11월 14일·조회 0

2026년 3월 trivy-action이 털린 뒤로 보안 스캐너를 CI에 넣는 일이 전보다 조심스러워졌다. 취약점 찾으라고 넣은 도구가 오히려 러너 메모리에서 시크릿을 긁어가는 통로가 됐으니 당연하다. 그렇다고 스캔을 빼자는 게 아니다. 핀만 제대로 하면 Trivy는 여전히 파이프라인에서 제일 가성비 좋은 보안 게이트다. 여기서는 스캔 명령부터 핀 방법까지 실제 돌아가는 설정으로 정리한다.

결론부터 말하자면, trivy image 한 번으로 CVE와 시크릿, 이미지 설정 misconfig를 같이 잡을 수 있고, CI 안정성은 액션을 버전 태그가 아니라 전체 커밋 SHA로, 스캐너 컨테이너를 sha256 digest로 고정하는 데서 나온다. 태그는 force-push로 갈아끼울 수 있지만 SHA와 digest는 내용이 바뀌면 값 자체가 달라져 갈아끼기가 불가능하다.

1. 2026년 3월에 무슨 일이 있었나

공격자는 탈취한 자격증명으로 aquasecurity/trivy-action의 77개 버전 태그 중 76개를 악성 커밋으로 force-push했다. aquasecurity/setup-trivy의 7개 태그도 전부 교체됐고, 악성 trivy 릴리스 v0.69.4, v0.69.5, v0.69.6가 GitHub, Docker Hub, 패키지 매니저로 배포됐다. 심어진 코드는 /proc/<pid>/mem로 Runner.Worker 프로세스 메모리를 덤프해 파이프라인 시크릿을 빼냈다. 이 사건은 CVE-2026-33634, GHSA-69fq-xp46-6x23로 공개됐다.

노출 구간은 컴포넌트마다 다르다. trivy-action은 3월 19일부터 20일까지 약 12시간, setup-trivy는 약 4시간, trivy v0.69.4 바이너리는 약 3시간이었다. 안전한 버전은 trivy 바이너리 v0.69.2와 v0.69.3, trivy-action 0.35.0, setup-trivy 0.2.6이다. 노출 구간에 affected 버전을 돌렸다면 해당 파이프라인의 모든 시크릿을 교체해야 한다.

핵심은 공격 수법이 특별히 정교하지 않았다는 점이다. 자격증명이 털렸고, 다들 @v0나 @master 같은 움직이는 태그를 쓰고 있었기 때문에 태그 하나를 바꾸자 수천 개 파이프라인이 동시에 악성 코드를 당겨 왔다. SHA로 핀만 돼 있었으면 태그가 바뀌어도 내가 지정한 커밋은 그대로 당겨졌을 것이다.

2. trivy image로 CVE, 시크릿, misconfig 잡기

Trivy는 컨테이너 이미지를 레이어 단위로 풀어 설치된 패키지의 알려진 취약점(CVE), 하드코딩된 자격증명 같은 시크릿, 이미지 설정의 misconfiguration을 찾는다. 어떤 스캐너를 켤지는 --scanners 플래그로 고른다. 기본값은 vuln(취약점)과 secret(시크릿)이고, misconfig와 license는 꺼져 있다.

이미지 하나에 취약점과 시크릿을 같이 돌리는 기본 명령은 이렇다.

$ trivy image --scanners vuln,secret myapp:latest
2026-11-14T09:12:04Z	INFO	Vulnerability scanning is enabled
2026-11-14T09:12:04Z	INFO	Secret scanning is enabled
2026-11-14T09:12:07Z	INFO	Detected OS	family="debian" version="12.5"
2026-11-14T09:12:07Z	INFO	Number of language-specific files	num=1

myapp:latest (debian 12.5)
==========================
Total: 9 (UNKNOWN: 0, LOW: 1, MEDIUM: 1, HIGH: 5, CRITICAL: 2)

이미지 설정 자체의 misconfig는 조금 다르다. --scanners misconfig는 IaC나 파일시스템의 Dockerfile, 쿠버네티스 매니페스트 같은 설정 파일을 대상으로 한다. 빌드된 이미지의 설정(루트로 실행, HEALTHCHECK 누락 등)을 보려면 --image-config-scanners misconfig를 쓴다.

$ trivy image --image-config-scanners misconfig myapp:latest

CI에서는 매번 취약점 DB를 받느라 느려지는 지점에서 한 번 걸린다. Trivy는 기본으로 DB를 캐시에 받아두는데, 러너가 매번 깨끗하게 시작하면 캐시가 없어 수십 초가 더 든다. 캐시 디렉터리를 액션 캐시로 보존하거나, 바쁜 파이프라인이라면 --download-db-only로 DB만 미리 받아두는 단계를 따로 두면 낫다.

3. 액션은 커밋 SHA로, 스캐너 이미지는 digest로

trivy-action을 그대로 쓰되 움직이는 태그 대신 전체 커밋 SHA로 핀한다. 특정 릴리스의 SHA는 gh로 뽑는다.

$ gh api repos/aquasecurity/trivy-action/git/refs/tags/0.35.0 --jq '.object.sha'
6e7b7d1fd3e4dac4...  # 출력된 전체 40자리 SHA를 그대로 쓴다

그 값을 워크플로에 박고, 어떤 버전인지는 주석으로 남긴다. 주석은 Dependabot이 갱신할 때 참고하는 표식이기도 하다.

jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@<checkout-커밋-SHA>  # v4.2.2
      - name: Build image
        run: docker build -t myapp:${{ github.sha }} .
      - name: Scan image
        uses: aquasecurity/trivy-action@<위에서-뽑은-SHA>  # 0.35.0
        with:
          image-ref: myapp:${{ github.sha }}
          scanners: vuln,secret
          severity: HIGH,CRITICAL
          exit-code: 1
          ignore-unfixed: true

액션을 아예 안 쓰고 Trivy 컨테이너를 직접 당겨 돌리는 방법도 있다. 이쪽이 의존하는 외부 코드가 가장 적다. 이때는 이미지를 태그가 아니라 digest로 핀한다. digest는 먼저 resolve해서 값을 확인한다.

$ docker buildx imagetools inspect aquasec/trivy:0.69.3 --format '{{.Manifest.Digest}}'
sha256:3f2a...  # 출력된 digest를 아래에 그대로 박는다
$ docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    aquasec/trivy@sha256:<위에서-resolve한-digest> \
    image --scanners vuln,secret --severity HIGH,CRITICAL \
    --exit-code 1 --ignore-unfixed myapp:latest

digest로 핀하면 누가 aquasec/trivy:0.69.3 태그를 악성 이미지로 덮어써도 내가 지정한 sha256 내용과 맞지 않아 당겨지지 않는다. 태그는 가리키는 대상을 바꿀 수 있지만 digest는 내용의 해시라 바뀌면 값이 달라진다. 이게 이번 사건이 준 교훈을 그대로 적용한 지점이다.

4. 게이트로 쓸 때의 severity와 예외 처리

exit-code 1을 주면 조건에 맞는 취약점이 하나라도 걸릴 때 Trivy가 0이 아닌 코드로 끝나 CI가 실패한다. 다만 LOW까지 전부 막으면 파이프라인이 거의 매번 빨갛게 되고, 그러면 팀이 결과를 무시하기 시작한다. 처음에는 severity HIGH,CRITICAL로 좁혀 깨지는 것만 막고, 베이스 이미지가 정리되면 범위를 넓히는 쪽이 현실적이다.

ignore-unfixed는 아직 패치가 나오지 않은 취약점을 게이트에서 빼준다. 고칠 방법이 없는 CVE로 배포를 막아봐야 할 수 있는 조치가 없다. 리포트로는 보되 빌드는 세우지 않는 선택이다.

특정 CVE를 한시적으로 눈감아야 할 때는 레포 루트에 .trivyignore를 둔다.

# .trivyignore
# 2026-12-01까지 업스트림 패치 대기, 티켓 OPS-4821
CVE-2025-12345
CVE-2025-23456

스캐너 옵션을 워크플로 여기저기 흩지 말고 trivy.yaml 한 곳에 모으면 로컬과 CI가 같은 기준으로 돈다.

# trivy.yaml
scan:
  scanners:
    - vuln
    - secret
severity:
  - HIGH
  - CRITICAL
vulnerability:
  ignore-unfixed: true

마지막으로, 이미 affected 구간에 trivy-action이나 setup-trivy의 움직이는 태그를 당겨 쓴 레포가 있는지부터 확인하는 게 순서다. 돌린 적이 있다면 해당 파이프라인 시크릿을 교체하고, 공격자가 남긴 tpcp-docs 이름의 유출 레포가 조직에 생겼는지도 같이 훑어본다.

자주 묻는 질문

trivy image 한 번으로 CVE, 시크릿, misconfig를 전부 스캔할 수 있나?

CVE와 시크릿은 기본 스캐너라 trivy image만으로 같이 돈다. 이미지 설정의 misconfig는 별도로 --image-config-scanners misconfig를 붙여야 한다. IaC 파일(Dockerfile, 쿠버네티스 매니페스트)의 misconfig는 trivy config나 trivy fs에 --scanners misconfig를 쓰는 쪽이 맞다.

trivy-action을 커밋 SHA로 핀하려면 어떻게 하나?

gh api repos/aquasecurity/trivy-action/git/refs/tags/0.35.0 --jq '.object.sha'로 전체 SHA를 뽑아 uses: aquasecurity/trivy-action@<40자리 SHA> 형태로 박고, 뒤에 # 0.35.0 주석으로 버전을 남긴다. 주석은 Dependabot이 갱신할 때 기준으로 쓴다.

스캐너 이미지를 digest로 핀하는 이유는?

0.69.3 같은 태그는 가리키는 이미지를 바꿀 수 있어 공격자가 같은 태그에 악성 이미지를 덮어쓸 수 있다. sha256 digest는 이미지 내용의 해시라 내용이 바뀌면 값 자체가 달라진다. digest로 핀하면 내가 검증한 바로 그 이미지만 당겨진다.

2026년 3월 사건 때 안전했던 버전은 무엇인가?

trivy 바이너리 v0.69.2와 v0.69.3, trivy-action 0.35.0, setup-trivy 0.2.6이 안전하다. 악성으로 배포된 바이너리는 v0.69.4, v0.69.5, v0.69.6이었다. 노출 구간에 영향받은 버전을 돌렸다면 해당 파이프라인 시크릿을 모두 교체해야 한다.

exit-code로 CI를 막을 때 severity는 어디부터 잡아야 하나?

처음에는 severity HIGH,CRITICAL로 좁혀 깨지는 것만 막는 편이 현실적이다. LOW까지 전부 막으면 거의 매번 실패해 팀이 결과를 무시하게 된다. ignore-unfixed로 패치 없는 취약점은 게이트에서 빼고, 베이스 이미지가 정리되면 범위를 넓힌다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.