Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 18 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,28 @@ gozero: the wannabe zero dependency [language-here] runtime for Go developers

## Isolation

### Windows
Confinement is provided by the `confine` package. It is deny-by-default (no
network, dropped capabilities, no-new-privileges, read-only root filesystem,
private namespaces, cpu/memory/pids limits, minimal environment) and fail
closed: when confinement is requested but the backend is unavailable, execution
is refused instead of falling back to the host.

Native isolation on windows is supported only with the PRO version and is implemented via Windows Sandbox (which needs to be [activated](https://www.makeuseof.com/enable-set-up-windows-sandbox-windows-11/)).
Backends:

### Darwin
- Linux: bubblewrap (`bwrap`).
- macOS: Seatbelt (`sandbox-exec`).
- Any OS with a Docker daemon: a hardened, single-shot container.

OSX implements native isolation via the command `sandbox-exec`. The command line interface is marked as deprecated, but the system functionality is actively supported, and profiles are still used in well-known software like chrome, firefox.
Source is passed to the interpreter as bytes, never interpolated into a shell.

### Linux
## Usage

On Linux, the functionality is implemented with the default command `systemd-run`, which should be available on most systems and allow a vast fine-grained sandbox configuration via SecComp and EBPF
Confinement is off by default. Enable it per executor:

```go
opts := &gozero.Options{Engines: []string{"python3"}, Sandbox: true}
g, err := gozero.New(opts) // fails closed if no backend is available
```

## Note:

Sandbox is not enabled by default and needs to be used manually through sdk
Tune the posture with `Options.Confinement` (a `*confine.Policy`), or pick a
backend per call with `Gozero.EvalWithVirtualEnv`.
120 changes: 120 additions & 0 deletions confine/bwrap_args.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package confine

import (
"fmt"
"path/filepath"
"sort"
)

// bwrapArgs builds the full bubblewrap (bwrap) command line that wraps argv.
//
// Posture (deny-by-default):
//
// - --unshare-all creates fresh user, mount, PID, IPC, UTS, cgroup and network
// namespaces, so the payload sees none of the host's processes, IPC, or
// network. --share-net is added back only when the policy allows network.
// - --die-with-parent tears the sandbox down if gozero exits (no orphans).
// - --new-session detaches the controlling terminal (blocks TIOCSTI injection).
// - --clearenv drops the inherited environment; only the explicitly allowed
// variables are re-added with --setenv, so ambient secrets never leak in.
// - The host root is bind-mounted READ-ONLY so the interpreter and its shared
// libraries are reachable, then fresh /proc, /dev and a tmpfs /tmp are laid
// on top. Exactly one path — the workspace — is bound writable. The script's
// directory is bound read-only (it is normally already covered by the ro
// root, but binding it explicitly keeps the payload readable even if the
// script lives on a filesystem the root bind didn't capture).
// - --cap-drop ALL removes every capability as defense in depth on top of the
// user namespace.
//
// Flag order matters: bwrap applies binds left-to-right and later binds win for
// overlapping paths, so the read-only root is laid down before the writable
// workspace re-binds over it.
//
// bwrap is a transparent wrapper: stdin/stdout/stderr and the child's exit code
// pass straight through.
func bwrapArgs(bin string, p Policy, workspace, scriptPath string, env map[string]string, argv []string) []string {
ws := canonicalPath(workspace)

out := []string{
bin,
"--unshare-all",
"--die-with-parent",
"--new-session",
"--clearenv",
}
if p.AllowNetwork {
out = append(out, "--share-net")
}
if p.DropAllCapabilities {
out = append(out, "--cap-drop", "ALL")
}

// Read-only host root first, then overlay pseudo-filesystems.
out = append(out,
"--ro-bind", "/", "/",
"--proc", "/proc",
"--dev", "/dev",
"--tmpfs", "/tmp",
)

// Single writable path: the workspace.
if ws != "" {
out = append(out, "--bind", ws, ws)
}
if t := canonicalPath(p.Tmp); t != "" && t != ws {
out = append(out, "--bind", t, t)
}
// Ensure the script's directory is readable even if it sits outside the
// root bind (idempotent when already covered).
if scriptPath != "" {
if dir := canonicalPath(filepath.Dir(scriptPath)); dir != "" && dir != "/" && dir != ws {
out = append(out, "--ro-bind-try", dir, dir)
}
}

// Non-root identity inside the user namespace.
if p.RunAsUID >= 0 {
out = append(out, "--uid", fmt.Sprintf("%d", p.RunAsUID))
}
if p.RunAsGID >= 0 {
out = append(out, "--gid", fmt.Sprintf("%d", p.RunAsGID))
}

if ws != "" {
out = append(out, "--chdir", ws)
}

// Re-inject the scrubbed environment deterministically.
for _, k := range sortedKeys(env) {
out = append(out, "--setenv", k, env[k])
}

out = append(out, "--")
out = append(out, argv...)
return out
}

func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

// canonicalPath resolves symlinks so a bind/profile matches the path the kernel
// actually evaluates, falling back to an absolute/clean path when resolution
// fails (e.g. the path does not exist yet).
func canonicalPath(p string) string {
if p == "" {
return ""
}
if resolved, err := filepath.EvalSymlinks(p); err == nil {
return resolved
}
if abs, err := filepath.Abs(p); err == nil {
return abs
}
return filepath.Clean(p)
}
112 changes: 112 additions & 0 deletions confine/bwrap_args_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package confine

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// containsSeq reports whether sub appears as a contiguous subsequence of s.
func containsSeq(s, sub []string) bool {
if len(sub) == 0 {
return true
}
for i := 0; i+len(sub) <= len(s); i++ {
if equalSlice(s[i:i+len(sub)], sub) {
return true
}
}
return false
}

func equalSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func TestBwrapArgsHardenedByDefault(t *testing.T) {
ws := t.TempDir()
p := DefaultPolicy()
p.Tmp = ws // avoid a second writable bind for a distinct temp dir
argv := []string{"/usr/bin/python3", "/tmp/script.py", "--flag"}
env := map[string]string{"PATH": "/usr/bin", "FOO": "bar"}

got := bwrapArgs("/usr/bin/bwrap", p, ws, "/tmp/script.py", env, argv)
cws := canonicalPath(ws)

// Core isolation flags.
assert.Equal(t, "/usr/bin/bwrap", got[0])
for _, flag := range []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv"} {
assert.Contains(t, got, flag, "missing hardening flag %s", flag)
}
assert.True(t, containsSeq(got, []string{"--cap-drop", "ALL"}), "must drop all capabilities")
assert.True(t, containsSeq(got, []string{"--ro-bind", "/", "/"}), "host root must be read-only")
assert.True(t, containsSeq(got, []string{"--proc", "/proc"}))
assert.True(t, containsSeq(got, []string{"--dev", "/dev"}))
assert.True(t, containsSeq(got, []string{"--tmpfs", "/tmp"}))
assert.True(t, containsSeq(got, []string{"--bind", cws, cws}), "workspace must be the writable bind")
assert.True(t, containsSeq(got, []string{"--chdir", cws}))

// Environment is re-injected deterministically (sorted), never inherited.
assert.True(t, containsSeq(got, []string{"--setenv", "FOO", "bar"}))
assert.True(t, containsSeq(got, []string{"--setenv", "PATH", "/usr/bin"}))
fooIdx := indexOf(got, "FOO")
pathIdx := indexOf(got, "PATH")
assert.Less(t, fooIdx, pathIdx, "setenv keys must be sorted")

// No network by default.
assert.NotContains(t, got, "--share-net")

// Command follows the -- terminator, in order, and unchanged.
dashIdx := lastIndexOf(got, "--")
require.NotEqual(t, -1, dashIdx)
assert.Equal(t, argv, got[dashIdx+1:], "argv must be passed verbatim after --")
}

func TestBwrapArgsNetworkToggle(t *testing.T) {
ws := t.TempDir()
p := DefaultPolicy()
p.AllowNetwork = true
got := bwrapArgs("bwrap", p, ws, "", nil, []string{"sh"})
assert.Contains(t, got, "--share-net", "network must be re-shared when allowed")
}

func TestBwrapArgsNonRootIdentity(t *testing.T) {
ws := t.TempDir()
p := DefaultPolicy()
p.RunAsUID = 1000
p.RunAsGID = 1000
got := bwrapArgs("bwrap", p, ws, "", nil, []string{"sh"})
assert.True(t, containsSeq(got, []string{"--uid", "1000"}))
assert.True(t, containsSeq(got, []string{"--gid", "1000"}))
}

func indexOf(s []string, v string) int {
for i := range s {
if s[i] == v {
return i
}
}
return -1
}

func lastIndexOf(s []string, v string) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == v {
return i
}
}
return -1
}

// ensure the joined form is shell-inspectable (used in a couple of assertions).
var _ = strings.Join
Loading
Loading