diff --git a/detector/vuls2/vuls2.go b/detector/vuls2/vuls2.go index b20f761280..0af1478b5f 100644 --- a/detector/vuls2/vuls2.go +++ b/detector/vuls2/vuls2.go @@ -1038,19 +1038,24 @@ func pruneCPECriteria(c criteriaTypes.FilteredCriteria) criteriaTypes.FilteredCr // // Anything a CPE-only scan cannot evaluate — vulnerable=false // environment/hardware guards and other criterion types — is removed by a -// prune pass first, so it never vetoes an AND (the historical -// go-cve-dictionary behaviour existing users rely on). +// prune pass first, so it can neither be matched nor veto the result +// (reproducing the historical go-cve-dictionary behaviour existing users +// rely on). // // - Accepts.CPE.Exact (version-confirmed match) → exact tier // - Accepts.CPE.VersionUnconfirmed (accepted, but no version confirmation) // → vendor:product tier // -// AND nodes require every relevant child to be satisfied and demote their -// exact contributions when any leg holds only at vendor:product level (the -// conjunction as a whole is then only vendor:product-confirmed); OR nodes take -// any satisfied child as-is. A CPE source that carries no version data at all -// (JVN) is reported at vendor:product regardless of the projected tier — that -// is a source-semantics decision, kept here in vuls0. +// AND and OR nodes are both folded as OR: a scanned CPE matching any single +// leg satisfies the node. go-cve-dictionary treats every vulnerable=true CPE +// in an applicability node as independently matchable, ignoring the AND/OR +// operator, so a co-required product NVD happens to mark vulnerable=true +// (e.g. the Xen hypervisor conjoined with the vulnerable kernel in +// CVE-2021-28039) never vetoes the kernel leg. Existing users relied on this +// flattening under go-cve-dictionary; keeping it here trades AND precision for +// that compatibility, scoped to CPE detection only. A CPE source that carries +// no version data at all (JVN) is reported at vendor:product regardless of the +// projected tier — that is a source-semantics decision, kept here in vuls0. // // For a suppressed source (VulnCheck / JVN) a matched scanned CPE is dropped // when its part:vendor:product is in verifiedProducts — the products a verified @@ -1104,29 +1109,31 @@ func walkCPECriteria(sourceID sourceTypes.SourceID, ca criteriaTypes.FilteredCri } // Pass 2: walk returns (satisfied, exact, vp) — whether the subtree - // holds, and the supporting scanned CPEs per confidence tier. + // holds, and the supporting scanned CPEs per confidence tier. AND is + // folded identically to OR (see the flatten note above): any single + // satisfied leg carries the node and contributes its matches at its own + // tier, so a co-required product the scan lacks never vetoes the result. var walk func(c criteriaTypes.FilteredCriteria) (bool, []string, []string, error) walk = func(c criteriaTypes.FilteredCriteria) (bool, []string, []string, error) { - satisfied := c.Operator != criteriaTypes.CriteriaOperatorTypeOR + // Both operators are folded as OR (see the flatten note above), but + // validate anyway so an unsupported operator fails fast rather than + // silently taking the OR path — matching walkPkgCriteria. + switch c.Operator { + case criteriaTypes.CriteriaOperatorTypeAND, criteriaTypes.CriteriaOperatorTypeOR: + default: + return false, nil, nil, xerrors.Errorf("unexpected operator: %s", c.Operator) + } + + var satisfied bool var exact, vp []string - foldChild := func(childSatisfied bool, childExact, childVP []string) { - switch c.Operator { - case criteriaTypes.CriteriaOperatorTypeOR: - if childSatisfied { - satisfied = true - exact = append(exact, childExact...) - vp = append(vp, childVP...) - } - case criteriaTypes.CriteriaOperatorTypeAND: - if !childSatisfied { - satisfied = false - return - } - exact = append(exact, childExact...) - vp = append(vp, childVP...) - default: // unreachable: criteria operators are only AND / OR + fold := func(childSatisfied bool, childExact, childVP []string) { + if !childSatisfied { + return } + satisfied = true + exact = append(exact, childExact...) + vp = append(vp, childVP...) } for _, child := range c.Criterias { @@ -1134,7 +1141,7 @@ func walkCPECriteria(sourceID sourceTypes.SourceID, ca criteriaTypes.FilteredCri if err != nil { return false, nil, nil, xerrors.Errorf("Failed to walk criteria. err: %w", err) } - foldChild(sat, ex, v) + fold(sat, ex, v) } for _, cn := range c.Criterions { var exactMatched, vpMatched []string @@ -1158,19 +1165,10 @@ func walkCPECriteria(sourceID sourceTypes.SourceID, ca criteriaTypes.FilteredCri } vpMatched = append(vpMatched, fs) } - foldChild(len(exactMatched) > 0 || len(vpMatched) > 0, exactMatched, vpMatched) + fold(len(exactMatched) > 0 || len(vpMatched) > 0, exactMatched, vpMatched) } - if !satisfied { - return false, nil, nil, nil - } - if c.Operator != criteriaTypes.CriteriaOperatorTypeOR && len(vp) > 0 { - // A conjunction with a leg confirmed only at vendor:product - // level is only vendor:product-confirmed as a whole. - vp = append(vp, exact...) - exact = nil - } - return true, exact, vp, nil + return satisfied, exact, vp, nil } satisfied, exact, vp, err := walk(ca) diff --git a/detector/vuls2/vuls2_test.go b/detector/vuls2/vuls2_test.go index 5ebb054e03..29c8a0317f 100644 --- a/detector/vuls2/vuls2_test.go +++ b/detector/vuls2/vuls2_test.go @@ -10604,9 +10604,12 @@ func Test_postConvert(t *testing.T) { }, }, { - // Projection honours AND structure: a CVE whose configuration - // requires product A AND product B is not reported when only A was - // scanned (A's leg accepted, but B's leg did not). + // AND folds as OR in vuls0's CPE walk (go-cve-dictionary flatten + // compatibility): a CVE whose configuration requires producta AND + // productb is still reported when only producta was scanned. + // producta's leg accepts and carries the node at vendor:product; + // the co-required productb leg has no scanned CPE and does not + // accept, but no longer vetoes the result. name: "cpe version-unconfirmed accept, unsatisfied AND", args: args{ scanned: scanTypes.ScanResult{ @@ -10686,6 +10689,222 @@ func Test_postConvert(t *testing.T) { }, }, }, + want: models.VulnInfos{ + "CVE-2025-0004": { + CveID: "CVE-2025-0004", + Confidences: models.Confidences{models.NvdVendorProductMatch}, + CpeURIs: []string{"cpe:/a:vendor:producta:9.9.9"}, + CveContents: models.CveContents{ + models.Nvd: []models.CveContent{ + { + Type: models.Nvd, + CveID: "CVE-2025-0004", + Title: "title", + Summary: "description", + SourceLink: "https://nvd.nist.gov/vuln/detail/CVE-2025-0004", + Published: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + LastModified: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC), + Optional: map[string]string{ + "vuls2-sources": "[{\"root_id\":\"CVE-2025-0004\",\"source_id\":\"nvd-api-cve\",\"segment\":{\"ecosystem\":\"cpe\"}}]", + }, + }, + }, + }, + }, + }, + }, + { + // Companion to "unsatisfied AND": here the non-contributing AND + // leg is a vulnerable=false hardware guard, which pruneCPECriteria + // removes before the walk. The satisfied producta leg is reported + // at vendor:product. Unlike the vulnerable=true case above (which + // detects only because AND now folds as OR), this one detected + // even before the flatten — the guard never survived pruning. + name: "cpe version-unconfirmed accept, AND with vulnerable=false guard", + args: args{ + scanned: scanTypes.ScanResult{ + CPE: []string{ + "cpe:2.3:a:vendor:producta:9.9.9:*:*:*:*:*:*:*", + }, + }, + fsToOriginalCPE: map[string][]string{ + "cpe:2.3:a:vendor:producta:9.9.9:*:*:*:*:*:*:*": {"cpe:/a:vendor:producta:9.9.9"}, + }, + detected: detectTypes.DetectResult{ + Detected: []detectTypes.VulnerabilityData{ + { + ID: "CVE-2025-0009", + Vulnerabilities: []dbTypes.VulnerabilityDataVulnerability{ + { + ID: "CVE-2025-0009", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{ + sourceTypes.NVDAPICVE: { + dataTypes.RootID("CVE-2025-0009"): { + { + Content: vulnerabilityContentTypes.Content{ + ID: "CVE-2025-0009", + Title: "title", + Description: "description", + Published: new(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + Segments: []segmentTypes.Segment{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + }, + }, + }, + }, + }, + }, + }, + }, + Detections: []detectTypes.VulnerabilityDataDetection{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + Contents: map[sourceTypes.SourceID][]conditionTypes.FilteredCondition{ + sourceTypes.NVDAPICVE: { + { + Criteria: criteriaTypes.FilteredCriteria{ + Operator: criteriaTypes.CriteriaOperatorTypeAND, + Criterions: []criterionTypes.FilteredCriterion{ + { + Criterion: criterionTypes.Criterion{ + Type: criterionTypes.CriterionTypeCPE, + CPE: new(ccTypes.Criterion{ + Vulnerable: true, + CPE: ccTypes.CPE("cpe:2.3:a:vendor:producta:-:*:*:*:*:*:*:*"), + }), + }, + Accepts: criterionTypes.AcceptQueries{ + CPE: criterionTypes.CPEAccepts{VersionUnconfirmed: []int{0}}, + }, + }, + { + Criterion: criterionTypes.Criterion{ + Type: criterionTypes.CriterionTypeCPE, + CPE: new(ccTypes.Criterion{ + Vulnerable: false, + CPE: ccTypes.CPE("cpe:2.3:h:vendor:hardware:-:*:*:*:*:*:*:*"), + }), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: models.VulnInfos{ + "CVE-2025-0009": { + CveID: "CVE-2025-0009", + Confidences: models.Confidences{models.NvdVendorProductMatch}, + CpeURIs: []string{"cpe:/a:vendor:producta:9.9.9"}, + CveContents: models.CveContents{ + models.Nvd: []models.CveContent{ + { + Type: models.Nvd, + CveID: "CVE-2025-0009", + Title: "title", + Summary: "description", + SourceLink: "https://nvd.nist.gov/vuln/detail/CVE-2025-0009", + Published: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + LastModified: time.Date(1000, time.January, 1, 0, 0, 0, 0, time.UTC), + Optional: map[string]string{ + "vuls2-sources": "[{\"root_id\":\"CVE-2025-0009\",\"source_id\":\"nvd-api-cve\",\"segment\":{\"ecosystem\":\"cpe\"}}]", + }, + }, + }, + }, + }, + }, + }, + { + // AND where neither vulnerable=true leg accepts (both reached via the + // part:vendor:product index but their version criteria did not accept — + // concrete mismatch or out of range upstream): the flattened OR fold + // finds no satisfied leg, so nothing is reported. Guards the AND->OR + // flatten against spuriously detecting when no leg actually matched. + name: "cpe no accepted criterion in AND, report nothing", + args: args{ + scanned: scanTypes.ScanResult{ + CPE: []string{ + "cpe:2.3:a:vendor:producta:9.9.9:*:*:*:*:*:*:*", + }, + }, + fsToOriginalCPE: map[string][]string{ + "cpe:2.3:a:vendor:producta:9.9.9:*:*:*:*:*:*:*": {"cpe:/a:vendor:producta:9.9.9"}, + }, + detected: detectTypes.DetectResult{ + Detected: []detectTypes.VulnerabilityData{ + { + ID: "CVE-2025-0010", + Vulnerabilities: []dbTypes.VulnerabilityDataVulnerability{ + { + ID: "CVE-2025-0010", + Contents: map[sourceTypes.SourceID]map[dataTypes.RootID][]vulnerabilityTypes.Vulnerability{ + sourceTypes.NVDAPICVE: { + dataTypes.RootID("CVE-2025-0010"): { + { + Content: vulnerabilityContentTypes.Content{ + ID: "CVE-2025-0010", + Title: "title", + Description: "description", + Published: new(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)), + }, + Segments: []segmentTypes.Segment{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + }, + }, + }, + }, + }, + }, + }, + }, + Detections: []detectTypes.VulnerabilityDataDetection{ + { + Ecosystem: ecosystemTypes.EcosystemTypeCPE, + Contents: map[sourceTypes.SourceID][]conditionTypes.FilteredCondition{ + sourceTypes.NVDAPICVE: { + { + Criteria: criteriaTypes.FilteredCriteria{ + Operator: criteriaTypes.CriteriaOperatorTypeAND, + Criterions: []criterionTypes.FilteredCriterion{ + { + Criterion: criterionTypes.Criterion{ + Type: criterionTypes.CriterionTypeCPE, + CPE: new(ccTypes.Criterion{ + Vulnerable: true, + CPE: ccTypes.CPE("cpe:2.3:a:vendor:producta:-:*:*:*:*:*:*:*"), + }), + }, + }, + { + Criterion: criterionTypes.Criterion{ + Type: criterionTypes.CriterionTypeCPE, + CPE: new(ccTypes.Criterion{ + Vulnerable: true, + CPE: ccTypes.CPE("cpe:2.3:a:vendor:productb:-:*:*:*:*:*:*:*"), + }), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, want: models.VulnInfos{}, }, { @@ -12625,7 +12844,9 @@ func Test_walkCPECriteria(t *testing.T) { wantExact: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "cpe:2.3:o:vendor:os:2.0:*:*:*:*:*:*:*"}, }, { - name: "AND with a vendor:product leg demotes the whole conjunction", + // AND folds as OR (go-cve-dictionary flatten compatibility): each + // leg keeps its own tier instead of the conjunction being demoted. + name: "AND folds as OR: each leg keeps its own tier", args: args{ criteria: criteriaTypes.FilteredCriteria{ Operator: criteriaTypes.CriteriaOperatorTypeAND, @@ -12636,10 +12857,14 @@ func Test_walkCPECriteria(t *testing.T) { }, scanned: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*", "cpe:2.3:o:vendor:os:2.0:*:*:*:*:*:*:*"}, }, - wantVP: []string{"cpe:2.3:o:vendor:os:2.0:*:*:*:*:*:*:*", "cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, + wantExact: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, + wantVP: []string{"cpe:2.3:o:vendor:os:2.0:*:*:*:*:*:*:*"}, }, { - name: "AND with an unsatisfied leg -> nothing", + // AND folds as OR: an unsatisfied co-required leg (e.g. the Xen + // hypervisor the scan lacks in CVE-2021-28039) no longer vetoes the + // satisfied leg — the matched product is still reported. + name: "AND with an unsatisfied leg still reports the satisfied leg", args: args{ criteria: criteriaTypes.FilteredCriteria{ Operator: criteriaTypes.CriteriaOperatorTypeAND, @@ -12650,6 +12875,7 @@ func Test_walkCPECriteria(t *testing.T) { }, scanned: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, }, + wantExact: []string{"cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"}, }, { name: "same scanned CPE in exact and vendor:product -> exact wins",