Skip to content

Commit 4906fd7

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 fe7cc11 commit 4906fd7

2 files changed

Lines changed: 266 additions & 30 deletions

File tree

detector/vuls2/vuls2.go

Lines changed: 80 additions & 29 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,49 @@ 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)
864+
switch {
865+
case d.Ecosystem == ecosystemTypes.EcosystemTypeCPE && isSuppressedCPESource(sourceID):
866+
// Suppressed sources (VulnCheck / JVN) are walked ONCE
867+
// PER constituent CVE with that CVE's own verified
868+
// product set — the part:vendor:product the verified CPE
869+
// sources (NVD / Fortinet / Cisco / PaloAlto) DEFINE for
870+
// that CVE, mirroring go-cve-dictionary's
871+
// isCpeURIAlsoDefinedInVerifiedDataSource. Per-CVE keeps a
872+
// verified product for one CVE from suppressing a sibling
873+
// and keeps AND/OR satisfaction correct per CVE.
874+
exactByCVE, vpByCVE = map[string][]string{}, map[string][]string{}
875+
for _, cveID := range cveIDsOf(v) {
876+
exact, vp, err := walkCPECriteria(sourceID, fcond.Criteria, scanned, noJVNCPEs, verifiedProductsByRoot[v.ID][cveID])
877+
if err != nil {
878+
return xerrors.Errorf("Failed to walk cpe criteria. err: %w", err)
879+
}
880+
if len(exact) > 0 {
881+
exactByCVE[cveID] = append(exactByCVE[cveID], exact...)
882+
}
883+
if len(vp) > 0 {
884+
vpByCVE[cveID] = append(vpByCVE[cveID], vp...)
885+
}
886+
}
887+
case d.Ecosystem == ecosystemTypes.EcosystemTypeCPE:
888+
exact, vp, err := walkCPECriteria(sourceID, fcond.Criteria, scanned, noJVNCPEs, nil)
852889
if err != nil {
853890
return xerrors.Errorf("Failed to walk cpe criteria. err: %w", err)
854891
}
855892
exactCpes, vpCpes = exact, vp
856-
} else {
893+
default:
857894
s, k, err := walkPkgCriteria(d.Ecosystem, sourceID, fcond.Criteria, fcond.Tag, scanned)
858895
if err != nil {
859896
return xerrors.Errorf("Failed to walk pkg criteria. err: %w", err)
@@ -865,8 +902,9 @@ func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.Scan
865902
// WITHOUT registering it in m: the downstream walk
866903
// treats presence in m as "this source detected
867904
// 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 {
905+
// for an undetected CVE. A suppressed source's per-CVE
906+
// result counts as a signal here too.
907+
if len(statuses) == 0 && len(kbIDs) == 0 && len(exactCpes) == 0 && len(vpCpes) == 0 && len(exactByCVE) == 0 && len(vpByCVE) == 0 {
870908
continue
871909
}
872910

@@ -885,6 +923,18 @@ func walkVulnerabilityDetections(m map[source]sourceData, scanned scanTypes.Scan
885923
base.kbIDs = append(base.kbIDs, kbIDs...)
886924
base.exactCpes = append(base.exactCpes, exactCpes...)
887925
base.vpCpes = append(base.vpCpes, vpCpes...)
926+
for cveID, cpes := range exactByCVE {
927+
if base.exactCpesByCVE == nil {
928+
base.exactCpesByCVE = map[string][]string{}
929+
}
930+
base.exactCpesByCVE[cveID] = append(base.exactCpesByCVE[cveID], cpes...)
931+
}
932+
for cveID, cpes := range vpByCVE {
933+
if base.vpCpesByCVE == nil {
934+
base.vpCpesByCVE = map[string][]string{}
935+
}
936+
base.vpCpesByCVE[cveID] = append(base.vpCpesByCVE[cveID], cpes...)
937+
}
888938
m[src] = base
889939
}
890940
}
@@ -1161,9 +1211,10 @@ func hasSuppressedCPESource(v detectTypes.VulnerabilityData) bool {
11611211
// while NVD is under the CVE root), so the verified data is gathered across all
11621212
// roots that alias the CVE via GetVulnerabilityDataByVulnerabilityID. Results
11631213
// 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) {
1214+
// bolt read. The returned map is keyed by the suppressed source's RootID, then
1215+
// by constituent CVE ID, so a multi-CVE root keeps each CVE's verified product
1216+
// set separate (a verified product for one CVE must not suppress a sibling).
1217+
func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectResult) (map[dataTypes.RootID]map[string]map[string]struct{}, error) {
11671218
cache := make(map[string]map[string]struct{})
11681219
productsFor := func(cveID string) (map[string]struct{}, error) {
11691220
if set, ok := cache[cveID]; ok {
@@ -1193,23 +1244,23 @@ func collectVerifiedProducts(sesh *session.Session, detected detectTypes.DetectR
11931244
return set, nil
11941245
}
11951246

1196-
out := make(map[dataTypes.RootID]map[string]struct{})
1247+
out := make(map[dataTypes.RootID]map[string]map[string]struct{})
11971248
for _, v := range detected.Detected {
11981249
if !hasSuppressedCPESource(v) {
11991250
continue
12001251
}
1201-
merged := make(map[string]struct{})
1252+
byCVE := make(map[string]map[string]struct{})
12021253
for _, cveID := range cveIDsOf(v) {
12031254
set, err := productsFor(cveID)
12041255
if err != nil {
12051256
return nil, xerrors.Errorf("Failed to get verified products. CVE-ID: %s, err: %w", cveID, err)
12061257
}
1207-
for k := range set {
1208-
merged[k] = struct{}{}
1258+
if len(set) > 0 {
1259+
byCVE[cveID] = set
12091260
}
12101261
}
1211-
if len(merged) > 0 {
1212-
out[v.ID] = merged
1262+
if len(byCVE) > 0 {
1263+
out[v.ID] = byCVE
12131264
}
12141265
}
12151266
return out, nil

detector/vuls2/vuls2_test.go

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,7 @@ func Test_postConvert(t *testing.T) {
711711
scanned scanTypes.ScanResult
712712
detected detectTypes.DetectResult
713713
fsToOriginalCPE map[string][]string
714+
verified map[dataTypes.RootID]map[string]map[string]struct{}
714715
}
715716
tests := []struct {
716717
name string
@@ -11353,10 +11354,194 @@ func Test_postConvert(t *testing.T) {
1135311354
},
1135411355
},
1135511356
},
11357+
{
11358+
// A multi-CVE JVN root: one advisory and one CPE detection block
11359+
// shared by CVE-2024-30001 and CVE-2024-30002. A verified source
11360+
// defines a:vendor:product only for CVE-2024-30001, so the per-CVE
11361+
// walk suppresses the scanned CPE for that CVE alone — CVE-2024-30001
11362+
// drops out entirely while its sibling CVE-2024-30002 still detects
11363+
// (demoted to vendor:product, with the shared DistroAdvisory).
11364+
name: "cpe jvn multi-CVE root: verified product suppresses only its own CVE, not the sibling",
11365+
args: args{
11366+
scanned: scanTypes.ScanResult{
11367+
CPE: []string{
11368+
"cpe:2.3:a:vendor:product:0.0.0:*:*:*:*:*:*:*",
11369+
},
11370+
},
11371+
fsToOriginalCPE: map[string][]string{
11372+
"cpe:2.3:a:vendor:product:0.0.0:*:*:*:*:*:*:*": {"cpe:/a:vendor:product:0.0.0"},
11373+
},
11374+
verified: map[dataTypes.RootID]map[string]map[string]struct{}{
11375+
"JVNDB-2024-000456": {
11376+
"CVE-2024-30001": {"a:vendor:product": {}},
11377+
},
11378+
},
11379+
detected: detectTypes.DetectResult{
11380+
Detected: []detectTypes.VulnerabilityData{
11381+
{
11382+
ID: "JVNDB-2024-000456",
11383+
Advisories: []dbTypes.VulnerabilityDataAdvisory{
11384+
{
11385+
ID: "JVNDB-2024-000456",
11386+
Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]advisoryTypes.Advisory{
11387+
sourceTypes.JVNFeedRSS: {
11388+
dataTypes.RootID("JVNDB-2024-000456"): []advisoryTypes.Advisory{
11389+
{
11390+
Content: advisoryContentTypes.Content{
11391+
ID: "JVNDB-2024-000456",
11392+
Title: "advisory title",
11393+
Description: "advisory description",
11394+
Published: new(time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC)),
11395+
},
11396+
Segments: []segmentTypes.Segment{
11397+
{
11398+
Ecosystem: ecosystemTypes.EcosystemTypeCPE,
11399+
},
11400+
},
11401+
},
11402+
},
11403+
},
11404+
},
11405+
},
11406+
},
11407+
Vulnerabilities: []dbTypes.VulnerabilityDataVulnerability{
11408+
{
11409+
ID: "CVE-2024-30001",
11410+
Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{
11411+
sourceTypes.JVNFeedRSS: {
11412+
dataTypes.RootID("JVNDB-2024-000456"): {
11413+
{
11414+
Content: vulnerabilityContentTypes.Content{
11415+
ID: "CVE-2024-30001",
11416+
Title: "title 30001",
11417+
Description: "description 30001",
11418+
References: []referenceTypes.Reference{
11419+
{
11420+
URL: "https://jvn.jp/vu/JVNVU90009999/index.html",
11421+
},
11422+
},
11423+
Published: new(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
11424+
},
11425+
Segments: []segmentTypes.Segment{
11426+
{
11427+
Ecosystem: ecosystemTypes.EcosystemTypeCPE,
11428+
},
11429+
},
11430+
},
11431+
},
11432+
},
11433+
},
11434+
},
11435+
{
11436+
ID: "CVE-2024-30002",
11437+
Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{
11438+
sourceTypes.JVNFeedRSS: {
11439+
dataTypes.RootID("JVNDB-2024-000456"): {
11440+
{
11441+
Content: vulnerabilityContentTypes.Content{
11442+
ID: "CVE-2024-30002",
11443+
Title: "title 30002",
11444+
Description: "description 30002",
11445+
References: []referenceTypes.Reference{
11446+
{
11447+
URL: "https://jvn.jp/vu/JVNVU90009999/index.html",
11448+
},
11449+
},
11450+
Published: new(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
11451+
},
11452+
Segments: []segmentTypes.Segment{
11453+
{
11454+
Ecosystem: ecosystemTypes.EcosystemTypeCPE,
11455+
},
11456+
},
11457+
},
11458+
},
11459+
},
11460+
},
11461+
},
11462+
},
11463+
Detections: []detectTypes.VulnerabilityDataDetection{
11464+
{
11465+
Ecosystem: ecosystemTypes.EcosystemTypeCPE,
11466+
Contents: map[sourceTypes.SourceID][]conditionTypes.FilteredCondition{
11467+
sourceTypes.JVNFeedRSS: {
11468+
{
11469+
Criteria: criteriaTypes.FilteredCriteria{
11470+
Operator: criteriaTypes.CriteriaOperatorTypeOR,
11471+
Criterions: []criterionTypes.FilteredCriterion{
11472+
{
11473+
Criterion: criterionTypes.Criterion{
11474+
Type: criterionTypes.CriterionTypeCPE,
11475+
CPE: new(ccTypes.Criterion{
11476+
Vulnerable: true,
11477+
FixStatus: new(vcFixStatusTypes.FixStatus{
11478+
Class: vcFixStatusTypes.ClassUnknown,
11479+
}),
11480+
CPE: ccTypes.CPE("cpe:2.3:a:vendor:product:0.0.0:*:*:*:*:*:*:*"),
11481+
}),
11482+
},
11483+
Accepts: criterionTypes.AcceptQueries{
11484+
CPE: criterionTypes.CPEAccepts{Exact: []int{0}},
11485+
},
11486+
},
11487+
},
11488+
},
11489+
},
11490+
},
11491+
},
11492+
},
11493+
},
11494+
},
11495+
},
11496+
},
11497+
},
11498+
want: models.VulnInfos{
11499+
"CVE-2024-30002": {
11500+
CveID: "CVE-2024-30002",
11501+
Confidences: models.Confidences{models.JvnVendorProductMatch},
11502+
CpeURIs: []string{"cpe:/a:vendor:product:0.0.0"},
11503+
DistroAdvisories: models.DistroAdvisories{
11504+
{
11505+
AdvisoryID: "JVNDB-2024-000456",
11506+
Description: "advisory description",
11507+
Issued: time.Date(2024, 1, 2, 0, 0, 0, 0, time.UTC),
11508+
Updated: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC),
11509+
},
11510+
},
11511+
CveContents: models.CveContents{
11512+
models.Jvn: []models.CveContent{
11513+
{
11514+
Type: models.Jvn,
11515+
CveID: "CVE-2024-30002",
11516+
Title: "title 30002",
11517+
Summary: "description 30002",
11518+
SourceLink: "https://jvndb.jvn.jp/ja/contents/2024/JVNDB-2024-000456.html",
11519+
References: models.References{
11520+
{
11521+
Link: "https://jvn.jp/vu/JVNVU90009999/index.html",
11522+
Source: "MISC",
11523+
},
11524+
{
11525+
Link: "https://jvndb.jvn.jp/ja/contents/2024/JVNDB-2024-000456.html",
11526+
Source: "JVN",
11527+
RefID: "JVNDB-2024-000456",
11528+
},
11529+
},
11530+
Published: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
11531+
LastModified: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC),
11532+
Optional: map[string]string{
11533+
"vuls2-sources": "[{\"root_id\":\"JVNDB-2024-000456\",\"source_id\":\"jvn-feed-rss\",\"segment\":{\"ecosystem\":\"cpe\"}}]",
11534+
},
11535+
},
11536+
},
11537+
},
11538+
},
11539+
},
11540+
},
1135611541
}
1135711542
for _, tt := range tests {
1135811543
t.Run(tt.name, func(t *testing.T) {
11359-
got, err := vuls2.PostConvert(tt.args.scanned, tt.args.detected, tt.args.fsToOriginalCPE, nil, nil)
11544+
got, err := vuls2.PostConvert(tt.args.scanned, tt.args.detected, tt.args.fsToOriginalCPE, nil, tt.args.verified)
1136011545
if (err != nil) != tt.wantErr {
1136111546
t.Errorf("postConvert() error = %v, wantErr %v", err, tt.wantErr)
1136211547
return

0 commit comments

Comments
 (0)