사내 저장소를 정리하다 보면 config/deploy.env 같은 파일이 3년 전 커밋에 그대로 들어 있는 경우를 종종 만난다. 최신 커밋에서는 이미 지워져 있어서 눈에 안 띄다가, 저장소를 공개로 전환하거나 외주 개발자에게 클론을 넘기는 순간 문제가 된다. 이 글은 그 상황을 처음부터 끝까지 처리하는 절차를 정리한 것이다.
정리하면 이렇다. 히스토리를 다시 쓰기 전에 해당 키를 먼저 폐기하고 새로 발급해야 한다. 이미 유출된 값은 되돌릴 수 없기 때문이다. 그다음 git filter-repo --replace-text 또는 --invert-paths --path로 과거 커밋의 블롭을 다시 쓰고, git push --force로 원격을 덮어쓴 뒤, gitleaks pre-commit 훅으로 같은 사고가 반복되지 않게 막는다. GitHub 공식 문서도 BFG가 아니라 git filter-repo를 권장한다.
1. 히스토리 재작성이 무엇을 하는 일인가
Git은 파일 내용을 블롭(blob)이라는 객체로 저장하고, 커밋은 그 블롭을 가리키는 트리를 참조한다. 어떤 커밋에서 .env 파일을 git rm으로 지워도 그건 "이 시점부터 이 파일이 없다"는 새 커밋을 하나 더 쌓는 것일 뿐이고, 예전 커밋이 가리키던 블롭은 저장소 안에 그대로 남는다. git show <옛커밋>:config/deploy.env 한 줄이면 누구든 꺼내 볼 수 있다.
그래서 진짜로 지우려면 그 블롭을 참조하는 모든 커밋을 다시 만들어야 한다. 커밋 내용이 바뀌면 커밋 해시가 바뀌고, 그 뒤의 모든 커밋 해시도 연쇄적으로 바뀐다. 히스토리 재작성이 파괴적인 작업으로 취급되는 이유가 이것이다.
아래에서는 키 로테이션, 노출 지점 조사, git filter-repo 세척, BFG 대안, 강제 푸시와 팀 정리, pre-commit 방어를 차례로 살펴본다.
2. 먼저 키를 폐기한다
순서를 바꾸면 안 된다. 커밋된 시점부터 지금까지 그 키는 이미 외부에 노출된 것으로 간주해야 한다. 저장소가 공개였다면 봇이 수집했을 가능성이 높고, 비공개였더라도 클론을 가진 사람 수만큼 사본이 있다. GitHub 문서도 같은 순서를 명시한다. 비밀이 password, token, credential 종류라면 revoke 또는 rotate가 먼저이고, 그것만으로 위험이 사라지면 히스토리 재작성이 불필요할 수도 있다.
실무에서 자주 놓치는 지점은 키를 새로 발급한 뒤 옛 키를 실제로 비활성화했는지 확인하는 단계다. AWS IAM 액세스 키는 새 키를 만들고 배포까지 끝낸 다음 옛 키를 비활성화하고, 며칠 두었다가 삭제한다.
$ aws iam create-access-key --user-name deploy-bot
$ aws iam update-access-key --user-name deploy-bot \
--access-key-id AKIAIOSFODNN7EXAMPLE --status Inactive
$ aws iam list-access-keys --user-name deploy-bot
{
"AccessKeyMetadata": [
{
"UserName": "deploy-bot",
"AccessKeyId": "AKIAIOSFODNN7EXAMPLE",
"Status": "Inactive",
"CreateDate": "2023-04-11T02:18:44+00:00"
},
{
"UserName": "deploy-bot",
"AccessKeyId": "AKIAI44QH8DHBEXAMPLE",
"Status": "Active",
"CreateDate": "2026-08-06T01:12:09+00:00"
}
]
}
DB 비밀번호나 서드파티 API 키도 같다. CloudTrail이나 각 서비스의 감사 로그에서 옛 키의 마지막 사용 시각을 확인해 두면, 세척이 끝난 뒤 "어디서 아직 옛 키를 쓰고 있나"를 추적하기 쉽다.
3. 어디에 무엇이 박혀 있는지 조사한다
세척 대상을 정확히 알아야 명령을 한 번에 끝낼 수 있다. 조사에는 gitleaks가 가장 편하다. gitleaks는 정규식과 엔트로피 기반으로 커밋 히스토리에서 자격증명 패턴을 찾아내는 스캐너다.
$ brew install gitleaks
$ gitleaks version
8.30.1
$ gitleaks git -v --redact --log-opts="--all" .
○
│╲
│ ○
○ ░
░ gitleaks
Finding: AWS_SECRET_ACCESS_KEY=REDACTED
Secret: REDACTED
RuleID: aws-access-token
Entropy: 4.204159
File: config/deploy.env
Line: 12
Commit: c41f8a2d9b6e0f3a17d5c8b4e2a90f7d63b1c085
Author: Jin Choi
Email: jin@example.com
Date: 2023-04-11T02:31:07Z
Fingerprint: c41f8a2d9b6e0f3a17d5c8b4e2a90f7d63b1c085:config/deploy.env:aws-access-token:12
INF 412 commits scanned.
WRN leaks found: 3
--log-opts="--all"를 빼면 현재 브랜치만 훑는다. 다른 브랜치나 태그에만 남아 있는 시크릿을 놓치기 쉬우니 조사 단계에서는 항상 붙인다.
여기서 한 번 걸리는 지점이 있다. 오래된 블로그 글을 따라 gitleaks detect나 gitleaks protect를 치는 경우가 많은데, 두 명령은 v8.19.0에서 deprecated 처리되어 --help 목록에서 빠졌다. 아직 동작은 하지만 지금은 git, dir, stdin 세 가지가 정식 명령이다.
특정 문자열이 어느 커밋에서 들어오고 나갔는지는 -S(pickaxe)로 바로 확인할 수 있다.
$ git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE' 7d2b1e9 Remove env file from repo c41f8a2 Add deploy script
git filter-repo에는 저장소 구성을 미리 훑어보는 --analyze 옵션도 있다. 어떤 경로가 언제 삭제됐는지, 어떤 블롭이 큰지 리포트를 만들어 준다.
$ git filter-repo --analyze Processed 3520 blob sizes Processed 412 commits Writing reports to .git/filter-repo/analysis...done. $ ls .git/filter-repo/analysis/ blob-shas-and-paths.txt directories-deleted-sizes.txt directories-all-sizes.txt extensions-all-sizes.txt path-all-sizes.txt path-deleted-sizes.txt renames.txt
4. git filter-repo 설치
git filter-repo는 git filter-branch를 대체하는 히스토리 재작성 도구다. filter-branch보다 수십 배 빠르고, 커밋마다 셸을 띄우지 않아 결과가 예측 가능하다. Git 공식 문서의 filter-branch man 페이지도 filter-repo 사용을 권한다.
전제 조건은 git 2.36.0 이상, python3 3.6 이상이다. 설치는 패키지 매니저가 가장 간단하다.
$ brew install git-filter-repo # macOS $ sudo dnf install git-filter-repo # Fedora / RHEL 계열 $ sudo apt install git-filter-repo # Debian / Ubuntu $ pipx install git-filter-repo # PyPI $ git filter-repo --version 2.47.0 $ git --version git version 2.51.0
버전 번호는 저장소 상태에 따라 다를 수 있다. 다만 GitHub이 안내하는 --sensitive-data-removal 옵션은 2.47 이상에서만 쓸 수 있으니, 배포판 패키지가 오래됐다면 pipx나 단일 스크립트 설치로 올려 두는 쪽이 낫다.
5. git filter-repo로 과거 커밋 세척
5.1 신선한 미러 클론에서 작업한다
filter-repo는 작업 중인 저장소를 망가뜨리지 않으려고, 클론 직후 상태가 아니면 실행을 거부한다.
$ git filter-repo --replace-text ../secrets.txt Aborting: Refusing to destructively overwrite repo history since this does not look like a fresh clone. (expected freshly packed repo) Please operate on a fresh clone instead. If you want to proceed anyway, use --force.
여기서 --force로 밀어붙이지 말고 새로 클론한다. 모든 ref를 가져오려면 --mirror를 쓴다.
$ cd /tmp $ git clone --mirror https://github.com/acme/api.git Cloning into bare repository 'api.git'... remote: Enumerating objects: 1834, done. remote: Total 1834 (delta 0), reused 0 (delta 0), pack-reused 1834 Receiving objects: 100% (1834/1834), 2.41 MiB | 6.02 MiB/s, done. Resolving deltas: 100% (912/912), done. $ cp -a api.git api.git.bak # 되돌릴 수 있게 백업
백업은 선택이 아니다. filter-repo는 재작성 후 원본 객체를 정리해 버리므로, 규칙을 잘못 쓴 걸 나중에 알아채면 백업 없이는 복구가 어렵다.
5.2 치환 규칙 파일 작성
--replace-text는 표현식 파일을 받아 블롭 내용을 치환한다. 한 줄에 규칙 하나이고, 접두사로 매칭 방식을 지정한다. 접두사가 없으면 literal:로 간주하고, ==> 뒤에 치환값을 적는다. 치환값을 생략하면 기본값 ***REMOVED***가 들어간다.
$ cat ../secrets.txt AKIAIOSFODNN7EXAMPLE literal:wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY==>AWS_SECRET_KEY regex:postgres://[^:@/]+:[^@]+@==>postgres://USER:PASSWORD@ glob:slack_token=xoxb-*==>slack_token=REDACTED
첫 줄은 접두사가 없으니 리터럴 매칭이고, 결과는 ***REMOVED***로 바뀐다. 정규식은 Python 문법을 그대로 쓴다.
RSA 개인키처럼 여러 줄에 걸친 비밀은 이 방식으로 다루기 번거롭다. 이런 건 파일 자체를 히스토리에서 들어내는 쪽이 확실하다.
5.3 실행
문자열 치환은 다음과 같다.
$ cd /tmp/api.git $ git filter-repo --sensitive-data-removal --replace-text ../secrets.txt Parsed 412 commits New history written in 1.83 seconds; now repacking/cleaning... Repacking your repo and cleaning out old unneeded objects Enumerating objects: 1791, done. Counting objects: 100% (1791/1791), done. Delta compression using up to 8 threads Compressing objects: 100% (874/874), done. Writing objects: 100% (1791/1791), done. Total 1791 (delta 889), reused 1783 (delta 884), pack-reused 0 Completely finished after 4.71 seconds.
소요 시간은 저장소 크기와 디스크 성능에 따라 크게 달라진다. 커밋 수백 개 규모면 몇 초, 수십만 커밋짜리 모노레포면 수 분에서 그 이상 걸린다.
파일 통째로 제거하려면 --invert-paths와 --path를 조합한다. --path는 원래 "남길 경로"를 지정하는 옵션이고, --invert-paths가 그 선택을 뒤집어 "지정한 경로만 제외"로 만든다.
$ git filter-repo --sensitive-data-removal --invert-paths \
--path config/deploy.env --path deploy/id_rsa
Parsed 412 commits
New history written in 1.62 seconds; now repacking/cleaning...
...
Completely finished after 4.09 seconds.
--sensitive-data-removal은 정보 유출 대응 전용 모드다. 모든 ref를 가져오고, 고아가 된 LFS 객체를 추적하고, 업스트림 사본까지 정리하는 안내를 추가로 출력한다. GitHub 문서가 예시로 드는 형태가 이것이다.
5.4 결과 확인
재작성 결과는 .git/filter-repo/ 아래에 남는다. commit-map은 옛 커밋 해시와 새 해시의 대응표다.
$ head -3 filter-repo/commit-map old new c41f8a2d9b6e0f3a17d5c8b4e2a90f7d63b1c085 5b0d93f1a742c6e8095fb3d21c7a4e6089d5f312 7d2b1e9f04a83c5d6b21e9f70a4c38d5b6e91027 a917c4e5b03d82f16ca7509e4bd3182f7c05a9e4 $ git log --all --oneline -S 'AKIAIOSFODNN7EXAMPLE' $ gitleaks git -v --redact --log-opts="--all" . INF 412 commits scanned. INF no leaks found
pickaxe 결과가 비어 있고 gitleaks가 no leaks found를 내면 로컬 세척은 끝난 것이다.
6. BFG Repo-Cleaner를 쓰는 경우
BFG는 스칼라로 작성된 히스토리 세척 도구로, 옵션이 단순하고 대형 저장소에서 빠르다. 1.15.0 기준 Java 11 이상이 필요하다(Java 8 지원은 1.14.0이 마지막이다).
기본 선택은 git filter-repo로 잡는 것을 권한다. 경로 필터링, 커밋 메시지 치환, 메일맵 적용 같은 기능이 훨씬 넓고 GitHub 공식 문서도 filter-repo만 안내한다. BFG는 Python을 깔기 곤란한 CI 이미지이거나, 이미 BFG 기반 절차가 문서화되어 있는 팀에서 쓴다.
$ brew install bfg $ git clone --mirror https://github.com/acme/api.git $ bfg --replace-text ../secrets.txt api.git Using repo : /tmp/api.git Found 412 commits Cleaning -------- Found 1791 objects to protect Found 14 commit-pointing refs : HEAD, refs/heads/develop, refs/heads/main, ... Protected commits ----------------- These are your protected commits, and so their contents will NOT be altered: * commit 9a3f21ca (protected by 'HEAD') Cleaning -------- Commit file-path : 100% (412/412) Updating 87 Commits ... BFG run is complete! When ready, run: git reflog expire --expire=now --all && git gc --prune=now --aggressive
BFG의 가장 큰 함정은 HEAD 커밋을 건드리지 않는다는 기본 동작이다. 배포 중인 최신 코드가 깨지지 않게 하려는 설계인데, 시크릿이 최신 커밋에도 남아 있으면 그건 그대로 살아남는다. 그래서 BFG를 돌리기 전에 워킹트리에서 시크릿을 지우고 커밋을 하나 만들어 두어야 한다. 그러지 않으면 이런 경고가 뜬다.
Protected commits ----------------- These are your protected commits, and so their contents will NOT be altered: * commit 9a3f21ca (protected by 'HEAD') - contains 1 dirty file : - config/deploy.env (1.2 KB) WARNING: The dirty content above may be removed from other commits, but as it still exists in the protected commits, it StIll exists in your repository.
--no-blob-protection으로 이 보호를 끌 수 있지만 공식 문서도 권장하지 않는다. HEAD를 먼저 정리하는 쪽이 맞다. 또 BFG는 --replace-text와 --delete-files가 파일 이름과 내용 단위로 동작하고 경로 지정이 안 된다. --delete-files id_rsa는 저장소 어디에 있든 그 이름의 파일을 전부 지운다.
BFG는 filter-repo와 달리 정리를 자동으로 하지 않으므로 마지막에 직접 돌려야 한다.
$ cd api.git $ git reflog expire --expire=now --all && git gc --prune=now --aggressive Enumerating objects: 1791, done. Counting objects: 100% (1791/1791), done. ... Total 1791 (delta 889), reused 1783 (delta 884), pack-reused 0
7. 강제 푸시와 팀 정리
7.1 원격 덮어쓰기
filter-repo는 재작성이 끝나면 origin 리모트를 삭제한다. 옛 히스토리와 새 히스토리가 섞여 푸시되는 사고를 막으려는 의도다. 그래서 푸시 전에 리모트를 다시 등록해야 한다.
$ git remote -v $ git remote add origin https://github.com/acme/api.git $ git push --force --mirror origin
--mirror 푸시는 로컬에 없는 원격 ref를 삭제한다. 신선한 미러 클론에서 작업했다면 문제없지만, 일반 클론에서 이걸 던지면 동료 브랜치가 통째로 날아갈 수 있다. 일반 클론이라면 범위를 명시하는 편이 안전하다.
$ git push --force origin 'refs/heads/*' 'refs/tags/*'
GitHub 미러 클론에서 흔히 만나는 에러가 하나 더 있다. 미러 클론은 refs/pull/*까지 가져오는데, 이 ref는 GitHub이 서버에서 관리하므로 푸시가 거부된다.
$ git push --force --mirror origin ! [remote rejected] refs/pull/12/head -> refs/pull/12/head (deny updating a hidden ref) ! [remote rejected] refs/pull/13/head -> refs/pull/13/head (deny updating a hidden ref) error: failed to push some refs to 'https://github.com/acme/api.git'
로컬에서 해당 ref를 지우고 다시 푸시하면 된다.
$ git for-each-ref --format='delete %(refname)' 'refs/pull' | git update-ref --stdin $ git push --force --mirror origin
보호된 브랜치(protected branch) 설정이 켜져 있으면 force push가 거부된다. 작업 전에 잠시 해제하고, 끝나면 곧바로 복구한다.
7.2 협업자 재클론
여기서 방심하면 세척이 무의미해진다. 옛 히스토리를 가진 클론에서 누군가 git pull 후 git push를 하면 지웠던 객체가 그대로 되돌아올 수 있다. GitHub 문서도 이 재오염 위험을 명시한다.
가장 확실한 방법은 전원 재클론이다. 작업 중이던 브랜치가 있으면 새 클론에서 패치를 다시 얹는다.
# 옛 클론에서 작업분만 추출 $ git format-patch origin/main..HEAD -o /tmp/my-work # 새로 클론한 저장소에서 $ git checkout -b feature/x $ git am /tmp/my-work/*.patch
옛 클론 디렉터리는 지운다. 남겨 두면 몇 달 뒤에 누군가 거기서 푸시한다.
8. 원격 서비스에 남는 것들
force push로 브랜치를 덮어써도 GitHub 쪽에는 세 가지가 남을 수 있다.
Pull Request의 diff 캐시. PR 화면이 참조하는 내부 ref는 push로 갱신되지 않는다. 문제의 커밋을 도입한 PR뿐 아니라 그 히스토리 위에 쌓인 이후 PR들도 영향을 받는다.
커밋 URL 직접 접근. 옛 커밋 SHA를 알고 있으면 한동안 웹에서 열린다. 서버에서 GC가 돌기 전까지는 접근 가능한 상태가 유지될 수 있다.
포크. 포크된 저장소의 사본은 원본 소유자가 지울 수 없다. 포크 소유자와 직접 조율해야 한다.
이 캐시를 지우려면 GitHub Support에 저장소 이름, 영향받은 PR 개수, 처음 변경된 커밋 정보를 정리해 요청한다. Support는 PR의 참조를 끊고 GC를 돌린 뒤 캐시된 뷰를 제거하는데, 문서에 따르면 자격증명 로테이션만으로 위험을 줄일 수 없다고 판단되는 경우에 한해 처리한다. 2번을 먼저 해 두어야 하는 이유가 여기에도 있다.
9. 재유입 차단: pre-commit + gitleaks
한 번 세척했으면 같은 사고가 재발하지 않게 커밋 단계에서 막는다. pre-commit 프레임워크와 gitleaks 조합이 설정 부담이 가장 적다.
$ cat .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
$ pre-commit install
pre-commit installed at .git/hooks/pre-commit
이 훅이 실제로 실행하는 명령은 gitleaks git --pre-commit --redact --staged --verbose다. 스테이징된 변경분만 검사하므로 커밋할 때마다 히스토리 전체를 훑지 않는다.
id: gitleaks는 language가 golang이라 pre-commit이 gitleaks를 직접 빌드한다. 이미 바이너리를 설치해 뒀다면 id: gitleaks-system을 쓰면 훅 설치가 빨라지고, Go 툴체인이 없는 환경에서는 id: gitleaks-docker를 쓴다.
실제로 걸리면 커밋이 이렇게 중단된다.
$ git commit -m "add deploy config" Detect hardcoded secrets.................................................Failed - hook id: gitleaks - exit code: 1 Finding: aws_secret_access_key = REDACTED Secret: REDACTED RuleID: aws-access-token File: config/deploy.env Line: 12 Fingerprint: config/deploy.env:aws-access-token:12 WRN leaks found: 1
9.1 오탐 처리
테스트 픽스처의 더미 토큰 같은 건 오탐이 난다. 세 가지 방식으로 걸러낸다.
gitleaks:allow 주석. 해당 줄 하나만 무시한다. 가장 좁은 범위라 우선 고려한다.
const testKey = "AKIAIOSFODNN7EXAMPLE" // gitleaks:allow
.gitleaksignore 파일. 스캔 결과의 fingerprint 값을 그대로 한 줄씩 적는다.
$ cat .gitleaksignore config/deploy.env:aws-access-token:12 test/fixtures/sample.json:generic-api-key:7
.gitleaks.toml 허용 규칙. 경로 패턴 단위로 예외를 준다. 설정 파일 우선순위는 --config 플래그, GITLEAKS_CONFIG 환경변수, 대상 디렉터리의 .gitleaks.toml, 내장 기본값 순이다.
$ cat .gitleaks.toml
[extend]
useDefault = true
[[rules]]
id = "generic-api-key"
[rules.allowlist]
paths = [
'''test/fixtures/.*''',
'''docs/examples/.*''',
]
useDefault = true를 빼먹으면 내장 규칙이 전부 사라져 아무것도 안 잡힌다. 커스텀 설정을 처음 쓸 때 가장 흔히 걸리는 지점이다.
9.2 CI에도 같은 검사를 건다
훅은 로컬에만 있고 SKIP=gitleaks git commit이나 --no-verify로 우회된다. 서버 쪽 방어선이 반드시 필요하다.
$ gitleaks git -v --redact --log-opts="--all" --exit-code 1 .
기존 저장소에 CI를 붙일 때 옛 탐지 결과가 잔뜩 나오면 --baseline-path로 기준선을 만들어 두고 신규 탐지만 실패로 처리한다.
$ gitleaks git --report-path baseline.json --log-opts="--all" . $ gitleaks git --baseline-path baseline.json -v --redact .
GitHub 저장소라면 Push Protection도 함께 켜 둔다. gitleaks가 놓친 패턴을 서비스 쪽에서 잡아 주는 이중 방어가 된다.
10. 마무리
순서를 다시 정리하면 이렇다. 키 폐기와 재발급, gitleaks와 pickaxe로 노출 범위 조사, 신선한 미러 클론 확보와 백업, git filter-repo로 세척, force push, 협업자 전원 재클론, GitHub Support에 캐시 제거 요청, pre-commit과 CI로 재유입 차단.
도구 선택은 git filter-repo를 기본으로 잡는다. BFG는 Java만 있는 환경이나 이미 절차가 굳어진 팀에서 쓰되, HEAD 커밋을 보호한다는 점과 정리 명령을 직접 돌려야 한다는 점을 기억해 둔다.
그리고 히스토리 재작성은 마지막 수단이다. 처음부터 .env를 .gitignore에 넣고, 시크릿은 AWS Secrets Manager나 SSM Parameter Store 같은 곳에 두고 런타임에 주입하면 이 작업을 할 일 자체가 생기지 않는다.