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:
@@ -437,6 +437,17 @@ def version_sort_key(version):
|
||||
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):
|
||||
"""차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 —
|
||||
이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에
|
||||
@@ -469,19 +480,19 @@ def render_summary_table(results, charts_of=None):
|
||||
|
||||
L = []
|
||||
A = L.append
|
||||
A("| Chart | Image | OS | 커버리지 | EOSL | 벤더 C/H | NVD C/H | 실효 C/H | 차단 | 예외 |")
|
||||
A("|---|---|---|---|---|---:|---:|---:|---:|---:|")
|
||||
A("| Chart | Image | OS | 커버리지 | EOSL | 실효 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"), "? 미측정")
|
||||
under = len(r["underrated"])
|
||||
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'])} |"
|
||||
f"{len(r['blocking'])} | {len(r['excepted'])} | "
|
||||
f"{('⚠️ ' + str(under)) if under else '-'} |"
|
||||
)
|
||||
return L
|
||||
|
||||
@@ -505,6 +516,8 @@ def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report",
|
||||
A("")
|
||||
L.extend(render_summary_table(results, charts_of))
|
||||
A("")
|
||||
A(SUMMARY_LEGEND)
|
||||
A("")
|
||||
if missing:
|
||||
A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.")
|
||||
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("")
|
||||
L.extend(render_summary_table(results, charts_of))
|
||||
A("")
|
||||
A(SUMMARY_LEGEND)
|
||||
A("")
|
||||
A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 "
|
||||
"(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). "
|
||||
"`ok` = 데이터 있음이므로 0건은 진짜 0건 / `none` = 데이터 없음이므로 **차단** / "
|
||||
|
||||
Reference in New Issue
Block a user