数据库存储版本范围:SemVer 比较与漏洞版本匹配方案

为什么不能用字符串比较版本号

SELECT * FROM assets WHERE version < '2.15.0'

字符串比较结果:

'2.10.0' < '2.9.0'  -- 错误!字典序 '1' < '9'

SemVer(语义化版本)中 2.10.0 > 2.9.0,必须按数字分段比较。

方案一:程序端比较(推荐)

数据库只存原始版本字符串,程序端做 SemVer 比较:

Java(semver4j)

<dependency>
    <groupId>org.semver4j</groupId>
    <artifactId>semver4j</artifactId>
    <version>5.3.0</version>
</dependency>
import org.semver4j.Semver;

public class VersionMatcher {
    public static boolean isVulnerable(String version, String range) {
        try {
            Semver v = Semver.parse(version);
            return v.satisfies(range);
        } catch (Exception e) {
            return false;
        }
    }
}

// 使用
isVulnerable("2.14.1", ">=2.0.0 <2.15.0")  // true
isVulnerable("2.15.0", ">=2.0.0 <2.15.0")  // false

Python(packaging 库)

from packaging.version import Version
from packaging.specifiers import SpecifierSet

def is_vulnerable(version: str, spec: str) -> bool:
    try:
        return Version(version) in SpecifierSet(spec)
    except Exception:
        return False

is_vulnerable("2.14.1", ">=2.0.0,<2.15.0")  # True

方案二:拆成上下界存数据库

将范围条件拆为四个字段,程序比较时不需要解析字符串:

CREATE TABLE vuln_rule (
    id         BIGINT PRIMARY KEY,
    component  VARCHAR(100),
    min_version VARCHAR(50),
    max_version VARCHAR(50),
    min_include TINYINT DEFAULT 1,  -- 1=包含 >=,0=不包含 >
    max_include TINYINT DEFAULT 0   -- 1=包含 <=,0=不包含 <
);

表示 >= 2.0.0 < 2.15.0

min_versionmax_versionmin_includemax_include
2.0.02.15.010

程序判断:

boolean matches(String version, VulnRule rule) {
    Semver v = Semver.parse(version);
    Semver min = Semver.parse(rule.getMinVersion());
    Semver max = Semver.parse(rule.getMaxVersion());

    boolean lowerOk = rule.isMinInclude() ? v.isGreaterThanOrEqualTo(min) : v.isGreaterThan(min);
    boolean upperOk = rule.isMaxInclude() ? v.isLowerThanOrEqualTo(max) : v.isLowerThan(max);

    return lowerOk && upperOk;
}

方案三:版本号数字化后数据库直接查

2.15.0 转换为定宽整数 002015000,再存数据库:

public static long versionToLong(String version) {
    String[] parts = version.split("\\.");
    long major = parts.length > 0 ? Long.parseLong(parts[0]) : 0;
    long minor = parts.length > 1 ? Long.parseLong(parts[1]) : 0;
    long patch = parts.length > 2 ? Long.parseLong(parts[2]) : 0;
    return major * 1_000_000L + minor * 1_000L + patch;
}

// "2.15.0" -> 2015000
// "2.9.0"  -> 2009000
// "2.10.0" -> 2010000  (正确:2010000 > 2009000)

数据库存 version_num BIGINT,直接用 SQL 范围查询:

SELECT * FROM vuln_rule
WHERE version_num >= 2000000 AND version_num < 2015000;

适合版本号分段均不超过 999 的情况。

方案对比

方案优点缺点
程序端比较支持复杂范围(pre-release)无法在 SQL 直接过滤
上下界字段数据结构清晰,易扩展多字段 JOIN 稍复杂
数字化版本SQL 直接查询,性能最好分段超过 999 会溢出

推荐:中小规模用方案一(程序端比较),大规模漏洞库(百万级 asset)用方案三配合索引加速。

漏洞管理平台实践

批量扫描 asset 时,避免 N+1 查询:

// 一次查出所有漏洞规则
List<VulnRule> rules = vulnRuleMapper.selectAll();

// 按 component 分组
Map<String, List<VulnRule>> ruleMap = rules.stream()
    .collect(Collectors.groupingBy(VulnRule::getComponent));

// 批量匹配
for (Asset asset : assets) {
    List<VulnRule> candidates = ruleMap.getOrDefault(asset.getComponent(), List.of());
    for (VulnRule rule : candidates) {
        if (VersionMatcher.isVulnerable(asset.getVersion(), rule.getRange())) {
            record(asset, rule);
        }
    }
}