scan-sbom.sh: 취약점 요약을 차트별로 묶고 차트 합계 행 추가

trivy-summary.md/tsv 를 이미지 심각도 건수 내림차순 flat 목록 대신 차트 이름별로
묶고, 차트마다 합계 행(이미지 수 + 심각도별 합)을 붙인다.

- 이미지 하나가 여러 차트에 쓰이면(예: busybox) 예전엔 sbom-index.tsv 를 접어(마지막
  차트만 남김) 요약해 다른 차트의 노출이 조용히 빠졌다 — 이제 $INDEX 를 직접 읽어
  차트마다 한 행씩 보여준다(CHARTMAP 중간 파일은 더 필요 없어져 제거).
- 카탈로그는 같은 차트의 여러 버전을 동시에 보관하는 "버전 보관소"라, 요약 표는
  차트 이름별 최신 버전만 표시한다(표를 "차트 종류" 단위로 읽기 쉽게 하려는 목적).
  구버전은 표에서만 빠지고 실제 스캔·게이트 판정 대상에서는 빠지지 않는다 —
  cve-gate.md/json 은 전 버전을 그대로 판정한다. 몇 건이 빠졌는지는 요약 상단에 표시.

로컬에서 (a) 이미지 하나가 차트 두 개에 쓰이는 경우 양쪽 합계에 정확히 반영되는지
(b) 같은 차트 이름에 버전이 두 개일 때 최신만 남고 구버전 건수가 안내되는지
(c) 실제 flowise 차트(이미지 6개) 대상 로컬 파이프라인 실행
으로 확인했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-04 11:22:07 +09:00
parent aadbf866fb
commit 9b76b6f2a1
+78 -22
View File
@@ -27,8 +27,13 @@
# #
# 산출물 ($1 기준): # 산출물 ($1 기준):
# trivy-reports/<img>.json 이미지별 원본 스캔 결과 (+ CoverageProbe) # trivy-reports/<img>.json 이미지별 원본 스캔 결과 (+ CoverageProbe)
# trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status⇥seconds # trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status
# trivy-summary.md 마크다운 요약 # trivy-summary.md 마크다운 요약 — 차트명 기준으로 묶고 차트별 합계 행을 붙인다.
# 한 차트가 버전을 여러 개 갖고 있으면(카탈로그는 여러 버전을
# 동시에 보관하는 "버전 보관소") 이 표에는 차트별 최신 버전만
# 싣는다 — 구버전은 여전히 스캔·게이트 판정 대상이며
# cve-gate.md/json 에 전부 남는다. 이미지가 여러 차트에
# 쓰이면(예: busybox) 차트마다 한 행씩 나온다.
# trivy-run.log 실행 로그 # trivy-run.log 실행 로그
# #
# 요구사항(컨테이너 내부): trivy, python3, bash # 요구사항(컨테이너 내부): trivy, python3, bash
@@ -185,7 +190,6 @@ SUMMARY_TSV="$OUT_DIR/trivy-summary.tsv"
SUMMARY_MD="$OUT_DIR/trivy-summary.md" SUMMARY_MD="$OUT_DIR/trivy-summary.md"
LOG="$OUT_DIR/trivy-run.log" LOG="$OUT_DIR/trivy-run.log"
RESULTS="$OUT_DIR/.scan-results.tsv" RESULTS="$OUT_DIR/.scan-results.tsv"
CHARTMAP="$OUT_DIR/.chartmap.tsv"
TARGETS="$OUT_DIR/.scan-targets.tsv" TARGETS="$OUT_DIR/.scan-targets.tsv"
[ -d "$SBOM_DIR" ] || { echo "!! SBOM 디렉토리 없음: $SBOM_DIR (generate-sbom.sh 먼저 실행)" >&2; exit 1; } [ -d "$SBOM_DIR" ] || { echo "!! SBOM 디렉토리 없음: $SBOM_DIR (generate-sbom.sh 먼저 실행)" >&2; exit 1; }
mkdir -p "$REPORTS"; : > "$RESULTS" mkdir -p "$REPORTS"; : > "$RESULTS"
@@ -193,11 +197,10 @@ mkdir -p "$REPORTS"; : > "$RESULTS"
fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); } fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); }
# 스캔 대상: (image⇥sbom_file). sbom-index.tsv 있으면 그걸로, 없으면 sbom/*.cdx.json 글롭. # 스캔 대상: (image⇥sbom_file). sbom-index.tsv 있으면 그걸로, 없으면 sbom/*.cdx.json 글롭.
# 동시에 image->chart 매핑(CHARTMAP) 작성. # chart 귀속은 요약 생성 단계에서 $INDEX 를 직접 읽는다(한 이미지가 여러 차트에 쓰이면
: > "$CHARTMAP" # 차트마다 한 줄씩 보여주기 위함 — 아래 python 참고).
if [ -f "$INDEX" ]; then 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' -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 else
: > "$TARGETS" : > "$TARGETS"
for f in "$SBOM_DIR"/*.cdx.json; do for f in "$SBOM_DIR"/*.cdx.json; do
@@ -258,9 +261,12 @@ fi
TOTAL=$(( $(date +%s) - RUN_START )) # 취약점 스캔 단계(오프라인) 소요 TOTAL=$(( $(date +%s) - RUN_START )) # 취약점 스캔 단계(오프라인) 소요
GEN_SECS=$(cat "$OUT_DIR/.sbom-gen-seconds" 2>/dev/null || echo "") # SBOM 생성 단계(이미지 pull) 소요 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 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) total=int(total)
gen = sys.argv[7] if len(sys.argv)>7 and sys.argv[7].strip().isdigit() else None 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 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} 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()] 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"] 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: try:
for l in open(chartmap): for l in open(index_path):
p = l.rstrip("\n").split("\t") p = l.rstrip("\n").split("\t")
if len(p)>=2: cm[p[0]]=p[1] if len(p) >= 6 and p[3] == "OK" and p[2]:
except FileNotFoundError: pass charts_of.setdefault(p[2], []).append((p[0], p[1]))
except FileNotFoundError:
pass
best={} best={}
for l in open(results): for l in open(results):
p=l.rstrip("\n").split("\t") p=l.rstrip("\n").split("\t")
if len(p)<7: continue if len(p)<7: continue
img=p[0] img=p[0]
if p[5]=="OK" or img not in best or best[img][5]!="OK": best[img]=p 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): def cnt(r,s):
try: return int(r[IDX[s]]) try: return int(r[IDX[s]])
except: return 0 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"] 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, <shown counts...>, 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, <shown counts...>, status — 이미지가 여러 차트에 쓰이면 차트마다
# 한 행. .md 표와 동일하게 차트별 최신 버전만 담는다.
with open(tsv_out,"w") as f: with open(tsv_out,"w") as f:
for r in rows: for c, v, r in display:
cols=[cm.get(r[0],""), r[0]] + [str(cnt(r,s)) for s in shown] + [r[5]] 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") f.write("\t".join(cols)+"\n")
print("# Trivy SBOM 취약점 요약\n") print("# Trivy SBOM 취약점 요약\n")
print(f"- 생성: {tstamp}") print(f"- 생성: {tstamp}")
print(f"- 대상 SBOM: **{len(rows)}개** (성공 {len(ok)} / 실패 {len(err)})") 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"- SBOM 생성 소요: **{dur(gen)}** (이미지 pull 포함, 파이프라인 주 비용)")
print(f"- 취약점 스캔 소요: **{dur(total)}** (오프라인, SBOM 입력)") print(f"- 취약점 스캔 소요: **{dur(total)}** (오프라인, SBOM 입력)")
print(f"- 집계 심각도: `{sev}`") 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()
print("| Chart | Image | " + " | ".join(shown) + " | Status |") print("| Chart | Image | " + " | ".join(shown) + " | Status |")
print("|---|---|" + "---:|"*len(shown) + "---|") print("|---|---|" + "---:|"*len(shown) + "---|")
for r in rows: 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) cells=" | ".join(str(cnt(r,s)) for s in shown)
print(f"| {cm.get(r[0],'')} | `{r[0]}` | {cells} | {r[5]} |") 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 PY
{ {
@@ -315,5 +371,5 @@ PY
echo "요약: $SUMMARY_MD" echo "요약: $SUMMARY_MD"
echo "===================================================================" echo "==================================================================="
} | tee -a "$LOG" >&2 } | tee -a "$LOG" >&2
rm -f "$RESULTS" "$CHARTMAP" "$TARGETS" rm -f "$RESULTS" "$TARGETS"
rm -rf "$OUT_DIR/.probe" # 자가진단용 합성 SBOM 사본 — 산출물이 아니다 rm -rf "$OUT_DIR/.probe" # 자가진단용 합성 SBOM 사본 — 산출물이 아니다