"베이스 OS 교체"를 독립 레버로 두던 기존 표현을 없앤다 — 이 저장소의 베이스 교체 이력을 전부 찾아보면 예외 없이 hardened-containers 자체 빌드 커밋이다(argocd· keycloak·cnpg-postgresql/etcd·apisix). 다른 배포판의 backport를 실제로 쓰려면 그 배포판 위에 앱을 다시 얹어야 하므로 그 자체가 이미 자체 빌드다. 빌드 없이 끝나는 진짜 무료 치환은 태그 교체와, 관리가 끊긴 벤더 이미지(bitnamilegacy 등)를 활성 벤더의 기존 이미지로 바꾸는 것뿐이다. dip-catalog는 이제 이미지를 전혀 빌드하지 않는다(자체 빌드는 hardened-containers로 완전히 이관됨) — 이 사실을 CLAUDE.md·관련 skill에 명시한다. 유일한 예외인 apisix keycloak-authz는 빌드가 아니라 이미 private으로 빌드된 이미지의 태그 반영이라 원칙에 위배되지 않는다. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
19 KiB
Helm 카탈로그 SBOM 생성 파이프라인
manifests/helm/ 정적 카탈로그의 모든 컨테이너 이미지에 대한 SBOM(CycloneDX)과 취약점 리포트를
결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행하며,
GitHub Actions(.github/workflows/sbom.yml)로 자동화한다.
이 문서가 다루는 축
카탈로그의 CVE 파이프라인은 두 축이고 이 문서는 그중 하나만 다룬다.
| 다룬다 — 차트 카탈로그 축 | 다루지 않는다 — 자체 빌드 축 | |
|---|---|---|
| 질문 | 우리가 배포하는 이미지에 무엇이 있는가 | 그 이미지를 어떻게 만드는가 |
| 실행 위치 | 이 레포 | 별도 레포 hardened-containers |
| 워크플로 | sbom.yml · cve-edge-post.yml |
hardened-containers 의 build-image.yml·rescan.yml(이 레포는 트리거하지 않는다) |
| 단일 출처 | 이 문서 | hardened-containers 레포의 docs/image-authoring/README.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 에서 오프라인으로 뽑는다.
flowchart TB
CAT[/"manifests/helm/**<br/>차트 카탈로그"/]
subgraph S1["① 인벤토리 추출 · 오프라인 · 결정론"]
direction LR
EX["extract-helm-images.sh"] --> INV[/"images_final.tsv<br/>chart ⇥ version ⇥ image"/]
end
subgraph S2["② SBOM 생성 · 네트워크 · 레지스트리 인증 필요 · 비용의 대부분"]
direction LR
GN["generate-sbom.sh"] --> SB[/"sbom/*.cdx.json<br/>CycloneDX"/]
end
subgraph S3["③ 스캔 · 게이트 · 오프라인 · 인증 불필요 · 초 단위 반복 가능"]
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.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.
로컬/컨테이너 실행
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 필수(러너 아키텍처).
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))도 적용하지 않는다. 같은 스캔 데이터에서 다른 숫자가 나올 수 있다.
두 워크플로 모두 날짜 단위 키(trivy-db-<YYYYMMDD>)로 trivy 취약점 DB를 actions/cache에
캐싱한다 — 실패해도 워크플로를 막지 않는다(continue-on-error, 캐시는 최적화일 뿐 정확성
요건이 아니다). concurrency 그룹도 있다 — sbom.yml은 sbom-${{ github.ref }}(PR은
새 커밋이 오면 이전 실행 취소, 스케줄은 취소 안 함), cve-edge-post.yml은 cve-edge-post
(항상 직렬화, 외부 POST 잡이라 취소 대신 대기).
PR 을 실제로 막는 게이트는 이 축에 없다 — sbom.yml 은 warn-only 다. 강제 게이트는
자체 빌드 축(hardened-containers 레포의 images/** PR)에만 있다.
manifests/applicationset/**를 스캔하지 않는 것은 의도다. 그 아래dip-values.yaml은 dip-console 이 배포 values 를 만들 때 쓰는 참조 파일이고, 같은 이미지를manifests/helm/차트에서 이미 스캔·게이트한다. 같은 이미지를 두 번 스캔할 이유가 없어 인벤토리 추출 대상을manifests/helm/으로 한정했다 — 미검사 결함이 아니다.단, 그 가정은 "두 곳이 같은 이미지를 가리킨다" 에 의존한다. 참조 파일이 차트와 다른 태그를 들고 있으면 스캔한 것과 배포되는 것이 갈린다 — 태그 동기화는 스캔 커버리지와 별개 문제이고, 자체 빌드 이미지에 대해서는
catalog/image-map/<image>.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은 축약 리포트가 "상세는 이 아티팩트를 보라"고 안내할 때 쓸 이름이다.
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입력. - 이미지 하나가 여러
manifests/helm/<name>/<version>/에서 재사용되면 그 조합 수만큼 항목이 중복된다 (의도된 동작 — 소비 측이 카탈로그 단위로 집계한다).
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). 자체 빌드 축(hardened-containers 레포의 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건이면 보수적으로 실패 처리하는 예전 경로로 판정한다.
자체 빌드 축은 이 문서가 다루지 않는다
게이트가 무료 치환(상위 태그 교체·관리가 끊긴 벤더 이미지 좌표 교체)으로 해소되지 않는
차단 CVE 를 찾으면 자체 빌드로 간다.
그 축은 별도 레포 hardened-containers 가 갖는다 — 빌드·검증·게이트·push·드리프트 재빌드
전부 그 레포 안에서 이루어지고(rescan.yml이 매일 자율 재스캔·재빌드), 단일 출처는 그
레포의 docs/image-authoring/README.md 다. 이 카탈로그에는 "어느 차트가 그 이미지를
가리키는가"(catalog/image-map/)만 남아 있다 — 이관 배경은
doc/migrations/.
이 문서가 알아야 할 것은 하나뿐이다: 자체 빌드 이미지도 카탈로그가 가리키는 한 위 스캔·게이트
대상이다. 실제로 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 자격증명으로 인증. hardened-containers 레포도 자체 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으로 만드는 것이 목표가 아니다. 고칠 수 있고, 악용 가능하고, 노출된 것부터 대응한다.
- 우선순위: fix 가능(
--ignore-unfixed) → KEV/EPSS(실제 악용) → 노출도(인터넷 vs 내부) → 심각도. - 대응 레버(모두 Git PR): 차트/이미지 버전 업(최고 레버리지) → 베이스 이미지 교체(특히 미유지
bitnamilegacy/*) → 자체 빌드(위 절) → 수용. 앞 레버로 안 되는 것만 다음으로 넘긴다. - 수용하는 CVE 는
doc/cve-exceptions.json에 근거·만료일을 명시해 등록한다. - 새 CVE 는 계속 나오므로 스케줄 스캔으로 추세 추적.
운영 노트 — :latest/무태그 이미지 digest 고정
태그가 고정돼 있어도 내용물이 바뀌는 :latest/무태그 이미지는 재현성 저해 + 증분 스캔의 사각지대다.
정기 전체 스캔 대상에 항상 포함하고, 가능하면 구체 버전 또는 digest(@sha256:...)로 고정한다.
적용된 고정(2026-07-08 기준):
mlflow@1.9.0postgresql →bitnamilegacy/postgresql@sha256:42a8200d...(legacy 에 v18 버전 태그 부재, latest 내용 digest 고정)vllm@0.0.11vllm-openai →vllm/vllm-openai:v0.24.0
digest 고정은 재현성을 보장하지만 자동 보안 패치는 멈춘다 — 정기 스캔으로 새 CVE 를 감지하고 필요 시
digest 를 갱신한다. bitnamilegacy/* 는 미유지 이미지이므로 중기적으로 legacy 탈피를 검토한다.