gopls/internal/lsp/mod: add the vulncheck diagnostics mode

When user sets `"ui.vulncheck": "Imports"`, gopls will run the
vulnerability scanning on the modules used in the project
as part of the go.mod diagnostics. This scanning mode is less
expensive than the govulncheck callgraph analysis so it can
run almost in real time, but it is less precise than the govulncheck
callgraph analysis.

In the follow up change, we will add a code action that
triggers the more precise govulncheck callgraph analysis.

Change-Id: Ibf479c733c7e1ff98a3e74854c0f77ac6a6b5445
Reviewed-on: https://go-review.googlesource.com/c/tools/+/453156
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Robert Findley <rfindley@google.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/gopls/doc/settings.md b/gopls/doc/settings.md
index 4c3c7a8..8d32b4f 100644
--- a/gopls/doc/settings.md
+++ b/gopls/doc/settings.md
@@ -343,6 +343,20 @@
 
 Default: `{"bounds":true,"escape":true,"inline":true,"nil":true}`.
 
+##### **vulncheck** *enum*
+
+**This setting is experimental and may be deleted.**
+
+vulncheck enables vulnerability scanning.
+
+Must be one of:
+
+* `"Imports"`: In Imports mode, `gopls` will report vulnerabilities that affect packages
+directly and indirectly used by the analyzed main module.
+* `"Off"`: Disable vulnerability analysis.
+
+Default: `"Off"`.
+
 ##### **diagnosticsDelay** *time.Duration*
 
 **This is an advanced setting and should not be configured by most `gopls` users.**
diff --git a/gopls/internal/lsp/diagnostics.go b/gopls/internal/lsp/diagnostics.go
index e6e644d..4309789 100644
--- a/gopls/internal/lsp/diagnostics.go
+++ b/gopls/internal/lsp/diagnostics.go
@@ -260,14 +260,6 @@
 	}
 	store(modCheckUpgradesSource, "diagnosing go.mod upgrades", upgradeReports, upgradeErr)
 
-	// Diagnose vulnerabilities.
-	vulnReports, vulnErr := mod.VulnerabilityDiagnostics(ctx, snapshot)
-	if ctx.Err() != nil {
-		log.Trace.Log(ctx, "diagnose cancelled")
-		return
-	}
-	store(modVulncheckSource, "diagnosing vulnerabilities", vulnReports, vulnErr)
-
 	// Diagnose go.work file.
 	workReports, workErr := work.Diagnostics(ctx, snapshot)
 	if ctx.Err() != nil {
@@ -291,6 +283,14 @@
 	}
 	store(modSource, "diagnosing go.mod file", modReports, modErr)
 
+	// Diagnose vulnerabilities.
+	vulnReports, vulnErr := mod.VulnerabilityDiagnostics(ctx, snapshot)
+	if ctx.Err() != nil {
+		log.Trace.Log(ctx, "diagnose cancelled")
+		return
+	}
+	store(modVulncheckSource, "diagnosing vulnerabilities", vulnReports, vulnErr)
+
 	if s.shouldIgnoreError(ctx, snapshot, activeErr) {
 		return
 	}
diff --git a/gopls/internal/lsp/mod/diagnostics.go b/gopls/internal/lsp/mod/diagnostics.go
index e338520..dc369ad 100644
--- a/gopls/internal/lsp/mod/diagnostics.go
+++ b/gopls/internal/lsp/mod/diagnostics.go
@@ -180,7 +180,15 @@
 		return nil, err
 	}
 
+	fromGovulncheck := true
 	vs := snapshot.View().Vulnerabilities(fh.URI())[fh.URI()]
+	if vs == nil && snapshot.View().Options().Vulncheck == source.ModeVulncheckImports {
+		vs, err = snapshot.ModVuln(ctx, fh.URI())
+		if err != nil {
+			return nil, err
+		}
+		fromGovulncheck = false
+	}
 	if vs == nil || len(vs.Vulns) == 0 {
 		return nil, nil
 	}
@@ -300,11 +308,20 @@
 		}
 		if len(info) > 0 {
 			var b strings.Builder
-			switch len(info) {
-			case 1:
-				fmt.Fprintf(&b, "%v has a vulnerability %v that is not used in the code.", req.Mod.Path, info[0])
-			default:
-				fmt.Fprintf(&b, "%v has known vulnerabilities %v that are not used in the code.", req.Mod.Path, strings.Join(info, ", "))
+			if fromGovulncheck {
+				switch len(info) {
+				case 1:
+					fmt.Fprintf(&b, "%v has a vulnerability %v that is not used in the code.", req.Mod.Path, info[0])
+				default:
+					fmt.Fprintf(&b, "%v has known vulnerabilities %v that are not used in the code.", req.Mod.Path, strings.Join(info, ", "))
+				}
+			} else {
+				switch len(info) {
+				case 1:
+					fmt.Fprintf(&b, "%v has a vulnerability %v.", req.Mod.Path, info[0])
+				default:
+					fmt.Fprintf(&b, "%v has known vulnerabilities %v.", req.Mod.Path, strings.Join(info, ", "))
+				}
 			}
 			vulnDiagnostics = append(vulnDiagnostics, &source.Diagnostic{
 				URI:            fh.URI(),
diff --git a/gopls/internal/lsp/mod/hover.go b/gopls/internal/lsp/mod/hover.go
index 7068685..71ffa92 100644
--- a/gopls/internal/lsp/mod/hover.go
+++ b/gopls/internal/lsp/mod/hover.go
@@ -72,7 +72,16 @@
 	}
 
 	// Get the vulnerability info.
-	affecting, nonaffecting := lookupVulns(snapshot.View().Vulnerabilities(fh.URI())[fh.URI()], req.Mod.Path, req.Mod.Version)
+	fromGovulncheck := true
+	vs := snapshot.View().Vulnerabilities(fh.URI())[fh.URI()]
+	if vs == nil && snapshot.View().Options().Vulncheck == source.ModeVulncheckImports {
+		vs, err = snapshot.ModVuln(ctx, fh.URI())
+		if err != nil {
+			return nil, err
+		}
+		fromGovulncheck = false
+	}
+	affecting, nonaffecting := lookupVulns(vs, req.Mod.Path, req.Mod.Version)
 
 	// Get the `go mod why` results for the given file.
 	why, err := snapshot.ModWhy(ctx, fh)
@@ -95,7 +104,7 @@
 	isPrivate := snapshot.View().IsGoPrivatePath(req.Mod.Path)
 	header := formatHeader(req.Mod.Path, options)
 	explanation = formatExplanation(explanation, req, options, isPrivate)
-	vulns := formatVulnerabilities(req.Mod.Path, affecting, nonaffecting, options)
+	vulns := formatVulnerabilities(req.Mod.Path, affecting, nonaffecting, options, fromGovulncheck)
 
 	return &protocol.Hover{
 		Contents: protocol.MarkupContent{
@@ -158,7 +167,7 @@
 	return affecting, nonaffecting
 }
 
-func formatVulnerabilities(modPath string, affecting, nonaffecting []*govulncheck.Vuln, options *source.Options) string {
+func formatVulnerabilities(modPath string, affecting, nonaffecting []*govulncheck.Vuln, options *source.Options, fromGovulncheck bool) string {
 	if len(affecting) == 0 && len(nonaffecting) == 0 {
 		return ""
 	}
@@ -187,7 +196,11 @@
 		}
 	}
 	if len(nonaffecting) > 0 {
-		fmt.Fprintf(&b, "\n**FYI:** The project imports packages with known vulnerabilities, but does not call the vulnerable code.\n")
+		if fromGovulncheck {
+			fmt.Fprintf(&b, "\n**Note:** The project imports packages with known vulnerabilities, but does not call the vulnerable code.\n")
+		} else {
+			fmt.Fprintf(&b, "\n**Note:** The project imports packages with known vulnerabilities. Use `govulncheck` to check if the project uses vulnerable symbols.\n")
+		}
 	}
 	for _, v := range nonaffecting {
 		fix := fixedVersionInfo(v, modPath)
diff --git a/gopls/internal/lsp/source/api_json.go b/gopls/internal/lsp/source/api_json.go
index fc691a1..91b676a 100755
--- a/gopls/internal/lsp/source/api_json.go
+++ b/gopls/internal/lsp/source/api_json.go
@@ -499,6 +499,24 @@
 				Hierarchy: "ui.diagnostic",
 			},
 			{
+				Name: "vulncheck",
+				Type: "enum",
+				Doc:  "vulncheck enables vulnerability scanning.\n",
+				EnumValues: []EnumValue{
+					{
+						Value: "\"Imports\"",
+						Doc:   "`\"Imports\"`: In Imports mode, `gopls` will report vulnerabilities that affect packages\ndirectly and indirectly used by the analyzed main module.\n",
+					},
+					{
+						Value: "\"Off\"",
+						Doc:   "`\"Off\"`: Disable vulnerability analysis.\n",
+					},
+				},
+				Default:   "\"Off\"",
+				Status:    "experimental",
+				Hierarchy: "ui.diagnostic",
+			},
+			{
 				Name:      "diagnosticsDelay",
 				Type:      "time.Duration",
 				Doc:       "diagnosticsDelay controls the amount of time that gopls waits\nafter the most recent file modification before computing deep diagnostics.\nSimple diagnostics (parsing and type-checking) are always run immediately\non recently modified packages.\n\nThis option must be set to a valid duration string, for example `\"250ms\"`.\n",
diff --git a/gopls/internal/lsp/source/options.go b/gopls/internal/lsp/source/options.go
index 20c8f85..d6458cc 100644
--- a/gopls/internal/lsp/source/options.go
+++ b/gopls/internal/lsp/source/options.go
@@ -131,6 +131,7 @@
 							Inline: true,
 							Nil:    true,
 						},
+						Vulncheck: ModeVulncheckOff,
 					},
 					InlayHintOptions: InlayHintOptions{},
 					DocumentationOptions: DocumentationOptions{
@@ -430,6 +431,9 @@
 	// that should be reported by the gc_details command.
 	Annotations map[Annotation]bool `status:"experimental"`
 
+	// Vulncheck enables vulnerability scanning.
+	Vulncheck VulncheckMode `status:"experimental"`
+
 	// DiagnosticsDelay controls the amount of time that gopls waits
 	// after the most recent file modification before computing deep diagnostics.
 	// Simple diagnostics (parsing and type-checking) are always run immediately
@@ -692,6 +696,18 @@
 	ModeDegradeClosed MemoryMode = "DegradeClosed"
 )
 
+type VulncheckMode string
+
+const (
+	// Disable vulnerability analysis.
+	ModeVulncheckOff VulncheckMode = "Off"
+	// In Imports mode, `gopls` will report vulnerabilities that affect packages
+	// directly and indirectly used by the analyzed main module.
+	ModeVulncheckImports VulncheckMode = "Imports"
+
+	// TODO: VulncheckRequire, VulncheckCallgraph
+)
+
 type OptionResults []OptionResult
 
 type OptionResult struct {
@@ -1006,6 +1022,14 @@
 	case "annotations":
 		result.setAnnotationMap(&o.Annotations)
 
+	case "vulncheck":
+		if s, ok := result.asOneOf(
+			string(ModeVulncheckOff),
+			string(ModeVulncheckImports),
+		); ok {
+			o.Vulncheck = VulncheckMode(s)
+		}
+
 	case "codelenses", "codelens":
 		var lensOverrides map[string]bool
 		result.setBoolMap(&lensOverrides)
diff --git a/gopls/internal/lsp/source/options_test.go b/gopls/internal/lsp/source/options_test.go
index dfc464e..4fa6ecf 100644
--- a/gopls/internal/lsp/source/options_test.go
+++ b/gopls/internal/lsp/source/options_test.go
@@ -167,6 +167,28 @@
 				return !o.Annotations[Nil] && !o.Annotations[Bounds]
 			},
 		},
+		{
+			name:      "vulncheck",
+			value:     []interface{}{"invalid"},
+			wantError: true,
+			check: func(o Options) bool {
+				return o.Vulncheck == "" // For invalid value, default to 'off'.
+			},
+		},
+		{
+			name:  "vulncheck",
+			value: "Imports",
+			check: func(o Options) bool {
+				return o.Vulncheck == ModeVulncheckImports // For invalid value, default to 'off'.
+			},
+		},
+		{
+			name:  "vulncheck",
+			value: "imports",
+			check: func(o Options) bool {
+				return o.Vulncheck == ModeVulncheckImports
+			},
+		},
 	}
 
 	for _, test := range tests {
diff --git a/gopls/internal/lsp/source/view.go b/gopls/internal/lsp/source/view.go
index ad10129..a68c3c1 100644
--- a/gopls/internal/lsp/source/view.go
+++ b/gopls/internal/lsp/source/view.go
@@ -148,6 +148,10 @@
 	// the given go.mod file.
 	ModTidy(ctx context.Context, pm *ParsedModule) (*TidiedModule, error)
 
+	// ModVuln returns import vulnerability analysis for the given go.mod URI.
+	// Concurrent requests are combined into a single command.
+	ModVuln(ctx context.Context, modURI span.URI) (*govulncheck.Result, error)
+
 	// GoModForFile returns the URI of the go.mod file for the given URI.
 	GoModForFile(uri span.URI) span.URI
 
diff --git a/gopls/internal/regtest/misc/vuln_test.go b/gopls/internal/regtest/misc/vuln_test.go
index e50c38f..c37d404 100644
--- a/gopls/internal/regtest/misc/vuln_test.go
+++ b/gopls/internal/regtest/misc/vuln_test.go
@@ -9,6 +9,7 @@
 
 import (
 	"context"
+	"encoding/json"
 	"path/filepath"
 	"sort"
 	"strings"
@@ -98,6 +99,17 @@
     of this vulnerability.
 references:
   - href: pkg.go.dev/vuln/GO-2022-03
+-- GO-2022-04.yaml --
+modules:
+  - module: golang.org/bmod
+    packages:
+      - package: golang.org/bmod/unused
+        symbols:
+          - Vuln
+description: |
+    vuln in bmod/somtrhingelse
+references:
+  - href: pkg.go.dev/vuln/GO-2022-04
 -- GOSTDLIB.yaml --
 modules:
   - module: stdlib
@@ -237,7 +249,7 @@
 -- go.sum --
 golang.org/amod v1.0.0 h1:EUQOI2m5NhQZijXZf8WimSnnWubaFNrrKUH/PopTN8k=
 golang.org/amod v1.0.0/go.mod h1:yvny5/2OtYFomKt8ax+WJGvN6pfN1pqjGnn7DQLUi6E=
-golang.org/bmod v0.5.0 h1:0kt1EI53298Ta9w4RPEAzNUQjtDoHUA6cc0c7Rwxhlk=
+golang.org/bmod v0.5.0 h1:KgvUulMyMiYRB7suKA0x+DfWRVdeyPgVJvcishTH+ng=
 golang.org/bmod v0.5.0/go.mod h1:f6o+OhF66nz/0BBc/sbCsshyPRKMSxZIlG50B/bsM4c=
 golang.org/cmod v1.1.3 h1:PJ7rZFTk7xGAunBRDa0wDe7rZjZ9R/vr1S2QkVVCngQ=
 golang.org/cmod v1.1.3/go.mod h1:eCR8dnmvLYQomdeAZRCPgS5JJihXtqOQrpEkNj5feQA=
@@ -325,6 +337,12 @@
 func Vuln() {
 	// something evil
 }
+-- golang.org/bmod@v0.5.0/unused/unused.go --
+package unused
+
+func Vuln() {
+	// something evil
+}
 -- golang.org/amod@v1.0.6/go.mod --
 module golang.org/amod
 
@@ -360,6 +378,110 @@
 	return db, []RunOption{ProxyFiles(proxyData), ev, settings}, nil
 }
 
+func TestRunVulncheckPackageDiagnostics(t *testing.T) {
+	testenv.NeedsGo1Point(t, 18)
+
+	db, opts0, err := vulnTestEnv(vulnsData, proxy1)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer db.Clean()
+
+	checkVulncheckDiagnostics := func(env *Env, t *testing.T) {
+		env.OpenFile("go.mod")
+
+		gotDiagnostics := &protocol.PublishDiagnosticsParams{}
+		env.AfterChange(
+			env.DiagnosticAtRegexp("go.mod", `golang.org/amod`),
+			ReadDiagnostics("go.mod", gotDiagnostics),
+		)
+
+		testFetchVulncheckResult(t, env, map[string][]string{})
+
+		wantVulncheckDiagnostics := map[string]vulnDiagExpectation{
+			"golang.org/amod": {
+				diagnostics: []vulnDiag{
+					{
+						msg:      "golang.org/amod has known vulnerabilities GO-2022-01, GO-2022-03.",
+						severity: protocol.SeverityInformation,
+						codeActions: []string{
+							"Upgrade to latest",
+							"Upgrade to v1.0.6",
+						},
+					},
+				},
+				codeActions: []string{
+					"Upgrade to latest",
+					"Upgrade to v1.0.6",
+				},
+				hover: []string{"GO-2022-01", "Fixed in v1.0.4.", "GO-2022-03"},
+			},
+			"golang.org/bmod": {
+				diagnostics: []vulnDiag{
+					{
+						msg:      "golang.org/bmod has a vulnerability GO-2022-02.",
+						severity: protocol.SeverityInformation,
+					},
+				},
+				hover: []string{"GO-2022-02", "This is a long description of this vulnerability.", "No fix is available."},
+			},
+		}
+
+		for mod, want := range wantVulncheckDiagnostics {
+			modPathDiagnostics := testVulnDiagnostics(t, env, mod, want, gotDiagnostics)
+
+			gotActions := env.CodeAction("go.mod", modPathDiagnostics)
+			if diff := diffCodeActions(gotActions, want.codeActions); diff != "" {
+				t.Errorf("code actions for %q do not match, got %v, want %v\n%v\n", mod, gotActions, want.codeActions, diff)
+				continue
+			}
+		}
+	}
+
+	wantNoVulncheckDiagnostics := func(env *Env, t *testing.T) {
+		env.OpenFile("go.mod")
+
+		gotDiagnostics := &protocol.PublishDiagnosticsParams{}
+		env.AfterChange(
+			ReadDiagnostics("go.mod", gotDiagnostics),
+		)
+
+		if len(gotDiagnostics.Diagnostics) > 0 {
+			t.Errorf("Unexpected diagnostics: %v", stringify(gotDiagnostics))
+		}
+		testFetchVulncheckResult(t, env, map[string][]string{})
+	}
+
+	for _, tc := range []struct {
+		name            string
+		setting         Settings
+		wantDiagnostics bool
+	}{
+		{"imports", Settings{"ui.diagnostic.vulncheck": "Imports"}, true},
+		{"default", Settings{}, false},
+		{"invalid", Settings{"ui.diagnostic.vulncheck": "invalid"}, false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			// override the settings options to enable diagnostics
+			opts := append(opts0, tc.setting)
+			WithOptions(opts...).Run(t, workspace1, func(t *testing.T, env *Env) {
+				// TODO(hyangah): implement it, so we see GO-2022-01, GO-2022-02, and GO-2022-03.
+				// Check that the actions we get when including all diagnostics at a location return the same result
+				if tc.wantDiagnostics {
+					checkVulncheckDiagnostics(env, t)
+				} else {
+					wantNoVulncheckDiagnostics(env, t)
+				}
+			})
+		})
+	}
+}
+
+func stringify(a interface{}) string {
+	data, _ := json.Marshal(a)
+	return string(data)
+}
+
 func TestRunVulncheckWarning(t *testing.T) {
 	testenv.NeedsGo1Point(t, 18)