cmd/govulncheck: summarized call stacks

This CL is another attempt at compact but helpful default output.

For each vuln, pick a single call stack and summarize it.

The vulncheck package sorts the call stacks in a reasonable way, so
we use the first one.

Sample output:

Change-Id: I9a2928a4ce1f3b79f9c6b09e79cd2c53490756b5

---
package:        github.com/opencontainers/selinux/go-selinux
your version:   v0.0.0-20170621221121-4a2974bf1ee9
fixed version:  v1.0.0-rc8.0.20190930145003-cad42f6e0932
sample call stacks:
                k8s.io/kubernetes/pkg/util/selinux.SELinuxEnabled calls github.com/opencontainers/selinux/go-selinux.GetEnabled
                k8s.io/kubernetes/pkg/util/selinux.SetFileLabel calls github.com/opencontainers/selinux/go-selinux.SetFileLabel
                k8s.io/kubernetes/pkg/util/selinux.realSELinuxRunner.Getfilecon calls github.com/opencontainers/selinux/go-selinux.FileLabel
reference:      https://pkg.go.dev/vuln/GO-2021-0085
description:    AppArmor restrictions may be bypassed due to improper validation
                of mount targets, allowing a malicious image to mount volumes
                over e.g. /proc.
---

Change-Id: I982228e84dcd71870d67a467fd789547ef24d484
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/395156
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Zvonimir Pavlinovic <zpavlinovic@google.com>
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 123de4f..63bc149 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -176,26 +176,26 @@
 		// All the vulns in vg have the same PkgPath, ModPath and OSV.
 		// All have a non-zero CallSink.
 		v0 := vg[0]
-
-		current := moduleVersions[v0.ModPath]
-		fixed := "v" + latestFixed(v0.OSV.Affected)
-		ref := fmt.Sprintf("https://pkg.go.dev/vuln/%s", v0.OSV.ID)
-
-		fns := representativeFuncs(vg, topPackages, callStacks)
-		// Use first as representative.
-		rep := funcName(fns[0])
-		var syms string
-		if len(fns) == 1 {
-			syms = rep
-		} else {
-			syms = fmt.Sprintf("%s and %d others", rep, len(fns)-1)
-		}
 		line("package:", v0.PkgPath)
-		line("your version:", current)
-		line("fixed version:", fixed)
-		line("symbols:", syms)
-		line("reference:", ref)
-
+		line("your version:", moduleVersions[v0.ModPath])
+		line("fixed version:", "v"+latestFixed(v0.OSV.Affected))
+		var summaries []string
+		for _, v := range vg {
+			if css := callStacks[v]; len(css) > 0 {
+				if sum := summarizeCallStack(css[0], topPackages, v.PkgPath); sum != "" {
+					summaries = append(summaries, sum)
+				}
+			}
+		}
+		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))
 		desc := strings.Split(wrap(v0.OSV.Details, 80-labelWidth), "\n")
 		for i, l := range desc {
 			if i == 0 {
@@ -284,29 +284,59 @@
 	return v
 }
 
-// representativeFuncs collects representative functions for the group
-// of vulns from the the call stacks.
-func representativeFuncs(vg []*vulncheck.Vuln, topPkgs map[string]bool, callStacks map[*vulncheck.Vuln][]vulncheck.CallStack) []*vulncheck.FuncNode {
-	// Collect unique top of call stacks.
-	fns := map[*vulncheck.FuncNode]bool{}
-	for _, v := range vg {
-		for _, cs := range callStacks[v] {
-			// Find the lowest function in the stack that is in
-			// one of the top packages.
-			for i := len(cs) - 1; i >= 0; i-- {
-				pkg := pkgPath(cs[i].Function)
-				if topPkgs[pkg] {
-					fns[cs[i].Function] = true
-					break
-				}
-			}
+// summarizeCallStack returns a short description of the call stack.
+// It uses one of two forms, depending on what the lowest function F in topPkgs
+// calls:
+// - If it calls a function V from the vulnerable package, then summarizeCallStack
+//   returns "F calls V".
+// - If it calls a function G in some other package, which eventually calls V,
+//   it returns "F calls G, which eventually calls V".
+// If it can't find any of these functions, summarizeCallStack returns the empty string.
+func summarizeCallStack(cs vulncheck.CallStack, topPkgs map[string]bool, vulnPkg string) string {
+	// Find the lowest function in the top packages.
+	iTop := lowest(cs, func(e vulncheck.StackEntry) bool {
+		return topPkgs[pkgPath(e.Function)]
+	})
+	if iTop < 0 {
+		return ""
+	}
+	// Find the highest function in the vulnerable package that is below iTop.
+	iVuln := highest(cs[iTop+1:], func(e vulncheck.StackEntry) bool {
+		return pkgPath(e.Function) == vulnPkg
+	})
+	if iVuln < 0 {
+		return ""
+	}
+	iVuln += iTop + 1 // adjust for slice in call to highest.
+	topName := funcName(cs[iTop].Function)
+	vulnName := funcName(cs[iVuln].Function)
+	if iVuln == iTop+1 {
+		return fmt.Sprintf("%s calls %s", topName, vulnName)
+	}
+	return fmt.Sprintf("%s calls %s, which eventually calls %s",
+		topName, funcName(cs[iTop+1].Function), vulnName)
+}
+
+// highest returns the highest (one with the smallest index) entry in the call
+// stack for which f returns true.
+func highest(cs vulncheck.CallStack, f func(e vulncheck.StackEntry) bool) int {
+	for i := 0; i < len(cs); i++ {
+		if f(cs[i]) {
+			return i
 		}
 	}
-	var res []*vulncheck.FuncNode
-	for fn := range fns {
-		res = append(res, fn)
+	return -1
+}
+
+// lowest returns the lowest (one with the largets index) entry in the call
+// stack for which f returns true.
+func lowest(cs vulncheck.CallStack, f func(e vulncheck.StackEntry) bool) int {
+	for i := len(cs) - 1; i >= 0; i-- {
+		if f(cs[i]) {
+			return i
+		}
 	}
-	return res
+	return -1
 }
 
 func pkgPath(fn *vulncheck.FuncNode) string {
@@ -324,6 +354,27 @@
 	return strings.TrimPrefix(fn.String(), "*")
 }
 
+// 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.
+//
+// Modified (generics removed) from exp/slices/slices.go.
+func compact(s []string) []string {
+	if len(s) == 0 {
+		return s
+	}
+	i := 1
+	last := s[0]
+	for _, v := range s[1:] {
+		if v != last {
+			s[i] = v
+			i++
+			last = v
+		}
+	}
+	return s[:i]
+}
+
 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
index 45b37de..ba70211 100644
--- a/cmd/govulncheck/main_test.go
+++ b/cmd/govulncheck/main_test.go
@@ -8,6 +8,7 @@
 package main
 
 import (
+	"strings"
 	"testing"
 
 	"golang.org/x/vuln/osv"
@@ -110,3 +111,48 @@
 		}
 	}
 }
+
+func TestSummarizeCallStack(t *testing.T) {
+	topPkgs := map[string]bool{"t1": true, "t2": true}
+	vulnPkg := "v"
+
+	for _, test := range []struct {
+		in, want string
+	}{
+		{"a.F", ""},
+		{"t1.F", ""},
+		{"v.V", ""},
+		{
+			"t1.F v.V",
+			"t1.F calls v.V",
+		},
+		{
+			"t1.F t2.G v.V1 v.v2",
+			"t2.G calls v.V1",
+		},
+		{
+			"t1.F x.Y t2.G a.H b.I c.J v.V",
+			"t2.G calls a.H, which eventually calls v.V",
+		},
+	} {
+		in := stringToCallStack(test.in)
+		got := summarizeCallStack(in, topPkgs, vulnPkg)
+		if got != test.want {
+			t.Errorf("%s:\ngot  %s\nwant %s", test.in, got, test.want)
+		}
+	}
+}
+
+func stringToCallStack(s string) vulncheck.CallStack {
+	var cs vulncheck.CallStack
+	for _, e := range strings.Fields(s) {
+		parts := strings.Split(e, ".")
+		cs = append(cs, vulncheck.StackEntry{
+			Function: &vulncheck.FuncNode{
+				PkgPath: parts[0],
+				Name:    parts[1],
+			},
+		})
+	}
+	return cs
+}