PatchHolmes가 후보 100개를 통째로 읽어 취약점 패치 커밋을 찾는다
PatchHolmes: Agentic Patch Retrieval via Listwise Selection
무엇인가
이 논문이 다루는 문제는 패치 검색(patch retrieval)이다. 주어진 CVE 설명과 대상 저장소가 있을 때, 그 취약점을 실제로 고친 커밋 식별자를 찾아내는 작업이다. 보안 권고문, CVSS 점수, 영향 버전 범위 추적, SBOM 기반 공급망 스캐너가 모두 이 매핑 위에 서 있다. 그런데 GitHub Advisory Database와 NVD의 CVE 중 60~63%가 패치 링크를 갖고 있지 않고, 보조 정보 기반 매칭은 수작업을 동원해도 공개 OSS 취약점의 12~53%밖에 복구하지 못한다. NVD 자체가 유지보수 적체를 겪고 있고, Apache Rave의 CVE-2013-1814 패치는 공개 후 10년이 지나서야 드러났다. 저자들은 이 적체를 따라잡으려면 저장소별 파인튜닝 없이, 유료 외부 검색 API 없이, 고정된 LLM과 로컬 Git 저장소만으로 돌아가야 한다고 못 박는다. 문제를 어렵게 만드는 성질 세 가지도 짚는다. CVE의 49%가 커밋 5,000개가 넘는 저장소에 걸려 있고, 8,401개 CVE 코퍼스에서 평균 커밋 diff가 약 15,000 토큰이라 BERT류의 512 토큰 창을 한참 넘으며, CVE 설명과 수정 커밋 메시지가 공유하는 어휘가 적다. CVE는 증상(버퍼 오버플로)을 말하고 커밋은 원인(범위 밖 읽기)을 말하기 때문에 어휘 기반 검색으로는 맞출 수 없다는 것이다. 1단계 검색기가 정답을 top-100 어딘가에 올려놓을 수는 있어도, 병목은 그 100개 중 무엇이 진짜 수정인지 정하는 2단계 선택기다.
어떻게 동작하나
기존 연구의 한계를 저자들은 두 가지로 정리한다. 첫째는 개루프(open-loop) 구조다. 정적 피처 스택이 후보를 한 번만 채점해 최종 순위를 내놓고, top이 틀렸을 때 질의를 다듬거나 후보를 자세히 들여다보거나 되돌아 나올 방법이 없다. 둘째는 LLM 이전 시대의 인코더다. 512 토큰 창에 평균 15,000 토큰 diff를 담을 수 없어 채점이 시작되기도 전에 대부분이 잘린다. 가장 가까운 선행 시스템인 Favia는 10개 후보 각각에 대해 LLM이 yes/no와 신뢰도를 뱉는 방식인데, 10번의 호출이 서로 독립적이라 여러 후보가 그럴듯하게 yes를 받으면 동점을 깰 공동 신호가 없다. 논문은 CVE-2015-9251(jQuery의 크로스 도메인 Ajax를 통한 XSS)을 예로 든다. Favia의 10개 포인트와이즈 호출이 모든 후보를 신뢰도 5의 yes로 표시했고, 그 결과 rank-1으로 뽑힌 e7b3bc4는 크로스 도메인 코드 경로를 건드린 무관한 리팩터였으며, 실제 수정인 b078a62는 놓쳤다.
무엇과 다른가
PatchHolmes는 2단계 구조다. 1단계는 재현율 지향으로, CVE 설명만으로 100개 후보 목록을 만든다. 두 경로를 병렬로 돌려 RRF(k=60)로 융합한다. 어휘 경로는 커밋 메시지 BM25에 0.35, diff 본문 BM25에 0.15, CVE reserve 날짜 대비 시간 순위 점수에 0.30, 공개 날짜 대비 점수에 0.20을 주는 고정 가중 합이다. 시간 순위 점수는 커밋과 CVE의 순위 차이에 대해 1/(1+2|r-c|)로, 시간이 가장 가까운 커밋에서 1, 한 칸 차이에서 1/3, 열 칸 차이에서 1/21로 대칭 감쇠한다. 커밋 메시지에 가장 큰 가중치를 준 이유는 CVE 설명과 어휘를 더 많이 공유하기 때문이고, diff 본문이 가장 작은 이유는 대부분 코드 토큰이라 자연어 CVE 텍스트와 겹치지 않기 때문이다. 밀집 경로는 Qwen3-Embedding-8B로 CVE 설명과 모든 커밋을 4096차원 공간에 임베딩해 코사인 유사도로 순위를 매긴다. diff는 정보 밀도에 따라 파일 경로, 헝크 헤더, ± 줄, 컨텍스트 줄 네 버킷으로 나눠 우선순위가 높은 쪽부터 6,000자 예산을 채우되 줄을 반으로 자르지 않는다. 임베딩은 저장소별로 오프라인에서 미리 계산해 행렬 하나로 캐시해 두고, CVE별 검색은 그 캐시에 대한 행렬 곱 한 번으로 끝난다. 융합 상위 1,000개를 디스크에 쓰고 2단계는 그중 앞 100개를 읽는다. 100개로 자른 이유는 열린 루프 채점기가 늦게 순위를 매긴 정답 커밋까지 건질 만큼 깊으면서, 대화 프롬프트를 LLM 호출 한 번 분량의 후보 목록 토큰 안에 유지하기 위해서다.
어떻게 쓰나
2단계는 그 100개를 네 개의 도구를 가진 LLM 에이전트로 선별한다. 최대 15회 반복, 정체 감지가 켜져 있고, submit_answer를 호출하거나 반복 예산을 다 쓰거나 같은 행동을 연속 두 번 하면 종료된다. 도구는 조사-점검-파고들기-제출 순으로 이어진다. list_candidates는 인자 없이 100개 후보를 1단계 순위대로 한 줄씩 돌려준다. 순위, 커밋 ID, 커밋 메시지 첫 줄, 변경 파일 개요(예: 2src/1test/1doc)가 들어가며, 100개를 한 번에 보는 유일한 호출이다. read_commit은 커밋 ID를 받아 커밋 메시지, 파일 목록, 8,000자 예산으로 포맷한 diff 본문을 준다. diff는 세 단계를 거친다. 파일별로 레코드를 쪼개고, 소스 코드 태그를 테스트·문서 태그보다 우대하고 큰 편집에 상한까지 가점을 주며 CVE 설명과의 경로 토큰 겹침을 반영하되 리팩터 모양의 거대 diff에는 감점하는 휴리스틱으로 우선순위를 매기고, 우선순위 내림차순으로 예산에 채워 넣는다. 다 안 들어가면 경로·헝크 헤더·± 줄만 남기고 컨텍스트 줄을 버려 압축하고, 그래도 넘치면 read_file_diff로 읽으라는 포인터를 남긴다. read_file_diff는 커밋 ID와 파일 경로를 받아 16,000자 예산으로 단일 파일 diff를 가져온다. 커밋 단위 예산이 다 보여주지 못한 파일을 파고들거나, 여러 후보가 같은 파일을 건드릴 때 나란히 비교할 때 쓴다. submit_answer는 추론 문자열과 함께 정확히 한 번 호출되고, 제출된 커밋 ID가 검색 결과가 된다. 전형적인 대화는 100개 중 3~10개 커밋을 읽고 끝난다. OpenHands-SDK Conversation 런타임에 올렸지만 네 도구 설계 자체는 프레임워크에 종속되지 않는다고 밝힌다.
전제와 한계
실험은 GitHubAD의 809개 CVE 작업 부분집합(정제된 8,401개 CVE 코퍼스에서 저장소 단위 층화 표집)에서 Qwen3-235B 백본으로 수행됐다. Recall@1은 PatchHolmes 59.95% 대 Favia 34.61%, IRCoT 28.55%, SPFinder 27.32%로, 가장 강한 베이스라인 대비 25.34%p 앞선다. 같은 809개 CVE에 대한 McNemar 정확 검정에서 Favia 대비 p=2.4×10⁻⁴⁴이고, Recall@1의 95% 부트스트랩 신뢰구간 [56.7, 63.5]가 Favia의 [31.3, 37.9]와 겹치지 않는다. Recall@10은 77.26% 대 Favia 72.31%, SPFinder 70.36%로 차이가 4.95%p로 줄지만, NDCG@10은 68.01% 대 53.91%로 14.10%p 벌어진다. 포인트와이즈 방식이 후보를 따로 채점해 동점을 깰 신호가 없는 결과라는 해석이다. 검색기를 변수에서 제거한 비교도 있다. 1단계 rank-1 후보를 그대로 쓰면 32.63%인데 같은 100개 후보에 에이전트를 붙이면 59.95%로, 에이전트만으로 27.32%p가 오른다. 같은 후보 집합에서 Favia는 34.61%에서 39.80%로, IRCoT는 28.55%에서 37.58%로 오르므로 후보 집합 개선분은 각각 5.19%p, 9.03%p이고, 후보를 맞춘 뒤에도 남는 20.15%p와 22.37%p가 선택 방법의 몫이다. 다른 코퍼스로의 전이도 확인한다. PatchFinder의 TF-IDF+CodeReviewer 사전 순위기가 만든 10개 후보를 쓰는 PatchFinder_top10(1,252개 CVE)에서 1단계를 건너뛰고 같은 에이전트를 그대로 적용해 Recall@1을 PatchFinder 자체 top-1인 24.28%에서 39.86%로 올린다. 절대 15.58%p, 상대 64.16% 상승이다. 다만 1,252개 중 675개는 10개 후보 안에 정답 커밋이 아예 없어 단일 선택 방식의 상한이 46.10%이고, PatchHolmes는 그 상한까지의 격차 중 71.40%를 메운다.
절제 실험은 설계 선택을 하나씩 떼어낸다. 1단계에서 BM25만 쓰면 39.68%로 전체 대비 20.27%p 뒤지고, 시간 감쇠를 더하면 49.57%로 절반을 회복하며, 밀집 경로만 쓰면 57.11%로 2.84%p 차이까지 따라붙는다. 즉 밀집 경로가 일을 대부분 하지만 RRF 융합이 그 위에 Recall@1 +2.84%p, Recall@10 +5.32%p를 더한다. 짧은 CVE 설명을 Favia식 전체 NVD 리포트(CVSS, CWE, 설정)로 바꾸면 Recall@1은 59.83% 대 59.95%로 평평하고 Recall@10은 2.11%p 떨어지며, 긴 프롬프트가 일부 CVE를 32K 컨텍스트 밖으로 밀어 컨텍스트 초과 오류율이 0.49%에서 2.60%로 5배 오른다. 도구별 기여는 사다리로 제시된다. 에이전트 없이 1단계 rank-1을 쓰면 32.63%, list_candidates로 100개 한 줄 목록만 보고 제출하면 48.21%로 15.58%p 오르며 이 단일 단계가 가장 크다. 여기에 diff 텍스트 없는 파일 수준 개요로 read_commit을 더하면 9.27%p, 실제 diff를 읽으면 마지막 2.47%p가 붙는다. 백본 교체 실험에서 Qwen3-235B 59.95% 대 Qwen3-Coder-30B 59.09%로 0.86%p 차이고, 다른 계열인 gpt-oss-120B는 56.98%, gpt-oss-20B는 49.81%로 에이전트 없는 바닥인 32.63%를 크게 웃돈다. gpt-oss 모델들은 추론 모델이라 15회 반복 상한에 먼저 닿는 경우가 많아 제출률이 70%와 54%로 Qwen3-235B의 90.5%보다 낮고, 미제출 CVE는 1단계 순위로 되돌아가 점수가 눌린다. 비용은 CVE 한 건당 평균 입력 96,118 토큰, 출력 846 토큰, 약 8회 도구 호출, 5개 커밋 점검, 86초(중앙값 49초)이고 32K 창을 넘는 CVE는 0.49%뿐이다. OpenRouter 요율로 CVE당 약 0.007달러, 8,401개 전체 코퍼스 한 바퀴에 약 58달러로, CVE당 67,000 토큰짜리 대화를 10번 돌리는 Favia의 약 400달러 대비 토큰을 7배 적게 쓴다. Qwen3-Coder-30B로 바꾸면 Recall@1 0.86%p를 내주고 벽시계 시간이 86초에서 39초로 반으로 줄어든다.
실무자 입장에서 이 시스템은 CVE 설명과 로컬 Git 저장소만 있으면 돌아가는 단일 선택기다. 유료 검색 API도, 저장소별 파인튜닝도, 외부 인덱스 구축도 필요 없고 오픈웨이트 모델 하나를 고정해 쓴다는 점이 온프레미스 취약점 파이프라인에 붙이기 좋은 조건이다. 다만 에이전트는 1단계가 top-100 안에 넣어준 것만 고를 수 있다는 구조적 전제를 기억해야 한다. 후보 풀에 정답이 없으면 어떤 선택기도 살릴 수 없고, PatchFinder_top10에서 상한이 46.10%로 묶인 것이 그 예다. 따라서 도입 전에 확인할 것은 1단계 재현율과 후보 풀 크기, 그리고 CVE당 약 0.007달러·86초라는 비용 곡선이 자신의 CVE 처리량과 맞는지다. 또 이 평가는 정답 커밋이 이미 기록된 CVE만 대상으로 하므로, 아무도 수정을 찾지 못한 진짜 어려운 케이스에서의 성능은 이 수치로 알 수 없다.
저자들이 명시한 한계는 네 가지다. 첫째, 정답 패치 링크가 이미 존재하는 CVE에서만 시험할 수 있다. 주요 DB의 60~63%는 패치 링크가 아예 없으므로 8,401개 코퍼스는 그 필터를 통과한 부분집합이고, 모든 패치 검색 방법이 인구의 쉬운 37~40%에서만 평가된다. 벤치마크 밖 검증으로 2013~2025년에 공개된 11개 저장소의 실제 CVE 35건에 전체 시스템을 돌려 Recall@1 60.0%를 얻었지만, 이 역시 알려진 수정이 있어야 채점할 수 있어 범위 한계를 없애지는 못한다. 둘째, PatchFinder_top10의 복구 가능 상한이 46.10%라는 점이다. 셋째, 소스 코퍼스의 대형 샤드 아티팩트다. 2,816개 저장소 중 143개가 커밋 히스토리를 500MB 넘는 파일에 저장하고 있어(CoreNLP 15GB, aws-sdk-java 12GB, phpmyadmin 10GB 등) 전체 파싱 비용이 커서 커밋 수와 정답 패치 diff 길이에 코퍼스 전체 중앙값을 대입했고, 그 결과 표본이 그 두 축에서 원본보다 약간 균일해 보인다. 넷째, 다중 수정 CVE의 모호성이다. 8,401개 중 92.72%는 기록된 수정 커밋이 하나지만 7.28%는 여러 개이고 CVE당 평균 1.12개다. 평가는 제출 커밋이 기록된 정답 집합에 있으면 적중으로 세므로, 다른 브랜치로의 백포트나 여러 커밋으로 쪼갠 리팩터처럼 똑같이 유효하지만 기록되지 않은 수정을 골랐다면 이 시스템과 같은 데이터셋의 모든 단일 선택 방식에서 미스로 처리된다.