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