blob: aa0b19f2bb36014a3a901d0376b16396fe2e3d12 [file] [log] [blame]
// Copyright 2021 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 contains functionality for parsing and linting YAML reports
// in reports/.
package report
import (
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/exp/slices"
"golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/idstr"
"golang.org/x/vulndb/internal/osv"
"golang.org/x/vulndb/internal/stdlib"
"gopkg.in/yaml.v3"
)
type Module struct {
Module string `yaml:",omitempty"`
Versions Versions `yaml:",omitempty"`
// Versions that are not known to the module proxy, but
// that may be useful to display to humans.
NonGoVersions Versions `yaml:"non_go_versions,omitempty"`
// Version types that exist in OSV, but we don't support.
// These may be added when automatically creating a report,
// but must be deleted in order to pass lint checks.
UnsupportedVersions Versions `yaml:"unsupported_versions,omitempty"`
// Known-vulnerable version, to use when performing static analysis or
// other techniques on a vulnerable version of the package.
//
// In general, we want to use the most recent vulnerable version of
// the package. Determining this programmatically is difficult, especially
// for packages without tagged versions, so we specify it manually here.
VulnerableAt *Version `yaml:"vulnerable_at,omitempty"`
// Additional list of module@version to require when performing static analysis.
// It is rare that we need to specify this.
VulnerableAtRequires []string `yaml:"vulnerable_at_requires,omitempty"`
Packages []*Package `yaml:",omitempty"`
// Used to determine vulnerable symbols for a given module. If not populated,
// the fix links found in the report's References field will be used.
// Only auto-added if the -update flag is passed to vulnreport.
FixLinks []string `yaml:"fix_links,omitempty"`
// Do not lint this module.
// Only for use in exceptional circumstances, such as when a malicious
// module has been deleted from the proxy entirely.
SkipLint bool `yaml:"skip_lint,omitempty"`
}
type Version struct {
Version string `yaml:",omitempty"`
Type VersionType `yaml:",omitempty"`
}
type VersionType string
const (
VersionTypeIntroduced = "introduced"
VersionTypeFixed = "fixed"
VersionTypeVulnerableAt = "vulnerable_at"
)
func Introduced(v string) *Version {
return &Version{Version: v, Type: VersionTypeIntroduced}
}
func Fixed(v string) *Version {
return &Version{Version: v, Type: VersionTypeFixed}
}
type VulnerableAtVersion Version
func VulnerableAt(v string) *Version {
return &Version{Version: v, Type: VersionTypeVulnerableAt}
}
func (v *Version) IsIntroduced() bool {
return v.Type == VersionTypeIntroduced
}
func (v *Version) IsFixed() bool {
return v.Type == VersionTypeFixed
}
func (v *Version) MarshalYAML() (any, error) {
return v.Version, nil
}
func (v *Version) UnmarshalYAML(n *yaml.Node) error {
if n.Kind != yaml.ScalarNode {
return fmt.Errorf("line %d: report.Version must be a scalar node", n.Line)
}
v.Type = VersionTypeVulnerableAt
v.Version = n.Value
return nil
}
type Versions []*Version
func (vs Versions) MarshalYAML() (any, error) {
result := make([]map[string]string, len(vs))
for i, v := range vs {
result[i] = map[string]string{
strings.ToLower(string(v.Type)): v.Version,
}
}
return result, nil
}
func (vs *Versions) UnmarshalYAML(n *yaml.Node) error {
if n.Kind != yaml.SequenceNode {
return &yaml.TypeError{Errors: []string{
fmt.Sprintf("line %d: report.Versions must be a sequence node (got %#v)", n.Line, n),
}}
}
*vs = make(Versions, 0)
for _, vn := range n.Content {
// Support the old way of encoding introduced/fixed versions.
if vn.Kind == yaml.MappingNode && len(vn.Content) == 4 {
// Format is
// - introduced: v.v.v
// fixed: v.v.v
if VersionType(vn.Content[0].Value) == VersionTypeIntroduced {
*vs = append(*vs, &Version{
Type: VersionType(vn.Content[0].Value),
Version: vn.Content[1].Value,
},
&Version{
Type: VersionType(vn.Content[2].Value),
Version: vn.Content[3].Value,
},
)
continue
}
// Format is
// - version: v.v.v
// type: t
*vs = append(*vs, &Version{
Type: VersionType(vn.Content[3].Value),
Version: vn.Content[1].Value,
})
continue
}
if vn.Kind != yaml.MappingNode || len(vn.Content) != 2 || vn.Content[0].Kind != yaml.ScalarNode || vn.Content[1].Kind != yaml.ScalarNode {
return &yaml.TypeError{Errors: []string{
fmt.Sprintf("line %d: report.Version must contain a mapping with one value (got %#v)", vn.Line, vn),
}}
}
*vs = append(*vs, &Version{
Type: VersionType(vn.Content[0].Value),
Version: vn.Content[1].Value,
})
}
return nil
}
type Package struct {
Package string `yaml:",omitempty"`
GOOS []string `yaml:"goos,omitempty"`
GOARCH []string `yaml:"goarch,omitempty"`
// Symbols originally identified as vulnerable.
Symbols []string `yaml:",omitempty"`
// Additional vulnerable symbols, computed from Symbols via static analysis
// or other technique.
DerivedSymbols []string `yaml:"derived_symbols,omitempty"`
// Symbols that may be considered vulnerable by automated tools,
// but have been determined (by a human) to actually not be vulnerable.
// For now, this field is respected only by the tool that finds derived
// symbols, but is not published to OSV or elsewhere (so, for example,
// govulncheck cannot consume it).
ExcludedSymbols []string `yaml:"excluded_symbols,omitempty"`
// Reason the package's symbols are already considered fixed and should not
// be checked or automatically updated.
SkipFixSymbols string `yaml:"skip_fix,omitempty"`
}
type CVEMeta struct {
ID string `yaml:",omitempty"`
CWE string `yaml:",omitempty"`
Description string `yaml:",omitempty"`
// Additional references that should be included in the CVE record
// but not the OSV. This is used to preserve references that have been
// added to a CVE by the CVE program that the Go team does not want
// to display via OSV. An example that uses this is GO-2022-0476.
References []string `yaml:",omitempty"`
}
// ExcludedType is the reason a report is excluded from the database.
//
// It must be one of the values in ExcludedTypes.
type ExcludedType string
const (
ExcludedNotImportable ExcludedType = "NOT_IMPORTABLE"
ExcludedNotGoCode ExcludedType = "NOT_GO_CODE"
ExcludedNotAVulnerability ExcludedType = "NOT_A_VULNERABILITY"
ExcludedEffectivelyPrivate ExcludedType = "EFFECTIVELY_PRIVATE"
ExcludedDependentVulnerabilty ExcludedType = "DEPENDENT_VULNERABILITY"
ExcludedLegacyFalsePositive ExcludedType = "LEGACY_FALSE_POSITIVE"
ExcludedWithdrawn ExcludedType = "WITHDRAWN"
)
// ExcludedTypes are the set of reasons a report may be excluded from the database.
// These are described in detail at
// https://go.googlesource.com/vulndb/+/refs/heads/master/doc/format.md.
var ExcludedTypes = []ExcludedType{
ExcludedNotImportable,
ExcludedNotGoCode,
ExcludedNotAVulnerability,
ExcludedEffectivelyPrivate,
ExcludedDependentVulnerabilty,
ExcludedLegacyFalsePositive,
ExcludedWithdrawn,
}
func (e *ExcludedType) IsValid() bool {
return slices.Contains(ExcludedTypes, *e)
}
func ToExcludedType(s string) (ExcludedType, bool) {
e := ExcludedType(s)
if !e.IsValid() {
return "", false
}
return e, true
}
const excludedLabelPrefix = "excluded: "
func (e ExcludedType) ToLabel() string {
return fmt.Sprintf("%s%s", excludedLabelPrefix, string(e))
}
func FromLabel(label string) (ExcludedType, bool) {
pre, er, ok := strings.Cut(label, excludedLabelPrefix)
if pre != "" || !ok {
return "", false
}
return ToExcludedType(er)
}
// A Reference is a link to some external resource.
//
// For ease of typing, References are represented in the YAML as a
// single-element mapping of type to URL.
type Reference osv.Reference
func (r *Reference) MarshalYAML() (any, error) {
return map[string]string{
strings.ToLower(string(r.Type)): r.URL,
}, nil
}
func (r *Reference) UnmarshalYAML(n *yaml.Node) error {
if n.Kind != yaml.MappingNode || len(n.Content) != 2 || n.Content[0].Kind != yaml.ScalarNode || n.Content[1].Kind != yaml.ScalarNode {
return &yaml.TypeError{Errors: []string{
fmt.Sprintf("line %d: report.Reference must contain a mapping with one value", n.Line),
}}
}
r.Type = osv.ReferenceType(strings.ToUpper(n.Content[0].Value))
r.URL = n.Content[1].Value
return nil
}
// A Note is a note about the report.
// May be typed or untyped (with Type left blank).
type Note struct {
Body string
Type NoteType
}
type NoteType string
const (
NoteTypeNone NoteType = ""
NoteTypeLint NoteType = "LINT"
NoteTypeFix NoteType = "FIX"
NoteTypeCreate NoteType = "CREATE"
)
func (n *Note) MarshalYAML() (any, error) {
if n.Type == NoteTypeNone {
return n.Body, nil
}
return map[string]string{
strings.ToLower(string(n.Type)): n.Body,
}, nil
}
func (n *Note) UnmarshalYAML(node *yaml.Node) error {
// Handle untyped notes.
if node.Kind == yaml.ScalarNode {
n.Type = NoteTypeNone
n.Body = node.Value
return nil
}
// Handle typed notes.
if node.Kind != yaml.MappingNode || len(node.Content) != 2 || node.Content[0].Kind != yaml.ScalarNode || node.Content[1].Kind != yaml.ScalarNode {
return &yaml.TypeError{Errors: []string{
fmt.Sprintf("line %d: typed Note must contain a mapping with one value", node.Line),
}}
}
n.Type = NoteType(strings.ToUpper(node.Content[0].Value))
n.Body = node.Content[1].Value
return nil
}
// Report represents a vulnerability report in the vulndb.
// Remember to update doc/format.md when this structure changes.
type Report struct {
ID string `yaml:",omitempty"`
// Excluded indicates an excluded report.
Excluded ExcludedType `yaml:",omitempty"`
Modules []*Module `yaml:",omitempty"`
// Summary is a short phrase describing the vulnerability.
Summary Summary `yaml:",omitempty"`
// Description is the CVE description from an existing CVE. If we are
// assigning a CVE ID ourselves, use CVEMetadata.Description instead.
Description Description `yaml:",omitempty"`
Published time.Time `yaml:",omitempty"`
Withdrawn *osv.Time `yaml:",omitempty"`
// CVE are CVE IDs for existing CVEs.
// If we are assigning a CVE ID ourselves, use CVEMetadata.ID instead.
CVEs []string `yaml:",omitempty"`
// GHSAs are the IDs of GitHub Security Advisories that match
// the above CVEs.
GHSAs []string `yaml:",omitempty"`
// Aliases from other databases that we don't (yet) know about.
// Not published to OSV.
UnknownAliases []string `yaml:"unknown_aliases,omitempty"`
// Related is a list of identifiers (e.g. CVEs or GHSAs)
// that are related to, but are not direct aliases of, this report.
Related []string `yaml:",omitempty"`
Credits []string `yaml:",omitempty"`
References []*Reference `yaml:",omitempty"`
// CVEMetadata is used to capture CVE information when we want to assign a
// CVE ourselves. If a CVE already exists for an issue, use the CVE field
// to fill in the ID string.
CVEMetadata *CVEMeta `yaml:"cve_metadata,omitempty"`
// Notes about the report. This field is ignored when creating
// OSV and CVE records. It can be used to document decisions made when
// creating the report, outstanding issues, or anything else worth
// mentioning.
Notes []*Note `yaml:",omitempty"`
// Metadata about how this report was generated.
// Not published to OSV.
SourceMeta *SourceMeta `yaml:"source,omitempty"`
ReviewStatus ReviewStatus `yaml:"review_status,omitempty"`
// Allow this report to be UNREVIEWED regardless of it's modules'
// priorities.
UnreviewedOK bool `yaml:"unreviewed_ok,omitempty"`
// (For unexcluded reports) The reason this report was previously
// excluded. Not published to OSV.
Unexcluded ExcludedType `yaml:"unexcluded,omitempty"`
}
type ReviewStatus int
const (
unknownReviewStatus ReviewStatus = iota
Reviewed
Unreviewed
NeedsReview
)
type statusMapping struct {
name string
osv osv.ReviewStatus
}
var statuses = []statusMapping{
Reviewed: {"REVIEWED", osv.ReviewStatusReviewed},
Unreviewed: {"UNREVIEWED", osv.ReviewStatusUnreviewed},
NeedsReview: {"NEEDS_REVIEW", osv.ReviewStatusUnreviewed},
}
func reviewStatusValues() []string {
var vals []string
for _, v := range statuses[Reviewed:] {
vals = append(vals, v.name)
}
return vals
}
func (r ReviewStatus) String() string {
return statuses[r].name
}
func (r ReviewStatus) ToOSV() osv.ReviewStatus {
return statuses[r].osv
}
func ToReviewStatus(s string) (ReviewStatus, bool) {
for stat, v := range statuses {
if s == v.name {
return ReviewStatus(stat), true
}
}
return 0, false
}
func (r ReviewStatus) IsValid() bool {
return r >= Reviewed && int(r) < len(statuses)
}
func (r ReviewStatus) MarshalYAML() (any, error) {
if !r.IsValid() {
return nil, fmt.Errorf("MarshalYAML: unrecognized review status: %s", r)
}
return r.String(), nil
}
func (r *ReviewStatus) UnmarshalYAML(node *yaml.Node) error {
if node.Kind == yaml.ScalarNode {
v := node.Value
if rs, ok := ToReviewStatus(v); ok {
*r = rs
return nil
}
return fmt.Errorf("UnmarshalYAML: unrecognized review status: %s", v)
}
return fmt.Errorf("UnmarshalYAML: incorrect node type %v", node.Kind)
}
const sourceGoTeam = "go-security-team"
type SourceMeta struct {
// The ID (GHSA or CVE) of the original source of this report.
// If created by a human, this is "go-security-team".
ID string `yaml:",omitempty"`
// The time the auto-generated report was created (or re-generated
// from source).
Created *time.Time `yaml:",omitempty"`
}
type Summary string
type Description string
func (s *Summary) String() string {
return string(*s)
}
func (d *Description) String() string {
return string(*d)
}
// GoCVE returns the CVE assigned to this report by the Go CNA,
// or the empty string if not applicable.
func (r *Report) GoCVE() string {
if r.CVEMetadata == nil {
return ""
}
return r.CVEMetadata.ID
}
// AllCVEs returns all CVE IDs for a report.
func (r *Report) AllCVEs() []string {
all := slices.Clone(r.CVEs)
if goCVE := r.GoCVE(); goCVE != "" {
all = append(all, goCVE)
}
return all
}
// AllPkgs returns all affected packages in a given module.
func (m *Module) AllPackages() map[string]*Package {
pkgs := make(map[string]*Package)
for _, pkg := range m.Packages {
pkgs[pkg.Package] = pkg
}
return pkgs
}
// CommitLinks returns all commit fix links in report.References
func (r *Report) CommitLinks() (links []string) {
for _, ref := range r.References {
if ref.Type == osv.ReferenceTypeFix {
if strings.Contains(ref.URL, "commit") {
links = append(links, ref.URL)
}
}
}
return links
}
// Aliases returns all aliases (e.g., CVEs, GHSAs) for a report.
func (r *Report) Aliases() []string {
return append(r.AllCVEs(), r.GHSAs...)
}
// AddAliases adds any GHSAs and CVEs in aliases that were not
// already present to the report.
func (r *Report) AddAliases(aliases []string) (added int) {
original := make(map[string]bool)
for _, alias := range r.Aliases() {
original[alias] = true
}
for _, alias := range aliases {
switch {
case original[alias]:
continue
case idstr.IsGHSA(alias):
r.GHSAs = append(r.GHSAs, alias)
case idstr.IsCVE(alias):
r.CVEs = append(r.CVEs, alias)
default:
continue // skip aliases that are not CVEs or GHSAs
}
added++
}
if added > 0 {
slices.Sort(r.GHSAs)
slices.Sort(r.CVEs)
}
return added
}
// GoID returns the Go ID from the given filename, assuming the filename
// is of the form "*/<goID>.<ext>".
func GoID(filename string) string {
return strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))
}
// AllSymbols returns both original and derived symbols.
func (a *Package) AllSymbols() []string {
return append(append([]string(nil), a.Symbols...), a.DerivedSymbols...)
}
var reportFilepathRegexp = regexp.MustCompile(`^(data/\w+)/(GO-\d\d\d\d-0*(\d+)\.yaml)$`)
func ParseFilepath(path string) (folder, filename string, issueID int, err error) {
m := reportFilepathRegexp.FindStringSubmatch(filepath.ToSlash(path))
if len(m) != 4 {
return "", "", 0, fmt.Errorf("%v: not a report filepath", path)
}
folder = m[1]
filename = m[2]
issueID, err = strconv.Atoi(m[3])
if err != nil {
return "", "", 0, err
}
return
}
// Read reads a Report in YAML format from filename.
func Read(filename string) (_ *Report, err error) {
defer derrors.Wrap(&err, "report.Read(%q)", filename)
f, err := os.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
return decodeStrict(f)
}
func decodeStrict(f io.Reader) (*Report, error) {
d := yaml.NewDecoder(f)
// Require that all fields in the file are in the struct.
// This corresponds to v2's UnmarshalStrict.
d.KnownFields(true)
var r Report
if err := d.Decode(&r); err != nil {
return nil, fmt.Errorf("yaml.Decode: %v", err)
}
return &r, nil
}
func ReadStrict(fsys fs.FS, filename string) (*Report, error) {
r, err := readFS(fsys, filename)
if err != nil {
return nil, err
}
if err := r.CheckFilename(filename); err != nil {
return nil, err
}
return r, nil
}
func readFS(fsys fs.FS, filename string) (*Report, error) {
f, err := fsys.Open(filename)
if err != nil {
return nil, err
}
defer f.Close()
return decodeStrict(f)
}
func (r *Report) YAMLFilename() (string, error) {
if r.ID == "" {
return "", errors.New("report has no ID")
}
return filepath.Join(dataFolder, r.folder(), r.ID+".yaml"), nil
}
func (r *Report) CVEFilename() string {
return filepath.Join(cve5Dir, r.ID+".json")
}
func (r *Report) folder() string {
if r.IsExcluded() {
return excludedFolder
}
return reportsFolder
}
// Write writes r to filename in YAML format.
func (r *Report) Write(filename string) (err error) {
defer derrors.Wrap(&err, "Write(%s)", filename)
f, err := os.Create(filename)
if err != nil {
return err
}
err = r.Encode(f)
err2 := f.Close()
if err == nil {
err = err2
}
return err
}
// ToString encodes r to a YAML string.
func (r *Report) ToString() (string, error) {
var b strings.Builder
if err := r.Encode(&b); err != nil {
return "", err
}
return b.String(), nil
}
func (r *Report) Encode(w io.Writer) error {
e := yaml.NewEncoder(w)
defer e.Close()
e.SetIndent(4)
return e.Encode(r)
}
func Vendor(modulePath string) string {
switch modulePath {
case stdlib.ModulePath:
return "Go standard library"
case stdlib.ToolchainModulePath:
return "Go toolchain"
default:
return modulePath
}
}
func (r *Report) AddCVE(cveID, cwe string, isGoCNA bool) {
if isGoCNA {
r.CVEMetadata = &CVEMeta{
ID: cveID,
CWE: cwe,
}
return
}
r.CVEs = append(r.CVEs, cveID)
}
// RemoveNewlines removes leading and trailing space characters and
// replaces inner newlines with spaces.
func RemoveNewlines(s string) string {
newlines := regexp.MustCompile(`\n+`)
return newlines.ReplaceAllString(strings.TrimSpace(s), " ")
}