cmd/vulnreport: add subcommand framework
Adds a new unified framework for vulnreport subcommands. Each subcommand
now implements an interface, command, which has functions for setting up,
running, and tearing down the command.
Change-Id: I7c6ab5cf1b4c19b300dbdef6df0d76e2f1f303ea
Reviewed-on: https://go-review.googlesource.com/c/vulndb/+/559955
Reviewed-by: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/cmd/vulnreport/command.go b/cmd/vulnreport/command.go
new file mode 100644
index 0000000..0a03495
--- /dev/null
+++ b/cmd/vulnreport/command.go
@@ -0,0 +1,94 @@
+// 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"
+ "os"
+ "path/filepath"
+
+ "golang.org/x/vulndb/cmd/vulnreport/log"
+)
+
+// command represents a subcommand of vulnreport.
+type command interface {
+ // name outputs the string used to invoke the subcommand.
+ name() string
+ // usage outputs strings indicating how to use the subcommand.
+ usage() (args string, desc string)
+ // setup populates any state needed to run the subcommand.
+ setup(context.Context) error
+ // parseArgs takes in the raw args passed to the command line,
+ // and converts them to a representation understood by "run".
+ // This function need not be one-to-one: there may be more
+ // inputs than args or vice-versa.
+ parseArgs(_ context.Context, args []string) (inputs []string, _ error)
+ // run executes the subcommand on the given input.
+ run(_ context.Context, input string) error
+ // close cleans up state and/or completes tasks that should occur
+ // after run is called on all inputs.
+ close() error
+}
+
+// run executes the given command on the given raw arguments.
+func run(ctx context.Context, c command, args []string) error {
+ if err := c.setup(ctx); err != nil {
+ return err
+ }
+ defer c.close()
+
+ inputs, err := c.parseArgs(ctx, args)
+ if err != nil {
+ return err
+ }
+
+ for _, input := range inputs {
+ log.Infof("%s %v", c.name(), input)
+ if err := c.run(ctx, input); err != nil {
+ log.Errf("%s: %s", c.name(), err)
+ }
+ }
+ return nil
+}
+
+const (
+ filenameArgs = "[filename | github-id] ..."
+ ghIssueArgs = "[github-id] ..."
+)
+
+// filenameParser implements the "parseArgs" function of the command
+// interface, and can be used by commands that operate on YAML filenames.
+type filenameParser bool
+
+func (filenameParser) parseArgs(_ context.Context, args []string) (filenames []string, _ error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("no arguments provided")
+ }
+ for _, arg := range args {
+ fname, err := argToFilename(arg)
+ if err != nil {
+ log.Err(err)
+ continue
+ }
+ filenames = append(filenames, fname)
+ }
+ return filenames, nil
+}
+
+func argToFilename(arg string) (string, error) {
+ if _, err := os.Stat(arg); err != nil {
+ // If arg isn't a file, see if it might be an issue ID
+ // with an existing report.
+ for _, padding := range []string{"", "0", "00", "000"} {
+ m, _ := filepath.Glob("data/*/GO-*-" + padding + arg + ".yaml")
+ if len(m) == 1 {
+ return m[0], nil
+ }
+ }
+ return "", fmt.Errorf("%s is not a valid filename or issue ID with existing report: %w", arg, err)
+ }
+ return arg, nil
+}
diff --git a/cmd/vulnreport/commit.go b/cmd/vulnreport/commit.go
index 64db209..1904eab 100644
--- a/cmd/vulnreport/commit.go
+++ b/cmd/vulnreport/commit.go
@@ -11,7 +11,6 @@
"strings"
"golang.org/x/exp/slices"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/ghsa"
"golang.org/x/vulndb/internal/proxy"
"golang.org/x/vulndb/internal/report"
@@ -21,16 +20,36 @@
updateIssue = flag.Bool("up", false, "for commit, create a CL that updates (doesn't fix) the tracking bug")
)
-func commit(ctx context.Context, filename string, ghsaClient *ghsa.Client, pc *proxy.Client, force bool) (err error) {
- defer derrors.Wrap(&err, "commit(%q)", filename)
+type commit struct {
+ pc *proxy.Client
+ gc *ghsa.Client
+ filenameParser
+}
+
+func (commit) name() string { return "commit" }
+
+func (commit) usage() (string, string) {
+ const desc = "creates new commits for YAML reports"
+ return filenameArgs, desc
+}
+
+func (c *commit) setup(ctx context.Context) error {
+ c.pc = proxy.NewDefaultClient()
+ c.gc = ghsa.NewClient(ctx, *githubToken)
+ return nil
+}
+
+func (c *commit) close() error { return nil }
+
+func (c *commit) run(ctx context.Context, filename string) (err error) {
// Clean up the report file and lint the result.
// Stop if there any problems.
- if err := fix(ctx, filename, ghsaClient, pc, force); err != nil {
+ r, err := report.ReadAndLint(filename, c.pc)
+ if err != nil {
return err
}
- r, err := report.ReadAndLint(filename, pc)
- if err != nil {
+ if err := fixReport(ctx, r, filename, c.pc, c.gc); err != nil {
return err
}
if hasUnaddressedTodos(r) {
diff --git a/cmd/vulnreport/create.go b/cmd/vulnreport/create.go
index 3a8fad6..c75b8fd 100644
--- a/cmd/vulnreport/create.go
+++ b/cmd/vulnreport/create.go
@@ -15,7 +15,6 @@
"golang.org/x/vulndb/cmd/vulnreport/log"
"golang.org/x/vulndb/internal/cveclient"
"golang.org/x/vulndb/internal/cveschema5"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/genai"
"golang.org/x/vulndb/internal/genericosv"
"golang.org/x/vulndb/internal/ghsa"
@@ -34,19 +33,75 @@
useAI = flag.Bool("ai", false, "for create, use AI to write draft summary and description when creating report")
)
-func create(ctx context.Context, issueNumber int, cfg *createCfg) (err error) {
- defer derrors.Wrap(&err, "create(%d)", issueNumber)
- // Get GitHub issue.
- iss, err := cfg.issuesClient.Issue(ctx, issueNumber)
+type create struct {
+ gc *ghsa.Client
+ ic *issues.Client
+ pc *proxy.Client
+ ac *genai.GeminiClient
+ existingByFile map[string]*report.Report
+ existingByIssue map[int]*report.Report
+ allowClosed bool
+}
+
+func (create) name() string { return "create" }
+
+func (create) usage() (string, string) {
+ const desc = "creates a new vulnerability YAML report"
+ return ghIssueArgs, desc
+}
+
+func (c *create) setup(ctx context.Context) error {
+ if *githubToken == "" {
+ return fmt.Errorf("githubToken must be provided")
+ }
+ localRepo, err := gitrepo.Open(ctx, ".")
+ if err != nil {
+ return err
+ }
+ existingByIssue, existingByFile, err := report.All(localRepo)
+ if err != nil {
+ return err
+ }
+ owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
+ if err != nil {
+ return err
+ }
+ var aiClient *genai.GeminiClient
+ if *useAI {
+ aiClient, err = genai.NewGeminiClient(ctx)
+ if err != nil {
+ return err
+ }
+ }
+ c.ic = issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken})
+ c.gc = ghsa.NewClient(ctx, *githubToken)
+ c.pc = proxy.NewDefaultClient()
+ c.existingByFile = existingByFile
+ c.existingByIssue = existingByIssue
+ c.allowClosed = *closedOk
+ c.ac = aiClient
+ return nil
+}
+
+func (*create) close() error { return nil }
+
+func (cfg *create) run(ctx context.Context, issueNumber string) (err error) {
+ n, err := strconv.Atoi(issueNumber)
+ if err != nil {
+ return err
+ }
+ iss, err := cfg.ic.Issue(ctx, n)
if err != nil {
return err
}
- r, err := createReport(ctx, cfg, iss)
+ r, err := createReport(ctx, iss, cfg.pc, cfg.gc, cfg.ac, cfg.allowClosed)
if err != nil {
return err
}
+ addTODOs(r)
+
filename, err := writeReport(r)
if err != nil {
return err
@@ -54,7 +109,7 @@
log.Out(filename)
- xrefs := xref(filename, r, cfg.existingByFile)
+ xrefs := xrefInner(filename, r, cfg.existingByFile)
if len(xrefs) != 0 {
log.Infof("found cross-references:\n%s", xrefs)
}
@@ -62,116 +117,12 @@
return nil
}
-func createExcluded(ctx context.Context, cfg *createCfg) (err error) {
- defer derrors.Wrap(&err, "createExcluded()")
- isses := []*issues.Issue{}
- stateOption := "open"
- if cfg.allowClosed {
- stateOption = "all"
- }
- for _, er := range report.ExcludedReasons {
- label := er.ToLabel()
- tempIssues, err :=
- cfg.issuesClient.Issues(ctx, issues.IssuesOptions{Labels: []string{label}, State: stateOption})
- if err != nil {
- return err
- }
- log.Infof("found %d issues with label %s\n", len(tempIssues), label)
- isses = append(isses, tempIssues...)
+func (c *create) parseArgs(ctx context.Context, args []string) ([]string, error) {
+ if len(args) == 0 {
+ return nil, fmt.Errorf("no arguments provided")
}
- var created []string
- for _, iss := range isses {
- // Don't create a report for an issue that already has a report.
- if _, ok := cfg.existingByIssue[iss.Number]; ok {
- log.Infof("skipped issue %d which already has a report\n", iss.Number)
- continue
- }
-
- r, err := createReport(ctx, cfg, iss)
- if err != nil {
- log.Errf("skipped issue %d: %v\n", iss.Number, err)
- continue
- }
-
- filename, err := writeReport(r)
- if err != nil {
- return err
- }
-
- created = append(created, filename)
- }
-
- skipped := len(isses) - len(created)
- if skipped > 0 {
- log.Infof("skipped %d issue(s)\n", skipped)
- }
-
- if len(created) == 0 {
- log.Infof("no files to commit, exiting")
- return nil
- }
-
- msg, err := excludedCommitMsg(created)
- if err != nil {
- return err
- }
- if err := gitAdd(created...); err != nil {
- return err
- }
- return gitCommit(msg, created...)
-}
-
-type createCfg struct {
- ghsaClient *ghsa.Client
- issuesClient *issues.Client
- proxyClient *proxy.Client
- existingByFile map[string]*report.Report
- existingByIssue map[int]*report.Report
- allowClosed bool
- aiClient *genai.GeminiClient
-}
-
-func setupCreate(ctx context.Context, args []string) ([]int, *createCfg, error) {
- if *githubToken == "" {
- return nil, nil, fmt.Errorf("githubToken must be provided")
- }
- localRepo, err := gitrepo.Open(ctx, ".")
- if err != nil {
- return nil, nil, err
- }
- existingByIssue, existingByFile, err := report.All(localRepo)
- if err != nil {
- return nil, nil, err
- }
- githubIDs, err := parseArgsToGithubIDs(args, existingByIssue)
- if err != nil {
- return nil, nil, err
- }
- owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
- if err != nil {
- return nil, nil, err
- }
- var aiClient *genai.GeminiClient
- if *useAI {
- aiClient, err = genai.NewGeminiClient(ctx)
- if err != nil {
- return nil, nil, err
- }
- }
- return githubIDs, &createCfg{
- issuesClient: issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken}),
- ghsaClient: ghsa.NewClient(ctx, *githubToken),
- proxyClient: proxy.NewDefaultClient(),
- existingByFile: existingByFile,
- existingByIssue: existingByIssue,
- allowClosed: *closedOk,
- aiClient: aiClient,
- }, nil
-}
-
-func parseArgsToGithubIDs(args []string, existingByIssue map[int]*report.Report) ([]int, error) {
- var githubIDs []int
+ var githubIDs []string
parseGithubID := func(s string) (int, error) {
id, err := strconv.Atoi(s)
if err != nil {
@@ -181,11 +132,11 @@
}
for _, arg := range args {
if !strings.Contains(arg, "-") {
- id, err := parseGithubID(arg)
+ _, err := parseGithubID(arg)
if err != nil {
return nil, err
}
- githubIDs = append(githubIDs, id)
+ githubIDs = append(githubIDs, arg)
continue
}
from, to, _ := strings.Cut(arg, "-")
@@ -201,25 +152,23 @@
return nil, fmt.Errorf("%v > %v", fromID, toID)
}
for id := fromID; id <= toID; id++ {
- if existingByIssue[id] != nil {
+ if c.existingByIssue[id] != nil {
continue
}
- githubIDs = append(githubIDs, id)
+ githubIDs = append(githubIDs, strconv.Itoa(id))
}
}
return githubIDs, nil
}
-func createReport(ctx context.Context, cfg *createCfg, iss *issues.Issue) (r *report.Report, err error) {
- defer derrors.Wrap(&err, "createReport(%d)", iss.Number)
-
- parsed, err := parseGithubIssue(iss, cfg.proxyClient, cfg.allowClosed)
+func createReport(ctx context.Context, iss *issues.Issue, pc *proxy.Client, gc *ghsa.Client, ac *genai.GeminiClient, allowClosed bool) (r *report.Report, err error) {
+ parsed, err := parseGithubIssue(iss, pc, allowClosed)
if err != nil {
return nil, err
}
r, err = reportFromAliases(ctx, parsed.id, parsed.modulePath, parsed.aliases,
- cfg.proxyClient, cfg.ghsaClient, cfg.aiClient)
+ pc, gc, ac)
if err != nil {
return nil, err
}
@@ -237,8 +186,6 @@
GHSAs: r.GHSAs,
}
}
-
- addTODOs(r)
return r, nil
}
@@ -269,7 +216,7 @@
addMissingAliases(ctx, r, gc)
if ac != nil {
- suggestions, err := suggest(ctx, ac, r, 1)
+ suggestions, err := suggestions(ctx, ac, r, 1)
if err != nil {
log.Warnf("failed to get AI-generated suggestions for %s: %v\n", r.ID, err)
} else if len(suggestions) == 0 {
@@ -338,29 +285,6 @@
excluded report.ExcludedReason
}
-func excludedCommitMsg(fs []string) (string, error) {
- var issNums []string
- for _, f := range fs {
- _, _, iss, err := report.ParseFilepath(f)
- if err != nil {
- return "", err
- }
- issNums = append(issNums, fmt.Sprintf("Fixes golang/vulndb#%d", iss))
- }
-
- return fmt.Sprintf(
- `%s: batch add %d excluded reports
-
-Adds excluded reports:
- - %s
-
-%s`,
- report.ExcludedDir,
- len(fs),
- strings.Join(fs, "\n\t- "),
- strings.Join(issNums, "\n")), nil
-}
-
// reportFromBestAlias returns a new report created from the "best" alias in the list.
// For now, it prefers the first GHSA in the list, followed by the first CVE in the list
// (if no GHSA is present). If no GHSAs or CVEs are present, it returns a new empty Report.
diff --git a/cmd/vulnreport/create_excluded.go b/cmd/vulnreport/create_excluded.go
new file mode 100644
index 0000000..fcbfb06
--- /dev/null
+++ b/cmd/vulnreport/create_excluded.go
@@ -0,0 +1,180 @@
+// 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"
+ "flag"
+ "fmt"
+ "strconv"
+ "strings"
+
+ "golang.org/x/vulndb/cmd/vulnreport/log"
+ "golang.org/x/vulndb/internal/genai"
+ "golang.org/x/vulndb/internal/ghsa"
+ "golang.org/x/vulndb/internal/gitrepo"
+ "golang.org/x/vulndb/internal/issues"
+ "golang.org/x/vulndb/internal/proxy"
+ "golang.org/x/vulndb/internal/report"
+)
+
+var dry = flag.Bool("dry", false, "for create-excluded, do not commit files")
+
+type createExcluded struct {
+ gc *ghsa.Client
+ ic *issues.Client
+ pc *proxy.Client
+ ac *genai.GeminiClient
+ existingByIssue map[int]*report.Report
+ allowClosed bool
+
+ isses map[string]*issues.Issue
+ created []string
+}
+
+func (createExcluded) name() string { return "create-excluded" }
+
+func (createExcluded) usage() (string, string) {
+ const desc = "creates and commits reports for Github issues marked excluded"
+ return "", desc
+}
+
+func (c *createExcluded) close() error {
+ skipped := len(c.isses) - len(c.created)
+ if skipped > 0 {
+ log.Infof("skipped %d issue(s)\n", skipped)
+ }
+
+ if len(c.created) == 0 {
+ log.Infof("no files to commit, exiting")
+ return nil
+ }
+
+ msg, err := excludedCommitMsg(c.created)
+ if err != nil {
+ return err
+ }
+
+ if *dry {
+ log.Outf("create-excluded would commit files:\n\n\t%s\n\nwith message:\n\n%s", strings.Join(c.created, "\n\t"), msg)
+ return nil
+ }
+
+ if err := gitAdd(c.created...); err != nil {
+ return err
+ }
+ return gitCommit(msg, c.created...)
+}
+
+func (c *createExcluded) setup(ctx context.Context) error {
+ if *githubToken == "" {
+ return fmt.Errorf("githubToken must be provided")
+ }
+ localRepo, err := gitrepo.Open(ctx, ".")
+ if err != nil {
+ return err
+ }
+ existingByIssue, _, err := report.All(localRepo)
+ if err != nil {
+ return err
+ }
+ owner, repoName, err := gitrepo.ParseGitHubRepo(*issueRepo)
+ if err != nil {
+ return err
+ }
+ var aiClient *genai.GeminiClient
+ if *useAI {
+ aiClient, err = genai.NewGeminiClient(ctx)
+ if err != nil {
+ return err
+ }
+ }
+
+ c.ic = issues.NewClient(ctx, &issues.Config{Owner: owner, Repo: repoName, Token: *githubToken})
+ c.gc = ghsa.NewClient(ctx, *githubToken)
+ c.pc = proxy.NewDefaultClient()
+ c.existingByIssue = existingByIssue
+ c.allowClosed = *closedOk
+ c.ac = aiClient
+ c.isses = make(map[string]*issues.Issue)
+
+ return nil
+}
+
+func (c *createExcluded) parseArgs(ctx context.Context, args []string) (issNums []string, err error) {
+ if len(args) > 0 {
+ return nil, fmt.Errorf("expected no arguments")
+ }
+
+ stateOption := "open"
+ if c.allowClosed {
+ stateOption = "all"
+ }
+
+ for _, er := range report.ExcludedReasons {
+ label := er.ToLabel()
+ is, err := c.ic.Issues(ctx, issues.IssuesOptions{Labels: []string{label}, State: stateOption})
+ if err != nil {
+ return nil, err
+ }
+ log.Infof("found %d issues with label %s\n", len(is), label)
+
+ for _, iss := range is {
+ if _, ok := c.existingByIssue[iss.Number]; ok {
+ log.Infof("skipping issue %d which already has a report\n", iss.Number)
+ continue
+ }
+
+ n := strconv.Itoa(iss.Number)
+ c.isses[n] = iss
+ issNums = append(issNums, n)
+ }
+ }
+
+ return issNums, nil
+}
+
+func (c *createExcluded) run(ctx context.Context, issNum string) (err error) {
+ iss, ok := c.isses[issNum]
+ if !ok {
+ return fmt.Errorf("BUG: could not find issue %s (this should have been populated in parseArgs)", issNum)
+ }
+
+ r, err := createReport(ctx, iss, c.pc, c.gc, c.ac, c.allowClosed)
+ if err != nil {
+ return err
+ }
+
+ filename, err := writeReport(r)
+ if err != nil {
+ return err
+ }
+
+ c.created = append(c.created, filename)
+ return nil
+}
+
+func excludedCommitMsg(fs []string) (string, error) {
+ var issNums []string
+ for _, f := range fs {
+ _, _, iss, err := report.ParseFilepath(f)
+ if err != nil {
+ return "", err
+ }
+ issNums = append(issNums, fmt.Sprintf("Fixes golang/vulndb#%d", iss))
+ }
+
+ return fmt.Sprintf(
+ `%s: batch add %d excluded reports
+
+Adds excluded reports:
+ - %s
+
+%s`,
+ report.ExcludedDir,
+ len(fs),
+ strings.Join(fs, "\n\t- "),
+ strings.Join(issNums, "\n")), nil
+}
diff --git a/cmd/vulnreport/cve.go b/cmd/vulnreport/cve.go
index ba4cbb2..91f82a8 100644
--- a/cmd/vulnreport/cve.go
+++ b/cmd/vulnreport/cve.go
@@ -9,12 +9,25 @@
"golang.org/x/vulndb/cmd/vulnreport/log"
"golang.org/x/vulndb/internal/database"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/report"
)
-func cveCmd(_ context.Context, filename string) (err error) {
- defer derrors.Wrap(&err, "cve(%q)", filename)
+type cveCmd struct{ filenameParser }
+
+func (cveCmd) name() string { return "cve" }
+
+func (cveCmd) usage() (string, string) {
+ const desc = "creates and saves CVE 5.0 record from the provided YAML reports"
+ return filenameArgs, desc
+}
+
+func (c *cveCmd) setup(ctx context.Context) error {
+ return nil
+}
+
+func (c *cveCmd) close() error { return nil }
+
+func (c *cveCmd) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
diff --git a/cmd/vulnreport/fix.go b/cmd/vulnreport/fix.go
index 5f5ce9d..f6d8420 100644
--- a/cmd/vulnreport/fix.go
+++ b/cmd/vulnreport/fix.go
@@ -15,7 +15,6 @@
"github.com/google/go-cmp/cmp"
"golang.org/x/exp/slices"
"golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/ghsa"
"golang.org/x/vulndb/internal/osvutils"
"golang.org/x/vulndb/internal/proxy"
@@ -29,14 +28,38 @@
skipSymbols = flag.Bool("skip-symbols", false, "for lint and fix, don't load package for symbols checks")
)
-func fix(ctx context.Context, filename string, ghsaClient *ghsa.Client, pc *proxy.Client, force bool) (err error) {
- defer derrors.Wrap(&err, "fix(%q)", filename)
- log.Infof("fix %s\n", filename)
+type fix struct {
+ pc *proxy.Client
+ gc *ghsa.Client
+ filenameParser
+}
+
+func (fix) name() string { return "fix" }
+
+func (fix) usage() (string, string) {
+ const desc = "fix a YAML report"
+ return filenameArgs, desc
+}
+
+func (f *fix) setup(ctx context.Context) error {
+ f.pc = proxy.NewDefaultClient()
+ f.gc = ghsa.NewClient(ctx, *githubToken)
+ return nil
+}
+
+func (*fix) close() error { return nil }
+
+func (f *fix) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
}
+
+ return fixReport(ctx, r, filename, f.pc, f.gc)
+}
+
+func fixReport(ctx context.Context, r *report.Report, filename string, pc *proxy.Client, gc *ghsa.Client) error {
if err := r.CheckFilename(filename); err != nil {
return err
}
@@ -49,7 +72,7 @@
}
}()
- if lints := r.Lint(pc); force || len(lints) > 0 {
+ if lints := r.Lint(pc); *force || len(lints) > 0 {
r.Fix(pc)
}
if lints := r.Lint(pc); len(lints) > 0 {
@@ -64,7 +87,7 @@
}
if !*skipAlias {
log.Infof("%s: checking for missing GHSAs and CVEs (use -skip-alias to skip this)", r.ID)
- if added := addMissingAliases(ctx, r, ghsaClient); added > 0 {
+ if added := addMissingAliases(ctx, r, gc); added > 0 {
log.Infof("%s: added %d missing aliases", r.ID, added)
}
}
diff --git a/cmd/vulnreport/lint.go b/cmd/vulnreport/lint.go
index cd203e3..0b319b1 100644
--- a/cmd/vulnreport/lint.go
+++ b/cmd/vulnreport/lint.go
@@ -7,16 +7,31 @@
import (
"context"
- "golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/proxy"
"golang.org/x/vulndb/internal/report"
)
-func lint(_ context.Context, filename string, pc *proxy.Client) (err error) {
- defer derrors.Wrap(&err, "lint(%q)", filename)
- log.Infof("lint %s\n", filename)
+type lint struct {
+ pc *proxy.Client
- _, err = report.ReadAndLint(filename, pc)
+ filenameParser
+}
+
+func (lint) name() string { return "lint" }
+
+func (lint) usage() (string, string) {
+ const desc = "lints vulnerability YAML reports"
+ return filenameArgs, desc
+}
+
+func (l *lint) setup(ctx context.Context) error {
+ l.pc = proxy.NewDefaultClient()
+ return nil
+}
+
+func (l *lint) close() error { return nil }
+
+func (l *lint) run(ctx context.Context, filename string) (err error) {
+ _, err = report.ReadAndLint(filename, l.pc)
return err
}
diff --git a/cmd/vulnreport/main.go b/cmd/vulnreport/main.go
index e8c5b50..f79d88a 100644
--- a/cmd/vulnreport/main.go
+++ b/cmd/vulnreport/main.go
@@ -12,15 +12,10 @@
"fmt"
"log"
"os"
- "path/filepath"
"runtime/pprof"
+ "text/tabwriter"
vlog "golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/genai"
- "golang.org/x/vulndb/internal/ghsa"
- "golang.org/x/vulndb/internal/gitrepo"
- "golang.org/x/vulndb/internal/proxy"
- "golang.org/x/vulndb/internal/report"
)
var (
@@ -31,25 +26,40 @@
func init() {
vlog.Init(*quiet)
+ out := flag.CommandLine.Output()
+ flag.Usage = func() {
+ fmt.Fprintf(out, "usage: vulnreport [flags] [cmd] [args]\n\n")
+ tw := tabwriter.NewWriter(out, 2, 4, 2, ' ', 0)
+ for _, command := range commands {
+ argUsage, desc := command.usage()
+ fmt.Fprintf(tw, " %s\t%s\t%s\n", command.name(), argUsage, desc)
+ }
+ tw.Flush()
+ fmt.Fprint(out, "\nsupported flags:\n\n")
+ flag.PrintDefaults()
+ }
+}
+
+// The subcommands supported by vulnreport.
+// To add a new command, implement the command interface and
+// add the command to this list.
+var commands = map[string]command{
+ "create": &create{},
+ "create-excluded": &createExcluded{},
+ "commit": &commit{},
+ "cve": &cveCmd{},
+ "fix": &fix{},
+ "lint": &lint{},
+ "set-dates": &setDates{},
+ "suggest": &suggest{},
+ "symbols": &symbolsCmd{},
+ "osv": &osvCmd{},
+ "unexclude": &unexclude{},
+ "xref": &xref{},
}
func main() {
ctx := context.Background()
- flag.Usage = func() {
- fmt.Fprintf(flag.CommandLine.Output(), "usage: vulnreport [cmd] [filename.yaml]\n")
- fmt.Fprintf(flag.CommandLine.Output(), " create [githubIssueNumber]: creates a new vulnerability YAML report\n")
- fmt.Fprintf(flag.CommandLine.Output(), " create-excluded: creates and commits all open github issues marked as excluded\n")
- fmt.Fprintf(flag.CommandLine.Output(), " symbols filename.yaml: finds and populates possible vulnerable symbols for a given report\n")
- fmt.Fprintf(flag.CommandLine.Output(), " lint filename.yaml ...: lints vulnerability YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " cve filename.yaml ...: creates and saves CVE 5.0 record from the provided YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " fix filename.yaml ...: fixes and reformats YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " osv filename.yaml ...: converts YAML reports to OSV JSON and writes to data/osv\n")
- fmt.Fprintf(flag.CommandLine.Output(), " set-dates filename.yaml ...: sets PublishDate of YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " suggest filename.yaml ...: (EXPERIMENTAL) use AI to suggest summary and description for YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " commit filename.yaml ...: creates new commits for YAML reports\n")
- fmt.Fprintf(flag.CommandLine.Output(), " xref filename.yaml ...: prints cross references for YAML reports\n")
- flag.PrintDefaults()
- }
flag.Parse()
if flag.NArg() < 1 {
@@ -61,18 +71,6 @@
*githubToken = os.Getenv("VULN_GITHUB_ACCESS_TOKEN")
}
- var (
- args []string
- cmd = flag.Arg(0)
- )
- if cmd != "create-excluded" {
- if flag.NArg() < 2 {
- flag.Usage()
- log.Fatal("not enough arguments")
- }
- args = flag.Args()[1:]
- }
-
// Start CPU profiler.
if *cpuprofile != "" {
f, err := os.Create(*cpuprofile)
@@ -83,117 +81,16 @@
defer pprof.StopCPUProfile()
}
- // setupCreate clones the CVEList repo and can be very slow,
- // so commands that require this functionality are separated from other
- // commands.
- if cmd == "create-excluded" || cmd == "create" {
- githubIDs, cfg, err := setupCreate(ctx, args)
- if err != nil {
- log.Fatal(err)
- }
- switch cmd {
- case "create-excluded":
- if err = createExcluded(ctx, cfg); err != nil {
- log.Fatal(err)
- }
- case "create":
- // Unlike commands below, create operates on github issue IDs
- // instead of filenames.
- for _, githubID := range githubIDs {
- if err := create(ctx, githubID, cfg); err != nil {
- vlog.Err(err)
- }
- }
- }
- return
- }
+ cmdName := flag.Arg(0)
+ args := flag.Args()[1:]
- ghsaClient := ghsa.NewClient(ctx, *githubToken)
- pc := proxy.NewDefaultClient()
- var cmdFunc func(context.Context, string) error
- switch cmd {
- case "lint":
- cmdFunc = func(ctx context.Context, name string) error { return lint(ctx, name, pc) }
- case "suggest":
- cmdFunc = func(ctx context.Context, name string) error { return suggestCmd(ctx, name) }
- case "commit":
- cmdFunc = func(ctx context.Context, name string) error { return commit(ctx, name, ghsaClient, pc, *force) }
- case "cve":
- cmdFunc = func(ctx context.Context, name string) error { return cveCmd(ctx, name) }
- case "fix":
- cmdFunc = func(ctx context.Context, name string) error { return fix(ctx, name, ghsaClient, pc, *force) }
- case "symbols":
- cmdFunc = func(ctx context.Context, name string) error { return findSymbols(ctx, name) }
- case "osv":
- cmdFunc = func(ctx context.Context, name string) error { return osvCmd(ctx, name, pc) }
- case "set-dates":
- repo, err := gitrepo.Open(ctx, ".")
- if err != nil {
- log.Fatal(err)
- }
- commitDates, err := gitrepo.AllCommitDates(repo, gitrepo.MainReference, report.YAMLDir)
- if err != nil {
- log.Fatal(err)
- }
- cmdFunc = func(ctx context.Context, name string) error { return setDates(ctx, name, commitDates) }
- case "unexclude":
- var ac *genai.GeminiClient
- var err error
- if *useAI {
- ac, err = genai.NewGeminiClient(ctx)
- if err != nil {
- log.Fatal(err)
- }
- defer ac.Close()
- }
- cmdFunc = func(ctx context.Context, name string) error { return unexclude(ctx, name, ghsaClient, pc, ac) }
- case "xref":
- repo, err := gitrepo.Open(ctx, ".")
- if err != nil {
- log.Fatal(err)
- }
- _, existingByFile, err := report.All(repo)
- if err != nil {
- log.Fatal(err)
- }
- cmdFunc = func(ctx context.Context, name string) error {
- r, err := report.Read(name)
- if err != nil {
- return err
- }
- vlog.Out(name)
- vlog.Out(xref(name, r, existingByFile))
- return nil
- }
- default:
+ cmd, ok := commands[cmdName]
+ if !ok {
flag.Usage()
- log.Fatalf("unsupported command: %q", cmd)
+ log.Fatalf("unsupported command: %q", cmdName)
}
- // Run the command on each argument.
- for _, arg := range args {
- arg, err := argToFilename(arg)
- if err != nil {
- vlog.Err(err)
- continue
- }
- if err := cmdFunc(ctx, arg); err != nil {
- vlog.Err(err)
- }
+ if err := run(ctx, cmd, args); err != nil {
+ log.Fatalf("%s: %s", cmdName, err)
}
}
-
-func argToFilename(arg string) (string, error) {
- if _, err := os.Stat(arg); err != nil {
- // If arg isn't a file, see if it might be an issue ID
- // with an existing report.
- for _, padding := range []string{"", "0", "00", "000"} {
- m, _ := filepath.Glob("data/*/GO-*-" + padding + arg + ".yaml")
- if len(m) == 1 {
- return m[0], nil
- }
- }
- return "", fmt.Errorf("%s is not a valid filename or issue ID with existing report: %w", arg, err)
- }
- return arg, nil
-}
diff --git a/cmd/vulnreport/osv.go b/cmd/vulnreport/osv.go
index 091df84..fb65616 100644
--- a/cmd/vulnreport/osv.go
+++ b/cmd/vulnreport/osv.go
@@ -10,15 +10,32 @@
"golang.org/x/vulndb/cmd/vulnreport/log"
"golang.org/x/vulndb/internal/database"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/proxy"
"golang.org/x/vulndb/internal/report"
)
-func osvCmd(_ context.Context, filename string, pc *proxy.Client) (err error) {
- defer derrors.Wrap(&err, "osv(%q)", filename)
+type osvCmd struct {
+ pc *proxy.Client
- r, err := report.ReadAndLint(filename, pc)
+ filenameParser
+}
+
+func (osvCmd) name() string { return "osv" }
+
+func (osvCmd) usage() (string, string) {
+ const desc = "converts YAML reports to OSV JSON and writes to data/osv"
+ return filenameArgs, desc
+}
+
+func (o *osvCmd) setup(ctx context.Context) error {
+ o.pc = proxy.NewDefaultClient()
+ return nil
+}
+
+func (o *osvCmd) close() error { return nil }
+
+func (o *osvCmd) run(ctx context.Context, filename string) (err error) {
+ r, err := report.ReadAndLint(filename, o.pc)
if err != nil {
return err
}
diff --git a/cmd/vulnreport/set_dates.go b/cmd/vulnreport/set_dates.go
index f9dc3e1..b7e3e6b 100644
--- a/cmd/vulnreport/set_dates.go
+++ b/cmd/vulnreport/set_dates.go
@@ -8,11 +8,38 @@
"context"
"fmt"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/gitrepo"
"golang.org/x/vulndb/internal/report"
)
+type setDates struct {
+ dates map[string]gitrepo.Dates
+
+ filenameParser
+}
+
+func (setDates) name() string { return "set-dates" }
+
+func (setDates) usage() (string, string) {
+ const desc = "sets PublishDate of YAML reports"
+ return filenameArgs, desc
+}
+
+func (sd *setDates) setup(ctx context.Context) error {
+ repo, err := gitrepo.Open(ctx, ".")
+ if err != nil {
+ return err
+ }
+ dates, err := gitrepo.AllCommitDates(repo, gitrepo.MainReference, report.YAMLDir)
+ if err != nil {
+ return err
+ }
+ sd.dates = dates
+ return nil
+}
+
+func (sd *setDates) close() error { return nil }
+
// setDates sets the PublishedDate of the report at filename to the oldest
// commit date in the repo that contains that file. (It may someday also set a
// last-modified date, hence the plural.) Since it looks at the commits from
@@ -29,9 +56,7 @@
// date can. Always using the git history as the source of truth for the
// last-modified date avoids confusion if the report YAML and the git history
// disagree.
-func setDates(_ context.Context, filename string, dates map[string]gitrepo.Dates) (err error) {
- defer derrors.Wrap(&err, "setDates(%q)", filename)
-
+func (sd *setDates) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
@@ -39,7 +64,7 @@
if !r.Published.IsZero() {
return nil
}
- d, ok := dates[filename]
+ d, ok := sd.dates[filename]
if !ok {
return fmt.Errorf("can't find git repo commit dates for %q", filename)
}
diff --git a/cmd/vulnreport/suggest.go b/cmd/vulnreport/suggest.go
index f32052f..f063068 100644
--- a/cmd/vulnreport/suggest.go
+++ b/cmd/vulnreport/suggest.go
@@ -10,7 +10,6 @@
"fmt"
"golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/genai"
"golang.org/x/vulndb/internal/report"
)
@@ -20,21 +19,43 @@
numSuggestions = flag.Int("n", 1, "for suggest, the number of suggestions to generate (>1 can be slow)")
)
-func suggestCmd(ctx context.Context, filename string) (err error) {
- defer derrors.Wrap(&err, "suggest(%q)", filename)
+type suggest struct {
+ ac *genai.GeminiClient
+ filenameParser
+}
+
+func (suggest) name() string { return "suggest" }
+
+func (suggest) usage() (string, string) {
+ const desc = "(EXPERIMENTAL) use AI to suggest summary and description for YAML reports"
+ return filenameArgs, desc
+}
+
+func (s *suggest) setup(ctx context.Context) error {
+ ac, err := genai.NewGeminiClient(ctx)
+ if err != nil {
+ return err
+ }
+ s.ac = ac
+ return nil
+}
+
+func (s *suggest) close() error {
+ if s.ac == nil {
+ return nil
+ }
+ return s.ac.Close()
+}
+
+func (s *suggest) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
}
log.Info("contacting the Gemini API...")
- c, err := genai.NewGeminiClient(ctx)
- if err != nil {
- return err
- }
-
- suggestions, err := suggest(ctx, c, r, *numSuggestions)
+ suggestions, err := suggestions(ctx, s.ac, r, *numSuggestions)
if err != nil {
return err
}
@@ -79,7 +100,7 @@
return nil
}
-func suggest(ctx context.Context, c genai.Client, r *report.Report, max int) (suggestions []*genai.Suggestion, err error) {
+func suggestions(ctx context.Context, c genai.Client, r *report.Report, max int) (suggestions []*genai.Suggestion, err error) {
attempts := 0
maxAttempts := max + 2
for len(suggestions) < max && attempts < maxAttempts {
diff --git a/cmd/vulnreport/symbols.go b/cmd/vulnreport/symbols.go
index 59c85b1..852d397 100644
--- a/cmd/vulnreport/symbols.go
+++ b/cmd/vulnreport/symbols.go
@@ -11,16 +11,25 @@
"strings"
"golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/osv"
"golang.org/x/vulndb/internal/report"
"golang.org/x/vulndb/internal/symbols"
)
-func findSymbols(_ context.Context, filename string) (err error) {
- defer derrors.Wrap(&err, "findSymbols(%q)", filename)
- log.Infof("symbols %s\n", filename)
+type symbolsCmd struct{ filenameParser }
+func (symbolsCmd) name() string { return "symbols" }
+
+func (symbolsCmd) usage() (string, string) {
+ const desc = "finds and populates possible vulnerable symbols for a given report"
+ return filenameArgs, desc
+}
+
+func (s *symbolsCmd) setup(ctx context.Context) error { return nil }
+
+func (s *symbolsCmd) close() error { return nil }
+
+func (s *symbolsCmd) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
diff --git a/cmd/vulnreport/unexclude.go b/cmd/vulnreport/unexclude.go
index 9a4aecb..974b1d4 100644
--- a/cmd/vulnreport/unexclude.go
+++ b/cmd/vulnreport/unexclude.go
@@ -9,19 +9,51 @@
"os"
"golang.org/x/vulndb/cmd/vulnreport/log"
- "golang.org/x/vulndb/internal/derrors"
"golang.org/x/vulndb/internal/genai"
"golang.org/x/vulndb/internal/ghsa"
"golang.org/x/vulndb/internal/proxy"
"golang.org/x/vulndb/internal/report"
)
+type unexclude struct {
+ gc *ghsa.Client
+ pc *proxy.Client
+ ac *genai.GeminiClient
+
+ filenameParser
+}
+
+func (unexclude) name() string { return "unexclude" }
+
+func (unexclude) usage() (string, string) {
+ const desc = "converts excluded YAML reports to regular YAML reports"
+ return filenameArgs, desc
+}
+
+func (u *unexclude) setup(ctx context.Context) error {
+ u.gc = ghsa.NewClient(ctx, *githubToken)
+ u.pc = proxy.NewDefaultClient()
+
+ if *useAI {
+ ac, err := genai.NewGeminiClient(ctx)
+ if err != nil {
+ return err
+ }
+ u.ac = ac
+ }
+
+ return nil
+}
+
+func (u *unexclude) close() error {
+ if u.ac != nil {
+ return u.ac.Close()
+ }
+ return nil
+}
+
// unexclude converts an excluded report into a regular report.
-func unexclude(ctx context.Context, filename string, gc *ghsa.Client, pc *proxy.Client, ac *genai.GeminiClient) (err error) {
- defer derrors.Wrap(&err, "unexclude(%s)", filename)
-
- log.Infof("unexclude %s", filename)
-
+func (u *unexclude) run(ctx context.Context, filename string) (err error) {
r, err := report.Read(filename)
if err != nil {
return err
@@ -49,7 +81,7 @@
if len(r.Modules) > 0 {
modulePath = r.Modules[0].Module
}
- newR, err := reportFromAliases(ctx, id, modulePath, aliases, pc, gc, ac)
+ newR, err := reportFromAliases(ctx, id, modulePath, aliases, u.pc, u.gc, u.ac)
if err != nil {
return err
}
diff --git a/cmd/vulnreport/xref.go b/cmd/vulnreport/xref.go
index 60c0cd8..a86f70e 100644
--- a/cmd/vulnreport/xref.go
+++ b/cmd/vulnreport/xref.go
@@ -5,21 +5,62 @@
package main
import (
+ "context"
"fmt"
"strings"
"golang.org/x/exp/constraints"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
+ vlog "golang.org/x/vulndb/cmd/vulnreport/log"
+ "golang.org/x/vulndb/internal/gitrepo"
"golang.org/x/vulndb/internal/report"
)
-// xref returns cross-references for a report: Information about other reports
+type xref struct {
+ existingByFile map[string]*report.Report
+
+ filenameParser
+}
+
+func (xref) name() string { return "xref" }
+
+func (xref) usage() (string, string) {
+ const desc = "prints cross references for YAML reports"
+ return filenameArgs, desc
+}
+
+func (x *xref) setup(ctx context.Context) error {
+ repo, err := gitrepo.Open(ctx, ".")
+ if err != nil {
+ return err
+ }
+ _, existingByFile, err := report.All(repo)
+ if err != nil {
+ return err
+ }
+ x.existingByFile = existingByFile
+ return nil
+}
+
+func (x *xref) close() error { return nil }
+
+// run returns cross-references for a report: Information about other reports
// for the same CVE, GHSA, or module.
-func xref(rname string, r *report.Report, existingByFile map[string]*report.Report) string {
+func (x *xref) run(ctx context.Context, filename string) (err error) {
+ r, err := report.Read(filename)
+ if err != nil {
+ return err
+ }
+ vlog.Out(filename)
+ vlog.Out(xrefInner(filename, r, x.existingByFile))
+ return nil
+}
+
+func xrefInner(filename string, r *report.Report, existingByFile map[string]*report.Report) string {
out := &strings.Builder{}
matches := report.XRef(r, existingByFile)
- delete(matches, rname)
+ delete(matches, filename)
// This sorts as CVEs, GHSAs, and then modules.
for _, fname := range sorted(maps.Keys(matches)) {
for _, id := range sorted(matches[fname]) {