cmd/govulncheck: HTML output

If the -html flag is provided, print HTML to standard out.

The HTML is similar to the default text output, except
full call stacks are available by opening detail elements.

Change-Id: I7ea9bfeb6f9cd43b66e8a659c2b61a6b2e5a95a3
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/395554
Trust: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
Reviewed-by: Julie Qiu <julie@golang.org>
diff --git a/cmd/govulncheck/html.go b/cmd/govulncheck/html.go
new file mode 100644
index 0000000..70a4105
--- /dev/null
+++ b/cmd/govulncheck/html.go
@@ -0,0 +1,112 @@
+// 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 (
+	"fmt"
+	"html/template"
+	"io"
+
+	"golang.org/x/vuln/vulncheck"
+)
+
+func html(w io.Writer, r *vulncheck.Result, callStacks map[*vulncheck.Vuln][]vulncheck.CallStack, moduleVersions map[string]string, topPackages map[string]bool, vulnGroups [][]*vulncheck.Vuln) error {
+	tmpl, err := template.New("").Funcs(template.FuncMap{
+		"funcName": funcName,
+	}).Parse(templateSource)
+	if err != nil {
+		return err
+	}
+
+	type vuln struct {
+		PkgPath        string
+		CurrentVersion string
+		FixedVersion   string
+		Reference      string
+		Details        string
+	}
+
+	type callstack struct {
+		Summary string
+		Stack   vulncheck.CallStack
+	}
+
+	type callstacks struct {
+		ID     string // osv.Entry ID
+		Stacks []callstack
+	}
+
+	data := struct {
+		Vulns      []vuln
+		CallStacks []callstacks
+	}{}
+
+	for _, vg := range vulnGroups {
+		v0 := vg[0]
+		data.Vulns = append(data.Vulns, vuln{
+			PkgPath:        v0.PkgPath,
+			CurrentVersion: moduleVersions[v0.ModPath],
+			FixedVersion:   "v" + latestFixed(v0.OSV.Affected),
+			Reference:      fmt.Sprintf("https://pkg.go.dev/vuln/%s", v0.OSV.ID),
+			Details:        v0.OSV.Details,
+		})
+		// Keep first call stack for each vuln.
+		stacks := callstacks{ID: v0.OSV.ID}
+		for _, v := range vg {
+			if css := callStacks[v]; len(css) > 0 {
+				stacks.Stacks = append(stacks.Stacks, callstack{
+					Summary: summarizeCallStack(css[0], topPackages, v.PkgPath),
+					Stack:   css[0],
+				})
+			}
+		}
+		data.CallStacks = append(data.CallStacks, stacks)
+	}
+	return tmpl.Execute(w, data)
+}
+
+var templateSource = `
+<!DOCTYPE html>
+<html lang="en">
+<meta charset="utf-8">
+<title>govulncheck Results</title>
+
+<body>
+  {{with .Vulns}}
+	<h2>Vulnerabilities</h2>
+	<table>
+	  <tr><th>Package</th><th>Your Version</th><th>Fixed Version</th><th>Reference</th><th>Details</th><tr>
+	  {{range .Vulns}}
+		<tr>
+		  <td>{[.PkgPath}}</td>
+		  <td>{{.CurrentVersion}}</td>
+		  <td>{{.FixedVersion}}</td>
+		  <td>{{.Reference}}</td>
+		  <td>{{.Details}}</td>
+		</tr>
+	  {{end}}
+	</table>
+
+	 <h2>Call Stacks</h2>
+	 {{range .CallStacks}}
+	   <h3>.ID</h3>
+	   {{range .Stacks}}
+		 <details>
+		   <summary>{{.Summary}}</summary>
+		   {{range .Stack}}
+			 <p>{{.Function | funcName}}</p>
+		   {{end}}
+		 </details>
+	   {{end}}
+	 {{end}}
+  {{else}}
+    No vulnerabilities found.
+  {{end}}
+</body>
+</html>
+`
diff --git a/cmd/govulncheck/main.go b/cmd/govulncheck/main.go
index 63bc149..05327d8 100644
--- a/cmd/govulncheck/main.go
+++ b/cmd/govulncheck/main.go
@@ -40,6 +40,7 @@
 	jsonFlag    = flag.Bool("json", false, "")
 	verboseFlag = flag.Bool("v", false, "")
 	testsFlag   = flag.Bool("tests", false, "")
+	htmlFlag    = flag.Bool("html", false, "")
 )
 
 const usage = `govulncheck: identify known vulnerabilities by call graph traversal.
@@ -52,11 +53,13 @@
 
 Flags:
 
-	-json  	   Print vulnerability findings in JSON format.
+	-json	Print vulnerability findings in JSON format.
 
-	-tags	   Comma-separated list of build tags.
+	-html	Generate HTML with the vulnerability findings.
 
-	-tests     Boolean flag indicating if test files should be analyzed too.
+	-tags	Comma-separated list of build tags.
+
+	-tests	Boolean flag indicating if test files should be analyzed too.
 
 govulncheck can be used with either one or more package patterns (i.e. golang.org/x/crypto/...
 or ./...) or with a single path to a Go binary. In the latter case module and symbol
@@ -135,7 +138,20 @@
 	if *jsonFlag {
 		writeJSON(r)
 	} else {
-		writeText(r, pkgs, moduleVersions)
+		callStacks := vulncheck.CallStacks(r)
+		// Create set of top-level packages, used to find representative symbols
+		topPackages := map[string]bool{}
+		for _, p := range pkgs {
+			topPackages[p.PkgPath] = true
+		}
+		vulnGroups := groupByIDAndPackage(r.Vulns)
+		if *htmlFlag {
+			if err := html(os.Stdout, r, callStacks, moduleVersions, topPackages, vulnGroups); err != nil {
+				die("writing HTML: %v", err)
+			}
+		} else {
+			writeText(r, callStacks, moduleVersions, topPackages, vulnGroups)
+		}
 	}
 	exitCode := 0
 	// Following go vet, fail with 3 if there are findings (in this case, vulns).
@@ -154,24 +170,11 @@
 	fmt.Println()
 }
 
-func writeText(r *vulncheck.Result, pkgs []*packages.Package, moduleVersions map[string]string) {
-	if len(r.Vulns) == 0 {
-		return
-	}
-	callStacks := vulncheck.CallStacks(r)
-
+func writeText(r *vulncheck.Result, callStacks map[*vulncheck.Vuln][]vulncheck.CallStack, moduleVersions map[string]string, topPackages map[string]bool, vulnGroups [][]*vulncheck.Vuln) {
 	const labelWidth = 16
 	line := func(label, text string) {
 		fmt.Printf("%-*s%s\n", labelWidth, label, text)
 	}
-
-	// Create set of top-level packages, used to find
-	// representative symbols
-	topPackages := map[string]bool{}
-	for _, p := range pkgs {
-		topPackages[p.PkgPath] = true
-	}
-	vulnGroups := groupByIDAndPackage(r.Vulns)
 	for _, vg := range vulnGroups {
 		// All the vulns in vg have the same PkgPath, ModPath and OSV.
 		// All have a non-zero CallSink.