Tech Artifacts/K8s Security

K8s Security - 08, mtls supply-chain

전송 구간 암호화와 이미지 서명이 막는 다른 위협 — mTLS와 공급망 보안

한 팀이 두 가지 사고를 겪었다. 첫째, 같은 클러스터 안의 두 서비스가 평문으로 통신하고 있었는데 노드가 탈취됐다. 공격자는 그 노드에서 패킷을 가로채 비밀을 빼냈다. "내부니까 안전하다"는 가정이 무너진 순간이었다. 둘째, 같은 팀이 배포 파이프라인을 점검하다가 레지스트리의 이미지가 빌드한 것과 다르다는 걸 발견했다. 중간에 누군가 바꿔치기한 것이다. 두 사고는 전혀 다른 공격 경로에서 왔고, 그래서 방어도 다른 두 축이 필요하다 — 전송을 암호화하는 mTLS와, 이미지 출처를 증명하는 서명(cosign/Sigstore). 이 글은 이 두 축이 각각 무엇을 막고, 어떻게 공급망 보안의 한 벽돌이 되는지를 따진다.

전송과 출처는 다른 공격 경로다

세 가지 위협이 자주 뒤섞이므로 먼저 갈라놓는다.

막는 위협 언제
mTLS 패킷 도청·변조·위장 통신이 이동 중일 때
이미지 서명 위조 이미지 배포 이미지가 빌드→레지스트리→노드로 흐를 때
SBOM + 스캔 알려진 취약점 포함 이미지 이미지 안의 구성 요소에 CVE가 있을 때

한 축만으로 다른 축의 위협은 못 막는다. mTLS를 켠다고 위조 이미지가 걸리지 않고, 이미지에 서명한다고 통신 도청이 막히지 않는다. 그래서 "공급망 보안"이란 세 축이 각자 자기 공격 경로를 담당하는 일이다.

mTLS — 내부 통신을 왜 암호화하는가

mTLS의 핵심은 서버만 인증하는 일반 HTTPS와 달리 양쪽이 서로 인증서로 상대를 인증한다는 점이다. 서비스 A가 서비스 B를 호출할 때 B는 A의 인증서를, A는 B의 인증서를 검증한다. 도청자는 암호문만 보고, 변조를 시도하면 TLS 검증에서 거부된다.

왜 "내부" 통신까지 암호화해야 하나. 노드가 하나라도 탈취되면 그 노드에서 돌아가는 패킷 가로채기는 공짜다. "클러스터 내부는 믿을 수 있다"는 성벽 모델(castle-and-moat)의 전제가 깨지는 순간이 바로 노드 침해다. PCI-DSS·HIPAA 같은 규정과 zero-trust 모델이 내부 mTLS를 요구하는 이유가 여기에 있다.

서비스 메시(Istio·Cilium)가 mTLS를 다루는 방식은 "앱이 모르게"다. 사이드카나 eBPF가 트래픽을 가로채 mTLS를 대신 종료·설정하므로 앱 코드는 바뀌지 않는다. Istio에선 PeerAuthentication 하나로 namespace 전체의 mTLS 모드를 정한다. (Istio docs - Security)

# Istio — namespace 기본 mTLS 강제
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: app
spec:
  mtls:
    mode: STRICT        # PERMISSIVE(점진 전환) / STRICT(강제)

PERMISSIVE는 평문과 mTLS를 둘 다 받아들이는 전환기용 모드이고, STRICT는 mTLS만 허용한다. Istio는 또 클라이언트가 mTLS를 지원하면 자동으로 mTLS로 올리는 자동 mTLS 기능이 있어, 서버측 STRICT 없이도 대부분의 내부 트래픽이 암호화된다.

인증서 생명주기 — 짧은 수명과 자동 갱신이 곧 보안이다

mTLS가 작동하려면 양쪽이 서로의 인증서를 신뢰해야 한다. 그런데 인증서는 만료된다. 서비스가 수백 개인 클러스터에서 이걸 사람이 관리할 수는 없다. 메시의 컨트롤 플레인(Istio의 Istiod 등)이 이 생명주기를 자동화한다.

flowchart LR
    CP["컨트롤플레인<br/>(Istiod / SPIRE)"] -->|"발급"| S1["서비스1 인증서"]
    CP -->|"발급"| S2["서비스2 인증서"]
    S1 -. 만료 임박 .-> CP
    CP -->|"자동 갱신"| S1
    S1 == "mTLS<br/>(인증서 상호 검증)" ==> S2

메시가 발급하는 워크로드 인증서 수명은 보통 수 시간에서 24시간이다. 이렇게 짧은 이유는 침해 시 노출 창을 최소화하기 위해서다. 인증서가 탈취돼도 몇 시간 뒤면 만료한다. 1년짜리 인증서는 한 번 탈취되면 취소되기 전까지 계속 유효하므로 위험이 크다. 이것이 07장의 cert-manager(앱용 인증서, 보통 90일 갱신)와 메시 mTLS 인증서가 다른 점이다 — 메시 쪽이 더 짧고 더 자동이다.

이미지 서명 — 배포되는 것이 정말 빌드한 것인가

배포 파이프라인이 이미지를 빌드해 레지스트리에 푸시한다. 그 사이에 이미지가 바뀌면, 팀이 푸시한 것과 클러스터가 받는 것이 달라진다. cosign(Sigstore 프로젝트)이 이를 막는다 — 이미지에 서명하고, 클러스터 측에서 그 서명을 검증한다. (Sigstore / cosign)

# 빌드 시 서명 (키 기반)
cosign sign --key cosign.key myregistry/app:v1.2.3
# 배포 시 검증 (클러스터 어드미션 또는 별도 컨트롤러)
cosign verify --key cosign.pub myregistry/app:v1.2.3

서명만 하면 끝이 아니다. 클러스터 어드미션 단(04장의 ValidatingAdmissionPolicy, 또는 Sigstore policy-controller)에서 검증을 통과한 이미지만 배포하도록 정책을 걸어야 서명이 의미를 갖는다. (Kubernetes docs - Admission controllers)

flowchart LR
    BUILD["CI: 이미지 빌드"] --> SIGN["cosign 서명"]
    SIGN --> REG["레지스트리<br/>(이미지 + 서명)"]
    REG -. 배포 .-> ADM["클러스터 어드미션"]
    ADM -->|"서명 검증"| POLICY{"정책"}
    POLICY -->|통과| DEPLOY["배포"]
    POLICY -->|실패| DENY["거부"]

keyless 서명 — 키 관리 부담을 없앤 방식

cosign엔 두 모드가 있다. 키 기반(cosign.key/.pub 쌍으로 서명·검증)은 단순하지만 비밀키 관리·유출이 부담이다. 최근 강조되는 keyless 모드는 서명키 자체가 없다. 대신 OIDC 신원(GitHub Actions 워크플로 ID 등)으로 서명한다. (Sigstore - Keyless signing overview)

keyless가 작동하는 과정:

  1. cosign이 임시 공개/개인키 쌍을 메모리에서 만든다.
  2. OIDC ID 토큰(GitHub Actions면 https://token.actions.githubusercontent.com 발급)을 가져온다.
  3. Fulcio(Sigstore의 공개 CA)가 OIDC 토큰을 검증하고, 그 신원을 SAN에 넣은 단기 서명 인증서를 공개키에 묶어 발급한다.
  4. 인증서로 아티팩트에 서명한다.
  5. 서명·인증서·해시를 Rekor(변경 불가 투명 로그)에 타임스탬프와 함께 기록한다.
  6. 임시 개인키는 즉시 파기하고, 인증서는 수분 안에 만료한다.

이 과정이 신뢰를 만드는 두 기둥은 Fulcio와 Rekor다. Fulcio는 "이 서명은 이 OIDC 신원(예: 이 GitHub 워크플로)이 했다"는 인증서를 발급하고, Rekor는 그 서명 이벤트를 누구나 검증 가능한 변경 불가 로그에 남긴다. 비밀키 유출 우려도, 키 순환 부담도 없다. "이 이미지가 언제 누구에 의해 서명됐나"를 제3자가 독립적으로 확인할 수 있다는 게 keyless의 핵심 혁신이다.

SBOM + 취약점 스캔 — 이미지 안의 구성 요소

서명은 이미지가 원본임을 증명할 뿐, 그 원본이 안전하다는 뜻은 아니다. 원본 이미지 안의 오래된 log4j에 CVE가 있으면 서명된 채로 배포된다. SBOM(Software Bill of Materials)이 이미지의 구성 요소 목록을 만들고, Trivy·Grype 같은 도구가 그 목록을 CVE 데이터베이스와 대조해 취약점을 찾는다. (Trivy)

가치는 단발 스캔이 아니라 지속 매칭에 있다. 이미지를 빌드할 때 SBOM을 만들어 두면, 나중에 새 CVE가 발표됐을 때 "내 클러스터의 이미지 중 이 취약 버전을 쓰는 게 있나"를 SBOM 기반으로 즉시 답할 수 있다. 매칭되면 알림 → 패치된 이미지로 재빌드. 이 "SBOM + CVE DB + 자동 매칭" 루프가 공급망 보안의 운영 사이클이다.

SLSA — 공급망 성숙도를 등급으로 따지다

SLSA(Supply-chain Levels for Software Artifacts)는 공급망 보안의 성숙도를 등급으로 매긴 프레임워크다. 주의할 점 — 예전 문서에서 자주 보이는 "SLSA 1-4"는 v1.0(2023)에서 폐기됐다. 현재 스펙은 빌드 무결성에 집중한 Build 트랙 L0-L3로 개편됐다. (SLSA - Build track)

Build 레벨 요구 사항 막는 것
L0 (없음)
L1 provenance(빌드 출처 증명)가 존재 실수·문서화 부족
L2 호스티드 빌드 플랫폼이 provenance를 서명 빌드 변조
L3 강화된 빌드 플랫폼(빌드 간 격리, 서명키 보호) 빌드 변조

구 SLSA 1-4가 대략 Build L1-L3로 옮겨갔고, 구 L4(재현 가능 빌드 등)는 제외됐다. 실제 빌더의 수준은 공식 매트릭스를 따른다 — GitHub Actions과 Google Cloud Build는 Build L3, FRSCA는 L2로 알려져 있다. (SLSA - Get started)

도구(Sigstore)와 등급(SLSA)의 관계를 헷갈리면 안 된다. Sigstore(cosign/Fulcio/Rekor)는 provenance에 서명하고 검증하게 해 주는 도구다. "Sigstore를 깔았으니 SLSA L3"가 아니라 — 도구가 L2·L3의 요구(서명된 provenance, 격리된 빌드)를 충족하게 돕되, 등급 자체는 빌드 플랫폼의 격리·신뢰성에 달린다.

SPIFFE — 메시 신원의 표준

메시 mTLS의 "신원"은 인증서의 SAN에 든 URI 하나로 표현된다. 이 형식을 표준화한 게 SPIFFE다. (SPIFFE ID spec)

spiffe://<trust-domain>/<path>

trust domain은 신원을 발급하는 신뢰 영역(예: Istio 기본값 cluster.local)이고, path가 그 안의 워크로드를 식별한다. Istio의 형식은 spiffe://cluster.local/ns/<namespace>/sa/<serviceaccount>다. 이 신원을 담은 문서가 SVID(SPIFFE Verifiable Identity Document)이며, 보통 X.509 인증서의 URI SAN 또는 JWT로 쓰인다.

이 표준화의 의미는 "인증서 = 신원"이 된다는 것이다. 과거엔 인증서가 연결 암호화 수단이었지만, 메시에선 인증서가 서비스 신원 그 자체다. "이 요청은 spiffe://cluster.local/ns/app/sa/web-sa에서 왔다"를 암호학적으로 증명할 수 있다. 이것이 다음 글(09장)에서 신원 기반 인가 정책이 IP가 아닌 신원으로 평가하는 근거가 된다.

mTLS가 막지 못하는 것 — 인가가 그 위에 필요하다

mTLS는 강력하지만 만능이 아니다. 정확히 짚으면:

  • 막는다: 통신 도청, 중간자 변조, 신원 없는 서비스의 위장 접근.
  • 못 막는다: 인증받은 서비스가 악의적으로 행동하는 것(내부자 위협), 앱 자체 취약점, 권한 상승 뒤 합법적 신원으로 접근하는 것.

mTLS는 "통신 채널의 신원"을 보증할 뿐 "통신 내용의 안전"을 주지 않는다. 침해된 web-sa가 mTLS로 합법적으로 db-sa에 접근해 비밀을 빼가는 건 mTLS가 *허용한다 — 신원은 맞으니까. 이걸 막으려면 mTLS(신원) 위에 *인가 정책(AuthorizationPolicy)이 또一层 필요하다. 신원 + 권한이 함께야 심층 방어가 성립한다.

직접 확인하기

# Kubernetes 1.36 — 서명 없는 이미지 배포 시 어드미션이 거부하는지
# (Sigstore policy-controller 또는 cosigned admission 설치·정책 설정 후)
kubectl run x --image=untrusted/app:v1

확인할 것: 정책이 켜져 있으면 서명 없는 이미지가 denied로 거부된다.

# Istio 설치 클러스터에서 mTLS 적용 상태 (Istio 공식 문서 기반 명령)
istioctl authn tls-check app.default.svc.cluster.local

cosign/Sigstore 파이프라인 구축과 Istio 세부 플래그는 각 공식 문서로 실측.

실무 도입의 우선순위

세 축(mTLS·서명·SBOM)을 한 번에 도입하긴 어렵다. 현실적 순서:

  1. etcd 암호화 + 외부 비밀 — 가장 기본. 평문 비밀 제거.
  2. 이미지 서명(cosign) — 공급망. 신뢰된 이미지만 배포하도록 어드미션 정책.
  3. mTLS — 메시 도입 뒤. 서비스 간 암호화.
  4. SBOM + 스캔 — CI에 지속적으로 통합.

"기본 보호 → 공급망 → 통신 암호화 → 지속 스캔"의 점진 강화다. 한 번에 다 끼얹으면 복잡도가 폭발한다.

흔히 묻는 것, 흔히 틀리는 것

오해 정정
"mTLS와 이미지 서명은 같다" 다른 위협(도청 vs 위조). 별개 계층
"내부 통신은 암호화 불필요" 노드 침해 시 도청 가능. zero-trust는 내부 mTLS 요구
"이미지 서명은 빌드만 하면 된다" 클러스터 어드미션 검증이 짝. 둘 다 있어야
"mTLS는 일반 HTTPS와 같다" mTLS는 상호 인증. 서버만 인증하는 HTTPS와 다름
"SBOM은 빌드 후 쓸모없다" 배포 전 스캔의 기반. 새 CVE 발표 시 실시간 매칭
"SLSA 4가 최고 등급이다" 구 SLSA 1-4는 v1.0에서 폐지. 현재는 Build L0-L3
"Sigstore 깔면 SLSA L3" 도구가 요구를 돕될 뿐. 등급은 빌드 플랫폼 격리에 달림

요약 — 이 글의 결론

  • mTLS와 이미지 서명은 다른 위협을 막는 별개 축. mTLS는 통신 도청·변조(전송), 서명은 위조 이미지(출처/무결성), SBOM은 취약 구성 요소.
  • mTLS는 양쪽이 상호 인증. 메시(Istio/Cilium)가 사이드카/eBPF로 앱 몰래 자동화하고, 인증서는 짧은 수명(수 시간) + 자동 갱신이 곧 보안.
  • cosign/Sigstore가 이미지에 서명하고 어드미션이 검증. keyless 모드는 Fulcio(OIDC→단기 인증서) + Rekor(변경 불가 투명 로그)로 키 관리 부담을 없애면서 투명성을 확보.
  • SBOM + 스캔(Trivy/Grype)이 이미지 구성 요소의 CVE를 지속 매칭. 단발이 아니라 새 CVE마다 즉시 영향 평가.
  • SLSA는 공급망 성숙도 등급. 구 "1-4"는 폐지됐고 현재는 Build L0-L3. Sigstore는 그 요구를 돕는 도구일 뿐.
  • SPIFFE(spiffe://trust-domain/path)가 메시 신원을 표준화 — 인증서 = 서비스 신원. 이게 09장 신원 기반 인가의 기반.
  • mTLS는 못 막는 게 있다(내부자 위협). 신원 위에 인가 정책이 또 필요하다.

생각해 볼 문제

  1. 노드가 탈취됐다. mTLS 없는 평문 통신이었다. 공격자가 얻을 수 있는 것은? mTLS가 있었다면?
  2. 빌드는 서명했는데 클러스터 어드미션 검증을 안 켰다. 의미가 있나?
  3. keyless 서명에서 Fulcio가 발급한 인증서가 10분 뒤 만료한다. 그래도 서명이 검증 가능한 이유는? (Rekor의 역할)
  4. 메시 없이 mTLS를 구현하려 한다. 앱 부담은? (언어/프레임워크별)
  5. SBOM으로 CVE를 찾았다. 이미 배포된 이미지엔 그 취약점이 있다. 어떻게 대응하나?
  6. cosign 키 기반 모드의 비밀키가 유출됐다. 공급망 보안에 어떤 영향? keyless였다면 달랐을까?
  7. Istio의 자동 mTLS가 켜져 있는데 PeerAuthenticationSTRICT로 안 설정했다. 보안상 차이는?

참고