internal/sandbox: add package

Package sandbox is added to allow the worker to run go tools and
analyses in a sandbox.

This package exports the Run function, which can run any program
with arguments inside a sandbox.

The sandbox is established with gvisor's runsc program.
For more on gvisor, see https://gvisor.dev.

The testdata directory holds a minimal bundle for testing.

Because the test requires some setup, and must be run as root,
there is a Makefile that does all the work. Test this package
using `make`, not `go test`.

Change-Id: I797b711a087acc91932964c1b03e1352500a79e4
Reviewed-on: https://go-review.googlesource.com/c/pkgsite-metrics/+/464620
Reviewed-by: Jonathan Amsterdam <jba@google.com>
TryBot-Result: Gopher Robot <gobot@golang.org>
Reviewed-by: Julie Qiu <julieqiu@google.com>
Auto-Submit: Julie Qiu <julieqiu@google.com>
Run-TryBot: Julie Qiu <julieqiu@google.com>
diff --git a/internal/sandbox/Makefile b/internal/sandbox/Makefile
new file mode 100644
index 0000000..93e91fc
--- /dev/null
+++ b/internal/sandbox/Makefile
@@ -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.
+
+# Makefile for the sandbox package.
+# `make` will build and install binaries needed for the test, run the test as root,
+# then clean up.
+
+default: test clean
+
+test: /usr/local/bin/runsc testbundle
+	sudo $(shell which go) test -v
+
+
+# Release version must match the one in cmd/worker/Dockerfile.
+RUNSC_URL := https://storage.googleapis.com/gvisor/releases/release/20221107.0/$(shell uname -m)
+
+# This is an edited version of the commands at https://gvisor.dev/docs/user_guide/install.
+/usr/local/bin/runsc:
+	wget $(RUNSC_URL)/runsc $(RUNSC_URL)/runsc.sha512
+	sha512sum -c runsc.sha512
+	rm -f *.sha512
+	chmod a+rx runsc
+	sudo mv runsc /usr/local/bin
+
+testbundle: testdata/bundle/rootfs/runner testdata/bundle/rootfs/printargs
+
+testdata/bundle/rootfs/runner: runner.go
+	go build -o $@ $<
+	chmod o+rx $@
+
+testdata/bundle/rootfs/printargs: testdata/printargs.go
+	go build -o $@ $<
+	chmod o+rx $@
+
+clean:
+	rm testdata/bundle/rootfs/runner
+	rm testdata/bundle/rootfs/printargs
+
+.PHONY: clean testbundle
+
diff --git a/internal/sandbox/runner.go b/internal/sandbox/runner.go
new file mode 100644
index 0000000..d76b88f
--- /dev/null
+++ b/internal/sandbox/runner.go
@@ -0,0 +1,52 @@
+// 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.
+
+//go:build ignore
+
+// This program runs another program provided on standard input,
+// prints its standard output, then terminates. It logs to stderr.
+//
+// It first reads all of standard input, then splits it into words on
+// whitespace. It treats the first word as the program path and the rest as
+// arguments.
+package main
+
+import (
+	"bytes"
+	"errors"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+func main() {
+	log.SetOutput(os.Stderr)
+	log.SetPrefix("runner: ")
+	log.Print("starting")
+	in, err := io.ReadAll(os.Stdin)
+	if err != nil {
+		log.Fatal(err)
+	}
+	log.Printf("read %q", in)
+	args := strings.Fields(string(in))
+	if len(args) == 0 {
+		log.Fatal("no args")
+	}
+	cmd := exec.Command(args[0], args[1:]...)
+	out, err := cmd.Output()
+	if err != nil {
+		s := err.Error()
+		var eerr *exec.ExitError
+		if errors.As(err, &eerr) {
+			s += ": " + string(bytes.TrimSpace(eerr.Stderr))
+		}
+		log.Fatalf("%s failed with %s", args[0], s)
+	}
+	if _, err := os.Stdout.Write(out); err != nil {
+		log.Fatal(err)
+	}
+	log.Print("succeeded")
+}
diff --git a/internal/sandbox/sandbox.go b/internal/sandbox/sandbox.go
new file mode 100644
index 0000000..0625d1d
--- /dev/null
+++ b/internal/sandbox/sandbox.go
@@ -0,0 +1,81 @@
+// 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 sandbox runs programs in a secure environment.
+package sandbox
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"os/exec"
+	"strings"
+	"unicode"
+
+	"golang.org/x/pkgsite-metrics/internal/derrors"
+)
+
+// A Sandbox is a restricted execution environment.
+// A Sandbox instance refers to a directory containing an OCI
+// bundle (see https://github.com/opencontainers/runtime-spec/blob/main/bundle.md).
+type Sandbox struct {
+	bundleDir string
+	Runsc     string // path to runsc program
+}
+
+// New returns a new Sandbox using the bundle in bundleDir.
+// The bundle must be configured to run the 'runner' program,
+// built from runner.go in this directory.
+// The Sandbox expects the runsc program to be on the path.
+// That can be overridden by setting the Runsc field.
+func New(bundleDir string) *Sandbox {
+	return &Sandbox{
+		bundleDir: bundleDir,
+		Runsc:     "runsc",
+	}
+}
+
+// Run runs program with args in a sandbox.
+// The program argument is the absolute path to the program from
+// within the sandbox.
+// It is invoked directly, as with [exec.Command]; no shell
+// interpretation is performed.
+// Its working directory is the bundle filesystem root.
+// The program is passed the given arguments, which must not contain whitespace.
+//
+// If the program succeeds (exits with code 0), its standard output is returned.
+// If it fails, the first return value is empty and the error comes from [exec.Command.Output].
+func (s *Sandbox) Run(ctx context.Context, program string, args ...string) (stdout []byte, err error) {
+	defer derrors.Wrap(&err, "Run(%s, %q)", program, args)
+	for _, a := range args {
+		if strings.IndexFunc(a, unicode.IsSpace) >= 0 {
+			return nil, fmt.Errorf("arg %q contains whitespace", a)
+		}
+	}
+
+	// -ignore-cgroups is needed to avoid this error from runsc:
+	// cannot set up cgroup for root: configuring cgroup: write /sys/fs/cgroup/cgroup.subtree_control: device or resource busy
+	cmd := exec.CommandContext(ctx, s.Runsc, "-ignore-cgroups", "-network=none", "run", "sandbox")
+	cmd.Dir = s.bundleDir
+	stdinPipe, err := cmd.StdinPipe()
+	if err != nil {
+		return nil, err
+	}
+	stdin := program + " " + strings.Join(args, " ")
+	c := make(chan error, 1)
+	go func() {
+		_, err := io.WriteString(stdinPipe, stdin)
+		stdinPipe.Close()
+		c <- err
+	}()
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, err
+	}
+	if err := <-c; err != nil {
+		return nil, fmt.Errorf("writing stdin: %w", err)
+	}
+	return bytes.TrimSpace(out), nil
+}
diff --git a/internal/sandbox/sandbox_test.go b/internal/sandbox/sandbox_test.go
new file mode 100644
index 0000000..d88d652
--- /dev/null
+++ b/internal/sandbox/sandbox_test.go
@@ -0,0 +1,70 @@
+// 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 sandbox
+
+import (
+	"context"
+	"errors"
+	"os"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"golang.org/x/pkgsite-metrics/internal"
+)
+
+// These tests require a minimal bundle, in testdata/bundle.
+// The Makefile in this directory will build and install
+// the binaries needed for the test.
+
+func Test(t *testing.T) {
+	t.Skip()
+
+	if os.Getenv("USER") != "root" {
+		t.Skip("skipping; must run as root. Run 'make'.")
+	}
+	ctx := context.Background()
+	sb := New("testdata/bundle")
+	sb.Runsc = "/usr/local/bin/runsc" // must match path in Makefile
+
+	t.Run("printargs", func(t *testing.T) {
+		out, err := sb.Run(ctx, "/printargs", "a", "b")
+		if err != nil {
+			t.Fatal(internal.IncludeStderr(err))
+		}
+
+		want := `args:
+0: "a"
+1: "b"`
+		got := string(out)
+		if got != want {
+			t.Fatalf("got\n%q\nwant\n%q", got, want)
+		}
+	})
+
+	t.Run("space in arg", func(t *testing.T) {
+		_, err := sb.Run(ctx, "foo", "a b c")
+		if err == nil {
+			t.Fatal("got nil, want error")
+		}
+		if g, w := err.Error(), "contains whitespace"; !strings.Contains(g, w) {
+			t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
+		}
+	})
+
+	t.Run("no program", func(t *testing.T) {
+		_, err := sb.Run(ctx, "foo")
+		var eerr *exec.ExitError
+		if !errors.As(err, &eerr) {
+			t.Fatalf("got %T, wanted *exec.ExitError", err)
+		}
+		if g, w := eerr.ExitCode(), 1; g != w {
+			t.Fatalf("got exit code %d, wanted %d", g, w)
+		}
+		if g, w := string(eerr.Stderr), "executable file not found"; !strings.Contains(g, w) {
+			t.Fatalf("got\n%q\nwhich does not contain %q", g, w)
+		}
+	})
+}
diff --git a/internal/sandbox/testdata/bundle/config.json b/internal/sandbox/testdata/bundle/config.json
new file mode 100644
index 0000000..1bd2beb
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/config.json
@@ -0,0 +1,99 @@
+{
+	"ociVersion": "1.0.0",
+	"process": {
+		"user": {
+			"uid": 0,
+			"gid": 0
+		},
+		"args": [
+			"/runner"
+		],
+		"env": [
+			"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
+			"TERM=xterm"
+		],
+		"cwd": "/",
+		"capabilities": {
+			"bounding": [
+				"CAP_AUDIT_WRITE",
+				"CAP_KILL",
+				"CAP_NET_BIND_SERVICE"
+			],
+			"effective": [
+				"CAP_AUDIT_WRITE",
+				"CAP_KILL",
+				"CAP_NET_BIND_SERVICE"
+			],
+			"inheritable": [
+				"CAP_AUDIT_WRITE",
+				"CAP_KILL",
+				"CAP_NET_BIND_SERVICE"
+			],
+			"permitted": [
+				"CAP_AUDIT_WRITE",
+				"CAP_KILL",
+				"CAP_NET_BIND_SERVICE"
+			],
+			"ambient": [
+				"CAP_AUDIT_WRITE",
+				"CAP_KILL",
+				"CAP_NET_BIND_SERVICE"
+			]
+		},
+		"rlimits": [
+			{
+				"type": "RLIMIT_NOFILE",
+				"hard": 1024,
+				"soft": 1024
+			}
+		]
+	},
+	"root": {
+		"path": "rootfs",
+		"readonly": false
+	},
+	"hostname": "runsc",
+	"mounts": [
+		{
+			"destination": "/proc",
+			"type": "proc",
+			"source": "proc"
+		},
+		{
+			"destination": "/dev",
+			"type": "tmpfs",
+			"source": "tmpfs",
+			"options": []
+		},
+		{
+			"destination": "/sys",
+			"type": "sysfs",
+			"source": "sysfs",
+			"options": [
+				"nosuid",
+				"noexec",
+				"nodev",
+				"ro"
+			]
+		}
+	],
+	"linux": {
+		"namespaces": [
+			{
+				"type": "pid"
+			},
+			{
+				"type": "network"
+			},
+			{
+				"type": "ipc"
+			},
+			{
+				"type": "uts"
+			},
+			{
+				"type": "mount"
+			}
+		]
+	}
+}
diff --git a/internal/sandbox/testdata/bundle/rootfs/.dockerenv b/internal/sandbox/testdata/bundle/rootfs/.dockerenv
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/.dockerenv
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/hostname b/internal/sandbox/testdata/bundle/rootfs/etc/hostname
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/hostname
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/hosts b/internal/sandbox/testdata/bundle/rootfs/etc/hosts
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/hosts
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/mtab b/internal/sandbox/testdata/bundle/rootfs/etc/mtab
new file mode 120000
index 0000000..4c0a094
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/mtab
@@ -0,0 +1 @@
+/proc/mounts
\ No newline at end of file
diff --git a/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf b/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf
new file mode 100755
index 0000000..e69de29
--- /dev/null
+++ b/internal/sandbox/testdata/bundle/rootfs/etc/resolv.conf
diff --git a/internal/sandbox/testdata/printargs.go b/internal/sandbox/testdata/printargs.go
new file mode 100644
index 0000000..2a03b5e
--- /dev/null
+++ b/internal/sandbox/testdata/printargs.go
@@ -0,0 +1,22 @@
+// 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.
+
+//go:build ignore
+
+// This program prints its arguments and exits.
+// It is used for testing the sandbox package.
+
+package main
+
+import (
+	"fmt"
+	"os"
+)
+
+func main() {
+	fmt.Printf("args:\n")
+	for i, arg := range os.Args[1:] {
+		fmt.Printf("%d: %q\n", i, arg)
+	}
+}
diff --git a/internal/util.go b/internal/util.go
new file mode 100644
index 0000000..15395a6
--- /dev/null
+++ b/internal/util.go
@@ -0,0 +1,22 @@
+// 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 internal
+
+import (
+	"bytes"
+	"errors"
+	"fmt"
+	"os/exec"
+)
+
+// IncludeStderr includes the stderr with an *exec.ExitError.
+// If err is not an *exec.ExitError, it returns err.Error().
+func IncludeStderr(err error) string {
+	var eerr *exec.ExitError
+	if errors.As(err, &eerr) {
+		return fmt.Sprintf("%v: %s", eerr, bytes.TrimSpace(eerr.Stderr))
+	}
+	return err.Error()
+}