diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml new file mode 100644 index 0000000..f0309bb --- /dev/null +++ b/.github/workflows/sbom.yml @@ -0,0 +1,126 @@ +name: helm-catalog-sbom + +# manifests/helm 카탈로그의 컨테이너 이미지 SBOM(CycloneDX) + 취약점 리포트 생성. +# 상세: doc/sbom-pipeline.md +# +# 실행 컨테이너: vars.SBOM_PIPELINE_IMAGE 에 helm + trivy + python3 + bash + git 가 설치된 +# 이미지를 지정해야 한다. (Settings > Secrets and variables > Actions > Variables) + +on: + workflow_dispatch: {} + pull_request: + paths: + - 'manifests/helm/**' + schedule: + - cron: '0 18 * * 0' # 매주 일요일 18:00 UTC — 전체 스캔(신규 CVE 반영) + +permissions: + contents: read + +jobs: + sbom: + runs-on: ubuntu-latest + timeout-minutes: 120 + container: + image: ${{ vars.SBOM_PIPELINE_IMAGE }} # helm+trivy+python3+bash+git 보유 이미지 + env: + OUT_DIR: ${{ github.workspace }}/sbom-out + TRIVY_CACHE_DIR: ${{ github.workspace }}/sbom-out/cache + PARALLEL: '3' + steps: + - name: Preflight — 도구 확인 + run: | + set -e + for t in helm trivy python3 bash git; do + command -v "$t" >/dev/null || { echo "::error::컨테이너에 $t 없음 (vars.SBOM_PIPELINE_IMAGE 확인)"; exit 1; } + done + helm version --short; trivy --version | head -1 + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # PR 증분 diff 용 + + - name: git safe.directory + run: git config --global --add safe.directory "$GITHUB_WORKSPACE" + + # 사설 레지스트리 인증: 시크릿으로 docker config.json 을 만들어 trivy 가 읽게 한다. + - name: 레지스트리 인증 구성 + env: + DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + NGC_API_KEY: ${{ secrets.NGC_API_KEY }} + run: | + mkdir -p "$OUT_DIR/.docker" + python3 - <<'PY' + import os, json, base64 + auths={} + def add(reg,user,secret): + if user and secret: + auths[reg]={"auth":base64.b64encode(f"{user}:{secret}".encode()).decode()} + add("https://index.docker.io/v1/", os.environ.get("DOCKERHUB_USER"), os.environ.get("DOCKERHUB_TOKEN")) + add("nvcr.io", "$oauthtoken", os.environ.get("NGC_API_KEY")) + out=os.path.join(os.environ["OUT_DIR"],".docker","config.json") + json.dump({"auths":auths}, open(out,"w")) + print("configured registries:", list(auths.keys()) or "(none)") + PY + echo "DOCKER_CONFIG=$OUT_DIR/.docker" >> "$GITHUB_ENV" + + - name: 이미지 인벤토리 추출 + run: bash doc/scripts/extract-helm-images.sh "$GITHUB_WORKSPACE/manifests/helm" "$OUT_DIR" + + - name: PR 증분 — 변경 차트만 대상 축소 + if: github.event_name == 'pull_request' + run: | + changed=$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- manifests/helm/ \ + | awk -F/ 'NF>=3{print $3}' | sort -u) + if [ -z "$changed" ]; then echo "변경 차트 없음 — 스캔 생략"; : > "$OUT_DIR/images_scan.tsv"; else + echo "변경 차트:"; echo "$changed" + awk -F'\t' -v c="$(echo "$changed" | paste -sd'|' -)" '$1 ~ ("^("c")$")' \ + "$OUT_DIR/images_final.tsv" > "$OUT_DIR/images_scan.tsv" + fi + wc -l "$OUT_DIR/images_scan.tsv" + + - name: 스캔 대상 결정 (스케줄/수동=전체) + if: github.event_name != 'pull_request' + run: cp "$OUT_DIR/images_final.tsv" "$OUT_DIR/images_scan.tsv" + + - name: SBOM 생성 (CycloneDX) + run: | + if [ -s "$OUT_DIR/images_scan.tsv" ]; then + bash doc/scripts/generate-sbom.sh "$OUT_DIR/images_scan.tsv" "$OUT_DIR" + else + echo "대상 이미지 없음 — SBOM 생성 생략"; mkdir -p "$OUT_DIR/sbom" + fi + + - name: 취약점 스캔 (SBOM 입력, 오프라인) + run: | + if ls "$OUT_DIR/sbom/"*.cdx.json >/dev/null 2>&1; then + bash doc/scripts/scan-sbom.sh "$OUT_DIR" + else + echo "SBOM 없음 — 스캔 생략" + fi + + - name: Job Summary + if: always() + run: | + if [ -f "$OUT_DIR/trivy-summary.md" ]; then cat "$OUT_DIR/trivy-summary.md" >> "$GITHUB_STEP_SUMMARY"; fi + + - name: 아티팩트 업로드 (SBOM + 요약) + if: always() + uses: actions/upload-artifact@v4 + with: + name: sbom-and-vuln-report + path: | + ${{ env.OUT_DIR }}/sbom/** + ${{ env.OUT_DIR }}/catalog.cdx.json + ${{ env.OUT_DIR }}/sbom-index.tsv + ${{ env.OUT_DIR }}/trivy-summary.* + ${{ env.OUT_DIR }}/*.log + if-no-files-found: warn + + # 선택: PR 에서 CRITICAL 발견 시 실패 게이트 (필요 시 주석 해제) + # - name: CRITICAL 게이트 + # if: github.event_name == 'pull_request' + # run: | + # crit=$(awk -F'\t' '{s+=$3} END{print s+0}' "$OUT_DIR/trivy-summary.tsv" 2>/dev/null || echo 0) + # echo "CRITICAL 합계: $crit"; [ "$crit" -eq 0 ] || { echo "::error::CRITICAL $crit 건"; exit 1; } diff --git a/.gitignore b/.gitignore index b7474d6..938fd9a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,17 @@ __pycache__/ # 로컬 테스트 산출물 (커밋 금지) — 멀티테넌시 격리 검증 스크립트·문서·결과 /tenant-verification/ + +# SBOM 파이프라인 산출물 (CI 아티팩트 — 커밋 금지). 상세: doc/sbom-pipeline.md +/sbom-out/ +sbom/ +*.cdx.json +images_final.tsv +images_raw.tsv +images_scan.tsv +render_status.tsv +sbom-index.tsv +trivy-reports/ +trivy-summary.* +trivy-run.log +sbom-gen.log diff --git a/doc/sbom-pipeline.md b/doc/sbom-pipeline.md new file mode 100644 index 0000000..84915f2 --- /dev/null +++ b/doc/sbom-pipeline.md @@ -0,0 +1,95 @@ +# Helm 카탈로그 SBOM 생성 파이프라인 + +`manifests/helm/` 정적 카탈로그의 **모든 컨테이너 이미지에 대한 SBOM(CycloneDX)과 취약점 리포트**를 +결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행한다. + +## 왜 필요한가 + +GitHub Dependency-Graph SBOM(예: `paasup_dip-catalog_199087.json`)은 pypi 등 소스 매니페스트만 담고 +**Helm 차트가 참조하는 컨테이너 이미지는 하나도 포함하지 못한다**(Helm 미지원 + 이미지 미스캔). 이 파이프라인이 +그 공백을 메운다 — `helm template` 렌더로 실제 배포 이미지를 뽑고, 각 이미지의 SBOM 과 취약점을 생성한다. + +## 아키텍처 (SBOM-first) + +이미지를 **한 번만 pull** 해서 SBOM 을 만들고, 취약점은 그 SBOM 에서 오프라인으로 뽑는다. + +``` +extract-helm-images.sh generate-sbom.sh scan-sbom.sh +manifests/helm/** ──helm template──▶ images_final.tsv ──trivy image──▶ sbom/*.cdx.json ──trivy sbom──▶ trivy-summary.md + (오프라인·결정론) (이미지 1회 pull, 인증 필요) (CycloneDX) (오프라인·인증 불필요) + trivy-reports/ +``` + +- **레지스트리 pull·인증은 SBOM 생성 단계 1곳으로 집중.** 취약점 스캔은 네트워크·인증 없이 빠르게 반복 가능 + (CVE DB 갱신 시 재pull 없이 재스캔). +- 스캔 대상 이미지는 trivy 가 원격에서 받아 분석 후 폐기하므로 호스트/이미지 스토어에 남지 않는다. + +## 실행 환경 요구사항 + +스크립트는 **컨테이너 내부에 설치된 바이너리를 직접 호출**한다. 실행 이미지에 다음이 있어야 한다. + +| 도구 | 사용 스크립트 | +|------|--------------| +| `helm` (v3) | extract-helm-images.sh | +| `trivy` | generate-sbom.sh, scan-sbom.sh | +| `python3`, `bash`, `git` | 공통 | + +## 스크립트 (`doc/scripts/`) + +| 스크립트 | 입력 | 출력 | +|---------|------|------| +| `extract-helm-images.sh` | `manifests/helm/` | `images_final.tsv` (chart⇥version⇥image), `render_status.tsv` | +| `generate-sbom.sh` | `images_final.tsv` | `sbom/.cdx.json`, `sbom-index.tsv`, `sbom-gen.log` | +| `scan-sbom.sh` | `sbom/` + `sbom-index.tsv` | `trivy-summary.{tsv,md}`, `trivy-reports/`, `trivy-run.log` | + +### 로컬/컨테이너 실행 + +```bash +# 도구가 설치된 이미지 안에서 (예: = helm+trivy+python3 보유 이미지) +docker run --rm -v "$PWD:/repo" -w /repo -e TRIVY_CACHE_DIR=/repo/sbom-out/cache bash -c ' + bash doc/scripts/extract-helm-images.sh manifests/helm sbom-out + bash doc/scripts/generate-sbom.sh sbom-out/images_final.tsv sbom-out # DOCKER_CONFIG/TRIVY_USERNAME 로 인증 + bash doc/scripts/scan-sbom.sh sbom-out +' +``` + +주요 환경변수: `PARALLEL`(동시 처리), `LIMIT`(대상 상한, 디버그용), `SEVERITY`(기본 `HIGH,CRITICAL`), +`TRIVY_CACHE_DIR`(캐시 디렉토리), `MERGE=1`(generate 시 `catalog.cdx.json` 병합, cyclonedx CLI 필요). +사설 레지스트리 인증은 trivy 네이티브: `TRIVY_USERNAME`/`TRIVY_PASSWORD`(단일) 또는 `DOCKER_CONFIG`(다중). + +## CI (`.github/workflows/sbom.yml`) + +- **트리거**: `schedule`(주 1회, 전체) / `pull_request`(`manifests/helm/**` 변경 시, 변경 차트만 증분) / `workflow_dispatch`. +- **컨테이너**: `vars.SBOM_PIPELINE_IMAGE` (helm+trivy+python3 보유 이미지)에서 실행. +- **시크릿**: `DOCKERHUB_TOKEN`/`DOCKERHUB_USER`(rate limit), `NGC_API_KEY`(nvcr.io), 필요 시 paasup 사설 레지스트리. +- **아티팩트**: `sbom/`(+`catalog.cdx.json`), `trivy-summary.*` 업로드 + Job Summary 표시. 산출물은 **커밋하지 않는다**(`.gitignore`). +- **스캔 주기 원칙**: 인벤토리·SBOM 은 차트 변경 시에만 바뀌지만 **취약점은 새 CVE 로 계속 변하므로 스케줄 전체 스캔이 필수**. + PR 은 변경 차트만(빠른 게이트), 스케줄은 전체. + +## 렌더 함정 (extract 단계, 스크립트에 내장) + +| 플래그 | 이유 | +|--------|------| +| `--set global.security.allowInsecureImages=true` | `bitnamilegacy/*` 오버라이드가 Bitnami common 의 insecureImages 가드에 걸려 렌더 실패하는 것 방지 | +| `--kube-version 1.31.0` | 일부 차트의 `kubeVersion` 상한 제약 회피 (예: rancher `< 1.32`) | + +trivy 병렬 실행 시 공유 캐시 bolt 잠금 충돌을 피하려 워커는 `--cache-backend memory --skip-db-update`, +메인은 시작 시 `trivy image --download-db-only` 로 DB 를 1회 워밍한다. + +## 산출물 + +- `sbom/.cdx.json` — 이미지별 CycloneDX SBOM (bomFormat CycloneDX). `catalog.cdx.json`(선택) = 전체 병합. +- `sbom-index.tsv` — chart⇥version⇥image⇥status⇥sec⇥sbom_file (차트↔이미지↔SBOM 매핑). +- `trivy-summary.md` / `.tsv` — 이미지별 CRITICAL/HIGH 집계 (차트 컬럼 포함), 총계·소요시간. +- `trivy-reports/.json` — 이미지별 원본 취약점 결과. + +## 운영 노트 — `:latest`/무태그 이미지 digest 고정 + +태그가 고정돼 있어도 내용물이 바뀌는 `:latest`/무태그 이미지는 재현성 저해 + 증분 스캔의 사각지대다. +정기 전체 스캔 대상에 항상 포함하고, 가능하면 **구체 버전 또는 digest(`@sha256:...`)로 고정**한다. + +적용된 고정(2026-07-08 기준): +- `mlflow@1.9.0` postgresql → `bitnamilegacy/postgresql@sha256:42a8200d...` (legacy 에 v18 버전 태그 부재, latest 내용 digest 고정) +- `vllm@0.0.11` vllm-openai → `vllm/vllm-openai:v0.24.0` + +digest 고정은 재현성을 보장하지만 자동 보안 패치는 멈춘다. → 정기 스캔으로 새 CVE 를 감지하고 필요 시 digest 를 갱신한다. +`bitnamilegacy/*` 는 미유지 이미지이므로 중기적으로 legacy 탈피(대체 이미지)를 검토한다. diff --git a/doc/scripts/extract-helm-images.sh b/doc/scripts/extract-helm-images.sh new file mode 100755 index 0000000..0c540d6 --- /dev/null +++ b/doc/scripts/extract-helm-images.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# ============================================================================= +# extract-helm-images.sh +# dip-catalog 정적 Helm 카탈로그에서 컨테이너 이미지 인벤토리를 추출한다. +# +# 방법 (100% deterministic, LLM 미사용): +# 각 차트 버전 디렉토리를 `helm template` 로 렌더 → 렌더된 매니페스트에서 +# `image:` 필드를 추출 → 정규화/중복 제거. +# +# custom-values.yaml 이 있으면 적용하여 "실제 배포되는(effective) 이미지"를 +# 뽑는다. 없으면 차트 기본값으로 렌더한다. +# +# 사용: +# bash doc/scripts/extract-helm-images.sh [HELM_ROOT] [OUT_DIR] +# HELM_ROOT 기본값: /manifests/helm +# OUT_DIR 기본값: 현재 디렉토리 +# +# 산출물: +# images_final.tsv chartversionimage (정렬/중복제거) +# render_status.tsv chartversionmodestatus +# +# 요구사항: helm v3, bash, awk, sed, grep +# ============================================================================= +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELM_ROOT="${1:-$(cd "$SCRIPT_DIR/../../manifests/helm" && pwd)}" +OUT_DIR="${2:-$(pwd)}" + +# bitnamilegacy 이미지가 있는 차트는 Bitnami common 의 insecureImages 가드에 +# 걸리므로 렌더 시 허용 플래그가 필요하다. +KUBE_VERSION="${KUBE_VERSION:-1.31.0}" +COMMON_SET=(--set global.security.allowInsecureImages=true) + +RAW="$OUT_DIR/images_raw.tsv" +FINAL="$OUT_DIR/images_final.tsv" +STATUS="$OUT_DIR/render_status.tsv" +: > "$RAW"; : > "$STATUS" + +cd "$HELM_ROOT" + +extract_images() { # stdin: 렌더된 yaml → stdout: 이미지 문자열 + grep -ioE '^[[:space:]]*(- )?image:[[:space:]]*"?[^"[:space:]]+' \ + | sed -E 's/.*image:[[:space:]]*"?//' \ + | grep -vE '^$' +} + +for chart in */; do + chart="${chart%/}" + for vdir in "$chart"/*/; do + [ -f "${vdir}Chart.yaml" ] || continue + version="$(basename "$vdir")" + + args=(template t "$vdir" --kube-version "$KUBE_VERSION" "${COMMON_SET[@]}") + mode="default" + if [ -f "${vdir}custom-values.yaml" ]; then + args+=(-f "${vdir}custom-values.yaml") + mode="effective" + fi + + if out="$(helm "${args[@]}" 2>/tmp/_helmerr)"; then + echo -e "${chart}\t${version}\t${mode}\tOK" >> "$STATUS" + elif [ "$mode" = "effective" ] && out="$(helm template t "$vdir" --kube-version "$KUBE_VERSION" "${COMMON_SET[@]}" 2>/tmp/_helmerr)"; then + echo -e "${chart}\t${version}\tdefault-fallback\tOK" >> "$STATUS" + else + echo -e "${chart}\t${version}\t${mode}\tFAIL: $(head -1 /tmp/_helmerr | cut -c1-120)" >> "$STATUS" + out="" + fi + + [ -n "$out" ] || continue + while read -r img; do + [ -z "$img" ] && continue + echo -e "${chart}\t${version}\t${img}" >> "$RAW" + done < <(printf '%s\n' "$out" | extract_images) + done +done + +# 정규화(따옴표 제거) + 정렬 + 중복 제거 +awk -F'\t' '{gsub(/^['"'"'"]+|['"'"'"]+$/,"",$3); if($3!="") print $1"\t"$2"\t"$3}' "$RAW" \ + | sort -u > "$FINAL" + +echo "== 렌더 상태 ==" +awk -F'\t' '{s=$4; sub(/FAIL.*/,"FAIL",s); print s}' "$STATUS" | sort | uniq -c +echo "== 커버 차트 : $(cut -f1 "$FINAL" | sort -u | wc -l | tr -d ' ') ==" +echo "== 고유 이미지: $(cut -f3 "$FINAL" | sort -u | wc -l | tr -d ' ') ==" +echo "== 산출물 : $FINAL ==" diff --git a/doc/scripts/generate-sbom.sh b/doc/scripts/generate-sbom.sh new file mode 100755 index 0000000..7f2a337 --- /dev/null +++ b/doc/scripts/generate-sbom.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# ============================================================================= +# generate-sbom.sh +# extract-helm-images.sh 가 만든 이미지 인벤토리(images_final.tsv)의 각 이미지에 +# 대해 **컨테이너 내부에 설치된 `trivy` 바이너리**로 CycloneDX SBOM 을 생성한다. +# +# 실행 환경: helm/trivy/python3 가 설치된 리눅스 컨테이너 내부(직접 호출). +# 이미지는 trivy 가 원격 pull → 분석 후 SBOM 작성. 취약점 스캔은 이후 scan-sbom.sh +# 가 이 SBOM 을 입력으로 오프라인 수행한다(이미지 재pull 불필요). +# +# 사용: +# bash doc/scripts/extract-helm-images.sh . . # 선행: images_final.tsv 생성 +# bash doc/scripts/generate-sbom.sh images_final.tsv . # SBOM 생성 +# +# 인자: +# $1 이미지 소스. images_final.tsv(TSV 3열: chart⇥version⇥image) 또는 이미지 1줄씩 텍스트. +# $2 출력 디렉토리 (기본: 현재 디렉토리) +# +# 환경변수: +# PARALLEL 1차 동시 생성 개수 (기본 3) +# RETRY_PARALLEL 재시도 동시 개수 (기본 1) +# RETRIES 실패 이미지 재시도 횟수 (기본 1) +# LIMIT 대상 이미지 수 상한 (기본 0 = 전체) +# TRIVY_CACHE_DIR trivy 캐시 디렉토리 (미설정 시 trivy 기본 ~/.cache/trivy) +# MERGE=1 cyclonedx CLI 가 있으면 catalog.cdx.json 병합 생성 (선택) +# 사설 레지스트리 인증: trivy 네이티브 방식 사용 — +# - 단일 레지스트리: TRIVY_USERNAME / TRIVY_PASSWORD +# - 다중 레지스트리: DOCKER_CONFIG(=docker config.json 이 있는 디렉토리) 또는 ~/.docker/config.json +# +# 산출물 ($2 기준): +# sbom/.cdx.json 이미지별 CycloneDX SBOM +# sbom-index.tsv chart⇥version⇥image⇥status⇥seconds⇥sbom_file +# sbom-gen.log 실행 로그 +# catalog.cdx.json (MERGE=1 + cyclonedx CLI 존재 시) 전체 병합 SBOM +# +# 요구사항(컨테이너 내부): trivy, python3, bash. (선택) cyclonedx +# ============================================================================= +set -uo pipefail + +command -v trivy >/dev/null || { echo "!! trivy 바이너리가 없습니다 (컨테이너에 설치 필요)" >&2; exit 127; } +command -v python3 >/dev/null || { echo "!! python3 가 없습니다" >&2; exit 127; } + +PARALLEL="${PARALLEL:-3}" +RETRY_PARALLEL="${RETRY_PARALLEL:-1}" +RETRIES="${RETRIES:-1}" +LIMIT="${LIMIT:-0}" +[ -n "${TRIVY_CACHE_DIR:-}" ] && export TRIVY_CACHE_DIR # trivy 가 네이티브로 인식 + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# --- 내부 모드: 이미지 1개 SBOM 생성 (xargs 병렬 워커가 재귀 호출) --------------- +# 인자: --gen-one +if [ "${1:-}" = "--gen-one" ]; then + img="$2"; sbomdir="$3"; log="${4:-/dev/null}" + safe="$(printf '%s' "$img" | tr '/:@' '___')" + out="$sbomdir/$safe.cdx.json" + t0=$(date +%s) + # --cache-backend memory : 분석 캐시를 메모리에 두어 공유 캐시 디렉토리의 bolt 잠금 충돌 방지(병렬 안전). + # --skip-db-update : DB 는 메인 모드에서 1회 워밍했으므로 읽기만. + if trivy image --quiet --cache-backend memory --skip-db-update \ + --format cyclonedx --timeout 10m --output "$out" "$img" 2>"$out.err"; then + dur=$(( $(date +%s) - t0 )) + if [ -s "$out" ]; then line="$(printf '%s\tOK\t%s\t%s' "$img" "$dur" "$out")" + else rm -f "$out"; line="$(printf '%s\tERROR:empty-sbom\t%s\t' "$img" "$dur")"; fi + else + dur=$(( $(date +%s) - t0 )) + reason="$(tr '\n' ' ' < "$out.err" | grep -oiE '(unauthorized|not found|no such|manifest unknown|denied|timeout|unexpected eof|failed to)[^.]*' | head -1)" + [ -z "$reason" ] && reason="sbom failed" + rm -f "$out" + line="$(printf '%s\tERROR:%s\t%s\t' "$img" "${reason:0:50}" "$dur")" + fi + printf '%s\n' "$line" + st="$(printf '%s' "$line" | cut -f2)" + printf '%s %-6s %5ss %s\n' "$(ts)" "${st%%:*}" "$dur" "$img" >> "$log" + exit 0 +fi + +# --- 메인 모드 -------------------------------------------------------------- +SRC="${1:?이미지 소스 파일(images_final.tsv 또는 이미지 목록)을 지정하세요}" +OUT_DIR="${2:-$(pwd)}" +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +SBOM_DIR="$OUT_DIR/sbom" +INDEX="$OUT_DIR/sbom-index.tsv" +LOG="$OUT_DIR/sbom-gen.log" +RESULTS="$OUT_DIR/.sbom-results.tsv" +mkdir -p "$SBOM_DIR"; : > "$RESULTS" + +fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); } + +if awk -F'\t' 'NR==1{exit !(NF>=3)}' "$SRC"; then + IMAGES="$(cut -f3 "$SRC" | sed -E 's/^["'"'"']+|["'"'"']+$//g' | grep -vE '^$' | sort -u)" +else + IMAGES="$(sed -E 's/^["'"'"']+|["'"'"']+$//g' "$SRC" | grep -vE '^$' | sort -u)" +fi +[ "$LIMIT" -gt 0 ] && IMAGES="$(printf '%s\n' "$IMAGES" | head -n "$LIMIT")" +N="$(printf '%s\n' "$IMAGES" | grep -c .)" + +RUN_START=$(date +%s) +{ + echo "===================================================================" + echo "SBOM GEN START : $(ts)" + echo "trivy : $(trivy --version 2>/dev/null | head -1) format: cyclonedx" + echo "parallel: $PARALLEL (retry: $RETRY_PARALLEL x$RETRIES)" + echo "images : $N cache: ${TRIVY_CACHE_DIR:-~/.cache/trivy}" + echo "===================================================================" +} | tee "$LOG" >&2 + +# DB 1회 사전 다운로드(워밍). 이후 워커는 --skip-db-update 로 읽기만 하여 동시 갱신 충돌 방지. +echo ">> [$(ts)] DB 워밍 중..." | tee -a "$LOG" >&2 +DB_START=$(date +%s) +trivy image --download-db-only >>"$LOG" 2>&1 \ + || { echo "!! DB 다운로드 실패" | tee -a "$LOG" >&2; exit 1; } +echo ">> [$(ts)] DB 워밍 완료 ($(fmt_elapsed $(( $(date +%s) - DB_START ))))" | tee -a "$LOG" >&2 + +echo ">> [$(ts)] SBOM 1차 생성 (parallel=$PARALLEL)" | tee -a "$LOG" >&2 +P1=$(date +%s) +printf '%s\n' "$IMAGES" \ + | xargs -P "$PARALLEL" -I{} bash "$SELF" --gen-one {} "$SBOM_DIR" "$LOG" \ + >> "$RESULTS" +echo ">> [$(ts)] 1차 완료 ($(fmt_elapsed $(( $(date +%s) - P1 ))))" | tee -a "$LOG" >&2 + +attempt=0 +while [ "$attempt" -lt "$RETRIES" ]; do + attempt=$((attempt+1)) + FAILED="$(comm -23 \ + <(awk -F'\t' '$2!="OK"{print $1}' "$RESULTS" | sort -u) \ + <(awk -F'\t' '$2=="OK"{print $1}' "$RESULTS" | sort -u))" + nf="$(printf '%s\n' "$FAILED" | grep -c .)" + [ "$nf" -eq 0 ] && { echo ">> [$(ts)] 재시도할 실패 없음" | tee -a "$LOG" >&2; break; } + echo ">> [$(ts)] 재시도 $attempt/$RETRIES — 실패 $nf개 (parallel=$RETRY_PARALLEL)" | tee -a "$LOG" >&2 + printf '%s\n' "$FAILED" \ + | xargs -P "$RETRY_PARALLEL" -I{} bash "$SELF" --gen-one {} "$SBOM_DIR" "$LOG" \ + >> "$RESULTS" +done + +# --- 인덱스 확정: images_final.tsv(chart,version,image) 와 조인 -------------- +python3 - "$RESULTS" "$SRC" > "$INDEX" <<'PY' +import sys, os +results, src = sys.argv[1], sys.argv[2] +best={} +for l in open(results): + p=l.rstrip("\n").split("\t") + if len(p)<3: continue + img=p[0] + if p[1]=="OK" or img not in best or best[img][0]!="OK": + best[img]=(p[1], p[2] if len(p)>2 else "", p[3] if len(p)>3 else "") +rows=[]; seen=set() +try: + for l in open(src): + c=l.rstrip("\n").split("\t") + if len(c)>=3 and c[2]: + img=c[2].strip('"\'' ) + st,sec,f=best.get(img,("MISSING","","")) + rows.append((c[0],c[1],img,st,sec,os.path.basename(f))); seen.add(img) +except Exception: pass +for img,(st,sec,f) in best.items(): + if img not in seen: rows.append(("","",img,st,sec,os.path.basename(f))) +for r in sorted(rows): print("\t".join(r)) +PY + +TOTAL=$(( $(date +%s) - RUN_START )) +OKN=$(awk -F'\t' '$4=="OK"' "$INDEX" | wc -l | tr -d ' ') +ERRN=$(awk -F'\t' '$4!="OK" && $4!=""' "$INDEX" | wc -l | tr -d ' ') + +# --- 선택: catalog.cdx.json 병합 (cyclonedx CLI 존재 시) --------------------- +if [ "${MERGE:-0}" = "1" ]; then + if command -v cyclonedx >/dev/null; then + echo ">> [$(ts)] catalog.cdx.json 병합" | tee -a "$LOG" >&2 + files=$(cd "$SBOM_DIR" && ls *.cdx.json 2>/dev/null) + if [ -n "$files" ]; then + ( cd "$SBOM_DIR" && cyclonedx merge --input-files $files --output-file "$OUT_DIR/catalog.cdx.json" ) >>"$LOG" 2>&1 \ + && echo ">> catalog.cdx.json 생성" | tee -a "$LOG" >&2 \ + || echo "!! 병합 실패(선택 단계) — 개별 SBOM 은 정상" | tee -a "$LOG" >&2 + fi + else + echo "!! MERGE=1 이나 cyclonedx CLI 없음 — 병합 생략" | tee -a "$LOG" >&2 + fi +fi + +{ + echo "===================================================================" + echo "SBOM GEN END : $(ts) TOTAL: $(fmt_elapsed "$TOTAL") 성공/실패: $OKN / $ERRN" + echo "SBOM: $SBOM_DIR/ 인덱스: $INDEX" + echo "===================================================================" +} | tee -a "$LOG" >&2 +rm -f "$RESULTS" diff --git a/doc/scripts/scan-sbom.sh b/doc/scripts/scan-sbom.sh new file mode 100755 index 0000000..4eed972 --- /dev/null +++ b/doc/scripts/scan-sbom.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# ============================================================================= +# scan-sbom.sh +# generate-sbom.sh 가 만든 CycloneDX SBOM(sbom/*.cdx.json)을 **컨테이너 내부의 +# `trivy` 바이너리**(`trivy sbom`)로 스캔해 이미지별 취약점을 집계한다. +# +# SBOM 을 입력으로 쓰므로 **이미지 재pull·레지스트리 인증이 불필요**(오프라인). +# 취약점 DB 만 있으면 되며, CVE DB 갱신 시 재pull 없이 재스캔할 수 있다. +# +# 사용: +# bash doc/scripts/generate-sbom.sh images_final.tsv . # 선행: sbom/ 생성 +# bash doc/scripts/scan-sbom.sh . # SBOM 취약점 스캔 +# +# 인자: +# $1 SBOM 작업 디렉토리 (sbom/ 과 sbom-index.tsv 가 있는 곳. 기본: 현재 디렉토리) +# +# 환경변수: +# SEVERITY 집계 심각도 (기본 HIGH,CRITICAL) +# PARALLEL 동시 스캔 개수 (기본 4 — 오프라인이라 높여도 안전) +# TRIVY_CACHE_DIR trivy 캐시 디렉토리 (미설정 시 trivy 기본 ~/.cache/trivy) +# +# 산출물 ($1 기준): +# trivy-reports/.json 이미지별 원본 스캔 결과 +# trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status⇥seconds +# trivy-summary.md 마크다운 요약 +# trivy-run.log 실행 로그 +# +# 요구사항(컨테이너 내부): trivy, python3, bash +# ============================================================================= +set -uo pipefail + +command -v trivy >/dev/null || { echo "!! trivy 바이너리가 없습니다 (컨테이너에 설치 필요)" >&2; exit 127; } +command -v python3 >/dev/null || { echo "!! python3 가 없습니다" >&2; exit 127; } + +SEVERITY="${SEVERITY:-HIGH,CRITICAL}" +PARALLEL="${PARALLEL:-4}" +[ -n "${TRIVY_CACHE_DIR:-}" ] && export TRIVY_CACHE_DIR + +ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# --- 내부 모드: SBOM 1개 스캔 (xargs 병렬 워커) ------------------------------ +# 인자: --scan-one +if [ "${1:-}" = "--scan-one" ]; then + img="$2"; sbomfile="$3"; reports="$4"; log="${5:-/dev/null}" + safe="$(printf '%s' "$img" | tr '/:@' '___')" + json="$reports/$safe.json" + t0=$(date +%s) + if trivy sbom --quiet --scanners vuln --severity "$SEVERITY" \ + --cache-backend memory --skip-db-update \ + --format json "$sbomfile" > "$json" 2>"$json.err"; then + dur=$(( $(date +%s) - t0 )) + line="$(python3 - "$img" "$json" "$dur" <<'PY' +import sys,json,collections +img,path,dur=sys.argv[1],sys.argv[2],sys.argv[3] +try: + d=json.load(open(path)); c=collections.Counter() + for r in d.get("Results",[]): + for v in (r.get("Vulnerabilities") or []): c[v["Severity"]]+=1 + print(f"{img}\t{c['CRITICAL']}\t{c['HIGH']}\t{c['MEDIUM']}\t{c['LOW']}\tOK\t{dur}") +except Exception as e: + print(f"{img}\t-\t-\t-\t-\tPARSE_ERR:{str(e)[:40]}\t{dur}") +PY +)" + else + dur=$(( $(date +%s) - t0 )) + reason="$(tr '\n' ' ' < "$json.err" | grep -oiE '(unable|invalid|unsupported|not found|failed to)[^.]*' | head -1)" + [ -z "$reason" ] && reason="scan failed" + rm -f "$json" + line="$(printf '%s\t-\t-\t-\t-\tERROR:%s\t%s' "$img" "${reason:0:40}" "$dur")" + fi + printf '%s\n' "$line" + st="$(printf '%s' "$line" | cut -f6)" + printf '%s %-6s %5ss %s\n' "$(ts)" "${st%%:*}" "$dur" "$img" >> "$log" + exit 0 +fi + +# --- 메인 모드 -------------------------------------------------------------- +OUT_DIR="${1:-$(pwd)}" +SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" +SBOM_DIR="$OUT_DIR/sbom" +INDEX="$OUT_DIR/sbom-index.tsv" +REPORTS="$OUT_DIR/trivy-reports" +SUMMARY_TSV="$OUT_DIR/trivy-summary.tsv" +SUMMARY_MD="$OUT_DIR/trivy-summary.md" +LOG="$OUT_DIR/trivy-run.log" +RESULTS="$OUT_DIR/.scan-results.tsv" +CHARTMAP="$OUT_DIR/.chartmap.tsv" +TARGETS="$OUT_DIR/.scan-targets.tsv" +[ -d "$SBOM_DIR" ] || { echo "!! SBOM 디렉토리 없음: $SBOM_DIR (generate-sbom.sh 먼저 실행)" >&2; exit 1; } +mkdir -p "$REPORTS"; : > "$RESULTS" + +fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); } + +# 스캔 대상: (image⇥sbom_file). sbom-index.tsv 있으면 그걸로, 없으면 sbom/*.cdx.json 글롭. +# 동시에 image->chart 매핑(CHARTMAP) 작성. +: > "$CHARTMAP" +if [ -f "$INDEX" ]; then + awk -F'\t' -v d="$SBOM_DIR" '$4=="OK" && $6!=""{print $3"\t"d"/"$6}' "$INDEX" | sort -u > "$TARGETS" + awk -F'\t' '$4=="OK" && $6!=""{print $3"\t"$1"@"$2}' "$INDEX" | sort -u > "$CHARTMAP" +else + : > "$TARGETS" + for f in "$SBOM_DIR"/*.cdx.json; do + [ -e "$f" ] || continue + img="$(python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));m=d.get("metadata",{}).get("component",{});print(m.get("name","")+((":"+m["version"]) if m.get("version") else ""))' "$f" 2>/dev/null)" + [ -z "$img" ] && img="$(basename "$f")" + printf '%s\t%s\n' "$img" "$f" >> "$TARGETS" + done +fi +N="$(grep -c . "$TARGETS" 2>/dev/null || echo 0)" + +RUN_START=$(date +%s) +{ + echo "===================================================================" + echo "SBOM SCAN START : $(ts)" + echo "trivy : $(trivy --version 2>/dev/null | head -1) (trivy sbom, 오프라인)" + echo "severity : $SEVERITY parallel: $PARALLEL" + echo "SBOMs : $N cache: ${TRIVY_CACHE_DIR:-~/.cache/trivy}" + echo "===================================================================" +} | tee "$LOG" >&2 + +echo ">> [$(ts)] DB 워밍 중..." | tee -a "$LOG" >&2 +trivy image --download-db-only >>"$LOG" 2>&1 \ + || { echo "!! DB 다운로드 실패" | tee -a "$LOG" >&2; exit 1; } + +echo ">> [$(ts)] 스캔 시작 (parallel=$PARALLEL)" | tee -a "$LOG" >&2 +P1=$(date +%s) +# 각 타깃 라인(image⇥sbom_file)을 워커로. -I{} 는 라인 단위로 아이템을 읽으므로 +# {} 에 탭 포함 전체 라인이 들어온다. bash -c 안에서 cut 으로 탭 분해(BSD/GNU 공통). +xargs -P "$PARALLEL" -I{} bash -c ' + im=$(printf %s "$1" | cut -f1) + sf=$(printf %s "$1" | cut -f2) + [ -n "$im" ] && bash "$0" --scan-one "$im" "$sf" "$2" "$3" +' "$SELF" {} "$REPORTS" "$LOG" < "$TARGETS" \ + >> "$RESULTS" +echo ">> [$(ts)] 스캔 완료 ($(fmt_elapsed $(( $(date +%s) - P1 ))))" | tee -a "$LOG" >&2 + +TOTAL=$(( $(date +%s) - RUN_START )) +python3 - "$RESULTS" "$CHARTMAP" "$SEVERITY" "$TOTAL" "$(ts)" "$SUMMARY_TSV" <<'PY' | tee "$SUMMARY_MD" >&2 +import sys +results, chartmap, sev, total, tstamp, tsv_out = sys.argv[1:7] +total=int(total) +cm={} +try: + for l in open(chartmap): + p=l.rstrip("\n").split("\t") + if len(p)>=2: cm[p[0]]=p[1] +except FileNotFoundError: pass +best={} +for l in open(results): + p=l.rstrip("\n").split("\t") + if len(p)<7: continue + img=p[0] + if p[5]=="OK" or img not in best or best[img][5]!="OK": best[img]=p +rows=list(best.values()) +def key(r): + try: return (-int(r[1]),-int(r[2])) + except: return (1,0) +rows.sort(key=key) +ok=[r for r in rows if r[5]=="OK"]; err=[r for r in rows if r[5]!="OK"] +tc=sum(int(r[1]) for r in ok); th=sum(int(r[2]) for r in ok) +secs=[int(r[6]) for r in rows if len(r)>=7 and r[6].isdigit()] +with open(tsv_out,"w") as f: + for r in rows: + f.write(f"{cm.get(r[0],'')}\t{r[0]}\t{r[1]}\t{r[2]}\t{r[3]}\t{r[4]}\t{r[5]}\t{r[6] if len(r)>=7 else ''}\n") +print("# Trivy SBOM 취약점 요약\n") +print(f"- 생성: {tstamp}") +print(f"- 총 소요: **{total//60}분 {total%60}초** (SBOM {len(rows)}개)") +print(f"- 성공/실패: **{len(ok)} / {len(err)}**") +print(f"- 집계 심각도: `{sev}`") +print(f"- 취약점 합계: **CRITICAL {tc}**, **HIGH {th}**") +if secs: print(f"- SBOM당 스캔시간: 최소 {min(secs)}s / 최대 {max(secs)}s / 평균 {sum(secs)//len(secs)}s") +print() +print("| Chart | Image | CRIT | HIGH | MED | LOW | Status | Sec |") +print("|---|---|---:|---:|---:|---:|---|---:|") +for r in rows: + sec=r[6] if len(r)>=7 else "-" + print(f"| {cm.get(r[0],'')} | `{r[0]}` | {r[1]} | {r[2]} | {r[3]} | {r[4]} | {r[5]} | {sec} |") +PY + +{ + echo "===================================================================" + echo "SBOM SCAN END : $(ts) TOTAL: $(fmt_elapsed "$TOTAL")" + echo "요약: $SUMMARY_MD" + echo "===================================================================" +} | tee -a "$LOG" >&2 +rm -f "$RESULTS" "$CHARTMAP" "$TARGETS" diff --git a/manifests/helm/mlflow/1.9.0/custom-values.yaml b/manifests/helm/mlflow/1.9.0/custom-values.yaml index 4b9fd4b..28aa513 100644 --- a/manifests/helm/mlflow/1.9.0/custom-values.yaml +++ b/manifests/helm/mlflow/1.9.0/custom-values.yaml @@ -23,6 +23,11 @@ postgresql: database: mlflow image: repository: bitnamilegacy/postgresql + # bitnamilegacy 레지스트리에 postgresql v18 버전 태그가 없어(서브차트는 v18 요구, + # bitnami legacy 지원 중단 #83267) chart 기본값이 latest 로 풀린다. 재현성을 위해 + # 조회 시점(2026-07-08) latest 내용을 digest 로 고정한다. digest 는 tag 를 override 한다. + # 상세: doc/sbom-pipeline.md (운영 노트 — digest 고정) + digest: "sha256:42a8200d35971f931b869ef5252d996e137c6beb4b8f1b6d2181dc7d1b6f62e0" primary: persistence: enabled: true diff --git a/manifests/helm/vllm/0.0.11/custom-values.yaml b/manifests/helm/vllm/0.0.11/custom-values.yaml index 236f05d..537164e 100644 --- a/manifests/helm/vllm/0.0.11/custom-values.yaml +++ b/manifests/helm/vllm/0.0.11/custom-values.yaml @@ -2,7 +2,7 @@ servingEngineSpec: modelSpec: - name: "qwen" repository: "vllm/vllm-openai" - tag: "latest" + tag: "v0.24.0" # latest 고정 해제 (재현성). 상세: doc/sbom-pipeline.md modelURL: "Qwen/Qwen2.5-Coder-14B-Instruct" replicaCount: 1