add-keycloakx: keycloak 자체 빌드 하드닝 이미지 추가 (차단 CVE 17건 → 0건)

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) <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-07 13:49:42 +09:00
parent e1e3926430
commit f9a2d8400f
10 changed files with 802 additions and 17 deletions
+79 -11
View File
@@ -44,23 +44,91 @@ security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅
실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야 실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야
통과로 판정된다. 통과로 판정된다.
## 원칙 2 — 최종 런타임 베이스 OS 는 아직 미결 ## 원칙 2 — 최종 런타임 베이스 OS 는 SUSE BCI
security-catalog 는 자체 빌드 이미지의 최종 런타임 베이스로 SUSE BCI 하나만 쓰기로 **배포판은 결정됐다(2026-08-07, `keycloak` 이미지 추가 PR).** 그전까지는
결정했지만, 그 결정은 이 레포에 이식하지 않았다. **dip-catalog 는 베이스 OS 정책이 "security-catalog 의 SUSE BCI 단일화 결정을 이식하지 않았다" 는 미결 상태였다.
아직 없다** — 처음 자체 빌드 이미지를 추가할 때 정하고, 결정 배경을 남긴다(카탈로그 `keycloak` 은 업스트림이 UBI9 기반이라 "업스트림과 최대한 동일하게" 와 정면으로
전용 `doc/decisions/` 관례는 아직 없으므로 우선 해당 PR 설명과 `MEMORY.md`에 기록). 충돌했고, **카탈로그 내 일관성을 우선**했다 — 기존 3종이 전부 BCI 이고 trivy 의
SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다
(`doc/analysis/sles-oval-measurement.md`).
- "업스트림과 최대한 동일하게" 라는 기본 원칙과 특정 베이스 OS 채택이 충돌할 수 있다 - 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 **무엇을 우선했는지와 왜인지를
(예: 정적 링크 바이너리에 어떤 최소 이미지를 쓸지). 그 경우 무엇을 우선했는지와 왜인지 해당 이미지 README 에 남긴다.** 정책이 있다고 기록을 생략하지 않는다.
기록한다.
- 최소 이미지(distroless 류, BCI micro 류 등)는 `sed`/`grep` 같은 흔한 도구가 없을 수
있다 — `verify.sh` 게스트 스크립트는 그런 도구에 의존하지 말고 순수 셸 루프
(`while IFS= read -r line; do ...; done`)로 작성하는 편이 안전하다.
- 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — - 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 —
공식 언어 이미지(`golang` 등)를 그대로 써도 된다. 정책이 적용되는 것은 **스캔·배포 공식 언어 이미지(`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 이 하는 일 | 셸 유무 | | 유형 | Dockerfile 이 하는 일 | 셸 유무 |
+8 -1
View File
@@ -11,5 +11,12 @@
"예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /", "예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /",
"scripts/build/build-hardened-image.sh 로 자체 빌드. 예외는 마지막 수단이다." "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"
}
]
} }
+191
View File
@@ -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-<ver>.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 ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
+10
View File
@@ -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
+97
View File
@@ -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 <KEYCLOAK_HOME>}"
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
+110
View File
@@ -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" ]
+58
View File
@@ -0,0 +1,58 @@
# Keycloak 자체 빌드 — SUSE BCI 기반, 취약 jar 오버레이 포함
#
# APP_VERSION 이 곧 Keycloak 릴리스 버전이다. 태그는
# docker.io/paasup/keycloak:<APP_VERSION>-<TAG_SLUG>-hardened-<YYYYMMDD>
# 로 만들어진다 (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 오버레이 대상. <OLD> 는 keycloak $KEYCLOAK_VERSION 배포본이 실제로 담고 있는
# 버전이고(Quarkus 3.33.2.1 BOM 이 고정), <NEW> 는 차단 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"
+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env bash
# keycloak 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... <build.env 의 모든 변수> 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"
@@ -9,6 +9,7 @@
- **`http.relativePath: "/"` 를 지우거나 값을 바꾸지 말 것.** 이 차트의 기본값은 구버전 WildFly Keycloak 호환을 위한 `"/auth"`다. `"/"`로 명시하지 않으면 Quarkus 네이티브 경로 규칙과 달라져, OIDC issuer URL(`/realms/{realm}`)이나 admin REST API(`/admin/realms/...`)를 경로 접미사 없이 호출하는 소비 앱들의 연동이 조용히 깨진다. - **`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"]`를 유지한다. - **`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). - **`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) 배포 방법 ### 2) 배포 방법
@@ -24,7 +25,7 @@ helm upgrade keycloak ./ -f custom-values.yaml --install -n platform --create-na
| Name | 설명 | 기본값 | | 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 참조` | | `resources` | keycloak pod의 자원 설정. | `custom-values.yaml 참조` |
### 2) Postgresql 연동 설정 ### 2) Postgresql 연동 설정
@@ -119,3 +120,41 @@ proxy:
enabled: true enabled: true
mode: forwarded 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` 가 자동 갱신된 브랜치가 생성된다.
@@ -1,6 +1,6 @@
image: image:
repository: quay.io/keycloak/keycloak repository: docker.io/paasup/keycloak
tag: "26.6.4" tag: "26.7.1-bci15.7-hardened-20260807"
# keycloakx 기본값은 "/auth"(구 WildFly 기반 codecentric/keycloak 호환용). Keycloak # keycloakx 기본값은 "/auth"(구 WildFly 기반 codecentric/keycloak 호환용). Keycloak
# 26(Quarkus) 네이티브 기본값은 "/"이며, /auth 를 그대로 두면 OIDC issuer/admin API # 26(Quarkus) 네이티브 기본값은 "/"이며, /auth 를 그대로 두면 OIDC issuer/admin API
@@ -14,16 +14,22 @@ command:
- "/opt/keycloak/bin/kc.sh" - "/opt/keycloak/bin/kc.sh"
- "start" - "start"
# path 를 exact "/" 로 두면 apisix 에서 루트만 매치되어 Keycloak 의 실제 엔드포인트
# (/realms/*, /admin/*, /resources/*)가 전부 404 가 난다 — airflow·superset·mlflow·
# lakekeeper 에서 이미 실측된 문제로 카탈로그 전체가 regex 방식으로 통일돼 있다
# (.claude/pitfalls.md). ingressClassName 도 반드시 명시한다 — 미지정 시 이 클러스터의
# apisix 가 인식하지 않아 CLASS: <none> 으로 뜨고 접근 자체가 불가능하다.
ingress: ingress:
enabled: true enabled: true
ingressClassName: apisix ingressClassName: apisix
annotations: annotations:
cert-manager.io/cluster-issuer: "root-ca-issuer" cert-manager.io/cluster-issuer: "root-ca-issuer"
k8s.apisix.apache.org/use-regex: "true"
rules: rules:
- host: keycloak.example.org - host: keycloak.example.org
paths: paths:
- path: / - path: /.*
pathType: Prefix pathType: ImplementationSpecific
tls: tls:
- hosts: - hosts:
- keycloak.example.org - keycloak.example.org