.git 폴더 노출은 웹 서버가 내주는 경로 안에 소스 저장소의 .git 디렉터리가 들어가 누구나 URL로 읽을 수 있는 상태를 말하며, 파일 하나가 아니라 소스코드와 커밋 기록 전체가 복원 대상이 된다.
서버에서 git pull 로 배포하며 저장소 폴더를 그대로 웹 루트로 쓰거나, 프로젝트 폴더를 통째로 업로드하거나, Dockerfile 에 COPY . . 한 줄을 넣으면 .git 이 서빙 경로 안으로 따라 들어온다. 웹 서버는 그 폴더가 소스 관리용인지 구분하지 않고 다른 정적 파일처럼 내준다. MITRE 는 이 유형을 CWE-527 로 분류하고, 버전 관리 하위 폴더가 웹 서버에 있으면 사용자 이름, 파일 이름, 경로, IP 주소, 변경 내역(diff)이 드러나 공개할 생각이 없던 소스코드 조각까지 나올 수 있다고 설명한다. OWASP 웹 보안 테스트 가이드도 백업·미참조 파일 점검 항목에서 .git/ 을 확장자가 아닌 특수 경로의 예로 든다. 공개된 복원 도구는 디렉터리 목록이 꺼져 있어도 HEAD, config, index 같은 정해진 파일부터 따라가 저장소를 되살리도록 만들어져 있어서, 이미 지운 비밀값도 과거 커밋에 남아 있었다면 같은 길로 나갈 수 있다.
이 글은 자주 발생하는 실수 패턴, 점검·조치 절차, 자가진단을 정리했다.
한눈에 보는 핵심
디렉터리 목록을 꺼 두었는데도 .git 이 열려 있으면 위험한가요?
위험합니다. 공개된 복원 도구(git-dumper)의 설명에 따르면 목록이 꺼져 있을 때는 HEAD, index, config, packed-refs 같은 정해진 파일을 직접 요청해 참조와 객체 해시를 찾고, 객체를 하나씩 받은 뒤 마지막에 작업 트리까지 되살립니다. 목록 설정은 이 과정과 상관이 없어서, 막으려면 .git 경로 자체를 서버가 내주지 않게 해야 합니다. 가능한 만큼 찾아 받는 방식이라 항상 완전하게 복원된다고 단정할 수는 없지만, 받아 간 만큼의 소스와 설정은 이미 나간 것으로 봐야 합니다.
저장소를 비공개로 쓰고 있는데도 .git 노출이 문제가 되나요?
됩니다. 비공개는 저장소 호스팅 서비스 쪽의 접근 권한이고, 서버에 놓인 .git 폴더는 웹 서버가 내주는 순간 별개의 사본이 됩니다. 사본에는 소스코드와 커밋 기록, 원격 저장소 주소가 적힌 config 가 들어 있어서, 운영 서버를 건드리지 않고 소스를 오프라인으로 읽으며 숨은 관리자 경로나 하드코딩된 키, 인증 로직의 빈틈을 찾을 수 있습니다.
저장소에서 이미 지운 .env 나 키도 .git 안에 남아 있나요?
남아 있을 수 있습니다. 파일을 지우는 커밋은 새 기록을 하나 더 쌓을 뿐이라, 지우기 전 커밋에는 내용이 그대로 있습니다. .git 이 열렸다면 그 기록도 함께 나갈 수 있으니 지운 비밀값도 교체 대상으로 봐야 합니다. 기록에서 지우는 작업은 교체가 끝난 뒤의 정리이고, 교체를 대신하지 못합니다.
점검했더니 403 이 나옵니다. 서버에 .git 폴더가 있다는 뜻인가요?
단정할 수 없습니다. 차단 규칙이 deny 방식이거나 앞단 방화벽이 대신 막으면 폴더가 있든 없든 403 이 나오고, 반대로 폴더가 있는데 파일 권한 때문에 막힌 경우에도 403 이 나옵니다. 어느 쪽인지는 서버에 들어가 웹 루트를 ls -a 로 직접 보는 것이 확실합니다. 노출 판정은 200 응답에 본문이 ref: 로 시작하거나 config 에 [core] 가 보일 때입니다.
자주 발생하는 실수 패턴
| # | 패턴 | 위험도 |
|---|---|---|
| 1 | 서버에서 git clone·pull 로 배포하면서 웹 서버의 root 를 저장소 최상위 폴더로 지정 | 치명 |
| 2 | 프로젝트 폴더를 통째로 올리는 방식(정적 호스팅 업로드, FTP, rsync 등)에서 .git 을 빼지 않음 | 치명 |
| 3 | Dockerfile 의 COPY . . 에 .dockerignore 가 없어 .git 이 이미지에 들어가고, 그 폴더를 웹 서버가 서빙함 | 높음 |
| 4 | 차단 규칙을 넣었다고 믿지만 실제로는 안 걸림: Apache FilesMatch 는 파일 이름만 보고, nginx 정규식 location 은 ^~ 접두 location 안쪽에서 검사가 건너뛰어짐 | 높음 |
| 5 | 사이트 루트(/.git/)만 확인하고 /blog/ 나 테마·플러그인 폴더 같은 하위 경로에 따로 올라간 저장소를 놓침 | 높음 |
| 6 | 접근만 막고 끝냄: 노출 기간에 저장소와 기록에 있던 비밀값과 .git/config 원격 주소의 토큰을 교체하지 않음 | 치명 |
차단 설정과 배포 방식 수정
# ===== nginx: server 블록 바로 아래(location 바깥)에 넣고 nginx -t 로 확인한 뒤 reload =====
# 점으로 시작하는 경로(.git .svn .env .DS_Store 등)를 404 로 닫는다.
# .well-known 은 인증서 갱신(HTTP-01)과 security.txt(RFC 9116)가 쓰므로 예외로 둔다.
# server 레벨의 if 는 location 을 고르기 전에 실행되므로, 다른 정규식 location 이나
# ^~ 접두 location 에 가려지지 않는다.
if ($uri ~* "/\.(?!well-known/)") {
return 404;
}
# ===== Apache: 가상호스트 또는 .htaccess (.htaccess 는 AllowOverride FileInfo 필요) =====
# FilesMatch "^\." 는 파일 이름(마지막 구성요소)만 보기 때문에 /.git/HEAD 의 HEAD 를 막지 못한다.
# RedirectMatch 는 URL 경로 전체를 보므로 폴더 안의 파일까지 막는다.
RedirectMatch 404 "(?i)/\.(?!well-known/)"
# ===== 배포 방식: 막기 전에 .git 이 올라가지 않게 한다 =====
# (a) 서버에서 pull 하는 방식: 저장소는 웹 루트 밖에 두고, .git 없는 사본만 웹 루트에 푼다
cd /srv/app-repo; git pull; git archive HEAD | tar -xf - -C /var/www/app
# (b) Docker: 프로젝트 루트의 .dockerignore 파일에 한 줄을 추가하면 COPY . . 이 .git 을 이미지에 넣지 않는다
.git
# (c) S3 정적 호스팅: 현재 폴더를 올릴 때 .git 을 뺀다
aws s3 sync . s3://my-bucket --exclude ".git/*"
차단 규칙은 두 번째 방어선이고, 첫 번째는 .git 이 웹 루트에 올라가지 않게 배포 방식을 고치는 것이다.
점 경로를 통째로 막을 때는 .well-known 예외가 필수인데, Let's Encrypt 의 HTTP-01 검증이 /.well-known/acme-challenge/ 아래 파일을 가져가기 때문이다.
규칙을 넣은 뒤에는 certbot renew --dry-run 과 아래 자가진단으로 둘 다 확인한다.
자가진단
H=https://example.com
# 1) HEAD 확인. 200 이고 본문이 ref: refs/heads/main 같은 한 줄이면 노출이다.
# 브랜치 대신 커밋 해시 한 줄(detached HEAD)이어도 노출이다.
# 200 인데 본문이 HTML 이면 없는 경로에도 200 을 주는 사이트라서 노출이 아니다.
curl -s -o /dev/null -w '%{http_code}\n' --max-time 15 "$H/.git/HEAD"
curl -s --max-time 15 "$H/.git/HEAD" | head -c 100
# 2) config 확인. 내용을 화면에 띄우지 않고 표시만 센다(원격 주소에 토큰이 들어 있을 수 있다).
# 첫 줄이 1 이상이면 노출이다. 둘째 줄이 1 이상이면 원격 주소에 계정 정보가 들어 있으니 그 토큰을 바로 폐기한다.
curl -s --max-time 15 "$H/.git/config" | grep -cF '[core]'
curl -s --max-time 15 "$H/.git/config" | grep -cE 'url = https?://[^/@[:space:]]+@'
# 3) 403 은 폴더가 있다는 확인이 아니다. 없는 점 경로와 나란히 비교한다.
# 둘 다 403: 점 경로 전체를 막는 규칙이다. 폴더 유무는 이것만으로 알 수 없다.
# .git 만 403 이고 다른 쪽은 404: .git 전용 규칙이거나, 폴더가 있는데 권한으로 막힌 상태다.
# 404 면 이 경로로는 열려 있지 않다. 어느 쪽이든 4)로 서버에서 확인한다.
for p in .git/HEAD .zz-nope-81/HEAD; do
printf '%s ' "$p"; curl -s -o /dev/null -w '%{http_code}\n' --max-time 15 "$H/$p"
done
# 4) 서버에서 확인(경로는 본인 환경에 맞게). 웹 루트 아래 .git 이 하나라도 있으면 막혀 있어도 정리 대상이다.
find /var/www -maxdepth 6 -name .git -type d 2>/dev/null
# 컨테이너 이미지는 앱 폴더 목록에 .git 이 보이면 이미지에 들어간 것이다.
docker run --rm --entrypoint ls IMAGE_NAME -a /app
# 5) 하위 경로에 따로 올라간 저장소는 루트 확인만으로 놓친다. 본인 사이트의 경로로 바꿔 넣는다.
for d in blog app api admin old dev test; do
curl -s --max-time 15 "$H/$d/.git/HEAD" | grep -q '^ref:' && echo "노출 -> /$d/.git/"
done
# 6) 이미 열려 있었다면 접근 로그를 본다(combined 형식 기준, 9번째 칸이 상태 코드).
# 200 응답이 있고 /.git/objects/ 아래 경로가 연달아 찍혀 있으면 내려받기가 이뤄진 흔적으로 본다.
grep -F '/.git/' /var/log/nginx/access.log | awk '$9==200 {print $1, $7}' | sort | uniq -c | sort -rn | head
위 시그널이 발견되면 결함이다. MITRE CWE-527, 버전 관리 저장소 노출 정의 문서를 함께 참조하라.
조치 절차
| 순위 | 조치 |
|---|---|
| 1 | 접근부터 끊는다. 위 nginx·Apache 규칙을 적용해 reload 하고, 자가진단 1~3단계에서 .git 요청이 404(또는 403)로 바뀐 것을 확인한다. 같은 시점에 웹 루트의 .git 폴더를 웹 루트 밖으로 옮긴다. 규칙만 믿고 폴더를 그대로 두면 설정이 바뀌는 날 다시 열린다 |
| 2 | 저장소와 커밋 기록에 한 번이라도 들어간 비밀값을 전부 교체한다. 대상은 DB 비밀번호, API 키, 토큰, 서명 키, 그리고 .git/config 원격 주소에 박힌 토큰이다. 어떤 값이 있었는지는 git log --all --diff-filter=D --name-only --pretty=format: | sort -u 로 지워진 적 있는 파일 이름부터 훑어 .env 나 키 파일이 있었는지 본다. 접근 로그에 흔적이 없어도 로그 보관 기간 밖의 요청은 알 수 없으니 교체를 미루지 않는다 |
| 3 | 접근 로그에서 노출 기간의 /.git/ 요청을 추려 200 응답, 요청 IP, /.git/objects/ 아래 연속 요청이 있는지 본다. 교체 전 키나 이 서버로 개인정보에 접근할 수 있었다면 법적 통지·신고 의무가 있는지 별도로 판단한다 |
| 4 | 원격 저장소 쪽도 점검한다. config 에 있던 토큰으로 접근할 수 있던 저장소와 조직을 확인하고, 토큰을 폐기한 뒤 서버용으로는 읽기 권한만 있는 키나 만료되는 토큰으로 바꾼다 |
| 5 | 배포 방식을 고쳐 .git 이 다시 올라가지 않게 한다. 서버에서 pull 한 폴더를 그대로 서빙하지 말고 git archive 결과나 CI 빌드 산출물만 웹 루트에 둔다. Docker 는 .dockerignore 에 .git 을 넣고 멀티스테이지 빌드로 산출물만 최종 이미지에 복사하며, S3 업로드에는 --exclude 로 .git/* 를 뺀다. AI 코딩 도구가 써 준 Dockerfile 과 배포 스크립트도 COPY . . 과 .dockerignore 유무를 직접 열어 확인한다 |
| 6 | 교체가 끝난 뒤에 기록 속 비밀값을 정리하고 재발 방지 장치를 둔다. git 문서는 filter-branch 대신 git filter-repo 를 권하고, GitHub 문서는 포크나 다른 사람의 클론에는 남는다고 밝힌다. 기록 정리는 이미 받아 간 사본을 되돌리지 못하니 교체를 대신할 수 없다. 배포 마지막 단계에 /.git/HEAD 요청이 200 이면 실패로 처리하는 확인을 넣어 규칙이 빠지는 날을 잡는다 |
지금 해야 할 것
실무자: 위 자가진단으로 /.git/HEAD 와 /.git/config 응답부터 직접 확인하고 접근을 끊은 뒤 비밀값을 교체한다. CodeScan 무료 스캔은 입력한 주소 아래 이 두 경로를 확인하지만 무료 결과 화면은 위험 항목의 제목과 위치를 가리고 일부만 보여 주므로, 하위 경로의 저장소·서버 안의 폴더·커밋 기록 속 비밀값은 자가진단으로 채운다.
CISO·임원: 웹·API·소스코드에서 무엇이 외부에 노출됐는지 함께 진단하려면 SENTRIX 보안 상담으로.