From 33e91c0536c55efb02a8e0bd9be0489b493ea671 Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Wed, 19 Aug 2026 15:04:05 +0900 Subject: [PATCH] =?UTF-8?q?argocd:=20=EC=9E=90=EC=B2=B4=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=ED=95=98=EB=93=9C=EB=8B=9D=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94=EA=B0=80,=20=EA=B2=8C=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=20=EC=B0=A8=EB=8B=A8=200=EA=B1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 업스트림 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) --- .claude/image-authoring.md | 35 +++ MEMORY.md | 72 ++++- images/argocd/README.md | 104 +++++++ images/argocd/catalog.env | 18 ++ images/argocd/go-mod-upgrade.sh | 51 ++++ images/argocd/source.Dockerfile | 263 ++++++++++++++++++ images/argocd/source.build.env | 77 +++++ images/argocd/verify.sh | 128 +++++++++ .../helm/argo-cd/10.4.0/custom-values.yaml | 14 + scripts/build/suggest-go-upgrades.py | 204 ++++++++++++++ 10 files changed, 965 insertions(+), 1 deletion(-) create mode 100644 images/argocd/README.md create mode 100644 images/argocd/catalog.env create mode 100755 images/argocd/go-mod-upgrade.sh create mode 100644 images/argocd/source.Dockerfile create mode 100644 images/argocd/source.build.env create mode 100755 images/argocd/verify.sh create mode 100755 scripts/build/suggest-go-upgrades.py diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md index 79bb3e4..75fb075 100644 --- a/.claude/image-authoring.md +++ b/.claude/image-authoring.md @@ -129,6 +129,41 @@ COPY --from=builder /rootfs/ / 업스트림 Dockerfile 의 패키지 목록을 그대로 옮기면 `No provider of '...' found` 로 빌드가 실패한다. `zypper -n search -t package '<패턴>'` 로 먼저 확인한다. +## Go 모듈 CVE — 업그레이드 버전을 손으로 찾지 않는다 + +Go 바이너리에 정적 링크된 모듈의 차단 CVE 는 `build.env` 의 `GO_MODULE_UPGRADES` 에 +`@` 을 적어 해소한다. 그 버전은 게이트 리포트의 `FixedVersion` 에 이미 +들어 있으므로 스크립트가 뽑는다 — 사람이 CVE 를 하나씩 훑어 최대값을 고르지 않는다. + +```sh +python3 scripts/build/suggest-go-upgrades.py --reports [--image <필터>] +``` + +붙여넣을 수 있는 `GO_MODULE_UPGRADES="..."` 한 줄과, 모듈별 근거(설치된 버전 → 목표 버전, +관련 CVE 목록)를 주석으로 낸다. `stdlib` 은 모듈이 아니라 툴체인 문제이므로 따로 +`GO_BUILDER_TAG` 후보를 계산해 알려준다. + +**빌드 시점에 최신을 당기지 않는 이유** — `go get -u` 로 매번 최신을 끌면 같은 소스로 +빌드해도 이미지가 달라진다. 이 문서가 "롤링 태그를 쓰지 않는다" 고 정한 것과 같은 이유다. +버전 핀은 **우리가 무엇을 검증했는지의 기록**이고, `git diff` 에 무엇이 왜 올라갔는지 +남는다. 스크립트는 값을 제안만 하고 채택은 사람이 커밋한다. + +두 가지 실측 함정이 있다. + +- **`FixedVersion` 의 여러 값은 "더 높은 버전"이 아니라 브랜치별 대안이다.** + stdlib 의 `1.25.13, 1.26.6, 1.27.0-rc.3` 은 세 브랜치 각각에서 고쳐진 지점이다. + 전체 최대값을 고르면 프리릴리스를 정식 버전으로 오독한다(실제로 그 버그를 냈다). + 스크립트가 이 규칙을 처리한다. +- **제안값은 CVE 요건의 최소치다.** 모듈 간 제약으로 더 올려야 할 수 있다 — 실측: + `go-git 5.19.2` 와 `x/net 0.56.0` 이 `x/crypto 0.53.0` 을 요구해 제안값 `0.52.0` 으로는 + 빌드가 `requires golang.org/x/crypto@v0.53.0, not v0.52.0` 로 실패했다. 그 메시지가 + 가리키는 버전으로 올린다. + +한 이미지가 여러 Go 프로젝트를 빌드하면(예: `argocd` 는 argocd·helm·kustomize·git-lfs 를 +함께 빌드한다) **목록 하나를 전부에 재사용한다** — `images/argocd/go-mod-upgrade.sh` 처럼 +"그 프로젝트의 의존성 그래프에 있는 모듈만" 골라 적용하는 헬퍼를 두면 된다. `go get` 은 +의존성에 없는 모듈도 `go.mod` 에 추가해버리므로 그냥 넘기면 안 된다. + ## 두 가지 유형 (둘 다 같은 스크립트를 쓴다) | 유형 | Dockerfile 이 하는 일 | 셸 유무 | diff --git a/MEMORY.md b/MEMORY.md index f82ba0b..cc7f517 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -5,12 +5,82 @@ - 프로젝트 개요·설계 원칙 → [CLAUDE.md](CLAUDE.md) - SBOM·CVE 게이트 메커니즘 → [doc/sbom-pipeline.md](doc/sbom-pipeline.md) -최종 갱신: 2026-08-12 +최종 갱신: 2026-08-19 --- ## 다음 작업 +**argo-cd@10.4.0 이 게이트 차단 0건으로 완결됐다(2026-08-19).** ArgoCD 가 k8s 1.35 를 +지원하지 못해 시작한 업그레이드가 CVE 조치까지 이어진 건이다. + +**발단 — k8s 1.33 신규 필드로 ArgoCD 가 전멸했다.** `.status.terminatingReplicas`(k8s 1.33 +도입)를 ArgoCD v2.14.5(k8s 라이브러리 0.31/0.32)가 몰라서, `ServerSideApply=true` 인 +Application 이 전부 `ComparisonError: field not declared in schema` 로 죽었다. 차트 +7.8.11 → 10.4.0(ArgoCD v3.5.1, k8s 라이브러리 0.36.1)으로 해소했고 dev 클러스터 +(1.35.7+rke2r1)에서 발생·해소를 모두 실측했다. + +**CVE 조치 — 레버를 순서대로 적용해 574 → 239건.** + +| 조치 | 차단 | +|---|---:| +| 시작(7.7.0·7.8.11·10.4.0 전부) | 574 | +| `argo-cd/7.7.0` 삭제(구버전, EOSL 이미지 2종 포함) | 337 | +| `dex.enabled=false` — 쓰지도 않는데 떠 있었다 | 280 | +| `images/argocd/` 자체 빌드 | **239** | + +남은 239건은 전부 **동결된 7.8.11**(직전 버전) 몫이고, 운영에 쓰는 **10.4.0 은 0건**이다. +7.8.11 을 언제 지울지가 미결이다 — 지우면 게이트가 PASS 로 떨어진다. + +**dex 를 끈 것이 가장 값싼 레버였다.** ArgoCD 는 `oidc.config` 가 있으면 dex 를 우회하는데 +차트 기본값이 `enabled: true` 라 유휴 파드가 떠서 차단의 절반 이상을 만들고 있었다. +dipup 도 항상 `configs.cm.oidc.config` 로 Keycloak 에 직접 붙으므로 dex 를 쓰지 않는다. +**대응 레버를 보기 전에 "이 컴포넌트가 필요하긴 한가" 를 먼저 묻는다** 는 교훈이다. + +**`images/argocd/` 는 기존 자체 빌드 중 가장 크다** — Go 프로젝트 4개(argocd·helm· +kustomize·git-lfs) + C 2개(tini·connect-proxy) + Node UI. 업스트림이 번들 도구를 **미리 +빌드된 릴리스 바이너리로 내려받기** 때문에 그 바이너리의 Go 버전을 우리가 통제할 수 없고, +같은 CVE 가 여러 바이너리에 공유돼 **부분 조치가 무효**였다(본체만 고치면 거의 줄지 않았다). + +실측 함정 셋: + +- **`cp --update=none` 이 베이스 OS 를 갈랐다.** 차트의 repo-server init 컨테이너가 쓰는 + 이 형식은 GNU coreutils 9.3+ 에서만 된다. BCI 15.7 로 빌드한 이미지가 게이트도 + `verify.sh` 도 통과하고 **배포 시점에** `Init:CrashLoopBackOff` 로 죽었다. BCI 16.0 + (coreutils 9.6)으로 올려 해소했고, `verify.sh` 가 이 명령을 직접 실행해 다음엔 빌드 + 단계에서 걸리게 했다. **게이트 PASS 는 동작을 증명하지 않는다** 의 실제 사례다. +- **같은 날 재빌드는 태그가 겹친다.** 태그에 빌드일을 넣어도 같은 날 다시 빌드하면 태그가 + 같아, 노드가 캐시한 옛 digest 가 그대로 쓰인다(`imagePullPolicy: IfNotPresent`). + 이번엔 검증용으로 `Always` 를 넣어 우회했다 — 프레임워크 차원의 미결 사항이다. +- **모듈 업그레이드 제안값은 최소치다.** 모듈 간 제약으로 더 올려야 할 수 있다 + (`requires @vX, not @vY` 로 빌드가 실패한다). + +**Go 모듈 CVE 조치를 자동화했다** — `scripts/build/suggest-go-upgrades.py` 가 스캔 +리포트의 `FixedVersion` 에서 `GO_MODULE_UPGRADES` / `GO_BUILDER_TAG` 를 산출한다. 사람이 +CVE 를 훑어 최대값을 고르지 않는다. 다음 조치에서 바뀌는 것은 `build.env` 뿐이고 +Dockerfile 은 그대로다(모듈 목록·패키지 목록을 전부 값으로 뺐다). + +### 남은 것 (argo-cd) + +- `argo-cd/7.8.11` 삭제 여부 — 지우면 게이트 PASS +- 자체 빌드 이미지의 정식 반영 경로: CI `build-image.yml`(`workflow_dispatch`) 로 다시 태워 + 볼지, 로컬 push 로 끝낼지. 이번엔 로컬에서 `docker.io/paasup` 에 push 하고 운영 argocd 를 + 교체해 동작 확인까지 마쳤다. + +### 별건으로 발견한 airflow 문제 2건 + +- **매니페스트가 비결정적이다.** `webserver-secret-key-secret.yaml` 이 `randAlphaNum` 으로 + 매 렌더마다 새 값을 만들어, repo-server 의 매니페스트 캐시가 날아갈 때마다(ArgoCD 재시작· + hard refresh) webserver 가 롤링되고 웹 세션이 끊긴다. reconcile 만으로는 재렌더가 일어나지 + 않아 상시 루프는 아니다(4시간 19분 동안 롤아웃 0회로 확인). `webserverSecretKey` 또는 + `webserverSecretKeySecretName` 을 지정하면 해소된다. 카탈로그 기본값에 없어 모든 테넌트가 + 해당된다. +- **migrations job 이 5분마다 재실행된다.** `migrateDatabaseJob.useHelmHooks: false` 로 + hook 이 아닌 추적 리소스가 됐는데 `ttlSecondsAfterFinished: 300` 이 그대로라, TTL 삭제 → + ArgoCD selfHeal 재생성이 반복된다. `ttlSecondsAfterFinished` 를 끄거나 hook 으로 되돌린다. + +--- + **apisix@2.16.0 전체가 게이트 PASS 로 완결됐다(2026-08-12).** `images/adc/` 신규 추가로 마지막 남은 이미지까지 해소 — 차단 55건(2026-08-12 초 기준) → **0건**. adc· apisix-ingress-controller·apisix(paasup/apisix) 세 자체 빌드 이미지 전부 게이트 diff --git a/images/argocd/README.md b/images/argocd/README.md new file mode 100644 index 0000000..65caff0 --- /dev/null +++ b/images/argocd/README.md @@ -0,0 +1,104 @@ +# argocd (자체 빌드) + +`quay.io/argoproj/argocd` 를 대체하는 하드닝 이미지. 업스트림 소스를 pinned commit 으로 +직접 컴파일하고, 업스트림이 릴리스 바이너리로 내려받던 번들 도구(helm·kustomize·git-lfs)까지 +같은 Go 툴체인으로 다시 만든다. + +```sh +IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out +``` + +## 왜 자체 빌드인가 + +이 이미지의 차단 CVE 는 대부분 OS 패키지가 아니라 **바이너리에 정적 링크된 Go 모듈**이다. +그래서 앞선 두 레버가 모두 통하지 않는다. + +- **상위 태그 교체 불가** — 이미 최신 릴리스다. 번들 도구도 각각 최신이고, 낡은 Go 는 + 각 업스트림 프로젝트의 선택이라 버전을 올려도 바뀌지 않는다. +- **베이스 OS 교체로는 거의 안 잡힌다** — 모듈은 컴파일된 바이너리 안에 있다. + +## 왜 번들 도구까지 다시 만드는가 + +업스트림 Dockerfile 은 helm/kustomize/git-lfs 를 `hack/install.sh` 로 **미리 빌드된 릴리스 +바이너리를 내려받아** 넣는다. 그 바이너리의 Go 버전은 우리가 통제할 수 없다. + +그리고 **부분 조치는 효과가 없다.** 같은 `stdlib`·`x/crypto` 취약점이 다섯 바이너리에 각각 +들어있어서, 한 바이너리만 고치면 나머지에 그대로 남는다. 고유 CVE 는 소수이고 대부분이 +공유분이라 전부 다시 만들어야 게이트가 0 이 된다(실측 확인 — argocd 본체만 조치했을 때는 +거의 줄지 않았다). + +`pebble` 은 우리가 넣은 것이 아니라 ubuntu 베이스에 딸려온 것이다 — 베이스를 SUSE BCI 로 +바꾸면 함께 사라진다. + +## 다음 CVE 조치 — Dockerfile 은 건드리지 않는다 + +버전도 모듈 목록도 Dockerfile 에 박혀 있지 않다. 새 차단 CVE 가 나오면 **`source.build.env` +한 곳만** 바뀐다. + +```sh +# 1) 게이트 리포트에서 업그레이드 값을 산출한다 (손으로 찾지 않는다) +python3 scripts/build/suggest-go-upgrades.py --reports sbom-out/trivy-reports --image argocd + +# 2) 출력된 GO_MODULE_UPGRADES / GO_BUILDER_TAG 를 source.build.env 에 반영 + +# 3) 재빌드 — 게이트까지 한 번에 돈다 +IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out +``` + +| 새 CVE 유형 | 바꾸는 값 | +|---|---| +| Go `stdlib` | `GO_BUILDER_TAG` | +| Go 모듈 | `GO_MODULE_UPGRADES` 에 `@` 추가 | +| OS 패키지 | `RUNTIME_BASE` 또는 `RUNTIME_PACKAGES` | +| 구성요소 새 릴리스 | 해당 `*_VERSION` | + +`GO_MODULE_UPGRADES` 는 argocd·helm·kustomize·git-lfs **네 프로젝트에 공통 적용**된다. +[go-mod-upgrade.sh](go-mod-upgrade.sh) 가 각 프로젝트의 의존성 그래프에 있는 것만 골라 +적용하므로 목록 하나를 그대로 재사용한다 — `go get` 은 의존성에 없는 모듈도 `go.mod` 에 +추가해버리기 때문에 필요한 필터다. + +> 제안값은 CVE 요건의 **최소치**다. 모듈 간 제약으로 더 올려야 할 수 있다 — 빌드가 +> `requires @vX, not @vY` 로 실패하면 그 버전으로 올린다. + +## 업스트림과 다르게 한 부분 + +| 항목 | 업스트림 | 이 이미지 | 이유 | +|---|---|---|---| +| 최종 베이스 | `ubuntu` | `registry.suse.com/bci/bci-base` | 카탈로그는 SUSE BCI 하나만 쓴다(`.claude/image-authoring.md` 원칙 2). `pebble` 도 함께 소멸 | +| helm / kustomize / git-lfs | 릴리스 바이너리 다운로드 | 소스 컴파일 | 다운로드 바이너리의 Go 를 통제할 수 없다 | +| helm 버전 | 업스트림 pin | 최신 | kustomize·git-lfs 는 pin 이 이미 최신이라 그대로 | +| `tini` · `connect-proxy` | apt 패키지 | 소스 빌드 | SLE_BCI 에 둘 다 없다. 기능을 빼지 않기 위해 빌드 | +| Go 툴체인 | 업스트림 pin | `GO_BUILDER_TAG` | stdlib CVE 해소 | +| 취약 모듈 | 그대로 | `GO_MODULE_UPGRADES` 로 강제 업그레이드 | | +| `BUILD_DATE` | 빌드 시각 | 고정값 | 빌드 재현성 | +| UI | node 빌드 | **동일** | Go embed 로 바이너리에 들어가 생략 불가 | + +애플리케이션 코드 자체는 pinned 태그 그대로다 — 최소 diff 원칙. + +## SLE 패키지 매핑 + +업스트림 apt 목록을 SLE_BCI 이름으로 옮겼다(`.claude/image-authoring.md` 참고). +목록 자체는 `source.build.env` 의 `RUNTIME_PACKAGES` 에 있다. + +| ubuntu | SLE_BCI | +|---|---| +| `git` | `git-core` | +| `tzdata` | `timezone` | +| `gpg` · `gpg-agent` | `gpg2` | +| `openssh-client` | `openssh-clients` | +| `ca-certificates` | `ca-certificates` (동일) | +| `tini` | **없음** → 소스 빌드 | +| `connect-proxy` | **없음** → 소스 빌드 | + +## 검증 + +`verify.sh` 가 보는 것 — argocd 버전 문자열, 업스트림 심볼릭 링크 9개, 번들 도구 3개의 +버전, tini·connect-proxy 실행 가능, git/gpg/ssh 존재, `/etc/gitconfig` 의 LFS 필터, +`/app/config` 디렉토리 구조와 래퍼 스크립트. + +**게이트 PASS 는 "동작한다" 를 증명하지 않는다.** 실제 기동은 Kubernetes API 가 필요해 +스모크 범위 밖이다 — dev 클러스터 배포 검증을 반드시 한다. + +```sh +VALUES_FILE= bash scripts/deploy-test/deploy-test-argo-cd.sh /tmp/deploy-test-argocd +``` diff --git a/images/argocd/catalog.env b/images/argocd/catalog.env new file mode 100644 index 0000000..9f6d53e --- /dev/null +++ b/images/argocd/catalog.env @@ -0,0 +1,18 @@ +# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.build.env)와는 +# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. +# +# argo-cd 카탈로그는 10.4.0(현재 운영)과 7.8.11(직전)을 보관한다. 자체 빌드 이미지는 +# 운영 버전에만 반영한다 — 7.8.11 은 released 로 동결하고 손대지 않는다 +# (7.7.0 은 2026-08-19 삭제). +CHART_DIRS="manifests/helm/argo-cd/10.4.0" + +# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) +TAG_STYLE=split + +# argo-cd 차트는 모든 컴포넌트(server/repo-server/controller/applicationset/notifications)가 +# global.image.{repository,tag} 하나를 공유한다 — 컴포넌트별 image 블록은 기본이 비어 있고 +# 비면 global 을 상속한다. 그래서 이 한 곳만 갱신하면 전부 바뀐다. +TAG_BLOCK=global.image + +# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) +DEFAULT_BASE_OS=source diff --git a/images/argocd/go-mod-upgrade.sh b/images/argocd/go-mod-upgrade.sh new file mode 100755 index 0000000..37dfb91 --- /dev/null +++ b/images/argocd/go-mod-upgrade.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env sh +# go-mod-upgrade — CVE 대응으로 강제 업그레이드할 Go 모듈 목록을 현재 모듈에 적용한다. +# 빌더 스테이지에서 각 Go 프로젝트 디렉토리에 들어간 뒤 인자 없이 호출한다. +# +# 왜 스크립트로 빼는가 +# -------------------- +# 업그레이드 대상을 Dockerfile 에 직접 나열하면, 새 CVE 가 하나 나올 때마다 +# ① build.env 에 _FIX_VERSION 추가 ② Dockerfile 의 ARG 선언 추가(스테이지마다) +# ③ go get 목록에 추가(빌드 대상마다) ④ BUILD_ARGS 에 이름 추가 +# 네 곳을 고쳐야 한다. 목록을 GO_MODULE_UPGRADES 값 하나로 받으면 **build.env 한 줄만** +# 바뀌고 Dockerfile 은 그대로다 — 이 이미지는 앞으로도 CVE 조치를 반복해서 받는다. +# +# 목록에 있으나 이 프로젝트가 쓰지 않는 모듈은 건너뛴다 +# ---------------------------------------------------- +# 한 이미지 안에서 여러 Go 프로젝트(argocd·helm·kustomize·git-lfs)를 빌드하는데 각자 +# 의존성이 다르다. `go get` 은 의존성 그래프에 없는 모듈도 go.mod 에 요구사항으로 +# **추가**하므로(뒤이은 go mod tidy 가 지우긴 하지만) 애초에 넣지 않는 편이 의도가 분명하고, +# 목록 하나를 네 프로젝트에 그대로 재사용할 수 있다. +# +# 형식: 공백으로 구분한 `@` 목록. +# GO_MODULE_UPGRADES="golang.org/x/net@v0.56.0 google.golang.org/grpc@v1.82.1" +set -eu + +if [ -z "${GO_MODULE_UPGRADES:-}" ]; then + echo "go-mod-upgrade: GO_MODULE_UPGRADES 가 비어 있다 — 업그레이드 없이 진행" + exit 0 +fi + +targets="" +for spec in $GO_MODULE_UPGRADES; do + path="${spec%@*}" + if [ "$path" = "$spec" ]; then + echo "go-mod-upgrade: ::error:: '$spec' 에 @ 이 없다" + exit 2 + fi + if go list -m "$path" >/dev/null 2>&1; then + targets="$targets $spec" + echo "go-mod-upgrade: 적용 $spec" + else + echo "go-mod-upgrade: 건너뜀 $path (이 프로젝트의 의존성 그래프에 없음)" + fi +done + +if [ -z "$targets" ]; then + echo "go-mod-upgrade: 적용 대상 없음" + exit 0 +fi + +# shellcheck disable=SC2086 # targets 는 공백 구분 목록이라 의도적으로 분리한다 +go get $targets +go mod tidy diff --git a/images/argocd/source.Dockerfile b/images/argocd/source.Dockerfile new file mode 100644 index 0000000..221a2c5 --- /dev/null +++ b/images/argocd/source.Dockerfile @@ -0,0 +1,263 @@ +# argocd — 업스트림 소스를 pinned commit 으로 직접 컴파일하고, 번들되는 Go 도구 +# (helm/kustomize/git-lfs)까지 같은 툴체인으로 다시 만든다. +# +# 왜 자체 빌드인가 +# ---------------- +# 이 이미지의 차단 CVE 는 대부분 OS 패키지가 아니라 **바이너리에 정적 링크된 Go 모듈**이다. +# 상위 태그 교체(이미 최신)로도, 베이스 OS 교체(모듈은 바이너리 안에 있다)로도 잡히지 않아 +# 자체 빌드만 남는다. +# +# 왜 번들 도구까지 다시 만드는가 +# ------------------------------ +# 업스트림 Dockerfile 은 helm/kustomize/git-lfs 를 hack/install.sh 로 **미리 빌드된 릴리스 +# 바이너리를 내려받아** 넣는다. 그 바이너리의 Go 버전은 각 프로젝트가 정한 것이라 우리가 +# 태그를 올려도 바뀌지 않는다. +# +# 그리고 부분 조치는 효과가 없다 — 같은 stdlib·x/crypto 취약점이 다섯 바이너리에 각각 +# 들어있어서, 한 바이너리만 고치면 나머지에 그대로 남는다. 고유 CVE 는 소수이고 대부분이 +# 공유분이다. 전부 다시 만들어야 게이트가 0 이 된다(실측 확인). +# +# 재사용 설계 — Dockerfile 에 버전을 박지 않는다 +# --------------------------------------------- +# 이 이미지는 앞으로도 CVE 조치를 반복해서 받는다. 다음 조치에서 바뀌는 것은 전부 +# source.build.env 값이다: +# +# GO_MODULE_UPGRADES 강제 업그레이드할 Go 모듈 목록 (네 프로젝트에 공통 적용) +# GO_BUILDER_TAG Go 툴체인 (stdlib CVE 해소) +# *_VERSION 각 구성요소 버전 +# RUNTIME_PACKAGES 최종 이미지에 설치할 SLE 패키지 +# +# 값은 scripts/build/suggest-go-upgrades.py 가 스캔 리포트에서 산출한다 — 사람이 CVE 를 +# 훑어 최대값을 고르지 않는다. 모듈 목록은 go-mod-upgrade.sh 가 프로젝트마다 "그 프로젝트가 +# 실제로 쓰는 것만" 골라 적용하므로 목록 하나를 네 프로젝트에 그대로 재사용한다. +# +# 업스트림과 다르게 하는 부분 +# --------------------------- +# ubuntu → SUSE BCI(bci-base). 카탈로그는 SUSE BCI 하나만 쓴다 +# (.claude/image-authoring.md 원칙 2). argocd 는 git/gpg/ssh 런타임이 필요해 패키지 +# 매니저가 있는 base 를 쓴다(micro 씨앗 방식의 복잡도를 감수할 이점이 없었다). +# ubuntu 베이스가 딸려오던 pebble 바이너리도 함께 사라진다. +# tini·connect-proxy 는 SLE_BCI 에 패키지가 없어 소스에서 빌드한다. +# helm/kustomize/git-lfs 를 릴리스 바이너리 다운로드 → 소스 컴파일로 교체. +# UI 는 업스트림과 동일하게 node 로 빌드한다 — Go embed 로 바이너리에 들어가 생략 불가. + +# syntax=docker/dockerfile:1 + +# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — +# 스테이지 안에 두면 지역 변수가 되어 이후 FROM 의 이미지명이 빈 값이 된다 +# (.claude/image-authoring.md). +ARG GO_BUILDER_TAG=1.26.6-trixie +ARG NODE_BUILDER_TAG=24.14.1 +ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7 + +#################################################################################################### +# UI — 업스트림 argocd-ui 스테이지를 그대로 재현한다. 산출물이 Go embed 로 바이너리에 들어간다. +#################################################################################################### +FROM --platform=$BUILDPLATFORM docker.io/library/node:${NODE_BUILDER_TAG} AS argocd-ui + +ARG SOURCE_COMMIT +ARG APP_VERSION +ADD https://github.com/argoproj/argo-cd.git#${SOURCE_COMMIT} /src + +# 업스트림과 동일: corepack 으로 pnpm 을 켜고 lockfile 그대로 설치한다. +WORKDIR /src/ui +RUN npm install -g corepack@0.34.6 && corepack enable && pnpm install --frozen-lockfile + +# UI 번들은 아키텍처 무관이다 — 업스트림 주석대로 TARGETARCH 를 쓰지 않는다. +ENV ARGO_VERSION=$APP_VERSION +RUN NODE_ENV='production' NODE_ONLINE_ENV='online' NODE_OPTIONS=--max_old_space_size=8192 pnpm build + +#################################################################################################### +# 번들 Go 도구 — 업스트림이 릴리스 바이너리로 받아오던 것을 소스에서 다시 컴파일한다. +# +# 새 툴체인으로 다시 컴파일하면 stdlib 은 해소되지만 각 프로젝트가 go.mod 에 pin 한 모듈은 +# 그대로 낡아 있다 — 도구 쪽 모듈을 빠뜨려 게이트가 실패한 적이 있다(실측). 그래서 argocd +# 본체와 같은 GO_MODULE_UPGRADES 를 세 도구에도 적용한다. +#################################################################################################### +FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS go-tools + +ARG TARGETARCH +ARG HELM_VERSION +ARG KUSTOMIZE_VERSION +ARG GIT_LFS_VERSION +ARG GO_MODULE_UPGRADES +ENV CGO_ENABLED=0 GOOS=linux GO_MODULE_UPGRADES=${GO_MODULE_UPGRADES} +COPY go-mod-upgrade.sh /usr/local/bin/go-mod-upgrade +RUN chmod 0755 /usr/local/bin/go-mod-upgrade +WORKDIR /out + +# helm — 업스트림 Makefile 의 ldflags(version/gitTreeState)를 재현한다. +ADD https://github.com/helm/helm.git#v${HELM_VERSION} /src/helm +RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + cd /src/helm && go-mod-upgrade && \ + P=helm.sh/helm/v4/internal/version && \ + GOARCH=${TARGETARCH} go build -trimpath \ + -ldflags "-X ${P}.version=v${HELM_VERSION} -X ${P}.gitTreeState=clean" \ + -o /out/helm ./cmd/helm + +# kustomize — 리포 안의 kustomize/ 서브모듈이 CLI 다. +ADD https://github.com/kubernetes-sigs/kustomize.git#kustomize/v${KUSTOMIZE_VERSION} /src/kustomize +RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + cd /src/kustomize/kustomize && go-mod-upgrade && \ + P=sigs.k8s.io/kustomize/api/provenance && \ + GOARCH=${TARGETARCH} go build -trimpath \ + -ldflags "-X ${P}.version=v${KUSTOMIZE_VERSION}" \ + -o /out/kustomize . + +# git-lfs — Makefile 이 ldflags 로 버전을 심는다. +ADD https://github.com/git-lfs/git-lfs.git#v${GIT_LFS_VERSION} /src/git-lfs +RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + cd /src/git-lfs && go-mod-upgrade && \ + GOARCH=${TARGETARCH} go build -trimpath \ + -ldflags "-X github.com/git-lfs/git-lfs/v3/config.GitCommit=v${GIT_LFS_VERSION}" \ + -o /out/git-lfs . + +#################################################################################################### +# argocd 본체 — 업스트림 argocd-build 스테이지 + 취약 모듈 강제 업그레이드 +#################################################################################################### +FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS argocd-build + +ARG TARGETOS +ARG TARGETARCH +ARG SOURCE_COMMIT +ARG APP_VERSION +ARG GO_MODULE_UPGRADES +ENV GO_MODULE_UPGRADES=${GO_MODULE_UPGRADES} +COPY go-mod-upgrade.sh /usr/local/bin/go-mod-upgrade +RUN chmod 0755 /usr/local/bin/go-mod-upgrade + +WORKDIR /go/src/github.com/argoproj/argo-cd +ADD https://github.com/argoproj/argo-cd.git#${SOURCE_COMMIT} . + +RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + go-mod-upgrade + +COPY --from=argocd-ui /src/ui/dist/app ./ui/dist/app + +# 업스트림 Dockerfile 과 동일하게 Makefile 의 argocd-all 타겟을 부른다 +# (단일 `go build -o dist/argocd ./cmd`). 버전 심볼은 Makefile 이 ldflags 로 심는다. +# BUILD_DATE 를 고정해 빌드 재현성을 확보한다(업스트림은 date 를 그때그때 넣는다). +RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \ + GIT_TAG=v${APP_VERSION} \ + GIT_COMMIT=${SOURCE_COMMIT} \ + GIT_TREE_STATE=clean \ + BUILD_DATE=1970-01-01T00:00:00Z \ + GOOS=${TARGETOS} GOARCH=${TARGETARCH} \ + make argocd-all + +#################################################################################################### +# C 도구 — SLE_BCI 에 패키지가 없어 소스에서 빌드한다(2026-08-19 zypper 실측) +#################################################################################################### +FROM ${RUNTIME_BASE} AS c-builder + +ARG TINI_VERSION +ARG SSH_CONNECT_VERSION +ARG BUILDER_PACKAGES +# SLE_BCI 미러가 간헐적으로 끊긴다(실측: curl error 56 / SSL unexpected eof). 재시도한다. +RUN for i in 1 2 3 4 5; do \ + zypper --non-interactive --gpg-auto-import-keys refresh && break; \ + echo "zypper refresh 실패 — 재시도 $i"; sleep 10; \ + done && \ + zypper --non-interactive install -y --no-recommends ${BUILDER_PACKAGES} && \ + zypper --non-interactive clean --all + +# tini — 업스트림 argocd 의 ENTRYPOINT(["/usr/bin/tini", "--"]). +# 동적 링크로 빌드한다. 이 스테이지와 final 이 같은 BCI 베이스라 glibc ABI 가 동일하고, +# 업스트림이 쓰는 apt tini 도 동적 링크다. 정적 링크는 SLE_BCI 에 정적 glibc 가 없어 +# 애초에 불가능하다(2026-08-19 실측: "cannot find -lc ... static version of the c library"). +ADD https://github.com/krallin/tini.git#v${TINI_VERSION} /src/tini +RUN cd /src/tini && \ + cmake -DCMAKE_BUILD_TYPE=Release . && \ + make tini && \ + install -m 0755 tini /out-tini + +# connect-proxy — Debian 의 connect-proxy 패키지 원본(gotoh/ssh-connect 의 connect.c). +# SSH-over-proxy 환경에서 git 이 쓴다. 기능을 빼지 않기 위해 빌드한다. +ADD https://github.com/gotoh/ssh-connect.git#${SSH_CONNECT_VERSION} /src/connect +RUN cd /src/connect && \ + gcc -O2 -o /out-connect connect.c + +#################################################################################################### +# 최종 이미지 — 업스트림 argocd-base + final 을 SUSE BCI 위에서 재현한다 +#################################################################################################### +FROM ${RUNTIME_BASE} AS final + +LABEL org.opencontainers.image.source="https://github.com/argoproj/argo-cd" + +ARG RUNTIME_PACKAGES +ENV ARGOCD_USER_ID=999 + +# RUNTIME_PACKAGES 는 업스트림 apt 목록을 SLE 이름으로 옮긴 것이다. 이름이 다른 것들: +# git → git-core tzdata → timezone gpg/gpg-agent → gpg2 openssh-client → openssh-clients +# tini·connect-proxy 는 SLE_BCI 에 없어 c-builder 에서 빌드해 COPY 한다. +# SLE_BCI 미러가 간헐적으로 끊긴다(실측). 재시도한다. +RUN for i in 1 2 3 4 5; do \ + zypper --non-interactive --gpg-auto-import-keys refresh && break; \ + echo "zypper refresh 실패 — 재시도 $i"; sleep 10; \ + done && \ + zypper --non-interactive update -y && \ + zypper --non-interactive install -y --no-recommends ${RUNTIME_PACKAGES} && \ + zypper --non-interactive clean --all && \ + rm -rf /var/log/zypp /usr/share/doc/packages/* + +RUN groupadd -g $ARGOCD_USER_ID argocd && \ + useradd -r -u $ARGOCD_USER_ID -g argocd argocd && \ + mkdir -p /home/argocd && \ + chown argocd:0 /home/argocd && \ + chmod g=u /home/argocd + +COPY --from=c-builder /out-tini /usr/bin/tini +COPY --from=c-builder /out-connect /usr/bin/connect-proxy +COPY --from=go-tools /out/helm /usr/local/bin/helm +COPY --from=go-tools /out/kustomize /usr/local/bin/kustomize +COPY --from=go-tools /out/git-lfs /usr/local/bin/git-lfs + +# 업스트림이 소스에서 넣는 래퍼 스크립트들 — argocd 가 gpg 검증·entrypoint 에 쓴다. +COPY --from=argocd-build \ + /go/src/github.com/argoproj/argo-cd/hack/gpg-wrapper.sh \ + /go/src/github.com/argoproj/argo-cd/hack/git-verify-wrapper.sh \ + /go/src/github.com/argoproj/argo-cd/entrypoint.sh \ + /usr/local/bin/ +RUN chmod 0755 /usr/local/bin/gpg-wrapper.sh /usr/local/bin/git-verify-wrapper.sh /usr/local/bin/entrypoint.sh + +# git-lfs 시스템 설정(/etc/gitconfig) 을 만들어 LFS 필터를 활성화한다 — 업스트림과 동일. +RUN git lfs install --system + +# 하위 호환용 심볼릭 링크 — 업스트림과 동일. +RUN ln -s /usr/local/bin/entrypoint.sh /usr/local/bin/uid_entrypoint.sh + +# configmap 마운트 지원 — 업스트림과 동일한 경로/권한. +WORKDIR /app/config/ssh +RUN touch ssh_known_hosts && \ + ln -s /app/config/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts + +WORKDIR /app/config +RUN mkdir -p tls && \ + mkdir -p gpg/source && \ + mkdir -p gpg/keys && \ + chown argocd gpg/keys && \ + chmod 0700 gpg/keys + +ENV USER=argocd + +# dual-stack 환경에서 _grpc_config DNS TXT 조회가 타임아웃을 유발하는 문제 회피 — 업스트림과 동일. +# argocd-cmd-params-cm 으로 덮어쓸 수 있다. +ENV GRPC_ENABLE_TXT_SERVICE_CONFIG=false + +COPY --from=argocd-build /go/src/github.com/argoproj/argo-cd/dist/argocd /usr/local/bin/argocd + +# 업스트림과 동일한 심볼릭 링크 9개 — 차트가 이 이름들로 컨테이너 command 를 지정한다. +RUN ln -s /usr/local/bin/argocd /usr/local/bin/argocd-server && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-repo-server && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-cmp-server && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-application-controller && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-dex && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-notifications && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-applicationset-controller && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-k8s-auth && \ + ln -s /usr/local/bin/argocd /usr/local/bin/argocd-commit-server + +ENTRYPOINT ["/usr/bin/tini", "--"] + +USER $ARGOCD_USER_ID +WORKDIR /home/argocd diff --git a/images/argocd/source.build.env b/images/argocd/source.build.env new file mode 100644 index 0000000..1d87ba6 --- /dev/null +++ b/images/argocd/source.build.env @@ -0,0 +1,77 @@ +# argocd — 소스 컴파일형 자체 빌드 (유일한 변종) +# +# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. +# 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다: +# IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh +# +# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석 / README.md. +# argo-cd 10.4.0 CVE 조치(2026-08-19)로 도입 — 근거·경과는 MEMORY.md. +# +# ── 다음 CVE 조치에서 바뀌는 것은 이 파일뿐이다 ──────────────────────────────── +# Dockerfile 에는 버전도 모듈 목록도 박혀 있지 않다. 새 차단 CVE 가 나오면: +# Go stdlib CVE → GO_BUILDER_TAG 를 올린다 +# Go 모듈 CVE → GO_MODULE_UPGRADES 에 `@` 을 추가한다 +# OS 패키지 CVE → RUNTIME_BASE 를 올리거나 RUNTIME_PACKAGES 를 조정한다 +# 구성요소 새 릴리스 → 해당 *_VERSION 을 올린다 + +DOCKERFILE=source.Dockerfile +TARGET=final +TAG_SLUG=security + +# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. +# 스톡 v3.5.1 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열은 그대로 둔다. +APP_VERSION=3.5.1 + +# v3.5.1 태그가 가리키는 커밋(lightweight 태그라 태그 객체 없이 커밋 직접 참조), 2026-08-19 확인. +SOURCE_COMMIT=109ca7ca71139e514114499d294a492e7910a965 + +# stdlib 차단 CVE 를 해소하는 최소 Go 버전. suggest-go-upgrades.py 가 산출한다. +# 빌더 스테이지에만 쓰이고 최종 이미지에는 남지 않는다. +GO_BUILDER_TAG=1.26.6-trixie + +# UI 빌드용. 업스트림 Dockerfile 의 node pin 과 동일하게 맞춘다 — UI 번들은 스캔에서 +# 취약점이 잡히지 않으므로 올릴 이유가 없고, lockfile 과의 정합을 지키는 쪽이 안전하다. +NODE_BUILDER_TAG=24.14.1 + +# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(.claude/image-authoring.md 원칙 2). +# argocd 는 git/gpg/ssh 런타임이 필요해 패키지 매니저가 있는 bci-base 를 쓴다. +# base 와 micro 를 스캔해 비교했고 차이가 없어, micro 씨앗 방식(.claude/image-authoring.md)의 +# 복잡도를 감수할 이점이 없었다. +# +# **16.0 을 쓰는 이유 — coreutils 버전이 차트 요구사항을 가른다.** +# argo-cd 차트의 repo-server init 컨테이너(copyutil)가 `cp --update=none` 을 쓴다. 이 +# 형식은 GNU coreutils 9.3+ 에서만 되는데 15.7 은 그보다 낮아 +# "option '--update' doesn't allow an argument" 로 죽는다(실측 — 15.7 로 빌드해 배포했다가 +# repo-server 가 Init:CrashLoopBackOff 로 실패했다). 16.0 은 coreutils 9.6 이라 동작한다. +# 16.0 의 패키지 가용성과 trivy 커버리지(CoverageProbe=ok, EOSL 아님)도 확인했다. +# verify.sh 가 이 명령을 직접 검사하므로 다음에 베이스를 바꿔도 빌드 단계에서 걸린다. +RUNTIME_BASE=registry.suse.com/bci/bci-base:16.0 + +# 번들 Go 도구 — 업스트림은 hack/install.sh 로 릴리스 바이너리를 내려받지만, 그 바이너리의 +# Go 버전이 낡아(git-lfs 1.25.3 · kustomize 1.24.0) CVE 의 주범이었다. 소스에서 다시 만든다. +# helm 만 업스트림 pin(4.2.1) 대신 최신을 쓴다 — 나머지 둘은 pin 이 이미 최신이다. +HELM_VERSION=4.2.4 +KUSTOMIZE_VERSION=5.8.1 +GIT_LFS_VERSION=3.7.1 + +# SLE_BCI 에 패키지가 없어 소스 빌드한다(2026-08-19 zypper 실측). +# tini 는 업스트림 ENTRYPOINT, connect-proxy 는 SSH-over-proxy 용이다. +TINI_VERSION=0.19.0 +SSH_CONNECT_VERSION=1.106 + +# 강제 업그레이드할 Go 모듈 — argocd·helm·kustomize·git-lfs 네 프로젝트에 **공통 적용**된다. +# go-mod-upgrade.sh 가 각 프로젝트의 의존성 그래프에 있는 것만 골라 적용하므로 하나의 +# 목록을 그대로 재사용할 수 있다. +# +# 값은 suggest-go-upgrades.py 로 산출한다. 단, 제안값은 CVE 요건의 **최소치**라 모듈 간 +# 제약으로 더 올려야 할 수 있다 — 빌드가 "requires @vX, not @vY" 로 실패하면 그 +# 버전으로 올린다(x/crypto 가 이 경우였다). +GO_MODULE_UPGRADES="golang.org/x/crypto@v0.53.0 golang.org/x/net@v0.56.0 golang.org/x/text@v0.39.0 google.golang.org/grpc@v1.82.1 github.com/go-git/go-git/v5@v5.19.2 oras.land/oras-go/v2@v2.6.2" + +# c-builder 가 tini(cmake)·connect(gcc) 를 컴파일하는 데 필요한 것들. +BUILDER_PACKAGES="gcc make cmake git-core tar gzip" + +# 최종 이미지 런타임 패키지 — 업스트림 apt 목록의 SLE 대응(README.md 매핑표 참고). +RUNTIME_PACKAGES="git-core ca-certificates gpg2 timezone openssh-clients" + +BUILD_ARGS="SOURCE_COMMIT APP_VERSION GO_BUILDER_TAG NODE_BUILDER_TAG RUNTIME_BASE HELM_VERSION KUSTOMIZE_VERSION GIT_LFS_VERSION TINI_VERSION SSH_CONNECT_VERSION GO_MODULE_UPGRADES BUILDER_PACKAGES RUNTIME_PACKAGES" diff --git a/images/argocd/verify.sh b/images/argocd/verify.sh new file mode 100755 index 0000000..4ee8fb9 --- /dev/null +++ b/images/argocd/verify.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# argocd 이미지 기능 검증 — 호스트에서 bash 로 실행된다 +# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... bash verify.sh` +# 형태로 호출한다). +# +# 이 이미지의 핵심 위험은 "번들 도구를 릴리스 바이너리 다운로드에서 소스 컴파일로 바꾼 것"과 +# "ubuntu → SUSE BCI 베이스 교체" 두 가지다. 그래서 argocd 본체뿐 아니라 helm·kustomize· +# git-lfs·tini·connect-proxy 가 전부 실제로 실행되는지, 업스트림이 만들던 런타임 구조 +# (심볼릭 링크 9개, /etc/gitconfig 의 LFS 필터, /app/config 권한)가 그대로인지 확인한다. +# +# 실제 기동(서버·컨트롤러)은 Kubernetes API 가 필요해 이 스모크 범위 밖이다 — +# dev 클러스터 배포 검증(.claude/deploy-test-procedure.md, scripts/deploy-test/deploy-test-argo-cd.sh)이 +# 담당한다. +# +# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. +set -e + +TAG="${TAG:?TAG 환경변수가 필요하다}" +PLATFORM="${PLATFORM:-linux/amd64}" +APP_VERSION="${APP_VERSION:?APP_VERSION 환경변수가 필요하다 (build.env 에서 전달)}" +SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다}" +HELM_VERSION="${HELM_VERSION:?HELM_VERSION 환경변수가 필요하다}" +KUSTOMIZE_VERSION="${KUSTOMIZE_VERSION:?KUSTOMIZE_VERSION 환경변수가 필요하다}" +GIT_LFS_VERSION="${GIT_LFS_VERSION:?GIT_LFS_VERSION 환경변수가 필요하다}" + +echo "== 이미지 메타데이터 ==" +USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")" +[ "$USER_CFG" = "999" ] || { echo "FAIL: Config.User 가 999 가 아니다 (실제: $USER_CFG) — 업스트림 ARGOCD_USER_ID"; exit 1; } +ENTRY="$(docker inspect --format '{{json .Config.Entrypoint}}' "$TAG")" +case "$ENTRY" in + *tini*) ;; + *) echo "FAIL: ENTRYPOINT 에 tini 가 없다 (실제: $ENTRY)"; exit 1 ;; +esac +echo " Config.User=$USER_CFG Entrypoint=$ENTRY" + +docker run --rm -i --platform "$PLATFORM" \ + -e APP_VERSION="$APP_VERSION" \ + -e SOURCE_COMMIT="$SOURCE_COMMIT" \ + -e HELM_VERSION="$HELM_VERSION" \ + -e KUSTOMIZE_VERSION="$KUSTOMIZE_VERSION" \ + -e GIT_LFS_VERSION="$GIT_LFS_VERSION" \ + --entrypoint sh "$TAG" <<'GUEST' +set -e + +echo "== argocd 본체 ==" +OUT="$(argocd version --client --short)" +echo " $OUT" +case "$OUT" in + *"v$APP_VERSION"*) ;; + *) echo "FAIL: 버전 출력에 v$APP_VERSION 이 없다 — Makefile ldflags 주입 확인 필요"; exit 1 ;; +esac +case "$OUT" in + *"$SOURCE_COMMIT"*) ;; + *) echo "WARN: 버전 출력에 pinned commit 이 안 보인다 (short 출력이라 생략됐을 수 있음)" ;; +esac + +echo "== 업스트림 심볼릭 링크 9개 ==" +for n in argocd-server argocd-repo-server argocd-cmp-server argocd-application-controller \ + argocd-dex argocd-notifications argocd-applicationset-controller argocd-k8s-auth \ + argocd-commit-server; do + [ -x "/usr/local/bin/$n" ] || { echo "FAIL: /usr/local/bin/$n 없음"; exit 1; } +done +echo " 9개 모두 존재·실행 가능" + +echo "== 번들 도구 (소스 컴파일로 교체한 것들) ==" +H="$(helm version --short)" +echo " helm $H" +case "$H" in *"$HELM_VERSION"*) ;; *) echo "FAIL: helm 버전이 $HELM_VERSION 이 아니다"; exit 1 ;; esac + +K="$(kustomize version)" +echo " kustomize $K" +case "$K" in *"$KUSTOMIZE_VERSION"*) ;; *) echo "FAIL: kustomize 버전이 $KUSTOMIZE_VERSION 이 아니다"; exit 1 ;; esac + +L="$(git-lfs version)" +echo " git-lfs $L" +case "$L" in *"$GIT_LFS_VERSION"*) ;; *) echo "FAIL: git-lfs 버전이 $GIT_LFS_VERSION 이 아니다"; exit 1 ;; esac + +echo "== SLE_BCI 에 없어 소스 빌드한 C 도구 ==" +[ -x /usr/bin/tini ] || { echo "FAIL: /usr/bin/tini 없음"; exit 1; } +/usr/bin/tini --version +[ -x /usr/bin/connect-proxy ] || { echo "FAIL: /usr/bin/connect-proxy 없음"; exit 1; } +# connect 는 인자 없이 부르면 usage 를 내고 비정상 종료한다 — 실행 가능 여부만 본다. +/usr/bin/connect-proxy >/dev/null 2>&1 || true +echo " tini · connect-proxy 실행 가능" + +echo "== 런타임 필수 도구 (업스트림 apt 목록 대응) ==" +git --version >/dev/null || { echo "FAIL: git 없음"; exit 1; } +gpg --version >/dev/null || { echo "FAIL: gpg 없음"; exit 1; } +ssh -V 2>/dev/null || ssh -V || { echo "FAIL: ssh 없음"; exit 1; } +echo " git · gpg · ssh 정상" + +echo "== 차트가 실제로 실행하는 명령 ==" +# argo-cd 차트의 repo-server init 컨테이너(copyutil)가 그대로 쓰는 형식이다. +# /bin/cp --update=none /usr/local/bin/argocd /var/run/argocd/argocd +# `--update=none` 은 GNU coreutils 9.3+ 에서만 된다. 베이스 OS 의 coreutils 가 낮으면 +# 이미지는 멀쩡히 빌드·스캔을 통과하고 **배포 시점에** Init:CrashLoopBackOff 로 죽는다 +# (실측). 그래서 여기서 직접 확인한다 — 베이스를 바꿀 때 이 검사가 걸러준다. +: > /tmp/_vsrc +if ! /bin/cp --update=none /tmp/_vsrc /tmp/_vdst 2>/dev/null; then + echo "FAIL: 'cp --update=none' 이 동작하지 않는다 — 베이스 OS 의 coreutils 가 9.3 미만이다." + echo " argo-cd 차트의 repo-server init 컨테이너가 이 형식을 쓰므로 배포가 실패한다." + cp --version 2>/dev/null | head -1 + exit 1 +fi +echo " cp --update=none 동작 ($(cp --version 2>/dev/null | head -1))" + +# argocd 바이너리를 공유 볼륨에 복사하는 흐름 자체를 그대로 재현해 본다. +mkdir -p /tmp/_vrun +/bin/cp --update=none /usr/local/bin/argocd /tmp/_vrun/argocd || { echo "FAIL: argocd 복사 실패"; exit 1; } +/bin/ln -sf /tmp/_vrun/argocd /tmp/_vrun/argocd-cmp-server || { echo "FAIL: cmp-server 심볼릭 링크 실패"; exit 1; } +[ -x /tmp/_vrun/argocd-cmp-server ] || { echo "FAIL: 복사된 argocd 가 실행 가능하지 않다"; exit 1; } +echo " copyutil 흐름(복사 + cmp-server 링크) 재현 성공" + +echo "== git-lfs 시스템 설정 (업스트림 git lfs install --system) ==" +git config --system --get filter.lfs.clean >/dev/null || { echo "FAIL: /etc/gitconfig 에 LFS 필터가 없다"; exit 1; } +echo " filter.lfs.clean 등록됨" + +echo "== 업스트림 런타임 디렉토리 구조 ==" +[ -L /etc/ssh/ssh_known_hosts ] || { echo "FAIL: /etc/ssh/ssh_known_hosts 심볼릭 링크 없음"; exit 1; } +[ -d /app/config/tls ] || { echo "FAIL: /app/config/tls 없음"; exit 1; } +[ -d /app/config/gpg/keys ] || { echo "FAIL: /app/config/gpg/keys 없음"; exit 1; } +[ -x /usr/local/bin/entrypoint.sh ] || { echo "FAIL: entrypoint.sh 없음"; exit 1; } +[ -x /usr/local/bin/uid_entrypoint.sh ] || { echo "FAIL: uid_entrypoint.sh 없음"; exit 1; } +[ -x /usr/local/bin/gpg-wrapper.sh ] || { echo "FAIL: gpg-wrapper.sh 없음"; exit 1; } +echo " ssh_known_hosts 링크 · /app/config/{tls,gpg/keys} · 래퍼 스크립트 정상" + +echo "VERIFY-OK" +GUEST diff --git a/manifests/helm/argo-cd/10.4.0/custom-values.yaml b/manifests/helm/argo-cd/10.4.0/custom-values.yaml index 8872cc4..df63a95 100644 --- a/manifests/helm/argo-cd/10.4.0/custom-values.yaml +++ b/manifests/helm/argo-cd/10.4.0/custom-values.yaml @@ -4,6 +4,20 @@ # 이 파일은 "PaaSup 이 실제로 어떤 key 를 쓰는가"의 기준이며(Breaking Change 판정 근거), # 도메인·시크릿 같은 환경별 값은 placeholder 로 둔다. 비밀값을 커밋하지 않는다. +global: + image: + # 자체 빌드(대응 우선순위 c) — 업스트림 v3.5.1 의 차단 CVE 대부분이 바이너리에 정적 + # 링크된 Go 모듈이라 상위 태그 교체·베이스 OS 교체로 해소되지 않았다. 번들 도구 + # (helm/kustomize/git-lfs)까지 최신 툴체인으로 다시 컴파일했다. + # 빌드 정의: images/argocd/. 상위 태그가 이 문제를 해결하면(대응 우선순위 a) + # 업스트림으로 되돌리는 것이 우선이다. + # + # 이 값은 argocd 바이너리를 쓰는 5개 컴포넌트(server/repo-server/application-controller/ + # applicationset/notifications)가 공유한다 — 컴포넌트별 image 블록이 비면 global 을 + # 상속한다. dex·redis 는 각자 별도 이미지라 영향받지 않는다. + repository: docker.io/paasup/argocd + tag: "3.5.1-security-hardened-20260819" + configs: params: # ArgoCD v3.0 부터 리소스 health 를 Application CR 에 저장하지 않는다(기본 false). diff --git a/scripts/build/suggest-go-upgrades.py b/scripts/build/suggest-go-upgrades.py new file mode 100755 index 0000000..4bf2562 --- /dev/null +++ b/scripts/build/suggest-go-upgrades.py @@ -0,0 +1,204 @@ +#!/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())