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
26 changes: 23 additions & 3 deletions vulnfeeds/conversion/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,21 @@ func IsGitCommitSHA(s string) bool {
return true
}

// isHexCommitPrefix checks whether a string is a 7-to-64 character hexadecimal Git commit hash/prefix.
func isHexCommitPrefix(s string) bool {
s = strings.TrimSpace(s)
if len(s) < 7 || len(s) > 64 {
return false
}
for _, c := range s {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') {
return false
}
}

return true
}

// IsDirectGitRange determines if a range is already composed of Git commit hashes rather than tag/version names.
func IsDirectGitRange(vr models.RangeWithMetadata) bool {
if vr.Range == nil {
Expand All @@ -759,25 +774,30 @@ func IsDirectGitRange(vr models.RangeWithMetadata) bool {
return false
}

checkSHA := IsGitCommitSHA
if vr.Range.GetType() == osvschema.Range_GIT {
checkSHA = isHexCommitPrefix
}

hasCommit := false
for _, e := range events {
intro := e.GetIntroduced()
if intro != "" && intro != "0" {
if !IsGitCommitSHA(intro) {
if !checkSHA(intro) {
return false
}
hasCommit = true
}
fixed := e.GetFixed()
if fixed != "" {
if !IsGitCommitSHA(fixed) {
if !checkSHA(fixed) {
return false
}
hasCommit = true
}
lastAffected := e.GetLastAffected()
if lastAffected != "" {
if !IsGitCommitSHA(lastAffected) {
if !checkSHA(lastAffected) {
return false
}
hasCommit = true
Expand Down
29 changes: 27 additions & 2 deletions vulnfeeds/conversion/cve5/default_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,38 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln
metrics.AddNotef("Extracted versions from description: %v", textRanges)
}
if len(textRanges) != 0 {
processRanges(textRanges)
if processRanges(textRanges) {
gotVersions = true
}
}
}

var commits []models.AffectedCommit
if !gotVersions && httpClient != nil {
refs := slices.Clone(cve.Containers.CNA.References)
for _, adp := range cve.Containers.ADP {
refs = append(refs, adp.References...)
}
refs = c.DeduplicateRefs(refs)
extractedCommits, err := c.ExtractCommitsFromRefs(refs, httpClient, cache)
if err != nil {
metrics.AddNotef("Failed to extract commits from refs: %v", err)
}
if len(extractedCommits) > 0 {
commits = extractedCommits
metrics.AddNotef("Extracted commits from refs: %v", commits)
for _, commit := range commits {
successfulRepos[commit.Repo] = true
}
metrics.ResolvedRangesCount += len(commits)
metrics.SetOutcome(models.Successful)
metrics.AddSource(models.VersionSourceRefs)
}
}

keys := slices.Collect(maps.Keys(successfulRepos))
groupedRanges := c.GroupRanges(resolvedRanges)
affected := c.MergeRangesAndCreateAffected(groupedRanges, nil, keys, metrics)
affected := c.MergeRangesAndCreateAffected(groupedRanges, commits, keys, metrics)
v.Affected = append(v.Affected, affected...)

addUnresolvedRanges(unresolvedRanges)
Expand Down
6 changes: 3 additions & 3 deletions vulnfeeds/conversion/cve5/strategies/inverse_range.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func FindInverseAffectedRanges(cveAff models.Affected, metrics *models.Conversio
continue
}

if versionValue == "0" || ToVersionRangeType(vers.VersionType) != VersionRangeTypeSemver {
if versionValue == "0" || (ToVersionRangeType(vers.VersionType) != VersionRangeTypeSemver && (cveAff.DefaultStatus != "affected" || vers.LessThan != "*")) {
continue
}
fixed = append(fixed, versionValue)
Expand All @@ -76,8 +76,8 @@ func FindInverseAffectedRanges(cveAff models.Affected, metrics *models.Conversio
slices.SortFunc(introduced, CompareSemverLike)
slices.SortFunc(fixed, CompareSemverLike)

// If the first fixed version is earlier than the first introduced, assume introduction from "0".
if len(fixed) > 0 && len(introduced) > 0 && CompareSemverLike(fixed[0], introduced[0]) < 0 {
// If the first fixed version is earlier than the first introduced (or no introduced was listed while defaultStatus is affected), assume introduction from "0".
if len(fixed) > 0 && ((len(introduced) == 0 && cveAff.DefaultStatus == "affected") || (len(introduced) > 0 && CompareSemverLike(fixed[0], introduced[0]) < 0)) {
introduced = append([]string{"0"}, introduced...)
}

Expand Down
3 changes: 3 additions & 0 deletions vulnfeeds/conversion/cve5/strategies/presets.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ func Default() []VersionStrategy {
&CPEVersionStringStrategy{},
&VersionTextExtractionStrategy{},
&StandaloneSingleVersionStrategy{},
&InverseAffectedRangesStrategy{},
}
}

Expand Down Expand Up @@ -44,8 +45,10 @@ func MITRE() []VersionStrategy {
&StringRangeExpressionStrategy{},
&GitCommitStrategy{},
&CPEVersionStringStrategy{},
&VersionTextExtractionStrategy{},
&ZeroIntroducedSingleVersionStrategy{},
&StandaloneSingleVersionStrategy{},
&InverseAffectedRangesStrategy{},
}
}

Expand Down
17 changes: 14 additions & 3 deletions vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -1944,7 +1944,18 @@
"aliases": [
"GHSA-j7xp-4mg9-x28r"
],
"database_specific": {},
"database_specific": {
"unresolved_ranges": [
{
"extracted_events": [
{
"fixed": "2.0.0-next.193"
}
],
"source": "DESCRIPTION"
}
]
},
"details": "LobeChat is an open source chat application platform. Prior to version 2.0.0-next.193, `knowledgeBase.removeFilesFromKnowledgeBase` tRPC ep allows authenticated users to delete files from any knowledge base without verifying ownership. `userId` filter in the database query is commented out, so it's enabling attackers to delete other users' KB files if they know the knowledge base ID and file ID. While the vulnerability is confirmed, practical exploitation requires knowing target's KB ID and target's file ID. These IDs are random and not easily enumerable. However, IDs may leak through shared links, logs, referrer headers and so on. Missing authorization check is a critical security flaw regardless. Users should upgrade to version 2.0.0-next.193 to receive a patch.",
"id": "CVE-2026-23522",
"modified": "2026-01-26T15:05:39.840Z",
Expand Down Expand Up @@ -3588,7 +3599,7 @@
"introduced": "0.68"
},
{
"fixed": "0.80"
"last_affected": "0.80"
},
{
"fixed": "0.81"
Expand All @@ -3603,7 +3614,7 @@
"fixed": "2.15.0.1"
},
{
"fixed": "1.14.6"
"last_affected": "1.14.6"
}
],
"source": "DESCRIPTION"
Expand Down
131 changes: 104 additions & 27 deletions vulnfeeds/conversion/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -648,36 +648,77 @@ func processExtractedVersion(version string) string {
return version
}

func ExtractVersionsFromText(validVersions []string, text string, metrics *models.ConversionMetrics, source models.VersionSource) []models.RangeWithMetadata {
// Match:
// - 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 {
metrics.AddNotef("Failed to parse versions from text")
return nil
var (
textRangePattern = regexp.MustCompile(`(?i)(?:([vV]?[0-9][\w.+\-]*)\s+)?(through|before|prior\s+to|earlier\s+than|up\s+to(?:\s+and\s+including)?|fixed\s+in|patched\s+in|resolved\s+in|versions?\s*<=?)\s+(?:versions?\s+)?[vV]?([0-9][\w.+\-]*)`)
textTrailingInclusivePattern = regexp.MustCompile(`(?i)\b[vV]?([0-9]+(?:\.[0-9a-zA-Z.+\-]+)+)\s+and\s+(?:earlier|before|below|prior|older)\b`)
textBeforeCommitPattern = regexp.MustCompile(`(?i)\b(?:before|prior\s+to|fixed\s+in)\s+commits?\s+([0-9a-f]{7,40})\b`)
textThroughCommitPattern = regexp.MustCompile(`(?i)\b(?:through|up\s+to|at|in)\s+commits?\s+([0-9a-f]{7,40})\b`)
textVulnerableCommitPattern = regexp.MustCompile(`(?i)\b([0-9a-f]{7,40})\s+(?:is\s+vulnerable|l?contains\s+a\b|has\s+a\b)`)
)

func isInclusiveKeyword(kw string) bool {
kw = strings.ToLower(strings.TrimSpace(kw))
return kw == "through" || strings.HasPrefix(kw, "up to") || strings.HasSuffix(kw, "<=")
}

func isLikelyGitSHA(s string) bool {
if !isHexCommitPrefix(s) {
return false
}

versions := make([]models.RangeWithMetadata, 0, len(matches))
return strings.ContainsAny(s, "0123456789") && strings.ContainsAny(strings.ToLower(s), "abcdef")
}

func ExtractVersionsFromText(validVersions []string, text string, metrics *models.ConversionMetrics, source models.VersionSource) []models.RangeWithMetadata {
var versions []models.RangeWithMetadata

// 1. Check for explicit git commit references in prose (e.g., "before commit 6187a4e").
for _, m := range textBeforeCommitPattern.FindAllStringSubmatch(text, -1) {
sha := strings.Trim(m[1], ".")
if isLikelyGitSHA(sha) {
versions = append(versions, models.RangeWithMetadata{
Range: BuildGitVersionRange("0", "", sha, ""),
Metadata: models.Metadata{Source: source},
})
}
}
for _, m := range textThroughCommitPattern.FindAllStringSubmatch(text, -1) {
sha := strings.Trim(m[1], ".")
if isLikelyGitSHA(sha) {
versions = append(versions, models.RangeWithMetadata{
Range: BuildGitVersionRange("0", sha, "", ""),
Metadata: models.Metadata{Source: source},
})
}
}

// 2. Match leading/infix relative version phrases:
// - x.x.x before/through x.x.x
// - before/through/prior to/earlier than/up to/fixed in x.x.x
matches := textRangePattern.FindAllStringSubmatch(text, -1)
for _, match := range matches {
// Trim periods that are part of sentences.
introduced := processExtractedVersion(match[1])
fixed := processExtractedVersion(match[3])
introduced := processExtractedVersion(strings.TrimPrefix(strings.TrimPrefix(match[1], "v"), "V"))
target := processExtractedVersion(match[3])
if target == "" || isLikelyGitSHA(target) {
continue
}

fixed := ""
lastaffected := ""
if match[2] == "through" && validVersions != nil {
// "Through" implies inclusive range, so the fixed version is the one that comes after.
var err error
fixed, err = nextVersion(validVersions, fixed)
if err != nil {
metrics.AddNotef("Failed to determine next version after %s: %s", fixed, err.Error())
// if that inference failed, we know this version was definitely still vulnerable.
lastaffected = cleanVersion(match[3])
metrics.AddNotef("Using %s as last_affected version instead", cleanVersion(match[3]))
if isInclusiveKeyword(match[2]) {
if validVersions != nil {
var err error
fixed, err = nextVersion(validVersions, target)
if err != nil {
metrics.AddNotef("Failed to determine next version after %s: %s", target, err.Error())
lastaffected = cleanVersion(target)
metrics.AddNotef("Using %s as last_affected version instead", lastaffected)
}
} else {
lastaffected = cleanVersion(target)
}
} else {
fixed = cleanVersion(target)
}

if introduced == "" && fixed == "" && lastaffected == "" {
Expand All @@ -694,7 +735,6 @@ func ExtractVersionsFromText(validVersions []string, text string, metrics *model
if lastaffected != "" && !HasVersion(validVersions, lastaffected) {
metrics.AddNotef("Extracted last_affected version %s is not a valid version", lastaffected)
}
// Favour fixed over last_affected for schema compliance.
if fixed != "" && lastaffected != "" {
lastaffected = ""
}
Expand All @@ -705,8 +745,45 @@ func ExtractVersionsFromText(validVersions []string, text string, metrics *model
Metadata: models.Metadata{
Source: source,
},
},
)
})
}

// 3. Match trailing inclusive bounds (e.g., "25.11 and before", "3.0.1 and earlier").
for _, match := range textTrailingInclusivePattern.FindAllStringSubmatch(text, -1) {
target := cleanVersion(processExtractedVersion(match[1]))
if target == "" {
continue
}
fixed := ""
lastaffected := target
if validVersions != nil {
if nextVer, err := nextVersion(validVersions, target); err == nil {
fixed = nextVer
lastaffected = ""
}
}
versions = append(versions, models.RangeWithMetadata{
Range: BuildVersionRange("0", lastaffected, fixed),
Metadata: models.Metadata{Source: source},
})
}

// 4. Fallback: commit hash immediately preceding "is vulnerable" / "contains a" (e.g. "stomper 5e2741e is vulnerable").
if len(versions) == 0 {
for _, m := range textVulnerableCommitPattern.FindAllStringSubmatch(text, -1) {
sha := strings.Trim(m[1], ".")
if isLikelyGitSHA(sha) {
versions = append(versions, models.RangeWithMetadata{
Range: BuildGitVersionRange("0", sha, "", ""),
Metadata: models.Metadata{Source: source},
})
}
}
}

if len(versions) == 0 {
metrics.AddNotef("Failed to parse versions from text")
return nil
}

return versions
Expand Down
Loading
Loading