CI/CD 파이프라인 · 파이프라인의 비밀 취급 · 이론
비밀은 로그와 레이어에 남는다
한 줄 요약
파이프라인에서 비밀이 새는 자리는 거의 정해져 있다. 빌드 로그, 이미지 레이어와 설정 이력, 저장소 커밋 이력, 산출물 네 곳이다. 그리고 한 번 샌 비밀에 대한 대응은 지우는 것이 아니라 폐기하고 새로 발급하는 것이다.
왜 이게 필요했나
비밀을 다루는 실수는 대개 악의가 아니라 편의에서 나온다. 디버깅하려고 환경 변수를 통째로 찍었고, 빌드 인자로 토큰을 넘기는 것이 가장 간단했고, 설정 파일에 값을 넣은 채로 커밋했다. 각각은 한 사람이 몇 분 아끼려고 한 일인데, 결과는 그 값을 읽을 수 있는 사람의 범위가 통째로 넓어지는 것이다.
네 자리를 하나씩 보자.
- 빌드 로그. 로그는 대개 조직 안에서 널리 읽히고, 오래 남고, 검색된다. 값을 찍는 명령 한 줄이면 충분하다.
- 이미지 레이어와 설정 이력. 이미지는 파일 시스템만이 아니라 어떻게 만들어졌는지 도 함께 담는다. 빌드 중간에 파일로 썼다가 나중 단계에서 지워도 앞 레이어에는 그대로 남는다.
- 저장소 커밋 이력. 커밋해 버린 값은 다음 커밋에서 지워도 이력에 남는다. 그 사이에 복제해 간 사본, 포크, 캐시에도 남는다.
- 산출물. 빌드가 만든 설정 파일, 시험 보고서, 덤프 안에 값이 섞여 들어간다. 산출물은 로그보다 더 오래 보관되는 경우가 많다.
마스킹은 대책이 아니라 최후의 방어선이다
CI 플랫폼들은 로그에서 알려진 비밀 값을 가려 준다. 도움은 되지만 대책으로 삼으면 안 된다. 이유는 단순하다. 마스킹은 아는 문자열과 똑같은 것 만 가릴 수 있다. 값이 조금이라도 변형되면 그대로 보인다.
- base64 로 인코딩해 찍으면 다른 문자열이라 가려지지 않는다. GitHub 문서도 base64 는 이진을 텍스트로 바꿀 뿐 암호화의 대체물이 아니라고 못 박는다.
- JSON 이나 URL 안에 끼어 들어가며 이스케이프되면 다른 문자열이 된다.
- 여러 줄짜리 값이 줄 단위로 쪼개져 출력되면 각 줄은 등록된 값과 다르다.
- 비밀에서 파생된 값(서명, 해시, 토큰 교환 결과)은 애초에 등록되어 있지 않다.
그래서 순서는 이렇다. 먼저 찍지 않게 만들고, 마스킹은 실수를 줄여 주는 마지막 그물로 둔다. GitHub 은 자체 비밀이 아닌 민감한 값도 ::add-mask:: 로 등록하라고 권하는데, 이것도 같은 한계를 그대로 안고 있다. 그리고 포크 저장소에서 촉발된 워크플로에는 GITHUB_TOKEN 을 제외하고 비밀이 전달되지 않는다. 이것은 마스킹보다 훨씬 강한 보호이고, 경계를 나누는 쪽이 가리는 쪽보다 낫다는 원칙을 그대로 보여 준다.
빌드 인자와 런타임 비밀
Docker 공식 문서의 문장이 결론을 그대로 적어 준다. "빌드 인자와 환경 변수는 빌드에 비밀을 넘기기에 적절하지 않다. 최종 이미지에 남기 때문이다." 값을 쓰고 지워도 이미지가 담고 있는 빌드 기록에 남는다.
대신 쓰는 것이 빌드 시점의 비밀 마운트다.
# 값은 그 RUN 명령이 도는 동안에만 존재하고, 레이어에 남지 않는다RUN --mount=type=secret,id=npmtoken \ NPM_TOKEN="$(cat /run/secrets/npmtoken)" npm ci기본 마운트 위치는 /run/secrets/<id> 이고, 그 명령이 끝나면 사라지며 이미지 레이어에 보존되지 않는다.
런타임 비밀은 또 다른 이야기다. 실행할 때 필요한 값은 이미지가 아니라 실행 환경이 준다. 환경 변수로 주면 편하지만, 프로세스 목록과 자식 프로세스, 오류 보고, 덤프에 함께 실린다. 파일로 주는 편이 대개 낫고, 그때는 두 가지를 정한다. 권한(그 프로세스만 읽을 수 있게 좁히고 디렉터리 권한까지 본다)과 수명(언제 사라지는가, 회전하면 다시 읽는가).
짧은 수명이 구조적으로 낫다
수명이 긴 자격증명은 "언제 샜는지 모른다" 는 문제를 안고 있다. 몇 달 전에 로그로 새어 나간 토큰이 지금도 유효하다. 수명이 짧은 자격증명은 새어 나간 값의 쓸모에 시효를 건다. 유출을 못 막는 대신 피해 창을 몇 분으로 줄인다.
같은 이유로 권한 범위도 좁게 준다. 파이프라인이 읽기만 필요하면 읽기만 준다. 이것은 유출을 막는 조치가 아니라 유출됐을 때의 피해를 정하는 조치다.
유출 뒤의 대응은 폐기와 재발급이다
여기서 가장 자주 틀린다. 값이 커밋에 들어갔을 때 사람들은 이력을 고쳐 지우려 한다. 이력 수정은 이미 복제된 사본, 포크, 캐시, 로그, 백업에는 아무 영향을 주지 않는다. 그 사이에 값을 읽은 사람이 있었는지 알 수 없다는 것이 핵심이다.
그래서 순서는 언제나 같다. 먼저 그 자격증명을 폐기한다. 새 값을 발급한다. 쓰는 곳을 바꾼다. 그다음에야 이력 정리와 재발 방지를 이야기한다. 이력에서 지우는 일은 첫 단계가 아니고, 대개 가장 덜 중요한 단계다.
현장에서 만나는 모습
- 실패를 조사하려고 환경 변수를 통째로 찍는 디버그 단계가 남아 있다. 사고를 만들 준비가 끝난 상태다. 필요하면 키 이름만 찍고 값은 찍지 않는다.
- 이미지를 뜯어보면 빌드 인자로 넘긴 토큰이 그대로 나온다. 값은 이미 폐기 대상이다.
- 비밀을 파일로 주면서 권한을 넓게 열어 두어, 같은 파드의 다른 프로세스가 읽을 수 있다.
- 비밀이 실수로 커밋됐을 때 "이력을 정리했으니 괜찮다" 고 정리된 회고. 폐기하지 않았다면 괜찮지 않다.
참고
- Docker 빌드 비밀: https://docs.docker.com/build/building/secrets/
- Dockerfile 참조: https://docs.docker.com/reference/dockerfile/
- GitHub Actions 비밀 사용: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions
- GitHub Actions 워크플로 명령: https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
- GitLab CI/CD 변수: https://docs.gitlab.com/ci/variables/
다음 실습에서 할 것
비밀이 새는 네 자리를 셸과 git, skopeo 로 하나씩 재현하고 막는다. 먼저 환경 변수를 찍는 단계를 넣어 로그에 값이 남는 것을 확인하고, 마스킹 흉내를 낸 필터를 붙인 다음 base64 로 바꿔 찍어 그 필터를 그대로 통과시키는 반례를 만든다. 그다음 /opt/images 의 oci-archive 를 skopeo inspect --raw 로 열어 이미지가 파일 시스템만이 아니라 설정과 이력을 함께 담는다는 것을 눈으로 확인한다. 커밋 이력 쪽은 값을 커밋했다가 지운 뒤 옛 커밋에서 그대로 꺼내 보는 것으로 다룬다. 마지막에는 비밀 파일의 권한과 수명을 검사하는 스크립트와, 유출 대응 절차를 폐기부터 적은 점검표를 만든다.