감사 보고서 기반의 로컬 RAG: 5년간의 취약점 오프라인 검색
요약
보안 감사 보고서의 비정형 데이터를 효율적으로 검색하기 위해 발견(finding) 단위로 청킹하는 로컬 RAG 시스템 구축 방법을 소개합니다. 고정 크기 청킹 대신 문서의 논리적 구조를 활용하여 검색 정확도를 높이는 전략을 다룹니다.
핵심 포인트
- 고정 크기 청킹 대신 '발견(finding)' 단위의 원자적 청킹 적용
- Ollama와 SQLite를 활용한 비용 효율적인 오프라인 로컬 RAG 구성
- 정규 표현식을 이용한 감사 보고서 내 취약점 패턴 추출 및 구조화
- 키워드 검색의 한계를 임베딩 기반 검색으로 극복
지난달에 저는 금고(vault) 컨트랙트를 검토하던 중 갈증을 느꼈습니다. 2~3년 전 어떤 감사 보고서에서 ERC4626 지분 계산(share math)과 관련된 정확히 이와 똑같은 반올림 버그를 본 적이 있었거든요. 저는 PDF와 마크다운 파일이 담긴 폴더를 40분 동안 grep으로 뒤졌지만 결국 찾지 못했습니다. 지식은 제 디스크에 존재했지만, 단지 그것을 쿼리(query)할 수 없었을 뿐입니다.
공개된 감사 보고서는 이 분야에서 가장 활용도가 낮은 자원 중 하나입니다. Sherlock 컨테스트 보고서, Code4rena 결과물, Trail of Bits 간행물, OpenZeppelin 감사 결과 등에는 발견자들이 기술한 수천 개의 실제 취약점과 이를 유발한 정확한 코드 패턴이 담겨 있습니다. 하지만 이들은 PDF, GitHub 저장소, 심사 플랫폼 등에 흩어져 있으며, 키워드 검색은 실패하기 마련입니다. 왜냐하면 동일한 버그가 열 가지 다른 방식으로 설명되기 때문입니다. "공격자에게 유리한 반올림 방향(Rounding direction favors attacker)", "지분 가격 인플레이션(share price inflation)", "첫 예치자 공격(first depositor attack)", "기부 공격(donation attack)": 네 가지 표현 방식이지만, 결국 하나의 버그 계열입니다.
이것은 임베딩(embeddings) 문제입니다. 그래서 저는 제가 가진 보고서 모음에 대해 로컬 RAG를 구축했습니다. 임베딩을 위한 Ollama, 저장을 위한 SQLite를 사용하여 모든 것을 오프라인으로 구성했습니다. API 비용도 없고, 속도 제한(rate limits)도 없으며, 비행기 안에서도 쿼리할 수 있습니다. 다른 무엇보다 중요한 결정 사항을 포함하여 그 방법을 소개합니다.
페이지가 아닌 발견(finding) 단위로 청킹하기
모든 RAG 튜토리얼은 문서를 중첩(overlap)이 있는 고정 크기 청크(chunks)로 나누라고 말합니다. 하지만 감사 보고서의 경우 이는 오히려 해롭습니다. 500토큰 윈도우(window)는 "H-02: withdraw에서의 재진입성(Reentrancy)"의 뒷부분을 "H-03: 오라클 신선도 미확인(Oracle staleness not checked)"의 앞부분과 붙여버리며 발견 내용을 반으로 쪼개버릴 것입니다. 그러면 당신의 임베딩은 무엇과도 잘 맞지 않는 키메라(chimera)를 나타하게 됩니다.
감사 보고서에는 자연스러운 원자적 단위(atomic unit)가 있습니다. 바로 발견(finding)입니다. 하나의 제목, 하나의 심각도(severity), 하나의 설명, 하나의 코드 스니펫(code snippet), 하나의 수정 방법. 발견 내용은 거의 항상 전체를 임베딩하기에 충분할 만큼 짧으며, 검색 결과로 돌려받고 싶은 정확한 입도(granularity)를 가지고 있습니다. 따라서 청커(chunker)의 역할은 발견의 경계를 감지하는 것이며, 감사 보고서는 이 방식이 잘 작동할 만큼 정형화되어 있습니다.
interface Finding {
id: string; // "H-01", "M-07", "TOB-XYZ-3"
severity: string; // id 또는 헤딩(heading)에서 파싱됨
...
해당 정규 표현식(regex)은 Code4rena 및 Sherlock의 관례와 Trail of Bits의 ID를 모두 커버합니다. 실제 환경에서는 몇 가지 변형이 더 필요할 수 있습니다 (어떤 기업은 "Finding 3:"를 사용하고, 어떤 기업은 심각도(severity) 단어를 헤딩으로 사용합니다). 또한 PDF의 경우 먼저 텍스트 추출(text-extraction) 과정을 거쳐야 합니다 (저는 CLI 컨버터를 사용하며, 결과물이 엉망이더라도 그대로 받아들입니다). 완벽할 필요는 없습니다. 발견 사항(findings)의 90%를 정확하게 분리해내는 청커(chunker)가 고정 크기 윈도우(fixed-size windows) 방식보다 훨씬 뛰어납니다.
한 가지 더 유용한 기술은, 임베딩(embedding)되는 텍스트 앞에 메타데이터를 붙이는 것입니다. 이렇게 하면 "protocol: , severity: High"가 벡터가 나타내는 정보의 일부가 됩니다. 그러면 심각도나 프로토콜 유형을 언급하는 쿼리(query)가 별도의 설정 없이도 작동하게 됩니다.
임베딩(Embeddings) 및 저장: Ollama와 SQLite만으로도 충분합니다
이를 위해 벡터 데이터베이스(vector database)가 꼭 필요한 것은 아닙니다. 제 전체 코퍼스(corpus)는 수천 개의 발견 사항에 불과하며, 수천 개의 벡터에 대해 브루트 포스(brute-force) 코사인 유사도(cosine similarity)를 계산하는 데는 밀리초(milliseconds) 단위의 시간밖에 걸리지 않습니다. SQLite가 모든 것을 저장합니다:
import Database from "better-sqlite3";
const db = new Database("audits.db");
...
쿼리는 동일한 임베딩 호출에 스캔(scan) 과정을 더한 것과 같습니다:
function cosine(a: Float32Array, b: Float32Array): number {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
...
만약 코퍼스가 수만 개의 발견 사항 이상으로 커진다면, sqlite-vec을 통해 동일한 파일 내에서 인덱싱된 벡터 검색(indexed vector search)을 수행할 수 있습니다. 저는 아직까지 그것이 필요하지 않았습니다.
실제로 쿼리를 수행할 때의 느낌
이 모든 것을 시작하게 만든 쿼리는 다음과 같았습니다: "ERC4626 반올림 문제로 피해를 입은 프로토콜들". 동일한 코퍼스에 대해 키워드 검색(keyword search)을 수행하면 문자 그대로 "ERC4626"을 포함하는 발견 사항들이 반환됩니다. RAG는 해당 결과뿐만 아니라, 표준 명칭을 직접 언급하지 않는 '최초 예치자 지분 인플레이션(first-depositor share inflation)' 관련 발견 사항, 그리고 완전히 "자산-지분 변환(assets-to-shares conversion)이 잘못된 방향으로 절사됨"이라고 설명된 금고(vault) 버그까지 찾아냅니다. 마지막 카테고리가 바로 진정한 보상입니다. 즉, 당신이 검색할 생각조차 하지 못했던 단어들로 설명된 버그들을 찾아내는 것입니다.
유용하게 쓰이는 다른 쿼리(queries)들도 있습니다: "signature replay across chains" (체인 간 서명 재전송), "reward accounting broken by direct token transfers" (직접적인 토큰 전송으로 인한 보상 회계 오류), "pausable functions that brick withdrawals" (출금을 차단하는 일시 중지 가능 함수) 등입니다. 이제 저는 감사(audit)나 Sherlock 컨테스트를 시작하기 전에, 코퍼스(corpus)에서 해당 프로토콜의 카테고리를 검색하고 상위 20개의 결과(findings)를 읽으며 워밍업을 합니다. 이는 제 머릿속에 정확히 필요한 사전 지식(prior)을 채워줍니다.
또한 저는 검색 과정을 작은 답변 단계(answer step)로 연결했습니다. 상위 5개의 결과(findings)를 가져와 질문과 함께 qwen2.5-coder:7b에 집어넣고, 보고서 인용이 포함된 합성된 답변을 얻는 방식입니다. 솔직히 말해서 이 부분은 선택 사항입니다. 출처가 포함된 순위별 원본 결과(raw findings)가 모델의 요약보다 저에게는 보통 더 유용하며, 진정한 가치는 검색(retrieval) 단계에 있기 때문입니다. 이러한 파이프라인(plumbing)의 일부는 나중에 spectr-ai가 분석 엔진에 알려진 취약점 컨텍스트(known-vulnerability context)를 제공하는 방식에 반영되었지만, 독립형 버전은 주말 동안 진행할 수 있는 프로젝트이며 총 200줄 정도의 코드면 충분합니다.
여기서 진정한 해자(moat)는 코퍼스(corpus)이며, 이는 복리로 쌓입니다. 이제 제가 읽는 모든 보고서는 폴더에 저장되고 인덱싱(indexed)됩니다. 단 30초의 노력만으로 제가 지금까지 공부한 모든 취약점에 대해 영구적으로 검색 가능한 메모리를 갖게 되는 것입니다.
당신의 북마크나 다운로드 폴더에 들어있는 것 중, 실제로 검색이 가능하다면 매주 검색하고 싶은 것은 무엇인가요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기