Skip to content

Commit 28d61cb

Browse files
MaineK00nclaude
andcommitted
refactor(detector): scope verified-product suppression per-CVE
collectVerifiedProducts keyed the verified part:vendor:product set by RootID, unioning it across every CVE under the root. For multi-CVE JVN roots (a JVNDB note covers several CVEs) this over-suppressed: a VulnCheck / JVN match for product P was dropped for every CVE in the note when P was verified for any one of them, hiding sibling CVEs the verified source does not actually cover. Key the verified set per (RootID, CVE) and walk each suppressed source's CPE criteria once per constituent CVE with that CVE's own verified set, so suppression is scoped to the CVE and AND/OR criteria satisfaction stays correct. (Empirically JVN/VulnCheck criteria are all single/OR with no AND of multiple CPEs, so the result matches a simpler post-walk filter, but keeping suppression inside walkCPECriteria stays correct even if AND criteria appear.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent fe091e9 commit 28d61cb

2 files changed

Lines changed: 272 additions & 34 deletions

File tree

detector/vuls2/vuls2.go

Lines changed: 86 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,14 @@ type sourceData struct {
593593
// confidence instead of ExactVersionMatch — the vuls2 replacement for
594594
// go-cve-dictionary's fuzzy CPE reporting.
595595
vpCpes []string
596+
// exactCpesByCVE / vpCpesByCVE hold the per-CVE matched CPEs for SUPPRESSED
597+
// CPE sources (VulnCheck / JVN). Those sources are walked once per
598+
// constituent CVE with that CVE's own verified-product set, so a verified
599+
// product for one CVE never suppresses a sibling and the AND/OR criteria
600+
// satisfaction stays correct per CVE. Non-suppressed sources keep using the
601+
// per-root exactCpes / vpCpes above.
602+
exactCpesByCVE map[string][]string
603+
vpCpesByCVE map[string][]string
596604
}
597605

598606
type rootTag struct {
@@ -632,7 +640,7 @@ func appendMissing(dst, src []string) []string {
632640
// VulnInfo.CpeURIs so the report shows the user-supplied CPE rather than
633641
// the internal FS-with-wildcards form. Nil / missing keys fall back to
634642
// the FS string as-is.
635-
func postConvert(scanned scanTypes.ScanResult, detected detectTypes.DetectResult, fsToOriginalCPE map[string][]string, noJVNCPEs map[string]struct{}, verifiedProductsByRoot map[dataTypes.RootID]map[string]struct{}) (models.VulnInfos, error) {
643+
func postConvert(scanned scanTypes.ScanResult, detected detectTypes.DetectResult, fsToOriginalCPE map[string][]string, noJVNCPEs map[string]struct{}, verifiedProductsByRoot map[dataTypes.RootID]map[string]map[string]struct{}) (models.VulnInfos, error) {
636644
m := make(map[source]sourceData)
637645

638646
if err := walkVulnerabilityDetections(m, scanned, detected.Detected, noJVNCPEs, verifiedProductsByRoot); err != nil {
@@ -716,14 +724,24 @@ func postConvert(scanned scanTypes.ScanResult, detected detectTypes.DetectResult
716724
}
717725
}
718726

727+
// Suppressed CPE sources (VulnCheck / JVN) were walked per CVE, so
728+
// pick this vulninfo's own matched CPEs rather than the per-root
729+
// union — a verified product for a sibling CVE must not surface or
730+
// gate this one.
731+
exactForCVE, vpForCVE := vd.exactCpes, vd.vpCpes
732+
if isSuppressedCPESource(src.SourceID) {
733+
exactForCVE = vd.exactCpesByCVE[vi.CveID]
734+
vpForCVE = vd.vpCpesByCVE[vi.CveID]
735+
}
736+
719737
base.kbIDs = appendMissing(base.kbIDs, vd.kbIDs)
720-
base.exactCpes = appendMissing(base.exactCpes, vd.exactCpes)
721-
base.vpCpes = appendMissing(base.vpCpes, vd.vpCpes)
738+
base.exactCpes = appendMissing(base.exactCpes, exactForCVE)
739+
base.vpCpes = appendMissing(base.vpCpes, vpForCVE)
722740

723741
// A KB/CPE detection signal marks this CVE emittable. packStatuses
724742
// are deliberately excluded — packages are gated separately through
725743
// comparePack's per-source winner selection.
726-
if len(vd.kbIDs) > 0 || len(vd.exactCpes) > 0 || len(vd.vpCpes) > 0 {
744+
if len(vd.kbIDs) > 0 || len(exactForCVE) > 0 || len(vpForCVE) > 0 {
727745
if !slices.Contains(vd.detectableCveIDs, vi.CveID) {
728746
vd.detectableCveIDs = append(vd.detectableCveIDs, vi.CveID)
729747
}
@@ -830,30 +848,51 @@ func postConvert(scanned scanTypes.ScanResult, detected detectTypes.DetectResult
830848
return vim, nil
831849
}
832850

833-
func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.ScanResult, vs []detectTypes.VulnerabilityData, noJVNCPEs map[string]struct{}, verifiedProductsByRoot map[dataTypes.RootID]map[string]struct{}) error {
851+
func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.ScanResult, vs []detectTypes.VulnerabilityData, noJVNCPEs map[string]struct{}, verifiedProductsByRoot map[dataTypes.RootID]map[string]map[string]struct{}) error {
834852
for _, v := range vs {
835-
// The part:vendor:product set DEFINED by the verified CPE sources
836-
// (NVD / Fortinet / Cisco / PaloAlto) for this CVE — used to suppress
837-
// overlapping VulnCheck / JVN matches, mirroring go-cve-dictionary's
838-
// isCpeURIAlsoDefinedInVerifiedDataSource. Nil when no verified source
839-
// defines anything for the CVE.
840-
verifiedProducts := verifiedProductsByRoot[v.ID]
841853
for _, d := range v.Detections {
842854
for sourceID, fconds := range d.Contents {
843855
for _, fcond := range fconds {
844856
var (
845-
statuses []packStatus
846-
kbIDs []string
847-
exactCpes []string
848-
vpCpes []string
857+
statuses []packStatus
858+
kbIDs []string
859+
exactCpes []string
860+
vpCpes []string
861+
exactByCVE map[string][]string
862+
vpByCVE map[string][]string
849863
)
850-
if d.Ecosystem == ecosystemTypes.EcosystemTypeCPE {
851-
exact, vp, err := walkCPECriteria(sourceID, fcond.Criteria, scanned, noJVNCPEs, verifiedProducts)
852-
if err != nil {
853-
return xerrors.Errorf("Failed to walk cpe criteria. err: %w", err)
864+
switch d.Ecosystem {
865+
case ecosystemTypes.EcosystemTypeCPE:
866+
if isSuppressedCPESource(sourceID) {
867+
// Suppressed sources (VulnCheck / JVN) are walked ONCE
868+
// PER constituent CVE with that CVE's own verified
869+
// product set — the part:vendor:product the verified CPE
870+
// sources (NVD / Fortinet / Cisco / PaloAlto) DEFINE for
871+
// that CVE, mirroring go-cve-dictionary's
872+
// isCpeURIAlsoDefinedInVerifiedDataSource. Per-CVE keeps a
873+
// verified product for one CVE from suppressing a sibling
874+
// and keeps AND/OR satisfaction correct per CVE.
875+
exactByCVE, vpByCVE = make(map[string][]string), make(map[string][]string)
876+
for _, cveID := range cveIDsOf(v) {
877+
exact, vp, err := walkCPECriteria(sourceID, fcond.Criteria, scanned, noJVNCPEs, verifiedProductsByRoot[v.ID][cveID])
878+
if err != nil {
879+
return xerrors.Errorf("Failed to walk cpe criteria. err: %w", err)
880+
}
881+
if len(exact) > 0 {
882+
exactByCVE[cveID] = append(exactByCVE[cveID], exact...)
883+
}
884+
if len(vp) > 0 {
885+
vpByCVE[cveID] = append(vpByCVE[cveID], vp...)
886+
}
887+
}
888+
} else {
889+
exact, vp, err := walkCPECriteria(sourceID, fcond.Criteria, scanned, noJVNCPEs, nil)
890+
if err != nil {
891+
return xerrors.Errorf("Failed to walk cpe criteria. err: %w", err)
892+
}
893+
exactCpes, vpCpes = exact, vp
854894
}
855-
exactCpes, vpCpes = exact, vp
856-
} else {
895+
default:
857896
s, k, err := walkPkgCriteria(d.Ecosystem, sourceID, fcond.Criteria, fcond.Tag, scanned)
858897
if err != nil {
859898
return xerrors.Errorf("Failed to walk pkg criteria. err: %w", err)
@@ -865,8 +904,9 @@ func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.Scan
865904
// WITHOUT registering it in m: the downstream walk
866905
// treats presence in m as "this source detected
867906
// something", and an empty entry would emit contents
868-
// for an undetected CVE.
869-
if len(statuses) == 0 && len(kbIDs) == 0 && len(exactCpes) == 0 && len(vpCpes) == 0 {
907+
// for an undetected CVE. A suppressed source's per-CVE
908+
// result counts as a signal here too.
909+
if len(statuses) == 0 && len(kbIDs) == 0 && len(exactCpes) == 0 && len(vpCpes) == 0 && len(exactByCVE) == 0 && len(vpByCVE) == 0 {
870910
continue
871911
}
872912

@@ -885,6 +925,18 @@ func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.Scan
885925
base.kbIDs = append(base.kbIDs, kbIDs...)
886926
base.exactCpes = append(base.exactCpes, exactCpes...)
887927
base.vpCpes = append(base.vpCpes, vpCpes...)
928+
for cveID, cpes := range exactByCVE {
929+
if base.exactCpesByCVE == nil {
930+
base.exactCpesByCVE = make(map[string][]string)
931+
}
932+
base.exactCpesByCVE[cveID] = append(base.exactCpesByCVE[cveID], cpes...)
933+
}
934+
for cveID, cpes := range vpByCVE {
935+
if base.vpCpesByCVE == nil {
936+
base.vpCpesByCVE = make(map[string][]string)
937+
}
938+
base.vpCpesByCVE[cveID] = append(base.vpCpesByCVE[cveID], cpes...)
939+
}
888940
m[src] = base
889941
}
890942
}
@@ -1161,9 +1213,10 @@ func hasSuppressedCPESource(v detectTypes.VulnerabilityData) bool {
11611213
// while NVD is under the CVE root), so the verified data is gathered across all
11621214
// roots that alias the CVE via GetVulnerabilityDataByVulnerabilityID. Results
11631215
// are cached per CVE so VulnCheck and JVN detections of the same CVE share one
1164-
// bolt read. The returned map is keyed by the suppressed source's RootID for a
1165-
// direct lookup in walkVulnerabilityDetections.
1166-
func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectResult) (map[dataTypes.RootID]map[string]struct{}, error) {
1216+
// bolt read. The returned map is keyed by the suppressed source's RootID, then
1217+
// by constituent CVE ID, so a multi-CVE root keeps each CVE's verified product
1218+
// set separate (a verified product for one CVE must not suppress a sibling).
1219+
func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectResult) (map[dataTypes.RootID]map[string]map[string]struct{}, error) {
11671220
cache := make(map[string]map[string]struct{})
11681221
productsFor := func(cveID string) (map[string]struct{}, error) {
11691222
if set, ok := cache[cveID]; ok {
@@ -1174,7 +1227,7 @@ func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectR
11741227
DataSources: verifiedCPESources,
11751228
})
11761229
if err != nil {
1177-
return nil, err
1230+
return nil, xerrors.Errorf("Failed to get vulnerability data by vulnerability ID. CVE-ID: %s, err: %w", cveID, err)
11781231
}
11791232
set := make(map[string]struct{})
11801233
for _, d := range vd.Detections {
@@ -1193,23 +1246,23 @@ func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectR
11931246
return set, nil
11941247
}
11951248

1196-
out := make(map[dataTypes.RootID]map[string]struct{})
1249+
out := make(map[dataTypes.RootID]map[string]map[string]struct{})
11971250
for _, v := range detected.Detected {
11981251
if !hasSuppressedCPESource(v) {
11991252
continue
12001253
}
1201-
merged := make(map[string]struct{})
1254+
byCVE := make(map[string]map[string]struct{})
12021255
for _, cveID := range cveIDsOf(v) {
12031256
set, err := productsFor(cveID)
12041257
if err != nil {
12051258
return nil, xerrors.Errorf("Failed to get verified products. CVE-ID: %s, err: %w", cveID, err)
12061259
}
1207-
for k := range set {
1208-
merged[k] = struct{}{}
1260+
if len(set) > 0 {
1261+
byCVE[cveID] = set
12091262
}
12101263
}
1211-
if len(merged) > 0 {
1212-
out[v.ID] = merged
1264+
if len(byCVE) > 0 {
1265+
out[v.ID] = byCVE
12131266
}
12141267
}
12151268
return out, nil

0 commit comments

Comments
 (0)