CVE 게이트(scripts/pipeline/cve-gate.py) 도입

security-catalog 에서 포팅: 고유 CVE 단위 집계, max(벤더,NVD) 실효 등급,
승인 예외(doc/cve-exceptions.json) 처리. 워크플로 연결은 다음 커밋에서.
This commit is contained in:
wbsong111
2026-08-03 09:25:21 +09:00
parent 39bb879495
commit 20d7a41193
2 changed files with 660 additions and 0 deletions
+15
View File
@@ -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": []
}
+645
View File
@@ -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())