# Helm 카탈로그 SBOM 생성 파이프라인 `manifests/helm/` 정적 카탈로그의 **모든 컨테이너 이미지에 대한 SBOM(CycloneDX)과 취약점 리포트**를 결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행하며, GitHub Actions([.github/workflows/sbom.yml](../.github/workflows/sbom.yml))로 자동화한다. ## 이 문서가 다루는 축 카탈로그의 CVE 파이프라인은 **두 축**이고 이 문서는 그중 하나만 다룬다. | | 다룬다 — **차트 카탈로그 축** | 다루지 않는다 — **자체 빌드 축** | |---|---|---| | 질문 | 우리가 배포하는 이미지에 무엇이 있는가 | 그 이미지를 어떻게 만드는가 | | 실행 위치 | 이 레포 | 별도 레포 `security-images` | | 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `security-images` 의 `build-image.yml`(이 레포에서는 `self-build-drift-check.yml` 이 필요할 때 그것을 부른다) | | 단일 출처 | **이 문서** | `security-images` 레포의 `docs/image-authoring.md` | **카탈로그 values 가 자체 빌드 이미지(`docker.io/paasup/*`)를 가리키므로 그 이미지도 이 문서의 스캔 대상이다** — 축이 갈린 것은 "누가 만들고 결정하는가" 이고 "누가 스캔되는가" 가 아니다. 자체 빌드 이미지도 카탈로그가 배포하는 한 여기서 판정된다. ## 왜 필요한가 GitHub Dependency-Graph SBOM(예: `paasup_dip-catalog_199087.json`)은 pypi 등 소스 매니페스트만 담고 **Helm 차트가 참조하는 컨테이너 이미지는 하나도 포함하지 못한다**(Helm 미지원 + 이미지 미스캔). 이 파이프라인이 그 공백을 메운다 — `helm template` 렌더로 실제 배포 이미지를 뽑고, 각 이미지의 SBOM 과 취약점을 생성한다. ## 아키텍처 (SBOM-first) 이미지를 **한 번만 pull** 해서 SBOM 을 만들고, 취약점은 그 SBOM 에서 오프라인으로 뽑는다. ```mermaid flowchart TB CAT[/"manifests/helm/**
차트 카탈로그"/] subgraph S1["① 인벤토리 추출  ·  오프라인 · 결정론"] direction LR EX["extract-helm-images.sh"] --> INV[/"images_final.tsv
chart ⇥ version ⇥ image"/] end subgraph S2["② SBOM 생성  ·  네트워크 · 레지스트리 인증 필요 · 비용의 대부분"] direction LR GN["generate-sbom.sh"] --> SB[/"sbom/*.cdx.json
CycloneDX"/] end subgraph S3["③ 스캔 · 게이트  ·  오프라인 · 인증 불필요 · 초 단위 반복 가능"] direction LR SC["scan-sbom.sh
+ CoverageProbe"] --> RP[/"trivy-reports/*.json
trivy-summary.md"/] RP --> GT["cve-gate.py"] GT --> VD[/"cve-gate.md
cve-gate.json"/] end CAT -- "helm template" --> EX INV -- "trivy image · 이미지 1회 pull" --> GN SB -- "trivy sbom" --> SC classDef step fill:#dbeafe,stroke:#3b82f6,stroke-width:1.5px,color:#12305c classDef art fill:#f1f3f5,stroke:#adb5bd,color:#212529 class EX,GN,SC,GT step class CAT,INV,SB,RP,VD art ``` **레지스트리 pull·인증을 ② 한 곳에 몰아둔 것이 이 구조의 핵심이다.** CVE DB 가 갱신돼도 재pull 없이 ③ 만 다시 돌리면 된다. 스캔 대상 이미지는 trivy 가 원격에서 받아 분석 후 폐기하므로 호스트에 남지 않는다. ## 스크립트 (`scripts/pipeline/`) 바이너리(`helm`/`trivy`/`python3`)를 **컨테이너 내부에서 직접 호출**한다(도커 소켓·docker CLI 불필요). | 스크립트 | 입력 | 출력 | |---------|------|------| | `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.{md,tsv}`, `trivy-reports/`, `trivy-run.log` | SBOM 생성 실패는 대부분 **사설/미인증 레지스트리이거나 대용량 이미지 timeout** 이다 — `sbom-gen.log` 와 `render_status.tsv` 로 확인한다. 주요 환경변수: `PARALLEL`(동시 처리 — generate 기본 3/이미지 pull 이라 보수적, scan 기본 4/오프라인이라 높여도 안전), `LIMIT`(대상 상한, 0=전체 — 테스트용), `SEVERITY`(스크립트 기본 `HIGH,CRITICAL`), `TRIVY_CACHE_DIR`(캐시 디렉토리), `MERGE=1`(generate 시 `catalog.cdx.json` 병합, cyclonedx CLI 필요). 사설 레지스트리 인증은 trivy 네이티브: `TRIVY_USERNAME`/`TRIVY_PASSWORD`(단일) 또는 `DOCKER_CONFIG`(다중). > **게이트를 돌릴 거라면 전 심각도로 스캔해야 한다.** `cve-gate.py` 는 벤더 하향 등급·NVD 재평가· > 사각지대 판정에 MEDIUM/LOW 데이터까지 쓴다. `HIGH,CRITICAL` 만 스캔한 리포트로 게이트를 돌리면 > 판정 자체가 달라진다. 그래서 CI 는 스크립트 기본값을 덮어쓴다 — `sbom.yml` 은 > `UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL`, `cve-edge-post.yml` 은 `LOW,MEDIUM,HIGH,CRITICAL`. ### 로컬/컨테이너 실행 ```bash docker run --rm -v "$PWD:/repo" -w /repo \ -e TRIVY_CACHE_DIR=/repo/sbom-out/cache -e DOCKER_CONFIG=/repo/sbom-out/.docker \ docker.io/paasup/sbom-pipeline:20260820 bash -c ' bash scripts/pipeline/extract-helm-images.sh manifests/helm sbom-out bash scripts/pipeline/generate-sbom.sh sbom-out/images_final.tsv sbom-out # 인증: DOCKER_CONFIG/TRIVY_USERNAME bash scripts/pipeline/scan-sbom.sh sbom-out ' # 빠른 검증: generate 단계에 -e LIMIT=3 ``` ## 실행 이미지 (`scripts/pipeline/Dockerfile`) | 항목 | 값 | |------|------| | 베이스 | `debian:stable-slim` (**glibc**) | | 포함 도구 | `helm`(v3) · `trivy` · `python3` · `bash` · `git` | | 아키텍처 | **linux/amd64** (GitHub 러너와 일치) | | 현재 이미지 | `docker.io/paasup/sbom-pipeline:20260820` (public) | > **glibc(debian) 필수**: GitHub Actions 의 `container:` 안에서 `actions/checkout`·`upload-artifact` > (node 기반)가 동작하려면 glibc 이거나 node 가 있어야 한다. `aquasec/trivy` 같은 **alpine(musl) > 이미지는 node 실행 실패** → debian 사용. **amd64 필수**(러너 아키텍처). ```bash docker buildx build --platform linux/amd64 \ -t docker.io/paasup/sbom-pipeline:$(date -u +%Y%m%d) \ -f scripts/pipeline/Dockerfile --push scripts/pipeline ``` > credsStore(Docker Desktop) 환경에서 `docker-container` 빌더로 `--push` 시 인증 실패하면, > 단일 아키텍처를 로컬 적재(`--load`) 후 `docker push` 하거나 인라인 토큰 config 를 쓴다. ## CI — 이 축의 워크플로 2개 같은 스크립트(`extract` → `generate-sbom` → `scan-sbom`)와 같은 실행 이미지를 쓰지만 **뒤가 다르다.** | | `sbom.yml` | `cve-edge-post.yml` | |---|---|---| | 목적 | 게이트 판정 | 외부 엔드포인트로 집계 POST | | PR 트리거 | `manifests/helm/**` | **없음** | | schedule | 일 18:00 UTC | **주석 처리** (수동만) | | **게이트** | `cve-gate.py` **`--warn-only`** | **호출하지 않음** | | 산출 | 아티팩트 `sbom-and-vuln-report` | `cve-summary.json` → POST | > **판정기가 두 벌이라는 뜻이다.** `cve-edge-post.yml` 은 집계를 워크플로 YAML 안의 인라인 > python 으로 갖고 있어 **승인 예외(`cve-exceptions.json`)도 실효 등급(`max(벤더,NVD)`)도 > 적용하지 않는다.** 같은 스캔 데이터에서 다른 숫자가 나올 수 있다. **PR 을 실제로 막는 게이트는 이 축에 없다** — `sbom.yml` 은 warn-only 다. 강제 게이트는 자체 빌드 축(`security-images` 레포의 `images/**` PR)에만 있다. > **`manifests/applicationset/**` 를 스캔하지 않는 것은 의도다.** 그 아래 `dip-values.yaml` 은 > dip-console 이 배포 values 를 만들 때 쓰는 **참조 파일**이고, 같은 이미지를 `manifests/helm/` > 차트에서 이미 스캔·게이트한다. 같은 이미지를 두 번 스캔할 이유가 없어 인벤토리 추출 대상을 > `manifests/helm/` 으로 한정했다 — **미검사 결함이 아니다.** > > 단, 그 가정은 **"두 곳이 같은 이미지를 가리킨다"** 에 의존한다. 참조 파일이 차트와 다른 > 태그를 들고 있으면 스캔한 것과 배포되는 것이 갈린다 — 태그 동기화는 스캔 커버리지와 별개 > 문제이고, 자체 빌드 이미지에 대해서는 `catalog/image-map/.env` 의 `CHART_DIRS` 가 > 그 범위를 결정한다(`manifests/applicationset/**` 는 포함하지 않는다 — MEMORY.md #42 참고). ### `sbom.yml` 스캔 범위 | 트리거 | 스캔 범위 | |--------|----------| | `workflow_dispatch` | 입력 `limit`(기본 0=전체) | | `pull_request` (`manifests/helm/**`) | **변경된 차트의 이미지만** (빠른 게이트) | | `schedule` (`0 18 * * 0`, 매주 일요일 18:00 UTC) | **전체** | 인벤토리·SBOM 은 차트 변경 시에만 바뀌지만 **취약점은 새 CVE 로 계속 변하므로 스케줄 전체 스캔이 필수**다. - **컨테이너**: `vars.SBOM_PIPELINE_IMAGE` 이미지에서 실행. - **인증**: 시크릿으로 `~/.docker/config.json`(인라인 토큰)을 만들어 `DOCKER_CONFIG` 로 trivy 에 전달. - **아티팩트**: 스테이징 디렉토리(`report/`)로 모아 **상대경로**로 업로드(절대경로면 v4 가 전체 경로트리를 보존). - **게이트 판정 범위는 `--inventory` 가 정한다** — PR 증분에서는 `images_scan.tsv`(변경 차트 이미지만)라 **그 PR 이 건드리지 않은 차트는 게이트가 보지 않는다.** 전체 판정은 스케줄/수동 실행에서만 나온다. > **Job Summary 는 축약본만 싣는다.** `$GITHUB_STEP_SUMMARY` 는 1MB 를 넘으면 잘리는데 카탈로그 전체 > 스캔의 `cve-gate.md` 는 실측 2.2MB 라 실제로 잘렸다. 그래서 Summary 에는 `trivy-summary.md`(건수 표)와 > `cve-gate-brief.md`(판정 요약)만 넣고, **CVE 상세는 아티팩트의 `cve-gate.md`** 에서 본다. > `--artifact-name` 은 축약 리포트가 "상세는 이 아티팩트를 보라"고 안내할 때 쓸 이름이다. ```bash gh workflow run helm-catalog-sbom --repo /dip-catalog -f limit=3 # 빠른 검증 gh workflow run helm-catalog-sbom --repo /dip-catalog # 전체(limit=0) gh run watch --repo /dip-catalog ``` ### `cve-edge-post.yml` 상세 `(catalog, version, image)` 단위 취약점 건수 + CRITICAL 설명을 **단일 JSON 으로 만들어 외부 엔드포인트(`https://edge.gke.paasup.io/api/v1/cve-scans`)로 POST** 한다(`X-CVE-API-Key`, Repo Secret `CVE_API_KEY`). - 트리거는 `workflow_dispatch` 뿐 — `chart`(빈 값=전체) · `limit` 입력. - 이미지 하나가 여러 `manifests/helm///` 에서 재사용되면 그 조합 수만큼 항목이 중복된다 (의도된 동작 — 소비 측이 카탈로그 단위로 집계한다). ## CVE 게이트 (`scripts/pipeline/cve-gate.py`) 스캔이 끝나면 `cve-gate.py` 가 `trivy-reports/*.json` 을 판정한다: 고유 CVE 단위 집계, `max(벤더 등급, NVD 등급)` 실효 등급, `doc/cve-exceptions.json` 승인 예외 처리. 상세 판정 로직·근거는 스크립트 자체의 모듈 docstring 을 우선 참고한다. **`sbom.yml` 에서는 아직 `--warn-only` 다** — 게이트가 실패해도 워크플로/PR 을 막지 않는다. 카탈로그 차트 전체가 아직 이 게이트로 트리아지된 적이 없어, 강제 전환 전에 먼저 전체 스캔 1회로 현황을 파악해야 한다(`MEMORY.md`). 자체 빌드 축(`security-images` 레포의 `build-image.yml`)의 게이트는 이미 강제다 — 단, 그 게이트는 별도 레포가 소유한다. > **커버리지 자가진단(`CoverageProbe`).** `scan-sbom.sh` 는 os-pkgs findings 가 0건인 > 이미지의 SBOM 사본에 배포판별(deb/rpm/apk) 센티널 패키지를 주입해 재스캔하고, 발화 > 여부로 `ok`(데이터 있음, 0건은 진짜 0건) / `none`(데이터 없음 → 게이트 차단) / > `n/a`(OS 패키지 없음) 를 리포트에 기록한다 — "0건"과 "측정되지 않음"을 구분하는 > 유일한 수단이다(security-catalog 프로젝트에서 이식, 2026-08-03). 이 키가 없는 구버전 > 리포트는 findings 총계 0건이면 보수적으로 실패 처리하는 예전 경로로 판정한다. ## 자체 빌드 축은 이 문서가 다루지 않는다 게이트가 상위 태그 교체·베이스 OS 교체로 해소되지 않는 차단 CVE 를 찾으면 자체 빌드로 간다. 그 축은 **별도 레포 `security-images`** 가 갖는다 — 빌드·검증·게이트·push 전부 그 레포 안에서 이루어지고, 단일 출처는 그 레포의 `docs/image-authoring.md` 다. 이 카탈로그에는 "어느 차트가 그 이미지를 가리키는가"(`catalog/image-map/`)와 드리프트 탐지 (`scripts/build/check-rebuild-needed.py`, 주간 `self-build-drift-check.yml`)만 남아 있다 — 이관 배경은 [doc/migrations/](migrations/self-build-images-to-security-images.md). 이 문서가 알아야 할 것은 하나뿐이다: **자체 빌드 이미지도 카탈로그가 가리키는 한 위 스캔·게이트 대상이다.** 실제로 `docker.io/paasup/*` 가 게이트 리포트에 등장한다. ## GitHub 설정 (워크플로 활성화에 필요) `Settings → Secrets and variables → Actions` | 종류 | 이름 | 용도 | |------|------|------| | **Variable** | `SBOM_PIPELINE_IMAGE` | 실행 이미지 태그 (예: `docker.io/paasup/sbom-pipeline:20260820`) | | Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io + docker.getcollate.io rate limit 회피**. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. `security-images` 레포도 자체 push 용으로 별도 등록된 같은 이름의 시크릿을 쓴다(이 레포와는 무관하게 그 레포에 따로 등록) | | Secret | `NGC_API_KEY` | **nvcr.io 인증**(NVIDIA nemo/nim — 없으면 pull 불가) | | Secret | `CVE_API_KEY` | `cve-edge-post.yml` 의 외부 엔드포인트 인증 | 추가 고려: - `SBOM_PIPELINE_IMAGE` 가 **사설**이면 워크플로 `container:` 에 `credentials:` 추가 필요. - 저장소 Actions 활성화(org 정책). 서드파티 액션은 GitHub 공식(`checkout`·`upload-artifact`)만 사용. - `DOCKERHUB_TOKEN` 이 Docker Desktop 세션 토큰이면 만료 가능 → 장기적으로 **스코프 지정 PAT** 권장. ## 결과 확인 `gh run download -n sbom-and-vuln-report -D ./out` ``` sbom-and-vuln-report/ ← 아티팩트 루트 ├── trivy-summary.md ← 취약점 요약(차트별 표) ├── trivy-summary.tsv ├── cve-gate.md ← 게이트 판정 전문 (CVE 상세 전부 — 실질적인 트리아지 입력) ├── cve-gate.json ← 게이트 판정 결과(기계 판독용) ├── sbom-index.tsv ← 차트↔이미지↔SBOM 인덱스 ├── catalog.cdx.json ← MERGE=1 로 병합한 경우에만 ├── sbom-gen.log / trivy-run.log └── sbom/ ← 이미지별 상세 CycloneDX SBOM ├── .cdx.json └── ... ``` ### 요약(`trivy-summary.md`) 읽는 법 - **단계별 소요시간**: `SBOM 생성 소요`(이미지 pull, 주 비용) / `취약점 스캔 소요`(오프라인). - **심각도 컬럼은 실제 스캔한 `SEVERITY` 만** 나온다. CI 는 전 심각도라 컬럼도 전부 나오고, 로컬에서 스크립트 기본값으로 돌리면 `CRITICAL`·`HIGH` 두 컬럼만 나온다. - **차트명 기준으로 묶고 차트마다 합계 행**을 붙인다. 이미지 하나가 여러 차트에 쓰이면(예: `busybox`) 차트마다 한 행씩 나온다 — 그래야 차트 합계가 그 차트의 실제 노출을 뜻한다. - TSV 는 `chart⇥image⇥<심각도들>⇥status`(CRITICAL 이 첫 카운트 열), `.md` 표와 같은 범위를 담는다. > **표에는 차트별 최신 버전만 나온다.** 카탈로그는 한 차트의 여러 버전을 동시에 보관하는 "버전 보관소"라 > 전 버전을 표에 실으면 읽을 수 없게 된다. **구버전도 스캔·게이트 판정은 전부 받는다** — 다만 그 결과는 > `cve-gate.md`/`cve-gate.json` 에만 있다. 요약 표에 안 보인다고 판정 대상에서 빠진 것이 아니다. ## 렌더 함정 (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회 워밍한다. ## 결과 대응 (트리아지) 원시 CVE 수를 0으로 만드는 것이 목표가 아니다. **고칠 수 있고, 악용 가능하고, 노출된** 것부터 대응한다. 1. **우선순위**: fix 가능(`--ignore-unfixed`) → KEV/EPSS(실제 악용) → 노출도(인터넷 vs 내부) → 심각도. 2. **대응 레버(모두 Git PR)**: 차트/이미지 **버전 업**(최고 레버리지) → **베이스 이미지 교체**(특히 미유지 `bitnamilegacy/*`) → **자체 빌드**(위 절) → 수용. 앞 레버로 안 되는 것만 다음으로 넘긴다. 3. 수용하는 CVE 는 `doc/cve-exceptions.json` 에 **근거·만료일을 명시**해 등록한다. 4. 새 CVE 는 계속 나오므로 스케줄 스캔으로 **추세 추적**. ## 운영 노트 — `: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 탈피를 검토한다.