# Trivy로 컨테이너 이미지 취약점을 스캔하고 빌드 게이트 만들기

> 오래된 베이스 이미지와 고정 버전 패키지로 작은 이미지를 빌드해 Trivy로 스캔하고, 결과 표 읽는 법과 HIGH,CRITICAL 필터, --ignore-unfixed, --exit-code 1 게이트, 만료일이 있는 .trivyignore 예외를 거쳐 베이스 이미지를 올려 통과시킵니다.

- 주소: https://copyandrun.com/blog/trivy-image-scan-gate/
- 분야: DevSecOps
- 발행일: 2026-10-16
- 태그: Trivy, 컨테이너 이미지, 빌드 게이트, Docker

오래된 베이스 이미지와 오래된 파이썬 패키지로 작은 이미지를 빌드하고 Trivy로 스캔합니다. 결과 표에서 심각도, 설치 버전, 수정 버전을 읽고, 심각도 필터와 종료 코드로 빌드를 멈추는 게이트를 만든 다음, 예외 처리와 베이스 이미지 교체로 게이트를 통과시킵니다.

이 글은 이미지 스캔과 게이트만 다룹니다. requirements.txt 같은 의존성 목록의 스캔과 SBOM은 [취약한 의존성 스캔하고 CycloneDX SBOM 만들기](/blog/sca-sbom-practice/)에서 다룹니다. 이 글의 취약한 이미지는 스캔 연습용이므로 실제 서비스에 배포하지 마세요.

## 준비물

| 항목 | 버전 | 용도 |
|---|---|---|
| Docker | 29.5.3 (Docker Desktop) | 이미지 빌드, Trivy 실행 |
| Trivy | 0.76.0 (Docker 이미지 `aquasec/trivy:0.76.0`, 다이제스트로 고정) | 이미지 취약점 스캔 |
| 대상 베이스 이미지 | `python:3.9-slim` (Debian 13.1) | 일부러 오래된 쪽 |
| 교체할 베이스 이미지 | `python:3.13-slim` (Debian 13.7) | 게이트 통과용 |

명령은 Git Bash와 리눅스에서 확인했고, macOS는 확인하지 않았습니다. 예상 소요 시간은 25분 안팎이고, 처음 한 번은 Trivy가 취약점 데이터베이스(약 130MB)를 내려받습니다. Trivy는 `0.76.0` 버전의 이미지를 쓰되 다이제스트(`@sha256:...`)까지 함께 지정합니다. 2026년 3월에 Trivy의 일부 릴리스, 이미지, GitHub Action 태그가 악성 파일로 바뀌는 공급망 사고가 있었고, 공식 공지(GHSA-69fq-xp46-6x23)는 v0.69.4, Docker Hub의 v0.69.5와 v0.69.6, trivy-action 0.34.2 이하 태그를 영향 범위로 안내합니다. 공지는 이미지를 다이제스트로 참조한 경우에는 영향이 없다고 밝히고, 안전한 버전으로 trivy-action v0.35.0, setup-trivy v0.2.6, Trivy 0.69.2와 0.69.3을 안내합니다. 버전 태그는 나중에 다른 내용으로 바뀔 수 있어서 고정 효과에 한계가 있으므로 이 글은 다이제스트를 함께 씁니다. 다이제스트는 `docker pull aquasec/trivy:0.76.0` 뒤에 `docker inspect aquasec/trivy:0.76.0 --format '{{index .RepoDigests 0}}'`로 확인할 수 있고, 이 글의 값은 2026-10-16에 확인한 것입니다.

## 1. 취약한 이미지 빌드해서 tar로 저장하기

스캔 대상은 파이썬 3.9 베이스에 오래된 flask, requests, pyyaml을 고정한 이미지입니다.

```bash
mkdir trivy-lab && cd trivy-lab

cat > Dockerfile <<'EOT'
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
CMD ["python", "app.py"]
EOT

printf 'flask==2.0.1\nrequests==2.25.1\npyyaml==5.4.1\n' > requirements.txt

cat > app.py <<'EOT'
from flask import Flask
app = Flask(__name__)

@app.get("/")
def index():
    return "hello"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8000)
EOT

docker build -q -t cr-trivy-demo:old .
docker save -o old.tar cr-trivy-demo:old
du -h old.tar
```

Trivy는 컨테이너 안에서 실행합니다. 호스트의 `/var/run/docker.sock`을 컨테이너에 마운트하면 Trivy 컨테이너가 Docker 데몬 전체를 다룰 수 있게 되므로, 이 글에서는 `docker save`로 만든 tar 파일만 마운트해서 읽게 합니다. 아래 함수를 한 번 정의해 두면 이후 명령이 짧아집니다. 취약점 데이터베이스는 `cr-trivy-cache` 볼륨에 보관되어 두 번째 실행부터 다시 받지 않습니다.

```bash
trivy() {
  MSYS_NO_PATHCONV=1 docker run --rm -v "$PWD:/work" -w /work \
    -v cr-trivy-cache:/root/.cache/ \
    aquasec/trivy:0.76.0@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222 "$@"
}
```

`MSYS_NO_PATHCONV=1`은 Git Bash에서 `/root/.cache/` 같은 경로가 윈도우 경로로 바뀌는 것을 막습니다. 리눅스에서는 있어도 영향이 없습니다. 호스트에 `trivy`가 설치되어 있으면 이 셸 함수가 먼저 실행되어 호스트의 명령을 가립니다.

## 2. 이미지를 스캔하고 전체 건수 확인하기

옵션 없이 tar 파일을 스캔합니다. 결과가 길어서 요약 줄만 추려 봅니다.

```bash
trivy image --input old.tar --scanners vuln | grep -B2 "^Total"
```

기대 출력(건수는 데이터베이스가 갱신되면 달라지며, 아래는 2026-10-16 기준입니다):

```text
old.tar (debian 13.1)
=====================
Total: 455 (UNKNOWN: 3, LOW: 155, MEDIUM: 172, HIGH: 119, CRITICAL: 6)
--
Python (python-pkg)
===================
Total: 26 (UNKNOWN: 0, LOW: 2, MEDIUM: 14, HIGH: 10, CRITICAL: 0)
```

위쪽이 Debian 운영체제 패키지, 아래쪽이 pip로 설치한 파이썬 패키지입니다. 베이스 이미지만으로 455건이 나오는 것이 이미지 스캔의 특징입니다. 의존성 목록만 스캔할 때는 보이지 않던 부분입니다.

![베이스 이미지의 Debian 패키지에서 455건, 파이썬 패키지에서 26건이 나오는 것을 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/01-image-scan-summary.png)

## 3. 결과 표 읽기

이 글에서는 파이썬 패키지 쪽 표를 예로 읽습니다. 심각도가 HIGH 이상이고 수정 버전이 있는 항목만 보려고 `--severity`와 `--ignore-unfixed`를 붙였습니다.

```bash
trivy image -q --input old.tar --pkg-types library \
  --severity HIGH,CRITICAL --ignore-unfixed | grep -E "Library|CVE-" | cut -c1-175
```

기대 출력(앞부분):

```text
│          Library          │ Vulnerability  │ Severity │ Status │ Installed Version │ Fixed Version │                            Title                          
│ Flask (METADATA)          │ CVE-2023-30861 │ HIGH     │ fixed  │ 2.0.1             │ 2.3.2, 2.2.5  │ flask: Possible disclosure of permanent session cookie due
│ jaraco.context (METADATA) │ CVE-2026-23949 │          │        │ 5.3.0             │ 6.1.0         │ jaraco.context: jaraco.context: Path traversal via malicio
│ urllib3 (METADATA)        │ CVE-2025-66418 │          │        │ 1.26.20           │ 2.6.0         │ urllib3: urllib3: Unbounded decompression chain leads to
...
```

열마다 의미가 있습니다.

| 열 | 읽는 법 |
|---|---|
| Library | 취약한 패키지 이름입니다. 괄호 안은 버전 정보를 읽은 파일 종류입니다. |
| Vulnerability | CVE 또는 GHSA 번호입니다. 예외 처리할 때 이 값을 씁니다. |
| Severity | 위험도입니다. 같은 패키지의 아래 줄은 값이 같아 비워 두고 표시합니다. |
| Status | `fixed`는 수정 버전이 배포되었다는 뜻입니다. |
| Installed Version, Fixed Version | 지금 버전과 올려야 할 버전입니다. 수정 버전이 여러 개면 쓰는 메이저 계열에 맞는 것을 고릅니다. |

이 표에서 눈여겨볼 곳은 urllib3입니다. 고정하지 않은 urllib3가 1.26.20으로 들어왔고, 수정 버전은 모두 2.x입니다. requests 2.25.1이 urllib3 1.x만 허용하기 때문에, urllib3만 따로 올릴 수 없고 requests를 올려야 합니다. 표의 Fixed Version은 직접 의존성뿐 아니라 함께 설치된 패키지까지 보여 줍니다.

![Flask의 CVE-2023-30861이 HIGH로 표시되고 설치 버전 2.0.1과 수정 버전 2.3.2를 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/02-library-table.png)

## 4. 빌드 게이트 만들기

CI에서 빌드를 멈추려면 종료 코드가 필요합니다. 다음 세 옵션을 함께 씁니다.

| 옵션 | 효과 |
|---|---|
| `--severity HIGH,CRITICAL` | 해당 심각도만 결과와 종료 코드에 반영합니다. |
| `--ignore-unfixed` | 수정 버전이 아직 없는 취약점을 뺍니다. 지금 당장 고칠 방법이 없는 항목으로 빌드가 막히는 것을 피합니다. |
| `--exit-code 1` | 위 조건에 걸리는 취약점이 하나라도 있으면 종료 코드를 1로 합니다. 기본값은 0입니다. |

```bash
trivy image -q --input old.tar --scanners vuln --severity HIGH,CRITICAL \
  --ignore-unfixed --exit-code 1 > report.txt; echo "exit=$?"
grep -B2 "^Total" report.txt
```

기대 출력:

```text
exit=1
old.tar (debian 13.1)
=====================
Total: 81 (HIGH: 75, CRITICAL: 6)
--
Python (python-pkg)
===================
Total: 10 (HIGH: 10, CRITICAL: 0)
```

전체 481건(Debian 455건과 파이썬 26건)이 91건(Debian 81건과 파이썬 10건)으로 줄었지만 CRITICAL이 여섯 건 남아 있어 종료 코드가 1입니다. `--ignore-unfixed`는 공식 문서 기준으로 `--ignore-status affected,will_not_fix,fix_deferred,end_of_life`의 줄임말이어서, 수정 버전이 없는 항목뿐 아니라 수정하지 않기로 한 항목과 지원이 끝난(end_of_life) 항목도 함께 빠집니다. 그래서 지원이 끝난 베이스 이미지를 쓰고 있어도 게이트가 통과할 수 있으므로 베이스 이미지의 지원 종료 여부는 따로 확인합니다. 파이프(`| grep`)를 붙이면 `$?`가 grep의 종료 코드가 되므로, 위처럼 파일로 받고 종료 코드를 바로 확인합니다.

## 5. .trivyignore로 예외 처리하기

당장 고칠 수 없는 항목은 `.trivyignore`에 적어 게이트에서 뺍니다. 각 줄 끝에 `exp:날짜`를 붙이면 그 날짜가 지난 뒤에는 예외가 사라져 다시 걸립니다. 예외가 영구 면제로 남는 것을 막는 장치입니다. `.trivyignore`는 ID만 적기 때문에 같은 ID가 나오는 모든 패키지에 적용됩니다. 범위를 좁히려면 `.trivyignore.yaml`에서 `purls`(패키지)나 `paths`(파일 경로)를 함께 지정할 수 있습니다(공식 문서의 filtering 항목에서 확인했고, 이 글에서는 실행하지 않았습니다). 현재 디렉터리의 `.trivyignore`는 자동으로 읽힙니다(위 함수는 `-w /work`로 작업 디렉터리를 맞춰 두었습니다).

```bash
cat > .trivyignore <<'EOT'
# flask 세션 쿠키 취약점은 2.3.2 로 올리기 전까지 임시 허용
CVE-2023-30861 exp:2026-12-31
EOT

trivy image -q --input old.tar --scanners vuln --severity HIGH,CRITICAL \
  --ignore-unfixed --exit-code 1 > report.txt; echo "exit=$?"
grep -B2 "^Total" report.txt | tail -3
grep -c CVE-2023-30861 report.txt
```

기대 출력:

```text
exit=1
Python (python-pkg)
===================
Total: 9 (HIGH: 9, CRITICAL: 0)
0
```

파이썬 패키지 쪽이 10건에서 9건으로 줄고 CVE-2023-30861이 보고서에서 빠졌습니다. Debian 쪽 81건은 그대로라서 종료 코드는 여전히 1입니다. 예외로 빼는 항목은 적을수록 좋습니다.

![.trivyignore를 쓰기 전에는 파이썬 패키지 10건, 쓴 뒤에는 9건이 되고 종료 코드는 여전히 1인 것을 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/03-gate-and-ignore.png)

## 6. 베이스 이미지와 패키지를 올려 재스캔하기

이 단계의 결과도 2026-10-16 기준입니다. `python:3.9-slim`과 `python:3.13-slim`은 계속 갱신되는 태그이므로 시간이 지나면 건수와 예외 ID가 달라지고, 지원이 끝난 파이썬 3.9 태그는 나중에 사라질 수도 있습니다. 7단계의 예외 ID는 본인이 스캔한 결과에서 다시 확인합니다.

Dockerfile의 베이스 이미지를 올리고, 패키지는 이 글을 쓴 시점의 최신 안정 버전으로 올립니다. 이전 `.trivyignore`는 지우고 시작합니다.

```bash
rm .trivyignore
sed 's/python:3.9-slim/python:3.13-slim/' Dockerfile > Dockerfile.new && mv Dockerfile.new Dockerfile
printf 'flask==3.1.2\nrequests==2.32.5\npyyaml==6.0.3\n' > requirements.txt

docker build -q -t cr-trivy-demo:fixed . && docker save -o fixed.tar cr-trivy-demo:fixed
trivy image -q --input fixed.tar --scanners vuln --severity HIGH,CRITICAL \
  --ignore-unfixed --exit-code 1 > report.txt; echo "exit=$?"
grep -E "Library|CVE-|GHSA-" report.txt | cut -c1-175
```

기대 출력:

```text
exit=1
│  Library   │    Vulnerability    │ Severity │ Status │ Installed Version │ Fixed Version │                           Title                            │
│ msgpack    │ GHSA-6v7p-g79w-8964 │ HIGH     │ fixed  │ 1.1.2             │ 1.2.1         │ MessagePack for Python: Out-of-bounds read / crash on      │
│ setuptools │ CVE-2025-47273      │          │        │ 70.3.0            │ 78.1.1        │ setuptools: Path Traversal Vulnerability in setuptools     │
│ urllib3    │ CVE-2026-97687      │          │        │ 2.7.0             │ 2.8.0         │ urllib3: urllib3: Traffic interception via HTTPS proxy TLS │
│            │ CVE-2026-97689      │          │        │                   │               │ urllib3: urllib3: Denial of Service via unbounded memory   │
```

Debian 쪽은 수정 가능한 HIGH 이상이 0건이 되었고, 앞서 여섯 건이던 CRITICAL도 사라졌습니다. 남은 네 건은 앱이 설치한 패키지가 아니라 pip 안에 포함된 vendored 라이브러리(msgpack, setuptools, urllib3)입니다. 앱의 urllib3는 이미 2.8.0이지만 pip가 내부에 2.7.0 사본을 포함하고 있어서 `requirements.txt`를 고쳐도 사라지지 않습니다. 런타임에 pip가 필요 없다면 최종 이미지에서 pip를 빼는 쪽이 예외보다 안전합니다(멀티 스테이지 빌드는 아래 이어서 해 볼 것에 있습니다). pip를 남겨야 한다면 만료일이 있는 예외를 씁니다. 사유와 만료일을 적어 두고, pip가 새 버전을 내면 이미지를 다시 빌드해 예외를 지웁니다.

![베이스 이미지를 올린 뒤 남은 네 건의 라이브러리 이름과 수정 버전을 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/04-fixed-remaining.png)

## 7. 예외를 등록하고 게이트 통과 확인하기

```bash
cat > .trivyignore <<'EOT'
# pip 26.2.1 에 들어 있는 vendored 라이브러리. pip 새 버전이 나올 때까지 임시 허용
GHSA-6v7p-g79w-8964 exp:2026-12-31
CVE-2025-47273 exp:2026-12-31
CVE-2026-97687 exp:2026-12-31
CVE-2026-97689 exp:2026-12-31
EOT

trivy image -q --input fixed.tar --scanners vuln --severity HIGH,CRITICAL \
  --ignore-unfixed --exit-code 1 > report.txt; echo "exit=$?"
grep -E "debian|Python" report.txt | cut -c1-120
```

기대 출력:

```text
exit=0
│ fixed.tar (debian 13.7)                                                          │   debian   │        0
│ Python                                                                           │ python-pkg │        0
```

종료 코드가 0이 되어 게이트를 통과했습니다. 예외 날짜인 2026-12-31이 지나면 위 네 항목이 다시 걸리므로 그 전에 pip를 올리거나 예외를 갱신해야 합니다. `.trivyignore`는 저장소에 커밋해서 변경 이력이 리뷰에 남게 합니다.

![예외 네 줄을 등록한 뒤 종료 코드가 0이고 debian과 python-pkg 모두 0건인 것을 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/05-gate-pass.png)

## 8. 취약점 데이터베이스와 GitHub Actions

Trivy는 취약점 데이터베이스를 컨테이너 이미지 형태로 내려받으며, 공식 문서 기준 기본 저장소는 `mirror.gcr.io/aquasec`와 `ghcr.io/aquasecurity` 순서입니다. 일시적인 오류(429, 5xx)가 나면 다음 저장소로 넘어갑니다. 다른 곳을 쓰려면 `--db-repository`를 지정하는데, 지정하면 기본 저장소가 대체되므로 공식 저장소도 함께 적어야 대체 경로가 유지됩니다. 아래는 기본값과 같은 두 저장소를 쉼표로 이어서 지정하고 데이터베이스 내려받기만 실행한 예시이며, 이 글에서 실행해 정상 종료를 확인했습니다.

```bash
docker run --rm -v cr-trivy-cache:/root/.cache/ \
  aquasec/trivy:0.76.0@sha256:2416d1f74045b57274df1a340eee6b510cacd73c66a6ccb8da092b117eb7d222 \
  image -q --download-db-only \
  --db-repository mirror.gcr.io/aquasec/trivy-db:2,ghcr.io/aquasecurity/trivy-db:2
```

같은 게이트를 GitHub Actions에 붙일 때는 `aquasecurity/trivy-action`을 씁니다. 아래는 공식 저장소 README의 이미지 스캔 예시에 `actions/checkout` 단계와 `version` 고정을 더한 것입니다. `version` 입력은 사용할 Trivy 버전이며 README 기준 기본값은 v0.76.0입니다. 이 글에서는 Actions 위에서 실행하지 않았습니다.

```yaml
- uses: actions/checkout@v4
- name: Build an image from Dockerfile
  run: docker build -t my-app:${{ github.sha }} .
- name: Run Trivy vulnerability scanner
  uses: aquasecurity/trivy-action@<고정할 커밋 SHA>
  with:
    version: 'v0.76.0'
    image-ref: 'my-app:${{ github.sha }}'
    format: 'table'
    exit-code: '1'
    ignore-unfixed: true
    vuln-type: 'os,library'
    severity: 'CRITICAL,HIGH'
```

공급망 사고에서 trivy-action의 기존 태그가 악성 커밋으로 바뀌었던 만큼, 공식 공지는 액션을 태그가 아닌 전체 커밋 SHA로 고정하라고 권고합니다. 사용 시점의 안전한 릴리스를 확인한 뒤 SHA로 고정하세요. 공지는 `version: latest`를 명시한 경우도 노출 기간에는 위험했다고 설명하므로, `version`은 위처럼 구체적인 값으로 지정합니다.

## 자주 막히는 곳

**`trivy image cr-trivy-demo:old`가 `docker error: unable to inspect the image`로 실패합니다**
Trivy 컨테이너 안에는 Docker 데몬이 없어서 이미지를 찾지 못합니다. 이 실습에서는 로컬 이미지 이름으로 스캔하면 Docker, containerd, Podman 소켓을 차례로 찾다가 모두 실패했습니다. `docker.sock`을 마운트하는 대신 `docker save`로 만든 tar를 `--input`으로 넘깁니다.

![로컬 이미지 이름으로 스캔하면 docker, containerd, podman 연결 오류가 차례로 나오는 것을 확인합니다.](https://copyandrun.com/img/trivy-image-scan-gate/06-docker-sock-error.png)

**`| grep` 뒤의 `echo $?`가 항상 0입니다**
파이프의 마지막 명령(grep)의 종료 코드가 나오기 때문입니다. Trivy의 출력을 파일로 받고 바로 `echo "exit=$?"`를 확인하거나 `set -o pipefail`을 씁니다.

**`.trivyignore`에 적었는데도 취약점이 계속 나옵니다**
`exp:` 날짜가 이미 지났을 때입니다. 이 실습에서 `CVE-2023-30861 exp:2026-10-01`로 바꿔 스캔하면 지난 날짜의 예외가 적용되지 않고 항목이 다시 나왔습니다. 그리고 ID 철자와 파일 위치(현재 디렉터리)도 확인합니다.

**Git Bash에서 `-v cr-trivy-cache:/root/.cache/` 경로가 윈도우 경로로 바뀌어 실패합니다**
명령 앞에 `MSYS_NO_PATHCONV=1`을 붙입니다. 이 글의 `trivy` 함수에 이미 들어 있습니다.

**`requirements.txt`를 올렸는데 pip 내부 라이브러리 취약점이 남습니다**
pip, setuptools가 내부에 가진 vendored 사본은 앱의 의존성 목록을 고쳐도 바뀌지 않습니다. 6단계처럼 새 베이스 이미지와 pip 업데이트를 기다리거나, 만료일을 둔 예외로 관리합니다.

## 이어서 해 볼 것

- 6단계의 Dockerfile을 멀티 스테이지로 바꿔 최종 이미지에서 pip를 빼고, 4개 예외가 필요 없어지는지 같은 게이트로 확인합니다.
- `--severity HIGH,CRITICAL`에서 `CRITICAL`만으로 바꿔 보고, 팀이 허용할 수 있는 기준이 어디인지 정해 봅니다.
- `--format json`으로 결과를 받아 CI 산출물로 보관하고, 날짜별 건수 변화를 비교해 봅니다.

## 확인 범위

1~7단계의 명령은 Windows의 Git Bash와 Docker Desktop(Docker 29.5.3)에서 Trivy 0.76.0 이미지로 직접 실행했고, 취약점 건수는 2026-10-16 기준이며 데이터베이스가 갱신되면 달라집니다. 8단계의 `--db-repository` 지정은 기본 두 저장소를 모두 적어 데이터베이스 내려받기까지만 실행해 확인했고 스캔에 이어 쓰지는 않았습니다. 이미지 다이제스트는 `docker inspect`와 `docker buildx imagetools inspect`로 확인했습니다. `.trivyignore.yaml`의 범위 지정은 공식 문서만 읽었고 실행하지 않았으며, 6, 7단계의 건수와 Debian 13.7은 이동 태그 기준이라 시점에 따라 달라집니다. 요청이 제한되는 상황은 재현하지 못했으며 대체 저장소 동작은 공식 문서(trivy.dev의 데이터베이스 설정)에 적힌 내용입니다. GitHub Actions 워크플로는 aquasecurity/trivy-action 문서의 예시를 줄인 것으로 실제 Actions에서 실행하지 않았고, SHA 고정은 사용 시점에 확인해야 합니다. sed 대신 파일을 다시 쓰는 방식으로 바꾼 부분은 Git Bash에서만 확인했습니다. `docker.sock`을 마운트하는 방식은 실행하지 않았습니다. 공급망 사고 내용은 Trivy 공식 저장소의 보안 공지(GHSA-69fq-xp46-6x23)를 읽어 정리했습니다.
