cve-gate: 요약표의 등급 열을 실효 C/H 하나로 정리

벤더 C/H · NVD C/H · 실효 C/H 세 열이 나란히 놓여 어느 것이 판정값인지 헷갈렸다.
실제로 "벤더 5 / NVD 11 인데 실효가 15" 를 어떻게 읽어야 하는지 질문이 나왔다 —
세 열이 같은 CVE 집합을 세지 않기 때문이다. 실효는 CVE 마다 max(벤더, NVD) 를
적용한 결과라 합집합이 된다(5 + 11 − 교집합 1 = 15). 가로로 더하거나 빼서
검산되지 않는 숫자들을 나란히 두는 것이 혼란의 원인이었다.

판정 기준은 실효 C/H 하나뿐이므로 그것만 남기고, 벤더·NVD 의 불일치는
`하향` 열(벤더가 NVD 보다 낮게 매긴 CVE 수)로 대신 드러낸다. 개별 CVE 목록은
이미 `⚠️ 벤더 하향 등급` 절에 있어 집계 열은 중복이었다.

  이전: | 벤더 C/H | NVD C/H | 실효 C/H | 차단 | 예외 |
  이후: | 실효 C/H | 차단 | 예외 | 하향 |

- 범례(SUMMARY_LEGEND) 를 상수로 빼서 render_md 와 render_brief_md 양쪽에 붙였다.
  Job Summary(render_brief_md)에는 범례가 아예 없어서, 새 `하향` 열이 설명 없이
  노출될 뻔했다.

하향 신호를 열로 남긴 이유 — 실측:

  argocd:v2.14.5 (ubuntu 24.04)  하향 43건
  argocd:v3.5.1  (ubuntu 26.04)  하향  1건
  dex:v2.45.1    (alpine)        하향  4건  ← 전부 SeveritySource=ghsa (Go 모듈)
  redis 2개      (alpine)        하향  0건  ← Alpine 은 자체 등급을 제공하지 않는다

os-pkgs 의 등급 출처를 세어보면 ubuntu 332건은 SeveritySource=ubuntu 인데
alpine 150건은 nvd 이거나 출처 없음이다. 즉 Alpine 이미지에서 "벤더" 열은
NVD 를 그대로 되비추는 값이라 비교 자체가 성립하지 않았다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-19 12:38:43 +09:00
parent f10d50e400
commit 5120120ed9
+19 -4
View File
@@ -437,6 +437,17 @@ def version_sort_key(version):
return (core_parts, 0 if pre_parts else 1, pre_parts) return (core_parts, 0 if pre_parts else 1, pre_parts)
SUMMARY_LEGEND = (
"> **실효 C/H** 는 `max(벤더 등급, NVD 등급)` 으로 집계한 고유 CVE 수이며 "
"**게이트 판정의 유일한 기준**이다. 벤더 등급과 NVD 등급을 따로 세던 열은 뺐다 — "
"비슷하게 생긴 숫자 셋이 나란히 놓여 어느 것이 판정값인지 헷갈렸다. "
"대신 **하향** 열이 벤더가 NVD 보다 낮게 매긴 CVE 수를 알려준다. 이 값이 있으면 "
"\"벤더 기준으로는 안전해 보이지만 실제 위험은 NVD 등급에 가까운\" 항목이 있다는 뜻이고, "
"베이스 OS 교체로 수치만 낮아진 것이 아닌지 확인해야 한다 "
"(해당 CVE 목록은 `cve-gate.md` 의 `⚠️ 벤더 하향 등급` 절)."
)
def render_summary_table(results, charts_of=None): def render_summary_table(results, charts_of=None):
"""차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 — """차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 —
이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에 이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에
@@ -469,19 +480,19 @@ def render_summary_table(results, charts_of=None):
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 | 차단 | 예외 | 하향 |")
A("|---|---|---|---|---|---:|---:|---:|---:|---:|") A("|---|---|---|---|---|---:|---:|---:|---:|")
PROBE_LABEL = {"ok": "✅ ok", "none": "❌ none", "n/a": " n/a"} PROBE_LABEL = {"ok": "✅ ok", "none": "❌ none", "n/a": " n/a"}
for chart, version, r in expanded: 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"), "? 미측정")
under = len(r["underrated"])
A( A(
f"| {chart}@{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['nvd']['CRITICAL']}/{c['nvd']['HIGH']} | "
f"**{c['effective']['CRITICAL']}/{c['effective']['HIGH']}** | " f"**{c['effective']['CRITICAL']}/{c['effective']['HIGH']}** | "
f"{len(r['blocking'])} | {len(r['excepted'])} | " f"{len(r['blocking'])} | {len(r['excepted'])} | "
f"{('⚠️ ' + str(under)) if under else '-'} |"
) )
return L return L
@@ -505,6 +516,8 @@ def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report",
A("") A("")
L.extend(render_summary_table(results, charts_of)) L.extend(render_summary_table(results, charts_of))
A("") A("")
A(SUMMARY_LEGEND)
A("")
if missing: if missing:
A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.") A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.")
nodata_n = sum(1 for r in results if r["no_data"]) nodata_n = sum(1 for r in results if r["no_data"])
@@ -530,6 +543,8 @@ def render_md(results, expired_exceptions, missing=None, charts_of=None):
A("") A("")
L.extend(render_summary_table(results, charts_of)) L.extend(render_summary_table(results, charts_of))
A("") A("")
A(SUMMARY_LEGEND)
A("")
A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 " A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 "
"(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). " "(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). "
"`ok` = 데이터 있음이므로 0건은 진짜 0건 / `none` = 데이터 없음이므로 **차단** / " "`ok` = 데이터 있음이므로 0건은 진짜 0건 / `none` = 데이터 없음이므로 **차단** / "