자체 빌드 이미지 프레임워크 도입 (실사용 이미지 없음)

security-catalog 에서 포팅: build-hardened-image.sh, patch-catalog-tag.py,
build-image.yml(REGISTRY_HOST=docker.io/paasup). images/ 는 아직 비어있다 —
베이스 OS 정책 미결 등은 .claude/image-authoring.md, MEMORY.md 참고.
This commit is contained in:
wbsong111
2026-08-03 09:55:52 +09:00
parent 20d7a41193
commit 844c567d0d
4 changed files with 727 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# =============================================================================
# build-hardened-image.sh
# 업스트림 이미지가 CRITICAL/HIGH 0건 목표를 만족하지 못할 때, 업스트림 Dockerfile 을
# 기준으로 베이스 OS 를 교체하고 보안 업데이트를 적용한 이미지를 빌드·검증한다.
#
# 빌드 → 기능 검증 → 취약점 스캔 → 게이트 판정 까지 한 번에 수행한다.
# 기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다 (0건이어도 못 쓰는 이미지는 무의미).
#
# 사용:
# IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh <OUT_DIR> [TAG]
# REGISTRY=docker.io/paasup IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 빌드 정의는 이 스크립트에 없다. images/<IMAGE>/<BASE_OS>.build.env 를 source 해서
# 베이스·버전·확장·build-arg 목록을 읽고, 기능 검증은 images/<IMAGE>/verify.sh 에 위임한다.
# (베이스 OS 가 둘 이상이 되면 하드코딩된 검증이 깨지기 때문이다 — 실제로 그렇게 됐었다)
#
# 이미지 종류(OS 패키지 설치형·소스 컴파일형 등)에 무관하게 이 스크립트 하나를 쓴다 —
# build.env 가 선언하는 것 이상을 이 스크립트가 알지 못하게 하는 게 원칙이다. build.env 가
# 요구하는 값은 APP_VERSION(태그·verify.sh 전달용) 하나뿐이고, 그 외 이미지별 변수는
# build.env 에 무엇을 적든 자동으로 verify.sh 의 환경변수로 전달된다(아래 참고).
#
# 환경변수:
# IMAGE 이미지 디렉토리명 (필수 — images/<IMAGE>/. 기본값 없음: 아직 도입된
# 자체 빌드 이미지가 없어 어떤 기본값도 실재하지 않는 이미지를 가리킨다)
# BASE_OS 빌드 변종 파일명 (필수 — images/<IMAGE>/<BASE_OS>.build.env. 베이스
# OS 정책은 아직 미결이다 — 처음 도입하는 이미지에서 정한다, MEMORY.md 참고)
# PLATFORM 빌드 플랫폼 (기본 linux/amd64)
# REGISTRY 푸시할 레지스트리 (미설정 시 푸시 생략. TAG 도 여기서 유도된다)
# IMAGE_REPO 레지스트리 내 저장소명 (기본 $IMAGE)
# SEVERITY 스캔 심각도 (기본 전 심각도 — 필터하면 목표 판정이 불가능해진다)
# CROSSREF 교차 검증용 참조 리포트 (선택, cve-gate.py 로 전달)
# build.env 의 값은 동일 이름 환경변수로 덮어쓸 수 있다 (예: APP_VERSION=18.5)
#
# 산출물 (OUT_DIR):
# build.log 빌드 로그
# verify.log 기능 검증 로그
# sbom/<tag>.cdx.json CycloneDX SBOM
# trivy-reports/<tag>.json 전 심각도 스캔 결과 (+ CoverageProbe)
# cve-gate.md 게이트 판정 요약
# =============================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PIPELINE_DIR="$REPO_ROOT/scripts/pipeline"
OUT_DIR="${1:?사용법: build-hardened-image.sh <OUT_DIR> [TAG]}"
# 기본값을 두지 않는다 — 도입된 자체 빌드 이미지가 아직 없어 어떤 기본값을 골라도
# 실재하지 않는 images/<IMAGE>/ 를 가리키게 된다. 반드시 명시적으로 지정한다.
IMAGE="${IMAGE:?IMAGE 환경변수 필수 — images/<IMAGE>/ 디렉토리명}"
BASE_OS="${BASE_OS:?BASE_OS 환경변수 필수 — images/<IMAGE>/<BASE_OS>.build.env 파일명}"
IMAGE_DIR="$REPO_ROOT/images/$IMAGE"
ENV_FILE="$IMAGE_DIR/$BASE_OS.build.env"
[ -d "$IMAGE_DIR" ] || { echo "::error::이미지 디렉토리 없음: $IMAGE_DIR"; exit 2; }
[ -f "$ENV_FILE" ] || { echo "::error::build.env 없음: $ENV_FILE"; exit 2; }
# build.env 는 **기본값**이다. 이미 설정된 환경변수를 덮어쓰지 않는다.
# (`. "$ENV_FILE"` 로 그냥 source 하면 외부 지정이 무시된다)
# 읽은 변수명을 ENV_FILE_VARS 에 함께 기록한다 — 기능 검증 단계에서 이미지별로 어떤
# 값이 필요한지 이 스크립트가 몰라도 되게, build.env 에 적힌 것을 통째로 verify.sh 에
# 환경변수로 넘기기 위함이다.
ENV_FILE_VARS=()
while IFS= read -r line; do
case "$line" in ''|'#'*|[[:space:]]*) continue ;; esac
name="${line%%=*}"
case "$name" in ''|*[!A-Za-z0-9_]*) continue ;; esac
ENV_FILE_VARS+=("$name")
if [ -z "${!name+set}" ]; then
eval "$line"
else
echo " (외부 지정 우선: $name=${!name})"
fi
done < "$ENV_FILE"
PLATFORM="${PLATFORM:-linux/amd64}"
DOCKERFILE="$IMAGE_DIR/${DOCKERFILE:?build.env 에 DOCKERFILE 이 없다}"
TARGET="${TARGET:-patched}"
: "${BUILD_ARGS:?build.env 에 BUILD_ARGS 가 없다}"
# APP_VERSION 이 유일한 이미지 종류 무관 필수값이다 — 태그 프리픽스와 verify.sh 양쪽에
# 쓰인다. PG_MAJOR 처럼 이미지별로만 의미 있는 값은 여기서 요구하지 않는다(ENV_FILE_VARS
# 전달로 충분하다).
: "${APP_VERSION:?build.env 에 APP_VERSION 이 없다}"
# 태그에 빌드일을 넣는다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르다.
# 태그를 REGISTRY 에서 유도한다. 예전에는 TAG 기본값이 REGISTRY 와 무관해서, push 하는
# 곳과 태그가 가리키는 곳이 달랐다 (기본 paasup.io/... 인데 실제로는 docker.io/... 로 push).
BUILD_DATE="$(date -u +%Y%m%d)"
APP_VER="$APP_VERSION"
IMAGE_REPO="${IMAGE_REPO:-$IMAGE}"
DEFAULT_TAG="${APP_VER}-${TAG_SLUG:-$BASE_OS}-hardened-${BUILD_DATE}"
if [ -n "${REGISTRY:-}" ]; then
TAG="${2:-${REGISTRY%/}/${IMAGE_REPO}:${DEFAULT_TAG}}"
else
# push 하지 않는 로컬 빌드. 레지스트리 없는 이름이면 push 를 시도할 수도 없다.
TAG="${2:-localhost/${IMAGE_REPO}:${DEFAULT_TAG}}"
fi
mkdir -p "$OUT_DIR/sbom"
STEM="$(echo "$TAG" | tr ':/' '__')"
# build.env 가 선언한 이름만 --build-arg 로 넘긴다. 베이스 OS 마다 인자 집합이 다르다
# (deb 계열: EXTENSIONS/STANDARD_ADDITIONAL_… / suse: SLE_REPO/PGDG_KEY).
BA=()
for name in $BUILD_ARGS; do
BA+=(--build-arg "$name=${!name-}")
done
echo "== 빌드 =="
echo " image=$IMAGE base_os=$BASE_OS target=$TARGET platform=$PLATFORM"
echo " dockerfile=${DOCKERFILE#$REPO_ROOT/}"
echo " tag=$TAG"
# --pull 을 명시한다. 없으면 러너에 남은 로컬 캐시를 쓸 수 있어, 스케줄 재빌드가
# 전제하는 "베이스 이미지를 매번 새로 받는다" 가 조용히 깨진다.
if ! docker build --pull --platform "$PLATFORM" -f "$DOCKERFILE" --target "$TARGET" \
"${BA[@]}" -t "$TAG" "$IMAGE_DIR" > "$OUT_DIR/build.log" 2>&1; then
echo "::error::빌드 실패 — $OUT_DIR/build.log 확인"; tail -20 "$OUT_DIR/build.log"; exit 1
fi
echo " OK"
echo "== 기능 검증 =="
# 검증 항목은 이미지 디렉토리가 소유한다. 여기에 하드코딩하면 변종이 늘 때 깨진다.
#
# verify.sh 는 **호스트에서 bash 로 실행**되고, 자신이 필요한 docker run 을 직접 호출한다
# (게스트 셸에 stdin 으로 스크립트를 주입하는 방식이 아니다). distroless 최종 이미지처럼
# 셸이 아예 없는 이미지도 있기 때문이다(cloudnative-pg — `--entrypoint sh` 로 들어갈 방법이
# 없다) — 호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로
# 게스트 셸을 여전히 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로
# 직접 실행할 수 있어 상위 호환이다.
VERIFY_SH="$IMAGE_DIR/verify.sh"
[ -f "$VERIFY_SH" ] || { echo "::error::verify.sh 없음: $VERIFY_SH"; exit 2; }
# build.env 에서 읽은 변수를 전부 환경변수로 넘긴다 — verify.sh 가 무엇을 필요로 하는지
# 이 스크립트가 알 필요가 없어진다. TAG/PLATFORM 은 검증 대상·실행 플랫폼으로 항상 넘긴다.
VERIFY_ENV_ASSIGN=(TAG="$TAG" PLATFORM="$PLATFORM")
for name in "${ENV_FILE_VARS[@]}"; do
VERIFY_ENV_ASSIGN+=("$name=${!name-}")
done
if ! env "${VERIFY_ENV_ASSIGN[@]}" bash "$VERIFY_SH" > "$OUT_DIR/verify.log" 2>&1 \
|| ! grep -q VERIFY-OK "$OUT_DIR/verify.log"; then
echo "::error::기능 검증 실패 — $OUT_DIR/verify.log 확인"; cat "$OUT_DIR/verify.log"; exit 1
fi
sed -n '1,40p' "$OUT_DIR/verify.log" | sed 's/^/ /'
grep -q 'WARN:' "$OUT_DIR/verify.log" && echo " (경고 있음 — verify.log 확인)"
echo "== SBOM =="
docker save "$TAG" -o "$OUT_DIR/image.tar" 2>/dev/null
trivy image --quiet --format cyclonedx --input "$OUT_DIR/image.tar" \
> "$OUT_DIR/sbom/${STEM}.cdx.json" 2>/dev/null
rm -f "$OUT_DIR/image.tar"
echo " 컴포넌트: $(python3 -c "import json;print(len(json.load(open('$OUT_DIR/sbom/${STEM}.cdx.json')).get('components') or []))" 2>/dev/null || echo '?')"
# 인덱스는 스캔의 입력이다: chart⇥version⇥image⇥status⇥?⇥sbom파일
printf 'hardened\t%s\t%s\tOK\t0\t%s.cdx.json\n' "$APP_VERSION" "$TAG" "$STEM" > "$OUT_DIR/sbom-index.tsv"
echo "== 스캔 (전 심각도 + 커버리지 자가진단) =="
# scan-sbom.sh 를 재사용한다. 예전에는 여기서 `trivy image` 를 직접 불렀는데, 그러면
# 리포트에 CoverageProbe 가 없어 게이트가 "데이터 커버리지 이상" 으로 실패한다.
# SLES 기반 이미지는 전 심각도 0건이라 **정상 이미지가 반드시 FAIL 했다.**
# 스캔 로직이 두 곳에 사는 것 자체가 원인이었으므로 한 곳으로 모은다.
#
# 심각도로 필터하지 않는다. 벤더가 낮게 등급한 항목까지 받아야 NVD 기준 재평가가 가능하다.
SEVERITY="${SEVERITY:-UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL}" \
bash "$PIPELINE_DIR/scan-sbom.sh" "$OUT_DIR" >/dev/null || {
echo "::error::스캔 실패 — $OUT_DIR/trivy-run.log 확인"; exit 1; }
grep 'cov=' "$OUT_DIR/trivy-run.log" 2>/dev/null | sed 's/^/ /'
echo "== 게이트 판정 =="
GATE_ARGS=(--reports "$OUT_DIR/trivy-reports" --index "$OUT_DIR/sbom-index.tsv"
--sbom-dir "$OUT_DIR/sbom" --summary-md "$OUT_DIR/cve-gate.md")
[ -f "${CROSSREF:-}" ] && GATE_ARGS+=(--crossref "$CROSSREF")
[ -f "$REPO_ROOT/doc/cve-exceptions.json" ] && GATE_ARGS+=(--exceptions "$REPO_ROOT/doc/cve-exceptions.json")
python3 "$PIPELINE_DIR/cve-gate.py" "${GATE_ARGS[@]}" >/dev/null
RC=$?
if [ -n "${REGISTRY:-}" ] && [ "$RC" -eq 0 ]; then
echo "== 푸시 =="
docker push "$TAG" >/dev/null 2>&1 && echo " $TAG" || echo "::warning::푸시 실패"
fi
echo
echo "== 결과 =="
echo " 게이트: $([ "$RC" -eq 0 ] && echo PASS || echo FAIL) 요약: $OUT_DIR/cve-gate.md"
exit "$RC"