gopls/internal/vulncheck: add import info based vuln scanning
This vulnerability check mode reports the vulnerabilities
that affect any of the packages known to gopls.
This is not yet wired into diagnostics and hover handlers.
Tests will be added after cl/452516 and diagnostics/hover
handler change are merged.
Change-Id: Id9b30ccd58780f5fd66eb72f2a74b0ed98bfbaff
Reviewed-on: https://go-review.googlesource.com/c/tools/+/452516
gopls-CI: kokoro <noreply+kokoro@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Alan Donovan <adonovan@google.com>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/gopls/internal/govulncheck/util.go b/gopls/internal/govulncheck/util.go
index fc63d56..9bcb9b4 100644
--- a/gopls/internal/govulncheck/util.go
+++ b/gopls/internal/govulncheck/util.go
@@ -19,9 +19,12 @@
// LatestFixed returns the latest fixed version in the list of affected ranges,
// or the empty string if there are no fixed versions.
-func LatestFixed(as []osv.Affected) string {
+func LatestFixed(modulePath string, as []osv.Affected) string {
v := ""
for _, a := range as {
+ if a.Package.Name != modulePath {
+ continue
+ }
for _, r := range a.Ranges {
if r.Type == osv.TypeSemver {
for _, e := range r.Events {
diff --git a/gopls/internal/lsp/cache/mod_vuln.go b/gopls/internal/lsp/cache/mod_vuln.go
index d783f5d..b16c8c5 100644
--- a/gopls/internal/lsp/cache/mod_vuln.go
+++ b/gopls/internal/lsp/cache/mod_vuln.go
@@ -64,9 +64,12 @@
}
func modVulnImpl(ctx context.Context, s *snapshot, uri span.URI) (*govulncheck.Result, error) {
+ if vulncheck.VulnerablePackages == nil {
+ return &govulncheck.Result{}, nil
+ }
fh, err := s.GetFile(ctx, uri)
if err != nil {
return nil, err
}
- return vulncheck.AnalyzeVulnerableImports(ctx, s, fh)
+ return vulncheck.VulnerablePackages(ctx, s, fh)
}
diff --git a/gopls/internal/vulncheck/command.go b/gopls/internal/vulncheck/command.go
index a45290a..004541f 100644
--- a/gopls/internal/vulncheck/command.go
+++ b/gopls/internal/vulncheck/command.go
@@ -13,12 +13,17 @@
"fmt"
"log"
"os"
+ "regexp"
"sort"
"strings"
+ "sync"
+ "golang.org/x/mod/semver"
+ "golang.org/x/sync/errgroup"
"golang.org/x/tools/go/packages"
- gvc "golang.org/x/tools/gopls/internal/govulncheck"
+ "golang.org/x/tools/gopls/internal/govulncheck"
"golang.org/x/tools/gopls/internal/lsp/command"
+ "golang.org/x/tools/gopls/internal/lsp/source"
"golang.org/x/vuln/client"
gvcapi "golang.org/x/vuln/exp/govulncheck"
"golang.org/x/vuln/osv"
@@ -27,6 +32,8 @@
func init() {
Govulncheck = govulncheckFunc
+
+ VulnerablePackages = vulnerablePackages
}
func govulncheckFunc(ctx context.Context, cfg *packages.Config, patterns string) (res command.VulncheckResult, _ error) {
@@ -34,7 +41,7 @@
patterns = "."
}
- dbClient, err := client.NewClient(findGOVULNDB(cfg), client.Options{HTTPCache: gvc.DefaultCache()})
+ dbClient, err := client.NewClient(findGOVULNDB(cfg.Env), client.Options{HTTPCache: govulncheck.DefaultCache()})
if err != nil {
return res, err
}
@@ -49,8 +56,8 @@
return res, err
}
-func findGOVULNDB(cfg *packages.Config) []string {
- for _, kv := range cfg.Env {
+func findGOVULNDB(env []string) []string {
+ for _, kv := range env {
if strings.HasPrefix(kv, "GOVULNDB=") {
return strings.Split(kv[len("GOVULNDB="):], ",")
}
@@ -79,7 +86,7 @@
packages.NeedTypesSizes | packages.NeedSyntax | packages.NeedTypesInfo | packages.NeedDeps
logger.Println("loading packages...")
- loadedPkgs, err := gvc.LoadPackages(cfg, patterns...)
+ loadedPkgs, err := govulncheck.LoadPackages(cfg, patterns...)
if err != nil {
logger.Printf("%v", err)
return nil, fmt.Errorf("package load failed")
@@ -97,7 +104,7 @@
r.Vulns = filterCalled(r)
logger.Printf("found %d vulnerabilities.\n", len(r.Vulns))
- callInfo := gvc.GetCallInfo(r, loadedPkgs)
+ callInfo := govulncheck.GetCallInfo(r, loadedPkgs)
return toVulns(callInfo, unaffectedMods)
// TODO: add import graphs.
}
@@ -148,15 +155,15 @@
return output
}
-func fixed(v *osv.Entry) string {
- lf := gvc.LatestFixed(v.Affected)
+func fixed(modPath string, v *osv.Entry) string {
+ lf := govulncheck.LatestFixed(modPath, v.Affected)
if lf != "" && lf[0] != 'v' {
lf = "v" + lf
}
return lf
}
-func toVulns(ci *gvc.CallInfo, unaffectedMods map[string][]*osv.Entry) ([]Vuln, error) {
+func toVulns(ci *govulncheck.CallInfo, unaffectedMods map[string][]*osv.Entry) ([]Vuln, error) {
var vulns []Vuln
for _, vg := range ci.VulnGroups {
@@ -165,7 +172,7 @@
ID: v0.OSV.ID,
PkgPath: v0.PkgPath,
CurrentVersion: ci.ModuleVersions[v0.ModPath],
- FixedVersion: fixed(v0.OSV),
+ FixedVersion: fixed(v0.ModPath, v0.OSV),
Details: v0.OSV.Details,
Aliases: v0.OSV.Aliases,
@@ -180,7 +187,7 @@
vuln.CallStacks = append(vuln.CallStacks, toCallStack(css[0]))
// TODO(hyangah): https://go-review.googlesource.com/c/vuln/+/425183 added position info
// in the summary but we don't need the info. Allow SummarizeCallStack to skip it optionally.
- sum := trimPosPrefix(gvc.SummarizeCallStack(css[0], ci.TopPackages, v.PkgPath))
+ sum := trimPosPrefix(govulncheck.SummarizeCallStack(css[0], ci.TopPackages, v.PkgPath))
vuln.CallStackSummaries = append(vuln.CallStackSummaries, sum)
}
}
@@ -195,7 +202,7 @@
ModPath: m,
URL: href(v0),
CurrentVersion: "",
- FixedVersion: fixed(v0),
+ FixedVersion: fixed(m, v0),
}
vulns = append(vulns, vuln)
}
@@ -230,8 +237,8 @@
return err
}
logf("Loaded %d packages and their dependencies", len(pkgs))
- cli, err := client.NewClient(findGOVULNDB(&cfg), client.Options{
- HTTPCache: gvc.DefaultCache(),
+ cli, err := client.NewClient(findGOVULNDB(cfg.Env), client.Options{
+ HTTPCache: govulncheck.DefaultCache(),
})
if err != nil {
return err
@@ -250,3 +257,257 @@
return nil
}
}
+
+var (
+ // Regexp for matching go tags. The groups are:
+ // 1 the major.minor version
+ // 2 the patch version, or empty if none
+ // 3 the entire prerelease, if present
+ // 4 the prerelease type ("beta" or "rc")
+ // 5 the prerelease number
+ tagRegexp = regexp.MustCompile(`^go(\d+\.\d+)(\.\d+|)((beta|rc|-pre)(\d+))?$`)
+)
+
+// This is a modified copy of pkgsite/internal/stdlib:VersionForTag.
+func GoTagToSemver(tag string) string {
+ if tag == "" {
+ return ""
+ }
+
+ tag = strings.Fields(tag)[0]
+ // Special cases for go1.
+ if tag == "go1" {
+ return "v1.0.0"
+ }
+ if tag == "go1.0" {
+ return ""
+ }
+ m := tagRegexp.FindStringSubmatch(tag)
+ if m == nil {
+ return ""
+ }
+ version := "v" + m[1]
+ if m[2] != "" {
+ version += m[2]
+ } else {
+ version += ".0"
+ }
+ if m[3] != "" {
+ if !strings.HasPrefix(m[4], "-") {
+ version += "-"
+ }
+ version += m[4] + "." + m[5]
+ }
+ return version
+}
+
+// semverToGoTag returns the Go standard library repository tag corresponding
+// to semver, a version string without the initial "v".
+// Go tags differ from standard semantic versions in a few ways,
+// such as beginning with "go" instead of "v".
+func semverToGoTag(v string) string {
+ if strings.HasPrefix(v, "v0.0.0") {
+ return "master"
+ }
+ // Special case: v1.0.0 => go1.
+ if v == "v1.0.0" {
+ return "go1"
+ }
+ if !semver.IsValid(v) {
+ return fmt.Sprintf("<!%s:invalid semver>", v)
+ }
+ goVersion := semver.Canonical(v)
+ prerelease := semver.Prerelease(goVersion)
+ versionWithoutPrerelease := strings.TrimSuffix(goVersion, prerelease)
+ patch := strings.TrimPrefix(versionWithoutPrerelease, semver.MajorMinor(goVersion)+".")
+ if patch == "0" {
+ versionWithoutPrerelease = strings.TrimSuffix(versionWithoutPrerelease, ".0")
+ }
+ goVersion = fmt.Sprintf("go%s", strings.TrimPrefix(versionWithoutPrerelease, "v"))
+ if prerelease != "" {
+ // Go prereleases look like "beta1" instead of "beta.1".
+ // "beta1" is bad for sorting (since beta10 comes before beta9), so
+ // require the dot form.
+ i := finalDigitsIndex(prerelease)
+ if i >= 1 {
+ if prerelease[i-1] != '.' {
+ return fmt.Sprintf("<!%s:final digits in a prerelease must follow a period>", v)
+ }
+ // Remove the dot.
+ prerelease = prerelease[:i-1] + prerelease[i:]
+ }
+ goVersion += strings.TrimPrefix(prerelease, "-")
+ }
+ return goVersion
+}
+
+// finalDigitsIndex returns the index of the first digit in the sequence of digits ending s.
+// If s doesn't end in digits, it returns -1.
+func finalDigitsIndex(s string) int {
+ // Assume ASCII (since the semver package does anyway).
+ var i int
+ for i = len(s) - 1; i >= 0; i-- {
+ if s[i] < '0' || s[i] > '9' {
+ break
+ }
+ }
+ if i == len(s)-1 {
+ return -1
+ }
+ return i + 1
+}
+
+// vulnerablePackages queries the vulndb and reports which vulnerabilities
+// apply to this snapshot. The result contains a set of packages,
+// grouped by vuln ID and by module.
+func vulnerablePackages(ctx context.Context, snapshot source.Snapshot, modfile source.FileHandle) (*govulncheck.Result, error) {
+ metadata, err := snapshot.AllValidMetadata(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ // Group packages by modules since vuln db is keyed by module.
+ metadataByModule := map[source.PackagePath][]*source.Metadata{}
+ for _, md := range metadata {
+ // TODO(hyangah): delete after go.dev/cl/452057 is merged.
+ // After the cl, this becomes an impossible condition.
+ if md == nil {
+ continue
+ }
+ mi := md.Module
+ if mi == nil {
+ continue
+ }
+ modulePath := source.PackagePath(mi.Path)
+ metadataByModule[modulePath] = append(metadataByModule[modulePath], md)
+ }
+
+ // Request vuln entries from remote service.
+ cli, err := client.NewClient(
+ findGOVULNDB(snapshot.View().Options().EnvSlice()),
+ client.Options{HTTPCache: govulncheck.DefaultCache()})
+ if err != nil {
+ return nil, err
+ }
+ // Keys are osv.Entry.IDs
+ vulnsResult := map[string]*govulncheck.Vuln{}
+ var (
+ group errgroup.Group
+ mu sync.Mutex
+ )
+
+ group.SetLimit(10)
+ for path, mds := range metadataByModule {
+ path, mds := path, mds
+ group.Go(func() error {
+
+ effectiveModule := mds[0].Module
+ for effectiveModule.Replace != nil {
+ effectiveModule = effectiveModule.Replace
+ }
+ ver := effectiveModule.Version
+
+ // TODO(go.dev/issues/56312): batch these requests for efficiency.
+ vulns, err := cli.GetByModule(ctx, effectiveModule.Path)
+ if err != nil {
+ return err
+ }
+ if len(vulns) == 0 { // No known vulnerability.
+ return nil
+ }
+
+ // set of packages in this module known to gopls.
+ // This will be lazily initialized when we need it.
+ var knownPkgs map[source.PackagePath]bool
+
+ // Report vulnerabilities that affect packages of this module.
+ for _, entry := range vulns {
+ var vulnerablePkgs []*govulncheck.Package
+
+ for _, a := range entry.Affected {
+ if a.Package.Ecosystem != osv.GoEcosystem || a.Package.Name != effectiveModule.Path {
+ continue
+ }
+ if !a.Ranges.AffectsSemver(ver) {
+ continue
+ }
+ for _, imp := range a.EcosystemSpecific.Imports {
+ if knownPkgs == nil {
+ knownPkgs = toPackagePathSet(mds)
+ }
+ if knownPkgs[source.PackagePath(imp.Path)] {
+ vulnerablePkgs = append(vulnerablePkgs, &govulncheck.Package{
+ Path: imp.Path,
+ })
+ }
+ }
+ }
+ if len(vulnerablePkgs) == 0 {
+ continue
+ }
+ mu.Lock()
+ vuln, ok := vulnsResult[entry.ID]
+ if !ok {
+ vuln = &govulncheck.Vuln{OSV: entry}
+ vulnsResult[entry.ID] = vuln
+ }
+ vuln.Modules = append(vuln.Modules, &govulncheck.Module{
+ Path: string(path),
+ FoundVersion: ver,
+ FixedVersion: fixedVersion(effectiveModule.Path, entry.Affected),
+ Packages: vulnerablePkgs,
+ })
+ mu.Unlock()
+ }
+ return nil
+ })
+ }
+ if err := group.Wait(); err != nil {
+ return nil, err
+ }
+
+ vulns := make([]*govulncheck.Vuln, 0, len(vulnsResult))
+ for _, v := range vulnsResult {
+ vulns = append(vulns, v)
+ }
+ // Sort so the results are deterministic.
+ sort.Slice(vulns, func(i, j int) bool {
+ return vulns[i].OSV.ID < vulns[j].OSV.ID
+ })
+ ret := &govulncheck.Result{
+ Vulns: vulns,
+ }
+ return ret, nil
+}
+
+// toPackagePathSet transforms the metadata to a set of package paths.
+func toPackagePathSet(mds []*source.Metadata) map[source.PackagePath]bool {
+ pkgPaths := make(map[source.PackagePath]bool, len(mds))
+ for _, md := range mds {
+ pkgPaths[md.PkgPath] = true
+ }
+ return pkgPaths
+}
+
+func fixedVersion(modulePath string, affected []osv.Affected) string {
+ fixed := govulncheck.LatestFixed(modulePath, affected)
+ if fixed != "" {
+ fixed = versionString(modulePath, fixed)
+ }
+ return fixed
+}
+
+// versionString prepends a version string prefix (`v` or `go`
+// depending on the modulePath) to the given semver-style version string.
+func versionString(modulePath, version string) string {
+ if version == "" {
+ return ""
+ }
+ v := "v" + version
+ // These are internal Go module paths used by the vuln DB
+ // when listing vulns in standard library and the go command.
+ if modulePath == "stdlib" || modulePath == "toolchain" {
+ return semverToGoTag(v)
+ }
+ return v
+}
diff --git a/gopls/internal/vulncheck/vulncheck.go b/gopls/internal/vulncheck/vulncheck.go
index c4dfe51..71ac117 100644
--- a/gopls/internal/vulncheck/vulncheck.go
+++ b/gopls/internal/vulncheck/vulncheck.go
@@ -23,6 +23,7 @@
var Main func(cfg packages.Config, patterns ...string) error = nil
-func AnalyzeVulnerableImports(ctx context.Context, snapshot source.Snapshot, modfile source.FileHandle) (*govulncheck.Result, error) {
- panic("not implemented")
-}
+// VulnerablePackages queries the vulndb and reports which vulnerabilities
+// apply to this snapshot. The result contains a set of packages,
+// grouped by vuln ID and by module.
+var VulnerablePackages func(ctx context.Context, snapshot source.Snapshot, modfile source.FileHandle) (*govulncheck.Result, error) = nil