Trivy를 처음 붙였을 때 우리 팀은 CVE 몇천 개짜리 리포트 앞에서 멘탈이 나갔다. CVSS 9.8이 수백 개인데 이걸 다 이번 스프린트에 잡으라니. 근데 그게 진짜로 다 위험한 걸까? 결론부터 말하면 아니다. CVSS는 "이 취약점이 뚫리면 얼마나 아플까"만 알려주지, "누가 지금 이걸 실제로 뚫고 있냐"는 답하지 않는다.
이 글은 CVSS 하나로만 우선순위를 매기다가 지친 팀을 위한 실무 가이드다. EPSS로 실제 익스플로잇 확률을, KEV로 이미 공격당하고 있는 CVE를 걸러내서 컨테이너 이미지 스캔 결과를 실행 가능한 수준으로 줄이는 방법을 다룬다.
세 지표가 서로 다른 질문에 답한다
먼저 각 지표가 뭐에 답하는 도구인지 정리하자.
CVSS는 "이론적으로 얼마나 심각한가"에 답한다. 인증 없이 원격에서 RCE가 되면 9.8이다. 그런데 이 취약점을 실제로 뚫고 있는 사람이 있는지, 뚫기 위한 익스플로잇 코드가 공개됐는지는 관심 없다.
EPSS(Exploit Prediction Scoring System)는 "앞으로 30일 안에 이 CVE가 실제로 익스플로잇될 확률"을 0에서 1 사이 값으로 준다. FIRST가 운영하고, ML 모델이 매일 갱신한다. EPSS 0.9면 상위 1% 안에 드는 위험이고, 0.01이면 아직 아무도 관심 없다는 뜻이다.
KEV(CISA Known Exploited Vulnerabilities)는 확률이 아니라 팩트다. "이미 실제 공격에서 사용된 게 확인된" CVE 목록. 2026년 8월 기준 1,665개가 등록돼 있고, 전체 CVE 중 0.44%밖에 안 된다. 이 리스트에 있으면 "지금 당장" 잡아야 하는 것들이다.
우리 팀이 배운 건 이거다. 세 지표는 서로 대체재가 아니라 보완재다. KEV로 "무조건 잡을 것"을 결정하고, EPSS로 "곧 뚫릴 가능성 높은 것"을 걸러낸 뒤, 그 안에서 CVSS 임팩트와 자산 노출 정도로 우선순위를 매긴다.
실무 우선순위 매트릭스
우리는 파이프라인 안에서 이렇게 티어링한다.
T0 — 즉시 (24시간 이내): KEV 등재 + 프로덕션 노출 자산. 이건 협의 없이 잡는다. CISA BOD 26-04가 명시적으로 짧은 SLA를 요구하기도 하고, 실제로 익스플로잇이 도는 중이라 노출 시간이 곧 리스크다.
T1 — 이번 스프린트: EPSS ≥ 0.5 이면서 내부 서비스로 노출된 CVE. 아직 KEV에는 없지만 익스플로잇 활동이 관측되고 있다는 신호다.
T2 — 다음 릴리즈: EPSS 0.1 ~ 0.5, CVSS ≥ 7.0. 배포 사이클에 맞춰 정리.
T3 — 백로그: 나머지. 다음 베이스 이미지 갱신 때 자연 소거되게 둔다.
임의로 던지는 수치가 아니라, 여기 도달한 이유가 있다. 초기에 우리는 CVSS ≥ 7 전부를 T1으로 몰았다가 개발팀에서 폭동이 났었다. EPSS로 다시 걸렀더니 T1이 20분의 1로 줄었다. 그리고 지난 1년간 실제 인시던트가 된 CVE는 전부 그 20분의 1 안에 있었다.
Trivy 리포트에 EPSS/KEV 붙이기
Trivy 자체는 아직 EPSS 스코어를 리포트에 직접 넣어주지 않는다(그런데 이건 곧 바뀔 것 같다, GitHub 이슈에 계속 논의 중이다). 그래서 우리는 후처리 스크립트로 붙인다.
FIRST가 매일 CSV를 공개한다. 다운로드하고 조인만 하면 된다.
#!/bin/bash
# fetch-epss.sh - EPSS 데이터 하루 한 번 갱신
set -euo pipefail
EPSS_URL="https://epss.cyentia.com/epss_scores-current.csv.gz"
KEV_URL="https://www.cisa.gov/sites/default/files/csv/known_exploited_vulnerabilities.csv"
CACHE_DIR="${CACHE_DIR:-/var/cache/vulndata}"
mkdir -p "$CACHE_DIR"
# EPSS
curl -sSL "$EPSS_URL" | gunzip > "$CACHE_DIR/epss.csv.tmp"
mv "$CACHE_DIR/epss.csv.tmp" "$CACHE_DIR/epss.csv"
# KEV
curl -sSL "$KEV_URL" > "$CACHE_DIR/kev.csv.tmp"
mv "$CACHE_DIR/kev.csv.tmp" "$CACHE_DIR/kev.csv"
echo "Updated at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
이걸 CronJob으로 하루 한 번 돌린다. EPSS 스코어는 매일 갱신되니까 캐시 유효 기간은 24시간을 넘기지 말자.
그다음 Trivy JSON 출력에 조인.
# enrich.py
import csv
import json
import sys
from pathlib import Path
CACHE = Path("/var/cache/vulndata")
def load_epss():
scores = {}
with (CACHE / "epss.csv").open() as f:
# 첫 줄은 model_version 코멘트라 스킵
next(f)
reader = csv.DictReader(f)
for row in reader:
scores[row["cve"]] = float(row["epss"])
return scores
def load_kev():
kev = set()
with (CACHE / "kev.csv").open() as f:
for row in csv.DictReader(f):
kev.add(row["cveID"])
return kev
def tier(cvss, epss, in_kev):
if in_kev:
return "T0"
if epss >= 0.5:
return "T1"
if epss >= 0.1 and cvss >= 7.0:
return "T2"
return "T3"
def main(trivy_json):
epss = load_epss()
kev = load_kev()
report = json.loads(Path(trivy_json).read_text())
enriched = []
for result in report.get("Results", []):
for vuln in result.get("Vulnerabilities", []) or []:
cve = vuln["VulnerabilityID"]
cvss = vuln.get("CVSS", {}).get("nvd", {}).get("V3Score", 0.0)
e = epss.get(cve, 0.0)
k = cve in kev
enriched.append({
"cve": cve,
"pkg": vuln.get("PkgName"),
"installed": vuln.get("InstalledVersion"),
"fixed": vuln.get("FixedVersion"),
"cvss": cvss,
"epss": e,
"kev": k,
"tier": tier(cvss, e, k),
})
enriched.sort(key=lambda x: (x["tier"], -x["epss"], -x["cvss"]))
json.dump(enriched, sys.stdout, indent=2)
if __name__ == "__main__":
main(sys.argv[1])
GitHub Actions에서는 이렇게 엮는다.
- name: Scan image
run: |
trivy image --format json --output raw.json ${{ inputs.image }}
- name: Enrich with EPSS/KEV
run: |
./scripts/fetch-epss.sh
python enrich.py raw.json > enriched.json
- name: Fail on T0/T1
run: |
python -c "
import json, sys
data = json.load(open('enriched.json'))
blocking = [v for v in data if v['tier'] in ('T0', 'T1')]
if blocking:
print(f'{len(blocking)} blocking CVEs found')
for v in blocking[:10]:
print(f' {v[\"tier\"]} {v[\"cve\"]} epss={v[\"epss\"]:.3f} kev={v[\"kev\"]}')
sys.exit(1)
"
이 파이프라인의 핵심은 게이팅을 T0/T1에만 걸었다는 점이다. T2/T3은 리포트에만 남기고 빌드는 통과시킨다. 그래야 개발자들이 "또 이 스캔 때문에 배포 막혔네"라는 인상을 안 갖는다.
함정 몇 가지
EPSS 스코어는 매일 바뀐다. 어제까지 0.02였던 CVE가 오늘 0.7로 뛸 수 있다. PoC가 공개됐거나 실제 익스플로잇이 관측됐다는 뜻이다. 그래서 CI 실패는 재현 가능해야 한다는 원칙에도 불구하고, 우리는 EPSS 캐시를 파이프라인 실행 시점에 갱신한다. "어제는 통과했는데 오늘 실패했다"는 리얼한 신호다.
KEV에 등재된다고 무조건 T0이 아니다. 자산 노출 컨텍스트가 있어야 한다. 예를 들어 KEV에 오른 Apache Struts CVE가 있어도, 우리가 Struts를 안 쓰면 그건 그냥 정보다. 이걸 자동으로 알려면 SBOM에 어떤 패키지가 어떤 서비스에 들어가는지 매핑이 있어야 한다. 우리는 syft로 SBOM 뽑아서 태그로 관리한다.
EPSS 4.0 모델 업데이트에 유의하자. 2026년에 4.0으로 넘어오면서 스코어 분포가 좀 바뀌었다. 임계값을 예전 3.x 시절 기준(예: 0.2)으로 잡아뒀다면 다시 튜닝해야 한다. 우리 팀은 릴리즈 노트 나올 때마다 지난 30일 데이터로 백테스트해서 임계값을 재조정한다.
"우선순위 지표"와 "SLA 지표"를 섞지 말자. EPSS는 우선순위를 정하는 도구지, "며칠 안에 잡아라"라는 SLA를 정하는 도구가 아니다. SLA는 조직의 리스크 수용도, 자산 중요도, 컴플라이언스 요구에서 나온다. 이 둘을 섞으면 EPSS 스코어가 조금 낮다는 이유로 실제 위험한 CVE를 미루는 실수가 생긴다.
그래서 얻은 것
이 파이프라인을 도입하고 나서 우리 팀 이미지 스캔 리포트의 "봐야 할 수" 는 평균 2,400개에서 40개 정도로 줄었다. 개발자들이 리포트를 실제로 열어보기 시작한 게 가장 큰 변화다. 그리고 지난 반년 동안 KEV/EPSS 기반 T0로 잡힌 것들 중 실제 익스플로잇 시도가 로그에 찍힌 경우가 두 건 있었다. 예전 방식이었으면 CVSS 8.2짜리 로컬 권한 상승 CVE 사이에 파묻혀 있었을 것이다.
완벽하진 않다. 자산 컨텍스트를 자동으로 붙이는 부분은 아직 반쯤 수동이고, EPSS도 확률일 뿐이라 100% 신뢰할 순 없다. 그래도 CVSS 하나로 정렬하던 시절보다는 훨씬 나은 세상이라고 본다.
혹시 사내에서 다른 방식으로 우선순위 매기시는 분 있으면 댓글로 공유 부탁드립니다. 특히 자산-CVE 매핑 자동화 쪽 노하우가 궁금하다.
참고
태그: DevSecOps, EPSS, KEV, CVE, Trivy, 취약점관리
'IT > DevSecOps' 카테고리의 다른 글
| External Secrets Operator refreshInterval 15초로 뒀다가 청구서 두 번 놀란 이야기 (0) | 2026.08.28 |
|---|---|
| Kyverno vs OPA Gatekeeper, 2026년에는 뭘 쓸까 (0) | 2026.08.21 |
| cosign keyless signing, 이거 모르는 분 꽤 많더라 (1) | 2026.08.17 |
| External Secrets Operator, IRSA로 시작하는 실전 가이드 (1) | 2026.08.13 |
| Vault vs AWS Secrets Manager, 우리 팀은 왜 하이브리드로 갔나 (0) | 2026.08.08 |