diff --git a/MEMORY.md b/MEMORY.md index a16ee2c..f82ba0b 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -5,12 +5,88 @@ - 프로젝트 개요·설계 원칙 → [CLAUDE.md](CLAUDE.md) - SBOM·CVE 게이트 메커니즘 → [doc/sbom-pipeline.md](doc/sbom-pipeline.md) -최종 갱신: 2026-08-07 +최종 갱신: 2026-08-12 --- ## 다음 작업 +**apisix@2.16.0 전체가 게이트 PASS 로 완결됐다(2026-08-12).** `images/adc/` 신규 추가로 +마지막 남은 이미지까지 해소 — 차단 55건(2026-08-12 초 기준) → **0건**. adc· +apisix-ingress-controller·apisix(paasup/apisix) 세 자체 빌드 이미지 전부 게이트 +실효 CRITICAL/HIGH 0/0. apisix-ingress-controller·apisix 두 이미지는 사용자가 별도 +테스트 클러스터에서 **배포 검증 완료**(2026-08-12) — adc 는 아직 미완료 +(`.claude/deploy-test-procedure.md` 로 apisix-ingress-controller 와 함께 배포해 +dump/diff/sync 백엔드 연동까지 확인 필요). + +**adc — "태그 교체로 끝난 줄 알았는데 게이트 재검증하니 아니었다".** 0.27.1→0.29.0 +태그 교체 후 `custom-values.yaml`에 "게이트 PASS 확인"이라고 적어뒀는데, 이건 벤더 +등급만 본 결과였다 — 실제 `cve-gate.py`(max(벤더,NVD))로 재검증하니 glibc regex/ +collating 스택오버플로 4건(CVE-2019-1010022/23, CVE-2018-20796, CVE-2019-9192, +전부 libc6, Debian "affected, 수정 없음" 영구 고정)이 실효 CRITICAL/HIGH 로 여전히 +차단(FAIL). `images/adc/` 자체 빌드로 해소 — 업스트림 Dockerfile +(`libs/tools/src/docker/Dockerfile`)이 `node:lts-bookworm-slim` 빌더 + +`gcr.io/distroless/nodejs24-debian13` 최종인데, **빌더 스테이지는 그대로 두고 최종 +베이스 한 줄만 SUSE BCI + `nodejs24`(zypper)로 교체** — apisix/ +apisix-ingress-controller 자체 빌드보다 훨씬 단순한 유형(cnpg-postgresql 과 같은 +"OS 패키지 재설치형", 빌드 시간 2분 이내). SUSE 글리브C 에 이 4건이 없음을 사전에 +직접 SBOM·스캔으로 검증한 뒤 진행했다. 게이트 PASS(실효 C/H 0/0). +`docker.io/paasup/adc:0.29.0-security-hardened-20260812` 로 push·카탈로그 반영 완료. +**이 사례는 일반화할 교훈이 있다 — "벤더 릴리스 노트가 CVE 를 줄였다고 명시해도, 게이트 +로 직접 재검증하기 전엔 PASS 로 단정하지 않는다"**(`sbom-cve-gate`/`cve-remediation` +skill 이 이미 강조하는 원칙이지만 이번에 실제로 걸렸다). + +**apisix 자체 이미지도 SUSE BCI 자체 빌드로 교체 완료(2026-08-12).** `images/apisix/` +신규 — 기존엔 별도 프로젝트(`paasup/dataup` 레포 `experiment/apisix/apisix-plugin`)에서 +`apache/apisix:3.17.0-debian`(이미 최신 태그) 위에 keycloak-authz 플러그인만 얹어 +빌드했는데, 그 Debian 베이스 자체가 차단 26건이었다. 문제는 이게 vanilla OpenResty가 +아니라 WASM(wasmtime)·mod_dubbo 등 15개 커스텀 모듈을 정적으로 얹은 "APISIX-Runtime" +이라, apiseven 의 빌드 파이프라인(`api7/apisix-build-tools`, 태그 +`apisix-runtime/1.3.6`)이 Debian/RHEL 용으로만 배포하고 SUSE 용은 없다 — vanilla +OpenResty(openresty.org 가 SLES 공식 지원)로 축소하는 것도 검토했으나, WASM·dubbo +관련 기능이 필요하다는 판단으로 커스텀 모듈 전체를 소스에서 그대로 재현하는 쪽으로 +갔다. 3단계(runtime: OpenSSL/zlib/PCRE+OpenResty+6개 모듈 컴파일 → apisix-app: +luarocks 로 APISIX 앱 설치 → final: SUSE BCI 로 산출물 이관+keycloak-authz 오버레이) +빌드, 실측 함정 다수 해결(zlib→OpenSSL 순서, lua-resty-saml 의 libxml2 2.12 시그니처 +불일치를 gcc 래퍼로 우회, `ui/`(admin 대시보드) 부재 허용, SUSE 공유 라이브러리 +soname 등 — 상세는 `images/apisix/README.md`). 실제 nginx 기동 + HTTP 요청 응답까지 +확인하는 `verify.sh` 작성(초판에 curl 연결 실패를 "000" 문자열로 오판하는 버그가 +있었음 — 수정 완료). 게이트 PASS(실효 C/H 0/0, 업스트림 26건에서 완전 해소). +`docker.io/paasup/apisix:3.17.0-security-hardened-20260811` 로 push·카탈로그 반영 +완료. **배포 검증 완료(2026-08-12, 사용자가 별도 테스트 클러스터에서 확인).** + +**apisix 카탈로그 CVE 조치 완료(2026-08-11).** `cve-remediation` skill(신설, +`.claude/skills/cve-remediation/SKILL.md`)의 결정 절차를 처음 실제 적용한 사례. 조치 전 +차단 실효 CRITICAL/HIGH 165건(2.16.0 기준, 구버전 2.14.0 포함 시 331건) → 조치 후 55건. + +- **`docker.io/bitnamilegacy/etcd:latest`(차단 65건) 완전 제거** — bitnami 가 무료 + 레포를 동결한 레거시 미러라 `latest` 태그만 제공(bitnami/containers#83267), 태그 + 교체로 해소 불가. `etcd.enabled: false` 로 기본값을 바꾸고 카탈로그 자체 `etcd` 차트 + (`manifests/helm/etcd/1.1.12`)를 `externalEtcd` 기본 대상으로 연결했다 + (`manifests/helm/apisix/2.16.0/custom-values.yaml`). +- **`ghcr.io/api7/adc` 를 최신 태그(0.27.1)로 교체** — 완전 해소는 아니고 34→29건으로 + 부분 완화(같은 debian 12.14 베이스가 계속 쓰여 OS 패키지 CVE 잔존). 상위 태그가 + 이미 최신이라 추가 레버는 자체 빌드뿐 — 아직 안 함. +- **`images/apisix-ingress-controller/` 신규 자체 빌드** — 이미 최신 태그(2.1.0)인데도 + 차단 25건(21건이 정적 링크 Go 모듈: stdlib/x-net/x-text/grpc/otel — 태그 교체 불가, + 나머지 4건은 distroless 베이스 OS 패키지). `v2.1.0` 태그 코드는 그대로 두고 취약 + 모듈만 `go get`+`go mod tidy` 로 강제 업그레이드, 베이스는 SUSE BCI(`bci-micro`)로 + 교체 → 게이트 PASS(실효 C/H 0/0). `docker.io/paasup/apisix-ingress-controller: + 2.1.0-security-hardened-20260811` 로 push·카탈로그 반영 완료. **배포 검증은 아직 + 안 함**(이 세션에 클러스터 없음) — `.claude/deploy-test-procedure.md` 로 필수 수행. +- **`scripts/build/patch-catalog-tag.py` 확장** — `TAG_BLOCK` 이 점 구분 중첩 경로(예: + `ingress-controller.deployment.image`)를 지원하도록 바꿨다(기존엔 top-level 키만 + 가능). apisix 서브차트 alias 때문에 필요해짐. 기존 이미지(단일 세그먼트 `image`) + 회귀 테스트로 동작 동일함 확인. +- **`manifests/helm/apisix/2.14.0` 삭제** — 사용자 지시. 자체 빌드가 앱 버전(2.0.1)까지 + 같이 올리며 두 카탈로그 버전이 서로 다른 컨트롤러 버전을 요구하는 복잡도가 생겨서, + 구버전을 유지하는 대신 최신(2.16.0) 하나로 정리했다. `doc/catalog-stack-classification.md` + 갱신 완료. +- **남은 것**: `paasup/apisix:3.17.0-keycloak-authz`(Keycloak authz 플러그인 포함 커스텀 + 빌드, 차단 26건, debian 12.14 OS 패키지) — 빌드 소스가 dip-catalog 밖의 별도 프로젝트 + (`dataup experiment/apisix/apisix-plugin`)에 있어 **이 레포에서 재빌드 불가**. 그 + 프로젝트에서 베이스를 갱신해야 한다. + **CVE 게이트(`scripts/pipeline/cve-gate.py`)를 도입했다 — 아직 warn-only다.** `sbom.yml` 에 게이트 판정 스텝을 추가했지만 `--warn-only` 로 실행돼 실패해도 워크플로/PR 을 막지 않는다. **45+ 개 카탈로그 차트가 이 게이트로 한 번도 트리아지된 적이 없다** — 강제 diff --git a/images/adc/README.md b/images/adc/README.md new file mode 100644 index 0000000..f661322 --- /dev/null +++ b/images/adc/README.md @@ -0,0 +1,129 @@ +# 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 자체 빌드는 이미 이 절차로 배포 검증 +완료됨 — 같은 방식으로 진행). diff --git a/images/adc/catalog.env b/images/adc/catalog.env new file mode 100644 index 0000000..4596d7d --- /dev/null +++ b/images/adc/catalog.env @@ -0,0 +1,14 @@ +# 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//.build.env) +DEFAULT_BASE_OS=source diff --git a/images/adc/source.Dockerfile b/images/adc/source.Dockerfile new file mode 100644 index 0000000..49e7960 --- /dev/null +++ b/images/adc/source.Dockerfile @@ -0,0 +1,65 @@ +# 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"] diff --git a/images/adc/source.build.env b/images/adc/source.build.env new file mode 100644 index 0000000..0665565 --- /dev/null +++ b/images/adc/source.build.env @@ -0,0 +1,23 @@ +# 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 +# +# 왜 자체 빌드하는가 → 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" diff --git a/images/adc/verify.sh b/images/adc/verify.sh new file mode 100755 index 0000000..8aad569 --- /dev/null +++ b/images/adc/verify.sh @@ -0,0 +1,42 @@ +#!/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 diff --git a/images/apisix-ingress-controller/README.md b/images/apisix-ingress-controller/README.md new file mode 100644 index 0000000..a6e2b15 --- /dev/null +++ b/images/apisix-ingress-controller/README.md @@ -0,0 +1,116 @@ +# 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`)의 동작은 회귀 테스트로 그대로임을 확인했다. diff --git a/images/apisix-ingress-controller/catalog.env b/images/apisix-ingress-controller/catalog.env new file mode 100644 index 0000000..0eb5e6c --- /dev/null +++ b/images/apisix-ingress-controller/catalog.env @@ -0,0 +1,16 @@ +# build-image.yml 이 읽는 카탈로그 반영 메타데이터. 빌드 정의(.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//.build.env) +DEFAULT_BASE_OS=source diff --git a/images/apisix-ingress-controller/source.Dockerfile b/images/apisix-ingress-controller/source.Dockerfile new file mode 100644 index 0000000..6dc594a --- /dev/null +++ b/images/apisix-ingress-controller/source.Dockerfile @@ -0,0 +1,98 @@ +# 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"] diff --git a/images/apisix-ingress-controller/source.build.env b/images/apisix-ingress-controller/source.build.env new file mode 100644 index 0000000..768cd47 --- /dev/null +++ b/images/apisix-ingress-controller/source.build.env @@ -0,0 +1,43 @@ +# 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 +# +# 왜 자체 빌드하는가 → 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_BUILDER_TAG=1.26.5-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" diff --git a/images/apisix-ingress-controller/verify.sh b/images/apisix-ingress-controller/verify.sh new file mode 100644 index 0000000..a506873 --- /dev/null +++ b/images/apisix-ingress-controller/verify.sh @@ -0,0 +1,46 @@ +#!/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 </dev/null +echo " --help 종료 코드 0" + +echo "VERIFY-OK" +GUEST diff --git a/images/apisix/README.md b/images/apisix/README.md new file mode 100644 index 0000000..59c2588 --- /dev/null +++ b/images/apisix/README.md @@ -0,0 +1,153 @@ +# 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` 절차로 카탈로그 반영 전 반드시 수행할 것. diff --git a/images/apisix/catalog.env b/images/apisix/catalog.env new file mode 100644 index 0000000..0f8aabc --- /dev/null +++ b/images/apisix/catalog.env @@ -0,0 +1,13 @@ +# 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//.build.env) +DEFAULT_BASE_OS=source diff --git a/images/apisix/docker-entrypoint.sh b/images/apisix/docker-entrypoint.sh new file mode 100644 index 0000000..4b534b9 --- /dev/null +++ b/images/apisix/docker-entrypoint.sh @@ -0,0 +1,64 @@ +#!/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 "$@" diff --git a/images/apisix/keycloak-authz.lua b/images/apisix/keycloak-authz.lua new file mode 100644 index 0000000..7d1de75 --- /dev/null +++ b/images/apisix/keycloak-authz.lua @@ -0,0 +1,168 @@ +-- 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 diff --git a/images/apisix/source.Dockerfile b/images/apisix/source.Dockerfile new file mode 100644 index 0000000..79bcec2 --- /dev/null +++ b/images/apisix/source.Dockerfile @@ -0,0 +1,268 @@ +# 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"] diff --git a/images/apisix/source.build.env b/images/apisix/source.build.env new file mode 100644 index 0000000..9ecddde --- /dev/null +++ b/images/apisix/source.build.env @@ -0,0 +1,34 @@ +# 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 +# +# 왜 자체 빌드하는가·구성 상세 → 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" diff --git a/images/apisix/verify.sh b/images/apisix/verify.sh new file mode 100755 index 0000000..544ad28 --- /dev/null +++ b/images/apisix/verify.sh @@ -0,0 +1,74 @@ +#!/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 < conf/apisix.yaml </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" diff --git a/manifests/helm/apisix/2.16.0/CUSTOM-README.md b/manifests/helm/apisix/2.16.0/CUSTOM-README.md index f52d568..7e1e09b 100644 --- a/manifests/helm/apisix/2.16.0/CUSTOM-README.md +++ b/manifests/helm/apisix/2.16.0/CUSTOM-README.md @@ -37,16 +37,26 @@ ingress-controller: 두 값이 불일치하면 ingress-controller가 Admin API 인증에 실패해 라우트 등록이 되지 않는다. -### 2. etcd StorageClass 확인 +### 2. externalEtcd 접속 정보 확인 (CVE 대응으로 기본값 변경됨) + +`etcd.enabled`는 **기본 `false`**다. 내장 etcd(bitnami 서브차트)가 쓰던 +`bitnamilegacy/etcd:latest`가 CVE 게이트 차단 65건(2026-08-11 실측)이었고, +업스트림이 `latest` 태그만 제공해 태그 교체로 해소되지 않는 알려진 이슈다 +(bitnami/containers#83267). 기본값은 카탈로그의 하드닝된 별도 +`etcd`(`manifests/helm/etcd/1.1.12`) 차트를 가리킨다. ```yaml -etcd: - persistence: - storageClass: longhorn # 클러스터에 설치된 StorageClass로 변경 +externalEtcd: + host: + - http://etcd.etcd-system.svc.cluster.local:2379 # 릴리스명 etcd, 네임스페이스 etcd-system 기준 ``` -클러스터에 `longhorn`이 없으면 PVC가 Pending 상태로 남아 배포가 중단된다. -사용 가능한 StorageClass는 `kubectl get storageclass`로 확인한다. +`manifests/helm/etcd/1.1.12`를 다른 릴리스명/네임스페이스로 배포했거나 TLS(`autoTls`)를 +켰다면 `host`/TLS 설정을 그에 맞게 바꾼다. 그 차트가 아직 없다면 먼저 배포한다 +(`manifests/helm/etcd/1.1.12/CUSTOM-README.md`). + +개발/테스트에서 내장 etcd가 필요하면 `etcd.enabled: true`로 되돌릴 수 있지만, 그러면 +`bitnamilegacy/etcd:latest`의 CVE 노출이 그대로 돌아온다는 점을 감수해야 한다. ### 3. ingress-controller 엔드포인트 및 publishService 확인 @@ -131,28 +141,29 @@ ingress: - APISIX Admin API를 외부에 노출할 경우에만 활성화한다 - `host`를 실제 도메인으로 변경한다 -### etcd (내장 etcd) +### etcd (내장 etcd, 기본 비활성) ```yaml etcd: - enabled: true # 내장 etcd 사용 (개발/테스트용) + enabled: true # 켜면 bitnamilegacy/etcd:latest 의 CVE 노출이 돌아온다 replicaCount: 3 # 운영 환경 HA: 3개 권장 persistence: enabled: true size: 8Gi ``` -> **운영 환경 주의**: 내장 etcd는 테스트 전용이다. -> 운영에서는 `etcd.enabled: false`로 설정하고 외부 etcd를 `externalEtcd`로 연결한다. +> **기본값이 `externalEtcd`로 바뀌었다(CVE 대응).** 내장 etcd는 개발/테스트에서 +> `etcd.enabled: true`로 켤 수 있지만, `bitnamilegacy/etcd:latest`는 상위 태그가 없어 +> CVE 게이트가 계속 차단한다. -### externalEtcd (외부 etcd 연동) +### externalEtcd (기본값 — 카탈로그의 하드닝된 etcd 차트 연동) ```yaml etcd: enabled: false externalEtcd: host: - - http://etcd-cluster.platform.svc.cluster.local:2379 + - http://etcd.etcd-system.svc.cluster.local:2379 # manifests/helm/etcd/1.1.12 기본 배포 기준 user: "" password: "" ``` @@ -192,8 +203,11 @@ helm template apisix apisix/apisix \ ### etcd 데이터 마이그레이션 -- etcd 내장 차트를 사용 중이라면 버전 업그레이드 전 etcd 스냅샷을 반드시 백업한다 -- `etcd.image.tag`가 `latest`로 고정되어 있으므로 버전 고정이 필요하면 `externalEtcd`로 전환한다 +- 기본값은 `externalEtcd`(카탈로그 `etcd` 차트)라 이 차트 자체의 업그레이드는 etcd 데이터에 + 영향을 주지 않는다 +- `etcd.enabled: true`로 내장 etcd를 쓰는 중이라면 버전 업그레이드 전 스냅샷을 반드시 + 백업한다 — `etcd.image.tag`가 `latest` 고정이라(bitnami/containers#83267) 버전 고정이 + 필요하면 `externalEtcd`로 전환한다 ### Breaking Change 체크 키 diff --git a/manifests/helm/apisix/2.16.0/custom-values.yaml b/manifests/helm/apisix/2.16.0/custom-values.yaml index 4310f5b..b476f3c 100644 --- a/manifests/helm/apisix/2.16.0/custom-values.yaml +++ b/manifests/helm/apisix/2.16.0/custom-values.yaml @@ -1,10 +1,17 @@ -# 차트 2.16.0 의 appVersion 은 3.17.0 이다. keycloak-authz 커스텀 플러그인을 포함한 -# paasup 자체 빌드 이미지도 같은 base 로 재빌드해 태그를 함께 올린다. -# (dataup experiment/apisix/apisix-plugin 에서 apache/apisix:3.17.0-debian 기반으로 빌드) -# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 갱신할 것. +# 차트 2.16.0 의 appVersion 은 3.17.0 이다. +# +# 기존엔 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. +# +# 차트 업그레이드 시 이 태그도 새 appVersion 에 맞춰 images/apisix 를 다시 빌드해 갱신할 것 — +# dataup 레포 쪽은 더 이상 이 카탈로그가 참조하지 않는다(그 레포 자체는 그대로 둠). image: repository: paasup/apisix - tag: "3.17.0-keycloak-authz" + tag: "3.17.0-security-hardened-20260811" pullPolicy: IfNotPresent replicaCount: 1 @@ -154,26 +161,49 @@ service: tcp: [] udp: [] -# 내장 etcd는 개발/테스트 전용. 운영에서는 externalEtcd를 사용한다. +# 내장 etcd(bitnami 서브차트)는 기본 비활성. bitnamilegacy/etcd:latest 가 CVE 게이트를 +# 차단(실효 CRITICAL/HIGH 65건, 2026-08-11 실측)하는데 업스트림이 latest 태그만 제공해 +# 태그 교체로 해소가 안 된다(bitnami/containers#83267). 카탈로그의 하드닝된 별도 etcd +# 차트(manifests/helm/etcd/1.1.12)를 externalEtcd 로 쓴다 — 개발/테스트에서 내장 etcd가 +# 필요하면 enabled: true 로 되돌리되 그 CVE 노출은 그대로 남는다. etcd: - enabled: true - replicaCount: 1 # 단일 노드 클러스터 — 운영 HA는 3 권장 - persistence: - enabled: true - storageClass: longhorn - size: 4Gi + enabled: false -# 외부 etcd 사용 시 etcd.enabled=false로 설정하고 아래 값을 채운다. -# externalEtcd: -# host: -# - http://etcd-cluster.platform.svc.cluster.local:2379 -# user: "" -# password: "" +# 기본 배포 대상: manifests/helm/etcd/1.1.12 를 릴리스명 "etcd", 네임스페이스 +# "etcd-system" 으로 배포했을 때의 서비스(포트 2379, 평문). 다른 네임스페이스/릴리스명으로 +# 배포했거나 dip-values.yaml 로 TLS(autoTls)를 켰다면 host/tls 설정을 맞게 바꾼다. +externalEtcd: + host: + - http://etcd.etcd-system.svc.cluster.local:2379 + user: "" + password: "" ingress-controller: enabled: true 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. + # + # 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. + deployment: + image: + repository: paasup/apisix-ingress-controller + tag: "2.1.0-security-hardened-20260811" + adcContainer: + image: + repository: paasup/adc + tag: "0.29.0-security-hardened-20260812" + gatewayProxy: createDefault: true # 기본값: false — v2.0.1+에서 미설정 시 라우트 등록 안 됨 publishService: apisix/apisix-gateway # Ingress STATUS에 External IP 반영 diff --git a/scripts/build/patch-catalog-tag.py b/scripts/build/patch-catalog-tag.py index 6262063..20aa651 100755 --- a/scripts/build/patch-catalog-tag.py +++ b/scripts/build/patch-catalog-tag.py @@ -9,6 +9,11 @@ build-image.yml 이 호출한다. 카탈로그가 실제로 쓰는 두 표기 텍스트 치환만 한다(YAML 파서를 쓰지 않는다) — 기존 주석·포매팅을 그대로 보존하기 위함이다(기존 cnpg-cluster 워크플로의 sed 방식과 같은 원칙). 예상한 패턴을 하나도 찾지 못하면 실패한다(조용히 건너뛰지 않는다) — 잘못된 치환보다 실패가 낫다. + +--block 은 점으로 구분한 중첩 경로를 받는다(예: `ingress-controller.deployment.image` — +apisix 서브차트처럼 image 블록이 top-level 이 아닌 경우). 첫 세그먼트만 진짜 top-level +키로 요구하고(기존 단일 세그먼트 동작과 동일), 그다음 세그먼트부터는 앞 블록의 body +안에서만 찾는다 — 다른 형제 블록에 있는 동명 키를 잘못 집지 않기 위함이다. """ import argparse import pathlib @@ -30,12 +35,53 @@ def read_image_name(text): return m.group(1) if m else None -def read_split_block(text, block): - block_re = re.compile(rf"^{re.escape(block)}:\n((?:[ \t]+.*\n?)*)", re.MULTILINE) - m = block_re.search(text) +def _locate_block(text, dotted_block, start=0, end=None, top_level=True): + """dotted_block(`a.b.c`) 를 순서대로 내려가며 최종 블록 body 의 (start, end) 절대 + 오프셋을 text 안에서 찾는다. 첫 세그먼트는 top_level=True 라 들여쓰기 없는 줄에서만 + 찾고(기존 단일 세그먼트 동작과 동일), 그 뒤 세그먼트는 앞 단계 body 범위 안에서만 + 찾는다(들여쓰기는 실제 값으로 감지 — 파일마다 폭이 다를 수 있어 하드코딩하지 않는다).""" + if end is None: + end = len(text) + segment, _, rest = dotted_block.partition(".") + indent_group = r"" if top_level else r"([ \t]*)" + pat = re.compile(rf"^{indent_group}{re.escape(segment)}:[ \t]*\n", re.MULTILINE) + m = pat.search(text, start, end) if not m: return None - body = m.group(1) + indent = "" if top_level else m.group(1) + body_start = m.end() + # 더 들여쓰인 줄이 이어지는 동안 body 로 삼는다. 빈 줄(또는 공백만 있는 줄)은 그 + # 자체로는 블록을 끝내지 않는다 — 사람이 읽기 좋게 블록 중간에 문단 구분으로 넣는 + # 경우가 실제로 있다(업스트림 서브차트 values.yaml 등). 다만 뒤에 더 들여쓰인 줄이 + # 안 나오면(다음 블록으로 넘어가거나 파일 끝) 그 빈 줄들은 body 에서 잘라낸다 — + # 아니면 다음 형제 블록과의 구분용 빈 줄까지 이 블록 소유로 잘못 삼키게 된다. + line_re = re.compile(r"[^\n]*\n?") + pos = body_start + body_end = body_start + while pos < end: + lm = line_re.match(text, pos, end) + line = lm.group(0) + if not line: + break + content = line.rstrip("\n") + if content.strip() == "": + pos = lm.end() + continue + if not (content[: len(indent) + 1] == indent + " " or content[: len(indent) + 1] == indent + "\t"): + break + pos = lm.end() + body_end = pos + if not rest: + return body_start, body_end + return _locate_block(text, rest, body_start, body_end, top_level=False) + + +def read_split_block(text, block): + loc = _locate_block(text, block) + if not loc: + return None + body_start, body_end = loc + body = text[body_start:body_end] values = {} for name in ("registry", "repository", "tag"): fm = re.search(rf'^\s*{name}:\s*"?([^"\n]+?)"?\s*$', body, re.MULTILINE) @@ -63,14 +109,14 @@ def _sub_field(body, name, old_v, new_v): def patch_split_block(text, block, old, new): - # block 시작 줄부터, 그보다 더 들여써진 줄이 이어지는 동안만 치환 대상으로 삼는다. - # 다음 top-level(들여쓰기 없는) 키가 나오면 블록이 끝난 것으로 본다. - block_re = re.compile(rf"^{re.escape(block)}:\n((?:[ \t]+.*\n?)*)", re.MULTILINE) - m = block_re.search(text) - if not m: + # block(점 구분 중첩 경로 가능)이 시작하는 줄부터, 그보다 더 들여써진 줄이 이어지는 + # 동안만 치환 대상으로 삼는다. 그만큼 들여써지지 않은 줄이 나오면 블록이 끝난 것으로 본다. + loc = _locate_block(text, block) + if not loc: return None + body_start, body_end = loc - body = m.group(1) + body = text[body_start:body_end] changed = False # registry 를 repository 와 분리된 필드로 쓰지 않는 차트가 있다(예: cloudnative-pg @@ -99,13 +145,13 @@ def patch_split_block(text, block, old, new): if not changed: return None - return text[: m.start(1)] + body + text[m.end(1) :] + return text[:body_start] + body + text[body_end:] def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--style", required=True, choices=["imageName", "split"]) - ap.add_argument("--block", default="image", help="split 스타일일 때 대상 top-level 키") + ap.add_argument("--block", default="image", help="split 스타일일 때 대상 키(점 구분 중첩 경로 가능, 예: ingress-controller.deployment.image)") ap.add_argument("--read", metavar="FILE", help="현재 태그만 읽어 출력하고 종료 (patch 안 함)") ap.add_argument("--old", help="이전 전체 태그 (registry/repo:tag) — --read 아닐 때 필수") ap.add_argument("--new", help="새 전체 태그 (registry/repo:tag) — --read 아닐 때 필수")