cmd/govulncheck: add verbose mode

First attempt at verbose output for govulncheck, selected by
the -v flag.

The output is the same as default mode, except that instead of
summarized call stacks, we show full call stacks. Limit to
one per vulnerable symbol.

This required increasing the scope of the file path filter
in TestCommand, to include all output.

Change-Id: Ia5fc8db4906fc472a6ccf4ac87d440815f21ee26
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/406577
Reviewed-by: Damien Neil <dneil@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 7e8a0f0..545c104 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -23,7 +23,6 @@
 	"flag"
 	"fmt"
 	"go/build"
-	"log"
 	"os"
 	"sort"
 	"strings"
@@ -114,7 +113,7 @@
 			Tests:      *testsFlag,
 			BuildFlags: []string{fmt.Sprintf("-tags=%s", strings.Join(build.Default.BuildTags, ","))},
 		}
-		pkgs, err = loadPackages(cfg, patterns)
+		pkgs, err = govulncheck.LoadPackages(cfg, patterns...)
 		if err != nil {
 			die("govulncheck: %v", err)
 		}
@@ -153,48 +152,100 @@
 	fmt.Println()
 }
 
-func writeText(r *vulncheck.Result, ci *govulncheck.CallInfo) {
+const labelWidth = 16
 
-	const labelWidth = 16
-	line := func(label, text string) {
-		fmt.Printf("%-*s%s\n", labelWidth, label, text)
-	}
+func writeLine(label, text string) {
+	fmt.Printf("%-*s%s\n", labelWidth, label, text)
+}
+
+func writeText(r *vulncheck.Result, ci *govulncheck.CallInfo) {
 	for _, vg := range ci.VulnGroups {
 		// All the vulns in vg have the same PkgPath, ModPath and OSV.
 		// All have a non-zero CallSink.
 		v0 := vg[0]
-		line("package:", v0.PkgPath)
-		line("your version:", ci.ModuleVersions[v0.ModPath])
-		line("fixed version:", "v"+govulncheck.LatestFixed(v0.OSV.Affected))
-		var summaries []string
-		for _, v := range vg {
-			if css := ci.CallStacks[v]; len(css) > 0 {
-				if sum := govulncheck.SummarizeCallStack(css[0], ci.TopPackages, v.PkgPath); sum != "" {
-					summaries = append(summaries, sum)
-				}
-			}
+		writeLine("package:", v0.PkgPath)
+		writeLine("your version:", ci.ModuleVersions[v0.ModPath])
+		writeLine("fixed version:", "v"+govulncheck.LatestFixed(v0.OSV.Affected))
+		if *verboseFlag {
+			writeCallStacksVerbose(vg, ci)
+		} else {
+			writeCallStacksDefault(vg, ci)
 		}
-		if len(summaries) > 0 {
-			sort.Strings(summaries)
-			summaries = compact(summaries)
-			line("sample call stacks:", "")
-			for _, s := range summaries {
-				line("", s)
-			}
-		}
-		line("reference:", fmt.Sprintf("https://pkg.go.dev/vuln/%s", v0.OSV.ID))
+		writeLine("reference:", fmt.Sprintf("https://pkg.go.dev/vuln/%s", v0.OSV.ID))
 		desc := strings.Split(wrap(v0.OSV.Details, 80-labelWidth), "\n")
 		for i, l := range desc {
 			if i == 0 {
-				line("description:", l)
+				writeLine("description:", l)
 			} else {
-				line("", l)
+				writeLine("", l)
 			}
 		}
 		fmt.Println()
 	}
 }
 
+func writeCallStacksDefault(vg []*vulncheck.Vuln, ci *govulncheck.CallInfo) {
+
+	var summaries []string
+	for _, v := range vg {
+		if css := ci.CallStacks[v]; len(css) > 0 {
+			if sum := govulncheck.SummarizeCallStack(css[0], ci.TopPackages, v.PkgPath); sum != "" {
+				summaries = append(summaries, sum)
+			}
+		}
+	}
+	if len(summaries) > 0 {
+		sort.Strings(summaries)
+		summaries = compact(summaries)
+		fmt.Println("sample call stacks:")
+		for _, s := range summaries {
+			writeLine("", s)
+		}
+	}
+}
+
+func writeCallStacksVerbose(vg []*vulncheck.Vuln, ci *govulncheck.CallInfo) {
+	// Display one full call stack for each vuln.
+	fmt.Println("call stacks:")
+	nMore := 0
+	i := 1
+	for _, v := range vg {
+		css := ci.CallStacks[v]
+		if len(css) == 0 {
+			continue
+		}
+		fmt.Printf("    #%d: for function %s\n", i, v.Symbol)
+		writeCallStack(css[0])
+		fmt.Println()
+		i++
+		nMore += len(css) - 1
+	}
+	if nMore > 0 {
+		fmt.Printf("    There are %d more call stacks available.\n", nMore)
+		fmt.Printf("To     see all of them, pass the -json or -html flags.\n")
+	}
+}
+
+func writeCallStack(cs vulncheck.CallStack) {
+	for _, e := range cs {
+		fmt.Printf("        %s\n", govulncheck.FuncName(e.Function))
+		if e.Call != nil && e.Call.Pos != nil {
+			fmt.Printf("            %s\n", e.Call.Pos.String())
+		}
+	}
+}
+
+func packageModule(p *packages.Package) *packages.Module {
+	m := p.Module
+	if m == nil {
+		return nil
+	}
+	if r := m.Replace; r != nil {
+		return r
+	}
+	return m
+}
+
 func isFile(path string) bool {
 	s, err := os.Stat(path)
 	if err != nil {
@@ -203,20 +254,6 @@
 	return !s.IsDir()
 }
 
-func loadPackages(cfg *packages.Config, patterns []string) ([]*vulncheck.Package, error) {
-	if *verboseFlag {
-		log.Println("loading packages...")
-	}
-	pkgs, err := govulncheck.LoadPackages(cfg, patterns...)
-	if err != nil {
-		return nil, err
-	}
-	if *verboseFlag {
-		log.Printf("\t%d loaded packages\n", len(pkgs))
-	}
-	return pkgs, nil
-}
-
 // compact replaces consecutive runs of equal elements with a single copy.
 // This is like the uniq command found on Unix.
 // compact modifies the contents of the slice s; it does not create a new slice.
diff --git a/cmd/govulncheck/main_test.go b/cmd/govulncheck/main_test.go
index 28f3553..32dd1c2 100644
--- a/cmd/govulncheck/main_test.go
+++ b/cmd/govulncheck/main_test.go
@@ -56,12 +56,7 @@
 		}
 		cmd.Env = append(os.Environ(), "GOVULNDB=file://"+testDir+"/testdata/vulndb")
 		out, err := cmd.CombinedOutput()
-		for _, arg := range args {
-			if arg == "-json" {
-				out = filterJSON(out)
-				break
-			}
-		}
+		out = filterGoFilePaths(out)
 		return out, err
 	}
 
@@ -81,15 +76,16 @@
 	ts.Run(t, *update)
 }
 
-var goFileRegexp = regexp.MustCompile(`"[^"]*\.go"`)
+var goFileRegexp = regexp.MustCompile(`[^\s"]*\.go[\s":]`)
 
-// filterJSON  modifies paths to Go files by replacing their directory with "...".
-// For example, "/a/b/c.go" becomes ".../c.go".
-// This makes it possible to compare govulncheck JSON  output across systems, because
-// Go filenames in JSON output include setup-specific paths.
-func filterJSON(data []byte) []byte {
+// filterGoFilePaths  modifies paths to Go files by replacing their directory with "...".
+// For example,/a/b/c.go becomes .../c.go .
+// This makes it possible to compare govulncheck output across systems, because
+// Go filenames include setup-specific paths.
+func filterGoFilePaths(data []byte) []byte {
 	return goFileRegexp.ReplaceAllFunc(data, func(b []byte) []byte {
-		return []byte(fmt.Sprintf(`".../%s"`, filepath.Base(string(b)[1:len(b)-1])))
+		s := string(b)
+		return []byte(fmt.Sprintf(`.../%s%c`, filepath.Base(s[1:len(s)-1]), s[len(s)-1]))
 	})
 }
 
diff --git a/cmd/govulncheck/testdata/verbose.ct b/cmd/govulncheck/testdata/verbose.ct
new file mode 100644
index 0000000..95c1311
--- /dev/null
+++ b/cmd/govulncheck/testdata/verbose.ct
@@ -0,0 +1,22 @@
+# Test of verbose mode.
+
+# No vulnerabilities, no output.
+$ cdmodule novuln
+$ govulncheck -v .
+
+$ cdmodule vuln
+$ govulncheck -v . --> FAIL 3
+package:        golang.org/x/text/language
+your version:   v0.3.0
+fixed version:  v0.3.7
+call stacks:
+    #1: for function Parse
+        vuln.main
+            .../vuln.go:11:16
+        golang.org/x/text/language.Parse
+
+reference:      https://pkg.go.dev/vuln/GO-2021-0113
+description:    Due to improper index calculation, an incorrectly formatted
+                language tag can cause Parse to panic via an out of bounds read.
+                If Parse is used to process untrusted user inputs, this may be
+                used as a vector for a denial of service attack.