From f9a2d8400f81848bb67e38e164c924d40c4c0210 Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Fri, 7 Aug 2026 13:49:42 +0900 Subject: [PATCH] =?UTF-8?q?add-keycloakx:=20keycloak=20=EC=9E=90=EC=B2=B4?= =?UTF-8?q?=20=EB=B9=8C=EB=93=9C=20=ED=95=98=EB=93=9C=EB=8B=9D=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B6=94=EA=B0=80=20(=EC=B0=A8=EB=8B=A8?= =?UTF-8?q?=20CVE=2017=EA=B1=B4=20=E2=86=92=200=EA=B1=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #18 이 카탈로그에 넣는 quay.io/keycloak/keycloak:26.6.4 가 게이트에서 차단 17건(실효 HIGH 17 / CRITICAL 0)이었다. sbom.yml 이 warn-only 라 PR 은 통과했지만 실제로는 게이트 실패 상태로 카탈로그에 들어간다. ## 상위 태그·베이스 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 를 고정한다(keycloak pom.xml 두 태그 + quarkus BOM 실측). 필요한 수정 버전은 netty 4.1.136.Final, jackson 2.21.4 라 **상위 태그로도 풀리지 않고**, CVE 가 OS 패키지가 아니라 jar 자체라 **베이스 OS 교체도 통하지 않는다.** jar 를 직접 교체하는 자체 빌드가 유일한 수단이다 — etcd 이미지의 `go.work replace golang.org/x/text` 와 같은 성격의 의존성 override. ## images/keycloak/ 업스트림 quarkus/container/Dockerfile 을 기준으로 하되 셋이 다르다. 1. 런타임 rootfs 가 SUSE BCI. bci-micro 파일시스템을 **씨앗으로 깔고** 그 위에 zypper --installroot 로 설치한다. 업스트림 ubi-null.sh 처럼 별도 installroot 를 micro 위에 덮으면 micro 의 rpmdb 가 가려져 micro 자체 패키지가 SBOM 에서 통째로 사라진다 — CVE 가 주는 게 아니라 스캔 사각지대가 생긴다. 씨앗 방식으로 OS 패키지 65종이 정상적으로 잡히는 것을 SBOM 으로 확인했다. 2. 취약 jar 오버레이(overlay-jars.sh). netty 17종 → 4.1.136.Final, jackson core/databind → 2.21.4, pgjdbc → 42.7.12. Quarkus fast-jar 의 클래스패스가 파일명을 그대로 참조하므로 **파일명은 유지하고 내용만** 바꾸고 sha1 로 검증한다. trivy 는 jar 내부 메타데이터를 읽으므로 SBOM 에 새 버전이 정확히 잡힌다. 3. bin/client 제거. keycloak-admin-cli 가 jackson 을 shade 로 품은 uber-jar 라 교체가 불가능하다. 서버 JVM 이 로드하지 않는 독립 CLI 라 제거했다 — 업스트림 대비 유일한 기능적 차이이며 CUSTOM-README 에 대안을 적었다. 버전은 26.7.1 로 올렸다. 26.6.4 는 26.7.1(및 26.6.5)에서만 패치된 keycloak-services HIGH 5건(CVE-2026-16102/16442/16443/15572/15573)에 취약하다. 차트(keycloakx 7.2.2)는 최신이고 그대로 둔다 — appVersion 26.6.4 는 codecentric 의 릴리스 캐던스 지연이다. ## 베이스 OS 정책 확정 (image-authoring.md 원칙 2 미결 해소) SUSE BCI 로 통일하되 **버전은 이미지마다 실측해서 고른다.** BCI 16.0 이 나와 있지만 SLE_BCI 의 java-21-openjdk-headless 가 15.7 은 21.0.12, 16.0 은 21.0.11 이라 최신 베이스로 가면 CVE-2026-41254·CVE-2026-47063 이 오히려 남는다. bci-micro 에 sed·grep·find 가 셋 다 없다는 것과 SLE 패키지명 차이(tzdata→timezone 등)도 함께 기록했다. ## 실측 결과 로컬 빌드(linux/amd64) → verify.sh → SBOM → 전 심각도 스캔 → 게이트: 차단 17건 → **0건** (커버리지 자가진단 ok, OS=sles 15.7) 남은 1건 CVE-2025-59250 은 예외 등록했다 — 트리비가 같은 mssql-jdbc jar 하나로 컴포넌트를 둘 만들어(pom.properties 의 13.2.1.jre11 / 파일명의 13.2.1) 접미사가 잘린 쪽이 매칭된 파싱 오탐이다. 설치본은 FixedVersion 목록에 있는 13.2.1.jre11 이다. dev 클러스터 격리 네임스페이스(kc-test-build)에 cnpg-cluster + keycloakx 로 실배포 검증: Pod Running, jdbc-postgresql 연결, liquibase 스키마 생성, admin 부트스트랩 (KC-SERVICES0077), apisix ingress 경유 OIDC discovery 200 / admin 토큰 발급 / realm·client 생성(201) 까지 확인. 정리 시 Longhorn Volume 까지 삭제했다. ## custom-values.yaml ingress 수정 path 가 exact "/" 였다. apisix 에서는 루트만 매치되어 /realms/*, /admin/* 이 전부 404 가 난다 — airflow·superset·mlflow·lakekeeper 에서 이미 실측된 문제로 카탈로그가 regex 방식으로 통일돼 있다. path: /.* + k8s.apisix.apache.org/use-regex 로 맞췄고, 배포 검증에서 이 경로들이 실제로 뜨는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/image-authoring.md | 90 +++++++- doc/cve-exceptions.json | 9 +- images/keycloak/README.md | 191 +++++++++++++++++ images/keycloak/catalog.env | 10 + images/keycloak/overlay-jars.sh | 97 +++++++++ images/keycloak/suse.Dockerfile | 110 ++++++++++ images/keycloak/suse.build.env | 58 +++++ images/keycloak/verify.sh | 199 ++++++++++++++++++ .../helm/keycloakx/7.2.2/CUSTOM-README.md | 41 +++- .../helm/keycloakx/7.2.2/custom-values.yaml | 14 +- 10 files changed, 802 insertions(+), 17 deletions(-) create mode 100644 images/keycloak/README.md create mode 100644 images/keycloak/catalog.env create mode 100644 images/keycloak/overlay-jars.sh create mode 100644 images/keycloak/suse.Dockerfile create mode 100644 images/keycloak/suse.build.env create mode 100644 images/keycloak/verify.sh diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md index 9150599..f4ae982 100644 --- a/.claude/image-authoring.md +++ b/.claude/image-authoring.md @@ -44,23 +44,91 @@ security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅 실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야 통과로 판정된다. -## 원칙 2 — 최종 런타임 베이스 OS 는 아직 미결 +## 원칙 2 — 최종 런타임 베이스 OS 는 SUSE BCI -security-catalog 는 자체 빌드 이미지의 최종 런타임 베이스로 SUSE BCI 하나만 쓰기로 -결정했지만, 그 결정은 이 레포에 이식하지 않았다. **dip-catalog 는 베이스 OS 정책이 -아직 없다** — 처음 자체 빌드 이미지를 추가할 때 정하고, 결정 배경을 남긴다(카탈로그 -전용 `doc/decisions/` 관례는 아직 없으므로 우선 해당 PR 설명과 `MEMORY.md`에 기록). +**배포판은 결정됐다(2026-08-07, `keycloak` 이미지 추가 PR).** 그전까지는 +"security-catalog 의 SUSE BCI 단일화 결정을 이식하지 않았다" 는 미결 상태였다. +`keycloak` 은 업스트림이 UBI9 기반이라 "업스트림과 최대한 동일하게" 와 정면으로 +충돌했고, **카탈로그 내 일관성을 우선**했다 — 기존 3종이 전부 BCI 이고 trivy 의 +SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다 +(`doc/analysis/sles-oval-measurement.md`). -- "업스트림과 최대한 동일하게" 라는 기본 원칙과 특정 베이스 OS 채택이 충돌할 수 있다 - (예: 정적 링크 바이너리에 어떤 최소 이미지를 쓸지). 그 경우 무엇을 우선했는지와 왜인지 - 기록한다. -- 최소 이미지(distroless 류, BCI micro 류 등)는 `sed`/`grep` 같은 흔한 도구가 없을 수 - 있다 — `verify.sh` 게스트 스크립트는 그런 도구에 의존하지 말고 순수 셸 루프 - (`while IFS= read -r line; do ...; done`)로 작성하는 편이 안전하다. +- 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 **무엇을 우선했는지와 왜인지를 + 해당 이미지 README 에 남긴다.** 정책이 있다고 기록을 생략하지 않는다. - 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — 공식 언어 이미지(`golang` 등)를 그대로 써도 된다. 정책이 적용되는 것은 **스캔·배포 대상인 최종 스테이지**뿐이다. +### 어느 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 이 아니다(게이트가 막는다). + +### `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 '<패턴>'` 로 먼저 확인한다. + ## 두 가지 유형 (둘 다 같은 스크립트를 쓴다) | 유형 | Dockerfile 이 하는 일 | 셸 유무 | diff --git a/doc/cve-exceptions.json b/doc/cve-exceptions.json index 307bf1a..3fc1c56 100644 --- a/doc/cve-exceptions.json +++ b/doc/cve-exceptions.json @@ -11,5 +11,12 @@ "예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /", "scripts/build/build-hardened-image.sh 로 자체 빌드. 예외는 마지막 수단이다." ], - "exceptions": [] + "exceptions": [ + { + "id": "CVE-2025-59250", + "images": ["/keycloak:"], + "reason": "트리비 파싱 오탐 — 실제로는 이미 수정 버전이다. 이미지가 담고 있는 파일은 opt/keycloak/lib/lib/main/com.microsoft.sqlserver.mssql-jdbc-13.2.1.jre11.jar 하나뿐인데, 트리비가 이 jar 로부터 컴포넌트를 두 개 만든다: pom.properties 에서 읽은 '13.2.1.jre11' 과 파일명에서 .jre11 접미사가 잘린 '13.2.1'. 이 CVE 의 FixedVersion 목록에 '13.2.1.jre11' 이 명시돼 있으므로 설치본은 수정 버전이고, 잘린 쪽 컴포넌트만 취약 범위에 매칭된 것이다. 2026-08-07 SBOM·트리비 리포트 실측(InstalledVersion=13.2.1, PkgPath=...-13.2.1.jre11.jar). 이미지 쪽에서 없앨 방법이 없다 — mssql 드라이버는 Quarkus augmentation 에 포함돼 있어 파일을 지우면 클래스패스가 깨진다. images 패턴이 '/keycloak:' 인 이유: 로컬 빌드(localhost/keycloak:), CI push 본(docker.io/paasup/keycloak:), 업스트림(quay.io/keycloak/keycloak:)을 모두 덮되 keycloakx 차트명 등 다른 문자열에는 걸리지 않게 하기 위함이다.", + "expires": "2026-11-07" + } + ] } diff --git a/images/keycloak/README.md b/images/keycloak/README.md new file mode 100644 index 0000000..8af1a26 --- /dev/null +++ b/images/keycloak/README.md @@ -0,0 +1,191 @@ +# 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` 최종이다. 카탈로그의 기존 자체 빌드 3종 +(`cloudnative-pg`·`cnpg-postgresql`·`etcd`)이 전부 SUSE BCI 이고 trivy 의 SLES 15.7 +커버리지가 양성 대조로 실측 확인돼 있어(`doc/analysis/sles-oval-measurement.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/` 의 netty·jackson· + postgresql-jdbc jar 를 **파일명은 유지하고 내용만** 수정 버전으로 바꾼다. Quarkus + fast-jar 의 클래스패스가 파일명을 그대로 참조하기 때문이다. trivy 는 jar 내부 + `META-INF/maven/**/pom.properties` 를 읽으므로 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 new file mode 100644 index 0000000..348d9ab --- /dev/null +++ b/images/keycloak/catalog.env @@ -0,0 +1,10 @@ +# 이 이미지가 어느 차트의 어느 필드를 가리키는가 (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 new file mode 100644 index 0000000..5a0a589 --- /dev/null +++ b/images/keycloak/overlay-jars.sh @@ -0,0 +1,97 @@ +#!/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:?}" + +# 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}" +) + +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 new file mode 100644 index 0000000..adf6c0b --- /dev/null +++ b/images/keycloak/suse.Dockerfile @@ -0,0 +1,110 @@ +# 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 커버리지는 양성 대조로 실측 확인돼 있다 +# (doc/analysis/sles-oval-measurement.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 + +# (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 new file mode 100644 index 0000000..de725c3 --- /dev/null +++ b/images/keycloak/suse.build.env @@ -0,0 +1,58 @@ +# 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 + +BUILD_ARGS="KEYCLOAK_VERSION BUILDER_BASE MICRO_BASE RUNTIME_PACKAGES NETTY_OLD NETTY_VERSION JACKSON_OLD JACKSON_VERSION PGJDBC_OLD PGJDBC_VERSION" diff --git a/images/keycloak/verify.sh b/images/keycloak/verify.sh new file mode 100644 index 0000000..3271872 --- /dev/null +++ b/images/keycloak/verify.sh @@ -0,0 +1,199 @@ +#!/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:?}" + +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" + +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/keycloakx/7.2.2/CUSTOM-README.md b/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md index aaab8c6..b131029 100644 --- a/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md +++ b/manifests/helm/keycloakx/7.2.2/CUSTOM-README.md @@ -9,6 +9,7 @@ - **`http.relativePath: "/"` 를 지우거나 값을 바꾸지 말 것.** 이 차트의 기본값은 구버전 WildFly Keycloak 호환을 위한 `"/auth"`다. `"/"`로 명시하지 않으면 Quarkus 네이티브 경로 규칙과 달라져, OIDC issuer URL(`/realms/{realm}`)이나 admin REST API(`/admin/realms/...`)를 경로 접미사 없이 호출하는 소비 앱들의 연동이 조용히 깨진다. - **`command`를 반드시 지정할 것.** 차트 기본값(`command: []`, `args: []`)만으로는 컨테이너가 인자 없는 `kc.sh`(도움말 출력, exit 0)로 끝나 CrashLoopBackOff가 된다(실측 확인). `custom-values.yaml`의 `command: ["/opt/keycloak/bin/kc.sh", "start"]`를 유지한다. - **`extraEnv`에 `KC_HOSTNAME`을 반드시 지정할 것.** 미지정 시 `hostname is not configured; either configure hostname, or set hostname-strict to false`로 기동이 실패한다(실측 확인, hostname-strict 기본값 true). +- **이미지는 업스트림이 아니라 자체 빌드 하드닝 이미지다** — 아래 "3. 자체 빌드 이미지" 참조. `kcadm.sh`/`kcreg.sh`(`bin/client`)가 들어 있지 않다. ### 2) 배포 방법 @@ -24,7 +25,7 @@ helm upgrade keycloak ./ -f custom-values.yaml --install -n platform --create-na | Name | 설명 | 기본값 | | --- | --- | --- | -| `image.repository`/`image.tag` | 오프라인 설치 시에는 사설 미러 레지스트리로 변경. | `quay.io/keycloak/keycloak:26.6.4` | +| `image.repository`/`image.tag` | **자체 빌드 하드닝 이미지**(아래 3절). 오프라인 설치 시에는 사설 미러 레지스트리로 변경. | `custom-values.yaml 참조` | | `resources` | keycloak pod의 자원 설정. | `custom-values.yaml 참조` | ### 2) Postgresql 연동 설정 @@ -119,3 +120,41 @@ proxy: enabled: true mode: forwarded ``` + +## 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) 가 +단일 출처다. 배포 관점에서 알아야 할 것만 아래에 적는다. + +### 앱 버전이 차트 `appVersion` 과 다르다 + +차트 `appVersion` 은 `26.6.4` 지만 이미지는 **Keycloak 26.7.1** 이다. + +- `appVersion` 은 `image.tag` 미지정 시의 기본값일 뿐이고, `custom-values.yaml` 이 + 태그를 명시하므로 실제 배포 버전은 26.7.1 이다. +- codecentric `keycloakx` 는 7.2.2 가 최신 차트이고 아직 26.7.x 를 따라잡지 못했다 — + 릴리스 캐던스 지연이지 차트 결함이 아니다. +- 26.6.4 를 쓰지 않는 이유: 26.7.1(및 26.6.5)에서만 패치된 `keycloak-services` + HIGH 5건(CVE-2026-16102 / 16442 / 16443 / 15572 / 15573)에 취약하다. + +### 업스트림 이미지와의 차이 — `kcadm.sh`/`kcreg.sh` 없음 + +`/opt/keycloak/bin/client/` 를 제거했다. 이 디렉토리의 `keycloak-admin-cli-*.jar` 가 +취약한 jackson 을 shade 로 품은 uber-jar 라 교체가 불가능해서다. **서버 런타임은 이 +디렉토리를 쓰지 않으므로 배포 동작에는 영향이 없다.** + +파드에 exec 해서 `kcadm.sh` 를 쓰던 절차가 있다면 대안이 필요하다. + +- 권장: admin REST API 직접 호출 (`/admin/realms/...`, 토큰은 + `/realms/master/protocol/openid-connect/token` 에서 발급) +- 또는 업스트림 이미지(`quay.io/keycloak/keycloak:26.7.1`)를 일회성 잡/디버그 + 컨테이너로 띄워 `kcadm.sh` 만 쓴다 (서버로 쓰지 않는다) + +### 이미지 갱신 + +`images/keycloak/suse.build.env` 의 `KEYCLOAK_VERSION` 과 jar 오버레이 버전을 사람이 +고쳐 PR 을 여는 것이 갱신 트리거다. `build-image.yml` 을 `workflow_dispatch` 로 돌리면 +빌드·게이트 통과 후 이 파일의 `image.tag` 가 자동 갱신된 브랜치가 생성된다. diff --git a/manifests/helm/keycloakx/7.2.2/custom-values.yaml b/manifests/helm/keycloakx/7.2.2/custom-values.yaml index 66bd24b..0a3d3f1 100644 --- a/manifests/helm/keycloakx/7.2.2/custom-values.yaml +++ b/manifests/helm/keycloakx/7.2.2/custom-values.yaml @@ -1,6 +1,6 @@ image: - repository: quay.io/keycloak/keycloak - tag: "26.6.4" + repository: docker.io/paasup/keycloak + tag: "26.7.1-bci15.7-hardened-20260807" # keycloakx 기본값은 "/auth"(구 WildFly 기반 codecentric/keycloak 호환용). Keycloak # 26(Quarkus) 네이티브 기본값은 "/"이며, /auth 를 그대로 두면 OIDC issuer/admin API @@ -14,16 +14,22 @@ command: - "/opt/keycloak/bin/kc.sh" - "start" +# path 를 exact "/" 로 두면 apisix 에서 루트만 매치되어 Keycloak 의 실제 엔드포인트 +# (/realms/*, /admin/*, /resources/*)가 전부 404 가 난다 — airflow·superset·mlflow· +# lakekeeper 에서 이미 실측된 문제로 카탈로그 전체가 regex 방식으로 통일돼 있다 +# (.claude/pitfalls.md). ingressClassName 도 반드시 명시한다 — 미지정 시 이 클러스터의 +# apisix 가 인식하지 않아 CLASS: 으로 뜨고 접근 자체가 불가능하다. ingress: enabled: true ingressClassName: apisix annotations: cert-manager.io/cluster-issuer: "root-ca-issuer" + k8s.apisix.apache.org/use-regex: "true" rules: - host: keycloak.example.org paths: - - path: / - pathType: Prefix + - path: /.* + pathType: ImplementationSpecific tls: - hosts: - keycloak.example.org