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

내부 보안팀에서 "프로덕션 클러스터에 서명 안 된 이미지 못 들어오게 하자"는 얘기가 나온 지 한참 됐다. 우리 팀은 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) 쪽으로 확장한 얘기를 다뤄볼까 한다.