본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 21. 08:15

보안 스캐너를 만들었습니다. 첫 번째 탐지 결과가 틀렸습니다. 제가 무엇을 변경했는지 소개합니다.

요약

개발자가 Claude Code 및 AI 에이전트 사용 시 발생할 수 있는 보안 위험을 탐지하기 위해 정적 분석기인 'claudemd-security-auditor'를 개발했습니다. 초기 버전에서 주석 처리된 코드를 오탐지하는 문제를 겪었으며, 이를 해결하기 위해 어휘적 문맥(lexical context)을 고려한 휴리스틱 방식을 도입했습니다. 이 도구는 API 키 노출, 파괴적인 명령어, 권한 확장 등의 패턴을 스캔하여 개발자에게 경고합니다.

핵심 포인트

  • AI 에이전트용 훅(hook) 스크립트 내 보안 취약점(하드코딩된 키, 파괴적 명령어 등) 탐지 필요성
  • 단순 정규 표현식 기반 스캐너의 한계와 어휘적 문맥(lexical context) 파악의 중요성
  • 오탐지(False Positive)가 보안 도구의 신뢰도와 사용자 수용성에 미치는 영향
  • claudemd-security-auditor는 Claude Code, Cursor, Cline 사용자를 위한 무결성 검사 도구임

어젯밤 저는 조용히 굴욕을 맛볼 뻔하며 GitHub에 공개 이슈(public issue)를 올릴 뻔했습니다. 저는 몇 주 동안 작성해 온 작은 정적 분석기(static analyzer)의 첫 번째 사용 가능한 버전을 막 출시한 상태였습니다. 이 도구는 CLAUDE.md 파일과 .claude/hooks/* 스크립트를 스캔하여 개발자를 곤경에 빠뜨리는 유형의 패턴들, 즉 하드코딩된 API 키, --dangerously-skip-permissions, rm -rf $HOME, curl | sh와 같은 흔한 문제들을 찾아냅니다. 인기 있는 저장소(repository)를 대상으로 한 첫 번째 실제 운영 실행에서, 이 도구는 rm -rf /를 가리키는 HIGH(높음) 등급의 탐지 결과를 반환했습니다. 제 손가락은 이미 키보드 위에 놓여 있었고, "Security: hook script references rm -rf /"와 같은 제목으로 이슈를 작성하고 있었습니다. 그러다 저는 모든 보안 도구 제작자가 이슈를 열기 전에 반드시 해야 한다고 생각하는 한 가지 일을 했습니다. 바로 해당 저장소를 클론(clone)하여 그 줄을 직접 읽어보는 것이었습니다. 그 줄은 주석(comment)이었습니다. 빈 배열(empty array) 안에 들어 있었습니다. 심지어 그 파일의 존재 목적 자체가 바로 그 패턴을 차단하는 것이었습니다. 이 글은 스캐너가 저나 다른 누구를 다시는 그렇게 당황스럽게 만들지 않도록 제가 무엇을 변경했는지에 관한 것입니다. 총 40줄 정도의 JavaScript 코드와 두 가지 휴리스틱(heuristics)이지만, 이는 많은 정규 표현식(regex) 기반 린터(linter)들이 놓치는 부분을 포착합니다. 즉, 어휘적 문맥(lexical context)이 중요하다는 것과, 이를 무시하는 스캐너는 사용자가 스캐너를 무시하도록 서서히 길들인다는 사실입니다.

스캐너가 하는 일과 존재 이유
이 도구의 이름은 claudemd-security-auditor 입니다. 이 도구는 저장소의 CLAUDE.md, .claude/settings.json, 그리고 .claude/hooks/ 아래의 모든 .sh / .py / .js 파일을 가져와서 일련의 작은 정규 표현식(regex) 규칙을 실행합니다. 탐지 결과는 심각도(critical, high, medium, low)에 따라 등급이 매겨지고, 카테고리(secret, prompt-injection, destructive-cmd, permission, exfiltration)별로 분류되어 Markdown 보고서로 작성됩니다. 저는 올해 초, 제대로 작동하지 않는 AI 에이전트 때문에 네 자릿수 금액의 클라우드 비용 청구서를 받은 후 이 도구를 만들기 시작했습니다. 튜토리얼 저장소에서 복사해 온 훅(hook) 스크립트가 제가 충분히 주의 깊게 읽지 않은 방식으로 에이전트의 권한을 조용히 확장시켰기 때문입니다.

제가 이를 알아차렸을 때, 청구서에는 제가 원치 않았던 쉼표가 찍혀 있었습니다. 제가 스캐너를 작성한 이유는, 다음에 누군가의 .claude/ 디렉토리를 복사하여 붙여넣을 때, LLM (Large Language Model)에게 셸 액세스 (shell access) 권한을 부여하기 전에 단 한 번의 명령어로 무결성 검사 (sanity check)를 하고 싶기 때문입니다. 이 도구의 대상은 저와 같은 사람들입니다. 즉, Claude Code, Cursor, Cline 및 유사한 에이전트 (agents)를 워크플로 (workflows)에 연결하여 사용하면서도, 모든 훅 (hook) 라인을 한 줄씩 읽을 시간도 보안 팀도 없는 1인 개발자나 소규모 팀입니다.

탐지 결과
제가 거의 무작위로 스캐너를 처음으로 돌려본 저장소는 disler/claude-code-hooks-mastery였습니다. 이곳은 Claude Code를 위한 예시 훅들이 가득 담긴, 잘 알려져 있고 별(star)도 많이 받은 참조 저장소 (reference repo)입니다. 스캐너는 단 하나의 HIGH 등급 결과를 반환했습니다:

[HIGH] 훅 또는 CLAUDE.md에서 $HOME / root에 대한 rm -rf 참조됨 - 파일: .claude/hooks/user_prompt_submit.py - 라인: 128 - 카테고리: destructive-cmd (파괴적 명령) - 일치 항목: rm -rf /

저의 첫 반응은 잘못된 것이었습니다. 대략 이런 식이었죠: '아. 안 돼. 이 저장소는 별이 수천 개나 달려 있잖아. 사람들이 이 훅들을 자신의 프로젝트에 복사하고 있어. 당장 이슈를 제기해야겠어.' 저는 그 반응에 대해 솔직해지고 싶습니다. 왜냐하면 그것이 바로 이 글의 나머지 부분이 다루고자 하는 실패 모드 (failure mode)라고 생각하기 때문입니다. 스캐너는 저에게 탐지 결과를 주었습니다. 그 결과는 구체적이었고, 심각도가 분류되어 있었으며, 파일과 라인 번호까지 명시되어 있었습니다. 그것은 마치 증거처럼 느껴졌습니다. 저는 도구를 만들었고, 도구를 신뢰했으며, 근본적인 코드를 전혀 살펴보지 않은 채 그 출력값에 따라 행동하려 했습니다. 그것이 바로 보안 도구가 방지해야 하는 바로 그 태도이며, 저 또한 도구의 제작자로서 그 태도에 빠질 뻔했습니다.

대신 제가 한 일
어떠한 이슈 (issue)도 생성하기 전에 제가 한 일은 세 가지 작은 행동이었습니다. 그리고 저는 이것들을 기록해두고 싶습니다. 왜냐하면 이것들은 제가 자꾸 잊어버리곤 하지만 결코 선택 사항이 아닌, 지루하지만 필수적인 습관들이기 때문입니다. 첫째, 저는 저장소를 로컬에 클론 (clone) 했습니다. GitHub 웹 UI로

그 코드를 제 눈으로 직접 확인하는 것이 메인테이너 (maintainer)에 대한 예의였습니다. 둘째, 저는 .claude/hooks/user_prompt_submit.py 파일을 열어 128행으로 이동했습니다. 실제로 그곳에 적혀 있던 내용은 다음과 같았습니다:

# Example dangerous patterns to block (customize as needed): blocked_patterns = [ # Add patterns here to block specific prompts # Example: ('rm -rf /', 'Dangerous command detected'), # Example: ('format c:', 'Dangerous command detected'), ]

이는 주석 처리된 문서 예시였습니다. 비어 있는 blocked_patterns 리스트 안에 말이죠. 사용자 프롬프트에서 위험한 패턴을 스캔하고 거부하는 것이 유일한 임무인 스크립트 안에서 말입니다.

셋째, 저는 Claude의 PreToolUse 훅 (hook)에 실제로 연결된 파일인 pre_tool_use.py를 읽었습니다. 102행 근처에는 시스템 경로에 대한 rm -rf를 실제로 차단하는 라이브 정규 표현식 (regex)이 있었습니다. 사실 이 리포지토리 (repo)는 방어용 훅 컬렉션이었습니다. 제 스캐너가 생각했던 것과는 정반대였죠. 만약 제가 "보안: 당신의 훅 스크립트가 rm -rf /를 참조하고 있습니다"라는 이슈 (issue)를 제기했다면, 저는 소화기에 연소 지침이 들어있다고 소리치는 사람과 다를 바 없었을 것입니다.

수정 사항
수정 사항은 destructive-cmd 규칙 경로에 추가된 두 가지 새로운 휴리스틱 (heuristics)입니다. 두 가지 모두 규칙 테이블 (rule table) 옆의 src/main.js에 위치합니다. 저는 미래의 제가 쉽게 찾을 수 있도록 의도적으로 크기를 작게 유지하고 이름을 명확하게 지었습니다.

첫 번째 휴리스틱은 주석 라인 탐지기 (comment-line detector)입니다. 공백을 제거한 후 라인이 #, //, *, 또는 --로 시작한다면, 그것은 거의 확실하게 문서, 헤더 주석, 또는 주석 처리된 예시이지 실행 코드가 아닙니다.

function isCommentLine ( line ) {
  const trimmed = line . trimStart ();
  return (
    trimmed . startsWith ( ' # ' ) ||
    trimmed . startsWith ( ' // ' ) ||
    trimmed . startsWith ( ' * ' ) ||
    trimmed . startsWith ( ' -- ' )
  );
}

두 번째 휴리스틱은 방어적 컨텍스트 탐지기 (defensive-context detector)입니다.

두 번째 휴리스틱은 방어적 컨텍스트 탐지기 (defensive-context detector)입니다.

이 탐지기는 현재 라인에서 최대 8줄 위까지 거슬러 올라가 "이것은 우리가 수행하는 작업이 아니라, 우리가 차단하는 항목들의 목록이다"라는 점을 강력하게 시사하는 변수명을 찾습니다.

const DEFENSIVE_CONTEXT_RE = / \b( blocked_patterns|blocklist|denylist|blacklist|dangerous_commands|forbidden_commands|banned_commands|pattern_blacklist|deny_list ) /i ;

function isInDefensiveContext ( lineIndex ) {
  // 방어적 배열 선언이 있는지 위로 8줄을 확인합니다.
  const start = Math . max ( 0 , lineIndex - 8 );
  for ( let j = start ; j <= lineIndex ; j ++ ) {
    if ( DEFENSIVE_CONTEXT_RE . test ( lines [ j ])) return true ;
  }
  return false ;
}

destructive-cmd 규칙이 일치할 때, 이제 루프는 두 가지 휴리스틱을 모두 참조하여 탐지 결과를 버리는 대신 등급을 낮춥니다 (downgrade):

if ( rule . category === ' destructive-cmd ' ) {
  if ( isCommentLine ( line ) || isInDefensiveContext ( i )) {
    severity = ' low ' ;
    suppressed = true ;
  }
}

findings . push ({
  path ,
  line : i + 1 ,
  severity ,
  category : rule . category ,
  message : suppressed ? ` ${ rule . msg } (주석 또는 방어적 차단 목록에 있음 --- 실행이 아닌 문서일 가능성이 높음)` : rule . msg ,
  matched : m [ 0 ],
  context : line . trim (),
});

언급할 만한 두 가지 설계 결정이 있습니다. 저는 결과를 완전히 억제 (suppress)하기보다 'low' 등급으로 낮추는 (downgrade-to-low) 방식을 선택했는데, 그 이유는 사용자가 스캐너가 무엇을 발견했는지 여전히 확인하기를 원하기 때문입니다. 조용한 억제는 그 자체로 신뢰의 문제를 야기합니다. 그리고 실제 환경에 존재하는 수십 개의 blocked_patterns 배열을 살펴본 끝에, 탐색 창 (lookback window)을 8줄로 정했습니다. 이는 현실적인 다중 라인 선언을 포착하기에 충분히 길면서도, blacklist라는 이름의 변수로부터 30줄 아래에 있는 rm -rf 명령어가 실수로 통과되지 않을 만큼 충분히 짧은 길이입니다.

이제 disler/claude-code-hooks-mastery를 대상으로 스캐너를 다시 실행하면 "...실행이 아닌 문서일 가능성이 높음"이라는 메시지와 함께 하나의 LOW 탐지 결과가 생성됩니다. 이것이 정답입니다. 스캐너는 여전히 해당 문자열을 발견했다고 알려주지만, 단지 그것에 대해 소리 높여 외치지 않을 뿐입니다.

관련 없는 보너스 발견 사항: 방어적 맥락을 이해하기 위해 pre_tool_use.py를 읽는 동안, 기록할 가치가 있는 다른 사항을 발견했습니다. 해당 리포지토리(repo) 자체의 실시간 차단 정규 표현식(regex)은 대략 r'rm\s+.*-[rf]'입니다. 이는 rm -rf /rm -fr /는 잡아내지만, GNU의 긴 형식 플래그(long-form flags)는 잡아내지 못합니다. 의도적인 공격자 --- 또는 더 현실적으로, 혼란에 빠진 LLM --- 은 rm --recursive --force /를 그대로 통과시켜 버릴 수 있습니다. 아직 이슈(issue)를 제기하지는 않았습니다 (방금 교훈을 얻었기에 먼저 적절한 재현 코드(repro)를 작성하고 싶습니다). 하지만 이는 명백한 공백이며, 단순히 지나치듯 수정하기보다는 후속 PR(Pull Request)을 보낼 가치가 있을 것입니다. 이 리포지토리를 스캔하는 다음 사람이 차단 목록(blocklist)이 완벽하다고 가정하지 않도록 이곳에 언급해 둡니다.

제가 얻은 교훈
지루한 교훈: 어휘적 맥락(lexical context)이 없는 정규 표현식(regex)은 탐지 실패보다 사용자 신뢰를 더 빠르게 무너뜨립니다. 주석 처리된 예시를 한 번 지적하는 스캐너는 짜증을 유발합니다. 두 번 지적하는 스캐너는 사용자가 알림을 꺼버리는 보안 도구가 됩니다. 알림을 꺼버린 보안 도구는 보안 도구가 없는 것보다 더 나쁩니다. 왜냐하면 보호받고 있다는 착각을 주기 때문입니다.

약간 덜 지루한 교훈: 보안 도구의 역할은 '양치기 소년'이 되지 않도록 조심하는 것입니다. 심각도 인플레이션(Severity inflation)은 제가 거의 배포할 뻔했던 실패 모드였습니다. 맥락이 모호할 때는 등급을 낮추는(Downgrading) 것이 거의 항상 옳은 선택이며, 삭제하는 것은 거의 항상 옳지 않은 선택입니다. 사용자는 스캐너가 무엇을 보았는지 확인해야 합니다. 단지 그것이 정직하게 프레임화되어 전달될 필요가 있을 뿐입니다.

그리고 주로 저 자신에 관한, 진정으로 불편한 교훈: 저는 제 도구의 출력 결과를 신뢰하기 전에 리포지토리를 직접 클론(clone)하고 파일 하나를 읽어야만 했습니다. 그것이 기준입니다. 제가 그렇게 할 의지가 없다면, 다른 사람의 리포지토리에 이슈를 제기해서는 안 되며 --- 그리고 다른 개발자들에게 스캐너의 보고서에 따라 행동하라고 말해서도 결코 안 됩니다. 저는 이 도구가 무엇이 되어야 하는지 여전히 배우고 있습니다. 만약 여러분이 자신의 CLAUDE.md에 이 도구를 실행했을 때 '양치기 소년'처럼 잘못된 경고를 보낸다면, 제게 꼭 알려주세요. 그런 것들이 이 도구를 실제로 유용하게 만드는 버그 리포트가 될 것입니다.

스캐너: apify.com/ianymu/claudemd-security-auditor

관련된 Stop-hook 작업의 소스: github.com/ianymu/claude-verify-before-stop . 두 프로젝트 모두 오픈 소스이며, 순서대로 아직 미완성 상태입니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0