리뷰어는 버그를 축복했다: 에이전트가 작성한 코드에 계층적 리뷰 (Layered Review)가 필요한 이유
요약
AI 에이전트가 작성한 코드의 보안 결함을 방지하기 위해 독립적인 계층적 리뷰(Layered Review)의 중요성을 강조합니다. 단일 리뷰의 사각지대를 극복하기 위해 서로 다른 관점을 가진 다중 리뷰 레이어가 필요함을 실제 로그 스크러버 사례를 통해 설명합니다.
핵심 포인트
- AI 에이전트 작성 코드에 대한 단일 리뷰는 보안 사각지대를 놓칠 위험이 큼
- 리뷰 레이어는 반드시 서로 독립적이어야 사각지대 중첩을 방지할 수 있음
- 실제 실행 환경에서의 검증(Live-environment recon)이 코드 리뷰(Diff review)의 한계를 보완함
- 작고 단순한 코드라도 보안 관련 로직은 다중 검증 프로세스가 필수적임
한 보안 리뷰어가 제 diff를 읽고 특정 주장에 승인을 내렸습니다. 자격 증명 삭제(credential-scrubbing)가 정확하며, "인코딩된 access/secret 형태가 서로 구별되어 오작동으로 인한 잘못된 삭제(mis-redaction)가 없다"는 내용이었습니다. 리뷰는 철저했습니다. 모든 로그 경로를 추적했습니다. 하지만 그 리뷰는 틀렸습니다.
한 단계 더 나아간 리뷰 레이어(review layer)에서, 다른 리뷰어가 동일한 함수를 가져가 두 개의 중첩된 자격 증명을 입력했고 그 결과를 출력했습니다. 결과는 로그 라인에 부분적인 비밀 값(partial secret)이 남아 있는 것이었습니다. 삭제(redaction) 과정이 하나의 비밀 값 일부를 먹어버리고 다른 비밀 값의 뒷부분을 노출시킨 것입니다. 같은 코드, 같은 오후였지만, 결과는 정반대였습니다.
"주의 깊은 리뷰어가 이를 인증했다"와 "독립적인 공격자가 단 한 번의 실행으로 이를 깨뜨렸다" 사이의 그 간극이 바로 계층적 리뷰(layered review)가 필요한 이유 전체를 대변합니다. 에이전트(agent)가 코드를 작성할 때(그리고 점점 더 그렇게 되어가고 있습니다), 단 한 번의 리뷰 패스(review pass)는 안전망이 될 수 없습니다. 리뷰 패스들은 반드시 **독립적(independent)**이어야 합니다. 독립성만이 그들의 사각지대(blind spots)가 일치하지 않게 만드는 유일한 방법이기 때문입니다.
이 가이드는 실제 흐름을 따라갑니다: 하나의 작은 로그 스크러버(log-scrubber), 4개의 리뷰 레이어, 5개의 결함, 그리고 각 레이어가 다른 레이어가 보지 못한 것을 포착할 수 있게 해준 정확한 방식(modality)을 다룹니다. 여기에 등장하는 모든 버그는 여러분의 자체 점검 프로세스에 복사해 넣을 수 있는 형태들입니다.
대상 독자: AI 에이전트가 작성한 코드를 배포하거나, 이를 검증하는 리뷰 하네스(review harness)를 구축하는 모든 사람. 특히 조용한 실패가 몇 주 동안 숨어 있을 수 있는 셀프 호스팅(self-hosted) 인프라 환경의 개발자.
실제로 무엇을 강화하고 있었나?
2026-07 패스 중, 셀프 호스팅 LLM-관측성(observability) 스택(ClickHouse 기반의 Langfuse, MinIO 오브젝트 스토어로 푸시되는 백업, restic을 통한 암호화된 오프박스(off-box) 복제본)에 대해 저는 한 가지 함수를 강화했습니다. 바로 백업 실패 메시지가 cron 로그에 도달하기 전에 자격 증명을 제거하는 로그 스크러버(log scrubber)입니다. 약 20줄 정도의 코드입니다. 이는 에이전트가 몇 초 만에 생성하고 인간이 몇 초 만에 훑어보는, 보안과 밀접한 바로 그 종류의 작은 코드입니다.
스크러버 (scrubber)가 중요한 이유는 백업 스크립트가 실패할 때 오류가 명확하게 드러나기 때문입니다. 그리고 실패 메시지에는 종종 실패한 명령어가 포함되는데, 이 명령어에는 자격 증명 (credentials)이 포함되어 있습니다. 만약 야간 백업이 중단되어 S3 비밀 키 (secret key)가 로그 애그리게이터 (aggregator)로 전송되는 로그에 남게 된다면, 당신은 신뢰성 이벤트 (reliability event)를 자격 증명 노출 이벤트 (credential-exposure event)로 바꿔버린 것입니다. 스크러버의 유일한 임무는 그것을 불가능하게 만드는 것입니다.
따라서 이는 좋은 테스트 케이스입니다. 규모는 작지만 위험도가 높으며, 모두가 실제 리뷰가 필요하기에는 너무 작다고 가정하는 종류의 작업입니다. 어쨌든 이 코드는 네 개의 계층을 거쳤습니다. 각 계층이 무엇을 확인했는지 여기 있습니다.
왜 라이브 환경 정찰 (live-environment recon)은 디프 (diff)가 잡지 못하는 것을 잡아내는가?
어떠한 디프 리뷰 (diff review)를 수행하기 전에, 저는 실제 호스트를 대상으로 백업을 한 번 실행했습니다. 그리고 그 단 한 번의 실행이 코드 판독기로는 절대 찾을 수 없었을 결함을 드러냈습니다. 스크립트가 퍼지 이름 매칭 (fuzzy name match)을 통해 MinIO 컨테이너를 선택했는데, 마침 해당 호스트에서 두 개의 MinIO 컨테이너가 실행 중이었던 것입니다. 매칭 과정에서 잘못된 컨테이너를 잡았고, 볼 수 없는 버킷에 대해 액세스 거부 (Access Denied) 오류가 발생했으며, 실제 백업은 정상임에도 불구하고 매일 밤 잘못된 CRITICAL 오류를 보고했습니다.
디프 리뷰로는 이를 잡아낼 수 없습니다. 코드는 로컬에서 올바르게 작동했습니다. docker ps를 이름 부분 문자열로 필터링하면 첫 번째 매칭 항목을 반환하며, MinIO가 하나뿐인 개발 환경 (dev box)에서는 그 첫 번째 매칭 항목이 항상 의도한 항목이 됩니다. 이 버그는 오직 라이브 환경의 형태 안에서만 존재합니다. 즉, 백업이 원하는 컨테이너보다 관련 없는 두 번째 MinIO가 우연히 정렬 순서상 앞서게 된 상황 말입니다. 교훈은 오래되었고 여전히 유효합니다. 통과된 로컬 테스트와 깨끗한 디프는 코드를 설명할 뿐, 코드가 실행되는 세상을 설명하지는 못합니다. 해결책은 스크립트 내에서 기본값으로 정확한 이름 (exact name)을 통해 컨테이너를 선택하도록 하여, 퍼지 매칭이 잘못된 인스턴스로 흘러 들어가지 않도록 하는 것이었습니다.
정찰 (Recon)은 그 자체로 하나의 리뷰 계층입니다. 정찰은 정적 리뷰어 (static reviewer)가 도달할 수 없는 양식인 런타임 상태 (runtime state)를 감지합니다. 만약 당신의 에이전트 리뷰 하네스 (agent-review harness)가 오직 디프 판독기들로만 구성되어 있다면, 프로덕션 (production)과 접촉할 때만 나타나는 결함 클래스 전체를 보지 못하고 눈먼 상태로 남게 됩니다.
단일 리뷰어가 놓치는 것을 PR 전 리뷰어 함대 (pre-PR reviewer fleet)는 무엇을 잡아내는가?
Pull Request (PR)가 열리기 전, 저는 diff에 대해 세 명의 독립적인 리뷰어(adversarial, correctness, security)를 실행했습니다. 그중 security pass가 눈에 띄게 숨겨져 있던 redaction(비식별화) 버그를 잡아냈습니다. scrubber(데이터 마스킹 도구)가 각 비밀 정보를 쉘 패턴 치환 방식인 s="${s//${SECRET}/<redacted>}"로 교체하고 있었는데, 따옴표로 감싸지 않은 needle(찾을 문자열)이 glob(와일드카드 패턴)으로 취급된 것이 문제였습니다. [ab]와 같은 대괄호 표현식을 포함하는 비밀 정보는 문자 클래스(character class)로 읽혀 자기 자신과 리터럴하게 일치하지 못하게 되고, 결국 비식별화되지 않은 채 로그를 통과하게 됩니다. 반대로 비밀 정보에 *나 ?가 포함된 경우에는 과도하게 매칭되어 인접한 로그 텍스트까지 비식별화해 버리는 문제가 발생합니다. 두 경우 모두 잘못된 것이며, 비밀 정보를 알지 못하면 어느 쪽이 발생할지 예측할 수 없으므로 정보 유출의 위험이 있습니다.
해결책은 단 한 줄의 수정, 즉 매칭이 항상 리터럴하게 이루어지도록 needle을 따옴표로 감싸는 것입니다:
# 따옴표 없음: 대괄호 표현식이 포함된 비밀 정보는 유출되고, * 또는 ?는 과도하게 비식별화함
s="${s//${SECRET}/<redacted>}"
# 따옴표 있음: 비밀 정보가 리터럴하게 매칭되어 두 가지 오류 모드가 모두 차단됨
...
리뷰어 함대(fleet)의 가치는 단순히 읽기 횟수가 세 배가 되는 데 있지 않습니다. 그것은 세 번의 서로 다른 읽기입니다. 한 명의 리뷰어에게 diff를 맡기면, 무엇을 확인할 가치가 있는지에 대한 해당 모델의 사전 확률(priors)에 의존하게 됩니다. 하지만 서로 다른 adversarial framing(적대적 프레임워크)과 각자의 사각지대를 가진 세 명의 리뷰어를 배치하면, 그들이 인지하는 내용의 합집합은 엄격하게 더 커집니다. 이것은 추가하기에 가장 저렴한 계층(layer)임에도 불구하고 사람들이 가장 많이 건너뛰는 부분입니다. 왜냐하면 유능한 모델이 내뱉는 단 한 번의 "looks good(괜찮아 보입니다)"라는 답변이 충분하다고 느껴지기 때문입니다. 하지만 그것은 충분하지 않습니다. 그것은 단 하나의 샘플일 뿐입니다.
왜 push 이후의 계층은 security 리뷰어가 인증한 내용을 잡아냈는가?
ABC라는 액세스 키(access key)와 ABCDEF라는 비밀번호(password)가 있을 때, push 이후의 리뷰어(post-push reviewer)가 이미 인증된 스크러버(scrubber)를 password=ABCDEF 라인에 실행하자 password=<redacted>DEF라는 결과가 나왔습니다. 즉, 로그에 비밀 정보의 일부가 남았습니다. PR 이전의 보안 검사(pre-PR security pass) 단계에서는 정확히 이 함수를 대상으로 비밀 정보 중첩 버그(overlapping-secret bugs)를 확인했으며, "인코딩된 액세스/비밀 정보 형식이 구별되며, 중첩으로 인한 잘못된 마스킹(mis-redaction)은 없음"이라고 승인했습니다. 하지만 코드를 추론하는 대신 직접 실행한 두 번째 모델은 단 한 번의 실행만으로 그 인증을 깨뜨렸습니다.
전체 버그 내용은 다음과 같습니다. 스크러버는 각 비밀 정보를 순차적으로 마스킹(redact)합니다. 만약 더 짧은 비밀 정보가 더 긴 비밀 정보의 부분 문자열(substring)인 경우, 짧은 정보의 교체 작업이 먼저 실행되어 라인을 변형시키고, 결과적으로 더 긴 비밀 정보가 자기 자신과 일치하지 않게 되어 그 뒷부분(tail)이 남게 됩니다.
# ACCESS='ABC' RESTIC='ABCDEF'
s="${s//"${ACCESS}"/<redacted>}" # 'password=ABCDEF' -> 'password=<redacted>DEF'
s="${s//"${RESTIC}"/<redacted>}" # 더 이상 일치하지 않음; 'DEF'가 유출됨
해결책은 탐색 대상(needles)을 순서가 있는 리스트로 취급하는 것을 멈추고, 가장 긴 것부터 우선적으로(longest-first) 마스킹하는 것입니다. 이렇게 하면 더 짧은 탐색 대상이 더 긴 비밀 정보를 완전히 일치시키기 전에 파편화하는 것을 방지할 수 있습니다.
# 탐색 대상을 길이 내림차순으로 정렬한 다음, 각 항목을 문자 그대로 마스킹함
if [ ${#needles[@]} -gt 0 ]; then
readarray -t needles < <(printf '%s\n' "${needles[@]}" | awk '{print length"\t"$0}' | sort -rn | cut -f2-)
...
가장 긴 것부터 우선 처리하는 방식은 한 비밀 정보가 다른 비밀 정보 내부에 완전히 포함되는 부분 문자열 케이스를 해결합니다. 다만, 로그 라인 내에서 두 개의 서로 다른 비밀 정보가 겹치는(하나의 접미사(suffix)가 다른 하나의 접두사(prefix)와 일치하는) 더 드문 케이스까지는 해결하지 못합니다. 무작위로 생성된 자격 증명(credentials)은 이런 경우가 거의 없지만, 만약 사용 중인 자격 증명이 그럴 가능성이 있다면 루프(loop) 대신 단일 결합 패스(single combined pass)로 마스킹하십시오. 한계를 명시하되, 이 해결책이 완벽하다고 과장하지 마십시오.
이 부분은 핵심적인 대목이므로, 발생한 상황을 잘 곱씹어 보시기 바랍니다. 유능한 리뷰어는 중첩(overlap) 문제를 간과하지 않았습니다. 리뷰어는 중첩 문제를 고려했으나 잘못된 답변을 내놓았습니다. 인코딩된 형태(encoded forms)는 서로 구별된다고 추론했으나, 원본 형태(raw forms)가 중첩될 수 있다는 점을 놓쳤기 때문입니다. 함수를 추론하는 대신 직접 실행한 두 번째 독립적 적대자(adversary)는 즉시 반례(counterexample)를 찾아냈습니다. 한 명의 리뷰어에 의한 인증은 하나의 주장(argument)일 뿐입니다. 독립적인 다른 이의 재현(reproduction)은 사실(fact)입니다. 단 한 명의 리뷰어만 감당할 수 있다면, 코드를 직접 실행하는 계층(layer)을 선택하십시오.
머지 게이트(merge gate) 자체가 리뷰 계층이 될 수 있는가?
마지막 결함은 코드 자체에 있었던 것이 아니라, CI 게이트(CI gate)에서 드러났습니다. 제가 새로 작성한 테스트는 자격 증명 형태의 피스처(credential-shaped fixtures, AKIA 접두사가 붙은 AWS access-key-ID 형태의 값)를 사용했는데, 시크릿 스캐너(secret scanner)가 이를 탐지하여 **전이적으로 머지를 차단(transitively blocked the merge)**했습니다. 해당 스캐너는 해당 브랜치의 필수 상태 체크(required status checks) 항목 중 하나가 아니었음에도 말입니다. 동일한 피스처에 대해 실행된 두 번째 스캐너는 통과되었습니다. 게이트가 스스로 모순된 결과를 낸 것입니다.
함정은 한 단계의 간접 참조(indirection)에 있었습니다. 스캐너가 브랜치 보호(branch-protection) 필수 목록에 없었기 때문에 권고(advisory) 사항처럼 보였습니다. 하지만 필수 사항이었던 별도의 '실패 시 차단(fail-closed)' 메타 체크(meta-check)가 자신이 대기하는 체크 항목들의 자체 내부 목록을 가지고 있었고, 그 목록에 해당 스캐너가 포함되어 있었습니다. 즉,
두 가지 실용적인 교훈을 얻을 수 있습니다. 첫째, 자격 증명(credential) 형태의 피스처(fixture)를 절대 하드코딩하지 마십시오. 코드 경로를 실행할 수 있으면서도 명백히 가짜이고 엔트로피가 낮은 플레이스홀더(placeholder)를 사용해야 합니다. 스캐너는 테스트 데이터와 실제 유출된 데이터를 구분할 수 없으며, 구분해서는 안 되기 때문입니다. 둘째, 머지(merge)가 차단되었는데 필수 체크 항목들이 통과(green)된 것처럼 보인다면, 단순히 보호 규칙(protection rules)만 보지 말고 메타 게이트(meta-gates)가 실제로 무엇을 대기하고 있는지 읽어보십시오. 스캐너는 전체 브랜치 히스토리(branch history)를 스캔하므로, 팁(tip) 부분에서 피스처를 무력화한다고 해서 이전 커밋에서 발생한 탐지 결과가 사라지지는 않습니다. 반면, 순차적 머지(squash-merge)를 통해 최종 차이(net diff)로 압축하면 해결됩니다.
왜 레이어들이 동일한 항목을 잡아내지 못할까요?
전체 과정(arc)에 걸쳐, 네 개의 레이어가 중복 없이 다섯 개의 서로 다른 결함을 잡아냈습니다(머지 게이트 하나가 두 개를 찾아냈으며, 자격 증명 형태의 피스처와 전이적 차단(transitive block)이 포함됨). 이는 각 레이어가 서로 다른 양상(modality)을 감지하기 때문입니다. 라이브 정찰(Live recon)은 런타임 상태(runtime state)를 감지합니다. PR 이전의 플릿(pre-PR fleet)은 새로운 적대적 사전 확률(adversarial priors)을 가지고 차이(diff)를 감지합니다. 푸시 이후의 패스(post-push pass)는 코드를 실행하는 독립적인 모델에 의해 감지됩니다. CI 게이트는 정책(policy)과 스캐너 상태를 감지합니다. 독립적인 양상은 독립적인 사각지대(blind spot)를 가지며, 이것이 바로 레이어를 쌓는 것이 가치 있는 유일한 이유입니다.
| 리뷰 레이어 | 감지 양상 (Sensing modality) | 잡아낸 결함 |
|---|---|---|
| 라이브 정찰 (Live recon) | 실제 호스트의 런타임 상태 | 잘못된 컨테이너 선택, 잘못된 야간 CRITICAL |
| ... |
동일한 리뷰어를 중첩하는 것은 효과가 없습니다. 동일한 프레이밍(framing)을 가진 동일한 모델의 세 번의 패스는 사전 확률(priors)을 공유하기 때문에 동일한 항목을 놓치는 경향이 있습니다. 이득은 **직교성(orthogonality)**에서 옵니다. 런타임 프로브(runtime probe)는 독자가 볼 수 없는 것을 보고, 코드 실행기(code-runner)는 추론기(reasoner)가 볼 수 없는 것을 보며, 스캐너는 설계자가 잊어버린 것을 봅니다. 이 과정은 이를 증명하는 깔끔하고 자연스러운 실험입니다. 왜냐하면 한 레이어가 인증한 정확한 속성을 다른 레이어가 깨뜨렸기 때문입니다. 만약 두 레이어가 동일한 양상을 공유했다면 사각지대도 공유했을 것이고, 유출은 그대로 배포되었을 것입니다.
AI가 작성한 코드에 대한 불편한 귀결은 다음과 같습니다. 코드를 작성한 모델, diff(차이점)를 리뷰하는 모델, 그리고 정확성을 추론하는 모델이 동일한 방식으로 추론하는 동일한 모델이라면, 이들은 상관관계가 있는 실패 원인(correlated failure sources)이 됩니다. 독립성은 리뷰에 덧붙이는 '있으면 좋은 것'이 아닙니다. 독립성 그 자체가 바로 리뷰입니다.
프로세스에 매몰되지 않고 이를 구축하는 방법은 무엇인가?
모든 한 줄의 변경 사항에 대해 20단계의 시련을 거칠 필요는 없습니다. 보안과 관련된 사항에는 진정으로 독립적인 몇 개의 레이어가 필요하며, 그 외의 경우에는 더 적은 세트가 필요합니다. 실제로 에이전트(agent)가 작성한 인프라 코드의 경우, 네 가지 레이어가 중요한 양상(modalities)을 커버하며, 일단 연결되면 각 레이어의 비용은 저렴합니다.
구체적으로는 다음과 같습니다:
- diff를 신뢰하기 전에 실제 환경에서 실행하십시오. 단 한 번의 라이브 실행만으로도 어떤 검토자도 잡아낼 수 없는 잘못된 컨테이너(wrong-container), 잘못된 엔드포인트(wrong-endpoint), 그리고 "개발 환경에서는 작동함(works-on-the-dev-box)" 유형의 오류들을 드러낼 수 있습니다. 재현(Recon)은 하나의 레이어입니다.
- 한 명의 리뷰어가 세 번 검토하는 것이 아니라, 서로 다른 관점을 가진 소수의 PR 전(pre-PR) 리뷰어 세트를 구성하십시오. 적대적(Adversarial) 관점, 정확성(correctness) 관점, 그리고 명시된 버그 클래스에 대한 보안(security) 관점의 검토를 수행합니다. 이들의 합집합이 평균보다 낫습니다.
- 코드를 단순히 읽기만 하는 것이 아니라, 코드를 실행하는 다른 모델에 의한 push 후(post-push) 검토 단계를 추가하십시오. 이것이 당신의 가장 뛰어난 리뷰어가 자신 있게 승인한 것을 잡아내는 레이어입니다. 인증(certification)보다 재현(reproduction)에 무게를 두십시오.
- CI 게이트를 하나의 리뷰어로 취급하고, 실패 시 차단(fail-closed)되는 메타 체크가 무엇을 기다리는지를 포함하여 실제 의존성 그래프(dependency graph)를 읽으십시오. 그리고 테스트에는 자격 증명(credential) 형태의 데이터를 완전히 배제하십시오.
핵심은 "더 많은 리뷰"가 아닙니다. 그것은 독립적인 리뷰입니다. 빠른 작성자(writer)가 단 한 명의 느린 독자(reader)를 만났을 때, 20줄짜리 함수 하나에 있는 5개의 결함이 단 하나의 레이어를 제외한 모든 레이어에서 보이지 않는 상태로 존재하는 것이 소프트웨어의 일반적인 모습입니다. 이것은 에이전트에 대한 판결이 아니라 산술적인 결과입니다. 대신 작성자에게 네 명의 직교하는(orthogonal) 독자를 제공하십시오. 그러면 사각지대가 일렬로 늘어서는 일이 멈출 것입니다.
원문은 danmercede.com에 게시되었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기