From 7746570ec099024323f94488ac7951a32c74178e Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Mon, 24 Aug 2026 15:21:29 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EC=B2=B4=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=ED=94=84=EB=A0=88=EC=9E=84?= =?UTF-8?q?=EC=9B=8C=ED=81=AC=EB=A5=BC=20security-images=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EB=A1=9C=20=EC=9D=B4=EA=B4=80=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8=20=EC=AA=BD=EC=9D=84=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit images/·scripts/build/build-hardened-image.sh·suggest-go-upgrades.py· build-image.yml·.claude/image-authoring.md·이미지 ADR(0001·0002·0004)을 삭제했다 — 전부 별도 public 레포 security-images 로 이미 이관됐다. 카탈로그 쪽에는 "무엇을 배포 중인가"를 아는 부분만 남긴다: - catalog/image-map/.env — 옛 catalog.env 의 카탈로그 레이아웃 정보만 뗀 것 - scripts/build/check-rebuild-needed.py — 드리프트 탐지(A 파트)만 남기고 핀 판단 (B 파트: pin_changes/apply_changes/parse_module_specs)은 제거 - scripts/build/apply-published-tags.py(신규) — security-images 의 published.json 을 읽어 카탈로그 values 를 패치 - .github/workflows/{self-build-drift-check,catalog-tag-update}.yml(신규) — 각각 드리프트 스캔+트리거, 발행 태그 반영 effective_severity 를 cve-gate.py 로 옮겼다 — check-rebuild-needed.py 가 핀 도구를 거치지 않고 게이트를 직접 로드하게 하기 위한 선행 작업이다. 두 레포의 계약은 published.json 스키마 하나뿐이다 — security-images 는 이 카탈로그를 모른다(단방향 의존). 이관 배경·결합점 전체는 doc/migrations/self-build-images-to-security-images.md. 부수 수정: 자체 빌드 이미지를 참조하는 차트 values/README 의 죽은 링크(images/**, doc/decisions/000{1,2,4}, .claude/image-authoring.md)를 security-images 레포를 가리키는 서술로 교체. deploy-test 스크립트·CUSTOM-README 의 개인 Docker Hub 계정 (docker.io/wbsong111) 을 docker.io/paasup 로 교체. pitfalls.md 의 "스캐너 결과를 그대로 믿지 말 것" 절은 sbom-cve-gate skill 이 차트 축 설명에 실제로 참조하고 있어 남겼다 — "이미지 태그의 베이스 OS" 절만 제거했다 (다른 참조 없음, security-images 문서로 이관 완료). Co-Authored-By: Claude Sonnet 5 --- .claude/deploy-test-procedure.md | 8 +- .claude/image-authoring.md | 485 ------------------ .claude/pitfalls.md | 6 - .claude/skills/cve-remediation/SKILL.md | 14 +- .claude/skills/sbom-cve-gate/SKILL.md | 4 +- .claude/skills/self-build-image/SKILL.md | 108 ---- .github/workflows/build-image.yml | 432 ---------------- .github/workflows/catalog-tag-update.yml | 88 ++++ .github/workflows/self-build-drift-check.yml | 83 +++ CLAUDE.md | 63 ++- MEMORY.md | 33 +- catalog/image-map/README.md | 29 ++ catalog/image-map/adc.env | 3 + .../image-map/apisix-ingress-controller.env | 3 + catalog/image-map/apisix.env | 3 + catalog/image-map/argocd.env | 3 + catalog/image-map/cloudnative-pg.env | 3 + catalog/image-map/cnpg-postgresql.env | 2 + catalog/image-map/etcd.env | 3 + catalog/image-map/keycloak.env | 3 + doc/cve-exceptions.json | 5 +- doc/decisions/0001-cnpg-postgresql-image.md | 63 --- ...0002-cloudnative-pg-operator-self-build.md | 72 --- doc/decisions/0003-etcd-chart-selection.md | 3 +- doc/decisions/0004-etcd-image-self-build.md | 66 --- doc/decisions/README.md | 35 +- .../self-build-images-to-security-images.md | 95 ++++ doc/sbom-pipeline.md | 27 +- images/adc/README.md | 129 ----- images/adc/catalog.env | 14 - images/adc/source.Dockerfile | 65 --- images/adc/source.build.env | 23 - images/adc/verify.sh | 42 -- images/apisix-ingress-controller/README.md | 116 ----- images/apisix-ingress-controller/catalog.env | 16 - .../source.Dockerfile | 98 ---- .../source.build.env | 47 -- images/apisix-ingress-controller/verify.sh | 46 -- images/apisix/README.md | 153 ------ images/apisix/catalog.env | 13 - images/apisix/docker-entrypoint.sh | 64 --- images/apisix/keycloak-authz.lua | 168 ------ images/apisix/source.Dockerfile | 268 ---------- images/apisix/source.build.env | 34 -- images/apisix/verify.sh | 74 --- 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 ----- images/cloudnative-pg/README.md | 95 ---- images/cloudnative-pg/catalog.env | 11 - images/cloudnative-pg/source.Dockerfile | 80 --- images/cloudnative-pg/source.build.env | 36 -- images/cloudnative-pg/verify.sh | 39 -- images/cnpg-postgresql/README.md | 133 ----- images/cnpg-postgresql/catalog.env | 11 - images/cnpg-postgresql/suse.Dockerfile | 83 --- images/cnpg-postgresql/suse.build.env | 28 - images/cnpg-postgresql/verify.sh | 71 --- images/etcd/README.md | 96 ---- images/etcd/catalog.env | 11 - images/etcd/source.Dockerfile | 83 --- images/etcd/source.build.env | 41 -- images/etcd/verify.sh | 81 --- images/keycloak/README.md | 203 -------- images/keycloak/catalog.env | 10 - images/keycloak/overlay-jars.sh | 99 ---- images/keycloak/suse.Dockerfile | 112 ---- images/keycloak/suse.build.env | 64 --- images/keycloak/verify.sh | 201 -------- .../helm/apisix/2.16.0/custom-values.yaml | 32 +- .../helm/argo-cd/10.4.0/custom-values.yaml | 4 +- .../cloudnative-pg/0.29.0/custom-values.yaml | 5 +- .../cnpg-cluster/1.0.0/custom-values.yaml | 9 +- .../helm/cnpg-cluster/1.0.0/dip-values.yaml | 3 +- .../cnpg-cluster/1.1.0/custom-values.yaml | 4 +- manifests/helm/etcd/1.1.12/CUSTOM-README.md | 2 +- manifests/helm/etcd/1.1.12/custom-values.yaml | 7 +- .../helm/keycloakx/7.2.2/CUSTOM-README.md | 16 +- scripts/build/apply-published-tags.py | 161 ++++++ scripts/build/build-hardened-image.sh | 185 ------- scripts/build/check-rebuild-needed.py | 233 ++------- scripts/build/suggest-go-upgrades.py | 230 --------- .../deploy-test/deploy-test-cnpg-cluster.sh | 2 +- scripts/pipeline/cve-gate.py | 13 +- 87 files changed, 693 insertions(+), 5554 deletions(-) delete mode 100644 .claude/image-authoring.md delete mode 100644 .claude/skills/self-build-image/SKILL.md delete mode 100644 .github/workflows/build-image.yml create mode 100644 .github/workflows/catalog-tag-update.yml create mode 100644 .github/workflows/self-build-drift-check.yml create mode 100644 catalog/image-map/README.md create mode 100644 catalog/image-map/adc.env create mode 100644 catalog/image-map/apisix-ingress-controller.env create mode 100644 catalog/image-map/apisix.env create mode 100644 catalog/image-map/argocd.env create mode 100644 catalog/image-map/cloudnative-pg.env create mode 100644 catalog/image-map/cnpg-postgresql.env create mode 100644 catalog/image-map/etcd.env create mode 100644 catalog/image-map/keycloak.env delete mode 100644 doc/decisions/0001-cnpg-postgresql-image.md delete mode 100644 doc/decisions/0002-cloudnative-pg-operator-self-build.md delete mode 100644 doc/decisions/0004-etcd-image-self-build.md create mode 100644 doc/migrations/self-build-images-to-security-images.md delete mode 100644 images/adc/README.md delete mode 100644 images/adc/catalog.env delete mode 100644 images/adc/source.Dockerfile delete mode 100644 images/adc/source.build.env delete mode 100755 images/adc/verify.sh delete mode 100644 images/apisix-ingress-controller/README.md delete mode 100644 images/apisix-ingress-controller/catalog.env delete mode 100644 images/apisix-ingress-controller/source.Dockerfile delete mode 100644 images/apisix-ingress-controller/source.build.env delete mode 100644 images/apisix-ingress-controller/verify.sh delete mode 100644 images/apisix/README.md delete mode 100644 images/apisix/catalog.env delete mode 100644 images/apisix/docker-entrypoint.sh delete mode 100644 images/apisix/keycloak-authz.lua delete mode 100644 images/apisix/source.Dockerfile delete mode 100644 images/apisix/source.build.env delete mode 100755 images/apisix/verify.sh delete mode 100644 images/argocd/README.md delete mode 100644 images/argocd/catalog.env delete mode 100755 images/argocd/go-mod-upgrade.sh delete mode 100644 images/argocd/source.Dockerfile delete mode 100644 images/argocd/source.build.env delete mode 100755 images/argocd/verify.sh delete mode 100644 images/cloudnative-pg/README.md delete mode 100644 images/cloudnative-pg/catalog.env delete mode 100644 images/cloudnative-pg/source.Dockerfile delete mode 100644 images/cloudnative-pg/source.build.env delete mode 100755 images/cloudnative-pg/verify.sh delete mode 100644 images/cnpg-postgresql/README.md delete mode 100644 images/cnpg-postgresql/catalog.env delete mode 100644 images/cnpg-postgresql/suse.Dockerfile delete mode 100644 images/cnpg-postgresql/suse.build.env delete mode 100755 images/cnpg-postgresql/verify.sh delete mode 100644 images/etcd/README.md delete mode 100644 images/etcd/catalog.env delete mode 100644 images/etcd/source.Dockerfile delete mode 100644 images/etcd/source.build.env delete mode 100755 images/etcd/verify.sh delete mode 100644 images/keycloak/README.md delete mode 100644 images/keycloak/catalog.env delete mode 100755 images/keycloak/overlay-jars.sh delete mode 100644 images/keycloak/suse.Dockerfile delete mode 100644 images/keycloak/suse.build.env delete mode 100755 images/keycloak/verify.sh create mode 100644 scripts/build/apply-published-tags.py delete mode 100755 scripts/build/build-hardened-image.sh delete mode 100755 scripts/build/suggest-go-upgrades.py diff --git a/.claude/deploy-test-procedure.md b/.claude/deploy-test-procedure.md index 6e68573..651d9f7 100644 --- a/.claude/deploy-test-procedure.md +++ b/.claude/deploy-test-procedure.md @@ -8,12 +8,12 @@ CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수 ## CNPG (`cnpg-postgresql` 이미지) — 자동화됨 -`build-image.yml` 이 새 이미지로 카탈로그 PR 을 열면, 병합 전에 로컬 kubeconfig 로 -`scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만 빠르게 확인한다(PR 본문에도 -안내됨). +`catalog-tag-update.yml` 이 새 이미지 태그로 카탈로그 브랜치를 push 하면, 병합 전에 로컬 +kubeconfig 로 `scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만 +빠르게 확인한다(브랜치의 Job Summary 에도 안내됨). ```sh -IMAGE_NAME=docker.io/wbsong111/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out +IMAGE_NAME=docker.io/paasup/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out ``` - **Operator(`cloudnative-pg`)는 상시 컴포넌트다** — release `cnpg`, namespace diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md deleted file mode 100644 index b2c40a5..0000000 --- a/.claude/image-authoring.md +++ /dev/null @@ -1,485 +0,0 @@ -# 자체 빌드 이미지 작업 규칙 - -**자체 빌드 축의 단일 출처다.** 새 자체 빌드 이미지를 추가하거나 기존 빌드 정의를 바꿀 때, -그리고 `build-image.yml` 이 무엇을 하는지 알아야 할 때 여기를 본다. - -카탈로그의 CVE 파이프라인은 두 축이고 이 문서는 **"이미지를 어떻게 만드는가"** 를 갖는다. -**"우리가 배포하는 이미지에 무엇이 있는가"**(`sbom.yml`·`cve-edge-post.yml`, 스캔·게이트 -메커니즘)는 [doc/sbom-pipeline.md](../doc/sbom-pipeline.md) 가 단일 출처다 — 여기 복제하지 않는다. - -> **레포 분리 시 이 파일은 `images/` · `scripts/build/` · `build-image.yml` 과 함께 이동한다.** -> 커스텀 이미지가 별도 레포로 나가는 것이 계획이고, 그때 끊기는 결합점은 아래 -> "레포 분리 후 무엇이 끊기는가" 에 정리돼 있다. - -security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅했다. 자체 빌드 이미지는 -`images//` 에 있고(목록은 그 디렉토리가 단일 출처) `docker.io/paasup` 에 push 되어 -카탈로그 values 가 그 태그를 참조한다. 아래는 프레임워크가 어떻게 동작하는지와, -이미지를 추가·변경할 때 지켜야 할 규칙이다. - -## 원칙 1 — 오케스트레이션은 항상 하나, 이미지 종류는 몰라도 된다 - -**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.** -이미지가 OS 패키지를 재설치하는 것이든, 소스를 직접 컴파일하는 것이든 스크립트는 -같다 — 차이는 전부 `images//` 안에 있다. - -**새 오케스트레이션 스크립트를 만드는 것은 최후의 수단이다.** "이 이미지는 성격이 -다르다"는 이유만으로 새 스크립트를 만들지 않는다. 절차(빌드 → 기능검증 → SBOM → 스캔 -→ 게이트 → push)는 이미지 종류와 무관하게 동일하고, 차이는 Dockerfile 내부(무엇을 -어떻게 설치·컴파일하는가)에만 있어야 한다. - -### `build-hardened-image.sh` 가 요구하는 계약 - -`images//.build.env` 가 다음을 선언하면 스크립트는 이미지 종류를 -몰라도 된다: - -| 키 | 의미 | -| --- | --- | -| `DOCKERFILE` | `images//` 기준 상대 경로 | -| `TARGET` | `docker build --target` 에 넘길 스테이지명 | -| `BUILD_ARGS` | 공백 구분 변수명 목록. 여기 나열한 것만 `--build-arg` 로 전달된다 | -| `APP_VERSION` | 태그 프리픽스·`verify.sh` 전달용 범용 버전 문자열 (유일한 필수값) | - -그 외 이미지별 변수(예: `PG_MAJOR`, `SOURCE_COMMIT`)는 build.env 에 적기만 하면 **자동으로 -`verify.sh` 의 환경변수로 전달된다** — 스크립트가 무엇을 넘겨야 하는지 알 필요가 없다. - -### `verify.sh` 는 호스트에서 bash 로 실행된다 - -게스트 컨테이너에 stdin 으로 셸 스크립트를 주입하는 방식이 아니다 — -`env TAG=... PLATFORM=... bash images//verify.sh` 로 호출된다. -**이유**: 이미지에 셸이 없을 수 있다(distroless 계열 최종 이미지는 `/bin/sh` 가 없다). -호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로 게스트 -스크립트를 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로 직접 -실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야 -통과로 판정된다. - -## 원칙 2 — 최종 런타임 베이스 OS 는 SUSE BCI - -**배포판은 결정됐다(2026-08-07, `keycloak` 이미지 추가 PR).** 그전까지는 -"security-catalog 의 SUSE BCI 단일화 결정을 이식하지 않았다" 는 미결 상태였다. -`keycloak` 은 업스트림이 UBI9 기반이라 "업스트림과 최대한 동일하게" 와 정면으로 -충돌했고, **카탈로그 내 일관성을 우선**했다 — 먼저 들어온 이미지들이 전부 BCI 이고 -trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다 -(게이트의 `CoverageProbe` — [doc/sbom-pipeline.md](../doc/sbom-pipeline.md)). - -- 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 **무엇을 우선했는지와 왜인지를 - 해당 이미지 README 에 남긴다.** 정책이 있다고 기록을 생략하지 않는다. -- 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — - 공식 언어 이미지(`golang` 등)를 그대로 써도 된다. 정책이 적용되는 것은 **스캔·배포 - 대상인 최종 스테이지**뿐이다. - -### 어느 BCI 변종을 쓸지는 "런타임이 무엇을 필요로 하는가" 로 정한다 - -카탈로그의 8개 이미지가 실제로 쓰는 조합은 셋뿐이다. 새 이미지는 이 중 하나를 고른다 — -네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다. - -| 최종 베이스 | 고르는 조건 | 대가 | 쓰는 이미지 | -| --- | --- | --- | --- | -| `bci-base` | 런타임이 OS 패키지·셸을 쓴다 (`zypper` 로 앱을 설치, entrypoint 가 셸 스크립트) | 표면적이 가장 크다 | `adc` · `apisix` · `argocd` · `cnpg-postgresql` | -| `bci-micro` | 정적 링크 바이너리 하나만 실행한다 | 패키지 매니저가 없다. `sed`·`grep`·`find` 도 없다. nonroot 계정을 직접 만들어야 한다 | `apisix-ingress-controller` · `cloudnative-pg` · `etcd` | -| `scratch` + micro rootfs | 런타임 구성을 builder 에서 통째로 조립한다 (JVM + 앱 트리) | rootfs 조립을 직접 책임진다("씨앗" 방식 필수, 아래) | `keycloak` | - -`bci-micro`·`scratch` 를 고르면 **업스트림이 distroless `:nonroot` 태그로 공짜로 얻던 것을 -직접 만들어야 한다.** 실측된 형태는 이렇다. - -```dockerfile -# bci-micro 는 root 만 있다 — distroless 의 nonroot 변종에 해당하는 태그가 없다 -RUN echo 'nonroot:x:65532:65532:nonroot:/home/nonroot:/bin/false' >> /etc/passwd; \ - echo 'nonroot:x:65532:' >> /etc/group; \ - mkdir -p /home/nonroot; chown 65532:65532 /home/nonroot -USER 65532:65532 -``` - -`bci-base` 는 `groupadd`/`useradd` 가 있으므로 그것을 쓴다(`images/adc` 참고). -**uid·gid 는 업스트림 값을 그대로 쓴다** — 임의로 바꾸면 볼륨 권한이 깨진다. - -### 어느 BCI 버전을 쓸지는 이미지마다 실측해서 정한다 - -**"최신 BCI 를 쓴다" 는 규칙을 두지 않는다.** 새 SLE 메이저의 SLE_BCI 저장소가 특정 -패키지에서 구버전에 뒤처져 있을 수 있고, 그러면 최신 베이스가 오히려 CVE 를 남긴다. - -실례 — `keycloak` 이미지에서 15.7 을 고른 근거(2026-08-07 실측): - -| BCI | `java-21-openjdk-headless` | -| --- | --- | -| 15.7 | `21.0.12.0-150600.3.29.1` | -| 16.0 | `21.0.11.0-160000.2.1` | - -`21.0.12` 가 CVE-2026-41254·CVE-2026-47063 의 수정 버전이라 16.0 으로 갔으면 차단 -CVE 2건이 그대로 남았다. **이 이미지가 실제로 필요로 하는 패키지의 버전을 후보 태그마다 -직접 재고 결과를 `build.env` 주석에 남긴다.** - -```sh -docker run --rm registry.suse.com/bci/bci-base:<태그> \ - sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info <패키지>' -``` - -새 버전으로 올릴 때는 trivy 의 해당 SLE 버전 커버리지도 다시 확인한다 — -`CoverageProbe` 가 `none` 이면 findings 0 이 진짜 0 이 아니다(게이트가 막는다). - -**CVE 수치만 재고 고르면 배포에서 죽는다.** 베이스 OS 는 런타임이 요구하는 도구의 **버전**도 -같이 바꾼다. 실측(2026-08-19, `argocd`): argo-cd 차트의 repo-server init 컨테이너가 -`cp --update=none` 을 쓰는데 이 형식은 GNU coreutils 9.3+ 에서만 된다. BCI 15.7 로 빌드한 -이미지가 **게이트도 `verify.sh` 도 통과하고 배포 시점에** `Init:CrashLoopBackOff` 로 죽었다. -스캐너는 "설치된 패키지에 알려진 CVE 가 있는가" 만 보지 "이 이미지를 쓰는 차트가 무엇을 -요구하는가" 는 전혀 보지 못한다. - -→ **차트가 이 이미지에 대고 실제로 실행하는 명령을 `verify.sh` 에서 그대로 재현한다.** -베이스를 바꿀 때 빌드 단계에서 걸린다. `images/argocd/verify.sh` 의 "차트가 실제로 실행하는 -명령" 절이 예다. - -### `bci-micro` 위에 패키지를 얹을 때 — rootfs 는 반드시 "씨앗" 방식으로 - -`bci-micro` 는 패키지 매니저가 없지만 **rpmdb 는 갖고 있다** -(`/usr/lib/sysimage/rpm`, 2026-08-07 실측). 빈 installroot 에 설치한 rootfs 를 micro -위에 그냥 덮으면 **micro 의 rpmdb 가 가려져 micro 자체 패키지가 SBOM 에서 통째로 -사라진다** — CVE 가 줄어드는 게 아니라 스캔 사각지대가 생기는 것이다. - -micro 의 파일시스템을 씨앗으로 깔고 그 위에 설치한다: - -```dockerfile -FROM registry.suse.com/bci/bci-micro:15.7 AS micro -FROM registry.suse.com/bci/bci-base:15.7 AS builder -COPY --from=micro / /rootfs -RUN rpm --root /rootfs --import /usr/lib/rpm/gnupg/keys/*.asc && \ - zypper --non-interactive --installroot /rootfs --gpg-auto-import-keys refresh && \ - zypper --non-interactive --installroot /rootfs install -y --no-recommends <패키지들> -FROM scratch AS final -COPY --from=builder /rootfs/ / -``` - -`rpm --import` 를 빠뜨리면 설치되는 패키지마다 `NOKEY` 경고로 개별 서명 검증이 -생략된다. 빌드 후 SBOM 의 OS 패키지 수도 반드시 확인한다 — 한 자릿수로 떨어졌으면 -마스킹이 일어난 것이다. 실례는 `images/keycloak/suse.Dockerfile`. - -### `bci-micro` 에 없는 흔한 도구 (2026-08-07 실측) - -`sed`·`grep`·`find` 가 **셋 다 없다**. `bash`·`coreutils`·`readlink`·`dirname`· -`uname`·`locale` 은 있다. - -- 최종 이미지의 앱이 이 도구들을 쓰면(예: Keycloak `bin/kc.sh` 는 `sed`·`grep` 을 - 쓴다) 런타임 패키지 목록에 **명시적으로 넣어야 한다.** -- `verify.sh` 의 **게스트** 스크립트는 이 도구들에 의존하지 말고 순수 셸 루프 - (`while IFS= read -r line; do ...; done`)로 작성한다 — 이미지마다 설치 여부가 다르다. - -### SLE 패키지명이 RHEL/Debian 과 다른 것들 (실측) - -| 다른 배포판 | SLE_BCI 15.7 | -| --- | --- | -| `tzdata` | `timezone` | -| `tzdata-java` | **없음** (JDK 내장 tzdb 사용) | -| `glibc-langpack-en` | `glibc-locale-base` | -| `coreutils-single` | `coreutils` | - -업스트림 Dockerfile 의 패키지 목록을 그대로 옮기면 `No provider of '...' found` 로 -빌드가 실패한다. `zypper -n search -t package '<패턴>'` 로 먼저 확인한다. - -## 언어별 빌더 규칙 - -베이스 OS(위 원칙 2)가 **최종 스테이지**를 정하고, 여기가 **빌더 스테이지**를 정한다. 두 축은 -독립이다 — 같은 `bci-micro` 최종 위에 Go 빌더가 오기도 하고(`etcd`) Node 빌더가 오기도 한다. - -빌더는 원칙 2의 대상이 아니다(최종 이미지에 남지 않으므로 공식 언어 이미지를 그대로 쓴다). -대신 **버전을 값으로 빼서 `build.env` 에 두는 것**이 모든 언어에 공통이다 — Dockerfile 에 박으면 -CVE 조치마다 Dockerfile 을 고쳐야 한다. - -### 공통 — 무엇을 `build.env` 로 빼는가 - -| 성격 | 예 | -| --- | --- | -| 빌더 이미지 태그 | `GO_BUILDER_TAG` · `NODE_BUILDER_TAG` · `BUILDER_BASE` | -| 업스트림 소스 지점 | `SOURCE_COMMIT`(pinned commit) · `APP_VERSION` | -| 취약 의존성 강제 버전 | `GO_MODULE_UPGRADES` · `_FIX_VERSION` · `_OLD`/`_VERSION` 쌍 | -| 패키지 목록 | `BUILDER_PACKAGES` · `RUNTIME_PACKAGES` | - -`BUILD_ARGS` 에 나열하지 않은 변수는 `--build-arg` 로 전달되지 않는다 — 값을 추가하면 -`BUILD_ARGS` 도 같이 고친다(빠뜨리면 조용히 기본값으로 빌드된다). - -### Go - -```dockerfile -ARG GO_BUILDER_TAG=1.26.6-trixie # 전역 스코프 — FROM 에 쓰이므로 -FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder -ARG TARGETARCH # 크로스 컴파일: 빌더는 호스트 아치, 산출물은 타깃 -``` - -- **`--platform=$BUILDPLATFORM` + `TARGETARCH` 를 쓴다.** 에뮬레이션으로 빌더를 돌리지 않는다. -- **버전 문자열은 `ldflags` 로 주입한다.** 업스트림은 보통 `git rev-parse HEAD` 로 얻지만 우리는 - `.git` 없이 빌드하므로 `SOURCE_COMMIT` 을 직접 넣는다 — 넣지 않으면 `verify.sh` 의 버전 검사가 - 깨지고, 무엇을 빌드했는지 이미지가 스스로 증언하지 못한다. -- **취약 모듈 강제 업그레이드는 세 형태가 있다.** 아래 "Go 모듈 CVE" 절이 값 산출을 담당한다. - - | 형태 | 쓰는 경우 | 예 | - | --- | --- | --- | - | `GO_MODULE_UPGRADES` + `go-mod-upgrade.sh` | 한 이미지가 여러 Go 프로젝트를 빌드한다 | `argocd` | - | `go.work` 전역 `replace` | 업스트림이 워크스페이스를 쓴다 | `etcd` | - | 개별 `_FIX_VERSION` | 취약 모듈이 소수로 고정돼 있다 | `apisix-ingress-controller` | - - 세 번째를 새로 만들지 않는다 — 모듈이 하나든 둘이든 `GO_MODULE_UPGRADES` 로 통일하는 쪽이 - 재빌드 판정(`check-rebuild-needed.py`)이 자동으로 다룰 수 있어 유리하다. -- **업스트림이 이미 백포트했으면 모듈을 올리지 말고 그 커밋을 쓴다** — 차이가 작을수록 좋다 - (`cloudnative-pg` 는 `release-1.30` HEAD 를 그대로 컴파일해 세 CVE 를 해소했다). - -### Node - -```dockerfile -FROM ${BUILDER_BASE} AS builder # node:lts-* 또는 node:${NODE_BUILDER_TAG} -FROM ${RUNTIME_BASE} AS final # bci-base -RUN zypper -n install -y ${NODE_PKG} && zypper -n clean --all -``` - -- **런타임 Node 는 빌더 Node 와 별개다.** 빌더는 공식 `node` 이미지, 런타임은 **OS 패키지** - (`nodejs24`)를 쓴다 — 런타임 쪽이 스캔 대상이므로 벤더가 패치하는 경로에 둔다. -- 두 메이저가 어긋나지 않게 맞춘다. `NODE_PKG` 도 `build.env` 값이다. -- 번들 산출물(`main.cjs` 등) 하나만 복사한다 — `node_modules` 를 최종에 넣지 않는다. - -### JVM (jar 교체형) - -업스트림이 배포하는 tarball 을 풀고 **취약 jar 만 갈아끼운다.** 재컴파일하지 않는다. - -```dockerfile -ARG NETTY_OLD # 지금 들어있는 버전 -ARG NETTY_VERSION # 갈아끼울 버전 -``` - -- **`OLD`/`VERSION` 쌍으로 받는다.** `OLD` 를 명시하는 이유는 **교체 대상을 못 찾았을 때 실패** - 시키기 위함이다 — 업스트림이 버전을 올리면 조용히 지나가는 대신 빌드가 깨져야 한다. -- **BOM 이 pin 한 jar 는 태그 교체·베이스 OS 교체로 안 고쳐진다** — 그래서 자체 빌드다. -- `tzdata-java` 는 SLE_BCI 에 없다(JDK 내장 tzdb 를 쓴다). 패키지명 차이는 원칙 2의 표 참고. - -### C · Lua (소스 컴파일형) - -- **정적 링크를 시도하지 않는다.** SLE_BCI 에 static glibc 가 없다(실측). c-builder 스테이지와 - 최종 스테이지의 **베이스를 동일하게** 두고 동적 링크한다(`argocd` 의 `tini`·`connect-proxy`). -- 컴포넌트 버전이 여러 개면 전부 개별 `ARG` 로 뺀다(`apisix` 는 10개가 넘는다). -- SLE_BCI 에 없어 소스 빌드하는 도구는 **왜 없는지와 무엇을 대신하는지**를 주석에 남긴다 — - 기능을 빼는 것과 구분되어야 한다. - -## 업스트림 런타임 계약은 보존한다 - -CVE 를 없애려고 이미지를 바꾸는 것이지, **동작을 바꾸는 게 아니다.** 스캐너는 이걸 전혀 보지 -못하므로(원칙 2의 `cp --update=none` 실측) 아래는 사람이 지켜야 한다. - -- **`USER`·`ENTRYPOINT` 는 업스트림과 같게 유지한다.** uid 를 바꾸면 볼륨 권한이, entrypoint 를 - 바꾸면 차트의 `args` 가 깨진다. -- **업스트림이 만들던 파일 레이아웃을 그대로 만든다** — 심볼릭 링크, 빈 디렉토리, 권한까지. - 오퍼레이터·차트가 그것에 의존한다(`cloudnative-pg` 는 멀티아치 심볼릭 링크를 "부수 장치" 로 - 오판해 지웠다가 `invalid architecture` 로 리컨실이 실패했다). -- **차트가 이 이미지에 대고 실행하는 명령을 `verify.sh` 에서 재현한다.** 위 실측 참고. -- Dockerfile 상단에 **업스트림과의 대응 관계 표**를 남긴다(무엇이 동일하고 무엇이 다른지). - 기존 이미지들이 전부 이 형식을 갖고 있다. - -## 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` 에 추가해버리므로 그냥 넘기면 안 된다. - -### 핀은 가만히 있어도 뒤처진다 — 드리프트는 주간 스캔이 잡는다 - -버전 핀을 고정하는 것의 대가는 **소스를 안 바꿔도 새 CVE 가 공개되면 그 핀이 규정을 -벗어난다**는 것이다. 2026-08-19 에 실제로 났다: `etcd`·`cloudnative-pg` 가 -`GO_BUILDER_TAG=1.26.5-trixie` 에 묶여 새 stdlib 차단 CVE 8건에 걸렸는데, **문서 전용 PR 이 -우연히 `images/**` 를 건드려 검증 빌드가 돌면서** 발견됐다. - -그래서 `build-image.yml` 이 **주간(월요일 02:00 UTC)으로 스스로 확인하고 재빌드까지 한다.** - -``` -배포 중인 이미지 스캔 → check-rebuild-needed.py → (핀이 뒤처졌으면) 브랜치 push - → 빌드·verify.sh·게이트 -``` - -**트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다 — 핀이 뒤처졌는가가 아니다.** 처음에는 -핀 기준으로 잡았는데 실측에서 틀렸다. 배포 중인 이미지 8개를 스캔했더니 차단이 있는 4개 중 -핀 변경이 필요한 것은 하나도 없었다. 재빌드로 고쳐지는 경우가 셋이기 때문이다. - -| 경우 | 재빌드가 고치는 이유 | -| --- | --- | -| 핀이 뒤처졌다 | 핀을 올려서 빌드한다 | -| 핀은 맞는데 그 핀으로 아직 안 빌드됐다 | 핀만 고친 PR 이 머지된 직후가 이 상태다 | -| 베이스 OS 패키지가 뒤처졌다 | 재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다 | - -- **수정 버전이 없는 차단은 트리거가 아니다.** 재빌드해도 그대로다 — `no-fix` 로 따로 보고해 - 사람이 다른 레버(상위 태그·예외 승인)를 판단한다. -- **승인 예외(`doc/cve-exceptions.json`)를 적용한다.** 만료된 예외는 인정하지 않는다. -- **레지스트리 push 와 카탈로그 반영은 하지 않는다.** `push` 는 `workflow_dispatch` + - `mode=image` 에서만 켜진다. -- 수동으로 같은 것을 돌릴 때는 `mode=drift` 로 `workflow_dispatch` 한다. - -로컬에서는 이렇게 쓴다. - -```sh -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 -``` - -게이트가 "차단 8건" 까지 말하는 데서 한 걸음 더 가서 **어느 `build.env` 의 어느 값을 무엇으로 -바꾸면 되는지**를 낸다. 기준은 **카탈로그가 실제로 가리키는 이미지**다 — 재빌드해 보지 않고도 -"지금 배포 중인 것이 규정을 벗어났는가" 를 답한다(`catalog.env` → 카탈로그 values → 그 ref 의 -스캔 리포트 순으로 따라간다). 무엇을 스캔할지도 `--list-refs` 로 스크립트가 낸다 — ref 해석 -규칙이 워크플로로 새면 두 곳이 어긋난다. - -- **`--apply` 는 편집만 대신한다.** 커밋·빌드·검증은 그대로 사람이 한다 — 핀은 "우리가 무엇을 - 검증했는지의 기록" 이므로 자동 커밋하지 않는다. -- **`GO_MODULE_UPGRADES` 를 쓰지 않는 이미지**(예: `etcd` 는 `go.work` replace + - `XTEXT_FIX_VERSION` 을 쓴다)에는 값을 넣어도 무효라 자동 적용하지 않고 "수동 확인" 으로 - 보고한다. -- 판정 기준은 게이트와 같다(`max(벤더, NVD)`, 기본 HIGH). 즉 **드리프트 = 차단 CVE 를 만드는 - 뒤처짐**이고, 게이트를 통과하는 낮은 등급은 보고하지 않는다. - -**이 판정은 카탈로그와 이미지 정의 양쪽을 읽는다 — 레포 분리 시 갈라지는 지점이다.** -커스텀 이미지가 별도 레포로 나가면 "무엇을 배포 중인가"(카탈로그)와 "어떻게 만드는가"(이미지 -정의)가 다른 레포에 놓인다. 절단면은 `check-rebuild-needed.py` 의 함수 경계에 이미 있고 그 -docstring 이 어느 함수가 어느 쪽인지 적어둔다 — **탐지는 카탈로그 쪽에 남고, 핀 판단은 이미지 -쪽으로 간다.** 분리 작업 때 그 주석을 먼저 읽는다. - -## CI — `build-image.yml` - -이 축의 워크플로다. 실행기는 `scripts/build/build-hardened-image.sh` 하나이고 빌드 → `verify.sh` -→ SBOM → `scan-sbom.sh` → `cve-gate.py` 를 순서대로 부른다 — 스캔·게이트는 차트 축의 스크립트를 -그대로 재사용한다([doc/sbom-pipeline.md](../doc/sbom-pipeline.md)). - -**이 워크플로의 게이트는 강제다.** 차트 축의 `sbom.yml` 이 `--warn-only` 인 것과 다르다 — -게이트가 실패하면 push 도 카탈로그 반영도 일어나지 않는다. - -| 트리거 | 대상 결정 | 빌드·검증·게이트 | 레지스트리 push | 카탈로그 태그 갱신 | -|--------|----------|------------------|-----------------|-------------------| -| `workflow_dispatch` | `image` 입력(필수) · `base_os`(비우면 `catalog.env` 의 `DEFAULT_BASE_OS`) · `push`(기본 `true`) | ✅ | `push=true` 일 때만 | 게이트 PASS + 태그 변경 시 | -| `pull_request` (`images/**`) | 변경된 `images//` 를 diff 로 자동 탐지(복수면 매트릭스 병렬) | ✅ | ❌ | ❌ | -| `schedule` (`0 2 * * 1`, 매주 월요일 02:00 UTC) · `mode=drift` | 배포 중인 이미지를 스캔해 재빌드가 필요한 것만 | ✅ | ❌ | ❌ | - -PR 트리거가 push 하지 않는 것은 `REGISTRY` 를 넘기지 않기 때문이다 — 태그가 `localhost/...` 로 남아 -push 를 시도할 수조차 없다(검증 전용 안전장치). - -- **`schedule` 이 있지만 블라인드 정기 재빌드는 아니다.** 예전에는 트리거가 없었고 그 이유가 - "블라인드 재빌드는 정말 개선인지를 매번 되묻게 만든다" 였다. 지금 것은 **수정 버전이 있는 차단 - CVE 가 있을 때만** 빌드하고 없으면 아무것도 하지 않는다 — 거부된 것과 조건이 다르다. - 판정은 아래 "핀은 가만히 있어도 뒤처진다" 절이 갖는다. -- **`sbom.yml` 의 게이트가 이 워크플로를 자동 호출하지 않는다.** 차트 축이 차단 CVE 를 찾아도 - 자체 빌드로 갈지는 사람이 판단한다 — 두 축 사이에 자동 연결은 없다. 이 워크플로의 `schedule` 은 - **자기 이미지만** 본다(배포 중인 자체 빌드 이미지). -- 레지스트리는 `REGISTRY_HOST: docker.io/paasup` 고정. 인증은 `DOCKERHUB_USER`/`DOCKERHUB_TOKEN`. - -### 카탈로그 반영 — PR 은 자동 생성되지 않는다 - -게이트 PASS 이고 새 태그가 현재 카탈로그 태그와 다르면, 워크플로가 `catalog.env` 의 `CHART_DIRS` 아래 -`custom-values.yaml`/`dip-values.yaml` 을 `scripts/build/patch-catalog-tag.py` 로 갱신하고 -`build/-<타임스탬프>` 브랜치를 push 한다. **여기까지가 자동이다.** - -PR 오픈은 사람이 한다 — `GITHUB_TOKEN` 으로 PR 을 만드는 것 자체를 조직 정책이 막는다("Allow GitHub -Actions to create and approve pull requests" 미허용, 리포 설정으로 변경 불가). 2026-08-04 실측으로 -확인했고(run 30882785612, GraphQL: `GitHub Actions is not permitted to create or approve pull requests`), -`gh pr create` 호출은 워크플로에서 제거했다. 대신 Job Summary 에 compare 링크가 남는다. - -병합 전에 사람이 해야 하는 것: - -1. compare 링크로 PR 오픈. -2. `gh workflow run helm-catalog-sbom --ref <브랜치>` 로 카탈로그 게이트 확인. -3. **배포 검증** — 게이트 PASS 는 "동작한다"를 증명하지 않는다(스캐너는 런타임 요구사항을 보지 못한다). - 절차: [deploy-test-procedure.md](deploy-test-procedure.md). - -> **매핑이 불완전하면 조용히 지나간다.** `patch-catalog-tag.py` 는 "예상 패턴을 못 찾으면 실패" -> 하도록 만들어져 있지만 **애초에 `CHART_DIRS` 에 없는 파일은 검사조차 하지 않는다.** 실측 -> (2026-08-20): `cnpg-postgresql` 의 `CHART_DIRS` 가 `cnpg-cluster/1.0.0` 만 담고 있어 같은 -> 이미지를 쓰는 `1.1.0` 이 낡은 태그로 남았고, 게이트만 계속 차단으로 잡았다. 카탈로그는 여러 -> 버전을 동시에 보관하므로 **이미지 하나가 여러 버전 디렉토리에 걸리는 것이 정상**이다 — -> 새 버전 디렉토리를 만들 때 `catalog.env` 를 함께 본다. - -### 레포 분리 후 무엇이 끊기는가 - -커스텀 이미지가 별도 레포로 나가면 이 축은 **"어떻게 만드는가"** 만 갖고, **"무엇을 배포 중인가"** -는 카탈로그 레포에 남는다. 지금 한 레포 안이라 보이지 않는 결합이 그때 드러난다. - -| # | 결합점 | 분리 후 필요한 것 | -|---|---|---| -| 1 | `patch-catalog-tag.py` 가 카탈로그 values 를 **쓴다** | 카탈로그 쪽이 자기 파일을 쓰게 한다(반영 워크플로) | -| 2 | `catalog.env` 의 `CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK` | **카탈로그 레이아웃 정보다** — 카탈로그 쪽으로 옮긴다 | -| 3 | `check-rebuild-needed.py` 가 카탈로그 values 를 **읽어** 배포 중 ref 를 해석 | **탐지는 카탈로그 쪽에 남는다** (그 파일 docstring 의 절단면 참고) | -| 4 | `build-hardened-image.sh` 가 `scan-sbom.sh`·`cve-gate.py` 를 부른다 | 게이트를 composite action 으로 공개해 양쪽이 `uses:` | -| 5 | `doc/cve-exceptions.json` 공유 | 카탈로그가 소유하고 action 입력으로 받는다(예외는 "무엇을 감수하는가") | -| 6 | `build/-` 브랜치를 카탈로그 레포에 push | 카탈로그 쪽이 자기 브랜치를 만든다 | -| 7 | `images/` 가 이미지 목록의 단일 출처 (여러 문서가 이 경로 참조) | 참조 갱신 | - -**3번이 가장 크다** — 드리프트 탐지는 "카탈로그가 가리키는 이미지" 를 기준으로 재는 것이 설계의 -핵심인데 이미지 레포는 카탈로그를 갖고 있지 않다. 그래서 **탐지는 카탈로그, 빌드는 이미지 레포** -로 갈라야 한다. - -**게이트를 공유할 때 `workflow_call` 은 쓸 수 없다** — 별도 job 으로 돌아 파일시스템을 공유하지 -않는데 게이트는 스캔과 **같은 job 에서 `$OUT_DIR` 를 읽어야** 한다. composite action 이어야 한다. -참고로 `SBOM_PIPELINE_IMAGE` 는 도구만 담고 있어(`Dockerfile` 에 `COPY` 가 없다) 그것만으로는 -`cve-gate.py` 가 따라오지 않는다. - -## 두 가지 유형 (둘 다 같은 스크립트를 쓴다) - -| 유형 | Dockerfile 이 하는 일 | 셸 유무 | -| --- | --- | --- | -| OS 패키지 재설치형 | 업스트림이 배포하는 산출물을 다른 배포판(zypper/apt 등)에 재설치 | 보통 있음 — 게스트 스크립트로 검증 | -| 소스 컴파일형 | 업스트림 pinned commit 을 `go build` 등으로 직접 컴파일 | 최종 베이스에 따라 다름 | - -어느 유형이든 이 셋만 새로 쓰면 된다: `.Dockerfile`, `.build.env`, -`verify.sh`(+ `README.md`). - -## 신규 이미지 추가 체크리스트 - -1. **상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가.** 그것으로 해소되면 - 자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에 - 정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는 - 원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는 `MEMORY.md`) -2. **어느 유형인지 판단한다** ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접 - 컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2) -3. `images//.Dockerfile`·`.build.env`·`verify.sh`·`README.md` - 작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다. - **`FROM` 에 쓰는 `ARG` 는 반드시 파일의 첫 `FROM` 이전(전역 스코프)에 선언한다** — - 스테이지 내부(어떤 `FROM` 뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후 `FROM` 의 - 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다 -4. 로컬 빌드: - ```sh - IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh /tmp/out - ``` - `cve-gate.md` 로 실효 C/H 0 확인. 커버리지 자가진단(`CoverageProbe`)이 `ok` 인지도 - 확인 — `none` 이면 findings 0건이 진짜 0건이 아니라 스캐너에 그 배포판 데이터가 - 없다는 뜻이므로 게이트가 차단한다(`doc/sbom-pipeline.md` 참고) -5. **게이트 PASS 는 "동작한다" 를 증명하지 않는다.** CVE 스캐너는 CVE 와 무관한 런타임 - 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. - 실제 배포 검증을 반드시 한다 — 절차와 스크립트는 - [deploy-test-procedure.md](deploy-test-procedure.md) 에 있다(cnpg·etcd 는 전용 스크립트, - 그 외는 수동 절차). 업스트림과 다르게 만든 부분은 전부 이유를 확인하고 남긴다. -6. 카탈로그 values(`custom-values.yaml`/`dip-values.yaml` 등) 갱신, 조사·결정 근거를 - PR 설명과 `MEMORY.md`에 기록. `images/**`+`manifests/helm/**` 는 PR 로. -7. CI 자동화: `build-image.yml` 은 이미 `image` 입력으로 파라미터화돼 있다 — - `images//catalog.env` 만 추가하면 별도 워크플로 수정 없이 태울 수 있다. - -## SBOM·스캔·게이트는 절대 다시 만들지 않는다 - -`scripts/pipeline/scan-sbom.sh` · `scripts/pipeline/cve-gate.py` 는 이미지 종류와 무관하게 -동작하며 커버리지 자가진단(`CoverageProbe`)도 포함한다(`doc/sbom-pipeline.md` 참고). -`build-hardened-image.sh` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다. diff --git a/.claude/pitfalls.md b/.claude/pitfalls.md index 0624ffe..d780788 100644 --- a/.claude/pitfalls.md +++ b/.claude/pitfalls.md @@ -18,12 +18,6 @@ security-catalog 프로젝트에서 CNPG/etcd 자체 빌드·배포 테스트 > dip-catalog 의 `scan-sbom.sh` 는 두 번째 양상(데이터 커버리지 부재)을 구분하는 자가진단 > (`CoverageProbe`)을 이식했다(2026-08-03) — `doc/sbom-pipeline.md` 참고. -## 이미지 태그의 베이스 OS 를 확인할 것 - -같은 앱 버전이라도 태그에 따라 베이스 OS 가 다르고 EOL 이 임박한 것이 섞여 있다. -오퍼레이터가 배포판 수명 테이블을 갖고 있으면 기동 로그에 남는다 -(CNPG: `internal/cmd/manager/instance/run/osdb.go`). - ## `kubectl get cluster` 는 쓰면 안 된다 dev 클러스터에 `clusters` 단축명을 쓰는 CRD 가 3개 있다(CNPG 배포 테스트 대상 클러스터 diff --git a/.claude/skills/cve-remediation/SKILL.md b/.claude/skills/cve-remediation/SKILL.md index e27faf6..28a20c1 100644 --- a/.claude/skills/cve-remediation/SKILL.md +++ b/.claude/skills/cve-remediation/SKILL.md @@ -1,12 +1,13 @@ --- name: cve-remediation -description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate·self-build-image 사이 결정 단계만 담당하며 둘의 절차는 복제하지 않는다. +description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate 로 게이트를 해석하는 것과 security-images 레포에서 자체 빌드를 실행하는 것 사이의 결정 단계만 담당하며 둘의 절차는 복제하지 않는다. --- # 차단 CVE 대응 절차 이 skill 은 실행하지 않는다 — 레버를 판단해 실행 skill로 넘긴다. 게이트 실행/해석은 -`sbom-cve-gate`, 자체 빌드는 `self-build-image`, 배포 검증은 +`sbom-cve-gate`, 자체 빌드는 **별도 레포 `security-images`**(그 레포의 +`docs/image-authoring.md`가 단일 출처), 배포 검증은 [deploy-test-procedure.md](../../deploy-test-procedure.md)가 단일 출처다 — 여기 반복 안 한다. ## 흐름 @@ -35,8 +36,11 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH) FixedVersion 이상인지, 같은 파일이 SBOM에서 컴포넌트 두 개로 잡히지 않는지(PkgPath 비교) 확인한다. 실례: keycloak `CVE-2025-59250`(mssql-jdbc jar 하나가 두 컴포넌트로 잡혀 잘린 쪽만 매칭된 오탐). 근거·만료일은 `doc/cve-exceptions.json`. -6. 자체 빌드는 `self-build-image` + [image-authoring.md](../../image-authoring.md)로, - 태그/베이스 OS 교체는 카탈로그 values만 바꾸고 재게이트한다 — 별도 스크립트 없음. +6. 자체 빌드는 별도 레포 `security-images`에서 한다 — 그 레포의 + `docs/image-authoring.md`가 절차 단일 출처다. 재빌드가 필요하면 그 레포의 + `build-image.yml`을 `workflow_dispatch`로 부른다(`scripts/build/check-rebuild-needed.py` + 가 배포 중인 이미지를 스캔해 대상을 판단한다). 태그/베이스 OS 교체는 카탈로그 + values만 바꾸고 재게이트한다 — 별도 스크립트 없음. 7. 수정 후 반드시 재게이트하고 PASS라도 배포 검증까지 끝나야 종료다 — 1회로 끝난다고 가정하지 않는다(keycloak은 1차 수정 후 재스캔에서 micrometer 2건이 새로 잡혔다). 8. 착지는 브랜치 push까지다 — 조직 정책상 `GITHUB_TOKEN`으로 PR을 못 연다. 사람이 PR을 @@ -44,5 +48,5 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH) ## 참고 -- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · [self-build-image](../self-build-image/SKILL.md) +- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · 자체 빌드는 별도 레포 `security-images` - 함정: [pitfalls.md](../../pitfalls.md) · 미결 사항: [MEMORY.md](../../../MEMORY.md) diff --git a/.claude/skills/sbom-cve-gate/SKILL.md b/.claude/skills/sbom-cve-gate/SKILL.md index dcb6e83..cb5e777 100644 --- a/.claude/skills/sbom-cve-gate/SKILL.md +++ b/.claude/skills/sbom-cve-gate/SKILL.md @@ -55,8 +55,8 @@ gh run watch --repo /dip-catalog 상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인 ``` -자체 빌드로 가야 한다면 `self-build-image` skill과 -[.claude/image-authoring.md](../../image-authoring.md)를 따른다. 판정 로직 상세는 +자체 빌드로 가야 한다면 별도 레포 `security-images`에서 한다 — 그 레포의 +`docs/image-authoring.md`가 절차 단일 출처다. 판정 로직 상세는 `scripts/pipeline/cve-gate.py`의 모듈 docstring을 1차 출처로 본다. ## 참고 diff --git a/.claude/skills/self-build-image/SKILL.md b/.claude/skills/self-build-image/SKILL.md deleted file mode 100644 index ea73731..0000000 --- a/.claude/skills/self-build-image/SKILL.md +++ /dev/null @@ -1,108 +0,0 @@ ---- -name: self-build-image -description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, argocd, cloudnative-pg, cnpg-postgresql, etcd, keycloak 8종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처). ---- - -# 자체 빌드 하드닝 이미지 - -CVE 게이트 대응 우선순위(상위 태그 교체 → 베이스 OS 교체 → **자체 빌드** → 예외 승인)에서 -앞의 두 단계로 해소가 안 될 때만 온다. - -## 원칙 — 오케스트레이션은 항상 하나다 - -**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.** -OS 패키지 재설치든 소스 컴파일이든 스크립트는 같고, 차이는 전부 `images//` 안에 있다. - -**"이 이미지는 성격이 다르다"는 이유로 새 오케스트레이션 스크립트를 만들지 않는다** — -절차(빌드 → 기능검증 → SBOM → 스캔 → 게이트 → push)는 이미지 종류와 무관하게 동일하다. -SBOM·스캔·게이트도 다시 만들지 않는다 — `build-hardened-image.sh`가 이미 -`scan-sbom.sh`/`cve-gate.py`를 호출한다. - -## 실행 - -```sh -IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh /tmp/out -``` - -`images//.build.env`가 계약(`DOCKERFILE`, `TARGET`, `BUILD_ARGS` 등)을 -선언하면 스크립트는 이미지 종류를 몰라도 된다. 전체 계약표와 신규 이미지 추가 7단계 절차는 -[.claude/image-authoring.md](../../image-authoring.md)에 있다 — 여기 복제하지 않는다. - -CI는 `build-image.yml`이 `image` 입력으로 이미 파라미터화돼 있다. `images//catalog.env`만 -추가하면 워크플로 수정 없이 태울 수 있다. - -## 새 Dockerfile 은 두 축을 먼저 고른다 - -**작성 규칙 본문은 [.claude/image-authoring.md](../../image-authoring.md)가 단일 출처다** — -여기 복제하지 않는다. 이 표는 "어느 절을 읽어야 하는가" 만 정한다. - -두 축은 **독립**이다. 같은 `bci-micro` 최종 위에 Go 빌더가 오기도 하고 Node 빌더가 오기도 한다. - -**축 1 — 최종 런타임 베이스** (스캔·배포 대상. 원칙 2) - -| 런타임이 필요로 하는 것 | 고른다 | 선례 | -|---|---|---| -| OS 패키지·셸 (zypper 설치, 셸 entrypoint) | `bci-base` | `adc` `apisix` `argocd` `cnpg-postgresql` | -| 정적 링크 바이너리 하나뿐 | `bci-micro` | `apisix-ingress-controller` `cloudnative-pg` `etcd` | -| 런타임 트리를 builder 에서 조립 (JVM 등) | `scratch` + micro rootfs 씨앗 | `keycloak` | - -→ 세 조합 밖으로 나가려면 **왜 셋으로 안 되는지 먼저 적는다.** `micro`/`scratch` 는 -nonroot 계정·`sed`/`grep` 부재를 직접 감당해야 한다(image-authoring.md "어느 BCI 변종"). - -**축 2 — 빌더 스테이지** (최종에 남지 않음. 원칙 2 대상 아님 → 공식 언어 이미지 그대로) - -| 언어 | 빌더 | 핀 키 | -|---|---|---| -| Go | `golang:${GO_BUILDER_TAG}` + `$BUILDPLATFORM`/`TARGETARCH` | `GO_BUILDER_TAG` · `GO_MODULE_UPGRADES` | -| Node | `node:*` (런타임은 **OS 패키지** `nodejs24`) | `NODE_BUILDER_TAG` · `NODE_PKG` | -| JVM | BCI base — 재컴파일 없이 **취약 jar 만 교체** | `_OLD` / `_VERSION` 쌍 | -| C · Lua | BCI base — **정적 링크 금지**(static glibc 없음) | 컴포넌트별 버전 ARG | - -→ 상세는 image-authoring.md "언어별 빌더 규칙". 세 가지가 언어와 무관하게 공통이다: -**버전은 Dockerfile 에 박지 말고 `build.env` 값으로**, `BUILD_ARGS` 에 **반드시 등록**(빠뜨리면 -조용히 기본값으로 빌드된다), 그리고 **업스트림 런타임 계약(`USER`·`ENTRYPOINT`·파일 레이아웃)은 -보존**한다. - -## 자동 재빌드 — 실행 계약만 - -`build-image.yml` 이 주간(월요일 02:00 UTC)으로 배포 중인 이미지를 스캔해 재빌드가 필요한 -것을 찾아 빌드·검증·게이트까지 돈다. **트리거 조건과 왜 그 조건인지는 -[.claude/image-authoring.md](../../image-authoring.md) "핀은 가만히 있어도 뒤처진다" 가 -단일 출처다** — 여기 복제하지 않는다. - -```sh -# 수동으로 같은 것을 돌린다 -gh workflow run self-build-image --repo /dip-catalog -f mode=drift -# 판정만 로컬에서 본다 -python3 scripts/build/check-rebuild-needed.py --list-refs -python3 scripts/build/check-rebuild-needed.py --reports [--apply] -``` - -이 경로는 **레지스트리 push 와 카탈로그 반영을 하지 않는다** — push 는 -`workflow_dispatch` + `mode=image` 에서만 켜진다. - -## 실측된 함정 - -- **`FROM`에 쓰는 `ARG`는 첫 `FROM` 이전(전역 스코프)에 선언한다.** 스테이지 내부에 선언하면 - 그 스테이지 지역 변수가 되어 이후 `FROM`의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다. -- **게이트 PASS는 "동작한다"를 증명하지 않는다.** CVE 스캐너는 런타임 요구사항(오퍼레이터가 - 자신의 파일 레이아웃에 의존하는 것 등)을 전혀 보지 못한다. 배포 검증을 반드시 한다 — - 절차는 [.claude/deploy-test-procedure.md](../../deploy-test-procedure.md). -- **`CoverageProbe`가 `ok`인지 확인한다.** `none`이면 findings 0건이 진짜 0건이 아니라 - 스캐너에 그 배포판 데이터가 없다는 뜻이다(`sbom-cve-gate` skill 참고). -- **롤링 태그를 쓰지 않는다.** 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 달라 - 태그에 빌드일을 포함한다(예: `1.30.0-security-hardened-20260804`). -- **핀을 고정한 대가로 핀이 뒤처진다.** 소스를 안 바꿔도 새 CVE 가 공개되면 어제 PASS 였던 - 핀이 오늘 FAIL 이 된다. 위 "자동 재빌드" 가 이것을 잡는다. 조치 전에 그 결과를 먼저 본다: - `python3 scripts/build/check-rebuild-needed.py --reports [--image ] [--apply]` -- **단, 같은 날 다시 빌드하면 그 태그가 겹친다.** 노드가 캐시한 옛 digest 가 그대로 쓰여 - (`imagePullPolicy: IfNotPresent`) 고친 것이 반영되지 않은 채 "안 고쳐졌다" 로 보인다. - **검증 대상 워크로드에 `imagePullPolicy: Always` 를 수동으로 걸고 digest 로 확인한다** — - 절차는 [deploy-test-procedure.md](../../deploy-test-procedure.md) "같은 날 재빌드했다면" - 이 단일 출처다. **카탈로그 values 에는 넣지 않는다**(같은 문서에 이유). - -## 마무리 - -카탈로그 values(`custom-values.yaml`/`dip-values.yaml`) 태그 갱신은 -`scripts/build/patch-catalog-tag.py`가 한다. 조사·결정 근거는 PR 설명과 -[MEMORY.md](../../../MEMORY.md)에 남긴다. `images/**`+`manifests/helm/**` 변경은 PR로만 반영한다. diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml deleted file mode 100644 index 42e9019..0000000 --- a/.github/workflows/build-image.yml +++ /dev/null @@ -1,432 +0,0 @@ -name: self-build-image - -# 자체 빌드 이미지(images/, 실행기 scripts/build/build-hardened-image.sh)의 -# 빌드·검증·스캔·게이트·카탈로그 반영을 자동화한다. security-catalog 의 동일 워크플로에서 -# 프레임워크를 포팅했고 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지 -# 실제로 검증됐다(2026-08 기준). 대상 이미지 목록은 images/ 디렉토리가 단일 출처다. -# 게이트(scripts/pipeline/cve-gate.py)가 상위 태그·베이스 -# OS 교체로 해소되지 않는 차단 CVE 를 찾으면 이 워크플로로 자체 빌드를 검토한다 — -# 절차: .claude/image-authoring.md, 트리거·제약 요약: doc/sbom-pipeline.md 의 "자체 빌드" 절 -# -# "이미지가 어느 차트의 어느 필드를 가리키는가" 는 images//catalog.env 가 선언한다 -# (CHART_DIRS·TAG_STYLE·TAG_BLOCK·DEFAULT_BASE_OS). 태그 표기 스타일이 두 가지다: -# imageName 단일 필드 문자열 (`imageName: "repo:tag"`) -# split registry/repository/tag 세 필드로 분리된 블록 -# 둘 다 scripts/build/patch-catalog-tag.py 하나로 다룬다(YAML 파서 없이 텍스트 치환만 -# 해서 기존 주석·포매팅을 보존한다 — 예상 패턴을 못 찾으면 조용히 넘어가지 않고 실패한다). -# -# 이 워크플로는 helm-catalog-sbom(sbom.yml)과 별도 파일이다. sbom.yml 은 -# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. -# 빌드는 호스트 러너여야 한다. -# -# 트리거 3종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다. -# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 브랜치 push 여부뿐이다. -# -# pull_request(images/**) 변경된 images// 디렉토리를 diff 로 자동 탐지해 그 -# 이미지들만 검증한다(push·카탈로그 브랜치 없음). 여러 이미지가 -# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다. -# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 태그 -# 갱신 브랜치 push 는 이 트리거로만 일어난다(사람이 수동 실행) — -# push 입력이 false 면 검증만 한다. -# schedule 재빌드 트리거. 배포 중인 이미지를 직접 스캔해 -# (scripts/build/check-rebuild-needed.py) **수정 버전이 있는 -# 차단 CVE** 가 있는 이미지를 찾아 빌드·검증·게이트만 돈다. -# 핀이 뒤처졌으면 핀을 올린 브랜치를 push 하고 그 브랜치로 -# 빌드한다. 핀이 이미 맞아도 재빌드한다 — 그 핀으로 아직 안 -# 빌드됐거나 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다 -# (실측: 차단 있는 4개 중 핀 변경 필요는 0개였다). -# 레지스트리 push 와 카탈로그 반영은 하지 않는다 — 반영은 사람이 -# workflow_dispatch 로 한다. -# -# 카탈로그 PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수 -# 없다는 조직 정책("Allow GitHub Actions to create and approve pull requests" 미허용, -# 리포 설정으로 못 바꿈)에 실측으로 막혔다(2026-08-04, run 30882785612, GraphQL: -# "GitHub Actions is not permitted to create or approve pull requests"). 브랜치·커밋·push -# 까지만 워크플로가 하고, PR 오픈은 Job Summary 에 남는 compare 링크로 사람이 직접 연다 -# (사람의 gh/브라우저 인증은 이 제약을 받지 않는다). - -on: - workflow_dispatch: - inputs: - mode: - description: 'image=지정한 이미지 하나 | drift=재빌드가 필요한 이미지를 찾아 전부' - required: false - default: 'image' - type: choice - options: [image, drift] - image: - description: '빌드할 이미지 디렉토리명 (images//). mode=drift 면 비워둔다' - required: false - default: '' - base_os: - description: '빌드 변종 (images//.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용' - required: false - default: '' - push: - description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 push 여부 (mode=drift·schedule 은 무조건 false)' - required: false - default: 'true' - pull_request: - paths: - - 'images/**' - schedule: - # sbom.yml 주간 전체 스캔(일요일 18:00 UTC) 다음날. 이 잡은 그 산출물에 의존하지 않고 - # 배포 중인 이미지를 직접 스캔한다 — 워크플로 간 아티팩트 결합을 만들지 않기 위함이다. - - cron: '0 2 * * 1' - -permissions: - contents: write - -env: - REGISTRY_HOST: docker.io/paasup - -jobs: - # --- 대상 이미지 결정 --------------------------------------------------------- - # workflow_dispatch: inputs.image 하나. pull_request: images// 아래 변경이 있는 - # 디렉토리를 전부 찾는다(여러 이미지가 한 PR 에서 바뀌면 각각 매트릭스로 돈다). - discover: - runs-on: ubuntu-latest - outputs: - images: ${{ steps.list.outputs.images }} - ref: ${{ steps.list.outputs.ref }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # 드리프트 경로에서만 필요하다. trivy 로 **배포 중인 이미지**를 직접 스캔한다 - # (docker 불필요 — trivy 가 자체 레지스트리 클라이언트로 pull 한다). - - name: trivy 설치 (재빌드 판정용) - if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift' - run: | - curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ - | sh -s -- -b /usr/local/bin - trivy --version | head -1 - - # 무엇을 스캔해야 하는지는 스크립트가 안다(catalog.env → 카탈로그 values → ref). - # ref 해석 규칙이 워크플로로 새면 두 곳이 어긋난다. - - name: 재빌드 판정 — 배포 중인 이미지 스캔 - id: drift - if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift' - run: | - set -uo pipefail - mkdir -p /tmp/rebuild-reports - python3 scripts/build/check-rebuild-needed.py --list-refs > /tmp/refs.tsv - while IFS=$'\t' read -r img ref fname; do - [ -n "${fname:-}" ] || continue - echo "스캔: $ref" - trivy image --quiet --scanners vuln \ - --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \ - --format json "$ref" > "/tmp/rebuild-reports/$fname" \ - || { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다" - rm -f "/tmp/rebuild-reports/$fname"; } - done < /tmp/refs.tsv - - # --apply 는 핀이 뒤처진 경우에만 build.env 를 고친다. 핀이 이미 맞으면 아무것도 - # 건드리지 않고, 그래도 재빌드 대상이다(베이스 패키지가 갱신되므로). - python3 scripts/build/check-rebuild-needed.py \ - --reports /tmp/rebuild-reports \ - --summary-md /tmp/rebuild.md \ - --json-out /tmp/rebuild.json \ - --apply - cat /tmp/rebuild.md >> "$GITHUB_STEP_SUMMARY" - - python3 - <<'PY' >> "$GITHUB_OUTPUT" - import json - rows = json.load(open("/tmp/rebuild.json")) - # 트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다. 핀 변경 여부가 아니다 — - # 핀이 이미 맞아도(핀만 고친 PR 이 머지된 뒤) 이미지가 그 핀으로 안 빌드됐거나 - # 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다. 실측으로 확인한 사실이다. - print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"])) - PY - - # 핀이 바뀐 경우에만 브랜치가 필요하다. 핀 변경이 없으면 기본 ref 로 그냥 빌드한다. - - name: 핀이 바뀌었으면 브랜치 push - id: branch - if: steps.drift.outputs.images != '' && steps.drift.outputs.images != '[]' - run: | - set -euo pipefail - if git diff --quiet -- images/; then - echo "핀 변경 없음 — 브랜치를 만들지 않고 기본 ref 로 빌드한다" - echo "핀은 이미 맞다 — 재빌드만으로 해소된다(베이스 패키지·미반영 핀)." \ - >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - BRANCH="chore/pin-drift-$(date -u +%Y%m%d%H%M%S)" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git add images/ - git diff --cached --stat - git commit \ - -m "자체 빌드 이미지 버전 핀 갱신 (자동 감지)" \ - -m "배포 중인 이미지를 스캔해 핀이 뒤처진 것을 찾아 올렸다. 소스는 바꾸지 않았다 —" \ - -m "CVE 데이터가 갱신되면서 같은 핀이 차단 대상이 된 것이다. 산출은" \ - -m "scripts/build/check-rebuild-needed.py 가 했고 근거는 이 실행의 Job Summary 에 있다." \ - -m "이 브랜치로 빌드·검증·게이트까지 자동으로 돈다. 레지스트리 push 와 카탈로그" \ - -m "반영은 하지 않았다 — 사람이 확인 후 workflow_dispatch 로 한다." \ - -m "Co-Authored-By: github-actions[bot] " - git push -u origin "$BRANCH" - echo "ref=$BRANCH" >> "$GITHUB_OUTPUT" - { - echo - echo "핀을 올린 브랜치를 push 했다: \`$BRANCH\`" - echo - echo "아래 빌드가 **이 브랜치로** 돌아 핀이 실제로 해소하는지 확인한다." - echo "게이트가 PASS 면 사람이 PR 을 열고, 반영은 \`workflow_dispatch\` 로 한다." - echo - echo "https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" - } >> "$GITHUB_STEP_SUMMARY" - - - name: 대상 이미지 목록 산출 - id: list - run: | - set -euo pipefail - echo "ref=${{ steps.branch.outputs.ref }}" >> "$GITHUB_OUTPUT" - if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event.inputs.mode }}" = "drift" ]; then - # 판정 결과가 곧 대상이다. 핀이 안 바뀌어 브랜치가 없어도 빌드한다 - # (기본 ref 로 재빌드하면 베이스 패키지·미반영 핀이 해소된다). - json='${{ steps.drift.outputs.images }}' - [ -n "$json" ] || json="[]" - elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - img="${{ github.event.inputs.image }}" - [ -n "$img" ] || { echo "::error::image 입력이 비어있다 (mode=drift 가 아니면 필수)"; exit 1; } - [ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; } - json="[\"$img\"]" - else - changed="$(git diff --name-only \ - "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" \ - -- images/ | awk -F/ 'NF>1 {print $2}' | sort -u)" - if [ -z "$changed" ]; then - json="[]" - else - json="$(printf '%s\n' "$changed" \ - | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')" - fi - fi - echo "images=$json" >> "$GITHUB_OUTPUT" - echo "대상 이미지: $json" - - build: - needs: discover - if: needs.discover.outputs.images != '[]' && needs.discover.outputs.images != '' - strategy: - fail-fast: false - matrix: - image: ${{ fromJson(needs.discover.outputs.images) }} - runs-on: ubuntu-latest - timeout-minutes: 60 - env: - IMAGE_DIR: images/${{ matrix.image }} - steps: - # 드리프트 경로면 discover 가 핀을 올려 push 한 브랜치를 받는다(비어 있으면 기본 ref). - - uses: actions/checkout@v4 - with: - ref: ${{ needs.discover.outputs.ref }} - - - name: Preflight — 도구 확인 - run: | - set -e - for t in docker python3 bash; do - command -v "$t" >/dev/null || { echo "::error::러너에 $t 없음"; exit 1; } - done - docker version - - - name: trivy 설치 - run: | - curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ - | sh -s -- -b /usr/local/bin - trivy --version | head -1 - - # "이 이미지가 어느 차트의 어느 필드를 가리키는가" 는 이미지 디렉토리 자신이 - # 선언한다(catalog.env) — 이 워크플로가 이미지별 지식을 갖지 않게 하기 위함 - # (build.env 계약과 같은 원칙, .claude/image-authoring.md). - - name: 이미지 메타데이터 로드 (catalog.env) - id: meta - run: | - set -euo pipefail - ENV_FILE="$IMAGE_DIR/catalog.env" - [ -f "$ENV_FILE" ] || { echo "::error::catalog.env 없음: $ENV_FILE — images//catalog.env 를 추가해야 한다"; exit 1; } - # shellcheck disable=SC1090 - . "$ENV_FILE" - : "${DEFAULT_BASE_OS:?catalog.env 에 DEFAULT_BASE_OS 가 없다}" - : "${CHART_DIRS:?catalog.env 에 CHART_DIRS 가 없다}" - : "${TAG_STYLE:?catalog.env 에 TAG_STYLE 이 없다}" - BASE_OS="${{ github.event.inputs.base_os }}" - BASE_OS="${BASE_OS:-$DEFAULT_BASE_OS}" - { - echo "base_os=$BASE_OS" - echo "chart_dirs=$CHART_DIRS" - echo "tag_style=$TAG_STYLE" - echo "tag_block=${TAG_BLOCK:-image}" - } >> "$GITHUB_OUTPUT" - echo " image=${{ matrix.image }} base_os=$BASE_OS chart_dirs=$CHART_DIRS tag_style=$TAG_STYLE" - - # push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면 - # push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다. - # 자동 트리거(schedule)와 드리프트 모드는 **절대 push 하지 않는다.** 자동 빌드는 - # "핀을 이만큼 올리면 정말 해소되는가" 까지만 답한다 — 레지스트리 반영은 사람이 - # workflow_dispatch 로 한다(CLAUDE.md 의 정책). catch-all 을 false 로 두어 앞으로 - # 트리거가 추가돼도 실수로 push 되지 않게 한다. - - name: 게시 여부 결정 - id: publish - run: | - case "${{ github.event_name }}" in - workflow_dispatch) - if [ "${{ github.event.inputs.mode }}" = "drift" ] \ - || [ "${{ github.event.inputs.push }}" = "false" ]; then - echo "enabled=false" >> "$GITHUB_OUTPUT" - else - echo "enabled=true" >> "$GITHUB_OUTPUT" - fi ;; - *) echo "enabled=false" >> "$GITHUB_OUTPUT" ;; - esac - - - name: 레지스트리 로그인 - if: steps.publish.outputs.enabled == 'true' - env: - DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} - DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} - run: | - [ -n "$DOCKERHUB_USER" ] || { echo "::error::DOCKERHUB_USER 시크릿 없음"; exit 1; } - echo "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin - - - name: 빌드 → 검증 → SBOM → 스캔 → 게이트 - id: build - env: - IMAGE: ${{ matrix.image }} - BASE_OS: ${{ steps.meta.outputs.base_os }} - run: | - set -uo pipefail - OUT="$GITHUB_WORKSPACE/build-out" - mkdir -p "$OUT" - # build-hardened-image.sh 의 build.log 는 그 안에서 docker build 출력만 담는 - # 별도 파일이다(스크립트 자신의 tag= 안내는 그 파일에 없다). 태그를 뒤에서 - # 뽑으려면 스크립트 자신의 표준출력을 따로 남겨야 한다 — tee 로 wrapper.log - # 에도 기록한다. - # - # GitHub Actions 의 run: 스텝은 기본으로 bash -e 다. 빌드 스크립트의 실패를 - # $rc 로 정상 캡처하려면 그 호출 동안만 -e 를 끈다 — 아니면 실패 시 여기서 - # 바로 중단돼 아래의 rc 기록·output 기록이 실행되지 않는다. - set +e - if [ "${{ steps.publish.outputs.enabled }}" = "true" ]; then - REGISTRY="$REGISTRY_HOST" bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" - else - bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" # push 없음 — 검증만 - fi - rc="${PIPESTATUS[0]}" - set -e - echo "rc=$rc" >> "$GITHUB_OUTPUT" - # 끝에 `|| true` 를 붙인다 — grep 이 매치를 못 찾아 실패해도(pipefail 이 - # 그 실패를 대입식 전체의 실패로 만든다) -e 아래에서 스크립트가 중단되지 - # 않게 한다. tag 가 비어도 그만이다 — rc!=0 이면 어차피 이후 단계가 건너뛴다. - tag="$(grep -h '^ tag=' "$OUT/wrapper.log" 2>/dev/null | tail -1 | cut -d= -f2- || true)" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - exit "$rc" - - - name: 아티팩트 업로드 - if: always() - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.image }}-build - path: | - build-out/build.log - build-out/verify.log - build-out/cve-gate.md - build-out/sbom - if-no-files-found: warn - - - name: Job Summary - if: always() - run: | - if [ -f build-out/cve-gate.md ]; then - { echo "## ${{ matrix.image }}"; cat build-out/cve-gate.md; echo; } >> "$GITHUB_STEP_SUMMARY" - fi - - # --- 카탈로그 반영 (push/workflow_dispatch 이고 게이트 PASS 일 때만) -------------- - - - name: 현재 카탈로그 태그 확인 - id: current - if: steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' - run: | - set -euo pipefail - # CHART_DIRS 가 여러 개일 수 있으나(공백 구분) 첫 번째 디렉토리를 기준값으로 - # 삼는다 — 지금까지 이미지 하나가 차트 여러 개에 걸친 사례가 없다. - first_dir="$(echo "${{ steps.meta.outputs.chart_dirs }}" | awk '{print $1}')" - cur="$(python3 scripts/build/patch-catalog-tag.py \ - --style "${{ steps.meta.outputs.tag_style }}" \ - --block "${{ steps.meta.outputs.tag_block }}" \ - --read "$first_dir/custom-values.yaml")" - echo "tag=$cur" >> "$GITHUB_OUTPUT" - echo "현재 카탈로그 태그: $cur" - echo "새 빌드 태그: ${{ steps.build.outputs.tag }}" - - # 이 워크플로를 트리거하는 것 자체가 이미 "조치가 필요하다" 는 판단(sbom.yml 의 - # 게이트가 차단+수정가능 CVE 를 확인)이거나 사람의 명시적 실행이다. 예전에는 - # 블라인드 스케줄 재빌드가 있어서 "정말 개선인지" 를 여기서 재확인해야 했는데, - # 그 트리거를 없앤 뒤로는 불필요해졌다 — 게이트 PASS + 태그 변경만 확인한다. - # - # PR 은 만들지 않는다(위 파일 헤더 참고 — GITHUB_TOKEN 으로 PR 생성이 조직 정책으로 - # 막혀 있고 우리 쪽에서 그 정책을 못 바꾼다, 실측: run 30882785612). 브랜치 push 까지만 - # 하고 compare 링크를 Job Summary 에 남겨 사람이 직접 PR 을 연다. - - name: 카탈로그 브랜치 push + PR 안내 - if: > - steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' && - steps.current.outputs.tag != steps.build.outputs.tag - run: | - set -uo pipefail - NEW="${{ steps.build.outputs.tag }}" - OLD="${{ steps.current.outputs.tag }}" - IMAGE="${{ matrix.image }}" - BRANCH="build/${IMAGE}-$(date -u +%Y%m%d%H%M%S)" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - - TOUCHED=() - for dir in ${{ steps.meta.outputs.chart_dirs }}; do - candidates=() - [ -f "$dir/custom-values.yaml" ] && candidates+=("$dir/custom-values.yaml") - [ -f "$dir/dip-values.yaml" ] && candidates+=("$dir/dip-values.yaml") - [ "${#candidates[@]}" -eq 0 ] && continue - out="$(python3 scripts/build/patch-catalog-tag.py \ - --style "${{ steps.meta.outputs.tag_style }}" \ - --block "${{ steps.meta.outputs.tag_block }}" \ - --old "$OLD" --new "$NEW" "${candidates[@]}")" - echo "$out" - TOUCHED+=("${candidates[@]}") - done - git diff --stat - - git add "${TOUCHED[@]}" - git commit \ - -m "${IMAGE} 이미지 태그 갱신: ${OLD##*:} → ${NEW##*:}" \ - -m "게이트 PASS(build-image.yml, ${{ github.event_name }} 트리거)로 확인된 태그로 교체한다." \ - -m "Co-Authored-By: github-actions[bot] " - git push -u origin "$BRANCH" - - COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" - { - echo "## ${IMAGE} 카탈로그 태그 갱신 — 브랜치 push 완료, PR 은 직접 열어야 함" - echo - echo "- 이전 태그: \`$OLD\`" - echo "- 새 태그: \`$NEW\`" - echo "- 브랜치: \`$BRANCH\`" - echo "- 게이트: PASS (아티팩트의 \`cve-gate.md\` 참고)" - echo - echo "**PR 은 자동 생성되지 않는다** — GitHub 조직 정책상 \`GITHUB_TOKEN\`(Actions 자체 토큰)으로는" - echo "PR 을 만들 수 없다. 아래 링크에서 사람이 직접 연다:" - echo - echo "$COMPARE_URL" - echo - echo "PR을 연 뒤 병합 전에 아래를 수동으로 실행해 카탈로그 게이트를 확인한다." - echo - echo '```sh' - echo "gh workflow run helm-catalog-sbom --ref $BRANCH" - echo '```' - echo - echo "이미지가 실제로 동작하는지도 병합 전에 수동으로 확인한다(해당 차트 배포 후" - echo "기능 점검 — 자동화된 배포 테스트 절차는 아직 없다)." - } >> "$GITHUB_STEP_SUMMARY" - echo "브랜치 push 완료 — PR은 사람이 직접 열어야 한다: $COMPARE_URL" diff --git a/.github/workflows/catalog-tag-update.yml b/.github/workflows/catalog-tag-update.yml new file mode 100644 index 0000000..6038751 --- /dev/null +++ b/.github/workflows/catalog-tag-update.yml @@ -0,0 +1,88 @@ +name: catalog-tag-update + +# security-images(자체 빌드 이미지 레포, public)의 `published.json` 을 읽어 카탈로그 +# values 의 이미지 태그를 갱신한다. 그 레포는 이 카탈로그를 모른다 — 게이트 PASS + push +# 가 실제로 일어난 이미지의 ref 를 `published.json` 에 기록해 둘 뿐이다. "그 태그를 어느 +# 차트에 반영할지" 는 이 카탈로그가 판단한다(catalog/image-map/.env). +# +# public raw URL 로 읽으므로 인증이 필요 없다. security-images 쪽 시크릿·PAT 도 없다 — +# 의존 방향은 카탈로그 → 이미지 단방향이다(docs/image-authoring.md "카탈로그 레포와의 +# 계약", security-images 레포). +# +# PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수 없다는 +# 조직 정책에 막혀 있다(build-image.yml 이 자체 빌드 축에서도 같은 제약을 받았다). +# 브랜치·커밋·push 까지만 하고 compare 링크를 Job Summary 에 남긴다. + +on: + workflow_dispatch: + schedule: + # 자체 빌드는 workflow_dispatch 수동 실행뿐이라 발행이 잦지 않다 — 하루 한 번이면 충분하다. + - cron: '0 3 * * *' + +permissions: + contents: write + +env: + SECURITY_IMAGES_REPO: paasup/security-images + +jobs: + update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: published.json 조회 + run: | + curl -fsSL "https://raw.githubusercontent.com/${SECURITY_IMAGES_REPO}/main/published.json" \ + -o /tmp/published.json + python3 -c "import json; json.load(open('/tmp/published.json'))" # 스키마 최소 확인 + + - name: 반영 대상 계산 + id: apply + run: | + set -euo pipefail + python3 scripts/build/apply-published-tags.py --published /tmp/published.json \ + --json-out /tmp/changes.json --summary-md /tmp/changes.md + cat /tmp/changes.md >> "$GITHUB_STEP_SUMMARY" + n="$(python3 -c "import json;print(len(json.load(open('/tmp/changes.json'))))")" + echo "count=$n" >> "$GITHUB_OUTPUT" + + - name: 브랜치 push + PR 안내 + if: steps.apply.outputs.count != '0' + run: | + set -euo pipefail + BRANCH="build/catalog-tag-update-$(date -u +%Y%m%d%H%M%S)" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + + # apply-published-tags.py 가 이미 파일을 고쳤다 — 여기서는 그 결과를 커밋만 한다. + FILES="$(python3 -c " + import json + for c in json.load(open('/tmp/changes.json')): + print(c['file']) + " | sort -u)" + git add $FILES + git diff --cached --stat + + IMAGES="$(python3 -c " + import json + print(', '.join(sorted({c['image'] for c in json.load(open('/tmp/changes.json'))}))) + ")" + git commit \ + -m "자체 빌드 이미지 태그 갱신: $IMAGES" \ + -m "security-images 레포의 published.json(게이트 PASS + push 확인된 발행 기록)을" \ + -m "반영한다. 상세는 이 실행의 Job Summary 참고." \ + -m "Co-Authored-By: github-actions[bot] " + git push -u origin "$BRANCH" + + COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1" + { + echo + echo "브랜치 push 완료 — PR은 사람이 직접 연다: $COMPARE_URL" + echo + echo "병합 전 확인할 것:" + echo '1. `gh workflow run helm-catalog-sbom --ref '"$BRANCH"'` 로 카탈로그 게이트 확인' + echo "2. 배포 검증 — 게이트 PASS 는 동작을 증명하지 않는다" + } >> "$GITHUB_STEP_SUMMARY" + echo "브랜치 push 완료: $COMPARE_URL" diff --git a/.github/workflows/self-build-drift-check.yml b/.github/workflows/self-build-drift-check.yml new file mode 100644 index 0000000..47f1ef7 --- /dev/null +++ b/.github/workflows/self-build-drift-check.yml @@ -0,0 +1,83 @@ +name: self-build-drift-check + +# 배포 중인 자체 빌드 이미지(security-images 레포가 만든 것)가 새 CVE 로 규정을 벗어났는지 +# 이 카탈로그가 스스로 스캔해 판정한다. "무엇이 배포 중인가"는 이 카탈로그만 안다 — +# security-images 는 자기 스스로 이 판단을 하지 않는다 +# (security-images 레포 docs/image-authoring.md "재빌드는 이 레포가 스스로 트리거하지 않는다"). +# +# 판정(scripts/build/check-rebuild-needed.py)이 재빌드 대상이라고 하면 security-images 의 +# `build-image.yml` 을 `workflow_dispatch` 로 부른다. 그 워크플로가 실제 빌드·검증·게이트· +# push·`published.json` 갱신을 한다 — 이 워크플로는 트리거만 한다. +# +# 레지스트리 push 와 카탈로그 반영은 이 워크플로가 하지 않는다. `catalog-tag-update.yml` 이 +# `published.json` 을 읽어가는 별도 경로로 반영한다. + +on: + workflow_dispatch: + schedule: + - cron: '0 2 * * 1' # 매주 월요일 02:00 UTC + +env: + SECURITY_IMAGES_REPO: paasup/security-images + +jobs: + drift-check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: trivy 설치 + run: | + curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + trivy --version | head -1 + + - name: 배포 중인 자체 빌드 이미지 스캔 + id: scan + run: | + set -uo pipefail + mkdir -p /tmp/drift-reports + python3 scripts/build/check-rebuild-needed.py --list-refs > /tmp/refs.tsv + while IFS=$'\t' read -r img ref fname; do + [ -n "${fname:-}" ] || continue + echo "스캔: $ref" + trivy image --quiet --scanners vuln \ + --severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \ + --format json "$ref" > "/tmp/drift-reports/$fname" \ + || { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다" + rm -f "/tmp/drift-reports/$fname"; } + done < /tmp/refs.tsv + + python3 scripts/build/check-rebuild-needed.py \ + --reports /tmp/drift-reports \ + --summary-md /tmp/drift.md \ + --json-out /tmp/drift.json + cat /tmp/drift.md >> "$GITHUB_STEP_SUMMARY" + + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + rows = json.load(open("/tmp/drift.json")) + print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"])) + PY + + - name: security-images 재빌드 트리거 + if: steps.scan.outputs.images != '' && steps.scan.outputs.images != '[]' + env: + GH_TOKEN: ${{ secrets.SECURITY_IMAGES_DISPATCH_TOKEN }} + run: | + set -euo pipefail + [ -n "$GH_TOKEN" ] || { echo "::error::SECURITY_IMAGES_DISPATCH_TOKEN 시크릿 없음 — ${SECURITY_IMAGES_REPO} 에 workflow_dispatch 를 걸 권한이 있는 PAT 필요"; exit 1; } + python3 -c "import json,sys; print('\n'.join(json.loads(sys.argv[1])))" '${{ steps.scan.outputs.images }}' | \ + while read -r img; do + [ -n "$img" ] || continue + echo "재빌드 트리거: $img" + gh workflow run self-build-image --repo "$SECURITY_IMAGES_REPO" -f "image=$img" + done + { + echo + echo "재빌드를 트리거했다: ${{ steps.scan.outputs.images }}" + echo "진행 상황: https://github.com/${SECURITY_IMAGES_REPO}/actions/workflows/self-build-image.yml" + echo + echo "게이트 PASS + push 성공 시 그 레포의 \`published.json\` 이 갱신되고," + echo "\`catalog-tag-update.yml\` 이 다음 실행에서 카탈로그에 반영한다." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CLAUDE.md b/CLAUDE.md index 88f87f0..0dcd631 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,16 +32,20 @@ dip-catalog/ │ └── status.md # 구현 현황 ├── scripts/ │ ├── pipeline/ # SBOM 생성 + CVE 스캔 + 게이트 판정 (sbom.yml/cve-edge-post.yml 이 쓴다) -│ ├── build/ # 자체 빌드 이미지 프레임워크 (build-image.yml 이 쓴다) +│ ├── build/ # 자체 빌드 이미지 축의 카탈로그 쪽 절반 — 드리프트 탐지 +│ │ # (check-rebuild-needed.py) + 발행 태그 반영 +│ │ # (apply-published-tags.py). 빌드 자체는 security-images 레포 │ └── deploy-test/ # 배포 검증 스크립트 + fixtures (helm/kubectl 실행 전담) -├── images// # 자체 빌드 하드닝 이미지 정의 (목록은 이 디렉토리가 단일 출처) +├── catalog/ +│ └── image-map/.env # 자체 빌드 이미지 → 차트·필드 매핑 (목록은 이 디렉토리가 단일 출처) ├── MEMORY.md # 현재 상태·미결 (작업 이어받을 때 여기서 시작 — 유지 규칙은 파일 안에) └── doc/ # 차트 리소스 프로파일 + 스택 분류 + CVE/SBOM 파이프라인 + 운영 가이드 ├── sbom-pipeline.md # SBOM 생성·스캔·게이트 메커니즘 ├── cve-exceptions.json # 게이트 승인 예외 목록 ├── catalog-stack-classification.md # 차트 스택 분류·우선순위(P0~P2) ├── define-chart-resources.md # 차트별 Small/Medium/Large 리소스 프로파일 - ├── decisions/ # ADR — 재측정으로 복원되지 않는 이미지·차트 선택 근거 + ├── decisions/ # ADR — 재측정으로 복원되지 않는 차트 선택 근거 + │ # (이미지 자체 빌드 ADR은 security-images 레포로 이관됨) └── migrations/ # 레포 간 이관 핸드오프 문서 ``` @@ -146,41 +150,54 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \ |-------|------| | [chart-to-cnpg](.claude/skills/chart-to-cnpg/SKILL.md) | 카탈로그 차트의 내장 bitnami postgresql 서브차트를 전용 cnpg-cluster로 전환 | | [catalog-update-pipeline](.claude/skills/catalog-update-pipeline/SKILL.md) | 차트 신규 버전 감지 → diff → breaking 판정 → 문서 생성 파이프라인 실행 (`agent/update_catalog`) | -| [cve-remediation](.claude/skills/cve-remediation/SKILL.md) | 차단 CVE 대응 레버(태그 교체/베이스 OS 교체/자체 빌드/예외) 결정 — `sbom-cve-gate`·`self-build-image` 실행으로 위임 | +| [cve-remediation](.claude/skills/cve-remediation/SKILL.md) | 차단 CVE 대응 레버(태그 교체/베이스 OS 교체/자체 빌드/예외) 결정 — `sbom-cve-gate` 실행 또는 별도 레포 `security-images`(자체 빌드)로 위임 | | [sbom-cve-gate](.claude/skills/sbom-cve-gate/SKILL.md) | SBOM 생성·CVE 스캔·게이트 판정 실행 및 결과 해석 (`scripts/pipeline`) | -| [self-build-image](.claude/skills/self-build-image/SKILL.md) | 자체 빌드 하드닝 이미지 추가·변경 (`scripts/build`, `images/`) | -각 Skill 은 절차 본문을 복제하지 않고 권위 있는 문서(`doc/sbom-pipeline.md`, -`.claude/image-authoring.md` 등)를 가리킨다 — 문서가 단일 출처이고, Skill 은 **실행 계약과 -문서가 놓치기 쉬운 함정·현재 상태**만 담는다. +자체 빌드 하드닝 이미지 추가·변경은 이 레포의 일이 아니다 — 별도 레포 `security-images` +에서 하고, 그 레포의 `docs/image-authoring.md`가 단일 출처다(이관 배경: +[doc/migrations/](doc/migrations/)). + +각 Skill 은 절차 본문을 복제하지 않고 권위 있는 문서(`doc/sbom-pipeline.md` 등)를 +가리킨다 — 문서가 단일 출처이고, Skill 은 **실행 계약과 문서가 놓치기 쉬운 함정·현재 +상태**만 담는다. 관련 참조 문서(Skill이 절차의 단일 출처로 삼는다): [deploy-test-procedure.md](.claude/deploy-test-procedure.md) · -[pitfalls.md](.claude/pitfalls.md) · [image-authoring.md](.claude/image-authoring.md) +[pitfalls.md](.claude/pitfalls.md) --- ## CVE/SBOM 게이트 작업 -카탈로그가 참조하는 컨테이너 이미지의 취약점을 다룬다. **두 축**이고 각 축의 상세는 소유 문서가 -갖는다 — 여기 메커니즘을 쓰지 않는다. +카탈로그가 참조하는 컨테이너 이미지의 취약점을 다룬다. **두 축**이고, 축마다 소유 레포와 +문서가 다르다 — 여기 메커니즘을 복제하지 않는다. -| 축 | 질문 | 워크플로 | 게이트 | 단일 출처 | -|----|------|---------|--------|----------| -| **차트 카탈로그** | 우리가 배포하는 이미지에 무엇이 있는가 | `sbom.yml`
`cve-edge-post.yml` | warn-only
**호출 안 함** | [doc/sbom-pipeline.md](doc/sbom-pipeline.md) | -| **자체 빌드** | 그 이미지를 어떻게 만드는가 | `build-image.yml` | **강제** | [.claude/image-authoring.md](.claude/image-authoring.md) | +| 축 | 질문 | 실행 위치 | 워크플로(이 레포) | 게이트 | 단일 출처 | +|----|------|----------|------------------|--------|----------| +| **차트 카탈로그** | 우리가 배포하는 이미지에 무엇이 있는가 | 이 레포 | `sbom.yml`
`cve-edge-post.yml` | warn-only
**호출 안 함** | [doc/sbom-pipeline.md](doc/sbom-pipeline.md) | +| **자체 빌드** | 그 이미지를 어떻게 만드는가 | 별도 레포 `security-images` | `self-build-drift-check.yml`(탐지·트리거)
`catalog-tag-update.yml`(반영) | **강제**(그 레포 소유) | 그 레포의 `docs/image-authoring.md` | - **차트 축은 warn-only 다** — 게이트가 실패해도 CI/PR 을 막지 않는다. 카탈로그 차트 전체가 - 이 게이트로 트리아지된 적이 없다. **자체 빌드 축의 게이트는 이미 강제다.** + 이 게이트로 트리아지된 적이 없다. **자체 빌드 축의 게이트는 이미 강제다**(단, 그 게이트는 + 이 레포가 아니라 `security-images` 레포가 돌린다). - **`cve-edge-post.yml` 은 게이트를 부르지 않는다** — 같은 스캔 데이터에 판정기가 두 벌이라는 뜻이다(승인 예외·실효 등급 미적용). 외부 엔드포인트로 집계를 POST 하는 용도다. - **자체 빌드는 대응 우선순위 3번**(상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인). 레버 판단은 [cve-remediation](.claude/skills/cve-remediation/SKILL.md) skill 이 갖는다. - **레지스트리 push·카탈로그 반영을 동반하는 빌드는 `workflow_dispatch` 수동 실행뿐이다** — - `schedule`·PR 트리거는 검증만 한다. -- 커스텀 이미지는 **별도 레포로 분리 예정**이다. 그때 끊기는 결합점은 - [.claude/image-authoring.md](.claude/image-authoring.md) "레포 분리 후 무엇이 끊기는가". -- 승인 예외: `doc/cve-exceptions.json` · 현재 미결: [MEMORY.md](MEMORY.md) + 실행은 `security-images` 레포에서 `workflow_dispatch` 로만 한다. +- **이 레포는 "무엇을 배포 중인가"만 안다.** 어느 이미지를 자체 빌드하고 있는지는 + `catalog/image-map/` 이 단일 출처다. `scripts/build/check-rebuild-needed.py`(주간 + `self-build-drift-check.yml`)가 배포 중인 이미지를 직접 스캔해 재빌드 대상을 판단하고, + 필요하면 `security-images` 의 `build-image.yml` 을 트리거만 한다 — 핀을 무엇으로 + 올릴지는 그 레포가 판단한다. +- **카탈로그 반영은 pull 방식이다.** `security-images` 는 이 카탈로그를 모른다 — 게이트 + PASS + push 성공 시 자기 레포의 `published.json` 만 갱신한다. `catalog-tag-update.yml` + 이 그 파일을 public raw URL 로 읽어가 `custom-values.yaml`/`dip-values.yaml` 을 패치한다. +- 커스텀 이미지는 자체 빌드 프레임워크·이미지 정의와 함께 **별도 레포 `security-images` + 로 분리됐다.** 이관 배경과 남은 결합점은 [doc/migrations/](doc/migrations/) 참고. +- 승인 예외: `doc/cve-exceptions.json`(차트 축) — `security-images` 의 `cve-exceptions.json` + (자체 빌드 축)과 별도 관리되며, 같은 이미지를 양쪽이 스캔하므로 필요하면 양쪽에 각각 + 등록한다. 현재 미결: [MEMORY.md](MEMORY.md) --- @@ -190,8 +207,8 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \ | 성격 | 목적지 | |------|--------| -| **무엇을 왜 바꿨나** (완료된 작업의 경위) | 커밋 메시지 · PR 설명 · `images//README.md` | -| **다시 밟지 말아야 할 함정** | [.claude/pitfalls.md](.claude/pitfalls.md) · [.claude/image-authoring.md](.claude/image-authoring.md) · 해당 Skill | +| **무엇을 왜 바꿨나** (완료된 작업의 경위) | 커밋 메시지 · PR 설명 | +| **다시 밟지 말아야 할 함정** | [.claude/pitfalls.md](.claude/pitfalls.md) · 해당 Skill | | **후보를 비교해 하나를 고른 근거** | [doc/decisions/](doc/decisions/) — ADR. 선택지가 하나뿐인 조치는 여기 쓰지 않는다 | | **추적·논의·배정이 필요한 미결** | GitHub 이슈 | | **지금 상태와 다음에 할 일** | [MEMORY.md](MEMORY.md) — 위 셋에 속하면 여기 남기지 않고 링크만 | diff --git a/MEMORY.md b/MEMORY.md index f667828..3a05673 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -12,15 +12,15 @@ ## 이 파일의 유지 규칙 **지금 시점의 상태와 다음에 할 일만 담는다.** 완료된 작업의 경위는 담지 않는다 — 커밋 -메시지·PR 설명·`images//README.md` 가 이미 권위 있는 기록이고, 복사본을 두면 나중에 -어느 쪽이 맞는지가 새 문제가 된다. +메시지·PR 설명이 이미 권위 있는 기록이고, 복사본을 두면 나중에 어느 쪽이 맞는지가 새 +문제가 된다. 항목이 "다음에 할 일" 이 아니게 되면 **셋 중 하나로 내보내고 여기서 지운다.** | 성격 | 목적지 | |---|---| | 추적·논의·배정이 필요한 미결 | GitHub 이슈 — 여기엔 **한 줄 링크만** | -| 재발 방지 교훈 | `.claude/pitfalls.md` · `.claude/image-authoring.md` · 해당 skill | +| 재발 방지 교훈 | `.claude/pitfalls.md` · 해당 skill | | 단순 완료 기록 | 삭제 | 트리거는 시간이 아니라 **상태**다. 다만 놓친 것을 걷어내기 위해 **월 1회 점검**한다 — @@ -47,18 +47,23 @@ - **`argo-cd/7.8.11` 삭제 여부** — 카탈로그 차단 CVE 239건이 전부 이 동결 버전 몫이고, 지우면 게이트가 PASS 로 떨어진다. 직전 버전을 없애는 결정이라 PR #28 에서 보류했다. -- **자체 빌드 이미지의 정식 반영 경로** — argocd 는 로컬에서 빌드·push 했다. CI - `build-image.yml`(`workflow_dispatch`)로 다시 태울지, 로컬 push 로 끝낼지. -- **레포 분리 시점의 두 작업** — ① `catalog.env` 의 카탈로그 매핑(`CHART_DIRS`·`TAG_STYLE`· - `TAG_BLOCK`)을 카탈로그 자산으로 떼어내고 `catalog-tag-update.yml` 로 반영, - ② 게이트를 composite action 으로(`workflow_call` 아님 — 스캔과 같은 job 에서 `$OUT_DIR` - 공유가 필요하다). 결합점 전체는 `.claude/image-authoring.md` "레포 분리 후 무엇이 끊기는가". +- **자체 빌드 이미지 레포 분리는 완료됐다** — 프레임워크·이미지 정의·ADR 이 + `security-images` 레포로 나갔다. 카탈로그 쪽은 `catalog/image-map/`(어느 차트를 + 가리키는지) + `check-rebuild-needed.py`(드리프트 탐지) + `apply-published-tags.py` + (발행 태그 반영)만 남았다. 배경·결합점 전체는 + [doc/migrations/](doc/migrations/self-build-images-to-security-images.md). + **아직 설정 안 된 것**: `self-build-drift-check.yml` 이 `security-images` 의 + `build-image.yml` 을 트리거하려면 그 레포에 `workflow_dispatch` 권한이 있는 PAT 을 + `SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿으로 등록해야 한다. 등록 전까지 트리거 스텝은 + 실패한다(의도된 명시적 실패 — 조용히 넘어가지 않는다). - **카탈로그 태그가 클러스터보다 앞서 있고 재검증 경로가 없다** — 게이트 PASS 만으로 - `patch-catalog-tag.py` 가 태그를 올린다. 2026-08-21 실측: 클러스터의 adc `20260812` · - apisix-ingress-controller `20260811` 이 검증된 것인데 카탈로그는 `20260813` · `20260820` - 이다. 소스 안 바뀐 재빌드라 위험은 낮지만 **게이트 PASS 는 동작을 증명하지 않는다** - (argocd `cp --update=none` 사례). [#32](https://github.com/paasup/dip-catalog/issues/32) - 코멘트에 실측을 남겼다. + `patch-catalog-tag.py`(이제 `catalog-tag-update.yml` 을 통해)가 태그를 올린다. 실측: + 클러스터에 배포된 태그가 카탈로그가 가리키는 태그보다 뒤처진 사례가 있었다. 소스 안 + 바뀐 재빌드라 위험은 낮지만 **게이트 PASS 는 동작을 증명하지 않는다** + (argocd `cp --update=none` 사례). `published.json` 의 `digest` 필드가 이 재검증의 + 실마리다(태그가 아니라 digest 로 "실제로 검증된 것과 카탈로그가 가리키는 것"을 + 대조할 수 있다) — 아직 그 대조를 실제로 하는 자동화는 없다. + [#32](https://github.com/paasup/dip-catalog/issues/32) 코멘트에 실측을 남겼다. - **워크플로 결함 3건** — `cve-edge-post.yml` 이 게이트를 안 부른다(판정기 두 벌) · 인증 스텝이 `sbom.yml` 과 diff 0 으로 복붙 · `scripts/pipeline/Dockerfile` 을 빌드하는 워크플로가 없다(손으로 push). diff --git a/catalog/image-map/README.md b/catalog/image-map/README.md new file mode 100644 index 0000000..dbc2182 --- /dev/null +++ b/catalog/image-map/README.md @@ -0,0 +1,29 @@ +# 이미지 → 차트 매핑 + +security-images 레포가 발행하는 자체 빌드 이미지가 이 카탈로그의 어느 차트·어느 필드를 +가리키는지 선언한다. **이 디렉토리에 파일이 있는 이미지 이름 = 이 카탈로그가 추적하는 +자체 빌드 이미지 목록**이다(`scripts/build/check-rebuild-needed.py`와 +`.github/workflows/catalog-tag-update.yml`이 이 목록을 기준으로 동작한다). + +이관 배경: 이 정보는 원래 security-images(당시 `images//catalog.env`)에 있었다. +"어느 이미지를 쓰는가"는 카탈로그가 알아야 하고 "그 이미지를 어떻게 만드는가"는 +security-images 가 알아야 하므로, 레포 분리 시 이 지식을 카탈로그 쪽으로 옮겼다 — +[doc/migrations/self-build-images-to-security-images.md](../../doc/migrations/self-build-images-to-security-images.md) +참고. + +## 파일 형식 — `.env` + +``는 security-images 레포의 `images//` 디렉토리명과 정확히 같아야 한다 +(`published.json`의 키도 같다). + +| 키 | 의미 | +| --- | --- | +| `CHART_DIRS` | 이 이미지의 태그를 참조하는 차트 버전 디렉토리 (공백 구분, 여러 개 가능) | +| `TAG_STYLE` | `imageName`(단일 필드 문자열) \| `split`(registry/repository/tag 분리) | +| `TAG_BLOCK` | `split`일 때 태그가 있는 블록의 점 구분 경로 (기본 `image`) | + +카탈로그는 한 차트의 여러 버전을 동시에 보관하는 "버전 보관소"다 — **이미지 하나가 +여러 버전 디렉토리에 걸리는 것이 정상**이다. 새 버전 디렉토리를 만들 때 이 매핑도 +함께 갱신한다. 빠뜨리면 `scripts/build/patch-catalog-tag.py`가 그 파일을 검사조차 +하지 않아 낡은 태그가 조용히 남는다(실측: `cnpg-postgresql`이 `cnpg-cluster/1.0.0`만 +매핑돼 있어 `1.1.0`이 낡은 태그로 남은 사고가 있었다). diff --git a/catalog/image-map/adc.env b/catalog/image-map/adc.env new file mode 100644 index 0000000..6108405 --- /dev/null +++ b/catalog/image-map/adc.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/apisix/2.16.0" +TAG_STYLE=split +TAG_BLOCK=ingress-controller.deployment.adcContainer.image diff --git a/catalog/image-map/apisix-ingress-controller.env b/catalog/image-map/apisix-ingress-controller.env new file mode 100644 index 0000000..c293569 --- /dev/null +++ b/catalog/image-map/apisix-ingress-controller.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/apisix/2.16.0" +TAG_STYLE=split +TAG_BLOCK=ingress-controller.deployment.image diff --git a/catalog/image-map/apisix.env b/catalog/image-map/apisix.env new file mode 100644 index 0000000..2d4837f --- /dev/null +++ b/catalog/image-map/apisix.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/apisix/2.16.0" +TAG_STYLE=split +TAG_BLOCK=image diff --git a/catalog/image-map/argocd.env b/catalog/image-map/argocd.env new file mode 100644 index 0000000..e54a10a --- /dev/null +++ b/catalog/image-map/argocd.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/argo-cd/10.4.0" +TAG_STYLE=split +TAG_BLOCK=global.image diff --git a/catalog/image-map/cloudnative-pg.env b/catalog/image-map/cloudnative-pg.env new file mode 100644 index 0000000..7ee4eb4 --- /dev/null +++ b/catalog/image-map/cloudnative-pg.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0" +TAG_STYLE=split +TAG_BLOCK=image diff --git a/catalog/image-map/cnpg-postgresql.env b/catalog/image-map/cnpg-postgresql.env new file mode 100644 index 0000000..639f691 --- /dev/null +++ b/catalog/image-map/cnpg-postgresql.env @@ -0,0 +1,2 @@ +CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0 manifests/helm/cnpg-cluster/1.1.0" +TAG_STYLE=imageName diff --git a/catalog/image-map/etcd.env b/catalog/image-map/etcd.env new file mode 100644 index 0000000..58114ef --- /dev/null +++ b/catalog/image-map/etcd.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/etcd/1.1.12" +TAG_STYLE=split +TAG_BLOCK=image diff --git a/catalog/image-map/keycloak.env b/catalog/image-map/keycloak.env new file mode 100644 index 0000000..b32a845 --- /dev/null +++ b/catalog/image-map/keycloak.env @@ -0,0 +1,3 @@ +CHART_DIRS="manifests/helm/keycloakx/7.2.2" +TAG_STYLE=split +TAG_BLOCK=image diff --git a/doc/cve-exceptions.json b/doc/cve-exceptions.json index 3fc1c56..85a2a5f 100644 --- a/doc/cve-exceptions.json +++ b/doc/cve-exceptions.json @@ -9,7 +9,10 @@ " - 예외는 '위험을 수용한다'는 기록이다. 숫자를 지우는 수단으로 쓰면 목표 자체가 무의미해진다.", "", "예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /", - "scripts/build/build-hardened-image.sh 로 자체 빌드. 예외는 마지막 수단이다." + "그래도 안 되면 별도 레포 security-images 에서 자체 빌드. 예외는 마지막 수단이다.", + "자체 빌드 이미지(docker.io/paasup/*)는 그 레포도 같은 이미지를 스캔하므로,", + "그 이미지에 대한 예외는 이 파일과 security-images 레포의 cve-exceptions.json", + "양쪽에 각각 등록해야 두 게이트 모두 통과한다." ], "exceptions": [ { diff --git a/doc/decisions/0001-cnpg-postgresql-image.md b/doc/decisions/0001-cnpg-postgresql-image.md deleted file mode 100644 index 5210e97..0000000 --- a/doc/decisions/0001-cnpg-postgresql-image.md +++ /dev/null @@ -1,63 +0,0 @@ -# 0001. cnpg-cluster 의 PostgreSQL 이미지를 SUSE BCI 자체 빌드로 한다 - -- 날짜: 2026-07-28 (결정) · 2026-08-03 (`docker.io/paasup` 로 재빌드) -- 상태: 확정 -- 원본: security-catalog ADR 0001 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고) - -## 결정 - -`manifests/helm/cnpg-cluster/1.0.0` 의 PostgreSQL 이미지를 SUSE BCI 15.7 기반 자체 빌드로 -한다. 현재 태그는 [custom-values.yaml](../../manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml) -의 `postgresql.imageName` 이 단일 출처이고, 빌드 정의는 -[images/cnpg-postgresql/](../../images/cnpg-postgresql/)(`suse.Dockerfile` + `suse.build.env`)다. - -## 배경 - -이전 값 `ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie` 에 두 문제가 있었다. - -1. `system` 은 **업스트림에서 deprecated 된 타입**이다 (in-core barman phase out 예정) -2. 실효 CRITICAL/HIGH **6/26**, 게이트 차단 **32건** (2026-07-28 실측) - -후보 셋을 비교했다. - -| | A `standard-trixie` | B ubuntu:24.04 자체 빌드 | **C SUSE BCI 15.7 자체 빌드** | -| --- | --- | --- | --- | -| 실효 고유 C/H | 23 | 0 | 0 | -| 측정 가능성 | trivy 완전 | trivy 완전 | trivy 완전 | -| 서명·attestation | ✅ | ❌ | ❌ | -| 미조사 사각지대 | 0 | 18건 | 18건 | -| 배포 검증 | — | failover 2초 | failover 3초 | -| gid | `postgres` | ⚠️ `tape` 가 gid 26 선점 | `postgres` | - -## 근거 - -- **A 의 차단 23건은 전부 수정 버전이 없다.** Debian 판정이 `unimportant` 9 / `no-dsa` 7 / - `postponed` 3 / 미분류 4 로, 어떤 조치로도 해소되지 않는다. KEV 교집합은 0건이다. -- **C 는 0건이고, 그 0건이 측정된 0건이다.** 결정 당시에는 "trivy 가 SLES 15.7 을 커버하지 - 않으므로 직접 평가해야 한다" 고 봤는데 **그 전제가 틀렸다**(2026-07-29 재측정). 깨끗한 - 이미지도 findings 0 이라 "0건" 과 "데이터 없음" 이 구분되지 않았던 것이 원인이다. 올바른 - 검사는 양성 대조이고, dip-catalog 는 그것을 `CoverageProbe` 로 **매 스캔마다** 한다 - ([doc/sbom-pipeline.md](../sbom-pipeline.md)). -- **C 는 B 의 gid 위생 문제가 없다.** B 는 gid 26 이 `tape` 그룹에 선점되어 있다. -- **C 는 배포 검증을 통과했다** — CNPG 오퍼레이터 통합, 롤링 전환, 데이터 보존, failover 3초. -- **SUSE 공식 PostgreSQL 차트는 대안이 아니었다** — 확장이 `plpgsql` 하나뿐이다. C 는 - `pgaudit`·`pgvector`·contrib 를 포함한다. - -## 받아들인 비용 - -- **업스트림 서명·provenance·SBOM attestation 을 잃는다.** -- **재빌드 책임을 진다.** PostgreSQL 마이너 릴리스마다, 베이스 보안 업데이트마다. 후보 B 에서 - 이미 실증됐다 — 빌드 후 하루도 안 되어 glibc 업데이트로 findings 38 → 46. -- **업스트림이 테스트하지 않는 구성이다.** CNPG bake 매트릭스는 Debian 3종뿐이다. -- **`pg-failover-slots` 확장이 없다.** PGDG zypp 저장소에 패키지가 없다. -- **미조사 사각지대 18건.** SUSE 는 "미평가" 를 문서 부재로 표현하므로 **무엇이 미평가인지 - 열거할 수 없다.** 이것은 어떤 도구를 쓰든 같다(이 이미지 기준 벤더 데이터가 언급하지 않는 - 패키지 61개 / 176개). `0/0` 의 근거가 그만큼 불완전하다. - -## 재검토 조건 - -- **A 의 차단 23건에 수정 버전이 도착하면** — 업스트림 이미지로 돌아가는 것이 유리해진다. - 게이트 리포트의 `status: fixed` 여부로 감지된다. -- **사각지대 18건 조사 결과가 C 에 불리하면.** -- **재빌드 부담이 실제로 문제가 되면** — 분기 1회 이상 재빌드가 필요해지는 경우. -- **SUSE 가 BCI 기반 CNPG 호환 이미지를 직접 발행하면** — 자체 빌드가 불필요해진다. diff --git a/doc/decisions/0002-cloudnative-pg-operator-self-build.md b/doc/decisions/0002-cloudnative-pg-operator-self-build.md deleted file mode 100644 index 866e4b1..0000000 --- a/doc/decisions/0002-cloudnative-pg-operator-self-build.md +++ /dev/null @@ -1,72 +0,0 @@ -# 0002. cloudnative-pg 오퍼레이터를 소스 컴파일 자체 빌드로 대체하고, 자체 빌드 오케스트레이션을 하나로 통일한다 - -- 날짜: 2026-07-30 (결정) · 2026-08-04 (`docker.io/paasup` 로 재빌드) -- 상태: 확정 -- 원본: security-catalog ADR 0005 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고) - -## 결정 - -`manifests/helm/cloudnative-pg/0.29.0` 의 오퍼레이터 이미지를 자체 빌드로 한다. 현재 태그는 -[custom-values.yaml](../../manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml) 의 -`image.repository`/`image.tag` 가 단일 출처이고, 빌드 정의는 -[images/cloudnative-pg/](../../images/cloudnative-pg/)다 — 업스트림 `release-1.30` 브랜치의 -pinned commit 을 직접 `go build` 로 컴파일하고, 최종 런타임 베이스는 SUSE BCI(`bci-micro`)다. - -**추가로, 이 작업에서 `scripts/build/build-hardened-image.sh` 를 일반화해 OS 패키지 -재설치형(`cnpg-postgresql`)과 소스 컴파일형(`cloudnative-pg`)이 오케스트레이션 스크립트 -하나를 공유하게 했다.** 자체 빌드 이미지가 늘어날 때마다 방법론이 갈라지지 않게 하기 -위함이다. 계약은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다. - -## 배경 - -업스트림 `cloudnative-pg:1.30.0` 이 게이트에서 실효 HIGH 3건으로 차단됐다 — `CVE-2026-39822` -(stdlib), `CVE-2026-56852`(`golang.org/x/text`), `GHSA-hrxh-6v49-42gf`(grpc). - -셋 다 Go 바이너리에 **정적 링크된 모듈 버전**이 원인이라 앞선 두 레버가 통하지 않는다. - -- 상위 태그 교체 — 상위 태그가 없다(2026-07-30 확인, 최신 릴리스가 여전히 `v1.30.0`) -- 베이스 OS 교체 — 배포 이미지 베이스가 distroless 라 OS 패키지가 사실상 0개다 - -자체 빌드만 유효한 대응이었다. 세 CVE 모두 업스트림 `release-1.30` 브랜치에 이미 백포트돼 -있어, Go 의존성을 직접 올릴 필요 없이 그 커밋을 그대로 컴파일하면 됐다. - -## 근거 - -- **`release-1.30` HEAD 는 실측으로 확인된 안전한 픽업 지점이다.** `go.mod` 비교로 세 CVE 의 - 수정 버전이 전부 포함됨을 확인했고(stdlib 1.26.5, x/text 0.39.0, grpc 1.82.1), 재스캔으로 - 실효 C/H 0/0 을 확인했다. -- **게이트 통과가 "동작함" 을 증명하지 않는다는 원칙이 실제로 작동했다.** 1차 빌드는 게이트를 - 0/0 으로 통과했지만 배포 검증에서 `Cluster` 리컨실이 `invalid architecture: amd64` 로 - 실패했다. 오퍼레이터가 자신의 가용 아키텍처를 `operator/manager_` **파일 존재**로 - 판단하는데, 업스트림의 멀티아치 심볼릭 링크를 "부수 장치" 로 오판해 제거했기 때문이다. - 같은 바이너리를 `/manager` 와 `/operator/manager_amd64` 양쪽에 COPY 해 해결했다. -- **베이스 OS 는 BCI 로 통일한다는 결정([0001](0001-cnpg-postgresql-image.md))이 이 이미지에도 - 적용된다.** 최초 설계는 "업스트림과 최대한 동일하게" 를 따라 distroless 를 그대로 썼으나 - 카탈로그 정책과 어긋나 `bci-micro` 로 교체했고, 교체 후에도 게이트·배포 검증 PASS 를 - 유지하는 것을 확인했다. -- **오케스트레이션 통일이 회귀를 만들지 않는 것을 확인했다** — 스크립트를 일반화한 직후 기존 - `cnpg-postgresql` 을 다시 빌드해 태그·게이트 결과가 동일함을 확인했다. - -## 받아들인 비용 - -- **업스트림 서명·provenance·SBOM attestation 을 잃는다.** ADR [0001](0001-cnpg-postgresql-image.md) - 과 동일한 비용이다. -- **`SOURCE_COMMIT` 갱신을 사람이 한다.** 자동 추적하지 않는다 — 다음 CVE 발생 시 그 시점의 - 유지보수 브랜치 최신 커밋을 사람이 골라 `source.build.env` 를 고쳐 PR 을 여는 것 자체가 - 트리거다. -- **`release-1.30` 은 순수 패치 백포트가 아니다.** 기능 커밋 2개(`feat(pooler)` auth_user, - `feat` check_empty_wal_archive)가 섞여 있다. 59 commits 전체를 라인 단위로 감사하지 않았고, - 배포 검증 스모크 수준으로만 회귀 여부를 봤다. -- **업스트림이 테스트하지 않는 구성이다.** BCI 기반 오퍼레이터 빌드는 CNPG 의 CI 매트릭스에 없다. -- **`bci-micro` 는 `bci-base` 와 달리 nonroot 계정이 없어 직접 만들어야 한다** — BCI - micro/minimal 계열을 쓰는 다른 자체 빌드 이미지도 같은 작업이 필요하다. - -## 재검토 조건 - -- **업스트림이 `v1.30.1` 이상을 정식 릴리스하면** — 상위 태그 교체가 다시 가능해지므로 자체 - 빌드보다 우선한다. -- **`release-1.30` 의 기능 커밋 2개가 실제 회귀를 일으키면** — 기능 커밋 이전 시점 등 다른 - pinned commit 으로 되돌린다. -- **SUSE 가 CNPG 호환 공식 오퍼레이터 이미지를 발행하면** — 자체 빌드가 불필요해진다. -- **`bci-micro` 에서 예기치 못한 런타임 문제(CA 신뢰 체인, TLS 등)가 나오면** — `bci-base` 로 - 승격하거나 다른 BCI 변종을 재검토한다. diff --git a/doc/decisions/0003-etcd-chart-selection.md b/doc/decisions/0003-etcd-chart-selection.md index b429603..bbe0a66 100644 --- a/doc/decisions/0003-etcd-chart-selection.md +++ b/doc/decisions/0003-etcd-chart-selection.md @@ -35,7 +35,8 @@ dev 클러스터 `apisix` 네임스페이스에 이미 `data-apisix-etcd-0` PVC 1. 패치가 멈춘 이미지는 시간이 지날수록 미수정 CVE 가 구조적으로 누적된다 — "CRITICAL/HIGH 0건" 을 유지 가능한 상태로 지속할 수 없다. 2. 버전 고정 없는 `latest`-only 배포는 **롤링 태그 금지** 규칙과 애초에 양립하지 않는다 - ([.claude/image-authoring.md](../../.claude/image-authoring.md)). + (자체 빌드 이미지에 적용되는 이 규칙은 `security-images` 레포의 `docs/image-authoring.md` + 가 갖는다). `groundhog2k/etcd` 는 업스트림 etcd 프로젝트의 원본 이미지(`quay.io/coreos/etcd`)를 그대로 쓰므로 이 문제가 없다 — 다른 카탈로그 항목과 동일한 "업스트림 원본 이미지 + 버전 고정 태그" diff --git a/doc/decisions/0004-etcd-image-self-build.md b/doc/decisions/0004-etcd-image-self-build.md deleted file mode 100644 index 0caecc2..0000000 --- a/doc/decisions/0004-etcd-image-self-build.md +++ /dev/null @@ -1,66 +0,0 @@ -# 0004. etcd 이미지를 소스 컴파일 자체 빌드로 대체한다 - -- 날짜: 2026-07-31 (결정) · 2026-08-04 (`docker.io/paasup` 로 재빌드) -- 상태: 확정 -- 원본: security-catalog ADR 0007 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고) - -## 결정 - -[manifests/helm/etcd/1.1.12/](../../manifests/helm/etcd/1.1.12/) 의 이미지를 자체 빌드로 -한다. 현재 태그는 `custom-values.yaml` 의 `image.registry`/`image.repository`/`image.tag` 가 -단일 출처이고, 빌드 정의는 [images/etcd/](../../images/etcd/)다 — 업스트림 `v3.7.1` 태그가 -가리키는 commit 을 그대로 컴파일하되, `go.work` 워크스페이스 전역 `replace` 로 -`golang.org/x/text` 만 강제 업그레이드한다(버전은 `source.build.env` 의 `XTEXT_FIX_VERSION`). -최종 런타임 베이스는 SUSE BCI(`bci-micro`) — [0002](0002-cloudnative-pg-operator-self-build.md) -와 같은 패턴이다. - -**추가로, 이 작업에서 `build-image.yml` CI 를 이미지 종류 무관하게 일반화했다** -(`images//catalog.env` + `scripts/build/patch-catalog-tag.py`) — [0002](0002-cloudnative-pg-operator-self-build.md) -의 백로그였다. 자체 빌드 이미지가 둘일 때는 하드코딩으로 버틸 수 있었지만 세 번째가 생기며 -실제로 한계에 부딪혔다. - -## 배경 - -업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트에서 실효 HIGH 1건으로 차단됐다 — -`CVE-2026-56852`, `golang.org/x/text` 의 깨진 UTF-8 입력에 대한 `norm.Iter` 무한루프 DoS. - -Go 바이너리에 정적 링크된 모듈 버전이 원인이라 베이스 OS 교체가 통하지 않고, 상위 태그도 -없다(2026-07-31 확인, 최신 릴리스가 여전히 `v3.7.1`). 게다가 [0002](0002-cloudnative-pg-operator-self-build.md) -와 달리 **`release-3.7` 브랜치에 이 수정이 백포트조차 안 돼 있다**(브랜치 HEAD 가 태그와 -완전히 동일). 자체 빌드 외에는 즉시 대응할 방법이 없었다. - -## 근거 - -- **워크스페이스 전역 `replace` 한 줄로 게이트가 PASS 로 바뀌는 것을 실측했다** — 실효 C/H - 0/1 → **0/0**. 이미 백포트된 커밋을 그대로 가져다 쓸 수 없었지만, 취약 모듈이 이 CVE - 하나뿐이라 직접 강제 업그레이드로도 충분했다. -- **기능 검증(단일 노드 기동 + `etcdctl` put/get 왕복)이 통과했다.** 게이트 PASS 가 "동작함" - 을 증명하지 않으므로 `images/etcd/verify.sh` 로 확인한다. 최초 시도는 최종 베이스에 `sed` - 가 없어 실패했다(`sh: sed: command not found`) — `bci-base` 에는 있지만 `bci-micro` 에는 - 없다는 것을 이때 처음 확인했다. 순수 셸 루프로 교체해 해결하고 그 제약을 - [.claude/image-authoring.md](../../.claude/image-authoring.md) 에 기록했다. -- **CI 일반화가 기존 이미지에 회귀를 만들지 않는 것을 확인했다** — `catalog.env` 를 세 - 이미지에 추가한 뒤 매트릭스로 동시에 검증 빌드했고 기존 두 이미지도 동일하게 PASS 했다. -- **태그 치환 로직을 실제 카탈로그 파일 사본에 돌려 검증했다** — `imageName` 단일 필드 - (cnpg-cluster)와 `image:` 분리 필드(cloudnative-pg, etcd) 두 스타일 모두 기존 주석·포매팅을 - 보존한 채 정확히 치환됨을 diff 로 확인했다. - -## 받아들인 비용 - -- **업스트림 서명·provenance·SBOM attestation 을 잃는다.** [0001](0001-cnpg-postgresql-image.md) - ·[0002](0002-cloudnative-pg-operator-self-build.md) 와 동일한 비용이다. -- **`SOURCE_COMMIT`·`XTEXT_FIX_VERSION` 갱신을 사람이 한다.** 자동 추적하지 않는다. -- **매 patch 릴리스마다 이 자체 빌드를 다시 판단해야 한다** — 업스트림이 이 CVE 를 - `release-3.7` 에 백포트하기 전까지는 "이미 백포트된 안전한 픽업 지점" 이 없어 재검토 부담이 - [0002](0002-cloudnative-pg-operator-self-build.md) 보다 크다. -- **업스트림이 테스트하지 않는 구성이다.** BCI 기반 etcd 빌드는 etcd 의 CI 매트릭스에 없다. -- **강제 업그레이드가 `server`/`etcdctl`/`etcdutl` 각각에 반영됐는지는 SBOM 스캔으로만 간접 - 확인했다** — `go version -m` 직접 대조는 하지 않았다. - -## 재검토 조건 - -- **etcd 가 `release-3.7` 에 이 CVE 를 백포트하고 `v3.7.2` 이상을 릴리스하면** — 상위 태그 - 교체가 다시 가능해지므로 자체 빌드보다 우선한다. -- **`bci-micro` 에서 예기치 못한 런타임 문제가 나오면** — `bci-base` 로 승격하거나 다른 BCI - 변종을 재검토한다([0002](0002-cloudnative-pg-operator-self-build.md) 와 동일한 조건). -- **다중 노드(쿼럼) 배포 검증에서 이 자체 빌드 이미지에 문제가 발견되면.** diff --git a/doc/decisions/README.md b/doc/decisions/README.md index b9d6482..7cbab5f 100644 --- a/doc/decisions/README.md +++ b/doc/decisions/README.md @@ -1,6 +1,6 @@ # 결정 기록 (ADR) -카탈로그의 이미지·차트 선택 중 **재측정으로 복원되지 않는 것**만 여기 둔다. +카탈로그의 차트 선택 중 **재측정으로 복원되지 않는 것**만 여기 둔다. CVE 건수·커버리지·패키지 버전 같은 수치는 게이트가 매 실행마다 다시 재므로 문서로 남기지 않는다([doc/sbom-pipeline.md](../sbom-pipeline.md)). 반면 "왜 이 후보를 골랐고 무엇을 비용으로 @@ -8,31 +8,22 @@ CVE 건수·커버리지·패키지 버전 같은 수치는 게이트가 매 실 | # | 결정 | 날짜 | 상태 | |---|---|---|---| -| [0001](0001-cnpg-postgresql-image.md) | cnpg-cluster 의 PostgreSQL 이미지를 SUSE BCI 자체 빌드로 한다 | 2026-07-28 | 확정 | -| [0002](0002-cloudnative-pg-operator-self-build.md) | cloudnative-pg 오퍼레이터를 소스 컴파일 자체 빌드로 대체한다 | 2026-07-30 | 확정 | | [0003](0003-etcd-chart-selection.md) | etcd 는 `groundhog2k/etcd` 단일 차트 · replicas=1 로 배포한다 | 2026-07-31 | 확정 | -| [0004](0004-etcd-image-self-build.md) | etcd 이미지를 소스 컴파일 자체 빌드로 대체한다 | 2026-07-31 | 확정 | + +**이미지 자체 빌드 ADR(0001·0002·0004)은 별도 레포 `security-images` 로 이관됐다** — +그 이미지들의 빌드 정의와 함께 그 레포의 `docs/decisions/` 에 있다. 카탈로그가 무엇을 +배포하는가(차트)와 그 이미지를 어떻게 만드는가(자체 빌드)가 다른 레포에 놓이며 근거도 +함께 옮긴 것이다. 이관 배경은 +[doc/migrations/self-build-images-to-security-images.md](../migrations/self-build-images-to-security-images.md). +번호가 0003 하나만 남아 이어지지 않아 보이지만, 다른 세 번호가 예약돼 있던 것일 뿐이고 +새 ADR 은 0005 부터 잇는다. ## 이 문서들의 출처 -네 결정은 **security-catalog 프로젝트에서 내려졌고**, 그 레포의 ADR 0001·0005·0006·0007 이 -원본이다. dip-catalog 의 이미지·차트가 그 결정의 산물이므로 이 레포에도 근거가 있어야 한다. - -**원문을 복사하지 않고 다시 썼다.** 원본은 같은 레포의 다른 문서(`doc/analysis/*`, -`doc/plans/*`)를 근거로 인용하는데, 그것들까지 연쇄로 가져오면 dip-catalog 에 이중 기록이 -생기고 그러고도 링크가 또 밖으로 나간다. 그래서 **결론과 근거만 추려 자립적으로** 옮겼다 — -이 디렉토리의 문서는 레포 밖을 가리키는 링크가 없다. - -이관하지 않은 것과 그 이유. 아래 경로는 **security-catalog 안의 경로이고 이 레포에는 없다** — -링크로 걸지 않은 이유가 그것이다. - -| security-catalog 의 원본 | 왜 안 가져왔나 | -|---|---| -| `analysis/sles-oval-measurement.md` | 스캐너 커버리지 실측. 원문 스스로 "재측정하면 갱신된다" 고 밝히는 스냅샷이고, dip-catalog 는 이것을 `CoverageProbe` 로 **매 스캔마다 자동 재측정**한다 | -| `analysis/*-cve.md` (3종) | 착수 시점의 CVE 스냅샷. 결론은 각 ADR 과 `images//README.md` 에 들어 있고, 현재 수치는 게이트가 낸다 | -| `cve-zero-pipeline.md` · `architecture/build-pipeline.md` | [doc/sbom-pipeline.md](../sbom-pipeline.md) 와 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 dip-catalog 의 단일 출처다 | -| `image-selection.md` | 이미지 선정 규칙은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다 | -| `charts/*/deploy-test.md` | 배포 검증은 `scripts/deploy-test/*.sh` 가 실행하고 절차는 [.claude/deploy-test-procedure.md](../../.claude/deploy-test-procedure.md) 가 갖는다. 기록은 실행 산출물이라 문서로 고정하지 않는다 | +이 카탈로그의 결정은 원래 **security-catalog 프로젝트에서 내려졌다.** 결정 당시의 원본 +분석·계획 문서(`doc/analysis/*`, `doc/plans/*`)까지 연쇄로 가져오면 이중 기록이 생기므로 +**결론과 근거만 추려 자립적으로** 옮겼다 — 이 디렉토리의 문서는 레포 밖을 가리키는 링크가 +없다. ## 새 ADR 을 언제 쓰나 diff --git a/doc/migrations/self-build-images-to-security-images.md b/doc/migrations/self-build-images-to-security-images.md new file mode 100644 index 0000000..deee8fc --- /dev/null +++ b/doc/migrations/self-build-images-to-security-images.md @@ -0,0 +1,95 @@ +# 자체 빌드 하드닝 이미지 → `security-images` 레포 이관 + +이 문서는 자체 빌드 이미지 프레임워크·이미지 정의를 별도 public 레포 +`security-images` 로 분리한 작업의 기록이다. 무엇이 어디로 갔고, 두 레포가 지금 +무엇으로 계약하는지, 아직 안 된 것이 무엇인지를 담는다. + +## 배경 + +이 카탈로그는 두 서로 다른 성격의 축을 한 레포에 담고 있었다. + +- **차트 카탈로그 축** — "무엇을 배포 중인가" (`manifests/helm/`, warn-only 게이트) +- **자체 빌드 축** — "그 이미지를 어떻게 만드는가" (`images/`, 강제 게이트) + +`security-images` 가 public 이 될 예정이라 세 조건이 붙었다: **외부 의존성 없이 +단독 동작**(클론 + `docker` + `trivy` 만으로 빌드·게이트 완결), **슬림 게이트**(이미지 +판정에 불필요한 차트 축 로직 제거), **산업화**(내부 호스트명·개인 계정·이슈/PR 번호 +제거). 이 조건들이 원래 `.claude/image-authoring.md` 가 적어 두었던 "레포 분리 후 +무엇이 끊기는가" 계획을 여러 지점에서 다시 설계하게 만들었다 — 아래 "원래 계획과 +달라진 점"이 그 차이다. + +## 무엇이 옮겨갔는가 + +| 카탈로그(이 레포)에 있던 것 | `security-images` 로 | +|---|---| +| `images//` 8개 | 그대로(경로 보존) | +| `scripts/build/build-hardened-image.sh` | 그대로(경로 보존) | +| `scripts/build/suggest-go-upgrades.py` | `--apply`/`--dry-run` 신규 추가 | +| `scripts/pipeline/scan-sbom.sh` · `cve-gate.py` (이미지 판정 부분만) | `scripts/gate/scan-image.sh` · `image-gate.py` (슬림화) | +| `.github/workflows/build-image.yml` | 카탈로그 무의존으로 재구성(`catalog.env` 의존 제거, drift 모드 제거) | +| `.claude/image-authoring.md` | `docs/image-authoring.md` (정책 중심으로 재작성) | +| `doc/decisions/0001·0002·0004`(이미지 ADR) | `docs/decisions/`(같은 번호 유지) | +| `.claude/skills/self-build-image/` | 그대로(문서 경로만 수정) | + +## 무엇이 남았는가 (이 레포) + +카탈로그가 "무엇을 배포 중인가"를 알아야 하는 부분만 남았다 — 전부 새로 만든 것이다. + +| 경로 | 역할 | +|---|---| +| `catalog/image-map/.env` | 자체 빌드 이미지 → 어느 차트의 어느 필드(`CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK`). 옛 `images//catalog.env` 의 카탈로그 레이아웃 정보만 뗀 것 | +| `scripts/build/check-rebuild-needed.py` | 드리프트 탐지(A 파트만) — 배포 중인 이미지가 새 CVE 로 규정을 벗어났는가. 핀 판단(B 파트)은 제거했다 — `security-images` 의 `suggest-go-upgrades.py` 가 갖는다 | +| `scripts/build/apply-published-tags.py` | `security-images` 의 `published.json` 을 카탈로그 values 에 반영 | +| `scripts/build/patch-catalog-tag.py` | 변경 없음(원래도 카탈로그 파일만 다루는 순수 도구였다) | +| `.github/workflows/self-build-drift-check.yml` | 주간, 배포 중인 이미지 스캔 → 재빌드 필요하면 `security-images` 의 `build-image.yml` 을 `workflow_dispatch` 로 트리거만 | +| `.github/workflows/catalog-tag-update.yml` | 매일, `published.json` 조회 → 카탈로그 values 패치 → 브랜치 push | + +## 계약 — `published.json` 하나뿐 + +`security-images` 는 **이 카탈로그를 모른다.** 게이트 PASS + 레지스트리 push 가 실제로 +일어났을 때만 자기 레포의 `published.json` 을 갱신한다: + +```json +{"schemaVersion": 1, "images": {"": {"ref": "...", "tag": "...", "digest": "...", "gate": "pass"}}} +``` + +`catalog-tag-update.yml` 이 이 파일을 **public raw URL** 로 읽어간다 — 인증도, 그 +레포의 시크릿도 필요 없다. 의존 방향은 **카탈로그 → 이미지 단방향**이다. +`repository_dispatch` 같은 역방향 알림은 쓰지 않는다 — 그러려면 `security-images` +시크릿에 이 카탈로그 쓰기 권한 PAT 을 둬야 하는데, 그건 그 레포의 "외부 의존성 없이 +단독 동작" 원칙과 충돌한다. + +## 원래 계획과 달라진 점 + +`.claude/image-authoring.md`(삭제됨, 원문은 `security-images` 의 git 히스토리에)의 +"레포 분리 후 무엇이 끊기는가" 는 게이트를 **composite action** 으로 공개해 두 축이 +`uses:` 로 공유하는 안을 제시했다. 실제로는 그렇게 하지 않았다 — 두 가지 이유다. + +1. **`suggest-go-upgrades.py` 가 `cve-gate.py` 를 `importlib` 로 모듈 로드했다.** + composite action 은 워크플로 스텝만 공유하고 파이썬 모듈 임포트를 공유하지 못한다. + `effective_severity` 함수를 게이트의 "정식 소유"로 옮기고(이 레포의 `cve-gate.py` · + `security-images` 의 `image-gate.py` 양쪽에 독립적으로), 두 게이트가 완전히 갈라지는 + 쪽을 택했다. +2. **게이트 규칙 분기를 허용하기로 했다.** 차트 축은 warn-only, 자체 빌드 축은 강제라 + 강제력부터 다르다. 공유 action 대신 각자 자기 게이트를 소유하고, `max(벤더, NVD)` + 계산과 예외 파일 스키마만 같게 유지하기로 했다(승인 예외는 카탈로그 자체 빌드 + 이미지에도 별도로 등록해야 한다 — `doc/cve-exceptions.json` 의 `_readme` 참고). + +카탈로그 반영도 원래 계획(`patch-catalog-tag.py` 를 카탈로그 쪽에서 그대로 재사용)은 +맞았지만, **트리거 방식**이 달라졌다 — `repository_dispatch` 가 아니라 `published.json` +을 카탈로그가 주기적으로 읽어가는 pull 방식이다. 위 "계약" 절 참고. + +## 아직 안 된 것 + +- **`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿 미등록.** `self-build-drift-check.yml` 이 + `security-images` 의 `build-image.yml` 을 `workflow_dispatch` 로 부르려면 + 그 레포에 `workflow_dispatch` 권한이 있는 PAT 이 필요하다. 등록 전까지 트리거 스텝은 + 명시적으로 실패한다. +- **`security-images` 레포 자체가 아직 GitHub 에 없다.** 로컬 레포만 있는 상태에서 + 이 이관을 진행했다 — GitHub 레포 생성·push, `DOCKERHUB_USER`/`DOCKERHUB_TOKEN` 시크릿 + 등록이 선행돼야 위 두 워크플로가 실제로 동작한다. +- **`published.json` 의 `digest` 필드 대부분 공란.** `security-images` 가 아직 실제로 + 이미지를 재빌드·push 하지 않아서다. 다음 빌드가 채운다 — MEMORY.md 의 "카탈로그 태그가 + 클러스터보다 앞서 있다" 항목이 이 필드를 쓸 계획이다. +- **`manifests/applicationset/**` 는 `catalog/image-map/` 대상이 아니다.** 옛 + `catalog.env` 도 그랬다 — MEMORY.md #42(고착된 낡은 차단 태그)와 같은 공백이다. diff --git a/doc/sbom-pipeline.md b/doc/sbom-pipeline.md index 32d78c4..0b96f91 100644 --- a/doc/sbom-pipeline.md +++ b/doc/sbom-pipeline.md @@ -11,8 +11,9 @@ GitHub Actions([.github/workflows/sbom.yml](../.github/workflows/sbom.yml))로 | | 다룬다 — **차트 카탈로그 축** | 다루지 않는다 — **자체 빌드 축** | |---|---|---| | 질문 | 우리가 배포하는 이미지에 무엇이 있는가 | 그 이미지를 어떻게 만드는가 | -| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `build-image.yml` | -| 단일 출처 | **이 문서** | [.claude/image-authoring.md](../.claude/image-authoring.md) | +| 실행 위치 | 이 레포 | 별도 레포 `security-images` | +| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `security-images` 의 `build-image.yml`(이 레포에서는 `self-build-drift-check.yml` 이 필요할 때 그것을 부른다) | +| 단일 출처 | **이 문서** | `security-images` 레포의 `docs/image-authoring.md` | **카탈로그 values 가 자체 빌드 이미지(`docker.io/paasup/*`)를 가리키므로 그 이미지도 이 문서의 스캔 대상이다** — 축이 갈린 것은 "누가 만들고 결정하는가" 이고 "누가 스캔되는가" 가 아니다. @@ -136,8 +137,8 @@ docker buildx build --platform linux/amd64 \ > python 으로 갖고 있어 **승인 예외(`cve-exceptions.json`)도 실효 등급(`max(벤더,NVD)`)도 > 적용하지 않는다.** 같은 스캔 데이터에서 다른 숫자가 나올 수 있다. -**PR 을 실제로 막는 게이트는 `images/**` 를 건드린 PR 에만 있다** — 이 축의 `sbom.yml` 은 -warn-only 다. +**PR 을 실제로 막는 게이트는 이 축에 없다** — `sbom.yml` 은 warn-only 다. 강제 게이트는 +자체 빌드 축(`security-images` 레포의 `images/**` PR)에만 있다. > **`manifests/applicationset/**` 를 스캔하지 않는 것은 의도다.** 그 아래 `dip-values.yaml` 은 > dip-console 이 배포 values 를 만들 때 쓰는 **참조 파일**이고, 같은 이미지를 `manifests/helm/` @@ -146,7 +147,8 @@ warn-only 다. > > 단, 그 가정은 **"두 곳이 같은 이미지를 가리킨다"** 에 의존한다. 참조 파일이 차트와 다른 > 태그를 들고 있으면 스캔한 것과 배포되는 것이 갈린다 — 태그 동기화는 스캔 커버리지와 별개 -> 문제이고 `scripts/build/patch-catalog-tag.py` 의 `CHART_DIRS` 범위가 그것을 결정한다. +> 문제이고, 자체 빌드 이미지에 대해서는 `catalog/image-map/.env` 의 `CHART_DIRS` 가 +> 그 범위를 결정한다(`manifests/applicationset/**` 는 포함하지 않는다 — MEMORY.md #42 참고). ### `sbom.yml` 스캔 범위 @@ -193,7 +195,8 @@ Repo Secret `CVE_API_KEY`). **`sbom.yml` 에서는 아직 `--warn-only` 다** — 게이트가 실패해도 워크플로/PR 을 막지 않는다. 카탈로그 차트 전체가 아직 이 게이트로 트리아지된 적이 없어, 강제 전환 전에 먼저 전체 스캔 1회로 현황을 -파악해야 한다(`MEMORY.md`). `build-image.yml` 의 게이트는 이미 강제다. +파악해야 한다(`MEMORY.md`). 자체 빌드 축(`security-images` 레포의 `build-image.yml`)의 +게이트는 이미 강제다 — 단, 그 게이트는 별도 레포가 소유한다. > **커버리지 자가진단(`CoverageProbe`).** `scan-sbom.sh` 는 os-pkgs findings 가 0건인 > 이미지의 SBOM 사본에 배포판별(deb/rpm/apk) 센티널 패키지를 주입해 재스캔하고, 발화 @@ -205,15 +208,15 @@ Repo Secret `CVE_API_KEY`). ## 자체 빌드 축은 이 문서가 다루지 않는다 게이트가 상위 태그 교체·베이스 OS 교체로 해소되지 않는 차단 CVE 를 찾으면 자체 빌드로 간다. -그 축의 단일 출처는 **[.claude/image-authoring.md](../.claude/image-authoring.md)** 다 — -`build-image.yml` 의 트리거·게이트 강도·카탈로그 반영·레포 분리 계획이 전부 거기 있다. +그 축은 **별도 레포 `security-images`** 가 갖는다 — 빌드·검증·게이트·push 전부 그 레포 +안에서 이루어지고, 단일 출처는 그 레포의 `docs/image-authoring.md` 다. 이 카탈로그에는 +"어느 차트가 그 이미지를 가리키는가"(`catalog/image-map/`)와 드리프트 탐지 +(`scripts/build/check-rebuild-needed.py`, 주간 `self-build-drift-check.yml`)만 남아 있다 — +이관 배경은 [doc/migrations/](migrations/self-build-images-to-security-images.md). 이 문서가 알아야 할 것은 하나뿐이다: **자체 빌드 이미지도 카탈로그가 가리키는 한 위 스캔·게이트 대상이다.** 실제로 `docker.io/paasup/*` 가 게이트 리포트에 등장한다. -> `build-image.yml` 이 `sbom.yml` 과 별도 파일인 이유: `sbom.yml` 은 `vars.SBOM_PIPELINE_IMAGE` -> 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. 빌드는 호스트 러너여야 한다. - ## GitHub 설정 (워크플로 활성화에 필요) `Settings → Secrets and variables → Actions` @@ -221,7 +224,7 @@ Repo Secret `CVE_API_KEY`). | 종류 | 이름 | 용도 | |------|------|------| | **Variable** | `SBOM_PIPELINE_IMAGE` | 실행 이미지 태그 (예: `docker.io/paasup/sbom-pipeline:20260820`) | -| Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io + docker.getcollate.io rate limit 회피**. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. `build-image.yml` 의 push 에도 같은 시크릿을 쓴다 | +| Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io + docker.getcollate.io rate limit 회피**. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. `security-images` 레포도 자체 push 용으로 별도 등록된 같은 이름의 시크릿을 쓴다(이 레포와는 무관하게 그 레포에 따로 등록) | | Secret | `NGC_API_KEY` | **nvcr.io 인증**(NVIDIA nemo/nim — 없으면 pull 불가) | | Secret | `CVE_API_KEY` | `cve-edge-post.yml` 의 외부 엔드포인트 인증 | diff --git a/images/adc/README.md b/images/adc/README.md deleted file mode 100644 index f661322..0000000 --- a/images/adc/README.md +++ /dev/null @@ -1,129 +0,0 @@ -# adc — 자체 빌드 - -adc(APISIX/API7 관리 CLI, apisix-ingress-controller 의 사이드카) 를 업스트림 소스에서 -SUSE BCI 위에 직접 빌드한다. `manifests/helm/apisix/2.16.0/custom-values.yaml`의 -`ingress-controller.deployment.adcContainer.image.repository`/`tag`가 이 산출물을 -가리킨다. - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 — "태그 교체로 끝난 줄 알았는데 아니었다" - -apisix 카탈로그 CVE 조치 중 `ghcr.io/api7/adc`를 0.27.1 → 0.29.0(확인 시점 최신 태그) -으로 올렸다. 업스트림 0.29.0 릴리스 노트: "distroless 이미지로 전환해 베이스 이미지 -취약점을 0으로 줄였다" — 실제로 `trivy image` 원시 스캔은 CRITICAL/HIGH 0/0 이 맞다. - -**하지만 이건 벤더 등급만 본 결과다.** 카탈로그 게이트(`scripts/pipeline/cve-gate.py`) -는 `max(벤더, NVD)`로 재평가하는데, 이걸로 다시 돌리면 아래 4건이 실효 -CRITICAL/HIGH 로 여전히 차단한다(2026-08-12 실측): - -| CVE | 실효 등급 | 벤더 | NVD | 패키지 | status | -| --- | --- | --- | --- | --- | --- | -| CVE-2019-1010022 | CRITICAL | LOW | CRITICAL (9.8) | libc6 | affected, 수정 없음 | -| CVE-2019-1010023 | HIGH | LOW | HIGH (8.8) | libc6 | affected, 수정 없음 | -| CVE-2018-20796 | HIGH | LOW | HIGH (7.5) | libc6 | affected, 수정 없음 | -| CVE-2019-9192 | HIGH | LOW | HIGH (7.5) | libc6 | affected, 수정 없음 | - -전부 2018~2019년 glibc regex/collating 스택오버플로 버그다. Debian 이 "affected, -수정 버전 없음"으로 영구 고정해뒀다 — `cnpg-postgresql` 자체 빌드 때 겪은 것과 완전히 -같은 패턴("Debian 은 unimportant/no-dsa 로 판단해 안 고치는데 다른 배포판은 이미 -백포트했음"). 이미 최신 태그(0.29.0)라 상위 태그 교체 레버는 소진됐고, 남은 건 베이스 -OS 교체뿐이다. - -## SUSE 로 바꾸면 실제로 해소되는지 사전 검증 - -`registry.suse.com/bci/bci-base:15.7` + `zypper install nodejs24`만으로 최소 이미지를 -만들어 SBOM·스캔해봤다 — **위 4건이 전혀 안 잡힌다**(2026-08-12 실측). SUSE 글리브C -에는 이미 고쳐져 있다는 뜻이다. - -## 업스트림과 다르게 하는 부분 — 딱 한 줄 - -업스트림 Dockerfile(`libs/tools/src/docker/Dockerfile`, 태그 `v0.29.0`)은 2단계다: - -```dockerfile -FROM node:lts-bookworm-slim AS builder # pnpm+nx 로 main.cjs 하나로 번들 -... -FROM gcr.io/distroless/nodejs24-debian13:nonroot -COPY --from=builder /build/dist/apps/cli/main.cjs . -ENTRYPOINT [ "/nodejs/bin/node", "main.cjs" ] -``` - -빌더 스테이지(pnpm/nx 빌드)는 정책 대상이 아니라(`.claude/image-authoring.md` 원칙 2) -**업스트림 그대로 재사용한다.** 바뀌는 건 최종 스테이지 베이스 한 줄뿐이다: - -- `gcr.io/distroless/nodejs24-debian13:nonroot` → `registry.suse.com/bci/bci-base:15.7` - + `zypper install nodejs24`(같은 Node 24 메이저 유지 — SUSE 표준 패키지라 - cnpg-postgresql 때 겪은 openresty 전용 -devel 문제도 없다) -- 엔트리포인트 경로 `/nodejs/bin/node` → `/usr/bin/node`(zypper 설치 경로, 실측) -- non-root 사용자: distroless `:nonroot` 태그 대신 명시적으로 `adc` 시스템 계정 생성 - -애플리케이션 코드(`main.cjs` 번들)는 업스트림과 100% 동일하다 — apisix/ -apisix-ingress-controller 자체 빌드보다 훨씬 단순한 유형(cnpg-postgresql 과 같은 -"OS 패키지 재설치형"에 가깝다 — 소스를 여러 모듈 컴파일하는 게 아니라 빌더 스테이지를 -그대로 두고 런타임 베이스만 바꾼다). 실측 빌드 시간도 2분 이내(대부분 pnpm -install+nx build 시간). - -## 소스·버전 관리 - -| 항목 | 값 | -| --- | --- | -| 소스 | `https://github.com/api7/adc.git` | -| pinned commit | `source.build.env` 의 `SOURCE_COMMIT` — `v0.29.0` 태그(lightweight, peeled 커밋 없음)가 가리키는 실제 커밋 | -| 빌더 | 업스트림과 동일한 `node:lts-bookworm-slim` — 정책 대상 아님(빌더 스테이지) | -| 최종 베이스 | `registry.suse.com/bci/bci-base:15.7` + `nodejs24` | - -`SOURCE_COMMIT`은 **자동 추적하지 않는다.** 다른 자체 빌드 이미지와 동일하게, 사람이 -업스트림 새 릴리스(또는 위 4건을 이미 해소한 버전)를 보고 `source.build.env`를 -고쳐 PR을 여는 것 자체가 갱신 트리거다. - -**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE를 다시 보고할 때, 또는 -`api7/adc`가 다음 릴리스를 내놓았을 때 — 릴리스가 나오면 그쪽으로 갈아타는 것(대응 -우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다. - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=adc BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 -IMAGE=adc BASE_OS=source REGISTRY=docker.io/paasup \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -`verify.sh`는 non-root 실행 여부, `--version` 출력, `--help`의 커맨드 목록(dump/diff/ -sync)을 확인한다 — **adc 는 실제 APISIX/API7 백엔드 접속이 필요한 명령이 대부분이라 -그 연동 자체는 이 스모크 범위 밖**이다. dev 클러스터 배포 검증 -(`.claude/deploy-test-procedure.md`)이 담당한다. - -**결과(2026-08-12 실측)**: 게이트 `PASS`, 커버리지 `ok`, 실효 CRITICAL/HIGH `0/0` -(원본 4건에서 완전 해소). `docker.io/paasup/adc:0.29.0-security-hardened-20260812` -로 push 완료. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `source.Dockerfile` | 빌드 정의 — 빌더 스테이지(업스트림 그대로) + SUSE BCI 최종 스테이지 | -| `source.build.env` | pinned commit·버전(`BUILD_ARGS`에 나열한 이름만 `--build-arg` 로 전달됨) | -| `verify.sh` | 기능 검증 — non-root·버전·--help 커맨드 확인 | - -베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다. - -### 태그 - -``` -docker.io/paasup/adc:0.29.0-security-hardened-20260812 - └ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -## 아직 안 한 것 — 배포 검증 - -게이트 PASS·기능 스모크테스트(버전+help)까지만 확인했다. **apisix-ingress-controller -와 함께 실제 APISIX/API7 백엔드에 접속해 dump/diff/sync 가 정상 동작하는지는 아직 -확인하지 않았다** — `.claude/deploy-test-procedure.md` 절차로 카탈로그 반영 전 -반드시 수행할 것(apisix-ingress-controller 자체 빌드는 이미 이 절차로 배포 검증 -완료됨 — 같은 방식으로 진행). diff --git a/images/adc/catalog.env b/images/adc/catalog.env deleted file mode 100644 index 4596d7d..0000000 --- a/images/adc/catalog.env +++ /dev/null @@ -1,14 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(source.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. - -CHART_DIRS="manifests/helm/apisix/2.16.0" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=split -# apisix 서브차트 alias(ingress-controller) 아래 deployment.adcContainer.image 로 -# 중첩돼 있다 — 점 구분 경로는 scripts/build/patch-catalog-tag.py 가 이미 지원한다 -# (apisix-ingress-controller 자체 빌드 추가 때 확장됨, 새 확장 불필요). -TAG_BLOCK=ingress-controller.deployment.adcContainer.image - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=source diff --git a/images/adc/source.Dockerfile b/images/adc/source.Dockerfile deleted file mode 100644 index 49e7960..0000000 --- a/images/adc/source.Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -# adc — 업스트림 ghcr.io/api7/adc:0.29.0 을 대체하는 자체 빌드. -# -# 업스트림(libs/tools/src/docker/Dockerfile, 태그 v0.29.0)은 2단계다: -# FROM node:lts-bookworm-slim AS builder (pnpm+nx 로 main.cjs 하나로 번들) -# FROM gcr.io/distroless/nodejs24-debian13:nonroot -# COPY --from=builder main.cjs . -# builder 스테이지는 정책 대상이 아니므로(.claude/image-authoring.md 원칙 2) 그대로 -# 재현한다 — **바뀌는 건 final 스테이지 베이스 한 줄뿐이다.** -# -# 왜 자체 빌드인가 — 0.29.0(2026-08-05 배포, 확인 시점 최신 태그)이 distroless 전환으로 -# 벤더 관점 CRITICAL/HIGH 는 0/0 이지만, 게이트가 max(벤더,NVD) 로 재평가하면 glibc -# regex/collating 스택오버플로 4건(CVE-2019-1010022/23, CVE-2018-20796, CVE-2019-9192, -# 전부 libc6)이 실효 CRITICAL/HIGH 로 잡힌다(2026-08-12 실측) — Debian 이 "affected, -# 수정 버전 없음"으로 영구 고정해둔 벤더 하향 등급 사례다(cnpg-postgresql 과 같은 패턴). -# SUSE BCI 글리브C 에는 이 4건이 아예 없음을 실측 확인했다(registry.suse.com/bci/ -# bci-base:15.7 + nodejs24 설치 후 SBOM 스캔, 2026-08-12). -# -# 업스트림과 다르게 하는 부분 — final 스테이지 베이스만 -# gcr.io/distroless/nodejs24-debian13:nonroot → registry.suse.com/bci/bci-base:15.7 -# + zypper install nodejs24 (같은 Node 메이저 버전 24 로 유지 — SUSE 표준 패키지, -# distroless 전용 -devel 류가 필요 없다. 업스트림처럼 배포판 무관 패키지 매니저로 -# 설치하는 형태라 openresty-pcre-devel 같은 문제가 없다) -# /nodejs/bin/node → /usr/bin/node (zypper 가 설치한 실제 경로, 2026-08-12 실측) -# 애플리케이션 코드(main.cjs 번들)는 업스트림과 100% 동일하다 — 빌더 스테이지를 그대로 -# 재사용하므로 diff 가 최소다. - -# syntax=docker/dockerfile:1 - -ARG BUILDER_BASE=node:lts-bookworm-slim -ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7 - -FROM ${BUILDER_BASE} AS builder -ARG SOURCE_COMMIT - -ENV PNPM_HOME="/pnpm" -ENV PATH="$PNPM_HOME/bin:$PATH" -ENV NX_DAEMON="false" -RUN corepack enable - -WORKDIR /build - -# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다. -ADD https://github.com/api7/adc.git#${SOURCE_COMMIT} /build - -RUN pnpm install nx -g \ - && pnpm install \ - && NODE_ENV=production nx build cli - -FROM ${RUNTIME_BASE} AS final -ARG NODE_PKG=nodejs24 - -RUN zypper -n install -y ${NODE_PKG} \ - && zypper -n clean --all - -COPY --from=builder /build/dist/apps/cli/main.cjs /adc/main.cjs - -# 업스트림 최종 스테이지는 distroless ":nonroot" 태그로 non-root 를 기본 적용한다 — -# bci-base 는 그런 태그 변형이 없어 명시적으로 사용자를 만든다. -RUN groupadd --system --gid 1000 adc \ - && useradd --system --gid adc --no-create-home --shell /usr/sbin/nologin --uid 1000 adc \ - && chown -R adc:adc /adc - -WORKDIR /adc -USER adc -ENTRYPOINT ["/usr/bin/node", "main.cjs"] diff --git a/images/adc/source.build.env b/images/adc/source.build.env deleted file mode 100644 index 0665565..0000000 --- a/images/adc/source.build.env +++ /dev/null @@ -1,23 +0,0 @@ -# adc — 소스 컴파일형 자체 빌드 (유일한 변종) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# 빌더 스테이지는 업스트림 그대로(node:lts-bookworm-slim), final 스테이지만 SUSE BCI 로 -# 바뀐다 — "베이스 OS 선택지"가 없는 소스 빌드형이라 BASE_OS=source 로 호출한다: -# IMAGE=adc BASE_OS=source bash scripts/build/build-hardened-image.sh -# -# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치 -# (2026-08-12)로 도입 — 근거·경과는 MEMORY.md. - -DOCKERFILE=source.Dockerfile -TARGET=final -TAG_SLUG=security - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# 스톡 0.29.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다. -APP_VERSION=0.29.0 - -# "v0.29.0" 태그가 가리키는 실제 커밋(lightweight 태그 — peeled 커밋 별도로 없음), -# 2026-08-12 확인: git ls-remote --tags https://github.com/api7/adc.git v0.29.0 -SOURCE_COMMIT=6594ee9f7cd9fd8786c0d11601c1ed05377646a1 - -BUILD_ARGS="SOURCE_COMMIT" diff --git a/images/adc/verify.sh b/images/adc/verify.sh deleted file mode 100755 index 8aad569..0000000 --- a/images/adc/verify.sh +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env bash -# adc 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 -# `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh` 형태로 호출한다). -# -# 최종 베이스가 bci-base 라 bash/coreutils 가 있다 — 게스트 셸을 그대로 쓴다. adc 는 -# APISIX/API7 백엔드에 실제로 접속해야 하는 명령(dump/diff/sync)이 대부분이라 그건 이 -# 스모크 범위 밖이다(백엔드 연동은 dev 클러스터 배포 검증이 담당, -# .claude/deploy-test-procedure.md). 여기서는 백엔드 없이 확인 가능한 것만 본다: -# 바이너리 실행 가능 여부, 버전 문자열, --help 커맨드 목록, non-root 실행. -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" - -echo "== 실행 사용자 (nonroot) ==" -USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")" -[ "$USER_CFG" = "adc" ] || { echo "FAIL: 이미지 Config.User 가 adc 가 아니다 (실제: $USER_CFG)"; exit 1; } -echo " Config.User=$USER_CFG" - -docker run --rm -i --platform "$PLATFORM" --entrypoint sh "$TAG" <<'GUEST' -set -e - -echo "== 버전 ==" -OUT="$(/usr/bin/node /adc/main.cjs --version)" -echo " $OUT" -case "$OUT" in - *0.29.0*) ;; - *) echo "FAIL: 버전 출력에 0.29.0 이 없다"; exit 1 ;; -esac - -echo "== --help 스모크 (백엔드 없이 도는 유일한 확인 범위) ==" -OUT="$(/usr/bin/node /adc/main.cjs --help)" -case "$OUT" in - *"dump"*"diff"*"sync"*) ;; - *) echo "FAIL: --help 출력에 예상 커맨드(dump/diff/sync)가 없다"; exit 1 ;; -esac -echo " dump/diff/sync 커맨드 확인됨" - -echo "VERIFY-OK" -GUEST diff --git a/images/apisix-ingress-controller/README.md b/images/apisix-ingress-controller/README.md deleted file mode 100644 index a6e2b15..0000000 --- a/images/apisix-ingress-controller/README.md +++ /dev/null @@ -1,116 +0,0 @@ -# apisix-ingress-controller — 자체 빌드 - -apisix-ingress-controller 바이너리를 업스트림 소스에서 직접 컴파일한다. -`manifests/helm/apisix/{2.14.0,2.16.0}/custom-values.yaml` 의 -`ingress-controller.deployment.image.repository`/`tag` 가 이 산출물을 가리킨다. - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 - -apisix 카탈로그 CVE 조치(2026-08-11) 중 발견 — `apache/apisix-ingress-controller:2.1.0` -(2026-05-29 빌드, **이 시점 기준 최신 태그**)가 게이트에서 차단하는 CRITICAL/HIGH 25건 중 -21건이 OS 패키지가 아니라 바이너리에 **정적 링크된 Go 모듈** 버전이 원인이다: - -| 모듈 | 설치 버전 | 필요 버전 | 관련 CVE(대표) | -| --- | --- | --- | --- | -| stdlib (Go 툴체인) | go1.24.7 | go1.25.12/1.26.5+ | CVE-2026-39822 외 다수 | -| golang.org/x/net | v0.47.0 | v0.56.0 | CVE-2026-25681, 27136, 39821 | -| golang.org/x/text | v0.31.0 | v0.39.0 | CVE-2026-56852 | -| google.golang.org/grpc | v1.71.1 | v1.82.1 | CVE-2026-33186, GHSA-hrxh-6v49-42gf | -| go.opentelemetry.io/otel | v1.40.0 | v1.43.0 | CVE-2026-29181 | -| go.opentelemetry.io/otel/sdk | v1.40.0 | v1.43.0 | CVE-2026-39883 | - -이미 최신 태그라 **상위 태그 교체가 불가능**하다. 나머지 4건(libc6 3건, libssl3 1건)은 -업스트림 베이스(`gcr.io/distroless/cc-debian12`)의 OS 패키지지만, 그 4건만 잡자고 -베이스를 바꿔도 위 21건은 그대로 남는다 — 자체 빌드(소스 재컴파일)가 유일한 대응이다. - -## 업스트림과 다르게 하는 부분 - -**애플리케이션 코드는 `v2.1.0` 태그 그대로다.** cloudnative-pg 처럼 "release 브랜치 -HEAD 로 옮겨서 백포트된 수정을 받는" 방식을 먼저 검토했지만, 이 프로젝트는 그런 유지보수 -브랜치가 없다 — `master` 하나뿐이고, 2026-08-11 확인 시점 `master` HEAD 는 `v2.1.0` 태그 -대비 커밋 35개 앞서 있으며 그 사이에 Gateway API 1.6.0 지원·L4RoutePolicy·mTLS 지원 같은 -**신규 기능 커밋이 다수 섞여 있다.** 최소 diff 원칙(`.claude/image-authoring.md`)에 맞지 -않아 쓰지 않았다. - -대신 위 표의 취약 모듈만 `go get`(+`go mod tidy`)로 최소 호환 버전까지 끌어올린다. -`go.work` 워크스페이스가 없는 단일 모듈 프로젝트라 etcd 처럼 전역 `replace` 한 줄로 끝나지 -않고 여러 모듈을 한 번에 지정해야 하지만(otel/otel-sdk 는 버전이 서로 맞아야 해서 반드시 -함께 지정), 로컬에서 `go mod tidy` + `go build`(linux/amd64, `CGO_ENABLED=0`)가 정상 -종료하는 것을 실측 확인했다(2026-08-11) — 의존성 그래프가 서로 충돌하지 않는다. - -`gcr.io/distroless/cc-debian12` → `registry.suse.com/bci/bci-micro:15.7`(카탈로그는 SUSE -BCI 하나만 쓴다 — `.claude/image-authoring.md` 원칙 2). 바이너리가 `CGO_ENABLED=0` 정적 -링크라 애초에 `cc`(glibc 포함) 변형이 필요 없었다 — 업스트림이 왜 `cc` 를 쓰는지는 불명 -(안전 마진으로 추정), `static` 대비 이점이 없어 우리는 `bci-micro` 로 통일한다. - -## 소스·버전 관리 - -| 항목 | 값 | -| --- | --- | -| 소스 | `https://github.com/apache/apisix-ingress-controller.git` | -| pinned commit | `source.build.env` 의 `SOURCE_COMMIT` — `2.1.0` 태그가 가리키는 실제 커밋 | -| 빌더 | 공식 `golang` 이미지(`source.build.env` 의 `GO_BUILDER_TAG`) — 의존성 업그레이드 후 `go mod tidy` 가 `go.mod` 를 `go 1.25`/`toolchain go1.26.5` 로 자동 상향했다(2026-08-11 실측) | -| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` | - -`SOURCE_COMMIT`·의존성 최소 버전은 **자동 추적하지 않는다.** 다른 자체 빌드 이미지와 -동일하게, 사람이 업스트림 새 태그(또는 이 CVE 세트를 이미 해소한 커밋)를 보고 -`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다. - -**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 -업스트림이 `2.1.1`(혹은 그 이상, 위 표의 CVE 를 이미 해소한 릴리스)을 내놓았을 때 — -릴리스가 나오면 그쪽으로 갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 -항상 우선이다. - -> apisix 카탈로그는 한때 2.14.0/2.16.0 두 버전을 함께 보관해 apisix-ingress-controller -> 앱 버전(2.0.1/2.1.0)도 갈렸었다 — 2.14.0 은 2026-08-11 삭제되어(구버전 유지 대신 최신 -> 버전 하나로 정리) 지금은 변종이 하나뿐이다. - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=apisix-ingress-controller BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 -IMAGE=apisix-ingress-controller BASE_OS=source REGISTRY=docker.io/paasup \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -`verify.sh` 는 바이너리가 실행 가능한지, `version --long` 출력에 pinned commit 이 실제로 -반영됐는지(ldflags 주입 확인), `--help` 가 정상 종료하는지, 이미지가 `65532:65532` -(nonroot) 로 실행되는지를 확인한다 — **이 컨트롤러는 Kubernetes API 서버 접속이 있어야 -실제로 기동한다**, 그 부분은 이 스모크 테스트 범위 밖이며 dev 클러스터 배포 테스트 -(`.claude/deploy-test-procedure.md`)가 담당한다. 이 세션에서는 클러스터가 없어 배포 -검증을 아직 하지 못했다 — **카탈로그 반영 전 반드시 수행할 것.** - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) | -| `source.build.env` | pinned commit·버전·빌더 이미지 태그·취약 모듈 최소 버전. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 | -| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 `docker run --entrypoint sh` 로 게스트 셸 스크립트를 주입한다(`bci-micro` 는 bash·coreutils 가 있다) | - -베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다. - -### 태그 - -``` -docker.io/paasup/apisix-ingress-controller:2.1.0-security-hardened-20260811 - └ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -### 카탈로그 반영 - -`ingress-controller.deployment.image` 가 apisix 서브차트 alias 아래 중첩돼 있어 -(`ingress-controller:` → `deployment:` → `image:`), `catalog.env` 의 `TAG_BLOCK` 은 -점 구분 경로(`ingress-controller.deployment.image`)를 쓴다. 기존 -`scripts/build/patch-catalog-tag.py` 는 top-level 블록만 지원했어서, 이 이미지를 -추가하며 중첩 경로까지 지원하도록 확장했다(첫 세그먼트는 여전히 top-level 강제, 그 -뒤부터는 부모 블록 범위 안에서만 찾아 다른 형제 블록의 동명 키를 오인하지 않는다) — -기존 이미지(단일 세그먼트 `image`)의 동작은 회귀 테스트로 그대로임을 확인했다. diff --git a/images/apisix-ingress-controller/catalog.env b/images/apisix-ingress-controller/catalog.env deleted file mode 100644 index 0eb5e6c..0000000 --- a/images/apisix-ingress-controller/catalog.env +++ /dev/null @@ -1,16 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. -# -# apisix 카탈로그는 2.16.0 하나만 보관한다(2.14.0 은 2026-08-11 삭제 — 구버전 유지 대신 -# 최신 버전 하나로 정리). -CHART_DIRS="manifests/helm/apisix/2.16.0" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=split -# apisix 서브차트 alias(ingress-controller) 아래 deployment.image 로 중첩돼 있다 — 점 -# 구분 경로는 scripts/build/patch-catalog-tag.py 가 지원한다(2026-08-11, 이 이미지 -# 추가하며 top-level 전용이던 걸 중첩 경로까지 확장). -TAG_BLOCK=ingress-controller.deployment.image - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=source diff --git a/images/apisix-ingress-controller/source.Dockerfile b/images/apisix-ingress-controller/source.Dockerfile deleted file mode 100644 index 6dc594a..0000000 --- a/images/apisix-ingress-controller/source.Dockerfile +++ /dev/null @@ -1,98 +0,0 @@ -# apisix-ingress-controller — 업스트림 소스를 pinned commit(v2.1.0 태그)으로 직접 -# 컴파일하고, 취약한 전이 의존성만 강제로 올린다(cloudnative-pg/etcd 자체 빌드와 동일한 -# 패턴). 업스트림 루트 Dockerfile 은 이미 빌드된 바이너리를 COPY 만 한다 — 컴파일 자체는 -# Makefile 의 `build`/`build-multi-arch` 타겟(`CGO_ENABLED=0 go build ...`)이 한다. 그 -# 빌드 단계를 Dockerfile 안으로 가져와 재현한다. -# -# 왜 자체 빌드인가 — apache/apisix-ingress-controller:2.1.0(2026-05-29 빌드, 현재 최신 -# 태그)이 게이트에서 차단하는 CRITICAL/HIGH 25건 중 21건이 OS 패키지가 아니라 바이너리에 -# 정적 링크된 Go 모듈(stdlib, golang.org/x/net, golang.org/x/text, google.golang.org/grpc, -# go.opentelemetry.io/otel[/sdk])이 원인이다(2026-08-11 실측, apisix 카탈로그 CVE 조치). -# 이미 최신 태그라 상위 태그 교체가 불가능하고, 베이스가 distroless(cc-debian12)라 -# libc6/libssl3 OS 패키지 4건만 베이스 교체로 잡히고 나머지는 못 잡는다 — 자체 빌드가 -# 유일한 대응이다. -# -# 업스트림과 다르게 하는 부분 — 취약 모듈만 강제 업그레이드(go.work 가 없는 단일 모듈 -# 프로젝트라 etcd 처럼 워크스페이스 전역 replace 를 못 쓴다. `go get`+`go mod tidy` 로 -# 대신한다). 애플리케이션 코드 자체는 v2.1.0 태그 그대로다 — master 는 릴리스 이후 기능 -# 커밋이 다수 섞여 있어(2026-08-11 기준 태그 대비 35커밋 앞섬) 최소 diff 원칙에 맞지 않아 -# 쓰지 않았다. -# golang.org/x/net v0.47.0 -> v0.56.0 (CVE-2026-25681/27136/39821 등) -# golang.org/x/text v0.31.0 -> v0.39.0 (CVE-2026-56852) -# google.golang.org/grpc v1.71.1 -> v1.82.1 (CVE-2026-33186, GHSA-hrxh-6v49-42gf) -# go.opentelemetry.io/otel v1.40.0 -> v1.43.0 (CVE-2026-29181) -# go.opentelemetry.io/otel/sdk v1.40.0 -> v1.43.0 (CVE-2026-39883, otel 코어와 버전 동기 필요) -# 이 조합은 `go get` 가 알아서 서로 호환되는 최소 버전으로 끌어올린다(go mod tidy 로 확정) — -# 로컬에서 go build 성공 실측 완료(2026-08-11). -# -# gcr.io/distroless/cc-debian12 → SUSE BCI(bci-micro)로 교체. 카탈로그는 SUSE BCI -# 하나만 쓴다(.claude/image-authoring.md 원칙 2) — builder 스테이지는 공식 golang -# 이미지를 그대로 쓴다. CGO_ENABLED=0 정적 바이너리라 cc(glibc 포함) 변형이 애초에 -# 필요 없었다 — 업스트림이 왜 cc 를 쓰는지는 불명(안전 마진으로 추정), static 대비 -# 이점이 없어 우리는 bci-micro 로 통일한다. -# -# ldflags — 업스트림 Makefile 의 GO_LDFLAGS 를 그대로 재현한다(internal/version 패키지의 -# 빌드타임 심볼 4개). GitSHA 자리에는 pinned commit 전체 해시를 심어 verify.sh 가 컨테이너 -# 안에서 git 을 실행하지 않고도 버전 문자열로 확인할 수 있게 한다(etcd/cloudnative-pg 와 -# 동일한 이유). - -# syntax=docker/dockerfile:1 - -ARG GO_BUILDER_TAG=1.26.5-trixie -# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — .claude/image-authoring.md. -ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder -ARG TARGETARCH -ARG SOURCE_COMMIT -ARG APP_VERSION -ARG MIN_K8S_VERSION -ARG XNET_FIX_VERSION -ARG XTEXT_FIX_VERSION -ARG GRPC_FIX_VERSION -ARG OTEL_FIX_VERSION -WORKDIR /src - -# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다. -ADD https://github.com/apache/apisix-ingress-controller.git#${SOURCE_COMMIT} /src - -# 취약 전이 의존성만 최소 버전으로 강제 업그레이드한다(위 설명 참고). otel/otel-sdk 는 -# 서로 버전이 맞아야 해서 함께 지정한다 — go get 이 나머지 호환 버전을 알아서 정리한다. -RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - go get \ - golang.org/x/net@v${XNET_FIX_VERSION} \ - golang.org/x/text@v${XTEXT_FIX_VERSION} \ - google.golang.org/grpc@v${GRPC_FIX_VERSION} \ - go.opentelemetry.io/otel@v${OTEL_FIX_VERSION} \ - go.opentelemetry.io/otel/sdk@v${OTEL_FIX_VERSION} \ - && go mod tidy - -# 업스트림 Makefile 의 build 타겟을 그대로 재현한다(GOARCH 는 TARGETARCH 로 대체, GitSHA -# 자리에 pinned commit 전체 해시를 심는다 — 위 설명 참고). -RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - set -eux; \ - mkdir -p /out; \ - VERSYM="github.com/apache/apisix-ingress-controller/internal/version._buildVersion"; \ - GITSHASYM="github.com/apache/apisix-ingress-controller/internal/version._buildGitRevision"; \ - BUILDOSSYM="github.com/apache/apisix-ingress-controller/internal/version._buildOS"; \ - MINK8SVERSYM="github.com/apache/apisix-ingress-controller/internal/manager._minK8sVersion"; \ - LDFLAGS="-X=${VERSYM}=${APP_VERSION} -X=${GITSHASYM}=${SOURCE_COMMIT} -X=${BUILDOSSYM}=linux/${TARGETARCH} -X=${MINK8SVERSYM}=${MIN_K8S_VERSION}"; \ - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="${LDFLAGS}" -o /out/apisix-ingress-controller cmd/main.go - -FROM ${RUNTIME_BASE} AS final - -# 업스트림과 동일한 경로 — Helm 차트가 별도 경로를 하드코딩하지 않으므로 필수는 아니지만 -# 업스트림 Dockerfile 과의 대응을 그대로 유지한다. -WORKDIR /app -COPY --from=builder /out/apisix-ingress-controller ./apisix-ingress-controller -COPY --from=builder /src/LICENSE /licenses/LICENSE -COPY --from=builder /src/NOTICE /licenses/NOTICE - -# 업스트림은 distroless `:nonroot` 태그로 65532:65532 를 기본 사용자로 쓴다 — bci-micro 는 -# 그런 태그 변형이 없어 명시적으로 지정한다. -USER 65532:65532 - -ENTRYPOINT ["/app/apisix-ingress-controller"] -CMD ["-c", "/app/conf/config.yaml"] diff --git a/images/apisix-ingress-controller/source.build.env b/images/apisix-ingress-controller/source.build.env deleted file mode 100644 index 8531729..0000000 --- a/images/apisix-ingress-controller/source.build.env +++ /dev/null @@ -1,47 +0,0 @@ -# apisix-ingress-controller — 소스 컴파일형 자체 빌드 (유일한 변종) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다: -# IMAGE=apisix-ingress-controller BASE_OS=source bash scripts/build/build-hardened-image.sh -# -# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치(2026-08-11) -# 로 도입 — 근거·경과는 MEMORY.md. - -DOCKERFILE=source.Dockerfile -TARGET=final -TAG_SLUG=security - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# 스톡 2.1.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다. -APP_VERSION=2.1.0 - -# v2.1.0 태그가 가리키는 실제 커밋(peeled commit), 2026-08-11 확인. -# apisix-ingress-controller 는 cloudnative-pg 식 "release-X.Y 브랜치"가 없다(master 하나) — -# master HEAD 는 태그 대비 커밋 35개 앞서 있고 신기능 커밋이 섞여 있어(2026-08-11 확인) -# 최소 diff 원칙(.claude/image-authoring.md)에 맞지 않는다. 태그 그대로 두고 의존성만 올린다. -SOURCE_COMMIT=f4a8dd1223573a5b72e1c7b65b37c13b49d98042 - -# 업스트림 Makefile 의 MIN_K8S_VERSION 기본값(ldflags 로 바이너리에 심긴다). -MIN_K8S_VERSION=1.26.0 - -# go.mod 의 `go 1.25`/`toolchain go1.26.5` 요구(의존성 업그레이드 후 go mod tidy 가 자동 -# 상향한 값, 2026-08-11 로컬 실측)를 만족하는 공식 golang 이미지 태그. 빌더 스테이지에만 -# 쓰이고 최종 이미지에는 남지 않는다. -# -# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를 -# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다: -# python3 scripts/build/suggest-go-upgrades.py --reports --image apisix-ingress-controller -GO_BUILDER_TAG=1.26.6-trixie - -# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(.claude/image-authoring.md 원칙 2). -# 정적 링크 바이너리(CGO_ENABLED=0)라 패키지 매니저가 없는 가장 가벼운 bci-micro 로 충분하다. -RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -# 차단 CVE 해소에 필요한 최소 버전(2026-08-11 실측, source.Dockerfile 상단 주석 참고). -# go get 이 서로 호환되는 조합으로 정리한다 — 개별 go.sum 버전을 여기 나열하지 않는다. -XNET_FIX_VERSION=0.56.0 -XTEXT_FIX_VERSION=0.39.0 -GRPC_FIX_VERSION=1.82.1 -OTEL_FIX_VERSION=1.43.0 - -BUILD_ARGS="SOURCE_COMMIT APP_VERSION MIN_K8S_VERSION GO_BUILDER_TAG RUNTIME_BASE XNET_FIX_VERSION XTEXT_FIX_VERSION GRPC_FIX_VERSION OTEL_FIX_VERSION" diff --git a/images/apisix-ingress-controller/verify.sh b/images/apisix-ingress-controller/verify.sh deleted file mode 100644 index a506873..0000000 --- a/images/apisix-ingress-controller/verify.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# apisix-ingress-controller 이미지 기능 검증 — 호스트에서 bash 로 실행된다 -# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh` -# 형태로 호출한다). -# -# 최종 베이스가 bci-micro 라 bash/coreutils 가 있다(패키지 매니저만 없음 — -# .claude/image-authoring.md). 다만 이 바이너리는 컨트롤러라 실제 기동에는 Kubernetes -# API 서버 접속이 필요하다 — 그건 이 스모크 테스트 범위 밖이고 dev 클러스터 배포 검증 -# (.claude/deploy-test-procedure.md)이 담당한다. 여기서는 k8s API 없이 확인 가능한 것만 -# 본다: 바이너리 존재, 버전 문자열에 pinned commit 반영, --help 정상 종료, 실행 사용자. -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" -SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}" - -echo "== 실행 사용자 (nonroot, 이미지 메타데이터) ==" -USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")" -[ "$USER_CFG" = "65532:65532" ] || { echo "FAIL: 이미지 Config.User 가 65532:65532 가 아니다 (실제: $USER_CFG)"; exit 1; } -echo " Config.User=$USER_CFG" - -docker run --rm -i --platform "$PLATFORM" -e SOURCE_COMMIT="$SOURCE_COMMIT" --entrypoint sh "$TAG" <<'GUEST' -set -e - -echo "== 바이너리 탐색 ==" -[ -x /app/apisix-ingress-controller ] || { echo "FAIL: /app/apisix-ingress-controller 실행 파일 없음"; exit 1; } -echo " /app/apisix-ingress-controller 존재·실행 가능" - -echo "== 버전 (pinned commit 반영 확인) ==" -OUT="$(/app/apisix-ingress-controller version --long)" -while IFS= read -r line; do echo " $line"; done </dev/null -echo " --help 종료 코드 0" - -echo "VERIFY-OK" -GUEST diff --git a/images/apisix/README.md b/images/apisix/README.md deleted file mode 100644 index 59c2588..0000000 --- a/images/apisix/README.md +++ /dev/null @@ -1,153 +0,0 @@ -# apisix — 자체 빌드 - -apisix(APISIX-Runtime + APISIX 애플리케이션 + keycloak-authz 커스텀 플러그인) 전체를 -업스트림 소스에서 SUSE BCI 위에 직접 컴파일한다. -`manifests/helm/apisix/2.16.0/custom-values.yaml`의 `image.repository`/`tag`가 이 -산출물을 가리킨다. - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 - -apisix 카탈로그 CVE 조치(2026-08-11~12) 중 발견 — 기존엔 별도 프로젝트 -(`paasup/dataup` 레포 `experiment/apisix/apisix-plugin`)에서 `apache/apisix:3.17.0-debian` -위에 keycloak-authz 커스텀 플러그인만 얹어 빌드했다. 이 Debian 베이스 자체가 게이트 -차단 26건(2026-08-11 실측) — `apache/apisix:3.17.0-debian`은 확인 시점 기준 **이미 -최신 업스트림 태그**라 상위 태그 교체로는 해소가 안 되고, 전부 Debian 베이스 OS -패키지(libc, perl, pcre 등) CVE라 베이스 OS를 통째로 바꿔야 했다. - -## 업스트림과 다르게 하는 부분 — SUSE 에 이 이미지가 없는 이유 - -업스트림(`apache/apisix:3.17.0-debian`)은 vanilla OpenResty 가 아니라 **"APISIX-Runtime"** -이라는 커스텀 컴파일 nginx다 — OpenSSL 3.4.1·zlib·PCRE 를 직접 빌드해 넣고, -`apisix-nginx-module`·`wasm-nginx-module`(WASM, wasmtime)·`lua-var-nginx-module`· -`lua-resty-events`·`mod_dubbo`·`ngx_multi_upstream_module`을 `--add-module`로 -**컴파일 타임에 정적으로** 얹는다(2026-08-11 `openresty -V` 로 실측 — 아래 "소스·버전 -관리" 표에 정확한 버전 나열). 이 조합을 배포하는 apiseven 의 빌드 파이프라인 -(`api7/apisix-build-tools`)은 **Debian/RHEL(UBI9) 용 사전 빌드 패키지만** 만든다 — SUSE -용은 없다. - -- **vanilla OpenResty로 대체 불가**: openresty.org 는 SLES 15.x 를 공식 지원해 zypper - 로 설치할 수 있지만, 그건 위 커스텀 모듈(WASM·mod_dubbo 등)이 하나도 없는 순정 - 빌드다. `--add-module`은 컴파일 타임에만 바이너리에 정적으로 박히는 옵션이라 이미 - 컴파일된 vanilla 바이너리에 나중에 모듈을 추가할 수 없다 — nginx의 "동적 모듈" - 메커니즘도 이 모듈들이 그 형태로 배포되지 않아 적용 안 된다. 우리 apisix 배포가 - WASM 플러그인과 `http-dubbo` 관련 기능을 공식적으로 지원해야 한다는 요구사항이 있어 - (`custom-values.yaml`의 `apisix.plugins`에 `http-dubbo` 포함), 모듈을 뺀 축소판으로 - 타협하지 않고 업스트림과 동일한 모듈 구성을 그대로 재현했다. -- **Debian/RHEL 사전 빌드 RPM/DEB 를 SUSE 에 강제 설치도 불가**: 같은 이유로 시도하지 - 않았다 — glibc/openssl 심볼 버전이 안 맞아 ABI 가 깨질 위험(cnpg-postgresql 때와 - 달리 이번엔 SUSE 자체 저장소에 대응 패키지가 없어 "같은 배포판 계열 재설치"가 성립 - 하지 않는다). -- 그래서 **apiseven 의 공식 빌드 스크립트(`api7/apisix-build-tools`, 태그 - `apisix-runtime/1.3.6` — `openresty -V`의 `APISIX_RUNTIME_VER=1.3.6`과 실측 일치)를 - 그대로 재현**해 SUSE BCI 위에서 소스 컴파일했다. 애플리케이션 코드·모듈 버전은 - 업스트림과 100% 동일하고, 베이스 OS만 바뀐다(diff 최소화 원칙). - -## 3단계 구성 - -| 단계 | 목표 | -| --- | --- | -| `runtime` | OpenSSL 3.4.1·zlib·PCRE 를 `$OR_PREFIX/{openssl3,zlib,pcre}`에 직접 빌드하고, OpenResty 소스에 위 커스텀 모듈 6종을 `--add-module`로 붙여 컴파일한다. openresty.org 의 SLES 전용 `-devel` 패키지가 없어 이 경로들을 소스 빌드로 채운다. | -| `apisix-app` | `runtime`이 만든 OpenResty/LuaJIT 위에 APISIX 애플리케이션(Lua 코드 + 일부 C 확장 rock)을 `luarocks make`로 설치한다. | -| `final` | 위 두 스테이지 산출물만 깨끗한 SUSE BCI 이미지로 옮기고, 런타임에 필요한 공유 라이브러리(libxml2·libxslt·libyaml·pcre·pcre2)만 추가, non-root(`apisix`, uid 636) 설정, keycloak-authz 플러그인 오버레이까지 마친다. | - -## 실측 함정 (2026-08-11/12) - -- **빌드 순서 — zlib 을 OpenSSL 보다 먼저 빌드한다.** OpenSSL 의 `zlib` config 옵션이 - 컴파일 타임에 `zlib.h` 를 요구한다 — 반대 순서로 뒀다가 `zlib.h: No such file` 로 - 실패했다. -- **`lua-resty-saml`(luarocks 가 자동으로 받는 의존 rock)이 SUSE 표준 `libxml2-devel` - (2.12.10)의 `xmlSetStructuredErrorFunc` 시그니처(콜백 인자에 `const` 추가됨)와 안 - 맞아 `-Werror=incompatible-pointer-types` 로 빌드 실패한다.** rock 자체의 Makefile 이 - `-Werror` 를 하드코딩해 환경변수 `CFLAGS` 로는 못 끈다 — `apisix-app` 스테이지 안에서만 - 쓰는 `gcc` 래퍼(`-Wno-error=incompatible-pointer-types` 추가)로 그 진단 하나만 - 경고로 낮추고, `luarocks make` 끝나면 즉시 원복한다(다른 빌드에 영향 안 줌). - source.Dockerfile 의 해당 RUN 스텝 주석 참고. -- **`ui/`(Admin 대시보드 프론트엔드)는 `apache/apisix` 저장소 자체엔 없다.** apiseven - 의 패키징 파이프라인이 별도로(Node.js/yarn 빌드) 끼워 넣는 단계라 git clone 만으로는 - 재현 안 된다 — 우리 배포는 이 UI 를 쓰지 않아(`custom-values.yaml` 에 관련 설정 없음) - 없으면 빈 디렉터리로 대체하고 건너뛴다. -- **`/usr/bin/apisix` 래퍼 스크립트가 내부적으로 `awk` 를 쓴다.** OpenResty 버전 파싱에 - 쓰는데, `final` 스테이지에 `gawk` 를 안 깔면 "awk: command not found" 로 `apisix - version`부터 실패한다. -- **SUSE 공유 라이브러리 패키지명은 soname 이 붙는다.** `libxml2`/`libxslt`/`pcre` 같은 - 이름 그대로는 없고 `libxml2-2`·`libxslt1`·`libpcre1`·`libpcre2-8-0`·`libyaml-0-2` 다 - — `libxml2-2`/`libpcre2-8-0`/`libldap-2_4-2` 는 `bci-base` 에 기본 포함이라 명시 안 - 해도 되지만, 명확성을 위해 실측한 이름 그대로 적었다. -- **`docker build --pull` 은 베이스 이미지 digest 가 바뀌면 캐시를 전부 무효화한다.** - `build-hardened-image.sh`가 항상 `--pull` 을 쓰므로(스케줄 재빌드가 최신 베이스를 - 전제하기 때문), Dockerfile 을 안 고쳤어도 SUSE BCI 태그가 그 사이 갱신되면 전체 - 재컴파일(약 35분)이 발생할 수 있다 — Dockerfile 반복 수정·테스트는 `--pull` 없는 - 순수 `docker build` 로 하고, 최종 검증에서만 `build-hardened-image.sh` 를 쓴다. - -## 소스·버전 관리 - -| 항목 | 값 | -| --- | --- | -| APISIX 소스 | `https://github.com/apache/apisix.git` (태그 `3.17.0`) | -| OpenResty | `1.29.2.4` | -| OpenSSL | `3.4.1` | -| zlib / PCRE | `1.3.1` / `8.45` | -| 커스텀 모듈 | `apisix-nginx-module 1.19.5`, `wasm-nginx-module 0.7.0`, `lua-var-nginx-module v0.5.3`, `lua-resty-events 0.2.0`, `ngx_multi_upstream_module 1.3.3`, `mod_dubbo 1.0.2` | -| APISIX_RUNTIME_VER | `1.3.6` (apiseven 빌드 스크립트 버전 태그) | -| 최종 베이스 | `registry.suse.com/bci/bci-base:15.7` | - -전부 `source.build.env` 에서 관리하며 **자동 추적하지 않는다.** 다른 자체 빌드 이미지와 -동일하게, 사람이 업스트림 새 릴리스(또는 위 CVE 세트를 이미 해소한 버전)를 보고 -`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다. - -**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 -`apache/apisix`가 `3.17.1`(혹은 그 이상)을 내놓았을 때 — 릴리스가 나오면 그쪽으로 -갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다. - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=apisix BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 -IMAGE=apisix BASE_OS=source REGISTRY=docker.io/paasup \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -`verify.sh`는 `apisix version` 출력·nginx 설정 문법(`nginx -t`)뿐 아니라, standalone -YAML 설정으로 **실제 nginx worker 를 기동**해 APISIX 라우터가 HTTP 요청에 응답하는지 -(라우트 없음 → 404)까지 확인한다 — 15개 커스텀 모듈 + ~90개 Lua 플러그인(lua-resty-saml -같은 C 확장 포함) 로딩까지 실제로 거치는 유일한 방법이다. **keycloak-authz 커스텀 -플러그인은 실제 Keycloak 연동이 필요해 이 스모크 범위 밖**이다 — dev 클러스터 배포 -검증(`.claude/deploy-test-procedure.md`)이 담당한다. - -**결과(2026-08-12 실측)**: 게이트 `PASS`, 커버리지 `ok`, 실효 CRITICAL/HIGH `0/0` -(업스트림 26건에서 완전 해소). `docker.io/paasup/apisix:3.17.0-security-hardened-20260811` -로 push 완료. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `source.Dockerfile` | 빌드 정의 — 3단계(runtime/apisix-app/final) | -| `source.build.env` | pinned 버전 전체(`BUILD_ARGS`에 나열한 이름만 `--build-arg` 로 전달됨) | -| `verify.sh` | 기능 검증 — 실제 nginx 기동 + HTTP 요청까지 | -| `keycloak-authz.lua` | `paasup/dataup` 레포(`experiment/apisix/apisix-plugin/keycloak-authz/`)와 동일한 커스텀 플러그인 — 원본 레포는 그대로 두고 이 카탈로그가 더 이상 참조만 안 한다 | -| `docker-entrypoint.sh` | 업스트림 `apache/apisix-docker`(`utils/docker-entrypoint.sh`)와 동일 | - -베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다. - -### 태그 - -``` -docker.io/paasup/apisix:3.17.0-security-hardened-20260811 - └ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -## 아직 안 한 것 — 배포 검증 - -게이트 PASS·기능 스모크테스트(nginx 기동+HTTP 응답)까지만 확인했다. **실제 Keycloak -연동(keycloak-authz 플러그인 동작), etcd 연동(externalEtcd), ingress-controller 와의 -연동을 포함한 실제 클러스터 배포 검증은 아직 하지 않았다** — -`.claude/deploy-test-procedure.md` 절차로 카탈로그 반영 전 반드시 수행할 것. diff --git a/images/apisix/catalog.env b/images/apisix/catalog.env deleted file mode 100644 index 0f8aabc..0000000 --- a/images/apisix/catalog.env +++ /dev/null @@ -1,13 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(source.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. - -CHART_DIRS="manifests/helm/apisix/2.16.0" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=split -# apisix 차트 자체의 최상위 image 블록(서브차트 alias 아래가 아니다 — 그건 -# apisix-ingress-controller 자체 빌드가 쓰는 ingress-controller.deployment.image). -TAG_BLOCK=image - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=source diff --git a/images/apisix/docker-entrypoint.sh b/images/apisix/docker-entrypoint.sh deleted file mode 100644 index 4b534b9..0000000 --- a/images/apisix/docker-entrypoint.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# -# Licensed to the Apache Software Foundation (ASF) under one or more -# contributor license agreements. See the NOTICE file distributed with -# this work for additional information regarding copyright ownership. -# The ASF licenses this file to You under the Apache License, Version 2.0 -# (the "License"); you may not use this file except in compliance with -# the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -set -eo pipefail - -PREFIX=${APISIX_PREFIX:=/usr/local/apisix} - -if [[ "$1" == "docker-start" ]]; then - if [ "$APISIX_STAND_ALONE" = "true" ]; then - # If the file is not present then initialise the content otherwise update relevant keys for standalone mode - if [ ! -f "${PREFIX}/conf/config.yaml" ]; then - cat > ${PREFIX}/conf/config.yaml << _EOC_ -deployment: - role: data_plane - role_data_plane: - config_provider: yaml -_EOC_ - fi - - if [ ! -f "${PREFIX}/conf/apisix.yaml" ]; then - cat > ${PREFIX}/conf/apisix.yaml << _EOC_ -routes: - - -#END -_EOC_ - fi - /usr/bin/apisix init - else - /usr/bin/apisix init - /usr/bin/apisix init_etcd - fi - - # For versions below 3.5.0 whose conf_server has not been removed. - if [ -e "/usr/local/apisix/conf/config_listen.sock" ]; then - rm -f "/usr/local/apisix/conf/config_listen.sock" - fi - - if [ -e "/usr/local/apisix/logs/worker_events.sock" ]; then - rm -f "/usr/local/apisix/logs/worker_events.sock" - fi - - if [ -e "/usr/local/apisix/logs/stream_worker_events.sock" ]; then - rm -f "/usr/local/apisix/logs/stream_worker_events.sock" - fi - - exec /usr/local/openresty/bin/openresty -p /usr/local/apisix -g 'daemon off;' -fi - -exec "$@" diff --git a/images/apisix/keycloak-authz.lua b/images/apisix/keycloak-authz.lua deleted file mode 100644 index 7d1de75..0000000 --- a/images/apisix/keycloak-authz.lua +++ /dev/null @@ -1,168 +0,0 @@ --- keycloak-authz APISIX custom plugin --- Kong keycloak-authz 플러그인을 APISIX로 포팅 --- --- 동작: --- 1. X-Access-Token 헤더에서 JWT 추출 → preferred_username 파싱 --- 2. 외부 API (/gwapi/v1/projectusers/{username}) POST 호출로 권한 확인 --- 3. 결과를 TTL 기반으로 in-memory 캐싱 (lrucache) --- 4. 미인가 시 401, 내부 오류 시 500 반환 - -local core = require("apisix.core") -local http = require("resty.http") -local jwt = require("resty.jwt") -local lrucache = require("resty.lrucache") -local cjson = require("cjson.safe") - -local plugin_name = "keycloak-authz" - --- 최대 1024개의 사용자 인증 결과를 캐싱 -local auth_cache, err = lrucache.new(1024) -if not auth_cache then - error("failed to create lrucache: " .. (err or "unknown")) -end - -local schema = { - type = "object", - properties = { - api_url = { - type = "string", - description = "권한 확인 API의 base URL (예: https://api.example.com)", - }, - basic_auth_token = { - type = "string", - description = "API 호출 시 사용할 Basic 인증 토큰 (Base64 인코딩된 값)", - default = "", - }, - timeout = { - type = "integer", - description = "API 호출 타임아웃 (밀리초)", - default = 5000, - minimum = 100, - }, - skip_ssl_verify = { - type = "boolean", - description = "API 호출 시 SSL 인증서 검증 생략 여부", - default = false, - }, - ttl = { - type = "integer", - description = "인증 결과 캐싱 시간 (초)", - default = 300, - minimum = 1, - }, - }, - required = {"api_url"}, -} - -local _M = { - version = 0.1, - priority = 950, -- Kong의 PRIORITY = 950과 동일 - name = plugin_name, - schema = schema, -} - -function _M.check_schema(conf) - return core.schema.check(schema, conf) -end - --- 외부 API 호출로 사용자 권한 확인 -local function fetch_auth_status(api_url, preferred_username, basic_auth_token, timeout, skip_ssl_verify) - local httpc = http.new() - - local scheme = ngx.var.scheme - local host = ngx.var.host - local url = scheme .. "://" .. host - - local body, encode_err = cjson.encode({ url = url }) - if encode_err then - return nil, "failed to encode request body: " .. encode_err - end - - local headers = { ["Content-Type"] = "application/json" } - if basic_auth_token and basic_auth_token ~= "" then - headers["Authorization"] = "Basic " .. basic_auth_token - end - - local full_url = api_url .. "/gwapi/v1/projectusers/" .. preferred_username - - local res, req_err = httpc:request_uri(full_url, { - method = "POST", - body = body, - headers = headers, - timeout = timeout, - ssl_verify = not skip_ssl_verify, - keepalive_timeout = 60000, - keepalive_pool = 10, - }) - - httpc:close() - - if not res then - return nil, "API request failed: " .. (req_err or "unknown error") - end - - core.log.debug("auth API response status=", res.status, " user=", preferred_username) - return res.status == 200, nil -end - -function _M.access(conf, ctx) - -- X-Access-Token 헤더 추출 - local access_token = core.request.header(ctx, "X-Access-Token") - if not access_token then - return core.response.exit(401, { message = "Missing X-Access-Token header" }) - end - - -- JWT 디코딩 (서명 검증 없이 페이로드만 파싱) - local jwt_obj = jwt:load_jwt(access_token) - if not jwt_obj or not jwt_obj.payload then - core.log.warn("failed to decode JWT token") - return core.response.exit(401, { message = "Failed to decode JWT token" }) - end - - local preferred_username = jwt_obj.payload["preferred_username"] - if not preferred_username then - return core.response.exit(401, { message = "Missing preferred_username in JWT token" }) - end - - local cache_key = "auth_status:" .. preferred_username - - -- 캐시 조회 - local cached_result = auth_cache:get(cache_key) - if cached_result ~= nil then - core.log.debug("cache hit for user=", preferred_username, " result=", tostring(cached_result)) - if not cached_result then - return core.response.exit(401, { message = "User is not authorized" }) - end - ctx.authenticated_user = preferred_username - return - end - - -- 캐시 미스: 외부 API 호출 - core.log.debug("cache miss, fetching auth status for user=", preferred_username) - - local is_authorized, fetch_err = fetch_auth_status( - conf.api_url, - preferred_username, - conf.basic_auth_token or "", - conf.timeout or 5000, - conf.skip_ssl_verify or false - ) - - if fetch_err then - core.log.error("auth check failed user=", preferred_username, " err=", fetch_err) - return core.response.exit(500, { message = "Internal server error" }) - end - - -- 결과 캐싱 (TTL 적용) - auth_cache:set(cache_key, is_authorized, conf.ttl or 300) - core.log.debug("cached auth result user=", preferred_username, " authorized=", tostring(is_authorized)) - - if not is_authorized then - core.log.warn("authorization denied for user=", preferred_username) - return core.response.exit(401, { message = "User is not authorized" }) - end - - ctx.authenticated_user = preferred_username -end - -return _M diff --git a/images/apisix/source.Dockerfile b/images/apisix/source.Dockerfile deleted file mode 100644 index 79bcec2..0000000 --- a/images/apisix/source.Dockerfile +++ /dev/null @@ -1,268 +0,0 @@ -# apisix — 업스트림 apache/apisix:3.17.0-debian 를 대체하는 자체 빌드. -# -# 업스트림은 vanilla OpenResty 가 아니라 "APISIX-Runtime" 이라는 커스텀 컴파일 nginx다 -# (openssl3·zlib·pcre 를 직접 빌드해 넣고, apisix-nginx-module·wasm-nginx-module(WASM, -# wasmtime)·lua-var-nginx-module·lua-resty-events·mod_dubbo·ngx_multi_upstream_module -# 를 --add-module 로 정적으로 얹는다 — 2026-08-11 `openresty -V` 로 실측). 업스트림 빌드 -# 스크립트(https://github.com/api7/apisix-build-tools, 태그 apisix-runtime/1.3.6 — -# `openresty -V` 의 APISIX_RUNTIME_VER=1.3.6 과 실측 일치)가 배포판 무관하게 소스에서 -# 컴파일하는 구조라 SUSE BCI 로 옮길 수 있었다 — Debian/RHEL 전용 사전빌드 RPM/DEB 를 -# SUSE 에 강제 설치하는 건 ABI 가 안 맞아 불가능하다(dockerfiles/Dockerfile.apisix.rpm -# 등 참고, 둘 다 UBI9/Ubuntu 전용). -# -# 왜 자체 빌드인가 — apache/apisix:3.17.0-debian(2026-08-06 배포, 확인 시점 최신 태그)이 -# 게이트 차단 26건. 이미 최신 태그라 상위 태그 교체 불가, Debian 베이스 OS 패키지 -# (libc/perl/pcre 등) CVE 라 이번엔 정적 링크 바이너리가 아니라 진짜 베이스 OS 문제다. -# -# 구성(3단계): -# 1) runtime — OpenSSL 3.4.1·zlib·pcre 를 $OR_PREFIX/{openssl3,zlib,pcre} 에 직접 -# 빌드하고, openresty-${OR_VER} 소스에 위 커스텀 모듈들을 --add-module 로 붙여 -# 컴파일한다(업스트림 빌드 스크립트 그대로 재현, 버전 전부 pinned). -# 2) apisix — runtime 위에 APISIX 본체(Lua 애플리케이션 + 일부 C 확장 Lua rock)를 -# luarocks 로 설치한다. 이 단계에서 필요한 pcre2·openldap·libxml2·libxslt(lua-resty- -# saml 이 xmlsec1 바인딩에 씀)·zlib devel 은 SUSE 표준 패키지로 설치한다(업스트림처럼 -# openresty 전용 -devel 패키지가 아니라 시스템 표준 -devel — 실제 배포판 표준 -# pcre/pcre2/libxml2/libxslt 헤더로 컴파일해도 무방한 부분이라 문제 없다). -# 3) final — bci-base(런타임 공유 라이브러리 필요: libxml2·libxslt·openldap2 등을 -# 정적이 아니라 동적 링크로 쓰는 Lua rock 이 있어 bci-micro 로는 부족하다 — 실측 -# 후 bci-micro 로 축소 검토, 1차는 정확성 우선). -# -# 업스트림과 다르게 하는 부분 -# - 베이스: Debian → SUSE BCI(.claude/image-authoring.md 원칙 2). -# - Rust 툴체인: rustup 으로 설치(wasm-nginx-module 의 wasmtime-c-api, APISIX 일부 -# rock 컴파일에 필요 — 업스트림도 동일하게 rustup 을 쓴다, 배포판 무관 설치라 차이 없음). -# - 그 외 애플리케이션 코드·모듈 버전은 100% 동일(diff 최소화 원칙). - -# syntax=docker/dockerfile:1 - -ARG BUILDER_BASE=registry.suse.com/bci/bci-base:15.7 -ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7 - -# ============================================================================= -# 1) runtime — OpenSSL/zlib/pcre + OpenResty(APISIX-Runtime 커스텀 모듈 전체) -# ============================================================================= -FROM ${BUILDER_BASE} AS runtime -ARG OPENRESTY_VERSION=1.29.2.4 -ARG OPENSSL_VERSION=3.4.1 -ARG APISIX_NGINX_MODULE_VER=1.19.5 -ARG WASM_NGINX_MODULE_VER=0.7.0 -ARG LUA_VAR_NGINX_MODULE_VER=v0.5.3 -ARG LUA_RESTY_EVENTS_VER=0.2.0 -ARG NGX_MULTI_UPSTREAM_MODULE_VER=1.3.3 -ARG MOD_DUBBO_VER=1.0.2 -ARG APISIX_RUNTIME_VER=1.3.6 - -ENV OR_PREFIX=/usr/local/openresty -ENV PATH=/root/.cargo/bin:$PATH - -RUN zypper -n refresh && zypper -n install -y \ - gcc gcc-c++ make patch git wget curl tar gzip xz which findutils perl unzip gawk - -# 업스트림처럼 cpanm 으로 IPC::Cmd 를 보강한다(OpenSSL 3.x Configure 가 요구) — SUSE 는 -# perl-App-cpanminus 패키지가 없어 공식 부트스트랩으로 설치. -RUN curl -L https://cpanmin.us | perl - App::cpanminus \ - && cpanm --notest IPC::Cmd - -RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - -WORKDIR /tmp/build - -# --- zlib 먼저 (업스트림 openresty-zlib-devel 대신 표준 소스 빌드 — 같은 산출 경로). -# OpenSSL 의 `zlib` config 옵션이 컴파일 타임에 zlib.h 를 요구하므로 OpenSSL 보다 -# 먼저 빌드해야 한다 — 실제로 순서를 반대로 뒀다가 `zlib.h: No such file` 로 실패한 -# 것을 실측했다(2026-08-11). -ARG ZLIB_VERSION=1.3.1 -RUN wget "https://github.com/madler/zlib/releases/download/v${ZLIB_VERSION}/zlib-${ZLIB_VERSION}.tar.gz" \ - && tar xzf "zlib-${ZLIB_VERSION}.tar.gz" \ - && cd "zlib-${ZLIB_VERSION}" \ - && ./configure --prefix="${OR_PREFIX}/zlib" \ - && make -j"$(nproc)" \ - && make install - -# --- pcre (classic PCRE1, nginx/openresty 기본 요구사항 — 업스트림 openresty-pcre-devel -# 과 같은 산출 경로 $OR_PREFIX/pcre 에 설치) --- -ARG PCRE_VERSION=8.45 -RUN wget "https://sourceforge.net/projects/pcre/files/pcre/${PCRE_VERSION}/pcre-${PCRE_VERSION}.tar.gz/download" -O "pcre-${PCRE_VERSION}.tar.gz" \ - && tar xzf "pcre-${PCRE_VERSION}.tar.gz" \ - && cd "pcre-${PCRE_VERSION}" \ - && ./configure --prefix="${OR_PREFIX}/pcre" --enable-jit --enable-utf --enable-unicode-properties \ - && make -j"$(nproc)" \ - && make install - -# --- OpenSSL 3.4.1 (업스트림과 동일 버전) — zlib 헤더 경로를 명시로 넘긴다 --- -RUN wget --no-check-certificate "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" \ - && tar xzf "openssl-${OPENSSL_VERSION}.tar.gz" \ - && cd "openssl-${OPENSSL_VERSION}" \ - && CFLAGS="-I${OR_PREFIX}/zlib/include" LDFLAGS="-L${OR_PREFIX}/zlib/lib -Wl,-rpath,${OR_PREFIX}/zlib/lib" \ - ./config shared zlib enable-camellia enable-seed enable-rfc3779 \ - enable-cms enable-md2 enable-rc5 enable-weak-ssl-ciphers \ - --prefix="${OR_PREFIX}/openssl3" --libdir=lib \ - --with-zlib-lib="${OR_PREFIX}/zlib/lib" --with-zlib-include="${OR_PREFIX}/zlib/include" \ - && make -j"$(nproc)" \ - && make install_sw install_ssldirs - -# --- OpenResty 소스 + 커스텀 모듈 (업스트림 build-apisix-runtime.sh 재현) --- -RUN wget --no-check-certificate "https://openresty.org/download/openresty-${OPENRESTY_VERSION}.tar.gz" \ - && tar zxf "openresty-${OPENRESTY_VERSION}.tar.gz" - -RUN git clone --depth=1 -b "${LUA_RESTY_EVENTS_VER}" https://github.com/Kong/lua-resty-events.git "lua-resty-events-${LUA_RESTY_EVENTS_VER}" \ - && git clone --depth=1 -b "${NGX_MULTI_UPSTREAM_MODULE_VER}" https://github.com/api7/ngx_multi_upstream_module.git "ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" \ - && git clone --depth=1 -b "${MOD_DUBBO_VER}" https://github.com/api7/mod_dubbo.git "mod_dubbo-${MOD_DUBBO_VER}" \ - && git clone --depth=1 -b "${APISIX_NGINX_MODULE_VER}" -- https://github.com/api7/apisix-nginx-module.git "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" \ - && git clone --depth=1 -b "${WASM_NGINX_MODULE_VER}" https://github.com/api7/wasm-nginx-module.git "wasm-nginx-module-${WASM_NGINX_MODULE_VER}" \ - && git clone --depth=1 -b "${LUA_VAR_NGINX_MODULE_VER}" https://github.com/api7/lua-var-nginx-module "lua-var-nginx-module-${LUA_VAR_NGINX_MODULE_VER}" - -RUN cd "ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" && ./patch.sh "../openresty-${OPENRESTY_VERSION}" && cd .. \ - && cd "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/patch" && ./patch.sh "../../openresty-${OPENRESTY_VERSION}" && cd ../.. \ - && cd "wasm-nginx-module-${WASM_NGINX_MODULE_VER}" && ./install-wasmtime.sh && cd .. - -RUN cd "openresty-${OPENRESTY_VERSION}" \ - && or_limit_ver=0.09 \ - && rm -rf "bundle/lua-resty-limit-traffic-${or_limit_ver}" \ - && limit_ver=1.2.0 \ - && wget "https://github.com/api7/lua-resty-limit-traffic/archive/refs/tags/v${limit_ver}.tar.gz" -O "lua-resty-limit-traffic-${limit_ver}.tar.gz" \ - && tar xzf "lua-resty-limit-traffic-${limit_ver}.tar.gz" \ - && mv "lua-resty-limit-traffic-${limit_ver}" "bundle/lua-resty-limit-traffic-${or_limit_ver}" - -RUN cd "openresty-${OPENRESTY_VERSION}" \ - && zlib_prefix="${OR_PREFIX}/zlib" pcre_prefix="${OR_PREFIX}/pcre" openssl_prefix="${OR_PREFIX}/openssl3" ; \ - ./configure --prefix="${OR_PREFIX}" \ - --with-cc-opt="-DAPISIX_RUNTIME_VER=${APISIX_RUNTIME_VER} -DNGX_LUA_ABORT_AT_PANIC -I${zlib_prefix}/include -I${pcre_prefix}/include -I${openssl_prefix}/include" \ - --with-ld-opt="-Wl,-rpath,${OR_PREFIX}/wasmtime-c-api/lib -L${zlib_prefix}/lib -L${pcre_prefix}/lib -L${openssl_prefix}/lib -Wl,-rpath,${zlib_prefix}/lib:${pcre_prefix}/lib:${openssl_prefix}/lib" \ - --add-module="../mod_dubbo-${MOD_DUBBO_VER}" \ - --add-module="../ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" \ - --add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" \ - --add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/src/stream" \ - --add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/src/meta" \ - --add-module="../wasm-nginx-module-${WASM_NGINX_MODULE_VER}" \ - --add-module="../lua-var-nginx-module-${LUA_VAR_NGINX_MODULE_VER}" \ - --add-module="../lua-resty-events-${LUA_RESTY_EVENTS_VER}" \ - --with-poll_module --with-pcre-jit \ - --without-http_rds_json_module --without-http_rds_csv_module --without-lua_rds_parser \ - --with-stream --with-stream_ssl_module --with-stream_ssl_preread_module \ - --with-http_v2_module --with-http_v3_module \ - --without-mail_pop3_module --without-mail_imap_module --without-mail_smtp_module \ - --with-http_stub_status_module --with-http_realip_module --with-http_addition_module \ - --with-http_auth_request_module --with-http_secure_link_module --with-http_random_index_module \ - --with-http_gzip_static_module --with-http_sub_module --with-http_dav_module \ - --with-http_flv_module --with-http_mp4_module --with-http_gunzip_module \ - --with-threads --with-compat \ - --with-luajit-xcflags="-DLUAJIT_NUMMODE=2 -DLUAJIT_ENABLE_LUA52COMPAT" \ - -j"$(nproc)" \ - && make -j"$(nproc)" \ - && make install - -RUN cd "lua-resty-events-${LUA_RESTY_EVENTS_VER}" \ - && install -d "${OR_PREFIX}/lualib/resty/events/" \ - && install -m 664 lualib/resty/events/*.lua "${OR_PREFIX}/lualib/resty/events/" \ - && install -d "${OR_PREFIX}/lualib/resty/events/compat/" \ - && install -m 644 lualib/resty/events/compat/*.lua "${OR_PREFIX}/lualib/resty/events/compat/" - -RUN cd "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" && OPENRESTY_PREFIX="${OR_PREFIX}" make install \ - && cd "../wasm-nginx-module-${WASM_NGINX_MODULE_VER}" && OPENRESTY_PREFIX="${OR_PREFIX}" make install - -# ============================================================================= -# 2) apisix — APISIX 본체(Lua 애플리케이션) 설치 -# ============================================================================= -FROM runtime AS apisix-app -ARG APISIX_VERSION=3.17.0 - -ENV PATH=$PATH:${OR_PREFIX}/luajit/bin:${OR_PREFIX}/nginx/sbin:${OR_PREFIX}/bin - -# lua-resty-saml(xmlsec1 바인딩)·lyaml·기타 rock 컴파일에 필요. 업스트림은 openresty -# 전용 -devel 대신 시스템 표준 pcre/pcre2/openldap/libxml2/libxslt/zlib/yaml -devel 를 -# 쓴다(RHEL 기준 utils/install-dependencies.sh 참고) — SUSE 표준 패키지로 대응한다. -# sudo 는 utils/linux-install-luarocks.sh 내부에서 그대로 호출한다(스크립트 수정 없이 -# 재사용하려고 패키지로 설치 — 이 스테이지는 어차피 root 로 실행된다). -RUN zypper -n install -y \ - pcre-devel pcre2-devel openldap2-devel \ - libxml2-devel libxslt-devel zlib-devel libyaml-devel \ - diffutils cmake automake autoconf libtool gawk readline-devel sudo - -RUN wget https://raw.githubusercontent.com/apache/apisix/${APISIX_VERSION}/utils/linux-install-luarocks.sh \ - && chmod +x linux-install-luarocks.sh \ - && ./linux-install-luarocks.sh - -WORKDIR /apisix -RUN git clone --depth=1 -b "${APISIX_VERSION}" https://github.com/apache/apisix.git . - -# apisix-master-0.rockspec 는 저장소 루트에 있다(2026-08-11 태그 3.17.0 기준 실측). -# `luarocks make` 는 rockspec 의 source.url(원격 tarball)을 쓰지 않고 현재 디렉토리를 -# 그대로 빌드 대상으로 삼는다 — git clone 만으로 충분하고 apiseven 빌드처럼 source.url -# 을 로컬 경로로 sed 패치할 필요가 없다. -# -# lua-resty-saml(luarocks 가 자동으로 받는 의존 rock)의 xmlsec 바인딩(src/saml.c)이 -# libxml2 2.12(SUSE 표준 -devel 버전) 의 `xmlSetStructuredErrorFunc` 시그니처(콜백 인자에 -# const 추가됨)와 안 맞아 `-Werror=incompatible-pointer-types` 로 빌드 실패한다(2026-08-11 -# 실측) — rock 자체의 Makefile 이 `CFLAGS_ALL := ... -Werror ...` 를 하드코딩해 환경변수 -# CFLAGS 로는 못 끈다. rock 소스를 포크하지 않고, 이 스테이지 안에서만 쓰는 gcc 래퍼로 -# 그 진단 하나만 경고로 낮춘다(뒤에 오는 -Wno-error=가 앞의 -Werror 를 그 진단에 한해 이긴다). -RUN mv /usr/bin/gcc /usr/bin/gcc.real \ - && printf '#!/bin/sh\nexec /usr/bin/gcc.real -Wno-error=incompatible-pointer-types "$@"\n' > /usr/bin/gcc \ - && chmod +x /usr/bin/gcc - -RUN luarocks make ./apisix-master-0.rockspec --tree=/usr/local/apisix/deps --local - -# 이후 단계에 영향 주지 않도록 원복한다 — 위 gcc 래퍼는 lua-resty-saml 빌드에만 쓴다. -RUN mv /usr/bin/gcc.real /usr/bin/gcc - -# apisix-build-tools 의 install_apisix() 를 재현한다(utils/install-common.sh) — luarocks -# 가 rockspec 의 build.install.bin 으로 설치한 bin/apisix 래퍼와, deps 트리 안에 설치된 -# apisix Lua 패키지(share/lua/5.1/apisix)를 /usr/local/apisix 구조로 재배치한다. -# -# ui/ 는 apache/apisix 저장소 자체엔 없다(2026-08-11 3.17.0 태그 실측) — apiseven 의 -# 패키징 파이프라인이 별도로 admin 대시보드 프론트엔드를 빌드해 끼워 넣는 단계라 -# (Node.js/yarn 빌드, Dockerfile.package.apisix), 소스만 git clone 해서는 재현할 수 -# 없다. 우리 apisix 카탈로그 배포는 이 내장 UI 를 쓰지 않는다(custom-values.yaml 에 -# 관련 설정 없음) — 없으면 빈 디렉터리만 만들고 건너뛴다(있으면 그대로 복사). -RUN mkdir -p /usr/local/apisix \ - && cp -r conf /usr/local/apisix/conf \ - && { cp -r ui /usr/local/apisix/ui 2>/dev/null || mkdir -p /usr/local/apisix/ui; } \ - && install -m 755 bin/apisix /usr/local/apisix/apisix-cli-bin \ - && mv /usr/local/apisix/deps/share/lua/5.1/apisix /usr/local/apisix/apisix \ - && sed -i '1i package.path = "/usr/local/apisix/deps/share/lua/5.1/?/init.lua;" .. package.path' \ - /usr/local/apisix/apisix/cli/apisix.lua - -# ============================================================================= -# 3) final -# ============================================================================= -FROM ${RUNTIME_BASE} AS final - -# 실제 SUSE 공유 라이브러리 패키지명은 soname 이 붙는다(libxml2/libldap 등은 이미 -# bci-base 기본 설치에 있어 명시 안 해도 되지만, 명확성을 위해 실측한 이름 그대로 적는다 -# — 2026-08-11 zypper search 로 확인: libxml2-2, libpcre2-8-0, libldap-2_4-2 는 기본 -# 포함, libpcre1·libxslt1·libyaml-0-2 만 추가 설치가 필요했다). -# gawk 는 라이브러리가 아니라 /usr/bin/apisix 래퍼 스크립트 자체가 openresty 버전 -# 파싱에 awk 를 쓰기 때문에 필요하다(2026-08-11 실측: "awk: command not found"). -RUN zypper -n install -y libpcre1 libxslt1 libyaml-0-2 libxml2-2 libpcre2-8-0 gawk \ - && zypper -n clean --all - -COPY --from=apisix-app /usr/local/openresty /usr/local/openresty -COPY --from=apisix-app /usr/local/apisix /usr/local/apisix -COPY --from=apisix-app /usr/local/apisix/apisix-cli-bin /usr/bin/apisix - -ENV PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin - -RUN chmod 755 /usr/bin/apisix \ - && rm -f /usr/local/apisix/apisix-cli-bin \ - && mkdir -p /usr/local/apisix/logs /usr/local/apisix/conf/cert \ - && groupadd --system --gid 636 apisix \ - && useradd --system --gid apisix --no-create-home --shell /usr/sbin/nologin --uid 636 apisix \ - && chown -R apisix:0 /usr/local/apisix \ - && chmod -R g=u /usr/local/apisix \ - && ln -sf /dev/stdout /usr/local/apisix/logs/access.log \ - && ln -sf /dev/stderr /usr/local/apisix/logs/error.log - -# keycloak-authz 커스텀 플러그인 (paasup/dataup 레포의 dockerfile 과 동일한 오버레이) -COPY keycloak-authz.lua /usr/local/apisix/apisix/plugins/keycloak-authz.lua - -COPY docker-entrypoint.sh /docker-entrypoint.sh -RUN chmod 755 /docker-entrypoint.sh - -WORKDIR /usr/local/apisix -USER apisix - -EXPOSE 9080 9443 - -ENTRYPOINT ["/docker-entrypoint.sh"] -CMD ["docker-start"] diff --git a/images/apisix/source.build.env b/images/apisix/source.build.env deleted file mode 100644 index 9ecddde..0000000 --- a/images/apisix/source.build.env +++ /dev/null @@ -1,34 +0,0 @@ -# apisix — 소스 컴파일형 자체 빌드 (유일한 변종) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다: -# IMAGE=apisix BASE_OS=source bash scripts/build/build-hardened-image.sh -# -# 왜 자체 빌드하는가·구성 상세 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치 -# (2026-08-11)로 도입 — 근거·경과는 MEMORY.md. - -DOCKERFILE=source.Dockerfile -TARGET=final -TAG_SLUG=security - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# 스톡 3.17.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다. -APP_VERSION=3.17.0 - -# 아래 값은 전부 2026-08-11 실측(apache/apisix:3.17.0-debian 의 `openresty -V`, apiseven -# 빌드 스크립트 api7/apisix-build-tools 태그 apisix-runtime/1.3.6) — 상위 태그 나오면 -# 이 값들을 갱신하는 게 자체 빌드 유지보수보다 항상 우선이다(대응 우선순위 a). -APISIX_VERSION=3.17.0 -OPENRESTY_VERSION=1.29.2.4 -OPENSSL_VERSION=3.4.1 -ZLIB_VERSION=1.3.1 -PCRE_VERSION=8.45 -APISIX_NGINX_MODULE_VER=1.19.5 -WASM_NGINX_MODULE_VER=0.7.0 -LUA_VAR_NGINX_MODULE_VER=v0.5.3 -LUA_RESTY_EVENTS_VER=0.2.0 -NGX_MULTI_UPSTREAM_MODULE_VER=1.3.3 -MOD_DUBBO_VER=1.0.2 -APISIX_RUNTIME_VER=1.3.6 - -BUILD_ARGS="APISIX_VERSION OPENRESTY_VERSION OPENSSL_VERSION ZLIB_VERSION PCRE_VERSION APISIX_NGINX_MODULE_VER WASM_NGINX_MODULE_VER LUA_VAR_NGINX_MODULE_VER LUA_RESTY_EVENTS_VER NGX_MULTI_UPSTREAM_MODULE_VER MOD_DUBBO_VER APISIX_RUNTIME_VER" diff --git a/images/apisix/verify.sh b/images/apisix/verify.sh deleted file mode 100755 index 544ad28..0000000 --- a/images/apisix/verify.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -# apisix 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 -# `env TAG=... PLATFORM=... bash verify.sh` 형태로 호출한다). -# -# 최종 베이스가 bci-base 라 bash/coreutils/awk 가 있다 — 게스트 셸 스크립트를 그대로 -# 주입한다. 단순 --help 스모크가 아니라 실제로 nginx worker 를 기동해 APISIX 라우터가 -# HTTP 요청에 응답하는지까지 확인한다(standalone/yaml config, etcd 불필요) — 15개 -# 커스텀 nginx 모듈 + ~90개 Lua 플러그인(lua-resty-saml 같은 C 확장 포함) 로딩까지 -# 실제로 거치는 유일한 방법이다. keycloak-authz 커스텀 플러그인은 실제 Keycloak 연동이 -# 필요해 이 스모크 범위 밖 — 이건 dev 클러스터 배포 검증(.claude/deploy-test-procedure.md) -# 이 담당한다. -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" - -echo "== apisix version ==" -OUT="$(docker run --rm --platform "$PLATFORM" --entrypoint sh "$TAG" -c ' -export PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin -/usr/bin/apisix version -')" -echo " $OUT" -case "$OUT" in - *3.17.0*) ;; - *) echo "FAIL: apisix version 출력에 3.17.0 이 없다"; exit 1 ;; -esac - -echo "== nginx 설정 문법 검사 (init + nginx -t) ==" -CONTAINER="verify-apisix-$$" -docker run -d --rm --platform "$PLATFORM" --name "$CONTAINER" --entrypoint sh "$TAG" -c ' -export PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin -cd /usr/local/apisix -export APISIX_STAND_ALONE=true -cat > conf/config.yaml < conf/apisix.yaml </tmp/init.log 2>&1 -/usr/local/openresty/nginx/sbin/nginx -p /usr/local/apisix -t >>/tmp/init.log 2>&1 -exec /usr/local/openresty/bin/openresty -p /usr/local/apisix -g "daemon off;" -' >/dev/null -trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT - -echo "== HTTP 응답 대기 (최대 15초) ==" -# curl 은 연결 자체가 실패해도 %{http_code} 로 "000" 을 찍는다 — 빈 문자열이 아니라서 -# `[ -n "$CODE" ]` 만으로는 연결 실패를 통과로 오판한다(2026-08-12 실측: 파이프라인 -# 첫 실행에서 "HTTP 000" 인데도 VERIFY-OK 가 나온 버그). "000" 은 명시적으로 실패로 친다. -ok=0 -for i in $(seq 1 15); do - CODE="$(docker run --rm --platform "$PLATFORM" --network "container:$CONTAINER" curlimages/curl:latest \ - -s -o /dev/null -w '%{http_code}' -m 2 http://127.0.0.1:9080/ 2>/dev/null || true)" - if [ -n "$CODE" ] && [ "$CODE" != "000" ]; then ok=1; break; fi - sleep 1 -done -if [ "$ok" != "1" ]; then - echo "FAIL: 15초 내에 9080 포트가 유효한 HTTP 응답을 하지 않았다 (마지막 시도: '${CODE:-없음}')" - docker logs "$CONTAINER" 2>&1 | tail -40 - exit 1 -fi -echo " HTTP $CODE (라우트가 없어 404 가 정상 — nginx/APISIX 라우터가 실제로 요청을 처리했다는 뜻)" -case "$CODE" in - 404) ;; - *) echo "FAIL: 예상한 404(라우트 없음)가 아니라 $CODE 가 나왔다 — 로그 확인 필요"; docker logs "$CONTAINER" 2>&1 | tail -40; exit 1 ;; -esac - -echo "VERIFY-OK" diff --git a/images/argocd/README.md b/images/argocd/README.md deleted file mode 100644 index 65caff0..0000000 --- a/images/argocd/README.md +++ /dev/null @@ -1,104 +0,0 @@ -# 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 deleted file mode 100644 index 9f6d53e..0000000 --- a/images/argocd/catalog.env +++ /dev/null @@ -1,18 +0,0 @@ -# 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 deleted file mode 100755 index 37dfb91..0000000 --- a/images/argocd/go-mod-upgrade.sh +++ /dev/null @@ -1,51 +0,0 @@ -#!/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 deleted file mode 100644 index 221a2c5..0000000 --- a/images/argocd/source.Dockerfile +++ /dev/null @@ -1,263 +0,0 @@ -# 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 deleted file mode 100644 index 1d87ba6..0000000 --- a/images/argocd/source.build.env +++ /dev/null @@ -1,77 +0,0 @@ -# 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 deleted file mode 100755 index 4ee8fb9..0000000 --- a/images/argocd/verify.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/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/images/cloudnative-pg/README.md b/images/cloudnative-pg/README.md deleted file mode 100644 index dac53bf..0000000 --- a/images/cloudnative-pg/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# cloudnative-pg — CloudNativePG 오퍼레이터 자체 빌드 - -CNPG 오퍼레이터(컨트롤러) 이미지를 업스트림 소스에서 직접 컴파일한다. -`manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml`·`dip-values.yaml` 의 -`image.repository`/`image.tag` 가 이 산출물을 가리킨다. - -security-catalog 프로젝트에서 포팅했다. 채택 결정과 받아들인 비용은 -[ADR 0002](../../doc/decisions/0002-cloudnative-pg-operator-self-build.md), 게이트·SBOM -파이프라인은 [doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 신규 자체 빌드 이미지 추가 -절차 전반은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다. -아래 CVE 번호는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.** - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 — 그리고 왜 `cnpg-postgresql` 과 다른 방법인가 - -업스트림 `ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0` 은 실효 HIGH 3건 -(`CVE-2026-39822` stdlib, `CVE-2026-56852` `golang.org/x/text`, `GHSA-hrxh-6v49-42gf` -`google.golang.org/grpc`)으로 차단된다. 상위 태그가 없다(2026-07-30 확인, 최신 릴리스가 -여전히 `v1.30.0`). 업스트림 배포 이미지 베이스가 `gcr.io/distroless/static-debian13` -(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만으로는 고쳐지지 않는다** — CVE 는 -바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 자체 빌드(소스 컴파일)만 유효한 대응이다. - -`cnpg-postgresql`(OS 베이스 교체 + zypper 패치)과 성격이 다르지만, **오케스트레이션은 -동일한 [scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh) -하나를 공유한다.** 이 이미지가 그 스크립트의 계약(`build.env` 가 `DOCKERFILE`·`TARGET`· -`BUILD_ARGS`·`APP_VERSION` 선언, `verify.sh` 가 `VERIFY-OK` 로 종료)만 지키면, 이미지 -종류(OS 패키지 설치형 vs 소스 컴파일형)는 스크립트가 몰라도 된다 — 근거는 -[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고. - -## 소스·버전 관리 - -| 항목 | 값 | -| --- | --- | -| 소스 | `https://github.com/cloudnative-pg/cloudnative-pg.git` | -| pinned commit | `source.build.env` 의 `SOURCE_COMMIT` (release-1.30 브랜치) | -| 빌더 | 공식 `golang` 이미지 (`source.build.env` 의 `GO_BUILDER_TAG`, go.mod 요구 버전과 일치) | -| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 | - -빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이 -아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다. `bci-micro` -는 SUSE BCI 중 가장 가벼운 변종이지만 `bci-base` 와 달리 `nonroot`(uid 65532) 계정이 -미리 없어 `source.Dockerfile` 이 직접 만든다(`/etc/passwd`·`/etc/group` 에 추가). - -`SOURCE_COMMIT` 은 **자동 추적하지 않는다.** `cnpg-postgresql` 의 PGDG 버전처럼 사람이 -업스트림 `release-1.30` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고 -`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다. - -**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 -업스트림이 `v1.30.1`(혹은 그 이상) 을 릴리스했을 때 — 릴리스가 나오면 그쪽으로 갈아타는 -것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다. - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다) -IMAGE=cloudnative-pg BASE_OS=source REGISTRY=docker.io/wbsong111 \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -`scan-sbom.sh` 의 커버리지 자가진단(`CoverageProbe`)까지 포함한다 — `doc/sbom-pipeline.md` -참고. dip-catalog 로컬 빌드 실측(2026-08-03): 커버리지 `ok`, 실효 C/H 0/0, 게이트 -PASS(`localhost/...` 태그, push 는 아직 안 함 — `MEMORY.md` 참고). `verify.sh` 는 -`manager version` 출력에 pinned -commit 이 실제로 반영됐는지(ldflags 주입 확인), 이미지 `Config.User` 가 -`65532:65532`(nonroot)인지, `--help` 가 정상 종료하는지를 확인한다 — 실제 컨트롤러 -기동(k8s API 필요)은 이 스모크 테스트 범위 밖이며, dev 클러스터 배포 테스트 -(`.claude/deploy-test-procedure.md`)가 담당한다. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) | -| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 | -| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 직접 `docker run --entrypoint /manager` 를 호출한다(게스트 스크립트 주입 방식보다 상위 호환 — 최종 이미지에 셸이 없는 경우에도 그대로 동작한다) | - -베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다 — `cnpg-postgresql` 처럼 -변종이 늘면 `.Dockerfile`/`.build.env` 로 분기한다. - -### 태그 - -``` -docker.io/wbsong111/cloudnative-pg:1.30.0-security-hardened-20260730 - └ app ─┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다. -dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고. diff --git a/images/cloudnative-pg/catalog.env b/images/cloudnative-pg/catalog.env deleted file mode 100644 index c95c08c..0000000 --- a/images/cloudnative-pg/catalog.env +++ /dev/null @@ -1,11 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. - -CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=split -TAG_BLOCK=image - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=source diff --git a/images/cloudnative-pg/source.Dockerfile b/images/cloudnative-pg/source.Dockerfile deleted file mode 100644 index 4fe88e9..0000000 --- a/images/cloudnative-pg/source.Dockerfile +++ /dev/null @@ -1,80 +0,0 @@ -# CloudNativePG 오퍼레이터 — 업스트림 소스를 pinned commit 으로 직접 컴파일한다. -# -# 업스트림(https://github.com/cloudnative-pg/cloudnative-pg)의 Dockerfile 은 이미 goreleaser -# 로 빌드된 바이너리(`dist/manager/manager_`)를 COPY 만 한다 — 컴파일 자체는 이 Dockerfile -# 밖(Makefile `docker-build` → goreleaser)에서 일어난다. 우리는 그 컴파일 단계를 Dockerfile -# 안으로 가져와 `go build` 로 직접 재현한다. `cnpg-postgresql/suse.Dockerfile` 이 "업스트림 -# Dockerfile 을 다른 배포판으로 이식"한 것이라면, 이건 "업스트림이 Dockerfile 밖에서 하던 -# 빌드를 Dockerfile 안으로 흡수"한 것이다. -# -# 왜 자체 빌드인가 — cloudnative-pg:1.30.0 이 게이트에서 차단하는 CVE 3건(stdlib·x/text·grpc) -# 은 OS 패키지가 아니라 바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 배포 이미지 베이스가 -# distroless(OS 패키지 사실상 0개)라 베이스 OS 교체로는 고칠 수 없다 — 소스를 다시 컴파일해야 -# 한다. 세 CVE 모두 업스트림 release-1.30 브랜치에 이미 백포트돼 있다(SOURCE_COMMIT 이 그 -# 커밋을 가리킨다) — 근거: doc/decisions/0002-cloudnative-pg-operator-self-build.md -# -# 업스트림과의 대응 관계 (release-1.30 브랜치 Dockerfile 기준) -# go build (Makefile build-manager) → 동일 (ldflags 까지 그대로) -# goreleaser 멀티아치(manager_amd64/arm64) → 단일 아키텍처(linux/amd64)만 직접 COPY. -# 심볼릭 링크 대신 같은 바이너리를 두 경로에 COPY (아래 "실측으로 드러난 필수 조건" 참고) -# distroless base(gcr.io/distroless/static-debian13:nonroot) → SUSE BCI(bci-micro)로 교체. -# 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001) — 최종 런타임 이미지에도 동일하게 적용한다. -# builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다 — 컴파일 결과물에는 -# 영향이 없고, 최종 이미지에만 남는 게 무엇인지가 스캔·정책 대상이다 - -# syntax=docker/dockerfile:1 - -ARG GO_BUILDER_TAG=1.26.5-trixie -# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — 스테이지 안에서 -# 선언하면(예: 이전엔 builder 스테이지 RUN 다음에 둠) 그 스테이지 지역 변수가 되어 다음 -# FROM 의 이미지명 해석에 쓰이지 않는다(실측: "FROM argument 'RUNTIME_BASE' is not -# declared" 경고와 함께 빈 이미지명 에러 발생). -ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없으므로 -# --platform=$BUILDPLATFORM 로 고정해 에뮬레이션 오버헤드를 피한다(로컬 arm64 Docker 데스크톱 -# 에서 linux/amd64 결과물을 만들 때 특히 중요). -FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder -ARG TARGETARCH -ARG SOURCE_COMMIT -ARG APP_VERSION -WORKDIR /src - -# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다. -ADD https://github.com/cloudnative-pg/cloudnative-pg.git#${SOURCE_COMMIT} /src - -# 업스트림 Makefile 의 LDFLAGS·.goreleaser.yml 의 build 설정을 그대로 재현한다. -# (release-1.30 기준 go.mod: go 1.26.5, google.golang.org/grpc v1.82.1, golang.org/x/text v0.39.0) -RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - set -eux; \ - CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath \ - -ldflags "-s -w \ - -X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildVersion=${APP_VERSION} \ - -X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildCommit=${SOURCE_COMMIT} \ - -X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildDate=$(date -u +%Y-%m-%d)" \ - -o /out/manager ./cmd/manager - -FROM ${RUNTIME_BASE} AS final -WORKDIR / - -# bci-micro 는 root 만 있고 65532 사용자가 없다(distroless 의 nonroot 변종과 달리 nonroot -# 계정을 미리 만들어두지 않는다) — 직접 만든다. bci-micro 에 bash·coreutils 는 있다 -# (zypper·rpm 은 없음 — "micro" 는 패키지 매니저가 빠진 것이지 셸까지 없는 건 아니다). -RUN set -eux; \ - echo 'nonroot:x:65532:65532:nonroot:/home/nonroot:/bin/false' >> /etc/passwd; \ - echo 'nonroot:x:65532:' >> /etc/group; \ - mkdir -p /home/nonroot; \ - chown 65532:65532 /home/nonroot - -# 실측으로 드러난 필수 조건: 오퍼레이터는 `operator/manager_` 를 런타임에 glob 해서 -# "가용 아키텍처" 목록을 만든다(pkg/utils/discovery.go DetectAvailableArchitectures) — 이 -# 목록이 비어 있으면 Cluster 리컨실이 "invalid architecture: amd64" 로 실패한다(배포 -# 테스트 2026-07-30 에서 재현). 업스트림은 멀티아치 심볼릭 링크로 이걸 만들지만, 우리는 -# 단일 아키텍처만 다루므로 같은 바이너리를 두 경로에 COPY 해 같은 효과를 낸다. -COPY --from=builder /out/manager /manager -COPY --from=builder /out/manager /operator/manager_amd64 -COPY --from=builder /src/licenses /licenses -COPY --from=builder /src/LICENSE /licenses/LICENSE -USER 65532:65532 -ENTRYPOINT ["/manager"] diff --git a/images/cloudnative-pg/source.build.env b/images/cloudnative-pg/source.build.env deleted file mode 100644 index 2663dbd..0000000 --- a/images/cloudnative-pg/source.build.env +++ /dev/null @@ -1,36 +0,0 @@ -# CloudNativePG 오퍼레이터 — 소스 컴파일형 자체 빌드 (유일한 변종) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다: -# IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh -# -# 왜 자체 빌드하는가·CVE 근거 → README.md -# 결정 → doc/decisions/0002-cloudnative-pg-operator-self-build.md - -DOCKERFILE=source.Dockerfile -TARGET=final -TAG_SLUG=security - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# 스톡 1.30.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다. -APP_VERSION=1.30.0 - -# release-1.30 브랜치 HEAD, 2026-07-30 확인 — CVE-2026-39822(stdlib)·CVE-2026-56852(x/text)· -# GHSA-hrxh-6v49-42gf(grpc) 가 모두 이 커밋에 백포트돼 있다. 갱신할 때는 release-1.30 의 -# 최신 커밋으로 사람이 다시 고른다(자동 추적하지 않음 — cnpg-postgresql 의 PGDG 버전처럼 -# 사람이 트리거해야 하는 갱신 축). -SOURCE_COMMIT=4463551204bc5cdb5af05b2c60a2d6b58ce9ff6a - -# go.mod 의 `go 1.26.5` 요구를 만족하는 공식 golang 이미지 태그. 이 이미지는 builder -# 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다. -# -# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를 -# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다: -# python3 scripts/build/suggest-go-upgrades.py --reports --image cloudnative-pg -GO_BUILDER_TAG=1.26.6-trixie - -# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). bci-base 대신 -# 가장 가벼운 bci-micro 를 쓴다(패키지 매니저 없음, bash·coreutils 는 있음). -RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE" diff --git a/images/cloudnative-pg/verify.sh b/images/cloudnative-pg/verify.sh deleted file mode 100755 index ed6640b..0000000 --- a/images/cloudnative-pg/verify.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash -# cloudnative-pg 오퍼레이터 이미지 기능 검증 — 호스트에서 bash 로 실행된다 -# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh` -# 형태로 호출한다). -# -# cnpg-postgresql/verify.sh 와 달리 게스트 셸을 쓰지 않는다 — 최종 이미지 베이스가 -# gcr.io/distroless/static-debian13:nonroot 라 셸이 아예 없다(`/bin/sh` 없음, 업스트림 -# Dockerfile 도 이 사실을 자기 빌더 스테이지 주석에 명시한다). 대신 `--entrypoint /manager` -# 로 바이너리를 직접 실행해 검증한다 — 실제 컨트롤러 기동(k8s API 필요)은 이 스모크 -# 테스트 범위 밖이며, dev 클러스터 배포 테스트가 담당한다. -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" -SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}" -SHORT_COMMIT="${SOURCE_COMMIT:0:8}" - -echo "== manager version ==" -OUT="$(docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" version)" -echo " $OUT" -case "$OUT" in - *"$SHORT_COMMIT"*) ;; - *) echo "FAIL: version 출력에 pinned commit($SHORT_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;; -esac - -echo "== 실행 사용자 (nonroot, 이미지 메타데이터) ==" -# 셸이 없어 컨테이너 안에서 id 를 실행할 수 없다 — 이미지가 선언한 기본 User 를 확인한다. -# 위 version 실행 자체가 이 기본 사용자로 이미 성공했다(권한 문제였다면 여기까지 오지 못한다). -USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")" -[ "$USER_CFG" = "65532:65532" ] || { echo "FAIL: 이미지 Config.User 가 65532:65532 가 아니다 (실제: $USER_CFG)"; exit 1; } -echo " Config.User=$USER_CFG" - -echo "== --help 스모크 (k8s API 없이 도는 유일한 확인 범위) ==" -docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" --help >/dev/null -echo " /manager --help 종료 코드 0" - -echo "VERIFY-OK" diff --git a/images/cnpg-postgresql/README.md b/images/cnpg-postgresql/README.md deleted file mode 100644 index 4ded64c..0000000 --- a/images/cnpg-postgresql/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# cnpg-postgresql — CloudNativePG PostgreSQL 자체 빌드 - -CNPG 오퍼레이터가 관리하는 PostgreSQL 인스턴스 이미지를 직접 빌드한다. -`manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml` 의 `imageName` 이 이 산출물을 가리킨다. - -security-catalog 프로젝트에서 포팅했다. 채택 결정·후보 비교표·받아들인 비용은 -[ADR 0001](../../doc/decisions/0001-cnpg-postgresql-image.md), 게이트·SBOM 파이프라인은 -[doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 이미지 선정 규칙과 빌드 프레임워크는 -[.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다. -아래 CVE 수치는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.** - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - ---- - -## 왜 자체 빌드하나 - -업스트림 `ghcr.io/cloudnative-pg/postgresql:18.4-standard-trixie` 는 실효 CRITICAL/HIGH -23건을 갖고 있고, **전부 수정 버전이 없다**(Debian `unimportant` 9 / `no-dsa` 7 / -`postponed` 3 / 미분류 4). SUSE 는 같은 CVE 들을 이미 백포트했다 — 베이스 OS 를 바꾸면 -벤더 판정이 달라진다. - -**단, 수치가 낮아진 것이 실제 개선인지 벤더 미평가인지 반드시 구분해야 한다.** -`cve-gate.py` 의 교차 검증(`--crossref`)이 이를 돕고, `scan-sbom.sh` 의 커버리지 -자가진단(`CoverageProbe`)이 "0건"과 "측정 안 됨"을 구분한다(`doc/sbom-pipeline.md` 참고). -dip-catalog 로컬 빌드로 재확인(2026-08-03): 커버리지 `ok`, 실효 C/H 0/0, 게이트 -PASS(`localhost/...` 태그, push 는 아직 안 함 — `MEMORY.md` 참고). - -## 베이스 OS - -이 이미지는 **SUSE BCI 15.7** 로 빌드됐다(security-catalog 프로젝트 ADR, 미이관). - -| 파일 | 베이스 | 패키지 관리자 | 측정 가능성 | -| --- | --- | --- | --- | -| `suse.Dockerfile` | `registry.suse.com/bci/bci-base:15.7` | zypper + PGDG rpm | ✅ trivy 로 완전 측정 | - -`ubuntu:24.04` 기반 빌드(후보 B)는 security-catalog 프로젝트에서 검토 후 제거됐다 — -카탈로그가 채택하지 않아 재빌드되지 않은 채 낡아가고 있었다(빌드 하루 만에 findings -38 → 46). 관련 실측은 이관되지 않았다. - -### 업스트림과의 차이 - -**원칙: 업스트림 Dockerfile 과의 차이를 최소로 유지한다.** 패키지 구성·uid·확장 목록을 -임의로 줄이면 오퍼레이터가 이미지에 기대하는 조건이 깨진다. - -`suse.Dockerfile` 은 업스트림 CNPG(Debian/apt/PGDG-deb)를 SUSE(zypper/PGDG-rpm)로 -이식한 것이라 저장소 설정·패키지명·경로가 전부 다르다. 대응 관계는 파일 상단 주석 참고. - -`patched` 단계의 `zypper update -y` 가 실제로 기여한 몫: 베이스 이미지는 주기적으로만 -재빌드되므로 배포판 아카이브보다 뒤처져 있다. 이 단계가 없으면 그 시점의 미패치 취약점이 -그대로 남는다 — 자동 재빌드가 필요한 이유가 이것이다. - -### 업스트림 빌드 레시피 변경 점검 (수동, 자동화 대상 아님) - -`cloudnative-pg/postgres-containers` 저장소가 자체 Dockerfile 구조를 바꾸면(새 확장, -새 하드닝 단계 등) 이 포트도 따라가야 한다. **CI 로 자동 감지하지 않는다** — 배포판이 -달라(Debian vs SUSE) 의미 있는 diff 가 안 나온다. - -**권장 점검 주기: CNPG 마이너 릴리스마다.** 업스트림 Dockerfile 을 훑어보고 구조가 -바뀌었으면 `suse.Dockerfile` 을 손으로 다시 이식한다. - ---- - -## 빌드 - -```sh -# 카탈로그가 쓰는 베이스 OS (기본값) -IMAGE=cnpg-postgresql BASE_OS=suse bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다) -IMAGE=cnpg-postgresql BASE_OS=suse REGISTRY=docker.io/wbsong111 \ - bash scripts/build/build-hardened-image.sh /tmp/out - -# 교차 검증을 함께 (사각지대 후보를 뽑는다. CI 에는 아직 연결되지 않았다) -CROSSREF=/tmp/ref/standard-trixie.json IMAGE=cnpg-postgresql BASE_OS=suse \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다. -CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수 없다. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `.Dockerfile` | 빌드 정의 | -| `.build.env` | 베이스·앱 버전·확장 목록. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 | -| `verify.sh` | 기능 검증. **베이스 OS 공통** — CNPG 요구사항은 베이스와 무관하다 | - -베이스 OS 가 늘어도 이 구조는 그대로다 — `build-hardened-image.sh` 는 `BASE_OS` 환경변수로 -`.build.env` 를 고른다. - -### 태그에 빌드일을 넣는다 - -``` -docker.io/wbsong111/cnpg-postgresql:18.4-bci15.7-hardened-20260729 - └ 앱 ─┘└ 베이스 ─┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -같은 앱 버전이라도 `zypper update` 결과가 시점마다 다르다. 롤링 태그를 피하고 -자체 이미지에도 같은 실수를 하지 않는다(빌드일 포함 고정 태그). - -이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다. -dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고. - ---- - -## 실측 (security-catalog, 2026-07-29 / trivy 0.72.0 기준) - -| | `bci15.7-hardened-20260728` | -| --- | --- | -| 커버리지 자가진단 | ✅ `ok` | -| 전 심각도 findings | 0 | -| 실효 고유 C/H | **0 / 0** | -| 게이트 | **PASS** | - -### 채택 당시 비교 (후보 A/B/C, security-catalog 기준) - -| | 업스트림 `standard-trixie` (A) | Ubuntu 24.04 자체빌드 (B, 제거됨) | **SUSE BCI 15.7 (C, 채택)** | -| --- | --- | --- | --- | -| 패키지 수 | 148 | 147 | 182 | -| trivy findings (전 심각도) | 316 | 46 | 0 | -| 실효 고유 C/H | 23 | 0 | 0 | -| 미조사 사각지대 | 0 | 18건 | 18건 | -| 서명·attestation | ✅ | ❌ | ❌ | -| 배포 검증 | — | ✅ failover 2초 | ✅ failover 3초 | -| gid | `postgres` | ⚠️ `tape` | `postgres` | - -이 수치는 dip-catalog 에서 재측정한 것이 아니라 security-catalog 프로젝트에서 이관한 -기록이다 — dip-catalog 자체 게이트로 재확인은 아직 하지 않았다(`MEMORY.md` 참고). diff --git a/images/cnpg-postgresql/catalog.env b/images/cnpg-postgresql/catalog.env deleted file mode 100644 index e87f274..0000000 --- a/images/cnpg-postgresql/catalog.env +++ /dev/null @@ -1,11 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. - -# 이 이미지의 태그를 참조하는 차트 버전 디렉토리 (공백 구분, 여러 개 가능) -CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0 manifests/helm/cnpg-cluster/1.1.0" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=imageName - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=suse diff --git a/images/cnpg-postgresql/suse.Dockerfile b/images/cnpg-postgresql/suse.Dockerfile deleted file mode 100644 index 16c64b7..0000000 --- a/images/cnpg-postgresql/suse.Dockerfile +++ /dev/null @@ -1,83 +0,0 @@ -# CloudNativePG PostgreSQL — SUSE BCI 기반 자체 빌드 -# -# 업스트림 CNPG Dockerfile(Debian/apt/PGDG-deb)을 SUSE(zypper/PGDG-rpm)로 이식한 것이다. -# ARG BASE 만 바꾸는 수준이 아니라 패키지 관리자·저장소 설정·패키지명·경로가 모두 다르다. -# -# 업스트림과의 대응 관계 -# postgresql-common + apt.postgresql.org.sh → rpm --import + zypper addrepo (PGDG zypp) -# postgresql-18 → postgresql18-server -# postgresql-18-pgaudit / -pgvector → pgaudit_18 / pgvector_18 -# locales-all → glibc-locale -# usermod -u 26 postgres → 불필요 (PGDG RPM 이 uid 26 으로 생성) -# /usr/lib/postgresql/18/bin → /usr/pgsql-18/bin -# -# CNPG 호환성 근거 (실측) -# - uid 26: PGDG RPM 이 postgres 를 uid/gid 26 으로 만든다 (RPM 계열 관례) -# - 바이너리 탐색: CNPG 는 경로를 하드코딩하지 않고 PATH 로 찾는다. -# cloudnative-pg 소스에서 "usr/lib/postgresql" 는 테스트 픽스처에만 등장한다. -# 그래도 하드코딩 경로를 쓰는 코드가 생길 경우를 대비해 심볼릭 링크를 함께 둔다. -# -# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다. -# 2026-07-29 이전에는 "trivy 의 SUSE 데이터가 15.7 을 커버하지 않는다" 로 판단했으나 -# 양성 대조(SBOM 사본에 취약 버전 주입 후 재스캔)로 재측정한 결과 오판이었다 — trivy 는 -# 13건을 잡았다(SUSE-SU ID 포함). 0건은 이 이미지가 실제로 최신이라서다. -# scan-sbom.sh 의 커버리지 자가진단이 매 스캔마다 이를 확인한다. -# 판정 메커니즘: doc/sbom-pipeline.md (CoverageProbe) - -ARG BASE=registry.suse.com/bci/bci-base:15.7 -FROM $BASE AS minimal - -ARG PG_MAJOR=18 -ARG PG_VERSION=18.4-4200001PGDG.sles15.7 -ARG SLE_REPO=sles-15.7-x86_64 -ARG PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15 - -ENV PATH=$PATH:/usr/pgsql-${PG_MAJOR}/bin - -# PGDG 저장소는 GPG 키를 미리 import 해야 한다. addrepo 만 하면 -# "Signature verification failed for repomd.xml" 로 저장소가 스킵된다. -RUN set -eux; \ - rpm --import "https://download.postgresql.org/pub/repos/zypp/keys/${PGDG_KEY}"; \ - zypper --non-interactive addrepo --refresh \ - "https://download.postgresql.org/pub/repos/zypp/${PG_MAJOR}/suse/${SLE_REPO}/" pgdg; \ - zypper --non-interactive refresh; \ - zypper --non-interactive install -y --no-recommends \ - "postgresql${PG_MAJOR}-server=${PG_VERSION}"; \ - zypper clean --all; \ - rm -rf /var/log/zypp /var/cache/zypp - -# CNPG 는 PATH 로 바이너리를 찾지만, Debian 레이아웃을 가정하는 코드가 생길 경우를 대비한다. -RUN set -eux; \ - mkdir -p /usr/lib/postgresql; \ - ln -sfn "/usr/pgsql-${PG_MAJOR}" "/usr/lib/postgresql/${PG_MAJOR}"; \ - id postgres | grep -q 'uid=26(' || { echo "postgres uid 가 26 이 아니다"; exit 1; } - -USER 26 - - -FROM minimal AS standard -ARG PG_MAJOR=18 -# 업스트림 standard 와 동등한 구성: pgaudit, pgvector, contrib(pg_stat_statements), 전 로케일. -# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (업스트림 Debian standard 와의 차이). -ARG EXTENSIONS="pgaudit_${PG_MAJOR} pgvector_${PG_MAJOR}" -USER 0 -RUN set -eux; \ - zypper --non-interactive install -y --no-recommends \ - glibc-locale \ - "postgresql${PG_MAJOR}-contrib" \ - ${EXTENSIONS}; \ - zypper clean --all; \ - rm -rf /var/log/zypp /var/cache/zypp -USER 26 - - -# 베이스 이미지에 남은 구버전 패키지를 보안 업데이트로 올린다. -# Debian 판의 `apt-get upgrade` 단계에 대응한다 (그쪽에서 NVD 기준 HIGH 5건을 해소했다). -FROM standard AS patched -USER 0 -RUN set -eux; \ - zypper --non-interactive refresh; \ - zypper --non-interactive update -y --no-recommends; \ - zypper clean --all; \ - rm -rf /var/log/zypp /var/cache/zypp -USER 26 diff --git a/images/cnpg-postgresql/suse.build.env b/images/cnpg-postgresql/suse.build.env deleted file mode 100644 index 79773c2..0000000 --- a/images/cnpg-postgresql/suse.build.env +++ /dev/null @@ -1,28 +0,0 @@ -# CNPG PostgreSQL — SUSE BCI / zypper 기반 자체 빌드 (카탈로그 채택 베이스 OS) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# BASE_OS 기본값이 이 파일(suse)이다 — 카탈로그의 imageName 이 실제로 이 정의로 빌드된다. -# -# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다 (2026-07-29 재측정, 이전 판단 정정). -# 매 스캔마다 커버리지 자가진단(CoverageProbe)이 재확인한다 — doc/sbom-pipeline.md - -DOCKERFILE=suse.Dockerfile -TARGET=patched -TAG_SLUG=bci15.7 - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# PG_VERSION(아래, PGDG 패키지의 정확한 EVR)에서 메이저.마이너만 뽑은 값과 같다 — -# 예전엔 스크립트가 PG_VERSION 을 파싱해 유도했지만, 이제는 이미지가 명시적으로 선언한다. -APP_VERSION=18.4 - -BASE=registry.suse.com/bci/bci-base:15.7 -PG_MAJOR=18 -# PGDG zypp 저장소의 정확한 EVR. apt 쪽과 형식이 다르다. -PG_VERSION=18.4-4200001PGDG.sles15.7 -SLE_REPO=sles-15.7-x86_64 -PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15 - -# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (apt 변종과의 차이). -EXTENSIONS="pgaudit_18 pgvector_18" - -BUILD_ARGS="BASE PG_MAJOR PG_VERSION SLE_REPO PGDG_KEY EXTENSIONS" diff --git a/images/cnpg-postgresql/verify.sh b/images/cnpg-postgresql/verify.sh deleted file mode 100755 index eb25bc7..0000000 --- a/images/cnpg-postgresql/verify.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -# CNPG PostgreSQL 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 -# `env TAG=... PLATFORM=... PG_MAJOR=... bash verify.sh` 형태로 호출한다). 이 이미지는 셸 -# (`/bin/sh`)이 있으므로, 실제 점검은 컨테이너 안에서 게스트 셸 스크립트로 수행한다. -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -# -# 왜 이미지 디렉토리에 두나: 검증 항목은 이미지마다 다르다. 빌드 스크립트에 하드코딩하면 -# 이미지가 둘 이상이 될 때 깨진다. 실제로 apt·suse 두 변종이 생겨 분리했다. -# -# CNPG 가 이미지에 요구하는 것(오퍼레이터 소스 기준) -# - uid 26 — 다르면 오퍼레이터가 관리하는 볼륨 권한이 깨진다 -# - PATH 에서 initdb/pg_ctl/postgres 를 찾을 수 있어야 한다 (경로 하드코딩 아님) -# - 차트가 선언한 확장이 실제로 생성되어야 한다 -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" -PG_MAJOR="${PG_MAJOR:?PG_MAJOR 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}" - -docker run --rm -i --platform "$PLATFORM" -e PG_MAJOR="$PG_MAJOR" --entrypoint sh "$TAG" <<'GUEST' -set -e - -export PGDATA=/tmp/_verify -export PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:/usr/pgsql-${PG_MAJOR}/bin:$PATH" - -echo "== 실행 사용자 ==" -uid=$(id -u) -gid=$(id -g) -gname=$(id -gn 2>/dev/null || echo '?') -echo " uid=$uid gid=$gid($gname)" -[ "$uid" = "26" ] || { echo "FAIL: uid=$uid — CNPG 는 26 을 요구한다"; exit 1; } - -# gid 는 실패시키지 않고 경고만 한다. 실측 사례: Ubuntu 베이스에서 gid 26 이 `tape` -# 그룹에 선점되어 있어 postgres 가 tape 그룹으로 동작했다. 기능은 동작하지만 위생 문제다. -if [ "$gname" != "postgres" ]; then - echo " WARN: gid $gid 의 그룹명이 '$gname' 이다 (postgres 가 아님)" -fi - -echo "== 바이너리 탐색 (PATH 기준) ==" -for b in initdb pg_ctl postgres psql; do - p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; } - echo " $b → $p" -done - -echo "== initdb + 기동 ==" -initdb -D "$PGDATA" -A trust >/dev/null -pg_ctl -D "$PGDATA" -w start >/dev/null \ - -o "-c shared_preload_libraries=pgaudit,pg_stat_statements -c unix_socket_directories=/tmp" -psql -h /tmp -U postgres -Atc "SELECT version();" | sed 's/^/ /' - -echo "== 확장 생성 ==" -# 차트(cnpg-cluster)가 Database CRD 로 선언하는 것과 같은 목록이다. -psql -h /tmp -U postgres -q -c \ - "CREATE EXTENSION pgaudit; CREATE EXTENSION vector; CREATE EXTENSION pg_stat_statements;" -psql -h /tmp -U postgres -Atc \ - "SELECT extname||' '||extversion FROM pg_extension ORDER BY 1;" | sed 's/^/ /' - -echo "== libxml2 링크 (XML 함수) ==" -psql -h /tmp -U postgres -Atc \ - "SELECT xml_is_well_formed_document('x');" | sed 's/^/ /' - -echo "== 로케일 ==" -# 업스트림 standard 는 전 로케일을 포함한다. 줄어들면 정렬·비교 동작이 달라진다. -n=$(psql -h /tmp -U postgres -Atc "SELECT count(*) FROM pg_collation;") -echo " pg_collation: ${n}개" -[ "$n" -gt 100 ] || echo " WARN: collation 이 ${n}개다 — 로케일 패키지 누락 의심" - -pg_ctl -D "$PGDATA" -w stop >/dev/null -echo "VERIFY-OK" -GUEST diff --git a/images/etcd/README.md b/images/etcd/README.md deleted file mode 100644 index 5f796ab..0000000 --- a/images/etcd/README.md +++ /dev/null @@ -1,96 +0,0 @@ -# etcd — 자체 빌드 - -etcd 서버/`etcdctl`/`etcdutl` 바이너리를 업스트림 소스에서 직접 컴파일한다. -`manifests/helm/etcd/1.1.12/custom-values.yaml` 의 `image.repository`/`image.tag` 가 -이 산출물을 가리킨다. - -security-catalog 프로젝트에서 포팅했다. 채택 결정과 받아들인 비용은 -[ADR 0004](../../doc/decisions/0004-etcd-image-self-build.md), 이 차트를 고른 이유는 -[ADR 0003](../../doc/decisions/0003-etcd-chart-selection.md), 게이트·SBOM 파이프라인은 -[doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 신규 자체 빌드 이미지 추가 절차 전반은 -[.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다. -아래 CVE 번호는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.** - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 — 그리고 왜 `cloudnative-pg` 와 같은 방법인가 - -업스트림 `quay.io/coreos/etcd:v3.7.1` 은 실효 HIGH 1건(`CVE-2026-56852`, -`golang.org/x/text` — 깨진 UTF-8 입력에 대한 `norm.Iter` 무한루프 DoS)으로 차단된다. -상위 태그가 없다(2026-07-31 확인, 최신 릴리스가 여전히 `v3.7.1`이고 `release-3.7` -브랜치도 아직 이 CVE 를 백포트하지 않았다). 업스트림 배포 이미지 베이스가 -`gcr.io/distroless/static-debian12`(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만 -으로는 고쳐지지 않는다** — CVE 는 바이너리에 정적 링크된 Go 모듈 버전이 원인이다. -자체 빌드(소스 컴파일)만 유효한 대응이다. - -이는 `cloudnative-pg` 오퍼레이터 자체 빌드와 **완전히 같은 유형의 문제**(같은 -CVE-2026-56852)이고 대응도 같다 — **오케스트레이션은 동일한 -[scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh) -하나를 공유한다.** - -## 소스·버전 관리 - -| 항목 | 값 | -| --- | --- | -| 소스 | `https://github.com/etcd-io/etcd.git` | -| pinned commit | `source.build.env` 의 `SOURCE_COMMIT` — `v3.7.1` 태그가 가리키는 실제 커밋 | -| 빌더 | 공식 `golang` 이미지 (`source.build.env` 의 `GO_BUILDER_TAG`, go.mod 요구 버전과 일치) | -| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 | - -빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이 -아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다. - -**업스트림과 다른 부분은 딱 하나다** — `golang.org/x/text` 를 워크스페이스 전역 -`go.work` `replace` 한 줄로 `0.39.0` 이상으로 강제 업그레이드한다(각 서브모듈의 -`go.mod` 를 개별로 고치지 않는다). 그 외 빌드 절차(`server`/`etcdutl`/`etcdctl` 세 -바이너리를 각각 컴파일, `CGO_ENABLED=0` 정적 링크)는 업스트림 -`scripts/build_lib.sh` 의 `etcd_build()` 를 그대로 재현한다. - -`SOURCE_COMMIT` 은 **자동 추적하지 않는다.** `cloudnative-pg` 와 동일하게 사람이 -업스트림 `release-3.7` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고 -`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다. - -**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 -업스트림이 `v3.7.2`(혹은 그 이상, `x/text` 백포트 포함)를 릴리스했을 때 — 릴리스가 -나오면 그쪽으로 갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 -우선이다. - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out - -# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다) -IMAGE=etcd BASE_OS=source REGISTRY=docker.io/wbsong111 \ - bash scripts/build/build-hardened-image.sh /tmp/out -``` - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** -`verify.sh` 는 `etcd`/`etcdctl`/`etcdutl` 이 PATH 에 있는지, `etcd --version` 출력에 -pinned commit 이 실제로 반영됐는지(ldflags 주입 확인), 단일 노드로 기동해 `etcdctl` -put/get 왕복이 실제로 동작하는지를 확인한다 — 다중 노드 쿼럼·TLS 등은 이 스모크 테스트 -범위 밖이며, dev 클러스터 배포 테스트(`.claude/deploy-test-procedure.md`, -`scripts/deploy-test/deploy-test-etcd.sh`)가 담당한다. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) | -| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 | -| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 `docker run --entrypoint sh` 로 게스트 셸 스크립트를 주입한다(`bci-micro` 는 bash·coreutils 가 있다 — `cnpg-postgresql/verify.sh` 와 같은 패턴, `cloudnative-pg` 의 셸 없는 distroless 와는 다르다) | - -베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다. - -### 태그 - -``` -docker.io/wbsong111/etcd:3.7.1-security-hardened-20260731 - └ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` - -이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다. -dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고. diff --git a/images/etcd/catalog.env b/images/etcd/catalog.env deleted file mode 100644 index 253f888..0000000 --- a/images/etcd/catalog.env +++ /dev/null @@ -1,11 +0,0 @@ -# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.build.env)와는 -# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다. - -CHART_DIRS="manifests/helm/etcd/1.1.12" - -# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리) -TAG_STYLE=split -TAG_BLOCK=image - -# base_os 입력을 안 주면 쓸 기본 변종 (images//.build.env) -DEFAULT_BASE_OS=source diff --git a/images/etcd/source.Dockerfile b/images/etcd/source.Dockerfile deleted file mode 100644 index 13bc790..0000000 --- a/images/etcd/source.Dockerfile +++ /dev/null @@ -1,83 +0,0 @@ -# etcd — 업스트림 소스를 pinned commit 으로 직접 컴파일한다. -# -# 업스트림(https://github.com/etcd-io/etcd) 의 루트 Dockerfile 은 이미 빌드된 바이너리 -# (`etcd`/`etcdctl`/`etcdutl`)를 ADD 만 한다 — 컴파일 자체는 `scripts/build.sh` → -# `scripts/build_lib.sh` 의 `etcd_build()` 가 Dockerfile 밖에서 수행한다. 우리는 그 빌드 -# 단계를 Dockerfile 안으로 가져와 `go build` 로 직접 재현한다(cloudnative-pg 자체 빌드와 -# 동일한 패턴 — doc/decisions/0005 참고). -# -# 왜 자체 빌드인가 — quay.io/coreos/etcd:v3.7.1 이 게이트에서 차단하는 HIGH 1건 -# (CVE-2026-56852, golang.org/x/text)은 OS 패키지가 아니라 바이너리에 정적 링크된 Go -# 모듈 버전이 원인이다. etcd v3.7.1/release-3.7 브랜치는 go.mod 에 x/text v0.37.0(취약)을 -# 고정하고 있고 상위 패치 릴리스가 아직 없다(2026-07-31 확인) — 베이스 OS 교체로는 -# 고쳐지지 않으므로 자체 빌드가 유일한 대응이다. 근거: doc/decisions/0004-etcd-image-self-build.md -# -# 업스트림과의 대응 관계 (v3.7.1 태그, scripts/build_lib.sh 의 etcd_build() 기준) -# cd server && go build ... → 동일 (ldflags 는 GitSHA 대신 pinned commit 을 직접 주입 — 아래 참고) -# cd etcdutl && go build ... → 동일 -# cd etcdctl && go build ... → 동일 -# distroless/static-debian12 → SUSE BCI(bci-micro)로 교체. 카탈로그는 SUSE BCI 하나만 -# 쓴다(decisions/0001) — builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다 -# -# 업스트림과 다르게 하는 부분 — x/text 강제 업그레이드 -# etcd 는 Go 워크스페이스(go.work)로 서버/etcdctl/etcdutl 모듈을 함께 관리한다. -# 각 go.mod 를 개별로 고치는 대신 go.work 에 워크스페이스 전역 replace 한 줄만 추가해 -# golang.org/x/text 를 강제 업그레이드한다 — 업스트림과의 차이를 최소로 유지하기 위함 -# (.claude/image-authoring.md — 업스트림과의 차이를 최소로 유지한다). -# -# ldflags — GitSHA 대신 pinned commit 을 직접 주입하는 이유 -# 업스트림은 `git rev-parse --short HEAD` 로 얻은 값을 버전 문자열에 심는다. 우리는 -# BuildKit 의 git context(`ADD ...#${SOURCE_COMMIT}`)로 체크아웃하므로 커밋을 이미 알고 -# 있다 — 호스트의 verify.sh 가 굳이 컨테이너 안에서 git 을 실행하지 않고도 버전 문자열에 -# pinned commit 이 반영됐는지 확인할 수 있도록, 알고 있는 전체 커밋 해시를 그대로 심는다 -# (cloudnative-pg/source.Dockerfile 과 동일한 이유). - -# syntax=docker/dockerfile:1 - -ARG GO_BUILDER_TAG=1.26.5-trixie -# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — .claude/image-authoring.md -# 3번 항목, cloudnative-pg/source.Dockerfile 에서 실제로 겪은 버그. -ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없다. -FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder -ARG TARGETARCH -ARG SOURCE_COMMIT -ARG APP_VERSION -ARG XTEXT_FIX_VERSION -WORKDIR /src - -# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다. -ADD https://github.com/etcd-io/etcd.git#${SOURCE_COMMIT} /src - -# CVE-2026-56852 대응: golang.org/x/text 를 워크스페이스 전역으로 강제 업그레이드한다. -# `go work sync` 가 이 replace 를 각 모듈(server/etcdctl/etcdutl 등)의 go.sum 에 반영한다. -RUN echo "replace golang.org/x/text => golang.org/x/text v${XTEXT_FIX_VERSION}" >> go.work \ - && go work sync - -# 업스트림 scripts/build_lib.sh 의 etcd_build() 를 그대로 재현한다. -# (GOARCH 는 TARGETARCH 로 대체, GitSHA 대신 pinned commit 전체 해시를 심는다 — 위 설명 참고) -RUN --mount=type=cache,target=/root/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - set -eux; \ - mkdir -p /out; \ - LDFLAGS="-X=go.etcd.io/etcd/api/v3/version.GitSHA=${SOURCE_COMMIT}"; \ - ( cd server && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcd . ); \ - ( cd etcdutl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdutl . ); \ - ( cd etcdctl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdctl . ) - -FROM ${RUNTIME_BASE} AS final - -# 업스트림 루트 Dockerfile 과 동일한 경로·구조 — chart(groundhog2k/etcd) 의 startup/liveness/ -# readiness probe 가 `/usr/local/bin/etcdctl` 을 하드코딩해 호출하므로 경로를 바꾸면 안 된다. -COPY --from=builder /out/etcd /usr/local/bin/etcd -COPY --from=builder /out/etcdctl /usr/local/bin/etcdctl -COPY --from=builder /out/etcdutl /usr/local/bin/etcdutl -COPY --from=builder /src/LICENSE /licenses/LICENSE - -WORKDIR /var/etcd/ -WORKDIR /var/lib/etcd/ - -EXPOSE 2379 2380 - -CMD ["/usr/local/bin/etcd"] diff --git a/images/etcd/source.build.env b/images/etcd/source.build.env deleted file mode 100644 index 8e64621..0000000 --- a/images/etcd/source.build.env +++ /dev/null @@ -1,41 +0,0 @@ -# etcd — 소스 컴파일형 자체 빌드 (유일한 변종) -# -# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다. -# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다: -# IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh -# -# 왜 자체 빌드하는가·CVE 근거 → README.md -# 결정 → doc/decisions/0004-etcd-image-self-build.md - -DOCKERFILE=source.Dockerfile -TARGET=final -TAG_SLUG=security - -# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값. -# 업스트림 stock 이미지(quay.io/coreos/etcd:v3.7.1)와는 태그 슬러그(TAG_SLUG=security)로 -# 구분되므로 버전 문자열 자체는 그대로 둔다. -APP_VERSION=3.7.1 - -# v3.7.1 태그가 가리키는 실제 커밋(태그 객체가 아니라 peeled commit), 2026-07-31 확인: -# git ls-remote --tags https://github.com/etcd-io/etcd.git v3.7.1 v3.7.1^{} -SOURCE_COMMIT=5e7fd0de9a57db03ecc11794dc40403a734c07bb - -# go.mod 의 `go 1.26`/`toolchain go1.26.5` 요구를 만족하는 공식 golang 이미지 태그. -# 이 이미지는 builder 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다. -# -# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를 -# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다: -# python3 scripts/build/suggest-go-upgrades.py --reports --image etcd -GO_BUILDER_TAG=1.26.6-trixie - -# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). 정적 링크 -# 바이너리(CGO_ENABLED=0)라 패키지 매니저가 없는 가장 가벼운 bci-micro 로 충분하다. -RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7 - -# CVE-2026-56852 대응 — golang.org/x/text 를 이 버전 이상으로 강제 업그레이드한다. -# v3.7.1/release-3.7 브랜치는 아직 이 CVE 를 백포트하지 않았다(go.mod 실측 x/text -# v0.37.0). etcd 메인테이너가 release-3.7 브랜치에 패치를 백포트하면(그리고 v3.7.2 가 -# 나오면) 이 자체 빌드는 걷어내고 상위 태그로 돌아간다(대응 우선순위 a 가 c 보다 우선). -XTEXT_FIX_VERSION=0.39.0 - -BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE XTEXT_FIX_VERSION" diff --git a/images/etcd/verify.sh b/images/etcd/verify.sh deleted file mode 100755 index dae08f6..0000000 --- a/images/etcd/verify.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# etcd 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 -# `env TAG=... PLATFORM=... SOURCE_COMMIT=... XTEXT_FIX_VERSION=... bash verify.sh` -# 형태로 호출한다). 최종 베이스가 bci-micro 라 bash/coreutils 가 있으므로(패키지 -# 매니저만 없음 — .claude/image-authoring.md), cnpg-postgresql/verify.sh 와 같은 -# 게스트 셸 패턴을 쓴다(cloudnative-pg 처럼 셸 없는 distroless 가 아니다). -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다. -# -# 이 이미지에 요구하는 것 -# - /usr/local/bin/etcd·etcdctl·etcdutl 이 PATH 에 있어야 한다 (chart 의 startup/ -# liveness/readiness probe 가 /usr/local/bin/etcdctl 을 하드코딩해 호출한다) -# - 단일 노드로 기동해 put/get 왕복이 실제로 동작해야 한다 (게이트 0건이어도 못 쓰는 -# 이미지는 무의미하다 — .claude/image-authoring.md 5번) -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" -SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}" - -docker run --rm -i --platform "$PLATFORM" -e SOURCE_COMMIT="$SOURCE_COMMIT" --entrypoint sh "$TAG" <<'GUEST' -set -e - -echo "== 바이너리 탐색 (PATH 기준) ==" -for b in etcd etcdctl etcdutl; do - p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; } - echo " $b -> $p" -done - -echo "== 버전 (pinned commit 반영 확인) ==" -# sed 가 없다(bci-micro 는 bash·coreutils 는 있지만 sed 는 별도 패키지라 빠져 있다 — -# 2026-07-31 CI 에서 실측: "sh: sed: command not found"). 순수 셸 루프로 들여쓴다. -OUT="$(etcd --version)" -while IFS= read -r line; do echo " $line"; done </tmp/etcd-verify.log 2>&1 & -ETCD_PID=$! - -trap 'kill $ETCD_PID 2>/dev/null || true' EXIT - -echo "== health 대기 (최대 30s) ==" -ok=0 -for i in $(seq 1 30); do - if etcdctl endpoint health >/dev/null 2>&1; then ok=1; break; fi - sleep 1 -done -if [ "$ok" != "1" ]; then - echo "FAIL: etcd 가 30초 내에 healthy 상태가 되지 못했다" - cat /tmp/etcd-verify.log - exit 1 -fi -HEALTH="$(etcdctl endpoint health)" -while IFS= read -r line; do echo " $line"; done </dev/null -answer="$(etcdctl get smoke-test --print-value-only)" -[ "$answer" = "ok" ] || { echo "FAIL: get 응답 이상 (got: '$answer')"; exit 1; } -echo " get smoke-test => '$answer'" - -kill "$ETCD_PID" 2>/dev/null || true -trap - EXIT - -echo "VERIFY-OK" -GUEST diff --git a/images/keycloak/README.md b/images/keycloak/README.md deleted file mode 100644 index 827ec2f..0000000 --- a/images/keycloak/README.md +++ /dev/null @@ -1,203 +0,0 @@ -# keycloak — 자체 빌드 - -업스트림 Keycloak 배포본(tar.gz)을 SUSE BCI rootfs 위에 재패키징하고, 배포본에 정적으로 -들어 있는 취약 jar 를 수정 버전으로 교체한다. -`manifests/helm/keycloakx/7.2.2/custom-values.yaml` 의 `image.repository`/`image.tag` 가 -이 산출물을 가리킨다. - -신규 자체 빌드 이미지 추가 절차 전반은 -[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고. - -> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를 -> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을 -> 잃고 재빌드 책임을 지는 선택이다. - -## 왜 자체 빌드하나 - -업스트림 `quay.io/keycloak/keycloak:26.6.4` 는 게이트에서 **차단 17건(실효 HIGH 17, -CRITICAL 0)** 이다(PR #18 의 `helm-catalog-sbom` run 31085455385 실측). 계층별로 성격이 -전혀 다르다. - -| 계층 | 건수 | 대표 CVE | 상위 태그·베이스 OS 교체로 풀리나 | -| --- | --- | --- | --- | -| UBI9 rpm | 5 | `java-21-openjdk-headless` ×3, `libacl`, `pcre2` | 일부. 3건은 rpm 최신화로 해소 | -| 번들 jar | 12 | netty ×6, jackson ×3, postgresql-jdbc, mssql-jdbc, keycloak-services | **아니다** | - -### 상위 태그 교체를 먼저 검토한 결과 (image-authoring.md 체크리스트 1) - -**jar 12건은 keycloak 버전을 올려도 풀리지 않는다.** keycloak `26.6.4` 와 최신 -`26.7.1` 의 `quarkus.version` 이 둘 다 `3.33.2.1` 이고, Quarkus 3.33.2.1 BOM 이 -`netty 4.1.135.Final` / `jackson-bom 2.21.2` 를 고정한다(실측: keycloak `pom.xml` 두 -태그 비교 + `quarkusio/quarkus` `bom/application/pom.xml@3.33.2.1`). 차단 CVE 의 수정 -버전은 netty `4.1.136.Final`, jackson `2.21.4` 다 — 다음 Quarkus BOM 이 올라오기 전까지 -업스트림 이미지로는 방법이 없다. - -**베이스 OS 교체도 jar 에는 통하지 않는다.** CVE 가 OS 패키지가 아니라 배포본에 함께 -실려 있는 jar 자체이기 때문이다 — `cloudnative-pg`/`etcd` 의 정적 링크 Go 모듈과 같은 -구조다. - -그래서 **jar 를 직접 교체하는 자체 빌드가 유일한 수단**이다. `etcd` 이미지가 -`go.work` 에 `replace golang.org/x/text` 한 줄을 넣어 해결한 것과 같은 성격의 의존성 -override 이며, 오케스트레이션은 동일한 -[scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh) -하나를 공유한다. - -### 그래도 버전은 26.7.1 로 올린다 - -`26.6.4` 는 `26.7.1`(및 `26.6.5`)에서만 패치된 `keycloak-services` HIGH 5건에 -취약하다 — CVE-2026-16102 / 16442 / 16443 / 15572 / 15573 (+ MEDIUM 2건, GitHub -Security Advisory 실측). 스캔 시점 trivy DB 에 아직 없어 게이트에 잡히지 않았을 뿐이다. - -차트(`keycloakx`)는 **7.2.2 가 최신이고 그대로 둔다.** 차트의 `appVersion: 26.6.4` 는 -`image.tag` 미지정 시의 기본값일 뿐이고(`templates/statefulset.yaml` -— `.Values.image.tag | default .Chart.AppVersion`), 우리는 `custom-values.yaml` 에서 -태그를 명시한다. appVersion 이 26.7.1 보다 낮은 것은 codecentric 차트의 릴리스 캐던스 -지연이지 차트 결함이 아니다. - -## 유형과 베이스 OS - -**유형: "업스트림 산출물을 다른 배포판에 재설치"** (image-authoring.md 두 유형 중 첫 -번째). Keycloak 을 소스에서 Maven 빌드하지 않는다 — 업스트림이 릴리스한 tar.gz 를 -그대로 쓰고 런타임 rootfs 만 SUSE 로 바꾼다. - -| 항목 | 값 | -| --- | --- | -| 배포본 | `github.com/keycloak/keycloak/releases/download/$KEYCLOAK_VERSION/keycloak-$KEYCLOAK_VERSION.tar.gz` | -| 빌더 | `registry.suse.com/bci/bci-base:15.7` (zypper 필요) | -| 최종 rootfs 씨앗 | `registry.suse.com/bci/bci-micro:15.7` | -| 최종 스테이지 | `FROM scratch` + 위 rootfs | - -### 베이스 OS 결정 배경 (image-authoring.md 원칙 2) - -업스트림은 `ubi9` 빌더 + `ubi9-micro` 최종이다. 카탈로그의 먼저 들어온 자체 빌드 -이미지들이 전부 SUSE BCI 이고 trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 -있어(게이트의 `CoverageProbe` — [doc/sbom-pipeline.md](../../doc/sbom-pipeline.md)), **"업스트림과 최대한 동일하게" 보다 -카탈로그 내 일관성을 우선**했다. - -**BCI 16.0 이 나와 있지만 15.7 을 쓴다 — 최신이 이 이미지에는 더 낡았다**(2026-08-07 -실측). 필요한 나머지 패키지는 16.0 에도 전부 있지만 JDK 가 뒤처져 있다. - -| BCI | `java-21-openjdk-headless` | -| --- | --- | -| 15.7 | `21.0.12.0-150600.3.29.1` | -| 16.0 | `21.0.11.0-160000.2.1` | - -`21.0.12` 가 CVE-2026-41254·CVE-2026-47063 의 수정 버전이라, 16.0 으로 갔으면 이 -이미지가 없애려는 차단 CVE 2건이 그대로 남았다. **16.0 의 JDK 가 15.7 을 따라잡으면 -그때 올린다** — 재측정 방법은 `suse.build.env` 주석에 있다. - -### rootfs 를 왜 "씨앗" 방식으로 만드나 - -업스트림 `ubi-null.sh` 는 빈 installroot 에 패키지를 깔고 그 rootfs 를 `ubi9-micro` -**위에 덮는다**. 이 구조를 SUSE 에 그대로 옮기면 `bci-micro` 의 rpmdb 가 새 rpmdb 로 -가려져 **micro 자체 패키지가 SBOM 에서 통째로 사라진다** — CVE 가 줄어드는 게 아니라 -스캔 사각지대가 생기는 것이고, 게이트가 경고하는 "베이스 OS 교체로 수치만 낮아진 것"의 -전형이다. - -그래서 `bci-micro` 파일시스템을 씨앗으로 깐 뒤 그 위에 `zypper --installroot` 로 -설치한다. rpmdb 가 micro 것 위에 이어 써지므로 최종 이미지의 모든 OS 패키지가 SBOM 에 -잡힌다. 빌드 후 이 점을 반드시 확인한다(아래 "빌드" 절). - -## 업스트림과 다른 부분 - -업스트림 [`quarkus/container/Dockerfile`](https://github.com/keycloak/keycloak/blob/main/quarkus/container/Dockerfile) -대비 차이는 셋뿐이다. - -1. **런타임 rootfs 가 SUSE BCI** — 위 참조. 패키지 목록(`RUNTIME_PACKAGES`)은 업스트림 - 이미지 SBOM 의 rpm 44종을 근거로 정했다. `sed`·`grep` 은 **없으면 안 된다** — - `bin/kc.sh` 가 `/bin/sh` 스크립트로 `esceval()` 에서 `sed`, 인자 파싱에서 `grep` 을 - 쓰는데 `bci-micro` 에는 `sed` 가 없다. -2. **취약 jar 오버레이** (`overlay-jars.sh`) — `lib/lib/main/` 의 jar 를 **파일명은 - 유지하고 내용만** 수정 버전으로 바꾼다. Quarkus fast-jar 의 클래스패스가 파일명을 - 그대로 참조하기 때문이다. 대상은 `suse.build.env` 의 `*_OLD`/`*_VERSION` 이 정의한다: - - | 그룹 | 대상 | 버전 | - | --- | --- | --- | - | `io.netty` | 패밀리 전체 17종 | `4.1.135.Final` → `4.1.136.Final` | - | `com.fasterxml.jackson.core` | `jackson-core`, `jackson-databind` | `2.21.2` → `2.21.4` | - | `org.postgresql` | `postgresql` | `42.7.11` → `42.7.12` | - | `io.micrometer` | 패밀리 전체 4종 | `1.16.3` → `1.16.6` | - - netty·micrometer 는 CVE 가 붙은 아티팩트만이 아니라 **패밀리 전체**를 함께 올린다 — - 둘 다 아티팩트 간 버전 혼용이 비지원이다. jackson 은 2.21.x 안에서 호환이 보장되고 - `jackson-annotations` 는 `2.21` 로 별도 버저닝돼 있어 CVE 있는 둘만 바꾼다. - - 위장이 아니다 — trivy 는 jar 내부 `META-INF/maven/**/pom.properties`(없으면 sha1↔GAV - 인덱스)로 버전을 판정하므로 SBOM 에는 새 버전이 정확히 잡히고, 실제 내용도 새 버전이다. -3. **`bin/client/` 제거** — `keycloak-admin-cli-.jar` 는 jackson 을 shade 로 품은 - uber-jar 라 jar 교체로 고칠 수 없다(SBOM 실측: `jackson-databind@2.21.2` 의 - FilePath 가 이 파일). 서버 JVM 이 로드하지 않는 독립 CLI(`kcadm.sh`/`kcreg.sh`)이므로 - 하드닝 이미지에서는 제거했다. **업스트림 대비 유일한 기능적 차이다** — 운영에서 - `kcadm` 이 필요하면 업스트림 이미지를 별도 컨테이너로 쓴다. - -`kc.sh build` 를 최종 스테이지에서 한 번 돌리는 것은 차이가 아니다 — 옵션 없는 build 는 -릴리스 tar 의 사전 augmentation 상태를 그대로 재현하며, 오버레이한 jar 로 augmentation 이 -실제로 통과하는지 빌드 시점에 확인하기 위한 것이다(최적화 이미지로 만드는 것이 아니다). - -## 버전 관리 - -`APP_VERSION`/`KEYCLOAK_VERSION` 과 `*_OLD`/`*_VERSION` jar 버전은 **자동 추적하지 -않는다.** 사람이 업스트림 릴리스를 보고 `suse.build.env` 를 고쳐 PR 을 여는 것 자체가 -갱신 트리거다. - -`overlay-jars.sh` 는 `*_OLD` 버전 jar 를 하나도 못 찾으면 **빌드를 실패시킨다.** -업스트림이 의존성을 올렸는데 스크립트가 조용히 아무것도 안 해서 "CVE 는 그대로인데 -빌드는 성공" 하는 상태를 막기 위함이다. - -**권장 점검 주기**: 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 Keycloak 이 -Quarkus BOM 을 올린 릴리스를 낼 때 — **BOM 이 올라가 오버레이가 불필요해지면 해당 -spec 을 제거하는 것이 이 자체 빌드를 유지하는 것보다 항상 우선이다.** - -## 빌드 - -```sh -# 로컬 빌드 (push 없음) -IMAGE=keycloak BASE_OS=suse bash scripts/build/build-hardened-image.sh /tmp/kc-out - -# 레지스트리 push 까지 -IMAGE=keycloak BASE_OS=suse REGISTRY=docker.io/paasup \ - bash scripts/build/build-hardened-image.sh /tmp/kc-out -``` - -> **arm64 호스트(Apple Silicon)에서 돌릴 때**: `linux/amd64` 를 QEMU 로 에뮬레이션하므로 -> `verify.sh` 의 실기동이 매우 느리다 — Quarkus augmentation 만 ~100초, 기동 전체가 -> 300초를 넘는다(2026-08-07 실측). `verify.sh` 의 기본 대기 시간은 600초이고 -> `VERIFY_BOOT_TIMEOUT` 으로 조정한다. 네이티브 amd64 러너에서는 1~2분이면 끝난다. - -수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.** - -빌드 후 확인할 것: - -1. `verify.log` 마지막 줄이 `VERIFY-OK` -2. `cve-gate.md` 의 차단 항목 — `doc/cve-exceptions.json` 에 등록된 것 외에 없는지 -3. **커버리지 자가진단이 `ok`** 인지(`none` 이면 findings 0 이 진짜 0 이 아니다 — - `doc/sbom-pipeline.md`) -4. **rpmdb 마스킹이 없는지** — SBOM 의 OS 패키지 수가 `bci-micro` 단독 + 설치분에 - 해당하는지. 업스트림 UBI9 이미지의 OS 패키지는 44종이었다. 한 자릿수로 떨어졌다면 - 씨앗 방식이 동작하지 않은 것이므로 설계를 재검토한다. - -`verify.sh` 가 확인하는 것: kc.sh 가 쓰는 셸 도구·java 21·`en_US.UTF-8` 로케일· -`Asia/Seoul` 타임존, `bin/client` 제거, **오버레이한 jar 의 내부 -`pom.properties` 버전**(파일명이 아니라 내용 — trivy 와 같은 근거), 그리고 실제 -`start-dev` 기동 후 OIDC discovery 응답·admin 토큰 발급·`GET /admin/realms` 까지. -외부 postgres·ingress·클러스터링은 이 스모크 테스트 범위 밖이며 dev 클러스터 배포 -테스트(`.claude/deploy-test-procedure.md`)가 담당한다. - -### 파일 구성 - -| 파일 | 역할 | -| --- | --- | -| `suse.Dockerfile` | 빌드 정의 — bci-base 빌더(rootfs 구성 + 배포본 전개 + jar 오버레이) + `FROM scratch` 최종 | -| `suse.build.env` | keycloak 버전·베이스 이미지·런타임 패키지·jar 오버레이 버전. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 | -| `overlay-jars.sh` | 취약 jar 교체 + `bin/client` 제거. builder 스테이지 전용(호스트에서 직접 쓰지 않는다) | -| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 게스트 셸 주입 + 호스트 python3 jar 검사 + 실기동을 조합한다 | -| `catalog.env` | 이 이미지가 갱신하는 차트 디렉토리와 태그 표기 스타일 (`build-image.yml` 이 읽는다) | - -베이스 변종이 하나뿐이라 파일명이 `suse.*` 로 고정돼 있다. - -### 태그 - -``` -docker.io/paasup/keycloak:26.7.1-bci15.7-hardened-20260807 - └ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘ -``` diff --git a/images/keycloak/catalog.env b/images/keycloak/catalog.env deleted file mode 100644 index 348d9ab..0000000 --- a/images/keycloak/catalog.env +++ /dev/null @@ -1,10 +0,0 @@ -# 이 이미지가 어느 차트의 어느 필드를 가리키는가 (build-image.yml 이 읽는다). -# -# keycloakx 차트는 registry 필드가 없고 repository 하나에 전체 경로를 쓴다 -# (templates/statefulset.yaml: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"). -# patch-catalog-tag.py 의 has_registry_field == False 분기가 repository 를 -# docker.io/paasup/keycloak 전체 경로로 치환한다 — cloudnative-pg 와 같은 형태. -CHART_DIRS="manifests/helm/keycloakx/7.2.2" -TAG_STYLE=split -TAG_BLOCK=image -DEFAULT_BASE_OS=suse diff --git a/images/keycloak/overlay-jars.sh b/images/keycloak/overlay-jars.sh deleted file mode 100755 index 953e096..0000000 --- a/images/keycloak/overlay-jars.sh +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env bash -# Keycloak 배포본에 정적으로 들어 있는 취약 jar 를 수정 버전으로 교체한다. -# suse.Dockerfile 의 builder 스테이지에서만 실행된다(호스트에서 직접 쓰지 않는다). -# -# 왜 필요한가 -# 차단 CVE 17건 중 12건이 /opt/keycloak/lib/lib/main/ 의 jar 다. 이 버전들은 -# Quarkus 3.33.2.1 BOM 이 고정하고 있어 keycloak 상위 태그로도, 베이스 OS 교체로도 -# 바뀌지 않는다. etcd 이미지의 `go.work replace` 와 같은 성격의 의존성 override 다. -# -# 왜 파일명을 유지하는가 -# Quarkus fast-jar 배포본은 lib/quarkus-run.jar 의 Class-Path 로 jar 파일명을 그대로 -# 참조한다. 이름을 바꾸면 클래스패스가 깨진다. 이름을 유지하고 내용만 바꾸면 -# - 런타임: 클래스패스 그대로 동작 -# - SBOM: trivy 는 jar 내부 META-INF/maven/**/pom.properties 를 읽으므로 새 버전이 -# 정확히 잡힌다 (파일명으로 버전을 위장하는 것이 아니다 — 실제 내용이 새 버전이다) -# -# 무결성 -# Maven Central 의 .sha1 을 함께 받아 검증한다. 실패하면 즉시 종료한다. -# -# 실패 조건 -# OLD 버전 파일을 하나도 못 찾은 spec 이 있으면 실패한다. 업스트림이 의존성 버전을 -# 올렸는데 이 스크립트가 조용히 아무것도 안 하는 상태(= CVE 는 그대로인데 빌드는 성공) -# 를 막기 위함이다. patch-catalog-tag.py 가 패턴 불일치 시 크게 실패하는 것과 같은 원칙. -set -euo pipefail - -KC_HOME="${1:?사용법: overlay-jars.sh }" -LIB="$KC_HOME/lib/lib/main" -M2="${M2_BASE:-https://repo1.maven.org/maven2}" - -[ -d "$LIB" ] || { echo "FAIL: $LIB 가 없다 — 배포본 레이아웃이 바뀌었는지 확인"; exit 1; } - -: "${NETTY_OLD:?}" "${NETTY_VERSION:?}" -: "${JACKSON_OLD:?}" "${JACKSON_VERSION:?}" -: "${PGJDBC_OLD:?}" "${PGJDBC_VERSION:?}" -: "${MICROMETER_OLD:?}" "${MICROMETER_VERSION:?}" - -# groupId(파일명 프리픽스) | groupPath(Maven 경로) | artifact 필터 | OLD | NEW -# artifact 필터가 '*' 이면 해당 groupId + OLD 버전의 모든 jar 가 대상이다. -SPECS=( - "io.netty|io/netty|*|${NETTY_OLD}|${NETTY_VERSION}" - "com.fasterxml.jackson.core|com/fasterxml/jackson/core|jackson-core|${JACKSON_OLD}|${JACKSON_VERSION}" - "com.fasterxml.jackson.core|com/fasterxml/jackson/core|jackson-databind|${JACKSON_OLD}|${JACKSON_VERSION}" - "org.postgresql|org/postgresql|postgresql|${PGJDBC_OLD}|${PGJDBC_VERSION}" - "io.micrometer|io/micrometer|*|${MICROMETER_OLD}|${MICROMETER_VERSION}" -) - -fetch() { # $1=url $2=출력경로 - local url="$1" out="$2" want got - curl -fsSL --retry 3 --retry-delay 2 -o "$out" "$url" - want="$(curl -fsSL --retry 3 --retry-delay 2 "$url.sha1" | tr -d '[:space:]')" - got="$(sha1sum "$out" | cut -d' ' -f1)" - [ "$want" = "$got" ] || { echo "FAIL: sha1 불일치 $url (기대 $want / 실제 $got)"; exit 1; } -} - -total=0 -for spec in "${SPECS[@]}"; do - IFS='|' read -r gid gpath filter old new <<<"$spec" - - matched=0 - for f in "$LIB/$gid."*"-$old"*.jar; do - [ -e "$f" ] || continue - base="$(basename "$f")" - - rest="${base#"$gid."}" # netty-codec-http-4.1.135.Final.jar - rest="${rest%.jar}" # netty-codec-http-4.1.135.Final - artifact="${rest%%-"$old"*}" # netty-codec-http - tail="${rest#*-"$old"}" # '' 또는 -linux-x86_64 (classifier) - - if [ "$filter" != "*" ] && [ "$artifact" != "$filter" ]; then continue; fi - - url="$M2/$gpath/$artifact/$new/$artifact-$new$tail.jar" - fetch "$url" "$f.new" - mv "$f.new" "$f" # 파일명 유지 — 내용만 교체 - echo "overlay: $base <= $artifact-$new$tail.jar" - matched=$((matched + 1)) - done - - if [ "$matched" -eq 0 ]; then - echo "FAIL: spec '$gid/$filter@$old' 에 해당하는 jar 가 없다." - echo " 업스트림이 이미 버전을 올렸을 수 있다 — build.env 의 *_OLD 를 재확인하고," - echo " 해소됐다면 해당 spec 을 제거한다(그냥 두면 CVE 가 남은 채 빌드가 성공한다)." - exit 1 - fi - total=$((total + matched)) -done - -echo "overlay: 총 ${total}개 jar 교체 완료" - -# keycloak-admin-cli 는 jackson 을 shade 로 품은 uber-jar 라 jar 교체로 못 고친다 -# (SBOM 실측: jackson-databind@2.21.2 의 FilePath 가 bin/client/keycloak-admin-cli-*.jar). -# 서버 JVM 이 로드하지 않는 독립 CLI(kcadm.sh/kcreg.sh)이므로 하드닝 이미지에서는 제거한다. -# 업스트림 대비 유일한 기능적 차이다 — README.md / CUSTOM-README.md 에 명시. -if [ -d "$KC_HOME/bin/client" ]; then - rm -rf "$KC_HOME/bin/client" - echo "removed: bin/client (kcadm/kcreg — jackson shaded uber-jar, 서버 런타임 미사용)" -else - echo "FAIL: bin/client 가 없다 — 배포본 레이아웃이 바뀌었다. 제거 전제를 재확인할 것" - exit 1 -fi diff --git a/images/keycloak/suse.Dockerfile b/images/keycloak/suse.Dockerfile deleted file mode 100644 index 3ae8ad8..0000000 --- a/images/keycloak/suse.Dockerfile +++ /dev/null @@ -1,112 +0,0 @@ -# Keycloak — SUSE BCI 기반 자체 빌드 (+ 취약 jar 오버레이) -# -# 업스트림: https://github.com/keycloak/keycloak/blob/main/quarkus/container/Dockerfile -# -# 업스트림과의 대응 관계 -# registry.access.redhat.com/ubi9 (빌더) → registry.suse.com/bci/bci-base:15.7 -# ubi-null.sh (rootfs 구성 후 불필요 rpm erase) → bci-micro 파일시스템 씨앗 + zypper --installroot -# registry.access.redhat.com/ubi9-micro (최종) → FROM scratch + 위 rootfs -# ADD $KEYCLOAK_DIST → tar → /opt/keycloak → 동일 -# keycloak:x:0:root / uid 1000 / ENTRYPOINT → 동일 -# (없음) → 취약 jar 오버레이 + kc.sh build 재augmentation -# -# 왜 자체 빌드인가 (상위 태그·베이스 OS 교체로 안 풀리는 근거) -# 차단 17건 중 12건이 배포본에 정적으로 들어 있는 jar 다. keycloak 26.6.4 와 26.7.1 의 -# quarkus.version 이 둘 다 3.33.2.1 이고 그 BOM 이 netty 4.1.135.Final / jackson-bom -# 2.21.2 를 고정한다 — 상위 태그로 올려도 그대로다. 베이스 OS 교체도 jar 에는 통하지 -# 않는다. 그래서 jar 를 직접 교체하는 자체 빌드가 유일한 수단이다. -# (etcd 이미지의 `go.work replace golang.org/x/text` 와 같은 성격의 의존성 override) -# -# 왜 SUSE BCI 인가 -# 기존 자체 빌드 3종(cloudnative-pg·cnpg-postgresql·etcd)이 전부 SUSE BCI 이고, -# trivy 의 SLES 15.7 커버리지는 양성 대조로 실측 확인돼 있다 -# (게이트의 CoverageProbe — doc/sbom-pipeline.md). "업스트림과 최대한 동일하게" 원칙과 -# 충돌하지만 카탈로그 내 일관성을 우선했다 — 근거는 README.md. -# -# ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언한다. 스테이지 내부에 두면 지역 변수가 -# 되어 이후 FROM 의 이미지명 해석에 쓰이지 않는다 (.claude/image-authoring.md 원칙 3). -ARG BUILDER_BASE=registry.suse.com/bci/bci-base:15.7 -ARG MICRO_BASE=registry.suse.com/bci/bci-micro:15.7 - -# 최종 런타임 rootfs 의 씨앗. 여기서 직접 빌드하지 않고 파일시스템만 가져다 쓴다. -FROM ${MICRO_BASE} AS micro - - -FROM ${BUILDER_BASE} AS builder - -ARG KEYCLOAK_VERSION -ARG RUNTIME_PACKAGES -ARG NETTY_OLD -ARG NETTY_VERSION -ARG JACKSON_OLD -ARG JACKSON_VERSION -ARG PGJDBC_OLD -ARG PGJDBC_VERSION -ARG MICROMETER_OLD -ARG MICROMETER_VERSION - -# (a) 런타임 rootfs 구성. -# -# bci-micro 의 파일시스템을 씨앗으로 깔고 그 위에 zypper --installroot 로 설치한다. -# 별도 installroot 를 만들어 micro 위에 COPY 로 덮는 방식(업스트림 ubi-null.sh 가 -# ubi9-micro 에 하는 것)을 쓰지 않는 이유: 그러면 micro 의 rpmdb 가 새 rpmdb 로 가려져 -# micro 자체 패키지가 SBOM 에서 사라진다 — CVE 가 줄어드는 게 아니라 스캔 사각지대가 -# 생기는 것이다. 씨앗 방식은 rpmdb 가 micro 것 위에 이어 써져 전부 보인다. -# -# rpm --import 를 먼저 한다. 안 하면 installroot 의 rpmdb 에 SUSE 키가 없어 설치되는 -# 패키지마다 "Header V3 RSA/SHA256 Signature, key ID ...: NOKEY" 로 개별 서명 검증이 -# 생략된다(저장소 메타데이터 서명은 zypper 가 확인하지만 패키지 단위 검증은 별개다). -COPY --from=micro / /rootfs -RUN set -eux; \ - rpm --root /rootfs --import /usr/lib/rpm/gnupg/keys/*.asc; \ - zypper --non-interactive --installroot /rootfs --gpg-auto-import-keys refresh; \ - zypper --non-interactive --installroot /rootfs install -y --no-recommends \ - ${RUNTIME_PACKAGES}; \ - zypper --non-interactive --installroot /rootfs clean --all; \ - rm -rf /rootfs/var/log/zypp /rootfs/var/cache/zypp /rootfs/var/cache/zypper - -# (b) 업스트림 배포본 전개 — 업스트림의 ADD $KEYCLOAK_DIST + tar 단계와 동일하다. -ADD https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz /tmp/keycloak/ -RUN set -eux; \ - cd /tmp/keycloak; \ - tar -xf keycloak-*.tar.gz; \ - rm keycloak-*.tar.gz; \ - mv keycloak-* /opt/keycloak; \ - mkdir -p /opt/keycloak/data; \ - chmod -R g+rwX /opt/keycloak - -# (c) 취약 jar 오버레이. 파일명은 그대로 두고 내용만 수정 버전으로 바꾼다 — 상세는 -# overlay-jars.sh 주석. 대상 파일을 하나도 못 찾으면 스크립트가 실패한다. -COPY overlay-jars.sh /tmp/ -RUN bash /tmp/overlay-jars.sh /opt/keycloak - - -FROM scratch AS final - -COPY --from=builder /rootfs/ / -COPY --from=builder --chown=1000:0 /opt/keycloak /opt/keycloak - -ENV LANG=en_US.UTF-8 -# 업스트림과 동일 — 컨테이너 실행 여부 판별 플래그 -ENV KC_RUN_IN_CONTAINER=true - -RUN echo "keycloak:x:0:root" >> /etc/group && \ - echo "keycloak:x:1000:0:keycloak user:/opt/keycloak:/sbin/nologin" >> /etc/passwd - -# SUSE 는 java 를 update-alternatives 심볼릭 링크로 노출한다. chroot 설치라 이 링크가 -# 안 만들어질 수 있어 빌드 시점에 단정한다 — 런타임에 "java: not found" 로 죽는 것보다 -# 여기서 깨지는 게 낫다. -RUN java -version 2>&1 | head -1 - -# 오버레이한 jar 로 augmentation 이 실제로 통과하는지 빌드 시점에 확인한다. -# 옵션 없는 build 는 릴리스 tar 의 사전 augmentation 상태를 그대로 재현하므로 -# 런타임 동작은 업스트림 이미지와 같다(최적화 이미지로 만드는 것이 아니다). -RUN /opt/keycloak/bin/kc.sh build && chown -R 1000:0 /opt/keycloak - -USER 1000 - -EXPOSE 8080 -EXPOSE 8443 -EXPOSE 9000 - -ENTRYPOINT [ "/opt/keycloak/bin/kc.sh" ] diff --git a/images/keycloak/suse.build.env b/images/keycloak/suse.build.env deleted file mode 100644 index 1ad81b5..0000000 --- a/images/keycloak/suse.build.env +++ /dev/null @@ -1,64 +0,0 @@ -# Keycloak 자체 빌드 — SUSE BCI 기반, 취약 jar 오버레이 포함 -# -# APP_VERSION 이 곧 Keycloak 릴리스 버전이다. 태그는 -# docker.io/paasup/keycloak:--hardened- -# 로 만들어진다 (build-hardened-image.sh). -DOCKERFILE=suse.Dockerfile -TARGET=final -TAG_SLUG=bci15.7 -APP_VERSION=26.7.1 -KEYCLOAK_VERSION=26.7.1 - -# BCI 는 16.0 이 나와 있지만 15.7 을 쓴다 — 최신이 더 낡았다(2026-08-07 실측). -# 15.7 SLE_BCI: java-21-openjdk-headless 21.0.12.0-150600.3.29.1 -# 16.0 SLE_BCI: java-21-openjdk-headless 21.0.11.0-160000.2.1 -# 21.0.12 가 CVE-2026-41254·CVE-2026-47063 의 수정 버전이라, 16.0 으로 가면 이 이미지의 -# 차단 CVE 2건이 오히려 남는다. 필요한 나머지 패키지는 16.0 에도 전부 있으므로 -# (glibc-locale-base·ca-certificates-mozilla·sed·grep·findutils·timezone 확인) -# **16.0 의 java 가 15.7 을 따라잡으면 그때 올린다.** 재검토 시 위 두 값을 다시 잰다: -# docker run --rm registry.suse.com/bci/bci-base:<태그> \ -# sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info java-21-openjdk-headless' -BUILDER_BASE=registry.suse.com/bci/bci-base:15.7 -MICRO_BASE=registry.suse.com/bci/bci-micro:15.7 - -# 업스트림 ubi-null.sh 가 ubi9-micro 위에 남기는 rpm 클로저(44종)의 SUSE 대응물이다. -# 근거: quay.io/keycloak/keycloak:26.6.4 SBOM 실측 — bash, coreutils-single, sed, grep, -# findutils, glibc-langpack-en, ca-certificates, tzdata, tzdata-java, java-21-openjdk-headless -# 가 들어 있고 나머지는 이들의 의존성이다(zypper 가 알아서 끌어온다). -# -# sed·grep·findutils 는 없으면 안 된다 — bin/kc.sh 가 /bin/sh 스크립트로 esceval() 에서 -# sed, 인자 파싱에서 grep 을 쓴다. bci-micro 에는 sed·grep·find 가 **셋 다** 없다 -# (2026-08-07 실측 — .claude/image-authoring.md 는 sed 만 기록하고 있었다). -# -# 패키지명 주의 (2026-08-07 SLE_BCI 15.7 실측) -# tzdata → SLE 에는 없다. 이름이 `timezone` 이다. -# tzdata-java → SLE 에는 대응 패키지가 없다. Java 는 JDK 내장 tzdb 를 쓴다. -# bash·coreutils·ca-certificates-mozilla 는 bci-micro 씨앗에 이미 있다(zypper 가 -# "already installed" 로 넘어간다). 무엇이 필요한지 명시하려고 목록에 남겨 둔다. -RUNTIME_PACKAGES="java-21-openjdk-headless glibc-locale-base ca-certificates-mozilla bash coreutils sed grep findutils timezone" - -# 취약 jar 오버레이 대상. 는 keycloak $KEYCLOAK_VERSION 배포본이 실제로 담고 있는 -# 버전이고(Quarkus 3.33.2.1 BOM 이 고정), 는 차단 CVE 의 수정 버전이다. -# overlay-jars.sh 가 OLD 로 파일을 찾지 못하면 즉시 실패한다 — 업스트림이 버전을 올리면 -# 여기가 조용히 무의미해지는 것을 막기 위함이다. -# -# netty: CVE-2026-55831/55833/56745/56819/59901/55851 (netty-codec-*) -# CVE 가 붙은 것만이 아니라 패밀리 전체를 함께 올린다 — netty 는 아티팩트 간 -# 버전 혼용이 비지원이다. -NETTY_OLD=4.1.135.Final -NETTY_VERSION=4.1.136.Final -# jackson: CVE-2026-54512/54513 (databind), GHSA-r7wm-3cxj-wff9 (core) -# jackson-annotations 는 2.21 로 별도 버저닝이고 CVE 가 없어 건드리지 않는다. -JACKSON_OLD=2.21.2 -JACKSON_VERSION=2.21.4 -# postgresql jdbc: CVE-2026-54291 -PGJDBC_OLD=42.7.11 -PGJDBC_VERSION=42.7.12 -# micrometer: CVE-2026-40983, CVE-2026-40984 (micrometer-core) -# netty 와 같은 이유로 패밀리 전체(commons·core·observation·registry-prometheus- -# simpleclient)를 함께 올린다. 이 2건은 2026-08-07 로컬 스캔에는 없었고 같은 날 -# CI(더 최신 trivy DB)에서 처음 잡혔다 — 로컬 DB 가 뒤처질 수 있다는 실례다. -MICROMETER_OLD=1.16.3 -MICROMETER_VERSION=1.16.6 - -BUILD_ARGS="KEYCLOAK_VERSION BUILDER_BASE MICRO_BASE RUNTIME_PACKAGES NETTY_OLD NETTY_VERSION JACKSON_OLD JACKSON_VERSION PGJDBC_OLD PGJDBC_VERSION MICROMETER_OLD MICROMETER_VERSION" diff --git a/images/keycloak/verify.sh b/images/keycloak/verify.sh deleted file mode 100755 index c6bdd8e..0000000 --- a/images/keycloak/verify.sh +++ /dev/null @@ -1,201 +0,0 @@ -#!/usr/bin/env bash -# keycloak 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가 -# `env TAG=... PLATFORM=... bash verify.sh` 로 호출한다). -# -# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. -# -# 이 이미지에 요구하는 것 -# 1. kc.sh 가 의존하는 셸 도구(sed·grep·readlink·dirname·uname)와 java 21 -# — bci-micro 에는 sed 가 없어 RUNTIME_PACKAGES 로 명시 설치한다 -# 2. 로케일(en_US.UTF-8)·타임존(custom-values.yaml 이 TZ=Asia/Seoul 을 넣는다) -# 3. 오버레이한 jar 가 **내용상** 새 버전일 것 -# — overlay-jars.sh 가 파일명을 유지하므로 파일명으로는 확인할 수 없다. -# trivy 가 보는 것과 같은 근거(jar 내부 META-INF/maven/**/pom.properties)로 확인한다. -# 4. bin/client 제거 확인 (overlay-jars.sh 가 지운다) -# 5. 실제 기동 — realm 이 만들어지고 admin 토큰이 발급될 것 -# (게이트 0건이어도 못 쓰는 이미지는 무의미하다 — .claude/image-authoring.md 5번) -set -e - -TAG="${TAG:?TAG 환경변수가 필요하다}" -PLATFORM="${PLATFORM:-linux/amd64}" -KEYCLOAK_VERSION="${KEYCLOAK_VERSION:?build.env 에서 전달돼야 한다}" -NETTY_OLD="${NETTY_OLD:?}" ; NETTY_VERSION="${NETTY_VERSION:?}" -JACKSON_OLD="${JACKSON_OLD:?}" ; JACKSON_VERSION="${JACKSON_VERSION:?}" -PGJDBC_OLD="${PGJDBC_OLD:?}" ; PGJDBC_VERSION="${PGJDBC_VERSION:?}" -MICROMETER_OLD="${MICROMETER_OLD:?}" ; MICROMETER_VERSION="${MICROMETER_VERSION:?}" - -WORK="$(mktemp -d)" -CID="" -CCID="" -cleanup() { - [ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true - [ -n "$CCID" ] && docker rm -f "$CCID" >/dev/null 2>&1 || true - rm -rf "$WORK" -} -trap cleanup EXIT - -LIBDIR=/opt/keycloak/lib/lib/main - -# ------------------------------------------------------------------ 1~2, 4단계 -docker run --rm -i --platform "$PLATFORM" -e TZ=Asia/Seoul \ - -e NETTY_OLD="$NETTY_OLD" --entrypoint sh "$TAG" <<'GUEST' -set -e -LIB=/opt/keycloak/lib/lib/main - -echo "== kc.sh 가 쓰는 셸 도구 ==" -for b in sh bash sed grep readlink dirname uname java; do - p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; } - echo " $b -> $p" -done - -echo "== java 21 ==" -VER="$(java -version 2>&1 | head -1)" -echo " $VER" -case "$VER" in - *'"21'*) ;; - *) echo "FAIL: java 21 이 아니다"; exit 1 ;; -esac - -echo "== 로케일 / 타임존 ==" -CHARMAP="$(locale charmap 2>/dev/null || echo '?')" -echo " LANG=$LANG charmap=$CHARMAP" -[ "$CHARMAP" = "UTF-8" ] || { echo "FAIL: en_US.UTF-8 로케일이 없다 (glibc-locale-base 확인)"; exit 1; } -TZNAME="$(date +%Z)" -echo " TZ=Asia/Seoul -> $TZNAME" -[ "$TZNAME" = "KST" ] || { echo "FAIL: tzdata 가 Asia/Seoul 을 모른다 (got: $TZNAME)"; exit 1; } - -echo "== bin/client 제거 확인 ==" -# jackson 을 shade 로 품은 uber-jar 라 jar 교체로 못 고쳐 통째로 제거했다. -if [ -e /opt/keycloak/bin/client ]; then - echo "FAIL: bin/client 가 남아 있다 — overlay-jars.sh 의 제거 단계가 동작하지 않았다"; exit 1 -fi -echo " 없음 (정상)" - -echo "== 배포본 레이아웃 ==" -[ -d "$LIB" ] || { echo "FAIL: $LIB 없음"; exit 1; } -n=0; for f in "$LIB"/io.netty.*-"$NETTY_OLD"*.jar; do [ -e "$f" ] && n=$((n+1)); done -echo " netty jar 파일 ${n}개 (파일명은 유지되는 것이 정상 — 내용 검증은 호스트에서)" -[ "$n" -gt 0 ] || { echo "FAIL: netty jar 가 없다"; exit 1; } -GUEST - -# ------------------------------------------------------------------ 3단계 -# 파일명을 유지하는 설계라 파일명으로는 교체 여부를 알 수 없다. jar 를 호스트로 꺼내 -# 내부 pom.properties 를 읽는다 — trivy 가 버전을 판정하는 것과 같은 근거다. -# (게스트에 unzip 을 넣지 않기 위해 호스트 python3 로 검사한다. build-hardened-image.sh -# 가 이미 python3 를 전제한다.) -echo "== 오버레이 jar 내용 검증 (jar 내부 메타데이터) ==" -CCID="$(docker create --platform "$PLATFORM" "$TAG")" - -check_jar() { # $1=컨테이너 내 파일명 $2=기대 버전 - local name="$1" want="$2" got - docker cp "$CCID:$LIBDIR/$name" "$WORK/$name" >/dev/null - got="$(python3 - "$WORK/$name" <<'PY' -import sys, zipfile, re -# 1순위: META-INF/maven/**/pom.properties (netty·jackson 등 대부분) -# 2순위: MANIFEST.MF 의 Bundle-Version / Implementation-Version -# (pgjdbc 는 pom.properties 를 넣지 않고 OSGi Bundle-Version 만 쓴다 — 실측) -with zipfile.ZipFile(sys.argv[1]) as z: - for n in z.namelist(): - if re.fullmatch(r'META-INF/maven/[^/]+/[^/]+/pom\.properties', n): - for line in z.read(n).decode().splitlines(): - if line.startswith('version='): - print(line.split('=', 1)[1].strip()); sys.exit(0) - try: - mf = z.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace') - except KeyError: - print('NOT-FOUND'); sys.exit(0) - # MANIFEST 는 72바이트에서 줄바꿈 후 다음 줄을 한 칸 들여쓰기로 이어붙인다 - mf = mf.replace('\r\n', '\n').replace('\n ', '') - for key in ('Bundle-Version', 'Implementation-Version'): - m = re.search(rf'^{key}:\s*(\S+)\s*$', mf, re.M) - if m: - print(m.group(1)); sys.exit(0) -print('NOT-FOUND') -PY -)" - if [ "$got" != "$want" ]; then - echo "FAIL: $name 의 내부 버전이 '$got' 다 (기대 '$want') — 오버레이가 적용되지 않았다" - exit 1 - fi - echo " $name -> 내장 버전=$got" -} - -check_jar "io.netty.netty-codec-http-${NETTY_OLD}.jar" "$NETTY_VERSION" -check_jar "io.netty.netty-codec-${NETTY_OLD}.jar" "$NETTY_VERSION" -check_jar "com.fasterxml.jackson.core.jackson-databind-${JACKSON_OLD}.jar" "$JACKSON_VERSION" -check_jar "com.fasterxml.jackson.core.jackson-core-${JACKSON_OLD}.jar" "$JACKSON_VERSION" -check_jar "org.postgresql.postgresql-${PGJDBC_OLD}.jar" "$PGJDBC_VERSION" -check_jar "io.micrometer.micrometer-core-${MICROMETER_OLD}.jar" "$MICROMETER_VERSION" - -docker rm -f "$CCID" >/dev/null 2>&1 || true -CCID="" - -# ------------------------------------------------------------------ 5단계 -echo "== 실기동 (start-dev, 내장 dev-file DB) ==" -ADMIN_USER="verify-admin" -ADMIN_PASS="verify-$$-$RANDOM" - -# 호스트 포트는 커널이 고르게 한다(CI 러너에서 고정 포트 충돌을 피한다). -CID="$(docker run -d --platform "$PLATFORM" \ - -p 127.0.0.1::8080 \ - -e KC_BOOTSTRAP_ADMIN_USERNAME="$ADMIN_USER" \ - -e KC_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASS" \ - -e TZ=Asia/Seoul \ - "$TAG" start-dev)" -HOSTPORT="$(docker port "$CID" 8080/tcp | head -1 | rev | cut -d: -f1 | rev)" -BASE="http://127.0.0.1:${HOSTPORT}" -echo " container=${CID:0:12} base=$BASE" - -# 기본값이 넉넉한 이유: arm64 호스트에서 linux/amd64 를 QEMU 로 돌리면 Quarkus -# augmentation 만 ~100초, 기동 전체가 ~300초를 넘는다(2026-08-07 실측 — 180초로는 -# liquibase 스키마 생성 중에 잘렸다). 네이티브 amd64 러너에서는 1~2분이면 끝나고, -# 루프는 뜨는 즉시 빠져나오므로 큰 값이 느려지는 비용은 없다. -BOOT_TIMEOUT="${VERIFY_BOOT_TIMEOUT:-600}" -echo "== master realm 기동 대기 (최대 ${BOOT_TIMEOUT}s) ==" -ok=0 -for i in $(seq 1 "$BOOT_TIMEOUT"); do - if curl -fsS "$BASE/realms/master/.well-known/openid-configuration" >"$WORK/disco.json" 2>/dev/null; then - ok=1; echo " ${i}s 만에 응답"; break - fi - if [ -z "$(docker ps -q --filter "id=$CID")" ]; then - echo "FAIL: 컨테이너가 죽었다"; docker logs "$CID" 2>&1 | tail -40; exit 1 - fi - # 30초마다 어디까지 갔는지 남긴다 — 느린 것과 멈춘 것을 로그로 구분하기 위함이다. - if [ $((i % 30)) -eq 0 ]; then - echo " ...${i}s: $(docker logs "$CID" 2>&1 | tail -1 | cut -c1-140)" - fi - sleep 1 -done -if [ "$ok" != 1 ]; then - echo "FAIL: ${BOOT_TIMEOUT}초 내에 OIDC discovery 가 응답하지 않았다" - docker logs "$CID" 2>&1 | tail -60 - exit 1 -fi -ISSUER="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["issuer"])' "$WORK/disco.json")" -echo " issuer=$ISSUER" - -echo "== admin 토큰 발급 (부트스트랩 계정 + DB 마이그레이션 확인) ==" -curl -fsS -X POST "$BASE/realms/master/protocol/openid-connect/token" \ - -d "client_id=admin-cli" -d "grant_type=password" \ - -d "username=$ADMIN_USER" --data-urlencode "password=$ADMIN_PASS" \ - > "$WORK/token.json" -python3 - "$WORK/token.json" <<'PY' -import json, sys -tok = json.load(open(sys.argv[1])) -assert tok.get("access_token"), f"access_token 없음: {tok}" -print(f" access_token 발급 OK (expires_in={tok.get('expires_in')}s)") -PY - -echo "== admin REST API 호출 (realms 목록) ==" -AT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["access_token"])' "$WORK/token.json")" -curl -fsS -H "Authorization: Bearer $AT" "$BASE/admin/realms" > "$WORK/realms.json" -python3 - "$WORK/realms.json" <<'PY' -import json, sys -realms = [r["realm"] for r in json.load(open(sys.argv[1]))] -assert "master" in realms, f"master realm 없음: {realms}" -print(f" realms={realms}") -PY - -# netty/jackson 오버레이가 실제 HTTP 스택에서 동작했다는 증거 — 위 요청들이 전부 -# Quarkus(netty) 위에서 처리되고 jackson 으로 직렬화된 JSON 이다. -echo "VERIFY-OK" diff --git a/manifests/helm/apisix/2.16.0/custom-values.yaml b/manifests/helm/apisix/2.16.0/custom-values.yaml index 77f3d37..f527fbe 100644 --- a/manifests/helm/apisix/2.16.0/custom-values.yaml +++ b/manifests/helm/apisix/2.16.0/custom-values.yaml @@ -2,13 +2,14 @@ # # 기존엔 dataup experiment/apisix/apisix-plugin 레포에서 apache/apisix:3.17.0-debian # 기반으로 keycloak-authz 커스텀 플러그인만 얹어 빌드했다. 이 베이스(Debian) 자체가 -# CVE 게이트 차단 26건(2026-08-11 실측) — 이미 최신 apache/apisix 태그라 태그 교체로도 -# 안 없어지는 Debian 베이스 OS 패키지 CVE라, images/apisix 자체 빌드(SUSE BCI 위에 -# APISIX-Runtime 전체를 소스에서 재현 + keycloak-authz 오버레이)로 교체했다. -# 게이트 PASS(실효 CRITICAL/HIGH 0/0, 2026-08-12 실측). 근거: images/apisix/README.md. +# CVE 게이트를 다수 차단 — 이미 최신 apache/apisix 태그라 태그 교체로도 안 없어지는 +# Debian 베이스 OS 패키지 CVE라, 자체 빌드(SUSE BCI 위에 APISIX-Runtime 전체를 소스에서 +# 재현 + keycloak-authz 오버레이)로 교체했다. 빌드 정의·근거는 별도 레포 security-images +# 의 images/apisix/(README.md 포함) — 이 레포에는 없다. # -# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 images/apisix 를 다시 빌드해 갱신할 것 — -# dataup 레포 쪽은 더 이상 이 카탈로그가 참조하지 않는다(그 레포 자체는 그대로 둠). +# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 security-images 에서 apisix 를 다시 +# 빌드해 갱신할 것 — dataup 레포 쪽은 더 이상 이 카탈로그가 참조하지 않는다(그 레포 +# 자체는 그대로 둠). image: repository: docker.io/paasup/apisix tag: "3.17.0-security-hardened-20260813" @@ -183,18 +184,15 @@ ingress-controller: ingressClass: apisix # Kong의 'kong' class와 분리 # apisix-ingress-controller 서브차트 기본값(2.1.0, apache/apisix-ingress-controller)이 - # 이미 최신 태그인데도 CVE 게이트 차단 25건(정적 링크된 Go 모듈 21건 — 태그 교체로도 - # 해소 안 됨) — images/apisix-ingress-controller 자체 빌드(취약 모듈만 강제 업그레이드) - # 로 교체해 게이트 PASS(실효 CRITICAL/HIGH 0건, 2026-08-11 실측). 근거: images/ - # apisix-ingress-controller/README.md. + # 이미 최신 태그인데도 CVE 게이트를 다수 차단(정적 링크된 Go 모듈 다수 — 태그 교체로도 + # 해소 안 됨) — 자체 빌드(취약 모듈만 강제 업그레이드)로 교체해 게이트 PASS. 빌드 + # 정의·근거는 별도 레포 security-images 의 images/apisix-ingress-controller/(이 레포에는 없다). # - # adc 사이드카 — 0.27.1 → 0.29.0 태그 교체는 유효한 부분 조치였지만 완전 해소는 - # 아니었다(2026-08-12 게이트 재검증에서 정정 — 벤더 등급만 보면 0/0 이지만 - # max(벤더,NVD) 로는 glibc regex/collating 스택오버플로 4건이 실효 CRITICAL/HIGH 로 - # 차단. Debian 이 "affected, 수정 없음"으로 영구 고정해둔 벤더 하향 등급 사례 — - # cnpg-postgresql 때와 같은 패턴). images/adc 자체 빌드(distroless 대신 SUSE BCI + - # nodejs24, 빌더 스테이지는 업스트림 그대로)로 교체해 게이트 PASS(실효 CRITICAL/HIGH - # 0/0, 2026-08-12 실측). 근거: images/adc/README.md. + # adc 사이드카 — 태그 교체는 유효한 부분 조치였지만 완전 해소는 아니었다(벤더 등급만 + # 보면 통과지만 max(벤더,NVD) 로는 여전히 차단 — Debian 이 "affected, 수정 없음"으로 + # 영구 고정해둔 벤더 하향 등급 사례, cnpg-postgresql 때와 같은 패턴). 자체 빌드 + # (distroless 대신 SUSE BCI + nodejs24, 빌더 스테이지는 업스트림 그대로)로 교체해 + # 게이트 PASS. 빌드 정의·근거는 별도 레포 security-images 의 images/adc/(이 레포에는 없다). deployment: image: repository: docker.io/paasup/apisix-ingress-controller 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 df63a95..b42a9d7 100644 --- a/manifests/helm/argo-cd/10.4.0/custom-values.yaml +++ b/manifests/helm/argo-cd/10.4.0/custom-values.yaml @@ -9,8 +9,8 @@ global: # 자체 빌드(대응 우선순위 c) — 업스트림 v3.5.1 의 차단 CVE 대부분이 바이너리에 정적 # 링크된 Go 모듈이라 상위 태그 교체·베이스 OS 교체로 해소되지 않았다. 번들 도구 # (helm/kustomize/git-lfs)까지 최신 툴체인으로 다시 컴파일했다. - # 빌드 정의: images/argocd/. 상위 태그가 이 문제를 해결하면(대응 우선순위 a) - # 업스트림으로 되돌리는 것이 우선이다. + # 빌드 정의·근거는 별도 레포 security-images 의 images/argocd/(이 레포에는 없다). + # 상위 태그가 이 문제를 해결하면(대응 우선순위 a) 업스트림으로 되돌리는 것이 우선이다. # # 이 값은 argocd 바이너리를 쓰는 5개 컴포넌트(server/repo-server/application-controller/ # applicationset/notifications)가 공유한다 — 컴포넌트별 image 블록이 비면 global 을 diff --git a/manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml b/manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml index 4f9a2a0..996baee 100644 --- a/manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml +++ b/manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml @@ -4,8 +4,9 @@ image: # 자체 빌드(대응 우선순위 c) — 업스트림 1.30.0 이 게이트 차단 HIGH 3건(stdlib·x/text·grpc, # Go 모듈 정적 링크라 베이스 OS 교체로 해소 불가)으로 막혀 release-1.30 브랜치를 직접 - # 컴파일했다. 근거·결정: doc/decisions/0002-cloudnative-pg-operator-self-build.md. - # 빌드 정의: images/cloudnative-pg/. 상위 태그가 나오면(대응 우선순위 a) 되돌리는 것이 우선. + # 컴파일했다. 빌드 정의·근거·결정은 별도 레포 security-images 의 images/cloudnative-pg/ + # 와 docs/decisions/0002-cloudnative-pg-operator-self-build.md(이 레포에는 없다). + # 상위 태그가 나오면(대응 우선순위 a) 되돌리는 것이 우선. repository: docker.io/paasup/cloudnative-pg tag: "1.30.0-security-hardened-20260820" diff --git a/manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml b/manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml index 4824bef..b17232c 100644 --- a/manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml +++ b/manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml @@ -4,12 +4,13 @@ instances: 3 postgresql: - # SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다 (2026-07-28). - # 결정 근거·받아들인 비용 → doc/decisions/0001-cnpg-postgresql-image.md - # 빌드 정의 → images/cnpg-postgresql/suse.Dockerfile + suse.build.env + # SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다. + # 결정 근거·받아들인 비용·빌드 정의는 별도 레포 security-images 의 + # docs/decisions/0001-cnpg-postgresql-image.md 와 images/cnpg-postgresql/ + # (이 레포에는 없다). # # 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로 - # 롤링 태그를 쓰지 않는다 (.claude/image-authoring.md). + # 롤링 태그를 쓰지 않는다(security-images 레포의 docs/image-authoring.md). imageName: "docker.io/paasup/cnpg-postgresql:18.4-bci15.7-hardened-20260820" # # trivy 는 SLES 15.7 을 정상 커버한다 — 실효 C/H 0/0 은 측정된 결과이며 게이트 PASS 다. diff --git a/manifests/helm/cnpg-cluster/1.0.0/dip-values.yaml b/manifests/helm/cnpg-cluster/1.0.0/dip-values.yaml index 264cb7b..cbf471e 100644 --- a/manifests/helm/cnpg-cluster/1.0.0/dip-values.yaml +++ b/manifests/helm/cnpg-cluster/1.0.0/dip-values.yaml @@ -4,7 +4,8 @@ instances: 3 postgresql: # custom-values.yaml 과 동일하게 SUSE BCI 15.7 자체 빌드를 쓴다. - # 근거·비용 → doc/decisions/0001-cnpg-postgresql-image.md + # 근거·비용은 별도 레포 security-images 의 docs/decisions/0001-cnpg-postgresql-image.md + # (이 레포에는 없다). # trivy 는 SLES 15.7 을 정상 커버한다(게이트의 CoverageProbe 가 매 스캔마다 확인 — # doc/sbom-pipeline.md). # 실효 C/H 0/0, 게이트 PASS. diff --git a/manifests/helm/cnpg-cluster/1.1.0/custom-values.yaml b/manifests/helm/cnpg-cluster/1.1.0/custom-values.yaml index 6eaaf56..90ac94f 100644 --- a/manifests/helm/cnpg-cluster/1.1.0/custom-values.yaml +++ b/manifests/helm/cnpg-cluster/1.1.0/custom-values.yaml @@ -4,8 +4,8 @@ instances: 3 postgresql: - # SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다 (2026-07-28). - # 빌드 정의 → images/cnpg-postgresql/suse.Dockerfile + suse.build.env + # SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다. + # 빌드 정의는 별도 레포 security-images 의 images/cnpg-postgresql/(이 레포에는 없다). # # 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로 # 롤링 태그를 쓰지 않는다. diff --git a/manifests/helm/etcd/1.1.12/CUSTOM-README.md b/manifests/helm/etcd/1.1.12/CUSTOM-README.md index d9c2ac7..b6ed4a9 100644 --- a/manifests/helm/etcd/1.1.12/CUSTOM-README.md +++ b/manifests/helm/etcd/1.1.12/CUSTOM-README.md @@ -52,7 +52,7 @@ kubectl -n etcd-system exec etcd-0 -- etcdctl endpoint health --cluster | Name | 설명 | 기본값 | | --- | --- | --- | -| `image.registry` / `image.repository` / `image.tag` | etcd 본체 이미지. **자체 빌드**(`docker.io/wbsong111/etcd:3.7.1-security-hardened-20260731`) — 업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트 차단 HIGH 1건(`CVE-2026-56852`, `golang.org/x/text`)으로 막혀 대체했다. 근거·빌드 정의는 [images/etcd/README.md](../../../images/etcd/README.md), 채택 결정 배경은 security-catalog 프로젝트 decisions/0007(dip-catalog 미이관). 상위 태그가 나오거나 `release-3.7` 에 백포트되면 업스트림으로 되돌리는 것이 우선(대응 우선순위 a) | 업스트림 `quay.io/coreos/etcd` (태그 미지정) | +| `image.registry` / `image.repository` / `image.tag` | etcd 본체 이미지. **자체 빌드**(`docker.io/paasup/etcd:3.7.1-security-hardened-20260820`) — 업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트 차단 HIGH 1건(`CVE-2026-56852`, `golang.org/x/text`)으로 막혀 대체했다. 빌드 정의·근거·채택 결정 배경은 별도 레포 `security-images` 의 `images/etcd/README.md` 와 `docs/decisions/0004-etcd-image-self-build.md`(이 레포에는 없다). 상위 태그가 나오거나 `release-3.7` 에 백포트되면 업스트림으로 되돌리는 것이 우선(대응 우선순위 a) | 업스트림 `quay.io/coreos/etcd` (태그 미지정) | | `initImage.registry` / `initImage.repository` / `initImage.tag` | 데이터 디렉토리 초기화용 init 컨테이너. 업스트림 기본값(`busybox:stable`)은 롤링 태그라 `busybox:1.38.0-uclibc` 로 고정 | 업스트림 `busybox:stable` | ### 2) 클러스터 크기 diff --git a/manifests/helm/etcd/1.1.12/custom-values.yaml b/manifests/helm/etcd/1.1.12/custom-values.yaml index bd7c39c..77d599a 100644 --- a/manifests/helm/etcd/1.1.12/custom-values.yaml +++ b/manifests/helm/etcd/1.1.12/custom-values.yaml @@ -6,8 +6,9 @@ image: # HIGH 1건(golang.org/x/text, CVE-2026-56852, 바이너리 정적 링크라 베이스 OS 교체로 # 해소 불가)으로 막혀 v3.7.1 태그가 가리키는 commit 을 그대로 컴파일했다. x/text 만 # go.work 워크스페이스 전역 replace 로 0.39.0 이상으로 강제. - # 근거·결정: doc/decisions/0004-etcd-image-self-build.md. 빌드 정의: images/etcd/. - # 상위 태그가 나오거나 release-3.7 에 백포트되면(대응 우선순위 a) 되돌리는 것이 우선. + # 근거·결정·빌드 정의는 별도 레포 security-images 의 docs/decisions/0004-etcd-image-self-build.md + # 와 images/etcd/(이 레포에는 없다). 상위 태그가 나오거나 release-3.7 에 백포트되면 + # (대응 우선순위 a) 되돌리는 것이 우선. # # 이전 값: quay.io/coreos/etcd:v3.7.1 (업스트림). etcd 프로젝트는 3.8부터 # gcr.io/etcd-development·quay.io/coreos 를 폐지하고 registry.k8s.io/etcd 로 이전 @@ -17,7 +18,7 @@ image: tag: "3.7.1-security-hardened-20260820" initImage: - # 업스트림 기본값 "stable" 은 롤링 태그다 (롤링 태그 금지 — .claude/image-authoring.md). + # 업스트림 기본값 "stable" 은 롤링 태그다 — 스캔 대상이 시점마다 달라지므로 고정한다. # 이 init 컨테이너도 extract-helm-images.sh 에 잡혀 게이트 대상이 되므로 고정한다. registry: "docker.io" repository: "busybox" diff --git a/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md b/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md index 284fc56..eb77d82 100644 --- a/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md +++ b/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md @@ -124,10 +124,10 @@ proxy: ## 3. 자체 빌드 이미지 `image.repository`/`image.tag` 는 업스트림 `quay.io/keycloak/keycloak` 이 아니라 -`docker.io/paasup/keycloak` 자체 빌드 하드닝 이미지를 가리킨다. 빌드 정의는 -[`images/keycloak/`](../../../../images/keycloak/) 에 있고, 왜 자체 빌드인지·업스트림과 -무엇이 다른지는 [`images/keycloak/README.md`](../../../../images/keycloak/README.md) 가 -단일 출처다. 배포 관점에서 알아야 할 것만 아래에 적는다. +`docker.io/paasup/keycloak` 자체 빌드 하드닝 이미지를 가리킨다. 빌드 정의와, 왜 자체 +빌드인지·업스트림과 무엇이 다른지는 별도 레포 `security-images` 의 `images/keycloak/` +(`README.md` 포함)가 단일 출처다 — 이 레포에는 없다. 배포 관점에서 알아야 할 것만 +아래에 적는다. ### 앱 버전이 차트 `appVersion` 과 다르다 @@ -155,6 +155,8 @@ proxy: ### 이미지 갱신 -`images/keycloak/suse.build.env` 의 `KEYCLOAK_VERSION` 과 jar 오버레이 버전을 사람이 -고쳐 PR 을 여는 것이 갱신 트리거다. `build-image.yml` 을 `workflow_dispatch` 로 돌리면 -빌드·게이트 통과 후 이 파일의 `image.tag` 가 자동 갱신된 브랜치가 생성된다. +`security-images` 레포의 `images/keycloak/suse.build.env` 의 `KEYCLOAK_VERSION` 과 +jar 오버레이 버전을 사람이 고쳐 커밋하는 것이 갱신 트리거다(그 레포에서). 그 레포의 +`build-image.yml` 이 빌드·게이트 통과 후 push 하면 `published.json` 이 갱신되고, +이 카탈로그의 `catalog-tag-update.yml` 이 그것을 읽어가 이 파일의 `image.tag` 를 +자동 갱신한 브랜치를 이 레포에 만든다. diff --git a/scripts/build/apply-published-tags.py b/scripts/build/apply-published-tags.py new file mode 100644 index 0000000..38c28a1 --- /dev/null +++ b/scripts/build/apply-published-tags.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""apply-published-tags.py — security-images 의 `published.json` 을 읽어 카탈로그 +values 의 자체 빌드 이미지 태그를 갱신한다. + +카탈로그와 security-images(자체 빌드 이미지 레포)의 계약은 `published.json` 파일 +스키마 하나뿐이다 — 그 레포는 게이트 PASS + push 가 실제로 일어났을 때만 이 파일을 +갱신한다. 이 스크립트는 그 파일과 `catalog/image-map/.env`(어느 차트의 어느 +필드를 갱신할지)를 대조해 `custom-values.yaml`/`dip-values.yaml` 을 패치한다. + +`gate != "pass"` 인 이미지는 건너뛴다 — 실패한 빌드의 태그를 반영하면 안 된다. + +이 스크립트는 파일만 고친다. 브랜치 생성·커밋·push 는 호출자(워크플로)의 책임이다 — +그래야 "무엇이 바뀌었는지" 를 워크플로가 커밋 메시지에 정확히 담을 수 있다. + +사용 +---- + python3 scripts/build/apply-published-tags.py --published published.json + python3 scripts/build/apply-published-tags.py --published published.json --dry-run + python3 scripts/build/apply-published-tags.py --published published.json --image etcd + +종료 코드 + 0 정상 (변경 사항 유무와 무관) + 2 실행 오류 +""" +import argparse +import importlib.util +import json +import os +import pathlib +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.normpath(os.path.join(HERE, "..", "..")) +IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map") +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 + + +PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag") + + +def read_env(path): + 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) + out[k] = v.strip().strip('"').strip("'") + return out + + +def read_current(text, style, block): + return (PATCH.read_image_name(text) if style == "imageName" + else PATCH.read_split_block(text, block)) + + +def patch_text(text, style, block, old, new): + return (PATCH.patch_image_name(text, old, new) if style == "imageName" + else PATCH.patch_split_block(text, block, old, new)) + + +def apply_for_image(image, new_ref, dry_run): + """반환: [{"file", "old", "new"}, ...] — 실제로 바뀐(또는 --dry-run 이면 바뀔) 파일들.""" + chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env")) + chart_dirs = (chart_env.get("CHART_DIRS") or "").split() + style = chart_env.get("TAG_STYLE") or "" + block = chart_env.get("TAG_BLOCK") or "image" + if not chart_dirs or style not in ("imageName", "split"): + print(f"::warning::{image}: catalog/image-map/{image}.env 에 CHART_DIRS/TAG_STYLE 이 없다", + file=sys.stderr) + return [] + + changed = [] + for d in chart_dirs: + for fname in VALUES_CANDIDATES: + p = pathlib.Path(ROOT, d, fname) + if not p.is_file(): + continue + text = p.read_text() + current = read_current(text, style, block) + if current is None: + continue + if current == new_ref: + continue + new_text = patch_text(text, style, block, current, new_ref) + if new_text is None: + print(f"::warning::{image}: {p} 에서 '{current}' 패턴을 찾지 못해 건너뛴다", + file=sys.stderr) + continue + if not dry_run: + p.write_text(new_text) + changed.append({"file": os.path.relpath(p, ROOT), "old": current, "new": new_ref}) + return changed + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--published", required=True, metavar="FILE", + help="security-images 의 published.json (로컬 경로 — 원격 조회는 호출자가 한다)") + ap.add_argument("--image", default="", help="이 이미지만 반영 (기본: published.json 의 전체)") + ap.add_argument("--dry-run", action="store_true", help="무엇이 바뀔지 보고만 하고 파일은 건드리지 않는다") + ap.add_argument("--json-out", metavar="FILE", help="변경 목록을 JSON 으로 이 파일에 쓴다") + ap.add_argument("--summary-md", metavar="FILE", help="변경 요약을 마크다운으로 이 파일에 쓴다") + args = ap.parse_args() + + if not os.path.isfile(args.published): + print(f"::error::published.json 을 찾지 못했다: {args.published}", file=sys.stderr) + return 2 + with open(args.published) as f: + doc = json.load(f) + + images = doc.get("images") or {} + names = [args.image] if args.image else sorted(images) + + all_changes = [] + for name in names: + entry = images.get(name) + if entry is None: + print(f"::warning::{name}: published.json 에 없다", file=sys.stderr) + continue + if entry.get("gate") != "pass": + print(f"::warning::{name}: gate={entry.get('gate')!r} — 건너뛴다", file=sys.stderr) + continue + ref = entry.get("ref") + if not ref: + continue + changes = apply_for_image(name, ref, args.dry_run) + for c in changes: + all_changes.append({"image": name, **c}) + print(f"{'(dry-run) ' if args.dry_run else ''}{name}: {c['file']} " + f"{c['old']} -> {c['new']}") + + if not all_changes: + print("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다") + + if args.json_out: + pathlib.Path(args.json_out).write_text(json.dumps(all_changes, ensure_ascii=False, indent=2) + "\n") + if args.summary_md: + lines = ["## 자체 빌드 이미지 태그 반영", ""] + if not all_changes: + lines.append("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다.") + else: + lines += ["| 이미지 | 파일 | 이전 태그 | 새 태그 |", "|---|---|---|---|"] + for c in all_changes: + lines.append(f"| `{c['image']}` | `{c['file']}` | `{c['old']}` | `{c['new']}` |") + pathlib.Path(args.summary_md).write_text("\n".join(lines) + "\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build/build-hardened-image.sh b/scripts/build/build-hardened-image.sh deleted file mode 100755 index 1636073..0000000 --- a/scripts/build/build-hardened-image.sh +++ /dev/null @@ -1,185 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# build-hardened-image.sh -# 업스트림 이미지가 CRITICAL/HIGH 0건 목표를 만족하지 못할 때, 업스트림 Dockerfile 을 -# 기준으로 베이스 OS 를 교체하고 보안 업데이트를 적용한 이미지를 빌드·검증한다. -# -# 빌드 → 기능 검증 → 취약점 스캔 → 게이트 판정 까지 한 번에 수행한다. -# 기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다 (0건이어도 못 쓰는 이미지는 무의미). -# -# 사용: -# IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh [TAG] -# REGISTRY=docker.io/paasup IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh -# -# 빌드 정의는 이 스크립트에 없다. images//.build.env 를 source 해서 -# 베이스·버전·확장·build-arg 목록을 읽고, 기능 검증은 images//verify.sh 에 위임한다. -# (베이스 OS 가 둘 이상이 되면 하드코딩된 검증이 깨지기 때문이다 — 실제로 그렇게 됐었다) -# -# 이미지 종류(OS 패키지 설치형·소스 컴파일형 등)에 무관하게 이 스크립트 하나를 쓴다 — -# build.env 가 선언하는 것 이상을 이 스크립트가 알지 못하게 하는 게 원칙이다. build.env 가 -# 요구하는 값은 APP_VERSION(태그·verify.sh 전달용) 하나뿐이고, 그 외 이미지별 변수는 -# build.env 에 무엇을 적든 자동으로 verify.sh 의 환경변수로 전달된다(아래 참고). -# -# 환경변수: -# IMAGE 이미지 디렉토리명 (필수 — images//. 기본값 없음: 아직 도입된 -# 자체 빌드 이미지가 없어 어떤 기본값도 실재하지 않는 이미지를 가리킨다) -# BASE_OS 빌드 변종 파일명 (필수 — images//.build.env. 베이스 -# OS 정책은 아직 미결이다 — 처음 도입하는 이미지에서 정한다, MEMORY.md 참고) -# PLATFORM 빌드 플랫폼 (기본 linux/amd64) -# REGISTRY 푸시할 레지스트리 (미설정 시 푸시 생략. TAG 도 여기서 유도된다) -# IMAGE_REPO 레지스트리 내 저장소명 (기본 $IMAGE) -# SEVERITY 스캔 심각도 (기본 전 심각도 — 필터하면 목표 판정이 불가능해진다) -# CROSSREF 교차 검증용 참조 리포트 (선택, cve-gate.py 로 전달) -# build.env 의 값은 동일 이름 환경변수로 덮어쓸 수 있다 (예: APP_VERSION=18.5) -# -# 산출물 (OUT_DIR): -# build.log 빌드 로그 -# verify.log 기능 검증 로그 -# sbom/.cdx.json CycloneDX SBOM -# trivy-reports/.json 전 심각도 스캔 결과 (+ CoverageProbe) -# cve-gate.md 게이트 판정 요약 -# ============================================================================= -set -uo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -PIPELINE_DIR="$REPO_ROOT/scripts/pipeline" - -OUT_DIR="${1:?사용법: build-hardened-image.sh [TAG]}" -# 기본값을 두지 않는다 — 도입된 자체 빌드 이미지가 아직 없어 어떤 기본값을 골라도 -# 실재하지 않는 images// 를 가리키게 된다. 반드시 명시적으로 지정한다. -IMAGE="${IMAGE:?IMAGE 환경변수 필수 — images// 디렉토리명}" -BASE_OS="${BASE_OS:?BASE_OS 환경변수 필수 — images//.build.env 파일명}" -IMAGE_DIR="$REPO_ROOT/images/$IMAGE" -ENV_FILE="$IMAGE_DIR/$BASE_OS.build.env" - -[ -d "$IMAGE_DIR" ] || { echo "::error::이미지 디렉토리 없음: $IMAGE_DIR"; exit 2; } -[ -f "$ENV_FILE" ] || { echo "::error::build.env 없음: $ENV_FILE"; exit 2; } - -# build.env 는 **기본값**이다. 이미 설정된 환경변수를 덮어쓰지 않는다. -# (`. "$ENV_FILE"` 로 그냥 source 하면 외부 지정이 무시된다) -# 읽은 변수명을 ENV_FILE_VARS 에 함께 기록한다 — 기능 검증 단계에서 이미지별로 어떤 -# 값이 필요한지 이 스크립트가 몰라도 되게, build.env 에 적힌 것을 통째로 verify.sh 에 -# 환경변수로 넘기기 위함이다. -ENV_FILE_VARS=() -while IFS= read -r line; do - case "$line" in ''|'#'*|[[:space:]]*) continue ;; esac - name="${line%%=*}" - case "$name" in ''|*[!A-Za-z0-9_]*) continue ;; esac - ENV_FILE_VARS+=("$name") - if [ -z "${!name+set}" ]; then - eval "$line" - else - echo " (외부 지정 우선: $name=${!name})" - fi -done < "$ENV_FILE" - -PLATFORM="${PLATFORM:-linux/amd64}" -DOCKERFILE="$IMAGE_DIR/${DOCKERFILE:?build.env 에 DOCKERFILE 이 없다}" -TARGET="${TARGET:-patched}" -: "${BUILD_ARGS:?build.env 에 BUILD_ARGS 가 없다}" -# APP_VERSION 이 유일한 이미지 종류 무관 필수값이다 — 태그 프리픽스와 verify.sh 양쪽에 -# 쓰인다. PG_MAJOR 처럼 이미지별로만 의미 있는 값은 여기서 요구하지 않는다(ENV_FILE_VARS -# 전달로 충분하다). -: "${APP_VERSION:?build.env 에 APP_VERSION 이 없다}" - -# 태그에 빌드일을 넣는다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르다. -# 태그를 REGISTRY 에서 유도한다. 예전에는 TAG 기본값이 REGISTRY 와 무관해서, push 하는 -# 곳과 태그가 가리키는 곳이 달랐다 (기본 paasup.io/... 인데 실제로는 docker.io/... 로 push). -BUILD_DATE="$(date -u +%Y%m%d)" -APP_VER="$APP_VERSION" -IMAGE_REPO="${IMAGE_REPO:-$IMAGE}" -DEFAULT_TAG="${APP_VER}-${TAG_SLUG:-$BASE_OS}-hardened-${BUILD_DATE}" -if [ -n "${REGISTRY:-}" ]; then - TAG="${2:-${REGISTRY%/}/${IMAGE_REPO}:${DEFAULT_TAG}}" -else - # push 하지 않는 로컬 빌드. 레지스트리 없는 이름이면 push 를 시도할 수도 없다. - TAG="${2:-localhost/${IMAGE_REPO}:${DEFAULT_TAG}}" -fi - -mkdir -p "$OUT_DIR/sbom" -STEM="$(echo "$TAG" | tr ':/' '__')" - -# build.env 가 선언한 이름만 --build-arg 로 넘긴다. 베이스 OS 마다 인자 집합이 다르다 -# (deb 계열: EXTENSIONS/STANDARD_ADDITIONAL_… / suse: SLE_REPO/PGDG_KEY). -BA=() -for name in $BUILD_ARGS; do - BA+=(--build-arg "$name=${!name-}") -done - -echo "== 빌드 ==" -echo " image=$IMAGE base_os=$BASE_OS target=$TARGET platform=$PLATFORM" -echo " dockerfile=${DOCKERFILE#$REPO_ROOT/}" -echo " tag=$TAG" -# --pull 을 명시한다. 없으면 러너에 남은 로컬 캐시를 쓸 수 있어, 스케줄 재빌드가 -# 전제하는 "베이스 이미지를 매번 새로 받는다" 가 조용히 깨진다. -if ! docker build --pull --platform "$PLATFORM" -f "$DOCKERFILE" --target "$TARGET" \ - "${BA[@]}" -t "$TAG" "$IMAGE_DIR" > "$OUT_DIR/build.log" 2>&1; then - echo "::error::빌드 실패 — $OUT_DIR/build.log 확인"; tail -20 "$OUT_DIR/build.log"; exit 1 -fi -echo " OK" - -echo "== 기능 검증 ==" -# 검증 항목은 이미지 디렉토리가 소유한다. 여기에 하드코딩하면 변종이 늘 때 깨진다. -# -# verify.sh 는 **호스트에서 bash 로 실행**되고, 자신이 필요한 docker run 을 직접 호출한다 -# (게스트 셸에 stdin 으로 스크립트를 주입하는 방식이 아니다). distroless 최종 이미지처럼 -# 셸이 아예 없는 이미지도 있기 때문이다(cloudnative-pg — `--entrypoint sh` 로 들어갈 방법이 -# 없다) — 호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로 -# 게스트 셸을 여전히 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로 -# 직접 실행할 수 있어 상위 호환이다. -VERIFY_SH="$IMAGE_DIR/verify.sh" -[ -f "$VERIFY_SH" ] || { echo "::error::verify.sh 없음: $VERIFY_SH"; exit 2; } -# build.env 에서 읽은 변수를 전부 환경변수로 넘긴다 — verify.sh 가 무엇을 필요로 하는지 -# 이 스크립트가 알 필요가 없어진다. TAG/PLATFORM 은 검증 대상·실행 플랫폼으로 항상 넘긴다. -VERIFY_ENV_ASSIGN=(TAG="$TAG" PLATFORM="$PLATFORM") -for name in "${ENV_FILE_VARS[@]}"; do - VERIFY_ENV_ASSIGN+=("$name=${!name-}") -done -if ! env "${VERIFY_ENV_ASSIGN[@]}" bash "$VERIFY_SH" > "$OUT_DIR/verify.log" 2>&1 \ - || ! grep -q VERIFY-OK "$OUT_DIR/verify.log"; then - echo "::error::기능 검증 실패 — $OUT_DIR/verify.log 확인"; cat "$OUT_DIR/verify.log"; exit 1 -fi -sed -n '1,40p' "$OUT_DIR/verify.log" | sed 's/^/ /' -grep -q 'WARN:' "$OUT_DIR/verify.log" && echo " (경고 있음 — verify.log 확인)" - -echo "== SBOM ==" -docker save "$TAG" -o "$OUT_DIR/image.tar" 2>/dev/null -trivy image --quiet --format cyclonedx --input "$OUT_DIR/image.tar" \ - > "$OUT_DIR/sbom/${STEM}.cdx.json" 2>/dev/null -rm -f "$OUT_DIR/image.tar" -echo " 컴포넌트: $(python3 -c "import json;print(len(json.load(open('$OUT_DIR/sbom/${STEM}.cdx.json')).get('components') or []))" 2>/dev/null || echo '?')" - -# 인덱스는 스캔의 입력이다: chart⇥version⇥image⇥status⇥?⇥sbom파일 -printf 'hardened\t%s\t%s\tOK\t0\t%s.cdx.json\n' "$APP_VERSION" "$TAG" "$STEM" > "$OUT_DIR/sbom-index.tsv" - -echo "== 스캔 (전 심각도 + 커버리지 자가진단) ==" -# scan-sbom.sh 를 재사용한다. 예전에는 여기서 `trivy image` 를 직접 불렀는데, 그러면 -# 리포트에 CoverageProbe 가 없어 게이트가 "데이터 커버리지 이상" 으로 실패한다. -# SLES 기반 이미지는 전 심각도 0건이라 **정상 이미지가 반드시 FAIL 했다.** -# 스캔 로직이 두 곳에 사는 것 자체가 원인이었으므로 한 곳으로 모은다. -# -# 심각도로 필터하지 않는다. 벤더가 낮게 등급한 항목까지 받아야 NVD 기준 재평가가 가능하다. -SEVERITY="${SEVERITY:-UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL}" \ - bash "$PIPELINE_DIR/scan-sbom.sh" "$OUT_DIR" >/dev/null || { - echo "::error::스캔 실패 — $OUT_DIR/trivy-run.log 확인"; exit 1; } -grep 'cov=' "$OUT_DIR/trivy-run.log" 2>/dev/null | sed 's/^/ /' - -echo "== 게이트 판정 ==" -GATE_ARGS=(--reports "$OUT_DIR/trivy-reports" --index "$OUT_DIR/sbom-index.tsv" - --sbom-dir "$OUT_DIR/sbom" --summary-md "$OUT_DIR/cve-gate.md") -[ -f "${CROSSREF:-}" ] && GATE_ARGS+=(--crossref "$CROSSREF") -[ -f "$REPO_ROOT/doc/cve-exceptions.json" ] && GATE_ARGS+=(--exceptions "$REPO_ROOT/doc/cve-exceptions.json") - -python3 "$PIPELINE_DIR/cve-gate.py" "${GATE_ARGS[@]}" >/dev/null -RC=$? - -if [ -n "${REGISTRY:-}" ] && [ "$RC" -eq 0 ]; then - echo "== 푸시 ==" - docker push "$TAG" >/dev/null 2>&1 && echo " $TAG" || echo "::warning::푸시 실패" -fi - -echo -echo "== 결과 ==" -echo " 게이트: $([ "$RC" -eq 0 ] && echo PASS || echo FAIL) 요약: $OUT_DIR/cve-gate.md" -exit "$RC" diff --git a/scripts/build/check-rebuild-needed.py b/scripts/build/check-rebuild-needed.py index d9a2786..56df6bb 100644 --- a/scripts/build/check-rebuild-needed.py +++ b/scripts/build/check-rebuild-needed.py @@ -4,25 +4,21 @@ 왜 필요한가 ----------- 자체 빌드 이미지는 **소스를 안 바꿔도 시간이 지나면 게이트를 벗어난다.** 새 CVE 가 공개되고, -베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 push 해 둔 이미지는 그대로다. -2026-08-19 에 etcd·cloudnative-pg 가 이 상태로 차단됐는데 **문서 전용 PR 이 우연히 -`images/**` 를 건드려 검증 빌드가 돌면서야** 발견됐다. 의도적으로 보는 장치가 없었다. +베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 참조해 둔 태그는 그대로다. +의도적으로 보는 장치가 없으면 문서 전용 PR 이 우연히 검증 빌드를 돌릴 때만 발견된다. -`build-image.yml` 이 주간으로 이것을 돌려 재빌드까지 한다. +`self-build-drift-check.yml` 이 주간으로 이것을 돌린다. -무엇이 "재빌드로 고쳐지는가" 인가 — 세 경우 모두 그렇다 ------------------------------------------------------ -처음에는 "핀이 뒤처진 경우" 만 트리거로 잡았는데 **실측에서 그게 틀렸다.** 배포 중인 이미지 -8개를 스캔했더니 차단이 있는 5개 중 핀 변경이 필요한 것은 하나도 없었다. +무엇이 "재빌드로 고쳐지는가" 인가 +-------------------------------- +"핀이 뒤처진 경우" 만으로는 부족하다. 재빌드로 고쳐지는 경우는 셋이다. - 1. 핀이 뒤처졌다 → 핀을 올리고 빌드 (GO_BUILDER_TAG 등) + 1. 핀이 뒤처졌다 → 핀을 올리고 빌드 2. 핀은 맞는데 이미지가 그 핀으로 안 빌드됐다 → 그냥 빌드 - (핀만 고친 PR 이 머지되고 재빌드가 아직 안 된 상태) - 3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 - (재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다) + 3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 (재빌드하면 zypper 가 최신을 깐다) -그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 적용하는 -부수 작업이고, 트리거 조건이 아니다. +그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 이 스크립트는 그 판정만 +한다 — 핀을 무엇으로 올려야 하는지는 판단하지 않는다(아래 "레포 분리" 참고). 수정 버전이 없는 차단(`affected`·`will_not_fix`)은 재빌드해도 그대로이므로 트리거가 아니다 — `no-fix` 로 따로 보고해 사람이 다른 레버(태그 교체·예외 승인)를 판단하게 한다. @@ -33,45 +29,25 @@ `scripts/pipeline/cve-gate.py` 의 함수를 그대로 가져다 쓴다. 규칙이 두 곳으로 갈리면 "게이트는 PASS 인데 재빌드하라고 한다" 가 나온다. -핀 산출은 `suggest-go-upgrades.py` 를 재사용한다(그것이 다시 게이트의 심각도 규칙을 쓴다). - 기준은 카탈로그가 실제로 가리키는 이미지다 ---------------------------------------- - images//catalog.env → 카탈로그 values 의 현재 ref - → 그 ref 의 스캔 리포트 - ↕ - .build.env 의 핀 + catalog/image-map/.env → 카탈로그 values 의 현재 ref + → 그 ref 의 스캔 리포트 재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다. -레포 분리 시 이 파일은 둘로 갈린다 --------------------------------- -커스텀 이미지가 별도 레포로 분리될 예정이고(`.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 가 핀을 판단한다. - -이 주석의 목적은 분리 작업을 **고고학이 아니라 기계적 작업**으로 만드는 것이다. 새 함수를 -추가할 때 어느 쪽에 속하는지 함께 적는다. +레포 분리 이후 — 이 스크립트는 절반이다 +---------------------------------------- +커스텀 이미지는 security-images(별도 레포)로 나갔다. "무엇을 배포 중인가"는 이 카탈로그가 +알고, "그 이미지를 어떻게 만드는가"(핀 판단·재빌드)는 security-images 가 안다. 이 스크립트는 +**탐지만** 한다 — 재빌드가 필요하다고 판단되면 `self-build-drift-check.yml` 이 +security-images 의 `build-image.yml` 을 `workflow_dispatch` 로 부른다. 핀을 무엇으로 +올릴지는 그 레포의 `suggest-go-upgrades.py --apply` 가 한다. 사용 ---- 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) @@ -88,7 +64,7 @@ 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") +IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map") DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json") # 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고 @@ -103,9 +79,8 @@ def load(path, name): 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() +GATE = load(os.path.join(HERE, "..", "pipeline", "cve-gate.py"), "cve_gate") def read_env(path): @@ -125,36 +100,18 @@ def read_env(path): def report_path(reports_dir, image_ref): - """이미지 ref → 리포트 파일 경로. scan-sbom.sh 가 `tr '/:@' '___'` 로 만든다.""" + """이미지 ref → 리포트 파일 경로. `trivy image ` 스캔 결과를 + `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): +def resolve_current_ref(chart_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" + chart_dirs = (chart_env.get("CHART_DIRS") or "").split() + style = chart_env.get("TAG_STYLE") or "" + block = chart_env.get("TAG_BLOCK") or "image" if not chart_dirs or style not in ("imageName", "split"): - return None, "catalog.env 에 CHART_DIRS/TAG_STYLE 이 없다" + return None, "catalog/image-map/.env 에 CHART_DIRS/TAG_STYLE 이 없다" for d in chart_dirs: for fname in VALUES_CANDIDATES: p = os.path.join(ROOT, d, fname) @@ -179,7 +136,10 @@ def blocking_cves(report, image_ref, rank, floor, exceptions): cve = v.get("VulnerabilityID") if not cve: continue - eff = SUGGEST.effective_severity(v, GATE, rank) + eff = GATE.effective_severity( + v.get("Severity"), + GATE.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")), + ) if rank.get(eff, 0) < floor: continue if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions): @@ -193,68 +153,11 @@ def blocking_cves(report, image_ref, rank, floor, exceptions): 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} + res = {"image": image, "status": "ok", "notes": [], "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) + chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env")) + ref, where = resolve_current_ref(chart_env) if not ref: res["status"] = "skip" res["notes"].append(where) @@ -279,8 +182,6 @@ def check_image(image, reports_dir, rank, floor, exceptions): if not cves: return res # ok - res["changes"], res["notes"] = pin_changes([rp], pins, rank, floor) - if fixable: res["status"] = "rebuild" else: @@ -290,26 +191,6 @@ def check_image(image, reports_dir, rank, floor, exceptions): 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"] @@ -319,16 +200,14 @@ def render_md(results): if not rebuild and not nofix: out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.") if rebuild: - out += [f"**{len(rebuild)}개 이미지가 재빌드로 고쳐진다.** 차단 CVE 에 수정 버전이 있다 " - "— 재빌드하면 베이스 패키지·툴체인이 최신으로 반영된다.", "", - "| 이미지 | 차단 | 수정 가능 | 패키지 | 함께 올릴 핀 |", - "|---|---:|---:|---|---|"] + out += [f"**{len(rebuild)}개 이미지가 재빌드 대상이다.** 차단 CVE 에 수정 버전이 있다 " + "— security-images 레포에서 재빌드하면 해소될 가능성이 높다. 핀 조정이 " + "필요한지는 그 레포의 `suggest-go-upgrades.py` 가 판단한다.", "", + "| 이미지 | 현재 ref | 차단 | 수정 가능 | 패키지 |", + "|---|---|---:|---:|---|"] 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} |") + out.append(f"| `{r['image']}` | `{r['ref']}` | {r['blocking']} | {r['fixable']} | {pkgs} |") if nofix: out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.", "", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"] @@ -349,14 +228,15 @@ def render_md(results): out += ["", ""] out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · " - "기본 HIGH 이상. 핀 제안은 **제안**이고 채택은 사람이 커밋한다.", ""] + "기본 HIGH 이상. 재빌드는 security-images 레포의 `build-image.yml` 을 " + "`workflow_dispatch` 로 부른다.", ""] 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("--image", default="", help="이 이미지만 판정 (catalog/image-map/.env)") ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS, help="승인 예외 목록 (기본 doc/cve-exceptions.json)") ap.add_argument("--min-severity", default="HIGH", @@ -368,14 +248,12 @@ def main(): "새지 않게 한다") 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) + if not os.path.isdir(IMAGE_MAP_DIR): + print(f"::error::catalog/image-map/ 를 찾지 못했다: {IMAGE_MAP_DIR}", file=sys.stderr) return 2 if not args.list_refs and not args.reports: print("::error::--reports 가 필요하다", file=sys.stderr) @@ -384,17 +262,16 @@ def main(): 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))) + names = sorted(f[:-4] for f in os.listdir(IMAGE_MAP_DIR) if f.endswith(".env")) 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) + print(f"::error::catalog/image-map/{args.image}.env 가 없다", 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"))) + ref, why = resolve_current_ref(read_env(os.path.join(IMAGE_MAP_DIR, f"{n}.env"))) if not ref: print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr) continue @@ -414,9 +291,8 @@ def main(): 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 "")) + print(f"::warning::{r['image']}: 재빌드 대상 — 차단 {r['blocking']}건" + f"(수정 가능 {r['fixable']})") elif r["status"] == "no-fix": print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — " "재빌드로 해소되지 않는다") @@ -434,13 +310,6 @@ def main(): 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 diff --git a/scripts/build/suggest-go-upgrades.py b/scripts/build/suggest-go-upgrades.py deleted file mode 100755 index 298e929..0000000 --- a/scripts/build/suggest-go-upgrades.py +++ /dev/null @@ -1,230 +0,0 @@ -#!/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 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()) diff --git a/scripts/deploy-test/deploy-test-cnpg-cluster.sh b/scripts/deploy-test/deploy-test-cnpg-cluster.sh index af8fe8c..c1fc104 100755 --- a/scripts/deploy-test/deploy-test-cnpg-cluster.sh +++ b/scripts/deploy-test/deploy-test-cnpg-cluster.sh @@ -24,7 +24,7 @@ # 무관한 변수가 늘어난다. 유일한 override 는 검증 대상인 이미지 자체뿐이다. # # 사용: -# IMAGE_NAME=docker.io/wbsong111/cnpg-postgresql: bash scripts/deploy-test/deploy-test-cnpg-cluster.sh +# IMAGE_NAME=docker.io/paasup/cnpg-postgresql: bash scripts/deploy-test/deploy-test-cnpg-cluster.sh # # 환경변수: # IMAGE_NAME (필수) 검증할 postgresql 이미지 전체 경로:태그 diff --git a/scripts/pipeline/cve-gate.py b/scripts/pipeline/cve-gate.py index 47daa0f..3e5a2cb 100755 --- a/scripts/pipeline/cve-gate.py +++ b/scripts/pipeline/cve-gate.py @@ -82,6 +82,16 @@ def nvd_severity(score): return "LOW" +def effective_severity(vendor_sev, nvd_sev): + """실효 등급 = max(벤더 등급, NVD 등급). 벤더의 하향 등급으로 게이트를 + 통과하는 것을 막는다. 이 게이트가 심각도 규칙의 단일 출처다 — + `check-rebuild-needed.py`(드리프트 탐지)와 자체 빌드 레포의 `image-gate.py` + 가 이 함수를 그대로 재사용한다.""" + vend = vendor_sev or "UNKNOWN" + nvd = nvd_sev or "UNKNOWN" + return vend if RANK.get(vend, 0) >= RANK.get(nvd, 0) else nvd + + def load_exceptions(path): """ 승인된 예외 목록. @@ -332,8 +342,7 @@ def evaluate(img, exceptions): for cve in by_cve.values(): vend = cve["vendor_sev"] nvd = cve["nvd_sev"] - # 실효 심각도 = 벤더와 NVD 중 높은 쪽. 벤더의 하향 등급으로 게이트를 통과하는 것을 막는다. - effective = vend if RANK.get(vend, 0) >= RANK.get(nvd or "UNKNOWN", 0) else nvd + effective = effective_severity(vend, nvd) cve["effective_sev"] = effective if nvd and RANK.get(nvd, 0) > RANK.get(vend, 0) and RANK.get(nvd, 0) >= RANK["HIGH"]: