c36edb3015
- scan-sbom: 요약에 단계별 소요(SBOM 생성 vs 취약점 스캔) 표시, 오해 주던 "총 소요"· 항상 0인 per-SBOM Sec·min/max 라인 제거. 스캔한 SEVERITY 만 동적 컬럼. - generate-sbom: 생성 소요시간을 .sbom-gen-seconds 로 기록(요약 단계별 시간용). - doc/sbom-pipeline.md: 실행 이미지(Dockerfile)·빌드/푸시·GitHub 설정·결과 확인/대응 보강. - .gitignore: .sbom-gen-seconds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
197 lines
8.8 KiB
Bash
Executable File
197 lines
8.8 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 없이 재스캔할 수 있다.
|
|
#
|
|
# 사용:
|
|
# bash doc/scripts/generate-sbom.sh images_final.tsv . # 선행: sbom/ 생성
|
|
# bash doc/scripts/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 이미지별 원본 스캔 결과
|
|
# trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status⇥seconds
|
|
# trivy-summary.md 마크다운 요약
|
|
# 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; }
|
|
|
|
# --- 내부 모드: 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
|
|
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
|
|
print(f"{img}\t{c['CRITICAL']}\t{c['HIGH']}\t{c['MEDIUM']}\t{c['LOW']}\tOK\t{dur}")
|
|
except Exception as e:
|
|
print(f"{img}\t-\t-\t-\t-\tPARSE_ERR:{str(e)[:40]}\t{dur}")
|
|
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' "$img" "${reason:0:40}" "$dur")"
|
|
fi
|
|
printf '%s\n' "$line"
|
|
st="$(printf '%s' "$line" | cut -f6)"
|
|
printf '%s %-6s %5ss %s\n' "$(ts)" "${st%%:*}" "$dur" "$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"
|
|
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"
|
|
|
|
fmt_elapsed() { printf '%dm %02ds' $(($1/60)) $(($1%60)); }
|
|
|
|
# 스캔 대상: (image⇥sbom_file). sbom-index.tsv 있으면 그걸로, 없으면 sbom/*.cdx.json 글롭.
|
|
# 동시에 image->chart 매핑(CHARTMAP) 작성.
|
|
: > "$CHARTMAP"
|
|
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
|
|
[ -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)
|
|
# 각 타깃 라인(image⇥sbom_file)을 워커로. -I{} 는 라인 단위로 아이템을 읽으므로
|
|
# {} 에 탭 포함 전체 라인이 들어온다. bash -c 안에서 cut 으로 탭 분해(BSD/GNU 공통).
|
|
xargs -P "$PARALLEL" -I{} bash -c '
|
|
im=$(printf %s "$1" | cut -f1)
|
|
sf=$(printf %s "$1" | cut -f2)
|
|
[ -n "$im" ] && bash "$0" --scan-one "$im" "$sf" "$2" "$3"
|
|
' "$SELF" {} "$REPORTS" "$LOG" < "$TARGETS" \
|
|
>> "$RESULTS"
|
|
echo ">> [$(ts)] 스캔 완료 ($(fmt_elapsed $(( $(date +%s) - P1 ))))" | tee -a "$LOG" >&2
|
|
|
|
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
|
|
import sys
|
|
results, chartmap, 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"]
|
|
cm={}
|
|
try:
|
|
for l in open(chartmap):
|
|
p=l.rstrip("\n").split("\t")
|
|
if len(p)>=2: cm[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))
|
|
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)
|
|
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]]
|
|
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))
|
|
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]} |")
|
|
PY
|
|
|
|
{
|
|
echo "==================================================================="
|
|
echo "SBOM SCAN END : $(ts) TOTAL: $(fmt_elapsed "$TOTAL")"
|
|
echo "요약: $SUMMARY_MD"
|
|
echo "==================================================================="
|
|
} | tee -a "$LOG" >&2
|
|
rm -f "$RESULTS" "$CHARTMAP" "$TARGETS"
|