차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙 (#40)
* 차단 CVE 가 나오면 자체 빌드 이미지를 스스로 재빌드한다 + Dockerfile 작성 규칙
두 가지다. (1) 이전 이미지에서 CVE 가 발견되면 재빌드가 자동으로 일어나게 한다.
(2) Dockerfile 을 OS·언어 축으로 일관되게 쓰도록 규칙을 정한다.
(1) 재빌드 자동화 — 트리거를 로컬 실측으로 고쳤다
------------------------------------------------
처음에는 트리거를 "핀이 뒤처졌는가" 로 잡았다. **배포 중인 이미지 8개를 실제로 스캔해 보니
그게 틀렸다** — 차단 CVE 가 있는 4개 중 핀 변경이 필요한 것은 하나도 없었고, 그 판정은
"0건" 을 냈다. 재빌드로 고쳐지는 경우가 셋이기 때문이다.
핀이 뒤처졌다 → 핀 올려서 빌드
핀은 맞는데 그 핀으로 아직 안 빌드됐다 → 그냥 빌드 ← 핀만 고친 PR 직후 상태
베이스 OS 패키지가 뒤처졌다 → 그냥 빌드 ← 재빌드하면 zypper 가 최신을 깐다
그래서 트리거는 **"수정 버전이 있는 차단 CVE 가 있는가"** 다. 핀 변경은 필요하면 함께 하는
부수 작업이고 트리거가 아니다. 수정 버전 없는 차단(affected·will_not_fix)은 재빌드해도
그대로이므로 no-fix 로 따로 보고해 사람이 다른 레버를 판단하게 한다.
scripts/build/check-rebuild-needed.py 신규
카탈로그가 실제로 가리키는 이미지를 기준으로 잰다 — 재빌드해 보지 않고도 "지금 배포 중인
것이 규정을 벗어났는가" 를 답한다.
catalog.env → 카탈로그 values 의 ref → 그 ref 의 스캔 리포트 ↕ build.env 의 핀
판정 규칙은 게이트와 같은 출처를 쓴다(max(벤더,NVD)·승인 예외·만료 예외 무효) —
cve-gate.py 의 함수를 그대로 가져다 쓴다. 핀 산출은 suggest-go-upgrades.py 재사용.
--list-refs 로 "무엇을 스캔해야 하나" 도 스크립트가 낸다 — ref 해석 규칙이 워크플로로
새면 두 곳이 어긋난다.
suggest-go-upgrades.py 는 산출 로직이 main() 안에 있어 재사용할 수 없었다. collect_modules/
collect_stdlib/builder_candidates/effective_severity 로 빼고 main() 은 호출해 출력만 한다 —
CLI 출력은 리팩터 전후 동일하다(실측 대조).
build-image.yml 에 schedule(월요일 02:00 UTC)과 mode=drift 를 추가했다. 판정 → (핀이
뒤처졌으면) 브랜치 push → 빌드·verify.sh·게이트까지 자동이고 **레지스트리 push 와 카탈로그
반영은 하지 않는다.** 게시 여부 로직에서 catch-all 을 true → false 로 바꿨다 — 원래는
트리거를 추가하면 자동으로 push 가 켜지는 구조였고 schedule 을 넣는 순간 사고가 났다.
이제 push 는 workflow_dispatch + mode=image 에서만 켜진다.
(2) Dockerfile 작성 규칙 — 실제 8개에서 추출했다
----------------------------------------------
image-authoring.md 가 규칙 본문의 단일 출처이고 skill 은 "어느 절을 읽어야 하는가" 선택표만
갖는다(CLAUDE.md 의 "Skill 은 절차 본문을 복제하지 않는다").
두 축은 독립이다 — 같은 bci-micro 최종 위에 Go 빌더도 Node 빌더도 온다.
축 1 최종 베이스 bci-base / bci-micro / scratch+micro rootfs — 셋뿐이다.
네 번째를 만들기 전에 왜 셋으로 안 되는지 먼저 적는다.
micro·scratch 는 nonroot 계정을 직접 만들어야 한다(실측 형태 첨부).
축 2 빌더 Go(BUILDPLATFORM/TARGETARCH, ldflags 에 SOURCE_COMMIT) /
Node(런타임은 OS 패키지) / JVM(재컴파일 없이 jar 만 OLD/NEW 쌍으로
교체 — OLD 를 받는 이유는 못 찾으면 실패시키기 위함) /
C·Lua(정적 링크 금지 — SLE_BCI 에 static glibc 없음)
"업스트림 런타임 계약은 보존한다" 절을 새로 넣었다(USER·ENTRYPOINT·파일 레이아웃).
cloudnative-pg 가 멀티아치 심볼릭 링크를 부수 장치로 오판해 지웠다가 리컨실이 실패한 것이
근거다. 스캐너는 이걸 전혀 보지 못한다.
로컬 검증 — 판정만이 아니라 빌드까지 돌렸다
-----------------------------------------
판정이 지목한 4건을 실제로 빌드해 전제를 확인했다. 문서에 단정만 해두고 넘어가지 않았다.
이미지 배포 중 재빌드 후 핀 변경
apisix-ingress-controller 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
cloudnative-pg 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
etcd 차단 8 0/0 PASS · VERIFY-OK · cov=ok 없음
cnpg-postgresql 차단 2 0/0 PASS · VERIFY-OK · cov=ok 없음
- cnpg-postgresql 이 "베이스 OS 패키지 갱신으로 해소" 사례다(perl·rpm-ndb). 핀을 하나도
안 바꾸고 재빌드만으로 0건이 됐다 — 검증 없이 단정했던 부분이라 이게 핵심 확인이다.
- 빌드된 etcd 바이너리가 go1.26.6 + pinned commit 을 보고한다 — 핀이 실제로 반영됐다.
- 4건 다 CoverageProbe=ok 이므로 0건이 "측정되지 않음" 이 아니다.
- 빌드 후에도 git status images/ 가 깨끗하다 = 트리거를 핀 기준으로 뒀다면 4건 전부
놓쳤을 것이라는 확인.
그 외: 예외 적용(keycloak CVE-2025-59250 → 0건), --fail-on-drift rc=1, 워크플로 셸 재현
(GITHUB_OUTPUT 4개 JSON·fromJson OK·핀 변경 0 → 브랜치 미생성 → 기본 ref 로 빌드),
push 정책 전 조합 확인.
CI 에서 미검증인 것은 환경 특성뿐이다 — trivy 설치 스텝, GITHUB_STEP_SUMMARY 출력,
matrix 팬아웃, contents:write 로 브랜치 push. 빌드 자체는 로컬과 같은 스크립트를 쓴다.
Refs #35
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* self-build-image SKILL 의 자동 재빌드 절을 실행 계약만 남긴다
같은 커밋에서 image-authoring.md 와 SKILL 양쪽에 자동 재빌드 절차를 거의 동일하게 썼다.
SKILL 이 "작성 규칙 본문은 image-authoring.md 가 단일 출처다 — 여기 복제하지 않는다" 고
두 번 선언한 파일에서 내가 그 규칙을 어겼다.
SKILL 에는 실행 계약(수동 실행 명령 · 로컬 판정 명령 · push 안 한다는 사실)만 남기고
트리거 조건과 그 근거는 image-authoring.md 를 가리킨다.
frontmatter 도 함께 고친다 — "7종" 에 argocd 가 빠져 있어 실제 images/ 8개와 어긋났다.
description 은 Skill 로딩 판단에 쓰이는 텍스트이므로 "목록은 images/ 가 단일 출처" 라는
회피 조항으로 덮을 수 없다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 레포 분리 시 갈라지는 이음선을 코드와 문서에 명시한다
커스텀 이미지가 별도 레포로 분리될 예정인데, check-rebuild-needed.py 는 카탈로그(무엇을
배포 중인가)와 build.env(어떻게 만드는가)를 **양쪽 다** 읽는다. 분리 시 이 파일이 갈라진다.
절단면은 이미 함수 경계에 있다. 6개월 뒤에 "이 함수가 왜 카탈로그를 읽지" 를 다시 파지
않도록 어느 함수가 어느 쪽인지 docstring 에 적었다.
A (카탈로그 레포) resolve_current_ref + blocking_cves → "차단됐는가"
B (이미지 레포) pin_changes + apply_changes → "핀을 올려야 하는가"
check_image 둘을 엮는 오케스트레이션 — 여기가 갈라진다
image-authoring.md 의 드리프트 절에도 같은 사실을 한 문단으로 적어 분리 작업 때 이 주석을
먼저 읽게 했다. 결합점 전체 목록은 경계 문서화 작업에서 표로 정리한다.
코드 동작은 바뀌지 않았다 — 주석과 문서만 추가했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ name: self-build-image
|
||||
# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다.
|
||||
# 빌드는 호스트 러너여야 한다.
|
||||
#
|
||||
# 트리거 2종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다.
|
||||
# 트리거 3종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다.
|
||||
# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 브랜치 push 여부뿐이다.
|
||||
#
|
||||
# pull_request(images/**) 변경된 images/<image>/ 디렉토리를 diff 로 자동 탐지해 그
|
||||
@@ -27,8 +27,16 @@ name: self-build-image
|
||||
# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다.
|
||||
# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 태그
|
||||
# 갱신 브랜치 push 는 이 트리거로만 일어난다(사람이 수동 실행) —
|
||||
# sbom.yml 의 게이트는 현재 이 워크플로를 자동으로 호출하지 않는다.
|
||||
# push 입력이 false 면 검증만 한다.
|
||||
# schedule 재빌드 트리거. 배포 중인 이미지를 직접 스캔해
|
||||
# (scripts/build/check-rebuild-needed.py) **수정 버전이 있는
|
||||
# 차단 CVE** 가 있는 이미지를 찾아 빌드·검증·게이트만 돈다.
|
||||
# 핀이 뒤처졌으면 핀을 올린 브랜치를 push 하고 그 브랜치로
|
||||
# 빌드한다. 핀이 이미 맞아도 재빌드한다 — 그 핀으로 아직 안
|
||||
# 빌드됐거나 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다
|
||||
# (실측: 차단 있는 4개 중 핀 변경 필요는 0개였다).
|
||||
# 레지스트리 push 와 카탈로그 반영은 하지 않는다 — 반영은 사람이
|
||||
# workflow_dispatch 로 한다.
|
||||
#
|
||||
# 카탈로그 PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수
|
||||
# 없다는 조직 정책("Allow GitHub Actions to create and approve pull requests" 미허용,
|
||||
@@ -40,20 +48,31 @@ name: self-build-image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'image=지정한 이미지 하나 | drift=재빌드가 필요한 이미지를 찾아 전부'
|
||||
required: false
|
||||
default: 'image'
|
||||
type: choice
|
||||
options: [image, drift]
|
||||
image:
|
||||
description: '빌드할 이미지 디렉토리명 (images/<image>/)'
|
||||
required: true
|
||||
description: '빌드할 이미지 디렉토리명 (images/<image>/). mode=drift 면 비워둔다'
|
||||
required: false
|
||||
default: ''
|
||||
base_os:
|
||||
description: '빌드 변종 (images/<image>/<base_os>.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용'
|
||||
required: false
|
||||
default: ''
|
||||
push:
|
||||
description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 push 여부'
|
||||
description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 push 여부 (mode=drift·schedule 은 무조건 false)'
|
||||
required: false
|
||||
default: 'true'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'images/**'
|
||||
schedule:
|
||||
# sbom.yml 주간 전체 스캔(일요일 18:00 UTC) 다음날. 이 잡은 그 산출물에 의존하지 않고
|
||||
# 배포 중인 이미지를 직접 스캔한다 — 워크플로 간 아티팩트 결합을 만들지 않기 위함이다.
|
||||
- cron: '0 2 * * 1'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -69,18 +88,109 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
images: ${{ steps.list.outputs.images }}
|
||||
ref: ${{ steps.list.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 드리프트 경로에서만 필요하다. trivy 로 **배포 중인 이미지**를 직접 스캔한다
|
||||
# (docker 불필요 — trivy 가 자체 레지스트리 클라이언트로 pull 한다).
|
||||
- name: trivy 설치 (재빌드 판정용)
|
||||
if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift'
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
|
||||
| sh -s -- -b /usr/local/bin
|
||||
trivy --version | head -1
|
||||
|
||||
# 무엇을 스캔해야 하는지는 스크립트가 안다(catalog.env → 카탈로그 values → ref).
|
||||
# ref 해석 규칙이 워크플로로 새면 두 곳이 어긋난다.
|
||||
- name: 재빌드 판정 — 배포 중인 이미지 스캔
|
||||
id: drift
|
||||
if: github.event_name == 'schedule' || github.event.inputs.mode == 'drift'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
mkdir -p /tmp/rebuild-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/rebuild-reports/$fname" \
|
||||
|| { echo "::warning::$img: 스캔 실패 ($ref) — 이 이미지는 판정에서 빠진다"
|
||||
rm -f "/tmp/rebuild-reports/$fname"; }
|
||||
done < /tmp/refs.tsv
|
||||
|
||||
# --apply 는 핀이 뒤처진 경우에만 build.env 를 고친다. 핀이 이미 맞으면 아무것도
|
||||
# 건드리지 않고, 그래도 재빌드 대상이다(베이스 패키지가 갱신되므로).
|
||||
python3 scripts/build/check-rebuild-needed.py \
|
||||
--reports /tmp/rebuild-reports \
|
||||
--summary-md /tmp/rebuild.md \
|
||||
--json-out /tmp/rebuild.json \
|
||||
--apply
|
||||
cat /tmp/rebuild.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
python3 - <<'PY' >> "$GITHUB_OUTPUT"
|
||||
import json
|
||||
rows = json.load(open("/tmp/rebuild.json"))
|
||||
# 트리거는 "수정 버전이 있는 차단 CVE 가 있는가" 다. 핀 변경 여부가 아니다 —
|
||||
# 핀이 이미 맞아도(핀만 고친 PR 이 머지된 뒤) 이미지가 그 핀으로 안 빌드됐거나
|
||||
# 베이스 OS 패키지가 뒤처졌으면 재빌드가 해소한다. 실측으로 확인한 사실이다.
|
||||
print("images=" + json.dumps([r["image"] for r in rows if r["status"] == "rebuild"]))
|
||||
PY
|
||||
|
||||
# 핀이 바뀐 경우에만 브랜치가 필요하다. 핀 변경이 없으면 기본 ref 로 그냥 빌드한다.
|
||||
- name: 핀이 바뀌었으면 브랜치 push
|
||||
id: branch
|
||||
if: steps.drift.outputs.images != '' && steps.drift.outputs.images != '[]'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if git diff --quiet -- images/; then
|
||||
echo "핀 변경 없음 — 브랜치를 만들지 않고 기본 ref 로 빌드한다"
|
||||
echo "핀은 이미 맞다 — 재빌드만으로 해소된다(베이스 패키지·미반영 핀)." \
|
||||
>> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
BRANCH="chore/pin-drift-$(date -u +%Y%m%d%H%M%S)"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add images/
|
||||
git diff --cached --stat
|
||||
git commit \
|
||||
-m "자체 빌드 이미지 버전 핀 갱신 (자동 감지)" \
|
||||
-m "배포 중인 이미지를 스캔해 핀이 뒤처진 것을 찾아 올렸다. 소스는 바꾸지 않았다 —" \
|
||||
-m "CVE 데이터가 갱신되면서 같은 핀이 차단 대상이 된 것이다. 산출은" \
|
||||
-m "scripts/build/check-rebuild-needed.py 가 했고 근거는 이 실행의 Job Summary 에 있다." \
|
||||
-m "이 브랜치로 빌드·검증·게이트까지 자동으로 돈다. 레지스트리 push 와 카탈로그" \
|
||||
-m "반영은 하지 않았다 — 사람이 확인 후 workflow_dispatch 로 한다." \
|
||||
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push -u origin "$BRANCH"
|
||||
echo "ref=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo
|
||||
echo "핀을 올린 브랜치를 push 했다: \`$BRANCH\`"
|
||||
echo
|
||||
echo "아래 빌드가 **이 브랜치로** 돌아 핀이 실제로 해소하는지 확인한다."
|
||||
echo "게이트가 PASS 면 사람이 PR 을 열고, 반영은 \`workflow_dispatch\` 로 한다."
|
||||
echo
|
||||
echo "https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: 대상 이미지 목록 산출
|
||||
id: list
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "ref=${{ steps.branch.outputs.ref }}" >> "$GITHUB_OUTPUT"
|
||||
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event.inputs.mode }}" = "drift" ]; then
|
||||
# 판정 결과가 곧 대상이다. 핀이 안 바뀌어 브랜치가 없어도 빌드한다
|
||||
# (기본 ref 로 재빌드하면 베이스 패키지·미반영 핀이 해소된다).
|
||||
json='${{ steps.drift.outputs.images }}'
|
||||
[ -n "$json" ] || json="[]"
|
||||
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
img="${{ github.event.inputs.image }}"
|
||||
[ -n "$img" ] || { echo "::error::image 입력이 비어있다"; exit 1; }
|
||||
[ -n "$img" ] || { echo "::error::image 입력이 비어있다 (mode=drift 가 아니면 필수)"; exit 1; }
|
||||
[ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; }
|
||||
json="[\"$img\"]"
|
||||
else
|
||||
@@ -99,7 +209,7 @@ jobs:
|
||||
|
||||
build:
|
||||
needs: discover
|
||||
if: needs.discover.outputs.images != '[]'
|
||||
if: needs.discover.outputs.images != '[]' && needs.discover.outputs.images != ''
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -109,7 +219,10 @@ jobs:
|
||||
env:
|
||||
IMAGE_DIR: images/${{ matrix.image }}
|
||||
steps:
|
||||
# 드리프트 경로면 discover 가 핀을 올려 push 한 브랜치를 받는다(비어 있으면 기본 ref).
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.discover.outputs.ref }}
|
||||
|
||||
- name: Preflight — 도구 확인
|
||||
run: |
|
||||
@@ -151,16 +264,22 @@ jobs:
|
||||
|
||||
# push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면
|
||||
# push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다.
|
||||
# 자동 트리거(schedule)와 드리프트 모드는 **절대 push 하지 않는다.** 자동 빌드는
|
||||
# "핀을 이만큼 올리면 정말 해소되는가" 까지만 답한다 — 레지스트리 반영은 사람이
|
||||
# workflow_dispatch 로 한다(CLAUDE.md 의 정책). catch-all 을 false 로 두어 앞으로
|
||||
# 트리거가 추가돼도 실수로 push 되지 않게 한다.
|
||||
- name: 게시 여부 결정
|
||||
id: publish
|
||||
run: |
|
||||
case "${{ github.event_name }}" in
|
||||
pull_request) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
|
||||
workflow_dispatch)
|
||||
[ "${{ github.event.inputs.push }}" = "false" ] \
|
||||
&& echo "enabled=false" >> "$GITHUB_OUTPUT" \
|
||||
|| echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
|
||||
*) echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
|
||||
if [ "${{ github.event.inputs.mode }}" = "drift" ] \
|
||||
|| [ "${{ github.event.inputs.push }}" = "false" ]; then
|
||||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||||
fi ;;
|
||||
*) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
|
||||
esac
|
||||
|
||||
- name: 레지스트리 로그인
|
||||
|
||||
Reference in New Issue
Block a user