#!/usr/bin/env python3 """ suggest-go-upgrades.py — 스캔 리포트에서 GO_MODULE_UPGRADES 값을 산출한다. 왜 필요한가 ----------- 자체 빌드 이미지가 Go 모듈 CVE 를 해소할 때 `images//.build.env` 의 `GO_MODULE_UPGRADES` 에 `@` 을 적는다. 그 버전을 사람이 게이트 리포트에서 하나씩 찾아 최대값을 고르는 것은 지루하고 틀리기 쉽다 — 필요한 정보는 이미 trivy 리포트의 `FixedVersion` 에 다 있다. 이 스크립트가 그것을 뽑아 붙여넣을 수 있는 형태로 낸다. 왜 "완전 자동"(빌드 시점에 최신으로 당기기)이 아닌가 -------------------------------------------------- `go get -u` 로 매번 최신을 끌면 같은 소스로 빌드해도 이미지가 달라진다. 이 레포는 반대를 택했다(.claude/image-authoring.md "롤링 태그를 쓰지 않는다", SOURCE_COMMIT·BUILD_DATE 고정). 버전 핀은 **우리가 무엇을 검증했는지의 기록**이다. 그래서 이 스크립트는 값을 제안만 하고, 채택은 사람이 build.env 에 커밋한다 — git diff 에 "무엇이 왜 올라갔는지" 가 남는다. 심각도 기준은 게이트와 같다 -------------------------- `max(벤더 등급, NVD 등급)` 을 실효 등급으로 쓴다. 판정 규칙이 두 곳으로 갈리지 않도록 cve-gate.py 의 함수를 그대로 가져다 쓴다. 사용 ---- python3 scripts/build/suggest-go-upgrades.py --reports python3 scripts/build/suggest-go-upgrades.py --reports sbom-out/trivy-reports \\ --image argocd --min-severity HIGH 종료 코드 0 제안 출력(대상이 없으면 그 사실을 출력) 2 실행 오류 """ import argparse import glob import importlib.util import json import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) GATE = os.path.join(HERE, "..", "pipeline", "cve-gate.py") def load_gate(): """cve-gate.py 를 모듈로 읽어 심각도 규칙을 재사용한다 — 규칙의 단일 출처를 지킨다.""" spec = importlib.util.spec_from_file_location("cve_gate", GATE) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod def version_tuple(v): """'0.56.0' → (0,56,0). 비교용. 숫자가 아닌 꼬리는 버린다.""" nums = re.findall(r"\d+", v or "") return tuple(int(n) for n in nums) or (0,) def pick_fixed(raw): """FixedVersion 문자열에서 후보를 뽑아 최대값을 고른다. Go 모듈은 보통 단일 값이지만 쉼표로 여러 브랜치를 나열하는 경우가 있다 (stdlib 의 '1.24.13, 1.25.7' 형태). 모듈에 대해서는 가장 높은 값을 쓴다. """ cands = [c.strip() for c in re.split(r"[,/]", raw or "") if c.strip()] cands = [c for c in cands if re.match(r"^v?\d", c)] if not cands: return None return max(cands, key=version_tuple).lstrip("v") def main(): ap = argparse.ArgumentParser() ap.add_argument("--reports", required=True, help="trivy 리포트(JSON) 디렉토리") ap.add_argument("--image", default="", help="리포트 파일명에 이 문자열이 든 것만 대상") ap.add_argument("--min-severity", default="HIGH", choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"], help="이 등급 이상만 제안 (기본 HIGH — 게이트 차단 기준과 동일)") args = ap.parse_args() gate = load_gate() rank = gate.RANK floor = rank[args.min_severity] paths = sorted(glob.glob(os.path.join(args.reports, "*.json"))) if args.image: paths = [p for p in paths if args.image in os.path.basename(p)] if not paths: print(f"::error::리포트를 찾지 못했다: {args.reports} (--image={args.image or '전체'})", file=sys.stderr) return 2 # module -> {"fixed": 최대 FixedVersion, "cves": {...}, "installed": {...}} mods = {} for p in paths: with open(p) as f: doc = json.load(f) for res in doc.get("Results") or []: # Go 바이너리에 정적 링크된 모듈만 대상이다. OS 패키지는 베이스 교체 영역이라 # 여기서 다루지 않는다. if res.get("Class") != "lang-pkgs" or res.get("Type") != "gobinary": continue for v in res.get("Vulnerabilities") or []: pkg = v.get("PkgName") or "" if pkg == "stdlib": # stdlib 은 모듈 업그레이드가 아니라 GO_BUILDER_TAG 로 해소한다. continue vend = v.get("Severity") or "UNKNOWN" nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")) eff = vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd if rank.get(eff, 0) < floor: continue fx = pick_fixed(v.get("FixedVersion")) if not fx: continue e = mods.setdefault(pkg, {"fixed": fx, "cves": {}, "installed": set()}) if version_tuple(fx) > version_tuple(e["fixed"]): e["fixed"] = fx e["cves"][v["VulnerabilityID"]] = eff if v.get("InstalledVersion"): e["installed"].add(v["InstalledVersion"]) # stdlib 은 별도 안내한다 — 모듈 목록이 아니라 툴체인으로 푼다. # # FixedVersion 의 여러 값은 "더 높은 버전"이 아니라 **브랜치별 대안**이다 # ('1.25.13, 1.26.6, 1.27.0-rc.3' = 세 브랜치 각각에서 고쳐진 지점). Go 는 보안 수정을 # 지원 브랜치에 백포트하므로, 어떤 툴체인 T 는 다음이면 그 CVE 를 해소한다: # T 와 같은 마이너의 대안이 있고 T 의 패치가 그 이상이거나, # 대안의 마이너가 T 보다 낮다(이미 이전 브랜치에서 고쳐져 T 에 포함됨). # 그래서 단순히 전체 최대값을 고르면 안 된다 — 프리릴리스(1.27.0-rc.3)를 정식 1.27.0 으로 # 오독하게 된다(실측 버그). std_alts = {} # cve -> [(major, minor, patch, is_pre), ...] for p in paths: with open(p) as f: doc = json.load(f) for res in doc.get("Results") or []: if res.get("Type") != "gobinary": continue for v in res.get("Vulnerabilities") or []: if v.get("PkgName") != "stdlib": continue vend = v.get("Severity") or "UNKNOWN" nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")) eff = vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd if rank.get(eff, 0) < floor: continue alts = [] for tok in re.split(r"[,\s]+", v.get("FixedVersion") or ""): m = re.match(r"^(\d+)\.(\d+)\.(\d+)(\S*)$", tok.strip()) if m: a, b, c, tail = m.groups() alts.append((int(a), int(b), int(c), bool(tail))) if alts: std_alts[v["VulnerabilityID"]] = alts if std_alts: # 정식 릴리스 대안만으로 후보 마이너를 만든다(프리릴리스는 툴체인 태그로 못 쓴다). cand = sorted({(a, b) for alts in std_alts.values() for a, b, _, pre in alts if not pre}) rows = [] for mm in cand: need = 0 covered = True for cve, alts in std_alts.items(): same = [c for a, b, c, pre in alts if (a, b) == mm and not pre] if same: need = max(need, max(same)) elif not any((a, b) < mm for a, b, _, _ in alts): covered = False # 이 마이너보다 높은 브랜치에서만 고쳐졌다 break if covered: rows.append((mm, need)) print("# stdlib 차단 CVE 가 있다 — GO_MODULE_UPGRADES 가 아니라 툴체인으로 해소한다.") print(f"# 대상 CVE {len(std_alts)}건. 아래 중 어느 것을 써도 전부 해소된다:") for (a, b), c in rows: print(f"# go{a}.{b}.{c}") if rows: (a, b), c = rows[-1] print(f"# GO_BUILDER_TAG={a}.{b}.{c}-trixie # 가장 높은 정식 브랜치 기준") else: print("# ::warning:: 정식 릴리스 대안이 없다 — 프리릴리스 툴체인이 필요할 수 있다") print() if not mods: print("# Go 모듈 업그레이드 제안 없음 (해당 등급 이상 findings 이 없다)") return 0 print(f"# {args.min_severity} 이상 Go 모듈 CVE 로부터 산출 — build.env 의 GO_MODULE_UPGRADES 에 반영한다.") for name in sorted(mods): e = mods[name] inst = ", ".join(sorted(e["installed"])) or "?" cves = ", ".join(sorted(e["cves"])) print(f"# {name} {inst} → {e['fixed']}") print(f"# {cves}") specs = " ".join(f"{n}@v{mods[n]['fixed']}" for n in sorted(mods)) print(f'GO_MODULE_UPGRADES="{specs}"') print() print("# 주의 — 이 값은 CVE 요건의 최소치다. 모듈 간 제약으로 더 올려야 할 수 있다") print("# (실측: go-git 5.19.2 와 x/net 0.56.0 이 x/crypto 0.53.0 을 요구해 0.52.0 으로는 빌드 실패).") print("# 빌드가 'requires ...@vX, not ...@vY' 로 실패하면 그 버전으로 올린다.") return 0 if __name__ == "__main__": sys.exit(main())