diff --git a/README.md b/README.md index 4e475b5..9edb414 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,28 @@ gozero: the wannabe zero dependency [language-here] runtime for Go developers ## Isolation -### Windows +Confinement is provided by the `confine` package. It is deny-by-default (no +network, dropped capabilities, no-new-privileges, read-only root filesystem, +private namespaces, cpu/memory/pids limits, minimal environment) and fail +closed: when confinement is requested but the backend is unavailable, execution +is refused instead of falling back to the host. -Native isolation on windows is supported only with the PRO version and is implemented via Windows Sandbox (which needs to be [activated](https://www.makeuseof.com/enable-set-up-windows-sandbox-windows-11/)). +Backends: -### Darwin +- Linux: bubblewrap (`bwrap`). +- macOS: Seatbelt (`sandbox-exec`). +- Any OS with a Docker daemon: a hardened, single-shot container. -OSX implements native isolation via the command `sandbox-exec`. The command line interface is marked as deprecated, but the system functionality is actively supported, and profiles are still used in well-known software like chrome, firefox. +Source is passed to the interpreter as bytes, never interpolated into a shell. -### Linux +## Usage -On Linux, the functionality is implemented with the default command `systemd-run`, which should be available on most systems and allow a vast fine-grained sandbox configuration via SecComp and EBPF +Confinement is off by default. Enable it per executor: +```go +opts := &gozero.Options{Engines: []string{"python3"}, Sandbox: true} +g, err := gozero.New(opts) // fails closed if no backend is available +``` -## Note: - -Sandbox is not enabled by default and needs to be used manually through sdk \ No newline at end of file +Tune the posture with `Options.Confinement` (a `*confine.Policy`), or pick a +backend per call with `Gozero.EvalWithVirtualEnv`. diff --git a/confine/bwrap_args.go b/confine/bwrap_args.go new file mode 100644 index 0000000..a844d6b --- /dev/null +++ b/confine/bwrap_args.go @@ -0,0 +1,120 @@ +package confine + +import ( + "fmt" + "path/filepath" + "sort" +) + +// bwrapArgs builds the full bubblewrap (bwrap) command line that wraps argv. +// +// Posture (deny-by-default): +// +// - --unshare-all creates fresh user, mount, PID, IPC, UTS, cgroup and network +// namespaces, so the payload sees none of the host's processes, IPC, or +// network. --share-net is added back only when the policy allows network. +// - --die-with-parent tears the sandbox down if gozero exits (no orphans). +// - --new-session detaches the controlling terminal (blocks TIOCSTI injection). +// - --clearenv drops the inherited environment; only the explicitly allowed +// variables are re-added with --setenv, so ambient secrets never leak in. +// - The host root is bind-mounted READ-ONLY so the interpreter and its shared +// libraries are reachable, then fresh /proc, /dev and a tmpfs /tmp are laid +// on top. Exactly one path — the workspace — is bound writable. The script's +// directory is bound read-only (it is normally already covered by the ro +// root, but binding it explicitly keeps the payload readable even if the +// script lives on a filesystem the root bind didn't capture). +// - --cap-drop ALL removes every capability as defense in depth on top of the +// user namespace. +// +// Flag order matters: bwrap applies binds left-to-right and later binds win for +// overlapping paths, so the read-only root is laid down before the writable +// workspace re-binds over it. +// +// bwrap is a transparent wrapper: stdin/stdout/stderr and the child's exit code +// pass straight through. +func bwrapArgs(bin string, p Policy, workspace, scriptPath string, env map[string]string, argv []string) []string { + ws := canonicalPath(workspace) + + out := []string{ + bin, + "--unshare-all", + "--die-with-parent", + "--new-session", + "--clearenv", + } + if p.AllowNetwork { + out = append(out, "--share-net") + } + if p.DropAllCapabilities { + out = append(out, "--cap-drop", "ALL") + } + + // Read-only host root first, then overlay pseudo-filesystems. + out = append(out, + "--ro-bind", "/", "/", + "--proc", "/proc", + "--dev", "/dev", + "--tmpfs", "/tmp", + ) + + // Single writable path: the workspace. + if ws != "" { + out = append(out, "--bind", ws, ws) + } + if t := canonicalPath(p.Tmp); t != "" && t != ws { + out = append(out, "--bind", t, t) + } + // Ensure the script's directory is readable even if it sits outside the + // root bind (idempotent when already covered). + if scriptPath != "" { + if dir := canonicalPath(filepath.Dir(scriptPath)); dir != "" && dir != "/" && dir != ws { + out = append(out, "--ro-bind-try", dir, dir) + } + } + + // Non-root identity inside the user namespace. + if p.RunAsUID >= 0 { + out = append(out, "--uid", fmt.Sprintf("%d", p.RunAsUID)) + } + if p.RunAsGID >= 0 { + out = append(out, "--gid", fmt.Sprintf("%d", p.RunAsGID)) + } + + if ws != "" { + out = append(out, "--chdir", ws) + } + + // Re-inject the scrubbed environment deterministically. + for _, k := range sortedKeys(env) { + out = append(out, "--setenv", k, env[k]) + } + + out = append(out, "--") + out = append(out, argv...) + return out +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// canonicalPath resolves symlinks so a bind/profile matches the path the kernel +// actually evaluates, falling back to an absolute/clean path when resolution +// fails (e.g. the path does not exist yet). +func canonicalPath(p string) string { + if p == "" { + return "" + } + if resolved, err := filepath.EvalSymlinks(p); err == nil { + return resolved + } + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return filepath.Clean(p) +} diff --git a/confine/bwrap_args_test.go b/confine/bwrap_args_test.go new file mode 100644 index 0000000..559c564 --- /dev/null +++ b/confine/bwrap_args_test.go @@ -0,0 +1,112 @@ +package confine + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// containsSeq reports whether sub appears as a contiguous subsequence of s. +func containsSeq(s, sub []string) bool { + if len(sub) == 0 { + return true + } + for i := 0; i+len(sub) <= len(s); i++ { + if equalSlice(s[i:i+len(sub)], sub) { + return true + } + } + return false +} + +func equalSlice(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestBwrapArgsHardenedByDefault(t *testing.T) { + ws := t.TempDir() + p := DefaultPolicy() + p.Tmp = ws // avoid a second writable bind for a distinct temp dir + argv := []string{"/usr/bin/python3", "/tmp/script.py", "--flag"} + env := map[string]string{"PATH": "/usr/bin", "FOO": "bar"} + + got := bwrapArgs("/usr/bin/bwrap", p, ws, "/tmp/script.py", env, argv) + cws := canonicalPath(ws) + + // Core isolation flags. + assert.Equal(t, "/usr/bin/bwrap", got[0]) + for _, flag := range []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv"} { + assert.Contains(t, got, flag, "missing hardening flag %s", flag) + } + assert.True(t, containsSeq(got, []string{"--cap-drop", "ALL"}), "must drop all capabilities") + assert.True(t, containsSeq(got, []string{"--ro-bind", "/", "/"}), "host root must be read-only") + assert.True(t, containsSeq(got, []string{"--proc", "/proc"})) + assert.True(t, containsSeq(got, []string{"--dev", "/dev"})) + assert.True(t, containsSeq(got, []string{"--tmpfs", "/tmp"})) + assert.True(t, containsSeq(got, []string{"--bind", cws, cws}), "workspace must be the writable bind") + assert.True(t, containsSeq(got, []string{"--chdir", cws})) + + // Environment is re-injected deterministically (sorted), never inherited. + assert.True(t, containsSeq(got, []string{"--setenv", "FOO", "bar"})) + assert.True(t, containsSeq(got, []string{"--setenv", "PATH", "/usr/bin"})) + fooIdx := indexOf(got, "FOO") + pathIdx := indexOf(got, "PATH") + assert.Less(t, fooIdx, pathIdx, "setenv keys must be sorted") + + // No network by default. + assert.NotContains(t, got, "--share-net") + + // Command follows the -- terminator, in order, and unchanged. + dashIdx := lastIndexOf(got, "--") + require.NotEqual(t, -1, dashIdx) + assert.Equal(t, argv, got[dashIdx+1:], "argv must be passed verbatim after --") +} + +func TestBwrapArgsNetworkToggle(t *testing.T) { + ws := t.TempDir() + p := DefaultPolicy() + p.AllowNetwork = true + got := bwrapArgs("bwrap", p, ws, "", nil, []string{"sh"}) + assert.Contains(t, got, "--share-net", "network must be re-shared when allowed") +} + +func TestBwrapArgsNonRootIdentity(t *testing.T) { + ws := t.TempDir() + p := DefaultPolicy() + p.RunAsUID = 1000 + p.RunAsGID = 1000 + got := bwrapArgs("bwrap", p, ws, "", nil, []string{"sh"}) + assert.True(t, containsSeq(got, []string{"--uid", "1000"})) + assert.True(t, containsSeq(got, []string{"--gid", "1000"})) +} + +func indexOf(s []string, v string) int { + for i := range s { + if s[i] == v { + return i + } + } + return -1 +} + +func lastIndexOf(s []string, v string) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == v { + return i + } + } + return -1 +} + +// ensure the joined form is shell-inspectable (used in a couple of assertions). +var _ = strings.Join diff --git a/confine/confine.go b/confine/confine.go new file mode 100644 index 0000000..468f140 --- /dev/null +++ b/confine/confine.go @@ -0,0 +1,320 @@ +// 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, private namespaces, a +// minimal scrubbed environment, non-root identity and CPU/memory/pids +// limits are all ON unless the policy explicitly relaxes them. +// - 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, non-root) with the source injected via the archive API. +package confine + +import ( + "context" + "errors" + "io" + "log/slog" + "os" + "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 not meant to be used directly; call DefaultPolicy and tune +// from there, or rely on New to fill in safe defaults for unset fields. +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 temp dir. Defaults to os.TempDir(). + Tmp string + + // DropAllCapabilities drops every Linux capability (docker/bwrap). Default true. + DropAllCapabilities bool + // NoNewPrivileges sets no_new_privs so setuid/setgid bits can't raise + // privileges (docker). Default true. + NoNewPrivileges bool + // ReadonlyRootfs makes the root filesystem read-only (docker) / read-only + // bind (bwrap). Default true. + ReadonlyRootfs bool + + // 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) and "image/OS default" where uid/gid are not meaningful. + RunAsUID int + RunAsGID int + + // 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 + // 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, private +// namespaces, resource limits, non-root, minimal environment. +func DefaultPolicy() Policy { + return Policy{ + Backend: BackendAuto, + AllowNetwork: false, + DropAllCapabilities: true, + NoNewPrivileges: true, + ReadonlyRootfs: true, + 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.Tmp == "" { + p.Tmp = os.TempDir() + } + 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..6bcb312 --- /dev/null +++ b/confine/confine_test.go @@ -0,0 +1,92 @@ +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.True(t, p.DropAllCapabilities) + assert.True(t, p.NoNewPrivileges) + assert.True(t, p.ReadonlyRootfs) + 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.NotEmpty(t, got.Tmp) + 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..8d6ab46 --- /dev/null +++ b/confine/docker.go @@ -0,0 +1,315 @@ +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" + + // A non-root identity is only forced when the policy sets one explicitly + // (RunAsUID >= 0). The default (-1) leaves the image's own user in place, + // because forcing an arbitrary uid breaks images without a matching passwd + // entry — the container is already hardened by cap-drop, no-new-privileges, + // a read-only rootfs and no network. + user := "" + if c.policy.RunAsUID >= 0 { + user = fmt.Sprintf("%d", c.policy.RunAsUID) + if c.policy.RunAsGID >= 0 { + user = fmt.Sprintf("%d:%d", c.policy.RunAsUID, c.policy.RunAsGID) + } + } + + 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 relaxing ReadonlyRootfs 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 + // may run under a forced non-root uid (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)) + 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 +} + +// 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. +func dockerHostConfig(p Policy) *container.HostConfig { + hc := &container.HostConfig{ + AutoRemove: false, // we remove explicitly so we can still read logs + ReadonlyRootfs: p.ReadonlyRootfs, + CgroupnsMode: container.CgroupnsModePrivate, + IpcMode: container.IPCModePrivate, + Privileged: false, + // mode=1777 (sticky, world-writable) so a forced 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.DropAllCapabilities { + hc.CapDrop = []string{"ALL"} + } + if p.NoNewPrivileges { + 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..76c91a8 --- /dev/null +++ b/confine/docker_integration_test.go @@ -0,0 +1,114 @@ +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() + 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, _ := c.Run(ctx, Spec{Command: []string{"/bin/sh", p}, ScriptPath: p, ScriptData: data}) + 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") +} diff --git a/confine/docker_test.go b/confine/docker_test.go new file mode 100644 index 0000000..872e639 --- /dev/null +++ b/confine/docker_test.go @@ -0,0 +1,65 @@ +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") +} + +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..d28fcc7 --- /dev/null +++ b/confine/exec_integration_test.go @@ -0,0 +1,189 @@ +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) +} + +// 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) +} 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..fad3e0e --- /dev/null +++ b/confine/native_darwin.go @@ -0,0 +1,61 @@ +//go:build darwin + +package confine + +import ( + "context" + "os" + "os/exec" + + "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 + } + return &seatbeltConfiner{bin: bin, policy: p} +} + +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(ws, c.policy.Tmp, c.policy.AllowNetwork) + + 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..a8e6295 --- /dev/null +++ b/confine/native_linux.go @@ -0,0 +1,58 @@ +//go:build linux + +package confine + +import ( + "context" + "os" + "os/exec" + + "github.com/projectdiscovery/gozero/types" +) + +// newNativeConfiner returns a bubblewrap confiner when bwrap is on PATH, 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 + } + return &bubblewrapConfiner{bin: bin, policy: p} +} + +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..5e3a949 --- /dev/null +++ b/confine/seatbelt_profile.go @@ -0,0 +1,55 @@ +package confine + +import "strings" + +// 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; 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 two things that actually matter for +// untrusted execution — writing outside the workspace and phoning home. +// +// SBPL is last-match-wins, so the deny rules must come after (allow default) +// and the write re-allow must come after the write 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(workspace, tmp string, allowNetwork bool) string { + ws := canonicalPath(workspace) + tmpC := canonicalPath(tmp) + + var b strings.Builder + b.WriteString("(version 1)\n") + b.WriteString("(allow default)\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") + b.WriteString(")\n") + if !allowNetwork { + b.WriteString("(deny network*)\n") + } + return b.String() +} + +// 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..5785647 --- /dev/null +++ b/confine/seatbelt_profile_test.go @@ -0,0 +1,40 @@ +package confine + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSeatbeltProfileConfinesWrites(t *testing.T) { + ws := t.TempDir() + profile := seatbeltProfile(ws, ws, false) + + 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(ws, ws, true), "(deny network*)") + assert.Contains(t, seatbeltProfile(ws, ws, false), "(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(ws, ws, false) + 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") +} diff --git a/confine/util.go b/confine/util.go new file mode 100644 index 0000000..756eeab --- /dev/null +++ b/confine/util.go @@ -0,0 +1,16 @@ +package confine + +import ( + "context" + "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) +} 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..1d4c8a4 100644 --- a/gozero_test.go +++ b/gozero_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "github.com/projectdiscovery/gozero/confine" osutils "github.com/projectdiscovery/utils/os" "github.com/stretchr/testify/require" ) @@ -56,3 +57,43 @@ 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())) +} 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 }