자체 빌드 이미지 프레임워크를 security-images 레포로 이관하고 카탈로그 쪽을 정리한다

images/·scripts/build/build-hardened-image.sh·suggest-go-upgrades.py·
build-image.yml·.claude/image-authoring.md·이미지 ADR(0001·0002·0004)을 삭제했다 —
전부 별도 public 레포 security-images 로 이미 이관됐다.

카탈로그 쪽에는 "무엇을 배포 중인가"를 아는 부분만 남긴다:
- catalog/image-map/<image>.env — 옛 catalog.env 의 카탈로그 레이아웃 정보만 뗀 것
- scripts/build/check-rebuild-needed.py — 드리프트 탐지(A 파트)만 남기고 핀 판단
  (B 파트: pin_changes/apply_changes/parse_module_specs)은 제거
- scripts/build/apply-published-tags.py(신규) — security-images 의 published.json
  을 읽어 카탈로그 values 를 패치
- .github/workflows/{self-build-drift-check,catalog-tag-update}.yml(신규) — 각각
  드리프트 스캔+트리거, 발행 태그 반영

effective_severity 를 cve-gate.py 로 옮겼다 — check-rebuild-needed.py 가 핀 도구를
거치지 않고 게이트를 직접 로드하게 하기 위한 선행 작업이다.

두 레포의 계약은 published.json 스키마 하나뿐이다 — security-images 는 이 카탈로그를
모른다(단방향 의존). 이관 배경·결합점 전체는
doc/migrations/self-build-images-to-security-images.md.

부수 수정: 자체 빌드 이미지를 참조하는 차트 values/README 의 죽은 링크(images/**,
doc/decisions/000{1,2,4}, .claude/image-authoring.md)를 security-images 레포를
가리키는 서술로 교체. deploy-test 스크립트·CUSTOM-README 의 개인 Docker Hub 계정
(docker.io/wbsong111) 을 docker.io/paasup 로 교체.

pitfalls.md 의 "스캐너 결과를 그대로 믿지 말 것" 절은 sbom-cve-gate skill 이 차트
축 설명에 실제로 참조하고 있어 남겼다 — "이미지 태그의 베이스 OS" 절만 제거했다
(다른 참조 없음, security-images 문서로 이관 완료).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-24 15:21:29 +09:00
parent e948436f53
commit 7746570ec0
87 changed files with 693 additions and 5554 deletions
+4 -4
View File
@@ -8,12 +8,12 @@ CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수
## CNPG (`cnpg-postgresql` 이미지) — 자동화됨
`build-image.yml` 이 새 이미지로 카탈로그 PR 을 열면, 병합 전에 로컬 kubeconfig 로
`scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만 빠르게 확인한다(PR 본문에도
안내됨).
`catalog-tag-update.yml` 이 새 이미지 태그로 카탈로그 브랜치를 push 하면, 병합 전에 로컬
kubeconfig 로 `scripts/deploy-test/deploy-test-cnpg-cluster.sh` 를 실행해 "실제로 뜨는가"만
빠르게 확인한다(브랜치의 Job Summary 에도 안내됨).
```sh
IMAGE_NAME=docker.io/wbsong111/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out
IMAGE_NAME=docker.io/paasup/cnpg-postgresql:<태그> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh /tmp/deploy-test-out
```
- **Operator(`cloudnative-pg`)는 상시 컴포넌트다** — release `cnpg`, namespace
-485
View File
@@ -1,485 +0,0 @@
# 자체 빌드 이미지 작업 규칙
**자체 빌드 축의 단일 출처다.** 새 자체 빌드 이미지를 추가하거나 기존 빌드 정의를 바꿀 때,
그리고 `build-image.yml` 이 무엇을 하는지 알아야 할 때 여기를 본다.
카탈로그의 CVE 파이프라인은 두 축이고 이 문서는 **"이미지를 어떻게 만드는가"** 를 갖는다.
**"우리가 배포하는 이미지에 무엇이 있는가"**(`sbom.yml`·`cve-edge-post.yml`, 스캔·게이트
메커니즘)는 [doc/sbom-pipeline.md](../doc/sbom-pipeline.md) 가 단일 출처다 — 여기 복제하지 않는다.
> **레포 분리 시 이 파일은 `images/` · `scripts/build/` · `build-image.yml` 과 함께 이동한다.**
> 커스텀 이미지가 별도 레포로 나가는 것이 계획이고, 그때 끊기는 결합점은 아래
> "레포 분리 후 무엇이 끊기는가" 에 정리돼 있다.
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](../doc/sbom-pipeline.md)).
- 이 정책과 "업스트림과 최대한 동일하게" 가 충돌하면 **무엇을 우선했는지와 왜인지를
해당 이미지 README 에 남긴다.** 정책이 있다고 기록을 생략하지 않는다.
- 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 —
공식 언어 이미지(`golang` 등)를 그대로 써도 된다. 정책이 적용되는 것은 **스캔·배포
대상인 최종 스테이지**뿐이다.
### 어느 BCI 변종을 쓸지는 "런타임이 무엇을 필요로 하는가" 로 정한다
카탈로그의 8개 이미지가 실제로 쓰는 조합은 셋뿐이다. 새 이미지는 이 중 하나를 고른다 —
네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다.
| 최종 베이스 | 고르는 조건 | 대가 | 쓰는 이미지 |
| --- | --- | --- | --- |
| `bci-base` | 런타임이 OS 패키지·셸을 쓴다 (`zypper` 로 앱을 설치, entrypoint 가 셸 스크립트) | 표면적이 가장 크다 | `adc` · `apisix` · `argocd` · `cnpg-postgresql` |
| `bci-micro` | 정적 링크 바이너리 하나만 실행한다 | 패키지 매니저가 없다. `sed`·`grep`·`find` 도 없다. nonroot 계정을 직접 만들어야 한다 | `apisix-ingress-controller` · `cloudnative-pg` · `etcd` |
| `scratch` + micro rootfs | 런타임 구성을 builder 에서 통째로 조립한다 (JVM + 앱 트리) | rootfs 조립을 직접 책임진다("씨앗" 방식 필수, 아래) | `keycloak` |
`bci-micro`·`scratch` 를 고르면 **업스트림이 distroless `:nonroot` 태그로 공짜로 얻던 것을
직접 만들어야 한다.** 실측된 형태는 이렇다.
```dockerfile
# bci-micro 는 root 만 있다 — distroless 의 nonroot 변종에 해당하는 태그가 없다
RUN echo 'nonroot:x:65532:65532:nonroot:/home/nonroot:/bin/false' >> /etc/passwd; \
echo 'nonroot:x:65532:' >> /etc/group; \
mkdir -p /home/nonroot; chown 65532:65532 /home/nonroot
USER 65532:65532
```
`bci-base``groupadd`/`useradd` 가 있으므로 그것을 쓴다(`images/adc` 참고).
**uid·gid 는 업스트림 값을 그대로 쓴다** — 임의로 바꾸면 볼륨 권한이 깨진다.
### 어느 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` 주석에 남긴다.**
```sh
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 의 파일시스템을 씨앗으로 깔고 그 위에 설치한다:
```dockerfile
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 '<패턴>'` 로 먼저 확인한다.
## 언어별 빌더 규칙
베이스 OS(위 원칙 2)가 **최종 스테이지**를 정하고, 여기가 **빌더 스테이지**를 정한다. 두 축은
독립이다 — 같은 `bci-micro` 최종 위에 Go 빌더가 오기도 하고(`etcd`) Node 빌더가 오기도 한다.
빌더는 원칙 2의 대상이 아니다(최종 이미지에 남지 않으므로 공식 언어 이미지를 그대로 쓴다).
대신 **버전을 값으로 빼서 `build.env` 에 두는 것**이 모든 언어에 공통이다 — Dockerfile 에 박으면
CVE 조치마다 Dockerfile 을 고쳐야 한다.
### 공통 — 무엇을 `build.env` 로 빼는가
| 성격 | 예 |
| --- | --- |
| 빌더 이미지 태그 | `GO_BUILDER_TAG` · `NODE_BUILDER_TAG` · `BUILDER_BASE` |
| 업스트림 소스 지점 | `SOURCE_COMMIT`(pinned commit) · `APP_VERSION` |
| 취약 의존성 강제 버전 | `GO_MODULE_UPGRADES` · `<LIB>_FIX_VERSION` · `<LIB>_OLD`/`<LIB>_VERSION` 쌍 |
| 패키지 목록 | `BUILDER_PACKAGES` · `RUNTIME_PACKAGES` |
`BUILD_ARGS` 에 나열하지 않은 변수는 `--build-arg` 로 전달되지 않는다 — 값을 추가하면
`BUILD_ARGS` 도 같이 고친다(빠뜨리면 조용히 기본값으로 빌드된다).
### Go
```dockerfile
ARG GO_BUILDER_TAG=1.26.6-trixie # 전역 스코프 — FROM 에 쓰이므로
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH # 크로스 컴파일: 빌더는 호스트 아치, 산출물은 타깃
```
- **`--platform=$BUILDPLATFORM` + `TARGETARCH` 를 쓴다.** 에뮬레이션으로 빌더를 돌리지 않는다.
- **버전 문자열은 `ldflags` 로 주입한다.** 업스트림은 보통 `git rev-parse HEAD` 로 얻지만 우리는
`.git` 없이 빌드하므로 `SOURCE_COMMIT` 을 직접 넣는다 — 넣지 않으면 `verify.sh` 의 버전 검사가
깨지고, 무엇을 빌드했는지 이미지가 스스로 증언하지 못한다.
- **취약 모듈 강제 업그레이드는 세 형태가 있다.** 아래 "Go 모듈 CVE" 절이 값 산출을 담당한다.
| 형태 | 쓰는 경우 | 예 |
| --- | --- | --- |
| `GO_MODULE_UPGRADES` + `go-mod-upgrade.sh` | 한 이미지가 여러 Go 프로젝트를 빌드한다 | `argocd` |
| `go.work` 전역 `replace` | 업스트림이 워크스페이스를 쓴다 | `etcd` |
| 개별 `<LIB>_FIX_VERSION` | 취약 모듈이 소수로 고정돼 있다 | `apisix-ingress-controller` |
세 번째를 새로 만들지 않는다 — 모듈이 하나든 둘이든 `GO_MODULE_UPGRADES` 로 통일하는 쪽이
재빌드 판정(`check-rebuild-needed.py`)이 자동으로 다룰 수 있어 유리하다.
- **업스트림이 이미 백포트했으면 모듈을 올리지 말고 그 커밋을 쓴다** — 차이가 작을수록 좋다
(`cloudnative-pg``release-1.30` HEAD 를 그대로 컴파일해 세 CVE 를 해소했다).
### Node
```dockerfile
FROM ${BUILDER_BASE} AS builder # node:lts-* 또는 node:${NODE_BUILDER_TAG}
FROM ${RUNTIME_BASE} AS final # bci-base
RUN zypper -n install -y ${NODE_PKG} && zypper -n clean --all
```
- **런타임 Node 는 빌더 Node 와 별개다.** 빌더는 공식 `node` 이미지, 런타임은 **OS 패키지**
(`nodejs24`)를 쓴다 — 런타임 쪽이 스캔 대상이므로 벤더가 패치하는 경로에 둔다.
- 두 메이저가 어긋나지 않게 맞춘다. `NODE_PKG``build.env` 값이다.
- 번들 산출물(`main.cjs` 등) 하나만 복사한다 — `node_modules` 를 최종에 넣지 않는다.
### JVM (jar 교체형)
업스트림이 배포하는 tarball 을 풀고 **취약 jar 만 갈아끼운다.** 재컴파일하지 않는다.
```dockerfile
ARG NETTY_OLD # 지금 들어있는 버전
ARG NETTY_VERSION # 갈아끼울 버전
```
- **`OLD`/`VERSION` 쌍으로 받는다.** `OLD` 를 명시하는 이유는 **교체 대상을 못 찾았을 때 실패**
시키기 위함이다 — 업스트림이 버전을 올리면 조용히 지나가는 대신 빌드가 깨져야 한다.
- **BOM 이 pin 한 jar 는 태그 교체·베이스 OS 교체로 안 고쳐진다** — 그래서 자체 빌드다.
- `tzdata-java` 는 SLE_BCI 에 없다(JDK 내장 tzdb 를 쓴다). 패키지명 차이는 원칙 2의 표 참고.
### C · Lua (소스 컴파일형)
- **정적 링크를 시도하지 않는다.** SLE_BCI 에 static glibc 가 없다(실측). c-builder 스테이지와
최종 스테이지의 **베이스를 동일하게** 두고 동적 링크한다(`argocd``tini`·`connect-proxy`).
- 컴포넌트 버전이 여러 개면 전부 개별 `ARG` 로 뺀다(`apisix` 는 10개가 넘는다).
- SLE_BCI 에 없어 소스 빌드하는 도구는 **왜 없는지와 무엇을 대신하는지**를 주석에 남긴다 —
기능을 빼는 것과 구분되어야 한다.
## 업스트림 런타임 계약은 보존한다
CVE 를 없애려고 이미지를 바꾸는 것이지, **동작을 바꾸는 게 아니다.** 스캐너는 이걸 전혀 보지
못하므로(원칙 2의 `cp --update=none` 실측) 아래는 사람이 지켜야 한다.
- **`USER`·`ENTRYPOINT` 는 업스트림과 같게 유지한다.** uid 를 바꾸면 볼륨 권한이, entrypoint 를
바꾸면 차트의 `args` 가 깨진다.
- **업스트림이 만들던 파일 레이아웃을 그대로 만든다** — 심볼릭 링크, 빈 디렉토리, 권한까지.
오퍼레이터·차트가 그것에 의존한다(`cloudnative-pg` 는 멀티아치 심볼릭 링크를 "부수 장치" 로
오판해 지웠다가 `invalid architecture` 로 리컨실이 실패했다).
- **차트가 이 이미지에 대고 실행하는 명령을 `verify.sh` 에서 재현한다.** 위 실측 참고.
- Dockerfile 상단에 **업스트림과의 대응 관계 표**를 남긴다(무엇이 동일하고 무엇이 다른지).
기존 이미지들이 전부 이 형식을 갖고 있다.
## Go 모듈 CVE — 업그레이드 버전을 손으로 찾지 않는다
Go 바이너리에 정적 링크된 모듈의 차단 CVE 는 `build.env``GO_MODULE_UPGRADES`
`<module>@<version>` 을 적어 해소한다. 그 버전은 게이트 리포트의 `FixedVersion` 에 이미
들어 있으므로 스크립트가 뽑는다 — 사람이 CVE 를 하나씩 훑어 최대값을 고르지 않는다.
```sh
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` 에 추가해버리므로 그냥 넘기면 안 된다.
### 핀은 가만히 있어도 뒤처진다 — 드리프트는 주간 스캔이 잡는다
버전 핀을 고정하는 것의 대가는 **소스를 안 바꿔도 새 CVE 가 공개되면 그 핀이 규정을
벗어난다**는 것이다. 2026-08-19 에 실제로 났다: `etcd`·`cloudnative-pg`
`GO_BUILDER_TAG=1.26.5-trixie` 에 묶여 새 stdlib 차단 CVE 8건에 걸렸는데, **문서 전용 PR 이
우연히 `images/**` 를 건드려 검증 빌드가 돌면서** 발견됐다.
그래서 `build-image.yml`**주간(월요일 02:00 UTC)으로 스스로 확인하고 재빌드까지 한다.**
```
배포 중인 이미지 스캔 → check-rebuild-needed.py → (핀이 뒤처졌으면) 브랜치 push
→ 빌드·verify.sh·게이트
```
**트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다 — 핀이 뒤처졌는가가 아니다.** 처음에는
핀 기준으로 잡았는데 실측에서 틀렸다. 배포 중인 이미지 8개를 스캔했더니 차단이 있는 4개 중
핀 변경이 필요한 것은 하나도 없었다. 재빌드로 고쳐지는 경우가 셋이기 때문이다.
| 경우 | 재빌드가 고치는 이유 |
| --- | --- |
| 핀이 뒤처졌다 | 핀을 올려서 빌드한다 |
| 핀은 맞는데 그 핀으로 아직 안 빌드됐다 | 핀만 고친 PR 이 머지된 직후가 이 상태다 |
| 베이스 OS 패키지가 뒤처졌다 | 재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다 |
- **수정 버전이 없는 차단은 트리거가 아니다.** 재빌드해도 그대로다 — `no-fix` 로 따로 보고해
사람이 다른 레버(상위 태그·예외 승인)를 판단한다.
- **승인 예외(`doc/cve-exceptions.json`)를 적용한다.** 만료된 예외는 인정하지 않는다.
- **레지스트리 push 와 카탈로그 반영은 하지 않는다.** `push``workflow_dispatch` +
`mode=image` 에서만 켜진다.
- 수동으로 같은 것을 돌릴 때는 `mode=drift``workflow_dispatch` 한다.
로컬에서는 이렇게 쓴다.
```sh
python3 scripts/build/check-rebuild-needed.py --list-refs # 무엇을 스캔해야 하나
python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports 디렉토리>
python3 scripts/build/check-rebuild-needed.py --reports ... --image etcd --apply
```
게이트가 "차단 8건" 까지 말하는 데서 한 걸음 더 가서 **어느 `build.env` 의 어느 값을 무엇으로
바꾸면 되는지**를 낸다. 기준은 **카탈로그가 실제로 가리키는 이미지**다 — 재빌드해 보지 않고도
"지금 배포 중인 것이 규정을 벗어났는가" 를 답한다(`catalog.env` → 카탈로그 values → 그 ref 의
스캔 리포트 순으로 따라간다). 무엇을 스캔할지도 `--list-refs` 로 스크립트가 낸다 — ref 해석
규칙이 워크플로로 새면 두 곳이 어긋난다.
- **`--apply` 는 편집만 대신한다.** 커밋·빌드·검증은 그대로 사람이 한다 — 핀은 "우리가 무엇을
검증했는지의 기록" 이므로 자동 커밋하지 않는다.
- **`GO_MODULE_UPGRADES` 를 쓰지 않는 이미지**(예: `etcd``go.work` replace +
`XTEXT_FIX_VERSION` 을 쓴다)에는 값을 넣어도 무효라 자동 적용하지 않고 "수동 확인" 으로
보고한다.
- 판정 기준은 게이트와 같다(`max(벤더, NVD)`, 기본 HIGH). 즉 **드리프트 = 차단 CVE 를 만드는
뒤처짐**이고, 게이트를 통과하는 낮은 등급은 보고하지 않는다.
**이 판정은 카탈로그와 이미지 정의 양쪽을 읽는다 — 레포 분리 시 갈라지는 지점이다.**
커스텀 이미지가 별도 레포로 나가면 "무엇을 배포 중인가"(카탈로그)와 "어떻게 만드는가"(이미지
정의)가 다른 레포에 놓인다. 절단면은 `check-rebuild-needed.py` 의 함수 경계에 이미 있고 그
docstring 이 어느 함수가 어느 쪽인지 적어둔다 — **탐지는 카탈로그 쪽에 남고, 핀 판단은 이미지
쪽으로 간다.** 분리 작업 때 그 주석을 먼저 읽는다.
## CI — `build-image.yml`
이 축의 워크플로다. 실행기는 `scripts/build/build-hardened-image.sh` 하나이고 빌드 → `verify.sh`
→ SBOM → `scan-sbom.sh``cve-gate.py` 를 순서대로 부른다 — 스캔·게이트는 차트 축의 스크립트를
그대로 재사용한다([doc/sbom-pipeline.md](../doc/sbom-pipeline.md)).
**이 워크플로의 게이트는 강제다.** 차트 축의 `sbom.yml``--warn-only` 인 것과 다르다 —
게이트가 실패하면 push 도 카탈로그 반영도 일어나지 않는다.
| 트리거 | 대상 결정 | 빌드·검증·게이트 | 레지스트리 push | 카탈로그 태그 갱신 |
|--------|----------|------------------|-----------------|-------------------|
| `workflow_dispatch` | `image` 입력(필수) · `base_os`(비우면 `catalog.env``DEFAULT_BASE_OS`) · `push`(기본 `true`) | ✅ | `push=true` 일 때만 | 게이트 PASS + 태그 변경 시 |
| `pull_request` (`images/**`) | 변경된 `images/<image>/` 를 diff 로 자동 탐지(복수면 매트릭스 병렬) | ✅ | ❌ | ❌ |
| `schedule` (`0 2 * * 1`, 매주 월요일 02:00 UTC) · `mode=drift` | 배포 중인 이미지를 스캔해 재빌드가 필요한 것만 | ✅ | ❌ | ❌ |
PR 트리거가 push 하지 않는 것은 `REGISTRY` 를 넘기지 않기 때문이다 — 태그가 `localhost/...` 로 남아
push 를 시도할 수조차 없다(검증 전용 안전장치).
- **`schedule` 이 있지만 블라인드 정기 재빌드는 아니다.** 예전에는 트리거가 없었고 그 이유가
"블라인드 재빌드는 정말 개선인지를 매번 되묻게 만든다" 였다. 지금 것은 **수정 버전이 있는 차단
CVE 가 있을 때만** 빌드하고 없으면 아무것도 하지 않는다 — 거부된 것과 조건이 다르다.
판정은 아래 "핀은 가만히 있어도 뒤처진다" 절이 갖는다.
- **`sbom.yml` 의 게이트가 이 워크플로를 자동 호출하지 않는다.** 차트 축이 차단 CVE 를 찾아도
자체 빌드로 갈지는 사람이 판단한다 — 두 축 사이에 자동 연결은 없다. 이 워크플로의 `schedule`
**자기 이미지만** 본다(배포 중인 자체 빌드 이미지).
- 레지스트리는 `REGISTRY_HOST: docker.io/paasup` 고정. 인증은 `DOCKERHUB_USER`/`DOCKERHUB_TOKEN`.
### 카탈로그 반영 — PR 은 자동 생성되지 않는다
게이트 PASS 이고 새 태그가 현재 카탈로그 태그와 다르면, 워크플로가 `catalog.env``CHART_DIRS` 아래
`custom-values.yaml`/`dip-values.yaml``scripts/build/patch-catalog-tag.py` 로 갱신하고
`build/<image>-<타임스탬프>` 브랜치를 push 한다. **여기까지가 자동이다.**
PR 오픈은 사람이 한다 — `GITHUB_TOKEN` 으로 PR 을 만드는 것 자체를 조직 정책이 막는다("Allow GitHub
Actions to create and approve pull requests" 미허용, 리포 설정으로 변경 불가). 2026-08-04 실측으로
확인했고(run 30882785612, GraphQL: `GitHub Actions is not permitted to create or approve pull requests`),
`gh pr create` 호출은 워크플로에서 제거했다. 대신 Job Summary 에 compare 링크가 남는다.
병합 전에 사람이 해야 하는 것:
1. compare 링크로 PR 오픈.
2. `gh workflow run helm-catalog-sbom --ref <브랜치>` 로 카탈로그 게이트 확인.
3. **배포 검증** — 게이트 PASS 는 "동작한다"를 증명하지 않는다(스캐너는 런타임 요구사항을 보지 못한다).
절차: [deploy-test-procedure.md](deploy-test-procedure.md).
> **매핑이 불완전하면 조용히 지나간다.** `patch-catalog-tag.py` 는 "예상 패턴을 못 찾으면 실패"
> 하도록 만들어져 있지만 **애초에 `CHART_DIRS` 에 없는 파일은 검사조차 하지 않는다.** 실측
> (2026-08-20): `cnpg-postgresql` 의 `CHART_DIRS` 가 `cnpg-cluster/1.0.0` 만 담고 있어 같은
> 이미지를 쓰는 `1.1.0` 이 낡은 태그로 남았고, 게이트만 계속 차단으로 잡았다. 카탈로그는 여러
> 버전을 동시에 보관하므로 **이미지 하나가 여러 버전 디렉토리에 걸리는 것이 정상**이다 —
> 새 버전 디렉토리를 만들 때 `catalog.env` 를 함께 본다.
### 레포 분리 후 무엇이 끊기는가
커스텀 이미지가 별도 레포로 나가면 이 축은 **"어떻게 만드는가"** 만 갖고, **"무엇을 배포 중인가"**
는 카탈로그 레포에 남는다. 지금 한 레포 안이라 보이지 않는 결합이 그때 드러난다.
| # | 결합점 | 분리 후 필요한 것 |
|---|---|---|
| 1 | `patch-catalog-tag.py` 가 카탈로그 values 를 **쓴다** | 카탈로그 쪽이 자기 파일을 쓰게 한다(반영 워크플로) |
| 2 | `catalog.env``CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK` | **카탈로그 레이아웃 정보다** — 카탈로그 쪽으로 옮긴다 |
| 3 | `check-rebuild-needed.py` 가 카탈로그 values 를 **읽어** 배포 중 ref 를 해석 | **탐지는 카탈로그 쪽에 남는다** (그 파일 docstring 의 절단면 참고) |
| 4 | `build-hardened-image.sh``scan-sbom.sh`·`cve-gate.py` 를 부른다 | 게이트를 composite action 으로 공개해 양쪽이 `uses:` |
| 5 | `doc/cve-exceptions.json` 공유 | 카탈로그가 소유하고 action 입력으로 받는다(예외는 "무엇을 감수하는가") |
| 6 | `build/<image>-<ts>` 브랜치를 카탈로그 레포에 push | 카탈로그 쪽이 자기 브랜치를 만든다 |
| 7 | `images/` 가 이미지 목록의 단일 출처 (여러 문서가 이 경로 참조) | 참조 갱신 |
**3번이 가장 크다** — 드리프트 탐지는 "카탈로그가 가리키는 이미지" 를 기준으로 재는 것이 설계의
핵심인데 이미지 레포는 카탈로그를 갖고 있지 않다. 그래서 **탐지는 카탈로그, 빌드는 이미지 레포**
로 갈라야 한다.
**게이트를 공유할 때 `workflow_call` 은 쓸 수 없다** — 별도 job 으로 돌아 파일시스템을 공유하지
않는데 게이트는 스캔과 **같은 job 에서 `$OUT_DIR` 를 읽어야** 한다. composite action 이어야 한다.
참고로 `SBOM_PIPELINE_IMAGE` 는 도구만 담고 있어(`Dockerfile``COPY` 가 없다) 그것만으로는
`cve-gate.py` 가 따라오지 않는다.
## 두 가지 유형 (둘 다 같은 스크립트를 쓴다)
| 유형 | 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. 로컬 빌드:
```sh
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](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` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.
-6
View File
@@ -18,12 +18,6 @@ security-catalog 프로젝트에서 CNPG/etcd 자체 빌드·배포 테스트
> dip-catalog 의 `scan-sbom.sh` 는 두 번째 양상(데이터 커버리지 부재)을 구분하는 자가진단
> (`CoverageProbe`)을 이식했다(2026-08-03) — `doc/sbom-pipeline.md` 참고.
## 이미지 태그의 베이스 OS 를 확인할 것
같은 앱 버전이라도 태그에 따라 베이스 OS 가 다르고 EOL 이 임박한 것이 섞여 있다.
오퍼레이터가 배포판 수명 테이블을 갖고 있으면 기동 로그에 남는다
(CNPG: `internal/cmd/manager/instance/run/osdb.go`).
## `kubectl get cluster` 는 쓰면 안 된다
dev 클러스터에 `clusters` 단축명을 쓰는 CRD 가 3개 있다(CNPG 배포 테스트 대상 클러스터
+9 -5
View File
@@ -1,12 +1,13 @@
---
name: cve-remediation
description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate·self-build-image 사이 결정 단계만 담당하며 둘의 절차는 복제하지 않는다.
description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)를 잡았을 때 태그 교체·베이스 OS 교체·자체 빌드·예외 승인 중 어느 레버를 쓸지 판단할 때 사용한다. "이 CVE 어떻게 없애", "차단 CVE 뭐부터 해야 해", "자체 빌드 가야 하나 예외 가야 하나", "게이트 실패 다음 스텝" 같은 요청이 해당한다. sbom-cve-gate 로 게이트를 해석하는 것과 security-images 레포에서 자체 빌드를 실행하는 것 사이 결정 단계만 담당하며 둘의 절차는 복제하지 않는다.
---
# 차단 CVE 대응 절차
이 skill 은 실행하지 않는다 — 레버를 판단해 실행 skill로 넘긴다. 게이트 실행/해석은
`sbom-cve-gate`, 자체 빌드는 `self-build-image`, 배포 검증은
`sbom-cve-gate`, 자체 빌드는 **별도 레포 `security-images`**(그 레포의
`docs/image-authoring.md`가 단일 출처), 배포 검증은
[deploy-test-procedure.md](../../deploy-test-procedure.md)가 단일 출처다 — 여기 반복 안 한다.
## 흐름
@@ -35,8 +36,11 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
FixedVersion 이상인지, 같은 파일이 SBOM에서 컴포넌트 두 개로 잡히지 않는지(PkgPath
비교) 확인한다. 실례: keycloak `CVE-2025-59250`(mssql-jdbc jar 하나가 두 컴포넌트로
잡혀 잘린 쪽만 매칭된 오탐). 근거·만료일은 `doc/cve-exceptions.json`.
6. 자체 빌드는 `self-build-image` + [image-authoring.md](../../image-authoring.md)로,
태그/베이스 OS 교체는 카탈로그 values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
6. 자체 빌드는 별도 레포 `security-images`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 재빌드가 필요하면 그 레포의
`build-image.yml``workflow_dispatch`로 부른다(`scripts/build/check-rebuild-needed.py`
가 배포 중인 이미지를 스캔해 대상을 판단한다). 태그/베이스 OS 교체는 카탈로그
values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
7. 수정 후 반드시 재게이트하고 PASS라도 배포 검증까지 끝나야 종료다 — 1회로 끝난다고
가정하지 않는다(keycloak은 1차 수정 후 재스캔에서 micrometer 2건이 새로 잡혔다).
8. 착지는 브랜치 push까지다 — 조직 정책상 `GITHUB_TOKEN`으로 PR을 못 연다. 사람이 PR을
@@ -44,5 +48,5 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
## 참고
- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · [self-build-image](../self-build-image/SKILL.md)
- 레버 실행: [sbom-cve-gate](../sbom-cve-gate/SKILL.md) · 자체 빌드는 별도 레포 `security-images`
- 함정: [pitfalls.md](../../pitfalls.md) · 미결 사항: [MEMORY.md](../../../MEMORY.md)
+2 -2
View File
@@ -55,8 +55,8 @@ gh run watch --repo <org>/dip-catalog
상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인
```
자체 빌드로 가야 한다면 `self-build-image` skill과
[.claude/image-authoring.md](../../image-authoring.md)를 따른다. 판정 로직 상세는
자체 빌드로 가야 한다면 별도 레포 `security-images`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 판정 로직 상세는
`scripts/pipeline/cve-gate.py`의 모듈 docstring을 1차 출처로 본다.
## 참고
-108
View File
@@ -1,108 +0,0 @@
---
name: self-build-image
description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, argocd, cloudnative-pg, cnpg-postgresql, etcd, keycloak 8종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처).
---
# 자체 빌드 하드닝 이미지
CVE 게이트 대응 우선순위(상위 태그 교체 → 베이스 OS 교체 → **자체 빌드** → 예외 승인)에서
앞의 두 단계로 해소가 안 될 때만 온다.
## 원칙 — 오케스트레이션은 항상 하나다
**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.**
OS 패키지 재설치든 소스 컴파일이든 스크립트는 같고, 차이는 전부 `images/<image>/` 안에 있다.
**"이 이미지는 성격이 다르다"는 이유로 새 오케스트레이션 스크립트를 만들지 않는다** —
절차(빌드 → 기능검증 → SBOM → 스캔 → 게이트 → push)는 이미지 종류와 무관하게 동일하다.
SBOM·스캔·게이트도 다시 만들지 않는다 — `build-hardened-image.sh`가 이미
`scan-sbom.sh`/`cve-gate.py`를 호출한다.
## 실행
```sh
IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/out
```
`images/<image>/<variant>.build.env`가 계약(`DOCKERFILE`, `TARGET`, `BUILD_ARGS` 등)을
선언하면 스크립트는 이미지 종류를 몰라도 된다. 전체 계약표와 신규 이미지 추가 7단계 절차는
[.claude/image-authoring.md](../../image-authoring.md)에 있다 — 여기 복제하지 않는다.
CI는 `build-image.yml``image` 입력으로 이미 파라미터화돼 있다. `images/<image>/catalog.env`
추가하면 워크플로 수정 없이 태울 수 있다.
## 새 Dockerfile 은 두 축을 먼저 고른다
**작성 규칙 본문은 [.claude/image-authoring.md](../../image-authoring.md)가 단일 출처다**
여기 복제하지 않는다. 이 표는 "어느 절을 읽어야 하는가" 만 정한다.
두 축은 **독립**이다. 같은 `bci-micro` 최종 위에 Go 빌더가 오기도 하고 Node 빌더가 오기도 한다.
**축 1 — 최종 런타임 베이스** (스캔·배포 대상. 원칙 2)
| 런타임이 필요로 하는 것 | 고른다 | 선례 |
|---|---|---|
| OS 패키지·셸 (zypper 설치, 셸 entrypoint) | `bci-base` | `adc` `apisix` `argocd` `cnpg-postgresql` |
| 정적 링크 바이너리 하나뿐 | `bci-micro` | `apisix-ingress-controller` `cloudnative-pg` `etcd` |
| 런타임 트리를 builder 에서 조립 (JVM 등) | `scratch` + micro rootfs 씨앗 | `keycloak` |
→ 세 조합 밖으로 나가려면 **왜 셋으로 안 되는지 먼저 적는다.** `micro`/`scratch`
nonroot 계정·`sed`/`grep` 부재를 직접 감당해야 한다(image-authoring.md "어느 BCI 변종").
**축 2 — 빌더 스테이지** (최종에 남지 않음. 원칙 2 대상 아님 → 공식 언어 이미지 그대로)
| 언어 | 빌더 | 핀 키 |
|---|---|---|
| Go | `golang:${GO_BUILDER_TAG}` + `$BUILDPLATFORM`/`TARGETARCH` | `GO_BUILDER_TAG` · `GO_MODULE_UPGRADES` |
| Node | `node:*` (런타임은 **OS 패키지** `nodejs24`) | `NODE_BUILDER_TAG` · `NODE_PKG` |
| JVM | BCI base — 재컴파일 없이 **취약 jar 만 교체** | `<LIB>_OLD` / `<LIB>_VERSION` 쌍 |
| C · Lua | BCI base — **정적 링크 금지**(static glibc 없음) | 컴포넌트별 버전 ARG |
→ 상세는 image-authoring.md "언어별 빌더 규칙". 세 가지가 언어와 무관하게 공통이다:
**버전은 Dockerfile 에 박지 말고 `build.env` 값으로**, `BUILD_ARGS`**반드시 등록**(빠뜨리면
조용히 기본값으로 빌드된다), 그리고 **업스트림 런타임 계약(`USER`·`ENTRYPOINT`·파일 레이아웃)은
보존**한다.
## 자동 재빌드 — 실행 계약만
`build-image.yml` 이 주간(월요일 02:00 UTC)으로 배포 중인 이미지를 스캔해 재빌드가 필요한
것을 찾아 빌드·검증·게이트까지 돈다. **트리거 조건과 왜 그 조건인지는
[.claude/image-authoring.md](../../image-authoring.md) "핀은 가만히 있어도 뒤처진다" 가
단일 출처다** — 여기 복제하지 않는다.
```sh
# 수동으로 같은 것을 돌린다
gh workflow run self-build-image --repo <org>/dip-catalog -f mode=drift
# 판정만 로컬에서 본다
python3 scripts/build/check-rebuild-needed.py --list-refs
python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports> [--apply]
```
이 경로는 **레지스트리 push 와 카탈로그 반영을 하지 않는다** — push 는
`workflow_dispatch` + `mode=image` 에서만 켜진다.
## 실측된 함정
- **`FROM`에 쓰는 `ARG`는 첫 `FROM` 이전(전역 스코프)에 선언한다.** 스테이지 내부에 선언하면
그 스테이지 지역 변수가 되어 이후 `FROM`의 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다.
- **게이트 PASS는 "동작한다"를 증명하지 않는다.** CVE 스캐너는 런타임 요구사항(오퍼레이터가
자신의 파일 레이아웃에 의존하는 것 등)을 전혀 보지 못한다. 배포 검증을 반드시 한다 —
절차는 [.claude/deploy-test-procedure.md](../../deploy-test-procedure.md).
- **`CoverageProbe``ok`인지 확인한다.** `none`이면 findings 0건이 진짜 0건이 아니라
스캐너에 그 배포판 데이터가 없다는 뜻이다(`sbom-cve-gate` skill 참고).
- **롤링 태그를 쓰지 않는다.** 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 달라
태그에 빌드일을 포함한다(예: `1.30.0-security-hardened-20260804`).
- **핀을 고정한 대가로 핀이 뒤처진다.** 소스를 안 바꿔도 새 CVE 가 공개되면 어제 PASS 였던
핀이 오늘 FAIL 이 된다. 위 "자동 재빌드" 가 이것을 잡는다. 조치 전에 그 결과를 먼저 본다:
`python3 scripts/build/check-rebuild-needed.py --reports <trivy-reports> [--image <name>] [--apply]`
- **단, 같은 날 다시 빌드하면 그 태그가 겹친다.** 노드가 캐시한 옛 digest 가 그대로 쓰여
(`imagePullPolicy: IfNotPresent`) 고친 것이 반영되지 않은 채 "안 고쳐졌다" 로 보인다.
**검증 대상 워크로드에 `imagePullPolicy: Always` 를 수동으로 걸고 digest 로 확인한다**
절차는 [deploy-test-procedure.md](../../deploy-test-procedure.md) "같은 날 재빌드했다면"
이 단일 출처다. **카탈로그 values 에는 넣지 않는다**(같은 문서에 이유).
## 마무리
카탈로그 values(`custom-values.yaml`/`dip-values.yaml`) 태그 갱신은
`scripts/build/patch-catalog-tag.py`가 한다. 조사·결정 근거는 PR 설명과
[MEMORY.md](../../../MEMORY.md)에 남긴다. `images/**`+`manifests/helm/**` 변경은 PR로만 반영한다.
-432
View File
@@ -1,432 +0,0 @@
name: self-build-image
# 자체 빌드 이미지(images/<image>, 실행기 scripts/build/build-hardened-image.sh)의
# 빌드·검증·스캔·게이트·카탈로그 반영을 자동화한다. security-catalog 의 동일 워크플로에서
# 프레임워크를 포팅했고 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지
# 실제로 검증됐다(2026-08 기준). 대상 이미지 목록은 images/ 디렉토리가 단일 출처다.
# 게이트(scripts/pipeline/cve-gate.py)가 상위 태그·베이스
# OS 교체로 해소되지 않는 차단 CVE 를 찾으면 이 워크플로로 자체 빌드를 검토한다 —
# 절차: .claude/image-authoring.md, 트리거·제약 요약: doc/sbom-pipeline.md 의 "자체 빌드" 절
#
# "이미지가 어느 차트의 어느 필드를 가리키는가" 는 images/<image>/catalog.env 가 선언한다
# (CHART_DIRS·TAG_STYLE·TAG_BLOCK·DEFAULT_BASE_OS). 태그 표기 스타일이 두 가지다:
# imageName 단일 필드 문자열 (`imageName: "repo:tag"`)
# split registry/repository/tag 세 필드로 분리된 블록
# 둘 다 scripts/build/patch-catalog-tag.py 하나로 다룬다(YAML 파서 없이 텍스트 치환만
# 해서 기존 주석·포매팅을 보존한다 — 예상 패턴을 못 찾으면 조용히 넘어가지 않고 실패한다).
#
# 이 워크플로는 helm-catalog-sbom(sbom.yml)과 별도 파일이다. sbom.yml 은
# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다.
# 빌드는 호스트 러너여야 한다.
#
# 트리거 3종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다.
# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 브랜치 push 여부뿐이다.
#
# pull_request(images/**) 변경된 images/<image>/ 디렉토리를 diff 로 자동 탐지해 그
# 이미지들만 검증한다(push·카탈로그 브랜치 없음). 여러 이미지가
# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다.
# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 태그
# 갱신 브랜치 push 는 이 트리거로만 일어난다(사람이 수동 실행) —
# push 입력이 false 면 검증만 한다.
# schedule 재빌드 트리거. 배포 중인 이미지를 직접 스캔해
# (scripts/build/check-rebuild-needed.py) **수정 버전이 있는
# 차단 CVE** 가 있는 이미지를 찾아 빌드·검증·게이트만 돈다.
# 핀이 뒤처졌으면 핀을 올린 브랜치를 push 하고 그 브랜치로
# 빌드한다. 핀이 이미 맞아도 재빌드한다 — 그 핀으로 아직 안
# 빌드됐거나 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다
# (실측: 차단 있는 4개 중 핀 변경 필요는 0개였다).
# 레지스트리 push 와 카탈로그 반영은 하지 않는다 — 반영은 사람이
# workflow_dispatch 로 한다.
#
# 카탈로그 PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수
# 없다는 조직 정책("Allow GitHub Actions to create and approve pull requests" 미허용,
# 리포 설정으로 못 바꿈)에 실측으로 막혔다(2026-08-04, run 30882785612, GraphQL:
# "GitHub Actions is not permitted to create or approve pull requests"). 브랜치·커밋·push
# 까지만 워크플로가 하고, PR 오픈은 Job Summary 에 남는 compare 링크로 사람이 직접 연다
# (사람의 gh/브라우저 인증은 이 제약을 받지 않는다).
on:
workflow_dispatch:
inputs:
mode:
description: 'image=지정한 이미지 하나 | drift=재빌드가 필요한 이미지를 찾아 전부'
required: false
default: 'image'
type: choice
options: [image, drift]
image:
description: '빌드할 이미지 디렉토리명 (images/<image>/). mode=drift 면 비워둔다'
required: false
default: ''
base_os:
description: '빌드 변종 (images/<image>/<base_os>.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용'
required: false
default: ''
push:
description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 push 여부 (mode=drift·schedule 은 무조건 false)'
required: false
default: 'true'
pull_request:
paths:
- 'images/**'
schedule:
# sbom.yml 주간 전체 스캔(일요일 18:00 UTC) 다음날. 이 잡은 그 산출물에 의존하지 않고
# 배포 중인 이미지를 직접 스캔한다 — 워크플로 간 아티팩트 결합을 만들지 않기 위함이다.
- cron: '0 2 * * 1'
permissions:
contents: write
env:
REGISTRY_HOST: docker.io/paasup
jobs:
# --- 대상 이미지 결정 ---------------------------------------------------------
# workflow_dispatch: inputs.image 하나. pull_request: images/<image>/ 아래 변경이 있는
# 디렉토리를 전부 찾는다(여러 이미지가 한 PR 에서 바뀌면 각각 매트릭스로 돈다).
discover:
runs-on: ubuntu-latest
outputs:
images: ${{ steps.list.outputs.images }}
ref: ${{ steps.list.outputs.ref }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# 드리프트 경로에서만 필요하다. trivy 로 **배포 중인 이미지**를 직접 스캔한다
# (docker 불필요 — trivy 가 자체 레지스트리 클라이언트로 pull 한다).
- name: trivy 설치 (재빌드 판정용)
if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift'
run: |
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version | head -1
# 무엇을 스캔해야 하는지는 스크립트가 안다(catalog.env → 카탈로그 values → ref).
# ref 해석 규칙이 워크플로로 새면 두 곳이 어긋난다.
- name: 재빌드 판정 — 배포 중인 이미지 스캔
id: drift
if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift'
run: |
set -uo pipefail
mkdir -p /tmp/rebuild-reports
python3 scripts/build/check-rebuild-needed.py --list-refs > /tmp/refs.tsv
while IFS=$'\t' read -r img ref fname; do
[ -n "${fname:-}" ] || continue
echo "스캔: $ref"
trivy image --quiet --scanners vuln \
--severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \
--format json "$ref" > "/tmp/rebuild-reports/$fname" \
|| { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다"
rm -f "/tmp/rebuild-reports/$fname"; }
done < /tmp/refs.tsv
# --apply 는 핀이 뒤처진 경우에만 build.env 를 고친다. 핀이 이미 맞으면 아무것도
# 건드리지 않고, 그래도 재빌드 대상이다(베이스 패키지가 갱신되므로).
python3 scripts/build/check-rebuild-needed.py \
--reports /tmp/rebuild-reports \
--summary-md /tmp/rebuild.md \
--json-out /tmp/rebuild.json \
--apply
cat /tmp/rebuild.md >> "$GITHUB_STEP_SUMMARY"
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
rows = json.load(open("/tmp/rebuild.json"))
# 트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다. 핀 변경 여부가 아니다 —
# 핀이 이미 맞아도(핀만 고친 PR 이 머지된 뒤) 이미지가 그 핀으로 안 빌드됐거나
# 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다. 실측으로 확인한 사실이다.
print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"]))
PY
# 핀이 바뀐 경우에만 브랜치가 필요하다. 핀 변경이 없으면 기본 ref 로 그냥 빌드한다.
- name: 핀이 바뀌었으면 브랜치 push
id: branch
if: steps.drift.outputs.images != '' && steps.drift.outputs.images != '[]'
run: |
set -euo pipefail
if git diff --quiet -- images/; then
echo "핀 변경 없음 — 브랜치를 만들지 않고 기본 ref 로 빌드한다"
echo "핀은 이미 맞다 — 재빌드만으로 해소된다(베이스 패키지·미반영 핀)." \
>> "$GITHUB_STEP_SUMMARY"
exit 0
fi
BRANCH="chore/pin-drift-$(date -u +%Y%m%d%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git add images/
git diff --cached --stat
git commit \
-m "자체 빌드 이미지 버전 핀 갱신 (자동 감지)" \
-m "배포 중인 이미지를 스캔해 핀이 뒤처진 것을 찾아 올렸다. 소스는 바꾸지 않았다 —" \
-m "CVE 데이터가 갱신되면서 같은 핀이 차단 대상이 된 것이다. 산출은" \
-m "scripts/build/check-rebuild-needed.py 가 했고 근거는 이 실행의 Job Summary 에 있다." \
-m "이 브랜치로 빌드·검증·게이트까지 자동으로 돈다. 레지스트리 push 와 카탈로그" \
-m "반영은 하지 않았다 — 사람이 확인 후 workflow_dispatch 로 한다." \
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push -u origin "$BRANCH"
echo "ref=$BRANCH" >> "$GITHUB_OUTPUT"
{
echo
echo "핀을 올린 브랜치를 push 했다: \`$BRANCH\`"
echo
echo "아래 빌드가 **이 브랜치로** 돌아 핀이 실제로 해소하는지 확인한다."
echo "게이트가 PASS 면 사람이 PR 을 열고, 반영은 \`workflow_dispatch\` 로 한다."
echo
echo "https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
} >> "$GITHUB_STEP_SUMMARY"
- name: 대상 이미지 목록 산출
id: list
run: |
set -euo pipefail
echo "ref=${{ steps.branch.outputs.ref }}" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event.inputs.mode }}" = "drift" ]; then
# 판정 결과가 곧 대상이다. 핀이 안 바뀌어 브랜치가 없어도 빌드한다
# (기본 ref 로 재빌드하면 베이스 패키지·미반영 핀이 해소된다).
json='${{ steps.drift.outputs.images }}'
[ -n "$json" ] || json="[]"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
img="${{ github.event.inputs.image }}"
[ -n "$img" ] || { echo "::error::image 입력이 비어있다 (mode=drift 가 아니면 필수)"; exit 1; }
[ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; }
json="[\"$img\"]"
else
changed="$(git diff --name-only \
"${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" \
-- images/ | awk -F/ 'NF>1 {print $2}' | sort -u)"
if [ -z "$changed" ]; then
json="[]"
else
json="$(printf '%s\n' "$changed" \
| python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')"
fi
fi
echo "images=$json" >> "$GITHUB_OUTPUT"
echo "대상 이미지: $json"
build:
needs: discover
if: needs.discover.outputs.images != '[]' && needs.discover.outputs.images != ''
strategy:
fail-fast: false
matrix:
image: ${{ fromJson(needs.discover.outputs.images) }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
IMAGE_DIR: images/${{ matrix.image }}
steps:
# 드리프트 경로면 discover 가 핀을 올려 push 한 브랜치를 받는다(비어 있으면 기본 ref).
- uses: actions/checkout@v4
with:
ref: ${{ needs.discover.outputs.ref }}
- name: Preflight — 도구 확인
run: |
set -e
for t in docker python3 bash; do
command -v "$t" >/dev/null || { echo "::error::러너에 $t 없음"; exit 1; }
done
docker version
- name: trivy 설치
run: |
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version | head -1
# "이 이미지가 어느 차트의 어느 필드를 가리키는가" 는 이미지 디렉토리 자신이
# 선언한다(catalog.env) — 이 워크플로가 이미지별 지식을 갖지 않게 하기 위함
# (build.env 계약과 같은 원칙, .claude/image-authoring.md).
- name: 이미지 메타데이터 로드 (catalog.env)
id: meta
run: |
set -euo pipefail
ENV_FILE="$IMAGE_DIR/catalog.env"
[ -f "$ENV_FILE" ] || { echo "::error::catalog.env 없음: $ENV_FILE — images/<image>/catalog.env 를 추가해야 한다"; exit 1; }
# shellcheck disable=SC1090
. "$ENV_FILE"
: "${DEFAULT_BASE_OS:?catalog.env 에 DEFAULT_BASE_OS 가 없다}"
: "${CHART_DIRS:?catalog.env 에 CHART_DIRS 가 없다}"
: "${TAG_STYLE:?catalog.env 에 TAG_STYLE 이 없다}"
BASE_OS="${{ github.event.inputs.base_os }}"
BASE_OS="${BASE_OS:-$DEFAULT_BASE_OS}"
{
echo "base_os=$BASE_OS"
echo "chart_dirs=$CHART_DIRS"
echo "tag_style=$TAG_STYLE"
echo "tag_block=${TAG_BLOCK:-image}"
} >> "$GITHUB_OUTPUT"
echo " image=${{ matrix.image }} base_os=$BASE_OS chart_dirs=$CHART_DIRS tag_style=$TAG_STYLE"
# push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면
# push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다.
# 자동 트리거(schedule)와 드리프트 모드는 **절대 push 하지 않는다.** 자동 빌드는
# "핀을 이만큼 올리면 정말 해소되는가" 까지만 답한다 — 레지스트리 반영은 사람이
# workflow_dispatch 로 한다(CLAUDE.md 의 정책). catch-all 을 false 로 두어 앞으로
# 트리거가 추가돼도 실수로 push 되지 않게 한다.
- name: 게시 여부 결정
id: publish
run: |
case "${{ github.event_name }}" in
workflow_dispatch)
if [ "${{ github.event.inputs.mode }}" = "drift" ] \
|| [ "${{ github.event.inputs.push }}" = "false" ]; then
echo "enabled=false" >> "$GITHUB_OUTPUT"
else
echo "enabled=true" >> "$GITHUB_OUTPUT"
fi ;;
*) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
esac
- name: 레지스트리 로그인
if: steps.publish.outputs.enabled == 'true'
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
[ -n "$DOCKERHUB_USER" ] || { echo "::error::DOCKERHUB_USER 시크릿 없음"; exit 1; }
echo "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
- name: 빌드 → 검증 → SBOM → 스캔 → 게이트
id: build
env:
IMAGE: ${{ matrix.image }}
BASE_OS: ${{ steps.meta.outputs.base_os }}
run: |
set -uo pipefail
OUT="$GITHUB_WORKSPACE/build-out"
mkdir -p "$OUT"
# build-hardened-image.sh 의 build.log 는 그 안에서 docker build 출력만 담는
# 별도 파일이다(스크립트 자신의 tag= 안내는 그 파일에 없다). 태그를 뒤에서
# 뽑으려면 스크립트 자신의 표준출력을 따로 남겨야 한다 — tee 로 wrapper.log
# 에도 기록한다.
#
# GitHub Actions 의 run: 스텝은 기본으로 bash -e 다. 빌드 스크립트의 실패를
# $rc 로 정상 캡처하려면 그 호출 동안만 -e 를 끈다 — 아니면 실패 시 여기서
# 바로 중단돼 아래의 rc 기록·output 기록이 실행되지 않는다.
set +e
if [ "${{ steps.publish.outputs.enabled }}" = "true" ]; then
REGISTRY="$REGISTRY_HOST" bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log"
else
bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" # push 없음 — 검증만
fi
rc="${PIPESTATUS[0]}"
set -e
echo "rc=$rc" >> "$GITHUB_OUTPUT"
# 끝에 `|| true` 를 붙인다 — grep 이 매치를 못 찾아 실패해도(pipefail 이
# 그 실패를 대입식 전체의 실패로 만든다) -e 아래에서 스크립트가 중단되지
# 않게 한다. tag 가 비어도 그만이다 — rc!=0 이면 어차피 이후 단계가 건너뛴다.
tag="$(grep -h '^ tag=' "$OUT/wrapper.log" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
exit "$rc"
- name: 아티팩트 업로드
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.image }}-build
path: |
build-out/build.log
build-out/verify.log
build-out/cve-gate.md
build-out/sbom
if-no-files-found: warn
- name: Job Summary
if: always()
run: |
if [ -f build-out/cve-gate.md ]; then
{ echo "## ${{ matrix.image }}"; cat build-out/cve-gate.md; echo; } >> "$GITHUB_STEP_SUMMARY"
fi
# --- 카탈로그 반영 (push/workflow_dispatch 이고 게이트 PASS 일 때만) --------------
- name: 현재 카탈로그 태그 확인
id: current
if: steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0'
run: |
set -euo pipefail
# CHART_DIRS 가 여러 개일 수 있으나(공백 구분) 첫 번째 디렉토리를 기준값으로
# 삼는다 — 지금까지 이미지 하나가 차트 여러 개에 걸친 사례가 없다.
first_dir="$(echo "${{ steps.meta.outputs.chart_dirs }}" | awk '{print $1}')"
cur="$(python3 scripts/build/patch-catalog-tag.py \
--style "${{ steps.meta.outputs.tag_style }}" \
--block "${{ steps.meta.outputs.tag_block }}" \
--read "$first_dir/custom-values.yaml")"
echo "tag=$cur" >> "$GITHUB_OUTPUT"
echo "현재 카탈로그 태그: $cur"
echo "새 빌드 태그: ${{ steps.build.outputs.tag }}"
# 이 워크플로를 트리거하는 것 자체가 이미 "조치가 필요하다" 는 판단(sbom.yml 의
# 게이트가 차단+수정가능 CVE 를 확인)이거나 사람의 명시적 실행이다. 예전에는
# 블라인드 스케줄 재빌드가 있어서 "정말 개선인지" 를 여기서 재확인해야 했는데,
# 그 트리거를 없앤 뒤로는 불필요해졌다 — 게이트 PASS + 태그 변경만 확인한다.
#
# PR 은 만들지 않는다(위 파일 헤더 참고 — GITHUB_TOKEN 으로 PR 생성이 조직 정책으로
# 막혀 있고 우리 쪽에서 그 정책을 못 바꾼다, 실측: run 30882785612). 브랜치 push 까지만
# 하고 compare 링크를 Job Summary 에 남겨 사람이 직접 PR 을 연다.
- name: 카탈로그 브랜치 push + PR 안내
if: >
steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' &&
steps.current.outputs.tag != steps.build.outputs.tag
run: |
set -uo pipefail
NEW="${{ steps.build.outputs.tag }}"
OLD="${{ steps.current.outputs.tag }}"
IMAGE="${{ matrix.image }}"
BRANCH="build/${IMAGE}-$(date -u +%Y%m%d%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
TOUCHED=()
for dir in ${{ steps.meta.outputs.chart_dirs }}; do
candidates=()
[ -f "$dir/custom-values.yaml" ] && candidates+=("$dir/custom-values.yaml")
[ -f "$dir/dip-values.yaml" ] && candidates+=("$dir/dip-values.yaml")
[ "${#candidates[@]}" -eq 0 ] && continue
out="$(python3 scripts/build/patch-catalog-tag.py \
--style "${{ steps.meta.outputs.tag_style }}" \
--block "${{ steps.meta.outputs.tag_block }}" \
--old "$OLD" --new "$NEW" "${candidates[@]}")"
echo "$out"
TOUCHED+=("${candidates[@]}")
done
git diff --stat
git add "${TOUCHED[@]}"
git commit \
-m "${IMAGE} 이미지 태그 갱신: ${OLD##*:} → ${NEW##*:}" \
-m "게이트 PASS(build-image.yml, ${{ github.event_name }} 트리거)로 확인된 태그로 교체한다." \
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push -u origin "$BRANCH"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
{
echo "## ${IMAGE} 카탈로그 태그 갱신 — 브랜치 push 완료, PR 은 직접 열어야 함"
echo
echo "- 이전 태그: \`$OLD\`"
echo "- 새 태그: \`$NEW\`"
echo "- 브랜치: \`$BRANCH\`"
echo "- 게이트: PASS (아티팩트의 \`cve-gate.md\` 참고)"
echo
echo "**PR 은 자동 생성되지 않는다** — GitHub 조직 정책상 \`GITHUB_TOKEN\`(Actions 자체 토큰)으로는"
echo "PR 을 만들 수 없다. 아래 링크에서 사람이 직접 연다:"
echo
echo "$COMPARE_URL"
echo
echo "PR을 연 뒤 병합 전에 아래를 수동으로 실행해 카탈로그 게이트를 확인한다."
echo
echo '```sh'
echo "gh workflow run helm-catalog-sbom --ref $BRANCH"
echo '```'
echo
echo "이미지가 실제로 동작하는지도 병합 전에 수동으로 확인한다(해당 차트 배포 후"
echo "기능 점검 — 자동화된 배포 테스트 절차는 아직 없다)."
} >> "$GITHUB_STEP_SUMMARY"
echo "브랜치 push 완료 — PR은 사람이 직접 열어야 한다: $COMPARE_URL"
+88
View File
@@ -0,0 +1,88 @@
name: catalog-tag-update
# security-images(자체 빌드 이미지 레포, public)의 `published.json` 을 읽어 카탈로그
# values 의 이미지 태그를 갱신한다. 그 레포는 이 카탈로그를 모른다 — 게이트 PASS + push
# 가 실제로 일어난 이미지의 ref 를 `published.json` 에 기록해 둘 뿐이다. "그 태그를 어느
# 차트에 반영할지" 는 이 카탈로그가 판단한다(catalog/image-map/<image>.env).
#
# public raw URL 로 읽으므로 인증이 필요 없다. security-images 쪽 시크릿·PAT 도 없다 —
# 의존 방향은 카탈로그 → 이미지 단방향이다(docs/image-authoring.md "카탈로그 레포와의
# 계약", security-images 레포).
#
# PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수 없다는
# 조직 정책에 막혀 있다(build-image.yml 이 자체 빌드 축에서도 같은 제약을 받았다).
# 브랜치·커밋·push 까지만 하고 compare 링크를 Job Summary 에 남긴다.
on:
workflow_dispatch:
schedule:
# 자체 빌드는 workflow_dispatch 수동 실행뿐이라 발행이 잦지 않다 — 하루 한 번이면 충분하다.
- cron: '0 3 * * *'
permissions:
contents: write
env:
SECURITY_IMAGES_REPO: paasup/security-images
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: published.json 조회
run: |
curl -fsSL "https://raw.githubusercontent.com/${SECURITY_IMAGES_REPO}/main/published.json" \
-o /tmp/published.json
python3 -c "import json; json.load(open('/tmp/published.json'))" # 스키마 최소 확인
- name: 반영 대상 계산
id: apply
run: |
set -euo pipefail
python3 scripts/build/apply-published-tags.py --published /tmp/published.json \
--json-out /tmp/changes.json --summary-md /tmp/changes.md
cat /tmp/changes.md >> "$GITHUB_STEP_SUMMARY"
n="$(python3 -c "import json;print(len(json.load(open('/tmp/changes.json'))))")"
echo "count=$n" >> "$GITHUB_OUTPUT"
- name: 브랜치 push + PR 안내
if: steps.apply.outputs.count != '0'
run: |
set -euo pipefail
BRANCH="build/catalog-tag-update-$(date -u +%Y%m%d%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
# apply-published-tags.py 가 이미 파일을 고쳤다 — 여기서는 그 결과를 커밋만 한다.
FILES="$(python3 -c "
import json
for c in json.load(open('/tmp/changes.json')):
print(c['file'])
" | sort -u)"
git add $FILES
git diff --cached --stat
IMAGES="$(python3 -c "
import json
print(', '.join(sorted({c['image'] for c in json.load(open('/tmp/changes.json'))})))
")"
git commit \
-m "자체 빌드 이미지 태그 갱신: $IMAGES" \
-m "security-images 레포의 published.json(게이트 PASS + push 확인된 발행 기록)을" \
-m "반영한다. 상세는 이 실행의 Job Summary 참고." \
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push -u origin "$BRANCH"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
{
echo
echo "브랜치 push 완료 — PR은 사람이 직접 연다: $COMPARE_URL"
echo
echo "병합 전 확인할 것:"
echo '1. `gh workflow run helm-catalog-sbom --ref '"$BRANCH"'` 로 카탈로그 게이트 확인'
echo "2. 배포 검증 — 게이트 PASS 는 동작을 증명하지 않는다"
} >> "$GITHUB_STEP_SUMMARY"
echo "브랜치 push 완료: $COMPARE_URL"
@@ -0,0 +1,83 @@
name: self-build-drift-check
# 배포 중인 자체 빌드 이미지(security-images 레포가 만든 것)가 새 CVE 로 규정을 벗어났는지
# 이 카탈로그가 스스로 스캔해 판정한다. "무엇이 배포 중인가"는 이 카탈로그만 안다 —
# security-images 는 자기 스스로 이 판단을 하지 않는다
# (security-images 레포 docs/image-authoring.md "재빌드는 이 레포가 스스로 트리거하지 않는다").
#
# 판정(scripts/build/check-rebuild-needed.py)이 재빌드 대상이라고 하면 security-images 의
# `build-image.yml` 을 `workflow_dispatch` 로 부른다. 그 워크플로가 실제 빌드·검증·게이트·
# push·`published.json` 갱신을 한다 — 이 워크플로는 트리거만 한다.
#
# 레지스트리 push 와 카탈로그 반영은 이 워크플로가 하지 않는다. `catalog-tag-update.yml` 이
# `published.json` 을 읽어가는 별도 경로로 반영한다.
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 1' # 매주 월요일 02:00 UTC
env:
SECURITY_IMAGES_REPO: paasup/security-images
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: trivy 설치
run: |
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version | head -1
- name: 배포 중인 자체 빌드 이미지 스캔
id: scan
run: |
set -uo pipefail
mkdir -p /tmp/drift-reports
python3 scripts/build/check-rebuild-needed.py --list-refs > /tmp/refs.tsv
while IFS=$'\t' read -r img ref fname; do
[ -n "${fname:-}" ] || continue
echo "스캔: $ref"
trivy image --quiet --scanners vuln \
--severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \
--format json "$ref" > "/tmp/drift-reports/$fname" \
|| { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다"
rm -f "/tmp/drift-reports/$fname"; }
done < /tmp/refs.tsv
python3 scripts/build/check-rebuild-needed.py \
--reports /tmp/drift-reports \
--summary-md /tmp/drift.md \
--json-out /tmp/drift.json
cat /tmp/drift.md >> "$GITHUB_STEP_SUMMARY"
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
rows = json.load(open("/tmp/drift.json"))
print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"]))
PY
- name: security-images 재빌드 트리거
if: steps.scan.outputs.images != '' && steps.scan.outputs.images != '[]'
env:
GH_TOKEN: ${{ secrets.SECURITY_IMAGES_DISPATCH_TOKEN }}
run: |
set -euo pipefail
[ -n "$GH_TOKEN" ] || { echo "::error::SECURITY_IMAGES_DISPATCH_TOKEN 시크릿 없음 — ${SECURITY_IMAGES_REPO} 에 workflow_dispatch 를 걸 권한이 있는 PAT 필요"; exit 1; }
python3 -c "import json,sys; print('\n'.join(json.loads(sys.argv[1])))" '${{ steps.scan.outputs.images }}' | \
while read -r img; do
[ -n "$img" ] || continue
echo "재빌드 트리거: $img"
gh workflow run self-build-image --repo "$SECURITY_IMAGES_REPO" -f "image=$img"
done
{
echo
echo "재빌드를 트리거했다: ${{ steps.scan.outputs.images }}"
echo "진행 상황: https://github.com/${SECURITY_IMAGES_REPO}/actions/workflows/self-build-image.yml"
echo
echo "게이트 PASS + push 성공 시 그 레포의 \`published.json\` 이 갱신되고,"
echo "\`catalog-tag-update.yml\` 이 다음 실행에서 카탈로그에 반영한다."
} >> "$GITHUB_STEP_SUMMARY"
+40 -23
View File
@@ -32,16 +32,20 @@ dip-catalog/
│ └── status.md # 구현 현황
├── scripts/
│ ├── pipeline/ # SBOM 생성 + CVE 스캔 + 게이트 판정 (sbom.yml/cve-edge-post.yml 이 쓴다)
│ ├── build/ # 자체 빌드 이미지 프레임워크 (build-image.yml 이 쓴다)
│ ├── build/ # 자체 빌드 이미지 축의 카탈로그 쪽 절반 — 드리프트 탐지
│ │ # (check-rebuild-needed.py) + 발행 태그 반영
│ │ # (apply-published-tags.py). 빌드 자체는 security-images 레포
│ └── deploy-test/ # 배포 검증 스크립트 + fixtures (helm/kubectl 실행 전담)
├── images/<image>/ # 자체 빌드 하드닝 이미지 정의 (목록은 이 디렉토리가 단일 출처)
├── catalog/
│ └── image-map/<image>.env # 자체 빌드 이미지 → 차트·필드 매핑 (목록은 이 디렉토리가 단일 출처)
├── MEMORY.md # 현재 상태·미결 (작업 이어받을 때 여기서 시작 — 유지 규칙은 파일 안에)
└── doc/ # 차트 리소스 프로파일 + 스택 분류 + CVE/SBOM 파이프라인 + 운영 가이드
├── sbom-pipeline.md # SBOM 생성·스캔·게이트 메커니즘
├── cve-exceptions.json # 게이트 승인 예외 목록
├── catalog-stack-classification.md # 차트 스택 분류·우선순위(P0~P2)
├── define-chart-resources.md # 차트별 Small/Medium/Large 리소스 프로파일
├── decisions/ # ADR — 재측정으로 복원되지 않는 이미지·차트 선택 근거
├── decisions/ # ADR — 재측정으로 복원되지 않는 차트 선택 근거
│ # (이미지 자체 빌드 ADR은 security-images 레포로 이관됨)
└── migrations/ # 레포 간 이관 핸드오프 문서
```
@@ -146,41 +150,54 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
|-------|------|
| [chart-to-cnpg](.claude/skills/chart-to-cnpg/SKILL.md) | 카탈로그 차트의 내장 bitnami postgresql 서브차트를 전용 cnpg-cluster로 전환 |
| [catalog-update-pipeline](.claude/skills/catalog-update-pipeline/SKILL.md) | 차트 신규 버전 감지 → diff → breaking 판정 → 문서 생성 파이프라인 실행 (`agent/update_catalog`) |
| [cve-remediation](.claude/skills/cve-remediation/SKILL.md) | 차단 CVE 대응 레버(태그 교체/베이스 OS 교체/자체 빌드/예외) 결정 — `sbom-cve-gate`·`self-build-image` 실행으로 위임 |
| [cve-remediation](.claude/skills/cve-remediation/SKILL.md) | 차단 CVE 대응 레버(태그 교체/베이스 OS 교체/자체 빌드/예외) 결정 — `sbom-cve-gate` 실행 또는 별도 레포 `security-images`(자체 빌드)로 위임 |
| [sbom-cve-gate](.claude/skills/sbom-cve-gate/SKILL.md) | SBOM 생성·CVE 스캔·게이트 판정 실행 및 결과 해석 (`scripts/pipeline`) |
| [self-build-image](.claude/skills/self-build-image/SKILL.md) | 자체 빌드 하드닝 이미지 추가·변경 (`scripts/build`, `images/`) |
각 Skill 은 절차 본문을 복제하지 않고 권위 있는 문서(`doc/sbom-pipeline.md`,
`.claude/image-authoring.md` 등)를 가리킨다 — 문서가 단일 출처이고, Skill 은 **실행 계약과
문서가 놓치기 쉬운 함정·현재 상태**만 담는다.
자체 빌드 하드닝 이미지 추가·변경은 이 레포의 일이 아니다 — 별도 레포 `security-images`
에서 하고, 그 레포의 `docs/image-authoring.md`가 단일 출처다(이관 배경:
[doc/migrations/](doc/migrations/)).
각 Skill 은 절차 본문을 복제하지 않고 권위 있는 문서(`doc/sbom-pipeline.md` 등)를
가리킨다 — 문서가 단일 출처이고, Skill 은 **실행 계약과 문서가 놓치기 쉬운 함정·현재
상태**만 담는다.
관련 참조 문서(Skill이 절차의 단일 출처로 삼는다):
[deploy-test-procedure.md](.claude/deploy-test-procedure.md) ·
[pitfalls.md](.claude/pitfalls.md) · [image-authoring.md](.claude/image-authoring.md)
[pitfalls.md](.claude/pitfalls.md)
---
## CVE/SBOM 게이트 작업
카탈로그가 참조하는 컨테이너 이미지의 취약점을 다룬다. **두 축**이고 각 축의 상세는 소유 문서가
갖는다 — 여기 메커니즘을 지 않는다.
카탈로그가 참조하는 컨테이너 이미지의 취약점을 다룬다. **두 축**이고, 축마다 소유 레포와
문서가 다르다 — 여기 메커니즘을 복제하지 않는다.
| 축 | 질문 | 워크플로 | 게이트 | 단일 출처 |
|----|------|---------|--------|----------|
| **차트 카탈로그** | 우리가 배포하는 이미지에 무엇이 있는가 | `sbom.yml`<br>`cve-edge-post.yml` | warn-only<br>**호출 안 함** | [doc/sbom-pipeline.md](doc/sbom-pipeline.md) |
| **자체 빌드** | 그 이미지를 어떻게 만드는가 | `build-image.yml` | **강제** | [.claude/image-authoring.md](.claude/image-authoring.md) |
| 축 | 질문 | 실행 위치 | 워크플로(이 레포) | 게이트 | 단일 출처 |
|----|------|----------|------------------|--------|----------|
| **차트 카탈로그** | 우리가 배포하는 이미지에 무엇이 있는가 | 이 레포 | `sbom.yml`<br>`cve-edge-post.yml` | warn-only<br>**호출 안 함** | [doc/sbom-pipeline.md](doc/sbom-pipeline.md) |
| **자체 빌드** | 그 이미지를 어떻게 만드는가 | 별도 레포 `security-images` | `self-build-drift-check.yml`(탐지·트리거)<br>`catalog-tag-update.yml`(반영) | **강제**(그 레포 소유) | 그 레포의 `docs/image-authoring.md` |
- **차트 축은 warn-only 다** — 게이트가 실패해도 CI/PR 을 막지 않는다. 카탈로그 차트 전체가
이 게이트로 트리아지된 적이 없다. **자체 빌드 축의 게이트는 이미 강제다.**
이 게이트로 트리아지된 적이 없다. **자체 빌드 축의 게이트는 이미 강제다**(단, 그 게이트는
이 레포가 아니라 `security-images` 레포가 돌린다).
- **`cve-edge-post.yml` 은 게이트를 부르지 않는다** — 같은 스캔 데이터에 판정기가 두 벌이라는
뜻이다(승인 예외·실효 등급 미적용). 외부 엔드포인트로 집계를 POST 하는 용도다.
- **자체 빌드는 대응 우선순위 3번**(상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인).
레버 판단은 [cve-remediation](.claude/skills/cve-remediation/SKILL.md) skill 이 갖는다.
**레지스트리 push·카탈로그 반영을 동반하는 빌드는 `workflow_dispatch` 수동 실행뿐이다**
`schedule`·PR 트리거는 검증다.
- 커스텀 이미지는 **별도 레포로 분리 예정**이다. 그때 끊기는 결합점은
[.claude/image-authoring.md](.claude/image-authoring.md) "레포 분리 후 무엇이 끊기는가".
- 승인 예외: `doc/cve-exceptions.json` · 현재 미결: [MEMORY.md](MEMORY.md)
실행은 `security-images` 레포에서 `workflow_dispatch` 로만 한다.
- **이 레포는 "무엇을 배포 중인가"다.** 어느 이미지를 자체 빌드하고 있는지는
`catalog/image-map/` 이 단일 출처다. `scripts/build/check-rebuild-needed.py`(주간
`self-build-drift-check.yml`)가 배포 중인 이미지를 직접 스캔해 재빌드 대상을 판단하고,
필요하면 `security-images``build-image.yml` 을 트리거만 한다 — 핀을 무엇으로
올릴지는 그 레포가 판단한다.
- **카탈로그 반영은 pull 방식이다.** `security-images` 는 이 카탈로그를 모른다 — 게이트
PASS + push 성공 시 자기 레포의 `published.json` 만 갱신한다. `catalog-tag-update.yml`
이 그 파일을 public raw URL 로 읽어가 `custom-values.yaml`/`dip-values.yaml` 을 패치한다.
- 커스텀 이미지는 자체 빌드 프레임워크·이미지 정의와 함께 **별도 레포 `security-images`
로 분리됐다.** 이관 배경과 남은 결합점은 [doc/migrations/](doc/migrations/) 참고.
- 승인 예외: `doc/cve-exceptions.json`(차트 축) — `security-images``cve-exceptions.json`
(자체 빌드 축)과 별도 관리되며, 같은 이미지를 양쪽이 스캔하므로 필요하면 양쪽에 각각
등록한다. 현재 미결: [MEMORY.md](MEMORY.md)
---
@@ -190,8 +207,8 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
| 성격 | 목적지 |
|------|--------|
| **무엇을 왜 바꿨나** (완료된 작업의 경위) | 커밋 메시지 · PR 설명 · `images/<image>/README.md` |
| **다시 밟지 말아야 할 함정** | [.claude/pitfalls.md](.claude/pitfalls.md) · [.claude/image-authoring.md](.claude/image-authoring.md) · 해당 Skill |
| **무엇을 왜 바꿨나** (완료된 작업의 경위) | 커밋 메시지 · PR 설명 |
| **다시 밟지 말아야 할 함정** | [.claude/pitfalls.md](.claude/pitfalls.md) · 해당 Skill |
| **후보를 비교해 하나를 고른 근거** | [doc/decisions/](doc/decisions/) — ADR. 선택지가 하나뿐인 조치는 여기 쓰지 않는다 |
| **추적·논의·배정이 필요한 미결** | GitHub 이슈 |
| **지금 상태와 다음에 할 일** | [MEMORY.md](MEMORY.md) — 위 셋에 속하면 여기 남기지 않고 링크만 |
+19 -14
View File
@@ -12,15 +12,15 @@
## 이 파일의 유지 규칙
**지금 시점의 상태와 다음에 할 일만 담는다.** 완료된 작업의 경위는 담지 않는다 — 커밋
메시지·PR 설명·`images/<image>/README.md` 이미 권위 있는 기록이고, 복사본을 두면 나중에
어느 쪽이 맞는지가 새 문제가 된다.
메시지·PR 설명 이미 권위 있는 기록이고, 복사본을 두면 나중에 어느 쪽이 맞는지가 새
문제가 된다.
항목이 "다음에 할 일" 이 아니게 되면 **셋 중 하나로 내보내고 여기서 지운다.**
| 성격 | 목적지 |
|---|---|
| 추적·논의·배정이 필요한 미결 | GitHub 이슈 — 여기엔 **한 줄 링크만** |
| 재발 방지 교훈 | `.claude/pitfalls.md` · `.claude/image-authoring.md` · 해당 skill |
| 재발 방지 교훈 | `.claude/pitfalls.md` · 해당 skill |
| 단순 완료 기록 | 삭제 |
트리거는 시간이 아니라 **상태**다. 다만 놓친 것을 걷어내기 위해 **월 1회 점검**한다 —
@@ -47,18 +47,23 @@
- **`argo-cd/7.8.11` 삭제 여부** — 카탈로그 차단 CVE 239건이 전부 이 동결 버전 몫이고,
지우면 게이트가 PASS 로 떨어진다. 직전 버전을 없애는 결정이라 PR #28 에서 보류했다.
- **자체 빌드 이미지의 정식 반영 경로** — argocd 는 로컬에서 빌드·push 했다. CI
`build-image.yml`(`workflow_dispatch`)로 다시 태울지, 로컬 push 로 끝낼지.
- **레포 분리 시점의 두 작업** — ① `catalog.env` 의 카탈로그 매핑(`CHART_DIRS`·`TAG_STYLE`·
`TAG_BLOCK`)을 카탈로그 자산으로 떼어내고 `catalog-tag-update.yml` 로 반영,
② 게이트를 composite action 으로(`workflow_call` 아님 — 스캔과 같은 job 에서 `$OUT_DIR`
공유가 필요하다). 결합점 전체는 `.claude/image-authoring.md` "레포 분리 후 무엇이 끊기는가".
- **자체 빌드 이미지 레포 분리는 완료됐다** — 프레임워크·이미지 정의·ADR 이
`security-images` 레포로 나갔다. 카탈로그 쪽은 `catalog/image-map/`(어느 차트를
가리키는지) + `check-rebuild-needed.py`(드리프트 탐지) + `apply-published-tags.py`
(발행 태그 반영)만 남았다. 배경·결합점 전체는
[doc/migrations/](doc/migrations/self-build-images-to-security-images.md).
**아직 설정 안 된 것**: `self-build-drift-check.yml``security-images`
`build-image.yml` 을 트리거하려면 그 레포에 `workflow_dispatch` 권한이 있는 PAT 을
`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿으로 등록해야 한다. 등록 전까지 트리거 스텝은
실패한다(의도된 명시적 실패 — 조용히 넘어가지 않는다).
- **카탈로그 태그가 클러스터보다 앞서 있고 재검증 경로가 없다** — 게이트 PASS 만으로
`patch-catalog-tag.py` 가 태그를 올린다. 2026-08-21 실측: 클러스터의 adc `20260812` ·
apisix-ingress-controller `20260811` 이 검증된 것인데 카탈로그는 `20260813` · `20260820`
이다. 소스 안 바뀐 재빌드라 위험은 낮지만 **게이트 PASS 는 동작을 증명하지 않는다**
(argocd `cp --update=none` 사례). [#32](https://github.com/paasup/dip-catalog/issues/32)
코멘트에 실측을 남겼다.
`patch-catalog-tag.py`(이제 `catalog-tag-update.yml` 을 통해)가 태그를 올린다. 실측:
클러스터에 배포된 태그가 카탈로그가 가리키는 태그보다 뒤처진 사례가 있었다. 소스 안
바뀐 재빌드라 위험은 낮지만 **게이트 PASS 는 동작을 증명하지 않는다**
(argocd `cp --update=none` 사례). `published.json``digest` 필드가 이 재검증의
실마리다(태그가 아니라 digest 로 "실제로 검증된 것과 카탈로그가 가리키는 것"을
대조할 수 있다) — 아직 그 대조를 실제로 하는 자동화는 없다.
[#32](https://github.com/paasup/dip-catalog/issues/32) 코멘트에 실측을 남겼다.
- **워크플로 결함 3건** — `cve-edge-post.yml` 이 게이트를 안 부른다(판정기 두 벌) ·
인증 스텝이 `sbom.yml` 과 diff 0 으로 복붙 · `scripts/pipeline/Dockerfile` 을 빌드하는
워크플로가 없다(손으로 push).
+29
View File
@@ -0,0 +1,29 @@
# 이미지 → 차트 매핑
security-images 레포가 발행하는 자체 빌드 이미지가 이 카탈로그의 어느 차트·어느 필드를
가리키는지 선언한다. **이 디렉토리에 파일이 있는 이미지 이름 = 이 카탈로그가 추적하는
자체 빌드 이미지 목록**이다(`scripts/build/check-rebuild-needed.py`
`.github/workflows/catalog-tag-update.yml`이 이 목록을 기준으로 동작한다).
이관 배경: 이 정보는 원래 security-images(당시 `images/<image>/catalog.env`)에 있었다.
"어느 이미지를 쓰는가"는 카탈로그가 알아야 하고 "그 이미지를 어떻게 만드는가"는
security-images 가 알아야 하므로, 레포 분리 시 이 지식을 카탈로그 쪽으로 옮겼다 —
[doc/migrations/self-build-images-to-security-images.md](../../doc/migrations/self-build-images-to-security-images.md)
참고.
## 파일 형식 — `<image>.env`
`<image>`는 security-images 레포의 `images/<image>/` 디렉토리명과 정확히 같아야 한다
(`published.json`의 키도 같다).
| 키 | 의미 |
| --- | --- |
| `CHART_DIRS` | 이 이미지의 태그를 참조하는 차트 버전 디렉토리 (공백 구분, 여러 개 가능) |
| `TAG_STYLE` | `imageName`(단일 필드 문자열) \| `split`(registry/repository/tag 분리) |
| `TAG_BLOCK` | `split`일 때 태그가 있는 블록의 점 구분 경로 (기본 `image`) |
카탈로그는 한 차트의 여러 버전을 동시에 보관하는 "버전 보관소"다 — **이미지 하나가
여러 버전 디렉토리에 걸리는 것이 정상**이다. 새 버전 디렉토리를 만들 때 이 매핑도
함께 갱신한다. 빠뜨리면 `scripts/build/patch-catalog-tag.py`가 그 파일을 검사조차
하지 않아 낡은 태그가 조용히 남는다(실측: `cnpg-postgresql``cnpg-cluster/1.0.0`
매핑돼 있어 `1.1.0`이 낡은 태그로 남은 사고가 있었다).
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/apisix/2.16.0"
TAG_STYLE=split
TAG_BLOCK=ingress-controller.deployment.adcContainer.image
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/apisix/2.16.0"
TAG_STYLE=split
TAG_BLOCK=ingress-controller.deployment.image
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/apisix/2.16.0"
TAG_STYLE=split
TAG_BLOCK=image
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/argo-cd/10.4.0"
TAG_STYLE=split
TAG_BLOCK=global.image
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0"
TAG_STYLE=split
TAG_BLOCK=image
+2
View File
@@ -0,0 +1,2 @@
CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0 manifests/helm/cnpg-cluster/1.1.0"
TAG_STYLE=imageName
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/etcd/1.1.12"
TAG_STYLE=split
TAG_BLOCK=image
+3
View File
@@ -0,0 +1,3 @@
CHART_DIRS="manifests/helm/keycloakx/7.2.2"
TAG_STYLE=split
TAG_BLOCK=image
+4 -1
View File
@@ -9,7 +9,10 @@
" - 예외는 '위험을 수용한다'는 기록이다. 숫자를 지우는 수단으로 쓰면 목표 자체가 무의미해진다.",
"",
"예외를 늘리기 전에 먼저 검토할 것: 상위 태그로 교체 / 베이스 OS 교체 /",
"scripts/build/build-hardened-image.sh 로 자체 빌드. 예외는 마지막 수단이다."
"그래도 안 되면 별도 레포 security-images 에서 자체 빌드. 예외는 마지막 수단이다.",
"자체 빌드 이미지(docker.io/paasup/*)는 그 레포도 같은 이미지를 스캔하므로,",
"그 이미지에 대한 예외는 이 파일과 security-images 레포의 cve-exceptions.json",
"양쪽에 각각 등록해야 두 게이트 모두 통과한다."
],
"exceptions": [
{
@@ -1,63 +0,0 @@
# 0001. cnpg-cluster 의 PostgreSQL 이미지를 SUSE BCI 자체 빌드로 한다
- 날짜: 2026-07-28 (결정) · 2026-08-03 (`docker.io/paasup` 로 재빌드)
- 상태: 확정
- 원본: security-catalog ADR 0001 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고)
## 결정
`manifests/helm/cnpg-cluster/1.0.0` 의 PostgreSQL 이미지를 SUSE BCI 15.7 기반 자체 빌드로
한다. 현재 태그는 [custom-values.yaml](../../manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml)
`postgresql.imageName` 이 단일 출처이고, 빌드 정의는
[images/cnpg-postgresql/](../../images/cnpg-postgresql/)(`suse.Dockerfile` + `suse.build.env`)다.
## 배경
이전 값 `ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie` 에 두 문제가 있었다.
1. `system` 은 **업스트림에서 deprecated 된 타입**이다 (in-core barman phase out 예정)
2. 실효 CRITICAL/HIGH **6/26**, 게이트 차단 **32건** (2026-07-28 실측)
후보 셋을 비교했다.
| | A `standard-trixie` | B ubuntu:24.04 자체 빌드 | **C SUSE BCI 15.7 자체 빌드** |
| --- | --- | --- | --- |
| 실효 고유 C/H | 23 | 0 | 0 |
| 측정 가능성 | trivy 완전 | trivy 완전 | trivy 완전 |
| 서명·attestation | ✅ | ❌ | ❌ |
| 미조사 사각지대 | 0 | 18건 | 18건 |
| 배포 검증 | — | failover 2초 | failover 3초 |
| gid | `postgres` | ⚠️ `tape` 가 gid 26 선점 | `postgres` |
## 근거
- **A 의 차단 23건은 전부 수정 버전이 없다.** Debian 판정이 `unimportant` 9 / `no-dsa` 7 /
`postponed` 3 / 미분류 4 로, 어떤 조치로도 해소되지 않는다. KEV 교집합은 0건이다.
- **C 는 0건이고, 그 0건이 측정된 0건이다.** 결정 당시에는 "trivy 가 SLES 15.7 을 커버하지
않으므로 직접 평가해야 한다" 고 봤는데 **그 전제가 틀렸다**(2026-07-29 재측정). 깨끗한
이미지도 findings 0 이라 "0건" 과 "데이터 없음" 이 구분되지 않았던 것이 원인이다. 올바른
검사는 양성 대조이고, dip-catalog 는 그것을 `CoverageProbe`**매 스캔마다** 한다
([doc/sbom-pipeline.md](../sbom-pipeline.md)).
- **C 는 B 의 gid 위생 문제가 없다.** B 는 gid 26 이 `tape` 그룹에 선점되어 있다.
- **C 는 배포 검증을 통과했다** — CNPG 오퍼레이터 통합, 롤링 전환, 데이터 보존, failover 3초.
- **SUSE 공식 PostgreSQL 차트는 대안이 아니었다** — 확장이 `plpgsql` 하나뿐이다. C 는
`pgaudit`·`pgvector`·contrib 를 포함한다.
## 받아들인 비용
- **업스트림 서명·provenance·SBOM attestation 을 잃는다.**
- **재빌드 책임을 진다.** PostgreSQL 마이너 릴리스마다, 베이스 보안 업데이트마다. 후보 B 에서
이미 실증됐다 — 빌드 후 하루도 안 되어 glibc 업데이트로 findings 38 → 46.
- **업스트림이 테스트하지 않는 구성이다.** CNPG bake 매트릭스는 Debian 3종뿐이다.
- **`pg-failover-slots` 확장이 없다.** PGDG zypp 저장소에 패키지가 없다.
- **미조사 사각지대 18건.** SUSE 는 "미평가" 를 문서 부재로 표현하므로 **무엇이 미평가인지
열거할 수 없다.** 이것은 어떤 도구를 쓰든 같다(이 이미지 기준 벤더 데이터가 언급하지 않는
패키지 61개 / 176개). `0/0` 의 근거가 그만큼 불완전하다.
## 재검토 조건
- **A 의 차단 23건에 수정 버전이 도착하면** — 업스트림 이미지로 돌아가는 것이 유리해진다.
게이트 리포트의 `status: fixed` 여부로 감지된다.
- **사각지대 18건 조사 결과가 C 에 불리하면.**
- **재빌드 부담이 실제로 문제가 되면** — 분기 1회 이상 재빌드가 필요해지는 경우.
- **SUSE 가 BCI 기반 CNPG 호환 이미지를 직접 발행하면** — 자체 빌드가 불필요해진다.
@@ -1,72 +0,0 @@
# 0002. cloudnative-pg 오퍼레이터를 소스 컴파일 자체 빌드로 대체하고, 자체 빌드 오케스트레이션을 하나로 통일한다
- 날짜: 2026-07-30 (결정) · 2026-08-04 (`docker.io/paasup` 로 재빌드)
- 상태: 확정
- 원본: security-catalog ADR 0005 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고)
## 결정
`manifests/helm/cloudnative-pg/0.29.0` 의 오퍼레이터 이미지를 자체 빌드로 한다. 현재 태그는
[custom-values.yaml](../../manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml) 의
`image.repository`/`image.tag` 가 단일 출처이고, 빌드 정의는
[images/cloudnative-pg/](../../images/cloudnative-pg/)다 — 업스트림 `release-1.30` 브랜치의
pinned commit 을 직접 `go build` 로 컴파일하고, 최종 런타임 베이스는 SUSE BCI(`bci-micro`)다.
**추가로, 이 작업에서 `scripts/build/build-hardened-image.sh` 를 일반화해 OS 패키지
재설치형(`cnpg-postgresql`)과 소스 컴파일형(`cloudnative-pg`)이 오케스트레이션 스크립트
하나를 공유하게 했다.** 자체 빌드 이미지가 늘어날 때마다 방법론이 갈라지지 않게 하기
위함이다. 계약은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다.
## 배경
업스트림 `cloudnative-pg:1.30.0` 이 게이트에서 실효 HIGH 3건으로 차단됐다 — `CVE-2026-39822`
(stdlib), `CVE-2026-56852`(`golang.org/x/text`), `GHSA-hrxh-6v49-42gf`(grpc).
셋 다 Go 바이너리에 **정적 링크된 모듈 버전**이 원인이라 앞선 두 레버가 통하지 않는다.
- 상위 태그 교체 — 상위 태그가 없다(2026-07-30 확인, 최신 릴리스가 여전히 `v1.30.0`)
- 베이스 OS 교체 — 배포 이미지 베이스가 distroless 라 OS 패키지가 사실상 0개다
자체 빌드만 유효한 대응이었다. 세 CVE 모두 업스트림 `release-1.30` 브랜치에 이미 백포트돼
있어, Go 의존성을 직접 올릴 필요 없이 그 커밋을 그대로 컴파일하면 됐다.
## 근거
- **`release-1.30` HEAD 는 실측으로 확인된 안전한 픽업 지점이다.** `go.mod` 비교로 세 CVE 의
수정 버전이 전부 포함됨을 확인했고(stdlib 1.26.5, x/text 0.39.0, grpc 1.82.1), 재스캔으로
실효 C/H 0/0 을 확인했다.
- **게이트 통과가 "동작함" 을 증명하지 않는다는 원칙이 실제로 작동했다.** 1차 빌드는 게이트를
0/0 으로 통과했지만 배포 검증에서 `Cluster` 리컨실이 `invalid architecture: amd64`
실패했다. 오퍼레이터가 자신의 가용 아키텍처를 `operator/manager_<arch>` **파일 존재**로
판단하는데, 업스트림의 멀티아치 심볼릭 링크를 "부수 장치" 로 오판해 제거했기 때문이다.
같은 바이너리를 `/manager``/operator/manager_amd64` 양쪽에 COPY 해 해결했다.
- **베이스 OS 는 BCI 로 통일한다는 결정([0001](0001-cnpg-postgresql-image.md))이 이 이미지에도
적용된다.** 최초 설계는 "업스트림과 최대한 동일하게" 를 따라 distroless 를 그대로 썼으나
카탈로그 정책과 어긋나 `bci-micro` 로 교체했고, 교체 후에도 게이트·배포 검증 PASS 를
유지하는 것을 확인했다.
- **오케스트레이션 통일이 회귀를 만들지 않는 것을 확인했다** — 스크립트를 일반화한 직후 기존
`cnpg-postgresql` 을 다시 빌드해 태그·게이트 결과가 동일함을 확인했다.
## 받아들인 비용
- **업스트림 서명·provenance·SBOM attestation 을 잃는다.** ADR [0001](0001-cnpg-postgresql-image.md)
과 동일한 비용이다.
- **`SOURCE_COMMIT` 갱신을 사람이 한다.** 자동 추적하지 않는다 — 다음 CVE 발생 시 그 시점의
유지보수 브랜치 최신 커밋을 사람이 골라 `source.build.env` 를 고쳐 PR 을 여는 것 자체가
트리거다.
- **`release-1.30` 은 순수 패치 백포트가 아니다.** 기능 커밋 2개(`feat(pooler)` auth_user,
`feat` check_empty_wal_archive)가 섞여 있다. 59 commits 전체를 라인 단위로 감사하지 않았고,
배포 검증 스모크 수준으로만 회귀 여부를 봤다.
- **업스트림이 테스트하지 않는 구성이다.** BCI 기반 오퍼레이터 빌드는 CNPG 의 CI 매트릭스에 없다.
- **`bci-micro``bci-base` 와 달리 nonroot 계정이 없어 직접 만들어야 한다** — BCI
micro/minimal 계열을 쓰는 다른 자체 빌드 이미지도 같은 작업이 필요하다.
## 재검토 조건
- **업스트림이 `v1.30.1` 이상을 정식 릴리스하면** — 상위 태그 교체가 다시 가능해지므로 자체
빌드보다 우선한다.
- **`release-1.30` 의 기능 커밋 2개가 실제 회귀를 일으키면** — 기능 커밋 이전 시점 등 다른
pinned commit 으로 되돌린다.
- **SUSE 가 CNPG 호환 공식 오퍼레이터 이미지를 발행하면** — 자체 빌드가 불필요해진다.
- **`bci-micro` 에서 예기치 못한 런타임 문제(CA 신뢰 체인, TLS 등)가 나오면** — `bci-base`
승격하거나 다른 BCI 변종을 재검토한다.
+2 -1
View File
@@ -35,7 +35,8 @@ dev 클러스터 `apisix` 네임스페이스에 이미 `data-apisix-etcd-0` PVC
1. 패치가 멈춘 이미지는 시간이 지날수록 미수정 CVE 가 구조적으로 누적된다 —
"CRITICAL/HIGH 0건" 을 유지 가능한 상태로 지속할 수 없다.
2. 버전 고정 없는 `latest`-only 배포는 **롤링 태그 금지** 규칙과 애초에 양립하지 않는다
([.claude/image-authoring.md](../../.claude/image-authoring.md)).
(자체 빌드 이미지에 적용되는 이 규칙은 `security-images` 레포의 `docs/image-authoring.md`
가 갖는다).
`groundhog2k/etcd` 는 업스트림 etcd 프로젝트의 원본 이미지(`quay.io/coreos/etcd`)를 그대로
쓰므로 이 문제가 없다 — 다른 카탈로그 항목과 동일한 "업스트림 원본 이미지 + 버전 고정 태그"
@@ -1,66 +0,0 @@
# 0004. etcd 이미지를 소스 컴파일 자체 빌드로 대체한다
- 날짜: 2026-07-31 (결정) · 2026-08-04 (`docker.io/paasup` 로 재빌드)
- 상태: 확정
- 원본: security-catalog ADR 0007 — dip-catalog 맥락으로 다시 썼다([README](README.md) 참고)
## 결정
[manifests/helm/etcd/1.1.12/](../../manifests/helm/etcd/1.1.12/) 의 이미지를 자체 빌드로
한다. 현재 태그는 `custom-values.yaml``image.registry`/`image.repository`/`image.tag`
단일 출처이고, 빌드 정의는 [images/etcd/](../../images/etcd/)다 — 업스트림 `v3.7.1` 태그가
가리키는 commit 을 그대로 컴파일하되, `go.work` 워크스페이스 전역 `replace`
`golang.org/x/text` 만 강제 업그레이드한다(버전은 `source.build.env``XTEXT_FIX_VERSION`).
최종 런타임 베이스는 SUSE BCI(`bci-micro`) — [0002](0002-cloudnative-pg-operator-self-build.md)
와 같은 패턴이다.
**추가로, 이 작업에서 `build-image.yml` CI 를 이미지 종류 무관하게 일반화했다**
(`images/<image>/catalog.env` + `scripts/build/patch-catalog-tag.py`) — [0002](0002-cloudnative-pg-operator-self-build.md)
의 백로그였다. 자체 빌드 이미지가 둘일 때는 하드코딩으로 버틸 수 있었지만 세 번째가 생기며
실제로 한계에 부딪혔다.
## 배경
업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트에서 실효 HIGH 1건으로 차단됐다 —
`CVE-2026-56852`, `golang.org/x/text` 의 깨진 UTF-8 입력에 대한 `norm.Iter` 무한루프 DoS.
Go 바이너리에 정적 링크된 모듈 버전이 원인이라 베이스 OS 교체가 통하지 않고, 상위 태그도
없다(2026-07-31 확인, 최신 릴리스가 여전히 `v3.7.1`). 게다가 [0002](0002-cloudnative-pg-operator-self-build.md)
와 달리 **`release-3.7` 브랜치에 이 수정이 백포트조차 안 돼 있다**(브랜치 HEAD 가 태그와
완전히 동일). 자체 빌드 외에는 즉시 대응할 방법이 없었다.
## 근거
- **워크스페이스 전역 `replace` 한 줄로 게이트가 PASS 로 바뀌는 것을 실측했다** — 실효 C/H
0/1 → **0/0**. 이미 백포트된 커밋을 그대로 가져다 쓸 수 없었지만, 취약 모듈이 이 CVE
하나뿐이라 직접 강제 업그레이드로도 충분했다.
- **기능 검증(단일 노드 기동 + `etcdctl` put/get 왕복)이 통과했다.** 게이트 PASS 가 "동작함"
을 증명하지 않으므로 `images/etcd/verify.sh` 로 확인한다. 최초 시도는 최종 베이스에 `sed`
가 없어 실패했다(`sh: sed: command not found`) — `bci-base` 에는 있지만 `bci-micro` 에는
없다는 것을 이때 처음 확인했다. 순수 셸 루프로 교체해 해결하고 그 제약을
[.claude/image-authoring.md](../../.claude/image-authoring.md) 에 기록했다.
- **CI 일반화가 기존 이미지에 회귀를 만들지 않는 것을 확인했다** — `catalog.env` 를 세
이미지에 추가한 뒤 매트릭스로 동시에 검증 빌드했고 기존 두 이미지도 동일하게 PASS 했다.
- **태그 치환 로직을 실제 카탈로그 파일 사본에 돌려 검증했다** — `imageName` 단일 필드
(cnpg-cluster)와 `image:` 분리 필드(cloudnative-pg, etcd) 두 스타일 모두 기존 주석·포매팅을
보존한 채 정확히 치환됨을 diff 로 확인했다.
## 받아들인 비용
- **업스트림 서명·provenance·SBOM attestation 을 잃는다.** [0001](0001-cnpg-postgresql-image.md)
·[0002](0002-cloudnative-pg-operator-self-build.md) 와 동일한 비용이다.
- **`SOURCE_COMMIT`·`XTEXT_FIX_VERSION` 갱신을 사람이 한다.** 자동 추적하지 않는다.
- **매 patch 릴리스마다 이 자체 빌드를 다시 판단해야 한다** — 업스트림이 이 CVE 를
`release-3.7` 에 백포트하기 전까지는 "이미 백포트된 안전한 픽업 지점" 이 없어 재검토 부담이
[0002](0002-cloudnative-pg-operator-self-build.md) 보다 크다.
- **업스트림이 테스트하지 않는 구성이다.** BCI 기반 etcd 빌드는 etcd 의 CI 매트릭스에 없다.
- **강제 업그레이드가 `server`/`etcdctl`/`etcdutl` 각각에 반영됐는지는 SBOM 스캔으로만 간접
확인했다** — `go version -m` 직접 대조는 하지 않았다.
## 재검토 조건
- **etcd 가 `release-3.7` 에 이 CVE 를 백포트하고 `v3.7.2` 이상을 릴리스하면** — 상위 태그
교체가 다시 가능해지므로 자체 빌드보다 우선한다.
- **`bci-micro` 에서 예기치 못한 런타임 문제가 나오면** — `bci-base` 로 승격하거나 다른 BCI
변종을 재검토한다([0002](0002-cloudnative-pg-operator-self-build.md) 와 동일한 조건).
- **다중 노드(쿼럼) 배포 검증에서 이 자체 빌드 이미지에 문제가 발견되면.**
+13 -22
View File
@@ -1,6 +1,6 @@
# 결정 기록 (ADR)
카탈로그의 이미지·차트 선택 중 **재측정으로 복원되지 않는 것**만 여기 둔다.
카탈로그의 차트 선택 중 **재측정으로 복원되지 않는 것**만 여기 둔다.
CVE 건수·커버리지·패키지 버전 같은 수치는 게이트가 매 실행마다 다시 재므로 문서로 남기지
않는다([doc/sbom-pipeline.md](../sbom-pipeline.md)). 반면 "왜 이 후보를 골랐고 무엇을 비용으로
@@ -8,31 +8,22 @@ CVE 건수·커버리지·패키지 버전 같은 수치는 게이트가 매 실
| # | 결정 | 날짜 | 상태 |
|---|---|---|---|
| [0001](0001-cnpg-postgresql-image.md) | cnpg-cluster 의 PostgreSQL 이미지를 SUSE BCI 자체 빌드로 한다 | 2026-07-28 | 확정 |
| [0002](0002-cloudnative-pg-operator-self-build.md) | cloudnative-pg 오퍼레이터를 소스 컴파일 자체 빌드로 대체한다 | 2026-07-30 | 확정 |
| [0003](0003-etcd-chart-selection.md) | etcd 는 `groundhog2k/etcd` 단일 차트 · replicas=1 로 배포한다 | 2026-07-31 | 확정 |
| [0004](0004-etcd-image-self-build.md) | etcd 이미지를 소스 컴파일 자체 빌드로 대체한다 | 2026-07-31 | 확정 |
**이미지 자체 빌드 ADR(0001·0002·0004)은 별도 레포 `security-images` 로 이관됐다**
그 이미지들의 빌드 정의와 함께 그 레포의 `docs/decisions/` 에 있다. 카탈로그가 무엇을
배포하는가(차트)와 그 이미지를 어떻게 만드는가(자체 빌드)가 다른 레포에 놓이며 근거도
함께 옮긴 것이다. 이관 배경은
[doc/migrations/self-build-images-to-security-images.md](../migrations/self-build-images-to-security-images.md).
번호가 0003 하나만 남아 이어지지 않아 보이지만, 다른 세 번호가 예약돼 있던 것일 뿐이고
새 ADR 은 0005 부터 잇는다.
## 이 문서들의 출처
결정은 **security-catalog 프로젝트에서 내려졌**, 그 레포의 ADR 0001·0005·0006·0007 이
원본이다. dip-catalog 의 이미지·차트가 그 결정의 산물이므로 이 레포에도 근거가 있어야 한다.
**원문을 복사하지 않고 다시 썼다.** 원본은 같은 레포의 다른 문서(`doc/analysis/*`,
`doc/plans/*`)를 근거로 인용하는데, 그것들까지 연쇄로 가져오면 dip-catalog 에 이중 기록이
생기고 그러고도 링크가 또 밖으로 나간다. 그래서 **결론과 근거만 추려 자립적으로** 옮겼다 —
이 디렉토리의 문서는 레포 밖을 가리키는 링크가 없다.
이관하지 않은 것과 그 이유. 아래 경로는 **security-catalog 안의 경로이고 이 레포에는 없다**
링크로 걸지 않은 이유가 그것이다.
| security-catalog 의 원본 | 왜 안 가져왔나 |
|---|---|
| `analysis/sles-oval-measurement.md` | 스캐너 커버리지 실측. 원문 스스로 "재측정하면 갱신된다" 고 밝히는 스냅샷이고, dip-catalog 는 이것을 `CoverageProbe` 로 **매 스캔마다 자동 재측정**한다 |
| `analysis/*-cve.md` (3종) | 착수 시점의 CVE 스냅샷. 결론은 각 ADR 과 `images/<image>/README.md` 에 들어 있고, 현재 수치는 게이트가 낸다 |
| `cve-zero-pipeline.md` · `architecture/build-pipeline.md` | [doc/sbom-pipeline.md](../sbom-pipeline.md) 와 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 dip-catalog 의 단일 출처다 |
| `image-selection.md` | 이미지 선정 규칙은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다 |
| `charts/*/deploy-test.md` | 배포 검증은 `scripts/deploy-test/*.sh` 가 실행하고 절차는 [.claude/deploy-test-procedure.md](../../.claude/deploy-test-procedure.md) 가 갖는다. 기록은 실행 산출물이라 문서로 고정하지 않는다 |
이 카탈로그의 결정은 원래 **security-catalog 프로젝트에서 내려졌다.** 결정 당시의 원본
분석·계획 문서(`doc/analysis/*`, `doc/plans/*`)까지 연쇄로 가져오면 이중 기록이 생기므로
**결론과 근거만 추려 자립적으로** 옮겼다 — 이 디렉토리의 문서는 레포 밖을 가리키는 링크가
없다.
## 새 ADR 을 언제 쓰나
@@ -0,0 +1,95 @@
# 자체 빌드 하드닝 이미지 → `security-images` 레포 이관
이 문서는 자체 빌드 이미지 프레임워크·이미지 정의를 별도 public 레포
`security-images` 로 분리한 작업의 기록이다. 무엇이 어디로 갔고, 두 레포가 지금
무엇으로 계약하는지, 아직 안 된 것이 무엇인지를 담는다.
## 배경
이 카탈로그는 두 서로 다른 성격의 축을 한 레포에 담고 있었다.
- **차트 카탈로그 축** — "무엇을 배포 중인가" (`manifests/helm/`, warn-only 게이트)
- **자체 빌드 축** — "그 이미지를 어떻게 만드는가" (`images/`, 강제 게이트)
`security-images` 가 public 이 될 예정이라 세 조건이 붙었다: **외부 의존성 없이
단독 동작**(클론 + `docker` + `trivy` 만으로 빌드·게이트 완결), **슬림 게이트**(이미지
판정에 불필요한 차트 축 로직 제거), **산업화**(내부 호스트명·개인 계정·이슈/PR 번호
제거). 이 조건들이 원래 `.claude/image-authoring.md` 가 적어 두었던 "레포 분리 후
무엇이 끊기는가" 계획을 여러 지점에서 다시 설계하게 만들었다 — 아래 "원래 계획과
달라진 점"이 그 차이다.
## 무엇이 옮겨갔는가
| 카탈로그(이 레포)에 있던 것 | `security-images` 로 |
|---|---|
| `images/<image>/` 8개 | 그대로(경로 보존) |
| `scripts/build/build-hardened-image.sh` | 그대로(경로 보존) |
| `scripts/build/suggest-go-upgrades.py` | `--apply`/`--dry-run` 신규 추가 |
| `scripts/pipeline/scan-sbom.sh` · `cve-gate.py` (이미지 판정 부분만) | `scripts/gate/scan-image.sh` · `image-gate.py` (슬림화) |
| `.github/workflows/build-image.yml` | 카탈로그 무의존으로 재구성(`catalog.env` 의존 제거, drift 모드 제거) |
| `.claude/image-authoring.md` | `docs/image-authoring.md` (정책 중심으로 재작성) |
| `doc/decisions/0001·0002·0004`(이미지 ADR) | `docs/decisions/`(같은 번호 유지) |
| `.claude/skills/self-build-image/` | 그대로(문서 경로만 수정) |
## 무엇이 남았는가 (이 레포)
카탈로그가 "무엇을 배포 중인가"를 알아야 하는 부분만 남았다 — 전부 새로 만든 것이다.
| 경로 | 역할 |
|---|---|
| `catalog/image-map/<image>.env` | 자체 빌드 이미지 → 어느 차트의 어느 필드(`CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK`). 옛 `images/<image>/catalog.env` 의 카탈로그 레이아웃 정보만 뗀 것 |
| `scripts/build/check-rebuild-needed.py` | 드리프트 탐지(A 파트만) — 배포 중인 이미지가 새 CVE 로 규정을 벗어났는가. 핀 판단(B 파트)은 제거했다 — `security-images``suggest-go-upgrades.py` 가 갖는다 |
| `scripts/build/apply-published-tags.py` | `security-images``published.json` 을 카탈로그 values 에 반영 |
| `scripts/build/patch-catalog-tag.py` | 변경 없음(원래도 카탈로그 파일만 다루는 순수 도구였다) |
| `.github/workflows/self-build-drift-check.yml` | 주간, 배포 중인 이미지 스캔 → 재빌드 필요하면 `security-images``build-image.yml``workflow_dispatch` 로 트리거만 |
| `.github/workflows/catalog-tag-update.yml` | 매일, `published.json` 조회 → 카탈로그 values 패치 → 브랜치 push |
## 계약 — `published.json` 하나뿐
`security-images`**이 카탈로그를 모른다.** 게이트 PASS + 레지스트리 push 가 실제로
일어났을 때만 자기 레포의 `published.json` 을 갱신한다:
```json
{"schemaVersion": 1, "images": {"<image>": {"ref": "...", "tag": "...", "digest": "...", "gate": "pass"}}}
```
`catalog-tag-update.yml` 이 이 파일을 **public raw URL** 로 읽어간다 — 인증도, 그
레포의 시크릿도 필요 없다. 의존 방향은 **카탈로그 → 이미지 단방향**이다.
`repository_dispatch` 같은 역방향 알림은 쓰지 않는다 — 그러려면 `security-images`
시크릿에 이 카탈로그 쓰기 권한 PAT 을 둬야 하는데, 그건 그 레포의 "외부 의존성 없이
단독 동작" 원칙과 충돌한다.
## 원래 계획과 달라진 점
`.claude/image-authoring.md`(삭제됨, 원문은 `security-images` 의 git 히스토리에)의
"레포 분리 후 무엇이 끊기는가" 는 게이트를 **composite action** 으로 공개해 두 축이
`uses:` 로 공유하는 안을 제시했다. 실제로는 그렇게 하지 않았다 — 두 가지 이유다.
1. **`suggest-go-upgrades.py``cve-gate.py``importlib` 로 모듈 로드했다.**
composite action 은 워크플로 스텝만 공유하고 파이썬 모듈 임포트를 공유하지 못한다.
`effective_severity` 함수를 게이트의 "정식 소유"로 옮기고(이 레포의 `cve-gate.py` ·
`security-images``image-gate.py` 양쪽에 독립적으로), 두 게이트가 완전히 갈라지는
쪽을 택했다.
2. **게이트 규칙 분기를 허용하기로 했다.** 차트 축은 warn-only, 자체 빌드 축은 강제라
강제력부터 다르다. 공유 action 대신 각자 자기 게이트를 소유하고, `max(벤더, NVD)`
계산과 예외 파일 스키마만 같게 유지하기로 했다(승인 예외는 카탈로그 자체 빌드
이미지에도 별도로 등록해야 한다 — `doc/cve-exceptions.json``_readme` 참고).
카탈로그 반영도 원래 계획(`patch-catalog-tag.py` 를 카탈로그 쪽에서 그대로 재사용)은
맞았지만, **트리거 방식**이 달라졌다 — `repository_dispatch` 가 아니라 `published.json`
을 카탈로그가 주기적으로 읽어가는 pull 방식이다. 위 "계약" 절 참고.
## 아직 안 된 것
- **`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿 미등록.** `self-build-drift-check.yml`
`security-images``build-image.yml``workflow_dispatch` 로 부르려면
그 레포에 `workflow_dispatch` 권한이 있는 PAT 이 필요하다. 등록 전까지 트리거 스텝은
명시적으로 실패한다.
- **`security-images` 레포 자체가 아직 GitHub 에 없다.** 로컬 레포만 있는 상태에서
이 이관을 진행했다 — GitHub 레포 생성·push, `DOCKERHUB_USER`/`DOCKERHUB_TOKEN` 시크릿
등록이 선행돼야 위 두 워크플로가 실제로 동작한다.
- **`published.json``digest` 필드 대부분 공란.** `security-images` 가 아직 실제로
이미지를 재빌드·push 하지 않아서다. 다음 빌드가 채운다 — MEMORY.md 의 "카탈로그 태그가
클러스터보다 앞서 있다" 항목이 이 필드를 쓸 계획이다.
- **`manifests/applicationset/**``catalog/image-map/` 대상이 아니다.** 옛
`catalog.env` 도 그랬다 — MEMORY.md #42(고착된 낡은 차단 태그)와 같은 공백이다.
+15 -12
View File
@@ -11,8 +11,9 @@ GitHub Actions([.github/workflows/sbom.yml](../.github/workflows/sbom.yml))로
| | 다룬다 — **차트 카탈로그 축** | 다루지 않는다 — **자체 빌드 축** |
|---|---|---|
| 질문 | 우리가 배포하는 이미지에 무엇이 있는가 | 그 이미지를 어떻게 만드는가 |
| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `build-image.yml` |
| 단일 출처 | **이 문서** | [.claude/image-authoring.md](../.claude/image-authoring.md) |
| 실행 위치 | 이 레포 | 별도 레포 `security-images` |
| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `security-images``build-image.yml`(이 레포에서는 `self-build-drift-check.yml` 이 필요할 때 그것을 부른다) |
| 단일 출처 | **이 문서** | `security-images` 레포의 `docs/image-authoring.md` |
**카탈로그 values 가 자체 빌드 이미지(`docker.io/paasup/*`)를 가리키므로 그 이미지도 이 문서의
스캔 대상이다** — 축이 갈린 것은 "누가 만들고 결정하는가" 이고 "누가 스캔되는가" 가 아니다.
@@ -136,8 +137,8 @@ docker buildx build --platform linux/amd64 \
> python 으로 갖고 있어 **승인 예외(`cve-exceptions.json`)도 실효 등급(`max(벤더,NVD)`)도
> 적용하지 않는다.** 같은 스캔 데이터에서 다른 숫자가 나올 수 있다.
**PR 을 실제로 막는 게이트는 `images/**` 를 건드린 PR 에만 있다** — 이 축의 `sbom.yml` 은
warn-only 다.
**PR 을 실제로 막는 게이트는 이 축에 없다**`sbom.yml` 은 warn-only 다. 강제 게이트는
자체 빌드 축(`security-images` 레포의 `images/**` PR)에만 있다.
> **`manifests/applicationset/**` 를 스캔하지 않는 것은 의도다.** 그 아래 `dip-values.yaml` 은
> dip-console 이 배포 values 를 만들 때 쓰는 **참조 파일**이고, 같은 이미지를 `manifests/helm/`
@@ -146,7 +147,8 @@ warn-only 다.
>
> 단, 그 가정은 **"두 곳이 같은 이미지를 가리킨다"** 에 의존한다. 참조 파일이 차트와 다른
> 태그를 들고 있으면 스캔한 것과 배포되는 것이 갈린다 — 태그 동기화는 스캔 커버리지와 별개
> 문제이고 `scripts/build/patch-catalog-tag.py` 의 `CHART_DIRS` 범위가 그것을 결정한다.
> 문제이고, 자체 빌드 이미지에 대해서는 `catalog/image-map/<image>.env` 의 `CHART_DIRS`
> 그 범위를 결정한다(`manifests/applicationset/**` 는 포함하지 않는다 — MEMORY.md #42 참고).
### `sbom.yml` 스캔 범위
@@ -193,7 +195,8 @@ Repo Secret `CVE_API_KEY`).
**`sbom.yml` 에서는 아직 `--warn-only` 다** — 게이트가 실패해도 워크플로/PR 을 막지 않는다. 카탈로그
차트 전체가 아직 이 게이트로 트리아지된 적이 없어, 강제 전환 전에 먼저 전체 스캔 1회로 현황을
파악해야 한다(`MEMORY.md`). `build-image.yml` 의 게이트는 이미 강제다.
파악해야 한다(`MEMORY.md`). 자체 빌드 축(`security-images` 레포의 `build-image.yml`)의
게이트는 이미 강제다 — 단, 그 게이트는 별도 레포가 소유한다.
> **커버리지 자가진단(`CoverageProbe`).** `scan-sbom.sh` 는 os-pkgs findings 가 0건인
> 이미지의 SBOM 사본에 배포판별(deb/rpm/apk) 센티널 패키지를 주입해 재스캔하고, 발화
@@ -205,15 +208,15 @@ Repo Secret `CVE_API_KEY`).
## 자체 빌드 축은 이 문서가 다루지 않는다
게이트가 상위 태그 교체·베이스 OS 교체로 해소되지 않는 차단 CVE 를 찾으면 자체 빌드로 간다.
그 축의 단일 출처는 **[.claude/image-authoring.md](../.claude/image-authoring.md)** 다 —
`build-image.yml` 의 트리거·게이트 강도·카탈로그 반영·레포 분리 계획이 전부 거기 있다.
그 축**별도 레포 `security-images`** 가 갖는다 — 빌드·검증·게이트·push 전부 그 레포
안에서 이루어지고, 단일 출처는 그 레포의 `docs/image-authoring.md` 다. 이 카탈로그에는
"어느 차트가 그 이미지를 가리키는가"(`catalog/image-map/`)와 드리프트 탐지
(`scripts/build/check-rebuild-needed.py`, 주간 `self-build-drift-check.yml`)만 남아 있다 —
이관 배경은 [doc/migrations/](migrations/self-build-images-to-security-images.md).
이 문서가 알아야 할 것은 하나뿐이다: **자체 빌드 이미지도 카탈로그가 가리키는 한 위 스캔·게이트
대상이다.** 실제로 `docker.io/paasup/*` 가 게이트 리포트에 등장한다.
> `build-image.yml` 이 `sbom.yml` 과 별도 파일인 이유: `sbom.yml` 은 `vars.SBOM_PIPELINE_IMAGE`
> 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. 빌드는 호스트 러너여야 한다.
## GitHub 설정 (워크플로 활성화에 필요)
`Settings → Secrets and variables → Actions`
@@ -221,7 +224,7 @@ Repo Secret `CVE_API_KEY`).
| 종류 | 이름 | 용도 |
|------|------|------|
| **Variable** | `SBOM_PIPELINE_IMAGE` | 실행 이미지 태그 (예: `docker.io/paasup/sbom-pipeline:20260820`) |
| Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io + docker.getcollate.io rate limit 회피**. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. `build-image.yml` 의 push 에도 같은 시크릿을 쓴다 |
| Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io + docker.getcollate.io rate limit 회피**. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. `security-images` 레포도 자체 push 용으로 별도 등록된 같은 이름의 시크릿을 쓴다(이 레포와는 무관하게 그 레포에 따로 등록) |
| Secret | `NGC_API_KEY` | **nvcr.io 인증**(NVIDIA nemo/nim — 없으면 pull 불가) |
| Secret | `CVE_API_KEY` | `cve-edge-post.yml` 의 외부 엔드포인트 인증 |
-129
View File
@@ -1,129 +0,0 @@
# adc — 자체 빌드
adc(APISIX/API7 관리 CLI, apisix-ingress-controller 의 사이드카) 를 업스트림 소스에서
SUSE BCI 위에 직접 빌드한다. `manifests/helm/apisix/2.16.0/custom-values.yaml`
`ingress-controller.deployment.adcContainer.image.repository`/`tag`가 이 산출물을
가리킨다.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나 — "태그 교체로 끝난 줄 알았는데 아니었다"
apisix 카탈로그 CVE 조치 중 `ghcr.io/api7/adc`를 0.27.1 → 0.29.0(확인 시점 최신 태그)
으로 올렸다. 업스트림 0.29.0 릴리스 노트: "distroless 이미지로 전환해 베이스 이미지
취약점을 0으로 줄였다" — 실제로 `trivy image` 원시 스캔은 CRITICAL/HIGH 0/0 이 맞다.
**하지만 이건 벤더 등급만 본 결과다.** 카탈로그 게이트(`scripts/pipeline/cve-gate.py`)
`max(벤더, NVD)`로 재평가하는데, 이걸로 다시 돌리면 아래 4건이 실효
CRITICAL/HIGH 로 여전히 차단한다(2026-08-12 실측):
| CVE | 실효 등급 | 벤더 | NVD | 패키지 | status |
| --- | --- | --- | --- | --- | --- |
| CVE-2019-1010022 | CRITICAL | LOW | CRITICAL (9.8) | libc6 | affected, 수정 없음 |
| CVE-2019-1010023 | HIGH | LOW | HIGH (8.8) | libc6 | affected, 수정 없음 |
| CVE-2018-20796 | HIGH | LOW | HIGH (7.5) | libc6 | affected, 수정 없음 |
| CVE-2019-9192 | HIGH | LOW | HIGH (7.5) | libc6 | affected, 수정 없음 |
전부 2018~2019년 glibc regex/collating 스택오버플로 버그다. Debian 이 "affected,
수정 버전 없음"으로 영구 고정해뒀다 — `cnpg-postgresql` 자체 빌드 때 겪은 것과 완전히
같은 패턴("Debian 은 unimportant/no-dsa 로 판단해 안 고치는데 다른 배포판은 이미
백포트했음"). 이미 최신 태그(0.29.0)라 상위 태그 교체 레버는 소진됐고, 남은 건 베이스
OS 교체뿐이다.
## SUSE 로 바꾸면 실제로 해소되는지 사전 검증
`registry.suse.com/bci/bci-base:15.7` + `zypper install nodejs24`만으로 최소 이미지를
만들어 SBOM·스캔해봤다 — **위 4건이 전혀 안 잡힌다**(2026-08-12 실측). SUSE 글리브C
에는 이미 고쳐져 있다는 뜻이다.
## 업스트림과 다르게 하는 부분 — 딱 한 줄
업스트림 Dockerfile(`libs/tools/src/docker/Dockerfile`, 태그 `v0.29.0`)은 2단계다:
```dockerfile
FROM node:lts-bookworm-slim AS builder # pnpm+nx 로 main.cjs 하나로 번들
...
FROM gcr.io/distroless/nodejs24-debian13:nonroot
COPY --from=builder /build/dist/apps/cli/main.cjs .
ENTRYPOINT [ "/nodejs/bin/node", "main.cjs" ]
```
빌더 스테이지(pnpm/nx 빌드)는 정책 대상이 아니라(`.claude/image-authoring.md` 원칙 2)
**업스트림 그대로 재사용한다.** 바뀌는 건 최종 스테이지 베이스 한 줄뿐이다:
- `gcr.io/distroless/nodejs24-debian13:nonroot``registry.suse.com/bci/bci-base:15.7`
+ `zypper install nodejs24`(같은 Node 24 메이저 유지 — SUSE 표준 패키지라
cnpg-postgresql 때 겪은 openresty 전용 -devel 문제도 없다)
- 엔트리포인트 경로 `/nodejs/bin/node``/usr/bin/node`(zypper 설치 경로, 실측)
- non-root 사용자: distroless `:nonroot` 태그 대신 명시적으로 `adc` 시스템 계정 생성
애플리케이션 코드(`main.cjs` 번들)는 업스트림과 100% 동일하다 — apisix/
apisix-ingress-controller 자체 빌드보다 훨씬 단순한 유형(cnpg-postgresql 과 같은
"OS 패키지 재설치형"에 가깝다 — 소스를 여러 모듈 컴파일하는 게 아니라 빌더 스테이지를
그대로 두고 런타임 베이스만 바꾼다). 실측 빌드 시간도 2분 이내(대부분 pnpm
install+nx build 시간).
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/api7/adc.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT``v0.29.0` 태그(lightweight, peeled 커밋 없음)가 가리키는 실제 커밋 |
| 빌더 | 업스트림과 동일한 `node:lts-bookworm-slim` — 정책 대상 아님(빌더 스테이지) |
| 최종 베이스 | `registry.suse.com/bci/bci-base:15.7` + `nodejs24` |
`SOURCE_COMMIT`**자동 추적하지 않는다.** 다른 자체 빌드 이미지와 동일하게, 사람이
업스트림 새 릴리스(또는 위 4건을 이미 해소한 버전)를 보고 `source.build.env`
고쳐 PR을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE를 다시 보고할 때, 또는
`api7/adc`가 다음 릴리스를 내놓았을 때 — 릴리스가 나오면 그쪽으로 갈아타는 것(대응
우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=adc BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지
IMAGE=adc BASE_OS=source REGISTRY=docker.io/paasup \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`verify.sh`는 non-root 실행 여부, `--version` 출력, `--help`의 커맨드 목록(dump/diff/
sync)을 확인한다 — **adc 는 실제 APISIX/API7 백엔드 접속이 필요한 명령이 대부분이라
그 연동 자체는 이 스모크 범위 밖**이다. dev 클러스터 배포 검증
(`.claude/deploy-test-procedure.md`)이 담당한다.
**결과(2026-08-12 실측)**: 게이트 `PASS`, 커버리지 `ok`, 실효 CRITICAL/HIGH `0/0`
(원본 4건에서 완전 해소). `docker.io/paasup/adc:0.29.0-security-hardened-20260812`
로 push 완료.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 빌더 스테이지(업스트림 그대로) + SUSE BCI 최종 스테이지 |
| `source.build.env` | pinned commit·버전(`BUILD_ARGS`에 나열한 이름만 `--build-arg` 로 전달됨) |
| `verify.sh` | 기능 검증 — non-root·버전·--help 커맨드 확인 |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다.
### 태그
```
docker.io/paasup/adc:0.29.0-security-hardened-20260812
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
## 아직 안 한 것 — 배포 검증
게이트 PASS·기능 스모크테스트(버전+help)까지만 확인했다. **apisix-ingress-controller
와 함께 실제 APISIX/API7 백엔드에 접속해 dump/diff/sync 가 정상 동작하는지는 아직
확인하지 않았다** — `.claude/deploy-test-procedure.md` 절차로 카탈로그 반영 전
반드시 수행할 것(apisix-ingress-controller 자체 빌드는 이미 이 절차로 배포 검증
완료됨 — 같은 방식으로 진행).
-14
View File
@@ -1,14 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(source.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/apisix/2.16.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
# apisix 서브차트 alias(ingress-controller) 아래 deployment.adcContainer.image 로
# 중첩돼 있다 — 점 구분 경로는 scripts/build/patch-catalog-tag.py 가 이미 지원한다
# (apisix-ingress-controller 자체 빌드 추가 때 확장됨, 새 확장 불필요).
TAG_BLOCK=ingress-controller.deployment.adcContainer.image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
-65
View File
@@ -1,65 +0,0 @@
# adc — 업스트림 ghcr.io/api7/adc:0.29.0 을 대체하는 자체 빌드.
#
# 업스트림(libs/tools/src/docker/Dockerfile, 태그 v0.29.0)은 2단계다:
# FROM node:lts-bookworm-slim AS builder (pnpm+nx 로 main.cjs 하나로 번들)
# FROM gcr.io/distroless/nodejs24-debian13:nonroot
# COPY --from=builder main.cjs .
# builder 스테이지는 정책 대상이 아니므로(.claude/image-authoring.md 원칙 2) 그대로
# 재현한다 — **바뀌는 건 final 스테이지 베이스 한 줄뿐이다.**
#
# 왜 자체 빌드인가 — 0.29.0(2026-08-05 배포, 확인 시점 최신 태그)이 distroless 전환으로
# 벤더 관점 CRITICAL/HIGH 는 0/0 이지만, 게이트가 max(벤더,NVD) 로 재평가하면 glibc
# regex/collating 스택오버플로 4건(CVE-2019-1010022/23, CVE-2018-20796, CVE-2019-9192,
# 전부 libc6)이 실효 CRITICAL/HIGH 로 잡힌다(2026-08-12 실측) — Debian 이 "affected,
# 수정 버전 없음"으로 영구 고정해둔 벤더 하향 등급 사례다(cnpg-postgresql 과 같은 패턴).
# SUSE BCI 글리브C 에는 이 4건이 아예 없음을 실측 확인했다(registry.suse.com/bci/
# bci-base:15.7 + nodejs24 설치 후 SBOM 스캔, 2026-08-12).
#
# 업스트림과 다르게 하는 부분 — final 스테이지 베이스만
# gcr.io/distroless/nodejs24-debian13:nonroot → registry.suse.com/bci/bci-base:15.7
# + zypper install nodejs24 (같은 Node 메이저 버전 24 로 유지 — SUSE 표준 패키지,
# distroless 전용 -devel 류가 필요 없다. 업스트림처럼 배포판 무관 패키지 매니저로
# 설치하는 형태라 openresty-pcre-devel 같은 문제가 없다)
# /nodejs/bin/node → /usr/bin/node (zypper 가 설치한 실제 경로, 2026-08-12 실측)
# 애플리케이션 코드(main.cjs 번들)는 업스트림과 100% 동일하다 — 빌더 스테이지를 그대로
# 재사용하므로 diff 가 최소다.
# syntax=docker/dockerfile:1
ARG BUILDER_BASE=node:lts-bookworm-slim
ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7
FROM ${BUILDER_BASE} AS builder
ARG SOURCE_COMMIT
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME/bin:$PATH"
ENV NX_DAEMON="false"
RUN corepack enable
WORKDIR /build
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/api7/adc.git#${SOURCE_COMMIT} /build
RUN pnpm install nx -g \
&& pnpm install \
&& NODE_ENV=production nx build cli
FROM ${RUNTIME_BASE} AS final
ARG NODE_PKG=nodejs24
RUN zypper -n install -y ${NODE_PKG} \
&& zypper -n clean --all
COPY --from=builder /build/dist/apps/cli/main.cjs /adc/main.cjs
# 업스트림 최종 스테이지는 distroless ":nonroot" 태그로 non-root 를 기본 적용한다 —
# bci-base 는 그런 태그 변형이 없어 명시적으로 사용자를 만든다.
RUN groupadd --system --gid 1000 adc \
&& useradd --system --gid adc --no-create-home --shell /usr/sbin/nologin --uid 1000 adc \
&& chown -R adc:adc /adc
WORKDIR /adc
USER adc
ENTRYPOINT ["/usr/bin/node", "main.cjs"]
-23
View File
@@ -1,23 +0,0 @@
# adc — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 빌더 스테이지는 업스트림 그대로(node:lts-bookworm-slim), final 스테이지만 SUSE BCI 로
# 바뀐다 — "베이스 OS 선택지"가 없는 소스 빌드형이라 BASE_OS=source 로 호출한다:
# IMAGE=adc BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치
# (2026-08-12)로 도입 — 근거·경과는 MEMORY.md.
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 0.29.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=0.29.0
# "v0.29.0" 태그가 가리키는 실제 커밋(lightweight 태그 — peeled 커밋 별도로 없음),
# 2026-08-12 확인: git ls-remote --tags https://github.com/api7/adc.git v0.29.0
SOURCE_COMMIT=6594ee9f7cd9fd8786c0d11601c1ed05377646a1
BUILD_ARGS="SOURCE_COMMIT"
-42
View File
@@ -1,42 +0,0 @@
#!/usr/bin/env bash
# adc 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh` 형태로 호출한다).
#
# 최종 베이스가 bci-base 라 bash/coreutils 가 있다 — 게스트 셸을 그대로 쓴다. adc 는
# APISIX/API7 백엔드에 실제로 접속해야 하는 명령(dump/diff/sync)이 대부분이라 그건 이
# 스모크 범위 밖이다(백엔드 연동은 dev 클러스터 배포 검증이 담당,
# .claude/deploy-test-procedure.md). 여기서는 백엔드 없이 확인 가능한 것만 본다:
# 바이너리 실행 가능 여부, 버전 문자열, --help 커맨드 목록, non-root 실행.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
echo "== 실행 사용자 (nonroot) =="
USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")"
[ "$USER_CFG" = "adc" ] || { echo "FAIL: 이미지 Config.User 가 adc 가 아니다 (실제: $USER_CFG)"; exit 1; }
echo " Config.User=$USER_CFG"
docker run --rm -i --platform "$PLATFORM" --entrypoint sh "$TAG" <<'GUEST'
set -e
echo "== 버전 =="
OUT="$(/usr/bin/node /adc/main.cjs --version)"
echo " $OUT"
case "$OUT" in
*0.29.0*) ;;
*) echo "FAIL: 버전 출력에 0.29.0 이 없다"; exit 1 ;;
esac
echo "== --help 스모크 (백엔드 없이 도는 유일한 확인 범위) =="
OUT="$(/usr/bin/node /adc/main.cjs --help)"
case "$OUT" in
*"dump"*"diff"*"sync"*) ;;
*) echo "FAIL: --help 출력에 예상 커맨드(dump/diff/sync)가 없다"; exit 1 ;;
esac
echo " dump/diff/sync 커맨드 확인됨"
echo "VERIFY-OK"
GUEST
-116
View File
@@ -1,116 +0,0 @@
# apisix-ingress-controller — 자체 빌드
apisix-ingress-controller 바이너리를 업스트림 소스에서 직접 컴파일한다.
`manifests/helm/apisix/{2.14.0,2.16.0}/custom-values.yaml`
`ingress-controller.deployment.image.repository`/`tag` 가 이 산출물을 가리킨다.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나
apisix 카탈로그 CVE 조치(2026-08-11) 중 발견 — `apache/apisix-ingress-controller:2.1.0`
(2026-05-29 빌드, **이 시점 기준 최신 태그**)가 게이트에서 차단하는 CRITICAL/HIGH 25건 중
21건이 OS 패키지가 아니라 바이너리에 **정적 링크된 Go 모듈** 버전이 원인이다:
| 모듈 | 설치 버전 | 필요 버전 | 관련 CVE(대표) |
| --- | --- | --- | --- |
| stdlib (Go 툴체인) | go1.24.7 | go1.25.12/1.26.5+ | CVE-2026-39822 외 다수 |
| golang.org/x/net | v0.47.0 | v0.56.0 | CVE-2026-25681, 27136, 39821 |
| golang.org/x/text | v0.31.0 | v0.39.0 | CVE-2026-56852 |
| google.golang.org/grpc | v1.71.1 | v1.82.1 | CVE-2026-33186, GHSA-hrxh-6v49-42gf |
| go.opentelemetry.io/otel | v1.40.0 | v1.43.0 | CVE-2026-29181 |
| go.opentelemetry.io/otel/sdk | v1.40.0 | v1.43.0 | CVE-2026-39883 |
이미 최신 태그라 **상위 태그 교체가 불가능**하다. 나머지 4건(libc6 3건, libssl3 1건)은
업스트림 베이스(`gcr.io/distroless/cc-debian12`)의 OS 패키지지만, 그 4건만 잡자고
베이스를 바꿔도 위 21건은 그대로 남는다 — 자체 빌드(소스 재컴파일)가 유일한 대응이다.
## 업스트림과 다르게 하는 부분
**애플리케이션 코드는 `v2.1.0` 태그 그대로다.** cloudnative-pg 처럼 "release 브랜치
HEAD 로 옮겨서 백포트된 수정을 받는" 방식을 먼저 검토했지만, 이 프로젝트는 그런 유지보수
브랜치가 없다 — `master` 하나뿐이고, 2026-08-11 확인 시점 `master` HEAD 는 `v2.1.0` 태그
대비 커밋 35개 앞서 있으며 그 사이에 Gateway API 1.6.0 지원·L4RoutePolicy·mTLS 지원 같은
**신규 기능 커밋이 다수 섞여 있다.** 최소 diff 원칙(`.claude/image-authoring.md`)에 맞지
않아 쓰지 않았다.
대신 위 표의 취약 모듈만 `go get`(+`go mod tidy`)로 최소 호환 버전까지 끌어올린다.
`go.work` 워크스페이스가 없는 단일 모듈 프로젝트라 etcd 처럼 전역 `replace` 한 줄로 끝나지
않고 여러 모듈을 한 번에 지정해야 하지만(otel/otel-sdk 는 버전이 서로 맞아야 해서 반드시
함께 지정), 로컬에서 `go mod tidy` + `go build`(linux/amd64, `CGO_ENABLED=0`)가 정상
종료하는 것을 실측 확인했다(2026-08-11) — 의존성 그래프가 서로 충돌하지 않는다.
`gcr.io/distroless/cc-debian12``registry.suse.com/bci/bci-micro:15.7`(카탈로그는 SUSE
BCI 하나만 쓴다 — `.claude/image-authoring.md` 원칙 2). 바이너리가 `CGO_ENABLED=0` 정적
링크라 애초에 `cc`(glibc 포함) 변형이 필요 없었다 — 업스트림이 왜 `cc` 를 쓰는지는 불명
(안전 마진으로 추정), `static` 대비 이점이 없어 우리는 `bci-micro` 로 통일한다.
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/apache/apisix-ingress-controller.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT``2.1.0` 태그가 가리키는 실제 커밋 |
| 빌더 | 공식 `golang` 이미지(`source.build.env``GO_BUILDER_TAG`) — 의존성 업그레이드 후 `go mod tidy``go.mod``go 1.25`/`toolchain go1.26.5` 로 자동 상향했다(2026-08-11 실측) |
| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` |
`SOURCE_COMMIT`·의존성 최소 버전은 **자동 추적하지 않는다.** 다른 자체 빌드 이미지와
동일하게, 사람이 업스트림 새 태그(또는 이 CVE 세트를 이미 해소한 커밋)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
업스트림이 `2.1.1`(혹은 그 이상, 위 표의 CVE 를 이미 해소한 릴리스)을 내놓았을 때 —
릴리스가 나오면 그쪽으로 갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다
항상 우선이다.
> apisix 카탈로그는 한때 2.14.0/2.16.0 두 버전을 함께 보관해 apisix-ingress-controller
> 앱 버전(2.0.1/2.1.0)도 갈렸었다 — 2.14.0 은 2026-08-11 삭제되어(구버전 유지 대신 최신
> 버전 하나로 정리) 지금은 변종이 하나뿐이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=apisix-ingress-controller BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지
IMAGE=apisix-ingress-controller BASE_OS=source REGISTRY=docker.io/paasup \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`verify.sh` 는 바이너리가 실행 가능한지, `version --long` 출력에 pinned commit 이 실제로
반영됐는지(ldflags 주입 확인), `--help` 가 정상 종료하는지, 이미지가 `65532:65532`
(nonroot) 로 실행되는지를 확인한다 — **이 컨트롤러는 Kubernetes API 서버 접속이 있어야
실제로 기동한다**, 그 부분은 이 스모크 테스트 범위 밖이며 dev 클러스터 배포 테스트
(`.claude/deploy-test-procedure.md`)가 담당한다. 이 세션에서는 클러스터가 없어 배포
검증을 아직 하지 못했다 — **카탈로그 반영 전 반드시 수행할 것.**
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) |
| `source.build.env` | pinned commit·버전·빌더 이미지 태그·취약 모듈 최소 버전. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 `docker run --entrypoint sh` 로 게스트 셸 스크립트를 주입한다(`bci-micro` 는 bash·coreutils 가 있다) |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다.
### 태그
```
docker.io/paasup/apisix-ingress-controller:2.1.0-security-hardened-20260811
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
### 카탈로그 반영
`ingress-controller.deployment.image` 가 apisix 서브차트 alias 아래 중첩돼 있어
(`ingress-controller:``deployment:``image:`), `catalog.env``TAG_BLOCK`
점 구분 경로(`ingress-controller.deployment.image`)를 쓴다. 기존
`scripts/build/patch-catalog-tag.py` 는 top-level 블록만 지원했어서, 이 이미지를
추가하며 중첩 경로까지 지원하도록 확장했다(첫 세그먼트는 여전히 top-level 강제, 그
뒤부터는 부모 블록 범위 안에서만 찾아 다른 형제 블록의 동명 키를 오인하지 않는다) —
기존 이미지(단일 세그먼트 `image`)의 동작은 회귀 테스트로 그대로임을 확인했다.
@@ -1,16 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
#
# apisix 카탈로그는 2.16.0 하나만 보관한다(2.14.0 은 2026-08-11 삭제 — 구버전 유지 대신
# 최신 버전 하나로 정리).
CHART_DIRS="manifests/helm/apisix/2.16.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
# apisix 서브차트 alias(ingress-controller) 아래 deployment.image 로 중첩돼 있다 — 점
# 구분 경로는 scripts/build/patch-catalog-tag.py 가 지원한다(2026-08-11, 이 이미지
# 추가하며 top-level 전용이던 걸 중첩 경로까지 확장).
TAG_BLOCK=ingress-controller.deployment.image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
@@ -1,98 +0,0 @@
# apisix-ingress-controller — 업스트림 소스를 pinned commit(v2.1.0 태그)으로 직접
# 컴파일하고, 취약한 전이 의존성만 강제로 올린다(cloudnative-pg/etcd 자체 빌드와 동일한
# 패턴). 업스트림 루트 Dockerfile 은 이미 빌드된 바이너리를 COPY 만 한다 — 컴파일 자체는
# Makefile 의 `build`/`build-multi-arch` 타겟(`CGO_ENABLED=0 go build ...`)이 한다. 그
# 빌드 단계를 Dockerfile 안으로 가져와 재현한다.
#
# 왜 자체 빌드인가 — apache/apisix-ingress-controller:2.1.0(2026-05-29 빌드, 현재 최신
# 태그)이 게이트에서 차단하는 CRITICAL/HIGH 25건 중 21건이 OS 패키지가 아니라 바이너리에
# 정적 링크된 Go 모듈(stdlib, golang.org/x/net, golang.org/x/text, google.golang.org/grpc,
# go.opentelemetry.io/otel[/sdk])이 원인이다(2026-08-11 실측, apisix 카탈로그 CVE 조치).
# 이미 최신 태그라 상위 태그 교체가 불가능하고, 베이스가 distroless(cc-debian12)라
# libc6/libssl3 OS 패키지 4건만 베이스 교체로 잡히고 나머지는 못 잡는다 — 자체 빌드가
# 유일한 대응이다.
#
# 업스트림과 다르게 하는 부분 — 취약 모듈만 강제 업그레이드(go.work 가 없는 단일 모듈
# 프로젝트라 etcd 처럼 워크스페이스 전역 replace 를 못 쓴다. `go get`+`go mod tidy` 로
# 대신한다). 애플리케이션 코드 자체는 v2.1.0 태그 그대로다 — master 는 릴리스 이후 기능
# 커밋이 다수 섞여 있어(2026-08-11 기준 태그 대비 35커밋 앞섬) 최소 diff 원칙에 맞지 않아
# 쓰지 않았다.
# golang.org/x/net v0.47.0 -> v0.56.0 (CVE-2026-25681/27136/39821 등)
# golang.org/x/text v0.31.0 -> v0.39.0 (CVE-2026-56852)
# google.golang.org/grpc v1.71.1 -> v1.82.1 (CVE-2026-33186, GHSA-hrxh-6v49-42gf)
# go.opentelemetry.io/otel v1.40.0 -> v1.43.0 (CVE-2026-29181)
# go.opentelemetry.io/otel/sdk v1.40.0 -> v1.43.0 (CVE-2026-39883, otel 코어와 버전 동기 필요)
# 이 조합은 `go get` 가 알아서 서로 호환되는 최소 버전으로 끌어올린다(go mod tidy 로 확정) —
# 로컬에서 go build 성공 실측 완료(2026-08-11).
#
# gcr.io/distroless/cc-debian12 → SUSE BCI(bci-micro)로 교체. 카탈로그는 SUSE BCI
# 하나만 쓴다(.claude/image-authoring.md 원칙 2) — builder 스테이지는 공식 golang
# 이미지를 그대로 쓴다. CGO_ENABLED=0 정적 바이너리라 cc(glibc 포함) 변형이 애초에
# 필요 없었다 — 업스트림이 왜 cc 를 쓰는지는 불명(안전 마진으로 추정), static 대비
# 이점이 없어 우리는 bci-micro 로 통일한다.
#
# ldflags — 업스트림 Makefile 의 GO_LDFLAGS 를 그대로 재현한다(internal/version 패키지의
# 빌드타임 심볼 4개). GitSHA 자리에는 pinned commit 전체 해시를 심어 verify.sh 가 컨테이너
# 안에서 git 을 실행하지 않고도 버전 문자열로 확인할 수 있게 한다(etcd/cloudnative-pg 와
# 동일한 이유).
# syntax=docker/dockerfile:1
ARG GO_BUILDER_TAG=1.26.5-trixie
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — .claude/image-authoring.md.
ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
ARG MIN_K8S_VERSION
ARG XNET_FIX_VERSION
ARG XTEXT_FIX_VERSION
ARG GRPC_FIX_VERSION
ARG OTEL_FIX_VERSION
WORKDIR /src
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/apache/apisix-ingress-controller.git#${SOURCE_COMMIT} /src
# 취약 전이 의존성만 최소 버전으로 강제 업그레이드한다(위 설명 참고). otel/otel-sdk 는
# 서로 버전이 맞아야 해서 함께 지정한다 — go get 이 나머지 호환 버전을 알아서 정리한다.
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go get \
golang.org/x/net@v${XNET_FIX_VERSION} \
golang.org/x/text@v${XTEXT_FIX_VERSION} \
google.golang.org/grpc@v${GRPC_FIX_VERSION} \
go.opentelemetry.io/otel@v${OTEL_FIX_VERSION} \
go.opentelemetry.io/otel/sdk@v${OTEL_FIX_VERSION} \
&& go mod tidy
# 업스트림 Makefile 의 build 타겟을 그대로 재현한다(GOARCH 는 TARGETARCH 로 대체, GitSHA
# 자리에 pinned commit 전체 해시를 심는다 — 위 설명 참고).
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
mkdir -p /out; \
VERSYM="github.com/apache/apisix-ingress-controller/internal/version._buildVersion"; \
GITSHASYM="github.com/apache/apisix-ingress-controller/internal/version._buildGitRevision"; \
BUILDOSSYM="github.com/apache/apisix-ingress-controller/internal/version._buildOS"; \
MINK8SVERSYM="github.com/apache/apisix-ingress-controller/internal/manager._minK8sVersion"; \
LDFLAGS="-X=${VERSYM}=${APP_VERSION} -X=${GITSHASYM}=${SOURCE_COMMIT} -X=${BUILDOSSYM}=linux/${TARGETARCH} -X=${MINK8SVERSYM}=${MIN_K8S_VERSION}"; \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -ldflags="${LDFLAGS}" -o /out/apisix-ingress-controller cmd/main.go
FROM ${RUNTIME_BASE} AS final
# 업스트림과 동일한 경로 — Helm 차트가 별도 경로를 하드코딩하지 않으므로 필수는 아니지만
# 업스트림 Dockerfile 과의 대응을 그대로 유지한다.
WORKDIR /app
COPY --from=builder /out/apisix-ingress-controller ./apisix-ingress-controller
COPY --from=builder /src/LICENSE /licenses/LICENSE
COPY --from=builder /src/NOTICE /licenses/NOTICE
# 업스트림은 distroless `:nonroot` 태그로 65532:65532 를 기본 사용자로 쓴다 — bci-micro 는
# 그런 태그 변형이 없어 명시적으로 지정한다.
USER 65532:65532
ENTRYPOINT ["/app/apisix-ingress-controller"]
CMD ["-c", "/app/conf/config.yaml"]
@@ -1,47 +0,0 @@
# apisix-ingress-controller — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=apisix-ingress-controller BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치(2026-08-11)
# 로 도입 — 근거·경과는 MEMORY.md.
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 2.1.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=2.1.0
# v2.1.0 태그가 가리키는 실제 커밋(peeled commit), 2026-08-11 확인.
# apisix-ingress-controller 는 cloudnative-pg 식 "release-X.Y 브랜치"가 없다(master 하나) —
# master HEAD 는 태그 대비 커밋 35개 앞서 있고 신기능 커밋이 섞여 있어(2026-08-11 확인)
# 최소 diff 원칙(.claude/image-authoring.md)에 맞지 않는다. 태그 그대로 두고 의존성만 올린다.
SOURCE_COMMIT=f4a8dd1223573a5b72e1c7b65b37c13b49d98042
# 업스트림 Makefile 의 MIN_K8S_VERSION 기본값(ldflags 로 바이너리에 심긴다).
MIN_K8S_VERSION=1.26.0
# go.mod 의 `go 1.25`/`toolchain go1.26.5` 요구(의존성 업그레이드 후 go mod tidy 가 자동
# 상향한 값, 2026-08-11 로컬 실측)를 만족하는 공식 golang 이미지 태그. 빌더 스테이지에만
# 쓰이고 최종 이미지에는 남지 않는다.
#
# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를
# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다:
# python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports> --image apisix-ingress-controller
GO_BUILDER_TAG=1.26.6-trixie
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(.claude/image-authoring.md 원칙 2).
# 정적 링크 바이너리(CGO_ENABLED=0)라 패키지 매니저가 없는 가장 가벼운 bci-micro 로 충분하다.
RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# 차단 CVE 해소에 필요한 최소 버전(2026-08-11 실측, source.Dockerfile 상단 주석 참고).
# go get 이 서로 호환되는 조합으로 정리한다 — 개별 go.sum 버전을 여기 나열하지 않는다.
XNET_FIX_VERSION=0.56.0
XTEXT_FIX_VERSION=0.39.0
GRPC_FIX_VERSION=1.82.1
OTEL_FIX_VERSION=1.43.0
BUILD_ARGS="SOURCE_COMMIT APP_VERSION MIN_K8S_VERSION GO_BUILDER_TAG RUNTIME_BASE XNET_FIX_VERSION XTEXT_FIX_VERSION GRPC_FIX_VERSION OTEL_FIX_VERSION"
@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# apisix-ingress-controller 이미지 기능 검증 — 호스트에서 bash 로 실행된다
# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh`
# 형태로 호출한다).
#
# 최종 베이스가 bci-micro 라 bash/coreutils 가 있다(패키지 매니저만 없음 —
# .claude/image-authoring.md). 다만 이 바이너리는 컨트롤러라 실제 기동에는 Kubernetes
# API 서버 접속이 필요하다 — 그건 이 스모크 테스트 범위 밖이고 dev 클러스터 배포 검증
# (.claude/deploy-test-procedure.md)이 담당한다. 여기서는 k8s API 없이 확인 가능한 것만
# 본다: 바이너리 존재, 버전 문자열에 pinned commit 반영, --help 정상 종료, 실행 사용자.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
echo "== 실행 사용자 (nonroot, 이미지 메타데이터) =="
USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")"
[ "$USER_CFG" = "65532:65532" ] || { echo "FAIL: 이미지 Config.User 가 65532:65532 가 아니다 (실제: $USER_CFG)"; exit 1; }
echo " Config.User=$USER_CFG"
docker run --rm -i --platform "$PLATFORM" -e SOURCE_COMMIT="$SOURCE_COMMIT" --entrypoint sh "$TAG" <<'GUEST'
set -e
echo "== 바이너리 탐색 =="
[ -x /app/apisix-ingress-controller ] || { echo "FAIL: /app/apisix-ingress-controller 실행 파일 없음"; exit 1; }
echo " /app/apisix-ingress-controller 존재·실행 가능"
echo "== 버전 (pinned commit 반영 확인) =="
OUT="$(/app/apisix-ingress-controller version --long)"
while IFS= read -r line; do echo " $line"; done <<EOF
$OUT
EOF
case "$OUT" in
*"$SOURCE_COMMIT"*) ;;
*) echo "FAIL: 버전 출력에 pinned commit($SOURCE_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;;
esac
echo "== --help 스모크 (k8s API 없이 도는 유일한 확인 범위) =="
/app/apisix-ingress-controller --help >/dev/null
echo " --help 종료 코드 0"
echo "VERIFY-OK"
GUEST
-153
View File
@@ -1,153 +0,0 @@
# apisix — 자체 빌드
apisix(APISIX-Runtime + APISIX 애플리케이션 + keycloak-authz 커스텀 플러그인) 전체를
업스트림 소스에서 SUSE BCI 위에 직접 컴파일한다.
`manifests/helm/apisix/2.16.0/custom-values.yaml``image.repository`/`tag`가 이
산출물을 가리킨다.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나
apisix 카탈로그 CVE 조치(2026-08-11~12) 중 발견 — 기존엔 별도 프로젝트
(`paasup/dataup` 레포 `experiment/apisix/apisix-plugin`)에서 `apache/apisix:3.17.0-debian`
위에 keycloak-authz 커스텀 플러그인만 얹어 빌드했다. 이 Debian 베이스 자체가 게이트
차단 26건(2026-08-11 실측) — `apache/apisix:3.17.0-debian`은 확인 시점 기준 **이미
최신 업스트림 태그**라 상위 태그 교체로는 해소가 안 되고, 전부 Debian 베이스 OS
패키지(libc, perl, pcre 등) CVE라 베이스 OS를 통째로 바꿔야 했다.
## 업스트림과 다르게 하는 부분 — SUSE 에 이 이미지가 없는 이유
업스트림(`apache/apisix:3.17.0-debian`)은 vanilla OpenResty 가 아니라 **"APISIX-Runtime"**
이라는 커스텀 컴파일 nginx다 — OpenSSL 3.4.1·zlib·PCRE 를 직접 빌드해 넣고,
`apisix-nginx-module`·`wasm-nginx-module`(WASM, wasmtime)·`lua-var-nginx-module`·
`lua-resty-events`·`mod_dubbo`·`ngx_multi_upstream_module``--add-module`
**컴파일 타임에 정적으로** 얹는다(2026-08-11 `openresty -V` 로 실측 — 아래 "소스·버전
관리" 표에 정확한 버전 나열). 이 조합을 배포하는 apiseven 의 빌드 파이프라인
(`api7/apisix-build-tools`)은 **Debian/RHEL(UBI9) 용 사전 빌드 패키지만** 만든다 — SUSE
용은 없다.
- **vanilla OpenResty로 대체 불가**: openresty.org 는 SLES 15.x 를 공식 지원해 zypper
로 설치할 수 있지만, 그건 위 커스텀 모듈(WASM·mod_dubbo 등)이 하나도 없는 순정
빌드다. `--add-module`은 컴파일 타임에만 바이너리에 정적으로 박히는 옵션이라 이미
컴파일된 vanilla 바이너리에 나중에 모듈을 추가할 수 없다 — nginx의 "동적 모듈"
메커니즘도 이 모듈들이 그 형태로 배포되지 않아 적용 안 된다. 우리 apisix 배포가
WASM 플러그인과 `http-dubbo` 관련 기능을 공식적으로 지원해야 한다는 요구사항이 있어
(`custom-values.yaml``apisix.plugins``http-dubbo` 포함), 모듈을 뺀 축소판으로
타협하지 않고 업스트림과 동일한 모듈 구성을 그대로 재현했다.
- **Debian/RHEL 사전 빌드 RPM/DEB 를 SUSE 에 강제 설치도 불가**: 같은 이유로 시도하지
않았다 — glibc/openssl 심볼 버전이 안 맞아 ABI 가 깨질 위험(cnpg-postgresql 때와
달리 이번엔 SUSE 자체 저장소에 대응 패키지가 없어 "같은 배포판 계열 재설치"가 성립
하지 않는다).
- 그래서 **apiseven 의 공식 빌드 스크립트(`api7/apisix-build-tools`, 태그
`apisix-runtime/1.3.6``openresty -V``APISIX_RUNTIME_VER=1.3.6`과 실측 일치)를
그대로 재현**해 SUSE BCI 위에서 소스 컴파일했다. 애플리케이션 코드·모듈 버전은
업스트림과 100% 동일하고, 베이스 OS만 바뀐다(diff 최소화 원칙).
## 3단계 구성
| 단계 | 목표 |
| --- | --- |
| `runtime` | OpenSSL 3.4.1·zlib·PCRE 를 `$OR_PREFIX/{openssl3,zlib,pcre}`에 직접 빌드하고, OpenResty 소스에 위 커스텀 모듈 6종을 `--add-module`로 붙여 컴파일한다. openresty.org 의 SLES 전용 `-devel` 패키지가 없어 이 경로들을 소스 빌드로 채운다. |
| `apisix-app` | `runtime`이 만든 OpenResty/LuaJIT 위에 APISIX 애플리케이션(Lua 코드 + 일부 C 확장 rock)을 `luarocks make`로 설치한다. |
| `final` | 위 두 스테이지 산출물만 깨끗한 SUSE BCI 이미지로 옮기고, 런타임에 필요한 공유 라이브러리(libxml2·libxslt·libyaml·pcre·pcre2)만 추가, non-root(`apisix`, uid 636) 설정, keycloak-authz 플러그인 오버레이까지 마친다. |
## 실측 함정 (2026-08-11/12)
- **빌드 순서 — zlib 을 OpenSSL 보다 먼저 빌드한다.** OpenSSL 의 `zlib` config 옵션이
컴파일 타임에 `zlib.h` 를 요구한다 — 반대 순서로 뒀다가 `zlib.h: No such file`
실패했다.
- **`lua-resty-saml`(luarocks 가 자동으로 받는 의존 rock)이 SUSE 표준 `libxml2-devel`
(2.12.10)의 `xmlSetStructuredErrorFunc` 시그니처(콜백 인자에 `const` 추가됨)와 안
맞아 `-Werror=incompatible-pointer-types` 로 빌드 실패한다.** rock 자체의 Makefile 이
`-Werror` 를 하드코딩해 환경변수 `CFLAGS` 로는 못 끈다 — `apisix-app` 스테이지 안에서만
쓰는 `gcc` 래퍼(`-Wno-error=incompatible-pointer-types` 추가)로 그 진단 하나만
경고로 낮추고, `luarocks make` 끝나면 즉시 원복한다(다른 빌드에 영향 안 줌).
source.Dockerfile 의 해당 RUN 스텝 주석 참고.
- **`ui/`(Admin 대시보드 프론트엔드)는 `apache/apisix` 저장소 자체엔 없다.** apiseven
의 패키징 파이프라인이 별도로(Node.js/yarn 빌드) 끼워 넣는 단계라 git clone 만으로는
재현 안 된다 — 우리 배포는 이 UI 를 쓰지 않아(`custom-values.yaml` 에 관련 설정 없음)
없으면 빈 디렉터리로 대체하고 건너뛴다.
- **`/usr/bin/apisix` 래퍼 스크립트가 내부적으로 `awk` 를 쓴다.** OpenResty 버전 파싱에
쓰는데, `final` 스테이지에 `gawk` 를 안 깔면 "awk: command not found" 로 `apisix
version`부터 실패한다.
- **SUSE 공유 라이브러리 패키지명은 soname 이 붙는다.** `libxml2`/`libxslt`/`pcre` 같은
이름 그대로는 없고 `libxml2-2`·`libxslt1`·`libpcre1`·`libpcre2-8-0`·`libyaml-0-2`
`libxml2-2`/`libpcre2-8-0`/`libldap-2_4-2``bci-base` 에 기본 포함이라 명시 안
해도 되지만, 명확성을 위해 실측한 이름 그대로 적었다.
- **`docker build --pull` 은 베이스 이미지 digest 가 바뀌면 캐시를 전부 무효화한다.**
`build-hardened-image.sh`가 항상 `--pull` 을 쓰므로(스케줄 재빌드가 최신 베이스를
전제하기 때문), Dockerfile 을 안 고쳤어도 SUSE BCI 태그가 그 사이 갱신되면 전체
재컴파일(약 35분)이 발생할 수 있다 — Dockerfile 반복 수정·테스트는 `--pull` 없는
순수 `docker build` 로 하고, 최종 검증에서만 `build-hardened-image.sh` 를 쓴다.
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| APISIX 소스 | `https://github.com/apache/apisix.git` (태그 `3.17.0`) |
| OpenResty | `1.29.2.4` |
| OpenSSL | `3.4.1` |
| zlib / PCRE | `1.3.1` / `8.45` |
| 커스텀 모듈 | `apisix-nginx-module 1.19.5`, `wasm-nginx-module 0.7.0`, `lua-var-nginx-module v0.5.3`, `lua-resty-events 0.2.0`, `ngx_multi_upstream_module 1.3.3`, `mod_dubbo 1.0.2` |
| APISIX_RUNTIME_VER | `1.3.6` (apiseven 빌드 스크립트 버전 태그) |
| 최종 베이스 | `registry.suse.com/bci/bci-base:15.7` |
전부 `source.build.env` 에서 관리하며 **자동 추적하지 않는다.** 다른 자체 빌드 이미지와
동일하게, 사람이 업스트림 새 릴리스(또는 위 CVE 세트를 이미 해소한 버전)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
`apache/apisix``3.17.1`(혹은 그 이상)을 내놓았을 때 — 릴리스가 나오면 그쪽으로
갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=apisix BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지
IMAGE=apisix BASE_OS=source REGISTRY=docker.io/paasup \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`verify.sh``apisix version` 출력·nginx 설정 문법(`nginx -t`)뿐 아니라, standalone
YAML 설정으로 **실제 nginx worker 를 기동**해 APISIX 라우터가 HTTP 요청에 응답하는지
(라우트 없음 → 404)까지 확인한다 — 15개 커스텀 모듈 + ~90개 Lua 플러그인(lua-resty-saml
같은 C 확장 포함) 로딩까지 실제로 거치는 유일한 방법이다. **keycloak-authz 커스텀
플러그인은 실제 Keycloak 연동이 필요해 이 스모크 범위 밖**이다 — dev 클러스터 배포
검증(`.claude/deploy-test-procedure.md`)이 담당한다.
**결과(2026-08-12 실측)**: 게이트 `PASS`, 커버리지 `ok`, 실효 CRITICAL/HIGH `0/0`
(업스트림 26건에서 완전 해소). `docker.io/paasup/apisix:3.17.0-security-hardened-20260811`
로 push 완료.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 3단계(runtime/apisix-app/final) |
| `source.build.env` | pinned 버전 전체(`BUILD_ARGS`에 나열한 이름만 `--build-arg` 로 전달됨) |
| `verify.sh` | 기능 검증 — 실제 nginx 기동 + HTTP 요청까지 |
| `keycloak-authz.lua` | `paasup/dataup` 레포(`experiment/apisix/apisix-plugin/keycloak-authz/`)와 동일한 커스텀 플러그인 — 원본 레포는 그대로 두고 이 카탈로그가 더 이상 참조만 안 한다 |
| `docker-entrypoint.sh` | 업스트림 `apache/apisix-docker`(`utils/docker-entrypoint.sh`)와 동일 |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다.
### 태그
```
docker.io/paasup/apisix:3.17.0-security-hardened-20260811
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
## 아직 안 한 것 — 배포 검증
게이트 PASS·기능 스모크테스트(nginx 기동+HTTP 응답)까지만 확인했다. **실제 Keycloak
연동(keycloak-authz 플러그인 동작), etcd 연동(externalEtcd), ingress-controller 와의
연동을 포함한 실제 클러스터 배포 검증은 아직 하지 않았다** —
`.claude/deploy-test-procedure.md` 절차로 카탈로그 반영 전 반드시 수행할 것.
-13
View File
@@ -1,13 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(source.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/apisix/2.16.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
# apisix 차트 자체의 최상위 image 블록(서브차트 alias 아래가 아니다 — 그건
# apisix-ingress-controller 자체 빌드가 쓰는 ingress-controller.deployment.image).
TAG_BLOCK=image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
-64
View File
@@ -1,64 +0,0 @@
#!/usr/bin/env bash
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
set -eo pipefail
PREFIX=${APISIX_PREFIX:=/usr/local/apisix}
if [[ "$1" == "docker-start" ]]; then
if [ "$APISIX_STAND_ALONE" = "true" ]; then
# If the file is not present then initialise the content otherwise update relevant keys for standalone mode
if [ ! -f "${PREFIX}/conf/config.yaml" ]; then
cat > ${PREFIX}/conf/config.yaml << _EOC_
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
_EOC_
fi
if [ ! -f "${PREFIX}/conf/apisix.yaml" ]; then
cat > ${PREFIX}/conf/apisix.yaml << _EOC_
routes:
-
#END
_EOC_
fi
/usr/bin/apisix init
else
/usr/bin/apisix init
/usr/bin/apisix init_etcd
fi
# For versions below 3.5.0 whose conf_server has not been removed.
if [ -e "/usr/local/apisix/conf/config_listen.sock" ]; then
rm -f "/usr/local/apisix/conf/config_listen.sock"
fi
if [ -e "/usr/local/apisix/logs/worker_events.sock" ]; then
rm -f "/usr/local/apisix/logs/worker_events.sock"
fi
if [ -e "/usr/local/apisix/logs/stream_worker_events.sock" ]; then
rm -f "/usr/local/apisix/logs/stream_worker_events.sock"
fi
exec /usr/local/openresty/bin/openresty -p /usr/local/apisix -g 'daemon off;'
fi
exec "$@"
-168
View File
@@ -1,168 +0,0 @@
-- keycloak-authz APISIX custom plugin
-- Kong keycloak-authz 플러그인을 APISIX로 포팅
--
-- 동작:
-- 1. X-Access-Token 헤더에서 JWT 추출 → preferred_username 파싱
-- 2. 외부 API (/gwapi/v1/projectusers/{username}) POST 호출로 권한 확인
-- 3. 결과를 TTL 기반으로 in-memory 캐싱 (lrucache)
-- 4. 미인가 시 401, 내부 오류 시 500 반환
local core = require("apisix.core")
local http = require("resty.http")
local jwt = require("resty.jwt")
local lrucache = require("resty.lrucache")
local cjson = require("cjson.safe")
local plugin_name = "keycloak-authz"
-- 최대 1024개의 사용자 인증 결과를 캐싱
local auth_cache, err = lrucache.new(1024)
if not auth_cache then
error("failed to create lrucache: " .. (err or "unknown"))
end
local schema = {
type = "object",
properties = {
api_url = {
type = "string",
description = "권한 확인 API의 base URL (예: https://api.example.com)",
},
basic_auth_token = {
type = "string",
description = "API 호출 시 사용할 Basic 인증 토큰 (Base64 인코딩된 값)",
default = "",
},
timeout = {
type = "integer",
description = "API 호출 타임아웃 (밀리초)",
default = 5000,
minimum = 100,
},
skip_ssl_verify = {
type = "boolean",
description = "API 호출 시 SSL 인증서 검증 생략 여부",
default = false,
},
ttl = {
type = "integer",
description = "인증 결과 캐싱 시간 (초)",
default = 300,
minimum = 1,
},
},
required = {"api_url"},
}
local _M = {
version = 0.1,
priority = 950, -- Kong의 PRIORITY = 950과 동일
name = plugin_name,
schema = schema,
}
function _M.check_schema(conf)
return core.schema.check(schema, conf)
end
-- 외부 API 호출로 사용자 권한 확인
local function fetch_auth_status(api_url, preferred_username, basic_auth_token, timeout, skip_ssl_verify)
local httpc = http.new()
local scheme = ngx.var.scheme
local host = ngx.var.host
local url = scheme .. "://" .. host
local body, encode_err = cjson.encode({ url = url })
if encode_err then
return nil, "failed to encode request body: " .. encode_err
end
local headers = { ["Content-Type"] = "application/json" }
if basic_auth_token and basic_auth_token ~= "" then
headers["Authorization"] = "Basic " .. basic_auth_token
end
local full_url = api_url .. "/gwapi/v1/projectusers/" .. preferred_username
local res, req_err = httpc:request_uri(full_url, {
method = "POST",
body = body,
headers = headers,
timeout = timeout,
ssl_verify = not skip_ssl_verify,
keepalive_timeout = 60000,
keepalive_pool = 10,
})
httpc:close()
if not res then
return nil, "API request failed: " .. (req_err or "unknown error")
end
core.log.debug("auth API response status=", res.status, " user=", preferred_username)
return res.status == 200, nil
end
function _M.access(conf, ctx)
-- X-Access-Token 헤더 추출
local access_token = core.request.header(ctx, "X-Access-Token")
if not access_token then
return core.response.exit(401, { message = "Missing X-Access-Token header" })
end
-- JWT 디코딩 (서명 검증 없이 페이로드만 파싱)
local jwt_obj = jwt:load_jwt(access_token)
if not jwt_obj or not jwt_obj.payload then
core.log.warn("failed to decode JWT token")
return core.response.exit(401, { message = "Failed to decode JWT token" })
end
local preferred_username = jwt_obj.payload["preferred_username"]
if not preferred_username then
return core.response.exit(401, { message = "Missing preferred_username in JWT token" })
end
local cache_key = "auth_status:" .. preferred_username
-- 캐시 조회
local cached_result = auth_cache:get(cache_key)
if cached_result ~= nil then
core.log.debug("cache hit for user=", preferred_username, " result=", tostring(cached_result))
if not cached_result then
return core.response.exit(401, { message = "User is not authorized" })
end
ctx.authenticated_user = preferred_username
return
end
-- 캐시 미스: 외부 API 호출
core.log.debug("cache miss, fetching auth status for user=", preferred_username)
local is_authorized, fetch_err = fetch_auth_status(
conf.api_url,
preferred_username,
conf.basic_auth_token or "",
conf.timeout or 5000,
conf.skip_ssl_verify or false
)
if fetch_err then
core.log.error("auth check failed user=", preferred_username, " err=", fetch_err)
return core.response.exit(500, { message = "Internal server error" })
end
-- 결과 캐싱 (TTL 적용)
auth_cache:set(cache_key, is_authorized, conf.ttl or 300)
core.log.debug("cached auth result user=", preferred_username, " authorized=", tostring(is_authorized))
if not is_authorized then
core.log.warn("authorization denied for user=", preferred_username)
return core.response.exit(401, { message = "User is not authorized" })
end
ctx.authenticated_user = preferred_username
end
return _M
-268
View File
@@ -1,268 +0,0 @@
# apisix — 업스트림 apache/apisix:3.17.0-debian 를 대체하는 자체 빌드.
#
# 업스트림은 vanilla OpenResty 가 아니라 "APISIX-Runtime" 이라는 커스텀 컴파일 nginx다
# (openssl3·zlib·pcre 를 직접 빌드해 넣고, apisix-nginx-module·wasm-nginx-module(WASM,
# wasmtime)·lua-var-nginx-module·lua-resty-events·mod_dubbo·ngx_multi_upstream_module
# 를 --add-module 로 정적으로 얹는다 — 2026-08-11 `openresty -V` 로 실측). 업스트림 빌드
# 스크립트(https://github.com/api7/apisix-build-tools, 태그 apisix-runtime/1.3.6 —
# `openresty -V` 의 APISIX_RUNTIME_VER=1.3.6 과 실측 일치)가 배포판 무관하게 소스에서
# 컴파일하는 구조라 SUSE BCI 로 옮길 수 있었다 — Debian/RHEL 전용 사전빌드 RPM/DEB 를
# SUSE 에 강제 설치하는 건 ABI 가 안 맞아 불가능하다(dockerfiles/Dockerfile.apisix.rpm
# 등 참고, 둘 다 UBI9/Ubuntu 전용).
#
# 왜 자체 빌드인가 — apache/apisix:3.17.0-debian(2026-08-06 배포, 확인 시점 최신 태그)이
# 게이트 차단 26건. 이미 최신 태그라 상위 태그 교체 불가, Debian 베이스 OS 패키지
# (libc/perl/pcre 등) CVE 라 이번엔 정적 링크 바이너리가 아니라 진짜 베이스 OS 문제다.
#
# 구성(3단계):
# 1) runtime — OpenSSL 3.4.1·zlib·pcre 를 $OR_PREFIX/{openssl3,zlib,pcre} 에 직접
# 빌드하고, openresty-${OR_VER} 소스에 위 커스텀 모듈들을 --add-module 로 붙여
# 컴파일한다(업스트림 빌드 스크립트 그대로 재현, 버전 전부 pinned).
# 2) apisix — runtime 위에 APISIX 본체(Lua 애플리케이션 + 일부 C 확장 Lua rock)를
# luarocks 로 설치한다. 이 단계에서 필요한 pcre2·openldap·libxml2·libxslt(lua-resty-
# saml 이 xmlsec1 바인딩에 씀)·zlib devel 은 SUSE 표준 패키지로 설치한다(업스트림처럼
# openresty 전용 -devel 패키지가 아니라 시스템 표준 -devel — 실제 배포판 표준
# pcre/pcre2/libxml2/libxslt 헤더로 컴파일해도 무방한 부분이라 문제 없다).
# 3) final — bci-base(런타임 공유 라이브러리 필요: libxml2·libxslt·openldap2 등을
# 정적이 아니라 동적 링크로 쓰는 Lua rock 이 있어 bci-micro 로는 부족하다 — 실측
# 후 bci-micro 로 축소 검토, 1차는 정확성 우선).
#
# 업스트림과 다르게 하는 부분
# - 베이스: Debian → SUSE BCI(.claude/image-authoring.md 원칙 2).
# - Rust 툴체인: rustup 으로 설치(wasm-nginx-module 의 wasmtime-c-api, APISIX 일부
# rock 컴파일에 필요 — 업스트림도 동일하게 rustup 을 쓴다, 배포판 무관 설치라 차이 없음).
# - 그 외 애플리케이션 코드·모듈 버전은 100% 동일(diff 최소화 원칙).
# syntax=docker/dockerfile:1
ARG BUILDER_BASE=registry.suse.com/bci/bci-base:15.7
ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7
# =============================================================================
# 1) runtime — OpenSSL/zlib/pcre + OpenResty(APISIX-Runtime 커스텀 모듈 전체)
# =============================================================================
FROM ${BUILDER_BASE} AS runtime
ARG OPENRESTY_VERSION=1.29.2.4
ARG OPENSSL_VERSION=3.4.1
ARG APISIX_NGINX_MODULE_VER=1.19.5
ARG WASM_NGINX_MODULE_VER=0.7.0
ARG LUA_VAR_NGINX_MODULE_VER=v0.5.3
ARG LUA_RESTY_EVENTS_VER=0.2.0
ARG NGX_MULTI_UPSTREAM_MODULE_VER=1.3.3
ARG MOD_DUBBO_VER=1.0.2
ARG APISIX_RUNTIME_VER=1.3.6
ENV OR_PREFIX=/usr/local/openresty
ENV PATH=/root/.cargo/bin:$PATH
RUN zypper -n refresh && zypper -n install -y \
gcc gcc-c++ make patch git wget curl tar gzip xz which findutils perl unzip gawk
# 업스트림처럼 cpanm 으로 IPC::Cmd 를 보강한다(OpenSSL 3.x Configure 가 요구) — SUSE 는
# perl-App-cpanminus 패키지가 없어 공식 부트스트랩으로 설치.
RUN curl -L https://cpanmin.us | perl - App::cpanminus \
&& cpanm --notest IPC::Cmd
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
WORKDIR /tmp/build
# --- zlib 먼저 (업스트림 openresty-zlib-devel 대신 표준 소스 빌드 — 같은 산출 경로).
# OpenSSL 의 `zlib` config 옵션이 컴파일 타임에 zlib.h 를 요구하므로 OpenSSL 보다
# 먼저 빌드해야 한다 — 실제로 순서를 반대로 뒀다가 `zlib.h: No such file` 로 실패한
# 것을 실측했다(2026-08-11).
ARG ZLIB_VERSION=1.3.1
RUN wget "https://github.com/madler/zlib/releases/download/v${ZLIB_VERSION}/zlib-${ZLIB_VERSION}.tar.gz" \
&& tar xzf "zlib-${ZLIB_VERSION}.tar.gz" \
&& cd "zlib-${ZLIB_VERSION}" \
&& ./configure --prefix="${OR_PREFIX}/zlib" \
&& make -j"$(nproc)" \
&& make install
# --- pcre (classic PCRE1, nginx/openresty 기본 요구사항 — 업스트림 openresty-pcre-devel
# 과 같은 산출 경로 $OR_PREFIX/pcre 에 설치) ---
ARG PCRE_VERSION=8.45
RUN wget "https://sourceforge.net/projects/pcre/files/pcre/${PCRE_VERSION}/pcre-${PCRE_VERSION}.tar.gz/download" -O "pcre-${PCRE_VERSION}.tar.gz" \
&& tar xzf "pcre-${PCRE_VERSION}.tar.gz" \
&& cd "pcre-${PCRE_VERSION}" \
&& ./configure --prefix="${OR_PREFIX}/pcre" --enable-jit --enable-utf --enable-unicode-properties \
&& make -j"$(nproc)" \
&& make install
# --- OpenSSL 3.4.1 (업스트림과 동일 버전) — zlib 헤더 경로를 명시로 넘긴다 ---
RUN wget --no-check-certificate "https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz" \
&& tar xzf "openssl-${OPENSSL_VERSION}.tar.gz" \
&& cd "openssl-${OPENSSL_VERSION}" \
&& CFLAGS="-I${OR_PREFIX}/zlib/include" LDFLAGS="-L${OR_PREFIX}/zlib/lib -Wl,-rpath,${OR_PREFIX}/zlib/lib" \
./config shared zlib enable-camellia enable-seed enable-rfc3779 \
enable-cms enable-md2 enable-rc5 enable-weak-ssl-ciphers \
--prefix="${OR_PREFIX}/openssl3" --libdir=lib \
--with-zlib-lib="${OR_PREFIX}/zlib/lib" --with-zlib-include="${OR_PREFIX}/zlib/include" \
&& make -j"$(nproc)" \
&& make install_sw install_ssldirs
# --- OpenResty 소스 + 커스텀 모듈 (업스트림 build-apisix-runtime.sh 재현) ---
RUN wget --no-check-certificate "https://openresty.org/download/openresty-${OPENRESTY_VERSION}.tar.gz" \
&& tar zxf "openresty-${OPENRESTY_VERSION}.tar.gz"
RUN git clone --depth=1 -b "${LUA_RESTY_EVENTS_VER}" https://github.com/Kong/lua-resty-events.git "lua-resty-events-${LUA_RESTY_EVENTS_VER}" \
&& git clone --depth=1 -b "${NGX_MULTI_UPSTREAM_MODULE_VER}" https://github.com/api7/ngx_multi_upstream_module.git "ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" \
&& git clone --depth=1 -b "${MOD_DUBBO_VER}" https://github.com/api7/mod_dubbo.git "mod_dubbo-${MOD_DUBBO_VER}" \
&& git clone --depth=1 -b "${APISIX_NGINX_MODULE_VER}" -- https://github.com/api7/apisix-nginx-module.git "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" \
&& git clone --depth=1 -b "${WASM_NGINX_MODULE_VER}" https://github.com/api7/wasm-nginx-module.git "wasm-nginx-module-${WASM_NGINX_MODULE_VER}" \
&& git clone --depth=1 -b "${LUA_VAR_NGINX_MODULE_VER}" https://github.com/api7/lua-var-nginx-module "lua-var-nginx-module-${LUA_VAR_NGINX_MODULE_VER}"
RUN cd "ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" && ./patch.sh "../openresty-${OPENRESTY_VERSION}" && cd .. \
&& cd "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/patch" && ./patch.sh "../../openresty-${OPENRESTY_VERSION}" && cd ../.. \
&& cd "wasm-nginx-module-${WASM_NGINX_MODULE_VER}" && ./install-wasmtime.sh && cd ..
RUN cd "openresty-${OPENRESTY_VERSION}" \
&& or_limit_ver=0.09 \
&& rm -rf "bundle/lua-resty-limit-traffic-${or_limit_ver}" \
&& limit_ver=1.2.0 \
&& wget "https://github.com/api7/lua-resty-limit-traffic/archive/refs/tags/v${limit_ver}.tar.gz" -O "lua-resty-limit-traffic-${limit_ver}.tar.gz" \
&& tar xzf "lua-resty-limit-traffic-${limit_ver}.tar.gz" \
&& mv "lua-resty-limit-traffic-${limit_ver}" "bundle/lua-resty-limit-traffic-${or_limit_ver}"
RUN cd "openresty-${OPENRESTY_VERSION}" \
&& zlib_prefix="${OR_PREFIX}/zlib" pcre_prefix="${OR_PREFIX}/pcre" openssl_prefix="${OR_PREFIX}/openssl3" ; \
./configure --prefix="${OR_PREFIX}" \
--with-cc-opt="-DAPISIX_RUNTIME_VER=${APISIX_RUNTIME_VER} -DNGX_LUA_ABORT_AT_PANIC -I${zlib_prefix}/include -I${pcre_prefix}/include -I${openssl_prefix}/include" \
--with-ld-opt="-Wl,-rpath,${OR_PREFIX}/wasmtime-c-api/lib -L${zlib_prefix}/lib -L${pcre_prefix}/lib -L${openssl_prefix}/lib -Wl,-rpath,${zlib_prefix}/lib:${pcre_prefix}/lib:${openssl_prefix}/lib" \
--add-module="../mod_dubbo-${MOD_DUBBO_VER}" \
--add-module="../ngx_multi_upstream_module-${NGX_MULTI_UPSTREAM_MODULE_VER}" \
--add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" \
--add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/src/stream" \
--add-module="../apisix-nginx-module-${APISIX_NGINX_MODULE_VER}/src/meta" \
--add-module="../wasm-nginx-module-${WASM_NGINX_MODULE_VER}" \
--add-module="../lua-var-nginx-module-${LUA_VAR_NGINX_MODULE_VER}" \
--add-module="../lua-resty-events-${LUA_RESTY_EVENTS_VER}" \
--with-poll_module --with-pcre-jit \
--without-http_rds_json_module --without-http_rds_csv_module --without-lua_rds_parser \
--with-stream --with-stream_ssl_module --with-stream_ssl_preread_module \
--with-http_v2_module --with-http_v3_module \
--without-mail_pop3_module --without-mail_imap_module --without-mail_smtp_module \
--with-http_stub_status_module --with-http_realip_module --with-http_addition_module \
--with-http_auth_request_module --with-http_secure_link_module --with-http_random_index_module \
--with-http_gzip_static_module --with-http_sub_module --with-http_dav_module \
--with-http_flv_module --with-http_mp4_module --with-http_gunzip_module \
--with-threads --with-compat \
--with-luajit-xcflags="-DLUAJIT_NUMMODE=2 -DLUAJIT_ENABLE_LUA52COMPAT" \
-j"$(nproc)" \
&& make -j"$(nproc)" \
&& make install
RUN cd "lua-resty-events-${LUA_RESTY_EVENTS_VER}" \
&& install -d "${OR_PREFIX}/lualib/resty/events/" \
&& install -m 664 lualib/resty/events/*.lua "${OR_PREFIX}/lualib/resty/events/" \
&& install -d "${OR_PREFIX}/lualib/resty/events/compat/" \
&& install -m 644 lualib/resty/events/compat/*.lua "${OR_PREFIX}/lualib/resty/events/compat/"
RUN cd "apisix-nginx-module-${APISIX_NGINX_MODULE_VER}" && OPENRESTY_PREFIX="${OR_PREFIX}" make install \
&& cd "../wasm-nginx-module-${WASM_NGINX_MODULE_VER}" && OPENRESTY_PREFIX="${OR_PREFIX}" make install
# =============================================================================
# 2) apisix — APISIX 본체(Lua 애플리케이션) 설치
# =============================================================================
FROM runtime AS apisix-app
ARG APISIX_VERSION=3.17.0
ENV PATH=$PATH:${OR_PREFIX}/luajit/bin:${OR_PREFIX}/nginx/sbin:${OR_PREFIX}/bin
# lua-resty-saml(xmlsec1 바인딩)·lyaml·기타 rock 컴파일에 필요. 업스트림은 openresty
# 전용 -devel 대신 시스템 표준 pcre/pcre2/openldap/libxml2/libxslt/zlib/yaml -devel 를
# 쓴다(RHEL 기준 utils/install-dependencies.sh 참고) — SUSE 표준 패키지로 대응한다.
# sudo 는 utils/linux-install-luarocks.sh 내부에서 그대로 호출한다(스크립트 수정 없이
# 재사용하려고 패키지로 설치 — 이 스테이지는 어차피 root 로 실행된다).
RUN zypper -n install -y \
pcre-devel pcre2-devel openldap2-devel \
libxml2-devel libxslt-devel zlib-devel libyaml-devel \
diffutils cmake automake autoconf libtool gawk readline-devel sudo
RUN wget https://raw.githubusercontent.com/apache/apisix/${APISIX_VERSION}/utils/linux-install-luarocks.sh \
&& chmod +x linux-install-luarocks.sh \
&& ./linux-install-luarocks.sh
WORKDIR /apisix
RUN git clone --depth=1 -b "${APISIX_VERSION}" https://github.com/apache/apisix.git .
# apisix-master-0.rockspec 는 저장소 루트에 있다(2026-08-11 태그 3.17.0 기준 실측).
# `luarocks make` 는 rockspec 의 source.url(원격 tarball)을 쓰지 않고 현재 디렉토리를
# 그대로 빌드 대상으로 삼는다 — git clone 만으로 충분하고 apiseven 빌드처럼 source.url
# 을 로컬 경로로 sed 패치할 필요가 없다.
#
# lua-resty-saml(luarocks 가 자동으로 받는 의존 rock)의 xmlsec 바인딩(src/saml.c)이
# libxml2 2.12(SUSE 표준 -devel 버전) 의 `xmlSetStructuredErrorFunc` 시그니처(콜백 인자에
# const 추가됨)와 안 맞아 `-Werror=incompatible-pointer-types` 로 빌드 실패한다(2026-08-11
# 실측) — rock 자체의 Makefile 이 `CFLAGS_ALL := ... -Werror ...` 를 하드코딩해 환경변수
# CFLAGS 로는 못 끈다. rock 소스를 포크하지 않고, 이 스테이지 안에서만 쓰는 gcc 래퍼로
# 그 진단 하나만 경고로 낮춘다(뒤에 오는 -Wno-error=가 앞의 -Werror 를 그 진단에 한해 이긴다).
RUN mv /usr/bin/gcc /usr/bin/gcc.real \
&& printf '#!/bin/sh\nexec /usr/bin/gcc.real -Wno-error=incompatible-pointer-types "$@"\n' > /usr/bin/gcc \
&& chmod +x /usr/bin/gcc
RUN luarocks make ./apisix-master-0.rockspec --tree=/usr/local/apisix/deps --local
# 이후 단계에 영향 주지 않도록 원복한다 — 위 gcc 래퍼는 lua-resty-saml 빌드에만 쓴다.
RUN mv /usr/bin/gcc.real /usr/bin/gcc
# apisix-build-tools 의 install_apisix() 를 재현한다(utils/install-common.sh) — luarocks
# 가 rockspec 의 build.install.bin 으로 설치한 bin/apisix 래퍼와, deps 트리 안에 설치된
# apisix Lua 패키지(share/lua/5.1/apisix)를 /usr/local/apisix 구조로 재배치한다.
#
# ui/ 는 apache/apisix 저장소 자체엔 없다(2026-08-11 3.17.0 태그 실측) — apiseven 의
# 패키징 파이프라인이 별도로 admin 대시보드 프론트엔드를 빌드해 끼워 넣는 단계라
# (Node.js/yarn 빌드, Dockerfile.package.apisix), 소스만 git clone 해서는 재현할 수
# 없다. 우리 apisix 카탈로그 배포는 이 내장 UI 를 쓰지 않는다(custom-values.yaml 에
# 관련 설정 없음) — 없으면 빈 디렉터리만 만들고 건너뛴다(있으면 그대로 복사).
RUN mkdir -p /usr/local/apisix \
&& cp -r conf /usr/local/apisix/conf \
&& { cp -r ui /usr/local/apisix/ui 2>/dev/null || mkdir -p /usr/local/apisix/ui; } \
&& install -m 755 bin/apisix /usr/local/apisix/apisix-cli-bin \
&& mv /usr/local/apisix/deps/share/lua/5.1/apisix /usr/local/apisix/apisix \
&& sed -i '1i package.path = "/usr/local/apisix/deps/share/lua/5.1/?/init.lua;" .. package.path' \
/usr/local/apisix/apisix/cli/apisix.lua
# =============================================================================
# 3) final
# =============================================================================
FROM ${RUNTIME_BASE} AS final
# 실제 SUSE 공유 라이브러리 패키지명은 soname 이 붙는다(libxml2/libldap 등은 이미
# bci-base 기본 설치에 있어 명시 안 해도 되지만, 명확성을 위해 실측한 이름 그대로 적는다
# — 2026-08-11 zypper search 로 확인: libxml2-2, libpcre2-8-0, libldap-2_4-2 는 기본
# 포함, libpcre1·libxslt1·libyaml-0-2 만 추가 설치가 필요했다).
# gawk 는 라이브러리가 아니라 /usr/bin/apisix 래퍼 스크립트 자체가 openresty 버전
# 파싱에 awk 를 쓰기 때문에 필요하다(2026-08-11 실측: "awk: command not found").
RUN zypper -n install -y libpcre1 libxslt1 libyaml-0-2 libxml2-2 libpcre2-8-0 gawk \
&& zypper -n clean --all
COPY --from=apisix-app /usr/local/openresty /usr/local/openresty
COPY --from=apisix-app /usr/local/apisix /usr/local/apisix
COPY --from=apisix-app /usr/local/apisix/apisix-cli-bin /usr/bin/apisix
ENV PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin
RUN chmod 755 /usr/bin/apisix \
&& rm -f /usr/local/apisix/apisix-cli-bin \
&& mkdir -p /usr/local/apisix/logs /usr/local/apisix/conf/cert \
&& groupadd --system --gid 636 apisix \
&& useradd --system --gid apisix --no-create-home --shell /usr/sbin/nologin --uid 636 apisix \
&& chown -R apisix:0 /usr/local/apisix \
&& chmod -R g=u /usr/local/apisix \
&& ln -sf /dev/stdout /usr/local/apisix/logs/access.log \
&& ln -sf /dev/stderr /usr/local/apisix/logs/error.log
# keycloak-authz 커스텀 플러그인 (paasup/dataup 레포의 dockerfile 과 동일한 오버레이)
COPY keycloak-authz.lua /usr/local/apisix/apisix/plugins/keycloak-authz.lua
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod 755 /docker-entrypoint.sh
WORKDIR /usr/local/apisix
USER apisix
EXPOSE 9080 9443
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["docker-start"]
-34
View File
@@ -1,34 +0,0 @@
# apisix — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=apisix BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가·구성 상세 → source.Dockerfile 상단 주석. apisix 카탈로그 CVE 조치
# (2026-08-11)로 도입 — 근거·경과는 MEMORY.md.
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 3.17.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=3.17.0
# 아래 값은 전부 2026-08-11 실측(apache/apisix:3.17.0-debian 의 `openresty -V`, apiseven
# 빌드 스크립트 api7/apisix-build-tools 태그 apisix-runtime/1.3.6) — 상위 태그 나오면
# 이 값들을 갱신하는 게 자체 빌드 유지보수보다 항상 우선이다(대응 우선순위 a).
APISIX_VERSION=3.17.0
OPENRESTY_VERSION=1.29.2.4
OPENSSL_VERSION=3.4.1
ZLIB_VERSION=1.3.1
PCRE_VERSION=8.45
APISIX_NGINX_MODULE_VER=1.19.5
WASM_NGINX_MODULE_VER=0.7.0
LUA_VAR_NGINX_MODULE_VER=v0.5.3
LUA_RESTY_EVENTS_VER=0.2.0
NGX_MULTI_UPSTREAM_MODULE_VER=1.3.3
MOD_DUBBO_VER=1.0.2
APISIX_RUNTIME_VER=1.3.6
BUILD_ARGS="APISIX_VERSION OPENRESTY_VERSION OPENSSL_VERSION ZLIB_VERSION PCRE_VERSION APISIX_NGINX_MODULE_VER WASM_NGINX_MODULE_VER LUA_VAR_NGINX_MODULE_VER LUA_RESTY_EVENTS_VER NGX_MULTI_UPSTREAM_MODULE_VER MOD_DUBBO_VER APISIX_RUNTIME_VER"
-74
View File
@@ -1,74 +0,0 @@
#!/usr/bin/env bash
# apisix 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... bash verify.sh` 형태로 호출한다).
#
# 최종 베이스가 bci-base 라 bash/coreutils/awk 가 있다 — 게스트 셸 스크립트를 그대로
# 주입한다. 단순 --help 스모크가 아니라 실제로 nginx worker 를 기동해 APISIX 라우터가
# HTTP 요청에 응답하는지까지 확인한다(standalone/yaml config, etcd 불필요) — 15개
# 커스텀 nginx 모듈 + ~90개 Lua 플러그인(lua-resty-saml 같은 C 확장 포함) 로딩까지
# 실제로 거치는 유일한 방법이다. keycloak-authz 커스텀 플러그인은 실제 Keycloak 연동이
# 필요해 이 스모크 범위 밖 — 이건 dev 클러스터 배포 검증(.claude/deploy-test-procedure.md)
# 이 담당한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
echo "== apisix version =="
OUT="$(docker run --rm --platform "$PLATFORM" --entrypoint sh "$TAG" -c '
export PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin
/usr/bin/apisix version
')"
echo " $OUT"
case "$OUT" in
*3.17.0*) ;;
*) echo "FAIL: apisix version 출력에 3.17.0 이 없다"; exit 1 ;;
esac
echo "== nginx 설정 문법 검사 (init + nginx -t) =="
CONTAINER="verify-apisix-$$"
docker run -d --rm --platform "$PLATFORM" --name "$CONTAINER" --entrypoint sh "$TAG" -c '
export PATH=$PATH:/usr/local/openresty/luajit/bin:/usr/local/openresty/nginx/sbin:/usr/local/openresty/bin
cd /usr/local/apisix
export APISIX_STAND_ALONE=true
cat > conf/config.yaml <<EOF
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
EOF
cat > conf/apisix.yaml <<EOF
routes:
#END
EOF
/usr/bin/apisix init >/tmp/init.log 2>&1
/usr/local/openresty/nginx/sbin/nginx -p /usr/local/apisix -t >>/tmp/init.log 2>&1
exec /usr/local/openresty/bin/openresty -p /usr/local/apisix -g "daemon off;"
' >/dev/null
trap 'docker rm -f "$CONTAINER" >/dev/null 2>&1 || true' EXIT
echo "== HTTP 응답 대기 (최대 15초) =="
# curl 은 연결 자체가 실패해도 %{http_code} 로 "000" 을 찍는다 — 빈 문자열이 아니라서
# `[ -n "$CODE" ]` 만으로는 연결 실패를 통과로 오판한다(2026-08-12 실측: 파이프라인
# 첫 실행에서 "HTTP 000" 인데도 VERIFY-OK 가 나온 버그). "000" 은 명시적으로 실패로 친다.
ok=0
for i in $(seq 1 15); do
CODE="$(docker run --rm --platform "$PLATFORM" --network "container:$CONTAINER" curlimages/curl:latest \
-s -o /dev/null -w '%{http_code}' -m 2 http://127.0.0.1:9080/ 2>/dev/null || true)"
if [ -n "$CODE" ] && [ "$CODE" != "000" ]; then ok=1; break; fi
sleep 1
done
if [ "$ok" != "1" ]; then
echo "FAIL: 15초 내에 9080 포트가 유효한 HTTP 응답을 하지 않았다 (마지막 시도: '${CODE:-없음}')"
docker logs "$CONTAINER" 2>&1 | tail -40
exit 1
fi
echo " HTTP $CODE (라우트가 없어 404 가 정상 — nginx/APISIX 라우터가 실제로 요청을 처리했다는 뜻)"
case "$CODE" in
404) ;;
*) echo "FAIL: 예상한 404(라우트 없음)가 아니라 $CODE 가 나왔다 — 로그 확인 필요"; docker logs "$CONTAINER" 2>&1 | tail -40; exit 1 ;;
esac
echo "VERIFY-OK"
-104
View File
@@ -1,104 +0,0 @@
# argocd (자체 빌드)
`quay.io/argoproj/argocd` 를 대체하는 하드닝 이미지. 업스트림 소스를 pinned commit 으로
직접 컴파일하고, 업스트림이 릴리스 바이너리로 내려받던 번들 도구(helm·kustomize·git-lfs)까지
같은 Go 툴체인으로 다시 만든다.
```sh
IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
```
## 왜 자체 빌드인가
이 이미지의 차단 CVE 는 대부분 OS 패키지가 아니라 **바이너리에 정적 링크된 Go 모듈**이다.
그래서 앞선 두 레버가 모두 통하지 않는다.
- **상위 태그 교체 불가** — 이미 최신 릴리스다. 번들 도구도 각각 최신이고, 낡은 Go 는
각 업스트림 프로젝트의 선택이라 버전을 올려도 바뀌지 않는다.
- **베이스 OS 교체로는 거의 안 잡힌다** — 모듈은 컴파일된 바이너리 안에 있다.
## 왜 번들 도구까지 다시 만드는가
업스트림 Dockerfile 은 helm/kustomize/git-lfs 를 `hack/install.sh` 로 **미리 빌드된 릴리스
바이너리를 내려받아** 넣는다. 그 바이너리의 Go 버전은 우리가 통제할 수 없다.
그리고 **부분 조치는 효과가 없다.** 같은 `stdlib`·`x/crypto` 취약점이 다섯 바이너리에 각각
들어있어서, 한 바이너리만 고치면 나머지에 그대로 남는다. 고유 CVE 는 소수이고 대부분이
공유분이라 전부 다시 만들어야 게이트가 0 이 된다(실측 확인 — argocd 본체만 조치했을 때는
거의 줄지 않았다).
`pebble` 은 우리가 넣은 것이 아니라 ubuntu 베이스에 딸려온 것이다 — 베이스를 SUSE BCI 로
바꾸면 함께 사라진다.
## 다음 CVE 조치 — Dockerfile 은 건드리지 않는다
버전도 모듈 목록도 Dockerfile 에 박혀 있지 않다. 새 차단 CVE 가 나오면 **`source.build.env`
한 곳만** 바뀐다.
```sh
# 1) 게이트 리포트에서 업그레이드 값을 산출한다 (손으로 찾지 않는다)
python3 scripts/build/suggest-go-upgrades.py --reports sbom-out/trivy-reports --image argocd
# 2) 출력된 GO_MODULE_UPGRADES / GO_BUILDER_TAG 를 source.build.env 에 반영
# 3) 재빌드 — 게이트까지 한 번에 돈다
IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
```
| 새 CVE 유형 | 바꾸는 값 |
|---|---|
| Go `stdlib` | `GO_BUILDER_TAG` |
| Go 모듈 | `GO_MODULE_UPGRADES``<module>@<version>` 추가 |
| OS 패키지 | `RUNTIME_BASE` 또는 `RUNTIME_PACKAGES` |
| 구성요소 새 릴리스 | 해당 `*_VERSION` |
`GO_MODULE_UPGRADES` 는 argocd·helm·kustomize·git-lfs **네 프로젝트에 공통 적용**된다.
[go-mod-upgrade.sh](go-mod-upgrade.sh) 가 각 프로젝트의 의존성 그래프에 있는 것만 골라
적용하므로 목록 하나를 그대로 재사용한다 — `go get` 은 의존성에 없는 모듈도 `go.mod`
추가해버리기 때문에 필요한 필터다.
> 제안값은 CVE 요건의 **최소치**다. 모듈 간 제약으로 더 올려야 할 수 있다 — 빌드가
> `requires <module>@vX, not @vY` 로 실패하면 그 버전으로 올린다.
## 업스트림과 다르게 한 부분
| 항목 | 업스트림 | 이 이미지 | 이유 |
|---|---|---|---|
| 최종 베이스 | `ubuntu` | `registry.suse.com/bci/bci-base` | 카탈로그는 SUSE BCI 하나만 쓴다(`.claude/image-authoring.md` 원칙 2). `pebble` 도 함께 소멸 |
| helm / kustomize / git-lfs | 릴리스 바이너리 다운로드 | 소스 컴파일 | 다운로드 바이너리의 Go 를 통제할 수 없다 |
| helm 버전 | 업스트림 pin | 최신 | kustomize·git-lfs 는 pin 이 이미 최신이라 그대로 |
| `tini` · `connect-proxy` | apt 패키지 | 소스 빌드 | SLE_BCI 에 둘 다 없다. 기능을 빼지 않기 위해 빌드 |
| Go 툴체인 | 업스트림 pin | `GO_BUILDER_TAG` | stdlib CVE 해소 |
| 취약 모듈 | 그대로 | `GO_MODULE_UPGRADES` 로 강제 업그레이드 | |
| `BUILD_DATE` | 빌드 시각 | 고정값 | 빌드 재현성 |
| UI | node 빌드 | **동일** | Go embed 로 바이너리에 들어가 생략 불가 |
애플리케이션 코드 자체는 pinned 태그 그대로다 — 최소 diff 원칙.
## SLE 패키지 매핑
업스트림 apt 목록을 SLE_BCI 이름으로 옮겼다(`.claude/image-authoring.md` 참고).
목록 자체는 `source.build.env``RUNTIME_PACKAGES` 에 있다.
| ubuntu | SLE_BCI |
|---|---|
| `git` | `git-core` |
| `tzdata` | `timezone` |
| `gpg` · `gpg-agent` | `gpg2` |
| `openssh-client` | `openssh-clients` |
| `ca-certificates` | `ca-certificates` (동일) |
| `tini` | **없음** → 소스 빌드 |
| `connect-proxy` | **없음** → 소스 빌드 |
## 검증
`verify.sh` 가 보는 것 — argocd 버전 문자열, 업스트림 심볼릭 링크 9개, 번들 도구 3개의
버전, tini·connect-proxy 실행 가능, git/gpg/ssh 존재, `/etc/gitconfig` 의 LFS 필터,
`/app/config` 디렉토리 구조와 래퍼 스크립트.
**게이트 PASS 는 "동작한다" 를 증명하지 않는다.** 실제 기동은 Kubernetes API 가 필요해
스모크 범위 밖이다 — dev 클러스터 배포 검증을 반드시 한다.
```sh
VALUES_FILE=<values> bash scripts/deploy-test/deploy-test-argo-cd.sh /tmp/deploy-test-argocd
```
-18
View File
@@ -1,18 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
#
# argo-cd 카탈로그는 10.4.0(현재 운영)과 7.8.11(직전)을 보관한다. 자체 빌드 이미지는
# 운영 버전에만 반영한다 — 7.8.11 은 released 로 동결하고 손대지 않는다
# (7.7.0 은 2026-08-19 삭제).
CHART_DIRS="manifests/helm/argo-cd/10.4.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
# argo-cd 차트는 모든 컴포넌트(server/repo-server/controller/applicationset/notifications)가
# global.image.{repository,tag} 하나를 공유한다 — 컴포넌트별 image 블록은 기본이 비어 있고
# 비면 global 을 상속한다. 그래서 이 한 곳만 갱신하면 전부 바뀐다.
TAG_BLOCK=global.image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env sh
# go-mod-upgrade — CVE 대응으로 강제 업그레이드할 Go 모듈 목록을 현재 모듈에 적용한다.
# 빌더 스테이지에서 각 Go 프로젝트 디렉토리에 들어간 뒤 인자 없이 호출한다.
#
# 왜 스크립트로 빼는가
# --------------------
# 업그레이드 대상을 Dockerfile 에 직접 나열하면, 새 CVE 가 하나 나올 때마다
# ① build.env 에 <MOD>_FIX_VERSION 추가 ② Dockerfile 의 ARG 선언 추가(스테이지마다)
# ③ go get 목록에 추가(빌드 대상마다) ④ BUILD_ARGS 에 이름 추가
# 네 곳을 고쳐야 한다. 목록을 GO_MODULE_UPGRADES 값 하나로 받으면 **build.env 한 줄만**
# 바뀌고 Dockerfile 은 그대로다 — 이 이미지는 앞으로도 CVE 조치를 반복해서 받는다.
#
# 목록에 있으나 이 프로젝트가 쓰지 않는 모듈은 건너뛴다
# ----------------------------------------------------
# 한 이미지 안에서 여러 Go 프로젝트(argocd·helm·kustomize·git-lfs)를 빌드하는데 각자
# 의존성이 다르다. `go get` 은 의존성 그래프에 없는 모듈도 go.mod 에 요구사항으로
# **추가**하므로(뒤이은 go mod tidy 가 지우긴 하지만) 애초에 넣지 않는 편이 의도가 분명하고,
# 목록 하나를 네 프로젝트에 그대로 재사용할 수 있다.
#
# 형식: 공백으로 구분한 `<module path>@<version>` 목록.
# GO_MODULE_UPGRADES="golang.org/x/net@v0.56.0 google.golang.org/grpc@v1.82.1"
set -eu
if [ -z "${GO_MODULE_UPGRADES:-}" ]; then
echo "go-mod-upgrade: GO_MODULE_UPGRADES 가 비어 있다 — 업그레이드 없이 진행"
exit 0
fi
targets=""
for spec in $GO_MODULE_UPGRADES; do
path="${spec%@*}"
if [ "$path" = "$spec" ]; then
echo "go-mod-upgrade: ::error:: '$spec' 에 @<version> 이 없다"
exit 2
fi
if go list -m "$path" >/dev/null 2>&1; then
targets="$targets $spec"
echo "go-mod-upgrade: 적용 $spec"
else
echo "go-mod-upgrade: 건너뜀 $path (이 프로젝트의 의존성 그래프에 없음)"
fi
done
if [ -z "$targets" ]; then
echo "go-mod-upgrade: 적용 대상 없음"
exit 0
fi
# shellcheck disable=SC2086 # targets 는 공백 구분 목록이라 의도적으로 분리한다
go get $targets
go mod tidy
-263
View File
@@ -1,263 +0,0 @@
# argocd — 업스트림 소스를 pinned commit 으로 직접 컴파일하고, 번들되는 Go 도구
# (helm/kustomize/git-lfs)까지 같은 툴체인으로 다시 만든다.
#
# 왜 자체 빌드인가
# ----------------
# 이 이미지의 차단 CVE 는 대부분 OS 패키지가 아니라 **바이너리에 정적 링크된 Go 모듈**이다.
# 상위 태그 교체(이미 최신)로도, 베이스 OS 교체(모듈은 바이너리 안에 있다)로도 잡히지 않아
# 자체 빌드만 남는다.
#
# 왜 번들 도구까지 다시 만드는가
# ------------------------------
# 업스트림 Dockerfile 은 helm/kustomize/git-lfs 를 hack/install.sh 로 **미리 빌드된 릴리스
# 바이너리를 내려받아** 넣는다. 그 바이너리의 Go 버전은 각 프로젝트가 정한 것이라 우리가
# 태그를 올려도 바뀌지 않는다.
#
# 그리고 부분 조치는 효과가 없다 — 같은 stdlib·x/crypto 취약점이 다섯 바이너리에 각각
# 들어있어서, 한 바이너리만 고치면 나머지에 그대로 남는다. 고유 CVE 는 소수이고 대부분이
# 공유분이다. 전부 다시 만들어야 게이트가 0 이 된다(실측 확인).
#
# 재사용 설계 — Dockerfile 에 버전을 박지 않는다
# ---------------------------------------------
# 이 이미지는 앞으로도 CVE 조치를 반복해서 받는다. 다음 조치에서 바뀌는 것은 전부
# source.build.env 값이다:
#
# GO_MODULE_UPGRADES 강제 업그레이드할 Go 모듈 목록 (네 프로젝트에 공통 적용)
# GO_BUILDER_TAG Go 툴체인 (stdlib CVE 해소)
# *_VERSION 각 구성요소 버전
# RUNTIME_PACKAGES 최종 이미지에 설치할 SLE 패키지
#
# 값은 scripts/build/suggest-go-upgrades.py 가 스캔 리포트에서 산출한다 — 사람이 CVE 를
# 훑어 최대값을 고르지 않는다. 모듈 목록은 go-mod-upgrade.sh 가 프로젝트마다 "그 프로젝트가
# 실제로 쓰는 것만" 골라 적용하므로 목록 하나를 네 프로젝트에 그대로 재사용한다.
#
# 업스트림과 다르게 하는 부분
# ---------------------------
# ubuntu → SUSE BCI(bci-base). 카탈로그는 SUSE BCI 하나만 쓴다
# (.claude/image-authoring.md 원칙 2). argocd 는 git/gpg/ssh 런타임이 필요해 패키지
# 매니저가 있는 base 를 쓴다(micro 씨앗 방식의 복잡도를 감수할 이점이 없었다).
# ubuntu 베이스가 딸려오던 pebble 바이너리도 함께 사라진다.
# tini·connect-proxy 는 SLE_BCI 에 패키지가 없어 소스에서 빌드한다.
# helm/kustomize/git-lfs 를 릴리스 바이너리 다운로드 → 소스 컴파일로 교체.
# UI 는 업스트림과 동일하게 node 로 빌드한다 — Go embed 로 바이너리에 들어가 생략 불가.
# syntax=docker/dockerfile:1
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 —
# 스테이지 안에 두면 지역 변수가 되어 이후 FROM 의 이미지명이 빈 값이 된다
# (.claude/image-authoring.md).
ARG GO_BUILDER_TAG=1.26.6-trixie
ARG NODE_BUILDER_TAG=24.14.1
ARG RUNTIME_BASE=registry.suse.com/bci/bci-base:15.7
####################################################################################################
# UI — 업스트림 argocd-ui 스테이지를 그대로 재현한다. 산출물이 Go embed 로 바이너리에 들어간다.
####################################################################################################
FROM --platform=$BUILDPLATFORM docker.io/library/node:${NODE_BUILDER_TAG} AS argocd-ui
ARG SOURCE_COMMIT
ARG APP_VERSION
ADD https://github.com/argoproj/argo-cd.git#${SOURCE_COMMIT} /src
# 업스트림과 동일: corepack 으로 pnpm 을 켜고 lockfile 그대로 설치한다.
WORKDIR /src/ui
RUN npm install -g corepack@0.34.6 && corepack enable && pnpm install --frozen-lockfile
# UI 번들은 아키텍처 무관이다 — 업스트림 주석대로 TARGETARCH 를 쓰지 않는다.
ENV ARGO_VERSION=$APP_VERSION
RUN NODE_ENV='production' NODE_ONLINE_ENV='online' NODE_OPTIONS=--max_old_space_size=8192 pnpm build
####################################################################################################
# 번들 Go 도구 — 업스트림이 릴리스 바이너리로 받아오던 것을 소스에서 다시 컴파일한다.
#
# 새 툴체인으로 다시 컴파일하면 stdlib 은 해소되지만 각 프로젝트가 go.mod 에 pin 한 모듈은
# 그대로 낡아 있다 — 도구 쪽 모듈을 빠뜨려 게이트가 실패한 적이 있다(실측). 그래서 argocd
# 본체와 같은 GO_MODULE_UPGRADES 를 세 도구에도 적용한다.
####################################################################################################
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS go-tools
ARG TARGETARCH
ARG HELM_VERSION
ARG KUSTOMIZE_VERSION
ARG GIT_LFS_VERSION
ARG GO_MODULE_UPGRADES
ENV CGO_ENABLED=0 GOOS=linux GO_MODULE_UPGRADES=${GO_MODULE_UPGRADES}
COPY go-mod-upgrade.sh /usr/local/bin/go-mod-upgrade
RUN chmod 0755 /usr/local/bin/go-mod-upgrade
WORKDIR /out
# helm — 업스트림 Makefile 의 ldflags(version/gitTreeState)를 재현한다.
ADD https://github.com/helm/helm.git#v${HELM_VERSION} /src/helm
RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
cd /src/helm && go-mod-upgrade && \
P=helm.sh/helm/v4/internal/version && \
GOARCH=${TARGETARCH} go build -trimpath \
-ldflags "-X ${P}.version=v${HELM_VERSION} -X ${P}.gitTreeState=clean" \
-o /out/helm ./cmd/helm
# kustomize — 리포 안의 kustomize/ 서브모듈이 CLI 다.
ADD https://github.com/kubernetes-sigs/kustomize.git#kustomize/v${KUSTOMIZE_VERSION} /src/kustomize
RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
cd /src/kustomize/kustomize && go-mod-upgrade && \
P=sigs.k8s.io/kustomize/api/provenance && \
GOARCH=${TARGETARCH} go build -trimpath \
-ldflags "-X ${P}.version=v${KUSTOMIZE_VERSION}" \
-o /out/kustomize .
# git-lfs — Makefile 이 ldflags 로 버전을 심는다.
ADD https://github.com/git-lfs/git-lfs.git#v${GIT_LFS_VERSION} /src/git-lfs
RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
cd /src/git-lfs && go-mod-upgrade && \
GOARCH=${TARGETARCH} go build -trimpath \
-ldflags "-X github.com/git-lfs/git-lfs/v3/config.GitCommit=v${GIT_LFS_VERSION}" \
-o /out/git-lfs .
####################################################################################################
# argocd 본체 — 업스트림 argocd-build 스테이지 + 취약 모듈 강제 업그레이드
####################################################################################################
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS argocd-build
ARG TARGETOS
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
ARG GO_MODULE_UPGRADES
ENV GO_MODULE_UPGRADES=${GO_MODULE_UPGRADES}
COPY go-mod-upgrade.sh /usr/local/bin/go-mod-upgrade
RUN chmod 0755 /usr/local/bin/go-mod-upgrade
WORKDIR /go/src/github.com/argoproj/argo-cd
ADD https://github.com/argoproj/argo-cd.git#${SOURCE_COMMIT} .
RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go-mod-upgrade
COPY --from=argocd-ui /src/ui/dist/app ./ui/dist/app
# 업스트림 Dockerfile 과 동일하게 Makefile 의 argocd-all 타겟을 부른다
# (단일 `go build -o dist/argocd ./cmd`). 버전 심볼은 Makefile 이 ldflags 로 심는다.
# BUILD_DATE 를 고정해 빌드 재현성을 확보한다(업스트림은 date 를 그때그때 넣는다).
RUN --mount=type=cache,target=/root/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
GIT_TAG=v${APP_VERSION} \
GIT_COMMIT=${SOURCE_COMMIT} \
GIT_TREE_STATE=clean \
BUILD_DATE=1970-01-01T00:00:00Z \
GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
make argocd-all
####################################################################################################
# C 도구 — SLE_BCI 에 패키지가 없어 소스에서 빌드한다(2026-08-19 zypper 실측)
####################################################################################################
FROM ${RUNTIME_BASE} AS c-builder
ARG TINI_VERSION
ARG SSH_CONNECT_VERSION
ARG BUILDER_PACKAGES
# SLE_BCI 미러가 간헐적으로 끊긴다(실측: curl error 56 / SSL unexpected eof). 재시도한다.
RUN for i in 1 2 3 4 5; do \
zypper --non-interactive --gpg-auto-import-keys refresh && break; \
echo "zypper refresh 실패 — 재시도 $i"; sleep 10; \
done && \
zypper --non-interactive install -y --no-recommends ${BUILDER_PACKAGES} && \
zypper --non-interactive clean --all
# tini — 업스트림 argocd 의 ENTRYPOINT(["/usr/bin/tini", "--"]).
# 동적 링크로 빌드한다. 이 스테이지와 final 이 같은 BCI 베이스라 glibc ABI 가 동일하고,
# 업스트림이 쓰는 apt tini 도 동적 링크다. 정적 링크는 SLE_BCI 에 정적 glibc 가 없어
# 애초에 불가능하다(2026-08-19 실측: "cannot find -lc ... static version of the c library").
ADD https://github.com/krallin/tini.git#v${TINI_VERSION} /src/tini
RUN cd /src/tini && \
cmake -DCMAKE_BUILD_TYPE=Release . && \
make tini && \
install -m 0755 tini /out-tini
# connect-proxy — Debian 의 connect-proxy 패키지 원본(gotoh/ssh-connect 의 connect.c).
# SSH-over-proxy 환경에서 git 이 쓴다. 기능을 빼지 않기 위해 빌드한다.
ADD https://github.com/gotoh/ssh-connect.git#${SSH_CONNECT_VERSION} /src/connect
RUN cd /src/connect && \
gcc -O2 -o /out-connect connect.c
####################################################################################################
# 최종 이미지 — 업스트림 argocd-base + final 을 SUSE BCI 위에서 재현한다
####################################################################################################
FROM ${RUNTIME_BASE} AS final
LABEL org.opencontainers.image.source="https://github.com/argoproj/argo-cd"
ARG RUNTIME_PACKAGES
ENV ARGOCD_USER_ID=999
# RUNTIME_PACKAGES 는 업스트림 apt 목록을 SLE 이름으로 옮긴 것이다. 이름이 다른 것들:
# git → git-core tzdata → timezone gpg/gpg-agent → gpg2 openssh-client → openssh-clients
# tini·connect-proxy 는 SLE_BCI 에 없어 c-builder 에서 빌드해 COPY 한다.
# SLE_BCI 미러가 간헐적으로 끊긴다(실측). 재시도한다.
RUN for i in 1 2 3 4 5; do \
zypper --non-interactive --gpg-auto-import-keys refresh && break; \
echo "zypper refresh 실패 — 재시도 $i"; sleep 10; \
done && \
zypper --non-interactive update -y && \
zypper --non-interactive install -y --no-recommends ${RUNTIME_PACKAGES} && \
zypper --non-interactive clean --all && \
rm -rf /var/log/zypp /usr/share/doc/packages/*
RUN groupadd -g $ARGOCD_USER_ID argocd && \
useradd -r -u $ARGOCD_USER_ID -g argocd argocd && \
mkdir -p /home/argocd && \
chown argocd:0 /home/argocd && \
chmod g=u /home/argocd
COPY --from=c-builder /out-tini /usr/bin/tini
COPY --from=c-builder /out-connect /usr/bin/connect-proxy
COPY --from=go-tools /out/helm /usr/local/bin/helm
COPY --from=go-tools /out/kustomize /usr/local/bin/kustomize
COPY --from=go-tools /out/git-lfs /usr/local/bin/git-lfs
# 업스트림이 소스에서 넣는 래퍼 스크립트들 — argocd 가 gpg 검증·entrypoint 에 쓴다.
COPY --from=argocd-build \
/go/src/github.com/argoproj/argo-cd/hack/gpg-wrapper.sh \
/go/src/github.com/argoproj/argo-cd/hack/git-verify-wrapper.sh \
/go/src/github.com/argoproj/argo-cd/entrypoint.sh \
/usr/local/bin/
RUN chmod 0755 /usr/local/bin/gpg-wrapper.sh /usr/local/bin/git-verify-wrapper.sh /usr/local/bin/entrypoint.sh
# git-lfs 시스템 설정(/etc/gitconfig) 을 만들어 LFS 필터를 활성화한다 — 업스트림과 동일.
RUN git lfs install --system
# 하위 호환용 심볼릭 링크 — 업스트림과 동일.
RUN ln -s /usr/local/bin/entrypoint.sh /usr/local/bin/uid_entrypoint.sh
# configmap 마운트 지원 — 업스트림과 동일한 경로/권한.
WORKDIR /app/config/ssh
RUN touch ssh_known_hosts && \
ln -s /app/config/ssh/ssh_known_hosts /etc/ssh/ssh_known_hosts
WORKDIR /app/config
RUN mkdir -p tls && \
mkdir -p gpg/source && \
mkdir -p gpg/keys && \
chown argocd gpg/keys && \
chmod 0700 gpg/keys
ENV USER=argocd
# dual-stack 환경에서 _grpc_config DNS TXT 조회가 타임아웃을 유발하는 문제 회피 — 업스트림과 동일.
# argocd-cmd-params-cm 으로 덮어쓸 수 있다.
ENV GRPC_ENABLE_TXT_SERVICE_CONFIG=false
COPY --from=argocd-build /go/src/github.com/argoproj/argo-cd/dist/argocd /usr/local/bin/argocd
# 업스트림과 동일한 심볼릭 링크 9개 — 차트가 이 이름들로 컨테이너 command 를 지정한다.
RUN ln -s /usr/local/bin/argocd /usr/local/bin/argocd-server && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-repo-server && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-cmp-server && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-application-controller && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-dex && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-notifications && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-applicationset-controller && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-k8s-auth && \
ln -s /usr/local/bin/argocd /usr/local/bin/argocd-commit-server
ENTRYPOINT ["/usr/bin/tini", "--"]
USER $ARGOCD_USER_ID
WORKDIR /home/argocd
-77
View File
@@ -1,77 +0,0 @@
# argocd — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=argocd BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가 → source.Dockerfile 상단 주석 / README.md.
# argo-cd 10.4.0 CVE 조치(2026-08-19)로 도입 — 근거·경과는 MEMORY.md.
#
# ── 다음 CVE 조치에서 바뀌는 것은 이 파일뿐이다 ────────────────────────────────
# Dockerfile 에는 버전도 모듈 목록도 박혀 있지 않다. 새 차단 CVE 가 나오면:
# Go stdlib CVE → GO_BUILDER_TAG 를 올린다
# Go 모듈 CVE → GO_MODULE_UPGRADES 에 `<module>@<version>` 을 추가한다
# OS 패키지 CVE → RUNTIME_BASE 를 올리거나 RUNTIME_PACKAGES 를 조정한다
# 구성요소 새 릴리스 → 해당 *_VERSION 을 올린다
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 v3.5.1 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열은 그대로 둔다.
APP_VERSION=3.5.1
# v3.5.1 태그가 가리키는 커밋(lightweight 태그라 태그 객체 없이 커밋 직접 참조), 2026-08-19 확인.
SOURCE_COMMIT=109ca7ca71139e514114499d294a492e7910a965
# stdlib 차단 CVE 를 해소하는 최소 Go 버전. suggest-go-upgrades.py 가 산출한다.
# 빌더 스테이지에만 쓰이고 최종 이미지에는 남지 않는다.
GO_BUILDER_TAG=1.26.6-trixie
# UI 빌드용. 업스트림 Dockerfile 의 node pin 과 동일하게 맞춘다 — UI 번들은 스캔에서
# 취약점이 잡히지 않으므로 올릴 이유가 없고, lockfile 과의 정합을 지키는 쪽이 안전하다.
NODE_BUILDER_TAG=24.14.1
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(.claude/image-authoring.md 원칙 2).
# argocd 는 git/gpg/ssh 런타임이 필요해 패키지 매니저가 있는 bci-base 를 쓴다.
# base 와 micro 를 스캔해 비교했고 차이가 없어, micro 씨앗 방식(.claude/image-authoring.md)의
# 복잡도를 감수할 이점이 없었다.
#
# **16.0 을 쓰는 이유 — coreutils 버전이 차트 요구사항을 가른다.**
# argo-cd 차트의 repo-server init 컨테이너(copyutil)가 `cp --update=none` 을 쓴다. 이
# 형식은 GNU coreutils 9.3+ 에서만 되는데 15.7 은 그보다 낮아
# "option '--update' doesn't allow an argument" 로 죽는다(실측 — 15.7 로 빌드해 배포했다가
# repo-server 가 Init:CrashLoopBackOff 로 실패했다). 16.0 은 coreutils 9.6 이라 동작한다.
# 16.0 의 패키지 가용성과 trivy 커버리지(CoverageProbe=ok, EOSL 아님)도 확인했다.
# verify.sh 가 이 명령을 직접 검사하므로 다음에 베이스를 바꿔도 빌드 단계에서 걸린다.
RUNTIME_BASE=registry.suse.com/bci/bci-base:16.0
# 번들 Go 도구 — 업스트림은 hack/install.sh 로 릴리스 바이너리를 내려받지만, 그 바이너리의
# Go 버전이 낡아(git-lfs 1.25.3 · kustomize 1.24.0) CVE 의 주범이었다. 소스에서 다시 만든다.
# helm 만 업스트림 pin(4.2.1) 대신 최신을 쓴다 — 나머지 둘은 pin 이 이미 최신이다.
HELM_VERSION=4.2.4
KUSTOMIZE_VERSION=5.8.1
GIT_LFS_VERSION=3.7.1
# SLE_BCI 에 패키지가 없어 소스 빌드한다(2026-08-19 zypper 실측).
# tini 는 업스트림 ENTRYPOINT, connect-proxy 는 SSH-over-proxy 용이다.
TINI_VERSION=0.19.0
SSH_CONNECT_VERSION=1.106
# 강제 업그레이드할 Go 모듈 — argocd·helm·kustomize·git-lfs 네 프로젝트에 **공통 적용**된다.
# go-mod-upgrade.sh 가 각 프로젝트의 의존성 그래프에 있는 것만 골라 적용하므로 하나의
# 목록을 그대로 재사용할 수 있다.
#
# 값은 suggest-go-upgrades.py 로 산출한다. 단, 제안값은 CVE 요건의 **최소치**라 모듈 간
# 제약으로 더 올려야 할 수 있다 — 빌드가 "requires <module>@vX, not @vY" 로 실패하면 그
# 버전으로 올린다(x/crypto 가 이 경우였다).
GO_MODULE_UPGRADES="golang.org/x/crypto@v0.53.0 golang.org/x/net@v0.56.0 golang.org/x/text@v0.39.0 google.golang.org/grpc@v1.82.1 github.com/go-git/go-git/v5@v5.19.2 oras.land/oras-go/v2@v2.6.2"
# c-builder 가 tini(cmake)·connect(gcc) 를 컴파일하는 데 필요한 것들.
BUILDER_PACKAGES="gcc make cmake git-core tar gzip"
# 최종 이미지 런타임 패키지 — 업스트림 apt 목록의 SLE 대응(README.md 매핑표 참고).
RUNTIME_PACKAGES="git-core ca-certificates gpg2 timezone openssh-clients"
BUILD_ARGS="SOURCE_COMMIT APP_VERSION GO_BUILDER_TAG NODE_BUILDER_TAG RUNTIME_BASE HELM_VERSION KUSTOMIZE_VERSION GIT_LFS_VERSION TINI_VERSION SSH_CONNECT_VERSION GO_MODULE_UPGRADES BUILDER_PACKAGES RUNTIME_PACKAGES"
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env bash
# argocd 이미지 기능 검증 — 호스트에서 bash 로 실행된다
# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... <build.env 변수들> bash verify.sh`
# 형태로 호출한다).
#
# 이 이미지의 핵심 위험은 "번들 도구를 릴리스 바이너리 다운로드에서 소스 컴파일로 바꾼 것"과
# "ubuntu → SUSE BCI 베이스 교체" 두 가지다. 그래서 argocd 본체뿐 아니라 helm·kustomize·
# git-lfs·tini·connect-proxy 가 전부 실제로 실행되는지, 업스트림이 만들던 런타임 구조
# (심볼릭 링크 9개, /etc/gitconfig 의 LFS 필터, /app/config 권한)가 그대로인지 확인한다.
#
# 실제 기동(서버·컨트롤러)은 Kubernetes API 가 필요해 이 스모크 범위 밖이다 —
# dev 클러스터 배포 검증(.claude/deploy-test-procedure.md, scripts/deploy-test/deploy-test-argo-cd.sh)이
# 담당한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
APP_VERSION="${APP_VERSION:?APP_VERSION 환경변수가 필요하다 (build.env 에서 전달)}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다}"
HELM_VERSION="${HELM_VERSION:?HELM_VERSION 환경변수가 필요하다}"
KUSTOMIZE_VERSION="${KUSTOMIZE_VERSION:?KUSTOMIZE_VERSION 환경변수가 필요하다}"
GIT_LFS_VERSION="${GIT_LFS_VERSION:?GIT_LFS_VERSION 환경변수가 필요하다}"
echo "== 이미지 메타데이터 =="
USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")"
[ "$USER_CFG" = "999" ] || { echo "FAIL: Config.User 가 999 가 아니다 (실제: $USER_CFG) — 업스트림 ARGOCD_USER_ID"; exit 1; }
ENTRY="$(docker inspect --format '{{json .Config.Entrypoint}}' "$TAG")"
case "$ENTRY" in
*tini*) ;;
*) echo "FAIL: ENTRYPOINT 에 tini 가 없다 (실제: $ENTRY)"; exit 1 ;;
esac
echo " Config.User=$USER_CFG Entrypoint=$ENTRY"
docker run --rm -i --platform "$PLATFORM" \
-e APP_VERSION="$APP_VERSION" \
-e SOURCE_COMMIT="$SOURCE_COMMIT" \
-e HELM_VERSION="$HELM_VERSION" \
-e KUSTOMIZE_VERSION="$KUSTOMIZE_VERSION" \
-e GIT_LFS_VERSION="$GIT_LFS_VERSION" \
--entrypoint sh "$TAG" <<'GUEST'
set -e
echo "== argocd 본체 =="
OUT="$(argocd version --client --short)"
echo " $OUT"
case "$OUT" in
*"v$APP_VERSION"*) ;;
*) echo "FAIL: 버전 출력에 v$APP_VERSION 이 없다 — Makefile ldflags 주입 확인 필요"; exit 1 ;;
esac
case "$OUT" in
*"$SOURCE_COMMIT"*) ;;
*) echo "WARN: 버전 출력에 pinned commit 이 안 보인다 (short 출력이라 생략됐을 수 있음)" ;;
esac
echo "== 업스트림 심볼릭 링크 9개 =="
for n in argocd-server argocd-repo-server argocd-cmp-server argocd-application-controller \
argocd-dex argocd-notifications argocd-applicationset-controller argocd-k8s-auth \
argocd-commit-server; do
[ -x "/usr/local/bin/$n" ] || { echo "FAIL: /usr/local/bin/$n 없음"; exit 1; }
done
echo " 9개 모두 존재·실행 가능"
echo "== 번들 도구 (소스 컴파일로 교체한 것들) =="
H="$(helm version --short)"
echo " helm $H"
case "$H" in *"$HELM_VERSION"*) ;; *) echo "FAIL: helm 버전이 $HELM_VERSION 이 아니다"; exit 1 ;; esac
K="$(kustomize version)"
echo " kustomize $K"
case "$K" in *"$KUSTOMIZE_VERSION"*) ;; *) echo "FAIL: kustomize 버전이 $KUSTOMIZE_VERSION 이 아니다"; exit 1 ;; esac
L="$(git-lfs version)"
echo " git-lfs $L"
case "$L" in *"$GIT_LFS_VERSION"*) ;; *) echo "FAIL: git-lfs 버전이 $GIT_LFS_VERSION 이 아니다"; exit 1 ;; esac
echo "== SLE_BCI 에 없어 소스 빌드한 C 도구 =="
[ -x /usr/bin/tini ] || { echo "FAIL: /usr/bin/tini 없음"; exit 1; }
/usr/bin/tini --version
[ -x /usr/bin/connect-proxy ] || { echo "FAIL: /usr/bin/connect-proxy 없음"; exit 1; }
# connect 는 인자 없이 부르면 usage 를 내고 비정상 종료한다 — 실행 가능 여부만 본다.
/usr/bin/connect-proxy >/dev/null 2>&1 || true
echo " tini · connect-proxy 실행 가능"
echo "== 런타임 필수 도구 (업스트림 apt 목록 대응) =="
git --version >/dev/null || { echo "FAIL: git 없음"; exit 1; }
gpg --version >/dev/null || { echo "FAIL: gpg 없음"; exit 1; }
ssh -V 2>/dev/null || ssh -V || { echo "FAIL: ssh 없음"; exit 1; }
echo " git · gpg · ssh 정상"
echo "== 차트가 실제로 실행하는 명령 =="
# argo-cd 차트의 repo-server init 컨테이너(copyutil)가 그대로 쓰는 형식이다.
# /bin/cp --update=none /usr/local/bin/argocd /var/run/argocd/argocd
# `--update=none` 은 GNU coreutils 9.3+ 에서만 된다. 베이스 OS 의 coreutils 가 낮으면
# 이미지는 멀쩡히 빌드·스캔을 통과하고 **배포 시점에** Init:CrashLoopBackOff 로 죽는다
# (실측). 그래서 여기서 직접 확인한다 — 베이스를 바꿀 때 이 검사가 걸러준다.
: > /tmp/_vsrc
if ! /bin/cp --update=none /tmp/_vsrc /tmp/_vdst 2>/dev/null; then
echo "FAIL: 'cp --update=none' 이 동작하지 않는다 — 베이스 OS 의 coreutils 가 9.3 미만이다."
echo " argo-cd 차트의 repo-server init 컨테이너가 이 형식을 쓰므로 배포가 실패한다."
cp --version 2>/dev/null | head -1
exit 1
fi
echo " cp --update=none 동작 ($(cp --version 2>/dev/null | head -1))"
# argocd 바이너리를 공유 볼륨에 복사하는 흐름 자체를 그대로 재현해 본다.
mkdir -p /tmp/_vrun
/bin/cp --update=none /usr/local/bin/argocd /tmp/_vrun/argocd || { echo "FAIL: argocd 복사 실패"; exit 1; }
/bin/ln -sf /tmp/_vrun/argocd /tmp/_vrun/argocd-cmp-server || { echo "FAIL: cmp-server 심볼릭 링크 실패"; exit 1; }
[ -x /tmp/_vrun/argocd-cmp-server ] || { echo "FAIL: 복사된 argocd 가 실행 가능하지 않다"; exit 1; }
echo " copyutil 흐름(복사 + cmp-server 링크) 재현 성공"
echo "== git-lfs 시스템 설정 (업스트림 git lfs install --system) =="
git config --system --get filter.lfs.clean >/dev/null || { echo "FAIL: /etc/gitconfig 에 LFS 필터가 없다"; exit 1; }
echo " filter.lfs.clean 등록됨"
echo "== 업스트림 런타임 디렉토리 구조 =="
[ -L /etc/ssh/ssh_known_hosts ] || { echo "FAIL: /etc/ssh/ssh_known_hosts 심볼릭 링크 없음"; exit 1; }
[ -d /app/config/tls ] || { echo "FAIL: /app/config/tls 없음"; exit 1; }
[ -d /app/config/gpg/keys ] || { echo "FAIL: /app/config/gpg/keys 없음"; exit 1; }
[ -x /usr/local/bin/entrypoint.sh ] || { echo "FAIL: entrypoint.sh 없음"; exit 1; }
[ -x /usr/local/bin/uid_entrypoint.sh ] || { echo "FAIL: uid_entrypoint.sh 없음"; exit 1; }
[ -x /usr/local/bin/gpg-wrapper.sh ] || { echo "FAIL: gpg-wrapper.sh 없음"; exit 1; }
echo " ssh_known_hosts 링크 · /app/config/{tls,gpg/keys} · 래퍼 스크립트 정상"
echo "VERIFY-OK"
GUEST
-95
View File
@@ -1,95 +0,0 @@
# cloudnative-pg — CloudNativePG 오퍼레이터 자체 빌드
CNPG 오퍼레이터(컨트롤러) 이미지를 업스트림 소스에서 직접 컴파일한다.
`manifests/helm/cloudnative-pg/0.29.0/custom-values.yaml`·`dip-values.yaml`
`image.repository`/`image.tag` 가 이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 채택 결정과 받아들인 비용은
[ADR 0002](../../doc/decisions/0002-cloudnative-pg-operator-self-build.md), 게이트·SBOM
파이프라인은 [doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 신규 자체 빌드 이미지 추가
절차 전반은 [.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다.
아래 CVE 번호는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.**
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나 — 그리고 왜 `cnpg-postgresql` 과 다른 방법인가
업스트림 `ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0` 은 실효 HIGH 3건
(`CVE-2026-39822` stdlib, `CVE-2026-56852` `golang.org/x/text`, `GHSA-hrxh-6v49-42gf`
`google.golang.org/grpc`)으로 차단된다. 상위 태그가 없다(2026-07-30 확인, 최신 릴리스가
여전히 `v1.30.0`). 업스트림 배포 이미지 베이스가 `gcr.io/distroless/static-debian13`
(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만으로는 고쳐지지 않는다** — CVE 는
바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 자체 빌드(소스 컴파일)만 유효한 대응이다.
`cnpg-postgresql`(OS 베이스 교체 + zypper 패치)과 성격이 다르지만, **오케스트레이션은
동일한 [scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh)
하나를 공유한다.** 이 이미지가 그 스크립트의 계약(`build.env``DOCKERFILE`·`TARGET`·
`BUILD_ARGS`·`APP_VERSION` 선언, `verify.sh``VERIFY-OK` 로 종료)만 지키면, 이미지
종류(OS 패키지 설치형 vs 소스 컴파일형)는 스크립트가 몰라도 된다 — 근거는
[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고.
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/cloudnative-pg/cloudnative-pg.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT` (release-1.30 브랜치) |
| 빌더 | 공식 `golang` 이미지 (`source.build.env``GO_BUILDER_TAG`, go.mod 요구 버전과 일치) |
| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 |
빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이
아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다. `bci-micro`
는 SUSE BCI 중 가장 가벼운 변종이지만 `bci-base` 와 달리 `nonroot`(uid 65532) 계정이
미리 없어 `source.Dockerfile` 이 직접 만든다(`/etc/passwd`·`/etc/group` 에 추가).
`SOURCE_COMMIT`**자동 추적하지 않는다.** `cnpg-postgresql` 의 PGDG 버전처럼 사람이
업스트림 `release-1.30` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
업스트림이 `v1.30.1`(혹은 그 이상) 을 릴리스했을 때 — 릴리스가 나오면 그쪽으로 갈아타는
것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상 우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=cloudnative-pg BASE_OS=source REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`scan-sbom.sh` 의 커버리지 자가진단(`CoverageProbe`)까지 포함한다 — `doc/sbom-pipeline.md`
참고. dip-catalog 로컬 빌드 실측(2026-08-03): 커버리지 `ok`, 실효 C/H 0/0, 게이트
PASS(`localhost/...` 태그, push 는 아직 안 함 — `MEMORY.md` 참고). `verify.sh`
`manager version` 출력에 pinned
commit 이 실제로 반영됐는지(ldflags 주입 확인), 이미지 `Config.User`
`65532:65532`(nonroot)인지, `--help` 가 정상 종료하는지를 확인한다 — 실제 컨트롤러
기동(k8s API 필요)은 이 스모크 테스트 범위 밖이며, dev 클러스터 배포 테스트
(`.claude/deploy-test-procedure.md`)가 담당한다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) |
| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 직접 `docker run --entrypoint /manager` 를 호출한다(게스트 스크립트 주입 방식보다 상위 호환 — 최종 이미지에 셸이 없는 경우에도 그대로 동작한다) |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다 — `cnpg-postgresql` 처럼
변종이 늘면 `<variant>.Dockerfile`/`<variant>.build.env` 로 분기한다.
### 태그
```
docker.io/wbsong111/cloudnative-pg:1.30.0-security-hardened-20260730
└ app ─┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
-11
View File
@@ -1,11 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
TAG_BLOCK=image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
-80
View File
@@ -1,80 +0,0 @@
# CloudNativePG 오퍼레이터 — 업스트림 소스를 pinned commit 으로 직접 컴파일한다.
#
# 업스트림(https://github.com/cloudnative-pg/cloudnative-pg)의 Dockerfile 은 이미 goreleaser
# 로 빌드된 바이너리(`dist/manager/manager_<arch>`)를 COPY 만 한다 — 컴파일 자체는 이 Dockerfile
# 밖(Makefile `docker-build` → goreleaser)에서 일어난다. 우리는 그 컴파일 단계를 Dockerfile
# 안으로 가져와 `go build` 로 직접 재현한다. `cnpg-postgresql/suse.Dockerfile` 이 "업스트림
# Dockerfile 을 다른 배포판으로 이식"한 것이라면, 이건 "업스트림이 Dockerfile 밖에서 하던
# 빌드를 Dockerfile 안으로 흡수"한 것이다.
#
# 왜 자체 빌드인가 — cloudnative-pg:1.30.0 이 게이트에서 차단하는 CVE 3건(stdlib·x/text·grpc)
# 은 OS 패키지가 아니라 바이너리에 정적 링크된 Go 모듈 버전이 원인이다. 배포 이미지 베이스가
# distroless(OS 패키지 사실상 0개)라 베이스 OS 교체로는 고칠 수 없다 — 소스를 다시 컴파일해야
# 한다. 세 CVE 모두 업스트림 release-1.30 브랜치에 이미 백포트돼 있다(SOURCE_COMMIT 이 그
# 커밋을 가리킨다) — 근거: doc/decisions/0002-cloudnative-pg-operator-self-build.md
#
# 업스트림과의 대응 관계 (release-1.30 브랜치 Dockerfile 기준)
# go build (Makefile build-manager) → 동일 (ldflags 까지 그대로)
# goreleaser 멀티아치(manager_amd64/arm64) → 단일 아키텍처(linux/amd64)만 직접 COPY.
# 심볼릭 링크 대신 같은 바이너리를 두 경로에 COPY (아래 "실측으로 드러난 필수 조건" 참고)
# distroless base(gcr.io/distroless/static-debian13:nonroot) → SUSE BCI(bci-micro)로 교체.
# 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001) — 최종 런타임 이미지에도 동일하게 적용한다.
# builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다 — 컴파일 결과물에는
# 영향이 없고, 최종 이미지에만 남는 게 무엇인지가 스캔·정책 대상이다
# syntax=docker/dockerfile:1
ARG GO_BUILDER_TAG=1.26.5-trixie
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — 스테이지 안에서
# 선언하면(예: 이전엔 builder 스테이지 RUN 다음에 둠) 그 스테이지 지역 변수가 되어 다음
# FROM 의 이미지명 해석에 쓰이지 않는다(실측: "FROM argument 'RUNTIME_BASE' is not
# declared" 경고와 함께 빈 이미지명 에러 발생).
ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없으므로
# --platform=$BUILDPLATFORM 로 고정해 에뮬레이션 오버헤드를 피한다(로컬 arm64 Docker 데스크톱
# 에서 linux/amd64 결과물을 만들 때 특히 중요).
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
WORKDIR /src
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/cloudnative-pg/cloudnative-pg.git#${SOURCE_COMMIT} /src
# 업스트림 Makefile 의 LDFLAGS·.goreleaser.yml 의 build 설정을 그대로 재현한다.
# (release-1.30 기준 go.mod: go 1.26.5, google.golang.org/grpc v1.82.1, golang.org/x/text v0.39.0)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath \
-ldflags "-s -w \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildVersion=${APP_VERSION} \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildCommit=${SOURCE_COMMIT} \
-X github.com/cloudnative-pg/cloudnative-pg/pkg/versions.buildDate=$(date -u +%Y-%m-%d)" \
-o /out/manager ./cmd/manager
FROM ${RUNTIME_BASE} AS final
WORKDIR /
# bci-micro 는 root 만 있고 65532 사용자가 없다(distroless 의 nonroot 변종과 달리 nonroot
# 계정을 미리 만들어두지 않는다) — 직접 만든다. bci-micro 에 bash·coreutils 는 있다
# (zypper·rpm 은 없음 — "micro" 는 패키지 매니저가 빠진 것이지 셸까지 없는 건 아니다).
RUN set -eux; \
echo 'nonroot:x:65532:65532:nonroot:/home/nonroot:/bin/false' >> /etc/passwd; \
echo 'nonroot:x:65532:' >> /etc/group; \
mkdir -p /home/nonroot; \
chown 65532:65532 /home/nonroot
# 실측으로 드러난 필수 조건: 오퍼레이터는 `operator/manager_<GOARCH>` 를 런타임에 glob 해서
# "가용 아키텍처" 목록을 만든다(pkg/utils/discovery.go DetectAvailableArchitectures) — 이
# 목록이 비어 있으면 Cluster 리컨실이 "invalid architecture: amd64" 로 실패한다(배포
# 테스트 2026-07-30 에서 재현). 업스트림은 멀티아치 심볼릭 링크로 이걸 만들지만, 우리는
# 단일 아키텍처만 다루므로 같은 바이너리를 두 경로에 COPY 해 같은 효과를 낸다.
COPY --from=builder /out/manager /manager
COPY --from=builder /out/manager /operator/manager_amd64
COPY --from=builder /src/licenses /licenses
COPY --from=builder /src/LICENSE /licenses/LICENSE
USER 65532:65532
ENTRYPOINT ["/manager"]
-36
View File
@@ -1,36 +0,0 @@
# CloudNativePG 오퍼레이터 — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=cloudnative-pg BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가·CVE 근거 → README.md
# 결정 → doc/decisions/0002-cloudnative-pg-operator-self-build.md
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 스톡 1.30.0 과는 태그 슬러그(TAG_SLUG=security)로 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=1.30.0
# release-1.30 브랜치 HEAD, 2026-07-30 확인 — CVE-2026-39822(stdlib)·CVE-2026-56852(x/text)·
# GHSA-hrxh-6v49-42gf(grpc) 가 모두 이 커밋에 백포트돼 있다. 갱신할 때는 release-1.30 의
# 최신 커밋으로 사람이 다시 고른다(자동 추적하지 않음 — cnpg-postgresql 의 PGDG 버전처럼
# 사람이 트리거해야 하는 갱신 축).
SOURCE_COMMIT=4463551204bc5cdb5af05b2c60a2d6b58ce9ff6a
# go.mod 의 `go 1.26.5` 요구를 만족하는 공식 golang 이미지 태그. 이 이미지는 builder
# 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다.
#
# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를
# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다:
# python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports> --image cloudnative-pg
GO_BUILDER_TAG=1.26.6-trixie
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). bci-base 대신
# 가장 가벼운 bci-micro 를 쓴다(패키지 매니저 없음, bash·coreutils 는 있음).
RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE"
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# cloudnative-pg 오퍼레이터 이미지 기능 검증 — 호스트에서 bash 로 실행된다
# (build-hardened-image.sh 가 `env TAG=... PLATFORM=... SOURCE_COMMIT=... bash verify.sh`
# 형태로 호출한다).
#
# cnpg-postgresql/verify.sh 와 달리 게스트 셸을 쓰지 않는다 — 최종 이미지 베이스가
# gcr.io/distroless/static-debian13:nonroot 라 셸이 아예 없다(`/bin/sh` 없음, 업스트림
# Dockerfile 도 이 사실을 자기 빌더 스테이지 주석에 명시한다). 대신 `--entrypoint /manager`
# 로 바이너리를 직접 실행해 검증한다 — 실제 컨트롤러 기동(k8s API 필요)은 이 스모크
# 테스트 범위 밖이며, dev 클러스터 배포 테스트가 담당한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
SHORT_COMMIT="${SOURCE_COMMIT:0:8}"
echo "== manager version =="
OUT="$(docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" version)"
echo " $OUT"
case "$OUT" in
*"$SHORT_COMMIT"*) ;;
*) echo "FAIL: version 출력에 pinned commit($SHORT_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;;
esac
echo "== 실행 사용자 (nonroot, 이미지 메타데이터) =="
# 셸이 없어 컨테이너 안에서 id 를 실행할 수 없다 — 이미지가 선언한 기본 User 를 확인한다.
# 위 version 실행 자체가 이 기본 사용자로 이미 성공했다(권한 문제였다면 여기까지 오지 못한다).
USER_CFG="$(docker inspect --format '{{.Config.User}}' "$TAG")"
[ "$USER_CFG" = "65532:65532" ] || { echo "FAIL: 이미지 Config.User 가 65532:65532 가 아니다 (실제: $USER_CFG)"; exit 1; }
echo " Config.User=$USER_CFG"
echo "== --help 스모크 (k8s API 없이 도는 유일한 확인 범위) =="
docker run --rm --platform "$PLATFORM" --entrypoint /manager "$TAG" --help >/dev/null
echo " /manager --help 종료 코드 0"
echo "VERIFY-OK"
-133
View File
@@ -1,133 +0,0 @@
# cnpg-postgresql — CloudNativePG PostgreSQL 자체 빌드
CNPG 오퍼레이터가 관리하는 PostgreSQL 인스턴스 이미지를 직접 빌드한다.
`manifests/helm/cnpg-cluster/1.0.0/custom-values.yaml``imageName` 이 이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 채택 결정·후보 비교표·받아들인 비용은
[ADR 0001](../../doc/decisions/0001-cnpg-postgresql-image.md), 게이트·SBOM 파이프라인은
[doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 이미지 선정 규칙과 빌드 프레임워크는
[.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다.
아래 CVE 수치는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.**
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
---
## 왜 자체 빌드하나
업스트림 `ghcr.io/cloudnative-pg/postgresql:18.4-standard-trixie` 는 실효 CRITICAL/HIGH
23건을 갖고 있고, **전부 수정 버전이 없다**(Debian `unimportant` 9 / `no-dsa` 7 /
`postponed` 3 / 미분류 4). SUSE 는 같은 CVE 들을 이미 백포트했다 — 베이스 OS 를 바꾸면
벤더 판정이 달라진다.
**단, 수치가 낮아진 것이 실제 개선인지 벤더 미평가인지 반드시 구분해야 한다.**
`cve-gate.py` 의 교차 검증(`--crossref`)이 이를 돕고, `scan-sbom.sh` 의 커버리지
자가진단(`CoverageProbe`)이 "0건"과 "측정 안 됨"을 구분한다(`doc/sbom-pipeline.md` 참고).
dip-catalog 로컬 빌드로 재확인(2026-08-03): 커버리지 `ok`, 실효 C/H 0/0, 게이트
PASS(`localhost/...` 태그, push 는 아직 안 함 — `MEMORY.md` 참고).
## 베이스 OS
이 이미지는 **SUSE BCI 15.7** 로 빌드됐다(security-catalog 프로젝트 ADR, 미이관).
| 파일 | 베이스 | 패키지 관리자 | 측정 가능성 |
| --- | --- | --- | --- |
| `suse.Dockerfile` | `registry.suse.com/bci/bci-base:15.7` | zypper + PGDG rpm | ✅ trivy 로 완전 측정 |
`ubuntu:24.04` 기반 빌드(후보 B)는 security-catalog 프로젝트에서 검토 후 제거됐다 —
카탈로그가 채택하지 않아 재빌드되지 않은 채 낡아가고 있었다(빌드 하루 만에 findings
38 → 46). 관련 실측은 이관되지 않았다.
### 업스트림과의 차이
**원칙: 업스트림 Dockerfile 과의 차이를 최소로 유지한다.** 패키지 구성·uid·확장 목록을
임의로 줄이면 오퍼레이터가 이미지에 기대하는 조건이 깨진다.
`suse.Dockerfile` 은 업스트림 CNPG(Debian/apt/PGDG-deb)를 SUSE(zypper/PGDG-rpm)로
이식한 것이라 저장소 설정·패키지명·경로가 전부 다르다. 대응 관계는 파일 상단 주석 참고.
`patched` 단계의 `zypper update -y` 가 실제로 기여한 몫: 베이스 이미지는 주기적으로만
재빌드되므로 배포판 아카이브보다 뒤처져 있다. 이 단계가 없으면 그 시점의 미패치 취약점이
그대로 남는다 — 자동 재빌드가 필요한 이유가 이것이다.
### 업스트림 빌드 레시피 변경 점검 (수동, 자동화 대상 아님)
`cloudnative-pg/postgres-containers` 저장소가 자체 Dockerfile 구조를 바꾸면(새 확장,
새 하드닝 단계 등) 이 포트도 따라가야 한다. **CI 로 자동 감지하지 않는다** — 배포판이
달라(Debian vs SUSE) 의미 있는 diff 가 안 나온다.
**권장 점검 주기: CNPG 마이너 릴리스마다.** 업스트림 Dockerfile 을 훑어보고 구조가
바뀌었으면 `suse.Dockerfile` 을 손으로 다시 이식한다.
---
## 빌드
```sh
# 카탈로그가 쓰는 베이스 OS (기본값)
IMAGE=cnpg-postgresql BASE_OS=suse bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=cnpg-postgresql BASE_OS=suse REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
# 교차 검증을 함께 (사각지대 후보를 뽑는다. CI 에는 아직 연결되지 않았다)
CROSSREF=/tmp/ref/standard-trixie.json IMAGE=cnpg-postgresql BASE_OS=suse \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다.
CVE 0건이어도 동작하지 않는 이미지는 카탈로그에 넣을 수 없다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `<base-os>.Dockerfile` | 빌드 정의 |
| `<base-os>.build.env` | 베이스·앱 버전·확장 목록. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. **베이스 OS 공통** — CNPG 요구사항은 베이스와 무관하다 |
베이스 OS 가 늘어도 이 구조는 그대로다 — `build-hardened-image.sh``BASE_OS` 환경변수로
`<base-os>.build.env` 를 고른다.
### 태그에 빌드일을 넣는다
```
docker.io/wbsong111/cnpg-postgresql:18.4-bci15.7-hardened-20260729
└ 앱 ─┘└ 베이스 ─┘└ 하드닝 ┘└ 빌드일 ┘
```
같은 앱 버전이라도 `zypper update` 결과가 시점마다 다르다. 롤링 태그를 피하고
자체 이미지에도 같은 실수를 하지 않는다(빌드일 포함 고정 태그).
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
---
## 실측 (security-catalog, 2026-07-29 / trivy 0.72.0 기준)
| | `bci15.7-hardened-20260728` |
| --- | --- |
| 커버리지 자가진단 | ✅ `ok` |
| 전 심각도 findings | 0 |
| 실효 고유 C/H | **0 / 0** |
| 게이트 | **PASS** |
### 채택 당시 비교 (후보 A/B/C, security-catalog 기준)
| | 업스트림 `standard-trixie` (A) | Ubuntu 24.04 자체빌드 (B, 제거됨) | **SUSE BCI 15.7 (C, 채택)** |
| --- | --- | --- | --- |
| 패키지 수 | 148 | 147 | 182 |
| trivy findings (전 심각도) | 316 | 46 | 0 |
| 실효 고유 C/H | 23 | 0 | 0 |
| 미조사 사각지대 | 0 | 18건 | 18건 |
| 서명·attestation | ✅ | ❌ | ❌ |
| 배포 검증 | — | ✅ failover 2초 | ✅ failover 3초 |
| gid | `postgres` | ⚠️ `tape` | `postgres` |
이 수치는 dip-catalog 에서 재측정한 것이 아니라 security-catalog 프로젝트에서 이관한
기록이다 — dip-catalog 자체 게이트로 재확인은 아직 하지 않았다(`MEMORY.md` 참고).
-11
View File
@@ -1,11 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
# 이 이미지의 태그를 참조하는 차트 버전 디렉토리 (공백 구분, 여러 개 가능)
CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0 manifests/helm/cnpg-cluster/1.1.0"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=imageName
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=suse
-83
View File
@@ -1,83 +0,0 @@
# CloudNativePG PostgreSQL — SUSE BCI 기반 자체 빌드
#
# 업스트림 CNPG Dockerfile(Debian/apt/PGDG-deb)을 SUSE(zypper/PGDG-rpm)로 이식한 것이다.
# ARG BASE 만 바꾸는 수준이 아니라 패키지 관리자·저장소 설정·패키지명·경로가 모두 다르다.
#
# 업스트림과의 대응 관계
# postgresql-common + apt.postgresql.org.sh → rpm --import + zypper addrepo (PGDG zypp)
# postgresql-18 → postgresql18-server
# postgresql-18-pgaudit / -pgvector → pgaudit_18 / pgvector_18
# locales-all → glibc-locale
# usermod -u 26 postgres → 불필요 (PGDG RPM 이 uid 26 으로 생성)
# /usr/lib/postgresql/18/bin → /usr/pgsql-18/bin
#
# CNPG 호환성 근거 (실측)
# - uid 26: PGDG RPM 이 postgres 를 uid/gid 26 으로 만든다 (RPM 계열 관례)
# - 바이너리 탐색: CNPG 는 경로를 하드코딩하지 않고 PATH 로 찾는다.
# cloudnative-pg 소스에서 "usr/lib/postgresql" 는 테스트 픽스처에만 등장한다.
# 그래도 하드코딩 경로를 쓰는 코드가 생길 경우를 대비해 심볼릭 링크를 함께 둔다.
#
# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다.
# 2026-07-29 이전에는 "trivy 의 SUSE 데이터가 15.7 을 커버하지 않는다" 로 판단했으나
# 양성 대조(SBOM 사본에 취약 버전 주입 후 재스캔)로 재측정한 결과 오판이었다 — trivy 는
# 13건을 잡았다(SUSE-SU ID 포함). 0건은 이 이미지가 실제로 최신이라서다.
# scan-sbom.sh 의 커버리지 자가진단이 매 스캔마다 이를 확인한다.
# 판정 메커니즘: doc/sbom-pipeline.md (CoverageProbe)
ARG BASE=registry.suse.com/bci/bci-base:15.7
FROM $BASE AS minimal
ARG PG_MAJOR=18
ARG PG_VERSION=18.4-4200001PGDG.sles15.7
ARG SLE_REPO=sles-15.7-x86_64
ARG PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15
ENV PATH=$PATH:/usr/pgsql-${PG_MAJOR}/bin
# PGDG 저장소는 GPG 키를 미리 import 해야 한다. addrepo 만 하면
# "Signature verification failed for repomd.xml" 로 저장소가 스킵된다.
RUN set -eux; \
rpm --import "https://download.postgresql.org/pub/repos/zypp/keys/${PGDG_KEY}"; \
zypper --non-interactive addrepo --refresh \
"https://download.postgresql.org/pub/repos/zypp/${PG_MAJOR}/suse/${SLE_REPO}/" pgdg; \
zypper --non-interactive refresh; \
zypper --non-interactive install -y --no-recommends \
"postgresql${PG_MAJOR}-server=${PG_VERSION}"; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
# CNPG 는 PATH 로 바이너리를 찾지만, Debian 레이아웃을 가정하는 코드가 생길 경우를 대비한다.
RUN set -eux; \
mkdir -p /usr/lib/postgresql; \
ln -sfn "/usr/pgsql-${PG_MAJOR}" "/usr/lib/postgresql/${PG_MAJOR}"; \
id postgres | grep -q 'uid=26(' || { echo "postgres uid 가 26 이 아니다"; exit 1; }
USER 26
FROM minimal AS standard
ARG PG_MAJOR=18
# 업스트림 standard 와 동등한 구성: pgaudit, pgvector, contrib(pg_stat_statements), 전 로케일.
# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (업스트림 Debian standard 와의 차이).
ARG EXTENSIONS="pgaudit_${PG_MAJOR} pgvector_${PG_MAJOR}"
USER 0
RUN set -eux; \
zypper --non-interactive install -y --no-recommends \
glibc-locale \
"postgresql${PG_MAJOR}-contrib" \
${EXTENSIONS}; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
USER 26
# 베이스 이미지에 남은 구버전 패키지를 보안 업데이트로 올린다.
# Debian 판의 `apt-get upgrade` 단계에 대응한다 (그쪽에서 NVD 기준 HIGH 5건을 해소했다).
FROM standard AS patched
USER 0
RUN set -eux; \
zypper --non-interactive refresh; \
zypper --non-interactive update -y --no-recommends; \
zypper clean --all; \
rm -rf /var/log/zypp /var/cache/zypp
USER 26
-28
View File
@@ -1,28 +0,0 @@
# CNPG PostgreSQL — SUSE BCI / zypper 기반 자체 빌드 (카탈로그 채택 베이스 OS)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# BASE_OS 기본값이 이 파일(suse)이다 — 카탈로그의 imageName 이 실제로 이 정의로 빌드된다.
#
# 측정 가능성 — trivy 는 SLES 15.7 을 정상 커버한다 (2026-07-29 재측정, 이전 판단 정정).
# 매 스캔마다 커버리지 자가진단(CoverageProbe)이 재확인한다 — doc/sbom-pipeline.md
DOCKERFILE=suse.Dockerfile
TARGET=patched
TAG_SLUG=bci15.7
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# PG_VERSION(아래, PGDG 패키지의 정확한 EVR)에서 메이저.마이너만 뽑은 값과 같다 —
# 예전엔 스크립트가 PG_VERSION 을 파싱해 유도했지만, 이제는 이미지가 명시적으로 선언한다.
APP_VERSION=18.4
BASE=registry.suse.com/bci/bci-base:15.7
PG_MAJOR=18
# PGDG zypp 저장소의 정확한 EVR. apt 쪽과 형식이 다르다.
PG_VERSION=18.4-4200001PGDG.sles15.7
SLE_REPO=sles-15.7-x86_64
PGDG_KEY=PGDG-RPM-GPG-KEY-SLES15
# pg-failover-slots 는 PGDG zypp 저장소에 없어 제외한다 (apt 변종과의 차이).
EXTENSIONS="pgaudit_18 pgvector_18"
BUILD_ARGS="BASE PG_MAJOR PG_VERSION SLE_REPO PGDG_KEY EXTENSIONS"
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
# CNPG PostgreSQL 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... PG_MAJOR=... bash verify.sh` 형태로 호출한다). 이 이미지는 셸
# (`/bin/sh`)이 있으므로, 실제 점검은 컨테이너 안에서 게스트 셸 스크립트로 수행한다.
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
#
# 왜 이미지 디렉토리에 두나: 검증 항목은 이미지마다 다르다. 빌드 스크립트에 하드코딩하면
# 이미지가 둘 이상이 될 때 깨진다. 실제로 apt·suse 두 변종이 생겨 분리했다.
#
# CNPG 가 이미지에 요구하는 것(오퍼레이터 소스 기준)
# - uid 26 — 다르면 오퍼레이터가 관리하는 볼륨 권한이 깨진다
# - PATH 에서 initdb/pg_ctl/postgres 를 찾을 수 있어야 한다 (경로 하드코딩 아님)
# - 차트가 선언한 확장이 실제로 생성되어야 한다
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
PG_MAJOR="${PG_MAJOR:?PG_MAJOR 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
docker run --rm -i --platform "$PLATFORM" -e PG_MAJOR="$PG_MAJOR" --entrypoint sh "$TAG" <<'GUEST'
set -e
export PGDATA=/tmp/_verify
export PATH="/usr/lib/postgresql/${PG_MAJOR}/bin:/usr/pgsql-${PG_MAJOR}/bin:$PATH"
echo "== 실행 사용자 =="
uid=$(id -u)
gid=$(id -g)
gname=$(id -gn 2>/dev/null || echo '?')
echo " uid=$uid gid=$gid($gname)"
[ "$uid" = "26" ] || { echo "FAIL: uid=$uid — CNPG 는 26 을 요구한다"; exit 1; }
# gid 는 실패시키지 않고 경고만 한다. 실측 사례: Ubuntu 베이스에서 gid 26 이 `tape`
# 그룹에 선점되어 있어 postgres 가 tape 그룹으로 동작했다. 기능은 동작하지만 위생 문제다.
if [ "$gname" != "postgres" ]; then
echo " WARN: gid $gid 의 그룹명이 '$gname' 이다 (postgres 가 아님)"
fi
echo "== 바이너리 탐색 (PATH 기준) =="
for b in initdb pg_ctl postgres psql; do
p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; }
echo " $b$p"
done
echo "== initdb + 기동 =="
initdb -D "$PGDATA" -A trust >/dev/null
pg_ctl -D "$PGDATA" -w start >/dev/null \
-o "-c shared_preload_libraries=pgaudit,pg_stat_statements -c unix_socket_directories=/tmp"
psql -h /tmp -U postgres -Atc "SELECT version();" | sed 's/^/ /'
echo "== 확장 생성 =="
# 차트(cnpg-cluster)가 Database CRD 로 선언하는 것과 같은 목록이다.
psql -h /tmp -U postgres -q -c \
"CREATE EXTENSION pgaudit; CREATE EXTENSION vector; CREATE EXTENSION pg_stat_statements;"
psql -h /tmp -U postgres -Atc \
"SELECT extname||' '||extversion FROM pg_extension ORDER BY 1;" | sed 's/^/ /'
echo "== libxml2 링크 (XML 함수) =="
psql -h /tmp -U postgres -Atc \
"SELECT xml_is_well_formed_document('<a>x</a>');" | sed 's/^/ /'
echo "== 로케일 =="
# 업스트림 standard 는 전 로케일을 포함한다. 줄어들면 정렬·비교 동작이 달라진다.
n=$(psql -h /tmp -U postgres -Atc "SELECT count(*) FROM pg_collation;")
echo " pg_collation: ${n}"
[ "$n" -gt 100 ] || echo " WARN: collation 이 ${n}개다 — 로케일 패키지 누락 의심"
pg_ctl -D "$PGDATA" -w stop >/dev/null
echo "VERIFY-OK"
GUEST
-96
View File
@@ -1,96 +0,0 @@
# etcd — 자체 빌드
etcd 서버/`etcdctl`/`etcdutl` 바이너리를 업스트림 소스에서 직접 컴파일한다.
`manifests/helm/etcd/1.1.12/custom-values.yaml``image.repository`/`image.tag`
이 산출물을 가리킨다.
security-catalog 프로젝트에서 포팅했다. 채택 결정과 받아들인 비용은
[ADR 0004](../../doc/decisions/0004-etcd-image-self-build.md), 이 차트를 고른 이유는
[ADR 0003](../../doc/decisions/0003-etcd-chart-selection.md), 게이트·SBOM 파이프라인은
[doc/sbom-pipeline.md](../../doc/sbom-pipeline.md), 신규 자체 빌드 이미지 추가 절차 전반은
[.claude/image-authoring.md](../../.claude/image-authoring.md) 가 갖는다.
아래 CVE 번호는 자체 빌드에 착수한 시점의 근거다 — **현재 수치는 게이트가 낸다.**
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나 — 그리고 왜 `cloudnative-pg` 와 같은 방법인가
업스트림 `quay.io/coreos/etcd:v3.7.1` 은 실효 HIGH 1건(`CVE-2026-56852`,
`golang.org/x/text` — 깨진 UTF-8 입력에 대한 `norm.Iter` 무한루프 DoS)으로 차단된다.
상위 태그가 없다(2026-07-31 확인, 최신 릴리스가 여전히 `v3.7.1`이고 `release-3.7`
브랜치도 아직 이 CVE 를 백포트하지 않았다). 업스트림 배포 이미지 베이스가
`gcr.io/distroless/static-debian12`(OS 패키지 사실상 0개)라 **베이스 OS 를 바꾸는 것만
으로는 고쳐지지 않는다** — CVE 는 바이너리에 정적 링크된 Go 모듈 버전이 원인이다.
자체 빌드(소스 컴파일)만 유효한 대응이다.
이는 `cloudnative-pg` 오퍼레이터 자체 빌드와 **완전히 같은 유형의 문제**(같은
CVE-2026-56852)이고 대응도 같다 — **오케스트레이션은 동일한
[scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh)
하나를 공유한다.**
## 소스·버전 관리
| 항목 | 값 |
| --- | --- |
| 소스 | `https://github.com/etcd-io/etcd.git` |
| pinned commit | `source.build.env``SOURCE_COMMIT``v3.7.1` 태그가 가리키는 실제 커밋 |
| 빌더 | 공식 `golang` 이미지 (`source.build.env``GO_BUILDER_TAG`, go.mod 요구 버전과 일치) |
| 최종 베이스 | `registry.suse.com/bci/bci-micro:15.7` — security-catalog 는 SUSE BCI 하나만 쓰기로 결정했다(ADR 미이관); 업스트림의 distroless 대신 여기 적용 |
빌더 스테이지(Go 컴파일)는 공식 `golang` 이미지를 그대로 쓴다 — 최종 이미지에 남는 것이
아니라 컴파일 산출물만 최종 스테이지로 넘어오므로 스캔·정책 대상이 아니다.
**업스트림과 다른 부분은 딱 하나다** — `golang.org/x/text` 를 워크스페이스 전역
`go.work` `replace` 한 줄로 `0.39.0` 이상으로 강제 업그레이드한다(각 서브모듈의
`go.mod` 를 개별로 고치지 않는다). 그 외 빌드 절차(`server`/`etcdutl`/`etcdctl`
바이너리를 각각 컴파일, `CGO_ENABLED=0` 정적 링크)는 업스트림
`scripts/build_lib.sh``etcd_build()` 를 그대로 재현한다.
`SOURCE_COMMIT`**자동 추적하지 않는다.** `cloudnative-pg` 와 동일하게 사람이
업스트림 `release-3.7` 브랜치(또는 그다음 패치 릴리스가 나오면 그 태그)를 보고
`source.build.env` 를 고쳐 PR 을 여는 것 자체가 갱신 트리거다.
**권장 점검 주기**: 카탈로그 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는
업스트림이 `v3.7.2`(혹은 그 이상, `x/text` 백포트 포함)를 릴리스했을 때 — 릴리스가
나오면 그쪽으로 갈아타는 것(대응 우선순위 a)이 이 자체 빌드를 유지하는 것보다 항상
우선이다.
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh /tmp/out
# 레지스트리에 push 까지 (현재 실제 배포 이미지도 이 네임스페이스에 있다)
IMAGE=etcd BASE_OS=source REGISTRY=docker.io/wbsong111 \
bash scripts/build/build-hardened-image.sh /tmp/out
```
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
`verify.sh``etcd`/`etcdctl`/`etcdutl` 이 PATH 에 있는지, `etcd --version` 출력에
pinned commit 이 실제로 반영됐는지(ldflags 주입 확인), 단일 노드로 기동해 `etcdctl`
put/get 왕복이 실제로 동작하는지를 확인한다 — 다중 노드 쿼럼·TLS 등은 이 스모크 테스트
범위 밖이며, dev 클러스터 배포 테스트(`.claude/deploy-test-procedure.md`,
`scripts/deploy-test/deploy-test-etcd.sh`)가 담당한다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `source.Dockerfile` | 빌드 정의 — 소스 컴파일(builder 스테이지) + SUSE BCI(`bci-micro`) 패키징(final 스테이지) |
| `source.build.env` | pinned commit·버전·빌더 이미지 태그. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 `docker run --entrypoint sh` 로 게스트 셸 스크립트를 주입한다(`bci-micro` 는 bash·coreutils 가 있다 — `cnpg-postgresql/verify.sh` 와 같은 패턴, `cloudnative-pg` 의 셸 없는 distroless 와는 다르다) |
베이스 변종이 하나뿐이라 파일명이 `source.*` 로 고정돼 있다.
### 태그
```
docker.io/wbsong111/etcd:3.7.1-security-hardened-20260731
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
이 태그는 security-catalog 프로젝트에서 이미 빌드·게이트 PASS·push 된 실제 이미지다.
dip-catalog 는 이를 그대로 재사용한다 — 재빌드·재푸시 여부는 `MEMORY.md` 참고.
-11
View File
@@ -1,11 +0,0 @@
# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(<variant>.build.env)와는
# 별개다 — 이건 "이 이미지가 어느 차트의 어느 필드를 가리키는가" 만 담는다.
CHART_DIRS="manifests/helm/etcd/1.1.12"
# 태그 표기 스타일 — imageName(단일 필드 문자열) | split(registry/repository/tag 분리)
TAG_STYLE=split
TAG_BLOCK=image
# base_os 입력을 안 주면 쓸 기본 변종 (images/<image>/<DEFAULT_BASE_OS>.build.env)
DEFAULT_BASE_OS=source
-83
View File
@@ -1,83 +0,0 @@
# etcd — 업스트림 소스를 pinned commit 으로 직접 컴파일한다.
#
# 업스트림(https://github.com/etcd-io/etcd) 의 루트 Dockerfile 은 이미 빌드된 바이너리
# (`etcd`/`etcdctl`/`etcdutl`)를 ADD 만 한다 — 컴파일 자체는 `scripts/build.sh` →
# `scripts/build_lib.sh` 의 `etcd_build()` 가 Dockerfile 밖에서 수행한다. 우리는 그 빌드
# 단계를 Dockerfile 안으로 가져와 `go build` 로 직접 재현한다(cloudnative-pg 자체 빌드와
# 동일한 패턴 — doc/decisions/0005 참고).
#
# 왜 자체 빌드인가 — quay.io/coreos/etcd:v3.7.1 이 게이트에서 차단하는 HIGH 1건
# (CVE-2026-56852, golang.org/x/text)은 OS 패키지가 아니라 바이너리에 정적 링크된 Go
# 모듈 버전이 원인이다. etcd v3.7.1/release-3.7 브랜치는 go.mod 에 x/text v0.37.0(취약)을
# 고정하고 있고 상위 패치 릴리스가 아직 없다(2026-07-31 확인) — 베이스 OS 교체로는
# 고쳐지지 않으므로 자체 빌드가 유일한 대응이다. 근거: doc/decisions/0004-etcd-image-self-build.md
#
# 업스트림과의 대응 관계 (v3.7.1 태그, scripts/build_lib.sh 의 etcd_build() 기준)
# cd server && go build ... → 동일 (ldflags 는 GitSHA 대신 pinned commit 을 직접 주입 — 아래 참고)
# cd etcdutl && go build ... → 동일
# cd etcdctl && go build ... → 동일
# distroless/static-debian12 → SUSE BCI(bci-micro)로 교체. 카탈로그는 SUSE BCI 하나만
# 쓴다(decisions/0001) — builder 스테이지(Go 컴파일)는 공식 golang 이미지를 그대로 쓴다
#
# 업스트림과 다르게 하는 부분 — x/text 강제 업그레이드
# etcd 는 Go 워크스페이스(go.work)로 서버/etcdctl/etcdutl 모듈을 함께 관리한다.
# 각 go.mod 를 개별로 고치는 대신 go.work 에 워크스페이스 전역 replace 한 줄만 추가해
# golang.org/x/text 를 강제 업그레이드한다 — 업스트림과의 차이를 최소로 유지하기 위함
# (.claude/image-authoring.md — 업스트림과의 차이를 최소로 유지한다).
#
# ldflags — GitSHA 대신 pinned commit 을 직접 주입하는 이유
# 업스트림은 `git rev-parse --short HEAD` 로 얻은 값을 버전 문자열에 심는다. 우리는
# BuildKit 의 git context(`ADD ...#${SOURCE_COMMIT}`)로 체크아웃하므로 커밋을 이미 알고
# 있다 — 호스트의 verify.sh 가 굳이 컨테이너 안에서 git 을 실행하지 않고도 버전 문자열에
# pinned commit 이 반영됐는지 확인할 수 있도록, 알고 있는 전체 커밋 해시를 그대로 심는다
# (cloudnative-pg/source.Dockerfile 과 동일한 이유).
# syntax=docker/dockerfile:1
ARG GO_BUILDER_TAG=1.26.5-trixie
# FROM 에서 쓰는 ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언해야 한다 — .claude/image-authoring.md
# 3번 항목, cloudnative-pg/source.Dockerfile 에서 실제로 겪은 버그.
ARG RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# 호스트 네이티브 아키텍처로 빌더를 띄운다. Go 크로스컴파일은 에뮬레이션이 필요 없다.
FROM --platform=$BUILDPLATFORM golang:${GO_BUILDER_TAG} AS builder
ARG TARGETARCH
ARG SOURCE_COMMIT
ARG APP_VERSION
ARG XTEXT_FIX_VERSION
WORKDIR /src
# BuildKit 의 git context 지원 — tarball+checksum 관리 없이 git 자체가 커밋 무결성을 보장한다.
ADD https://github.com/etcd-io/etcd.git#${SOURCE_COMMIT} /src
# CVE-2026-56852 대응: golang.org/x/text 를 워크스페이스 전역으로 강제 업그레이드한다.
# `go work sync` 가 이 replace 를 각 모듈(server/etcdctl/etcdutl 등)의 go.sum 에 반영한다.
RUN echo "replace golang.org/x/text => golang.org/x/text v${XTEXT_FIX_VERSION}" >> go.work \
&& go work sync
# 업스트림 scripts/build_lib.sh 의 etcd_build() 를 그대로 재현한다.
# (GOARCH 는 TARGETARCH 로 대체, GitSHA 대신 pinned commit 전체 해시를 심는다 — 위 설명 참고)
RUN --mount=type=cache,target=/root/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
set -eux; \
mkdir -p /out; \
LDFLAGS="-X=go.etcd.io/etcd/api/v3/version.GitSHA=${SOURCE_COMMIT}"; \
( cd server && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcd . ); \
( cd etcdutl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdutl . ); \
( cd etcdctl && CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -trimpath -installsuffix=cgo -ldflags="${LDFLAGS}" -o /out/etcdctl . )
FROM ${RUNTIME_BASE} AS final
# 업스트림 루트 Dockerfile 과 동일한 경로·구조 — chart(groundhog2k/etcd) 의 startup/liveness/
# readiness probe 가 `/usr/local/bin/etcdctl` 을 하드코딩해 호출하므로 경로를 바꾸면 안 된다.
COPY --from=builder /out/etcd /usr/local/bin/etcd
COPY --from=builder /out/etcdctl /usr/local/bin/etcdctl
COPY --from=builder /out/etcdutl /usr/local/bin/etcdutl
COPY --from=builder /src/LICENSE /licenses/LICENSE
WORKDIR /var/etcd/
WORKDIR /var/lib/etcd/
EXPOSE 2379 2380
CMD ["/usr/local/bin/etcd"]
-41
View File
@@ -1,41 +0,0 @@
# etcd — 소스 컴파일형 자체 빌드 (유일한 변종)
#
# build-hardened-image.sh 가 source 한다. BUILD_ARGS 에 나열한 이름만 --build-arg 로 넘어간다.
# 이 이미지는 소스를 직접 컴파일하므로 "베이스 OS" 선택지가 없다 — BASE_OS=source 로 호출한다:
# IMAGE=etcd BASE_OS=source bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 왜 자체 빌드하는가·CVE 근거 → README.md
# 결정 → doc/decisions/0004-etcd-image-self-build.md
DOCKERFILE=source.Dockerfile
TARGET=final
TAG_SLUG=security
# build-hardened-image.sh 가 태그·verify.sh 전달용으로 요구하는 범용 필수값.
# 업스트림 stock 이미지(quay.io/coreos/etcd:v3.7.1)와는 태그 슬러그(TAG_SLUG=security)로
# 구분되므로 버전 문자열 자체는 그대로 둔다.
APP_VERSION=3.7.1
# v3.7.1 태그가 가리키는 실제 커밋(태그 객체가 아니라 peeled commit), 2026-07-31 확인:
# git ls-remote --tags https://github.com/etcd-io/etcd.git v3.7.1 v3.7.1^{}
SOURCE_COMMIT=5e7fd0de9a57db03ecc11794dc40403a734c07bb
# go.mod 의 `go 1.26`/`toolchain go1.26.5` 요구를 만족하는 공식 golang 이미지 태그.
# 이 이미지는 builder 스테이지에서만 쓰이고 최종 이미지에는 남지 않는다.
#
# 값은 go.mod 최소 요구가 아니라 **stdlib 차단 CVE 를 해소하는 지점**으로 정한다 — 소스를
# 안 바꿔도 CVE 데이터가 갱신되면 여기가 올라간다. 손으로 고르지 않는다:
# python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports> --image etcd
GO_BUILDER_TAG=1.26.6-trixie
# 최종 런타임 베이스 — 카탈로그는 SUSE BCI 하나만 쓴다(decisions/0001). 정적 링크
# 바이너리(CGO_ENABLED=0)라 패키지 매니저가 없는 가장 가벼운 bci-micro 로 충분하다.
RUNTIME_BASE=registry.suse.com/bci/bci-micro:15.7
# CVE-2026-56852 대응 — golang.org/x/text 를 이 버전 이상으로 강제 업그레이드한다.
# v3.7.1/release-3.7 브랜치는 아직 이 CVE 를 백포트하지 않았다(go.mod 실측 x/text
# v0.37.0). etcd 메인테이너가 release-3.7 브랜치에 패치를 백포트하면(그리고 v3.7.2 가
# 나오면) 이 자체 빌드는 걷어내고 상위 태그로 돌아간다(대응 우선순위 a 가 c 보다 우선).
XTEXT_FIX_VERSION=0.39.0
BUILD_ARGS="SOURCE_COMMIT GO_BUILDER_TAG APP_VERSION RUNTIME_BASE XTEXT_FIX_VERSION"
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# etcd 이미지 기능 검증 — 호스트에서 bash 로 실행된다(build-hardened-image.sh 가
# `env TAG=... PLATFORM=... SOURCE_COMMIT=... XTEXT_FIX_VERSION=... bash verify.sh`
# 형태로 호출한다). 최종 베이스가 bci-micro 라 bash/coreutils 가 있으므로(패키지
# 매니저만 없음 — .claude/image-authoring.md), cnpg-postgresql/verify.sh 와 같은
# 게스트 셸 패턴을 쓴다(cloudnative-pg 처럼 셸 없는 distroless 가 아니다).
#
# 마지막 줄에 VERIFY-OK 를 출력하면 통과다. build-hardened-image.sh 가 그것으로 판정한다.
#
# 이 이미지에 요구하는 것
# - /usr/local/bin/etcd·etcdctl·etcdutl 이 PATH 에 있어야 한다 (chart 의 startup/
# liveness/readiness probe 가 /usr/local/bin/etcdctl 을 하드코딩해 호출한다)
# - 단일 노드로 기동해 put/get 왕복이 실제로 동작해야 한다 (게이트 0건이어도 못 쓰는
# 이미지는 무의미하다 — .claude/image-authoring.md 5번)
set -e
TAG="${TAG:?TAG 환경변수가 필요하다}"
PLATFORM="${PLATFORM:-linux/amd64}"
SOURCE_COMMIT="${SOURCE_COMMIT:?SOURCE_COMMIT 환경변수가 필요하다 (build-hardened-image.sh 가 build.env 에서 전달)}"
docker run --rm -i --platform "$PLATFORM" -e SOURCE_COMMIT="$SOURCE_COMMIT" --entrypoint sh "$TAG" <<'GUEST'
set -e
echo "== 바이너리 탐색 (PATH 기준) =="
for b in etcd etcdctl etcdutl; do
p=$(command -v "$b") || { echo "FAIL: $b 를 PATH 에서 찾을 수 없다"; exit 1; }
echo " $b -> $p"
done
echo "== 버전 (pinned commit 반영 확인) =="
# sed 가 없다(bci-micro 는 bash·coreutils 는 있지만 sed 는 별도 패키지라 빠져 있다 —
# 2026-07-31 CI 에서 실측: "sh: sed: command not found"). 순수 셸 루프로 들여쓴다.
OUT="$(etcd --version)"
while IFS= read -r line; do echo " $line"; done <<EOF
$OUT
EOF
case "$OUT" in
*"$SOURCE_COMMIT"*) ;;
*) echo "FAIL: 버전 출력에 pinned commit($SOURCE_COMMIT) 이 없다 — ldflags 주입 확인 필요"; exit 1 ;;
esac
echo "== 단일 노드 기동 =="
export ETCD_DATA_DIR=/tmp/verify-data
export ETCD_LISTEN_CLIENT_URLS=http://127.0.0.1:2379
export ETCD_ADVERTISE_CLIENT_URLS=http://127.0.0.1:2379
export ETCD_LISTEN_PEER_URLS=http://127.0.0.1:12380
export ETCD_INITIAL_ADVERTISE_PEER_URLS=http://127.0.0.1:12380
export ETCD_INITIAL_CLUSTER=default=http://127.0.0.1:12380
export ETCD_NAME=default
etcd >/tmp/etcd-verify.log 2>&1 &
ETCD_PID=$!
trap 'kill $ETCD_PID 2>/dev/null || true' EXIT
echo "== health 대기 (최대 30s) =="
ok=0
for i in $(seq 1 30); do
if etcdctl endpoint health >/dev/null 2>&1; then ok=1; break; fi
sleep 1
done
if [ "$ok" != "1" ]; then
echo "FAIL: etcd 가 30초 내에 healthy 상태가 되지 못했다"
cat /tmp/etcd-verify.log
exit 1
fi
HEALTH="$(etcdctl endpoint health)"
while IFS= read -r line; do echo " $line"; done <<EOF
$HEALTH
EOF
echo "== put/get 왕복 =="
etcdctl put smoke-test ok >/dev/null
answer="$(etcdctl get smoke-test --print-value-only)"
[ "$answer" = "ok" ] || { echo "FAIL: get 응답 이상 (got: '$answer')"; exit 1; }
echo " get smoke-test => '$answer'"
kill "$ETCD_PID" 2>/dev/null || true
trap - EXIT
echo "VERIFY-OK"
GUEST
-203
View File
@@ -1,203 +0,0 @@
# keycloak — 자체 빌드
업스트림 Keycloak 배포본(tar.gz)을 SUSE BCI rootfs 위에 재패키징하고, 배포본에 정적으로
들어 있는 취약 jar 를 수정 버전으로 교체한다.
`manifests/helm/keycloakx/7.2.2/custom-values.yaml``image.repository`/`image.tag`
이 산출물을 가리킨다.
신규 자체 빌드 이미지 추가 절차 전반은
[.claude/image-authoring.md](../../.claude/image-authoring.md) 참고.
> **자체 빌드는 대응 우선순위 3번이다.** 상위 태그 교체·베이스 OS 교체로 목표를
> 만족할 수 있으면 그 쪽을 쓴다. 자체 빌드는 업스트림 서명·provenance·SBOM attestation 을
> 잃고 재빌드 책임을 지는 선택이다.
## 왜 자체 빌드하나
업스트림 `quay.io/keycloak/keycloak:26.6.4` 는 게이트에서 **차단 17건(실효 HIGH 17,
CRITICAL 0)** 이다(PR #18`helm-catalog-sbom` run 31085455385 실측). 계층별로 성격이
전혀 다르다.
| 계층 | 건수 | 대표 CVE | 상위 태그·베이스 OS 교체로 풀리나 |
| --- | --- | --- | --- |
| UBI9 rpm | 5 | `java-21-openjdk-headless` ×3, `libacl`, `pcre2` | 일부. 3건은 rpm 최신화로 해소 |
| 번들 jar | 12 | netty ×6, jackson ×3, postgresql-jdbc, mssql-jdbc, keycloak-services | **아니다** |
### 상위 태그 교체를 먼저 검토한 결과 (image-authoring.md 체크리스트 1)
**jar 12건은 keycloak 버전을 올려도 풀리지 않는다.** keycloak `26.6.4` 와 최신
`26.7.1``quarkus.version` 이 둘 다 `3.33.2.1` 이고, Quarkus 3.33.2.1 BOM 이
`netty 4.1.135.Final` / `jackson-bom 2.21.2` 를 고정한다(실측: keycloak `pom.xml`
태그 비교 + `quarkusio/quarkus` `bom/application/pom.xml@3.33.2.1`). 차단 CVE 의 수정
버전은 netty `4.1.136.Final`, jackson `2.21.4` 다 — 다음 Quarkus BOM 이 올라오기 전까지
업스트림 이미지로는 방법이 없다.
**베이스 OS 교체도 jar 에는 통하지 않는다.** CVE 가 OS 패키지가 아니라 배포본에 함께
실려 있는 jar 자체이기 때문이다 — `cloudnative-pg`/`etcd` 의 정적 링크 Go 모듈과 같은
구조다.
그래서 **jar 를 직접 교체하는 자체 빌드가 유일한 수단**이다. `etcd` 이미지가
`go.work``replace golang.org/x/text` 한 줄을 넣어 해결한 것과 같은 성격의 의존성
override 이며, 오케스트레이션은 동일한
[scripts/build/build-hardened-image.sh](../../scripts/build/build-hardened-image.sh)
하나를 공유한다.
### 그래도 버전은 26.7.1 로 올린다
`26.6.4``26.7.1`(및 `26.6.5`)에서만 패치된 `keycloak-services` HIGH 5건에
취약하다 — CVE-2026-16102 / 16442 / 16443 / 15572 / 15573 (+ MEDIUM 2건, GitHub
Security Advisory 실측). 스캔 시점 trivy DB 에 아직 없어 게이트에 잡히지 않았을 뿐이다.
차트(`keycloakx`)는 **7.2.2 가 최신이고 그대로 둔다.** 차트의 `appVersion: 26.6.4`
`image.tag` 미지정 시의 기본값일 뿐이고(`templates/statefulset.yaml`
`.Values.image.tag | default .Chart.AppVersion`), 우리는 `custom-values.yaml` 에서
태그를 명시한다. appVersion 이 26.7.1 보다 낮은 것은 codecentric 차트의 릴리스 캐던스
지연이지 차트 결함이 아니다.
## 유형과 베이스 OS
**유형: "업스트림 산출물을 다른 배포판에 재설치"** (image-authoring.md 두 유형 중 첫
번째). Keycloak 을 소스에서 Maven 빌드하지 않는다 — 업스트림이 릴리스한 tar.gz 를
그대로 쓰고 런타임 rootfs 만 SUSE 로 바꾼다.
| 항목 | 값 |
| --- | --- |
| 배포본 | `github.com/keycloak/keycloak/releases/download/$KEYCLOAK_VERSION/keycloak-$KEYCLOAK_VERSION.tar.gz` |
| 빌더 | `registry.suse.com/bci/bci-base:15.7` (zypper 필요) |
| 최종 rootfs 씨앗 | `registry.suse.com/bci/bci-micro:15.7` |
| 최종 스테이지 | `FROM scratch` + 위 rootfs |
### 베이스 OS 결정 배경 (image-authoring.md 원칙 2)
업스트림은 `ubi9` 빌더 + `ubi9-micro` 최종이다. 카탈로그의 먼저 들어온 자체 빌드
이미지들이 전부 SUSE BCI 이고 trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼
있어(게이트의 `CoverageProbe` — [doc/sbom-pipeline.md](../../doc/sbom-pipeline.md)), **"업스트림과 최대한 동일하게" 보다
카탈로그 내 일관성을 우선**했다.
**BCI 16.0 이 나와 있지만 15.7 을 쓴다 — 최신이 이 이미지에는 더 낡았다**(2026-08-07
실측). 필요한 나머지 패키지는 16.0 에도 전부 있지만 JDK 가 뒤처져 있다.
| 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건이 그대로 남았다. **16.0 의 JDK 가 15.7 을 따라잡으면
그때 올린다** — 재측정 방법은 `suse.build.env` 주석에 있다.
### rootfs 를 왜 "씨앗" 방식으로 만드나
업스트림 `ubi-null.sh` 는 빈 installroot 에 패키지를 깔고 그 rootfs 를 `ubi9-micro`
**위에 덮는다**. 이 구조를 SUSE 에 그대로 옮기면 `bci-micro` 의 rpmdb 가 새 rpmdb 로
가려져 **micro 자체 패키지가 SBOM 에서 통째로 사라진다** — CVE 가 줄어드는 게 아니라
스캔 사각지대가 생기는 것이고, 게이트가 경고하는 "베이스 OS 교체로 수치만 낮아진 것"의
전형이다.
그래서 `bci-micro` 파일시스템을 씨앗으로 깐 뒤 그 위에 `zypper --installroot`
설치한다. rpmdb 가 micro 것 위에 이어 써지므로 최종 이미지의 모든 OS 패키지가 SBOM 에
잡힌다. 빌드 후 이 점을 반드시 확인한다(아래 "빌드" 절).
## 업스트림과 다른 부분
업스트림 [`quarkus/container/Dockerfile`](https://github.com/keycloak/keycloak/blob/main/quarkus/container/Dockerfile)
대비 차이는 셋뿐이다.
1. **런타임 rootfs 가 SUSE BCI** — 위 참조. 패키지 목록(`RUNTIME_PACKAGES`)은 업스트림
이미지 SBOM 의 rpm 44종을 근거로 정했다. `sed`·`grep`**없으면 안 된다**
`bin/kc.sh``/bin/sh` 스크립트로 `esceval()` 에서 `sed`, 인자 파싱에서 `grep`
쓰는데 `bci-micro` 에는 `sed` 가 없다.
2. **취약 jar 오버레이** (`overlay-jars.sh`) — `lib/lib/main/` 의 jar 를 **파일명은
유지하고 내용만** 수정 버전으로 바꾼다. Quarkus fast-jar 의 클래스패스가 파일명을
그대로 참조하기 때문이다. 대상은 `suse.build.env``*_OLD`/`*_VERSION` 이 정의한다:
| 그룹 | 대상 | 버전 |
| --- | --- | --- |
| `io.netty` | 패밀리 전체 17종 | `4.1.135.Final``4.1.136.Final` |
| `com.fasterxml.jackson.core` | `jackson-core`, `jackson-databind` | `2.21.2``2.21.4` |
| `org.postgresql` | `postgresql` | `42.7.11``42.7.12` |
| `io.micrometer` | 패밀리 전체 4종 | `1.16.3``1.16.6` |
netty·micrometer 는 CVE 가 붙은 아티팩트만이 아니라 **패밀리 전체**를 함께 올린다 —
둘 다 아티팩트 간 버전 혼용이 비지원이다. jackson 은 2.21.x 안에서 호환이 보장되고
`jackson-annotations``2.21` 로 별도 버저닝돼 있어 CVE 있는 둘만 바꾼다.
위장이 아니다 — trivy 는 jar 내부 `META-INF/maven/**/pom.properties`(없으면 sha1↔GAV
인덱스)로 버전을 판정하므로 SBOM 에는 새 버전이 정확히 잡히고, 실제 내용도 새 버전이다.
3. **`bin/client/` 제거** — `keycloak-admin-cli-<ver>.jar` 는 jackson 을 shade 로 품은
uber-jar 라 jar 교체로 고칠 수 없다(SBOM 실측: `jackson-databind@2.21.2`
FilePath 가 이 파일). 서버 JVM 이 로드하지 않는 독립 CLI(`kcadm.sh`/`kcreg.sh`)이므로
하드닝 이미지에서는 제거했다. **업스트림 대비 유일한 기능적 차이다** — 운영에서
`kcadm` 이 필요하면 업스트림 이미지를 별도 컨테이너로 쓴다.
`kc.sh build` 를 최종 스테이지에서 한 번 돌리는 것은 차이가 아니다 — 옵션 없는 build 는
릴리스 tar 의 사전 augmentation 상태를 그대로 재현하며, 오버레이한 jar 로 augmentation 이
실제로 통과하는지 빌드 시점에 확인하기 위한 것이다(최적화 이미지로 만드는 것이 아니다).
## 버전 관리
`APP_VERSION`/`KEYCLOAK_VERSION``*_OLD`/`*_VERSION` jar 버전은 **자동 추적하지
않는다.** 사람이 업스트림 릴리스를 보고 `suse.build.env` 를 고쳐 PR 을 여는 것 자체가
갱신 트리거다.
`overlay-jars.sh``*_OLD` 버전 jar 를 하나도 못 찾으면 **빌드를 실패시킨다.**
업스트림이 의존성을 올렸는데 스크립트가 조용히 아무것도 안 해서 "CVE 는 그대로인데
빌드는 성공" 하는 상태를 막기 위함이다.
**권장 점검 주기**: 게이트가 이 이미지의 차단 CVE 를 다시 보고할 때, 또는 Keycloak 이
Quarkus BOM 을 올린 릴리스를 낼 때 — **BOM 이 올라가 오버레이가 불필요해지면 해당
spec 을 제거하는 것이 이 자체 빌드를 유지하는 것보다 항상 우선이다.**
## 빌드
```sh
# 로컬 빌드 (push 없음)
IMAGE=keycloak BASE_OS=suse bash scripts/build/build-hardened-image.sh /tmp/kc-out
# 레지스트리 push 까지
IMAGE=keycloak BASE_OS=suse REGISTRY=docker.io/paasup \
bash scripts/build/build-hardened-image.sh /tmp/kc-out
```
> **arm64 호스트(Apple Silicon)에서 돌릴 때**: `linux/amd64` 를 QEMU 로 에뮬레이션하므로
> `verify.sh` 의 실기동이 매우 느리다 — Quarkus augmentation 만 ~100초, 기동 전체가
> 300초를 넘는다(2026-08-07 실측). `verify.sh` 의 기본 대기 시간은 600초이고
> `VERIFY_BOOT_TIMEOUT` 으로 조정한다. 네이티브 amd64 러너에서는 1~2분이면 끝난다.
수행 순서: **빌드 → 기능 검증(`verify.sh`) → SBOM → 전 심각도 스캔 → 게이트 판정.**
빌드 후 확인할 것:
1. `verify.log` 마지막 줄이 `VERIFY-OK`
2. `cve-gate.md` 의 차단 항목 — `doc/cve-exceptions.json` 에 등록된 것 외에 없는지
3. **커버리지 자가진단이 `ok`** 인지(`none` 이면 findings 0 이 진짜 0 이 아니다 —
`doc/sbom-pipeline.md`)
4. **rpmdb 마스킹이 없는지** — SBOM 의 OS 패키지 수가 `bci-micro` 단독 + 설치분에
해당하는지. 업스트림 UBI9 이미지의 OS 패키지는 44종이었다. 한 자릿수로 떨어졌다면
씨앗 방식이 동작하지 않은 것이므로 설계를 재검토한다.
`verify.sh` 가 확인하는 것: kc.sh 가 쓰는 셸 도구·java 21·`en_US.UTF-8` 로케일·
`Asia/Seoul` 타임존, `bin/client` 제거, **오버레이한 jar 의 내부
`pom.properties` 버전**(파일명이 아니라 내용 — trivy 와 같은 근거), 그리고 실제
`start-dev` 기동 후 OIDC discovery 응답·admin 토큰 발급·`GET /admin/realms` 까지.
외부 postgres·ingress·클러스터링은 이 스모크 테스트 범위 밖이며 dev 클러스터 배포
테스트(`.claude/deploy-test-procedure.md`)가 담당한다.
### 파일 구성
| 파일 | 역할 |
| --- | --- |
| `suse.Dockerfile` | 빌드 정의 — bci-base 빌더(rootfs 구성 + 배포본 전개 + jar 오버레이) + `FROM scratch` 최종 |
| `suse.build.env` | keycloak 버전·베이스 이미지·런타임 패키지·jar 오버레이 버전. `BUILD_ARGS` 에 나열한 이름만 `--build-arg` 로 전달된다 |
| `overlay-jars.sh` | 취약 jar 교체 + `bin/client` 제거. builder 스테이지 전용(호스트에서 직접 쓰지 않는다) |
| `verify.sh` | 기능 검증. 호스트에서 bash 로 실행되며 게스트 셸 주입 + 호스트 python3 jar 검사 + 실기동을 조합한다 |
| `catalog.env` | 이 이미지가 갱신하는 차트 디렉토리와 태그 표기 스타일 (`build-image.yml` 이 읽는다) |
베이스 변종이 하나뿐이라 파일명이 `suse.*` 로 고정돼 있다.
### 태그
```
docker.io/paasup/keycloak:26.7.1-bci15.7-hardened-20260807
└ app ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
```
-10
View File
@@ -1,10 +0,0 @@
# 이 이미지가 어느 차트의 어느 필드를 가리키는가 (build-image.yml 이 읽는다).
#
# keycloakx 차트는 registry 필드가 없고 repository 하나에 전체 경로를 쓴다
# (templates/statefulset.yaml: "{{ .Values.image.repository }}:{{ .Values.image.tag }}").
# patch-catalog-tag.py 의 has_registry_field == False 분기가 repository 를
# docker.io/paasup/keycloak 전체 경로로 치환한다 — cloudnative-pg 와 같은 형태.
CHART_DIRS="manifests/helm/keycloakx/7.2.2"
TAG_STYLE=split
TAG_BLOCK=image
DEFAULT_BASE_OS=suse
-99
View File
@@ -1,99 +0,0 @@
#!/usr/bin/env bash
# Keycloak 배포본에 정적으로 들어 있는 취약 jar 를 수정 버전으로 교체한다.
# suse.Dockerfile 의 builder 스테이지에서만 실행된다(호스트에서 직접 쓰지 않는다).
#
# 왜 필요한가
# 차단 CVE 17건 중 12건이 /opt/keycloak/lib/lib/main/ 의 jar 다. 이 버전들은
# Quarkus 3.33.2.1 BOM 이 고정하고 있어 keycloak 상위 태그로도, 베이스 OS 교체로도
# 바뀌지 않는다. etcd 이미지의 `go.work replace` 와 같은 성격의 의존성 override 다.
#
# 왜 파일명을 유지하는가
# Quarkus fast-jar 배포본은 lib/quarkus-run.jar 의 Class-Path 로 jar 파일명을 그대로
# 참조한다. 이름을 바꾸면 클래스패스가 깨진다. 이름을 유지하고 내용만 바꾸면
# - 런타임: 클래스패스 그대로 동작
# - SBOM: trivy 는 jar 내부 META-INF/maven/**/pom.properties 를 읽으므로 새 버전이
# 정확히 잡힌다 (파일명으로 버전을 위장하는 것이 아니다 — 실제 내용이 새 버전이다)
#
# 무결성
# Maven Central 의 .sha1 을 함께 받아 검증한다. 실패하면 즉시 종료한다.
#
# 실패 조건
# OLD 버전 파일을 하나도 못 찾은 spec 이 있으면 실패한다. 업스트림이 의존성 버전을
# 올렸는데 이 스크립트가 조용히 아무것도 안 하는 상태(= CVE 는 그대로인데 빌드는 성공)
# 를 막기 위함이다. patch-catalog-tag.py 가 패턴 불일치 시 크게 실패하는 것과 같은 원칙.
set -euo pipefail
KC_HOME="${1:?사용법: overlay-jars.sh <KEYCLOAK_HOME>}"
LIB="$KC_HOME/lib/lib/main"
M2="${M2_BASE:-https://repo1.maven.org/maven2}"
[ -d "$LIB" ] || { echo "FAIL: $LIB 가 없다 — 배포본 레이아웃이 바뀌었는지 확인"; exit 1; }
: "${NETTY_OLD:?}" "${NETTY_VERSION:?}"
: "${JACKSON_OLD:?}" "${JACKSON_VERSION:?}"
: "${PGJDBC_OLD:?}" "${PGJDBC_VERSION:?}"
: "${MICROMETER_OLD:?}" "${MICROMETER_VERSION:?}"
# groupId(파일명 프리픽스) | groupPath(Maven 경로) | artifact 필터 | OLD | NEW
# artifact 필터가 '*' 이면 해당 groupId + OLD 버전의 모든 jar 가 대상이다.
SPECS=(
"io.netty|io/netty|*|${NETTY_OLD}|${NETTY_VERSION}"
"com.fasterxml.jackson.core|com/fasterxml/jackson/core|jackson-core|${JACKSON_OLD}|${JACKSON_VERSION}"
"com.fasterxml.jackson.core|com/fasterxml/jackson/core|jackson-databind|${JACKSON_OLD}|${JACKSON_VERSION}"
"org.postgresql|org/postgresql|postgresql|${PGJDBC_OLD}|${PGJDBC_VERSION}"
"io.micrometer|io/micrometer|*|${MICROMETER_OLD}|${MICROMETER_VERSION}"
)
fetch() { # $1=url $2=출력경로
local url="$1" out="$2" want got
curl -fsSL --retry 3 --retry-delay 2 -o "$out" "$url"
want="$(curl -fsSL --retry 3 --retry-delay 2 "$url.sha1" | tr -d '[:space:]')"
got="$(sha1sum "$out" | cut -d' ' -f1)"
[ "$want" = "$got" ] || { echo "FAIL: sha1 불일치 $url (기대 $want / 실제 $got)"; exit 1; }
}
total=0
for spec in "${SPECS[@]}"; do
IFS='|' read -r gid gpath filter old new <<<"$spec"
matched=0
for f in "$LIB/$gid."*"-$old"*.jar; do
[ -e "$f" ] || continue
base="$(basename "$f")"
rest="${base#"$gid."}" # netty-codec-http-4.1.135.Final.jar
rest="${rest%.jar}" # netty-codec-http-4.1.135.Final
artifact="${rest%%-"$old"*}" # netty-codec-http
tail="${rest#*-"$old"}" # '' 또는 -linux-x86_64 (classifier)
if [ "$filter" != "*" ] && [ "$artifact" != "$filter" ]; then continue; fi
url="$M2/$gpath/$artifact/$new/$artifact-$new$tail.jar"
fetch "$url" "$f.new"
mv "$f.new" "$f" # 파일명 유지 — 내용만 교체
echo "overlay: $base <= $artifact-$new$tail.jar"
matched=$((matched + 1))
done
if [ "$matched" -eq 0 ]; then
echo "FAIL: spec '$gid/$filter@$old' 에 해당하는 jar 가 없다."
echo " 업스트림이 이미 버전을 올렸을 수 있다 — build.env 의 *_OLD 를 재확인하고,"
echo " 해소됐다면 해당 spec 을 제거한다(그냥 두면 CVE 가 남은 채 빌드가 성공한다)."
exit 1
fi
total=$((total + matched))
done
echo "overlay: 총 ${total}개 jar 교체 완료"
# keycloak-admin-cli 는 jackson 을 shade 로 품은 uber-jar 라 jar 교체로 못 고친다
# (SBOM 실측: jackson-databind@2.21.2 의 FilePath 가 bin/client/keycloak-admin-cli-*.jar).
# 서버 JVM 이 로드하지 않는 독립 CLI(kcadm.sh/kcreg.sh)이므로 하드닝 이미지에서는 제거한다.
# 업스트림 대비 유일한 기능적 차이다 — README.md / CUSTOM-README.md 에 명시.
if [ -d "$KC_HOME/bin/client" ]; then
rm -rf "$KC_HOME/bin/client"
echo "removed: bin/client (kcadm/kcreg — jackson shaded uber-jar, 서버 런타임 미사용)"
else
echo "FAIL: bin/client 가 없다 — 배포본 레이아웃이 바뀌었다. 제거 전제를 재확인할 것"
exit 1
fi
-112
View File
@@ -1,112 +0,0 @@
# Keycloak — SUSE BCI 기반 자체 빌드 (+ 취약 jar 오버레이)
#
# 업스트림: https://github.com/keycloak/keycloak/blob/main/quarkus/container/Dockerfile
#
# 업스트림과의 대응 관계
# registry.access.redhat.com/ubi9 (빌더) → registry.suse.com/bci/bci-base:15.7
# ubi-null.sh (rootfs 구성 후 불필요 rpm erase) → bci-micro 파일시스템 씨앗 + zypper --installroot
# registry.access.redhat.com/ubi9-micro (최종) → FROM scratch + 위 rootfs
# ADD $KEYCLOAK_DIST → tar → /opt/keycloak → 동일
# keycloak:x:0:root / uid 1000 / ENTRYPOINT → 동일
# (없음) → 취약 jar 오버레이 + kc.sh build 재augmentation
#
# 왜 자체 빌드인가 (상위 태그·베이스 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 를 고정한다 — 상위 태그로 올려도 그대로다. 베이스 OS 교체도 jar 에는 통하지
# 않는다. 그래서 jar 를 직접 교체하는 자체 빌드가 유일한 수단이다.
# (etcd 이미지의 `go.work replace golang.org/x/text` 와 같은 성격의 의존성 override)
#
# 왜 SUSE BCI 인가
# 기존 자체 빌드 3종(cloudnative-pg·cnpg-postgresql·etcd)이 전부 SUSE BCI 이고,
# trivy 의 SLES 15.7 커버리지는 양성 대조로 실측 확인돼 있다
# (게이트의 CoverageProbe — doc/sbom-pipeline.md). "업스트림과 최대한 동일하게" 원칙과
# 충돌하지만 카탈로그 내 일관성을 우선했다 — 근거는 README.md.
#
# ARG 는 반드시 첫 FROM 이전(전역 스코프)에 선언한다. 스테이지 내부에 두면 지역 변수가
# 되어 이후 FROM 의 이미지명 해석에 쓰이지 않는다 (.claude/image-authoring.md 원칙 3).
ARG BUILDER_BASE=registry.suse.com/bci/bci-base:15.7
ARG MICRO_BASE=registry.suse.com/bci/bci-micro:15.7
# 최종 런타임 rootfs 의 씨앗. 여기서 직접 빌드하지 않고 파일시스템만 가져다 쓴다.
FROM ${MICRO_BASE} AS micro
FROM ${BUILDER_BASE} AS builder
ARG KEYCLOAK_VERSION
ARG RUNTIME_PACKAGES
ARG NETTY_OLD
ARG NETTY_VERSION
ARG JACKSON_OLD
ARG JACKSON_VERSION
ARG PGJDBC_OLD
ARG PGJDBC_VERSION
ARG MICROMETER_OLD
ARG MICROMETER_VERSION
# (a) 런타임 rootfs 구성.
#
# bci-micro 의 파일시스템을 씨앗으로 깔고 그 위에 zypper --installroot 로 설치한다.
# 별도 installroot 를 만들어 micro 위에 COPY 로 덮는 방식(업스트림 ubi-null.sh 가
# ubi9-micro 에 하는 것)을 쓰지 않는 이유: 그러면 micro 의 rpmdb 가 새 rpmdb 로 가려져
# micro 자체 패키지가 SBOM 에서 사라진다 — CVE 가 줄어드는 게 아니라 스캔 사각지대가
# 생기는 것이다. 씨앗 방식은 rpmdb 가 micro 것 위에 이어 써져 전부 보인다.
#
# rpm --import 를 먼저 한다. 안 하면 installroot 의 rpmdb 에 SUSE 키가 없어 설치되는
# 패키지마다 "Header V3 RSA/SHA256 Signature, key ID ...: NOKEY" 로 개별 서명 검증이
# 생략된다(저장소 메타데이터 서명은 zypper 가 확인하지만 패키지 단위 검증은 별개다).
COPY --from=micro / /rootfs
RUN set -eux; \
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 \
${RUNTIME_PACKAGES}; \
zypper --non-interactive --installroot /rootfs clean --all; \
rm -rf /rootfs/var/log/zypp /rootfs/var/cache/zypp /rootfs/var/cache/zypper
# (b) 업스트림 배포본 전개 — 업스트림의 ADD $KEYCLOAK_DIST + tar 단계와 동일하다.
ADD https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz /tmp/keycloak/
RUN set -eux; \
cd /tmp/keycloak; \
tar -xf keycloak-*.tar.gz; \
rm keycloak-*.tar.gz; \
mv keycloak-* /opt/keycloak; \
mkdir -p /opt/keycloak/data; \
chmod -R g+rwX /opt/keycloak
# (c) 취약 jar 오버레이. 파일명은 그대로 두고 내용만 수정 버전으로 바꾼다 — 상세는
# overlay-jars.sh 주석. 대상 파일을 하나도 못 찾으면 스크립트가 실패한다.
COPY overlay-jars.sh /tmp/
RUN bash /tmp/overlay-jars.sh /opt/keycloak
FROM scratch AS final
COPY --from=builder /rootfs/ /
COPY --from=builder --chown=1000:0 /opt/keycloak /opt/keycloak
ENV LANG=en_US.UTF-8
# 업스트림과 동일 — 컨테이너 실행 여부 판별 플래그
ENV KC_RUN_IN_CONTAINER=true
RUN echo "keycloak:x:0:root" >> /etc/group && \
echo "keycloak:x:1000:0:keycloak user:/opt/keycloak:/sbin/nologin" >> /etc/passwd
# SUSE 는 java 를 update-alternatives 심볼릭 링크로 노출한다. chroot 설치라 이 링크가
# 안 만들어질 수 있어 빌드 시점에 단정한다 — 런타임에 "java: not found" 로 죽는 것보다
# 여기서 깨지는 게 낫다.
RUN java -version 2>&1 | head -1
# 오버레이한 jar 로 augmentation 이 실제로 통과하는지 빌드 시점에 확인한다.
# 옵션 없는 build 는 릴리스 tar 의 사전 augmentation 상태를 그대로 재현하므로
# 런타임 동작은 업스트림 이미지와 같다(최적화 이미지로 만드는 것이 아니다).
RUN /opt/keycloak/bin/kc.sh build && chown -R 1000:0 /opt/keycloak
USER 1000
EXPOSE 8080
EXPOSE 8443
EXPOSE 9000
ENTRYPOINT [ "/opt/keycloak/bin/kc.sh" ]
-64
View File
@@ -1,64 +0,0 @@
# Keycloak 자체 빌드 — SUSE BCI 기반, 취약 jar 오버레이 포함
#
# APP_VERSION 이 곧 Keycloak 릴리스 버전이다. 태그는
# docker.io/paasup/keycloak:<APP_VERSION>-<TAG_SLUG>-hardened-<YYYYMMDD>
# 로 만들어진다 (build-hardened-image.sh).
DOCKERFILE=suse.Dockerfile
TARGET=final
TAG_SLUG=bci15.7
APP_VERSION=26.7.1
KEYCLOAK_VERSION=26.7.1
# BCI 는 16.0 이 나와 있지만 15.7 을 쓴다 — 최신이 더 낡았다(2026-08-07 실측).
# 15.7 SLE_BCI: java-21-openjdk-headless 21.0.12.0-150600.3.29.1
# 16.0 SLE_BCI: java-21-openjdk-headless 21.0.11.0-160000.2.1
# 21.0.12 가 CVE-2026-41254·CVE-2026-47063 의 수정 버전이라, 16.0 으로 가면 이 이미지의
# 차단 CVE 2건이 오히려 남는다. 필요한 나머지 패키지는 16.0 에도 전부 있으므로
# (glibc-locale-base·ca-certificates-mozilla·sed·grep·findutils·timezone 확인)
# **16.0 의 java 가 15.7 을 따라잡으면 그때 올린다.** 재검토 시 위 두 값을 다시 잰다:
# docker run --rm registry.suse.com/bci/bci-base:<태그> \
# sh -c 'zypper -n refresh >/dev/null 2>&1; zypper -n info java-21-openjdk-headless'
BUILDER_BASE=registry.suse.com/bci/bci-base:15.7
MICRO_BASE=registry.suse.com/bci/bci-micro:15.7
# 업스트림 ubi-null.sh 가 ubi9-micro 위에 남기는 rpm 클로저(44종)의 SUSE 대응물이다.
# 근거: quay.io/keycloak/keycloak:26.6.4 SBOM 실측 — bash, coreutils-single, sed, grep,
# findutils, glibc-langpack-en, ca-certificates, tzdata, tzdata-java, java-21-openjdk-headless
# 가 들어 있고 나머지는 이들의 의존성이다(zypper 가 알아서 끌어온다).
#
# sed·grep·findutils 는 없으면 안 된다 — bin/kc.sh 가 /bin/sh 스크립트로 esceval() 에서
# sed, 인자 파싱에서 grep 을 쓴다. bci-micro 에는 sed·grep·find 가 **셋 다** 없다
# (2026-08-07 실측 — .claude/image-authoring.md 는 sed 만 기록하고 있었다).
#
# 패키지명 주의 (2026-08-07 SLE_BCI 15.7 실측)
# tzdata → SLE 에는 없다. 이름이 `timezone` 이다.
# tzdata-java → SLE 에는 대응 패키지가 없다. Java 는 JDK 내장 tzdb 를 쓴다.
# bash·coreutils·ca-certificates-mozilla 는 bci-micro 씨앗에 이미 있다(zypper 가
# "already installed" 로 넘어간다). 무엇이 필요한지 명시하려고 목록에 남겨 둔다.
RUNTIME_PACKAGES="java-21-openjdk-headless glibc-locale-base ca-certificates-mozilla bash coreutils sed grep findutils timezone"
# 취약 jar 오버레이 대상. <OLD> 는 keycloak $KEYCLOAK_VERSION 배포본이 실제로 담고 있는
# 버전이고(Quarkus 3.33.2.1 BOM 이 고정), <NEW> 는 차단 CVE 의 수정 버전이다.
# overlay-jars.sh 가 OLD 로 파일을 찾지 못하면 즉시 실패한다 — 업스트림이 버전을 올리면
# 여기가 조용히 무의미해지는 것을 막기 위함이다.
#
# netty: CVE-2026-55831/55833/56745/56819/59901/55851 (netty-codec-*)
# CVE 가 붙은 것만이 아니라 패밀리 전체를 함께 올린다 — netty 는 아티팩트 간
# 버전 혼용이 비지원이다.
NETTY_OLD=4.1.135.Final
NETTY_VERSION=4.1.136.Final
# jackson: CVE-2026-54512/54513 (databind), GHSA-r7wm-3cxj-wff9 (core)
# jackson-annotations 는 2.21 로 별도 버저닝이고 CVE 가 없어 건드리지 않는다.
JACKSON_OLD=2.21.2
JACKSON_VERSION=2.21.4
# postgresql jdbc: CVE-2026-54291
PGJDBC_OLD=42.7.11
PGJDBC_VERSION=42.7.12
# micrometer: CVE-2026-40983, CVE-2026-40984 (micrometer-core)
# netty 와 같은 이유로 패밀리 전체(commons·core·observation·registry-prometheus-
# simpleclient)를 함께 올린다. 이 2건은 2026-08-07 로컬 스캔에는 없었고 같은 날
# CI(더 최신 trivy DB)에서 처음 잡혔다 — 로컬 DB 가 뒤처질 수 있다는 실례다.
MICROMETER_OLD=1.16.3
MICROMETER_VERSION=1.16.6
BUILD_ARGS="KEYCLOAK_VERSION BUILDER_BASE MICRO_BASE RUNTIME_PACKAGES NETTY_OLD NETTY_VERSION JACKSON_OLD JACKSON_VERSION PGJDBC_OLD PGJDBC_VERSION MICROMETER_OLD MICROMETER_VERSION"
-201
View File
@@ -1,201 +0,0 @@
#!/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"
+15 -17
View File
@@ -2,13 +2,14 @@
#
# 기존엔 dataup experiment/apisix/apisix-plugin 레포에서 apache/apisix:3.17.0-debian
# 기반으로 keycloak-authz 커스텀 플러그인만 얹어 빌드했다. 이 베이스(Debian) 자체가
# CVE 게이트 차단 26건(2026-08-11 실측) — 이미 최신 apache/apisix 태그라 태그 교체로도
# 안 없어지는 Debian 베이스 OS 패키지 CVE라, images/apisix 자체 빌드(SUSE BCI 위에
# APISIX-Runtime 전체를 소스에서 재현 + keycloak-authz 오버레이)로 교체했다.
# 게이트 PASS(실효 CRITICAL/HIGH 0/0, 2026-08-12 실측). 근거: images/apisix/README.md.
# CVE 게이트를 다수 차단 — 이미 최신 apache/apisix 태그라 태그 교체로도 안 없어지는
# Debian 베이스 OS 패키지 CVE라, 자체 빌드(SUSE BCI 위에 APISIX-Runtime 전체를 소스에서
# 재현 + keycloak-authz 오버레이)로 교체했다. 빌드 정의·근거는 별도 레포 security-images
# images/apisix/(README.md 포함) — 이 레포에는 없다.
#
# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 images/apisix 를 다시 빌드해 갱신할 것 —
# dataup 레포 쪽은 더 이상 이 카탈로그가 참조하지 않는다(그 레포 자체는 그대로 둠).
# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 security-images 에서 apisix 를 다시
# 빌드해 갱신할 것 — dataup 레포 쪽은 더 이상 이 카탈로그가 참조하지 않는다(그 레포
# 자체는 그대로 둠).
image:
repository: docker.io/paasup/apisix
tag: "3.17.0-security-hardened-20260813"
@@ -183,18 +184,15 @@ ingress-controller:
ingressClass: apisix # Kong의 'kong' class와 분리
# apisix-ingress-controller 서브차트 기본값(2.1.0, apache/apisix-ingress-controller)이
# 이미 최신 태그인데도 CVE 게이트 차단 25건(정적 링크된 Go 모듈 21건 — 태그 교체로도
# 해소 안 됨) — images/apisix-ingress-controller 자체 빌드(취약 모듈만 강제 업그레이드)
# 로 교체해 게이트 PASS(실효 CRITICAL/HIGH 0건, 2026-08-11 실측). 근거: images/
# apisix-ingress-controller/README.md.
# 이미 최신 태그인데도 CVE 게이트를 다수 차단(정적 링크된 Go 모듈 다수 — 태그 교체로도
# 해소 안 됨) — 자체 빌드(취약 모듈만 강제 업그레이드)로 교체해 게이트 PASS. 빌드
# 정의·근거는 별도 레포 security-images 의 images/apisix-ingress-controller/(이 레포에는 없다).
#
# adc 사이드카 — 0.27.1 → 0.29.0 태그 교체는 유효한 부분 조치였지만 완전 해소는
# 아니었다(2026-08-12 게이트 재검증에서 정정 — 벤더 등급만 보면 0/0 이지만
# max(벤더,NVD) 로는 glibc regex/collating 스택오버플로 4건이 실효 CRITICAL/HIGH 로
# 차단. Debian 이 "affected, 수정 없음"으로 영구 고정해둔 벤더 하향 등급 사례 —
# cnpg-postgresql 때와 같은 패턴). images/adc 자체 빌드(distroless 대신 SUSE BCI +
# nodejs24, 빌더 스테이지는 업스트림 그대로)로 교체해 게이트 PASS(실효 CRITICAL/HIGH
# 0/0, 2026-08-12 실측). 근거: images/adc/README.md.
# adc 사이드카 — 태그 교체는 유효한 부분 조치였지만 완전 해소는 아니었다(벤더 등급만
# 보면 통과지만 max(벤더,NVD) 로는 여전히 차단 — Debian 이 "affected, 수정 없음"으로
# 영구 고정해둔 벤더 하향 등급 사례, cnpg-postgresql 때와 같은 패턴). 자체 빌드
# (distroless 대신 SUSE BCI + nodejs24, 빌더 스테이지는 업스트림 그대로)로 교체해
# 게이트 PASS. 빌드 정의·근거는 별도 레포 security-images 의 images/adc/(이 레포에는 없다).
deployment:
image:
repository: docker.io/paasup/apisix-ingress-controller
@@ -9,8 +9,8 @@ global:
# 자체 빌드(대응 우선순위 c) — 업스트림 v3.5.1 의 차단 CVE 대부분이 바이너리에 정적
# 링크된 Go 모듈이라 상위 태그 교체·베이스 OS 교체로 해소되지 않았다. 번들 도구
# (helm/kustomize/git-lfs)까지 최신 툴체인으로 다시 컴파일했다.
# 빌드 정의: images/argocd/. 상위 태그가 이 문제를 해결하면(대응 우선순위 a)
# 업스트림으로 되돌리는 것이 우선이다.
# 빌드 정의·근거는 별도 레포 security-images 의 images/argocd/(이 레포에는 없다).
# 상위 태그가 이 문제를 해결하면(대응 우선순위 a) 업스트림으로 되돌리는 것이 우선이다.
#
# 이 값은 argocd 바이너리를 쓰는 5개 컴포넌트(server/repo-server/application-controller/
# applicationset/notifications)가 공유한다 — 컴포넌트별 image 블록이 비면 global 을
@@ -4,8 +4,9 @@
image:
# 자체 빌드(대응 우선순위 c) — 업스트림 1.30.0 이 게이트 차단 HIGH 3건(stdlib·x/text·grpc,
# Go 모듈 정적 링크라 베이스 OS 교체로 해소 불가)으로 막혀 release-1.30 브랜치를 직접
# 컴파일했다. 근거·결정: doc/decisions/0002-cloudnative-pg-operator-self-build.md.
# 빌드 정의: images/cloudnative-pg/. 상위 태그가 나오면(대응 우선순위 a) 되돌리는 것이 우선.
# 컴파일했다. 빌드 정의·근거·결정은 별도 레포 security-images 의 images/cloudnative-pg/
# 와 docs/decisions/0002-cloudnative-pg-operator-self-build.md(이 레포에는 없다).
# 상위 태그가 나오면(대응 우선순위 a) 되돌리는 것이 우선.
repository: docker.io/paasup/cloudnative-pg
tag: "1.30.0-security-hardened-20260820"
@@ -4,12 +4,13 @@
instances: 3
postgresql:
# SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다 (2026-07-28).
# 결정 근거·받아들인 비용 → doc/decisions/0001-cnpg-postgresql-image.md
# 빌드 정의 → images/cnpg-postgresql/suse.Dockerfile + suse.build.env
# SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다.
# 결정 근거·받아들인 비용·빌드 정의는 별도 레포 security-images 의
# docs/decisions/0001-cnpg-postgresql-image.md 와 images/cnpg-postgresql/
# (이 레포에는 없다).
#
# 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로
# 롤링 태그를 쓰지 않는다 (.claude/image-authoring.md).
# 롤링 태그를 쓰지 않는다(security-images 레포의 docs/image-authoring.md).
imageName: "docker.io/paasup/cnpg-postgresql:18.4-bci15.7-hardened-20260820"
#
# trivy 는 SLES 15.7 을 정상 커버한다 — 실효 C/H 0/0 은 측정된 결과이며 게이트 PASS 다.
@@ -4,7 +4,8 @@ instances: 3
postgresql:
# custom-values.yaml 과 동일하게 SUSE BCI 15.7 자체 빌드를 쓴다.
# 근거·비용 doc/decisions/0001-cnpg-postgresql-image.md
# 근거·비용은 별도 레포 security-images 의 docs/decisions/0001-cnpg-postgresql-image.md
# (이 레포에는 없다).
# trivy 는 SLES 15.7 을 정상 커버한다(게이트의 CoverageProbe 가 매 스캔마다 확인 —
# doc/sbom-pipeline.md).
# 실효 C/H 0/0, 게이트 PASS.
@@ -4,8 +4,8 @@
instances: 3
postgresql:
# SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다 (2026-07-28).
# 빌드 정의 → images/cnpg-postgresql/suse.Dockerfile + suse.build.env
# SUSE BCI 15.7 기반 자체 하드닝 빌드로 교체했다.
# 빌드 정의는 별도 레포 security-images 의 images/cnpg-postgresql/(이 레포에는 없다).
#
# 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로
# 롤링 태그를 쓰지 않는다.
+1 -1
View File
@@ -52,7 +52,7 @@ kubectl -n etcd-system exec etcd-0 -- etcdctl endpoint health --cluster
| Name | 설명 | 기본값 |
| --- | --- | --- |
| `image.registry` / `image.repository` / `image.tag` | etcd 본체 이미지. **자체 빌드**(`docker.io/wbsong111/etcd:3.7.1-security-hardened-20260731`) — 업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트 차단 HIGH 1건(`CVE-2026-56852`, `golang.org/x/text`)으로 막혀 대체했다. 근거·빌드 정의는 [images/etcd/README.md](../../../images/etcd/README.md), 채택 결정 배경은 security-catalog 프로젝트 decisions/0007(dip-catalog 미이관). 상위 태그가 나오거나 `release-3.7` 에 백포트되면 업스트림으로 되돌리는 것이 우선(대응 우선순위 a) | 업스트림 `quay.io/coreos/etcd` (태그 미지정) |
| `image.registry` / `image.repository` / `image.tag` | etcd 본체 이미지. **자체 빌드**(`docker.io/paasup/etcd:3.7.1-security-hardened-20260820`) — 업스트림 `quay.io/coreos/etcd:v3.7.1` 이 게이트 차단 HIGH 1건(`CVE-2026-56852`, `golang.org/x/text`)으로 막혀 대체했다. 빌드 정의·근거·채택 결정 배경은 별도 레포 `security-images``images/etcd/README.md``docs/decisions/0004-etcd-image-self-build.md`(이 레포에는 없다). 상위 태그가 나오거나 `release-3.7` 에 백포트되면 업스트림으로 되돌리는 것이 우선(대응 우선순위 a) | 업스트림 `quay.io/coreos/etcd` (태그 미지정) |
| `initImage.registry` / `initImage.repository` / `initImage.tag` | 데이터 디렉토리 초기화용 init 컨테이너. 업스트림 기본값(`busybox:stable`)은 롤링 태그라 `busybox:1.38.0-uclibc` 로 고정 | 업스트림 `busybox:stable` |
### 2) 클러스터 크기
@@ -6,8 +6,9 @@ image:
# HIGH 1건(golang.org/x/text, CVE-2026-56852, 바이너리 정적 링크라 베이스 OS 교체로
# 해소 불가)으로 막혀 v3.7.1 태그가 가리키는 commit 을 그대로 컴파일했다. x/text 만
# go.work 워크스페이스 전역 replace 로 0.39.0 이상으로 강제.
# 근거·결정: doc/decisions/0004-etcd-image-self-build.md. 빌드 정의: images/etcd/.
# 상위 태그가 나오거나 release-3.7 에 백포트되면(대응 우선순위 a) 되돌리는 것이 우선.
# 근거·결정·빌드 정의는 별도 레포 security-images 의 docs/decisions/0004-etcd-image-self-build.md
# 와 images/etcd/(이 레포에는 없다). 상위 태그가 나오거나 release-3.7 에 백포트되면
# (대응 우선순위 a) 되돌리는 것이 우선.
#
# 이전 값: quay.io/coreos/etcd:v3.7.1 (업스트림). etcd 프로젝트는 3.8부터
# gcr.io/etcd-development·quay.io/coreos 를 폐지하고 registry.k8s.io/etcd 로 이전
@@ -17,7 +18,7 @@ image:
tag: "3.7.1-security-hardened-20260820"
initImage:
# 업스트림 기본값 "stable" 은 롤링 태그다 (롤링 태그 금지 — .claude/image-authoring.md).
# 업스트림 기본값 "stable" 은 롤링 태그다 — 스캔 대상이 시점마다 달라지므로 고정한다.
# 이 init 컨테이너도 extract-helm-images.sh 에 잡혀 게이트 대상이 되므로 고정한다.
registry: "docker.io"
repository: "busybox"
@@ -124,10 +124,10 @@ proxy:
## 3. 자체 빌드 이미지
`image.repository`/`image.tag` 는 업스트림 `quay.io/keycloak/keycloak` 이 아니라
`docker.io/paasup/keycloak` 자체 빌드 하드닝 이미지를 가리킨다. 빌드 정의
[`images/keycloak/`](../../../../images/keycloak/) 에 있고, 왜 자체 빌드인지·업스트림과
무엇이 다른지는 [`images/keycloak/README.md`](../../../../images/keycloak/README.md) 가
단일 출처다. 배포 관점에서 알아야 할 것만 아래에 적는다.
`docker.io/paasup/keycloak` 자체 빌드 하드닝 이미지를 가리킨다. 빌드 정의와, 왜 자체
빌드인지·업스트림과 무엇이 다른지는 별도 레포 `security-images``images/keycloak/`
(`README.md` 포함)가 단일 출처다 — 이 레포에는 없다. 배포 관점에서 알아야 할 것만
아래에 적는다.
### 앱 버전이 차트 `appVersion` 과 다르다
@@ -155,6 +155,8 @@ proxy:
### 이미지 갱신
`images/keycloak/suse.build.env``KEYCLOAK_VERSION` jar 오버레이 버전을 사람이
고쳐 PR 을 여는 것이 갱신 트리거다. `build-image.yml``workflow_dispatch` 로 돌리면
빌드·게이트 통과 후 이 파일의 `image.tag` 가 자동 갱신된 브랜치가 생성된다.
`security-images` 레포의 `images/keycloak/suse.build.env``KEYCLOAK_VERSION`
jar 오버레이 버전을 사람이 고쳐 커밋하는 것이 갱신 트리거다(그 레포에서). 그 레포의
`build-image.yml` 이 빌드·게이트 통과 후 push 하면 `published.json` 이 갱신되고,
이 카탈로그의 `catalog-tag-update.yml` 이 그것을 읽어가 이 파일의 `image.tag`
자동 갱신한 브랜치를 이 레포에 만든다.
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""apply-published-tags.py — security-images 의 `published.json` 을 읽어 카탈로그
values 자체 빌드 이미지 태그를 갱신한다.
카탈로그와 security-images(자체 빌드 이미지 레포) 계약은 `published.json` 파일
스키마 하나뿐이다 레포는 게이트 PASS + push 실제로 일어났을 때만 파일을
갱신한다. 스크립트는 파일과 `catalog/image-map/<image>.env`(어느 차트의 어느
필드를 갱신할지) 대조해 `custom-values.yaml`/`dip-values.yaml` 패치한다.
`gate != "pass"` 이미지는 건너뛴다 실패한 빌드의 태그를 반영하면 된다.
스크립트는 파일만 고친다. 브랜치 생성·커밋·push 호출자(워크플로) 책임이다
그래야 "무엇이 바뀌었는지" 워크플로가 커밋 메시지에 정확히 담을 있다.
사용
----
python3 scripts/build/apply-published-tags.py --published published.json
python3 scripts/build/apply-published-tags.py --published published.json --dry-run
python3 scripts/build/apply-published-tags.py --published published.json --image etcd
종료 코드
0 정상 (변경 사항 유무와 무관)
2 실행 오류
"""
import argparse
import importlib.util
import json
import os
import pathlib
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map")
VALUES_CANDIDATES = ("custom-values.yaml", "dip-values.yaml")
def load(path, name):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag")
def read_env(path):
out = {}
if not os.path.isfile(path):
return out
for line in pathlib.Path(path).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
out[k] = v.strip().strip('"').strip("'")
return out
def read_current(text, style, block):
return (PATCH.read_image_name(text) if style == "imageName"
else PATCH.read_split_block(text, block))
def patch_text(text, style, block, old, new):
return (PATCH.patch_image_name(text, old, new) if style == "imageName"
else PATCH.patch_split_block(text, block, old, new))
def apply_for_image(image, new_ref, dry_run):
"""반환: [{"file", "old", "new"}, ...] — 실제로 바뀐(또는 --dry-run 이면 바뀔) 파일들."""
chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env"))
chart_dirs = (chart_env.get("CHART_DIRS") or "").split()
style = chart_env.get("TAG_STYLE") or ""
block = chart_env.get("TAG_BLOCK") or "image"
if not chart_dirs or style not in ("imageName", "split"):
print(f"::warning::{image}: catalog/image-map/{image}.env 에 CHART_DIRS/TAG_STYLE 이 없다",
file=sys.stderr)
return []
changed = []
for d in chart_dirs:
for fname in VALUES_CANDIDATES:
p = pathlib.Path(ROOT, d, fname)
if not p.is_file():
continue
text = p.read_text()
current = read_current(text, style, block)
if current is None:
continue
if current == new_ref:
continue
new_text = patch_text(text, style, block, current, new_ref)
if new_text is None:
print(f"::warning::{image}: {p} 에서 '{current}' 패턴을 찾지 못해 건너뛴다",
file=sys.stderr)
continue
if not dry_run:
p.write_text(new_text)
changed.append({"file": os.path.relpath(p, ROOT), "old": current, "new": new_ref})
return changed
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--published", required=True, metavar="FILE",
help="security-images 의 published.json (로컬 경로 — 원격 조회는 호출자가 한다)")
ap.add_argument("--image", default="", help="이 이미지만 반영 (기본: published.json 의 전체)")
ap.add_argument("--dry-run", action="store_true", help="무엇이 바뀔지 보고만 하고 파일은 건드리지 않는다")
ap.add_argument("--json-out", metavar="FILE", help="변경 목록을 JSON 으로 이 파일에 쓴다")
ap.add_argument("--summary-md", metavar="FILE", help="변경 요약을 마크다운으로 이 파일에 쓴다")
args = ap.parse_args()
if not os.path.isfile(args.published):
print(f"::error::published.json 을 찾지 못했다: {args.published}", file=sys.stderr)
return 2
with open(args.published) as f:
doc = json.load(f)
images = doc.get("images") or {}
names = [args.image] if args.image else sorted(images)
all_changes = []
for name in names:
entry = images.get(name)
if entry is None:
print(f"::warning::{name}: published.json 에 없다", file=sys.stderr)
continue
if entry.get("gate") != "pass":
print(f"::warning::{name}: gate={entry.get('gate')!r} — 건너뛴다", file=sys.stderr)
continue
ref = entry.get("ref")
if not ref:
continue
changes = apply_for_image(name, ref, args.dry_run)
for c in changes:
all_changes.append({"image": name, **c})
print(f"{'(dry-run) ' if args.dry_run else ''}{name}: {c['file']} "
f"{c['old']} -> {c['new']}")
if not all_changes:
print("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다")
if args.json_out:
pathlib.Path(args.json_out).write_text(json.dumps(all_changes, ensure_ascii=False, indent=2) + "\n")
if args.summary_md:
lines = ["## 자체 빌드 이미지 태그 반영", ""]
if not all_changes:
lines.append("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다.")
else:
lines += ["| 이미지 | 파일 | 이전 태그 | 새 태그 |", "|---|---|---|---|"]
for c in all_changes:
lines.append(f"| `{c['image']}` | `{c['file']}` | `{c['old']}` | `{c['new']}` |")
pathlib.Path(args.summary_md).write_text("\n".join(lines) + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())
-185
View File
@@ -1,185 +0,0 @@
#!/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"
+51 -182
View File
@@ -4,25 +4,21 @@
필요한가
-----------
자체 빌드 이미지는 **소스를 바꿔도 시간이 지나면 게이트를 벗어난다.** CVE 공개되고,
베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 push 이미지 그대로다.
2026-08-19 etcd·cloudnative-pg 상태로 차단됐는데 **문서 전용 PR 우연히
`images/**` 건드려 검증 빌드가 돌면서야** 발견됐다. 의도적으로 보는 장치가 없었다.
베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 참조 태그 그대로다.
의도적으로 보는 장치가 없으면 문서 전용 PR 우연히 검증 빌드를 돌릴 때만 발견된다.
`build-image.yml` 주간으로 이것을 재빌드까지 .
`self-build-drift-check.yml` 주간으로 이것을 .
무엇이 "재빌드로 고쳐지는가" 인가 경우 모두 그렇다
-----------------------------------------------------
처음에는 "핀이 뒤처진 경우" 트리거로 잡았는데 **실측에서 그게 틀렸다.** 배포 중인 이미지
8개를 스캔했더니 차단이 있는 5 변경이 필요한 것은 하나도 없었다.
무엇이 "재빌드로 고쳐지는가" 인가
--------------------------------
"핀이 뒤처진 경우" 으로는 부족하다. 재빌드로 고쳐지는 경우는 셋이다.
1. 핀이 뒤처졌다 핀을 올리고 빌드 (GO_BUILDER_TAG )
1. 핀이 뒤처졌다 핀을 올리고 빌드
2. 핀은 맞는데 이미지가 핀으로 빌드됐다 그냥 빌드
(핀만 고친 PR 머지되고 재빌드가 아직 상태)
3. 베이스 OS 패키지가 뒤처졌다 그냥 빌드
(재빌드하면 zypper 최신을 깐다 핀과 무관하다)
3. 베이스 OS 패키지가 뒤처졌다 그냥 빌드 (재빌드하면 zypper 최신을 깐다)
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** . 변경은 필요하면 함께 적용하는
부수 작업이고, 트리거 조건이 아니다.
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** . 스크립트는 판정만
한다 핀을 무엇으로 올려야 하는지는 판단하지 않는다(아래 "레포 분리" 참고).
수정 버전이 없는 차단(`affected`·`will_not_fix`) 재빌드해도 그대로이므로 트리거가 아니다
`no-fix` 따로 보고해 사람이 다른 레버(태그 교체·예외 승인) 판단하게 한다.
@@ -33,45 +29,25 @@
`scripts/pipeline/cve-gate.py` 함수를 그대로 가져다 쓴다. 규칙이 곳으로 갈리면
"게이트는 PASS 인데 재빌드하라고 한다" 나온다.
산출은 `suggest-go-upgrades.py` 재사용한다(그것이 다시 게이트의 심각도 규칙을 쓴다).
기준은 카탈로그가 실제로 가리키는 이미지다
----------------------------------------
images/<image>/catalog.env 카탈로그 values 현재 ref
ref 스캔 리포트
<variant>.build.env
catalog/image-map/<image>.env 카탈로그 values 현재 ref
ref 스캔 리포트
재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 답한다.
레포 분리 파일은 둘로 갈린
--------------------------------
커스텀 이미지 별도 레포 분리될 예정이고(`.claude/image-authoring.md` 참고), 그때
스크립트는 **카탈로그를 읽는 쪽과 build.env 읽는 쪽이 서로 다른 레포에 놓인다.** 절단면은
이미 함수 경계로 나뉘어 있으니 그대로 쓴다:
A (카탈로그 레포) resolve_current_ref + blocking_cves
"이 이미지가 차단됐는가". 카탈로그 values 스캔 리포트만 쓴다.
카탈로그가 무엇을 배포하는지는 카탈로그가 안다.
B (이미지 레포) pin_changes + apply_changes
"핀을 올려야 하는가". build.env 쓴다.
어떻게 만드는지는 이미지 레포가 안다.
check_image 둘을 엮는 오케스트레이션 분리 여기가 갈라진다.
A 탐지해 B 빌드를 요청하고, B 핀을 판단한다.
주석의 목적은 분리 작업을 **고고학이 아니라 기계적 작업**으로 만드는 것이다. 함수를
추가할 어느 쪽에 속하는지 함께 적는다.
레포 분리 이후 스크립트는 절반이
----------------------------------------
커스텀 이미지 security-images(별도 레포) 나갔다. "무엇을 배포 중인가" 카탈로그가
알고, "그 이미지를 어떻게 만드는가"( 판단·재빌드) security-images 안다. 스크립트는
**탐지만** 한다 재빌드가 필요하다고 판단되면 `self-build-drift-check.yml`
security-images `build-image.yml` `workflow_dispatch` 부른다. 핀을 무엇으로
올릴지는 레포의 `suggest-go-upgrades.py --apply` 한다.
사용
----
python3 scripts/build/check-rebuild-needed.py --list-refs # 무엇을 스캔해야 하나
python3 scripts/build/check-rebuild-needed.py --reports <디렉토리>
python3 scripts/build/check-rebuild-needed.py --reports ... --image etcd --apply
`--apply` 편집만 대신한다. 커밋·빌드·검증은 사람이 한다 핀은 "우리가 무엇을
검증했는지의 기록" 이므로 자동 커밋하지 않는다.
종료 코드
0 정상 (재빌드 대상이 있어도 0 --fail-on-drift 주면 1)
@@ -88,7 +64,7 @@ import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
IMAGES_DIR = os.path.join(ROOT, "images")
IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map")
DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json")
# 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고
@@ -103,9 +79,8 @@ def load(path, name):
return mod
SUGGEST = load(os.path.join(HERE, "suggest-go-upgrades.py"), "suggest_go_upgrades")
PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag")
GATE = SUGGEST.load_gate()
GATE = load(os.path.join(HERE, "..", "pipeline", "cve-gate.py"), "cve_gate")
def read_env(path):
@@ -125,36 +100,18 @@ def read_env(path):
def report_path(reports_dir, image_ref):
"""이미지 ref → 리포트 파일 경로. scan-sbom.sh 가 `tr '/:@' '___'` 로 만든다."""
"""이미지 ref → 리포트 파일 경로. `trivy image <ref>` 스캔 결과를
`tr '/:@' '___'` 만든 파일명으로 저장했다고 가정한다."""
return os.path.join(reports_dir, re.sub(r"[/:@]", "_", image_ref) + ".json")
def numeric_prefix(v):
"""'1.26.5-trixie' → ((1,26,5), '-trixie'). 접미사는 그대로 유지해야 한다."""
m = re.match(r"^(\d+(?:\.\d+)*)(.*)$", v or "")
if not m:
return (0,), ""
return tuple(int(x) for x in m.group(1).split(".")), m.group(2)
def parse_module_specs(raw):
"""'mod@v1.2.3 mod2@v0.4.0'{mod: (1,2,3)}"""
out = {}
for tok in (raw or "").split():
if "@" not in tok:
continue
name, ver = tok.rsplit("@", 1)
out[name] = SUGGEST.version_tuple(ver)
return out
def resolve_current_ref(catalog_env):
def resolve_current_ref(chart_env):
"""카탈로그 values 에서 현재 이미지 ref 를 읽는다. (ref, 읽은 파일) 또는 (None, 이유)."""
chart_dirs = (catalog_env.get("CHART_DIRS") or "").split()
style = catalog_env.get("TAG_STYLE") or ""
block = catalog_env.get("TAG_BLOCK") or "image"
chart_dirs = (chart_env.get("CHART_DIRS") or "").split()
style = chart_env.get("TAG_STYLE") or ""
block = chart_env.get("TAG_BLOCK") or "image"
if not chart_dirs or style not in ("imageName", "split"):
return None, "catalog.env 에 CHART_DIRS/TAG_STYLE 이 없다"
return None, "catalog/image-map/<image>.env 에 CHART_DIRS/TAG_STYLE 이 없다"
for d in chart_dirs:
for fname in VALUES_CANDIDATES:
p = os.path.join(ROOT, d, fname)
@@ -179,7 +136,10 @@ def blocking_cves(report, image_ref, rank, floor, exceptions):
cve = v.get("VulnerabilityID")
if not cve:
continue
eff = SUGGEST.effective_severity(v, GATE, rank)
eff = GATE.effective_severity(
v.get("Severity"),
GATE.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")),
)
if rank.get(eff, 0) < floor:
continue
if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions):
@@ -193,68 +153,11 @@ def blocking_cves(report, image_ref, rank, floor, exceptions):
return out
def pin_changes(paths, pins, rank, floor):
"""핀을 올려야 하는 항목. 재빌드 트리거가 아니라 **부수 작업**이다."""
changes, notes = [], []
std_alts = SUGGEST.collect_stdlib(paths, GATE, rank, floor)
if std_alts:
rows = SUGGEST.builder_candidates(std_alts)
cur = pins.get("GO_BUILDER_TAG", "")
if not rows:
notes.append(f"stdlib 차단 {len(std_alts)}건 — 정식 릴리스 대안이 없다"
"(프리릴리스 툴체인이 필요할 수 있다)")
elif not cur:
notes.append(f"stdlib 차단 {len(std_alts)}건 — 이 이미지에 GO_BUILDER_TAG 가 없다")
else:
cur_nums, suffix = numeric_prefix(cur)
# 현재와 같은 마이너의 후보가 있으면 그것을 쓴다(마이너 점프를 강요하지 않는다).
same_minor = [r for r in rows if r[0] == cur_nums[:2]]
(a, b), c = (same_minor or rows)[-1]
if cur_nums[:3] < (a, b, c):
changes.append({"key": "GO_BUILDER_TAG", "current": cur,
"required": f"{a}.{b}.{c}{suffix}",
"reason": f"stdlib 차단 {len(std_alts)}", "appliable": True})
mods = SUGGEST.collect_modules(paths, GATE, rank, floor)
if mods:
cur_raw = pins.get("GO_MODULE_UPGRADES")
cur_mods = parse_module_specs(cur_raw)
behind = {n: e["fixed"] for n, e in mods.items()
if cur_mods.get(n) is None or cur_mods[n] < SUGGEST.version_tuple(e["fixed"])}
if behind:
listing = " ".join(f"{n}@v{behind[n]}" for n in sorted(behind))
if cur_raw is None:
# 이 이미지는 GO_MODULE_UPGRADES 를 소비하지 않는다(etcd 의 XTEXT_FIX_VERSION
# 처럼 이미지별 ARG). 값을 넣어도 무효이므로 사람이 판단하게 한다.
notes.append(f"모듈 {len(behind)}건 뒤처짐({listing}) — 이 이미지는 "
"GO_MODULE_UPGRADES 를 쓰지 않는다. 이미지별 FIX_VERSION 수동 확인")
else:
merged = dict(cur_mods)
merged.update({n: SUGGEST.version_tuple(v) for n, v in behind.items()})
spec = " ".join(f"{n}@v{'.'.join(map(str, merged[n]))}" for n in sorted(merged))
changes.append({"key": "GO_MODULE_UPGRADES", "current": cur_raw,
"required": spec,
"reason": f"모듈 {len(behind)}건 뒤처짐", "appliable": True})
return changes, notes
def check_image(image, reports_dir, rank, floor, exceptions):
idir = os.path.join(IMAGES_DIR, image)
res = {"image": image, "status": "ok", "notes": [], "changes": [],
"blocking": 0, "fixable": 0}
res = {"image": image, "status": "ok", "notes": [], "blocking": 0, "fixable": 0}
catalog_env = read_env(os.path.join(idir, "catalog.env"))
variant = catalog_env.get("DEFAULT_BASE_OS") or ""
if not variant:
res["status"] = "skip"
res["notes"].append("catalog.env 에 DEFAULT_BASE_OS 가 없다")
return res
build_env_path = os.path.join(idir, f"{variant}.build.env")
res["build_env"] = os.path.relpath(build_env_path, ROOT)
pins = read_env(build_env_path)
ref, where = resolve_current_ref(catalog_env)
chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env"))
ref, where = resolve_current_ref(chart_env)
if not ref:
res["status"] = "skip"
res["notes"].append(where)
@@ -279,8 +182,6 @@ def check_image(image, reports_dir, rank, floor, exceptions):
if not cves:
return res # ok
res["changes"], res["notes"] = pin_changes([rp], pins, rank, floor)
if fixable:
res["status"] = "rebuild"
else:
@@ -290,26 +191,6 @@ def check_image(image, reports_dir, rank, floor, exceptions):
return res
def apply_changes(build_env_path, changes):
"""build.env 의 KEY=value 줄만 제자리 치환한다(주석·순서 보존)."""
p = pathlib.Path(build_env_path)
text = p.read_text()
applied = []
for ch in changes:
if not ch.get("appliable"):
continue
key = ch["key"]
pattern = re.compile(rf'^({re.escape(key)}=)(")?.*?(")?$', re.MULTILINE)
if not pattern.search(text):
continue
quote = '"' if " " in ch["required"] else ""
text = pattern.sub(lambda m: f'{m.group(1)}{quote}{ch["required"]}{quote}', text, count=1)
applied.append(key)
if applied:
p.write_text(text)
return applied
def render_md(results):
rebuild = [r for r in results if r["status"] == "rebuild"]
nofix = [r for r in results if r["status"] == "no-fix"]
@@ -319,16 +200,14 @@ def render_md(results):
if not rebuild and not nofix:
out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.")
if rebuild:
out += [f"**{len(rebuild)}개 이미지가 재빌드로 고쳐진다.** 차단 CVE 에 수정 버전이 있다 "
"재빌드하면 베이스 패키지·툴체인이 최신으로 반영된다.", "",
"| 이미지 | 차단 | 수정 가능 | 패키지 | 함께 올릴 핀 |",
"|---|---:|---:|---|---|"]
out += [f"**{len(rebuild)}개 이미지가 재빌드 대상이다.** 차단 CVE 에 수정 버전이 있다 "
"security-images 레포에서 재빌드하면 해소될 가능성이 높다. 핀 조정이 "
"필요한지는 그 레포의 `suggest-go-upgrades.py` 가 판단한다.", "",
"| 이미지 | 현재 ref | 차단 | 수정 가능 | 패키지 |",
"|---|---|---:|---:|---|"]
for r in rebuild:
pins = "<br>".join(
f"`{c['key']}` `{c['current']}` → **`{c['required']}`**" for c in r["changes"]
) or "없음 (핀은 이미 맞다 — 빌드만 안 됐다)"
pkgs = ", ".join(f"`{p}`" for p in r["packages"][:4])
out.append(f"| `{r['image']}` | {r['blocking']} | {r['fixable']} | {pkgs} | {pins} |")
out.append(f"| `{r['image']}` | `{r['ref']}` | {r['blocking']} | {r['fixable']} | {pkgs} |")
if nofix:
out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.",
"", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"]
@@ -349,14 +228,15 @@ def render_md(results):
out += ["", "</details>"]
out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · "
"기본 HIGH 이상. 핀 제안은 **제안**이고 채택은 사람이 커밋한다.", ""]
"기본 HIGH 이상. 재빌드는 security-images 레포의 `build-image.yml` 을 "
"`workflow_dispatch` 로 부른다.", ""]
return "\n".join(out)
def main():
ap = argparse.ArgumentParser(description="배포 중인 자체 빌드 이미지의 재빌드 필요 여부 판정")
ap.add_argument("--reports", help="trivy 리포트(JSON) 디렉토리 (--list-refs 면 불필요)")
ap.add_argument("--image", default="", help="이 이미지만 판정 (images/<image>)")
ap.add_argument("--image", default="", help="이 이미지만 판정 (catalog/image-map/<image>.env)")
ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS,
help="승인 예외 목록 (기본 doc/cve-exceptions.json)")
ap.add_argument("--min-severity", default="HIGH",
@@ -368,14 +248,12 @@ def main():
"새지 않게 한다")
ap.add_argument("--summary-md", metavar="FILE", help="마크다운 요약을 이 파일에 쓴다")
ap.add_argument("--json-out", metavar="FILE", help="결과 JSON 을 이 파일에 쓴다")
ap.add_argument("--apply", action="store_true",
help="핀 제안을 build.env 에 제자리 반영한다 (커밋·빌드는 사람이 한다)")
ap.add_argument("--fail-on-drift", action="store_true",
help="재빌드 대상이 있으면 1 로 종료 (기본은 보고만 하고 0)")
args = ap.parse_args()
if not os.path.isdir(IMAGES_DIR):
print(f"::error::images/ 를 찾지 못했다: {IMAGES_DIR}", file=sys.stderr)
if not os.path.isdir(IMAGE_MAP_DIR):
print(f"::error::catalog/image-map/ 를 찾지 못했다: {IMAGE_MAP_DIR}", file=sys.stderr)
return 2
if not args.list_refs and not args.reports:
print("::error::--reports 가 필요하다", file=sys.stderr)
@@ -384,17 +262,16 @@ def main():
print(f"::error::리포트 디렉토리가 없다: {args.reports}", file=sys.stderr)
return 2
names = sorted(d for d in os.listdir(IMAGES_DIR)
if os.path.isdir(os.path.join(IMAGES_DIR, d)))
names = sorted(f[:-4] for f in os.listdir(IMAGE_MAP_DIR) if f.endswith(".env"))
if args.image:
names = [n for n in names if n == args.image]
if not names:
print(f"::error::images/{args.image} 가 없다", file=sys.stderr)
print(f"::error::catalog/image-map/{args.image}.env 가 없다", file=sys.stderr)
return 2
if args.list_refs:
for n in names:
ref, why = resolve_current_ref(read_env(os.path.join(IMAGES_DIR, n, "catalog.env")))
ref, why = resolve_current_ref(read_env(os.path.join(IMAGE_MAP_DIR, f"{n}.env")))
if not ref:
print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr)
continue
@@ -414,9 +291,8 @@ def main():
for r in results:
if r["status"] == "rebuild":
pins = "; ".join(f"{c['key']} {c['current']} {c['required']}" for c in r["changes"])
print(f"::warning::{r['image']}: 재빌드 필요 — 차단 {r['blocking']}"
f"(수정 가능 {r['fixable']})" + (f" / {pins}" if pins else ""))
print(f"::warning::{r['image']}: 재빌드 대상 — 차단 {r['blocking']}"
f"(수정 가능 {r['fixable']})")
elif r["status"] == "no-fix":
print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — "
"재빌드로 해소되지 않는다")
@@ -434,13 +310,6 @@ def main():
pathlib.Path(args.json_out).write_text(
json.dumps(results, ensure_ascii=False, indent=2) + "\n")
if args.apply:
for r in results:
if r["changes"]:
applied = apply_changes(os.path.join(ROOT, r["build_env"]), r["changes"])
if applied:
print(f"반영: {r['build_env']}{', '.join(applied)}")
return 1 if ([r for r in results if r["status"] == "rebuild"] and args.fail_on_drift) else 0
-230
View File
@@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""
suggest-go-upgrades.py 스캔 리포트에서 GO_MODULE_UPGRADES 값을 산출한다.
필요한가
-----------
자체 빌드 이미지가 Go 모듈 CVE 해소할 `images/<image>/<variant>.build.env`
`GO_MODULE_UPGRADES` `<module>@<version>` 적는다. 버전을 사람이 게이트 리포트에서
하나씩 찾아 최대값을 고르는 것은 지루하고 틀리기 쉽다 필요한 정보는 이미 trivy 리포트의
`FixedVersion` 있다. 스크립트가 그것을 뽑아 붙여넣을 있는 형태로 낸다.
"완전 자동"(빌드 시점에 최신으로 당기기) 아닌가
--------------------------------------------------
`go get -u` 매번 최신을 끌면 같은 소스로 빌드해도 이미지가 달라진다. 레포는 반대를
택했다(.claude/image-authoring.md "롤링 태그를 쓰지 않는다", SOURCE_COMMIT·BUILD_DATE 고정).
버전 핀은 **우리가 무엇을 검증했는지의 기록**이다. 그래서 스크립트는 값을 제안만 하고,
채택은 사람이 build.env 커밋한다 git diff "무엇이 왜 올라갔는지" 남는다.
심각도 기준은 게이트와 같다
--------------------------
`max(벤더 등급, NVD 등급)` 실효 등급으로 쓴다. 판정 규칙이 곳으로 갈리지 않도록
cve-gate.py 함수를 그대로 가져다 쓴다.
사용
----
python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports 디렉토리>
python3 scripts/build/suggest-go-upgrades.py --reports sbom-out/trivy-reports \\
--image argocd --min-severity HIGH
종료 코드
0 제안 출력(대상이 없으면 사실을 출력)
2 실행 오류
"""
import argparse
import glob
import importlib.util
import json
import os
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
GATE = os.path.join(HERE, "..", "pipeline", "cve-gate.py")
def load_gate():
"""cve-gate.py 를 모듈로 읽어 심각도 규칙을 재사용한다 — 규칙의 단일 출처를 지킨다."""
spec = importlib.util.spec_from_file_location("cve_gate", GATE)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def version_tuple(v):
"""'0.56.0' → (0,56,0). 비교용. 숫자가 아닌 꼬리는 버린다."""
nums = re.findall(r"\d+", v or "")
return tuple(int(n) for n in nums) or (0,)
def pick_fixed(raw):
"""FixedVersion 문자열에서 후보를 뽑아 최대값을 고른다.
Go 모듈은 보통 단일 값이지만 쉼표로 여러 브랜치를 나열하는 경우가 있다
(stdlib '1.24.13, 1.25.7' 형태). 모듈에 대해서는 가장 높은 값을 쓴다.
"""
cands = [c.strip() for c in re.split(r"[,/]", raw or "") if c.strip()]
cands = [c for c in cands if re.match(r"^v?\d", c)]
if not cands:
return None
return max(cands, key=version_tuple).lstrip("v")
def effective_severity(v, gate, rank):
"""게이트와 같은 실효 등급 — `max(벤더 등급, NVD 등급)`."""
vend = v.get("Severity") or "UNKNOWN"
nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score"))
return vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd
def collect_modules(paths, gate, rank, floor):
"""정적 링크된 Go 모듈 중 floor 이상 등급의 차단 CVE 를 가진 것들.
반환: `{모듈명: {"fixed": 목표버전, "cves": {cve: 실효등급}, "installed": {설치버전}}}`
"""
mods = {}
for p in paths:
with open(p) as f:
doc = json.load(f)
for res in doc.get("Results") or []:
# Go 바이너리에 정적 링크된 모듈만 대상이다. OS 패키지는 베이스 교체 영역이라
# 여기서 다루지 않는다.
if res.get("Class") != "lang-pkgs" or res.get("Type") != "gobinary":
continue
for v in res.get("Vulnerabilities") or []:
pkg = v.get("PkgName") or ""
if pkg == "stdlib":
# stdlib 은 모듈 업그레이드가 아니라 GO_BUILDER_TAG 로 해소한다.
continue
eff = effective_severity(v, gate, rank)
if rank.get(eff, 0) < floor:
continue
fx = pick_fixed(v.get("FixedVersion"))
if not fx:
continue
e = mods.setdefault(pkg, {"fixed": fx, "cves": {}, "installed": set()})
if version_tuple(fx) > version_tuple(e["fixed"]):
e["fixed"] = fx
e["cves"][v["VulnerabilityID"]] = eff
if v.get("InstalledVersion"):
e["installed"].add(v["InstalledVersion"])
return mods
def collect_stdlib(paths, gate, rank, floor):
"""stdlib 차단 CVE 별 `FixedVersion` 대안 목록.
반환: `{cve: [(major, minor, patch, is_prerelease), ...]}`
"""
std_alts = {}
for p in paths:
with open(p) as f:
doc = json.load(f)
for res in doc.get("Results") or []:
if res.get("Type") != "gobinary":
continue
for v in res.get("Vulnerabilities") or []:
if v.get("PkgName") != "stdlib":
continue
if rank.get(effective_severity(v, gate, rank), 0) < floor:
continue
alts = []
for tok in re.split(r"[,\s]+", v.get("FixedVersion") or ""):
m = re.match(r"^(\d+)\.(\d+)\.(\d+)(\S*)$", tok.strip())
if m:
a, b, c, tail = m.groups()
alts.append((int(a), int(b), int(c), bool(tail)))
if alts:
std_alts[v["VulnerabilityID"]] = alts
return std_alts
def builder_candidates(std_alts):
"""stdlib 대안 목록에서 "이 툴체인이면 전부 해소된다" 는 후보를 만든다.
`FixedVersion` 여러 값은 "더 높은 버전" 아니라 **브랜치별 대안**이다
('1.25.13, 1.26.6, 1.27.0-rc.3' = 브랜치 각각에서 고쳐진 지점). Go 보안 수정을
지원 브랜치에 백포트하므로, 어떤 툴체인 T 다음이면 CVE 해소한다:
T 같은 마이너의 대안이 있고 T 패치가 이상이거나,
대안의 마이너가 T 보다 낮다(이미 이전 브랜치에서 고쳐져 T 포함됨).
그래서 단순히 전체 최대값을 고르면 된다 프리릴리스(1.27.0-rc.3) 정식 1.27.0 으로
오독하게 된다(실측 버그).
반환: `[((major, minor), 필요_패치), ...]` 마이너 오름차순
"""
# 정식 릴리스 대안만으로 후보 마이너를 만든다(프리릴리스는 툴체인 태그로 못 쓴다).
cand = sorted({(a, b) for alts in std_alts.values() for a, b, _, pre in alts if not pre})
rows = []
for mm in cand:
need = 0
covered = True
for cve, alts in std_alts.items():
same = [c for a, b, c, pre in alts if (a, b) == mm and not pre]
if same:
need = max(need, max(same))
elif not any((a, b) < mm for a, b, _, _ in alts):
covered = False # 이 마이너보다 높은 브랜치에서만 고쳐졌다
break
if covered:
rows.append((mm, need))
return rows
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--reports", required=True, help="trivy 리포트(JSON) 디렉토리")
ap.add_argument("--image", default="", help="리포트 파일명에 이 문자열이 든 것만 대상")
ap.add_argument("--min-severity", default="HIGH", choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
help="이 등급 이상만 제안 (기본 HIGH — 게이트 차단 기준과 동일)")
args = ap.parse_args()
gate = load_gate()
rank = gate.RANK
floor = rank[args.min_severity]
paths = sorted(glob.glob(os.path.join(args.reports, "*.json")))
if args.image:
paths = [p for p in paths if args.image in os.path.basename(p)]
if not paths:
print(f"::error::리포트를 찾지 못했다: {args.reports} (--image={args.image or '전체'})", file=sys.stderr)
return 2
mods = collect_modules(paths, gate, rank, floor)
std_alts = collect_stdlib(paths, gate, rank, floor)
if std_alts:
rows = builder_candidates(std_alts)
print("# stdlib 차단 CVE 가 있다 — GO_MODULE_UPGRADES 가 아니라 툴체인으로 해소한다.")
print(f"# 대상 CVE {len(std_alts)}건. 아래 중 어느 것을 써도 전부 해소된다:")
for (a, b), c in rows:
print(f"# go{a}.{b}.{c}")
if rows:
(a, b), c = rows[-1]
print(f"# GO_BUILDER_TAG={a}.{b}.{c}-trixie # 가장 높은 정식 브랜치 기준")
else:
print("# ::warning:: 정식 릴리스 대안이 없다 — 프리릴리스 툴체인이 필요할 수 있다")
print()
if not mods:
print("# Go 모듈 업그레이드 제안 없음 (해당 등급 이상 findings 이 없다)")
return 0
print(f"# {args.min_severity} 이상 Go 모듈 CVE 로부터 산출 — build.env 의 GO_MODULE_UPGRADES 에 반영한다.")
for name in sorted(mods):
e = mods[name]
inst = ", ".join(sorted(e["installed"])) or "?"
cves = ", ".join(sorted(e["cves"]))
print(f"# {name} {inst}{e['fixed']}")
print(f"# {cves}")
specs = " ".join(f"{n}@v{mods[n]['fixed']}" for n in sorted(mods))
print(f'GO_MODULE_UPGRADES="{specs}"')
print()
print("# 주의 — 이 값은 CVE 요건의 최소치다. 모듈 간 제약으로 더 올려야 할 수 있다")
print("# (실측: go-git 5.19.2 와 x/net 0.56.0 이 x/crypto 0.53.0 을 요구해 0.52.0 으로는 빌드 실패).")
print("# 빌드가 'requires ...@vX, not ...@vY' 로 실패하면 그 버전으로 올린다.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -24,7 +24,7 @@
# 무관한 변수가 늘어난다. 유일한 override 는 검증 대상인 이미지 자체뿐이다.
#
# 사용:
# IMAGE_NAME=docker.io/wbsong111/cnpg-postgresql:<tag> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh <OUT_DIR>
# IMAGE_NAME=docker.io/paasup/cnpg-postgresql:<tag> bash scripts/deploy-test/deploy-test-cnpg-cluster.sh <OUT_DIR>
#
# 환경변수:
# IMAGE_NAME (필수) 검증할 postgresql 이미지 전체 경로:태그
+11 -2
View File
@@ -82,6 +82,16 @@ def nvd_severity(score):
return "LOW"
def effective_severity(vendor_sev, nvd_sev):
"""실효 등급 = max(벤더 등급, NVD 등급). 벤더의 하향 등급으로 게이트를
통과하는 것을 막는다. 게이트가 심각도 규칙의 단일 출처다
`check-rebuild-needed.py`(드리프트 탐지) 자체 빌드 레포의 `image-gate.py`
함수를 그대로 재사용한다."""
vend = vendor_sev or "UNKNOWN"
nvd = nvd_sev or "UNKNOWN"
return vend if RANK.get(vend, 0) >= RANK.get(nvd, 0) else nvd
def load_exceptions(path):
"""
승인된 예외 목록.
@@ -332,8 +342,7 @@ def evaluate(img, exceptions):
for cve in by_cve.values():
vend = cve["vendor_sev"]
nvd = cve["nvd_sev"]
# 실효 심각도 = 벤더와 NVD 중 높은 쪽. 벤더의 하향 등급으로 게이트를 통과하는 것을 막는다.
effective = vend if RANK.get(vend, 0) >= RANK.get(nvd or "UNKNOWN", 0) else nvd
effective = effective_severity(vend, nvd)
cve["effective_sev"] = effective
if nvd and RANK.get(nvd, 0) > RANK.get(vend, 0) and RANK.get(nvd, 0) >= RANK["HIGH"]: