LabHub

블로그

Kyverno 이미지 검증: Sigstore와 공급망 보안

한국어English日本語


1. verifyImages 규칙 개요

Kyverno의 verifyImages 규칙은 컨테이너 이미지의 서명과 증명(attestation)을 검증하여 공급망 보안을 강화합니다.

1.1 기본 구조

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-images
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - 'ghcr.io/myorg/*'
            - 'myregistry.io/apps/*'
          attestors:
            - entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
                      -----END PUBLIC KEY-----

2. Cosign 서명 검증

2.1 정적 키 기반

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    attestors:
      - entries:
          - keys:
              publicKeys: |-
                -----BEGIN PUBLIC KEY-----
                MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
                -----END PUBLIC KEY-----

2.2 Keyless 서명 (Fulcio)

OIDC 기반 키리스 서명 검증:

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    attestors:
      - entries:
          - keyless:
              url: https://fulcio.sigstore.dev
              rekor:
                url: https://rekor.sigstore.dev
              subject: 'https://github.com/myorg/*'
              issuer: 'https://token.actions.githubusercontent.com'

이 정책은:

2.3 KMS 키

verifyImages:
  - imageReferences:
      - 'myregistry.io/apps/*'
    attestors:
      - entries:
          - keys:
              kms: awskms:///arn:aws:kms:us-east-1:123456789:key/abc-123
          # 또는
          - keys:
              kms: gcpkms://projects/my-project/locations/global/keyRings/my-ring/cryptoKeys/my-key
          # 또는
          - keys:
              kms: azurekms://my-vault.vault.azure.net/keys/my-key

3. Attestation 검증

3.1 in-toto Attestation

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    attestations:
      - type: https://slsa.dev/provenance/v1
        attestors:
          - entries:
              - keyless:
                  url: https://fulcio.sigstore.dev
                  rekor:
                    url: https://rekor.sigstore.dev
        conditions:
          - all:
              - key: '{{ builder.id }}'
                operator: Equals
                value: 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v1.9.0'

3.2 SLSA Provenance 검증

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    attestations:
      - type: https://slsa.dev/provenance/v1
        attestors:
          - entries:
              - keyless:
                  url: https://fulcio.sigstore.dev
                  subject: 'https://github.com/myorg/*'
                  issuer: 'https://token.actions.githubusercontent.com'
        conditions:
          - all:
              - key: '{{ buildDefinition.buildType }}'
                operator: Equals
                value: 'https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1'
              - key: '{{ runDetails.builder.id }}'
                operator: Equals
                value: 'https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v1.9.0'

4. 이미지 레지스트리 인증

4.1 프라이빗 레지스트리 접근

# Kyverno가 프라이빗 레지스트리의 서명을 조회하려면
# imagePullSecrets 또는 ServiceAccount에 레지스트리 인증 정보가 필요

# values.yaml (Helm 설치 시)
# admissionController:
#   container:
#     image:
#       pullSecrets:
#         - name: my-registry-secret

5. SBOM 검증

5.1 CycloneDX SBOM attestation 검증

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    attestations:
      - type: https://cyclonedx.org/bom/v1.4
        attestors:
          - entries:
              - keyless:
                  url: https://fulcio.sigstore.dev
        conditions:
          - all:
              - key: "{{ components[?name=='log4j-core'].version | [0] }}"
                operator: NotEquals
                value: '2.14.1'

6. 이미지 변형(Mutation)

6.1 태그를 다이제스트로 변환

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    mutateDigest: true # 태그를 다이제스트로 자동 변환
    required: true # 서명이 반드시 존재해야 함
    verifyDigest: true # 다이제스트 검증
    attestors:
      - entries:
          - keys:
              publicKeys: |-
                -----BEGIN PUBLIC KEY-----
                ...
                -----END PUBLIC KEY-----

mutateDigest: true는 이미지 태그를 다이제스트(SHA256)로 자동 변환하여 이미지 불변성을 보장합니다.


7. 실전 예시: 종합 이미지 보안 정책

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: comprehensive-image-security
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    # 1. 허용된 레지스트리만 사용
    - name: allowed-registries
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: 'Images must be from approved registries'
        foreach:
          - list: 'request.object.spec.[initContainers, containers][]'
            deny:
              conditions:
                all:
                  - key: '{{ element.image }}'
                    operator: AnyNotIn
                    value:
                      - 'ghcr.io/myorg/*'
                      - 'myregistry.io/*'

    # 2. latest 태그 금지
    - name: deny-latest
      match:
        any:
          - resources:
              kinds:
                - Pod
      validate:
        message: "Using 'latest' tag is not allowed"
        foreach:
          - list: 'request.object.spec.[initContainers, containers][]'
            deny:
              conditions:
                any:
                  - key: '{{ element.image }}'
                    operator: Equals
                    value: '*:latest'

    # 3. 서명 검증
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - 'ghcr.io/myorg/*'
          mutateDigest: true
          required: true
          attestors:
            - entries:
                - keyless:
                    url: https://fulcio.sigstore.dev
                    subject: 'https://github.com/myorg/*'
                    issuer: 'https://token.actions.githubusercontent.com'

8. 검증이 admission에서 실제로 도는 순서

위의 YAML은 무엇을 검사할지 선언할 뿐이고, 실제 동작은 admission 요청이 들어온 순간에 일어난다. 파드 생성 요청이 API 서버에 도착하면 Kyverno admission controller가 파드 스펙에서 컨테이너 이미지 참조를 모두 꺼내고, imageReferences 패턴에 걸리는 것만 골라 레지스트리에 서명과 attestation을 요청한다. 여기서 놓치기 쉬운 사실은 이 조회가 클러스터 밖으로 나가는 네트워크 호출이라는 점이다. 정책을 켜는 순간부터 레지스트리는 이미지를 내려받는 경로일 뿐 아니라 파드가 생성될 수 있는지를 결정하는 경로가 된다.

서명이 항상 이미지와 같은 레포지토리에 있는 것은 아니다. 미러링이나 사내 규정 때문에 서명만 따로 모아둔 곳이 있다면 repository로 조회 대상을 바꾼다. 검증자가 여러 개일 때는 attestors.count가 몇 개를 통과해야 하는지 정한다. 문서는 이 값을 지정하지 않으면 모든 attestor를 검증한다고 명시한다. 이 차이는 키 교체 때 그대로 드러난다. 옛 키와 새 키를 둘 다 entries에 넣고 count를 1로 두면 어느 쪽으로 서명된 이미지든 통과하므로 교체 기간을 무중단으로 넘길 수 있지만, count를 빼면 두 키 모두로 서명된 이미지만 통과해서 정반대의 결과가 나온다.

verifyImages:
  - imageReferences:
      - 'ghcr.io/myorg/*'
    skipImageReferences:
      - 'ghcr.io/myorg/legacy-*'
    repository: 'ghcr.io/myorg/signatures' # 서명을 다른 레포에서 조회
    required: true
    verifyDigest: true
    mutateDigest: true
    attestors:
      - count: 1 # entries 중 1개만 통과하면 됨(키 교체 기간)
        entries:
          - keys:
              publicKeys: |-
                -----BEGIN PUBLIC KEY-----
                (현재 키)
                -----END PUBLIC KEY-----
          - keys:
              publicKeys: |-
                -----BEGIN PUBLIC KEY-----
                (교체 예정 키)
                -----END PUBLIC KEY-----

같은 이미지가 반복해서 들어올 때마다 레지스트리를 왕복하면 admission 지연이 그대로 배포 지연이 된다. Kyverno는 검증 결과를 TTL 캐시에 담아 이 비용을 줄인다. 정책 필드가 아니라 설치 수준 설정이고, 기본값은 캐시 활성화 true, 최대 키 개수 1000, TTL 60m이다. 크기와 TTL은 0을 주면 기본값으로 되돌아간다. 이 캐시 덕분에 레플리카 스무 개짜리 롤아웃에서 레지스트리 왕복 비용을 실제로 내는 것은 처음 한 번뿐이다. 성능을 재볼 때 두 번째 측정이 첫 번째보다 훨씬 빠르게 나오는 이유도, TTL이 만료된 다음 날 첫 배포만 유독 느린 이유도 여기에 있다. 캐시를 끄고 admission 지연을 재면 그 값이 레지스트리가 죽었을 때 겪게 될 최악의 모습에 가깝다.

# Kyverno 설치 수준 설정 — 정책 필드가 아니다
imageVerifyCacheEnabled: true # 기본 true
imageVerifyCacheMaxSize: 1000 # 기본 1000, 0을 주면 기본값
imageVerifyCacheTTLDuration: 60m # 기본 60m, 0을 주면 기본값

이 글 첫 예제가 webhookTimeoutSeconds: 30을 준 것은 우연이 아니다. 이 필드는 해당 정책을 적용하는 데 허용되는 최대 시간이고, 문서 기준 기본값은 10초이며 1에서 30초 사이만 허용된다. 레지스트리 왕복이 들어가는 정책은 기본 10초로는 부족해지는 순간이 온다. 시간이 초과되었을 때 무슨 일이 벌어지는지는 failurePolicy가 결정한다. 기본값 Fail이면 요청이 거부되고, Ignore면 검증 없이 통과한다. 레지스트리가 느려졌을 때 배포 전체가 멈출 것인가 검증이 조용히 사라질 것인가가 이 한 필드에 달려 있다. 다만 두 필드 모두 1.13부터 deprecated로 표시되어 webhookConfiguration.timeoutSecondswebhookConfiguration.failurePolicy로 이동한다. 쓰고 있는 버전의 문서에서 어느 쪽이 실제로 읽히는지 먼저 확인하는 편이 낫다.

mutateDigest: true가 롤아웃에 미치는 영향은 켜 보면 바로 눈에 띈다. 정책이 태그를 다이제스트로 바꿔 쓰기 때문에 Deployment를 올리고 나면 파드 스펙에 남는 것은 태그가 아니라 @sha256:으로 시작하는 고정된 참조다. 같은 태그를 레지스트리에서 덮어써도 이미 떠 있는 파드는 물론이고 스케일 아웃으로 새로 뜨는 파드까지 처음 고정된 이미지를 쓴다. 이것이 원래 의도한 불변성이지만, 태그를 밀어 놓고 파드를 지워서 새 이미지를 받게 하던 파이프라인은 이 시점부터 조용히 아무 일도 하지 않는다. mutateDigest를 켠 다음 날 가장 먼저 들어오는 문의가 대체로 이것이다.

나머지 세 필드는 역할이 분명하다. required는 매칭된 이미지가 전부 검증을 거쳤음을 강제하고, verifyDigest는 다이제스트 사용 자체를 강제하며, skipImageReferences는 매칭에서 빼는 패턴 목록이다. 예외를 만들겠다고 정책을 통째로 복제하지 말고 여기에 적는 편이 관리하기 쉽다. 그리고 imageReferences에는 변수 보간이 들어가지 않는다. 문서가 명시적으로 못 박아 둔 제약이라, 네임스페이스 라벨에 따라 허용 레지스트리를 바꾸는 식의 정책은 이 필드만으로는 만들 수 없고 정책을 나누거나 별도의 validate 규칙으로 처리해야 한다.


9. 처음부터 끝까지: 서명하고, 정책을 걸고, 막히는 것을 본다

시작은 클러스터가 아니라 로컬이다. cosign으로 키 쌍을 만들고 이미지에 서명한 다음, 같은 키의 공개 부분으로 검증까지 손으로 통과시켜 본다. 여기서 실패하면 정책을 아무리 고쳐도 통과하지 않는다. attestation을 같이 쓸 계획이라면 predicate 파일을 붙여 attest하고 verify-attestation으로 확인하는 단계까지 로컬에서 끝내 둔다.

cosign generate-key-pair
cosign sign --key cosign.key ${IMAGE}
cosign verify --key cosign.pub ${IMAGE}

# attestation까지 검증할 계획이라면
cosign attest --key cosign.key --predicate <file> --type <predicate type> ${IMAGE}
cosign verify-attestation --key cosign.pub --type <type> ${IMAGE}

다음은 정책을 클러스터에 넣기 전에 확인하는 단계다. kyverno apply는 정책과 리소스를 로컬에서 맞물려 돌린다. 이미지 검증 정책은 레지스트리에 붙어야 의미가 있으므로 --registry를 붙이는데, 문서는 이 플래그를 로컬 도커 자격 증명으로 이미지 레지스트리에 접근하는 옵션이라고 설명한다. 결과를 정리해서 보고 싶으면 -t, 규칙 단위까지 펼치고 싶으면 --detailed-results, 리포트 형태로 받고 싶으면 -p를 쓴다. 실패나 에러가 있으면 명령이 1로 종료하므로 CI 잡에 그대로 걸어두면 서명 없는 이미지가 매니페스트로 들어오는 순간 파이프라인이 멈춘다. 정책이 클러스터에 배포되기 전에 잡히는 것과 배포 후에 잡히는 것의 차이는 크다.

kyverno apply policy.yaml --resource pod.yaml --registry
kyverno apply policy.yaml --resource pod.yaml --registry -t
kyverno apply policy.yaml --resource pod.yaml --registry --detailed-results
kyverno apply policy.yaml --resource pod.yaml --registry --policy-report

# 실패나 에러가 있으면 1
echo $?

로컬에서 기대한 대로 나오면 정책을 올리고, 서명하지 않은 이미지를 일부러 넣어 실제로 막히는지 본다. 거부는 API 서버가 웹훅의 응답을 그대로 되돌려주는 형태로 오고, 메시지에는 어느 정책의 어느 규칙이 막았는지와 실패 사유가 함께 들어간다. 서명이 아예 없으면 signature not found 계열, 서명은 있는데 키가 맞지 않으면 invalid signature 계열의 문장이 붙는다. 이 둘을 구분하는 것이 다음 절 진단의 출발점이다.

kubectl apply -f policy.yaml
kubectl get cpol,pol -A
kubectl apply -f unsigned-pod.yaml

10. 실패 사례와 진단 순서

증상은 대부분 하나로 수렴한다. 파드가 안 뜬다. 그런데 원인은 다섯 갈래로 갈라지고, 순서를 지키지 않으면 엉뚱한 곳을 오래 판다. 진단은 항상 바깥에서 안으로, 즉 정책이 돌기는 하는지부터 확인하고 서명 자체로 내려간다.

첫 번째는 정책이 실제로 평가되고 있는지다. 파드가 통과했다고 정책이 잘 도는 것이 아니라 정책이 안 도는 것일 수 있다. 파드 상태를 보고, 정책 목록의 ready 열이 전부 true인지 보고, 웹훅이 등록되어 있는지 본다. Kyverno는 두 종류의 웹훅으로 등록되며, mutateDigest를 쓰는 이미지 검증 정책은 mutating 쪽에도 걸린다.

kubectl -n kyverno get po
kubectl get cpol,pol -A
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations

두 번째는 서명이 없는 것인지 맞지 않는 것인지의 구분이다. 앞 절의 cosign verify를 그대로 손으로 돌려 본다. 손으로도 실패하면 클러스터 문제가 아니라 빌드 파이프라인 문제이고, 손으로는 되는데 클러스터에서만 실패하면 그때부터 Kyverno 쪽을 본다. 이 한 번의 확인이 절반의 사례를 걸러낸다.

세 번째는 레지스트리 인증이다. Kyverno 자신이 프라이빗 레지스트리를 읽지 못하면 서명이 멀쩡히 있어도 없는 것처럼 보인다. 파드가 이미지를 잘 받아오는 것과 Kyverno가 서명을 조회하는 것은 완전히 별개의 자격 증명 경로라는 점이 함정이다. 문서는 kyverno 네임스페이스에 docker-registry 시크릿을 만들고 Kyverno 디플로이먼트에 --imagePullSecrets로 넘기는 방법을 안내한다. 정책 단위로 다른 자격 증명을 쓰려면 imageRegistryCredentials가 있다. 사내 CA로 서명한 레지스트리라면 인증서 신뢰 문제가 따로 있고, 문서는 global.caCertificates.data로 인증서 저장소를 교체하거나 global.caCertificates.volume으로 호스트 인증서를 마운트하는 두 가지를 제시한다.

네 번째는 attestor 불일치다. 서명은 있고 조회도 되는데 검증만 실패하는 경우로, keyless에서 특히 잦다. subject와 issuer는 워크플로 경로와 태그까지 정확히 맞아야 하며, 릴리스 워크플로 파일 이름을 바꾸거나 태그 규칙을 바꾸면 그 순간부터 전부 막힌다. 그리고 attestors.count를 지정하지 않으면 entries 전부가 통과해야 한다는 점을 다시 확인한다. 키를 하나 추가했는데 갑자기 전부 실패한다면 십중팔구 이것이다.

다섯 번째는 Rekor 도달 불가다. 폐쇄망이거나 아웃바운드 프록시가 있는 환경에서 투명성 로그 조회가 막히면 서명과 키가 완벽해도 검증이 끝나지 않는다. rekorignoreTlog를 true로 두면 투명성 로그 검증을 건너뛰고, ctlogignoreSCT를 true로 두면 SCT 검증을 건너뛴다. 다만 이것은 우회가 아니라 검증 범위를 줄이는 선택이므로, 무엇을 포기했는지 정책 주석에 남겨 두는 편이 좋다.

여기까지 와도 안 잡히면 로그를 키운다. -v=4는 변수 치환 과정을 보여주고, -v=6이 최대 상세도이며, dumpPayload=true는 AdmissionReview 전문을 찍는다. 마지막 옵션은 로그 양이 상당하니 재현 직전에 켜고 바로 끄는 편이 낫다. 그리고 정책이 API 서버 자체를 막아 아무것도 배포할 수 없는 최악의 상황이 오면, 컨트롤러를 0으로 줄이거나 웹훅 설정을 지워서 클러스터를 먼저 되살린 다음 원인을 본다. 이 두 명령은 클러스터의 정책 강제를 잠시 통째로 끄는 것이므로, 되살린 뒤 반드시 원복해야 한다.

kubectl -n kyverno edit deploy kyverno-admission-controller
kubectl -n kyverno logs <pod_name> -f

# 최후의 수단 — 정책 강제가 잠시 통째로 꺼진다
kubectl scale deploy kyverno-admission-controller -n kyverno --replicas 0
kubectl delete validatingwebhookconfiguration kyverno-resource-validating-webhook-cfg
kubectl delete mutatingwebhookconfiguration kyverno-resource-mutating-webhook-cfg

11. 언제 쓰지 않나

미러 레지스트리에 이미지만 복사하고 서명 아티팩트를 함께 옮기지 않았다면 이 정책은 예외 없이 전부 실패한다. 폐쇄망으로 이미지를 들여올 때 가장 흔히 벌어지는 일이다. 이미지는 사내 레지스트리에 있는데 서명은 원본 레지스트리에만 남아 있으므로, repository로 서명 위치를 따로 지정하거나 미러링 파이프라인이 서명까지 옮기도록 고치기 전에는 Enforce를 켜면 안 된다. 여기에 Rekor 조회까지 필요한 keyless 구성이라면 외부 접근이 차단된 환경에서는 애초에 성립하지 않는다.

내가 만들지 않은 이미지에도 쓰기 어렵다. 서드파티 이미지는 서명을 하지 않거나, 하더라도 내가 신뢰 관계를 맺지 않은 키로 한다. 이 경우 skipImageReferences로 빼거나 정책을 나누게 되는데, 예외 목록이 실제로 도는 이미지의 절반을 넘어가면 그 정책은 보안을 주는 것이 아니라 보안이 있다는 착각을 준다. 그 상태라면 차라리 허용 레지스트리 검사만 남기고 서명 검증은 내부 빌드 산출물에만 거는 편이 정직하다.

개발 클러스터에 처음부터 Enforce로 거는 것도 피해야 한다. 서명 파이프라인이 아직 모든 팀에 깔리지 않은 상태에서 강제 모드를 켜면 배포가 통째로 막히고, 결국 정책이 아니라 정책을 만든 사람이 병목이 된다. Audit로 며칠 돌려 리포트를 보고, 걸리는 이미지 목록이 예상과 일치할 때 Enforce로 올린다.

마지막으로, 레지스트리를 admission 경로의 의존성으로 만든다는 사실 자체가 비용이다. failurePolicy가 기본값 Fail이면 레지스트리가 느려지는 동안 파드 생성이 막히고, Ignore로 두면 그동안 검증이 없는 것과 같다. 문서는 Ignore가 레지스트리 장애로 작업이 막히지 않게 해주며 이미지가 이미 노드에 있는 상황에서 유용하다고 설명하지만, 그 말은 곧 장애 구간에 서명 없는 이미지가 들어올 수 있다는 뜻이기도 하다. 어느 쪽도 공짜가 아니고, 이 선택은 보안팀이 아니라 서비스 가용성을 책임지는 쪽과 같이 해야 한다.


12. 참고 자료


13. 정리

Kyverno 이미지 검증의 핵심:

  1. cosign 서명 검증: 정적 키, Keyless(Fulcio), KMS 지원
  2. Attestation 검증: in-toto, SLSA provenance 조건 기반 검증
  3. SBOM 검증: CycloneDX/SPDX attestation 내 취약 컴포넌트 확인
  4. 다이제스트 변환: mutateDigest로 이미지 불변성 보장
  5. 종합 정책: 레지스트리 제한 + 태그 정책 + 서명 검증 결합
  6. 운영 파라미터: 검증 캐시, webhookTimeoutSeconds, failurePolicy가 admission 지연과 장애 시 동작을 결정
  7. 진단 순서: 정책 동작 확인 → 서명 존재 여부 → 레지스트리 인증 → attestor 일치 → Rekor 도달성

다음 글에서는 Kyverno와 OPA/Gatekeeper의 비교 분석을 다룹니다.

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다