IT/DevSecOps

Cosign keyless 서명을 Kyverno로 강제하는 법 — GitHub Actions OIDC 기반 실전 가이드

gfrog 2026. 6. 14. 18:13
SMALL

내부 보안팀에서 "프로덕션 클러스터에 서명 안 된 이미지 못 들어오게 하자"는 얘기가 나온 지 한참 됐다. 우리 팀은 GitHub Actions로 빌드한 이미지를 ECR에 푸시하고 있었고, Cosign keyless 서명 자체는 이미 파이프라인에 붙여둔 상태였다. 문제는 검증. 클러스터 어드미션에서 막는 부분이 없었다.

Kyverno ImageValidatingPolicy(IVP)로 정리한 결과를 적어둔다. 1.13에서 들어온 새 정책 타입인데, 기존 ClusterPolicy의 verifyImages 룰보다 모듈화가 잘 돼 있어서 운영하기 편하다.

전제: Cosign keyless 서명이 무엇을 보장하나

Keyless는 키 파일을 보관하지 않는다. 대신 Sigstore Fulcio가 OIDC ID(예: GitHub Actions의 워크플로 토큰)를 받아 단명 인증서(10분)를 발급하고, 그걸로 이미지를 서명한 뒤 Rekor 투명성 로그에 기록한다. 검증할 때는 "이 이미지가 어느 워크플로에서, 어느 레포에서, 어느 브랜치에서 서명됐는지"를 인증서 SAN과 issuer로 검사한다.

즉 정책의 본질은 두 가지다.

  • 서명한 신원(subject): 누가 서명했는가
  • 서명자의 issuer: 어느 OIDC 발급자에서 왔는가

여기를 헐겁게 잡으면 keyless의 의미가 없다. subject: ".*" 같은 정책은 그냥 "서명만 돼 있으면 통과"라서 임의의 GitHub 레포에서 서명한 이미지도 들어온다. 실제로 처음 도입할 때 이걸 못 잡아서 한 번 다시 손봤다.

워크플로 쪽: id-token 권한과 cosign sign

GitHub Actions 워크플로에서 OIDC 토큰을 쓰려면 id-token: write 권한이 필요하다. 이 부분 자주 놓친다.

# .github/workflows/build.yml
permissions:
  contents: read
  id-token: write       # ← 이게 없으면 keyless 서명 안 됨
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ secrets.ECR_REGISTRY }}
          username: ${{ secrets.AWS_ACCESS_KEY_ID }}
          password: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      - uses: docker/build-push-action@v6
        id: build
        with:
          push: true
          tags: ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}
      - uses: sigstore/cosign-installer@v3
      - name: Sign image
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
          IMAGE: ${{ secrets.ECR_REGISTRY }}/myapp
        run: |
          cosign sign --yes "${IMAGE}@${DIGEST}"

--yes는 대화형 확인을 끄는 옵션. CI에서 안 넣으면 멈춘다. 그리고 반드시 digest로 서명해야 한다. 태그로 서명하면 같은 태그가 재푸시될 때 서명이 안 맞는다 — 우리도 이거 한 번 겪어서 빌드 액션의 outputs.digest를 쓰도록 통일했다.

클러스터 쪽: Kyverno ImageValidatingPolicy

Kyverno 1.13부터 들어온 ImageValidatingPolicy는 verifyImages 룰을 별도 리소스로 뽑아낸 것. 정책 평가가 ClusterPolicy보다 가볍고, 매칭 조건도 CEL로 쓸 수 있다.

apiVersion: policies.kyverno.io/v1alpha1
kind: ImageValidatingPolicy
metadata:
  name: require-cosign-keyless-from-our-org
spec:
  failurePolicy: Fail
  validationActions: [Deny]
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: ["v1"]
      operations: ["CREATE", "UPDATE"]
      resources: ["pods"]
  matchImageReferences:
  - glob: "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/*"
  attestors:
  - name: keyless
    cosign:
      keyless:
        identities:
        - issuer: "https://token.actions.githubusercontent.com"
          subjectRegExp: "^https://github\.com/myorg/(myapp|otherapp)/\.github/workflows/build\.yml@refs/heads/main$"
        rekor:
          url: "https://rekor.sigstore.dev"
  validations:
  - expression: "imageVerify.verified == true"

핵심은 subjectRegExp다. SAN URI 형식이 정확히 https://github.com/<org>/<repo>/.github/workflows/<file>@<ref> 이런 모양이라서, 우리는 main 브랜치의 build.yml에서 서명된 이미지만 통과시키도록 잠갔다. 다른 워크플로(예: PR 빌드)에서 만든 이미지는 issuer는 같아도 subject가 안 맞아서 떨어진다.

처음엔 subject 필드를 그냥 문자열로 박아뒀다가, 레포가 늘어나면서 정규식으로 바꿨다. 운영 들어가니까 이 정도 유연성은 필요하더라.

failurePolicy: Fail은 Kyverno admission이 죽었을 때 새 파드 생성을 막는다는 뜻. 처음엔 무서워서 Ignore로 뒀는데, 그러면 정책이 사실상 무의미해진다 — Kyverno가 일시적으로 안 떠도 서명 안 된 이미지가 다 들어오니까. 우리는 결국 Fail로 바꾸고 대신 Kyverno HA 셋업을 강화했다.

점진적 롤아웃 — Audit 먼저, Enforce 나중

처음부터 Deny로 걸면 기존 워크로드가 다 떨어진다. 우리는 2주간 validationActions: [Audit]으로 돌렸다. Audit 모드는 위반을 PolicyReport에 기록만 하고 차단은 안 한다.

# 미서명 이미지로 떨어진 워크로드 보기
kubectl get policyreports -A \
  -o jsonpath='{range .items[*]}{.results[?(@.result=="fail")].resources[*].name}{"\n"}{end}' \
  | sort -u

이걸 매일 돌려서 리스트가 비면 그제서야 Deny로 바꿨다. 첫 주에 외부 의존 이미지(예: 모니터링 사이드카, init 컨테이너용 부트스트랩 이미지)가 줄줄이 걸렸는데, 그건 matchImageReferences에서 우리 ECR만 검사하도록 좁혀서 해결했다. 외부 이미지까지 다 서명 강제하려면 cosign copy로 미러링한 뒤 우리가 다시 서명하는 흐름이 필요한데, 거기까지는 아직 안 갔다.

운영 들어간 뒤 알게 된 것들

  • Rekor 다운 시 검증 실패: 한 번 sigstore.dev 쪽이 30분쯤 흔들렸을 때, 새 파드 생성이 다 막혔다. 캐시(tufMirror)나 --insecure-ignore-tlog 같은 옵션을 검토하긴 했는데 트레이드오프가 커서 그냥 두기로 했다. Sigstore 자체 SLA를 믿는 쪽.
  • 이미지 digest로 deploy 강제: 태그로 deploy하면 Kyverno가 매번 풀해서 digest를 확인해야 해서 admission 레이턴시가 오른다. 우리는 ArgoCD에서 Kustomize images: 필드로 digest 핀을 강제했다.
  • PolicyReport 백로그: Audit 모드 켜둔 채로 잊으면 PolicyReport CR이 수만 개 쌓인다. TTL 정리 job 하나 만들어두는 게 좋다.

서명 검증은 한 번 자리잡으면 운영 부담이 크진 않은데, 도입 직전·직후 1-2주가 가장 어수선하다. Audit → Deny 2단계로 가는 거 강력 추천. 다음에는 같은 정책을 attestation(SLSA provenance) 쪽으로 확장한 얘기를 다뤄볼까 한다.

BIG