internal/vulncheck: vulncheck data structures

This CL follows the
https://go-review.git.corp.google.com/c/pkgsite-metrics/+/472657.

There are many modifications, but they are just pushing things around.

This will also help when simplifying vulncheck support in the future.

Change-Id: I3a58d4de8f4ee459ee708f077a45c829217153ab
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/473835
TryBot-Result: Gopher Robot <gobot@golang.org>
Run-TryBot: Zvonimir Pavlinovic <zpavlinovic@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
diff --git a/internal/bigquery/bigquery.go b/internal/bigquery/bigquery.go
index e02d269..c51248b 100644
--- a/internal/bigquery/bigquery.go
+++ b/internal/bigquery/bigquery.go
@@ -91,6 +91,11 @@
 	return gerr.Code == code
 }
 
+// Dataset returns the underlying client dataset.
+func (c *Client) Dataset() *bq.Dataset {
+	return c.dataset
+}
+
 // Table returns a handle for the given tableID in the client's dataset.
 func (c *Client) Table(tableID string) *bq.Table {
 	return c.dataset.Table(tableID)
@@ -106,7 +111,7 @@
 // CreateTable creates a table with the given name if it doesn't exist.
 func (c *Client) CreateTable(ctx context.Context, tableID string) (err error) {
 	defer derrors.Wrap(&err, "CreateTable(%q)", tableID)
-	schema := tableSchema(tableID)
+	schema := TableSchema(tableID)
 	if schema == nil {
 		return fmt.Errorf("no schema registered for table %q", tableID)
 	}
@@ -128,7 +133,7 @@
 		}
 		return true, c.CreateTable(ctx, tableID)
 	}
-	schema := tableSchema(tableID)
+	schema := TableSchema(tableID)
 	if schema == nil {
 		return false, fmt.Errorf("no schema registered for table %q", tableID)
 	}
@@ -243,12 +248,12 @@
 // SchemaVersion computes a relatively short string from a schema, such that
 // different schemas result in different strings with high probability.
 func SchemaVersion(schema bq.Schema) string {
-	hash := sha256.Sum256([]byte(schemaString(schema)))
+	hash := sha256.Sum256([]byte(SchemaString(schema)))
 	return hex.EncodeToString(hash[:])
 }
 
-// schemaString returns a long, human-readable string summarizing schema.
-func schemaString(schema bq.Schema) string {
+// SchemaString returns a long, human-readable string summarizing schema.
+func SchemaString(schema bq.Schema) string {
 	var b strings.Builder
 	for i, field := range schema {
 		if i > 0 {
@@ -263,7 +268,7 @@
 		}
 		b.WriteByte(':')
 		if field.Type == bq.RecordFieldType {
-			fmt.Fprintf(&b, "(%s)", schemaString(field.Schema))
+			fmt.Fprintf(&b, "(%s)", SchemaString(field.Schema))
 		} else {
 			b.WriteString(string(field.Type))
 		}
@@ -282,9 +287,9 @@
 	tables[tableID] = s
 }
 
-// tableSchema returns the schema associated with the given table,
+// TableSchema returns the schema associated with the given table,
 // or nil if there is none.
-func tableSchema(tableID string) bq.Schema {
+func TableSchema(tableID string) bq.Schema {
 	tableMu.Lock()
 	defer tableMu.Unlock()
 	return tables[tableID]
diff --git a/internal/bigquery/bigquery_test.go b/internal/bigquery/bigquery_test.go
index 908ac14..559b45e 100644
--- a/internal/bigquery/bigquery_test.go
+++ b/internal/bigquery/bigquery_test.go
@@ -8,7 +8,6 @@
 	"context"
 	"flag"
 	"fmt"
-	"sort"
 	"testing"
 	"time"
 
@@ -17,8 +16,6 @@
 	"github.com/google/go-cmp/cmp"
 	"github.com/google/go-cmp/cmp/cmpopts"
 	"golang.org/x/exp/slices"
-	"golang.org/x/pkgsite-metrics/internal/version"
-	"google.golang.org/api/iterator"
 )
 
 var integration = flag.Bool("integration", false, "test against actual service")
@@ -48,155 +45,6 @@
 		must(client.dataset.Delete(ctx))
 	}()
 
-	if _, err := client.CreateOrUpdateTable(ctx, VulncheckTableName); err != nil {
-		t.Fatal(err)
-	}
-	defer func() { must(client.Table(VulncheckTableName).Delete(ctx)) }()
-
-	tm := time.Date(2022, 7, 21, 0, 0, 0, 0, time.UTC)
-	row := &VulnResult{
-		ModulePath:  "m",
-		Version:     "v",
-		SortVersion: "sv",
-		ImportedBy:  10,
-		VulncheckWorkVersion: VulncheckWorkVersion{
-			WorkerVersion:      "1",
-			SchemaVersion:      "s",
-			VulnVersion:        "2",
-			VulnDBLastModified: tm,
-		},
-	}
-
-	t.Run("upload", func(t *testing.T) {
-		must(client.Upload(ctx, VulncheckTableName, row))
-		// Round, strip monotonic data and convert to UTC.
-		// Discrepancies of a few microseconds have been seen, so round to seconds
-		// just to be safe.
-		row.CreatedAt = row.CreatedAt.Round(time.Second).UTC()
-		gots, err := readTable[VulnResult](ctx, client.Table(VulncheckTableName), nil)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if g, w := len(gots), 1; g != w {
-			t.Fatalf("got %d, rows, wanted %d", g, w)
-		}
-		got := gots[0]
-		got.CreatedAt = got.CreatedAt.Round(time.Second)
-		if diff := cmp.Diff(row, got); diff != "" {
-			t.Errorf("mismatch (-want, +got):\n%s", diff)
-		}
-	})
-	t.Run("work versions", func(t *testing.T) {
-		wv, err := ReadVulncheckWorkVersions(ctx, client)
-		if err != nil {
-			t.Fatal(err)
-		}
-		wgot := wv[[2]string{"m", "v"}]
-		if wgot == nil {
-			t.Fatal("got nil, wanted work version")
-		}
-		if want := &row.VulncheckWorkVersion; !wgot.Equal(want) {
-			t.Errorf("got %+v, want %+v", wgot, want)
-		}
-
-		if got := wv[[2]string{"m", "v2"}]; got != nil {
-			t.Errorf("got %v; want nil", got)
-		}
-	})
-
-	t.Run("latest", func(t *testing.T) {
-		latestTableID := VulncheckTableName + "-latest"
-		AddTable(latestTableID, tableSchema(VulncheckTableName))
-		must(client.CreateTable(ctx, latestTableID))
-		defer func() { must(client.Table(latestTableID).Delete(ctx)) }()
-
-		var want []*VulnResult
-		// Module "a": same work version, should get the latest module version.
-		a1 := &VulnResult{
-			ModulePath: "a",
-			Version:    "v1.0.0",
-			ScanMode:   "M1",
-			VulncheckWorkVersion: VulncheckWorkVersion{
-				WorkerVersion:      "1",
-				SchemaVersion:      "s",
-				VulnVersion:        "2",
-				VulnDBLastModified: tm,
-			},
-		}
-		a2 := *a1
-		a2.Version = "v1.1.0"
-		want = append(want, &a2)
-
-		// Different scan mode: should get this one too.
-		a3 := a2
-		a3.ScanMode = "M2"
-		want = append(want, &a3)
-
-		// Module "b": same module version, should get the latest work version.
-		b1 := &VulnResult{
-			ModulePath: "b",
-			Version:    "v1.0.0",
-			VulncheckWorkVersion: VulncheckWorkVersion{
-				WorkerVersion:      "1",
-				SchemaVersion:      "s",
-				VulnVersion:        "2",
-				VulnDBLastModified: tm,
-			},
-		}
-		b2 := *b1
-		b2.WorkerVersion = "0"
-		want = append(want, b1)
-
-		vrs := []*VulnResult{
-			a1, &a2, &a3,
-			b1, &b2,
-		}
-		for _, vr := range vrs {
-			vr.SortVersion = version.ForSorting(vr.Version)
-		}
-		must(UploadMany(ctx, client, latestTableID, vrs, 20))
-
-		got, err := fetchVulncheckResults(ctx, client, latestTableID)
-		if err != nil {
-			t.Fatal(err)
-		}
-		sort.Slice(got, func(i, j int) bool { return got[i].ModulePath < got[j].ModulePath })
-		if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(VulnResult{}, "CreatedAt")); diff != "" {
-			t.Errorf("mismatch (-want, +got):\n%s", diff)
-		}
-
-		// Test InsertVulncheckResults
-		reportTableID := latestTableID + "-report"
-		AddTable(reportTableID, tableSchema(VulncheckTableName+"-report"))
-		reportTable := client.dataset.Table(reportTableID)
-		// Table is created by InsertVulncheckResults.
-		defer func() { must(reportTable.Delete(ctx)) }()
-
-		if err := insertVulncheckResults(ctx, client, reportTableID, got, civil.DateOf(time.Now()), false); err != nil {
-			t.Fatal(err)
-		}
-		rgot, err := readTable[ReportVulnResult](ctx, reportTable, func() *ReportVulnResult {
-			return &ReportVulnResult{VulnResult: &VulnResult{}}
-		})
-		if err != nil {
-			t.Fatal(err)
-		}
-		wantDate := civil.DateOf(time.Now())
-		for _, r := range rgot {
-			if r.ReportDate != wantDate {
-				t.Errorf("got %s, want %s", r.ReportDate, wantDate)
-			}
-			if d := time.Minute; time.Since(r.InsertedAt) > d {
-				t.Errorf("inserted at %s, more than %s ago", r.InsertedAt, d)
-			}
-			// Sanity check for VulnResult.
-			if r.ModulePath != "a" && r.ModulePath != "b" {
-				t.Errorf("got %q, want 'a' or 'b'", r.ModulePath)
-			}
-
-		}
-
-	})
 	t.Run("request counts", func(t *testing.T) {
 		date := func(y, m, d int) civil.Date {
 			return civil.Date{Year: y, Month: time.Month(m), Day: d}
@@ -232,26 +80,6 @@
 
 }
 
-func readTable[T any](ctx context.Context, table *bq.Table, newT func() *T) ([]*T, error) {
-	var ts []*T
-	if newT == nil {
-		newT = func() *T { return new(T) }
-	}
-	iter := table.Read(ctx)
-	for {
-		tp := newT()
-		err := iter.Next(tp)
-		if err == iterator.Done {
-			break
-		}
-		if err != nil {
-			return nil, err
-		}
-		ts = append(ts, tp)
-	}
-	return ts, nil
-}
-
 func TestIsNotFoundError(t *testing.T) {
 	if !*integration {
 		t.Skip("missing -integration")
diff --git a/internal/bigquery/vulncheck.go b/internal/bigquery/vulncheck.go
deleted file mode 100644
index 51b58ac..0000000
--- a/internal/bigquery/vulncheck.go
+++ /dev/null
@@ -1,235 +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 bigquery
-
-import (
-	"context"
-	"fmt"
-	"sort"
-	"time"
-
-	bq "cloud.google.com/go/bigquery"
-	"cloud.google.com/go/civil"
-	"golang.org/x/exp/maps"
-	"golang.org/x/pkgsite-metrics/internal/derrors"
-	"golang.org/x/pkgsite-metrics/internal/log"
-	"google.golang.org/api/iterator"
-)
-
-const VulncheckTableName = "vulncheck"
-
-// Note: before modifying VulnResult or Vuln, make sure the change
-// is a valid schema modification.
-// The only supported changes are:
-//   - adding a nullable or repeated column
-//   - dropping a column
-//   - changing a column from required to nullable.
-// See https://cloud.google.com/bigquery/docs/managing-table-schemas for details.
-
-// VulnResult is a row in the BigQuery vulncheck table. It corresponds to a
-// result from the output for vulncheck.Source.
-type VulnResult struct {
-	CreatedAt     time.Time `bigquery:"created_at"`
-	ModulePath    string    `bigquery:"module_path"`
-	Version       string    `bigquery:"version"`
-	Suffix        string    `bigquery:"suffix"`
-	SortVersion   string    `bigquery:"sort_version"`
-	ImportedBy    int       `bigquery:"imported_by"`
-	Error         string    `bigquery:"error"`
-	ErrorCategory string    `bigquery:"error_category"`
-	CommitTime    time.Time `bigquery:"commit_time"`
-	ScanSeconds   float64   `bigquery:"scan_seconds"`
-	ScanMemory    int64     `bigquery:"scan_memory"`
-	PkgsMemory    int64     `bigquery:"pkgs_memory"`
-	ScanMode      string    `bigquery:"scan_mode"`
-	// Workers is the concurrency limit under which a module is
-	// analyzed. Useful for interpreting memory measurements when
-	// there are multiple modules analyzed in the same process.
-	// 0 if no limit is specified, -1 for potential errors.
-	Workers              int     `bigquery:"workers"`
-	VulncheckWorkVersion         // InferSchema flattens embedded fields
-	Vulns                []*Vuln `bigquery:"vulns"`
-}
-
-// VulncheckWorkVersion contains information that can be used to avoid duplicate work.
-// Given two VulncheckWorkVersion values v1 and v2 for the same module path and version,
-// if v1.Equal(v2) then it is not necessary to scan the module.
-type VulncheckWorkVersion struct {
-	// The version of the currently running code. This tracks changes in the
-	// logic of module scanning and processing.
-	WorkerVersion string `bigquery:"worker_version"`
-	// The version of the bigquery schema.
-	SchemaVersion string ` bigquery:"schema_version"`
-	// The version of the golang.org/x/vuln module used by the current module.
-	VulnVersion string `bigquery:"x_vuln_version"`
-	// When the vuln DB was last modified.
-	VulnDBLastModified time.Time `bigquery:"vulndb_last_modified"`
-}
-
-func (v1 *VulncheckWorkVersion) Equal(v2 *VulncheckWorkVersion) bool {
-	if v1 == nil || v2 == nil {
-		return v1 == v2
-	}
-	return v1.WorkerVersion == v2.WorkerVersion &&
-		v1.SchemaVersion == v2.SchemaVersion &&
-		v1.VulnVersion == v2.VulnVersion &&
-		v1.VulnDBLastModified.Equal(v2.VulnDBLastModified)
-}
-
-func (vr *VulnResult) SetUploadTime(t time.Time) { vr.CreatedAt = t }
-
-func (vr *VulnResult) AddError(err error) {
-	if err == nil {
-		return
-	}
-	vr.Error = err.Error()
-	vr.ErrorCategory = derrors.CategorizeError(err)
-}
-
-// Vuln is a record in VulnResult and corresponds to an item in
-// vulncheck.Result.Vulns.
-type Vuln struct {
-	ID          string       `bigquery:"id"`
-	Symbol      string       `bigquery:"symbol"`
-	PackagePath string       `bigquery:"package_path"`
-	ModulePath  string       `bigquery:"module_path"`
-	CallSink    bq.NullInt64 `bigquery:"call_sink"`
-	ImportSink  bq.NullInt64 `bigquery:"import_sink"`
-	RequireSink bq.NullInt64 `bigquery:"require_sink"`
-}
-
-// VulncheckSchemaVersion changes whenever the vulncheck schema changes.
-var VulncheckSchemaVersion string
-
-func init() {
-	s, err := bq.InferSchema(VulnResult{})
-	if err != nil {
-		panic(err)
-	}
-	VulncheckSchemaVersion = SchemaVersion(s)
-	AddTable(VulncheckTableName, s)
-}
-
-// ReadVulncheckWorkVersions reads the most recent WorkVersions in the vulncheck table.
-func ReadVulncheckWorkVersions(ctx context.Context, c *Client) (_ map[[2]string]*VulncheckWorkVersion, err error) {
-	defer derrors.Wrap(&err, "ReadVulncheckWorkVersions")
-	m := map[[2]string]*VulncheckWorkVersion{}
-	query := PartitionQuery(c.FullTableName(VulncheckTableName), "module_path, sort_version", "created_at DESC")
-	iter, err := c.Query(ctx, query)
-	if err != nil {
-		return nil, err
-	}
-	err = ForEachRow(iter, func(r *VulnResult) bool {
-		m[[2]string{r.ModulePath, r.Version}] = &r.VulncheckWorkVersion
-		return true
-	})
-	if err != nil {
-		return nil, err
-	}
-	return m, nil
-}
-
-// The module path along with the four sort columns should uniquely specify a
-// row, because we do not generate a new row for a (module, version) if the
-// other three versions are identical. (There is actually a fourth component of
-// the work version, the schema version. But since it is represented by a struct
-// in the worker code and the worker version captures every change to that code,
-// it cannot change independently of worker_version.)
-const orderByClauses = `
-			vulndb_last_modified DESC, -- latest version of database
-			x_vuln_version DESC,       -- latest version of x/vuln
-			worker_version DESC,       -- latest version of x/pkgsite-metrics
-			sort_version DESC,         -- latest version of module
-			created_at DESC            -- latest insertion time
-`
-
-func FetchVulncheckResults(ctx context.Context, c *Client) (rows []*VulnResult, err error) {
-	return fetchVulncheckResults(ctx, c, VulncheckTableName)
-}
-
-func fetchVulncheckResults(ctx context.Context, c *Client, tableName string) (rows []*VulnResult, err error) {
-	name := c.FullTableName(tableName)
-	query := PartitionQuery(name, "module_path, scan_mode", orderByClauses)
-	log.Infof(ctx, "running latest query on %s", name)
-	iter, err := c.Query(ctx, query)
-	if err != nil {
-		return nil, err
-	}
-	rows, err = All[VulnResult](iter)
-	if err != nil {
-		return nil, err
-	}
-	log.Infof(ctx, "got %d rows", len(rows))
-
-	// Check for duplicate rows.
-	modvers := map[string]int{}
-	for _, r := range rows {
-		modvers[r.ModulePath+"@"+r.Version+" "+r.ScanMode]++
-	}
-	keys := maps.Keys(modvers)
-	sort.Strings(keys)
-	for _, k := range keys {
-		if n := modvers[k]; n > 1 {
-			return nil, fmt.Errorf("%s has %d rows", k, n)
-		}
-	}
-	return rows, nil
-}
-
-type ReportVulnResult struct {
-	*VulnResult
-	ReportDate civil.Date `bigquery:"report_date"` // for reporting (e.g. dashboard)
-	InsertedAt time.Time  `bigquery:"inserted_at"` // to disambiguate if >1 insertion for same date
-}
-
-func init() {
-	s, err := bq.InferSchema(ReportVulnResult{})
-	if err != nil {
-		panic(err)
-	}
-	AddTable(VulncheckTableName+"-report", s)
-}
-
-func InsertVulncheckResults(ctx context.Context, c *Client, results []*VulnResult, date civil.Date, allowDuplicates bool) (err error) {
-	return insertVulncheckResults(ctx, c, VulncheckTableName+"-report", results, date, allowDuplicates)
-}
-
-func insertVulncheckResults(ctx context.Context, c *Client, reportTableName string, results []*VulnResult, date civil.Date, allowDuplicates bool) (err error) {
-	derrors.Wrap(&err, "InsertVulncheckResults(%s)", date)
-	// Create the report table if it doesn't exist.
-	if err := c.CreateTable(ctx, reportTableName); err != nil {
-		return err
-	}
-
-	if !allowDuplicates {
-		query := fmt.Sprintf("SELECT COUNT(*) FROM `%s` WHERE report_date = '%s'",
-			c.FullTableName(reportTableName), date)
-		iter, err := c.Query(ctx, query)
-		if err != nil {
-			return err
-		}
-		var count struct {
-			n int
-		}
-		err = iter.Next(&count)
-		if err != nil && err != iterator.Done {
-			return err
-		}
-		if count.n > 0 {
-			return fmt.Errorf("already have %d rows for %s", count.n, date)
-		}
-	}
-
-	now := time.Now()
-	var rows []ReportVulnResult
-	for _, r := range results {
-		rows = append(rows, ReportVulnResult{VulnResult: r, ReportDate: date, InsertedAt: now})
-	}
-
-	ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) // to avoid retrying forever on permanent errors
-	defer cancel()
-	const chunkSize = 1024 // Chunk rows to a void exceeding the maximum allowable request size.
-	return UploadMany(ctx, c, reportTableName, rows, chunkSize)
-}
diff --git a/internal/bigquery/vulncheck_test.go b/internal/bigquery/vulncheck_test.go
deleted file mode 100644
index 658ca84..0000000
--- a/internal/bigquery/vulncheck_test.go
+++ /dev/null
@@ -1,34 +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 bigquery
-
-import (
-	"testing"
-
-	bq "cloud.google.com/go/bigquery"
-)
-
-func TestSchemaString(t *testing.T) {
-	type nest struct {
-		N []byte
-		M float64
-	}
-
-	type s struct {
-		A string
-		B int
-		C []bool
-		D nest
-	}
-	const want = "A,req:STRING;B,req:INTEGER;C,rep:BOOLEAN;D,req:(N,req:BYTES;M,req:FLOAT)"
-	schema, err := bq.InferSchema(s{})
-	if err != nil {
-		t.Fatal(err)
-	}
-	got := schemaString(schema)
-	if got != want {
-		t.Errorf("\ngot  %q\nwant %q", got, want)
-	}
-}
diff --git a/internal/vulncheck/vulncheck.go b/internal/vulncheck/vulncheck.go
new file mode 100644
index 0000000..1ba9019
--- /dev/null
+++ b/internal/vulncheck/vulncheck.go
@@ -0,0 +1,358 @@
+// 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 vulncheck
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+
+	bq "cloud.google.com/go/bigquery"
+	"cloud.google.com/go/civil"
+	"golang.org/x/exp/maps"
+	"golang.org/x/pkgsite-metrics/internal/bigquery"
+	"golang.org/x/pkgsite-metrics/internal/derrors"
+	"golang.org/x/pkgsite-metrics/internal/log"
+	"golang.org/x/pkgsite-metrics/internal/scan"
+	"golang.org/x/vuln/exp/govulncheck"
+	"golang.org/x/vuln/vulncheck"
+	"google.golang.org/api/iterator"
+)
+
+// EnqueueQueryParams for vulncheck/enqueue
+type EnqueueQueryParams struct {
+	Suffix string // appended to task queue IDs to generate unique tasks
+	Mode   string // type of analysis to run
+	Min    int    // minimum import-by count for a module to be included
+	File   string // path to file containing modules; if missing, use DB
+}
+
+// Request contains information passed
+// to a scan endpoint.
+type Request struct {
+	scan.ModuleURLPath
+	QueryParams
+}
+
+// QueryParams has query parameters for a vulncheck scan request.
+type QueryParams struct {
+	ImportedBy int    // imported-by count
+	Mode       string // vulncheck mode (VTA, etc)
+	Insecure   bool   // if true, run outside sandbox
+	Serve      bool   // serve results back to client instead of writing them to BigQuery
+}
+
+// These methods implement queue.Task.
+func (r *Request) Name() string { return r.Module + "@" + r.Version }
+
+func (r *Request) Path() string { return r.ModuleURLPath.Path() }
+
+func (r *Request) Params() string {
+	return scan.FormatParams(r.QueryParams)
+}
+
+// ParseRequest parses an http request r for an endpoint
+// prefix and produces a corresponding ScanRequest.
+//
+// The module and version should have one of the following three forms:
+//   - <module>/@v/<version>
+//   - <module>@<version>
+//   - <module>/@latest
+//
+// (These are the same forms that the module proxy accepts.)
+func ParseRequest(r *http.Request, prefix string) (*Request, error) {
+	mp, err := scan.ParseModuleURLPath(strings.TrimPrefix(r.URL.Path, prefix))
+	if err != nil {
+		return nil, err
+	}
+
+	rp := QueryParams{ImportedBy: -1}
+	if err := scan.ParseParams(r, &rp); err != nil {
+		return nil, err
+	}
+	if rp.ImportedBy < 0 {
+		return nil, errors.New(`missing or negative "importedby" query param`)
+	}
+	return &Request{
+		ModuleURLPath: mp,
+		QueryParams:   rp,
+	}, nil
+}
+
+func ConvertVulncheckOutput(v *vulncheck.Vuln) *Vuln {
+	return &Vuln{
+		ID:          v.OSV.ID,
+		ModulePath:  v.ModPath,
+		PackagePath: v.PkgPath,
+		Symbol:      v.Symbol,
+		CallSink:    bigquery.NullInt(v.CallSink),
+		ImportSink:  bigquery.NullInt(v.ImportSink),
+		RequireSink: bigquery.NullInt(v.RequireSink),
+	}
+}
+
+func ConvertGovulncheckOutput(v *govulncheck.Vuln) (vulns []*Vuln) {
+	for _, module := range v.Modules {
+		for pkgNum, pkg := range module.Packages {
+			addedSymbols := make(map[string]bool)
+			baseVuln := &Vuln{
+				ID:          v.OSV.ID,
+				ModulePath:  module.Path,
+				PackagePath: pkg.Path,
+				CallSink:    bigquery.NullInt(0),
+				ImportSink:  bigquery.NullInt(pkgNum + 1),
+				RequireSink: bigquery.NullInt(pkgNum + 1),
+			}
+
+			// For each called symbol, reconstruct sinks and create the corresponding bigquery vuln
+			for symbolNum, cs := range pkg.CallStacks {
+				addedSymbols[cs.Symbol] = true
+				toAdd := *baseVuln
+				toAdd.Symbol = cs.Symbol
+				toAdd.CallSink = bigquery.NullInt(symbolNum + 1)
+				vulns = append(vulns, &toAdd)
+			}
+
+			// Find the rest of the vulnerable imported symbols that haven't been called
+			// and create corresponding bigquery vulns
+			for _, affected := range v.OSV.Affected {
+				if affected.Package.Name == module.Path {
+					for _, imp := range affected.EcosystemSpecific.Imports {
+						if imp.Path == pkg.Path {
+							for _, symbol := range imp.Symbols {
+								if !addedSymbols[symbol] {
+									toAdd := *baseVuln
+									toAdd.Symbol = symbol
+									vulns = append(vulns, &toAdd)
+								}
+							}
+						}
+					}
+				}
+			}
+		}
+	}
+	return vulns
+}
+
+const TableName = "vulncheck"
+
+// Note: before modifying Result or Vuln, make sure the change
+// is a valid schema modification.
+// The only supported changes are:
+//   - adding a nullable or repeated column
+//   - dropping a column
+//   - changing a column from required to nullable.
+// See https://cloud.google.com/bigquery/docs/managing-table-schemas for details.
+
+// Result is a row in the BigQuery vulncheck table. It corresponds to a
+// result from the output for vulncheck.Source.
+type Result struct {
+	CreatedAt     time.Time `bigquery:"created_at"`
+	ModulePath    string    `bigquery:"module_path"`
+	Version       string    `bigquery:"version"`
+	Suffix        string    `bigquery:"suffix"`
+	SortVersion   string    `bigquery:"sort_version"`
+	ImportedBy    int       `bigquery:"imported_by"`
+	Error         string    `bigquery:"error"`
+	ErrorCategory string    `bigquery:"error_category"`
+	CommitTime    time.Time `bigquery:"commit_time"`
+	ScanSeconds   float64   `bigquery:"scan_seconds"`
+	ScanMemory    int64     `bigquery:"scan_memory"`
+	PkgsMemory    int64     `bigquery:"pkgs_memory"`
+	ScanMode      string    `bigquery:"scan_mode"`
+	// Workers is the concurrency limit under which a module is
+	// analyzed. Useful for interpreting memory measurements when
+	// there are multiple modules analyzed in the same process.
+	// 0 if no limit is specified, -1 for potential errors.
+	Workers     int     `bigquery:"workers"`
+	WorkVersion         // InferSchema flattens embedded fields
+	Vulns       []*Vuln `bigquery:"vulns"`
+}
+
+// WorkVersion contains information that can be used to avoid duplicate work.
+// Given two WorkVersion values v1 and v2 for the same module path and version,
+// if v1.Equal(v2) then it is not necessary to scan the module.
+type WorkVersion struct {
+	// The version of the currently running code. This tracks changes in the
+	// logic of module scanning and processing.
+	WorkerVersion string `bigquery:"worker_version"`
+	// The version of the bigquery schema.
+	SchemaVersion string ` bigquery:"schema_version"`
+	// The version of the golang.org/x/vuln module used by the current module.
+	VulnVersion string `bigquery:"x_vuln_version"`
+	// When the vuln DB was last modified.
+	VulnDBLastModified time.Time `bigquery:"vulndb_last_modified"`
+}
+
+func (v1 *WorkVersion) Equal(v2 *WorkVersion) bool {
+	if v1 == nil || v2 == nil {
+		return v1 == v2
+	}
+	return v1.WorkerVersion == v2.WorkerVersion &&
+		v1.SchemaVersion == v2.SchemaVersion &&
+		v1.VulnVersion == v2.VulnVersion &&
+		v1.VulnDBLastModified.Equal(v2.VulnDBLastModified)
+}
+
+func (vr *Result) SetUploadTime(t time.Time) { vr.CreatedAt = t }
+
+func (vr *Result) AddError(err error) {
+	if err == nil {
+		return
+	}
+	vr.Error = err.Error()
+	vr.ErrorCategory = derrors.CategorizeError(err)
+}
+
+// Vuln is a record in Result and corresponds to an item in
+// vulncheck.Result.Vulns.
+type Vuln struct {
+	ID          string       `bigquery:"id"`
+	Symbol      string       `bigquery:"symbol"`
+	PackagePath string       `bigquery:"package_path"`
+	ModulePath  string       `bigquery:"module_path"`
+	CallSink    bq.NullInt64 `bigquery:"call_sink"`
+	ImportSink  bq.NullInt64 `bigquery:"import_sink"`
+	RequireSink bq.NullInt64 `bigquery:"require_sink"`
+}
+
+// SchemaVersion changes whenever the vulncheck schema changes.
+var SchemaVersion string
+
+func init() {
+	s, err := bigquery.InferSchema(Result{})
+	if err != nil {
+		panic(err)
+	}
+	SchemaVersion = bigquery.SchemaVersion(s)
+	bigquery.AddTable(TableName, s)
+}
+
+// ReadWorkVersions reads the most recent WorkVersions in the vulncheck table.
+func ReadWorkVersions(ctx context.Context, c *bigquery.Client) (_ map[[2]string]*WorkVersion, err error) {
+	defer derrors.Wrap(&err, "ReadWorkVersions")
+	m := map[[2]string]*WorkVersion{}
+	query := bigquery.PartitionQuery(c.FullTableName(TableName), "module_path, sort_version", "created_at DESC")
+	iter, err := c.Query(ctx, query)
+	if err != nil {
+		return nil, err
+	}
+	err = bigquery.ForEachRow(iter, func(r *Result) bool {
+		m[[2]string{r.ModulePath, r.Version}] = &r.WorkVersion
+		return true
+	})
+	if err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+// The module path along with the four sort columns should uniquely specify a
+// row, because we do not generate a new row for a (module, version) if the
+// other three versions are identical. (There is actually a fourth component of
+// the work version, the schema version. But since it is represented by a struct
+// in the worker code and the worker version captures every change to that code,
+// it cannot change independently of worker_version.)
+const orderByClauses = `
+			vulndb_last_modified DESC, -- latest version of database
+			x_vuln_version DESC,       -- latest version of x/vuln
+			worker_version DESC,       -- latest version of x/pkgsite-metrics
+			sort_version DESC,         -- latest version of module
+			created_at DESC            -- latest insertion time
+`
+
+func FetchResults(ctx context.Context, c *bigquery.Client) (rows []*Result, err error) {
+	return fetchResults(ctx, c, TableName)
+}
+
+func fetchResults(ctx context.Context, c *bigquery.Client, tableName string) (rows []*Result, err error) {
+	name := c.FullTableName(tableName)
+	query := bigquery.PartitionQuery(name, "module_path, scan_mode", orderByClauses)
+	log.Infof(ctx, "running latest query on %s", name)
+	iter, err := c.Query(ctx, query)
+	if err != nil {
+		return nil, err
+	}
+	rows, err = bigquery.All[Result](iter)
+	if err != nil {
+		return nil, err
+	}
+	log.Infof(ctx, "got %d rows", len(rows))
+
+	// Check for duplicate rows.
+	modvers := map[string]int{}
+	for _, r := range rows {
+		modvers[r.ModulePath+"@"+r.Version+" "+r.ScanMode]++
+	}
+	keys := maps.Keys(modvers)
+	sort.Strings(keys)
+	for _, k := range keys {
+		if n := modvers[k]; n > 1 {
+			return nil, fmt.Errorf("%s has %d rows", k, n)
+		}
+	}
+	return rows, nil
+}
+
+type ReportResult struct {
+	*Result
+	ReportDate civil.Date `bigquery:"report_date"` // for reporting (e.g. dashboard)
+	InsertedAt time.Time  `bigquery:"inserted_at"` // to disambiguate if >1 insertion for same date
+}
+
+func init() {
+	s, err := bigquery.InferSchema(ReportResult{})
+	if err != nil {
+		panic(err)
+	}
+	bigquery.AddTable(TableName+"-report", s)
+}
+
+func InsertResults(ctx context.Context, c *bigquery.Client, results []*Result, date civil.Date, allowDuplicates bool) (err error) {
+	return insertResults(ctx, c, TableName+"-report", results, date, allowDuplicates)
+}
+
+func insertResults(ctx context.Context, c *bigquery.Client, reportTableName string, results []*Result, date civil.Date, allowDuplicates bool) (err error) {
+	derrors.Wrap(&err, "InsertResults(%s)", date)
+	// Create the report table if it doesn't exist.
+	if err := c.CreateTable(ctx, reportTableName); err != nil {
+		return err
+	}
+
+	if !allowDuplicates {
+		query := fmt.Sprintf("SELECT COUNT(*) FROM `%s` WHERE report_date = '%s'",
+			c.FullTableName(reportTableName), date)
+		iter, err := c.Query(ctx, query)
+		if err != nil {
+			return err
+		}
+		var count struct {
+			n int
+		}
+		err = iter.Next(&count)
+		if err != nil && err != iterator.Done {
+			return err
+		}
+		if count.n > 0 {
+			return fmt.Errorf("already have %d rows for %s", count.n, date)
+		}
+	}
+
+	now := time.Now()
+	var rows []ReportResult
+	for _, r := range results {
+		rows = append(rows, ReportResult{Result: r, ReportDate: date, InsertedAt: now})
+	}
+
+	ctx, cancel := context.WithTimeout(ctx, 5*time.Minute) // to avoid retrying forever on permanent errors
+	defer cancel()
+	const chunkSize = 1024 // Chunk rows to a void exceeding the maximum allowable request size.
+	return bigquery.UploadMany(ctx, c, reportTableName, rows, chunkSize)
+}
diff --git a/internal/vulncheck/vulncheck_test.go b/internal/vulncheck/vulncheck_test.go
new file mode 100644
index 0000000..145d23a
--- /dev/null
+++ b/internal/vulncheck/vulncheck_test.go
@@ -0,0 +1,368 @@
+// 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 vulncheck
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"sort"
+	"testing"
+	"time"
+
+	bq "cloud.google.com/go/bigquery"
+	"cloud.google.com/go/civil"
+	"github.com/google/go-cmp/cmp"
+	"github.com/google/go-cmp/cmp/cmpopts"
+	"golang.org/x/pkgsite-metrics/internal/bigquery"
+	"golang.org/x/pkgsite-metrics/internal/version"
+	"golang.org/x/vuln/exp/govulncheck"
+	"golang.org/x/vuln/osv"
+	"google.golang.org/api/iterator"
+)
+
+func TestConvertGovulncheckOutput(t *testing.T) {
+	var (
+		osvEntry = &osv.Entry{
+			ID: "GO-YYYY-1234",
+			Affected: []osv.Affected{
+				{
+					Package: osv.Package{
+						Name:      "example.com/repo/module",
+						Ecosystem: "Go",
+					},
+					EcosystemSpecific: osv.EcosystemSpecific{
+						Imports: []osv.EcosystemSpecificImport{
+							{
+								Path: "example.com/repo/module/package",
+								Symbols: []string{
+									"Symbol",
+									"Another",
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		vuln1 = &govulncheck.Vuln{
+			OSV: osvEntry,
+			Modules: []*govulncheck.Module{
+				{
+					Path: "example.com/repo/module",
+					Packages: []*govulncheck.Package{
+						{
+							Path: "example.com/repo/module/package",
+							CallStacks: []govulncheck.CallStack{
+								{
+									Symbol:  "Symbol",
+									Summary: "example.go:1:1 xyz.func calls pkgPath.Symbol",
+									Frames:  []*govulncheck.StackFrame{},
+								},
+							},
+						},
+					},
+				},
+			},
+		}
+
+		vuln2 = &govulncheck.Vuln{
+			OSV: osvEntry,
+			Modules: []*govulncheck.Module{
+				{
+					Path: "example.com/repo/module",
+					Packages: []*govulncheck.Package{
+						{
+							Path: "example.com/repo/module/package",
+						},
+					},
+				},
+			},
+		}
+	)
+	tests := []struct {
+		name      string
+		vuln      *govulncheck.Vuln
+		wantVulns []*Vuln
+	}{
+		{
+			name: "Call One Symbol",
+			vuln: vuln1,
+			wantVulns: []*Vuln{
+				{
+					ID:          "GO-YYYY-1234",
+					Symbol:      "Symbol",
+					PackagePath: "example.com/repo/module/package",
+					ModulePath:  "example.com/repo/module",
+					CallSink:    bigquery.NullInt(1),
+					ImportSink:  bigquery.NullInt(1),
+					RequireSink: bigquery.NullInt(1),
+				},
+				{
+					ID:          "GO-YYYY-1234",
+					Symbol:      "Another",
+					PackagePath: "example.com/repo/module/package",
+					ModulePath:  "example.com/repo/module",
+					CallSink:    bigquery.NullInt(0),
+					ImportSink:  bigquery.NullInt(1),
+					RequireSink: bigquery.NullInt(1),
+				},
+			},
+		},
+		{
+			name: "Call no symbols",
+			vuln: vuln2,
+			wantVulns: []*Vuln{
+				{
+					ID:          "GO-YYYY-1234",
+					PackagePath: "example.com/repo/module/package",
+					ModulePath:  "example.com/repo/module",
+					Symbol:      "Symbol",
+					CallSink:    bigquery.NullInt(0),
+					ImportSink:  bigquery.NullInt(1),
+					RequireSink: bigquery.NullInt(1),
+				},
+				{
+					ID:          "GO-YYYY-1234",
+					PackagePath: "example.com/repo/module/package",
+					ModulePath:  "example.com/repo/module",
+					Symbol:      "Another",
+					CallSink:    bigquery.NullInt(0),
+					ImportSink:  bigquery.NullInt(1),
+					RequireSink: bigquery.NullInt(1),
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if diff := cmp.Diff(ConvertGovulncheckOutput(tt.vuln), tt.wantVulns, cmpopts.EquateEmpty()); diff != "" {
+				t.Errorf("mismatch (-got, +want): %s", diff)
+			}
+		})
+	}
+}
+
+func TestSchemaString(t *testing.T) {
+	type nest struct {
+		N []byte
+		M float64
+	}
+
+	type s struct {
+		A string
+		B int
+		C []bool
+		D nest
+	}
+	const want = "A,req:STRING;B,req:INTEGER;C,rep:BOOLEAN;D,req:(N,req:BYTES;M,req:FLOAT)"
+	schema, err := bigquery.InferSchema(s{})
+	if err != nil {
+		t.Fatal(err)
+	}
+	got := bigquery.SchemaString(schema)
+	if got != want {
+		t.Errorf("\ngot  %q\nwant %q", got, want)
+	}
+}
+
+var integration = flag.Bool("integration", false, "test against actual service")
+
+func TestIntegration(t *testing.T) {
+	must := func(err error) {
+		t.Helper()
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	if !*integration {
+		t.Skip("missing -integration")
+	}
+	ctx := context.Background()
+	const projectID = "go-ecosystem"
+
+	// Create a new dataset ID to avoid problems with re-using existing tables.
+	dsID := fmt.Sprintf("test_%s", time.Now().Format("20060102T030405"))
+	t.Logf("using dataset %s", dsID)
+	client, err := bigquery.NewClientCreate(ctx, projectID, dsID)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		must(client.Dataset().Delete(ctx))
+	}()
+
+	if _, err := client.CreateOrUpdateTable(ctx, TableName); err != nil {
+		t.Fatal(err)
+	}
+	defer func() { must(client.Table(TableName).Delete(ctx)) }()
+
+	tm := time.Date(2022, 7, 21, 0, 0, 0, 0, time.UTC)
+	row := &Result{
+		ModulePath:  "m",
+		Version:     "v",
+		SortVersion: "sv",
+		ImportedBy:  10,
+		WorkVersion: WorkVersion{
+			WorkerVersion:      "1",
+			SchemaVersion:      "s",
+			VulnVersion:        "2",
+			VulnDBLastModified: tm,
+		},
+	}
+
+	t.Run("upload", func(t *testing.T) {
+		must(client.Upload(ctx, TableName, row))
+		// Round, strip monotonic data and convert to UTC.
+		// Discrepancies of a few microseconds have been seen, so round to seconds
+		// just to be safe.
+		row.CreatedAt = row.CreatedAt.Round(time.Second).UTC()
+		gots, err := readTable[Result](ctx, client.Table(TableName), nil)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if g, w := len(gots), 1; g != w {
+			t.Fatalf("got %d, rows, wanted %d", g, w)
+		}
+		got := gots[0]
+		got.CreatedAt = got.CreatedAt.Round(time.Second)
+		if diff := cmp.Diff(row, got); diff != "" {
+			t.Errorf("mismatch (-want, +got):\n%s", diff)
+		}
+	})
+	t.Run("work versions", func(t *testing.T) {
+		wv, err := ReadWorkVersions(ctx, client)
+		if err != nil {
+			t.Fatal(err)
+		}
+		wgot := wv[[2]string{"m", "v"}]
+		if wgot == nil {
+			t.Fatal("got nil, wanted work version")
+		}
+		if want := &row.WorkVersion; !wgot.Equal(want) {
+			t.Errorf("got %+v, want %+v", wgot, want)
+		}
+
+		if got := wv[[2]string{"m", "v2"}]; got != nil {
+			t.Errorf("got %v; want nil", got)
+		}
+	})
+
+	t.Run("latest", func(t *testing.T) {
+		latestTableID := TableName + "-latest"
+		bigquery.AddTable(latestTableID, bigquery.TableSchema(TableName))
+		must(client.CreateTable(ctx, latestTableID))
+		defer func() { must(client.Table(latestTableID).Delete(ctx)) }()
+
+		var want []*Result
+		// Module "a": same work version, should get the latest module version.
+		a1 := &Result{
+			ModulePath: "a",
+			Version:    "v1.0.0",
+			ScanMode:   "M1",
+			WorkVersion: WorkVersion{
+				WorkerVersion:      "1",
+				SchemaVersion:      "s",
+				VulnVersion:        "2",
+				VulnDBLastModified: tm,
+			},
+		}
+		a2 := *a1
+		a2.Version = "v1.1.0"
+		want = append(want, &a2)
+
+		// Different scan mode: should get this one too.
+		a3 := a2
+		a3.ScanMode = "M2"
+		want = append(want, &a3)
+
+		// Module "b": same module version, should get the latest work version.
+		b1 := &Result{
+			ModulePath: "b",
+			Version:    "v1.0.0",
+			WorkVersion: WorkVersion{
+				WorkerVersion:      "1",
+				SchemaVersion:      "s",
+				VulnVersion:        "2",
+				VulnDBLastModified: tm,
+			},
+		}
+		b2 := *b1
+		b2.WorkerVersion = "0"
+		want = append(want, b1)
+
+		vrs := []*Result{
+			a1, &a2, &a3,
+			b1, &b2,
+		}
+		for _, vr := range vrs {
+			vr.SortVersion = version.ForSorting(vr.Version)
+		}
+		must(bigquery.UploadMany(ctx, client, latestTableID, vrs, 20))
+
+		got, err := fetchResults(ctx, client, latestTableID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		sort.Slice(got, func(i, j int) bool { return got[i].ModulePath < got[j].ModulePath })
+		if diff := cmp.Diff(want, got, cmpopts.IgnoreFields(Result{}, "CreatedAt")); diff != "" {
+			t.Errorf("mismatch (-want, +got):\n%s", diff)
+		}
+
+		// Test InsertVulncheckResults
+		reportTableID := latestTableID + "-report"
+		bigquery.AddTable(reportTableID, bigquery.TableSchema(TableName+"-report"))
+		reportTable := client.Dataset().Table(reportTableID)
+		// Table is created by InsertVulncheckResults.
+		defer func() { must(reportTable.Delete(ctx)) }()
+
+		if err := insertResults(ctx, client, reportTableID, got, civil.DateOf(time.Now()), false); err != nil {
+			t.Fatal(err)
+		}
+		rgot, err := readTable[ReportResult](ctx, reportTable, func() *ReportResult {
+			return &ReportResult{Result: &Result{}}
+		})
+		if err != nil {
+			t.Fatal(err)
+		}
+		wantDate := civil.DateOf(time.Now())
+		for _, r := range rgot {
+			if r.ReportDate != wantDate {
+				t.Errorf("got %s, want %s", r.ReportDate, wantDate)
+			}
+			if d := time.Minute; time.Since(r.InsertedAt) > d {
+				t.Errorf("inserted at %s, more than %s ago", r.InsertedAt, d)
+			}
+			// Sanity check for Result.
+			if r.ModulePath != "a" && r.ModulePath != "b" {
+				t.Errorf("got %q, want 'a' or 'b'", r.ModulePath)
+			}
+
+		}
+
+	})
+}
+
+func readTable[T any](ctx context.Context, table *bq.Table, newT func() *T) ([]*T, error) {
+	var ts []*T
+	if newT == nil {
+		newT = func() *T { return new(T) }
+	}
+	iter := table.Read(ctx)
+	for {
+		tp := newT()
+		err := iter.Next(tp)
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return nil, err
+		}
+		ts = append(ts, tp)
+	}
+	return ts, nil
+}
diff --git a/internal/worker/enqueue.go b/internal/worker/enqueue.go
index bc636f0..9471fc4 100644
--- a/internal/worker/enqueue.go
+++ b/internal/worker/enqueue.go
@@ -14,6 +14,7 @@
 	"golang.org/x/pkgsite-metrics/internal/pkgsitedb"
 	"golang.org/x/pkgsite-metrics/internal/queue"
 	"golang.org/x/pkgsite-metrics/internal/scan"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 )
 
 const defaultMinImportedByCount = 10
@@ -72,15 +73,15 @@
 	return nil
 }
 
-func moduleSpecsToScanRequests(modspecs []scan.ModuleSpec, mode string) []*vulncheckRequest {
-	var sreqs []*vulncheckRequest
+func moduleSpecsToScanRequests(modspecs []scan.ModuleSpec, mode string) []*ivulncheck.Request {
+	var sreqs []*ivulncheck.Request
 	for _, ms := range modspecs {
-		sreqs = append(sreqs, &vulncheckRequest{
-			scan.ModuleURLPath{
+		sreqs = append(sreqs, &ivulncheck.Request{
+			ModuleURLPath: scan.ModuleURLPath{
 				Module:  ms.Path,
 				Version: ms.Version,
 			},
-			vulncheckRequestParams{
+			QueryParams: ivulncheck.QueryParams{
 				ImportedBy: ms.ImportedBy,
 				Mode:       mode,
 			},
diff --git a/internal/worker/vulncheck.go b/internal/worker/vulncheck.go
index 92b860a..a91926d 100644
--- a/internal/worker/vulncheck.go
+++ b/internal/worker/vulncheck.go
@@ -11,24 +11,24 @@
 	"net/http"
 	"runtime/debug"
 
-	"golang.org/x/pkgsite-metrics/internal/bigquery"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
 	"golang.org/x/pkgsite-metrics/internal/log"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 )
 
 type VulncheckServer struct {
 	*Server
-	storedWorkVersions map[[2]string]*bigquery.VulncheckWorkVersion
-	workVersion        *bigquery.VulncheckWorkVersion
+	storedWorkVersions map[[2]string]*ivulncheck.WorkVersion
+	workVersion        *ivulncheck.WorkVersion
 }
 
 func newVulncheckServer(ctx context.Context, s *Server) (*VulncheckServer, error) {
 	var (
-		swv map[[2]string]*bigquery.VulncheckWorkVersion
+		swv map[[2]string]*ivulncheck.WorkVersion
 		err error
 	)
 	if s.bqClient != nil {
-		swv, err = bigquery.ReadVulncheckWorkVersions(ctx, s.bqClient)
+		swv, err = ivulncheck.ReadWorkVersions(ctx, s.bqClient)
 		if err != nil {
 			return nil, err
 		}
@@ -40,7 +40,7 @@
 	}, nil
 }
 
-func (h *VulncheckServer) getWorkVersion(ctx context.Context) (_ *bigquery.VulncheckWorkVersion, err error) {
+func (h *VulncheckServer) getWorkVersion(ctx context.Context) (_ *ivulncheck.WorkVersion, err error) {
 	defer derrors.Wrap(&err, "VulncheckServer.getWorkVersion")
 	h.mu.Lock()
 	defer h.mu.Unlock()
@@ -54,10 +54,10 @@
 		if err != nil {
 			return nil, err
 		}
-		h.workVersion = &bigquery.VulncheckWorkVersion{
+		h.workVersion = &ivulncheck.WorkVersion{
 			VulnDBLastModified: lmt,
 			WorkerVersion:      h.cfg.VersionID,
-			SchemaVersion:      bigquery.VulncheckSchemaVersion,
+			SchemaVersion:      ivulncheck.SchemaVersion,
 			VulnVersion:        vulnVersion,
 		}
 		log.Infof(ctx, "vulncheck work version: %+v", h.workVersion)
@@ -101,11 +101,11 @@
 	if h.bqClient == nil {
 		return nil, errBQDisabled
 	}
-	table := h.bqClient.FullTableName(bigquery.VulncheckTableName)
+	table := h.bqClient.FullTableName(ivulncheck.TableName)
 	page := newPage(table)
 	page.basePage = newBasePage()
 
-	rows, err := bigquery.FetchVulncheckResults(ctx, h.bqClient)
+	rows, err := ivulncheck.FetchResults(ctx, h.bqClient)
 	if err != nil {
 		return nil, err
 	}
@@ -197,7 +197,7 @@
 	return (float64(v.NumModulesNoVuln()) / float64(v.NumModulesSuccess)) * 100
 }
 
-func (r *VulncheckResult) update(row *bigquery.VulnResult) {
+func (r *VulncheckResult) update(row *ivulncheck.Result) {
 	r.NumModulesScanned++
 	if row.Error != "" {
 		r.NumModulesError++
@@ -243,7 +243,7 @@
 
 // handleVulncheckRows populates page based on vulncheck result rows and
 // returns statistics for each vulnerability detected.
-func handleVulncheckRows(ctx context.Context, page *VulncheckPage, rows []*bigquery.VulnResult) map[string]*ReportResult {
+func handleVulncheckRows(ctx context.Context, page *VulncheckPage, rows []*ivulncheck.Result) map[string]*ReportResult {
 	vulnsScanned := map[string]*ReportResult{}
 	for _, row := range rows {
 		switch row.ScanMode {
diff --git a/internal/worker/vulncheck_enqueue.go b/internal/worker/vulncheck_enqueue.go
index 18fafa3..6f80a36 100644
--- a/internal/worker/vulncheck_enqueue.go
+++ b/internal/worker/vulncheck_enqueue.go
@@ -19,17 +19,10 @@
 	"golang.org/x/pkgsite-metrics/internal/log"
 	"golang.org/x/pkgsite-metrics/internal/queue"
 	"golang.org/x/pkgsite-metrics/internal/scan"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 	"google.golang.org/api/iterator"
 )
 
-// query params for vulncheck/enqueue
-type vulncheckEnqueueParams struct {
-	Suffix string // appended to task queue IDs to generate unique tasks
-	Mode   string // type of analysis to run
-	Min    int    // minimum import-by count for a module to be included
-	File   string // path to file containing modules; if missing, use DB
-}
-
 // handleEnqueue enqueues multiple modules for a single vulncheck mode.
 func (h *VulncheckServer) handleEnqueue(w http.ResponseWriter, r *http.Request) error {
 	return h.enqueue(r, false)
@@ -42,7 +35,7 @@
 
 func (h *VulncheckServer) enqueue(r *http.Request, allModes bool) error {
 	ctx := r.Context()
-	params := &vulncheckEnqueueParams{Min: defaultMinImportedByCount}
+	params := &ivulncheck.EnqueueQueryParams{Min: defaultMinImportedByCount}
 	if err := scan.ParseParams(r, params); err != nil {
 		return fmt.Errorf("%w: %v", derrors.InvalidArgument, err)
 	}
@@ -74,14 +67,14 @@
 	return []string{mode}, nil
 }
 
-func createVulncheckQueueTasks(ctx context.Context, cfg *config.Config, params *vulncheckEnqueueParams, modes []string) (_ []queue.Task, err error) {
+func createVulncheckQueueTasks(ctx context.Context, cfg *config.Config, params *ivulncheck.EnqueueQueryParams, modes []string) (_ []queue.Task, err error) {
 	defer derrors.Wrap(&err, "createVulncheckQueueTasks(%v)", modes)
 	var (
 		tasks    []queue.Task
 		modspecs []scan.ModuleSpec
 	)
 	for _, mode := range modes {
-		var reqs []*vulncheckRequest
+		var reqs []*ivulncheck.Request
 		if mode == ModeBinary {
 			reqs, err = readBinaries(ctx, cfg.BinaryBucket)
 			if err != nil {
@@ -120,7 +113,7 @@
 // binaryDir is the directory in the GCS bucket that contains binaries that should be scanned.
 const binaryDir = "binaries"
 
-func readBinaries(ctx context.Context, bucketName string) (reqs []*vulncheckRequest, err error) {
+func readBinaries(ctx context.Context, bucketName string) (reqs []*ivulncheck.Request, err error) {
 	defer derrors.Wrap(&err, "readBinaries(%q)", bucketName)
 	if bucketName == "" {
 		log.Infof(ctx, "binary bucket not configured; not enqueuing binaries")
@@ -143,9 +136,9 @@
 		if err != nil {
 			return nil, err
 		}
-		reqs = append(reqs, &vulncheckRequest{
-			ModuleURLPath:          mp,
-			vulncheckRequestParams: vulncheckRequestParams{Mode: ModeBinary},
+		reqs = append(reqs, &ivulncheck.Request{
+			ModuleURLPath: mp,
+			QueryParams:   ivulncheck.QueryParams{Mode: ModeBinary},
 		})
 	}
 	return reqs, nil
diff --git a/internal/worker/vulncheck_enqueue_test.go b/internal/worker/vulncheck_enqueue_test.go
index d54bc35..befea8b 100644
--- a/internal/worker/vulncheck_enqueue_test.go
+++ b/internal/worker/vulncheck_enqueue_test.go
@@ -14,6 +14,7 @@
 	"golang.org/x/pkgsite-metrics/internal/config"
 	"golang.org/x/pkgsite-metrics/internal/queue"
 	"golang.org/x/pkgsite-metrics/internal/scan"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 )
 
 var binaryBucket = flag.String("binary-bucket", "", "bucket for scannable binaries")
@@ -26,13 +27,13 @@
 	if err != nil {
 		t.Fatal(err)
 	}
-	want := &vulncheckRequest{
-		scan.ModuleURLPath{
+	want := &ivulncheck.Request{
+		ModuleURLPath: scan.ModuleURLPath{
 			Module:  "golang.org/x/pkgsite",
 			Version: "v0.0.0-20221004150836-873fb37c2479",
 			Suffix:  "cmd/worker",
 		},
-		vulncheckRequestParams{Mode: ModeBinary},
+		QueryParams: ivulncheck.QueryParams{Mode: ModeBinary},
 	}
 	found := false
 	for _, sr := range sreqs {
@@ -50,14 +51,14 @@
 }
 
 func TestCreateQueueTasks(t *testing.T) {
-	vreq := func(path, version, mode string, importedBy int) *vulncheckRequest {
-		return &vulncheckRequest{
-			scan.ModuleURLPath{Module: path, Version: version},
-			vulncheckRequestParams{Mode: mode, ImportedBy: importedBy},
+	vreq := func(path, version, mode string, importedBy int) *ivulncheck.Request {
+		return &ivulncheck.Request{
+			ModuleURLPath: scan.ModuleURLPath{Module: path, Version: version},
+			QueryParams:   ivulncheck.QueryParams{Mode: mode, ImportedBy: importedBy},
 		}
 	}
 
-	params := &vulncheckEnqueueParams{Min: 8, File: "testdata/modules.txt"}
+	params := &ivulncheck.EnqueueQueryParams{Min: 8, File: "testdata/modules.txt"}
 	gotTasks, err := createVulncheckQueueTasks(context.Background(), &config.Config{}, params, []string{ModeVTAStacks})
 	if err != nil {
 		t.Fatal(err)
@@ -67,7 +68,7 @@
 		vreq("github.com/pkg/errors", "v0.9.1", ModeVTAStacks, 10),
 		vreq("golang.org/x/net", "v0.4.0", ModeVTAStacks, 20),
 	}
-	if diff := cmp.Diff(wantTasks, gotTasks, cmp.AllowUnexported(vulncheckRequest{})); diff != "" {
+	if diff := cmp.Diff(wantTasks, gotTasks, cmp.AllowUnexported(ivulncheck.Request{})); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
 
@@ -87,7 +88,7 @@
 			vreq("golang.org/x/net", "v0.4.0", mode, 20))
 	}
 
-	if diff := cmp.Diff(wantTasks, gotTasks, cmp.AllowUnexported(vulncheckRequest{})); diff != "" {
+	if diff := cmp.Diff(wantTasks, gotTasks, cmp.AllowUnexported(ivulncheck.Request{})); diff != "" {
 		t.Errorf("mismatch (-want, +got):\n%s", diff)
 	}
 }
diff --git a/internal/worker/vulncheck_results.go b/internal/worker/vulncheck_results.go
index 31e9777..c743279 100644
--- a/internal/worker/vulncheck_results.go
+++ b/internal/worker/vulncheck_results.go
@@ -11,10 +11,10 @@
 
 	"cloud.google.com/go/civil"
 	"golang.org/x/exp/event"
-	"golang.org/x/pkgsite-metrics/internal/bigquery"
 	"golang.org/x/pkgsite-metrics/internal/derrors"
 	"golang.org/x/pkgsite-metrics/internal/log"
 	"golang.org/x/pkgsite-metrics/internal/scan"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 )
 
 var insertResultsCounter = event.NewCounter("insert-results", &event.MetricOptions{Namespace: metricNamespace})
@@ -44,12 +44,12 @@
 	}
 	ctx := r.Context()
 	log.Infof(ctx, "reading results")
-	results, err := bigquery.FetchVulncheckResults(ctx, h.bqClient)
+	results, err := ivulncheck.FetchResults(ctx, h.bqClient)
 	if err != nil {
 		return err
 	}
 	log.Infof(ctx, "inserting %d results for %s", len(results), date)
-	if err := bigquery.InsertVulncheckResults(ctx, h.bqClient, results, date, allowDuplicates); err != nil {
+	if err := ivulncheck.InsertResults(ctx, h.bqClient, results, date, allowDuplicates); err != nil {
 		return err
 	}
 	fmt.Fprintf(w, "%d results for %s inserted successfully.\n", len(results), date)
diff --git a/internal/worker/vulncheck_scan.go b/internal/worker/vulncheck_scan.go
index f2f18a3..54795ce 100644
--- a/internal/worker/vulncheck_scan.go
+++ b/internal/worker/vulncheck_scan.go
@@ -31,9 +31,9 @@
 	"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/scan"
 	"golang.org/x/pkgsite-metrics/internal/version"
-	vulnc "golang.org/x/vuln/client"
+	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"
 )
@@ -76,7 +76,7 @@
 var scanCounter = event.NewCounter("scans", &event.MetricOptions{Namespace: metricNamespace})
 
 // path: /vulncheck/scan/MODULE_VERSION_SUFFIX?params
-// See parseVulncheckRequest for allowed path forms and query params.
+// See internal/vulncheck.ParseRequest for allowed path forms and query params.
 func (h *VulncheckServer) handleScan(w http.ResponseWriter, r *http.Request) (err error) {
 	defer derrors.Wrap(&err, "handleScan")
 
@@ -85,7 +85,7 @@
 	}()
 
 	ctx := r.Context()
-	sreq, err := parseVulncheckRequest(r, "/vulncheck/scan")
+	sreq, err := ivulncheck.ParseRequest(r, "/vulncheck/scan")
 	if err != nil {
 		return fmt.Errorf("%w: %v", derrors.InvalidArgument, err)
 	}
@@ -127,16 +127,16 @@
 		return nil
 	}
 	var err error
-	h.storedWorkVersions, err = bigquery.ReadVulncheckWorkVersions(ctx, h.bqClient)
+	h.storedWorkVersions, err = ivulncheck.ReadWorkVersions(ctx, h.bqClient)
 	return err
 }
 
 // A scanner holds state for scanning modules.
 type scanner struct {
 	proxyClient *proxy.Client
-	dbClient    vulnc.Client
+	dbClient    vulnclient.Client
 	bqClient    *bigquery.Client
-	workVersion *bigquery.VulncheckWorkVersion
+	workVersion *ivulncheck.WorkVersion
 	goMemLimit  uint64
 	gcsBucket   *storage.BucketHandle
 	insecure    bool
@@ -182,14 +182,14 @@
 	return s.err
 }
 
-func (s *scanner) ScanModule(ctx context.Context, w http.ResponseWriter, sreq *vulncheckRequest) error {
+func (s *scanner) ScanModule(ctx context.Context, w http.ResponseWriter, sreq *ivulncheck.Request) error {
 	if sreq.Module == "std" {
 		return nil // ignore the standard library
 	}
-	row := &bigquery.VulnResult{
-		ModulePath:           sreq.Module,
-		Suffix:               sreq.Suffix,
-		VulncheckWorkVersion: *s.workVersion,
+	row := &ivulncheck.Result{
+		ModulePath:  sreq.Module,
+		Suffix:      sreq.Suffix,
+		WorkVersion: *s.workVersion,
 	}
 	// Scan the version.
 	log.Infof(ctx, "fetching proxy info: %s@%s", sreq.Module, sreq.Version)
@@ -238,7 +238,7 @@
 		log.Infof(ctx, "bigquery disabled, not uploading")
 	} else {
 		log.Infof(ctx, "uploading to bigquery: %s", sreq.Path())
-		if err := s.bqClient.Upload(ctx, bigquery.VulncheckTableName, row); err != nil {
+		if err := s.bqClient.Upload(ctx, ivulncheck.TableName, row); err != nil {
 			// This is often caused by:
 			// "Upload: googleapi: got HTTP response code 413 with body"
 			// which happens for some modules.
@@ -259,7 +259,7 @@
 
 // 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 []*bigquery.Vuln, err error) {
+func (s *scanner) runScanModule(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (bvulns []*ivulncheck.Vuln, err error) {
 	defer func() {
 		if e := recover(); e != nil {
 			err = fmt.Errorf("%w: %v\n\n%s", derrors.ScanModulePanicError, e, debug.Stack())
@@ -290,7 +290,7 @@
 			return nil, err
 		}
 		for _, v := range vulns {
-			bvulns = append(bvulns, convertVuln(v))
+			bvulns = append(bvulns, ivulncheck.ConvertVulncheckOutput(v))
 		}
 	} else { // Govulncheck mode
 		var vulns []*govulncheck.Vuln
@@ -307,7 +307,7 @@
 			return nil, err
 		}
 		for _, v := range vulns {
-			bvulns = append(bvulns, convertGovulncheckOutput(v)...)
+			bvulns = append(bvulns, ivulncheck.ConvertGovulncheckOutput(v)...)
 		}
 	}
 	return bvulns, nil
@@ -704,7 +704,7 @@
 		strings.Contains(s, "connection")
 }
 
-func vulncheckConfig(dbClient vulnc.Client, mode string) *vulncheck.Config {
+func vulncheckConfig(dbClient vulnclient.Client, mode string) *vulncheck.Config {
 	cfg := &vulncheck.Config{Client: dbClient}
 	switch mode {
 	case ModeImports:
@@ -715,62 +715,6 @@
 	return cfg
 }
 
-func convertVuln(v *vulncheck.Vuln) *bigquery.Vuln {
-	return &bigquery.Vuln{
-		ID:          v.OSV.ID,
-		ModulePath:  v.ModPath,
-		PackagePath: v.PkgPath,
-		Symbol:      v.Symbol,
-		CallSink:    bigquery.NullInt(v.CallSink),
-		ImportSink:  bigquery.NullInt(v.ImportSink),
-		RequireSink: bigquery.NullInt(v.RequireSink),
-	}
-}
-
-func convertGovulncheckOutput(v *govulncheck.Vuln) (vulns []*bigquery.Vuln) {
-	for _, module := range v.Modules {
-		for pkgNum, pkg := range module.Packages {
-			addedSymbols := make(map[string]bool)
-			baseVuln := &bigquery.Vuln{
-				ID:          v.OSV.ID,
-				ModulePath:  module.Path,
-				PackagePath: pkg.Path,
-				CallSink:    bigquery.NullInt(0),
-				ImportSink:  bigquery.NullInt(pkgNum + 1),
-				RequireSink: bigquery.NullInt(pkgNum + 1),
-			}
-
-			// For each called symbol, reconstruct sinks and create the corresponding bigquery vuln
-			for symbolNum, cs := range pkg.CallStacks {
-				addedSymbols[cs.Symbol] = true
-				toAdd := *baseVuln
-				toAdd.Symbol = cs.Symbol
-				toAdd.CallSink = bigquery.NullInt(symbolNum + 1)
-				vulns = append(vulns, &toAdd)
-			}
-
-			// Find the rest of the vulnerable imported symbols that haven't been called
-			// and create corresponding bigquery vulns
-			for _, affected := range v.OSV.Affected {
-				if affected.Package.Name == module.Path {
-					for _, imp := range affected.EcosystemSpecific.Imports {
-						if imp.Path == pkg.Path {
-							for _, symbol := range imp.Symbols {
-								if !addedSymbols[symbol] {
-									toAdd := *baseVuln
-									toAdd.Symbol = symbol
-									vulns = append(vulns, &toAdd)
-								}
-							}
-						}
-					}
-				}
-			}
-		}
-	}
-	return vulns
-}
-
 // currHeapUsage computes currently allocate heap bytes.
 func currHeapUsage() uint64 {
 	var stats runtime.MemStats
@@ -889,58 +833,6 @@
 	}
 }
 
-// vulncheckRequest contains information passed
-// to a scan endpoint.
-type vulncheckRequest struct {
-	scan.ModuleURLPath
-	vulncheckRequestParams
-}
-
-// vulncheckRequestParams has query parameters for a vulncheck scan request.
-type vulncheckRequestParams struct {
-	ImportedBy int    // imported-by count
-	Mode       string // vulncheck mode (VTA, etc)
-	Insecure   bool   // if true, run outside sandbox
-	Serve      bool   // serve results back to client instead of writing them to BigQuery
-}
-
-// These methods implement queue.Task.
-func (r *vulncheckRequest) Name() string { return r.Module + "@" + r.Version }
-
-func (r *vulncheckRequest) Path() string { return r.ModuleURLPath.Path() }
-
-func (r *vulncheckRequest) Params() string {
-	return scan.FormatParams(r.vulncheckRequestParams)
-}
-
-// parseVulncheckRequest parses an http request r for an endpoint
-// prefix and produces a corresponding ScanRequest.
-//
-// The module and version should have one of the following three forms:
-//   - <module>/@v/<version>
-//   - <module>@<version>
-//   - <module>/@latest
-//
-// (These are the same forms that the module proxy accepts.)
-func parseVulncheckRequest(r *http.Request, prefix string) (*vulncheckRequest, error) {
-	mp, err := scan.ParseModuleURLPath(strings.TrimPrefix(r.URL.Path, prefix))
-	if err != nil {
-		return nil, err
-	}
-
-	rp := vulncheckRequestParams{ImportedBy: -1}
-	if err := scan.ParseParams(r, &rp); err != nil {
-		return nil, err
-	}
-	if rp.ImportedBy < 0 {
-		return nil, errors.New(`missing or negative "importedby" query param`)
-	}
-	return &vulncheckRequest{
-		ModuleURLPath:          mp,
-		vulncheckRequestParams: rp,
-	}, nil
-}
-
 // diskUsage runs the du command to determine how much disk space the given
 // directories occupy.
 func diskUsage(dirs ...string) string {
diff --git a/internal/worker/vulncheck_scan_test.go b/internal/worker/vulncheck_scan_test.go
index 18589b2..8a4c7dd 100644
--- a/internal/worker/vulncheck_scan_test.go
+++ b/internal/worker/vulncheck_scan_test.go
@@ -14,14 +14,10 @@
 
 	"cloud.google.com/go/storage"
 	"github.com/google/go-cmp/cmp"
-	"github.com/google/go-cmp/cmp/cmpopts"
-	"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"
 	vulnc "golang.org/x/vuln/client"
-	"golang.org/x/vuln/exp/govulncheck"
-	"golang.org/x/vuln/osv"
 	"golang.org/x/vuln/vulncheck"
 )
 
@@ -172,126 +168,3 @@
 		t.Errorf("got %+v, want %+v", got, want)
 	}
 }
-
-func TestConvertGovulncheckOutput(t *testing.T) {
-	var (
-		osvEntry = &osv.Entry{
-			ID: "GO-YYYY-1234",
-			Affected: []osv.Affected{
-				{
-					Package: osv.Package{
-						Name:      "example.com/repo/module",
-						Ecosystem: "Go",
-					},
-					EcosystemSpecific: osv.EcosystemSpecific{
-						Imports: []osv.EcosystemSpecificImport{
-							{
-								Path: "example.com/repo/module/package",
-								Symbols: []string{
-									"Symbol",
-									"Another",
-								},
-							},
-						},
-					},
-				},
-			},
-		}
-
-		vuln1 = &govulncheck.Vuln{
-			OSV: osvEntry,
-			Modules: []*govulncheck.Module{
-				{
-					Path: "example.com/repo/module",
-					Packages: []*govulncheck.Package{
-						{
-							Path: "example.com/repo/module/package",
-							CallStacks: []govulncheck.CallStack{
-								{
-									Symbol:  "Symbol",
-									Summary: "example.go:1:1 xyz.func calls pkgPath.Symbol",
-									Frames:  []*govulncheck.StackFrame{},
-								},
-							},
-						},
-					},
-				},
-			},
-		}
-
-		vuln2 = &govulncheck.Vuln{
-			OSV: osvEntry,
-			Modules: []*govulncheck.Module{
-				{
-					Path: "example.com/repo/module",
-					Packages: []*govulncheck.Package{
-						{
-							Path: "example.com/repo/module/package",
-						},
-					},
-				},
-			},
-		}
-	)
-	tests := []struct {
-		name      string
-		vuln      *govulncheck.Vuln
-		wantVulns []*bigquery.Vuln
-	}{
-		{
-			name: "Call One Symbol",
-			vuln: vuln1,
-			wantVulns: []*bigquery.Vuln{
-				{
-					ID:          "GO-YYYY-1234",
-					Symbol:      "Symbol",
-					PackagePath: "example.com/repo/module/package",
-					ModulePath:  "example.com/repo/module",
-					CallSink:    bigquery.NullInt(1),
-					ImportSink:  bigquery.NullInt(1),
-					RequireSink: bigquery.NullInt(1),
-				},
-				{
-					ID:          "GO-YYYY-1234",
-					Symbol:      "Another",
-					PackagePath: "example.com/repo/module/package",
-					ModulePath:  "example.com/repo/module",
-					CallSink:    bigquery.NullInt(0),
-					ImportSink:  bigquery.NullInt(1),
-					RequireSink: bigquery.NullInt(1),
-				},
-			},
-		},
-		{
-			name: "Call no symbols",
-			vuln: vuln2,
-			wantVulns: []*bigquery.Vuln{
-				{
-					ID:          "GO-YYYY-1234",
-					PackagePath: "example.com/repo/module/package",
-					ModulePath:  "example.com/repo/module",
-					Symbol:      "Symbol",
-					CallSink:    bigquery.NullInt(0),
-					ImportSink:  bigquery.NullInt(1),
-					RequireSink: bigquery.NullInt(1),
-				},
-				{
-					ID:          "GO-YYYY-1234",
-					PackagePath: "example.com/repo/module/package",
-					ModulePath:  "example.com/repo/module",
-					Symbol:      "Another",
-					CallSink:    bigquery.NullInt(0),
-					ImportSink:  bigquery.NullInt(1),
-					RequireSink: bigquery.NullInt(1),
-				},
-			},
-		},
-	}
-	for _, tt := range tests {
-		t.Run(tt.name, func(t *testing.T) {
-			if diff := cmp.Diff(convertGovulncheckOutput(tt.vuln), tt.wantVulns, cmpopts.EquateEmpty()); diff != "" {
-				t.Errorf("mismatch (-got, +want): %s", diff)
-			}
-		})
-	}
-}
diff --git a/internal/worker/vulncheck_test.go b/internal/worker/vulncheck_test.go
index f876711..65a933a 100644
--- a/internal/worker/vulncheck_test.go
+++ b/internal/worker/vulncheck_test.go
@@ -10,21 +10,22 @@
 
 	"github.com/google/go-cmp/cmp"
 	"golang.org/x/pkgsite-metrics/internal/bigquery"
+	ivulncheck "golang.org/x/pkgsite-metrics/internal/vulncheck"
 )
 
 func TestVulnsScanned(t *testing.T) {
 	p := newPage("test")
 
-	vuln1A := &bigquery.Vuln{ID: "1", Symbol: "A", CallSink: bigquery.NullInt(100)}
-	vuln1B := &bigquery.Vuln{ID: "1", Symbol: "B", CallSink: bigquery.NullInt(101)}
-	vuln1C := &bigquery.Vuln{ID: "1", Symbol: "C"}
-	vuln2A := &bigquery.Vuln{ID: "2", Symbol: "A"}
+	vuln1A := &ivulncheck.Vuln{ID: "1", Symbol: "A", CallSink: bigquery.NullInt(100)}
+	vuln1B := &ivulncheck.Vuln{ID: "1", Symbol: "B", CallSink: bigquery.NullInt(101)}
+	vuln1C := &ivulncheck.Vuln{ID: "1", Symbol: "C"}
+	vuln2A := &ivulncheck.Vuln{ID: "2", Symbol: "A"}
 
-	rows := []*bigquery.VulnResult{
-		{ModulePath: "m1", ScanMode: ModeImports, Vulns: []*bigquery.Vuln{vuln1A, vuln1B, vuln1C, vuln2A}},
-		{ModulePath: "m1", ScanMode: ModeVTAStacks, Vulns: []*bigquery.Vuln{vuln1A, vuln1B}},
-		{ModulePath: "m2", ScanMode: ModeImports, Vulns: []*bigquery.Vuln{vuln2A}},
-		{ModulePath: "m2", ScanMode: ModeVTAStacks, Vulns: []*bigquery.Vuln{}},
+	rows := []*ivulncheck.Result{
+		{ModulePath: "m1", ScanMode: ModeImports, Vulns: []*ivulncheck.Vuln{vuln1A, vuln1B, vuln1C, vuln2A}},
+		{ModulePath: "m1", ScanMode: ModeVTAStacks, Vulns: []*ivulncheck.Vuln{vuln1A, vuln1B}},
+		{ModulePath: "m2", ScanMode: ModeImports, Vulns: []*ivulncheck.Vuln{vuln2A}},
+		{ModulePath: "m2", ScanMode: ModeVTAStacks, Vulns: []*ivulncheck.Vuln{}},
 	}
 
 	got := handleVulncheckRows(context.Background(), p, rows)