Files
service-catalog/doc/sbom-pipeline.md
T
wbsong111 4f67f63f69 SBOM 파이프라인 문서 정비 + 이미지 목록 열거 제거
## doc/sbom-pipeline.md — 중복·모순 정리 (328 → 286줄)

같은 사실이 여러 절에 흩어져 있었고, 일부는 문서가 아니라 변경 이력이었다.

- `SEVERITY` 를 전 심각도로 덮어써야 하는 이유가 환경변수 절·CI 절·요약 절 3곳에
  있었다. 스크립트 절의 blockquote 하나로 합쳤다 — "게이트를 돌릴 거라면 전 심각도로
  스캔해야 한다"가 핵심이고 나머지는 그 결과다.
- Job Summary 1MB 제한이 CI 절과 결과 확인 절에 중복됐다. CI 절 하나로 합쳤다.
- `--warn-only` 서술이 mermaid 라벨·CI 절·게이트 절·트리아지 절 4곳에 있었다.
  게이트 절 하나로 합치고, `build-image.yml` 쪽은 이미 강제라는 대비를 함께 적었다.
- 자체 빌드 트리거 표가 "PR 은 push 안 함"을 말하는데 바로 아래 불릿이 같은 말을
  반복했다. 표는 그대로 두고 불릿은 **왜** 그런지(REGISTRY 미전달 → localhost 태그라
  push 를 시도할 수조차 없다)만 남겼다.
- "오해를 주던 단일 '총 소요'는 제거" 같은 변경 이력 서술을 걷어냈다. 문서는 현재
  상태를 적는 곳이다.
- "첫 전체 실행 결과(2026-07-08)" 절은 수치를 싣고 바로 아래에서 "현재 수치가
  아니다"로 무효화하는 구조였다. 절 자체를 없애고, 거기서 유일하게 쓸모 있던 사실
  (SBOM 생성 실패는 대부분 사설/미인증 레지스트리이거나 대용량 timeout)만 스크립트
  절로 옮겼다.
- "실행 이력(2026-08-04)" 절은 MEMORY.md 와 중복이라 제거했다. 거기서만 알 수 있던
  사실(Actions 시크릿의 push 권한 확인)은 GitHub 설정 표에 반영했다.
- `CVE_API_KEY` 가 본문에만 있고 GitHub 설정 표에 빠져 있어 추가했다.
- 아키텍처 절 불릿이 mermaid 서브그래프 라벨과 같은 말을 하고 있어, "pull 을 ② 한
  곳에 몰아둔 것이 핵심"이라는 결론 한 문장으로 줄였다.

## 이미지 목록을 문서에 박아두지 않는다

이미지는 계속 추가되므로 열거하면 추가할 때마다 낡는다. `images/` 디렉토리를 단일
출처로 삼고 CLAUDE.md·image-authoring.md·build-image.yml·sbom-pipeline.md 의 열거를
걷어냈다. keycloak README 의 베이스 OS 결정 근거도 "기존 3종" 대신 "먼저 들어온
이미지들"로 바꿨다 — 근거의 내용은 그대로다.

## 현황 서술 정정

- build-image.yml 주석이 "아직 도입된 자체 빌드 이미지가 없다(images/ 가 비어 있음)"
  로 남아 있었다. 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지
  실제로 검증된 상태다.
- MEMORY.md: cve-exceptions.json 첫 예외 등록, 베이스 OS 정책 확정, PR 자동 생성이
  조직 정책으로 불가하다는 실측(run 30882785612)을 반영했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:27:03 +09:00

18 KiB

Helm 카탈로그 SBOM 생성 파이프라인

manifests/helm/ 정적 카탈로그의 모든 컨테이너 이미지에 대한 SBOM(CycloneDX)과 취약점 리포트를 결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행하며, GitHub Actions(.github/workflows/sbom.yml)로 자동화한다.

왜 필요한가

GitHub Dependency-Graph SBOM(예: paasup_dip-catalog_199087.json)은 pypi 등 소스 매니페스트만 담고 Helm 차트가 참조하는 컨테이너 이미지는 하나도 포함하지 못한다(Helm 미지원 + 이미지 미스캔). 이 파이프라인이 그 공백을 메운다 — helm template 렌더로 실제 배포 이미지를 뽑고, 각 이미지의 SBOM 과 취약점을 생성한다.

아키텍처 (SBOM-first)

이미지를 한 번만 pull 해서 SBOM 을 만들고, 취약점은 그 SBOM 에서 오프라인으로 뽑는다.

flowchart TB
    CAT[/"manifests/helm/**<br/>차트 카탈로그"/]

    subgraph S1["① 인벤토리 추출 &nbsp;·&nbsp; 오프라인 · 결정론"]
        direction LR
        EX["extract-helm-images.sh"] --> INV[/"images_final.tsv<br/>chart ⇥ version ⇥ image"/]
    end

    subgraph S2["② SBOM 생성 &nbsp;·&nbsp; 네트워크 · 레지스트리 인증 필요 · 비용의 대부분"]
        direction LR
        GN["generate-sbom.sh"] --> SB[/"sbom/*.cdx.json<br/>CycloneDX"/]
    end

    subgraph S3["③ 스캔 · 게이트 &nbsp;·&nbsp; 오프라인 · 인증 불필요 · 초 단위 반복 가능"]
        direction LR
        SC["scan-sbom.sh<br/>+ CoverageProbe"] --> RP[/"trivy-reports/*.json<br/>trivy-summary.md"/]
        RP --> GT["cve-gate.py"]
        GT --> VD[/"cve-gate.md<br/>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/<img>.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.logrender_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.ymlUNKNOWN,LOW,MEDIUM,HIGH,CRITICAL, cve-edge-post.ymlLOW,MEDIUM,HIGH,CRITICAL.

로컬/컨테이너 실행

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/wbsong111/sbom-pipeline:latest 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/wbsong111/sbom-pipeline:latest (public) — paasup 네임스페이스 이전 미완료(MEMORY.md)

glibc(debian) 필수: GitHub Actions 의 container: 안에서 actions/checkout·upload-artifact (node 기반)가 동작하려면 glibc 이거나 node 가 있어야 한다. aquasec/trivy 같은 alpine(musl) 이미지는 node 실행 실패 → debian 사용. amd64 필수(러너 아키텍처).

docker buildx build --platform linux/amd64 \
  -t docker.io/paasup/sbom-pipeline:latest \
  -f scripts/pipeline/Dockerfile --push scripts/pipeline

credsStore(Docker Desktop) 환경에서 docker-container 빌더로 --push 시 인증 실패하면, 단일 아키텍처를 로컬 적재(--load) 후 docker push 하거나 인라인 토큰 config 를 쓴다.

CI (.github/workflows/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 은 축약 리포트가 "상세는 이 아티팩트를 보라"고 안내할 때 쓸 이름이다.

gh workflow run helm-catalog-sbom --repo <org>/dip-catalog -f limit=3   # 빠른 검증
gh workflow run helm-catalog-sbom --repo <org>/dip-catalog              # 전체(limit=0)
gh run watch --repo <org>/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 입력. schedule 은 주석 처리 상태다.
  • 이미지 하나가 여러 manifests/helm/<name>/<version>/ 에서 재사용되면 그 조합 수만큼 항목이 중복된다 (의도된 동작 — 소비 측이 카탈로그 단위로 집계한다).

CVE 게이트 (scripts/pipeline/cve-gate.py)

스캔이 끝나면 cve-gate.pytrivy-reports/*.json 을 판정한다: 고유 CVE 단위 집계, max(벤더 등급, NVD 등급) 실효 등급, doc/cve-exceptions.json 승인 예외 처리. 상세 판정 로직·근거는 스크립트 자체의 모듈 docstring 을 우선 참고한다.

sbom.yml 에서는 아직 --warn-only — 게이트가 실패해도 워크플로/PR 을 막지 않는다. 카탈로그 차트 전체가 아직 이 게이트로 트리아지된 적이 없어, 강제 전환 전에 먼저 전체 스캔 1회로 현황을 파악해야 한다(MEMORY.md). 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건이면 보수적으로 실패 처리하는 예전 경로로 판정한다.

자체 빌드 (.github/workflows/build-image.yml)

게이트 대응 우선순위(상위 태그 교체 → 베이스 OS 교체 → 자체 빌드 → 예외 승인)의 세 번째 레버를 실행하는 워크플로다. 대상은 images/<image>/ 에 빌드 정의가 있는 이미지이고(목록은 그 디렉토리가 단일 출처), 실행기는 scripts/build/build-hardened-image.sh 하나다 — 빌드 → verify.sh → SBOM → scan-sbom.shcve-gate.py 를 순서대로 부른다(이 문서의 파이프라인을 그대로 재사용). 신규 이미지 추가 절차·계약(build.env/catalog.env)은 .claude/image-authoring.md.

sbom.yml 과 별도 파일인 이유: sbom.ymlvars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다. 빌드는 호스트 러너여야 한다.

트리거 대상 결정 빌드·검증·게이트 레지스트리 push 카탈로그 태그 갱신
workflow_dispatch image 입력(필수) · base_os(비우면 catalog.envDEFAULT_BASE_OS) · push(기본 true) push=true 일 때만 게이트 PASS + 태그 변경 시
pull_request (images/**) 변경된 images/<image>/ 를 diff 로 자동 탐지(복수면 매트릭스 병렬)

PR 트리거가 push 하지 않는 것은 REGISTRY 를 넘기지 않기 때문이다 — 태그가 localhost/... 로 남아 push 를 시도할 수조차 없다(검증 전용 안전장치).

  • schedule 트리거는 없다. 블라인드 정기 재빌드는 "정말 개선인지"를 매번 되묻게 만들어 제거했다.
  • sbom.yml 의 게이트가 이 워크플로를 자동 호출하지 않는다. 차단 CVE 를 찾아도 자체 빌드로 갈지는 사람이 판단해 수동 실행한다 — 두 워크플로 사이에 자동 연결은 없다.
  • 레지스트리는 REGISTRY_HOST: docker.io/paasup 고정. 인증은 DOCKERHUB_USER/DOCKERHUB_TOKEN.

카탈로그 반영 — PR 은 자동 생성되지 않는다

게이트 PASS 이고 새 태그가 현재 카탈로그 태그와 다르면, 워크플로가 catalog.envCHART_DIRS 아래 custom-values.yaml/dip-values.yamlscripts/build/patch-catalog-tag.py 로 갱신하고 build/<image>-<타임스탬프> 브랜치를 push 한다. 여기까지가 자동이다.

PR 오픈은 사람이 한다 — GITHUB_TOKEN 으로 PR 을 만드는 것 자체를 조직 정책이 막는다("Allow GitHub Actions to create and approve pull requests" 미허용, 리포 설정으로 변경 불가). 2026-08-04 실측으로 확인했고(run 30882785612, GraphQL: GitHub Actions is not permitted to create or approve pull requests), gh pr create 호출은 워크플로에서 제거했다. 대신 Job Summary 에 compare 링크가 남는다.

병합 전에 사람이 해야 하는 것:

  1. compare 링크로 PR 오픈.
  2. gh workflow run helm-catalog-sbom --ref <브랜치> 로 카탈로그 게이트 확인.
  3. 배포 검증 — 게이트 PASS 는 "동작한다"를 증명하지 않는다(스캐너는 런타임 요구사항을 보지 못한다). 절차: .claude/deploy-test-procedure.md.

GitHub 설정 (워크플로 활성화에 필요)

Settings → Secrets and variables → Actions

종류 이름 용도
Variable SBOM_PIPELINE_IMAGE 실행 이미지 태그 (예: docker.io/wbsong111/sbom-pipeline:latest)
Secret DOCKERHUB_USER / DOCKERHUB_TOKEN docker.io + docker.getcollate.io rate limit 회피. getcollate(openmetadata)는 Docker Hub 프록시라 익명 pull 시 rate limit(TOOMANYREQUESTS)에 걸림 → Docker Hub 자격증명으로 인증. build-image.yml 의 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 <run-id> -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
    ├── <img>.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 탈피를 검토한다.