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:
@@ -161,6 +161,25 @@ def report_stem_to_image(index_path):
|
||||
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 {}
|
||||
@@ -389,20 +408,37 @@ def gate_verdict(results, missing):
|
||||
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 양쪽에서 재사용한다 —
|
||||
이 표만으로는 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 = []
|
||||
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 r in results:
|
||||
for chart, version, r in expanded:
|
||||
c = r["counts"]
|
||||
src = PROBE_LABEL.get(r.get("coverage_probe"), "? 미측정")
|
||||
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"{c['vendor']['CRITICAL']}/{c['vendor']['HIGH']} | "
|
||||
f"{c['nvd']['CRITICAL']}/{c['nvd']['HIGH']} | "
|
||||
@@ -412,7 +448,7 @@ def render_summary_table(results):
|
||||
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
|
||||
개별 상세는 첨부 파일을 보라고 안내한다.
|
||||
|
||||
@@ -429,7 +465,7 @@ def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report")
|
||||
A("")
|
||||
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
|
||||
A("")
|
||||
L.extend(render_summary_table(results))
|
||||
L.extend(render_summary_table(results, charts_of))
|
||||
A("")
|
||||
if 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)
|
||||
|
||||
|
||||
def render_md(results, expired_exceptions, missing=None):
|
||||
def render_md(results, expired_exceptions, missing=None, charts_of=None):
|
||||
L = []
|
||||
A = L.append
|
||||
missing = missing or []
|
||||
@@ -454,7 +490,7 @@ def render_md(results, expired_exceptions, missing=None):
|
||||
A("")
|
||||
A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)")
|
||||
A("")
|
||||
L.extend(render_summary_table(results))
|
||||
L.extend(render_summary_table(results, charts_of))
|
||||
A("")
|
||||
A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 "
|
||||
"(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). "
|
||||
@@ -613,6 +649,7 @@ def main():
|
||||
return 2
|
||||
|
||||
stem_map = report_stem_to_image(args.index)
|
||||
charts_of = charts_of_image(args.index)
|
||||
exceptions, expired = load_exceptions(args.exceptions)
|
||||
|
||||
results = []
|
||||
@@ -651,14 +688,14 @@ def main():
|
||||
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)
|
||||
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) + "\n")
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user