1. 개요

백엔드 application.yml 을 GitHub Actions 시크릿에 통째로 넣고, 빌드 때 파일로 떨궈 이미지에 굽는 구조가 있었다. 편하다. 그런데 이 파일은 write-only 다. 시크릿 UI 는 값을 다시 보여 주지 않는다. 안에 비밀번호가 평문으로 몇 개 들어 있는지, ${ENV} 참조로 바뀌었는지 아무도 모른다. 이 글은 그 파일을 값 없이 검사하는 가드를 만든 이야기다.


2. 핵심 내용

2-1. 요구사항: 값을 절대 출력하지 않는다

가드가 “여기 비밀번호가 있다” 고 알려 주려면 그 줄을 읽어야 한다. 하지만 그 줄을 CI 로그에 찍으면 가드가 유출 경로가 된다. 출력은 행 번호와 키 이름 까지만. 값은 절대.

# 값이 리터럴인 줄을 찾되, 값은 출력하지 않는다
awk '
  /^[ \t]*(password|secret|token|key|api-key|access-key)[ \t]*:/ {
    val = $0; sub(/^[^:]*:[ \t]*/, "", val)
    if (val !~ /^\$\{[A-Z_][A-Z0-9_]*(:[^}]*)?\}[ \t]*$/ && val != "" && val != "\"\"") {
      key = $0; sub(/:.*/, "", key); gsub(/^[ \t]+/, "", key)
      printf "line %d: %s is a literal\n", NR, key
      found++
    }
  }
  END { exit found > 0 }
' application.yml

키 이름은 출력해도 된다. datasource.password 라는 키가 있다는 건 비밀이 아니다. 값이 비밀이다.

2-2. 처음 돌렸을 때 8건이 나왔다

기존 설정 파일에 리터럴 자격증명이 8건 있었다. 매 이미지에 구워지고 있었고, 그 전까지 빌드는 항상 초록이었다. 가드 PR 이 열린 PR 15개 중 유일한 빨간불이었다. 테스트는 SUCCESS, 가드만 FAIL.

이 PR 은 의도적으로 머지하지 않았다. 빨간불이 결과물이었기 때문이다. 가드를 통과시키려면 8건을 ${ENV} 로 바꾸고 SealedSecret 으로 주입해야 하는데, 그건 시크릿 회전을 동반하는 별도 작업이다. 가드를 먼저 머지하면 main 이 빨간불이 되고, 가드를 완화하면 가드가 아니다. 그래서 “선행 작업이 끝나면 머지” 로 두고 원장에 순서를 적었다.

2-3. 정규식의 구멍 다섯 가지

교차 검토에서 첫 정규식이 놓치는 모양이 다섯 개 나왔다.

모양예첫 정규식
URL userinfojdbc:postgresql://user:pw@host/db키 이름이 url 이라 못 봄
쿼리스트링?password=pw같음
camelCase 키accessKey:kebab 만 알아서 못 봄
리스트 항목- password: pw줄 앞 - 때문에 못 봄
기본값 있는 참조${DB_PW:realpassword}${} 라 통과

다른 저장소로 가드를 옮겼을 때 세 번째가 실제로 걸렸다. 그 저장소는 옛 라이브러리 관례로 minio.accessKey 였고, 원본 정규식은 access-key 만 알았다. 현역 자격증명 2건이 영원히 초록이었을 것이다. 가드는 원본 fixture 가 아니라 대상 저장소의 실제 키 모양으로 시험한다.

다섯 번째는 미묘하다. ${VAR:default} 는 정당한 문법이다. 하지만 default 자리에 진짜 비밀번호를 넣어 두면 env 가 없을 때 그 값이 쓰인다. 비어 있지 않은 default 는 경고한다. 단 제안된 정규식 하나는 올바른 ${VAR} 참조까지 잡아서 그대로 쓰지 않았다. 오탐이 많은 가드는 무시당한다.

2-4. 가드 자체의 고장을 잡는다

정규식은 조용히 좁아진다. 누가 “오탐이 나서” 패턴을 손대면, 잡아야 할 것을 못 잡게 될 수 있다. 그런데 가드는 “찾은 게 없다” 와 “고장나서 못 찾는다” 를 구분하지 못한다. 둘 다 exit 0.

그래서 가드가 자기 자신을 먼저 시험 한다. 저장소에 fixture 두 개를 둔다. 통과해야 하는 파일(전부 ${ENV})과 걸려야 하는 파일(위 다섯 모양 전부 포함). 매 실행 앞에 이 둘을 돌린다.

./check-literal-secrets.sh fixtures/clean.yml   || { echo "guard rejects clean fixture"; exit 2; }
./check-literal-secrets.sh fixtures/dirty.yml   && { echo "guard passes dirty fixture"; exit 2; }
./check-literal-secrets.sh "$TARGET"

fixture 가 걸리지 않으면 가드가 고장난 것이다. 대상 파일을 보기 전에 멈춘다. 그리고 변이 시험을 했다. 정규식의 조각을 하나씩 지워 dirty fixture 가 통과하는지 본다. 여섯 가지 변이가 전부 자가검증에서 잡혔다.

awk 는 구현이 셋이다(gawk, mawk, BSD awk). CI 러너와 맥이 다를 수 있다. 셋에서 같은 결과가 나오는지 확인했다.

2-5. 두 저장소가 바이트 동일한 스크립트를 쓴다

가드를 두 프로젝트에 넣으면서 판정 로직을 스크립트 파일 하나로 빼고, 두 저장소의 사본이 바이트 동일 한지 diff 로 확인했다. 한쪽에서 구멍을 막으면 다른 쪽에 그대로 복사한다. 워크플로 yml 안에 인라인으로 두면 두 사본이 조용히 갈라진다.

2-6. 부수 발견: 시더의 순서

가드를 넣다가 관리자 계정 시더의 버그를 발견했다. 시더가 “비밀번호가 비어 있으면 종료” 를 계정 존재 확인보다 먼저 했다. CI 시크릿에서 초기 비밀번호를 지우면(de-bake 의 일부) 기존 계정의 role 동기화까지 멈춘다. 그래서 초기 비밀번호가 jar 에 남아 있었다. 시더를 “비밀번호는 새로 만들 때만 요구” 로 바꿨다.

가드는 파일만 본다. 그 파일의 값을 쓰는 코드가 값의 부재를 어떻게 처리하는지는 가드가 모른다. de-bake 는 파일과 코드를 함께 봐야 한다.


3. 마무리

요약

  • write-only 시크릿 안의 파일은 아무도 리뷰하지 못한다. 가드가 행 번호로만 센다.
  • 빨간불이 결과물일 수 있다. 가드를 완화하지 말고 선행 작업 순서를 적는다.
  • URL userinfo·쿼리스트링·camelCase·리스트 항목·기본값 있는 참조. 대상 저장소의 실제 키로 시험.
  • 가드는 fixture 로 자기 고장을 먼저 잡는다. 변이로 확인한다.
  • 두 저장소의 가드는 바이트 동일. 인라인은 갈라진다.