KCA — Kyverno 인증 어소시에이트 · 이미지 검증과 공급망 · 이론
서명은 안전하다가 아니라 여기서 나왔다를 증명한다
한 줄 요약
이미지 서명은 그 이미지가 안전하다는 뜻이 아니다. 그 이미지가 특정 파이프라인에서 나왔다는 뜻이다. 취약점이 가득한 이미지도 서명될 수 있고, 그것이 서명의 실패가 아니라 정의다. verifyImages 규칙은 이 출처 증명을 어드미션에서 강제하는 장치다.
왜 이게 필요했나
레지스트리에 이미지가 있다는 사실은 아무것도 보장하지 않는다. 누군가 노트북에서 빌드해 손으로 푸시했을 수도 있고, 태그가 나중에 다른 내용으로 덮어써졌을 수도 있다. CI 파이프라인에 스캔 게이트를 아무리 촘촘히 걸어도, 그 파이프라인을 우회해 푸시한 이미지가 클러스터에 뜰 수 있다면 게이트는 권고에 가깝다.
서명은 이 구멍을 막는다. 파이프라인만 아는 키(또는 파이프라인의 OIDC 신원)로 이미지에 서명하고, 클러스터는 그 서명이 붙은 이미지만 받아들인다. 그러면 스캔 게이트가 실제로 강제된다. 스캔 결과를 강제력으로 바꾸는 지점이 여기다.
어떻게 동작하나
verifyImages 규칙은 파드 스펙에서 이미지 참조를 모두 꺼내 imageReferences 패턴에 걸리는 것만 골라 레지스트리에 서명을 요청한다. 여기서 놓치기 쉬운 사실은 이 조회가 클러스터 밖으로 나가는 네트워크 호출이라는 점이다. 정책을 켜는 순간부터 레지스트리는 이미지를 내려받는 경로일 뿐 아니라 파드가 생성될 수 있는지를 결정하는 경로가 된다.
검증자는 attestors 로 표현한다. 정적 공개키(keys.publicKeys), KMS, 그리고 keyless 세 방식이 있다. keyless 는 Fulcio 가 발급한 단명 인증서로 서명하고 Rekor 투명성 로그에 기록을 남기는 방식인데, 여기서 subject 와 issuer 가 핵심이다. issuer 는 어느 OIDC 제공자인지이고 subject 는 그 안에서 누구인지다. GitHub Actions 라면 subject 가 워크플로 파일 경로와 참조 태그까지 포함하므로, 릴리스 워크플로 파일 이름을 바꾸거나 태그 규칙을 바꾸면 그 순간부터 전부 막힌다. 이 정밀함이 목적이지만 운영 부담이기도 하다.
attestors.count 는 반드시 알아야 하는 함정이다. 지정하지 않으면 entries 의 모든 항목이 통과해야 한다. 키를 하나 추가했는데 갑자기 전부 실패한다면 십중팔구 이것이다. 옛 키와 새 키를 둘 다 넣고 count 를 1로 두면 어느 쪽으로 서명된 이미지든 통과하므로 교체 기간을 무중단으로 넘길 수 있고, count 를 빼면 두 키 모두로 서명된 것만 통과해 정반대 결과가 나온다.
attestation 은 서명 위에 얹는 진술이다. SLSA provenance 는 "이 이미지가 어떤 빌더에서 어떤 소스로부터 나왔다" 를 담고, CycloneDX 나 SPDX SBOM 은 "이 안에 어떤 컴포넌트가 들어 있다" 를 담는다. conditions 로 그 내용을 검사할 수 있어서, 예를 들어 SBOM 안의 특정 라이브러리 버전이 취약 버전과 같지 않을 것을 요구하는 정책을 쓸 수 있다. 서명만 확인하면 누가 빌드했는지는 검사하지 않은 것이라, provenance 조건을 함께 거는 편이 낫다.
플래그 네 개도 정리해 두자. required 는 매칭된 이미지가 전부 검증을 거쳤음을 강제하고, verifyDigest 는 다이제스트 사용 자체를 강제하며, skipImageReferences 는 매칭에서 빼는 패턴 목록이고, mutateDigest 는 태그를 다이제스트로 바꿔 쓴다. 마지막 것이 파이프라인에 미치는 영향이 크다. 켜면 파드 스펙에 남는 것이 태그가 아니라 @sha256: 로 시작하는 고정 참조라, 같은 태그를 레지스트리에서 덮어써도 스케일 아웃으로 새로 뜨는 파드까지 처음 고정된 이미지를 쓴다. 이것이 의도한 불변성이지만, 태그를 밀어 놓고 파드를 지워 새 이미지를 받게 하던 파이프라인은 이 시점부터 조용히 아무 일도 하지 않는다.
성능에는 캐시가 끼어든다. 이미지 검증 결과는 TTL 캐시에 담기고, 이것은 정책 필드가 아니라 설치 수준 설정이다. 기본값은 활성화 true, 최대 키 1000개, TTL 60분이다. 레플리카 스무 개짜리 롤아웃에서 레지스트리 왕복 비용을 실제로 내는 것은 처음 한 번뿐이라, 두 번째 측정이 첫 번째보다 훨씬 빠르게 나오고 TTL 만료 다음 첫 배포만 유독 느리다. 캐시를 끄고 admission 지연을 재야 레지스트리가 죽었을 때 겪을 최악의 모습에 가까운 값이 나온다.
현장에서 만나는 모습
저자의 홈랩 레지스트리는 Harbor 이고 MetalLB 풀에서 받은 10.0.0.202 에 떠 있다. 사내 레지스트리를 쓰는 순간 반드시 만나는 문제가 자격 증명 경로가 둘이라는 점이다. 파드가 이미지를 잘 받아오는 것과 Kyverno 가 서명을 조회하는 것은 완전히 별개의 경로다. 파드는 자기 imagePullSecrets 를 쓰고 Kyverno 는 자기 자격 증명을 쓴다. Kyverno 가 프라이빗 레지스트리를 읽지 못하면 서명이 멀쩡히 있어도 없는 것처럼 보인다.
스캔 쪽 경험도 같은 결론으로 모인다. 어떤 이미지에서 총 1,247건의 취약점이 나왔고 Critical 이 9건이었는데, 수정본이 있는 것만 남기니 4건, 실제 악용이 확인된 목록과 대조하니 1건이었다. 그리고 그 1,247건을 없앤 것은 개별 패치가 아니라 베이스 이미지 교체였다. 애플리케이션이 실제로 링크하는 공유 라이브러리는 여덟 개인데 이미지에는 패키지가 432개 들어 있었기 때문이다. 디스트로리스로 옮기니 OS 패키지 취약점이 0이 되고 남은 것은 애플리케이션의 npm 의존성 두 건뿐이었다. 서명과 스캔은 이렇게 이어진다. 스캔으로 이미지를 얇게 만들고, 통과한 것에 서명을 붙이고, 클러스터가 서명된 것만 받아들이게 하는 것이 한 벌이다.
한 가지 더. 미러 레지스트리에 이미지만 복사하고 서명 아티팩트를 함께 옮기지 않으면 이 정책은 예외 없이 전부 실패한다. 폐쇄망으로 이미지를 들여올 때 가장 흔히 벌어지는 일이고, repository 로 서명 위치를 따로 지정하거나 미러링 파이프라인을 고치기 전에는 Enforce 를 켜면 안 된다.
다음 실습에서 할 것
/root/kca-verify/ 에 정적 키와 keyless 를 함께 쓰는 verifyImages 규칙, SLSA provenance 와 SBOM attestation 규칙, 허용 레지스트리 validate 규칙을 작성한다. 그리고 레지스트리 자격 증명 Secret 과 그것을 매단 ServiceAccount, 다이제스트로 고정된 Deployment 를 실제 클러스터에 올린다.