git pre-commit 훅이 스테이지된 파일을 검사하지 않습니다 - 시크릿 스캔이 뚫린 세 경로
- 공유 링크 만들기
- X
- 이메일
- 기타 앱

7월 8일에 Wiz가 GhostApproval이라는 걸 공개했습니다. 코딩 에이전트 여섯 개가 공통으로 갖고 있는 신뢰 경계 결함인데, 그 목록에 제가 매일 쓰는 Claude Code가 들어 있었습니다. 처음엔 제목만 보고 샌드박스 우회가 또 하나 나왔나 보다 하고 넘기려고 했습니다. 그런데 어떻게 동작하는지 설명한 부분을 읽다가 좀 멈칫했습니다. 샌드박스를 뚫는 얘기가 아니었습니다.
악성 레포가 정상 설정 파일처럼 보이는 심링크를 하나 심어둡니다. 이름은 project_settings.json인데 실제로는 ~/.ssh/authorized_keys 같은 곳을 가리키는 식입니다. 개발자가 그 레포를 클론해서 에이전트한테 설정 파일 좀 고쳐달라고 하면, 에이전트는 평범하게 파일을 열다가 심링크를 따라 워크스페이스 밖으로 나갑니다. 그리고 확인 창을 띄우는데 거기 보이는 건 심링크 경로입니다. 실제로 쓰게 될 대상이 아니라요. 사용자는 로컬 설정 파일을 고치는 줄 알고 승인하고, 쓰기는 민감한 시스템 파일에 떨어집니다.
Claude Code 사례에서 Wiz가 붙잡은 장면이 특히 기억에 남았습니다. 에이전트 내부 추론에는 이렇게 적혀 있었습니다.
"I can see that project_settings.json is actually a zsh configuration file."
같은 순간 사용자한테 뜬 프롬프트는 이랬습니다.
"Make this edit to project_settings.json?"
에이전트는 진짜 대상을 알고 있었고 사용자는 몰랐습니다. 승인 버튼을 누른 사람이 승인한 것과 실제로 일어난 일이 다릅니다. Wiz는 이걸 샌드박스 우회가 아니라 정보에 근거한 동의(informed consent)의 우회라고 불렀는데, 저는 이 말이 꽤 정확하다고 느꼈습니다.
샌드박스 우회라면 격리를 세게 하면 됩니다. 권한을 줄이고, 경계를 높이고, 쓸 수 있는 경로를 좁히면 됩니다. 그런데 동의 우회는 격리를 아무리 세게 해도 남습니다. 사용자가 승인을 눌러서 허락한 일이니까요. 격리는 승인 없는 행동을 막는 장치이고, 여기서 문제는 승인 자체가 엉뚱한 대상을 향하고 있었다는 겁니다. Wiz가 권고한 것도 전부 보여주는 것과 해석하는 것에 관한 얘기였습니다. 프롬프트를 띄우기 전에 심링크를 해석하고, 해석된 경로가 워크스페이스 밖이면 분명하게 경고하고, 승인 전에는 디스크에 쓰지 말라는 겁니다. 격리를 강화하라는 말은 하나도 없었습니다.
저는 취약점 자체보다 그 모양이 더 마음에 걸렸습니다. 판단하는 대상과 실제로 행동하는 대상이 다를 수 있다는 것. 이건 심링크만의 문제가 아니라고 봤습니다. 판단과 행동 사이에 한 겹이라도 뭔가 끼어 있으면 어디서든 같은 모양이 나올 수 있고, 게이트라는 건 원래 그 사이에 서 있는 물건입니다.
대응은 업체마다 달랐습니다. 아마존, 커서, 구글은 몇 달 사이에 고쳤고 Augment와 Windsurf는 아직 진행 중이라고 했습니다. Anthropic은 처음 신고를 받았을 때 위협 모델 밖(outside threat model)이라고 답했고, 결과적으로는 v2.1.32 이후에 패치가 들어갔습니다. CVE는 없었습니다. 발견에서 공개까지는 다섯 달쯤 걸렸습니다.
위협 모델 밖이라는 답이 왜 나왔는지는 대충 짐작이 갑니다. 악성 레포를 클론해서 에이전트한테 일을 시키는 상황이면 사용자는 이미 그 레포를 믿기로 한 겁니다. 그렇게 보면 경계는 심링크가 아니라 클론하는 순간이고, 그 뒤의 파일 연산 하나하나를 방어선으로 잡으면 끝이 없습니다. 심각도 판정이 업체마다 High, Critical, Disputed로 다 다르게 나온 것도 이 선을 각자 다르게 그어서일 겁니다. 어느 쪽이 맞는지는 저도 잘 모르겠습니다. 다만 사용자 입장에서는 승인 창에 뜬 이름을 믿고 누른 건데, 그게 다른 파일이었다면 클론할 때 뭘 믿기로 했든 좀 억울할 것 같습니다.
저도 제 훅을 짤 때 비슷하게 선을 그었습니다. 이 레포는 제가 만들고 제가 씁니다. 악성 레포를 클론해서 커밋하는 상황은 생각하지 않았습니다. 훅한테 바란 건 공격을 막는 게 아니라 실수를 막는 거였습니다. 시크릿을 실수로 스테이지했을 때 잡아주는 안전망 정도요. 그렇게 선을 그은 게 틀렸다고는 지금도 생각하지 않습니다.
이 선은 나중에 한 번 흔들리긴 했습니다. 침해된 npm 패키지가 .claude/settings.json에 SessionStart 훅을 심어두는 걸 보고 내 레포 여섯 곳의 훅을 세어본 적이 있는데, 그때 알게 된 건 훅이 검사 장치이기 전에 제가 아무 확인 없이 실행하는 스크립트라는 거였습니다. 여기서 하는 얘기는 훅이 뭘 읽느냐는 쪽이라 훅 파일은 믿을 만하다고 놓고 갑니다.
제 훅을 찔러봤습니다
GhostApproval을 읽으면서 바로 떠오른 게 지난주에 손본 제 커밋 훅이었습니다. 이 레포의 pre-commit-check.sh는 커밋 직전에 시크릿을 스캔하는데, 원래는 가짜 AWS 키를 여섯 줄 던졌더니 하나도 못 잡던 물건이었습니다. 지난 세션에 탐지 패턴을 고쳐서 여섯 개를 다 잡게 했고, 값이 stdout에 찍히던 것도 막았고, 공백 든 파일명으로 빠져나가던 것도 git diff -z로 고쳐놨습니다. 회귀까지 돌려서 오탐이 없는 것도 봤습니다. 그래서 같은 모양이 여기 있는지 보는 건 30분이면 끝날 줄 알았습니다.
실제 훅 파일이랑 인덱스는 건드리지 않기로 했습니다. 이 프로젝트 보안 기준선(S2)에 _workspace/ 밖 인프라 파일은 사람 승인을 받고 고치라고 되어 있기도 하고, 진단하는 중에 진단 대상을 바꿔버리면 전후 비교가 의미가 없어지니까요. .claude/hooks/pre-commit-check.sh를 스크래치패드로 복사하고, 따로 임시 git 레포를 만들어서 CLAUDE_PROJECT_DIR을 그쪽으로 지정한 다음 훅을 그대로 실행했습니다. 훅은 PreToolUse(Bash)에서 stdin으로 명령 JSON을 받으니까 {"command": "git commit -m x"}를 넣어주면 실제 커밋할 때랑 같은 길로 갑니다. 코드는 한 줄도 안 고치고 돌아가는 환경만 바꾼 겁니다.
환경은 git 2.50.1에 macOS였고, 하나가 눈에 띄었습니다.
$ command -v gitleaks
gitleaks: NOT INSTALLED
훅은 gitleaks가 있으면 그걸 먼저 쓰고, 없으면 자체 정규식이 유일한 방어선입니다. 제 머신에는 없었습니다. 지난 세션에 코드 주석에는 자체 정규식은 늘 뒤처지니 gitleaks가 있으면 그걸 먼저 쓴다고 적어두고 정작 설치는 안 해둔 겁니다. 주석에는 의도가 적혀 있었고 실제로 돌아가는 건 fallback 쪽이었습니다. 이게 뒤에서 꽤 중요해졌습니다.
시크릿은 AKIAEXAMPLEQQQQQQQQ1처럼 형식만 맞춘 가짜를 썼습니다. 진짜 자격증명은 안 넣었습니다.
먼저 평범한 경우부터 돌렸습니다. AWS 키가 그대로 들어 있는 파일을 스테이지하고 훅을 실행했습니다.
❌ 시크릿 의심: plain.txt — 클라우드 액세스 키/토큰 패턴. (값은 출력하지 않습니다)
커밋 차단. 위 항목 정리 후 다시 시도하세요.
exit=1
잘 잡았고 값도 안 보입니다. 지난번에 고친 게 살아 있었습니다.
심링크는 막혔는데
이번엔 워크스페이스 밖에 시크릿이 든 파일을 하나 두고, 레포 안에서 그걸 가리키는 심링크를 만들어 스테이지했습니다. 이름도 Wiz 사례랑 똑같이 붙였습니다.
$ ln -s "$LAB/outside/real-creds.txt" project_settings.json
$ git add project_settings.json
여기서 git이 실제로 뭘 저장했는지를 먼저 봤습니다.
$ git show :project_settings.json
/private/tmp/.../outside/real-creds.txt
심링크 블롭에 들어가는 건 가리키는 경로 문자열입니다. 타깃 파일 내용이 아닙니다. 이 커밋에 들어가는 건 경로 한 줄이고 시크릿은 들어가지 않습니다.
그런데 훅은 이렇게 말했습니다.
❌ 시크릿 의심: project_settings.json — 클라우드 액세스 키/토큰 패턴. (값은 출력하지 않습니다)
exit=1
막긴 막았습니다. 결과만 보면 성공입니다. 그런데 막은 이유가 틀렸습니다. 훅은 project_settings.json에서 클라우드 키 패턴을 찾았다고 하는데, 그 파일이 커밋될 내용에는 키가 없습니다. 훅이 읽은 건 심링크를 따라간 워크스페이스 밖 파일이었습니다.
원인은 이 두 군데였습니다.
while IFS= read -r -d '' f; do
[ -f "$f" ] || continue
has_real_match() {
local file=$1 re=$2 n
n=$(grep -Ei "$re" "$file" 2>/dev/null | grep -Eiv "$PLACEHOLDER_RE" | wc -l | tr -d ' ' || true)
[ -f ]도 심링크를 따라가고 grep도 따라갑니다. 그래서 훅은 레포 안 경로 이름으로 보고하면서 실제로는 레포 밖 파일을 읽고 있었습니다. 보여주는 건 안쪽이고 실제 대상은 바깥쪽이라는 점에서 GhostApproval이랑 방향이 똑같았습니다. 이걸 보고 좀 웃었습니다. 남의 도구 얘기를 읽다가 제 훅에서 같은 걸 찾은 거니까요.
이게 실제로 문제가 되는 건 두 가지 정도입니다. 하나는 훅이 ~/.ssh/id_rsa 같은 아무 외부 파일이나 읽을 수 있다는 건데, 지난번에 값 출력을 막아둬서 내용이 로그로 새지는 않습니다. 다른 하나가 더 귀찮습니다. 시크릿이 들어가지도 않는 멀쩡한 커밋이 막힙니다. 오탐입니다.
오탐을 가볍게 볼 수 없다는 건 제가 훅 안에 직접 적어놨었습니다.
# 오탐 관리가 탐지율만큼 중요하다. 게이트가 정상 작업을 막기 시작하면
# 사람은 게이트를 고치지 않고 끈다(`--no-verify` 한 번).
그 주석을 쓴 사람이 오탐을 하나 심어둔 겁니다.
심링크가 필요 없었습니다
심링크 결과를 보고 나니 궁금한 게 바뀌었습니다. 훅은 뭘 읽나. 작업 트리에 있는 파일입니다. 그럼 커밋에 들어가는 건 뭔가. 스테이지된 블롭입니다. 이 둘이 같아야 할 이유가 사실 없습니다. git add 한 다음에 파일을 고치면 바로 달라집니다.
$ printf 'AKIAEXAMPLEQQQQQQQQ1\n' > cfg.txt
$ git add cfg.txt # 스테이지: 키 포함
$ printf 'clean\n' > cfg.txt # 작업 트리: 깨끗
대단한 기술도 아닙니다. git을 쓰면 하루에도 몇 번씩 이런 상태가 됩니다. 스테이지해놓고, 마음이 바뀌어서 파일을 고치고, 아직 git add를 다시 안 한 상태요. 순서만 이렇게 되면 됩니다.
훅을 돌렸습니다.
-- 스테이지된 실제 내용:
AKIAEXAMPLEQQQQQQQQ1
-- 작업 트리 내용:
nothing to see here
-- 훅 판정:
exit=0
통과였습니다. 그래서 그대로 커밋해보고 뭐가 들어갔는지 봤습니다.
$ git show HEAD:cfg.txt
AKIAEXAMPLEQQQQQQQQ1
AWS 액세스 키가 커밋에 들어갔고 게이트는 초록불을 켰습니다. 악성 레포도, 심링크도, 공격자도 필요 없었습니다. 스테이지하고 나서 파일을 고친 게 전부였습니다. 솔직히 이 결과를 봤을 때가 심링크 때보다 훨씬 당황스러웠습니다. 심링크는 누가 일부러 꾸며야 생기는 상황인데, 이건 제가 평소에 아무 생각 없이 하는 순서였으니까요. 지금까지 이 훅을 믿고 커밋한 것 중에 이런 순서가 몇 번이나 있었을지는 모르겠습니다.
지난 세션에 이 훅이 여섯 개를 다 잡게 됐다고 적어뒀었습니다. 그건 사실입니다. 다만 작업 트리에 시크릿이 남아 있을 때만 그랬던 겁니다. 보는 대상이 잘못 잡혀 있으면 패턴을 아무리 늘려도 엉뚱한 데를 더 꼼꼼하게 볼 뿐이라는 생각이 들었습니다.
다른 규칙도 같았습니다. 훅은 _workspace/ 아래 1MB 넘는 파일을 막는데 그것도 작업 트리를 봅니다.
-- 스테이지된 블롭 크기: 2000000
-- 작업 트리 크기: 5
-- 훅 판정: exit=0
2MB 파일이 그냥 지나갔습니다. 규칙은 두 개인데 둘 다 같은 전제 위에 있었으니 이상할 것도 없습니다. 구멍이 두 개라기보다 전제 하나가 틀린 거라고 봅니다.
타깃이 없는 끊어진 심링크로도 해봤는데 이건 안 됐습니다. [ -f ]에서 실패해서 그 파일만 건너뛰고, 같이 스테이지한 시크릿 파일은 그대로 잡혔습니다.
자리표시자 허용목록
훅에는 오탐을 줄이려고 넣은 허용목록이 있습니다. 값에 EXAMPLE, FAKE, DUMMY, CHANGE_ME 같은 말이 들어간 줄은 문서용 예시로 보고 넘어갑니다. 주석에는 실제 시크릿에 EXAMPLE이 들어가는 일은 없다고 적어놨습니다.
그 판단 자체는 지금도 대체로 맞다고 생각합니다. 문제는 이게 줄 단위로 돌아간다는 겁니다. 같은 줄에 그 단어만 붙이면 그냥 지나갑니다.
$ cat g.txt
# EXAMPLE config
aws_secret_access_key = "wJalrXUtnFEMIEXAMPLEbPxRfiCYQQQTKEY01" # EXAMPLE
$ 훅 판정
exit=0
키 모양은 규칙에 딱 걸리는 형태인데 뒤에 주석 한 마디 붙었다고 통과합니다. 공격자를 생각하면 이건 방어선이라고 하기 어렵습니다. 그런데 이 훅은 공격보다 실수를 막으려고 만든 거라서, 실수 쪽에서 위험해지는 경우를 생각해봤습니다. 예시 설정 파일을 만들면서 실제 값을 넣어두고 이건 예시라고 주석을 붙이는 경우요. 사람도 에이전트도 별생각 없이 할 수 있는 일이고, 그때 스캐너는 눈을 감습니다.
이건 이번에 안 고쳤습니다. 고칠 방법이 아예 안 보이는 건 아닌데, 고치는 쪽이 지금보다 나을지가 확신이 안 섰습니다. 허용목록을 없애면 오탐이 돌아오고, 오탐이 돌아오면 사람은 게이트를 끕니다. secret-scan:allow 같은 명시적인 표식만 남기고 일반 단어는 빼는 게 맞아 보이긴 하는데, 그러려면 기존 문서들이 얼마나 걸리는지부터 봐야 해서 일단 남겨뒀습니다.
왜 이렇게 짰을까
코드를 다시 읽으면서 왜 작업 트리를 읽게 짰는지 생각해봤는데 이유는 단순했던 것 같습니다. 파일 목록은 git에서 가져오고 내용은 파일 시스템에서 읽는 게 짜기 쉽습니다. git diff --cached --name-only로 이름을 받아서 그대로 grep에 넘기면 한 줄로 끝납니다. 블롭을 읽으려면 git cat-file blob ":$f"처럼 한 단계를 더 거쳐야 합니다. 하는 일이 같아 보이는데 코드가 길어지니 짧은 쪽을 골랐을 겁니다.
테스트할 때도 늘 파일을 만들고 바로 git add하고 바로 커밋했습니다. 그 순서면 작업 트리랑 블롭이 같습니다. 제가 만든 테스트가 제가 깔아둔 전제를 한 번도 건드리지 않은 겁니다. 이게 좀 뼈아팠습니다. 테스트를 많이 돌렸다는 게 안심이 됐는데, 그 테스트들이 전부 같은 순서로만 돌았다는 생각은 못 했습니다.
지난 세션 기록을 다시 보니 비슷한 게 또 있었습니다. 실패하면 막는 쪽으로 동작하게 하고, 값 노출을 막고, 널 구분으로 파일명을 읽게 고쳤습니다. 전부 훅이 뭘 어떻게 처리하느냐를 손본 거였고, 훅이 맞는 걸 보고 있느냐는 질문 목록에 아예 없었습니다. GhostApproval을 읽고 얻은 건 새 취약점 지식이라기보다 이 질문이었던 것 같습니다.
고치고 다시 재봤습니다
실제 훅 파일은 그대로 두고 스크래치패드 사본에만 고쳐서 전후를 비교했습니다. 고친 데는 네 군데입니다.
먼저 보는 대상을 블롭으로 바꿨습니다.
# 검사 대상은 작업 트리 파일이 아니라 "스테이지된 블롭"이다.
# 커밋에 들어가는 것이 블롭이므로, 작업 트리를 읽으면 검사 대상과 행위 대상이 갈린다.
staged_blob() { git cat-file blob ":$1" 2>/dev/null || true; }
staged_mode() { git ls-files --stage -- "$1" 2>/dev/null | awk 'NR==1{print $1}'; }
has_real_match() {
local file=$1 re=$2 n
n=$(staged_blob "$file" | grep -Ei "$re" 2>/dev/null | grep -Eiv "$PLACEHOLDER_RE" | wc -l | tr -d ' ' || true)
[ "${n:-0}" -gt 0 ]
}
그다음 루프 들어가는 데서 [ -f ]를 뺐습니다. 파일 시스템한테 묻지 않고 인덱스에 적힌 모드로 판단합니다. 심링크(모드 120000)는 타깃을 따라가지 않고, 블롭에 든 링크 경로가 절대경로이거나 ../를 포함하면 그 자체로 막습니다.
mode=$(staged_mode "$f")
[ -n "$mode" ] || continue
if [ "$mode" = "120000" ]; then
target=$(staged_blob "$f")
case "$target" in
/*|*../*) printf '❌ 워크스페이스 밖을 가리키는 심링크가 스테이지됨: %s\n' "$f" >&2; fail=1 ;;
esac
continue
fi
나머지 두 군데는 사설키 검사랑 크기 검사인데 같은 기준으로 바꿨습니다. 크기는 wc -c <"$f" 대신 git cat-file -s ":$f"를 씁니다.
| 테스트 | 조건 | 수정 전 | 수정 후 |
|---|---|---|---|
| A | 스테이지 블롭에 AWS 키, 작업 트리 깨끗 | exit=0 통과 | exit=1 차단 |
| B | 워크스페이스 밖 심링크 | exit=1 차단 (근거 틀림) | exit=1 차단 (심링크 이탈로 판정) |
| C | 끊어진 심링크 | 스킵, 우회 불가 | 동일 |
| D | 대조군(평문 시크릿) | exit=1 차단 | exit=1 차단 |
| E | 2MB 블롭 + 작업 트리 5바이트 | exit=0 통과 | exit=1 차단 |
| F | 회귀(정상 문서) | exit=0 통과 | exit=0 통과 |
B에서 나오는 메시지가 이렇게 바뀌었습니다.
❌ 워크스페이스 밖을 가리키는 심링크가 스테이지됨: project_settings.json
여전히 막지만 이제 막는 이유가 사실이랑 맞습니다. 훅도 더 이상 워크스페이스 밖 파일을 읽지 않습니다.
저는 여기서 끝낼 생각이었습니다. 그런데 방금 알게 된 걸 그대로 다시 적용해보니 하나가 더 남았습니다. 커밋에 들어가는 건 스테이지된 블롭이라는 전제로 고쳤는데, 그게 늘 맞나 싶었습니다.
제 패치도 같았습니다
git commit -a가 있습니다. 추적 중인 파일의 수정분을 알아서 스테이지한 다음 커밋하는 명령입니다. 그리고 훅은 PreToolUse에서, 그러니까 명령이 실행되기 전에 돕니다. 훅이 인덱스를 들여다볼 때 그 수정분은 아직 스테이지되지 않았고, 훅이 지나간 다음에 git이 스테이지합니다.
추적 중인 파일을 고쳐서 시크릿을 넣고 git add는 하지 않았습니다. 인덱스는 비어 있습니다.
스테이지 상태:
(비어 있음이 정상)
작업 트리:
AKIAEXAMPLEQQQQQQQQ9
원본이랑 방금 만든 블롭 기반 패치를 둘 다 돌렸습니다.
[v0] exit=0
[v1] exit=0
둘 다 통과였고, 실제로 git commit -am x를 실행하면 커밋에 키가 들어갑니다.
$ git show HEAD:tracked.txt
AKIAEXAMPLEQQQQQQQQ9
원본 훅은 작업 트리를 봐서 놓쳤고, 제 패치는 인덱스를 봐서 놓쳤습니다. 이유는 같습니다. 커밋에 뭐가 들어가는지는 파일이나 인덱스가 정하는 게 아니라 명령이 정합니다. git commit이면 인덱스가 커밋되고, git commit -a면 인덱스에 추적 파일 수정분을 합친 게 커밋됩니다. 한쪽으로 고정하면 다른 쪽이 비어버립니다. 오탐 원인을 고치면서 정반대 방향으로 같은 실수를 한 겁니다.
웃긴 건 훅이 그 정보를 이미 갖고 있었다는 겁니다. 훅은 stdin으로 명령 문자열을 받아서 git commit인지 아닌지를 판단합니다. 그 코드가 훅 맨 위에 있습니다.
if [ -n "$input" ] && printf '%s' "$input" | grep -q '"command"'; then
if ! printf '%s' "$input" | grep -qE 'git[[:space:]]+([^"]*[[:space:]])?commit'; then
exit 0
fi
fi
명령을 읽어서 검사할지 말지는 정하는데 뭘 검사할지는 안 정했던 겁니다. GhostApproval의 Claude Code 사례랑 모양이 같습니다. 정보는 시스템 안에 있었고 판단하는 쪽에만 없었습니다.
명령을 보고 대상을 정하게
세 번째 버전에서는 명령에 -a나 --all이 있는지 먼저 봅니다. 있으면 검사할 목록에 추적 파일 수정분을 더하고, 그 파일들은 작업 트리 내용을 읽습니다. 그 명령에서는 작업 트리가 커밋될 내용이니까요.
commit_all=0
if [ -n "$input" ] && printf '%s' "$input" \
| grep -qE 'git[[:space:]]+([^"]*[[:space:]])?commit([[:space:]]+[^"]*)?[[:space:]]-[A-Za-z]*a|--all'; then
commit_all=1
fi
target_list() {
git diff --cached --name-only -z --diff-filter=ACM 2>/dev/null || true
if [ "$commit_all" = "1" ]; then
git diff --name-only -z --diff-filter=ACM 2>/dev/null || true
fi
}
읽는 쪽도 대상에 따라 다릅니다. 인덱스에 이미 올라간 변경이면 블롭을 읽고, -a로 새로 합쳐질 추적 파일이면 작업 트리를 읽습니다.
| 테스트 | v0 원본 | v1 블롭 기반 | v2 명령 기반 |
|---|---|---|---|
| A. 스테이지 블롭에 키, 작업 트리 깨끗 | exit=0 | exit=1 | exit=1 |
H. commit -a, 작업 트리에만 키 |
exit=0 | exit=0 | exit=1 |
| F. 정상 문서 커밋 | exit=0 | exit=0 | exit=0 |
F′. 정상 commit -a |
(미측정) | (미측정) | exit=0 |
A랑 H를 둘 다 막는 건 v2뿐이고, 정상 커밋이랑 정상 commit -a는 둘 다 지나갑니다.
오탐은 실제 레포 로컬 클론에서 봤습니다. orphan 브랜치를 만들어서 추적 파일 337개를 전부 새로 스테이지된 상태로 만들고 훅을 돌렸습니다.
스테이지된 파일 수: 337
탐지 건수: 9
시크릿 오탐: 0
9건은 전부 _workspace/posts/images/**/*.png를 막은 바이너리 규칙이었고 v0에서도 똑같이 9건이었습니다. 원래 막으려던 걸 막은 겁니다. 시크릿 쪽 오탐은 세 버전 다 없었습니다. 그러다 엉뚱한 걸 하나 알게 됐는데, 그 PNG 9개가 지금 git에 추적되고 있었습니다. 바이너리 차단 규칙이 생기기 전에 커밋된 파일들입니다. 보안 문제는 아니라서 따로 보기로 했습니다.
거의 맞는 것
실험하는 동안 계속 머리에 겹쳐 보인 게 Stack Overflow 개발자 설문이었습니다. AI 도구는 84%가 쓰는데 출력이 정확하다고 믿는 사람은 29%로 내려갔고, 제일 많이 나온 불만이 거의 맞는데 딱 틀린 답이었습니다. 66%가 그걸 골랐습니다.
제 훅이 딱 그 66%였습니다. 시크릿 패턴 여섯 개를 다 잡고, 값을 안 보여주고, 공백 든 파일명도 처리하고, 실패하면 막는 쪽으로 동작합니다. 거의 맞습니다. 보는 대상만 틀렸습니다.
이런 건 초록불을 켠다는 점에서 더 비싸다고 생각합니다. 대놓고 고장 난 게이트는 사람이 고칩니다. 초록불을 정확한 형식으로 켜주는 게이트는 아무도 안 봅니다. 지난 세션에 저는 이 훅을 재검증까지 끝냈다고 적었고, 그 한 줄 때문에 이번 확인이 한 달쯤 늦어졌습니다.
v1에서 제가 또 같은 실수를 한 게 저한테는 이 얘기의 진짜 알맹이였습니다. 문제 구조를 이해했다고 생각하고 패치를 짰고, 이해한 것도 맞았습니다. 검사하는 대상과 실제로 들어가는 대상을 맞춰야 한다는 것. 그런데 실제로 들어가는 대상이 명령에 따라 달라진다는 걸 빼먹었습니다. 또 거의 맞았던 겁니다. 이해했다는 느낌이 들 때가 제일 위험한 것 같기도 합니다. 처음 구멍을 찾았을 때는 의심하면서 하나하나 찔러봤는데, 고칠 때는 이미 답을 안다고 생각하고 있어서 그만큼 덜 찔러봤습니다. v1을 만들고 나서 바로 표를 채웠고 표가 깔끔하게 나오니까 끝난 줄 알았습니다. 그 표에 commit -a 줄이 없었다는 걸 그때는 못 봤습니다.
GhostApproval의 Claude Code 사례가 불편했던 것도 비슷한 이유였습니다. 에이전트는 진짜 대상을 알고 있었고, 정보는 시스템 안에 있었는데 승인 화면에만 없었습니다. 제 훅도 정보는 다 있었습니다. git diff --cached로 파일 이름을 정확히 가져왔고 stdin으로 명령 문자열까지 받았습니다. 그걸 어디에 쓸지에서 놓친 겁니다.
아직 손 못 댄 것도 꽤 남아 있습니다. 히스토리 스캔을 안 했습니다. 훅은 이번 커밋에 들어갈 변경만 보니까 예전 커밋에 시크릿이 남아 있는지는 모릅니다. gitleaks를 깔아서 --redact를 걸고 전체 히스토리를 한 번 훑어봐야 할 것 같고, gitleaks가 안 깔려 있다는 것도 이번에 알았으니 그게 먼저일 것 같습니다.
서버 쪽 검사도 없습니다. 로컬 훅뿐이라 훅이 안 깔린 머신에서는 그냥 지나갑니다. 이번에 찾은 구멍은 훅을 고쳐서 막을 수 있지만 훅이 아예 안 도는 경우는 훅을 고쳐서는 못 막습니다.
AI 게이트도 결국 자기가 자기를 채점하고 있었습니다. 발행 스크립트의 check_ai_gate()는 판정 파일에서 판정: 합격을 찾는데, 그 문자열을 검사받는 에이전트가 직접 씁니다. 이번에 본 것이랑 같은 모양입니다. 검사하는 쪽과 검사받는 쪽이 나뉘어 있지 않습니다. 판정 파일보다 본문 수정 시각이 더 최신이면 판정을 무효로 보는 정도는 해볼 수 있겠다고 생각만 해뒀습니다.
허용목록도 아직 그대로고, 이번 패치도 아직 실제 훅에는 안 들어갔습니다. 스크래치패드 사본에서 세 버전을 재봤을 뿐입니다. 인프라 파일은 승인 받고 고친다는 규칙을 이번엔 지키기로 했습니다.
패턴을 늘리는 일이랑 대상을 맞추는 일은 다른 일이었습니다. 앞쪽은 눈에 잘 보이고 커밋 메시지로도 잘 적힙니다. 시크릿 패턴 5종 추가 같은 문장은 일을 한 것처럼 읽힙니다. 뒤쪽은 코드가 거의 안 늘어납니다. grep "$file"이 read_target "$file" | grep으로 바뀌는 정도입니다. 그런데 exit=0을 exit=1로 바꾼 건 두 번 다 뒤쪽이었습니다. 다음에 게이트를 또 만들게 되면 뭘 잡느냐보다 뭘 읽느냐를 먼저 물어볼 것 같고, 그 답이 하나로 정해지는지도 같이 봐야 할 것 같습니다. 이번엔 하나로 안 정해졌습니다.
댓글
댓글 쓰기