A Token Baked Into an Image Cannot Be Taken Back
한국어 원문으로 표시합니다.
한 줄 요약
환경변수는 저장 위치가 아니라 전달 방식입니다. 그리고 이미지 빌드 중에 넣은 시크릿은 지운다고 사라지지 않습니다. 레지스트리에 푸시했다면 이미 유출된 것으로 간주하고 폐기해야 합니다.
왜 이게 필요했나
.env 파일을 로드한 뒤 지우면 안전하다고 생각하기 쉽습니다. 그런데
/proc/<pid>/environ 은 프로세스가 사는 동안 계속 읽힙니다. 같은 호스트에
있는 사이드카, 디버깅용 셸, 크래시 덤프, 모니터링 에이전트가 전부 읽을 수 있는
자리입니다. 환경변수는 값을 프로세스에 건네주는 통로일 뿐, 값을 숨겨 주는 장치가
아닙니다.
이미지 쪽은 더 나쁩니다. ENV 로 넣은 토큰은 이미지 설정에 영구히 남습니다.
docker inspect -f '{{json .Config.Env}}' bad:v1
["NPM_TOKEN=npm_9fA3...", ...]
ARG 로 받아 쓰고 rm ~/.npmrc 로 지우는 방식도 마찬가지입니다. 파일은
하위 레이어에 남아 있고 빌드 히스토리에도 남습니다.
docker history --no-trunc bad:v2 | grep -o 'NPM_TOKEN=[^ ]*'
여기서 나와야 할 결론은 하나입니다. 레지스트리에 푸시했다면 그 토큰은 이미 유출된 것으로 간주하고 폐기해야 합니다. 이미지를 지우는 것으로는 되돌릴 수 없습니다.
어떻게 동작하나
빌드 중에 시크릿이 정말 필요하다면 BuildKit 의 secret mount 를 씁니다.
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
이 마운트는 tmpfs 라서 해당 RUN 이 끝나면 사라지고 레이어에 남지 않습니다. 빌드 인자로 넘기는 방식과 근본적으로 다릅니다.
쿠버네티스 Secret 의 base64 는 암호화가 아닙니다. 인코딩일 뿐이고 etcd 암호화나 외부 시크릿 저장소를 따로 켜야 실제 보호가 시작됩니다.
이미 커밋한 시크릿의 조치 순서는 정해져 있습니다. 폐기 → 영향 조사 → 히스토리 정리. 순서를 바꾸면 안 됩니다. 그리고 히스토리 재작성은 유출을 되돌리는 조치가 아니라 재발을 줄이는 위생 작업입니다. 이미 포크, 로컬 클론, PR 참조, CI 캐시에 복제돼 있기 때문입니다.
현장에서 만나는 모습
로테이션은 운영 절차가 아니라 설계 속성입니다. 코드가 "유효한 키는 정확히 하나"라고 가정하면 로테이션은 반드시 실패합니다. 새 키를 배포하는 순간과 옛 키를 쓰는 클라이언트가 남아 있는 순간이 겹치기 때문입니다. 그래서 서명은 현재 키 하나로, 검증은 유효 키 집합 전체로 설계해야 합니다. 이 원칙이 없으면 로테이션 계획서는 실행되지 않습니다.
탐지 도구에도 한계가 셋 있습니다. 첫째, 사전 커밋 훅은 --no-verify 한 번이면
우회됩니다. 그래서 훅은 편의 장치이고 통제 장치는 서버 쪽 검사입니다. 둘째,
규칙은 AKIA, sk_live_ 처럼 형태가 뚜렷한 값에만 잘 듣습니다. 사내 시스템의
자체 형식 토큰은 잘 안 잡힙니다. 셋째, 스캐너는 코드 저장소만 봅니다. 위키,
이슈 첨부파일, 채팅 로그는 보지 못합니다.
마지막으로 팀에 던져야 할 질문이 하나 있습니다. 지금 이 자격증명이 공개됐다고 가정할 때, 폐기하고 교체해 서비스를 정상화하는 데 몇 분이 걸립니까. 이 질문에 답하지 못하면 시크릿 관리 체계가 아직 없는 것입니다.
파일 마운트도 그냥 놓아두면 안 된다
환경변수에서 파일로 옮기는 것이 개선이지만, 파일이라고 저절로 안전해지지는 않는다. 옮긴 뒤에 확인할 것이 몇 가지 남는다.
권한과 소유자. 파일이 다른 사용자도 읽을 수 있게 놓이면 옮긴 의미가 없다. 컨테이너 안에 사용자가 하나뿐이라 괜찮아 보여도, 사이드카가 같은 볼륨을 마운트하고 있으면 그쪽에서도 읽힌다. 마운트할 때 권한을 명시하고, 사이드카에는 그 볼륨을 마운트하지 않는다.
디스크에 남는가. 시크릿을 담은 볼륨이 메모리 기반이면 노드 디스크에 쓰이지 않지만, 일반 볼륨이면 그대로 남는다. 노드가 폐기될 때 디스크가 지워지는지까지가 이 판단의 범위다.
로그와 덤프. 앞에서 본 대로 값을 읽어 온 코드가 그것을 오류 메시지에 실으면 파일이든 환경변수든 결과는 같다. 그리고 프로세스가 죽으면서 남기는 메모리 덤프에는 읽어 들인 값이 그대로 들어 있다. 덤프를 수집하는 설정이 켜져 있는지, 그것이 어디에 쌓이고 누가 읽을 수 있는지를 함께 봐야 한다.
갱신될 때 무슨 일이 일어나는가. 파일 마운트의 값어치 중 하나가 재시작 없이 갱신된다는 것인데, 애플리케이션이 그 파일을 다시 읽지 않으면 아무 소용이 없다. 앞의 설정 주입 이야기와 같고, 시크릿에서는 더 중요하다. 옛 키가 폐기됐는데 프로세스가 여전히 옛 값을 들고 있으면 그때부터 요청이 전부 실패한다.
정리하면 시크릿 관리는 저장 위치를 고르는 문제가 아니라 수명 주기를 설계하는 문제입니다. 어디에 두는지보다, 누가 읽을 수 있고 언제 바뀌며 바뀐 것을 어떻게 알아채는지가 실제 안전을 결정합니다.
다음 실습에서 할 것
환경변수로 넘긴 시크릿이 docker inspect 에 그대로 보이는 것을 확인하고, 같은
값을 파일 마운트로 옮겨 inspect 에서 사라지는 것을 확인합니다. 그리고 컨테이너를
재시작하지 않은 채 값을 바꿔 두 방식의 로테이션 특성 차이를 직접 재어 본 뒤,
간단한 시크릿 탐지 스크립트를 작성합니다.