diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md new file mode 100644 index 0000000..a6d54ba --- /dev/null +++ b/.claude/image-authoring.md @@ -0,0 +1,106 @@ +# 자체 빌드 이미지 작업 규칙 + +[CLAUDE.md](../CLAUDE.md) 에서 분리했다. 새 자체 빌드 이미지를 추가하거나(CVE 게이트 +대응 우선순위 중 "자체 빌드") 기존 이미지의 빌드 정의를 바꿀 때만 참고한다. + +security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅했다. **이 시점에는 아직 +이 레포에 도입된 자체 빌드 이미지가 없다** — `images/` 디렉토리 자체가 없다. 아래는 +프레임워크가 어떻게 동작하는지와, 첫 이미지를 추가할 때 지켜야 할 규칙이다. + +## 원칙 1 — 오케스트레이션은 항상 하나, 이미지 종류는 몰라도 된다 + +**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.** +이미지가 OS 패키지를 재설치하는 것이든, 소스를 직접 컴파일하는 것이든 스크립트는 +같다 — 차이는 전부 `images//` 안에 있다. + +**새 오케스트레이션 스크립트를 만드는 것은 최후의 수단이다.** "이 이미지는 성격이 +다르다"는 이유만으로 새 스크립트를 만들지 않는다. 절차(빌드 → 기능검증 → SBOM → 스캔 +→ 게이트 → push)는 이미지 종류와 무관하게 동일하고, 차이는 Dockerfile 내부(무엇을 +어떻게 설치·컴파일하는가)에만 있어야 한다. + +### `build-hardened-image.sh` 가 요구하는 계약 + +`images//.build.env` 가 다음을 선언하면 스크립트는 이미지 종류를 +몰라도 된다: + +| 키 | 의미 | +| --- | --- | +| `DOCKERFILE` | `images//` 기준 상대 경로 | +| `TARGET` | `docker build --target` 에 넘길 스테이지명 | +| `BUILD_ARGS` | 공백 구분 변수명 목록. 여기 나열한 것만 `--build-arg` 로 전달된다 | +| `APP_VERSION` | 태그 프리픽스·`verify.sh` 전달용 범용 버전 문자열 (유일한 필수값) | + +그 외 이미지별 변수(예: `PG_MAJOR`, `SOURCE_COMMIT`)는 build.env 에 적기만 하면 **자동으로 +`verify.sh` 의 환경변수로 전달된다** — 스크립트가 무엇을 넘겨야 하는지 알 필요가 없다. + +### `verify.sh` 는 호스트에서 bash 로 실행된다 + +게스트 컨테이너에 stdin 으로 셸 스크립트를 주입하는 방식이 아니다 — +`env TAG=... PLATFORM=... bash images//verify.sh` 로 호출된다. +**이유**: 이미지에 셸이 없을 수 있다(distroless 계열 최종 이미지는 `/bin/sh` 가 없다). +호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로 게스트 +스크립트를 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로 직접 +실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야 +통과로 판정된다. + +## 원칙 2 — 최종 런타임 베이스 OS 는 아직 미결 + +security-catalog 는 자체 빌드 이미지의 최종 런타임 베이스로 SUSE BCI 하나만 쓰기로 +결정했지만, 그 결정은 이 레포에 이식하지 않았다. **dip-catalog 는 베이스 OS 정책이 +아직 없다** — 처음 자체 빌드 이미지를 추가할 때 정하고, 결정 배경을 남긴다(카탈로그 +전용 `doc/decisions/` 관례는 아직 없으므로 우선 해당 PR 설명과 `MEMORY.md`에 기록). + +- "업스트림과 최대한 동일하게" 라는 기본 원칙과 특정 베이스 OS 채택이 충돌할 수 있다 + (예: 정적 링크 바이너리에 어떤 최소 이미지를 쓸지). 그 경우 무엇을 우선했는지와 왜인지 + 기록한다. +- 최소 이미지(distroless 류, BCI micro 류 등)는 `sed`/`grep` 같은 흔한 도구가 없을 수 + 있다 — `verify.sh` 게스트 스크립트는 그런 도구에 의존하지 말고 순수 셸 루프 + (`while IFS= read -r line; do ...; done`)로 작성하는 편이 안전하다. +- 빌더 스테이지(컴파일용, 최종 이미지에 남지 않는 스테이지)는 이 정책 대상이 아니다 — + 공식 언어 이미지(`golang` 등)를 그대로 써도 된다. 정책이 적용되는 것은 **스캔·배포 + 대상인 최종 스테이지**뿐이다. + +## 두 가지 유형 (둘 다 같은 스크립트를 쓴다) + +| 유형 | Dockerfile 이 하는 일 | 셸 유무 | +| --- | --- | --- | +| OS 패키지 재설치형 | 업스트림이 배포하는 산출물을 다른 배포판(zypper/apt 등)에 재설치 | 보통 있음 — 게스트 스크립트로 검증 | +| 소스 컴파일형 | 업스트림 pinned commit 을 `go build` 등으로 직접 컴파일 | 최종 베이스에 따라 다름 | + +어느 유형이든 이 셋만 새로 쓰면 된다: `.Dockerfile`, `.build.env`, +`verify.sh`(+ `README.md`). + +## 신규 이미지 추가 체크리스트 + +1. **상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가.** 그것으로 해소되면 + 자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에 + 정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는 + 원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는 `MEMORY.md`) +2. **어느 유형인지 판단한다** ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접 + 컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2) +3. `images//.Dockerfile`·`.build.env`·`verify.sh`·`README.md` + 작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다. + **`FROM` 에 쓰는 `ARG` 는 반드시 파일의 첫 `FROM` 이전(전역 스코프)에 선언한다** — + 스테이지 내부(어떤 `FROM` 뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후 `FROM` 의 + 이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다 +4. 로컬 빌드: + ```sh + IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh /tmp/out + ``` + `cve-gate.md` 로 실효 C/H 0 확인. 커버리지 자가진단(`cov=`)이 `ok` 인지도 확인 — + 패키지가 적은 이미지(최소 베이스 등)는 "데이터 없음"으로 오판될 위험이 있다 +5. **게이트 PASS 는 "동작한다" 를 증명하지 않는다.** CVE 스캐너는 CVE 와 무관한 런타임 + 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. + 실제 배포 검증을 반드시 한다 — 자동화된 배포 테스트 절차는 아직 없으므로 해당 차트를 + dev 클러스터에 배포해 수동으로 기능을 확인한다. 업스트림과 다르게 만든 부분은 전부 + 이유를 확인하고 남긴다. +6. 카탈로그 values(`custom-values.yaml`/`dip-values.yaml` 등) 갱신, 조사·결정 근거를 + PR 설명과 `MEMORY.md`에 기록. `images/**`+`manifests/helm/**` 는 PR 로. +7. CI 자동화: `build-image.yml` 은 이미 `image` 입력으로 파라미터화돼 있다 — + `images//catalog.env` 만 추가하면 별도 워크플로 수정 없이 태울 수 있다. + +## SBOM·스캔·게이트는 절대 다시 만들지 않는다 + +`scripts/pipeline/scan-sbom.sh` · `scripts/pipeline/cve-gate.py` 는 이미지 종류와 무관하게 +동작한다(단, dip-catalog 의 `scan-sbom.sh` 는 커버리지 자가진단이 없다 — `doc/sbom-pipeline.md` +참고). `build-hardened-image.sh` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다. diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 0000000..1010f1e --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,303 @@ +name: self-build-image + +# 자체 빌드 이미지(images/, 실행기 scripts/build/build-hardened-image.sh)의 +# 빌드·검증·스캔·게이트·카탈로그 반영을 자동화한다. security-catalog 의 동일 워크플로에서 +# 프레임워크만 포팅했다 — 아직 이 레포에 도입된 자체 빌드 이미지는 없다(images/ 디렉토리 +# 자체가 비어 있음). 게이트(scripts/pipeline/cve-gate.py)가 상위 태그·베이스 OS 교체로 +# 해소되지 않는 차단 CVE 를 찾으면 이 워크플로로 자체 빌드를 검토한다 — +# 절차: .claude/image-authoring.md +# +# "이미지가 어느 차트의 어느 필드를 가리키는가" 는 images//catalog.env 가 선언한다 +# (CHART_DIRS·TAG_STYLE·TAG_BLOCK·DEFAULT_BASE_OS). 태그 표기 스타일이 두 가지다: +# imageName 단일 필드 문자열 (`imageName: "repo:tag"`) +# split registry/repository/tag 세 필드로 분리된 블록 +# 둘 다 scripts/build/patch-catalog-tag.py 하나로 다룬다(YAML 파서 없이 텍스트 치환만 +# 해서 기존 주석·포매팅을 보존한다 — 예상 패턴을 못 찾으면 조용히 넘어가지 않고 실패한다). +# +# 이 워크플로는 helm-catalog-sbom(sbom.yml)과 별도 파일이다. sbom.yml 은 +# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. +# 빌드는 호스트 러너여야 한다. +# +# 트리거 2종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다. +# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 PR 생성 여부뿐이다. +# +# pull_request(images/**) 변경된 images// 디렉토리를 diff 로 자동 탐지해 그 +# 이미지들만 검증한다(push·카탈로그 PR 생성 없음). 여러 이미지가 +# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다. +# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 PR +# 생성은 이 트리거로만 일어난다(사람이 수동 실행) — sbom.yml 의 +# 게이트는 현재 이 워크플로를 자동으로 호출하지 않는다. +# push 입력이 false 면 검증만 한다. + +on: + workflow_dispatch: + inputs: + image: + description: '빌드할 이미지 디렉토리명 (images//)' + required: true + base_os: + description: '빌드 변종 (images//.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용' + required: false + default: '' + push: + description: '레지스트리 push + 카탈로그 PR 생성 여부' + required: false + default: 'true' + pull_request: + paths: + - 'images/**' + +permissions: + contents: write + pull-requests: write + +env: + REGISTRY_HOST: docker.io/paasup + +jobs: + # --- 대상 이미지 결정 --------------------------------------------------------- + # workflow_dispatch: inputs.image 하나. pull_request: images// 아래 변경이 있는 + # 디렉토리를 전부 찾는다(여러 이미지가 한 PR 에서 바뀌면 각각 매트릭스로 돈다). + discover: + runs-on: ubuntu-latest + outputs: + images: ${{ steps.list.outputs.images }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: 대상 이미지 목록 산출 + id: list + run: | + set -euo pipefail + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + img="${{ github.event.inputs.image }}" + [ -n "$img" ] || { echo "::error::image 입력이 비어있다"; exit 1; } + [ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; } + json="[\"$img\"]" + else + changed="$(git diff --name-only \ + "${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" \ + -- images/ | awk -F/ 'NF>1 {print $2}' | sort -u)" + if [ -z "$changed" ]; then + json="[]" + else + json="$(printf '%s\n' "$changed" \ + | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')" + fi + fi + echo "images=$json" >> "$GITHUB_OUTPUT" + echo "대상 이미지: $json" + + build: + needs: discover + if: needs.discover.outputs.images != '[]' + strategy: + fail-fast: false + matrix: + image: ${{ fromJson(needs.discover.outputs.images) }} + runs-on: ubuntu-latest + timeout-minutes: 60 + env: + IMAGE_DIR: images/${{ matrix.image }} + steps: + - uses: actions/checkout@v4 + + - name: Preflight — 도구 확인 + run: | + set -e + for t in docker python3 bash; do + command -v "$t" >/dev/null || { echo "::error::러너에 $t 없음"; exit 1; } + done + docker version + + - name: trivy 설치 + run: | + curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \ + | sh -s -- -b /usr/local/bin + trivy --version | head -1 + + # "이 이미지가 어느 차트의 어느 필드를 가리키는가" 는 이미지 디렉토리 자신이 + # 선언한다(catalog.env) — 이 워크플로가 이미지별 지식을 갖지 않게 하기 위함 + # (build.env 계약과 같은 원칙, .claude/image-authoring.md). + - name: 이미지 메타데이터 로드 (catalog.env) + id: meta + run: | + set -euo pipefail + ENV_FILE="$IMAGE_DIR/catalog.env" + [ -f "$ENV_FILE" ] || { echo "::error::catalog.env 없음: $ENV_FILE — images//catalog.env 를 추가해야 한다"; exit 1; } + # shellcheck disable=SC1090 + . "$ENV_FILE" + : "${DEFAULT_BASE_OS:?catalog.env 에 DEFAULT_BASE_OS 가 없다}" + : "${CHART_DIRS:?catalog.env 에 CHART_DIRS 가 없다}" + : "${TAG_STYLE:?catalog.env 에 TAG_STYLE 이 없다}" + BASE_OS="${{ github.event.inputs.base_os }}" + BASE_OS="${BASE_OS:-$DEFAULT_BASE_OS}" + { + echo "base_os=$BASE_OS" + echo "chart_dirs=$CHART_DIRS" + echo "tag_style=$TAG_STYLE" + echo "tag_block=${TAG_BLOCK:-image}" + } >> "$GITHUB_OUTPUT" + echo " image=${{ matrix.image }} base_os=$BASE_OS chart_dirs=$CHART_DIRS tag_style=$TAG_STYLE" + + # push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면 + # push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다. + - 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" ;; + esac + + - name: 레지스트리 로그인 + if: steps.publish.outputs.enabled == 'true' + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + [ -n "$DOCKERHUB_USER" ] || { echo "::error::DOCKERHUB_USER 시크릿 없음"; exit 1; } + echo "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin + + - name: 빌드 → 검증 → SBOM → 스캔 → 게이트 + id: build + env: + IMAGE: ${{ matrix.image }} + BASE_OS: ${{ steps.meta.outputs.base_os }} + run: | + set -uo pipefail + OUT="$GITHUB_WORKSPACE/build-out" + mkdir -p "$OUT" + # build-hardened-image.sh 의 build.log 는 그 안에서 docker build 출력만 담는 + # 별도 파일이다(스크립트 자신의 tag= 안내는 그 파일에 없다). 태그를 뒤에서 + # 뽑으려면 스크립트 자신의 표준출력을 따로 남겨야 한다 — tee 로 wrapper.log + # 에도 기록한다. + # + # GitHub Actions 의 run: 스텝은 기본으로 bash -e 다. 빌드 스크립트의 실패를 + # $rc 로 정상 캡처하려면 그 호출 동안만 -e 를 끈다 — 아니면 실패 시 여기서 + # 바로 중단돼 아래의 rc 기록·output 기록이 실행되지 않는다. + set +e + if [ "${{ steps.publish.outputs.enabled }}" = "true" ]; then + REGISTRY="$REGISTRY_HOST" bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" + else + bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" # push 없음 — 검증만 + fi + rc="${PIPESTATUS[0]}" + set -e + echo "rc=$rc" >> "$GITHUB_OUTPUT" + # 끝에 `|| true` 를 붙인다 — grep 이 매치를 못 찾아 실패해도(pipefail 이 + # 그 실패를 대입식 전체의 실패로 만든다) -e 아래에서 스크립트가 중단되지 + # 않게 한다. tag 가 비어도 그만이다 — rc!=0 이면 어차피 이후 단계가 건너뛴다. + tag="$(grep -h '^ tag=' "$OUT/wrapper.log" 2>/dev/null | tail -1 | cut -d= -f2- || true)" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + exit "$rc" + + - name: 아티팩트 업로드 + if: always() + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.image }}-build + path: | + build-out/build.log + build-out/verify.log + build-out/cve-gate.md + build-out/sbom + if-no-files-found: warn + + - name: Job Summary + if: always() + run: | + if [ -f build-out/cve-gate.md ]; then + { echo "## ${{ matrix.image }}"; cat build-out/cve-gate.md; echo; } >> "$GITHUB_STEP_SUMMARY" + fi + + # --- 카탈로그 반영 (push/workflow_dispatch 이고 게이트 PASS 일 때만) -------------- + + - name: 현재 카탈로그 태그 확인 + id: current + if: steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' + run: | + set -euo pipefail + # CHART_DIRS 가 여러 개일 수 있으나(공백 구분) 첫 번째 디렉토리를 기준값으로 + # 삼는다 — 지금까지 이미지 하나가 차트 여러 개에 걸친 사례가 없다. + first_dir="$(echo "${{ steps.meta.outputs.chart_dirs }}" | awk '{print $1}')" + cur="$(python3 scripts/build/patch-catalog-tag.py \ + --style "${{ steps.meta.outputs.tag_style }}" \ + --block "${{ steps.meta.outputs.tag_block }}" \ + --read "$first_dir/custom-values.yaml")" + echo "tag=$cur" >> "$GITHUB_OUTPUT" + echo "현재 카탈로그 태그: $cur" + echo "새 빌드 태그: ${{ steps.build.outputs.tag }}" + + # 이 워크플로를 트리거하는 것 자체가 이미 "조치가 필요하다" 는 판단(sbom.yml 의 + # 게이트가 차단+수정가능 CVE 를 확인)이거나 사람의 명시적 실행이다. 예전에는 + # 블라인드 스케줄 재빌드가 있어서 "정말 개선인지" 를 여기서 재확인해야 했는데, + # 그 트리거를 없앤 뒤로는 불필요해졌다 — 게이트 PASS + 태그 변경만 확인한다. + - name: 카탈로그 PR 생성 + if: > + steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' && + steps.current.outputs.tag != steps.build.outputs.tag + env: + GH_TOKEN: ${{ github.token }} + run: | + set -uo pipefail + NEW="${{ steps.build.outputs.tag }}" + OLD="${{ steps.current.outputs.tag }}" + IMAGE="${{ matrix.image }}" + BRANCH="build/${IMAGE}-$(date -u +%Y%m%d%H%M%S)" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + + TOUCHED=() + for dir in ${{ steps.meta.outputs.chart_dirs }}; do + candidates=() + [ -f "$dir/custom-values.yaml" ] && candidates+=("$dir/custom-values.yaml") + [ -f "$dir/dip-values.yaml" ] && candidates+=("$dir/dip-values.yaml") + [ "${#candidates[@]}" -eq 0 ] && continue + out="$(python3 scripts/build/patch-catalog-tag.py \ + --style "${{ steps.meta.outputs.tag_style }}" \ + --block "${{ steps.meta.outputs.tag_block }}" \ + --old "$OLD" --new "$NEW" "${candidates[@]}")" + echo "$out" + TOUCHED+=("${candidates[@]}") + done + git diff --stat + + git add "${TOUCHED[@]}" + git commit \ + -m "${IMAGE} 이미지 태그 갱신: ${OLD##*:} → ${NEW##*:}" \ + -m "게이트 PASS(build-image.yml, ${{ github.event_name }} 트리거)로 확인된 태그로 교체한다." \ + -m "Co-Authored-By: github-actions[bot] " + git push -u origin "$BRANCH" + + # 본문을 파일로 만든다. heredoc 을 워크플로 run 블록 안에 그대로 두면 내용의 + # 들여쓰기가 YAML 블록 스칼라 들여쓰기와 어긋난다. + BODY="$GITHUB_WORKSPACE/pr_body.md" + { + echo "자체 빌드 이미지(\`${IMAGE}\`) 태그 갱신. 트리거: \`${{ github.event_name }}\`." + echo + echo "- 이전 태그: \`$OLD\`" + echo "- 새 태그: \`$NEW\`" + echo "- 게이트: PASS (아티팩트의 \`cve-gate.md\` 참고)" + echo + echo "**주의 — 이 PR 은 다른 워크플로를 자동으로 트리거하지 않는다**" + echo "(\`GITHUB_TOKEN\` 이 만든 PR 에 대한 GitHub 의 재귀 방지 정책). 병합 전에 아래를 수동으로" + echo "실행해 카탈로그 게이트를 확인한다." + echo + echo '```sh' + echo "gh workflow run helm-catalog-sbom --ref $BRANCH" + echo '```' + echo + echo "이미지가 실제로 동작하는지도 병합 전에 수동으로 확인한다(해당 차트 배포 후" + echo "기능 점검 — 자동화된 배포 테스트 절차는 아직 없다)." + } > "$BODY" + + gh pr create --title "${IMAGE}: ${OLD##*:} → ${NEW##*:}" --body-file "$BODY" diff --git a/scripts/build/build-hardened-image.sh b/scripts/build/build-hardened-image.sh new file mode 100755 index 0000000..1636073 --- /dev/null +++ b/scripts/build/build-hardened-image.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# ============================================================================= +# build-hardened-image.sh +# 업스트림 이미지가 CRITICAL/HIGH 0건 목표를 만족하지 못할 때, 업스트림 Dockerfile 을 +# 기준으로 베이스 OS 를 교체하고 보안 업데이트를 적용한 이미지를 빌드·검증한다. +# +# 빌드 → 기능 검증 → 취약점 스캔 → 게이트 판정 까지 한 번에 수행한다. +# 기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다 (0건이어도 못 쓰는 이미지는 무의미). +# +# 사용: +# IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh [TAG] +# REGISTRY=docker.io/paasup IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh +# +# 빌드 정의는 이 스크립트에 없다. images//.build.env 를 source 해서 +# 베이스·버전·확장·build-arg 목록을 읽고, 기능 검증은 images//verify.sh 에 위임한다. +# (베이스 OS 가 둘 이상이 되면 하드코딩된 검증이 깨지기 때문이다 — 실제로 그렇게 됐었다) +# +# 이미지 종류(OS 패키지 설치형·소스 컴파일형 등)에 무관하게 이 스크립트 하나를 쓴다 — +# build.env 가 선언하는 것 이상을 이 스크립트가 알지 못하게 하는 게 원칙이다. build.env 가 +# 요구하는 값은 APP_VERSION(태그·verify.sh 전달용) 하나뿐이고, 그 외 이미지별 변수는 +# build.env 에 무엇을 적든 자동으로 verify.sh 의 환경변수로 전달된다(아래 참고). +# +# 환경변수: +# IMAGE 이미지 디렉토리명 (필수 — images//. 기본값 없음: 아직 도입된 +# 자체 빌드 이미지가 없어 어떤 기본값도 실재하지 않는 이미지를 가리킨다) +# BASE_OS 빌드 변종 파일명 (필수 — images//.build.env. 베이스 +# OS 정책은 아직 미결이다 — 처음 도입하는 이미지에서 정한다, MEMORY.md 참고) +# PLATFORM 빌드 플랫폼 (기본 linux/amd64) +# REGISTRY 푸시할 레지스트리 (미설정 시 푸시 생략. TAG 도 여기서 유도된다) +# IMAGE_REPO 레지스트리 내 저장소명 (기본 $IMAGE) +# SEVERITY 스캔 심각도 (기본 전 심각도 — 필터하면 목표 판정이 불가능해진다) +# CROSSREF 교차 검증용 참조 리포트 (선택, cve-gate.py 로 전달) +# build.env 의 값은 동일 이름 환경변수로 덮어쓸 수 있다 (예: APP_VERSION=18.5) +# +# 산출물 (OUT_DIR): +# build.log 빌드 로그 +# verify.log 기능 검증 로그 +# sbom/.cdx.json CycloneDX SBOM +# trivy-reports/.json 전 심각도 스캔 결과 (+ CoverageProbe) +# cve-gate.md 게이트 판정 요약 +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +PIPELINE_DIR="$REPO_ROOT/scripts/pipeline" + +OUT_DIR="${1:?사용법: build-hardened-image.sh [TAG]}" +# 기본값을 두지 않는다 — 도입된 자체 빌드 이미지가 아직 없어 어떤 기본값을 골라도 +# 실재하지 않는 images// 를 가리키게 된다. 반드시 명시적으로 지정한다. +IMAGE="${IMAGE:?IMAGE 환경변수 필수 — images// 디렉토리명}" +BASE_OS="${BASE_OS:?BASE_OS 환경변수 필수 — images//.build.env 파일명}" +IMAGE_DIR="$REPO_ROOT/images/$IMAGE" +ENV_FILE="$IMAGE_DIR/$BASE_OS.build.env" + +[ -d "$IMAGE_DIR" ] || { echo "::error::이미지 디렉토리 없음: $IMAGE_DIR"; exit 2; } +[ -f "$ENV_FILE" ] || { echo "::error::build.env 없음: $ENV_FILE"; exit 2; } + +# build.env 는 **기본값**이다. 이미 설정된 환경변수를 덮어쓰지 않는다. +# (`. "$ENV_FILE"` 로 그냥 source 하면 외부 지정이 무시된다) +# 읽은 변수명을 ENV_FILE_VARS 에 함께 기록한다 — 기능 검증 단계에서 이미지별로 어떤 +# 값이 필요한지 이 스크립트가 몰라도 되게, build.env 에 적힌 것을 통째로 verify.sh 에 +# 환경변수로 넘기기 위함이다. +ENV_FILE_VARS=() +while IFS= read -r line; do + case "$line" in ''|'#'*|[[:space:]]*) continue ;; esac + name="${line%%=*}" + case "$name" in ''|*[!A-Za-z0-9_]*) continue ;; esac + ENV_FILE_VARS+=("$name") + if [ -z "${!name+set}" ]; then + eval "$line" + else + echo " (외부 지정 우선: $name=${!name})" + fi +done < "$ENV_FILE" + +PLATFORM="${PLATFORM:-linux/amd64}" +DOCKERFILE="$IMAGE_DIR/${DOCKERFILE:?build.env 에 DOCKERFILE 이 없다}" +TARGET="${TARGET:-patched}" +: "${BUILD_ARGS:?build.env 에 BUILD_ARGS 가 없다}" +# APP_VERSION 이 유일한 이미지 종류 무관 필수값이다 — 태그 프리픽스와 verify.sh 양쪽에 +# 쓰인다. PG_MAJOR 처럼 이미지별로만 의미 있는 값은 여기서 요구하지 않는다(ENV_FILE_VARS +# 전달로 충분하다). +: "${APP_VERSION:?build.env 에 APP_VERSION 이 없다}" + +# 태그에 빌드일을 넣는다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르다. +# 태그를 REGISTRY 에서 유도한다. 예전에는 TAG 기본값이 REGISTRY 와 무관해서, push 하는 +# 곳과 태그가 가리키는 곳이 달랐다 (기본 paasup.io/... 인데 실제로는 docker.io/... 로 push). +BUILD_DATE="$(date -u +%Y%m%d)" +APP_VER="$APP_VERSION" +IMAGE_REPO="${IMAGE_REPO:-$IMAGE}" +DEFAULT_TAG="${APP_VER}-${TAG_SLUG:-$BASE_OS}-hardened-${BUILD_DATE}" +if [ -n "${REGISTRY:-}" ]; then + TAG="${2:-${REGISTRY%/}/${IMAGE_REPO}:${DEFAULT_TAG}}" +else + # push 하지 않는 로컬 빌드. 레지스트리 없는 이름이면 push 를 시도할 수도 없다. + TAG="${2:-localhost/${IMAGE_REPO}:${DEFAULT_TAG}}" +fi + +mkdir -p "$OUT_DIR/sbom" +STEM="$(echo "$TAG" | tr ':/' '__')" + +# build.env 가 선언한 이름만 --build-arg 로 넘긴다. 베이스 OS 마다 인자 집합이 다르다 +# (deb 계열: EXTENSIONS/STANDARD_ADDITIONAL_… / suse: SLE_REPO/PGDG_KEY). +BA=() +for name in $BUILD_ARGS; do + BA+=(--build-arg "$name=${!name-}") +done + +echo "== 빌드 ==" +echo " image=$IMAGE base_os=$BASE_OS target=$TARGET platform=$PLATFORM" +echo " dockerfile=${DOCKERFILE#$REPO_ROOT/}" +echo " tag=$TAG" +# --pull 을 명시한다. 없으면 러너에 남은 로컬 캐시를 쓸 수 있어, 스케줄 재빌드가 +# 전제하는 "베이스 이미지를 매번 새로 받는다" 가 조용히 깨진다. +if ! docker build --pull --platform "$PLATFORM" -f "$DOCKERFILE" --target "$TARGET" \ + "${BA[@]}" -t "$TAG" "$IMAGE_DIR" > "$OUT_DIR/build.log" 2>&1; then + echo "::error::빌드 실패 — $OUT_DIR/build.log 확인"; tail -20 "$OUT_DIR/build.log"; exit 1 +fi +echo " OK" + +echo "== 기능 검증 ==" +# 검증 항목은 이미지 디렉토리가 소유한다. 여기에 하드코딩하면 변종이 늘 때 깨진다. +# +# verify.sh 는 **호스트에서 bash 로 실행**되고, 자신이 필요한 docker run 을 직접 호출한다 +# (게스트 셸에 stdin 으로 스크립트를 주입하는 방식이 아니다). distroless 최종 이미지처럼 +# 셸이 아예 없는 이미지도 있기 때문이다(cloudnative-pg — `--entrypoint sh` 로 들어갈 방법이 +# 없다) — 호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로 +# 게스트 셸을 여전히 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로 +# 직접 실행할 수 있어 상위 호환이다. +VERIFY_SH="$IMAGE_DIR/verify.sh" +[ -f "$VERIFY_SH" ] || { echo "::error::verify.sh 없음: $VERIFY_SH"; exit 2; } +# build.env 에서 읽은 변수를 전부 환경변수로 넘긴다 — verify.sh 가 무엇을 필요로 하는지 +# 이 스크립트가 알 필요가 없어진다. TAG/PLATFORM 은 검증 대상·실행 플랫폼으로 항상 넘긴다. +VERIFY_ENV_ASSIGN=(TAG="$TAG" PLATFORM="$PLATFORM") +for name in "${ENV_FILE_VARS[@]}"; do + VERIFY_ENV_ASSIGN+=("$name=${!name-}") +done +if ! env "${VERIFY_ENV_ASSIGN[@]}" bash "$VERIFY_SH" > "$OUT_DIR/verify.log" 2>&1 \ + || ! grep -q VERIFY-OK "$OUT_DIR/verify.log"; then + echo "::error::기능 검증 실패 — $OUT_DIR/verify.log 확인"; cat "$OUT_DIR/verify.log"; exit 1 +fi +sed -n '1,40p' "$OUT_DIR/verify.log" | sed 's/^/ /' +grep -q 'WARN:' "$OUT_DIR/verify.log" && echo " (경고 있음 — verify.log 확인)" + +echo "== SBOM ==" +docker save "$TAG" -o "$OUT_DIR/image.tar" 2>/dev/null +trivy image --quiet --format cyclonedx --input "$OUT_DIR/image.tar" \ + > "$OUT_DIR/sbom/${STEM}.cdx.json" 2>/dev/null +rm -f "$OUT_DIR/image.tar" +echo " 컴포넌트: $(python3 -c "import json;print(len(json.load(open('$OUT_DIR/sbom/${STEM}.cdx.json')).get('components') or []))" 2>/dev/null || echo '?')" + +# 인덱스는 스캔의 입력이다: chart⇥version⇥image⇥status⇥?⇥sbom파일 +printf 'hardened\t%s\t%s\tOK\t0\t%s.cdx.json\n' "$APP_VERSION" "$TAG" "$STEM" > "$OUT_DIR/sbom-index.tsv" + +echo "== 스캔 (전 심각도 + 커버리지 자가진단) ==" +# scan-sbom.sh 를 재사용한다. 예전에는 여기서 `trivy image` 를 직접 불렀는데, 그러면 +# 리포트에 CoverageProbe 가 없어 게이트가 "데이터 커버리지 이상" 으로 실패한다. +# SLES 기반 이미지는 전 심각도 0건이라 **정상 이미지가 반드시 FAIL 했다.** +# 스캔 로직이 두 곳에 사는 것 자체가 원인이었으므로 한 곳으로 모은다. +# +# 심각도로 필터하지 않는다. 벤더가 낮게 등급한 항목까지 받아야 NVD 기준 재평가가 가능하다. +SEVERITY="${SEVERITY:-UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL}" \ + bash "$PIPELINE_DIR/scan-sbom.sh" "$OUT_DIR" >/dev/null || { + echo "::error::스캔 실패 — $OUT_DIR/trivy-run.log 확인"; exit 1; } +grep 'cov=' "$OUT_DIR/trivy-run.log" 2>/dev/null | sed 's/^/ /' + +echo "== 게이트 판정 ==" +GATE_ARGS=(--reports "$OUT_DIR/trivy-reports" --index "$OUT_DIR/sbom-index.tsv" + --sbom-dir "$OUT_DIR/sbom" --summary-md "$OUT_DIR/cve-gate.md") +[ -f "${CROSSREF:-}" ] && GATE_ARGS+=(--crossref "$CROSSREF") +[ -f "$REPO_ROOT/doc/cve-exceptions.json" ] && GATE_ARGS+=(--exceptions "$REPO_ROOT/doc/cve-exceptions.json") + +python3 "$PIPELINE_DIR/cve-gate.py" "${GATE_ARGS[@]}" >/dev/null +RC=$? + +if [ -n "${REGISTRY:-}" ] && [ "$RC" -eq 0 ]; then + echo "== 푸시 ==" + docker push "$TAG" >/dev/null 2>&1 && echo " $TAG" || echo "::warning::푸시 실패" +fi + +echo +echo "== 결과 ==" +echo " 게이트: $([ "$RC" -eq 0 ] && echo PASS || echo FAIL) 요약: $OUT_DIR/cve-gate.md" +exit "$RC" diff --git a/scripts/build/patch-catalog-tag.py b/scripts/build/patch-catalog-tag.py new file mode 100755 index 0000000..244c9de --- /dev/null +++ b/scripts/build/patch-catalog-tag.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""카탈로그 values 파일의 자체 빌드 이미지 태그를 갱신한다. + +build-image.yml 이 호출한다. 카탈로그가 실제로 쓰는 두 표기 스타일을 지원한다: + - imageName: 단일 필드 문자열 (cnpg-cluster 유형 — `imageName: "repo:tag"`) + - split: image:/initImage: 등 블록 아래 registry/repository/tag 세 필드로 분리 + (cloudnative-pg, etcd 유형) + +텍스트 치환만 한다(YAML 파서를 쓰지 않는다) — 기존 주석·포매팅을 그대로 보존하기 +위함이다(기존 cnpg-cluster 워크플로의 sed 방식과 같은 원칙). 예상한 패턴을 하나도 +찾지 못하면 실패한다(조용히 건너뛰지 않는다) — 잘못된 치환보다 실패가 낫다. +""" +import argparse +import pathlib +import re +import sys + + +def split_tag(full): + repo_full, tag = full.rsplit(":", 1) + if "/" in repo_full: + registry, repo = repo_full.rsplit("/", 1) + else: + registry, repo = "", repo_full + return registry, repo, tag + + +def read_image_name(text): + m = re.search(r'imageName:\s*"([^"]+)"', 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) + if not m: + return None + body = m.group(1) + values = {} + for name in ("registry", "repository", "tag"): + fm = re.search(rf'^\s*{name}:\s*"?([^"\n]+?)"?\s*$', body, re.MULTILINE) + values[name] = fm.group(1) if fm else "" + if not values["repository"] or not values["tag"]: + return None + return "/".join(p for p in (values["registry"], values["repository"]) if p) + ":" + values["tag"] + + +def patch_image_name(text, old, new): + pattern = f'imageName: "{old}"' + if pattern not in text: + return None + return text.replace(pattern, f'imageName: "{new}"') + + +def patch_split_block(text, block, old, new): + old_registry, old_repo, old_tag = split_tag(old) + new_registry, new_repo, new_tag = split_tag(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: + return None + + body = m.group(1) + changed = False + for name, old_v, new_v in ( + ("registry", old_registry, new_registry), + ("repository", old_repo, new_repo), + ("tag", old_tag, new_tag), + ): + if not old_v: + continue + field_re = re.compile(rf'(^\s*{name}:\s*)"{re.escape(old_v)}"', re.MULTILINE) + new_body, n = field_re.subn(rf'\1"{new_v}"', body) + if n: + body = new_body + changed = True + + if not changed: + return None + return text[: m.start(1)] + body + text[m.end(1) :] + + +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("--read", metavar="FILE", help="현재 태그만 읽어 출력하고 종료 (patch 안 함)") + ap.add_argument("--old", help="이전 전체 태그 (registry/repo:tag) — --read 아닐 때 필수") + ap.add_argument("--new", help="새 전체 태그 (registry/repo:tag) — --read 아닐 때 필수") + ap.add_argument("files", nargs="*", help="patch 대상 후보 파일들 (없으면 건너뜀)") + args = ap.parse_args() + + if args.read: + text = pathlib.Path(args.read).read_text() + current = read_image_name(text) if args.style == "imageName" else read_split_block(text, args.block) + if current is None: + print(f"::error::{args.read} 에서 현재 태그를 읽지 못했다 (style={args.style})", file=sys.stderr) + sys.exit(1) + print(current) + return + + if not args.old or not args.new or not args.files: + ap.error("patch 모드에서는 --old/--new/files 가 모두 필요하다") + + touched = [] + for f in args.files: + p = pathlib.Path(f) + if not p.is_file(): + continue + text = p.read_text() + if args.style == "imageName": + result = patch_image_name(text, args.old, args.new) + else: + result = patch_split_block(text, args.block, args.old, args.new) + if result is not None: + p.write_text(result) + touched.append(f) + + if not touched: + print( + f"::error::'{args.old}' 패턴을 어떤 파일에서도 찾지 못했다 — 대상: {args.files}", + file=sys.stderr, + ) + sys.exit(1) + + print("갱신됨: " + ", ".join(touched)) + + +if __name__ == "__main__": + main()