자체 빌드 이미지 프레임워크를 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:
@@ -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
|
||||
|
||||
@@ -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` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.
|
||||
@@ -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 배포 테스트 대상 클러스터
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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차 출처로 본다.
|
||||
|
||||
## 참고
|
||||
|
||||
@@ -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로만 반영한다.
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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) — 위 셋에 속하면 여기 남기지 않고 링크만 |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`이 낡은 태그로 남은 사고가 있었다).
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
CHART_DIRS="manifests/helm/apisix/2.16.0"
|
||||
TAG_STYLE=split
|
||||
TAG_BLOCK=image
|
||||
@@ -0,0 +1,3 @@
|
||||
CHART_DIRS="manifests/helm/argo-cd/10.4.0"
|
||||
TAG_STYLE=split
|
||||
TAG_BLOCK=global.image
|
||||
@@ -0,0 +1,3 @@
|
||||
CHART_DIRS="manifests/helm/cloudnative-pg/0.29.0"
|
||||
TAG_STYLE=split
|
||||
TAG_BLOCK=image
|
||||
@@ -0,0 +1,2 @@
|
||||
CHART_DIRS="manifests/helm/cnpg-cluster/1.0.0 manifests/helm/cnpg-cluster/1.1.0"
|
||||
TAG_STYLE=imageName
|
||||
@@ -0,0 +1,3 @@
|
||||
CHART_DIRS="manifests/helm/etcd/1.1.12"
|
||||
TAG_STYLE=split
|
||||
TAG_BLOCK=image
|
||||
@@ -0,0 +1,3 @@
|
||||
CHART_DIRS="manifests/helm/keycloakx/7.2.2"
|
||||
TAG_STYLE=split
|
||||
TAG_BLOCK=image
|
||||
@@ -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 변종을 재검토한다.
|
||||
@@ -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
@@ -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
@@ -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` 의 외부 엔드포인트 인증 |
|
||||
|
||||
|
||||
@@ -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 자체 빌드는 이미 이 절차로 배포 검증
|
||||
완료됨 — 같은 방식으로 진행).
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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` 절차로 카탈로그 반영 전 반드시 수행할 것.
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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` 참고.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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` 참고).
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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` 참고.
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 ┘└ 슬러그 ┘└ 하드닝 ┘└ 빌드일 ┘
|
||||
```
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" ]
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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/(이 레포에는 없다).
|
||||
#
|
||||
# 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로
|
||||
# 롤링 태그를 쓰지 않는다.
|
||||
|
||||
@@ -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` 를
|
||||
자동 갱신한 브랜치를 이 레포에 만든다.
|
||||
|
||||
@@ -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())
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 이미지 전체 경로:태그
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
Reference in New Issue
Block a user