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 9a1e5d5befe073a4d66736ee519bfae62f154767 Mon Sep 17 00:00:00 2001 From: Jess Lowe Date: Thu, 24 Sep 2026 05:03:24 +0000 Subject: [PATCH 12/12] provenance is gorgeous --- vulnfeeds/cmd/combine-to-osv/main.go | 66 +- vulnfeeds/conversion/common.go | 76 +- vulnfeeds/conversion/common_test.go | 32 +- .../cve5/__snapshots__/converter_test.snap | 2335 +++++++++++------ .../conversion/cve5/default_extractor.go | 8 +- vulnfeeds/conversion/cve5/extraction.go | 1 + vulnfeeds/conversion/cve5/strategies/cpe.go | 6 + .../conversion/cve5/strategies/strategy.go | 9 + .../cve5/version_extraction_test.go | 164 +- vulnfeeds/conversion/grouping.go | 91 +- vulnfeeds/conversion/grouping_test.go | 92 +- .../nvd/__snapshots__/converter_test.snap | 2226 ++++++++++------ vulnfeeds/conversion/versions.go | 57 +- vulnfeeds/conversion/versions_test.go | 112 +- vulnfeeds/models/metrics.go | 2 + vulnfeeds/models/types.go | 20 +- 16 files changed, 3437 insertions(+), 1860 deletions(-) diff --git a/vulnfeeds/cmd/combine-to-osv/main.go b/vulnfeeds/cmd/combine-to-osv/main.go index 9bfd50f1c21..6ef18fa246e 100644 --- a/vulnfeeds/cmd/combine-to-osv/main.go +++ b/vulnfeeds/cmd/combine-to-osv/main.go @@ -607,12 +607,41 @@ func getExtractedEvents(r *osvschema.Range) []*structpb.Value { return val.GetListValue().GetValues() } -func parseExtractedEvent(v *structpb.Value) ExtractedEvent { +func parseExtractedEvent(v *structpb.Value) []ExtractedEvent { s := v.GetStructValue() if s == nil { - return ExtractedEvent{} + return nil } fields := s.GetFields() + + if rangeVal, ok := fields["range"]; ok && rangeVal.GetListValue() != nil { + var events []ExtractedEvent + for _, evVal := range rangeVal.GetListValue().GetValues() { + evS := evVal.GetStructValue() + if evS == nil { + continue + } + evFields := evS.GetFields() + var ev ExtractedEvent + if intro, ok := evFields["introduced"]; ok { + ev.Introduced = intro.GetStringValue() + } + if fixed, ok := evFields["fixed"]; ok { + ev.Fixed = fixed.GetStringValue() + } + if la, ok := evFields["last_affected"]; ok { + ev.LastAffected = la.GetStringValue() + } + if lim, ok := evFields["limit"]; ok { + ev.Limit = lim.GetStringValue() + } + events = append(events, ev) + } + + return events + } + + // Fallback to old flat structure (single event) var ev ExtractedEvent if intro, ok := fields["introduced"]; ok { ev.Introduced = intro.GetStringValue() @@ -627,7 +656,11 @@ func parseExtractedEvent(v *structpb.Value) ExtractedEvent { ev.Limit = lim.GetStringValue() } - return ev + if ev != (ExtractedEvent{}) { + return []ExtractedEvent{ev} + } + + return nil } func parseExtractedEvents(r *osvschema.Range) []ExtractedEvent { @@ -635,9 +668,9 @@ func parseExtractedEvents(r *osvschema.Range) []ExtractedEvent { if len(rawValues) == 0 { return nil } - events := make([]ExtractedEvent, 0, len(rawValues)) + var events []ExtractedEvent for _, val := range rawValues { - events = append(events, parseExtractedEvent(val)) + events = append(events, parseExtractedEvent(val)...) } return events @@ -688,6 +721,29 @@ func isCPERange(r *osvschema.Range) bool { if fields == nil { return false } + + // Check new location inside extracted_events first + if extractedEventsVal, ok := fields["extracted_events"]; ok && extractedEventsVal.GetListValue() != nil { + for _, groupVal := range extractedEventsVal.GetListValue().GetValues() { + groupS := groupVal.GetStructValue() + if groupS == nil { + continue + } + groupFields := groupS.GetFields() + if sourceVal, ok := groupFields["source"]; ok { + if sourceVal.GetStringValue() == "CPE_RANGE" { + return true + } + } + if strategyVal, ok := groupFields["strategy"]; ok { + if strategyVal.GetStringValue() == "CPEApplicability" { + return true + } + } + } + } + + // Fallback to old location val, ok := fields["source"] if !ok { return false diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index 865c9f46fe9..04646cd5a41 100644 --- a/vulnfeeds/conversion/common.go +++ b/vulnfeeds/conversion/common.go @@ -293,16 +293,7 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri } successfulRepos = append(successfulRepos, repo) if len(vr.Range.GetEvents()) > 0 { - dbSpecificMap := map[string]any{ - "extracted_events": vr.Range.GetEvents(), - } - if vr.Metadata.CPE != "" { - dbSpecificMap["cpe"] = vr.Metadata.CPE - } - if string(vr.Metadata.Source) != "" { - dbSpecificMap["source"] = string(vr.Metadata.Source) - } - databaseSpecific, err := utility.NewStructpbFromMap(dbSpecificMap) + databaseSpecific, err := buildRangeDatabaseSpecific(vr) if err != nil { metrics.AddNotef("failed to make database specific: %v", err) } else { @@ -324,6 +315,29 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri return newVersionRanges, unresolvedRanges, successfulRepos } +func buildRangeDatabaseSpecific(vr models.RangeWithMetadata) (*structpb.Struct, error) { + extractedEventGroup := map[string]any{ + "range": vr.Range.GetEvents(), + } + if vr.Metadata.CPE != "" { + extractedEventGroup["cpe"] = vr.Metadata.CPE + } + if string(vr.Metadata.Source) != "" { + extractedEventGroup["source"] = string(vr.Metadata.Source) + } + if vr.Metadata.Strategy != "" { + extractedEventGroup["strategy"] = vr.Metadata.Strategy + } + if vr.Metadata.OriginalTag != "" { + extractedEventGroup["original_tag"] = vr.Metadata.OriginalTag + } + dbSpecificMap := map[string]any{ + "extracted_events": []any{extractedEventGroup}, + } + + return utility.NewStructpbFromMap(dbSpecificMap) +} + // BuildVersionRange is a helper function that adds 'introduced', 'fixed', or 'last_affected' // events to an OSV version range. If 'intro' is empty, it defaults to "0". func BuildVersionRange(intro string, lastAff string, fixed string) *osvschema.Range { @@ -555,7 +569,9 @@ func CreateUnresolvedRanges(unresolvedRanges []models.RangeWithMetadata) *struct type key struct { Source string + Strategy string VendorProduct string + OriginalTag string } rangesByKey := make(map[key][]models.RangeWithMetadata) @@ -570,7 +586,12 @@ func CreateUnresolvedRanges(unresolvedRanges []models.RangeWithMetadata) *struct vendorProduct = ur.Metadata.CPE } } - k := key{Source: string(ur.Metadata.Source), VendorProduct: vendorProduct} + k := key{ + Source: string(ur.Metadata.Source), + Strategy: ur.Metadata.Strategy, + VendorProduct: vendorProduct, + OriginalTag: ur.Metadata.OriginalTag, + } if _, ok := rangesByKey[k]; !ok { keys = append(keys, k) } @@ -581,8 +602,14 @@ func CreateUnresolvedRanges(unresolvedRanges []models.RangeWithMetadata) *struct if a.Source != b.Source { return strings.Compare(a.Source, b.Source) } + if a.Strategy != b.Strategy { + return strings.Compare(a.Strategy, b.Strategy) + } + if a.VendorProduct != b.VendorProduct { + return strings.Compare(a.VendorProduct, b.VendorProduct) + } - return strings.Compare(a.VendorProduct, b.VendorProduct) + return strings.Compare(a.OriginalTag, b.OriginalTag) }) listElements := make([]any, 0, len(keys)) @@ -623,14 +650,24 @@ func CreateUnresolvedRanges(unresolvedRanges []models.RangeWithMetadata) *struct if k.VendorProduct != "" { unresolvedRangesMap["vendor_product"] = k.VendorProduct } - if k.Source != "" { - unresolvedRangesMap["source"] = k.Source - } if len(cpes) > 0 { unresolvedRangesMap["cpes"] = cpes } - unresolvedRangesMap["extracted_events"] = events + extractedEventGroup := map[string]any{ + "range": events, + } + if k.Source != "" { + extractedEventGroup["source"] = k.Source + } + if k.Strategy != "" { + extractedEventGroup["strategy"] = k.Strategy + } + if k.OriginalTag != "" { + extractedEventGroup["original_tag"] = k.OriginalTag + } + + unresolvedRangesMap["extracted_events"] = []any{extractedEventGroup} listElements = append(listElements, unresolvedRangesMap) } @@ -807,6 +844,13 @@ func ProcessRanges(ranges []models.RangeWithMetadata, repos []string, metrics *m if repo != "" { vr.Range.Repo = repo vr.Range.Type = osvschema.Range_GIT + if len(vr.Range.GetEvents()) > 0 && vr.Range.GetDatabaseSpecific() == nil { + if databaseSpecific, err := buildRangeDatabaseSpecific(vr); err != nil { + metrics.AddNotef("failed to make database specific: %v", err) + } else { + vr.Range.DatabaseSpecific = databaseSpecific + } + } resolvedRanges = append(resolvedRanges, vr) successfulRepos = append(successfulRepos, repo) } else { diff --git a/vulnfeeds/conversion/common_test.go b/vulnfeeds/conversion/common_test.go index e6da1344ff4..b5c702881d6 100644 --- a/vulnfeeds/conversion/common_test.go +++ b/vulnfeeds/conversion/common_test.go @@ -392,7 +392,6 @@ func TestCreateUnresolvedRanges(t *testing.T) { StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ "vendor_product": structpb.NewStringValue("another:app"), - "source": structpb.NewStringValue(string(models.VersionSourceCPE)), "cpes": structpb.NewListValue(&structpb.ListValue{ Values: []*structpb.Value{ structpb.NewStringValue("cpe:2.3:a:another:app:*:*:*:*:*:*:*:*"), @@ -406,7 +405,20 @@ func TestCreateUnresolvedRanges(t *testing.T) { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "fixed": structpb.NewStringValue("2.0"), + "source": structpb.NewStringValue(string(models.VersionSourceCPE)), + "range": structpb.NewListValue(&structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "fixed": structpb.NewStringValue("2.0"), + }, + }, + }, + }, + }, + }), }, }, }, @@ -424,7 +436,6 @@ func TestCreateUnresolvedRanges(t *testing.T) { StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ "vendor_product": structpb.NewStringValue("example:app"), - "source": structpb.NewStringValue(string(models.VersionSourceDescription)), "cpes": structpb.NewListValue(&structpb.ListValue{ Values: []*structpb.Value{ structpb.NewStringValue("cpe:2.3:a:example:app:*:*:*:*:*:*:*:*"), @@ -438,7 +449,20 @@ func TestCreateUnresolvedRanges(t *testing.T) { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "introduced": structpb.NewStringValue("1.0"), + "source": structpb.NewStringValue(string(models.VersionSourceDescription)), + "range": structpb.NewListValue(&structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "introduced": structpb.NewStringValue("1.0"), + }, + }, + }, + }, + }, + }), }, }, }, diff --git a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap index de1ea01eab7..9d0222c6372 100755 --- a/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/cve5/__snapshots__/converter_test.snap @@ -2304,13 +2304,18 @@ { "extracted_events": [ { - "introduced": "11 and 12" - }, - { - "last_affected": "11 and 12" + "range": [ + { + "introduced": "11 and 12" + }, + { + "last_affected": "11 and 12" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" } - ], - "source": "AFFECTED_FIELD" + ] } ] }, @@ -2348,13 +2353,18 @@ { "extracted_events": [ { - "introduced": "7.61.1" - }, - { - "last_affected": "7.61.1" + "range": [ + { + "introduced": "7.61.1" + }, + { + "last_affected": "7.61.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" } - ], - "source": "AFFECTED_FIELD" + ] } ] }, @@ -2433,13 +2443,18 @@ { "extracted_events": [ { - "introduced": "0.1.0" - }, - { - "last_affected": "0.1.0" + "range": [ + { + "introduced": "0.1.0" + }, + { + "last_affected": "0.1.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" } - ], - "source": "AFFECTED_FIELD" + ] } ] }, @@ -2496,13 +2511,18 @@ "database_specific": { "extracted_events": [ { - "introduced": "4.x" - }, - { - "last_affected": "4.x" + "range": [ + { + "introduced": "4.x" + }, + { + "last_affected": "4.x" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -2573,25 +2593,42 @@ "database_specific": { "extracted_events": [ { - "introduced": "1.7.0" - }, - { - "fixed": "1.18.4" - }, - { - "introduced": "1.19.0" - }, - { - "fixed": "1.20.3" - }, - { - "introduced": "1.21.0" - }, - { - "fixed": "1.23.1" + "range": [ + { + "introduced": "1.7.0" + }, + { + "fixed": "1.18.4" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" + }, + { + "range": [ + { + "introduced": "1.19.0" + }, + { + "fixed": "1.20.3" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" + }, + { + "range": [ + { + "introduced": "1.21.0" + }, + { + "fixed": "1.23.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -2674,19 +2711,42 @@ "database_specific": { "extracted_events": [ { - "introduced": "0" - }, - { - "fixed": "4.25.8" - }, - { - "fixed": "5.29.5" - }, - { - "fixed": "6.31.1" + "range": [ + { + "introduced": "0" + }, + { + "fixed": "4.25.8" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandardRange" + }, + { + "range": [ + { + "introduced": "0" + }, + { + "fixed": "5.29.5" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandardRange" + }, + { + "range": [ + { + "introduced": "0" + }, + { + "fixed": "6.31.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandardRange" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -2756,13 +2816,18 @@ "database_specific": { "extracted_events": [ { - "introduced": "0" - }, - { - "fixed": "2.0.0-next.193" + "range": [ + { + "introduced": "0" + }, + { + "fixed": "2.0.0-next.193" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -2828,761 +2893,1335 @@ "database_specific": { "extracted_events": [ { - "introduced": "8.9.0" - }, + "range": [ + { + "introduced": "8.9.0" + }, + { + "last_affected": "8.9.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.8.0" + }, + { + "last_affected": "8.8.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.7.1" + }, + { + "last_affected": "8.7.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.7.0" + }, + { + "last_affected": "8.7.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.6.0" + }, + { + "last_affected": "8.6.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.5.0" + }, + { + "last_affected": "8.5.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.4.0" + }, + { + "last_affected": "8.4.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.3.0" + }, + { + "last_affected": "8.3.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.2.1" + }, + { + "last_affected": "8.2.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.2.0" + }, + { + "last_affected": "8.2.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.1.2" + }, + { + "last_affected": "8.1.2" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.1.1" + }, + { + "last_affected": "8.1.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.1.0" + }, + { + "last_affected": "8.1.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.0.1" + }, + { + "last_affected": "8.0.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "8.0.0" + }, + { + "last_affected": "8.0.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.88.1" + }, + { + "last_affected": "7.88.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.88.0" + }, + { + "last_affected": "7.88.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.87.0" + }, + { + "last_affected": "7.87.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.86.0" + }, + { + "last_affected": "7.86.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.85.0" + }, + { + "last_affected": "7.85.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.84.0" + }, + { + "last_affected": "7.84.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.83.1" + }, + { + "last_affected": "7.83.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.83.0" + }, + { + "last_affected": "7.83.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.82.0" + }, + { + "last_affected": "7.82.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.81.0" + }, + { + "last_affected": "7.81.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.80.0" + }, + { + "last_affected": "7.80.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.79.1" + }, + { + "last_affected": "7.79.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.79.0" + }, + { + "last_affected": "7.79.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.78.0" + }, + { + "last_affected": "7.78.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.77.0" + }, + { + "last_affected": "7.77.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.76.1" + }, + { + "last_affected": "7.76.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.76.0" + }, + { + "last_affected": "7.76.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.75.0" + }, + { + "last_affected": "7.75.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.74.0" + }, + { + "last_affected": "7.74.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.73.0" + }, + { + "last_affected": "7.73.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.72.0" + }, + { + "last_affected": "7.72.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.71.1" + }, + { + "last_affected": "7.71.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.71.0" + }, + { + "last_affected": "7.71.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.70.0" + }, + { + "last_affected": "7.70.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.69.1" + }, + { + "last_affected": "7.69.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.69.0" + }, + { + "last_affected": "7.69.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.68.0" + }, + { + "last_affected": "7.68.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.67.0" + }, + { + "last_affected": "7.67.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.66.0" + }, + { + "last_affected": "7.66.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.65.3" + }, + { + "last_affected": "7.65.3" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.65.2" + }, + { + "last_affected": "7.65.2" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.65.1" + }, + { + "last_affected": "7.65.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.65.0" + }, + { + "last_affected": "7.65.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.64.1" + }, + { + "last_affected": "7.64.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.64.0" + }, + { + "last_affected": "7.64.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.63.0" + }, + { + "last_affected": "7.63.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.62.0" + }, + { + "last_affected": "7.62.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.61.1" + }, + { + "last_affected": "7.61.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.61.0" + }, + { + "last_affected": "7.61.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.60.0" + }, + { + "last_affected": "7.60.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.59.0" + }, + { + "last_affected": "7.59.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.58.0" + }, + { + "last_affected": "7.58.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.57.0" + }, + { + "last_affected": "7.57.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.56.1" + }, + { + "last_affected": "7.56.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.56.0" + }, + { + "last_affected": "7.56.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.55.1" + }, + { + "last_affected": "7.55.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.55.0" + }, + { + "last_affected": "7.55.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.54.1" + }, + { + "last_affected": "7.54.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.54.0" + }, + { + "last_affected": "7.54.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.53.1" + }, + { + "last_affected": "7.53.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.53.0" + }, + { + "last_affected": "7.53.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.52.1" + }, + { + "last_affected": "7.52.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.52.0" + }, + { + "last_affected": "7.52.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.51.0" + }, + { + "last_affected": "7.51.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.50.3" + }, + { + "last_affected": "7.50.3" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.50.2" + }, + { + "last_affected": "7.50.2" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.50.1" + }, + { + "last_affected": "7.50.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.50.0" + }, + { + "last_affected": "7.50.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.49.1" + }, + { + "last_affected": "7.49.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.49.0" + }, + { + "last_affected": "7.49.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.48.0" + }, + { + "last_affected": "7.48.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.47.1" + }, + { + "last_affected": "7.47.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.47.0" + }, + { + "last_affected": "7.47.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.46.0" + }, + { + "last_affected": "7.46.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.45.0" + }, + { + "last_affected": "7.45.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.44.0" + }, + { + "last_affected": "7.44.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.43.0" + }, + { + "last_affected": "7.43.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.42.1" + }, + { + "last_affected": "7.42.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.42.0" + }, + { + "last_affected": "7.42.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.41.0" + }, + { + "last_affected": "7.41.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.40.0" + }, + { + "last_affected": "7.40.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.39.0" + }, + { + "last_affected": "7.39.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.38.0" + }, + { + "last_affected": "7.38.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.37.1" + }, + { + "last_affected": "7.37.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.37.0" + }, + { + "last_affected": "7.37.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.36.0" + }, + { + "last_affected": "7.36.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.35.0" + }, + { + "last_affected": "7.35.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.34.0" + }, + { + "last_affected": "7.34.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.33.0" + }, + { + "last_affected": "7.33.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "7.32.0" + }, + { + "last_affected": "7.32.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + } + ] + }, + "events": [ + { + "introduced": "5040f7e94cd01decbe7ba8fdacbf489182d503dc" + }, + { + "last_affected": "70812c2f32fc5734bcbbe572b9f61c380433ad6a" + } + ], + "repo": "https://github.com/curl/curl", + "type": "GIT" + } + ], + "versions": [ + "7.32.0", + "7.33.0", + "7.34.0", + "7.35.0", + "7.36.0", + "7.37.0", + "7.37.1", + "7.38.0", + "7.39.0", + "7.40.0", + "7.41.0", + "7.42.0", + "7.42.1", + "7.43.0", + "7.44.0", + "7.45.0", + "7.46.0", + "7.47.0", + "7.47.1", + "7.48.0", + "7.49.0", + "7.49.1", + "7.50.0", + "7.50.1", + "7.50.2", + "7.50.3", + "7.51.0", + "7.52.0", + "7.52.1", + "7.53.0", + "7.53.1", + "7.54.0", + "7.54.1", + "7.55.0", + "7.55.1", + "7.56.0", + "7.56.1", + "7.57.0", + "7.58.0", + "7.59.0", + "7.60.0", + "7.61.0", + "7.61.1", + "7.62.0", + "7.63.0", + "7.64.0", + "7.64.1", + "7.65.0", + "7.65.1", + "7.65.2", + "7.65.3", + "7.66.0", + "7.67.0", + "7.68.0", + "7.69.0", + "7.69.1", + "7.70.0", + "7.71.0", + "7.71.1", + "7.72.0", + "7.73.0", + "7.74.0", + "7.75.0", + "7.76.0", + "7.76.1", + "7.77.0", + "7.78.0", + "7.79.0", + "7.79.1", + "7.80.0", + "7.81.0", + "7.82.0", + "7.83.0", + "7.83.1", + "7.84.0", + "7.85.0", + "7.86.0", + "7.87.0", + "7.88.0", + "7.88.1", + "8.0.0", + "8.0.1", + "8.1.0", + "8.1.1", + "8.1.2", + "8.2.0", + "8.2.1", + "8.3.0", + "8.4.0", + "8.5.0", + "8.6.0", + "8.7.0", + "8.7.1", + "8.8.0", + "8.9.0" + ] + } + ], + "database_specific": { + "cna_assigner": "curl", + "osv_generated_from": "unknown" + }, + "details": "libcurl's ASN1 parser code has the `GTime2str()` function, used for parsing an\nASN.1 Generalized Time field. If given an syntactically incorrect field, the\nparser might end up using -1 for the length of the *time fraction*, leading to\na `strlen()` getting performed on a pointer to a heap buffer area that is not\n(purposely) null terminated.\n\nThis flaw most likely leads to a crash, but can also lead to heap contents\ngetting returned to the application when\n[CURLINFO_CERTINFO](https://curl.se/libcurl/c/CURLINFO_CERTINFO.html) is used.", + "id": "CVE-2024-7264", + "modified": "2025-11-03T22:32:51.400Z", + "published": "2024-07-31T08:08:14.585Z", + "references": [ + { + "type": "WEB", + "url": "http://www.openwall.com/lists/oss-security/2024/07/31/1" + }, + { + "type": "WEB", + "url": "https://curl.se/docs/CVE-2024-7264.html" + }, + { + "type": "WEB", + "url": "https://curl.se/docs/CVE-2024-7264.json" + }, + { + "type": "FIX", + "url": "https://github.com/curl/curl/commit/27959ecce75cdb2809c0bdb3286e60e08fadb519" + }, + { + "type": "WEB", + "url": "https://hackerone.com/reports/2629968" + }, + { + "type": "ADVISORY", + "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-7264" + }, + { + "type": "ADVISORY", + "url": "https://security.netapp.com/advisory/ntap-20240828-0008/" + }, + { + "type": "ADVISORY", + "url": "https://security.netapp.com/advisory/ntap-20241025-0006/" + }, + { + "type": "ADVISORY", + "url": "https://security.netapp.com/advisory/ntap-20241025-0010/" + } + ], + "severity": [ + { + "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L", + "type": "CVSS_V3" + } + ], + "summary": "ASN.1 date parser overread" +} +--- + +[TestCVE5Snapshot/CVE-2026-20912.json - 1] +{ + "affected": [ + { + "ranges": [ + { + "database_specific": { + "extracted_events": [ { - "last_affected": "8.9.0" - }, - { - "introduced": "8.8.0" - }, - { - "last_affected": "8.8.0" - }, - { - "introduced": "8.7.1" - }, - { - "last_affected": "8.7.1" - }, - { - "introduced": "8.7.0" - }, - { - "last_affected": "8.7.0" - }, - { - "introduced": "8.6.0" - }, - { - "last_affected": "8.6.0" - }, - { - "introduced": "8.5.0" - }, - { - "last_affected": "8.5.0" - }, - { - "introduced": "8.4.0" - }, - { - "last_affected": "8.4.0" - }, - { - "introduced": "8.3.0" - }, - { - "last_affected": "8.3.0" - }, - { - "introduced": "8.2.1" - }, - { - "last_affected": "8.2.1" - }, - { - "introduced": "8.2.0" - }, - { - "last_affected": "8.2.0" - }, - { - "introduced": "8.1.2" - }, - { - "last_affected": "8.1.2" - }, - { - "introduced": "8.1.1" - }, - { - "last_affected": "8.1.1" - }, - { - "introduced": "8.1.0" - }, - { - "last_affected": "8.1.0" - }, - { - "introduced": "8.0.1" - }, - { - "last_affected": "8.0.1" - }, - { - "introduced": "8.0.0" - }, - { - "last_affected": "8.0.0" - }, - { - "introduced": "7.88.1" - }, - { - "last_affected": "7.88.1" - }, - { - "introduced": "7.88.0" - }, - { - "last_affected": "7.88.0" - }, - { - "introduced": "7.87.0" - }, - { - "last_affected": "7.87.0" - }, - { - "introduced": "7.86.0" - }, - { - "last_affected": "7.86.0" - }, - { - "introduced": "7.85.0" - }, - { - "last_affected": "7.85.0" - }, - { - "introduced": "7.84.0" - }, - { - "last_affected": "7.84.0" - }, - { - "introduced": "7.83.1" - }, - { - "last_affected": "7.83.1" - }, - { - "introduced": "7.83.0" - }, - { - "last_affected": "7.83.0" - }, - { - "introduced": "7.82.0" - }, - { - "last_affected": "7.82.0" - }, - { - "introduced": "7.81.0" - }, - { - "last_affected": "7.81.0" - }, - { - "introduced": "7.80.0" - }, - { - "last_affected": "7.80.0" - }, - { - "introduced": "7.79.1" - }, - { - "last_affected": "7.79.1" - }, - { - "introduced": "7.79.0" - }, - { - "last_affected": "7.79.0" - }, - { - "introduced": "7.78.0" - }, - { - "last_affected": "7.78.0" - }, - { - "introduced": "7.77.0" - }, - { - "last_affected": "7.77.0" - }, - { - "introduced": "7.76.1" - }, - { - "last_affected": "7.76.1" - }, - { - "introduced": "7.76.0" - }, - { - "last_affected": "7.76.0" - }, - { - "introduced": "7.75.0" - }, - { - "last_affected": "7.75.0" - }, - { - "introduced": "7.74.0" - }, - { - "last_affected": "7.74.0" - }, - { - "introduced": "7.73.0" - }, - { - "last_affected": "7.73.0" - }, - { - "introduced": "7.72.0" - }, - { - "last_affected": "7.72.0" - }, - { - "introduced": "7.71.1" - }, - { - "last_affected": "7.71.1" - }, - { - "introduced": "7.71.0" - }, - { - "last_affected": "7.71.0" - }, - { - "introduced": "7.70.0" - }, - { - "last_affected": "7.70.0" - }, - { - "introduced": "7.69.1" - }, - { - "last_affected": "7.69.1" - }, - { - "introduced": "7.69.0" - }, - { - "last_affected": "7.69.0" - }, - { - "introduced": "7.68.0" - }, - { - "last_affected": "7.68.0" - }, - { - "introduced": "7.67.0" - }, - { - "last_affected": "7.67.0" - }, - { - "introduced": "7.66.0" - }, - { - "last_affected": "7.66.0" - }, - { - "introduced": "7.65.3" - }, - { - "last_affected": "7.65.3" - }, - { - "introduced": "7.65.2" - }, - { - "last_affected": "7.65.2" - }, - { - "introduced": "7.65.1" - }, - { - "last_affected": "7.65.1" - }, - { - "introduced": "7.65.0" - }, - { - "last_affected": "7.65.0" - }, - { - "introduced": "7.64.1" - }, - { - "last_affected": "7.64.1" - }, - { - "introduced": "7.64.0" - }, - { - "last_affected": "7.64.0" - }, - { - "introduced": "7.63.0" - }, - { - "last_affected": "7.63.0" - }, - { - "introduced": "7.62.0" - }, - { - "last_affected": "7.62.0" - }, - { - "introduced": "7.61.1" - }, - { - "last_affected": "7.61.1" - }, - { - "introduced": "7.61.0" - }, - { - "last_affected": "7.61.0" - }, - { - "introduced": "7.60.0" - }, - { - "last_affected": "7.60.0" - }, - { - "introduced": "7.59.0" - }, - { - "last_affected": "7.59.0" - }, - { - "introduced": "7.58.0" - }, - { - "last_affected": "7.58.0" - }, - { - "introduced": "7.57.0" - }, - { - "last_affected": "7.57.0" - }, - { - "introduced": "7.56.1" - }, - { - "last_affected": "7.56.1" - }, - { - "introduced": "7.56.0" - }, - { - "last_affected": "7.56.0" - }, - { - "introduced": "7.55.1" - }, - { - "last_affected": "7.55.1" - }, - { - "introduced": "7.55.0" - }, - { - "last_affected": "7.55.0" - }, - { - "introduced": "7.54.1" - }, - { - "last_affected": "7.54.1" - }, - { - "introduced": "7.54.0" - }, - { - "last_affected": "7.54.0" - }, - { - "introduced": "7.53.1" - }, - { - "last_affected": "7.53.1" - }, - { - "introduced": "7.53.0" - }, - { - "last_affected": "7.53.0" - }, - { - "introduced": "7.52.1" - }, - { - "last_affected": "7.52.1" - }, - { - "introduced": "7.52.0" - }, - { - "last_affected": "7.52.0" - }, - { - "introduced": "7.51.0" - }, - { - "last_affected": "7.51.0" - }, - { - "introduced": "7.50.3" - }, - { - "last_affected": "7.50.3" - }, - { - "introduced": "7.50.2" - }, - { - "last_affected": "7.50.2" - }, - { - "introduced": "7.50.1" - }, - { - "last_affected": "7.50.1" - }, - { - "introduced": "7.50.0" - }, - { - "last_affected": "7.50.0" - }, - { - "introduced": "7.49.1" - }, - { - "last_affected": "7.49.1" - }, - { - "introduced": "7.49.0" - }, - { - "last_affected": "7.49.0" - }, - { - "introduced": "7.48.0" - }, - { - "last_affected": "7.48.0" - }, - { - "introduced": "7.47.1" - }, - { - "last_affected": "7.47.1" - }, - { - "introduced": "7.47.0" - }, - { - "last_affected": "7.47.0" - }, - { - "introduced": "7.46.0" - }, - { - "last_affected": "7.46.0" - }, - { - "introduced": "7.45.0" - }, - { - "last_affected": "7.45.0" - }, - { - "introduced": "7.44.0" - }, - { - "last_affected": "7.44.0" - }, - { - "introduced": "7.43.0" - }, - { - "last_affected": "7.43.0" - }, - { - "introduced": "7.42.1" - }, - { - "last_affected": "7.42.1" - }, - { - "introduced": "7.42.0" - }, - { - "last_affected": "7.42.0" - }, - { - "introduced": "7.41.0" - }, - { - "last_affected": "7.41.0" - }, - { - "introduced": "7.40.0" - }, - { - "last_affected": "7.40.0" - }, - { - "introduced": "7.39.0" - }, - { - "last_affected": "7.39.0" - }, - { - "introduced": "7.38.0" - }, - { - "last_affected": "7.38.0" - }, - { - "introduced": "7.37.1" - }, - { - "last_affected": "7.37.1" - }, - { - "introduced": "7.37.0" - }, - { - "last_affected": "7.37.0" - }, - { - "introduced": "7.36.0" - }, - { - "last_affected": "7.36.0" - }, - { - "introduced": "7.35.0" - }, - { - "last_affected": "7.35.0" - }, - { - "introduced": "7.34.0" - }, - { - "last_affected": "7.34.0" - }, - { - "introduced": "7.33.0" - }, - { - "last_affected": "7.33.0" - }, - { - "introduced": "7.32.0" - }, - { - "last_affected": "7.32.0" - } - ], - "source": "AFFECTED_FIELD" - }, - "events": [ - { - "introduced": "5040f7e94cd01decbe7ba8fdacbf489182d503dc" - }, - { - "last_affected": "70812c2f32fc5734bcbbe572b9f61c380433ad6a" - } - ], - "repo": "https://github.com/curl/curl", - "type": "GIT" - } - ], - "versions": [ - "7.32.0", - "7.33.0", - "7.34.0", - "7.35.0", - "7.36.0", - "7.37.0", - "7.37.1", - "7.38.0", - "7.39.0", - "7.40.0", - "7.41.0", - "7.42.0", - "7.42.1", - "7.43.0", - "7.44.0", - "7.45.0", - "7.46.0", - "7.47.0", - "7.47.1", - "7.48.0", - "7.49.0", - "7.49.1", - "7.50.0", - "7.50.1", - "7.50.2", - "7.50.3", - "7.51.0", - "7.52.0", - "7.52.1", - "7.53.0", - "7.53.1", - "7.54.0", - "7.54.1", - "7.55.0", - "7.55.1", - "7.56.0", - "7.56.1", - "7.57.0", - "7.58.0", - "7.59.0", - "7.60.0", - "7.61.0", - "7.61.1", - "7.62.0", - "7.63.0", - "7.64.0", - "7.64.1", - "7.65.0", - "7.65.1", - "7.65.2", - "7.65.3", - "7.66.0", - "7.67.0", - "7.68.0", - "7.69.0", - "7.69.1", - "7.70.0", - "7.71.0", - "7.71.1", - "7.72.0", - "7.73.0", - "7.74.0", - "7.75.0", - "7.76.0", - "7.76.1", - "7.77.0", - "7.78.0", - "7.79.0", - "7.79.1", - "7.80.0", - "7.81.0", - "7.82.0", - "7.83.0", - "7.83.1", - "7.84.0", - "7.85.0", - "7.86.0", - "7.87.0", - "7.88.0", - "7.88.1", - "8.0.0", - "8.0.1", - "8.1.0", - "8.1.1", - "8.1.2", - "8.2.0", - "8.2.1", - "8.3.0", - "8.4.0", - "8.5.0", - "8.6.0", - "8.7.0", - "8.7.1", - "8.8.0", - "8.9.0" - ] - } - ], - "database_specific": { - "cna_assigner": "curl", - "osv_generated_from": "unknown" - }, - "details": "libcurl's ASN1 parser code has the `GTime2str()` function, used for parsing an\nASN.1 Generalized Time field. If given an syntactically incorrect field, the\nparser might end up using -1 for the length of the *time fraction*, leading to\na `strlen()` getting performed on a pointer to a heap buffer area that is not\n(purposely) null terminated.\n\nThis flaw most likely leads to a crash, but can also lead to heap contents\ngetting returned to the application when\n[CURLINFO_CERTINFO](https://curl.se/libcurl/c/CURLINFO_CERTINFO.html) is used.", - "id": "CVE-2024-7264", - "modified": "2025-11-03T22:32:51.400Z", - "published": "2024-07-31T08:08:14.585Z", - "references": [ - { - "type": "WEB", - "url": "http://www.openwall.com/lists/oss-security/2024/07/31/1" - }, - { - "type": "WEB", - "url": "https://curl.se/docs/CVE-2024-7264.html" - }, - { - "type": "WEB", - "url": "https://curl.se/docs/CVE-2024-7264.json" - }, - { - "type": "FIX", - "url": "https://github.com/curl/curl/commit/27959ecce75cdb2809c0bdb3286e60e08fadb519" - }, - { - "type": "WEB", - "url": "https://hackerone.com/reports/2629968" - }, - { - "type": "ADVISORY", - "url": "https://nvd.nist.gov/vuln/detail/CVE-2024-7264" - }, - { - "type": "ADVISORY", - "url": "https://security.netapp.com/advisory/ntap-20240828-0008/" - }, - { - "type": "ADVISORY", - "url": "https://security.netapp.com/advisory/ntap-20241025-0006/" - }, - { - "type": "ADVISORY", - "url": "https://security.netapp.com/advisory/ntap-20241025-0010/" - } - ], - "severity": [ - { - "score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L", - "type": "CVSS_V3" - } - ], - "summary": "ASN.1 date parser overread" -} ---- - -[TestCVE5Snapshot/CVE-2026-20912.json - 1] -{ - "affected": [ - { - "ranges": [ - { - "database_specific": { - "extracted_events": [ - { - "introduced": "0" - }, - { - "last_affected": "1.25.3" + "range": [ + { + "introduced": "0" + }, + { + "last_affected": "1.25.3" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandardRange" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -3756,19 +4395,30 @@ "database_specific": { "extracted_events": [ { - "introduced": "2.0.0" - }, - { - "fixed": "2.0.7" - }, - { - "introduced": "0" - }, - { - "fixed": "1.26.18" + "range": [ + { + "introduced": "2.0.0" + }, + { + "fixed": "2.0.7" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" + }, + { + "range": [ + { + "introduced": "0" + }, + { + "fixed": "1.26.18" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StringRangeExpression" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { @@ -3853,19 +4503,30 @@ "database_specific": { "extracted_events": [ { - "introduced": "5.6.0" - }, - { - "last_affected": "5.6.0" - }, - { - "introduced": "5.6.1" - }, - { - "last_affected": "5.6.1" + "range": [ + { + "introduced": "5.6.0" + }, + { + "last_affected": "5.6.0" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" + }, + { + "range": [ + { + "introduced": "5.6.1" + }, + { + "last_affected": "5.6.1" + } + ], + "source": "AFFECTED_FIELD", + "strategy": "StandaloneSingleVersion" } - ], - "source": "AFFECTED_FIELD" + ] }, "events": [ { diff --git a/vulnfeeds/conversion/cve5/default_extractor.go b/vulnfeeds/conversion/cve5/default_extractor.go index c038e995469..f9053465896 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -124,9 +124,15 @@ func (d *DefaultVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vuln } } + references := identifyPossibleURLs(cve) + commits, err := c.ExtractCommitsFromRefs(references, httpClient, cache) + if err != nil { + metrics.AddNotef("Failed to extract commits from references: %v", err) + } + 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/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index c7f3b3544a3..d45f63b9b09 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -50,6 +50,7 @@ func ExtractAffectedRanges(affected models.Affected, strategyList []strategies.V prevCount := len(state.Ranges()) strategy.Extract(state, metrics) if len(state.Ranges()) > prevCount { + state.SetStrategyFrom(prevCount, strategy.Name()) metrics.AddNotef("Strategy successful: %s", strategy.Name()) } } diff --git a/vulnfeeds/conversion/cve5/strategies/cpe.go b/vulnfeeds/conversion/cve5/strategies/cpe.go index 06f28cb74dd..f17f6e80f5b 100644 --- a/vulnfeeds/conversion/cve5/strategies/cpe.go +++ b/vulnfeeds/conversion/cve5/strategies/cpe.go @@ -38,6 +38,7 @@ func extractRangeFromCPEString(cpeStr string, metrics *models.ConversionMetrics) rwms := c.ToRangeWithMetadata(vr, models.VersionSourceCPE) for i := range rwms { rwms[i].Metadata.CPE = cpeStr + rwms[i].Metadata.Strategy = "CPEVersionString" rwms[i].Metadata.Versions = []string{version} } @@ -89,6 +90,11 @@ func (s *CPEVersionStrategy) Name() string { func (s *CPEVersionStrategy) Extract(cve models.CVE5, metrics *models.ConversionMetrics) ([]models.RangeWithMetadata, error) { cpeRanges, cpeStrings, err := findCPEVersionRanges(cve, metrics) if err == nil && len(cpeRanges) > 0 { + for i := range cpeRanges { + if cpeRanges[i].Metadata.Strategy == "" { + cpeRanges[i].Metadata.Strategy = s.Name() + } + } metrics.AddNotef("Strategy successful: %s", s.Name()) metrics.VersionSources = append(metrics.VersionSources, models.VersionSourceCPE) metrics.CPEs = vulns.Unique(cpeStrings) diff --git a/vulnfeeds/conversion/cve5/strategies/strategy.go b/vulnfeeds/conversion/cve5/strategies/strategy.go index 09b4bb2d6b8..fd72f8c58b5 100644 --- a/vulnfeeds/conversion/cve5/strategies/strategy.go +++ b/vulnfeeds/conversion/cve5/strategies/strategy.go @@ -111,6 +111,15 @@ func (s *ExtractionState) Ranges() []models.RangeWithMetadata { return s.ranges } +// SetStrategyFrom assigns strategyName to the Metadata.Strategy field for all ranges starting from startIdx. +func (s *ExtractionState) SetStrategyFrom(startIdx int, strategyName string) { + for i := startIdx; i < len(s.ranges); i++ { + if s.ranges[i].Metadata.Strategy == "" { + s.ranges[i].Metadata.Strategy = strategyName + } + } +} + // 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 { diff --git a/vulnfeeds/conversion/cve5/version_extraction_test.go b/vulnfeeds/conversion/cve5/version_extraction_test.go index 1d6192899c9..a947e5552b3 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -539,7 +539,6 @@ func TestExtractVersions(t *testing.T) { }, DatabaseSpecific: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("AFFECTED_FIELD"), "extracted_events": { Kind: &structpb.Value_ListValue{ ListValue: &structpb.ListValue{ @@ -548,7 +547,34 @@ func TestExtractVersions(t *testing.T) { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "introduced": structpb.NewStringValue("2.0.0"), + "source": structpb.NewStringValue("AFFECTED_FIELD"), + "strategy": structpb.NewStringValue("StringRangeExpression"), + "range": { + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "introduced": structpb.NewStringValue("2.0.0"), + }, + }, + }, + }, + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "fixed": structpb.NewStringValue("2.0.7"), + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -557,25 +583,34 @@ func TestExtractVersions(t *testing.T) { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "fixed": structpb.NewStringValue("2.0.7"), - }, - }, - }, - }, - { - Kind: &structpb.Value_StructValue{ - StructValue: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "introduced": structpb.NewStringValue("0"), - }, - }, - }, - }, - { - Kind: &structpb.Value_StructValue{ - StructValue: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "fixed": structpb.NewStringValue("1.26.18"), + "source": structpb.NewStringValue("AFFECTED_FIELD"), + "strategy": structpb.NewStringValue("StringRangeExpression"), + "range": { + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "introduced": structpb.NewStringValue("0"), + }, + }, + }, + }, + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "fixed": structpb.NewStringValue("1.26.18"), + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -603,7 +638,6 @@ func TestExtractVersions(t *testing.T) { }, DatabaseSpecific: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("AFFECTED_FIELD"), "extracted_events": { Kind: &structpb.Value_ListValue{ ListValue: &structpb.ListValue{ @@ -612,16 +646,34 @@ func TestExtractVersions(t *testing.T) { Kind: &structpb.Value_StructValue{ StructValue: &structpb.Struct{ Fields: map[string]*structpb.Value{ - "introduced": structpb.NewStringValue("0"), - }, - }, - }, - }, - { - Kind: &structpb.Value_StructValue{ - StructValue: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "fixed": structpb.NewStringValue("1.10.5"), + "source": structpb.NewStringValue("AFFECTED_FIELD"), + "strategy": structpb.NewStringValue("StringRangeExpression"), + "range": { + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "introduced": structpb.NewStringValue("0"), + }, + }, + }, + }, + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "fixed": structpb.NewStringValue("1.10.5"), + }, + }, + }, + }, + }, + }, + }, + }, }, }, }, @@ -734,6 +786,54 @@ func TestExtractVersions(t *testing.T) { {Introduced: "0b3b5fdb5a058f50248cd8547824936b8dd10351"}, {LastAffected: "a381da252fe8e873c8aff22703040426cc9b2ae0"}, }, + DatabaseSpecific: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "extracted_events": { + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "source": structpb.NewStringValue("AFFECTED_FIELD"), + "strategy": structpb.NewStringValue("StandardRange"), + "range": { + Kind: &structpb.Value_ListValue{ + ListValue: &structpb.ListValue{ + Values: []*structpb.Value{ + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "introduced": structpb.NewStringValue("0b3b5fdb5a058f50248cd8547824936b8dd10351"), + }, + }, + }, + }, + { + Kind: &structpb.Value_StructValue{ + StructValue: &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "last_affected": structpb.NewStringValue("a381da252fe8e873c8aff22703040426cc9b2ae0"), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, }}, }}, }, diff --git a/vulnfeeds/conversion/grouping.go b/vulnfeeds/conversion/grouping.go index 164fb7a007c..b62707210a2 100644 --- a/vulnfeeds/conversion/grouping.go +++ b/vulnfeeds/conversion/grouping.go @@ -6,6 +6,7 @@ import ( "slices" "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/utility" "github.com/google/osv.dev/vulnfeeds/utility/logger" "github.com/ossf/osv-schema/bindings/go/osvschema" "google.golang.org/protobuf/encoding/protojson" @@ -364,25 +365,37 @@ func MergeRangesAndCreateAffected( if len(commits) > 0 { for _, commit := range commits { if commit.Repo == repo { + events := convertCommitToEvents(commit) if mergedRange == nil { mergedRange = BuildGitVersionRange(commit.Introduced, commit.LastAffected, commit.Fixed, repo) } else { - event := convertCommitToEvent(commit) - if event != nil { - addEventToRange(mergedRange, event) + for _, e := range events { + addEventToRange(mergedRange, e) } } - if mergedRange.GetDatabaseSpecific() == nil { - mergedRange.DatabaseSpecific = &structpb.Struct{ - Fields: make(map[string]*structpb.Value), + if len(events) > 0 { + source := commit.Source + if source == "" || source == models.VersionSourceNone { + source = models.VersionSourceRefs + } + extractedEventGroup := map[string]any{ + "range": events, + "source": string(source), + } + if commit.OriginalTag != "" { + extractedEventGroup["original_tag"] = commit.OriginalTag + } + dbSpecificMap := map[string]any{ + "extracted_events": []any{extractedEventGroup}, + } + dbSpecific, err := utility.NewStructpbFromMap(dbSpecificMap) + if err == nil { + mergeDatabaseSpecific(mergedRange, dbSpecific) + } else { + metrics.AddNotef("failed to make database specific for commit: %v", err) } } - mergeDatabaseSpecific(mergedRange, &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue(string(models.VersionSourceRefs)), - }, - }) } } } @@ -404,20 +417,39 @@ func MergeRangesAndCreateAffected( for _, commit := range commits { repo := commit.Repo + events := convertCommitToEvents(commit) if vr, ok := repoToRange[repo]; !ok { vr := BuildGitVersionRange(commit.Introduced, commit.LastAffected, commit.Fixed, repo) - vr.DatabaseSpecific = &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue(string(models.VersionSourceRefs)), - }, - } repoToRange[repo] = vr repoOrder = append(repoOrder, repo) metrics.ResolvedRangesCount++ } else { - event := convertCommitToEvent(commit) - if event != nil { - addEventToRange(vr, event) + for _, e := range events { + addEventToRange(vr, e) + } + } + + if len(events) > 0 { + source := commit.Source + if source == "" || source == models.VersionSourceNone { + source = models.VersionSourceRefs + } + extractedEventGroup := map[string]any{ + "range": events, + "source": string(source), + } + if commit.OriginalTag != "" { + extractedEventGroup["original_tag"] = commit.OriginalTag + } + dbSpecificMap := map[string]any{ + "extracted_events": []any{extractedEventGroup}, + } + dbSpecific, err := utility.NewStructpbFromMap(dbSpecificMap) + if err == nil { + // mergeDatabaseSpecific handles nil DatabaseSpecific in target + mergeDatabaseSpecific(repoToRange[repo], dbSpecific) + } else { + metrics.AddNotef("failed to make database specific for commit: %v", err) } } } @@ -464,26 +496,27 @@ func addEventToRange(versionRange *osvschema.Range, event *osvschema.Event) { } } -// convertCommitToEvent creates an OSV Event from an AffectedCommit. -// It returns an event with the Introduced, Fixed, or LastAffected value from the commit. -func convertCommitToEvent(commit models.AffectedCommit) *osvschema.Event { +// convertCommitToEvents creates OSV Events from an AffectedCommit. +// It returns a slice of events with the Introduced, Fixed, or LastAffected values from the commit. +func convertCommitToEvents(commit models.AffectedCommit) []*osvschema.Event { + var events []*osvschema.Event if commit.Introduced != "" { - return &osvschema.Event{ + events = append(events, &osvschema.Event{ Introduced: commit.Introduced, - } + }) } if commit.Fixed != "" { - return &osvschema.Event{ + events = append(events, &osvschema.Event{ Fixed: commit.Fixed, - } + }) } if commit.LastAffected != "" { - return &osvschema.Event{ + events = append(events, &osvschema.Event{ LastAffected: commit.LastAffected, - } + }) } - return nil + return events } func isStandaloneRange(vrwm models.RangeWithMetadata) bool { diff --git a/vulnfeeds/conversion/grouping_test.go b/vulnfeeds/conversion/grouping_test.go index 4c5d9f78a63..4e58a6b3b54 100644 --- a/vulnfeeds/conversion/grouping_test.go +++ b/vulnfeeds/conversion/grouping_test.go @@ -5,6 +5,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/osv.dev/vulnfeeds/models" + "github.com/google/osv.dev/vulnfeeds/utility" "github.com/ossf/osv-schema/bindings/go/osvschema" "google.golang.org/protobuf/testing/protocmp" "google.golang.org/protobuf/types/known/structpb" @@ -438,6 +439,15 @@ func TestGroupAffectedRanges(t *testing.T) { } func TestMergeRangesAndCreateAffected(t *testing.T) { + mustMakeDBSpecific := func(m map[string]any) *structpb.Struct { + ds, err := utility.NewStructpbFromMap(m) + if err != nil { + t.Fatalf("failed to make database specific: %v", err) + } + + return ds + } + tests := []struct { name string resolvedRanges []*osvschema.Range @@ -479,11 +489,18 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { {Fixed: "1.0"}, {Fixed: "1.2"}, }, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Introduced: "1.1"}}, + "source": "REFERENCES", + }, + map[string]any{ + "range": []*osvschema.Event{{Fixed: "1.2"}}, + "source": "REFERENCES", + }, }, - }, + }), }, }, }, @@ -508,11 +525,14 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { }, Repo: "repo2", Type: osvschema.Range_GIT, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Introduced: "0"}, {Fixed: "1.0"}}, + "source": "REFERENCES", + }, }, - }, + }), }, }, }, @@ -543,11 +563,14 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { }, Repo: "repo_a", Type: osvschema.Range_GIT, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Introduced: "0.5"}}, + "source": "REFERENCES", + }, }, - }, + }), }, { Events: []*osvschema.Event{ @@ -556,11 +579,18 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { }, Repo: "repo_b", Type: osvschema.Range_GIT, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Introduced: "0"}}, + "source": "REFERENCES", + }, + map[string]any{ + "range": []*osvschema.Event{{Fixed: "1.0"}}, + "source": "REFERENCES", + }, }, - }, + }), }, }, }, @@ -605,11 +635,22 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { {Fixed: "1.0"}, {LastAffected: "0.5"}, }, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Fixed: "1.0"}}, + "source": "REFERENCES", + }, + map[string]any{ + "range": []*osvschema.Event{{Introduced: "0"}}, + "source": "REFERENCES", + }, + map[string]any{ + "range": []*osvschema.Event{{LastAffected: "0.5"}}, + "source": "REFERENCES", + }, }, - }, + }), }, }, }, @@ -683,11 +724,14 @@ func TestMergeRangesAndCreateAffected(t *testing.T) { {Fixed: "3.0"}, {Fixed: "4.0"}, }, - DatabaseSpecific: &structpb.Struct{ - Fields: map[string]*structpb.Value{ - "source": structpb.NewStringValue("REFERENCES"), + DatabaseSpecific: mustMakeDBSpecific(map[string]any{ + "extracted_events": []any{ + map[string]any{ + "range": []*osvschema.Event{{Fixed: "4.0"}}, + "source": "REFERENCES", + }, }, - }, + }), }, }, }, diff --git a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap index a4cbbb87f32..f20b386a41d 100755 --- a/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap +++ b/vulnfeeds/conversion/nvd/__snapshots__/converter_test.snap @@ -1757,18 +1757,28 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:gitea:gitea:*:*:*:*:*:-:*:*", "extracted_events": [ { - "introduced": "0" - }, - { - "fixed": "1.25.4" + "cpe": "cpe:2.3:a:gitea:gitea:*:*:*:*:*:-:*:*", + "range": [ + { + "introduced": "0" + }, + { + "fixed": "1.25.4" + } + ], + "source": "CPE_RANGE" + }, + { + "original_tag": "v1.25.4", + "range": [ + { + "fixed": "369830bada2fd8826a5135cb2fc66660a9bef708" + } + ], + "source": "REFERENCES_TAG" } - ], - "source": [ - "CPE_RANGE", - "REFERENCES" ] }, "events": [ @@ -1831,30 +1841,52 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:tokio:tokio:*:*:*:*:*:rust:*:*", "extracted_events": [ { - "introduced": "1.7.0" - }, - { - "fixed": "1.18.4" - }, - { - "introduced": "1.19.0" - }, - { - "fixed": "1.20.3" - }, - { - "introduced": "1.21.0" - }, - { - "fixed": "1.23.1" + "cpe": "cpe:2.3:a:tokio:tokio:*:*:*:*:*:rust:*:*", + "range": [ + { + "introduced": "1.7.0" + }, + { + "fixed": "1.18.4" + } + ], + "source": "CPE_RANGE" + }, + { + "cpe": "cpe:2.3:a:tokio:tokio:*:*:*:*:*:rust:*:*", + "range": [ + { + "introduced": "1.19.0" + }, + { + "fixed": "1.20.3" + } + ], + "source": "CPE_RANGE" + }, + { + "cpe": "cpe:2.3:a:tokio:tokio:*:*:*:*:*:rust:*:*", + "range": [ + { + "introduced": "1.21.0" + }, + { + "fixed": "1.23.1" + } + ], + "source": "CPE_RANGE" + }, + { + "original_tag": "tokio-1.23.1", + "range": [ + { + "fixed": "1a997ffbd62334af2553775234e75ede2d7d949f" + } + ], + "source": "REFERENCES_TAG" } - ], - "source": [ - "CPE_RANGE", - "REFERENCES" ] }, "events": [ @@ -1925,7 +1957,16 @@ "ranges": [ { "database_specific": { - "source": "REFERENCES" + "extracted_events": [ + { + "range": [ + { + "fixed": "2c1762b85acb84467ed5e799afe1499cd2f912e6" + } + ], + "source": "REFERENCES_COMMIT" + } + ] }, "events": [ { @@ -1975,30 +2016,51 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:google:protobuf-python:*:*:*:*:*:*:*:*", "extracted_events": [ { - "introduced": "0" - }, - { - "fixed": "4.25.8" - }, - { - "introduced": "5.26.0" - }, - { - "fixed": "5.29.5" - }, - { - "introduced": "6.30.0" - }, - { - "fixed": "6.31.1" + "cpe": "cpe:2.3:a:google:protobuf-python:*:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "0" + }, + { + "fixed": "4.25.8" + } + ], + "source": "CPE_RANGE" + }, + { + "cpe": "cpe:2.3:a:google:protobuf-python:*:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "5.26.0" + }, + { + "fixed": "5.29.5" + } + ], + "source": "CPE_RANGE" + }, + { + "cpe": "cpe:2.3:a:google:protobuf-python:*:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "6.30.0" + }, + { + "fixed": "6.31.1" + } + ], + "source": "CPE_RANGE" + }, + { + "range": [ + { + "fixed": "17838beda2943d08b8a9d4df5b68f5f04f26d901" + } + ], + "source": "REFERENCES_COMMIT" } - ], - "source": [ - "CPE_RANGE", - "REFERENCES" ] }, "events": [ @@ -2057,16 +2119,20 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:haxx:libcurl:*:*:*:*:*:*:*:*", "extracted_events": [ { - "introduced": "0" - }, - { - "fixed": "7.61.1" + "cpe": "cpe:2.3:a:haxx:libcurl:*:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "0" + }, + { + "fixed": "7.61.1" + } + ], + "source": "CPE_RANGE" } - ], - "source": "CPE_RANGE" + ] }, "events": [ { @@ -2093,31 +2159,35 @@ ], "extracted_events": [ { - "introduced": "12.04" - }, - { - "last_affected": "12.04" - }, - { - "introduced": "14.04" - }, - { - "last_affected": "14.04" - }, - { - "introduced": "16.04" - }, - { - "last_affected": "16.04" - }, - { - "introduced": "18.04" - }, - { - "last_affected": "18.04" + "range": [ + { + "introduced": "12.04" + }, + { + "last_affected": "12.04" + }, + { + "introduced": "14.04" + }, + { + "last_affected": "14.04" + }, + { + "introduced": "16.04" + }, + { + "last_affected": "16.04" + }, + { + "introduced": "18.04" + }, + { + "last_affected": "18.04" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "canonical:ubuntu_linux" }, { @@ -2126,13 +2196,17 @@ ], "extracted_events": [ { - "introduced": "9.0" - }, - { - "last_affected": "9.0" + "range": [ + { + "introduced": "9.0" + }, + { + "last_affected": "9.0" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "debian:debian_linux" }, { @@ -2145,37 +2219,41 @@ ], "extracted_events": [ { - "introduced": "6.0" - }, - { - "last_affected": "6.0" - }, - { - "introduced": "7.0" - }, - { - "last_affected": "7.0" - }, - { - "introduced": "7.4" - }, - { - "last_affected": "7.4" - }, - { - "introduced": "7.5" - }, - { - "last_affected": "7.5" - }, - { - "introduced": "7.6" - }, - { - "last_affected": "7.6" + "range": [ + { + "introduced": "6.0" + }, + { + "last_affected": "6.0" + }, + { + "introduced": "7.0" + }, + { + "last_affected": "7.0" + }, + { + "introduced": "7.4" + }, + { + "last_affected": "7.4" + }, + { + "introduced": "7.5" + }, + { + "last_affected": "7.5" + }, + { + "introduced": "7.6" + }, + { + "last_affected": "7.6" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "redhat:enterprise_linux" } ] @@ -2251,25 +2329,29 @@ ], "extracted_events": [ { - "introduced": "36" - }, - { - "last_affected": "36" - }, - { - "introduced": "37" - }, - { - "last_affected": "37" - }, - { - "introduced": "38" - }, - { - "last_affected": "38" + "range": [ + { + "introduced": "36" + }, + { + "last_affected": "36" + }, + { + "introduced": "37" + }, + { + "last_affected": "37" + }, + { + "introduced": "38" + }, + { + "last_affected": "38" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "fedoraproject:fedora" }, { @@ -2281,31 +2363,35 @@ ], "extracted_events": [ { - "introduced": "11.5" - }, - { - "last_affected": "11.5" - }, - { - "introduced": "11.6" - }, - { - "last_affected": "11.6" - }, - { - "introduced": "12.0" - }, - { - "last_affected": "12.0" - }, - { - "introduced": "12.1" - }, - { - "last_affected": "12.1" + "range": [ + { + "introduced": "11.5" + }, + { + "last_affected": "11.5" + }, + { + "introduced": "11.6" + }, + { + "last_affected": "11.6" + }, + { + "introduced": "12.0" + }, + { + "last_affected": "12.0" + }, + { + "introduced": "12.1" + }, + { + "last_affected": "12.1" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "redhat:directory_server" } ] @@ -2429,18 +2515,27 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:harfbuzz_project:harfbuzz:4.3.0:*:*:*:*:*:*:*", "extracted_events": [ { - "introduced": "4.3.0" - }, - { - "last_affected": "4.3.0" + "cpe": "cpe:2.3:a:harfbuzz_project:harfbuzz:4.3.0:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "4.3.0" + }, + { + "last_affected": "4.3.0" + } + ], + "source": "CPE_STRING" + }, + { + "range": [ + { + "fixed": "62e803b36173fd096d7ad460dd1d1db9be542593" + } + ], + "source": "REFERENCES_COMMIT" } - ], - "source": [ - "CPE_STRING", - "REFERENCES" ] }, "events": [ @@ -2472,19 +2567,23 @@ ], "extracted_events": [ { - "introduced": "35" - }, - { - "last_affected": "35" - }, - { - "introduced": "36" - }, - { - "last_affected": "36" + "range": [ + { + "introduced": "35" + }, + { + "last_affected": "35" + }, + { + "introduced": "36" + }, + { + "last_affected": "36" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "fedoraproject:fedora" } ] @@ -2535,585 +2634,992 @@ "ranges": [ { "database_specific": { - "cpe": [ - "cpe:2.3:a:ffmpeg:ffmpeg:2.0:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.0.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.1.8:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.8:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.9:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.10:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.11:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.12:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.13:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.14:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.15:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.2.16:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.3.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.8:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.9:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.10:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.11:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.4.12:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.8:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.5.9:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.5:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.6.6:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.7:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.7.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.7.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.7.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.7.4:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8:dev:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8.1:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8.2:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8.3:*:*:*:*:*:*:*", - "cpe:2.3:a:ffmpeg:ffmpeg:2.8.4:*:*:*:*:*:*:*" - ], "extracted_events": [ { - "introduced": "2.0" - }, - { - "last_affected": "2.0" - }, - { - "introduced": "2.0.1" - }, - { - "last_affected": "2.0.1" - }, - { - "introduced": "2.0.2" - }, - { - "last_affected": "2.0.2" - }, - { - "introduced": "2.0.3" - }, - { - "last_affected": "2.0.3" - }, - { - "introduced": "2.0.4" - }, - { - "last_affected": "2.0.4" - }, - { - "introduced": "2.0.5" - }, - { - "last_affected": "2.0.5" - }, - { - "introduced": "2.0.6" - }, - { - "last_affected": "2.0.6" - }, - { - "introduced": "2.0.7" - }, - { - "last_affected": "2.0.7" - }, - { - "introduced": "2.1" - }, - { - "last_affected": "2.1" - }, - { - "introduced": "2.1.1" - }, - { - "last_affected": "2.1.1" - }, - { - "introduced": "2.1.2" - }, - { - "last_affected": "2.1.2" - }, - { - "introduced": "2.1.3" - }, - { - "last_affected": "2.1.3" - }, - { - "introduced": "2.1.4" - }, - { - "last_affected": "2.1.4" - }, - { - "introduced": "2.1.5" - }, - { - "last_affected": "2.1.5" - }, - { - "introduced": "2.1.6" - }, - { - "last_affected": "2.1.6" - }, - { - "introduced": "2.1.7" - }, - { - "last_affected": "2.1.7" - }, - { - "introduced": "2.1.8" - }, - { - "last_affected": "2.1.8" - }, - { - "introduced": "2.2" - }, - { - "last_affected": "2.2" - }, - { - "introduced": "2.2.1" - }, - { - "last_affected": "2.2.1" - }, - { - "introduced": "2.2.2" - }, - { - "last_affected": "2.2.2" - }, - { - "introduced": "2.2.3" - }, - { - "last_affected": "2.2.3" - }, - { - "introduced": "2.2.4" - }, - { - "last_affected": "2.2.4" - }, - { - "introduced": "2.2.5" - }, - { - "last_affected": "2.2.5" - }, - { - "introduced": "2.2.6" - }, - { - "last_affected": "2.2.6" - }, - { - "introduced": "2.2.7" - }, - { - "last_affected": "2.2.7" - }, - { - "introduced": "2.2.8" - }, - { - "last_affected": "2.2.8" - }, - { - "introduced": "2.2.9" - }, - { - "last_affected": "2.2.9" - }, - { - "introduced": "2.2.10" - }, - { - "last_affected": "2.2.10" - }, - { - "introduced": "2.2.11" - }, - { - "last_affected": "2.2.11" - }, - { - "introduced": "2.2.12" - }, - { - "last_affected": "2.2.12" - }, - { - "introduced": "2.2.13" - }, - { - "last_affected": "2.2.13" - }, - { - "introduced": "2.2.14" - }, - { - "last_affected": "2.2.14" - }, - { - "introduced": "2.2.15" - }, - { - "last_affected": "2.2.15" - }, - { - "introduced": "2.2.16" - }, - { - "last_affected": "2.2.16" - }, - { - "introduced": "2.3" - }, - { - "last_affected": "2.3" - }, - { - "introduced": "2.3.1" - }, - { - "last_affected": "2.3.1" - }, - { - "introduced": "2.3.2" - }, - { - "last_affected": "2.3.2" - }, - { - "introduced": "2.3.3" - }, - { - "last_affected": "2.3.3" - }, - { - "introduced": "2.3.4" - }, - { - "last_affected": "2.3.4" - }, - { - "introduced": "2.3.5" - }, - { - "last_affected": "2.3.5" - }, - { - "introduced": "2.3.6" - }, - { - "last_affected": "2.3.6" - }, - { - "introduced": "2.4" - }, - { - "last_affected": "2.4" - }, - { - "introduced": "2.4.1" - }, - { - "last_affected": "2.4.1" - }, - { - "introduced": "2.4.2" - }, - { - "last_affected": "2.4.2" - }, - { - "introduced": "2.4.3" - }, - { - "last_affected": "2.4.3" - }, - { - "introduced": "2.4.4" - }, - { - "last_affected": "2.4.4" - }, - { - "introduced": "2.4.5" - }, - { - "last_affected": "2.4.5" - }, - { - "introduced": "2.4.6" - }, - { - "last_affected": "2.4.6" - }, - { - "introduced": "2.4.7" - }, - { - "last_affected": "2.4.7" - }, - { - "introduced": "2.4.8" - }, - { - "last_affected": "2.4.8" - }, - { - "introduced": "2.4.9" - }, - { - "last_affected": "2.4.9" - }, - { - "introduced": "2.4.10" - }, - { - "last_affected": "2.4.10" - }, - { - "introduced": "2.4.11" - }, - { - "last_affected": "2.4.11" - }, - { - "introduced": "2.4.12" - }, - { - "last_affected": "2.4.12" - }, - { - "introduced": "2.5" - }, - { - "last_affected": "2.5" - }, - { - "introduced": "2.5.1" - }, - { - "last_affected": "2.5.1" - }, - { - "introduced": "2.5.2" - }, - { - "last_affected": "2.5.2" - }, - { - "introduced": "2.5.3" - }, - { - "last_affected": "2.5.3" - }, - { - "introduced": "2.5.4" - }, - { - "last_affected": "2.5.4" - }, - { - "introduced": "2.5.5" - }, - { - "last_affected": "2.5.5" - }, - { - "introduced": "2.5.6" - }, - { - "last_affected": "2.5.6" - }, - { - "introduced": "2.5.7" - }, - { - "last_affected": "2.5.7" - }, - { - "introduced": "2.5.8" - }, - { - "last_affected": "2.5.8" - }, - { - "introduced": "2.5.9" - }, - { - "last_affected": "2.5.9" - }, - { - "introduced": "2.6" - }, - { - "last_affected": "2.6" - }, - { - "introduced": "2.6.1" - }, - { - "last_affected": "2.6.1" - }, - { - "introduced": "2.6.2" - }, - { - "last_affected": "2.6.2" - }, - { - "introduced": "2.6.3" - }, - { - "last_affected": "2.6.3" - }, - { - "introduced": "2.6.4" - }, - { - "last_affected": "2.6.4" - }, - { - "introduced": "2.6.5" - }, - { - "last_affected": "2.6.5" - }, - { - "introduced": "2.6.6" - }, - { - "last_affected": "2.6.6" - }, - { - "introduced": "2.7" - }, - { - "last_affected": "2.7" - }, - { - "introduced": "2.7.1" - }, - { - "last_affected": "2.7.1" - }, - { - "introduced": "2.7.2" - }, - { - "last_affected": "2.7.2" - }, - { - "introduced": "2.7.3" - }, - { - "last_affected": "2.7.3" - }, - { - "introduced": "2.7.4" - }, - { - "last_affected": "2.7.4" - }, - { - "introduced": "2.8" - }, - { - "last_affected": "2.8" - }, - { - "introduced": "2.8-dev" - }, - { - "last_affected": "2.8-dev" - }, - { - "introduced": "2.8.1" - }, - { - "last_affected": "2.8.1" - }, - { - "introduced": "2.8.2" - }, - { - "last_affected": "2.8.2" - }, - { - "introduced": "2.8.3" - }, - { - "last_affected": "2.8.3" - }, - { - "introduced": "2.8.4" - }, - { - "last_affected": "2.8.4" + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0" + }, + { + "last_affected": "2.0" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.1" + }, + { + "last_affected": "2.0.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.2" + }, + { + "last_affected": "2.0.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.3" + }, + { + "last_affected": "2.0.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.4" + }, + { + "last_affected": "2.0.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.5" + }, + { + "last_affected": "2.0.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.6" + }, + { + "last_affected": "2.0.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.0.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.0.7" + }, + { + "last_affected": "2.0.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1" + }, + { + "last_affected": "2.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.1" + }, + { + "last_affected": "2.1.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.2" + }, + { + "last_affected": "2.1.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.3" + }, + { + "last_affected": "2.1.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.4" + }, + { + "last_affected": "2.1.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.5" + }, + { + "last_affected": "2.1.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.6" + }, + { + "last_affected": "2.1.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.7" + }, + { + "last_affected": "2.1.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.1.8:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.1.8" + }, + { + "last_affected": "2.1.8" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2" + }, + { + "last_affected": "2.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.1" + }, + { + "last_affected": "2.2.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.2" + }, + { + "last_affected": "2.2.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.3" + }, + { + "last_affected": "2.2.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.4" + }, + { + "last_affected": "2.2.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.5" + }, + { + "last_affected": "2.2.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.6" + }, + { + "last_affected": "2.2.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.7" + }, + { + "last_affected": "2.2.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.8:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.8" + }, + { + "last_affected": "2.2.8" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.9:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.9" + }, + { + "last_affected": "2.2.9" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.10:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.10" + }, + { + "last_affected": "2.2.10" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.11:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.11" + }, + { + "last_affected": "2.2.11" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.12:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.12" + }, + { + "last_affected": "2.2.12" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.13:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.13" + }, + { + "last_affected": "2.2.13" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.14:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.14" + }, + { + "last_affected": "2.2.14" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.15:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.15" + }, + { + "last_affected": "2.2.15" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.2.16:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.2.16" + }, + { + "last_affected": "2.2.16" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3" + }, + { + "last_affected": "2.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.1" + }, + { + "last_affected": "2.3.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.2" + }, + { + "last_affected": "2.3.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.3" + }, + { + "last_affected": "2.3.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.4" + }, + { + "last_affected": "2.3.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.5" + }, + { + "last_affected": "2.3.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.3.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.3.6" + }, + { + "last_affected": "2.3.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4" + }, + { + "last_affected": "2.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.1" + }, + { + "last_affected": "2.4.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.2" + }, + { + "last_affected": "2.4.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.3" + }, + { + "last_affected": "2.4.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.4" + }, + { + "last_affected": "2.4.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.5" + }, + { + "last_affected": "2.4.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.6" + }, + { + "last_affected": "2.4.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.7" + }, + { + "last_affected": "2.4.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.8:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.8" + }, + { + "last_affected": "2.4.8" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.9:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.9" + }, + { + "last_affected": "2.4.9" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.10:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.10" + }, + { + "last_affected": "2.4.10" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.11:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.11" + }, + { + "last_affected": "2.4.11" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.4.12:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.4.12" + }, + { + "last_affected": "2.4.12" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5" + }, + { + "last_affected": "2.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.1" + }, + { + "last_affected": "2.5.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.2" + }, + { + "last_affected": "2.5.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.3" + }, + { + "last_affected": "2.5.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.4" + }, + { + "last_affected": "2.5.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.5" + }, + { + "last_affected": "2.5.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.6" + }, + { + "last_affected": "2.5.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.7" + }, + { + "last_affected": "2.5.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.8:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.8" + }, + { + "last_affected": "2.5.8" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.5.9:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.5.9" + }, + { + "last_affected": "2.5.9" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6" + }, + { + "last_affected": "2.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.1" + }, + { + "last_affected": "2.6.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.2" + }, + { + "last_affected": "2.6.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.3" + }, + { + "last_affected": "2.6.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.4" + }, + { + "last_affected": "2.6.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.5:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.5" + }, + { + "last_affected": "2.6.5" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.6.6:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.6.6" + }, + { + "last_affected": "2.6.6" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.7:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.7" + }, + { + "last_affected": "2.7" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.7.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.7.1" + }, + { + "last_affected": "2.7.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.7.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.7.2" + }, + { + "last_affected": "2.7.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.7.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.7.3" + }, + { + "last_affected": "2.7.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.7.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.7.4" + }, + { + "last_affected": "2.7.4" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8" + }, + { + "last_affected": "2.8" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8:dev:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8-dev" + }, + { + "last_affected": "2.8-dev" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8.1:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8.1" + }, + { + "last_affected": "2.8.1" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8.2:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8.2" + }, + { + "last_affected": "2.8.2" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8.3:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8.3" + }, + { + "last_affected": "2.8.3" + } + ], + "source": "CPE_STRING" + }, + { + "cpe": "cpe:2.3:a:ffmpeg:ffmpeg:2.8.4:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "2.8.4" + }, + { + "last_affected": "2.8.4" + } + ], + "source": "CPE_STRING" } - ], - "source": "CPE_STRING" + ] }, "events": [ { @@ -3220,13 +3726,17 @@ ], "extracted_events": [ { - "introduced": "12.04" - }, - { - "last_affected": "12.04" + "range": [ + { + "introduced": "12.04" + }, + { + "last_affected": "12.04" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "canonical:ubuntu_linux" }, { @@ -3235,13 +3745,17 @@ ], "extracted_events": [ { - "introduced": "42.1" - }, - { - "last_affected": "42.1" + "range": [ + { + "introduced": "42.1" + }, + { + "last_affected": "42.1" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "opensuse:leap" } ] @@ -3386,16 +3900,20 @@ "ranges": [ { "database_specific": { - "cpe": "cpe:2.3:a:libdwarf_project:libdwarf:*:*:*:*:*:*:*:*", "extracted_events": [ { - "introduced": "0.1.0" - }, - { - "fixed": "0.9.2" + "cpe": "cpe:2.3:a:libdwarf_project:libdwarf:*:*:*:*:*:*:*:*", + "range": [ + { + "introduced": "0.1.0" + }, + { + "fixed": "0.9.2" + } + ], + "source": "CPE_RANGE" } - ], - "source": "CPE_RANGE" + ] }, "events": [ { @@ -3419,13 +3937,17 @@ ], "extracted_events": [ { - "introduced": "40" - }, - { - "last_affected": "40" + "range": [ + { + "introduced": "40" + }, + { + "last_affected": "40" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "fedoraproject:fedora" }, { @@ -3435,19 +3957,23 @@ ], "extracted_events": [ { - "introduced": "7.0" - }, - { - "last_affected": "7.0" - }, - { - "introduced": "8.0" - }, - { - "last_affected": "8.0" + "range": [ + { + "introduced": "7.0" + }, + { + "last_affected": "7.0" + }, + { + "introduced": "8.0" + }, + { + "last_affected": "8.0" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "redhat:enterprise_linux" } ] @@ -3496,10 +4022,14 @@ ], "extracted_events": [ { - "fixed": "3.67.0" + "range": [ + { + "fixed": "3.67.0" + } + ], + "source": "CPE_RANGE" } ], - "source": "CPE_RANGE", "vendor_product": "filezilla-project:filezilla_client" }, { @@ -3508,13 +4038,17 @@ ], "extracted_events": [ { - "introduced": "0.68" - }, - { - "fixed": "0.81" + "range": [ + { + "introduced": "0.68" + }, + { + "fixed": "0.81" + } + ], + "source": "CPE_RANGE" } ], - "source": "CPE_RANGE", "vendor_product": "putty:putty" }, { @@ -3523,10 +4057,14 @@ ], "extracted_events": [ { - "fixed": "1.14.6" + "range": [ + { + "fixed": "1.14.6" + } + ], + "source": "CPE_RANGE" } ], - "source": "CPE_RANGE", "vendor_product": "tigris:tortoisesvn" }, { @@ -3535,10 +4073,14 @@ ], "extracted_events": [ { - "fixed": "2.15.0.1" + "range": [ + { + "fixed": "2.15.0.1" + } + ], + "source": "CPE_RANGE" } ], - "source": "CPE_RANGE", "vendor_product": "tortoisegit:tortoisegit" }, { @@ -3547,10 +4089,14 @@ ], "extracted_events": [ { - "fixed": "6.3.3" + "range": [ + { + "fixed": "6.3.3" + } + ], + "source": "CPE_RANGE" } ], - "source": "CPE_RANGE", "vendor_product": "winscp:winscp" }, { @@ -3561,52 +4107,60 @@ ], "extracted_events": [ { - "introduced": "38" - }, - { - "last_affected": "38" - }, - { - "introduced": "39" - }, - { - "last_affected": "39" - }, - { - "introduced": "40" - }, - { - "last_affected": "40" + "range": [ + { + "introduced": "38" + }, + { + "last_affected": "38" + }, + { + "introduced": "39" + }, + { + "last_affected": "39" + }, + { + "introduced": "40" + }, + { + "last_affected": "40" + } + ], + "source": "CPE_STRING" } ], - "source": "CPE_STRING", "vendor_product": "fedoraproject:fedora" }, { "extracted_events": [ { - "introduced": "0.68" - }, - { - "fixed": "0.80" - }, - { - "fixed": "0.81" - }, - { - "fixed": "3.67.0" - }, - { - "fixed": "6.3.3" - }, - { - "fixed": "2.15.0.1" - }, - { - "fixed": "1.14.6" + "range": [ + { + "introduced": "0.68" + }, + { + "fixed": "0.80" + }, + { + "fixed": "0.81" + }, + { + "fixed": "3.67.0" + }, + { + "fixed": "6.3.3" + }, + { + "fixed": "2.15.0.1" + }, + { + "fixed": "1.14.6" + } + ], + "source": "DESCRIPTION" } - ], - "source": "DESCRIPTION" + ] } ] }, diff --git a/vulnfeeds/conversion/versions.go b/vulnfeeds/conversion/versions.go index 3dcb099a7c1..ffcb2a75ba2 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -423,10 +423,10 @@ func repo(u string) (string, error) { } // Returns the commit ID from supported links. -func Commit(u string, httpClient *http.Client) (string, error) { +func Commit(u string, httpClient *http.Client) (string, string, models.VersionSource, error) { parsedURL, err := url.Parse(u) if err != nil { - return "", err + return "", "", models.VersionSourceNone, err } gitSHA1Regex := regexp.MustCompile("^[0-9a-f]{7,40}") @@ -439,14 +439,14 @@ func Commit(u string, httpClient *http.Client) (string, error) { if strings.HasPrefix(parsedURL.Path, "/cgit") && strings.HasSuffix(parsedURL.Path, "commit/") && strings.HasPrefix(parsedURL.RawQuery, "id=") { - return strings.Split(parsedURL.RawQuery, "=")[1], nil + return strings.Split(parsedURL.RawQuery, "=")[1], "", models.VersionSourceRefsCommit, nil } // Canonicalized git.kernel.org URLs lose /cgit in the path... if parsedURL.Hostname() == "git.kernel.org" && strings.HasSuffix(parsedURL.Path, "commit/") && strings.HasPrefix(parsedURL.RawQuery, "id=") { - return strings.Split(parsedURL.RawQuery, "=")[1], nil + return strings.Split(parsedURL.RawQuery, "=")[1], "", models.VersionSourceRefsCommit, nil } // GitWeb cgi-bin URLs are structured another way, e.g. @@ -459,14 +459,14 @@ func Commit(u string, httpClient *http.Client) (string, error) { continue } - return strings.Split(param, "=")[1], nil + return strings.Split(param, "=")[1], "", models.VersionSourceRefsCommit, nil } } // FFMpeg's GitWeb seems to be it's own unique snowflake, e.g. // https://git.ffmpeg.org/gitweb/ffmpeg.git/commit/c94875471e3ba3dc396c6919ff3ec9b14539cd71 if strings.HasPrefix(parsedURL.Path, "/gitweb/") && len(strings.Split(parsedURL.Path, "/")) == 5 { - return strings.Split(parsedURL.Path, "/")[4], nil + return strings.Split(parsedURL.Path, "/")[4], "", models.VersionSourceRefsCommit, nil } // GitHub and GitLab commit URLs are structured one way, e.g. @@ -477,7 +477,7 @@ func Commit(u string, httpClient *http.Client) (string, error) { parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/") directory, possibleCommitHash := path.Split(parsedURL.Path) if strings.HasSuffix(directory, "commit/") && gitSHA1Regex.MatchString(possibleCommitHash) { - return strings.TrimSuffix(possibleCommitHash, ".patch"), nil + return strings.TrimSuffix(possibleCommitHash, ".patch"), "", models.VersionSourceRefsCommit, nil } // and Bitbucket.org commit URLs are similar yet slightly different: @@ -489,7 +489,7 @@ func Commit(u string, httpClient *http.Client) (string, error) { parsedURL.Path = strings.TrimSuffix(parsedURL.Path, "/") directory, possibleCommitHash := path.Split(parsedURL.Path) if strings.HasSuffix(directory, "commits/") && gitSHA1Regex.MatchString(possibleCommitHash) { - return possibleCommitHash, nil + return possibleCommitHash, "", models.VersionSourceRefsCommit, nil } } @@ -498,42 +498,43 @@ func Commit(u string, httpClient *http.Client) (string, error) { // Support for resolving a Github tag to a commit hash // example: https://github.com/redis/redis/releases/tag/6.2.17 if parsedURL.Host == "github.com" { - possibleCommitHash, err := resolveGitTag(parsedURL, u, gitSHA1Regex, httpClient) + possibleCommitHash, originalTag, err := resolveGitTag(parsedURL, u, gitSHA1Regex, httpClient) if possibleCommitHash != "" && err == nil { - return possibleCommitHash, nil + return possibleCommitHash, originalTag, models.VersionSourceRefsTag, nil } } // If we get to here, we've encountered an unsupported URL. - return "", fmt.Errorf("Commit(): unsupported URL: %s", u) + return "", "", models.VersionSourceNone, fmt.Errorf("Commit(): unsupported URL: %s", u) } -func resolveGitTag(parsedURL *url.URL, u string, gitSHA1Regex *regexp.Regexp, httpClient *http.Client) (string, error) { +func resolveGitTag(parsedURL *url.URL, u string, gitSHA1Regex *regexp.Regexp, httpClient *http.Client) (string, string, error) { directory, tag := path.Split(parsedURL.Path) if !strings.HasSuffix(directory, "tag/") { - return "", errors.New("no tag found") + return "", "", errors.New("no tag found") } + originalTag := tag tag, err := git.NormalizeVersion(tag) if err != nil { - return "", err + return "", "", err } maybeRepoURL, err := Repo(u) if err != nil { - return "", err + return "", "", err } normalizedTags, err := git.NormalizeRepoTags(maybeRepoURL, nil, httpClient) if err != nil { - return "", err + return "", "", err } for t, nTag := range normalizedTags { if tag == t && gitSHA1Regex.MatchString(nTag.Commit) { - return nTag.Commit, nil + return nTag.Commit, originalTag, nil } } - return "", errors.New("no tag found") + return "", "", errors.New("no tag found") } // For URLs referencing commits in supported Git repository hosts, return a cloneable AffectedCommit. @@ -560,32 +561,34 @@ func ExtractCommitsFromRefs(references []models.Reference, httpClient *http.Clie // For URLs referencing commits in supported Git repository hosts, return a cloneable AffectedCommit. func extractGitAffectedCommit(link string, commitType models.CommitType, httpClient *http.Client, cache git.RepoTagsCache) (models.AffectedCommit, error) { var ac models.AffectedCommit - c, r, err := ExtractGitCommit(link, httpClient, 0, cache) + c, r, tag, source, err := ExtractGitCommit(link, httpClient, 0, cache) if err != nil { return ac, err } ac.SetRepo(r) + ac.Source = source + ac.OriginalTag = tag models.SetCommitByType(&ac, commitType, c) return ac, nil } -func ExtractGitCommit(link string, httpClient *http.Client, depth int, cache git.RepoTagsCache) (string, string, error) { +func ExtractGitCommit(link string, httpClient *http.Client, depth int, cache git.RepoTagsCache) (string, string, string, models.VersionSource, error) { if depth > 10 { - return "", "", fmt.Errorf("max recursion depth exceeded for %s", link) + return "", "", "", models.VersionSourceNone, fmt.Errorf("max recursion depth exceeded for %s", link) } var commit string r, err := Repo(link) if err != nil { - return "", "", err + return "", "", "", models.VersionSourceNone, err } - c, err := Commit(link, httpClient) + c, tag, source, err := Commit(link, httpClient) if err != nil { - return "", "", err + return "", "", "", models.VersionSourceNone, err } commit = c @@ -593,7 +596,7 @@ func ExtractGitCommit(link string, httpClient *http.Client, depth int, cache git // If URL doesn't validate, treat it as linkrot. possiblyDifferentLink, err := git.FindCanonicalLink(link, httpClient, cache) if err != nil { - return "", "", err + return "", "", "", models.VersionSourceNone, err } // restart the entire extraction process when the URL changes (i.e. handle a @@ -603,7 +606,7 @@ func ExtractGitCommit(link string, httpClient *http.Client, depth int, cache git return ExtractGitCommit(possiblyDifferentLink, httpClient, depth+1, cache) } - return commit, r, nil + return commit, r, tag, source, nil } func HasVersion(validVersions []string, version string) bool { @@ -1249,7 +1252,7 @@ func ReposFromReferences(cache *VPRepoCache, vp *VendorProduct, refs []models.Re continue } // If the reference is a commit URL, the repo is inherently useful (but only if the repo still ultimately works). - _, err = Commit(ref.URL, httpClient) + _, _, _, err = Commit(ref.URL, httpClient) // Check if it was previously found to be bad: if repoTagsCache != nil && repoTagsCache.IsInvalid(repo) { continue diff --git a/vulnfeeds/conversion/versions_test.go b/vulnfeeds/conversion/versions_test.go index a68291dc772..608d82bf326 100644 --- a/vulnfeeds/conversion/versions_test.go +++ b/vulnfeeds/conversion/versions_test.go @@ -512,8 +512,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/google/osv.dev/commit/cd4e934d0527e5010e373e7fed54ef5daefba2f5", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/google/osv.dev", - Fixed: "cd4e934d0527e5010e373e7fed54ef5daefba2f5", + Repo: "https://github.com/google/osv.dev", + Fixed: "cd4e934d0527e5010e373e7fed54ef5daefba2f5", + Source: models.VersionSourceRefsCommit, }, }, { @@ -527,8 +528,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/pimcore/customer-data-framework/commit/e3f333391582d9309115e6b94e875367d0ea7163.patch", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/pimcore/customer-data-framework", - Fixed: "e3f333391582d9309115e6b94e875367d0ea7163", + Repo: "https://github.com/pimcore/customer-data-framework", + Fixed: "e3f333391582d9309115e6b94e875367d0ea7163", + Source: models.VersionSourceRefsCommit, }, }, { @@ -542,8 +544,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://gitlab.freedesktop.org/virgl/virglrenderer/-/commit/b05bb61f454eeb8a85164c8a31510aeb9d79129c", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://gitlab.freedesktop.org/virgl/virglrenderer", - Fixed: "b05bb61f454eeb8a85164c8a31510aeb9d79129c", + Repo: "https://gitlab.freedesktop.org/virgl/virglrenderer", + Fixed: "b05bb61f454eeb8a85164c8a31510aeb9d79129c", + Source: models.VersionSourceRefsCommit, }, }, { @@ -551,8 +554,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://gitlab.com/muttmua/mutt/-/commit/452ee330e094bfc7c9a68555e5152b1826534555.patch", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://gitlab.com/muttmua/mutt", - Fixed: "452ee330e094bfc7c9a68555e5152b1826534555", + Repo: "https://gitlab.com/muttmua/mutt", + Fixed: "452ee330e094bfc7c9a68555e5152b1826534555", + Source: models.VersionSourceRefsCommit, }, }, { @@ -560,8 +564,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://gitlab.com/mayan-edms/mayan-edms/commit/9ebe80595afe4fdd1e2c74358d6a9421f4ce130e", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://gitlab.com/mayan-edms/mayan-edms", - Fixed: "9ebe80595afe4fdd1e2c74358d6a9421f4ce130e", + Repo: "https://gitlab.com/mayan-edms/mayan-edms", + Fixed: "9ebe80595afe4fdd1e2c74358d6a9421f4ce130e", + Source: models.VersionSourceRefsCommit, }, }, { @@ -569,8 +574,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://bitbucket.org/openpyxl/openpyxl/commits/3b4905f428e1", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://bitbucket.org/openpyxl/openpyxl", - Fixed: "3b4905f428e1", + Repo: "https://bitbucket.org/openpyxl/openpyxl", + Fixed: "3b4905f428e1", + Source: models.VersionSourceRefsCommit, }, }, { @@ -578,8 +584,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://bitbucket.org/utmandrew/pcrs/commits/5f18bcb/", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://bitbucket.org/utmandrew/pcrs", - Fixed: "5f18bcb", + Repo: "https://bitbucket.org/utmandrew/pcrs", + Fixed: "5f18bcb", + Source: models.VersionSourceRefsCommit, }, }, { @@ -587,8 +594,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://git.dpkg.org/cgit/dpkg/dpkg.git/commit/?id=faa4c92debe45412bfcf8a44f26e827800bb24be", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://git.dpkg.org/cgit/dpkg/dpkg.git", - Fixed: "faa4c92debe45412bfcf8a44f26e827800bb24be", + Repo: "https://git.dpkg.org/cgit/dpkg/dpkg.git", + Fixed: "faa4c92debe45412bfcf8a44f26e827800bb24be", + Source: models.VersionSourceRefsCommit, }, }, { @@ -599,8 +607,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://git.gnupg.org/cgi-bin/gitweb.cgi?p=libksba.git&a=commit&h=f61a5ea4e0f6a80fd4b28ef0174bee77793cf070", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "git://git.gnupg.org/libksba.git", - Fixed: "f61a5ea4e0f6a80fd4b28ef0174bee77793cf070", + Repo: "git://git.gnupg.org/libksba.git", + Fixed: "f61a5ea4e0f6a80fd4b28ef0174bee77793cf070", + Source: models.VersionSourceRefsCommit, }, }, { @@ -615,8 +624,10 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/google/osv.dev/releases/tag/v0.0.14", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/google/osv.dev", - Fixed: "8de7697b3b8a73e79a73ec34f17ef0fa842cfbb2", + Repo: "https://github.com/google/osv.dev", + Fixed: "8de7697b3b8a73e79a73ec34f17ef0fa842cfbb2", + Source: models.VersionSourceRefsTag, + OriginalTag: "v0.0.14", }, expectFailure: false, }, @@ -632,8 +643,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=ee1fee900537b5d9560e9f937402de5ddc8412f3", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", - Fixed: "ee1fee900537b5d9560e9f937402de5ddc8412f3", + Repo: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + Fixed: "ee1fee900537b5d9560e9f937402de5ddc8412f3", + Source: models.VersionSourceRefsCommit, }, skipOnCloudBuild: true, // observing indications of IP denylisting as at 2025-02-13 }, @@ -642,8 +654,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://git.ffmpeg.org/gitweb/ffmpeg.git/commitdiff/c94875471e3ba3dc396c6919ff3ec9b14539cd71", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://git.ffmpeg.org/ffmpeg.git", - Fixed: "c94875471e3ba3dc396c6919ff3ec9b14539cd71", + Repo: "https://git.ffmpeg.org/ffmpeg.git", + Fixed: "c94875471e3ba3dc396c6919ff3ec9b14539cd71", + Source: models.VersionSourceRefsCommit, }, }, { @@ -651,8 +664,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/uWebSockets/uWebSockets/commit/37deefd01f0875e133ea967122e3a5e421b8fcd9", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/unetworking/uwebsockets", - Fixed: "37deefd01f0875e133ea967122e3a5e421b8fcd9", + Repo: "https://github.com/unetworking/uwebsockets", + Fixed: "37deefd01f0875e133ea967122e3a5e421b8fcd9", + Source: models.VersionSourceRefsCommit, }, }, { @@ -660,8 +674,9 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/eggjs/extend2/commit/aa332a59116c8398976434b57ea477c6823054f8", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/eggjs/extend2", - Fixed: "aa332a59116c8398976434b57ea477c6823054f8", + Repo: "https://github.com/eggjs/extend2", + Fixed: "aa332a59116c8398976434b57ea477c6823054f8", + Source: models.VersionSourceRefsCommit, }, }, { @@ -676,8 +691,10 @@ func TestExtractGitCommit(t *testing.T) { inputLink: "https://github.com/redis/redis/releases/tag/6.2.17", inputCommitType: models.Fixed, expectedAffectedCommit: models.AffectedCommit{ - Repo: "https://github.com/redis/redis", - Fixed: "441001a4e5e37a7a450c0929d2a94ba489941874", + Repo: "https://github.com/redis/redis", + Fixed: "441001a4e5e37a7a450c0929d2a94ba489941874", + Source: models.VersionSourceRefsTag, + OriginalTag: "6.2.17", }, expectFailure: false, }, @@ -1129,6 +1146,8 @@ func TestCommit(t *testing.T) { name string args args want string + wantTag string + wantSource models.VersionSource wantErr bool disableExpiryDate time.Time // If test needs to be disabled due to known outage. }{ @@ -1137,40 +1156,45 @@ func TestCommit(t *testing.T) { args: args{ u: "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=ee1fee900537b5d9560e9f937402de5ddc8412f3", }, - want: "ee1fee900537b5d9560e9f937402de5ddc8412f3", - wantErr: false, + want: "ee1fee900537b5d9560e9f937402de5ddc8412f3", + wantSource: models.VersionSourceRefsCommit, + wantErr: false, }, { name: "an unusual and technically valid GitHub commit URL based on a tag (with ancestry)", args: args{ u: "https://github.com/curl/curl/commit/curl-7_50_2~32", }, - want: "", // Ideally it would be 7700fcba64bf5806de28f6c1c7da3b4f0b38567d but this isn't `git rev-parse` - wantErr: true, + want: "", // Ideally it would be 7700fcba64bf5806de28f6c1c7da3b4f0b38567d but this isn't `git rev-parse` + wantSource: models.VersionSourceNone, + wantErr: true, }, { name: "Valid GitHub commit URL", args: args{ u: "https://github.com/MariaDB/server/commit/b1351c15946349f9daa7e5297fb2ac6f3139e4a", }, - want: "b1351c15946349f9daa7e5297fb2ac6f3139e4a", - wantErr: false, + want: "b1351c15946349f9daa7e5297fb2ac6f3139e4a", + wantSource: models.VersionSourceRefsCommit, + wantErr: false, }, { name: "Valid FreeDesktop GitLab commit URL", args: args{ u: "https://gitlab.freedesktop.org/virgl/virglrenderer/-/commit/b05bb61f454eeb8a85164c8a31510aeb9d79129", }, - want: "b05bb61f454eeb8a85164c8a31510aeb9d79129", - wantErr: false, + want: "b05bb61f454eeb8a85164c8a31510aeb9d79129", + wantSource: models.VersionSourceRefsCommit, + wantErr: false, }, { name: "Valid GitLab commit URL with a shorter hash", args: args{ u: "https://gitlab.com/qemu-project/qemu/-/commit/4367a20cc", }, - want: "4367a20cc", - wantErr: false, + want: "4367a20cc", + wantSource: models.VersionSourceRefsCommit, + wantErr: false, }, } for _, tt := range tests { @@ -1178,7 +1202,7 @@ func TestCommit(t *testing.T) { if time.Now().Before(tt.disableExpiryDate) { t.Skipf("test %q has been skipped due to known outage and will be reenabled on %s.", tt.name, tt.disableExpiryDate) } - got, err := Commit(tt.args.u, r.GetDefaultClient()) + got, gotTag, gotSource, err := Commit(tt.args.u, r.GetDefaultClient()) if (err != nil) != tt.wantErr { t.Errorf("Commit() error = %v, wantErr %v", err, tt.wantErr) return @@ -1186,6 +1210,12 @@ func TestCommit(t *testing.T) { if got != tt.want { t.Errorf("Commit() = %v, want %v", got, tt.want) } + if gotSource != tt.wantSource { + t.Errorf("Commit() source = %v, want %v", gotSource, tt.wantSource) + } + if gotTag != tt.wantTag { + t.Errorf("Commit() tag = %v, want %v", gotTag, tt.wantTag) + } }) } } diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 2d091485b17..373128ef7b2 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -131,6 +131,8 @@ const ( VersionSourceDescription VersionSource = "DESCRIPTION" VersionSourceText VersionSource = "TEXT_EXTRACTION" VersionSourceRefs VersionSource = "REFERENCES" + VersionSourceRefsCommit VersionSource = "REFERENCES_COMMIT" + VersionSourceRefsTag VersionSource = "REFERENCES_TAG" ) func DetermineOutcome(metrics *ConversionMetrics) { diff --git a/vulnfeeds/models/types.go b/vulnfeeds/models/types.go index 58cfede8682..8f8c578fed8 100644 --- a/vulnfeeds/models/types.go +++ b/vulnfeeds/models/types.go @@ -10,11 +10,13 @@ import ( ) type AffectedCommit struct { - Repo string `json:"repo,omitempty" yaml:"repo,omitempty"` - Introduced string `json:"introduced,omitempty" yaml:"introduced,omitempty"` - Fixed string `json:"fixed,omitempty" yaml:"fixed,omitempty"` - Limit string `json:"limit,omitempty" yaml:"limit,omitempty"` - LastAffected string `json:"last_affected,omitempty" yaml:"last_affected,omitempty"` + Repo string `json:"repo,omitempty" yaml:"repo,omitempty"` + Introduced string `json:"introduced,omitempty" yaml:"introduced,omitempty"` + Fixed string `json:"fixed,omitempty" yaml:"fixed,omitempty"` + Limit string `json:"limit,omitempty" yaml:"limit,omitempty"` + LastAffected string `json:"last_affected,omitempty" yaml:"last_affected,omitempty"` + Source VersionSource `json:"-" yaml:"-"` + OriginalTag string `json:"-" yaml:"-"` } // SetCommitByType sets the appropriate commit field on an AffectedCommit based on the CommitType. @@ -37,9 +39,11 @@ type RangeWithMetadata struct { } type Metadata struct { - CPE string - Source VersionSource - Versions []string + CPE string + Source VersionSource + Strategy string + Versions []string + OriginalTag string } func (ac *AffectedCommit) SetRepo(repo string) {