blob: 65d4c4cae52a0b40631bdc0acddaa0ce1cf86360 [file] [log] [blame]
// Copyright 2024 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 main
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"golang.org/x/exp/slices"
"golang.org/x/vulndb/cmd/vulnreport/log"
"golang.org/x/vulndb/internal/idstr"
"golang.org/x/vulndb/internal/issues"
"golang.org/x/vulndb/internal/osv"
"golang.org/x/vulndb/internal/report"
"golang.org/x/vulndb/internal/symbols"
"golang.org/x/vulndb/internal/triage/priority"
)
type creator struct {
assignee string
created []*yamlReport
// If non-zero, use this review status
// instead of the default for new reports.
reviewStatus report.ReviewStatus
*fixer
*xrefer
*suggester
}
func (c *creator) setup(ctx context.Context, env environment) (err error) {
user := *user
if user == "" {
user = os.Getenv("GITHUB_USER")
}
c.assignee = user
rs, ok := report.ToReviewStatus(*reviewStatus)
if !ok {
return fmt.Errorf("invalid -status=%s", rs)
}
c.reviewStatus = rs
c.fixer = new(fixer)
c.xrefer = new(xrefer)
if *useAI {
c.suggester = new(suggester)
}
return setupAll(ctx, env, c.fixer, c.xrefer, c.suggester)
}
func (c *creator) skip(input any) string {
iss := input.(*issues.Issue)
if c.assignee != "" && iss.Assignee != c.assignee {
return fmt.Sprintf("assignee = %q, not %q", iss.Assignee, c.assignee)
}
return skip(iss, c.xrefer)
}
func skip(iss *issues.Issue, x *xrefer) string {
if iss.HasLabel(labelOutOfScope) {
return "out of scope"
}
if iss.HasLabel(labelDuplicate) {
return "duplicate issue"
}
if iss.HasLabel(labelSuggestedEdit) {
return "suggested edit"
}
// indicates that there is already a report for this
// vuln but the report needs to be updated
if iss.HasLabel(labelNeedsAlias) {
return "existing report needs alias"
}
if iss.HasLabel(labelPossiblyNotGo) {
return "possibly not Go"
}
if x.rc.HasReport(iss.Number) {
return "already has report"
}
return ""
}
func (c *creator) newReportFromIssue(ctx context.Context, iss *issues.Issue) error {
r, err := c.reportFromMeta(ctx, &reportMeta{
id: iss.NewGoID(),
excluded: excludedReason(iss),
modulePath: modulePath(iss),
aliases: aliases(iss),
reviewStatus: reviewStatusOf(iss, c.reviewStatus),
originalCVE: originalCVE(iss),
})
if err != nil {
return err
}
if r.Withdrawn != nil {
return fmt.Errorf("new report should not be created for withdrawn vulnerability; close issue #%d as excluded:OUT_OF_SCOPE instead", iss.Number)
}
return c.write(ctx, r)
}
func originalCVE(iss *issues.Issue) string {
aliases := aliases(iss)
if iss.HasLabel(labelFirstParty) && len(aliases) == 1 && idstr.IsCVE(aliases[0]) {
return aliases[0]
}
return ""
}
func reviewStatusOf(iss *issues.Issue, reviewStatus report.ReviewStatus) report.ReviewStatus {
d := defaultReviewStatus(iss)
// If a valid review status is provided, it overrides the priority label.
if reviewStatus != 0 {
if d != reviewStatus {
log.Warnf("issue #%d: should be %s based on label(s) but this was overridden with the -status=%s flag", iss.Number, d, reviewStatus)
}
return reviewStatus
}
return d
}
func defaultReviewStatus(iss *issues.Issue) report.ReviewStatus {
if iss.HasLabel(labelHighPriority) ||
iss.HasLabel(labelDirect) ||
iss.HasLabel(labelFirstParty) {
return report.Reviewed
}
return report.Unreviewed
}
func (c *creator) metaToSource(ctx context.Context, meta *reportMeta) report.Source {
if cveID := meta.originalCVE; cveID != "" {
log.Infof("%s: creating original report for Go-CNA-assigned %s", meta.id, cveID)
return report.OriginalCVE(cveID)
}
if src := c.sourceFromBestAlias(ctx, meta.aliases, *preferCVE); src != nil {
log.Infof("%s: picked %s as best source alias (from [%s])", meta.id, src.SourceID(),
strings.Join(meta.aliases, ", "))
return src
}
log.Infof("%s: no suitable alias found, creating basic report", meta.id)
return report.Original()
}
func (c *creator) rawReport(ctx context.Context, meta *reportMeta) *report.Report {
return report.New(c.metaToSource(ctx, meta), c.pxc,
report.WithGoID(meta.id),
report.WithModulePath(meta.modulePath),
report.WithAliases(meta.aliases),
report.WithReviewStatus(meta.reviewStatus),
report.WithUnexcluded(meta.unexcluded),
)
}
func (c *creator) reportFromMeta(ctx context.Context, meta *reportMeta) (*yamlReport, error) {
// Find the underlying module if the "module" provided is actually a package path.
if module, err := c.pxc.FindModule(meta.modulePath); err == nil { // no error
meta.modulePath = module
}
meta.aliases = c.allAliases(ctx, meta.aliases)
raw := c.rawReport(ctx, meta)
if meta.excluded != "" {
raw = &report.Report{
ID: meta.id,
Modules: []*report.Module{
{
Module: meta.modulePath,
},
},
Excluded: meta.excluded,
CVEs: raw.CVEs,
GHSAs: raw.GHSAs,
}
}
// The initial quick triage algorithm doesn't know about all
// affected modules, so double check the priority after the
// report is created.
if raw.IsUnreviewed() {
pr, _ := c.reportPriority(raw)
if pr.Priority == priority.High {
log.Warnf("%s: re-generating; vuln is high priority and should be REVIEWED; reason: %s", raw.ID, pr.Reason)
meta.reviewStatus = report.Reviewed
raw = c.rawReport(ctx, meta)
}
}
fname, err := raw.YAMLFilename()
if err != nil {
return nil, err
}
r := &yamlReport{Report: raw, Filename: fname}
// Find any additional aliases referenced by the source aliases.
r.addMissingAliases(ctx, c.aliasFinder)
if c.suggester != nil {
suggestions, err := c.suggest(ctx, r, 1)
if err != nil {
r.AddNote(report.NoteTypeCreate, "failed to get AI-generated suggestions")
log.Warnf("%s: failed to get AI-generated suggestions: %v", r.ID, err)
} else {
log.Infof("%s: applying AI-generated suggestion", r.ID)
r.applySuggestion(suggestions[0])
}
}
if *populateSymbols {
log.Infof("%s: attempting to auto-populate symbols (this may take a while...)", r.ID)
if err := symbols.Populate(r.Report, false); err != nil {
r.AddNote(report.NoteTypeCreate, "failed to auto-populate symbols")
log.Warnf("%s: could not auto-populate symbols: %s", r.ID, err)
} else {
if err := r.checkSymbols(); err != nil {
log.Warnf("%s: auto-populated symbols have error(s): %s", r.ID, err)
}
}
}
switch {
case raw.IsExcluded():
// nothing
case raw.IsUnreviewed():
r.removeUnreachableRefs()
default:
// Regular, full-length reports.
addTODOs(r)
if xrefs := c.xref(r); len(xrefs) != 0 {
log.Infof("%s: found cross-references: %s", r.ID, xrefs)
}
}
return r, nil
}
func (r *yamlReport) removeUnreachableRefs() {
r.Report.References = slices.DeleteFunc(r.Report.References, func(r *report.Reference) bool {
resp, err := http.Head(r.URL)
if err != nil {
return true
}
defer resp.Body.Close()
return resp.StatusCode == http.StatusNotFound
})
}
func (c *creator) write(ctx context.Context, r *yamlReport) error {
if r.IsReviewed() || r.IsExcluded() {
if err := c.fileWriter.write(r); err != nil {
return err
}
} else { // unreviewed
addNotes := true
if err := c.fixAndWriteAll(ctx, r, addNotes); err != nil {
return err
}
}
c.created = append(c.created, r)
return nil
}
const (
labelDuplicate = "duplicate"
labelDirect = "Direct External Report"
labelSuggestedEdit = "Suggested Edit"
labelNeedsAlias = "NeedsAlias"
labelTriaged = "triaged"
labelHighPriority = "high priority"
labelFirstParty = "first party"
labelPossiblyNotGo = "possibly not Go"
labelOutOfScope = "excluded: OUT_OF_SCOPE"
)
func excludedReason(iss *issues.Issue) report.ExcludedReason {
for _, label := range iss.Labels {
if reason, ok := report.FromLabel(label); ok {
return reason
}
}
return ""
}
func modulePath(iss *issues.Issue) string {
for _, p := range strings.Fields(iss.Title) {
if p == "x/vulndb:" {
continue
}
if strings.HasSuffix(p, ":") || strings.Contains(p, "/") {
// Remove backslashes.
return strings.ReplaceAll(strings.TrimSuffix(p, ":"), "\"", "")
}
}
return ""
}
func aliases(iss *issues.Issue) (aliases []string) {
for _, p := range strings.Fields(iss.Title) {
if idstr.IsAliasType(p) {
aliases = append(aliases, strings.TrimSuffix(p, ","))
}
}
return aliases
}
// Data that can be combined with a source vulnerability
// to create a new report.
type reportMeta struct {
id string
modulePath string
aliases []string
excluded, unexcluded report.ExcludedReason
reviewStatus report.ReviewStatus
originalCVE string
}
const todo = "TODO: "
// addTODOs adds "TODO" comments to unfilled fields of r.
func addTODOs(r *yamlReport) {
if r.Excluded != "" {
return
}
if len(r.Modules) == 0 {
r.Modules = append(r.Modules, &report.Module{
Packages: []*report.Package{{}},
})
}
for _, m := range r.Modules {
if m.Module == "" {
m.Module = todo + "affected module path"
}
if len(m.Versions) == 0 {
m.Versions = report.Versions{
report.Introduced(todo + "introduced version (blank if unknown)"),
report.Fixed(todo + "fixed version"),
}
}
if m.VulnerableAt == nil {
m.VulnerableAt = report.VulnerableAt(todo + "a version at which the package is vulnerable")
}
if len(m.Packages) == 0 {
m.Packages = []*report.Package{
{
Package: todo + "affected package path(s) - blank if all",
},
}
}
for _, p := range m.Packages {
if p.Package == "" {
p.Package = todo + "affected package path"
}
if len(p.Symbols) == 0 {
p.Symbols = []string{todo + "affected symbol(s) - blank if all"}
}
}
}
if r.Summary == "" {
r.Summary = todo + "short (one phrase) summary of the form '<Problem> in <module>(s)'"
}
if r.Description == "" {
r.Description = todo + "description of the vulnerability"
}
if len(r.Credits) == 0 {
r.Credits = []string{todo + "who discovered/reported this vulnerability (optional)"}
}
if r.CVEMetadata == nil && len(r.CVEs) == 0 {
r.CVEs = []string{todo + "CVE id(s) for this vulnerability"}
}
if r.CVEMetadata != nil && r.CVEMetadata.CWE == "" {
r.CVEMetadata.CWE = todo + "CWE ID"
}
addReferenceTODOs(r)
}
// addReferenceTODOs adds a TODO for each important reference type not
// already present in the report.
func addReferenceTODOs(r *yamlReport) {
todos := []*report.Reference{
{Type: osv.ReferenceTypeAdvisory, URL: "TODO: canonical security advisory"},
{Type: osv.ReferenceTypeReport, URL: "TODO: issue tracker link"},
{Type: osv.ReferenceTypeFix, URL: "TODO: PR or commit (commit preferred)"}}
types := make(map[osv.ReferenceType]bool)
for _, r := range r.References {
types[r.Type] = true
}
for _, todo := range todos {
if !types[todo.Type] {
r.References = append(r.References, todo)
}
}
}