왜 아티팩트 서명이 필요한가

컨테이너 이미지나 배포 아티팩트는 레지스트리에서 태그로 참조된다. 문제는 태그가 언제든 다른 다이제스트로 덮어써질 수 있고, 중간에 누군가 악성 레이어를 밀어넣어도 소비하는 쪽에서 알아채기 어렵다는 점이다. "우리 CI가 빌드한 그 이미지가 맞다"를 증명할 방법이 없으면 공급망 공격의 표적이 된다.

서명은 이 문제를 "출처(provenance)와 무결성"의 문제로 바꾼다. 빌드 파이프라인이 아티팩트에 서명하고, 배포 단계에서 서명을 검증하면 신뢰할 수 있는 주체가 만든 아티팩트만 클러스터에 들어온다. cosign은 이 과정을 컨테이너 레지스트리 위에서 표준적으로 처리하는 도구다.

키 기반 서명 vs Keyless

cosign은 두 가지 방식을 제공한다. 장기 키를 관리하기 부담스럽다면 OIDC 신원과 짧은 수명 인증서를 쓰는 keyless 방식이 운영상 유리하다.

구분키 기반Keyless (OIDC)
키 관리KMS/파일로 직접 관리, 유출·회전 부담단명 인증서, 관리 대상 없음
신뢰 근거공개키 보유 여부OIDC 신원 + 투명성 로그(Rekor)
적합한 상황폐쇄망, 외부 의존 최소화GitHub Actions 등 CI 표준 환경

기본 서명과 검증

KMS(예: AWS KMS)에 키를 두고 서명하는 흐름은 다음과 같다. 반드시 태그가 아니라 다이제스트로 서명·검증해야 태그 교체 공격을 막을 수 있다.

#!/usr/bin/env bash
set -euo pipefail

IMAGE="registry.example.com/app"
DIGEST=$(crane digest "${IMAGE}:latest")
REF="${IMAGE}@${DIGEST}"

# KMS 키로 서명 (서명은 레지스트리에 함께 저장됨)
cosign sign --yes \
  --key awskms:///alias/artifact-signing \
  "${REF}"

# 검증: 공개키로 무결성/출처 확인
cosign verify \
  --key awskms:///alias/artifact-signing \
  "${REF}"

CI에서 Keyless 서명 자동화

GitHub Actions에서는 OIDC 토큰을 발급받아 Fulcio가 단명 인증서를 발급하고, 서명 사실은 Rekor 투명성 로그에 기록된다. 별도 시크릿 키를 저장하지 않아도 된다.

name: build-and-sign
on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write
  id-token: write   # OIDC 발급에 필수

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: sigstore/cosign-installer@v3

      - name: Build & push
        id: push
        run: |
          IMAGE=ghcr.io/${{ github.repository }}
          docker build -t "$IMAGE:${{ github.ref_name }}" .
          docker push "$IMAGE:${{ github.ref_name }}"
          DIGEST=$(cosign triangulate --type digest "$IMAGE:${{ github.ref_name }}" || \
                   crane digest "$IMAGE:${{ github.ref_name }}")
          echo "ref=$IMAGE@$DIGEST" >> "$GITHUB_OUTPUT"

      - name: Sign (keyless)
        env:
          COSIGN_EXPERIMENTAL: "1"
        run: cosign sign --yes "${{ steps.push.outputs.ref }}"

검증 시 신원 고정

keyless 검증에서는 "누가 서명했는가"를 반드시 고정해야 한다. 신원과 발급자를 명시하지 않으면 아무 유효 서명이나 통과하므로 의미가 없다.

cosign verify \
  --certificate-identity-regexp \
    "^https://github.com/myorg/app/.github/workflows/.+@refs/tags/v" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "ghcr.io/myorg/app@sha256:..."

배포 단계에서 정책으로 강제

서명만 하고 검증을 강제하지 않으면 효과가 없다. 쿠버네티스에서는 Kyverno나 sigstore policy-controller로 어드미션 단계에서 미서명 이미지를 차단한다. 아래는 Kyverno 예시로, 지정한 CI 신원의 서명이 없는 이미지의 파드 생성을 거부한다.

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-signed-images
spec:
  validationFailureAction: Enforce
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds: ["Pod"]
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    subjectRegExp: "^https://github.com/myorg/.+"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"

SBOM·프로버넌스 어테스테이션

서명은 "무결성"만 보장한다. "이 이미지가 어떤 소스와 빌드 과정에서 나왔는가"까지 검증하려면 어테스테이션을 붙인다. SBOM을 생성해 이미지에 서명된 형태로 첨부하면, 취약점 대응 시 어떤 컴포넌트가 포함됐는지 추적할 수 있다.

# SBOM 생성 후 어테스테이션으로 첨부
syft "$REF" -o cyclonedx-json > sbom.json
cosign attest --yes --predicate sbom.json \
  --type cyclonedx "$REF"

# 검증
cosign verify-attestation --type cyclonedx \
  --certificate-identity-regexp "^https://github.com/myorg/" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  "$REF"

운영 시 주의점

  • 태그가 아니라 다이제스트로 검증하라. 태그는 가변이므로 서명과 실제 실행 이미지가 어긋날 수 있다.
  • 신원 고정은 필수다. --certificate-identity와 --certificate-oidc-issuer 없이 하는 keyless 검증은 사실상 무방비다. 정규식은 브랜치/태그 조건까지 좁혀라.
  • 폐쇄망은 Rekor·Fulcio를 자체 호스팅하거나 키 기반으로 전환한다. 공용 sigstore 인프라 접근이 막히면 검증이 실패한다.
  • 키 회전 계획을 세워라. KMS 키를 쓰면 회전 시 과거 서명 검증을 위해 이전 공개키를 함께 신뢰 목록에 유지해야 한다.
  • 검증 실패를 관측 가능하게 만들어라. 어드미션 거부 이벤트를 로그·알림으로 연결하지 않으면 배포가 조용히 막힌다.