diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index ebd3491f483..50be1f27998 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -16,6 +16,12 @@ func GetVersionExtractor(cna string) VersionExtractor { switch cna { case "Linux": return &LinuxVersionExtractor{} + case "Wordfence": + return &WordpressExtractor{Handler: &WordfenceHandler{}} + case "Patchstack": + return &WordpressExtractor{Handler: &PatchstackHandler{}} + case "WPScan": + return &WordpressExtractor{Handler: &WPScanHandler{}} default: return &DefaultVersionExtractor{} } diff --git a/vulnfeeds/conversion/cve5/strategies.go b/vulnfeeds/conversion/cve5/strategies.go index 95b5babd448..76fd355692f 100644 --- a/vulnfeeds/conversion/cve5/strategies.go +++ b/vulnfeeds/conversion/cve5/strategies.go @@ -58,7 +58,19 @@ func initialNormalExtraction(vers models.Versions, metrics *models.ConversionMet introduced = vers.Version metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) } - if vLessThanQual.AtLeast(acceptableQuality) { + // Prefer changes for fixed version if available. + var fixedFromChanges string + for _, ch := range vers.Changes { + if ch.Status == "unaffected" && ch.At != "" { + fixedFromChanges = ch.At + break + } + } + + if fixedFromChanges != "" { + fixed = fixedFromChanges + metrics.AddNote("Fixed from changes - %s", fixed) + } else if vLessThanQual.AtLeast(acceptableQuality) { fixed = vers.LessThan metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) } else if vLTOEQual.AtLeast(acceptableQuality) { diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index b3fc79fe8b4..1e3238482e2 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -149,6 +149,26 @@ func TestFindNormalAffectedRanges(t *testing.T) { }, wantRangeType: VersionRangeTypeGit, }, + { + name: "changes preferred over lessThanOrEqual with filler version", + affected: models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "n/a", + LessThanOrEqual: "1.0.32", + Changes: []models.Change{ + {At: "1.0.33", Status: "unaffected"}, + }, + VersionType: "custom", + }, + }, + }, + wantRanges: []*osvschema.Range{ + conversion.BuildVersionRange("0", "", "1.0.33"), + }, + wantRangeType: VersionRangeTypeEcosystem, + }, } for _, tt := range tests { @@ -381,6 +401,15 @@ func TestGetVersionExtractor(t *testing.T) { }, expectedType: reflect.TypeOf(&LinuxVersionExtractor{}), }, + { + name: "Wordfence CVE", + cve: models.CVE5{ + Metadata: models.CVE5Metadata{ + AssignerShortName: "Wordfence", + }, + }, + expectedType: reflect.TypeOf(&WordpressExtractor{}), + }, { name: "Default CVE", cve: models.CVE5{ @@ -593,13 +622,72 @@ func TestExtractVersions(t *testing.T) { }, }}, }, + { + name: "CVE-2026-1293", + cve: loadTestData(t, "CVE-2026-1293"), + cnaAssigner: "Wordfence", + repos: []string{}, + expectedAffected: []*osvschema.Affected{{ + Package: &osvschema.Package{ + Ecosystem: "WordPress:Plugin", + Name: "wordpress-seo", + }, + Ranges: []*osvschema.Range{{ + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {LastAffected: "26.8"}, + }, + }}, + }}, + }, + { + name: "CVE-2021-23209", + cve: loadTestData(t, "CVE-2021-23209"), + cnaAssigner: "Patchstack", + repos: []string{}, + expectedAffected: []*osvschema.Affected{{ + Package: &osvschema.Package{ + Ecosystem: "WordPress:Plugin", + Name: "accelerated-mobile-pages", + }, + Ranges: []*osvschema.Range{{ + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {Fixed: "1.0.77.33"}, + }, + }}, + }}, + }, + { + name: "CVE-2015-10001", + cve: loadTestData(t, "CVE-2015-10001"), + cnaAssigner: "WPScan", + repos: []string{}, + expectedAffected: []*osvschema.Affected{{ + Package: &osvschema.Package{ + Ecosystem: "WordPress:Plugin", + Name: "wp-stats", + }, + Ranges: []*osvschema.Range{{ + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "2.52"}, + {LastAffected: "2.52"}, + }, + }}, + }}, + }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { metrics := &models.ConversionMetrics{} v := vulns.Vulnerability{ - Vulnerability: &osvschema.Vulnerability{}, + Vulnerability: &osvschema.Vulnerability{ + References: vulns.ClassifyReferences(identifyPossibleURLs(tc.cve)), + }, } extractor := GetVersionExtractor(tc.cnaAssigner) extractor.ExtractVersions(tc.cve, &v, metrics, tc.repos) diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go new file mode 100644 index 00000000000..21e3b0b521c --- /dev/null +++ b/vulnfeeds/conversion/cve5/wordpress.go @@ -0,0 +1,344 @@ +package cve5 + +import ( + "regexp" + "slices" + "strings" + + c "github.com/google/osv/vulnfeeds/conversion" + "github.com/google/osv/vulnfeeds/models" + "github.com/google/osv/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +var ( + wpPluginTracRegex = regexp.MustCompile(`plugins\.trac\.wordpress\.org/browser/([^/]+)`) + wpPluginSvnRegex = regexp.MustCompile(`plugins\.svn\.wordpress\.org/([^/]+)`) + wordfencePluginRegex = regexp.MustCompile(`wordfence\.com/threat-intel/vulnerabilities/wordpress-plugins/([^/]+)`) + wpPluginOrgRegex = regexp.MustCompile(`wordpress\.org/plugins/([^/]+)`) + wpThemeOrgRegex = regexp.MustCompile(`wordpress\.org/themes/([^/]+)`) + + patchstackVulnRegex = regexp.MustCompile(`patchstack\.com/database/vulnerability/([^/]+)`) + patchstackPluginRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/plugin/([^/]+)`) + patchstackThemeRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/theme/([^/]+)`) +) + +// extractWordPressSlugAndEcosystem unifies the logic to extract the slug and determine +// the specific WordPress ecosystem (Core, Plugin, Theme) for a given CVE. +func extractWordPressSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) (string, string) { + var slug string + var ecosystem string = "WordPress" // Default/Fallback + + // 1. Core Check (Highest Priority) + if len(cve.Containers.CNA.Affected) > 0 { + aff := cve.Containers.CNA.Affected[0] + if strings.EqualFold(aff.Vendor, "wordpress") && strings.EqualFold(aff.Product, "wordpress") { + return "wordpress", "WordPress:Core" + } + } + + // 2. Slug from PackageName + if len(cve.Containers.CNA.Affected) > 0 { + aff := cve.Containers.CNA.Affected[0] + if aff.PackageName != "" { + slug = aff.PackageName + } + } + + // 3. Ecosystem Extraction from CollectionURL + if len(cve.Containers.CNA.Affected) > 0 { + aff := cve.Containers.CNA.Affected[0] + switch aff.CollectionURL { + case "https://wordpress.org/themes": + ecosystem = "WordPress:Theme" + case "https://wordpress.org/plugins": + ecosystem = "WordPress:Plugin" + } + } + + // 4. URL Heuristics Fallback (for slug and ecosystem) + var tracSlug, svnSlug, wordfenceSlug, wpOrgPluginSlug, wpOrgThemeSlug, patchstackPluginSlug, patchstackThemeSlug, patchstackVulnSlug string + var urlEcosystem string + + for _, ref := range v.References { + url := ref.GetUrl() + + if match := wpPluginTracRegex.FindStringSubmatch(url); match != nil { + tracSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Plugin" } + } else if match := wpPluginSvnRegex.FindStringSubmatch(url); match != nil { + svnSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Plugin" } + } else if match := wordfencePluginRegex.FindStringSubmatch(url); match != nil { + wordfenceSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Plugin" } + } else if match := wpPluginOrgRegex.FindStringSubmatch(url); match != nil { + wpOrgPluginSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Plugin" } + } else if match := wpThemeOrgRegex.FindStringSubmatch(url); match != nil { + wpOrgThemeSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Theme" } + } else if match := patchstackPluginRegex.FindStringSubmatch(url); match != nil { + patchstackPluginSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Plugin" } + } else if match := patchstackThemeRegex.FindStringSubmatch(url); match != nil { + patchstackThemeSlug = match[1] + if urlEcosystem == "" { urlEcosystem = "WordPress:Theme" } + } else if match := patchstackVulnRegex.FindStringSubmatch(url); match != nil { + patchstackVulnSlug = match[1] + } + + // Generic URL keyword check for ecosystem if still generic + if urlEcosystem == "" { + if strings.Contains(url, "/theme/") || strings.Contains(url, "/themes/") { + urlEcosystem = "WordPress:Theme" + } else if strings.Contains(url, "/plugin/") || strings.Contains(url, "/plugins/") { + urlEcosystem = "WordPress:Plugin" + } + } + } + + var urlSlug string + slugsToTry := []string{ + tracSlug, + svnSlug, + wordfenceSlug, + wpOrgPluginSlug, + wpOrgThemeSlug, + patchstackPluginSlug, + patchstackThemeSlug, + patchstackVulnSlug, + } + + for _, s := range slugsToTry { + if s != "" { + urlSlug = s + break + } + } + + if slug == "" && urlSlug != "" { + slug = urlSlug + } + + // Slug from Product Fallback + if slug == "" && len(cve.Containers.CNA.Affected) > 0 { + aff := cve.Containers.CNA.Affected[0] + if vulns.CheckQuality(aff.Product).AtLeast(vulns.Spaces) { + // Basic slugification (lowercase, replace spaces with hyphens) + slug = strings.ToLower(aff.Product) + slug = strings.ReplaceAll(slug, " ", "-") + } + } + + if ecosystem == "WordPress" && urlEcosystem != "" { + ecosystem = urlEcosystem + } + + // 5. Description/Title Heuristics Fallback + if ecosystem == "WordPress" { + desc := strings.ToLower(models.EnglishDescription(cve.Containers.CNA.Descriptions)) + title := strings.ToLower(cve.Containers.CNA.Title) + + if strings.Contains(desc, "plugin") || strings.Contains(title, "plugin") { + ecosystem = "WordPress:Plugin" + } else if strings.Contains(desc, "theme") || strings.Contains(title, "theme") { + ecosystem = "WordPress:Theme" + } + } + + return slug, ecosystem +} + +// WordpressHandler defines hooks for CNA-specific logic. +type WordpressHandler interface { + PreExtract(cve *models.CVE5) + PostExtractDefault(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, slug string, ecosystem string) +} + +// WordpressExtractor handles version extraction for WordPress CVEs. +type WordpressExtractor struct { + DefaultVersionExtractor + Handler WordpressHandler +} + +var _ VersionExtractor = &WordpressExtractor{} + +func (w *WordpressExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { + if w.Handler != nil { + w.Handler.PreExtract(&cve) + } + + // 1. Run default extraction first + w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) + + // 2. Extract slug and determine ecosystem using shared helper + slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) + + if w.Handler != nil { + w.Handler.PostExtractDefault(cve, v, metrics, slug, ecosystem) + } + + // 3. Update affected packages with correct ecosystem and slug + if len(v.Affected) > 0 { + for _, aff := range v.Affected { + isGit := false + for _, r := range aff.Ranges { + if r.Type == osvschema.Range_GIT { + isGit = true + break + } + } + if isGit { + aff.Package = nil // Do not put package info on GIT ranges + continue + } + + if slug == "" { + continue // Skip enriching if we have no slug + } + + if aff.Package == nil { + aff.Package = &osvschema.Package{ + Ecosystem: ecosystem, + Name: slug, + } + } else { + // Update ecosystem if it was generic + if aff.Package.Ecosystem == "WordPress" || aff.Package.Ecosystem == "" { + aff.Package.Ecosystem = ecosystem + } + if aff.Package.Name == "" { + aff.Package.Name = slug + } + } + } + } + + // 4. Unified Fallback Strategy + if len(v.Affected) == 0 { + if slug == "" { + metrics.AddNote("Failed to extract versions via default, and no WordPress slug found to attempt fallback") + return + } + + metrics.AddNote("Attempting to generate ECOSYSTEM ranges for WordPress") + + gotVersions := false + var allRanges []*osvschema.Range + + // Fallback 1: CNA Affected + for _, cveAff := range cve.Containers.CNA.Affected { + versionRanges, _ := w.FindNormalAffectedRanges(cveAff, metrics) + for _, r := range versionRanges { + r.Range.Type = osvschema.Range_ECOSYSTEM + allRanges = append(allRanges, r.Range) + } + } + + if len(allRanges) > 0 { + gotVersions = true + metrics.AddSource(models.VersionSourceAffected) + } + + // Fallback 2: CPE + if !gotVersions { + versionRanges, _ := cpeVersionExtraction(cve, metrics) + for _, r := range versionRanges { + r.Range.Type = osvschema.Range_ECOSYSTEM + allRanges = append(allRanges, r.Range) + } + if len(allRanges) > 0 { + gotVersions = true + } + } + + // Fallback 3: Description + if !gotVersions { + textRanges := c.ExtractVersionsFromText(nil, models.EnglishDescription(cve.Containers.CNA.Descriptions), metrics, models.VersionSourceDescription) + for _, r := range textRanges { + r.Range.Type = osvschema.Range_ECOSYSTEM + allRanges = append(allRanges, r.Range) + } + if len(allRanges) > 0 { + gotVersions = true + } + } + + if gotVersions { + aff := &osvschema.Affected{ + Package: &osvschema.Package{ + Ecosystem: ecosystem, + Name: slug, + }, + Ranges: allRanges, + } + c.AddAffected(v, aff, metrics) + metrics.Outcome = models.Successful // Override outcome directly + } + } +} + +// DefaultWordpressHandler provides empty implementations for the hooks. +type DefaultWordpressHandler struct{} + +func (d *DefaultWordpressHandler) PreExtract(cve *models.CVE5) {} +func (d *DefaultWordpressHandler) PostExtractDefault(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, slug string, ecosystem string) {} + +// WordfenceHandler implements Wordfence specific quirks. +type WordfenceHandler struct { + DefaultWordpressHandler +} + +func normalizeVersion(v string) string { + return strings.TrimPrefix(v, "v") +} + +func (w *WordfenceHandler) PreExtract(cve *models.CVE5) { + for i := range cve.Containers.CNA.Affected { + for j := range cve.Containers.CNA.Affected[i].Versions { + vers := &cve.Containers.CNA.Affected[i].Versions[j] + vers.Version = normalizeVersion(vers.Version) + vers.LessThan = normalizeVersion(vers.LessThan) + vers.LessThanOrEqual = normalizeVersion(vers.LessThanOrEqual) + } + } +} + +// PatchstackHandler implements Patchstack specific quirks. +type PatchstackHandler struct { + DefaultWordpressHandler +} + +func (p *PatchstackHandler) PostExtractDefault(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, slug string, ecosystem string) { + if slug != "" { + var baseURL string + switch ecosystem { + case "WordPress:Plugin": + baseURL = "https://wordpress.org/plugins/" + case "WordPress:Theme": + baseURL = "https://wordpress.org/themes/" + } + + if baseURL != "" { + wpURL := baseURL + slug + "/" + // Check if already exists to avoid duplicates + exists := slices.ContainsFunc(v.References, func(ref *osvschema.Reference) bool { + return ref.GetUrl() == wpURL + }) + if !exists { + v.References = append(v.References, &osvschema.Reference{ + Type: osvschema.Reference_WEB, + Url: wpURL, + }) + metrics.AddNote("Added wordpress.org reference link: %s", wpURL) + } + } + } +} + +// WPScanHandler implements WPScan specific quirks. +type WPScanHandler struct { + DefaultWordpressHandler +} + diff --git a/vulnfeeds/models/cve.go b/vulnfeeds/models/cve.go index f2393c004a1..cd9b34991bb 100644 --- a/vulnfeeds/models/cve.go +++ b/vulnfeeds/models/cve.go @@ -126,12 +126,18 @@ type Affected struct { DefaultStatus string `json:"defaultStatus,omitempty"` } +type Change struct { + At string `json:"at,omitempty"` + Status string `json:"status,omitempty"` +} + type Versions struct { - Version string `json:"version,omitempty"` - Status string `json:"status,omitempty"` - LessThanOrEqual string `json:"lessThanOrEqual,omitempty"` - LessThan string `json:"lessThan,omitempty"` - VersionType string `json:"versionType,omitempty"` + Version string `json:"version,omitempty"` + Status string `json:"status,omitempty"` + LessThanOrEqual string `json:"lessThanOrEqual,omitempty"` + LessThan string `json:"lessThan,omitempty"` + VersionType string `json:"versionType,omitempty"` + Changes []Change `json:"changes,omitempty"` } type CVE5 struct {