자체 빌드 드리프트 스캔·재빌드 트리거를 제거하고 CI 파이프라인을 정리한다

hardened-containers가 이미 rescan.yml로 매일 자율 재스캔·재빌드하고, sbom.yml이
custom-values.yaml 기준으로 자체 빌드 이미지를 다른 카탈로그 이미지와 동일하게
스캔하고 있어 self-build-drift-check.yml의 트리거·전용 스캔이 순수 중복이었다
(SECURITY_IMAGES_DISPATCH_TOKEN도 등록된 적 없어 트리거 스텝은 항상 실패하던
죽은 코드). check-rebuild-needed.py가 더하던 fixable/no-fix 구분도 cve-gate.py
리포트에 이미 있어 흡수할 필요 없이 삭제했다. 근거는 ADR 0005.

곁들여 CI 위생 문제(trivy DB 캐시 없음, concurrency 없음, catalog-tag-update.yml의
브랜치 누적)를 함께 고치고, hardened-containers의 docs/image-authoring.md가
docs/image-authoring/ 로 분할된 것을 반영해 관련 링크를 정정했다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wbsong111
2026-08-26 09:48:48 +09:00
parent 9e5b6633db
commit f5b8e4b91f
20 changed files with 339 additions and 452 deletions
+5 -5
View File
@@ -7,7 +7,7 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
이 skill 은 실행하지 않는다 — 레버를 판단해 실행 skill로 넘긴다. 게이트 실행/해석은
`sbom-cve-gate`, 자체 빌드는 **별도 레포 `hardened-containers`**(그 레포의
`docs/image-authoring.md`가 단일 출처), 배포 검증은
`docs/image-authoring/README.md`가 단일 출처), 배포 검증은
[deploy-test-procedure.md](../../deploy-test-procedure.md)가 단일 출처다 — 여기 반복 안 한다.
## 흐름
@@ -37,10 +37,10 @@ description: 게이트가 카탈로그 이미지에서 차단 CVE(CRITICAL/HIGH)
비교) 확인한다. 실례: keycloak `CVE-2025-59250`(mssql-jdbc jar 하나가 두 컴포넌트로
잡혀 잘린 쪽만 매칭된 오탐). 근거·만료일은 `doc/cve-exceptions.json`.
6. 자체 빌드는 별도 레포 `hardened-containers`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 재빌드가 필요하면 그 레포의
`build-image.yml``workflow_dispatch`로 부른다(`scripts/build/check-rebuild-needed.py`
가 배포 중인 이미지를 스캔해 대상을 판단한다). 태그/베이스 OS 교체는 카탈로그
values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
`docs/image-authoring/README.md`가 절차 단일 출처다. 재빌드는 그 레포가 `rescan.yml`
매일 자율 수행한다 — dip-catalog는 트리거하지 않는다. `sbom.yml`이 배포 중인 자체
빌드 이미지의 CVE 드리프트를 다른 카탈로그 이미지와 동일하게 보고만 한다. 태그/베이스
OS 교체는 카탈로그 values만 바꾸고 재게이트한다 — 별도 스크립트 없음.
7. 수정 후 반드시 재게이트하고 PASS라도 배포 검증까지 끝나야 종료다 — 1회로 끝난다고
가정하지 않는다(keycloak은 1차 수정 후 재스캔에서 micrometer 2건이 새로 잡혔다).
8. 착지는 브랜치 push까지다 — 조직 정책상 `GITHUB_TOKEN`으로 PR을 못 연다. 사람이 PR을
+1 -1
View File
@@ -56,7 +56,7 @@ gh run watch --repo <org>/dip-catalog
```
자체 빌드로 가야 한다면 별도 레포 `hardened-containers`에서 한다 — 그 레포의
`docs/image-authoring.md`가 절차 단일 출처다. 판정 로직 상세는
`docs/image-authoring/README.md`가 절차 단일 출처다. 판정 로직 상세는
`scripts/pipeline/cve-gate.py`의 모듈 docstring을 1차 출처로 본다.
## 참고
+15 -4
View File
@@ -6,7 +6,7 @@ name: catalog-tag-update
# 차트에 반영할지" 는 이 카탈로그가 판단한다(catalog/image-map/<image>.env).
#
# public raw URL 로 읽으므로 인증이 필요 없다. hardened-containers 쪽 시크릿·PAT 도 없다 —
# 의존 방향은 카탈로그 → 이미지 단방향이다(docs/image-authoring.md "카탈로그 레포와의
# 의존 방향은 카탈로그 → 이미지 단방향이다(docs/image-authoring/ci.md "카탈로그 레포와의
# 계약", hardened-containers 레포).
#
# PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수 없다는
@@ -22,6 +22,12 @@ on:
permissions:
contents: write
concurrency:
group: catalog-tag-update
# push 도중 취소를 막는다 — 아래 고정 브랜치(build/catalog-tag-update)를 두고
# 두 실행이 경합하는 것을 방지한다.
cancel-in-progress: false
env:
SECURITY_IMAGES_REPO: paasup/hardened-containers
@@ -51,10 +57,15 @@ jobs:
if: steps.apply.outputs.count != '0'
run: |
set -euo pipefail
BRANCH="build/catalog-tag-update-$(date -u +%Y%m%d%H%M%S)"
# 고정 브랜치명 하나를 재사용한다 — PR 은 사람이 열어야 해서(조직 정책) 매 실행
# 새 브랜치를 만들면 방치된 동일 내용 브랜치가 날마다 쌓인다. 이 브랜치는 이
# 워크플로만 쓰므로 force push 가 안전하다. 이미 이 브랜치로 PR 이 열려 있으면
# force push 가 그 PR 을 갱신한다(원하는 동작) — PR 이 머지된 뒤면 다음 실행이
# main 기준으로 새로 만든다.
BRANCH="build/catalog-tag-update"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
git checkout -B "$BRANCH"
# apply-published-tags.py 가 이미 파일을 고쳤다 — 여기서는 그 결과를 커밋만 한다.
FILES="$(python3 -c "
@@ -74,7 +85,7 @@ jobs:
-m "hardened-containers 레포의 published.json(게이트 PASS + push 확인된 발행 기록)을" \
-m "반영한다. 상세는 이 실행의 Job Summary 참고." \
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push -u origin "$BRANCH"
git push --force origin "HEAD:$BRANCH"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
{
+18
View File
@@ -32,6 +32,10 @@ on:
permissions:
contents: read
concurrency:
group: cve-edge-post
cancel-in-progress: false # 외부로 POST 하는 잡이라 중간 취소보다 직렬화가 안전하다
jobs:
cve-json:
runs-on: ubuntu-latest
@@ -57,6 +61,20 @@ jobs:
- name: git safe.directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: 캐시 키 날짜 계산
id: cache-date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
# trivy 취약점 DB 캐시. sbom.yml 과 같은 규칙 — 하루 단위 키, 실패해도 무시.
- name: trivy DB 캐시
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/sbom-out/cache
key: trivy-db-${{ steps.cache-date.outputs.date }}
restore-keys: |
trivy-db-
# 사설 레지스트리 인증: 시크릿으로 docker config.json 을 만들어 trivy 가 읽게 한다.
- name: 레지스트리 인증 구성
env:
+24
View File
@@ -26,6 +26,12 @@ on:
permissions:
contents: read
concurrency:
group: sbom-${{ github.ref }}
# PR 증분 diff 는 base_ref...HEAD 기준이라 최신 실행이 이전 커밋들의 변경까지 모두
# 포함한다 — 취소해도 누락이 없다. 스케줄 전체 스캔은 취소하지 않는다.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
sbom:
runs-on: ubuntu-latest
@@ -58,6 +64,24 @@ jobs:
- name: git safe.directory
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
- name: 캐시 키 날짜 계산
id: cache-date
run: echo "date=$(date -u +%Y%m%d)" >> "$GITHUB_OUTPUT"
# trivy 취약점 DB 캐시. 하루 단위 키라 같은 날 여러 번 돌아도 최초 1회만
# 새로 받는다. DB 가 낡아도 trivy 가 스스로 갱신하므로 오래된 캐시(전날 키,
# restore-keys 로 폴백)를 복원해도 정확성 문제는 없다 — 다운로드 분량만
# 줄어든다. 캐시는 최적화이지 정확성 요건이 아니므로 실패해도 워크플로를
# 막지 않는다.
- name: trivy DB 캐시
continue-on-error: true
uses: actions/cache@v4
with:
path: ${{ github.workspace }}/sbom-out/cache
key: trivy-db-${{ steps.cache-date.outputs.date }}
restore-keys: |
trivy-db-
# 사설 레지스트리 인증: 시크릿으로 docker config.json 을 만들어 trivy 가 읽게 한다.
- name: 레지스트리 인증 구성
env:
@@ -1,83 +0,0 @@
name: self-build-drift-check
# 배포 중인 자체 빌드 이미지(hardened-containers 레포가 만든 것)가 새 CVE 로 규정을 벗어났는지
# 이 카탈로그가 스스로 스캔해 판정한다. "무엇이 배포 중인가"는 이 카탈로그만 안다 —
# hardened-containers 는 자기 스스로 이 판단을 하지 않는다
# (hardened-containers 레포 docs/image-authoring.md "재빌드는 이 레포가 스스로 트리거하지 않는다").
#
# 판정(scripts/build/check-rebuild-needed.py)이 재빌드 대상이라고 하면 hardened-containers 의
# `build-image.yml` 을 `workflow_dispatch` 로 부른다. 그 워크플로가 실제 빌드·검증·게이트·
# push·`published.json` 갱신을 한다 — 이 워크플로는 트리거만 한다.
#
# 레지스트리 push 와 카탈로그 반영은 이 워크플로가 하지 않는다. `catalog-tag-update.yml` 이
# `published.json` 을 읽어가는 별도 경로로 반영한다.
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * 1' # 매주 월요일 02:00 UTC
env:
SECURITY_IMAGES_REPO: paasup/hardened-containers
jobs:
drift-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: trivy 설치
run: |
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version | head -1
- name: 배포 중인 자체 빌드 이미지 스캔
id: scan
run: |
set -uo pipefail
mkdir -p /tmp/drift-reports
python3 scripts/build/check-rebuild-needed.py --list-refs > /tmp/refs.tsv
while IFS=$'\t' read -r img ref fname; do
[ -n "${fname:-}" ] || continue
echo "스캔: $ref"
trivy image --quiet --scanners vuln \
--severity UNKNOWN,LOW,MEDIUM,HIGH,CRITICAL \
--format json "$ref" > "/tmp/drift-reports/$fname" \
|| { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다"
rm -f "/tmp/drift-reports/$fname"; }
done < /tmp/refs.tsv
python3 scripts/build/check-rebuild-needed.py \
--reports /tmp/drift-reports \
--summary-md /tmp/drift.md \
--json-out /tmp/drift.json
cat /tmp/drift.md >> "$GITHUB_STEP_SUMMARY"
python3 - <<'PY' >> "$GITHUB_OUTPUT"
import json
rows = json.load(open("/tmp/drift.json"))
print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"]))
PY
- name: hardened-containers 재빌드 트리거
if: steps.scan.outputs.images != '' && steps.scan.outputs.images != '[]'
env:
GH_TOKEN: ${{ secrets.SECURITY_IMAGES_DISPATCH_TOKEN }}
run: |
set -euo pipefail
[ -n "$GH_TOKEN" ] || { echo "::error::SECURITY_IMAGES_DISPATCH_TOKEN 시크릿 없음 — ${SECURITY_IMAGES_REPO} 에 workflow_dispatch 를 걸 권한이 있는 PAT 필요"; exit 1; }
python3 -c "import json,sys; print('\n'.join(json.loads(sys.argv[1])))" '${{ steps.scan.outputs.images }}' | \
while read -r img; do
[ -n "$img" ] || continue
echo "재빌드 트리거: $img"
gh workflow run self-build-image --repo "$SECURITY_IMAGES_REPO" -f "image=$img"
done
{
echo
echo "재빌드를 트리거했다: ${{ steps.scan.outputs.images }}"
echo "진행 상황: https://github.com/${SECURITY_IMAGES_REPO}/actions/workflows/self-build-image.yml"
echo
echo "게이트 PASS + push 성공 시 그 레포의 \`published.json\` 이 갱신되고,"
echo "\`catalog-tag-update.yml\` 이 다음 실행에서 카탈로그에 반영한다."
} >> "$GITHUB_STEP_SUMMARY"
+3
View File
@@ -1,6 +1,9 @@
__pycache__/
*.pyc
# 로컬 Claude Code 도구 설정(개인 경로·권한 포함, 팀 공유 대상 아님)
/.claude/settings.json
# 로컬 테스트 산출물 (커밋 금지) — 멀티테넌시 격리 검증 스크립트·문서·결과
/tenant-verification/
+10 -9
View File
@@ -32,9 +32,9 @@ dip-catalog/
│ └── status.md # 구현 현황
├── scripts/
│ ├── pipeline/ # SBOM 생성 + CVE 스캔 + 게이트 판정 (sbom.yml/cve-edge-post.yml 이 쓴다)
│ ├── build/ # 자체 빌드 이미지 축의 카탈로그 쪽 절반 — 드리프트 탐지
│ │ # (check-rebuild-needed.py) + 발행 태그 반영
│ │ # (apply-published-tags.py). 빌드 자체는 hardened-containers 레포
│ ├── build/ # 자체 빌드 이미지 축의 카탈로그 쪽 절반 — 발행 태그 반영
│ │ # (apply-published-tags.py). CVE 스캔은 sbom.yml 이 다른
│ │ # 카탈로그 이미지와 동일하게 하고, 빌드 자체는 hardened-containers 레포
│ └── deploy-test/ # 배포 검증 스크립트 + fixtures (helm/kubectl 실행 전담)
├── catalog/
│ └── image-map/<image>.env # 자체 빌드 이미지 → 차트·필드 매핑 (목록은 이 디렉토리가 단일 출처)
@@ -154,7 +154,7 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
| [sbom-cve-gate](.claude/skills/sbom-cve-gate/SKILL.md) | SBOM 생성·CVE 스캔·게이트 판정 실행 및 결과 해석 (`scripts/pipeline`) |
자체 빌드 하드닝 이미지 추가·변경은 이 레포의 일이 아니다 — 별도 레포 `hardened-containers`
에서 하고, 그 레포의 `docs/image-authoring.md`가 단일 출처다(이관 배경:
에서 하고, 그 레포의 `docs/image-authoring/README.md`가 단일 출처다(이관 배경:
[doc/migrations/](doc/migrations/)).
각 Skill 은 절차 본문을 복제하지 않고 권위 있는 문서(`doc/sbom-pipeline.md` 등)를
@@ -175,7 +175,7 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
| 축 | 질문 | 실행 위치 | 워크플로(이 레포) | 게이트 | 단일 출처 |
|----|------|----------|------------------|--------|----------|
| **차트 카탈로그** | 우리가 배포하는 이미지에 무엇이 있는가 | 이 레포 | `sbom.yml`<br>`cve-edge-post.yml` | warn-only<br>**호출 안 함** | [doc/sbom-pipeline.md](doc/sbom-pipeline.md) |
| **자체 빌드** | 그 이미지를 어떻게 만드는가 | 별도 레포 `hardened-containers` | `self-build-drift-check.yml`(탐지·트리거)<br>`catalog-tag-update.yml`(반영) | **강제**(그 레포 소유) | 그 레포의 `docs/image-authoring.md` |
| **자체 빌드** | 그 이미지를 어떻게 만드는가 | 별도 레포 `hardened-containers` | `catalog-tag-update.yml`(반영) | **강제**(그 레포 소유) | 그 레포의 `docs/image-authoring/README.md` |
- **차트 축은 warn-only 다** — 게이트가 실패해도 CI/PR 을 막지 않는다. 카탈로그 차트 전체가
이 게이트로 트리아지된 적이 없다. **자체 빌드 축의 게이트는 이미 강제다**(단, 그 게이트는
@@ -186,10 +186,10 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
레버 판단은 [cve-remediation](.claude/skills/cve-remediation/SKILL.md) skill 이 갖는다.
실행은 `hardened-containers` 레포에서 `workflow_dispatch` 로만 한다.
- **이 레포는 "무엇을 배포 중인가"만 안다.** 어느 이미지를 자체 빌드하고 있는지는
`catalog/image-map/` 이 단일 출처다. `scripts/build/check-rebuild-needed.py`(주간
`self-build-drift-check.yml`)가 배포 중인 이미지를 직접 스캔해 재빌드 대상을 판단하고,
필요하면 `hardened-containers``build-image.yml` 을 트리거만 한다 — 핀을 무엇으로
올릴지는 그 레포가 판단한다.
`catalog/image-map/` 이 단일 출처다. 배포 중인 자체 빌드 이미지의 CVE 드리프트는
`sbom.yml` 이 다른 카탈로그 이미지와 동일하게 스캔·보고한다 — 재빌드 여부·시점은
전적으로 `hardened-containers`자체 `rescan.yml` 이 매일 자율 결정한다. 이 레포는
트리거하지 않는다(ADR [0005](doc/decisions/0005-self-build-drift-check-removed.md)).
- **카탈로그 반영은 pull 방식이다.** `hardened-containers` 는 이 카탈로그를 모른다 — 게이트
PASS + push 성공 시 자기 레포의 `published.json` 만 갱신한다. `catalog-tag-update.yml`
이 그 파일을 public raw URL 로 읽어가 `custom-values.yaml`/`dip-values.yaml` 을 패치한다.
@@ -261,3 +261,4 @@ python3 agent/update_catalog/skills/helm_diff/scripts/run.py \
| [agent/update_catalog/docs/decisions/001-agentic-first.md](agent/update_catalog/docs/decisions/001-agentic-first.md) | 파이프라인 오케스트레이션 건너뛰기 결정 배경 |
| [agent/update_catalog/docs/status.md](agent/update_catalog/docs/status.md) | 구현 현황 상세 |
| [doc/sbom-pipeline.md](doc/sbom-pipeline.md) | SBOM 생성·CVE 스캔·게이트 파이프라인 상세 |
| [doc/architecture.md](doc/architecture.md) | `.github/workflows/` 4개 워크플로의 트리거 조건·잡 흐름도 (CI 파이프라인 아키텍처) |
+4 -6
View File
@@ -49,13 +49,11 @@
지우면 게이트가 PASS 로 떨어진다. 직전 버전을 없애는 결정이라 PR #28 에서 보류했다.
- **자체 빌드 이미지 레포 분리는 완료됐다** — 프레임워크·이미지 정의·ADR 이
`hardened-containers` 레포로 나갔다. 카탈로그 쪽은 `catalog/image-map/`(어느 차트를
가리키는지) + `check-rebuild-needed.py`(드리프트 탐지) + `apply-published-tags.py`
(발행 태그 반영)만 남았다. 배경·결합점 전체는
가리키는지) + `apply-published-tags.py`(발행 태그 반영)만 남았다. CVE 드리프트
스캔·재빌드 트리거는 별도로 두지 않는다 — `sbom.yml`이 자체 빌드 이미지도 함께 스캔·보고하고,
재빌드는 `hardened-containers``rescan.yml`이 자율 수행한다(ADR
[0005](doc/decisions/0005-self-build-drift-check-removed.md)). 배경·결합점 전체는
[doc/migrations/](doc/migrations/self-build-images-to-hardened-containers.md).
**아직 설정 안 된 것**: `self-build-drift-check.yml``hardened-containers`
`build-image.yml` 을 트리거하려면 그 레포에 `workflow_dispatch` 권한이 있는 PAT 을
`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿으로 등록해야 한다. 등록 전까지 트리거 스텝은
실패한다(의도된 명시적 실패 — 조용히 넘어가지 않는다).
- **카탈로그 태그가 클러스터보다 앞서 있고 재검증 경로가 없다** — 게이트 PASS 만으로
`patch-catalog-tag.py`(이제 `catalog-tag-update.yml` 을 통해)가 태그를 올린다. 실측:
클러스터에 배포된 태그가 카탈로그가 가리키는 태그보다 뒤처진 사례가 있었다. 소스 안
+2 -2
View File
@@ -2,8 +2,8 @@
hardened-containers 레포가 발행하는 자체 빌드 이미지가 이 카탈로그의 어느 차트·어느 필드를
가리키는지 선언한다. **이 디렉토리에 파일이 있는 이미지 이름 = 이 카탈로그가 추적하는
자체 빌드 이미지 목록**이다(`scripts/build/check-rebuild-needed.py`
`.github/workflows/catalog-tag-update.yml`이 이 목록을 기준으로 동작한다).
자체 빌드 이미지 목록**이다(`.github/workflows/catalog-tag-update.yml`이 이 목록을 기준으로
동작한다).
이관 배경: 이 정보는 원래 hardened-containers(당시 `images/<image>/catalog.env`)에 있었다.
"어느 이미지를 쓰는가"는 카탈로그가 알아야 하고 "그 이미지를 어떻게 만드는가"는
+155
View File
@@ -0,0 +1,155 @@
# CI 파이프라인 아키텍처
이 문서는 `.github/workflows/`에 있는 3개 워크플로가 **무엇을 트리거로 도는지**, **잡 흐름이
어떻게 이어지는지**를 한눈에 보여준다. 각 워크플로 내부 메커니즘(스캔 방식, 게이트 판정
로직 등)의 단일 출처는 별도 문서다 — 여기서 복제하지 않는다. 스타일은 별도 레포
`hardened-containers``docs/architecture.md`를 참고했다.
## 1. 두 축, 세 워크플로
카탈로그는 "무엇을 배포 중인가"(차트 카탈로그 축)와 "그 이미지를 어떻게 만드는가"(자체
빌드 축)를 분리해서 다룬다 — 축·소유 레포·게이트 강제력 비교는
[CLAUDE.md](../CLAUDE.md)의 "CVE/SBOM 게이트 작업" 표 참고. 이 레포에 있는 워크플로 3개는
그 두 축 위에 이렇게 나뉜다.
| 워크플로 | 축 | 트리거 | 게이트 |
|---|---|---|---|
| `sbom.yml` | 차트 카탈로그(자체 빌드 이미지 포함) | `pull_request`(`manifests/helm/**`) · `workflow_dispatch` · 매주 일 18:00 UTC | warn-only |
| `cve-edge-post.yml` | 차트 카탈로그 | `workflow_dispatch`만 (스케줄 주석 처리됨) | 없음 — 원시 집계 POST |
| `catalog-tag-update.yml` | 자체 빌드 | `workflow_dispatch` · 매일 03:00 UTC | 없음 — 반영만 |
**PR 이벤트에 반응하는 워크플로는 `sbom.yml` 하나뿐이다.** 나머지 두 개는 사람이 Actions
탭에서 수동 실행하거나 스케줄로만 돈다.
> 자체 빌드 이미지의 CVE 드리프트 스캔·재빌드 트리거를 위한 전용 워크플로
> (`self-build-drift-check.yml`)는 없다 — `sbom.yml`이 자체 빌드 이미지도 다른 카탈로그
> 이미지와 동일하게 스캔·보고하고, 재빌드는 전적으로 `hardened-containers``rescan.yml`
> 자율 수행한다. 근거는 ADR [0005](decisions/0005-self-build-drift-check-removed.md).
## 2. 트리거 조건 → Job 흐름
```mermaid
flowchart TD
PR["pull_request<br/>manifests/helm/** 변경"]
WD1["workflow_dispatch<br/>chart, limit"]
SCH1["schedule<br/>매주 일 18:00 UTC"]
subgraph SBOM["sbom.yml — 차트 카탈로그 SBOM/CVE 스캔 (자체 빌드 이미지 포함)"]
S1[이미지 인벤토리 추출]
S2["PR: diff 증분만 대상<br/>스케줄·수동: 전체 또는 지정 차트"]
S3[SBOM 생성 CycloneDX]
S4[취약점 스캔]
S5["CVE 게이트 판정<br/>warn-only — 실패해도 PR 안 막음"]
S6[아티팩트 업로드 + Job Summary]
S1 --> S2 --> S3 --> S4 --> S5 --> S6
end
PR --> S1
WD1 --> S1
SCH1 --> S1
WD2["workflow_dispatch<br/>chart, limit<br/>(스케줄 없음 — 주석 처리)"]
subgraph EDGE["cve-edge-post.yml — 외부 집계 전송"]
V1[이미지 인벤토리 추출]
V2[SBOM 생성 + 취약점 스캔]
V3[CVE JSON 집계]
V4["POST edge.gke.paasup.io<br/>승인 예외·실효 등급 미적용"]
V1 --> V2 --> V3 --> V4
end
WD2 --> V1
subgraph HC["hardened-containers — 다른 레포, 독립 루프 (이 레포는 트리거하지 않는다)"]
HR1["rescan.yml<br/>매일 자율 재스캔"]
HR2{드리프트 있음?}
HB1[build-image.yml<br/>빌드→검증→SBOM→스캔→게이트]
HB2["PASS → 레지스트리 push + attest<br/>+ published.json 갱신"]
HR1 --> HR2
HR2 -->|예| HB1
HB1 --> HB2
end
WD4["workflow_dispatch"]
SCH4["schedule<br/>매일 03:00 UTC"]
subgraph SYNC["catalog-tag-update.yml — 발행 태그 카탈로그 반영"]
C1[published.json 조회]
C2["반영 대상 계산<br/>apply-published-tags.py"]
C3{변경 있음?}
C4["고정 브랜치로 push<br/>+ compare 링크 Job Summary"]
C5[변경 없음 — 종료]
C1 --> C2 --> C3
C3 -->|예| C4
C3 -->|아니오| C5
end
WD4 --> C1
SCH4 --> C1
HB2 -.->|"public raw URL 폴링<br/>인증 없음, pull 방식"| C1
C4 -.->|"CI는 GITHUB_TOKEN으로 PR 생성 불가<br/>(조직 정책, 실측 확정)"| HumanPR["사람이 compare 링크로<br/>PR 직접 오픈"]
```
세 갈래를 눈여겨볼 것:
1. **`sbom.yml`만 PR에 반응한다.** `cve-edge-post.yml`·`catalog-tag-update.yml`은 스케줄·수동뿐이다.
2. **레포 경계를 넘는 방향은 하나뿐이다 — pull.** `catalog-tag-update.yml`
`published.json`을 폴링해서 가져올 뿐, 자체 빌드가 언제 도는지는 이 레포가 전혀
모른다 — `hardened-containers``rescan.yml`이 전적으로 자율 결정하고, 이 레포는
어느 쪽으로도 그 레포를 트리거하지 않는다.
3. **끝은 항상 사람이다.** `catalog-tag-update.yml`이 브랜치까지는 push하지만 PR은 열지
않는다 — CI가 `GITHUB_TOKEN`으로 PR을 못 여는 게 조직 정책으로 실측 확정된 제약이라서다
(완화 방법은 [MEMORY.md](../MEMORY.md), [.claude/skills/cve-remediation/SKILL.md](../.claude/skills/cve-remediation/SKILL.md) 참고).
## 3. 워크플로별 상세
### `sbom.yml` — 차트 카탈로그 SBOM/CVE 스캔 (warn-only)
메커니즘 상세(스캔 대상 결정, `cve-gate.py` 판정 로직, 아티팩트 구조, trivy DB 캐싱·
concurrency)의 단일 출처는 [doc/sbom-pipeline.md](sbom-pipeline.md)다. 여기서는 트리거·잡
경계만 짚는다.
- PR에서는 `git diff`로 변경된 차트만 골라 스캔 범위를 줄인다(전체 스캔은 무겁다).
- 스케줄·수동 실행은 전체(또는 `chart` 입력으로 지정한 하나)를 스캔한다.
- **자체 빌드 이미지(`docker.io/paasup/*`)도 같은 대상이다**`extract-helm-images.sh`
`custom-values.yaml`을 적용해 렌더하므로 별도 취급 없이 그대로 스캔된다. 자체 빌드
이미지 CVE 드리프트를 따로 보는 워크플로는 없다 — 이 리포트가 유일한 경로다.
- 실행 컨테이너(`vars.SBOM_PIPELINE_IMAGE`)에 helm/trivy/python3/bash/git이 없으면
preflight 스텝에서 즉시 실패한다.
- 게이트가 실패해도(`--warn-only`) 워크플로 자체는 성공으로 끝난다 — Job Summary와
아티팩트로만 결과를 남긴다.
### `cve-edge-post.yml` — 외부 집계 전송
- `sbom.yml`과 이미지 인벤토리·SBOM·스캔 스텝을 그대로 재사용하지만, 게이트 판정
(`cve-gate.py`)을 부르지 않는다 — 승인 예외·실효 등급(`max(벤더,NVD)`)이 적용 안 된
**원시** 집계라는 뜻이다. `sbom.yml`의 결과와 CRITICAL 건수가 다르게 보일 수 있다.
- 결과는 `(catalog, version, image)` 조합 단위 JSON으로 만들어져
`https://edge.gke.paasup.io/api/v1/cve-scans`로 POST된다(`CVE_API_KEY` 헤더 인증,
TLS 검증은 `--insecure`로 스킵).
- 스케줄이 주석 처리돼 있어 현재는 **수동 실행만** 된다.
### `catalog-tag-update.yml` — 발행 태그 카탈로그 반영
- `hardened-containers`는 이 카탈로그의 존재를 모른다 — 게이트 PASS + push가 실제로
일어났을 때만 자기 레포의 `published.json`을 갱신해둘 뿐이다. 이 워크플로가 그 파일을
**public raw URL로 폴링**해서 가져온다(인증 불필요, 상세 계약은
[doc/migrations/self-build-images-to-hardened-containers.md](migrations/self-build-images-to-hardened-containers.md)).
역방향 알림(`repository_dispatch` 등)은 의도적으로 안 쓴다 — 그러려면
`hardened-containers` 쪽에 이 카탈로그 쓰기 권한 PAT을 둬야 하는데, 그 레포의 "외부
의존성 없이 단독 동작" 원칙과 충돌하기 때문이다.
- `scripts/build/apply-published-tags.py``gate == "pass"`인 이미지만 골라
`custom-values.yaml`/`dip-values.yaml`을 패치한다(`patch-catalog-tag.py`를 내부에서
호출) — 실패한 빌드의 태그가 반영되는 일은 없다.
- 변경이 있으면 고정 브랜치(`build/catalog-tag-update`)로 force push까지만 하고
멈춘다 — 매 실행 새 브랜치를 만들지 않으므로 방치된 동일 내용 브랜치가 쌓이지 않는다.
**PR은 자동으로 열리지 않는다** — Job Summary의 compare 링크를 보고 사람이 직접 연다.
## 4. 참고
- 두 축 개념·소유 레포·문서 단일 출처 매핑: [CLAUDE.md](../CLAUDE.md) "CVE/SBOM 게이트 작업"
- 차트 카탈로그 축 스캔 메커니즘·CI 위생(캐싱·concurrency) 상세: [doc/sbom-pipeline.md](sbom-pipeline.md)
- 자체 빌드 축 이관 배경·계약: [doc/migrations/](migrations/)
- 자체 빌드 드리프트 전용 워크플로를 없앤 근거: ADR [0005](decisions/0005-self-build-drift-check-removed.md)
- 차단 CVE 대응 시 레버 판단: [.claude/skills/cve-remediation/SKILL.md](../.claude/skills/cve-remediation/SKILL.md)
- `hardened-containers` 자체 파이프라인(빌드→검증→SBOM→스캔→게이트→push, 매일 자율
재스캔 `rescan.yml`) 상세는 그 레포의 `docs/architecture.md`가 단일 출처다 — 이 문서는
그 파이프라인의 트리거·출력 지점만 다룬다.
+1 -1
View File
@@ -35,7 +35,7 @@ dev 클러스터 `apisix` 네임스페이스에 이미 `data-apisix-etcd-0` PVC
1. 패치가 멈춘 이미지는 시간이 지날수록 미수정 CVE 가 구조적으로 누적된다 —
"CRITICAL/HIGH 0건" 을 유지 가능한 상태로 지속할 수 없다.
2. 버전 고정 없는 `latest`-only 배포는 **롤링 태그 금지** 규칙과 애초에 양립하지 않는다
(자체 빌드 이미지에 적용되는 이 규칙은 `hardened-containers` 레포의 `docs/image-authoring.md`
(자체 빌드 이미지에 적용되는 이 규칙은 `hardened-containers` 레포의 `docs/image-authoring/README.md`
가 갖는다).
`groundhog2k/etcd` 는 업스트림 etcd 프로젝트의 원본 이미지(`quay.io/coreos/etcd`)를 그대로
@@ -0,0 +1,75 @@
# 0005. 자체 빌드 드리프트 스캔·재빌드 트리거를 dip-catalog에서 제거한다
- 날짜: 2026-08-26
- 상태: 확정
## 결정
dip-catalog에서 자체 빌드 이미지 재빌드 트리거와 전용 드리프트 스캔을 모두 제거한다 —
`.github/workflows/self-build-drift-check.yml``scripts/build/check-rebuild-needed.py`
삭제한다. 자체 빌드 이미지의 CVE는 `sbom.yml`이 다른 카탈로그 이미지와 동일하게
스캔·보고하고, 재빌드 여부·시점은 전적으로 `hardened-containers``rescan.yml`
자율 결정한다.
## 배경
`self-build-drift-check.yml`은 두 가지를 했다: (1) 배포 중인 자체 빌드 이미지를 스캔해
재빌드 대상을 판정하고, (2) 대상이 있으면 별도 레포 `hardened-containers`
`build-image.yml``workflow_dispatch`로 트리거했다.
## 근거
자체 빌드 이미지의 CVE는 이미 세 겹으로 덮여 있었다.
1. **빌드 시점**`hardened-containers`가 강제 zero-CVE 게이트를 통과한 이미지만 push하고,
`published.json`은 게이트 PASS + 실제 push된 것만 기록한다. 새로 발행된 이미지는
구조적으로 깨끗하다.
2. **빌드 이후 드리프트** — 그 레포의 `rescan.yml`이 매일 재스캔하고 자율적으로 재빌드한다.
dip-catalog가 별도로 트리거를 보내는 건 이 루프를 중복시킬 뿐이다. 실제로도
`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿이 한 번도 등록된 적 없어 트리거 스텝은
실행할 때마다 실패하는 죽은 코드였다.
3. **dip-catalog가 실제 배포 중인 것**`sbom.yml`이 이미 스캔한다. `extract-helm-images.sh`
`custom-values.yaml`을 적용해 `helm template`로 렌더하므로 `docker.io/paasup/*`가 그대로
스캔 대상에 들어가고, `cve-gate.py`가 같은 게이트 규칙(실효 등급 `max(벤더,NVD)` ·
승인 예외)으로 판정한다. 같은 이미지를 주 2회(일 18:00 · 월 02:00) 따로 스캔하고
있었을 뿐이다.
`check-rebuild-needed.py`가 유일하게 더하던 "수정 버전이 있는가(fixable/no-fix)" 분류조차
새 정보가 아니었다 — `cve-gate.py`의 리포트 테이블에 이미 "수정 버전" 컬럼이 있고
`FixedVersion`으로 채워진다. 이 스크립트는 같은 findings를 자체 빌드 8개 기준으로 다시
묶어 보여줄 뿐이었다.
### 탈락한 후보
- **(a) 현행 유지** — 트리거 + 별도 워크플로 그대로. 위 근거대로 순수 중복이라 기각.
- **(b) 트리거만 제거, 워크플로는 스캔·보고용으로 존치** — `sbom.yml`과 같은 이미지를
또 스캔하는 문제(주 2회 중복)가 남아 기각.
- **(c) `check-rebuild-needed.py``sbom.yml`의 리포트 스텝으로 흡수** — SBOM/리포트
파일 명명 규칙이 두 파이프라인에서 동일해(`tr '/:@' '___'` vs `re.sub(r"[/:@]","_",ref)`)
기술적으로는 가능했다. 하지만 `cve-gate.py` 리포트에 이미 "수정 버전" 컬럼이 있어
흡수해도 **새 정보가 생기지 않음**을 확인해 기각했다 — 같은 findings의 재그룹핑을
위해 스크립트와 워크플로 스텝을 유지할 이유가 없었다.
## 받아들인 비용
- 자체 빌드 8개만 좁혀 보는 전용 표가 사라진다 — `sbom.yml` 리포트에서
`docker.io/paasup/*`를 골라 봐야 한다.
- 자체 빌드 이미지 리포트 주기가 월요일 02:00 → 일요일 18:00(`sbom.yml` 스케줄)로
바뀌고, 그 워크플로가 실패하면 자체 빌드 리포트도 함께 보이지 않는다.
- dip-catalog가 옛 태그에 멈춰 있어도 스스로 재빌드를 재촉할 수단이 없다 — Job Summary
리포트를 사람이 보고 수동으로 판단해야 한다. 단, `catalog-tag-update.yml`이 매일
폴링해 보통 하루 안에 따라잡으므로 드문 시나리오다.
## 알려진 잠재 공백 (이번 결정이 만든 것이 아니라 기존부터 있던 것 — 기록만)
`extract-helm-images.sh``custom-values.yaml`만 적용해 렌더하므로 `dip-values.yaml`에만
있는 태그는 스캔되지 않는다. 현재 카탈로그에서는 두 파일의 paasup 태그가 일치해 실질
영향이 없음을 확인했다(cnpg-cluster 1.0.0/1.1.0). `manifests/applicationset/**` 미스캔
(MEMORY.md #42)과 같은 성격의 공백이다.
## 재검토 조건
- `hardened-containers``rescan.yml`이 장기간 중단되거나 신뢰할 수 없게 되면 — 그 경우
dip-catalog 쪽에 독립적인 드리프트 감시가 다시 필요해진다.
- `dip-values.yaml`에만 존재하는 자체 빌드 태그가 실제로 생기면 — 이 결정의 전제("두
values 파일의 paasup 태그가 일치한다")가 깨지므로 스캔 커버리지를 다시 검토해야 한다.
+2 -2
View File
@@ -9,14 +9,14 @@ CVE 건수·커버리지·패키지 버전 같은 수치는 게이트가 매 실
| # | 결정 | 날짜 | 상태 |
|---|---|---|---|
| [0003](0003-etcd-chart-selection.md) | etcd 는 `groundhog2k/etcd` 단일 차트 · replicas=1 로 배포한다 | 2026-07-31 | 확정 |
| [0005](0005-self-build-drift-check-removed.md) | 자체 빌드 드리프트 스캔·재빌드 트리거를 dip-catalog에서 제거한다(sbom.yml + hardened-containers의 rescan.yml로 대체) | 2026-08-26 | 확정 |
**이미지 자체 빌드 ADR(0001·0002·0004)은 별도 레포 `hardened-containers` 로 이관됐다** —
그 이미지들의 빌드 정의와 함께 그 레포의 `docs/decisions/` 에 있다. 카탈로그가 무엇을
배포하는가(차트)와 그 이미지를 어떻게 만드는가(자체 빌드)가 다른 레포에 놓이며 근거도
함께 옮긴 것이다. 이관 배경은
[doc/migrations/self-build-images-to-hardened-containers.md](../migrations/self-build-images-to-hardened-containers.md).
번호가 0003 하나만 남아 이어지지 않아 보이지만, 다른 세 번호가 예약돼 있던 것일 뿐이고
새 ADR 은 0005 부터 잇는다.
0004까지는 그 세 번호가 예약돼 있던 것이고, 새 ADR 은 0006 부터 잇는다.
## 이 문서들의 출처
@@ -27,7 +27,7 @@
| `scripts/build/suggest-go-upgrades.py` | `--apply`/`--dry-run` 신규 추가 |
| `scripts/pipeline/scan-sbom.sh` · `cve-gate.py` (이미지 판정 부분만) | `scripts/gate/scan-image.sh` · `image-gate.py` (슬림화) |
| `.github/workflows/build-image.yml` | 카탈로그 무의존으로 재구성(`catalog.env` 의존 제거, drift 모드 제거) |
| `.claude/image-authoring.md` | `docs/image-authoring.md` (정책 중심으로 재작성) |
| `.claude/image-authoring.md` | `docs/image-authoring.md` (정책 중심으로 재작성, 이후 `docs/image-authoring/`로 추가 분할됨) |
| `doc/decisions/0001·0002·0004`(이미지 ADR) | `docs/decisions/`(같은 번호 유지) |
| `.claude/skills/self-build-image/` | 그대로(문서 경로만 수정) |
@@ -38,12 +38,16 @@
| 경로 | 역할 |
|---|---|
| `catalog/image-map/<image>.env` | 자체 빌드 이미지 → 어느 차트의 어느 필드(`CHART_DIRS`·`TAG_STYLE`·`TAG_BLOCK`). 옛 `images/<image>/catalog.env` 의 카탈로그 레이아웃 정보만 뗀 것 |
| `scripts/build/check-rebuild-needed.py` | 드리프트 탐지(A 파트만) — 배포 중인 이미지가 새 CVE 로 규정을 벗어났는가. 핀 판단(B 파트)은 제거했다 — `hardened-containers``suggest-go-upgrades.py` 가 갖는다 |
| `scripts/build/apply-published-tags.py` | `hardened-containers``published.json` 을 카탈로그 values 에 반영 |
| `scripts/build/patch-catalog-tag.py` | 변경 없음(원래도 카탈로그 파일만 다루는 순수 도구였다) |
| `.github/workflows/self-build-drift-check.yml` | 주간, 배포 중인 이미지 스캔 → 재빌드 필요하면 `hardened-containers``build-image.yml``workflow_dispatch` 로 트리거만 |
| `.github/workflows/catalog-tag-update.yml` | 매일, `published.json` 조회 → 카탈로그 values 패치 → 브랜치 push |
> 드리프트 탐지 전용 스크립트(`scripts/build/check-rebuild-needed.py`)와 그것을 주간으로
> 돌리던 `.github/workflows/self-build-drift-check.yml`은 이후 제거됐다 — `sbom.yml`
> 자체 빌드 이미지도 다른 카탈로그 이미지와 동일하게 스캔·보고하고, 재빌드는
> `hardened-containers``rescan.yml`이 전적으로 자율 수행하므로 별도 경로가 불필요했다.
> 근거는 ADR [0005](../decisions/0005-self-build-drift-check-removed.md).
## 계약 — `published.json` 하나뿐
`hardened-containers`**이 카탈로그를 모른다.** 게이트 PASS + 레지스트리 push 가 실제로
@@ -81,13 +85,6 @@
## 아직 안 된 것
- **`SECURITY_IMAGES_DISPATCH_TOKEN` 시크릿 미등록.** `self-build-drift-check.yml`
`hardened-containers``build-image.yml``workflow_dispatch` 로 부르려면
그 레포에 `workflow_dispatch` 권한이 있는 PAT 이 필요하다. 등록 전까지 트리거 스텝은
명시적으로 실패한다.
- **`hardened-containers` 레포 자체가 아직 GitHub 에 없다.** 로컬 레포만 있는 상태에서
이 이관을 진행했다 — GitHub 레포 생성·push, `DOCKERHUB_USER`/`DOCKERHUB_TOKEN` 시크릿
등록이 선행돼야 위 두 워크플로가 실제로 동작한다.
- **`published.json``digest` 필드 대부분 공란.** `hardened-containers` 가 아직 실제로
이미지를 재빌드·push 하지 않아서다. 다음 빌드가 채운다 — MEMORY.md 의 "카탈로그 태그가
클러스터보다 앞서 있다" 항목이 이 필드를 쓸 계획이다.
+13 -7
View File
@@ -12,8 +12,8 @@ GitHub Actions([.github/workflows/sbom.yml](../.github/workflows/sbom.yml))로
|---|---|---|
| 질문 | 우리가 배포하는 이미지에 무엇이 있는가 | 그 이미지를 어떻게 만드는가 |
| 실행 위치 | 이 레포 | 별도 레포 `hardened-containers` |
| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `hardened-containers``build-image.yml`(이 레포에서는 `self-build-drift-check.yml` 필요할 때 그것을 부른다) |
| 단일 출처 | **이 문서** | `hardened-containers` 레포의 `docs/image-authoring.md` |
| 워크플로 | `sbom.yml` · `cve-edge-post.yml` | `hardened-containers``build-image.yml`·`rescan.yml`(레포는 트리거하지 않는다) |
| 단일 출처 | **이 문서** | `hardened-containers` 레포의 `docs/image-authoring/README.md` |
**카탈로그 values 가 자체 빌드 이미지(`docker.io/paasup/*`)를 가리키므로 그 이미지도 이 문서의
스캔 대상이다** — 축이 갈린 것은 "누가 만들고 결정하는가" 이고 "누가 스캔되는가" 가 아니다.
@@ -137,6 +137,12 @@ docker buildx build --platform linux/amd64 \
> python 으로 갖고 있어 **승인 예외(`cve-exceptions.json`)도 실효 등급(`max(벤더,NVD)`)도
> 적용하지 않는다.** 같은 스캔 데이터에서 다른 숫자가 나올 수 있다.
두 워크플로 모두 날짜 단위 키(`trivy-db-<YYYYMMDD>`)로 trivy 취약점 DB를 `actions/cache`
캐싱한다 — 실패해도 워크플로를 막지 않는다(`continue-on-error`, 캐시는 최적화일 뿐 정확성
요건이 아니다). `concurrency` 그룹도 있다 — `sbom.yml``sbom-${{ github.ref }}`(PR은
새 커밋이 오면 이전 실행 취소, 스케줄은 취소 안 함), `cve-edge-post.yml``cve-edge-post`
(항상 직렬화, 외부 POST 잡이라 취소 대신 대기).
**PR 을 실제로 막는 게이트는 이 축에 없다** — `sbom.yml` 은 warn-only 다. 강제 게이트는
자체 빌드 축(`hardened-containers` 레포의 `images/**` PR)에만 있다.
@@ -208,11 +214,11 @@ Repo Secret `CVE_API_KEY`).
## 자체 빌드 축은 이 문서가 다루지 않는다
게이트가 상위 태그 교체·베이스 OS 교체로 해소되지 않는 차단 CVE 를 찾으면 자체 빌드로 간다.
그 축은 **별도 레포 `hardened-containers`** 가 갖는다 — 빌드·검증·게이트·push 전부 그 레포
안에서 이루어지고, 단일 출처는 그 레포의 `docs/image-authoring.md` 다. 이 카탈로그에는
"어느 차트가 그 이미지를 가리키는가"(`catalog/image-map/`)와 드리프트 탐지
(`scripts/build/check-rebuild-needed.py`, 주간 `self-build-drift-check.yml`)만 남아 있다 —
이관 배경은 [doc/migrations/](migrations/self-build-images-to-hardened-containers.md).
그 축은 **별도 레포 `hardened-containers`** 가 갖는다 — 빌드·검증·게이트·push·드리프트 재빌드
전부 그 레포 안에서 이루어지고(`rescan.yml`이 매일 자율 재스캔·재빌드), 단일 출처는 그
레포의 `docs/image-authoring/README.md` 다. 이 카탈로그에는 "어느 차트가 그 이미지를
가리키는가"(`catalog/image-map/`)만 남아 있다 — 이관 배경은
[doc/migrations/](migrations/self-build-images-to-hardened-containers.md).
이 문서가 알아야 할 것은 하나뿐이다: **자체 빌드 이미지도 카탈로그가 가리키는 한 위 스캔·게이트
대상이다.** 실제로 `docker.io/paasup/*` 가 게이트 리포트에 등장한다.
@@ -10,7 +10,7 @@ postgresql:
# (이 레포에는 없다).
#
# 태그에 빌드일을 포함한다. 같은 앱 버전이라도 베이스 업데이트 결과가 시점마다 다르므로
# 롤링 태그를 쓰지 않는다(hardened-containers 레포의 docs/image-authoring.md).
# 롤링 태그를 쓰지 않는다(hardened-containers 레포의 docs/image-authoring/README.md).
imageName: "docker.io/paasup/cnpg-postgresql:18.4-bci15.7-hardened-20260820"
#
# trivy 는 SLES 15.7 을 정상 커버한다 — 실효 C/H 0/0 은 측정된 결과이며 게이트 PASS 다.
-317
View File
@@ -1,317 +0,0 @@
#!/usr/bin/env python3
"""check-rebuild-needed.py — 배포 중인 자체 빌드 이미지가 재빌드로 고쳐지는지 판정한다.
필요한가
-----------
자체 빌드 이미지는 **소스를 바꿔도 시간이 지나면 게이트를 벗어난다.** CVE 공개되고,
베이스 OS 패키지가 갱신되고, 툴체인 패치가 나오는 동안 우리가 참조해 태그는 그대로다.
의도적으로 보는 장치가 없으면 문서 전용 PR 우연히 검증 빌드를 돌릴 때만 발견된다.
`self-build-drift-check.yml` 주간으로 이것을 돌린다.
무엇이 "재빌드로 고쳐지는가" 인가
--------------------------------
"핀이 뒤처진 경우" 만으로는 부족하다. 재빌드로 고쳐지는 경우는 셋이다.
1. 핀이 뒤처졌다 핀을 올리고 빌드
2. 핀은 맞는데 이미지가 핀으로 빌드됐다 그냥 빌드
3. 베이스 OS 패키지가 뒤처졌다 그냥 빌드 (재빌드하면 zypper 최신을 깐다)
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** . 스크립트는 판정만
한다 핀을 무엇으로 올려야 하는지는 판단하지 않는다(아래 "레포 분리" 참고).
수정 버전이 없는 차단(`affected`·`will_not_fix`) 재빌드해도 그대로이므로 트리거가 아니다
`no-fix` 따로 보고해 사람이 다른 레버(태그 교체·예외 승인) 판단하게 한다.
판정 규칙은 게이트와 같은 출처를 쓴다
-----------------------------------
실효 등급 `max(벤더, NVD)` · 승인 예외(`doc/cve-exceptions.json`) 적용 전부
`scripts/pipeline/cve-gate.py` 함수를 그대로 가져다 쓴다. 규칙이 곳으로 갈리면
"게이트는 PASS 인데 재빌드하라고 한다" 나온다.
기준은 카탈로그가 실제로 가리키는 이미지다
----------------------------------------
catalog/image-map/<image>.env 카탈로그 values 현재 ref
ref 스캔 리포트
재빌드해 보지 않고도 "지금 배포 중인 것이 규정을 벗어났는가" 답한다.
레포 분리 이후 스크립트는 절반이다
----------------------------------------
커스텀 이미지는 hardened-containers(별도 레포) 나갔다. "무엇을 배포 중인가" 카탈로그가
알고, "그 이미지를 어떻게 만드는가"( 판단·재빌드) hardened-containers 안다. 스크립트는
**탐지만** 한다 재빌드가 필요하다고 판단되면 `self-build-drift-check.yml`
hardened-containers `build-image.yml` `workflow_dispatch` 부른다. 핀을 무엇으로
올릴지는 레포의 `suggest-go-upgrades.py --apply` 한다.
사용
----
python3 scripts/build/check-rebuild-needed.py --list-refs # 무엇을 스캔해야 하나
python3 scripts/build/check-rebuild-needed.py --reports <디렉토리>
종료 코드
0 정상 (재빌드 대상이 있어도 0 --fail-on-drift 주면 1)
1 재빌드 대상 있음 + --fail-on-drift
2 실행 오류
"""
import argparse
import importlib.util
import json
import os
import pathlib
import re
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, "..", ".."))
IMAGE_MAP_DIR = os.path.join(ROOT, "catalog", "image-map")
DEFAULT_EXCEPTIONS = os.path.join(ROOT, "doc", "cve-exceptions.json")
# 카탈로그 values 는 이 순서로 찾는다. 자체 빌드 태그는 custom-values.yaml 이 기준이고
# dip-values.yaml 은 같은 값을 쓰는 배포 파라미터화용이다.
VALUES_CANDIDATES = ("custom-values.yaml", "dip-values.yaml")
def load(path, name):
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
PATCH = load(os.path.join(HERE, "patch-catalog-tag.py"), "patch_catalog_tag")
GATE = load(os.path.join(HERE, "..", "pipeline", "cve-gate.py"), "cve_gate")
def read_env(path):
"""`KEY=value` 셸 할당만 읽는다. 값의 따옴표는 벗긴다."""
out = {}
if not os.path.isfile(path):
return out
for line in pathlib.Path(path).read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
if not re.match(r"^[A-Z_][A-Z0-9_]*$", k):
continue
out[k] = v.strip().strip('"').strip("'")
return out
def report_path(reports_dir, image_ref):
"""이미지 ref → 리포트 파일 경로. `trivy image <ref>` 스캔 결과를
`tr '/:@' '___'` 만든 파일명으로 저장했다고 가정한다."""
return os.path.join(reports_dir, re.sub(r"[/:@]", "_", image_ref) + ".json")
def resolve_current_ref(chart_env):
"""카탈로그 values 에서 현재 이미지 ref 를 읽는다. (ref, 읽은 파일) 또는 (None, 이유)."""
chart_dirs = (chart_env.get("CHART_DIRS") or "").split()
style = chart_env.get("TAG_STYLE") or ""
block = chart_env.get("TAG_BLOCK") or "image"
if not chart_dirs or style not in ("imageName", "split"):
return None, "catalog/image-map/<image>.env 에 CHART_DIRS/TAG_STYLE 이 없다"
for d in chart_dirs:
for fname in VALUES_CANDIDATES:
p = os.path.join(ROOT, d, fname)
if not os.path.isfile(p):
continue
text = pathlib.Path(p).read_text()
ref = (PATCH.read_image_name(text) if style == "imageName"
else PATCH.read_split_block(text, block))
if ref:
return ref, os.path.relpath(p, ROOT)
return None, f"{chart_dirs} 에서 태그를 읽지 못했다 (style={style}, block={block})"
def blocking_cves(report, image_ref, rank, floor, exceptions):
"""실효 등급 floor 이상 + 승인 예외 제외한 고유 CVE.
반환: {cve: {"pkg", "sev", "fixable"}}
"""
out = {}
for res in report.get("Results") or []:
for v in res.get("Vulnerabilities") or []:
cve = v.get("VulnerabilityID")
if not cve:
continue
eff = GATE.effective_severity(
v.get("Severity"),
GATE.nvd_severity(((v.get("CVSS") or {}).get("nvd") or {}).get("V3Score")),
)
if rank.get(eff, 0) < floor:
continue
if any(GATE.exception_applies(e, cve, image_ref) for e in exceptions):
continue
prev = out.get(cve)
fixable = bool(v.get("FixedVersion"))
if prev is None:
out[cve] = {"pkg": v.get("PkgName"), "sev": eff, "fixable": fixable}
elif fixable:
prev["fixable"] = True
return out
def check_image(image, reports_dir, rank, floor, exceptions):
res = {"image": image, "status": "ok", "notes": [], "blocking": 0, "fixable": 0}
chart_env = read_env(os.path.join(IMAGE_MAP_DIR, f"{image}.env"))
ref, where = resolve_current_ref(chart_env)
if not ref:
res["status"] = "skip"
res["notes"].append(where)
return res
res["ref"] = ref
res["ref_from"] = where
rp = report_path(reports_dir, ref)
if not os.path.isfile(rp):
res["status"] = "no-report"
res["notes"].append("이 ref 의 스캔 리포트가 없다 — 스캔 대상에 없었거나 태그가 갱신됐다")
return res
with open(rp) as f:
report = json.load(f)
cves = blocking_cves(report, ref, rank, floor, exceptions)
fixable = {c: d for c, d in cves.items() if d["fixable"]}
res["blocking"] = len(cves)
res["fixable"] = len(fixable)
res["packages"] = sorted({d["pkg"] for d in cves.values() if d["pkg"]})
if not cves:
return res # ok
if fixable:
res["status"] = "rebuild"
else:
res["status"] = "no-fix"
res["notes"].append("차단 CVE 에 수정 버전이 없다 — 재빌드로 해소되지 않는다. "
"상위 태그 교체나 예외 승인을 검토한다")
return res
def render_md(results):
rebuild = [r for r in results if r["status"] == "rebuild"]
nofix = [r for r in results if r["status"] == "no-fix"]
ok = [r for r in results if r["status"] == "ok"]
out = ["## 🔁 자체 빌드 이미지 재빌드 판정", ""]
if not rebuild and not nofix:
out.append(f"배포 중인 자체 빌드 이미지 {len(ok)}개에 차단 CVE 가 없다.")
if rebuild:
out += [f"**{len(rebuild)}개 이미지가 재빌드 대상이다.** 차단 CVE 에 수정 버전이 있다 "
"— hardened-containers 레포에서 재빌드하면 해소될 가능성이 높다. 핀 조정이 "
"필요한지는 그 레포의 `suggest-go-upgrades.py` 가 판단한다.", "",
"| 이미지 | 현재 ref | 차단 | 수정 가능 | 패키지 |",
"|---|---|---:|---:|---|"]
for r in rebuild:
pkgs = ", ".join(f"`{p}`" for p in r["packages"][:4])
out.append(f"| `{r['image']}` | `{r['ref']}` | {r['blocking']} | {r['fixable']} | {pkgs} |")
if nofix:
out += ["", f"**{len(nofix)}개 이미지는 재빌드로 해소되지 않는다.** 다른 레버가 필요하다.",
"", "| 이미지 | 차단 | 패키지 |", "|---|---:|---|"]
for r in nofix:
out.append(f"| `{r['image']}` | {r['blocking']} | "
f"{', '.join(f'`{p}`' for p in r['packages'][:4])} |")
notes = [(r["image"], n) for r in results for n in r["notes"]
if r["status"] in ("rebuild", "no-fix")]
if notes:
out += ["", "**수동 확인 필요**", ""]
out += [f"- `{img}` — {n}" for img, n in notes]
skipped = [r for r in results if r["status"] in ("skip", "no-report")]
if skipped:
out += ["", f"<details><summary>판정하지 않은 이미지 {len(skipped)}개</summary>", ""]
out += [f"- `{r['image']}` — {'; '.join(r['notes'])}" for r in skipped]
out += ["", "</details>"]
out += ["", "> 판정 기준은 게이트와 같다 — 실효 등급 `max(벤더, NVD)` · 승인 예외 적용 · "
"기본 HIGH 이상. 재빌드는 hardened-containers 레포의 `build-image.yml` 을 "
"`workflow_dispatch` 로 부른다.", ""]
return "\n".join(out)
def main():
ap = argparse.ArgumentParser(description="배포 중인 자체 빌드 이미지의 재빌드 필요 여부 판정")
ap.add_argument("--reports", help="trivy 리포트(JSON) 디렉토리 (--list-refs 면 불필요)")
ap.add_argument("--image", default="", help="이 이미지만 판정 (catalog/image-map/<image>.env)")
ap.add_argument("--exceptions", default=DEFAULT_EXCEPTIONS,
help="승인 예외 목록 (기본 doc/cve-exceptions.json)")
ap.add_argument("--min-severity", default="HIGH",
choices=["CRITICAL", "HIGH", "MEDIUM", "LOW"],
help="이 등급 이상만 차단으로 본다 (기본 HIGH — 게이트와 동일)")
ap.add_argument("--list-refs", action="store_true",
help="`이미지<TAB>ref<TAB>리포트파일명` 만 출력하고 종료. CI 가 무엇을 "
"스캔해야 하는지 알아야 할 때 쓴다 — ref 해석 규칙이 워크플로로 "
"새지 않게 한다")
ap.add_argument("--summary-md", metavar="FILE", help="마크다운 요약을 이 파일에 쓴다")
ap.add_argument("--json-out", metavar="FILE", help="결과 JSON 을 이 파일에 쓴다")
ap.add_argument("--fail-on-drift", action="store_true",
help="재빌드 대상이 있으면 1 로 종료 (기본은 보고만 하고 0)")
args = ap.parse_args()
if not os.path.isdir(IMAGE_MAP_DIR):
print(f"::error::catalog/image-map/ 를 찾지 못했다: {IMAGE_MAP_DIR}", file=sys.stderr)
return 2
if not args.list_refs and not args.reports:
print("::error::--reports 가 필요하다", file=sys.stderr)
return 2
if args.reports and not os.path.isdir(args.reports):
print(f"::error::리포트 디렉토리가 없다: {args.reports}", file=sys.stderr)
return 2
names = sorted(f[:-4] for f in os.listdir(IMAGE_MAP_DIR) if f.endswith(".env"))
if args.image:
names = [n for n in names if n == args.image]
if not names:
print(f"::error::catalog/image-map/{args.image}.env 가 없다", file=sys.stderr)
return 2
if args.list_refs:
for n in names:
ref, why = resolve_current_ref(read_env(os.path.join(IMAGE_MAP_DIR, f"{n}.env")))
if not ref:
print(f"::warning::{n}: ref 를 읽지 못했다 — {why}", file=sys.stderr)
continue
print(f"{n}\t{ref}\t{os.path.basename(report_path('', ref))}")
return 0
rank = GATE.RANK
floor = rank[args.min_severity]
# load_exceptions 는 (유효, 만료) 를 낸다. 만료된 예외는 게이트가 인정하지 않으므로
# 여기서도 인정하지 않는다 — 재검토를 강제하는 것이 그 설계의 의도다.
exceptions, expired = GATE.load_exceptions(args.exceptions)
if expired:
print(f"::warning::만료된 예외 {len(expired)}건은 인정하지 않는다 — "
f"{', '.join(e.get('id', '?') for e in expired)}")
results = [check_image(n, args.reports, rank, floor, exceptions) for n in names]
for r in results:
if r["status"] == "rebuild":
print(f"::warning::{r['image']}: 재빌드 대상 — 차단 {r['blocking']}"
f"(수정 가능 {r['fixable']})")
elif r["status"] == "no-fix":
print(f"::warning::{r['image']}: 차단 {r['blocking']}건이나 수정 버전 없음 — "
"재빌드로 해소되지 않는다")
for n in r["notes"]:
if r["status"] in ("rebuild", "no-fix"):
print(f"::warning::{r['image']}: {n}")
md = render_md(results)
print()
print(md)
if args.summary_md:
pathlib.Path(args.summary_md).write_text(md + "\n")
if args.json_out:
pathlib.Path(args.json_out).write_text(
json.dumps(results, ensure_ascii=False, indent=2) + "\n")
return 1 if ([r for r in results if r["status"] == "rebuild"] and args.fail_on_drift) else 0
if __name__ == "__main__":
sys.exit(main())
+2 -2
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
"""카탈로그 values 파일의 자체 빌드 이미지 태그를 갱신한다.
check-rebuild-needed.py / apply-published-tags.py `catalog-tag-update.yml` 경유로
호출한다. 카탈로그가 실제로 쓰는 표기 스타일을 지원한다:
apply-published-tags.py `catalog-tag-update.yml` 경유로 호출한다. 카탈로그가 실제로
쓰는 표기 스타일을 지원한다:
- imageName: 단일 필드 문자열 (cnpg-cluster 유형 `imageName: "repo:tag"`)
- split: image:/initImage: 블록 아래 registry/repository/tag 필드로 분리
(cloudnative-pg, etcd 유형)
+1 -2
View File
@@ -85,8 +85,7 @@ def nvd_severity(score):
def effective_severity(vendor_sev, nvd_sev):
"""실효 등급 = max(벤더 등급, NVD 등급). 벤더의 하향 등급으로 게이트를
통과하는 것을 막는다. 게이트가 심각도 규칙의 단일 출처다
`check-rebuild-needed.py`(드리프트 탐지) 자체 빌드 레포의 `image-gate.py`
함수를 그대로 재사용한다."""
자체 빌드 레포의 `image-gate.py` 함수를 그대로 재사용한다."""
vend = vendor_sev or "UNKNOWN"
nvd = nvd_sev or "UNKNOWN"
return vend if RANK.get(vend, 0) >= RANK.get(nvd, 0) else nvd