자체 빌드 이미지 프레임워크 도입 (실사용 이미지 없음)

security-catalog 에서 포팅: build-hardened-image.sh, patch-catalog-tag.py,
build-image.yml(REGISTRY_HOST=docker.io/paasup). images/ 는 아직 비어있다 —
베이스 OS 정책 미결 등은 .claude/image-authoring.md, MEMORY.md 참고.
This commit is contained in:
wbsong111
2026-08-03 09:55:52 +09:00
parent 20d7a41193
commit 844c567d0d
4 changed files with 727 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# 자체 빌드 이미지 작업 규칙
[CLAUDE.md](../CLAUDE.md) 에서 분리했다. 새 자체 빌드 이미지를 추가하거나(CVE 게이트
대응 우선순위 중 "자체 빌드") 기존 이미지의 빌드 정의를 바꿀 때만 참고한다.
security-catalog 레포에서 검증한 자체 빌드 프레임워크를 포팅했다. **이 시점에는 아직
이 레포에 도입된 자체 빌드 이미지가 없다** — `images/` 디렉토리 자체가 없다. 아래는
프레임워크가 어떻게 동작하는지와, 첫 이미지를 추가할 때 지켜야 할 규칙이다.
## 원칙 1 — 오케스트레이션은 항상 하나, 이미지 종류는 몰라도 된다
**`scripts/build/build-hardened-image.sh` 하나가 모든 자체 빌드 이미지를 빌드한다.**
이미지가 OS 패키지를 재설치하는 것이든, 소스를 직접 컴파일하는 것이든 스크립트는
같다 — 차이는 전부 `images/<image>/` 안에 있다.
**새 오케스트레이션 스크립트를 만드는 것은 최후의 수단이다.** "이 이미지는 성격이
다르다"는 이유만으로 새 스크립트를 만들지 않는다. 절차(빌드 → 기능검증 → SBOM → 스캔
→ 게이트 → push)는 이미지 종류와 무관하게 동일하고, 차이는 Dockerfile 내부(무엇을
어떻게 설치·컴파일하는가)에만 있어야 한다.
### `build-hardened-image.sh` 가 요구하는 계약
`images/<image>/<variant>.build.env` 가 다음을 선언하면 스크립트는 이미지 종류를
몰라도 된다:
| 키 | 의미 |
| --- | --- |
| `DOCKERFILE` | `images/<image>/` 기준 상대 경로 |
| `TARGET` | `docker build --target` 에 넘길 스테이지명 |
| `BUILD_ARGS` | 공백 구분 변수명 목록. 여기 나열한 것만 `--build-arg` 로 전달된다 |
| `APP_VERSION` | 태그 프리픽스·`verify.sh` 전달용 범용 버전 문자열 (유일한 필수값) |
그 외 이미지별 변수(예: `PG_MAJOR`, `SOURCE_COMMIT`)는 build.env 에 적기만 하면 **자동으로
`verify.sh` 의 환경변수로 전달된다** — 스크립트가 무엇을 넘겨야 하는지 알 필요가 없다.
### `verify.sh` 는 호스트에서 bash 로 실행된다
게스트 컨테이너에 stdin 으로 셸 스크립트를 주입하는 방식이 아니다 —
`env TAG=... PLATFORM=... <build.env 변수들> bash images/<image>/verify.sh` 로 호출된다.
**이유**: 이미지에 셸이 없을 수 있다(distroless 계열 최종 이미지는 `/bin/sh` 가 없다).
호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로 게스트
스크립트를 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로 직접
실행할 수 있다 — 호스트 실행이 상위 호환이다. 마지막 줄에 `VERIFY-OK` 를 출력해야
통과로 판정된다.
## 원칙 2 — 최종 런타임 베이스 OS 는 아직 미결
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` 등으로 직접 컴파일 | 최종 베이스에 따라 다름 |
어느 유형이든 이 셋만 새로 쓰면 된다: `<variant>.Dockerfile`, `<variant>.build.env`,
`verify.sh`(+ `README.md`).
## 신규 이미지 추가 체크리스트
1. **상위 태그 교체 → 베이스 OS 교체 순으로 먼저 검토했는가.** 그것으로 해소되면
자체 빌드로 가지 않는다. 특히 CVE 가 OS 패키지가 아니라 애플리케이션/바이너리 자체에
정적으로 포함된 것이면(예: Go 모듈, 정적 링크된 라이브러리) 베이스 OS 교체는
원천적으로 통하지 않는다 — 이 판단 근거를 남긴다(PR 설명 또는 `MEMORY.md`)
2. **어느 유형인지 판단한다** ("업스트림 산출물을 다른 배포판에 재설치" vs "소스를 직접
컴파일"). 최종 베이스 OS 를 이때 정한다(원칙 2)
3. `images/<image>/<variant>.Dockerfile`·`<variant>.build.env`·`verify.sh`·`README.md`
작성. 업스트림 Dockerfile 과의 대응 관계·차이를 파일 상단 주석으로 남긴다.
**`FROM` 에 쓰는 `ARG` 는 반드시 파일의 첫 `FROM` 이전(전역 스코프)에 선언한다** —
스테이지 내부(어떤 `FROM` 뒤)에 선언하면 그 스테이지 지역 변수가 되어 이후 `FROM`
이미지명 해석에 쓰이지 않고 빈 이미지명 에러가 난다
4. 로컬 빌드:
```sh
IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh /tmp/out
```
`cve-gate.md` 로 실효 C/H 0 확인. 커버리지 자가진단(`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/<image>/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` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다.
+303
View File
@@ -0,0 +1,303 @@
name: self-build-image
# 자체 빌드 이미지(images/<image>, 실행기 scripts/build/build-hardened-image.sh)의
# 빌드·검증·스캔·게이트·카탈로그 반영을 자동화한다. security-catalog 의 동일 워크플로에서
# 프레임워크만 포팅했다 — 아직 이 레포에 도입된 자체 빌드 이미지는 없다(images/ 디렉토리
# 자체가 비어 있음). 게이트(scripts/pipeline/cve-gate.py)가 상위 태그·베이스 OS 교체로
# 해소되지 않는 차단 CVE 를 찾으면 이 워크플로로 자체 빌드를 검토한다 —
# 절차: .claude/image-authoring.md
#
# "이미지가 어느 차트의 어느 필드를 가리키는가" 는 images/<image>/catalog.env 가 선언한다
# (CHART_DIRS·TAG_STYLE·TAG_BLOCK·DEFAULT_BASE_OS). 태그 표기 스타일이 두 가지다:
# imageName 단일 필드 문자열 (`imageName: "repo:tag"`)
# split registry/repository/tag 세 필드로 분리된 블록
# 둘 다 scripts/build/patch-catalog-tag.py 하나로 다룬다(YAML 파서 없이 텍스트 치환만
# 해서 기존 주석·포매팅을 보존한다 — 예상 패턴을 못 찾으면 조용히 넘어가지 않고 실패한다).
#
# 이 워크플로는 helm-catalog-sbom(sbom.yml)과 별도 파일이다. sbom.yml 은
# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다.
# 빌드는 호스트 러너여야 한다.
#
# 트리거 2종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다.
# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 PR 생성 여부뿐이다.
#
# pull_request(images/**) 변경된 images/<image>/ 디렉토리를 diff 로 자동 탐지해 그
# 이미지들만 검증한다(push·카탈로그 PR 생성 없음). 여러 이미지가
# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다.
# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 PR
# 생성은 이 트리거로만 일어난다(사람이 수동 실행) — sbom.yml 의
# 게이트는 현재 이 워크플로를 자동으로 호출하지 않는다.
# push 입력이 false 면 검증만 한다.
on:
workflow_dispatch:
inputs:
image:
description: '빌드할 이미지 디렉토리명 (images/<image>/)'
required: true
base_os:
description: '빌드 변종 (images/<image>/<base_os>.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/<image>/ 아래 변경이 있는
# 디렉토리를 전부 찾는다(여러 이미지가 한 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/<image>/catalog.env 를 추가해야 한다"; exit 1; }
# shellcheck disable=SC1090
. "$ENV_FILE"
: "${DEFAULT_BASE_OS:?catalog.env 에 DEFAULT_BASE_OS 가 없다}"
: "${CHART_DIRS:?catalog.env 에 CHART_DIRS 가 없다}"
: "${TAG_STYLE:?catalog.env 에 TAG_STYLE 이 없다}"
BASE_OS="${{ github.event.inputs.base_os }}"
BASE_OS="${BASE_OS:-$DEFAULT_BASE_OS}"
{
echo "base_os=$BASE_OS"
echo "chart_dirs=$CHART_DIRS"
echo "tag_style=$TAG_STYLE"
echo "tag_block=${TAG_BLOCK:-image}"
} >> "$GITHUB_OUTPUT"
echo " image=${{ matrix.image }} base_os=$BASE_OS chart_dirs=$CHART_DIRS tag_style=$TAG_STYLE"
# push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면
# push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다.
- 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] <github-actions[bot]@users.noreply.github.com>"
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"
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# =============================================================================
# build-hardened-image.sh
# 업스트림 이미지가 CRITICAL/HIGH 0건 목표를 만족하지 못할 때, 업스트림 Dockerfile 을
# 기준으로 베이스 OS 를 교체하고 보안 업데이트를 적용한 이미지를 빌드·검증한다.
#
# 빌드 → 기능 검증 → 취약점 스캔 → 게이트 판정 까지 한 번에 수행한다.
# 기능 검증을 통과하지 못하면 스캔으로 넘어가지 않는다 (0건이어도 못 쓰는 이미지는 무의미).
#
# 사용:
# IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh <OUT_DIR> [TAG]
# REGISTRY=docker.io/paasup IMAGE=<image> BASE_OS=<variant> bash scripts/build/build-hardened-image.sh <OUT_DIR>
#
# 빌드 정의는 이 스크립트에 없다. images/<IMAGE>/<BASE_OS>.build.env 를 source 해서
# 베이스·버전·확장·build-arg 목록을 읽고, 기능 검증은 images/<IMAGE>/verify.sh 에 위임한다.
# (베이스 OS 가 둘 이상이 되면 하드코딩된 검증이 깨지기 때문이다 — 실제로 그렇게 됐었다)
#
# 이미지 종류(OS 패키지 설치형·소스 컴파일형 등)에 무관하게 이 스크립트 하나를 쓴다 —
# build.env 가 선언하는 것 이상을 이 스크립트가 알지 못하게 하는 게 원칙이다. build.env 가
# 요구하는 값은 APP_VERSION(태그·verify.sh 전달용) 하나뿐이고, 그 외 이미지별 변수는
# build.env 에 무엇을 적든 자동으로 verify.sh 의 환경변수로 전달된다(아래 참고).
#
# 환경변수:
# IMAGE 이미지 디렉토리명 (필수 — images/<IMAGE>/. 기본값 없음: 아직 도입된
# 자체 빌드 이미지가 없어 어떤 기본값도 실재하지 않는 이미지를 가리킨다)
# BASE_OS 빌드 변종 파일명 (필수 — images/<IMAGE>/<BASE_OS>.build.env. 베이스
# OS 정책은 아직 미결이다 — 처음 도입하는 이미지에서 정한다, MEMORY.md 참고)
# PLATFORM 빌드 플랫폼 (기본 linux/amd64)
# REGISTRY 푸시할 레지스트리 (미설정 시 푸시 생략. TAG 도 여기서 유도된다)
# IMAGE_REPO 레지스트리 내 저장소명 (기본 $IMAGE)
# SEVERITY 스캔 심각도 (기본 전 심각도 — 필터하면 목표 판정이 불가능해진다)
# CROSSREF 교차 검증용 참조 리포트 (선택, cve-gate.py 로 전달)
# build.env 의 값은 동일 이름 환경변수로 덮어쓸 수 있다 (예: APP_VERSION=18.5)
#
# 산출물 (OUT_DIR):
# build.log 빌드 로그
# verify.log 기능 검증 로그
# sbom/<tag>.cdx.json CycloneDX SBOM
# trivy-reports/<tag>.json 전 심각도 스캔 결과 (+ CoverageProbe)
# cve-gate.md 게이트 판정 요약
# =============================================================================
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PIPELINE_DIR="$REPO_ROOT/scripts/pipeline"
OUT_DIR="${1:?사용법: build-hardened-image.sh <OUT_DIR> [TAG]}"
# 기본값을 두지 않는다 — 도입된 자체 빌드 이미지가 아직 없어 어떤 기본값을 골라도
# 실재하지 않는 images/<IMAGE>/ 를 가리키게 된다. 반드시 명시적으로 지정한다.
IMAGE="${IMAGE:?IMAGE 환경변수 필수 — images/<IMAGE>/ 디렉토리명}"
BASE_OS="${BASE_OS:?BASE_OS 환경변수 필수 — images/<IMAGE>/<BASE_OS>.build.env 파일명}"
IMAGE_DIR="$REPO_ROOT/images/$IMAGE"
ENV_FILE="$IMAGE_DIR/$BASE_OS.build.env"
[ -d "$IMAGE_DIR" ] || { echo "::error::이미지 디렉토리 없음: $IMAGE_DIR"; exit 2; }
[ -f "$ENV_FILE" ] || { echo "::error::build.env 없음: $ENV_FILE"; exit 2; }
# build.env 는 **기본값**이다. 이미 설정된 환경변수를 덮어쓰지 않는다.
# (`. "$ENV_FILE"` 로 그냥 source 하면 외부 지정이 무시된다)
# 읽은 변수명을 ENV_FILE_VARS 에 함께 기록한다 — 기능 검증 단계에서 이미지별로 어떤
# 값이 필요한지 이 스크립트가 몰라도 되게, build.env 에 적힌 것을 통째로 verify.sh 에
# 환경변수로 넘기기 위함이다.
ENV_FILE_VARS=()
while IFS= read -r line; do
case "$line" in ''|'#'*|[[:space:]]*) continue ;; esac
name="${line%%=*}"
case "$name" in ''|*[!A-Za-z0-9_]*) continue ;; esac
ENV_FILE_VARS+=("$name")
if [ -z "${!name+set}" ]; then
eval "$line"
else
echo " (외부 지정 우선: $name=${!name})"
fi
done < "$ENV_FILE"
PLATFORM="${PLATFORM:-linux/amd64}"
DOCKERFILE="$IMAGE_DIR/${DOCKERFILE:?build.env 에 DOCKERFILE 이 없다}"
TARGET="${TARGET:-patched}"
: "${BUILD_ARGS:?build.env 에 BUILD_ARGS 가 없다}"
# APP_VERSION 이 유일한 이미지 종류 무관 필수값이다 — 태그 프리픽스와 verify.sh 양쪽에
# 쓰인다. PG_MAJOR 처럼 이미지별로만 의미 있는 값은 여기서 요구하지 않는다(ENV_FILE_VARS
# 전달로 충분하다).
: "${APP_VERSION:?build.env 에 APP_VERSION 이 없다}"
# 태그에 빌드일을 넣는다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르다.
# 태그를 REGISTRY 에서 유도한다. 예전에는 TAG 기본값이 REGISTRY 와 무관해서, push 하는
# 곳과 태그가 가리키는 곳이 달랐다 (기본 paasup.io/... 인데 실제로는 docker.io/... 로 push).
BUILD_DATE="$(date -u +%Y%m%d)"
APP_VER="$APP_VERSION"
IMAGE_REPO="${IMAGE_REPO:-$IMAGE}"
DEFAULT_TAG="${APP_VER}-${TAG_SLUG:-$BASE_OS}-hardened-${BUILD_DATE}"
if [ -n "${REGISTRY:-}" ]; then
TAG="${2:-${REGISTRY%/}/${IMAGE_REPO}:${DEFAULT_TAG}}"
else
# push 하지 않는 로컬 빌드. 레지스트리 없는 이름이면 push 를 시도할 수도 없다.
TAG="${2:-localhost/${IMAGE_REPO}:${DEFAULT_TAG}}"
fi
mkdir -p "$OUT_DIR/sbom"
STEM="$(echo "$TAG" | tr ':/' '__')"
# build.env 가 선언한 이름만 --build-arg 로 넘긴다. 베이스 OS 마다 인자 집합이 다르다
# (deb 계열: EXTENSIONS/STANDARD_ADDITIONAL_… / suse: SLE_REPO/PGDG_KEY).
BA=()
for name in $BUILD_ARGS; do
BA+=(--build-arg "$name=${!name-}")
done
echo "== 빌드 =="
echo " image=$IMAGE base_os=$BASE_OS target=$TARGET platform=$PLATFORM"
echo " dockerfile=${DOCKERFILE#$REPO_ROOT/}"
echo " tag=$TAG"
# --pull 을 명시한다. 없으면 러너에 남은 로컬 캐시를 쓸 수 있어, 스케줄 재빌드가
# 전제하는 "베이스 이미지를 매번 새로 받는다" 가 조용히 깨진다.
if ! docker build --pull --platform "$PLATFORM" -f "$DOCKERFILE" --target "$TARGET" \
"${BA[@]}" -t "$TAG" "$IMAGE_DIR" > "$OUT_DIR/build.log" 2>&1; then
echo "::error::빌드 실패 — $OUT_DIR/build.log 확인"; tail -20 "$OUT_DIR/build.log"; exit 1
fi
echo " OK"
echo "== 기능 검증 =="
# 검증 항목은 이미지 디렉토리가 소유한다. 여기에 하드코딩하면 변종이 늘 때 깨진다.
#
# verify.sh 는 **호스트에서 bash 로 실행**되고, 자신이 필요한 docker run 을 직접 호출한다
# (게스트 셸에 stdin 으로 스크립트를 주입하는 방식이 아니다). distroless 최종 이미지처럼
# 셸이 아예 없는 이미지도 있기 때문이다(cloudnative-pg — `--entrypoint sh` 로 들어갈 방법이
# 없다) — 호스트 스크립트는 셸이 있는 이미지엔 `docker run --entrypoint sh ... <<'EOF'` 로
# 게스트 셸을 여전히 쓸 수 있고, 셸이 없는 이미지엔 `docker run --entrypoint <바이너리>` 로
# 직접 실행할 수 있어 상위 호환이다.
VERIFY_SH="$IMAGE_DIR/verify.sh"
[ -f "$VERIFY_SH" ] || { echo "::error::verify.sh 없음: $VERIFY_SH"; exit 2; }
# build.env 에서 읽은 변수를 전부 환경변수로 넘긴다 — verify.sh 가 무엇을 필요로 하는지
# 이 스크립트가 알 필요가 없어진다. TAG/PLATFORM 은 검증 대상·실행 플랫폼으로 항상 넘긴다.
VERIFY_ENV_ASSIGN=(TAG="$TAG" PLATFORM="$PLATFORM")
for name in "${ENV_FILE_VARS[@]}"; do
VERIFY_ENV_ASSIGN+=("$name=${!name-}")
done
if ! env "${VERIFY_ENV_ASSIGN[@]}" bash "$VERIFY_SH" > "$OUT_DIR/verify.log" 2>&1 \
|| ! grep -q VERIFY-OK "$OUT_DIR/verify.log"; then
echo "::error::기능 검증 실패 — $OUT_DIR/verify.log 확인"; cat "$OUT_DIR/verify.log"; exit 1
fi
sed -n '1,40p' "$OUT_DIR/verify.log" | sed 's/^/ /'
grep -q 'WARN:' "$OUT_DIR/verify.log" && echo " (경고 있음 — verify.log 확인)"
echo "== SBOM =="
docker save "$TAG" -o "$OUT_DIR/image.tar" 2>/dev/null
trivy image --quiet --format cyclonedx --input "$OUT_DIR/image.tar" \
> "$OUT_DIR/sbom/${STEM}.cdx.json" 2>/dev/null
rm -f "$OUT_DIR/image.tar"
echo " 컴포넌트: $(python3 -c "import json;print(len(json.load(open('$OUT_DIR/sbom/${STEM}.cdx.json')).get('components') or []))" 2>/dev/null || echo '?')"
# 인덱스는 스캔의 입력이다: chart⇥version⇥image⇥status⇥?⇥sbom파일
printf 'hardened\t%s\t%s\tOK\t0\t%s.cdx.json\n' "$APP_VERSION" "$TAG" "$STEM" > "$OUT_DIR/sbom-index.tsv"
echo "== 스캔 (전 심각도 + 커버리지 자가진단) =="
# scan-sbom.sh 를 재사용한다. 예전에는 여기서 `trivy image` 를 직접 불렀는데, 그러면
# 리포트에 CoverageProbe 가 없어 게이트가 "데이터 커버리지 이상" 으로 실패한다.
# SLES 기반 이미지는 전 심각도 0건이라 **정상 이미지가 반드시 FAIL 했다.**
# 스캔 로직이 두 곳에 사는 것 자체가 원인이었으므로 한 곳으로 모은다.
#
# 심각도로 필터하지 않는다. 벤더가 낮게 등급한 항목까지 받아야 NVD 기준 재평가가 가능하다.
SEVERITY="${SEVERITY:-UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL}" \
bash "$PIPELINE_DIR/scan-sbom.sh" "$OUT_DIR" >/dev/null || {
echo "::error::스캔 실패 — $OUT_DIR/trivy-run.log 확인"; exit 1; }
grep 'cov=' "$OUT_DIR/trivy-run.log" 2>/dev/null | sed 's/^/ /'
echo "== 게이트 판정 =="
GATE_ARGS=(--reports "$OUT_DIR/trivy-reports" --index "$OUT_DIR/sbom-index.tsv"
--sbom-dir "$OUT_DIR/sbom" --summary-md "$OUT_DIR/cve-gate.md")
[ -f "${CROSSREF:-}" ] && GATE_ARGS+=(--crossref "$CROSSREF")
[ -f "$REPO_ROOT/doc/cve-exceptions.json" ] && GATE_ARGS+=(--exceptions "$REPO_ROOT/doc/cve-exceptions.json")
python3 "$PIPELINE_DIR/cve-gate.py" "${GATE_ARGS[@]}" >/dev/null
RC=$?
if [ -n "${REGISTRY:-}" ] && [ "$RC" -eq 0 ]; then
echo "== 푸시 =="
docker push "$TAG" >/dev/null 2>&1 && echo " $TAG" || echo "::warning::푸시 실패"
fi
echo
echo "== 결과 =="
echo " 게이트: $([ "$RC" -eq 0 ] && echo PASS || echo FAIL) 요약: $OUT_DIR/cve-gate.md"
exit "$RC"
+133
View File
@@ -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()