| // Copyright 2022 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 ( | |
| "encoding/json" | |
| "fmt" | |
| "os" | |
| "path/filepath" | |
| "regexp" | |
| "sort" | |
| "strings" | |
| "time" | |
| "golang.org/x/exp/maps" | |
| "golang.org/x/vulndb/internal/derrors" | |
| "golang.org/x/vulndb/internal/idstr" | |
| "golang.org/x/vulndb/internal/osv" | |
| "golang.org/x/vulndb/internal/stdlib" | |
| "golang.org/x/vulndb/internal/version" | |
| ) | |
| var ( | |
| // osvDir is the name of the directory in the vulndb repo that | |
| // contains reports. | |
| OSVDir = "data/osv" | |
| // SchemaVersion is used to indicate which version of the OSV schema a | |
| // particular vulnerability was exported with. | |
| SchemaVersion = "1.3.1" | |
| ) | |
| func (r *Report) nonGoExplanation() string { | |
| const nonGoExplanation = `NOTE: The source advisory for this report | |
| contains additional versions that could not be automatically mapped to standard | |
| Go module versions. | |
| (If this is causing false-positive reports from vulnerability scanners, | |
| please suggest an edit to the report.) | |
| The additional affected modules and versions are: ` | |
| vs := r.nonGoVersionsStr() | |
| if vs != "" { | |
| return fmt.Sprintf("%s%s", nonGoExplanation, vs) | |
| } | |
| return "" | |
| } | |
| func (r *Report) nonGoVersionsStr() string { | |
| var vs []string | |
| for _, m := range r.Modules { | |
| if s := m.NonGoVersions.verboseString(); s != "" { | |
| vs = append(vs, fmt.Sprintf("%s %s", m.Module, s)) | |
| } | |
| } | |
| return strings.Join(vs, "; ") + "." | |
| } | |
| func (v Versions) verboseString() string { | |
| if len(v) == 0 { | |
| return "" | |
| } | |
| pairs := v.collectRangePairs() | |
| var vs []string | |
| for _, p := range pairs { | |
| var s string | |
| if p.intro == "" && p.fixed == "" { | |
| // If neither field is set, the vuln applies to all versions. | |
| // Leave it blank, the template will render it properly. | |
| s = "" | |
| } else if p.intro == "" { | |
| s = "before " + p.fixed | |
| } else if p.fixed == "" { | |
| s = p.intro + " and later" | |
| } else { | |
| s = "from " + p.intro + " before " + p.fixed | |
| } | |
| vs = append(vs, s) | |
| } | |
| return strings.Join(vs, ", ") | |
| } | |
| // A pair is like an osv.Range, but each pair is a self-contained 2-tuple | |
| // (introduced version, fixed version). | |
| type pair struct { | |
| intro, fixed string | |
| } | |
| func (vs Versions) collectRangePairs() []pair { | |
| var ( | |
| ps []pair | |
| p pair | |
| ) | |
| addPrefix := func(s *string) { | |
| const semverPrefix = "v" | |
| if *s != "" && version.IsValid(*s) { | |
| *s = semverPrefix + *s | |
| } | |
| } | |
| for _, v := range vs { | |
| if v.IsIntroduced() { | |
| // We expected Introduced and Fixed to alternate, but if | |
| // p.intro != "", then they don't. | |
| // Keep going in that case, ignoring the first Introduced. | |
| p.intro = v.Version | |
| if p.intro == "0" { | |
| p.intro = "" | |
| } | |
| addPrefix(&p.intro) | |
| } | |
| if v.IsFixed() { | |
| p.fixed = v.Version | |
| addPrefix(&p.fixed) | |
| ps = append(ps, p) | |
| p = pair{} | |
| } | |
| } | |
| return ps | |
| } | |
| // ToOSV creates an osv.Entry for a report. | |
| // lastModified is the time the report should be considered to have | |
| // been most recently modified. | |
| func (r *Report) ToOSV(lastModified time.Time) (osv.Entry, error) { | |
| var credits []osv.Credit | |
| for _, credit := range r.Credits { | |
| credits = append(credits, osv.Credit{ | |
| Name: credit, | |
| }) | |
| } | |
| entry := osv.Entry{ | |
| ID: r.ID, | |
| Published: osv.Time{Time: r.Published}, | |
| Modified: osv.Time{Time: lastModified}, | |
| Withdrawn: r.Withdrawn, | |
| Related: r.Related, | |
| Summary: toParagraphs(r.Summary.String()), | |
| Credits: credits, | |
| SchemaVersion: SchemaVersion, | |
| DatabaseSpecific: &osv.DatabaseSpecific{ | |
| URL: idstr.GoAdvisory(r.ID), | |
| ReviewStatus: r.ReviewStatus.ToOSV(), | |
| }, | |
| } | |
| hasNonGoVersions := false | |
| for _, m := range r.Modules { | |
| affected, err := toAffected(m) | |
| if err != nil { | |
| return osv.Entry{}, err | |
| } | |
| entry.Affected = append(entry.Affected, affected) | |
| if len(m.NonGoVersions) != 0 { | |
| hasNonGoVersions = true | |
| } | |
| } | |
| for _, ref := range r.References { | |
| entry.References = append(entry.References, osv.Reference{ | |
| Type: ref.Type, | |
| URL: ref.URL, | |
| }) | |
| } | |
| entry.Aliases = r.Aliases() | |
| // If the report has no description, use the summary for now. | |
| // TODO(https://go.dev/issues/61201): Remove this once pkgsite and | |
| // govulncheck can robustly display summaries in place of details. | |
| details := r.Description.String() | |
| if details == "" { | |
| details = r.Summary.String() | |
| } | |
| // Add an explanation about non-Go versions if applicable. | |
| if hasNonGoVersions && !r.IsReviewed() { | |
| if !strings.HasSuffix(details, ".") { | |
| details = fmt.Sprintf("%s.", details) | |
| } | |
| details = fmt.Sprintf("%s\n\n%s", details, r.nonGoExplanation()) | |
| } | |
| entry.Details = toParagraphs(details) | |
| return entry, nil | |
| } | |
| func (r *Report) OSVFilename() string { | |
| return filepath.Join(OSVDir, r.ID+".json") | |
| } | |
| // ReadOSV reads an osv.Entry from a file. | |
| func ReadOSV(filename string) (entry osv.Entry, err error) { | |
| derrors.Wrap(&err, "ReadOSV(%s)", filename) | |
| if err = UnmarshalFromFile(filename, &entry); err != nil { | |
| return osv.Entry{}, err | |
| } | |
| return entry, nil | |
| } | |
| func UnmarshalFromFile(path string, v any) (err error) { | |
| content, err := os.ReadFile(path) | |
| if err != nil { | |
| return err | |
| } | |
| if err = json.Unmarshal(content, v); err != nil { | |
| return err | |
| } | |
| return nil | |
| } | |
| // ModulesForEntry returns the list of modules affected by an OSV entry. | |
| func ModulesForEntry(entry osv.Entry) []string { | |
| mods := map[string]bool{} | |
| for _, a := range entry.Affected { | |
| mods[a.Module.Path] = true | |
| } | |
| return maps.Keys(mods) | |
| } | |
| func (v *Version) ToRangeEvent() (osv.RangeEvent, error) { | |
| switch t := v.Type; t { | |
| case VersionTypeFixed: | |
| return osv.RangeEvent{Fixed: v.Version}, nil | |
| case VersionTypeIntroduced: | |
| return osv.RangeEvent{Introduced: v.Version}, nil | |
| default: | |
| return osv.RangeEvent{}, fmt.Errorf("version type %s not supported for osv.RangeEvent", t) | |
| } | |
| } | |
| var zeroEvent = osv.RangeEvent{Introduced: "0"} | |
| func (vs Versions) ToSemverRanges() ([]osv.Range, error) { | |
| t := osv.RangeTypeSemver | |
| a, err := vs.ToRangesWithType(t) | |
| if err != nil { | |
| return nil, err | |
| } else if a == nil { | |
| return []osv.Range{{Type: t, Events: []osv.RangeEvent{zeroEvent}}}, nil | |
| } | |
| return a, nil | |
| } | |
| func (vs Versions) ToRangesWithType(t osv.RangeType) ([]osv.Range, error) { | |
| if len(vs) == 0 { | |
| return nil, nil | |
| } | |
| a := osv.Range{Type: t} | |
| if !vs[0].IsIntroduced() { | |
| a.Events = append(a.Events, zeroEvent) | |
| } | |
| for _, v := range vs { | |
| re, err := v.ToRangeEvent() | |
| if err != nil { | |
| return nil, err | |
| } | |
| a.Events = append(a.Events, re) | |
| } | |
| return []osv.Range{a}, nil | |
| } | |
| var ( | |
| listMarker = regexp.MustCompile(`([\*\-\+>]|\d+[.\)]) [^\n]+`) | |
| spaces = regexp.MustCompile(`[[:space:]]+`) | |
| paragraphBreak = regexp.MustCompile(`\s*\n{2,}\s*`) | |
| ) | |
| // toParagraphs removes unnecessary whitespace (spaces, tabs and newlines) from | |
| // a string, but preserves paragraph breaks (indicated by two consecutive | |
| // newlines), and Markdown-style list breaks. | |
| func toParagraphs(s string) string { | |
| if len(s) == 0 { | |
| return "" | |
| } | |
| var result strings.Builder | |
| result.Grow(len(s)) | |
| for i, line := range strings.Split(strings.TrimSpace(s), "\n") { | |
| // Replace consecutive space characters with single spaces. | |
| line = spaces.ReplaceAllString(strings.TrimSpace(line), " ") | |
| // An empty line represents a paragraph break. | |
| if len(line) == 0 { | |
| result.WriteString("\n\n") | |
| continue | |
| } | |
| if i > 0 { | |
| // Preserve line break if the line starts with a Markdown list marker. | |
| if loc := listMarker.FindStringIndex(line); loc != nil && loc[0] == 0 { | |
| result.WriteRune('\n') | |
| } else { | |
| result.WriteRune(' ') | |
| } | |
| } | |
| result.WriteString(line) | |
| } | |
| // Replace instances of 2 or more newlines with exactly two newlines. | |
| return paragraphBreak.ReplaceAllString(result.String(), "\n\n") | |
| } | |
| func toOSVPackages(pkgs []*Package) (imps []osv.Package) { | |
| for _, p := range pkgs { | |
| syms := append([]string{}, p.Symbols...) | |
| syms = append(syms, p.DerivedSymbols...) | |
| sort.Strings(syms) | |
| imps = append(imps, osv.Package{ | |
| Path: p.Package, | |
| GOOS: p.GOOS, | |
| GOARCH: p.GOARCH, | |
| Symbols: syms, | |
| }) | |
| } | |
| return imps | |
| } | |
| func toAffected(m *Module) (osv.Affected, error) { | |
| name := m.Module | |
| switch name { | |
| case stdlib.ModulePath: | |
| name = osv.GoStdModulePath | |
| case stdlib.ToolchainModulePath: | |
| name = osv.GoCmdModulePath | |
| } | |
| ranges, err := m.Versions.ToSemverRanges() | |
| if err != nil { | |
| return osv.Affected{}, err | |
| } | |
| customRanges, err := m.NonGoVersions.ToRangesWithType(osv.RangeTypeEcosystem) | |
| if err != nil { | |
| return osv.Affected{}, err | |
| } | |
| return osv.Affected{ | |
| Module: osv.Module{ | |
| Path: name, | |
| Ecosystem: osv.GoEcosystem, | |
| }, | |
| Ranges: ranges, | |
| EcosystemSpecific: &osv.EcosystemSpecific{ | |
| Packages: toOSVPackages(m.Packages), | |
| CustomRanges: customRanges, | |
| }, | |
| }, nil | |
| } |