diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 865c9f46fe9..9e5f12c3b46 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -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 { @@ -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 diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index c038e995469..3fb35f6f3d0 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -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) diff --git a/vulnfeeds/conversion/cve5/strategies/inverse_range.go b/vulnfeeds/conversion/cve5/strategies/inverse_range.go index 05cb174cb17..a67e5eb60c8 100644 --- a/vulnfeeds/conversion/cve5/strategies/inverse_range.go +++ b/vulnfeeds/conversion/cve5/strategies/inverse_range.go @@ -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) @@ -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...) } diff --git a/vulnfeeds/conversion/cve5/strategies/presets.go b/vulnfeeds/conversion/cve5/strategies/presets.go index 00f6c784c51..595a8df103e 100644 --- a/vulnfeeds/conversion/cve5/strategies/presets.go +++ b/vulnfeeds/conversion/cve5/strategies/presets.go @@ -15,6 +15,7 @@ func Default() []VersionStrategy { &CPEVersionStringStrategy{}, &VersionTextExtractionStrategy{}, &StandaloneSingleVersionStrategy{}, + &InverseAffectedRangesStrategy{}, } } @@ -44,8 +45,10 @@ func MITRE() []VersionStrategy { &StringRangeExpressionStrategy{}, &GitCommitStrategy{}, &CPEVersionStringStrategy{}, + &VersionTextExtractionStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, &StandaloneSingleVersionStrategy{}, + &InverseAffectedRangesStrategy{}, } } diff --git a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap index a4cbbb87f32..9ba8e6681ed 100755 --- a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap @@ -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", @@ -3588,7 +3599,7 @@ "introduced": "0.68" }, { - "fixed": "0.80" + "last_affected": "0.80" }, { "fixed": "0.81" @@ -3603,7 +3614,7 @@ "fixed": "2.15.0.1" }, { - "fixed": "1.14.6" + "last_affected": "1.14.6" } ], "source": "DESCRIPTION" diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 3dcb099a7c1..a3e3fa91fcf 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -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 == "" { @@ -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 = "" } @@ -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 diff --git a/vulnfeeds/conversion/versions_test.go b/vulnfeeds/conversion/versions_test.go index a68291dc772..6b00d594f43 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -1760,3 +1760,91 @@ func TestProcessRanges_DirectGitRange(t *testing.T) { t.Errorf("expected range type to be GIT, got %s", resolved[0].Range.GetType().String()) } } + +func TestExtractVersionsFromText(t *testing.T) { + tests := []struct { + name string + text string + wantIntro string + wantFixed string + wantLastAff string + wantRangeTyp string + }{ + { + name: "trailing and before (CVE-2026-26377)", + text: "Cross Site Scripting vulnerability in Koha 25.11 and before allows a remote attacker to execute arbitrary code.", + wantIntro: "0", + wantLastAff: "25.11", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "versions up to (CVE-2026-26379)", + text: "Koha versions up to 25.11 contain a Server-Side Request Forgery (SSRF) vulnerability.", + wantIntro: "0", + wantLastAff: "25.11", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "prior to version (CVE-2026-26399)", + text: "A stack-use-after-return issue exists in the Arduino_Core_STM32 library prior to version 1.7.0.", + wantIntro: "0", + wantFixed: "1.7.0", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "trailing and earlier (CVE-2026-26483)", + text: "Mettle SendPortal 3.0.1 and earlier contains a stored cross-site scripting (XSS) vulnerability.", + wantIntro: "0", + wantLastAff: "3.0.1", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "before commit short SHA (CVE-2026-26514)", + text: "An Argument Injection vulnerability exists in bird-lg-go before commit 6187a4e.", + wantIntro: "0", + wantFixed: "6187a4e", + wantRangeTyp: "GIT", + }, + { + name: "vulnerable commit short SHA (CVE-2026-26445)", + text: "stomper 5e2741e is vulnerable to Denial of Service.", + wantIntro: "0", + wantLastAff: "5e2741e", + wantRangeTyp: "GIT", + }, + { + name: "contains commit 40-char SHA (CVE-2026-26452)", + text: "ccoap 77f55c4b466e99327c24ace8a2913d3ba7e2ccd5 lcontains a vulnerability in the option parsing logic.", + wantIntro: "0", + wantLastAff: "77f55c4b466e99327c24ace8a2913d3ba7e2ccd5", + wantRangeTyp: "GIT", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + metrics := &models.ConversionMetrics{} + got := ExtractVersionsFromText(nil, tc.text, metrics, models.VersionSourceDescription) + if len(got) == 0 { + t.Fatalf("ExtractVersionsFromText(%q) returned no ranges", tc.text) + } + r := got[0].Range + if r.GetType().String() != tc.wantRangeTyp { + t.Errorf("Range.Type = %s, want %s", r.GetType().String(), tc.wantRangeTyp) + } + events := r.GetEvents() + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %+v", events) + } + if events[0].GetIntroduced() != tc.wantIntro { + t.Errorf("Introduced = %q, want %q", events[0].GetIntroduced(), tc.wantIntro) + } + if events[1].GetFixed() != tc.wantFixed { + t.Errorf("Fixed = %q, want %q", events[1].GetFixed(), tc.wantFixed) + } + if events[1].GetLastAffected() != tc.wantLastAff { + t.Errorf("LastAffected = %q, want %q", events[1].GetLastAffected(), tc.wantLastAff) + } + }) + } +} diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 2d091485b17..90326fb70e4 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -100,7 +100,7 @@ func (m *ConversionMetrics) AddNotef(format string, a ...any) { // SetOutcome sets the outcome of the conversion only if it's not already set, or has become successful. func (m *ConversionMetrics) SetOutcome(outcome ConversionOutcome) { - if m.Outcome == ConversionUnknown { // TODO DOUBLE CHECK + if m.Outcome == ConversionUnknown || outcome == Successful { m.Outcome = outcome } }