internal: add vulncheck metrics pipeline
An initial pipeline is added to measure impact of running functions from
x/vuln/vulncheck on a set of modules downloaded from the Go module
proxy.
Change-Id: I4f3f49653b3e05c5890177955e5b7824af45ec4c
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/465215
Reviewed-by: Julie Qiu <julieqiu@google.com>
Run-TryBot: Julie Qiu <julie@golang.org>
Auto-Submit: Julie Qiu <julieqiu@google.com>
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Julie Qiu <julieqiu@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/worker/main.go b/cmd/worker/main.go
index b9e90d4..6454383 100644
--- a/cmd/worker/main.go
+++ b/cmd/worker/main.go
@@ -22,7 +22,7 @@
workers = flag.Int("workers", 10, "number of concurrent requests to the fetch service, when running locally")
devMode = flag.Bool("dev", false, "enable developer mode (reload templates on each page load, serve non-minified JS/CSS, etc.)")
port = flag.String("port", config.GetEnv("PORT", "8080"), "port to listen to")
- dataset = flag.String("dataset", "", "dataset (overrides GO_ECOSYSTEM_METRICS_BIGQUERY_DATASET env var); use 'disable' for no BQ")
+ dataset = flag.String("dataset", "", "dataset (overrides GO_ECOSYSTEM_BIGQUERY_DATASET env var); use 'disable' for no BQ")
insecure = flag.Bool("insecure", false, "bypass sandbox in order to compare with old code")
// flag used in call to safehtml/template.TrustedSourceFromFlag
_ = flag.String("static", "static", "path to folder containing static files served")
diff --git a/go.mod b/go.mod
index a922848..6744930 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,7 @@
cloud.google.com/go/errorreporting v0.1.0
cloud.google.com/go/logging v1.6.1
cloud.google.com/go/secretmanager v1.9.0
+ cloud.google.com/go/storage v1.27.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go v1.0.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.26.0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.0.0
@@ -26,6 +27,7 @@
golang.org/x/mod v0.7.0
golang.org/x/net v0.5.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.103.0
google.golang.org/genproto v0.0.0-20221201164419-0e50fba7f41c
google.golang.org/grpc v1.50.1
diff --git a/go.sum b/go.sum
index 4bbfa8e..859969e 100644
--- a/go.sum
+++ b/go.sum
@@ -73,6 +73,7 @@
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.27.0 h1:YOO045NZI9RKfCj1c5A/ZtuuENUc8OAW+gHdGnDgyMQ=
+cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s=
cloud.google.com/go/trace v1.4.0 h1:qO9eLn2esajC9sxpqp1YKX37nXC3L4BfGnPS0Cx9dYo=
cloud.google.com/go/trace v1.4.0/go.mod h1:UG0v8UBqzusp+z63o7FK74SdFE+AXpCLdFb1rshXG+Y=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -618,6 +619,8 @@
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2 h1:v0FhRDmSCNH/0EurAT6T8KRY4aNuUhz6/WwBMxG+gvQ=
golang.org/x/tools v0.5.1-0.20230117180257-8aba49bb5ea2/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=
+golang.org/x/vuln v0.0.0-20230201222900-4c848edceff1 h1:HRexnHfiDA2hkPNMDgf3vxabRMeC+XeS8tCKP9olVbs=
+golang.org/x/vuln v0.0.0-20230201222900-4c848edceff1/go.mod h1:cBP4HMKv0X+x96j8IJWCKk0eqpakBmmHjKGSSC0NaYE=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/internal/bigquery/bigquery_test.go b/internal/bigquery/bigquery_test.go
new file mode 100644
index 0000000..34d85fb
--- /dev/null
+++ b/internal/bigquery/bigquery_test.go
@@ -0,0 +1,234 @@
+// 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"
+ "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/version"
+ "google.golang.org/api/iterator"
+)
+
+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 := NewClientCreate(ctx, projectID, dsID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ 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)
+ }
+
+ }
+
+ })
+}
+
+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")
+ }
+ client, err := bq.NewClient(context.Background(), "go-ecosystem")
+ if err != nil {
+ t.Fatal(err)
+ }
+ dataset := client.Dataset("nope")
+ _, err = dataset.Metadata(context.Background())
+ if !isNotFoundError(err) {
+ t.Errorf("got false, want true for %v", err)
+ }
+}
diff --git a/internal/bigquery/vulncheck.go b/internal/bigquery/vulncheck.go
new file mode 100644
index 0000000..a583d75
--- /dev/null
+++ b/internal/bigquery/vulncheck.go
@@ -0,0 +1,235 @@
+// 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
new file mode 100644
index 0000000..658ca84
--- /dev/null
+++ b/internal/bigquery/vulncheck_test.go
@@ -0,0 +1,34 @@
+// 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/config/config.go b/internal/config/config.go
index f919027..974fe67 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -74,6 +74,13 @@
// DevMode indicates whether the server is running in development mode.
DevMode bool
+ // VulnDBBucketProjectID is the project ID for the vuln DB bucket and its
+ // associated load balancer.
+ VulnDBBucketProjectID string
+
+ // BinaryBucket holds binaries for vulncheck scanning.
+ BinaryBucket string
+
// The host, port and user of the pkgsite database used to find
// modules to scan.
PkgsiteDBHost string
@@ -86,8 +93,11 @@
// Run analysis binaries without sandbox.
Insecure bool
- // ProxyURL is the URL of the module proxy.
+ // ProxyURL is the url for the Go module proxy.
ProxyURL string
+
+ // VulnDBURL is the url for the Go vulnerability database.
+ VulnDBURL string
}
// Init resolves all configuration values provided by the config package. It
@@ -102,20 +112,23 @@
ts = template.TrustedSourceFromFlag(f.Value)
}
cfg := &Config{
- ProjectID: GetEnv("GOOGLE_CLOUD_PROJECT", "go-ecosystem"),
- ServiceID: "go-ecosystem-worker",
- VersionID: os.Getenv("DOCKER_IMAGE"),
- LocationID: "us-central1",
- StaticPath: ts,
- BigQueryDataset: GetEnv("GO_ECOSYSTEM_BIGQUERY_DATASET", "test"),
- QueueName: os.Getenv("GO_ECOSYSTEM_QUEUE_NAME"),
- QueueURL: os.Getenv("GO_ECOSYSTEM_QUEUE_URL"),
- PkgsiteDBHost: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_HOST", "localhost"),
- PkgsiteDBPort: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_PORT", "5432"),
- PkgsiteDBName: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_NAME", "discovery-db"),
- PkgsiteDBUser: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_USER", "postgres"),
- PkgsiteDBSecret: os.Getenv("GO_ECOSYSTEM_PKGSITE_DB_SECRET"),
- ProxyURL: GetEnv("GO_ECOSYSTEM_PROXY_URL", "https://proxy.golang.org"),
+ ProjectID: os.Getenv("GOOGLE_CLOUD_PROJECT"),
+ ServiceID: "go-ecosystem-worker",
+ VersionID: os.Getenv("DOCKER_IMAGE"),
+ LocationID: "us-central1",
+ StaticPath: ts,
+ BigQueryDataset: GetEnv("GO_ECOSYSTEM_BIGQUERY_DATASET", "test"),
+ QueueName: os.Getenv("GO_ECOSYSTEM_QUEUE_NAME"),
+ QueueURL: os.Getenv("GO_ECOSYSTEM_QUEUE_URL"),
+ VulnDBBucketProjectID: os.Getenv("GO_ECOSYSTEM_VULNDB_BUCKET_PROJECT"),
+ BinaryBucket: os.Getenv("GO_ECOSYSTEM_BINARY_BUCKET"),
+ PkgsiteDBHost: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_HOST", "localhost"),
+ PkgsiteDBPort: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_PORT", "5432"),
+ PkgsiteDBName: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_NAME", "discovery-db"),
+ PkgsiteDBUser: GetEnv("GO_ECOSYSTEM_PKGSITE_DB_USER", "postgres"),
+ PkgsiteDBSecret: os.Getenv("GO_ECOSYSTEM_PKGSITE_DB_SECRET"),
+ ProxyURL: GetEnv("GO_MODULE_PROXY_URL", "https://proxy.golang.org"),
+ VulnDBURL: GetEnv("GO_VULNDB_URL", "https://vuln.go.dev"),
}
if OnCloudRun() {
sa, err := gceMetadata(ctx, "instance/service-accounts/default/email")
diff --git a/internal/derrors/derrors.go b/internal/derrors/derrors.go
index f9514b7..2e00c86 100644
--- a/internal/derrors/derrors.go
+++ b/internal/derrors/derrors.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The Go Authors. All rights reserved.
+// Copyright 2021 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
@@ -10,10 +10,13 @@
"errors"
"fmt"
"runtime"
+ "strings"
"cloud.google.com/go/errorreporting"
)
+//lint:file-ignore ST1012 prefixing error values with Err would stutter
+
var (
// NotFound indicates that a requested entity was not found (HTTP 404).
NotFound = errors.New("not found")
@@ -36,9 +39,59 @@
// ProxyError is used to capture non-actionable server errors returned from the proxy.
ProxyError = errors.New("proxy error")
+ // BigQueryError is used to capture server errors returned by BigQuery.
+ BigQueryError = errors.New("BigQuery error")
+
+ // ScanModulePanicError is used to capture panic issues.
+ ScanModulePanicError = errors.New("scan module panic")
+
// ScanModuleOSError is used to capture issues with writing the module zip
// to disk during the scan setup process. This is not an error with vulncheck.
ScanModuleOSError = errors.New("scan module OS error")
+
+ // ScanModuleLoadPackagesError is used to capture general unclassified issues
+ // with load packages during the scan setup process. This is not an error with
+ // vulncheck. There are specific load packages errors that are categorized
+ // separately, e.g., ScanModuleLoadPackagesNoGoModError.
+ ScanModuleLoadPackagesError = errors.New("scan module load packages error")
+
+ // ScanModuleLoadPackagesGoVersionError is used to capture issues with loading
+ // packages where the module is not supported by the current Go version. This
+ // is not an error with any specific scan technique.
+ ScanModuleLoadPackagesGoVersionError = errors.New("scan module load packages error: Go version mismatch")
+
+ // ScanModuleLoadPackagesNoGoModError is used to capture a specific issue
+ // with loading packages during the scan setup process where a go.mod file
+ // is missing. This is not an error with vulncheck.
+ ScanModuleLoadPackagesNoGoModError = errors.New("scan module load packages error: does not have go.mod")
+
+ // ScanModuleLoadPackagesNoGoSumError is used to capture a specific issue
+ // with loading packages during the scan setup process where a go.sum file
+ // is missing. This is not an error with vulncheck.
+ ScanModuleLoadPackagesNoGoSumError = errors.New("scan module load packages error: does not have go.sum")
+
+ // ScanModuleLoadPackagesNoRequiredModuleError is used to capture a specific
+ // issue with loading packages during the scan setup process where a package
+ // is imported but no required module is provided. This is not an error with
+ // vulncheck and is likely happening due to outdated go.sum file.
+ ScanModuleLoadPackagesNoRequiredModuleError = errors.New("scan module load packages error: no required module provided")
+
+ // ScanModuleLoadPackagesMissingGoSumEntryError is used to capture a specific
+ // issue with loading packages during the scan setup process where a package
+ // is imported but some of its go.sum entries are missing. This is not an error
+ // with vulncheck and is likely happening due to outdated go.sum file.
+ ScanModuleLoadPackagesMissingGoSumEntryError = errors.New("scan module load packages error: missing go.sum entry")
+
+ // ScanModuleVulncheckDBConnectionError is used to capture a specific
+ // vulncheck scan error where a connection to vuln db failed.
+ ScanModuleVulncheckDBConnectionError = errors.New("scan module vulncheck error: communication with vuln db failed")
+
+ // ScanModuleVulncheckError is used to capture general issues where
+ // vulncheck.Source fails due to an uncategorized error.
+ ScanModuleVulncheckError = errors.New("scan module vulncheck error")
+
+ // ScanModuleMemoryLimitExceeded occurs when scanning uses too much memory.
+ ScanModuleMemoryLimitExceeded = errors.New("scan module memory limit exceeded")
)
// Wrap adds context to the error and allows
@@ -114,3 +167,40 @@
repClient.Report(errorreporting.Entry{Error: err})
}
}
+
+// CategorizeError returns the category for a given error.
+func CategorizeError(err error) string {
+ switch {
+ case errors.Is(err, ScanModuleVulncheckError):
+ return "VULNCHECK - MISC"
+ case errors.Is(err, ScanModuleVulncheckDBConnectionError):
+ return "VULNCHECK - DB CONNECTION"
+ case errors.Is(err, ScanModuleLoadPackagesError):
+ return "LOAD"
+ case errors.Is(err, ScanModuleLoadPackagesGoVersionError):
+ return "LOAD - WRONG GO VERSION"
+ case errors.Is(err, ScanModuleLoadPackagesNoGoModError):
+ return "LOAD - NO GO.MOD"
+ case errors.Is(err, ScanModuleLoadPackagesNoGoSumError):
+ return "LOAD - NO GO.SUM"
+ case errors.Is(err, ScanModuleLoadPackagesNoRequiredModuleError):
+ return "LOAD - NO REQUIRED MODULE"
+ case errors.Is(err, ScanModuleLoadPackagesMissingGoSumEntryError):
+ return "LOAD - NO GO.SUM ENTRY"
+ case errors.Is(err, ScanModuleOSError):
+ return "OS"
+ case errors.Is(err, ScanModulePanicError):
+ return "PANIC"
+ case errors.Is(err, ScanModuleMemoryLimitExceeded):
+ return "MEM LIMIT EXCEEDED"
+ case errors.Is(err, ProxyError):
+ return "PROXY"
+ case errors.Is(err, BigQueryError):
+ return "BIGQUERY"
+ }
+ return ""
+}
+
+func IsGoVersionMismatchError(msg string) bool {
+ return strings.Contains(msg, "can't be built on Go")
+}
diff --git a/internal/worker/mem_monitor.go b/internal/worker/mem_monitor.go
new file mode 100644
index 0000000..48cf2d7
--- /dev/null
+++ b/internal/worker/mem_monitor.go
@@ -0,0 +1,56 @@
+// 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/server.go b/internal/worker/server.go
index 8ad8b42..980f378 100644
--- a/internal/worker/server.go
+++ b/internal/worker/server.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The Go Authors. All rights reserved.
+// 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.
@@ -24,14 +24,19 @@
"golang.org/x/pkgsite-metrics/internal/log"
"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/scan"
+ vulnc "golang.org/x/vuln/client"
)
type Server struct {
- cfg *config.Config
- observer *observe.Observer
- bqClient *bigquery.Client
- proxyClient *proxy.Client
- staticPath template.TrustedSource
+ cfg *config.Config
+ observer *observe.Observer
+ bqClient *bigquery.Client
+ vulndbClient vulnc.Client
+ proxyClient *proxy.Client
+ queue queue.Queue
+ staticPath template.TrustedSource
devMode bool
mu sync.Mutex
@@ -64,22 +69,38 @@
}
}
+ q, err := queue.New(ctx, cfg,
+ func(ctx context.Context, sreq *scan.Request) (int, error) {
+ // When running locally, only the module path and version are
+ // printed for now.
+ log.Infof(ctx, "enqueuing %s", sreq.URLPathAndParams())
+ return 0, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ dbClient, err := vulnc.NewClient([]string{cfg.VulnDBURL}, vulnc.Options{})
+ if err != nil {
+ return nil, err
+ }
proxyClient, err := proxy.New(cfg.ProxyURL)
if err != nil {
return nil, err
}
s := &Server{
- cfg: cfg,
- bqClient: bq,
- proxyClient: proxyClient,
- devMode: cfg.DevMode,
- staticPath: cfg.StaticPath,
+ cfg: cfg,
+ bqClient: bq,
+ vulndbClient: dbClient,
+ queue: q,
+ proxyClient: proxyClient,
+ devMode: cfg.DevMode,
+ staticPath: cfg.StaticPath,
}
if err := s.loadTemplates(); err != nil {
return nil, err
}
- s.observer, err = observe.NewObserver(ctx, cfg.ProjectID, "go-metrics-worker")
+ s.observer, err = observe.NewObserver(ctx, cfg.ProjectID, "go-ecosystem-worker")
if err != nil {
return nil, err
}
@@ -111,10 +132,18 @@
return nil
})
s.handle("/", s.handleIndexPage)
+
+ if err := s.registerVulncheckHandlers(ctx); err != nil {
+ return nil, err
+ }
+
+ s.handle("/test-vulncheck-sandbox/", s.handleTestVulncheckSandbox)
+ s.handle("/test-db", s.handleTestDB)
+
return s, nil
}
-const metricNamespace = "ecosystem/worker"
+const metricNamespace = "metrics/worker"
type handlerFunc func(w http.ResponseWriter, r *http.Request) error
@@ -138,6 +167,21 @@
http.Handle(pattern, s.observer.Observe(h))
}
+func (s *Server) registerVulncheckHandlers(ctx context.Context) error {
+ h, err := newVulncheckServer(ctx, s)
+ if err != nil {
+ return err
+ }
+ // returns an HTML page displaying information about vulncheck.
+ s.handle("/vulncheck", h.handlePage)
+
+ s.handle("/vulncheck/enqueueall", h.handleEnqueueAll)
+ s.handle("/vulncheck/enqueue", h.handleEnqueue)
+ s.handle("/vulncheck/scan/", h.handleScan)
+ s.handle("/vulncheck/insert-results", h.handleInsertResults)
+ return nil
+}
+
type serverError struct {
status int // HTTP status code
err error // wrapped error
diff --git a/internal/worker/templates.go b/internal/worker/templates.go
index 9753459..71d62d1 100644
--- a/internal/worker/templates.go
+++ b/internal/worker/templates.go
@@ -1,4 +1,4 @@
-// Copyright 2023 The Go Authors. All rights reserved.
+// 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.
@@ -18,7 +18,8 @@
)
const (
- indexTemplate = "worker.tmpl"
+ indexTemplate = "index.tmpl"
+ vulncheckTemplate = "vulncheck.tmpl"
)
type basePage struct {
@@ -33,8 +34,13 @@
if err != nil {
return err
}
+ vulncheck, err := s.parseTemplate(template.TrustedSourceFromConstant(vulncheckTemplate))
+ if err != nil {
+ return err
+ }
s.templates = map[string]*template.Template{
- indexTemplate: index,
+ indexTemplate: index,
+ vulncheckTemplate: vulncheck,
}
return nil
}
diff --git a/internal/worker/vulncheck.go b/internal/worker/vulncheck.go
new file mode 100644
index 0000000..a95530b
--- /dev/null
+++ b/internal/worker/vulncheck.go
@@ -0,0 +1,318 @@
+// 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 (
+ "context"
+ "errors"
+ "fmt"
+ "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"
+)
+
+type VulncheckServer struct {
+ *Server
+ storedWorkVersions map[[2]string]*bigquery.VulncheckWorkVersion
+ workVersion *bigquery.VulncheckWorkVersion
+}
+
+func newVulncheckServer(ctx context.Context, s *Server) (*VulncheckServer, error) {
+ var (
+ swv map[[2]string]*bigquery.VulncheckWorkVersion
+ err error
+ )
+ if s.bqClient != nil {
+ swv, err = bigquery.ReadVulncheckWorkVersions(ctx, s.bqClient)
+ if err != nil {
+ return nil, err
+ }
+ log.Infof(ctx, "read %d work versions", len(swv))
+ }
+ return &VulncheckServer{
+ Server: s,
+ storedWorkVersions: swv,
+ }, nil
+}
+
+func (h *VulncheckServer) getWorkVersion(ctx context.Context) (_ *bigquery.VulncheckWorkVersion, err error) {
+ defer derrors.Wrap(&err, "VulncheckServer.getWorkVersion")
+ h.mu.Lock()
+ defer h.mu.Unlock()
+
+ if h.workVersion == nil {
+ lmt, err := h.vulndbClient.LastModifiedTime(ctx)
+ if err != nil {
+ return nil, err
+ }
+ vulnVersion, err := readVulnVersion()
+ if err != nil {
+ return nil, err
+ }
+ h.workVersion = &bigquery.VulncheckWorkVersion{
+ VulnDBLastModified: lmt,
+ WorkerVersion: h.cfg.VersionID,
+ SchemaVersion: bigquery.VulncheckSchemaVersion,
+ VulnVersion: vulnVersion,
+ }
+ log.Infof(ctx, "vulncheck work version: %+v", h.workVersion)
+ }
+ return h.workVersion, nil
+}
+
+// readVulnVersion returns the version of the golang.org/x/vuln module linked into
+// the current binary.
+func readVulnVersion() (string, error) {
+ const modulePath = "golang.org/x/vuln"
+ info, ok := debug.ReadBuildInfo()
+ if !ok {
+ return "", errors.New("vuln version not available")
+ }
+ for _, mod := range info.Deps {
+ if mod.Path == modulePath {
+ if mod.Replace != nil {
+ mod = mod.Replace
+ }
+ return mod.Version, nil
+ }
+ }
+ return "", fmt.Errorf("module %s not found", modulePath)
+}
+
+func (h *VulncheckServer) handlePage(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+ page, err := h.createVulncheckPage(ctx)
+ if err != nil {
+ return err
+ }
+ tmpl, err := h.Server.maybeLoadTemplate(vulncheckTemplate)
+ if err != nil {
+ return err
+ }
+ return renderPage(ctx, w, page, tmpl)
+}
+
+func (h *VulncheckServer) createVulncheckPage(ctx context.Context) (*VulncheckPage, error) {
+ if h.bqClient == nil {
+ return nil, errBQDisabled
+ }
+ table := h.bqClient.FullTableName(bigquery.VulncheckTableName)
+ page := newPage(table)
+ page.basePage = newBasePage()
+
+ rows, err := bigquery.FetchVulncheckResults(ctx, h.bqClient)
+ if err != nil {
+ return nil, err
+ }
+
+ vulnsScanned := handleVulncheckRows(ctx, page, rows)
+ page.NumVulnsScanned = len(vulnsScanned)
+ page.addErrors()
+ return page, nil
+}
+
+type VulncheckPage struct {
+ basePage
+
+ TableName string
+
+ NumVulnsInDatabase int
+ NumVulnsScanned int
+
+ VTAResult *VulncheckResult
+ VTAStacksResult *VulncheckResult
+ ImportsResult *VulncheckResult
+
+ Errors []*ErrorCategory
+}
+
+type ErrorCategory struct {
+ Name string
+ VTANumModules int
+ ImportsNumModules int
+}
+
+func (p *VulncheckPage) PercentVulnsScanned() float64 {
+ return (float64(p.NumVulnsScanned) / float64(p.NumVulnsInDatabase)) * 100
+}
+
+func (p *VulncheckPage) NumModulesSuccess() int {
+ return p.VTAResult.NumModulesSuccess + p.ImportsResult.NumModulesSuccess
+}
+
+type VulncheckResult struct {
+ NumModulesScanned int
+ NumModulesSuccess int
+ NumModulesError int
+ NumModulesVuln int
+
+ ErrorCategory map[string]int
+
+ maxScanSeconds float64
+ sumScanSeconds float64
+
+ maxScanMemory float64
+ sumScanMemory float64
+}
+
+func (v *VulncheckResult) AverageScanSeconds() float64 {
+ return v.sumScanSeconds / float64(v.NumModulesSuccess)
+}
+
+func (v *VulncheckResult) MaxScanSeconds() float64 {
+ return v.maxScanSeconds
+}
+
+// AverageScanMemory in megabytes.
+func (v *VulncheckResult) AverageScanMemory() float64 {
+ return v.sumScanMemory / (float64(v.NumModulesSuccess) * 1024 * 1024)
+}
+
+// MaxScanMemory in megabytes.
+func (v *VulncheckResult) MaxScanMemory() float64 {
+ return v.maxScanMemory / (1024 * 1024)
+}
+
+func (v *VulncheckResult) NumModulesNoVuln() int {
+ return v.NumModulesSuccess - v.NumModulesVuln
+}
+
+func (v *VulncheckResult) PercentSuccess() float64 {
+ return (float64(v.NumModulesSuccess) / float64(v.NumModulesScanned)) * 100
+}
+
+func (v *VulncheckResult) PercentFailed() float64 {
+ return (float64(v.NumModulesError) / float64(v.NumModulesScanned)) * 100
+}
+
+func (v *VulncheckResult) PercentVuln() float64 {
+ return (float64(v.NumModulesVuln) / float64(v.NumModulesSuccess)) * 100
+}
+
+func (v *VulncheckResult) PercentNoVuln() float64 {
+ return (float64(v.NumModulesNoVuln()) / float64(v.NumModulesSuccess)) * 100
+}
+
+func (r *VulncheckResult) update(row *bigquery.VulnResult) {
+ r.NumModulesScanned++
+ if row.Error != "" {
+ r.NumModulesError++
+ r.ErrorCategory[row.ErrorCategory]++
+ return
+ }
+ r.NumModulesSuccess++
+
+ s := row.ScanSeconds
+ if s > r.maxScanSeconds {
+ r.maxScanSeconds = s
+ }
+ r.sumScanSeconds += s
+
+ m := float64(row.ScanMemory)
+ if m > r.maxScanMemory {
+ r.maxScanMemory = m
+ }
+ r.sumScanMemory += m
+
+ if len(row.Vulns) > 0 {
+ if row.ScanMode == ModeImports {
+ r.NumModulesVuln++
+ } else {
+ // VTA and VTA with stacks mode.
+ for _, v := range row.Vulns {
+ if v.CallSink.Int64 > 0 {
+ r.NumModulesVuln++
+ break
+ }
+ }
+ }
+ }
+}
+
+// ReportResults contains aggregate results for a
+// vulnerability reports, such as number of modules
+// in which the vulnerability is found by vulncheck.
+type ReportResult struct {
+ VTANumModules int
+ ImportsNumModules int
+}
+
+// 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 {
+ vulnsScanned := map[string]*ReportResult{}
+ for _, row := range rows {
+ switch row.ScanMode {
+ case ModeVTA:
+ page.VTAResult.update(row)
+ case ModeVTAStacks:
+ page.VTAStacksResult.update(row)
+ case ModeImports:
+ page.ImportsResult.update(row)
+ default:
+ log.Errorf(ctx, "unexpected mode for %s@%s: %q", row.ModulePath, row.Version, row.ScanMode)
+ continue
+ }
+
+ // For each vuln, count the number of modules in which it
+ // was detected for each mode. Since a vuln in row.Vulns
+ // is defined by a symbol, make sure not to count multiple
+ // symbols of each vuln separately.
+ importsSeen := make(map[string]bool)
+ callsSeen := make(map[string]bool)
+ for _, v := range row.Vulns {
+ if _, ok := vulnsScanned[v.ID]; !ok {
+ vulnsScanned[v.ID] = &ReportResult{}
+ }
+ r := vulnsScanned[v.ID]
+
+ if row.ScanMode == ModeImports {
+ if !importsSeen[v.ID] {
+ r.ImportsNumModules++
+ }
+ importsSeen[v.ID] = true
+ }
+
+ if row.ScanMode == ModeVTA && v.CallSink.Int64 > 0 {
+ if !callsSeen[v.ID] {
+ r.VTANumModules++
+ }
+ callsSeen[v.ID] = true
+ }
+ }
+ }
+ return vulnsScanned
+}
+
+func newPage(table string) *VulncheckPage {
+ return &VulncheckPage{
+ TableName: table,
+ VTAResult: &VulncheckResult{ErrorCategory: make(map[string]int)},
+ VTAStacksResult: &VulncheckResult{ErrorCategory: make(map[string]int)},
+ ImportsResult: &VulncheckResult{ErrorCategory: make(map[string]int)},
+ }
+}
+
+func (page *VulncheckPage) addErrors() {
+ ecs := map[string]*ErrorCategory{}
+ for category, count := range page.VTAResult.ErrorCategory {
+ if _, ok := ecs[category]; !ok {
+ ecs[category] = &ErrorCategory{Name: category}
+ }
+ ecs[category].VTANumModules = count
+ }
+ for category, count := range page.ImportsResult.ErrorCategory {
+ if _, ok := ecs[category]; !ok {
+ ecs[category] = &ErrorCategory{Name: category}
+ }
+ ecs[category].ImportsNumModules = count
+ }
+ for _, ec := range ecs {
+ page.Errors = append(page.Errors, ec)
+ }
+}
diff --git a/internal/worker/vulncheck_enqueue.go b/internal/worker/vulncheck_enqueue.go
new file mode 100644
index 0000000..bf93a8f
--- /dev/null
+++ b/internal/worker/vulncheck_enqueue.go
@@ -0,0 +1,136 @@
+// 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 (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "cloud.google.com/go/storage"
+ "golang.org/x/pkgsite-metrics/internal/derrors"
+ "golang.org/x/pkgsite-metrics/internal/log"
+ "golang.org/x/pkgsite-metrics/internal/queue"
+ "golang.org/x/pkgsite-metrics/internal/scan"
+ "google.golang.org/api/iterator"
+)
+
+// handleEnqueue enqueues multiple modules for a single vulncheck mode.
+// Query params:
+// - suffix: appended to task queue IDs to generate unique tasks
+// - mode: type of analysis to run; see [modes]
+// - file: path to file containing modules; if missing, use DB
+// - min: minimum import-by count for a module to be included
+func (h *VulncheckServer) handleEnqueue(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+ suffix := r.FormValue("suffix")
+ mode, err := vulncheckMode(scan.ParseMode(r))
+ if err != nil {
+ return err
+ }
+
+ var sreqs []*scan.Request
+ if mode == ModeBinary {
+ var err error
+ sreqs, err = readBinaries(ctx, h.cfg.BinaryBucket)
+ if err != nil {
+ return err
+ }
+ } else {
+ minImpCount, err := scan.ParseOptionalIntParam(r, "min", defaultMinImportedByCount)
+ if err != nil {
+ return err
+ }
+ modspecs, err := readModules(ctx, h.cfg, r.FormValue("file"), minImpCount)
+ if err != nil {
+ return err
+ }
+ sreqs = moduleSpecsToScanRequests(modspecs, mode)
+ }
+ return enqueueModules(ctx, sreqs, h.queue, &queue.Options{Namespace: "vulncheck", TaskNameSuffix: suffix})
+}
+
+func vulncheckMode(mode string) (string, error) {
+ if mode == "" {
+ // VTA is the default mode
+ return ModeVTA, nil
+ }
+ mode = strings.ToUpper(mode)
+ if _, ok := modes[mode]; !ok {
+ return "", fmt.Errorf("unsupported mode: %v", mode)
+ }
+ return mode, nil
+}
+
+// handleEnqueueAll enqueues multiple modules for all vulncheck modes.
+// Query params:
+// - suffix: appended to task queue IDs to generate unique tasks
+// - file: path to file containing modules; if missing, use DB
+// - min: minimum import-by count for a module to be included
+func (h *VulncheckServer) handleEnqueueAll(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+ suffix := r.FormValue("suffix")
+ minImpCount, err := scan.ParseOptionalIntParam(r, "min", defaultMinImportedByCount)
+ if err != nil {
+ return err
+ }
+ modspecs, err := readModules(ctx, h.cfg, r.FormValue("file"), minImpCount)
+ if err != nil {
+ return err
+ }
+ opts := &queue.Options{Namespace: "vulncheck", TaskNameSuffix: suffix}
+ for mode := range modes {
+ var sreqs []*scan.Request
+ if mode == ModeBinary {
+ sreqs, err = readBinaries(ctx, h.cfg.BinaryBucket)
+ if err != nil {
+ return err
+ }
+ } else {
+ sreqs = moduleSpecsToScanRequests(modspecs, mode)
+ }
+ if err := enqueueModules(ctx, sreqs, h.queue, opts); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// 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) (sreqs []*scan.Request, err error) {
+ defer derrors.Wrap(&err, "readBinaries(%q)", bucketName)
+ if bucketName == "" {
+ log.Infof(ctx, "binary bucket not configured; not enqueuing binaries")
+ return nil, nil
+ }
+ c, err := storage.NewClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ iter := c.Bucket(bucketName).Objects(ctx, &storage.Query{Prefix: binaryDir})
+ for {
+ attrs, err := iter.Next()
+ if err == iterator.Done {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ mod, vers, suff, err := scan.ParseModuleVersionSuffix(strings.TrimPrefix(attrs.Name, binaryDir+"/"))
+ if err != nil {
+ return nil, err
+ }
+ sreqs = append(sreqs, &scan.Request{
+ Module: mod,
+ Version: vers,
+ Suffix: suff,
+ Mode: ModeBinary,
+ })
+ }
+ return sreqs, nil
+}
diff --git a/internal/worker/vulncheck_enqueue_test.go b/internal/worker/vulncheck_enqueue_test.go
new file mode 100644
index 0000000..83d2121
--- /dev/null
+++ b/internal/worker/vulncheck_enqueue_test.go
@@ -0,0 +1,44 @@
+// 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 (
+ "context"
+ "flag"
+ "testing"
+
+ "golang.org/x/pkgsite-metrics/internal/scan"
+)
+
+var binaryBucket = flag.String("binary-bucket", "", "bucket for scannable binaries")
+
+func TestReadBinaries(t *testing.T) {
+ if *binaryBucket == "" {
+ t.Skip("missing -binary-bucket")
+ }
+ sreqs, err := readBinaries(context.Background(), *binaryBucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := &scan.Request{
+ Module: "golang.org/x/pkgsite",
+ Version: "v0.0.0-20221004150836-873fb37c2479",
+ Suffix: "cmd/worker",
+ Mode: ModeBinary,
+ }
+ found := false
+ for _, sr := range sreqs {
+ if *sr == *want {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("did not find %+v in results:", want)
+ for _, r := range sreqs {
+ t.Logf(" %+v", r)
+ }
+ }
+}
diff --git a/internal/worker/vulncheck_results.go b/internal/worker/vulncheck_results.go
new file mode 100644
index 0000000..31e9777
--- /dev/null
+++ b/internal/worker/vulncheck_results.go
@@ -0,0 +1,57 @@
+// 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 (
+ "fmt"
+ "net/http"
+ "time"
+
+ "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"
+)
+
+var insertResultsCounter = event.NewCounter("insert-results", &event.MetricOptions{Namespace: metricNamespace})
+
+func (h *VulncheckServer) handleInsertResults(w http.ResponseWriter, r *http.Request) (err error) {
+ defer func() {
+ insertResultsCounter.Record(r.Context(), 1, event.Bool("success", err == nil))
+ }()
+
+ if h.bqClient == nil {
+ return errBQDisabled
+ }
+
+ var date civil.Date
+ if d := r.FormValue("date"); d != "" {
+ var err error
+ date, err = civil.ParseDate(d)
+ if err != nil {
+ return fmt.Errorf("%w: parsing 'date' query param: %v", derrors.InvalidArgument, err)
+ }
+ } else {
+ date = civil.DateOf(time.Now())
+ }
+ allowDuplicates, err := scan.ParseOptionalBoolParam(r, "allow-dups", false)
+ if err != nil {
+ return fmt.Errorf("%w: %v", derrors.InvalidArgument, err)
+ }
+ ctx := r.Context()
+ log.Infof(ctx, "reading results")
+ results, err := bigquery.FetchVulncheckResults(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 {
+ return err
+ }
+ fmt.Fprintf(w, "%d results for %s inserted successfully.\n", len(results), date)
+ return nil
+}
diff --git a/internal/worker/vulncheck_scan.go b/internal/worker/vulncheck_scan.go
new file mode 100644
index 0000000..200bb5c
--- /dev/null
+++ b/internal/worker/vulncheck_scan.go
@@ -0,0 +1,731 @@
+// 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 (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "runtime/debug"
+ "strconv"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "cloud.google.com/go/storage"
+ "golang.org/x/exp/event"
+ "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/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/scan"
+ "golang.org/x/pkgsite-metrics/internal/version"
+ vulnc "golang.org/x/vuln/client"
+ "golang.org/x/vuln/vulncheck"
+)
+
+const (
+ // ModeVTA is the default vulncheck mode
+ ModeVTA string = "VTA"
+
+ // modeVTStacks is modeVTA where call stacks are
+ // computed additionally. Closely resembles the
+ // actual logic of govulncheck.
+ ModeVTAStacks string = "VTASTACKS"
+
+ // ModeImports only computes import-level analysis.
+ ModeImports string = "IMPORTS"
+
+ // ModeBinary runs vulncheck.Binary
+ ModeBinary string = "BINARY"
+)
+
+// modes is a set of supported vulncheck modes
+var modes = map[string]bool{
+ ModeImports: true,
+ ModeVTA: true,
+ ModeVTAStacks: true,
+ ModeBinary: true,
+}
+
+func IsValidVulncheckMode(mode string) bool {
+ return modes[mode]
+}
+
+// TODO(b/241402488): shouldSkip is the list of modules that we are not
+// currently scanning due to previous issues that need investigation.
+var shouldSkip = map[string]bool{}
+
+var scanCounter = event.NewCounter("scans", &event.MetricOptions{Namespace: metricNamespace})
+
+// path: /vulncheck/scan/MODULE_VERSION_SUFFIX
+// See scan.ParseRequest for allowed forms.
+// Query params:
+// - mode: type of analysis to run; see [modes]
+// - importedby: number of importers
+func (h *VulncheckServer) handleScan(w http.ResponseWriter, r *http.Request) (err error) {
+ defer derrors.Wrap(&err, "handleScan")
+
+ defer func() {
+ scanCounter.Record(r.Context(), 1, event.Bool("success", err == nil))
+ }()
+
+ ctx := r.Context()
+ sreq, err := scan.ParseRequest(r, "/vulncheck/scan")
+ if err != nil {
+ return fmt.Errorf("%w: %v", derrors.InvalidArgument, err)
+ }
+ if sreq.Mode == "" {
+ sreq.Mode = ModeVTA
+ }
+ if shouldSkip[sreq.Module] {
+ log.Infof(ctx, "skipping %s (module in shouldSkip list)", sreq.Path())
+ return nil
+ }
+ if err := h.readVulncheckWorkVersions(ctx); err != nil {
+ return err
+ }
+ scanner, err := newScanner(ctx, h)
+ if err != nil {
+ return err
+ }
+ // An explicit "insecure" query param overrides the default.
+ // This is temporary, for testing running in a sandbox.
+ if r.FormValue("insecure") != "" {
+ scanner.insecure = sreq.Insecure
+ }
+ wv := h.storedWorkVersions[[2]string{sreq.Module, sreq.Version}]
+ if scanner.workVersion.Equal(wv) {
+ log.Infof(ctx, "skipping %s@%s (work version unchanged)", sreq.Module, sreq.Version)
+ return nil
+ }
+
+ log.Infof(ctx, "scanning: %s", sreq.Path())
+ scanner.ScanModule(ctx, sreq)
+ log.Infof(ctx, "fetched and updated %s", sreq.Path())
+ return nil
+}
+
+func (h *VulncheckServer) readVulncheckWorkVersions(ctx context.Context) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.storedWorkVersions != nil {
+ return nil
+ }
+ if h.bqClient == nil {
+ return nil
+ }
+ var err error
+ h.storedWorkVersions, err = bigquery.ReadVulncheckWorkVersions(ctx, h.bqClient)
+ return err
+}
+
+// A scanner holds state for scanning modules.
+type scanner struct {
+ proxyClient *proxy.Client
+ dbClient vulnc.Client
+ bqClient *bigquery.Client
+ workVersion *bigquery.VulncheckWorkVersion
+ goMemLimit uint64
+ gcsBucket *storage.BucketHandle
+ insecure bool
+ sbox *sandbox.Sandbox
+}
+
+func newScanner(ctx context.Context, h *VulncheckServer) (*scanner, error) {
+ workVersion, err := h.getWorkVersion(ctx)
+ if err != nil {
+ return nil, err
+ }
+ var bucket *storage.BucketHandle
+ if h.cfg.BinaryBucket != "" {
+ c, err := storage.NewClient(ctx)
+ if err != nil {
+ return nil, err
+ }
+ bucket = c.Bucket(h.cfg.BinaryBucket)
+ }
+ sbox := sandbox.New("/bundle")
+ sbox.Runsc = "/usr/local/bin/runsc"
+ return &scanner{
+ proxyClient: h.proxyClient,
+ bqClient: h.bqClient,
+ dbClient: h.vulndbClient,
+ workVersion: workVersion,
+ goMemLimit: parseGoMemLimit(os.Getenv("GOMEMLIMIT")),
+ gcsBucket: bucket,
+ insecure: h.cfg.Insecure,
+ sbox: sbox,
+ }, nil
+}
+
+type scanError struct {
+ err error
+}
+
+func (s scanError) Error() string {
+ return s.err.Error()
+}
+
+func (s scanError) Unwrap() error {
+ return s.err
+}
+
+func (s *scanner) ScanModule(ctx context.Context, sreq *scan.Request) {
+ if sreq.Module == "std" {
+ return // ignore the standard library
+ }
+ row := &bigquery.VulnResult{
+ ModulePath: sreq.Module,
+ Suffix: sreq.Suffix,
+ VulncheckWorkVersion: *s.workVersion,
+ }
+ // Scan the version.
+ log.Infof(ctx, "fetching proxy info: %s@%s", sreq.Module, sreq.Version)
+ info, err := s.proxyClient.Info(ctx, sreq.Module, sreq.Version)
+ if err != nil {
+ log.Errorf(ctx, "proxy error: %v", err)
+ row.AddError(fmt.Errorf("%v: %w", err, derrors.ProxyError))
+ return
+ }
+ row.Version = info.Version
+ row.SortVersion = version.ForSorting(row.Version)
+ row.CommitTime = info.Time
+ row.ImportedBy = sreq.ImportedBy
+ row.VulnDBLastModified = s.workVersion.VulnDBLastModified
+ row.ScanMode = sreq.Mode
+
+ log.Infof(ctx, "scanning: %s", sreq.Path())
+ stats := &vulncheckStats{}
+ 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 {
+ row.AddError(err)
+ log.Infof(ctx, "scanner.runScanModule return error for %s (%v)", sreq.Path(), err)
+ } else {
+ row.Vulns = vulns
+ log.Infof(ctx, "scanner.runScanModule returned %d vulns: %s", len(vulns), sreq.Path())
+ }
+ if s.bqClient == nil {
+ 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 {
+ // This is often caused by:
+ // "Upload: googleapi: got HTTP response code 413 with body"
+ // which happens for some modules.
+ row.AddError(fmt.Errorf("%v: %w", err, derrors.BigQueryError))
+ log.Errorf(ctx, "bq.Upload for %s: %v", sreq.Path(), err)
+ }
+ }
+}
+
+type vulncheckStats struct {
+ scanSeconds float64
+ scanMemory uint64
+ pkgsMemory uint64
+}
+
+var activeScans atomic.Int32
+
+// 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) {
+ defer func() {
+ if e := recover(); e != nil {
+ err = fmt.Errorf("%w: %v\n\n%s", derrors.ScanModulePanicError, e, debug.Stack())
+ }
+ }()
+
+ logMemory(ctx, fmt.Sprintf("before scanning %s@%s", modulePath, version))
+ defer logMemory(ctx, fmt.Sprintf("after scanning %s@%s", modulePath, version))
+
+ activeScans.Add(1)
+ defer func() {
+ if activeScans.Add(-1) == 0 {
+ logMemory(ctx, fmt.Sprintf("before 'go clean' for %s@%s", modulePath, version))
+ s.cleanGoCaches(ctx)
+ }
+ }()
+
+ var vulns []*vulncheck.Vuln
+ if s.insecure {
+ vulns, err = s.runScanModuleInsecure(ctx, modulePath, version, binaryDir, mode, stats)
+ } else {
+ vulns, err = s.runScanModuleSandbox(ctx, modulePath, version, binaryDir, mode, stats)
+ }
+
+ // If an error occurred, wrap it accordingly.
+ if err != nil {
+ if isVulnDBConnection(err) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleVulncheckDBConnectionError)
+ } else if !errors.Is(err, derrors.ScanModuleMemoryLimitExceeded) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleVulncheckError)
+ }
+ return nil, err
+ }
+ for _, v := range vulns {
+ bvulns = append(bvulns, convertVuln(v))
+ }
+ return bvulns, nil
+}
+
+func (s *scanner) runScanModuleSandbox(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) ([]*vulncheck.Vuln, error) {
+ var (
+ res *vulncheck.Result
+ err error
+ )
+ if mode == ModeBinary {
+ res, err = s.runBinaryScanSandbox(ctx, modulePath, version, binaryDir, stats)
+ } else {
+ res, err = s.runSourceScanSandbox(ctx, modulePath, version, mode, stats)
+ }
+ log.Debugf(ctx, "runScanModuleSandbox %s@%s bin %s, mode %s: got %+v, %v", modulePath, version, binaryDir, mode, res, err)
+ if err != nil {
+ return nil, err
+ }
+ return res.Vulns, nil
+}
+
+func (s *scanner) runSourceScanSandbox(ctx context.Context, modulePath, version, mode string, stats *vulncheckStats) (*vulncheck.Result, error) {
+ stdout, err := runSourceScanSandbox(ctx, modulePath, version, mode, s.proxyClient, s.sbox)
+ if err != nil {
+ return nil, err
+ }
+ return unmarshalVulncheckOutput(stdout)
+}
+
+const sandboxGoModCache = "go/pkg/mod"
+
+func runSourceScanSandbox(ctx context.Context, modulePath, version, mode string, proxyClient *proxy.Client, sbox *sandbox.Sandbox) ([]byte, error) {
+ sandboxDir := "/modules/" + modulePath + "@" + version
+ imageDir := "/bundle/rootfs" + sandboxDir
+ defer os.RemoveAll(imageDir)
+ log.Infof(ctx, "downloading %s@%s to %s", modulePath, version, imageDir)
+ if err := modules.Download(ctx, modulePath, version, imageDir, proxyClient, true); err != nil {
+ log.Debugf(ctx, "download error: %v (%[1]T)", err)
+ return nil, err
+ }
+ // Download all dependencies outside of the sandbox, but use the Go build
+ // cache inside the bundle.
+ log.Infof(ctx, "running go mod download")
+ cmd := exec.Command("go", "mod", "download")
+ cmd.Dir = imageDir
+ cmd.Env = append(cmd.Environ(),
+ "GOPROXY=https://proxy.golang.org",
+ "GOMODCACHE=/bundle/rootfs/"+sandboxGoModCache)
+ _, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("%w: 'go mod download' for %s@%s returned %s",
+ derrors.BadModule, modulePath, version, log.IncludeStderr(err))
+ }
+ log.Infof(ctx, "go mod download succeeded")
+ log.Infof(ctx, "%s@%s: running vulncheck in sandbox", modulePath, version)
+ stdout, err := sbox.Run(ctx, "/binaries/vulncheck_sandbox", "-gomodcache", "/"+sandboxGoModCache, mode, sandboxDir)
+ if err != nil {
+ return nil, errors.New(log.IncludeStderr(err))
+ }
+ return stdout, nil
+}
+
+func (s *scanner) runBinaryScanSandbox(ctx context.Context, modulePath, version, binDir string, stats *vulncheckStats) (*vulncheck.Result, error) {
+ if s.gcsBucket == nil {
+ return nil, errors.New("binary bucket not configured; set GO_ECOSYSTEM_BINARY_BUCKET")
+ }
+ // Copy the binary from GCS to the local disk, because vulncheck.Binary
+ // requires a ReaderAt and GCS doesn't provide that.
+ gcsPathname := fmt.Sprintf("%s/%s@%s/%s", binaryDir, modulePath, version, binDir)
+ const destDir = "/bundle/rootfs/binaries"
+ log.With("module", modulePath, "version", version, "dir", binDir).
+ Debugf(ctx, "copying %s to %s", gcsPathname, destDir)
+ destf, err := os.CreateTemp(destDir, "vulncheck-binary-")
+ if err != nil {
+ return nil, err
+ }
+ defer os.Remove(destf.Name())
+ if err := copyFromGCSToWriter(ctx, destf, s.gcsBucket, gcsPathname); err != nil {
+ return nil, err
+ }
+ log.Infof(ctx, "%s@%s/%s: running vulncheck in sandbox on %s", modulePath, version, binDir, destf.Name())
+ stdout, err := s.sbox.Run(ctx, "/binaries/vulncheck_sandbox", ModeBinary, destf.Name())
+ if err != nil {
+ return nil, errors.New(log.IncludeStderr(err))
+ }
+ return unmarshalVulncheckOutput(stdout)
+}
+
+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 (s *scanner) runScanModuleInsecure(ctx context.Context, modulePath, version, binaryDir, mode string, stats *vulncheckStats) (_ []*vulncheck.Vuln, err error) {
+ tempDir, err := os.MkdirTemp("", "runScanModule")
+ if err != nil {
+ return nil, err
+ }
+
+ defer func() {
+ err1 := os.RemoveAll(tempDir)
+ if err == nil {
+ err = err1
+ }
+ }()
+
+ if mode == ModeBinary {
+ return s.runBinaryScanInsecure(ctx, modulePath, version, binaryDir, tempDir, stats)
+ }
+ return s.runSourceScanInsecure(ctx, modulePath, version, mode, tempDir, stats)
+}
+
+func (s *scanner) runSourceScanInsecure(ctx context.Context, modulePath, version, mode, tempDir string, stats *vulncheckStats) ([]*vulncheck.Vuln, error) {
+ 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 {
+ if !fileExists(filepath.Join(tempDir, "go.mod")) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleLoadPackagesNoGoModError)
+ } else if !fileExists(filepath.Join(tempDir, "go.sum")) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleLoadPackagesNoGoSumError)
+ } else if isNoRequiredModule(err) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleLoadPackagesNoRequiredModuleError)
+ } else if isMissingGoSumEntry(err.Error()) {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleLoadPackagesMissingGoSumEntryError)
+ } else {
+ err = fmt.Errorf("%v: %w", err, derrors.ScanModuleLoadPackagesError)
+ }
+ return nil, err
+ }
+
+ stats.pkgsMemory = memSubtract(currHeapUsage(), preScanMemory)
+
+ // Run vulncheck.Source and collect results.
+ start := time.Now()
+ vcfg := vulncheckConfig(s.dbClient, mode)
+ res, peakMem, err := s.runWithMemoryMonitor(ctx, func() (*vulncheck.Result, error) {
+ log.Debugf(ctx, "running vulncheck.Source: %s@%s", modulePath, version)
+ res, err := vulncheck.Source(cctx, vulncheck.Convert(pkgs), vcfg)
+ log.Debugf(ctx, "completed run for vulncheck.Source: %s@%s, err=%v", modulePath, version, err)
+
+ if err != nil {
+ return res, err
+ }
+ if mode == ModeVTAStacks {
+ log.Debugf(ctx, "running vulncheck.CallStacks: %s@%s", modulePath, version)
+ vulncheck.CallStacks(res)
+ log.Debugf(ctx, "completed run for vulncheck.CallStacks: %s@%s, err=%v", modulePath, version, 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
+}
+
+// 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 (s *scanner) runBinaryScanInsecure(ctx context.Context, modulePath, version, binDir, tempDir string, stats *vulncheckStats) ([]*vulncheck.Vuln, error) {
+ if s.gcsBucket == nil {
+ return nil, errors.New("binary bucket not configured; set GO_ECOSYSTEM_BINARY_BUCKET")
+ }
+ // Copy the binary from GCS to the local disk, because vulncheck.Binary
+ // requires a ReaderAt and GCS doesn't provide that.
+ gcsPathname := fmt.Sprintf("%s/%s@%s/%s", binaryDir, modulePath, version, binDir)
+ log.With("module", modulePath, "version", version, "dir", binDir).
+ Debugf(ctx, "copying %s to temp dir", gcsPathname)
+ localPathname := filepath.Join(tempDir, "binary")
+ if err := copyFromGCS(ctx, s.gcsBucket, gcsPathname, localPathname); err != nil {
+ return nil, err
+ }
+
+ binaryFile, err := os.Open(localPathname)
+ if err != nil {
+ return nil, err
+ }
+ defer binaryFile.Close()
+
+ start := time.Now()
+ runtime.GC()
+ // current memory not related to core (go)vulncheck operations.
+ preScanMemory := currHeapUsage()
+ log.Debugf(ctx, "running vulncheck.Binary: %s", gcsPathname)
+ res, err := vulncheck.Binary(ctx, binaryFile, vulncheckConfig(s.dbClient, ModeBinary))
+ log.Debugf(ctx, "completed run for vulncheck.Binary: %s, err=%v", gcsPathname, err)
+ stats.scanSeconds = time.Since(start).Seconds()
+ // TODO: measure peak usage?
+ stats.scanMemory = memSubtract(currHeapUsage(), preScanMemory)
+ if err != nil {
+ return nil, err
+ }
+ return res.Vulns, nil
+}
+
+func copyFromGCS(ctx context.Context, bucket *storage.BucketHandle, srcPath, destPath string) (err error) {
+ defer derrors.Wrap(&err, "copyFromGCS(%q, %q)", srcPath, destPath)
+ destf, err := os.Create(destPath)
+ if err != nil {
+ return err
+ }
+ err1 := copyFromGCSToWriter(ctx, destf, bucket, srcPath)
+ err2 := destf.Close()
+ if err1 != nil {
+ return err1
+ }
+ return err2
+}
+
+func copyFromGCSToWriter(ctx context.Context, w io.Writer, bucket *storage.BucketHandle, srcPath string) error {
+ gcsReader, err := bucket.Object(srcPath).NewReader(ctx)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(w, gcsReader)
+ return err
+}
+
+// fileExists checks if file path exists. Returns true
+// if the file exists or it cannot prove that it does
+// not exist. Otherwise, returns false.
+func fileExists(file string) bool {
+ if _, err := os.Stat(file); err == nil {
+ return true
+ } else if errors.Is(err, os.ErrNotExist) {
+ return false
+ }
+ // Conservatively return true if os.Stat fails
+ // for some other reason.
+ return true
+}
+
+func isVulnDBConnection(err error) bool {
+ s := err.Error()
+ return strings.Contains(s, "https://vuln.go.dev") &&
+ strings.Contains(s, "connection")
+}
+
+func isNoRequiredModule(err error) bool {
+ return strings.Contains(err.Error(), "no required module")
+}
+
+func isMissingGoSumEntry(errMsg string) bool {
+ return strings.Contains(errMsg, "missing go.sum entry")
+}
+
+func vulncheckConfig(dbClient vulnc.Client, mode string) *vulncheck.Config {
+ cfg := &vulncheck.Config{Client: dbClient}
+ switch mode {
+ case ModeImports:
+ cfg.ImportsOnly = true
+ default:
+ cfg.ImportsOnly = false
+ }
+ 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),
+ }
+}
+
+// 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 {
+ if len(s) < 2 {
+ return 0
+ }
+ m := uint64(1)
+ if s[len(s)-1] == 'i' {
+ switch s[len(s)-2] {
+ case 'K':
+ m = 1024
+ case 'M':
+ m = 1024 * 1024
+ case 'G':
+ m = 1024 * 1024 * 1024
+ default:
+ return 0
+ }
+ s = s[:len(s)-2]
+ }
+ v, err := strconv.ParseUint(s, 10, 64)
+ if err != nil {
+ return 0
+ }
+ return v * m
+}
+
+func logMemory(ctx context.Context, prefix string) {
+ if !config.OnCloudRun() {
+ return
+ }
+
+ readIntFile := func(filename string) (int, error) {
+ data, err := os.ReadFile(filename)
+ if err != nil {
+ return 0, err
+ }
+ return strconv.Atoi(strings.TrimSpace(string(data)))
+ }
+
+ const (
+ curFilename = "/sys/fs/cgroup/memory/memory.usage_in_bytes"
+ maxFilename = "/sys/fs/cgroup/memory/memory.limit_in_bytes"
+ )
+
+ cur, err := readIntFile(curFilename)
+ if err != nil {
+ log.Errorf(ctx, "reading %s: %v", curFilename, err)
+ }
+ max, err := readIntFile(maxFilename)
+ if err != nil {
+ log.Errorf(ctx, "reading %s: %v", maxFilename, err)
+ }
+
+ const G float64 = 1024 * 1024 * 1024
+
+ log.Infof(ctx, "%s: using %.1fG out of %.1fG", prefix, float64(cur)/G, float64(max)/G)
+}
+
+func (s *scanner) cleanGoCaches(ctx context.Context) error {
+ if !config.OnCloudRun() {
+ log.Infof(ctx, "not on Cloud Run, so not cleaning caches")
+ return nil
+ }
+ var (
+ out []byte
+ err error
+ )
+ if s.insecure {
+ out, err = exec.Command("go", "clean", "-cache", "-modcache").CombinedOutput()
+ } else {
+ out, err = s.sbox.Run(ctx, "/binaries/vulncheck_sandbox", "-gomodcache", "/"+sandboxGoModCache, "-clean")
+ }
+ if err != nil {
+ return fmt.Errorf("cleaning Go caches: %s", log.IncludeStderr(err))
+ }
+ output := ""
+ if len(out) > 0 {
+ output = fmt.Sprintf(" with output %s", out)
+ }
+ log.Infof(ctx, "'go clean' succeeded%s", output)
+ return nil
+}
+
+// Test running vulncheck in the sandbox.
+// This runs a scan but returns the resulting JSON instead of writing it to BigQuery.
+func (s *Server) handleTestVulncheckSandbox(w http.ResponseWriter, r *http.Request) error {
+ ctx := r.Context()
+ sreq, err := scan.ParseRequest(r, "/test-vulncheck-sandbox")
+ if err != nil {
+ return fmt.Errorf("%w: %v", derrors.InvalidArgument, err)
+ }
+ sbox := sandbox.New("/bundle")
+ sbox.Runsc = "/usr/local/bin/runsc"
+ out, err := runSourceScanSandbox(ctx, sreq.Module, sreq.Version, ModeVTA, s.proxyClient, sbox)
+ if err != nil {
+ return err
+ }
+ _, err = w.Write(out)
+ return err
+}
diff --git a/internal/worker/vulncheck_scan_test.go b/internal/worker/vulncheck_scan_test.go
new file mode 100644
index 0000000..686b9f1
--- /dev/null
+++ b/internal/worker/vulncheck_scan_test.go
@@ -0,0 +1,146 @@
+// 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 (
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "io"
+ "testing"
+
+ "cloud.google.com/go/storage"
+ "github.com/google/go-cmp/cmp"
+ "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/vulncheck"
+)
+
+var integration = flag.Bool("integration", false, "test against actual service")
+
+func TestAsScanError(t *testing.T) {
+ check := func(err error, want bool) {
+ if got := errors.As(err, new(scanError)); got != want {
+ t.Errorf("%T: got %t, want %t", err, got, want)
+ }
+ }
+ check(io.EOF, false)
+ check(scanError{io.EOF}, true)
+}
+
+func TestRunScanModule(t *testing.T) {
+ t.Skip()
+
+ ctx := context.Background()
+ cfg, err := config.Init(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ dbClient, err := vulnc.NewClient([]string{cfg.VulnDBURL}, vulnc.Options{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ proxyClient, err := proxy.New(cfg.ProxyURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Run("source", func(t *testing.T) {
+ s := &scanner{proxyClient: proxyClient, dbClient: dbClient, insecure: true}
+ stats := &vulncheckStats{}
+ vulns, err := s.runScanModule(ctx,
+ "golang.org/x/exp/event", "v0.0.0-20220929112958-4a82f8963a65",
+ "", ModeVTA, stats)
+ if err != nil {
+ t.Fatal(err)
+ }
+ wantID := "GO-2022-0493"
+ found := false
+ for _, v := range vulns {
+ if v.ID == wantID {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("want %s, did not find it in %d vulns", wantID, len(vulns))
+ }
+ 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}
+ _, err := s.runScanModule(ctx, "golang.org/x/mod", "v0.5.1",
+ "", ModeVTA, &vulncheckStats{})
+ if !errors.Is(err, derrors.ScanModuleMemoryLimitExceeded) {
+ t.Errorf("got %v, want MemoryLimitExceeded", err)
+ }
+ })
+ t.Run("binary", func(t *testing.T) {
+ if !*integration { // needs GCS read permission, not available on kokoro
+ t.Skip("missing -integration")
+ }
+ s := &scanner{proxyClient: proxyClient, dbClient: dbClient}
+ gcsClient, err := storage.NewClient(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.gcsBucket = gcsClient.Bucket("go-ecosystem")
+ stats := &vulncheckStats{}
+ vulns, err := s.runScanModule(ctx, "golang.org/x/pkgsite", "v0.0.0-20221004150836-873fb37c2479", "cmd/worker", ModeBinary, stats)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if g, w := len(vulns), 14; g != w {
+ t.Errorf("got %d vulns, want %d", g, w)
+ }
+ })
+}
+
+func TestParseGoMemLimit(t *testing.T) {
+ for _, test := range []struct {
+ in string
+ want uint64
+ }{
+ {"", 0},
+ {"foo", 0},
+ {"23", 23},
+ {"56Ki", 56 * 1024},
+ {"3Mi", 3 * 1024 * 1024},
+ {"8Gi", 8 * 1024 * 1024 * 1024},
+ } {
+ got := parseGoMemLimit(test.in)
+ if got != test.want {
+ t.Errorf("%q: got %d, want %d", test.in, got, test.want)
+ }
+ }
+}
+
+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)
+ }
+}
diff --git a/internal/worker/vulncheck_test.go b/internal/worker/vulncheck_test.go
new file mode 100644
index 0000000..8159c12
--- /dev/null
+++ b/internal/worker/vulncheck_test.go
@@ -0,0 +1,41 @@
+// 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 (
+ "context"
+ "testing"
+
+ "github.com/google/go-cmp/cmp"
+ "golang.org/x/pkgsite-metrics/internal/bigquery"
+)
+
+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"}
+
+ rows := []*bigquery.VulnResult{
+ {ModulePath: "m1", ScanMode: ModeImports, Vulns: []*bigquery.Vuln{vuln1A, vuln1B, vuln1C, vuln2A}},
+ {ModulePath: "m1", ScanMode: ModeVTA, Vulns: []*bigquery.Vuln{vuln1A, vuln1B}},
+ {ModulePath: "m2", ScanMode: ModeImports, Vulns: []*bigquery.Vuln{vuln2A}},
+ {ModulePath: "m2", ScanMode: ModeVTA, Vulns: []*bigquery.Vuln{}},
+ }
+
+ got := handleVulncheckRows(context.Background(), p, rows)
+ // Vuln 1 is detected in m1 module in both IMPORTS and VTA modes.
+ // Vuln 2 is detected in m1 and m2 in IMPORTS mode, but nowhere
+ // in VTA mode.
+ want := map[string]*ReportResult{
+ "1": &ReportResult{ImportsNumModules: 1, VTANumModules: 1},
+ "2": &ReportResult{ImportsNumModules: 2, VTANumModules: 0},
+ }
+ if diff := cmp.Diff(want, got); diff != "" {
+ t.Errorf("mismatch (-want, +got):\n%s", diff)
+ }
+}
diff --git a/static/vulncheck.tmpl b/static/vulncheck.tmpl
new file mode 100644
index 0000000..f023c2b
--- /dev/null
+++ b/static/vulncheck.tmpl
@@ -0,0 +1,159 @@
+<!--
+ 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.
+-->
+
+<!DOCTYPE html>
+<html lang="en">
+<meta charset="utf-8">
+<link href="/static/static.css" rel="stylesheet">
+<title>Go Vulnerability Management - Metrics</title>
+
+<body>
+<main class="go-Container">
+<div class="go-Content">
+ <h1>Go Vulnerability Management - Metrics</h1>
+ <div class="Container">
+ <p>Results are from {{.TableName}}</p>
+ {{template "vulncheck" .}}
+ </div>
+</div>
+</main>
+</body>
+</html>
+
+{{define "vulncheck"}}
+{{/* . is VulncheckPage */}}
+<div>
+ {{template "corpus" .}}
+ {{template "errors" .}}
+ {{template "vulnerabilities" .}}
+</div>
+{{end}}
+
+{{define "corpus"}}
+{{/* . is VulncheckPage */}}
+<div>
+ <h2>Scan Results</h2>
+ <table>
+ <tbody>
+ <tr>
+ <th>Mode</th>
+ <th>Imports Only</th>
+ <th>VTA</th>
+ <th>VTA - Stacks</th>
+ </tr>
+ <tr>
+ <td># Modules Scanned</td>
+ <td>{{.ImportsResult.NumModulesScanned}}</td>
+ <td>{{.VTAResult.NumModulesScanned}}</td>
+ <td>{{.VTAStacksResult.NumModulesScanned}}</td>
+ </tr>
+ <tr>
+ <td># Modules Success</td>
+ <td>{{.ImportsResult.NumModulesSuccess}}</td>
+ <td>{{.VTAResult.NumModulesSuccess}}</td>
+ <td>{{.VTAStacksResult.NumModulesSuccess}}</td>
+ </tr>
+ <tr>
+ <td>% Modules Success</td>
+ <td>{{round .ImportsResult.PercentSuccess}}%</td>
+ <td>{{round .VTAResult.PercentSuccess}}%</td>
+ <td>{{round .VTAStacksResult.PercentSuccess}}%</td>
+ </tr>
+ <tr>
+ <td># Modules Failed</td>
+ <td>{{.ImportsResult.NumModulesError}}</td>
+ <td>{{.VTAResult.NumModulesError}}</td>
+ <td>{{.VTAStacksResult.NumModulesError}}</td>
+ </tr>
+ <tr>
+ <td>Avg Scan Seconds</td>
+ <td>{{round .ImportsResult.AverageScanSeconds}}s</td>
+ <td>{{round .VTAResult.AverageScanSeconds}}s</td>
+ <td>{{round .VTAStacksResult.AverageScanSeconds}}s</td>
+ </tr>
+ <tr>
+ <td>Max Scan Seconds</td>
+ <td>{{round .ImportsResult.MaxScanSeconds}}s</td>
+ <td>{{round .VTAResult.MaxScanSeconds}}s</td>
+ <td>{{round .VTAStacksResult.MaxScanSeconds}}s</td>
+ </tr>
+ <tr>
+ <td>Avg Scan Memory (MB)</td>
+ <td>{{round .ImportsResult.AverageScanMemory}}MB</td>
+ <td>{{round .VTAResult.AverageScanMemory}}MB</td>
+ <td>{{round .VTAStacksResult.AverageScanMemory}}MB</td>
+ </tr>
+ <tr>
+ <td>Max Scan Memory (MB)</td>
+ <td>{{round .ImportsResult.MaxScanMemory}}MB</td>
+ <td>{{round .VTAResult.MaxScanMemory}}MB</td>
+ <td>{{round .VTAStacksResult.MaxScanMemory}}MB</td>
+ </tr>
+ </tbody>
+ </table>
+</div>
+{{end}}
+
+{{define "vulnerabilities"}}
+{{/* . is VulncheckPage */}}
+<div>
+ <h2>Vulnerabilities</h2>
+ <p><strong>VTA: {{.VTAResult.NumModulesSuccess}}</strong> scans succeeded (<strong>{{round .VTAResult.PercentSuccess}}%</strong>)</p>
+ <p><strong>Imports: {{.ImportsResult.NumModulesSuccess}}</strong> scans succeeded (<strong>{{round .ImportsResult.PercentSuccess}}%</strong>)</p>
+ <div>
+ <table>
+ <tbody>
+ <tr>
+ <th>Category</th>
+ <th>VTA</br># modules calls</th>
+ <th>Imports Only</br># modules imports</th>
+ <th>VTA</br>% of success</th>
+ <th>Imports Only</br>% of success</th>
+ </tr>
+ <tr>
+ <td>0 vulns</td>
+ <td>{{.VTAResult.NumModulesNoVuln}}</td>
+ <td>{{.ImportsResult.NumModulesNoVuln}}</td>
+ <td>{{round .VTAResult.PercentNoVuln}}%</td>
+ <td>{{round .ImportsResult.PercentNoVuln}}%</td>
+ </tr>
+ <tr>
+ <td>1+ vulns</td>
+ <td>{{.VTAResult.NumModulesVuln}}</td>
+ <td>{{.ImportsResult.NumModulesVuln}}</td>
+ <td>{{round .VTAResult.PercentVuln}}%</td>
+ <td>{{round .ImportsResult.PercentVuln}}%</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+</div>
+{{end}}
+
+{{define "errors"}}
+{{/* . is VulncheckPage */}}
+<div>
+ <h2>Errors</h2>
+ <p><strong>VTA: {{.VTAResult.NumModulesError}}</strong> scans failed (<strong>{{round .VTAResult.PercentFailed}}%</strong>)</p>
+ <p><strong>Imports: {{.ImportsResult.NumModulesError}}</strong> scans failed (<strong>{{round .ImportsResult.PercentFailed}}%</strong>)</p>
+ <table>
+ <tbody>
+ <tr>
+ <th>Category</th>
+ <th>VTA</br># Modules</th>
+ <th>Imports Only</br># Modules</th>
+ </tr>
+ {{range .Errors}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td>{{.VTANumModules}}</td>
+ <td>{{.ImportsNumModules}}</td>
+ </tr>
+ {{end}}
+ </tbody>
+ </table>
+</div>
+{{end}}