| // Copyright 2023 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package report |
| |
| import ( |
| "errors" |
| "fmt" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| |
| "golang.org/x/exp/maps" |
| "golang.org/x/exp/slices" |
| "golang.org/x/mod/module" |
| "golang.org/x/vulndb/internal/idstr" |
| "golang.org/x/vulndb/internal/osv" |
| "golang.org/x/vulndb/internal/osvutils" |
| "golang.org/x/vulndb/internal/proxy" |
| "golang.org/x/vulndb/internal/stdlib" |
| "golang.org/x/vulndb/internal/version" |
| ) |
| |
| func (r *Report) Fix(pc *proxy.Client) { |
| r.deleteNotes(NoteTypeFix) |
| expandGitCommits(r) |
| _ = r.FixModules(pc) |
| r.FixText() |
| r.FixReferences() |
| } |
| |
| func (r *Report) FixText() { |
| fixLines := func(sp *string) { |
| *sp = fixLineLength(*sp, maxLineLength) |
| } |
| fixLines((*string)(&r.Summary)) |
| fixLines((*string)(&r.Description)) |
| if r.CVEMetadata != nil { |
| fixLines(&r.CVEMetadata.Description) |
| } |
| |
| r.fixSummary() |
| } |
| |
| func (r *Report) fixSummary() { |
| summary := r.Summary.String() |
| |
| // If there is no summary, create a basic one. |
| if summary == "" { |
| if aliases := r.Aliases(); len(aliases) != 0 { |
| summary = aliases[0] |
| } else { |
| summary = "Vulnerability" |
| } |
| } |
| |
| // Add a path if one exists and is needed. |
| if paths := r.nonStdPaths(); len(paths) > 0 && !containsPath(summary, paths) { |
| summary = fmt.Sprintf("%s in %s", summary, stripMajor(paths[0])) |
| } |
| |
| r.Summary = Summary(fixSpelling(summary)) |
| } |
| |
| func stripMajor(path string) string { |
| base, _, ok := module.SplitPathVersion(path) |
| if !ok { |
| return path |
| } |
| return base |
| } |
| |
| func (v *Version) commitHashToVersion(modulePath string, pc *proxy.Client) { |
| if v == nil { |
| return |
| } |
| |
| vv := v.Version |
| if version.IsCommitHash(vv) { |
| if c, err := pc.CanonicalModuleVersion(modulePath, vv); err == nil { // no error |
| v.Version = c |
| } |
| } |
| } |
| |
| // FixVersions replaces each version with its canonical form (if possible), |
| // sorts version ranges, and moves versions to their proper spot. |
| func (m *Module) FixVersions(pc *proxy.Client) { |
| for _, v := range m.Versions { |
| v.commitHashToVersion(m.Module, pc) |
| } |
| m.VulnerableAt.commitHashToVersion(m.Module, pc) |
| |
| m.Versions.fix() |
| m.UnsupportedVersions.fix() |
| m.VulnerableAt.fix() |
| |
| if pc != nil && !m.IsFirstParty() { |
| found, notFound, _ := m.classifyVersions(pc) |
| if len(notFound) != 0 { |
| m.Versions = found |
| m.NonGoVersions = append(m.NonGoVersions, notFound...) |
| } |
| } |
| } |
| |
| func (v *Version) fix() { |
| if v == nil { |
| return |
| } |
| vv := version.TrimPrefix(v.Version) |
| if version.IsValid(vv) { |
| vv = version.Canonical(vv) |
| } |
| v.Version = vv |
| } |
| |
| func (vs *Versions) fix() { |
| for i := range *vs { |
| (*vs)[i].fix() |
| } |
| sort.SliceStable(*vs, func(i, j int) bool { |
| return version.Before((*vs)[i].Version, (*vs)[j].Version) |
| }) |
| // Remove duplicates. |
| *vs = slices.Compact(*vs) |
| *vs = slices.CompactFunc(*vs, func(a, b *Version) bool { |
| return a.Type == b.Type && a.Version == b.Version |
| }) |
| } |
| |
| func (m *Module) fixVulnerableAt(pc *proxy.Client) error { |
| if m.VulnerableAt != nil { |
| return nil |
| } |
| if m.IsFirstParty() { |
| return fmt.Errorf("not implemented for std/cmd") |
| } |
| // Don't attempt to guess if the given version ranges don't make sense. |
| if err := m.checkModVersions(pc); err != nil { |
| return err |
| } |
| v, err := m.guessVulnerableAt(pc) |
| if err != nil { |
| return err |
| } |
| m.VulnerableAt = VulnerableAt(v) |
| return nil |
| } |
| |
| var errZeroPseudo = errors.New("cannot auto-guess when fixed version is 0.0.0 pseudo-version") |
| |
| // Find the latest fixed and introduced version, assuming the version |
| // ranges are sorted and valid. |
| func (vs Versions) latestVersions() (introduced, fixed *Version) { |
| if len(vs) == 0 { |
| return |
| } |
| last := vs[len(vs)-1] |
| if last.IsIntroduced() { |
| introduced = last |
| return |
| } |
| fixed = last |
| if len(vs) > 1 { |
| if penultimate := vs[len(vs)-2]; penultimate.IsIntroduced() { |
| introduced = penultimate |
| } |
| } |
| return |
| } |
| |
| // guessVulnerableAt attempts to find a vulnerable_at |
| // version using the module proxy, assuming that the version ranges |
| // have already been validated. |
| // If there is no fix, the latest version is used. |
| func (m *Module) guessVulnerableAt(pc *proxy.Client) (v string, err error) { |
| if m.IsFirstParty() { |
| return "", errors.New("cannot auto-guess vulnerable_at for first-party modules") |
| } |
| |
| introduced, fixed := m.Versions.latestVersions() |
| |
| // If there is no latest fix, find the latest version of the module. |
| if fixed == nil { |
| latest, err := pc.Latest(m.Module) |
| if err != nil || latest == "" { |
| return "", fmt.Errorf("no fix, but could not find latest version from proxy: %s", err) |
| } |
| if introduced != nil && version.Before(latest, introduced.Version) { |
| return "", fmt.Errorf("latest version (%s) is before last introduced version", latest) |
| } |
| return latest, nil |
| } |
| |
| // If the latest fixed version is a 0.0.0 pseudo-version, or not a valid version, |
| // don't attempt to determine the vulnerable_at version. |
| if !version.IsValid(fixed.Version) { |
| return "", errors.New("cannot auto-guess when fixed version is invalid") |
| } |
| if strings.HasPrefix(fixed.Version, "0.0.0-") { |
| return "", errZeroPseudo |
| } |
| |
| // Otherwise, find the version right before the fixed version. |
| vs, err := pc.Versions(m.Module) |
| if err != nil { |
| return "", fmt.Errorf("could not find versions from proxy: %s", err) |
| } |
| for i := len(vs) - 1; i >= 0; i-- { |
| if version.Before(vs[i], fixed.Version) { |
| // Make sure the version is >= the latest introduced version. |
| if introduced == nil || !version.Before(vs[i], introduced.Version) { |
| return vs[i], nil |
| } |
| } |
| } |
| |
| return "", errors.New("could not find tagged version between introduced and fixed") |
| } |
| |
| // fixLineLength returns a copy of s with all lines trimmed to <=n characters |
| // (with the exception of single-word lines). |
| // It preserves paragraph breaks (indicated by "\n\n") and markdown-style list |
| // breaks. |
| func fixLineLength(s string, n int) string { |
| var result strings.Builder |
| result.Grow(len(s)) |
| for i, paragraph := range strings.Split(toParagraphs(s), "\n\n") { |
| if i > 0 { |
| result.WriteString("\n\n") |
| } |
| var lines []string |
| for _, forcedLine := range strings.Split(paragraph, "\n") { |
| words := strings.Split(forcedLine, " ") |
| start, length := 0, 0 |
| for k, word := range words { |
| newLength := length + len(word) |
| if length > 0 { |
| newLength++ // space character |
| } |
| if newLength <= n { |
| length = newLength |
| continue |
| } |
| // Adding the word would put the line over the max length, |
| // so add the line as is (if it is non-empty). |
| if length > 0 { |
| lines = append(lines, strings.Join(words[start:k], " ")) |
| } |
| // Begin a new line with just the word. |
| start, length = k, len(word) |
| } |
| // Add the last line. |
| if length > 0 { |
| lines = append(lines, strings.Join(words[start:], " ")) |
| } |
| } |
| result.WriteString(strings.Join(lines, "\n")) |
| } |
| return result.String() |
| } |
| |
| var urlReplacements = []struct { |
| re *regexp.Regexp |
| repl string |
| }{{ |
| regexp.MustCompile(`golang.org`), |
| `go.dev`, |
| }, { |
| regexp.MustCompile(`https?://groups.google.com/forum/\#\![^/]*/([^/]+)/([^/]+)/(.*)`), |
| |
| `https://groups.google.com/g/$1/c/$2/m/$3`, |
| }, { |
| regexp.MustCompile(`.*github.com/golang/go/issues`), |
| `https://go.dev/issue`, |
| }, { |
| regexp.MustCompile(`.*github.com/golang/go/commit`), |
| `https://go.googlesource.com/+`, |
| }, |
| } |
| |
| func fixURL(u string) string { |
| for _, repl := range urlReplacements { |
| u = repl.re.ReplaceAllString(u, repl.repl) |
| } |
| return u |
| } |
| |
| func (r *Report) FixModules(pc *proxy.Client) (errs error) { |
| var fixed []*Module |
| for _, m := range r.Modules { |
| m.Module = transform(m.Module) |
| extractImportPath(m, pc) |
| fixed = append(fixed, m.splitByMajor(pc)...) |
| } |
| r.Modules = fixed |
| |
| merged, err := merge(fixed) |
| if err != nil { |
| r.AddNote(NoteTypeFix, "module merge error: %s", err) |
| errs = errors.Join(errs, err) |
| } else { |
| r.Modules = merged |
| } |
| |
| // For non-reviewed reports, assume that all major versions |
| // up to the highest mentioned are affected at all versions. |
| if !r.IsReviewed() { |
| r.addMissingMajors(pc) |
| } |
| |
| // Fix the versions *after* the modules have been merged. |
| for _, m := range r.Modules { |
| m.FixVersions(pc) |
| if err := m.fixVulnerableAt(pc); err != nil { |
| r.AddNote(NoteTypeFix, "%s: could not add vulnerable_at: %v", m.Module, err) |
| errs = errors.Join(errs, err) |
| } |
| } |
| |
| sortModules(r.Modules) |
| return errs |
| } |
| |
| // extractImportPath checks if the module m's "module" path is actually |
| // an import path. If so, it adds the import path to the packages list |
| // and fixes the module path. Modifies m. |
| // |
| // Does nothing if the module path is already correct, or isn't recognized |
| // by the proxy at all. |
| func extractImportPath(m *Module, pc *proxy.Client) { |
| path := m.Module |
| modulePath, err := pc.FindModule(m.Module) |
| if err != nil || // path doesn't contain a module, needs human review |
| path == modulePath { // path is already a module, no action needed |
| return |
| } |
| m.Module = modulePath |
| m.Packages = append(m.Packages, &Package{Package: path}) |
| } |
| |
| func (m *Module) hasVersions() bool { |
| return len(m.Versions) != 0 || len(m.NonGoVersions) != 0 || len(m.UnsupportedVersions) != 0 |
| } |
| |
| type majorInfo struct { |
| base string |
| high int |
| all map[int]bool |
| } |
| |
| func majorToInt(maj string) (int, bool) { |
| if maj == "" { |
| return 0, true |
| } |
| i, err := strconv.Atoi(strings.TrimPrefix(maj, "/v")) |
| if err != nil { |
| return 0, false |
| } |
| return i, true |
| } |
| |
| func intToMajor(i int) string { |
| if i == 0 { |
| return v0v1 |
| } |
| return fmt.Sprintf("v%d", i) |
| } |
| |
| func (r *Report) addMissingMajors(pc *proxy.Client) { |
| // Map from module v1 path to set of all listed major versions. |
| majorMap := make(map[string]*majorInfo) |
| for _, m := range r.Modules { |
| base, pathMajor, ok := module.SplitPathVersion(m.Module) |
| if !ok { // couldn't parse module path, skip |
| continue |
| } |
| i, ok := majorToInt(pathMajor) |
| if !ok { // invalid major version, skip |
| continue |
| } |
| v1Mod := modulePath(base, v0v1) |
| if majorMap[v1Mod] == nil { |
| majorMap[v1Mod] = &majorInfo{ |
| base: base, |
| all: make(map[int]bool), |
| } |
| } |
| if i > majorMap[v1Mod].high { |
| majorMap[v1Mod].high = i |
| } |
| majorMap[v1Mod].all[i] = true |
| } |
| |
| for _, mi := range majorMap { |
| for i := 0; i < mi.high; i++ { |
| if mi.all[i] { |
| continue |
| } |
| mod := modulePath(mi.base, intToMajor(i)) |
| if !pc.ModuleExists(mod) { |
| continue |
| } |
| r.Modules = append(r.Modules, &Module{ |
| Module: mod, |
| }) |
| } |
| } |
| } |
| |
| func (m *Module) splitByMajor(pc *proxy.Client) (modules []*Module) { |
| if stdlib.IsCmdModule(m.Module) || stdlib.IsStdModule(m.Module) || // no major versions for stdlib |
| !m.hasVersions() || // no versions -> no need to split |
| strings.HasPrefix(m.Module, "gopkg.in/") { // for now, don't attempt to split gopkg.in modules |
| return []*Module{m} |
| } |
| |
| base, _, ok := module.SplitPathVersion(m.Module) |
| if !ok { // couldn't parse module path, don't attempt to fix |
| return []*Module{m} |
| } |
| v1Mod := modulePath(base, v0v1) |
| rawMajorMap := m.byMajor() |
| validated := make(map[string]*allVersions) |
| |
| for maj, av := range rawMajorMap { |
| mod := modulePath(base, maj) |
| // If the module at the major version doesn't exist, add the |
| // version to the v1 module. |
| if mod == v1Mod || !pc.ModuleExists(mod) { |
| if validated[v1Mod] == nil { |
| validated[v1Mod] = new(allVersions) |
| } |
| validated[v1Mod].add(av) |
| continue |
| } |
| validated[mod] = av |
| } |
| |
| // Ensure that the original module mentioned is preserved, |
| // if it exists, even if there are now no versions associated |
| // with it. |
| original := m.Module |
| if _, ok := validated[original]; !ok { |
| if pc.ModuleExists(original) { |
| validated[original] = &allVersions{} |
| } |
| } |
| |
| for mod, av := range validated { |
| mc := m.copy() |
| mc.Module = mod |
| mc.Versions = av.standard |
| mc.UnsupportedVersions = av.unsupported |
| mc.NonGoVersions = av.nonGo |
| if !inVulnerableRange(mc.Versions, mc.VulnerableAt) { |
| mc.VulnerableAt = nil // needs to be re-generated |
| } |
| if mod == v1Mod { |
| addIncompatible(mc, pc) |
| } |
| canonicalize(mc, pc) |
| modules = append(modules, mc) |
| } |
| |
| return modules |
| } |
| |
| func inVulnerableRange(vs Versions, v *Version) bool { |
| if v == nil { |
| return false |
| } |
| |
| rs, err := vs.ToSemverRanges() |
| if err != nil { |
| return false |
| } |
| affected, err := osvutils.AffectsSemver(rs, v.Version) |
| if err != nil { |
| return false |
| } |
| |
| return affected |
| } |
| |
| var transforms = map[string]string{ |
| "github.com/mattermost/mattermost/server": "github.com/mattermost/mattermost-server", |
| "github.com/mattermost/mattermost/server/v5": "github.com/mattermost/mattermost-server/v5", |
| "github.com/mattermost/mattermost/server/v6": "github.com/mattermost/mattermost-server/v6", |
| } |
| |
| func transform(m string) string { |
| if t, ok := transforms[m]; ok { |
| return t |
| } |
| return m |
| } |
| |
| func modulePath(prefix, pathMajor string) string { |
| raw := func(prefix, pathMajor string) string { |
| if pathMajor == v0v1 { |
| return prefix |
| } |
| return prefix + "/" + pathMajor |
| } |
| return transform(raw(prefix, pathMajor)) |
| } |
| |
| func (m *Module) copy() *Module { |
| return &Module{ |
| Module: m.Module, |
| Versions: m.Versions.copy(), |
| NonGoVersions: m.NonGoVersions.copy(), |
| UnsupportedVersions: m.UnsupportedVersions.copy(), |
| VulnerableAt: m.VulnerableAt.copy(), |
| VulnerableAtRequires: slices.Clone(m.VulnerableAtRequires), |
| Packages: copyPackages(m.Packages), |
| FixLinks: slices.Clone(m.FixLinks), |
| } |
| } |
| |
| func (vs Versions) copy() Versions { |
| if vs == nil { |
| return nil |
| } |
| vsc := make(Versions, len(vs)) |
| for i, v := range vs { |
| vsc[i] = v.copy() |
| } |
| return vsc |
| } |
| |
| func (v *Version) copy() *Version { |
| if v == nil { |
| return nil |
| } |
| return &Version{ |
| Type: v.Type, |
| Version: v.Version, |
| } |
| } |
| |
| func copyPackages(ps []*Package) []*Package { |
| if ps == nil { |
| return nil |
| } |
| psc := make([]*Package, len(ps)) |
| for i, p := range ps { |
| psc[i] = p.copy() |
| } |
| return psc |
| } |
| |
| func (p *Package) copy() *Package { |
| if p == nil { |
| return nil |
| } |
| return &Package{ |
| Package: p.Package, |
| GOOS: slices.Clone(p.GOOS), |
| GOARCH: slices.Clone(p.GOARCH), |
| Symbols: slices.Clone(p.Symbols), |
| DerivedSymbols: slices.Clone(p.DerivedSymbols), |
| ExcludedSymbols: slices.Clone(p.ExcludedSymbols), |
| SkipFixSymbols: p.SkipFixSymbols, |
| } |
| } |
| |
| const ( |
| v0 = "v0" |
| v1 = "v1" |
| v0v1 = "v0 or v1" |
| ) |
| |
| func major(v string) string { |
| m := version.Major(v) |
| if m == v0 || m == v1 { |
| return v0v1 |
| } |
| return m |
| } |
| |
| type allVersions struct { |
| standard, unsupported, nonGo Versions |
| } |
| |
| func (a *allVersions) add(b *allVersions) { |
| if b == nil { |
| return |
| } |
| a.standard = append(a.standard, b.standard...) |
| a.unsupported = append(a.unsupported, b.unsupported...) |
| a.nonGo = append(a.nonGo, b.nonGo...) |
| } |
| |
| func (m *Module) byMajor() map[string]*allVersions { |
| mp := make(map[string]*allVersions) |
| getMajor := func(v *Version) string { |
| maj := major(v.Version) |
| if mp[maj] == nil { |
| mp[maj] = new(allVersions) |
| } |
| return maj |
| } |
| for _, v := range m.Versions { |
| maj := getMajor(v) |
| mp[maj].standard = append(mp[maj].standard, v) |
| } |
| for _, v := range m.UnsupportedVersions { |
| maj := getMajor(v) |
| mp[maj].unsupported = append(mp[maj].unsupported, v) |
| } |
| for _, v := range m.NonGoVersions { |
| maj := getMajor(v) |
| mp[maj].nonGo = append(mp[maj].nonGo, v) |
| } |
| return mp |
| } |
| |
| // canonicalize attempts to canonicalize the module path, |
| // and updates the module path and packages list if successful. |
| // Modifies m. |
| // |
| // Does nothing if the module path is already canonical, or isn't recognized |
| // by the proxy at all. |
| func canonicalize(m *Module, pc *proxy.Client) { |
| if len(m.Versions) == 0 { |
| return // no versions, don't attempt to fix |
| } |
| |
| canonical, err := commonCanonical(m, pc) |
| if err != nil { |
| return // no consistent canonical version found, don't attempt to fix |
| } |
| |
| original := m.Module |
| m.Module = canonical |
| |
| // Fix any package paths. |
| for _, p := range m.Packages { |
| if strings.HasPrefix(p.Package, original) { |
| p.Package = canonical + strings.TrimPrefix(p.Package, original) |
| } |
| } |
| } |
| |
| func commonCanonical(m *Module, pc *proxy.Client) (string, error) { |
| if len(m.Versions) == 0 { |
| return m.Module, nil |
| } |
| |
| canonical, err := pc.CanonicalModulePath(m.Module, m.Versions[0].Version) |
| if err != nil { |
| return "", err |
| } |
| |
| for _, v := range m.Versions { |
| current, err := pc.CanonicalModulePath(m.Module, v.Version) |
| if err != nil { |
| return "", err |
| } |
| if current != canonical { |
| return "", fmt.Errorf("inconsistent canonical module paths: %s and %s", canonical, current) |
| } |
| } |
| return canonical, nil |
| } |
| |
| // addIncompatible adds "+incompatible" to all versions where module@version |
| // does not exist but module@version+incompatible does exist. |
| // TODO(https://go.dev/issue/61769): Consider making this work for |
| // non-canonical versions too (example: GHSA-w4xh-w33p-4v29). |
| func addIncompatible(m *Module, pc *proxy.Client) { |
| tryAdd := func(v string) string { |
| if v == "" { |
| return v |
| } |
| if major(v) == v0v1 { |
| return v // +incompatible does not apply for major versions < 2 |
| } |
| if pc.ModuleExistsAtTaggedVersion(m.Module, v) { |
| return v // module@version is already OK |
| } |
| if vi := v + "+incompatible"; pc.ModuleExistsAtTaggedVersion(m.Module, vi) { |
| return vi |
| } |
| return v // module@version+incompatible doesn't exist |
| } |
| for i, v := range m.Versions { |
| m.Versions[i].Version = tryAdd(v.Version) |
| } |
| } |
| |
| func sortModules(ms []*Module) { |
| sort.SliceStable(ms, func(i, j int) bool { |
| m1, m2 := ms[i], ms[j] |
| |
| // Break ties by versions, assuming the version list is sorted. |
| // If needed, further break ties by packages. |
| if m1.Module == m2.Module { |
| byPackage := func(m1, m2 *Module) bool { |
| pkgs1, pkgs2 := m1.Packages, m2.Packages |
| if len(pkgs1) == 0 { |
| return true |
| } else if len(pkgs2) == 0 { |
| return false |
| } |
| return pkgs1[0].Package < pkgs2[0].Package |
| } |
| |
| vr1, vr2 := m1.Versions, m2.Versions |
| if len(vr1) == 0 && len(vr2) == 0 { |
| return byPackage(m1, m2) |
| } else if len(vr1) == 0 { |
| return true |
| } else if len(vr2) == 0 { |
| return false |
| } |
| |
| v1, v2 := vr1[0], vr2[0] |
| if v1.Version == v2.Version { |
| return byPackage(m1, m2) |
| } |
| |
| return version.Before(v1.Version, v2.Version) |
| } |
| |
| // Sort by module base name then major version. |
| base1, major1, ok1 := module.SplitPathVersion(m1.Module) |
| base2, major2, ok2 := module.SplitPathVersion(m2.Module) |
| if !ok1 || !ok2 { |
| return m1.Module < m2.Module |
| } |
| |
| if base1 == base2 { |
| i1, ok1 := majorToInt(major1) |
| i2, ok2 := majorToInt(major2) |
| if ok1 && ok2 { |
| return i1 < i2 |
| } |
| return major1 < major2 |
| } |
| |
| return base1 < base2 |
| }) |
| } |
| |
| // merge merges all modules with the same module & package info |
| // (but possibly different versions) into one. |
| func merge(ms []*Module) ([]*Module, error) { |
| type compMod struct { |
| path string |
| packages string // sorted, comma separated list of package names |
| } |
| |
| toCompMod := func(m *Module) compMod { |
| var packages []string |
| for _, p := range m.Packages { |
| packages = append(packages, p.Package) |
| } |
| return compMod{ |
| path: m.Module, |
| packages: strings.Join(packages, ","), |
| } |
| } |
| |
| // only run if m1 and m2 are same except versions |
| // deletes vulnerable_at if set |
| merge := func(m1, m2 *Module) (*Module, error) { |
| merged, err := m1.Versions.mergeStrict(m2.Versions) |
| if err != nil { |
| return nil, fmt.Errorf("could not merge versions of module %s: %w", m1.Module, err) |
| } |
| return &Module{ |
| Module: m1.Module, |
| Versions: merged, |
| UnsupportedVersions: m1.UnsupportedVersions.merge(m2.UnsupportedVersions), |
| NonGoVersions: m1.NonGoVersions.merge(m2.NonGoVersions), |
| Packages: m1.Packages, |
| }, nil |
| } |
| |
| modules := make(map[compMod]*Module) |
| for _, m := range ms { |
| c := toCompMod(m) |
| mod, ok := modules[c] |
| if !ok { |
| modules[c] = m |
| } else { |
| merged, err := merge(mod, m) |
| if err != nil { |
| // For now, bail out if any module can't be merged. |
| // This could be improved by continuing to try even if |
| // some merges fail. |
| return nil, err |
| } |
| modules[c] = merged |
| } |
| } |
| |
| return maps.Values(modules), nil |
| } |
| |
| func (v Versions) merge(v2 Versions) Versions { |
| merged := append(slices.Clone(v), v2...) |
| merged.fix() |
| return merged |
| } |
| |
| func (v Versions) mergeStrict(v2 Versions) (merged Versions, _ error) { |
| merged = v.merge(v2) |
| ranges, err := merged.ToSemverRanges() |
| if err != nil { |
| return nil, err |
| } |
| if err := osvutils.ValidateRanges(ranges); err != nil { |
| return nil, err |
| } |
| return merged, nil |
| } |
| |
| // FixReferences deletes some unneeded references, and attempts to fix reference types. |
| // Modifies r. |
| // |
| // Deletes: |
| // - "package"-type references |
| // - Go advisory references (these are redundant for us) |
| // - all advisories except the "best" one (if applicable) |
| // |
| // Changes: |
| // - reference type to "advisory" for GHSA and CVE links. |
| // - reference type to "fix" for Github pull requests and commit links in one of |
| // the affected modules |
| // - reference type to "report" for Github issues in one of |
| // the affected modules |
| func (r *Report) FixReferences() { |
| for _, ref := range r.References { |
| ref.URL = fixURL(ref.URL) |
| } |
| r.References = slices.DeleteFunc(r.References, func(ref *Reference) bool { |
| return ref.Type == osv.ReferenceTypePackage || |
| idstr.IsGoAdvisory(ref.URL) |
| }) |
| |
| re := newRE(r) |
| |
| aliases := r.Aliases() |
| for _, ref := range r.References { |
| switch re.Type(ref.URL, aliases) { |
| case urlTypeAdvisory: |
| ref.Type = osv.ReferenceTypeAdvisory |
| case urlTypeIssue: |
| ref.Type = osv.ReferenceTypeReport |
| case urlTypeFix: |
| ref.Type = osv.ReferenceTypeFix |
| case urlTypeWeb: |
| ref.Type = osv.ReferenceTypeWeb |
| } |
| } |
| |
| // If this is a reviewed report, attempt to find the "best" advisory and delete others. |
| if r.IsReviewed() { |
| if bestAdvisory := bestAdvisory(r.References, r.Aliases()); bestAdvisory != "" { |
| isNotBest := func(ref *Reference) bool { |
| return ref.Type == osv.ReferenceTypeAdvisory && ref.URL != bestAdvisory |
| } |
| r.References = slices.DeleteFunc(r.References, isNotBest) |
| } |
| } |
| |
| if r.countAdvisories() == 0 && r.needsAdvisory() { |
| if r.hasExternalSource() { |
| r.addSourceAdvisory() |
| } else if as := r.Aliases(); len(as) > 0 { |
| r.addAdvisory(as[0]) |
| } |
| } |
| |
| slices.SortFunc(r.References, func(a *Reference, b *Reference) int { |
| if a.Type == b.Type { |
| return strings.Compare(a.URL, b.URL) |
| } |
| return strings.Compare(string(a.Type), string(b.Type)) |
| }) |
| |
| if len(r.References) == 0 { |
| r.References = nil |
| } |
| } |
| |
| func (r *Report) hasExternalSource() bool { |
| return r.SourceMeta != nil && idstr.IsIdentifier(r.SourceMeta.ID) |
| } |
| |
| func (r *Report) addAdvisory(id string) { |
| if link := idstr.AdvisoryLink(id); link != "" { |
| r.References = append(r.References, &Reference{ |
| Type: osv.ReferenceTypeAdvisory, |
| URL: link, |
| }) |
| } |
| } |
| |
| func (r *Report) addSourceAdvisory() { |
| srcID := r.SourceMeta.ID |
| for _, ref := range r.References { |
| if idstr.IsAdvisoryFor(ref.URL, srcID) { |
| ref.Type = osv.ReferenceTypeAdvisory |
| return |
| } |
| } |
| r.addAdvisory(srcID) |
| } |
| |
| // bestAdvisory returns the URL of the "best" advisory in the references, |
| // or ("", false) if none can be found. |
| // Repository-level GHSAs are considered the best, followed by regular |
| // GHSAs, followed by CVEs. |
| // For now, if there are advisories mentioning two or more |
| // aliases of the same type, we don't try to determine which is best. |
| // (For example, if there are two advisories, referencing GHSA-1 and GHSA-2, we leave it |
| // to the triager to pick the best one.) |
| func bestAdvisory(refs []*Reference, aliases []string) string { |
| bestAdvisory := "" |
| bestType := advisoryTypeUnknown |
| ghsas, cves := make(map[string]bool), make(map[string]bool) |
| for _, ref := range refs { |
| if ref.Type != osv.ReferenceTypeAdvisory { |
| continue |
| } |
| alias, ok := idstr.IsAdvisoryForOneOf(ref.URL, aliases) |
| if !ok { |
| continue |
| } |
| if t := advisoryTypeOf(ref.URL); t > bestType { |
| bestAdvisory = ref.URL |
| bestType = t |
| } |
| |
| if idstr.IsGHSA(alias) { |
| ghsas[alias] = true |
| } else if idstr.IsCVE(alias) { |
| cves[alias] = true |
| } |
| } |
| |
| if len(ghsas) > 1 || len(cves) > 1 { |
| return "" |
| } |
| |
| return bestAdvisory |
| } |
| |
| type urlType int |
| |
| const ( |
| urlTypeUnknown urlType = iota |
| urlTypeIssue |
| urlTypeFix |
| urlTypeAdvisory |
| urlTypeWeb |
| ) |
| |
| func (re *reportRE) Type(url string, aliases []string) urlType { |
| if _, ok := idstr.IsAdvisoryForOneOf(url, aliases); ok { |
| return urlTypeAdvisory |
| } else if idstr.IsAdvisory(url) { |
| // URLs that point to other vulns should not be considered |
| // advisories for this vuln. |
| return urlTypeWeb |
| } |
| |
| switch { |
| case re.issue.MatchString(url): |
| return urlTypeIssue |
| case re.fix.MatchString(url): |
| return urlTypeFix |
| } |
| |
| return urlTypeUnknown |
| } |
| |
| type advisoryType int |
| |
| // Advisory link types in ascending order of (likely) quality. |
| // In general, repo-level GHSAs tend to be the best because |
| // they are more likely to be directly created by a maintainer. |
| const ( |
| advisoryTypeUnknown advisoryType = iota |
| advisoryTypeCVE |
| advisoryTypeGHSA |
| advisoryTypeGHSARepo |
| ) |
| |
| func advisoryTypeOf(url string) advisoryType { |
| switch { |
| case idstr.IsCVELink(url): |
| return advisoryTypeCVE |
| case idstr.IsGHSAGlobalLink(url): |
| return advisoryTypeGHSA |
| case idstr.IsGHSARepoLink(url): |
| return advisoryTypeGHSARepo |
| } |
| return advisoryTypeUnknown |
| } |
| |
| type reportRE struct { |
| issue, fix *regexp.Regexp |
| } |
| |
| func newRE(r *Report) *reportRE { |
| oneOfRE := func(s []string) string { |
| return `(` + strings.Join(s, "|") + `)` |
| } |
| |
| // For now, this will not attempt to fix reference types for |
| // modules whose canonical names are different from their github path. |
| var modulePaths []string |
| for _, m := range r.Modules { |
| modulePaths = append(modulePaths, m.Module) |
| } |
| moduleRE := oneOfRE(modulePaths) |
| |
| return &reportRE{ |
| issue: regexp.MustCompile(`^https://` + moduleRE + `/issue(s?)/.*$`), |
| fix: regexp.MustCompile(`^https://` + moduleRE + `/(commit(s?)|pull)/.*$`), |
| } |
| } |