From 981d36daa47b906b09f232fbad891b923e7ec40c Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Thu, 20 Aug 2026 16:19:26 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20CVE=20=EA=B0=80=20?= =?UTF-8?q?=EB=82=98=EC=98=A4=EB=A9=B4=20=EC=9E=90=EC=B2=B4=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A5=BC=20=EC=8A=A4?= =?UTF-8?q?=EC=8A=A4=EB=A1=9C=20=EC=9E=AC=EB=B9=8C=EB=93=9C=ED=95=9C?= =?UTF-8?q?=EB=8B=A4=20+=20Dockerfile=20=EC=9E=91=EC=84=B1=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙 두 가지다. (1) 이전 이미지에서 CVE 가 발견되면 재빌드가 자동으로 일어나게 한다. (2) Dockerfile 을 OS·언어 축으로 일관되게 쓰도록 규칙을 정한다. (1) 재빌드 자동화 — 트리거를 로컬 실측으로 고쳤다 ------------------------------------------------ 처음에는 트리거를 "핀이 뒤처졌는가" 로 잡았다. **배포 중인 이미지 8개를 실제로 스캔해 보니 그게 틀렸다** — 차단 CVE 가 있는 4개 중 핀 변경이 필요한 것은 하나도 없었고, 그 판정은 "0건" 을 냈다. 재빌드로 고쳐지는 경우가 셋이기 때문이다. 핀이 뒤처졌다 → 핀 올려서 빌드 핀은 맞는데 그 핀으로 아직 안 빌드됐다 → 그냥 빌드 ← 핀만 고친 PR 직후 상태 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 ← 재빌드하면 zypper 가 최신을 깐다 그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 하는 부수 작업이고 트리거가 아니다. 수정 버전 없는 차단(affected·will_not_fix)은 재빌드해도 그대로이므로 no-fix 로 따로 보고해 사람이 다른 레버를 판단하게 한다. scripts/build/check-rebuild-needed.py 신규 카탈로그가 실제로 가리키는 이미지를 기준으로 잰다 — 재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다. catalog.env → 카탈로그 values 의 ref → 그 ref 의 스캔 리포트 ↕ build.env 의 핀 판정 규칙은 게이트와 같은 출처를 쓴다(max(벤더,NVD)·승인 예외·만료 예외 무효) — cve-gate.py 의 함수를 그대로 가져다 쓴다. 핀 산출은 suggest-go-upgrades.py 재사용. --list-refs 로 "무엇을 스캔해야 하나" 도 스크립트가 낸다 — ref 해석 규칙이 워크플로로 새면 두 곳이 어긋난다. suggest-go-upgrades.py 는 산출 로직이 main() 안에 있어 재사용할 수 없었다. collect_modules/ collect_stdlib/builder_candidates/effective_severity 로 빼고 main() 은 호출해 출력만 한다 — CLI 출력은 리팩터 전후 동일하다(실측 대조). build-image.yml 에 schedule(월요일 02:00 UTC)과 mode=drift 를 추가했다. 판정 → (핀이 뒤처졌으면) 브랜치 push → 빌드·verify.sh·게이트까지 자동이고 **레지스트리 push 와 카탈로그 반영은 하지 않는다.** 게시 여부 로직에서 catch-all 을 true → false 로 바꿨다 — 원래는 트리거를 추가하면 자동으로 push 가 켜지는 구조였고 schedule 을 넣는 순간 사고가 났다. 이제 push 는 workflow_dispatch + mode=image 에서만 켜진다. (2) Dockerfile 작성 규칙 — 실제 8개에서 추출했다 ---------------------------------------------- image-authoring.md 가 규칙 본문의 단일 출처이고 skill 은 "어느 절을 읽어야 하는가" 선택표만 갖는다(CLAUDE.md 의 "Skill 은 절차 본문을 복제하지 않는다"). 두 축은 독립이다 — 같은 bci-micro 최종 위에 Go 빌더도 Node 빌더도 온다. 축 1 최종 베이스 bci-base / bci-micro / scratch+micro rootfs — 셋뿐이다. 네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다. micro·scratch 는 nonroot 계정을 직접 만들어야 한다(실측 형태 첨부). 축 2 빌더 Go(BUILDPLATFORM/TARGETARCH, ldflags 에 SOURCE_COMMIT) / Node(런타임은 OS 패키지) / JVM(재컴파일 없이 jar 만 OLD/NEW 쌍으로 교체 — OLD 를 받는 이유는 못 찾으면 실패시키기 위함) / C·Lua(정적 링크 금지 — SLE_BCI 에 static glibc 없음) "업스트림 런타임 계약은 보존한다" 절을 새로 넣었다(USER·ENTRYPOINT·파일 레이아웃). cloudnative-pg 가 멀티아치 심볼릭 링크를 부수 장치로 오판해 지웠다가 리컨실이 실패한 것이 근거다. 스캐너는 이걸 전혀 보지 못한다. 로컬 검증 — 판정만이 아니라 빌드까지 돌렸다 ----------------------------------------- 판정이 지목한 4건을 실제로 빌드해 전제를 확인했다. 문서에 단정만 해두고 넘어가지 않았다. 이미지 배포 중 재빌드 후 핀 변경 apisix-ingress-controller 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음 cloudnative-pg 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음 etcd 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음 cnpg-postgresql 차단 2 0/0 PASS · VERIFY-OK · cov=ok 없음 - cnpg-postgresql 이 "베이스 OS 패키지 갱신으로 해소" 사례다(perl·rpm-ndb). 핀을 하나도 안 바꾸고 재빌드만으로 0건이 됐다 — 검증 없이 단정했던 부분이라 이게 핵심 확인이다. - 빌드된 etcd 바이너리가 go1.26.6 + pinned commit 을 보고한다 — 핀이 실제로 반영됐다. - 4건 다 CoverageProbe=ok 이므로 0건이 "측정되지 않음" 이 아니다. - 빌드 후에도 git status images/ 가 깨끗하다 = 트리거를 핀 기준으로 뒀다면 4건 전부 놓쳤을 것이라는 확인. 그 외: 예외 적용(keycloak CVE-2025-59250 → 0건), --fail-on-drift rc=1, 워크플로 셸 재현 (GITHUB_OUTPUT 4개 JSON·fromJson OK·핀 변경 0 → 브랜치 미생성 → 기본 ref 로 빌드), push 정책 전 조합 확인. CI 에서 미검증인 것은 환경 특성뿐이다 — trivy 설치 스텝, GITHUB_STEP_SUMMARY 출력, matrix 팬아웃, contents:write 로 브랜치 push. 빌드 자체는 로컬과 같은 스크립트를 쓴다. Refs #35 Co-Authored-By: Claude Opus 5 (1M context) * self-build-image SKILL 의 자동 재빌드 절을 실행 계약만 남긴다 같은 커밋에서 image-authoring.md 와 SKILL 양쪽에 자동 재빌드 절차를 거의 동일하게 썼다. SKILL 이 "작성 규칙 본문은 image-authoring.md 가 단일 출처다 — 여기 복제하지 않는다" 고 두 번 선언한 파일에서 내가 그 규칙을 어겼다. SKILL 에는 실행 계약(수동 실행 명령 · 로컬 판정 명령 · push 안 한다는 사실)만 남기고 트리거 조건과 그 근거는 image-authoring.md 를 가리킨다. frontmatter 도 함께 고친다 — "7종" 에 argocd 가 빠져 있어 실제 images/ 8개와 어긋났다. description 은 Skill 로딩 판단에 쓰이는 텍스트이므로 "목록은 images/ 가 단일 출처" 라는 회피 조항으로 덮을 수 없다. Co-Authored-By: Claude Opus 5 (1M context) * 레포 분리 시 갈라지는 이음선을 코드와 문서에 명시한다 커스텀 이미지가 별도 레포로 분리될 예정인데, check-rebuild-needed.py 는 카탈로그(무엇을 배포 중인가)와 build.env(어떻게 만드는가)를 **양쪽 다** 읽는다. 분리 시 이 파일이 갈라진다. 절단면은 이미 함수 경계에 있다. 6개월 뒤에 "이 함수가 왜 카탈로그를 읽지" 를 다시 파지 않도록 어느 함수가 어느 쪽인지 docstring 에 적었다. A (카탈로그 레포) resolve_current_ref + blocking_cves → "차단됐는가" B (이미지 레포) pin_changes + apply_changes → "핀을 올려야 하는가" check_image 둘을 엮는 오케스트레이션 — 여기가 갈라진다 image-authoring.md 의 드리프트 절에도 같은 사실을 한 문단으로 적어 분리 작업 때 이 주석을 먼저 읽게 했다. 결합점 전체 목록은 경계 문서화 작업에서 표로 정리한다. 코드 동작은 바뀌지 않았다 — 주석과 문서만 추가했다. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/image-authoring.md | 179 +++++++++ .claude/skills/self-build-image/SKILL.md | 55 ++- .github/workflows/build-image.yml | 145 +++++++- scripts/build/check-rebuild-needed.py | 448 +++++++++++++++++++++++ scripts/build/suggest-go-upgrades.py | 180 +++++---- 5 files changed, 916 insertions(+), 91 deletions(-) create mode 100644 scripts/build/check-rebuild-needed.py diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md index 882a626..8fc07bf 100644 --- a/.claude/image-authoring.md +++ b/.claude/image-authoring.md @@ -59,6 +59,31 @@ trivy 의 SLES 15.7 커버리지가 양성 대조로 실측 확인돼 있다 공식 언어 이미지(`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 저장소가 특정 @@ -140,6 +165,101 @@ COPY --from=builder /rootfs/ / 업스트림 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` · `_FIX_VERSION` · `_OLD`/`_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` | + | 개별 `_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` 에 @@ -175,6 +295,65 @@ python3 scripts/build/suggest-go-upgrades.py --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 이 어느 함수가 어느 쪽인지 적어둔다 — **탐지는 카탈로그 쪽에 남고, 핀 판단은 이미지 +쪽으로 간다.** 분리 작업 때 그 주석을 먼저 읽는다. + ## 두 가지 유형 (둘 다 같은 스크립트를 쓴다) | 유형 | Dockerfile 이 하는 일 | 셸 유무 | diff --git a/.claude/skills/self-build-image/SKILL.md b/.claude/skills/self-build-image/SKILL.md index 6ffbf17..5efea32 100644 --- a/.claude/skills/self-build-image/SKILL.md +++ b/.claude/skills/self-build-image/SKILL.md @@ -1,6 +1,6 @@ --- name: self-build-image -description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, cloudnative-pg, cnpg-postgresql, etcd, keycloak 7종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처). +description: 자체 빌드 하드닝 이미지를 추가하거나 기존 빌드 정의를 변경할 때 사용한다. "이미지 자체 빌드해줘", "하드닝 이미지 추가", "차단 CVE를 자체 빌드로 해소", "build-hardened-image.sh 실행", "images/ 아래 새 이미지" 같은 요청이 해당한다. 현재 adc, apisix, apisix-ingress-controller, argocd, cloudnative-pg, cnpg-postgresql, etcd, keycloak 8종이 있다(정확한 목록은 images/ 디렉토리가 단일 출처). --- # 자체 빌드 하드닝 이미지 @@ -31,6 +31,56 @@ IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh /tmp/ CI는 `build-image.yml`이 `image` 입력으로 이미 파라미터화돼 있다. `images//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 만 교체** | `_OLD` / `_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 /dip-catalog -f mode=drift +# 판정만 로컬에서 본다 +python3 scripts/build/check-rebuild-needed.py --list-refs +python3 scripts/build/check-rebuild-needed.py --reports [--apply] +``` + +이 경로는 **레지스트리 push 와 카탈로그 반영을 하지 않는다** — push 는 +`workflow_dispatch` + `mode=image` 에서만 켜진다. + ## 실측된 함정 - **`FROM`에 쓰는 `ARG`는 첫 `FROM` 이전(전역 스코프)에 선언한다.** 스테이지 내부에 선언하면 @@ -42,6 +92,9 @@ CI는 `build-image.yml`이 `image` 입력으로 이미 파라미터화돼 있다 스캐너에 그 배포판 데이터가 없다는 뜻이다(`sbom-cve-gate` skill 참고). - **롤링 태그를 쓰지 않는다.** 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 달라 태그에 빌드일을 포함한다(예: `1.30.0-security-hardened-20260804`). +- **핀을 고정한 대가로 핀이 뒤처진다.** 소스를 안 바꿔도 새 CVE 가 공개되면 어제 PASS 였던 + 핀이 오늘 FAIL 이 된다. 위 "자동 재빌드" 가 이것을 잡는다. 조치 전에 그 결과를 먼저 본다: + `python3 scripts/build/check-rebuild-needed.py --reports [--image ] [--apply]` - **단, 같은 날 다시 빌드하면 그 태그가 겹친다.** 노드가 캐시한 옛 digest 가 그대로 쓰여 (`imagePullPolicy: IfNotPresent`) 고친 것이 반영되지 않은 채 "안 고쳐졌다" 로 보인다. 배포 검증 중이라면 `imagePullPolicy: Always` 로 우회하고, digest 로 확인한다 — diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index dcd2ab9..42e9019 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -19,7 +19,7 @@ name: self-build-image # vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. # 빌드는 호스트 러너여야 한다. # -# 트리거 2종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다. +# 트리거 3종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다. # 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 브랜치 push 여부뿐이다. # # pull_request(images/**) 변경된 images// 디렉토리를 diff 로 자동 탐지해 그 @@ -27,8 +27,16 @@ name: self-build-image # 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다. # workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 태그 # 갱신 브랜치 push 는 이 트리거로만 일어난다(사람이 수동 실행) — -# sbom.yml 의 게이트는 현재 이 워크플로를 자동으로 호출하지 않는다. # 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" 미허용, @@ -40,20 +48,31 @@ name: self-build-image on: workflow_dispatch: inputs: + mode: + description: 'image=지정한 이미지 하나 | drift=재빌드가 필요한 이미지를 찾아 전부' + required: false + default: 'image' + type: choice + options: [image, drift] image: - description: '빌드할 이미지 디렉토리명 (images//)' - required: true + description: '빌드할 이미지 디렉토리명 (images//). mode=drift 면 비워둔다' + required: false + default: '' base_os: description: '빌드 변종 (images//.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용' required: false default: '' push: - description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 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 @@ -69,18 +88,109 @@ jobs: 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] " + 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 - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + 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 입력이 비어있다"; exit 1; } + [ -n "$img" ] || { echo "::error::image 입력이 비어있다 (mode=drift 가 아니면 필수)"; exit 1; } [ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; } json="[\"$img\"]" else @@ -99,7 +209,7 @@ jobs: build: needs: discover - if: needs.discover.outputs.images != '[]' + if: needs.discover.outputs.images != '[]' && needs.discover.outputs.images != '' strategy: fail-fast: false matrix: @@ -109,7 +219,10 @@ jobs: env: IMAGE_DIR: images/${{ matrix.image }} steps: + # 드리프트 경로면 discover 가 핀을 올려 push 한 브랜치를 받는다(비어 있으면 기본 ref). - uses: actions/checkout@v4 + with: + ref: ${{ needs.discover.outputs.ref }} - name: Preflight — 도구 확인 run: | @@ -151,16 +264,22 @@ jobs: # 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 - pull_request) echo "enabled=false" >> "$GITHUB_OUTPUT" ;; workflow_dispatch) - [ "${{ github.event.inputs.push }}" = "false" ] \ - && echo "enabled=false" >> "$GITHUB_OUTPUT" \ - || echo "enabled=true" >> "$GITHUB_OUTPUT" ;; - *) echo "enabled=true" >> "$GITHUB_OUTPUT" ;; + 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: 레지스트리 로그인 diff --git a/scripts/build/check-rebuild-needed.py b/scripts/build/check-rebuild-needed.py new file mode 100644 index 0000000..d9a2786 --- /dev/null +++ b/scripts/build/check-rebuild-needed.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""check-rebuild-needed.py — 배포 중인 자체 빌드 이미지가 재빌드로 고쳐지는지 판정한다. + +왜 필요한가 +----------- +자체 빌드 이미지는 **소스를 안 바꿔도 시간이 지나면 게이트를 벗어난다.** 새 CVE 가 공개되고, +베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 push 해 둔 이미지는 그대로다. +2026-08-19 에 etcd·cloudnative-pg 가 이 상태로 차단됐는데 **문서 전용 PR 이 우연히 +`images/**` 를 건드려 검증 빌드가 돌면서야** 발견됐다. 의도적으로 보는 장치가 없었다. + +`build-image.yml` 이 주간으로 이것을 돌려 재빌드까지 한다. + +무엇이 "재빌드로 고쳐지는가" 인가 — 세 경우 모두 그렇다 +----------------------------------------------------- +처음에는 "핀이 뒤처진 경우" 만 트리거로 잡았는데 **실측에서 그게 틀렸다.** 배포 중인 이미지 +8개를 스캔했더니 차단이 있는 5개 중 핀 변경이 필요한 것은 하나도 없었다. + + 1. 핀이 뒤처졌다 → 핀을 올리고 빌드 (GO_BUILDER_TAG 등) + 2. 핀은 맞는데 이미지가 그 핀으로 안 빌드됐다 → 그냥 빌드 + (핀만 고친 PR 이 머지되고 재빌드가 아직 안 된 상태) + 3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 + (재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다) + +그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 적용하는 +부수 작업이고, 트리거 조건이 아니다. + +수정 버전이 없는 차단(`affected`·`will_not_fix`)은 재빌드해도 그대로이므로 트리거가 아니다 — +`no-fix` 로 따로 보고해 사람이 다른 레버(태그 교체·예외 승인)를 판단하게 한다. + +판정 규칙은 게이트와 같은 출처를 쓴다 +----------------------------------- +실효 등급 `max(벤더, NVD)` · 승인 예외(`doc/cve-exceptions.json`) 적용 — 전부 +`scripts/pipeline/cve-gate.py` 의 함수를 그대로 가져다 쓴다. 규칙이 두 곳으로 갈리면 +"게이트는 PASS 인데 재빌드하라고 한다" 가 나온다. + +핀 산출은 `suggest-go-upgrades.py` 를 재사용한다(그것이 다시 게이트의 심각도 규칙을 쓴다). + +기준은 카탈로그가 실제로 가리키는 이미지다 +---------------------------------------- + images//catalog.env → 카탈로그 values 의 현재 ref + → 그 ref 의 스캔 리포트 + ↕ + .build.env 의 핀 + +재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다. + +레포 분리 시 이 파일은 둘로 갈린다 +-------------------------------- +커스텀 이미지가 별도 레포로 분리될 예정이고(`.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 가 핀을 판단한다. + +이 주석의 목적은 분리 작업을 **고고학이 아니라 기계적 작업**으로 만드는 것이다. 새 함수를 +추가할 때 어느 쪽에 속하는지 함께 적는다. + +사용 +---- + 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) + 1 재빌드 대상 있음 + --fail-on-drift + 2 실행 오류 +""" +import argparse +import importlib.util +import json +import os +import pathlib +import re +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") +DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json") + +# 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고 +# dip-values.yaml 은 같은 값을 쓰는 배포 파라미터화용이다. +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 + + +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() + + +def read_env(path): + """`KEY=value` 셸 할당만 읽는다. 값의 따옴표는 벗긴다.""" + 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) + if not re.match(r"^[A-Z_][A-Z0-9_]*$", k): + continue + out[k] = v.strip().strip('"').strip("'") + return out + + +def report_path(reports_dir, image_ref): + """이미지 ref → 리포트 파일 경로. scan-sbom.sh 가 `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): + """카탈로그 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" + if not chart_dirs or style not in ("imageName", "split"): + return None, "catalog.env 에 CHART_DIRS/TAG_STYLE 이 없다" + for d in chart_dirs: + for fname in VALUES_CANDIDATES: + p = os.path.join(ROOT, d, fname) + if not os.path.isfile(p): + continue + text = pathlib.Path(p).read_text() + ref = (PATCH.read_image_name(text) if style == "imageName" + else PATCH.read_split_block(text, block)) + if ref: + return ref, os.path.relpath(p, ROOT) + return None, f"{chart_dirs} 에서 태그를 읽지 못했다 (style={style}, block={block})" + + +def blocking_cves(report, image_ref, rank, floor, exceptions): + """실효 등급 floor 이상 + 승인 예외 제외한 고유 CVE. + + 반환: {cve: {"pkg", "sev", "fixable"}} + """ + out = {} + for res in report.get("Results") or []: + for v in res.get("Vulnerabilities") or []: + cve = v.get("VulnerabilityID") + if not cve: + continue + eff = SUGGEST.effective_severity(v, GATE, rank) + if rank.get(eff, 0) < floor: + continue + if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions): + continue + prev = out.get(cve) + fixable = bool(v.get("FixedVersion")) + if prev is None: + out[cve] = {"pkg": v.get("PkgName"), "sev": eff, "fixable": fixable} + elif fixable: + prev["fixable"] = True + 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} + + 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) + if not ref: + res["status"] = "skip" + res["notes"].append(where) + return res + res["ref"] = ref + res["ref_from"] = where + + rp = report_path(reports_dir, ref) + if not os.path.isfile(rp): + res["status"] = "no-report" + res["notes"].append("이 ref 의 스캔 리포트가 없다 — 스캔 대상에 없었거나 태그가 갱신됐다") + return res + + with open(rp) as f: + report = json.load(f) + cves = blocking_cves(report, ref, rank, floor, exceptions) + fixable = {c: d for c, d in cves.items() if d["fixable"]} + res["blocking"] = len(cves) + res["fixable"] = len(fixable) + res["packages"] = sorted({d["pkg"] for d in cves.values() if d["pkg"]}) + + if not cves: + return res # ok + + res["changes"], res["notes"] = pin_changes([rp], pins, rank, floor) + + if fixable: + res["status"] = "rebuild" + else: + res["status"] = "no-fix" + res["notes"].append("차단 CVE 에 수정 버전이 없다 — 재빌드로 해소되지 않는다. " + "상위 태그 교체나 예외 승인을 검토한다") + 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"] + ok = [r for r in results if r["status"] == "ok"] + out = ["## 🔁 자체 빌드 이미지 재빌드 판정", ""] + + if not rebuild and not nofix: + out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.") + if rebuild: + out += [f"**{len(rebuild)}개 이미지가 재빌드로 고쳐진다.** 차단 CVE 에 수정 버전이 있다 " + "— 재빌드하면 베이스 패키지·툴체인이 최신으로 반영된다.", "", + "| 이미지 | 차단 | 수정 가능 | 패키지 | 함께 올릴 핀 |", + "|---|---:|---:|---|---|"] + for r in rebuild: + pins = "
".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} |") + if nofix: + out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.", + "", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"] + for r in nofix: + out.append(f"| `{r['image']}` | {r['blocking']} | " + f"{', '.join(f'`{p}`' for p in r['packages'][:4])} |") + + notes = [(r["image"], n) for r in results for n in r["notes"] + if r["status"] in ("rebuild", "no-fix")] + if notes: + out += ["", "**수동 확인 필요**", ""] + out += [f"- `{img}` — {n}" for img, n in notes] + + skipped = [r for r in results if r["status"] in ("skip", "no-report")] + if skipped: + out += ["", f"
판정하지 않은 이미지 {len(skipped)}개", ""] + out += [f"- `{r['image']}` — {'; '.join(r['notes'])}" for r in skipped] + out += ["", "
"] + + out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · " + "기본 HIGH 이상. 핀 제안은 **제안**이고 채택은 사람이 커밋한다.", ""] + 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/)") + ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS, + help="승인 예외 목록 (기본 doc/cve-exceptions.json)") + ap.add_argument("--min-severity", default="HIGH", + choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"], + help="이 등급 이상만 차단으로 본다 (기본 HIGH — 게이트와 동일)") + ap.add_argument("--list-refs", action="store_true", + help="`이미지ref리포트파일명` 만 출력하고 종료. CI 가 무엇을 " + "스캔해야 하는지 알아야 할 때 쓴다 — ref 해석 규칙이 워크플로로 " + "새지 않게 한다") + 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) + return 2 + if not args.list_refs and not args.reports: + print("::error::--reports 가 필요하다", file=sys.stderr) + return 2 + if args.reports and not os.path.isdir(args.reports): + 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))) + 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) + 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"))) + if not ref: + print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr) + continue + print(f"{n}\t{ref}\t{os.path.basename(report_path('', ref))}") + return 0 + + rank = GATE.RANK + floor = rank[args.min_severity] + # load_exceptions 는 (유효, 만료) 를 낸다. 만료된 예외는 게이트가 인정하지 않으므로 + # 여기서도 인정하지 않는다 — 재검토를 강제하는 것이 그 설계의 의도다. + exceptions, expired = GATE.load_exceptions(args.exceptions) + if expired: + print(f"::warning::만료된 예외 {len(expired)}건은 인정하지 않는다 — " + f"{', '.join(e.get('id', '?') for e in expired)}") + + results = [check_image(n, args.reports, rank, floor, exceptions) for n in names] + + 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 "")) + elif r["status"] == "no-fix": + print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — " + "재빌드로 해소되지 않는다") + for n in r["notes"]: + if r["status"] in ("rebuild", "no-fix"): + print(f"::warning::{r['image']}: {n}") + + md = render_md(results) + print() + print(md) + + if args.summary_md: + pathlib.Path(args.summary_md).write_text(md + "\n") + if args.json_out: + 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 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build/suggest-go-upgrades.py b/scripts/build/suggest-go-upgrades.py index 4bf2562..298e929 100755 --- a/scripts/build/suggest-go-upgrades.py +++ b/scripts/build/suggest-go-upgrades.py @@ -71,6 +71,106 @@ def pick_fixed(raw): 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) 디렉토리") @@ -90,85 +190,11 @@ def main(): print(f"::error::리포트를 찾지 못했다: {args.reports} (--image={args.image or '전체'})", file=sys.stderr) return 2 - # module -> {"fixed": 최대 FixedVersion, "cves": {...}, "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 - vend = v.get("Severity") or "UNKNOWN" - nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")) - eff = vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd - 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"]) - - # 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 으로 - # 오독하게 된다(실측 버그). - std_alts = {} # cve -> [(major, minor, patch, is_pre), ...] - 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 - vend = v.get("Severity") or "UNKNOWN" - nvd = gate.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")) - eff = vend if rank.get(vend, 0) >= rank.get(nvd or "UNKNOWN", 0) else nvd - if rank.get(eff, 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 + mods = collect_modules(paths, gate, rank, floor) + std_alts = collect_stdlib(paths, gate, rank, floor) if std_alts: - # 정식 릴리스 대안만으로 후보 마이너를 만든다(프리릴리스는 툴체인 태그로 못 쓴다). - 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)) + rows = builder_candidates(std_alts) print("# stdlib 차단 CVE 가 있다 — GO_MODULE_UPGRADES 가 아니라 툴체인으로 해소한다.") print(f"# 대상 CVE {len(std_alts)}건. 아래 중 어느 것을 써도 전부 해소된다:") for (a, b), c in rows: