Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 36 additions & 38 deletions detector/vuls2/vuls2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1104,37 +1109,39 @@ 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) {
Comment thread
MaineK00n marked this conversation as resolved.
if !childSatisfied {
return
}
satisfied = true
exact = append(exact, childExact...)
vp = append(vp, childVP...)
}

for _, child := range c.Criterias {
sat, ex, v, err := walk(child)
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
Expand All @@ -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)
Expand Down
238 changes: 232 additions & 6 deletions detector/vuls2/vuls2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{},
},
{
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand Down
Loading