cmd/govulncheck: add default formatting

Output results from source analysis as a table.

For the fixed version, choose the latest fixed version available.

This CL is cherry-picked from
https://go-review.googlesource.com/c/exp/+/390855, with the modification
that go1.18 build tags have been added to the new files.

Change-Id: I53f69325332a2332ba91c57fd16cd751ffa8baa0
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/395236
Trust: Julie Qiu <julie@golang.org>
Run-TryBot: Julie Qiu <julie@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/govulncheck/formatting.go b/cmd/govulncheck/formatting.go
new file mode 100644
index 0000000..d1a7b02
--- /dev/null
+++ b/cmd/govulncheck/formatting.go
@@ -0,0 +1,125 @@
+// 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.
+
+//go:build go1.18
+// +build go1.18
+
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"strings"
+)
+
+// wrap wraps s to fit in maxWidth by breaking it into lines at whitespace. If a
+// single word is longer than maxWidth, it is retained as its own line.
+func wrap(s string, maxWidth int) string {
+	var b strings.Builder
+	w := 0
+
+	for _, f := range strings.Fields(s) {
+		if w > 0 && w+len(f)+1 > maxWidth {
+			b.WriteByte('\n')
+			w = 0
+		}
+		if w != 0 {
+			b.WriteByte(' ')
+			w++
+		}
+		b.WriteString(f)
+		w += len(f)
+	}
+	return b.String()
+}
+
+type table struct {
+	headings []string
+	lines    [][]string
+}
+
+func newTable(headings ...string) *table {
+	return &table{headings: headings}
+}
+
+func (t *table) row(cells ...string) {
+	// Split each cell into lines.
+	// Track the max number of lines.
+	var cls [][]string
+	max := 0
+	for _, c := range cells {
+		ls := strings.Split(c, "\n")
+		if len(ls) > max {
+			max = len(ls)
+		}
+		cls = append(cls, ls)
+	}
+	// Add each line to the table.
+	for i := 0; i < max; i++ {
+		var line []string
+		for _, cl := range cls {
+			if i >= len(cl) {
+				line = append(line, "")
+			} else {
+				line = append(line, cl[i])
+			}
+		}
+		t.lines = append(t.lines, line)
+	}
+}
+
+func (t *table) write(w io.Writer) (err error) {
+	// Calculate column widths.
+	widths := make([]int, len(t.headings))
+	for i, h := range t.headings {
+		widths[i] = len(h)
+	}
+	for _, l := range t.lines {
+		for i, c := range l {
+			if len(c) > widths[i] {
+				widths[i] = len(c)
+			}
+		}
+	}
+
+	totalWidth := 0
+	for _, w := range widths {
+		totalWidth += w
+	}
+	// Account for a space between columns.
+	totalWidth += len(widths) - 1
+	dashes := strings.Repeat("-", totalWidth)
+
+	writeLine := func(s string) {
+		if err == nil {
+			_, err = io.WriteString(w, s)
+		}
+		if err == nil {
+			_, err = io.WriteString(w, "\n")
+		}
+	}
+
+	writeCells := func(cells []string) {
+		var buf bytes.Buffer
+		for i, c := range cells {
+			if i > 0 {
+				buf.WriteByte(' ')
+			}
+			fmt.Fprintf(&buf, "%-*s", widths[i], c)
+		}
+		writeLine(strings.TrimRight(buf.String(), " "))
+	}
+
+	// Write headings.
+	writeLine(dashes)
+	writeCells(t.headings)
+	writeLine(dashes)
+
+	// Write body.
+	for _, l := range t.lines {
+		writeCells(l)
+	}
+	return err
+}
diff --git a/cmd/govulncheck/formatting_test.go b/cmd/govulncheck/formatting_test.go
new file mode 100644
index 0000000..d91dd0f
--- /dev/null
+++ b/cmd/govulncheck/formatting_test.go
@@ -0,0 +1,78 @@
+// 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.
+
+//go:build go1.18
+// +build go1.18
+
+package main
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestWrap(t *testing.T) {
+	const width = 10
+	for _, test := range []struct {
+		in, want string
+	}{
+		{"", ""},
+		{"foo", "foo"},
+		{"omnivorous", "omnivorous"},   // equals width
+		{"carnivorous", "carnivorous"}, // exceeds width
+		{
+			"A carnivorous beast.",
+			"A\ncarnivorous\nbeast.",
+		},
+		{
+			"An omnivorous beast.",
+			"An\nomnivorous\nbeast.",
+		},
+		{
+			"A nivorous beast.",
+			"A nivorous\nbeast.",
+		},
+		{
+			"Carnivorous beasts of the forest primeval.",
+			"Carnivorous\nbeasts of\nthe forest\nprimeval.",
+		},
+		{
+			"Able was I ere I saw Elba.",
+			"Able was I\nere I saw\nElba.",
+		},
+	} {
+		got := wrap(test.in, width)
+		if got != test.want {
+			t.Errorf("\ngot:\n%s\n\nwant:\n%s", got, test.want)
+		}
+	}
+}
+
+func TestTable(t *testing.T) {
+	tab := newTable("Package", "Version", "Description")
+	tab.row("p", "v", "d")
+	tab.row("github.com/foo/bar", "v1.2.3", wrap("Could be a denial-of-service attack.", 10))
+	tab.row("x", "y\nz", "w")
+
+	var w bytes.Buffer
+	if err := tab.write(&w); err != nil {
+		t.Fatal(err)
+	}
+
+	got := w.String()
+	want := `--------------------------------------------
+Package            Version Description
+--------------------------------------------
+p                  v       d
+github.com/foo/bar v1.2.3  Could be a
+                           denial-of-service
+                           attack.
+x                  y       w
+                   z
+`
+	if got != want {
+		t.Errorf("got\n%s\n\nwant\n%s", got, want)
+	}
+
+}
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 3143283..01ae99f 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -28,9 +28,11 @@
 	"strings"
 
 	"golang.org/x/exp/vulncheck"
+	"golang.org/x/mod/semver"
 	"golang.org/x/tools/go/buildutil"
 	"golang.org/x/tools/go/packages"
 	"golang.org/x/vuln/client"
+	"golang.org/x/vuln/osv"
 )
 
 var (
@@ -73,8 +75,7 @@
 	flag.Parse()
 
 	if len(flag.Args()) == 0 {
-		fmt.Fprint(os.Stderr, usage)
-		os.Exit(1)
+		die("%s", usage)
 	}
 
 	dbs := []string{"https://storage.googleapis.com/go-vulndb"}
@@ -83,37 +84,88 @@
 	}
 	dbClient, err := client.NewClient(dbs, client.Options{HTTPCache: defaultCache()})
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "govulncheck: %s\n", err)
-		os.Exit(1)
+		die("govulncheck: %s", err)
+	}
+	vcfg := &vulncheck.Config{
+		Client: dbClient,
+	}
+	ctx := context.Background()
+
+	patterns := flag.Args()
+	var r *vulncheck.Result
+	var pkgs []*packages.Package
+	if len(patterns) == 1 && isFile(patterns[0]) {
+		f, err := os.Open(patterns[0])
+		if err != nil {
+			die("govulncheck: %v", err)
+		}
+		defer f.Close()
+		r, err = vulncheck.Binary(ctx, f, vcfg)
+		if err != nil {
+			die("govulncheck: %v", err)
+		}
+	} else {
+		cfg := &packages.Config{
+			Mode:       packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedTypes | packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps | packages.NeedModule,
+			Tests:      *testsFlag,
+			BuildFlags: []string{fmt.Sprintf("-tags=%s", strings.Join(build.Default.BuildTags, ","))},
+		}
+		pkgs, err = loadPackages(cfg, patterns)
+		if err != nil {
+			die("govulncheck: %v", err)
+		}
+		r, err = vulncheck.Source(ctx, vulncheck.Convert(pkgs), vcfg)
+		if err != nil {
+			die("govulncheck: %v", err)
+		}
+	}
+	if *jsonFlag {
+		writeJSON(r)
+	} else {
+		writeText(r, pkgs)
 	}
 
-	cfg := &packages.Config{
-		Mode:       packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles | packages.NeedImports | packages.NeedTypes | packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps | packages.NeedModule,
-		Tests:      *testsFlag,
-		BuildFlags: []string{fmt.Sprintf("-tags=%s", strings.Join(build.Default.BuildTags, ","))},
-	}
-
-	r, err := run(cfg, flag.Args(), dbClient)
-	if err != nil {
-		fmt.Fprintf(os.Stderr, "govulncheck: %s\n", err)
-		os.Exit(1)
-	}
-	writeOut(r, *jsonFlag)
 }
 
-func writeOut(r *vulncheck.Result, toJson bool) {
-	if !toJson {
-		fmt.Println(r)
-		return
-	}
-
+func writeJSON(r *vulncheck.Result) {
 	b, err := json.MarshalIndent(r, "", "\t")
 	if err != nil {
-		fmt.Fprintf(os.Stderr, "govulncheck: %s\n", err)
-		os.Exit(1)
+		die("govulncheck: %s", err)
 	}
 	os.Stdout.Write(b)
-	os.Stdout.Write([]byte{'\n'})
+	fmt.Println()
+}
+
+func writeText(r *vulncheck.Result, pkgs []*packages.Package) {
+	if len(r.Vulns) == 0 {
+		return
+	}
+	// Build a map from module paths to versions.
+	moduleVersions := map[string]string{}
+	packages.Visit(pkgs, nil, func(p *packages.Package) {
+		m := p.Module
+		if m != nil {
+			if m.Replace != nil {
+				m = m.Replace
+			}
+			moduleVersions[m.Path] = m.Version
+		}
+	})
+	t := newTable("Info", "Description", "Symbols")
+	for _, v := range r.Vulns {
+		desc := wrap(v.OSV.Details, 30)
+		current := moduleVersions[v.ModPath]
+		fixed := "v" + latestFixed(v.OSV.Affected)
+		ref := fmt.Sprintf("https://pkg.go.dev/vuln/%s", v.OSV.ID)
+		col1 := fmt.Sprintf("%s\nyours: %s\nfixed: %s\n%s",
+			v.PkgPath, current, fixed, ref)
+		t.row(col1, desc, " "+v.Symbol)
+		// empty row
+		t.row("", "", "")
+	}
+	if err := t.write(os.Stdout); err != nil {
+		die("govulncheck: %v", err)
+	}
 }
 
 func isFile(path string) bool {
@@ -124,20 +176,7 @@
 	return !s.IsDir()
 }
 
-func run(cfg *packages.Config, patterns []string, dbClient client.Client) (*vulncheck.Result, error) {
-	vcfg := &vulncheck.Config{
-		Client: dbClient,
-	}
-	if len(patterns) == 1 && isFile(patterns[0]) {
-		f, err := os.Open(patterns[0])
-		if err != nil {
-			return nil, err
-		}
-		defer f.Close()
-		return vulncheck.Binary(context.Background(), f, vcfg)
-	}
-
-	// Load packages.
+func loadPackages(cfg *packages.Config, patterns []string) ([]*packages.Package, error) {
 	if *verboseFlag {
 		log.Println("loading packages...")
 	}
@@ -151,7 +190,28 @@
 	if *verboseFlag {
 		log.Printf("\t%d loaded packages\n", len(pkgs))
 	}
+	return pkgs, nil
+}
 
-	return vulncheck.Source(context.Background(), vulncheck.Convert(pkgs), vcfg)
+// latestFixed returns the latest fixed version in the list of affected ranges,
+// or the empty string if there are no fixed versions.
+func latestFixed(as []osv.Affected) string {
+	v := ""
+	for _, a := range as {
+		for _, r := range a.Ranges {
+			if r.Type == osv.TypeSemver {
+				for _, e := range r.Events {
+					if e.Fixed != "" && (v == "" || semver.Compare(e.Fixed, v) > 0) {
+						v = e.Fixed
+					}
+				}
+			}
+		}
+	}
+	return v
+}
 
+func die(format string, args ...interface{}) {
+	fmt.Fprintf(os.Stderr, format+"\n", args...)
+	os.Exit(1)
 }
diff --git a/cmd/govulncheck/main_test.go b/cmd/govulncheck/main_test.go
new file mode 100644
index 0000000..624e311
--- /dev/null
+++ b/cmd/govulncheck/main_test.go
@@ -0,0 +1,86 @@
+// 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.
+
+//go:build go1.18
+// +build go1.18
+
+package main
+
+import (
+	"testing"
+
+	"golang.org/x/vuln/osv"
+)
+
+func TestLatestFixed(t *testing.T) {
+	for _, test := range []struct {
+		name string
+		in   []osv.Affected
+		want string
+	}{
+		{"empty", nil, ""},
+		{
+			"no semver",
+			[]osv.Affected{
+				{
+					Ranges: osv.Affects{
+						{
+							Type: osv.TypeGit,
+							Events: []osv.RangeEvent{
+								{Introduced: "v1.0.0", Fixed: "v1.2.3"},
+							},
+						}},
+				},
+			},
+			"",
+		},
+		{
+			"one",
+			[]osv.Affected{
+				{
+					Ranges: osv.Affects{
+						{
+							Type: osv.TypeSemver,
+							Events: []osv.RangeEvent{
+								{Introduced: "v1.0.0", Fixed: "v1.2.3"},
+							},
+						}},
+				},
+			},
+			"v1.2.3",
+		},
+		{
+			"several",
+			[]osv.Affected{
+				{
+					Ranges: osv.Affects{
+						{
+							Type: osv.TypeSemver,
+							Events: []osv.RangeEvent{
+								{Introduced: "v1.0.0", Fixed: "v1.2.3"},
+								{Introduced: "v1.5.0", Fixed: "v1.5.6"},
+							},
+						}},
+				},
+				{
+					Ranges: osv.Affects{
+						{
+							Type: osv.TypeSemver,
+							Events: []osv.RangeEvent{
+								{Introduced: "v1.3.0", Fixed: "v1.4.1"},
+							},
+						}},
+				},
+			},
+			"v1.5.6",
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			got := latestFixed(test.in)
+			if got != test.want {
+				t.Errorf("got %q, want %q", got, test.want)
+			}
+		})
+	}
+}