Files
service-catalog/scripts/pipeline/cve-gate.py
T
wbsong111 4e2e57cebc cve-gate.py: 요약 표를 차트별로 정렬 + 이미지 다중 차트 귀속 보정
render_summary_table() 을 이미지 알파벳순(리포트 파일명 순) 대신 차트 이름
오름차순으로 묶고, 같은 차트 안에서는 실효 CRITICAL/HIGH 내림차순으로 정렬한다.
scan-sbom.sh 의 trivy-summary.md 는 이미 차트별로 묶여 있는데 cve-gate.md/brief 는
아니어서 두 리포트의 정렬 기준이 서로 달랐다.

이 과정에서 report_stem_to_image() 와 같은 종류의 문제를 발견해 같이 고쳤다 —
이미지 하나가 여러 차트에 쓰이면 리포트 파일명→차트 매핑이 마지막 차트만 남겨,
차트별로 묶었을 때 다른 차트의 노출이 조용히 빠졌을 것이다(scan-sbom.sh 에서
실측으로 확인된 것과 동일한 패턴). 신규 charts_of_image() 로 sbom-index.tsv 를
전부 읽어 이미지→차트 목록을 보존하고, render_summary_table 표시에서만 이걸로
펼친다 — 게이트 판정(evaluate 등)에 쓰이는 대표 차트/버전은 안 건드린다.

로컬에서 (a) flowise/cnpg-cluster/etcd 3개 차트 실제 데이터로 정렬 확인
(b) 이미지 하나가 차트 두 개에 쓰이는 합성 케이스로 양쪽에 다 나오는지 확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 11:43:21 +09:00

745 lines
33 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
cve-gate.py — 카탈로그 이미지의 CRITICAL/HIGH 0건 목표를 판정하는 게이트.
왜 별도 게이트가 필요한가
-------------------------
trivy 는 배포판이 제공하는 **벤더 심각도**를 우선 사용한다. 그런데 같은 CVE 를
배포판마다 다르게 등급한다. 실측 예:
CVE-2026-8376 NVD CVSS 9.8 → Debian: CRITICAL / Ubuntu: MEDIUM
즉 "벤더 기준 CRITICAL 0건" 은 베이스 OS 를 바꾸는 것만으로도 달성될 수 있고,
그것은 위험이 줄어든 것이 아니다. 이 게이트는 **벤더 기준과 NVD CVSS 기준을 함께**
집계해 그 차이를 드러낸다.
또한 스캔 단계에서 `--severity HIGH,CRITICAL` 로 필터하면 벤더가 MEDIUM 으로
낮춘 항목이 리포트에 아예 없으므로 위 비교가 불가능하다. 이 스크립트가 소비하는
리포트는 **전 심각도로 스캔된 것**이어야 한다. (scan-sbom.sh 에 SEVERITY 를
지정하지 않고 실행)
"0건" 과 "측정되지 않음" 의 구분
--------------------------------
findings 0건은 그 자체로 안전을 뜻하지 않는다. 스캐너에 그 배포판 데이터가 없어도
같은 값이 나온다(실측: Debian sid 149 패키지 0건). 이 게이트는 스스로 판단하지 않고
`scan-sbom.sh` 가 리포트에 기록한 자가진단 결과 `CoverageProbe` 를 읽는다.
ok 스캐너가 이 배포판을 안다 → 0건은 진짜 0건
none 스캐너에 데이터가 없다 → **차단**
n/a SBOM 에 OS 패키지가 없다
(키 없음) 프로브 이전 리포트 → 예전 동작(총계 0건 → 차단) 유지
사용
----
python3 scripts/pipeline/cve-gate.py \
--reports sbom-out/trivy-reports \
--index sbom-out/sbom-index.tsv \
--exceptions doc/cve-exceptions.json \
--summary-md sbom-out/cve-gate.md
종료 코드
0 게이트 통과
1 게이트 실패 (미승인 CRITICAL/HIGH 존재, 또는 데이터 커버리지 이상)
2 실행 오류
"""
import argparse
import glob
import json
import os
import sys
from datetime import date, datetime
RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "UNKNOWN": 0}
def normalize_pkg(name):
"""
배포판별 패키지 이름 차이를 흡수한다.
Debian 의 libperl5.40 / perl-modules-5.40 과 Ubuntu 의 libperl5.38t64 /
perl-modules-5.38 을 같은 것으로 보기 위한 정규화다.
"""
import re
if not name:
return ""
n = name.split(":")[0]
n = re.sub(r"\d+(\.\d+)*(t64)?$", "", n) # 뒤쪽 버전 숫자 제거
n = re.sub(r"[-.]$", "", n)
return n
def nvd_severity(score):
"""NVD CVSS v3 점수를 심각도로 환산. 점수가 없으면 None."""
if score is None:
return None
if score >= 9.0:
return "CRITICAL"
if score >= 7.0:
return "HIGH"
if score >= 4.0:
return "MEDIUM"
return "LOW"
def load_exceptions(path):
"""
승인된 예외 목록.
형식(JSON):
{"exceptions": [
{"id": "CVE-2026-8376",
"images": ["*"], # 또는 특정 이미지 문자열 부분일치
"reason": "32-bit 빌드 한정 — 배포 대상은 x86_64",
"expires": "2026-10-31"}
]}
expires 가 지난 항목은 예외로 인정하지 않는다(재검토 강제).
"""
if not path or not os.path.exists(path):
return [], []
with open(path) as f:
data = json.load(f)
active, expired = [], []
today = date.today()
for e in data.get("exceptions") or []:
exp = e.get("expires")
if exp:
try:
if datetime.strptime(exp, "%Y-%m-%d").date() < today:
expired.append(e)
continue
except ValueError:
raise SystemExit(f"예외 항목의 expires 형식이 잘못됨: {exp!r} (YYYY-MM-DD)")
active.append(e)
return active, expired
def exception_applies(exc, cve_id, image):
if exc.get("id") != cve_id:
return False
pats = exc.get("images") or ["*"]
return any(p == "*" or p in image for p in pats)
def inventory_images(path):
"""
images_final.tsv (chart⇥version⇥image) 에서 스캔되어야 할 이미지 목록을 읽는다.
필요한 이유: 게이트는 리포트 디렉토리만 보므로 **SBOM 생성이 실패해 리포트가 아예
없는 이미지를 알 수 없다.** 실측 사례 — CI 에서 SUSE 이미지 3개의 SBOM 생성이
실패했는데 워크플로는 성공으로 끝나고 게이트는 남은 2개만 판정했다.
인벤토리 5개 중 2개만 보고도 "판정 완료" 가 된 것이다.
목표 정의 4번("0건" 과 "측정되지 않음" 을 구분한다)이 이미지 단위에서도 지켜지도록
인벤토리와 대조한다.
"""
if not path or not os.path.exists(path):
return None
imgs = {}
with open(path) as f:
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) >= 3 and p[2]:
imgs[p[2]] = f"{p[0]}@{p[1]}"
return imgs or None
def report_stem_to_image(index_path):
"""sbom-index.tsv(chart⇥version⇥image⇥status⇥seconds⇥sbom_file) → 리포트 파일명 매핑."""
m = {}
if not os.path.exists(index_path):
return m
with open(index_path) as f:
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) >= 6 and p[2] and p[5]:
stem = os.path.basename(p[5])
if stem.endswith(".cdx.json"):
stem = stem[: -len(".cdx.json")]
m[stem] = {"image": p[2], "chart": p[0], "version": p[1]}
return m
def charts_of_image(index_path):
"""image → [(chart, version), ...] 전체 목록(중복 제거 없이 모두 보존).
report_stem_to_image() 는 리포트 파일 하나당 대표 차트 하나만 남기므로(같은 이미지를
여러 차트가 쓰면 마지막 것만 남음), 이미지 하나가 여러 차트에 쓰일 때 요약 표를 차트별로
묶으면 다른 차트의 노출이 조용히 빠진다(scan-sbom.sh 에서 실측으로 확인된 것과 같은
문제). 표시(render_summary_table)에서만 이 전체 목록으로 펼쳐 보정한다 — 게이트
판정(evaluate 등)에 쓰이는 대표 차트/버전은 그대로 둔다."""
m = {}
if not os.path.exists(index_path):
return m
with open(index_path) as f:
for line in f:
p = line.rstrip("\n").split("\t")
if len(p) >= 6 and p[2]:
m.setdefault(p[2], []).append((p[0], p[1]))
return m
def analyze(path, meta):
d = json.load(open(path))
os_info = (d.get("Metadata") or {}).get("OS") or {}
findings = []
# Result 별 메타를 보존한다. 커버리지 판정에 필요하다 — findings 총계만 보면
# OS 패키지가 0건인데 언어 패키지 findings 때문에 통과한다(실측: postgres-exporter).
results_meta = []
for res in d.get("Results") or []:
results_meta.append({
"class": res.get("Class") or "",
"type": res.get("Type") or "",
"n": len(res.get("Vulnerabilities") or []),
})
for v in res.get("Vulnerabilities") or []:
nvd = ((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")
findings.append(
{
"id": v.get("VulnerabilityID"),
"pkg": v.get("PkgName"),
"installed": v.get("InstalledVersion"),
"vendor_sev": v.get("Severity") or "UNKNOWN",
"sev_source": v.get("SeveritySource"),
"nvd_score": nvd,
"nvd_sev": nvd_severity(nvd),
"status": v.get("Status"),
"fixed": v.get("FixedVersion") or "",
}
)
return {
"image": meta.get("image") or os.path.basename(path),
"chart": meta.get("chart", ""),
"version": meta.get("version", ""),
"os": f"{os_info.get('Family','?')} {os_info.get('Name','?')}",
"eosl": bool(os_info.get("EOSL")),
"findings": findings,
"results_meta": results_meta,
# scan-sbom.sh 의 커버리지 자가진단 결과 (ok|none|n/a). 키가 없으면 구버전 리포트다.
"coverage_probe": d.get("CoverageProbe"),
}
def sbom_os_package_count(sbom_path):
"""SBOM 의 OS 패키지 수 (deb/rpm/apk). os-pkgs 0건이 이상인지 판단할 맥락을 준다."""
if not sbom_path or not os.path.exists(sbom_path):
return None
import re as _re
d = json.load(open(sbom_path))
n = 0
for c in d.get("components") or []:
if _re.match(r"pkg:(deb|rpm|apk)/", c.get("purl") or ""):
n += 1
return n
def sbom_packages(sbom_path):
"""
CycloneDX SBOM 에서 전체 패키지 목록을 읽는다.
스캔 리포트의 findings 로 패키지 목록을 근사하면 **취약점이 0건인 패키지가 빠진다.**
사각지대 검출은 정확히 그런 패키지(벤더가 평가하지 않아 아무 CVE 도 안 붙은 것)를
찾는 것이 목적이므로, 반드시 SBOM 을 근거로 써야 한다.
"""
if not sbom_path or not os.path.exists(sbom_path):
return None
d = json.load(open(sbom_path))
names = set()
for c in d.get("components") or []:
n = c.get("name")
if n:
names.add(normalize_pkg(n))
return names or None
def crossref_blindspots(img, ref_paths, target_pkgs_override=None):
"""
교차 검증 — 다른 배포판 기반 이미지의 스캔 결과와 비교해 '보이지 않는' CVE 를 찾는다.
필요한 이유: 배포판이 CVE 를 아직 평가하지 않으면(예: Ubuntu 의
"Needs evaluation") 스캐너가 그 CVE 를 **아예 보고하지 않는다.** 실측 결과
`--detection-priority comprehensive` 로도 드러나지 않았다. 따라서 벤더 데이터에만
의존하면 "0건" 이 "평가되지 않아 안 보이는 것" 과 구별되지 않는다.
방법: 참조 이미지(데이터가 충실한 배포판, 예: Debian stable)의 CRITICAL/HIGH CVE 중
대상 이미지에 없고, 정규화된 패키지 이름이 대상 이미지에도 존재하는 것을
'사각지대 후보' 로 표시한다. 후보는 사람이 벤더 트래커에서 실제 상태를 확인해야 한다
(fixed 인지 needs-evaluation 인지).
"""
target_cves = {f["id"] for f in img["findings"]}
if target_pkgs_override:
target_pkgs = target_pkgs_override
else:
# SBOM 이 없으면 취약점이 붙은 패키지로 근사한다. 이 경우 취약점 0건인
# 패키지는 비교 대상에서 빠지므로 사각지대 검출이 불완전해진다.
target_pkgs = {normalize_pkg(f["pkg"]) for f in img["findings"]}
candidates = {}
for rp in ref_paths:
if not os.path.exists(rp):
continue
d = json.load(open(rp))
ref_os = ((d.get("Metadata") or {}).get("OS") or {})
for res in d.get("Results") or []:
for v in res.get("Vulnerabilities") or []:
cid = v.get("VulnerabilityID")
if cid in target_cves:
continue
nvd = ((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")
eff = v.get("Severity") or "UNKNOWN"
ns = nvd_severity(nvd)
if ns and RANK.get(ns, 0) > RANK.get(eff, 0):
eff = ns
if RANK.get(eff, 0) < RANK["HIGH"]:
continue
np = normalize_pkg(v.get("PkgName"))
if np not in target_pkgs:
continue
c = candidates.setdefault(
cid,
{"id": cid, "sev": eff, "pkgs": set(), "ref_os": f"{ref_os.get('Family','?')} {ref_os.get('Name','?')}"},
)
c["pkgs"].add(v.get("PkgName"))
if RANK.get(eff, 0) > RANK.get(c["sev"], 0):
c["sev"] = eff
return sorted(candidates.values(), key=lambda c: (-RANK.get(c["sev"], 0), c["id"]))
def evaluate(img, exceptions):
"""이미지 하나를 판정. 반환: 판정 결과 dict."""
blocking = [] # 게이트를 막는 항목 (고유 CVE 단위)
excepted = [] # 예외로 승인된 항목
underrated = [] # 벤더가 NVD 보다 낮게 등급한 항목 (NVD >= HIGH)
# 고유 CVE 단위로 접는다. 같은 CVE 가 여러 패키지로 분할 집계되는 것을
# 그대로 세면 실제 위험을 과대평가한다 (perl 이 4개 패키지로 쪼개지는 사례).
by_cve = {}
for f in img["findings"]:
cur = by_cve.setdefault(f["id"], {"id": f["id"], "pkgs": set(), "vendor_sev": "UNKNOWN",
"nvd_sev": None, "nvd_score": None,
"status": set(), "fixed": set()})
cur["pkgs"].add(f["pkg"])
if RANK.get(f["vendor_sev"], 0) > RANK.get(cur["vendor_sev"], 0):
cur["vendor_sev"] = f["vendor_sev"]
if f["nvd_sev"] and RANK.get(f["nvd_sev"], 0) > RANK.get(cur["nvd_sev"] or "UNKNOWN", 0):
cur["nvd_sev"] = f["nvd_sev"]
cur["nvd_score"] = f["nvd_score"]
if f["status"]:
cur["status"].add(f["status"])
if f["fixed"]:
cur["fixed"].add(f["fixed"])
for cve in by_cve.values():
vend = cve["vendor_sev"]
nvd = cve["nvd_sev"]
# 실효 심각도 = 벤더와 NVD 중 높은 쪽. 벤더의 하향 등급으로 게이트를 통과하는 것을 막는다.
effective = vend if RANK.get(vend, 0) >= RANK.get(nvd or "UNKNOWN", 0) else nvd
cve["effective_sev"] = effective
if nvd and RANK.get(nvd, 0) > RANK.get(vend, 0) and RANK.get(nvd, 0) >= RANK["HIGH"]:
underrated.append(cve)
if RANK.get(effective, 0) < RANK["HIGH"]:
continue
exc = next((e for e in exceptions if exception_applies(e, cve["id"], img["image"])), None)
if exc:
cve["exception"] = exc
excepted.append(cve)
else:
blocking.append(cve)
# 데이터 커버리지 이상: findings 0건이 "취약점 없음" 인지 "해당 배포판 보안 데이터가
# 없음" 인지는 **숫자로 구분되지 않는다.** 실측이 양쪽 다 있다 —
# debian sid 149 패키지 0건 → 데이터 부재 (거짓 clean)
# cloudnative-pg OS 패키지 4개 0건 → 진짜 0건
#
# 구분은 scan-sbom.sh 의 자가진단(양성 대조)이 한다. 게이트는 그 답을 읽을 뿐이다.
# ok 스캐너가 이 배포판을 안다 none 데이터 없음 n/a OS 패키지 없음
probe = img.get("coverage_probe")
rm = img.get("results_meta") or []
os_results = [r for r in rm if r["class"] == "os-pkgs"]
if probe is not None:
no_data = probe == "none"
# 프로브가 답했으면 os-pkgs 0건은 정상이므로 경고하지 않는다.
os_silent = False
else:
# 구버전 리포트 — 프로브가 없다. 예전 동작(findings 총계 0건 → 차단)을 유지하고,
# os-pkgs 만 0건인 경우는 맥락(OS 패키지 수)과 함께 경고한다.
no_data = len(img["findings"]) == 0
os_silent = bool(os_results) and sum(r["n"] for r in os_results) == 0
return {
"coverage_probe": probe,
"os_silent": os_silent,
"os_pkg_count": img.get("os_pkg_count"),
"image": img["image"],
"chart": img["chart"],
"version": img["version"],
"os": img["os"],
"eosl": img["eosl"],
"total_findings": len(img["findings"]),
"unique_cves": len(by_cve),
"blocking": sorted(blocking, key=lambda c: (-RANK.get(c["effective_sev"], 0), c["id"])),
"excepted": sorted(excepted, key=lambda c: c["id"]),
"underrated": sorted(underrated, key=lambda c: -(c["nvd_score"] or 0)),
"no_data": no_data,
"counts": {
"vendor": {
s: sum(1 for c in by_cve.values() if c["vendor_sev"] == s)
for s in ("CRITICAL", "HIGH")
},
"nvd": {
s: sum(1 for c in by_cve.values() if c["nvd_sev"] == s)
for s in ("CRITICAL", "HIGH")
},
"effective": {
s: sum(1 for c in by_cve.values() if c["effective_sev"] == s)
for s in ("CRITICAL", "HIGH")
},
},
}
def gate_verdict(results, missing):
total_block = sum(len(r["blocking"]) for r in results)
nodata = [r for r in results if r["no_data"]]
return "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL"
def render_summary_table(results, charts_of=None):
"""차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 —
이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에
선형으로만 늘어난다(차단 항목 상세 표와 달리 이미지당 CVE 수에 비례해 불어나지 않음).
차트 이름 오름차순으로 묶고, 같은 차트 안에서는 실효 CRITICAL/HIGH 내림차순으로
정렬한다(가장 위험한 이미지가 위로). `charts_of`(image → [(chart, version), ...])를
주면 이미지 하나가 여러 차트에 쓰일 때 차트마다 한 행씩 보여준다 — 안 주면(또는
해당 이미지 정보가 없으면) 결과가 이미 갖고 있는 대표 차트 하나만 쓴다."""
charts_of = charts_of or {}
expanded = [] # (chart, version, result)
for r in results:
pairs = charts_of.get(r["image"]) or [(r["chart"], r["version"])]
for chart, version in pairs:
expanded.append((chart, version, r))
def eff(r, sev):
return r["counts"]["effective"][sev]
expanded.sort(key=lambda cvr: (cvr[0], -eff(cvr[2], "CRITICAL"), -eff(cvr[2], "HIGH")))
L = []
A = L.append
A("| Chart | Image | OS | 커버리지 | EOSL | 벤더 C/H | NVD C/H | 실효 C/H | 차단 | 예외 |")
A("|---|---|---|---|---|---:|---:|---:|---:|---:|")
PROBE_LABEL = {"ok": "✅ ok", "none": "❌ none", "n/a": " n/a"}
for chart, version, r in expanded:
c = r["counts"]
src = PROBE_LABEL.get(r.get("coverage_probe"), "? 미측정")
A(
f"| {chart}@{version} | `{r['image']}` | {r['os']} | {src} | "
f"{'⚠️ EOL' if r['eosl'] else '-'} | "
f"{c['vendor']['CRITICAL']}/{c['vendor']['HIGH']} | "
f"{c['nvd']['CRITICAL']}/{c['nvd']['HIGH']} | "
f"**{c['effective']['CRITICAL']}/{c['effective']['HIGH']}** | "
f"{len(r['blocking'])} | {len(r['excepted'])} |"
)
return L
def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report", charts_of=None):
"""GitHub Actions Job Summary(`$GITHUB_STEP_SUMMARY`)용 — 건수 표까지만 담고 CVE
개별 상세는 첨부 파일을 보라고 안내한다.
전체 리포트(`render_md`)를 그대로 Job Summary 에 넣으면 카탈로그 전체 스캔에서
실제로 깨졌다 — 45+ 차트 전체 스캔 시 `cve-gate.md` 가 2.2MB 로 GitHub 의
Job Summary 1MB 제한을 넘어 업로드 자체가 중단됐다(실측). 건수 표는 이미지 수에
선형으로만 늘어나 이 한계에 걸리지 않는다.
"""
missing = missing or []
L = []
A = L.append
verdict = gate_verdict(results, missing)
A(f"## 🎯 CVE 게이트: {verdict}")
A("")
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
A("")
L.extend(render_summary_table(results, charts_of))
A("")
if missing:
A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.")
nodata_n = sum(1 for r in results if r["no_data"])
if nodata_n:
A(f"❌ **데이터 커버리지 이상 {nodata_n}건** — findings 0건이 실제 0건인지 측정 불가인지 구분 안 됨.")
A("")
A(
f"CVE 개별 상세(차단 항목·벤더 하향 등급·사각지대 후보)는 이 표에 없다 — "
f"아티팩트 **`{artifact_name}`** 의 **`cve-gate.md`**(마크다운 전체 리포트) 또는 "
f"**`cve-gate.json`**(구조화 데이터)을 내려받아 확인한다."
)
return "\n".join(L)
def render_md(results, expired_exceptions, missing=None, charts_of=None):
L = []
A = L.append
missing = missing or []
verdict = gate_verdict(results, missing)
A(f"## 🎯 CVE 게이트: {verdict}")
A("")
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
A("")
L.extend(render_summary_table(results, charts_of))
A("")
A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 "
"(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). "
"`ok` = 데이터 있음이므로 0건은 진짜 0건 / `none` = 데이터 없음이므로 **차단** / "
"`n/a` = OS 패키지 없음 / `미측정` = 프로브 이전 리포트. "
"이 열이 없으면 \"0건\"\"측정되지 않음\" 이 구분되지 않는다.")
A("")
if missing:
A(f"### ❌ 스캔되지 않은 이미지 — {len(missing)}건")
A("")
A("인벤토리에 있으나 스캔 리포트가 없다. SBOM 생성이 실패한 것이며 "
"**이 이미지들은 판정되지 않았다.** 리포트에 안 나타나는 것을 "
"'문제 없음' 으로 읽어서는 안 된다.")
A("")
A("| Chart | Image |")
A("|---|---|")
for img, chart in missing:
A(f"| {chart} | `{img}` |")
A("")
A("확인 순서: `sbom-gen.log` 의 stderr 원문 → 레지스트리 인증 → 태그 존재 여부.")
A("")
for r in results:
if r["no_data"]:
A(f"### ❌ 데이터 커버리지 이상 — `{r['image']}`")
A("")
if r.get("coverage_probe") == "none":
A(f"자가진단이 **`none`** 이다 — SBOM 사본에 취약한 센티널 패키지를 주입해 "
f"재스캔해도 findings 가 0건이었다. 스캐너에 `{r['os']}` 보안 데이터가 "
"**없다.** 이 이미지의 0건은 측정 결과가 아니므로 게이트를 실패시킨다.")
else:
A(f"전 심각도 findings 가 **0건**이고 자가진단 결과가 없다(프로브 이전 리포트). "
f"`{r['os']}` 데이터 부재일 가능성을 배제할 수 없어 실패시킨다. "
"`scan-sbom.sh` 로 재스캔하면 자가진단이 판정한다.")
A("")
for r in results:
if not r["blocking"]:
continue
A(f"### 차단 항목 — `{r['image']}`")
A("")
A("| CVE | 실효 등급 | 벤더 | NVD | 패키지 | status | 수정 버전 |")
A("|---|---|---|---|---|---|---|")
for c in r["blocking"]:
pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "")
nvd = c["nvd_sev"] or "-"
if c["nvd_score"]:
nvd = f"{nvd} ({c['nvd_score']})"
status = "/".join(sorted(c["status"])) or "-"
fixed = "/".join(sorted(c["fixed"])) or "(없음)"
A(f"| {c['id']} | **{c['effective_sev']}** | {c['vendor_sev']} | {nvd} | "
f"{pkgs} | {status} | {fixed} |")
A("")
for r in results:
if not r["underrated"]:
continue
A(f"### ⚠️ 벤더 하향 등급 — `{r['image']}`")
A("")
A("벤더가 NVD 보다 낮게 등급한 항목이다. 벤더 기준만 보면 게이트를 통과하지만 "
"실제 위험은 NVD 등급에 가깝다. 베이스 OS 교체로 수치만 낮아진 것이 아닌지 확인한다.")
A("")
A("| CVE | NVD | 벤더 | 패키지 |")
A("|---|---|---|---|")
for c in r["underrated"]:
pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "")
A(f"| {c['id']} | {c['nvd_sev']} ({c['nvd_score']}) | {c['vendor_sev']} | {pkgs} |")
A("")
for r in results:
if not r.get("blindspots"):
continue
A(f"### 🕳️ 사각지대 후보 — `{r['image']}`")
A("")
A("참조 배포판에서는 CRITICAL/HIGH 로 보고되는데 이 이미지의 리포트에는 없는 CVE 다. "
"벤더가 아직 평가하지 않아(needs-evaluation) 보고되지 않는 것일 수 있다. "
"**`0건` 을 근거로 쓰기 전에 각 항목의 벤더 트래커 상태를 확인해야 한다.**")
A("")
A("| CVE | 참조 등급 | 참조 OS | 해당 패키지 |")
A("|---|---|---|---|")
for c in r["blindspots"]:
pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "")
A(f"| {c['id']} | {c['sev']} | {c['ref_os']} | {pkgs} |")
A("")
excepted_any = [(r, c) for r in results for c in r["excepted"]]
if excepted_any:
A("### 승인된 예외")
A("")
A("| CVE | 이미지 | 만료일 | 근거 |")
A("|---|---|---|---|")
for r, c in excepted_any:
e = c["exception"]
A(f"| {c['id']} | `{r['image']}` | {e.get('expires','(무기한)')} | {e.get('reason','')} |")
A("")
if expired_exceptions:
A("### ⏰ 만료된 예외 — 재검토 필요")
A("")
for e in expired_exceptions:
A(f"- `{e.get('id')}` (만료 {e.get('expires')}) — {e.get('reason','')}")
A("")
return "\n".join(L)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reports", required=True, help="trivy-reports 디렉토리 (전 심각도 스캔 결과)")
ap.add_argument("--index", default="", help="sbom-index.tsv (리포트→이미지 매핑)")
ap.add_argument("--exceptions", default="", help="승인 예외 JSON")
ap.add_argument("--summary-md", default="", help="마크다운 요약 출력 경로 (CVE 상세 전부 포함)")
ap.add_argument(
"--brief-md",
default="",
help="건수 표만 담은 축약 마크다운 출력 경로. GitHub Actions Job Summary 처럼 "
"크기 제한이 있는 곳에 쓴다 — --summary-md 전체 리포트는 카탈로그 규모가 커지면 "
"GitHub 의 Job Summary 1MB 제한을 넘을 수 있다(실측: 45+ 차트 전체 스캔 시 2.2MB)",
)
ap.add_argument(
"--artifact-name",
default="sbom-and-vuln-report",
help="--brief-md 안내문에 적을 아티팩트 이름 (CVE 상세는 여기서 --summary-md/--json-out 파일을 찾으라고 안내)",
)
ap.add_argument("--json-out", default="", help="판정 결과 JSON 출력 경로")
ap.add_argument("--warn-only", action="store_true", help="실패해도 종료 코드 0")
ap.add_argument(
"--crossref",
action="append",
default=[],
help="교차 검증용 참조 스캔 리포트 JSON (데이터가 충실한 배포판 기반). 반복 지정 가능",
)
ap.add_argument(
"--sbom-dir",
default="",
help="CycloneDX SBOM 디렉토리. 교차 검증 시 대상 패키지 목록의 근거가 된다. "
"지정하지 않으면 취약점이 0건인 패키지가 비교에서 빠져 사각지대 검출이 불완전해진다",
)
ap.add_argument(
"--fail-on-blindspot",
action="store_true",
help="사각지대 후보가 있으면 게이트를 실패시킨다 (기본: 경고만)",
)
ap.add_argument(
"--inventory",
default="",
help="images_final.tsv. 지정하면 인벤토리에 있으나 리포트가 없는 이미지를 "
"실패로 판정한다. 지정하지 않으면 SBOM 생성 실패로 스캔되지 않은 이미지를 "
"게이트가 알 수 없다",
)
args = ap.parse_args()
if not os.path.isdir(args.reports):
print(f"::error::리포트 디렉토리 없음: {args.reports}", file=sys.stderr)
return 2
stem_map = report_stem_to_image(args.index)
charts_of = charts_of_image(args.index)
exceptions, expired = load_exceptions(args.exceptions)
results = []
for path in sorted(glob.glob(os.path.join(args.reports, "*.json"))):
stem = os.path.basename(path)[: -len(".json")]
meta = stem_map.get(stem, {})
img = analyze(path, meta)
# OS 패키지 수 — os-pkgs 0건이 이상인지 사람이 판단할 맥락이다.
if args.sbom_dir:
cand = os.path.join(args.sbom_dir, stem + ".cdx.json")
img["os_pkg_count"] = sbom_os_package_count(cand)
r = evaluate(img, exceptions)
if args.crossref:
# 대상 패키지 목록은 SBOM 에서 읽는다 (취약점 0건 패키지까지 포함하기 위해).
sbom = ""
if args.sbom_dir:
cand = os.path.join(args.sbom_dir, stem + ".cdx.json")
if os.path.exists(cand):
sbom = cand
pkgs = sbom_packages(sbom)
r["blindspot_basis"] = "sbom" if pkgs else "findings(불완전)"
r["blindspots"] = crossref_blindspots(img, args.crossref, pkgs)
else:
r["blindspots"] = []
results.append(r)
if not results:
print(f"::error::스캔 리포트가 없다: {args.reports}", file=sys.stderr)
return 2
# 인벤토리 대조 — 리포트가 아예 없는 이미지를 찾는다.
# 이것이 없으면 SBOM 생성 실패로 스캔되지 않은 이미지가 게이트에 보이지 않는다.
inv = inventory_images(args.inventory)
missing = []
if inv:
scanned = {r["image"] for r in results}
missing = sorted((img, chart) for img, chart in inv.items() if img not in scanned)
md = render_md(results, expired, missing, charts_of)
print(md)
if args.summary_md:
with open(args.summary_md, "w") as f:
f.write(md + "\n")
if args.brief_md:
with open(args.brief_md, "w") as f:
f.write(render_brief_md(results, missing, args.artifact_name, charts_of) + "\n")
if args.json_out:
with open(args.json_out, "w") as f:
json.dump(results, f, indent=2, default=lambda o: sorted(o) if isinstance(o, set) else str(o))
blocking = sum(len(r["blocking"]) for r in results)
nodata = [r for r in results if r["no_data"]]
for img, chart in missing:
print(f"::error::스캔되지 않음 — {img} ({chart}) 인벤토리에 있으나 리포트가 없다",
file=sys.stderr)
for r in nodata:
why = ("자가진단 none — 스캐너에 데이터가 없다" if r.get("coverage_probe") == "none"
else "findings 0건 + 자가진단 결과 없음")
print(f"::error::데이터 커버리지 이상 — {r['image']} ({r['os']}) {why}", file=sys.stderr)
# 구버전 리포트 전용 경고 — 자가진단이 있으면 뜨지 않는다.
for r in results:
if r.get("os_silent") and not r["no_data"]:
n = r.get("os_pkg_count")
ctx = f"OS 패키지 {n}개" if n is not None else "패키지 수 미상"
print(f"::warning::{r['image']} ({r['os']}) OS 패키지 findings 0건 — {ctx}. "
f"자가진단 결과가 없다 — scan-sbom.sh 로 재스캔하면 판정된다", file=sys.stderr)
for r in results:
for c in r["blocking"]:
print(f"::error::{r['image']}: {c['id']} {c['effective_sev']} "
f"(벤더 {c['vendor_sev']} / NVD {c['nvd_sev'] or '-'})", file=sys.stderr)
blind = sum(len(r.get("blindspots") or []) for r in results)
for r in results:
for c in r.get("blindspots") or []:
lvl = "error" if args.fail_on_blindspot else "warning"
print(f"::{lvl}::{r['image']}: {c['id']} 사각지대 후보 "
f"(참조 {c['ref_os']} 에서 {c['sev']}, 이 이미지 리포트에는 없음)", file=sys.stderr)
failed = bool(blocking or nodata or missing or (blind and args.fail_on_blindspot))
if failed:
print(f"\n게이트 실패 — 차단 {blocking}건, 데이터 이상 {len(nodata)}건, "
f"스캔 누락 {len(missing)}건, 사각지대 후보 {blind}건", file=sys.stderr)
return 0 if args.warn_only else 1
msg = "게이트 통과 — 실효 CRITICAL/HIGH 0건"
if blind:
msg += f" (단 사각지대 후보 {blind}건 — 벤더 트래커 확인 필요)"
print("\n" + msg, file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())