Files
service-catalog/images/keycloak/verify.sh
T
wbsong111 f9a2d8400f 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>
2026-08-07 13:49:42 +09:00

200 lines
9.1 KiB
Bash

#!/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"