#!/usr/bin/env python3 """check-rebuild-needed.py — 배포 중인 자체 빌드 이미지가 재빌드로 고쳐지는지 판정한다. 왜 필요한가 ----------- 자체 빌드 이미지는 **소스를 안 바꿔도 시간이 지나면 게이트를 벗어난다.** 새 CVE 가 공개되고, 베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 push 해 둔 이미지는 그대로다. 2026-08-19 에 etcd·cloudnative-pg 가 이 상태로 차단됐는데 **문서 전용 PR 이 우연히 `images/**` 를 건드려 검증 빌드가 돌면서야** 발견됐다. 의도적으로 보는 장치가 없었다. `build-image.yml` 이 주간으로 이것을 돌려 재빌드까지 한다. 무엇이 "재빌드로 고쳐지는가" 인가 — 세 경우 모두 그렇다 ----------------------------------------------------- 처음에는 "핀이 뒤처진 경우" 만 트리거로 잡았는데 **실측에서 그게 틀렸다.** 배포 중인 이미지 8개를 스캔했더니 차단이 있는 5개 중 핀 변경이 필요한 것은 하나도 없었다. 1. 핀이 뒤처졌다 → 핀을 올리고 빌드 (GO_BUILDER_TAG 등) 2. 핀은 맞는데 이미지가 그 핀으로 안 빌드됐다 → 그냥 빌드 (핀만 고친 PR 이 머지되고 재빌드가 아직 안 된 상태) 3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 (재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다) 그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 적용하는 부수 작업이고, 트리거 조건이 아니다. 수정 버전이 없는 차단(`affected`·`will_not_fix`)은 재빌드해도 그대로이므로 트리거가 아니다 — `no-fix` 로 따로 보고해 사람이 다른 레버(태그 교체·예외 승인)를 판단하게 한다. 판정 규칙은 게이트와 같은 출처를 쓴다 ----------------------------------- 실효 등급 `max(벤더, NVD)` · 승인 예외(`doc/cve-exceptions.json`) 적용 — 전부 `scripts/pipeline/cve-gate.py` 의 함수를 그대로 가져다 쓴다. 규칙이 두 곳으로 갈리면 "게이트는 PASS 인데 재빌드하라고 한다" 가 나온다. 핀 산출은 `suggest-go-upgrades.py` 를 재사용한다(그것이 다시 게이트의 심각도 규칙을 쓴다). 기준은 카탈로그가 실제로 가리키는 이미지다 ---------------------------------------- images//catalog.env → 카탈로그 values 의 현재 ref → 그 ref 의 스캔 리포트 ↕ .build.env 의 핀 재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다. 레포 분리 시 이 파일은 둘로 갈린다 -------------------------------- 커스텀 이미지가 별도 레포로 분리될 예정이고(`.claude/image-authoring.md` 참고), 그때 이 스크립트는 **카탈로그를 읽는 쪽과 build.env 를 읽는 쪽이 서로 다른 레포에 놓인다.** 절단면은 이미 함수 경계로 나뉘어 있으니 그대로 쓴다: A (카탈로그 레포) resolve_current_ref + blocking_cves → "이 이미지가 차단됐는가". 카탈로그 values 와 스캔 리포트만 쓴다. 카탈로그가 무엇을 배포하는지는 카탈로그가 안다. B (이미지 레포) pin_changes + apply_changes → "핀을 올려야 하는가". build.env 만 쓴다. 어떻게 만드는지는 이미지 레포가 안다. check_image 둘을 엮는 오케스트레이션 — 분리 시 여기가 갈라진다. A 가 탐지해 B 에 빌드를 요청하고, B 가 핀을 판단한다. 이 주석의 목적은 분리 작업을 **고고학이 아니라 기계적 작업**으로 만드는 것이다. 새 함수를 추가할 때 어느 쪽에 속하는지 함께 적는다. 사용 ---- python3 scripts/build/check-rebuild-needed.py --list-refs # 무엇을 스캔해야 하나 python3 scripts/build/check-rebuild-needed.py --reports <디렉토리> python3 scripts/build/check-rebuild-needed.py --reports ... --image etcd --apply `--apply` 는 핀 편집만 대신한다. 커밋·빌드·검증은 사람이 한다 — 핀은 "우리가 무엇을 검증했는지의 기록" 이므로 자동 커밋하지 않는다. 종료 코드 0 정상 (재빌드 대상이 있어도 0 — --fail-on-drift 를 주면 1) 1 재빌드 대상 있음 + --fail-on-drift 2 실행 오류 """ import argparse import importlib.util import json import os import pathlib import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) IMAGES_DIR = os.path.join(ROOT, "images") DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json") # 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고 # dip-values.yaml 은 같은 값을 쓰는 배포 파라미터화용이다. VALUES_CANDIDATES = ("custom-values.yaml", "dip-values.yaml") def load(path, name): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod SUGGEST = load(os.path.join(HERE, "suggest-go-upgrades.py"), "suggest_go_upgrades") PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag") GATE = SUGGEST.load_gate() def read_env(path): """`KEY=value` 셸 할당만 읽는다. 값의 따옴표는 벗긴다.""" out = {} if not os.path.isfile(path): return out for line in pathlib.Path(path).read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) if not re.match(r"^[A-Z_][A-Z0-9_]*$", k): continue out[k] = v.strip().strip('"').strip("'") return out def report_path(reports_dir, image_ref): """이미지 ref → 리포트 파일 경로. scan-sbom.sh 가 `tr '/:@' '___'` 로 만든다.""" return os.path.join(reports_dir, re.sub(r"[/:@]", "_", image_ref) + ".json") def numeric_prefix(v): """'1.26.5-trixie' → ((1,26,5), '-trixie'). 접미사는 그대로 유지해야 한다.""" m = re.match(r"^(\d+(?:\.\d+)*)(.*)$", v or "") if not m: return (0,), "" return tuple(int(x) for x in m.group(1).split(".")), m.group(2) def parse_module_specs(raw): """'mod@v1.2.3 mod2@v0.4.0' → {mod: (1,2,3)}""" out = {} for tok in (raw or "").split(): if "@" not in tok: continue name, ver = tok.rsplit("@", 1) out[name] = SUGGEST.version_tuple(ver) return out def resolve_current_ref(catalog_env): """카탈로그 values 에서 현재 이미지 ref 를 읽는다. (ref, 읽은 파일) 또는 (None, 이유).""" chart_dirs = (catalog_env.get("CHART_DIRS") or "").split() style = catalog_env.get("TAG_STYLE") or "" block = catalog_env.get("TAG_BLOCK") or "image" if not chart_dirs or style not in ("imageName", "split"): return None, "catalog.env 에 CHART_DIRS/TAG_STYLE 이 없다" for d in chart_dirs: for fname in VALUES_CANDIDATES: p = os.path.join(ROOT, d, fname) if not os.path.isfile(p): continue text = pathlib.Path(p).read_text() ref = (PATCH.read_image_name(text) if style == "imageName" else PATCH.read_split_block(text, block)) if ref: return ref, os.path.relpath(p, ROOT) return None, f"{chart_dirs} 에서 태그를 읽지 못했다 (style={style}, block={block})" def blocking_cves(report, image_ref, rank, floor, exceptions): """실효 등급 floor 이상 + 승인 예외 제외한 고유 CVE. 반환: {cve: {"pkg", "sev", "fixable"}} """ out = {} for res in report.get("Results") or []: for v in res.get("Vulnerabilities") or []: cve = v.get("VulnerabilityID") if not cve: continue eff = SUGGEST.effective_severity(v, GATE, rank) if rank.get(eff, 0) < floor: continue if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions): continue prev = out.get(cve) fixable = bool(v.get("FixedVersion")) if prev is None: out[cve] = {"pkg": v.get("PkgName"), "sev": eff, "fixable": fixable} elif fixable: prev["fixable"] = True return out def pin_changes(paths, pins, rank, floor): """핀을 올려야 하는 항목. 재빌드 트리거가 아니라 **부수 작업**이다.""" changes, notes = [], [] std_alts = SUGGEST.collect_stdlib(paths, GATE, rank, floor) if std_alts: rows = SUGGEST.builder_candidates(std_alts) cur = pins.get("GO_BUILDER_TAG", "") if not rows: notes.append(f"stdlib 차단 {len(std_alts)}건 — 정식 릴리스 대안이 없다" "(프리릴리스 툴체인이 필요할 수 있다)") elif not cur: notes.append(f"stdlib 차단 {len(std_alts)}건 — 이 이미지에 GO_BUILDER_TAG 가 없다") else: cur_nums, suffix = numeric_prefix(cur) # 현재와 같은 마이너의 후보가 있으면 그것을 쓴다(마이너 점프를 강요하지 않는다). same_minor = [r for r in rows if r[0] == cur_nums[:2]] (a, b), c = (same_minor or rows)[-1] if cur_nums[:3] < (a, b, c): changes.append({"key": "GO_BUILDER_TAG", "current": cur, "required": f"{a}.{b}.{c}{suffix}", "reason": f"stdlib 차단 {len(std_alts)}건", "appliable": True}) mods = SUGGEST.collect_modules(paths, GATE, rank, floor) if mods: cur_raw = pins.get("GO_MODULE_UPGRADES") cur_mods = parse_module_specs(cur_raw) behind = {n: e["fixed"] for n, e in mods.items() if cur_mods.get(n) is None or cur_mods[n] < SUGGEST.version_tuple(e["fixed"])} if behind: listing = " ".join(f"{n}@v{behind[n]}" for n in sorted(behind)) if cur_raw is None: # 이 이미지는 GO_MODULE_UPGRADES 를 소비하지 않는다(etcd 의 XTEXT_FIX_VERSION # 처럼 이미지별 ARG). 값을 넣어도 무효이므로 사람이 판단하게 한다. notes.append(f"모듈 {len(behind)}건 뒤처짐({listing}) — 이 이미지는 " "GO_MODULE_UPGRADES 를 쓰지 않는다. 이미지별 FIX_VERSION 수동 확인") else: merged = dict(cur_mods) merged.update({n: SUGGEST.version_tuple(v) for n, v in behind.items()}) spec = " ".join(f"{n}@v{'.'.join(map(str, merged[n]))}" for n in sorted(merged)) changes.append({"key": "GO_MODULE_UPGRADES", "current": cur_raw, "required": spec, "reason": f"모듈 {len(behind)}건 뒤처짐", "appliable": True}) return changes, notes def check_image(image, reports_dir, rank, floor, exceptions): idir = os.path.join(IMAGES_DIR, image) res = {"image": image, "status": "ok", "notes": [], "changes": [], "blocking": 0, "fixable": 0} catalog_env = read_env(os.path.join(idir, "catalog.env")) variant = catalog_env.get("DEFAULT_BASE_OS") or "" if not variant: res["status"] = "skip" res["notes"].append("catalog.env 에 DEFAULT_BASE_OS 가 없다") return res build_env_path = os.path.join(idir, f"{variant}.build.env") res["build_env"] = os.path.relpath(build_env_path, ROOT) pins = read_env(build_env_path) ref, where = resolve_current_ref(catalog_env) if not ref: res["status"] = "skip" res["notes"].append(where) return res res["ref"] = ref res["ref_from"] = where rp = report_path(reports_dir, ref) if not os.path.isfile(rp): res["status"] = "no-report" res["notes"].append("이 ref 의 스캔 리포트가 없다 — 스캔 대상에 없었거나 태그가 갱신됐다") return res with open(rp) as f: report = json.load(f) cves = blocking_cves(report, ref, rank, floor, exceptions) fixable = {c: d for c, d in cves.items() if d["fixable"]} res["blocking"] = len(cves) res["fixable"] = len(fixable) res["packages"] = sorted({d["pkg"] for d in cves.values() if d["pkg"]}) if not cves: return res # ok res["changes"], res["notes"] = pin_changes([rp], pins, rank, floor) if fixable: res["status"] = "rebuild" else: res["status"] = "no-fix" res["notes"].append("차단 CVE 에 수정 버전이 없다 — 재빌드로 해소되지 않는다. " "상위 태그 교체나 예외 승인을 검토한다") return res def apply_changes(build_env_path, changes): """build.env 의 KEY=value 줄만 제자리 치환한다(주석·순서 보존).""" p = pathlib.Path(build_env_path) text = p.read_text() applied = [] for ch in changes: if not ch.get("appliable"): continue key = ch["key"] pattern = re.compile(rf'^({re.escape(key)}=)(")?.*?(")?$', re.MULTILINE) if not pattern.search(text): continue quote = '"' if " " in ch["required"] else "" text = pattern.sub(lambda m: f'{m.group(1)}{quote}{ch["required"]}{quote}', text, count=1) applied.append(key) if applied: p.write_text(text) return applied def render_md(results): rebuild = [r for r in results if r["status"] == "rebuild"] nofix = [r for r in results if r["status"] == "no-fix"] ok = [r for r in results if r["status"] == "ok"] out = ["## 🔁 자체 빌드 이미지 재빌드 판정", ""] if not rebuild and not nofix: out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.") if rebuild: out += [f"**{len(rebuild)}개 이미지가 재빌드로 고쳐진다.** 차단 CVE 에 수정 버전이 있다 " "— 재빌드하면 베이스 패키지·툴체인이 최신으로 반영된다.", "", "| 이미지 | 차단 | 수정 가능 | 패키지 | 함께 올릴 핀 |", "|---|---:|---:|---|---|"] for r in rebuild: pins = "
".join( f"`{c['key']}` `{c['current']}` → **`{c['required']}`**" for c in r["changes"] ) or "없음 (핀은 이미 맞다 — 빌드만 안 됐다)" pkgs = ", ".join(f"`{p}`" for p in r["packages"][:4]) out.append(f"| `{r['image']}` | {r['blocking']} | {r['fixable']} | {pkgs} | {pins} |") if nofix: out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.", "", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"] for r in nofix: out.append(f"| `{r['image']}` | {r['blocking']} | " f"{', '.join(f'`{p}`' for p in r['packages'][:4])} |") notes = [(r["image"], n) for r in results for n in r["notes"] if r["status"] in ("rebuild", "no-fix")] if notes: out += ["", "**수동 확인 필요**", ""] out += [f"- `{img}` — {n}" for img, n in notes] skipped = [r for r in results if r["status"] in ("skip", "no-report")] if skipped: out += ["", f"
판정하지 않은 이미지 {len(skipped)}개", ""] out += [f"- `{r['image']}` — {'; '.join(r['notes'])}" for r in skipped] out += ["", "
"] out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · " "기본 HIGH 이상. 핀 제안은 **제안**이고 채택은 사람이 커밋한다.", ""] return "\n".join(out) def main(): ap = argparse.ArgumentParser(description="배포 중인 자체 빌드 이미지의 재빌드 필요 여부 판정") ap.add_argument("--reports", help="trivy 리포트(JSON) 디렉토리 (--list-refs 면 불필요)") ap.add_argument("--image", default="", help="이 이미지만 판정 (images/)") ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS, help="승인 예외 목록 (기본 doc/cve-exceptions.json)") ap.add_argument("--min-severity", default="HIGH", choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"], help="이 등급 이상만 차단으로 본다 (기본 HIGH — 게이트와 동일)") ap.add_argument("--list-refs", action="store_true", help="`이미지ref리포트파일명` 만 출력하고 종료. CI 가 무엇을 " "스캔해야 하는지 알아야 할 때 쓴다 — ref 해석 규칙이 워크플로로 " "새지 않게 한다") ap.add_argument("--summary-md", metavar="FILE", help="마크다운 요약을 이 파일에 쓴다") ap.add_argument("--json-out", metavar="FILE", help="결과 JSON 을 이 파일에 쓴다") ap.add_argument("--apply", action="store_true", help="핀 제안을 build.env 에 제자리 반영한다 (커밋·빌드는 사람이 한다)") ap.add_argument("--fail-on-drift", action="store_true", help="재빌드 대상이 있으면 1 로 종료 (기본은 보고만 하고 0)") args = ap.parse_args() if not os.path.isdir(IMAGES_DIR): print(f"::error::images/ 를 찾지 못했다: {IMAGES_DIR}", file=sys.stderr) return 2 if not args.list_refs and not args.reports: print("::error::--reports 가 필요하다", file=sys.stderr) return 2 if args.reports and not os.path.isdir(args.reports): print(f"::error::리포트 디렉토리가 없다: {args.reports}", file=sys.stderr) return 2 names = sorted(d for d in os.listdir(IMAGES_DIR) if os.path.isdir(os.path.join(IMAGES_DIR, d))) if args.image: names = [n for n in names if n == args.image] if not names: print(f"::error::images/{args.image} 가 없다", file=sys.stderr) return 2 if args.list_refs: for n in names: ref, why = resolve_current_ref(read_env(os.path.join(IMAGES_DIR, n, "catalog.env"))) if not ref: print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr) continue print(f"{n}\t{ref}\t{os.path.basename(report_path('', ref))}") return 0 rank = GATE.RANK floor = rank[args.min_severity] # load_exceptions 는 (유효, 만료) 를 낸다. 만료된 예외는 게이트가 인정하지 않으므로 # 여기서도 인정하지 않는다 — 재검토를 강제하는 것이 그 설계의 의도다. exceptions, expired = GATE.load_exceptions(args.exceptions) if expired: print(f"::warning::만료된 예외 {len(expired)}건은 인정하지 않는다 — " f"{', '.join(e.get('id', '?') for e in expired)}") results = [check_image(n, args.reports, rank, floor, exceptions) for n in names] for r in results: if r["status"] == "rebuild": pins = "; ".join(f"{c['key']} {c['current']} → {c['required']}" for c in r["changes"]) print(f"::warning::{r['image']}: 재빌드 필요 — 차단 {r['blocking']}건" f"(수정 가능 {r['fixable']})" + (f" / {pins}" if pins else "")) elif r["status"] == "no-fix": print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — " "재빌드로 해소되지 않는다") for n in r["notes"]: if r["status"] in ("rebuild", "no-fix"): print(f"::warning::{r['image']}: {n}") md = render_md(results) print() print(md) if args.summary_md: pathlib.Path(args.summary_md).write_text(md + "\n") if args.json_out: pathlib.Path(args.json_out).write_text( json.dumps(results, ensure_ascii=False, indent=2) + "\n") if args.apply: for r in results: if r["changes"]: applied = apply_changes(os.path.join(ROOT, r["build_env"]), r["changes"]) if applied: print(f"반영: {r['build_env']} — {', '.join(applied)}") return 1 if ([r for r in results if r["status"] == "rebuild"] and args.fail_on_drift) else 0 if __name__ == "__main__": sys.exit(main())