981d36daa4
* 차단 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>
231 lines
10 KiB
Python
Executable File
231 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
suggest-go-upgrades.py — 스캔 리포트에서 GO_MODULE_UPGRADES 값을 산출한다.
|
|
|
|
왜 필요한가
|
|
-----------
|
|
자체 빌드 이미지가 Go 모듈 CVE 를 해소할 때 `images/<image>/<variant>.build.env` 의
|
|
`GO_MODULE_UPGRADES` 에 `<module>@<version>` 을 적는다. 그 버전을 사람이 게이트 리포트에서
|
|
하나씩 찾아 최대값을 고르는 것은 지루하고 틀리기 쉽다 — 필요한 정보는 이미 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 <trivy-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 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) 디렉토리")
|
|
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
|
|
|
|
mods = collect_modules(paths, gate, rank, floor)
|
|
std_alts = collect_stdlib(paths, gate, rank, floor)
|
|
|
|
if std_alts:
|
|
rows = builder_candidates(std_alts)
|
|
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())
|