4e12e38293
CI(31148639764)의 trivy 취약점 DB 가 로컬보다 최신이라 로컬 스캔에는 없던
CVE-2026-40983·CVE-2026-40984(io.micrometer:micrometer-core)가 차단으로 잡혔다.
netty·jackson 과 동일한 성격의 번들 jar CVE 라 같은 오버레이 메커니즘으로 처리한다.
micrometer 1.16.3 → 1.16.6. netty 와 같은 이유로 CVE 가 붙은 micrometer-core 만이
아니라 패밀리 전체 4종(commons·core·observation·registry-prometheus-simpleclient)을
함께 올린다 — 아티팩트 간 버전 혼용이 비지원이다.
Dockerfile 의 ARG 선언을 처음에 빠뜨렸는데 overlay-jars.sh 의 `: "${VAR:?}"` 가드가
설계대로 빌드를 세웠다("parameter null or not set"). 조용히 통과하지 않는다.
재빌드 결과(로컬 trivy DB 캐시를 지우고 CI 와 같은 최신 DB 로 재판정):
차단 0건 — 게이트 PASS
docker.io/paasup/keycloak:26.7.1-bci15.7-hardened-20260807 push 완료
태그는 빌드일 기준이라 같은 날 재빌드가 같은 태그를 덮는다(프레임워크 설계대로).
직전에 push 된 것은 micrometer 취약점이 남아 있던 빌드이고 어디에도 소비되지 않았다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
9.2 KiB
Bash
Executable File
202 lines
9.2 KiB
Bash
Executable File
#!/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:?}"
|
|
MICROMETER_OLD="${MICROMETER_OLD:?}" ; MICROMETER_VERSION="${MICROMETER_VERSION:?}"
|
|
|
|
WORK="$(mktemp -d)"
|
|
CID=""
|
|
CCID=""
|
|
cleanup() {
|
|
[ -n "$CID" ] && docker rm -f "$CID" >/dev/null 2>&1 || true
|
|
[ -n "$CCID" ] && docker rm -f "$CCID" >/dev/null 2>&1 || true
|
|
rm -rf "$WORK"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
LIBDIR=/opt/keycloak/lib/lib/main
|
|
|
|
# ------------------------------------------------------------------ 1~2, 4단계
|
|
docker run --rm -i --platform "$PLATFORM" -e TZ=Asia/Seoul \
|
|
-e NETTY_OLD="$NETTY_OLD" --entrypoint sh "$TAG" <<'GUEST'
|
|
set -e
|
|
LIB=/opt/keycloak/lib/lib/main
|
|
|
|
echo "== kc.sh 가 쓰는 셸 도구 =="
|
|
for b in sh bash sed grep readlink dirname uname java; do
|
|
p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; }
|
|
echo " $b -> $p"
|
|
done
|
|
|
|
echo "== java 21 =="
|
|
VER="$(java -version 2>&1 | head -1)"
|
|
echo " $VER"
|
|
case "$VER" in
|
|
*'"21'*) ;;
|
|
*) echo "FAIL: java 21 이 아니다"; exit 1 ;;
|
|
esac
|
|
|
|
echo "== 로케일 / 타임존 =="
|
|
CHARMAP="$(locale charmap 2>/dev/null || echo '?')"
|
|
echo " LANG=$LANG charmap=$CHARMAP"
|
|
[ "$CHARMAP" = "UTF-8" ] || { echo "FAIL: en_US.UTF-8 로케일이 없다 (glibc-locale-base 확인)"; exit 1; }
|
|
TZNAME="$(date +%Z)"
|
|
echo " TZ=Asia/Seoul -> $TZNAME"
|
|
[ "$TZNAME" = "KST" ] || { echo "FAIL: tzdata 가 Asia/Seoul 을 모른다 (got: $TZNAME)"; exit 1; }
|
|
|
|
echo "== bin/client 제거 확인 =="
|
|
# jackson 을 shade 로 품은 uber-jar 라 jar 교체로 못 고쳐 통째로 제거했다.
|
|
if [ -e /opt/keycloak/bin/client ]; then
|
|
echo "FAIL: bin/client 가 남아 있다 — overlay-jars.sh 의 제거 단계가 동작하지 않았다"; exit 1
|
|
fi
|
|
echo " 없음 (정상)"
|
|
|
|
echo "== 배포본 레이아웃 =="
|
|
[ -d "$LIB" ] || { echo "FAIL: $LIB 없음"; exit 1; }
|
|
n=0; for f in "$LIB"/io.netty.*-"$NETTY_OLD"*.jar; do [ -e "$f" ] && n=$((n+1)); done
|
|
echo " netty jar 파일 ${n}개 (파일명은 유지되는 것이 정상 — 내용 검증은 호스트에서)"
|
|
[ "$n" -gt 0 ] || { echo "FAIL: netty jar 가 없다"; exit 1; }
|
|
GUEST
|
|
|
|
# ------------------------------------------------------------------ 3단계
|
|
# 파일명을 유지하는 설계라 파일명으로는 교체 여부를 알 수 없다. jar 를 호스트로 꺼내
|
|
# 내부 pom.properties 를 읽는다 — trivy 가 버전을 판정하는 것과 같은 근거다.
|
|
# (게스트에 unzip 을 넣지 않기 위해 호스트 python3 로 검사한다. build-hardened-image.sh
|
|
# 가 이미 python3 를 전제한다.)
|
|
echo "== 오버레이 jar 내용 검증 (jar 내부 메타데이터) =="
|
|
CCID="$(docker create --platform "$PLATFORM" "$TAG")"
|
|
|
|
check_jar() { # $1=컨테이너 내 파일명 $2=기대 버전
|
|
local name="$1" want="$2" got
|
|
docker cp "$CCID:$LIBDIR/$name" "$WORK/$name" >/dev/null
|
|
got="$(python3 - "$WORK/$name" <<'PY'
|
|
import sys, zipfile, re
|
|
# 1순위: META-INF/maven/**/pom.properties (netty·jackson 등 대부분)
|
|
# 2순위: MANIFEST.MF 의 Bundle-Version / Implementation-Version
|
|
# (pgjdbc 는 pom.properties 를 넣지 않고 OSGi Bundle-Version 만 쓴다 — 실측)
|
|
with zipfile.ZipFile(sys.argv[1]) as z:
|
|
for n in z.namelist():
|
|
if re.fullmatch(r'META-INF/maven/[^/]+/[^/]+/pom\.properties', n):
|
|
for line in z.read(n).decode().splitlines():
|
|
if line.startswith('version='):
|
|
print(line.split('=', 1)[1].strip()); sys.exit(0)
|
|
try:
|
|
mf = z.read('META-INF/MANIFEST.MF').decode('utf-8', 'replace')
|
|
except KeyError:
|
|
print('NOT-FOUND'); sys.exit(0)
|
|
# MANIFEST 는 72바이트에서 줄바꿈 후 다음 줄을 한 칸 들여쓰기로 이어붙인다
|
|
mf = mf.replace('\r\n', '\n').replace('\n ', '')
|
|
for key in ('Bundle-Version', 'Implementation-Version'):
|
|
m = re.search(rf'^{key}:\s*(\S+)\s*$', mf, re.M)
|
|
if m:
|
|
print(m.group(1)); sys.exit(0)
|
|
print('NOT-FOUND')
|
|
PY
|
|
)"
|
|
if [ "$got" != "$want" ]; then
|
|
echo "FAIL: $name 의 내부 버전이 '$got' 다 (기대 '$want') — 오버레이가 적용되지 않았다"
|
|
exit 1
|
|
fi
|
|
echo " $name -> 내장 버전=$got"
|
|
}
|
|
|
|
check_jar "io.netty.netty-codec-http-${NETTY_OLD}.jar" "$NETTY_VERSION"
|
|
check_jar "io.netty.netty-codec-${NETTY_OLD}.jar" "$NETTY_VERSION"
|
|
check_jar "com.fasterxml.jackson.core.jackson-databind-${JACKSON_OLD}.jar" "$JACKSON_VERSION"
|
|
check_jar "com.fasterxml.jackson.core.jackson-core-${JACKSON_OLD}.jar" "$JACKSON_VERSION"
|
|
check_jar "org.postgresql.postgresql-${PGJDBC_OLD}.jar" "$PGJDBC_VERSION"
|
|
check_jar "io.micrometer.micrometer-core-${MICROMETER_OLD}.jar" "$MICROMETER_VERSION"
|
|
|
|
docker rm -f "$CCID" >/dev/null 2>&1 || true
|
|
CCID=""
|
|
|
|
# ------------------------------------------------------------------ 5단계
|
|
echo "== 실기동 (start-dev, 내장 dev-file DB) =="
|
|
ADMIN_USER="verify-admin"
|
|
ADMIN_PASS="verify-$$-$RANDOM"
|
|
|
|
# 호스트 포트는 커널이 고르게 한다(CI 러너에서 고정 포트 충돌을 피한다).
|
|
CID="$(docker run -d --platform "$PLATFORM" \
|
|
-p 127.0.0.1::8080 \
|
|
-e KC_BOOTSTRAP_ADMIN_USERNAME="$ADMIN_USER" \
|
|
-e KC_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASS" \
|
|
-e TZ=Asia/Seoul \
|
|
"$TAG" start-dev)"
|
|
HOSTPORT="$(docker port "$CID" 8080/tcp | head -1 | rev | cut -d: -f1 | rev)"
|
|
BASE="http://127.0.0.1:${HOSTPORT}"
|
|
echo " container=${CID:0:12} base=$BASE"
|
|
|
|
# 기본값이 넉넉한 이유: arm64 호스트에서 linux/amd64 를 QEMU 로 돌리면 Quarkus
|
|
# augmentation 만 ~100초, 기동 전체가 ~300초를 넘는다(2026-08-07 실측 — 180초로는
|
|
# liquibase 스키마 생성 중에 잘렸다). 네이티브 amd64 러너에서는 1~2분이면 끝나고,
|
|
# 루프는 뜨는 즉시 빠져나오므로 큰 값이 느려지는 비용은 없다.
|
|
BOOT_TIMEOUT="${VERIFY_BOOT_TIMEOUT:-600}"
|
|
echo "== master realm 기동 대기 (최대 ${BOOT_TIMEOUT}s) =="
|
|
ok=0
|
|
for i in $(seq 1 "$BOOT_TIMEOUT"); do
|
|
if curl -fsS "$BASE/realms/master/.well-known/openid-configuration" >"$WORK/disco.json" 2>/dev/null; then
|
|
ok=1; echo " ${i}s 만에 응답"; break
|
|
fi
|
|
if [ -z "$(docker ps -q --filter "id=$CID")" ]; then
|
|
echo "FAIL: 컨테이너가 죽었다"; docker logs "$CID" 2>&1 | tail -40; exit 1
|
|
fi
|
|
# 30초마다 어디까지 갔는지 남긴다 — 느린 것과 멈춘 것을 로그로 구분하기 위함이다.
|
|
if [ $((i % 30)) -eq 0 ]; then
|
|
echo " ...${i}s: $(docker logs "$CID" 2>&1 | tail -1 | cut -c1-140)"
|
|
fi
|
|
sleep 1
|
|
done
|
|
if [ "$ok" != 1 ]; then
|
|
echo "FAIL: ${BOOT_TIMEOUT}초 내에 OIDC discovery 가 응답하지 않았다"
|
|
docker logs "$CID" 2>&1 | tail -60
|
|
exit 1
|
|
fi
|
|
ISSUER="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["issuer"])' "$WORK/disco.json")"
|
|
echo " issuer=$ISSUER"
|
|
|
|
echo "== admin 토큰 발급 (부트스트랩 계정 + DB 마이그레이션 확인) =="
|
|
curl -fsS -X POST "$BASE/realms/master/protocol/openid-connect/token" \
|
|
-d "client_id=admin-cli" -d "grant_type=password" \
|
|
-d "username=$ADMIN_USER" --data-urlencode "password=$ADMIN_PASS" \
|
|
> "$WORK/token.json"
|
|
python3 - "$WORK/token.json" <<'PY'
|
|
import json, sys
|
|
tok = json.load(open(sys.argv[1]))
|
|
assert tok.get("access_token"), f"access_token 없음: {tok}"
|
|
print(f" access_token 발급 OK (expires_in={tok.get('expires_in')}s)")
|
|
PY
|
|
|
|
echo "== admin REST API 호출 (realms 목록) =="
|
|
AT="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1]))["access_token"])' "$WORK/token.json")"
|
|
curl -fsS -H "Authorization: Bearer $AT" "$BASE/admin/realms" > "$WORK/realms.json"
|
|
python3 - "$WORK/realms.json" <<'PY'
|
|
import json, sys
|
|
realms = [r["realm"] for r in json.load(open(sys.argv[1]))]
|
|
assert "master" in realms, f"master realm 없음: {realms}"
|
|
print(f" realms={realms}")
|
|
PY
|
|
|
|
# netty/jackson 오버레이가 실제 HTTP 스택에서 동작했다는 증거 — 위 요청들이 전부
|
|
# Quarkus(netty) 위에서 처리되고 jackson 으로 직렬화된 JSON 이다.
|
|
echo "VERIFY-OK"
|