Files
wbsong111 9b76b6f2a1 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>
2026-08-04 11:22:07 +09:00

376 lines
19 KiB
Bash
Executable File

#!/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/<img>.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 <sbom_file> <report_json> <probe_dir>
#
# 리포트 최상위에 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 <image> <sbom_file> <reports_dir> <log>
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, <shown counts...>, 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 사본 — 산출물이 아니다