golang.org/x/tools/gopls@v0.15.3/internal/mod/diagnostics.go (about) 1 // Copyright 2019 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // Package mod provides core features related to go.mod file 6 // handling for use by Go editors and tools. 7 package mod 8 9 import ( 10 "context" 11 "fmt" 12 "runtime" 13 "sort" 14 "strings" 15 "sync" 16 17 "golang.org/x/mod/modfile" 18 "golang.org/x/mod/semver" 19 "golang.org/x/sync/errgroup" 20 "golang.org/x/tools/gopls/internal/cache" 21 "golang.org/x/tools/gopls/internal/file" 22 "golang.org/x/tools/gopls/internal/protocol" 23 "golang.org/x/tools/gopls/internal/protocol/command" 24 "golang.org/x/tools/gopls/internal/settings" 25 "golang.org/x/tools/gopls/internal/vulncheck/govulncheck" 26 "golang.org/x/tools/internal/event" 27 ) 28 29 // ParseDiagnostics returns diagnostics from parsing the go.mod files in the workspace. 30 func ParseDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) { 31 ctx, done := event.Start(ctx, "mod.Diagnostics", snapshot.Labels()...) 32 defer done() 33 34 return collectDiagnostics(ctx, snapshot, ModParseDiagnostics) 35 } 36 37 // Diagnostics returns diagnostics from running go mod tidy. 38 func TidyDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) { 39 ctx, done := event.Start(ctx, "mod.Diagnostics", snapshot.Labels()...) 40 defer done() 41 42 return collectDiagnostics(ctx, snapshot, ModTidyDiagnostics) 43 } 44 45 // UpgradeDiagnostics returns upgrade diagnostics for the modules in the 46 // workspace with known upgrades. 47 func UpgradeDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) { 48 ctx, done := event.Start(ctx, "mod.UpgradeDiagnostics", snapshot.Labels()...) 49 defer done() 50 51 return collectDiagnostics(ctx, snapshot, ModUpgradeDiagnostics) 52 } 53 54 // VulnerabilityDiagnostics returns vulnerability diagnostics for the active modules in the 55 // workspace with known vulnerabilities. 56 func VulnerabilityDiagnostics(ctx context.Context, snapshot *cache.Snapshot) (map[protocol.DocumentURI][]*cache.Diagnostic, error) { 57 ctx, done := event.Start(ctx, "mod.VulnerabilityDiagnostics", snapshot.Labels()...) 58 defer done() 59 60 return collectDiagnostics(ctx, snapshot, ModVulnerabilityDiagnostics) 61 } 62 63 func collectDiagnostics(ctx context.Context, snapshot *cache.Snapshot, diagFn func(context.Context, *cache.Snapshot, file.Handle) ([]*cache.Diagnostic, error)) (map[protocol.DocumentURI][]*cache.Diagnostic, error) { 64 g, ctx := errgroup.WithContext(ctx) 65 cpulimit := runtime.GOMAXPROCS(0) 66 g.SetLimit(cpulimit) 67 68 var mu sync.Mutex 69 reports := make(map[protocol.DocumentURI][]*cache.Diagnostic) 70 71 for _, uri := range snapshot.View().ModFiles() { 72 uri := uri 73 g.Go(func() error { 74 fh, err := snapshot.ReadFile(ctx, uri) 75 if err != nil { 76 return err 77 } 78 diagnostics, err := diagFn(ctx, snapshot, fh) 79 if err != nil { 80 return err 81 } 82 for _, d := range diagnostics { 83 mu.Lock() 84 reports[d.URI] = append(reports[fh.URI()], d) 85 mu.Unlock() 86 } 87 return nil 88 }) 89 } 90 91 if err := g.Wait(); err != nil { 92 return nil, err 93 } 94 return reports, nil 95 } 96 97 // ModParseDiagnostics reports diagnostics from parsing the mod file. 98 func ModParseDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (diagnostics []*cache.Diagnostic, err error) { 99 pm, err := snapshot.ParseMod(ctx, fh) 100 if err != nil { 101 if pm == nil || len(pm.ParseErrors) == 0 { 102 return nil, err 103 } 104 return pm.ParseErrors, nil 105 } 106 return nil, nil 107 } 108 109 // ModTidyDiagnostics reports diagnostics from running go mod tidy. 110 func ModTidyDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) ([]*cache.Diagnostic, error) { 111 pm, err := snapshot.ParseMod(ctx, fh) // memoized 112 if err != nil { 113 return nil, nil // errors reported by ModDiagnostics above 114 } 115 116 tidied, err := snapshot.ModTidy(ctx, pm) 117 if err != nil { 118 if err != cache.ErrNoModOnDisk { 119 // TODO(rfindley): the check for ErrNoModOnDisk was historically determined 120 // to be benign, but may date back to the time when the Go command did not 121 // have overlay support. 122 // 123 // See if we can pass the overlay to the Go command, and eliminate this guard.. 124 event.Error(ctx, fmt.Sprintf("tidy: diagnosing %s", pm.URI), err) 125 } 126 return nil, nil 127 } 128 return tidied.Diagnostics, nil 129 } 130 131 // ModUpgradeDiagnostics adds upgrade quick fixes for individual modules if the upgrades 132 // are recorded in the view. 133 func ModUpgradeDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (upgradeDiagnostics []*cache.Diagnostic, err error) { 134 pm, err := snapshot.ParseMod(ctx, fh) 135 if err != nil { 136 // Don't return an error if there are parse error diagnostics to be shown, but also do not 137 // continue since we won't be able to show the upgrade diagnostics. 138 if pm != nil && len(pm.ParseErrors) != 0 { 139 return nil, nil 140 } 141 return nil, err 142 } 143 144 upgrades := snapshot.ModuleUpgrades(fh.URI()) 145 for _, req := range pm.File.Require { 146 ver, ok := upgrades[req.Mod.Path] 147 if !ok || req.Mod.Version == ver { 148 continue 149 } 150 rng, err := pm.Mapper.OffsetRange(req.Syntax.Start.Byte, req.Syntax.End.Byte) 151 if err != nil { 152 return nil, err 153 } 154 // Upgrade to the exact version we offer the user, not the most recent. 155 title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, ver) 156 cmd, err := command.NewUpgradeDependencyCommand(title, command.DependencyArgs{ 157 URI: fh.URI(), 158 AddRequire: false, 159 GoCmdArgs: []string{req.Mod.Path + "@" + ver}, 160 }) 161 if err != nil { 162 return nil, err 163 } 164 upgradeDiagnostics = append(upgradeDiagnostics, &cache.Diagnostic{ 165 URI: fh.URI(), 166 Range: rng, 167 Severity: protocol.SeverityInformation, 168 Source: cache.UpgradeNotification, 169 Message: fmt.Sprintf("%v can be upgraded", req.Mod.Path), 170 SuggestedFixes: []cache.SuggestedFix{cache.SuggestedFixFromCommand(cmd, protocol.QuickFix)}, 171 }) 172 } 173 174 return upgradeDiagnostics, nil 175 } 176 177 const upgradeCodeActionPrefix = "Upgrade to " 178 179 // ModVulnerabilityDiagnostics adds diagnostics for vulnerabilities in individual modules 180 // if the vulnerability is recorded in the view. 181 func ModVulnerabilityDiagnostics(ctx context.Context, snapshot *cache.Snapshot, fh file.Handle) (vulnDiagnostics []*cache.Diagnostic, err error) { 182 pm, err := snapshot.ParseMod(ctx, fh) 183 if err != nil { 184 // Don't return an error if there are parse error diagnostics to be shown, but also do not 185 // continue since we won't be able to show the vulnerability diagnostics. 186 if pm != nil && len(pm.ParseErrors) != 0 { 187 return nil, nil 188 } 189 return nil, err 190 } 191 192 diagSource := cache.Govulncheck 193 vs := snapshot.Vulnerabilities(fh.URI())[fh.URI()] 194 if vs == nil && snapshot.Options().Vulncheck == settings.ModeVulncheckImports { 195 vs, err = snapshot.ModVuln(ctx, fh.URI()) 196 if err != nil { 197 return nil, err 198 } 199 diagSource = cache.Vulncheck 200 } 201 if vs == nil || len(vs.Findings) == 0 { 202 return nil, nil 203 } 204 205 suggestRunOrResetGovulncheck, err := suggestGovulncheckAction(diagSource == cache.Govulncheck, fh.URI()) 206 if err != nil { 207 // must not happen 208 return nil, err // TODO: bug report 209 } 210 vulnsByModule := make(map[string][]*govulncheck.Finding) 211 212 for _, finding := range vs.Findings { 213 if vuln, typ := foundVuln(finding); typ == vulnCalled || typ == vulnImported { 214 vulnsByModule[vuln.Module] = append(vulnsByModule[vuln.Module], finding) 215 } 216 } 217 for _, req := range pm.File.Require { 218 mod := req.Mod.Path 219 findings := vulnsByModule[mod] 220 if len(findings) == 0 { 221 continue 222 } 223 // note: req.Syntax is the line corresponding to 'require', which means 224 // req.Syntax.Start can point to the beginning of the "require" keyword 225 // for a single line require (e.g. "require golang.org/x/mod v0.0.0"). 226 start := req.Syntax.Start.Byte 227 if len(req.Syntax.Token) == 3 { 228 start += len("require ") 229 } 230 rng, err := pm.Mapper.OffsetRange(start, req.Syntax.End.Byte) 231 if err != nil { 232 return nil, err 233 } 234 // Map affecting vulns to 'warning' level diagnostics, 235 // others to 'info' level diagnostics. 236 // Fixes will include only the upgrades for warning level diagnostics. 237 var warningFixes, infoFixes []cache.SuggestedFix 238 var warningSet, infoSet = map[string]bool{}, map[string]bool{} 239 for _, finding := range findings { 240 // It is possible that the source code was changed since the last 241 // govulncheck run and information in the `vulns` info is stale. 242 // For example, imagine that a user is in the middle of updating 243 // problematic modules detected by the govulncheck run by applying 244 // quick fixes. Stale diagnostics can be confusing and prevent the 245 // user from quickly locating the next module to fix. 246 // Ideally we should rerun the analysis with the updated module 247 // dependencies or any other code changes, but we are not yet 248 // in the position of automatically triggering the analysis 249 // (govulncheck can take a while). We also don't know exactly what 250 // part of source code was changed since `vulns` was computed. 251 // As a heuristic, we assume that a user upgrades the affecting 252 // module to the version with the fix or the latest one, and if the 253 // version in the require statement is equal to or higher than the 254 // fixed version, skip generating a diagnostic about the vulnerability. 255 // Eventually, the user has to rerun govulncheck. 256 if finding.FixedVersion != "" && semver.IsValid(req.Mod.Version) && semver.Compare(finding.FixedVersion, req.Mod.Version) <= 0 { 257 continue 258 } 259 switch _, typ := foundVuln(finding); typ { 260 case vulnImported: 261 infoSet[finding.OSV] = true 262 case vulnCalled: 263 warningSet[finding.OSV] = true 264 } 265 // Upgrade to the exact version we offer the user, not the most recent. 266 if fixedVersion := finding.FixedVersion; semver.IsValid(fixedVersion) && semver.Compare(req.Mod.Version, fixedVersion) < 0 { 267 cmd, err := getUpgradeCodeAction(fh, req, fixedVersion) 268 if err != nil { 269 return nil, err // TODO: bug report 270 } 271 sf := cache.SuggestedFixFromCommand(cmd, protocol.QuickFix) 272 switch _, typ := foundVuln(finding); typ { 273 case vulnImported: 274 infoFixes = append(infoFixes, sf) 275 case vulnCalled: 276 warningFixes = append(warningFixes, sf) 277 } 278 } 279 } 280 281 if len(warningSet) == 0 && len(infoSet) == 0 { 282 continue 283 } 284 // Remove affecting osvs from the non-affecting osv list if any. 285 if len(warningSet) > 0 { 286 for k := range infoSet { 287 if warningSet[k] { 288 delete(infoSet, k) 289 } 290 } 291 } 292 // Add an upgrade for module@latest. 293 // TODO(suzmue): verify if latest is the same as fixedVersion. 294 latest, err := getUpgradeCodeAction(fh, req, "latest") 295 if err != nil { 296 return nil, err // TODO: bug report 297 } 298 sf := cache.SuggestedFixFromCommand(latest, protocol.QuickFix) 299 if len(warningFixes) > 0 { 300 warningFixes = append(warningFixes, sf) 301 } 302 if len(infoFixes) > 0 { 303 infoFixes = append(infoFixes, sf) 304 } 305 if len(warningSet) > 0 { 306 warning := sortedKeys(warningSet) 307 warningFixes = append(warningFixes, suggestRunOrResetGovulncheck) 308 vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{ 309 URI: fh.URI(), 310 Range: rng, 311 Severity: protocol.SeverityWarning, 312 Source: diagSource, 313 Message: getVulnMessage(req.Mod.Path, warning, true, diagSource == cache.Govulncheck), 314 SuggestedFixes: warningFixes, 315 }) 316 } 317 if len(infoSet) > 0 { 318 info := sortedKeys(infoSet) 319 infoFixes = append(infoFixes, suggestRunOrResetGovulncheck) 320 vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{ 321 URI: fh.URI(), 322 Range: rng, 323 Severity: protocol.SeverityInformation, 324 Source: diagSource, 325 Message: getVulnMessage(req.Mod.Path, info, false, diagSource == cache.Govulncheck), 326 SuggestedFixes: infoFixes, 327 }) 328 } 329 } 330 331 // TODO(hyangah): place this diagnostic on the `go` directive or `toolchain` directive 332 // after https://go.dev/issue/57001. 333 const diagnoseStdLib = false 334 335 // If diagnosing the stdlib, add standard library vulnerability diagnostics 336 // on the module declaration. 337 // 338 // Only proceed if we have a valid module declaration on which to position 339 // the diagnostics. 340 if diagnoseStdLib && pm.File.Module != nil && pm.File.Module.Syntax != nil { 341 // Add standard library vulnerabilities. 342 stdlibVulns := vulnsByModule["stdlib"] 343 if len(stdlibVulns) == 0 { 344 return vulnDiagnostics, nil 345 } 346 347 // Put the standard library diagnostic on the module declaration. 348 rng, err := pm.Mapper.OffsetRange(pm.File.Module.Syntax.Start.Byte, pm.File.Module.Syntax.End.Byte) 349 if err != nil { 350 return vulnDiagnostics, nil // TODO: bug report 351 } 352 353 var warningSet, infoSet = map[string]bool{}, map[string]bool{} 354 for _, finding := range stdlibVulns { 355 switch _, typ := foundVuln(finding); typ { 356 case vulnImported: 357 infoSet[finding.OSV] = true 358 case vulnCalled: 359 warningSet[finding.OSV] = true 360 } 361 } 362 if len(warningSet) > 0 { 363 warning := sortedKeys(warningSet) 364 fixes := []cache.SuggestedFix{suggestRunOrResetGovulncheck} 365 vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{ 366 URI: fh.URI(), 367 Range: rng, 368 Severity: protocol.SeverityWarning, 369 Source: diagSource, 370 Message: getVulnMessage("go", warning, true, diagSource == cache.Govulncheck), 371 SuggestedFixes: fixes, 372 }) 373 374 // remove affecting osvs from the non-affecting osv list if any. 375 for k := range infoSet { 376 if warningSet[k] { 377 delete(infoSet, k) 378 } 379 } 380 } 381 if len(infoSet) > 0 { 382 info := sortedKeys(infoSet) 383 fixes := []cache.SuggestedFix{suggestRunOrResetGovulncheck} 384 vulnDiagnostics = append(vulnDiagnostics, &cache.Diagnostic{ 385 URI: fh.URI(), 386 Range: rng, 387 Severity: protocol.SeverityInformation, 388 Source: diagSource, 389 Message: getVulnMessage("go", info, false, diagSource == cache.Govulncheck), 390 SuggestedFixes: fixes, 391 }) 392 } 393 } 394 395 return vulnDiagnostics, nil 396 } 397 398 type vulnFindingType int 399 400 const ( 401 vulnUnknown vulnFindingType = iota 402 vulnCalled 403 vulnImported 404 vulnRequired 405 ) 406 407 // foundVuln returns the frame info describing discovered vulnerable symbol/package/module 408 // and how this vulnerability affects the analyzed package or module. 409 func foundVuln(finding *govulncheck.Finding) (*govulncheck.Frame, vulnFindingType) { 410 // finding.Trace is sorted from the imported vulnerable symbol to 411 // the entry point in the callstack. 412 // If Function is set, then Package must be set. Module will always be set. 413 // If Function is set it was found in the call graph, otherwise if Package is set 414 // it was found in the import graph, otherwise it was found in the require graph. 415 // See the documentation of govulncheck.Finding. 416 if len(finding.Trace) == 0 { // this shouldn't happen, but just in case... 417 return nil, vulnUnknown 418 } 419 vuln := finding.Trace[0] 420 if vuln.Package == "" { 421 return vuln, vulnRequired 422 } 423 if vuln.Function == "" { 424 return vuln, vulnImported 425 } 426 return vuln, vulnCalled 427 } 428 429 func sortedKeys(m map[string]bool) []string { 430 ret := make([]string, 0, len(m)) 431 for k := range m { 432 ret = append(ret, k) 433 } 434 sort.Strings(ret) 435 return ret 436 } 437 438 // suggestGovulncheckAction returns a code action that suggests either run govulncheck 439 // for more accurate investigation (if the present vulncheck diagnostics are based on 440 // analysis less accurate than govulncheck) or reset the existing govulncheck result 441 // (if the present vulncheck diagnostics are already based on govulncheck run). 442 func suggestGovulncheckAction(fromGovulncheck bool, uri protocol.DocumentURI) (cache.SuggestedFix, error) { 443 if fromGovulncheck { 444 resetVulncheck, err := command.NewResetGoModDiagnosticsCommand("Reset govulncheck result", command.ResetGoModDiagnosticsArgs{ 445 URIArg: command.URIArg{URI: uri}, 446 DiagnosticSource: string(cache.Govulncheck), 447 }) 448 if err != nil { 449 return cache.SuggestedFix{}, err 450 } 451 return cache.SuggestedFixFromCommand(resetVulncheck, protocol.QuickFix), nil 452 } 453 vulncheck, err := command.NewRunGovulncheckCommand("Run govulncheck to verify", command.VulncheckArgs{ 454 URI: uri, 455 Pattern: "./...", 456 }) 457 if err != nil { 458 return cache.SuggestedFix{}, err 459 } 460 return cache.SuggestedFixFromCommand(vulncheck, protocol.QuickFix), nil 461 } 462 463 func getVulnMessage(mod string, vulns []string, used, fromGovulncheck bool) string { 464 var b strings.Builder 465 if used { 466 switch len(vulns) { 467 case 1: 468 fmt.Fprintf(&b, "%v has a vulnerability used in the code: %v.", mod, vulns[0]) 469 default: 470 fmt.Fprintf(&b, "%v has vulnerabilities used in the code: %v.", mod, strings.Join(vulns, ", ")) 471 } 472 } else { 473 if fromGovulncheck { 474 switch len(vulns) { 475 case 1: 476 fmt.Fprintf(&b, "%v has a vulnerability %v that is not used in the code.", mod, vulns[0]) 477 default: 478 fmt.Fprintf(&b, "%v has known vulnerabilities %v that are not used in the code.", mod, strings.Join(vulns, ", ")) 479 } 480 } else { 481 switch len(vulns) { 482 case 1: 483 fmt.Fprintf(&b, "%v has a vulnerability %v.", mod, vulns[0]) 484 default: 485 fmt.Fprintf(&b, "%v has known vulnerabilities %v.", mod, strings.Join(vulns, ", ")) 486 } 487 } 488 } 489 return b.String() 490 } 491 492 // href returns the url for the vulnerability information. 493 // Eventually we should retrieve the url embedded in the osv.Entry. 494 // While vuln.go.dev is under development, this always returns 495 // the page in pkg.go.dev. 496 func href(vulnID string) string { 497 return fmt.Sprintf("https://pkg.go.dev/vuln/%s", vulnID) 498 } 499 500 func getUpgradeCodeAction(fh file.Handle, req *modfile.Require, version string) (protocol.Command, error) { 501 cmd, err := command.NewUpgradeDependencyCommand(upgradeTitle(version), command.DependencyArgs{ 502 URI: fh.URI(), 503 AddRequire: false, 504 GoCmdArgs: []string{req.Mod.Path + "@" + version}, 505 }) 506 if err != nil { 507 return protocol.Command{}, err 508 } 509 return cmd, nil 510 } 511 512 func upgradeTitle(fixedVersion string) string { 513 title := fmt.Sprintf("%s%v", upgradeCodeActionPrefix, fixedVersion) 514 return title 515 } 516 517 // SelectUpgradeCodeActions takes a list of code actions for a required module 518 // and returns a more selective list of upgrade code actions, 519 // where the code actions have been deduped. Code actions unrelated to upgrade 520 // are deduplicated by the name. 521 func SelectUpgradeCodeActions(actions []protocol.CodeAction) []protocol.CodeAction { 522 if len(actions) <= 1 { 523 return actions // return early if no sorting necessary 524 } 525 var versionedUpgrade, latestUpgrade, resetAction protocol.CodeAction 526 var chosenVersionedUpgrade string 527 var selected []protocol.CodeAction 528 529 seenTitles := make(map[string]bool) 530 531 for _, action := range actions { 532 if strings.HasPrefix(action.Title, upgradeCodeActionPrefix) { 533 if v := getUpgradeVersion(action); v == "latest" && latestUpgrade.Title == "" { 534 latestUpgrade = action 535 } else if versionedUpgrade.Title == "" || semver.Compare(v, chosenVersionedUpgrade) > 0 { 536 chosenVersionedUpgrade = v 537 versionedUpgrade = action 538 } 539 } else if strings.HasPrefix(action.Title, "Reset govulncheck") { 540 resetAction = action 541 } else if !seenTitles[action.Command.Title] { 542 seenTitles[action.Command.Title] = true 543 selected = append(selected, action) 544 } 545 } 546 if versionedUpgrade.Title != "" { 547 selected = append(selected, versionedUpgrade) 548 } 549 if latestUpgrade.Title != "" { 550 selected = append(selected, latestUpgrade) 551 } 552 if resetAction.Title != "" { 553 selected = append(selected, resetAction) 554 } 555 return selected 556 } 557 558 func getUpgradeVersion(p protocol.CodeAction) string { 559 return strings.TrimPrefix(p.Title, upgradeCodeActionPrefix) 560 }