diff --git a/vulnfeeds/conversion/common.go b/vulnfeeds/conversion/common.go index ce60f936322..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) } } @@ -187,7 +188,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 { @@ -209,7 +210,7 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri if err != nil { metrics.AddNotef("Failed to find canonical link - %s %v", repo, err) if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } @@ -218,15 +219,35 @@ func GitVersionsToCommits(versionRanges []models.RangeWithMetadata, repos []stri normalizedTags, err := git.NormalizeRepoTags(repo, cache, httpClient) if err != nil { + metrics.AddNotef("Failed to normalize tags - %s: %v", repo, err) if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return nil, nil, nil } - metrics.AddNotef("Failed to normalize tags - %s", repo) 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() @@ -234,7 +255,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 { @@ -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 @@ -727,21 +734,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.AddNotef("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, httpClient) + 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) } } @@ -758,7 +849,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..de1ea01eab7 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" } ] }, @@ -2363,14 +2355,6 @@ } ], "source": "AFFECTED_FIELD" - }, - { - "extracted_events": [ - { - "fixed": "7.61.1" - } - ], - "source": "DESCRIPTION" } ] }, @@ -4162,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/common.go b/vulnfeeds/conversion/cve5/common.go deleted file mode 100644 index a6b945cca9d..00000000000 --- a/vulnfeeds/conversion/cve5/common.go +++ /dev/null @@ -1,136 +0,0 @@ -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" -) - -// VersionRangeType represents the type of versioning scheme for a range. -type VersionRangeType int - -const acceptableQuality = vulns.Spaces - -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 "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 -} - -// 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 5e01b097f96..c038e995469 100644 --- a/vulnfeeds/conversion/cve5/default_extractor.go +++ b/vulnfeeds/conversion/cve5/default_extractor.go @@ -4,23 +4,33 @@ import ( "maps" "net/http" "slices" - "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" "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 []strategies.VersionStrategy +} + +func (d *DefaultVersionExtractor) getStrategies() []strategies.VersionStrategy { + if len(d.Strategies) > 0 { + return d.Strategies + } + + 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 @@ -63,9 +73,38 @@ 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.AddNotef("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) { @@ -90,85 +129,5 @@ 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) - } - } -} - -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.AddNotef("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)...) - - 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.AddNotef("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.AddNotef("Single version found %v for MITRE - Setting only last_affected", vers.Version) - } else { - vr = []*osvschema.Range{c.BuildVersionRange(vers.Version, vers.Version, "")} - metrics.AddNotef("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} - } - versionRanges = append(versionRanges, rwms...) - } - } - - // 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 + addUnresolvedRanges(unresolvedRanges) } diff --git a/vulnfeeds/conversion/cve5/extraction.go b/vulnfeeds/conversion/cve5/extraction.go index bb443241183..c7f3b3544a3 100644 --- a/vulnfeeds/conversion/cve5/extraction.go +++ b/vulnfeeds/conversion/cve5/extraction.go @@ -2,7 +2,9 @@ package cve5 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" @@ -11,15 +13,46 @@ 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 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{ + Strategies: strategies.Linux(), + } + case "github_m", "github": + return &DefaultVersionExtractor{ + Strategies: strategies.GitHub(), + } + case "mitre": + return &DefaultVersionExtractor{ + Strategies: strategies.MITRE(), + } default: - return &DefaultVersionExtractor{} + return &DefaultVersionExtractor{ + 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 82d3913ea10..64baeece491 100644 --- a/vulnfeeds/conversion/cve5/linux_extractor.go +++ b/vulnfeeds/conversion/cve5/linux_extractor.go @@ -1,13 +1,10 @@ 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" "github.com/google/osv.dev/vulnfeeds/git" "github.com/google/osv.dev/vulnfeeds/models" "github.com/google/osv.dev/vulnfeeds/utility/logger" @@ -18,37 +15,40 @@ import ( // LinuxVersionExtractor provides the version extraction logic for Linux kernel CVEs. type LinuxVersionExtractor struct { - DefaultVersionExtractor + Strategies []strategies.VersionStrategy } 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.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) } @@ -62,7 +62,7 @@ func (l *LinuxVersionExtractor) ExtractVersions(cve models.CVE5, v *vulns.Vulner if !gotVersions { metrics.AddNotef("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") } @@ -71,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 { @@ -88,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", @@ -99,108 +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 -} - -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.AddNotef("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.AddNotef("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 +// 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.go b/vulnfeeds/conversion/cve5/strategies.go deleted file mode 100644 index 15714a9743a..00000000000 --- a/vulnfeeds/conversion/cve5/strategies.go +++ /dev/null @@ -1,79 +0,0 @@ -package cve5 - -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" -) - -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) - - return cpeRanges, nil - } else if err != nil { - metrics.AddNotef("%s", err.Error()) - } - - return nil, err -} - -// 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) { - if vers.Status != "affected" { - return nil, VersionRangeTypeUnknown, true - } - - currentVersionType := toVersionRangeType(vers.VersionType) - versionTypesCount[currentVersionType]++ - - var introduced, fixed, lastaffected string - - // Quality check the version strings to avoid using filler content. - vQuality := vulns.CheckQuality(vers.Version) - if !vQuality.AtLeast(acceptableQuality) { - metrics.AddNotef("Version value for 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'. - if vers.LessThan != "" && 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.AddNotef("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) - hasRange = false - } - - metrics.AddNotef("Range detected: %v", hasRange) - if hasRange { - if vQuality.AtLeast(acceptableQuality) { - introduced = vers.Version - metrics.AddNotef("%s - Introduced from version value - %s", vQuality.String(), vers.Version) - } - if vLessThanQual.AtLeast(acceptableQuality) { - fixed = vers.LessThan - metrics.AddNotef("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) - } else if vLTOEQual.AtLeast(acceptableQuality) { - lastaffected = vers.LessThanOrEqual - metrics.AddNotef("%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, "")) - } - - return versionRanges, currentVersionType, true - } - - return nil, VersionRangeTypeUnknown, false -} diff --git a/vulnfeeds/conversion/cve5/strategies/changes_at.go b/vulnfeeds/conversion/cve5/strategies/changes_at.go new file mode 100644 index 00000000000..bb635f17b1c --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/changes_at.go @@ -0,0 +1,50 @@ +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(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, false + } + + var fixedFromChanges string + for _, ch := range vers.Changes { + if ch.Status == "unaffected" && ch.At != "" { + fixedFromChanges = ch.At + break + } + } + + if fixedFromChanges == "" { + return nil, false + } + + metrics.AddNotef("Fixed from changes - %s", fixedFromChanges) + var introduced string + if vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNotef("Introduced from version value - %s", vers.Version) + } + + vr := []*osvschema.Range{c.BuildVersionRange(introduced, "", fixedFromChanges)} + + return c.ToRangeWithMetadata(vr, models.VersionSourceAffected), true +} diff --git a/vulnfeeds/conversion/cve5/strategies/cpe.go b/vulnfeeds/conversion/cve5/strategies/cpe.go new file mode 100644 index 00000000000..06f28cb74dd --- /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" +) + +// 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, 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, false + } + + if metrics != nil { + metrics.AddNotef("Extracted version %s from CPE %s", version, cpeStr) + } + + 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, 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) +} + +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: +// +// "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, metrics) + if err == nil && len(cpeRanges) > 0 { + 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.AddNotef("%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 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" { + 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)...) + } + } + } + } + + // 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") + } + + 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..5ada3ea9643 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/git_commit.go @@ -0,0 +1,88 @@ +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(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, false + } + if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, false + } + + 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 { + rwms[i].Metadata.Versions = []string{vers.Version} + } + + return rwms, 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(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, false + } + if ToVersionRangeType(vers.VersionType) != VersionRangeTypeGit { + return nil, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + 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), 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 new file mode 100644 index 00000000000..00f6c784c51 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/presets.go @@ -0,0 +1,62 @@ +package strategies + +// Strategy Pipeline Presets for different CNAs + +// Default returns the default strategy pipeline for CNAs without a custom preset. +// Example: test_data/cvelistV5/cves/2025/1xxx/CVE-2025-1110.json (GitLab), +// test_data/cvelistV5/cves/2026/67xxx/CVE-2026-67185.json (VulnCheck) +func Default() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &CPEVersionStringStrategy{}, + &VersionTextExtractionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +// GitHub returns the strategy pipeline for GitHub_M / GitHub advisories. +// Example: test_data/cvelistV5/cves/2023/45xxx/CVE-2023-45803.json, +// test_data/cvelistV5/cves/2024/21xxx/CVE-2024-21634.json +func GitHub() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &CPEVersionStringStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +// MITRE returns the strategy pipeline for MITRE advisories. +// Example: test_data/cvelistV5/cves/2021/26xxx/CVE-2021-26917.json, +// test_data/cve5/CVE-2016-1897.json +func MITRE() []VersionStrategy { + return []VersionStrategy{ + &SplitRangeStrategy{}, + &ChangesAtStrategy{}, + &StandardRangeStrategy{}, + &StringRangeExpressionStrategy{}, + &GitCommitStrategy{}, + &CPEVersionStringStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + &StandaloneSingleVersionStrategy{}, + } +} + +// Linux returns the strategy pipeline for Linux kernel advisories. +// Example: test_data/cvelistV5/cves/2025/21xxx/CVE-2025-21772.json, +// test_data/cvelistV5/cves/2025/21xxx/CVE-2025-21631.json +func Linux() []VersionStrategy { + return []VersionStrategy{ + &InverseAffectedRangesStrategy{}, + &StandardRangeStrategy{}, + &GitCommitIntroducedOnlyStrategy{}, + &ZeroIntroducedSingleVersionStrategy{}, + } +} diff --git a/vulnfeeds/conversion/cve5/strategies/single_version.go b/vulnfeeds/conversion/cve5/strategies/single_version.go new file mode 100644 index 00000000000..3a93305b745 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/single_version.go @@ -0,0 +1,82 @@ +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(state *ExtractionState, metrics *models.ConversionMetrics) { + if len(state.Affected.Versions) != 1 || state.IsConsumed(0) { + return + } + + vers := state.Affected.Versions[0] + if vers.Status != "affected" || vers.Version == "" { + return + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return + } + + metrics.AddNotef("Single version found %v - Assuming introduced = 0 and last affected = %v", vers.Version, vers.Version) + vr := []*osvschema.Range{c.BuildVersionRange("0", vers.Version, "")} + state.Emit(c.ToRangeWithMetadata(vr, models.VersionSourceAffected), 0) +} + +// 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(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, false + } + if !vulns.CheckQuality(vers.Version).AtLeast(acceptableQuality) { + return nil, false + } + + metrics.AddNotef("Single version found %v - Treating as standalone version", vers.Version) + 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, 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..e69f5bf381a --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/split_range.go @@ -0,0 +1,124 @@ +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(state *ExtractionState, metrics *models.ConversionMetrics) { + affected := state.Affected + + for i := 0; i < len(affected.Versions); i++ { + if state.IsConsumed(i) { + continue + } + + vers := affected.Versions[i] + if vers.Status != "affected" { + continue + } + + // 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 + } + + // 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) + + continue + } + + // 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 + } + + 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) + } + } +} diff --git a/vulnfeeds/conversion/cve5/strategies/standard_range.go b/vulnfeeds/conversion/cve5/strategies/standard_range.go new file mode 100644 index 00000000000..2bd1e502118 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/standard_range.go @@ -0,0 +1,98 @@ +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(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, false + } + + currentVersionType := ToVersionRangeType(vers.VersionType) + + vQuality := vulns.CheckQuality(vers.Version) + if !vQuality.AtLeast(acceptableQuality) { + metrics.AddNotef("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.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.AddNotef("Warning: lessThanOrEqual (%s) is the same as introduced (%s)\n", vers.LessThanOrEqual, vers.Version) + hasRange = false + } + + if !hasRange { + return nil, false + } + + metrics.AddNotef("Range detected: %v", hasRange) + var introduced, fixed, lastaffected string + if vQuality.AtLeast(acceptableQuality) { + introduced = vers.Version + metrics.AddNotef("%s - Introduced from version value - %s", vQuality.String(), vers.Version) + } + + if vLessThanQual.AtLeast(acceptableQuality) { + fixed = vers.LessThan + metrics.AddNotef("%s - Fixed from LessThan value - %s", vLessThanQual.String(), vers.LessThan) + } else if vLTOEQual.AtLeast(acceptableQuality) { + lastaffected = vers.LessThanOrEqual + metrics.AddNotef("%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, 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), 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..0ff6109807a --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/strategies_test.go @@ -0,0 +1,412 @@ +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{} + 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() + 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{} + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + Changes: []models.Change{ + {Status: "unaffected", At: "1.0.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() + 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{} + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: ">= 1.2.0, < 2.0.0", + VersionType: "semver", + }, + }, + } + 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() + 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{} + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "2.52", + VersionType: "custom", + }, + }, + } + 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() + if events[0].GetIntroduced() != "0" || events[1].GetLastAffected() != "2.52" { + t.Errorf("unexpected events: %+v", events) + } + + // 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: "1.0.0", LessThan: "1.5.0"}, + {Status: "affected", Version: "2.0.0"}, + }, + } + 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") + } + }) + + 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", + }, + }, + } + + 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 := rangesSplit[0].Range.GetEvents() + if events1[0].GetIntroduced() != "1.31.0" || events1[1].GetFixed() != "1.36.1" { + t.Errorf("unexpected events from split pair: %+v", events1) + } + + // 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", + }, + }, + } + 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() + 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{} + affected := models.Affected{ + Repo: "https://github.com/GeneralSandman/TinyWeb", + Versions: []models.Versions{ + { + Status: "affected", + Version: "0b3b5fdb5a058f50248cd8547824936b8dd10351", + VersionType: "git", + }, + }, + } + 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" { + t.Errorf("unexpected range properties: %+v", ranges[0].Range) + } + }) + + t.Run("GitCommitIntroducedOnlyStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &GitCommitIntroducedOnlyStrategy{} + 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", + }, + }, + } + 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() + 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{} + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + VersionType: "semver", + }, + }, + } + 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() + 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() + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + }, + }, + } + + // When StandardRangeStrategy comes first: + pipeline1 := []VersionStrategy{ + &StandardRangeStrategy{}, + &StandaloneSingleVersionStrategy{}, + } + state1 := NewExtractionState(affected) + for _, s := range pipeline1 { + 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) + } + + // When StandaloneSingleVersionStrategy comes first: + pipeline2 := []VersionStrategy{ + &StandaloneSingleVersionStrategy{}, + &StandardRangeStrategy{}, + } + state2 := NewExtractionState(affected) + for _, s := range pipeline2 { + 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("CPEVersionStringStrategy", func(t *testing.T) { + t.Parallel() + metrics := &models.ConversionMetrics{} + strategy := &CPEVersionStringStrategy{} + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "cpe:2.3:a:vendor:product:1.2.3:*:*:*:*:*:*:*", + }, + }, + } + 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) + } + 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", + }, + }, + }, + }, + }, + } + // 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) != 2 { + t.Fatalf("CPEVersionStrategy failed to extract ranges: %v (got %d ranges)", err, len(ranges)) + } + 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 new file mode 100644 index 00000000000..09b4bb2d6b8 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/strategy.go @@ -0,0 +1,185 @@ +// Package strategies provides version extraction strategies and CNA pipelines for CVE 5.0 records. +package strategies + +import ( + "cmp" + "strconv" + "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 + +// 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 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 new file mode 100644 index 00000000000..6ed62cb6f74 --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/string_range_expression.go @@ -0,0 +1,55 @@ +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(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, false + } + + av, err := git.ParseVersionRange(vers.Version) + if err != nil || av.Introduced == "" { + return nil, false + } + + 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, false + } + + metrics.AddNotef("Parsed range expression from version: %s", vers.Version) + + 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 new file mode 100644 index 00000000000..d056acef33e --- /dev/null +++ b/vulnfeeds/conversion/cve5/strategies/text_extraction.go @@ -0,0 +1,37 @@ +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(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, false + } + + possibleVersions := c.ExtractVersionsFromText(nil, vers.Version, metrics, models.VersionSourceAffected) + if len(possibleVersions) > 0 { + return possibleVersions, true + } + + return nil, false +} 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 f8a43f41def..1d6192899c9 100644 --- a/vulnfeeds/conversion/cve5/version_extraction_test.go +++ b/vulnfeeds/conversion/cve5/version_extraction_test.go @@ -3,10 +3,12 @@ package cve5 import ( "reflect" "sort" + "strings" "testing" "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" @@ -20,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) } }) } @@ -41,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", @@ -62,7 +63,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("1.0", "", "1.5"), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "single version fallback", @@ -78,7 +78,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("2.0", "2.0", ""), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "mitre single version fallback", @@ -95,7 +94,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("", "3.0", ""), }, - wantRangeType: VersionRangeTypeSemver, }, { name: "mitre multiple versions fallback", @@ -118,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", @@ -133,7 +130,6 @@ func TestFindNormalAffectedRanges(t *testing.T) { wantRanges: []*osvschema.Range{ conversion.BuildVersionRange("2.0", "", "2.5"), }, - wantRangeType: VersionRangeTypeEcosystem, }, { name: "git commit", @@ -147,25 +143,101 @@ func TestFindNormalAffectedRanges(t *testing.T) { }, }, wantRanges: []*osvschema.Range{ - conversion.BuildVersionRange("deadbeef", "deadbeef", ""), + conversion.BuildGitVersionRange("deadbeef", "deadbeef", "", ""), + }, + }, + { + 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"), + }, + }, + { + 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"), + }, + }, + { + 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: VersionRangeTypeGit, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - versionExtractor := &DefaultVersionExtractor{} - gotRangesWithMeta, gotRangeType := versionExtractor.FindNormalAffectedRanges(tt.affected, &models.ConversionMetrics{CNA: tt.cnaAssigner}) + 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 := 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) } }) } @@ -186,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) } }) } @@ -197,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 }{ @@ -218,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"), @@ -236,7 +308,7 @@ func TestFindInverseAffectedRanges(t *testing.T) { }, }, }, - versionType: VersionRangeTypeUnknown, + versionType: strategies.VersionRangeTypeUnknown, cnaAssigner: "NotLinux", want: nil, }, @@ -257,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"), @@ -409,6 +481,40 @@ func TestGetVersionExtractor(t *testing.T) { } } +func TestDefaultVersionExtractor_SliceOrderPrecedence(t *testing.T) { + t.Parallel() + affected := models.Affected{ + Versions: []models.Versions{ + { + Status: "affected", + Version: "1.0.0", + LessThan: "1.5.0", + VersionType: "semver", + }, + }, + } + + // When StandardRangeStrategy is first in slice order: + pipeline1 := []strategies.VersionStrategy{ + &strategies.StandardRangeStrategy{}, + &strategies.StandaloneSingleVersionStrategy{}, + } + 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: + pipeline2 := []strategies.VersionStrategy{ + &strategies.StandaloneSingleVersionStrategy{}, + &strategies.StandardRangeStrategy{}, + } + 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) + } +} + func TestExtractVersions(t *testing.T) { testCases := []struct { name string @@ -615,6 +721,22 @@ func TestExtractVersions(t *testing.T) { }, }}, }, + { + name: "CVE-2026-67185", + cve: loadTestData(t, "CVE-2026-67185"), + 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 +756,70 @@ 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") + 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) + } + + // 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 fc8982ec815..ccab13e5827 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.AddNotef("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 71c9d1f5e10..3dcb099a7c1 100644 --- a/vulnfeeds/conversion/versions.go +++ b/vulnfeeds/conversion/versions.go @@ -1130,7 +1130,7 @@ func VersionInfoToCommits(v *models.VersionInfo, repos []string, cache git.RepoT normalizedTags, err := git.NormalizeRepoTags(repo, cache, httpClient) if err != nil { if git.IsRateLimit(err) { - metrics.Outcome = models.Error + metrics.SetError(err) return } metrics.AddNotef("Failed to normalize tags %s %s", repo, err) @@ -1237,9 +1237,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.AddNotef("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..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" @@ -1665,3 +1666,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"} + 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)) + } + 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 0db16d72358..6f7f52ed2e9 100644 --- a/vulnfeeds/models/cve.go +++ b/vulnfeeds/models/cve.go @@ -122,17 +122,24 @@ 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"` } +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 { diff --git a/vulnfeeds/models/metrics.go b/vulnfeeds/models/metrics.go index 773ea6a99fd..2d091485b17 100644 --- a/vulnfeeds/models/metrics.go +++ b/vulnfeeds/models/metrics.go @@ -105,6 +105,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.AddNotef("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) + } +} 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