cmd/govulncheck_compare: add govulncheck_compare binary

Adds the govulncheck_compare binary for comparing binary and source mode
scanning with govulncheck in the sandbox for ecosystem metrics.

Change-Id: I111810879f010d48a6fd8957c672a18c7bc25ee9
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/509357
Reviewed-by: Jonathan Amsterdam <jba@google.com>
Run-TryBot: Maceo Thompson <maceothompson@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
diff --git a/cmd/govulncheck_compare/govulncheck_compare.go b/cmd/govulncheck_compare/govulncheck_compare.go
new file mode 100644
index 0000000..5516e6b
--- /dev/null
+++ b/cmd/govulncheck_compare/govulncheck_compare.go
@@ -0,0 +1,104 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// This program finds all binaries that can be compiled in a module, then runs
+// govulncheck on the module and the subpackages that are used for the binaries,
+// as well as the binaries themselves (for comparison). It then writes the results
+// 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 internal/govulncheck.CompareResponse as JSON.
+
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+
+	"golang.org/x/exp/slices"
+	"golang.org/x/pkgsite-metrics/internal/buildbinary"
+	"golang.org/x/pkgsite-metrics/internal/govulncheck"
+)
+
+// govulncheck compare accepts three inputs in the following order
+//   - path to govulncheck
+//   - input module to scan
+//   - full path to the vulnerability database
+func main() {
+	flag.Parse()
+	run(os.Stdout, flag.Args())
+}
+
+func run(w io.Writer, args []string) {
+	fail := func(err error) {
+		fmt.Fprintf(w, `{"Error": %q}`, err)
+		fmt.Fprintln(w)
+	}
+	if len(args) != 3 {
+		fail(errors.New("need three args: govulncheck path, input module dir, full path to vuln db"))
+		return
+	}
+	govulncheckPath := args[0]
+	modulePath := args[1]
+	vulndbPath := args[2]
+
+	response := govulncheck.CompareResponse{
+		FindingsForMod: make(map[string]*govulncheck.ComparePair),
+	}
+
+	binaryPaths, err := buildbinary.FindAndBuildBinaries(modulePath)
+	if err != nil {
+		fail(err)
+		return
+	}
+	defer removeBinaries(binaryPaths)
+
+	// Sort binaryPath keys so that range is deterministic
+	keys := make([]string, 0, len(binaryPaths))
+	for k := range binaryPaths {
+		keys = append(keys, k)
+	}
+	slices.Sort(keys)
+
+	for _, binaryPath := range keys {
+		importPath := binaryPaths[binaryPath]
+		pair := govulncheck.ComparePair{
+			BinaryResults: govulncheck.SandboxResponse{Stats: govulncheck.ScanStats{}},
+			SourceResults: govulncheck.SandboxResponse{Stats: govulncheck.ScanStats{}},
+		}
+
+		pair.SourceResults.Findings, err = govulncheck.RunGovulncheckCmd(govulncheckPath, govulncheck.FlagSource, importPath, modulePath, vulndbPath, &pair.SourceResults.Stats)
+		if err != nil {
+			fail(err)
+			return
+		}
+
+		pair.BinaryResults.Findings, err = govulncheck.RunGovulncheckCmd(govulncheckPath, govulncheck.FlagBinary, binaryPath, modulePath, vulndbPath, &pair.BinaryResults.Stats)
+		if err != nil {
+			fail(err)
+			return
+		}
+
+		response.FindingsForMod[importPath] = &pair
+	}
+
+	b, err := json.MarshalIndent(response, "", "\t")
+	if err != nil {
+		fail(err)
+		return
+	}
+
+	w.Write(b)
+	fmt.Println()
+}
+
+func removeBinaries(binaryPaths map[string]string) {
+	for path := range binaryPaths {
+		os.Remove(path)
+	}
+}
diff --git a/cmd/govulncheck_compare/govulncheck_compare_test.go b/cmd/govulncheck_compare/govulncheck_compare_test.go
new file mode 100644
index 0000000..3b001c8
--- /dev/null
+++ b/cmd/govulncheck_compare/govulncheck_compare_test.go
@@ -0,0 +1,62 @@
+// Copyright 2023 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Tests for the govulncheck_compare binary
+
+package main
+
+import (
+	"bytes"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"golang.org/x/pkgsite-metrics/internal/buildtest"
+	"golang.org/x/pkgsite-metrics/internal/govulncheck"
+)
+
+func Test(t *testing.T) {
+	// TODO: Modify test to ensure that govulncheck & the built binaries are all
+	// built with the same version of go. Test currently fails on cloudtop machines
+	// because go versions are different.
+	// govulncheck_compare works in integration testing, as binaries are built in
+	// the sandbox which ensures that the go versions are the same
+	t.Skip("Govulncheck fails on binaries built with Go versions 12.1+, which cloudtop is ran on")
+	if runtime.GOOS == "windows" {
+		t.Skip("cannot run on Windows")
+	}
+
+	govulncheckPath, err := buildtest.BuildGovulncheck(t.TempDir())
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	testData := "../../internal/testdata"
+	module := filepath.Join(testData, "module")
+
+	// govulncheck binary requires a full path to the vuln db. Otherwise, one
+	// gets "[file://testdata/vulndb], opts): file URL specifies non-local host."
+	vulndb, err := filepath.Abs(filepath.Join(testData, "vulndb"))
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	t.Run("basicComparison", func(t *testing.T) {
+		resp, err := runTest([]string{govulncheckPath, module, vulndb})
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		pair := resp.FindingsForMod["golang.org/vuln"]
+		t.Log(pair)
+		// TODO: concretely test that the results are as expected.
+	})
+
+}
+
+func runTest(args []string) (*govulncheck.CompareResponse, error) {
+	var buf bytes.Buffer
+	run(&buf, args)
+	return govulncheck.UnmarshalCompareResponse(buf.Bytes())
+}
diff --git a/cmd/worker/Dockerfile b/cmd/worker/Dockerfile
index 7a70bc1..9dfec2b 100644
--- a/cmd/worker/Dockerfile
+++ b/cmd/worker/Dockerfile
@@ -95,6 +95,9 @@
 # Build the program that runs govulncheck inside the sandbox.
 RUN go build -mod=readonly -o $BINARY_DIR/govulncheck_sandbox ./cmd/govulncheck_sandbox
 
+# Build the program that runs govulncheck comparisons inside the sandbox
+RUN go build -mod=readonly -o $BINARY_DIR/govulncheck_compare ./cmd/govulncheck_compare
+
 # Build the sandbox runner program and put it in the bundle root.
 RUN go build -mod=readonly -o /bundle/rootfs/runner ./internal/sandbox/runner.go
 
diff --git a/internal/govulncheck/govulncheck.go b/internal/govulncheck/govulncheck.go
index 9397f6f..142a054 100644
--- a/internal/govulncheck/govulncheck.go
+++ b/internal/govulncheck/govulncheck.go
@@ -272,6 +272,31 @@
 	return &res, nil
 }
 
+type CompareResponse struct {
+	// Map from package import path to pair of binary & source mode findings
+	FindingsForMod map[string]*ComparePair
+}
+
+type ComparePair struct {
+	BinaryResults SandboxResponse
+	SourceResults SandboxResponse
+}
+
+func UnmarshalCompareResponse(output []byte) (*CompareResponse, 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 CompareResponse
+	if err := json.Unmarshal(output, &res); err != nil {
+		return nil, err
+	}
+	return &res, nil
+}
+
 func RunGovulncheckCmd(govulncheckPath, modeFlag, pattern, moduleDir, vulndbDir string, stats *ScanStats) ([]*govulncheckapi.Finding, error) {
 	stdOut := bytes.Buffer{}
 	stdErr := bytes.Buffer{}