Skip to content

Commit eb54353

Browse files
shinoclaude
andcommitted
refactor(vuls2): drop the RPM-comparison fallback from CPE matching
vendorProductEligible mirrored go-cve-dictionary's match() by re-evaluating a range with RPM-style comparison when the semver comparator could not parse the query (e.g. juniper "21.4r3", safari "1.0.0b1"), reporting in-range hits at VendorProductMatch. Empirically that fallback is neither necessary nor sufficient: - Not necessary: with a well-formed query the matcher already reaches every affected version at ExactVersionMatch. Normalising juniper's joined form to version=21.4 / update=r3 makes the same wildcard range ("< 22.2") evaluate as plain semver; detection is byte-identical with the fallback on or off (199 CVEs either way). The fallback only compensates for a malformed query representation, which is a detect-side normalizer's responsibility. - Not sufficient: RPM comparison gives no consistent order for NVD's messy pre-release strings. "4.0_beta" > "4.0" but "4beta" < "4.0" (the same "4 beta" ordered oppositely by NVD spelling), and "1.0.0b1" > "1.0.0". It only lands correct when the leading version digits already dominate; near a boundary it mis-orders. Vendors like safari, whose NVD version formats are inconsistent, cannot be served by any version-comparison heuristic here. The fallback only ever produced the retired RoughVersionMatch tier (folded to VendorProductMatch), so removing it leaves ExactVersionMatch untouched; it drops only the fuzzy in-range VP guesses for non-semver query versions. Splitting such version strings belongs in a future detect-side query normalizer (tractable for regular forms like juniper, a known gap for irregular ones like safari). Simplify vendorProductEligible to the version-less cases (query ANY/NA, or criterion NA), delete rangeVendorProductEligible, and drop the now-unused go-rpm-version and cpecriterion range/criterion imports. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0d6df31 commit eb54353

3 files changed

Lines changed: 40 additions & 265 deletions

File tree

detector/vuls2/export_test.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ var (
99
PrunePkgCriteria = prunePkgCriteria
1010
Enrich = enrich
1111

12-
WalkCPECriteria = walkCPECriteria
13-
RangeVendorProductEligible = rangeVendorProductEligible
14-
MergeIntoScannedCves = mergeIntoScannedCves
12+
WalkCPECriteria = walkCPECriteria
13+
MergeIntoScannedCves = mergeIntoScannedCves
1514
)
1615

1716
type Source source

detector/vuls2/vuls2.go

Lines changed: 21 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ import (
1919
dataTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data"
2020
criteriaTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria"
2121
criterionTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion"
22-
ccTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/cpecriterion"
23-
ccRangeTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/cpecriterion/range"
2422
vcAffectedRangeTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/versioncriterion/affected/range"
2523
vcFixStatusTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/versioncriterion/fixstatus"
2624
vcPackageTypes "github.com/MaineK00n/vuls-data-update/pkg/extract/types/data/detection/condition/criteria/criterion/versioncriterion/package"
@@ -40,7 +38,6 @@ import (
4038
"github.com/MaineK00n/vuls2/pkg/version"
4139
"github.com/knqyf263/go-cpe/common"
4240
"github.com/knqyf263/go-cpe/naming"
43-
rpm "github.com/knqyf263/go-rpm-version"
4441

4542
"github.com/future-architect/vuls/config"
4643
"github.com/future-architect/vuls/constant"
@@ -1000,7 +997,7 @@ func walkCPECriteria(ca criteriaTypes.FilteredCriteria, scanned scanTypes.ScanRe
1000997

1001998
var m []string
1002999
for i, qWFN := range qWFNs {
1003-
if pvpEqual(qWFN, cWFN) && vendorProductEligible(cn.Criterion.CPE, cWFN, qWFN) {
1000+
if pvpEqual(qWFN, cWFN) && vendorProductEligible(cWFN, qWFN) {
10041001
m = append(m, scanned.CPE[i])
10051002
}
10061003
}
@@ -1051,74 +1048,29 @@ func walkCPECriteria(ca criteriaTypes.FilteredCriteria, scanned scanTypes.ScanRe
10511048
}
10521049

10531050
// vendorProductEligible reports whether a scanned CPE that already matched a
1054-
// criterion on part:vendor:product may be reported as VendorProductMatch. It
1055-
// mirrors go-cve-dictionary's match() (db/db.go): VendorProductMatch is
1056-
// reserved for "no version information" cases — a query without a concrete
1057-
// version, or a criterion whose version is NA — and a range that cannot be
1058-
// evaluated falls back to RPM-style comparison (with its documented
1059-
// false-positive tolerance) rather than reporting unconditionally. A
1060-
// comparison that confirms the query is OUTSIDE the range, or a concrete
1061-
// criterion version differing from the query, is a definite miss and is not
1062-
// reported at all.
1063-
func vendorProductEligible(cr *ccTypes.Criterion, cWFN, qWFN common.WellFormedName) bool {
1064-
qv := qWFN.GetString(common.AttributeVersion)
1065-
switch qv {
1051+
// criterion on part:vendor:product may be reported as VendorProductMatch even
1052+
// though the criterion did not accept. Mirroring go-cve-dictionary's match(),
1053+
// this is reserved for the "no version information to compare" cases: a query
1054+
// without a concrete version (ANY/NA), or a criterion whose version is NA.
1055+
//
1056+
// A wildcard (ANY) or concrete criterion that did not accept is a genuine
1057+
// version mismatch, not a vendor:product hit. go-cve-dictionary kept some of
1058+
// those via a non-semver RPM-comparison fallback, but that fallback only
1059+
// compensates for a malformed query representation rather than rescuing any
1060+
// NVD-known affected version: given a correctly formed query the matcher
1061+
// reaches every affected version at ExactVersionMatch on its own. The juniper
1062+
// case is illustrative — a scan of "21.4r3" cannot be range-compared, but the
1063+
// well-formed CPE (version "21.4", update "r3") evaluates "21.4 < 22.2" as
1064+
// plain semver and matches at Exact. Splitting such joined version strings is
1065+
// a detect-side query-normalizer responsibility, not the matcher's; the fuzzy
1066+
// RPM fallback (which only ever produced the retired RoughVersionMatch tier)
1067+
// is therefore omitted here.
1068+
func vendorProductEligible(cWFN, qWFN common.WellFormedName) bool {
1069+
switch qWFN.GetString(common.AttributeVersion) {
10661070
case "ANY", "NA":
1067-
// Query carries no concrete version to compare.
1068-
return true
1069-
}
1070-
1071-
switch cv := cWFN.GetString(common.AttributeVersion); cv {
1072-
case "NA":
1073-
// Criterion explicitly says "no version" (e.g. junos:-).
10741071
return true
1075-
case "ANY":
1076-
// Wildcard criterion: judged by its Range below.
1077-
default:
1078-
// Concrete criterion version differing from the query (an equal one
1079-
// would have been accepted on the exact path) — definite miss.
1080-
return false
1081-
}
1082-
1083-
if cr.Range == nil {
1084-
// CPEMatches-only criterion (a no-narrowing one would have been
1085-
// accepted): the query is absent from the enumerated concrete
1086-
// forms — definite miss.
1087-
return false
1088-
}
1089-
1090-
// WFN attribute values are quoted (21\.4r3); compare on the raw form
1091-
// like go-cve-dictionary does.
1092-
return rangeVendorProductEligible(cr.Range, strings.ReplaceAll(qv, "\\", ""))
1093-
}
1094-
1095-
// rangeVendorProductEligible evaluates the range bounds against the query
1096-
// version: a bound the comparator cannot evaluate (e.g. semver vs "21.4r3")
1097-
// is re-tried with RPM-style comparison — go-cve-dictionary's matchRpmVer
1098-
// fallback — and a bound that confirms out-of-range rejects the criterion.
1099-
func rangeVendorProductEligible(r *ccRangeTypes.Range, qv string) bool {
1100-
bounds := []struct {
1101-
s string
1102-
reject func(int) bool
1103-
}{
1104-
{r.GreaterEqual, func(n int) bool { return n > 0 }}, // need bound <= v
1105-
{r.GreaterThan, func(n int) bool { return n >= 0 }}, // need bound < v
1106-
{r.LessEqual, func(n int) bool { return n < 0 }}, // need bound >= v
1107-
{r.LessThan, func(n int) bool { return n <= 0 }}, // need bound > v
1108-
}
1109-
for _, b := range bounds {
1110-
if b.s == "" {
1111-
continue
1112-
}
1113-
n, err := r.Type.Compare(b.s, qv)
1114-
if err != nil {
1115-
n = rpm.NewVersion(b.s).Compare(rpm.NewVersion(qv))
1116-
}
1117-
if b.reject(n) {
1118-
return false
1119-
}
11201072
}
1121-
return true
1073+
return cWFN.GetString(common.AttributeVersion) == "NA"
11221074
}
11231075

11241076
// prunePkgCriteria drops unaffected branches from a FilteredCriteria tree.

detector/vuls2/vuls2_test.go

Lines changed: 17 additions & 193 deletions
Original file line numberDiff line numberDiff line change
@@ -9683,12 +9683,14 @@ func Test_postConvert(t *testing.T) {
96839683
},
96849684
},
96859685
{
9686-
// The scanned version (21.4r3, the juniper joined form) cannot
9687-
// be compared by the range's semver comparator; mirroring
9688-
// go-cve-dictionary's matchRpmVer fallback, the bound is
9689-
// re-evaluated RPM-style (21.4r3 < 22.2 holds) and the CVE is
9690-
// reported at VendorProductMatch instead of disappearing.
9691-
name: "cpe vendor:product fallback, range incomparable -> rpm fallback",
9686+
// The scanned version (21.4r3, the juniper joined form) cannot be
9687+
// compared by the range's semver comparator and is not in the
9688+
// criterion's CPEMatches, so it is not detected — the RPM
9689+
// fallback (which only produced the retired RoughVersionMatch) is
9690+
// gone. A detect-side query normalizer that splits the joined form
9691+
// into version "21.4" / update "r3" is the intended fix; the
9692+
// well-formed query would match "21.4 < 22.2" at Exact instead.
9693+
name: "cpe non-semver query against a range -> not detected",
96929694
args: args{
96939695
scanned: scanTypes.ScanResult{
96949696
CPE: []string{
@@ -9759,29 +9761,7 @@ func Test_postConvert(t *testing.T) {
97599761
},
97609762
},
97619763
},
9762-
want: models.VulnInfos{
9763-
"CVE-2025-0005": {
9764-
CveID: "CVE-2025-0005",
9765-
Confidences: models.Confidences{models.NvdVendorProductMatch},
9766-
CpeURIs: []string{"cpe:/o:vendor:product:21.4r3"},
9767-
CveContents: models.CveContents{
9768-
models.Nvd: []models.CveContent{
9769-
{
9770-
Type: models.Nvd,
9771-
CveID: "CVE-2025-0005",
9772-
Title: "title",
9773-
Summary: "description",
9774-
SourceLink: "https://nvd.nist.gov/vuln/detail/CVE-2025-0005",
9775-
Published: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
9776-
LastModified: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC),
9777-
Optional: map[string]string{
9778-
"vuls2-sources": "[{\"root_id\":\"CVE-2025-0005\",\"source_id\":\"nvd-api-cve\",\"segment\":{\"ecosystem\":\"cpe\"}}]",
9779-
},
9780-
},
9781-
},
9782-
},
9783-
},
9784-
},
9764+
want: models.VulnInfos{},
97859765
},
97869766
}
97879767
for _, tt := range tests {
@@ -10605,31 +10585,6 @@ func Test_walkCPECriteria(t *testing.T) {
1060510585
},
1060610586
wantVP: []string{"cpe:2.3:a:vendor:product:9.9.9:*:*:*:*:*:*:*"},
1060710587
},
10608-
{
10609-
name: "no accept, range incomparable but RPM-in-range (junos 21.4r3 < 22.2) -> vendor:product",
10610-
args: args{
10611-
criteria: criteriaTypes.FilteredCriteria{
10612-
Operator: criteriaTypes.CriteriaOperatorTypeOR,
10613-
Criterions: []criterionTypes.FilteredCriterion{
10614-
{
10615-
Criterion: criterionTypes.Criterion{
10616-
Type: criterionTypes.CriterionTypeCPE,
10617-
CPE: new(ccTypes.Criterion{
10618-
Vulnerable: true,
10619-
CPE: ccTypes.CPE("cpe:2.3:o:vendor:product:*:*:*:*:*:*:*:*"),
10620-
Range: new(ccRangeTypes.Range{
10621-
Type: ccRangeTypes.RangeTypeSEMVER,
10622-
LessThan: "22.2",
10623-
}),
10624-
}),
10625-
},
10626-
},
10627-
},
10628-
},
10629-
scanned: []string{"cpe:2.3:o:vendor:product:21.4r3:*:*:*:*:*:*:*"},
10630-
},
10631-
wantVP: []string{"cpe:2.3:o:vendor:product:21.4r3:*:*:*:*:*:*:*"},
10632-
},
1063310588
// --- definitive misses: report nothing ---
1063410589
{
1063510590
name: "no accept, concrete version mismatch -> nothing",
@@ -10676,7 +10631,13 @@ func Test_walkCPECriteria(t *testing.T) {
1067610631
},
1067710632
},
1067810633
{
10679-
name: "no accept, range incomparable and RPM-out-of-range -> nothing",
10634+
// A non-semver query (the juniper joined form "21.4r3") cannot be
10635+
// range-compared, so it is a miss regardless of where the bound
10636+
// sits — no RPM-comparison fallback. The well-formed query
10637+
// (version "21.4", update "r3") would evaluate "21.4 < 22.2" as
10638+
// plain semver and match at Exact; splitting it is the detect-side
10639+
// query normalizer's job, not this matcher's.
10640+
name: "no accept, non-semver query against a range -> nothing",
1068010641
args: args{
1068110642
criteria: criteriaTypes.FilteredCriteria{
1068210643
Operator: criteriaTypes.CriteriaOperatorTypeOR,
@@ -10689,7 +10650,7 @@ func Test_walkCPECriteria(t *testing.T) {
1068910650
CPE: ccTypes.CPE("cpe:2.3:o:vendor:product:*:*:*:*:*:*:*:*"),
1069010651
Range: new(ccRangeTypes.Range{
1069110652
Type: ccRangeTypes.RangeTypeSEMVER,
10692-
LessThan: "21.0",
10653+
LessThan: "22.2",
1069310654
}),
1069410655
}),
1069510656
},
@@ -10992,143 +10953,6 @@ func Test_walkCPECriteria(t *testing.T) {
1099210953
}
1099310954
}
1099410955

10995-
// Test_rangeVendorProductEligible pins the bound-by-bound behaviour: a bound
10996-
// the range's comparator can evaluate rejects on a confirmed out-of-range,
10997-
// and an incomparable pair falls back to RPM-style comparison —
10998-
// go-cve-dictionary's matchRpmVer fallback, false-positive tolerance
10999-
// included.
11000-
func Test_rangeVendorProductEligible(t *testing.T) {
11001-
type args struct {
11002-
r ccRangeTypes.Range
11003-
qv string
11004-
}
11005-
tests := []struct {
11006-
name string
11007-
args args
11008-
want bool
11009-
}{
11010-
{
11011-
name: "lt: inside",
11012-
args: args{
11013-
r: ccRangeTypes.Range{
11014-
Type: ccRangeTypes.RangeTypeSEMVER,
11015-
LessThan: "10.0",
11016-
},
11017-
qv: "9.9.9",
11018-
},
11019-
want: true,
11020-
},
11021-
{
11022-
name: "lt: outside",
11023-
args: args{
11024-
r: ccRangeTypes.Range{
11025-
Type: ccRangeTypes.RangeTypeSEMVER,
11026-
LessThan: "5.0",
11027-
},
11028-
qv: "9.9.9",
11029-
},
11030-
want: false,
11031-
},
11032-
{
11033-
name: "le: boundary inside",
11034-
args: args{
11035-
r: ccRangeTypes.Range{
11036-
Type: ccRangeTypes.RangeTypeSEMVER,
11037-
LessEqual: "9.9.9",
11038-
},
11039-
qv: "9.9.9",
11040-
},
11041-
want: true,
11042-
},
11043-
{
11044-
name: "lt: boundary outside",
11045-
args: args{
11046-
r: ccRangeTypes.Range{
11047-
Type: ccRangeTypes.RangeTypeSEMVER,
11048-
LessThan: "9.9.9",
11049-
},
11050-
qv: "9.9.9",
11051-
},
11052-
want: false,
11053-
},
11054-
{
11055-
name: "ge: inside",
11056-
args: args{
11057-
r: ccRangeTypes.Range{
11058-
Type: ccRangeTypes.RangeTypeSEMVER,
11059-
GreaterEqual: "9.9.9",
11060-
},
11061-
qv: "9.9.9",
11062-
},
11063-
want: true,
11064-
},
11065-
{
11066-
name: "gt: boundary outside",
11067-
args: args{
11068-
r: ccRangeTypes.Range{
11069-
Type: ccRangeTypes.RangeTypeSEMVER,
11070-
GreaterThan: "9.9.9",
11071-
},
11072-
qv: "9.9.9",
11073-
},
11074-
want: false,
11075-
},
11076-
{
11077-
name: "ge+lt window: inside",
11078-
args: args{
11079-
r: ccRangeTypes.Range{
11080-
Type: ccRangeTypes.RangeTypeSEMVER,
11081-
GreaterEqual: "5.0",
11082-
LessThan: "10.0",
11083-
},
11084-
qv: "9.9.9",
11085-
},
11086-
want: true,
11087-
},
11088-
{
11089-
name: "ge+lt window: below",
11090-
args: args{
11091-
r: ccRangeTypes.Range{
11092-
Type: ccRangeTypes.RangeTypeSEMVER,
11093-
GreaterEqual: "5.0",
11094-
LessThan: "10.0",
11095-
},
11096-
qv: "1.0",
11097-
},
11098-
want: false,
11099-
},
11100-
{
11101-
name: "incomparable query, RPM fallback in-range (21.4r3 < 22.2)",
11102-
args: args{
11103-
r: ccRangeTypes.Range{
11104-
Type: ccRangeTypes.RangeTypeSEMVER,
11105-
LessThan: "22.2",
11106-
},
11107-
qv: "21.4r3",
11108-
},
11109-
want: true,
11110-
},
11111-
{
11112-
name: "incomparable query, RPM fallback out-of-range (21.4r3 >= 21.0)",
11113-
args: args{
11114-
r: ccRangeTypes.Range{
11115-
Type: ccRangeTypes.RangeTypeSEMVER,
11116-
LessThan: "21.0",
11117-
},
11118-
qv: "21.4r3",
11119-
},
11120-
want: false,
11121-
},
11122-
}
11123-
for _, tt := range tests {
11124-
t.Run(tt.name, func(t *testing.T) {
11125-
if got := vuls2.RangeVendorProductEligible(&tt.args.r, tt.args.qv); got != tt.want {
11126-
t.Errorf("rangeVendorProductEligible() = %v, want %v", got, tt.want)
11127-
}
11128-
})
11129-
}
11130-
}
11131-
1113210956
func Test_enrich(t *testing.T) {
1113310957
type args struct {
1113410958
vim models.VulnInfos

0 commit comments

Comments
 (0)