자체 빌드 이미지 프레임워크 도입 (실사용 이미지 없음)
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:
Executable
+185
@@ -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"
|
||||
Executable
+133
@@ -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()
|
||||
Reference in New Issue
Block a user