자체 빌드 이미지 프레임워크를 security-images 레포로 이관하고 카탈로그 쪽을 정리한다
images/·scripts/build/build-hardened-image.sh·suggest-go-upgrades.py·
build-image.yml·.claude/image-authoring.md·이미지 ADR(0001·0002·0004)을 삭제했다 —
전부 별도 public 레포 security-images 로 이미 이관됐다.
카탈로그 쪽에는 "무엇을 배포 중인가"를 아는 부분만 남긴다:
- catalog/image-map/<image>.env — 옛 catalog.env 의 카탈로그 레이아웃 정보만 뗀 것
- scripts/build/check-rebuild-needed.py — 드리프트 탐지(A 파트)만 남기고 핀 판단
(B 파트: pin_changes/apply_changes/parse_module_specs)은 제거
- scripts/build/apply-published-tags.py(신규) — security-images 의 published.json
을 읽어 카탈로그 values 를 패치
- .github/workflows/{self-build-drift-check,catalog-tag-update}.yml(신규) — 각각
드리프트 스캔+트리거, 발행 태그 반영
effective_severity 를 cve-gate.py 로 옮겼다 — check-rebuild-needed.py 가 핀 도구를
거치지 않고 게이트를 직접 로드하게 하기 위한 선행 작업이다.
두 레포의 계약은 published.json 스키마 하나뿐이다 — security-images 는 이 카탈로그를
모른다(단방향 의존). 이관 배경·결합점 전체는
doc/migrations/self-build-images-to-security-images.md.
부수 수정: 자체 빌드 이미지를 참조하는 차트 values/README 의 죽은 링크(images/**,
doc/decisions/000{1,2,4}, .claude/image-authoring.md)를 security-images 레포를
가리키는 서술로 교체. deploy-test 스크립트·CUSTOM-README 의 개인 Docker Hub 계정
(docker.io/wbsong111) 을 docker.io/paasup 로 교체.
pitfalls.md 의 "스캐너 결과를 그대로 믿지 말 것" 절은 sbom-cve-gate skill 이 차트
축 설명에 실제로 참조하고 있어 남겼다 — "이미지 태그의 베이스 OS" 절만 제거했다
(다른 참조 없음, security-images 문서로 이관 완료).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""apply-published-tags.py — security-images 의 `published.json` 을 읽어 카탈로그
|
||||
values 의 자체 빌드 이미지 태그를 갱신한다.
|
||||
|
||||
카탈로그와 security-images(자체 빌드 이미지 레포)의 계약은 `published.json` 파일
|
||||
스키마 하나뿐이다 — 그 레포는 게이트 PASS + push 가 실제로 일어났을 때만 이 파일을
|
||||
갱신한다. 이 스크립트는 그 파일과 `catalog/image-map/<image>.env`(어느 차트의 어느
|
||||
필드를 갱신할지)를 대조해 `custom-values.yaml`/`dip-values.yaml` 을 패치한다.
|
||||
|
||||
`gate != "pass"` 인 이미지는 건너뛴다 — 실패한 빌드의 태그를 반영하면 안 된다.
|
||||
|
||||
이 스크립트는 파일만 고친다. 브랜치 생성·커밋·push 는 호출자(워크플로)의 책임이다 —
|
||||
그래야 "무엇이 바뀌었는지" 를 워크플로가 커밋 메시지에 정확히 담을 수 있다.
|
||||
|
||||
사용
|
||||
----
|
||||
python3 scripts/build/apply-published-tags.py --published published.json
|
||||
python3 scripts/build/apply-published-tags.py --published published.json --dry-run
|
||||
python3 scripts/build/apply-published-tags.py --published published.json --image etcd
|
||||
|
||||
종료 코드
|
||||
0 정상 (변경 사항 유무와 무관)
|
||||
2 실행 오류
|
||||
"""
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
|
||||
IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map")
|
||||
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
|
||||
|
||||
|
||||
PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag")
|
||||
|
||||
|
||||
def read_env(path):
|
||||
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)
|
||||
out[k] = v.strip().strip('"').strip("'")
|
||||
return out
|
||||
|
||||
|
||||
def read_current(text, style, block):
|
||||
return (PATCH.read_image_name(text) if style == "imageName"
|
||||
else PATCH.read_split_block(text, block))
|
||||
|
||||
|
||||
def patch_text(text, style, block, old, new):
|
||||
return (PATCH.patch_image_name(text, old, new) if style == "imageName"
|
||||
else PATCH.patch_split_block(text, block, old, new))
|
||||
|
||||
|
||||
def apply_for_image(image, new_ref, dry_run):
|
||||
"""반환: [{"file", "old", "new"}, ...] — 실제로 바뀐(또는 --dry-run 이면 바뀔) 파일들."""
|
||||
chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env"))
|
||||
chart_dirs = (chart_env.get("CHART_DIRS") or "").split()
|
||||
style = chart_env.get("TAG_STYLE") or ""
|
||||
block = chart_env.get("TAG_BLOCK") or "image"
|
||||
if not chart_dirs or style not in ("imageName", "split"):
|
||||
print(f"::warning::{image}: catalog/image-map/{image}.env 에 CHART_DIRS/TAG_STYLE 이 없다",
|
||||
file=sys.stderr)
|
||||
return []
|
||||
|
||||
changed = []
|
||||
for d in chart_dirs:
|
||||
for fname in VALUES_CANDIDATES:
|
||||
p = pathlib.Path(ROOT, d, fname)
|
||||
if not p.is_file():
|
||||
continue
|
||||
text = p.read_text()
|
||||
current = read_current(text, style, block)
|
||||
if current is None:
|
||||
continue
|
||||
if current == new_ref:
|
||||
continue
|
||||
new_text = patch_text(text, style, block, current, new_ref)
|
||||
if new_text is None:
|
||||
print(f"::warning::{image}: {p} 에서 '{current}' 패턴을 찾지 못해 건너뛴다",
|
||||
file=sys.stderr)
|
||||
continue
|
||||
if not dry_run:
|
||||
p.write_text(new_text)
|
||||
changed.append({"file": os.path.relpath(p, ROOT), "old": current, "new": new_ref})
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--published", required=True, metavar="FILE",
|
||||
help="security-images 의 published.json (로컬 경로 — 원격 조회는 호출자가 한다)")
|
||||
ap.add_argument("--image", default="", help="이 이미지만 반영 (기본: published.json 의 전체)")
|
||||
ap.add_argument("--dry-run", action="store_true", help="무엇이 바뀔지 보고만 하고 파일은 건드리지 않는다")
|
||||
ap.add_argument("--json-out", metavar="FILE", help="변경 목록을 JSON 으로 이 파일에 쓴다")
|
||||
ap.add_argument("--summary-md", metavar="FILE", help="변경 요약을 마크다운으로 이 파일에 쓴다")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(args.published):
|
||||
print(f"::error::published.json 을 찾지 못했다: {args.published}", file=sys.stderr)
|
||||
return 2
|
||||
with open(args.published) as f:
|
||||
doc = json.load(f)
|
||||
|
||||
images = doc.get("images") or {}
|
||||
names = [args.image] if args.image else sorted(images)
|
||||
|
||||
all_changes = []
|
||||
for name in names:
|
||||
entry = images.get(name)
|
||||
if entry is None:
|
||||
print(f"::warning::{name}: published.json 에 없다", file=sys.stderr)
|
||||
continue
|
||||
if entry.get("gate") != "pass":
|
||||
print(f"::warning::{name}: gate={entry.get('gate')!r} — 건너뛴다", file=sys.stderr)
|
||||
continue
|
||||
ref = entry.get("ref")
|
||||
if not ref:
|
||||
continue
|
||||
changes = apply_for_image(name, ref, args.dry_run)
|
||||
for c in changes:
|
||||
all_changes.append({"image": name, **c})
|
||||
print(f"{'(dry-run) ' if args.dry_run else ''}{name}: {c['file']} "
|
||||
f"{c['old']} -> {c['new']}")
|
||||
|
||||
if not all_changes:
|
||||
print("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다")
|
||||
|
||||
if args.json_out:
|
||||
pathlib.Path(args.json_out).write_text(json.dumps(all_changes, ensure_ascii=False, indent=2) + "\n")
|
||||
if args.summary_md:
|
||||
lines = ["## 자체 빌드 이미지 태그 반영", ""]
|
||||
if not all_changes:
|
||||
lines.append("변경 없음 — 카탈로그가 이미 최신 발행 태그를 가리킨다.")
|
||||
else:
|
||||
lines += ["| 이미지 | 파일 | 이전 태그 | 새 태그 |", "|---|---|---|---|"]
|
||||
for c in all_changes:
|
||||
lines.append(f"| `{c['image']}` | `{c['file']}` | `{c['old']}` | `{c['new']}` |")
|
||||
pathlib.Path(args.summary_md).write_text("\n".join(lines) + "\n")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,185 +0,0 @@
|
||||
#!/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"
|
||||
@@ -4,25 +4,21 @@
|
||||
왜 필요한가
|
||||
-----------
|
||||
자체 빌드 이미지는 **소스를 안 바꿔도 시간이 지나면 게이트를 벗어난다.** 새 CVE 가 공개되고,
|
||||
베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 push 해 둔 이미지는 그대로다.
|
||||
2026-08-19 에 etcd·cloudnative-pg 가 이 상태로 차단됐는데 **문서 전용 PR 이 우연히
|
||||
`images/**` 를 건드려 검증 빌드가 돌면서야** 발견됐다. 의도적으로 보는 장치가 없었다.
|
||||
베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 참조해 둔 태그는 그대로다.
|
||||
의도적으로 보는 장치가 없으면 문서 전용 PR 이 우연히 검증 빌드를 돌릴 때만 발견된다.
|
||||
|
||||
`build-image.yml` 이 주간으로 이것을 돌려 재빌드까지 한다.
|
||||
`self-build-drift-check.yml` 이 주간으로 이것을 돌린다.
|
||||
|
||||
무엇이 "재빌드로 고쳐지는가" 인가 — 세 경우 모두 그렇다
|
||||
-----------------------------------------------------
|
||||
처음에는 "핀이 뒤처진 경우" 만 트리거로 잡았는데 **실측에서 그게 틀렸다.** 배포 중인 이미지
|
||||
8개를 스캔했더니 차단이 있는 5개 중 핀 변경이 필요한 것은 하나도 없었다.
|
||||
무엇이 "재빌드로 고쳐지는가" 인가
|
||||
--------------------------------
|
||||
"핀이 뒤처진 경우" 만으로는 부족하다. 재빌드로 고쳐지는 경우는 셋이다.
|
||||
|
||||
1. 핀이 뒤처졌다 → 핀을 올리고 빌드 (GO_BUILDER_TAG 등)
|
||||
1. 핀이 뒤처졌다 → 핀을 올리고 빌드
|
||||
2. 핀은 맞는데 이미지가 그 핀으로 안 빌드됐다 → 그냥 빌드
|
||||
(핀만 고친 PR 이 머지되고 재빌드가 아직 안 된 상태)
|
||||
3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드
|
||||
(재빌드하면 zypper 가 최신을 깐다 — 핀과 무관하다)
|
||||
3. 베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 (재빌드하면 zypper 가 최신을 깐다)
|
||||
|
||||
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 적용하는
|
||||
부수 작업이고, 트리거 조건이 아니다.
|
||||
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 이 스크립트는 그 판정만
|
||||
한다 — 핀을 무엇으로 올려야 하는지는 판단하지 않는다(아래 "레포 분리" 참고).
|
||||
|
||||
수정 버전이 없는 차단(`affected`·`will_not_fix`)은 재빌드해도 그대로이므로 트리거가 아니다 —
|
||||
`no-fix` 로 따로 보고해 사람이 다른 레버(태그 교체·예외 승인)를 판단하게 한다.
|
||||
@@ -33,45 +29,25 @@
|
||||
`scripts/pipeline/cve-gate.py` 의 함수를 그대로 가져다 쓴다. 규칙이 두 곳으로 갈리면
|
||||
"게이트는 PASS 인데 재빌드하라고 한다" 가 나온다.
|
||||
|
||||
핀 산출은 `suggest-go-upgrades.py` 를 재사용한다(그것이 다시 게이트의 심각도 규칙을 쓴다).
|
||||
|
||||
기준은 카탈로그가 실제로 가리키는 이미지다
|
||||
----------------------------------------
|
||||
images/<image>/catalog.env → 카탈로그 values 의 현재 ref
|
||||
→ 그 ref 의 스캔 리포트
|
||||
↕
|
||||
<variant>.build.env 의 핀
|
||||
catalog/image-map/<image>.env → 카탈로그 values 의 현재 ref
|
||||
→ 그 ref 의 스캔 리포트
|
||||
|
||||
재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 를 답한다.
|
||||
|
||||
레포 분리 시 이 파일은 둘로 갈린다
|
||||
--------------------------------
|
||||
커스텀 이미지가 별도 레포로 분리될 예정이고(`.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 가 핀을 판단한다.
|
||||
|
||||
이 주석의 목적은 분리 작업을 **고고학이 아니라 기계적 작업**으로 만드는 것이다. 새 함수를
|
||||
추가할 때 어느 쪽에 속하는지 함께 적는다.
|
||||
레포 분리 이후 — 이 스크립트는 절반이다
|
||||
----------------------------------------
|
||||
커스텀 이미지는 security-images(별도 레포)로 나갔다. "무엇을 배포 중인가"는 이 카탈로그가
|
||||
알고, "그 이미지를 어떻게 만드는가"(핀 판단·재빌드)는 security-images 가 안다. 이 스크립트는
|
||||
**탐지만** 한다 — 재빌드가 필요하다고 판단되면 `self-build-drift-check.yml` 이
|
||||
security-images 의 `build-image.yml` 을 `workflow_dispatch` 로 부른다. 핀을 무엇으로
|
||||
올릴지는 그 레포의 `suggest-go-upgrades.py --apply` 가 한다.
|
||||
|
||||
사용
|
||||
----
|
||||
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)
|
||||
@@ -88,7 +64,7 @@ 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")
|
||||
IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map")
|
||||
DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json")
|
||||
|
||||
# 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고
|
||||
@@ -103,9 +79,8 @@ def load(path, name):
|
||||
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()
|
||||
GATE = load(os.path.join(HERE, "..", "pipeline", "cve-gate.py"), "cve_gate")
|
||||
|
||||
|
||||
def read_env(path):
|
||||
@@ -125,36 +100,18 @@ def read_env(path):
|
||||
|
||||
|
||||
def report_path(reports_dir, image_ref):
|
||||
"""이미지 ref → 리포트 파일 경로. scan-sbom.sh 가 `tr '/:@' '___'` 로 만든다."""
|
||||
"""이미지 ref → 리포트 파일 경로. `trivy image <ref>` 스캔 결과를
|
||||
`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):
|
||||
def resolve_current_ref(chart_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"
|
||||
chart_dirs = (chart_env.get("CHART_DIRS") or "").split()
|
||||
style = chart_env.get("TAG_STYLE") or ""
|
||||
block = chart_env.get("TAG_BLOCK") or "image"
|
||||
if not chart_dirs or style not in ("imageName", "split"):
|
||||
return None, "catalog.env 에 CHART_DIRS/TAG_STYLE 이 없다"
|
||||
return None, "catalog/image-map/<image>.env 에 CHART_DIRS/TAG_STYLE 이 없다"
|
||||
for d in chart_dirs:
|
||||
for fname in VALUES_CANDIDATES:
|
||||
p = os.path.join(ROOT, d, fname)
|
||||
@@ -179,7 +136,10 @@ def blocking_cves(report, image_ref, rank, floor, exceptions):
|
||||
cve = v.get("VulnerabilityID")
|
||||
if not cve:
|
||||
continue
|
||||
eff = SUGGEST.effective_severity(v, GATE, rank)
|
||||
eff = GATE.effective_severity(
|
||||
v.get("Severity"),
|
||||
GATE.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")),
|
||||
)
|
||||
if rank.get(eff, 0) < floor:
|
||||
continue
|
||||
if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions):
|
||||
@@ -193,68 +153,11 @@ def blocking_cves(report, image_ref, rank, floor, exceptions):
|
||||
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}
|
||||
res = {"image": image, "status": "ok", "notes": [], "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)
|
||||
chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env"))
|
||||
ref, where = resolve_current_ref(chart_env)
|
||||
if not ref:
|
||||
res["status"] = "skip"
|
||||
res["notes"].append(where)
|
||||
@@ -279,8 +182,6 @@ def check_image(image, reports_dir, rank, floor, exceptions):
|
||||
if not cves:
|
||||
return res # ok
|
||||
|
||||
res["changes"], res["notes"] = pin_changes([rp], pins, rank, floor)
|
||||
|
||||
if fixable:
|
||||
res["status"] = "rebuild"
|
||||
else:
|
||||
@@ -290,26 +191,6 @@ def check_image(image, reports_dir, rank, floor, exceptions):
|
||||
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"]
|
||||
@@ -319,16 +200,14 @@ def render_md(results):
|
||||
if not rebuild and not nofix:
|
||||
out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.")
|
||||
if rebuild:
|
||||
out += [f"**{len(rebuild)}개 이미지가 재빌드로 고쳐진다.** 차단 CVE 에 수정 버전이 있다 "
|
||||
"— 재빌드하면 베이스 패키지·툴체인이 최신으로 반영된다.", "",
|
||||
"| 이미지 | 차단 | 수정 가능 | 패키지 | 함께 올릴 핀 |",
|
||||
"|---|---:|---:|---|---|"]
|
||||
out += [f"**{len(rebuild)}개 이미지가 재빌드 대상이다.** 차단 CVE 에 수정 버전이 있다 "
|
||||
"— security-images 레포에서 재빌드하면 해소될 가능성이 높다. 핀 조정이 "
|
||||
"필요한지는 그 레포의 `suggest-go-upgrades.py` 가 판단한다.", "",
|
||||
"| 이미지 | 현재 ref | 차단 | 수정 가능 | 패키지 |",
|
||||
"|---|---|---:|---:|---|"]
|
||||
for r in rebuild:
|
||||
pins = "<br>".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} |")
|
||||
out.append(f"| `{r['image']}` | `{r['ref']}` | {r['blocking']} | {r['fixable']} | {pkgs} |")
|
||||
if nofix:
|
||||
out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.",
|
||||
"", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"]
|
||||
@@ -349,14 +228,15 @@ def render_md(results):
|
||||
out += ["", "</details>"]
|
||||
|
||||
out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · "
|
||||
"기본 HIGH 이상. 핀 제안은 **제안**이고 채택은 사람이 커밋한다.", ""]
|
||||
"기본 HIGH 이상. 재빌드는 security-images 레포의 `build-image.yml` 을 "
|
||||
"`workflow_dispatch` 로 부른다.", ""]
|
||||
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/<image>)")
|
||||
ap.add_argument("--image", default="", help="이 이미지만 판정 (catalog/image-map/<image>.env)")
|
||||
ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS,
|
||||
help="승인 예외 목록 (기본 doc/cve-exceptions.json)")
|
||||
ap.add_argument("--min-severity", default="HIGH",
|
||||
@@ -368,14 +248,12 @@ def main():
|
||||
"새지 않게 한다")
|
||||
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)
|
||||
if not os.path.isdir(IMAGE_MAP_DIR):
|
||||
print(f"::error::catalog/image-map/ 를 찾지 못했다: {IMAGE_MAP_DIR}", file=sys.stderr)
|
||||
return 2
|
||||
if not args.list_refs and not args.reports:
|
||||
print("::error::--reports 가 필요하다", file=sys.stderr)
|
||||
@@ -384,17 +262,16 @@ def main():
|
||||
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)))
|
||||
names = sorted(f[:-4] for f in os.listdir(IMAGE_MAP_DIR) if f.endswith(".env"))
|
||||
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)
|
||||
print(f"::error::catalog/image-map/{args.image}.env 가 없다", 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")))
|
||||
ref, why = resolve_current_ref(read_env(os.path.join(IMAGE_MAP_DIR, f"{n}.env")))
|
||||
if not ref:
|
||||
print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr)
|
||||
continue
|
||||
@@ -414,9 +291,8 @@ def main():
|
||||
|
||||
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 ""))
|
||||
print(f"::warning::{r['image']}: 재빌드 대상 — 차단 {r['blocking']}건"
|
||||
f"(수정 가능 {r['fixable']})")
|
||||
elif r["status"] == "no-fix":
|
||||
print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — "
|
||||
"재빌드로 해소되지 않는다")
|
||||
@@ -434,13 +310,6 @@ def main():
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -1,230 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
suggest-go-upgrades.py — 스캔 리포트에서 GO_MODULE_UPGRADES 값을 산출한다.
|
||||
|
||||
왜 필요한가
|
||||
-----------
|
||||
자체 빌드 이미지가 Go 모듈 CVE 를 해소할 때 `images/<image>/<variant>.build.env` 의
|
||||
`GO_MODULE_UPGRADES` 에 `<module>@<version>` 을 적는다. 그 버전을 사람이 게이트 리포트에서
|
||||
하나씩 찾아 최대값을 고르는 것은 지루하고 틀리기 쉽다 — 필요한 정보는 이미 trivy 리포트의
|
||||
`FixedVersion` 에 다 있다. 이 스크립트가 그것을 뽑아 붙여넣을 수 있는 형태로 낸다.
|
||||
|
||||
왜 "완전 자동"(빌드 시점에 최신으로 당기기)이 아닌가
|
||||
--------------------------------------------------
|
||||
`go get -u` 로 매번 최신을 끌면 같은 소스로 빌드해도 이미지가 달라진다. 이 레포는 반대를
|
||||
택했다(.claude/image-authoring.md "롤링 태그를 쓰지 않는다", SOURCE_COMMIT·BUILD_DATE 고정).
|
||||
버전 핀은 **우리가 무엇을 검증했는지의 기록**이다. 그래서 이 스크립트는 값을 제안만 하고,
|
||||
채택은 사람이 build.env 에 커밋한다 — git diff 에 "무엇이 왜 올라갔는지" 가 남는다.
|
||||
|
||||
심각도 기준은 게이트와 같다
|
||||
--------------------------
|
||||
`max(벤더 등급, NVD 등급)` 을 실효 등급으로 쓴다. 판정 규칙이 두 곳으로 갈리지 않도록
|
||||
cve-gate.py 의 함수를 그대로 가져다 쓴다.
|
||||
|
||||
사용
|
||||
----
|
||||
python3 scripts/build/suggest-go-upgrades.py --reports <trivy-reports 디렉토리>
|
||||
python3 scripts/build/suggest-go-upgrades.py --reports sbom-out/trivy-reports \\
|
||||
--image argocd --min-severity HIGH
|
||||
|
||||
종료 코드
|
||||
0 제안 출력(대상이 없으면 그 사실을 출력)
|
||||
2 실행 오류
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
GATE = os.path.join(HERE, "..", "pipeline", "cve-gate.py")
|
||||
|
||||
|
||||
def load_gate():
|
||||
"""cve-gate.py 를 모듈로 읽어 심각도 규칙을 재사용한다 — 규칙의 단일 출처를 지킨다."""
|
||||
spec = importlib.util.spec_from_file_location("cve_gate", GATE)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def version_tuple(v):
|
||||
"""'0.56.0' → (0,56,0). 비교용. 숫자가 아닌 꼬리는 버린다."""
|
||||
nums = re.findall(r"\d+", v or "")
|
||||
return tuple(int(n) for n in nums) or (0,)
|
||||
|
||||
|
||||
def pick_fixed(raw):
|
||||
"""FixedVersion 문자열에서 후보를 뽑아 최대값을 고른다.
|
||||
|
||||
Go 모듈은 보통 단일 값이지만 쉼표로 여러 브랜치를 나열하는 경우가 있다
|
||||
(stdlib 의 '1.24.13, 1.25.7' 형태). 모듈에 대해서는 가장 높은 값을 쓴다.
|
||||
"""
|
||||
cands = [c.strip() for c in re.split(r"[,/]", raw or "") if c.strip()]
|
||||
cands = [c for c in cands if re.match(r"^v?\d", c)]
|
||||
if not cands:
|
||||
return None
|
||||
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) 디렉토리")
|
||||
ap.add_argument("--image", default="", help="리포트 파일명에 이 문자열이 든 것만 대상")
|
||||
ap.add_argument("--min-severity", default="HIGH", choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
|
||||
help="이 등급 이상만 제안 (기본 HIGH — 게이트 차단 기준과 동일)")
|
||||
args = ap.parse_args()
|
||||
|
||||
gate = load_gate()
|
||||
rank = gate.RANK
|
||||
floor = rank[args.min_severity]
|
||||
|
||||
paths = sorted(glob.glob(os.path.join(args.reports, "*.json")))
|
||||
if args.image:
|
||||
paths = [p for p in paths if args.image in os.path.basename(p)]
|
||||
if not paths:
|
||||
print(f"::error::리포트를 찾지 못했다: {args.reports} (--image={args.image or '전체'})", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
mods = collect_modules(paths, gate, rank, floor)
|
||||
std_alts = collect_stdlib(paths, gate, rank, floor)
|
||||
|
||||
if std_alts:
|
||||
rows = builder_candidates(std_alts)
|
||||
print("# stdlib 차단 CVE 가 있다 — GO_MODULE_UPGRADES 가 아니라 툴체인으로 해소한다.")
|
||||
print(f"# 대상 CVE {len(std_alts)}건. 아래 중 어느 것을 써도 전부 해소된다:")
|
||||
for (a, b), c in rows:
|
||||
print(f"# go{a}.{b}.{c}")
|
||||
if rows:
|
||||
(a, b), c = rows[-1]
|
||||
print(f"# GO_BUILDER_TAG={a}.{b}.{c}-trixie # 가장 높은 정식 브랜치 기준")
|
||||
else:
|
||||
print("# ::warning:: 정식 릴리스 대안이 없다 — 프리릴리스 툴체인이 필요할 수 있다")
|
||||
print()
|
||||
|
||||
if not mods:
|
||||
print("# Go 모듈 업그레이드 제안 없음 (해당 등급 이상 findings 이 없다)")
|
||||
return 0
|
||||
|
||||
print(f"# {args.min_severity} 이상 Go 모듈 CVE 로부터 산출 — build.env 의 GO_MODULE_UPGRADES 에 반영한다.")
|
||||
for name in sorted(mods):
|
||||
e = mods[name]
|
||||
inst = ", ".join(sorted(e["installed"])) or "?"
|
||||
cves = ", ".join(sorted(e["cves"]))
|
||||
print(f"# {name} {inst} → {e['fixed']}")
|
||||
print(f"# {cves}")
|
||||
specs = " ".join(f"{n}@v{mods[n]['fixed']}" for n in sorted(mods))
|
||||
print(f'GO_MODULE_UPGRADES="{specs}"')
|
||||
print()
|
||||
print("# 주의 — 이 값은 CVE 요건의 최소치다. 모듈 간 제약으로 더 올려야 할 수 있다")
|
||||
print("# (실측: go-git 5.19.2 와 x/net 0.56.0 이 x/crypto 0.53.0 을 요구해 0.52.0 으로는 빌드 실패).")
|
||||
print("# 빌드가 'requires ...@vX, not ...@vY' 로 실패하면 그 버전으로 올린다.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user