diff --git a/README.md b/README.md index 4e475b5..e93f2ca 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,51 @@ 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, +hidden home directory, private namespaces, cpu/memory/pids limits, non-root +user, 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: +Tune the posture with `Options.Confinement` (a `*confine.Policy`), or pick a +backend per call with `Gozero.EvalWithVirtualEnv`. -Sandbox is not enabled by default and needs to be used manually through sdk \ No newline at end of file +Every security switch in `confine.Policy` is an opt-out whose zero value is the hardened setting, so a sparse policy such as `&confine.Policy{Backend: confine.BackendDocker}` is fully hardened. The opt-outs are `AllowNetwork`, `AllowCapabilities`, `AllowNewPrivileges` and `WritableRootfs`. + +### Home directory + +The native backends hide `$HOME` from the payload. Seatbelt denies reads under it, and bubblewrap mounts an empty tmpfs over it. The payload cannot read `~/.ssh`, shell history or credentials. The workspace and the script directory stay reachable. + +The native backends also hide the other users' homes and root's home (`/home` and `/root` on Linux, `/Users` and `/var/root` on macOS). On Linux they hide `/run` too: it holds daemon sockets such as the Docker socket, and an unshared network namespace does not block unix sockets. On macOS, the network deny blocks unix sockets. + +Each native backend runs a probe once before use. If bubblewrap cannot create its namespaces (for example in an unprivileged container, or under Landlock, which forbids mounts), or if `sandbox-exec` cannot apply a profile, `New` returns `ErrConfinementUnavailable`. + +If the interpreter lives under `$HOME` (pyenv, nvm, a Homebrew prefix in the home directory), list it in `ReadOnlyPaths`. Each entry is exposed read-only. A leading `~` is expanded. Paths outside `$HOME` are already readable and need no entry. + +```go +policy := confine.DefaultPolicy() +policy.ReadOnlyPaths = []string{"~/.pyenv"} +opts := &gozero.Options{Engines: []string{"python3"}, Sandbox: true, Confinement: &policy} +``` + +### Docker user + +The Docker backend runs the payload as `65534:65534` (nobody), not as the image user, which is root for most base images. `RunAsUID`/`RunAsGID` select another uid/gid, and `DockerUser` overrides the user verbatim (for example `"1000:1000"`). diff --git a/confine/bwrap_args.go b/confine/bwrap_args.go new file mode 100644 index 0000000..902cee6 --- /dev/null +++ b/confine/bwrap_args.go @@ -0,0 +1,149 @@ +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 +// file alone is bound read-only (its directory is usually the host temp +// dir, which must stay hidden behind the private /tmp). +// - $HOME, the other users' homes, root's home and /run (runtime sockets +// such as the Docker socket) are replaced by empty tmpfs mounts, then only +// the paths in the policy's ReadOnlyPaths allow list are bound back +// read-only (e.g. ~/.pyenv). +// - --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.AllowCapabilities { + 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", + ) + + // Hide $HOME, the other users' homes, root's home and the runtime socket + // dirs (/run: the Docker socket stays reachable through the read-only + // root otherwise, and an unshared network does not stop unix sockets) + // behind empty tmpfs mounts, so the payload can't read ~/.ssh, shell + // history or credentials, or drive a daemon. The explicit allow list + // (policy ReadOnlyPaths) is bound back read-only on top. Binds below + // (script, workspace, temp) come later, so they still win when they + // live in a hidden dir. + if roots := hiddenRoots(); len(roots) > 0 { + for _, root := range roots { + out = append(out, "--tmpfs", root) + } + var extra []string + if p.AllowNetwork { + // Keep DNS working when /run is hidden (systemd-resolved stub). + extra = append(extra, "/run/systemd/resolve") + } + for _, ro := range hiddenReadable(roots, p.ReadOnlyPaths, extra...) { + out = append(out, "--ro-bind-try", ro, ro) + } + } + + // Bind only the script file read-only, never its directory: the script + // usually sits in the host temp dir, and binding that directory would put + // the whole host /tmp back over the private tmpfs (and, coming after the + // workspace bind, would make a workspace inside it read-only). It comes + // before the writable binds so those always win. + if scriptPath != "" { + if sp := canonicalPath(scriptPath); sp != "" && !underDir(sp, ws) { + out = append(out, "--ro-bind-try", sp, sp) + } + } + + // 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) + } + + // 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) +} diff --git a/confine/bwrap_args_test.go b/confine/bwrap_args_test.go new file mode 100644 index 0000000..786861a --- /dev/null +++ b/confine/bwrap_args_test.go @@ -0,0 +1,195 @@ +package confine + +import ( + "path/filepath" + "runtime" + "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 TestBwrapArgsZeroPolicyDropsCapabilities(t *testing.T) { + got := bwrapArgs("bwrap", Policy{}.normalized(), t.TempDir(), "", nil, []string{"sh"}) + assert.True(t, containsSeq(got, []string{"--cap-drop", "ALL"}), "zero policy must drop all capabilities") + + p := DefaultPolicy() + p.AllowCapabilities = true + assert.NotContains(t, bwrapArgs("bwrap", p, t.TempDir(), "", nil, []string{"sh"}), "--cap-drop") +} + +func TestBwrapArgsHidesHome(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("bwrap runs only on Linux and takes POSIX paths") + } + home := t.TempDir() + t.Setenv("HOME", home) + ch := canonicalPath(home) + pyenv := filepath.Join(ch, ".pyenv") + ws := filepath.Join(ch, "ws") + + p := DefaultPolicy() + p.ReadOnlyPaths = []string{"~/.pyenv", "/usr/local"} + got := bwrapArgs("bwrap", p, ws, "", nil, []string{"sh"}) + + tmpfsIdx := indexOfSeq(got, []string{"--tmpfs", ch}) + require.NotEqual(t, -1, tmpfsIdx, "home must be covered by a tmpfs") + assert.Greater(t, tmpfsIdx, indexOfSeq(got, []string{"--ro-bind", "/", "/"}), "tmpfs must come after the root bind") + roIdx := indexOfSeq(got, []string{"--ro-bind-try", pyenv, pyenv}) + assert.Greater(t, roIdx, tmpfsIdx, "allow-listed home path is bound back read-only after the tmpfs") + assert.Greater(t, indexOfSeq(got, []string{"--bind", ws, ws}), tmpfsIdx, "workspace under home is bound after the tmpfs") + assert.False(t, containsSeq(got, []string{"--ro-bind-try", "/usr/local", "/usr/local"}), "paths outside home are not hidden and need no bind") +} + +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 indexOfSeq(s, sub []string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if equalSlice(s[i:i+len(sub)], sub) { + return i + } + } + return -1 +} + +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 + +func TestBwrapArgsHidesSystemDirs(t *testing.T) { + p := DefaultPolicy() + got := bwrapArgs("bwrap", p, t.TempDir(), "", nil, []string{"sh"}) + for _, root := range hiddenRoots() { + assert.True(t, containsSeq(got, []string{"--tmpfs", root}), "%s must be covered by a tmpfs", root) + } + if runtime.GOOS == "linux" { + if run, err := filepath.EvalSymlinks("/run"); err == nil { + assert.True(t, containsSeq(got, []string{"--tmpfs", run}), "runtime sockets (Docker) must be hidden") + } + } +} + +func TestHiddenRootsDropsNested(t *testing.T) { + roots := hiddenRoots() + for i, a := range roots { + assert.NotEqual(t, string(filepath.Separator), a) + for j, b := range roots { + if i != j { + assert.False(t, underDir(b, a), "%s is nested in %s", b, a) + } + } + } +} + +// The script usually sits directly in the host temp dir, next to the +// workspace. Only the script file may be bound, and before the workspace, so +// the host temp dir stays hidden and the workspace stays writable. +func TestBwrapArgsBindsOnlyTheScript(t *testing.T) { + dir := canonicalPath(t.TempDir()) + ws := filepath.Join(dir, "ws") + script := filepath.Join(dir, "script.sh") + got := bwrapArgs("bwrap", DefaultPolicy(), ws, script, nil, []string{"sh", script}) + + assert.False(t, containsSeq(got, []string{"--ro-bind-try", dir, dir}), "the script dir must not be bound") + scriptIdx := indexOfSeq(got, []string{"--ro-bind-try", script, script}) + require.NotEqual(t, -1, scriptIdx, "the script file must be bound read-only") + assert.Less(t, scriptIdx, indexOfSeq(got, []string{"--bind", ws, ws}), "the workspace bind must come last") +} diff --git a/confine/confine.go b/confine/confine.go new file mode 100644 index 0000000..7409bdd --- /dev/null +++ b/confine/confine.go @@ -0,0 +1,341 @@ +// Package confine is gozero's code-execution confinement layer. +// +// It exists to eliminate the "sandbox escape" error class: when a caller asks +// for confinement, code MUST run inside an OS-enforced boundary or not run at +// all. There is deliberately no silent fallback to a bare host exec — an +// unavailable or unhealthy confiner is a hard error (fail closed). This is the +// single property that turns "we tried to sandbox" into "it is impossible to +// execute unconfined once confinement is requested". +// +// Design (state of the art, deny-by-default): +// +// - One seam. Every backend implements the same Confiner and every execution +// path goes through Run, so there is no second, unconfined code path to +// forget about. +// - Deny by default. No network, all Linux capabilities dropped, +// no-new-privileges, read-only root filesystem, a hidden $HOME, private +// namespaces, a minimal scrubbed environment, non-root identity and +// CPU/memory/pids limits are all ON unless the policy explicitly relaxes +// them. Every relaxation is an opt-out field whose zero value is the +// hardened setting. +// - Least authority for the payload. Source is delivered as bytes (a bind or a +// tar copy), never interpolated into a shell — so there is no heredoc/quote +// breakout and the declared interpreter is the only thing that runs it. +// - Ephemeral. Each execution gets a fresh writable workspace that is torn +// down afterwards; the read-only root and container are disposable. +// +// Backends: +// +// - bubblewrap (Linux): transparent argv wrapper using user/mount/net/pid/… +// namespaces + capability drop; a read-only view of the host root with a +// single writable workspace bind. +// - Seatbelt (macOS): sandbox-exec with an SBPL profile that denies writes +// outside the workspace/temp (and, optionally, all network). +// - Docker (any OS with a daemon): a hardened, single-shot container +// (--network none, --cap-drop ALL, no-new-privileges, --read-only, pids/cpu/ +// memory limits, uid 65534) with the source in a read-only bind. +package confine + +import ( + "context" + "errors" + "io" + "log/slog" + "time" + + "github.com/projectdiscovery/gozero/types" +) + +// Backend selects which confinement mechanism to use. +type Backend string + +const ( + // BackendAuto picks the best native confiner for the current OS + // (bubblewrap on Linux, Seatbelt on macOS). It fails closed when none is + // available. + BackendAuto Backend = "auto" + // BackendBubblewrap forces the Linux bubblewrap confiner. + BackendBubblewrap Backend = "bubblewrap" + // BackendSeatbelt forces the macOS Seatbelt confiner. + BackendSeatbelt Backend = "seatbelt" + // BackendDocker forces the hardened Docker confiner. + BackendDocker Backend = "docker" + // BackendHost is an explicit, deliberate opt-out: code runs on the host with + // NO confinement. It must be chosen on purpose; it is never selected + // implicitly and never used as a fallback. + BackendHost Backend = "host" +) + +// Sentinel errors. Callers should treat ErrConfinementUnavailable as fatal: +// the whole point of this package is that a requested-but-unavailable confiner +// never degrades into an unconfined run. +var ( + // ErrConfinementUnavailable is returned when the requested backend is not + // present/usable on this host. Fail closed: do not execute. + ErrConfinementUnavailable = errors.New("confine: requested confinement backend is not available on this host") + // ErrUnsupportedBackend is returned for an unknown Backend value. + ErrUnsupportedBackend = errors.New("confine: unsupported backend") + // ErrNoCommand is returned when a Spec carries no command to execute. + ErrNoCommand = errors.New("confine: spec has no command") +) + +// Policy is the security posture applied to every execution a Confiner runs. +// The zero value is safe: every security switch is an opt-out, and New fills +// the remaining unset fields (limits, image, env) from DefaultPolicy. +type Policy struct { + // Backend selects the confinement mechanism. Empty means BackendAuto. + Backend Backend + + // AllowNetwork, when false (default), denies the payload all network egress + // (empty net namespace / --network none / deny network*). Only loopback, + // if anything, remains. + AllowNetwork bool + + // Workspace is the writable root granted to the payload. When empty, an + // ephemeral per-execution directory is created and removed afterwards. + Workspace string + // Tmp is an additional writable dir granted to native confiners, and the + // base of the ephemeral workspace. Empty means no extra writable dir; the + // workspace is then created under os.TempDir(). Setting it to a shared temp + // dir lets the payload write anywhere in it. + Tmp string + + // The three switches below are opt-outs: their zero value is the hardened + // setting, so a sparse Policy (e.g. only Backend) can never fail open. + + // AllowCapabilities keeps the default Linux capability set (docker/bwrap). + // Default false: every capability is dropped. + AllowCapabilities bool + // AllowNewPrivileges lets setuid/setgid binaries raise privileges (docker). + // Default false: no_new_privs is set. + AllowNewPrivileges bool + // WritableRootfs makes the container root filesystem writable (docker). + // Default false: the rootfs is read-only. + WritableRootfs bool + + // ReadOnlyPaths is the explicit allow list of host paths under $HOME that + // the native backends (bwrap/Seatbelt) expose read-only. $HOME is hidden by + // default so the payload can't read ~/.ssh, shell history, cloud + // credentials and so on. Add interpreters installed in the home dir here, + // e.g. ~/.pyenv, ~/.nvm or a Homebrew prefix in the home dir. Paths outside + // $HOME are already readable and need no entry. + ReadOnlyPaths []string + + // PidsLimit caps the number of processes (docker). Default 128. <=0 disables. + PidsLimit int64 + // MemoryBytes caps memory (docker). Default 512MiB. <=0 disables. + MemoryBytes int64 + // NanoCPUs caps CPU in units of 1e-9 CPUs (docker). Default 1e9 (1 CPU). + // <=0 disables. + NanoCPUs int64 + + // RunAsUID / RunAsGID set the identity the payload runs as. Negative means + // "use the current process uid/gid" on unix (so workspace files stay owned + // by the caller) for the native backends, and the unprivileged nobody user + // (65534:65534) for Docker. A zero value pair is treated as unset, so a + // sparse policy never forces root. + RunAsUID int + RunAsGID int + + // DockerUser overrides the container user verbatim (docker "--user" syntax, + // e.g. "1000:1000" or "root"). Empty means RunAsUID/RunAsGID when set, else + // 65534:65534. Use it for images that need a specific user. + DockerUser string + + // AllowedEnv is the whitelist of host environment variables passed through + // to the payload. Everything else in the host environment is scrubbed. + AllowedEnv []string + + // DockerImage is the image used by the Docker backend. + DockerImage string + // PullTimeout bounds a docker image pull. Default 5m. + PullTimeout time.Duration + + // Timeout bounds a single execution. 0 means "inherit the ctx deadline only". + Timeout time.Duration + + // Logger receives diagnostic events. Defaults to slog.Default(). + Logger *slog.Logger +} + +// defaultAllowedEnv is the minimal set of host environment variables passed +// through to a typical interpreter. Intentionally short: secrets live in the +// ambient environment and must not leak into untrusted code. PATH, HOME and +// TMPDIR are deliberately excluded — childEnv owns them (PATH from the host, +// HOME/TMPDIR pointed at the writable workspace) so host values can't override +// the sandbox's own locations. +var defaultAllowedEnv = []string{"LANG", "LC_ALL", "TERM", "TZ"} + +const ( + defaultPidsLimit = int64(128) + defaultMemoryBytes = int64(512) * 1024 * 1024 + defaultNanoCPUs = int64(1_000_000_000) // 1.0 CPU + defaultPullTimeout = 5 * time.Minute + // defaultDockerUser is the unprivileged nobody:nogroup identity. It needs + // no passwd entry, so it works on any image. + defaultDockerUser = "65534:65534" + // defaultDockerImage is a small, widely-available base. Callers running + // non-shell interpreters should override it with an image that ships them. + defaultDockerImage = "alpine:latest" + // defaultPATH is the fallback search path when the host PATH is empty. + defaultPATH = "/usr/local/bin:/usr/bin:/bin" +) + +// DefaultPolicy returns a hardened, deny-by-default policy: no network, all +// capabilities dropped, no-new-privileges, read-only rootfs, $HOME hidden, +// private namespaces, resource limits, non-root, minimal environment. The +// security switches are opt-outs, so the zero Policy is equally hardened. +func DefaultPolicy() Policy { + return Policy{ + Backend: BackendAuto, + AllowNetwork: false, + PidsLimit: defaultPidsLimit, + MemoryBytes: defaultMemoryBytes, + NanoCPUs: defaultNanoCPUs, + RunAsUID: -1, + RunAsGID: -1, + AllowedEnv: append([]string(nil), defaultAllowedEnv...), + DockerImage: defaultDockerImage, + PullTimeout: defaultPullTimeout, + } +} + +// normalized returns a copy of p with unset fields filled from DefaultPolicy, +// so callers can pass a sparse Policy (e.g. only Backend + AllowNetwork) and +// still get the hardened defaults for everything they didn't set. +func (p Policy) normalized() Policy { + d := DefaultPolicy() + if p.Backend == "" { + p.Backend = d.Backend + } + if p.PidsLimit == 0 { + p.PidsLimit = d.PidsLimit + } + if p.MemoryBytes == 0 { + p.MemoryBytes = d.MemoryBytes + } + if p.NanoCPUs == 0 { + p.NanoCPUs = d.NanoCPUs + } + if p.RunAsUID == 0 && p.RunAsGID == 0 { + // Treat the zero value as "unset" and default to current identity, so a + // sparse policy never accidentally forces root (uid 0). + p.RunAsUID, p.RunAsGID = -1, -1 + } + if len(p.AllowedEnv) == 0 { + p.AllowedEnv = append([]string(nil), defaultAllowedEnv...) + } + if p.DockerImage == "" { + p.DockerImage = d.DockerImage + } + if p.PullTimeout == 0 { + p.PullTimeout = d.PullTimeout + } + if p.Logger == nil { + p.Logger = slog.Default() + } + return p +} + +// Spec describes a single confined execution. Command is the full argv with the +// interpreter at index 0; ScriptPath/ScriptData identify the source file so +// backends can make it reachable inside the sandbox (a read-only bind for +// native confiners, an archive copy for Docker) instead of pasting it into a +// shell. +type Spec struct { + // Command is the full argv: [interpreter, scriptPath, userArgs...]. + Command []string + // ScriptPath is the host path of the source file that appears in Command. + // Backends locate this element in Command to remap it into the sandbox. + ScriptPath string + // ScriptData is the source content, used by backends (Docker) that copy the + // file in rather than binding the host path. + ScriptData []byte + // Env is the caller-supplied environment (e.g. template variables). It is + // merged on top of the policy's environment allow-list and validated. + Env map[string]string + // Stdin is streamed to the payload's standard input. + Stdin io.Reader + // Workdir overrides the writable working directory. When empty the + // confiner's ephemeral workspace is used. + Workdir string + // Debug mirrors gozero's debug mode: stdout+stderr are also captured into + // Result.DebugData. + Debug bool +} + +// Confiner runs a Spec inside an OS-enforced boundary. Implementations must +// fail closed: if the boundary cannot be established, Run returns an error and +// never executes the payload on the bare host. +type Confiner interface { + // Name identifies the confiner for logs ("bubblewrap", "seatbelt", "docker"). + Name() string + // Run executes spec under confinement and returns its result. + Run(ctx context.Context, spec Spec) (*types.Result, error) + // Close releases any long-lived resources the confiner owns. + Close() error +} + +// New builds the Confiner for the given policy, failing closed. A nil policy +// means DefaultPolicy. For BackendAuto/native and BackendDocker, an +// unavailable mechanism returns ErrConfinementUnavailable rather than a +// permissive fallback — callers MUST treat that as "do not execute". +func New(policy *Policy) (Confiner, error) { + if policy == nil { + d := DefaultPolicy() + policy = &d + } + // normalized() fills unset fields (including Logger) so the backend probes + // below can rely on them; skipping it caused a nil-logger panic on the + // nil-policy path. + p := policy.normalized() + + switch p.Backend { + case BackendHost: + return hostConfiner{}, nil + case BackendDocker: + c, err := newDockerConfiner(p) + if err != nil { + return nil, err + } + return c, nil + case BackendAuto, BackendBubblewrap, BackendSeatbelt: + c := newNativeConfiner(p) + if c == nil { + return nil, ErrConfinementUnavailable + } + // When a specific native backend was requested, enforce that we got it. + if p.Backend == BackendBubblewrap && c.Name() != "bubblewrap" { + return nil, ErrConfinementUnavailable + } + if p.Backend == BackendSeatbelt && c.Name() != "seatbelt" { + return nil, ErrConfinementUnavailable + } + return c, nil + default: + return nil, ErrUnsupportedBackend + } +} + +// Compile-time guarantees that every backend satisfies the single Confiner +// seam (the per-OS native confiners assert themselves in their tagged files). +var ( + _ Confiner = hostConfiner{} + _ Confiner = (*dockerConfiner)(nil) +) + +// hostConfiner is the explicit, deliberate no-confinement backend. It is only +// ever returned for BackendHost and never selected implicitly. It exists so the +// unconfined path is an auditable, named choice rather than a silent fallback. +type hostConfiner struct{} + +func (hostConfiner) Name() string { return "host" } +func (hostConfiner) Close() error { return nil } + +func (hostConfiner) Run(ctx context.Context, spec Spec) (*types.Result, error) { + if len(spec.Command) == 0 { + return nil, ErrNoCommand + } + env := childEnv(DefaultPolicy(), spec.Workdir, spec.Env) + return runHost(ctx, spec.Command, envMapToSlice(env), spec.Stdin, spec.Workdir, spec.Debug) +} diff --git a/confine/confine_test.go b/confine/confine_test.go new file mode 100644 index 0000000..1997745 --- /dev/null +++ b/confine/confine_test.go @@ -0,0 +1,94 @@ +package confine + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultPolicyIsHardened(t *testing.T) { + p := DefaultPolicy() + assert.Equal(t, BackendAuto, p.Backend) + assert.False(t, p.AllowNetwork, "network must be denied by default") + assert.False(t, p.AllowCapabilities) + assert.False(t, p.AllowNewPrivileges) + assert.False(t, p.WritableRootfs) + assert.Empty(t, p.ReadOnlyPaths, "no home path is readable by default") + assert.Empty(t, p.DockerUser) + assert.Equal(t, defaultPidsLimit, p.PidsLimit) + assert.Equal(t, defaultMemoryBytes, p.MemoryBytes) + assert.Equal(t, defaultNanoCPUs, p.NanoCPUs) + assert.Equal(t, -1, p.RunAsUID, "must not default to root") + assert.Equal(t, -1, p.RunAsGID) + assert.NotEmpty(t, p.AllowedEnv) +} + +func TestNormalizedFillsSparsePolicy(t *testing.T) { + // A caller who only sets a backend still gets every hardened default. + got := Policy{Backend: BackendDocker}.normalized() + assert.Equal(t, BackendDocker, got.Backend) + assert.Equal(t, defaultPidsLimit, got.PidsLimit) + assert.Equal(t, defaultMemoryBytes, got.MemoryBytes) + assert.Equal(t, defaultNanoCPUs, got.NanoCPUs) + assert.Equal(t, defaultDockerImage, got.DockerImage) + assert.Equal(t, defaultPullTimeout, got.PullTimeout) + assert.Empty(t, got.Tmp, "the shared temp dir must not be writable by default") + assert.NotNil(t, got.Logger) + // A zero-value uid/gid must be treated as "unset" and never forced to root. + assert.Equal(t, -1, got.RunAsUID) + assert.Equal(t, -1, got.RunAsGID) +} + +// TestNewNilPolicyDoesNotPanic guards the regression where New(nil) used +// DefaultPolicy() without normalizing it, leaving Logger nil and panicking in +// the backend probe. It must return cleanly (a confiner or a fail-closed error). +func TestNewNilPolicyDoesNotPanic(t *testing.T) { + c, err := New(nil) + if err != nil { + assert.ErrorIs(t, err, ErrConfinementUnavailable) + assert.Nil(t, c) + return + } + require.NotNil(t, c) + _ = c.Close() +} + +func TestNewUnsupportedBackendFailsClosed(t *testing.T) { + c, err := New(&Policy{Backend: Backend("nonsense")}) + assert.Nil(t, c) + assert.ErrorIs(t, err, ErrUnsupportedBackend) +} + +func TestNewHostBackendIsExplicitOptOut(t *testing.T) { + // BackendHost is the only way to get an unconfined executor, and it must be + // asked for by name — never returned as a fallback. + c, err := New(&Policy{Backend: BackendHost}) + require.NoError(t, err) + require.NotNil(t, c) + assert.Equal(t, "host", c.Name()) + assert.NoError(t, c.Close()) +} + +func TestHostConfinerRejectsEmptyCommand(t *testing.T) { + _, err := hostConfiner{}.Run(context.Background(), Spec{}) + assert.ErrorIs(t, err, ErrNoCommand) +} + +// TestNewNativeFailsClosedWhenUnavailable asserts the core anti-escape +// property: when a native backend is requested but the mechanism is not present +// on this host, New returns ErrConfinementUnavailable rather than a permissive +// confiner. It is skipped when the mechanism happens to be installed. +func TestNewNativeFailsClosedWhenUnavailable(t *testing.T) { + c, err := New(&Policy{Backend: BackendAuto}) + if err == nil { + require.NotNil(t, c) + _ = c.Close() + t.Skipf("a native confiner (%s) is available on this host; fail-closed path not exercised", c.Name()) + } + assert.Truef(t, errors.Is(err, ErrConfinementUnavailable), + "expected ErrConfinementUnavailable, got %v", err) + assert.Nil(t, c) +} diff --git a/confine/docker.go b/confine/docker.go new file mode 100644 index 0000000..fbf5bc5 --- /dev/null +++ b/confine/docker.go @@ -0,0 +1,329 @@ +package confine + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + "time" + + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" + "github.com/projectdiscovery/gozero/types" +) + +// containerSourceDir is where the payload's source file is injected inside the +// container. It lives on the (read-only-at-runtime) rootfs; it only needs to be +// readable/executable at runtime, and it is written before the container starts. +const containerSourceDir = "/gozero-src" + +// containerWorkDir is a writable tmpfs the payload runs in, so a read-only +// rootfs never blocks legitimate scratch writes. +const containerWorkDir = "/work" + +// dockerConfiner runs each payload in a fresh, hardened, single-shot container. +type dockerConfiner struct { + policy Policy + cli *client.Client + image string +} + +// newDockerConfiner connects to the daemon and validates the image, failing +// closed (returning ErrConfinementUnavailable) when Docker is not usable — so a +// caller that asked for Docker confinement never runs unconfined. +func newDockerConfiner(p Policy) (*dockerConfiner, error) { + cli, err := client.New(client.FromEnv) + if err != nil { + p.Logger.Debug("confine: docker unavailable", "reason", "client init failed", "error", err) + return nil, ErrConfinementUnavailable + } + pingCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := cli.Ping(pingCtx, client.PingOptions{NegotiateAPIVersion: true}); err != nil { + _ = cli.Close() + p.Logger.Debug("confine: docker unavailable", "reason", "daemon ping failed", "error", err) + return nil, ErrConfinementUnavailable + } + return &dockerConfiner{policy: p, cli: cli, image: p.DockerImage}, nil +} + +func (c *dockerConfiner) Name() string { return "docker" } + +func (c *dockerConfiner) Close() error { + if c.cli != nil { + return c.cli.Close() + } + return nil +} + +func (c *dockerConfiner) Run(ctx context.Context, spec Spec) (*types.Result, error) { + if len(spec.Command) == 0 { + return nil, ErrNoCommand + } + + ctx, cancel := withTimeout(ctx, c.policy.Timeout) + defer cancel() + + if err := c.ensureImage(ctx); err != nil { + return nil, err + } + + scriptName := "script" + if spec.ScriptPath != "" { + scriptName = filepath.Base(spec.ScriptPath) + } + containerScript := path.Join(containerSourceDir, scriptName) + + // The host's absolute interpreter path is meaningless inside the image, so + // reference it by base name (e.g. /usr/bin/python3 -> python3) and let the + // container's PATH resolve it. + interp := filepath.Base(spec.Command[0]) + cmd := remapScript(spec.Command, spec.ScriptPath, containerScript, interp, true) + + env := customEnv(spec.Env) + env["HOME"] = containerWorkDir + env["TMPDIR"] = "/tmp" + + user := dockerUser(c.policy) + + wantStdin := spec.Stdin != nil + cfg := dockerContainerConfig(c.image, cmd, containerWorkDir, envMapToSlice(env), user, wantStdin) + hostCfg := dockerHostConfig(c.policy) + + // Expose the source through a private read-only bind mount, never as shell + // text. CopyToContainer cannot reliably inject into a container whose rootfs + // is configured read-only, and making the rootfs writable for injection weakens + // the runtime posture. A bind of one temp dir keeps the rootfs read-only and + // gives the payload access to only this generated source file. + srcData := spec.ScriptData + if srcData == nil { + srcData = []byte{} + } + sourceDir, err := os.MkdirTemp("", "gozero-docker-src-*") + if err != nil { + return nil, fmt.Errorf("confine: create source dir: %w", err) + } + defer func() { _ = os.RemoveAll(sourceDir) }() + // World-traversable dir and world-readable+executable script: the container + // runs as a non-root uid (65534 by default, or policy.RunAsUID) that differs from the + // host uid that owns these files, and MkdirTemp/WriteFile would otherwise + // create 0700/0600 files only the host owner can reach. The bind is mounted + // read-only, so widening the mode does not let the payload tamper with them. + if err := os.Chmod(sourceDir, 0o755); err != nil { + return nil, fmt.Errorf("confine: chmod source dir: %w", err) + } + if err := os.WriteFile(filepath.Join(sourceDir, scriptName), srcData, 0o555); err != nil { + return nil, fmt.Errorf("confine: write source: %w", err) + } + hostCfg.Mounts = append(hostCfg.Mounts, mount.Mount{ + Type: mount.TypeBind, + Source: sourceDir, + Target: containerSourceDir, + ReadOnly: true, + }) + + createResp, err := c.cli.ContainerCreate(ctx, client.ContainerCreateOptions{Config: cfg, HostConfig: hostCfg}) + if err != nil { + return nil, fmt.Errorf("confine: create container: %w", err) + } + id := createResp.ID + defer func() { + rmCtx, rmCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer rmCancel() + _, _ = c.cli.ContainerRemove(rmCtx, id, client.ContainerRemoveOptions{Force: true, RemoveVolumes: true}) + }() + + // Attach stdin (only) before start so the payload can read it. stdout/stderr + // are collected from the logs stream after exit, demultiplexed via stdcopy. + var attach client.ContainerAttachResult + if wantStdin { + attach, err = c.cli.ContainerAttach(ctx, id, client.ContainerAttachOptions{Stream: true, Stdin: true}) + if err != nil { + return nil, fmt.Errorf("confine: attach stdin: %w", err) + } + defer attach.Close() + } + + // Register the wait BEFORE start using NextExit: WaitConditionNotRunning is + // satisfied immediately by a freshly created (not-yet-started) container and + // returns a bogus StatusCode 0, so it must not be used in the wait-then-start + // ordering. NextExit resolves only when the container next exits. + waitRes := c.cli.ContainerWait(ctx, id, client.ContainerWaitOptions{Condition: container.WaitConditionNextExit}) + + if _, err := c.cli.ContainerStart(ctx, id, client.ContainerStartOptions{}); err != nil { + return nil, fmt.Errorf("confine: start container: %w", err) + } + + if wantStdin { + go func() { + _, _ = io.Copy(attach.Conn, spec.Stdin) + if cw, ok := attach.Conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + } + }() + } + + var status int64 + select { + case werr := <-waitRes.Error: + if werr != nil { + return nil, fmt.Errorf("confine: wait container: %w", werr) + } + case wr := <-waitRes.Result: + status = wr.StatusCode + case <-ctx.Done(): + return nil, ctx.Err() + } + + res := &types.Result{Command: strings.Join(cmd, " ")} + if err := c.collectLogs(ctx, id, res, spec.Debug); err != nil { + return res, err + } + res.SetExitCode(int(status)) + // A non-zero exit is an error, as it is for the native confiners and for + // unconfined execution. + if status != 0 { + return res, fmt.Errorf("confine: confined command failed with exit status %d got: %v", status, res.Stderr.String()) + } + return res, nil +} + +// collectLogs reads the container's multiplexed log stream and demultiplexes it +// into the result's stdout/stderr buffers via stdcopy, so no 8-byte frame +// headers leak into the output and nothing is truncated. +func (c *dockerConfiner) collectLogs(ctx context.Context, id string, res *types.Result, debug bool) error { + logs, err := c.cli.ContainerLogs(ctx, id, client.ContainerLogsOptions{ShowStdout: true, ShowStderr: true}) + if err != nil { + return fmt.Errorf("confine: read container logs: %w", err) + } + defer func() { _ = logs.Close() }() + + var stdout, stderr io.Writer = &res.Stdout, &res.Stderr + if debug { + res.DebugData = &bytes.Buffer{} + stdout = io.MultiWriter(&res.Stdout, res.DebugData) + stderr = io.MultiWriter(&res.Stderr, res.DebugData) + } + if _, err := stdcopy.StdCopy(stdout, stderr, logs); err != nil && err != io.EOF { + return fmt.Errorf("confine: demux container logs: %w", err) + } + return nil +} + +// ensureImage pulls the image if it is not already present locally, bounded by +// the policy's pull timeout. +func (c *dockerConfiner) ensureImage(ctx context.Context) error { + if _, err := c.cli.ImageInspect(ctx, c.image); err == nil { + return nil + } + pullCtx, cancel := context.WithTimeout(ctx, c.policy.PullTimeout) + defer cancel() + + c.policy.Logger.Info("confine: pulling docker image", "image", c.image) + reader, err := c.cli.ImagePull(pullCtx, c.image, client.ImagePullOptions{}) + if err != nil { + return fmt.Errorf("confine: pull image %s: %w", c.image, err) + } + defer func() { _ = reader.Close() }() + // Drain the pull progress to completion (pull is async; the stream must be + // fully consumed for the image to finish downloading). + if _, err := io.Copy(io.Discard, reader); err != nil { + return fmt.Errorf("confine: complete image pull %s: %w", c.image, err) + } + return nil +} + +// dockerUser returns the container user. The default is the unprivileged +// 65534:65534 (nobody), never the image's own user, which is root for most base +// images (alpine, debian, python). DockerUser overrides it verbatim, and an +// explicit RunAsUID/RunAsGID is honoured next. +func dockerUser(p Policy) string { + if p.DockerUser != "" { + return p.DockerUser + } + if p.RunAsUID >= 0 { + if p.RunAsGID >= 0 { + return fmt.Sprintf("%d:%d", p.RunAsUID, p.RunAsGID) + } + return fmt.Sprintf("%d", p.RunAsUID) + } + return defaultDockerUser +} + +// dockerContainerConfig builds the portable container config. Stdin plumbing is +// only enabled when the caller supplies input; user is set only when non-empty. +func dockerContainerConfig(image string, cmd []string, workdir string, env []string, user string, wantStdin bool) *container.Config { + cfg := &container.Config{ + Image: image, + Cmd: cmd, + WorkingDir: workdir, + Env: env, + User: user, + AttachStdout: true, + AttachStderr: true, + } + if wantStdin { + cfg.AttachStdin = true + cfg.OpenStdin = true + cfg.StdinOnce = true + } + return cfg +} + +// dockerHostConfig translates the policy into a hardened, deny-by-default host +// config. This is the core of the Docker escape mitigation: +// +// - NetworkMode "none": no network unless explicitly allowed (anti-exfil). +// - CapDrop ALL: strip every Linux capability. +// - no-new-privileges: setuid/setgid binaries can't raise privileges. +// - ReadonlyRootfs + tmpfs work/tmp: the payload can't tamper with the image; +// it only has ephemeral scratch space. +// - private cgroup/ipc/uts namespaces: no sharing with host or other containers. +// - pids/memory/cpu limits: contain fork bombs and resource exhaustion. +// - Privileged is never set. +// +// The capability, privilege and rootfs switches are opt-outs (AllowCapabilities, +// AllowNewPrivileges, WritableRootfs), so a zero-value Policy is hardened. +func dockerHostConfig(p Policy) *container.HostConfig { + hc := &container.HostConfig{ + AutoRemove: false, // we remove explicitly so we can still read logs + ReadonlyRootfs: !p.WritableRootfs, + CgroupnsMode: container.CgroupnsModePrivate, + IpcMode: container.IPCModePrivate, + Privileged: false, + // mode=1777 (sticky, world-writable) so the non-root uid can still + // write scratch; nosuid/nodev keep the tmpfs from carrying setuid/device + // escalation primitives. + Tmpfs: map[string]string{ + containerWorkDir: "rw,nosuid,nodev,size=64m,mode=1777", + "/tmp": "rw,nosuid,nodev,size=64m,mode=1777", + }, + } + + if !p.AllowNetwork { + hc.NetworkMode = "none" + } + if !p.AllowCapabilities { + hc.CapDrop = []string{"ALL"} + } + if !p.AllowNewPrivileges { + hc.SecurityOpt = append(hc.SecurityOpt, "no-new-privileges:true") + } + if p.PidsLimit > 0 { + limit := p.PidsLimit + hc.PidsLimit = &limit + } + if p.MemoryBytes > 0 { + hc.Memory = p.MemoryBytes + } + if p.NanoCPUs > 0 { + hc.NanoCPUs = p.NanoCPUs + } + return hc +} diff --git a/confine/docker_integration_test.go b/confine/docker_integration_test.go new file mode 100644 index 0000000..7a7916f --- /dev/null +++ b/confine/docker_integration_test.go @@ -0,0 +1,141 @@ +package confine + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/moby/moby/client" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dockerOrSkip returns a Docker confiner or skips the test. It only runs where a +// reachable daemon exists (fail-closed New returns an error otherwise), so CI +// hosts without Docker skip cleanly instead of failing. The daemon must also be +// a Linux-container daemon: the confiner uses Linux container semantics (bind +// paths like /gozero-src, tmpfs, --network none), which a Windows-container +// daemon (e.g. the windows-latest runner) rejects, so those hosts skip too. +func dockerOrSkip(t *testing.T) Confiner { + t.Helper() + // A sparse policy on purpose: only Backend is set, so every test below runs + // under the zero-value security switches and proves they are hardened. + c, err := New(&Policy{Backend: BackendDocker}) + if err != nil { + t.Skipf("docker confinement unavailable on this host: %v", err) + } + dc, ok := c.(*dockerConfiner) + if !ok { + _ = c.Close() + t.Skipf("unexpected confiner type %T", c) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ver, err := dc.cli.ServerVersion(ctx, client.ServerVersionOptions{}) + if err != nil { + _ = c.Close() + t.Skipf("docker daemon version unavailable: %v", err) + } + if ver.Os != "linux" { + _ = c.Close() + t.Skipf("docker daemon is a %q-container daemon; confine targets linux containers", ver.Os) + } + return c +} + +// dockerCtx gives the first run enough headroom to pull the base image. +func dockerCtx(t *testing.T) (context.Context, context.CancelFunc) { + t.Helper() + return context.WithTimeout(context.Background(), 3*time.Minute) +} + +func TestDockerConfinedRunProducesOutput(t *testing.T) { + c := dockerOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "echo docker-hello\n") + ctx, cancel := dockerCtx(t) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NoError(t, err) + assert.Equal(t, "docker-hello", strings.TrimSpace(res.Stdout.String())) + assert.Equal(t, 0, res.GetExitCode()) +} + +func TestDockerConfinedPropagatesExitCode(t *testing.T) { + c := dockerOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "exit 5\n") + ctx, cancel := dockerCtx(t) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.Error(t, err, "a non-zero exit must be an error, as on the native confiners") + require.NotNil(t, res) + assert.Equal(t, 5, res.GetExitCode()) +} + +// TestDockerConfinedStreamsStdin exercises the stdin attach path and the +// stdcopy demux of the multiplexed container stream. +func TestDockerConfinedStreamsStdin(t *testing.T) { + c := dockerOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "cat\n") + ctx, cancel := dockerCtx(t) + defer cancel() + + res, err := c.Run(ctx, Spec{ + Command: []string{"/bin/sh", p}, + ScriptPath: p, + ScriptData: data, + Stdin: strings.NewReader("piped-input"), + }) + require.NoError(t, err) + assert.Equal(t, "piped-input", strings.TrimSpace(res.Stdout.String())) +} + +// TestDockerConfinedBlocksNetwork asserts the container runs with no network: +// an outbound connection attempt must fail under the default policy. +func TestDockerConfinedBlocksNetwork(t *testing.T) { + c := dockerOrSkip(t) + defer func() { _ = c.Close() }() + + // busybox wget on the default alpine image; -T bounds the attempt so the + // test fails fast rather than hanging if something is misconfigured. + p, data := writeScript(t, "wget -q -T 5 -O - http://example.com >/dev/null 2>&1 && echo REACHED || echo BLOCKED\n") + ctx, cancel := dockerCtx(t) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NoError(t, err) + assert.Equal(t, "BLOCKED", strings.TrimSpace(res.Stdout.String()), + "container reached the network under a network-denied policy") +} + +// TestDockerSparsePolicyIsHardenedAtRuntime runs a payload under a policy that +// only sets Backend and checks the kernel view from inside the container: no +// effective capabilities, no_new_privs set, a read-only rootfs and the nobody +// uid. It guards the fail-open regression where these were opt-in bools. +func TestDockerSparsePolicyIsHardenedAtRuntime(t *testing.T) { + c := dockerOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "grep -E '^(CapEff|NoNewPrivs):' /proc/self/status\n"+ + "touch /rootfs-probe 2>/dev/null && echo ROOTFS=rw || echo ROOTFS=ro\n"+ + "echo UID=$(id -u) GID=$(id -g)\n") + ctx, cancel := dockerCtx(t) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NoError(t, err) + out := res.Stdout.String() + t.Log(out) + assert.Regexp(t, `CapEff:\s+0000000000000000`, out, "all capabilities must be dropped") + assert.Regexp(t, `NoNewPrivs:\s+1`, out, "no_new_privs must be set") + assert.Contains(t, out, "ROOTFS=ro", "rootfs must be read-only") + assert.Contains(t, out, "UID=65534 GID=65534", "must run as nobody, not the image's root user") +} diff --git a/confine/docker_test.go b/confine/docker_test.go new file mode 100644 index 0000000..17d0ee6 --- /dev/null +++ b/confine/docker_test.go @@ -0,0 +1,101 @@ +package confine + +import ( + "testing" + + "github.com/moby/moby/api/types/container" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDockerHostConfigHardenedByDefault(t *testing.T) { + hc := dockerHostConfig(DefaultPolicy()) + + assert.Equal(t, container.NetworkMode("none"), hc.NetworkMode, "no network by default") + assert.Equal(t, []string{"ALL"}, hc.CapDrop, "all capabilities dropped") + assert.Contains(t, hc.SecurityOpt, "no-new-privileges:true") + assert.True(t, hc.ReadonlyRootfs, "rootfs must be read-only") + assert.False(t, hc.Privileged, "must never be privileged") + assert.Equal(t, container.CgroupnsModePrivate, hc.CgroupnsMode) + assert.Equal(t, container.IPCModePrivate, hc.IpcMode) + require.NotNil(t, hc.PidsLimit) + assert.Equal(t, defaultPidsLimit, *hc.PidsLimit) + assert.Equal(t, defaultMemoryBytes, hc.Memory) + assert.Equal(t, defaultNanoCPUs, hc.NanoCPUs) + assert.Contains(t, hc.Tmpfs, containerWorkDir) + assert.Contains(t, hc.Tmpfs, "/tmp") +} + +// TestDockerSparsePolicyIsHardened guards the fail-open regression where the +// security switches were opt-in bools: a policy that only sets Backend must +// still drop every capability, set no-new-privileges, use a read-only rootfs +// and run as a non-root user. +func TestDockerSparsePolicyIsHardened(t *testing.T) { + p := Policy{Backend: BackendDocker}.normalized() + hc := dockerHostConfig(p) + assert.Equal(t, container.NetworkMode("none"), hc.NetworkMode) + assert.Equal(t, []string{"ALL"}, hc.CapDrop) + assert.Contains(t, hc.SecurityOpt, "no-new-privileges:true") + assert.True(t, hc.ReadonlyRootfs) + assert.Equal(t, "65534:65534", dockerUser(p)) +} + +func TestDockerHostConfigOptOuts(t *testing.T) { + p := DefaultPolicy() + p.AllowCapabilities = true + p.AllowNewPrivileges = true + p.WritableRootfs = true + hc := dockerHostConfig(p) + assert.Empty(t, hc.CapDrop) + assert.NotContains(t, hc.SecurityOpt, "no-new-privileges:true") + assert.False(t, hc.ReadonlyRootfs) +} + +func TestDockerUser(t *testing.T) { + p := DefaultPolicy() + assert.Equal(t, "65534:65534", dockerUser(p), "default must be nobody, not the image user") + p.RunAsUID, p.RunAsGID = 1000, 1001 + assert.Equal(t, "1000:1001", dockerUser(p)) + p.RunAsGID = -1 + assert.Equal(t, "1000", dockerUser(p)) + p.DockerUser = "root" + assert.Equal(t, "root", dockerUser(p), "DockerUser overrides everything") +} + +func TestDockerHostConfigNetworkToggle(t *testing.T) { + p := DefaultPolicy() + p.AllowNetwork = true + hc := dockerHostConfig(p) + assert.NotEqual(t, container.NetworkMode("none"), hc.NetworkMode, "network must not be forced to none when allowed") +} + +func TestDockerContainerConfigStdinGating(t *testing.T) { + cfg := dockerContainerConfig("alpine", []string{"sh", "/gozero-src/s"}, "/work", []string{"HOME=/work"}, "", false) + assert.Equal(t, "alpine", cfg.Image) + assert.Equal(t, "/work", cfg.WorkingDir) + assert.True(t, cfg.AttachStdout) + assert.True(t, cfg.AttachStderr) + assert.False(t, cfg.OpenStdin) + assert.False(t, cfg.AttachStdin) + assert.Empty(t, cfg.User) + + withStdin := dockerContainerConfig("alpine", []string{"sh"}, "/work", nil, "1000:1000", true) + assert.True(t, withStdin.OpenStdin) + assert.True(t, withStdin.AttachStdin) + assert.True(t, withStdin.StdinOnce) + assert.Equal(t, "1000:1000", withStdin.User) +} + +func TestRemapScript(t *testing.T) { + argv := []string{"/usr/bin/python3", "/host/tmp/abc.py", "--flag", "value"} + got := remapScript(argv, "/host/tmp/abc.py", "/gozero-src/abc.py", "python3", true) + assert.Equal(t, []string{"python3", "/gozero-src/abc.py", "--flag", "value"}, got) + + // Without interpreter replacement, only the script path is remapped. + got2 := remapScript(argv, "/host/tmp/abc.py", "/gozero-src/abc.py", "python3", false) + assert.Equal(t, "/usr/bin/python3", got2[0]) + assert.Equal(t, "/gozero-src/abc.py", got2[1]) + + // Original argv is not mutated. + assert.Equal(t, "/host/tmp/abc.py", argv[1]) +} diff --git a/confine/env.go b/confine/env.go new file mode 100644 index 0000000..a0e6ccd --- /dev/null +++ b/confine/env.go @@ -0,0 +1,92 @@ +package confine + +import ( + "os" + "sort" +) + +// childEnv assembles the environment handed to a confined payload. Precedence, +// low to high: +// +// 1. a minimal base (PATH, plus HOME/TMPDIR pointing at the writable workspace), +// 2. the policy's host allow-list (only vars actually present in the host env), +// 3. the caller's custom vars (validated key shape). +// +// Everything not on the allow-list is dropped, so ambient secrets never reach +// untrusted code. The returned map has no ordering guarantees; use +// envMapToSlice to flatten it. +func childEnv(p Policy, workspace string, custom map[string]string) map[string]string { + m := make(map[string]string, len(p.AllowedEnv)+len(custom)+3) + + path := os.Getenv("PATH") + if path == "" { + path = defaultPATH + } + m["PATH"] = path + if workspace != "" { + m["HOME"] = workspace + m["TMPDIR"] = workspace + } + + for _, k := range p.AllowedEnv { + if v, ok := os.LookupEnv(k); ok && v != "" { + m[k] = v + } + } + + for k, v := range custom { + if envKeyOK(k) { + m[k] = v + } + } + return m +} + +// customEnv returns only the validated caller-supplied variables, with no host +// pass-through or base vars. Used by the Docker backend, where the image +// already provides its own base environment (PATH etc.) that we must not +// clobber with host values. +func customEnv(custom map[string]string) map[string]string { + m := make(map[string]string, len(custom)) + for k, v := range custom { + if envKeyOK(k) { + m[k] = v + } + } + return m +} + +// envMapToSlice flattens an env map into deterministic "KEY=VALUE" entries. +// Sorting makes generated argv/config stable for tests and logs. +func envMapToSlice(m map[string]string) []string { + out := make([]string, 0, len(m)) + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + out = append(out, k+"="+m[k]) + } + return out +} + +// envKeyOK accepts identifier-shaped keys only (^[A-Za-z_][A-Za-z0-9_]*$), +// rejecting anything that could smuggle shell/argv metacharacters into an +// environment assignment. +func envKeyOK(k string) bool { + if k == "" { + return false + } + for i, r := range k { + switch { + case r == '_': + case r >= 'A' && r <= 'Z': + case r >= 'a' && r <= 'z': + case i > 0 && r >= '0' && r <= '9': + default: + return false + } + } + return true +} diff --git a/confine/env_test.go b/confine/env_test.go new file mode 100644 index 0000000..234ae84 --- /dev/null +++ b/confine/env_test.go @@ -0,0 +1,47 @@ +package confine + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestChildEnvScrubsAmbientSecrets(t *testing.T) { + t.Setenv("LANG", "en_US.UTF-8") + t.Setenv("GOZERO_SECRET_TOKEN", "s3cr3t") // not on the allow-list + + p := DefaultPolicy() + ws := "/work" + env := childEnv(p, ws, map[string]string{"USER_VAR": "v", "bad-key": "x"}) + + assert.Equal(t, ws, env["HOME"], "HOME points at the writable workspace") + assert.Equal(t, ws, env["TMPDIR"]) + assert.NotEmpty(t, env["PATH"]) + assert.Equal(t, "en_US.UTF-8", env["LANG"], "allow-listed host var passes through") + assert.Equal(t, "v", env["USER_VAR"], "valid custom var passes through") + + _, leaked := env["GOZERO_SECRET_TOKEN"] + assert.False(t, leaked, "ambient secret must not leak into the payload") + _, bad := env["bad-key"] + assert.False(t, bad, "invalid env key must be dropped") +} + +func TestCustomEnvOnlyKeepsValidKeys(t *testing.T) { + got := customEnv(map[string]string{"A": "1", "bad-key": "x", "B2": "2", "": "y"}) + assert.Equal(t, map[string]string{"A": "1", "B2": "2"}, got) +} + +func TestEnvMapToSliceIsSorted(t *testing.T) { + got := envMapToSlice(map[string]string{"B": "2", "A": "1", "C": "3"}) + assert.Equal(t, []string{"A=1", "B=2", "C=3"}, got) +} + +func TestEnvKeyOK(t *testing.T) { + cases := map[string]bool{ + "PATH": true, "_X": true, "A1": true, "a_b_2": true, + "": false, "1A": false, "A-B": false, "A B": false, "A=B": false, "FOO$": false, + } + for k, want := range cases { + assert.Equalf(t, want, envKeyOK(k), "envKeyOK(%q)", k) + } +} diff --git a/confine/exec.go b/confine/exec.go new file mode 100644 index 0000000..6f6f2d6 --- /dev/null +++ b/confine/exec.go @@ -0,0 +1,116 @@ +package confine + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "time" + + "github.com/projectdiscovery/gozero/types" + "github.com/projectdiscovery/utils/errkit" +) + +// killGrace bounds how long cmd.Wait may block on inherited I/O pipes after the +// context is cancelled and the process group has been signalled. It is a +// backstop: normally the process-group kill closes the pipes immediately. +const killGrace = 5 * time.Second + +// runHost executes a fully-formed argv with an explicit, scrubbed environment +// and captures the result. It is the shared execution primitive for the native +// confiners (bubblewrap/Seatbelt), which run the payload as a child of this +// process wrapped by a sandbox launcher. +// +// Unlike gozero/cmdexec, this NEVER inherits the ambient host environment: +// cmd.Env is set to exactly env (a non-nil, possibly empty slice), so secrets +// in os.Environ() cannot leak to the payload through a native confiner that +// (like sandbox-exec) forwards its own environment to the child. +func runHost(ctx context.Context, argv, env []string, stdin io.Reader, dir string, debug bool) (*types.Result, error) { + if len(argv) == 0 { + return nil, ErrNoCommand + } + + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + // On cancellation, kill the whole process group (launcher + interpreter + + // its children) and bound how long Wait may block on inherited pipes. + configureProcessGroup(cmd) + cmd.WaitDelay = killGrace + // Force an explicit environment. A nil slice would inherit the parent's, so + // we always assign — even when empty — to guarantee a clean environment. + if env == nil { + env = []string{} + } + cmd.Env = env + if dir != "" { + if st, err := os.Stat(dir); err == nil && st.IsDir() { + cmd.Dir = dir + } + } + if stdin != nil { + cmd.Stdin = stdin + } + + res := &types.Result{Command: cmd.String()} + if debug { + res.DebugData = &bytes.Buffer{} + cmd.Stdout = io.MultiWriter(&res.Stdout, res.DebugData) + cmd.Stderr = io.MultiWriter(&res.Stderr, res.DebugData) + } else { + cmd.Stdout = &res.Stdout + cmd.Stderr = &res.Stderr + } + + if err := cmd.Start(); err != nil { + return res, errkit.WithMessagef(err, "failed to start confined command got: %v", res.Stderr.String()) + } + if err := cmd.Wait(); err != nil { + if execErr, ok := err.(*exec.ExitError); ok { + res.SetExitError(execErr) + res.SetExitCode(execErr.ExitCode()) + } + return res, errkit.WithMessagef(err, "confined command failed got: %v", res.Stderr.String()) + } + return res, nil +} + +// remapScript returns a copy of argv with the script path (if present) replaced +// by dst and, when replaceInterp is set, the interpreter (argv[0]) replaced by +// interp. Used by backends that relocate the source into the sandbox (Docker +// copies it to a container path; the interpreter is referenced by base name +// because the host's absolute path is meaningless in the image). +func remapScript(argv []string, scriptPath, dst, interp string, replaceInterp bool) []string { + out := make([]string, len(argv)) + copy(out, argv) + for i, v := range out { + if scriptPath != "" && v == scriptPath { + out[i] = dst + } + } + if replaceInterp && len(out) > 0 { + out[0] = interp + } + return out +} + +// ensureWorkspace returns the workspace to use for an execution and whether the +// caller owns (must clean up) it. When spec.Workdir is set it is used as-is +// (not owned); otherwise an ephemeral directory is created under the policy's +// temp dir (owned). +func ensureWorkspace(p Policy, spec Spec) (dir string, owned bool, err error) { + if spec.Workdir != "" { + return spec.Workdir, false, nil + } + if p.Workspace != "" { + return p.Workspace, false, nil + } + base := p.Tmp + if base == "" { + base = os.TempDir() + } + dir, err = os.MkdirTemp(base, "gozero-confine-*") + if err != nil { + return "", false, err + } + return dir, true, nil +} diff --git a/confine/exec_integration_test.go b/confine/exec_integration_test.go new file mode 100644 index 0000000..9f53063 --- /dev/null +++ b/confine/exec_integration_test.go @@ -0,0 +1,364 @@ +package confine + +import ( + "context" + "net" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// nativeOrSkip returns an available native confiner or skips the test. These +// tests exercise the real OS boundary and only run where the mechanism +// (bubblewrap / Seatbelt) is installed. +func nativeOrSkip(t *testing.T) Confiner { + t.Helper() + c, err := New(&Policy{Backend: BackendAuto}) + if err != nil { + t.Skipf("no native confiner available on this host: %v", err) + } + return c +} + +func writeScript(t *testing.T, body string) (path string, data []byte) { + t.Helper() + dir := t.TempDir() + p := filepath.Join(dir, "script.sh") + data = []byte(body) + require.NoError(t, os.WriteFile(p, data, 0o600)) + return p, data +} + +func TestConfinedRunProducesOutput(t *testing.T) { + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "echo confined-hello\n") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{ + Command: []string{"/bin/sh", p}, + ScriptPath: p, + ScriptData: data, + }) + require.NoError(t, err) + assert.Equal(t, "confined-hello", strings.TrimSpace(res.Stdout.String())) + assert.Equal(t, 0, res.GetExitCode()) +} + +func TestConfinedRunPropagatesExitCode(t *testing.T) { + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "exit 7\n") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + res, _ := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NotNil(t, res) + assert.Equal(t, 7, res.GetExitCode()) +} + +// TestConfinedBlocksWriteOutsideWorkspace is the escape assertion: a payload +// must not be able to write outside its workspace/temp. We aim at a file under +// the real user home (neither the workspace nor the system temp dir) and verify +// it is never created. +func TestConfinedBlocksWriteOutsideWorkspace(t *testing.T) { + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + home, err := os.UserHomeDir() + if err != nil || home == "" { + t.Skip("no home dir to target for the escape test") + } + escape := filepath.Join(home, ".gozero_confine_escape_probe") + _ = os.Remove(escape) + t.Cleanup(func() { _ = os.Remove(escape) }) + + p, data := writeScript(t, "echo pwned > \"$ESCAPE_PATH\"\n") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + _, _ = c.Run(ctx, Spec{ + Command: []string{"/bin/sh", p}, + ScriptPath: p, + ScriptData: data, + Env: map[string]string{"ESCAPE_PATH": escape}, + }) + + _, statErr := os.Stat(escape) + assert.Truef(t, os.IsNotExist(statErr), + "payload escaped confinement: wrote %s (statErr=%v)", escape, statErr) +} + +// TestConfinedBlocksWriteToSharedTemp checks that the payload cannot write in +// the host temp dir outside its own workspace, which is shared with other +// processes and with other confined runs. +func TestConfinedBlocksWriteToSharedTemp(t *testing.T) { + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + escape := filepath.Join(os.TempDir(), "gozero_confine_tmp_probe") + _ = os.Remove(escape) + t.Cleanup(func() { _ = os.Remove(escape) }) + + p, data := writeScript(t, "echo pwned > \"$ESCAPE_PATH\"\necho ok > \"$HOME/inside\"\ncat \"$HOME/inside\"\n") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + res, _ := c.Run(ctx, Spec{ + Command: []string{"/bin/sh", p}, + ScriptPath: p, + ScriptData: data, + Env: map[string]string{"ESCAPE_PATH": escape}, + }) + require.NotNil(t, res) + assert.Contains(t, res.Stdout.String(), "ok", "the workspace must stay writable") + + _, statErr := os.Stat(escape) + assert.Truef(t, os.IsNotExist(statErr), + "payload wrote the shared temp dir: %s (statErr=%v)", escape, statErr) +} + +// TestConfinedEnvIsScrubbed verifies that an ambient host secret does not reach +// the payload's environment. +func TestConfinedEnvIsScrubbed(t *testing.T) { + t.Setenv("GOZERO_CONFINE_SECRET", "leak-me") + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "printf '[%s]' \"$GOZERO_CONFINE_SECRET\"\n") + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NoError(t, err) + assert.Equal(t, "[]", strings.TrimSpace(res.Stdout.String()), + "ambient secret leaked into confined payload") +} + +// TestConfinedBlocksNetwork is the network-escape assertion: with the default +// (network-denied) policy a payload must not be able to reach a listening +// socket, even on loopback. We stand up a local TCP server, confirm the client +// technique works unconfined, then assert the confined payload cannot reach it. +func TestConfinedBlocksNetwork(t *testing.T) { + bash, err := exec.LookPath("bash") + if err != nil { + t.Skip("bash is required for the /dev/tcp network probe") + } + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _, _ = conn.Write([]byte("REACHED\n")) + _ = conn.Close() + } + }() + + _, port, err := net.SplitHostPort(ln.Addr().String()) + require.NoError(t, err) + + // bash's /dev/tcp connects to the listener and echoes whatever it sends. + p, data := writeScript(t, "exec 3<>/dev/tcp/127.0.0.1/$PORT && cat <&3\n") + env := map[string]string{"PORT": port} + + // Sanity: unconfined the technique must actually reach the server, otherwise + // this environment cannot even do loopback and the test proves nothing. + host, err := New(&Policy{Backend: BackendHost}) + require.NoError(t, err) + hctx, hcancel := context.WithTimeout(context.Background(), 20*time.Second) + defer hcancel() + hres, _ := host.Run(hctx, Spec{Command: []string{bash, p}, ScriptPath: p, ScriptData: data, Env: env}) + if hres == nil || !strings.Contains(hres.Stdout.String(), "REACHED") { + t.Skip("loopback not reachable even unconfined; cannot assert network denial here") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + res, _ := c.Run(ctx, Spec{Command: []string{bash, p}, ScriptPath: p, ScriptData: data, Env: env}) + require.NotNil(t, res) + assert.NotContains(t, res.Stdout.String(), "REACHED", + "payload reached the network under a network-denied policy") +} + +// TestConfinedTimeoutKillsPayload verifies that Policy.Timeout is enforced: a +// long-running payload is killed well before it would finish on its own. +func TestConfinedTimeoutKillsPayload(t *testing.T) { + c, err := New(&Policy{Backend: BackendAuto, Timeout: 750 * time.Millisecond}) + if err != nil { + t.Skipf("no native confiner available on this host: %v", err) + } + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "sleep 30\n") + + start := time.Now() + _, err = c.Run(context.Background(), Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + elapsed := time.Since(start) + + require.Error(t, err, "expected the payload to be killed by the timeout") + assert.Less(t, elapsed, 10*time.Second, + "timeout was not enforced; payload ran for %s", elapsed) +} + +// TestConfinedHidesRealHome runs against the real $HOME: the payload must not +// see any entry in it (Seatbelt denies the read, bwrap shows an empty tmpfs) +// and must not read ~/.ssh. +func TestConfinedHidesRealHome(t *testing.T) { + home := hiddenHome() + if home == "" { + t.Skip("no home directory to hide") + } + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "ls -A \"$REAL_HOME\" 2>/dev/null | wc -l | tr -d ' '\n"+ + "ls -A \"$REAL_HOME/.ssh\" >/dev/null 2>&1 && echo SSH=readable || echo SSH=denied\n") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data, Env: map[string]string{"REAL_HOME": home}}) + require.NoError(t, err) + out := strings.Fields(res.Stdout.String()) + require.Len(t, out, 2, "unexpected output: %q", res.Stdout.String()) + assert.Equal(t, "0", out[0], "home must look empty inside the sandbox") + assert.Equal(t, "SSH=denied", out[1]) +} + +// TestConfinedHomeAllowList checks that only ReadOnlyPaths entries under $HOME +// are readable, and that they stay read-only. +func TestConfinedHomeAllowList(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".secret"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".secret", "key"), []byte("TOPSECRET"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(home, ".pyenv", "bin"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(home, ".pyenv", "bin", "tool"), []byte("ALLOWED"), 0o644)) + + c, err := New(&Policy{Backend: BackendAuto, ReadOnlyPaths: []string{"~/.pyenv"}}) + if err != nil { + t.Skipf("no native confiner available on this host: %v", err) + } + defer func() { _ = c.Close() }() + + p, data := writeScript(t, "cat \"$H/.pyenv/bin/tool\" 2>/dev/null || echo NO_TOOL\n"+ + "cat \"$H/.secret/key\" 2>/dev/null || echo NO_SECRET\n"+ + "echo x > \"$H/.pyenv/bin/tool\" 2>/dev/null && echo WROTE || echo RO\n") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data, Env: map[string]string{"H": canonicalPath(home)}}) + require.NoError(t, err) + out := res.Stdout.String() + assert.Contains(t, out, "ALLOWED", "allow-listed home path must be readable") + assert.Contains(t, out, "NO_SECRET", "other home paths must stay hidden") + assert.NotContains(t, out, "TOPSECRET") + assert.Contains(t, out, "RO", "allow-listed path must be read-only") +} + +// TestConfinedHidesSystemDirs checks the dirs hidden besides the user's home: +// other users' homes, root's home and (Linux) the runtime socket dirs. The +// payload must see none of their entries. +func TestConfinedHidesSystemDirs(t *testing.T) { + roots := hiddenRoots() + if len(roots) == 0 { + t.Skip("no hidden dirs on this platform") + } + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + var body strings.Builder + for _, root := range roots { + body.WriteString("echo \"" + root + "=$(ls -A '" + root + "' 2>/dev/null | wc -l | tr -d ' ')\"\n") + } + p, data := writeScript(t, body.String()) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + require.NoError(t, err) + for _, root := range roots { + assert.Contains(t, res.Stdout.String(), root+"=0\n", "%s must look empty inside the sandbox", root) + } +} + +// TestConfinedHidesUserTemp is the temp dir escape assertion: the per-user +// temp dir holds other runs' files (such as a scan's browser profiles and +// dedupe db), so a payload must not read a file there outside its own +// workspace or next to its script, nor list other runs' dirs in the temp +// dir. The shared /tmp and the macOS per-user cache dir are hidden too. Its +// own workspace must stay writable. +func TestConfinedHidesUserTemp(t *testing.T) { + c := nativeOrSkip(t) + defer func() { _ = c.Close() }() + + other := t.TempDir() + canary := filepath.Join(other, "canary") + require.NoError(t, os.WriteFile(canary, []byte("TEMPSECRET"), 0o600)) + + // A canary in the shared /tmp, and one in the macOS per-user cache dir + // (C/ next to T/), which holds other apps' caches. + sharedDir, err := os.MkdirTemp("/tmp", "gozero-canary-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(sharedDir) }) + sharedCanary := filepath.Join(sharedDir, "canary") + require.NoError(t, os.WriteFile(sharedCanary, []byte("SHAREDSECRET"), 0o600)) + cacheCanary := "" + if ud := darwinUserDir(); ud != "" { + cacheDir, err := os.MkdirTemp(filepath.Join(ud, "C"), "gozero-canary-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(cacheDir) }) + cacheCanary = filepath.Join(cacheDir, "canary") + require.NoError(t, os.WriteFile(cacheCanary, []byte("CACHESECRET"), 0o600)) + } + + p, data := writeScript(t, "cat \"$CANARY\" 2>/dev/null || echo NO_CANARY\n"+ + "cat \"$SHARED\" 2>/dev/null || echo NO_SHARED\n"+ + "cat \"$CACHE\" 2>/dev/null || echo NO_CACHE\n"+ + "cat \"$SIBLING\" 2>/dev/null || echo NO_SIBLING\n"+ + "echo \"SEEN=$(ls -A \"$TMPROOT\" 2>/dev/null | grep -c \"$OTHER\")\"\n"+ + "echo w > ws-file && cat ws-file\n") + sibling := filepath.Join(filepath.Dir(p), "sibling") + require.NoError(t, os.WriteFile(sibling, []byte("SIBLINGSECRET"), 0o600)) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + res, err := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data, Env: map[string]string{ + "CANARY": canonicalPath(canary), + "SHARED": canonicalPath(sharedCanary), + "CACHE": canonicalPath(cacheCanary), + "SIBLING": canonicalPath(sibling), + "TMPROOT": canonicalPath(os.TempDir()), + "OTHER": filepath.Base(filepath.Dir(other)), + }}) + require.NoError(t, err) + out := res.Stdout.String() + assert.Contains(t, out, "NO_CANARY", "a file in the temp dir outside the workspace must be unreadable") + assert.NotContains(t, out, "TEMPSECRET") + assert.Contains(t, out, "NO_SHARED", "a file in the shared /tmp must be unreadable") + assert.NotContains(t, out, "SHAREDSECRET") + if cacheCanary != "" { + assert.Contains(t, out, "NO_CACHE", "a file in the per-user cache dir must be unreadable") + assert.NotContains(t, out, "CACHESECRET") + } + assert.Contains(t, out, "NO_SIBLING", "only the script file is readable, not its dir") + assert.NotContains(t, out, "SIBLINGSECRET") + assert.Contains(t, out, "SEEN=0\n", "other runs' dirs in the temp dir must not be listable") + assert.Contains(t, out, "w\n", "the workspace must stay writable") +} diff --git a/confine/exec_other.go b/confine/exec_other.go new file mode 100644 index 0000000..68fda16 --- /dev/null +++ b/confine/exec_other.go @@ -0,0 +1,9 @@ +//go:build !unix + +package confine + +import "os/exec" + +// configureProcessGroup is a no-op on platforms without POSIX process groups; +// the WaitDelay backstop in runHost still bounds a stuck wait. +func configureProcessGroup(cmd *exec.Cmd) {} diff --git a/confine/exec_unix.go b/confine/exec_unix.go new file mode 100644 index 0000000..b5cd92c --- /dev/null +++ b/confine/exec_unix.go @@ -0,0 +1,24 @@ +//go:build unix + +package confine + +import ( + "os/exec" + "syscall" +) + +// configureProcessGroup puts the confined command in its own process group and +// makes context cancellation kill the entire group. Without this, killing the +// launcher (e.g. sandbox-exec) on timeout leaves the reparented interpreter and +// its children alive; because they inherit the stdout/stderr pipes, cmd.Wait +// would block until they exit, defeating Policy.Timeout. +func configureProcessGroup(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + // Negative pid targets the whole process group. + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } +} diff --git a/confine/native_darwin.go b/confine/native_darwin.go new file mode 100644 index 0000000..40e575b --- /dev/null +++ b/confine/native_darwin.go @@ -0,0 +1,89 @@ +//go:build darwin + +package confine + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + + "github.com/projectdiscovery/gozero/types" +) + +// newNativeConfiner returns a Seatbelt confiner when sandbox-exec is on PATH, +// else nil so New fails closed. +func newNativeConfiner(p Policy) Confiner { + bin, err := exec.LookPath("sandbox-exec") + if err != nil { + p.Logger.Debug("confine: seatbelt unavailable", "reason", "sandbox-exec not found on PATH") + return nil + } + if err := probeSeatbelt(bin); err != nil { + p.Logger.Debug("confine: seatbelt unavailable", "reason", "probe failed", "error", err) + return nil + } + return &seatbeltConfiner{bin: bin, policy: p} +} + +// seatbeltProbes caches one probe result per sandbox-exec binary. +var seatbeltProbes sync.Map + +// probeSeatbelt checks that sandbox-exec can apply a profile here: it fails, +// for example, when the process already runs inside another sandbox. +func probeSeatbelt(bin string) error { + if v, ok := seatbeltProbes.Load(bin); ok { + if v == nil { + return nil + } + return v.(error) + } + out, err := exec.Command(bin, "-p", "(version 1)(allow default)", "/usr/bin/true").CombinedOutput() + if err != nil { + err = fmt.Errorf("%w: %s", err, out) + seatbeltProbes.Store(bin, err) + return err + } + seatbeltProbes.Store(bin, nil) + return nil +} + +var _ Confiner = (*seatbeltConfiner)(nil) + +// seatbeltConfiner confines the payload on macOS via sandbox-exec + an SBPL +// profile. sandbox-exec forwards its own environment to the child, so we run it +// with the scrubbed environment directly (no --setenv equivalent exists). +type seatbeltConfiner struct { + bin string + policy Policy +} + +func (c *seatbeltConfiner) Name() string { return "seatbelt" } +func (c *seatbeltConfiner) Close() error { return nil } + +func (c *seatbeltConfiner) Run(ctx context.Context, spec Spec) (*types.Result, error) { + if len(spec.Command) == 0 { + return nil, ErrNoCommand + } + + ctx, cancel := withTimeout(ctx, c.policy.Timeout) + defer cancel() + + ws, owned, err := ensureWorkspace(c.policy, spec) + if err != nil { + return nil, err + } + if owned { + defer func() { _ = os.RemoveAll(ws) }() + } + + profile := seatbeltProfile(c.policy, ws, spec.ScriptPath) + + argv := make([]string, 0, len(spec.Command)+3) + argv = append(argv, c.bin, "-p", profile) + argv = append(argv, spec.Command...) + + env := childEnv(c.policy, ws, spec.Env) + return runHost(ctx, argv, envMapToSlice(env), spec.Stdin, ws, spec.Debug) +} diff --git a/confine/native_linux.go b/confine/native_linux.go new file mode 100644 index 0000000..a423d77 --- /dev/null +++ b/confine/native_linux.go @@ -0,0 +1,94 @@ +//go:build linux + +package confine + +import ( + "context" + "fmt" + "os" + "os/exec" + "sync" + + "github.com/projectdiscovery/gozero/types" +) + +// newNativeConfiner returns a bubblewrap confiner when bwrap is on PATH and +// can build a sandbox here, else nil so New fails closed. +func newNativeConfiner(p Policy) Confiner { + bin, err := exec.LookPath("bwrap") + if err != nil { + p.Logger.Debug("confine: bubblewrap unavailable", "reason", "bwrap not found on PATH") + return nil + } + if err := probeBwrap(bin); err != nil { + p.Logger.Debug("confine: bubblewrap unavailable", "reason", "probe failed", "error", err) + return nil + } + return &bubblewrapConfiner{bin: bin, policy: p} +} + +// bwrapProbes caches one probe result per bwrap binary. +var bwrapProbes sync.Map + +// probeBwrap runs a trivial command under the same namespaces the payload +// gets. bwrap is often installed where it cannot work: unprivileged +// containers without user namespaces, or a process under Landlock, which +// forbids mount(2). Without the probe, New would succeed and every Run fail. +func probeBwrap(bin string) error { + if v, ok := bwrapProbes.Load(bin); ok { + if v == nil { + return nil + } + return v.(error) + } + trueBin, err := exec.LookPath("true") + if err == nil { + var out []byte + out, err = exec.Command(bin, "--unshare-all", "--die-with-parent", "--ro-bind", "/", "/", "--proc", "/proc", "--dev", "/dev", "--", trueBin).CombinedOutput() + if err != nil { + err = fmt.Errorf("%w: %s", err, out) + } + } + if err == nil { + bwrapProbes.Store(bin, nil) + return nil + } + bwrapProbes.Store(bin, err) + return err +} + +var _ Confiner = (*bubblewrapConfiner)(nil) + +// bubblewrapConfiner confines the payload on Linux via bubblewrap. +type bubblewrapConfiner struct { + bin string + policy Policy +} + +func (c *bubblewrapConfiner) Name() string { return "bubblewrap" } +func (c *bubblewrapConfiner) Close() error { return nil } + +func (c *bubblewrapConfiner) Run(ctx context.Context, spec Spec) (*types.Result, error) { + if len(spec.Command) == 0 { + return nil, ErrNoCommand + } + + ctx, cancel := withTimeout(ctx, c.policy.Timeout) + defer cancel() + + ws, owned, err := ensureWorkspace(c.policy, spec) + if err != nil { + return nil, err + } + if owned { + defer func() { _ = os.RemoveAll(ws) }() + } + + env := childEnv(c.policy, ws, spec.Env) + argv := bwrapArgs(c.bin, c.policy, ws, spec.ScriptPath, env, spec.Command) + + // The bwrap launcher itself needs no environment: the child's environment is + // injected via --setenv, so we run the wrapper with an empty environment as + // defense in depth. + return runHost(ctx, argv, []string{}, spec.Stdin, "", spec.Debug) +} diff --git a/confine/native_other.go b/confine/native_other.go new file mode 100644 index 0000000..11dcf1e --- /dev/null +++ b/confine/native_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin + +package confine + +// newNativeConfiner has no native mechanism on this OS, so it returns nil and +// New fails closed for BackendAuto/native. Use BackendDocker where a daemon is +// available, or BackendHost to run unconfined deliberately. +func newNativeConfiner(p Policy) Confiner { + p.Logger.Debug("confine: no native confiner for this OS") + return nil +} diff --git a/confine/seatbelt_profile.go b/confine/seatbelt_profile.go new file mode 100644 index 0000000..e817d83 --- /dev/null +++ b/confine/seatbelt_profile.go @@ -0,0 +1,176 @@ +package confine + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" +) + +// seatbeltProfile builds the SBPL (Sandbox Profile Language) policy handed to +// macOS sandbox-exec. +// +// Posture: (allow default) opens the world, then file *writes* are denied +// everywhere and re-allowed only under the workspace, the temp dir and a few +// harmless /dev nodes; reads of $HOME, the other users' homes, root's home, +// the per-user dir under /var/folders and /tmp are denied except for the workspace, the policy's Tmp +// dir, the script file and the policy's ReadOnlyPaths allow list; optionally +// all network is denied. This "allow-then-deny" shape is the pragmatic macOS +// state of the art (the same approach shipping browsers use): a hard deny-all +// base is impractical on macOS because the dynamic linker, interpreters and +// system frameworks read from a large, moving set of paths, so we constrain +// the things that actually matter for untrusted execution — reading the +// user's secrets, writing outside the workspace and phoning home. +// +// SBPL is last-match-wins, so the deny rules must come after (allow default) +// and each re-allow must come after its deny. +// +// Paths MUST be canonical: Seatbelt matches realpaths, and on macOS /tmp is a +// symlink to /private/tmp (likewise per-user TMPDIR under /var/folders), so an +// unresolved path silently fails to match and the write is denied. +func seatbeltProfile(p Policy, workspace, scriptPath string) string { + ws := canonicalPath(workspace) + tmpC := canonicalPath(p.Tmp) + + var b strings.Builder + b.WriteString("(version 1)\n") + b.WriteString("(allow default)\n") + + // The temp dirs are hidden too: they hold the files of other runs, such + // as other scans' work dirs and other payloads' scripts, and other apps' + // caches. + if roots := hiddenRoots(userTempDirs()...); len(roots) > 0 { + readable := hiddenReadable(roots, p.ReadOnlyPaths, ws, tmpC) + // Only the script file is re-allowed, never its directory: the script + // usually sits in the shared temp dir next to other runs' files. + script := "" + if sp := canonicalPath(scriptPath); containingRoot(roots, sp) != "" && !underDir(sp, ws) { + script = sp + } + for _, root := range roots { + b.WriteString("(deny file-read* (subpath " + sbplString(root) + ") (with no-report))\n") + } + if len(readable) > 0 || script != "" { + // Path lookups (realpath, getcwd) stat every parent directory, so + // the parents of a re-allowed path keep metadata access. They stay + // unlistable: file-read-data on them is still denied. + b.WriteString("(allow file-read-metadata\n") + parents := readable + if script != "" { + parents = append(append([]string(nil), readable...), script) + } + for _, dir := range hiddenParents(roots, parents) { + b.WriteString(" (literal " + sbplString(dir) + ")\n") + } + b.WriteString(")\n") + b.WriteString("(allow file-read*\n") + for _, r := range readable { + b.WriteString(" (subpath " + sbplString(r) + ")\n") + } + if script != "" { + b.WriteString(" (literal " + sbplString(script) + ")\n") + } + b.WriteString(")\n") + } + // The /usr/bin interpreter shims (xcrun) read their lookup cache from + // the per-user temp dir; without it they fall back to xcodebuild and + // print errors on stderr. The same file is the one write allowed below. + b.WriteString("(allow file-read* (regex " + xcrunDBRegex + "))\n") + } + + b.WriteString("(deny file-write* (with no-report))\n") + b.WriteString("(allow file-write*\n") + if ws != "" { + b.WriteString(" (subpath " + sbplString(ws) + ")\n") + } + if tmpC != "" && tmpC != ws { + b.WriteString(" (subpath " + sbplString(tmpC) + ")\n") + } + b.WriteString(" (literal \"/dev/null\")\n") + b.WriteString(" (literal \"/dev/stdout\")\n") + b.WriteString(" (literal \"/dev/stderr\")\n") + b.WriteString(" (regex #\"^/dev/tty\")\n") + // The /usr/bin interpreter shims (xcrun) write a lookup cache into the + // per-user temp dir; without it every run prints an error on stderr. + b.WriteString(" (regex " + xcrunDBRegex + ")\n") + b.WriteString(")\n") + if !p.AllowNetwork { + b.WriteString("(deny network*)\n") + } + return b.String() +} + +// xcrunDBRegex matches the xcrun lookup cache in the per-user temp dir (T), +// and the xcrun_db-XXXXXXXX temp file that xcrun writes and renames over it. +const xcrunDBRegex = `#"^/private/var/folders/[^/]+/[^/]+/T/xcrun_db(-[^/]+)?$"` + +// darwinUserDir is the per-user dir that macOS gives each user under +// /var/folders: the parent of DARWIN_USER_TEMP_DIR (T), which also holds the +// cache dir (C) and the user dir (0). It can differ from $TMPDIR when the +// caller overrides TMPDIR. Read once, with an absolute getconf path. +var darwinUserDir = sync.OnceValue(func() string { + if runtime.GOOS != "darwin" { + return "" + } + out, err := exec.Command("/usr/bin/getconf", "DARWIN_USER_TEMP_DIR").Output() + if err != nil { + return "" + } + t := strings.TrimSpace(string(out)) + if t == "" { + return "" + } + return filepath.Dir(filepath.Clean(t)) +}) + +// userTempDirs returns the canonical temp dirs that Seatbelt hides: $TMPDIR +// (os.TempDir), the macOS per-user dir under /var/folders and the shared /tmp +// (/private/tmp on macOS). A dir less than two levels deep (such as /private) +// is never hidden, because it holds system files; hiddenRoots drops dirs that +// do not exist. +func userTempDirs() []string { + var out []string + for _, d := range []string{os.TempDir(), darwinUserDir(), "/tmp"} { + if d == "" { + continue + } + c := canonicalPath(d) + if strings.Count(strings.Trim(c, string(filepath.Separator)), string(filepath.Separator)) < 1 { + continue + } + out = append(out, c) + } + return out +} + +// hiddenParents returns, for each path, its hidden root and every directory +// between that root and the path (exclusive), deduplicated, in a stable order. +func hiddenParents(roots []string, paths []string) []string { + seen := map[string]bool{} + var out []string + for _, p := range paths { + root := containingRoot(roots, p) + if root == "" { + continue + } + if !seen[root] { + seen[root] = true + out = append(out, root) + } + for dir := filepath.Dir(p); underDir(dir, root) && !seen[dir]; dir = filepath.Dir(dir) { + seen[dir] = true + out = append(out, dir) + } + } + return out +} + +// sbplString quotes a path as an SBPL string literal, escaping the only two +// metacharacters valid inside an SBPL "..." (backslash and double quote). +func sbplString(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `"`, `\"`) + return `"` + s + `"` +} diff --git a/confine/seatbelt_profile_test.go b/confine/seatbelt_profile_test.go new file mode 100644 index 0000000..e5ded04 --- /dev/null +++ b/confine/seatbelt_profile_test.go @@ -0,0 +1,123 @@ +package confine + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeatbeltProfileConfinesWrites(t *testing.T) { + ws := t.TempDir() + profile := seatbeltProfile(Policy{Tmp: ws}, ws, "") + + assert.Contains(t, profile, "(version 1)") + assert.Contains(t, profile, "(allow default)") + assert.Contains(t, profile, "(deny file-write* (with no-report))") + assert.Contains(t, profile, "(subpath "+sbplString(canonicalPath(ws))+")") + assert.Contains(t, profile, "(deny network*)", "network must be denied when not allowed") +} + +func TestSeatbeltProfileNetworkToggle(t *testing.T) { + ws := t.TempDir() + assert.NotContains(t, seatbeltProfile(Policy{Tmp: ws, AllowNetwork: true}, ws, ""), "(deny network*)") + assert.Contains(t, seatbeltProfile(Policy{Tmp: ws}, ws, ""), "(deny network*)") +} + +func TestSbplStringEscapes(t *testing.T) { + assert.Equal(t, `"/a/b"`, sbplString("/a/b")) + assert.Equal(t, `"/a\"b"`, sbplString(`/a"b`)) + assert.Equal(t, `"/a\\b"`, sbplString(`/a\b`)) +} + +// The deny-all base must precede the write re-allow (SBPL is last-match-wins). +func TestSeatbeltProfileRuleOrder(t *testing.T) { + ws := t.TempDir() + profile := seatbeltProfile(Policy{Tmp: ws}, ws, "") + denyIdx := strings.Index(profile, "(deny file-write*") + allowIdx := strings.Index(profile, "(allow file-write*") + assert.Less(t, denyIdx, allowIdx, "write deny must come before the workspace re-allow") +} + +func TestSeatbeltProfileHidesHome(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Seatbelt runs only on macOS and takes POSIX paths") + } + home := t.TempDir() + t.Setenv("HOME", home) + ch := canonicalPath(home) + ws := filepath.Join(ch, "work", "ws") + + p := Policy{ReadOnlyPaths: []string{"~/.pyenv", "/opt/homebrew"}} + profile := seatbeltProfile(p, ws, filepath.Join(ch, "src", "s.py")) + + // The test home sits in the temp dir, which is hidden too; the deny then + // names the outermost hidden dir that holds the home. + root := containingRoot(hiddenRoots(userTempDirs()...), ch) + require.NotEmpty(t, root, "home must be inside a hidden root") + deny := "(deny file-read* (subpath " + sbplString(root) + ") (with no-report))" + assert.Contains(t, profile, deny, "home reads must be denied") + allowIdx := strings.Index(profile, "(allow file-read*") + assert.Greater(t, allowIdx, strings.Index(profile, deny), "re-allow must follow the deny") + allow := profile[allowIdx:] + assert.Contains(t, allow, "(subpath "+sbplString(filepath.Join(ch, ".pyenv"))+")", "allow-listed path is readable") + assert.Contains(t, allow, "(subpath "+sbplString(ws)+")", "workspace under home is readable") + assert.Contains(t, allow, "(literal "+sbplString(filepath.Join(ch, "src", "s.py"))+")", "script file under home is readable") + assert.NotContains(t, allow, "(subpath "+sbplString(filepath.Join(ch, "src"))+")", "script dir under home stays hidden") + assert.NotContains(t, profile, "/opt/homebrew", "paths outside home are not hidden and need no rule") + // Parents of a re-allowed path keep metadata access only. + assert.Contains(t, profile, "(literal "+sbplString(filepath.Join(ch, "work"))+")") +} + +func TestSeatbeltProfileHidesSystemDirs(t *testing.T) { + ws := t.TempDir() + profile := seatbeltProfile(Policy{}, ws, "") + for _, root := range hiddenRoots() { + assert.Contains(t, profile, "(deny file-read* (subpath "+sbplString(root)+") (with no-report))", "%s must be hidden", root) + } + if runtime.GOOS == "darwin" { + assert.Contains(t, profile, `(subpath "/Users")`, "other users' homes must be hidden") + } +} + +// The per-user temp dir holds other runs' files (other scans' work dirs, +// other payloads' scripts), so it is hidden, with the rest of the per-user +// dir (C/ caches, 0/) and the shared /tmp. Only the run's own workspace and +// script file come back, plus the xcrun cache the /usr/bin shims need. +func TestSeatbeltProfileHidesUserTemp(t *testing.T) { + if runtime.GOOS != "darwin" { + t.Skip("Seatbelt and the per-user temp dir exist only on macOS") + } + tmp := canonicalPath(os.TempDir()) + ws := canonicalPath(t.TempDir()) + scriptDir := canonicalPath(t.TempDir()) + script := filepath.Join(scriptDir, "s.sh") + require.NoError(t, os.WriteFile(script, []byte("true\n"), 0o600)) + + profile := seatbeltProfile(Policy{}, ws, script) + + root := containingRoot(hiddenRoots(userTempDirs()...), tmp) + require.NotEmpty(t, root, "the temp dir must be inside a hidden root") + deny := "(deny file-read* (subpath " + sbplString(root) + ") (with no-report))" + assert.Contains(t, profile, deny, "temp dir reads must be denied") + allowIdx := strings.Index(profile, "(allow file-read*") + assert.Greater(t, allowIdx, strings.Index(profile, deny), "re-allow must follow the deny") + allow := profile[allowIdx:strings.Index(profile, "(deny file-write*")] + assert.Contains(t, allow, "(subpath "+sbplString(ws)+")", "workspace is readable") + assert.Contains(t, allow, "(literal "+sbplString(script)+")", "script file is readable") + assert.NotContains(t, allow, sbplString(scriptDir)+")", "script dir stays hidden") + assert.NotContains(t, allow, "(subpath "+sbplString(tmp)+")", "temp dir stays hidden") + assert.Contains(t, allow, "(regex "+xcrunDBRegex+")", "xcrun cache is readable") + + if runtime.GOOS == "darwin" { + assert.Contains(t, profile, `(deny file-read* (subpath "/private/tmp") (with no-report))`, "the shared /tmp must be hidden") + ud := canonicalPath(darwinUserDir()) + require.NotEmpty(t, ud) + assert.Contains(t, profile, "(deny file-read* (subpath "+sbplString(ud)+") (with no-report))", "the whole per-user dir (T, C, 0) must be hidden") + assert.NotContains(t, allow, "(subpath "+sbplString(filepath.Join(ud, "C"))+")", "the per-user cache dir stays hidden") + } +} diff --git a/confine/util.go b/confine/util.go new file mode 100644 index 0000000..3b5aadf --- /dev/null +++ b/confine/util.go @@ -0,0 +1,146 @@ +package confine + +import ( + "context" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" +) + +// withTimeout wraps ctx with d when d > 0, otherwise returns ctx unchanged with +// a no-op cancel. Lets a Policy.Timeout bound an execution on top of any +// deadline the caller's ctx already carries. +func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + if d <= 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, d) +} + +// hiddenHome returns the canonical home directory that the native backends +// hide from the payload, or "" when it is unknown or is the root directory +// (hiding "/" would hide the whole filesystem). +func hiddenHome() string { + h, err := os.UserHomeDir() + if err != nil || h == "" { + return "" + } + h = canonicalPath(h) + if h == "" || h == string(filepath.Separator) { + return "" + } + return h +} + +// expandHome resolves a leading "~" against home and canonicalizes the result. +func expandHome(p, home string) string { + if p == "~" { + p = home + } else if strings.HasPrefix(p, "~/") && home != "" { + p = filepath.Join(home, p[2:]) + } + return canonicalPath(p) +} + +// underDir reports whether p is dir itself or a path below it. +func underDir(p, dir string) bool { + if p == "" || dir == "" { + return false + } + if p == dir { + return true + } + return strings.HasPrefix(p, strings.TrimSuffix(dir, string(filepath.Separator))+string(filepath.Separator)) +} + +// systemHiddenDirs lists the directories the native backends hide besides +// the user's own home: the other users' homes and root's home, and on Linux +// the runtime dirs that hold unix sockets (such as the Docker socket). An +// unshared network namespace does not stop a unix socket connect, so these +// sockets must be out of reach to keep the payload away from the daemons. +func systemHiddenDirs() []string { + switch runtime.GOOS { + case "linux": + return []string{"/home", "/root", "/run", "/var/run"} + case "darwin": + return []string{"/Users", "/var/root"} + } + return nil +} + +// hiddenRoots returns the canonical, existing directories hidden from the +// payload: the user's home plus systemHiddenDirs, plus any extra candidates +// the caller adds. Duplicates and directories inside another hidden one are +// dropped, and "/" is never hidden. The result is ordered shortest first, so +// a parent is always handled before a child. +func hiddenRoots(extra ...string) []string { + candidates := systemHiddenDirs() + if home := hiddenHome(); home != "" { + candidates = append(candidates, home) + } + candidates = append(candidates, extra...) + var dirs []string + for _, c := range candidates { + resolved, err := filepath.EvalSymlinks(c) + if err != nil || resolved == string(filepath.Separator) { + continue + } + if st, err := os.Stat(resolved); err != nil || !st.IsDir() { + continue + } + dirs = append(dirs, resolved) + } + sort.SliceStable(dirs, func(i, j int) bool { return len(dirs[i]) < len(dirs[j]) }) + var out []string + for _, d := range dirs { + if containingRoot(out, d) == "" { + out = append(out, d) + } + } + return out +} + +// containingRoot returns the root in roots that p is in, or "". +func containingRoot(roots []string, p string) string { + for _, r := range roots { + if underDir(p, r) { + return r + } + } + return "" +} + +// hiddenReadable returns the canonical paths inside the hidden roots that +// stay readable: the policy's ReadOnlyPaths ("~" is the user's home) plus the +// given extra paths (workspace, temp, script dir). Paths outside every hidden +// root are dropped because they are not hidden. The result is deduplicated and +// keeps input order. +func hiddenReadable(roots []string, readOnly []string, extra ...string) []string { + if len(roots) == 0 { + return nil + } + home := hiddenHome() + seen := map[string]bool{} + var out []string + add := func(p string) { + if p == "" || seen[p] || containingRoot(roots, p) == "" { + return + } + seen[p] = true + out = append(out, p) + } + for _, p := range readOnly { + if p != "" { + add(expandHome(p, home)) + } + } + for _, p := range extra { + if p != "" { + add(canonicalPath(p)) + } + } + return out +} diff --git a/examples/bubblewrap_sandbox/main.go b/examples/bubblewrap_sandbox/main.go deleted file mode 100644 index 4e4773b..0000000 --- a/examples/bubblewrap_sandbox/main.go +++ /dev/null @@ -1,130 +0,0 @@ -//go:build linux - -package main - -import ( - "context" - "fmt" - "log" - "os" - "path/filepath" - - "github.com/projectdiscovery/gozero/sandbox" - osutils "github.com/projectdiscovery/utils/os" -) - -func main() { - ctx := context.Background() - - if !osutils.IsLinux() { - log.Printf("This example is only supported on Linux") - return - } - - fmt.Println("=== Bubblewrap Sandbox Example ===") - fmt.Println() - - // Create bubblewrap configuration with static settings - config := &sandbox.BubblewrapConfiguration{ - TempDir: filepath.Join(os.TempDir(), "gozero-bubblewrap"), - UnsharePID: true, - UnshareIPC: true, - UnshareNetwork: true, - UnshareUTS: true, - UnshareUser: true, - NewSession: true, - HostFilesystem: true, - Environment: map[string]string{ - "PATH": "/usr/bin:/bin", - }, - } - - // Create bubblewrap sandbox - bwrap, err := sandbox.NewBubblewrapSandbox(ctx, config) - if err != nil { - log.Fatalf("Failed to create bubblewrap sandbox: %v", err) - } - defer func() { - _ = bwrap.Clear() - }() - - fmt.Println("1. Running a simple command in the sandbox...") - result, err := bwrap.Run(ctx, "echo 'Hello from bubblewrap sandbox!'") - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Output: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Errors: %s\n", result.Stderr.String()) - } - } - fmt.Println("---") - - fmt.Println("2. Running ls command in the sandbox...") - result, err = bwrap.Run(ctx, "ls /") - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Output: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Errors: %s\n", result.Stderr.String()) - } - } - fmt.Println("---") - - fmt.Println("3. Running env command to see sandbox environment...") - result, err = bwrap.Run(ctx, "env") - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Output: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Errors: %s\n", result.Stderr.String()) - } - } - fmt.Println("---") - - fmt.Println("4. Running Python source code in the sandbox...") - pyCode := `print("Hello from Python in bubblewrap!") -print("Python is working in the sandbox!") -import sys -print(f"Python version: {sys.version}") -` - result, err = bwrap.RunSource(ctx, pyCode) - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Output: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Errors: %s\n", result.Stderr.String()) - } - } - fmt.Println("---") - - fmt.Println("5. Using ExecuteWithOptions with per-command configuration...") - options := &sandbox.BubblewrapCommandOptions{ - Command: "bash", - Args: []string{"-c", "echo 'Hello from per-command options!' && ls /src"}, - CommandBinds: []sandbox.BindMount{ - {HostPath: "/usr/bin", SandboxPath: "/usr/bin"}, - }, - WorkingDir: "/src", - } - result, err = bwrap.ExecuteWithOptions(ctx, options) - if err != nil { - fmt.Printf("Error: %v\n", err) - } else { - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Output: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Errors: %s\n", result.Stderr.String()) - } - } - fmt.Println("---") - - fmt.Println("=== Bubblewrap sandbox test completed ===") -} diff --git a/examples/docker_sandbox/bash_source/main.go b/examples/docker_sandbox/bash_source/main.go deleted file mode 100644 index cf6bdfe..0000000 --- a/examples/docker_sandbox/bash_source/main.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutils "github.com/projectdiscovery/utils/os" -) - -func main() { - ctx := context.Background() - - if !osutils.IsLinux() { - log.Printf("This example is only supported on Linux") - return - } - - // Create Docker sandbox configuration for shell execution - config := &sandbox.DockerConfiguration{ - Image: "alpine:latest", - WorkingDir: "/tmp", - Environment: map[string]string{ - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - }, - NetworkMode: "bridge", - NetworkDisabled: false, - User: "root", - Memory: "128m", - CPULimit: "0.5", - Timeout: 30 * time.Second, - Remove: true, - } - - // Create Docker sandbox - sandboxInstance, err := sandbox.NewDockerSandbox(ctx, config) - if err != nil { - log.Fatalf("Failed to create Docker sandbox: %v", err) - } - defer func() { - err := sandboxInstance.Clear() - if err != nil { - log.Fatalf("Failed to clear Docker sandbox: %v", err) - } - }() - - // Test shell scripts using RunSource - scripts := []struct { - name string - script string - }{ - { - name: "Simple Hello World", - script: `#!/bin/sh -echo "Hello from shell script!" -echo "Current user: $(whoami)" -echo "Current directory: $(pwd)" -echo "System info: $(uname -a)" -`, - }, - { - name: "File Operations", - script: `#!/bin/sh -echo "Creating test files..." -echo "File 1 content" > /tmp/test1.txt -echo "File 2 content" > /tmp/test2.txt -echo "Files created:" -ls -la /tmp/test*.txt -echo "File contents:" -cat /tmp/test1.txt -cat /tmp/test2.txt -`, - }, - { - name: "System Information", - script: `#!/bin/sh -echo "=== System Information ===" -echo "Hostname: $(hostname)" -echo "User: $(whoami)" -echo "UID: $(id -u)" -echo "GID: $(id -g)" -echo "Groups: $(id -G)" -echo "Home: $HOME" -echo "Shell: $SHELL" -echo "PATH: $PATH" -echo "" -echo "=== Memory Information ===" -cat /proc/meminfo | head -5 -echo "" -echo "=== CPU Information ===" -cat /proc/cpuinfo | head -10 -`, - }, - { - name: "Network Test", - script: `#!/bin/sh -echo "=== Network Configuration ===" -echo "Hostname: $(hostname)" -echo "IP addresses:" -ip addr show 2>/dev/null || ifconfig 2>/dev/null || echo "Network tools not available" -echo "" -echo "=== DNS Resolution Test ===" -nslookup google.com 2>/dev/null || echo "DNS resolution not available" -`, - }, - } - - for _, test := range scripts { - fmt.Printf("\n=== Running: %s ===\n", test.name) - result, err := sandboxInstance.RunSource(ctx, test.script, "sh") - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Stdout:\n%s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Stderr:\n%s\n", result.Stderr.String()) - } - fmt.Println("---") - } - - fmt.Println("\n=== Shell source execution test completed ===") -} diff --git a/examples/docker_sandbox/python_source/main.go b/examples/docker_sandbox/python_source/main.go deleted file mode 100644 index 5985138..0000000 --- a/examples/docker_sandbox/python_source/main.go +++ /dev/null @@ -1,295 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutils "github.com/projectdiscovery/utils/os" -) - -func main() { - ctx := context.Background() - - if !osutils.IsLinux() { - log.Printf("This example is only supported on Linux") - return - } - - // Create Docker sandbox configuration for Python execution - // Using Alpine Python image for minimal size - config := &sandbox.DockerConfiguration{ - Image: "python:3.11-alpine", // Alpine-based Python image - WorkingDir: "/tmp", - Environment: map[string]string{ - "PATH": "/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - "PYTHONPATH": "/tmp", - }, - NetworkMode: "bridge", - NetworkDisabled: false, - User: "root", - Memory: "256m", // Python needs a bit more memory - CPULimit: "0.5", - Timeout: 30 * time.Second, - Remove: true, - } - - // Create Docker sandbox - sandboxInstance, err := sandbox.NewDockerSandbox(ctx, config) - if err != nil { - log.Fatalf("Failed to create Docker sandbox: %v", err) - } - defer func() { - err := sandboxInstance.Clear() - if err != nil { - log.Fatalf("Failed to clear Docker sandbox: %v", err) - } - }() - - // Test Python scripts using RunSource - scripts := []struct { - name string - script string - }{ - { - name: "Simple Hello World", - script: `#!/usr/bin/env python3 -import sys -import os -import platform - -print("Hello from Python script!") -print(f"Python version: {sys.version}") -print(f"Platform: {platform.platform()}") -print(f"Current user: {os.getenv('USER', 'unknown')}") -print(f"Current directory: {os.getcwd()}") -print(f"Python executable: {sys.executable}") -`, - }, - { - name: "Math and Data Processing", - script: `#!/usr/bin/env python3 -import math -import random -import json -from datetime import datetime - -print("=== Math Operations ===") -print(f"Pi: {math.pi}") -print(f"E: {math.e}") -print(f"Square root of 16: {math.sqrt(16)}") -print(f"2^10: {2**10}") - -print("\n=== Random Numbers ===") -random.seed(42) # For reproducible results -for i in range(5): - print(f"Random number {i+1}: {random.randint(1, 100)}") - -print("\n=== JSON Processing ===") -data = { - "name": "Python Script", - "timestamp": datetime.now().isoformat(), - "values": [1, 2, 3, 4, 5], - "nested": {"key": "value"} -} -json_str = json.dumps(data, indent=2) -print("JSON data:") -print(json_str) - -print("\n=== List Comprehensions ===") -squares = [x**2 for x in range(1, 6)] -print(f"Squares of 1-5: {squares}") - -even_squares = [x**2 for x in range(1, 11) if x % 2 == 0] -print(f"Even squares of 1-10: {even_squares}") -`, - }, - { - name: "File Operations", - script: `#!/usr/bin/env python3 -import os -import tempfile -import json - -print("=== File Operations ===") - -# Create some test files -test_data = { - "numbers": [1, 2, 3, 4, 5], - "text": "Hello from Python!", - "nested": {"a": 1, "b": 2} -} - -# Write JSON file -with open('/tmp/test.json', 'w') as f: - json.dump(test_data, f, indent=2) - -# Write text file -with open('/tmp/test.txt', 'w') as f: - f.write("This is a test file created by Python\n") - f.write("Line 2\n") - f.write("Line 3\n") - -# Read and display files -print("Files created:") -for filename in ['/tmp/test.json', '/tmp/test.txt']: - if os.path.exists(filename): - print(f"\\n--- {filename} ---") - with open(filename, 'r') as f: - print(f.read()) - -# List directory contents -print("\\nDirectory contents:") -for item in os.listdir('/tmp'): - if item.startswith('test'): - full_path = os.path.join('/tmp', item) - size = os.path.getsize(full_path) - print(f" {item} ({size} bytes)") -`, - }, - { - name: "System Information", - script: `#!/usr/bin/env python3 -import sys -import os -import platform -import subprocess -import json - -print("=== Python Environment ===") -print(f"Python version: {sys.version}") -print(f"Python executable: {sys.executable}") -print(f"Platform: {platform.platform()}") -print(f"Architecture: {platform.architecture()}") -print(f"Machine: {platform.machine()}") -print(f"Processor: {platform.processor()}") - -print("\\n=== System Information ===") -print(f"Current working directory: {os.getcwd()}") -print(f"User: {os.getenv('USER', 'unknown')}") -print(f"Home directory: {os.getenv('HOME', 'unknown')}") -print(f"PATH: {os.getenv('PATH', 'unknown')}") - -print("\\n=== Environment Variables ===") -env_vars = ['PATH', 'HOME', 'USER', 'SHELL', 'PYTHONPATH'] -for var in env_vars: - value = os.getenv(var, 'Not set') - print(f"{var}: {value}") - -print("\\n=== Process Information ===") -print(f"Process ID: {os.getpid()}") -print(f"Parent Process ID: {os.getppid()}") - -# Try to get system info (may not work in all containers) -try: - result = subprocess.run(['uname', '-a'], capture_output=True, text=True, timeout=5) - if result.returncode == 0: - print(f"\\nSystem info: {result.stdout.strip()}") -except Exception as e: - print(f"\\nCould not get system info: {e}") - -print("\\n=== Memory Usage ===") -try: - import psutil - memory = psutil.virtual_memory() - print(f"Total memory: {memory.total / (1024**3):.2f} GB") - print(f"Available memory: {memory.available / (1024**3):.2f} GB") -except ImportError: - print("psutil not available for memory info") -except Exception as e: - print(f"Could not get memory info: {e}") -`, - }, - { - name: "Error Handling and Exception Testing", - script: `#!/usr/bin/env python3 -import sys -import traceback - -print("=== Error Handling Examples ===") - -# Test different types of errors -def test_division_by_zero(): - try: - result = 10 / 0 - return result - except ZeroDivisionError as e: - return f"Caught ZeroDivisionError: {e}" - -def test_file_not_found(): - try: - with open('/nonexistent/file.txt', 'r') as f: - return f.read() - except FileNotFoundError as e: - return f"Caught FileNotFoundError: {e}" - -def test_value_error(): - try: - result = int("not_a_number") - return result - except ValueError as e: - return f"Caught ValueError: {e}" - -def test_key_error(): - try: - my_dict = {"a": 1, "b": 2} - return my_dict["c"] - except KeyError as e: - return f"Caught KeyError: {e}" - -# Run error tests -print("1. Division by zero:") -print(test_division_by_zero()) - -print("\\n2. File not found:") -print(test_file_not_found()) - -print("\\n3. Value error:") -print(test_value_error()) - -print("\\n4. Key error:") -print(test_key_error()) - -print("\\n=== Exception with traceback ===") -try: - # This will raise an exception - x = 1 / 0 -except Exception as e: - print(f"Exception occurred: {e}") - print("Traceback:") - traceback.print_exc() - -print("\\n=== Custom Exception ===") -class CustomError(Exception): - def __init__(self, message): - self.message = message - super().__init__(self.message) - -try: - raise CustomError("This is a custom error!") -except CustomError as e: - print(f"Caught custom error: {e}") -`, - }, - } - - for _, test := range scripts { - fmt.Printf("\n=== Running: %s ===\n", test.name) - result, err := sandboxInstance.RunSource(ctx, test.script, "python3") - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Stdout:\n%s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Stderr:\n%s\n", result.Stderr.String()) - } - fmt.Println("---") - } - - fmt.Println("\n=== Python source execution test completed ===") -} diff --git a/examples/docker_sandbox/run_commands/main.go b/examples/docker_sandbox/run_commands/main.go deleted file mode 100644 index 8d12b5b..0000000 --- a/examples/docker_sandbox/run_commands/main.go +++ /dev/null @@ -1,75 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutils "github.com/projectdiscovery/utils/os" -) - -func main() { - ctx := context.Background() - - if !osutils.IsLinux() { - log.Printf("This example is only supported on Linux") - return - } - - // Create Docker sandbox configuration - // Note: The image will be automatically pulled if it doesn't exist locally - config := &sandbox.DockerConfiguration{ - Image: "alpine:latest", - WorkingDir: "/tmp", - Environment: map[string]string{ - "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", - }, - NetworkMode: "bridge", // or "host", "none", etc. - NetworkDisabled: false, // Set to true to disable networking entirely - User: "root", - Memory: "128m", // Alpine is much lighter, can use less memory - CPULimit: "0.5", - Timeout: 30 * time.Second, - Remove: true, - } - - // Create Docker sandbox - sandboxInstance, err := sandbox.NewDockerSandbox(ctx, config) - if err != nil { - log.Fatalf("Failed to create Docker sandbox: %v", err) - } - defer func() { - err := sandboxInstance.Clear() - if err != nil { - log.Fatalf("Failed to clear Docker sandbox: %v", err) - } - }() - - // Test commands - commands := []string{ - "echo 'Hello from Docker sandbox!'", - "whoami", - "pwd", - "ls -la /", - "uname -a", - } - - for _, cmd := range commands { - fmt.Printf("\n=== Running: %s ===\n", cmd) - result, err := sandboxInstance.Run(ctx, cmd) - if err != nil { - fmt.Printf("Error: %v\n", err) - continue - } - - fmt.Printf("Exit Code: %d\n", result.GetExitCode()) - fmt.Printf("Stdout: %s\n", result.Stdout.String()) - if result.Stderr.Len() > 0 { - fmt.Printf("Stderr: %s\n", result.Stderr.String()) - } - } - - fmt.Println("\n=== Docker sandbox test completed ===") -} diff --git a/examples/simple_darwin/main.go b/examples/simple_darwin/main.go deleted file mode 100644 index a79e069..0000000 --- a/examples/simple_darwin/main.go +++ /dev/null @@ -1,43 +0,0 @@ -//go:build darwin -// +build darwin - -package main - -import ( - "context" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutil "github.com/projectdiscovery/utils/os" -) - -func main() { - if !osutil.IsOSX() { - log.Printf("This example is only supported on Darwin") - return - } - - command := "hostname" - rules := []sandbox.Rule{ - {Action: sandbox.Deny, Scope: sandbox.FileWrite}, - {Action: sandbox.Allow, Scope: sandbox.FileWrite, Args: []sandbox.Arg{ - {Type: sandbox.SubPath, Params: []any{"/tmp"}}, - }}, - } - cfg := sandbox.Configuration{ - Rules: rules, - } - - instance, err := sandbox.New(context.Background(), &cfg) - if err != nil { - log.Fatal(err) - } - - if _, err := instance.Run(context.Background(), command); err != nil { - log.Fatal(err) - } - - time.Sleep(60 * time.Second) - _ = instance.Clear() -} diff --git a/examples/simple_linux/main.go b/examples/simple_linux/main.go deleted file mode 100644 index 6a35e19..0000000 --- a/examples/simple_linux/main.go +++ /dev/null @@ -1,39 +0,0 @@ -//go:build linux -// +build linux - -package main - -import ( - "context" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutil "github.com/projectdiscovery/utils/os" -) - -func main() { - if !osutil.IsLinux() { - log.Printf("This example is only supported on Linux") - return - } - command := "hostname" - rules := []sandbox.Rule{ - {Filter: sandbox.DynamicUser, Arg: sandbox.Arg{Type: sandbox.Bool, Params: "yes"}}, - {Filter: sandbox.ReadOnlyDirectories, Arg: sandbox.Arg{Type: sandbox.Folders, Params: []string{"/etc", "/home"}}}, - } - cfg := sandbox.Configuration{ - Rules: rules, - } - - instance, err := sandbox.New(context.Background(), &cfg) - if err != nil { - log.Fatal(err) - } - - if _, err := instance.Run(context.Background(), command); err != nil { - log.Fatal(err) - } - - time.Sleep(60 * time.Second) -} diff --git a/examples/simple_windows/main.go b/examples/simple_windows/main.go deleted file mode 100644 index 15dfb4c..0000000 --- a/examples/simple_windows/main.go +++ /dev/null @@ -1,41 +0,0 @@ -//go:build windows -// +build windows - -package main - -import ( - "context" - "log" - "time" - - "github.com/projectdiscovery/gozero/sandbox" - osutil "github.com/projectdiscovery/utils/os" -) - -func main() { - if !osutil.IsWindows() { - log.Printf("This example is only supported on Windows") - return - } - commands := []string{ - "ipconfig", - } - cfg := sandbox.Configuration{ - Networking: sandbox.Enable, - VirtualGPU: sandbox.Default, - MemoryInMB: 500, - } - cfg.LogonCommands.Command = commands - - instance, err := sandbox.New(context.Background(), &cfg) - if err != nil { - log.Fatal(err) - } - - if err := instance.Start(); err != nil { - log.Fatal(err) - } - - time.Sleep(60 * time.Second) - instance.Clear() -} diff --git a/gozero.go b/gozero.go index ad0f028..c42d247 100644 --- a/gozero.go +++ b/gozero.go @@ -6,26 +6,22 @@ import ( "os/exec" "github.com/projectdiscovery/gozero/cmdexec" - "github.com/projectdiscovery/gozero/sandbox" + "github.com/projectdiscovery/gozero/confine" "github.com/projectdiscovery/gozero/types" ) -// VirtualEnvType represents the type of virtual environment -type VirtualEnvType uint8 - -const ( - VirtualEnvLinux VirtualEnvType = iota - VirtualEnvDarwin - VirtualEnvWindows - VirtualEnvDocker -) - // Gozero is executor for gozero type Gozero struct { - Options *Options + Options *Options + confiner confine.Confiner } -// New creates a new gozero executor +// New creates a new gozero executor. +// +// When Options.Sandbox is set (or Options.Confinement is provided), New builds +// the confinement backend up front and FAILS CLOSED: if the requested backend +// is unavailable, New returns an error instead of a permissive executor, so it +// is impossible to reach Eval and then silently run unconfined. func New(options *Options) (*Gozero, error) { if len(options.Engines) == 0 { return nil, ErrNoEngines @@ -45,7 +41,26 @@ func New(options *Options) (*Gozero, error) { if options.engine == "" { return nil, ErrNoValidEngine } - return &Gozero{Options: options}, nil + + g := &Gozero{Options: options} + if options.Sandbox || options.Confinement != nil { + c, err := confine.New(options.Confinement) + if err != nil { + // Fail closed: confinement was requested but cannot be established. + return nil, fmt.Errorf("gozero: confinement required but unavailable: %w", err) + } + g.confiner = c + } + return g, nil +} + +// Close releases resources held by the executor (e.g. a confinement backend's +// docker client). Safe to call on a nil confiner. +func (g *Gozero) Close() error { + if g.confiner != nil { + return g.confiner.Close() + } + return nil } // Eval evaluates the source code and returns the output @@ -54,6 +69,17 @@ func (g *Gozero) Eval(ctx context.Context, src, input *Source, args ...string) ( if g.Options.EarlyCloseFileDescriptor { _ = src.File.Close() } + // Confined path: every execution goes through the OS-enforced boundary. This + // is the only place the payload runs when a sandbox was requested — there is + // no unconfined fallback. + if g.confiner != nil { + spec, err := g.buildSpec(src, input, args...) + if err != nil { + return nil, err + } + return g.confiner.Run(ctx, spec) + } + allargs := []string{} allargs = append(allargs, g.Options.Args...) allargs = append(allargs, src.Filename) @@ -73,65 +99,58 @@ func (g *Gozero) Eval(ctx context.Context, src, input *Source, args ...string) ( return gcmd.Execute(ctx) } -// EvalWithVirtualEnv evaluates the source code in a virtual environment and returns the output -// This function passes the source code into the virtual environment and external parameters as environment variables -func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, envType VirtualEnvType, src, input *Source, dockerConfig *sandbox.DockerConfiguration, args ...string) (*types.Result, error) { - // Read source code content - srcContent, err := src.ReadAll() +// buildSpec assembles the confinement Spec for an execution. The source is +// carried as bytes (never interpolated into a shell) and the declared +// interpreter is command[0], so the confiner runs exactly the intended program. +func (g *Gozero) buildSpec(src, input *Source, args ...string) (confine.Spec, error) { + scriptData, err := src.ReadAll() if err != nil { - return nil, err + return confine.Spec{}, err } - // Prepare environment variables from source and input variables - envVars := make(map[string]string) - - // Add source variables as environment variables - for _, variable := range src.Variables { - envVars[variable.Name] = variable.Value + env := make(map[string]string, len(src.Variables)+len(input.Variables)) + for _, v := range src.Variables { + env[v.Name] = v.Value } - - // Add input variables as environment variables - for _, variable := range input.Variables { - envVars[variable.Name] = variable.Value + for _, v := range input.Variables { + env[v.Name] = v.Value } - // Handle different virtual environment types - switch envType { - case VirtualEnvDocker: - // Update Docker configuration with environment variables - dockerConfig.Environment = envVars - - // Create Docker sandbox with updated configuration - dockerSandbox, err := sandbox.NewDockerSandbox(ctx, dockerConfig) - if err != nil { - return nil, err - } - - // Use the engine as the interpreter - interpreter := g.Options.engine - if interpreter == "" { - // Fallback to first engine if engine not set - if len(g.Options.Engines) > 0 { - interpreter = g.Options.Engines[0] - } else { - interpreter = "sh" // Default to shell - } - } - - // Execute the source code in the Docker container - result, err := dockerSandbox.RunSource(ctx, string(srcContent), interpreter) - if err != nil { - return nil, err - } - - return result, nil + command := make([]string, 0, len(g.Options.Args)+len(args)+2) + command = append(command, g.Options.engine) + command = append(command, g.Options.Args...) + command = append(command, src.Filename) + command = append(command, args...) + + return confine.Spec{ + Command: command, + ScriptPath: src.Filename, + ScriptData: scriptData, + Env: env, + Stdin: input.File, + Debug: g.Options.DebugMode, + }, nil +} - case VirtualEnvLinux, VirtualEnvDarwin, VirtualEnvWindows: - // For now, these are not implemented - they would use the regular Eval method - // In the future, these could be implemented to use different sandboxing mechanisms - return nil, fmt.Errorf("virtual environment type %d is not yet implemented", envType) +// EvalWithVirtualEnv evaluates the source code in a one-off confinement backend +// described by policy, independently of the executor's own Options.Sandbox +// setting. Use it to pick a backend per call (e.g. force Docker) without +// rebuilding the executor. +// +// It uses the same hardened, fail-closed confiner as Eval: the source is +// injected as bytes (no shell heredoc to break out of) and, if the policy's +// backend cannot be established, execution is refused rather than run +// unconfined. A nil policy uses confine.DefaultPolicy(). +func (g *Gozero) EvalWithVirtualEnv(ctx context.Context, policy *confine.Policy, src, input *Source, args ...string) (*types.Result, error) { + confiner, err := confine.New(policy) + if err != nil { + return nil, fmt.Errorf("gozero: confinement required but unavailable: %w", err) + } + defer func() { _ = confiner.Close() }() - default: - return nil, fmt.Errorf("unsupported virtual environment type: %d", envType) + spec, err := g.buildSpec(src, input, args...) + if err != nil { + return nil, err } + return confiner.Run(ctx, spec) } diff --git a/gozero_test.go b/gozero_test.go index da24442..6b9e0b1 100644 --- a/gozero_test.go +++ b/gozero_test.go @@ -2,9 +2,12 @@ package gozero import ( "context" + "os" "strings" "testing" + "github.com/projectdiscovery/gozero/confine" + "github.com/projectdiscovery/gozero/types" osutils "github.com/projectdiscovery/utils/os" "github.com/stretchr/testify/require" ) @@ -56,3 +59,83 @@ func TestErr(t *testing.T) { require.Nil(t, err) require.NotNil(t, gozero) } + +// TestNewFailsClosedWhenConfinementUnavailable asserts the anti-escape +// contract at the executor boundary: if confinement is requested but the +// backend cannot be established, New refuses to hand back an executor, so it is +// impossible to reach Eval and then run unconfined. +func TestNewFailsClosedWhenConfinementUnavailable(t *testing.T) { + opts := &Options{ + Engines: []string{"sh"}, + Sandbox: true, + Confinement: &confine.Policy{Backend: confine.Backend("does-not-exist")}, + } + g, err := New(opts) + require.Error(t, err) + require.Nil(t, g) +} + +// TestEvalSandboxed runs a real confined execution end-to-end through the +// executor when a native confiner is available (skipped otherwise). +func TestEvalSandboxed(t *testing.T) { + if osutils.IsWindows() { + t.Skip("native confinement not implemented on windows") + } + opts := &Options{Engines: []string{"sh"}, Sandbox: true} + g, err := New(opts) + if err != nil { + t.Skipf("no native confiner available: %v", err) + } + defer func() { _ = g.Close() }() + + src, err := NewSourceWithString("echo sandboxed-ok\n", "", "") + require.Nil(t, err) + defer func() { _ = src.Cleanup() }() + input, err := NewSource() + require.Nil(t, err) + defer func() { _ = input.Cleanup() }() + + out, err := g.Eval(context.Background(), src, input) + require.Nil(t, err) + require.Equal(t, "sandboxed-ok", strings.TrimSpace(out.Stdout.String())) +} + +// TestEvalSandboxedPythonHidesHome runs python3 through Eval with Sandbox on: +// the interpreter must still start, and the real $HOME (e.g. ~/.ssh) must not +// be readable from the payload. +func TestEvalSandboxedPythonHidesHome(t *testing.T) { + if osutils.IsWindows() { + t.Skip("native confinement not implemented on windows") + } + home, err := os.UserHomeDir() + if err != nil || home == "/" { + t.Skip("no home directory to hide") + } + g, err := New(&Options{Engines: []string{"python3"}, Sandbox: true}) + if err != nil { + t.Skipf("no native confiner or python3: %v", err) + } + defer func() { _ = g.Close() }() + + src, err := NewSourceWithString(`import os, sys +print("py", sys.version_info[0]) +for p in (os.environ["REAL_HOME"], os.path.join(os.environ["REAL_HOME"], ".ssh")): + try: + print("listed", len(os.listdir(p))) + except OSError as e: + print("denied", e.errno) +`, "", "") + require.Nil(t, err) + defer func() { _ = src.Cleanup() }() + src.AddVariable(types.Variable{Name: "REAL_HOME", Value: home}) + input, err := NewSource() + require.Nil(t, err) + defer func() { _ = input.Cleanup() }() + + out, err := g.Eval(context.Background(), src, input) + require.Nil(t, err) + got := out.Stdout.String() + t.Log(got) + require.Contains(t, got, "py 3") + require.NotRegexp(t, `listed [1-9]`, got, "home entries must not be visible") +} diff --git a/options.go b/options.go index 5f8f708..9bac2c2 100644 --- a/options.go +++ b/options.go @@ -1,11 +1,25 @@ package gozero +import "github.com/projectdiscovery/gozero/confine" + type Options struct { - Engines []string - Args []string - engine string - PreferStartProcess bool - Sandbox bool + Engines []string + Args []string + engine string + PreferStartProcess bool + + // Sandbox requests confined execution. When true (or when Confinement is + // set), Eval runs the payload through an OS-enforced boundary and FAILS + // CLOSED: if no confinement backend is available, New returns an error and + // nothing is ever executed on the bare host. Leaving it false preserves the + // legacy, unconfined behaviour. + Sandbox bool + // Confinement is the confinement policy applied when Sandbox is on (or when + // this is non-nil). Nil means confine.DefaultPolicy() — a hardened, + // deny-by-default posture (no network, dropped capabilities, + // no-new-privileges, read-only rootfs, resource limits, minimal env). + Confinement *confine.Policy + EarlyCloseFileDescriptor bool // When Debug Mode is set to true, Output result will contain // more debug information diff --git a/sandbox/error.go b/sandbox/error.go deleted file mode 100644 index 752adc6..0000000 --- a/sandbox/error.go +++ /dev/null @@ -1,8 +0,0 @@ -package sandbox - -import "errors" - -var ( - ErrNotImplemented = errors.New("not implemented") - ErrAgentRequired = errors.New("requires agent installed on the sandbox") -) diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go deleted file mode 100644 index 1a18e74..0000000 --- a/sandbox/sandbox.go +++ /dev/null @@ -1,17 +0,0 @@ -package sandbox - -import ( - "context" - - "github.com/projectdiscovery/gozero/types" -) - -type Sandbox interface { - Run(ctx context.Context, cmd string) (*types.Result, error) - RunScript(ctx context.Context, source string) (*types.Result, error) - RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) - Start() error - Wait() error - Stop() error - Clear() error -} diff --git a/sandbox/sandbox_darwin.go b/sandbox/sandbox_darwin.go deleted file mode 100644 index 318a6f9..0000000 --- a/sandbox/sandbox_darwin.go +++ /dev/null @@ -1,158 +0,0 @@ -//go:build darwin - -package sandbox - -import ( - "bytes" - "context" - "errors" - "fmt" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/projectdiscovery/gozero/cmdexec" - "github.com/projectdiscovery/gozero/types" -) - -type Configuration struct { - Rules []Rule -} - -type Action string - -const ( - Allow Action = "allow" - Deny Action = "deny" -) - -type Scope string - -const ( - Network Scope = "network" - FileWrite Scope = "file-write" - FileRead Scope = "file-read" - Process Scope = "process" - Default Scope = "default" -) - -type ArgsType string - -const ( - LocalIP = `local ip "%s"` - RemoteIP = `local ip "%s"` - SubPath = `subpath "%s"` -) - -type Rule struct { - Action Action - Scope Scope - Args []Arg -} - -type Arg struct { - Type ArgsType - Params []any -} - -// Sandbox native on windows -type SandboxDarwin struct { - Config *Configuration - confFile string -} - -// New sandbox with the given configuration -func New(ctx context.Context, config *Configuration) (Sandbox, error) { - if ok, err := isSandboxExecInstalled(context.Background()); err != nil || !ok { - return nil, errors.New("sandbox feature not installed") - } - - sharedFolder, err := os.MkdirTemp("", "") - if err != nil { - return nil, err - } - - sharedFolder = filepath.Join(sharedFolder, "gozero") - - if err := os.MkdirAll(sharedFolder, 0755); err != nil { - return nil, err - } - - confFile := filepath.Join(sharedFolder, "config.sb") - var confData bytes.Buffer - confData.WriteString("(version 1)\n") - confData.WriteString("(allow default)\n") - for _, rule := range config.Rules { - if rule.Action != "" { - confData.WriteString("(" + string(rule.Action) + " ") - } - if rule.Scope != "" { - confData.WriteString(string(rule.Scope) + "* ") - } - for _, arg := range rule.Args { - _, _ = fmt.Fprintf(&confData, "("+string(arg.Type)+")", arg.Params...) - } - if rule.Action != "" { - confData.WriteString(")") - } - } - if err := os.WriteFile(confFile, confData.Bytes(), 0600); err != nil { - return nil, err - } - - log.Println(confData.String()) - - s := &SandboxDarwin{Config: config, confFile: confFile} - return s, nil -} - -func (s *SandboxDarwin) Run(ctx context.Context, cmd string) (*types.Result, error) { - params := []string{"-f", s.confFile} - params = append(params, strings.Split(cmd, " ")...) - cmdContext, err := cmdexec.NewCommand("sandbox-exec", params...) - if err != nil { - return nil, err - } - return cmdContext.Execute(ctx) -} - -// RunScript executes a script or source code in the sandbox -func (s *SandboxDarwin) RunScript(ctx context.Context, source string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// RunSource writes source code to a temporary file, executes it with proper permissions, and cleans up -func (s *SandboxDarwin) RunSource(ctx context.Context, source, interpreter string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// Start the instance -func (s *SandboxDarwin) Start() error { - return ErrNotImplemented -} - -// Wait for the instance -func (s *SandboxDarwin) Wait() error { - return ErrNotImplemented -} - -// Stop the instance -func (s *SandboxDarwin) Stop() error { - return ErrNotImplemented -} - -// Clear the instance after stop -func (s *SandboxDarwin) Clear() error { - return os.RemoveAll(s.confFile) -} - -func isSandboxExecInstalled(ctx context.Context) (bool, error) { - cmd := exec.CommandContext(ctx, "sandbox-exec", "-p", "echo", "true") - err := cmd.Run() - if err != nil { - return false, err - } - return true, nil -} diff --git a/sandbox/sandbox_linux_bubblewrap.go b/sandbox/sandbox_linux_bubblewrap.go deleted file mode 100644 index f015447..0000000 --- a/sandbox/sandbox_linux_bubblewrap.go +++ /dev/null @@ -1,376 +0,0 @@ -//go:build linux - -package sandbox - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/projectdiscovery/gozero/types" - "github.com/projectdiscovery/utils/errkit" -) - -// BubblewrapConfiguration holds the general configuration for bubblewrap sandboxing -type BubblewrapConfiguration struct { - // Base directory for temporary sandbox files - TempDir string - - // Static namespace options (applied to all commands) - UnsharePID bool // Create a new PID namespace - UnshareIPC bool // Create a new IPC namespace - UnshareNetwork bool // Create a new network namespace - UnshareUTS bool // Create a new UTS namespace - UnshareUser bool // Create a new user namespace - UnshareCgroup bool // Create a new cgroup namespace - - // Static security options (applied to all commands) - NewSession bool // Create a new session (prevents TIOCSTI attacks) - UID int // UID to run as inside the sandbox - GID int // GID to run as inside the sandbox - - // Seccomp filter file - SeccompFile string - - // Static bind mounts (read-only system directories) - ReadOnlySystemBinds []BindMount - - // Static environment variables - Environment map[string]string - - // Enable host filesystem access (read-only) - HostFilesystem bool -} - -// BubblewrapCommandOptions holds per-command configuration -type BubblewrapCommandOptions struct { - // The command to execute - Command string - - // Arguments for the command - Args []string - - // Per-command bind mounts (e.g., source file location -> sandbox location) - CommandBinds []BindMount - - // Working directory for this command - WorkingDir string - - // Change to this directory before running - Chdir string - - // Per-command environment variables (merged with static ones) - Environment map[string]string - - // Input for stdin - Stdin string -} - -// BindMount represents a bind mount configuration -type BindMount struct { - HostPath string - SandboxPath string -} - -// Symlink represents a symlink to create in the sandbox -type Symlink struct { - Target string - Link string -} - -// BubblewrapSandbox implements sandboxing using bubblewrap (bwrap) -type BubblewrapSandbox struct { - config *BubblewrapConfiguration -} - -// NewBubblewrapSandbox creates a new bubblewrap sandbox -func NewBubblewrapSandbox(ctx context.Context, config *BubblewrapConfiguration) (*BubblewrapSandbox, error) { - if config == nil { - return nil, errors.New("config cannot be nil") - } - - // Check if bwrap is installed - installed, err := isBubblewrapInstalled(ctx) - if err != nil || !installed { - return nil, errors.New("bubblewrap (bwrap) is not installed") - } - - // Set default temp directory if not provided - if config.TempDir == "" { - config.TempDir = filepath.Join(os.TempDir(), "gozero-bubblewrap") - } - - // Validate configuration - if err := validateBubblewrapConfig(config); err != nil { - return nil, fmt.Errorf("invalid configuration: %w", err) - } - - return &BubblewrapSandbox{ - config: config, - }, nil -} - -// validateBubblewrapConfig validates the bubblewrap configuration -func validateBubblewrapConfig(config *BubblewrapConfiguration) error { - // Create temp directory if it doesn't exist - if err := os.MkdirAll(config.TempDir, 0755); err != nil { - return fmt.Errorf("failed to create temp directory: %w", err) - } - - return nil -} - -// isBubblewrapInstalled checks if bubblewrap (bwrap) is installed and available -func isBubblewrapInstalled(ctx context.Context) (bool, error) { - cmd := exec.CommandContext(ctx, "bwrap", "--help") - err := cmd.Run() - if err != nil { - return false, err - } - return true, nil -} - -// Run executes a command in the bubblewrap sandbox with default options -func (b *BubblewrapSandbox) Run(ctx context.Context, cmd string) (*types.Result, error) { - // Split command into executable and args - parts := strings.Fields(cmd) - if len(parts) == 0 { - return nil, errors.New("empty command") - } - - executable := parts[0] - args := parts[1:] - - options := &BubblewrapCommandOptions{ - Command: executable, - Args: args, - } - - return b.ExecuteWithOptions(ctx, options) -} - -// RunScript runs a script in the bubblewrap sandbox -func (b *BubblewrapSandbox) RunScript(ctx context.Context, source string) (*types.Result, error) { - return b.RunSource(ctx, source) -} - -// RunSource executes source code in the bubblewrap sandbox -// It creates a specific source directory, places the script file inside, and mounts only that directory -func (b *BubblewrapSandbox) RunSource(ctx context.Context, source string) (*types.Result, error) { - // Create a specific directory for this source execution - sourceDir, err := os.MkdirTemp(b.config.TempDir, "source_*") - if err != nil { - return nil, fmt.Errorf("failed to create source directory: %w", err) - } - defer func() { - _ = os.RemoveAll(sourceDir) - }() - - // Create the script file in the source directory - scriptPath := filepath.Join(sourceDir, "script.sh") - if err := os.WriteFile(scriptPath, []byte(source), 0755); err != nil { - return nil, fmt.Errorf("failed to write script to file: %w", err) - } - - // Create options with bind mount for the source directory - options := &BubblewrapCommandOptions{ - Command: "bash", - Args: []string{"/src/script.sh"}, - CommandBinds: []BindMount{ - { - HostPath: sourceDir, - SandboxPath: "/src", - }, - }, - WorkingDir: "/src", - } - - return b.ExecuteWithOptions(ctx, options) -} - -// ExecuteWithOptions executes a command with specific per-command options -func (b *BubblewrapSandbox) ExecuteWithOptions(ctx context.Context, options *BubblewrapCommandOptions) (*types.Result, error) { - if options == nil { - return nil, errors.New("options cannot be nil") - } - - if options.Command == "" { - return nil, errors.New("command cannot be empty") - } - - // Create a unique sandbox directory for this command execution - sandboxDir, err := os.MkdirTemp(b.config.TempDir, "sandbox_*") - if err != nil { - return nil, fmt.Errorf("failed to create sandbox directory: %w", err) - } - defer func() { - _ = os.RemoveAll(sandboxDir) - }() - - return b.executeInSandbox(ctx, sandboxDir, options) -} - -// executeInSandbox executes a command in the bubblewrap sandbox -func (b *BubblewrapSandbox) executeInSandbox(ctx context.Context, sandboxDir string, options *BubblewrapCommandOptions) (*types.Result, error) { - // Build the bwrap command with both static and per-command options - bwrapArgs := b.buildBubblewrapArgs(sandboxDir, options) - - // Add the command to execute - bwrapArgs = append(bwrapArgs, options.Command) - bwrapArgs = append(bwrapArgs, options.Args...) - - // Execute the command - cmd := exec.CommandContext(ctx, "bwrap", bwrapArgs...) - - // Create result - result := &types.Result{ - Command: fmt.Sprintf("bwrap %s", strings.Join(bwrapArgs, " ")), - } - - // Set stdin if provided - if options.Stdin != "" { - cmd.Stdin = strings.NewReader(options.Stdin) - } - - // Capture output - cmd.Stdout = &result.Stdout - cmd.Stderr = &result.Stderr - - // Run the command - if err := cmd.Start(); err != nil { - return result, errkit.New("failed to start bubblewrap command: %w", err) - } - - if err := cmd.Wait(); err != nil { - if execErr, ok := err.(*exec.ExitError); ok { - result.SetExitError(execErr) - } - return result, errkit.New("bubblewrap command failed: %w", err) - } - - return result, nil -} - -// buildBubblewrapArgs constructs the bwrap command arguments -func (b *BubblewrapSandbox) buildBubblewrapArgs(sandboxDir string, options *BubblewrapCommandOptions) []string { - args := []string{} - - // Create a temporary root filesystem - args = append(args, "--unshare-all") - args = append(args, "--die-with-parent") - - // Add proc and dev - args = append(args, "--proc", "/proc") - args = append(args, "--dev", "/dev") - - // Add tmpfs for /tmp and /run - args = append(args, "--tmpfs", "/tmp") - args = append(args, "--tmpfs", "/run") - - // Add the sandbox directory (unique per execution) as root - args = append(args, "--bind", sandboxDir, "/") - - // Add namespace options - if b.config.UnsharePID { - args = append(args, "--unshare-pid") - } - if b.config.UnshareIPC { - args = append(args, "--unshare-ipc") - } - if b.config.UnshareNetwork { - args = append(args, "--unshare-net") - } - if b.config.UnshareUTS { - args = append(args, "--unshare-uts") - } - if b.config.UnshareUser { - args = append(args, "--unshare-user") - } - - // Add session protection - if b.config.NewSession { - args = append(args, "--new-session") - } - - // Add UID/GID mapping - if b.config.UnshareUser { - if b.config.UID > 0 { - args = append(args, "--uid", fmt.Sprintf("%d", b.config.UID)) - } - if b.config.GID > 0 { - args = append(args, "--gid", fmt.Sprintf("%d", b.config.GID)) - } - } - - // Add host filesystem access if enabled - if b.config.HostFilesystem { - // Bind mount essential directories as read-only - args = append(args, "--ro-bind", "/usr", "/usr") - args = append(args, "--ro-bind", "/lib", "/lib") - args = append(args, "--ro-bind", "/lib64", "/lib64") - args = append(args, "--ro-bind", "/bin", "/bin") - args = append(args, "--ro-bind", "/sbin", "/sbin") - } - - // Add static read-only system binds - for _, bind := range b.config.ReadOnlySystemBinds { - hostPath, err := filepath.Abs(bind.HostPath) - if err != nil { - continue - } - args = append(args, "--ro-bind", hostPath, bind.SandboxPath) - } - - // Add static environment variables - for key, value := range b.config.Environment { - args = append(args, "--setenv", key, value) - } - - // Add per-command bind mounts (e.g., source directories) - for _, bind := range options.CommandBinds { - hostPath, err := filepath.Abs(bind.HostPath) - if err != nil { - continue - } - args = append(args, "--bind", hostPath, bind.SandboxPath) - } - - // Add per-command environment variables (merged with static ones) - for key, value := range options.Environment { - args = append(args, "--setenv", key, value) - } - - // Add chdir if specified - if options.Chdir != "" { - args = append(args, "--chdir", options.Chdir) - } - - return args -} - -// Start starts the sandbox (no-op for bubblewrap as it runs per-command) -func (b *BubblewrapSandbox) Start() error { - return nil -} - -// Wait waits for the sandbox to finish (no-op for bubblewrap) -func (b *BubblewrapSandbox) Wait() error { - return nil -} - -// Stop stops the sandbox (no-op for bubblewrap as each command runs independently) -func (b *BubblewrapSandbox) Stop() error { - return nil -} - -// Clear cleans up the temp directory -func (b *BubblewrapSandbox) Clear() error { - if b.config.TempDir != "" { - return os.RemoveAll(b.config.TempDir) - } - return nil -} diff --git a/sandbox/sandbox_linux_systemd.go b/sandbox/sandbox_linux_systemd.go deleted file mode 100644 index 7a1cf15..0000000 --- a/sandbox/sandbox_linux_systemd.go +++ /dev/null @@ -1,158 +0,0 @@ -//go:build linux - -package sandbox - -import ( - "context" - "errors" - "os/exec" - "strings" - - "github.com/projectdiscovery/gozero/cmdexec" - "github.com/projectdiscovery/gozero/types" - stringsutil "github.com/projectdiscovery/utils/strings" -) - -type Configuration struct { - Rules []Rule -} - -type Filter string - -const ( - PrivateTmp Filter = "PrivateTmp" - PrivateNetwork Filter = "PrivateNetwork" - SELinuxContext Filter = "SELinuxContext" - NoNewPrivileges Filter = "NoNewPrivileges" - ProtectSystem Filter = "ProtectSystem" - ProtectHome Filter = "ProtectHome" - ProtectDevices Filter = "ProtectDevices" - CapabilityBoundingSet Filter = "CapabilityBoundingSet" - ReadWriteDirectories Filter = "ReadWriteDirectories" - ReadOnlyDirectories Filter = "ReadOnlyDirectories" - InaccessibleDirectories Filter = "InaccessibleDirectories" - ProtectKernelTunables Filter = "InaccessibleDirectories" - ProtectKernelModules Filter = "ProtectKernelModules" - ProtectControlGroups Filter = "ProtectControlGroups" - RestrictNamespaces Filter = "RestrictNamespaces" - MemoryDenyWriteExecute Filter = "MemoryDenyWriteExecute" - RestrictRealtime Filter = "RestrictRealtime" - PrivateMounts Filter = "PrivateMounts" - DynamicUser Filter = "DynamicUser" - SystemCallFilter Filter = "SystemCallFilter" -) - -type ArgsType uint8 - -const ( - Bool ArgsType = iota - Folders - Capabilities - Namespaces - SystemCalls -) - -type Arg struct { - Type ArgsType - Params interface{} -} - -type Rule struct { - Filter Filter - Arg Arg -} - -// Sandbox native on linux -type SandboxLinux struct { - Config *Configuration - conf []string -} - -func isSystemdInstalled(ctx context.Context) (bool, error) { - cmd := exec.CommandContext(ctx, "systemd-run", "--help") - err := cmd.Run() - if err != nil { - return false, err - } - return true, nil -} - -// New sandbox with the given configuration -func New(ctx context.Context, config *Configuration) (Sandbox, error) { - if ok, err := isSystemdInstalled(context.Background()); err != nil || !ok { - return nil, errors.New("sandbox feature not installed") - } - - conf := []string{"--pipe", "--pty", "--user"} - for _, rule := range config.Rules { - var actionArgs []string - if rule.Filter == "" { - return nil, errors.New("empty action") - } - actionArgs = append(actionArgs, "-p") - switch rule.Arg.Type { - case Bool: - v, ok := rule.Arg.Params.(string) - if !ok { - return nil, errors.New("invalid string value") - } - if !stringsutil.EqualFoldAny(v, "yes", "no") { - return nil, errors.New("invalid value (yes/no)") - } - actionArgs = append(actionArgs, string(rule.Filter)+"="+v) - case Folders, Capabilities, Namespaces, SystemCalls: - v, ok := rule.Arg.Params.([]string) - if !ok { - return nil, errors.New("invalid string value") - } - actionArgs = append(actionArgs, string(rule.Filter)+"="+strings.Join(v, ",")) - default: - return nil, errors.New("unsupported type") - } - conf = append(conf, actionArgs...) - } - - s := &SandboxLinux{Config: config, conf: conf} - return s, nil -} - -func (s *SandboxLinux) Run(ctx context.Context, cmd string) (*types.Result, error) { - var params []string - params = append(params, s.conf...) - params = append(params, strings.Split(cmd, " ")...) - cmdContext, err := cmdexec.NewCommand("systemd-run", params...) - if err != nil { - return nil, err - } - return cmdContext.Execute(ctx) -} - -// RunScript executes a script or source code in the sandbox -func (s *SandboxLinux) RunScript(ctx context.Context, source string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// RunSource writes source code to a temporary file, executes it with proper permissions, and cleans up -func (s *SandboxLinux) RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// Start the instance -func (s *SandboxLinux) Start() error { - return ErrNotImplemented -} - -// Wait for the instance -func (s *SandboxLinux) Wait() error { - return ErrNotImplemented -} - -// Stop the instance -func (s *SandboxLinux) Stop() error { - return ErrNotImplemented -} - -// Clear the instance after stop -func (s *SandboxLinux) Clear() error { - return ErrNotImplemented -} diff --git a/sandbox/sandbox_window.go b/sandbox/sandbox_window.go deleted file mode 100644 index bfac0af..0000000 --- a/sandbox/sandbox_window.go +++ /dev/null @@ -1,210 +0,0 @@ -//go:build windows - -package sandbox - -import ( - "bytes" - "context" - "encoding/xml" - "net" - "os" - "os/exec" - "path/filepath" - "regexp" - - "github.com/projectdiscovery/gozero/types" -) - -const DefaultMountPoint = `C:\Users\WDAGUtilityAccount\Desktop` - -type Value string - -const ( - Enable Value = "Enable" - Disable Value = "Disable" - Default Value = "Default" -) - -type Configuration struct { - MappedFolders MappedFolders `xml:"MappedFolders"` - Networking Value `xml:"Networking"` - LogonCommands LogonCommands `xml:"LogonCommand,omitempty"` - VirtualGPU Value `xml:"vGPU"` - ProtectedClient Value `xml:"ProtectedClient"` - MemoryInMB int `xml:"MemoryInMB"` - IPs IPS `xml:"Ips,omitempty"` - DisableFirewall bool `xml:"-"` -} - -type MappedFolders struct { - MappedFolder []MappedFolder `xml:"MappedFolder"` -} - -type LogonCommands struct { - Command []string `xml:"Command,omitempty"` -} - -type IPS struct { - IP []string `xml:"IP,omitempty"` -} - -type MappedFolder struct { - HostFolder string `xml:"HostFolder"` - SandboxFolder string `xml:"SandboxFolder,omitempty"` - ReadOnly bool `xml:"ReadOnly,omitempty"` -} - -// Sandbox native on windows -type SandboxWindows struct { - Config *Configuration - confFile string - instance *exec.Cmd - stdout bytes.Buffer - stderr bytes.Buffer -} - -// New sandbox with the given configuration -func New(ctx context.Context, config *Configuration) (Sandbox, error) { - if ok, err := isInstalled(ctx); err != nil || !ok { - return nil, err - } - - sharedFolder, err := os.MkdirTemp("", "") - if err != nil { - return nil, err - } - - sharedFolder = filepath.Join(sharedFolder, "gozero") - - if err := os.MkdirAll(sharedFolder, 0600); err != nil { - return nil, err - } - - config.MappedFolders.MappedFolder = append(config.MappedFolders.MappedFolder, MappedFolder{ - HostFolder: sharedFolder, - }) - - if config.DisableFirewall { - config.LogonCommands.Command = append(config.LogonCommands.Command, - "netsh advfirewall set allprofiles state off", - ) - } - // collect all the callback ips - addresses, err := net.InterfaceAddrs() - if err != nil { - return nil, err - } - for _, addr := range addresses { - config.IPs.IP = append(config.IPs.IP, addr.String()) - } - - data, err := xml.Marshal(config) - if err != nil { - return nil, err - } - confFile := filepath.Join(sharedFolder, "config.wsb") - if err := os.WriteFile(confFile, data, 0600); err != nil { - return nil, err - } - - s := &SandboxWindows{Config: config, confFile: confFile} - s.instance = exec.CommandContext(ctx, "WindowsSandbox.exe", s.confFile) - s.instance.Stdout = &s.stdout - s.instance.Stderr = &s.stderr - - return s, nil -} - -func (s *SandboxWindows) Run(ctx context.Context, cmd string) (*types.Result, error) { - return nil, ErrAgentRequired -} - -// RunScript executes a script or source code in the sandbox -func (s *SandboxWindows) RunScript(ctx context.Context, source string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// RunSource writes source code to a temporary file, executes it with proper permissions, and cleans up -func (s *SandboxWindows) RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) { - return nil, ErrNotImplemented -} - -// Start the instance -func (s *SandboxWindows) Start() error { - return s.instance.Start() -} - -// Wait for the instance -func (s *SandboxWindows) Wait() error { - return s.instance.Wait() -} - -// Stop the instance -func (s *SandboxWindows) Stop() error { - err := s.instance.Cancel() - if err != nil { - return err - } - s.instance = nil - return nil -} - -// Clear the instance after stop -func (s *SandboxWindows) Clear() error { - if err := s.Stop(); err != nil { - return err - } - if err := os.RemoveAll(s.confFile); err != nil { - return err - } - return nil -} - -func shellExec(ctx context.Context, args ...string) (string, string, error) { - powershellPath, err := exec.LookPath("powershell") - if err != nil { - return "", "", err - } - cmd := exec.CommandContext(ctx, powershellPath, args...) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err = cmd.Run() - return stdout.String(), stderr.String(), err -} - -func isEnabled(ctx context.Context) (bool, error) { - stdout, _, err := shellExec(ctx, "Get-WindowsOptionalFeature", "-FeatureName", `"Containers-DisposableClientVM"`, "-Online") - if err != nil { - return false, err - } - - return regexp.MatchString(`(?m)State\s*:\s*Enabled`, stdout) -} - -func isInstalled(ctx context.Context) (bool, error) { - _, err := exec.LookPath("WindowsSandbox.exe") - if err != nil { - return false, err - } - return true, nil -} - -func activate(ctx context.Context) (bool, error) { - _, _, err := shellExec(ctx, "Enable-WindowsOptionalFeature", "-FeatureName", `"Containers-DisposableClientVM"`, "-NoRestart", "True") - if err != nil { - return false, err - } - - return true, nil -} - -func deactivate(ctx context.Context) (bool, error) { - _, _, err := shellExec(ctx, "Disable-WindowsOptionalFeature", "-FeatureName", `"Containers-DisposableClientVM"`, "-Online") - if err != nil { - return false, err - } - - return true, nil -} diff --git a/sandbox/virtual_env_docker.go b/sandbox/virtual_env_docker.go deleted file mode 100644 index ca4e9d5..0000000 --- a/sandbox/virtual_env_docker.go +++ /dev/null @@ -1,344 +0,0 @@ -package sandbox - -import ( - "context" - "fmt" - "log" - "os/exec" - "strings" - "time" - - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/client" - "github.com/projectdiscovery/gozero/types" -) - -// DockerConfiguration represents the configuration for Docker sandbox -type DockerConfiguration struct { - Image string // Docker image to use (e.g., "ubuntu:20.04", "alpine:latest") - WorkingDir string // Working directory inside container - Environment map[string]string // Environment variables - NetworkMode string // Network mode (bridge, host, etc.) - NetworkDisabled bool // Disable networking entirely - User string // User to run as inside container - Memory string // Memory limit (e.g., "512m", "1g") - CPULimit string // CPU limit (e.g., "0.5", "1.0") - Timeout time.Duration // Command timeout - Remove bool // Whether to remove container after execution -} - -// SandboxDocker implements the Sandbox interface using Docker containers -type SandboxDocker struct { - config *DockerConfiguration - dockerClient *client.Client -} - -// NewDockerSandbox creates a new Docker-based sandbox -func NewDockerSandbox(ctx context.Context, config *DockerConfiguration) (*SandboxDocker, error) { - // Check if Docker is available - if ok, err := isDockerInstalled(ctx); err != nil || !ok { - return nil, fmt.Errorf("docker not available: %w", err) - } - - // Create Docker client - dockerClient, err := client.New(client.FromEnv) - if err != nil { - return nil, fmt.Errorf("failed to create docker client: %w", err) - } - - // Test Docker connection - _, err = dockerClient.Ping(ctx, client.PingOptions{NegotiateAPIVersion: true}) - if err != nil { - _ = dockerClient.Close() - return nil, fmt.Errorf("failed to connect to docker daemon: %w", err) - } - - // Validate required configuration - if config.Image == "" { - _ = dockerClient.Close() - return nil, fmt.Errorf("docker image must be specified") - } - if config.WorkingDir == "" { - _ = dockerClient.Close() - return nil, fmt.Errorf("working directory must be specified") - } - if config.Timeout == 0 { - config.Timeout = 30 * time.Second - } - if config.Remove { - config.Remove = true // Default to removing containers - } - - return &SandboxDocker{ - config: config, - dockerClient: dockerClient, - }, nil -} - -// runCommand executes a command in the Docker container with the given command parts -func (s *SandboxDocker) runCommand(ctx context.Context, cmdParts []string, command string, createFile bool, fileContent string) (*types.Result, error) { - if len(cmdParts) == 0 { - return nil, fmt.Errorf("empty command") - } - - // Create a new context with timeout - runCtx, cancel := context.WithTimeout(ctx, s.config.Timeout) - defer cancel() - - // Prepare environment variables - env := []string{} - for key, value := range s.config.Environment { - env = append(env, fmt.Sprintf("%s=%s", key, value)) - } - - // If we need to create a file, modify the command to create it first - finalCmd := cmdParts - if createFile && len(fileContent) > 0 { - // Generate a temporary filename - tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d.sh", time.Now().UnixNano()) - - // Create a script that writes the file content and then executes it - scriptContent := fmt.Sprintf(`#!/bin/sh -cat > %s << 'EOF' -%s -EOF -chmod +x %s -exec %s -`, tmpFileName, fileContent, tmpFileName, tmpFileName) - - // Create a command that writes the script and executes it - finalCmd = []string{"/bin/sh", "-c", scriptContent} - } - - // Create container configuration - containerConfig := &container.Config{ - Image: s.config.Image, - Cmd: finalCmd, - WorkingDir: s.config.WorkingDir, - Env: env, - User: s.config.User, - AttachStdout: true, - AttachStderr: true, - } - - // Create host configuration - hostConfig := &container.HostConfig{ - AutoRemove: false, // Don't auto-remove so we can get logs - } - - // Set network configuration - if s.config.NetworkDisabled { - hostConfig.NetworkMode = "none" - } else if s.config.NetworkMode != "" { - hostConfig.NetworkMode = container.NetworkMode(s.config.NetworkMode) - } - - // Set resource limits if specified - if s.config.Memory != "" { - hostConfig.Memory = parseMemoryLimit(s.config.Memory) - } - - // Pull image if it doesn't exist locally - err := s.pullImageIfNeeded(runCtx, s.config.Image) - if err != nil { - return nil, fmt.Errorf("failed to pull image %s: %w", s.config.Image, err) - } - - // Create container - createResp, err := s.dockerClient.ContainerCreate(runCtx, client.ContainerCreateOptions{Config: containerConfig, HostConfig: hostConfig}) - if err != nil { - return nil, fmt.Errorf("failed to create container: %w", err) - } - - containerID := createResp.ID - - // Start container - _, err = s.dockerClient.ContainerStart(runCtx, containerID, client.ContainerStartOptions{}) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to start container: %w", err) - } - - // Wait for container to finish - waitRes := s.dockerClient.ContainerWait(runCtx, containerID, client.ContainerWaitOptions{Condition: container.WaitConditionNotRunning}) - - select { - case err := <-waitRes.Error: - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("container wait error: %w", err) - case result := <-waitRes.Result: - // Get container logs - logs, err := s.dockerClient.ContainerLogs(runCtx, containerID, client.ContainerLogsOptions{ - ShowStdout: true, - ShowStderr: true, - }) - if err != nil { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to get container logs: %w", err) - } - defer func() { - _ = logs.Close() - }() - - // Read logs - logData := make([]byte, 1024*1024) // 1MB buffer - n, err := logs.Read(logData) - if err != nil && err.Error() != "EOF" { - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - return nil, fmt.Errorf("failed to read container logs: %w", err) - } - - // Create result - cmdResult := &types.Result{ - Command: command, - } - cmdResult.Stdout.Write(logData[:n]) - - // Set exit code - if result.StatusCode != 0 { - cmdResult.SetExitError(&exec.ExitError{}) - } - - // Always clean up container manually - _, _ = s.dockerClient.ContainerRemove(runCtx, containerID, client.ContainerRemoveOptions{Force: true}) - - return cmdResult, nil - } -} - -// Run executes a command in the Docker container (synchronous execution) -func (s *SandboxDocker) Run(ctx context.Context, cmd string) (*types.Result, error) { - // Parse command into parts - cmdParts := strings.Fields(cmd) - return s.runCommand(ctx, cmdParts, cmd, false, "") -} - -// RunScript executes a script in the Docker container -func (s *SandboxDocker) RunScript(ctx context.Context, source string, interpreter string) (*types.Result, error) { - return s.RunSource(ctx, source, interpreter) -} - -// RunSource writes source code to a temporary file inside the container and executes it -func (s *SandboxDocker) RunSource(ctx context.Context, source string, interpreter string) (*types.Result, error) { - // Generate a temporary filename - tmpFileName := fmt.Sprintf("/tmp/gozero_script_%d", time.Now().UnixNano()) - - // Check if source has a shebang - if so, use it instead of the interpreter - // Otherwise, use the provided interpreter - execCmd := fmt.Sprintf("%s %s", interpreter, tmpFileName) - if strings.HasPrefix(source, "#!") { - // Source has shebang, let it execute directly - execCmd = tmpFileName - } - - // Create a script that writes the source content and then executes it - scriptContent := fmt.Sprintf(`cat > %s << 'EOF' -%s -EOF -chmod +x %s -exec %s -`, tmpFileName, source, tmpFileName, execCmd) - - log.Println(tmpFileName) - log.Println(scriptContent) - - // Execute the script directly - cmdParts := []string{"/bin/sh", "-c", scriptContent} - return s.runCommand(ctx, cmdParts, fmt.Sprintf("exec %s", tmpFileName), false, "") -} - -// Start is not implemented for Docker sandbox as it's stateless -func (s *SandboxDocker) Start() error { - return ErrNotImplemented -} - -// Wait is not implemented for Docker sandbox as it's stateless -func (s *SandboxDocker) Wait() error { - return ErrNotImplemented -} - -// Stop is not implemented for Docker sandbox as it's stateless -func (s *SandboxDocker) Stop() error { - return ErrNotImplemented -} - -// Clear cleans up Docker resources (containers, images, etc.) -func (s *SandboxDocker) Clear() error { - if s.dockerClient != nil { - return s.dockerClient.Close() - } - return nil -} - -// isDockerInstalled checks if Docker is installed and available by executing docker info -func isDockerInstalled(ctx context.Context) (bool, error) { - cmd := exec.CommandContext(ctx, "docker", "info") - err := cmd.Run() - if err != nil { - return false, err - } - return true, nil -} - -// parseMemoryLimit parses memory limit string (e.g., "512m", "1g") to bytes -func parseMemoryLimit(memory string) int64 { - if memory == "" { - return 0 - } - - // Basic parsing - in production, use a proper library - // This is a simplified implementation - memory = strings.ToLower(strings.TrimSpace(memory)) - - var multiplier int64 = 1 - if strings.HasSuffix(memory, "g") { - multiplier = 1024 * 1024 * 1024 - memory = strings.TrimSuffix(memory, "g") - } else if strings.HasSuffix(memory, "m") { - multiplier = 1024 * 1024 - memory = strings.TrimSuffix(memory, "m") - } else if strings.HasSuffix(memory, "k") { - multiplier = 1024 - memory = strings.TrimSuffix(memory, "k") - } - - // Parse the numeric part - var value int64 - _, _ = fmt.Sscanf(memory, "%d", &value) - return value * multiplier -} - -// pullImageIfNeeded pulls the Docker image if it doesn't exist locally -func (s *SandboxDocker) pullImageIfNeeded(ctx context.Context, imageName string) error { - // Check if image exists locally - _, err := s.dockerClient.ImageInspect(ctx, imageName) - if err == nil { - // Image exists locally, no need to pull - return nil - } - - // Image doesn't exist, pull it - reader, err := s.dockerClient.ImagePull(ctx, imageName, client.ImagePullOptions{}) - if err != nil { - return fmt.Errorf("failed to pull image: %w", err) - } - defer func() { - _ = reader.Close() - }() - - // Read the pull progress to completion - buf := make([]byte, 1024) - for { - _, readErr := reader.Read(buf) - if readErr == nil { - continue - } - // EOF is expected when pull completes successfully - if readErr.Error() != "EOF" { - return fmt.Errorf("failed to complete image pull: %w", readErr) - } - break - } - - return nil -} diff --git a/types/result.go b/types/result.go index 31c6c90..f5e45cd 100644 --- a/types/result.go +++ b/types/result.go @@ -16,6 +16,7 @@ type Result struct { Stdout bytes.Buffer Stderr bytes.Buffer exitErr *exec.ExitError // return exit error this includes exit code , command sysusage and more + exitCode *int // explicit exit code for backends without an *exec.ExitError (e.g. containers) DebugData *bytes.Buffer // only available when debug mode is enabled } @@ -29,8 +30,18 @@ func (r *Result) SetExitError(err *exec.ExitError) { r.exitErr = err } +// SetExitCode records an explicit exit code for execution backends that do not +// surface an *exec.ExitError (e.g. containers, where the code comes from the +// daemon's wait response). It takes precedence over the exit error. +func (r *Result) SetExitCode(code int) { + r.exitCode = &code +} + // GetExitCode returns the exit code of the command. func (r *Result) GetExitCode() int { + if r.exitCode != nil { + return *r.exitCode + } if r.exitErr == nil { return 0 }