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)
+ }
+ })
+ }
+}