blob: 477865ef0ec62285da6cf65f70d9470991ffd5d5 [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"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"golang.org/x/exp/maps"
"golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/osv"
"golang.org/x/vulndb/internal/stdlib"
)
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"
)
// 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 {
var credits []osv.Credit
for _, credit := range r.Credits {
credits = append(credits, osv.Credit{
Name: credit,
})
}
var withdrawn *osv.Time
if r.Withdrawn != nil {
withdrawn = &osv.Time{Time: *r.Withdrawn}
}
// 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
if details == "" {
details = Description(r.Summary)
}
entry := osv.Entry{
ID: r.ID,
Published: osv.Time{Time: r.Published},
Modified: osv.Time{Time: lastModified},
Withdrawn: withdrawn,
Related: r.Related,
Summary: toParagraphs(r.Summary.String()),
Details: toParagraphs(details.String()),
Credits: credits,
SchemaVersion: SchemaVersion,
DatabaseSpecific: &osv.DatabaseSpecific{URL: GoAdvisory(r.ID)},
}
for _, m := range r.Modules {
entry.Affected = append(entry.Affected, toAffected(m))
}
for _, ref := range r.References {
entry.References = append(entry.References, osv.Reference{
Type: ref.Type,
URL: ref.URL,
})
}
entry.Aliases = r.Aliases()
return entry
}
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 AffectedRanges(versions []VersionRange) []osv.Range {
a := osv.Range{Type: osv.RangeTypeSemver}
if len(versions) == 0 || versions[0].Introduced == "" {
a.Events = append(a.Events, osv.RangeEvent{Introduced: "0"})
}
for _, v := range versions {
if v.Introduced != "" {
a.Events = append(a.Events, osv.RangeEvent{Introduced: v.Introduced})
}
if v.Fixed != "" {
a.Events = append(a.Events, osv.RangeEvent{Fixed: v.Fixed})
}
}
return []osv.Range{a}
}
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 {
name := m.Module
switch name {
case stdlib.ModulePath:
name = osv.GoStdModulePath
case stdlib.ToolchainModulePath:
name = osv.GoCmdModulePath
}
return osv.Affected{
Module: osv.Module{
Path: name,
Ecosystem: osv.GoEcosystem,
},
Ranges: AffectedRanges(m.Versions),
EcosystemSpecific: &osv.EcosystemSpecific{
Packages: toOSVPackages(m.Packages),
},
}
}