blob: 65d7faa0d1fa7334a78c0e3b7c86f089610790fb [file] [log] [blame]
// 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
}