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 이름의 유출 레포가 조직에 생겼는지도 같이 훑어본다.