LLM WikiAccess-protected knowledge portal

WIKI

증분 수집 전략

왜 전체 재수집만으로는 부족한가 취약점 데이터베이스를 처음 만들 때는 전체 덤프나 전체 API 페이지를 한 번 훑으면 된다. 문제는 그 다음날부터다. CVE, GHSA, OSV 같은 보안 데이터는 새 레코드가 추가될 뿐 아니라 기존 레코드의 점수, 영향 버전, withdrawn 여부, reference, alias가 계속 바뀐다. "어제 이후 새로 나온 CVE만 가져오기"로 끝내면 이미 저장한 레코드의 수정분을 놓친다. 증분

경로human/study/content/vulnerability-collection/04-incremental-collection.md
카테고리Study
태그#collection #content #crawler #incremental #monitoring #report #security #study #vulnerability

왜 전체 재수집만으로는 부족한가

취약점 데이터베이스를 처음 만들 때는 전체 덤프나 전체 API 페이지를 한 번 훑으면 된다. 문제는 그 다음날부터다. CVE, GHSA, OSV 같은 보안 데이터는 새 레코드가 추가될 뿐 아니라 기존 레코드의 점수, 영향 버전, withdrawn 여부, reference, alias가 계속 바뀐다. "어제 이후 새로 나온 CVE만 가져오기"로 끝내면 이미 저장한 레코드의 수정분을 놓친다.

증분 수집의 목표는 세 가지다.

취약점 수집은 단순 크롤러가 아니라 작은 CDC 시스템에 가깝다. 데이터 소스의 modified 또는 updated 시간을 물고, 내부 저장소에는 마지막 성공 지점과 원본 해시를 남겨야 한다.

기본 아키텍처

Scheduler
5~30분 주기
Source Connector
NVD · GHSA · OSV
Checkpoint
watermark + cursor
Raw Event Log
원본 JSON + hash
Normalizer
CVE/GHSA/OSV 공통 모델
Canonical Store
upsert by advisory id
Diff Detector Package Matcher Alert / Report
증분 취약점 수집 파이프라인

핵심은 원본 수집과 정규화 저장을 분리하는 것이다. 원본 JSON을 보관하지 않으면 나중에 파서 버그를 고쳤을 때 재처리할 수 없다. 반대로 원본만 보관하고 정규화 테이블을 만들지 않으면 패키지 매칭, diff, 알림이 매번 비싸진다.

워터마크는 published가 아니라 modified 기준

취약점 레코드에는 대체로 두 종류의 시간이 있다.

시간 필드의미증분 수집에서의 용도
published최초 공개 시점신규 공개 정렬, 리포트 기준
modified / lastModified / updated_at마지막 수정 시점증분 수집의 주 기준

수집기는 published가 아니라 수정 시간을 기준으로 조회해야 한다. 예를 들어 2021년에 공개된 Log4Shell 레코드가 2026년에 reference나 affected range를 수정했다면, published >= 어제 조건으로는 절대 잡히지 않는다.

안전한 워터마크 규칙은 다음과 같다.

  1. 각 소스별로 last_successful_window_end를 저장한다.
  2. 다음 실행은 start = last_successful_window_end - overlap으로 시작한다.
  3. overlap은 5분~24시간 사이에서 소스 특성에 맞춘다. NVD처럼 지연 반영이 있을 수 있는 소스는 더 길게 둔다.
  4. 저장은 advisory id 기준 upsert이므로 overlap 구간의 중복은 문제가 되지 않게 만든다.
  5. 모든 페이지와 정규화가 성공한 뒤에만 워터마크를 전진시킨다.

즉 워터마크는 "여기까지 요청했다"가 아니라 "여기까지 저장·검증까지 끝났다"여야 한다.

소스별 증분 방식

NVD CVE API

NVD 2.0 CVE API는 https://services.nvd.nist.gov/rest/json/cves/2.0 엔드포인트에서 lastModStartDate, lastModEndDate, startIndex, resultsPerPage를 조합해 수정 시간 기준 페이지네이션을 수행할 수 있다. 응답에는 totalResults, startIndex, resultsPerPage가 포함되므로 offset 기반으로 끝까지 순회한다.

GET /rest/json/cves/2.0
  ?lastModStartDate=2026-06-01T00:00:00.000+00:00
  &lastModEndDate=2026-06-02T00:00:00.000+00:00
  &startIndex=0
  &resultsPerPage=2000

운영 팁은 두 가지다. 첫째, API 키를 쓰면 rate limit 여유가 커지지만 키를 URL query가 아니라 apiKey 헤더로 보내야 한다. 둘째, 큰 기간을 한 번에 조회하지 말고 하루 또는 몇 시간 단위 window로 쪼개야 실패 재시도가 쉬워진다.

NVD에는 CVE Change History API도 있다. 이것은 CVE 레코드가 언제, 왜 변경됐는지 추적하는 용도에 가깝다. 실제 canonical store를 갱신할 때는 CVE API로 현재 스냅샷을 다시 받아 저장하고, change history는 감사 로그나 원인 분석에 보조로 붙이는 구조가 단순하다.

GitHub Advisory Database

GitHub의 global security advisories REST API는 https://api.github.com/advisories에서 sort=updated, direction=desc, per_page 같은 파라미터를 지원한다. 공개 advisory 조회는 인증 없이도 가능하지만, 안정적인 운영에서는 토큰을 써서 rate limit을 관리하는 편이 낫다.

REST API가 updated since 필터를 모든 사용 사례에 직접 제공한다고 가정하지 않는 것이 안전하다. 실무적으로는 다음 방식이 견고하다.

  1. sort=updated&direction=desc로 최신 수정 순 페이지를 읽는다.
  2. 각 advisory의 updated_at 또는 동등한 갱신 시간을 확인한다.
  3. 로컬 워터마크보다 오래된 레코드가 충분히 연속으로 나오면 중단한다.
  4. 그래도 하루 1회 정도는 더 넓은 window를 재검사해 누락을 보정한다.

GHSA는 ghsa_idcve_id를 동시에 가질 수 있다. 저장할 때는 GHSA-...를 원본 키로 보존하고, aliases 또는 identifiers에서 CVE를 추출해 NVD 레코드와 연결한다. CVE가 없는 GitHub-originated advisory도 있으므로 cve_id가 null인 레코드를 버리면 안 된다.

OSV

OSV API의 /v1/query/v1/querybatch는 "내 패키지 버전이 취약한가"를 묻는 lookup API다. querybatch는 한 요청에 최대 1000개 package version 또는 commit 질의를 담을 수 있다. 이것은 인벤토리 스캔에는 훌륭하지만, 전체 데이터베이스를 증분 미러링하는 API는 아니다.

OSV를 내부 저장소로 미러링하려면 공개 GCS bucket(osv-vulnerabilities)이나 ecosystem별 덤프를 주기적으로 가져와 modified 필드와 원본 해시로 변경을 판단하는 쪽이 더 자연스럽다. Dependency-Track도 OSV를 GCS bucket에서 미러링하고, 선택한 ecosystem만 동기화하는 방식을 문서화한다.

OSV의 장점은 패키지 생태계와 영향 버전 범위가 이미 구조화되어 있다는 점이다. 수집 후 정규화 단계에서 affected[].package.ecosystem, affected[].package.name, ranges, versions, aliases를 그대로 보존하면 다음 단계의 패키지 매칭 비용이 크게 줄어든다.

저장 모델: 이벤트 로그와 현재 상태를 같이 둔다

증분 수집에서 가장 흔한 실수는 현재 상태 테이블 하나만 두는 것이다. 그렇게 하면 "무엇이 언제 바뀌었는지"를 추적할 수 없다. 최소한 아래 두 계층은 분리하는 편이 좋다.

계층저장 내용용도
raw eventsource + source_id + fetched_at원본 JSON, HTTP metadata, body hash재처리, 감사, 파서 회귀 테스트
canonical advisorycanonical id정규화 필드, aliases, latest hash, latest modified조회, 매칭, 리포트
change eventcanonical id + version이전/이후 hash, 변경 필드 목록알림, 영향 재계산

canonical id는 한 번에 완벽히 정하기 어렵다. 초기에 source:id를 기본 키로 저장하고, alias graph를 별도 테이블로 관리한 뒤, CVE/GHSA/OSV 관계가 확인되면 logical advisory group으로 묶는 방식이 안전하다.

-- 개념 예시
advisory_source_record(source, source_id, modified_at, raw_hash, raw_json)
advisory_alias(source, source_id, alias_type, alias_value)
advisory_canonical(canonical_id, latest_modified_at, normalized_json)
advisory_change(canonical_id, changed_at, old_hash, new_hash, changed_fields)

idempotent upsert 설계

overlap window를 쓰면 같은 레코드를 반복해서 보게 된다. 따라서 저장은 반드시 idempotent해야 한다.

좋은 upsert 흐름은 다음과 같다.

  1. 원본 JSON에서 안정적인 source_id를 뽑는다. NVD는 CVE ID, GitHub는 GHSA ID, OSV는 OSV id가 된다.
  2. 정규화 전 원본 body의 hash를 계산한다.
  3. 같은 source + source_id의 최신 hash와 같으면 canonical 갱신을 건너뛴다.
  4. hash가 다르면 raw event를 추가하고 정규화한다.
  5. 정규화 결과를 canonical store에 upsert한다.
  6. 영향받는 패키지 매칭 결과를 재계산한다.

이 구조에서는 네트워크 재시도, 스케줄러 중복 실행, overlap 재조회가 모두 안전해진다. 같은 입력은 같은 결과를 만들고, 변경된 입력만 downstream 작업을 발생시킨다.

삭제와 withdrawn 처리

취약점 데이터는 완전히 삭제되기보다 withdrawn, rejected, disputed 같은 상태로 바뀌는 경우가 많다. 수집기는 "조회 결과에서 사라졌다"를 곧바로 삭제로 해석하면 안 된다.

운영 저장소에서는 물리 삭제보다 status를 바꾸고 기존 매칭 결과를 무효화하는 방식이 안전하다. 이미 사용자에게 알림을 보낸 취약점이 나중에 withdrawn되면, "사라짐"이 아니라 "철회됨" 이벤트를 남겨야 감사가 가능하다.

스케줄링과 백오프

증분 수집은 빠를수록 좋은 작업이 아니다. 외부 보안 데이터 제공자의 rate limit과 갱신 주기를 존중해야 한다.

권장 패턴은 다음과 같다.

재시도는 "전체 하루를 다시"보다 "실패한 source/window/page만 다시"가 낫다. 그래야 한 소스 장애가 전체 파이프라인을 막지 않는다.

체크리스트

실제 구현 전에 아래 질문에 답할 수 있어야 한다.

Open Questions

References