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>
This commit is contained in:
wbsong111
2026-08-04 11:43:21 +09:00
parent d714667f81
commit 4e2e57cebc
+47 -10
View File
@@ -161,6 +161,25 @@ def report_stem_to_image(index_path):
return m 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): def analyze(path, meta):
d = json.load(open(path)) d = json.load(open(path))
os_info = (d.get("Metadata") or {}).get("OS") or {} os_info = (d.get("Metadata") or {}).get("OS") or {}
@@ -389,20 +408,37 @@ def gate_verdict(results, missing):
return "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL" return "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL"
def render_summary_table(results): def render_summary_table(results, charts_of=None):
"""차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 — """차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 —
이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에 이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에
선형으로만 늘어난다(차단 항목 상세 표와 달리 이미지당 CVE 수에 비례해 불어나지 않음).""" 선형으로만 늘어난다(차단 항목 상세 표와 달리 이미지당 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 = [] L = []
A = L.append A = L.append
A("| Chart | Image | OS | 커버리지 | EOSL | 벤더 C/H | NVD C/H | 실효 C/H | 차단 | 예외 |") A("| Chart | Image | OS | 커버리지 | EOSL | 벤더 C/H | NVD C/H | 실효 C/H | 차단 | 예외 |")
A("|---|---|---|---|---|---:|---:|---:|---:|---:|") A("|---|---|---|---|---|---:|---:|---:|---:|---:|")
PROBE_LABEL = {"ok": "✅ ok", "none": "❌ none", "n/a": " n/a"} PROBE_LABEL = {"ok": "✅ ok", "none": "❌ none", "n/a": " n/a"}
for r in results: for chart, version, r in expanded:
c = r["counts"] c = r["counts"]
src = PROBE_LABEL.get(r.get("coverage_probe"), "? 미측정") src = PROBE_LABEL.get(r.get("coverage_probe"), "? 미측정")
A( A(
f"| {r['chart']}@{r['version']} | `{r['image']}` | {r['os']} | {src} | " f"| {chart}@{version} | `{r['image']}` | {r['os']} | {src} | "
f"{'⚠️ EOL' if r['eosl'] else '-'} | " f"{'⚠️ EOL' if r['eosl'] else '-'} | "
f"{c['vendor']['CRITICAL']}/{c['vendor']['HIGH']} | " f"{c['vendor']['CRITICAL']}/{c['vendor']['HIGH']} | "
f"{c['nvd']['CRITICAL']}/{c['nvd']['HIGH']} | " f"{c['nvd']['CRITICAL']}/{c['nvd']['HIGH']} | "
@@ -412,7 +448,7 @@ def render_summary_table(results):
return L return L
def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report"): def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report", charts_of=None):
"""GitHub Actions Job Summary(`$GITHUB_STEP_SUMMARY`)용 — 건수 표까지만 담고 CVE """GitHub Actions Job Summary(`$GITHUB_STEP_SUMMARY`)용 — 건수 표까지만 담고 CVE
개별 상세는 첨부 파일을 보라고 안내한다. 개별 상세는 첨부 파일을 보라고 안내한다.
@@ -429,7 +465,7 @@ def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report")
A("") A("")
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)") A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
A("") A("")
L.extend(render_summary_table(results)) L.extend(render_summary_table(results, charts_of))
A("") A("")
if missing: if missing:
A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.") A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.")
@@ -445,7 +481,7 @@ def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report")
return "\n".join(L) return "\n".join(L)
def render_md(results, expired_exceptions, missing=None): def render_md(results, expired_exceptions, missing=None, charts_of=None):
L = [] L = []
A = L.append A = L.append
missing = missing or [] missing = missing or []
@@ -454,7 +490,7 @@ def render_md(results, expired_exceptions, missing=None):
A("") A("")
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)") A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
A("") A("")
L.extend(render_summary_table(results)) L.extend(render_summary_table(results, charts_of))
A("") A("")
A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 " A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 "
"(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). " "(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). "
@@ -613,6 +649,7 @@ def main():
return 2 return 2
stem_map = report_stem_to_image(args.index) stem_map = report_stem_to_image(args.index)
charts_of = charts_of_image(args.index)
exceptions, expired = load_exceptions(args.exceptions) exceptions, expired = load_exceptions(args.exceptions)
results = [] results = []
@@ -651,14 +688,14 @@ def main():
scanned = {r["image"] for r in results} scanned = {r["image"] for r in results}
missing = sorted((img, chart) for img, chart in inv.items() if img not in scanned) missing = sorted((img, chart) for img, chart in inv.items() if img not in scanned)
md = render_md(results, expired, missing) md = render_md(results, expired, missing, charts_of)
print(md) print(md)
if args.summary_md: if args.summary_md:
with open(args.summary_md, "w") as f: with open(args.summary_md, "w") as f:
f.write(md + "\n") f.write(md + "\n")
if args.brief_md: if args.brief_md:
with open(args.brief_md, "w") as f: with open(args.brief_md, "w") as f:
f.write(render_brief_md(results, missing, args.artifact_name) + "\n") f.write(render_brief_md(results, missing, args.artifact_name, charts_of) + "\n")
if args.json_out: if args.json_out:
with open(args.json_out, "w") as f: 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)) json.dump(results, f, indent=2, default=lambda o: sorted(o) if isinstance(o, set) else str(o))