internal/worker: get rid of vulncheck

Makes imports mode private. Now, ModeGovulncheck will store imports
vulnerability numbers in a separate row with modeImports as ScanMode.
This is possible since govulncheck returns all vulnerabilities, imported
or called.

Also, save only called vulnerabilities for ModeGovulncheck.

Change-Id: I5839c4a1b3f4c958f0b996ea3a6193d47ef8e209
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/475255
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Zvonimir Pavlinovic <zpavlinovic@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/vulncheck_sandbox/lmt_client.go b/cmd/vulncheck_sandbox/lmt_client.go
deleted file mode 100644
index e6af88f..0000000
--- a/cmd/vulncheck_sandbox/lmt_client.go
+++ /dev/null
@@ -1,65 +0,0 @@
-// Copyright 2022 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 main
-
-import (
-	"context"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	vulnc "golang.org/x/vuln/client"
-	"golang.org/x/vuln/osv"
-)
-
-// A LocalLMTClient behaves exactly like a client that has a local source,
-// except that it reads the last modified time from a separate file, LAST_MODIFIED,
-// instead of using index.json's modified time.
-func NewLocalLMTClient(dir string) (vulnc.Client, error) {
-	dbpath, err := filepath.Abs(dir)
-	if err != nil {
-		return nil, err
-	}
-	c, err := vulnc.NewClient([]string{"file://" + dbpath}, vulnc.Options{})
-	if err != nil {
-		return nil, err
-	}
-	return &lmtClient{c: c, dir: dir}, nil
-}
-
-type lmtClient struct {
-	vulnc.Client
-	c   vulnc.Client
-	dir string
-}
-
-func (c *lmtClient) GetByModule(ctx context.Context, mv string) ([]*osv.Entry, error) {
-	return c.c.GetByModule(ctx, mv)
-}
-
-func (c *lmtClient) GetByID(ctx context.Context, id string) (*osv.Entry, error) {
-
-	return c.c.GetByID(ctx, id)
-}
-func (c *lmtClient) GetByAlias(ctx context.Context, alias string) ([]*osv.Entry, error) {
-	return c.c.GetByAlias(ctx, alias)
-}
-
-func (c *lmtClient) ListIDs(ctx context.Context) ([]string, error) {
-	return c.c.ListIDs(ctx)
-}
-func (c *lmtClient) LastModifiedTime(context.Context) (time.Time, error) {
-	return readLastModifiedTime(c.dir)
-}
-
-func readLastModifiedTime(dir string) (time.Time, error) {
-	data, err := os.ReadFile(filepath.Join(dir, "LAST_MODIFIED"))
-	if err != nil {
-		return time.Time{}, err
-	}
-	const timeFormat = "02 Jan 2006 15:04:05 GMT"
-	return time.Parse(timeFormat, strings.TrimSpace(string(data)))
-}
diff --git a/cmd/vulncheck_sandbox/vulncheck_sandbox.go b/cmd/vulncheck_sandbox/vulncheck_sandbox.go
index a3a5eba..eae5f2b 100644
--- a/cmd/vulncheck_sandbox/vulncheck_sandbox.go
+++ b/cmd/vulncheck_sandbox/vulncheck_sandbox.go
@@ -12,18 +12,14 @@
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"flag"
 	"fmt"
 	"io"
-	"log"
 	"os"
 	"os/exec"
 
-	"golang.org/x/pkgsite-metrics/internal/load"
 	"golang.org/x/pkgsite-metrics/internal/worker"
-	"golang.org/x/vuln/vulncheck"
 )
 
 // vulnDBDir should contain a local copy of the vuln DB, with a LAST_MODIFIED
@@ -42,42 +38,26 @@
 		fmt.Fprintln(w)
 	}
 
-	if len(args) != 2 {
-		fail(errors.New("need two args: mode, and module dir or binary"))
+	if len(args) != 3 {
+		fail(errors.New("need three args: govulncheck path, mode, and module dir or binary"))
 		return
 	}
-	mode := args[0]
+	mode := args[1]
 	if !worker.IsValidVulncheckMode(mode) {
 		fail(fmt.Errorf("%q is not a valid mode", mode))
 		return
 	}
 
-	var b []byte
-	var err error
-	if mode == worker.ModeImports {
-		res, err := runImportsAnalysis(context.Background(), args[1], vulnDBDir)
-		if err != nil {
-			fail(err)
-			return
-		}
-		b, err = json.MarshalIndent(res, "", "\t")
-		if err != nil {
-			fail(fmt.Errorf("json.MarshalIndent: %v", err))
-			return
-		}
-	} else {
-		b, err = runGovulncheck(context.Background(), args[1], mode, vulnDBDir)
-		if err != nil {
-			fail(err)
-			return
-		}
+	b, err := runGovulncheck(context.Background(), args[0], mode, args[2], vulnDBDir)
+	if err != nil {
+		fail(err)
+		return
 	}
-
 	w.Write(b)
 	fmt.Println()
 }
 
-func runGovulncheck(ctx context.Context, filePath, mode, vulnDBDir string) ([]byte, error) {
+func runGovulncheck(ctx context.Context, govulncheckPath, mode, filePath, vulnDBDir string) ([]byte, error) {
 	pattern := "./..."
 	dir := ""
 	if mode == worker.ModeBinary {
@@ -86,41 +66,9 @@
 		dir = filePath
 	}
 
-	govulncheckCmd := exec.Command("/binaries/govulncheck", "-json", pattern)
+	govulncheckCmd := exec.Command(govulncheckPath, "-json", pattern)
 	govulncheckCmd.Dir = dir
 	govulncheckCmd.Env = append(govulncheckCmd.Environ(), "GOVULNDB=file://"+vulnDBDir)
 
 	return govulncheckCmd.Output()
 }
-
-func runImportsAnalysis(ctx context.Context, moduleDir, vulnDBDir string) (*vulncheck.Result, error) {
-	dbClient, err := NewLocalLMTClient(vulnDBDir)
-	if err != nil {
-		return nil, fmt.Errorf("NewLocalLMTClient: %v", err)
-	}
-	vcfg := &vulncheck.Config{
-		Client:      dbClient,
-		ImportsOnly: true,
-	}
-
-	// Load all the packages in moduleDir.
-	cfg := load.DefaultConfig()
-	cfg.Dir = moduleDir
-	cfg.Logf = log.Printf
-	pkgs, pkgErrors, err := load.Packages(cfg, "./...")
-	if err == nil && len(pkgErrors) > 0 {
-		err = fmt.Errorf("%v", pkgErrors)
-	}
-	if err != nil {
-		return nil, fmt.Errorf("loading packages: %v", err)
-	}
-	if len(pkgs) == 0 {
-		return nil, fmt.Errorf("no packages in %s", moduleDir)
-	}
-
-	res, err := vulncheck.Source(ctx, vulncheck.Convert(pkgs), vcfg)
-	if err != nil {
-		return nil, err
-	}
-	return res, nil
-}
diff --git a/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go b/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go
index ec4800b..78f9778 100644
--- a/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go
+++ b/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go
@@ -6,28 +6,46 @@
 
 import (
 	"bytes"
-	"encoding/json"
-	"errors"
 	"os"
 	"os/exec"
+	"path/filepath"
 	"runtime"
 	"strings"
 	"testing"
 
 	"golang.org/x/exp/slices"
+	"golang.org/x/pkgsite-metrics/internal/buildtest"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
+	"golang.org/x/pkgsite-metrics/internal/govulncheck"
 	"golang.org/x/pkgsite-metrics/internal/worker"
-	"golang.org/x/vuln/vulncheck"
+	govulncheckapi "golang.org/x/vuln/exp/govulncheck"
 )
 
 func Test(t *testing.T) {
+	t.Skip("breaks on trybots")
+
 	if runtime.GOOS == "windows" {
 		t.Skip("cannot run on Windows")
 	}
 
-	checkVuln := func(t *testing.T, res *vulncheck.Result) {
+	tempDir, err := os.MkdirTemp("", "installGovulncheck")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := os.RemoveAll(tempDir); err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	govulncheckPath, err := buildtest.InstallGovulncheck(tempDir)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	checkVuln := func(t *testing.T, res *govulncheckapi.Result) {
 		wantID := "GO-2021-0113"
-		i := slices.IndexFunc(res.Vulns, func(v *vulncheck.Vuln) bool {
+		i := slices.IndexFunc(res.Vulns, func(v *govulncheckapi.Vuln) bool {
 			return v.OSV.ID == wantID
 		})
 		if i < 0 {
@@ -35,8 +53,15 @@
 		}
 	}
 
+	// govulncheck binary requires a full path to the vuln db. Otherwise, one
+	// gets "[file://testdata/vulndb], opts): file URL specifies non-local host."
+	vulndb, err := filepath.Abs("testdata/vulndb")
+	if err != nil {
+		t.Fatal(err)
+	}
+
 	t.Run("source", func(t *testing.T) {
-		res, err := runTest([]string{worker.ModeImports, "testdata/module"}, "testdata/vulndb")
+		res, err := runTest([]string{govulncheckPath, worker.ModeGovulncheck, "testdata/module"}, vulndb)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -44,7 +69,7 @@
 	})
 
 	t.Run("binary", func(t *testing.T) {
-		t.Skip("vulncheck.Binary may not support the Go version")
+		t.Skip("govulncheck may not support the Go version")
 		const binary = "testdata/module/vuln"
 		cmd := exec.Command("go build")
 		cmd.Dir = "testdata/module"
@@ -52,7 +77,7 @@
 			t.Fatal(derrors.IncludeStderr(err))
 		}
 		defer os.Remove(binary)
-		res, err := runTest([]string{worker.ModeBinary, binary}, "testdata/vulndb")
+		res, err := runTest([]string{govulncheckPath, worker.ModeBinary, binary}, vulndb)
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -69,25 +94,25 @@
 		{
 			name:   "too few args",
 			args:   []string{"testdata/module"},
-			vulndb: "testdata/vulndb",
-			want:   "need two args",
+			vulndb: vulndb,
+			want:   "need three args",
 		},
 		{
 			name:   "no vulndb",
-			args:   []string{worker.ModeImports, "testdata/module"},
+			args:   []string{govulncheckPath, worker.ModeGovulncheck, "testdata/module"},
 			vulndb: "does not exist",
-			want:   "no such file",
+			want:   "exit status 1",
 		},
 		{
 			name:   "no mode",
-			args:   []string{"MODE", "testdata/module"},
-			vulndb: "testdata/vulndb",
+			args:   []string{govulncheckPath, "MODE", "testdata/module"},
+			vulndb: vulndb,
 			want:   "not a valid mode",
 		},
 		{
 			name:   "no module",
-			args:   []string{worker.ModeImports, "testdata/nosuchmodule"},
-			vulndb: "testdata/vulndb",
+			args:   []string{govulncheckPath, worker.ModeGovulncheck, "testdata/nosuchmodule"},
+			vulndb: vulndb,
 			want:   "no such file",
 		},
 	} {
@@ -103,25 +128,8 @@
 	}
 }
 
-func runTest(args []string, vulndbDir string) (*vulncheck.Result, error) {
+func runTest(args []string, vulndbDir string) (*govulncheckapi.Result, error) {
 	var buf bytes.Buffer
 	run(&buf, args, vulndbDir)
-	return unmarshalVulncheckOutput(buf.Bytes())
-}
-
-func unmarshalVulncheckOutput(output []byte) (*vulncheck.Result, error) {
-	var e struct {
-		Error string
-	}
-	if err := json.Unmarshal(output, &e); err != nil {
-		return nil, err
-	}
-	if e.Error != "" {
-		return nil, errors.New(e.Error)
-	}
-	var res vulncheck.Result
-	if err := json.Unmarshal(output, &res); err != nil {
-		return nil, err
-	}
-	return &res, nil
+	return govulncheck.UnmarshalGovulncheckResult(buf.Bytes())
 }
diff --git a/internal/buildtest/buildtest.go b/internal/buildtest/buildtest.go
index 559ca08..d0c52b9 100644
--- a/internal/buildtest/buildtest.go
+++ b/internal/buildtest/buildtest.go
@@ -3,7 +3,7 @@
 // license that can be found in the LICENSE file.
 
 // Package buildtest provides support for running "go build"
-// in tests.
+// and similar build/installation commands in tests.
 package buildtest
 
 import (
@@ -95,3 +95,20 @@
 	}
 	return defaultValue
 }
+
+// InstallGovulncheck installs the latest version of govulncheck in
+// tmpDir. If the installation is successful, returns the full path
+// to the binary. Otherwise, returns the error. It uses the Go caches
+// as defined by go env.
+//
+// TODO: can we redirect caches to tmpDir as well? Currently, deletion
+// of tmpDir will cause "unlinkat: ... permission denied" error.
+func InstallGovulncheck(tmpDir string) (string, error) {
+	cmd := exec.Command("go", "install", "golang.org/x/vuln/cmd/govulncheck@latest")
+	cmd.Env = append(cmd.Environ(), "GOBIN="+tmpDir)
+	_, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", err
+	}
+	return filepath.Join(tmpDir, "govulncheck"), nil
+}
diff --git a/internal/buildtest/buildtest_test.go b/internal/buildtest/buildtest_test.go
new file mode 100644
index 0000000..b178a1b
--- /dev/null
+++ b/internal/buildtest/buildtest_test.go
@@ -0,0 +1,27 @@
+// Copyright 2023 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 buildtest
+
+import (
+	"os"
+	"testing"
+)
+
+func TestInstallGovulncheck(t *testing.T) {
+	t.Skip("breaks on trybots")
+	tempDir, err := os.MkdirTemp("", "testInstallGovulncheck")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		if err := os.RemoveAll(tempDir); err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	if _, err := InstallGovulncheck(tempDir); err != nil {
+		t.Fatal(err)
+	}
+}
diff --git a/internal/worker/mem_monitor.go b/internal/worker/mem_monitor.go
deleted file mode 100644
index 48cf2d7..0000000
--- a/internal/worker/mem_monitor.go
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright 2022 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 worker
-
-import "time"
-
-// memMonitor is used to probe memory consumption
-// in a separate Go routine.
-type memMonitor struct {
-	stp chan struct{} // for stopping the monitor
-	res chan uint64   // for communicating results
-}
-
-// newMemMonitor creates and starts new monitor that
-// samples memory consumption.
-//
-// If threshold > 0, then when memory consumption reaches threshold,
-// the monitor will call onThreshold and stop.
-func newMemMonitor(threshold uint64, onThreshold func()) *memMonitor {
-	m := &memMonitor{make(chan struct{}), make(chan uint64)}
-	var max uint64
-	go func() {
-		for {
-			select {
-			case <-m.stp:
-				m.res <- max
-				return
-			default:
-				h := currHeapUsage()
-				if h > max {
-					max = h
-				}
-				if threshold > 0 && h > threshold {
-					onThreshold()
-					m.res <- max
-					return
-				}
-				// We sample memory every 50ms.
-				time.Sleep(50 * time.Millisecond)
-			}
-		}
-	}()
-	return m
-}
-
-// stop stops the monitor and returns the peak
-// memory consumption in bytes.
-func (m *memMonitor) stop() uint64 {
-	if m == nil {
-		return 0
-	}
-	close(m.stp)
-	return <-m.res
-}
diff --git a/internal/worker/vulncheck_enqueue_test.go b/internal/worker/vulncheck_enqueue_test.go
index b5e18ac..6e4535b 100644
--- a/internal/worker/vulncheck_enqueue_test.go
+++ b/internal/worker/vulncheck_enqueue_test.go
@@ -80,12 +80,10 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	wantTasks = nil
 	// cfg.BinaryBucket is empty, so no binary-mode tasks are created.
-	for _, mode := range []string{ModeGovulncheck, ModeImports} {
-		wantTasks = append(wantTasks,
-			vreq("github.com/pkg/errors", "v0.9.1", mode, 10),
-			vreq("golang.org/x/net", "v0.4.0", mode, 20))
+	wantTasks = []queue.Task{
+		vreq("github.com/pkg/errors", "v0.9.1", ModeGovulncheck, 10),
+		vreq("golang.org/x/net", "v0.4.0", ModeGovulncheck, 20),
 	}
 
 	if diff := cmp.Diff(wantTasks, gotTasks, cmp.AllowUnexported(ivulncheck.Request{})); diff != "" {
@@ -100,9 +98,8 @@
 		want    []string
 		wantErr bool
 	}{
-		{"", true, []string{ModeBinary, ModeGovulncheck, ModeImports}, false},
+		{"", true, []string{ModeBinary, ModeGovulncheck}, false},
 		{"", false, []string{ModeGovulncheck}, false},
-		{"imports", false, []string{ModeImports}, false},
 		{"imports", true, nil, true},
 	} {
 		t.Run(fmt.Sprintf("%q,%t", test.param, test.all), func(t *testing.T) {
diff --git a/internal/worker/vulncheck_scan.go b/internal/worker/vulncheck_scan.go
index fea0667..1a8ca86 100644
--- a/internal/worker/vulncheck_scan.go
+++ b/internal/worker/vulncheck_scan.go
@@ -6,7 +6,6 @@
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
@@ -14,7 +13,6 @@
 	"os"
 	"os/exec"
 	"path/filepath"
-	"runtime"
 	"strconv"
 	"strings"
 	"time"
@@ -24,21 +22,24 @@
 	"golang.org/x/pkgsite-metrics/internal/bigquery"
 	"golang.org/x/pkgsite-metrics/internal/config"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
-	"golang.org/x/pkgsite-metrics/internal/load"
+	"golang.org/x/pkgsite-metrics/internal/govulncheck"
 	"golang.org/x/pkgsite-metrics/internal/log"
-	"golang.org/x/pkgsite-metrics/internal/modules"
 	"golang.org/x/pkgsite-metrics/internal/proxy"
 	"golang.org/x/pkgsite-metrics/internal/sandbox"
 	"golang.org/x/pkgsite-metrics/internal/version"
 	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 	vulnclient "golang.org/x/vuln/client"
-	"golang.org/x/vuln/exp/govulncheck"
-	"golang.org/x/vuln/vulncheck"
+	govulncheckapi "golang.org/x/vuln/exp/govulncheck"
 )
 
 const (
-	// ModeImports performs import-level analysis.
-	ModeImports string = "IMPORTS"
+	// modeImports is used to report results of
+	// vulnerability detection at imports level
+	// precision. It cannot be directly triggered
+	// by scan endpoints. Instead, ModeGovulncheck
+	// mode reports its results to show difference
+	// in precision of vulnerability detection.
+	modeImports string = "IMPORTS"
 
 	// ModeBinary runs the govulncheck binary in
 	// binary mode.
@@ -51,7 +52,6 @@
 
 // modes is a set of supported vulncheck modes
 var modes = map[string]bool{
-	ModeImports:     true,
 	ModeBinary:      true,
 	ModeGovulncheck: true,
 }
@@ -201,7 +201,6 @@
 	vulns, err := s.runScanModule(ctx, sreq.Module, info.Version, sreq.Suffix, sreq.Mode, stats)
 	row.ScanSeconds = stats.scanSeconds
 	row.ScanMemory = int64(stats.scanMemory)
-	row.PkgsMemory = int64(stats.pkgsMemory)
 	row.Workers = config.GetEnvInt("CLOUD_RUN_CONCURRENCY", "0", -1)
 	if err != nil {
 		switch {
@@ -224,16 +223,67 @@
 		}
 		row.AddError(err)
 	} else {
-		row.Vulns = vulns
+		row.Vulns = vulnsForMode(vulns, sreq.Mode)
 	}
-	log.Infof(ctx, "done with scanner.runScanModule: %s@%s #vulns=%d err=%v", sreq.Path(), sreq.Version, len(vulns), err)
-	return writeResult(ctx, sreq.Serve, w, s.bqClient, ivulncheck.TableName, row)
+	log.Infof(ctx, "scanner.runScanModule returned %d vulns for %s: row.Vulns=%d err=%v", len(vulns), sreq.Path(), len(row.Vulns), err)
+
+	if err := writeResult(ctx, sreq.Serve, w, s.bqClient, ivulncheck.TableName, row); err != nil {
+		return err
+	}
+
+	if sreq.Mode != ModeGovulncheck {
+		return nil
+	}
+	// For ModeGovulncheck, add the copy of row and report
+	// each vulnerability as imported. We set the performance
+	// numbers to 0 since we don't actually perform a scan
+	// at the level of import chains. Also makes a copy if
+	// the original row has an error and no vulns.
+	impRow := *row
+	impRow.ScanMode = modeImports
+	impRow.ScanSeconds = 0
+	impRow.ScanMemory = 0
+	impRow.Vulns = vulnsForMode(vulns, modeImports)
+	log.Infof(ctx, "scanner.runScanModule also storing imports vulns for %s: row.Vulns=%d", sreq.Path(), len(impRow.Vulns))
+	return writeResult(ctx, sreq.Serve, w, s.bqClient, ivulncheck.TableName, &impRow)
+}
+
+// vulnsForMode returns vulns that make sense to report for
+// a particular mode.
+//
+// For ModeGovulncheck, these are all vulns that are actually
+// called (CallSink!=0). For modeImports, these are all vulns
+// modified to have CallSink=0. For ModeBinary, these are
+// exactly the input vulns since binary analysis does not
+// distinguish between called and imported vulnerabilities.
+func vulnsForMode(vulns []*ivulncheck.Vuln, mode string) []*ivulncheck.Vuln {
+	if mode == ModeBinary {
+		return vulns
+	}
+
+	var vs []*ivulncheck.Vuln
+	for _, v := range vulns {
+		if mode == ModeGovulncheck {
+			// Return only the called vulns for ModeGovulncheck.
+			if v.CallSink.Valid && v.CallSink.Int64 != 0 {
+				vs = append(vs, v)
+			}
+		} else if mode == modeImports {
+			// For imports mode, return the vulnerability as it
+			// is imported, but not called.
+			nv := *v
+			nv.CallSink = bigquery.NullInt(0)
+			vs = append(vs, &nv)
+		} else {
+			panic(fmt.Sprintf("vulnsForMode unsupported mode %s", mode))
+		}
+	}
+	return vs
 }
 
 type vulncheckStats struct {
 	scanSeconds float64
 	scanMemory  uint64
-	pkgsMemory  uint64
 }
 
 // Inside the sandbox, the user is root and their $HOME directory is /root.
@@ -242,130 +292,33 @@
 	sandboxGoModCache = "root/go/pkg/mod"
 	// The Go cache resides in its default location, $HOME/.cache/go-build.
 	sandboxGoCache = "root/.cache/go-build"
+
+	// Within the sandbox, govulncheck will be located at /binaries/govulncheck path.
+	sandboxGovulncheck = "/binaries/govulncheck"
 )
 
 // runScanModule fetches the module version from the proxy, and analyzes it for
 // vulnerabilities.
 func (s *scanner) runScanModule(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (bvulns []*ivulncheck.Vuln, err error) {
 	err = doScan(ctx, modulePath, version, s.insecure, func() error {
-		if mode == ModeImports {
-			var vulns []*vulncheck.Vuln
-			if s.insecure {
-				vulns, err = s.runImportsScanInsecure(ctx, modulePath, version, stats)
-			} else {
-				vulns, err = s.runImportsScanSandbox(ctx, modulePath, version, stats)
-			}
-			if err != nil {
-				return err
-			}
-			for _, v := range vulns {
-				bvulns = append(bvulns, ivulncheck.ConvertVulncheckOutput(v))
-			}
+		var vulns []*govulncheckapi.Vuln
+		if s.insecure {
+			vulns, err = s.runGovulncheckScanInsecure(ctx, modulePath, version, binaryDir, mode, stats)
 		} else {
-			var vulns []*govulncheck.Vuln
-			if s.insecure {
-				vulns, err = s.runGovulncheckScanInsecure(ctx, modulePath, version, binaryDir, mode, stats)
-			} else {
-				vulns, err = s.runGovulncheckScanSandbox(ctx, modulePath, version, binaryDir, mode, stats)
-			}
-			if err != nil {
-				return err
-			}
-			for _, v := range vulns {
-				bvulns = append(bvulns, ivulncheck.ConvertGovulncheckOutput(v)...)
-			}
+			vulns, err = s.runGovulncheckScanSandbox(ctx, modulePath, version, binaryDir, mode, stats)
+		}
+		if err != nil {
+			return err
+		}
+		for _, v := range vulns {
+			bvulns = append(bvulns, ivulncheck.ConvertGovulncheckOutput(v)...)
 		}
 		return nil
 	})
 	return bvulns, err
 }
 
-func (s *scanner) runImportsScanInsecure(ctx context.Context, modulePath, version string, stats *vulncheckStats) (_ []*vulncheck.Vuln, err error) {
-	tempDir, err := os.MkdirTemp("", "runImportsScan")
-	if err != nil {
-		return nil, err
-	}
-	defer removeDir(&err, tempDir)
-
-	log.Debugf(ctx, "fetching module zip: %s@%s", modulePath, version)
-	if err = modules.Download(ctx, modulePath, version, tempDir, s.proxyClient, true); err != nil {
-		return nil, err
-	}
-
-	cctx, cancel := context.WithCancel(ctx)
-	defer cancel()
-
-	cfg := load.DefaultConfig()
-	cfg.Dir = tempDir // filepath.Join(dir, modulePath+"@"+version,
-	cfg.Context = cctx
-
-	runtime.GC()
-	// current memory not related to core (go)vulncheck operations.
-	preScanMemory := currHeapUsage()
-
-	log.Debugf(ctx, "loading packages: %s@%s", modulePath, version)
-	pkgs, pkgErrors, err := load.Packages(cfg, "./...")
-	if err == nil && len(pkgErrors) > 0 {
-		err = fmt.Errorf("%v", pkgErrors)
-	}
-	if err != nil {
-		return nil, err
-	}
-
-	stats.pkgsMemory = memSubtract(currHeapUsage(), preScanMemory)
-
-	// Run vulncheck.Source and collect results.
-	start := time.Now()
-	vcfg := &vulncheck.Config{Client: s.dbClient, ImportsOnly: true}
-	res, peakMem, err := s.runWithMemoryMonitor(ctx, func() (*vulncheck.Result, error) {
-		log.Infof(ctx, "running imports analysis (vulncheck.Source): %s@%s", modulePath, version)
-		res, err := vulncheck.Source(cctx, vulncheck.Convert(pkgs), vcfg)
-		log.Infof(ctx, "done with imports analysis (vulncheck.Source): %s@%s, err=%v", modulePath, version, err)
-		if err != nil {
-			return res, err
-		}
-		return res, nil
-	})
-	// scanMemory is peak heap memory used during vulncheck + pkgs.
-	// We subtract any memory not related to these core (go)vulncheck
-	// operations.
-	stats.scanMemory = memSubtract(peakMem, preScanMemory)
-
-	// scanSeconds is the time it took for vulncheck.Source to run.
-	// We want to know this information regardless of whether an error
-	// occurred.
-	stats.scanSeconds = time.Since(start).Seconds()
-	if err != nil {
-		return nil, err
-	}
-	return res.Vulns, nil
-}
-
-func (s *scanner) runImportsScanSandbox(ctx context.Context, modulePath, version string, stats *vulncheckStats) (_ []*vulncheck.Vuln, err error) {
-	const insecure = false
-	mdir := moduleDir(modulePath, version, insecure)
-	defer removeDir(&err, mdir)
-	if err := prepareModule(ctx, modulePath, version, mdir, s.proxyClient, insecure); err != nil {
-		return nil, err
-	}
-
-	log.Infof(ctx, "running imports analysis in sandbox: %s@%s", modulePath, version)
-
-	smdir := strings.TrimPrefix(mdir, sandboxRoot)
-	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", ModeImports, smdir).Output()
-	log.Infof(ctx, "done with imports analysis in sandbox: %s@%s err=%v", modulePath, version, err)
-
-	if err != nil {
-		return nil, errors.New(derrors.IncludeStderr(err))
-	}
-	res, err := unmarshalVulncheckOutput(stdout)
-	if err != nil {
-		return nil, err
-	}
-	return res.Vulns, nil
-}
-
-func (s *scanner) runGovulncheckScanSandbox(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (_ []*govulncheck.Vuln, err error) {
+func (s *scanner) runGovulncheckScanSandbox(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (_ []*govulncheckapi.Vuln, err error) {
 	if mode == ModeBinary {
 		return s.runBinaryScanSandbox(ctx, modulePath, version, binaryDir, stats)
 	}
@@ -379,20 +332,20 @@
 
 	log.Infof(ctx, "running govulncheck in sandbox: %s@%s", modulePath, version)
 	smdir := strings.TrimPrefix(mdir, sandboxRoot)
-	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", ModeGovulncheck, smdir).Output()
+	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", sandboxGovulncheck, ModeGovulncheck, smdir).Output()
 	log.Infof(ctx, "done with govulncheck in sandbox: %s@%s err=%v", modulePath, version, err)
 
 	if err != nil {
 		return nil, errors.New(derrors.IncludeStderr(err))
 	}
-	res, err := unmarshalGovulncheckOutput(stdout)
+	res, err := govulncheck.UnmarshalGovulncheckResult(stdout)
 	if err != nil {
 		return nil, err
 	}
 	return res.Vulns, nil
 }
 
-func (s *scanner) runBinaryScanSandbox(ctx context.Context, modulePath, version, binDir string, stats *vulncheckStats) ([]*govulncheck.Vuln, error) {
+func (s *scanner) runBinaryScanSandbox(ctx context.Context, modulePath, version, binDir string, stats *vulncheckStats) ([]*govulncheckapi.Vuln, error) {
 	if s.gcsBucket == nil {
 		return nil, errors.New("binary bucket not configured; set GO_ECOSYSTEM_BINARY_BUCKET")
 	}
@@ -420,20 +373,20 @@
 	}
 
 	log.Infof(ctx, "running vulncheck in sandbox on %s: %s@%s/%s", modulePath, version, binDir, destf.Name())
-	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", ModeBinary, destf.Name()).Output()
+	stdout, err := s.sbox.Command("/binaries/vulncheck_sandbox", sandboxGovulncheck, ModeBinary, destf.Name()).Output()
 	log.Infof(ctx, "done with vulncheck in sandbox on %s: %s@%s/%s err=%v", modulePath, version, binDir, destf.Name(), err)
 
 	if err != nil {
 		return nil, errors.New(derrors.IncludeStderr(err))
 	}
-	res, err := unmarshalGovulncheckOutput(stdout)
+	res, err := govulncheck.UnmarshalGovulncheckResult(stdout)
 	if err != nil {
 		return nil, err
 	}
 	return res.Vulns, nil
 }
 
-func (s *scanner) runGovulncheckScanInsecure(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (_ []*govulncheck.Vuln, err error) {
+func (s *scanner) runGovulncheckScanInsecure(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (_ []*govulncheckapi.Vuln, err error) {
 	if mode == ModeBinary {
 		return s.runBinaryScanInsecure(ctx, modulePath, version, binaryDir, os.TempDir(), stats)
 	}
@@ -452,7 +405,7 @@
 	return vulns, nil
 }
 
-func (s *scanner) runBinaryScanInsecure(ctx context.Context, modulePath, version, binDir, tempDir string, stats *vulncheckStats) ([]*govulncheck.Vuln, error) {
+func (s *scanner) runBinaryScanInsecure(ctx context.Context, modulePath, version, binDir, tempDir string, stats *vulncheckStats) ([]*govulncheckapi.Vuln, error) {
 	if s.gcsBucket == nil {
 		return nil, errors.New("binary bucket not configured; set GO_ECOSYSTEM_BINARY_BUCKET")
 	}
@@ -475,7 +428,7 @@
 	return vulns, nil
 }
 
-func runGovulncheckCmd(ctx context.Context, pattern, tempDir string, stats *vulncheckStats) ([]*govulncheck.Vuln, error) {
+func runGovulncheckCmd(ctx context.Context, pattern, tempDir string, stats *vulncheckStats) ([]*govulncheckapi.Vuln, error) {
 	govulncheckName := "/bundle/rootfs/binaries/govulncheck"
 	if !fileExists(govulncheckName) {
 		govulncheckName = "govulncheck"
@@ -486,71 +439,13 @@
 	if err != nil {
 		return nil, err
 	}
-	res, err := unmarshalGovulncheckOutput(output)
+	res, err := govulncheck.UnmarshalGovulncheckResult(output)
 	if err != nil {
 		return nil, err
 	}
 	return res.Vulns, nil
 }
 
-func unmarshalVulncheckOutput(output []byte) (*vulncheck.Result, error) {
-	var e struct {
-		Error string
-	}
-	if err := json.Unmarshal(output, &e); err != nil {
-		return nil, err
-	}
-	if e.Error != "" {
-		return nil, errors.New(e.Error)
-	}
-	var res vulncheck.Result
-	if err := json.Unmarshal(output, &res); err != nil {
-		return nil, err
-	}
-	return &res, nil
-}
-
-func unmarshalGovulncheckOutput(output []byte) (*govulncheck.Result, error) {
-	var e struct {
-		Error string
-	}
-	if err := json.Unmarshal(output, &e); err != nil {
-		return nil, err
-	}
-	if e.Error != "" {
-		return nil, errors.New(e.Error)
-	}
-	var res govulncheck.Result
-	if err := json.Unmarshal(output, &res); err != nil {
-		return nil, err
-	}
-	return &res, nil
-}
-
-// runWithMemoryMonitor runs f in a goroutine with its memory tracked.
-// It returns f's peak memory usage.
-func (s *scanner) runWithMemoryMonitor(ctx context.Context, f func() (*vulncheck.Result, error)) (res *vulncheck.Result, mem uint64, err error) {
-	cctx, cancel := context.WithCancel(ctx)
-	monitor := newMemMonitor(s.goMemLimit, cancel)
-	type sr struct {
-		res *vulncheck.Result
-		err error
-	}
-	srchan := make(chan sr)
-	go func() {
-		res, err := f()
-		srchan <- sr{res, err}
-	}()
-	select {
-	case r := <-srchan:
-		res = r.res
-		err = r.err
-	case <-cctx.Done():
-		err = derrors.ScanModuleMemoryLimitExceeded
-	}
-	return res, monitor.stop(), err
-}
-
 func copyFromGCSToWriter(ctx context.Context, w io.Writer, bucket *storage.BucketHandle, srcPath string) error {
 	gcsReader, err := bucket.Object(srcPath).NewReader(ctx)
 	if err != nil {
@@ -578,22 +473,6 @@
 		strings.Contains(s, "connection")
 }
 
-// currHeapUsage computes currently allocate heap bytes.
-func currHeapUsage() uint64 {
-	var stats runtime.MemStats
-	runtime.ReadMemStats(&stats)
-	return stats.Alloc
-}
-
-// memSubtract subtracts memory usage m2 from m1, returning
-// 0 if the result is negative.
-func memSubtract(m1, m2 uint64) uint64 {
-	if m1 <= m2 {
-		return 0
-	}
-	return m1 - m2
-}
-
 // parseGoMemLimit parses the GOMEMLIMIT environment variable.
 // It returns 0 if the variable isn't set or its value is malformed.
 func parseGoMemLimit(s string) uint64 {
diff --git a/internal/worker/vulncheck_scan_test.go b/internal/worker/vulncheck_scan_test.go
index 4e8caaa..907a5e0 100644
--- a/internal/worker/vulncheck_scan_test.go
+++ b/internal/worker/vulncheck_scan_test.go
@@ -6,19 +6,20 @@
 
 import (
 	"context"
-	"encoding/json"
 	"errors"
 	"flag"
+	"fmt"
 	"io"
+	"strings"
 	"testing"
 
 	"cloud.google.com/go/storage"
-	"github.com/google/go-cmp/cmp"
+	"golang.org/x/pkgsite-metrics/internal/bigquery"
 	"golang.org/x/pkgsite-metrics/internal/config"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
 	"golang.org/x/pkgsite-metrics/internal/proxy"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 	vulnc "golang.org/x/vuln/client"
-	"golang.org/x/vuln/vulncheck"
 )
 
 var integration = flag.Bool("integration", false, "test against actual service")
@@ -33,6 +34,39 @@
 	check(scanError{io.EOF}, true)
 }
 
+func TestVulnsForMode(t *testing.T) {
+	vulns := []*ivulncheck.Vuln{
+		&ivulncheck.Vuln{Symbol: "A", CallSink: bigquery.NullInt(0)},
+		&ivulncheck.Vuln{Symbol: "B"},
+		&ivulncheck.Vuln{Symbol: "C", CallSink: bigquery.NullInt(9)},
+	}
+
+	vulnsStr := func(vulns []*ivulncheck.Vuln) string {
+		var vs []string
+		for _, v := range vulns {
+			vs = append(vs, fmt.Sprintf("%s:%d", v.Symbol, v.CallSink.Int64))
+		}
+		return strings.Join(vs, ", ")
+	}
+
+	for _, tc := range []struct {
+		mode string
+		want string
+	}{
+		{modeImports, "A:0, B:0, C:0"},
+		{ModeGovulncheck, "C:9"},
+		{ModeBinary, "A:0, B:0, C:9"},
+	} {
+		tc := tc
+		t.Run(tc.mode, func(t *testing.T) {
+			modeVulns := vulnsForMode(vulns, tc.mode)
+			if got := vulnsStr(modeVulns); got != tc.want {
+				t.Errorf("got %s; want %s", got, tc.want)
+			}
+		})
+	}
+}
+
 func TestRunScanModule(t *testing.T) {
 	t.Skip("breaks on trybots")
 
@@ -72,9 +106,6 @@
 		if got := stats.scanMemory; got <= 0 {
 			t.Errorf("scan memory not collected or negative: %v", got)
 		}
-		if got := stats.pkgsMemory; got <= 0 {
-			t.Errorf("pkgs memory not collected or negative: %v", got)
-		}
 	})
 	t.Run("memoryLimit", func(t *testing.T) {
 		s := &scanner{proxyClient: proxyClient, dbClient: dbClient, insecure: true, goMemLimit: 2000}
@@ -147,24 +178,3 @@
 		}
 	}
 }
-
-func TestUnmarshalVulncheckOutput(t *testing.T) {
-	_, err := unmarshalVulncheckOutput([]byte(`{"Error": "bad"}`))
-	if got, want := err.Error(), "bad"; got != want {
-		t.Errorf("got %q, want %q", got, want)
-	}
-	want := &vulncheck.Result{
-		Modules: []*vulncheck.Module{{Path: "m", Version: "v1.2.3"}},
-	}
-	in, err := json.Marshal(want)
-	if err != nil {
-		t.Fatal(err)
-	}
-	got, err := unmarshalVulncheckOutput(in)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if !cmp.Equal(got, want) {
-		t.Errorf("got %+v, want %+v", got, want)
-	}
-}