Files
service-catalog/.claude/image-authoring.md
T
wbsong111 4f67f63f69 SBOM 파이프라인 문서 정비 + 이미지 목록 열거 제거
## doc/sbom-pipeline.md — 중복·모순 정리 (328 → 286줄)

같은 사실이 여러 절에 흩어져 있었고, 일부는 문서가 아니라 변경 이력이었다.

- `SEVERITY` 를 전 심각도로 덮어써야 하는 이유가 환경변수 절·CI 절·요약 절 3곳에
  있었다. 스크립트 절의 blockquote 하나로 합쳤다 — "게이트를 돌릴 거라면 전 심각도로
  스캔해야 한다"가 핵심이고 나머지는 그 결과다.
- Job Summary 1MB 제한이 CI 절과 결과 확인 절에 중복됐다. CI 절 하나로 합쳤다.
- `--warn-only` 서술이 mermaid 라벨·CI 절·게이트 절·트리아지 절 4곳에 있었다.
  게이트 절 하나로 합치고, `build-image.yml` 쪽은 이미 강제라는 대비를 함께 적었다.
- 자체 빌드 트리거 표가 "PR 은 push 안 함"을 말하는데 바로 아래 불릿이 같은 말을
  반복했다. 표는 그대로 두고 불릿은 **왜** 그런지(REGISTRY 미전달 → localhost 태그라
  push 를 시도할 수조차 없다)만 남겼다.
- "오해를 주던 단일 '총 소요'는 제거" 같은 변경 이력 서술을 걷어냈다. 문서는 현재
  상태를 적는 곳이다.
- "첫 전체 실행 결과(2026-07-08)" 절은 수치를 싣고 바로 아래에서 "현재 수치가
  아니다"로 무효화하는 구조였다. 절 자체를 없애고, 거기서 유일하게 쓸모 있던 사실
  (SBOM 생성 실패는 대부분 사설/미인증 레지스트리이거나 대용량 timeout)만 스크립트
  절로 옮겼다.
- "실행 이력(2026-08-04)" 절은 MEMORY.md 와 중복이라 제거했다. 거기서만 알 수 있던
  사실(Actions 시크릿의 push 권한 확인)은 GitHub 설정 표에 반영했다.
- `CVE_API_KEY` 가 본문에만 있고 GitHub 설정 표에 빠져 있어 추가했다.
- 아키텍처 절 불릿이 mermaid 서브그래프 라벨과 같은 말을 하고 있어, "pull 을 ② 한
  곳에 몰아둔 것이 핵심"이라는 결론 한 문장으로 줄였다.

## 이미지 목록을 문서에 박아두지 않는다

이미지는 계속 추가되므로 열거하면 추가할 때마다 낡는다. `images/` 디렉토리를 단일
출처로 삼고 CLAUDE.md·image-authoring.md·build-image.yml·sbom-pipeline.md 의 열거를
걷어냈다. keycloak README 의 베이스 OS 결정 근거도 "기존 3종" 대신 "먼저 들어온
이미지들"로 바꿨다 — 근거의 내용은 그대로다.

## 현황 서술 정정

- build-image.yml 주석이 "아직 도입된 자체 빌드 이미지가 없다(images/ 가 비어 있음)"
  로 남아 있었다. 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지
  실제로 검증된 상태다.
- MEMORY.md: cve-exceptions.json 첫 예외 등록, 베이스 OS 정책 확정, PR 자동 생성이
  조직 정책으로 불가하다는 실측(run 30882785612)을 반영했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:27:03 +09:00

11 KiB

자체 빌드 이미지 작업 규칙

CLAUDE.md 에서 분리했다. 새 자체 빌드 이미지를 추가하거나(CVE 게이트 대응 우선순위 중 "자체 빌드") 기존 이미지의 빌드 정의를 바꿀 때만 참고한다.

security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅했다. 자체 빌드 이미지는 images/<image>/ 에 있고(목록은 그 디렉토리가 단일 출처) docker.io/paasup 에 push 되어 카탈로그 values 가 그 태그를 참조한다. 아래는 프레임워크가 어떻게 동작하는지와, 이미지를 추가·변경할 때 지켜야 할 규칙이다.

원칙 1 — 오케스트레이션은 항상 하나, 이미지 종류는 몰라도 된다

scripts/build/build-hardened-image.sh 하나가 모든 자체 빌드 이미지를 빌드한다. 이미지가 OS 패키지를 재설치하는 것이든, 소스를 직접 컴파일하는 것이든 스크립트는 같다 — 차이는 전부 images/<image>/ 안에 있다.

새 오케스트레이션 스크립트를 만드는 것은 최후의 수단이다. "이 이미지는 성격이 다르다"는 이유만으로 새 스크립트를 만들지 않는다. 절차(빌드 → 기능검증 → SBOM → 스캔 → 게이트 → push)는 이미지 종류와 무관하게 동일하고, 차이는 Dockerfile 내부(무엇을 어떻게 설치·컴파일하는가)에만 있어야 한다.

build-hardened-image.sh 가 요구하는 계약

images/<image>/<variant>.build.env 가 다음을 선언하면 스크립트는 이미지 종류를 몰라도 된다:

의미
DOCKERFILE images/<image>/ 기준 상대 경로
TARGET docker build --target 에 넘길 스테이지명
BUILD_ARGS 공백 구분 변수명 목록. 여기 나열한 것만 --build-arg 로 전달된다
APP_VERSION 태그 프리픽스·verify.sh 전달용 범용 버전 문자열 (유일한 필수값)

그 외 이미지별 변수(예: PG_MAJOR, SOURCE_COMMIT)는 build.env 에 적기만 하면 자동으로 verify.sh 의 환경변수로 전달된다 — 스크립트가 무엇을 넘겨야 하는지 알 필요가 없다.

verify.sh 는 호스트에서 bash 로 실행된다

게스트 컨테이너에 stdin 으로 셸 스크립트를 주입하는 방식이 아니다 — env TAG=... PLATFORM=... <build.env 변수들> bash images/<image>/verify.sh 로 호출된다. 이유: 이미지에 셸이 없을 수 있다(distroless 계열 최종 이미지는 /bin/sh 가 없다). 호스트 스크립트는 셸이 있는 이미지엔 docker run --entrypoint sh ... <<'EOF' 로 게스트 스크립트를 쓸 수 있고, 셸이 없는 이미지엔 docker run --entrypoint <바이너리> 로 직접 실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 VERIFY-OK 를 출력해야 통과로 판정된다.

원칙 2 — 최종 런타임 베이스 OS 는 SUSE BCI

배포판은 결정됐다(2026-08-07, keycloak 이미지 추가 PR). 그전까지는 "security-catalog 의 SUSE BCI 단일화 결정을 이식하지 않았다" 는 미결 상태였다. keycloak 은 업스트림이 UBI9 기반이라 "업스트림과 최대한 동일하게" 와 정면으로 충돌했고, 카탈로그 내 일관성을 우선했다 — 먼저 들어온 이미지들이 전부 BCI 이고 trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다 (doc/analysis/sles-oval-measurement.md).

  • 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 무엇을 우선했는지와 왜인지를 해당 이미지 README 에 남긴다. 정책이 있다고 기록을 생략하지 않는다.
  • 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — 공식 언어 이미지(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 주석에 남긴다.

docker run --rm registry.suse.com/bci/bci-base:<태그> \
  sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info <패키지>'

새 버전으로 올릴 때는 trivy 의 해당 SLE 버전 커버리지도 다시 확인한다 — CoverageProbenone 이면 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 의 파일시스템을 씨앗으로 깔고 그 위에 설치한다:

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.shsed·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 이 하는 일 셸 유무
OS 패키지 재설치형 업스트림이 배포하는 산출물을 다른 배포판(zypper/apt 등)에 재설치 보통 있음 — 게스트 스크립트로 검증
소스 컴파일형 업스트림 pinned commit 을 go build 등으로 직접 컴파일 최종 베이스에 따라 다름

어느 유형이든 이 셋만 새로 쓰면 된다: <variant>.Dockerfile, <variant>.build.env, verify.sh(+ README.md).

신규 이미지 추가 체크리스트

  1. 상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가. 그것으로 해소되면 자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에 정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는 원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는 MEMORY.md)
  2. 어느 유형인지 판단한다 ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접 컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2)
  3. images/<image>/<variant>.Dockerfile·<variant>.build.env·verify.sh·README.md 작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다. FROM 에 쓰는 ARG 는 반드시 파일의 첫 FROM 이전(전역 스코프)에 선언한다 — 스테이지 내부(어떤 FROM 뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후 FROM 의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다
  4. 로컬 빌드:
    IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/out
    
    cve-gate.md 로 실효 C/H 0 확인. 커버리지 자가진단(CoverageProbe)이 ok 인지도 확인 — none 이면 findings 0건이 진짜 0건이 아니라 스캐너에 그 배포판 데이터가 없다는 뜻이므로 게이트가 차단한다(doc/sbom-pipeline.md 참고)
  5. 게이트 PASS 는 "동작한다" 를 증명하지 않는다. CVE 스캐너는 CVE 와 무관한 런타임 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. 실제 배포 검증을 반드시 한다 — 절차와 스크립트는 deploy-test-procedure.md 에 있다(cnpg·etcd 는 전용 스크립트, 그 외는 수동 절차). 업스트림과 다르게 만든 부분은 전부 이유를 확인하고 남긴다.
  6. 카탈로그 values(custom-values.yaml/dip-values.yaml 등) 갱신, 조사·결정 근거를 PR 설명과 MEMORY.md에 기록. images/**+manifests/helm/** 는 PR 로.
  7. CI 자동화: build-image.yml 은 이미 image 입력으로 파라미터화돼 있다 — images/<image>/catalog.env 만 추가하면 별도 워크플로 수정 없이 태울 수 있다.

SBOM·스캔·게이트는 절대 다시 만들지 않는다

scripts/pipeline/scan-sbom.sh · scripts/pipeline/cve-gate.py 는 이미지 종류와 무관하게 동작하며 커버리지 자가진단(CoverageProbe)도 포함한다(doc/sbom-pipeline.md 참고). build-hardened-image.sh 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.