gopls/internal/vulncheck: include nonaffecting vulnerability info

This info is still useful to tell users that some required modules
have known vulnerabilities, but the analyzed packages/workspaces
are not affected.

Those vulnerabilities are missing Symbol/PkgPath/CallStacks.

Change-Id: I94ea0d8f9ebcb1270e05f055caff2a18ebacd034
Reviewed-on: https://go-review.googlesource.com/c/tools/+/412457
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/gopls/internal/vulncheck/command.go b/gopls/internal/vulncheck/command.go
index a89354f..53bf0f0 100644
--- a/gopls/internal/vulncheck/command.go
+++ b/gopls/internal/vulncheck/command.go
@@ -11,12 +11,15 @@
 	"context"
 	"log"
 	"os"
+	"sort"
 	"strings"
 
 	"golang.org/x/tools/go/packages"
 	gvc "golang.org/x/tools/gopls/internal/govulncheck"
 	"golang.org/x/tools/internal/lsp/command"
 	"golang.org/x/vuln/client"
+	"golang.org/x/vuln/osv"
+	"golang.org/x/vuln/vulncheck"
 )
 
 func init() {
@@ -79,29 +82,84 @@
 	}
 	log.Printf("loaded %d packages\n", len(loadedPkgs))
 
-	r, err := gvc.Source(ctx, loadedPkgs, c.Client)
+	log.Printf("analyzing %d packages...\n", len(loadedPkgs))
+
+	r, err := vulncheck.Source(ctx, loadedPkgs, &vulncheck.Config{Client: c.Client})
 	if err != nil {
 		return nil, err
 	}
+	unaffectedMods := filterUnaffected(r.Vulns)
+	r.Vulns = filterCalled(r)
+
 	callInfo := gvc.GetCallInfo(r, loadedPkgs)
-	return toVulns(callInfo)
+	return toVulns(callInfo, unaffectedMods)
 	// TODO: add import graphs.
 }
 
-func toVulns(ci *gvc.CallInfo) ([]Vuln, error) {
+// filterCalled returns vulnerabilities where the symbols are actually called.
+func filterCalled(r *vulncheck.Result) []*vulncheck.Vuln {
+	var vulns []*vulncheck.Vuln
+	for _, v := range r.Vulns {
+		if v.CallSink != 0 {
+			vulns = append(vulns, v)
+		}
+	}
+	return vulns
+}
+
+// filterUnaffected returns vulnerabilities where no symbols are called,
+// grouped by module.
+func filterUnaffected(vulns []*vulncheck.Vuln) map[string][]*osv.Entry {
+	// It is possible that the same vuln.OSV.ID has vuln.CallSink != 0
+	// for one symbol, but vuln.CallSink == 0 for a different one, so
+	// we need to filter out ones that have been called.
+	called := map[string]bool{}
+	for _, vuln := range vulns {
+		if vuln.CallSink != 0 {
+			called[vuln.OSV.ID] = true
+		}
+	}
+
+	modToIDs := map[string]map[string]*osv.Entry{}
+	for _, vuln := range vulns {
+		if !called[vuln.OSV.ID] {
+			if _, ok := modToIDs[vuln.ModPath]; !ok {
+				modToIDs[vuln.ModPath] = map[string]*osv.Entry{}
+			}
+			// keep only one vuln.OSV instance for the same ID.
+			modToIDs[vuln.ModPath][vuln.OSV.ID] = vuln.OSV
+		}
+	}
+	output := map[string][]*osv.Entry{}
+	for m, vulnSet := range modToIDs {
+		var vulns []*osv.Entry
+		for _, vuln := range vulnSet {
+			vulns = append(vulns, vuln)
+		}
+		sort.Slice(vulns, func(i, j int) bool { return vulns[i].ID < vulns[j].ID })
+		output[m] = vulns
+	}
+	return output
+}
+
+func fixed(v *osv.Entry) string {
+	lf := gvc.LatestFixed(v.Affected)
+	if lf != "" && lf[0] != 'v' {
+		lf = "v" + lf
+	}
+	return lf
+}
+
+func toVulns(ci *gvc.CallInfo, unaffectedMods map[string][]*osv.Entry) ([]Vuln, error) {
 	var vulns []Vuln
 
 	for _, vg := range ci.VulnGroups {
 		v0 := vg[0]
-		lf := gvc.LatestFixed(v0.OSV.Affected)
-		if lf != "" && lf[0] != 'v' {
-			lf = "v" + lf
-		}
 		vuln := Vuln{
 			ID:             v0.OSV.ID,
 			PkgPath:        v0.PkgPath,
 			CurrentVersion: ci.ModuleVersions[v0.ModPath],
-			FixedVersion:   lf,
+			FixedVersion:   fixed(v0.OSV),
 			Details:        v0.OSV.Details,
 
 			Aliases: v0.OSV.Aliases,
@@ -119,5 +177,19 @@
 		}
 		vulns = append(vulns, vuln)
 	}
+	for m, vg := range unaffectedMods {
+		for _, v0 := range vg {
+			vuln := Vuln{
+				ID:             v0.ID,
+				Details:        v0.Details,
+				Aliases:        v0.Aliases,
+				ModPath:        m,
+				URL:            href(v0),
+				CurrentVersion: "",
+				FixedVersion:   fixed(v0),
+			}
+			vulns = append(vulns, vuln)
+		}
+	}
 	return vulns, nil
 }
diff --git a/gopls/internal/vulncheck/command_test.go b/gopls/internal/vulncheck/command_test.go
index f689ab9..f6e2d1b 100644
--- a/gopls/internal/vulncheck/command_test.go
+++ b/gopls/internal/vulncheck/command_test.go
@@ -81,6 +81,15 @@
 						"golang.org/bmod/bvuln.Vuln (bvuln.go:2)\n",
 				},
 			},
+			{
+				Vuln: Vuln{
+					ID:           "GO-2022-03",
+					Details:      "unaffecting vulnerability",
+					ModPath:      "golang.org/amod",
+					URL:          "https://pkg.go.dev/vuln/GO-2022-03",
+					FixedVersion: "v1.0.4",
+				},
+			},
 		}
 		// sort reports for stability before comparison.
 		for _, rpts := range [][]report{got, want} {
@@ -228,6 +237,21 @@
 					EcosystemSpecific: osv.EcosystemSpecific{Symbols: []string{"VulnData.Vuln1", "VulnData.Vuln2"}},
 				}},
 			},
+			{
+				ID:      "GO-2022-03",
+				Details: "unaffecting vulnerability",
+				References: []osv.Reference{
+					{
+						Type: "href",
+						URL:  "pkg.go.dev/vuln/GO-2022-01",
+					},
+				},
+				Affected: []osv.Affected{{
+					Package:           osv.Package{Name: "golang.org/amod/avuln"},
+					Ranges:            osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "1.0.0"}, {Fixed: "1.0.4"}, {Introduced: "1.1.2"}}}},
+					EcosystemSpecific: osv.EcosystemSpecific{Symbols: []string{"nonExisting"}},
+				}},
+			},
 		},
 		"golang.org/bmod": {
 			{
diff --git a/internal/lsp/command/interface.go b/internal/lsp/command/interface.go
index 8e4b105..1f3b092 100644
--- a/internal/lsp/command/interface.go
+++ b/internal/lsp/command/interface.go
@@ -359,8 +359,10 @@
 	Aliases []string `json:",omitempty"`
 
 	// Symbol is the name of the detected vulnerable function or method.
+	// Can be empty if the vulnerability exists in required modules, but no vulnerable symbols are used.
 	Symbol string `json:",omitempty"`
 	// PkgPath is the package path of the detected Symbol.
+	// Can be empty if the vulnerability exists in required modules, but no vulnerable packages are used.
 	PkgPath string `json:",omitempty"`
 	// ModPath is the module path corresponding to PkgPath.
 	// TODO: how do we specify standard library's vulnerability?