Sigstore로 공급망 보안 서명하기: cosign keyless 서명, GitHub Actions 자동화, SBOM, Kubernetes 검증

이 글의 핵심

GPG 키를 오래 보관하고 교체하는 부담 때문에 서명을 미뤄 왔다면, Sigstore는 OIDC 신원으로 짧은 수명의 인증서를 받아 서명하는 방식으로 이 문제를 풉니다. 검증 시 no matching signatures나 could not find identity 에러가 나는 원인, 에어갭 환경용 private Sigstore, 키 기반 서명과 섞어 쓰는 방법까지 짚습니다.

왜 공급망 보안인가

사건영향
SolarWinds (2020)18,000 조직에 악성 업데이트
Codecov (2021)CI 토큰 대량 탈취
Log4Shell (2021)수천 조직의 Java 앱 RCE
xz-utils 백도어 (2024)오픈소스 유지보수자 사회공학 공격
npm/PyPI typo-squatting·악성 패키지레지스트리마다 지속적으로 보고됨

공통점: “빌드·배포 파이프라인 어디에선가 신뢰할 수 없는 코드가 들어왔다”. 방어 핵심은:

  1. 출처 증명 — 누가 만들었는가 (서명)
  2. 내용 증명 — 무엇이 들어있는가 (SBOM)
  3. 빌드 증명 — 어떻게 빌드되었는가 (SLSA Provenance)
  4. 정책 집행 — 허용된 것만 배포 (admission controller)

Sigstore는 1·3의 기반 서명 인프라이자 2의 서명에도 쓰이는 공통 도구입니다.

표의 사건들은 성격이 다르다는 점도 구분해 두면 좋습니다. SolarWinds와 Codecov는 빌드·배포 경로 자체가 침해되어 정상 서명 절차를 거친 악성 산출물이 배포된 사례이고, Log4Shell은 널리 쓰인 의존성의 취약점, xz-utils는 유지보수자 신뢰를 장기간에 걸쳐 획득한 공격입니다. 서명은 “이 산출물이 우리가 정한 빌드 경로에서 나왔는가”를 확인해 주므로 레지스트리에서 이미지를 바꿔치기하거나 개발자 PC에서 임의로 빌드해 올리는 공격에는 강하지만, SolarWinds처럼 빌드 시스템 안에서 악성 코드가 주입되면 악성 산출물에도 정상 서명이 붙습니다. 그래서 서명 위에 빌드 과정을 증명하는 SLSA provenance와 격리된 빌더가 함께 필요합니다.

Sigstore 구성 요소

┌──────────────────────────────────────────────────────┐
│  개발자 / CI 러너                                    │
│    │                                                   │
│    ▼  1) OIDC 토큰 요청 (GitHub/Google/...)          │
│  OIDC IdP                                              │
│    │                                                   │
│    ▼  2) OIDC 토큰으로 단명 인증서 발급 요청         │
│  Fulcio  ── 3) 단명(10분) x509 인증서 발급            │
│    │                                                   │
│    ▼  4) 인증서 개인키로 아티팩트 서명                │
│  cosign CLI                                            │
│    │                                                   │
│    ▼  5) 서명·인증서를 Rekor(투명성 로그)에 기록      │
│  Rekor                                                 │
│    │                                                   │
│    ▼  6) 서명과 인증서를 OCI 레지스트리에 저장        │
│  OCI Registry                                          │
└──────────────────────────────────────────────────────┘

검증 측:
  OCI Registry → cosign verify → Fulcio/Rekor 확인 → OK/FAIL
  • cosign: CLI/라이브러리, 서명·검증·SBOM 첨부
  • Fulcio: OIDC → 단명 x509 발급 CA
  • Rekor: 투명성 로그 (immutable append-only)
  • TUF: 신뢰 앵커(공개 키) 배포 체계

Keyless라는 이름 때문에 “키가 아예 없다”고 오해하기 쉽지만, 실제로는 서명할 때마다 cosign이 일회용 키 쌍을 메모리에서 생성하고, Fulcio가 그 공개 키와 OIDC 신원을 묶은 10분짜리 인증서를 발급합니다. 서명이 끝나면 개인 키는 버려지므로 보관하거나 유출될 키가 남지 않습니다. 인증서가 10분 뒤 만료되는데도 몇 년 뒤에 검증이 가능한 이유는 Rekor 때문입니다. Rekor는 서명이 인증서가 유효하던 시점에 기록되었다는 타임스탬프 증거(SET, inclusion proof)를 남기고, 검증자는 “서명 시점에 인증서가 유효했는가”를 확인합니다. 그래서 keyless 서명에서 Rekor 기록을 건너뛰면 만료된 인증서만 남아 검증할 방법이 사라집니다.

설치

# macOS
brew install cosign

# Linux
wget -qO cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign && sudo mv cosign /usr/local/bin/

# Windows
winget install cosign

Kubernetes 측 policy-controller(구 Cosigned) 또는 Kyverno도 준비합니다.

Keyless 서명: 개인 개발자

# 서명
cosign sign ghcr.io/myorg/myapp:1.2.3

# 실행되는 일:
# - 브라우저가 열려 OIDC 인증 (GitHub/Google/Microsoft 선택)
# - Fulcio가 "[email protected]" 주소로 10분짜리 인증서 발급
# - 서명이 레지스트리에 `:sha256-...sig` 태그로 저장
# - Rekor에 영구 기록

검증

cosign verify ghcr.io/myorg/myapp:1.2.3 \
  [email protected] \
  --certificate-oidc-issuer=https://github.com/login/oauth

--certificate-identity / --certificate-oidc-issuer로 “누가 어떤 IdP로 서명했는가” 를 강제합니다. 이게 빠지면 아무나 서명해도 통과됩니다.

cosign 2.x는 keyless 검증에서 이 두 옵션(또는 -regexp 버전)을 필수로 요구해서, 빠뜨리면 --certificate-identity or --certificate-identity-regexp is required for verification in keyless mode 에러로 멈춥니다. 1.x 시절 예제를 그대로 따라 하면 이 에러를 먼저 만나게 됩니다. 발급자 값은 로그인한 방식에 따라 달라서, GitHub 계정으로 브라우저 로그인했다면 https://github.com/login/oauth, Google이면 https://accounts.google.com, GitHub Actions에서 서명했다면 https://token.actions.githubusercontent.com입니다. 어떤 값으로 서명되었는지 모르겠다면 cosign verify에 느슨한 정규식(--certificate-identity-regexp='.*')을 한 번 걸어 출력된 인증서의 Subject와 Issuer를 확인한 뒤, 정책에는 정확한 값을 적는 방식으로 좁혀 가면 됩니다.

GitHub Actions에서 자동 서명

# .github/workflows/release.yml
name: release

on:
  push:
    tags: ["v*"]

permissions:
  contents: read
  packages: write
  id-token: write       # ← Sigstore OIDC 토큰 필수

jobs:
  publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/setup-buildx-action@v3

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}

      - uses: sigstore/cosign-installer@v3
        with: {cosign-release: "v2.4.1"}

      - name: Sign image (keyless)
        env:
          DIGEST: ${{ steps.build.outputs.digest }}  # cosign 2.x는 COSIGN_EXPERIMENTAL 불필요
        run: |
          cosign sign --yes \
            ghcr.io/${{ github.repository }}@${DIGEST}

id-token: write 퍼미션이 OIDC 토큰을 발급받아 사람의 개입 없이 CI가 서명하게 해줍니다.

서명 대상을 태그(:v1.2.3)가 아니라 build-push-action이 출력한 다이제스트(@sha256:...)로 지정한 것이 중요합니다. 서명은 다이제스트에 붙는데, 태그로 서명하면 cosign이 그 순간 태그가 가리키는 다이제스트를 조회해 서명하므로, 빌드와 서명 사이에 다른 작업이 같은 태그로 푸시하면 엉뚱한 이미지에 서명하게 됩니다. cosign도 태그로 서명하면 경고를 출력합니다. --yes는 Rekor 공개 로그에 이메일 같은 신원 정보가 기록된다는 확인 프롬프트를 건너뛰는 옵션이라, 비공개 저장소라도 서명한 워크플로 경로와 저장소 이름이 공개 로그에 남는다는 점은 알고 써야 합니다.

검증 규칙

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

“이 저장소의 이 워크플로에서 tag push로 서명된 이미지만” 통과합니다. 매우 강력한 출처 정책입니다.

정규식은 반드시 ^로 시작을 고정하고 점(.)을 의도대로 다뤄야 합니다. 앞의 ^를 빼면 https://github.com/attacker/x/...github.com/myorg/myapp/...처럼 다른 문자열 안에 패턴이 포함된 신원도 통과할 수 있고, myorg를 .*로 느슨하게 쓰면 공격자가 자기 저장소의 워크플로로 서명한 이미지가 정책을 통과합니다. 정규식 대신 --certificate-identity=https://github.com/myorg/myapp/.github/workflows/release.yml@refs/tags/v1.2.3처럼 정확한 값을 쓸 수 있으면 그편이 가장 안전하고, 태그마다 값이 바뀌어 불편하다면 --certificate-github-workflow-repository, --certificate-github-workflow-ref 같은 GitHub 전용 확장 필드 검증 옵션을 함께 거는 방법도 있습니다.

SBOM 첨부

# Syft로 SBOM 생성
syft ghcr.io/myorg/myapp:1.2.3 -o cyclonedx-json > sbom.json

# Sigstore 서명 + OCI에 첨부
cosign attest --yes --predicate sbom.json \
  --type cyclonedx \
  ghcr.io/myorg/myapp:1.2.3

검증 측은 SBOM이 서명된 상태임을 확인하고 내용을 취약점 스캐너에 넘길 수 있습니다.

cosign verify-attestation \
  --certificate-identity-regexp="..." \
  --certificate-oidc-issuer="..." \
  --type cyclonedx \
  ghcr.io/myorg/myapp:1.2.3 | jq .

SLSA Provenance

빌드 시스템이 “이 바이너리는 커밋 abc123의 소스 코드를 이 빌더가 이 단계로 만들었다”를 증명하는 문서입니다. GitHub Actions는 slsa-github-generator 액션으로 자동 생성·서명해줍니다.

# steps가 아니라 별도 job으로 호출하는 재사용 워크플로
jobs:
  # publish job은 outputs로 digest를 내보내야 함
  #   outputs: { digest: ${{ steps.build.outputs.digest }} }
  provenance:
    needs: [publish]
    permissions:
      actions: read
      id-token: write
      packages: write
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      image: ghcr.io/${{ github.repository }}
      digest: ${{ needs.publish.outputs.digest }}
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GITHUB_TOKEN }}

SLSA Level 3 증명을 자동 첨부하며, cosign verify-attestation --type slsaprovenance로 검증합니다.

이 생성기는 step이 아니라 재사용 가능한 워크플로(reusable workflow)라서 jobs.<id>.uses로 호출해야 합니다. step 목록 안에 - uses: .../generator_container_slsa3.yml로 넣으면 워크플로 파싱 단계에서 거부됩니다. 별도 job이 되는 것 자체가 SLSA Level 3의 핵심인데, provenance를 만드는 코드가 빌드 job과 격리된 러너에서 실행되어야 빌드 스크립트가 침해되더라도 증명 내용을 위조할 수 없기 때문입니다. 그래서 빌드 job의 다이제스트를 outputs로 넘겨받는 구조가 필요하고, @v2.0.0처럼 태그로 참조하는 것이 이 생성기의 권장 방식입니다(커밋 해시로 고정하면 생성기의 자체 검증이 실패합니다). 검증 쪽에서는 cosign 외에 slsa-verifier verify-image를 쓰면 빌더 신원과 소스 저장소를 한 번에 확인할 수 있습니다.

Kubernetes 배포 시 검증

Option A: Sigstore policy-controller

helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller sigstore/policy-controller \
  --namespace cosign-system --create-namespace
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-myorg-signature
spec:
  images:
    - glob: "ghcr.io/myorg/**"
  authorities:
    - keyless:
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: "^https://github.com/myorg/.*/\\.github/workflows/release\\.yml@refs/tags/v.*"
        url: https://fulcio.sigstore.dev
      ctlog:
        url: https://rekor.sigstore.dev

ghcr.io/myorg/ 경로의 이미지는 이 정책에 맞게 서명되어 있어야만 Pod가 생성됩니다. 아니면 admission webhook에서 차단.

policy-controller를 설치하고 정책을 만들었는데 서명 없는 이미지가 그대로 배포된다면, 가장 흔한 원인은 네임스페이스 옵트인입니다. policy-controller는 기본적으로 policy.sigstore.dev/include=true 라벨이 붙은 네임스페이스에서만 검증하므로 kubectl label namespace prod policy.sigstore.dev/include=true가 필요합니다. 또 검증은 태그를 다이제스트로 해석해서 하므로, 매니페스트에 태그를 쓰더라도 admission 시점의 다이제스트로 확인되고 이후 태그가 바뀌어도 이미 실행 중인 Pod에는 영향이 없습니다. 정책에 걸리지 않는 이미지(glob에 해당하지 않는 퍼블릭 베이스 이미지 등)를 어떻게 다룰지도 no-match-policy 설정으로 정해야 하며, 처음 도입할 때는 mode: warn으로 돌려 어떤 워크로드가 막힐지 먼저 확인하는 것이 안전합니다.

Option B: Kyverno verifyImages

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-signed-images
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds: [Pod]
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/myorg/*/.github/workflows/release.yml@refs/tags/v*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev

Kyverno가 여러 정책(리소스 요구량·태그·서명)을 한 번에 관리할 수 있는 장점이 있습니다.

Kyverno의 subject는 정규식이 아니라 와일드카드 패턴(*, ?)이라 policy-controller의 subjectRegExp와 문법이 다르므로, 한쪽 정책을 다른 쪽으로 옮길 때 그대로 복사하면 의도와 다르게 매칭됩니다. 또 최근 Kyverno 버전은 정책 전체의 validationFailureAction 대신 규칙 단위의 실패 처리 설정을 권장하는 방향으로 바뀌고 있으니 설치한 버전의 문서를 확인하세요. 서명 검증은 admission 요청마다 레지스트리와 Rekor를 조회하므로 webhookTimeoutSeconds를 넉넉히 두고, Kyverno가 다운되었을 때 배포를 막을지(fail closed) 통과시킬지(fail open)를 웹훅 failurePolicy로 미리 정해 두어야 장애 때 당황하지 않습니다.

private Sigstore (에어갭/규제 환경)

금융·공공 등에서 인터넷 연결 없이 쓰려면 자체 Fulcio + Rekor + TUF를 구축합니다.

  • sigstore/sigstore-scaffolding으로 Kubernetes 위에 스택 배포
  • 사내 OIDC(Keycloak/Okta) 연동
  • 자체 TUF 루트를 모든 클라이언트에 배포

운영 부담이 있으므로 클라우드 규제 요건이 강한 경우에만 고려하세요. 대부분은 공개 Sigstore 인프라로 충분합니다.

키 기반 서명 (Hybrid)

Keyless가 낯선 조직은 기존 방식으로 시작할 수 있습니다:

cosign generate-key-pair
# cosign.key, cosign.pub 생성

cosign sign --key cosign.key ghcr.io/myorg/myapp:1.0.0

cosign verify --key cosign.pub ghcr.io/myorg/myapp:1.0.0

KMS(AWS KMS / GCP KMS / Azure Key Vault / HashiCorp Vault) 통합도 1급 지원:

cosign sign --key "awskms:///alias/cosign" ghcr.io/...

모니터링: Rekor 감시

Rekor는 누가 언제 무엇을 서명했는지의 공개 로그입니다. 자신의 조직 명의로 예상하지 못한 서명이 일어나면 즉시 감지할 수 있습니다.

# 특정 이메일로 서명된 최근 엔트리
rekor-cli search --email [email protected]

# 특정 리포지토리에서 발행된 서명
rekor-cli search --artifact sha256:...

SIEM 파이프라인에 붙여 조직 외부에서 내 명의로 서명이 등록되면 알림을 받는 체계 구축이 권장됩니다.

트러블슈팅

no matching signatures

  • --certificate-identity와 --certificate-oidc-issuer가 실제 서명자와 일치하는지
  • 이미지가 @digest가 아닌 태그라면 중간에 재태깅되어 서명된 다이제스트와 다를 수 있음 → 다이제스트로 검증

could not find identity

  • GitHub Actions OIDC 설정: permissions.id-token: write 누락

Rekor 일시 장애

  • 서명 시 --tlog-upload=false, 검증 시 --insecure-ignore-tlog=true로 투명성 로그를 건너뛸 수 있지만, keyless 서명에서는 Rekor 기록이 없으면 인증서 만료 후 검증할 근거가 사라지므로 사용 금지
  • 공개 Sigstore 상태는 status.sigstore.dev에서 확인하고, 비상시 별도 로그 저장소(자체 Rekor)로 이중화

레지스트리에 서명 태그 밀림

  • cosign clean 은 서명 제거 가능 — 사용 금지 or RBAC으로 차단
  • GCR/ECR의 lifecycle 정책이 :sha256-*.sig를 삭제하지 않도록 필터

체크리스트

  • 모든 컨테이너 이미지에 Sigstore 서명
  • 중요한 바이너리(go/rust release)도 서명
  • SBOM(CycloneDX/SPDX)을 cosign attest로 첨부
  • SLSA Provenance 자동 생성
  • Kubernetes admission에서 서명 검증 강제
  • Rekor 감사 로그 SIEM 연동
  • 키 기반/Keyless 정책을 조직 표준 문서로 관리
  • 재해 복구: 서명 복제·오프라인 검증 도구 준비

마무리

Sigstore는 GPG 시대의 무거운 키 관리를 CI OIDC 기반 단명 인증서로 뒤집어, “서명이 당연한 것”이 되도록 만들었습니다. Kubernetes 릴리스 산출물, CPython 릴리스, npm의 provenance 기능처럼 주요 프로젝트와 패키지 레지스트리가 이미 Sigstore를 쓰고 있고, 미국 Executive Order 14028과 EU Cyber Resilience Act 같은 규제도 SBOM과 공급망 투명성을 요구하는 방향으로 움직이고 있습니다. 오늘 당장 CI에 10줄 추가로 서명을 시작해, 다음 사건의 피해자가 되지 마세요.

자주 묻는 질문 (FAQ)

Q. cosign verify가 no matching signatures로 실패하면 무엇을 확인하나요?

A. —certificate-identity와 —certificate-oidc-issuer 값이 실제 서명한 워크플로와 OIDC 발급자와 정확히 일치하는지부터 확인합니다. 이미지를 태그로 검증하면 서명 이후 다른 이미지가 같은 태그로 다시 푸시되어 서명된 다이제스트와 달라질 수 있으므로, 검증과 배포 모두 @sha256 다이제스트로 고정하는 것이 안전합니다. GitHub Actions에서 서명 단계가 could not find identity로 실패했다면 permissions에 id-token: write가 빠졌는지 확인합니다.


같이 보면 좋은 글