cmd/vulncheck_sanbox: add

Add the vulncheck_sandbox command, used to run vulncheck
in a sandbox.

Change-Id: Id3edc9b1facb05b01bc5f1e2955c218f8e6f6c77
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/467716
Reviewed-by: Julie Qiu <julieqiu@google.com>
Run-TryBot: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/vulncheck_sandbox/lmt_client.go b/cmd/vulncheck_sandbox/lmt_client.go
new file mode 100644
index 0000000..e6af88f
--- /dev/null
+++ b/cmd/vulncheck_sandbox/lmt_client.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"strings"
+	"time"
+
+	vulnc "golang.org/x/vuln/client"
+	"golang.org/x/vuln/osv"
+)
+
+// A LocalLMTClient behaves exactly like a client that has a local source,
+// except that it reads the last modified time from a separate file, LAST_MODIFIED,
+// instead of using index.json's modified time.
+func NewLocalLMTClient(dir string) (vulnc.Client, error) {
+	dbpath, err := filepath.Abs(dir)
+	if err != nil {
+		return nil, err
+	}
+	c, err := vulnc.NewClient([]string{"file://" + dbpath}, vulnc.Options{})
+	if err != nil {
+		return nil, err
+	}
+	return &lmtClient{c: c, dir: dir}, nil
+}
+
+type lmtClient struct {
+	vulnc.Client
+	c   vulnc.Client
+	dir string
+}
+
+func (c *lmtClient) GetByModule(ctx context.Context, mv string) ([]*osv.Entry, error) {
+	return c.c.GetByModule(ctx, mv)
+}
+
+func (c *lmtClient) GetByID(ctx context.Context, id string) (*osv.Entry, error) {
+
+	return c.c.GetByID(ctx, id)
+}
+func (c *lmtClient) GetByAlias(ctx context.Context, alias string) ([]*osv.Entry, error) {
+	return c.c.GetByAlias(ctx, alias)
+}
+
+func (c *lmtClient) ListIDs(ctx context.Context) ([]string, error) {
+	return c.c.ListIDs(ctx)
+}
+func (c *lmtClient) LastModifiedTime(context.Context) (time.Time, error) {
+	return readLastModifiedTime(c.dir)
+}
+
+func readLastModifiedTime(dir string) (time.Time, error) {
+	data, err := os.ReadFile(filepath.Join(dir, "LAST_MODIFIED"))
+	if err != nil {
+		return time.Time{}, err
+	}
+	const timeFormat = "02 Jan 2006 15:04:05 GMT"
+	return time.Parse(timeFormat, strings.TrimSpace(string(data)))
+}
diff --git a/cmd/vulncheck_sandbox/testdata/module/go.mod b/cmd/vulncheck_sandbox/testdata/module/go.mod
new file mode 100644
index 0000000..d8a8a70
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/module/go.mod
@@ -0,0 +1,6 @@
+module golang.org/vuln
+
+go 1.18
+
+// This version has a vulnerability that is called.
+require golang.org/x/text v0.3.0
diff --git a/cmd/vulncheck_sandbox/testdata/module/go.sum b/cmd/vulncheck_sandbox/testdata/module/go.sum
new file mode 100644
index 0000000..6bad37b
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/module/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
diff --git a/cmd/vulncheck_sandbox/testdata/module/vuln.go b/cmd/vulncheck_sandbox/testdata/module/vuln.go
new file mode 100644
index 0000000..38ef771
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/module/vuln.go
@@ -0,0 +1,7 @@
+package main
+
+import "golang.org/x/text/language"
+
+func main() {
+	language.Parse("")
+}
diff --git a/cmd/vulncheck_sandbox/testdata/vulndb/golang.org/x/text.json b/cmd/vulncheck_sandbox/testdata/vulndb/golang.org/x/text.json
new file mode 100644
index 0000000..e9d55a3
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/vulndb/golang.org/x/text.json
@@ -0,0 +1 @@
+[{"id":"GO-2020-0015","published":"2021-04-14T20:04:52Z","modified":"2021-06-07T12:00:00Z","aliases":["CVE-2020-14040","GHSA-5rcv-m4m3-hfh7"],"details":"An attacker could provide a single byte to a UTF16 decoder instantiated with\nUseBOM or ExpectBOM to trigger an infinite loop if the String function on\nthe Decoder is called, or the Decoder is passed to transform.String.\nIf used to parse user supplied input, this may be used as a denial of service\nvector.\n","affected":[{"package":{"name":"golang.org/x/text","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.3.3"}]}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2020-0015"},"ecosystem_specific":{"imports":[{"path":"golang.org/x/text/encoding/unicode","symbols":["bomOverride.Transform","utf16Decoder.Transform"]},{"path":"golang.org/x/text/transform","symbols":["Transform"]}]}}],"references":[{"type":"FIX","url":"https://go.dev/cl/238238"},{"type":"FIX","url":"https://go.googlesource.com/text/+/23ae387dee1f90d29a23c0e87ee0b46038fbed0e"},{"type":"WEB","url":"https://go.dev/issue/39491"},{"type":"WEB","url":"https://groups.google.com/g/golang-announce/c/bXVeAmGOqz0"},{"type":"WEB","url":"https://nvd.nist.gov/vuln/detail/CVE-2020-14040"},{"type":"WEB","url":"https://github.com/advisories/GHSA-5rcv-m4m3-hfh7"}]},{"id":"GO-2021-0113","published":"2021-10-06T17:51:21Z","modified":"2021-10-06T17:51:21Z","aliases":["CVE-2021-38561"],"details":"Due to improper index calculation, an incorrectly formatted language tag can cause Parse\nto panic via an out of bounds read. If Parse is used to process untrusted user inputs,\nthis may be used as a vector for a denial of service attack.\n","affected":[{"package":{"name":"golang.org/x/text","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.3.7"}]}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2021-0113"},"ecosystem_specific":{"imports":[{"path":"golang.org/x/text/language","symbols":["MatchStrings","MustParse","Parse","ParseAcceptLanguage"]}]}}],"references":[{"type":"FIX","url":"https://go.dev/cl/340830"},{"type":"FIX","url":"https://go.googlesource.com/text/+/383b2e75a7a4198c42f8f87833eefb772868a56f"},{"type":"WEB","url":"https://nvd.nist.gov/vuln/detail/CVE-2021-38561"}]}]
\ No newline at end of file
diff --git a/cmd/vulncheck_sandbox/testdata/vulndb/index.json b/cmd/vulncheck_sandbox/testdata/vulndb/index.json
new file mode 100644
index 0000000..0792fcd
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/vulndb/index.json
@@ -0,0 +1 @@
+{"stdlib":"2022-09-20T15:16:04Z","golang.org/x/text":"2022-09-20T15:16:04Z"}
diff --git a/cmd/vulncheck_sandbox/testdata/vulndb/stdlib.json b/cmd/vulncheck_sandbox/testdata/vulndb/stdlib.json
new file mode 100644
index 0000000..7a1de40
--- /dev/null
+++ b/cmd/vulncheck_sandbox/testdata/vulndb/stdlib.json
@@ -0,0 +1 @@
+[{"id":"STD","affected":[{"package":{"name":"stdlib"},"ranges":[{"type":"SEMVER","events":[{"introduced":"1.18.0"}]}],"ecosystem_specific":{"imports":[{"path":"archive/zip","symbols":["OpenReader"]}]}}]},{"id":"GO-2022-0969","published":"2022-09-12T20:23:06Z","modified":"2022-09-12T20:23:06Z","aliases":["CVE-2022-27664"],"details":"HTTP/2 server connections can hang forever waiting for a clean shutdown\nthat was preempted by a fatal error. This condition can be exploited\nby a malicious client to cause a denial of service.\n","affected":[{"package":{"name":"stdlib","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"1.18.6"},{"introduced":"1.19.0"},{"fixed":"1.19.1"}]}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2022-0969"},"ecosystem_specific":{"imports":[{"path":"net/http","symbols":["ListenAndServe","ListenAndServeTLS","Serve","ServeTLS","Server.ListenAndServe","Server.ListenAndServeTLS","Server.Serve","Server.ServeTLS","http2Server.ServeConn","http2serverConn.goAway"]}]}},{"package":{"name":"golang.org/x/net","ecosystem":"Go"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"0.0.0-20220906165146-f3363e06e74c"}]}],"database_specific":{"url":"https://pkg.go.dev/vuln/GO-2022-0969"},"ecosystem_specific":{"imports":[{"path":"golang.org/x/net/http2","symbols":["Server.ServeConn","serverConn.goAway"]}]}}],"references":[{"type":"WEB","url":"https://groups.google.com/g/golang-announce/c/x49AQzIVX-s"},{"type":"REPORT","url":"https://go.dev/issue/54658"},{"type":"FIX","url":"https://go.dev/cl/428735"}]}]
diff --git a/cmd/vulncheck_sandbox/vulncheck_sandbox.go b/cmd/vulncheck_sandbox/vulncheck_sandbox.go
new file mode 100644
index 0000000..700cfca
--- /dev/null
+++ b/cmd/vulncheck_sandbox/vulncheck_sandbox.go
@@ -0,0 +1,134 @@
+// 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.
+
+// This program runs vulncheck.Source or vulncheck.Binary on a module, then
+// writes the result as JSON. It is intended to be run in a sandbox.
+//
+// Unless it panics, this program always terminates with exit code 0.
+// If there is an error, it writes a JSON object with field "Error".
+// Otherwise, it writes a vulncheck.Result as JSON.
+package main
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+
+	"golang.org/x/pkgsite-metrics/internal/load"
+	"golang.org/x/pkgsite-metrics/internal/worker"
+	"golang.org/x/vuln/vulncheck"
+)
+
+var (
+	// vulnDBDir should contain a local copy of the vuln DB, with a LAST_MODIFIED
+	// file containing a timestamp.
+	vulnDBDir = flag.String("vulndb", "/go-vulndb", "directory of local vuln DB")
+
+	modCacheDir = flag.String("gomodcache", "", "override GOMODCACHE env var")
+
+	clean = flag.Bool("clean", false, "clean caches instead of running a module")
+)
+
+func main() {
+	flag.Parse()
+	if *modCacheDir != "" {
+		// Change the location of the module cache.
+		os.Setenv("GOMODCACHE", *modCacheDir)
+	}
+	if *clean {
+		cleanGoCaches()
+	} else {
+		run(os.Stdout, flag.Args(), *vulnDBDir)
+	}
+}
+
+func run(w io.Writer, args []string, vulnDBDir string) {
+
+	fail := func(err error) {
+		fmt.Fprintf(w, `{"Error": %q}`, err)
+		fmt.Fprintln(w)
+	}
+
+	res, err := runVulncheck(context.Background(), args, vulnDBDir)
+	if err != nil {
+		fail(err)
+		return
+	}
+	b, err := json.MarshalIndent(res, "", "\t")
+	if err != nil {
+		fail(fmt.Errorf("json.MarshalIndent: %v", err))
+		return
+	}
+	w.Write(b)
+	fmt.Println()
+}
+
+func runVulncheck(ctx context.Context, args []string, vulnDBDir string) (*vulncheck.Result, error) {
+	if len(args) != 2 {
+		return nil, errors.New("need two args: mode, and module dir or binary")
+	}
+	mode := args[0]
+	if !worker.IsValidVulncheckMode(mode) {
+		return nil, fmt.Errorf("%q is not a valid mode", mode)
+	}
+	dbClient, err := NewLocalLMTClient(vulnDBDir)
+	if err != nil {
+		return nil, fmt.Errorf("NewLocalLMTClient: %v", err)
+	}
+	vcfg := &vulncheck.Config{
+		Client:      dbClient,
+		ImportsOnly: mode == worker.ModeImports,
+	}
+
+	if mode == worker.ModeBinary {
+		binaryFilePath := args[1]
+		binaryFile, err := os.Open(binaryFilePath)
+		if err != nil {
+			return nil, err
+		}
+		defer binaryFile.Close()
+		return vulncheck.Binary(ctx, binaryFile, vcfg)
+	}
+	moduleDir := args[1]
+	// Load all the packages in moduleDir.
+	cfg := load.DefaultConfig()
+	cfg.Dir = moduleDir
+	cfg.Logf = log.Printf
+	pkgs, pkgErrors, err := load.Packages(cfg, "./...")
+	if err == nil && len(pkgErrors) > 0 {
+		err = fmt.Errorf("%v", pkgErrors)
+	}
+	if err != nil {
+		return nil, fmt.Errorf("loading packages: %v", err)
+	}
+	if len(pkgs) == 0 {
+		return nil, fmt.Errorf("no packages in %s", moduleDir)
+	}
+
+	res, err := vulncheck.Source(ctx, vulncheck.Convert(pkgs), vcfg)
+	if err != nil {
+		return nil, err
+	}
+	if mode == worker.ModeVTAStacks {
+		// Do this for effect only, to measure resources consumed.
+		_ = vulncheck.CallStacks(res)
+	}
+	return res, nil
+
+}
+
+func cleanGoCaches() {
+	_, err := exec.Command("go", "clean", "-cache", "-modcache").CombinedOutput()
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "%s\n", err)
+		os.Exit(1)
+	}
+	fmt.Printf("go clean succeeded in sandbox\n")
+}
diff --git a/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go b/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go
new file mode 100644
index 0000000..06ba927
--- /dev/null
+++ b/cmd/vulncheck_sandbox/vulncheck_sandbox_test.go
@@ -0,0 +1,125 @@
+// Copyright 2022 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"errors"
+	"os"
+	"os/exec"
+	"runtime"
+	"strings"
+	"testing"
+
+	"golang.org/x/exp/slices"
+	"golang.org/x/pkgsite-metrics/internal/log"
+	"golang.org/x/pkgsite-metrics/internal/worker"
+	"golang.org/x/vuln/vulncheck"
+)
+
+func Test(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("cannot run on Windows")
+	}
+
+	checkVuln := func(t *testing.T, res *vulncheck.Result) {
+		wantID := "GO-2021-0113"
+		i := slices.IndexFunc(res.Vulns, func(v *vulncheck.Vuln) bool {
+			return v.OSV.ID == wantID
+		})
+		if i < 0 {
+			t.Fatalf("no vuln with ID %s. Result:\n%+v", wantID, res)
+		}
+	}
+
+	t.Run("source", func(t *testing.T) {
+		res, err := runTest([]string{worker.ModeVTA, "testdata/module"}, "testdata/vulndb")
+		if err != nil {
+			t.Fatal(err)
+		}
+		checkVuln(t, res)
+	})
+
+	t.Run("binary", func(t *testing.T) {
+		t.Skip("vulncheck.Binary may not support the Go version")
+		const binary = "testdata/module/vuln"
+		if _, err := exec.Command("/bin/sh", "-c", "cd testdata/module && go build").Output(); err != nil {
+			t.Fatal(log.IncludeStderr(err))
+		}
+		defer os.Remove(binary)
+		res, err := runTest([]string{worker.ModeBinary, binary}, "testdata/vulndb")
+		if err != nil {
+			t.Fatal(err)
+		}
+		checkVuln(t, res)
+	})
+
+	// Errors
+	for _, test := range []struct {
+		name   string
+		args   []string
+		vulndb string
+		want   string
+	}{
+		{
+			name:   "too few args",
+			args:   []string{"testdata/module"},
+			vulndb: "testdata/vulndb",
+			want:   "need two args",
+		},
+		{
+			name:   "no vulndb",
+			args:   []string{worker.ModeVTA, "testdata/module"},
+			vulndb: "does not exist",
+			want:   "no such file",
+		},
+		{
+			name:   "no mode",
+			args:   []string{"MODE", "testdata/module"},
+			vulndb: "testdata/vulndb",
+			want:   "not a valid mode",
+		},
+		{
+			name:   "no module",
+			args:   []string{worker.ModeVTA, "testdata/nosuchmodule"},
+			vulndb: "testdata/vulndb",
+			want:   "no such file",
+		},
+	} {
+		t.Run(test.name, func(t *testing.T) {
+			_, err := runTest(test.args, test.vulndb)
+			if err == nil {
+				t.Fatal("got nil, want error")
+			}
+			if g, w := err.Error(), test.want; !strings.Contains(g, w) {
+				t.Fatalf("error %q does not contain %q", g, w)
+			}
+		})
+	}
+}
+
+func runTest(args []string, vulndbDir string) (*vulncheck.Result, error) {
+	var buf bytes.Buffer
+	run(&buf, args, vulndbDir)
+	return unmarshalVulncheckOutput(buf.Bytes())
+}
+
+func unmarshalVulncheckOutput(output []byte) (*vulncheck.Result, error) {
+	var e struct {
+		Error string
+	}
+	if err := json.Unmarshal(output, &e); err != nil {
+		return nil, err
+	}
+	if e.Error != "" {
+		return nil, errors.New(e.Error)
+	}
+	var res vulncheck.Result
+	if err := json.Unmarshal(output, &res); err != nil {
+		return nil, err
+	}
+	return &res, nil
+}