#!/usr/bin/env python3 """ cve-gate.py — 카탈로그 이미지의 CRITICAL/HIGH 0건 목표를 판정하는 게이트. 왜 별도 게이트가 필요한가 ------------------------- trivy 는 배포판이 제공하는 **벤더 심각도**를 우선 사용한다. 그런데 같은 CVE 를 배포판마다 다르게 등급한다. 실측 예: CVE-2026-8376 NVD CVSS 9.8 → Debian: CRITICAL / Ubuntu: MEDIUM 즉 "벤더 기준 CRITICAL 0건" 은 베이스 OS 를 바꾸는 것만으로도 달성될 수 있고, 그것은 위험이 줄어든 것이 아니다. 이 게이트는 **벤더 기준과 NVD CVSS 기준을 함께** 집계해 그 차이를 드러낸다. 또한 스캔 단계에서 `--severity HIGH,CRITICAL` 로 필터하면 벤더가 MEDIUM 으로 낮춘 항목이 리포트에 아예 없으므로 위 비교가 불가능하다. 이 스크립트가 소비하는 리포트는 **전 심각도로 스캔된 것**이어야 한다. (scan-sbom.sh 에 SEVERITY 를 지정하지 않고 실행) "0건" 과 "측정되지 않음" 의 구분 -------------------------------- findings 0건은 그 자체로 안전을 뜻하지 않는다. 스캐너에 그 배포판 데이터가 없어도 같은 값이 나온다(실측: Debian sid 149 패키지 0건). 이 게이트는 스스로 판단하지 않고 `scan-sbom.sh` 가 리포트에 기록한 자가진단 결과 `CoverageProbe` 를 읽는다. ok 스캐너가 이 배포판을 안다 → 0건은 진짜 0건 none 스캐너에 데이터가 없다 → **차단** n/a SBOM 에 OS 패키지가 없다 (키 없음) 프로브 이전 리포트 → 예전 동작(총계 0건 → 차단) 유지 사용 ---- python3 scripts/pipeline/cve-gate.py \ --reports sbom-out/trivy-reports \ --index sbom-out/sbom-index.tsv \ --exceptions doc/cve-exceptions.json \ --summary-md sbom-out/cve-gate.md 종료 코드 0 게이트 통과 1 게이트 실패 (미승인 CRITICAL/HIGH 존재, 또는 데이터 커버리지 이상) 2 실행 오류 """ import argparse import glob import json import os import sys from datetime import date, datetime RANK = {"CRITICAL": 4, "HIGH": 3, "MEDIUM": 2, "LOW": 1, "UNKNOWN": 0} def normalize_pkg(name): """ 배포판별 패키지 이름 차이를 흡수한다. Debian 의 libperl5.40 / perl-modules-5.40 과 Ubuntu 의 libperl5.38t64 / perl-modules-5.38 을 같은 것으로 보기 위한 정규화다. """ import re if not name: return "" n = name.split(":")[0] n = re.sub(r"\d+(\.\d+)*(t64)?$", "", n) # 뒤쪽 버전 숫자 제거 n = re.sub(r"[-.]$", "", n) return n def nvd_severity(score): """NVD CVSS v3 점수를 심각도로 환산. 점수가 없으면 None.""" if score is None: return None if score >= 9.0: return "CRITICAL" if score >= 7.0: return "HIGH" if score >= 4.0: return "MEDIUM" return "LOW" def load_exceptions(path): """ 승인된 예외 목록. 형식(JSON): {"exceptions": [ {"id": "CVE-2026-8376", "images": ["*"], # 또는 특정 이미지 문자열 부분일치 "reason": "32-bit 빌드 한정 — 배포 대상은 x86_64", "expires": "2026-10-31"} ]} expires 가 지난 항목은 예외로 인정하지 않는다(재검토 강제). """ if not path or not os.path.exists(path): return [], [] with open(path) as f: data = json.load(f) active, expired = [], [] today = date.today() for e in data.get("exceptions") or []: exp = e.get("expires") if exp: try: if datetime.strptime(exp, "%Y-%m-%d").date() < today: expired.append(e) continue except ValueError: raise SystemExit(f"예외 항목의 expires 형식이 잘못됨: {exp!r} (YYYY-MM-DD)") active.append(e) return active, expired def exception_applies(exc, cve_id, image): if exc.get("id") != cve_id: return False pats = exc.get("images") or ["*"] return any(p == "*" or p in image for p in pats) def inventory_images(path): """ images_final.tsv (chart⇥version⇥image) 에서 스캔되어야 할 이미지 목록을 읽는다. 필요한 이유: 게이트는 리포트 디렉토리만 보므로 **SBOM 생성이 실패해 리포트가 아예 없는 이미지를 알 수 없다.** 실측 사례 — CI 에서 SUSE 이미지 3개의 SBOM 생성이 실패했는데 워크플로는 성공으로 끝나고 게이트는 남은 2개만 판정했다. 인벤토리 5개 중 2개만 보고도 "판정 완료" 가 된 것이다. 목표 정의 4번("0건" 과 "측정되지 않음" 을 구분한다)이 이미지 단위에서도 지켜지도록 인벤토리와 대조한다. """ if not path or not os.path.exists(path): return None imgs = {} with open(path) as f: for line in f: p = line.rstrip("\n").split("\t") if len(p) >= 3 and p[2]: imgs[p[2]] = f"{p[0]}@{p[1]}" return imgs or None def report_stem_to_image(index_path): """sbom-index.tsv(chart⇥version⇥image⇥status⇥seconds⇥sbom_file) → 리포트 파일명 매핑.""" 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] and p[5]: stem = os.path.basename(p[5]) if stem.endswith(".cdx.json"): stem = stem[: -len(".cdx.json")] m[stem] = {"image": p[2], "chart": p[0], "version": p[1]} 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 {} findings = [] # Result 별 메타를 보존한다. 커버리지 판정에 필요하다 — findings 총계만 보면 # OS 패키지가 0건인데 언어 패키지 findings 때문에 통과한다(실측: postgres-exporter). results_meta = [] for res in d.get("Results") or []: results_meta.append({ "class": res.get("Class") or "", "type": res.get("Type") or "", "n": len(res.get("Vulnerabilities") or []), }) for v in res.get("Vulnerabilities") or []: nvd = ((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score") findings.append( { "id": v.get("VulnerabilityID"), "pkg": v.get("PkgName"), "installed": v.get("InstalledVersion"), "vendor_sev": v.get("Severity") or "UNKNOWN", "sev_source": v.get("SeveritySource"), "nvd_score": nvd, "nvd_sev": nvd_severity(nvd), "status": v.get("Status"), "fixed": v.get("FixedVersion") or "", } ) return { "image": meta.get("image") or os.path.basename(path), "chart": meta.get("chart", ""), "version": meta.get("version", ""), "os": f"{os_info.get('Family','?')} {os_info.get('Name','?')}", "eosl": bool(os_info.get("EOSL")), "findings": findings, "results_meta": results_meta, # scan-sbom.sh 의 커버리지 자가진단 결과 (ok|none|n/a). 키가 없으면 구버전 리포트다. "coverage_probe": d.get("CoverageProbe"), } def sbom_os_package_count(sbom_path): """SBOM 의 OS 패키지 수 (deb/rpm/apk). os-pkgs 0건이 이상인지 판단할 맥락을 준다.""" if not sbom_path or not os.path.exists(sbom_path): return None import re as _re d = json.load(open(sbom_path)) n = 0 for c in d.get("components") or []: if _re.match(r"pkg:(deb|rpm|apk)/", c.get("purl") or ""): n += 1 return n def sbom_packages(sbom_path): """ CycloneDX SBOM 에서 전체 패키지 목록을 읽는다. 스캔 리포트의 findings 로 패키지 목록을 근사하면 **취약점이 0건인 패키지가 빠진다.** 사각지대 검출은 정확히 그런 패키지(벤더가 평가하지 않아 아무 CVE 도 안 붙은 것)를 찾는 것이 목적이므로, 반드시 SBOM 을 근거로 써야 한다. """ if not sbom_path or not os.path.exists(sbom_path): return None d = json.load(open(sbom_path)) names = set() for c in d.get("components") or []: n = c.get("name") if n: names.add(normalize_pkg(n)) return names or None def crossref_blindspots(img, ref_paths, target_pkgs_override=None): """ 교차 검증 — 다른 배포판 기반 이미지의 스캔 결과와 비교해 '보이지 않는' CVE 를 찾는다. 필요한 이유: 배포판이 CVE 를 아직 평가하지 않으면(예: Ubuntu 의 "Needs evaluation") 스캐너가 그 CVE 를 **아예 보고하지 않는다.** 실측 결과 `--detection-priority comprehensive` 로도 드러나지 않았다. 따라서 벤더 데이터에만 의존하면 "0건" 이 "평가되지 않아 안 보이는 것" 과 구별되지 않는다. 방법: 참조 이미지(데이터가 충실한 배포판, 예: Debian stable)의 CRITICAL/HIGH CVE 중 대상 이미지에 없고, 정규화된 패키지 이름이 대상 이미지에도 존재하는 것을 '사각지대 후보' 로 표시한다. 후보는 사람이 벤더 트래커에서 실제 상태를 확인해야 한다 (fixed 인지 needs-evaluation 인지). """ target_cves = {f["id"] for f in img["findings"]} if target_pkgs_override: target_pkgs = target_pkgs_override else: # SBOM 이 없으면 취약점이 붙은 패키지로 근사한다. 이 경우 취약점 0건인 # 패키지는 비교 대상에서 빠지므로 사각지대 검출이 불완전해진다. target_pkgs = {normalize_pkg(f["pkg"]) for f in img["findings"]} candidates = {} for rp in ref_paths: if not os.path.exists(rp): continue d = json.load(open(rp)) ref_os = ((d.get("Metadata") or {}).get("OS") or {}) for res in d.get("Results") or []: for v in res.get("Vulnerabilities") or []: cid = v.get("VulnerabilityID") if cid in target_cves: continue nvd = ((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score") eff = v.get("Severity") or "UNKNOWN" ns = nvd_severity(nvd) if ns and RANK.get(ns, 0) > RANK.get(eff, 0): eff = ns if RANK.get(eff, 0) < RANK["HIGH"]: continue np = normalize_pkg(v.get("PkgName")) if np not in target_pkgs: continue c = candidates.setdefault( cid, {"id": cid, "sev": eff, "pkgs": set(), "ref_os": f"{ref_os.get('Family','?')} {ref_os.get('Name','?')}"}, ) c["pkgs"].add(v.get("PkgName")) if RANK.get(eff, 0) > RANK.get(c["sev"], 0): c["sev"] = eff return sorted(candidates.values(), key=lambda c: (-RANK.get(c["sev"], 0), c["id"])) def evaluate(img, exceptions): """이미지 하나를 판정. 반환: 판정 결과 dict.""" blocking = [] # 게이트를 막는 항목 (고유 CVE 단위) excepted = [] # 예외로 승인된 항목 underrated = [] # 벤더가 NVD 보다 낮게 등급한 항목 (NVD >= HIGH) # 고유 CVE 단위로 접는다. 같은 CVE 가 여러 패키지로 분할 집계되는 것을 # 그대로 세면 실제 위험을 과대평가한다 (perl 이 4개 패키지로 쪼개지는 사례). by_cve = {} for f in img["findings"]: cur = by_cve.setdefault(f["id"], {"id": f["id"], "pkgs": set(), "vendor_sev": "UNKNOWN", "nvd_sev": None, "nvd_score": None, "status": set(), "fixed": set()}) cur["pkgs"].add(f["pkg"]) if RANK.get(f["vendor_sev"], 0) > RANK.get(cur["vendor_sev"], 0): cur["vendor_sev"] = f["vendor_sev"] if f["nvd_sev"] and RANK.get(f["nvd_sev"], 0) > RANK.get(cur["nvd_sev"] or "UNKNOWN", 0): cur["nvd_sev"] = f["nvd_sev"] cur["nvd_score"] = f["nvd_score"] if f["status"]: cur["status"].add(f["status"]) if f["fixed"]: cur["fixed"].add(f["fixed"]) for cve in by_cve.values(): vend = cve["vendor_sev"] nvd = cve["nvd_sev"] # 실효 심각도 = 벤더와 NVD 중 높은 쪽. 벤더의 하향 등급으로 게이트를 통과하는 것을 막는다. effective = vend if RANK.get(vend, 0) >= RANK.get(nvd or "UNKNOWN", 0) else nvd cve["effective_sev"] = effective if nvd and RANK.get(nvd, 0) > RANK.get(vend, 0) and RANK.get(nvd, 0) >= RANK["HIGH"]: underrated.append(cve) if RANK.get(effective, 0) < RANK["HIGH"]: continue exc = next((e for e in exceptions if exception_applies(e, cve["id"], img["image"])), None) if exc: cve["exception"] = exc excepted.append(cve) else: blocking.append(cve) # 데이터 커버리지 이상: findings 0건이 "취약점 없음" 인지 "해당 배포판 보안 데이터가 # 없음" 인지는 **숫자로 구분되지 않는다.** 실측이 양쪽 다 있다 — # debian sid 149 패키지 0건 → 데이터 부재 (거짓 clean) # cloudnative-pg OS 패키지 4개 0건 → 진짜 0건 # # 구분은 scan-sbom.sh 의 자가진단(양성 대조)이 한다. 게이트는 그 답을 읽을 뿐이다. # ok 스캐너가 이 배포판을 안다 none 데이터 없음 n/a OS 패키지 없음 probe = img.get("coverage_probe") rm = img.get("results_meta") or [] os_results = [r for r in rm if r["class"] == "os-pkgs"] if probe is not None: no_data = probe == "none" # 프로브가 답했으면 os-pkgs 0건은 정상이므로 경고하지 않는다. os_silent = False else: # 구버전 리포트 — 프로브가 없다. 예전 동작(findings 총계 0건 → 차단)을 유지하고, # os-pkgs 만 0건인 경우는 맥락(OS 패키지 수)과 함께 경고한다. no_data = len(img["findings"]) == 0 os_silent = bool(os_results) and sum(r["n"] for r in os_results) == 0 return { "coverage_probe": probe, "os_silent": os_silent, "os_pkg_count": img.get("os_pkg_count"), "image": img["image"], "chart": img["chart"], "version": img["version"], "os": img["os"], "eosl": img["eosl"], "total_findings": len(img["findings"]), "unique_cves": len(by_cve), "blocking": sorted(blocking, key=lambda c: (-RANK.get(c["effective_sev"], 0), c["id"])), "excepted": sorted(excepted, key=lambda c: c["id"]), "underrated": sorted(underrated, key=lambda c: -(c["nvd_score"] or 0)), "no_data": no_data, "counts": { "vendor": { s: sum(1 for c in by_cve.values() if c["vendor_sev"] == s) for s in ("CRITICAL", "HIGH") }, "nvd": { s: sum(1 for c in by_cve.values() if c["nvd_sev"] == s) for s in ("CRITICAL", "HIGH") }, "effective": { s: sum(1 for c in by_cve.values() if c["effective_sev"] == s) for s in ("CRITICAL", "HIGH") }, }, } 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"]] return "PASS" if (total_block == 0 and not nodata and not missing) else "FAIL" def version_sort_key(version): """차트 버전 문자열을 비교 가능한 키로 바꾼다. 문자열 그대로 비교하면 "10.4.0" < "7.8.11" 이 되어 최신 버전이 아래로 내려간다. 숫자 구간을 정수로 파싱해 자연스러운 순서를 만든다. 프리릴리스(`-rc1`, `-alpha`)는 코어와 분리해 다룬다. 단순히 구간을 이어붙여 비교하면 리스트가 긴 쪽이 커져 "1.2.0-rc1" 이 "1.2.0" 보다 최신으로 정렬된다 (실측). semver 규칙대로 프리릴리스가 없는 정식 릴리스를 더 크게 본다. 숫자가 아닌 구간은 문자열로 비교하되, 두 종류가 섞여도 비교가 깨지지 않도록 각 구간을 (숫자여부, 정수, 문자열) 튜플로 통일한다. """ import re def parts_of(s, seps): out = [] for p in re.split(seps, s) if s else []: out.append((1, int(p), "") if p.isdigit() else (0, 0, p)) return out raw = (version or "").lstrip("vV") core, _, pre = raw.partition("-") core_parts = parts_of(core, r"[._+]") pre_parts = parts_of(pre, r"[.\-_+]") # 프리릴리스가 없는 쪽이 더 크다 → 1.2.0 > 1.2.0-rc1 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·패키지·상태)를 담지 않으므로 크기가 카탈로그 규모에 선형으로만 늘어난다(차단 항목 상세 표와 달리 이미지당 CVE 수에 비례해 불어나지 않음). 차트 이름 오름차순으로 묶고, 같은 차트 안에서는 **버전 내림차순(최신 먼저)**, 같은 버전 안에서는 실효 CRITICAL/HIGH 내림차순으로 정렬한다. 카탈로그가 한 차트의 여러 버전을 동시에 들고 있어서, 버전 정렬이 없으면 최신 버전과 구버전이 위험도 순으로 섞여 "지금 쓸 버전이 어느 정도인가" 를 한눈에 볼 수 없다(실측: argo-cd 10.4.0 이 구버전 7.x 사이에 끼어 표시됐다). `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] # 파이썬 sort 는 안정 정렬이므로, 우선순위가 낮은 키부터 차례로 정렬해 겹친다. # (버전은 내림차순이라 reverse=True 가 필요해 한 번에 튜플로 묶을 수 없다.) expanded.sort(key=lambda cvr: (-eff(cvr[2], "CRITICAL"), -eff(cvr[2], "HIGH"))) expanded.sort(key=lambda cvr: version_sort_key(cvr[1]), reverse=True) expanded.sort(key=lambda cvr: cvr[0]) L = [] A = L.append 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['effective']['CRITICAL']}/{c['effective']['HIGH']}** | " f"{len(r['blocking'])} | {len(r['excepted'])} | " f"{('⚠️ ' + str(under)) if under else '-'} |" ) return L def render_brief_md(results, missing=None, artifact_name="sbom-and-vuln-report", charts_of=None): """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, 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"]) 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, charts_of=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, charts_of)) A("") A(SUMMARY_LEGEND) A("") A("> **커버리지** 열은 스캐너가 그 배포판을 아는지 직접 물어본 결과다 " "(SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하는 양성 대조). " "`ok` = 데이터 있음이므로 0건은 진짜 0건 / `none` = 데이터 없음이므로 **차단** / " "`n/a` = OS 패키지 없음 / `미측정` = 프로브 이전 리포트. " "이 열이 없으면 \"0건\" 과 \"측정되지 않음\" 이 구분되지 않는다.") A("") if missing: A(f"### ❌ 스캔되지 않은 이미지 — {len(missing)}건") A("") A("인벤토리에 있으나 스캔 리포트가 없다. SBOM 생성이 실패한 것이며 " "**이 이미지들은 판정되지 않았다.** 리포트에 안 나타나는 것을 " "'문제 없음' 으로 읽어서는 안 된다.") A("") A("| Chart | Image |") A("|---|---|") for img, chart in missing: A(f"| {chart} | `{img}` |") A("") A("확인 순서: `sbom-gen.log` 의 stderr 원문 → 레지스트리 인증 → 태그 존재 여부.") A("") for r in results: if r["no_data"]: A(f"### ❌ 데이터 커버리지 이상 — `{r['image']}`") A("") if r.get("coverage_probe") == "none": A(f"자가진단이 **`none`** 이다 — SBOM 사본에 취약한 센티널 패키지를 주입해 " f"재스캔해도 findings 가 0건이었다. 스캐너에 `{r['os']}` 보안 데이터가 " "**없다.** 이 이미지의 0건은 측정 결과가 아니므로 게이트를 실패시킨다.") else: A(f"전 심각도 findings 가 **0건**이고 자가진단 결과가 없다(프로브 이전 리포트). " f"`{r['os']}` 데이터 부재일 가능성을 배제할 수 없어 실패시킨다. " "`scan-sbom.sh` 로 재스캔하면 자가진단이 판정한다.") A("") for r in results: if not r["blocking"]: continue A(f"### 차단 항목 — `{r['image']}`") A("") A("| CVE | 실효 등급 | 벤더 | NVD | 패키지 | status | 수정 버전 |") A("|---|---|---|---|---|---|---|") for c in r["blocking"]: pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "") nvd = c["nvd_sev"] or "-" if c["nvd_score"]: nvd = f"{nvd} ({c['nvd_score']})" status = "/".join(sorted(c["status"])) or "-" fixed = "/".join(sorted(c["fixed"])) or "(없음)" A(f"| {c['id']} | **{c['effective_sev']}** | {c['vendor_sev']} | {nvd} | " f"{pkgs} | {status} | {fixed} |") A("") for r in results: if not r["underrated"]: continue A(f"### ⚠️ 벤더 하향 등급 — `{r['image']}`") A("") A("벤더가 NVD 보다 낮게 등급한 항목이다. 벤더 기준만 보면 게이트를 통과하지만 " "실제 위험은 NVD 등급에 가깝다. 베이스 OS 교체로 수치만 낮아진 것이 아닌지 확인한다.") A("") A("| CVE | NVD | 벤더 | 패키지 |") A("|---|---|---|---|") for c in r["underrated"]: pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "") A(f"| {c['id']} | {c['nvd_sev']} ({c['nvd_score']}) | {c['vendor_sev']} | {pkgs} |") A("") for r in results: if not r.get("blindspots"): continue A(f"### 🕳️ 사각지대 후보 — `{r['image']}`") A("") A("참조 배포판에서는 CRITICAL/HIGH 로 보고되는데 이 이미지의 리포트에는 없는 CVE 다. " "벤더가 아직 평가하지 않아(needs-evaluation) 보고되지 않는 것일 수 있다. " "**`0건` 을 근거로 쓰기 전에 각 항목의 벤더 트래커 상태를 확인해야 한다.**") A("") A("| CVE | 참조 등급 | 참조 OS | 해당 패키지 |") A("|---|---|---|---|") for c in r["blindspots"]: pkgs = ", ".join(sorted(c["pkgs"])[:3]) + ("…" if len(c["pkgs"]) > 3 else "") A(f"| {c['id']} | {c['sev']} | {c['ref_os']} | {pkgs} |") A("") excepted_any = [(r, c) for r in results for c in r["excepted"]] if excepted_any: A("### 승인된 예외") A("") A("| CVE | 이미지 | 만료일 | 근거 |") A("|---|---|---|---|") for r, c in excepted_any: e = c["exception"] A(f"| {c['id']} | `{r['image']}` | {e.get('expires','(무기한)')} | {e.get('reason','')} |") A("") if expired_exceptions: A("### ⏰ 만료된 예외 — 재검토 필요") A("") for e in expired_exceptions: A(f"- `{e.get('id')}` (만료 {e.get('expires')}) — {e.get('reason','')}") A("") return "\n".join(L) def main(): ap = argparse.ArgumentParser() 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="마크다운 요약 출력 경로 (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( "--crossref", action="append", default=[], help="교차 검증용 참조 스캔 리포트 JSON (데이터가 충실한 배포판 기반). 반복 지정 가능", ) ap.add_argument( "--sbom-dir", default="", help="CycloneDX SBOM 디렉토리. 교차 검증 시 대상 패키지 목록의 근거가 된다. " "지정하지 않으면 취약점이 0건인 패키지가 비교에서 빠져 사각지대 검출이 불완전해진다", ) ap.add_argument( "--fail-on-blindspot", action="store_true", help="사각지대 후보가 있으면 게이트를 실패시킨다 (기본: 경고만)", ) ap.add_argument( "--inventory", default="", help="images_final.tsv. 지정하면 인벤토리에 있으나 리포트가 없는 이미지를 " "실패로 판정한다. 지정하지 않으면 SBOM 생성 실패로 스캔되지 않은 이미지를 " "게이트가 알 수 없다", ) args = ap.parse_args() if not os.path.isdir(args.reports): print(f"::error::리포트 디렉토리 없음: {args.reports}", file=sys.stderr) return 2 stem_map = report_stem_to_image(args.index) charts_of = charts_of_image(args.index) exceptions, expired = load_exceptions(args.exceptions) results = [] for path in sorted(glob.glob(os.path.join(args.reports, "*.json"))): stem = os.path.basename(path)[: -len(".json")] meta = stem_map.get(stem, {}) img = analyze(path, meta) # OS 패키지 수 — os-pkgs 0건이 이상인지 사람이 판단할 맥락이다. if args.sbom_dir: cand = os.path.join(args.sbom_dir, stem + ".cdx.json") img["os_pkg_count"] = sbom_os_package_count(cand) r = evaluate(img, exceptions) if args.crossref: # 대상 패키지 목록은 SBOM 에서 읽는다 (취약점 0건 패키지까지 포함하기 위해). sbom = "" if args.sbom_dir: cand = os.path.join(args.sbom_dir, stem + ".cdx.json") if os.path.exists(cand): sbom = cand pkgs = sbom_packages(sbom) r["blindspot_basis"] = "sbom" if pkgs else "findings(불완전)" r["blindspots"] = crossref_blindspots(img, args.crossref, pkgs) else: r["blindspots"] = [] results.append(r) if not results: print(f"::error::스캔 리포트가 없다: {args.reports}", file=sys.stderr) return 2 # 인벤토리 대조 — 리포트가 아예 없는 이미지를 찾는다. # 이것이 없으면 SBOM 생성 실패로 스캔되지 않은 이미지가 게이트에 보이지 않는다. inv = inventory_images(args.inventory) missing = [] if inv: 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, 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, 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)) blocking = sum(len(r["blocking"]) for r in results) nodata = [r for r in results if r["no_data"]] for img, chart in missing: print(f"::error::스캔되지 않음 — {img} ({chart}) 인벤토리에 있으나 리포트가 없다", file=sys.stderr) for r in nodata: why = ("자가진단 none — 스캐너에 데이터가 없다" if r.get("coverage_probe") == "none" else "findings 0건 + 자가진단 결과 없음") print(f"::error::데이터 커버리지 이상 — {r['image']} ({r['os']}) {why}", file=sys.stderr) # 구버전 리포트 전용 경고 — 자가진단이 있으면 뜨지 않는다. for r in results: if r.get("os_silent") and not r["no_data"]: n = r.get("os_pkg_count") ctx = f"OS 패키지 {n}개" if n is not None else "패키지 수 미상" print(f"::warning::{r['image']} ({r['os']}) OS 패키지 findings 0건 — {ctx}. " f"자가진단 결과가 없다 — scan-sbom.sh 로 재스캔하면 판정된다", file=sys.stderr) for r in results: for c in r["blocking"]: print(f"::error::{r['image']}: {c['id']} {c['effective_sev']} " f"(벤더 {c['vendor_sev']} / NVD {c['nvd_sev'] or '-'})", file=sys.stderr) blind = sum(len(r.get("blindspots") or []) for r in results) for r in results: for c in r.get("blindspots") or []: lvl = "error" if args.fail_on_blindspot else "warning" print(f"::{lvl}::{r['image']}: {c['id']} 사각지대 후보 " f"(참조 {c['ref_os']} 에서 {c['sev']}, 이 이미지 리포트에는 없음)", file=sys.stderr) failed = bool(blocking or nodata or missing or (blind and args.fail_on_blindspot)) if failed: print(f"\n게이트 실패 — 차단 {blocking}건, 데이터 이상 {len(nodata)}건, " f"스캔 누락 {len(missing)}건, 사각지대 후보 {blind}건", file=sys.stderr) return 0 if args.warn_only else 1 msg = "게이트 통과 — 실효 CRITICAL/HIGH 0건" if blind: msg += f" (단 사각지대 후보 {blind}건 — 벤더 트래커 확인 필요)" print("\n" + msg, file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())