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 사본 — 산출물이 아니다