From 6597059a1506b66397db87d48c95b9e86a4fe247 Mon Sep 17 00:00:00 2001 From: wbsong111 Date: Mon, 3 Aug 2026 15:30:57 +0900 Subject: [PATCH] =?UTF-8?q?scan-sbom.sh:=20CoverageProbe=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=9E=90=EA=B0=80=EC=A7=84?= =?UTF-8?q?=EB=8B=A8=20=EC=9D=B4=EC=8B=9D=20+=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=B4=ED=94=84=EB=9D=BC=EC=9D=B8=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit security-catalog 프로젝트에서 자체 빌드 이미지 3종을 실제로 로컬 빌드·게이트 검증하는 과정에서 발견한 문제들: - extract-helm-images.sh 가 `image:` 필드만 잡고 `imageName:`(CNPG Cluster CRD 관례) 은 놓쳐, cnpg-cluster 차트의 이미지가 SBOM·스캔·게이트 어디에도 안 나타났다. - patch-catalog-tag.py 의 split 포맷 패처가 registry 필드가 따로 없고 repository 에 registry+repo 를 합쳐 쓰는 차트(cloudnative-pg 오퍼레이터, 업스트림 템플릿이 image.registry 를 아예 참조하지 않음)에서 tag 만 조용히 갱신하고 repository 는 그대로 남겨 깨진 참조를 만들 수 있었다. - CoverageProbe(센티널 패키지 주입 재스캔으로 "0건"과 "측정 안 됨"을 구분)가 이식되지 않아, 전 심각도 0건인 자체 빌드 이미지가 실제로는 깨끗한데도 게이트가 "데이터 커버리지 이상"으로 오탐 처리했다 — security-catalog 의 scan-sbom.sh 를 이식해 해소. cve-gate.py 는 이미 이 키를 읽도록 구현돼 있어 소비 쪽 변경은 없다. rpm(SUSE)·deb(Debian)·apk(Alpine) 세 센티널 경로와 병렬 스캔 회귀를 로컬에서 확인했고, 이식 후 cloudnative-pg·cnpg-postgresql 게이트가 실제로 FAIL→PASS 로 바뀌는 것도 확인했다. Co-Authored-By: Claude Sonnet 5 --- .claude/image-authoring.md | 12 ++- .claude/pitfalls.md | 2 +- doc/sbom-pipeline.md | 11 +-- scripts/build/patch-catalog-tag.py | 49 +++++++---- scripts/pipeline/extract-helm-images.sh | 8 +- scripts/pipeline/scan-sbom.sh | 112 ++++++++++++++++++++++-- 6 files changed, 159 insertions(+), 35 deletions(-) diff --git a/.claude/image-authoring.md b/.claude/image-authoring.md index f0733ac..e7b4ad2 100644 --- a/.claude/image-authoring.md +++ b/.claude/image-authoring.md @@ -87,11 +87,9 @@ security-catalog 는 자체 빌드 이미지의 최종 런타임 베이스로 SU ```sh IMAGE= BASE_OS= bash scripts/build/build-hardened-image.sh /tmp/out ``` - `cve-gate.md` 로 실효 C/H 0 확인. **dip-catalog 의 `scan-sbom.sh` 는 커버리지 - 자가진단(`CoverageProbe`)이 없다** — 패키지가 적은 이미지(최소 베이스 등)는 findings - 전 심각도 0건이라는 이유만으로 게이트가 "데이터 커버리지 이상"으로 실패할 수 있다. - 진짜 0건인지 스캐너 데이터 부재인지는 사람이 `trivy-reports/.json`(OS/패키지 수)을 - 보고 직접 판단한다(`doc/sbom-pipeline.md` 참고) + `cve-gate.md` 로 실효 C/H 0 확인. 커버리지 자가진단(`CoverageProbe`)이 `ok` 인지도 + 확인 — `none` 이면 findings 0건이 진짜 0건이 아니라 스캐너에 그 배포판 데이터가 + 없다는 뜻이므로 게이트가 차단한다(`doc/sbom-pipeline.md` 참고) 5. **게이트 PASS 는 "동작한다" 를 증명하지 않는다.** CVE 스캐너는 CVE 와 무관한 런타임 요구사항(예: 오퍼레이터가 자신의 파일 레이아웃에 의존하는 것)을 전혀 보지 못한다. 실제 배포 검증을 반드시 한다 — 자동화된 배포 테스트 절차는 아직 없으므로 해당 차트를 @@ -105,5 +103,5 @@ security-catalog 는 자체 빌드 이미지의 최종 런타임 베이스로 SU ## SBOM·스캔·게이트는 절대 다시 만들지 않는다 `scripts/pipeline/scan-sbom.sh` · `scripts/pipeline/cve-gate.py` 는 이미지 종류와 무관하게 -동작한다(단, dip-catalog 의 `scan-sbom.sh` 는 커버리지 자가진단이 없다 — `doc/sbom-pipeline.md` -참고). `build-hardened-image.sh` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다. +동작하며 커버리지 자가진단(`CoverageProbe`)도 포함한다(`doc/sbom-pipeline.md` 참고). +`build-hardened-image.sh` 가 이미 이 둘을 호출한다 — 이미지별로 다시 구현하지 않는다. diff --git a/.claude/pitfalls.md b/.claude/pitfalls.md index d0cec6f..1dae07c 100644 --- a/.claude/pitfalls.md +++ b/.claude/pitfalls.md @@ -16,7 +16,7 @@ security-catalog 프로젝트에서 CNPG/etcd 자체 빌드·배포 테스트 | **벤더 미평가 은폐** | Ubuntu 의 "Needs evaluation" CVE 는 보고되지 않음. `--detection-priority comprehensive` 로도 안 나옴 | > dip-catalog 의 `scan-sbom.sh` 는 두 번째 양상(데이터 커버리지 부재)을 구분하는 자가진단 -> (`CoverageProbe`)이 아직 없다 — `doc/sbom-pipeline.md` 참고. +> (`CoverageProbe`)을 이식했다(2026-08-03) — `doc/sbom-pipeline.md` 참고. ## 이미지 태그의 베이스 OS 를 확인할 것 diff --git a/doc/sbom-pipeline.md b/doc/sbom-pipeline.md index 9dfb0f0..d914370 100644 --- a/doc/sbom-pipeline.md +++ b/doc/sbom-pipeline.md @@ -112,11 +112,12 @@ gh run watch --repo /dip-catalog 않는다. 45+ 개 카탈로그 차트가 아직 이 게이트로 트리아지된 적이 없어, 강제 전환 전에 먼저 전체 스캔 1회로 현황을 파악해야 한다(`MEMORY.md`). -> **커버리지 자가진단 미이식 — 알려진 제약.** security-catalog 의 `scan-sbom.sh` 는 SBOM -> 사본에 센티널 패키지를 주입해 재스캔하는 `CoverageProbe` 자가진단으로 "0건"과 -> "측정되지 않음"을 구분한다. dip-catalog 의 `scan-sbom.sh` 는 이 로직이 없다 — 게이트는 -> 항상 "프로브 이전 리포트" 경로로 판정한다(findings 총계 0건이면 보수적으로 실패, -> os-pkgs만 0건이면 경고만). 이식 여부는 미결(`MEMORY.md`). +> **커버리지 자가진단(`CoverageProbe`).** `scan-sbom.sh` 는 os-pkgs findings 가 0건인 +> 이미지의 SBOM 사본에 배포판별(deb/rpm/apk) 센티널 패키지를 주입해 재스캔하고, 발화 +> 여부로 `ok`(데이터 있음, 0건은 진짜 0건) / `none`(데이터 없음 → 게이트 차단) / +> `n/a`(OS 패키지 없음) 를 리포트에 기록한다 — "0건"과 "측정되지 않음"을 구분하는 +> 유일한 수단이다(security-catalog 프로젝트에서 이식, 2026-08-03). 이 키가 없는 구버전 +> 리포트는 findings 총계 0건이면 보수적으로 실패 처리하는 예전 경로로 판정한다. ## GitHub 설정 (워크플로 활성화에 필요) diff --git a/scripts/build/patch-catalog-tag.py b/scripts/build/patch-catalog-tag.py index 244c9de..6262063 100755 --- a/scripts/build/patch-catalog-tag.py +++ b/scripts/build/patch-catalog-tag.py @@ -52,10 +52,17 @@ def patch_image_name(text, old, new): return text.replace(pattern, f'imageName: "{new}"') -def patch_split_block(text, block, old, new): - old_registry, old_repo, old_tag = split_tag(old) - new_registry, new_repo, new_tag = split_tag(new) +def _sub_field(body, name, old_v, new_v): + """`name:` 필드의 값을 old_v -> new_v 로 치환한다. 값에 따옴표가 있었으면 유지하고 + 없었으면 안 붙인다(원본 포매팅 보존 원칙 — 모듈 docstring 참고).""" + if not old_v: + return body, False + pattern = re.compile(rf'(^\s*{name}:\s*)("?){re.escape(old_v)}\2(?=\s*$)', re.MULTILINE) + new_body, n = pattern.subn(lambda mo: f"{mo.group(1)}{mo.group(2)}{new_v}{mo.group(2)}", body) + return (new_body, True) if n else (body, False) + +def patch_split_block(text, block, old, new): # block 시작 줄부터, 그보다 더 들여써진 줄이 이어지는 동안만 치환 대상으로 삼는다. # 다음 top-level(들여쓰기 없는) 키가 나오면 블록이 끝난 것으로 본다. block_re = re.compile(rf"^{re.escape(block)}:\n((?:[ \t]+.*\n?)*)", re.MULTILINE) @@ -65,18 +72,30 @@ def patch_split_block(text, block, old, new): body = m.group(1) changed = False - for name, old_v, new_v in ( - ("registry", old_registry, new_registry), - ("repository", old_repo, new_repo), - ("tag", old_tag, new_tag), - ): - if not old_v: - continue - field_re = re.compile(rf'(^\s*{name}:\s*)"{re.escape(old_v)}"', re.MULTILINE) - new_body, n = field_re.subn(rf'\1"{new_v}"', body) - if n: - body = new_body - changed = True + + # registry 를 repository 와 분리된 필드로 쓰지 않는 차트가 있다(예: cloudnative-pg + # 오퍼레이터 — 업스트림 템플릿이 `.Values.image.registry` 를 아예 참조하지 않고 + # `repository` 하나에 registry/네임스페이스까지 전부 담아 쓴다, 실측으로 확인). + # 이런 차트에서 registry 를 억지로 분리해 치환하면 실제 배포에 쓰이는 repository + # 필드는 그대로 남고 tag 만 바뀌어 깨진 이미지 참조를 만든다 — block 에 registry: + # 필드가 실제로 있는지 먼저 확인해 분기한다. + has_registry_field = re.search(r"^\s*registry:\s*\S", body, re.MULTILINE) is not None + + if has_registry_field: + old_registry, old_repo, old_tag = split_tag(old) + new_registry, new_repo, new_tag = split_tag(new) + body, c = _sub_field(body, "registry", old_registry, new_registry) + changed = changed or c + body, c = _sub_field(body, "repository", old_repo, new_repo) + changed = changed or c + else: + old_repo_full, old_tag = old.rsplit(":", 1) + new_repo_full, new_tag = new.rsplit(":", 1) + body, c = _sub_field(body, "repository", old_repo_full, new_repo_full) + changed = changed or c + + body, c = _sub_field(body, "tag", old_tag, new_tag) + changed = changed or c if not changed: return None diff --git a/scripts/pipeline/extract-helm-images.sh b/scripts/pipeline/extract-helm-images.sh index 0c540d6..89d3f8a 100755 --- a/scripts/pipeline/extract-helm-images.sh +++ b/scripts/pipeline/extract-helm-images.sh @@ -40,8 +40,12 @@ STATUS="$OUT_DIR/render_status.tsv" cd "$HELM_ROOT" extract_images() { # stdin: 렌더된 yaml → stdout: 이미지 문자열 - grep -ioE '^[[:space:]]*(- )?image:[[:space:]]*"?[^"[:space:]]+' \ - | sed -E 's/.*image:[[:space:]]*"?//' \ + # `image:` 뿐 아니라 `imageName:` 도 잡는다 — CNPG Cluster CRD(cnpg-cluster 차트의 + # postgresql.imageName)처럼 단일 문자열 필드로 이미지를 표기하는 관례가 있다 + # (patch-catalog-tag.py 의 TAG_STYLE=imageName 과 같은 표기). 이걸 놓치면 그 이미지가 + # SBOM·스캔·게이트 어디에도 나타나지 않고 조용히 빠진다(실측: cnpg-cluster). + grep -ioE '^[[:space:]]*(- )?image(Name)?:[[:space:]]*"?[^"[:space:]]+' \ + | sed -E 's/.*image(Name)?:[[:space:]]*"?//' \ | grep -vE '^$' } diff --git a/scripts/pipeline/scan-sbom.sh b/scripts/pipeline/scan-sbom.sh index 7816f4b..5b6f435 100755 --- a/scripts/pipeline/scan-sbom.sh +++ b/scripts/pipeline/scan-sbom.sh @@ -7,6 +7,12 @@ # 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 취약점 스캔 @@ -20,7 +26,7 @@ # TRIVY_CACHE_DIR trivy 캐시 디렉토리 (미설정 시 trivy 기본 ~/.cache/trivy) # # 산출물 ($1 기준): -# trivy-reports/.json 이미지별 원본 스캔 결과 +# trivy-reports/.json 이미지별 원본 스캔 결과 (+ CoverageProbe) # trivy-summary.tsv chart⇥image⇥CRIT⇥HIGH⇥MED⇥LOW⇥status⇥seconds # trivy-summary.md 마크다운 요약 # trivy-run.log 실행 로그 @@ -38,6 +44,92 @@ PARALLEL="${PARALLEL:-4}" 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 @@ -48,6 +140,13 @@ if [ "${1:-}" = "--scan-one" ]; then 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 @@ -56,9 +155,10 @@ 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}") + # 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}") + print(f"{img}\t-\t-\t-\t-\tPARSE_ERR:{str(e)[:40]}\t{dur}\t?") PY )" else @@ -66,11 +166,12 @@ PY 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")" + 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)" - printf '%s %-6s %5ss %s\n' "$(ts)" "${st%%:*}" "$dur" "$img" >> "$log" + cov="$(printf '%s' "$line" | cut -f8)" + printf '%s %-6s %5ss cov=%-4s %s\n' "$(ts)" "${st%%:*}" "$dur" "$cov" "$img" >> "$log" exit 0 fi @@ -215,3 +316,4 @@ PY echo "===================================================================" } | tee -a "$LOG" >&2 rm -f "$RESULTS" "$CHARTMAP" "$TARGETS" +rm -rf "$OUT_DIR/.probe" # 자가진단용 합성 SBOM 사본 — 산출물이 아니다