Files
service-catalog/scripts/pipeline/cve-gate.py
T
wbsong111 20d7a41193 CVE 게이트(scripts/pipeline/cve-gate.py) 도입
security-catalog 에서 포팅: 고유 CVE 단위 집계, max(벤더,NVD) 실효 등급,
승인 예외(doc/cve-exceptions.json) 처리. 워크플로 연결은 다음 커밋에서.
2026-08-03 09:25:21 +09:00

646 lines
28 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())