AX 노트 · 방법

AI 코딩 에이전트와 비밀값: 채팅·로그·권한 세 겹, 복사된 마스킹 정규식이 놓친 예외 2곳

에이전트에게는 금지 문장 대신 값을 출력하지 않는 확인 명령을 주고, 로그 마스킹은 함수 하나와 복사본 검사 테스트로, 토큰은 필요한 권한만 남기십시오.

작성 SugarMount 검증과 출시 읽는 시간 13분 최종 업데이트

파일 4곳에 따로 복사된 로그 마스킹 정규식을 함수 하나로 모으다가, 그중 한 파일의 실패 예외 2곳이 토큰 값을 그대로 담고 있는 것을 찾았습니다. 2026년 9월 13일 P2P 채팅 앱에서였고, 6일 전에 고쳤다고 적은 결함이었습니다. 10월 2일에는 에이전트가 셸 기록 마지막 150줄을 출력해 API 키가 세션 기록에 남았고, 10월 10일에는 가족 앱 배포 토큰에 권한을 더 받으려는 계획이 있었습니다.

근거는 세 저장소(P2P 채팅 앱, 한글 띄어쓰기 앱, 개발 중인 가족 앱)의 git 기록과 테스트 코드, 에이전트 작업 세션 기록입니다. 글에 나오는 비밀값은 모두 XXXX 같은 자리표시입니다.

목차 (9)
  1. 같은 규칙 하나를 채팅, 로그, 토큰 권한에 걸었습니다
  2. 채팅: 키가 대화 기록에 찍히고 47초 뒤에 첫 비밀값 규칙이 생겼습니다
  3. 로그: 정규식 복사본 4벌, 그중 마스킹이 빠진 예외 2곳
  4. 릴리스 logcat에는 사용자가 쓴 글도 찍히고 있었습니다
  5. 권한: 토큰에 권한을 더하는 대신 Worker 구조를 바꿨습니다
  6. 다른 모양의 복사본은 소스 가드를 통과하고, 마스킹은 토큰을 지키지 못합니다
  7. 지침, 마스킹 함수, 토큰 권한에 하나씩 넣을 확인
  8. 보안 문서 한 줄, 세션 기록 한 줄, 배포 전 Worker
  9. 자주 묻는 질문

같은 규칙 하나를 채팅, 로그, 토큰 권한에 걸었습니다

규칙은 한 문장입니다. 비밀값은 어디에도 값으로 옮기지 않고, 어디에 무엇이 있는지만 말합니다. 에이전트가 읽은 파일은 대화 기록에 남고, 에이전트가 쓴 코드는 로그와 예외 메시지에 값을 남기고, 에이전트가 설계한 배포는 필요보다 넓은 토큰을 요구합니다.

단계 그림: 9월 7~8일 정규식이 파일 4곳에 복사됨, 9월 13일 함수 하나로 모으고 실패 예외 2곳 수정, 10월 2일 대화 기록에 키가 찍힌 뒤 위치와 종류만 알리는 규칙, 10월 11일 토큰 권한 2개로 설계, 10월 17일 옛 결함 줄 7개를 잡는 테스트.
같은 규칙을 채팅, 로그, 토큰 권한에 차례로 걸었고, 세 곳 모두 첫 판에 빈틈이 있었습니다.

채팅: 키가 대화 기록에 찍히고 47초 뒤에 첫 비밀값 규칙이 생겼습니다

에이전트가 셸 기록 마지막 150줄을 출력한 것은 2026년 10월 2일 오전 8시 47분이었습니다. 운영자가 모든 세션이 읽는 공통 지침을 채워 달라고 하자, 작업 환경을 파악하려고 한 일입니다. 그 안에 AI API 키를 환경 변수로 export한 줄이 있었습니다. 에이전트 도구의 출력은 세션 기록 파일에 그대로 저장되므로, 키는 그 순간 대화 기록의 일부가 됐습니다.

이때 지침 파일에는 응답 형식 규칙만 있었습니다. 키가 찍히고 47초 뒤, 에이전트가 지침에 처음 적은 비밀값 규칙은 비밀값을 어떤 출력에도 넣지 말라는 한 줄이었습니다. 금지만 있고 확인하는 방법은 없었습니다. 18초 뒤 에이전트는 운영자에게 셸 기록에 AI API 키가 평문으로 남아 있다고, 값 없이 위치와 종류만 보고했습니다.

8시 55분, 운영자가 모든 세션의 채팅에서 비밀번호와 개인정보를 언급하지 않게 해 달라고 했고, 에이전트는 그 한 줄을 확인 방법까지 담은 규칙으로 바꿨습니다. 요지는 값을 쓰지 않고 어디에 어떤 종류가 있는지만 말한다는 것입니다. 파일은 키 이름과 줄 수만 보는 명령(grep -c, cut -d= -f1)으로 확인하고, 명령에는 값 대신 환경 변수나 파일 참조를 씁니다. 채팅뿐 아니라 커밋과 문서, 메모리 파일에도 같게 적용하고, 사람이 붙여 넣은 값은 다시 인용하지 않고 재발급을 권합니다. 대상도 비밀값에서 개인정보까지 넓혔습니다.

에이전트는 셸 기록에서 키가 든 4줄(export 3줄, 값을 인자로 넘긴 비밀 등록 명령 1줄)을 지우고 다시 세어 0을 확인했습니다. 지금은 지우지 않은 곳도 둘 보고했습니다. 실행 중이라 고치면 깨질 수 있는 세션 기록 파일과, 그 키를 입력했던 터미널 창입니다. 보고 끝에는 지워도 노출된 사실은 남으니 재발급이 가장 확실하다는 말과, 새 키는 명령에 이름만 넘기고 값은 묻는 창에 넣으라는 안내가 있었습니다.

10월 25일에 메인 세션 기록 109개를 다시 세어 봤습니다. 키 이름만 보는 cut -d= -f1 명령은 24번 나왔고, 날짜가 찍힌 것은 모두 10월 3일 이후입니다. 하위 에이전트 기록까지 넣으면 9월 2번, 10월 179번입니다. grep -c로 .env를 세는 습관은 9월에도 25번 있었으니, 늘어난 쪽은 이름만 보는 확인입니다. 알려진 키 모양 세 가지(새 형식 AI API 키, GitHub 토큰, sk-로 시작하는 키)가 든 줄은 하위 에이전트 기록까지 2,567개 전체에서도 1줄뿐이었고, 10월 2일 8시 47분의 바로 그 출력이었습니다.

# .env: 값 없이 줄 수와 키 이름만
grep -c '=' .env
cut -d= -f1 .env

# 셸 기록: 그 키 이름이 든 줄 수만
grep -c 'API_KEY' ~/.bash_history

로그: 정규식 복사본 4벌, 그중 마스킹이 빠진 예외 2곳

P2P 채팅 앱(Flutter)은 시그널링 서버에 붙을 때 URL 쿼리에 ?token= 값을 실어 보냅니다. 9월 7일 출시 준비 분석을 맡은 에이전트가 이것을 High로 분류했습니다. 탐색 계층 로그가 debugPrint로 나가 릴리스 빌드에서도 찍히고, 연결 로그 4곳이 URL을 통째로 남긴다는 지적이었습니다.

분석 에이전트가 낸 수정안은 Uri.parse(url).replace(query: null)였습니다. 수정을 맡은 에이전트는 이 코드가 쿼리를 지우지 못한다고 보고했습니다. Dart의 Uri.replace에서 null을 넘긴 항목은 원래 값을 그대로 씁니다. 그래서 정규식 한 줄로 바꿨습니다.

복사본이 생긴 원인은 작업 분담입니다. 그날 수정은 파일 소유를 나눈 여러 에이전트의 몫이었고, 모바일 담당이 맡은 것은 자기 파일 2개의 "토큰 마스킹 한 줄"뿐이었습니다. 웹 파일 2개는 다른 담당에게 요청으로 넘어갔고, 다음 날 웹 쪽에 같은 정규식을 감싼 _maskToken이 따로 생겼습니다. 9월 8~9일 문서 정리에서 "정규식이 4개 파일에 중복, 공용 함수로 뽑는 것이 남은 일"이라고 적혔지만, 결함 목록에는 9월 7일 자로 Fixed가 붙어 있었습니다.

9월 13일(555680e) 공용 함수를 만들면서 파일마다 URL을 문자열로 만드는 곳을 모두 살펴보자, 모바일 HTTP 파일의 HttpException 두 곳이 걸렸습니다. SSE 연결이 실패하면 예외의 uri 필드에, POST가 실패하면 예외 메시지와 uri 둘 다에 토큰 값이 그대로 들어가는 코드였습니다. 모바일 쪽 첫 수정은 연결 성공 로그 한 줄만 맡았고, 같은 파일의 실패 예외는 그 범위 밖이었습니다. 웹 쪽 복사본은 실패 메시지도 가리고 있었습니다.

String maskSignalingUrl(String url) =>
    url.replaceAllMapped(_signalingToken, (m) => '${m[1]}***');

final RegExp _signalingToken = RegExp(
  r'([?&](?:token|secret)=)[^&#\s]*',
  caseSensitive: false,
);

새 정의는 대소문자를 무시하고, secret=도 가리고, #이나 공백 앞에서 끝납니다. 옛 정규식 ([?&]token=)[^&]*와의 차이는 같은 패턴을 Python으로 돌려 확인했습니다.

입력옛 정규식새 정규식
ws://h/ws?TOKEN=XXXX그대로 남음?TOKEN=***
ws://h/ws?secret=XXXX그대로 남음?secret=***
ws://h/ws?token=XXXX#frag?token=*** (#frag까지 지움)?token=***#frag

테스트는 세 종류를 같이 넣었습니다. 규칙 테스트 7개, 소스 가드 1개, 실제 실패 경로 1개입니다. 실패 경로 테스트는 루프백 주소에 HTTP 서버를 띄워 401을 돌려주고, 클라이언트가 던진 예외 문자열에 token=***가 있고 값은 없는지 봅니다. 소스 가드를 줄이면 이렇습니다.

test('discovery 안에 마스킹 정규식 복사본이 다시 생기지 않는다', () {
  final offenders = <String>[];
  for (final f in Directory('lib/core/discovery').listSync()) {
    if (f is! File || !f.path.endsWith('.dart')) continue;
    if (f.path.endsWith('discovery_log.dart')) continue; // 정의부
    final src = f.readAsStringSync();
    if (src.contains('token=)') || src.contains('_maskToken')) {
      offenders.add(f.path);
    }
  }
  expect(offenders, isEmpty);
});

정의 파일이 아닌 곳에 정규식 조각 token=)이나 옛 함수 이름이 다시 나타나면 실패합니다. 중복은 9월 8일 문서에 적힌 뒤에도 5일 동안 그대로였고, 그래서 복사본은 리뷰 대신 테스트로 막았습니다. 마스킹 함수는 하나만 두고, 복사본이 생기면 실패하는 테스트를 같이 커밋하십시오.

릴리스 logcat에는 사용자가 쓴 글도 찍히고 있었습니다

10월 17일 QA에서 한글 띄어쓰기 앱의 릴리스 logcat을 보다가 같은 종류의 결함이 나왔습니다(21895f6). 1.6.030까지 릴리스 빌드에 검사 요청 URL(사용자가 입력한 글이 들어 있음), 응답 원문 앞 500자, 화면에 돌아올 때마다 외부 검사 서비스의 세션 키, 푸시 알림(FCM) 토큰이 찍혔습니다. 앱의 로그 래퍼에서 log.e는 BuildConfig.DEBUG와 상관없이 출력됩니다.

진단 줄은 log.d로 내리고, 키와 글은 길이나 유무만 남겼습니다. 테스트 ReleaseLogSecretsTest는 메인 소스 전체에서 릴리스에도 찍히는 로그 호출을 찾아, 문자열 리터럴 밖의 보간과 인자만 보고 금지된 식이 있는지 검사합니다. 첫 테스트는 옛 결함 줄 7개를 넣고 검사기가 7개를 모두 잡는지부터 확인합니다. 테스트 주석에는 이 줄들을 못 잡으면 아래 검사도 가짜라고 적혀 있습니다. 다음 개발 버전 1.6.031 릴리스 빌드를 Galaxy S20에 깔고 logcat을 보니 해당 줄이 0건이었습니다.

권한: 토큰에 권한을 더하는 대신 Worker 구조를 바꿨습니다

개발 중인 가족 앱은 사진을 Cloudflare R2에 두고, 업로드와 조회를 미디어 Worker가 맡습니다. 10월 10일 오후의 배포 문서에는 사람이 할 일이 셋 적혀 있었습니다. 배포 토큰에 KV 편집과 결제 읽기 권한을 더하고, 사용량 조회 전용 토큰을 하나 따로 만들고, Firebase 함수가 R2 객체를 지울 때 쓸 S3 자격 증명을 한 쌍 발급하는 일입니다. 무료 한도를 넘기 전에 Worker를 멈추는 장치가 매시간 Cloudflare 분석 API를 읽어 KV에 정지 플래그를 쓰는 구조였기 때문입니다.

그날 밤 10시 52분, 운영자가 일을 자기에게 넘기지 말고 에이전트가 다 하라고 했습니다. 밤 11시 13분, 에이전트는 Worker가 추가 토큰 없이 돌아가게 바꾸는 작업이 이미 돌고 있다고 보고했고, 0시 4분에 2c906c7을 커밋했습니다. 기능마다 그 API에 맞는 자격 증명을 하나씩 붙인 첫 설계도 에이전트의 것이었고, 이번에는 이미 있던 R2 바인딩, Durable Object, 공유 키로 같은 기능을 다시 만들었습니다. 설계상 토큰에 필요한 권한은 Workers Scripts Edit과 R2 Edit 2개입니다.

비교 그림: 10월 10일 계획은 토큰 권한 2개 추가, 사용량 조회 토큰 1개, R2 S3 자격 증명 1쌍, Worker 비밀 3개. 10월 11일 커밋 2c906c7의 설계는 토큰 권한 2개와 Worker 비밀 1개.
운영자가 일을 넘겨받지 않겠다고 한 뒤, 에이전트는 자격 증명을 더 받는 대신 이미 있는 것으로 같은 기능을 다시 만들었습니다.

대가도 문서에 남겼습니다. 삭제 서명 키가 공유 HMAC 키에서 나오기 때문에, 그 키를 읽을 수 있는 함수 서비스 계정 4개가 이론상 모두 삭제를 서명할 수 있습니다. 전에는 삭제 함수 계정 하나만 R2 토큰을 읽었으니 그만큼 넓어진 셈입니다. 더 좁히려면 삭제 전용 키를 두면 되고, 그러면 Worker 비밀이 하나 늘어납니다.

10월 11일 낮, 읽기만 하는 운영 점검에서 실제 토큰 범위를 보니 설계보다 하나 많은 3개였습니다. Workers Scripts Edit, R2 Edit에 Account Settings Read가 붙어 있었습니다. 결제 읽기 권한이 없으니 플랜 조회 API는 인증 오류 10000을 돌려줬고, 이 토큰으로는 플랜과 결제를 바꿀 수 없다는 확인도 됐습니다.

다른 모양의 복사본은 소스 가드를 통과하고, 마스킹은 토큰을 지키지 못합니다

소스 가드는 문자열 두 개만 보고, 폴더 하나만 봅니다. 다른 모양으로 쓴 정규식이나 다른 폴더의 복사본은 통과합니다. 띄어쓰기 앱의 로그 검사도 한 줄 단위라 여러 줄에 걸친 로그 호출은 보지 못합니다.

마스킹은 비밀을 지키지 못합니다. 채팅 앱이 가리는 토큰은 앱 바이너리에 상수로 들어 있어 추출할 수 있고, 그래서 이 앱은 그 토큰을 스팸을 줄이는 용도로만 봅니다. 서버는 헤더로도 토큰을 받지만 클라이언트는 아직 쿼리로 보냅니다. 웹에서는 헤더를 붙일 수 없다는 분석이 있어서, URL이 로그로 가는 길을 막는 쪽을 골랐습니다.

대화 규칙은 에이전트가 지킬 때만 작동하고, 집계는 아는 키 모양만 셉니다.

Worker가 추가 토큰 없이 돌게 바꾸던 그날 밤, 에이전트 자신의 도구 권한은 오히려 넓어졌습니다. 에이전트의 자동 권한 검사가 IAM 권한 부여 명령을 막았고, 채팅으로 허락해도 풀리지 않아 설정에 허용 규칙을 넣어야 했습니다. 에이전트는 스크립트 하나와 gcloud 명령 전체를 허용하는 두 줄을 요청했고, 운영자는 4분 뒤 추가했다고 답했습니다. 밤 11시 13분에는 워크플로 도구, npx firebase-tools 명령 전체, curl 명령 전체를 허용하는 세 줄을 더 요청했습니다.

지침, 마스킹 함수, 토큰 권한에 하나씩 넣을 확인

  1. 에이전트 지침의 비밀값 절에 금지 문장과 함께 확인 명령을 적습니다: grep -c, cut -d= -f1, 명령에는 $NAME 같은 참조.
  2. 에이전트에게 셸 기록이나 .env를 통째로 읽히지 않습니다. 키 이름이 든 줄 수만 세게 하고, 이미 들어간 키는 지운 뒤 재발급합니다. 새 값은 명령 인자 대신 프롬프트나 파일로 넣습니다.
  3. 비밀이 든 문자열 종류(URL 등)마다 마스킹 함수를 하나 두고, 그 문자열을 만드는 모든 지점(로그, 예외 메시지, 예외의 uri 필드, 진단 맵)에서 부릅니다.
  4. 테스트는 셋입니다. 규칙(대소문자, 다른 키 이름, 프래그먼트, 경로의 같은 단어), 소스 가드(정의 파일 밖의 정규식 조각), 실제 실패 경로(로컬 서버의 4xx).
  5. 로그 검사기에는 옛 결함 줄을 먼저 넣어 잡는지 확인합니다.
  6. 릴리스 빌드를 실기기에 깔고 logcat을 키 이름이나 표식으로 찾습니다. 값으로 grep하면 그 명령이 다시 셸 기록에 남습니다.
  7. 토큰은 필요한 권한을 문서에 적고, 새 자격 증명을 붙이기 전에 이미 있는 바인딩이나 키로 되는지 봅니다. 실제 범위는 읽기 전용 호출로 확인합니다.
  8. 넓어진 곳은 문서에 한 줄로 남깁니다. 무엇이 넓어졌고, 좁히려면 무엇을 더해야 하는지.
# 릴리스 logcat: 가려지지 않은 token= 줄 수
adb logcat -d | grep 'token=' | grep -vc 'token=\*\*\*'

보안 문서 한 줄, 세션 기록 한 줄, 배포 전 Worker

P2P 채팅 앱의 보안 문서 한 줄은 9월 16일 커밋에서도 "정규식이 4개 파일에 중복"이라고 적혀 있습니다. 코드는 바꾸고 문서는 그대로 둔 것이라 고칠 곳으로 남아 있습니다. 10월 2일 세션 기록의 키 모양 1줄은 아직 지우지 않았고, 그 키를 다시 발급했는지는 기록으로 확인하지 못했습니다. 가족 앱은 Worker를 배포한 뒤 업로드와 삭제가 설계대로 권한 2개로 되는지, 실제 토큰에 붙은 Account Settings Read가 필요한지 확인해야 합니다.

자주 묻는 질문

AI 코딩 에이전트에게 .env 파일을 읽게 해도 되나요?

값을 출력하는 방식으로는 읽히지 마십시오. cut -d= -f1 .env로 키 이름만, grep -c로 줄 수만 보게 하면 설정이 빠졌는지는 확인할 수 있습니다.

에이전트가 쓸 클라우드 토큰 권한은 어떻게 줄이나요?

기능마다 필요한 권한을 적고, 새 자격 증명을 만들기 전에 이미 있는 바인딩이나 공유 키로 같은 일을 할 수 있는지 봅니다.

이 글은 SugarMount가 직접 측정한 값으로 쓰고, 초안 작성에 AI 코딩 에이전트를 썼습니다.

검증과 출시 글 전체 보기 · AX 노트 전체 보기