차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙 (#40)

* 차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙

두 가지다. (1) 이전 이미지에서 CVE 가 발견되면 재빌드가 자동으로 일어나게 한다.
(2) Dockerfile 을 OS·언어 축으로 일관되게 쓰도록 규칙을 정한다.

(1) 재빌드 자동화 — 트리거를 로컬 실측으로 고쳤다
------------------------------------------------
처음에는 트리거를 "핀이 뒤처졌는가" 로 잡았다. **배포 중인 이미지 8개를 실제로 스캔해 보니
그게 틀렸다** — 차단 CVE 가 있는 4개 중 핀 변경이 필요한 것은 하나도 없었고, 그 판정은
"0건" 을 냈다. 재빌드로 고쳐지는 경우가 셋이기 때문이다.

  핀이 뒤처졌다                          → 핀 올려서 빌드
  핀은 맞는데 그 핀으로 아직 안 빌드됐다  → 그냥 빌드   ← 핀만 고친 PR 직후 상태
  베이스 OS 패키지가 뒤처졌다             → 그냥 빌드   ← 재빌드하면 zypper 가 최신을 깐다

그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 하는
부수 작업이고 트리거가 아니다. 수정 버전 없는 차단(affected·will_not_fix)은 재빌드해도
그대로이므로 no-fix 로 따로 보고해 사람이 다른 레버를 판단하게 한다.

scripts/build/check-rebuild-needed.py 신규
  카탈로그가 실제로 가리키는 이미지를 기준으로 잰다 — 재빌드해 보지 않고도 "지금 배포 중인
  것이 규정을 벗어났는가" 를 답한다.
    catalog.env → 카탈로그 values 의 ref → 그 ref 의 스캔 리포트 ↕ build.env 의 핀
  판정 규칙은 게이트와 같은 출처를 쓴다(max(벤더,NVD)·승인 예외·만료 예외 무효) —
  cve-gate.py 의 함수를 그대로 가져다 쓴다. 핀 산출은 suggest-go-upgrades.py 재사용.
  --list-refs 로 "무엇을 스캔해야 하나" 도 스크립트가 낸다 — ref 해석 규칙이 워크플로로
  새면 두 곳이 어긋난다.

suggest-go-upgrades.py 는 산출 로직이 main() 안에 있어 재사용할 수 없었다. collect_modules/
collect_stdlib/builder_candidates/effective_severity 로 빼고 main() 은 호출해 출력만 한다 —
CLI 출력은 리팩터 전후 동일하다(실측 대조).

build-image.yml 에 schedule(월요일 02:00 UTC)과 mode=drift 를 추가했다. 판정 → (핀이
뒤처졌으면) 브랜치 push → 빌드·verify.sh·게이트까지 자동이고 **레지스트리 push 와 카탈로그
반영은 하지 않는다.** 게시 여부 로직에서 catch-all 을 true → false 로 바꿨다 — 원래는
트리거를 추가하면 자동으로 push 가 켜지는 구조였고 schedule 을 넣는 순간 사고가 났다.
이제 push 는 workflow_dispatch + mode=image 에서만 켜진다.

(2) Dockerfile 작성 규칙 — 실제 8개에서 추출했다
----------------------------------------------
image-authoring.md 가 규칙 본문의 단일 출처이고 skill 은 "어느 절을 읽어야 하는가" 선택표만
갖는다(CLAUDE.md 의 "Skill 은 절차 본문을 복제하지 않는다").

두 축은 독립이다 — 같은 bci-micro 최종 위에 Go 빌더도 Node 빌더도 온다.

  축 1 최종 베이스   bci-base / bci-micro / scratch+micro rootfs — 셋뿐이다.
                     네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다.
                     micro·scratch 는 nonroot 계정을 직접 만들어야 한다(실측 형태 첨부).
  축 2 빌더          Go(BUILDPLATFORM/TARGETARCH, ldflags 에 SOURCE_COMMIT) /
                     Node(런타임은 OS 패키지) / JVM(재컴파일 없이 jar 만 OLD/NEW 쌍으로
                     교체 — OLD 를 받는 이유는 못 찾으면 실패시키기 위함) /
                     C·Lua(정적 링크 금지 — SLE_BCI 에 static glibc 없음)

"업스트림 런타임 계약은 보존한다" 절을 새로 넣었다(USER·ENTRYPOINT·파일 레이아웃).
cloudnative-pg 가 멀티아치 심볼릭 링크를 부수 장치로 오판해 지웠다가 리컨실이 실패한 것이
근거다. 스캐너는 이걸 전혀 보지 못한다.

로컬 검증 — 판정만이 아니라 빌드까지 돌렸다
-----------------------------------------
판정이 지목한 4건을 실제로 빌드해 전제를 확인했다. 문서에 단정만 해두고 넘어가지 않았다.

  이미지                     배포 중   재빌드 후                      핀 변경
  apisix-ingress-controller  차단 8    0/0 PASS · VERIFY-OK · cov=ok  없음
  cloudnative-pg             차단 8    0/0 PASS · VERIFY-OK · cov=ok  없음
  etcd                       차단 8    0/0 PASS · VERIFY-OK · cov=ok  없음
  cnpg-postgresql            차단 2    0/0 PASS · VERIFY-OK · cov=ok  없음

  - cnpg-postgresql 이 "베이스 OS 패키지 갱신으로 해소" 사례다(perl·rpm-ndb). 핀을 하나도
    안 바꾸고 재빌드만으로 0건이 됐다 — 검증 없이 단정했던 부분이라 이게 핵심 확인이다.
  - 빌드된 etcd 바이너리가 go1.26.6 + pinned commit 을 보고한다 — 핀이 실제로 반영됐다.
  - 4건 다 CoverageProbe=ok 이므로 0건이 "측정되지 않음" 이 아니다.
  - 빌드 후에도 git status images/ 가 깨끗하다 = 트리거를 핀 기준으로 뒀다면 4건 전부
    놓쳤을 것이라는 확인.

그 외: 예외 적용(keycloak CVE-2025-59250 → 0건), --fail-on-drift rc=1, 워크플로 셸 재현
(GITHUB_OUTPUT 4개 JSON·fromJson OK·핀 변경 0 → 브랜치 미생성 → 기본 ref 로 빌드),
push 정책 전 조합 확인.

CI 에서 미검증인 것은 환경 특성뿐이다 — trivy 설치 스텝, GITHUB_STEP_SUMMARY 출력,
matrix 팬아웃, contents:write 로 브랜치 push. 빌드 자체는 로컬과 같은 스크립트를 쓴다.

Refs #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* self-build-image SKILL 의 자동 재빌드 절을 실행 계약만 남긴다

같은 커밋에서 image-authoring.md 와 SKILL 양쪽에 자동 재빌드 절차를 거의 동일하게 썼다.
SKILL 이 "작성 규칙 본문은 image-authoring.md 가 단일 출처다 — 여기 복제하지 않는다" 고
두 번 선언한 파일에서 내가 그 규칙을 어겼다.

SKILL 에는 실행 계약(수동 실행 명령 · 로컬 판정 명령 · push 안 한다는 사실)만 남기고
트리거 조건과 그 근거는 image-authoring.md 를 가리킨다.

frontmatter 도 함께 고친다 — "7종" 에 argocd 가 빠져 있어 실제 images/ 8개와 어긋났다.
description 은 Skill 로딩 판단에 쓰이는 텍스트이므로 "목록은 images/ 가 단일 출처" 라는
회피 조항으로 덮을 수 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* 레포 분리 시 갈라지는 이음선을 코드와 문서에 명시한다

커스텀 이미지가 별도 레포로 분리될 예정인데, check-rebuild-needed.py 는 카탈로그(무엇을
배포 중인가)와 build.env(어떻게 만드는가)를 **양쪽 다** 읽는다. 분리 시 이 파일이 갈라진다.

절단면은 이미 함수 경계에 있다. 6개월 뒤에 "이 함수가 왜 카탈로그를 읽지" 를 다시 파지
않도록 어느 함수가 어느 쪽인지 docstring 에 적었다.

  A (카탈로그 레포)  resolve_current_ref + blocking_cves  → "차단됐는가"
  B (이미지 레포)    pin_changes + apply_changes          → "핀을 올려야 하는가"
  check_image        둘을 엮는 오케스트레이션 — 여기가 갈라진다

image-authoring.md 의 드리프트 절에도 같은 사실을 한 문단으로 적어 분리 작업 때 이 주석을
먼저 읽게 했다. 결합점 전체 목록은 경계 문서화 작업에서 표로 정리한다.

코드 동작은 바뀌지 않았다 — 주석과 문서만 추가했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-20 16:19:26 +09:00
committed by GitHub
parent bd8615f82f
commit 981d36daa4
5 changed files with 916 additions and 91 deletions
+448
View File
@@ -0,0 +1,448 @@
#!/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/<image>/catalog.env → 카탈로그 values 의 현재 ref
→ 그 ref 의 스캔 리포트
<variant>.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 = "<br>".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"<details><summary>판정하지 않은 이미지 {len(skipped)}개</summary>", ""]
out += [f"- `{r['image']}` — {'; '.join(r['notes'])}" for r in skipped]
out += ["", "</details>"]
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/<image>)")
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="`이미지<TAB>ref<TAB>리포트파일명` 만 출력하고 종료. 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())
+103 -77
View File
@@ -71,6 +71,106 @@ def pick_fixed(raw):
return max(cands, key=version_tuple).lstrip("v")
def effective_severity(v, gate, rank):
"""게이트와 같은 실효 등급 — `max(벤더 등급, NVD 등급)`."""
vend = v.get("Severity") or "UNKNOWN"
nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score"))
return vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd
def collect_modules(paths, gate, rank, floor):
"""정적 링크된 Go 모듈 중 floor 이상 등급의 차단 CVE 를 가진 것들.
반환: `{모듈명: {"fixed": 목표버전, "cves": {cve: 실효등급}, "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
eff = effective_severity(v, gate, rank)
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"])
return mods
def collect_stdlib(paths, gate, rank, floor):
"""stdlib 차단 CVE 별 `FixedVersion` 대안 목록.
반환: `{cve: [(major, minor, patch, is_prerelease), ...]}`
"""
std_alts = {}
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
if rank.get(effective_severity(v, gate, rank), 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
return std_alts
def builder_candidates(std_alts):
"""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 으로
오독하게 된다(실측 버그).
반환: `[((major, minor), 필요_패치), ...]` — 마이너 오름차순
"""
# 정식 릴리스 대안만으로 후보 마이너를 만든다(프리릴리스는 툴체인 태그로 못 쓴다).
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))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reports", required=True, help="trivy 리포트(JSON) 디렉토리")
@@ -90,85 +190,11 @@ def main():
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
mods = collect_modules(paths, gate, rank, floor)
std_alts = collect_stdlib(paths, gate, rank, floor)
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))
rows = builder_candidates(std_alts)
print("# stdlib 차단 CVE 가 있다 — GO_MODULE_UPGRADES 가 아니라 툴체인으로 해소한다.")
print(f"# 대상 CVE {len(std_alts)}건. 아래 중 어느 것을 써도 전부 해소된다:")
for (a, b), c in rows: