| // 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 |
| |
| import ( |
| "errors" |
| "fmt" |
| "net/url" |
| "path/filepath" |
| "regexp" |
| "strings" |
| "unicode" |
| |
| "golang.org/x/exp/slices" |
| "golang.org/x/mod/module" |
| "golang.org/x/vulndb/internal/derrors" |
| "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" |
| ) |
| |
| func (m *Module) checkModVersions(pc *proxy.Client) error { |
| if ok := pc.ModuleExists(m.Module); !ok { |
| return fmt.Errorf("module %s not known to proxy", m.Module) |
| } |
| |
| _, notFound, nonCanonical := m.classifyVersions(pc) |
| |
| var sb strings.Builder |
| nf, nc := len(notFound), len(nonCanonical) |
| if nf > 0 { |
| if nf == 1 { |
| sb.WriteString(fmt.Sprintf("version %s does not exist", notFound[0])) |
| } else { |
| sb.WriteString(fmt.Sprintf("%d versions do not exist: %s", nf, notFound)) |
| } |
| } |
| if nc > 0 { |
| if nf > 0 { |
| sb.WriteString(" and ") |
| } |
| sb.WriteString(fmt.Sprintf("module is not canonical at %d version(s): %s", nc, strings.Join(nonCanonical, ", "))) |
| } |
| if s := sb.String(); s != "" { |
| return errors.New(s) |
| } |
| return nil |
| } |
| |
| func (vs Versions) String() string { |
| var s []string |
| for _, v := range vs { |
| s = append(s, v.Version) |
| } |
| return strings.Join(s, ", ") |
| } |
| |
| func (m *Module) classifyVersions(pc *proxy.Client) (found, notFound Versions, nonCanonical []string) { |
| for _, vr := range m.Versions { |
| v := vr.Version |
| c, err := pc.CanonicalModulePath(m.Module, v) |
| if err != nil { |
| // Workaround for a bug(?) in which the proxy |
| // returns 404 for the .mod endpoint for a version |
| // that shows up in list. |
| if pc.ModuleExistsAtTaggedVersion(m.Module, v) { |
| found = append(found, vr) |
| continue |
| } |
| |
| notFound = append(notFound, vr) |
| continue |
| } |
| found = append(found, vr) |
| if c != m.Module { |
| nonCanonical = append(nonCanonical, fmt.Sprintf("%s (canonical:%s)", v, c)) |
| } |
| } |
| return found, notFound, nonCanonical |
| } |
| |
| var missing = "missing" |
| |
| func (m *Module) lintVersions(l *linter, r *Report) { |
| vl := l.Group("versions") |
| ranges, err := m.Versions.ToSemverRanges() |
| if err != nil { |
| vl.Errorf("invalid version(s): %s", err) |
| } |
| if v := m.VulnerableAt; v != nil { |
| affected, err := osvutils.AffectsSemver(ranges, v.Version) |
| if err != nil { |
| vl.Error(err) |
| } else if !affected { |
| l.Group("vulnerable_at").Errorf("%s is not inside vulnerable range", v.Version) |
| } |
| } else { |
| if err := osvutils.ValidateRanges(ranges); err != nil { |
| vl.Error(err) |
| } |
| } |
| |
| if r.NeedsReview() { |
| if fixed := osvutils.LatestFixed(ranges); fixed == "" { |
| vl.Errorf("no latest fixed version (required for %s report)", NeedsReview) |
| } |
| } |
| } |
| |
| func (r *Report) lintCVEs(l *linter) { |
| for i, cve := range r.CVEs { |
| if !idstr.IsCVE(cve) { |
| l.Group(name("cves", i, cve)).Error("malformed cve identifier") |
| } |
| } |
| } |
| |
| func (r *Report) lintGHSAs(l *linter) { |
| for i, g := range r.GHSAs { |
| if !idstr.IsGHSA(g) { |
| l.Group(name("ghsas", i, g)).Errorf("%s is not a valid GHSA", g) |
| } |
| } |
| } |
| |
| func name(field string, index int, name string) string { |
| fieldIndex := fmt.Sprintf("%s[%d]", field, index) |
| if name != "" { |
| return fmt.Sprintf("%s %q", fieldIndex, name) |
| } |
| return fieldIndex |
| } |
| |
| func (r *Report) lintRelated(l *linter) { |
| if len(r.Related) == 0 { |
| // Not required. |
| return |
| } |
| |
| aliases := r.Aliases() |
| for i, related := range r.Related { |
| rl := l.Group(name("related", i, related)) |
| // In most cases, the related list is very short, so there's no |
| // need create a map of aliases. |
| if slices.Contains(aliases, related) { |
| rl.Error("also listed among aliases") |
| } |
| if !idstr.IsIdentifier(related) { |
| rl.Error("not a recognized identifier (CVE, GHSA or Go ID)") |
| } |
| } |
| } |
| |
| const maxLineLength = 80 |
| |
| func (r *Report) lintLineLength(l *linter, content string) { |
| for _, line := range strings.Split(content, "\n") { |
| if len(line) <= maxLineLength { |
| continue |
| } |
| if !strings.Contains(line, " ") { |
| continue // A single long word is OK. |
| } |
| l.Errorf("contains line > %v characters long: %q", maxLineLength, line) |
| return |
| } |
| } |
| |
| // Regex patterns for standard links. |
| var ( |
| prRegex = regexp.MustCompile(`https://go.dev/cl/\d+`) |
| commitRegex = regexp.MustCompile(`https://go.googlesource.com/[^/]+/\+/([^/]+)`) |
| issueRegex = regexp.MustCompile(`https://go.dev/issue/\d+`) |
| announceRegex = regexp.MustCompile(`https://groups.google.com/g/golang-(announce|dev|nuts)/c/([^/]+)`) |
| ) |
| |
| func (ref *Reference) lint(l *linter, r *Report) { |
| // Checks specific to first-party reports. |
| if r.IsFirstParty() { |
| switch ref.Type { |
| case osv.ReferenceTypeAdvisory: |
| l.Errorf("%q: advisory reference must not be set for first-party issues", ref.URL) |
| case osv.ReferenceTypeFix: |
| if !prRegex.MatchString(ref.URL) && !commitRegex.MatchString(ref.URL) { |
| l.Errorf("%q: fix reference must match %q or %q", ref.URL, prRegex, commitRegex) |
| } |
| case osv.ReferenceTypeReport: |
| if !issueRegex.MatchString(ref.URL) { |
| l.Errorf("%q: report reference must match regex %q", ref.URL, issueRegex) |
| } |
| case osv.ReferenceTypeWeb: |
| if !announceRegex.MatchString(ref.URL) { |
| l.Errorf("%q: web reference must match regex %q", ref.URL, announceRegex) |
| } |
| } |
| } |
| |
| if !slices.Contains(osv.ReferenceTypes, ref.Type) { |
| l.Errorf("invalid reference type %q", ref.Type) |
| } |
| u := ref.URL |
| if _, err := url.ParseRequestURI(u); err != nil { |
| l.Error("invalid URL") |
| } |
| if fixed := fixURL(u); fixed != u { |
| l.Errorf("should be %q (can be auto-fixed)", fixURL(u)) |
| } |
| if ref.Type != osv.ReferenceTypeAdvisory { |
| // An ADVISORY reference to a CVE/GHSA indicates that it |
| // is the canonical source of information on this vuln. |
| // |
| // A reference to a CVE/GHSA that is not an alias of this |
| // report indicates that it may contain related information. |
| // |
| // A reference to a CVE/GHSA that appears in the CVEs/GHSAs |
| // aliases is redundant. |
| if id, ok := idstr.IsAdvisoryForOneOf(ref.URL, r.Aliases()); ok { |
| l.Errorf("redundant non-advisory reference to %v", id) |
| } |
| } |
| } |
| |
| // IsOriginal returns whether the source of this report is |
| // definitely the Go security team. (Many older reports do not have this |
| // metadata so other heuristics would have to be used). |
| func (r *Report) IsOriginal() bool { |
| return r.SourceMeta != nil && r.SourceMeta.ID == sourceGoTeam |
| } |
| |
| func (r *Report) IsReviewed() bool { |
| return r.ReviewStatus == Reviewed |
| } |
| |
| func (r *Report) IsUnreviewed() bool { |
| return r.ReviewStatus == Unreviewed |
| } |
| |
| func (r *Report) NeedsReview() bool { |
| return r.ReviewStatus == NeedsReview |
| } |
| |
| func (r *Report) lintReferences(l *linter) { |
| for i, ref := range r.References { |
| rl := l.Group(name("references", i, ref.URL)) |
| ref.lint(rl, r) |
| } |
| |
| rl := l.Group("references") |
| |
| // Check advisory count. |
| switch c := r.countAdvisories(); { |
| case c == 0 && r.needsAdvisory(): |
| rl.Errorf("missing advisory (required because report has no description or is %v)", Unreviewed) |
| case c > 1 && r.IsReviewed(): |
| rl.Errorf("too many advisories (found %d, want <=1)", c) |
| } |
| |
| // First-party reports have stricter requirements for references. |
| if !r.IsExcluded() && r.IsFirstParty() { |
| var hasFixLink, hasReportLink, hasAnnounceLink bool |
| for _, ref := range r.References { |
| switch ref.Type { |
| case osv.ReferenceTypeFix: |
| hasFixLink = true |
| case osv.ReferenceTypeReport: |
| hasReportLink = true |
| case osv.ReferenceTypeWeb: |
| if announceRegex.MatchString(ref.URL) { |
| hasAnnounceLink = true |
| } |
| } |
| } |
| if !hasFixLink { |
| rl.Error("must contain at least one fix") |
| } |
| if !hasReportLink { |
| rl.Error("must contain at least one report") |
| } |
| if !hasAnnounceLink { |
| rl.Errorf("must contain an announcement link matching regex %q", announceRegex) |
| } |
| } |
| } |
| |
| func (r *Report) lintReviewStatus(l *linter) { |
| if r.IsExcluded() { |
| return |
| } |
| |
| if r.ReviewStatus == 0 || !r.ReviewStatus.IsValid() { |
| l.Errorf("review_status missing or invalid (must be one of [%s])", strings.Join(reviewStatusValues(), ", ")) |
| } |
| } |
| |
| func (r *Report) lintSource(l *linter) { |
| if r.SourceMeta == nil { |
| return |
| } |
| if !r.IsReviewed() && r.SourceMeta.ID == sourceGoTeam { |
| l.Errorf("source: if id=%s, report must be %s", sourceGoTeam, Reviewed) |
| } |
| } |
| |
| func (r *Report) countAdvisories() int { |
| advisoryCount := 0 |
| for _, ref := range r.References { |
| if ref.Type == osv.ReferenceTypeAdvisory { |
| advisoryCount++ |
| } |
| } |
| return advisoryCount |
| } |
| |
| func (r *Report) needsAdvisory() bool { |
| switch { |
| case r.IsExcluded(), r.CVEMetadata != nil, r.IsFirstParty(): |
| return false |
| case r.Description == "", r.IsUnreviewed(), r.NeedsReview(): |
| return true |
| } |
| return false |
| } |
| |
| func (d *Description) lint(l *linter, r *Report) { |
| desc := d.String() |
| |
| checkNoMarkdown(l, desc) |
| r.lintLineLength(l, desc) |
| if !r.IsExcluded() && desc == "" { |
| if r.CVEMetadata != nil { |
| l.Error("missing (reports with Go CVEs must have a description)") |
| } |
| } |
| } |
| |
| const summaryMaxLen = 125 |
| |
| func (s *Summary) lint(l *linter, r *Report) { |
| summary := s.String() |
| if len(summary) == 0 { |
| if !r.IsExcluded() { |
| l.Error(missing) |
| } |
| // Nothing else to lint. |
| return |
| } |
| if hasTODO(summary) { |
| l.Error(hasTODOErr) |
| // No need to keep linting, as this is likely a placeholder value. |
| return |
| } |
| |
| // Non-reviewed reports don't need to meet strict requirements. |
| if !r.IsReviewed() { |
| return |
| } |
| |
| checkNoMarkdown(l, summary) |
| if ln := len(summary); ln > summaryMaxLen { |
| l.Errorf("too long (found %d characters, want <=%d)", ln, summaryMaxLen) |
| } |
| if strings.HasSuffix(summary, ".") { |
| l.Error("must not end in a period (should be a phrase, not a sentence)") |
| } |
| if !startsWithUpper(summary) { |
| l.Error("must begin with a capital letter") |
| } |
| |
| // Summary must contain one of the listed module or package |
| // paths. (Except in the "std" module, where a specific package |
| // must be mentioned). |
| // If there are no such paths listed in the report at all, |
| // another lint will complain, so reduce noise by not erroring here. |
| if paths := r.nonStdPaths(); len(paths) > 0 { |
| if ok := containsPath(summary, paths); !ok { |
| l.Errorf("must contain an affected module or package path (e.g. %q)", paths[0]) |
| } |
| } |
| } |
| |
| func startsWithUpper(s string) bool { |
| for i, r := range s { |
| if i != 0 { |
| return true |
| } |
| if !unicode.IsUpper(r) { |
| return false |
| } |
| } |
| return false |
| } |
| |
| // containsPath returns whether the summary contains one of |
| // the paths in paths. |
| // As a special case, if the summary contains a word that contains a "/" |
| // and is a prefix of a path, the function returns true. This gives us a |
| // workaround for reports that affect a lot of modules and/or have very long |
| // module paths. |
| func containsPath(summary string, paths []string) bool { |
| if len(paths) == 0 { |
| return false |
| } |
| |
| for _, possiblePath := range strings.Fields(summary) { |
| possiblePath := strings.TrimRight(possiblePath, ":,.") |
| for _, path := range paths { |
| if possiblePath == path { |
| return true |
| } |
| if strings.Contains(possiblePath, "/") && |
| strings.HasPrefix(path, possiblePath) { |
| return true |
| } |
| } |
| } |
| |
| return false |
| } |
| |
| // nonStdPaths returns all module and package paths (except "std") |
| // mentioned in the report. |
| func (r *Report) nonStdPaths() (paths []string) { |
| for _, m := range r.Modules { |
| if m.Module != "" && m.Module != stdlib.ModulePath { |
| paths = append(paths, m.Module) |
| } |
| for _, p := range m.Packages { |
| if p.Package != "" { |
| paths = append(paths, p.Package) |
| } |
| } |
| } |
| return paths |
| } |
| |
| func (r *Report) IsExcluded() bool { |
| return r.Excluded != "" |
| } |
| |
| var ( |
| errWrongDir = errors.New("report is in incorrect directory") |
| errWrongID = errors.New("report ID mismatch") |
| ) |
| |
| // CheckFilename errors if the filename is inconsistent with the report. |
| func (r *Report) CheckFilename(filename string) (err error) { |
| defer derrors.Wrap(&err, "CheckFilename(%q)", filename) |
| |
| dir := filepath.Base(filepath.Dir(filename)) // innermost folder |
| excluded := r.IsExcluded() |
| |
| if excluded && dir != excludedFolder { |
| return fmt.Errorf("%w (want %s, found %s)", errWrongDir, excludedFolder, dir) |
| } |
| |
| if !excluded && dir != reportsFolder { |
| return fmt.Errorf("%w (want %s, found %s)", errWrongDir, reportsFolder, dir) |
| } |
| |
| wantID := GoID(filename) |
| if r.ID != wantID { |
| return fmt.Errorf("%w (want %s, found %s)", errWrongID, wantID, r.ID) |
| } |
| |
| return nil |
| } |
| |
| // Lint checks the content of a Report and outputs a list of strings |
| // representing lint errors. |
| // TODO: It might make sense to include warnings or informational things |
| // alongside errors, especially during for use during the triage process. |
| func (r *Report) Lint(pc *proxy.Client) []string { |
| result := r.lint(pc) |
| if pc == nil { |
| result = append(result, "proxy client is nil; cannot perform all lint checks") |
| } |
| return result |
| } |
| |
| // LintAsNotes works like Lint, but modifies r by adding any lints found |
| // to the notes section, instead of returning them. |
| // Removes any pre-existing lint notes. |
| // Returns true if any lints were found. |
| func (r *Report) LintAsNotes(pc *proxy.Client) bool { |
| r.deleteNotes(NoteTypeLint) |
| |
| if lints := r.Lint(pc); len(lints) > 0 { |
| slices.Sort(lints) |
| for _, lint := range lints { |
| r.AddNote(NoteTypeLint, "%s", lint) |
| } |
| return true |
| } |
| |
| return false |
| } |
| |
| func (r *Report) deleteNotes(t NoteType) { |
| r.Notes = slices.DeleteFunc(r.Notes, func(n *Note) bool { |
| return n.Type == t |
| }) |
| } |
| |
| func (r *Report) AddNote(t NoteType, format string, v ...any) { |
| n := &Note{ |
| Body: fmt.Sprintf(format, v...), |
| Type: t, |
| } |
| // Don't add the same note twice. |
| for _, nn := range r.Notes { |
| if nn.Type == n.Type && nn.Body == n.Body { |
| return |
| } |
| } |
| r.Notes = append(r.Notes, n) |
| } |
| |
| // LintOffline performs all lint checks that don't require a network connection. |
| func (r *Report) LintOffline() []string { |
| return r.lint(nil) |
| } |
| |
| func (r *Report) lint(pc *proxy.Client) []string { |
| l := NewLinter("") |
| |
| if r.ID == "" { |
| l.Group("id").Error(missing) |
| } |
| |
| r.Summary.lint(l.Group("summary"), r) |
| r.Description.lint(l.Group("description"), r) |
| r.Excluded.lint(l.Group("excluded")) |
| |
| r.lintModules(l, pc) |
| |
| r.CVEMetadata.lint(l.Group("cve_metadata"), r) |
| |
| if r.IsExcluded() && len(r.Aliases()) == 0 { |
| l.Group("cves,ghsas").Error(missing) |
| } |
| |
| r.lintCVEs(l) |
| r.lintGHSAs(l) |
| r.lintRelated(l) |
| |
| r.lintReferences(l) |
| r.lintReviewStatus(l) |
| r.lintSource(l) |
| |
| if r.hasTODOs() { |
| l.Error("contains one or more TODOs") |
| } |
| |
| return l.Errors() |
| } |
| |
| func (m *Module) lint(l *linter, r *Report, pc *proxy.Client) { |
| if m.SkipLint { |
| return |
| } |
| |
| if m.Module == "" { |
| l.Error("no module name") |
| } |
| |
| if !r.IsExcluded() && !m.IsFirstParty() && pc != nil { |
| if err := m.checkModVersions(pc); err != nil { |
| l.Error(err) |
| } |
| } |
| |
| if m.IsFirstParty() && len(m.Packages) == 0 { |
| l.Error("no packages") |
| } |
| |
| for i, p := range m.Packages { |
| p.lint(l.Group(name("packages", i, p.Package)), m, r) |
| } |
| |
| if r.IsReviewed() || r.NeedsReview() { |
| if u := len(m.UnsupportedVersions); u > 0 { |
| l.Group("unsupported_versions").Errorf("found %d (want none)", u) |
| } |
| } |
| |
| m.lintVersions(l, r) |
| } |
| |
| func (p *Package) lint(l *linter, m *Module, r *Report) { |
| if p.Package == "" { |
| l.Error("no package name") |
| } else { |
| if m.Module != stdlib.ModulePath { |
| if !strings.HasPrefix(p.Package, m.Module) { |
| l.Error("module must be a prefix of package") |
| } |
| } else { |
| if p.Package == "runtime" && len(p.Symbols) != 0 { |
| l.Errorf("runtime package must have no symbols (found %d)", len(p.Symbols)) |
| } |
| // As a special case, check for "cmd/" packages that are |
| // mistakenly placed in the "std" module. |
| if strings.HasPrefix(p.Package, stdlib.ToolchainModulePath) { |
| l.Error("must be in module cmd") |
| } |
| } |
| |
| if !m.IsFirstParty() { |
| if err := module.CheckImportPath(p.Package); err != nil { |
| l.Error(err) |
| } |
| } |
| } |
| |
| if !r.IsExcluded() { |
| if m.VulnerableAt == nil && p.SkipFixSymbols == "" { |
| l.Error("at least one of vulnerable_at and skip_fix must be set") |
| } |
| } |
| } |
| |
| func (r *Report) lintModules(l *linter, pc *proxy.Client) { |
| if r.Excluded != ExcludedNotGoCode && len(r.Modules) == 0 { |
| l.Group("modules").Error(missing) |
| } |
| |
| for i, m := range r.Modules { |
| m.lint(l.Group(name("modules", i, m.Module)), r, pc) |
| } |
| } |
| |
| func (r *Report) IsFirstParty() bool { |
| for _, m := range r.Modules { |
| if m.IsFirstParty() { |
| return true |
| } |
| } |
| return false |
| } |
| |
| func (m *Module) IsFirstParty() bool { |
| return stdlib.IsStdModule(m.Module) || stdlib.IsCmdModule(m.Module) |
| } |
| |
| func (e *ExcludedType) lint(l *linter) { |
| if e == nil || *e == "" { |
| return |
| } |
| if !e.IsValid() { |
| l.Errorf("excluded reason (%q) is not a valid excluded reason (accepted: %v)", *e, ExcludedTypes) |
| } |
| } |
| |
| func (m *CVEMeta) lint(l *linter, r *Report) { |
| if m == nil { |
| return |
| } |
| |
| il := l.Group("id") |
| if m.ID == "" { |
| il.Error(missing) |
| } else if !idstr.IsCVE(m.ID) { |
| il.Error("not a valid CVE") |
| } |
| |
| cl := l.Group("cwe") |
| if m.CWE == "" { |
| cl.Error(missing) |
| } |
| if hasTODO(m.CWE) { |
| cl.Error(hasTODOErr) |
| } |
| |
| r.lintLineLength(l.Group("description"), m.Description) |
| } |
| |
| var hasTODOErr = "contains a TODO" |
| |
| func hasTODO(s string) bool { |
| return strings.Contains(s, "TODO") |
| } |
| |
| // Regular expressions for markdown-style elements that shouldn't |
| // be in our descriptions/summaries. |
| var ( |
| backtickRE = regexp.MustCompile("(`.*`)") |
| linkRE = regexp.MustCompile(`(\[.*\]\(.*\))`) |
| headingRE = regexp.MustCompile(`(#+ )`) |
| ) |
| |
| func checkNoMarkdown(l *linter, s string) { |
| for _, re := range []*regexp.Regexp{backtickRE, linkRE, headingRE} { |
| matches := re.FindStringSubmatch(s) |
| if len(matches) > 0 { |
| l.Errorf("possible markdown formatting (found %s)", matches[1]) |
| } |
| } |
| } |
| |
| func (r *Report) hasTODOs() bool { |
| is := hasTODO |
| any := func(ss []string) bool { return slices.IndexFunc(ss, is) >= 0 } |
| |
| if is(string(r.Excluded)) { |
| return true |
| } |
| for _, m := range r.Modules { |
| if is(m.Module) { |
| return true |
| } |
| for _, v := range m.Versions { |
| if is(v.Version) { |
| return true |
| } |
| } |
| if m.VulnerableAt != nil && is(m.VulnerableAt.Version) { |
| return true |
| } |
| for _, p := range m.Packages { |
| // don't check p.SkipFix, this may contain TODOs for historical reasons |
| if is(p.Package) || any(p.Symbols) || any(p.DerivedSymbols) { |
| return true |
| } |
| } |
| } |
| for _, ref := range r.References { |
| if is(ref.URL) { |
| return true |
| } |
| } |
| if any(r.CVEs) || any(r.GHSAs) { |
| return true |
| } |
| return is(r.Description.String()) || any(r.Credits) |
| } |