배포는 새벽 2시에 CI 로그를 온통 빨갛게 물들이며 실패했다. 가장 빠른 답을 찾는 방법은 뻔해 보였다. 로그 전체를 선택해 AI 챗에 붙여넣고 무엇이 문제인지 물어보는 것. 답은 좋았다. 300줄쯤 아래에 있던 누락된 환경 변수를 정확히 짚어냈다.
같은 로그의 40번째 줄은 작업의 시작 배너였고, 이 배너는 해석된 설정값을 그대로 출력했다. 데이터베이스 URL이 비밀번호와 함께 거기 있었다. 결제 서비스의 라이브 시크릿 키도 마찬가지였다. 지난 분기 누군가 추가한 디버그 플래그가 이름이 _KEY로 끝나는 모든 변수를 그대로 찍어냈기 때문이다.
그 붙여넣기는 자격 증명을 공개한다는 느낌과 전혀 달랐다. 동료에게 묻는 것처럼 느껴졌을 뿐이다. 하지만 텍스트는 그 기기를 떠나 제3자의 서버에 도착했고, 이제 개발자가 통제할 수도 완전히 감사할 수도 없는 대화 기록 안에 존재한다. 로그가 깃허브 이슈, 슬랙 스레드, 지라 티켓, 스택 오버플로 질문에 들어갈 때도 같은 일이 벌어진다 — 다만 그런 곳은 대개 훨씬 더 많은 사람이 읽을 수 있다는 차이가 있다.
이 글은 일상적인 디버깅 텍스트에서 실제로 무엇이 유출되는지, 붙여넣은 뒤가 아니라 그 전에 제거하는 방법, 그리고 뒤늦게 알아챘을 때 해야 할 일을 다룬다.
붙여넣기 자체가 유출이다
어시스턴트에게 “키는 무시해줘” 또는 “시크릿은 지워줘”라고 부탁하려는 본능은 순서 자체가 거꾸로다. 모델이 그 지시를 읽는 시점에는 이미 전체 텍스트가 제공업체로 전송된 뒤다. 제공업체의 보관 정책과 학습 정책이 무엇이든, 이제 당신은 그것에 의존하는 처지다. 모델은 이미 보낸 요청을 되돌릴 수 없다.
같은 논리가 개발자가 도움을 구하려고 텍스트를 붙여넣는 모든 곳에 적용된다.
| 텍스트가 향하는 곳 | 이후 누가 읽을 수 있나 |
|---|---|
| AI 챗(ChatGPT, Claude, Gemini, 또는 모델 API를 호출하는 모든 앱) | 제공업체(자체 보관 정책에 따라), 그리고 당신의 대화 기록을 열람할 수 있는 모든 사람 |
| 공개 깃허브 이슈나 디스커션 | 자동 스크레이퍼를 포함한 모든 사람 |
| 비공개 저장소 이슈 | 모든 협업자, 읽기 권한을 가진 모든 통합, 그리고 앞으로 합류할 모든 멤버 |
| 슬랙, 팀즈, 지라, 컨플루언스 | 모든 채널 또는 프로젝트 멤버, 그리고 읽기 권한을 가진 모든 통합 |
| 스택 오버플로, 포럼 | 모든 사람 |
| 페이스트 사이트 | URL을 아는 모든 사람 |
GitGuardian의 State of Secrets Sprawl 2026 리포트는 2025년 한 해 동안 공개 깃허브 커밋에서 새로 발견된 하드코딩 시크릿이 2,865만 건에 달했다고 집계했다. 전년 대비 34% 증가한 수치다. AI 서비스용 시크릿만 따져도 1,275,105건으로 81% 늘었다. 같은 리포트는 사고의 약 28%가 저장소 바깥, 즉 슬랙·지라·컨플루언스 같은 곳에서 전적으로 발생한다는 사실도 밝혔다 — 사람들이 도움을 구하려고 로그를 붙여넣는 바로 그 도구들이다.
대응 수치는 더 나쁘다. GitGuardian이 2022년에 유효하다고 확인한 자격 증명 중 거의 70%가 2025년 1월까지도 여전히 유효했고, 2026년 1월에 다시 검사했을 때도 비율은 여전히 64%를 넘었다. 유출된 키는 저절로 순환(rotate)되는 경우가 거의 없다.
일상적인 디버깅 텍스트에서 무엇이 유출되는가
대부분의 유출은 개발자가 의도적으로 키를 붙여넣어서 일어나지 않는다. 키가 다른 무언가에 실려 함께 딸려오는 것이다.
| 원본 텍스트 | 그 안에 흔히 들어 있는 것 |
|---|---|
.env 파일, docker-compose.yml, Helm values | 서비스가 쓰는 모든 자격 증명, 한 줄에 하나씩, 이름표가 붙은 채로 |
| CI 로그 | 디버그 단계가 덤프한 해석된 환경 변수, Authorization 헤더가 담긴 curl -v 출력, git clone https://user:token@… 형태의 URL |
| 애플리케이션 로그 | Bearer 토큰이 담긴 요청 헤더, 쿼리 스트링 속 JWT, 시작 시 출력되는 연결 문자열 |
| 스택 트레이스 | 예외를 던진 드라이버에 넘겨진 연결 문자열, 혹은 예외 메시지 속 전체 설정 객체 |
| 셸 히스토리 | export OPENAI_API_KEY=…, mysql -p…, psql postgres://user:pass@host/db |
| Terraform plan, Kubernetes manifest | 프로바이더 자격 증명, base64로 인코딩된 Secret 데이터(base64는 인코딩이지 암호화가 아니다) |
~/.ssh, ~/.aws, ~/.config 스니펫 | 평문으로 된 개인 키와 장기 액세스 키 |
자격 증명 자체는 몇 가지 안 되는 형태로 수렴하며, 자동 탐지를 가능하게 하는 것이 바로 이 형태다.
접두사가 붙은 API 키
많은 벤더가 스캐너가 찾아낼 수 있도록 키에 고정된 접두사를 붙인다. 깃허브의 2021년 토큰 형식 변경이 가장 분명하게 문서화된 사례다. 개인 액세스 토큰은 ghp_, OAuth 토큰은 gho_, 사용자-서버 토큰은 ghu_, 서버-서버 토큰은 ghs_, 리프레시 토큰은 ghr_로 시작한다. 깃허브가 밝힌 이유는 “토큰 접두사는 시크릿 스캐닝을 위해 토큰을 식별 가능하게 만드는 명확한 방법”이라는 것이다.
AWS 액세스 키 ID도 비슷하다. IAM 식별자 레퍼런스는 장기 액세스 키에 AKIA를, 임시 STS 자격 증명에 ASIA를 쓴다고 명시한다. 짝이 되는 시크릿 액세스 키에는 접두사가 전혀 없어서 잡아내기가 더 어렵다. AWS 공식 문서의 예시는 wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY로, 다른 어떤 base64 문자열과도 구분되지 않는 40자짜리 문자열이다.
AI 제공업체 키도 같은 발상을 따른다. Anthropic의 API 키 문서는 전체 키가 sk-ant-로 시작한다고 명시한다.
JSON 웹 토큰
JWT는 RFC 7519(2015년 5월)에서 “마침표(’.’) 문자로 구분된 URL 안전 부분들의 시퀀스”로 정의되며, 각 부분은 base64url로 인코딩된다. 헤더와 페이로드는 첫 멤버 이름이 문자로 시작하는 JSON 객체이기 때문에, 둘 다 {" 다음에 문자가 오는 형태로 시작하고, 이는 base64url로 인코딩하면 eyJ가 된다. 그래서 사실상 모든 JWT가 eyJ….eyJ…. 라는 뚜렷한 모양을 갖는다. 세 번째 부분은 비어 있을 수도 있다. RFC 7519는 Unsecured JWT를 alg: none을 사용하고 “JWS Signature 값으로 빈 문자열을 쓰는” 토큰으로 정의한다.
로그 속 JWT는 대개 만료되기 전까지는 살아 있는 세션이거나 API 자격 증명이다. 디코딩은 무해하지만, 붙여넣는 것은 세션을 통째로 넘기는 것이다.
HTTP 인증 헤더
Authorization: Bearer …는 RFC 6750(2012년 10월)에서 왔으며, 토큰을 b64token으로 정의한다. 문자, 숫자, -._~+/로 이루어지고 끝에 =가 붙을 수도 있다. Authorization: Basic …은 RFC 7617(2015년 9월)에서 왔고, 그냥 user-id:password를 base64로 인코딩한 것이다. RFC 7617은 Basic이 그 자체로는 “안전한 사용자 인증 방법이 아니다”라고 분명히 밝힌다. 헤더를 본 사람이라면 누구나 비밀번호를 디코딩할 수 있다.
PEM 개인 키
개인 키는 대개 -----BEGIN … PRIVATE KEY-----와 -----END … PRIVATE KEY----- 사이의 PEM 텍스트 블록 형태로 옮겨진다. RFC 7468(2015년 4월)은 PRIVATE KEY와 ENCRYPTED PRIVATE KEY 라벨을 표준화했다. OpenSSL의 전통적인 형식은 RSA PRIVATE KEY와 EC PRIVATE KEY를 쓰고, OpenSSH는 OPENSSH PRIVATE KEY라고 쓴다. 이 라벨들은 RFC 7468에는 없지만 실제 디스크에 있는 대부분의 키 파일이 담고 있는 형태다. 같은 RFC는 공개해도 안전한 라벨 — CERTIFICATE, CERTIFICATE REQUEST, PUBLIC KEY, X509 CRL — 도 함께 정의한다.
연결 문자열
postgres://app:password@db:5432/app는 비밀번호를 URI의 userinfo 부분에 그대로 담는다. RFC 3986(2005년 1월) 3.2.1절은 이미 “userinfo 필드에서 ‘user:password’ 형식을 쓰는 것은 폐기 대상”이라고 명시하고 있다. 그럼에도 데이터베이스 드라이버, Redis 클라이언트, 메시지 브로커는 여전히 이를 받아들이므로, .env 파일 곳곳에서, 그리고 연결이 실패했을 때의 예외 메시지에서 계속 등장한다.
정해진 형식이 없는 이름 붙은 시크릿
DB_PASSWORD=hunter2, client_secret: …, ?api_key=… — 알아볼 수 있는 형태가 전혀 없고, 오직 옆에 붙은 이름으로만 식별할 수 있는 값들이다.
시크릿 리댁터는 어떻게 동작하는가
시크릿 리댁터는 붙여넣으려는 텍스트를 받아 각 자격 증명을 이름표가 붙은 플레이스홀더로 바꾸고, 나중에 어시스턴트의 답변에 실제 값을 다시 채워 넣는다. 모든 동작은 브라우저 탭 안에서만 일어난다.
1단계: 고정된 26개 규칙 표
탐지는 다섯 개 카테고리로 묶인 26개의 정규식 표에, 엔트로피라는 여섯 번째 카테고리가 더해진 구조다. 모든 카테고리에는 켜고 끄는 토글이 있고, 기본값은 전부 켜짐이다.
| 카테고리 | 매칭 대상 | 플레이스홀더 라벨 |
|---|---|---|
| AI 제공업체 키 | Anthropic sk-ant-…, OpenAI sk-proj- / sk-svcacct- / sk-admin- 및 레거시 형식, Hugging Face hf_…, 그 외 32자 이상의 sk- 키(많은 OpenAI 호환 제공업체가 쓰는 관례) | ANTHROPIC_KEY, OPENAI_KEY, HF_TOKEN, API_KEY |
| 클라우드 & SaaS 토큰 | 깃허브 클래식(ghp_, gho_, ghu_, ghs_, ghr_)과 세분화(github_pat_) 토큰, GitLab glpat-, Slack 토큰과 웹훅 URL, Stripe sk_/rk_ 라이브·테스트 키와 whsec_ 웹훅 시크릿, AWS AKIA/ASIA/ABIA/ACCA 키 ID, aws…secret 이름 옆의 AWS 시크릿 키, Google AIza… API 키와 GOCSPX- OAuth 시크릿, SendGrid, npm, PyPI, Telegram 봇 토큰 | GITHUB_TOKEN, AWS_ACCESS_KEY, STRIPE_KEY, … |
| JWT & 인증 헤더 | JWT(eyJ….eyJ….…, 빈 서명 포함), Authorization: Basic …, Bearer … | JWT, BASIC_AUTH, BEARER_TOKEN |
| 개인 키 | -----BEGIN … PRIVATE KEY-----부터 짝이 되는 END 줄까지 전부, OpenSSH·RSA·EC·암호화·PGP 블록 포함 | PRIVATE_KEY |
| 비밀번호 & 연결 문자열 | 모든 스킴에서 scheme://user:password@host 안의 비밀번호, 그리고 이름이 password, passwd, pwd, secret, token, api_key, access_key, private_key, client_secret, auth_token, credentials로 끝나는 값 | URL_PASSWORD, SECRET |
| 고엔트로피 문자열 | 이름표 없이 무작위처럼 보이는 문자열(아래 설명) | HIGH_ENTROPY |
몇몇 규칙은 매칭된 부분 중 일부만 치환한다. 변수 이름, 헤더 이름, Bearer나 Basic 스킴 단어, 그리고 연결 문자열의 사용자·호스트·포트·데이터베이스는 그대로 남는다. 의도된 것이다. 어시스턴트는 여전히 DATABASE_URL이 db.internal:5432를 가리킨다는 사실을 알아야 하고, 다만 비밀번호까지 알 필요는 없기 때문이다.
Stripe pk_ publishable 키는 Stripe 규칙에 매칭되지 않는다. Stripe의 API 키 문서는 publishable 키를 프런트엔드 코드에 노출해도 안전한 키로 명시하고 있다.
이름 붙은 시크릿 규칙에는 평범한 설정값을 오탐하지 않도록 막는 세 가지 안전장치가 있다.
- 이름은 키워드로 끝나야 한다.
max_tokens: 1024와token_count=12는 매칭되지 않지만GITHUB_TOKEN=…은 매칭된다. - 명백히 시크릿이 아닌 값은 건너뛴다. 환경 변수 참조(
${DB_PASS},$SECRET,%APPDATA%), 템플릿 플레이스홀더(<your-key>,{{secret}}), 한 문자가 반복되는 값(****,xxxxxxxx), 그리고true,false,null,none,nil,undefined,required,optional,bearer,basic같은 리터럴이다. - 값의 길이는 최소 4자 이상이어야 한다.
그 제외 목록의 마지막 두 단어는 JSON 로그에서 특히 중요하다. "token": "Bearer eyJ…"에서 이름 붙은 규칙은 원래대로라면 Bearer라는 단어 자체를 값으로 취급했을 것이다. 이를 건너뛰게 함으로써 Bearer 규칙이 자격 증명만 마스킹하고 스킴 단어는 읽을 수 있게 남긴다.
2단계: 접두사 없는 키를 위한 엔트로피 폴백
규칙은 미리 알고 있는 것만 잡아낸다. 내부 서비스에서 나온 무작위 40자 토큰은 접두사도 없고 도움이 될 만한 변수 이름도 없을 수 있다. 이런 값을 위해 도구는 32자 이상 이어지는 base64 또는 base64url 문자열을 찾아 섀넌 엔트로피를 계산한다.
H = −Σ p(c) · log2 p(c) over the characters c in the string
완전히 무작위인 base64는 64개 기호 중에서 뽑히므로 이론적 상한은 문자당 log2(64) = 6비트다. 하지만 32자짜리 샘플로는 64개 기호를 전부 보여줄 수 없다. 실제로는 무작위 base64 문자 32개의 평균이 문자당 약 4.56비트다. 영어 단어나 식별자는 몇몇 글자가 반복되기 때문에 이보다 낮은 점수가 나온다.
다음 조건을 모두 만족할 때만 후보가 마스킹된다.
| 조건 | 존재하는 이유 |
|---|---|
끝의 = 패딩을 제외하고 최소 32자 | 짧고 이름표 없는 문자열은 너무 모호하고, 짧고 이름표 붙은 문자열은 이름 붙은 규칙이 처리한다 |
| 대문자, 소문자, 숫자를 모두 포함 | CI 로그를 가득 채우는 모든 16진수 값(git SHA, MD5, SHA-256, sha256: 이미지 다이제스트)과 단일 케이스 UUID를 제외한다 |
| 문자당 엔트로피 4.2비트 이상 | 무작위 토큰과 읽을 수 있는 긴 식별자를 구분한다 |
| 소문자 세그먼트가 두 개 이상인 경로가 아님 | src/components/… 같은 경로는 길고 대소문자가 섞여 있지만 시크릿이 아니다 |
sha1-, sha256-, sha384-, sha512-로 시작하지 않음 | 락파일과 서브리소스 무결성 해시는 공개 정보다 |
base64, 바로 뒤가 아님 | data: URI는 콘텐츠이지 자격 증명이 아니다 |
CERTIFICATE, CERTIFICATE REQUEST, PUBLIC KEY, X509 CRL PEM 블록 안이 아님 | 이런 블록은 애초에 공개용으로 설계됐다 |
대소문자 혼합 조건이 핵심 트레이드오프다. 옆에 변수 이름이 없는 32자짜리 16진수 토큰은 잡히지 않는다. 대안 — 16진수도 플래그를 세우는 것 — 은 CI 로그 속 진짜 발견 세 건을 마흔 개의 커밋 SHA 밑에 파묻어 버릴 것이고, 아무도 읽지 않는 마스킹 리포트는 아무것도 지키지 못한다. 16진수 토큰에 이름을 붙이면(TWILIO_AUTH_TOKEN=…) 이름 붙은 규칙이 잡아낸다.
반대 방향의 알려진 오탐은 숫자가 섞인 긴 CamelCase 식별자다. AbstractSingletonProxyFactoryBean2Impl은 약 4.33비트로 계산돼 마스킹 대상이 된다. 고엔트로피 카테고리를 끄면 이 오탐을 없앨 수 있다.
3단계: 규칙 순서로 중복을 해소한다
하나의 값이 여러 규칙에 동시에 매칭되는 경우가 흔하다. OPENAI_API_KEY=sk-proj-…는 OpenAI 규칙, 범용 sk- 규칙, 이름 붙은 시크릿 규칙, 엔트로피 검사에 모두 매칭된다. 도구는 모든 매칭을 수집한 뒤 규칙 우선순위(개인 키 규칙이 가장 먼저, 벤더 전용 패턴이 범용 sk- 규칙보다 먼저, 정규식 중에서는 연결 문자열과 이름 붙은 시크릿 규칙이 마지막, 엔트로피는 그 모두보다 나중)로 정렬하고, 이미 채택된 매칭과 겹치지 않는 매칭만 남긴다. 가장 구체적인 규칙이 이기므로, 이 줄은 [API_KEY_1]이나 [HIGH_ENTROPY_1]이 아니라 OPENAI_API_KEY=[OPENAI_KEY_1]이 된다.
4단계: 안정된 플레이스홀더
서로 다른 값마다 [LABEL_n] 형태의 플레이스홀더가 하나씩 부여되며, 번호는 라벨별로 매겨진다.
OPENAI_API_KEY=[OPENAI_KEY_1]
DATABASE_URL=postgres://app:[URL_PASSWORD_1]@db.internal:5432/app
MAX_TOKENS=1024
...
2026-09-23T10:12:04Z retry with key [OPENAI_KEY_1] -> 200
다음 네 가지 성질이 이 플레이스홀더를 어시스턴트에게 넘겨도 안전하게 만든다.
- 같은 값은 같은 플레이스홀더. 스무 번 등장하는 키는 스무 번 모두
[OPENAI_KEY_1]이 된다. 어시스턴트는 재시도가 설정값과 같은 키를 썼다는 사실을 여전히 볼 수 있으며, 이것이 진단의 전부인 경우도 많다. - 라벨이 의미를 담는다.
[AWS_ACCESS_KEY_1]은 값을 드러내지 않고도 그 자리에 어떤 종류의 값이 있었는지 모델에게 알려준다. 그래서 “[AWS_ACCESS_KEY_1]을(를) 순환하고 CI 시크릿을 갱신하라” 같은 답변도 여전히 말이 된다. - 충돌 없음. 번호 매기기는 입력 안에 이미 존재하는 플레이스홀더 문자열을 건너뛴다. 그래서 문서에 문자 그대로
[SECRET_1]이 들어 있다면, 처음 탐지된 시크릿은[SECRET_2]가 된다. - 멱등성. 이미 플레이스홀더처럼 보이는 값은 모든 규칙이 무시한다. 그래서 이미 마스킹된 텍스트에 도구를 다시 돌려도 아무것도 바뀌지 않는다.
발견 목록은 각 플레이스홀더마다 그것을 만들어낸 규칙, 등장 횟수, 마스킹된 미리보기를 보여준다. 미리보기는 앞 4자와 뒤 2자에 전체 길이를 더한 형태이고, 값이 12자보다 짧으면 길이만 표시한다. 값을 화면에 다시 드러내지 않고도 올바른 대상이 잡혔는지 확인하기에는 충분하다.
5단계: 어시스턴트의 답변에서 복원하기
어시스턴트가 수정된 설정이나 실행할 명령으로 답했다면, 그 답변을 복원 상자에 붙여넣는다. 현재 매핑에 존재하는 모든 플레이스홀더가 실제 값으로 다시 치환되어, 바로 터미널에 복사해 넣을 수 있는 상태가 된다.
모델이 항상 텍스트를 정확히 그대로 재현하지는 않으므로, 복원 기능은 제한된 범위의 변형을 허용한다.
| 답변 속 형태 | 복원되는가 |
|---|---|
[OPENAI_KEY_1] | 예 |
\[OPENAI_KEY_1\](마크다운으로 이스케이프된 대괄호) | 예 |
[openai_key_1](대소문자가 바뀜) | 예 |
[ OPENAI_KEY_1 ](대괄호 안에 공백) | 예 |
코드 스팬 안의 `[OPENAI_KEY_1]` | 예 |
대괄호 없는 OPENAI_KEY_1 | 아니요 |
마지막 행은 의도된 것이다. 대괄호가 없으면 OPENAI_KEY_1은 환경 변수 이름과 구분되지 않으며, 변수 이름 자리에 시크릿을 조용히 밀어 넣는 것은 아무것도 하지 않는 것보다 나쁘다. 매핑에 없는 플레이스홀더 모양의 토큰 — 예를 들어 모델이 지어낸 [PASSWORD_7] — 은 그대로 남고 복원 상자 아래 목록에 표시된다. 모델이 무언가를 지어냈을 때 알아챌 수 있도록 하기 위해서다.
프롬프트 맨 앞에 짧은 지시문을 넣으면 대괄호 문제를 줄일 수 있다. “[OPENAI_KEY_1]처럼 대괄호로 감싼 값은 마스킹된 시크릿입니다. 쓰인 그대로 유지하세요.”
매핑은 첫 번째 상자의 텍스트가 바뀔 때마다 그 텍스트로부터 다시 만들어진다. 답변을 복원할 때까지는 원본 입력을 그대로 유지해야 한다.
데이터는 어디로 가는가
탐지와 복원 코드는 페이지 안의 순수한 JavaScript다. 입력한 텍스트로 어떤 네트워크 요청도 보내지 않는다. localStorage, sessionStorage, 쿠키, URL 어디에도 아무것도 기록되지 않는다. 플레이스홀더-값 매핑은 단 하나의 메모리 내 변수에만 존재하며, Clear 버튼을 누르거나 Ctrl/Cmd + L을 누르거나 탭을 새로고침·닫기·이동할 때 폐기된다. 텍스트 상자는 pagehide 시점에 지워지므로, 브라우저의 백-포워드 캐시가 시크릿을 다시 불러오는 일도 없다.
이 도구 페이지는 분석이나 광고 스크립트도 전혀 불러오지 않는다. 자격 증명, 개인 키, 토큰을 입력으로 받는 이 사이트의 페이지들은 애초에 두 종류 모두에서 제외되어 있으므로, 붙여넣은 텍스트 옆에서 제3자 스크립트가 돌아가는 일이 없다.
솔직한 한계 하나: JavaScript는 문자열을 제자리에서 덮어쓸 수 없다. Clear는 모든 참조를 끊을 뿐이고, 브라우저가 다음 가비지 컬렉션 때 그 메모리를 회수한다. 탭을 닫으면 거기서 끝난다.
흔한 함정
-
자신의 눈보다 리댁터를 더 믿는 것. 규칙 기반 탐지는 빠르고 예측 가능하지만, 규칙이 없는 것은 무엇이든 놓친다. 보내기 전에 마스킹된 텍스트를 직접 읽어라. 발견 개수는 빠른 정상성 검사가 된다. 300줄짜리
.env에서 발견이 하나뿐이었다면 뭔가 잘못된 것이다. -
여러 줄에 걸쳐 쪼개진 시크릿. 개인 키 규칙을 제외한 어떤 규칙도 줄바꿈을 포함한 값을 매칭하지 않는다. 터미널이나 로그 뷰어가 두 줄로 감싼 토큰은 탐지되지 않는다. 줄바꿈된 화면이 아니라 원본 파일에서 복사하라.
-
이름 없는 16진수 토큰. 앞서 다뤘듯 맨몸의 16진수 문자열은 그대로 남는다. 사용하는 서비스가 16진수 API 키를 발급한다면, 붙여넣는 텍스트 어디서든 설명이 담긴 이름 옆에 그 키가 나오도록 해야 한다.
-
다른 데이터 안에 base64로 인코딩된 시크릿. Kubernetes
Secret매니페스트는data:아래에 값을 base64로 인코딩해 저장하며, Kubernetes 문서는 Secret이 기본적으로 etcd에 암호화되지 않은 채 저장된다고 경고한다. 엔트로피 규칙은 대소문자가 섞인 긴 인코딩 값을 잡아내지만, 짧은 비밀번호는 짧은 문자열로 인코딩되어 조건을 만족하지 못할 수 있다.kind: Secret매니페스트는 기본적으로 민감한 것으로 취급하라. -
개인 정보. 이 도구는 자격 증명만을 대상으로 한다. 이메일, 이름, 전화번호, IP 주소, 호스트 이름은 그대로 보인다 — 호스트 이름은 의도적으로 남겨둔 것으로, 어시스턴트가 여전히 네트워크 구조를 추론할 수 있게 하기 위해서다. 정책상 필요하다면 개인 정보는 직접 손으로 제거해야 한다.
-
대화 도중 토글을 바꾸는 것. 카테고리를 켜거나 끄면 탐지가 다시 실행되고, 중복 승자가 바뀌면 플레이스홀더 번호도 밀릴 수 있다. 복원은 항상 현재 입력과 현재 토글 상태에 대응하는 매핑을 쓰므로, 마스킹할 때와 같은 설정으로 복원해야 한다.
-
입력 크기. 도구는 한 번에 최대 1,000,000자까지 받는다. 더 큰 로그라면 먼저 관련 구간만 잘라내라. 어차피 노이즈가 적을수록 어시스턴트도 더 나은 답을 준다.
이 도구가 하지 않는 일, 있는 그대로
| 이 도구가 하지 않는 일 | 대신 쓸 것 |
|---|---|
| 파일, 폴더, git 히스토리 스캔 | 로컬에서 실행하는 gitleaks(저장소는 gitleaks git, 파일은 gitleaks dir) 또는 TruffleHog |
| 개인 정보 탐지(이메일, 이름, 전화번호, IP 주소) | 수동 편집 |
| 오탐 하나만 골라 마스킹 해제 | 해당 카테고리를 끄거나, 복사한 텍스트를 직접 편집 |
| 키가 아직 살아 있는지 확인 | 이 페이지의 어떤 기능도 이를 하지 않는다. 확인한다는 것은 키를 제공업체로 보낸다는 뜻이기 때문이다 |
| 어시스턴트가 대괄호 없이 쓴 플레이스홀더 복원 | 어시스턴트에게 플레이스홀더를 그대로 유지해 달라고 요청 |
gitleaks와 TruffleHog는 다른 문제를 푼다. 이미 커밋된 시크릿을 찾는 문제다. gitleaks는 MIT 라이선스다. TruffleHog는 AGPL-3.0이고 후보를 제공업체 API로 검증해 어느 것이 살아 있는지 알려줄 수 있다. 둘 다 pre-commit 훅이나 CI 작업에 있어야 할 도구다. 시크릿 리댁터가 다루는 것은 붙여넣기 직전의 순간, 즉 스캔할 저장소가 아직 없는 순간이다.
코드로 직접 해보기
같은 발상은 로그를 다른 곳으로 전달하는 스크립트에도 쉽게 적용할 수 있다.
이슈에 로그를 첨부하기 전에 돌리는 Bash 사전 점검. 값은 절대 출력하지 않고 규칙 이름과 줄 번호만 보고한다.
#!/usr/bin/env bash
# usage: ./precheck.sh build.log
# Prints rule names and line numbers only, never the matched values.
file="$1"
found=0
check() {
local name="$1" pattern="$2" lines
lines=$(grep -nE -- "$pattern" "$file" | cut -d: -f1 | paste -sd, -)
if [ -n "$lines" ]; then
echo "$name: line $lines"
found=1
fi
}
check "ai-key" 'sk-(proj|svcacct|admin|ant-[a-z]{3,6}[0-9]{2})-'
check "github-token" 'gh[pousr]_[A-Za-z0-9]{36}|github_pat_'
check "aws-key-id" '(AKIA|ASIA)[A-Z2-7]{16}'
check "jwt" 'eyJ[A-Za-z0-9_-]{8,}\.eyJ'
check "private-key" 'BEGIN [A-Z ]*PRIVATE KEY'
check "url-credential" '://[^:/@ ]+:[^@ ]+@'
if [ "$found" -eq 1 ]; then
echo "Possible secrets found. Redact before sharing."
else
echo "No known patterns found. Read it anyway."
fi
도구가 쓰는 것과 같은 임계값을 적용한 Python 엔트로피 검사.
import math
import re
from collections import Counter
CANDIDATE = re.compile(r"[A-Za-z0-9+/_-]{32,}={0,2}")
def shannon(s: str) -> float:
n = len(s)
return -sum(c / n * math.log2(c / n) for c in Counter(s).values())
def looks_random(run: str) -> bool:
run = run.rstrip("=")
has_classes = (
re.search(r"[A-Z]", run)
and re.search(r"[a-z]", run)
and re.search(r"[0-9]", run)
)
return bool(has_classes) and shannon(run) >= 4.2
def high_entropy_spans(text: str):
return [m.span() for m in CANDIDATE.finditer(text) if looks_random(m.group())]
print(shannon("3f786850e387550fdab836ed7e6dc881de23001b")) # hex SHA: fails the class check anyway
print(shannon("AbstractSingletonProxyFactoryBean2Impl")) # ~4.33: the known false positive
JavaScript로 구현한 안정된 플레이스홀더와 복원. 매핑은 그것을 만든 프로세스 밖으로 결코 나가지 않는다.
function redact(text, rules) {
const valueToPh = new Map();
const counters = {};
let out = text;
for (const { label, re } of rules) {
out = out.replace(re, (value) => {
if (/^\[[A-Z][A-Z0-9_]*_\d+\]$/.test(value)) return value; // already a placeholder
if (!valueToPh.has(value)) {
counters[label] = (counters[label] || 0) + 1;
valueToPh.set(value, `[${label}_${counters[label]}]`);
}
return valueToPh.get(value);
});
}
const phToValue = new Map([...valueToPh].map(([v, ph]) => [ph.slice(1, -1), v]));
return { out, phToValue };
}
function restore(reply, phToValue) {
return reply.replace(/\\?\[\s*([A-Za-z][A-Za-z0-9_]*_\d+)\s*\\?\]/g, (m, key) =>
phToValue.get(key.toUpperCase()) ?? m,
);
}
이 단순화된 버전은 규칙을 하나씩 순서대로 적용하므로, 나중 규칙은 앞선 규칙이 이미 치환한 텍스트를 볼 일이 없다. 실제 도구는 대신 모든 매칭을 먼저 전부 수집한 뒤 우선순위로 중복을 해소한다. 이 방식 덕분에 매칭이 줄의 어디서 시작하든 가장 구체적인 규칙이 이길 수 있다.
이미 시크릿이 유출됐다면
마스킹은 예방이다. 자격 증명이 이미 통제할 수 없는 곳에 붙여넣어졌다면, 메시지를 삭제하는 것은 대응이 아니다. 텍스트는 이미 로그, 알림, 이메일 다이제스트, 검색 색인, 혹은 제공업체의 대화 저장소 어딘가에 들어가 있을 수 있다.
효과가 있는 순서는 다음과 같다.
- 먼저 폐기하거나 순환한다. 저장소에서 민감한 데이터 제거하기에 대한 깃허브 가이드는 이를 직설적으로 말한다. 데이터가 비밀번호, 토큰, 자격 증명이라면 “첫 단계로 해당 시크릿을 폐기하거나 순환해야 한다.” 일단 폐기되면 더는 쓸 수 없고, 그것만으로 충분한 경우도 많다.
- 플랫폼이 허용한다면 다운타임 없이 순환한다. AWS는 IAM 사용자당 최대 두 개의 액세스 키를 허용하는데, 이는 정확히 새 키를 만들고, 애플리케이션을 그쪽으로 옮기고, 기존 키를 비활성화하고, 아무것도 깨지지 않았는지 확인한 뒤 삭제할 수 있게 하기 위해서다.
- 사용 흔적을 확인한다. AWS는
aws iam get-access-key-last-used를 제공한다. 대부분의 제공업체에는 이에 준하는 감사 로그나 “마지막 사용” 필드가 있다. 유출 시점과 순환 시점 사이의 활동을 확인하라. - 사본을 정리한다. 메시지, 이슈 댓글, 채팅을 삭제한다. git 저장소라면 깃허브 가이드는 히스토리를 다시 쓰는 데
git-filter-repo를 권장하며, 포크·클론·캐시된 뷰에는 여전히 예전 내용이 남아 있을 수 있다고 짚는다 — 그래서 1단계가 먼저인 것이다. - 유출 경로를 막는다. 환경 변수를 덤프하던 디버그 단계를 제거하고, 요청 헤더 로깅을 멈추고, 붙여넣어진 파일에서 시크릿을 빼낸다.
깃허브는 또한 공개 저장소나 공개 gist에 푸시된 유효한 OAuth 토큰, GitHub App 토큰, 개인 액세스 토큰을 자동으로 폐기한다. 이 보호는 깃허브 자신의 공개 공간에 있는 깃허브 자신의 토큰만 다룬다. AI 챗, 슬랙 채널, 비공개 이슈 트래커에 붙여넣어진 키에는 아무 소용이 없다.
관련 도구
- JWT 디코더 — 아직 민감한 토큰인지 판단하기 전에 헤더, 클레임, 만료 시각을 로컬에서 확인한다
- .env 파일 파서 —
.env파일을 공유하기 전에 어떤 변수가 정의되어 있는지 정확히 확인한다 - Basic 인증 헤더 생성기 —
Basic헤더와 그 안의 비밀번호 사이에 얼마나 얇은 벽밖에 없는지 보여준다 - 폭 없는 문자 탐지기 — 붙여넣기 전에 돌려볼 만한 또 다른 점검
- AI 토큰 카운터 — 로그 전체를 붙여넣는 대신 컨텍스트 윈도우에 맞게 잘라낸다
더 읽을거리
- RFC 7519 — JSON 웹 토큰(JWT)
- RFC 7468 — PKIX, PKCS, CMS 구조체의 텍스트 인코딩
- RFC 6750 — OAuth 2.0 Bearer 토큰 사용
- RFC 7617 — ‘Basic’ HTTP 인증 스킴
- RFC 3986 §3.2.1 — 사용자 정보(User Information)
- GitHub — 깃허브의 새 인증 토큰 형식 살펴보기
- GitGuardian — The State of Secrets Sprawl 2026