CVE 게이트(scripts/pipeline/cve-gate.py) 도입
security-catalog 에서 포팅: 고유 CVE 단위 집계, max(벤더,NVD) 실효 등급, 승인 예외(doc/cve-exceptions.json) 처리. 워크플로 연결은 다음 커밋에서.
This commit is contained in:
@@ -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": []
|
||||
}
|
||||
Executable
+645
@@ -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())
|
||||
Reference in New Issue
Block a user