From 4e2e57cebc5f4c4bd6cae7b360c6ab2f674765f7 Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Tue, 4 Aug 2026 11:43:21 +0900 Subject: [PATCH] =?UTF-8?q?cve-gate.py:=20=EC=9A=94=EC=95=BD=20=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B0=A8=ED=8A=B8=EB=B3=84=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20+=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=B0=A8=ED=8A=B8=20=EA=B7=80=EC=86=8D=20=EB=B3=B4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/pipeline/cve-gate.py | 57 +++++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/scripts/pipeline/cve-gate.py b/scripts/pipeline/cve-gate.py index 0a928f1..5fd87a7 100755 --- a/scripts/pipeline/cve-gate.py +++ b/scripts/pipeline/cve-gate.py @@ -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))