f9a2d8400f
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>
200 lines
9.1 KiB
Bash
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"
|