diff --git a/scripts/pipeline/scan-sbom.sh b/scripts/pipeline/scan-sbom.sh index 5b6f435..633d6bd 100755 --- a/scripts/pipeline/scan-sbom.sh +++ b/scripts/pipeline/scan-sbom.sh @@ -27,8 +27,13 @@ # # 산출물 ($1 기준): # trivy-reports/.json 이미지별 원본 스캔 결과 (+ CoverageProbe) -# trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status⇥seconds -# trivy-summary.md 마크다운 요약 +# trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status +# trivy-summary.md 마크다운 요약 — 차트명 기준으로 묶고 차트별 합계 행을 붙인다. +# 한 차트가 버전을 여러 개 갖고 있으면(카탈로그는 여러 버전을 +# 동시에 보관하는 "버전 보관소") 이 표에는 차트별 최신 버전만 +# 싣는다 — 구버전은 여전히 스캔·게이트 판정 대상이며 +# cve-gate.md/json 에 전부 남는다. 이미지가 여러 차트에 +# 쓰이면(예: busybox) 차트마다 한 행씩 나온다. # trivy-run.log 실행 로그 # # 요구사항(컨테이너 내부): trivy, python3, bash @@ -185,7 +190,6 @@ 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" @@ -193,11 +197,10 @@ 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" +# chart 귀속은 요약 생성 단계에서 $INDEX 를 직접 읽는다(한 이미지가 여러 차트에 쓰이면 +# 차트마다 한 줄씩 보여주기 위함 — 아래 python 참고). 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 @@ -258,9 +261,12 @@ fi 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 +python3 - "$RESULTS" "$INDEX" "$SEVERITY" "$TOTAL" "$(ts)" "$SUMMARY_TSV" "${GEN_SECS:-}" <<'PY' | tee "$SUMMARY_MD" >&2 +import itertools +import re import sys -results, chartmap, sev, total, tstamp, tsv_out = sys.argv[1:7] + +results, index_path, 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 @@ -269,30 +275,70 @@ def dur(s): return f"{s//60}분 {s%60}초" 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()] shown=[s for s in RANK if s in scanned] or ["CRITICAL","HIGH"] -cm={} + +def version_key(v): + # "1.30.0" -> (1,30,0). 숫자로 안 읽히는 조각은 -1 취급(정렬은 되지만 최신 판단은 + # 못 함 — 그런 경우는 표에서도 그대로 드러나니 사람이 판단한다). + return tuple(int(m.group()) if m else -1 + for m in (re.match(r"\d+", seg) for seg in v.split("."))) + +# 이미지 -> 그 이미지를 쓰는 (차트, 버전) 목록. sbom-index.tsv 를 직접 읽는다(예전엔 +# 이미지당 차트 하나로 접어(마지막 것만 남김) 요약했는데, busybox·postgres 처럼 여러 +# 차트가 같은 이미지를 쓰면 다른 차트의 노출이 조용히 빠졌다). 한 이미지가 여러 차트에 +# 쓰이면 차트마다 한 줄씩 보여줘야 "차트별 합계"가 그 차트가 실제로 배포하는 노출을 +# 반영한다. +charts_of = {} try: - for l in open(chartmap): - p=l.rstrip("\n").split("\t") - if len(p)>=2: cm[p[0]]=p[1] -except FileNotFoundError: pass + for l in open(index_path): + p = l.rstrip("\n").split("\t") + if len(p) >= 6 and p[3] == "OK" and p[2]: + charts_of.setdefault(p[2], []).append((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 cnt(r,s): try: return int(r[IDX[s]]) except: return 0 -rows.sort(key=lambda r: tuple(-cnt(r,s) for s in shown)) + +# (차트, 버전, 이미지행) 로 펼친다 — 이미지가 차트 정보 없이 스캔됐으면(예: 인덱스 없이 +# sbom/*.cdx.json 만으로 실행) 빈 차트 하나로 묶는다. 이 목록은 전체(구버전 포함) 데이터다 +# — cve-gate.md/json 은 여기서 안 걸러진 전체를 그대로 쓴다. +expanded=[] +for img, r in best.items(): + for chart, version in (charts_of.get(img) or [("", "")]): + expanded.append((chart, version, r)) + +rows=list(best.values()) # SBOM 대상 수 등 헤더 통계는 고유 이미지 기준(차트·버전 무관). 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} -# TSV: chart, image, , status (CRITICAL 은 첫 카운트 열 = col3) + +# trivy-summary.md/tsv 표시는 차트 이름별 "최신 버전"만 남긴다 — 구버전도 여전히 +# 배포 가능한 카탈로그 항목이므로 실제 스캔·게이트 판정 대상에서는 빼지 않는다 +# (extract-helm-images.sh 는 그대로 전 버전을 렌더한다). 여기서 거르는 건 순전히 +# 이 요약 표를 "차트 종류" 단위로 읽기 좋게 만들기 위함이다. +latest_version = {} +for chart, version, _ in expanded: + if chart not in latest_version or version_key(version) > version_key(latest_version[chart]): + latest_version[chart] = version +display = [(c, v, r) for c, v, r in expanded if v == latest_version.get(c, v)] +# 차트명 오름차순으로 묶고, 같은 차트 안에서는 심각도 내림차순(기존 정렬 기준 유지). +display.sort(key=lambda cvr: (cvr[0], tuple(-cnt(cvr[2], s) for s in shown))) +skipped_old = len(expanded) - len(display) + +totals={s:sum(cnt(r,s) for _, _, r in display) for s in shown} + +# TSV: chart, image, , status — 이미지가 여러 차트에 쓰이면 차트마다 +# 한 행. .md 표와 동일하게 차트별 최신 버전만 담는다. 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]] + for c, v, r in display: + cols=[f"{c}@{v}" if c else c, 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"- 대상 SBOM: **{len(rows)}개** (성공 {len(ok)} / 실패 {len(err)})") @@ -300,13 +346,23 @@ 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)) +print("- 취약점 합계(차트별 최신 버전 기준, 아래 표와 일치): " + ", ".join(f"**{s} {totals[s]}**" for s in shown)) +if skipped_old: + print(f"- 구버전 {skipped_old}건은 이 표에서 제외했다(카탈로그에는 여전히 존재 — " + f"`cve-gate.md`/`cve-gate.json` 은 전 버전을 판정한다).") print() 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]} |") +for chart, group in itertools.groupby(display, key=lambda cvr: cvr[0]): + grp=list(group) + version = grp[0][1] + label = f"{chart}@{version}" if chart else "(차트 미상)" + for _, _, r in grp: + cells=" | ".join(str(cnt(r,s)) for s in shown) + print(f"| {label} | `{r[0]}` | {cells} | {r[5]} |") + ctotals={s: sum(cnt(r,s) for _, _, r in grp) for s in shown} + cells=" | ".join(f"**{ctotals[s]}**" for s in shown) + print(f"| **{label} 합계 ({len(grp)}개 이미지)** | | {cells} | |") PY { @@ -315,5 +371,5 @@ PY echo "요약: $SUMMARY_MD" echo "===================================================================" } | tee -a "$LOG" >&2 -rm -f "$RESULTS" "$CHARTMAP" "$TARGETS" +rm -f "$RESULTS" "$TARGETS" rm -rf "$OUT_DIR/.probe" # 자가진단용 합성 SBOM 사본 — 산출물이 아니다