AI한테 보안 리뷰를 맡기면 결과가 늘 화려하다. 내 블로그 코드에 Claude를 돌렸더니 이런 게 나왔다.
CRITICAL 10
HIGH 6
MEDIUM 9
열 개를 다 봤다. 진짜인 건 두 개였다. 나머지 여덟 개는 아무도 import하지 않는 파일에 있거나, 인증 미들웨어 뒤에 이미 막혀 있거나, 위험하게 생겼을 뿐 실제로 닿는 경로가 없었다. 하나씩 확인하는 데 한 시간이 걸렸다.
다음엔 안 하게 되더라. CRITICAL 10개는 CRITICAL 0개보다 쓸모가 없다. 믿을 수 없으면 리뷰 결과를 아예 안 보게 된다.
문제는 Claude가 탐지만 한다는 거다. 위험한 패턴을 찾는 건 잘한다. 그 코드가 실제 공격자에게 닿는지, 이미 다른 곳에서 막혔는지는 보지 않는다. 발견하는 역할과 검증하는 역할이 같은 AI 안에 있으니 자기가 발견한 걸 자기가 CRITICAL이라고 부르는 셈이다.
보안 쪽에서 레드팀/블루팀이라는 개념을 쓴다. 군사 훈련에서 온 말인데, 사이버보안에서는 레드팀이 해커처럼 시스템을 뚫으려 하고 블루팀이 막고 대응한다. 실제 보안 조직도 둘을 나눠 운영하는 경우가 많다. 공격과 방어를 한 팀이 하면 자기가 만든 걸 자기가 검증하게 되니까.
이걸 AI로 할 수 있지 않을까 싶었다.
레드팀
레드팀은 소스 코드를 손에 쥔 공격자처럼 행동하게 했다. 공격 AI 여러 개를 병렬로 띄워 발견을 쏟아낸 다음, 보고서에 올리기 전에 하나씩 세 가지를 통과시킨다.
- 이 코드가 실제로 호출되는가
- 외부에서 이 경로로 들어올 수 있는가
- 다른 발견과 엮이면 더 심각해지는 체인이 있는가
하나라도 통과 못 하면 심각도를 내리고 이유를 남긴다. 살아남는 CRITICAL은 셋 다 통과한 것뿐이다.
이번 블로그 보안 사이클에서 CRITICAL이 하나 나왔다. api/local-mdx/route.ts의 경로 탈출 취약점이다.
const slug = params.slug
const filePath = path.join(process.cwd(), 'src/app/local-mdx', `${slug}.mdx`)slug가 URL 파라미터에서 바로 들어온다. /api/local-mdx/../../.env 같은 요청을 보내면 path.join이 경로를 정직하게 합쳐서 .env 파일을 읽는 경로가 만들어진다. MDX 파일 대신 환경 변수 파일이 나오는 것이다.
레드팀 AI가 이 코드를 보고 "경로 탈출 가능" 하고 끝냈다면 기존 리뷰어랑 다를 게 없다. 그래서 검증 단계에서 확인하게 했다. 이 엔드포인트가 실제 라우팅에 연결되어 있는가, 인증 없이 외부에서 들어올 수 있는가. Next.js App Router가 자동으로 라우팅하고, 앞에 미들웨어가 없었다. CRITICAL 유지.
나머지 아홉 개는 다 이 단계에서 걸렸다. 미사용 파일, 이미 막힌 경로, 호출 경로 없음. 보고서에서 내려갔다.
처음엔 보안만 할 생각이었는데, 같은 구조를 다른 곳에도 쓸 수 있다는 걸 알았다. 차원마다 공격하는 시점만 바꾸면 됐다.
성능에서는 콜드 캐시 상태, 느린 네트워크, 수천 개 아이템이 한 번에 들어오는 순간을 집중적으로 본다. 데이터가 적을 땐 안 보이다가 규모가 커지면 터지는 N+1 쿼리가 여기서 잡힌다.
접근성에서는 마우스를 치우고 키보드만으로 서비스 전체를 써보게 했다. 브랜드 컬러가 대비 기준을 못 맞춰서 색을 바꾸면 디자인이 깨지는 경우가 있다. 레드팀은 그 타협을 기록으로 남긴다. 나중에 "왜 이 색이냐"는 질문에 답이 있어야 한다.
블루팀
블루팀은 레드팀 결과를 받아서 고치는 역할인데, 처음에 코드부터 바로 수정하게 했더니 문제가 있었다.
한 달이 지나면 diff만 남는다. 왜 그 결정을 했는지, 다른 방법은 없었는지가 안 남아있다. 혼자 작업할 땐 그냥 넘어갈 수 있지만, 팀이라면 컨텍스트 없는 diff는 그냥 변경 로그다.
그래서 코드 수정 전에 제안서를 먼저 쓰게 했다.
# Hardening Proposal — security — 2026-06-04
Baseline: 62/100 (CRITICAL 1, MEDIUM 3, LOW 1)
Target: 99
## Red team found
- [CRITICAL] path-traversal @ api/local-mdx/route.ts
slug가 path.join으로 바로 들어가 디렉토리 탈출 가능
- [MEDIUM] public-prefixed-secret @ .env
NEXT_PUBLIC_PINATA_* 키 존재하나 클라이언트 코드에서 미참조 확인됨
## Blue team proposes
- Fix #1: path.resolve로 baseDir 밖 차단 — 예상 +25 — risk: low
- Fix #2: 미참조 NEXT_PUBLIC_* 키 등 MEDIUM 3건 정리 — 예상 +12 — risk: low
## Out of scope this cycle
- LOW 1건 → 이월
- a11y 발견 → 다음 사이클로 이월무엇을 왜 고치는지, 점수가 얼마나 오를지가 코드보다 먼저 문서로 남는다. 팀이라면 이 제안서가 곧 PR description이 된다.
(혼자 쓰는 블로그에 이렇게까지 할 일인가 싶기도 하다. 그런데 막상 제안서를 먼저 받아보니, 코드를 머지하기 전에 한 번 멈추게 되더라.)
경로 탈출 수정은 결국 몇 줄이다. path.join 대신 path.resolve로 절대 경로를 만들고, 허용된 디렉토리 밖으로 나가는지 확인한다.
const baseDir = path.resolve(process.cwd(), 'src/app/local-mdx')
const filePath = path.resolve(baseDir, `${slug}.mdx`)
if (filePath !== baseDir && !filePath.startsWith(baseDir + path.sep)) {
return new Response('Not found', { status: 404 })
}여기서 한 번 더 의심해야 하는 게 있다. filePath.startsWith(baseDir)만 쓰면 통과해버리는 케이스가 있다. baseDir가 .../local-mdx일 때 형제 디렉토리 .../local-mdx-evil/x.mdx도 문자열상으론 local-mdx로 시작하니까 검사를 통과한다. 그래서 구분자(path.sep)까지 붙여서 비교해야 진짜 디렉토리 안쪽인지 확인된다. 그럴듯하게 생긴 한 줄짜리 체크가 실제로는 안 막는, 이 글이 내내 말하던 그 함정이다.
../../.env처럼 뻔한 탈출은 naive 검사도 막는다. 막상 새는 건 ../local-mdx-evil/x 같은 형제 디렉토리다. filePath가 baseDir 밖이라 fixed 검사에선 404가 떨어진다. 예상 점수 변화 +25.
변경은 hardening/security 같은 별도 브랜치에만 들어간다. main은 건드리지 않고 제안서를 검토한 뒤 결정한다. 관련 없는 커밋이 섞여 있으면 사이클이 시작조차 안 된다.
사이클
사이클마다 점수가 기록된다.
| Cycle | Date | Dim | Score | Δ |
|-------|------------|----------|---------|-----|
| 1 | 2026-06-04 | security | 62 → 99 | +37 |
| 2 | 2026-06-05 | perf | 71 → 83 | +12 |
| 3 | 2026-06-06 | a11y | 68 → 79 | +11 |
CRITICAL 25점, HIGH 10점, MEDIUM 4점, LOW 1점. 발견의 심각도로 baseline을 매기고, 고친 만큼 점수가 오른다. 숫자는 좀 임의적이다. CRITICAL이 HIGH보다 2.5배 더 중요하다는 수학적 근거가 있는 건 아니다. 다만 숫자가 없으면 "많이 좋아졌다"와 "조금 좋아졌다"를 구분할 방법이 없다.
한 사이클엔 한 차원만 판다. 보안을 돌릴 땐 보안만. 여러 차원을 같이 보면 우선순위가 흐려진다. 보안이 끝나면 성능, 성능이 끝나면 접근성. 공격 루브릭은 차원마다 달라지지만 발견 → 검증 → 제안서 → 적용 → 재채점 순서는 같다.
"이번에 보안 좀 봤다"가 아니라 62 → 99, +37로 남는다.
한계
이 시스템이 못 하는 게 있다. 비즈니스 로직을 모른다. 로그인한 유저가 다른 유저의 데이터에 접근할 수 있는 버그는 코드만 봐서는 알기 어렵다. 스키마를 이해하고 실제 권한 구조를 알아야 잡히는 종류의 문제다. 레드팀이 외부 공격자 시점에서 작동하기 때문에, 내부 권한 모델이 복잡한 서비스에서는 한계가 있다.
또 이 사이클이 주기적으로 돌아가려면 결과를 신뢰할 수 있어야 한다. 레드팀이 한번 틀린 검증을 내보내면 그 결과를 다시 믿게 되기까지 시간이 걸린다. 처음 신뢰를 쌓는 게 어렵다.
보안 리뷰, 성능 측정, 접근성 감사. 다 중요하고 다 비싸다. 따로 시간이 나야 하고, 결과를 믿을 수 있어야 반복한다. 그래서 대부분 한 번 하고 만다.
레드팀과 블루팀을 커맨드로 만들어두면 내가 다른 일을 하는 동안 서비스가 평가받고 개선안을 만들어 둔다. 돌아와서 제안서를 읽고 머지할지 결정하면 사이클이 닫힌다.
결국 바뀐 건 점수가 아니라 내 태도였다. 전에는 AI가 뱉은 CRITICAL 목록을 안 믿어서 안 봤다. 지금은 검증을 통과한 것만 올라오니 다시 보게 됐다. AI에게 일을 시키는 것보다, 그 결과를 믿을 수 있게 만드는 게 더 어려운 일이었다.
지금까지는 그렇게 돌아가고 있다. 얼마나 갈지는 모르겠다.