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
18 changes: 18 additions & 0 deletions vulnfeeds/conversion/nvd/converter.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
84 changes: 84 additions & 0 deletions vulnfeeds/conversion/versions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.")
}
Expand Down
130 changes: 130 additions & 0 deletions vulnfeeds/conversion/versions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
}
1 change: 1 addition & 0 deletions vulnfeeds/models/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const (
VersionSourceCPE VersionSource = "CPEVERS"
VersionSourceDescription VersionSource = "DESCRVERS"
VersionSourceRefs VersionSource = "REFS"
VersionSourceCompareURL VersionSource = "COMPAREURL"
)

func DetermineOutcome(metrics *ConversionMetrics) {
Expand Down