#!/usr/bin/env bash # ============================================================================= # scan-sbom.sh # generate-sbom.sh 가 만든 CycloneDX SBOM(sbom/*.cdx.json)을 **컨테이너 내부의 # `trivy` 바이너리**(`trivy sbom`)로 스캔해 이미지별 취약점을 집계한다. # # SBOM 을 입력으로 쓰므로 **이미지 재pull·레지스트리 인증이 불필요**(오프라인). # 취약점 DB 만 있으면 되며, CVE DB 갱신 시 재pull 없이 재스캔할 수 있다. # # 스캔과 함께 **커버리지 자가진단**을 수행한다. OS 패키지 findings 가 0건인 이미지에 # 대해 SBOM 사본에 센티널 패키지를 주입해 재스캔하고, 발화하면 스캐너가 그 배포판을 # 아는 것으로 본다. 결과를 리포트의 `CoverageProbe`(ok|none|n/a) 에 기록하며 게이트 # (`cve-gate.py`)가 이를 읽는다 — "0건" 과 "측정되지 않음" 을 구분하는 유일한 수단이다 # (security-catalog 프로젝트에서 포팅, doc/sbom-pipeline.md 참고). # # 사용: # bash scripts/pipeline/generate-sbom.sh images_final.tsv . # 선행: sbom/ 생성 # bash scripts/pipeline/scan-sbom.sh . # SBOM 취약점 스캔 # # 인자: # $1 SBOM 작업 디렉토리 (sbom/ 과 sbom-index.tsv 가 있는 곳. 기본: 현재 디렉토리) # # 환경변수: # SEVERITY 집계 심각도 (기본 HIGH,CRITICAL) # PARALLEL 동시 스캔 개수 (기본 4 — 오프라인이라 높여도 안전) # TRIVY_CACHE_DIR trivy 캐시 디렉토리 (미설정 시 trivy 기본 ~/.cache/trivy) # # 산출물 ($1 기준): # trivy-reports/.json 이미지별 원본 스캔 결과 (+ CoverageProbe) # trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status # trivy-summary.md 마크다운 요약 — 차트명 기준으로 묶고 차트별 합계 행을 붙인다. # 한 차트가 버전을 여러 개 갖고 있으면(카탈로그는 여러 버전을 # 동시에 보관하는 "버전 보관소") 이 표에는 차트별 최신 버전만 # 싣는다 — 구버전은 여전히 스캔·게이트 판정 대상이며 # cve-gate.md/json 에 전부 남는다. 이미지가 여러 차트에 # 쓰이면(예: busybox) 차트마다 한 행씩 나온다. # trivy-run.log 실행 로그 # # 요구사항(컨테이너 내부): trivy, python3, bash # ============================================================================= set -uo pipefail command -v trivy >/dev/null || { echo "!! trivy 바이너리가 없습니다 (컨테이너에 설치 필요)" >&2; exit 127; } command -v python3 >/dev/null || { echo "!! python3 가 없습니다" >&2; exit 127; } SEVERITY="${SEVERITY:-HIGH,CRITICAL}" PARALLEL="${PARALLEL:-4}" [ -n "${TRIVY_CACHE_DIR:-}" ] && export TRIVY_CACHE_DIR ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } # --- 내부 모드: 커버리지 자가진단 -------------------------------------------- # 인자: --probe # # 리포트 최상위에 CoverageProbe 를 기록한다. 게이트가 이 값으로 커버리지를 판정한다. # ok 스캐너가 이 배포판을 안다 (0건은 진짜 0건) # none 스캐너에 이 배포판 데이터가 없다 → 게이트 실패 # n/a SBOM 에 OS 패키지가 없다 (판정 대상 아님) if [ "${1:-}" = "--probe" ]; then python3 - "$2" "$3" "$4" <<'PY' import json, os, re, subprocess, sys, tempfile sbom_path, report_path, probe_dir = sys.argv[1], sys.argv[2], sys.argv[3] OS_PURL = re.compile(r"^pkg:(deb|rpm|apk)/([^/]+)/") FLOOR = "0.0.1-1" # 어떤 실제 버전보다도 낮다 # 센티널 — 그 배포판이라면 권고가 반드시 존재하는 패키지 이름. 여러 개를 넣고 # **하나라도 발화하면** 데이터가 있는 것으로 본다. # # 설치된 패키지를 낮추는 방식은 쓰지 않는다. security-catalog 프로젝트에서 실측으로 # 깨진 적이 있다 — cloudnative-pg:1.30.0 의 OS 패키지는 base-files·media-types·netbase· # tzdata 4개뿐이고 넷 다 Debian 권고가 존재한 적이 없어, 버전을 낮춰도 0건이라 프로브가 # "데이터 없음" 이라 오판했다. 설치 목록에 의존하지 않는 센티널이라야 한다. SENTINELS = { "deb": ["openssl", "curl", "libxml2", "perl", "zlib1g", "libc6"], "rpm": ["openssl", "openssl-3", "curl", "libxml2-2", "glibc", "zlib"], "apk": ["openssl", "curl", "busybox", "zlib", "musl"], } def write(verdict): d = json.load(open(report_path)) d["CoverageProbe"] = verdict json.dump(d, open(report_path, "w")) print(f"probe: {verdict}", file=sys.stderr) sbom = json.load(open(sbom_path)) sample = next((c for c in (sbom.get("components") or []) if OS_PURL.match(c.get("purl") or "")), None) if sample is None: write("n/a"); sys.exit(0) # 이미 findings 가 있으면 스캐너가 이 배포판을 아는 것이 자명하다 — 추가 스캔 불필요. rep = json.load(open(report_path)) if any((r.get("Vulnerabilities") or []) for r in (rep.get("Results") or []) if r.get("Class") == "os-pkgs"): write("ok"); sys.exit(0) # 사본에 센티널을 주입한다. **원본은 건드리지 않는다.** # 배포판·아키텍처 한정자(distro=, arch=)는 설치 패키지에서 그대로 베껴 와야 한다 — # trivy 는 purl 의 distro 로 어느 배포판 DB 를 볼지 정한다. ptype, namespace = OS_PURL.match(sample["purl"]).groups() qs = sample["purl"].partition("?")[2] comps = sbom.setdefault("components", []) for i, name in enumerate(SENTINELS[ptype]): comps.append({ "bom-ref": f"coverage-probe-{i}", "type": "library", "name": name, "version": FLOOR, "purl": f"pkg:{ptype}/{namespace}/{name}@{FLOOR}" + (f"?{qs}" if qs else ""), }) os.makedirs(probe_dir, exist_ok=True) fd, tmp = tempfile.mkstemp(dir=probe_dir, suffix=".cdx.json") try: with os.fdopen(fd, "w") as f: json.dump(sbom, f) p = subprocess.run( ["trivy", "sbom", "--quiet", "--scanners", "vuln", "--severity", "UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL", "--cache-backend", "memory", "--skip-db-update", "--format", "json", tmp], capture_output=True, text=True) if p.returncode != 0: # 프로브가 실패하면 판정을 지어내지 않는다. 키를 남기지 않으면 게이트가 # 구버전 리포트와 동일하게(총계 0건 → 실패) 보수적으로 처리한다. print("probe: 실패 — " + (p.stderr or "").strip()[:200], file=sys.stderr) sys.exit(0) got = sum(len(r.get("Vulnerabilities") or []) for r in (json.loads(p.stdout).get("Results") or []) if r.get("Class") == "os-pkgs") write("ok" if got > 0 else "none") finally: os.unlink(tmp) PY exit 0 fi # --- 내부 모드: SBOM 1개 스캔 (xargs 병렬 워커) ------------------------------ # 인자: --scan-one if [ "${1:-}" = "--scan-one" ]; then img="$2"; sbomfile="$3"; reports="$4"; log="${5:-/dev/null}" safe="$(printf '%s' "$img" | tr '/:@' '___')" json="$reports/$safe.json" t0=$(date +%s) if trivy sbom --quiet --scanners vuln --severity "$SEVERITY" \ --cache-backend memory --skip-db-update \ --format json "$sbomfile" > "$json" 2>"$json.err"; then # --- 커버리지 자가진단 -------------------------------------------------- # os-pkgs findings 가 0건이면 두 가지 가능성이 있고 숫자로는 구분되지 않는다. # (a) 이미지가 실제로 깨끗하다 (b) 스캐너에 그 배포판 데이터가 없다 # 스캐너에게 직접 물어본다 — SBOM 사본에 취약한 센티널 패키지를 주입해 재스캔하고, # 발화하면 (a), 여전히 0건이면 (b) 다. probe_dir="$(dirname "$json")/../.probe" bash "${BASH_SOURCE[0]}" --probe "$sbomfile" "$json" "$probe_dir" 2>>"$json.err" || true dur=$(( $(date +%s) - t0 )) line="$(python3 - "$img" "$json" "$dur" <<'PY' import sys,json,collections img,path,dur=sys.argv[1],sys.argv[2],sys.argv[3] try: d=json.load(open(path)); c=collections.Counter() for r in d.get("Results",[]): for v in (r.get("Vulnerabilities") or []): c[v["Severity"]]+=1 # 8번째 열 = 커버리지 자가진단 결과. 요약 생성기는 7열까지만 읽으므로 무해하다. print(f"{img}\t{c['CRITICAL']}\t{c['HIGH']}\t{c['MEDIUM']}\t{c['LOW']}\tOK\t{dur}\t{d.get('CoverageProbe','?')}") except Exception as e: print(f"{img}\t-\t-\t-\t-\tPARSE_ERR:{str(e)[:40]}\t{dur}\t?") PY )" else dur=$(( $(date +%s) - t0 )) reason="$(tr '\n' ' ' < "$json.err" | grep -oiE '(unable|invalid|unsupported|not found|failed to)[^.]*' | head -1)" [ -z "$reason" ] && reason="scan failed" rm -f "$json" line="$(printf '%s\t-\t-\t-\t-\tERROR:%s\t%s\t-' "$img" "${reason:0:40}" "$dur")" fi printf '%s\n' "$line" st="$(printf '%s' "$line" | cut -f6)" cov="$(printf '%s' "$line" | cut -f8)" printf '%s %-6s %5ss cov=%-4s %s\n' "$(ts)" "${st%%:*}" "$dur" "$cov" "$img" >> "$log" exit 0 fi # --- 메인 모드 -------------------------------------------------------------- OUT_DIR="${1:-$(pwd)}" SELF="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" SBOM_DIR="$OUT_DIR/sbom" INDEX="$OUT_DIR/sbom-index.tsv" REPORTS="$OUT_DIR/trivy-reports" 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" TARGETS="$OUT_DIR/.scan-targets.tsv" [ -d "$SBOM_DIR" ] || { echo "!! SBOM 디렉토리 없음: $SBOM_DIR (generate-sbom.sh 먼저 실행)" >&2; exit 1; } mkdir -p "$REPORTS"; : > "$RESULTS" fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); } # 스캔 대상: (image⇥sbom_file). sbom-index.tsv 있으면 그걸로, 없으면 sbom/*.cdx.json 글롭. # 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" else : > "$TARGETS" for f in "$SBOM_DIR"/*.cdx.json; do [ -e "$f" ] || continue img="$(python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));m=d.get("metadata",{}).get("component",{});print(m.get("name","")+((":"+m["version"]) if m.get("version") else ""))' "$f" 2>/dev/null)" [ -z "$img" ] && img="$(basename "$f")" printf '%s\t%s\n' "$img" "$f" >> "$TARGETS" done fi N="$(grep -c . "$TARGETS" 2>/dev/null || echo 0)" RUN_START=$(date +%s) { echo "===================================================================" echo "SBOM SCAN START : $(ts)" echo "trivy : $(trivy --version 2>/dev/null | head -1) (trivy sbom, 오프라인)" echo "severity : $SEVERITY parallel: $PARALLEL" echo "SBOMs : $N cache: ${TRIVY_CACHE_DIR:-~/.cache/trivy}" echo "===================================================================" } | tee "$LOG" >&2 echo ">> [$(ts)] DB 워밍 중..." | tee -a "$LOG" >&2 trivy image --download-db-only >>"$LOG" 2>&1 \ || { echo "!! DB 다운로드 실패" | tee -a "$LOG" >&2; exit 1; } echo ">> [$(ts)] 스캔 시작 (parallel=$PARALLEL)" | tee -a "$LOG" >&2 P1=$(date +%s) # xargs 를 쓰지 않는다. macOS(BSD)에서 두 가지가 깨진다 — 둘 다 실측으로 확인했다 # (동일 파이프라인의 security-catalog 프로젝트에서 먼저 발견됨). # # 1. `xargs -I{}` 가 치환 시 **탭을 공백으로 바꾼다.** 그러면 `cut -f`(탭 구분)가 # 구분자를 못 찾고 BSD cut 은 라인 전체를 반환하므로 이미지명과 SBOM 경로가 # 한 덩어리가 되어 `failed to open sbom file` 로 실패한다. # 2. `xargs -I` 는 조립 인자를 짧게 제한한다. SBOM 경로가 길면 대상이 조용히 # 누락되고도 스크립트가 성공으로 보고할 수 있다. # # `read` 로 탭을 직접 분해하고 배치 단위로 wait 한다. `wait -n` 은 bash 4.3+ 이므로 # macOS 기본 bash 3.2 에서 쓸 수 없어 batch-wait 방식을 쓴다. n=0 while IFS="$(printf '\t')" read -r im sf; do [ -n "$im" ] && [ -n "$sf" ] || continue bash "$SELF" --scan-one "$im" "$sf" "$REPORTS" "$LOG" >> "$RESULTS" & n=$((n+1)) [ $(( n % PARALLEL )) -eq 0 ] && wait done < "$TARGETS" wait echo ">> [$(ts)] 스캔 완료 ($(fmt_elapsed $(( $(date +%s) - P1 ))))" | tee -a "$LOG" >&2 # 누락 검출 — 대상 수와 결과 수가 다르면 실패시킨다. "스캔되지 않음" 이 "취약점 # 없음" 으로 오인되는 것이 이 파이프라인의 최악 실패다. GOT="$(grep -c . "$RESULTS" 2>/dev/null || echo 0)" if [ "$GOT" -ne "$N" ]; then echo "!! 스캔 대상 $N 개 중 $GOT 개만 결과가 있다 — 누락 발생" | tee -a "$LOG" >&2 awk -F'\t' 'NR==FNR{seen[$1]=1;next} !($1 in seen){print "!! 누락: "$1}' \ "$RESULTS" "$TARGETS" | tee -a "$LOG" >&2 exit 1 fi TOTAL=$(( $(date +%s) - RUN_START )) # 취약점 스캔 단계(오프라인) 소요 GEN_SECS=$(cat "$OUT_DIR/.sbom-gen-seconds" 2>/dev/null || echo "") # SBOM 생성 단계(이미지 pull) 소요 python3 - "$RESULTS" "$INDEX" "$SEVERITY" "$TOTAL" "$(ts)" "$SUMMARY_TSV" "${GEN_SECS:-}" <<'PY' | tee "$SUMMARY_MD" >&2 import itertools import re import sys 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 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()] shown=[s for s in RANK if s in scanned] or ["CRITICAL","HIGH"] 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(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 def cnt(r,s): try: return int(r[IDX[s]]) except: return 0 # (차트, 버전, 이미지행) 로 펼친다 — 이미지가 차트 정보 없이 스캔됐으면(예: 인덱스 없이 # 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"] # 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 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)})") 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 skipped_old: print(f"- 구버전 {skipped_old}건은 이 표에서 제외했다(카탈로그에는 여전히 존재 — " f"`cve-gate.md`/`cve-gate.json` 은 전 버전을 판정한다).") print() print("| Chart | Image | " + " | ".join(shown) + " | Status |") print("|---|---|" + "---:|"*len(shown) + "---|") 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 { echo "===================================================================" echo "SBOM SCAN END : $(ts) TOTAL: $(fmt_elapsed "$TOTAL")" echo "요약: $SUMMARY_MD" echo "===================================================================" } | tee -a "$LOG" >&2 rm -f "$RESULTS" "$TARGETS" rm -rf "$OUT_DIR/.probe" # 자가진단용 합성 SBOM 사본 — 산출물이 아니다