Published on

GitHub Actions로 Docker SBOM·서명까지 공급망 보안

Authors

소프트웨어 공급망 보안은 이제 선택이 아니라 기본값입니다. 특히 컨테이너 이미지는 빌드 시점에 수많은 의존성(베이스 이미지, OS 패키지, 언어 런타임, 애플리케이션 라이브러리)을 끌어오므로, "무엇이 들어있고"(SBOM) "누가 만들었는지"(서명) 그리고 "배포 전에 검증했는지"(policy/verify)를 자동화하지 않으면 운영 단계에서 리스크가 폭발합니다.

이 글은 GitHub Actions에서 Docker 이미지를 빌드한 뒤

  • SBOM(Software Bill of Materials) 생성
  • 이미지 서명(cosign) 및 레지스트리에 서명/어테스테이션(Attestation) 저장
  • 배포 전 검증 단계 추가

까지 이어지는 실전 파이프라인을 구성하는 방법을 다룹니다.

또한 쿠버네티스에서 이미지 풀 문제가 나는 상황을 줄이기 위해(태그/권한/레지스트리 설정 포함) 운영 관점의 체크포인트도 함께 정리합니다. 필요하면 K8s Pod ImagePullBackOff - ECR 403 해결 가이드도 같이 참고하면 좋습니다.

왜 SBOM과 서명이 함께 필요할까

SBOM이 해결하는 문제

SBOM은 "이 이미지에 들어있는 구성 요소 목록"입니다. 보통 다음을 포함합니다.

  • OS 패키지(예: apk, apt, yum)
  • 언어별 패키지(예: npm, pip, maven, go mod)
  • 애플리케이션 바이너리/아티팩트

SBOM이 있으면 CVE가 터졌을 때 "우리 이미지가 영향을 받는지"를 빠르게 판별하고, 영향 범위를 정확히 좁힐 수 있습니다.

서명이 해결하는 문제

서명은 "이 이미지가 신뢰할 수 있는 빌드 파이프라인에서 만들어졌는지"를 증명합니다.

  • 공격자가 레지스트리에 동일 태그로 악성 이미지를 푸시
  • CI 토큰 유출로 이미지가 변조
  • 중간자 공격/권한 오구성으로 다른 레포에 푸시

같은 시나리오에서, 배포 전 검증이 없으면 결국 런타임에서 터집니다.

핵심: SBOM은 투명성, 서명은 무결성

SBOM만 있어도 "무엇이 들어있는지"는 알 수 있지만, 그 SBOM 자체가 신뢰할 수 있는 빌드에서 생성됐는지 보장하지 못합니다.

서명만 있어도 "누가 만들었는지"는 보장되지만, 취약점 대응을 위한 구성 요소 가시성이 떨어집니다.

따라서 실전에서는 SBOM 생성 + 서명 + 검증을 한 세트로 자동화하는 것이 중요합니다.

구성 요소 선택: Syft + Cosign(키리스 OIDC)

  • SBOM 생성: anchore/syft (CycloneDX 또는 SPDX 지원)
  • 서명/어테스테이션: sigstore/cosign
  • 키 관리: GitHub OIDC 기반 키리스(Keyless) 서명 권장

키리스 서명의 장점은 다음과 같습니다.

  • 장기 보관 비밀키가 없음(유출 표면 감소)
  • GitHub Actions의 OIDC 토큰으로 짧게 발급된 인증 기반 서명
  • Rekor(투명성 로그)에 기록 가능(감사 추적 강화)

사전 준비: 권한과 태깅 전략

GitHub Actions 권한

키리스 서명을 쓰려면 워크플로에 다음 권한이 필요합니다.

  • id-token: write (OIDC 토큰 발급)
  • contents: read
  • 레지스트리 푸시를 위한 packages: write 또는 클라우드 레지스트리용 자격증명

이미지 태그는 반드시 불변(immutable) 기준을 포함

운영에서 "태그 재사용"은 사고의 지름길입니다.

  • 권장: sha 기반 태그(git sha) + 선택적으로 latest 같은 이동 태그
  • 검증/서명은 "다이제스트"(digest, sha256:...) 기준으로 이루어지는 것이 안정적

GitHub Actions 예제: 빌드, 푸시, SBOM, 서명

아래 예시는 GHCR(ghcr.io)에 이미지를 푸시하고, SBOM을 생성한 뒤 cosign으로 서명합니다.

주의: 본문에서 부등호 문자가 노출되면 MDX에서 JSX로 오인될 수 있으므로, 모든 코드는 코드 블록으로 제공합니다.

name: build-sign-sbom

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  packages: write
  id-token: write

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,format=long
            type=raw,value=latest

      - name: Build and push
        id: build
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          provenance: true
          sbom: false

      - name: Install syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Generate SBOM (CycloneDX JSON)
        run: |
          syft "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}" \
            -o cyclonedx-json \
            > sbom.cdx.json

      - name: Upload SBOM artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.cdx.json

      - name: Install cosign
        uses: sigstore/cosign-installer@v3

      - name: Cosign sign image (keyless)
        env:
          COSIGN_EXPERIMENTAL: "true"
        run: |
          cosign sign --yes \
            "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"

      - name: Cosign attest SBOM (keyless)
        env:
          COSIGN_EXPERIMENTAL: "true"
        run: |
          cosign attest --yes \
            --predicate sbom.cdx.json \
            --type cyclonedx \
            "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}"

이 워크플로가 남기는 것

  • 레지스트리에는 이미지 본체 + 서명 + SBOM 어테스테이션이 함께 저장됩니다.
  • sbom.cdx.json은 CI 아티팩트로도 보관되어, 감사/분석 시 빠르게 열람할 수 있습니다.

운영에서는 "태그"가 아니라 digest를 기준으로 서명/검증하는 습관이 중요합니다. 태그는 바뀔 수 있지만 다이제스트는 콘텐츠가 동일하면 불변입니다.

배포 전 검증 단계 추가: cosign verify

서명을 만들었으면, 배포 파이프라인(또는 CD)에서 검증을 강제해야 효과가 있습니다. 예를 들어 쿠버네티스 배포 직전 단계에서 다음을 수행할 수 있습니다.

cosign verify \
  --certificate-identity "https://github.com/ORG/REPO/.github/workflows/build-sign-sbom.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "ghcr.io/ORG/REPO@sha256:YOUR_DIGEST"

포인트는 두 가지입니다.

  • --certificate-identity로 "어떤 워크플로/브랜치에서 나온 산출물인지"를 고정
  • --certificate-oidc-issuer로 "어떤 OIDC 발급자"를 신뢰할지 고정

즉, "GitHub Actions 메인 브랜치의 특정 워크플로에서 만든 것"만 배포하도록 정책화할 수 있습니다.

SBOM 검증/활용: 취약점 스캐닝과 정책 게이트

SBOM은 단순 보관으로 끝나면 가치가 반감됩니다. 실전에서는 다음을 권장합니다.

  • PR 단계: 빠른 취약점 스캔(예: Trivy/Grype)
  • main 머지 후: 이미지 빌드 산출물에 대해 정밀 스캔 + 결과 저장
  • 배포 전: "심각도 임계치" 기반 게이트(예: Critical 발견 시 차단)

예를 들어 Grype로 SBOM 또는 이미지를 스캔할 수 있습니다.

grype "ghcr.io/ORG/REPO@sha256:YOUR_DIGEST" \
  --fail-on critical \
  --only-fixed

여기서 --only-fixed 같은 옵션은 "수정 가능한 취약점"에 집중해 불필요한 차단을 줄이는 데 도움이 됩니다(조직 정책에 맞게 조정).

운영 체크리스트: 흔한 실패 지점

1) 레지스트리 인증/권한 문제

GHCR은 GITHUB_TOKEN 권한만 맞으면 비교적 단순하지만, ECR/GCR/ACR은 OIDC 연동과 IAM 정책이 핵심입니다. 이미지가 배포 환경에서 풀리지 않는 문제는 결국 ImagePullBackOff로 이어지며, 원인은 대개 권한/토큰/리포지토리 정책입니다. 쿠버네티스에서 이 이슈를 겪는다면 K8s Pod ImagePullBackOff - ECR 403 해결 가이드의 점검 순서가 그대로 적용됩니다.

2) 태그 재사용으로 인한 "검증 통과했는데 다른 이미지"

  • 검증은 반드시 digest 기준으로 수행
  • 배포 매니페스트에도 가능하면 image: repo@sha256:... 형태를 사용

3) 멀티 아키텍처 이미지에서 SBOM 범위 혼동

멀티 아키텍처(linux/amd64, linux/arm64) 이미지는 매니페스트 리스트 구조를 가집니다.

  • 아키텍처별 SBOM을 별도로 생성할지
  • 최종 매니페스트에 대한 SBOM을 어떻게 정의할지

조직 기준을 먼저 정하세요. 보통은 배포 타깃 아키텍처가 명확하면 해당 아키텍처 digest에 대해 SBOM/서명을 고정하는 방식이 단순합니다.

4) "서명은 했는데 검증을 안 함"

서명은 "검증"이 강제되지 않으면 장식품이 됩니다.

  • CD 파이프라인에 cosign verify를 필수 단계로 추가
  • 쿠버네티스라면 admission controller(예: Kyverno, Gatekeeper, Sigstore policy-controller)로 클러스터 레벨에서 강제

보안 수준을 한 단계 더 올리는 확장 아이디어

SLSA provenance(빌드 출처) 강화

docker/build-push-actionprovenance: true는 provenance 생성에 도움이 됩니다. 조직이 SLSA 레벨을 올리고 싶다면, provenance를 어테스테이션으로 저장하고 검증 정책에 포함시키는 방향을 고려하세요.

릴리스 브랜치/태그에서만 서명 허용

  • main 빌드는 테스트용
  • release/* 또는 vX.Y.Z 태그에서만 "프로덕션 서명" 수행
  • 검증 단계에서 identity를 릴리스 워크플로로 제한

이렇게 하면 개발 브랜치 산출물이 실수로 운영에 배포되는 사고를 구조적으로 줄일 수 있습니다.

정리

GitHub Actions에서 Docker 공급망 보안을 현실적으로 강화하려면 다음 3가지를 "자동화"로 묶어야 합니다.

  1. SBOM 생성으로 구성 요소 투명성 확보
  2. cosign 키리스 서명으로 산출물 무결성/출처 보장
  3. 배포 전 검증(verify) 또는 클러스터 정책으로 강제

이 파이프라인이 자리 잡으면, 취약점 대응 속도와 사고 반경이 눈에 띄게 개선됩니다. 특히 "다이제스트 기반 배포"와 "검증 강제"까지 함께 도입해야 공급망 보안이 실제로 작동합니다.

추가로 운영에서 이미지 풀/권한 문제가 반복된다면, 레지스트리 인증과 IAM 구성을 먼저 안정화하세요. 그 위에 SBOM·서명·검증을 얹을 때 가장 효과가 큽니다.