33e91c0536
업스트림 quay.io/argoproj/argocd 의 차단 CVE 는 대부분 OS 패키지가 아니라 바이너리에
정적 링크된 Go 모듈이라, 상위 태그 교체(이미 최신)로도 베이스 OS 교체로도 잡히지 않는다.
부분 조치는 효과가 없었다 — 같은 stdlib·x/crypto 취약점이 다섯 바이너리(argocd·helm·
kustomize·git-lfs·pebble)에 각각 들어있어 본체만 고치면 거의 줄지 않는다. 업스트림
Dockerfile 이 helm/kustomize/git-lfs 를 hack/install.sh 로 **미리 빌드된 릴리스 바이너리**
로 내려받기 때문에 그 바이너리의 Go 버전을 우리가 통제할 수도 없다. 그래서 넷 다 소스에서
다시 컴파일한다.
- images/argocd/ 신규 (Go 4개 + C 2개 + Node UI — 기존 자체 빌드 중 가장 크다)
- 최종 베이스 ubuntu → SUSE BCI(원칙 2). ubuntu 가 딸려오던 pebble 도 함께 사라진다
- tini·connect-proxy 는 SLE_BCI 에 없어 소스 빌드 — 기능을 빼지 않기 위해
- UI 는 업스트림과 동일하게 node 로 빌드(Go embed 라 생략 불가)
- manifests/helm/argo-cd/10.4.0/custom-values.yaml 이 이 이미지를 가리킨다
global.image 하나로 argocd 바이너리를 쓰는 5개 컴포넌트가 공유한다
재사용 설계 — Dockerfile 에 버전을 박지 않는다
---------------------------------------------
이 이미지는 앞으로도 CVE 조치를 반복해서 받는다. 모듈 목록·패키지 목록을 전부 값으로 빼서
다음 조치에서 바뀌는 것이 source.build.env 한 곳뿐이게 했다. 이전 방식이라면 모듈 하나
추가에 build.env·ARG 선언·go get 목록·BUILD_ARGS 네 곳을 고쳐야 했다.
- scripts/build/suggest-go-upgrades.py 신규 — 스캔 리포트의 FixedVersion 에서
GO_MODULE_UPGRADES / GO_BUILDER_TAG 를 산출한다. 사람이 CVE 를 훑어 최대값을 고르지
않는다. 빌드 시점에 최신을 당기는 방식(go get -u)은 재현성을 버리므로 택하지 않았다 —
값은 제안만 하고 채택은 사람이 커밋한다.
- images/argocd/go-mod-upgrade.sh 신규 — 목록 하나를 네 프로젝트에 재사용한다.
go get 은 의존성에 없는 모듈도 go.mod 에 추가하므로 그래프에 있는 것만 골라 적용한다.
실측 함정 — verify.sh 에 반영했다
--------------------------------
차트의 repo-server init 컨테이너가 `cp --update=none` 을 쓴다. 이 형식은 GNU coreutils
9.3+ 에서만 되는데, BCI 15.7 로 빌드한 이미지가 게이트도 verify.sh 도 통과하고 **배포
시점에** Init:CrashLoopBackOff 로 죽었다. BCI 16.0(coreutils 9.6)으로 올려 해소했고,
verify.sh 가 이 명령과 copyutil 흐름을 직접 재현하도록 해서 다음엔 빌드 단계에서 걸린다.
16.0 의 패키지 가용성과 trivy 커버리지(CoverageProbe=ok, EOSL 아님)도 확인했다.
검증
----
게이트 PASS — 실효 CRITICAL/HIGH 0건, CoverageProbe ok
기능 VERIFY-OK (심볼릭 링크 9개 · 번들 도구 3종 · LFS 필터 · copyutil 흐름)
배포 docker.io/paasup 에 push 후 운영 argocd 교체 — 파드 8종 Running,
admin 로그인, Application 2건 Synced/Healthy, repo-server 렌더링 확인
(서버가 go1.26.6 · helm v4.2.4 로 보고 — 우리 빌드가 맞다)
카탈로그 차단은 574 → 239 건이 됐고(7.7.0 삭제 · dex 비활성 · 이 커밋), 남은 239 건은
전부 동결된 7.8.11 몫이다. 운영에 쓰는 10.4.0 은 0 건이다. 경과·미결은 MEMORY.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
205 lines
9.5 KiB
Python
Executable File
205 lines
9.5 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 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())
|