vulncheck: load vulnerabilities from client to internal structures

This CL introduces internal structures for querying vulnerabilities
provided by a database client. This code is a copy of
exp/vulndb/internal/audit.

Cherry-picked: https://go-review.googlesource.com/c/exp/+/359754

Change-Id: Id185d58b90cedcde0d42d34d12bc1176fa89c213
Reviewed-on: https://go-review.googlesource.com/c/vuln/+/395037
Trust: Julie Qiu <julie@golang.org>
Run-TryBot: Julie Qiu <julie@golang.org>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/vulncheck/fetch.go b/vulncheck/fetch.go
new file mode 100644
index 0000000..d028513
--- /dev/null
+++ b/vulncheck/fetch.go
@@ -0,0 +1,118 @@
+// Copyright 2021 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.
+
+package vulncheck
+
+import (
+	"fmt"
+	"go/build"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"golang.org/x/tools/go/packages"
+	"golang.org/x/vulndb/client"
+)
+
+// modKey creates a unique string identifier for mod.
+func modKey(mod *packages.Module) string {
+	if mod == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s@%s", modPath(mod), modVersion(mod))
+}
+
+func modPath(mod *packages.Module) string {
+	if mod.Replace != nil {
+		return mod.Replace.Path
+	}
+	return mod.Path
+}
+
+func modVersion(mod *packages.Module) string {
+	if mod.Replace != nil {
+		return mod.Replace.Version
+	}
+	return mod.Version
+}
+
+// extractModules collects modules in `pkgs` up to uniqueness of
+// module path and version.
+func extractModules(pkgs []*packages.Package) []*packages.Module {
+	modMap := map[string]*packages.Module{}
+
+	seen := map[*packages.Package]bool{}
+	var extract func(*packages.Package, map[string]*packages.Module)
+	extract = func(pkg *packages.Package, modMap map[string]*packages.Module) {
+		if pkg == nil || seen[pkg] {
+			return
+		}
+		if pkg.Module != nil {
+			modMap[modKey(pkg.Module)] = pkg.Module
+		}
+		seen[pkg] = true
+		for _, imp := range pkg.Imports {
+			extract(imp, modMap)
+		}
+	}
+	for _, pkg := range pkgs {
+		extract(pkg, modMap)
+	}
+
+	modules := []*packages.Module{}
+	for _, mod := range modMap {
+		modules = append(modules, mod)
+	}
+	return modules
+}
+
+// fetchVulnerabilities fetches vulnerabilities that affect the supplied modules.
+func fetchVulnerabilities(client client.Client, modules []*packages.Module) (moduleVulnerabilities, error) {
+	mv := moduleVulnerabilities{}
+	for _, mod := range modules {
+		modPath := mod.Path
+		if mod.Replace != nil {
+			modPath = mod.Replace.Path
+		}
+
+		// skip loading vulns for local imports
+		if isLocal(mod) {
+			// TODO: what if client has its own db
+			// with local vulns?
+			continue
+		}
+		vulns, err := client.GetByModule(modPath)
+		if err != nil {
+			return nil, err
+		}
+		if len(vulns) == 0 {
+			continue
+		}
+		mv = append(mv, modVulns{
+			mod:   mod,
+			vulns: vulns,
+		})
+	}
+	return mv, nil
+}
+
+func isLocal(mod *packages.Module) bool {
+	modDir := mod.Dir
+	if mod.Replace != nil {
+		modDir = mod.Replace.Dir
+	}
+	return !strings.HasPrefix(modDir, modCacheDirectory())
+}
+
+func modCacheDirectory() string {
+	var modCacheDir string
+	// TODO: define modCacheDir using something similar to cmd/go/internal/cfg.GOMODCACHE?
+	if modCacheDir = os.Getenv("GOMODCACHE"); modCacheDir == "" {
+		if modCacheDir = os.Getenv("GOPATH"); modCacheDir == "" {
+			modCacheDir = build.Default.GOPATH
+		}
+		modCacheDir = filepath.Join(modCacheDir, "pkg", "mod")
+	}
+	return modCacheDir
+}
diff --git a/vulncheck/fetch_test.go b/vulncheck/fetch_test.go
new file mode 100644
index 0000000..a60edee
--- /dev/null
+++ b/vulncheck/fetch_test.go
@@ -0,0 +1,58 @@
+// Copyright 2021 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.
+
+package vulncheck
+
+import (
+	"reflect"
+	"testing"
+
+	"golang.org/x/tools/go/packages"
+	"golang.org/x/vulndb/osv"
+)
+
+func TestFetchVulnerabilities(t *testing.T) {
+	mc := &mockClient{
+		ret: map[string][]*osv.Entry{
+			"example.mod/a": {{ID: "a", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}}}}},
+			"example.mod/b": {{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/b"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "1.1.1"}}}}}}}},
+			"example.mod/d": {{ID: "c", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/d"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}}}}},
+			"example.mod/e": {{ID: "e", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/e"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.2.0"}}}}}}}},
+		},
+	}
+
+	mv, err := fetchVulnerabilities(mc, []*packages.Module{
+		{Path: "example.mod/a", Dir: modCacheDirectory(), Version: "v1.0.0"},
+		{Path: "example.mod/b", Dir: modCacheDirectory(), Version: "v1.0.4"},
+		{Path: "example.mod/c", Replace: &packages.Module{Path: "example.mod/d", Dir: modCacheDirectory(), Version: "v1.0.0"}, Version: "v2.0.0"},
+		{Path: "example.mod/e", Replace: &packages.Module{Path: "../local/example.mod/d", Dir: modCacheDirectory(), Version: "v1.0.1"}, Version: "v2.1.0"},
+	})
+	if err != nil {
+		t.Fatalf("FetchVulnerabilities failed: %s", err)
+	}
+
+	expected := moduleVulnerabilities{
+		{
+			mod: &packages.Module{Path: "example.mod/a", Dir: modCacheDirectory(), Version: "v1.0.0"},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{Path: "example.mod/b", Dir: modCacheDirectory(), Version: "v1.0.4"},
+			vulns: []*osv.Entry{
+				{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/b"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "1.1.1"}}}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{Path: "example.mod/c", Replace: &packages.Module{Path: "example.mod/d", Dir: modCacheDirectory(), Version: "v1.0.0"}, Version: "v2.0.0"},
+			vulns: []*osv.Entry{
+				{ID: "c", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/d"}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}}}},
+			},
+		},
+	}
+	if !reflect.DeepEqual(mv, expected) {
+		t.Fatalf("FetchVulnerabilities returned unexpected results, got:\n%s\nwant:\n%s", moduleVulnerabilitiesToString(mv), moduleVulnerabilitiesToString(expected))
+	}
+}
diff --git a/vulncheck/go.mod b/vulncheck/go.mod
index de7dce9..c5c7b59 100644
--- a/vulncheck/go.mod
+++ b/vulncheck/go.mod
@@ -2,9 +2,13 @@
 
 go 1.17
 
-require golang.org/x/vulndb v0.0.0-20211020161650-d192a0cd6149
+require (
+	golang.org/x/tools v0.0.0-20210910171127-a568412ca0e6
+	golang.org/x/vulndb v0.0.0-20211020161650-d192a0cd6149
+)
 
 require (
-	golang.org/x/mod v0.4.1 // indirect
-	golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 // indirect
+	golang.org/x/mod v0.4.2 // indirect
+	golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e // indirect
+	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
 )
diff --git a/vulncheck/go.sum b/vulncheck/go.sum
index 4d817b1..978c610 100644
--- a/vulncheck/go.sum
+++ b/vulncheck/go.sum
@@ -42,19 +42,23 @@
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0=
+github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY=
 golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
+golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k=
+golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -64,19 +68,26 @@
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20210910171127-a568412ca0e6 h1:O8E8PvYQ1HtZae5hPC9lyLiVuGlqL50yMQW7Y6oRaXQ=
+golang.org/x/tools v0.0.0-20210910171127-a568412ca0e6/go.mod h1:YD9qOF0M9xpSpdWTBbzEl5e/RnCefISl8E5Noe10jFM=
 golang.org/x/vulndb v0.0.0-20211020161650-d192a0cd6149 h1:xCvKnKNK/wBCJAR3Xm1N294PIuxhVJDi570Fz6lh2O0=
 golang.org/x/vulndb v0.0.0-20211020161650-d192a0cd6149/go.mod h1:dbogqFEsxyrr+RMtOySAr6gAr9NWRrrtJoQLSdq5fLk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/vulncheck/helpers_test.go b/vulncheck/helpers_test.go
new file mode 100644
index 0000000..95eaa24
--- /dev/null
+++ b/vulncheck/helpers_test.go
@@ -0,0 +1,42 @@
+// Copyright 2021 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.
+
+package vulncheck
+
+import (
+	"fmt"
+
+	"golang.org/x/vulndb/osv"
+)
+
+type mockClient struct {
+	ret map[string][]*osv.Entry
+}
+
+func (mc *mockClient) GetByModule(a string) ([]*osv.Entry, error) {
+	return mc.ret[a], nil
+}
+
+func (mc *mockClient) GetByID(a string) (*osv.Entry, error) {
+	return nil, nil
+}
+
+func moduleVulnerabilitiesToString(mv moduleVulnerabilities) string {
+	var s string
+	for _, m := range mv {
+		s += fmt.Sprintf("mod: %v\n", m.mod)
+		for _, v := range m.vulns {
+			s += fmt.Sprintf("\t%v\n", v)
+		}
+	}
+	return s
+}
+
+func vulnsToString(vulns []*osv.Entry) string {
+	var s string
+	for _, v := range vulns {
+		s += fmt.Sprintf("\t%v\n", v)
+	}
+	return s
+}
diff --git a/vulncheck/vulncheck.go b/vulncheck/vulncheck.go
index 4d8cc90..132ebf9 100644
--- a/vulncheck/vulncheck.go
+++ b/vulncheck/vulncheck.go
@@ -7,8 +7,11 @@
 package vulncheck
 
 import (
+	"fmt"
 	"go/token"
+	"strings"
 
+	"golang.org/x/tools/go/packages"
 	"golang.org/x/vulndb/client"
 	"golang.org/x/vulndb/osv"
 )
@@ -148,3 +151,158 @@
 	// ImportedBy contains IDs of packages directly importing this package.
 	ImportedBy []int
 }
+
+// moduleVulnerabilities is an internal structure for
+// holding and querying vulnerabilities provided by a
+// vulnerability database client.
+type moduleVulnerabilities []modVulns
+
+// modVulns groups vulnerabilities per module.
+type modVulns struct {
+	mod   *packages.Module
+	vulns []*osv.Entry
+}
+
+func (mv moduleVulnerabilities) Filter(os, arch string) moduleVulnerabilities {
+	var filteredMod moduleVulnerabilities
+	for _, mod := range mv {
+		module := mod.mod
+		modVersion := module.Version
+		if module.Replace != nil {
+			modVersion = module.Replace.Version
+		}
+		// TODO(https://golang.org/issues/49264): if modVersion == "", try vcs?
+		var filteredVulns []*osv.Entry
+		for _, v := range mod.vulns {
+			var filteredAffected []osv.Affected
+			for _, a := range v.Affected {
+				// A module version is affected if
+				//  - it is included in one of the affected version ranges
+				//  - and module version is not ""
+				//  The latter means the module version is not available, so
+				//  we don't want to spam users with potential false alarms.
+				//  TODO: issue warning for "" cases above?
+				affected := modVersion != "" && a.Ranges.AffectsSemver(modVersion) && matchesPlatform(os, arch, a.EcosystemSpecific)
+				if affected {
+					filteredAffected = append(filteredAffected, a)
+				}
+			}
+			if len(filteredAffected) == 0 {
+				continue
+			}
+			// save the non-empty vulnerability with only
+			// affected symbols.
+			newV := *v
+			newV.Affected = filteredAffected
+			filteredVulns = append(filteredVulns, &newV)
+		}
+		filteredMod = append(filteredMod, modVulns{
+			mod:   module,
+			vulns: filteredVulns,
+		})
+	}
+	return filteredMod
+}
+
+func matchesPlatform(os, arch string, e osv.EcosystemSpecific) bool {
+	matchesOS := len(e.GOOS) == 0
+	matchesArch := len(e.GOARCH) == 0
+	for _, o := range e.GOOS {
+		if os == o {
+			matchesOS = true
+			break
+		}
+	}
+	for _, a := range e.GOARCH {
+		if arch == a {
+			matchesArch = true
+			break
+		}
+	}
+	return matchesOS && matchesArch
+}
+func (mv moduleVulnerabilities) Num() int {
+	var num int
+	for _, m := range mv {
+		num += len(m.vulns)
+	}
+	return num
+}
+
+// VulnsForPackage returns the vulnerabilities for the module which is the most
+// specific prefix of importPath, or nil if there is no matching module with
+// vulnerabilities.
+func (mv moduleVulnerabilities) VulnsForPackage(importPath string) []*osv.Entry {
+	var mostSpecificMod *modVulns
+	for _, mod := range mv {
+		md := mod
+		if strings.HasPrefix(importPath, md.mod.Path) {
+			if mostSpecificMod == nil || len(mostSpecificMod.mod.Path) < len(md.mod.Path) {
+				mostSpecificMod = &md
+			}
+		}
+	}
+
+	if mostSpecificMod == nil {
+		return nil
+	}
+
+	if mostSpecificMod.mod.Replace != nil {
+		importPath = fmt.Sprintf("%s%s", mostSpecificMod.mod.Replace.Path, strings.TrimPrefix(importPath, mostSpecificMod.mod.Path))
+	}
+	vulns := mostSpecificMod.vulns
+	packageVulns := []*osv.Entry{}
+	for _, v := range vulns {
+		for _, a := range v.Affected {
+			if a.Package.Name == importPath {
+				packageVulns = append(packageVulns, v)
+				break
+			}
+		}
+	}
+	return packageVulns
+}
+
+// VulnsForSymbol returns vulnerabilites for `symbol` in `mv.VulnsForPackage(importPath)`.
+func (mv moduleVulnerabilities) VulnsForSymbol(importPath, symbol string) []*osv.Entry {
+	vulns := mv.VulnsForPackage(importPath)
+	if vulns == nil {
+		return nil
+	}
+
+	symbolVulns := []*osv.Entry{}
+	for _, v := range vulns {
+	vulnLoop:
+		for _, a := range v.Affected {
+			if a.Package.Name != importPath {
+				continue
+			}
+			if len(a.EcosystemSpecific.Symbols) == 0 {
+				symbolVulns = append(symbolVulns, v)
+				continue vulnLoop
+			}
+			for _, s := range a.EcosystemSpecific.Symbols {
+				if s == symbol {
+					symbolVulns = append(symbolVulns, v)
+					continue vulnLoop
+				}
+			}
+		}
+	}
+	return symbolVulns
+}
+
+// Vulns returns vulnerabilities for all modules in `mv`.
+func (mv moduleVulnerabilities) Vulns() []*osv.Entry {
+	var vulns []*osv.Entry
+	seen := make(map[string]bool)
+	for _, mv := range mv {
+		for _, v := range mv.vulns {
+			if !seen[v.ID] {
+				vulns = append(vulns, v)
+				seen[v.ID] = true
+			}
+		}
+	}
+	return vulns
+}
diff --git a/vulncheck/vulncheck_test.go b/vulncheck/vulncheck_test.go
new file mode 100644
index 0000000..d4441bc
--- /dev/null
+++ b/vulncheck/vulncheck_test.go
@@ -0,0 +1,218 @@
+// Copyright 2021 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.
+
+package vulncheck
+
+import (
+	"reflect"
+	"testing"
+
+	"golang.org/x/tools/go/packages"
+	"golang.org/x/vulndb/osv"
+)
+
+func TestFilterVulns(t *testing.T) {
+	mv := moduleVulnerabilities{
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{
+					{Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}},
+					{Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "1.0.0"}}}}}, // should be filtered out
+				}},
+				{ID: "b", Affected: []osv.Affected{{Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "1.0.1"}}}}, EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"windows", "linux"}}}}},
+				{ID: "c", Affected: []osv.Affected{{Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "1.0.1"}, {Fixed: "1.0.1"}}}}, EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"arm64", "amd64"}}}}},
+				{ID: "d", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"windows"}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/b",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "e", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"arm64"}}}}},
+				{ID: "f", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"linux"}}}}},
+				{ID: "g", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"amd64"}}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "0.0.1"}, {Fixed: "2.0.1"}}}}}}},
+				{ID: "h", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"windows"}, GOARCH: []string{"amd64"}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path: "example.mod/c",
+			},
+			vulns: []*osv.Entry{
+				{ID: "i", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"amd64"}}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "0.0.0"}}}}}}},
+				{ID: "j", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"amd64"}}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "3.0.0"}}}}}}},
+				{ID: "k"},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/d",
+				Version: "v1.2.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "l", Affected: []osv.Affected{
+					{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"windows"}}}, // should be filtered out
+					{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"linux"}}},
+				}},
+			},
+		},
+	}
+
+	expected := moduleVulnerabilities{
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{{Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Fixed: "2.0.0"}}}}}}},
+				{ID: "c", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"arm64", "amd64"}}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "1.0.1"}, {Fixed: "1.0.1"}}}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/b",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "f", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"linux"}}}}},
+				{ID: "g", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOARCH: []string{"amd64"}}, Ranges: osv.Affects{{Type: osv.TypeSemver, Events: []osv.RangeEvent{{Introduced: "0.0.1"}, {Fixed: "2.0.1"}}}}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path: "example.mod/c",
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/d",
+				Version: "v1.2.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "l", Affected: []osv.Affected{{EcosystemSpecific: osv.EcosystemSpecific{GOOS: []string{"linux"}}}}},
+			},
+		},
+	}
+
+	filtered := mv.Filter("linux", "amd64")
+	if !reflect.DeepEqual(filtered, expected) {
+		t.Fatalf("Filter returned unexpected results, got:\n%s\nwant:\n%s", moduleVulnerabilitiesToString(filtered), moduleVulnerabilitiesToString(expected))
+	}
+}
+
+func TestVulnsForPackage(t *testing.T) {
+	mv := moduleVulnerabilities{
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a/b",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/d",
+				Version: "v0.0.1",
+			},
+			vulns: []*osv.Entry{
+				{ID: "d", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/d"}}}},
+			},
+		},
+	}
+
+	filtered := mv.VulnsForPackage("example.mod/a/b/c")
+	expected := []*osv.Entry{
+		{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}}}},
+	}
+
+	if !reflect.DeepEqual(filtered, expected) {
+		t.Fatalf("VulnsForPackage returned unexpected results, got:\n%s\nwant:\n%s", vulnsToString(filtered), vulnsToString(expected))
+	}
+}
+
+func TestVulnsForPackageReplaced(t *testing.T) {
+	mv := moduleVulnerabilities{
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path: "example.mod/a/b",
+				Replace: &packages.Module{
+					Path: "example.mod/b",
+				},
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "c", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/b/c"}}}},
+			},
+		},
+	}
+
+	filtered := mv.VulnsForPackage("example.mod/a/b/c")
+	expected := []*osv.Entry{
+		{ID: "c", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/b/c"}}}},
+	}
+
+	if !reflect.DeepEqual(filtered, expected) {
+		t.Fatalf("VulnsForPackage returned unexpected results, got:\n%s\nwant:\n%s", vulnsToString(filtered), vulnsToString(expected))
+	}
+}
+
+func TestVulnsForSymbol(t *testing.T) {
+	mv := moduleVulnerabilities{
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "a", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}}}},
+			},
+		},
+		{
+			mod: &packages.Module{
+				Path:    "example.mod/a/b",
+				Version: "v1.0.0",
+			},
+			vulns: []*osv.Entry{
+				{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}, EcosystemSpecific: osv.EcosystemSpecific{Symbols: []string{"a"}}}}},
+				{ID: "c", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}, EcosystemSpecific: osv.EcosystemSpecific{Symbols: []string{"b"}}}}},
+			},
+		},
+	}
+
+	filtered := mv.VulnsForSymbol("example.mod/a/b/c", "a")
+	expected := []*osv.Entry{
+		{ID: "b", Affected: []osv.Affected{{Package: osv.Package{Name: "example.mod/a/b/c"}, EcosystemSpecific: osv.EcosystemSpecific{Symbols: []string{"a"}}}}},
+	}
+
+	if !reflect.DeepEqual(filtered, expected) {
+		t.Fatalf("VulnsForPackage returned unexpected results, got:\n%s\nwant:\n%s", vulnsToString(filtered), vulnsToString(expected))
+	}
+}