From 0efaf6d3b0757decffcdf6fb16ef56dfc69ebe57 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 15 Jul 2026 05:54:41 +0000 Subject: [PATCH 1/6] support wordpress ecosystem extraction using cve5 --- vulnfeeds/conversion/cve5/extraction.go | 2 + .../cve5/version_extraction_test.go | 32 +++- .../conversion/cve5/wordfence_extractor.go | 163 ++++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 vulnfeeds/conversion/cve5/wordfence_extractor.go diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index ebd3491f483..599efb677f1 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -16,6 +16,8 @@ func GetVersionExtractor(cna string) VersionExtractor { switch cna { case "Linux": return &LinuxVersionExtractor{} + case "Wordfence": + return &WordfenceVersionExtractor{} default: return &DefaultVersionExtractor{} } diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index b3fc79fe8b4..575c0ece5c3 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -381,6 +381,15 @@ func TestGetVersionExtractor(t *testing.T) { }, expectedType: reflect.TypeOf(&LinuxVersionExtractor{}), }, + { + name: "Wordfence CVE", + cve: models.CVE5{ + Metadata: models.CVE5Metadata{ + AssignerShortName: "Wordfence", + }, + }, + expectedType: reflect.TypeOf(&WordfenceVersionExtractor{}), + }, { name: "Default CVE", cve: models.CVE5{ @@ -593,13 +602,34 @@ 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", + Name: "wordpress-seo", + }, + Ranges: []*osvschema.Range{{ + Type: osvschema.Range_ECOSYSTEM, + Events: []*osvschema.Event{ + {Introduced: "0"}, + {LastAffected: "26.8"}, + }, + }}, + }}, + }, } 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/wordfence_extractor.go b/vulnfeeds/conversion/cve5/wordfence_extractor.go new file mode 100644 index 00000000000..41940159f0c --- /dev/null +++ b/vulnfeeds/conversion/cve5/wordfence_extractor.go @@ -0,0 +1,163 @@ +package cve5 + +import ( + "regexp" + "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/osvconstants" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// WordfenceVersionExtractor provides version extraction logic for Wordfence CVEs. +type WordfenceVersionExtractor struct { + DefaultVersionExtractor +} + +var _ VersionExtractor = &WordfenceVersionExtractor{} + +var ( + tracRegex = regexp.MustCompile(`plugins\.trac\.wordpress\.org/browser/([^/]+)`) + svnRegex = regexp.MustCompile(`plugins\.svn\.wordpress\.org/([^/]+)`) + wordfenceRegex = regexp.MustCompile(`wordfence\.com/threat-intel/vulnerabilities/wordpress-plugins/([^/]+)`) + wpOrgRegex = regexp.MustCompile(`wordpress\.org/plugins/([^/]+)`) +) + +func extractSlugFromOSVRefs(refs []*osvschema.Reference) string { + var tracSlug, svnSlug, wordfenceSlug, wpOrgSlug string + + for _, ref := range refs { + url := ref.GetUrl() + if match := tracRegex.FindStringSubmatch(url); match != nil { + tracSlug = match[1] + } + if match := svnRegex.FindStringSubmatch(url); match != nil { + svnSlug = match[1] + } + if match := wordfenceRegex.FindStringSubmatch(url); match != nil { + wordfenceSlug = match[1] + } + if match := wpOrgRegex.FindStringSubmatch(url); match != nil { + wpOrgSlug = match[1] + } + } + + // Prioritize trac slug + if tracSlug != "" { + return tracSlug + } + if svnSlug != "" { + return svnSlug + } + if wordfenceSlug != "" { + return wordfenceSlug + } + if wpOrgSlug != "" { + return wpOrgSlug + } + + return "" +} + +func normalizeVersion(v string) string { + return strings.TrimPrefix(v, "v") +} + +// ExtractVersions for WordfenceVersionExtractor. +func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { + // Normalize versions in cve first + 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) + } + } + + // 1. Try standard extraction (which prefers GIT ranges) + w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) + + slug := extractSlugFromOSVRefs(v.References) + + // 2. If we have Affected entries, update them with slug/ecosystem if missing. + if len(v.Affected) > 0 { + if slug != "" { + for _, aff := range v.Affected { + if aff.Package == nil { + aff.Package = &osvschema.Package{ + Ecosystem: string(osvconstants.EcosystemWordPress), + Name: slug, + } + } + } + } + // Do not return early, we want both GIT and ECOSYSTEM ranges if they exist. + } + + // 3. We also want to produce ECOSYSTEM ranges if possible. + if slug == "" { + if len(v.Affected) == 0 { + metrics.AddNote("Failed to extract versions via default, and no WordPress slug found to attempt fallback") + } else { + metrics.AddNote("No WordPress slug found to attempt generating ECOSYSTEM ranges") + } + return + } + + metrics.AddNote("Attempting to generate ECOSYSTEM ranges for WordPress") + + gotVersions := false + var allRanges []*osvschema.Range + + 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) + } + + // CPE Fallback + 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 + } + } + + // Description Fallback + 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: string(osvconstants.EcosystemWordPress), + Name: slug, + }, + Ranges: allRanges, + } + c.AddAffected(v, aff, metrics) + metrics.Outcome = models.Successful // Override outcome directly + } +} From faa1f59a5583ff08341e1fa6ba973aa6ca906845 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 15 Jul 2026 06:18:58 +0000 Subject: [PATCH 2/6] no packageinfo on git --- vulnfeeds/conversion/cve5/wordfence_extractor.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/vulnfeeds/conversion/cve5/wordfence_extractor.go b/vulnfeeds/conversion/cve5/wordfence_extractor.go index 41940159f0c..a3a95b3d245 100644 --- a/vulnfeeds/conversion/cve5/wordfence_extractor.go +++ b/vulnfeeds/conversion/cve5/wordfence_extractor.go @@ -86,6 +86,17 @@ func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vu if len(v.Affected) > 0 { if slug != "" { 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 aff.Package == nil { aff.Package = &osvschema.Package{ Ecosystem: string(osvconstants.EcosystemWordPress), From 5dd81f380ead83dda108d1c3db8d8b126ae1892c Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 05:00:04 +0000 Subject: [PATCH 3/6] patchstack --- vulnfeeds/conversion/cve5/extraction.go | 2 + .../conversion/cve5/patchstack_extractor.go | 172 ++++++++++++++++++ vulnfeeds/conversion/cve5/strategies.go | 14 +- .../cve5/version_extraction_test.go | 39 ++++ vulnfeeds/models/cve.go | 16 +- 5 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 vulnfeeds/conversion/cve5/patchstack_extractor.go diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index 599efb677f1..b607ff25677 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -18,6 +18,8 @@ func GetVersionExtractor(cna string) VersionExtractor { return &LinuxVersionExtractor{} case "Wordfence": return &WordfenceVersionExtractor{} + case "Patchstack": + return &PatchstackVersionExtractor{} default: return &DefaultVersionExtractor{} } diff --git a/vulnfeeds/conversion/cve5/patchstack_extractor.go b/vulnfeeds/conversion/cve5/patchstack_extractor.go new file mode 100644 index 00000000000..e0e0ba7cd68 --- /dev/null +++ b/vulnfeeds/conversion/cve5/patchstack_extractor.go @@ -0,0 +1,172 @@ +package cve5 + +import ( + "regexp" + "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" +) + +// PatchstackVersionExtractor provides version extraction logic for Patchstack CVEs. +type PatchstackVersionExtractor struct { + DefaultVersionExtractor +} + +var _ VersionExtractor = &PatchstackVersionExtractor{} + +var ( + patchstackVulnRegex = regexp.MustCompile(`patchstack\.com/database/vulnerability/([^/]+)`) + patchstackPluginRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/plugin/([^/]+)`) + patchstackThemeRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/theme/([^/]+)`) + wpPluginsRegex = regexp.MustCompile(`wordpress\.org/plugins/([^/]+)`) + wpThemesRegex = regexp.MustCompile(`wordpress\.org/themes/([^/]+)`) +) + +func extractSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) (string, string) { + var slug string + var ecosystem string = "WordPress" // Default/Fallback + + // 1. Try packageName and collectionURL + if len(cve.Containers.CNA.Affected) > 0 { + aff := cve.Containers.CNA.Affected[0] + if aff.PackageName != "" { + slug = aff.PackageName + } + if aff.CollectionURL == "https://wordpress.org/themes" { + ecosystem = "WordPress:Theme" + } else if aff.CollectionURL == "https://wordpress.org/plugins" { + ecosystem = "WordPress:Plugin" + } + } + + // 2. Fallback to URLs if slug is missing or ecosystem is generic + if slug == "" || ecosystem == "WordPress" { + for _, ref := range v.References { + url := ref.GetUrl() + + if slug == "" { + if match := patchstackVulnRegex.FindStringSubmatch(url); match != nil { + slug = match[1] + } else if match := patchstackPluginRegex.FindStringSubmatch(url); match != nil { + slug = match[1] + if ecosystem == "WordPress" { + ecosystem = "WordPress:Plugin" + } + } else if match := patchstackThemeRegex.FindStringSubmatch(url); match != nil { + slug = match[1] + if ecosystem == "WordPress" { + ecosystem = "WordPress:Theme" + } + } else if match := wpPluginsRegex.FindStringSubmatch(url); match != nil { + slug = match[1] + if ecosystem == "WordPress" { + ecosystem = "WordPress:Plugin" + } + } else if match := wpThemesRegex.FindStringSubmatch(url); match != nil { + slug = match[1] + if ecosystem == "WordPress" { + ecosystem = "WordPress:Theme" + } + } + } + + // If slug already found via packageName, but ecosystem is still generic, try to infer from URLs + if ecosystem == "WordPress" { + if strings.Contains(url, "/theme/") || strings.Contains(url, "/themes/") { + ecosystem = "WordPress:Theme" + } else if strings.Contains(url, "/plugin/") || strings.Contains(url, "/plugins/") { + ecosystem = "WordPress:Plugin" + } + } + } + } + + return slug, ecosystem +} + +// ExtractVersions for PatchstackVersionExtractor. +func (p *PatchstackVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { + // 1. Run default extraction first + p.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) + + // 2. Extract slug and determine ecosystem + slug, ecosystem := extractSlugAndEcosystem(cve, v) + + if slug == "" { + metrics.AddNote("Could not extract slug for Patchstack record") + return + } + + // 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 { + continue + } + + if aff.Package == nil { + aff.Package = &osvschema.Package{ + Ecosystem: ecosystem, + Name: slug, + } + } + } + } + + // 4. If default extraction didn't produce anything (e.g. no git ranges found), + // we still want to produce ECOSYSTEM ranges for WordPress. + // This mirrors Wordfence extractor logic. + if len(v.Affected) == 0 { + metrics.AddNote("Attempting to generate ECOSYSTEM ranges for Patchstack") + + gotVersions := false + var allRanges []*osvschema.Range + + for _, cveAff := range cve.Containers.CNA.Affected { + versionRanges, _ := p.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) + } + + // Description Fallback (if still no versions) + 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 + } + } +} 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 575c0ece5c3..21256d32e98 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 { @@ -621,6 +641,25 @@ func TestExtractVersions(t *testing.T) { }}, }}, }, + { + 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"}, + }, + }}, + }}, + }, } for _, tc := range testCases { 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 { From e8c3e91283752daca0f9794e11efaac25a8428a0 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 29 Jul 2026 05:08:25 +0000 Subject: [PATCH 4/6] refactor to share wordpress slug logic and add wpscan --- vulnfeeds/conversion/cve5/extraction.go | 2 + .../conversion/cve5/patchstack_extractor.go | 76 +-------- .../cve5/version_extraction_test.go | 21 ++- .../conversion/cve5/wordfence_extractor.go | 51 +----- vulnfeeds/conversion/cve5/wordpress.go | 147 ++++++++++++++++++ vulnfeeds/conversion/cve5/wpscan_extractor.go | 105 +++++++++++++ 6 files changed, 279 insertions(+), 123 deletions(-) create mode 100644 vulnfeeds/conversion/cve5/wordpress.go create mode 100644 vulnfeeds/conversion/cve5/wpscan_extractor.go diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index b607ff25677..c8f472119c5 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -20,6 +20,8 @@ func GetVersionExtractor(cna string) VersionExtractor { return &WordfenceVersionExtractor{} case "Patchstack": return &PatchstackVersionExtractor{} + case "WPScan": + return &WPScanVersionExtractor{} default: return &DefaultVersionExtractor{} } diff --git a/vulnfeeds/conversion/cve5/patchstack_extractor.go b/vulnfeeds/conversion/cve5/patchstack_extractor.go index e0e0ba7cd68..1813a5a7aee 100644 --- a/vulnfeeds/conversion/cve5/patchstack_extractor.go +++ b/vulnfeeds/conversion/cve5/patchstack_extractor.go @@ -1,9 +1,6 @@ package cve5 import ( - "regexp" - "strings" - c "github.com/google/osv/vulnfeeds/conversion" "github.com/google/osv/vulnfeeds/models" "github.com/google/osv/vulnfeeds/vulns" @@ -17,83 +14,14 @@ type PatchstackVersionExtractor struct { var _ VersionExtractor = &PatchstackVersionExtractor{} -var ( - patchstackVulnRegex = regexp.MustCompile(`patchstack\.com/database/vulnerability/([^/]+)`) - patchstackPluginRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/plugin/([^/]+)`) - patchstackThemeRegex = regexp.MustCompile(`patchstack\.com/database/wordpress/theme/([^/]+)`) - wpPluginsRegex = regexp.MustCompile(`wordpress\.org/plugins/([^/]+)`) - wpThemesRegex = regexp.MustCompile(`wordpress\.org/themes/([^/]+)`) -) - -func extractSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) (string, string) { - var slug string - var ecosystem string = "WordPress" // Default/Fallback - - // 1. Try packageName and collectionURL - if len(cve.Containers.CNA.Affected) > 0 { - aff := cve.Containers.CNA.Affected[0] - if aff.PackageName != "" { - slug = aff.PackageName - } - if aff.CollectionURL == "https://wordpress.org/themes" { - ecosystem = "WordPress:Theme" - } else if aff.CollectionURL == "https://wordpress.org/plugins" { - ecosystem = "WordPress:Plugin" - } - } - - // 2. Fallback to URLs if slug is missing or ecosystem is generic - if slug == "" || ecosystem == "WordPress" { - for _, ref := range v.References { - url := ref.GetUrl() - - if slug == "" { - if match := patchstackVulnRegex.FindStringSubmatch(url); match != nil { - slug = match[1] - } else if match := patchstackPluginRegex.FindStringSubmatch(url); match != nil { - slug = match[1] - if ecosystem == "WordPress" { - ecosystem = "WordPress:Plugin" - } - } else if match := patchstackThemeRegex.FindStringSubmatch(url); match != nil { - slug = match[1] - if ecosystem == "WordPress" { - ecosystem = "WordPress:Theme" - } - } else if match := wpPluginsRegex.FindStringSubmatch(url); match != nil { - slug = match[1] - if ecosystem == "WordPress" { - ecosystem = "WordPress:Plugin" - } - } else if match := wpThemesRegex.FindStringSubmatch(url); match != nil { - slug = match[1] - if ecosystem == "WordPress" { - ecosystem = "WordPress:Theme" - } - } - } - - // If slug already found via packageName, but ecosystem is still generic, try to infer from URLs - if ecosystem == "WordPress" { - if strings.Contains(url, "/theme/") || strings.Contains(url, "/themes/") { - ecosystem = "WordPress:Theme" - } else if strings.Contains(url, "/plugin/") || strings.Contains(url, "/plugins/") { - ecosystem = "WordPress:Plugin" - } - } - } - } - - return slug, ecosystem -} // ExtractVersions for PatchstackVersionExtractor. func (p *PatchstackVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { // 1. Run default extraction first p.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) - // 2. Extract slug and determine ecosystem - slug, ecosystem := extractSlugAndEcosystem(cve, v) + // 2. Extract slug and determine ecosystem using shared helper + slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) if slug == "" { metrics.AddNote("Could not extract slug for Patchstack record") diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 21256d32e98..567396520f9 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -629,7 +629,7 @@ func TestExtractVersions(t *testing.T) { repos: []string{}, expectedAffected: []*osvschema.Affected{{ Package: &osvschema.Package{ - Ecosystem: "WordPress", + Ecosystem: "WordPress:Plugin", Name: "wordpress-seo", }, Ranges: []*osvschema.Range{{ @@ -660,6 +660,25 @@ func TestExtractVersions(t *testing.T) { }}, }}, }, + { + 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 { diff --git a/vulnfeeds/conversion/cve5/wordfence_extractor.go b/vulnfeeds/conversion/cve5/wordfence_extractor.go index a3a95b3d245..61f56573e6f 100644 --- a/vulnfeeds/conversion/cve5/wordfence_extractor.go +++ b/vulnfeeds/conversion/cve5/wordfence_extractor.go @@ -1,13 +1,11 @@ package cve5 import ( - "regexp" "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/osvconstants" "github.com/ossf/osv-schema/bindings/go/osvschema" ) @@ -18,49 +16,6 @@ type WordfenceVersionExtractor struct { var _ VersionExtractor = &WordfenceVersionExtractor{} -var ( - tracRegex = regexp.MustCompile(`plugins\.trac\.wordpress\.org/browser/([^/]+)`) - svnRegex = regexp.MustCompile(`plugins\.svn\.wordpress\.org/([^/]+)`) - wordfenceRegex = regexp.MustCompile(`wordfence\.com/threat-intel/vulnerabilities/wordpress-plugins/([^/]+)`) - wpOrgRegex = regexp.MustCompile(`wordpress\.org/plugins/([^/]+)`) -) - -func extractSlugFromOSVRefs(refs []*osvschema.Reference) string { - var tracSlug, svnSlug, wordfenceSlug, wpOrgSlug string - - for _, ref := range refs { - url := ref.GetUrl() - if match := tracRegex.FindStringSubmatch(url); match != nil { - tracSlug = match[1] - } - if match := svnRegex.FindStringSubmatch(url); match != nil { - svnSlug = match[1] - } - if match := wordfenceRegex.FindStringSubmatch(url); match != nil { - wordfenceSlug = match[1] - } - if match := wpOrgRegex.FindStringSubmatch(url); match != nil { - wpOrgSlug = match[1] - } - } - - // Prioritize trac slug - if tracSlug != "" { - return tracSlug - } - if svnSlug != "" { - return svnSlug - } - if wordfenceSlug != "" { - return wordfenceSlug - } - if wpOrgSlug != "" { - return wpOrgSlug - } - - return "" -} - func normalizeVersion(v string) string { return strings.TrimPrefix(v, "v") } @@ -80,7 +35,7 @@ func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vu // 1. Try standard extraction (which prefers GIT ranges) w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) - slug := extractSlugFromOSVRefs(v.References) + slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) // 2. If we have Affected entries, update them with slug/ecosystem if missing. if len(v.Affected) > 0 { @@ -99,7 +54,7 @@ func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vu } if aff.Package == nil { aff.Package = &osvschema.Package{ - Ecosystem: string(osvconstants.EcosystemWordPress), + Ecosystem: ecosystem, Name: slug, } } @@ -163,7 +118,7 @@ func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vu if gotVersions { aff := &osvschema.Affected{ Package: &osvschema.Package{ - Ecosystem: string(osvconstants.EcosystemWordPress), + Ecosystem: ecosystem, Name: slug, }, Ranges: allRanges, diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go new file mode 100644 index 00000000000..33e4fce3529 --- /dev/null +++ b/vulnfeeds/conversion/cve5/wordpress.go @@ -0,0 +1,147 @@ +package cve5 + +import ( + "regexp" + "strings" + + "github.com/google/osv/vulnfeeds/models" + "github.com/google/osv/vulnfeeds/vulns" +) + +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] + if aff.CollectionURL == "https://wordpress.org/themes" { + ecosystem = "WordPress:Theme" + } else if aff.CollectionURL == "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 + if tracSlug != "" { + urlSlug = tracSlug + } else if svnSlug != "" { + urlSlug = svnSlug + } else if wordfenceSlug != "" { + urlSlug = wordfenceSlug + } else if wpOrgPluginSlug != "" { + urlSlug = wpOrgPluginSlug + } else if wpOrgThemeSlug != "" { + urlSlug = wpOrgThemeSlug + } else if patchstackPluginSlug != "" { + urlSlug = patchstackPluginSlug + } else if patchstackThemeSlug != "" { + urlSlug = patchstackThemeSlug + } else if patchstackVulnSlug != "" { + urlSlug = patchstackVulnSlug + } + + 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 +} diff --git a/vulnfeeds/conversion/cve5/wpscan_extractor.go b/vulnfeeds/conversion/cve5/wpscan_extractor.go new file mode 100644 index 00000000000..c5bd6c97ddf --- /dev/null +++ b/vulnfeeds/conversion/cve5/wpscan_extractor.go @@ -0,0 +1,105 @@ +package cve5 + +import ( + 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" +) + +// WPScanVersionExtractor provides version extraction logic for WPScan CVEs. +type WPScanVersionExtractor struct { + DefaultVersionExtractor +} + +var _ VersionExtractor = &WPScanVersionExtractor{} + +// ExtractVersions for WPScanVersionExtractor. +func (w *WPScanVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { + // 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 slug == "" { + metrics.AddNote("Could not extract slug for WPScan record") + // We might still want to assign the ecosystem if we can determine it + } + + // 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 { + continue + } + + 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. If default extraction didn't produce anything, try to generate ECOSYSTEM ranges. + if len(v.Affected) == 0 { + metrics.AddNote("Attempting to generate ECOSYSTEM ranges for WPScan") + + gotVersions := false + var allRanges []*osvschema.Range + + 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) + } + + // Description Fallback + 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 + } + } +} From 2b702fb17d907b6ad52b32a4c7b333b443fad27e Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Thu, 30 Jul 2026 01:49:27 +0000 Subject: [PATCH 5/6] add useful links --- .../conversion/cve5/patchstack_extractor.go | 28 +++++++++++++++++-- vulnfeeds/conversion/cve5/wordpress.go | 5 ++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/vulnfeeds/conversion/cve5/patchstack_extractor.go b/vulnfeeds/conversion/cve5/patchstack_extractor.go index 1813a5a7aee..58e79bdd7a0 100644 --- a/vulnfeeds/conversion/cve5/patchstack_extractor.go +++ b/vulnfeeds/conversion/cve5/patchstack_extractor.go @@ -1,6 +1,8 @@ package cve5 import ( + "slices" + c "github.com/google/osv/vulnfeeds/conversion" "github.com/google/osv/vulnfeeds/models" "github.com/google/osv/vulnfeeds/vulns" @@ -14,7 +16,6 @@ type PatchstackVersionExtractor struct { var _ VersionExtractor = &PatchstackVersionExtractor{} - // ExtractVersions for PatchstackVersionExtractor. func (p *PatchstackVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { // 1. Run default extraction first @@ -23,7 +24,30 @@ func (p *PatchstackVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.V // 2. Extract slug and determine ecosystem using shared helper slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) - if slug == "" { + 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) + } + } + } else { metrics.AddNote("Could not extract slug for Patchstack record") return } diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go index 33e4fce3529..e8d61ad51c0 100644 --- a/vulnfeeds/conversion/cve5/wordpress.go +++ b/vulnfeeds/conversion/cve5/wordpress.go @@ -45,9 +45,10 @@ func extractWordPressSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) ( // 3. Ecosystem Extraction from CollectionURL if len(cve.Containers.CNA.Affected) > 0 { aff := cve.Containers.CNA.Affected[0] - if aff.CollectionURL == "https://wordpress.org/themes" { + switch aff.CollectionURL { + case "https://wordpress.org/themes": ecosystem = "WordPress:Theme" - } else if aff.CollectionURL == "https://wordpress.org/plugins" { + case "https://wordpress.org/plugins": ecosystem = "WordPress:Plugin" } } From f27ff3a20a57d84b7ee33aeb9b3c1311b6efa2f9 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Thu, 30 Jul 2026 03:48:16 +0000 Subject: [PATCH 6/6] turn the wordpress cnas into handlers to unify extractors --- vulnfeeds/conversion/cve5/extraction.go | 6 +- .../conversion/cve5/patchstack_extractor.go | 124 ---------- .../cve5/version_extraction_test.go | 2 +- .../conversion/cve5/wordfence_extractor.go | 129 ---------- vulnfeeds/conversion/cve5/wordpress.go | 228 ++++++++++++++++-- vulnfeeds/conversion/cve5/wpscan_extractor.go | 105 -------- 6 files changed, 216 insertions(+), 378 deletions(-) delete mode 100644 vulnfeeds/conversion/cve5/patchstack_extractor.go delete mode 100644 vulnfeeds/conversion/cve5/wordfence_extractor.go delete mode 100644 vulnfeeds/conversion/cve5/wpscan_extractor.go diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index c8f472119c5..50be1f27998 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -17,11 +17,11 @@ func GetVersionExtractor(cna string) VersionExtractor { case "Linux": return &LinuxVersionExtractor{} case "Wordfence": - return &WordfenceVersionExtractor{} + return &WordpressExtractor{Handler: &WordfenceHandler{}} case "Patchstack": - return &PatchstackVersionExtractor{} + return &WordpressExtractor{Handler: &PatchstackHandler{}} case "WPScan": - return &WPScanVersionExtractor{} + return &WordpressExtractor{Handler: &WPScanHandler{}} default: return &DefaultVersionExtractor{} } diff --git a/vulnfeeds/conversion/cve5/patchstack_extractor.go b/vulnfeeds/conversion/cve5/patchstack_extractor.go deleted file mode 100644 index 58e79bdd7a0..00000000000 --- a/vulnfeeds/conversion/cve5/patchstack_extractor.go +++ /dev/null @@ -1,124 +0,0 @@ -package cve5 - -import ( - "slices" - - 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" -) - -// PatchstackVersionExtractor provides version extraction logic for Patchstack CVEs. -type PatchstackVersionExtractor struct { - DefaultVersionExtractor -} - -var _ VersionExtractor = &PatchstackVersionExtractor{} - -// ExtractVersions for PatchstackVersionExtractor. -func (p *PatchstackVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { - // 1. Run default extraction first - p.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) - - // 2. Extract slug and determine ecosystem using shared helper - slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) - - 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) - } - } - } else { - metrics.AddNote("Could not extract slug for Patchstack record") - return - } - - // 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 { - continue - } - - if aff.Package == nil { - aff.Package = &osvschema.Package{ - Ecosystem: ecosystem, - Name: slug, - } - } - } - } - - // 4. If default extraction didn't produce anything (e.g. no git ranges found), - // we still want to produce ECOSYSTEM ranges for WordPress. - // This mirrors Wordfence extractor logic. - if len(v.Affected) == 0 { - metrics.AddNote("Attempting to generate ECOSYSTEM ranges for Patchstack") - - gotVersions := false - var allRanges []*osvschema.Range - - for _, cveAff := range cve.Containers.CNA.Affected { - versionRanges, _ := p.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) - } - - // Description Fallback (if still no versions) - 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 - } - } -} diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 567396520f9..1e3238482e2 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -408,7 +408,7 @@ func TestGetVersionExtractor(t *testing.T) { AssignerShortName: "Wordfence", }, }, - expectedType: reflect.TypeOf(&WordfenceVersionExtractor{}), + expectedType: reflect.TypeOf(&WordpressExtractor{}), }, { name: "Default CVE", diff --git a/vulnfeeds/conversion/cve5/wordfence_extractor.go b/vulnfeeds/conversion/cve5/wordfence_extractor.go deleted file mode 100644 index 61f56573e6f..00000000000 --- a/vulnfeeds/conversion/cve5/wordfence_extractor.go +++ /dev/null @@ -1,129 +0,0 @@ -package cve5 - -import ( - "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" -) - -// WordfenceVersionExtractor provides version extraction logic for Wordfence CVEs. -type WordfenceVersionExtractor struct { - DefaultVersionExtractor -} - -var _ VersionExtractor = &WordfenceVersionExtractor{} - -func normalizeVersion(v string) string { - return strings.TrimPrefix(v, "v") -} - -// ExtractVersions for WordfenceVersionExtractor. -func (w *WordfenceVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { - // Normalize versions in cve first - 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) - } - } - - // 1. Try standard extraction (which prefers GIT ranges) - w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos) - - slug, ecosystem := extractWordPressSlugAndEcosystem(cve, v) - - // 2. If we have Affected entries, update them with slug/ecosystem if missing. - if len(v.Affected) > 0 { - if slug != "" { - 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 aff.Package == nil { - aff.Package = &osvschema.Package{ - Ecosystem: ecosystem, - Name: slug, - } - } - } - } - // Do not return early, we want both GIT and ECOSYSTEM ranges if they exist. - } - - // 3. We also want to produce ECOSYSTEM ranges if possible. - if slug == "" { - if len(v.Affected) == 0 { - metrics.AddNote("Failed to extract versions via default, and no WordPress slug found to attempt fallback") - } else { - metrics.AddNote("No WordPress slug found to attempt generating ECOSYSTEM ranges") - } - return - } - - metrics.AddNote("Attempting to generate ECOSYSTEM ranges for WordPress") - - gotVersions := false - var allRanges []*osvschema.Range - - 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) - } - - // CPE Fallback - 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 - } - } - - // Description Fallback - 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 - } -} diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go index e8d61ad51c0..21e3b0b521c 100644 --- a/vulnfeeds/conversion/cve5/wordpress.go +++ b/vulnfeeds/conversion/cve5/wordpress.go @@ -2,10 +2,13 @@ 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 ( @@ -96,22 +99,22 @@ func extractWordPressSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) ( } var urlSlug string - if tracSlug != "" { - urlSlug = tracSlug - } else if svnSlug != "" { - urlSlug = svnSlug - } else if wordfenceSlug != "" { - urlSlug = wordfenceSlug - } else if wpOrgPluginSlug != "" { - urlSlug = wpOrgPluginSlug - } else if wpOrgThemeSlug != "" { - urlSlug = wpOrgThemeSlug - } else if patchstackPluginSlug != "" { - urlSlug = patchstackPluginSlug - } else if patchstackThemeSlug != "" { - urlSlug = patchstackThemeSlug - } else if patchstackVulnSlug != "" { - urlSlug = patchstackVulnSlug + slugsToTry := []string{ + tracSlug, + svnSlug, + wordfenceSlug, + wpOrgPluginSlug, + wpOrgThemeSlug, + patchstackPluginSlug, + patchstackThemeSlug, + patchstackVulnSlug, + } + + for _, s := range slugsToTry { + if s != "" { + urlSlug = s + break + } } if slug == "" && urlSlug != "" { @@ -146,3 +149,196 @@ func extractWordPressSlugAndEcosystem(cve models.CVE5, v *vulns.Vulnerability) ( 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/conversion/cve5/wpscan_extractor.go b/vulnfeeds/conversion/cve5/wpscan_extractor.go deleted file mode 100644 index c5bd6c97ddf..00000000000 --- a/vulnfeeds/conversion/cve5/wpscan_extractor.go +++ /dev/null @@ -1,105 +0,0 @@ -package cve5 - -import ( - 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" -) - -// WPScanVersionExtractor provides version extraction logic for WPScan CVEs. -type WPScanVersionExtractor struct { - DefaultVersionExtractor -} - -var _ VersionExtractor = &WPScanVersionExtractor{} - -// ExtractVersions for WPScanVersionExtractor. -func (w *WPScanVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string) { - // 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 slug == "" { - metrics.AddNote("Could not extract slug for WPScan record") - // We might still want to assign the ecosystem if we can determine it - } - - // 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 { - continue - } - - 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. If default extraction didn't produce anything, try to generate ECOSYSTEM ranges. - if len(v.Affected) == 0 { - metrics.AddNote("Attempting to generate ECOSYSTEM ranges for WPScan") - - gotVersions := false - var allRanges []*osvschema.Range - - 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) - } - - // Description Fallback - 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 - } - } -}