AWS GovCloud 키 GitHub 노출 사건 — secrets 관리 3개월 회고

목차

87개 레포를 trufflehog로 한 번 돌렸더니 키 후보가 1,847건 나왔다. 분류해보니 진짜 살아있는 게 23개, 이미 rotate된 게 19개, 나머지는 false positive였다. 그 무렵 CISA가 자기네 공개 GitHub 조직에 AWS GovCloud 키를 푸시했다는 보도가 떴다. 정확한 노출 시간이나 키 권한 범위는 보도 주체마다 숫자가 달라서 단정하지 못한다. 분명한 건 한 가지다. 정부 사이버보안 기관도 같은 실수를 한다는 것.

3개월 전, 같은 팀에서 .env 파일을 한 번 push한 사람이 있었다. AWS 키 한 쌍, RDS 비밀번호, Slack webhook URL. 30분 안에 알아채고 키를 rotate했지만 GitHub 히스토리에서 완전히 지우지는 못했다. git filter-repo로 정리했어도 fork된 캐시까지는 손쓸 방법이 없다. 그래서 시작한 프로젝트였다.

도구 비교 — detect-secrets부터 시작한 이유

특히, 옵션이 여러 개 있었다. trufflehog, gitleaks, detect-secrets, GitHub Secret Scanning, AWS Macie. 첫 주에 비교 매트릭스를 만들었다.

도구 위치 강점 약점
detect-secrets (Yelp) pre-commit baseline 관리, IDE 단계에서 차단 오탐 많음
gitleaks pre-commit + CI 룰 풍부, 빠름, TOML 설정 baseline 개념 약함
trufflehog CI + 일회성 스캔 verifier로 살아있는 키 판별 무겁고 느림
GitHub Secret Scanning push protection 서버 단계 차단, 파트너 자동 통보 private repo는 GHAS 라이선스 필요
AWS Macie S3 사후 탐지 저장된 파일 검사 코드 레포는 대상 외

결국 detect-secrets로 시작했다. 이유는 단순하다. pre-commit 단계에서 막아야 GitHub까지 가지 않는다. 서버 단계 차단은 "이미 push 직전"이라서 우회하기 쉽다고 봤다. 새 도구를 도입할 때는 가장 단순하고 검증된 걸 먼저 깐다는 게 평소 원칙이라 자연스럽게 그쪽으로 갔다.

trufflehog의 verifier 기능은 매력적이었다. 실제로 AWS API에 키를 던져서 살아있는지까지 확인해주니까. pre-commit에 넣기엔 무거워서 일회성 인벤토리 스캔용으로만 썼다.

첫 주 — baseline 1,847건을 한 줄씩 분류한 시간

detect-secrets scan > .secrets.baseline 한 번 돌리면 끝일 줄 알았다. 아니었다.

# 87개 레포 일괄 스캔
for repo in $(ls repos/); do
  cd "repos/$repo"
  detect-secrets scan --all-files > .secrets.baseline
  cd ../..
done

# 결과: 평균 한 레포당 21건, 총 1,847건
# 테스트 fixture, 예제 토큰, base64 인코딩된 컬럼명까지 다 잡혔다

또한, 5명이 나눠서 한 줄씩 봤다. 진짜인지, 가짜인지, 회색인지. 회색이란 건 "예전 데모용 키였는데 지금은 비활성"인 것들. 한 주가 통째로 갔다.

exclude-files 패턴을 정교화한 결과

분류 시간을 줄이려고 exclude 패턴을 다듬었다. 처음엔 잡스럽게 잡았는데, 너무 많이 제외하면 진짜 시크릿도 빠진다.

# .pre-commit-config.yaml
- repo: https://github.com/Yelp/detect-secrets
  rev: v1.5.0
  hooks:
    - id: detect-secrets
      args:
        - --baseline
        - .secrets.baseline
        - --exclude-files
        - '\.lock$|\.svg$|tests/fixtures/|docs/examples/'

반면, 여기서 한 가지 후회. baseline 분류할 때 "회색" 카테고리를 너무 많이 만들었다. 회색은 시간이 지나면 책임이 흐려진다. 다시 한다면 회색은 없앤다. 살아있거나 죽었거나, 둘 중 하나로만.

한 가지 잡지 못한 패턴 — AWS Session Token

detect-secrets 기본 룰셋이 STS session token (ASIA로 시작하는 임시 키)을 안 잡았다. 90% 비슷한 패턴이라 IAM access key (AKIA) 룰에 걸릴 줄 알았는데 prefix가 달라서 빠졌다. 커스텀 룰을 추가했다.

한 달째 — push protection으로 넘어간 사건

특히, detect-secrets만으로는 부족했다. 두 번째 달 초에 외주 개발자가 git commit --no-verify로 hook을 우회하고 push한 일이 있었다. 그 커밋에는 실제 키가 없었지만, "우회가 가능하다"는 사실을 본 순간 다음 단계가 필요했다.

그런데, GitHub Advanced Security 라이선스를 결제했다. 공식 가격 페이지 기준 사용자당 월 21달러 (작성 시점 가격 기준이며 변동 가능). 5명 팀이라 월 105달러. 작은 비용은 아니다. 누출 한 번 터졌을 때의 인건비와 비교하면 답이 나왔다.

특히, push protection을 켜면 서버에서 시크릿이 포함된 push를 거부한다. 우회하려면 GitHub UI에서 "bypass" 버튼을 누르고 사유를 적어야 하고, 이 기록이 감사 로그에 남는다. 기록이 남는다는 것 자체가 억지력이 된다고 봤다.

detect-secrets와 push protection을 같이 쓰는 이유

두 단계를 같이 쓰기로 했다.

  • 로컬 (detect-secrets): 인터넷 없이 돌고, baseline으로 프로젝트 컨텍스트를 안다. PR 만들기 전에 잡힘
  • 서버 (push protection): 우회 불가, 파트너 자동 통보 (AWS, Stripe, OpenAI 등 100+ 파트너)

로컬에서 못 잡은 패턴이 서버에서 잡히고, 서버에서 못 잡은 회사 내부 토큰이 로컬 baseline에 걸린다. 중첩 방어다. 한쪽만 믿지 않는다.

두 달째 — AWS 측에서 키를 짧게 가져간 작업

물론, GitHub에 키가 안 올라가게 막는 것과 별도로, AWS 측에서 long-lived 키 자체를 줄이는 작업을 병행했다. 살아있는 키 23개 중 18개는 그냥 폐기했다. 사용처를 추적해보니 이미 안 쓰는 게 대부분이었다.

또한, 나머지 5개는 두 갈래로 갔다.

  • EC2/ECS에서 쓰는 3개: IAM role로 전환
  • 외부 시스템에서 쓰는 2개: Secrets Manager + 90일 자동 rotation

Secrets Manager 자동 rotation Lambda

직접 Lambda를 짜는 대신 AWS가 제공하는 RDS rotation 템플릿을 IAM 키용으로 변형했다.

# IAM access key 자동 rotation
import boto3
import os

def lambda_handler(event, context):
    iam = boto3.client('iam')
    secrets = boto3.client('secretsmanager')
    user = os.environ['IAM_USER']
    secret_id = os.environ['SECRET_ID']

    # 1단계 — 새 키 생성
    new_key = iam.create_access_key(UserName=user)['AccessKey']

    # 2단계 — Secrets Manager에 pending으로 저장
    secrets.put_secret_value(
        SecretId=secret_id,
        SecretString=f'{{"access_key":"{new_key["AccessKeyId"]}",'
                     f'"secret_key":"{new_key["SecretAccessKey"]}"}}',
        VersionStages=['AWSPENDING']
    )

    # 3단계 — finishSecret 호출 시 AWSCURRENT로 승격
    # 4단계 — 옛 키는 24시간 후 삭제 (캐시된 클라이언트 transition 시간)

이 부분은 단순하지만 transition window를 잘못 잡으면 production이 죽는다. 처음에 1분으로 잡았다가 캐시된 클라이언트가 옛 키를 들고 있어서 5분 동안 InvalidAccessKeyId 401이 났다. 24시간으로 늘리니 깔끔해졌다.

6개월 운영 — 숫자가 말해주는 것

한편, push protection 도입 후 6개월. 차단된 push가 47건. 그중 진짜 시크릿은 4건. 나머지 43건은 false positive였다. 4건 모두 신규 합류 멤버였고, 본인이 의식 못한 상태에서 키를 박아둔 경우였다.

false positive 43건에 대한 피로도가 생겼다. 데이터 분석 코드에서 base64 인코딩된 컬럼명이나 hash 값을 시크릿으로 오인하는 패턴이 특히 많았다. allowlist를 계속 추가했더니, allowlist 자체가 보안 약점이 될 수 있다는 점을 알게 됐다. allowlist에 들어간 패턴은 진짜 시크릿도 통과시키니까. 그래서 allowlist 추가는 PR 리뷰 필수로 돌렸다.

지금 운영 중인 구조는 이렇다.

  • 로컬: detect-secrets pre-commit (baseline + exclude-files)
  • 서버 push: GitHub push protection (GHAS)
  • CI 매 PR: gitleaks 전체 diff 스캔
  • AWS 측: 90일 IAM 키 rotation, 가능한 곳은 IAM role 전환

물론, 3중이라 비용은 든다. 누출이 한 번 터지면 키 rotation 8시간 + 감사 + 고객 통보로 며칠이 사라지니, 비용 계산은 맞는다고 본다.

CISA 사건을 다시 본다

즉, 처음 보도를 봤을 때 팀 분위기가 묘했다. "정부 사이버보안 기관도 못 막는데"라는 말이 농담처럼 돌았다. 정확한 노출 시간이나 키 권한 범위는 보도 주체마다 숫자가 달라서 여기서 단정하지 못한다 (보도 자료 원문 확인을 권한다).

위안이 되는 게 아니다. 오히려 반대다. 보안에 가장 신경 써야 할 조직도 실수한다면, 일반 팀이 "우린 안 그럴 것"이라는 가정으로 운영하는 건 위험하다. 사람은 실수한다. 그 실수를 시스템이 잡아야 한다.

그런데, 실수를 줄이려면 세 가지를 같이 가야 한다고 본다.

  1. push protection처럼 우회가 기록되는 서버 측 차단 — 로컬 hook은 한 줄 옵션으로 우회된다
  2. IAM role 같은 long-lived 키를 없애는 구조 변경 — 키가 없으면 누출도 없다
  3. 짧은 rotation 주기 — 노출되더라도 노출 창을 줄인다

한편, 다음 분기에 검토할 게 두 가지다. AWS IAM Identity Center를 본격적으로 깔아서 short-lived credential로 모든 access key를 대체할 수 있는지 (작성 시점 기준 일부 서비스만 지원), 그리고 GitHub의 secret scanning 커스텀 패턴을 사내 토큰 형식에 맞춰 더 정교화할 수 있는지. 둘 다 공식 문서가 잘 나와 있어서 PoC부터 시작할 생각이다.

참고로 GitHub 공식 push protection 문서(docs.github.com/code-security/secret-scanning/push-protection-for-repositories-and-organizations)와 Yelp의 detect-secrets 레포(github.com/Yelp/detect-secrets, v1.5.0 기준)는 직접 읽어보길 권한다. 블로그 정리본보다 일관성 있게 업데이트된다.

지금 시점에서 가장 중요한 건 push protection 한 줄 켜두는 것. 라이선스가 없어도 public repo는 무료로 켤 수 있다. 일단 거기서부터 시작한다.

관련 글