보안

Git 히스토리에 커밋되기 전에 비밀을 차단하기

`git`에 커밋된 비밀은 삭제된 후에도 영원히 남습니다. 여기에 커밋 전에 이를 차단하는 계층적 방어 방법과, 비밀이 유출되었을 때 교체하는 방법이 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

API 키, 토큰, 비밀번호 또는 개인 인증서가 커밋에 포함되는 순간, 삭제로는 해결할 수 없는 문제가 발생합니다. Git 기록은 설계상 오래 지속됩니다. 강제로 제거된 보안 비밀은 모든 클론, 모든 포크, 모든 CI 캐시, 그리고 여러분이 알아채기 전에 브랜치를 가져온 모든 편집기에 여전히 남아 있습니다. 해당 값은 소각된 것으로 취급해야 하며, 이는 정리가 아닌 교체를 의미합니다. 따라서 진정한 방어는 사후에 유출된 보안 비밀을 탐지하는 것이 아닙니다. 보안 비밀이 기록에 남기 전에 커밋을 중단하는 것입니다. 이 게시물에서는 이러한 차단을 계층적으로 구축하는 방법, 단일 계층만으로는 결코 충분하지 않은 이유, 그리고 보안 비밀이 결국 유출되었을 때 무엇을 해야 하는지 보여줍니다.

커밋된 비밀을 삭제하는 것이 이미 너무 늦은 이유

커밋된 비밀은 공개된 비밀입니다. 푸시와 사용자가 알아차리는 순간 사이에, 읽기 접근 권한이 있는 누구나 해당 값을 가져갈 수 있고, 자동화된 포크에 의해 미러링될 수 있으며, 공개 및 비공개 호스트를 모두 크롤링하는 스캐너에 의해 인덱싱될 수 있습니다. 기록을 재작성하면 브랜치의 끝에서 값은 제거되지만, 이미 사용자의 머신을 떠난 복사본에는 도달하지 않습니다.

정리 작업이 실패하는 두 번째 이유가 있습니다. 기록을 재작성하면 수정한 커밋 이후의 모든 커밋에 대한 커밋 해시가 변경되어, 모든 협업자가 자신의 로컬 브랜치를 재설정해야 하고 열려 있는 풀 리퀘스트를 망가뜨립니다. 실질적인 운영 비용을 치르게 되며, 노출 기간 동안 아무도 그것을 복사하지 않았다고 증명할 수 없기 때문에 비밀은 여전히 유출된 상태입니다.

올바른 정신 모델은 간단합니다. 비밀이 공유된 기록에 도달하면 그 값은 죽은 것입니다. 유일하게 안전한 대응은 소스에서 그것을 교체하고 이전 것을 무효화하는 것입니다. 기록 재작성을 포함한 다른 모든 것은 값이 이미 쓸모없어진 후에 일어나는 정리 작업일 뿐입니다. 이것이 바로 비밀이 기록에 전혀 포함되지 않도록 할 수 있는 커밋 경계, 즉 업스트림에 노력을 기울여야 하는 이유입니다.

계층적 방어, 한눈에 보기

단일 검사만으로 모든 것을 잡아낼 수는 없습니다. 로컬 훅은 빠르지만 건너뛸 수 있습니다. 서버 스캔은 강제되지만 푸시 이후에 실행됩니다. 파일 규칙은 전체적인 종류의 실수를 방지하지만 의도를 읽을 수는 없습니다. 각 계층은 다른 계층이 남긴 공백을 메우므로 함께 실행해야 합니다.

계층 실행 위치 잡아내는 것 우회 위험
Pre-commit 훅 개발자 머신, 커밋 전 기록에 들어가기 전의 스테이징된 시크릿 높음, 로컬이며 건너뛸 수 있음
CI 스캔 서버, 푸시 후 훅이 놓쳤거나 건너뛴 훅이 통과시킨 모든 것 낮음, 중앙에서 강제됨
무시 및 허용 목록 규칙 둘 다 절대 스테이징되어서는 안 되는 시크릿 파일 및 거짓 양성(false positive) 해당 없음
자동화 가드 봇 및 스크립트 시크릿을 스테이징하는 머신 커밋 낮음, 자동화 내부에서 실행됨
순환(Rotation) 노출 후 유출된 값으로 인한 피해 해당 없음

표를 위에서 아래로 깔때기처럼 읽어보세요. Pre-commit 훅은 일반적인 경우를 저렴하고 초기에 막습니다. CI 스캔은 git commit --no-verify를 실행했거나 훅을 전혀 설치하지 않은 개발자를 위해 훅 아래에 있는 그물입니다. 파일 규칙은 두 스캐너가 추론해야 하는 표면을 줄여주고, 자동화 가드는 사람이 검토하지 않는 커밋을 처리하며, 순환(rotation)은 마지막 계층으로, 절대 실행되지 않기를 바라는 계층입니다.

pre-commit hook으로 커밋 차단하기

비밀을 막는 가장 저렴한 곳은 커밋 객체가 존재하기 전 개발자 자신의 머신입니다. pre-commit hook은 스테이징된 변경 사항에 대해 실행되며, 자격 증명처럼 보이는 것을 발견하면 0이 아닌 값으로 종료되고 커밋은 절대 발생하지 않습니다. 스테이징된 것만 검사하기 때문에 모든 커밋에서 실행할 만큼 충분히 빠르며 아무도 지연을 눈치채지 못합니다.

세 가지 신호가 대부분의 실제 유출을 잡아냅니다. 첫째, 파일 이름입니다. .env, .pem, .key, .p12, .pfx와 같은 파일은 리포지토리에 거의 포함되어서는 안 됩니다. 둘째, 알려진 자격 증명 형식입니다. 많은 제공업체가 고정된 접두사와 길이로 키를 발급하며, 개인 키는 인식 가능한 헤더 라인을 가지고 있습니다. 셋째, 높은 엔트로피입니다. token 또는 password라는 이름의 변수에 할당된, 무작위로 보이는 긴 문자열은 알려진 형식과 일치하지 않는 경우에도 강력한 힌트가 됩니다.

#!/usr/bin/env bash
# .git/hooks/pre-commit (or the equivalent managed by a hook framework)
set -euo pipefail

# Patterns for well-known credential shapes and private key headers.
SECRET_PATTERNS='(-----BEGIN [A-Z ]*PRIVATE KEY-----)|(secret_[A-Za-z0-9]{24,})|([A-Za-z0-9+/]{40,}={0,2})'

# Inspect only what is staged, and only added or changed lines.
staged=$(git diff --cached --name-only --diff-filter=ACM)
fail=0

for file in $staged; do
  # 1. Reject credential-bearing filenames outright.
  case "$file" in
    *.pem|*.key|*.p12|*.pfx|.env|.env.*)
      echo "blocked: $file looks like a credential file"
      fail=1
      continue
      ;;
  esac

  # 2. Scan the staged content of the file for secret-shaped values.
  if git show ":$file" | grep -nEq "$SECRET_PATTERNS"; then
    echo "blocked: $file contains a value matching a secret pattern"
    fail=1
  fi
done

if [ "$fail" -ne 0 ]; then
  echo "commit rejected. remove the secret, or add an allowlist entry if this is a false positive."
  exit 1
fi

겉보기보다 중요한 두 가지 세부 사항이 있습니다. 훅은 워킹 카피가 아닌 콘텐츠의 스테이징된 버전인 git show ":$file"을 읽습니다. 이는 개발자가 수정 사항을 언스테이징했지만 워킹 파일은 여전히 문제없는 것처럼 보이는 경쟁 상태를 방지합니다. 그리고 --diff-filter=ACM으로 필터링하여 삭제 시 스캔이 실행되지 않도록 합니다. 파일을 제거하는 방식으로 보안 비밀이 기록에 남는 경우는 없기 때문입니다.

엔트로피는 패턴 세트의 약점입니다. 정상적인 바이너리의 base64 블롭이나 긴 해시가 키와 똑같이 보일 수 있기 때문입니다. 위의 정규식은 노이즈를 줄이기 위해 보수적인 최소 길이를 사용하며, 다음 섹션에서는 엔트로피를 실제로 사용할 수 있게 만드는 두 가지, 즉 엔트로피가 놓치는 것을 잡기 위한 CI 망과 잘못 탐지한 것을 처리하기 위한 허용 목록을 다룹니다.

CI에서의 두 번째 관문

로컬 후크에는 한 가지 치명적인 약점이 있습니다. 개발자의 머신에 존재하므로 건너뛰거나, 제거하거나, 새로 복제한 저장소에 아예 설정하지 않을 수 있습니다. git commit --no-verify는 플래그 하나로 이를 우회합니다. 단일 플래그로 비활성화할 수 있는 모든 방어는 통제가 아니라 제안입니다. 따라서 아무도 빠져나갈 수 없는 곳, 즉 CI에서 모든 푸시마다 동일한 스캔을 다시 실행해야 합니다.

CI 스캔은 후크를 반복하는 것 이상의 역할을 합니다. 푸시의 전체 diff를 스캔하며, 후크가 전혀 없었던 머신에서 이루어진 커밋도 포함합니다. 또한 첫 실행 시 히스토리 깊이를 스캔하여 통제가 존재하기 전에 커밋된 보안 비밀을 찾아낼 수 있습니다. 서버에서 실행되기 때문에 그 결과는 권위가 있습니다. 스캔에 실패하면 병합이 차단되며, 어떤 로컬 플래그로도 이를 통과시킬 수 없습니다.

# A minimal CI job that fails the pipeline on a detected secret.
scan-secrets:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0          # full history, so the scan sees every commit in the push
    - name: scan for secrets
      run: |
        # Scan the range that this push introduced, not just the tip.
        base="${BEFORE_SHA:-HEAD~1}"
        if git diff "$base"..HEAD | grep -nE "$SECRET_PATTERNS"; then
          echo "secret detected in pushed commits. rotate the value and clean history."
          exit 1
        fi

역할 분담이 핵심입니다. 훅은 개발자 머신의 히스토리에서 거의 모든 비밀을 제외하는 빠른 경로로, 여기서는 파일을 수정하는 것 외에 다른 비용 없이 문제를 해결할 수 있습니다. CI 스캔은 훅을 건너뛰었다고 가정하고 비밀을 포함한 푸시가 병합되는 것을 거부하는 강제적인 경로입니다. 하나는 편리하고 다른 하나는 강제력을 가지며, 이 둘은 모두 필요합니다.

CI 스캔이 실행될 때는 중요한 비대칭성이 존재합니다. CI 스캔이 실행될 시점에는 비밀이 이미 푸시된 상태이며, 이는 곧 해당 비밀이 이미 노출되었을 수 있음을 의미합니다. 따라서 CI가 이를 탐지했더라도 완전히 안전하게 막은 것은 아닙니다. 이는 교체 절차를 시작하고, 해당 값을 유출된 것으로 간주한 다음, 히스토리를 정리해야 한다는 신호입니다.

오탐을 줄이는 파일 및 경로 규칙

스캐너는 추측할 내용이 적을수록 성능이 향상됩니다. 두 가지 파일 수준 규칙이 그 작업의 대부분을 수행합니다. 첫 번째는 애초에 전체 범주의 비밀 파일을 스테이징 영역에 포함시키지 않는 엄격한 .gitignore입니다. 만약 .env, 키 파일, 로컬 자격 증명 캐시가 무시된다면, 개발자는 실수로 git add를 할 수 없으며, 스캐너는 그 내용을 추론할 필요가 없습니다.

# Never track local secret material.
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json

!.env.example 줄은 주목할 가치가 있습니다. 어떤 변수가 존재하는지 플레이스홀더 값과 함께 보여주는 커밋된 템플릿을 두면 스캐너 허용 목록이 정확히 해당 파일을 예외 처리할 수 있습니다. 이 템플릿은 실제 값을 하나도 담지 않은 채 구성의 형태를 문서화합니다.

두 번째 규칙은 스캐너 자체를 위한 명시적인 허용 목록입니다. 오탐(false positive)은 불가피하기 때문입니다. 테스트 픽스처에는 의도적으로 가짜 키가 포함됩니다. 문서에는 예시 토큰이 표시됩니다. 벤더링된(vendored) 의존성은 샘플 인증서와 함께 제공됩니다. 이것들을 알려진 안전한 항목으로 표시할 방법이 없다면, 이들 각각이 정상적인 커밋을 차단하게 되고, 개발자들은 전체 시스템을 무력화시키는 --no-verify를 사용하게 됩니다. 허용 목록은 범위가 좁고 검토 가능해야 하며, 특정 경로 또는 특정 줄에 고정되어야 하고, "어디서든 이 패턴 무시"와 같은 포괄적인 방식이어서는 결코 안 됩니다.

# Scanner allowlist: narrow, path-scoped, reviewed in pull requests.
allowlist:
  - path: testdata/fake_keys.txt      # deliberately fake fixtures
  - path: docs/configuration.md       # example token in prose
    reason: "documented placeholder, not a live credential"

여기서의 원칙은 모든 허용 목록 항목이 이유를 가진 작고 명시적인 예외이며, 다른 변경 사항과 마찬가지로 검토된다는 것입니다. 패턴 수준의 음소거는 파놓고 잊어버리게 될 허점입니다. 명시된 이유가 있는 경로 수준의 예외는 감사가 가능하며, 풀 리퀘스트를 검토하는 사람은 무엇이 왜 제외되었는지 정확히 볼 수 있습니다.

자동화된 커밋 방어하기

가장 위험한 커밋은 사람이 살펴보지 않는 커밋입니다. 의존성 업데이트 풀 리퀘스트를 여는 봇, 설정 파일을 재생성하고 결과를 커밋하는 스크립트, 빌드 매니페스트를 작성하는 릴리스 작업 등 이들 중 어떤 것이든 사람이 개입하여 diff의 위험 신호를 알아차리지 못하는 사이에 비밀을 스테이징할 수 있습니다. 자동화는 빠르게 움직이고 아무것도 검토하지 않으므로, 자체 경로에 차단 장치가 직접 연결되어야 합니다.

규칙은 자동화된 커밋 경로가 사람이 수행하는 것과 동일한 스캔을 실행하고, 탐지된 내용을 기록할 경고가 아닌 즉각적인 중단(hard stop)으로 처리하는 것입니다. 만약 봇의 스캔이 커밋하려는 내용에서 비밀을 발견하면, 커밋은 일어나서는 안 됩니다. 봇은 조용히 비밀을 커밋하고 넘어가는 대신, 큰 소리로 작업을 실패하여 사람이 조사하도록 해야 합니다. 이는 사람이 사용하는 훅과 동일한 페일 클로즈(fail-closed) 자세이며, 백스톱(backstop) 역할을 할 검토자가 없기 때문에 위험이 더 높은 곳에 적용됩니다.

이 지점에서 엔트로피 검사가 그 진가를 발휘합니다. 코드를 작성하는 사람은 자신도 모르게 실수로 실제 40자 길이의 무작위 문자열을 붙여넣는 경우가 거의 없습니다. 반면에, 설정 파일을 템플릿으로 만드는 자동화는 환경 변수에서 실제 값을 가져와 추적되는 파일에 바로 쓸 수 있습니다. 일반적인 고엔트로피 검사는 바로 그런 종류의 실수를 잡아냅니다. 즉, 값이 알려진 공급자 형식과 일치하지는 않지만 그 형태와 변수 이름으로 보아 명백히 비밀인 경우입니다.

비밀이 유출되면, 먼저 교체하세요

조만간 비밀은 유출되기 마련이며, 대응 순서에 따라 피해 규모가 결정됩니다. 본능적으로는 값이 사라지도록 즉시 기록을 삭제하고 싶을 것입니다. 그 본능은 틀렸습니다. 값은 이미 외부로 나갔고, 기록을 재작성해도 사용자 기기를 떠난 사본에는 아무런 영향을 미치지 못하기 때문입니다. 가장 먼저 해야 할 일은 항상 교체입니다.

  1. 다른 무엇보다 먼저, 소스에서 노출된 값을 교체하거나 폐기하십시오. 공급자에서 대체 값을 생성하고 이전 값을 무효화하십시오. 비밀이 공개되는 순간 그 값은 죽은 것이나 마찬가지이므로, 먼저 그것부터 제거하십시오.
  2. 이전 값이 더 이상 작동하지 않는지 확인하십시오. 해당 값으로 인증을 시도하고 호출이 거부되는지 확인하십시오. 실패하는 것을 직접 보기 전까지는 실제로 폐기한 것이 아닙니다.
  3. 시스템이 대체된 값으로 계속 실행될 수 있도록, 일반적인 비밀 저장소를 통해 서비스에 필요한 모든 곳에 새 값을 배포하십시오.
  4. 이제서야 기록을 정리합니다. 클론, 포크, 캐시에는 여전히 이전 값이 남아있다는 것을 인지한 상태에서, 비밀을 포함했던 커밋을 재작성하고 강제 푸시(force-push)하십시오. 이 단계는 봉쇄가 아니라 위생 관리입니다.
  5. 노출과 폐기 사이에 이전 값이 사용되었는지 접근 로그를 확인하십시오. 만약 해당 자격 증명이 실제 데이터에 대한 접근 권한을 부여하는 경우, 사용되었을 가능성을 가정하고 그에 따라 조사하십시오.

이 순서가 교훈의 전부입니다. 교체는 사고를 봉쇄합니다. 기록 정리는 사후에 정리하는 것일 뿐입니다. 기록을 먼저 재작성하고 나중에 교체하는 팀은, 살아있고 손상된 자격 증명이 이미 그것을 복사한 누구에게나 계속 작동하는 동안, 아무것도 바꾸지 못하는 단계에 가장 빠른 시간을 허비하는 셈입니다.

오탐 트레이드오프, 그리고 페일 클로즈드가 더 나은 이유

모든 보안 비밀 스캐너는 동일한 딜레마에 직면합니다. 패턴을 엄격하게 적용하면 실제 보안 비밀을 놓치게 되고, 패턴을 완화하면 오탐으로 인해 정상적인 커밋이 차단됩니다. 두 가지를 모두 제거할 수 있는 설정은 없으므로, 어떤 오류를 선호할지 선택해야 하며 그 선택은 대칭적이지 않습니다.

오탐은 성가신 일입니다. 개발자는 차단된 커밋을 보고, 플래그가 지정된 라인을 확인하고, 그것이 테스트 픽스처나 문서화된 플레이스홀더임을 확인한 후, 좁은 범위의 허용 목록 항목을 추가합니다. 비용은 몇 분의 시간과 검토된 예외 처리입니다. 미탐은 보안 침해입니다. 실제 자격 증명이 빠져나가 공유 기록에 도달하면, 압박 속에서 보안 비밀을 교체하고 액세스 로그를 읽어야 합니다. 두 결과는 같은 수준이 아니며, 이것이 바로 스캐너가 페일 클로즈드(fail-closed) 방식으로 작동해야 하는 이유입니다. 즉, 의심스러울 때는 차단하고 사람이 예외를 확인하도록 해야 합니다.

이러한 선택은 오탐 처리 과정이 정말로 간단할 때만 실행 가능하며, 이것이 허용 목록이 매우 중요한 이유입니다. 오탐을 해결하는 것이 고통스러우면 개발자들은 --no-verify를 사용하여 스캐너를 우회하고, 우회된 통제 장치는 아무것도 보호하지 못합니다. 따라서 실제 보안 비밀을 탐지할 수 있도록 패턴을 충분히 공격적으로 유지하고, 예외 처리를 빠르고, 좁은 범위로, 검토 가능하게 만드는 데 투자해야 합니다. 탐지 시에는 페일 클로즈드로 작동하고 예외 처리는 간단하게 유지하면, 개발자들이 저항하는 대신 계속 사용하는 시스템을 얻게 됩니다.

여러 계층을 종합해 보면 전체적인 그림이 명확해집니다. 프리커밋 훅은 개발자 머신에서 보안 비밀이 기록에 남지 않도록 합니다. CI 스캔은 어떤 플래그로도 건너뛸 수 없도록 동일한 규칙을 강제합니다. 파일 및 허용 목록 규칙은 추측 작업을 줄이고 오탐으로 인한 비용을 낮게 유지합니다. 자동화 가드는 아무도 검토하지 않는 커밋을 처리합니다. 그리고 무언가 통과되는 날을 대비하여 교체(rotation)가 준비되어 있습니다. 왜냐하면 전체 설계의 기반이 되는 솔직한 가정은 결국 무언가는 통과될 것이며, 커밋된 보안 비밀은 기록에 남는 순간 무효화된다는 것이기 때문입니다.

관련 글