internal/vulndb: add schema and vulndb endpoint

Change-Id: I57a14295220377853ff8e360c196911205847b28
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/488176
Reviewed-by: Julie Qiu <julieqiu@google.com>
Run-TryBot: Zvonimir Pavlinovic <zpavlinovic@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/go.mod b/go.mod
index f088985..8a40c46 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@
 	golang.org/x/exp/event v0.0.0-20220218215828-6cf2b201936e
 	golang.org/x/mod v0.7.0
 	golang.org/x/net v0.7.0
+	golang.org/x/sync v0.1.0
 	golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2
 	golang.org/x/vuln v0.0.0-20230201222900-4c848edceff1
 	google.golang.org/api v0.110.0
@@ -57,7 +58,6 @@
 	go.opentelemetry.io/otel/trace v1.11.2 // indirect
 	golang.org/x/exp/typeparams v0.0.0-20221208152030-732eee02a75a // indirect
 	golang.org/x/oauth2 v0.5.0 // indirect
-	golang.org/x/sync v0.1.0 // indirect
 	golang.org/x/sys v0.5.0 // indirect
 	golang.org/x/text v0.7.0 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
diff --git a/internal/vulndb/vulndb.go b/internal/vulndb/vulndb.go
new file mode 100644
index 0000000..6fa62fd
--- /dev/null
+++ b/internal/vulndb/vulndb.go
@@ -0,0 +1,109 @@
+// 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 vulndb provides functionality for manipulating
+// inputs and outputs of vulndb endpoint.
+package vulndb
+
+import (
+	"time"
+
+	"golang.org/x/pkgsite-metrics/internal/bigquery"
+	"golang.org/x/vuln/osv"
+)
+
+// Definitions for BigQuery.
+
+// SchemaVersion changes whenever the BigQuery vulndb schema changes.
+var SchemaVersion string
+
+func init() {
+	s, err := bigquery.InferSchema(Entry{})
+	if err != nil {
+		panic(err)
+	}
+	SchemaVersion = bigquery.SchemaVersion(s)
+	bigquery.AddTable(TableName, s)
+}
+
+const (
+	// Vuln DB requests live in their own dataset that doesn't vary.
+	// This is the same database the vulnbreqs endpoint uses.
+	DatasetName = "vulndb"
+	TableName   = "vulndb"
+)
+
+// Entry is a row stored in a table. It follows the core
+// structure of osv.Entry.
+type Entry struct {
+	CreatedAt time.Time `bigquery:"created_at"`
+
+	ModifiedTime  time.Time `bigquery:"modified_time"`
+	PublishedTime time.Time `bigquery:"published_time"`
+	WithdrawnTime time.Time `bigquery:"withdrawn_time"`
+
+	ID string `bigquery:"id"`
+
+	// Modules can in principle have multiple entries
+	// with the same path.
+	Modules []Module `bigquery:"modules"`
+}
+
+func (e *Entry) SetUploadTime(t time.Time) { e.CreatedAt = t }
+
+// Module plays the role of osv.Affected. The latter also has
+// a Module field (among others), but we merge them into one
+// type to avoid nesting which can make the queries more complex.
+type Module struct {
+	Path string `bigquery:"path"`
+	// Ranges field plays the role of osv.Range type
+	// where “SEMVER” range kind is assumed.
+	Ranges []Range `bigquery:"ranges"`
+}
+
+// Range plays the role of osv.RangeEvent. That is, it is
+// a list of versions representing the ranges in which the
+// module is vulnerable. The events should be sorted, and
+// MUST represent non-overlapping ranges.
+type Range struct {
+	Introduced string `bigquery:"introduced"`
+	Fixed      string `bigquery:"fixed"`
+}
+
+func Convert(oe *osv.Entry) *Entry {
+	e := &Entry{
+		ID:            oe.ID,
+		ModifiedTime:  oe.Modified,
+		PublishedTime: oe.Published,
+		Modules:       modules(oe),
+	}
+	if oe.Withdrawn != nil {
+		e.WithdrawnTime = *oe.Withdrawn
+	}
+	return e
+}
+
+func modules(oe *osv.Entry) []Module {
+	var modules []Module
+	for _, a := range oe.Affected {
+		modules = append(modules, Module{
+			Path:   a.Package.Name,
+			Ranges: ranges(a),
+		})
+	}
+	return modules
+}
+
+func ranges(a osv.Affected) []Range {
+	var rs []Range
+	for _, r := range a.Ranges {
+		for _, e := range r.Events {
+			rs = append(rs, Range{
+				Introduced: e.Introduced,
+				Fixed:      e.Fixed,
+			})
+		}
+	}
+	return rs
+}
diff --git a/internal/vulndb/vulndb_test.go b/internal/vulndb/vulndb_test.go
new file mode 100644
index 0000000..6c86f73
--- /dev/null
+++ b/internal/vulndb/vulndb_test.go
@@ -0,0 +1,38 @@
+// 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 vulndb
+
+import (
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/vuln/osv"
+)
+
+func TestConvert(t *testing.T) {
+	oe := &osv.Entry{
+		ID: "a",
+		Affected: []osv.Affected{
+			{Package: osv.Package{Name: "example.mod/a"}, Ranges: []osv.AffectsRange{{Events: []osv.RangeEvent{{Introduced: "0"}, {Fixed: "0.9.0"}}}}},
+			{Package: osv.Package{Name: "a.example.mod/a"}, Ranges: []osv.AffectsRange{{Events: []osv.RangeEvent{{Introduced: "1.0.0"}, {Fixed: "2.0.0"}}}}},
+		}}
+	want := &Entry{
+		ID: "a",
+		Modules: []Module{
+			{
+				Path:   "example.mod/a",
+				Ranges: []Range{{Introduced: "0"}, {Fixed: "0.9.0"}},
+			},
+			{
+				Path:   "a.example.mod/a",
+				Ranges: []Range{{Introduced: "1.0.0"}, {Fixed: "2.0.0"}},
+			},
+		},
+	}
+	got := Convert(oe)
+	if diff := cmp.Diff(want, got); diff != "" {
+		t.Fatalf("mismatch (-want, +got):\n%s", diff)
+	}
+}
diff --git a/internal/worker/server.go b/internal/worker/server.go
index d577d78..1c18039 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -16,7 +16,6 @@
 	"time"
 
 	"cloud.google.com/go/errorreporting"
-	"golang.org/x/pkgsite-metrics/internal"
 	"golang.org/x/pkgsite-metrics/internal/analysis"
 	"golang.org/x/pkgsite-metrics/internal/bigquery"
 	"golang.org/x/pkgsite-metrics/internal/config"
@@ -26,7 +25,6 @@
 	"golang.org/x/pkgsite-metrics/internal/observe"
 	"golang.org/x/pkgsite-metrics/internal/proxy"
 	"golang.org/x/pkgsite-metrics/internal/queue"
-	"golang.org/x/pkgsite-metrics/internal/vulndbreqs"
 	vulnc "golang.org/x/vuln/client"
 )
 
@@ -119,6 +117,9 @@
 	if err := s.registerAnalysisHandlers(ctx); err != nil {
 		return nil, err
 	}
+
+	// compute vulndb entries
+	s.handle("/vulndb", s.handleVulnDB)
 	// compute missing vuln.go.dev request counts
 	s.handle("/compute-requests", s.handleComputeRequests)
 	return s, nil
@@ -196,27 +197,6 @@
 	return nil
 }
 
-func (s *Server) handleComputeRequests(w http.ResponseWriter, r *http.Request) error {
-	ctx := r.Context()
-	// Don't use the Server's BigQuery client: it's for the wrong
-	// dataset.
-	vClient, err := bigquery.NewClientCreate(ctx, s.cfg.ProjectID, vulndbreqs.DatasetName)
-	if err != nil {
-		return err
-	}
-	keyName := "projects/" + s.cfg.ProjectID + "/secrets/vulndb-hmac-key"
-	hmacKey, err := internal.GetSecret(ctx, keyName)
-	if err != nil {
-		return err
-	}
-	err = vulndbreqs.ComputeAndStore(ctx, s.cfg.VulnDBBucketProjectID, vClient, []byte(hmacKey))
-	if err != nil {
-		return err
-	}
-	fmt.Fprintf(w, "Successfully computed and stored request counts.\n")
-	return nil
-}
-
 type serverError struct {
 	status int   // HTTP status code
 	err    error // wrapped error
diff --git a/internal/worker/vulndb.go b/internal/worker/vulndb.go
index f681c6d..47a1b01 100644
--- a/internal/worker/vulndb.go
+++ b/internal/worker/vulndb.go
@@ -7,15 +7,85 @@
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"os"
 	"path/filepath"
 	"strings"
 
+	"fmt"
+	"net/http"
+
 	"cloud.google.com/go/storage"
 	"golang.org/x/vuln/osv"
 	"google.golang.org/api/iterator"
+
+	"golang.org/x/pkgsite-metrics/internal"
+	"golang.org/x/pkgsite-metrics/internal/bigquery"
+	"golang.org/x/pkgsite-metrics/internal/derrors"
+	"golang.org/x/pkgsite-metrics/internal/vulndb"
+	"golang.org/x/pkgsite-metrics/internal/vulndbreqs"
 )
 
+func (s *Server) handleComputeRequests(w http.ResponseWriter, r *http.Request) (err error) {
+	defer derrors.Wrap(&err, "handleComputeRequests")
+
+	ctx := r.Context()
+	// Don't use the Server's BigQuery client: it's for the wrong
+	// dataset.
+	vClient, err := bigquery.NewClientCreate(ctx, s.cfg.ProjectID, vulndbreqs.DatasetName)
+	if err != nil {
+		return err
+	}
+	keyName := "projects/" + s.cfg.ProjectID + "/secrets/vulndb-hmac-key"
+	hmacKey, err := internal.GetSecret(ctx, keyName)
+	if err != nil {
+		return err
+	}
+	err = vulndbreqs.ComputeAndStore(ctx, s.cfg.VulnDBBucketProjectID, vClient, []byte(hmacKey))
+	if err != nil {
+		return err
+	}
+	fmt.Fprintf(w, "Successfully computed and stored request counts.\n")
+	return nil
+}
+
+func (s *Server) handleVulnDB(w http.ResponseWriter, r *http.Request) (err error) {
+	defer derrors.Wrap(&err, "handleVulnDB")
+
+	ctx := r.Context()
+	dbClient, err := bigquery.NewClientCreate(ctx, s.cfg.ProjectID, vulndb.DatasetName)
+	if err != nil {
+		return err
+	}
+
+	c, err := storage.NewClient(ctx)
+	if err != nil {
+		return err
+	}
+	bucket := c.Bucket("go-vulndb")
+	if bucket == nil {
+		return errors.New("failed to create go-vulndb bucket")
+	}
+	entries, err := vulndbEntries(ctx, bucket)
+	if err != nil {
+		return err
+	}
+
+	return bigquery.UploadMany(ctx, dbClient, vulndb.TableName, entries, 10000)
+}
+
+func vulndbEntries(ctx context.Context, bucket *storage.BucketHandle) ([]*vulndb.Entry, error) {
+	osvEntries, err := allVulnerabilities(ctx, bucket)
+	if err != nil {
+		return nil, err
+	}
+	var entries []*vulndb.Entry
+	for _, oe := range osvEntries {
+		entries = append(entries, vulndb.Convert(oe))
+	}
+	return entries, nil
+}
+
 // gcsOSVPrefix is the directory under which .json
 // files with OSV entries are located.
 const gcsOSVPrefix = "ID"
diff --git a/internal/worker/vulndb_test.go b/internal/worker/vulndb_test.go
index c6c2b3f..98c2538 100644
--- a/internal/worker/vulndb_test.go
+++ b/internal/worker/vulndb_test.go
@@ -1,4 +1,4 @@
-// Copyright 2022 The Go Authors. All rights reserved.
+// 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.