From 08f3db6d410ab0df16d831ba3c4acbcc5940c6ee Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 2 Sep 2026 22:46:12 +0000 Subject: [PATCH 01/12] Strategy based refactor --- vulnfeeds/conversion/common.go | 104 ++- .../cve5/__snapshots__/converter_test.snap | 8 - .../conversion/cve5/default_extractor.go | 126 +-- vulnfeeds/conversion/cve5/extraction.go | 46 +- vulnfeeds/conversion/cve5/strategies.go | 728 +++++++++++++++++- .../cve5/version_extraction_test.go | 529 ++++++++++++- vulnfeeds/conversion/nvd/converter.go | 2 +- vulnfeeds/conversion/versions.go | 13 +- vulnfeeds/conversion/versions_test.go | 94 +++ vulnfeeds/models/cve.go | 1 + vulnfeeds/models/metrics.go | 8 + vulnfeeds/models/metrics_test.go | 33 + 12 files changed, 1565 insertions(+), 127 deletions(-) create mode 100644 vulnfeeds/models/metrics_test.go diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 4077f41a701..2f916e17a21 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -208,7 +208,7 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri if err != nil { metrics.AddNote("Failed to find canonical link - %s %v", repo, err) if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } @@ -217,11 +217,11 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri normalizedTags, err := git.NormalizeRepoTags(repo, cache, httpClient) if err != nil { + metrics.AddNote("Failed to normalize tags - %s: %v", repo, err) if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } - metrics.AddNote("Failed to normalize tags - %s", repo) continue } @@ -728,21 +728,105 @@ func AddFieldToDatabaseSpecific(ds *structpb.Struct, field string, value any) er return nil } +// IsGitCommitSHA checks whether a string is a valid 40-character (SHA-1) or 64-character (SHA-256) hexadecimal Git commit hash. +func IsGitCommitSHA(s string) bool { + s = strings.TrimSpace(s) + if len(s) != 40 && len(s) != 64 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + + return true +} + +// IsDirectGitRange determines if a range is already composed of Git commit hashes rather than tag/version names. +func IsDirectGitRange(vr models.RangeWithMetadata) bool { + if vr.Range == nil { + return false + } + events := vr.Range.GetEvents() + if len(events) == 0 { + return false + } + + hasCommit := false + for _, e := range events { + intro := e.GetIntroduced() + if intro != "" && intro != "0" { + if !IsGitCommitSHA(intro) { + return false + } + hasCommit = true + } + fixed := e.GetFixed() + if fixed != "" { + if !IsGitCommitSHA(fixed) { + return false + } + hasCommit = true + } + lastAffected := e.GetLastAffected() + if lastAffected != "" { + if !IsGitCommitSHA(lastAffected) { + return false + } + hasCommit = true + } + } + + return hasCommit +} + // ProcessRanges attempts to resolve the given ranges to commits and updates the metrics accordingly. func ProcessRanges(ranges []models.RangeWithMetadata, repos []string, metrics *models.ConversionMetrics, cache git.RepoTagsCache, httpClient *http.Client) ([]models.RangeWithMetadata, []models.RangeWithMetadata, []string) { if len(ranges) == 0 { return nil, nil, nil } - r, un, sR := GitVersionsToCommits(ranges, repos, metrics, cache, httpClient) - if len(r) > 0 { - metrics.ResolvedRangesCount += len(r) + var resolvedRanges []models.RangeWithMetadata + var unresolvedRanges []models.RangeWithMetadata + var successfulRepos []string + var tagVersionRanges []models.RangeWithMetadata + + for _, vr := range ranges { + if IsDirectGitRange(vr) { + repo := vr.Range.GetRepo() + if repo == "" && len(repos) > 0 { + repo = repos[0] + } + if repo != "" { + vr.Range.Repo = repo + vr.Range.Type = osvschema.Range_GIT + resolvedRanges = append(resolvedRanges, vr) + successfulRepos = append(successfulRepos, repo) + } else { + metrics.AddNote("no repository available for git commit range") + unresolvedRanges = append(unresolvedRanges, vr) + } + } else { + tagVersionRanges = append(tagVersionRanges, vr) + } + } + + if len(tagVersionRanges) > 0 { + r, un, sR := GitVersionsToCommits(tagVersionRanges, repos, metrics, cache) + resolvedRanges = append(resolvedRanges, r...) + unresolvedRanges = append(unresolvedRanges, un...) + successfulRepos = append(successfulRepos, sR...) + } + + if len(resolvedRanges) > 0 { + metrics.ResolvedRangesCount += len(resolvedRanges) metrics.SetOutcome(models.Successful) } - if len(un) > 0 { - metrics.UnresolvedRangesCount += len(un) - if len(r) == 0 { + if len(unresolvedRanges) > 0 { + metrics.UnresolvedRangesCount += len(unresolvedRanges) + if len(resolvedRanges) == 0 { metrics.SetOutcome(models.NoCommitRanges) } } @@ -759,7 +843,7 @@ func ProcessRanges(ranges []models.RangeWithMetadata, repos []string, metrics *m } } - return r, un, sR + return resolvedRanges, unresolvedRanges, successfulRepos } func LoadCPEDictionary(productToRepo *VPRepoCache, f string) error { diff --git a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap index 43f67333411..c26ce70abd3 100755 --- a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap @@ -2010,14 +2010,6 @@ } ], "source": "AFFECTED_FIELD" - }, - { - "extracted_events": [ - { - "fixed": "7.61.1" - } - ], - "source": "DESCRIPTION" } ] }, diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index 7a44fa6ca1a..121c93a6ed0 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -1,10 +1,10 @@ package cve5 import ( + "cmp" "maps" "net/http" "slices" - "strings" c "github.com/google/osv.dev/vulnfeeds/conversion" "github.com/google/osv.dev/vulnfeeds/git" @@ -12,10 +12,28 @@ import ( "github.com/google/osv.dev/vulnfeeds/utility/logger" "github.com/google/osv.dev/vulnfeeds/vulns" "github.com/ossf/osv-schema/bindings/go/osvschema" + "google.golang.org/protobuf/types/known/structpb" ) -// DefaultVersionExtractor provides the default version extraction logic. -type DefaultVersionExtractor struct{} +// DefaultVersionExtractor provides version extraction logic using a configurable pipeline of strategies. +type DefaultVersionExtractor struct { + Strategies []VersionStrategy +} + +func (d *DefaultVersionExtractor) getStrategies() []VersionStrategy { + var strategies []VersionStrategy + if len(d.Strategies) > 0 { + strategies = slices.Clone(d.Strategies) + } else { + strategies = DefaultStrategies() + } + + slices.SortStableFunc(strategies, func(a, b VersionStrategy) int { + return cmp.Compare(a.Priority(), b.Priority()) + }) + + return strategies +} func (d *DefaultVersionExtractor) handleAffected(affected []models.Affected, metrics *models.ConversionMetrics) []models.RangeWithMetadata { var ranges []models.RangeWithMetadata @@ -63,6 +81,35 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln } } + addUnresolvedRanges := func(unRanges []models.RangeWithMetadata) { + if len(unRanges) == 0 { + return + } + if v.DatabaseSpecific == nil { + v.DatabaseSpecific = &structpb.Struct{Fields: make(map[string]*structpb.Value)} + } else if v.DatabaseSpecific.Fields == nil { + v.DatabaseSpecific.Fields = make(map[string]*structpb.Value) + } + unresolvedRangesList := c.CreateUnresolvedRanges(unRanges) + if err := c.AddFieldToDatabaseSpecific(v.DatabaseSpecific, "unresolved_ranges", unresolvedRangesList); err != nil { + logger.Warn("failed to make database specific: %v", err) + } + } + + // Exit early if no repositories are available to resolve remaining versions. + if len(repos) == 0 && !gotVersions { + metrics.SetOutcome(models.NoRepos) + metrics.Outcome = models.NoRepos + if len(unresolvedRanges) > 0 { + addUnresolvedRanges(unresolvedRanges) + } else if len(ranges) > 0 { + metrics.UnresolvedRangesCount += len(ranges) + addUnresolvedRanges(ranges) + } + + return + } + if !gotVersions { metrics.AddNote("No versions in affected, attempting to extract from CPE") versionRanges, _ := cpeVersionExtraction(cve, metrics) @@ -90,73 +137,26 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln affected := c.MergeRangesAndCreateAffected(groupedRanges, nil, keys, metrics) v.Affected = append(v.Affected, affected...) - if len(unresolvedRanges) > 0 { - unresolvedRangesList := c.CreateUnresolvedRanges(unresolvedRanges) - if err := c.AddFieldToDatabaseSpecific(v.DatabaseSpecific, "unresolved_ranges", unresolvedRangesList); err != nil { - logger.Warn("failed to make database specific: %v", err) - } - } + addUnresolvedRanges(unresolvedRanges) } func (d *DefaultVersionExtractor) FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) { versionTypesCount := make(map[VersionRangeType]int) var versionRanges []models.RangeWithMetadata - for _, vers := range affected.Versions { - ranges, _, shouldContinue := initialNormalExtraction(vers, metrics, versionTypesCount) - if len(ranges) > 0 { - versionRanges = append(versionRanges, c.ToRangeWithMetadata(ranges, models.VersionSourceAffected)...) - } - - if shouldContinue { - continue - } - // In this case only vers.Version exists which either means that it is _only_ that version that is - // affected, but more likely, it affects up to that version. It could also mean that the range is given - // in one line instead - like "< 1.5.3" or "< 2.45.4, >= 2.0 " or just "before 1.4.7", so check for that. - metrics.AddNote("Only version exists") - - av, err := git.ParseVersionRange(vers.Version) - if err == nil { - if av.Introduced == "" { - continue - } - - if av.Fixed != "" { - vr := []*osvschema.Range{c.BuildVersionRange(av.Introduced, "", av.Fixed)} - versionRanges = append(versionRanges, c.ToRangeWithMetadata(vr, models.VersionSourceAffected)...) - - continue - } else if av.LastAffected != "" { - vr := []*osvschema.Range{c.BuildVersionRange(av.Introduced, av.LastAffected, "")} - versionRanges = append(versionRanges, c.ToRangeWithMetadata(vr, models.VersionSourceAffected)...) + strategies := d.getStrategies() - continue - } - } - - // Try to extract versions from text like "before 1.4.7". - possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) - - if possibleVersions != nil { - metrics.AddNote("Versions retrieved from text but not used CURRENTLY") - continue - } - - // As a fallback, treat a single version as a standalone version. - if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - var vr []*osvschema.Range - if strings.EqualFold(metrics.CNA, "mitre") && len(affected.Versions) == 1 { - vr = []*osvschema.Range{c.BuildVersionRange("", vers.Version, "")} - metrics.AddNote("Single version found %v for MITRE - Setting only last_affected", vers.Version) - } else { - vr = []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} - metrics.AddNote("Single version found %v - Treating as standalone version", vers.Version) - } - rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) - for i := range rwms { - rwms[i].Metadata.Versions = []string{vers.Version} + for _, vers := range affected.Versions { + for _, strategy := range strategies { + ranges, currentVersionType, handled := strategy.Extract(vers, affected, metrics) + if handled { + if len(ranges) > 0 { + metrics.AddNote("Strategy successful: %s", strategy.Name()) + versionTypesCount[currentVersionType]++ + versionRanges = append(versionRanges, ranges...) + } + + break } - versionRanges = append(versionRanges, rwms...) } } diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index bb443241183..e00d3ce16f3 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -2,6 +2,7 @@ package cve5 import ( "net/http" + "strings" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" @@ -14,12 +15,47 @@ type VersionExtractor interface { FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) } -// GetVersionExtractor returns the appropriate VersionExtractor for a given CNA. +// GetVersionExtractor returns the appropriate VersionExtractor configured with CNA-specific strategies. func GetVersionExtractor(cna string) VersionExtractor { - switch cna { - case "Linux": - return &LinuxVersionExtractor{} + switch strings.ToLower(cna) { + case "linux": + return &LinuxVersionExtractor{ + DefaultVersionExtractor: DefaultVersionExtractor{ + Strategies: LinuxStrategies(), + }, + } + case "wordfence": + return &WordpressExtractor{ + Handler: &WordfenceHandler{}, + DefaultVersionExtractor: DefaultVersionExtractor{ + Strategies: WordfenceStrategies(), + }, + } + case "patchstack": + return &WordpressExtractor{ + Handler: &PatchstackHandler{}, + DefaultVersionExtractor: DefaultVersionExtractor{ + Strategies: PatchstackStrategies(), + }, + } + case "wpscan": + return &WordpressExtractor{ + Handler: &WPScanHandler{}, + DefaultVersionExtractor: DefaultVersionExtractor{ + Strategies: WPScanStrategies(), + }, + } + case "github_m", "github": + return &DefaultVersionExtractor{ + Strategies: GitHubStrategies(), + } + case "mitre": + return &DefaultVersionExtractor{ + Strategies: MITREStrategies(), + } default: - return &DefaultVersionExtractor{} + return &DefaultVersionExtractor{ + Strategies: DefaultStrategies(), + } } } diff --git a/vulnfeeds/conversion/cve5/strategies.go b/vulnfeeds/conversion/cve5/strategies.go index 5a6de90fb6c..3804ce3a681 100644 --- a/vulnfeeds/conversion/cve5/strategies.go +++ b/vulnfeeds/conversion/cve5/strategies.go @@ -1,48 +1,140 @@ package cve5 import ( + "strings" + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/vulns" "github.com/ossf/osv-schema/bindings/go/osvschema" ) -func cpeVersionExtraction(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { - cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) - if err == nil && len(cpeRanges) > 0 { - metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) - metrics.CPEs = vulns.Unique(cpeStrings) +// StrategyPriority represents the execution precedence for version extraction strategies. +type StrategyPriority int - return cpeRanges, nil - } else if err != nil { - metrics.AddNote("%s", err.Error()) +const ( + // PriorityFirst represents specialized or CNA-specific overrides that should be attempted before standard parsing (e.g., changes field, split range syntax). + PriorityFirst StrategyPriority = 100 + + // PriorityStandard represents standard structured CVE5 version range parsing rules that should always be attempted (e.g., lessThan/lessThanOrEqual, range expressions, git commits). + PriorityStandard StrategyPriority = 200 + + // PriorityLastResort represents heuristic or single-version fallbacks to be attempted only when standard range parsing cannot resolve the versions (e.g., 0-introduced, MITRE up-to, standalone single versions, text extraction). + PriorityLastResort StrategyPriority = 300 +) + +// VersionStrategy defines the contract for an individual version extraction strategy. +type VersionStrategy interface { + // Name returns a human-readable identifier for the strategy. + Name() string + // Priority returns the execution precedence of this strategy. Lower values run first. + Priority() StrategyPriority + // Extract attempts to extract OSV version ranges from a CVE5 Versions entry. + // Returns the extracted ranges, the detected VersionRangeType, and true if this strategy handled the entry. + Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) (ranges []models.RangeWithMetadata, vrt VersionRangeType, handled bool) +} + +type prioritizedStrategy struct { + VersionStrategy + + customPriority StrategyPriority +} + +func (p *prioritizedStrategy) Priority() StrategyPriority { + return p.customPriority +} + +// WithPriority wraps an existing VersionStrategy with a customized priority level. +func WithPriority(strategy VersionStrategy, priority StrategyPriority) VersionStrategy { + return &prioritizedStrategy{ + VersionStrategy: strategy, + customPriority: priority, } +} - return nil, err +// ChangesAtStrategy extracts the fixed version from the vers.Changes list when status is 'unaffected'. +// +// Resulting OSV Range: [introduced: "17.7.0", fixed: "17.7.2"] +type ChangesAtStrategy struct{} + +func (s *ChangesAtStrategy) Name() string { + return "ChangesAt" } -// initialNormalExtraction handles an expected case of version ranges in the affected field of CVE5 -func initialNormalExtraction(vers models.Versions, metrics *models.ConversionMetrics, versionTypesCount map[VersionRangeType]int) ([]*osvschema.Range, VersionRangeType, bool) { +func (s *ChangesAtStrategy) Priority() StrategyPriority { + return PriorityFirst +} + +func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, true + return nil, VersionRangeTypeUnknown, false + } + + var fixedFromChanges string + for _, ch := range vers.Changes { + if ch.Status == "unaffected" && ch.At != "" { + fixedFromChanges = ch.At + break + } + } + + if fixedFromChanges == "" { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Fixed from changes - %s", fixedFromChanges) + var introduced string + if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNote("Introduced from version value - %s", vers.Version) } currentVersionType := toVersionRangeType(vers.VersionType) - versionTypesCount[currentVersionType]++ + vr := []*osvschema.Range{c.BuildVersionRange(introduced, "", fixedFromChanges)} - var introduced, fixed, lastaffected string + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} + +// StandardRangeStrategy handles standard CVE 5.0 'lessThan' and 'lessThanOrEqual' range fields. +// +// Example CVE Record (CVE-2026-22104 - Hashtopolis): +// +// { +// "version": "0", +// "lessThan": "0.14.8", +// "status": "affected", +// "versionType": "semver" +// } +// +// Resulting OSV Range: [introduced: "0", fixed: "0.14.8"] +type StandardRangeStrategy struct{} + +func (s *StandardRangeStrategy) Name() string { + return "StandardRange" +} + +func (s *StandardRangeStrategy) Priority() StrategyPriority { + return PriorityStandard +} + +func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + + currentVersionType := toVersionRangeType(vers.VersionType) - // Quality check the version strings to avoid using filler content. vQuality := vulns.CheckQuality(vers.Version) if !vQuality.AtLeast(acceptableQuality) { - metrics.AddNote("Version value for is filler or empty") + metrics.AddNote("Version value is filler or empty") } vLessThanQual := vulns.CheckQuality(vers.LessThan) vLTOEQual := vulns.CheckQuality(vers.LessThanOrEqual) hasRange := vLessThanQual.AtLeast(acceptableQuality) || vLTOEQual.AtLeast(acceptableQuality) - // Handle cases where 'lessThan' is mistakenly the same as 'version'. + // Handle cases where 'lessThan' or 'lessThanOrEqual' is mistakenly the same as 'version'. if vers.LessThan != "" && vers.LessThan == vers.Version { metrics.AddNote("Warning: lessThan (%s) is the same as introduced (%s)\n", vers.LessThan, vers.Version) hasRange = false @@ -52,28 +144,598 @@ func initialNormalExtraction(vers models.Versions, metrics *models.ConversionMet hasRange = false } + if !hasRange { + return nil, VersionRangeTypeUnknown, false + } + metrics.AddNote("Range detected: %v", hasRange) - if hasRange { - if vQuality.AtLeast(acceptableQuality) { - introduced = vers.Version - metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) + var introduced, fixed, lastaffected string + if vQuality.AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) + } + + if vLessThanQual.AtLeast(acceptableQuality) { + fixed = vers.LessThan + metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) + } else if vLTOEQual.AtLeast(acceptableQuality) { + lastaffected = vers.LessThanOrEqual + metrics.AddNote("%s - LastAffected from LessThanOrEqual value - %s", vLTOEQual.String(), vers.LessThanOrEqual) + } + + var versionRanges []*osvschema.Range + if fixed != "" { + versionRanges = append(versionRanges, c.BuildVersionRange(introduced, "", fixed)) + } else if lastaffected != "" { + versionRanges = append(versionRanges, c.BuildVersionRange(introduced, lastaffected, "")) + } + + if len(versionRanges) == 0 { + return nil, VersionRangeTypeUnknown, false + } + + for _, vr := range versionRanges { + if currentVersionType == VersionRangeTypeGit { + vr.Type = osvschema.Range_GIT + if affected.Repo != "" { + vr.Repo = affected.Repo + } + } + } + + return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), currentVersionType, true +} + +// StringRangeExpressionStrategy handles range expressions embedded within the version field. +// +// Example CVE Record (CVE-2024-21634 - Puma / GitHub_M): +// +// { +// "version": ">= 2.0, < 2.5", +// "status": "affected", +// "versionType": "semver" +// } +// +// Resulting OSV Range: [introduced: "2.0", fixed: "2.5"] +type StringRangeExpressionStrategy struct{} + +func (s *StringRangeExpressionStrategy) Name() string { + return "StringRangeExpression" +} + +func (s *StringRangeExpressionStrategy) Priority() StrategyPriority { + return PriorityStandard +} + +func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + + av, err := git.ParseVersionRange(vers.Version) + if err != nil || av.Introduced == "" { + return nil, VersionRangeTypeUnknown, false + } + + currentVersionType := toVersionRangeType(vers.VersionType) + var vr []*osvschema.Range + if av.Fixed != "" { + vr = append(vr, c.BuildVersionRange(av.Introduced, "", av.Fixed)) + } else if av.LastAffected != "" { + vr = append(vr, c.BuildVersionRange(av.Introduced, av.LastAffected, "")) + } + + if len(vr) == 0 { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Parsed range expression from version: %s", vers.Version) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} + +// GitCommitStrategy handles git commit versions by treating them as standalone git commits. +// +// Example CVE Record: +// +// { +// "version": "deadbeefcafebabe0123456789abcdef01234567", +// "status": "affected", +// "versionType": "git" +// } +// +// Resulting OSV Range: [introduced: "deadbeef...", last_affected: "deadbeef..."] +type GitCommitStrategy struct{} + +func (s *GitCommitStrategy) Name() string { + return "GitCommit" +} + +func (s *GitCommitStrategy) Priority() StrategyPriority { + return PriorityStandard +} + +func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + if toVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Git commit version found %v", vers.Version) + vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, vers.Version, "", affected.Repo)} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceGit) + for i := range rwms { + rwms[i].Metadata.Versions = []string{vers.Version} + } + + return rwms, VersionRangeTypeGit, true +} + +// GitCommitIntroducedOnlyStrategy treats a git commit version as an introduced-only point (used by Linux kernel). +// +// Example CVE Record (Linux Kernel git commits): +// +// { +// "version": "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", +// "status": "affected", +// "versionType": "git" +// } +// +// Resulting OSV Range: [introduced: "1da177e4c..."] +type GitCommitIntroducedOnlyStrategy struct{} + +func (s *GitCommitIntroducedOnlyStrategy) Name() string { + return "GitCommitIntroducedOnly" +} + +func (s *GitCommitIntroducedOnlyStrategy) Priority() StrategyPriority { + return PriorityStandard +} + +func (s *GitCommitIntroducedOnlyStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + if toVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Git commit introduced found %v", vers.Version) + vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, "", "", affected.Repo)} + + return c.ToRangeWithMetadata(vr, models.VersionSourceGit), VersionRangeTypeGit, true +} + +// AffectedCPEStrategy extracts version ranges from CPE strings specified in affected.Cpes or version strings formatted as CPEs. +// +// Example CVE Record: +// +// "affected": [ +// { +// "cpes": ["cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"], +// "versions": [{ "status": "affected" }] +// } +// ] +// +// Resulting OSV Range: [introduced: "1.2.3", last_affected: "1.2.3"] +type AffectedCPEStrategy struct{} + +func (s *AffectedCPEStrategy) Name() string { + return "AffectedCPE" +} + +func (s *AffectedCPEStrategy) Priority() StrategyPriority { + return PriorityLastResort +} + +func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + cpeStr := "" + if strings.HasPrefix(vers.Version, "cpe:") { + cpeStr = vers.Version + } else if len(affected.Cpes) > 0 { + for _, cpe := range affected.Cpes { + if strings.HasPrefix(cpe, "cpe:") { + cpeStr = cpe + break + } } - if vLessThanQual.AtLeast(acceptableQuality) { - fixed = vers.LessThan - metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) - } else if vLTOEQual.AtLeast(acceptableQuality) { - lastaffected = vers.LessThanOrEqual - metrics.AddNote("%s - LastAffected from LessThanOrEqual value- %s", vLTOEQual.String(), vers.LessThanOrEqual) + } + + if cpeStr == "" { + return nil, VersionRangeTypeUnknown, false + } + + parsedCPE, err := c.ParseCPE(cpeStr) + if err != nil || parsedCPE.Version == "" || parsedCPE.Version == "*" || parsedCPE.Version == "-" || parsedCPE.Version == "ANY" || parsedCPE.Version == "NA" { + return nil, VersionRangeTypeUnknown, false + } + + version := parsedCPE.Version + if parsedCPE.Update != "" && parsedCPE.Update != "*" && parsedCPE.Update != "-" && parsedCPE.Update != "ANY" && parsedCPE.Update != "NA" { + version += "." + parsedCPE.Update + } + + if !vulns.CheckQuality(version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Extracted version %s from CPE %s", version, cpeStr) + currentVersionType := toVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange(version, version, "")} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) + for i := range rwms { + rwms[i].Metadata.CPE = cpeStr + rwms[i].Metadata.Versions = []string{version} + } + + return rwms, currentVersionType, true +} + +// ZeroIntroducedSingleVersionStrategy treats a single version value (when only 1 version is listed) +// as spanning from 0 to that version (e.g. WPScan, Wordfence, Linux, or single-version MITRE records). +// +// Example CVE Record (CVE-2015-10001 - WPScan / Wordfence / MITRE): +// +// { +// "version": "2.52", +// "status": "affected", +// "versionType": "custom" +// } +// +// Resulting OSV Range: [introduced: "0", last_affected: "2.52"] +type ZeroIntroducedSingleVersionStrategy struct{} + +func (s *ZeroIntroducedSingleVersionStrategy) Name() string { + return "ZeroIntroducedSingleVersion" +} + +func (s *ZeroIntroducedSingleVersionStrategy) Priority() StrategyPriority { + return PriorityLastResort +} + +func (s *ZeroIntroducedSingleVersionStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + if len(affected.Versions) > 1 { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) + currentVersionType := toVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} + +// StandaloneSingleVersionStrategy treats a single version as an exact, standalone version (introduced == last_affected). +// +// Example CVE Record: +// +// { +// "version": "1.0.0", +// "status": "affected" +// } +// +// Resulting OSV Range: [introduced: "1.0.0", last_affected: "1.0.0"] +type StandaloneSingleVersionStrategy struct{} + +func (s *StandaloneSingleVersionStrategy) Name() string { + return "StandaloneSingleVersion" +} + +func (s *StandaloneSingleVersionStrategy) Priority() StrategyPriority { + return PriorityLastResort +} + +func (s *StandaloneSingleVersionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Single version found %v - Treating as standalone version", vers.Version) + currentVersionType := toVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) + for i := range rwms { + rwms[i].Metadata.Versions = []string{vers.Version} + } + + return rwms, currentVersionType, true +} + +// SplitRangeStrategy handles split sequential version ranges +// (e.g. one entry with introduced and lessThan="unspecified", followed by an entry +// with version="unspecified" and lessThan/lessThanOrEqual). +// +// Example 1: Split Pair (CVE-2022-25929): +// +// "versions": [ +// { "version": "1.31.0", "lessThan": "unspecified", "status": "affected" }, +// { "version": "unspecified", "lessThan": "1.36.1", "status": "affected" } +// ] +// Resulting OSV Range: [introduced: "1.31.0", fixed: "1.36.1"] +// +// Example 2: Multi Split Sequences (CVE-2022-25761): +// +// "versions": [ +// { "version": "unspecified", "lessThan": "1.2.5", "status": "affected" }, +// { "version": "1.3-rc1", "lessThan": "unspecified", "status": "affected" }, +// { "version": "unspecified", "lessThan": "1.3.1", "status": "affected" } +// ] +// Resulting OSV Ranges: [introduced: "0", fixed: "1.2.5"], [introduced: "1.3-rc1", fixed: "1.3.1"] +// +// Example 3: Standalone Upper Bound (CVE-2022-25865): +// +// "versions": [ +// { "version": "unspecified", "lessThan": "0.18.4", "status": "affected" } +// ] +// Resulting OSV Range: [introduced: "0", fixed: "0.18.4"] +type SplitRangeStrategy struct{} + +func (s *SplitRangeStrategy) Name() string { + return "SplitRange" +} + +func (s *SplitRangeStrategy) Priority() StrategyPriority { + return PriorityFirst +} + +func isExplicitUnspecified(val string) bool { + clean := strings.TrimSpace(strings.ToLower(val)) + + return clean == "unspecified" +} + +func isValidSplitVersion(val string) bool { + clean := strings.TrimSpace(strings.ToLower(val)) + if clean == "" || clean == "unspecified" || clean == "n/a" || clean == "na" || clean == "*" || clean == "-" { + return false + } + + return vulns.CheckQuality(val).AtLeast(acceptableQuality) || val == "0" +} + +func isSplitIntroducedOnly(v models.Versions) bool { + return isValidSplitVersion(v.Version) && (isExplicitUnspecified(v.LessThan) || isExplicitUnspecified(v.LessThanOrEqual)) +} + +func isSplitUpperBoundOnly(v models.Versions) bool { + return isExplicitUnspecified(v.Version) && (isValidSplitVersion(v.LessThan) || isValidSplitVersion(v.LessThanOrEqual)) +} + +func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + + // Check for "unspecified" split or standalone ranges across affected.Versions + idx := -1 + for i, v := range affected.Versions { + if v.Version == vers.Version && v.LessThan == vers.LessThan && v.LessThanOrEqual == vers.LessThanOrEqual && v.Status == vers.Status && v.VersionType == vers.VersionType { + idx = i + break +} + } + + // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) + if isSplitIntroducedOnly(vers) { + currentVersionType := toVersionRangeType(vers.VersionType) + // Check if the immediately following entry is an upper-bound entry + if idx >= 0 && idx+1 < len(affected.Versions) && isSplitUpperBoundOnly(affected.Versions[idx+1]) { + nextVers := affected.Versions[idx+1] + var fixed, lastAffected string + if isValidSplitVersion(nextVers.LessThan) { + fixed = nextVers.LessThan + } else if isValidSplitVersion(nextVers.LessThanOrEqual) { + lastAffected = nextVers.LessThanOrEqual + } + + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} + metrics.AddNote("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + // Standalone introduced entry without a following upper bound + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} + metrics.AddNote("Parsed standalone introduced version: %s", vers.Version) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + // Case B: Upper-bound-only entry (e.g. {version: "unspecified", lessThan: "1.36.1"}) + if isSplitUpperBoundOnly(vers) { + currentVersionType := toVersionRangeType(vers.VersionType) + // If preceded by an introduced-only entry, it was already paired and consumed in Case A + if idx > 0 && isSplitIntroducedOnly(affected.Versions[idx-1]) { + return nil, currentVersionType, true } - var versionRanges []*osvschema.Range - if fixed != "" { - versionRanges = append(versionRanges, c.BuildVersionRange(introduced, "", fixed)) - } else if lastaffected != "" { - versionRanges = append(versionRanges, c.BuildVersionRange(introduced, lastaffected, "")) + + // Standalone upper bound starting from 0 + var fixed, lastAffected string + if isValidSplitVersion(vers.LessThan) { + fixed = vers.LessThan + } else if isValidSplitVersion(vers.LessThanOrEqual) { + lastAffected = vers.LessThanOrEqual } - return versionRanges, currentVersionType, true + vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} + metrics.AddNote("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + return nil, VersionRangeTypeUnknown, false +} + +// VersionTextExtractionStrategy handles natural text version patterns. +// +// Example CVE Record: +// +// { +// "version": "Fixed in version 2.4.1 and higher", +// "status": "affected" +// } +type VersionTextExtractionStrategy struct{} + +func (s *VersionTextExtractionStrategy) Name() string { + return "VersionTextExtraction" +} + +func (s *VersionTextExtractionStrategy) Priority() StrategyPriority { + return PriorityLastResort +} + +func (s *VersionTextExtractionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + + possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) + if possibleVersions != nil { + metrics.AddNote("Versions retrieved from text but not used CURRENTLY") } return nil, VersionRangeTypeUnknown, false } + +// CPEVersionStrategy extracts version ranges from the CVE's CPE applicability statements. +// +// Example CVE Record: +// +// "cpeApplicability": [ +// { +// "nodes": [{ +// "operator": "OR", +// "cpeMatch": [{ +// "vulnerable": true, +// "criteria": "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", +// "versionStartIncluding": "1.0.0", +// "versionEndExcluding": "2.0.0" +// }] +// }] +// } +// ] +// +// Resulting OSV Range: [introduced: "1.0.0", fixed: "2.0.0"] +type CPEVersionStrategy struct{} + +func (s *CPEVersionStrategy) Name() string { + return "CPEApplicability" +} + +func (s *CPEVersionStrategy) Priority() StrategyPriority { + return PriorityStandard +} + +func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { + cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) + if err == nil && len(cpeRanges) > 0 { + metrics.AddNote("Strategy successful: %s", s.Name()) + metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) + metrics.CPEs = vulns.Unique(cpeStrings) + + return cpeRanges, nil + } else if err != nil { + metrics.AddNote("%s", err.Error()) + } + + return nil, err +} + +// Strategy Pipeline Presets for different CNAs + +func DefaultStrategies() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &VersionTextExtractionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func GitHubStrategies() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func WPScanStrategies() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func WordfenceStrategies() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func PatchstackStrategies() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func MITREStrategies() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func LinuxStrategies() []VersionStrategy { + return []VersionStrategy{ + &StandardRangeStrategy{}, + &GitCommitIntroducedOnlyStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func cpeVersionExtraction(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { + return (&CPEVersionStrategy{}).Extract(cve, metrics) +} diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 1a4d7b36efa..5f93138fa55 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -1,8 +1,10 @@ package cve5 import ( + "path/filepath" "reflect" "sort" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -147,15 +149,93 @@ func TestFindNormalAffectedRanges(t *testing.T) { }, }, wantRanges: []*osvschema.Range{ - conversion.BuildVersionRange("deadbeef", "deadbeef", ""), + conversion.BuildGitVersionRange("deadbeef", "deadbeef", "", ""), }, 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, + }, + { + name: "split range pair (CVE-2022-25929)", + affected: models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.31.0", + LessThan: "unspecified", + VersionType: "custom", + }, + { + Status: "affected", + Version: "unspecified", + LessThan: "1.36.1", + VersionType: "custom", + }, + }, + }, + wantRanges: []*osvschema.Range{ + conversion.BuildVersionRange("1.31.0", "", "1.36.1"), + }, + wantRangeType: VersionRangeTypeEcosystem, + }, + { + name: "multi split range (CVE-2022-25761)", + affected: models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "unspecified", + LessThan: "1.2.5", + VersionType: "custom", + }, + { + Status: "affected", + Version: "1.3-rc1", + LessThan: "unspecified", + VersionType: "custom", + }, + { + Status: "affected", + Version: "unspecified", + LessThan: "1.3.1", + VersionType: "custom", + }, + }, + }, + wantRanges: []*osvschema.Range{ + conversion.BuildVersionRange("0", "", "1.2.5"), + conversion.BuildVersionRange("1.3-rc1", "", "1.3.1"), + }, + wantRangeType: VersionRangeTypeEcosystem, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - versionExtractor := &DefaultVersionExtractor{} + var versionExtractor VersionExtractor + if tt.cnaAssigner != "" { + versionExtractor = GetVersionExtractor(tt.cnaAssigner) + } else { + versionExtractor = &DefaultVersionExtractor{} + } gotRangesWithMeta, gotRangeType := versionExtractor.FindNormalAffectedRanges(tt.affected, &models.ConversionMetrics{CNA: tt.cnaAssigner}) var gotRanges []*osvschema.Range for _, r := range gotRangesWithMeta { @@ -409,6 +489,313 @@ func TestGetVersionExtractor(t *testing.T) { } } +func TestStrategies(t *testing.T) { + t.Parallel() + + t.Run("StandardRangeStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StandardRangeStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StandardRangeStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.5.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("ChangesAtStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &ChangesAtStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + Changes: []models.Change{ + {Status: "unaffected", At: "1.0.1"}, + }, + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("ChangesAtStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.0.1" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("StringRangeExpressionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StringRangeExpressionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: ">= 1.2.0, < 2.0.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StringRangeExpressionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.2.0" || events[1].GetFixed() != "2.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("ZeroIntroducedSingleVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &ZeroIntroducedSingleVersionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "2.52", + VersionType: "custom", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeEcosystem || len(ranges) != 1 { + t.Fatalf("ZeroIntroducedSingleVersionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "0" || events[1].GetLastAffected() != "2.52" { + t.Errorf("unexpected events: %+v", events) + } + + // Should not handle if multiple versions are listed + multiAffected := models.Affected{ + Versions: []models.Versions{ + {Status: "affected", Version: "3.0.0"}, + {Status: "affected", Version: "3.1.0"}, + }, + } + _, _, handledMulti := strategy.Extract(multiAffected.Versions[0], multiAffected, metrics) + if handledMulti { + t.Errorf("ZeroIntroducedSingleVersionStrategy should not handle multiple versions") + } + }) + + t.Run("SplitRangeStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &SplitRangeStrategy{} + + // 1. Split unspecified pair (e.g., CVE-2022-25929) + affectedSplit := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.31.0", + LessThan: "unspecified", + VersionType: "custom", + }, + { + Status: "affected", + Version: "unspecified", + LessThan: "1.36.1", + VersionType: "custom", + }, + }, + } + + ranges1, _, handled1 := strategy.Extract(affectedSplit.Versions[0], affectedSplit, metrics) + if !handled1 || len(ranges1) != 1 { + t.Fatalf("SplitRangeStrategy failed to extract first split entry: %+v", ranges1) + } + events1 := ranges1[0].Range.GetEvents() + if events1[0].GetIntroduced() != "1.31.0" || events1[1].GetFixed() != "1.36.1" { + t.Errorf("unexpected events from first split entry: %+v", events1) + } + + ranges2, _, handled2 := strategy.Extract(affectedSplit.Versions[1], affectedSplit, metrics) + if !handled2 || len(ranges2) != 0 { + t.Errorf("expected second split entry to be consumed, got: %+v", ranges2) + } + + // 2. Standalone upper bound (e.g., CVE-2022-25865) + affectedUpper := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "unspecified", + LessThan: "0.18.4", + VersionType: "custom", + }, + }, + } + rangesUpper, _, handledUpper := strategy.Extract(affectedUpper.Versions[0], affectedUpper, metrics) + if !handledUpper || len(rangesUpper) != 1 { + t.Fatalf("SplitRangeStrategy failed to extract standalone upper bound: %+v", rangesUpper) + } + eventsUpper := rangesUpper[0].Range.GetEvents() + if eventsUpper[0].GetIntroduced() != "0" || eventsUpper[1].GetFixed() != "0.18.4" { + t.Errorf("unexpected events from standalone upper bound: %+v", eventsUpper) + } + }) + + t.Run("GitCommitStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &GitCommitStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", + VersionType: "git", + } + affected := models.Affected{ + Repo: "https://github.com/GeneralSandman/TinyWeb", + } + ranges, vrt, handled := strategy.Extract(vers, affected, metrics) + if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + t.Fatalf("GitCommitStrategy failed to extract range") + } + if ranges[0].Range.GetType() != osvschema.Range_GIT || ranges[0].Range.GetRepo() != "https://github.com/GeneralSandman/TinyWeb" { + t.Errorf("unexpected range properties: %+v", ranges[0].Range) + } + }) + + t.Run("GitCommitIntroducedOnlyStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &GitCommitIntroducedOnlyStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", + VersionType: "git", + } + affected := models.Affected{ + Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + } + ranges, vrt, handled := strategy.Extract(vers, affected, metrics) + if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + t.Fatalf("GitCommitIntroducedOnlyStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if len(events) != 1 || events[0].GetIntroduced() != "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("StandaloneSingleVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StandaloneSingleVersionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StandaloneSingleVersionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetLastAffected() != "1.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("StrategyPrioritySorting", func(t *testing.T) { + t.Parallel() + extractor := &DefaultVersionExtractor{ + Strategies: []VersionStrategy{ + &StandaloneSingleVersionStrategy{}, // PriorityLastResort (300) + &StandardRangeStrategy{}, // PriorityStandard (200) + &ChangesAtStrategy{}, // PriorityFirst (100) + }, + } + sorted := extractor.getStrategies() + if len(sorted) != 3 { + t.Fatalf("expected 3 strategies, got %d", len(sorted)) + } + if sorted[0].Name() != "ChangesAt" || sorted[0].Priority() != PriorityFirst { + t.Errorf("expected first strategy to be ChangesAt, got %s", sorted[0].Name()) + } + if sorted[1].Name() != "StandardRange" || sorted[1].Priority() != PriorityStandard { + t.Errorf("expected second strategy to be StandardRange, got %s", sorted[1].Name()) + } + if sorted[2].Name() != "StandaloneSingleVersion" || sorted[2].Priority() != PriorityLastResort { + t.Errorf("expected third strategy to be StandaloneSingleVersion, got %s", sorted[2].Name()) + } + + boosted := WithPriority(&StandaloneSingleVersionStrategy{}, PriorityFirst-10) + if boosted.Priority() >= PriorityFirst { + t.Errorf("expected boosted priority to be lower than PriorityFirst") + } + }) + + t.Run("AffectedCPEStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &AffectedCPEStrategy{} + affected := models.Affected{ + Cpes: []string{"cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"}, + } + vers := models.Versions{ + Status: "affected", + } + ranges, _, handled := strategy.Extract(vers, affected, metrics) + if !handled || len(ranges) != 1 { + t.Fatalf("AffectedCPEStrategy failed to extract range") + } + if ranges[0].Metadata.CPE != "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*" { + t.Errorf("unexpected CPE metadata: %s", ranges[0].Metadata.CPE) + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.2.3" || events[1].GetLastAffected() != "1.2.3" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("CPEVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &CPEVersionStrategy{} + cve := models.CVE5{} + cve.Containers.CNA.CPEApplicability = []models.CPE{ + { + Nodes: []models.CPENode{ + { + Operator: "OR", + CPEMatch: []struct { + Vulnerable bool `json:"vulnerable,omitempty"` + Criteria string `json:"criteria,omitempty"` + VersionEndIncluding string `json:"versionEndIncluding,omitempty"` + VersionStartExcluding string `json:"versionStartExcluding,omitempty" mapstructure:"versionStartExcluding,omitempty" yaml:"versionStartExcluding,omitempty"` + VersionStartIncluding string `json:"versionStartIncluding,omitempty" mapstructure:"versionStartIncluding,omitempty" yaml:"versionStartIncluding,omitempty"` + VersionEndExcluding string `json:"versionEndExcluding,omitempty"` + }{ + { + Vulnerable: true, + Criteria: "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", + VersionStartIncluding: "1.0.0", + VersionEndExcluding: "2.0.0", + }, + }, + }, + }, + }, + } + ranges, err := strategy.Extract(cve, metrics) + if err != nil || len(ranges) != 1 { + t.Fatalf("CPEVersionStrategy failed to extract range: %v", err) + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "2.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) +} + func TestExtractVersions(t *testing.T) { testCases := []struct { name string @@ -615,6 +1002,79 @@ 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: "0"}, + {LastAffected: "2.52"}, + }, + }}, + }}, + }, + { + name: "CVE-2026-67185", + cve: loadTestCVE(t, filepath.Join("..", "..", "cmd", "converters", "cve", "cve5", "bulk-converter", "cvelistV5", "cves", "2026", "67xxx", "CVE-2026-67185.json")), + cnaAssigner: "VulnCheck", + repos: []string{"https://github.com/GeneralSandman/TinyWeb"}, + expectedAffected: []*osvschema.Affected{{ + Ranges: []*osvschema.Range{{ + Repo: "https://github.com/GeneralSandman/TinyWeb", + Type: osvschema.Range_GIT, + Events: []*osvschema.Event{ + {Introduced: "0b3b5fdb5a058f50248cd8547824936b8dd10351"}, + {LastAffected: "a381da252fe8e873c8aff22703040426cc9b2ae0"}, + }, + }}, + }}, + }, } for _, tc := range testCases { @@ -634,3 +1094,68 @@ func TestExtractVersions(t *testing.T) { }) } } + +func TestExtractVersions_NoReposEarlyExit(t *testing.T) { + cve := models.CVE5{ + Metadata: models.CVE5Metadata{ + CVEID: "CVE-2026-0001", + AssignerShortName: "mitre", + }, + Containers: struct { + CNA models.CNA `json:"cna"` + ADP []models.CNA `json:"adp,omitempty"` + }{ + CNA: models.CNA{ + Affected: []models.Affected{ + { + Vendor: "Vendor", + Product: "Product", + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.2.0", + }, + }, + }, + }, + Descriptions: []models.LangString{ + { + Lang: "en", + Value: "Vulnerability in Product before 1.2.0 allows attackers to execute code.", + }, + }, + }, + }, + } + + metrics := &models.ConversionMetrics{CVEID: "CVE-2026-0001", CNA: "mitre"} + v := vulns.Vulnerability{ + Vulnerability: &osvschema.Vulnerability{ + Id: "CVE-2026-0001", + }, + } + + extractor := GetVersionExtractor("mitre") + extractor.ExtractVersions(cve, &v, metrics, []string{}) + + if metrics.Outcome != models.NoRepos { + t.Errorf("expected outcome to be NoRepos, got %v", metrics.Outcome) + } + + // Should not have attempted fallback to description + for _, note := range metrics.Notes { + if strings.Contains(note, "attempting extraction from description") || strings.Contains(note, "attempting to extract from CPE") { + t.Errorf("unexpected fallback note present when repos is empty: %s", note) + } + } + + if v.DatabaseSpecific == nil { + t.Fatalf("expected DatabaseSpecific to be populated with unresolved_ranges") + } + + fields := v.DatabaseSpecific.GetFields() + if _, ok := fields["unresolved_ranges"]; !ok { + t.Errorf("expected unresolved_ranges in DatabaseSpecific") + } +} diff --git a/vulnfeeds/conversion/nvd/converter.go b/vulnfeeds/conversion/nvd/converter.go index 677dea97127..91c410946d8 100644 --- a/vulnfeeds/conversion/nvd/converter.go +++ b/vulnfeeds/conversion/nvd/converter.go @@ -106,7 +106,7 @@ func CVEToOSV(cve models.NVDCVE, repos []string, vpRepoCache *c.VPRepoCache, cac if err != nil { metrics.AddNote("Failed to extract commits from refs: %v", err) if git.IsRateLimit(err) { - metrics.SetOutcome(models.Error) + metrics.SetError(err) return nil, metrics, models.Error } } diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 70a973309da..37562ff1c26 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -1130,11 +1130,11 @@ func VersionInfoToCommits(v *models.VersionInfo, repos []string, cache git.RepoT for _, repo := range repos { normalizedTags, err := git.NormalizeRepoTags(repo, cache, httpClient) if err != nil { + metrics.AddNote("Failed to normalize tags %s: %v", repo, err) if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return } - metrics.AddNote("Failed to normalize tags %s %s", repo, err) continue } @@ -1238,9 +1238,12 @@ func ReposFromReferences(cache *VPRepoCache, vp *VendorProduct, refs []models.Re canonicalRepo, err := git.FindCanonicalLink(repo, httpClient, repoTagsCache) if err == nil { repo = canonicalRepo - } else if git.IsRateLimit(err) { - metrics.Outcome = models.Error - return nil + } else { + metrics.AddNote("Failed to find canonical link for %s: %v", repo, err) + if git.IsRateLimit(err) { + metrics.SetError(err) + return nil + } } if slices.Contains(repos, repo) { diff --git a/vulnfeeds/conversion/versions_test.go b/vulnfeeds/conversion/versions_test.go index 4d96dc57c13..c4f3a1c1565 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -1665,3 +1665,97 @@ func TestVendorProduct_UnmarshalText(t *testing.T) { }) } } + +func TestIsGitCommitSHA(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"0b3b5fdb5a058f50248cd8547824936b8dd10351", true}, + {"a381da252fe8e873c8aff22703040426cc9b2ae0", true}, + {"fc300780da21f4bb92c148bc90257201220cf174", true}, + {"1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", true}, + {"0.0.8", false}, + {"v1.2.3", false}, + {"1.0.0-rc1", false}, + {"", false}, + {"0", false}, + {"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", false}, + {"0b3b5fdb5a058f50248cd8547824936b8dd1035", false}, // 39 chars + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + if got := IsGitCommitSHA(tt.input); got != tt.want { + t.Errorf("IsGitCommitSHA(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestIsDirectGitRange(t *testing.T) { + t.Run("Range_GIT with valid commit SHA", func(t *testing.T) { + r := models.RangeWithMetadata{ + Range: BuildGitVersionRange("0", "", "fc300780da21f4bb92c148bc90257201220cf174", "https://github.com/foo/bar"), + } + if !IsDirectGitRange(r) { + t.Errorf("expected IsDirectGitRange to be true for Range_GIT with commit SHA") + } + }) + + t.Run("Range_GIT with version tags", func(t *testing.T) { + r := models.RangeWithMetadata{ + Range: BuildGitVersionRange("0.1.0", "", "0.9.2", "https://github.com/foo/bar"), + } + if IsDirectGitRange(r) { + t.Errorf("expected IsDirectGitRange to be false for Range_GIT with tag versions") + } + }) + + t.Run("Valid SHAs without Range_GIT", func(t *testing.T) { + r := models.RangeWithMetadata{ + Range: BuildVersionRange("0b3b5fdb5a058f50248cd8547824936b8dd10351", "a381da252fe8e873c8aff22703040426cc9b2ae0", ""), + } + if !IsDirectGitRange(r) { + t.Errorf("expected IsDirectGitRange to be true for valid commit SHAs") + } + }) + + t.Run("Semver tags", func(t *testing.T) { + r := models.RangeWithMetadata{ + Range: BuildVersionRange("1.0.0", "", "2.0.0"), + } + if IsDirectGitRange(r) { + t.Errorf("expected IsDirectGitRange to be false for semver strings") + } + }) +} + +func TestProcessRanges_DirectGitRange(t *testing.T) { + metrics := &models.ConversionMetrics{} + gitRange := models.RangeWithMetadata{ + Range: BuildVersionRange("0b3b5fdb5a058f50248cd8547824936b8dd10351", "a381da252fe8e873c8aff22703040426cc9b2ae0", ""), + Metadata: models.Metadata{ + Source: models.VersionSourceAffected, + }, + } + repos := []string{"https://github.com/GeneralSandman/TinyWeb"} + + resolved, unresolved, successfulRepos := ProcessRanges([]models.RangeWithMetadata{gitRange}, repos, metrics, git.NewRepoTagsCache()) + + if len(resolved) != 1 { + t.Fatalf("expected 1 resolved range, got %d", len(resolved)) + } + if len(unresolved) != 0 { + t.Errorf("expected 0 unresolved ranges, got %d", len(unresolved)) + } + if len(successfulRepos) != 1 || successfulRepos[0] != "https://github.com/GeneralSandman/TinyWeb" { + t.Errorf("unexpected successful repos: %v", successfulRepos) + } + if resolved[0].Range.GetRepo() != "https://github.com/GeneralSandman/TinyWeb" { + t.Errorf("expected repo to be set to %s, got %s", "https://github.com/GeneralSandman/TinyWeb", resolved[0].Range.GetRepo()) + } + if resolved[0].Range.GetType().String() != "GIT" { + t.Errorf("expected range type to be GIT, got %s", resolved[0].Range.GetType().String()) + } +} diff --git a/vulnfeeds/models/cve.go b/vulnfeeds/models/cve.go index cb5635eecd5..c6cae93455e 100644 --- a/vulnfeeds/models/cve.go +++ b/vulnfeeds/models/cve.go @@ -122,6 +122,7 @@ type Affected struct { Product string `json:"product,omitempty"` PackageName string `json:"packageName,omitempty"` CollectionURL string `json:"collectionURL,omitempty"` + Cpes []string `json:"cpes,omitempty"` Versions []Versions `json:"versions,omitempty"` Repo string `json:"repo,omitempty"` DefaultStatus string `json:"defaultStatus,omitempty"` diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 0befba41e80..1b64dd9b66b 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -106,6 +106,14 @@ func (m *ConversionMetrics) SetOutcome(outcome ConversionOutcome) { } } +// SetError sets the outcome to Error and logs the error to the metrics notes. +func (m *ConversionMetrics) SetError(err error) { + m.Outcome = Error + if err != nil { + m.AddNote("Conversion error: %v", err) + } +} + // AddSource appends a source to the ConversionMetrics func (m *ConversionMetrics) AddSource(source VersionSource) { m.VersionSources = append(m.VersionSources, source) diff --git a/vulnfeeds/models/metrics_test.go b/vulnfeeds/models/metrics_test.go new file mode 100644 index 00000000000..5fc5783696a --- /dev/null +++ b/vulnfeeds/models/metrics_test.go @@ -0,0 +1,33 @@ +package models + +import ( + "errors" + "strings" + "testing" +) + +func TestConversionMetrics_SetError(t *testing.T) { + metrics := &ConversionMetrics{ + CVEID: "CVE-2026-0001", + CNA: "test", + } + + testErr := errors.New("rate limit reached: 429 Too Many Requests") + metrics.SetError(testErr) + + if metrics.Outcome != Error { + t.Errorf("expected Outcome to be Error, got %v", metrics.Outcome) + } + + found := false + for _, note := range metrics.Notes { + if strings.Contains(note, "Conversion error:") && strings.Contains(note, "rate limit reached: 429 Too Many Requests") { + found = true + break + } + } + + if !found { + t.Errorf("expected error note in metrics.Notes, got notes: %v", metrics.Notes) + } +} From 1e829b071df65a2994b7cf6d2b2b2a5bdd91e6ac Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 17 Aug 2026 01:22:00 +0000 Subject: [PATCH 02/12] linux --- vulnfeeds/conversion/cve5/linux_extractor.go | 46 ++------------------ 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/vulnfeeds/conversion/cve5/linux_extractor.go b/vulnfeeds/conversion/cve5/linux_extractor.go index 3007e8ce650..6e3b11e375e 100644 --- a/vulnfeeds/conversion/cve5/linux_extractor.go +++ b/vulnfeeds/conversion/cve5/linux_extractor.go @@ -25,6 +25,10 @@ var _ VersionExtractor = &LinuxVersionExtractor{} // handleAffected takes an array of models.Affected and handles how to extract them func (l *LinuxVersionExtractor) handleAffected(v *vulns.Vulnerability, affected []models.Affected, metrics *models.ConversionMetrics) bool { + if len(l.Strategies) == 0 { + l.Strategies = LinuxStrategies() + } + hasGit := false gotVersions := false for _, cveAff := range affected { @@ -162,45 +166,3 @@ func findInverseAffectedRanges(cveAff models.Affected, metrics *models.Conversio return nil, VersionRangeTypeUnknown } - -func (l *LinuxVersionExtractor) FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) { - versionTypesCount := make(map[VersionRangeType]int) - var versionRanges []models.RangeWithMetadata - for _, vers := range affected.Versions { - ranges, currentVersionType, shouldContinue := initialNormalExtraction(vers, metrics, versionTypesCount) - versionRanges = append(versionRanges, c.ToRangeWithMetadata(ranges, models.VersionSourceAffected)...) - if shouldContinue { - continue - } - // In this case only vers.Version exists which either means that it is _only_ that version that is - // affected, but more likely, it affects up to that version. It could also mean that the range is given - // in one line instead - like "< 1.5.3" or "< 2.45.4, >= 2.0 " or just "before 1.4.7", so check for that. - metrics.AddNote("Only version exists") - - if currentVersionType == VersionRangeTypeGit { - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} - versionRanges = append(versionRanges, c.ToRangeWithMetadata(vr, models.VersionSourceGit)...) - - continue - } - - // As a fallback, assume a single version means it's the last affected version. - if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} - versionRanges = append(versionRanges, c.ToRangeWithMetadata(vr, models.VersionSourceAffected)...) - metrics.AddNote("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) - } - } - - // Determine the most frequent version type to return as the range type. - maxCount := 0 - mostFrequentVersionType := VersionRangeTypeEcosystem - for versionType, count := range versionTypesCount { - if count > maxCount { - maxCount = count - mostFrequentVersionType = versionType - } - } - - return versionRanges, mostFrequentVersionType -} From 7a775d64e41db6faa749176fd6a372ee424f33a5 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 2 Sep 2026 05:47:12 +0000 Subject: [PATCH 03/12] fix some artifacts --- .../cve5/version_extraction_test.go | 4 +- vulnfeeds/conversion/cve5/wordpress.go | 360 ++++++++++++++++++ vulnfeeds/conversion/versions_test.go | 5 +- 3 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 vulnfeeds/conversion/cve5/wordpress.go diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 5f93138fa55..46b518863d5 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -1137,7 +1137,9 @@ func TestExtractVersions_NoReposEarlyExit(t *testing.T) { } extractor := GetVersionExtractor("mitre") - extractor.ExtractVersions(cve, &v, metrics, []string{}) + r := testutils.SetupGitVCR(t) + cache := &git.InMemoryRepoTagsCache{} + extractor.ExtractVersions(cve, &v, metrics, []string{}, cache, r.GetDefaultClient()) if metrics.Outcome != models.NoRepos { t.Errorf("expected outcome to be NoRepos, got %v", metrics.Outcome) diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go new file mode 100644 index 00000000000..8e4887115df --- /dev/null +++ b/vulnfeeds/conversion/cve5/wordpress.go @@ -0,0 +1,360 @@ +package cve5 + +import ( + "net/http" + "regexp" + "slices" + "strings" + + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/git" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/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, cache git.RepoTagsCache, httpClient *http.Client) { + if w.Handler != nil { + w.Handler.PreExtract(&cve) + } + + // 1. Run default extraction first + w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos, cache, httpClient) + + // 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/versions_test.go b/vulnfeeds/conversion/versions_test.go index c4f3a1c1565..a68291dc772 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "log" + "net/http" "os" "reflect" "slices" @@ -1740,8 +1741,8 @@ func TestProcessRanges_DirectGitRange(t *testing.T) { }, } repos := []string{"https://github.com/GeneralSandman/TinyWeb"} - - resolved, unresolved, successfulRepos := ProcessRanges([]models.RangeWithMetadata{gitRange}, repos, metrics, git.NewRepoTagsCache()) + httpClient := &http.Client{} + resolved, unresolved, successfulRepos := ProcessRanges([]models.RangeWithMetadata{gitRange}, repos, metrics, git.NewRepoTagsCache(), httpClient) if len(resolved) != 1 { t.Fatalf("expected 1 resolved range, got %d", len(resolved)) From beeb0df03f82d3d5572be44f8267bc1b9baa2f0d Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 2 Sep 2026 23:45:33 +0000 Subject: [PATCH 04/12] cleanup --- vulnfeeds/conversion/common.go | 6 +++--- vulnfeeds/conversion/cve5/default_extractor.go | 1 - vulnfeeds/models/cve.go | 16 +++++++++++----- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 2f916e17a21..44f49085109 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -186,7 +186,7 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri canonicalRepo, err := git.FindCanonicalLink(vr.Range.GetRepo(), httpClient, cache) if err != nil { if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } } else { @@ -233,7 +233,7 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri canonicalVRepo, err := git.FindCanonicalLink(vRepo, httpClient, cache) if err != nil { if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } } else { @@ -813,7 +813,7 @@ func ProcessRanges(ranges []models.RangeWithMetadata, repos []string, metrics *m } if len(tagVersionRanges) > 0 { - r, un, sR := GitVersionsToCommits(tagVersionRanges, repos, metrics, cache) + r, un, sR := GitVersionsToCommits(tagVersionRanges, repos, metrics, cache, httpClient) resolvedRanges = append(resolvedRanges, r...) unresolvedRanges = append(unresolvedRanges, un...) successfulRepos = append(successfulRepos, sR...) diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index 121c93a6ed0..93bafa67c3c 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -11,7 +11,6 @@ import ( "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/utility/logger" "github.com/google/osv.dev/vulnfeeds/vulns" - "github.com/ossf/osv-schema/bindings/go/osvschema" "google.golang.org/protobuf/types/known/structpb" ) diff --git a/vulnfeeds/models/cve.go b/vulnfeeds/models/cve.go index c6cae93455e..d39f6102820 100644 --- a/vulnfeeds/models/cve.go +++ b/vulnfeeds/models/cve.go @@ -128,12 +128,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 16bdbc5ab7b5f791f64908cd0c30c927eba448df Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Wed, 2 Sep 2026 23:46:27 +0000 Subject: [PATCH 05/12] remove wordpress for now --- vulnfeeds/conversion/cve5/extraction.go | 21 - .../TestExtractVersions_CVE-2026-67185.yaml | 112 ++++++ .../TestExtractVersions_NoReposEarlyExit.yaml | 3 + .../cve5/version_extraction_test.go | 57 --- vulnfeeds/conversion/cve5/wordpress.go | 360 ------------------ 5 files changed, 115 insertions(+), 438 deletions(-) create mode 100644 vulnfeeds/conversion/cve5/testdata/TestExtractVersions_CVE-2026-67185.yaml create mode 100644 vulnfeeds/conversion/cve5/testdata/TestExtractVersions_NoReposEarlyExit.yaml delete mode 100644 vulnfeeds/conversion/cve5/wordpress.go diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index e00d3ce16f3..9548b38f898 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -24,27 +24,6 @@ func GetVersionExtractor(cna string) VersionExtractor { Strategies: LinuxStrategies(), }, } - case "wordfence": - return &WordpressExtractor{ - Handler: &WordfenceHandler{}, - DefaultVersionExtractor: DefaultVersionExtractor{ - Strategies: WordfenceStrategies(), - }, - } - case "patchstack": - return &WordpressExtractor{ - Handler: &PatchstackHandler{}, - DefaultVersionExtractor: DefaultVersionExtractor{ - Strategies: PatchstackStrategies(), - }, - } - case "wpscan": - return &WordpressExtractor{ - Handler: &WPScanHandler{}, - DefaultVersionExtractor: DefaultVersionExtractor{ - Strategies: WPScanStrategies(), - }, - } case "github_m", "github": return &DefaultVersionExtractor{ Strategies: GitHubStrategies(), diff --git a/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_CVE-2026-67185.yaml b/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_CVE-2026-67185.yaml new file mode 100644 index 00000000000..cd9cc1df2ae --- /dev/null +++ b/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_CVE-2026-67185.yaml @@ -0,0 +1,112 @@ +--- +version: 2 +interactions: + - id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: github.com + headers: + Accept: + - text/html + url: https://github.com/GeneralSandman/TinyWeb + method: HEAD + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "" + headers: + Accept-Ranges: + - bytes + Cache-Control: + - max-age=0, private, must-revalidate + Content-Security-Policy: + - 'default-src ''none''; base-uri ''self''; child-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/; connect-src ''self'' uploads.github.com www.githubstatus.com collector.github.com raw.githubusercontent.com api.github.com github-cloud.s3.amazonaws.com github-production-repository-file-5c1aeb.s3.amazonaws.com github-production-upload-manifest-file-7fdce7.s3.amazonaws.com github-production-user-asset-6210df.s3.amazonaws.com *.rel.tunnels.api.visualstudio.com wss://*.rel.tunnels.api.visualstudio.com github.githubassets.com objects-origin.githubusercontent.com copilot-proxy.githubusercontent.com proxy.individual.githubcopilot.com proxy.business.githubcopilot.com proxy.enterprise.githubcopilot.com *.actions.githubusercontent.com wss://*.actions.githubusercontent.com productionresultssa0.blob.core.windows.net productionresultssa1.blob.core.windows.net productionresultssa2.blob.core.windows.net productionresultssa3.blob.core.windows.net productionresultssa4.blob.core.windows.net productionresultssa5.blob.core.windows.net productionresultssa6.blob.core.windows.net productionresultssa7.blob.core.windows.net productionresultssa8.blob.core.windows.net productionresultssa9.blob.core.windows.net productionresultssa10.blob.core.windows.net productionresultssa11.blob.core.windows.net productionresultssa12.blob.core.windows.net productionresultssa13.blob.core.windows.net productionresultssa14.blob.core.windows.net productionresultssa15.blob.core.windows.net productionresultssa16.blob.core.windows.net productionresultssa17.blob.core.windows.net productionresultssa18.blob.core.windows.net productionresultssa19.blob.core.windows.net github-production-repository-image-32fea6.s3.amazonaws.com github-production-release-asset-2e65be.s3.amazonaws.com insights.github.com wss://alive.github.com wss://alive-staging.github.com api.githubcopilot.com api.individual.githubcopilot.com api.business.githubcopilot.com api.enterprise.githubcopilot.com wss://production-copilot-host.webpubsub.azure.com; font-src github.githubassets.com; form-action ''self'' github.com gist.github.com copilot-workspace.githubnext.com objects-origin.githubusercontent.com; frame-ancestors ''none''; frame-src viewscreen.githubusercontent.com notebooks.githubusercontent.com; img-src ''self'' data: blob: github.githubassets.com media.githubusercontent.com camo.githubusercontent.com identicons.github.com avatars.githubusercontent.com private-avatars.githubusercontent.com github-cloud.s3.amazonaws.com objects.githubusercontent.com release-assets.githubusercontent.com secured-user-images.githubusercontent.com user-images.githubusercontent.com private-user-images.githubusercontent.com opengraph.githubassets.com repository-images.githubusercontent.com marketplace-screenshots.githubusercontent.com copilotprodattachments.blob.core.windows.net/github-production-copilot-attachments/ github-production-user-asset-6210df.s3.amazonaws.com customer-stories-feed.github.com spotlights-feed.github.com explore-feed.github.com objects-origin.githubusercontent.com *.githubusercontent.com; manifest-src ''self''; media-src github.com user-images.githubusercontent.com secured-user-images.githubusercontent.com private-user-images.githubusercontent.com github-production-user-asset-6210df.s3.amazonaws.com gist.github.com github.githubassets.com; script-src github.githubassets.com; style-src ''unsafe-inline'' github.githubassets.com; upgrade-insecure-requests; worker-src github.githubassets.com github.com/assets-cdn/worker/ github.com/assets/ gist.github.com/assets-cdn/worker/' + Content-Type: + - text/html; charset=utf-8 + Date: + - Wed, 02 Sep 2026 23:45:18 GMT + Etag: + - W/"0729301df2ef6488a39d501960f44c89" + Referrer-Policy: + - no-referrer-when-downgrade + Server: + - github.com + Set-Cookie: + - _gh_sess=c2BDtAQbeSW0YEo1E7CtYMwSNDA1q%2BCQ9hJ9cTHzqd2cL6colugvMa%2BnPk%2FNVwyTacZ9PaHW%2FSJngrgp0DX0L8Gh2CytDeHPGdohAGfah5RqcqzxzGmL%2Fybotf%2FMBYXYNI6ziYNGOynARh%2Fkv85Eh3ljVm%2FP98P9Dte4qmFzGlh%2BSqZngYCcPgbu7IUj7NMLBwdjGXmJXANthQtv220auLxzfqHDxpKvKKOzNnaBfBLMssC4G41NMpxVTZj4RW5iqEHPqxIWLuqe5gQiuvgXlw%3D%3D--ilNjXUWb%2BTEOEPwN--s3JXSt5JVvCuH7eHCm3QFA%3D%3D; path=/; HttpOnly; secure; SameSite=Lax + - _octo=GH1.1.1992390495.1788392717; expires=Thu, 02 Sep 2027 23:45:17 GMT; domain=.github.com; path=/; secure; SameSite=Lax + - logged_in=no; expires=Thu, 02 Sep 2027 23:45:17 GMT; domain=.github.com; path=/; HttpOnly; secure; SameSite=Lax + Strict-Transport-Security: + - max-age=31536000; includeSubdomains; preload + Vary: + - X-PJAX, X-PJAX-Container, Turbo-Visit, Turbo-Frame, X-Requested-With, X-GitHub-Client-Version, Sec-Fetch-Site,Accept-Encoding, Accept, X-Requested-With + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - deny + X-Github-Edge-Region: + - southeastasia + X-Github-Request-Id: + - 8C75:72D33:9E399E:B18D2B:6A98B50D + X-Xss-Protection: + - "0" + status: 200 OK + code: 200 + duration: 954.399818ms + - id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: github.com + form: + service: + - git-upload-pack + headers: + Accept: + - '*/*' + Host: + - github.com + User-Agent: + - go-git/5.x + url: https://github.com/GeneralSandman/TinyWeb/info/refs?service=git-upload-pack + method: GET + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "001e# service=git-upload-pack\n0000015ba381da252fe8e873c8aff22703040426cc9b2ae0 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow deepen-since deepen-not deepen-relative no-progress include-tag multi_ack_detailed allow-tip-sha1-in-want allow-reachable-sha1-in-want no-done symref=HEAD:refs/heads/master filter object-format=sha1 agent=git/github-c0ad1336a857-Linux\n003fa381da252fe8e873c8aff22703040426cc9b2ae0 refs/heads/master\n003e3ca8e3ed0a6ce514f13c3190274e2bde1e897ba0 refs/pull/6/head\n003fc446ccb56400d9dc786411294e3933ba86d8e716 refs/pull/6/merge\n0000" + headers: + Cache-Control: + - no-cache, max-age=0, must-revalidate + Content-Security-Policy: + - default-src 'none'; sandbox + Content-Type: + - application/x-git-upload-pack-advertisement + Date: + - Wed, 02 Sep 2026 23:45:19 GMT + Expires: + - Fri, 01 Jan 1980 00:00:00 GMT + Pragma: + - no-cache + Server: + - GitHub-Babel/3.0 + Strict-Transport-Security: + - max-age=31536000 + Vary: + - Accept-Encoding + X-Frame-Options: + - DENY + X-Github-Edge-Region: + - southeastasia + X-Github-Request-Id: + - 8C75:72D33:9E3BC0:B18F97:6A98B50D + status: 200 OK + code: 200 + duration: 329.916448ms diff --git a/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_NoReposEarlyExit.yaml b/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_NoReposEarlyExit.yaml new file mode 100644 index 00000000000..2797c38e00e --- /dev/null +++ b/vulnfeeds/conversion/cve5/testdata/TestExtractVersions_NoReposEarlyExit.yaml @@ -0,0 +1,3 @@ +--- +version: 2 +interactions: [] diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 46b518863d5..1542e5668c0 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -1002,63 +1002,6 @@ 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: "0"}, - {LastAffected: "2.52"}, - }, - }}, - }}, - }, { name: "CVE-2026-67185", cve: loadTestCVE(t, filepath.Join("..", "..", "cmd", "converters", "cve", "cve5", "bulk-converter", "cvelistV5", "cves", "2026", "67xxx", "CVE-2026-67185.json")), diff --git a/vulnfeeds/conversion/cve5/wordpress.go b/vulnfeeds/conversion/cve5/wordpress.go deleted file mode 100644 index 8e4887115df..00000000000 --- a/vulnfeeds/conversion/cve5/wordpress.go +++ /dev/null @@ -1,360 +0,0 @@ -package cve5 - -import ( - "net/http" - "regexp" - "slices" - "strings" - - c "github.com/google/osv.dev/vulnfeeds/conversion" - "github.com/google/osv.dev/vulnfeeds/git" - "github.com/google/osv.dev/vulnfeeds/models" - "github.com/google/osv.dev/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, cache git.RepoTagsCache, httpClient *http.Client) { - if w.Handler != nil { - w.Handler.PreExtract(&cve) - } - - // 1. Run default extraction first - w.DefaultVersionExtractor.ExtractVersions(cve, v, metrics, repos, cache, httpClient) - - // 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 -} From 0e462d697159d634a292dffdba5914ad57064281 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Thu, 3 Sep 2026 00:21:20 +0000 Subject: [PATCH 06/12] lint and snaps --- .../conversion/cve5/__snapshots__/converter_test.snap | 8 -------- vulnfeeds/conversion/cve5/strategies.go | 4 ++-- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap index c26ce70abd3..0ac8a87964c 100755 --- a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap @@ -2355,14 +2355,6 @@ } ], "source": "AFFECTED_FIELD" - }, - { - "extracted_events": [ - { - "fixed": "7.61.1" - } - ], - "source": "DESCRIPTION" } ] }, diff --git a/vulnfeeds/conversion/cve5/strategies.go b/vulnfeeds/conversion/cve5/strategies.go index 3804ce3a681..49ab8ab10bf 100644 --- a/vulnfeeds/conversion/cve5/strategies.go +++ b/vulnfeeds/conversion/cve5/strategies.go @@ -180,7 +180,7 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af if affected.Repo != "" { vr.Repo = affected.Repo } - } + } } return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), currentVersionType, true @@ -529,7 +529,7 @@ func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affec if v.Version == vers.Version && v.LessThan == vers.LessThan && v.LessThanOrEqual == vers.LessThanOrEqual && v.Status == vers.Status && v.VersionType == vers.VersionType { idx = i break -} + } } // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) From d44272e5b6984967b7543210f2ad484663c1524c Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 21 Sep 2026 00:01:45 +0000 Subject: [PATCH 07/12] put strategies in their own folders --- vulnfeeds/conversion/cve5/common.go | 80 +- .../conversion/cve5/default_extractor.go | 23 +- vulnfeeds/conversion/cve5/extraction.go | 9 +- vulnfeeds/conversion/cve5/linux_extractor.go | 5 +- vulnfeeds/conversion/cve5/strategies.go | 741 ------------------ .../conversion/cve5/strategies/changes_at.go | 47 ++ vulnfeeds/conversion/cve5/strategies/cpe.go | 155 ++++ .../conversion/cve5/strategies/git_commit.go | 80 ++ .../conversion/cve5/strategies/presets.go | 107 +++ .../cve5/strategies/single_version.go | 79 ++ .../conversion/cve5/strategies/split_range.go | 131 ++++ .../cve5/strategies/standard_range.go | 94 +++ .../cve5/strategies/strategies_test.go | 354 +++++++++ .../conversion/cve5/strategies/strategy.go | 58 ++ .../strategies/string_range_expression.go | 52 ++ .../cve5/strategies/text_extraction.go | 33 + .../cve5/version_extraction_test.go | 334 +------- 17 files changed, 1246 insertions(+), 1136 deletions(-) delete mode 100644 vulnfeeds/conversion/cve5/strategies.go create mode 100644 vulnfeeds/conversion/cve5/strategies/changes_at.go create mode 100644 vulnfeeds/conversion/cve5/strategies/cpe.go create mode 100644 vulnfeeds/conversion/cve5/strategies/git_commit.go create mode 100644 vulnfeeds/conversion/cve5/strategies/presets.go create mode 100644 vulnfeeds/conversion/cve5/strategies/single_version.go create mode 100644 vulnfeeds/conversion/cve5/strategies/split_range.go create mode 100644 vulnfeeds/conversion/cve5/strategies/standard_range.go create mode 100644 vulnfeeds/conversion/cve5/strategies/strategies_test.go create mode 100644 vulnfeeds/conversion/cve5/strategies/strategy.go create mode 100644 vulnfeeds/conversion/cve5/strategies/string_range_expression.go create mode 100644 vulnfeeds/conversion/cve5/strategies/text_extraction.go diff --git a/vulnfeeds/conversion/cve5/common.go b/vulnfeeds/conversion/cve5/common.go index a6b945cca9d..8b917fefb41 100644 --- a/vulnfeeds/conversion/cve5/common.go +++ b/vulnfeeds/conversion/cve5/common.go @@ -2,91 +2,25 @@ package cve5 import ( "cmp" - "errors" "strconv" "strings" - c "github.com/google/osv.dev/vulnfeeds/conversion" - "github.com/google/osv.dev/vulnfeeds/models" - "github.com/google/osv.dev/vulnfeeds/vulns" - "github.com/ossf/osv-schema/bindings/go/osvschema" + "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" ) // VersionRangeType represents the type of versioning scheme for a range. -type VersionRangeType int - -const acceptableQuality = vulns.Spaces +type VersionRangeType = strategies.VersionRangeType const ( - VersionRangeTypeUnknown VersionRangeType = iota - VersionRangeTypeGit - VersionRangeTypeSemver - VersionRangeTypeEcosystem + VersionRangeTypeUnknown = strategies.VersionRangeTypeUnknown + VersionRangeTypeGit = strategies.VersionRangeTypeGit + VersionRangeTypeSemver = strategies.VersionRangeTypeSemver + VersionRangeTypeEcosystem = strategies.VersionRangeTypeEcosystem ) -// String returns the string representation of a VersionRangeType. -func (vrt VersionRangeType) String() string { - switch vrt { - case VersionRangeTypeGit: - return "git" - case VersionRangeTypeEcosystem: - return "ecosystem" - case VersionRangeTypeSemver: - return "semver" - default: - return "unknown" - } -} - // toVersionRangeType converts a string to a VersionRangeType. func toVersionRangeType(s string) VersionRangeType { - switch strings.ToLower(s) { - case "git": - return VersionRangeTypeGit - case "semver": - return VersionRangeTypeSemver - default: - // Other version types like "semver" are treated as ecosystem ranges. - return VersionRangeTypeEcosystem - } -} - -// findCPEVersionRanges extracts version ranges and CPE strings from the CNA's -// CPE applicability statements in a CVE record. -func findCPEVersionRanges(cve models.CVE5) (versionRanges []models.RangeWithMetadata, cpes []string, err error) { - // TODO(jesslowe): Add logic to also extract CPEs from the 'affected' field (e.g., CVE-2025-1110). - for _, cpe := range cve.Containers.CNA.CPEApplicability { - for _, node := range cpe.Nodes { - if node.Operator != "OR" { - continue - } - for _, match := range node.CPEMatch { - if !match.Vulnerable { - continue - } - cpes = append(cpes, match.Criteria) - - // If no start version is given, assume the vulnerability starts from version "0". - if match.VersionStartIncluding == "" { - match.VersionStartIncluding = "0" - } - var nr []*osvschema.Range - if match.VersionEndExcluding != "" { - nr = append(nr, c.BuildVersionRange(match.VersionStartIncluding, "", match.VersionEndExcluding)) - } else if match.VersionEndIncluding != "" { - nr = append(nr, c.BuildVersionRange(match.VersionStartIncluding, match.VersionEndIncluding, "")) - } - if nr != nil { - versionRanges = append(versionRanges, c.ToRangeWithMetadata(nr, models.VersionSourceCPE)...) - } - } - } - } - if len(versionRanges) == 0 { - return nil, nil, errors.New("no versions extracted from CPEs") - } - - return versionRanges, cpes, nil + return strategies.ToVersionRangeType(s) } // compareSemverLike provides a custom comparison function for version strings that may not diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index 93bafa67c3c..e05424d2efd 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -1,12 +1,12 @@ package cve5 import ( - "cmp" "maps" "net/http" "slices" c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/utility/logger" @@ -16,22 +16,15 @@ import ( // DefaultVersionExtractor provides version extraction logic using a configurable pipeline of strategies. type DefaultVersionExtractor struct { - Strategies []VersionStrategy + Strategies []strategies.VersionStrategy } -func (d *DefaultVersionExtractor) getStrategies() []VersionStrategy { - var strategies []VersionStrategy +func (d *DefaultVersionExtractor) getStrategies() []strategies.VersionStrategy { if len(d.Strategies) > 0 { - strategies = slices.Clone(d.Strategies) - } else { - strategies = DefaultStrategies() + return d.Strategies } - slices.SortStableFunc(strategies, func(a, b VersionStrategy) int { - return cmp.Compare(a.Priority(), b.Priority()) - }) - - return strategies + return strategies.DefaultStrategies() } func (d *DefaultVersionExtractor) handleAffected(affected []models.Affected, metrics *models.ConversionMetrics) []models.RangeWithMetadata { @@ -111,7 +104,7 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln if !gotVersions { metrics.AddNote("No versions in affected, attempting to extract from CPE") - versionRanges, _ := cpeVersionExtraction(cve, metrics) + versionRanges, _ := strategies.CPEVersionExtraction(cve, metrics) if len(versionRanges) != 0 { if processRanges(versionRanges) { @@ -142,10 +135,10 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln func (d *DefaultVersionExtractor) FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) { versionTypesCount := make(map[VersionRangeType]int) var versionRanges []models.RangeWithMetadata - strategies := d.getStrategies() + strategyList := d.getStrategies() for _, vers := range affected.Versions { - for _, strategy := range strategies { + for _, strategy := range strategyList { ranges, currentVersionType, handled := strategy.Extract(vers, affected, metrics) if handled { if len(ranges) > 0 { diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index 9548b38f898..19d1ef7d508 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -4,6 +4,7 @@ import ( "net/http" "strings" + "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/vulns" @@ -21,20 +22,20 @@ func GetVersionExtractor(cna string) VersionExtractor { case "linux": return &LinuxVersionExtractor{ DefaultVersionExtractor: DefaultVersionExtractor{ - Strategies: LinuxStrategies(), + Strategies: strategies.LinuxStrategies(), }, } case "github_m", "github": return &DefaultVersionExtractor{ - Strategies: GitHubStrategies(), + Strategies: strategies.GitHubStrategies(), } case "mitre": return &DefaultVersionExtractor{ - Strategies: MITREStrategies(), + Strategies: strategies.MITREStrategies(), } default: return &DefaultVersionExtractor{ - Strategies: DefaultStrategies(), + Strategies: strategies.DefaultStrategies(), } } } diff --git a/vulnfeeds/conversion/cve5/linux_extractor.go b/vulnfeeds/conversion/cve5/linux_extractor.go index 6e3b11e375e..9bffb4b8f6f 100644 --- a/vulnfeeds/conversion/cve5/linux_extractor.go +++ b/vulnfeeds/conversion/cve5/linux_extractor.go @@ -8,6 +8,7 @@ import ( "strings" c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/utility/logger" @@ -26,7 +27,7 @@ var _ VersionExtractor = &LinuxVersionExtractor{} // handleAffected takes an array of models.Affected and handles how to extract them func (l *LinuxVersionExtractor) handleAffected(v *vulns.Vulnerability, affected []models.Affected, metrics *models.ConversionMetrics) bool { if len(l.Strategies) == 0 { - l.Strategies = LinuxStrategies() + l.Strategies = strategies.LinuxStrategies() } hasGit := false @@ -66,7 +67,7 @@ func (l *LinuxVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulner if !gotVersions { metrics.AddNote("No versions in affected, attempting to extract from CPE") - versionRanges, err := cpeVersionExtraction(cve, metrics) + versionRanges, err := strategies.CPEVersionExtraction(cve, metrics) if err != nil { logger.Warn("Error when extracting CPE versions") } diff --git a/vulnfeeds/conversion/cve5/strategies.go b/vulnfeeds/conversion/cve5/strategies.go deleted file mode 100644 index 49ab8ab10bf..00000000000 --- a/vulnfeeds/conversion/cve5/strategies.go +++ /dev/null @@ -1,741 +0,0 @@ -package cve5 - -import ( - "strings" - - c "github.com/google/osv.dev/vulnfeeds/conversion" - "github.com/google/osv.dev/vulnfeeds/git" - "github.com/google/osv.dev/vulnfeeds/models" - "github.com/google/osv.dev/vulnfeeds/vulns" - "github.com/ossf/osv-schema/bindings/go/osvschema" -) - -// StrategyPriority represents the execution precedence for version extraction strategies. -type StrategyPriority int - -const ( - // PriorityFirst represents specialized or CNA-specific overrides that should be attempted before standard parsing (e.g., changes field, split range syntax). - PriorityFirst StrategyPriority = 100 - - // PriorityStandard represents standard structured CVE5 version range parsing rules that should always be attempted (e.g., lessThan/lessThanOrEqual, range expressions, git commits). - PriorityStandard StrategyPriority = 200 - - // PriorityLastResort represents heuristic or single-version fallbacks to be attempted only when standard range parsing cannot resolve the versions (e.g., 0-introduced, MITRE up-to, standalone single versions, text extraction). - PriorityLastResort StrategyPriority = 300 -) - -// VersionStrategy defines the contract for an individual version extraction strategy. -type VersionStrategy interface { - // Name returns a human-readable identifier for the strategy. - Name() string - // Priority returns the execution precedence of this strategy. Lower values run first. - Priority() StrategyPriority - // Extract attempts to extract OSV version ranges from a CVE5 Versions entry. - // Returns the extracted ranges, the detected VersionRangeType, and true if this strategy handled the entry. - Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) (ranges []models.RangeWithMetadata, vrt VersionRangeType, handled bool) -} - -type prioritizedStrategy struct { - VersionStrategy - - customPriority StrategyPriority -} - -func (p *prioritizedStrategy) Priority() StrategyPriority { - return p.customPriority -} - -// WithPriority wraps an existing VersionStrategy with a customized priority level. -func WithPriority(strategy VersionStrategy, priority StrategyPriority) VersionStrategy { - return &prioritizedStrategy{ - VersionStrategy: strategy, - customPriority: priority, - } -} - -// ChangesAtStrategy extracts the fixed version from the vers.Changes list when status is 'unaffected'. -// -// Resulting OSV Range: [introduced: "17.7.0", fixed: "17.7.2"] -type ChangesAtStrategy struct{} - -func (s *ChangesAtStrategy) Name() string { - return "ChangesAt" -} - -func (s *ChangesAtStrategy) Priority() StrategyPriority { - return PriorityFirst -} - -func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } - - var fixedFromChanges string - for _, ch := range vers.Changes { - if ch.Status == "unaffected" && ch.At != "" { - fixedFromChanges = ch.At - break - } - } - - if fixedFromChanges == "" { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Fixed from changes - %s", fixedFromChanges) - var introduced string - if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - introduced = vers.Version - metrics.AddNote("Introduced from version value - %s", vers.Version) - } - - currentVersionType := toVersionRangeType(vers.VersionType) - vr := []*osvschema.Range{c.BuildVersionRange(introduced, "", fixedFromChanges)} - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true -} - -// StandardRangeStrategy handles standard CVE 5.0 'lessThan' and 'lessThanOrEqual' range fields. -// -// Example CVE Record (CVE-2026-22104 - Hashtopolis): -// -// { -// "version": "0", -// "lessThan": "0.14.8", -// "status": "affected", -// "versionType": "semver" -// } -// -// Resulting OSV Range: [introduced: "0", fixed: "0.14.8"] -type StandardRangeStrategy struct{} - -func (s *StandardRangeStrategy) Name() string { - return "StandardRange" -} - -func (s *StandardRangeStrategy) Priority() StrategyPriority { - return PriorityStandard -} - -func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } - - currentVersionType := toVersionRangeType(vers.VersionType) - - vQuality := vulns.CheckQuality(vers.Version) - if !vQuality.AtLeast(acceptableQuality) { - metrics.AddNote("Version value is filler or empty") - } - vLessThanQual := vulns.CheckQuality(vers.LessThan) - vLTOEQual := vulns.CheckQuality(vers.LessThanOrEqual) - - hasRange := vLessThanQual.AtLeast(acceptableQuality) || vLTOEQual.AtLeast(acceptableQuality) - - // Handle cases where 'lessThan' or 'lessThanOrEqual' is mistakenly the same as 'version'. - if vers.LessThan != "" && vers.LessThan == vers.Version { - metrics.AddNote("Warning: lessThan (%s) is the same as introduced (%s)\n", vers.LessThan, vers.Version) - hasRange = false - } - if vers.LessThanOrEqual != "" && vers.LessThanOrEqual == vers.Version { - metrics.AddNote("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) - hasRange = false - } - - if !hasRange { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Range detected: %v", hasRange) - var introduced, fixed, lastaffected string - if vQuality.AtLeast(acceptableQuality) { - introduced = vers.Version - metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) - } - - if vLessThanQual.AtLeast(acceptableQuality) { - fixed = vers.LessThan - metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) - } else if vLTOEQual.AtLeast(acceptableQuality) { - lastaffected = vers.LessThanOrEqual - metrics.AddNote("%s - LastAffected from LessThanOrEqual value - %s", vLTOEQual.String(), vers.LessThanOrEqual) - } - - var versionRanges []*osvschema.Range - if fixed != "" { - versionRanges = append(versionRanges, c.BuildVersionRange(introduced, "", fixed)) - } else if lastaffected != "" { - versionRanges = append(versionRanges, c.BuildVersionRange(introduced, lastaffected, "")) - } - - if len(versionRanges) == 0 { - return nil, VersionRangeTypeUnknown, false - } - - for _, vr := range versionRanges { - if currentVersionType == VersionRangeTypeGit { - vr.Type = osvschema.Range_GIT - if affected.Repo != "" { - vr.Repo = affected.Repo - } - } - } - - return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), currentVersionType, true -} - -// StringRangeExpressionStrategy handles range expressions embedded within the version field. -// -// Example CVE Record (CVE-2024-21634 - Puma / GitHub_M): -// -// { -// "version": ">= 2.0, < 2.5", -// "status": "affected", -// "versionType": "semver" -// } -// -// Resulting OSV Range: [introduced: "2.0", fixed: "2.5"] -type StringRangeExpressionStrategy struct{} - -func (s *StringRangeExpressionStrategy) Name() string { - return "StringRangeExpression" -} - -func (s *StringRangeExpressionStrategy) Priority() StrategyPriority { - return PriorityStandard -} - -func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false - } - - av, err := git.ParseVersionRange(vers.Version) - if err != nil || av.Introduced == "" { - return nil, VersionRangeTypeUnknown, false - } - - currentVersionType := toVersionRangeType(vers.VersionType) - var vr []*osvschema.Range - if av.Fixed != "" { - vr = append(vr, c.BuildVersionRange(av.Introduced, "", av.Fixed)) - } else if av.LastAffected != "" { - vr = append(vr, c.BuildVersionRange(av.Introduced, av.LastAffected, "")) - } - - if len(vr) == 0 { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Parsed range expression from version: %s", vers.Version) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true -} - -// GitCommitStrategy handles git commit versions by treating them as standalone git commits. -// -// Example CVE Record: -// -// { -// "version": "deadbeefcafebabe0123456789abcdef01234567", -// "status": "affected", -// "versionType": "git" -// } -// -// Resulting OSV Range: [introduced: "deadbeef...", last_affected: "deadbeef..."] -type GitCommitStrategy struct{} - -func (s *GitCommitStrategy) Name() string { - return "GitCommit" -} - -func (s *GitCommitStrategy) Priority() StrategyPriority { - return PriorityStandard -} - -func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } - if toVersionRangeType(vers.VersionType) != VersionRangeTypeGit { - return nil, VersionRangeTypeUnknown, false - } - if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Git commit version found %v", vers.Version) - vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, vers.Version, "", affected.Repo)} - rwms := c.ToRangeWithMetadata(vr, models.VersionSourceGit) - for i := range rwms { - rwms[i].Metadata.Versions = []string{vers.Version} - } - - return rwms, VersionRangeTypeGit, true -} - -// GitCommitIntroducedOnlyStrategy treats a git commit version as an introduced-only point (used by Linux kernel). -// -// Example CVE Record (Linux Kernel git commits): -// -// { -// "version": "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", -// "status": "affected", -// "versionType": "git" -// } -// -// Resulting OSV Range: [introduced: "1da177e4c..."] -type GitCommitIntroducedOnlyStrategy struct{} - -func (s *GitCommitIntroducedOnlyStrategy) Name() string { - return "GitCommitIntroducedOnly" -} - -func (s *GitCommitIntroducedOnlyStrategy) Priority() StrategyPriority { - return PriorityStandard -} - -func (s *GitCommitIntroducedOnlyStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } - if toVersionRangeType(vers.VersionType) != VersionRangeTypeGit { - return nil, VersionRangeTypeUnknown, false - } - if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Git commit introduced found %v", vers.Version) - vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, "", "", affected.Repo)} - - return c.ToRangeWithMetadata(vr, models.VersionSourceGit), VersionRangeTypeGit, true -} - -// AffectedCPEStrategy extracts version ranges from CPE strings specified in affected.Cpes or version strings formatted as CPEs. -// -// Example CVE Record: -// -// "affected": [ -// { -// "cpes": ["cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"], -// "versions": [{ "status": "affected" }] -// } -// ] -// -// Resulting OSV Range: [introduced: "1.2.3", last_affected: "1.2.3"] -type AffectedCPEStrategy struct{} - -func (s *AffectedCPEStrategy) Name() string { - return "AffectedCPE" -} - -func (s *AffectedCPEStrategy) Priority() StrategyPriority { - return PriorityLastResort -} - -func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - cpeStr := "" - if strings.HasPrefix(vers.Version, "cpe:") { - cpeStr = vers.Version - } else if len(affected.Cpes) > 0 { - for _, cpe := range affected.Cpes { - if strings.HasPrefix(cpe, "cpe:") { - cpeStr = cpe - break - } - } - } - - if cpeStr == "" { - return nil, VersionRangeTypeUnknown, false - } - - parsedCPE, err := c.ParseCPE(cpeStr) - if err != nil || parsedCPE.Version == "" || parsedCPE.Version == "*" || parsedCPE.Version == "-" || parsedCPE.Version == "ANY" || parsedCPE.Version == "NA" { - return nil, VersionRangeTypeUnknown, false - } - - version := parsedCPE.Version - if parsedCPE.Update != "" && parsedCPE.Update != "*" && parsedCPE.Update != "-" && parsedCPE.Update != "ANY" && parsedCPE.Update != "NA" { - version += "." + parsedCPE.Update - } - - if !vulns.CheckQuality(version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Extracted version %s from CPE %s", version, cpeStr) - currentVersionType := toVersionRangeType(vers.VersionType) - vr := []*osvschema.Range{c.BuildVersionRange(version, version, "")} - rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) - for i := range rwms { - rwms[i].Metadata.CPE = cpeStr - rwms[i].Metadata.Versions = []string{version} - } - - return rwms, currentVersionType, true -} - -// ZeroIntroducedSingleVersionStrategy treats a single version value (when only 1 version is listed) -// as spanning from 0 to that version (e.g. WPScan, Wordfence, Linux, or single-version MITRE records). -// -// Example CVE Record (CVE-2015-10001 - WPScan / Wordfence / MITRE): -// -// { -// "version": "2.52", -// "status": "affected", -// "versionType": "custom" -// } -// -// Resulting OSV Range: [introduced: "0", last_affected: "2.52"] -type ZeroIntroducedSingleVersionStrategy struct{} - -func (s *ZeroIntroducedSingleVersionStrategy) Name() string { - return "ZeroIntroducedSingleVersion" -} - -func (s *ZeroIntroducedSingleVersionStrategy) Priority() StrategyPriority { - return PriorityLastResort -} - -func (s *ZeroIntroducedSingleVersionStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false - } - if len(affected.Versions) > 1 { - return nil, VersionRangeTypeUnknown, false - } - if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) - currentVersionType := toVersionRangeType(vers.VersionType) - vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true -} - -// StandaloneSingleVersionStrategy treats a single version as an exact, standalone version (introduced == last_affected). -// -// Example CVE Record: -// -// { -// "version": "1.0.0", -// "status": "affected" -// } -// -// Resulting OSV Range: [introduced: "1.0.0", last_affected: "1.0.0"] -type StandaloneSingleVersionStrategy struct{} - -func (s *StandaloneSingleVersionStrategy) Name() string { - return "StandaloneSingleVersion" -} - -func (s *StandaloneSingleVersionStrategy) Priority() StrategyPriority { - return PriorityLastResort -} - -func (s *StandaloneSingleVersionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false - } - if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false - } - - metrics.AddNote("Single version found %v - Treating as standalone version", vers.Version) - currentVersionType := toVersionRangeType(vers.VersionType) - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} - rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) - for i := range rwms { - rwms[i].Metadata.Versions = []string{vers.Version} - } - - return rwms, currentVersionType, true -} - -// SplitRangeStrategy handles split sequential version ranges -// (e.g. one entry with introduced and lessThan="unspecified", followed by an entry -// with version="unspecified" and lessThan/lessThanOrEqual). -// -// Example 1: Split Pair (CVE-2022-25929): -// -// "versions": [ -// { "version": "1.31.0", "lessThan": "unspecified", "status": "affected" }, -// { "version": "unspecified", "lessThan": "1.36.1", "status": "affected" } -// ] -// Resulting OSV Range: [introduced: "1.31.0", fixed: "1.36.1"] -// -// Example 2: Multi Split Sequences (CVE-2022-25761): -// -// "versions": [ -// { "version": "unspecified", "lessThan": "1.2.5", "status": "affected" }, -// { "version": "1.3-rc1", "lessThan": "unspecified", "status": "affected" }, -// { "version": "unspecified", "lessThan": "1.3.1", "status": "affected" } -// ] -// Resulting OSV Ranges: [introduced: "0", fixed: "1.2.5"], [introduced: "1.3-rc1", fixed: "1.3.1"] -// -// Example 3: Standalone Upper Bound (CVE-2022-25865): -// -// "versions": [ -// { "version": "unspecified", "lessThan": "0.18.4", "status": "affected" } -// ] -// Resulting OSV Range: [introduced: "0", fixed: "0.18.4"] -type SplitRangeStrategy struct{} - -func (s *SplitRangeStrategy) Name() string { - return "SplitRange" -} - -func (s *SplitRangeStrategy) Priority() StrategyPriority { - return PriorityFirst -} - -func isExplicitUnspecified(val string) bool { - clean := strings.TrimSpace(strings.ToLower(val)) - - return clean == "unspecified" -} - -func isValidSplitVersion(val string) bool { - clean := strings.TrimSpace(strings.ToLower(val)) - if clean == "" || clean == "unspecified" || clean == "n/a" || clean == "na" || clean == "*" || clean == "-" { - return false - } - - return vulns.CheckQuality(val).AtLeast(acceptableQuality) || val == "0" -} - -func isSplitIntroducedOnly(v models.Versions) bool { - return isValidSplitVersion(v.Version) && (isExplicitUnspecified(v.LessThan) || isExplicitUnspecified(v.LessThanOrEqual)) -} - -func isSplitUpperBoundOnly(v models.Versions) bool { - return isExplicitUnspecified(v.Version) && (isValidSplitVersion(v.LessThan) || isValidSplitVersion(v.LessThanOrEqual)) -} - -func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } - - // Check for "unspecified" split or standalone ranges across affected.Versions - idx := -1 - for i, v := range affected.Versions { - if v.Version == vers.Version && v.LessThan == vers.LessThan && v.LessThanOrEqual == vers.LessThanOrEqual && v.Status == vers.Status && v.VersionType == vers.VersionType { - idx = i - break - } - } - - // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) - if isSplitIntroducedOnly(vers) { - currentVersionType := toVersionRangeType(vers.VersionType) - // Check if the immediately following entry is an upper-bound entry - if idx >= 0 && idx+1 < len(affected.Versions) && isSplitUpperBoundOnly(affected.Versions[idx+1]) { - nextVers := affected.Versions[idx+1] - var fixed, lastAffected string - if isValidSplitVersion(nextVers.LessThan) { - fixed = nextVers.LessThan - } else if isValidSplitVersion(nextVers.LessThanOrEqual) { - lastAffected = nextVers.LessThanOrEqual - } - - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} - metrics.AddNote("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true - } - - // Standalone introduced entry without a following upper bound - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} - metrics.AddNote("Parsed standalone introduced version: %s", vers.Version) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true - } - - // Case B: Upper-bound-only entry (e.g. {version: "unspecified", lessThan: "1.36.1"}) - if isSplitUpperBoundOnly(vers) { - currentVersionType := toVersionRangeType(vers.VersionType) - // If preceded by an introduced-only entry, it was already paired and consumed in Case A - if idx > 0 && isSplitIntroducedOnly(affected.Versions[idx-1]) { - return nil, currentVersionType, true - } - - // Standalone upper bound starting from 0 - var fixed, lastAffected string - if isValidSplitVersion(vers.LessThan) { - fixed = vers.LessThan - } else if isValidSplitVersion(vers.LessThanOrEqual) { - lastAffected = vers.LessThanOrEqual - } - - vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} - metrics.AddNote("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true - } - - return nil, VersionRangeTypeUnknown, false -} - -// VersionTextExtractionStrategy handles natural text version patterns. -// -// Example CVE Record: -// -// { -// "version": "Fixed in version 2.4.1 and higher", -// "status": "affected" -// } -type VersionTextExtractionStrategy struct{} - -func (s *VersionTextExtractionStrategy) Name() string { - return "VersionTextExtraction" -} - -func (s *VersionTextExtractionStrategy) Priority() StrategyPriority { - return PriorityLastResort -} - -func (s *VersionTextExtractionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false - } - - possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) - if possibleVersions != nil { - metrics.AddNote("Versions retrieved from text but not used CURRENTLY") - } - - return nil, VersionRangeTypeUnknown, false -} - -// CPEVersionStrategy extracts version ranges from the CVE's CPE applicability statements. -// -// Example CVE Record: -// -// "cpeApplicability": [ -// { -// "nodes": [{ -// "operator": "OR", -// "cpeMatch": [{ -// "vulnerable": true, -// "criteria": "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", -// "versionStartIncluding": "1.0.0", -// "versionEndExcluding": "2.0.0" -// }] -// }] -// } -// ] -// -// Resulting OSV Range: [introduced: "1.0.0", fixed: "2.0.0"] -type CPEVersionStrategy struct{} - -func (s *CPEVersionStrategy) Name() string { - return "CPEApplicability" -} - -func (s *CPEVersionStrategy) Priority() StrategyPriority { - return PriorityStandard -} - -func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { - cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) - if err == nil && len(cpeRanges) > 0 { - metrics.AddNote("Strategy successful: %s", s.Name()) - metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) - metrics.CPEs = vulns.Unique(cpeStrings) - - return cpeRanges, nil - } else if err != nil { - metrics.AddNote("%s", err.Error()) - } - - return nil, err -} - -// Strategy Pipeline Presets for different CNAs - -func DefaultStrategies() []VersionStrategy { - return []VersionStrategy{ - &SplitRangeStrategy{}, - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &GitCommitStrategy{}, - &AffectedCPEStrategy{}, - &VersionTextExtractionStrategy{}, - &StandaloneSingleVersionStrategy{}, - } -} - -func GitHubStrategies() []VersionStrategy { - return []VersionStrategy{ - &SplitRangeStrategy{}, - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &GitCommitStrategy{}, - &AffectedCPEStrategy{}, - &StandaloneSingleVersionStrategy{}, - } -} - -func WPScanStrategies() []VersionStrategy { - return []VersionStrategy{ - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, - &ZeroIntroducedSingleVersionStrategy{}, - } -} - -func WordfenceStrategies() []VersionStrategy { - return []VersionStrategy{ - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, - &ZeroIntroducedSingleVersionStrategy{}, - } -} - -func PatchstackStrategies() []VersionStrategy { - return []VersionStrategy{ - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, - &ZeroIntroducedSingleVersionStrategy{}, - } -} - -func MITREStrategies() []VersionStrategy { - return []VersionStrategy{ - &SplitRangeStrategy{}, - &ChangesAtStrategy{}, - &StandardRangeStrategy{}, - &StringRangeExpressionStrategy{}, - &GitCommitStrategy{}, - &AffectedCPEStrategy{}, - &ZeroIntroducedSingleVersionStrategy{}, - &StandaloneSingleVersionStrategy{}, - } -} - -func LinuxStrategies() []VersionStrategy { - return []VersionStrategy{ - &StandardRangeStrategy{}, - &GitCommitIntroducedOnlyStrategy{}, - &ZeroIntroducedSingleVersionStrategy{}, - } -} - -func cpeVersionExtraction(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { - return (&CPEVersionStrategy{}).Extract(cve, metrics) -} diff --git a/vulnfeeds/conversion/cve5/strategies/changes_at.go b/vulnfeeds/conversion/cve5/strategies/changes_at.go new file mode 100644 index 00000000000..ad452834729 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/changes_at.go @@ -0,0 +1,47 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// ChangesAtStrategy extracts the fixed version from the vers.Changes list when status is 'unaffected'. +// +// Resulting OSV Range: [introduced: "17.7.0", fixed: "17.7.2"] +type ChangesAtStrategy struct{} + +func (s *ChangesAtStrategy) Name() string { + return "ChangesAt" +} + +func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + + var fixedFromChanges string + for _, ch := range vers.Changes { + if ch.Status == "unaffected" && ch.At != "" { + fixedFromChanges = ch.At + break + } + } + + if fixedFromChanges == "" { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Fixed from changes - %s", fixedFromChanges) + var introduced string + if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNote("Introduced from version value - %s", vers.Version) + } + + currentVersionType := ToVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange(introduced, "", fixedFromChanges)} + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} diff --git a/vulnfeeds/conversion/cve5/strategies/cpe.go b/vulnfeeds/conversion/cve5/strategies/cpe.go new file mode 100644 index 00000000000..717cd9973d4 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/cpe.go @@ -0,0 +1,155 @@ +package strategies + +import ( + "errors" + "strings" + + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// AffectedCPEStrategy extracts version ranges from CPE strings specified in affected.Cpes or version strings formatted as CPEs. +// +// Example CVE Record: +// +// "affected": [ +// { +// "cpes": ["cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"], +// "versions": [{ "status": "affected" }] +// } +// ] +// +// Resulting OSV Range: [introduced: "1.2.3", last_affected: "1.2.3"] +type AffectedCPEStrategy struct{} + +func (s *AffectedCPEStrategy) Name() string { + return "AffectedCPE" +} + +func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + cpeStr := "" + if strings.HasPrefix(vers.Version, "cpe:") { + cpeStr = vers.Version + } else if len(affected.Cpes) > 0 { + for _, cpe := range affected.Cpes { + if strings.HasPrefix(cpe, "cpe:") { + cpeStr = cpe + break + } + } + } + + if cpeStr == "" { + return nil, VersionRangeTypeUnknown, false + } + + parsedCPE, err := c.ParseCPE(cpeStr) + if err != nil || parsedCPE.Version == "" || parsedCPE.Version == "*" || parsedCPE.Version == "-" || parsedCPE.Version == "ANY" || parsedCPE.Version == "NA" { + return nil, VersionRangeTypeUnknown, false + } + + version := parsedCPE.Version + if parsedCPE.Update != "" && parsedCPE.Update != "*" && parsedCPE.Update != "-" && parsedCPE.Update != "ANY" && parsedCPE.Update != "NA" { + version += "." + parsedCPE.Update + } + + if !vulns.CheckQuality(version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Extracted version %s from CPE %s", version, cpeStr) + currentVersionType := ToVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange(version, version, "")} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) + for i := range rwms { + rwms[i].Metadata.CPE = cpeStr + rwms[i].Metadata.Versions = []string{version} + } + + return rwms, currentVersionType, true +} + +// CPEVersionStrategy extracts version ranges from the CVE's CPE applicability statements. +// +// Example CVE Record: +// +// "cpeApplicability": [ +// { +// "nodes": [{ +// "operator": "OR", +// "cpeMatch": [{ +// "vulnerable": true, +// "criteria": "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", +// "versionStartIncluding": "1.0.0", +// "versionEndExcluding": "2.0.0" +// }] +// }] +// } +// ] +// +// Resulting OSV Range: [introduced: "1.0.0", fixed: "2.0.0"] +type CPEVersionStrategy struct{} + +func (s *CPEVersionStrategy) Name() string { + return "CPEApplicability" +} + +func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { + cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) + if err == nil && len(cpeRanges) > 0 { + metrics.AddNote("Strategy successful: %s", s.Name()) + metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) + metrics.CPEs = vulns.Unique(cpeStrings) + + return cpeRanges, nil + } else if err != nil { + metrics.AddNote("%s", err.Error()) + } + + return nil, err +} + +// CPEVersionExtraction runs the CPEVersionStrategy on a CVE5 record. +func CPEVersionExtraction(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { + return (&CPEVersionStrategy{}).Extract(cve, metrics) +} + +// findCPEVersionRanges extracts version ranges and CPE strings from the CNA's +// CPE applicability statements in a CVE record. +func findCPEVersionRanges(cve models.CVE5) (versionRanges []models.RangeWithMetadata, cpes []string, err error) { + // TODO(jesslowe): Add logic to also extract CPEs from the 'affected' field (e.g., CVE-2025-1110). + for _, cpe := range cve.Containers.CNA.CPEApplicability { + for _, node := range cpe.Nodes { + if node.Operator != "OR" { + continue + } + for _, match := range node.CPEMatch { + if !match.Vulnerable { + continue + } + cpes = append(cpes, match.Criteria) + + // If no start version is given, assume the vulnerability starts from version "0". + if match.VersionStartIncluding == "" { + match.VersionStartIncluding = "0" + } + var nr []*osvschema.Range + if match.VersionEndExcluding != "" { + nr = append(nr, c.BuildVersionRange(match.VersionStartIncluding, "", match.VersionEndExcluding)) + } else if match.VersionEndIncluding != "" { + nr = append(nr, c.BuildVersionRange(match.VersionStartIncluding, match.VersionEndIncluding, "")) + } + if nr != nil { + versionRanges = append(versionRanges, c.ToRangeWithMetadata(nr, models.VersionSourceCPE)...) + } + } + } + } + if len(versionRanges) == 0 { + return nil, nil, errors.New("no versions extracted from CPEs") + } + + return versionRanges, cpes, nil +} diff --git a/vulnfeeds/conversion/cve5/strategies/git_commit.go b/vulnfeeds/conversion/cve5/strategies/git_commit.go new file mode 100644 index 00000000000..e6b30af679a --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/git_commit.go @@ -0,0 +1,80 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// GitCommitStrategy handles git commit versions by treating them as standalone git commits. +// +// Example CVE Record: +// +// { +// "version": "deadbeefcafebabe0123456789abcdef01234567", +// "status": "affected", +// "versionType": "git" +// } +// +// Resulting OSV Range: [introduced: "deadbeef...", last_affected: "deadbeef..."] +type GitCommitStrategy struct{} + +func (s *GitCommitStrategy) Name() string { + return "GitCommit" +} + +func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Git commit version found %v", vers.Version) + vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, vers.Version, "", affected.Repo)} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceGit) + for i := range rwms { + rwms[i].Metadata.Versions = []string{vers.Version} + } + + return rwms, VersionRangeTypeGit, true +} + +// GitCommitIntroducedOnlyStrategy treats a git commit version as an introduced-only point (used by Linux kernel). +// +// Example CVE Record (Linux Kernel git commits): +// +// { +// "version": "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", +// "status": "affected", +// "versionType": "git" +// } +// +// Resulting OSV Range: [introduced: "1da177e4c..."] +type GitCommitIntroducedOnlyStrategy struct{} + +func (s *GitCommitIntroducedOnlyStrategy) Name() string { + return "GitCommitIntroducedOnly" +} + +func (s *GitCommitIntroducedOnlyStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Git commit introduced found %v", vers.Version) + vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, "", "", affected.Repo)} + + return c.ToRangeWithMetadata(vr, models.VersionSourceGit), VersionRangeTypeGit, true +} diff --git a/vulnfeeds/conversion/cve5/strategies/presets.go b/vulnfeeds/conversion/cve5/strategies/presets.go new file mode 100644 index 00000000000..4a3b9e1c90f --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/presets.go @@ -0,0 +1,107 @@ +package strategies + +// Strategy Pipeline Presets for different CNAs + +func Default() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &VersionTextExtractionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func DefaultStrategies() []VersionStrategy { + return Default() +} + +func GitHub() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func GitHubStrategies() []VersionStrategy { + return GitHub() +} + +func WPScan() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func WPScanStrategies() []VersionStrategy { + return WPScan() +} + +func Wordfence() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func WordfenceStrategies() []VersionStrategy { + return Wordfence() +} + +func Patchstack() []VersionStrategy { + return []VersionStrategy{ + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func PatchstackStrategies() []VersionStrategy { + return Patchstack() +} + +func MITRE() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &AffectedCPEStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +func MITREStrategies() []VersionStrategy { + return MITRE() +} + +func Linux() []VersionStrategy { + return []VersionStrategy{ + &StandardRangeStrategy{}, + &GitCommitIntroducedOnlyStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} + +func LinuxStrategies() []VersionStrategy { + return Linux() +} diff --git a/vulnfeeds/conversion/cve5/strategies/single_version.go b/vulnfeeds/conversion/cve5/strategies/single_version.go new file mode 100644 index 00000000000..d02c6324bcc --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/single_version.go @@ -0,0 +1,79 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// ZeroIntroducedSingleVersionStrategy treats a single version value (when only 1 version is listed) +// as spanning from 0 to that version (e.g. WPScan, Wordfence, Linux, or single-version MITRE records). +// +// Example CVE Record (CVE-2015-10001 - WPScan / Wordfence / MITRE): +// +// { +// "version": "2.52", +// "status": "affected", +// "versionType": "custom" +// } +// +// Resulting OSV Range: [introduced: "0", last_affected: "2.52"] +type ZeroIntroducedSingleVersionStrategy struct{} + +func (s *ZeroIntroducedSingleVersionStrategy) Name() string { + return "ZeroIntroducedSingleVersion" +} + +func (s *ZeroIntroducedSingleVersionStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + if len(affected.Versions) > 1 { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) + currentVersionType := ToVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} + +// StandaloneSingleVersionStrategy treats a single version as an exact, standalone version (introduced == last_affected). +// +// Example CVE Record: +// +// { +// "version": "1.0.0", +// "status": "affected" +// } +// +// Resulting OSV Range: [introduced: "1.0.0", last_affected: "1.0.0"] +type StandaloneSingleVersionStrategy struct{} + +func (s *StandaloneSingleVersionStrategy) Name() string { + return "StandaloneSingleVersion" +} + +func (s *StandaloneSingleVersionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Single version found %v - Treating as standalone version", vers.Version) + currentVersionType := ToVersionRangeType(vers.VersionType) + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} + rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) + for i := range rwms { + rwms[i].Metadata.Versions = []string{vers.Version} + } + + return rwms, currentVersionType, true +} diff --git a/vulnfeeds/conversion/cve5/strategies/split_range.go b/vulnfeeds/conversion/cve5/strategies/split_range.go new file mode 100644 index 00000000000..0c56d79f690 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/split_range.go @@ -0,0 +1,131 @@ +package strategies + +import ( + "strings" + + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// SplitRangeStrategy handles split sequential version ranges +// (e.g. one entry with introduced and lessThan="unspecified", followed by an entry +// with version="unspecified" and lessThan/lessThanOrEqual). +// +// Example 1: Split Pair (CVE-2022-25929): +// +// "versions": [ +// { "version": "1.31.0", "lessThan": "unspecified", "status": "affected" }, +// { "version": "unspecified", "lessThan": "1.36.1", "status": "affected" } +// ] +// Resulting OSV Range: [introduced: "1.31.0", fixed: "1.36.1"] +// +// Example 2: Multi Split Sequences (CVE-2022-25761): +// +// "versions": [ +// { "version": "unspecified", "lessThan": "1.2.5", "status": "affected" }, +// { "version": "1.3-rc1", "lessThan": "unspecified", "status": "affected" }, +// { "version": "unspecified", "lessThan": "1.3.1", "status": "affected" } +// ] +// Resulting OSV Ranges: [introduced: "0", fixed: "1.2.5"], [introduced: "1.3-rc1", fixed: "1.3.1"] +// +// Example 3: Standalone Upper Bound (CVE-2022-25865): +// +// "versions": [ +// { "version": "unspecified", "lessThan": "0.18.4", "status": "affected" } +// ] +// Resulting OSV Range: [introduced: "0", fixed: "0.18.4"] +type SplitRangeStrategy struct{} + +func (s *SplitRangeStrategy) Name() string { + return "SplitRange" +} + +func isExplicitUnspecified(val string) bool { + clean := strings.TrimSpace(strings.ToLower(val)) + + return clean == "unspecified" +} + +func isValidSplitVersion(val string) bool { + clean := strings.TrimSpace(strings.ToLower(val)) + if clean == "" || clean == "unspecified" || clean == "n/a" || clean == "na" || clean == "*" || clean == "-" { + return false + } + + return vulns.CheckQuality(val).AtLeast(acceptableQuality) || val == "0" +} + +func isSplitIntroducedOnly(v models.Versions) bool { + return isValidSplitVersion(v.Version) && (isExplicitUnspecified(v.LessThan) || isExplicitUnspecified(v.LessThanOrEqual)) +} + +func isSplitUpperBoundOnly(v models.Versions) bool { + return isExplicitUnspecified(v.Version) && (isValidSplitVersion(v.LessThan) || isValidSplitVersion(v.LessThanOrEqual)) +} + +func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + + // Check for "unspecified" split or standalone ranges across affected.Versions + idx := -1 + for i, v := range affected.Versions { + if v.Version == vers.Version && v.LessThan == vers.LessThan && v.LessThanOrEqual == vers.LessThanOrEqual && v.Status == vers.Status && v.VersionType == vers.VersionType { + idx = i + break + } + } + + // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) + if isSplitIntroducedOnly(vers) { + currentVersionType := ToVersionRangeType(vers.VersionType) + // Check if the immediately following entry is an upper-bound entry + if idx >= 0 && idx+1 < len(affected.Versions) && isSplitUpperBoundOnly(affected.Versions[idx+1]) { + nextVers := affected.Versions[idx+1] + var fixed, lastAffected string + if isValidSplitVersion(nextVers.LessThan) { + fixed = nextVers.LessThan + } else if isValidSplitVersion(nextVers.LessThanOrEqual) { + lastAffected = nextVers.LessThanOrEqual + } + + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} + metrics.AddNote("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + // Standalone introduced entry without a following upper bound + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} + metrics.AddNote("Parsed standalone introduced version: %s", vers.Version) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + // Case B: Upper-bound-only entry (e.g. {version: "unspecified", lessThan: "1.36.1"}) + if isSplitUpperBoundOnly(vers) { + currentVersionType := ToVersionRangeType(vers.VersionType) + // If preceded by an introduced-only entry, it was already paired and consumed in Case A + if idx > 0 && isSplitIntroducedOnly(affected.Versions[idx-1]) { + return nil, currentVersionType, true + } + + // Standalone upper bound starting from 0 + var fixed, lastAffected string + if isValidSplitVersion(vers.LessThan) { + fixed = vers.LessThan + } else if isValidSplitVersion(vers.LessThanOrEqual) { + lastAffected = vers.LessThanOrEqual + } + + vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} + metrics.AddNote("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + } + + return nil, VersionRangeTypeUnknown, false +} diff --git a/vulnfeeds/conversion/cve5/strategies/standard_range.go b/vulnfeeds/conversion/cve5/strategies/standard_range.go new file mode 100644 index 00000000000..92a359dcc3c --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/standard_range.go @@ -0,0 +1,94 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// StandardRangeStrategy handles standard CVE 5.0 'lessThan' and 'lessThanOrEqual' range fields. +// +// Example CVE Record (CVE-2026-22104 - Hashtopolis): +// +// { +// "version": "0", +// "lessThan": "0.14.8", +// "status": "affected", +// "versionType": "semver" +// } +// +// Resulting OSV Range: [introduced: "0", fixed: "0.14.8"] +type StandardRangeStrategy struct{} + +func (s *StandardRangeStrategy) Name() string { + return "StandardRange" +} + +func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" { + return nil, VersionRangeTypeUnknown, false + } + + currentVersionType := ToVersionRangeType(vers.VersionType) + + vQuality := vulns.CheckQuality(vers.Version) + if !vQuality.AtLeast(acceptableQuality) { + metrics.AddNote("Version value is filler or empty") + } + vLessThanQual := vulns.CheckQuality(vers.LessThan) + vLTOEQual := vulns.CheckQuality(vers.LessThanOrEqual) + + hasRange := vLessThanQual.AtLeast(acceptableQuality) || vLTOEQual.AtLeast(acceptableQuality) + + // Handle cases where 'lessThan' or 'lessThanOrEqual' is mistakenly the same as 'version'. + if vers.LessThan != "" && vers.LessThan == vers.Version { + metrics.AddNote("Warning: lessThan (%s) is the same as introduced (%s)\n", vers.LessThan, vers.Version) + hasRange = false + } + if vers.LessThanOrEqual != "" && vers.LessThanOrEqual == vers.Version { + metrics.AddNote("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) + hasRange = false + } + + if !hasRange { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Range detected: %v", hasRange) + var introduced, fixed, lastaffected string + if vQuality.AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) + } + + if vLessThanQual.AtLeast(acceptableQuality) { + fixed = vers.LessThan + metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) + } else if vLTOEQual.AtLeast(acceptableQuality) { + lastaffected = vers.LessThanOrEqual + metrics.AddNote("%s - LastAffected from LessThanOrEqual value - %s", vLTOEQual.String(), vers.LessThanOrEqual) + } + + var versionRanges []*osvschema.Range + if fixed != "" { + versionRanges = append(versionRanges, c.BuildVersionRange(introduced, "", fixed)) + } else if lastaffected != "" { + versionRanges = append(versionRanges, c.BuildVersionRange(introduced, lastaffected, "")) + } + + if len(versionRanges) == 0 { + return nil, VersionRangeTypeUnknown, false + } + + for _, vr := range versionRanges { + if currentVersionType == VersionRangeTypeGit { + vr.Type = osvschema.Range_GIT + if affected.Repo != "" { + vr.Repo = affected.Repo + } + } + } + + return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), currentVersionType, true +} diff --git a/vulnfeeds/conversion/cve5/strategies/strategies_test.go b/vulnfeeds/conversion/cve5/strategies/strategies_test.go new file mode 100644 index 00000000000..6605f02f8bb --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/strategies_test.go @@ -0,0 +1,354 @@ +package strategies + +import ( + "testing" + + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +func TestToVersionRangeType(t *testing.T) { + tests := []struct { + name string + input string + want VersionRangeType + }{ + {"git", "git", VersionRangeTypeGit}, + {"semver", "semver", VersionRangeTypeSemver}, + {"ecosystem", "ecosystem", VersionRangeTypeEcosystem}, + {"other", "custom", VersionRangeTypeEcosystem}, + {"empty", "", VersionRangeTypeEcosystem}, + {"case insensitive", "GiT", VersionRangeTypeGit}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ToVersionRangeType(tt.input); got != tt.want { + t.Errorf("ToVersionRangeType() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestStrategies(t *testing.T) { + t.Parallel() + + t.Run("StandardRangeStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StandardRangeStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StandardRangeStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.5.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("ChangesAtStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &ChangesAtStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + Changes: []models.Change{ + {Status: "unaffected", At: "1.0.1"}, + }, + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("ChangesAtStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.0.1" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("StringRangeExpressionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StringRangeExpressionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: ">= 1.2.0, < 2.0.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StringRangeExpressionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.2.0" || events[1].GetFixed() != "2.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("ZeroIntroducedSingleVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &ZeroIntroducedSingleVersionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "2.52", + VersionType: "custom", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeEcosystem || len(ranges) != 1 { + t.Fatalf("ZeroIntroducedSingleVersionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "0" || events[1].GetLastAffected() != "2.52" { + t.Errorf("unexpected events: %+v", events) + } + + // Should not handle if multiple versions are listed + multiAffected := models.Affected{ + Versions: []models.Versions{ + {Status: "affected", Version: "3.0.0"}, + {Status: "affected", Version: "3.1.0"}, + }, + } + _, _, handledMulti := strategy.Extract(multiAffected.Versions[0], multiAffected, metrics) + if handledMulti { + t.Errorf("ZeroIntroducedSingleVersionStrategy should not handle multiple versions") + } + }) + + t.Run("SplitRangeStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &SplitRangeStrategy{} + + // 1. Split unspecified pair (e.g., CVE-2022-25929) + affectedSplit := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.31.0", + LessThan: "unspecified", + VersionType: "custom", + }, + { + Status: "affected", + Version: "unspecified", + LessThan: "1.36.1", + VersionType: "custom", + }, + }, + } + + ranges1, _, handled1 := strategy.Extract(affectedSplit.Versions[0], affectedSplit, metrics) + if !handled1 || len(ranges1) != 1 { + t.Fatalf("SplitRangeStrategy failed to extract first split entry: %+v", ranges1) + } + events1 := ranges1[0].Range.GetEvents() + if events1[0].GetIntroduced() != "1.31.0" || events1[1].GetFixed() != "1.36.1" { + t.Errorf("unexpected events from first split entry: %+v", events1) + } + + ranges2, _, handled2 := strategy.Extract(affectedSplit.Versions[1], affectedSplit, metrics) + if !handled2 || len(ranges2) != 0 { + t.Errorf("expected second split entry to be consumed, got: %+v", ranges2) + } + + // 2. Standalone upper bound (e.g., CVE-2022-25865) + affectedUpper := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "unspecified", + LessThan: "0.18.4", + VersionType: "custom", + }, + }, + } + rangesUpper, _, handledUpper := strategy.Extract(affectedUpper.Versions[0], affectedUpper, metrics) + if !handledUpper || len(rangesUpper) != 1 { + t.Fatalf("SplitRangeStrategy failed to extract standalone upper bound: %+v", rangesUpper) + } + eventsUpper := rangesUpper[0].Range.GetEvents() + if eventsUpper[0].GetIntroduced() != "0" || eventsUpper[1].GetFixed() != "0.18.4" { + t.Errorf("unexpected events from standalone upper bound: %+v", eventsUpper) + } + }) + + t.Run("GitCommitStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &GitCommitStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", + VersionType: "git", + } + affected := models.Affected{ + Repo: "https://github.com/GeneralSandman/TinyWeb", + } + ranges, vrt, handled := strategy.Extract(vers, affected, metrics) + if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + t.Fatalf("GitCommitStrategy failed to extract range") + } + if ranges[0].Range.GetType() != osvschema.Range_GIT || ranges[0].Range.GetRepo() != "https://github.com/GeneralSandman/TinyWeb" { + t.Errorf("unexpected range properties: %+v", ranges[0].Range) + } + }) + + t.Run("GitCommitIntroducedOnlyStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &GitCommitIntroducedOnlyStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", + VersionType: "git", + } + affected := models.Affected{ + Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + } + ranges, vrt, handled := strategy.Extract(vers, affected, metrics) + if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + t.Fatalf("GitCommitIntroducedOnlyStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if len(events) != 1 || events[0].GetIntroduced() != "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("StandaloneSingleVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &StandaloneSingleVersionStrategy{} + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + } + ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) + if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + t.Fatalf("StandaloneSingleVersionStrategy failed to extract range") + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetLastAffected() != "1.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("PipelineOrderPrecedence", func(t *testing.T) { + t.Parallel() + vers := models.Versions{ + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + } + affected := models.Affected{ + Versions: []models.Versions{vers}, + } + + // When StandardRangeStrategy comes first: + pipeline1 := []VersionStrategy{ + &StandardRangeStrategy{}, + &StandaloneSingleVersionStrategy{}, + } + var ranges1 []models.RangeWithMetadata + for _, s := range pipeline1 { + if r, _, handled := s.Extract(vers, affected, &models.ConversionMetrics{}); handled { + ranges1 = r + break + } + } + if len(ranges1) != 1 || ranges1[0].Range.GetEvents()[1].GetFixed() != "1.5.0" { + t.Fatalf("expected StandardRangeStrategy to handle first, got: %+v", ranges1) + } + + // When StandaloneSingleVersionStrategy comes first: + pipeline2 := []VersionStrategy{ + &StandaloneSingleVersionStrategy{}, + &StandardRangeStrategy{}, + } + var ranges2 []models.RangeWithMetadata + for _, s := range pipeline2 { + if r, _, handled := s.Extract(vers, affected, &models.ConversionMetrics{}); handled { + ranges2 = r + break + } + } + if len(ranges2) != 1 || ranges2[0].Range.GetEvents()[1].GetLastAffected() != "1.0.0" { + t.Fatalf("expected StandaloneSingleVersionStrategy to handle first when placed earlier, got: %+v", ranges2) + } + }) + + t.Run("AffectedCPEStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &AffectedCPEStrategy{} + affected := models.Affected{ + Cpes: []string{"cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"}, + } + vers := models.Versions{ + Status: "affected", + } + ranges, _, handled := strategy.Extract(vers, affected, metrics) + if !handled || len(ranges) != 1 { + t.Fatalf("AffectedCPEStrategy failed to extract range") + } + if ranges[0].Metadata.CPE != "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*" { + t.Errorf("unexpected CPE metadata: %s", ranges[0].Metadata.CPE) + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.2.3" || events[1].GetLastAffected() != "1.2.3" { + t.Errorf("unexpected events: %+v", events) + } + }) + + t.Run("CPEVersionStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &CPEVersionStrategy{} + cve := models.CVE5{} + cve.Containers.CNA.CPEApplicability = []models.CPE{ + { + Nodes: []models.CPENode{ + { + Operator: "OR", + CPEMatch: []struct { + Vulnerable bool `json:"vulnerable,omitempty"` + Criteria string `json:"criteria,omitempty"` + VersionEndIncluding string `json:"versionEndIncluding,omitempty"` + VersionStartExcluding string `json:"versionStartExcluding,omitempty" mapstructure:"versionStartExcluding,omitempty" yaml:"versionStartExcluding,omitempty"` + VersionStartIncluding string `json:"versionStartIncluding,omitempty" mapstructure:"versionStartIncluding,omitempty" yaml:"versionStartIncluding,omitempty"` + VersionEndExcluding string `json:"versionEndExcluding,omitempty"` + }{ + { + Vulnerable: true, + Criteria: "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", + VersionStartIncluding: "1.0.0", + VersionEndExcluding: "2.0.0", + }, + }, + }, + }, + }, + } + ranges, err := strategy.Extract(cve, metrics) + if err != nil || len(ranges) != 1 { + t.Fatalf("CPEVersionStrategy failed to extract range: %v", err) + } + events := ranges[0].Range.GetEvents() + if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "2.0.0" { + t.Errorf("unexpected events: %+v", events) + } + }) +} diff --git a/vulnfeeds/conversion/cve5/strategies/strategy.go b/vulnfeeds/conversion/cve5/strategies/strategy.go new file mode 100644 index 00000000000..6eec725fe0a --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/strategy.go @@ -0,0 +1,58 @@ +// Package strategies provides version extraction strategies and CNA pipelines for CVE 5.0 records. +package strategies + +import ( + "strings" + + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/vulns" +) + +// VersionRangeType represents the type of versioning scheme for a range. +type VersionRangeType int + +const ( + VersionRangeTypeUnknown VersionRangeType = iota + VersionRangeTypeGit + VersionRangeTypeSemver + VersionRangeTypeEcosystem +) + +// String returns the string representation of a VersionRangeType. +func (vrt VersionRangeType) String() string { + switch vrt { + case VersionRangeTypeGit: + return "git" + case VersionRangeTypeEcosystem: + return "ecosystem" + case VersionRangeTypeSemver: + return "semver" + default: + return "unknown" + } +} + +// ToVersionRangeType converts a string to a VersionRangeType. +func ToVersionRangeType(s string) VersionRangeType { + switch strings.ToLower(s) { + case "git": + return VersionRangeTypeGit + case "semver": + return VersionRangeTypeSemver + default: + // Other version types like "custom" are treated as ecosystem ranges. + return VersionRangeTypeEcosystem + } +} + +const acceptableQuality = vulns.Spaces + +// VersionStrategy defines the contract for an individual version extraction strategy. +// Strategies are evaluated in the sequential order they appear in the configured slice. +type VersionStrategy interface { + // Name returns a human-readable identifier for the strategy. + Name() string + // Extract attempts to extract OSV version ranges from a CVE5 Versions entry. + // Returns the extracted ranges, the detected VersionRangeType, and true if this strategy handled the entry. + Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) (ranges []models.RangeWithMetadata, vrt VersionRangeType, handled bool) +} diff --git a/vulnfeeds/conversion/cve5/strategies/string_range_expression.go b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go new file mode 100644 index 00000000000..d8ecc3767ab --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go @@ -0,0 +1,52 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/git" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// StringRangeExpressionStrategy handles range expressions embedded within the version field. +// +// Example CVE Record (CVE-2024-21634 - Puma / GitHub_M): +// +// { +// "version": ">= 2.0, < 2.5", +// "status": "affected", +// "versionType": "semver" +// } +// +// Resulting OSV Range: [introduced: "2.0", fixed: "2.5"] +type StringRangeExpressionStrategy struct{} + +func (s *StringRangeExpressionStrategy) Name() string { + return "StringRangeExpression" +} + +func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + + av, err := git.ParseVersionRange(vers.Version) + if err != nil || av.Introduced == "" { + return nil, VersionRangeTypeUnknown, false + } + + currentVersionType := ToVersionRangeType(vers.VersionType) + var vr []*osvschema.Range + if av.Fixed != "" { + vr = append(vr, c.BuildVersionRange(av.Introduced, "", av.Fixed)) + } else if av.LastAffected != "" { + vr = append(vr, c.BuildVersionRange(av.Introduced, av.LastAffected, "")) + } + + if len(vr) == 0 { + return nil, VersionRangeTypeUnknown, false + } + + metrics.AddNote("Parsed range expression from version: %s", vers.Version) + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true +} diff --git a/vulnfeeds/conversion/cve5/strategies/text_extraction.go b/vulnfeeds/conversion/cve5/strategies/text_extraction.go new file mode 100644 index 00000000000..9f00a390a91 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/text_extraction.go @@ -0,0 +1,33 @@ +package strategies + +import ( + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" +) + +// VersionTextExtractionStrategy handles natural text version patterns. +// +// Example CVE Record: +// +// { +// "version": "Fixed in version 2.4.1 and higher", +// "status": "affected" +// } +type VersionTextExtractionStrategy struct{} + +func (s *VersionTextExtractionStrategy) Name() string { + return "VersionTextExtraction" +} + +func (s *VersionTextExtractionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { + if vers.Status != "affected" || vers.Version == "" { + return nil, VersionRangeTypeUnknown, false + } + + possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) + if possibleVersions != nil { + metrics.AddNote("Versions retrieved from text but not used CURRENTLY") + } + + return nil, VersionRangeTypeUnknown, false +} diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 1542e5668c0..f72bc37bac8 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -9,6 +9,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/internal/testutils" "github.com/google/osv.dev/vulnfeeds/models" @@ -489,311 +490,42 @@ func TestGetVersionExtractor(t *testing.T) { } } -func TestStrategies(t *testing.T) { +func TestDefaultVersionExtractor_SliceOrderPrecedence(t *testing.T) { t.Parallel() - - t.Run("StandardRangeStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &StandardRangeStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - LessThan: "1.5.0", - VersionType: "semver", - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { - t.Fatalf("StandardRangeStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.5.0" { - t.Errorf("unexpected events: %+v", events) - } - }) - - t.Run("ChangesAtStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &ChangesAtStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - VersionType: "semver", - Changes: []models.Change{ - {Status: "unaffected", At: "1.0.1"}, - }, - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { - t.Fatalf("ChangesAtStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "1.0.1" { - t.Errorf("unexpected events: %+v", events) - } - }) - - t.Run("StringRangeExpressionStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &StringRangeExpressionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: ">= 1.2.0, < 2.0.0", - VersionType: "semver", - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { - t.Fatalf("StringRangeExpressionStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.2.0" || events[1].GetFixed() != "2.0.0" { - t.Errorf("unexpected events: %+v", events) - } - }) - - t.Run("ZeroIntroducedSingleVersionStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &ZeroIntroducedSingleVersionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "2.52", - VersionType: "custom", - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeEcosystem || len(ranges) != 1 { - t.Fatalf("ZeroIntroducedSingleVersionStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "0" || events[1].GetLastAffected() != "2.52" { - t.Errorf("unexpected events: %+v", events) - } - - // Should not handle if multiple versions are listed - multiAffected := models.Affected{ - Versions: []models.Versions{ - {Status: "affected", Version: "3.0.0"}, - {Status: "affected", Version: "3.1.0"}, - }, - } - _, _, handledMulti := strategy.Extract(multiAffected.Versions[0], multiAffected, metrics) - if handledMulti { - t.Errorf("ZeroIntroducedSingleVersionStrategy should not handle multiple versions") - } - }) - - t.Run("SplitRangeStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &SplitRangeStrategy{} - - // 1. Split unspecified pair (e.g., CVE-2022-25929) - affectedSplit := models.Affected{ - Versions: []models.Versions{ - { - Status: "affected", - Version: "1.31.0", - LessThan: "unspecified", - VersionType: "custom", - }, - { - Status: "affected", - Version: "unspecified", - LessThan: "1.36.1", - VersionType: "custom", - }, - }, - } - - ranges1, _, handled1 := strategy.Extract(affectedSplit.Versions[0], affectedSplit, metrics) - if !handled1 || len(ranges1) != 1 { - t.Fatalf("SplitRangeStrategy failed to extract first split entry: %+v", ranges1) - } - events1 := ranges1[0].Range.GetEvents() - if events1[0].GetIntroduced() != "1.31.0" || events1[1].GetFixed() != "1.36.1" { - t.Errorf("unexpected events from first split entry: %+v", events1) - } - - ranges2, _, handled2 := strategy.Extract(affectedSplit.Versions[1], affectedSplit, metrics) - if !handled2 || len(ranges2) != 0 { - t.Errorf("expected second split entry to be consumed, got: %+v", ranges2) - } - - // 2. Standalone upper bound (e.g., CVE-2022-25865) - affectedUpper := models.Affected{ - Versions: []models.Versions{ - { - Status: "affected", - Version: "unspecified", - LessThan: "0.18.4", - VersionType: "custom", - }, - }, - } - rangesUpper, _, handledUpper := strategy.Extract(affectedUpper.Versions[0], affectedUpper, metrics) - if !handledUpper || len(rangesUpper) != 1 { - t.Fatalf("SplitRangeStrategy failed to extract standalone upper bound: %+v", rangesUpper) - } - eventsUpper := rangesUpper[0].Range.GetEvents() - if eventsUpper[0].GetIntroduced() != "0" || eventsUpper[1].GetFixed() != "0.18.4" { - t.Errorf("unexpected events from standalone upper bound: %+v", eventsUpper) - } - }) - - t.Run("GitCommitStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &GitCommitStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", - VersionType: "git", - } - affected := models.Affected{ - Repo: "https://github.com/GeneralSandman/TinyWeb", - } - ranges, vrt, handled := strategy.Extract(vers, affected, metrics) - if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { - t.Fatalf("GitCommitStrategy failed to extract range") - } - if ranges[0].Range.GetType() != osvschema.Range_GIT || ranges[0].Range.GetRepo() != "https://github.com/GeneralSandman/TinyWeb" { - t.Errorf("unexpected range properties: %+v", ranges[0].Range) - } - }) - - t.Run("GitCommitIntroducedOnlyStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &GitCommitIntroducedOnlyStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", - VersionType: "git", - } - affected := models.Affected{ - Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", - } - ranges, vrt, handled := strategy.Extract(vers, affected, metrics) - if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { - t.Fatalf("GitCommitIntroducedOnlyStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if len(events) != 1 || events[0].GetIntroduced() != "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2" { - t.Errorf("unexpected events: %+v", events) - } - }) - - t.Run("StandaloneSingleVersionStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &StandaloneSingleVersionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - VersionType: "semver", - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { - t.Fatalf("StandaloneSingleVersionStrategy failed to extract range") - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.0.0" || events[1].GetLastAffected() != "1.0.0" { - t.Errorf("unexpected events: %+v", events) - } - }) - - t.Run("StrategyPrioritySorting", func(t *testing.T) { - t.Parallel() - extractor := &DefaultVersionExtractor{ - Strategies: []VersionStrategy{ - &StandaloneSingleVersionStrategy{}, // PriorityLastResort (300) - &StandardRangeStrategy{}, // PriorityStandard (200) - &ChangesAtStrategy{}, // PriorityFirst (100) + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", }, - } - sorted := extractor.getStrategies() - if len(sorted) != 3 { - t.Fatalf("expected 3 strategies, got %d", len(sorted)) - } - if sorted[0].Name() != "ChangesAt" || sorted[0].Priority() != PriorityFirst { - t.Errorf("expected first strategy to be ChangesAt, got %s", sorted[0].Name()) - } - if sorted[1].Name() != "StandardRange" || sorted[1].Priority() != PriorityStandard { - t.Errorf("expected second strategy to be StandardRange, got %s", sorted[1].Name()) - } - if sorted[2].Name() != "StandaloneSingleVersion" || sorted[2].Priority() != PriorityLastResort { - t.Errorf("expected third strategy to be StandaloneSingleVersion, got %s", sorted[2].Name()) - } - - boosted := WithPriority(&StandaloneSingleVersionStrategy{}, PriorityFirst-10) - if boosted.Priority() >= PriorityFirst { - t.Errorf("expected boosted priority to be lower than PriorityFirst") - } - }) + }, + } - t.Run("AffectedCPEStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &AffectedCPEStrategy{} - affected := models.Affected{ - Cpes: []string{"cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"}, - } - vers := models.Versions{ - Status: "affected", - } - ranges, _, handled := strategy.Extract(vers, affected, metrics) - if !handled || len(ranges) != 1 { - t.Fatalf("AffectedCPEStrategy failed to extract range") - } - if ranges[0].Metadata.CPE != "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*" { - t.Errorf("unexpected CPE metadata: %s", ranges[0].Metadata.CPE) - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.2.3" || events[1].GetLastAffected() != "1.2.3" { - t.Errorf("unexpected events: %+v", events) - } - }) + // When StandardRangeStrategy is first in slice order: + extractor1 := &DefaultVersionExtractor{ + Strategies: []strategies.VersionStrategy{ + &strategies.StandardRangeStrategy{}, + &strategies.StandaloneSingleVersionStrategy{}, + }, + } + ranges1, _ := extractor1.FindNormalAffectedRanges(affected, &models.ConversionMetrics{}) + if len(ranges1) != 1 || ranges1[0].Range.GetEvents()[1].GetFixed() != "1.5.0" { + t.Fatalf("expected StandardRangeStrategy to match first, got: %+v", ranges1) + } - t.Run("CPEVersionStrategy", func(t *testing.T) { - t.Parallel() - metrics := &models.ConversionMetrics{} - strategy := &CPEVersionStrategy{} - cve := models.CVE5{} - cve.Containers.CNA.CPEApplicability = []models.CPE{ - { - Nodes: []models.CPENode{ - { - Operator: "OR", - CPEMatch: []struct { - Vulnerable bool `json:"vulnerable,omitempty"` - Criteria string `json:"criteria,omitempty"` - VersionEndIncluding string `json:"versionEndIncluding,omitempty"` - VersionStartExcluding string `json:"versionStartExcluding,omitempty" mapstructure:"versionStartExcluding,omitempty" yaml:"versionStartExcluding,omitempty"` - VersionStartIncluding string `json:"versionStartIncluding,omitempty" mapstructure:"versionStartIncluding,omitempty" yaml:"versionStartIncluding,omitempty"` - VersionEndExcluding string `json:"versionEndExcluding,omitempty"` - }{ - { - Vulnerable: true, - Criteria: "cpe:2.3:a:vendor:product:*:*:*:*:*:*:*:*", - VersionStartIncluding: "1.0.0", - VersionEndExcluding: "2.0.0", - }, - }, - }, - }, - }, - } - ranges, err := strategy.Extract(cve, metrics) - if err != nil || len(ranges) != 1 { - t.Fatalf("CPEVersionStrategy failed to extract range: %v", err) - } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "2.0.0" { - t.Errorf("unexpected events: %+v", events) - } - }) + // When StandaloneSingleVersionStrategy is first in slice order: + extractor2 := &DefaultVersionExtractor{ + Strategies: []strategies.VersionStrategy{ + &strategies.StandaloneSingleVersionStrategy{}, + &strategies.StandardRangeStrategy{}, + }, + } + ranges2, _ := extractor2.FindNormalAffectedRanges(affected, &models.ConversionMetrics{}) + if len(ranges2) != 1 || ranges2[0].Range.GetEvents()[1].GetLastAffected() != "1.0.0" { + t.Fatalf("expected StandaloneSingleVersionStrategy to match first when placed earlier, got: %+v", ranges2) + } } func TestExtractVersions(t *testing.T) { From b66b69b4d180cc52e1312de54c9b12b4b658028f Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 21 Sep 2026 00:08:22 +0000 Subject: [PATCH 08/12] fix instances of addnote --- vulnfeeds/conversion/common.go | 2 +- vulnfeeds/conversion/cve5/default_extractor.go | 2 +- vulnfeeds/conversion/cve5/extraction.go | 4 +--- vulnfeeds/conversion/cve5/strategies/changes_at.go | 4 ++-- vulnfeeds/conversion/cve5/strategies/cpe.go | 6 +++--- vulnfeeds/conversion/cve5/strategies/git_commit.go | 4 ++-- .../conversion/cve5/strategies/single_version.go | 4 ++-- .../conversion/cve5/strategies/split_range.go | 6 +++--- .../conversion/cve5/strategies/standard_range.go | 14 +++++++------- .../cve5/strategies/string_range_expression.go | 2 +- .../conversion/cve5/strategies/text_extraction.go | 2 +- vulnfeeds/conversion/versions.go | 2 +- vulnfeeds/models/metrics.go | 2 +- 13 files changed, 26 insertions(+), 28 deletions(-) diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 4d626b8a014..274fb03560a 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -803,7 +803,7 @@ func ProcessRanges(ranges []models.RangeWithMetadata, repos []string, metrics *m resolvedRanges = append(resolvedRanges, vr) successfulRepos = append(successfulRepos, repo) } else { - metrics.AddNote("no repository available for git commit range") + metrics.AddNotef("no repository available for git commit range") unresolvedRanges = append(unresolvedRanges, vr) } } else { diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index 74d2e0bfaa3..bdb340e1495 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -142,7 +142,7 @@ func (d *DefaultVersionExtractor) FindNormalAffectedRanges(affected models.Affec ranges, currentVersionType, handled := strategy.Extract(vers, affected, metrics) if handled { if len(ranges) > 0 { - metrics.AddNote("Strategy successful: %s", strategy.Name()) + metrics.AddNotef("Strategy successful: %s", strategy.Name()) versionTypesCount[currentVersionType]++ versionRanges = append(versionRanges, ranges...) } diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index 19d1ef7d508..6238274693c 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -21,9 +21,7 @@ func GetVersionExtractor(cna string) VersionExtractor { switch strings.ToLower(cna) { case "linux": return &LinuxVersionExtractor{ - DefaultVersionExtractor: DefaultVersionExtractor{ - Strategies: strategies.LinuxStrategies(), - }, + Strategies: strategies.LinuxStrategies(), } case "github_m", "github": return &DefaultVersionExtractor{ diff --git a/vulnfeeds/conversion/cve5/strategies/changes_at.go b/vulnfeeds/conversion/cve5/strategies/changes_at.go index ad452834729..bdb355abe77 100644 --- a/vulnfeeds/conversion/cve5/strategies/changes_at.go +++ b/vulnfeeds/conversion/cve5/strategies/changes_at.go @@ -33,11 +33,11 @@ func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, met return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Fixed from changes - %s", fixedFromChanges) + metrics.AddNotef("Fixed from changes - %s", fixedFromChanges) var introduced string if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { introduced = vers.Version - metrics.AddNote("Introduced from version value - %s", vers.Version) + metrics.AddNotef("Introduced from version value - %s", vers.Version) } currentVersionType := ToVersionRangeType(vers.VersionType) diff --git a/vulnfeeds/conversion/cve5/strategies/cpe.go b/vulnfeeds/conversion/cve5/strategies/cpe.go index 717cd9973d4..179b31cc370 100644 --- a/vulnfeeds/conversion/cve5/strategies/cpe.go +++ b/vulnfeeds/conversion/cve5/strategies/cpe.go @@ -59,7 +59,7 @@ func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affe return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Extracted version %s from CPE %s", version, cpeStr) + metrics.AddNotef("Extracted version %s from CPE %s", version, cpeStr) currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange(version, version, "")} rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) @@ -99,13 +99,13 @@ func (s *CPEVersionStrategy) Name() string { func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) if err == nil && len(cpeRanges) > 0 { - metrics.AddNote("Strategy successful: %s", s.Name()) + metrics.AddNotef("Strategy successful: %s", s.Name()) metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) metrics.CPEs = vulns.Unique(cpeStrings) return cpeRanges, nil } else if err != nil { - metrics.AddNote("%s", err.Error()) + metrics.AddNotef("%s", err.Error()) } return nil, err diff --git a/vulnfeeds/conversion/cve5/strategies/git_commit.go b/vulnfeeds/conversion/cve5/strategies/git_commit.go index e6b30af679a..5f9b8c41aeb 100644 --- a/vulnfeeds/conversion/cve5/strategies/git_commit.go +++ b/vulnfeeds/conversion/cve5/strategies/git_commit.go @@ -35,7 +35,7 @@ func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affect return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Git commit version found %v", vers.Version) + metrics.AddNotef("Git commit version found %v", vers.Version) vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, vers.Version, "", affected.Repo)} rwms := c.ToRangeWithMetadata(vr, models.VersionSourceGit) for i := range rwms { @@ -73,7 +73,7 @@ func (s *GitCommitIntroducedOnlyStrategy) Extract(vers models.Versions, affected return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Git commit introduced found %v", vers.Version) + metrics.AddNotef("Git commit introduced found %v", vers.Version) vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, "", "", affected.Repo)} return c.ToRangeWithMetadata(vr, models.VersionSourceGit), VersionRangeTypeGit, true diff --git a/vulnfeeds/conversion/cve5/strategies/single_version.go b/vulnfeeds/conversion/cve5/strategies/single_version.go index d02c6324bcc..4014b9086d6 100644 --- a/vulnfeeds/conversion/cve5/strategies/single_version.go +++ b/vulnfeeds/conversion/cve5/strategies/single_version.go @@ -36,7 +36,7 @@ func (s *ZeroIntroducedSingleVersionStrategy) Extract(vers models.Versions, affe return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) + metrics.AddNotef("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} @@ -67,7 +67,7 @@ func (s *StandaloneSingleVersionStrategy) Extract(vers models.Versions, _ models return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Single version found %v - Treating as standalone version", vers.Version) + metrics.AddNotef("Single version found %v - Treating as standalone version", vers.Version) currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) diff --git a/vulnfeeds/conversion/cve5/strategies/split_range.go b/vulnfeeds/conversion/cve5/strategies/split_range.go index 0c56d79f690..4019abd02c3 100644 --- a/vulnfeeds/conversion/cve5/strategies/split_range.go +++ b/vulnfeeds/conversion/cve5/strategies/split_range.go @@ -93,14 +93,14 @@ func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affec } vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} - metrics.AddNote("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) + metrics.AddNotef("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true } // Standalone introduced entry without a following upper bound vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} - metrics.AddNote("Parsed standalone introduced version: %s", vers.Version) + metrics.AddNotef("Parsed standalone introduced version: %s", vers.Version) return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true } @@ -122,7 +122,7 @@ func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affec } vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} - metrics.AddNote("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) + metrics.AddNotef("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true } diff --git a/vulnfeeds/conversion/cve5/strategies/standard_range.go b/vulnfeeds/conversion/cve5/strategies/standard_range.go index 92a359dcc3c..dcbf2fa261c 100644 --- a/vulnfeeds/conversion/cve5/strategies/standard_range.go +++ b/vulnfeeds/conversion/cve5/strategies/standard_range.go @@ -34,7 +34,7 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af vQuality := vulns.CheckQuality(vers.Version) if !vQuality.AtLeast(acceptableQuality) { - metrics.AddNote("Version value is filler or empty") + metrics.AddNotef("Version value is filler or empty") } vLessThanQual := vulns.CheckQuality(vers.LessThan) vLTOEQual := vulns.CheckQuality(vers.LessThanOrEqual) @@ -43,11 +43,11 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af // Handle cases where 'lessThan' or 'lessThanOrEqual' is mistakenly the same as 'version'. if vers.LessThan != "" && vers.LessThan == vers.Version { - metrics.AddNote("Warning: lessThan (%s) is the same as introduced (%s)\n", vers.LessThan, vers.Version) + metrics.AddNotef("Warning: lessThan (%s) is the same as introduced (%s)\n", vers.LessThan, vers.Version) hasRange = false } if vers.LessThanOrEqual != "" && vers.LessThanOrEqual == vers.Version { - metrics.AddNote("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) + metrics.AddNotef("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) hasRange = false } @@ -55,19 +55,19 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Range detected: %v", hasRange) + metrics.AddNotef("Range detected: %v", hasRange) var introduced, fixed, lastaffected string if vQuality.AtLeast(acceptableQuality) { introduced = vers.Version - metrics.AddNote("%s - Introduced from version value - %s", vQuality.String(), vers.Version) + metrics.AddNotef("%s - Introduced from version value - %s", vQuality.String(), vers.Version) } if vLessThanQual.AtLeast(acceptableQuality) { fixed = vers.LessThan - metrics.AddNote("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) + metrics.AddNotef("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) } else if vLTOEQual.AtLeast(acceptableQuality) { lastaffected = vers.LessThanOrEqual - metrics.AddNote("%s - LastAffected from LessThanOrEqual value - %s", vLTOEQual.String(), vers.LessThanOrEqual) + metrics.AddNotef("%s - LastAffected from LessThanOrEqual value - %s", vLTOEQual.String(), vers.LessThanOrEqual) } var versionRanges []*osvschema.Range diff --git a/vulnfeeds/conversion/cve5/strategies/string_range_expression.go b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go index d8ecc3767ab..74c696f4d4e 100644 --- a/vulnfeeds/conversion/cve5/strategies/string_range_expression.go +++ b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go @@ -46,7 +46,7 @@ func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.A return nil, VersionRangeTypeUnknown, false } - metrics.AddNote("Parsed range expression from version: %s", vers.Version) + metrics.AddNotef("Parsed range expression from version: %s", vers.Version) return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true } diff --git a/vulnfeeds/conversion/cve5/strategies/text_extraction.go b/vulnfeeds/conversion/cve5/strategies/text_extraction.go index 9f00a390a91..a0f8e1b0af9 100644 --- a/vulnfeeds/conversion/cve5/strategies/text_extraction.go +++ b/vulnfeeds/conversion/cve5/strategies/text_extraction.go @@ -26,7 +26,7 @@ func (s *VersionTextExtractionStrategy) Extract(vers models.Versions, _ models.A possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) if possibleVersions != nil { - metrics.AddNote("Versions retrieved from text but not used CURRENTLY") + metrics.AddNotef("Versions retrieved from text but not used CURRENTLY") } return nil, VersionRangeTypeUnknown, false diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 64a17f53ec9..3dcb099a7c1 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -1238,7 +1238,7 @@ func ReposFromReferences(cache *VPRepoCache, vp *VendorProduct, refs []models.Re if err == nil { repo = canonicalRepo } else { - metrics.AddNote("Failed to find canonical link for %s: %v", repo, err) + metrics.AddNotef("Failed to find canonical link for %s: %v", repo, err) if git.IsRateLimit(err) { metrics.SetError(err) return nil diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 3176417d48d..2d091485b17 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -109,7 +109,7 @@ func (m *ConversionMetrics) SetOutcome(outcome ConversionOutcome) { func (m *ConversionMetrics) SetError(err error) { m.Outcome = Error if err != nil { - m.AddNote("Conversion error: %v", err) + m.AddNotef("Conversion error: %v", err) } } From f8d1747bb6690fe4b34e0876bdf8986e0c8ed2bf Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Mon, 21 Sep 2026 03:06:01 +0000 Subject: [PATCH 09/12] fix test --- .../cve5/__snapshots__/converter_test.snap | 72 +++++++++++++++++++ .../cve5/version_extraction_test.go | 3 +- 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap index 0ac8a87964c..de1ea01eab7 100755 --- a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap @@ -4146,3 +4146,75 @@ "summary": "Xz: malicious code in distributed source" } --- + +[TestCVE5Snapshot/CVE-2026-67185.json - 1] +{ + "affected": [ + { + "ranges": [ + { + "events": [ + { + "introduced": "0b3b5fdb5a058f50248cd8547824936b8dd10351" + }, + { + "last_affected": "a381da252fe8e873c8aff22703040426cc9b2ae0" + } + ], + "repo": "https://github.com/GeneralSandman/TinyWeb", + "type": "GIT" + } + ] + } + ], + "database_specific": { + "cna_assigner": "VulnCheck", + "cwe_ids": [ + "CWE-22" + ], + "osv_generated_from": "unknown", + "unresolved_ranges": [ + { + "extracted_events": [ + { + "introduced": "0.0.8" + }, + { + "last_affected": "0.0.8" + } + ], + "source": "AFFECTED_FIELD" + } + ] + }, + "details": "TinyWeb through 0.0.8 contains a path traversal vulnerability that allows unauthenticated attackers to read arbitrary files by submitting ../ sequences in the URL path, which are concatenated directly to the configured web root in HttpBuilder::buildResponse() without normalization, dot-segment removal, or boundary checks. Attackers can craft a single request with ../ sequences that pass through the URL parser unchanged and reach the filesystem call via HttpFile::setFile(), exposing sensitive files such as credential stores and private keys when the server process runs as root.", + "id": "CVE-2026-67185", + "modified": "2026-07-28T19:34:14.583Z", + "published": "2026-07-28T16:30:45.229Z", + "references": [ + { + "type": "PACKAGE", + "url": "https://github.com/GeneralSandman/TinyWeb" + }, + { + "type": "EVIDENCE", + "url": "https://github.com/theopaid/Unauthenticated-Path-Traversal-Allows-Arbitrary-File-Read-TinyWeb-" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-67185" + }, + { + "type": "ADVISORY", + "url": "https://www.vulncheck.com/advisories/tinyweb-path-traversal-via-url-path-component" + } + ], + "severity": [ + { + "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N", + "type": "CVSS_V4" + } + ], + "summary": "TinyWeb 0.0.8 Path Traversal via URL Path Component" +} +--- diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index fef00c53220..e3c34cdc966 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -1,7 +1,6 @@ package cve5 import ( - "path/filepath" "reflect" "sort" "strings" @@ -736,7 +735,7 @@ func TestExtractVersions(t *testing.T) { }, { name: "CVE-2026-67185", - cve: loadTestCVE(t, filepath.Join("..", "..", "cmd", "converters", "cve", "cve5", "bulk-converter", "cvelistV5", "cves", "2026", "67xxx", "CVE-2026-67185.json")), + cve: loadTestData(t, "CVE-2026-67185"), cnaAssigner: "VulnCheck", repos: []string{"https://github.com/GeneralSandman/TinyWeb"}, expectedAffected: []*osvschema.Affected{{ From d3803ebe63e590008c272991fd2bdd957ac65406 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Tue, 22 Sep 2026 02:52:16 +0000 Subject: [PATCH 10/12] refactor(vulnfeeds): move CVE5 version extraction strategies to Affected-level contract Refactor VersionStrategy from a per-version interface with numeric priorities to a slice-ordered, Affected-level contract using ExtractionState to atomically track consumed version entries and prevent duplicate extractions. Also unify CPE fallback extraction across cpeApplicability and affected[].cpes, memoize per-repo version tag resolution in ProcessRanges, and fix Linux conversion outcome counting in AddAffected. --- vulnfeeds/conversion/common.go | 41 +-- vulnfeeds/conversion/cve5/common.go | 70 ----- .../conversion/cve5/default_extractor.go | 37 +-- vulnfeeds/conversion/cve5/extraction.go | 29 ++- vulnfeeds/conversion/cve5/linux_extractor.go | 108 ++------ .../conversion/cve5/strategies/changes_at.go | 13 +- vulnfeeds/conversion/cve5/strategies/cpe.go | 86 +++--- .../conversion/cve5/strategies/git_commit.go | 28 +- .../cve5/strategies/inverse_range.go | 99 +++++++ .../conversion/cve5/strategies/presets.go | 41 +-- .../cve5/strategies/single_version.go | 31 ++- .../conversion/cve5/strategies/split_range.go | 97 ++++--- .../cve5/strategies/standard_range.go | 14 +- .../cve5/strategies/strategies_test.go | 244 +++++++++++------- .../conversion/cve5/strategies/strategy.go | 135 +++++++++- .../strategies/string_range_expression.go | 15 +- .../cve5/strategies/text_extraction.go | 14 +- .../cve5/version_extraction_test.go | 90 +++---- 18 files changed, 657 insertions(+), 535 deletions(-) delete mode 100644 vulnfeeds/conversion/cve5/common.go create mode 100644 vulnfeeds/conversion/cve5/strategies/inverse_range.go diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 274fb03560a..865c9f46fe9 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -67,6 +67,7 @@ func AddAffected(v *vulns.Vulnerability, aff *osvschema.Affected, metrics *model DatabaseSpecific: aff.GetDatabaseSpecific(), } v.Affected = append(v.Affected, newAff) + metrics.ResolvedRangesCount += len(uniqueRanges) } } @@ -227,6 +228,26 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri continue } + resolvedVersions := make(map[string]string) + resolveVersion := func(ver string) string { + if ver == "" { + return "" + } + if ver == "0" { + return "0" + } + if commit, seen := resolvedVersions[ver]; seen { + return commit + } + commit, err := git.VersionToCommit(ver, normalizedTags) + if err != nil { + metrics.AddNotef("error resolving version to commit - %s - %s", ver, err) + } + resolvedVersions[ver] = commit + + return commit + } + var stillUnresolvedRanges []models.RangeWithMetadata for _, vr := range unresolvedRanges { vRepo := vr.Range.GetRepo() @@ -258,23 +279,9 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri } } - var introducedCommit string - if introduced == "0" { - introducedCommit = "0" - } else { - introducedCommit, err = git.VersionToCommit(introduced, normalizedTags) - if err != nil { - metrics.AddNotef("error resolving version to commit - %s - %s", introduced, err) - } - } - fixedCommit, err := git.VersionToCommit(fixed, normalizedTags) - if err != nil { - metrics.AddNotef("error resolving version to commit - %s - %s", fixed, err) - } - lastAffectedCommit, err := git.VersionToCommit(lastAffected, normalizedTags) - if err != nil { - metrics.AddNotef("error resolving version to commit - %s - %s", lastAffected, err) - } + introducedCommit := resolveVersion(introduced) + fixedCommit := resolveVersion(fixed) + lastAffectedCommit := resolveVersion(lastAffected) if fixedCommit != "" || lastAffectedCommit != "" { var newVR *osvschema.Range diff --git a/vulnfeeds/conversion/cve5/common.go b/vulnfeeds/conversion/cve5/common.go deleted file mode 100644 index 8b917fefb41..00000000000 --- a/vulnfeeds/conversion/cve5/common.go +++ /dev/null @@ -1,70 +0,0 @@ -package cve5 - -import ( - "cmp" - "strconv" - "strings" - - "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" -) - -// VersionRangeType represents the type of versioning scheme for a range. -type VersionRangeType = strategies.VersionRangeType - -const ( - VersionRangeTypeUnknown = strategies.VersionRangeTypeUnknown - VersionRangeTypeGit = strategies.VersionRangeTypeGit - VersionRangeTypeSemver = strategies.VersionRangeTypeSemver - VersionRangeTypeEcosystem = strategies.VersionRangeTypeEcosystem -) - -// toVersionRangeType converts a string to a VersionRangeType. -func toVersionRangeType(s string) VersionRangeType { - return strategies.ToVersionRangeType(s) -} - -// compareSemverLike provides a custom comparison function for version strings that may not -// strictly adhere to the SemVer specification. It compares versions numerically, -// part by part (major, minor, patch). -func compareSemverLike(a, b string) int { - partsA := strings.Split(a, ".") - partsB := strings.Split(b, ".") - minLen := min(len(partsA), len(partsB)) - for i := range minLen { - // Convert parts to integers for numerical comparison. - // We ignore the error, so non-numeric parts default to 0. - numA, _ := strconv.Atoi(partsA[i]) - numB, _ := strconv.Atoi(partsB[i]) - if v := cmp.Compare(numA, numB); v != 0 { - return v - } - } - // If lengths are the same, they're equal. - if len(partsA) == len(partsB) { - return 0 - } - - // Determine which version has extra parts and what the result - // should be if those parts are non-zero. - var longerParts []string - var result int - // Assume 'b' is greater - if len(partsA) > len(partsB) { - longerParts = partsA - result = 1 // 'a' is actually greater - } else if len(partsA) < len(partsB) { - longerParts = partsB - result = -1 - } - - // Check if any of the extra parts are non-zero. - for i := minLen; i < len(longerParts); i++ { - num, _ := strconv.Atoi(longerParts[i]) - if num != 0 { - return result - } - } - - // All extra parts were zero, so the versions are effectively equal. - return 0 -} diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index bdb340e1495..c038e995469 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -24,13 +24,13 @@ func (d *DefaultVersionExtractor) getStrategies() []strategies.VersionStrategy { return d.Strategies } - return strategies.DefaultStrategies() + return strategies.Default() } func (d *DefaultVersionExtractor) handleAffected(affected []models.Affected, metrics *models.ConversionMetrics) []models.RangeWithMetadata { var ranges []models.RangeWithMetadata for _, cveAff := range affected { - versionRanges, _ := d.FindNormalAffectedRanges(cveAff, metrics) + versionRanges := ExtractAffectedRanges(cveAff, d.getStrategies(), metrics) if len(versionRanges) == 0 { continue @@ -131,36 +131,3 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln addUnresolvedRanges(unresolvedRanges) } - -func (d *DefaultVersionExtractor) FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) { - versionTypesCount := make(map[VersionRangeType]int) - var versionRanges []models.RangeWithMetadata - strategyList := d.getStrategies() - - for _, vers := range affected.Versions { - for _, strategy := range strategyList { - ranges, currentVersionType, handled := strategy.Extract(vers, affected, metrics) - if handled { - if len(ranges) > 0 { - metrics.AddNotef("Strategy successful: %s", strategy.Name()) - versionTypesCount[currentVersionType]++ - versionRanges = append(versionRanges, ranges...) - } - - break - } - } - } - - // Determine the most frequent version type to return as the range type. - maxCount := 0 - mostFrequentVersionType := VersionRangeTypeEcosystem - for versionType, count := range versionTypesCount { - if count > maxCount { - maxCount = count - mostFrequentVersionType = versionType - } - } - - return versionRanges, mostFrequentVersionType -} diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index 6238274693c..c7f3b3544a3 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -13,7 +13,6 @@ import ( // VersionExtractor defines the interface for different version extraction strategies. type VersionExtractor interface { ExtractVersions(cve models.CVE5, v *vulns.Vulnerability, metrics *models.ConversionMetrics, repos []string, cache git.RepoTagsCache, httpClient *http.Client) - FindNormalAffectedRanges(affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType) } // GetVersionExtractor returns the appropriate VersionExtractor configured with CNA-specific strategies. @@ -21,19 +20,39 @@ func GetVersionExtractor(cna string) VersionExtractor { switch strings.ToLower(cna) { case "linux": return &LinuxVersionExtractor{ - Strategies: strategies.LinuxStrategies(), + Strategies: strategies.Linux(), } case "github_m", "github": return &DefaultVersionExtractor{ - Strategies: strategies.GitHubStrategies(), + Strategies: strategies.GitHub(), } case "mitre": return &DefaultVersionExtractor{ - Strategies: strategies.MITREStrategies(), + Strategies: strategies.MITRE(), } default: return &DefaultVersionExtractor{ - Strategies: strategies.DefaultStrategies(), + Strategies: strategies.Default(), } } } + +// ExtractAffectedRanges runs the given strategy pipeline across an Affected block, +// tracking consumed version indices via ExtractionState and returning extracted ranges. +func ExtractAffectedRanges(affected models.Affected, strategyList []strategies.VersionStrategy, metrics *models.ConversionMetrics) []models.RangeWithMetadata { + state := strategies.NewExtractionState(affected) + + for _, strategy := range strategyList { + if state.AllConsumed() { + break + } + + prevCount := len(state.Ranges()) + strategy.Extract(state, metrics) + if len(state.Ranges()) > prevCount { + metrics.AddNotef("Strategy successful: %s", strategy.Name()) + } + } + + return state.Ranges() +} diff --git a/vulnfeeds/conversion/cve5/linux_extractor.go b/vulnfeeds/conversion/cve5/linux_extractor.go index 59886cd3b3c..64baeece491 100644 --- a/vulnfeeds/conversion/cve5/linux_extractor.go +++ b/vulnfeeds/conversion/cve5/linux_extractor.go @@ -1,11 +1,7 @@ package cve5 import ( - "fmt" "net/http" - "slices" - "strconv" - "strings" c "github.com/google/osv.dev/vulnfeeds/conversion" "github.com/google/osv.dev/vulnfeeds/conversion/cve5/strategies" @@ -19,7 +15,7 @@ import ( // LinuxVersionExtractor provides the version extraction logic for Linux kernel CVEs. type LinuxVersionExtractor struct { - DefaultVersionExtractor + Strategies []strategies.VersionStrategy } var _ VersionExtractor = &LinuxVersionExtractor{} @@ -27,33 +23,32 @@ var _ VersionExtractor = &LinuxVersionExtractor{} // handleAffected takes an array of models.Affected and handles how to extract them func (l *LinuxVersionExtractor) handleAffected(v *vulns.Vulnerability, affected []models.Affected, metrics *models.ConversionMetrics) bool { if len(l.Strategies) == 0 { - l.Strategies = strategies.LinuxStrategies() + l.Strategies = strategies.Linux() } hasGit := false gotVersions := false for _, cveAff := range affected { - var versionRanges []*osvschema.Range - var versionType VersionRangeType - if cveAff.DefaultStatus == "affected" { - versionRanges, versionType = findInverseAffectedRanges(cveAff, metrics) - } else { - var versionRangesWithMetadata []models.RangeWithMetadata - versionRangesWithMetadata, versionType = l.FindNormalAffectedRanges(cveAff, metrics) - for _, r := range versionRangesWithMetadata { - versionRanges = append(versionRanges, r.Range) - } + versionRangesWithMetadata := ExtractAffectedRanges(cveAff, l.Strategies, metrics) + if len(versionRangesWithMetadata) == 0 { + continue } - if (versionType == VersionRangeTypeGit && hasGit) || len(versionRanges) == 0 { + + isGit := versionRangesWithMetadata[0].Range.GetType() == osvschema.Range_GIT + versionRanges := make([]*osvschema.Range, 0, len(versionRangesWithMetadata)) + for _, r := range versionRangesWithMetadata { + versionRanges = append(versionRanges, r.Range) + } + if isGit && hasGit { continue } gotVersions = true - if versionType == VersionRangeTypeGit { + if isGit { hasGit = true } - aff := createLinuxAffected(versionRanges, versionType, cveAff.Repo) + aff := createLinuxAffected(versionRanges, isGit, cveAff.Repo) metrics.AddSource(models.VersionSourceAffected) c.AddAffected(v, aff, metrics) } @@ -76,16 +71,16 @@ func (l *LinuxVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulner for _, r := range versionRanges { ranges = append(ranges, r.Range) } - aff := createLinuxAffected(ranges, VersionRangeTypeEcosystem, "") - v.Affected = append(v.Affected, aff) + aff := createLinuxAffected(ranges, false, "") + c.AddAffected(v, aff, metrics) } } } -func createLinuxAffected(versionRanges []*osvschema.Range, versionType VersionRangeType, repo string) *osvschema.Affected { +func createLinuxAffected(versionRanges []*osvschema.Range, isGit bool, repo string) *osvschema.Affected { var aff osvschema.Affected for _, vr := range versionRanges { - if versionType == VersionRangeTypeGit { + if isGit { vr.Type = osvschema.Range_GIT vr.Repo = repo } else { @@ -93,7 +88,7 @@ func createLinuxAffected(versionRanges []*osvschema.Range, versionType VersionRa } aff.Ranges = append(aff.Ranges, vr) } - if versionType != VersionRangeTypeGit { + if !isGit { aff.Package = &osvschema.Package{ Ecosystem: string(osvconstants.EcosystemLinux), Name: "Kernel", @@ -104,66 +99,7 @@ func createLinuxAffected(versionRanges []*osvschema.Range, versionType VersionRa } // findInverseAffectedRanges calculates the affected version ranges by analyzing a list -// of 'unaffected' versions. This is common in Linux kernel CVEs where a product is -// considered affected by default, and only unaffected versions are listed. -// It sorts the introduced and fixed versions to create chronological ranges. -func findInverseAffectedRanges(cveAff models.Affected, metrics *models.ConversionMetrics) (ranges []*osvschema.Range, versType VersionRangeType) { - var introduced []string - fixed := make([]string, 0, len(cveAff.Versions)) - for _, vers := range cveAff.Versions { - versionValue := vers.Version - if vers.Status == "affected" { - numParts := len(strings.Split(versionValue, ".")) - switch numParts { - case 2: - introduced = append(introduced, versionValue+".0") - case 3: - introduced = append(introduced, versionValue) - default: - metrics.AddNotef("Bad non-semver version given: %s", versionValue) - continue - } - } - if vers.Status != "unaffected" { - continue - } - - if versionValue == "0" || toVersionRangeType(vers.VersionType) != VersionRangeTypeSemver { - continue - } - fixed = append(fixed, versionValue) - // Infer the next introduced version from the 'lessThanOrEqual' field. - // For example, if "5.10.*" is unaffected, the next introduced version is "5.11.0". - minorVers, _, _ := strings.Cut(vers.LessThanOrEqual, ".*") - parts := strings.Split(minorVers, ".") - if len(parts) > 1 { - if intMin, err := strconv.Atoi(parts[len(parts)-1]); err == nil { - nextIntroduced := fmt.Sprintf("%s.%d.0", parts[0], intMin+1) - introduced = append(introduced, nextIntroduced) - } - } - } - slices.SortFunc(introduced, compareSemverLike) - slices.SortFunc(fixed, compareSemverLike) - - // If the first fixed version is earlier than the first introduced, assume introduction from "0". - if len(fixed) > 0 && len(introduced) > 0 && compareSemverLike(fixed[0], introduced[0]) < 0 { - introduced = append([]string{"0"}, introduced...) - } - - // Create ranges by pairing sorted introduced and fixed versions. - for index, f := range fixed { - if index < len(introduced) { - ranges = append(ranges, c.BuildVersionRange(introduced[index], "", f)) - metrics.AddNotef("Introduced from version value - %s", introduced[index]) - metrics.AddNotef("Fixed from version value - %s", f) - } - } - - if len(ranges) != 0 { - return ranges, VersionRangeTypeSemver - } - metrics.AddNotef("no ranges found") - - return nil, VersionRangeTypeUnknown +// of 'unaffected' versions. +func findInverseAffectedRanges(cveAff models.Affected, metrics *models.ConversionMetrics) (ranges []*osvschema.Range, versType strategies.VersionRangeType) { + return strategies.FindInverseAffectedRanges(cveAff, metrics) } diff --git a/vulnfeeds/conversion/cve5/strategies/changes_at.go b/vulnfeeds/conversion/cve5/strategies/changes_at.go index bdb355abe77..bb635f17b1c 100644 --- a/vulnfeeds/conversion/cve5/strategies/changes_at.go +++ b/vulnfeeds/conversion/cve5/strategies/changes_at.go @@ -16,9 +16,13 @@ func (s *ChangesAtStrategy) Name() string { return "ChangesAt" } -func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *ChangesAtStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *ChangesAtStrategy) extractVersion(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false + return nil, false } var fixedFromChanges string @@ -30,7 +34,7 @@ func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, met } if fixedFromChanges == "" { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Fixed from changes - %s", fixedFromChanges) @@ -40,8 +44,7 @@ func (s *ChangesAtStrategy) Extract(vers models.Versions, _ models.Affected, met metrics.AddNotef("Introduced from version value - %s", vers.Version) } - currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange(introduced, "", fixedFromChanges)} - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), true } diff --git a/vulnfeeds/conversion/cve5/strategies/cpe.go b/vulnfeeds/conversion/cve5/strategies/cpe.go index 179b31cc370..06f28cb74dd 100644 --- a/vulnfeeds/conversion/cve5/strategies/cpe.go +++ b/vulnfeeds/conversion/cve5/strategies/cpe.go @@ -10,44 +10,15 @@ import ( "github.com/ossf/osv-schema/bindings/go/osvschema" ) -// AffectedCPEStrategy extracts version ranges from CPE strings specified in affected.Cpes or version strings formatted as CPEs. -// -// Example CVE Record: -// -// "affected": [ -// { -// "cpes": ["cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"], -// "versions": [{ "status": "affected" }] -// } -// ] -// -// Resulting OSV Range: [introduced: "1.2.3", last_affected: "1.2.3"] -type AffectedCPEStrategy struct{} - -func (s *AffectedCPEStrategy) Name() string { - return "AffectedCPE" -} - -func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - cpeStr := "" - if strings.HasPrefix(vers.Version, "cpe:") { - cpeStr = vers.Version - } else if len(affected.Cpes) > 0 { - for _, cpe := range affected.Cpes { - if strings.HasPrefix(cpe, "cpe:") { - cpeStr = cpe - break - } - } - } - - if cpeStr == "" { - return nil, VersionRangeTypeUnknown, false +// extractRangeFromCPEString parses a single CPE string and builds a standalone OSV range if valid. +func extractRangeFromCPEString(cpeStr string, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { + if !strings.HasPrefix(cpeStr, "cpe:") { + return nil, false } parsedCPE, err := c.ParseCPE(cpeStr) if err != nil || parsedCPE.Version == "" || parsedCPE.Version == "*" || parsedCPE.Version == "-" || parsedCPE.Version == "ANY" || parsedCPE.Version == "NA" { - return nil, VersionRangeTypeUnknown, false + return nil, false } version := parsedCPE.Version @@ -56,11 +27,13 @@ func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affe } if !vulns.CheckQuality(version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false + return nil, false + } + + if metrics != nil { + metrics.AddNotef("Extracted version %s from CPE %s", version, cpeStr) } - metrics.AddNotef("Extracted version %s from CPE %s", version, cpeStr) - currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange(version, version, "")} rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) for i := range rwms { @@ -68,10 +41,27 @@ func (s *AffectedCPEStrategy) Extract(vers models.Versions, affected models.Affe rwms[i].Metadata.Versions = []string{version} } - return rwms, currentVersionType, true + return rwms, true +} + +// CPEVersionStringStrategy extracts version ranges from version entries where vers.Version is formatted as a CPE string. +// Placed before single-version strategies so CPE strings are consumed without coupling single-version strategies to CPE prefixes. +type CPEVersionStringStrategy struct{} + +func (s *CPEVersionStringStrategy) Name() string { + return "CPEVersionString" +} + +func (s *CPEVersionStringStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) } -// CPEVersionStrategy extracts version ranges from the CVE's CPE applicability statements. +func (s *CPEVersionStringStrategy) extractVersion(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { + return extractRangeFromCPEString(vers.Version, metrics) +} + +// CPEVersionStrategy extracts version ranges from the CVE's CPE applicability statements +// as well as any CPE lists attached to affected blocks (affected[].cpes). // // Example CVE Record: // @@ -97,7 +87,7 @@ func (s *CPEVersionStrategy) Name() string { } func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { - cpeRanges, cpeStrings, err := findCPEVersionRanges(cve) + cpeRanges, cpeStrings, err := findCPEVersionRanges(cve, metrics) if err == nil && len(cpeRanges) > 0 { metrics.AddNotef("Strategy successful: %s", s.Name()) metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) @@ -117,9 +107,8 @@ func CPEVersionExtraction(cve models.CVE5, metrics *models.ConversionMetrics) ([ } // findCPEVersionRanges extracts version ranges and CPE strings from the CNA's -// CPE applicability statements in a CVE record. -func findCPEVersionRanges(cve models.CVE5) (versionRanges []models.RangeWithMetadata, cpes []string, err error) { - // TODO(jesslowe): Add logic to also extract CPEs from the 'affected' field (e.g., CVE-2025-1110). +// CPE applicability statements and affected[].cpes lists in a CVE record. +func findCPEVersionRanges(cve models.CVE5, metrics *models.ConversionMetrics) (versionRanges []models.RangeWithMetadata, cpes []string, err error) { for _, cpe := range cve.Containers.CNA.CPEApplicability { for _, node := range cpe.Nodes { if node.Operator != "OR" { @@ -147,6 +136,17 @@ func findCPEVersionRanges(cve models.CVE5) (versionRanges []models.RangeWithMeta } } } + + // Also extract from any CPE strings listed in cve.Containers.CNA.Affected[].Cpes + for _, affected := range cve.Containers.CNA.Affected { + for _, cpeStr := range affected.Cpes { + if rwms, ok := extractRangeFromCPEString(cpeStr, metrics); ok { + cpes = append(cpes, cpeStr) + versionRanges = append(versionRanges, rwms...) + } + } + } + if len(versionRanges) == 0 { return nil, nil, errors.New("no versions extracted from CPEs") } diff --git a/vulnfeeds/conversion/cve5/strategies/git_commit.go b/vulnfeeds/conversion/cve5/strategies/git_commit.go index 5f9b8c41aeb..5ada3ea9643 100644 --- a/vulnfeeds/conversion/cve5/strategies/git_commit.go +++ b/vulnfeeds/conversion/cve5/strategies/git_commit.go @@ -24,15 +24,19 @@ func (s *GitCommitStrategy) Name() string { return "GitCommit" } -func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *GitCommitStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *GitCommitStrategy) extractVersion(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false + return nil, false } if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { - return nil, VersionRangeTypeUnknown, false + return nil, false } if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Git commit version found %v", vers.Version) @@ -42,7 +46,7 @@ func (s *GitCommitStrategy) Extract(vers models.Versions, affected models.Affect rwms[i].Metadata.Versions = []string{vers.Version} } - return rwms, VersionRangeTypeGit, true + return rwms, true } // GitCommitIntroducedOnlyStrategy treats a git commit version as an introduced-only point (used by Linux kernel). @@ -62,19 +66,23 @@ func (s *GitCommitIntroducedOnlyStrategy) Name() string { return "GitCommitIntroducedOnly" } -func (s *GitCommitIntroducedOnlyStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *GitCommitIntroducedOnlyStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *GitCommitIntroducedOnlyStrategy) extractVersion(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false + return nil, false } if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { - return nil, VersionRangeTypeUnknown, false + return nil, false } if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Git commit introduced found %v", vers.Version) vr := []*osvschema.Range{c.BuildGitVersionRange(vers.Version, "", "", affected.Repo)} - return c.ToRangeWithMetadata(vr, models.VersionSourceGit), VersionRangeTypeGit, true + return c.ToRangeWithMetadata(vr, models.VersionSourceGit), true } diff --git a/vulnfeeds/conversion/cve5/strategies/inverse_range.go b/vulnfeeds/conversion/cve5/strategies/inverse_range.go new file mode 100644 index 00000000000..05cb174cb17 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/inverse_range.go @@ -0,0 +1,99 @@ +package strategies + +import ( + "fmt" + "slices" + "strconv" + "strings" + + c "github.com/google/osv.dev/vulnfeeds/conversion" + "github.com/google/osv.dev/vulnfeeds/models" + "github.com/ossf/osv-schema/bindings/go/osvschema" +) + +// InverseAffectedRangesStrategy calculates affected version ranges by analyzing a list +// of 'unaffected' versions when DefaultStatus == "affected". +// This is common in Linux kernel CVEs where a product is considered affected by default, +// and only unaffected versions are listed. +type InverseAffectedRangesStrategy struct{} + +func (s *InverseAffectedRangesStrategy) Name() string { + return "InverseAffectedRanges" +} + +func (s *InverseAffectedRangesStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + if state.Affected.DefaultStatus != "affected" { + return + } + + ranges, _ := FindInverseAffectedRanges(state.Affected, metrics) + if len(ranges) == 0 { + return + } + + state.EmitAll(c.ToRangeWithMetadata(ranges, models.VersionSourceAffected)) +} + +// FindInverseAffectedRanges calculates the affected version ranges by analyzing a list +// of 'unaffected' versions. It sorts the introduced and fixed versions to create chronological ranges. +func FindInverseAffectedRanges(cveAff models.Affected, metrics *models.ConversionMetrics) (ranges []*osvschema.Range, versType VersionRangeType) { + var introduced []string + fixed := make([]string, 0, len(cveAff.Versions)) + for _, vers := range cveAff.Versions { + versionValue := vers.Version + if vers.Status == "affected" { + numParts := len(strings.Split(versionValue, ".")) + switch numParts { + case 2: + introduced = append(introduced, versionValue+".0") + case 3: + introduced = append(introduced, versionValue) + default: + metrics.AddNotef("Bad non-semver version given: %s", versionValue) + continue + } + } + if vers.Status != "unaffected" { + continue + } + + if versionValue == "0" || ToVersionRangeType(vers.VersionType) != VersionRangeTypeSemver { + continue + } + fixed = append(fixed, versionValue) + // Infer the next introduced version from the 'lessThanOrEqual' field when a wildcard is present. + // For example, if "5.10.*" is unaffected, the next introduced version is "5.11.0". + if minorVers, _, hasWildcard := strings.Cut(vers.LessThanOrEqual, ".*"); hasWildcard { + parts := strings.Split(minorVers, ".") + if len(parts) > 1 { + if intMin, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + nextIntroduced := fmt.Sprintf("%s.%d.0", parts[0], intMin+1) + introduced = append(introduced, nextIntroduced) + } + } + } + } + slices.SortFunc(introduced, CompareSemverLike) + slices.SortFunc(fixed, CompareSemverLike) + + // If the first fixed version is earlier than the first introduced, assume introduction from "0". + if len(fixed) > 0 && len(introduced) > 0 && CompareSemverLike(fixed[0], introduced[0]) < 0 { + introduced = append([]string{"0"}, introduced...) + } + + // Create ranges by pairing sorted introduced and fixed versions. + for index, f := range fixed { + if index < len(introduced) { + ranges = append(ranges, c.BuildVersionRange(introduced[index], "", f)) + metrics.AddNotef("Introduced from version value - %s", introduced[index]) + metrics.AddNotef("Fixed from version value - %s", f) + } + } + + if len(ranges) != 0 { + return ranges, VersionRangeTypeSemver + } + metrics.AddNotef("no ranges found") + + return nil, VersionRangeTypeUnknown +} diff --git a/vulnfeeds/conversion/cve5/strategies/presets.go b/vulnfeeds/conversion/cve5/strategies/presets.go index 4a3b9e1c90f..f18a8653b1a 100644 --- a/vulnfeeds/conversion/cve5/strategies/presets.go +++ b/vulnfeeds/conversion/cve5/strategies/presets.go @@ -9,16 +9,12 @@ func Default() []VersionStrategy { &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, &GitCommitStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &VersionTextExtractionStrategy{}, &StandaloneSingleVersionStrategy{}, } } -func DefaultStrategies() []VersionStrategy { - return Default() -} - func GitHub() []VersionStrategy { return []VersionStrategy{ &SplitRangeStrategy{}, @@ -26,57 +22,41 @@ func GitHub() []VersionStrategy { &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, &GitCommitStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &StandaloneSingleVersionStrategy{}, } } -func GitHubStrategies() []VersionStrategy { - return GitHub() -} - func WPScan() []VersionStrategy { return []VersionStrategy{ &ChangesAtStrategy{}, &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, } } -func WPScanStrategies() []VersionStrategy { - return WPScan() -} - func Wordfence() []VersionStrategy { return []VersionStrategy{ &ChangesAtStrategy{}, &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, } } -func WordfenceStrategies() []VersionStrategy { - return Wordfence() -} - func Patchstack() []VersionStrategy { return []VersionStrategy{ &ChangesAtStrategy{}, &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, } } -func PatchstackStrategies() []VersionStrategy { - return Patchstack() -} - func MITRE() []VersionStrategy { return []VersionStrategy{ &SplitRangeStrategy{}, @@ -84,24 +64,17 @@ func MITRE() []VersionStrategy { &StandardRangeStrategy{}, &StringRangeExpressionStrategy{}, &GitCommitStrategy{}, - &AffectedCPEStrategy{}, + &CPEVersionStringStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, &StandaloneSingleVersionStrategy{}, } } -func MITREStrategies() []VersionStrategy { - return MITRE() -} - func Linux() []VersionStrategy { return []VersionStrategy{ + &InverseAffectedRangesStrategy{}, &StandardRangeStrategy{}, &GitCommitIntroducedOnlyStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, } } - -func LinuxStrategies() []VersionStrategy { - return Linux() -} diff --git a/vulnfeeds/conversion/cve5/strategies/single_version.go b/vulnfeeds/conversion/cve5/strategies/single_version.go index 4014b9086d6..3a93305b745 100644 --- a/vulnfeeds/conversion/cve5/strategies/single_version.go +++ b/vulnfeeds/conversion/cve5/strategies/single_version.go @@ -25,22 +25,22 @@ func (s *ZeroIntroducedSingleVersionStrategy) Name() string { return "ZeroIntroducedSingleVersion" } -func (s *ZeroIntroducedSingleVersionStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false +func (s *ZeroIntroducedSingleVersionStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + if len(state.Affected.Versions) != 1 || state.IsConsumed(0) { + return } - if len(affected.Versions) > 1 { - return nil, VersionRangeTypeUnknown, false + + vers := state.Affected.Versions[0] + if vers.Status != "affected" || vers.Version == "" { + return } if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false + return } metrics.AddNotef("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) - currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + state.Emit(c.ToRangeWithMetadata(vr, models.VersionSourceAffected), 0) } // StandaloneSingleVersionStrategy treats a single version as an exact, standalone version (introduced == last_affected). @@ -59,21 +59,24 @@ func (s *StandaloneSingleVersionStrategy) Name() string { return "StandaloneSingleVersion" } -func (s *StandaloneSingleVersionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *StandaloneSingleVersionStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *StandaloneSingleVersionStrategy) extractVersion(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false + return nil, false } if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Single version found %v - Treating as standalone version", vers.Version) - currentVersionType := ToVersionRangeType(vers.VersionType) vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} rwms := c.ToRangeWithMetadata(vr, models.VersionSourceAffected) for i := range rwms { rwms[i].Metadata.Versions = []string{vers.Version} } - return rwms, currentVersionType, true + return rwms, true } diff --git a/vulnfeeds/conversion/cve5/strategies/split_range.go b/vulnfeeds/conversion/cve5/strategies/split_range.go index 4019abd02c3..e69f5bf381a 100644 --- a/vulnfeeds/conversion/cve5/strategies/split_range.go +++ b/vulnfeeds/conversion/cve5/strategies/split_range.go @@ -65,67 +65,60 @@ func isSplitUpperBoundOnly(v models.Versions) bool { return isExplicitUnspecified(v.Version) && (isValidSplitVersion(v.LessThan) || isValidSplitVersion(v.LessThanOrEqual)) } -func (s *SplitRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false - } +func (s *SplitRangeStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + affected := state.Affected - // Check for "unspecified" split or standalone ranges across affected.Versions - idx := -1 - for i, v := range affected.Versions { - if v.Version == vers.Version && v.LessThan == vers.LessThan && v.LessThanOrEqual == vers.LessThanOrEqual && v.Status == vers.Status && v.VersionType == vers.VersionType { - idx = i - break + for i := 0; i < len(affected.Versions); i++ { + if state.IsConsumed(i) { + continue } - } - // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) - if isSplitIntroducedOnly(vers) { - currentVersionType := ToVersionRangeType(vers.VersionType) - // Check if the immediately following entry is an upper-bound entry - if idx >= 0 && idx+1 < len(affected.Versions) && isSplitUpperBoundOnly(affected.Versions[idx+1]) { - nextVers := affected.Versions[idx+1] - var fixed, lastAffected string - if isValidSplitVersion(nextVers.LessThan) { - fixed = nextVers.LessThan - } else if isValidSplitVersion(nextVers.LessThanOrEqual) { - lastAffected = nextVers.LessThanOrEqual - } - - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} - metrics.AddNotef("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + vers := affected.Versions[i] + if vers.Status != "affected" { + continue } - // Standalone introduced entry without a following upper bound - vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} - metrics.AddNotef("Parsed standalone introduced version: %s", vers.Version) - - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true - } + // Case A: Introduced-only entry (e.g. {version: "1.31.0", lessThan: "unspecified"}) + if isSplitIntroducedOnly(vers) { + // Check if the immediately following entry in the original Affected block is an unconsumed upper-bound entry + if i+1 < len(affected.Versions) && !state.IsConsumed(i+1) && affected.Versions[i+1].Status == "affected" && isSplitUpperBoundOnly(affected.Versions[i+1]) { + nextVers := affected.Versions[i+1] + var fixed, lastAffected string + if isValidSplitVersion(nextVers.LessThan) { + fixed = nextVers.LessThan + } else if isValidSplitVersion(nextVers.LessThanOrEqual) { + lastAffected = nextVers.LessThanOrEqual + } + + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, lastAffected, fixed)} + metrics.AddNotef("Parsed split range: introduced %s, upper bound %s%s", vers.Version, fixed, lastAffected) + state.Emit(c.ToRangeWithMetadata(vr, models.VersionSourceAffected), i, i+1) + i++ // Advance past the paired upper-bound entry + + continue + } - // Case B: Upper-bound-only entry (e.g. {version: "unspecified", lessThan: "1.36.1"}) - if isSplitUpperBoundOnly(vers) { - currentVersionType := ToVersionRangeType(vers.VersionType) - // If preceded by an introduced-only entry, it was already paired and consumed in Case A - if idx > 0 && isSplitIntroducedOnly(affected.Versions[idx-1]) { - return nil, currentVersionType, true - } + // Standalone introduced entry without a following upper bound + vr := []*osvschema.Range{c.BuildVersionRange(vers.Version, "", "")} + metrics.AddNotef("Parsed standalone introduced version: %s", vers.Version) + state.Emit(c.ToRangeWithMetadata(vr, models.VersionSourceAffected), i) - // Standalone upper bound starting from 0 - var fixed, lastAffected string - if isValidSplitVersion(vers.LessThan) { - fixed = vers.LessThan - } else if isValidSplitVersion(vers.LessThanOrEqual) { - lastAffected = vers.LessThanOrEqual + continue } - vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} - metrics.AddNotef("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) + // Case B: Upper-bound-only entry (e.g. {version: "unspecified", lessThan: "1.36.1"}) + if isSplitUpperBoundOnly(vers) { + // Standalone upper bound starting from 0 + var fixed, lastAffected string + if isValidSplitVersion(vers.LessThan) { + fixed = vers.LessThan + } else if isValidSplitVersion(vers.LessThanOrEqual) { + lastAffected = vers.LessThanOrEqual + } - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + vr := []*osvschema.Range{c.BuildVersionRange("0", lastAffected, fixed)} + metrics.AddNotef("Parsed upper-bound range with introduced=0: %s%s", fixed, lastAffected) + state.Emit(c.ToRangeWithMetadata(vr, models.VersionSourceAffected), i) + } } - - return nil, VersionRangeTypeUnknown, false } diff --git a/vulnfeeds/conversion/cve5/strategies/standard_range.go b/vulnfeeds/conversion/cve5/strategies/standard_range.go index dcbf2fa261c..2bd1e502118 100644 --- a/vulnfeeds/conversion/cve5/strategies/standard_range.go +++ b/vulnfeeds/conversion/cve5/strategies/standard_range.go @@ -25,9 +25,13 @@ func (s *StandardRangeStrategy) Name() string { return "StandardRange" } -func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *StandardRangeStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *StandardRangeStrategy) extractVersion(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, false + return nil, false } currentVersionType := ToVersionRangeType(vers.VersionType) @@ -52,7 +56,7 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af } if !hasRange { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Range detected: %v", hasRange) @@ -78,7 +82,7 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af } if len(versionRanges) == 0 { - return nil, VersionRangeTypeUnknown, false + return nil, false } for _, vr := range versionRanges { @@ -90,5 +94,5 @@ func (s *StandardRangeStrategy) Extract(vers models.Versions, affected models.Af } } - return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), currentVersionType, true + return c.ToRangeWithMetadata(versionRanges, models.VersionSourceAffected), true } diff --git a/vulnfeeds/conversion/cve5/strategies/strategies_test.go b/vulnfeeds/conversion/cve5/strategies/strategies_test.go index 6605f02f8bb..0ff6109807a 100644 --- a/vulnfeeds/conversion/cve5/strategies/strategies_test.go +++ b/vulnfeeds/conversion/cve5/strategies/strategies_test.go @@ -37,14 +37,20 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &StandardRangeStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - LessThan: "1.5.0", - VersionType: "semver", - } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + }, + }, + } + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("StandardRangeStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -57,16 +63,22 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &ChangesAtStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - VersionType: "semver", - Changes: []models.Change{ - {Status: "unaffected", At: "1.0.1"}, + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + Changes: []models.Change{ + {Status: "unaffected", At: "1.0.1"}, + }, + }, }, } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("ChangesAtStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -79,13 +91,19 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &StringRangeExpressionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: ">= 1.2.0, < 2.0.0", - VersionType: "semver", + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: ">= 1.2.0, < 2.0.0", + VersionType: "semver", + }, + }, } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("StringRangeExpressionStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -98,13 +116,19 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &ZeroIntroducedSingleVersionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "2.52", - VersionType: "custom", + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "2.52", + VersionType: "custom", + }, + }, } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeEcosystem || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("ZeroIntroducedSingleVersionStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -112,16 +136,20 @@ func TestStrategies(t *testing.T) { t.Errorf("unexpected events: %+v", events) } - // Should not handle if multiple versions are listed + // Should not handle if multiple versions were originally listed in affected.Versions, + // even if only 1 unhandled version remains! multiAffected := models.Affected{ Versions: []models.Versions{ - {Status: "affected", Version: "3.0.0"}, - {Status: "affected", Version: "3.1.0"}, + {Status: "affected", Version: "1.0.0", LessThan: "1.5.0"}, + {Status: "affected", Version: "2.0.0"}, }, } - _, _, handledMulti := strategy.Extract(multiAffected.Versions[0], multiAffected, metrics) - if handledMulti { - t.Errorf("ZeroIntroducedSingleVersionStrategy should not handle multiple versions") + multiState := NewExtractionState(multiAffected) + (&StandardRangeStrategy{}).Extract(multiState, metrics) + prevCount := len(multiState.Ranges()) + strategy.Extract(multiState, metrics) + if len(multiState.Ranges()) != prevCount || multiState.IsConsumed(1) { + t.Errorf("ZeroIntroducedSingleVersionStrategy should not handle partially consumed multi-version block") } }) @@ -148,18 +176,15 @@ func TestStrategies(t *testing.T) { }, } - ranges1, _, handled1 := strategy.Extract(affectedSplit.Versions[0], affectedSplit, metrics) - if !handled1 || len(ranges1) != 1 { - t.Fatalf("SplitRangeStrategy failed to extract first split entry: %+v", ranges1) + stateSplit := NewExtractionState(affectedSplit) + strategy.Extract(stateSplit, metrics) + rangesSplit := stateSplit.Ranges() + if !stateSplit.AllConsumed() || len(rangesSplit) != 1 { + t.Fatalf("SplitRangeStrategy failed to extract split pair: %+v", rangesSplit) } - events1 := ranges1[0].Range.GetEvents() + events1 := rangesSplit[0].Range.GetEvents() if events1[0].GetIntroduced() != "1.31.0" || events1[1].GetFixed() != "1.36.1" { - t.Errorf("unexpected events from first split entry: %+v", events1) - } - - ranges2, _, handled2 := strategy.Extract(affectedSplit.Versions[1], affectedSplit, metrics) - if !handled2 || len(ranges2) != 0 { - t.Errorf("expected second split entry to be consumed, got: %+v", ranges2) + t.Errorf("unexpected events from split pair: %+v", events1) } // 2. Standalone upper bound (e.g., CVE-2022-25865) @@ -173,8 +198,10 @@ func TestStrategies(t *testing.T) { }, }, } - rangesUpper, _, handledUpper := strategy.Extract(affectedUpper.Versions[0], affectedUpper, metrics) - if !handledUpper || len(rangesUpper) != 1 { + stateUpper := NewExtractionState(affectedUpper) + strategy.Extract(stateUpper, metrics) + rangesUpper := stateUpper.Ranges() + if !stateUpper.AllConsumed() || len(rangesUpper) != 1 { t.Fatalf("SplitRangeStrategy failed to extract standalone upper bound: %+v", rangesUpper) } eventsUpper := rangesUpper[0].Range.GetEvents() @@ -187,16 +214,20 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &GitCommitStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", - VersionType: "git", - } affected := models.Affected{ Repo: "https://github.com/GeneralSandman/TinyWeb", + Versions: []models.Versions{ + { + Status: "affected", + Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", + VersionType: "git", + }, + }, } - ranges, vrt, handled := strategy.Extract(vers, affected, metrics) - if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("GitCommitStrategy failed to extract range") } if ranges[0].Range.GetType() != osvschema.Range_GIT || ranges[0].Range.GetRepo() != "https://github.com/GeneralSandman/TinyWeb" { @@ -208,16 +239,20 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &GitCommitIntroducedOnlyStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", - VersionType: "git", - } affected := models.Affected{ Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + Versions: []models.Versions{ + { + Status: "affected", + Version: "1da177e4c3f41524e886b7f1b8a0c1fc7321cac2", + VersionType: "git", + }, + }, } - ranges, vrt, handled := strategy.Extract(vers, affected, metrics) - if !handled || vrt != VersionRangeTypeGit || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("GitCommitIntroducedOnlyStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -230,13 +265,19 @@ func TestStrategies(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} strategy := &StandaloneSingleVersionStrategy{} - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - VersionType: "semver", + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + }, + }, } - ranges, vrt, handled := strategy.Extract(vers, models.Affected{}, metrics) - if !handled || vrt != VersionRangeTypeSemver || len(ranges) != 1 { + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { t.Fatalf("StandaloneSingleVersionStrategy failed to extract range") } events := ranges[0].Range.GetEvents() @@ -247,14 +288,15 @@ func TestStrategies(t *testing.T) { t.Run("PipelineOrderPrecedence", func(t *testing.T) { t.Parallel() - vers := models.Versions{ - Status: "affected", - Version: "1.0.0", - LessThan: "1.5.0", - VersionType: "semver", - } affected := models.Affected{ - Versions: []models.Versions{vers}, + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + }, + }, } // When StandardRangeStrategy comes first: @@ -262,13 +304,14 @@ func TestStrategies(t *testing.T) { &StandardRangeStrategy{}, &StandaloneSingleVersionStrategy{}, } - var ranges1 []models.RangeWithMetadata + state1 := NewExtractionState(affected) for _, s := range pipeline1 { - if r, _, handled := s.Extract(vers, affected, &models.ConversionMetrics{}); handled { - ranges1 = r + if state1.AllConsumed() { break } + s.Extract(state1, &models.ConversionMetrics{}) } + ranges1 := state1.Ranges() if len(ranges1) != 1 || ranges1[0].Range.GetEvents()[1].GetFixed() != "1.5.0" { t.Fatalf("expected StandardRangeStrategy to handle first, got: %+v", ranges1) } @@ -278,31 +321,36 @@ func TestStrategies(t *testing.T) { &StandaloneSingleVersionStrategy{}, &StandardRangeStrategy{}, } - var ranges2 []models.RangeWithMetadata + state2 := NewExtractionState(affected) for _, s := range pipeline2 { - if r, _, handled := s.Extract(vers, affected, &models.ConversionMetrics{}); handled { - ranges2 = r + if state2.AllConsumed() { break } + s.Extract(state2, &models.ConversionMetrics{}) } + ranges2 := state2.Ranges() if len(ranges2) != 1 || ranges2[0].Range.GetEvents()[1].GetLastAffected() != "1.0.0" { t.Fatalf("expected StandaloneSingleVersionStrategy to handle first when placed earlier, got: %+v", ranges2) } }) - t.Run("AffectedCPEStrategy", func(t *testing.T) { + t.Run("CPEVersionStringStrategy", func(t *testing.T) { t.Parallel() metrics := &models.ConversionMetrics{} - strategy := &AffectedCPEStrategy{} + strategy := &CPEVersionStringStrategy{} affected := models.Affected{ - Cpes: []string{"cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"}, - } - vers := models.Versions{ - Status: "affected", + Versions: []models.Versions{ + { + Status: "affected", + Version: "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*", + }, + }, } - ranges, _, handled := strategy.Extract(vers, affected, metrics) - if !handled || len(ranges) != 1 { - t.Fatalf("AffectedCPEStrategy failed to extract range") + state := NewExtractionState(affected) + strategy.Extract(state, metrics) + ranges := state.Ranges() + if !state.AllConsumed() || len(ranges) != 1 { + t.Fatalf("CPEVersionStringStrategy expected 1 range and all consumed, got %d ranges", len(ranges)) } if ranges[0].Metadata.CPE != "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*" { t.Errorf("unexpected CPE metadata: %s", ranges[0].Metadata.CPE) @@ -342,13 +390,23 @@ func TestStrategies(t *testing.T) { }, }, } + // Also include affected.Cpes to verify both cpeApplicability and affected.Cpes are extracted without duplicates + cve.Containers.CNA.Affected = []models.Affected{ + { + Cpes: []string{"cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*"}, + }, + } ranges, err := strategy.Extract(cve, metrics) - if err != nil || len(ranges) != 1 { - t.Fatalf("CPEVersionStrategy failed to extract range: %v", err) + if err != nil || len(ranges) != 2 { + t.Fatalf("CPEVersionStrategy failed to extract ranges: %v (got %d ranges)", err, len(ranges)) } - events := ranges[0].Range.GetEvents() - if events[0].GetIntroduced() != "1.0.0" || events[1].GetFixed() != "2.0.0" { - t.Errorf("unexpected events: %+v", events) + events0 := ranges[0].Range.GetEvents() + if events0[0].GetIntroduced() != "1.0.0" || events0[1].GetFixed() != "2.0.0" { + t.Errorf("unexpected events[0]: %+v", events0) + } + events1 := ranges[1].Range.GetEvents() + if events1[0].GetIntroduced() != "1.2.3" || events1[1].GetLastAffected() != "1.2.3" { + t.Errorf("unexpected events[1]: %+v", events1) } }) } diff --git a/vulnfeeds/conversion/cve5/strategies/strategy.go b/vulnfeeds/conversion/cve5/strategies/strategy.go index 6eec725fe0a..09b4bb2d6b8 100644 --- a/vulnfeeds/conversion/cve5/strategies/strategy.go +++ b/vulnfeeds/conversion/cve5/strategies/strategy.go @@ -2,6 +2,8 @@ package strategies import ( + "cmp" + "strconv" "strings" "github.com/google/osv.dev/vulnfeeds/models" @@ -47,12 +49,137 @@ func ToVersionRangeType(s string) VersionRangeType { const acceptableQuality = vulns.Spaces -// VersionStrategy defines the contract for an individual version extraction strategy. +// ExtractionState encapsulates the immutable Affected block, consumed version tracking, +// and accumulated extracted ranges so strategies cannot mark version indices as consumed +// without emitting corresponding ranges. +type ExtractionState struct { + Affected models.Affected + consumed []bool + ranges []models.RangeWithMetadata +} + +// NewExtractionState initializes a new ExtractionState for the given Affected block. +func NewExtractionState(affected models.Affected) *ExtractionState { + return &ExtractionState{ + Affected: affected, + consumed: make([]bool, len(affected.Versions)), + } +} + +// IsConsumed reports whether the version entry at index i has already been handled. +func (s *ExtractionState) IsConsumed(i int) bool { + return s.consumed[i] +} + +// AllConsumed reports whether every version entry in s.Affected.Versions has been handled. +func (s *ExtractionState) AllConsumed() bool { + for _, c := range s.consumed { + if !c { + return false + } + } + + return true +} + +// Emit atomically marks the given version indices as consumed and appends ranges to the state. +// If ranges is empty, Emit is a no-op. +func (s *ExtractionState) Emit(ranges []models.RangeWithMetadata, indices ...int) { + if len(ranges) == 0 { + return + } + for _, idx := range indices { + s.consumed[idx] = true + } + s.ranges = append(s.ranges, ranges...) +} + +// EmitAll atomically marks all version entries in s.Affected.Versions as consumed and appends ranges. +// If ranges is empty, EmitAll is a no-op. +func (s *ExtractionState) EmitAll(ranges []models.RangeWithMetadata) { + if len(ranges) == 0 { + return + } + for i := range s.consumed { + s.consumed[i] = true + } + s.ranges = append(s.ranges, ranges...) +} + +// Ranges returns all extracted ranges accumulated so far. +func (s *ExtractionState) Ranges() []models.RangeWithMetadata { + return s.ranges +} + +// VersionStrategy defines the contract for an Affected-level version extraction strategy. // Strategies are evaluated in the sequential order they appear in the configured slice. type VersionStrategy interface { // Name returns a human-readable identifier for the strategy. Name() string - // Extract attempts to extract OSV version ranges from a CVE5 Versions entry. - // Returns the extracted ranges, the detected VersionRangeType, and true if this strategy handled the entry. - Extract(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) (ranges []models.RangeWithMetadata, vrt VersionRangeType, handled bool) + // Extract attempts to extract OSV version ranges from state.Affected, + // skipping entries where state.IsConsumed(i) is true and recording extracted ranges + // via state.Emit or state.EmitAll. + Extract(state *ExtractionState, metrics *models.ConversionMetrics) +} + +// VersionExtractorFunc is the signature for strategies that inspect one models.Versions entry at a time. +type VersionExtractorFunc func(vers models.Versions, affected models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) + +// ExtractPerVersion applies a single-version extraction function across unconsumed entries in state.Affected.Versions, +// emitting handled entries to state. +func ExtractPerVersion(state *ExtractionState, metrics *models.ConversionMetrics, fn VersionExtractorFunc) { + for i, vers := range state.Affected.Versions { + if state.IsConsumed(i) { + continue + } + ranges, handled := fn(vers, state.Affected, metrics) + if handled { + state.Emit(ranges, i) + } + } +} + +// CompareSemverLike provides a custom comparison function for version strings that may not +// strictly adhere to the SemVer specification. It compares versions numerically, +// part by part (major, minor, patch). +func CompareSemverLike(a, b string) int { + partsA := strings.Split(a, ".") + partsB := strings.Split(b, ".") + minLen := min(len(partsA), len(partsB)) + for i := range minLen { + // Convert parts to integers for numerical comparison. + // We ignore the error, so non-numeric parts default to 0. + numA, _ := strconv.Atoi(partsA[i]) + numB, _ := strconv.Atoi(partsB[i]) + if v := cmp.Compare(numA, numB); v != 0 { + return v + } + } + // If lengths are the same, they're equal. + if len(partsA) == len(partsB) { + return 0 + } + + // Determine which version has extra parts and what the result + // should be if those parts are non-zero. + var longerParts []string + var result int + if len(partsA) > len(partsB) { + longerParts = partsA + result = 1 + } else if len(partsA) < len(partsB) { + longerParts = partsB + result = -1 + } + + // Check if any of the extra parts are non-zero. + for i := minLen; i < len(longerParts); i++ { + num, _ := strconv.Atoi(longerParts[i]) + if num != 0 { + return result + } + } + + // All extra parts were zero, so the versions are effectively equal. + return 0 } diff --git a/vulnfeeds/conversion/cve5/strategies/string_range_expression.go b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go index 74c696f4d4e..6ed62cb6f74 100644 --- a/vulnfeeds/conversion/cve5/strategies/string_range_expression.go +++ b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go @@ -24,17 +24,20 @@ func (s *StringRangeExpressionStrategy) Name() string { return "StringRangeExpression" } -func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *StringRangeExpressionStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *StringRangeExpressionStrategy) extractVersion(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false + return nil, false } av, err := git.ParseVersionRange(vers.Version) if err != nil || av.Introduced == "" { - return nil, VersionRangeTypeUnknown, false + return nil, false } - currentVersionType := ToVersionRangeType(vers.VersionType) var vr []*osvschema.Range if av.Fixed != "" { vr = append(vr, c.BuildVersionRange(av.Introduced, "", av.Fixed)) @@ -43,10 +46,10 @@ func (s *StringRangeExpressionStrategy) Extract(vers models.Versions, _ models.A } if len(vr) == 0 { - return nil, VersionRangeTypeUnknown, false + return nil, false } metrics.AddNotef("Parsed range expression from version: %s", vers.Version) - return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), currentVersionType, true + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), true } diff --git a/vulnfeeds/conversion/cve5/strategies/text_extraction.go b/vulnfeeds/conversion/cve5/strategies/text_extraction.go index a0f8e1b0af9..d056acef33e 100644 --- a/vulnfeeds/conversion/cve5/strategies/text_extraction.go +++ b/vulnfeeds/conversion/cve5/strategies/text_extraction.go @@ -19,15 +19,19 @@ func (s *VersionTextExtractionStrategy) Name() string { return "VersionTextExtraction" } -func (s *VersionTextExtractionStrategy) Extract(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, VersionRangeType, bool) { +func (s *VersionTextExtractionStrategy) Extract(state *ExtractionState, metrics *models.ConversionMetrics) { + ExtractPerVersion(state, metrics, s.extractVersion) +} + +func (s *VersionTextExtractionStrategy) extractVersion(vers models.Versions, _ models.Affected, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, bool) { if vers.Status != "affected" || vers.Version == "" { - return nil, VersionRangeTypeUnknown, false + return nil, false } possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) - if possibleVersions != nil { - metrics.AddNotef("Versions retrieved from text but not used CURRENTLY") + if len(possibleVersions) > 0 { + return possibleVersions, true } - return nil, VersionRangeTypeUnknown, false + return nil, false } diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index e3c34cdc966..1d6192899c9 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -22,20 +22,20 @@ func TestToVersionRangeType(t *testing.T) { tests := []struct { name string input string - want VersionRangeType + want strategies.VersionRangeType }{ - {"git", "git", VersionRangeTypeGit}, - {"semver", "semver", VersionRangeTypeSemver}, - {"ecosystem", "ecosystem", VersionRangeTypeEcosystem}, - {"other", "custom", VersionRangeTypeEcosystem}, - {"empty", "", VersionRangeTypeEcosystem}, - {"case insensitive", "GiT", VersionRangeTypeGit}, + {"git", "git", strategies.VersionRangeTypeGit}, + {"semver", "semver", strategies.VersionRangeTypeSemver}, + {"ecosystem", "ecosystem", strategies.VersionRangeTypeEcosystem}, + {"other", "custom", strategies.VersionRangeTypeEcosystem}, + {"empty", "", strategies.VersionRangeTypeEcosystem}, + {"case insensitive", "GiT", strategies.VersionRangeTypeGit}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := toVersionRangeType(tt.input); got != tt.want { - t.Errorf("toVersionRangeType() = %v, want %v", got, tt.want) + if got := strategies.ToVersionRangeType(tt.input); got != tt.want { + t.Errorf("ToVersionRangeType() = %v, want %v", got, tt.want) } }) } @@ -43,11 +43,10 @@ func TestToVersionRangeType(t *testing.T) { func TestFindNormalAffectedRanges(t *testing.T) { tests := []struct { - name string - affected models.Affected - cnaAssigner string - wantRanges []*osvschema.Range - wantRangeType VersionRangeType + name string + affected models.Affected + cnaAssigner string + wantRanges []*osvschema.Range }{ { name: "simple range", @@ -64,7 +63,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("1.0", "", "1.5"), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "single version fallback", @@ -80,7 +78,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("2.0", "2.0", ""), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "mitre single version fallback", @@ -97,7 +94,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("", "3.0", ""), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "mitre multiple versions fallback", @@ -120,7 +116,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { conversion.BuildVersionRange("3.0", "3.0", ""), conversion.BuildVersionRange("3.1", "3.1", ""), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "github range", @@ -135,7 +130,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("2.0", "", "2.5"), }, - wantRangeType: VersionRangeTypeEcosystem, }, { name: "git commit", @@ -151,7 +145,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildGitVersionRange("deadbeef", "deadbeef", "", ""), }, - wantRangeType: VersionRangeTypeGit, }, { name: "changes preferred over lessThanOrEqual with filler version", @@ -171,7 +164,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("0", "", "1.0.33"), }, - wantRangeType: VersionRangeTypeEcosystem, }, { name: "split range pair (CVE-2022-25929)", @@ -194,7 +186,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("1.31.0", "", "1.36.1"), }, - wantRangeType: VersionRangeTypeEcosystem, }, { name: "multi split range (CVE-2022-25761)", @@ -224,28 +215,29 @@ func TestFindNormalAffectedRanges(t *testing.T) { conversion.BuildVersionRange("0", "", "1.2.5"), conversion.BuildVersionRange("1.3-rc1", "", "1.3.1"), }, - wantRangeType: VersionRangeTypeEcosystem, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - var versionExtractor VersionExtractor - if tt.cnaAssigner != "" { - versionExtractor = GetVersionExtractor(tt.cnaAssigner) - } else { - versionExtractor = &DefaultVersionExtractor{} + var strategyList []strategies.VersionStrategy + switch strings.ToLower(tt.cnaAssigner) { + case "linux": + strategyList = strategies.Linux() + case "github_m", "github": + strategyList = strategies.GitHub() + case "mitre": + strategyList = strategies.MITRE() + default: + strategyList = strategies.Default() } - gotRangesWithMeta, gotRangeType := versionExtractor.FindNormalAffectedRanges(tt.affected, &models.ConversionMetrics{CNA: tt.cnaAssigner}) + gotRangesWithMeta := ExtractAffectedRanges(tt.affected, strategyList, &models.ConversionMetrics{CNA: tt.cnaAssigner}) var gotRanges []*osvschema.Range for _, r := range gotRangesWithMeta { gotRanges = append(gotRanges, r.Range) } if diff := cmp.Diff(tt.wantRanges, gotRanges, protocmp.Transform()); diff != "" { - t.Errorf("findNormalAffectedRanges() ranges mismatch (-want +got):\n%s", diff) - } - if gotRangeType != tt.wantRangeType { - t.Errorf("findNormalAffectedRanges() range type = %v, want %v", gotRangeType, tt.wantRangeType) + t.Errorf("ExtractAffectedRanges() ranges mismatch (-want +got):\n%s", diff) } }) } @@ -266,8 +258,8 @@ func TestCompareSemverLike(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := compareSemverLike(tt.a, tt.b); got != tt.want { - t.Errorf("compareSemverLike() = %v, want %v", got, tt.want) + if got := strategies.CompareSemverLike(tt.a, tt.b); got != tt.want { + t.Errorf("CompareSemverLike() = %v, want %v", got, tt.want) } }) } @@ -277,7 +269,7 @@ func TestFindInverseAffectedRanges(t *testing.T) { tests := []struct { name string affected models.Affected - versionType VersionRangeType + versionType strategies.VersionRangeType cnaAssigner string want []*osvschema.Range }{ @@ -298,7 +290,7 @@ func TestFindInverseAffectedRanges(t *testing.T) { }, }, }, - versionType: VersionRangeTypeSemver, + versionType: strategies.VersionRangeTypeSemver, cnaAssigner: "Linux", want: []*osvschema.Range{ conversion.BuildVersionRange("5.0.0", "", "5.10.1"), @@ -316,7 +308,7 @@ func TestFindInverseAffectedRanges(t *testing.T) { }, }, }, - versionType: VersionRangeTypeUnknown, + versionType: strategies.VersionRangeTypeUnknown, cnaAssigner: "NotLinux", want: nil, }, @@ -337,7 +329,7 @@ func TestFindInverseAffectedRanges(t *testing.T) { }, }, }, - versionType: VersionRangeTypeSemver, + versionType: strategies.VersionRangeTypeSemver, cnaAssigner: "Linux", want: []*osvschema.Range{ conversion.BuildVersionRange("4.0.0", "", "4.5.2"), @@ -503,25 +495,21 @@ func TestDefaultVersionExtractor_SliceOrderPrecedence(t *testing.T) { } // When StandardRangeStrategy is first in slice order: - extractor1 := &DefaultVersionExtractor{ - Strategies: []strategies.VersionStrategy{ - &strategies.StandardRangeStrategy{}, - &strategies.StandaloneSingleVersionStrategy{}, - }, + pipeline1 := []strategies.VersionStrategy{ + &strategies.StandardRangeStrategy{}, + &strategies.StandaloneSingleVersionStrategy{}, } - ranges1, _ := extractor1.FindNormalAffectedRanges(affected, &models.ConversionMetrics{}) + ranges1 := ExtractAffectedRanges(affected, pipeline1, &models.ConversionMetrics{}) if len(ranges1) != 1 || ranges1[0].Range.GetEvents()[1].GetFixed() != "1.5.0" { t.Fatalf("expected StandardRangeStrategy to match first, got: %+v", ranges1) } // When StandaloneSingleVersionStrategy is first in slice order: - extractor2 := &DefaultVersionExtractor{ - Strategies: []strategies.VersionStrategy{ - &strategies.StandaloneSingleVersionStrategy{}, - &strategies.StandardRangeStrategy{}, - }, + pipeline2 := []strategies.VersionStrategy{ + &strategies.StandaloneSingleVersionStrategy{}, + &strategies.StandardRangeStrategy{}, } - ranges2, _ := extractor2.FindNormalAffectedRanges(affected, &models.ConversionMetrics{}) + ranges2 := ExtractAffectedRanges(affected, pipeline2, &models.ConversionMetrics{}) if len(ranges2) != 1 || ranges2[0].Range.GetEvents()[1].GetLastAffected() != "1.0.0" { t.Fatalf("expected StandaloneSingleVersionStrategy to match first when placed earlier, got: %+v", ranges2) } From 2d6365100de6f1ddb6675c493adcd27f0478d984 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Tue, 22 Sep 2026 02:52:40 +0000 Subject: [PATCH 11/12] add missing testcase --- .../cves/2026/67xxx/CVE-2026-67185.json | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 vulnfeeds/test_data/cvelistV5/cves/2026/67xxx/CVE-2026-67185.json diff --git a/vulnfeeds/test_data/cvelistV5/cves/2026/67xxx/CVE-2026-67185.json b/vulnfeeds/test_data/cvelistV5/cves/2026/67xxx/CVE-2026-67185.json new file mode 100644 index 00000000000..0bed3388f63 --- /dev/null +++ b/vulnfeeds/test_data/cvelistV5/cves/2026/67xxx/CVE-2026-67185.json @@ -0,0 +1,188 @@ +{ + "dataType": "CVE_RECORD", + "dataVersion": "5.2", + "cveMetadata": { + "cveId": "CVE-2026-67185", + "assignerOrgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10", + "state": "PUBLISHED", + "assignerShortName": "VulnCheck", + "dateReserved": "2026-07-28T16:06:49.773Z", + "datePublished": "2026-07-28T16:30:45.229Z", + "dateUpdated": "2026-07-28T19:34:14.583Z" + }, + "containers": { + "cna": { + "providerMetadata": { + "orgId": "83251b91-4cc7-4094-a5c7-464a1b83ea10", + "shortName": "VulnCheck", + "dateUpdated": "2026-07-28T16:30:45.229Z" + }, + "title": "TinyWeb 0.0.8 Path Traversal via URL Path Component", + "descriptions": [ + { + "lang": "en", + "value": "TinyWeb through 0.0.8 contains a path traversal vulnerability that allows unauthenticated attackers to read arbitrary files by submitting ../ sequences in the URL path, which are concatenated directly to the configured web root in HttpBuilder::buildResponse() without normalization, dot-segment removal, or boundary checks. Attackers can craft a single request with ../ sequences that pass through the URL parser unchanged and reach the filesystem call via HttpFile::setFile(), exposing sensitive files such as credential stores and private keys when the server process runs as root." + } + ], + "tags": [ + "x_open-source" + ], + "datePublic": "2026-07-26T00:00:00.000Z", + "problemTypes": [ + { + "descriptions": [ + { + "lang": "en", + "cweId": "CWE-22", + "description": "Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')", + "type": "CWE" + } + ] + } + ], + "affected": [ + { + "defaultStatus": "affected", + "vendor": "GeneralSandman", + "product": "TinyWeb", + "packageURL": "pkg:github/GeneralSandman/TinyWeb", + "repo": "https://github.com/GeneralSandman/TinyWeb", + "versions": [ + { + "status": "affected", + "version": "0b3b5fdb5a058f50248cd8547824936b8dd10351", + "versionType": "git", + "lessThanOrEqual": "a381da252fe8e873c8aff22703040426cc9b2ae0" + }, + { + "status": "affected", + "version": "0.0.8", + "versionType": "semver" + } + ] + } + ], + "metrics": [ + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV4_0": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "attackRequirements": "NONE", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "vulnConfidentialityImpact": "HIGH", + "subConfidentialityImpact": "NONE", + "vulnIntegrityImpact": "NONE", + "subIntegrityImpact": "NONE", + "vulnAvailabilityImpact": "NONE", + "subAvailabilityImpact": "NONE", + "exploitMaturity": "NOT_DEFINED", + "Safety": "NOT_DEFINED", + "Automatable": "NOT_DEFINED", + "Recovery": "NOT_DEFINED", + "valueDensity": "NOT_DEFINED", + "vulnerabilityResponseEffort": "NOT_DEFINED", + "providerUrgency": "NOT_DEFINED", + "version": "4.0", + "baseSeverity": "HIGH", + "baseScore": 8.7, + "vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N" + } + }, + { + "format": "CVSS", + "scenarios": [ + { + "lang": "en", + "value": "GENERAL" + } + ], + "cvssV3_1": { + "attackVector": "NETWORK", + "attackComplexity": "LOW", + "privilegesRequired": "NONE", + "userInteraction": "NONE", + "scope": "UNCHANGED", + "confidentialityImpact": "HIGH", + "integrityImpact": "NONE", + "availabilityImpact": "NONE", + "version": "3.1", + "baseSeverity": "HIGH", + "baseScore": 7.5, + "vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N" + } + } + ], + "references": [ + { + "url": "https://github.com/theopaid/Unauthenticated-Path-Traversal-Allows-Arbitrary-File-Read-TinyWeb-", + "name": "Researcher Disclosure", + "tags": [ + "technical-description", + "exploit" + ] + }, + { + "url": "https://www.vulncheck.com/advisories/tinyweb-path-traversal-via-url-path-component", + "tags": [ + "third-party-advisory" + ] + } + ], + "credits": [ + { + "lang": "en", + "value": "Theodosis Paidakis", + "type": "finder" + } + ], + "source": { + "discovery": "UNKNOWN" + }, + "x_generator": { + "engine": "vulncheck" + } + }, + "adp": [ + { + "metrics": [ + { + "other": { + "type": "ssvc", + "content": { + "timestamp": "2026-07-28T19:34:04.567399Z", + "id": "CVE-2026-67185", + "options": [ + { + "Exploitation": "poc" + }, + { + "Automatable": "yes" + }, + { + "Technical Impact": "partial" + } + ], + "role": "CISA Coordinator", + "version": "2.0.3" + } + } + } + ], + "title": "CISA ADP Vulnrichment", + "providerMetadata": { + "orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0", + "shortName": "CISA-ADP", + "dateUpdated": "2026-07-28T19:34:14.583Z" + } + } + ] + } +} \ No newline at end of file From 4785e13596678da90e157046597807c1bb8b165b Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Thu, 24 Sep 2026 02:59:50 +0000 Subject: [PATCH 12/12] fix changes related to outcomes --- vulnfeeds/conversion/common.go | 26 +++- .../conversion/cve5/default_extractor.go | 29 +++- .../cve5/strategies/inverse_range.go | 6 +- .../conversion/cve5/strategies/presets.go | 3 + .../nvd/__snapshots__/converter_test.snap | 17 ++- vulnfeeds/conversion/versions.go | 131 ++++++++++++++---- vulnfeeds/conversion/versions_test.go | 88 ++++++++++++ vulnfeeds/models/metrics.go | 2 +- 8 files changed, 263 insertions(+), 39 deletions(-) diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 865c9f46fe9..9e5f12c3b46 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -749,6 +749,21 @@ func IsGitCommitSHA(s string) bool { return true } +// isHexCommitPrefix checks whether a string is a 7-to-64 character hexadecimal Git commit hash/prefix. +func isHexCommitPrefix(s string) bool { + s = strings.TrimSpace(s) + if len(s) < 7 || len(s) > 64 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + + return true +} + // IsDirectGitRange determines if a range is already composed of Git commit hashes rather than tag/version names. func IsDirectGitRange(vr models.RangeWithMetadata) bool { if vr.Range == nil { @@ -759,25 +774,30 @@ func IsDirectGitRange(vr models.RangeWithMetadata) bool { return false } + checkSHA := IsGitCommitSHA + if vr.Range.GetType() == osvschema.Range_GIT { + checkSHA = isHexCommitPrefix + } + hasCommit := false for _, e := range events { intro := e.GetIntroduced() if intro != "" && intro != "0" { - if !IsGitCommitSHA(intro) { + if !checkSHA(intro) { return false } hasCommit = true } fixed := e.GetFixed() if fixed != "" { - if !IsGitCommitSHA(fixed) { + if !checkSHA(fixed) { return false } hasCommit = true } lastAffected := e.GetLastAffected() if lastAffected != "" { - if !IsGitCommitSHA(lastAffected) { + if !checkSHA(lastAffected) { return false } hasCommit = true diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index c038e995469..3fb35f6f3d0 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -120,13 +120,38 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln metrics.AddNotef("Extracted versions from description: %v", textRanges) } if len(textRanges) != 0 { - processRanges(textRanges) + if processRanges(textRanges) { + gotVersions = true + } + } + } + + var commits []models.AffectedCommit + if !gotVersions && httpClient != nil { + refs := slices.Clone(cve.Containers.CNA.References) + for _, adp := range cve.Containers.ADP { + refs = append(refs, adp.References...) + } + refs = c.DeduplicateRefs(refs) + extractedCommits, err := c.ExtractCommitsFromRefs(refs, httpClient, cache) + if err != nil { + metrics.AddNotef("Failed to extract commits from refs: %v", err) + } + if len(extractedCommits) > 0 { + commits = extractedCommits + metrics.AddNotef("Extracted commits from refs: %v", commits) + for _, commit := range commits { + successfulRepos[commit.Repo] = true + } + metrics.ResolvedRangesCount += len(commits) + metrics.SetOutcome(models.Successful) + metrics.AddSource(models.VersionSourceRefs) } } keys := slices.Collect(maps.Keys(successfulRepos)) groupedRanges := c.GroupRanges(resolvedRanges) - affected := c.MergeRangesAndCreateAffected(groupedRanges, nil, keys, metrics) + affected := c.MergeRangesAndCreateAffected(groupedRanges, commits, keys, metrics) v.Affected = append(v.Affected, affected...) addUnresolvedRanges(unresolvedRanges) diff --git a/vulnfeeds/conversion/cve5/strategies/inverse_range.go b/vulnfeeds/conversion/cve5/strategies/inverse_range.go index 05cb174cb17..a67e5eb60c8 100644 --- a/vulnfeeds/conversion/cve5/strategies/inverse_range.go +++ b/vulnfeeds/conversion/cve5/strategies/inverse_range.go @@ -57,7 +57,7 @@ func FindInverseAffectedRanges(cveAff models.Affected, metrics *models.Conversio continue } - if versionValue == "0" || ToVersionRangeType(vers.VersionType) != VersionRangeTypeSemver { + if versionValue == "0" || (ToVersionRangeType(vers.VersionType) != VersionRangeTypeSemver && (cveAff.DefaultStatus != "affected" || vers.LessThan != "*")) { continue } fixed = append(fixed, versionValue) @@ -76,8 +76,8 @@ func FindInverseAffectedRanges(cveAff models.Affected, metrics *models.Conversio slices.SortFunc(introduced, CompareSemverLike) slices.SortFunc(fixed, CompareSemverLike) - // If the first fixed version is earlier than the first introduced, assume introduction from "0". - if len(fixed) > 0 && len(introduced) > 0 && CompareSemverLike(fixed[0], introduced[0]) < 0 { + // If the first fixed version is earlier than the first introduced (or no introduced was listed while defaultStatus is affected), assume introduction from "0". + if len(fixed) > 0 && ((len(introduced) == 0 && cveAff.DefaultStatus == "affected") || (len(introduced) > 0 && CompareSemverLike(fixed[0], introduced[0]) < 0)) { introduced = append([]string{"0"}, introduced...) } diff --git a/vulnfeeds/conversion/cve5/strategies/presets.go b/vulnfeeds/conversion/cve5/strategies/presets.go index f18a8653b1a..6b5ebeb6354 100644 --- a/vulnfeeds/conversion/cve5/strategies/presets.go +++ b/vulnfeeds/conversion/cve5/strategies/presets.go @@ -12,6 +12,7 @@ func Default() []VersionStrategy { &CPEVersionStringStrategy{}, &VersionTextExtractionStrategy{}, &StandaloneSingleVersionStrategy{}, + &InverseAffectedRangesStrategy{}, } } @@ -65,8 +66,10 @@ func MITRE() []VersionStrategy { &StringRangeExpressionStrategy{}, &GitCommitStrategy{}, &CPEVersionStringStrategy{}, + &VersionTextExtractionStrategy{}, &ZeroIntroducedSingleVersionStrategy{}, &StandaloneSingleVersionStrategy{}, + &InverseAffectedRangesStrategy{}, } } diff --git a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap index a4cbbb87f32..9ba8e6681ed 100755 --- a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap @@ -1944,7 +1944,18 @@ "aliases": [ "GHSA-j7xp-4mg9-x28r" ], - "database_specific": {}, + "database_specific": { + "unresolved_ranges": [ + { + "extracted_events": [ + { + "fixed": "2.0.0-next.193" + } + ], + "source": "DESCRIPTION" + } + ] + }, "details": "LobeChat is an open source chat application platform. Prior to version 2.0.0-next.193, `knowledgeBase.removeFilesFromKnowledgeBase` tRPC ep allows authenticated users to delete files from any knowledge base without verifying ownership. `userId` filter in the database query is commented out, so it's enabling attackers to delete other users' KB files if they know the knowledge base ID and file ID. While the vulnerability is confirmed, practical exploitation requires knowing target's KB ID and target's file ID. These IDs are random and not easily enumerable. However, IDs may leak through shared links, logs, referrer headers and so on. Missing authorization check is a critical security flaw regardless. Users should upgrade to version 2.0.0-next.193 to receive a patch.", "id": "CVE-2026-23522", "modified": "2026-01-26T15:05:39.840Z", @@ -3588,7 +3599,7 @@ "introduced": "0.68" }, { - "fixed": "0.80" + "last_affected": "0.80" }, { "fixed": "0.81" @@ -3603,7 +3614,7 @@ "fixed": "2.15.0.1" }, { - "fixed": "1.14.6" + "last_affected": "1.14.6" } ], "source": "DESCRIPTION" diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 3dcb099a7c1..a3e3fa91fcf 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -648,36 +648,77 @@ func processExtractedVersion(version string) string { return version } -func ExtractVersionsFromText(validVersions []string, text string, metrics *models.ConversionMetrics, source models.VersionSource) []models.RangeWithMetadata { - // Match: - // - x.x.x before x.x.x - // - x.x.x through x.x.x - // - through x.x.x - // - before x.x.x - pattern := regexp.MustCompile(`(?i)([\w.+\-]+)?\s+(through|before)\s+(?:version\s+)?([\w.+\-]+)`) - matches := pattern.FindAllStringSubmatch(text, -1) - if matches == nil { - metrics.AddNotef("Failed to parse versions from text") - return nil +var ( + textRangePattern = regexp.MustCompile(`(?i)(?:([vV]?[0-9][\w.+\-]*)\s+)?(through|before|prior\s+to|earlier\s+than|up\s+to(?:\s+and\s+including)?|fixed\s+in|patched\s+in|resolved\s+in|versions?\s*<=?)\s+(?:versions?\s+)?[vV]?([0-9][\w.+\-]*)`) + textTrailingInclusivePattern = regexp.MustCompile(`(?i)\b[vV]?([0-9]+(?:\.[0-9a-zA-Z.+\-]+)+)\s+and\s+(?:earlier|before|below|prior|older)\b`) + textBeforeCommitPattern = regexp.MustCompile(`(?i)\b(?:before|prior\s+to|fixed\s+in)\s+commits?\s+([0-9a-f]{7,40})\b`) + textThroughCommitPattern = regexp.MustCompile(`(?i)\b(?:through|up\s+to|at|in)\s+commits?\s+([0-9a-f]{7,40})\b`) + textVulnerableCommitPattern = regexp.MustCompile(`(?i)\b([0-9a-f]{7,40})\s+(?:is\s+vulnerable|l?contains\s+a\b|has\s+a\b)`) +) + +func isInclusiveKeyword(kw string) bool { + kw = strings.ToLower(strings.TrimSpace(kw)) + return kw == "through" || strings.HasPrefix(kw, "up to") || strings.HasSuffix(kw, "<=") +} + +func isLikelyGitSHA(s string) bool { + if !isHexCommitPrefix(s) { + return false } - versions := make([]models.RangeWithMetadata, 0, len(matches)) + return strings.ContainsAny(s, "0123456789") && strings.ContainsAny(strings.ToLower(s), "abcdef") +} +func ExtractVersionsFromText(validVersions []string, text string, metrics *models.ConversionMetrics, source models.VersionSource) []models.RangeWithMetadata { + var versions []models.RangeWithMetadata + + // 1. Check for explicit git commit references in prose (e.g., "before commit 6187a4e"). + for _, m := range textBeforeCommitPattern.FindAllStringSubmatch(text, -1) { + sha := strings.Trim(m[1], ".") + if isLikelyGitSHA(sha) { + versions = append(versions, models.RangeWithMetadata{ + Range: BuildGitVersionRange("0", "", sha, ""), + Metadata: models.Metadata{Source: source}, + }) + } + } + for _, m := range textThroughCommitPattern.FindAllStringSubmatch(text, -1) { + sha := strings.Trim(m[1], ".") + if isLikelyGitSHA(sha) { + versions = append(versions, models.RangeWithMetadata{ + Range: BuildGitVersionRange("0", sha, "", ""), + Metadata: models.Metadata{Source: source}, + }) + } + } + + // 2. Match leading/infix relative version phrases: + // - x.x.x before/through x.x.x + // - before/through/prior to/earlier than/up to/fixed in x.x.x + matches := textRangePattern.FindAllStringSubmatch(text, -1) for _, match := range matches { - // Trim periods that are part of sentences. - introduced := processExtractedVersion(match[1]) - fixed := processExtractedVersion(match[3]) + introduced := processExtractedVersion(strings.TrimPrefix(strings.TrimPrefix(match[1], "v"), "V")) + target := processExtractedVersion(match[3]) + if target == "" || isLikelyGitSHA(target) { + continue + } + + fixed := "" lastaffected := "" - if match[2] == "through" && validVersions != nil { - // "Through" implies inclusive range, so the fixed version is the one that comes after. - var err error - fixed, err = nextVersion(validVersions, fixed) - if err != nil { - metrics.AddNotef("Failed to determine next version after %s: %s", fixed, err.Error()) - // if that inference failed, we know this version was definitely still vulnerable. - lastaffected = cleanVersion(match[3]) - metrics.AddNotef("Using %s as last_affected version instead", cleanVersion(match[3])) + if isInclusiveKeyword(match[2]) { + if validVersions != nil { + var err error + fixed, err = nextVersion(validVersions, target) + if err != nil { + metrics.AddNotef("Failed to determine next version after %s: %s", target, err.Error()) + lastaffected = cleanVersion(target) + metrics.AddNotef("Using %s as last_affected version instead", lastaffected) + } + } else { + lastaffected = cleanVersion(target) } + } else { + fixed = cleanVersion(target) } if introduced == "" && fixed == "" && lastaffected == "" { @@ -694,7 +735,6 @@ func ExtractVersionsFromText(validVersions []string, text string, metrics *model if lastaffected != "" && !HasVersion(validVersions, lastaffected) { metrics.AddNotef("Extracted last_affected version %s is not a valid version", lastaffected) } - // Favour fixed over last_affected for schema compliance. if fixed != "" && lastaffected != "" { lastaffected = "" } @@ -705,8 +745,45 @@ func ExtractVersionsFromText(validVersions []string, text string, metrics *model Metadata: models.Metadata{ Source: source, }, - }, - ) + }) + } + + // 3. Match trailing inclusive bounds (e.g., "25.11 and before", "3.0.1 and earlier"). + for _, match := range textTrailingInclusivePattern.FindAllStringSubmatch(text, -1) { + target := cleanVersion(processExtractedVersion(match[1])) + if target == "" { + continue + } + fixed := "" + lastaffected := target + if validVersions != nil { + if nextVer, err := nextVersion(validVersions, target); err == nil { + fixed = nextVer + lastaffected = "" + } + } + versions = append(versions, models.RangeWithMetadata{ + Range: BuildVersionRange("0", lastaffected, fixed), + Metadata: models.Metadata{Source: source}, + }) + } + + // 4. Fallback: commit hash immediately preceding "is vulnerable" / "contains a" (e.g. "stomper 5e2741e is vulnerable"). + if len(versions) == 0 { + for _, m := range textVulnerableCommitPattern.FindAllStringSubmatch(text, -1) { + sha := strings.Trim(m[1], ".") + if isLikelyGitSHA(sha) { + versions = append(versions, models.RangeWithMetadata{ + Range: BuildGitVersionRange("0", sha, "", ""), + Metadata: models.Metadata{Source: source}, + }) + } + } + } + + if len(versions) == 0 { + metrics.AddNotef("Failed to parse versions from text") + return nil } return versions diff --git a/vulnfeeds/conversion/versions_test.go b/vulnfeeds/conversion/versions_test.go index a68291dc772..6b00d594f43 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -1760,3 +1760,91 @@ func TestProcessRanges_DirectGitRange(t *testing.T) { t.Errorf("expected range type to be GIT, got %s", resolved[0].Range.GetType().String()) } } + +func TestExtractVersionsFromText(t *testing.T) { + tests := []struct { + name string + text string + wantIntro string + wantFixed string + wantLastAff string + wantRangeTyp string + }{ + { + name: "trailing and before (CVE-2026-26377)", + text: "Cross Site Scripting vulnerability in Koha 25.11 and before allows a remote attacker to execute arbitrary code.", + wantIntro: "0", + wantLastAff: "25.11", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "versions up to (CVE-2026-26379)", + text: "Koha versions up to 25.11 contain a Server-Side Request Forgery (SSRF) vulnerability.", + wantIntro: "0", + wantLastAff: "25.11", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "prior to version (CVE-2026-26399)", + text: "A stack-use-after-return issue exists in the Arduino_Core_STM32 library prior to version 1.7.0.", + wantIntro: "0", + wantFixed: "1.7.0", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "trailing and earlier (CVE-2026-26483)", + text: "Mettle SendPortal 3.0.1 and earlier contains a stored cross-site scripting (XSS) vulnerability.", + wantIntro: "0", + wantLastAff: "3.0.1", + wantRangeTyp: "UNSPECIFIED", + }, + { + name: "before commit short SHA (CVE-2026-26514)", + text: "An Argument Injection vulnerability exists in bird-lg-go before commit 6187a4e.", + wantIntro: "0", + wantFixed: "6187a4e", + wantRangeTyp: "GIT", + }, + { + name: "vulnerable commit short SHA (CVE-2026-26445)", + text: "stomper 5e2741e is vulnerable to Denial of Service.", + wantIntro: "0", + wantLastAff: "5e2741e", + wantRangeTyp: "GIT", + }, + { + name: "contains commit 40-char SHA (CVE-2026-26452)", + text: "ccoap 77f55c4b466e99327c24ace8a2913d3ba7e2ccd5 lcontains a vulnerability in the option parsing logic.", + wantIntro: "0", + wantLastAff: "77f55c4b466e99327c24ace8a2913d3ba7e2ccd5", + wantRangeTyp: "GIT", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + metrics := &models.ConversionMetrics{} + got := ExtractVersionsFromText(nil, tc.text, metrics, models.VersionSourceDescription) + if len(got) == 0 { + t.Fatalf("ExtractVersionsFromText(%q) returned no ranges", tc.text) + } + r := got[0].Range + if r.GetType().String() != tc.wantRangeTyp { + t.Errorf("Range.Type = %s, want %s", r.GetType().String(), tc.wantRangeTyp) + } + events := r.GetEvents() + if len(events) < 2 { + t.Fatalf("expected at least 2 events, got %+v", events) + } + if events[0].GetIntroduced() != tc.wantIntro { + t.Errorf("Introduced = %q, want %q", events[0].GetIntroduced(), tc.wantIntro) + } + if events[1].GetFixed() != tc.wantFixed { + t.Errorf("Fixed = %q, want %q", events[1].GetFixed(), tc.wantFixed) + } + if events[1].GetLastAffected() != tc.wantLastAff { + t.Errorf("LastAffected = %q, want %q", events[1].GetLastAffected(), tc.wantLastAff) + } + }) + } +} diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 2d091485b17..90326fb70e4 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -100,7 +100,7 @@ func (m *ConversionMetrics) AddNotef(format string, a ...any) { // SetOutcome sets the outcome of the conversion only if it's not already set, or has become successful. func (m *ConversionMetrics) SetOutcome(outcome ConversionOutcome) { - if m.Outcome == ConversionUnknown { // TODO DOUBLE CHECK + if m.Outcome == ConversionUnknown || outcome == Successful { m.Outcome = outcome } }