diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index df7b0c0..45ae340 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -127,17 +127,22 @@ jobs: --exceptions doc/cve-exceptions.json \ --sbom-dir "$OUT_DIR/sbom" \ --summary-md "$OUT_DIR/cve-gate.md" \ + --brief-md "$OUT_DIR/cve-gate-brief.md" \ + --artifact-name sbom-and-vuln-report \ --json-out "$OUT_DIR/cve-gate.json" \ --warn-only else echo "스캔 리포트 없음 — 게이트 판정 생략" fi + # Job Summary($GITHUB_STEP_SUMMARY)는 1MB 제한이 있다 — 전체 리포트(cve-gate.md)를 + # 그대로 넣으면 카탈로그 전체 스캔에서 실제로 잘렸다(45+ 차트, 2.2MB 실측). + # 건수 표만 담은 cve-gate-brief.md 를 쓰고, CVE 상세는 아티팩트를 보라고 안내한다. - name: Job Summary if: always() run: | if [ -f "$OUT_DIR/trivy-summary.md" ]; then cat "$OUT_DIR/trivy-summary.md" >> "$GITHUB_STEP_SUMMARY"; fi - if [ -f "$OUT_DIR/cve-gate.md" ]; then cat "$OUT_DIR/cve-gate.md" >> "$GITHUB_STEP_SUMMARY"; fi + if [ -f "$OUT_DIR/cve-gate-brief.md" ]; then cat "$OUT_DIR/cve-gate-brief.md" >> "$GITHUB_STEP_SUMMARY"; fi # 아티팩트 레이아웃 정리: 요약·인덱스는 최상위, 이미지별 상세 SBOM 은 sbom/ 하위. # (절대경로로 업로드하면 v4 가 전체 경로트리를 보존하므로, 스테이징 후 상대경로로 업로드) diff --git a/scripts/pipeline/cve-gate.py b/scripts/pipeline/cve-gate.py index ef465a8..0a928f1 100755 --- a/scripts/pipeline/cve-gate.py +++ b/scripts/pipeline/cve-gate.py @@ -383,17 +383,18 @@ def evaluate(img, exceptions): } -def render_md(results, expired_exceptions, missing=None): - L = [] - A = L.append - missing = missing or [] +def gate_verdict(results, missing): total_block = sum(len(r["blocking"]) for r in results) nodata = [r for r in results if r["no_data"]] - verdict = "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL" - A(f"## 🎯 CVE 게이트: {verdict}") - A("") - A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)") - A("") + return "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL" + + +def render_summary_table(results): + """차트·이미지별 취약점 '건수' 요약 표. 전체 리포트와 Job Summary 양쪽에서 재사용한다 — + 이 표만으로는 CVE 개별 상세(ID·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에 + 선형으로만 늘어난다(차단 항목 상세 표와 달리 이미지당 CVE 수에 비례해 불어나지 않음).""" + 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"} @@ -408,6 +409,52 @@ def render_md(results, expired_exceptions, missing=None): f"**{c['effective']['CRITICAL']}/{c['effective']['HIGH']}** | " f"{len(r['blocking'])} | {len(r['excepted'])} |" ) + return L + + +def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report"): + """GitHub Actions Job Summary(`$GITHUB_STEP_SUMMARY`)용 — 건수 표까지만 담고 CVE + 개별 상세는 첨부 파일을 보라고 안내한다. + + 전체 리포트(`render_md`)를 그대로 Job Summary 에 넣으면 카탈로그 전체 스캔에서 + 실제로 깨졌다 — 45+ 차트 전체 스캔 시 `cve-gate.md` 가 2.2MB 로 GitHub 의 + Job Summary 1MB 제한을 넘어 업로드 자체가 중단됐다(실측). 건수 표는 이미지 수에 + 선형으로만 늘어나 이 한계에 걸리지 않는다. + """ + missing = missing or [] + L = [] + A = L.append + verdict = gate_verdict(results, missing) + A(f"## 🎯 CVE 게이트: {verdict}") + A("") + A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)") + A("") + L.extend(render_summary_table(results)) + A("") + if missing: + A(f"❌ **스캔되지 않은 이미지 {len(missing)}건** — 인벤토리에 있으나 리포트가 없어 판정하지 못했다.") + nodata_n = sum(1 for r in results if r["no_data"]) + if nodata_n: + A(f"❌ **데이터 커버리지 이상 {nodata_n}건** — findings 0건이 실제 0건인지 측정 불가인지 구분 안 됨.") + A("") + A( + f"CVE 개별 상세(차단 항목·벤더 하향 등급·사각지대 후보)는 이 표에 없다 — " + f"아티팩트 **`{artifact_name}`** 의 **`cve-gate.md`**(마크다운 전체 리포트) 또는 " + f"**`cve-gate.json`**(구조화 데이터)을 내려받아 확인한다." + ) + return "\n".join(L) + + +def render_md(results, expired_exceptions, missing=None): + L = [] + A = L.append + missing = missing or [] + verdict = gate_verdict(results, missing) + A(f"## 🎯 CVE 게이트: {verdict}") + A("") + A("목표: 카탈로그 제공 이미지의 CRITICAL/HIGH **0건** (고유 CVE 기준, 벤더·NVD 중 높은 등급 적용)") + A("") + L.extend(render_summary_table(results)) A("") A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 " "(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). " @@ -520,7 +567,19 @@ def main(): ap.add_argument("--reports", required=True, help="trivy-reports 디렉토리 (전 심각도 스캔 결과)") ap.add_argument("--index", default="", help="sbom-index.tsv (리포트→이미지 매핑)") ap.add_argument("--exceptions", default="", help="승인 예외 JSON") - ap.add_argument("--summary-md", default="", help="마크다운 요약 출력 경로") + ap.add_argument("--summary-md", default="", help="마크다운 요약 출력 경로 (CVE 상세 전부 포함)") + ap.add_argument( + "--brief-md", + default="", + help="건수 표만 담은 축약 마크다운 출력 경로. GitHub Actions Job Summary 처럼 " + "크기 제한이 있는 곳에 쓴다 — --summary-md 전체 리포트는 카탈로그 규모가 커지면 " + "GitHub 의 Job Summary 1MB 제한을 넘을 수 있다(실측: 45+ 차트 전체 스캔 시 2.2MB)", + ) + ap.add_argument( + "--artifact-name", + default="sbom-and-vuln-report", + help="--brief-md 안내문에 적을 아티팩트 이름 (CVE 상세는 여기서 --summary-md/--json-out 파일을 찾으라고 안내)", + ) ap.add_argument("--json-out", default="", help="판정 결과 JSON 출력 경로") ap.add_argument("--warn-only", action="store_true", help="실패해도 종료 코드 0") ap.add_argument( @@ -597,6 +656,9 @@ def main(): 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") 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))