컨테이너 안의 비밀이 Zed.log에 남은 이유: Zed 1.18.1의 인자 경계별 마스킹

개발 컨테이너에 GH_TOKEN이나 DATABASE_URL을 넘기는 건 흔한 일이다. 문제는 그 다음이었다. Zed 1.17.2에서는 컨테이너 연결이 끊겼다가 재접속에 실패하면 docker exec 명령 전체가 로그에 남을 수 있었다. -e NAME=VALUE로 전달한 환경변수도 예외가 아니었다. 컨테이너 안에서만 쓸 생각이던 토큰이 로컬의 Zed.log와 버그 보고서까지 따라 나오는 경로가 생긴 셈이다.

Zed는 2026년 9월 4일 공개한 1.18.1에서 이 문제를 고쳤다. 수정의 핵심은 "TOKEN이라는 이름만 찾아 가린다"가 아니다. 명령 인자, 오류 출력, 환경변수 이름 검증을 서로 다른 경계로 다뤘다. 로그를 줄이는 일과 이미 노출된 자격 증명을 폐기하는 일도 분리해야 한다.

요약: 컨테이너 경계와 로그 경계는 다르다

Dev Container 사양의 containerEnv는 컨테이너 전체 프로세스에 환경변수를 설정하고, remoteEnv는 에디터가 시작한 터미널이나 task 같은 도구 프로세스에 값을 전달한다. Zed는 원격 서버를 실행할 때 이 값들을 docker exec -e NAME=VALUE ... 형태의 인자로 구성했다. Docker 공식 CLI도 --env 또는 -e가 실행 프로세스의 환경변수를 설정한다고 설명한다.

여기까지는 기능상 정상이다. 유출은 진단 코드에서 생겼다. 기존 run_docker_command는 Rust의 Debug 형식으로 명령 전체와 실행 결과를 기록했다. 재접속이 실패하면 같은 명령이 오류 메시지에 다시 포함돼 ERROR 로그로도 올라갔다. 제보자가 확인한 재현 조건에서는 한 비밀값이 DEBUG 경로와 실패한 재접속의 ERROR 경로에 각각 노출될 수 있었다.

Dev Container 환경변수가 docker exec의 -e NAME=VALUE 인자를 거쳐 Debug 출력과 reconnect 오류 메시지, Zed.log와 버그 보고서로 이어지는 다섯 단계 유출 경로

Zed 1.17.2의 해당 코드 경로에서는 컨테이너 환경변수 값이 DEBUG 명령 출력과 실패한 재접속의 ERROR 메시지에 남을 수 있었다. 출처: github.com/zed-industries/zed/issues/63569.

컨테이너가 파일시스템과 프로세스를 격리해도 호스트 에디터가 실행 인자를 평문으로 기록하면 그 격리는 로그까지 이어지지 않는다. Zed.log는 사용자가 문제 해결을 위해 확인하거나 버그 보고서에 붙이기 쉬운 파일이다. 토큰을 컨테이너 안에 전달할 권한과 그 값을 진단 자료에 남길 권한은 같은 것이 아니다.

실패 경로에서 두 번 드러난 값

공개 이슈 #63569의 재현 절차는 단순하다. 실제 GH_TOKEN을 가진 개발 컨테이너를 열고, 원격 연결을 끊은 뒤 재시도를 실패시키고, 로컬 로그에서 변수 이름을 찾는다. 제보 환경은 Linux의 Zed 1.17.2였고 Docker Compose 기반 컨테이너를 사용했다. 따라서 "모든 설치에서 항상 유출된다"고 넓혀 말할 근거는 없다. 다만 해당 코드가 Docker 실행 인자를 통째로 포맷했기 때문에 GH_TOKEN만의 문제도 아니었다.

중요한 건 정상 실행보다 오류 처리다. 성공 로그를 깔끔하게 만든 뒤에도 예외 객체가 원본 명령이나 표준 오류를 품고 있으면 상위 호출자가 다시 출력할 수 있다. Zed의 수정 PR은 기존 DEBUG 로그에서 stdout과 stderr 바이트 덤프를 없앴고, 실패 메시지를 만들 때도 명령과 stderr를 각각 정리한다. 출력 위치 하나만 고쳐서는 충분하지 않았던 이유다.

운영자는 로그 수준만 낮추는 방식에 기대면 안 된다. 이번 사례처럼 ERROR 메시지에도 값이 들어가면 production 설정에서 DEBUG를 꺼도 남는다. 반대로 로그 파일 접근 권한만 강화해도 사용자가 파일을 이슈에 첨부하는 순간 복사본이 생긴다. 생성 시점에 민감값을 넣지 않는 게 먼저다.

-e 다음 인자를 따로 읽은 이유

수정된 redact_arguments는 인자 배열을 한 문자열로 합친 뒤 정규식 하나를 돌리지 않는다. 현재 인자가 -e라면 바로 다음 인자를 환경변수 할당으로 취급하고, 첫 = 앞의 이름만 남긴 채 값을 <redacted>로 바꾼다. DATABASE_URL=..., GH_TOKEN=..., PATH=..., 소문자 변수까지 이름의 민감도와 무관하게 모두 가린다.

이 선택은 꽤 보수적이다. PATH는 보통 비밀이 아니지만 값 안에는 사용자명이나 내부 경로가 들어갈 수 있다. 더구나 앞으로 추가될 변수 이름을 누락하지 않는다. 비밀로 보이는 접미사만 찾는 방식은 SESSION, COOKIE, 서비스별 별칭처럼 사전에 모르는 이름에서 새 구멍을 만든다.

인자 경계를 보존하는 것도 중요하다. 먼저 문자열로 합치면 공백, 줄바꿈, 따옴표 때문에 어느 값이 -e에 속했는지 흐려진다. Zed의 test는 줄바꿈이 든 프로그램 인자를 별도 인자로 유지하고 제어문자를 escape하는 경우, 프로그램 문자열과 stderr 안의 GH_TOKEN=... 패턴, Podman CLI 경로까지 다룬다. 직접 전달된 환경변수와 명령 안에 우연히 포함된 할당문은 서로 다른 방법으로 정리한다.

빈 변수 이름이 우회로가 된 이유

마스킹만 추가하면 -e =VALUE가 남는다. Docker CLI는 빈 환경변수 이름을 거부하면서 받은 값을 stderr에 되풀이할 수 있다. 그러면 정상 인자 로그에서는 가렸더라도 오류 출력이 다시 비밀을 내보낸다.

Zed는 containerEnvremoteEnv의 이름이 비어 있거나, = 또는 제어문자를 포함하면 컨테이너를 재사용하거나 build하기 전에 검증 오류로 중단한다. workspace 데이터베이스에서 복원된 remote_env는 같은 조건의 이름을 건너뛴다. 검증 함수는 네 가지 거부 예시를 test로 고정했다. 빈 문자열, NAME=VALUE처럼 등호가 든 이름, 줄바꿈, NUL 문자다.

이 지점에서 마스킹과 입력 검증의 역할이 갈린다. 마스킹은 정상적인 NAME=VALUE가 로그로 나갈 때 값을 숨긴다. 이름 검증은 downstream 도구가 입력을 거부하면서 원문을 반사하는 오류 경로를 막는다. stderr에는 여전히 다른 명령이 출력한 할당문이 섞일 수 있으므로 redact_command도 별도로 적용한다.

환경변수 이름 검증, 인자 경계별 값 마스킹, 명령과 stderr 재검사, 로그 최소화의 네 방어 계층과 패치 밖의 자격 증명 폐기 조치

Zed 1.18.1은 정상 인자만 가리는 데서 멈추지 않고 잘못된 이름, downstream stderr, 상위 ERROR 로그, 과도한 출력까지 서로 다른 경계에서 줄였다. 출처: github.com/zed-industries/zed/pull/63606.

이 패치가 하지 않는 일

Zed 1.18.1로 업데이트한다고 이전 로그에서 토큰이 사라지지는 않는다. 이미 Zed.log, shell history, issue 본문, 첨부 파일, 로그 수집 시스템에 복사된 값은 별개다. 노출 가능성이 있다면 해당 토큰을 폐기하고 새로 발급한 뒤, 공유한 이슈와 첨부 파일에서 흔적을 찾아야 한다. 파일을 지우는 것만으로 원격 복사본까지 회수되지는 않는다.

또 환경변수는 편리한 전달 수단이지 비밀 저장소가 아니다. 컨테이너 내부 프로세스, crash dump, 자식 프로세스, /proc 접근 정책에 따라 값이 보일 수 있다. 이번 수정은 Zed가 만든 Docker 명령과 오류 로그의 특정 노출 경로를 닫는다. 컨테이너에서 실행되는 프로그램이 스스로 토큰을 출력하는 경우까지 막는다고 볼 수 없다.

공개 이슈는 로그 파일이 기본적으로 world-readable이라고 보고했지만, 운영체제와 설치 방식별 파일 모드를 이번 조사에서 독립적으로 재현하지는 않았다. 따라서 이 글의 결론은 "모든 사용자가 다른 로컬 계정에 노출됐다"가 아니라 "민감값이 로컬 진단 로그와 공유 가능한 오류 자료에 평문으로 기록될 수 있었다"로 제한한다.

실전 적용: 로그보다 먼저 자격 증명을 회수한다

Zed에서 Dev Container를 썼고 1.18.1 이전 버전으로 재접속 실패를 겪었다면 먼저 에디터를 업데이트한다. 그다음 Zed.log와 공유한 버그 보고서에서 실제 변수명이 등장하는지 확인한다. 값 자체를 검색 명령에 다시 써서 shell history를 늘리지 말고 GH_TOKEN, DATABASE_URL 같은 이름부터 찾는 편이 낫다.

평문 값이 확인되면 영향받은 자격 증명을 공급자 쪽에서 폐기한다. GitHub token이라면 scope와 최근 사용 기록을 확인하고 재발급한다. 데이터베이스 URL이라면 계정 비밀번호 또는 단기 credentials를 교체한다. CI나 로컬 설정만 새 값으로 바꾸고 이전 토큰을 활성 상태로 남겨 두면 정리는 끝난 게 아니다.

직접 만드는 도구에도 네 가지 test를 넣을 수 있다. 정상 -e NAME=VALUE, 빈 이름 때문에 CLI가 실패하는 경우, stderr가 할당문을 반사하는 경우, DEBUG와 ERROR가 같은 명령을 서로 다른 경로로 기록하는 경우다. 마지막으로 redaction test fixture에 진짜 비밀을 넣지 않는다. test-secret 같은 명백한 가짜 값을 쓰고, 최종 문자열에 원문이 0회인지 확인한다.

이 사건은 "로그에 비밀번호를 쓰지 말자" 정도로 끝나지 않는다. 구조화된 인자를 구조화된 상태에서 가리고, downstream 오류가 값을 반사하기 전에 입력을 검증하고, 상위 오류 경로에서도 다시 정리해야 한다. 그리고 노출이 의심되면 패치보다 자격 증명 폐기가 먼저다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다