← 스터디 홈
3편 · 약 15분

버전 비교와 fixed_version 처리

버전 비교가 생각보다 어려운 이유

"설치된 패키지 버전이 취약한가?"라는 질문은 단순해 보이지만, 실제로는 생태계마다 버전 문자열 비교 규칙이 전혀 다르다. 1.0.0 < 1.0.1이야 누구나 동의하지만, 1.0.0-alpha vs 1.0.0은 어떤가? 1!2.0.0 vs 3.0.0은? 1.0.1e-58.el6_10 vs 1.0.1f는?

잘못된 버전 비교는 두 가지 문제를 만든다.

  • 오탐(false positive): 이미 패치된 버전을 취약하다고 보고.
  • 미탐(false negative): 실제로 취약한 버전을 안전하다고 판단.

이 챕터에서는 주요 생태계의 버전 비교 알고리즘과 OSV 포맷이 이를 어떻게 다루는지 살펴본다.

생태계별 버전 형식

SemVer 2.0 — npm, Go, Rust, 기타

SemVer(Semantic Versioning)는 MAJOR.MINOR.PATCH 세 숫자로 구성된다.

1.2.3-alpha.1+build.5
│ │ │  │           │
│ │ │  pre-release  build metadata (비교 무시)
│ │ PATCH
│ MINOR
MAJOR

비교 규칙의 핵심은 다음과 같다.

  1. MAJOR → MINOR → PATCH 순서로 숫자 비교.
  2. pre-release 태그가 있으면 없는 것보다 낮다: 1.0.0-rc.1 < 1.0.0.
  3. pre-release 간 비교: 숫자 필드는 숫자로, 문자 필드는 사전순. alpha < beta < rc.
  4. build metadata(+build.5)는 비교에서 완전히 무시한다.

취약점 대응에서 중요한 함의: 만약 fixed 버전이 1.4.0이라면, 1.4.0-rc.1은 아직 취약한 것으로 간주해야 한다.

Python PEP 440

Python 버전 형식은 SemVer와 유사하지만 더 복잡한 정규화 규칙을 가진다.

[N!]N(.N)*[{a|b|c|rc}N][.postN][.devN]

구성 요소를 순서대로 나열하면 다음과 같다.

1.4.2.dev0 1.4.2a1 1.4.2b2 1.4.2rc1 1.4.2 1.4.2.post1
개발 중 pre-release 정식 릴리즈 메타 수정
PEP 440 버전 순서 (같은 기본 버전 내)

epoch(N!): 버전 체계를 전환할 때만 사용한다. 1!0.1 > 99.9처럼, epoch가 다르면 그것이 모든 것을 결정한다. CalVer에서 SemVer로 전환하는 프로젝트가 이 방식을 쓴다.

post-release(.postN): 문서나 패키징 오류만 수정할 때 사용한다. 코드는 동일하므로 보안 관점에서는 1.4.21.4.2.post1을 동일하게 취급하는 경우가 많다.

Debian 버전 형식

dpkg 비교 알고리즘(dpkg --compare-versions)은 세 단계로 동작한다.

  1. epoch 비교: 숫자 비교. 1: > 0:.
  2. upstream_version 비교: 숫자와 비숫자 부분을 교대로 분리해 비교. 비숫자는 사전순, 숫자는 정수 비교.
  3. debian_revision 비교: upstream_version 방식과 동일.

예: 3.0.2-0ubuntu1.12 에서

  • upstream_version: 3.0.2
  • debian_revision: 0ubuntu1.12

tilde(~)는 특별한 의미를 가진다. 알파벳이나 빈 문자열보다 낮게 정렬된다: 1.0~beta < 1.0. 이것을 이용해 pre-release를 표현한다.

RPM EVR (Epoch:Version-Release)

RPM은 epoch:version-release를 순서대로 비교하는 rpmvercmp 알고리즘을 사용한다. 핵심 규칙은 다음과 같다.

  1. epoch가 다르면 epoch만으로 결정.
  2. version 문자열을 숫자 덩어리([0-9]+)와 알파벳 덩어리([a-zA-Z]+)로 번갈아 분리한다.
  3. 숫자 덩어리는 정수 비교, 알파벳 덩어리는 사전순 비교.
  4. 버전이 같으면 release 필드를 같은 방식으로 비교.

예: 1.0.1e-58.el6_10 vs 1.0.1f:

  • version 분리: [1, 0, 1, e] vs [1, 0, 1, f]
  • 알파벳 e < f 이므로 1.0.1e < 1.0.1f

실제로 RPM은 1.0.1e-58.el6_10을 RHEL 6용 패키지로 인식하고, 취약점 판단은 RHSA OVAL 정의와 교차 참조해서 한다.

OSV range type과 버전 비교

OSV 포맷은 ranges[].type으로 비교 방식을 명시한다.

SEMVER
SemVer 2.0 규칙 적용 npm · Go · Rust · 기타 SemVer 생태계 pre-release 포함 완전 비교 가능
ECOSYSTEM
생태계 고유 문자열 (비정형) PyPI · Maven · Debian · Ubuntu · Alpine versions[] 배열 함께 제공 권장
GIT
전체 길이 Git 커밋 해시 C/C++ 라이브러리 등 패키지 없는 생태계 커밋 그래프 순서로 범위 판단
OSV 세 가지 range type

SEMVER 타입

OSV가 SemVer 2.0 파서를 직접 사용해 비교한다. 도구 입장에서는 생태계 고유 로직 없이 판단할 수 있어 가장 편리하다.

{
  "type": "SEMVER",
  "events": [
    { "introduced": "1.0.0" },
    { "fixed": "1.2.3" }
  ]
}

1.0.0 ≤ version < 1.2.3 범위가 취약하다.

ECOSYSTEM 타입

생태계 고유 버전 문자열을 그대로 사용한다. 매칭 도구가 해당 생태계의 비교 로직을 구현하지 않으면 introduced/fixed 범위 질의에 답할 수 없다. 그래서 OSV 사양은 versions[] 배열도 함께 제공하도록 권고한다.

{
  "type": "ECOSYSTEM",
  "events": [
    { "introduced": "2.0-beta9" },
    { "fixed": "2.15.0" }
  ]
}

OSV 인프라는 인제스트 단계에서 지원하는 생태계에 대해 versions[]를 자동으로 채운다. 직접 레코드를 작성하는 경우에는 수동으로 추가해야 한다.

GIT 타입

패키지 시스템이 없는 C/C++ 프로젝트 등에서 쓴다. introducedfixed가 전체 길이 커밋 해시다. 범위 판단에 리포지터리의 커밋 그래프가 필요하다.

{
  "type": "GIT",
  "repo": "https://github.com/example/project",
  "events": [
    { "introduced": "a3b4c5d6..." },
    { "fixed": "f7e8d9ab..." }
  ]
}

fixed가 없는 경우

fixed 이벤트가 없으면 아직 수정이 없다는 의미다. limit: "*" 이벤트로 명시적으로 상한이 없음을 표시하거나, fixed 없이 introduced만 있을 수 있다.

{
  "type": "SEMVER",
  "events": [
    { "introduced": "2.0.0" }
  ]
}

이 경우 2.0.0 이상의 모든 버전이 취약하다. 취약점 수집 파이프라인은 이런 레코드를 미해결(unpatched) 으로 분류하고 별도로 추적해야 한다.

버전 비교 라이브러리

생태계별로 검증된 라이브러리를 쓰는 것이 안전하다. 직접 문자열 파싱 코드를 짜면 edge case에서 반드시 틀린다.

생태계추천 라이브러리언어
SemVer (일반)golang.org/x/mod/semverGo
SemVer (일반)semver (npm)Node.js
Python PEP 440packaging.version.VersionPython
Debianpython-debian, dpkg --compare-versionsPython / Shell
RPMrpm.labelCompare (librpm)C / Python
Alpineapk_version_compare (libapk)C

Python packaging 라이브러리 사용 예:

from packaging.version import Version

installed = Version("2.28.0")
fixed     = Version("2.28.2")
print(installed < fixed)   # True → 취약

Go golang.org/x/mod/semver 사용 예:

import "golang.org/x/mod/semver"

cmp := semver.Compare("v1.2.2", "v1.2.3")
// cmp < 0 → 취약

완전한 버전 비교 흐름

입력
설치된 패키지
ecosystem + name + version
+ OSV 레코드
affected[].ranges
생태계 판별
SEMVER ECOSYSTEM GIT
생태계별 비교 함수 선택
(packaging / semver / dpkg / rpmvercmp)
introduced ≤ version < fixed
→ 취약
그 외
→ 안전
fixed 없음
→ 미해결
취약점 매칭 시 버전 비교 전체 흐름

edge case 모음

실제 구현에서 자주 마주치는 함정들이다.

SemVer pre-release와 fixed

fixed가 1.4.0이고 설치 버전이 1.4.0-rc.1인 경우, 1.4.0-rc.1 < 1.4.0이므로 취약 범위 안이다. 이 케이스를 놓치는 구현이 많다.

Python epoch

requests 같은 패키지는 epoch를 쓰지 않지만, 버전 체계를 바꾼 프로젝트는 epoch를 올린다. 1!0.1.0을 단순 0.1.0으로 파싱하면 비교가 완전히 역전된다.

Debian tilde

1.0~beta1 < 1.0이다. ~를 일반 문자로 처리하면 순서가 틀린다.

RPM release 필드

1.0.1-1.el8 vs 1.0.1-1.el9는 버전이 동일하고 배포판만 다르다. 비교 시 배포판을 인식해야 한다.

versions[] 없는 ECOSYSTEM range

매칭 도구가 해당 생태계 비교 로직을 구현하지 않았는데 versions[]도 없으면 해당 레코드는 정확히 매칭할 수 없다. 수집 파이프라인에서 이런 레코드는 별도 처리가 필요하다.

Open Questions

  • Maven 버전 비교 규칙은 공식 사양이 복잡해 구현체마다 차이가 있을 수 있음 (확인 필요).
  • Go pseudo-version(v0.0.0-20231020...)과 SemVer 정식 태그의 혼용 처리 방식은 golang.org/x/mod/semverIsValid 결과로 먼저 확인 권장.

References

  • https://semver.org/
  • https://peps.python.org/pep-0440/
  • https://www.debian.org/doc/debian-policy/ch-binary.html
  • https://rpm.org/docs/6.0.x/man/rpm-version.7
  • https://ossf.github.io/osv-schema/
  • https://pkg.go.dev/golang.org/x/mod/semver
  • https://packaging.pypa.io/en/stable/version.html
  • https://sethmlarson.dev/pep-440