images/·manifests/helm/·.claude/ 의 20개 파일이 doc/decisions·doc/analysis 등 **이 레포에 존재한 적 없는 경로 15종을 48곳에서** 인용하고 있었다. security-catalog 에서 포팅할 때 따라온 것인데, 그 레포는 개인 레포(github.com/wbsong111/security-catalog)라 팀 구성원은 접근조차 못 한다 — "security-catalog 에 있으나 이관되지 않았다" 는 안내가 아무 역할을 하지 못했다. 원문을 통째로 복사하지 않았다 ---------------------------- 원본 문서들이 서로를 근거로 인용한다. decisions/0001 하나만 봐도 analysis/cnpg-image-baseline.md · analysis/vendor-unassessed-data-sources.md 처럼 **인용 목록에 없던 또 다른 미이관 문서**를 가리킨다. 복사는 문제를 옮기는 것이지 없애는 게 아니다. 그리고 대부분은 애초에 dip-catalog 가 더 나은 것을 갖고 있다. 7곳에서 인용되던 analysis/sles-oval-measurement.md 는 원문 스스로 "이 문서는 결정하지 않는다. 재측정하면 갱신된다" 고 밝히는 스냅샷인데, dip-catalog 는 같은 측정을 CoverageProbe 로 매 스캔마다 자동으로 한다. 문서를 복사하는 것보다 게이트를 가리키는 것이 정확하다. 그래서 성격별로 나눴다 --------------------- 재측정으로 복원 안 되는 것 → doc/decisions/ 에 자립적 ADR 로 다시 씀 (4건) 이미 단일 출처가 있는 것 → 그쪽으로 인용 교체 (11종 경로) ADR 4건은 security-catalog 0001·0005·0006·0007 이 원본이고, 결론과 근거만 추려 dip-catalog 맥락으로 새로 썼다 — **레포 밖을 가리키는 링크가 0이다.** 번호는 이 레포에서 0001~0004 로 다시 붙였고 원본 대응은 각 문서와 README 에 적었다. 왜 안 가져온 것은 안 가져왔는지도 README 표에 남겼다. 인용 교체는 카테고리별로: analysis/*-cve.md, cnpg-image-vuln-comparison.md → 해당 ADR · images/<image>/README.md analysis/sles-oval-measurement.md → 게이트 CoverageProbe (doc/sbom-pipeline.md) cve-zero-pipeline.md, architecture/build-pipeline.md → doc/sbom-pipeline.md image-selection.md → .claude/image-authoring.md charts/*/deploy-test.md → scripts/deploy-test/*.sh + 절차 문서 찾은 오류 2건 ------------- - images/cloudnative-pg/source.build.env 가 인용한 decisions/0004-cloudnative-pg-operator-self-build.md 는 **번호 오기**다. 원본 0004 는 postgresql-chart-selection 이고 이 결정은 0005 다. - cnpg-cluster values.yaml·templates/database.yaml 이 인용한 doc/deploy-test-cnpg.md 는 **원본 레포에도 없다.** CREATE EXTENSION 함정 설명은 주석 자체에 이미 있어 인용만 뺐다. 검증 ---- 우리 파일의 깨진 doc/ 인용 0건 (전수 스캔) 새 문서·수정 문서의 로컬 링크 전부 실재 확인 helm template cnpg-cluster · etcd · cloudnative-pg 정상 렌더 남은 doc/health-checking.md(144곳)·doc/integration/*(2곳)은 업스트림 CRD·차트 안의 문자열로 우리가 쓴 인용이 아니다 — 건드리지 않았다. Closes #33 Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 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 커버리지가 양성 대조로 실측 확인돼 있다
(게이트의 CoverageProbe — doc/sbom-pipeline.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 버전 커버리지도 다시 확인한다 —
CoverageProbe 가 none 이면 findings 0 이 진짜 0 이 아니다(게이트가 막는다).
CVE 수치만 재고 고르면 배포에서 죽는다. 베이스 OS 는 런타임이 요구하는 도구의 버전도
같이 바꾼다. 실측(2026-08-19, argocd): argo-cd 차트의 repo-server init 컨테이너가
cp --update=none 을 쓰는데 이 형식은 GNU coreutils 9.3+ 에서만 된다. BCI 15.7 로 빌드한
이미지가 게이트도 verify.sh 도 통과하고 배포 시점에 Init:CrashLoopBackOff 로 죽었다.
스캐너는 "설치된 패키지에 알려진 CVE 가 있는가" 만 보지 "이 이미지를 쓰는 차트가 무엇을
요구하는가" 는 전혀 보지 못한다.
→ 차트가 이 이미지에 대고 실제로 실행하는 명령을 verify.sh 에서 그대로 재현한다.
베이스를 바꿀 때 빌드 단계에서 걸린다. images/argocd/verify.sh 의 "차트가 실제로 실행하는
명령" 절이 예다.
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.sh는sed·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 '<패턴>' 로 먼저 확인한다.
Go 모듈 CVE — 업그레이드 버전을 손으로 찾지 않는다
Go 바이너리에 정적 링크된 모듈의 차단 CVE 는 build.env 의 GO_MODULE_UPGRADES 에
<module>@<version> 을 적어 해소한다. 그 버전은 게이트 리포트의 FixedVersion 에 이미
들어 있으므로 스크립트가 뽑는다 — 사람이 CVE 를 하나씩 훑어 최대값을 고르지 않는다.
python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports 디렉토리> [--image <필터>]
붙여넣을 수 있는 GO_MODULE_UPGRADES="..." 한 줄과, 모듈별 근거(설치된 버전 → 목표 버전,
관련 CVE 목록)를 주석으로 낸다. stdlib 은 모듈이 아니라 툴체인 문제이므로 따로
GO_BUILDER_TAG 후보를 계산해 알려준다.
빌드 시점에 최신을 당기지 않는 이유 — go get -u 로 매번 최신을 끌면 같은 소스로
빌드해도 이미지가 달라진다. 이 문서가 "롤링 태그를 쓰지 않는다" 고 정한 것과 같은 이유다.
버전 핀은 우리가 무엇을 검증했는지의 기록이고, git diff 에 무엇이 왜 올라갔는지
남는다. 스크립트는 값을 제안만 하고 채택은 사람이 커밋한다.
두 가지 실측 함정이 있다.
FixedVersion의 여러 값은 "더 높은 버전"이 아니라 브랜치별 대안이다. stdlib 의1.25.13, 1.26.6, 1.27.0-rc.3은 세 브랜치 각각에서 고쳐진 지점이다. 전체 최대값을 고르면 프리릴리스를 정식 버전으로 오독한다(실제로 그 버그를 냈다). 스크립트가 이 규칙을 처리한다.- 제안값은 CVE 요건의 최소치다. 모듈 간 제약으로 더 올려야 할 수 있다 — 실측:
go-git 5.19.2와x/net 0.56.0이x/crypto 0.53.0을 요구해 제안값0.52.0으로는 빌드가requires golang.org/x/crypto@v0.53.0, not v0.52.0로 실패했다. 그 메시지가 가리키는 버전으로 올린다.
한 이미지가 여러 Go 프로젝트를 빌드하면(예: argocd 는 argocd·helm·kustomize·git-lfs 를
함께 빌드한다) 목록 하나를 전부에 재사용한다 — images/argocd/go-mod-upgrade.sh 처럼
"그 프로젝트의 의존성 그래프에 있는 모듈만" 골라 적용하는 헬퍼를 두면 된다. go get 은
의존성에 없는 모듈도 go.mod 에 추가해버리므로 그냥 넘기면 안 된다.
두 가지 유형 (둘 다 같은 스크립트를 쓴다)
| 유형 | Dockerfile 이 하는 일 | 셸 유무 |
|---|---|---|
| OS 패키지 재설치형 | 업스트림이 배포하는 산출물을 다른 배포판(zypper/apt 등)에 재설치 | 보통 있음 — 게스트 스크립트로 검증 |
| 소스 컴파일형 | 업스트림 pinned commit 을 go build 등으로 직접 컴파일 |
최종 베이스에 따라 다름 |
어느 유형이든 이 셋만 새로 쓰면 된다: <variant>.Dockerfile, <variant>.build.env,
verify.sh(+ README.md).
신규 이미지 추가 체크리스트
- 상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가. 그것으로 해소되면
자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에
정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는
원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는
MEMORY.md) - 어느 유형인지 판단한다 ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접 컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2)
images/<image>/<variant>.Dockerfile·<variant>.build.env·verify.sh·README.md작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다.FROM에 쓰는ARG는 반드시 파일의 첫FROM이전(전역 스코프)에 선언한다 — 스테이지 내부(어떤FROM뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후FROM의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다- 로컬 빌드:
IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/outcve-gate.md로 실효 C/H 0 확인. 커버리지 자가진단(CoverageProbe)이ok인지도 확인 —none이면 findings 0건이 진짜 0건이 아니라 스캐너에 그 배포판 데이터가 없다는 뜻이므로 게이트가 차단한다(doc/sbom-pipeline.md참고) - 게이트 PASS 는 "동작한다" 를 증명하지 않는다. CVE 스캐너는 CVE 와 무관한 런타임 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. 실제 배포 검증을 반드시 한다 — 절차와 스크립트는 deploy-test-procedure.md 에 있다(cnpg·etcd 는 전용 스크립트, 그 외는 수동 절차). 업스트림과 다르게 만든 부분은 전부 이유를 확인하고 남긴다.
- 카탈로그 values(
custom-values.yaml/dip-values.yaml등) 갱신, 조사·결정 근거를 PR 설명과MEMORY.md에 기록.images/**+manifests/helm/**는 PR 로. - 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 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.