sbom-ci: meaningful stage timings, dynamic severity cols, doc update
- scan-sbom: 요약에 단계별 소요(SBOM 생성 vs 취약점 스캔) 표시, 오해 주던 "총 소요"· 항상 0인 per-SBOM Sec·min/max 라인 제거. 스캔한 SEVERITY 만 동적 컬럼. - generate-sbom: 생성 소요시간을 .sbom-gen-seconds 로 기록(요약 단계별 시간용). - doc/sbom-pipeline.md: 실행 이미지(Dockerfile)·빌드/푸시·GitHub 설정·결과 확인/대응 보강. - .gitignore: .sbom-gen-seconds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,3 +17,4 @@ trivy-reports/
|
||||
trivy-summary.*
|
||||
trivy-run.log
|
||||
sbom-gen.log
|
||||
.sbom-gen-seconds
|
||||
|
||||
+117
-38
@@ -1,7 +1,8 @@
|
||||
# Helm 카탈로그 SBOM 생성 파이프라인
|
||||
|
||||
`manifests/helm/` 정적 카탈로그의 **모든 컨테이너 이미지에 대한 SBOM(CycloneDX)과 취약점 리포트**를
|
||||
결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행한다.
|
||||
결정론적으로 생성하는 파이프라인. helm/trivy/python3 가 설치된 리눅스 컨테이너 안에서 실행하며,
|
||||
GitHub Actions([.github/workflows/sbom.yml](../.github/workflows/sbom.yml))로 자동화한다.
|
||||
|
||||
## 왜 필요한가
|
||||
|
||||
@@ -20,54 +21,125 @@ manifests/helm/** ──helm template──▶ images_final.tsv ──trivy ima
|
||||
```
|
||||
|
||||
- **레지스트리 pull·인증은 SBOM 생성 단계 1곳으로 집중.** 취약점 스캔은 네트워크·인증 없이 빠르게 반복 가능
|
||||
(CVE DB 갱신 시 재pull 없이 재스캔).
|
||||
(CVE DB 갱신 시 재pull 없이 재스캔). → 파이프라인 비용의 대부분은 SBOM 생성(이미지 pull)이고 스캔은 초 단위.
|
||||
- 스캔 대상 이미지는 trivy 가 원격에서 받아 분석 후 폐기하므로 호스트/이미지 스토어에 남지 않는다.
|
||||
|
||||
## 실행 환경 요구사항
|
||||
|
||||
스크립트는 **컨테이너 내부에 설치된 바이너리를 직접 호출**한다. 실행 이미지에 다음이 있어야 한다.
|
||||
|
||||
| 도구 | 사용 스크립트 |
|
||||
|------|--------------|
|
||||
| `helm` (v3) | extract-helm-images.sh |
|
||||
| `trivy` | generate-sbom.sh, scan-sbom.sh |
|
||||
| `python3`, `bash`, `git` | 공통 |
|
||||
|
||||
이 이미지는 [doc/scripts/Dockerfile](scripts/Dockerfile) 로 빌드한다(debian/glibc 기반 — CI `container:`
|
||||
안에서 node 기반 액션 호환). 빌드·푸시 후 태그를 `.github/workflows/sbom.yml` 의 Repo Variable
|
||||
`SBOM_PIPELINE_IMAGE` 에 지정한다. amd64 필수(GitHub 러너 아키텍처).
|
||||
|
||||
## 스크립트 (`doc/scripts/`)
|
||||
|
||||
바이너리(`helm`/`trivy`/`python3`)를 **컨테이너 내부에서 직접 호출**한다(도커 소켓·docker CLI 불필요).
|
||||
|
||||
| 스크립트 | 입력 | 출력 |
|
||||
|---------|------|------|
|
||||
| `extract-helm-images.sh` | `manifests/helm/` | `images_final.tsv` (chart⇥version⇥image), `render_status.tsv` |
|
||||
| `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.{tsv,md}`, `trivy-reports/`, `trivy-run.log` |
|
||||
| `scan-sbom.sh` | `sbom/` + `sbom-index.tsv` | `trivy-summary.{md,tsv}`, `trivy-reports/`, `trivy-run.log` |
|
||||
|
||||
주요 환경변수: `PARALLEL`(동시 처리, 기본 3), `LIMIT`(대상 상한, 0=전체 — 테스트용),
|
||||
`SEVERITY`(기본 `HIGH,CRITICAL`), `TRIVY_CACHE_DIR`(캐시 디렉토리), `MERGE=1`(generate 시
|
||||
`catalog.cdx.json` 병합, cyclonedx CLI 필요). 사설 레지스트리 인증은 trivy 네이티브:
|
||||
`TRIVY_USERNAME`/`TRIVY_PASSWORD`(단일) 또는 `DOCKER_CONFIG`(다중).
|
||||
|
||||
### 로컬/컨테이너 실행
|
||||
|
||||
```bash
|
||||
# 도구가 설치된 이미지 안에서 (예: <pipeline-image> = helm+trivy+python3 보유 이미지)
|
||||
docker run --rm -v "$PWD:/repo" -w /repo -e TRIVY_CACHE_DIR=/repo/sbom-out/cache <pipeline-image> 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
|
||||
'
|
||||
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 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
|
||||
'
|
||||
# 빠른 검증: generate 단계에 -e LIMIT=3
|
||||
```
|
||||
|
||||
주요 환경변수: `PARALLEL`(동시 처리), `LIMIT`(대상 상한, 디버그용), `SEVERITY`(기본 `HIGH,CRITICAL`),
|
||||
`TRIVY_CACHE_DIR`(캐시 디렉토리), `MERGE=1`(generate 시 `catalog.cdx.json` 병합, cyclonedx CLI 필요).
|
||||
사설 레지스트리 인증은 trivy 네이티브: `TRIVY_USERNAME`/`TRIVY_PASSWORD`(단일) 또는 `DOCKER_CONFIG`(다중).
|
||||
## 실행 이미지 (`doc/scripts/Dockerfile`)
|
||||
|
||||
파이프라인은 **도구가 설치된 컨테이너 안에서** 돈다. 그 이미지는 [doc/scripts/Dockerfile](scripts/Dockerfile)로 빌드한다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|------|
|
||||
| 베이스 | `debian:stable-slim` (**glibc**) |
|
||||
| 포함 도구 | `helm`(v3) · `trivy` · `python3` · `bash` · `git` |
|
||||
| 아키텍처 | **linux/amd64** (GitHub 러너와 일치) |
|
||||
| 현재 이미지 | `docker.io/wbsong111/sbom-pipeline:latest` (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/<org>/sbom-pipeline:latest \
|
||||
-f doc/scripts/Dockerfile --push doc/scripts
|
||||
```
|
||||
|
||||
> credsStore(Docker Desktop) 환경에서 `docker-container` 빌더로 `--push` 시 인증 실패하면,
|
||||
> 단일 아키텍처를 로컬 적재(`--load`) 후 `docker push` 하거나 인라인 토큰 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 은 변경 차트만(빠른 게이트), 스케줄은 전체.
|
||||
- **트리거**
|
||||
- `workflow_dispatch` — 수동. 입력 `limit`(기본 0=전체, 테스트 시 예 `3`).
|
||||
- `pull_request`(`manifests/helm/**`) — **변경된 차트의 이미지만** 증분 스캔(빠른 게이트).
|
||||
- `schedule`(주 1회) — **전체** 스캔.
|
||||
- **컨테이너**: `vars.SBOM_PIPELINE_IMAGE` 이미지에서 실행.
|
||||
- **인증 구성 스텝**: 시크릿으로 `~/.docker/config.json`(인라인 토큰)을 만들어 `DOCKER_CONFIG` 로 trivy 에 전달.
|
||||
- **아티팩트**: 스테이징 디렉토리(`report/`)로 모아 **상대경로**로 업로드(절대경로면 v4 가 전체 경로트리를 보존하므로).
|
||||
- **스캔 주기 원칙**: 인벤토리·SBOM 은 차트 변경 시에만 바뀌지만 **취약점은 새 CVE 로 계속 변하므로 스케줄 전체
|
||||
스캔이 필수**. PR 은 변경 차트만, 스케줄은 전체.
|
||||
|
||||
### 실행
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## GitHub 설정 (워크플로 활성화에 필요)
|
||||
|
||||
`Settings → Secrets and variables → Actions`
|
||||
|
||||
| 종류 | 이름 | 용도 |
|
||||
|------|------|------|
|
||||
| **Variable** | `SBOM_PIPELINE_IMAGE` | 실행 이미지 태그 (예: `docker.io/wbsong111/sbom-pipeline:latest`) |
|
||||
| Secret | `DOCKERHUB_USER` / `DOCKERHUB_TOKEN` | **docker.io rate limit 회피**(이미지는 public). pull 만 하므로 read-only PAT 권장 |
|
||||
| Secret | `NGC_API_KEY` | **nvcr.io 인증**(NVIDIA nemo/nim — 없으면 pull 불가) |
|
||||
| Secret | (필요 시) paasup 사설 레지스트리 자격증명 | `paasup/*` 이미지가 사설일 때 |
|
||||
|
||||
추가 고려:
|
||||
- `SBOM_PIPELINE_IMAGE` 가 **사설**이면 워크플로 `container:` 에 `credentials:` 추가 필요.
|
||||
- 저장소 Actions 활성화(org 정책). 서드파티 액션은 GitHub 공식(`checkout`·`upload-artifact`)만 사용.
|
||||
- `DOCKERHUB_TOKEN` 이 Docker Desktop 세션 토큰이면 만료 가능 → 장기적으로 **스코프 지정 PAT** 권장.
|
||||
|
||||
## 결과 확인
|
||||
|
||||
1. **Job Summary** (권장) — Actions run 페이지 → Summary. `trivy-summary.md` 표가 렌더링된다.
|
||||
2. **아티팩트** `sbom-and-vuln-report` — 아래 구조. `gh run download <run-id> -n sbom-and-vuln-report -D ./out`.
|
||||
|
||||
```
|
||||
sbom-and-vuln-report/ ← 아티팩트 루트
|
||||
├── trivy-summary.md ← 취약점 요약(표)
|
||||
├── trivy-summary.tsv
|
||||
├── sbom-index.tsv ← 차트↔이미지↔SBOM 인덱스
|
||||
├── sbom-gen.log / trivy-run.log
|
||||
└── sbom/ ← 이미지별 상세 CycloneDX SBOM
|
||||
├── <img>.cdx.json
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 요약(`trivy-summary.md`) 내용
|
||||
|
||||
- **단계별 소요시간**: `SBOM 생성 소요`(이미지 pull, 주 비용) / `취약점 스캔 소요`(오프라인). 오해를 주던 단일 "총 소요"는 제거.
|
||||
- **동적 심각도 컬럼**: 실제 스캔한 `SEVERITY` 만 표에 표시(기본 `CRITICAL`·`HIGH`). MEDIUM/LOW 는 스캔 안 하면 컬럼 자체가 없다.
|
||||
- 차트·이미지별 집계 + 심각도별 총계. TSV 는 `chart⇥image⇥<심각도들>⇥status`(CRITICAL 이 첫 카운트 열).
|
||||
|
||||
### 첫 전체 실행 결과(참고, 2026-07-08)
|
||||
|
||||
- SBOM 생성 **159/178**(19 실패 — 대부분 사설/미인증 레지스트리 또는 대용량 timeout), 스캔 **145 전부 성공**.
|
||||
- 취약점 합계 **CRITICAL 1,511 · HIGH 15,724**, 총 소요 약 20분(생성 19분 / 스캔 23초).
|
||||
|
||||
## 렌더 함정 (extract 단계, 스크립트에 내장)
|
||||
|
||||
@@ -79,12 +151,19 @@ docker run --rm -v "$PWD:/repo" -w /repo -e TRIVY_CACHE_DIR=/repo/sbom-out/cache
|
||||
trivy 병렬 실행 시 공유 캐시 bolt 잠금 충돌을 피하려 워커는 `--cache-backend memory --skip-db-update`,
|
||||
메인은 시작 시 `trivy image --download-db-only` 로 DB 를 1회 워밍한다.
|
||||
|
||||
## 산출물
|
||||
## 결과 대응 (트리아지)
|
||||
|
||||
- `sbom/<img>.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/<img>.json` — 이미지별 원본 취약점 결과.
|
||||
원시 CVE 수를 0으로 만드는 것이 목표가 아니다. **고칠 수 있고, 악용 가능하고, 노출된** 것부터 대응한다.
|
||||
|
||||
1. **우선순위**: fix 가능(`--ignore-unfixed`) → KEV/EPSS(실제 악용) → 노출도(인터넷 vs 내부) → 심각도.
|
||||
2. **대응 레버(모두 Git PR)**: 차트/이미지 **버전 업**(최고 레버리지) · **베이스 이미지 교체**(특히 미유지 `bitnamilegacy/*`) ·
|
||||
**소유 이미지(`paasup/*`, `wbsong111/*`) 재빌드** · 좋은 버전 선정 후 **digest 고정**.
|
||||
3. **게이트·억제**: PR 에 "새로 유입되는 fix-available CRITICAL" 차단 게이트(워크플로에 주석으로 준비됨),
|
||||
수용 CVE 는 `.trivyignore`(사유·만료일 명시).
|
||||
4. 새 CVE 는 계속 나오므로 스케줄 스캔으로 **추세 추적**.
|
||||
|
||||
향후 개선(요약을 행동 가능하게): 이미지별 **fixable(패치 존재) 집계** 컬럼, `.trivyignore` 지원,
|
||||
소유/legacy/3rd-party **분류 컬럼**.
|
||||
|
||||
## 운영 노트 — `:latest`/무태그 이미지 digest 고정
|
||||
|
||||
|
||||
@@ -159,6 +159,7 @@ for r in sorted(rows): print("\t".join(r))
|
||||
PY
|
||||
|
||||
TOTAL=$(( $(date +%s) - RUN_START ))
|
||||
printf '%s' "$TOTAL" > "$OUT_DIR/.sbom-gen-seconds" 2>/dev/null || true # scan-sbom 요약에서 단계별 시간 표시용
|
||||
OKN=$(awk -F'\t' '$4=="OK"' "$INDEX" | wc -l | tr -d ' ')
|
||||
ERRN=$(awk -F'\t' '$4!="OK" && $4!=""' "$INDEX" | wc -l | tr -d ' ')
|
||||
|
||||
|
||||
+15
-11
@@ -134,11 +134,15 @@ xargs -P "$PARALLEL" -I{} bash -c '
|
||||
>> "$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
|
||||
TOTAL=$(( $(date +%s) - RUN_START )) # 취약점 스캔 단계(오프라인) 소요
|
||||
GEN_SECS=$(cat "$OUT_DIR/.sbom-gen-seconds" 2>/dev/null || echo "") # SBOM 생성 단계(이미지 pull) 소요
|
||||
python3 - "$RESULTS" "$CHARTMAP" "$SEVERITY" "$TOTAL" "$(ts)" "$SUMMARY_TSV" "${GEN_SECS:-}" <<'PY' | tee "$SUMMARY_MD" >&2
|
||||
import sys
|
||||
results, chartmap, sev, total, tstamp, tsv_out = sys.argv[1:7]
|
||||
total=int(total)
|
||||
gen = sys.argv[7] if len(sys.argv)>7 and sys.argv[7].strip().isdigit() else None
|
||||
gen = int(gen) if gen is not None else None
|
||||
def dur(s): return f"{s//60}분 {s%60}초"
|
||||
# 표시 심각도 = 실제 스캔한 SEVERITY 만 (순위 내림차순). MED/LOW 는 스캔 안 하면 항상 0이므로 제외.
|
||||
RANK=["CRITICAL","HIGH","MEDIUM","LOW"]; IDX={"CRITICAL":1,"HIGH":2,"MEDIUM":3,"LOW":4}
|
||||
scanned=[s.strip().upper() for s in sev.split(",") if s.strip()]
|
||||
@@ -162,25 +166,25 @@ def cnt(r,s):
|
||||
rows.sort(key=lambda r: tuple(-cnt(r,s) for s in shown))
|
||||
ok=[r for r in rows if r[5]=="OK"]; err=[r for r in rows if r[5]!="OK"]
|
||||
totals={s:sum(cnt(r,s) for r in ok) for s in shown}
|
||||
secs=[int(r[6]) for r in rows if len(r)>=7 and r[6].isdigit()]
|
||||
# TSV: chart, image, <shown counts...>, status, sec (CRITICAL 은 첫 카운트 열 = col3)
|
||||
# TSV: chart, image, <shown counts...>, status (CRITICAL 은 첫 카운트 열 = col3)
|
||||
with open(tsv_out,"w") as f:
|
||||
for r in rows:
|
||||
cols=[cm.get(r[0],""), r[0]] + [str(cnt(r,s)) for s in shown] + [r[5], r[6] if len(r)>=7 else ""]
|
||||
cols=[cm.get(r[0],""), r[0]] + [str(cnt(r,s)) for s in shown] + [r[5]]
|
||||
f.write("\t".join(cols)+"\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"- 대상 SBOM: **{len(rows)}개** (성공 {len(ok)} / 실패 {len(err)})")
|
||||
if gen is not None:
|
||||
print(f"- SBOM 생성 소요: **{dur(gen)}** (이미지 pull 포함, 파이프라인 주 비용)")
|
||||
print(f"- 취약점 스캔 소요: **{dur(total)}** (오프라인, SBOM 입력)")
|
||||
print(f"- 집계 심각도: `{sev}`")
|
||||
print("- 취약점 합계: " + ", ".join(f"**{s} {totals[s]}**" for s in shown))
|
||||
if secs: print(f"- SBOM당 스캔시간: 최소 {min(secs)}s / 최대 {max(secs)}s / 평균 {sum(secs)//len(secs)}s")
|
||||
print()
|
||||
print("| Chart | Image | " + " | ".join(shown) + " | Status | Sec |")
|
||||
print("|---|---|" + "---:|"*len(shown) + "---|---:|")
|
||||
print("| Chart | Image | " + " | ".join(shown) + " | Status |")
|
||||
print("|---|---|" + "---:|"*len(shown) + "---|")
|
||||
for r in rows:
|
||||
cells=" | ".join(str(cnt(r,s)) for s in shown)
|
||||
print(f"| {cm.get(r[0],'')} | `{r[0]}` | {cells} | {r[5]} | {r[6] if len(r)>=7 else '-'} |")
|
||||
print(f"| {cm.get(r[0],'')} | `{r[0]}` | {cells} | {r[5]} |")
|
||||
PY
|
||||
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user