diff --git a/vulnfeeds/conversion/nvd/converter.go b/vulnfeeds/conversion/nvd/converter.go index 5fd0aa63d3d..ecb3f56fffa 100644 --- a/vulnfeeds/conversion/nvd/converter.go +++ b/vulnfeeds/conversion/nvd/converter.go @@ -97,6 +97,24 @@ func CVEToOSV(cve models.NVDCVE, repos []string, cache *git.RepoTagsCache, direc metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceRefs) } + // Extract version ranges from compare URLs (e.g. github.com/.../compare/v1.0...v2.0) + // before falling back to text description extraction. + if len(resolvedRanges) == 0 { + compareRanges := c.ExtractVersionsFromCompareURLs(cve.References) + if len(compareRanges) > 0 { + metrics.AddNote("Extracted versions from compare URLs: %v", compareRanges) + r, un, sR := processRanges(compareRanges, repos, metrics, cache, models.VersionSourceCompareURL) + if metrics.Outcome == models.Error { + return models.Error + } + resolvedRanges = append(resolvedRanges, r...) + unresolvedRanges = append(unresolvedRanges, un...) + for _, s := range sR { + successfulRepos[s] = true + } + } + } + // Extract Versions From Text if no CPE versions found if len(resolvedRanges) == 0 { textRanges := c.ExtractVersionsFromText(nil, models.EnglishDescription(cve.Descriptions), metrics) diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 8ec95e6c8ab..ea73d4125a2 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -565,6 +565,68 @@ func ExtractCommitsFromRefs(references []models.Reference, httpClient *http.Clie return commits, nil } +// ExtractVersionFromCompareURL parses a GitHub/GitLab compare URL and returns the +// introduced version, fixed version, and repository URL. +// Returns an error if the URL is not a parseable compare URL with a version range. +// Example: https://github.com/JSONPath-Plus/JSONPath/compare/v9.0.0...v10.1.0 +func ExtractVersionFromCompareURL(u string) (introduced, fixed, repo string, err error) { + parsedURL, err := url.Parse(u) + if err != nil { + return "", "", "", err + } + + if !strings.Contains(parsedURL.Path, "compare") { + return "", "", "", fmt.Errorf("not a compare URL: %s", u) + } + + repo, err = Repo(u) + if err != nil { + return "", "", "", fmt.Errorf("failed to extract repo from %s: %w", u, err) + } + + // Find the "compare" segment in the path and get the version range from the next part. + pathParts := strings.Split(parsedURL.Path, "/") + for i, part := range pathParts { + if part != "compare" || i+1 >= len(pathParts) { + continue + } + compareStr := pathParts[i+1] + + var parts []string + if strings.Contains(compareStr, "...") { + parts = strings.SplitN(compareStr, "...", 2) + } else if strings.Contains(compareStr, "..") { + parts = strings.SplitN(compareStr, "..", 2) + } + + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + return parts[0], parts[1], repo, nil + } + + return "", "", "", fmt.Errorf("could not parse version range from compare URL: %s", u) + } + + return "", "", "", fmt.Errorf("could not find compare segment in URL: %s", u) +} + +// ExtractVersionsFromCompareURLs iterates over the provided references and extracts +// ECOSYSTEM version ranges from GitHub/GitLab compare URLs. For a URL like: +// https://github.com/JSONPath-Plus/JSONPath/compare/v9.0.0...v10.1.0 +// it returns a range with introduced=v9.0.0 and fixed=v10.1.0. +func ExtractVersionsFromCompareURLs(refs []models.Reference) []*osvschema.Range { + var ranges []*osvschema.Range + for _, ref := range refs { + introduced, fixed, _, err := ExtractVersionFromCompareURL(ref.URL) + if err != nil { + continue + } + vr := BuildVersionRange(introduced, "", fixed) + ranges = append(ranges, vr) + } + + return ranges +} + // For URLs referencing commits in supported Git repository hosts, return a cloneable AffectedCommit. func extractGitAffectedCommit(link string, commitType models.CommitType, httpClient *http.Client) (models.AffectedCommit, error) { var ac models.AffectedCommit @@ -925,6 +987,28 @@ func ExtractVersionInfo(cve models.NVDCVE, validVersions []string, httpClient *h } } + // If no versions were found from CPEs, try to infer introduced/fixed versions + // from compare URLs in the references (e.g. github.com/.../compare/v1.0...v2.0). + if len(v.AffectedVersions) == 0 { + for _, ref := range cve.References { + introduced, fixed, _, err := ExtractVersionFromCompareURL(ref.URL) + if err != nil { + continue + } + possibleNewAffectedVersion := models.AffectedVersion{ + Introduced: introduced, + Fixed: fixed, + } + if slices.Contains(v.AffectedVersions, possibleNewAffectedVersion) { + continue + } + v.AffectedVersions = append(v.AffectedVersions, possibleNewAffectedVersion) + } + if len(v.AffectedVersions) > 0 { + metrics.AddNote("Extracted versions from compare URLs: %v", v.AffectedVersions) + } + } + if len(v.AffectedVersions) == 0 { metrics.AddNote("No versions detected.") } diff --git a/vulnfeeds/conversion/versions_test.go b/vulnfeeds/conversion/versions_test.go index 18b9da34aad..d54ec910a21 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -1816,3 +1816,133 @@ func TestVendorProduct_UnmarshalText(t *testing.T) { }) } } + +func TestExtractVersionFromCompareURL(t *testing.T) { + tests := []struct { + description string + inputURL string + expectedIntroduced string + expectedFixed string + expectedRepo string + expectError bool + }{ + { + description: "GitHub compare URL with three-dot range", + inputURL: "https://github.com/JSONPath-Plus/JSONPath/compare/v9.0.0...v10.1.0", + expectedIntroduced: "v9.0.0", + expectedFixed: "v10.1.0", + expectedRepo: "https://github.com/JSONPath-Plus/JSONPath", + }, + { + description: "GitHub compare URL with v-prefixed versions", + inputURL: "https://github.com/kovidgoyal/kitty/compare/v0.26.1...v0.26.2", + expectedIntroduced: "v0.26.1", + expectedFixed: "v0.26.2", + expectedRepo: "https://github.com/kovidgoyal/kitty", + }, + { + description: "GitLab compare URL with three-dot range and query params", + inputURL: "https://gitlab.com/mayan-edms/mayan-edms/-/compare/development...master?from_project_id=396557&straight=false", + expectedIntroduced: "development", + expectedFixed: "master", + expectedRepo: "https://gitlab.com/mayan-edms/mayan-edms", + }, + { + description: "GitLab compare URL with ambiguous version and branch", + inputURL: "https://git.drupalcode.org/project/views/-/compare/7.x-3.21...7.x-3.x", + expectedIntroduced: "7.x-3.21", + expectedFixed: "7.x-3.x", + expectedRepo: "https://git.drupalcode.org/project/views", + }, + { + description: "non-compare URL returns error", + inputURL: "https://github.com/foo/bar/commit/abc123", + expectError: true, + }, + { + description: "compare URL without version range returns error", + inputURL: "https://github.com/foo/bar/compare/", + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + gotIntroduced, gotFixed, gotRepo, err := ExtractVersionFromCompareURL(tc.inputURL) + if tc.expectError { + if err == nil { + t.Errorf("ExtractVersionFromCompareURL(%q) expected error but got nil", tc.inputURL) + } + return + } + if err != nil { + t.Errorf("ExtractVersionFromCompareURL(%q) unexpected error: %v", tc.inputURL, err) + return + } + if gotIntroduced != tc.expectedIntroduced { + t.Errorf("ExtractVersionFromCompareURL(%q) introduced = %q, want %q", tc.inputURL, gotIntroduced, tc.expectedIntroduced) + } + if gotFixed != tc.expectedFixed { + t.Errorf("ExtractVersionFromCompareURL(%q) fixed = %q, want %q", tc.inputURL, gotFixed, tc.expectedFixed) + } + if gotRepo != tc.expectedRepo { + t.Errorf("ExtractVersionFromCompareURL(%q) repo = %q, want %q", tc.inputURL, gotRepo, tc.expectedRepo) + } + }) + } +} + +func TestExtractVersionsFromCompareURLs(t *testing.T) { + tests := []struct { + description string + inputRefs []models.Reference + expectedRanges int + }{ + { + description: "single compare URL reference", + inputRefs: []models.Reference{ + {URL: "https://github.com/JSONPath-Plus/JSONPath/compare/v9.0.0...v10.1.0"}, + }, + expectedRanges: 1, + }, + { + description: "multiple references with one compare URL", + inputRefs: []models.Reference{ + {URL: "https://nvd.nist.gov/vuln/detail/CVE-2024-21534"}, + {URL: "https://github.com/JSONPath-Plus/JSONPath/compare/v9.0.0...v10.1.0"}, + {URL: "https://github.com/JSONPath-Plus/JSONPath/issues/123"}, + }, + expectedRanges: 1, + }, + { + description: "multiple compare URLs", + inputRefs: []models.Reference{ + {URL: "https://github.com/foo/bar/compare/v1.0.0...v1.0.1"}, + {URL: "https://github.com/foo/bar/compare/v2.0.0...v2.0.1"}, + }, + expectedRanges: 2, + }, + { + description: "no compare URL references", + inputRefs: []models.Reference{ + {URL: "https://nvd.nist.gov/vuln/detail/CVE-2024-21534"}, + {URL: "https://github.com/foo/bar/commit/abc123def456"}, + }, + expectedRanges: 0, + }, + { + description: "empty references", + inputRefs: []models.Reference{}, + expectedRanges: 0, + }, + } + + for _, tc := range tests { + t.Run(tc.description, func(t *testing.T) { + got := ExtractVersionsFromCompareURLs(tc.inputRefs) + if len(got) != tc.expectedRanges { + t.Errorf("ExtractVersionsFromCompareURLs() returned %d ranges, want %d; got: %+v", len(got), tc.expectedRanges, got) + } + }) + } +} diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 2befba51d4f..eb63c21aa1f 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -112,6 +112,7 @@ const ( VersionSourceCPE VersionSource = "CPEVERS" VersionSourceDescription VersionSource = "DESCRVERS" VersionSourceRefs VersionSource = "REFS" + VersionSourceCompareURL VersionSource = "COMPAREURL" ) func DetermineOutcome(metrics *ConversionMetrics) {