Skip to content
Open
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
85 changes: 76 additions & 9 deletions vulnfeeds/cves/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -608,34 +608,59 @@ func processExtractedVersion(version string) string {
}

func ExtractVersionsFromText(validVersions []string, text string) ([]models.AffectedVersion, []string) {
// Match:
// Match patterns where version comes AFTER keyword:
// - x.x.x before x.x.x
// - x.x.x through x.x.x
// - through x.x.x
// - before x.x.x
pattern := regexp.MustCompile(`(?i)([\w.+\-]+)?\s+(through|before)\s+(?:version\s+)?([\w.+\-]+)`)
matches := pattern.FindAllStringSubmatch(text, -1)
if matches == nil {
// - x.x.x up to and including x.x.x
// - prior to x.x.x
// - below x.x.x
prefixPattern := regexp.MustCompile(`(?i)([\w.+\-]+)?\s+(through|before|up to and including|prior to|below)\s+(?:version\s+)?([\w.+\-]+)`)

// Match patterns where version comes BEFORE keyword (trailing patterns):
// - x.x.x and earlier
// - x.x.x or older
// - x.x.x and below
trailingPattern := regexp.MustCompile(`(?i)(?:version\s+)?([\w.+\-]+)\s+(and earlier|or older|and below)`)

prefixMatches := prefixPattern.FindAllStringSubmatch(text, -1)
trailingMatches := trailingPattern.FindAllStringSubmatch(text, -1)

if prefixMatches == nil && trailingMatches == nil {
return nil, []string{"Failed to parse versions from text"}
}

var notes []string
versions := make([]models.AffectedVersion, 0, len(matches))
versions := make([]models.AffectedVersion, 0, len(prefixMatches)+len(trailingMatches))

// Keywords that imply the version is the last affected (inclusive), not fixed
inclusiveKeywords := map[string]bool{
"through": true,
"up to and including": true,
}

for _, match := range matches {
// Process prefix pattern matches (version comes after keyword)
for _, match := range prefixMatches {
// Trim periods that are part of sentences.
introduced := processExtractedVersion(match[1])
fixed := processExtractedVersion(match[3])
lastaffected := ""
if match[2] == "through" {
// "Through" implies inclusive range, so the fixed version is the one that comes after.
keyword := strings.ToLower(match[2])

if inclusiveKeywords[keyword] {
// These keywords imply inclusive range, so the version is lastAffected.
// Try to find the next version as "fixed", otherwise use lastAffected.
var err error
fixed, err = nextVersion(validVersions, fixed)
nextFixed, err := nextVersion(validVersions, fixed)
if err != nil {
notes = append(notes, err.Error())
// if that inference failed, we know this version was definitely still vulnerable.
lastaffected = cleanVersion(match[3])
fixed = ""
notes = append(notes, fmt.Sprintf("Using %s as last_affected version instead", cleanVersion(match[3])))
} else {
fixed = nextFixed
}
}

Expand Down Expand Up @@ -665,6 +690,48 @@ func ExtractVersionsFromText(validVersions []string, text string) ([]models.Affe
})
}

// Process trailing pattern matches (version comes before keyword like "x.x.x and earlier")
for _, match := range trailingMatches {
// For trailing patterns: match[1] is the version, match[2] is the keyword
versionStr := processExtractedVersion(match[1])
lastaffected := ""
fixed := ""

if versionStr == "" {
notes = append(notes, "Failed to match version from trailing pattern")
continue
}

// Try to find the next version as "fixed", otherwise use lastAffected
nextFixed, err := nextVersion(validVersions, versionStr)
if err != nil {
notes = append(notes, err.Error())
// if that inference failed, we know this version was definitely still vulnerable.
lastaffected = cleanVersion(match[1])
notes = append(notes, fmt.Sprintf("Using %s as last_affected version instead", cleanVersion(match[1])))
} else {
fixed = nextFixed
}

if fixed == "" && lastaffected == "" {
notes = append(notes, "Failed to match version range from text")
continue
}

if fixed != "" && !HasVersion(validVersions, fixed) {
notes = append(notes, fmt.Sprintf("Extracted fixed version %s is not a valid version", fixed))
}
if lastaffected != "" && !HasVersion(validVersions, lastaffected) {
notes = append(notes, fmt.Sprintf("Extracted last_affected version %s is not a valid version", lastaffected))
}

versions = append(versions, models.AffectedVersion{
Introduced: "",
Fixed: fixed,
LastAffected: lastaffected,
})
}

return versions, notes
}

Expand Down
97 changes: 97 additions & 0 deletions vulnfeeds/cves/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1612,3 +1612,100 @@ func TestBuildVersionRange(t *testing.T) {
})
}
}

func TestExtractVersionsFromText(t *testing.T) {
tests := []struct {
name string
description string
validVersions []string
expectedVersions []models.AffectedVersion
}{
{
name: "up to and including - CVE-2025-11411",
description: "NLnet Labs Unbound up to and including version 1.24.1 is vulnerable to possible domain hijack attacks.",
validVersions: []string{
"1.24.0", "1.24.1", "1.24.2",
},
expectedVersions: []models.AffectedVersion{
{
// "up to and including" implies inclusive upper bound.
// Since validVersions has "1.24.2" as next, logic prefers Fixed="1.24.2" over LastAffected="1.24.1".
Fixed: "1.24.2",
},
},
},
{
name: "prior to - CVE-2022-3306",
description: "The vulnerability affected versions prior to 106.0.5249.62 and was reported on April 27, 2022.",
validVersions: []string{},
expectedVersions: []models.AffectedVersion{
{
Fixed: "106.0.5249.62",
},
},
},
{
name: "below - CVE-2024-29945",
description: "In Splunk Enterprise versions below 9.2.1, 9.1.4, and 9.0.9, authentication tokens can be exposed.",
validVersions: []string{},
expectedVersions: []models.AffectedVersion{
{
Fixed: "9.2.1",
},
},
},
{
name: "and earlier - CVE-2025-27199",
description: "Adobe Animate versions 24.0.7 and earlier are affected by a Heap-based Buffer Overflow vulnerability.",
validVersions: []string{
"23.0.0", "24.0.0", "24.0.7", "24.0.8",
},
expectedVersions: []models.AffectedVersion{
{
Fixed: "24.0.8",
},
},
},
{
name: "and earlier - CVE-2025-27199 (No next version)",
description: "Adobe Animate versions 24.0.7 and earlier are affected by a Heap-based Buffer Overflow vulnerability.",
validVersions: []string{
"24.0.7",
},
expectedVersions: []models.AffectedVersion{
{
LastAffected: "24.0.7",
},
},
},
{
name: "or older - CVE-2025-0725",
description: "A vulnerability affects systems utilizing zlib version 1.2.0.3 or older.",
validVersions: []string{},
expectedVersions: []models.AffectedVersion{
{
LastAffected: "1.2.0.3",
},
},
},
{
name: "and below - CVE-2025-54591",
description: "FreshRSS, in versions 1.26.3 and below, exposes information about feeds.",
validVersions: []string{},
expectedVersions: []models.AffectedVersion{
{
LastAffected: "1.26.3",
},
},
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotVersions, _ := ExtractVersionsFromText(tc.validVersions, tc.description)
if diff := cmp.Diff(tc.expectedVersions, gotVersions); diff != "" {
t.Errorf("ExtractVersionsFromText() mismatch (-want +got):\n%s", diff)
}
})
}
}