diff --git a/doc/cve-exceptions.json b/doc/cve-exceptions.json new file mode 100644 index 0000000..307bf1a --- /dev/null +++ b/doc/cve-exceptions.json @@ -0,0 +1,15 @@ +{ + "_readme": [ + "CRITICAL/HIGH 0건 게이트의 승인된 예외 목록. scripts/pipeline/cve-gate.py 가 읽는다.", + "", + "규칙:", + " - 근거(reason)는 '조사해서 확인한 사실'만 쓴다. '위험이 낮아 보인다' 같은 추측은 예외 근거가 아니다.", + " - expires 는 필수. 만료되면 게이트가 다시 막고 재검토를 강제한다. 기본 3개월.", + " - images 는 부분일치. '*' 는 전체 적용.", + " - 예외는 '위험을 수용한다'는 기록이다. 숫자를 지우는 수단으로 쓰면 목표 자체가 무의미해진다.", + "", + "예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /", + "scripts/build/build-hardened-image.sh 로 자체 빌드. 예외는 마지막 수단이다." + ], + "exceptions": [] +} diff --git a/scripts/pipeline/cve-gate.py b/scripts/pipeline/cve-gate.py new file mode 100755 index 0000000..ef465a8 --- /dev/null +++ b/scripts/pipeline/cve-gate.py @@ -0,0 +1,645 @@ +#!/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 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 render_md(results, expired_exceptions, missing=None): + L = [] + A = L.append + missing = missing or [] + 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("") + 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: + c = r["counts"] + src = PROBE_LABEL.get(r.get("coverage_probe"), "? 미측정") + A( + f"| {r['chart']}@{r['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'])} |" + ) + 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="마크다운 요약 출력 경로") + 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) + 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) + print(md) + if args.summary_md: + with open(args.summary_md, "w") as f: + f.write(md + "\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())