.gitignore
여기에 git rm --cached .env를 추가해도, 바로 이전 커밋부터 (작성자 환경에서 확인). git show HEAD~1:.env
로 내용이 나왔습니다 - 히스토리에 남아있는지는
git log --all --full-history -- '.env*' '**/.env*'
등으로 찾을 수 있습니다. 삭제할 때는 새로운 클론에서 git filter-repo --sensitive-data-removal --invert-paths --path .env를 실행합니다 (GitHub 공식 절차). -
히스토리를 수정하더라도, 유출된 키는 무효화되지 않습니다. 가장 먼저 해야 할 일은 키 재발급(로테이션)입니다 (GitHub 공식).
수정하기 전의 클론에는 삭제했어야 할 .env
가 그대로 남아 있었습니다 (작성자 환경에서 확인).
이 글은 AI 에이전트와 비밀 정보에 대한 이야기의 연속입니다. Codex CLI에 .env를 읽지 못하게 하는 설정에서는, .env
의 읽기를 거부(deny)해도 git show HEAD:.env
로 내용이 나오는 것을 확인했습니다. deny는 '현재 작업 트리에 있는 파일'을 보호하는 설정이며, git 내부에 남아있는 같은 내용은 보호하지 않습니다. 이번에는 그 허점을 히스토리 측면에서 막습니다.
- Debian 13 / git 2.47.3 / git-filter-repo 2.47.0 (
pip
로 임시 venv에 도입)
- GitHub가 아닌, 로컬의 bare 리포지토리를
origin
으로 가정하고 확인했습니다.
- 넣은 값들은 모두 더미입니다 (
DUMMY_API_KEY=dummy_not_a_secret_123 등). 검증 후 삭제했습니다.
확인하지 못한 부분
- GitHub 상에서의 동작 (캐시된 표시, PR 참조, 포크). 이 부분은 GitHub 공식 문서의 내용만 소개합니다.
- macOS와 Windows에서의 동작.
- Claude Code에서
Read(./.env)를 deny 했을 때 git show HEAD~1:.env가 멈추는지 여부 (후술).
.env와 config/.env.local을 커밋한 후, 흔히 사용하는 절차로 '관리 대상에서 제외'했습니다.
git add . && git commit -m
파일 이름을 바꿔서 커밋했더라도 이 방법으로 찾을 수 있습니다. 실제 키를 찾을 때는 키의 값 자체를 명령어에 입력하지 마세요. 쉘 히스토리에 남습니다. `API_KEY=` 와 같은 변수명으로 검색하는 것이 좋습니다(필자의 제안).
**추가 정보: stash에도 남아 있음**
`git stash -u`는 무시된 파일(ignored file)을 포함하지 않습니다. 필자 환경에서는 변경 사항이 `.env`에만 있었기 때문에 stash 자체가 생성되지 않았습니다. 반면, `git stash --all`은 무시된 파일을 포함하므로, `.env`가 stash에 들어갔습니다.
$ git stash -u
No local changes to save
$ git stash --all
...
stash는 로컬에만 남아 일반적인 push로는 전송되지 않습니다. 하지만 AI 에이전트가 `git show`로 읽을 수 있는 장소가 하나 늘어납니다.
GitHub의 공식 문서는 민감 정보라면 처음에 revoke(취소)나 rotate(순환/교체)를 하도록 작성되어 있습니다. 재발급하면 오래된 키는 더 이상 사용할 수 없게 되므로, **이것만으로 충분하다면 히스토리 수정까지 할 필요가 없을 수도 있다**고도 적혀 있습니다.
히스토리를 수정하더라도 그전에 누군가가 가져간 키는 계속 사용 가능합니다. 순서를 거꾸로 하지 마세요.
`--sensitive-data-removal`은 git-filter-repo 2.47 이상에서 사용할 수 있습니다(GitHub 공식).
먼저 작업하던 리포지토리에서 그대로 실행해 보았습니다. 그러자 이렇게 나오며 멈췄습니다.
$ git filter-repo --sensitive-data-removal --invert-paths --path .env --path config/.env.local
Aborting: Refusing to destructively overwrite repo history since
this does not look like a fresh clone.
...
GitHub의 절차도 먼저 클론한 후 그 안에서 실행하는 흐름입니다. 새로 클론하여 다시 실행했습니다(로컬 리포지토리에서 클론했기 때문에 `--no-local`을 추가했습니다).
$ git clone -q --no-local origin.git fresh
$ cd fresh
$ git filter-repo --sensitive-data-removal --invert-paths --path .env --path config/.env.local
...
-
`--path`에는 파일 이름뿐만 아니라 리포지토리 내의 경로를 작성합니다. 이동이나 이름 변경을 한 적이 있다면, 이전 경로도 `--path`에 추가하세요(GitHub 공식). -
`First Changed Commit(s)` 값은 GitHub Support에 연락할 때 사용합니다(GitHub 공식).
$ git log --all --full-history --oneline -- '.env*' '**/.env*'
$ git grep -n dummy_ $(git rev-list --all)
$ git cat-file -p 17f709d7339578e5b4b54e9d4153693ddf609837
...
검색 명령어 두 개 모두 아무것도 출력하지 않게 되었고, 제2장 ②에서 언급한 blob의 해시도 발견되지 않았습니다. 커밋 해시는 전부 바뀌었습니다.
`--sensitive-data-removal`을 추가한 이번에는 `origin` 설정이 남아 있었습니다(`git remote -v`로 확인). 다른 새로운 클론에서 이 옵션을 붙이지 않고 실행했을 때는 `origin`
설정값이 사라졌습니다. 다음 push 전에 `git remote -v`를 확인해 두는 것이 안전합니다 (작성자 환경에서 확인).
$ git push --force --mirror origin
To /path/to/origin.git
- 74d5f5a...db361b5 main -> main (forced update)
(경로는 수정했습니다)
GitHub에서는 `refs/pull/`로 시작하는 참조는 읽기 전용이므로 push가 실패합니다 (GitHub 공식). 브랜치 보호 설정으로 force push를 금지했다면, 일시적으로 해제해야 합니다.
**① 재작성 이전의 클론 (실물 환경에서 확인)**
재작성 전에 만들어 둔 다른 클론에서는 구(舊) blob도 구(舊) 커밋도 그대로 읽을 수 있었습니다.
$ git cat-file -p 17f709d7339578e5b4b54e9d4153693ddf609837
DUMMY_API_KEY=dummy_not_a_secret_123
$ git show HEAD~1:.env
...
GitHub 공식에 따르면, 오래된 클론을 가진 사람이 재작성 후에 `git pull`을 하고 `git push`를 하면 비밀 정보가 다시 돌아옵니다. 오래된 클론은 버리고 새로 클론 받는 것이 확실합니다.
**② 원격 측에도 잠시 남아있다 (실물 환경에서 확인)**
force push 직후의 bare 리포지토리에는 구(舊) blob이 아직 남아 있었습니다. `git gc --prune=now`를 실행하여야 겨우 사라졌습니다.
$ git --git-dir=origin.git cat-file -t 17f709d7339578e5b4b54e9d4153693ddf609837
blob
$ git --git-dir=origin.git gc -q --prune=now
...
이것은 스스로 관리하는 bare 리포지토리에서의 결과입니다. GitHub에서는 서버 측의 gc를 자신이 실행할 수 없습니다.
**③ GitHub 상 (공식 기재 내용만. 실물 환경에서 미확인)**
GitHub 공식에 따르면, 재작성하고 force push를 한 후에도 다음 장소에서는 읽을 수 있습니다.
- 클론 및 포크(fork)
- SHA를 직접 지정한 GitHub 상의 캐시된 표시
- 해당 커밋을 참조하는 PR
캐시된 표시와 PR의 참조는 GitHub Support에 요청하면 삭제해 줄 수 있습니다. 다만, **키 로테이션(key rotation)으로 리스크를 낮출 수 있는 경우에는 대응하지 않는다**고 명시되어 있습니다. 포크에 남아있는 것은 포크 소유자에게 의지할 수밖에 없습니다.
Claude Code의 공식 문서에서는 `Read`의 deny가 효력이 미치는 범위를 다음과 같이 설명합니다.
- 내장 파일 도구와 Bash에서 인식 가능한 파일 명령어 (`cat`, `head`, `tail`, `sed`, `tee`) 및 리다이렉트 대상에는 효력이 있다. - 파일명을 지정하지 않고 읽는 명령어나, 스크립트처럼 간접적으로 파일을 읽는 서브 프로세스에는 효력이 없다. OS 레벨에서 막고 싶다면 sandbox를 사용해야 한다.
-
`git`의 읽기 관련 명령어는 확인 없이 실행되는 내장 읽기 전용 명령어에 포함된다.
`git show HEAD~1:.env`는 작업 트리의 `.env`가 아니라 `.git` 안의 오브젝트를 읽습니다. 따라서 Claude Code에서 `Read(./.env)`를 deny 하더라도, **막히지 않을 가능성이 있습니다. 다만, 이것은 공식 기재 내용을 바탕으로 추론한 것이며, 실물 환경에서는 확인하지 못했습니다.** Codex CLI에서는 deny를 설정해도 `git show HEAD:.env`로 읽을 수 있었던 것을 지난 글에서 확인했습니다.
AI에게 보여주고 싶지 않은 키가 히스토리에 한 번이라도 들어갔다면, deny 설정을 하기보다 먼저 키 재발급이 필요합니다.
GitHub 공식이 제시하는 방법입니다.
-
`.gitignore`에 `.env`를 작성하고, 그 파일 자체도 커밋하여 공유한다 -
`git add .`나 `git commit -a`를 피하고, 파일을 하나씩 추가한다. `git diff --cached`
내용을 확인한 후 커밋하기 - pre-commit 훅으로 비밀 정보 탐지하기 (git-secrets 또는 gitleaks 등)
- GitHub의 push protection 활성화
AI 에이전트 측에서는 다음 설정도 함께 진행해 주세요.
- Claude Code: settings.json의 permissions.deny
- Codex CLI: permission profile의 deny
- 어떤 에이전트가 `.env`를 읽을 수 있는지 점검하기: 파일 이름만으로 점검하는 일본어 CLI
- `git rm --cached`와 `.gitignore`만으로는 과거 커밋에 `.env`가 남는다 (작성자 환경에서 확인). - `git log --all --full-history`, `git rev-list --all --objects`, 모든 커밋에 대한 `git grep`으로 찾을 수 있다. - 발견했다면, 먼저 키를 재발급해야 한다. 그 후에 필요하다면, 새로 받은 클론에서 `git filter-repo --sensitive-data-removal`을 실행하고 force push한다. 오래된 클론, 포크, GitHub 상의 캐시에는 남아있다 (클론은 실기, 나머지는 공식).
관련 기사:
- Codex CLI에 .env를 읽지 못하게 설정: 공식 예제인 "**/*.env"만으로는 .env.local과 깊은 계층이 읽혔다
- Claude Code에 .env를 읽지 못하게 설정은 settings.json의 permissions.deny에 작성한다
- Claude Code의 대화 기록은 ~/.claude/projects/에 평문으로 30일 남는다
- AI 에이전트가 .env를 읽었는지, 파일 이름만으로 점검하는 일본어 CLI를 만들었다
참고 (공식):
- Removing sensitive data from a repository – GitHub Docs
- git-filter-repo(newren/git-filter-repo)
- git-stash 문서
- Configure permissions – Claude Code Docs
※ 사양은 2026-10-19 시점의 공식 문서를 기반으로 합니다. 검증은 git 2.47.3 / git-filter-repo 2.47.0에서, Linux의 일회용 환경에서만 진행했습니다.
업데이트 정보나 간단한 Tips는 X (@supportapp3_lab)에서 발신하고 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58