From 3336dc7d7a6e15cb623e2ae6292f27f2f6a2aea3 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 15:44:23 -0400 Subject: [PATCH 01/36] add remediation commands and output redirects --- README.md | 4 +- SHELL_FEATURES.md | 14 +- allowedpaths/sandbox.go | 43 +++- allowedpaths/sandbox_test.go | 38 +++ analysis/symbols_allowedpaths.go | 4 + analysis/symbols_builtins.go | 19 ++ analysis/symbols_interp.go | 4 + builtins/builtins.go | 23 ++ builtins/features.go | 12 +- builtins/kill/kill.go | 54 +++++ builtins/logrotate/logrotate.go | 47 ++++ builtins/systemctl/systemctl.go | 49 ++++ builtins/tee/tee.go | 48 ++++ builtins/tests/help/feature_probes_test.go | 19 +- builtins/tests/help/help_test.go | 2 +- builtins/truncate/truncate.go | 83 +++++++ interp/allowed_paths_internal_test.go | 22 +- interp/api.go | 38 ++- interp/register_builtins.go | 10 + interp/remediation_commands_test.go | 221 ++++++++++++++++++ interp/runner.go | 20 +- interp/runner_exec.go | 36 +++ interp/runner_redir.go | 28 ++- interp/tests/cmdsubst_pentest_test.go | 4 +- interp/tests/redir_devnull_pentest_test.go | 25 +- interp/tests/redir_devnull_test.go | 97 ++++++-- interp/validate.go | 17 +- .../append_redirect_blocked.yaml | 12 +- .../blocked_after_valid.yaml | 14 +- .../output_redirect_variable.yaml | 12 +- .../shell/blocked_redirects/stderr_write.yaml | 2 +- .../variable_redirect_target.yaml | 7 +- .../shell/blocked_redirects/write_append.yaml | 17 +- .../blocked_redirects/write_clobber.yaml | 2 +- .../blocked_redirects/write_truncate.yaml | 12 +- .../devnull_path_traversal_blocked.yaml | 6 +- .../redirect_to_file_still_blocked.yaml | 8 +- .../stderr_redirect_to_file_blocked.yaml | 2 +- 38 files changed, 939 insertions(+), 136 deletions(-) create mode 100644 builtins/kill/kill.go create mode 100644 builtins/logrotate/logrotate.go create mode 100644 builtins/systemctl/systemctl.go create mode 100644 builtins/tee/tee.go create mode 100644 builtins/truncate/truncate.go create mode 100644 interp/remediation_commands_test.go diff --git a/README.md b/README.md index b172de3f7..c550002cf 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Every access path is default-deny: | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | | Filesystem access | Blocked | Configure `AllowedPaths` with directory list | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | -| Output redirections | Only `/dev/null` allowed (exit code 2 for other targets) | `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | +| Output redirections | File writes blocked unless `AllowedPaths` permits the target | `> FILE`, `>> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. @@ -72,6 +72,8 @@ Every access path is default-deny: **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions, since proc is a read-only virtual filesystem that does not expose host data under the normal file hierarchy. +**Guarded host commands** (`truncate`, `systemctl`, `kill`, `logrotate`, and `tee`) validate a narrow rshell contract before invoking the host command handler. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. + ## Shell Features Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots (or a notice when none are configured). Use `help ` for details about a specific rshell feature or command. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 010f0b3c4..25d7f40d7 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -24,6 +24,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `ip [-o|-4|-6|--brief] addr|link [show] [dev IFNAME]` — show network interface addresses and link-layer info (read-only); write ops (`add`, `del`, `flush`, `set`), namespace ops (`netns`, `-n`), and batch mode (`-b`/`-B`/`--force`) are blocked - ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1) - ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported +- ✅ `kill [-9] PID` — guarded remediation command; sends SIGTERM or SIGKILL through the host command handler after validating a single positive PID +- ✅ `logrotate PATH` — guarded remediation command; delegates one existing allowed path to the host command handler, usually a scenario-provided wrapper - ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected - ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory @@ -34,8 +36,11 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `read [-r] [-p PROMPT] [-d DELIM] [-n N] [-N N] [-t SECS] [NAME...]` — read one delimited chunk from stdin and assign each IFS-split field to a shell variable (defaulting to `REPLY` when no NAME is given); `-n`/`-N` are capped at 1 MiB; non-raw mode treats `\` as a line continuation (both characters are dropped) and `\` for any other `X` (including the active custom delimiter under `-d`) as a literal `X` with the backslash removed — e.g. `printf 'a\,b,c' | read -d , x` assigns `x="a,b"`; `-p` is suppressed unless stdin is a terminal (matches bash); `-a` (array), `-s` (silent), `-u` (read from FD), `-e` (readline), and `-i` (initial text) are not implemented - ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` +- ✅ `systemctl start|stop|restart|reload UNIT` — guarded remediation command; delegates one lifecycle action and unit to the host command handler - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected +- ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one file through the host command handler; only overwrite and append forms are supported - ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) +- ✅ `truncate -s SIZE FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size, then delegates to the host command handler - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin - ✅ `true` — return exit code 0 - ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`) @@ -81,16 +86,18 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `<` — input redirection (read-only, within AllowedPaths) - ✅ `< FILE` — redirect stdout to FILE, creating/truncating within AllowedPaths +- ✅ `>> FILE` — append stdout to FILE, creating within AllowedPaths - ✅ `>/dev/null`, `2>/dev/null` — redirect stdout or stderr to /dev/null (output is discarded; only `/dev/null` is allowed as target) - ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null - ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate) - ✅ `2>&1`, `>&2` — file descriptor duplication between stdout (1) and stderr (2) - ❌ `|&` — pipe stdout and stderr (bash extension) - ❌ `<<<` — herestring (bash extension) -- ❌ `> FILE` — write/truncate to any file other than /dev/null -- ❌ `>> FILE` — append to any file other than /dev/null +- ❌ `2> FILE` — redirect stderr to a real file - ❌ `&> FILE` — redirect all to any file other than /dev/null - ❌ `&>> FILE` — append all to any file other than /dev/null +- ❌ `>| FILE` — clobber redirection - ❌ `<>` — read-write - ❌ `<&N` — input file descriptor duplication @@ -109,9 +116,10 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed - ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories +- ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments) -- ❌ External commands — blocked by default; requires an ExecHandler to be configured and the binary to be within AllowedPaths +- ❌ External commands — blocked by default; require an ExecHandler and the command name must pass AllowedCommands - ❌ Background execution: `cmd &` - ❌ Coprocesses: `coproc` - ❌ `time` diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 76263d410..6e106cb99 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -310,9 +310,10 @@ func IsDevNull(path string) bool { return false } -// Open implements the restricted file-open policy. The file is opened through -// os.Root for atomic path validation. Only read-only access is permitted; -// any write flags are rejected as a defense-in-depth measure. +// Open implements the restricted file-open policy for builtin file reads. The +// file is opened through os.Root for atomic path validation. Only read-only +// access is permitted; any write flags are rejected as a defense-in-depth +// measure. func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { if flag != os.O_RDONLY { return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} @@ -344,6 +345,42 @@ func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io. return f, nil } +// OpenForWrite implements the restricted file-open policy for shell output +// redirections. It is intentionally separate from Open so builtins keep their +// read-only file capability unless they are explicitly given another one. +func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { + switch flag { + case os.O_WRONLY | os.O_CREATE | os.O_TRUNC, + os.O_WRONLY | os.O_CREATE | os.O_APPEND: + default: + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + absPath := toAbs(path, cwd) + + ar, relPath, ok := s.resolve(absPath) + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + f, err := ar.root.OpenFile(relPath, flag, perm) + if err == nil { + return f, nil + } + if !isPathEscapeError(err) { + return nil, PortablePathError(err) + } + r, rel, ok := s.resolveFollowingSymlinks(absPath, false) + if !ok { + return nil, PortablePathError(err) + } + f, err = r.OpenFile(rel, flag, perm) + if err != nil { + return nil, PortablePathError(err) + } + return f, nil +} + // ReadDir implements the restricted directory-read policy. func (s *Sandbox) ReadDir(path string, cwd string) ([]fs.DirEntry, error) { return s.readDirN(path, cwd, -1) diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 5e52340c7..ef1404261 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -66,6 +66,44 @@ func TestSandboxOpenRejectsWriteFlags(t *testing.T) { f.Close() } +func TestSandboxOpenForWriteAllowsRedirectFlags(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "test.txt"), []byte("old\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite("test.txt", dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + require.NoError(t, err) + _, err = f.Write([]byte("new\n")) + require.NoError(t, err) + require.NoError(t, f.Close()) + + f, err = sb.OpenForWrite("test.txt", dir, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + require.NoError(t, err) + _, err = f.Write([]byte("tail\n")) + require.NoError(t, err) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(filepath.Join(dir, "test.txt")) + require.NoError(t, err) + assert.Equal(t, "new\ntail\n", string(data)) +} + +func TestSandboxOpenForWriteRejectsOutsideAllowedPaths(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite(filepath.Join(outside, "evil.txt"), dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) +} + func TestReadDirLimited(t *testing.T) { dir := t.TempDir() diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 4ba8f501c..c7b922df1 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -41,7 +41,11 @@ var allowedpathsAllowedSymbols = []string{ "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. + "os.O_APPEND", // 🟢 append file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_CREATE", // 🟢 create file flag constant; only accepted by the dedicated redirection write-open path. "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.O_TRUNC", // 🟢 truncate file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_WRONLY", // 🟢 write-only file flag constant; only accepted by the dedicated redirection write-open path. "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. "os.PathError", // 🟢 error type wrapping path and operation; pure type. "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 8842d1226..c0437031f 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -194,6 +194,14 @@ var builtinPerCommandSymbols = map[string][]string{ "os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself. "strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O. }, + "kill": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. + "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. + }, + "logrotate": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + }, "ls": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "errors.New", // 🟢 creates a simple error value; pure function, no I/O. @@ -344,6 +352,9 @@ var builtinPerCommandSymbols = map[string][]string{ "os.PathError", // 🟢 error type for filesystem path errors; pure type, no I/O. "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. }, + "systemctl": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + }, "tail": { "bufio.NewScanner", // 🟢 line-by-line input reading (e.g. head, cat); no write or exec capability. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -360,6 +371,9 @@ var builtinPerCommandSymbols = map[string][]string{ "strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O. "strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; pure function, no I/O. }, + "tee": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + }, "testcmd": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "io/fs.FileInfo", // 🟢 interface type for file information; no side effects. @@ -375,6 +389,11 @@ var builtinPerCommandSymbols = map[string][]string{ "io.EOF", // 🟢 sentinel error value; pure constant. "strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O. }, + "truncate": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. + "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. + }, "true": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. }, diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 7d04865e7..3ac86d56b 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -47,7 +47,11 @@ var interpAllowedSymbols = []string{ "os.File", // 🟠 file handle type; interpreter needs file I/O for redirects and pipes. "os.FileMode", // 🟢 file permission bits type; pure type. "os.Getwd", // 🟠 returns current working directory; read-only. + "os.O_APPEND", // 🟢 append file flag constant; used only for stdout append redirection through AllowedPaths. + "os.O_CREATE", // 🟢 create file flag constant; used only for stdout file redirection through AllowedPaths. "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.O_TRUNC", // 🟢 truncate file flag constant; used only for stdout overwrite redirection through AllowedPaths. + "os.O_WRONLY", // 🟢 write-only file flag constant; used only for stdout file redirection through AllowedPaths. "os.PathError", // 🟢 error type wrapping path and operation; pure type. "os.Pipe", // 🟠 creates an OS pipe pair; needed for shell pipelines. "path/filepath.Clean", // 🟢 normalizes a path lexically; pure function, no I/O. diff --git a/builtins/builtins.go b/builtins/builtins.go index 0e8c5b1c8..7cadc6599 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -260,6 +260,11 @@ type CallContext struct { // If nil, callers should fall back to RunCommand. RunCommandWithStdin func(ctx context.Context, dir string, name string, args []string, stdin io.Reader) (uint8, error) + // RunHostCommand executes a host command after the builtin has validated + // its restricted contract. This is intentionally separate from RunCommand, + // which only dispatches other rshell builtins. + RunHostCommand func(ctx context.Context, name string, args []string) (uint8, error) + // SetVar assigns a value to a shell variable in the calling shell's // scope. Returns an error if the value exceeds the per-variable size // limit or if the total variable-storage cap would be exceeded. @@ -292,6 +297,24 @@ func (c *CallContext) Errf(format string, a ...any) { fmt.Fprintf(c.Stderr, format, a...) } +// InvokeHostCommand runs a guarded host command and converts failures into a +// shell Result suitable for builtins. +func (c *CallContext) InvokeHostCommand(ctx context.Context, name string, args []string) Result { + if c.RunHostCommand == nil { + c.Errf("%s: host command execution not available\n", name) + return Result{Code: 127} + } + code, err := c.RunHostCommand(ctx, name, args) + if err != nil { + c.Errf("%s: %s\n", name, err) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return Result{Code: 1, Exiting: true} + } + return Result{Code: 1} + } + return Result{Code: code} +} + // IsBrokenPipe reports whether err is a broken-pipe (EPIPE) error, // which occurs when writing to a pipe whose read end has been closed. // In bash this triggers SIGPIPE which silently terminates the writer; diff --git a/builtins/features.go b/builtins/features.go index 031e356da..4de9ea2e9 100644 --- a/builtins/features.go +++ b/builtins/features.go @@ -69,19 +69,20 @@ var featureRegistry = []FeatureMeta{ }, { Name: "pipes-redirections", - Description: "Pipes, stdin/heredocs, /dev/null redirects, fd dup; no arbitrary file writes.", + Description: "Pipes, stdin/heredocs, stdout file redirects, /dev/null redirects, fd dup.", Supported: []string{ "Pipelines with | pipe stdout from one command to the next.", "Input redirection with < reads files through AllowedPaths.", "Heredocs with </dev/null, 2>/dev/null, &>/dev/null, >>/dev/null, and &>>/dev/null.", + "Stdout file redirection with > and >> writes through AllowedPaths.", + "Output redirection to /dev/null: >/dev/null, 2>/dev/null, &>/dev/null, >>/dev/null, and &>>/dev/null.", "File descriptor duplication between stdout and stderr with 2>&1 and >&2.", }, Unsupported: []string{ - "Writing, appending, or redirecting output to any file other than /dev/null.", + "Stderr file redirection to real files.", "Pipe stdout and stderr together with |&.", "Herestrings with <<<.", - "Read-write redirection with <> and input fd duplication with <&N.", + "Read-write redirection with <>, clobber redirection with >|, and input fd duplication with <&N.", }, }, { @@ -105,6 +106,7 @@ var featureRegistry = []FeatureMeta{ Supported: []string{ "AllowedCommands restricts executable commands; rshell commands use the rshell: namespace prefix.", "AllowedPaths restricts filesystem access to configured directories.", + "Guarded remediation commands can invoke host commands through the host command handler after validating their restricted contract.", "Whole-run timeouts can be set with context.Context, interp.MaxExecutionTime, or the CLI --timeout flag.", "ProcPath overrides the proc filesystem used by ps on Linux.", }, @@ -143,7 +145,7 @@ var unsupportedSummary = []string{ "Expansions: arithmetic $((...)), arrays, advanced ${...} operations, tilde expansion, process substitution, extended globbing.", "Control flow: case, select, C-style for ((...)), and shell functions.", "Execution: external commands by default, background jobs, coprocesses, time, [[...]], ((...)), declare/export/local/readonly/let.", - "I/O and environment: arbitrary output file redirects, |&, herestrings, read-write redirects, input fd duplication, host env inheritance.", + "I/O and environment: stderr file redirects, |&, herestrings, read-write redirects, input fd duplication, host env inheritance.", } var featureByName = buildFeatureIndex(featureRegistry) diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go new file mode 100644 index 000000000..969794783 --- /dev/null +++ b/builtins/kill/kill.go @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package kill implements a guarded kill command. +package kill + +import ( + "context" + "strconv" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the kill builtin command descriptor. +var Cmd = builtins.Command{ + Name: "kill", + Description: "terminate an allowed process by pid", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: kill [-9] PID\n") + callCtx.Out("Send SIGTERM, or SIGKILL with -9, to PID.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + forceFlag := fs.BoolP("force", "9", false, "send SIGKILL instead of SIGTERM") + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if len(args) != 1 { + callCtx.Errf("kill: expected exactly one pid\n") + return builtins.Result{Code: 1} + } + pid, err := strconv.ParseInt(args[0], 10, 64) + if err != nil || pid <= 0 { + callCtx.Errf("kill: invalid pid: %s\n", args[0]) + return builtins.Result{Code: 1} + } + argv := []string{strconv.FormatInt(pid, 10)} + if *forceFlag { + argv = []string{"-9", strconv.FormatInt(pid, 10)} + } + return callCtx.InvokeHostCommand(ctx, "kill", argv) + } +} diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go new file mode 100644 index 000000000..80a0d5bd5 --- /dev/null +++ b/builtins/logrotate/logrotate.go @@ -0,0 +1,47 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package logrotate implements a guarded logrotate command. +package logrotate + +import ( + "context" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the logrotate builtin command descriptor. +var Cmd = builtins.Command{ + Name: "logrotate", + Description: "rotate one allowed log path", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: logrotate PATH\n") + callCtx.Out("Rotate PATH using the scenario-provided logrotate wrapper.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if len(args) != 1 { + callCtx.Errf("logrotate: expected exactly one path\n") + return builtins.Result{Code: 1} + } + if _, err := callCtx.StatFile(ctx, args[0]); err != nil { + callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + return callCtx.InvokeHostCommand(ctx, "logrotate", args) + } +} diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go new file mode 100644 index 000000000..aa7534dfa --- /dev/null +++ b/builtins/systemctl/systemctl.go @@ -0,0 +1,49 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package systemctl implements a guarded systemctl command. +package systemctl + +import ( + "context" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the systemctl builtin command descriptor. +var Cmd = builtins.Command{ + Name: "systemctl", + Description: "run a restricted service lifecycle action", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: systemctl ACTION UNIT\n") + callCtx.Out("Run start, stop, restart, or reload for UNIT.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if len(args) != 2 { + callCtx.Errf("systemctl: expected ACTION and UNIT\n") + return builtins.Result{Code: 1} + } + switch args[0] { + case "restart", "start", "stop", "reload": + default: + callCtx.Errf("systemctl: unsupported action: %s\n", args[0]) + return builtins.Result{Code: 1} + } + return callCtx.InvokeHostCommand(ctx, "systemctl", args) + } +} diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go new file mode 100644 index 000000000..2503342a7 --- /dev/null +++ b/builtins/tee/tee.go @@ -0,0 +1,48 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package tee implements a guarded tee command. +package tee + +import ( + "context" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the tee builtin command descriptor. +var Cmd = builtins.Command{ + Name: "tee", + Description: "write stdin to stdout and one allowed file", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: tee [OPTION] FILE\n") + callCtx.Out("Copy standard input to standard output and FILE.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + appendFlag := fs.BoolP("append", "a", false, "append to FILE instead of overwriting") + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if len(args) != 1 { + callCtx.Errf("tee: expected exactly one file\n") + return builtins.Result{Code: 1} + } + argv := []string{args[0]} + if *appendFlag { + argv = []string{"-a", args[0]} + } + return callCtx.InvokeHostCommand(ctx, "tee", argv) + } +} diff --git a/builtins/tests/help/feature_probes_test.go b/builtins/tests/help/feature_probes_test.go index 06ce15b3e..0faf089e3 100644 --- a/builtins/tests/help/feature_probes_test.go +++ b/builtins/tests/help/feature_probes_test.go @@ -133,16 +133,17 @@ var featureProbes = map[string]map[string]string{ }, "pipes-redirections": { // Supported - "Pipelines with | pipe stdout from one command to the next.": "echo x | cat", - "Input redirection with < reads files through AllowedPaths.": ": /dev/null, 2>/dev/null, &>/dev/null, >>/dev/null, and &>>/dev/null.": "echo x >/dev/null", - "File descriptor duplication between stdout and stderr with 2>&1 and >&2.": "echo x 2>&1", + "Pipelines with | pipe stdout from one command to the next.": "echo x | cat", + "Input redirection with < reads files through AllowedPaths.": ": and >> writes through AllowedPaths.": "echo x > out", + "Output redirection to /dev/null: >/dev/null, 2>/dev/null, &>/dev/null, >>/dev/null, and &>>/dev/null.": "echo x >/dev/null", + "File descriptor duplication between stdout and stderr with 2>&1 and >&2.": "echo x 2>&1", // Unsupported - "Writing, appending, or redirecting output to any file other than /dev/null.": "echo x >/tmp/rshell-probe-should-not-write", - "Pipe stdout and stderr together with |&.": "echo x |& cat", - "Herestrings with <<<.": "cat << and input fd duplication with <&N.": "cat <&0", + "Stderr file redirection to real files.": "echo x 2> err", + "Pipe stdout and stderr together with |&.": "echo x |& cat", + "Herestrings with <<<.": "cat <<, clobber redirection with >|, and input fd duplication with <&N.": "cat <&0", }, "quoting-expansion": { // Supported diff --git a/builtins/tests/help/help_test.go b/builtins/tests/help/help_test.go index af9de6fca..3f8f3345e 100644 --- a/builtins/tests/help/help_test.go +++ b/builtins/tests/help/help_test.go @@ -211,7 +211,7 @@ func TestHelpListsFeaturesAndUnsupportedSummary(t *testing.T) { assert.Contains(t, stdout, "Not supported:") assert.Contains(t, stdout, "arithmetic $((...))") assert.Contains(t, stdout, "case, select") - assert.Contains(t, stdout, "arbitrary output file redirects") + assert.Contains(t, stdout, "stderr file redirects") } func TestHelpShowsFeatureHelp(t *testing.T) { diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go new file mode 100644 index 000000000..5f4c9476c --- /dev/null +++ b/builtins/truncate/truncate.go @@ -0,0 +1,83 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package truncate implements a guarded truncate command. +package truncate + +import ( + "context" + "strconv" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the truncate builtin command descriptor. +var Cmd = builtins.Command{ + Name: "truncate", + Description: "shrink an existing regular file to a byte size", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: truncate -s SIZE FILE\n") + callCtx.Out("Shrink FILE to SIZE bytes.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + sizeFlag := fs.StringP("size", "s", "", "target byte size") + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if *sizeFlag == "" { + callCtx.Errf("truncate: missing -s SIZE\n") + return builtins.Result{Code: 1} + } + if !isDecimalSize(*sizeFlag) { + callCtx.Errf("truncate: invalid size: %s\n", *sizeFlag) + return builtins.Result{Code: 1} + } + size, err := strconv.ParseInt(*sizeFlag, 10, 64) + if err != nil { + callCtx.Errf("truncate: invalid size: %s\n", *sizeFlag) + return builtins.Result{Code: 1} + } + if len(args) != 1 { + callCtx.Errf("truncate: expected exactly one file\n") + return builtins.Result{Code: 1} + } + info, err := callCtx.StatFile(ctx, args[0]) + if err != nil { + callCtx.Errf("truncate: %s: %s\n", args[0], callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + if !info.Mode().IsRegular() { + callCtx.Errf("truncate: %s: not a regular file\n", args[0]) + return builtins.Result{Code: 1} + } + if size > info.Size() { + callCtx.Errf("truncate: cannot grow file\n") + return builtins.Result{Code: 1} + } + return callCtx.InvokeHostCommand(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), args[0]}) + } +} + +func isDecimalSize(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/interp/allowed_paths_internal_test.go b/interp/allowed_paths_internal_test.go index 86fd8bdbd..62c9a5fca 100644 --- a/interp/allowed_paths_internal_test.go +++ b/interp/allowed_paths_internal_test.go @@ -10,7 +10,6 @@ import ( "context" "errors" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -49,20 +48,6 @@ func runScriptInternal(t *testing.T, script, dir string, opts ...RunnerOption) ( if dir != "" { runner.Dir = dir } - runner.execHandler = func(ctx context.Context, args []string) error { - hc := HandlerCtx(ctx) - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = hc.Dir - cmd.Stdout = hc.Stdout - cmd.Stderr = hc.Stderr - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok { - return ExitStatus(exitErr.ExitCode()) - } - return err - } - return nil - } err = runner.Run(context.Background(), prog) exitCode = 0 @@ -99,10 +84,9 @@ func TestAllowedPathsExecNonexistent(t *testing.T) { func TestAllowedPathsExecViaPathLookup(t *testing.T) { dir := t.TempDir() // "date" exists on PATH but /bin and /usr are not in AllowedPaths. - // The default noExecHandler must reject it. We avoid runScriptInternal - // because it overrides execHandler with a real exec.Command, bypassing - // the sandbox. We also cannot use a builtin name (find, grep, sed, etc.) - // because builtins are resolved before the exec handler is consulted. + // The default noExecHandler must reject it. We avoid a builtin name + // (find, grep, sed, etc.) because builtins are resolved before the exec + // handler is consulted. parser := syntax.NewParser() prog, err := parser.Parse(strings.NewReader("date"), "") require.NoError(t, err) diff --git a/interp/api.go b/interp/api.go index 3a45772fa..4b09f8c51 100644 --- a/interp/api.go +++ b/interp/api.go @@ -43,6 +43,10 @@ type runnerConfig struct { // execHandler is responsible for executing programs. It must not be nil. execHandler ExecHandlerFunc + // hostCommandHandler executes host commands on behalf of guarded builtins + // such as truncate, systemctl, kill, logrotate, and tee. + hostCommandHandler ExecHandlerFunc + // openHandler is a function responsible for opening files. It must not be nil. openHandler OpenHandlerFunc @@ -457,11 +461,13 @@ func (r *Runner) Reset() { r.readDirHandler = func(ctx context.Context, path string) ([]os.DirEntry, error) { return r.sandbox.ReadDirForGlob(path, HandlerCtx(ctx).Dir) } - r.execHandler = noExecHandler(r.allowAllCommands || r.allowedCommands["help"]) } if r.execHandler == nil { r.execHandler = noExecHandler(r.allowAllCommands || r.allowedCommands["help"]) } + if r.hostCommandHandler == nil { + r.hostCommandHandler = r.execHandler + } } // Reset only the mutable state; config is preserved. // startTime is intentionally zeroed here by the struct literal; it will @@ -675,6 +681,36 @@ func WarningsWriter(w io.Writer) RunnerOption { } } +// HostCommandHandler configures the host-command execution capability used by +// guarded remediation builtins. The handler receives argv after the builtin has +// validated the PAR-shaped command contract. The runner still enforces +// AllowedCommands before invoking this handler. +// +// When unset, guarded host commands use the configured ExecHandler, which +// defaults to rejecting external execution with exit code 127. +func HostCommandHandler(fn ExecHandlerFunc) RunnerOption { + return func(r *Runner) error { + if fn == nil { + return fmt.Errorf("HostCommandHandler: handler must not be nil") + } + r.hostCommandHandler = fn + return nil + } +} + +// ExecHandler configures execution for allowed commands that are not registered +// rshell builtins. AllowedCommands is still checked before this handler is +// invoked; when unset, unregistered commands return exit code 127. +func ExecHandler(fn ExecHandlerFunc) RunnerOption { + return func(r *Runner) error { + if fn == nil { + return fmt.Errorf("ExecHandler: handler must not be nil") + } + r.execHandler = fn + return nil + } +} + // Warnings returns the sandbox diagnostic messages collected during runner // construction (currently produced by [AllowedPaths] when a configured // directory cannot be opened), one entry per warning. The slice is empty when diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 520fc02f9..52d640e1d 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -24,6 +24,8 @@ import ( "github.com/DataDog/rshell/builtins/head" "github.com/DataDog/rshell/builtins/help" "github.com/DataDog/rshell/builtins/ip" + killcmd "github.com/DataDog/rshell/builtins/kill" + "github.com/DataDog/rshell/builtins/logrotate" "github.com/DataDog/rshell/builtins/ls" "github.com/DataDog/rshell/builtins/ping" printfcmd "github.com/DataDog/rshell/builtins/printf" @@ -34,10 +36,13 @@ import ( sortcmd "github.com/DataDog/rshell/builtins/sort" "github.com/DataDog/rshell/builtins/ss" "github.com/DataDog/rshell/builtins/strings_cmd" + "github.com/DataDog/rshell/builtins/systemctl" "github.com/DataDog/rshell/builtins/tail" + "github.com/DataDog/rshell/builtins/tee" "github.com/DataDog/rshell/builtins/testcmd" "github.com/DataDog/rshell/builtins/tr" truecmd "github.com/DataDog/rshell/builtins/true" + "github.com/DataDog/rshell/builtins/truncate" "github.com/DataDog/rshell/builtins/uname" "github.com/DataDog/rshell/builtins/uniq" "github.com/DataDog/rshell/builtins/wc" @@ -64,7 +69,9 @@ func registerBuiltins() { head.Cmd, help.Cmd, ip.Cmd, + killcmd.Cmd, ls.Cmd, + logrotate.Cmd, ping.Cmd, sortcmd.Cmd, printfcmd.Cmd, @@ -74,9 +81,12 @@ func registerBuiltins() { sed.Cmd, ss.Cmd, strings_cmd.Cmd, + systemctl.Cmd, tail.Cmd, + tee.Cmd, testcmd.Cmd, testcmd.BracketCmd, + truncate.Cmd, tr.Cmd, truecmd.Cmd, uname.Cmd, diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go new file mode 100644 index 000000000..f9fe1d6b7 --- /dev/null +++ b/interp/remediation_commands_test.go @@ -0,0 +1,221 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp_test + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "truncate -s 3 app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + assert.Equal(t, dir, interp.HandlerCtx(ctx).Dir) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"truncate", "-s", "3", "app.log"}, got) +} + +func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "truncate -s 0 app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.ExecHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + assert.Equal(t, dir, interp.HandlerCtx(ctx).Dir) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"truncate", "-s", "0", "app.log"}, got) +} + +func TestRemediationTruncateRejectsGrowth(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abc"), 0644)) + called := false + + _, stderr, code := runScript(t, "truncate -s 4 app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "cannot grow file") + assert.False(t, called) +} + +func TestRemediationTruncateRejectsRelativeSizeSyntax(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abc"), 0644)) + called := false + + _, stderr, code := runScript(t, "truncate -s +1 app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid size") + assert.False(t, called) +} + +func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "external one two", dir, + interp.ExecHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"external", "one", "two"}, got) +} + +func TestRemediationSystemctlDelegatesLifecycleAction(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "systemctl restart app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"systemctl", "restart", "app.service"}, got) +} + +func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { + dir := t.TempDir() + called := false + + _, stderr, code := runScript(t, "systemctl enable app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "unsupported action") + assert.False(t, called) +} + +func TestRemediationKillDelegatesForcePid(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "kill -9 123", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"kill", "-9", "123"}, got) +} + +func TestRemediationKillRejectsInvalidPid(t *testing.T) { + dir := t.TempDir() + called := false + + _, stderr, code := runScript(t, "kill 0", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "invalid pid") + assert.False(t, called) +} + +func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + var got []string + var stdin string + + stdout, stderr, code := runScript(t, "tee -a output.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + data, err := io.ReadAll(interp.HandlerCtx(ctx).Stdin) + require.NoError(t, err) + stdin = string(data) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"tee", "-a", "output.txt"}, got) + assert.Equal(t, "payload\n", stdin) +} + +func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("payload\n"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "logrotate app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"logrotate", "app.log"}, got) +} diff --git a/interp/runner.go b/interp/runner.go index f6a87e4d6..ac3a517af 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -19,9 +19,13 @@ import ( var todoPos syntax.Pos // for handlerCtx callers where we don't yet have a position func (r *Runner) handlerCtx(ctx context.Context, pos syntax.Pos) context.Context { + return r.handlerCtxWithDir(ctx, pos, r.Dir) +} + +func (r *Runner) handlerCtxWithDir(ctx context.Context, pos syntax.Pos, dir string) context.Context { hc := HandlerContext{ Env: &overlayEnviron{parent: r.writeEnv}, - Dir: r.Dir, + Dir: dir, Pos: pos, Stdout: r.stdout, Stderr: r.stderr, @@ -63,3 +67,17 @@ func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileM } return nil, err } + +func (r *Runner) openForWrite(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { + f, err := r.sandbox.OpenForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, flags, mode) + switch err.(type) { + case nil: + return f, nil + case *os.PathError: + err = allowedpaths.PortablePathError(err) + r.errf("%v\n", err) + default: + r.exit.fatal(err) + } + return nil, err +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 00e1d7a8f..03cc5787e 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -662,6 +662,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { return runCmdWithStdin(ctx, dir, name, args, childStdin) }, RunCommandWithStdin: runCmdWithStdin, + RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { + return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs) + }, // Intentionally not exposing SetVar / GetVar in the // child CallContext used for find -exec / -execdir // grandchildren. find treats each invocation as a @@ -768,6 +771,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { LookupEnvVar: r.lookupEnvVar, RunCommand: runCmd, RunCommandWithStdin: runCmdWithStdin, + RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { + return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs) + }, SetVar: func(name, value string) error { if len(value) > MaxVarBytes { return fmt.Errorf("%s: value too large (limit %d bytes)", name, MaxVarBytes) @@ -824,6 +830,36 @@ func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) } +func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string) (uint8, error) { + if caller != name || !isGuardedHostCommand(caller) { + return 127, fmt.Errorf("rshell: %s: host command execution not available", name) + } + if !r.allowAllCommands && !r.allowedCommands[name] { + return 127, fmt.Errorf("rshell: %s: command not allowed", name) + } + argv := make([]string, 0, len(args)+1) + argv = append(argv, name) + argv = append(argv, args...) + err := r.hostCommandHandler(r.handlerCtxWithDir(ctx, pos, dir), argv) + if err == nil { + return 0, nil + } + var status ExitStatus + if errors.As(err, &status) { + return uint8(status), nil + } + return 1, err +} + +func isGuardedHostCommand(name string) bool { + switch name { + case "kill", "logrotate", "systemctl", "tee", "truncate": + return true + default: + return false + } +} + // execIfChain runs an if/elif/else chain iteratively (rather than recursing // through cmd() on each elif as the AST's Else pointer suggests) so the whole // chain is covered by a single rshell.if span. The parser encodes "else" as a diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 15c8e9272..864af49fc 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -245,12 +245,30 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err case syntax.RdrIn: // done further below - case syntax.RdrOut, syntax.ClbOut, syntax.AppOut: - // Output redirects are only allowed to /dev/null (enforced at validation). - // Re-check at runtime after variable expansion for defense-in-depth. + case syntax.RdrOut, syntax.AppOut: if !isDevNull(arg) { - r.errf("> %s: file redirection is only supported for /dev/null\n", arg) - return nil, fmt.Errorf("> %s: file redirection is only supported for /dev/null", arg) + if rd.N != nil && rd.N.Value != "1" { + r.errf("%s: unsupported fd\n", rd.N.Value) + return nil, fmt.Errorf("%s: unsupported fd", rd.N.Value) + } + flag := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if rd.Op == syntax.AppOut { + flag = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + f, err := r.openForWrite(ctx, arg, flag, 0666) + if err != nil { + return nil, err + } + *orig = f + return f, nil + } + *orig = io.Discard + return nil, nil + + case syntax.ClbOut: + if !isDevNull(arg) { + r.errf(">| %s: file redirection is not supported\n", arg) + return nil, fmt.Errorf(">| %s: file redirection is not supported", arg) } *orig = io.Discard return nil, nil diff --git a/interp/tests/cmdsubst_pentest_test.go b/interp/tests/cmdsubst_pentest_test.go index 06f772c3a..acc6f47f6 100644 --- a/interp/tests/cmdsubst_pentest_test.go +++ b/interp/tests/cmdsubst_pentest_test.go @@ -179,8 +179,8 @@ func TestCmdSubstPentestCatShortcutEmptyFile(t *testing.T) { func TestSubshellPentestRedirectOutBlocked(t *testing.T) { dir := t.TempDir() _, stderr, code := subshellRun(t, `(echo data) > /tmp/evil.txt`, dir) - assert.Equal(t, 2, code) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") } // --- Context cancellation --- diff --git a/interp/tests/redir_devnull_pentest_test.go b/interp/tests/redir_devnull_pentest_test.go index ff9f01db2..6f7c8b305 100644 --- a/interp/tests/redir_devnull_pentest_test.go +++ b/interp/tests/redir_devnull_pentest_test.go @@ -85,7 +85,6 @@ func TestPentestRedirPathTraversal(t *testing.T) { {"trailing slash", "echo hello > /dev/null/"}, {"case variation", "echo hello > /Dev/Null"}, {"relative devnull", "echo hello > dev/null"}, - {"bare null", "echo hello > null"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -99,7 +98,7 @@ func TestPentestRedirPathTraversal(t *testing.T) { } } -// --- Variable expansion attacks --- +// --- Variable expansion targets stay sandboxed --- func TestPentestRedirVariableExpansion(t *testing.T) { dir := t.TempDir() @@ -113,13 +112,15 @@ func TestPentestRedirVariableExpansion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, code := pentestRedirRun(t, tt.script, dir) - assert.Equal(t, 2, code, "variable expansion in redirect target should be blocked at validation") + stdout, stderr, code := pentestRedirRun(t, tt.script, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) }) } } -// --- Quoting attacks --- +// --- Quoted /dev/null --- func TestPentestRedirQuotedDevNull(t *testing.T) { dir := t.TempDir() @@ -132,10 +133,10 @@ func TestPentestRedirQuotedDevNull(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, _, code := pentestRedirRun(t, tt.script, dir) - // Quoted paths have different AST structure (SglQuoted/DblQuoted vs Lit) - // Our check requires a single Lit part, so quoted paths should be rejected - assert.Equal(t, 2, code, "quoted /dev/null in redirect should be blocked at validation") + stdout, stderr, code := pentestRedirRun(t, tt.script, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) }) } } @@ -146,7 +147,7 @@ func TestPentestRedirGlobInTarget(t *testing.T) { dir := t.TempDir() // Glob characters in redirect targets _, _, code := pentestRedirRun(t, "echo hello > /dev/nul?", dir) - assert.Equal(t, 2, code, "glob in redirect target should be rejected") + assert.NotEqual(t, 0, code, "globbed redirect target should remain sandboxed") } // --- fd duplication attacks --- @@ -255,9 +256,9 @@ func TestPentestRedirHerestringBlocked(t *testing.T) { func TestPentestRedirMixedAllowedBlocked(t *testing.T) { dir := t.TempDir() - // First redirect is allowed, second is not + // First redirect is allowed, second is outside AllowedPaths. _, _, code := pentestRedirRun(t, "echo hello >/dev/null > /tmp/evil", dir) - assert.Equal(t, 2, code, "mixed redirects with blocked target should fail at validation") + assert.Equal(t, 1, code, "mixed redirects with blocked target should fail at runtime") } // --- Ensure /dev/null redirect doesn't create any files --- diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index f12ca6e6f..a7b5f12e7 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -174,15 +174,73 @@ func TestRedirDevNullPreservesFailureExitCode(t *testing.T) { assert.Equal(t, "1\n", stdout) } -// --- Blocked redirects (still rejected) --- +// --- File output redirects --- -func TestRedirToFileStillBlocked(t *testing.T) { +func TestRedirStdoutToFileCreates(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := redirRun(t, "echo hello > output.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "hello\n", string(data)) +} + +func TestRedirStdoutToFileOverwrites(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "output.txt"), []byte("old\n"), 0644)) + + stdout, stderr, code := redirRun(t, "echo new > output.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "new\n", string(data)) +} + +func TestRedirAppendToFile(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "output.txt"), []byte("old\n"), 0644)) + + stdout, stderr, code := redirRun(t, "echo new >> output.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "old\nnew\n", string(data)) +} + +func TestRedirExplicitFd1ToFile(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := redirRun(t, "echo hello 1>output.txt", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "hello\n", string(data)) +} + +func TestRedirVariableTargetAllowedWhenPathAllowed(t *testing.T) { + dir := t.TempDir() + stdout, stderr, code := redirRun(t, "TARGET=output.txt; echo hello > $TARGET", dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "hello\n", string(data)) +} + +func TestRedirToFileWithoutAllowedPathsBlocked(t *testing.T) { dir := t.TempDir() - // The validation should reject this stdout, stderr, code := redirRunNoAllowed(t, "echo hello > /tmp/output.txt", dir) - assert.Equal(t, 2, code) + assert.Equal(t, 1, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "permission denied") } func TestRedirStderrToFileStillBlocked(t *testing.T) { @@ -190,15 +248,15 @@ func TestRedirStderrToFileStillBlocked(t *testing.T) { stdout, stderr, code := redirRunNoAllowed(t, "echo hello 2> /tmp/errors.txt", dir) assert.Equal(t, 2, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "2> output fd redirection is not supported") } -func TestRedirAppendToFileStillBlocked(t *testing.T) { +func TestRedirAppendToFileWithoutAllowedPathsBlocked(t *testing.T) { dir := t.TempDir() stdout, stderr, code := redirRunNoAllowed(t, "echo hello >> /tmp/output.txt", dir) - assert.Equal(t, 2, code) + assert.Equal(t, 1, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "permission denied") } func TestRedirAllToFileStillBlocked(t *testing.T) { @@ -214,17 +272,17 @@ func TestRedirAllToFileStillBlocked(t *testing.T) { func TestRedirDevNullPathTraversalBlocked(t *testing.T) { dir := t.TempDir() stdout, stderr, code := redirRunNoAllowed(t, "echo hello > /dev/null/../../../tmp/evil", dir) - assert.Equal(t, 2, code) + assert.Equal(t, 1, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "permission denied") } func TestRedirDevNullExtraSlashBlocked(t *testing.T) { dir := t.TempDir() stdout, stderr, code := redirRunNoAllowed(t, "echo hello > /dev//null", dir) - assert.Equal(t, 2, code) + assert.Equal(t, 1, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "permission denied") } // --- Unsupported fd numbers --- @@ -234,7 +292,7 @@ func TestRedirFd3Blocked(t *testing.T) { stdout, stderr, code := redirRunNoAllowed(t, "echo hello 3>/dev/null", dir) assert.Equal(t, 2, code) assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "3> output fd redirection is not supported") } func TestRedirDupFd3Blocked(t *testing.T) { @@ -268,14 +326,3 @@ func TestRedirMultipleDevNull(t *testing.T) { assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) } - -// --- Variable in redirect target should be blocked --- - -func TestRedirVariableTargetBlocked(t *testing.T) { - dir := t.TempDir() - // $TARGET in redirect word makes it non-literal, so validation rejects it - stdout, stderr, code := redirRunNoAllowed(t, "TARGET=/dev/null; echo hello > $TARGET", dir) - assert.Equal(t, 2, code) - assert.Equal(t, "", stdout) - assert.Contains(t, stderr, "file redirection is not supported") -} diff --git a/interp/validate.go b/interp/validate.go index 6255e7c67..ad3c8c4fa 100644 --- a/interp/validate.go +++ b/interp/validate.go @@ -209,16 +209,25 @@ func validateRedirect(rd *syntax.Redirect) error { return fmt.Errorf("%s< input fd redirection is not supported", rd.N.Value) } return nil - case syntax.RdrOut, syntax.ClbOut: + case syntax.RdrOut: + // Output redirection is supported for stdout (default or fd 1). + // Stderr file redirection remains blocked except for /dev/null. + if rd.N == nil || rd.N.Value == "1" || redirectTargetIsDevNull(rd) { + return nil + } + return fmt.Errorf("%s> output fd redirection is not supported", rd.N.Value) + case syntax.ClbOut: if redirectTargetIsDevNull(rd) { return nil } - return fmt.Errorf("> file redirection is not supported") + return fmt.Errorf(">| file redirection is not supported") case syntax.AppOut: - if redirectTargetIsDevNull(rd) { + // Append redirection is supported for stdout (default or fd 1). + // Stderr file redirection remains blocked except for /dev/null. + if rd.N == nil || rd.N.Value == "1" || redirectTargetIsDevNull(rd) { return nil } - return fmt.Errorf(">> file redirection is not supported") + return fmt.Errorf("%s>> output fd redirection is not supported", rd.N.Value) case syntax.RdrAll: if redirectTargetIsDevNull(rd) { return nil diff --git a/tests/scenarios/shell/blocked_redirects/append_redirect_blocked.yaml b/tests/scenarios/shell/blocked_redirects/append_redirect_blocked.yaml index 6f2416bea..8735eb523 100644 --- a/tests/scenarios/shell/blocked_redirects/append_redirect_blocked.yaml +++ b/tests/scenarios/shell/blocked_redirects/append_redirect_blocked.yaml @@ -1,12 +1,12 @@ -# skip: file redirection is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Append redirection with variable filename is blocked. +description: Append redirection with variable filename writes inside allowed paths. input: + allowed_paths: ["$DIR"] script: |+ F=file.txt echo hello >> "$F" + cat "$F" expect: - stdout: "" + stdout: |+ + hello stderr: |+ - >> file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml b/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml index 6b8805e96..d615750de 100644 --- a/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml +++ b/tests/scenarios/shell/blocked_redirects/blocked_after_valid.yaml @@ -1,13 +1,15 @@ -# skip: redirect type is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Write redirect after valid commands still causes rejection. +description: Write redirect after valid commands writes the target and execution continues. input: + allowed_paths: ["$DIR"] script: |+ echo before echo data > output.txt echo after + cat output.txt expect: - stdout: "" + stdout: |+ + before + after + data stderr: |+ - > file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/blocked_redirects/output_redirect_variable.yaml b/tests/scenarios/shell/blocked_redirects/output_redirect_variable.yaml index b218c98d0..c084b3b81 100644 --- a/tests/scenarios/shell/blocked_redirects/output_redirect_variable.yaml +++ b/tests/scenarios/shell/blocked_redirects/output_redirect_variable.yaml @@ -1,12 +1,12 @@ -# skip: file redirection is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Output redirection with variable filename is blocked. +description: Output redirection with variable filename writes inside allowed paths. input: + allowed_paths: ["$DIR"] script: |+ F=out.txt echo hello > "$F" + cat "$F" expect: - stdout: "" + stdout: |+ + hello stderr: |+ - > file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/blocked_redirects/stderr_write.yaml b/tests/scenarios/shell/blocked_redirects/stderr_write.yaml index 70afe4c5a..7b41ed3e2 100644 --- a/tests/scenarios/shell/blocked_redirects/stderr_write.yaml +++ b/tests/scenarios/shell/blocked_redirects/stderr_write.yaml @@ -7,5 +7,5 @@ input: expect: stdout: "" stderr: |+ - > file redirection is not supported + 2> output fd redirection is not supported exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/variable_redirect_target.yaml b/tests/scenarios/shell/blocked_redirects/variable_redirect_target.yaml index d6f38c76c..e684ff9cf 100644 --- a/tests/scenarios/shell/blocked_redirects/variable_redirect_target.yaml +++ b/tests/scenarios/shell/blocked_redirects/variable_redirect_target.yaml @@ -1,11 +1,8 @@ -# skip: redirect type is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Variable expansion in redirect target is blocked (even if it resolves to /dev/null). +description: Variable expansion in redirect target can resolve to /dev/null. input: script: |+ TARGET=/dev/null; echo hello > $TARGET expect: stdout: "" stderr: |+ - > file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/blocked_redirects/write_append.yaml b/tests/scenarios/shell/blocked_redirects/write_append.yaml index 97adc6d99..ab421ead2 100644 --- a/tests/scenarios/shell/blocked_redirects/write_append.yaml +++ b/tests/scenarios/shell/blocked_redirects/write_append.yaml @@ -1,11 +1,16 @@ -# skip: redirect type is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Append redirection (>>) is not supported. +description: Append redirection (>>) appends inside allowed paths. +setup: + files: + - path: output.txt + content: "old\n" input: + allowed_paths: ["$DIR"] script: |+ echo hello >> output.txt + cat output.txt expect: - stdout: "" + stdout: |+ + old + hello stderr: |+ - >> file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/blocked_redirects/write_clobber.yaml b/tests/scenarios/shell/blocked_redirects/write_clobber.yaml index 60bc1f027..71095fd9e 100644 --- a/tests/scenarios/shell/blocked_redirects/write_clobber.yaml +++ b/tests/scenarios/shell/blocked_redirects/write_clobber.yaml @@ -7,5 +7,5 @@ input: expect: stdout: "" stderr: |+ - > file redirection is not supported + >| file redirection is not supported exit_code: 2 diff --git a/tests/scenarios/shell/blocked_redirects/write_truncate.yaml b/tests/scenarios/shell/blocked_redirects/write_truncate.yaml index 0498ccb44..d1b1e1a37 100644 --- a/tests/scenarios/shell/blocked_redirects/write_truncate.yaml +++ b/tests/scenarios/shell/blocked_redirects/write_truncate.yaml @@ -1,11 +1,11 @@ -# skip: redirect type is intentionally blocked in the restricted shell -skip_assert_against_bash: true -description: Output redirection (>) is not supported. +description: Output redirection (>) writes inside allowed paths. input: + allowed_paths: ["$DIR"] script: |+ echo hello > output.txt + cat output.txt expect: - stdout: "" + stdout: |+ + hello stderr: |+ - > file redirection is not supported - exit_code: 2 + exit_code: 0 diff --git a/tests/scenarios/shell/redirections/devnull/devnull_path_traversal_blocked.yaml b/tests/scenarios/shell/redirections/devnull/devnull_path_traversal_blocked.yaml index 23eb8ec0e..b356a3841 100644 --- a/tests/scenarios/shell/redirections/devnull/devnull_path_traversal_blocked.yaml +++ b/tests/scenarios/shell/redirections/devnull/devnull_path_traversal_blocked.yaml @@ -6,6 +6,6 @@ input: echo hello > /dev/null/../../../tmp/evil expect: stdout: "" - stderr: |+ - > file redirection is not supported - exit_code: 2 + stderr_contains: + - "permission denied" + exit_code: 1 diff --git a/tests/scenarios/shell/redirections/devnull/redirect_to_file_still_blocked.yaml b/tests/scenarios/shell/redirections/devnull/redirect_to_file_still_blocked.yaml index d6bc61ae5..10e01cb5a 100644 --- a/tests/scenarios/shell/redirections/devnull/redirect_to_file_still_blocked.yaml +++ b/tests/scenarios/shell/redirections/devnull/redirect_to_file_still_blocked.yaml @@ -1,11 +1,11 @@ # skip: redirect restrictions are an rshell-specific security feature skip_assert_against_bash: true -description: Output redirection to a real file (not /dev/null) is still blocked. +description: Output redirection outside AllowedPaths is blocked. input: script: |+ echo hello > /tmp/output.txt expect: stdout: "" - stderr: |+ - > file redirection is not supported - exit_code: 2 + stderr_contains: + - "permission denied" + exit_code: 1 diff --git a/tests/scenarios/shell/redirections/devnull/stderr_redirect_to_file_blocked.yaml b/tests/scenarios/shell/redirections/devnull/stderr_redirect_to_file_blocked.yaml index 4ff818158..af67ecc57 100644 --- a/tests/scenarios/shell/redirections/devnull/stderr_redirect_to_file_blocked.yaml +++ b/tests/scenarios/shell/redirections/devnull/stderr_redirect_to_file_blocked.yaml @@ -7,5 +7,5 @@ input: expect: stdout: "" stderr: |+ - > file redirection is not supported + 2> output fd redirection is not supported exit_code: 2 From fef9cff2288d38ebe46014e34c14c6dca7979a1c Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 15:58:25 -0400 Subject: [PATCH 02/36] guard tee host writes with allowed paths --- allowedpaths/sandbox.go | 48 +++++++++++++++++++++++++++++ allowedpaths/sandbox_test.go | 24 +++++++++++++++ builtins/builtins.go | 4 +++ builtins/tee/tee.go | 8 +++++ interp/remediation_commands_test.go | 45 +++++++++++++++++++++++++++ interp/runner_exec.go | 6 ++++ 6 files changed, 135 insertions(+) diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 6e106cb99..fdfe812c3 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -381,6 +381,54 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return f, nil } +// CheckWriteTarget validates that path is an AllowedPaths-governed write +// target without opening or creating it. Existing targets must resolve within +// the sandbox and be writable; missing targets are accepted only when their +// parent directory resolves within the sandbox and is writable. +func (s *Sandbox) CheckWriteTarget(path string, cwd string) error { + absPath := toAbs(path, cwd) + ar, relPath, ok := s.resolve(absPath) + if !ok { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + + _, err := ar.root.Stat(relPath) + if err != nil && isPathEscapeError(err) { + r, rel, ok := s.resolveFollowingSymlinks(absPath, false) + if !ok { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + _, err = r.Stat(rel) + } + if err == nil { + return s.Access(path, cwd, modeWrite) + } + if !errors.Is(err, fs.ErrNotExist) { + return PortablePathError(err) + } + + parent := filepath.Dir(absPath) + parentRoot, parentRel, ok := s.resolve(parent) + if !ok { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + info, err := parentRoot.root.Stat(parentRel) + if err != nil && isPathEscapeError(err) { + r, rel, ok := s.resolveFollowingSymlinks(parent, false) + if !ok { + return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + } + info, err = r.Stat(rel) + } + if err != nil { + return PortablePathError(err) + } + if !info.IsDir() { + return &os.PathError{Op: "access", Path: path, Err: errors.New("parent is not a directory")} + } + return s.Access(parent, cwd, modeWrite) +} + // ReadDir implements the restricted directory-read policy. func (s *Sandbox) ReadDir(path string, cwd string) ([]fs.DirEntry, error) { return s.readDirN(path, cwd, -1) diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index ef1404261..34d6ab574 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -104,6 +104,30 @@ func TestSandboxOpenForWriteRejectsOutsideAllowedPaths(t *testing.T) { assert.ErrorIs(t, err, os.ErrPermission) } +func TestSandboxCheckWriteTargetAllowsExistingAndMissingInsideAllowedPaths(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + require.NoError(t, sb.CheckWriteTarget("existing.txt", dir)) + require.NoError(t, sb.CheckWriteTarget("missing.txt", dir)) +} + +func TestSandboxCheckWriteTargetRejectsOutsideAllowedPaths(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.CheckWriteTarget(filepath.Join(outside, "evil.txt"), dir) + assert.ErrorIs(t, err, os.ErrPermission) +} + func TestReadDirLimited(t *testing.T) { dir := t.TempDir() diff --git a/builtins/builtins.go b/builtins/builtins.go index 7cadc6599..e3cd3e2b9 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -174,6 +174,10 @@ type CallContext struct { // within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute. AccessFile func(ctx context.Context, path string, mode uint32) error + // CheckFileWrite validates that path can be used as a write target within + // the shell's path restrictions without opening or creating it. + CheckFileWrite func(ctx context.Context, path string) error + // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go index 2503342a7..c51962f06 100644 --- a/builtins/tee/tee.go +++ b/builtins/tee/tee.go @@ -39,6 +39,14 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("tee: expected exactly one file\n") return builtins.Result{Code: 1} } + if callCtx.CheckFileWrite == nil { + callCtx.Errf("tee: file write validation is not available\n") + return builtins.Result{Code: 1} + } + if err := callCtx.CheckFileWrite(ctx, args[0]); err != nil { + callCtx.Errf("tee: %s: %s\n", args[0], callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } argv := []string{args[0]} if *appendFlag { argv = []string{"-a", args[0]} diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index f9fe1d6b7..5affbc449 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -10,6 +10,7 @@ import ( "io" "os" "path/filepath" + "runtime" "testing" "github.com/stretchr/testify/assert" @@ -201,6 +202,50 @@ func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { assert.Equal(t, "payload\n", stdin) } +func TestRemediationTeeRejectsOutsideAllowedPathsBeforeHostExecution(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "tee "+outside+" < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) + assert.NoFileExists(t, outside) +} + +func TestRemediationTeeRejectsSymlinkEscapeBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink escape behavior is platform-specific on Windows") + } + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "escape.txt"))) + called := false + + _, stderr, code := runScript(t, "tee escape.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) + assert.NoFileExists(t, outside) +} + func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("payload\n"), 0644)) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 03cc5787e..c7a0f97e3 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -620,6 +620,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, dir, mode) }, + CheckFileWrite: func(ctx context.Context, path string) error { + return r.sandbox.CheckWriteTarget(path, dir) + }, PortableErr: allowedpaths.PortableErrMsg, Now: r.startTime, FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { @@ -745,6 +748,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, mode) }, + CheckFileWrite: func(ctx context.Context, path string) error { + return r.sandbox.CheckWriteTarget(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) + }, PortableErr: allowedpaths.PortableErrMsg, Now: r.startTime, FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { From f1afa5698c2546357be5f441a61d0950f32bdc5d Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 16:06:47 -0400 Subject: [PATCH 03/36] preserve host command operand boundaries --- builtins/logrotate/logrotate.go | 2 +- builtins/systemctl/systemctl.go | 2 +- builtins/tee/tee.go | 4 +- builtins/truncate/truncate.go | 2 +- interp/remediation_commands_test.go | 84 +++++++++++++++++++++++++++-- 5 files changed, 84 insertions(+), 10 deletions(-) diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go index 80a0d5bd5..de4430cd0 100644 --- a/builtins/logrotate/logrotate.go +++ b/builtins/logrotate/logrotate.go @@ -42,6 +42,6 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "logrotate", args) + return callCtx.InvokeHostCommand(ctx, "logrotate", []string{"--", args[0]}) } } diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index aa7534dfa..94d918723 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -44,6 +44,6 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("systemctl: unsupported action: %s\n", args[0]) return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "systemctl", args) + return callCtx.InvokeHostCommand(ctx, "systemctl", []string{args[0], "--", args[1]}) } } diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go index c51962f06..928d181cd 100644 --- a/builtins/tee/tee.go +++ b/builtins/tee/tee.go @@ -47,9 +47,9 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("tee: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } - argv := []string{args[0]} + argv := []string{"--", args[0]} if *appendFlag { - argv = []string{"-a", args[0]} + argv = []string{"-a", "--", args[0]} } return callCtx.InvokeHostCommand(ctx, "tee", argv) } diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go index 5f4c9476c..d4e28cb62 100644 --- a/builtins/truncate/truncate.go +++ b/builtins/truncate/truncate.go @@ -66,7 +66,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("truncate: cannot grow file\n") return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), args[0]}) + return callCtx.InvokeHostCommand(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), "--", args[0]}) } } diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 5affbc449..c0a0067d6 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -36,7 +36,7 @@ func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"truncate", "-s", "3", "app.log"}, got) + assert.Equal(t, []string{"truncate", "-s", "3", "--", "app.log"}, got) } func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { @@ -56,7 +56,26 @@ func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"truncate", "-s", "0", "app.log"}, got) + assert.Equal(t, []string{"truncate", "-s", "0", "--", "app.log"}, got) +} + +func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "--help"), []byte("abcdef"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "truncate -s 0 -- --help", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"truncate", "-s", "0", "--", "--help"}, got) } func TestRemediationTruncateRejectsGrowth(t *testing.T) { @@ -126,7 +145,24 @@ func TestRemediationSystemctlDelegatesLifecycleAction(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"systemctl", "restart", "app.service"}, got) + assert.Equal(t, []string{"systemctl", "restart", "--", "app.service"}, got) +} + +func TestRemediationSystemctlPreservesLeadingDashUnit(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "systemctl restart -- -app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"systemctl", "restart", "--", "-app.service"}, got) } func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { @@ -198,10 +234,29 @@ func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"tee", "-a", "output.txt"}, got) + assert.Equal(t, []string{"tee", "-a", "--", "output.txt"}, got) assert.Equal(t, "payload\n", stdin) } +func TestRemediationTeePreservesLeadingDashOperand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "tee -- --help < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"tee", "--", "--help"}, got) +} + func TestRemediationTeeRejectsOutsideAllowedPathsBeforeHostExecution(t *testing.T) { dir := t.TempDir() outside := filepath.Join(t.TempDir(), "outside.txt") @@ -262,5 +317,24 @@ func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"logrotate", "app.log"}, got) + assert.Equal(t, []string{"logrotate", "--", "app.log"}, got) +} + +func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "--help"), []byte("payload\n"), 0644)) + var got []string + + stdout, stderr, code := runScript(t, "logrotate -- --help", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"logrotate", "--", "--help"}, got) } From 2ec31ec7409d95c54b81a8a89514f5d858fd640a Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 16:25:46 -0400 Subject: [PATCH 04/36] pass host file targets as sandboxed fds --- README.md | 2 +- SHELL_FEATURES.md | 8 +-- allowedpaths/sandbox.go | 58 ++++++++-------------- allowedpaths/sandbox_test.go | 23 +++++++-- analysis/symbols_builtins.go | 3 ++ builtins/builtins.go | 51 ++++++++++++++++--- builtins/logrotate/logrotate.go | 10 +++- builtins/tee/tee.go | 16 +++--- builtins/truncate/truncate.go | 17 ++++++- interp/api.go | 4 ++ interp/handler.go | 5 ++ interp/remediation_commands_test.go | 77 +++++++++++++++++++++++++---- interp/runner.go | 15 ++++-- interp/runner_exec.go | 40 +++++++++++---- 14 files changed, 241 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index c550002cf..f2f055d2d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Every access path is default-deny: **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions, since proc is a read-only virtual filesystem that does not expose host data under the normal file hierarchy. -**Guarded host commands** (`truncate`, `systemctl`, `kill`, `logrotate`, and `tee`) validate a narrow rshell contract before invoking the host command handler. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. +**Guarded host commands** (`truncate`, `systemctl`, `kill`, `logrotate`, and `tee`) validate a narrow rshell contract before invoking the host command handler. File-mutating commands hand the handler sandbox-opened descriptors instead of raw writable paths. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. ## Shell Features diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 25d7f40d7..5a6d7c19a 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -25,7 +25,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1) - ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported - ✅ `kill [-9] PID` — guarded remediation command; sends SIGTERM or SIGKILL through the host command handler after validating a single positive PID -- ✅ `logrotate PATH` — guarded remediation command; delegates one existing allowed path to the host command handler, usually a scenario-provided wrapper +- ✅ `logrotate PATH` — guarded remediation command; delegates one existing allowed file descriptor to the host command handler, usually a scenario-provided wrapper - ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected - ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory @@ -38,9 +38,9 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` - ✅ `systemctl start|stop|restart|reload UNIT` — guarded remediation command; delegates one lifecycle action and unit to the host command handler - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected -- ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one file through the host command handler; only overwrite and append forms are supported +- ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one sandbox-opened file descriptor through the host command handler; only overwrite and append forms are supported - ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) -- ✅ `truncate -s SIZE FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size, then delegates to the host command handler +- ✅ `truncate -s SIZE FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size by delegating a sandbox-opened file descriptor to the host command handler - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin - ✅ `true` — return exit code 0 - ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`) @@ -116,7 +116,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed - ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories -- ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler +- ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler; file-mutating commands pass sandbox-opened descriptors via handler context extra files - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments) - ❌ External commands — blocked by default; require an ExecHandler and the command name must pass AllowedCommands diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index fdfe812c3..b4274ed51 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -95,6 +95,9 @@ func New(paths []string) (sb *Sandbox, warnings []byte, err error) { // isPathEscapeError reports whether err is the unexported "path escapes // from parent" error from os.Root. Stable per Hyrum's Law. func isPathEscapeError(err error) bool { + if err != nil && err.Error() == "path escapes from parent" { + return true + } var pe *os.PathError if errors.As(err, &pe) { return pe.Err != nil && pe.Err.Error() == "path escapes from parent" @@ -346,9 +349,8 @@ func (s *Sandbox) Open(path string, cwd string, flag int, perm os.FileMode) (io. } // OpenForWrite implements the restricted file-open policy for shell output -// redirections. It is intentionally separate from Open so builtins keep their -// read-only file capability unless they are explicitly given another one. -func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMode) (io.ReadWriteCloser, error) { +// redirections and guarded write-file style builtins. +func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMode) (*os.File, error) { switch flag { case os.O_WRONLY | os.O_CREATE | os.O_TRUNC, os.O_WRONLY | os.O_CREATE | os.O_APPEND: @@ -372,7 +374,7 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo } r, rel, ok := s.resolveFollowingSymlinks(absPath, false) if !ok { - return nil, PortablePathError(err) + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } f, err = r.OpenFile(rel, flag, perm) if err != nil { @@ -381,52 +383,32 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return f, nil } -// CheckWriteTarget validates that path is an AllowedPaths-governed write -// target without opening or creating it. Existing targets must resolve within -// the sandbox and be writable; missing targets are accepted only when their -// parent directory resolves within the sandbox and is writable. -func (s *Sandbox) CheckWriteTarget(path string, cwd string) error { +// OpenExistingForWrite opens an existing file for write through the sandbox +// without creating, truncating, or appending. It is used by guarded host +// commands that need a stable fd for an already-existing mutation target. +func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error) { absPath := toAbs(path, cwd) ar, relPath, ok := s.resolve(absPath) if !ok { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - _, err := ar.root.Stat(relPath) - if err != nil && isPathEscapeError(err) { - r, rel, ok := s.resolveFollowingSymlinks(absPath, false) - if !ok { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } - _, err = r.Stat(rel) - } + f, err := ar.root.OpenFile(relPath, os.O_WRONLY, 0) if err == nil { - return s.Access(path, cwd, modeWrite) + return f, nil } - if !errors.Is(err, fs.ErrNotExist) { - return PortablePathError(err) + if !isPathEscapeError(err) { + return nil, PortablePathError(err) } - - parent := filepath.Dir(absPath) - parentRoot, parentRel, ok := s.resolve(parent) + r, rel, ok := s.resolveFollowingSymlinks(absPath, false) if !ok { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } - info, err := parentRoot.root.Stat(parentRel) - if err != nil && isPathEscapeError(err) { - r, rel, ok := s.resolveFollowingSymlinks(parent, false) - if !ok { - return &os.PathError{Op: "access", Path: path, Err: os.ErrPermission} - } - info, err = r.Stat(rel) + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } + f, err = r.OpenFile(rel, os.O_WRONLY, 0) if err != nil { - return PortablePathError(err) - } - if !info.IsDir() { - return &os.PathError{Op: "access", Path: path, Err: errors.New("parent is not a directory")} + return nil, PortablePathError(err) } - return s.Access(parent, cwd, modeWrite) + return f, nil } // ReadDir implements the restricted directory-read policy. diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 34d6ab574..18a797cd6 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -104,7 +104,7 @@ func TestSandboxOpenForWriteRejectsOutsideAllowedPaths(t *testing.T) { assert.ErrorIs(t, err, os.ErrPermission) } -func TestSandboxCheckWriteTargetAllowsExistingAndMissingInsideAllowedPaths(t *testing.T) { +func TestSandboxOpenExistingForWriteAllowsExistingInsideAllowedPaths(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old\n"), 0644)) @@ -112,11 +112,19 @@ func TestSandboxCheckWriteTargetAllowsExistingAndMissingInsideAllowedPaths(t *te require.NoError(t, err) defer sb.Close() - require.NoError(t, sb.CheckWriteTarget("existing.txt", dir)) - require.NoError(t, sb.CheckWriteTarget("missing.txt", dir)) + f, err := sb.OpenExistingForWrite("existing.txt", dir) + require.NoError(t, err) + info, err := f.Stat() + require.NoError(t, err) + assert.Equal(t, int64(4), info.Size()) + require.NoError(t, f.Close()) + + data, err := os.ReadFile(filepath.Join(dir, "existing.txt")) + require.NoError(t, err) + assert.Equal(t, "old\n", string(data)) } -func TestSandboxCheckWriteTargetRejectsOutsideAllowedPaths(t *testing.T) { +func TestSandboxOpenExistingForWriteRejectsMissingAndOutsideAllowedPaths(t *testing.T) { dir := t.TempDir() outside := t.TempDir() @@ -124,7 +132,12 @@ func TestSandboxCheckWriteTargetRejectsOutsideAllowedPaths(t *testing.T) { require.NoError(t, err) defer sb.Close() - err = sb.CheckWriteTarget(filepath.Join(outside, "evil.txt"), dir) + f, err := sb.OpenExistingForWrite("missing.txt", dir) + assert.Nil(t, f) + assert.Contains(t, err.Error(), "no such file or directory") + + f, err = sb.OpenExistingForWrite(filepath.Join(outside, "evil.txt"), dir) + assert.Nil(t, f) assert.ErrorIs(t, err, os.ErrPermission) } diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index c0437031f..b629abd9a 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -201,6 +201,7 @@ var builtinPerCommandSymbols = map[string][]string{ }, "logrotate": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "os.File", // 🟠 *os.File type used only to pass sandbox-opened descriptors through the host handler; no constructors invoked. }, "ls": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -373,6 +374,7 @@ var builtinPerCommandSymbols = map[string][]string{ }, "tee": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "os.File", // 🟠 *os.File type used only to pass sandbox-opened descriptors through the host handler; no constructors invoked. }, "testcmd": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -391,6 +393,7 @@ var builtinPerCommandSymbols = map[string][]string{ }, "truncate": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "os.File", // 🟠 *os.File type used only to pass sandbox-opened descriptors through the host handler; no constructors invoked. "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. }, diff --git a/builtins/builtins.go b/builtins/builtins.go index e3cd3e2b9..d63fa8a72 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -141,6 +141,16 @@ type CallContext struct { // OpenFile opens a file within the shell's path restrictions. OpenFile func(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) + // OpenFileForWrite opens a file for guarded write-style builtins within + // the shell's path restrictions. The open happens before any host + // delegation so the host command can receive a stable fd instead of a raw + // pathname. + OpenFileForWrite func(ctx context.Context, path string, append bool) (*os.File, error) + + // OpenExistingFileForWrite opens an existing file for guarded host + // mutations that must not create, truncate, or append during validation. + OpenExistingFileForWrite func(ctx context.Context, path string) (*os.File, error) + // ReadDir reads a directory within the shell's path restrictions. // Entries are returned sorted by name. Used by builtins like ls // that need deterministic sorted output. @@ -174,10 +184,6 @@ type CallContext struct { // within the shell's path restrictions. Mode: 0x04=read, 0x02=write, 0x01=execute. AccessFile func(ctx context.Context, path string, mode uint32) error - // CheckFileWrite validates that path can be used as a write target within - // the shell's path restrictions without opening or creating it. - CheckFileWrite func(ctx context.Context, path string) error - // PortableErr normalizes an OS error to a POSIX-style message. PortableErr func(err error) string @@ -269,6 +275,12 @@ type CallContext struct { // which only dispatches other rshell builtins. RunHostCommand func(ctx context.Context, name string, args []string) (uint8, error) + // RunHostCommandWithFiles is like RunHostCommand but passes additional + // sandbox-opened files through HandlerContext.ExtraFiles. Host handlers + // that execute via os/exec should wire these to Cmd.ExtraFiles so paths + // returned by HostExtraFilePath refer to the opened files. + RunHostCommandWithFiles func(ctx context.Context, name string, args []string, extraFiles []*os.File) (uint8, error) + // SetVar assigns a value to a shell variable in the calling shell's // scope. Returns an error if the value exceeds the per-variable size // limit or if the total variable-storage cap would be exceeded. @@ -301,14 +313,41 @@ func (c *CallContext) Errf(format string, a ...any) { fmt.Fprintf(c.Stderr, format, a...) } +const hostExtraFileBaseFD = 3 + +// HostExtraFilePath returns the argv path for an ExtraFiles entry. The first +// extra file is exposed to host commands as /dev/fd/3, matching os/exec's +// Cmd.ExtraFiles fd numbering on Unix-like platforms. +func HostExtraFilePath(index int) string { + return fmt.Sprintf("/dev/fd/%d", hostExtraFileBaseFD+index) +} + // InvokeHostCommand runs a guarded host command and converts failures into a // shell Result suitable for builtins. func (c *CallContext) InvokeHostCommand(ctx context.Context, name string, args []string) Result { - if c.RunHostCommand == nil { + return c.InvokeHostCommandWithFiles(ctx, name, args, nil) +} + +// InvokeHostCommandWithFiles runs a guarded host command with additional +// sandbox-opened files and converts failures into a shell Result. +func (c *CallContext) InvokeHostCommandWithFiles(ctx context.Context, name string, args []string, extraFiles []*os.File) Result { + for _, f := range extraFiles { + defer f.Close() + } + + var ( + code uint8 + err error + ) + switch { + case c.RunHostCommandWithFiles != nil: + code, err = c.RunHostCommandWithFiles(ctx, name, args, extraFiles) + case len(extraFiles) == 0 && c.RunHostCommand != nil: + code, err = c.RunHostCommand(ctx, name, args) + default: c.Errf("%s: host command execution not available\n", name) return Result{Code: 127} } - code, err := c.RunHostCommand(ctx, name, args) if err != nil { c.Errf("%s: %s\n", name, err) if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go index de4430cd0..092338ff0 100644 --- a/builtins/logrotate/logrotate.go +++ b/builtins/logrotate/logrotate.go @@ -8,6 +8,7 @@ package logrotate import ( "context" + "os" "github.com/DataDog/rshell/builtins" ) @@ -38,10 +39,15 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("logrotate: expected exactly one path\n") return builtins.Result{Code: 1} } - if _, err := callCtx.StatFile(ctx, args[0]); err != nil { + if callCtx.OpenExistingFileForWrite == nil { + callCtx.Errf("logrotate: file write is not available\n") + return builtins.Result{Code: 1} + } + f, err := callCtx.OpenExistingFileForWrite(ctx, args[0]) + if err != nil { callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "logrotate", []string{"--", args[0]}) + return callCtx.InvokeHostCommandWithFiles(ctx, "logrotate", []string{"--", builtins.HostExtraFilePath(0)}, []*os.File{f}) } } diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go index 928d181cd..12c386b55 100644 --- a/builtins/tee/tee.go +++ b/builtins/tee/tee.go @@ -8,6 +8,7 @@ package tee import ( "context" + "os" "github.com/DataDog/rshell/builtins" ) @@ -39,18 +40,21 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("tee: expected exactly one file\n") return builtins.Result{Code: 1} } - if callCtx.CheckFileWrite == nil { - callCtx.Errf("tee: file write validation is not available\n") + if callCtx.OpenFileForWrite == nil { + callCtx.Errf("tee: file write is not available\n") return builtins.Result{Code: 1} } - if err := callCtx.CheckFileWrite(ctx, args[0]); err != nil { + f, err := callCtx.OpenFileForWrite(ctx, args[0], *appendFlag) + if err != nil { callCtx.Errf("tee: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } - argv := []string{"--", args[0]} + files := []*os.File{f} + target := builtins.HostExtraFilePath(0) + argv := []string{"--", target} if *appendFlag { - argv = []string{"-a", "--", args[0]} + argv = []string{"-a", "--", target} } - return callCtx.InvokeHostCommand(ctx, "tee", argv) + return callCtx.InvokeHostCommandWithFiles(ctx, "tee", argv, files) } } diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go index d4e28cb62..2877220b0 100644 --- a/builtins/truncate/truncate.go +++ b/builtins/truncate/truncate.go @@ -8,6 +8,7 @@ package truncate import ( "context" + "os" "strconv" "github.com/DataDog/rshell/builtins" @@ -53,20 +54,32 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("truncate: expected exactly one file\n") return builtins.Result{Code: 1} } - info, err := callCtx.StatFile(ctx, args[0]) + if callCtx.OpenExistingFileForWrite == nil { + callCtx.Errf("truncate: file write is not available\n") + return builtins.Result{Code: 1} + } + f, err := callCtx.OpenExistingFileForWrite(ctx, args[0]) + if err != nil { + callCtx.Errf("truncate: %s: %s\n", args[0], callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + info, err := f.Stat() if err != nil { + f.Close() callCtx.Errf("truncate: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } if !info.Mode().IsRegular() { + f.Close() callCtx.Errf("truncate: %s: not a regular file\n", args[0]) return builtins.Result{Code: 1} } if size > info.Size() { + f.Close() callCtx.Errf("truncate: cannot grow file\n") return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), "--", args[0]}) + return callCtx.InvokeHostCommandWithFiles(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), "--", builtins.HostExtraFilePath(0)}, []*os.File{f}) } } diff --git a/interp/api.go b/interp/api.go index 4b09f8c51..e0e40a49c 100644 --- a/interp/api.go +++ b/interp/api.go @@ -685,6 +685,10 @@ func WarningsWriter(w io.Writer) RunnerOption { // guarded remediation builtins. The handler receives argv after the builtin has // validated the PAR-shaped command contract. The runner still enforces // AllowedCommands before invoking this handler. +// File-mutating guarded builtins pass sandbox-opened descriptors via +// [HandlerContext.ExtraFiles]; handlers that execute host binaries should wire +// those files to os/exec.Cmd.ExtraFiles so /dev/fd/3-style argv targets stay +// bound to the validated file. // // When unset, guarded host commands use the configured ExecHandler, which // defaults to rejecting external execution with exit code 127. diff --git a/interp/handler.go b/interp/handler.go index 64cd55cbc..7e08a9290 100644 --- a/interp/handler.go +++ b/interp/handler.go @@ -53,6 +53,11 @@ type HandlerContext struct { Stdout io.Writer // Stderr is the interpreter's current standard error writer. Stderr io.Writer + + // ExtraFiles contains additional files opened by guarded builtins for + // host-command execution. Handlers that exec host binaries can pass these + // through to os/exec.Cmd.ExtraFiles, where entry i is exposed as fd 3+i. + ExtraFiles []*os.File } // OpenHandlerFunc is a handler which opens files. diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index c0a0067d6..b25687b59 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/DataDog/rshell/builtins" "github.com/DataDog/rshell/interp" ) @@ -28,7 +29,12 @@ func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) - assert.Equal(t, dir, interp.HandlerCtx(ctx).Dir) + hc := interp.HandlerCtx(ctx) + assert.Equal(t, dir, hc.Dir) + require.Len(t, hc.ExtraFiles, 1) + info, err := hc.ExtraFiles[0].Stat() + require.NoError(t, err) + assert.Equal(t, int64(6), info.Size()) return nil }), ) @@ -36,7 +42,7 @@ func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"truncate", "-s", "3", "--", "app.log"}, got) + assert.Equal(t, []string{"truncate", "-s", "3", "--", builtins.HostExtraFilePath(0)}, got) } func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { @@ -48,7 +54,9 @@ func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.ExecHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) - assert.Equal(t, dir, interp.HandlerCtx(ctx).Dir) + hc := interp.HandlerCtx(ctx) + assert.Equal(t, dir, hc.Dir) + require.Len(t, hc.ExtraFiles, 1) return nil }), ) @@ -56,7 +64,7 @@ func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"truncate", "-s", "0", "--", "app.log"}, got) + assert.Equal(t, []string{"truncate", "-s", "0", "--", builtins.HostExtraFilePath(0)}, got) } func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { @@ -68,6 +76,7 @@ func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) return nil }), ) @@ -75,7 +84,7 @@ func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"truncate", "-s", "0", "--", "--help"}, got) + assert.Equal(t, []string{"truncate", "-s", "0", "--", builtins.HostExtraFilePath(0)}, got) } func TestRemediationTruncateRejectsGrowth(t *testing.T) { @@ -114,6 +123,29 @@ func TestRemediationTruncateRejectsRelativeSizeSyntax(t *testing.T) { assert.False(t, called) } +func TestRemediationTruncateRejectsSymlinkEscapeBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink escape behavior is platform-specific on Windows") + } + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.log") + require.NoError(t, os.WriteFile(outside, []byte("abcdef"), 0644)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "escape.log"))) + called := false + + _, stderr, code := runScript(t, "truncate -s 0 escape.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) +} + func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { dir := t.TempDir() var got []string @@ -224,6 +256,7 @@ func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) data, err := io.ReadAll(interp.HandlerCtx(ctx).Stdin) require.NoError(t, err) stdin = string(data) @@ -234,7 +267,7 @@ func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"tee", "-a", "--", "output.txt"}, got) + assert.Equal(t, []string{"tee", "-a", "--", builtins.HostExtraFilePath(0)}, got) assert.Equal(t, "payload\n", stdin) } @@ -247,6 +280,7 @@ func TestRemediationTeePreservesLeadingDashOperand(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) return nil }), ) @@ -254,7 +288,7 @@ func TestRemediationTeePreservesLeadingDashOperand(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"tee", "--", "--help"}, got) + assert.Equal(t, []string{"tee", "--", builtins.HostExtraFilePath(0)}, got) } func TestRemediationTeeRejectsOutsideAllowedPathsBeforeHostExecution(t *testing.T) { @@ -310,6 +344,7 @@ func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) return nil }), ) @@ -317,7 +352,7 @@ func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"logrotate", "--", "app.log"}, got) + assert.Equal(t, []string{"logrotate", "--", builtins.HostExtraFilePath(0)}, got) } func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { @@ -329,6 +364,7 @@ func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { interp.AllowedPaths([]string{dir}), interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) return nil }), ) @@ -336,5 +372,28 @@ func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"logrotate", "--", "--help"}, got) + assert.Equal(t, []string{"logrotate", "--", builtins.HostExtraFilePath(0)}, got) +} + +func TestRemediationLogrotateRejectsSymlinkEscapeBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink escape behavior is platform-specific on Windows") + } + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "outside.log") + require.NoError(t, os.WriteFile(outside, []byte("payload\n"), 0644)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "escape.log"))) + called := false + + _, stderr, code := runScript(t, "logrotate escape.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) } diff --git a/interp/runner.go b/interp/runner.go index ac3a517af..43a4b2440 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -23,12 +23,17 @@ func (r *Runner) handlerCtx(ctx context.Context, pos syntax.Pos) context.Context } func (r *Runner) handlerCtxWithDir(ctx context.Context, pos syntax.Pos, dir string) context.Context { + return r.handlerCtxWithDirFiles(ctx, pos, dir, nil) +} + +func (r *Runner) handlerCtxWithDirFiles(ctx context.Context, pos syntax.Pos, dir string, extraFiles []*os.File) context.Context { hc := HandlerContext{ - Env: &overlayEnviron{parent: r.writeEnv}, - Dir: dir, - Pos: pos, - Stdout: r.stdout, - Stderr: r.stderr, + Env: &overlayEnviron{parent: r.writeEnv}, + Dir: dir, + Pos: pos, + Stdout: r.stdout, + Stderr: r.stderr, + ExtraFiles: extraFiles, } if r.stdin != nil { // do not leave hc.Stdin as a typed nil hc.Stdin = r.stdin diff --git a/interp/runner_exec.go b/interp/runner_exec.go index c7a0f97e3..976b16095 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -596,6 +596,16 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, + OpenFileForWrite: func(ctx context.Context, path string, appendMode bool) (*os.File, error) { + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if appendMode { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + return r.sandbox.OpenForWrite(path, dir, flags, 0666) + }, + OpenExistingFileForWrite: func(ctx context.Context, path string) (*os.File, error) { + return r.sandbox.OpenExistingForWrite(path, dir) + }, ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { return r.sandbox.ReadDir(path, dir) }, @@ -620,9 +630,6 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, dir, mode) }, - CheckFileWrite: func(ctx context.Context, path string) error { - return r.sandbox.CheckWriteTarget(path, dir) - }, PortableErr: allowedpaths.PortableErrMsg, Now: r.startTime, FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { @@ -666,7 +673,10 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { }, RunCommandWithStdin: runCmdWithStdin, RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { - return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs) + return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs, nil) + }, + RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { + return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs, extraFiles) }, // Intentionally not exposing SetVar / GetVar in the // child CallContext used for find -exec / -execdir @@ -724,6 +734,16 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, + OpenFileForWrite: func(ctx context.Context, path string, appendMode bool) (*os.File, error) { + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if appendMode { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + return r.sandbox.OpenForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, flags, 0666) + }, + OpenExistingFileForWrite: func(ctx context.Context, path string) (*os.File, error) { + return r.sandbox.OpenExistingForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) + }, ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { return r.sandbox.ReadDir(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) }, @@ -748,9 +768,6 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { AccessFile: func(ctx context.Context, path string, mode uint32) error { return r.sandbox.Access(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, mode) }, - CheckFileWrite: func(ctx context.Context, path string) error { - return r.sandbox.CheckWriteTarget(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) - }, PortableErr: allowedpaths.PortableErrMsg, Now: r.startTime, FileIdentity: func(path string, info fs.FileInfo) (builtins.FileID, bool) { @@ -778,7 +795,10 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { RunCommand: runCmd, RunCommandWithStdin: runCmdWithStdin, RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { - return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs) + return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs, nil) + }, + RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { + return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs, extraFiles) }, SetVar: func(name, value string) error { if len(value) > MaxVarBytes { @@ -836,7 +856,7 @@ func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { r.exit.fromHandlerError(r.execHandler(r.handlerCtx(ctx, pos), args)) } -func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string) (uint8, error) { +func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string, extraFiles []*os.File) (uint8, error) { if caller != name || !isGuardedHostCommand(caller) { return 127, fmt.Errorf("rshell: %s: host command execution not available", name) } @@ -846,7 +866,7 @@ func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, argv := make([]string, 0, len(args)+1) argv = append(argv, name) argv = append(argv, args...) - err := r.hostCommandHandler(r.handlerCtxWithDir(ctx, pos, dir), argv) + err := r.hostCommandHandler(r.handlerCtxWithDirFiles(ctx, pos, dir, extraFiles), argv) if err == nil { return 0, nil } From ebb8741edccf86d229948eb77db1347a100d13ca Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 16:37:34 -0400 Subject: [PATCH 05/36] avoid tee target mutation without host handler --- builtins/builtins.go | 6 ++++++ builtins/tee/tee.go | 3 +++ interp/api.go | 11 +++++++++++ interp/remediation_commands_test.go | 24 ++++++++++++++++++++++++ interp/runner_exec.go | 12 ++++++++++++ 5 files changed, 56 insertions(+) diff --git a/builtins/builtins.go b/builtins/builtins.go index d63fa8a72..fbf8e6a1a 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -275,6 +275,12 @@ type CallContext struct { // which only dispatches other rshell builtins. RunHostCommand func(ctx context.Context, name string, args []string) (uint8, error) + // HostCommandAvailable reports whether a guarded host command has an + // execution path configured. Builtins that must open write targets before + // delegation use this to avoid create/truncate side effects when the host + // command would only be rejected by the default no-exec handler. + HostCommandAvailable func(name string) bool + // RunHostCommandWithFiles is like RunHostCommand but passes additional // sandbox-opened files through HandlerContext.ExtraFiles. Host handlers // that execute via os/exec should wire these to Cmd.ExtraFiles so paths diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go index 12c386b55..c5f2ceca9 100644 --- a/builtins/tee/tee.go +++ b/builtins/tee/tee.go @@ -44,6 +44,9 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("tee: file write is not available\n") return builtins.Result{Code: 1} } + if callCtx.HostCommandAvailable != nil && !callCtx.HostCommandAvailable("tee") { + return callCtx.InvokeHostCommand(ctx, "tee", nil) + } f, err := callCtx.OpenFileForWrite(ctx, args[0], *appendFlag) if err != nil { callCtx.Errf("tee: %s: %s\n", args[0], callCtx.PortableErr(err)) diff --git a/interp/api.go b/interp/api.go index e0e40a49c..6dfa5086a 100644 --- a/interp/api.go +++ b/interp/api.go @@ -43,10 +43,19 @@ type runnerConfig struct { // execHandler is responsible for executing programs. It must not be nil. execHandler ExecHandlerFunc + // execHandlerConfigured is true when callers explicitly provided an + // ExecHandler. Guarded host commands default to this handler when no + // HostCommandHandler is set. + execHandlerConfigured bool + // hostCommandHandler executes host commands on behalf of guarded builtins // such as truncate, systemctl, kill, logrotate, and tee. hostCommandHandler ExecHandlerFunc + // hostCommandHandlerConfigured is true when callers explicitly provided a + // HostCommandHandler rather than inheriting the default no-exec handler. + hostCommandHandlerConfigured bool + // openHandler is a function responsible for opening files. It must not be nil. openHandler OpenHandlerFunc @@ -698,6 +707,7 @@ func HostCommandHandler(fn ExecHandlerFunc) RunnerOption { return fmt.Errorf("HostCommandHandler: handler must not be nil") } r.hostCommandHandler = fn + r.hostCommandHandlerConfigured = true return nil } } @@ -711,6 +721,7 @@ func ExecHandler(fn ExecHandlerFunc) RunnerOption { return fmt.Errorf("ExecHandler: handler must not be nil") } r.execHandler = fn + r.execHandlerConfigured = true return nil } } diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index b25687b59..9b563efd4 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -335,6 +335,30 @@ func TestRemediationTeeRejectsSymlinkEscapeBeforeHostExecution(t *testing.T) { assert.NoFileExists(t, outside) } +func TestRemediationTeeWithoutHostHandlerDoesNotMutateTarget(t *testing.T) { + dir := t.TempDir() + existing := filepath.Join(dir, "existing.txt") + missing := filepath.Join(dir, "missing.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, os.WriteFile(existing, []byte("keep\n"), 0644)) + + _, stderr, code := runScript(t, "tee existing.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 127, code) + assert.Contains(t, stderr, "unknown command") + data, err := os.ReadFile(existing) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) + + _, stderr, code = runScript(t, "tee missing.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + ) + assert.Equal(t, 127, code) + assert.Contains(t, stderr, "unknown command") + assert.NoFileExists(t, missing) +} + func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("payload\n"), 0644)) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 976b16095..8f5ac3bad 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -675,6 +675,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs, nil) }, + HostCommandAvailable: r.hostCommandAvailable, RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs, extraFiles) }, @@ -797,6 +798,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { RunHostCommand: func(ctx context.Context, hostName string, hostArgs []string) (uint8, error) { return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs, nil) }, + HostCommandAvailable: r.hostCommandAvailable, RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs, extraFiles) }, @@ -877,6 +879,16 @@ func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, return 1, err } +func (r *Runner) hostCommandAvailable(name string) bool { + if !isGuardedHostCommand(name) { + return false + } + if !r.allowAllCommands && !r.allowedCommands[name] { + return false + } + return r.hostCommandHandlerConfigured || r.execHandlerConfigured +} + func isGuardedHostCommand(name string) bool { switch name { case "kill", "logrotate", "systemctl", "tee", "truncate": From 263fcfc645e66f4b493588d48edb7297465ee2b6 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 16:52:27 -0400 Subject: [PATCH 06/36] Reject symlink write targets --- allowedpaths/portable_unix.go | 8 +++ allowedpaths/portable_windows.go | 4 ++ allowedpaths/sandbox.go | 62 ++++++++++++++-------- allowedpaths/sandbox_unix_test.go | 53 +++++++++++++++++++ analysis/symbols_allowedpaths.go | 2 + interp/remediation_commands_test.go | 79 +++++++++++++++++++++++++++++ interp/tests/redir_devnull_test.go | 25 +++++++++ 7 files changed, 211 insertions(+), 22 deletions(-) diff --git a/allowedpaths/portable_unix.go b/allowedpaths/portable_unix.go index 96d395b88..830d65d97 100644 --- a/allowedpaths/portable_unix.go +++ b/allowedpaths/portable_unix.go @@ -104,6 +104,14 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f return info, nil } +func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) { + f, err := r.root.OpenFile(rel, flag|syscall.O_NOFOLLOW, perm) + if errors.Is(err, syscall.ELOOP) { + return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return f, err +} + // effectiveHasPerm checks whether the current process has the requested // permission by inspecting the file's owner/group/other permission class // that applies to the effective UID and GID of the running process. diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index 888582fd9..a99771967 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -85,3 +85,7 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f return info, nil } + +func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) { + return r.root.OpenFile(rel, flag, perm) +} diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index b4274ed51..417eaead4 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -365,18 +365,11 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - f, err := ar.root.OpenFile(relPath, flag, perm) - if err == nil { - return f, nil - } - if !isPathEscapeError(err) { - return nil, PortablePathError(err) - } - r, rel, ok := s.resolveFollowingSymlinks(absPath, false) - if !ok { - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + if err := ar.rejectWriteSymlinks(relPath, true); err != nil { + return nil, err } - f, err = r.OpenFile(rel, flag, perm) + + f, err := ar.openFileNoFollow(relPath, flag, perm) if err != nil { return nil, PortablePathError(err) } @@ -393,24 +386,49 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - f, err := ar.root.OpenFile(relPath, os.O_WRONLY, 0) - if err == nil { - return f, nil + if err := ar.rejectWriteSymlinks(relPath, false); err != nil { + return nil, err } - if !isPathEscapeError(err) { - return nil, PortablePathError(err) - } - r, rel, ok := s.resolveFollowingSymlinks(absPath, false) - if !ok { - return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} - } - f, err = r.OpenFile(rel, os.O_WRONLY, 0) + + f, err := ar.openFileNoFollow(relPath, os.O_WRONLY, 0) if err != nil { return nil, PortablePathError(err) } return f, nil } +func (r *root) rejectWriteSymlinks(rel string, allowMissingFinal bool) error { + rel = filepath.Clean(rel) + if rel == "." { + return nil + } + + components := strings.Split(rel, string(filepath.Separator)) + partial := "" + for i, component := range components { + if component == "" || component == "." { + continue + } + if partial == "" { + partial = component + } else { + partial = filepath.Join(partial, component) + } + + info, err := r.root.Lstat(partial) + if err != nil { + if allowMissingFinal && i == len(components)-1 && errors.Is(err, fs.ErrNotExist) { + return nil + } + return PortablePathError(err) + } + if info.Mode()&fs.ModeSymlink != 0 { + return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + } + return nil +} + // ReadDir implements the restricted directory-read policy. func (s *Sandbox) ReadDir(path string, cwd string) ([]fs.DirEntry, error) { return s.readDirN(path, cwd, -1) diff --git a/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go index e6a85924b..154931182 100644 --- a/allowedpaths/sandbox_unix_test.go +++ b/allowedpaths/sandbox_unix_test.go @@ -226,6 +226,59 @@ func TestAccessSymlinkEscapeBlocked(t *testing.T) { assert.Error(t, err) } +func TestOpenForWriteRejectsSymlinkTargetWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite("link.txt", dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + +func TestOpenForWriteRejectsSymlinkParentWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + require.NoError(t, os.Symlink("real", filepath.Join(dir, "linkdir"))) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite(filepath.Join("linkdir", "new.txt"), dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + assert.NoFileExists(t, filepath.Join(dir, "real", "new.txt")) +} + +func TestOpenExistingForWriteRejectsSymlinkTargetWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenExistingForWrite("link.txt", dir) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + // TestAccessCombinedModes verifies that Access correctly checks // combined permission modes (read+write, read+exec, etc.). func TestAccessCombinedModes(t *testing.T) { diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index c7b922df1..1e9fbf1ca 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -67,9 +67,11 @@ var allowedpathsAllowedSymbols = []string{ "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. + "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. "syscall.Errno", // 🟢 system call error number type; pure type. "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. "syscall.Handle", // 🟢 Windows file handle type; pure type alias. "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. + "syscall.O_NOFOLLOW", // 🟢 no-follow open flag; prevents terminal symlink writes when opening sandboxed write targets. "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. } diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 9b563efd4..88f20fb3e 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -146,6 +146,32 @@ func TestRemediationTruncateRejectsSymlinkEscapeBeforeHostExecution(t *testing.T assert.False(t, called) } +func TestRemediationTruncateRejectsSymlinkTargetBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-specific on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("abcdef"), 0644)) + require.NoError(t, os.Symlink("target.log", filepath.Join(dir, "link.log"))) + called := false + + _, stderr, code := runScript(t, "truncate -s 0 link.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "abcdef", string(data)) +} + func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { dir := t.TempDir() var got []string @@ -335,6 +361,33 @@ func TestRemediationTeeRejectsSymlinkEscapeBeforeHostExecution(t *testing.T) { assert.NoFileExists(t, outside) } +func TestRemediationTeeRejectsSymlinkTargetBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-specific on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + called := false + + _, stderr, code := runScript(t, "tee link.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRemediationTeeWithoutHostHandlerDoesNotMutateTarget(t *testing.T) { dir := t.TempDir() existing := filepath.Join(dir, "existing.txt") @@ -421,3 +474,29 @@ func TestRemediationLogrotateRejectsSymlinkEscapeBeforeHostExecution(t *testing. assert.Contains(t, stderr, "permission denied") assert.False(t, called) } + +func TestRemediationLogrotateRejectsSymlinkTargetBeforeHostExecution(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-specific on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("payload\n"), 0644)) + require.NoError(t, os.Symlink("target.log", filepath.Join(dir, "link.log"))) + called := false + + _, stderr, code := runScript(t, "logrotate link.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "permission denied") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "payload\n", string(data)) +} diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index a7b5f12e7..9a5378866 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -11,6 +11,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" @@ -213,6 +214,30 @@ func TestRedirAppendToFile(t *testing.T) { assert.Equal(t, "old\nnew\n", string(data)) } +func TestRedirStdoutToFileRejectsSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-specific on Windows") + } + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("target.txt", filepath.Join(dir, "link.txt"))) + + stdout, stderr, code := redirRun(t, "echo new > link.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "permission denied") + + stdout, stderr, code = redirRun(t, "echo new >> link.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "permission denied") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRedirExplicitFd1ToFile(t *testing.T) { dir := t.TempDir() stdout, stderr, code := redirRun(t, "echo hello 1>output.txt", dir) From 5ebd2aff8e8589bbdbb5e52c9c72cd6876957440 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 16:59:54 -0400 Subject: [PATCH 07/36] Reject special write targets --- allowedpaths/sandbox.go | 17 ++++-- allowedpaths/sandbox_unix_test.go | 60 +++++++++++++++++++ interp/remediation_commands_test.go | 92 +++++++++++++++++++++++++++++ interp/tests/redir_devnull_test.go | 30 ++++++++++ 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 417eaead4..900c50805 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -365,7 +365,7 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - if err := ar.rejectWriteSymlinks(relPath, true); err != nil { + if err := ar.validateWritePath(relPath, true); err != nil { return nil, err } @@ -386,7 +386,7 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - if err := ar.rejectWriteSymlinks(relPath, false); err != nil { + if err := ar.validateWritePath(relPath, false); err != nil { return nil, err } @@ -397,7 +397,7 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error return f, nil } -func (r *root) rejectWriteSymlinks(rel string, allowMissingFinal bool) error { +func (r *root) validateWritePath(rel string, allowMissingFinal bool) error { rel = filepath.Clean(rel) if rel == "." { return nil @@ -406,6 +406,7 @@ func (r *root) rejectWriteSymlinks(rel string, allowMissingFinal bool) error { components := strings.Split(rel, string(filepath.Separator)) partial := "" for i, component := range components { + isFinal := i == len(components)-1 if component == "" || component == "." { continue } @@ -417,7 +418,7 @@ func (r *root) rejectWriteSymlinks(rel string, allowMissingFinal bool) error { info, err := r.root.Lstat(partial) if err != nil { - if allowMissingFinal && i == len(components)-1 && errors.Is(err, fs.ErrNotExist) { + if allowMissingFinal && isFinal && errors.Is(err, fs.ErrNotExist) { return nil } return PortablePathError(err) @@ -425,6 +426,14 @@ func (r *root) rejectWriteSymlinks(rel string, allowMissingFinal bool) error { if info.Mode()&fs.ModeSymlink != 0 { return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} } + if isFinal { + if info.IsDir() { + return &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + if !info.Mode().IsRegular() { + return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + } } return nil } diff --git a/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go index 154931182..eb6520902 100644 --- a/allowedpaths/sandbox_unix_test.go +++ b/allowedpaths/sandbox_unix_test.go @@ -279,6 +279,66 @@ func TestOpenExistingForWriteRejectsSymlinkTargetWithinAllowedPath(t *testing.T) assert.Equal(t, "keep\n", string(data)) } +func TestOpenForWriteRejectsFIFOWithoutBlocking(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + type result struct { + f *os.File + err error + } + done := make(chan result, 1) + go func() { + f, err := sb.OpenForWrite("pipe", dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + done <- result{f: f, err: err} + }() + + select { + case res := <-done: + if res.f != nil { + require.NoError(t, res.f.Close()) + } + assert.Nil(t, res.f) + assert.ErrorIs(t, res.err, os.ErrPermission) + case <-time.After(2 * time.Second): + t.Fatal("OpenForWrite blocked on FIFO") + } +} + +func TestOpenExistingForWriteRejectsFIFOWithoutBlocking(t *testing.T) { + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + type result struct { + f *os.File + err error + } + done := make(chan result, 1) + go func() { + f, err := sb.OpenExistingForWrite("pipe", dir) + done <- result{f: f, err: err} + }() + + select { + case res := <-done: + if res.f != nil { + require.NoError(t, res.f.Close()) + } + assert.Nil(t, res.f) + assert.ErrorIs(t, res.err, os.ErrPermission) + case <-time.After(2 * time.Second): + t.Fatal("OpenExistingForWrite blocked on FIFO") + } +} + // TestAccessCombinedModes verifies that Access correctly checks // combined permission modes (read+write, read+exec, etc.). func TestAccessCombinedModes(t *testing.T) { diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 88f20fb3e..284293bc3 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -11,7 +11,9 @@ import ( "os" "path/filepath" "runtime" + "syscall" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -20,6 +22,29 @@ import ( "github.com/DataDog/rshell/interp" ) +type remediationRunResult struct { + stdout string + stderr string + code int +} + +func runRemediationScriptWithoutBlocking(t *testing.T, script, dir string, opts ...interp.RunnerOption) remediationRunResult { + t.Helper() + done := make(chan remediationRunResult, 1) + go func() { + stdout, stderr, code := runScript(t, script, dir, opts...) + done <- remediationRunResult{stdout: stdout, stderr: stderr, code: code} + }() + + select { + case res := <-done: + return res + case <-time.After(2 * time.Second): + t.Fatalf("%q blocked", script) + return remediationRunResult{} + } +} + func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) @@ -172,6 +197,28 @@ func TestRemediationTruncateRejectsSymlinkTargetBeforeHostExecution(t *testing.T assert.Equal(t, "abcdef", string(data)) } +func TestRemediationTruncateRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + called := false + + res := runRemediationScriptWithoutBlocking(t, "truncate -s 0 pipe", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, res.code) + assert.Equal(t, "", res.stdout) + assert.Contains(t, res.stderr, "permission denied") + assert.False(t, called) +} + func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { dir := t.TempDir() var got []string @@ -388,6 +435,29 @@ func TestRemediationTeeRejectsSymlinkTargetBeforeHostExecution(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestRemediationTeeRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + called := false + + res := runRemediationScriptWithoutBlocking(t, "tee pipe < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, res.code) + assert.Equal(t, "", res.stdout) + assert.Contains(t, res.stderr, "permission denied") + assert.False(t, called) +} + func TestRemediationTeeWithoutHostHandlerDoesNotMutateTarget(t *testing.T) { dir := t.TempDir() existing := filepath.Join(dir, "existing.txt") @@ -500,3 +570,25 @@ func TestRemediationLogrotateRejectsSymlinkTargetBeforeHostExecution(t *testing. require.NoError(t, err) assert.Equal(t, "payload\n", string(data)) } + +func TestRemediationLogrotateRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + called := false + + res := runRemediationScriptWithoutBlocking(t, "logrotate pipe", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, res.code) + assert.Equal(t, "", res.stdout) + assert.Contains(t, res.stderr, "permission denied") + assert.False(t, called) +} diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 9a5378866..4fecae8d1 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -13,7 +13,9 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -238,6 +240,34 @@ func TestRedirStdoutToFileRejectsSymlinkTarget(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestRedirStdoutToFileRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + + type result struct { + stdout string + stderr string + code int + } + done := make(chan result, 1) + go func() { + stdout, stderr, code := redirRun(t, "echo new > pipe", dir) + done <- result{stdout: stdout, stderr: stderr, code: code} + }() + + select { + case res := <-done: + assert.Equal(t, 1, res.code) + assert.Equal(t, "", res.stdout) + assert.Contains(t, res.stderr, "permission denied") + case <-time.After(2 * time.Second): + t.Fatal("stdout redirect blocked on FIFO") + } +} + func TestRedirExplicitFd1ToFile(t *testing.T) { dir := t.TempDir() stdout, stderr, code := redirRun(t, "echo hello 1>output.txt", dir) From 2488f5d6b6bac874afeb9078caf5ee2fd9c6e6e2 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 17:12:48 -0400 Subject: [PATCH 08/36] Reject trailing slash write targets --- allowedpaths/sandbox.go | 15 +++++ allowedpaths/sandbox_test.go | 36 ++++++++++ analysis/symbols_allowedpaths.go | 1 + interp/remediation_commands_test.go | 67 +++++++++++++++++++ interp/tests/redir_devnull_test.go | 20 ++++++ .../trailing_separator_target.yaml | 18 +++++ 6 files changed, 157 insertions(+) create mode 100644 tests/scenarios/shell/blocked_redirects/trailing_separator_target.yaml diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 900c50805..8279ea138 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -301,6 +301,13 @@ func toAbs(path, cwd string) string { return filepath.Join(cwd, path) } +func hasTrailingPathSeparator(path string) bool { + if path == "" { + return false + } + return os.IsPathSeparator(path[len(path)-1]) +} + // IsDevNull reports whether path refers to the platform's null device. func IsDevNull(path string) bool { if path == "/dev/null" { @@ -358,6 +365,10 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } + if hasTrailingPathSeparator(path) { + return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } + absPath := toAbs(path, cwd) ar, relPath, ok := s.resolve(absPath) @@ -380,6 +391,10 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo // without creating, truncating, or appending. It is used by guarded host // commands that need a stable fd for an already-existing mutation target. func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error) { + if hasTrailingPathSeparator(path) { + return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } + absPath := toAbs(path, cwd) ar, relPath, ok := s.resolve(absPath) if !ok { diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 18a797cd6..4142b2f59 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -104,6 +104,24 @@ func TestSandboxOpenForWriteRejectsOutsideAllowedPaths(t *testing.T) { assert.ErrorIs(t, err, os.ErrPermission) } +func TestSandboxOpenForWriteRejectsTrailingSeparator(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite("target.txt"+string(filepath.Separator), dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.Contains(t, err.Error(), "not a directory") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestSandboxOpenExistingForWriteAllowsExistingInsideAllowedPaths(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old\n"), 0644)) @@ -141,6 +159,24 @@ func TestSandboxOpenExistingForWriteRejectsMissingAndOutsideAllowedPaths(t *test assert.ErrorIs(t, err, os.ErrPermission) } +func TestSandboxOpenExistingForWriteRejectsTrailingSeparator(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "existing.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenExistingForWrite("existing.txt"+string(filepath.Separator), dir) + assert.Nil(t, f) + assert.Contains(t, err.Error(), "not a directory") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestReadDirLimited(t *testing.T) { dir := t.TempDir() diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 1e9fbf1ca..f2bd0e901 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -41,6 +41,7 @@ var allowedpathsAllowedSymbols = []string{ "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. + "os.IsPathSeparator", // 🟢 checks whether a byte is a platform path separator; pure function, no I/O. "os.O_APPEND", // 🟢 append file flag constant; only accepted by the dedicated redirection write-open path. "os.O_CREATE", // 🟢 create file flag constant; only accepted by the dedicated redirection write-open path. "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 284293bc3..19eb5b925 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -219,6 +219,28 @@ func TestRemediationTruncateRejectsFIFOWithoutBlocking(t *testing.T) { assert.False(t, called) } +func TestRemediationTruncateRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("abcdef"), 0644)) + called := false + + _, stderr, code := runScript(t, "truncate -s 0 target.log/", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "not a directory") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "abcdef", string(data)) +} + func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { dir := t.TempDir() var got []string @@ -458,6 +480,29 @@ func TestRemediationTeeRejectsFIFOWithoutBlocking(t *testing.T) { assert.False(t, called) } +func TestRemediationTeeRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "tee target.txt/ < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "not a directory") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRemediationTeeWithoutHostHandlerDoesNotMutateTarget(t *testing.T) { dir := t.TempDir() existing := filepath.Join(dir, "existing.txt") @@ -592,3 +637,25 @@ func TestRemediationLogrotateRejectsFIFOWithoutBlocking(t *testing.T) { assert.Contains(t, res.stderr, "permission denied") assert.False(t, called) } + +func TestRemediationLogrotateRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("payload\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "logrotate target.log/", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "not a directory") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "payload\n", string(data)) +} diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 4fecae8d1..5b7814279 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -216,6 +216,26 @@ func TestRedirAppendToFile(t *testing.T) { assert.Equal(t, "old\nnew\n", string(data)) } +func TestRedirStdoutToFileRejectsTrailingSeparator(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "output.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + stdout, stderr, code := redirRun(t, "echo new > output.txt/", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "not a directory") + + stdout, stderr, code = redirRun(t, "echo new >> output.txt/", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "not a directory") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRedirStdoutToFileRejectsSymlinkTarget(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior is platform-specific on Windows") diff --git a/tests/scenarios/shell/blocked_redirects/trailing_separator_target.yaml b/tests/scenarios/shell/blocked_redirects/trailing_separator_target.yaml new file mode 100644 index 000000000..6bf76324c --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/trailing_separator_target.yaml @@ -0,0 +1,18 @@ +description: Output redirection rejects trailing slash file targets without mutating them. +setup: + files: + - path: output.txt + content: "keep\n" +input: + allowed_paths: ["$DIR"] + script: |+ + echo new > output.txt/ + echo status=$? + cat output.txt +expect: + stdout: |+ + status=1 + keep + stderr_contains: + - "directory" + exit_code: 0 From 1555d3a0db5246c9cc2261447f6c40d0dff74019 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Fri, 15 May 2026 17:23:43 -0400 Subject: [PATCH 09/36] Harden fd redirects and host fd handoff --- builtins/builtins.go | 14 ++- builtins/logrotate/logrotate.go | 4 + builtins/tee/tee.go | 4 + builtins/truncate/truncate.go | 4 + interp/api.go | 31 ++++--- interp/remediation_commands_windows_test.go | 87 +++++++++++++++++++ interp/runner_exec.go | 2 + interp/runner_redir.go | 25 +++++- interp/tests/redir_devnull_test.go | 33 +++++++ .../stderr_dup_to_stdout_file.yaml | 19 ++++ 10 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 interp/remediation_commands_windows_test.go create mode 100644 tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml diff --git a/builtins/builtins.go b/builtins/builtins.go index fbf8e6a1a..e50ee9f2c 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -12,6 +12,7 @@ import ( "io" "io/fs" "os" + "runtime" "sort" "syscall" "time" @@ -323,11 +324,18 @@ const hostExtraFileBaseFD = 3 // HostExtraFilePath returns the argv path for an ExtraFiles entry. The first // extra file is exposed to host commands as /dev/fd/3, matching os/exec's -// Cmd.ExtraFiles fd numbering on Unix-like platforms. +// Cmd.ExtraFiles fd numbering on Unix-like platforms. Callers must only use +// this when HostExtraFilesSupported reports true. func HostExtraFilePath(index int) string { return fmt.Sprintf("/dev/fd/%d", hostExtraFileBaseFD+index) } +// HostExtraFilesSupported reports whether host commands can receive files via +// HandlerContext.ExtraFiles and address them with HostExtraFilePath. +func HostExtraFilesSupported() bool { + return runtime.GOOS != "windows" +} + // InvokeHostCommand runs a guarded host command and converts failures into a // shell Result suitable for builtins. func (c *CallContext) InvokeHostCommand(ctx context.Context, name string, args []string) Result { @@ -340,6 +348,10 @@ func (c *CallContext) InvokeHostCommandWithFiles(ctx context.Context, name strin for _, f := range extraFiles { defer f.Close() } + if len(extraFiles) > 0 && !HostExtraFilesSupported() { + c.Errf("%s: host file descriptor handoff is not supported on this platform\n", name) + return Result{Code: 1} + } var ( code uint8 diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go index 092338ff0..e1ff3fa25 100644 --- a/builtins/logrotate/logrotate.go +++ b/builtins/logrotate/logrotate.go @@ -43,6 +43,10 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("logrotate: file write is not available\n") return builtins.Result{Code: 1} } + if !builtins.HostExtraFilesSupported() { + callCtx.Errf("logrotate: host file descriptor handoff is not supported on this platform\n") + return builtins.Result{Code: 1} + } f, err := callCtx.OpenExistingFileForWrite(ctx, args[0]) if err != nil { callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) diff --git a/builtins/tee/tee.go b/builtins/tee/tee.go index c5f2ceca9..36c27f29c 100644 --- a/builtins/tee/tee.go +++ b/builtins/tee/tee.go @@ -47,6 +47,10 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { if callCtx.HostCommandAvailable != nil && !callCtx.HostCommandAvailable("tee") { return callCtx.InvokeHostCommand(ctx, "tee", nil) } + if !builtins.HostExtraFilesSupported() { + callCtx.Errf("tee: host file descriptor handoff is not supported on this platform\n") + return builtins.Result{Code: 1} + } f, err := callCtx.OpenFileForWrite(ctx, args[0], *appendFlag) if err != nil { callCtx.Errf("tee: %s: %s\n", args[0], callCtx.PortableErr(err)) diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go index 2877220b0..640db97e2 100644 --- a/builtins/truncate/truncate.go +++ b/builtins/truncate/truncate.go @@ -58,6 +58,10 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("truncate: file write is not available\n") return builtins.Result{Code: 1} } + if !builtins.HostExtraFilesSupported() { + callCtx.Errf("truncate: host file descriptor handoff is not supported on this platform\n") + return builtins.Result{Code: 1} + } f, err := callCtx.OpenExistingFileForWrite(ctx, args[0]) if err != nil { callCtx.Errf("truncate: %s: %s\n", args[0], callCtx.PortableErr(err)) diff --git a/interp/api.go b/interp/api.go index 6dfa5086a..4fa16b94a 100644 --- a/interp/api.go +++ b/interp/api.go @@ -140,6 +140,9 @@ type runnerState struct { stdout io.Writer stderr io.Writer + stdoutFileRedirect bool + stderrFileRedirect bool + // runStdin / runStdout are the baselines captured at the start of Run() // after any Run-level stdout wrapping. Telemetry uses them to decide // whether a command's stdin/stdout was reassigned by a pipe or redirect. @@ -853,19 +856,21 @@ func (r *Runner) subshell(background bool) *Runner { r2 := &Runner{ runnerConfig: r.runnerConfig, runnerState: runnerState{ - Dir: r.Dir, - Params: r.Params, - stdin: r.stdin, - stdout: r.stdout, - stderr: r.stderr, - runStdin: r.runStdin, - runStdout: r.runStdout, - inPipeline: r.inPipeline, - filename: r.filename, - exit: r.exit, - lastExit: r.lastExit, - startTime: r.startTime, - globReadDirCount: r.globReadDirCount, + Dir: r.Dir, + Params: r.Params, + stdin: r.stdin, + stdout: r.stdout, + stderr: r.stderr, + stdoutFileRedirect: r.stdoutFileRedirect, + stderrFileRedirect: r.stderrFileRedirect, + runStdin: r.runStdin, + runStdout: r.runStdout, + inPipeline: r.inPipeline, + filename: r.filename, + exit: r.exit, + lastExit: r.lastExit, + startTime: r.startTime, + globReadDirCount: r.globReadDirCount, }, } r2.writeEnv = newOverlayEnviron(r.writeEnv, background) diff --git a/interp/remediation_commands_windows_test.go b/interp/remediation_commands_windows_test.go new file mode 100644 index 000000000..aec9b3c49 --- /dev/null +++ b/interp/remediation_commands_windows_test.go @@ -0,0 +1,87 @@ +//go:build windows + +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +package interp_test + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/interp" +) + +func TestRemediationTeeRejectsWindowsFDHandoffBeforeOpeningTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "tee target.txt < input.txt", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "host file descriptor handoff is not supported") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + +func TestRemediationTruncateRejectsWindowsFDHandoffBeforeOpeningTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("payload\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "truncate -s 0 target.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "host file descriptor handoff is not supported") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "payload\n", string(data)) +} + +func TestRemediationLogrotateRejectsWindowsFDHandoffBeforeOpeningTarget(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("payload\n"), 0644)) + called := false + + _, stderr, code := runScript(t, "logrotate target.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "host file descriptor handoff is not supported") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "payload\n", string(data)) +} diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 8f5ac3bad..21382f871 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -34,6 +34,7 @@ func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) { func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr + oldOutFile, oldErrFile := r.stdoutFileRedirect, r.stderrFileRedirect for _, rd := range st.Redirs { cls, err := r.redir(ctx, rd) if err != nil { @@ -53,6 +54,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { r.exit.oneIf(wasOk) } r.stdin, r.stdout, r.stderr = oldIn, oldOut, oldErr + r.stdoutFileRedirect, r.stderrFileRedirect = oldOutFile, oldErrFile } func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 864af49fc..35e212fbe 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -223,6 +223,8 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err // Determine which fd this redirect targets (default: stdout for output ops). orig := &r.stdout + origFileRedirect := &r.stdoutFileRedirect + redirectsStderr := false if rd.N != nil { switch rd.N.Value { case "0": @@ -235,6 +237,8 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err // default (stdout) case "2": orig = &r.stderr + origFileRedirect = &r.stderrFileRedirect + redirectsStderr = true default: r.errf("%s: unsupported fd\n", rd.N.Value) return nil, fmt.Errorf("%s: unsupported fd", rd.N.Value) @@ -260,9 +264,11 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err return nil, err } *orig = f + *origFileRedirect = true return f, nil } *orig = io.Discard + *origFileRedirect = false return nil, nil case syntax.ClbOut: @@ -271,6 +277,7 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err return nil, fmt.Errorf(">| %s: file redirection is not supported", arg) } *orig = io.Discard + *origFileRedirect = false return nil, nil case syntax.RdrAll, syntax.AppAll: @@ -283,18 +290,32 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err } r.stdout = io.Discard r.stderr = io.Discard + r.stdoutFileRedirect = false + r.stderrFileRedirect = false return nil, nil case syntax.DplOut: + var ( + target io.Writer + targetFileRedirect bool + ) switch arg { case "1": - *orig = r.stdout + target = r.stdout + targetFileRedirect = r.stdoutFileRedirect case "2": - *orig = r.stderr + target = r.stderr + targetFileRedirect = r.stderrFileRedirect default: r.errf(">&%s: unsupported fd\n", arg) return nil, fmt.Errorf(">&%s: unsupported fd", arg) } + if redirectsStderr && targetFileRedirect { + r.errf("2>&%s: stderr file redirection via fd duplication is not supported\n", arg) + return nil, fmt.Errorf("2>&%s: stderr file redirection via fd duplication is not supported", arg) + } + *orig = target + *origFileRedirect = targetFileRedirect return nil, nil default: diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 5b7814279..42363fe96 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -152,6 +152,39 @@ func TestRedirDupStderrToStdout(t *testing.T) { assert.Equal(t, "", stderr) } +func TestRedirDupStderrToStdoutFileRejected(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, "cat nonexistent > output.txt 2>&1", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.NotContains(t, string(data), "nonexistent") + + require.NoError(t, os.WriteFile(filepath.Join(dir, "append.txt"), []byte("keep\n"), 0644)) + stdout, stderr, code = redirRun(t, "cat nonexistent >> append.txt 2>&1", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + data, err = os.ReadFile(filepath.Join(dir, "append.txt")) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + +func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, "cat nonexistent 2>&1 > output.txt", dir) + assert.Equal(t, 1, code) + assert.Contains(t, stdout, "nonexistent") + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "", string(data)) +} + func TestRedirDupStdoutToStderr(t *testing.T) { dir := t.TempDir() // >&2 redirects stdout to stderr diff --git a/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml new file mode 100644 index 000000000..70512ca25 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml @@ -0,0 +1,19 @@ +# skip: redirect restrictions are an rshell-specific security feature +skip_assert_against_bash: true +description: Stderr cannot be redirected to a file by duplicating it to file-backed stdout. +setup: + files: + - path: output.txt + content: "keep\n" +input: + allowed_paths: ["$DIR"] + script: |+ + cat missing > output.txt 2>&1 + echo status=$? + cat output.txt +expect: + stdout: |+ + status=1 + stderr_contains: + - "stderr file redirection via fd duplication is not supported" + exit_code: 0 From 74f84a32c5d0d8cf82dbca8afb10d872006f5548 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Sat, 16 May 2026 08:53:05 -0400 Subject: [PATCH 10/36] Fix CI for Windows FIFO tests --- interp/mkfifo_unix_test.go | 14 ++++++++++++++ interp/mkfifo_windows_test.go | 14 ++++++++++++++ interp/remediation_commands_test.go | 7 +++---- interp/remediation_commands_windows_test.go | 4 ++-- interp/tests/mkfifo_unix_test.go | 14 ++++++++++++++ interp/tests/mkfifo_windows_test.go | 14 ++++++++++++++ interp/tests/redir_devnull_test.go | 3 +-- 7 files changed, 62 insertions(+), 8 deletions(-) create mode 100644 interp/mkfifo_unix_test.go create mode 100644 interp/mkfifo_windows_test.go create mode 100644 interp/tests/mkfifo_unix_test.go create mode 100644 interp/tests/mkfifo_windows_test.go diff --git a/interp/mkfifo_unix_test.go b/interp/mkfifo_unix_test.go new file mode 100644 index 000000000..0ae3c941d --- /dev/null +++ b/interp/mkfifo_unix_test.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package interp_test + +import "syscall" + +func mkfifo(path string, mode uint32) error { + return syscall.Mkfifo(path, mode) +} diff --git a/interp/mkfifo_windows_test.go b/interp/mkfifo_windows_test.go new file mode 100644 index 000000000..7f8abdffc --- /dev/null +++ b/interp/mkfifo_windows_test.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build windows + +package interp_test + +import "errors" + +func mkfifo(path string, mode uint32) error { + return errors.New("mkfifo is not supported on Windows") +} diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 19eb5b925..89c664a7d 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -11,7 +11,6 @@ import ( "os" "path/filepath" "runtime" - "syscall" "testing" "time" @@ -202,7 +201,7 @@ func TestRemediationTruncateRejectsFIFOWithoutBlocking(t *testing.T) { t.Skip("FIFOs are Unix-specific") } dir := t.TempDir() - require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + require.NoError(t, mkfifo(filepath.Join(dir, "pipe"), 0644)) called := false res := runRemediationScriptWithoutBlocking(t, "truncate -s 0 pipe", dir, @@ -463,7 +462,7 @@ func TestRemediationTeeRejectsFIFOWithoutBlocking(t *testing.T) { } dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) - require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + require.NoError(t, mkfifo(filepath.Join(dir, "pipe"), 0644)) called := false res := runRemediationScriptWithoutBlocking(t, "tee pipe < input.txt", dir, @@ -621,7 +620,7 @@ func TestRemediationLogrotateRejectsFIFOWithoutBlocking(t *testing.T) { t.Skip("FIFOs are Unix-specific") } dir := t.TempDir() - require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + require.NoError(t, mkfifo(filepath.Join(dir, "pipe"), 0644)) called := false res := runRemediationScriptWithoutBlocking(t, "logrotate pipe", dir, diff --git a/interp/remediation_commands_windows_test.go b/interp/remediation_commands_windows_test.go index aec9b3c49..0a3f8d777 100644 --- a/interp/remediation_commands_windows_test.go +++ b/interp/remediation_commands_windows_test.go @@ -1,10 +1,10 @@ -//go:build windows - // Unless explicitly stated otherwise all files in this repository are licensed // under the Apache License Version 2.0. // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2026-present Datadog, Inc. +//go:build windows + package interp_test import ( diff --git a/interp/tests/mkfifo_unix_test.go b/interp/tests/mkfifo_unix_test.go new file mode 100644 index 000000000..b6007618f --- /dev/null +++ b/interp/tests/mkfifo_unix_test.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package tests_test + +import "syscall" + +func mkfifo(path string, mode uint32) error { + return syscall.Mkfifo(path, mode) +} diff --git a/interp/tests/mkfifo_windows_test.go b/interp/tests/mkfifo_windows_test.go new file mode 100644 index 000000000..dcb9020c9 --- /dev/null +++ b/interp/tests/mkfifo_windows_test.go @@ -0,0 +1,14 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build windows + +package tests_test + +import "errors" + +func mkfifo(path string, mode uint32) error { + return errors.New("mkfifo is not supported on Windows") +} diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 42363fe96..e00c2a2cc 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -13,7 +13,6 @@ import ( "path/filepath" "runtime" "strings" - "syscall" "testing" "time" @@ -298,7 +297,7 @@ func TestRedirStdoutToFileRejectsFIFOWithoutBlocking(t *testing.T) { t.Skip("FIFOs are Unix-specific") } dir := t.TempDir() - require.NoError(t, syscall.Mkfifo(filepath.Join(dir, "pipe"), 0644)) + require.NoError(t, mkfifo(filepath.Join(dir, "pipe"), 0644)) type result struct { stdout string From c07fe8a158c762af3cc2e81792500c87fc731c33 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Sat, 16 May 2026 09:00:43 -0400 Subject: [PATCH 11/36] Skip Unix fd handoff tests on Windows --- interp/remediation_commands_test.go | 19 +++++++++++++++++++ interp/tests/cmdsubst_pentest_test.go | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 89c664a7d..8ca9f06e9 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -44,7 +44,15 @@ func runRemediationScriptWithoutBlocking(t *testing.T, script, dir string, opts } } +func requireHostExtraFilesSupported(t *testing.T) { + t.Helper() + if !builtins.HostExtraFilesSupported() { + t.Skip("host file descriptor handoff is not supported on this platform") + } +} + func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) var got []string @@ -70,6 +78,7 @@ func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { } func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) var got []string @@ -92,6 +101,7 @@ func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { } func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "--help"), []byte("abcdef"), 0644)) var got []string @@ -112,6 +122,7 @@ func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { } func TestRemediationTruncateRejectsGrowth(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abc"), 0644)) called := false @@ -219,6 +230,7 @@ func TestRemediationTruncateRejectsFIFOWithoutBlocking(t *testing.T) { } func TestRemediationTruncateRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() target := filepath.Join(dir, "target.log") require.NoError(t, os.WriteFile(target, []byte("abcdef"), 0644)) @@ -341,6 +353,7 @@ func TestRemediationKillRejectsInvalidPid(t *testing.T) { } func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) var got []string @@ -366,6 +379,7 @@ func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { } func TestRemediationTeePreservesLeadingDashOperand(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) var got []string @@ -386,6 +400,7 @@ func TestRemediationTeePreservesLeadingDashOperand(t *testing.T) { } func TestRemediationTeeRejectsOutsideAllowedPathsBeforeHostExecution(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() outside := filepath.Join(t.TempDir(), "outside.txt") require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) @@ -480,6 +495,7 @@ func TestRemediationTeeRejectsFIFOWithoutBlocking(t *testing.T) { } func TestRemediationTeeRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() target := filepath.Join(dir, "target.txt") require.NoError(t, os.WriteFile(filepath.Join(dir, "input.txt"), []byte("payload\n"), 0644)) @@ -527,6 +543,7 @@ func TestRemediationTeeWithoutHostHandlerDoesNotMutateTarget(t *testing.T) { } func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("payload\n"), 0644)) var got []string @@ -547,6 +564,7 @@ func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { } func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "--help"), []byte("payload\n"), 0644)) var got []string @@ -638,6 +656,7 @@ func TestRemediationLogrotateRejectsFIFOWithoutBlocking(t *testing.T) { } func TestRemediationLogrotateRejectsTrailingSeparatorBeforeHostExecution(t *testing.T) { + requireHostExtraFilesSupported(t) dir := t.TempDir() target := filepath.Join(dir, "target.log") require.NoError(t, os.WriteFile(target, []byte("payload\n"), 0644)) diff --git a/interp/tests/cmdsubst_pentest_test.go b/interp/tests/cmdsubst_pentest_test.go index acc6f47f6..c942bbdc0 100644 --- a/interp/tests/cmdsubst_pentest_test.go +++ b/interp/tests/cmdsubst_pentest_test.go @@ -178,7 +178,9 @@ func TestCmdSubstPentestCatShortcutEmptyFile(t *testing.T) { func TestSubshellPentestRedirectOutBlocked(t *testing.T) { dir := t.TempDir() - _, stderr, code := subshellRun(t, `(echo data) > /tmp/evil.txt`, dir) + outside := filepath.ToSlash(filepath.Join(t.TempDir(), "evil.txt")) + quotedOutside := "'" + strings.ReplaceAll(outside, "'", "'\\''") + "'" + _, stderr, code := subshellRun(t, "(echo data) > "+quotedOutside, dir) assert.Equal(t, 1, code) assert.Contains(t, stderr, "permission denied") } From be5afcfa9859084fd4aac8ffd26b5319df586dcc Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 10:19:47 -0400 Subject: [PATCH 12/36] Add systemctl status query support --- SHELL_FEATURES.md | 2 +- builtins/systemctl/systemctl.go | 35 ++++++++++++--- interp/remediation_commands_test.go | 69 ++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 5a6d7c19a..b89a97f95 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -36,7 +36,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `read [-r] [-p PROMPT] [-d DELIM] [-n N] [-N N] [-t SECS] [NAME...]` — read one delimited chunk from stdin and assign each IFS-split field to a shell variable (defaulting to `REPLY` when no NAME is given); `-n`/`-N` are capped at 1 MiB; non-raw mode treats `\` as a line continuation (both characters are dropped) and `\` for any other `X` (including the active custom delimiter under `-d`) as a literal `X` with the backslash removed — e.g. `printf 'a\,b,c' | read -d , x` assigns `x="a,b"`; `-p` is suppressed unless stdin is a terminal (matches bash); `-a` (array), `-s` (silent), `-u` (read from FD), `-e` (readline), and `-i` (initial text) are not implemented - ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` -- ✅ `systemctl start|stop|restart|reload UNIT` — guarded remediation command; delegates one lifecycle action and unit to the host command handler +- ✅ `systemctl start|stop|restart|reload|status UNIT`; `systemctl show --property=ActiveState --value UNIT` — guarded remediation command; delegates one lifecycle or status action and unit to the host command handler - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected - ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one sandbox-opened file descriptor through the host command handler; only overwrite and append forms are supported - ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 94d918723..76a3143e0 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -15,16 +15,19 @@ import ( // Cmd is the systemctl builtin command descriptor. var Cmd = builtins.Command{ Name: "systemctl", - Description: "run a restricted service lifecycle action", + Description: "run a restricted service lifecycle or status action", MakeFlags: registerFlags, } func printUsage(callCtx *builtins.CallContext) { callCtx.Out("Usage: systemctl ACTION UNIT\n") - callCtx.Out("Run start, stop, restart, or reload for UNIT.\n") + callCtx.Out(" systemctl show --property=ActiveState --value UNIT\n") + callCtx.Out("Run start, stop, restart, reload, or status for UNIT.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + propertyFlag := fs.StringP("property", "p", "", "show a supported unit property") + valueFlag := fs.Bool("value", false, "print only the property value for show") helpFlag := fs.BoolP("help", "h", false, "print usage and exit") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { @@ -34,16 +37,38 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { fs.PrintDefaults() return builtins.Result{} } - if len(args) != 2 { + if len(args) == 0 { callCtx.Errf("systemctl: expected ACTION and UNIT\n") return builtins.Result{Code: 1} } switch args[0] { - case "restart", "start", "stop", "reload": + case "restart", "start", "stop", "reload", "status": + if len(args) != 2 { + callCtx.Errf("systemctl: expected ACTION and UNIT\n") + return builtins.Result{Code: 1} + } + if *propertyFlag != "" || *valueFlag { + callCtx.Errf("systemctl: --property and --value are only supported with show\n") + return builtins.Result{Code: 1} + } + return callCtx.InvokeHostCommand(ctx, "systemctl", []string{args[0], "--", args[1]}) + case "show": + if len(args) != 2 { + callCtx.Errf("systemctl: expected show UNIT\n") + return builtins.Result{Code: 1} + } + if *propertyFlag != "ActiveState" { + callCtx.Errf("systemctl: show requires --property=ActiveState\n") + return builtins.Result{Code: 1} + } + if !*valueFlag { + callCtx.Errf("systemctl: show requires --value\n") + return builtins.Result{Code: 1} + } + return callCtx.InvokeHostCommand(ctx, "systemctl", []string{"show", "--property=ActiveState", "--value", "--", args[1]}) default: callCtx.Errf("systemctl: unsupported action: %s\n", args[0]) return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "systemctl", []string{args[0], "--", args[1]}) } } diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 8ca9f06e9..713757fba 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -273,7 +273,27 @@ func TestRemediationSystemctlDelegatesLifecycleAction(t *testing.T) { dir := t.TempDir() var got []string - stdout, stderr, code := runScript(t, "systemctl restart app.service", dir, + for _, action := range []string{"restart", "start", "stop", "reload", "status"} { + got = nil + stdout, stderr, code := runScript(t, "systemctl "+action+" app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"systemctl", action, "--", "app.service"}, got) + } +} + +func TestRemediationSystemctlPreservesLeadingDashUnit(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "systemctl restart -- -app.service", dir, interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) return nil @@ -283,14 +303,14 @@ func TestRemediationSystemctlDelegatesLifecycleAction(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"systemctl", "restart", "--", "app.service"}, got) + assert.Equal(t, []string{"systemctl", "restart", "--", "-app.service"}, got) } -func TestRemediationSystemctlPreservesLeadingDashUnit(t *testing.T) { +func TestRemediationSystemctlShowActiveStateDelegates(t *testing.T) { dir := t.TempDir() var got []string - stdout, stderr, code := runScript(t, "systemctl restart -- -app.service", dir, + stdout, stderr, code := runScript(t, "systemctl show --property=ActiveState --value app.service", dir, interp.HostCommandHandler(func(ctx context.Context, args []string) error { got = append([]string(nil), args...) return nil @@ -300,7 +320,24 @@ func TestRemediationSystemctlPreservesLeadingDashUnit(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"systemctl", "restart", "--", "-app.service"}, got) + assert.Equal(t, []string{"systemctl", "show", "--property=ActiveState", "--value", "--", "app.service"}, got) +} + +func TestRemediationSystemctlShowActiveStatePreservesLeadingDashUnit(t *testing.T) { + dir := t.TempDir() + var got []string + + stdout, stderr, code := runScript(t, "systemctl show --property ActiveState --value -- -app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append([]string(nil), args...) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"systemctl", "show", "--property=ActiveState", "--value", "--", "-app.service"}, got) } func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { @@ -319,6 +356,28 @@ func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { assert.False(t, called) } +func TestRemediationSystemctlRejectsUnsupportedShowShape(t *testing.T) { + dir := t.TempDir() + called := false + + for _, script := range []string{ + "systemctl show --property=SubState --value app.service", + "systemctl show --property=ActiveState app.service", + "systemctl restart --property=ActiveState --value app.service", + } { + _, stderr, code := runScript(t, script, dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.NotEmpty(t, stderr) + assert.False(t, called) + } +} + func TestRemediationKillDelegatesForcePid(t *testing.T) { dir := t.TempDir() var got []string From f5520aeb67746af9f22ae9089cb016bd99354639 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 10:38:04 -0400 Subject: [PATCH 13/36] Add rshell remediation parity receipts --- README.md | 2 + SHELL_FEATURES.md | 10 +- analysis/symbols_builtins.go | 28 +++++- builtins/builtins.go | 57 +++++++++++ builtins/kill/kill.go | 104 +++++++++++++++++++- builtins/logrotate/logrotate.go | 125 +++++++++++++++++++++++- builtins/systemctl/systemctl.go | 62 +++++++++++- builtins/truncate/truncate.go | 44 ++++++++- builtins/write_file/write_file.go | 144 ++++++++++++++++++++++++++++ interp/register_builtins.go | 2 + interp/remediation_commands_test.go | 130 ++++++++++++++++++++++++- interp/runner.go | 12 ++- interp/runner_exec.go | 23 ++++- 13 files changed, 719 insertions(+), 24 deletions(-) create mode 100644 builtins/write_file/write_file.go diff --git a/README.md b/README.md index f2f055d2d..2abec2afd 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,8 @@ Every access path is default-deny: **Guarded host commands** (`truncate`, `systemctl`, `kill`, `logrotate`, and `tee`) validate a narrow rshell contract before invoking the host command handler. File-mutating commands hand the handler sandbox-opened descriptors instead of raw writable paths. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. +**Explicit remediation writes** can use `write_file [--mode overwrite|append] [--json] FILE` when a benchmark wants a command-shaped `write_file` action instead of redirection syntax. The command reads stdin and writes only through `AllowedPaths`. + ## Shell Features Inside rshell, run `help` to list supported feature categories, a concise unsupported-feature summary, enabled commands, and the configured `AllowedPaths` sandbox roots (or a notice when none are configured). Use `help ` for details about a specific rshell feature or command. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index b89a97f95..8d9ee0433 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -24,8 +24,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `ip [-o|-4|-6|--brief] addr|link [show] [dev IFNAME]` — show network interface addresses and link-layer info (read-only); write ops (`add`, `del`, `flush`, `set`), namespace ops (`netns`, `-n`), and batch mode (`-b`/`-B`/`--force`) are blocked - ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1) - ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported -- ✅ `kill [-9] PID` — guarded remediation command; sends SIGTERM or SIGKILL through the host command handler after validating a single positive PID -- ✅ `logrotate PATH` — guarded remediation command; delegates one existing allowed file descriptor to the host command handler, usually a scenario-provided wrapper +- ✅ `kill [-9] [--timeout DURATION] [--json] PID` — guarded remediation command; sends SIGTERM or SIGKILL through the host command handler after validating a single positive PID, then polls with `kill -0` until the PID exits or the timeout elapses +- ✅ `logrotate [--json] PATH` — guarded remediation command; delegates one existing allowed file descriptor to the host command handler, usually a scenario-provided wrapper; `--json` reports before/after size and best-effort rotated path discovery - ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected - ✅ `ls [-1aAdFhlpRrSt] [--offset N] [--limit N] [FILE]...` — list directory contents; `--offset`/`--limit` are non-standard pagination flags (single-directory only, silently ignored with `-R` or multiple arguments, capped at 1,000 entries per call); offset operates on filesystem order (not sorted order) for O(n) memory @@ -36,16 +36,17 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `read [-r] [-p PROMPT] [-d DELIM] [-n N] [-N N] [-t SECS] [NAME...]` — read one delimited chunk from stdin and assign each IFS-split field to a shell variable (defaulting to `REPLY` when no NAME is given); `-n`/`-N` are capped at 1 MiB; non-raw mode treats `\` as a line continuation (both characters are dropped) and `\` for any other `X` (including the active custom delimiter under `-d`) as a literal `X` with the backslash removed — e.g. `printf 'a\,b,c' | read -d , x` assigns `x="a,b"`; `-p` is suppressed unless stdin is a terminal (matches bash); `-a` (array), `-s` (silent), `-u` (read from FD), `-e` (readline), and `-i` (initial text) are not implemented - ✅ `sed [-n] [-e SCRIPT] [-E|-r] [SCRIPT] [FILE]...` — stream editor for filtering and transforming text; uses RE2 regex engine; `-i`/`-f` rejected; `e`/`w`/`W`/`r`/`R` commands blocked - ✅ `strings [-a] [-n MIN] [-t o|d|x] [-o] [-f] [-s SEP] [FILE]...` — print printable character sequences in files (default min length 4); offsets via `-t`/`-o`; filename prefix via `-f`; custom separator via `-s` -- ✅ `systemctl start|stop|restart|reload|status UNIT`; `systemctl show --property=ActiveState --value UNIT` — guarded remediation command; delegates one lifecycle or status action and unit to the host command handler +- ✅ `systemctl [--json] start|stop|restart|reload|status UNIT`; `systemctl show --property=ActiveState --value UNIT` — guarded remediation command; delegates one lifecycle or status action and unit to the host command handler; `--json` includes `active_state` - ✅ `tail [-n N|-c N] [-q|-v] [-z] [FILE]...` — output the last part of files (default: last 10 lines); supports `+N` offset mode; `-f`/`--follow` is rejected - ✅ `tee [-a] FILE` — guarded remediation command; copies stdin to stdout and one sandbox-opened file descriptor through the host command handler; only overwrite and append forms are supported - ✅ `test EXPRESSION` / `[ EXPRESSION ]` — evaluate conditional expression (file tests, string/integer comparison, logical operators) -- ✅ `truncate -s SIZE FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size by delegating a sandbox-opened file descriptor to the host command handler +- ✅ `truncate -s SIZE [--json] FILE` — guarded remediation command; shrinks one existing regular file to a non-negative byte size no larger than its current size by delegating a sandbox-opened file descriptor to the host command handler; `--json` reports before/after size - ✅ `tr [-cdsCt] SET1 [SET2]` — translate, squeeze, and/or delete characters from stdin - ✅ `true` — return exit code 0 - ✅ `uname [-asnrvm]` — print system information (Linux only; reads from `/proc/sys/kernel/`, respects `--proc-path`) - ✅ `uniq [OPTION]... [INPUT]` — report or omit repeated lines - ✅ `wc [-l] [-w] [-c] [-m] [-L] [FILE]...` — count lines, words, bytes, characters, or max line length +- ✅ `write_file [--mode overwrite|append] [--json] FILE` — explicit remediation write action; writes stdin to one AllowedPaths-constrained file, overwriting by default or appending with `--mode append` - ✅ `xargs [-0] [-a FILE] [-d DELIM] [-E EOF-STR] [-I REPLSTR] [-L N] [-n N] [-r] [-s N] [-t] [-x] [COMMAND [INITIAL-ARGS]...]` — build and execute commands from standard input; only invokes other registered builtins (subject to `CommandAllowed`), so the GTFOBins shell-escape `xargs … /bin/sh` is rejected; flags outside the supported set above (e.g. `-p` interactive, `-P` parallel, `-o`/`--open-tty`, `--show-limits`) are rejected as unknown - ❌ All other commands — return exit code 127 with `: not found` unless an ExecHandler is configured @@ -117,6 +118,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed - ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories - ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler; file-mutating commands pass sandbox-opened descriptors via handler context extra files +- ✅ Structured remediation receipts — guarded remediation commands accept `--json` where command-specific receipts are useful, while preserving normal shell stdout/stderr behavior by default - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments) - ❌ External commands — blocked by default; require an ExecHandler and the command name must pass AllowedCommands diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index b629abd9a..690edf66f 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -198,10 +198,20 @@ var builtinPerCommandSymbols = map[string][]string{ "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. + "time.Duration", // 🟢 duration type; pure integer alias, no I/O. + "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. + "time.NewTicker", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. + "time.NewTimer", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. + "time.Second", // 🟢 constant representing one second; no side effects. }, "logrotate": { - "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. - "os.File", // 🟠 *os.File type used only to pass sandbox-opened descriptors through the host handler; no constructors invoked. + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "os.File", // 🟠 *os.File type used only to pass sandbox-opened descriptors through the host handler; no constructors invoked. + "path/filepath.Base", // 🟢 returns the final path component; pure function, no I/O. + "path/filepath.Dir", // 🟢 returns the directory component of a path; pure function, no I/O. + "path/filepath.Join", // 🟢 lexically joins path components with the OS separator; pure function, no I/O. + "sort.Strings", // 🟢 sorts strings in memory; pure function, no I/O. + "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. }, "ls": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -354,7 +364,8 @@ var builtinPerCommandSymbols = map[string][]string{ "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. }, "systemctl": { - "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "strings.TrimSpace", // 🟢 removes leading/trailing whitespace; pure function. }, "tail": { "bufio.NewScanner", // 🟢 line-by-line input reading (e.g. head, cat); no write or exec capability. @@ -465,6 +476,11 @@ var builtinPerCommandSymbols = map[string][]string{ "unicode/utf8.UTFMax", // 🟢 maximum number of bytes in a UTF-8 encoding; constant, no I/O. "unicode/utf8.Valid", // 🟢 checks if a byte slice is valid UTF-8; pure function, no I/O. }, + "write_file": { + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "io.LimitReader", // 🟢 wraps stdin with an in-memory byte cap; no file/network side effects. + "io.ReadAll", // 🟢 reads from the already-provided stdin reader; no new filesystem/network access. + }, "xargs": { "bufio.NewReaderSize", // 🟢 buffered reader with caller-supplied size; pure wrapper, no I/O capability of its own. "bufio.Reader", // 🟢 buffered reader type; pure data, no side effects. @@ -574,8 +590,10 @@ var builtinAllowedSymbols = []string{ "golang.org/x/sys/unix.SysctlRaw", // 🟠 macOS: reads kernel socket tables (read-only, no exec, no filesystem). "golang.org/x/term.IsTerminal", // 🟠 platform-specific isatty check (TIOCGETA / GetConsoleMode); used to gate read -p prompt emission. Read-only inspection of the file descriptor; no I/O. "io.EOF", // 🟢 sentinel error value; pure constant. + "io.LimitReader", // 🟢 wraps a Reader with a byte cap; no I/O beyond reads requested by caller. "io.MultiReader", // 🟢 combines multiple Readers into one sequential Reader; no I/O side effects. "io.NopCloser", // 🟢 wraps a Reader with a no-op Close; no side effects. + "io.ReadAll", // 🟢 reads from an already-provided Reader; no new filesystem/network access. "io.ReadCloser", // 🟢 interface type; no side effects. "io.ReadSeeker", // 🟢 interface type combining Reader and Seeker; no side effects. "io.Reader", // 🟢 interface type; no side effects. @@ -626,6 +644,7 @@ var builtinAllowedSymbols = []string{ "os.IsNotExist", // 🟢 checks if error is "not exist"; pure function, no I/O. "os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself. "os.PathError", // 🟢 error type for filesystem path errors; pure type, no I/O. + "path/filepath.Base", // 🟢 returns the final path component; pure function, no I/O. "path/filepath.Clean", // 🟢 normalizes a path lexically (collapses ".", "..", duplicate separators); pure function, no I/O. "path/filepath.Dir", // 🟢 returns the directory component of a path; pure function, no I/O. "path/filepath.FromSlash", // 🟢 converts '/' to the OS separator without other normalisation; pure function, no I/O. @@ -641,6 +660,7 @@ var builtinAllowedSymbols = []string{ "slices.Reverse", // 🟢 reverses a slice in-place; pure function, no I/O. "slices.SortFunc", // 🟢 sorts a slice with a comparison function; pure function, no I/O. "slices.SortStableFunc", // 🟢 stable sort with a comparison function; pure function, no I/O. + "sort.Strings", // 🟢 sorts a string slice in memory; pure function, no I/O. "strings.Repeat", // 🟢 returns a string of n repetitions; pure function, no I/O. "strconv.Atoi", // 🟢 string-to-int conversion; pure function, no I/O. "strconv.ErrRange", // 🟢 sentinel error value for overflow; pure constant. @@ -681,6 +701,8 @@ var builtinAllowedSymbols = []string{ "time.Hour", // 🟢 constant representing one hour; no side effects. "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. "time.Minute", // 🟢 constant representing one minute; no side effects. + "time.NewTicker", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. + "time.NewTimer", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. "time.ParseDuration", // 🟢 parses Go duration strings (e.g. "1s"); pure function, no I/O. "time.Second", // 🟢 constant representing one second; no side effects. "time.Time", // 🟢 time value type; pure data, no side effects. diff --git a/builtins/builtins.go b/builtins/builtins.go index e50ee9f2c..ebf522aae 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -7,6 +7,7 @@ package builtins import ( "context" + "encoding/json" "errors" "fmt" "io" @@ -43,6 +44,14 @@ type Flag = pflag.Flag // parsed. args contains only the positional (non-flag) arguments. type HandlerFunc func(ctx context.Context, callCtx *CallContext, args []string) Result +// CapturedHostCommand is the output from a guarded host command run with +// stdout/stderr captured instead of streamed to the shell's current fds. +type CapturedHostCommand struct { + Code uint8 + Stdout string + Stderr string +} + // Command pairs a builtin name with its flag-declaring factory. MakeFlags // registers any flags on the provided FlagSet and returns the bound handler. // Commands that accept no flags may ignore fs via NoFlags. @@ -288,6 +297,12 @@ type CallContext struct { // returned by HostExtraFilePath refer to the opened files. RunHostCommandWithFiles func(ctx context.Context, name string, args []string, extraFiles []*os.File) (uint8, error) + // RunHostCommandWithFilesCapture is like RunHostCommandWithFiles, but + // captures stdout and stderr for builtins that need to return structured + // command receipts. It is optional so older direct CallContext tests can + // keep constructing only the capabilities they need. + RunHostCommandWithFilesCapture func(ctx context.Context, name string, args []string, extraFiles []*os.File) (CapturedHostCommand, error) + // SetVar assigns a value to a shell variable in the calling shell's // scope. Returns an error if the value exceeds the per-variable size // limit or if the total variable-storage cap would be exceeded. @@ -315,6 +330,18 @@ func (c *CallContext) Outf(format string, a ...any) { fmt.Fprintf(c.Stdout, format, a...) } +// OutJSON writes v as a single compact JSON line to stdout. +func (c *CallContext) OutJSON(v any) Result { + data, err := json.Marshal(v) + if err != nil { + c.Errf("json: %s\n", err) + return Result{Code: 1} + } + c.Out(string(data)) + c.Out("\n") + return Result{} +} + // Errf writes a formatted string to stderr. func (c *CallContext) Errf(format string, a ...any) { fmt.Fprintf(c.Stderr, format, a...) @@ -376,6 +403,36 @@ func (c *CallContext) InvokeHostCommandWithFiles(ctx context.Context, name strin return Result{Code: code} } +// CaptureHostCommandWithFiles runs a guarded host command with additional +// sandbox-opened files and captures stdout/stderr for structured receipts. +func (c *CallContext) CaptureHostCommandWithFiles(ctx context.Context, name string, args []string, extraFiles []*os.File) (CapturedHostCommand, Result, bool) { + for _, f := range extraFiles { + defer f.Close() + } + if len(extraFiles) > 0 && !HostExtraFilesSupported() { + c.Errf("%s: host file descriptor handoff is not supported on this platform\n", name) + return CapturedHostCommand{}, Result{Code: 1}, false + } + if c.RunHostCommandWithFilesCapture == nil { + c.Errf("%s: host command capture is not available\n", name) + return CapturedHostCommand{}, Result{Code: 127}, false + } + output, err := c.RunHostCommandWithFilesCapture(ctx, name, args, extraFiles) + if err != nil { + c.Errf("%s: %s\n", name, err) + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return CapturedHostCommand{}, Result{Code: 1, Exiting: true}, false + } + return CapturedHostCommand{}, Result{Code: 1}, false + } + return output, Result{}, true +} + +// CaptureHostCommand is CaptureHostCommandWithFiles without extra files. +func (c *CallContext) CaptureHostCommand(ctx context.Context, name string, args []string) (CapturedHostCommand, Result, bool) { + return c.CaptureHostCommandWithFiles(ctx, name, args, nil) +} + // IsBrokenPipe reports whether err is a broken-pipe (EPIPE) error, // which occurs when writing to a pipe whose read end has been closed. // In bash this triggers SIGPIPE which silently terminates the writer; diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index 969794783..4ca5118a8 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -9,6 +9,7 @@ package kill import ( "context" "strconv" + "time" "github.com/DataDog/rshell/builtins" ) @@ -21,12 +22,14 @@ var Cmd = builtins.Command{ } func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: kill [-9] PID\n") - callCtx.Out("Send SIGTERM, or SIGKILL with -9, to PID.\n") + callCtx.Out("Usage: kill [-9] [--timeout DURATION] [--json] PID\n") + callCtx.Out("Send SIGTERM, or SIGKILL with -9, to PID, then poll for exit.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { forceFlag := fs.BoolP("force", "9", false, "send SIGKILL instead of SIGTERM") + timeoutFlag := fs.Duration("timeout", 5*time.Second, "maximum time to wait for PID to exit") + jsonFlag := fs.Bool("json", false, "print a structured remediation receipt") helpFlag := fs.BoolP("help", "h", false, "print usage and exit") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { @@ -45,10 +48,105 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("kill: invalid pid: %s\n", args[0]) return builtins.Result{Code: 1} } + if *timeoutFlag < 0 { + callCtx.Errf("kill: invalid timeout: %s\n", timeoutFlag.String()) + return builtins.Result{Code: 1} + } argv := []string{strconv.FormatInt(pid, 10)} if *forceFlag { argv = []string{"-9", strconv.FormatInt(pid, 10)} } - return callCtx.InvokeHostCommand(ctx, "kill", argv) + if *jsonFlag { + return runJSON(ctx, callCtx, pid, *forceFlag, *timeoutFlag, argv) + } + res := callCtx.InvokeHostCommand(ctx, "kill", argv) + if res.Code != 0 || res.Exiting { + return res + } + timedOut, waitRes, ok := waitForExit(ctx, callCtx, strconv.FormatInt(pid, 10), *timeoutFlag) + if !ok { + return waitRes + } + if timedOut { + callCtx.Errf("kill: timed out waiting for pid %d to exit\n", pid) + } + return res + } +} + +type receipt struct { + PID int64 `json:"pid"` + Force bool `json:"force"` + Signal string `json:"signal"` + TimedOut bool `json:"timed_out"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, force bool, timeout time.Duration, argv []string) builtins.Result { + host, res, ok := callCtx.CaptureHostCommand(ctx, "kill", argv) + if !ok { + return res + } + timedOut := false + if host.Code == 0 { + var waitRes builtins.Result + timedOut, waitRes, ok = waitForExit(ctx, callCtx, strconv.FormatInt(pid, 10), timeout) + if !ok { + return waitRes + } + } + signal := "SIGTERM" + if force { + signal = "SIGKILL" + } + outRes := callCtx.OutJSON(receipt{ + PID: pid, + Force: force, + Signal: signal, + TimedOut: timedOut, + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + }) + if outRes.Code != 0 || outRes.Exiting { + return outRes + } + return builtins.Result{Code: host.Code} +} + +func waitForExit(ctx context.Context, callCtx *builtins.CallContext, pid string, timeout time.Duration) (bool, builtins.Result, bool) { + if timeout == 0 { + return false, builtins.Result{}, true + } + timer := time.NewTimer(timeout) + defer timer.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + alive, res, ok := processAlive(ctx, callCtx, pid) + if !ok { + return false, res, false + } + if !alive { + return false, builtins.Result{}, true + } + select { + case <-ctx.Done(): + return false, builtins.Result{Code: 1, Exiting: true}, false + case <-timer.C: + return true, builtins.Result{}, true + case <-ticker.C: + } + } +} + +func processAlive(ctx context.Context, callCtx *builtins.CallContext, pid string) (bool, builtins.Result, bool) { + host, res, ok := callCtx.CaptureHostCommand(ctx, "kill", []string{"-0", pid}) + if !ok { + return false, res, false } + return host.Code == 0, builtins.Result{}, true } diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go index e1ff3fa25..f5d204f06 100644 --- a/builtins/logrotate/logrotate.go +++ b/builtins/logrotate/logrotate.go @@ -9,6 +9,9 @@ package logrotate import ( "context" "os" + "path/filepath" + "sort" + "strings" "github.com/DataDog/rshell/builtins" ) @@ -21,11 +24,12 @@ var Cmd = builtins.Command{ } func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: logrotate PATH\n") + callCtx.Out("Usage: logrotate [--json] PATH\n") callCtx.Out("Rotate PATH using the scenario-provided logrotate wrapper.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + jsonFlag := fs.Bool("json", false, "print a structured remediation receipt") helpFlag := fs.BoolP("help", "h", false, "print usage and exit") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { @@ -52,6 +56,125 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) return builtins.Result{Code: 1} } + if *jsonFlag { + info, err := f.Stat() + if err != nil { + f.Close() + callCtx.Errf("logrotate: %s: %s\n", args[0], callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + return runJSON(ctx, callCtx, args[0], info.Size(), f) + } return callCtx.InvokeHostCommandWithFiles(ctx, "logrotate", []string{"--", builtins.HostExtraFilePath(0)}, []*os.File{f}) } } + +type receipt struct { + Path string `json:"path"` + RotatedPath string `json:"rotated_path"` + BytesBefore int64 `json:"bytes_before"` + BytesAfter int64 `json:"bytes_after"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +type rotateCandidate struct { + size int64 + modNano int64 + id builtins.FileID + hasID bool +} + +func runJSON(ctx context.Context, callCtx *builtins.CallContext, path string, bytesBefore int64, f *os.File) builtins.Result { + before := collectRotateCandidates(ctx, callCtx, path) + host, res, ok := callCtx.CaptureHostCommandWithFiles(ctx, "logrotate", []string{"--", builtins.HostExtraFilePath(0)}, []*os.File{f}) + if !ok { + return res + } + afterInfo, err := callCtx.StatFile(ctx, path) + if err != nil { + callCtx.Errf("logrotate: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + after := collectRotateCandidates(ctx, callCtx, path) + outRes := callCtx.OutJSON(receipt{ + Path: path, + RotatedPath: discoverRotatedPath(before, after), + BytesBefore: bytesBefore, + BytesAfter: afterInfo.Size(), + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + }) + if outRes.Code != 0 || outRes.Exiting { + return outRes + } + return builtins.Result{Code: host.Code} +} + +func collectRotateCandidates(ctx context.Context, callCtx *builtins.CallContext, path string) map[string]rotateCandidate { + dir := filepath.Dir(path) + base := filepath.Base(path) + entries, err := callCtx.ReadDir(ctx, dir) + if err != nil { + return nil + } + out := make(map[string]rotateCandidate) + for _, entry := range entries { + name := entry.Name() + if !strings.HasPrefix(name, base+".") { + continue + } + candidatePath := joinPath(dir, name) + info, err := callCtx.StatFile(ctx, candidatePath) + if err != nil || !info.Mode().IsRegular() { + continue + } + candidate := rotateCandidate{ + size: info.Size(), + modNano: info.ModTime().UnixNano(), + } + if callCtx.FileIdentity != nil { + candidate.id, candidate.hasID = callCtx.FileIdentity(candidatePath, info) + } + out[candidatePath] = candidate + } + return out +} + +func discoverRotatedPath(before, after map[string]rotateCandidate) string { + names := make([]string, 0, len(after)) + for name := range after { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + if _, ok := before[name]; !ok { + return name + } + } + for _, name := range names { + if !sameCandidate(before[name], after[name]) { + return name + } + } + return "" +} + +func sameCandidate(a, b rotateCandidate) bool { + if a.size != b.size || a.modNano != b.modNano { + return false + } + if a.hasID != b.hasID { + return false + } + return !a.hasID || a.id == b.id +} + +func joinPath(dir, name string) string { + if dir == "." { + return name + } + return filepath.Join(dir, name) +} diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 76a3143e0..0bda5bd2f 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -8,6 +8,7 @@ package systemctl import ( "context" + "strings" "github.com/DataDog/rshell/builtins" ) @@ -20,7 +21,7 @@ var Cmd = builtins.Command{ } func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: systemctl ACTION UNIT\n") + callCtx.Out("Usage: systemctl [--json] ACTION UNIT\n") callCtx.Out(" systemctl show --property=ActiveState --value UNIT\n") callCtx.Out("Run start, stop, restart, reload, or status for UNIT.\n") } @@ -28,6 +29,7 @@ func printUsage(callCtx *builtins.CallContext) { func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { propertyFlag := fs.StringP("property", "p", "", "show a supported unit property") valueFlag := fs.Bool("value", false, "print only the property value for show") + jsonFlag := fs.Bool("json", false, "print a structured remediation receipt") helpFlag := fs.BoolP("help", "h", false, "print usage and exit") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { @@ -51,7 +53,11 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("systemctl: --property and --value are only supported with show\n") return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "systemctl", []string{args[0], "--", args[1]}) + hostArgs := []string{args[0], "--", args[1]} + if *jsonFlag { + return runJSON(ctx, callCtx, args[0], args[1], hostArgs) + } + return callCtx.InvokeHostCommand(ctx, "systemctl", hostArgs) case "show": if len(args) != 2 { callCtx.Errf("systemctl: expected show UNIT\n") @@ -65,10 +71,60 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("systemctl: show requires --value\n") return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommand(ctx, "systemctl", []string{"show", "--property=ActiveState", "--value", "--", args[1]}) + hostArgs := activeStateArgs(args[1]) + if *jsonFlag { + return runJSON(ctx, callCtx, "show", args[1], hostArgs) + } + return callCtx.InvokeHostCommand(ctx, "systemctl", hostArgs) default: callCtx.Errf("systemctl: unsupported action: %s\n", args[0]) return builtins.Result{Code: 1} } } } + +type receipt struct { + Unit string `json:"unit"` + Action string `json:"action"` + ActiveState string `json:"active_state"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func runJSON(ctx context.Context, callCtx *builtins.CallContext, action, unit string, hostArgs []string) builtins.Result { + actionHost, res, ok := callCtx.CaptureHostCommand(ctx, "systemctl", hostArgs) + if !ok { + return res + } + stateHost := actionHost + if action != "show" { + stateHost, res, ok = callCtx.CaptureHostCommand(ctx, "systemctl", activeStateArgs(unit)) + if !ok { + return res + } + } + activeState := strings.TrimSpace(stateHost.Stdout) + exitCode := actionHost.Code + stderr := actionHost.Stderr + if actionHost.Code == 0 && stateHost.Code != 0 { + exitCode = stateHost.Code + stderr += stateHost.Stderr + } + outRes := callCtx.OutJSON(receipt{ + Unit: unit, + Action: action, + ActiveState: activeState, + ExitCode: exitCode, + Stdout: actionHost.Stdout, + Stderr: stderr, + }) + if outRes.Code != 0 || outRes.Exiting { + return outRes + } + return builtins.Result{Code: exitCode} +} + +func activeStateArgs(unit string) []string { + return []string{"show", "--property=ActiveState", "--value", "--", unit} +} diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go index 640db97e2..8c6b548a6 100644 --- a/builtins/truncate/truncate.go +++ b/builtins/truncate/truncate.go @@ -22,12 +22,13 @@ var Cmd = builtins.Command{ } func printUsage(callCtx *builtins.CallContext) { - callCtx.Out("Usage: truncate -s SIZE FILE\n") + callCtx.Out("Usage: truncate -s SIZE [--json] FILE\n") callCtx.Out("Shrink FILE to SIZE bytes.\n") } func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { sizeFlag := fs.StringP("size", "s", "", "target byte size") + jsonFlag := fs.Bool("json", false, "print a structured remediation receipt") helpFlag := fs.BoolP("help", "h", false, "print usage and exit") return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { @@ -83,8 +84,47 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("truncate: cannot grow file\n") return builtins.Result{Code: 1} } - return callCtx.InvokeHostCommandWithFiles(ctx, "truncate", []string{"-s", strconv.FormatInt(size, 10), "--", builtins.HostExtraFilePath(0)}, []*os.File{f}) + hostArgs := []string{"-s", strconv.FormatInt(size, 10), "--", builtins.HostExtraFilePath(0)} + if *jsonFlag { + return runJSON(ctx, callCtx, args[0], size, info.Size(), hostArgs, f) + } + return callCtx.InvokeHostCommandWithFiles(ctx, "truncate", hostArgs, []*os.File{f}) + } +} + +type receipt struct { + Path string `json:"path"` + BytesBefore int64 `json:"bytes_before"` + BytesAfter int64 `json:"bytes_after"` + SizeBytes int64 `json:"size_bytes"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func runJSON(ctx context.Context, callCtx *builtins.CallContext, path string, size int64, bytesBefore int64, hostArgs []string, f *os.File) builtins.Result { + host, res, ok := callCtx.CaptureHostCommandWithFiles(ctx, "truncate", hostArgs, []*os.File{f}) + if !ok { + return res + } + afterInfo, err := callCtx.StatFile(ctx, path) + if err != nil { + callCtx.Errf("truncate: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + outRes := callCtx.OutJSON(receipt{ + Path: path, + BytesBefore: bytesBefore, + BytesAfter: afterInfo.Size(), + SizeBytes: size, + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + }) + if outRes.Code != 0 || outRes.Exiting { + return outRes } + return builtins.Result{Code: host.Code} } func isDecimalSize(s string) bool { diff --git a/builtins/write_file/write_file.go b/builtins/write_file/write_file.go new file mode 100644 index 000000000..3f0727e60 --- /dev/null +++ b/builtins/write_file/write_file.go @@ -0,0 +1,144 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +// Package write_file implements an explicit guarded file-write command. +package write_file + +import ( + "context" + "io" + + "github.com/DataDog/rshell/builtins" +) + +// MaxWriteFileBytes caps stdin buffered by write_file. +const MaxWriteFileBytes = 10 << 20 // 10 MiB + +// Cmd is the write_file builtin command descriptor. +var Cmd = builtins.Command{ + Name: "write_file", + Description: "write stdin to one allowed file", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + callCtx.Out("Usage: write_file [--mode overwrite|append] [--json] FILE\n") + callCtx.Out("Write stdin to FILE, overwriting by default or appending with --mode append.\n") +} + +func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { + modeFlag := fs.String("mode", "overwrite", "write mode: overwrite or append") + appendFlag := fs.BoolP("append", "a", false, "append to FILE instead of overwriting") + jsonFlag := fs.Bool("json", false, "print a structured remediation receipt") + helpFlag := fs.BoolP("help", "h", false, "print usage and exit") + + return func(ctx context.Context, callCtx *builtins.CallContext, args []string) builtins.Result { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + if len(args) != 1 { + callCtx.Errf("write_file: expected exactly one file\n") + return builtins.Result{Code: 1} + } + if callCtx.OpenFileForWrite == nil { + callCtx.Errf("write_file: file write is not available\n") + return builtins.Result{Code: 1} + } + mode := *modeFlag + if *appendFlag { + mode = "append" + } + appendMode := false + switch mode { + case "overwrite": + case "append": + appendMode = true + default: + callCtx.Errf("write_file: unsupported mode: %s\n", mode) + return builtins.Result{Code: 1} + } + data, res, ok := readInput(callCtx) + if !ok { + return res + } + path := args[0] + created := true + if callCtx.StatFile != nil { + if _, err := callCtx.StatFile(ctx, path); err == nil { + created = false + } + } + f, err := callCtx.OpenFileForWrite(ctx, path, appendMode) + if err != nil { + callCtx.Errf("write_file: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + if _, err := f.Write(data); err != nil { + f.Close() + callCtx.Errf("write_file: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + if err := f.Close(); err != nil { + callCtx.Errf("write_file: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + if *jsonFlag { + return printReceipt(ctx, callCtx, path, mode, len(data), created) + } + return builtins.Result{} + } +} + +type receipt struct { + Path string `json:"path"` + Mode string `json:"mode"` + BytesWritten int `json:"bytes_written"` + BytesAfter int64 `json:"bytes_after"` + Created bool `json:"created"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +func readInput(callCtx *builtins.CallContext) ([]byte, builtins.Result, bool) { + if callCtx.Stdin == nil { + return nil, builtins.Result{}, true + } + data, err := io.ReadAll(io.LimitReader(callCtx.Stdin, MaxWriteFileBytes+1)) + if err != nil { + callCtx.Errf("write_file: reading stdin: %s\n", err) + return nil, builtins.Result{Code: 1}, false + } + if len(data) > MaxWriteFileBytes { + callCtx.Errf("write_file: input exceeds maximum of %d bytes\n", MaxWriteFileBytes) + return nil, builtins.Result{Code: 1}, false + } + return data, builtins.Result{}, true +} + +func printReceipt(ctx context.Context, callCtx *builtins.CallContext, path, mode string, bytesWritten int, created bool) builtins.Result { + var bytesAfter int64 + if callCtx.StatFile != nil { + info, err := callCtx.StatFile(ctx, path) + if err != nil { + callCtx.Errf("write_file: %s: %s\n", path, callCtx.PortableErr(err)) + return builtins.Result{Code: 1} + } + bytesAfter = info.Size() + } + return callCtx.OutJSON(receipt{ + Path: path, + Mode: mode, + BytesWritten: bytesWritten, + BytesAfter: bytesAfter, + Created: created, + ExitCode: 0, + Stdout: "", + Stderr: "", + }) +} diff --git a/interp/register_builtins.go b/interp/register_builtins.go index 52d640e1d..cc2e681a1 100644 --- a/interp/register_builtins.go +++ b/interp/register_builtins.go @@ -46,6 +46,7 @@ import ( "github.com/DataDog/rshell/builtins/uname" "github.com/DataDog/rshell/builtins/uniq" "github.com/DataDog/rshell/builtins/wc" + "github.com/DataDog/rshell/builtins/write_file" "github.com/DataDog/rshell/builtins/xargs" ) @@ -92,6 +93,7 @@ func registerBuiltins() { uname.Cmd, uniq.Cmd, wc.Cmd, + write_file.Cmd, xargs.Cmd, } { cmd.Register() diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 713757fba..d7507c7d4 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -100,6 +100,28 @@ func TestRemediationTruncateDelegatesThroughExecHandlerByDefault(t *testing.T) { assert.Equal(t, []string{"truncate", "-s", "0", "--", builtins.HostExtraFilePath(0)}, got) } +func TestRemediationTruncateJSONReportsSizes(t *testing.T) { + requireHostExtraFilesSupported(t) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "app.log"), []byte("abcdef"), 0644)) + + stdout, stderr, code := runScript(t, "truncate --json -s 3 app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + require.Equal(t, []string{"truncate", "-s", "3", "--", builtins.HostExtraFilePath(0)}, args) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + return interp.HandlerCtx(ctx).ExtraFiles[0].Truncate(3) + }), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"path":"app.log","bytes_before":6,"bytes_after":3,"size_bytes":3,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "app.log")) + require.NoError(t, err) + assert.Equal(t, "abc", string(data)) +} + func TestRemediationTruncatePreservesLeadingDashOperand(t *testing.T) { requireHostExtraFilesSupported(t) dir := t.TempDir() @@ -340,6 +362,30 @@ func TestRemediationSystemctlShowActiveStatePreservesLeadingDashUnit(t *testing. assert.Equal(t, []string{"systemctl", "show", "--property=ActiveState", "--value", "--", "-app.service"}, got) } +func TestRemediationSystemctlJSONReportsActiveState(t *testing.T) { + dir := t.TempDir() + var got [][]string + + stdout, stderr, code := runScript(t, "systemctl --json restart app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append(got, append([]string(nil), args...)) + if len(args) > 1 && args[1] == "show" { + _, err := io.WriteString(interp.HandlerCtx(ctx).Stdout, "active\n") + return err + } + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"unit":"app.service","action":"restart","active_state":"active","exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, [][]string{ + {"systemctl", "restart", "--", "app.service"}, + {"systemctl", "show", "--property=ActiveState", "--value", "--", "app.service"}, + }, got) +} + func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { dir := t.TempDir() called := false @@ -380,11 +426,14 @@ func TestRemediationSystemctlRejectsUnsupportedShowShape(t *testing.T) { func TestRemediationKillDelegatesForcePid(t *testing.T) { dir := t.TempDir() - var got []string + var got [][]string stdout, stderr, code := runScript(t, "kill -9 123", dir, interp.HostCommandHandler(func(ctx context.Context, args []string) error { - got = append([]string(nil), args...) + got = append(got, append([]string(nil), args...)) + if len(args) > 1 && args[1] == "-0" { + return interp.ExitStatus(1) + } return nil }), ) @@ -392,7 +441,28 @@ func TestRemediationKillDelegatesForcePid(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, []string{"kill", "-9", "123"}, got) + assert.Equal(t, [][]string{ + {"kill", "-9", "123"}, + {"kill", "-0", "123"}, + }, got) +} + +func TestRemediationKillJSONReportsTimedOut(t *testing.T) { + dir := t.TempDir() + var got [][]string + + stdout, stderr, code := runScript(t, "kill --json --timeout 1ms 123", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + got = append(got, append([]string(nil), args...)) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"pid":123,"force":false,"signal":"SIGTERM","timed_out":true,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + assert.NotEmpty(t, got) + assert.Equal(t, []string{"kill", "123"}, got[0]) } func TestRemediationKillRejectsInvalidPid(t *testing.T) { @@ -411,6 +481,37 @@ func TestRemediationKillRejectsInvalidPid(t *testing.T) { assert.False(t, called) } +func TestRemediationWriteFileJSONWritesAndReports(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := runScript(t, "write_file --json output.txt <<'EOF'\npayload\nEOF\n", dir, + interp.AllowedPaths([]string{dir}), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"path":"output.txt","mode":"overwrite","bytes_written":8,"bytes_after":8,"created":true,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "payload\n", string(data)) +} + +func TestRemediationWriteFileAppendReportsExistingTarget(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "output.txt"), []byte("old\n"), 0644)) + + stdout, stderr, code := runScript(t, "write_file --json --mode append output.txt <<'EOF'\nnew\nEOF\n", dir, + interp.AllowedPaths([]string{dir}), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"path":"output.txt","mode":"append","bytes_written":4,"bytes_after":8,"created":false,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + data, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "old\nnew\n", string(data)) +} + func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { requireHostExtraFilesSupported(t) dir := t.TempDir() @@ -622,6 +723,29 @@ func TestRemediationLogrotateDelegatesExistingPath(t *testing.T) { assert.Equal(t, []string{"logrotate", "--", builtins.HostExtraFilePath(0)}, got) } +func TestRemediationLogrotateJSONReportsRotatedPath(t *testing.T) { + requireHostExtraFilesSupported(t) + dir := t.TempDir() + active := filepath.Join(dir, "app.log") + rotated := filepath.Join(dir, "app.log.1") + require.NoError(t, os.WriteFile(active, []byte("payload\n"), 0644)) + + stdout, stderr, code := runScript(t, "logrotate --json app.log", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + require.Equal(t, []string{"logrotate", "--", builtins.HostExtraFilePath(0)}, args) + require.NoError(t, os.Rename(active, rotated)) + return os.WriteFile(active, nil, 0644) + }), + ) + + assert.Equal(t, 0, code) + assert.JSONEq(t, `{"path":"app.log","rotated_path":"app.log.1","bytes_before":8,"bytes_after":0,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + assert.FileExists(t, active) + assert.FileExists(t, rotated) +} + func TestRemediationLogrotatePreservesLeadingDashOperand(t *testing.T) { requireHostExtraFilesSupported(t) dir := t.TempDir() diff --git a/interp/runner.go b/interp/runner.go index 43a4b2440..80675f89e 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -27,16 +27,20 @@ func (r *Runner) handlerCtxWithDir(ctx context.Context, pos syntax.Pos, dir stri } func (r *Runner) handlerCtxWithDirFiles(ctx context.Context, pos syntax.Pos, dir string, extraFiles []*os.File) context.Context { + return r.handlerCtxWithDirFilesIO(ctx, pos, dir, extraFiles, r.stdin, r.stdout, r.stderr) +} + +func (r *Runner) handlerCtxWithDirFilesIO(ctx context.Context, pos syntax.Pos, dir string, extraFiles []*os.File, stdin io.Reader, stdout, stderr io.Writer) context.Context { hc := HandlerContext{ Env: &overlayEnviron{parent: r.writeEnv}, Dir: dir, Pos: pos, - Stdout: r.stdout, - Stderr: r.stderr, + Stdout: stdout, + Stderr: stderr, ExtraFiles: extraFiles, } - if r.stdin != nil { // do not leave hc.Stdin as a typed nil - hc.Stdin = r.stdin + if stdin != nil { // do not leave hc.Stdin as a typed nil + hc.Stdin = stdin } return context.WithValue(ctx, handlerCtxKey{}, hc) } diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 21382f871..0ecaed595 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -6,6 +6,7 @@ package interp import ( + "bytes" "context" "errors" "fmt" @@ -681,6 +682,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { return r.runHostCommand(ctx, todoPos, dir, cmdName, hostName, hostArgs, extraFiles) }, + RunHostCommandWithFilesCapture: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (builtins.CapturedHostCommand, error) { + return r.runHostCommandCapture(ctx, todoPos, dir, cmdName, hostName, hostArgs, extraFiles, childStdin) + }, // Intentionally not exposing SetVar / GetVar in the // child CallContext used for find -exec / -execdir // grandchildren. find treats each invocation as a @@ -804,6 +808,9 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { RunHostCommandWithFiles: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (uint8, error) { return r.runHostCommand(ctx, todoPos, r.Dir, name, hostName, hostArgs, extraFiles) }, + RunHostCommandWithFilesCapture: func(ctx context.Context, hostName string, hostArgs []string, extraFiles []*os.File) (builtins.CapturedHostCommand, error) { + return r.runHostCommandCapture(ctx, todoPos, r.Dir, name, hostName, hostArgs, extraFiles, r.stdin) + }, SetVar: func(name, value string) error { if len(value) > MaxVarBytes { return fmt.Errorf("%s: value too large (limit %d bytes)", name, MaxVarBytes) @@ -861,6 +868,20 @@ func (r *Runner) exec(ctx context.Context, pos syntax.Pos, args []string) { } func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string, extraFiles []*os.File) (uint8, error) { + return r.runHostCommandWithIO(ctx, pos, dir, caller, name, args, extraFiles, r.stdin, r.stdout, r.stderr) +} + +func (r *Runner) runHostCommandCapture(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string, extraFiles []*os.File, stdin io.Reader) (builtins.CapturedHostCommand, error) { + var stdout, stderr bytes.Buffer + code, err := r.runHostCommandWithIO(ctx, pos, dir, caller, name, args, extraFiles, stdin, &stdout, &stderr) + return builtins.CapturedHostCommand{ + Code: code, + Stdout: stdout.String(), + Stderr: stderr.String(), + }, err +} + +func (r *Runner) runHostCommandWithIO(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string, extraFiles []*os.File, stdin io.Reader, stdout, stderr io.Writer) (uint8, error) { if caller != name || !isGuardedHostCommand(caller) { return 127, fmt.Errorf("rshell: %s: host command execution not available", name) } @@ -870,7 +891,7 @@ func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, argv := make([]string, 0, len(args)+1) argv = append(argv, name) argv = append(argv, args...) - err := r.hostCommandHandler(r.handlerCtxWithDirFiles(ctx, pos, dir, extraFiles), argv) + err := r.hostCommandHandler(r.handlerCtxWithDirFilesIO(ctx, pos, dir, extraFiles, stdin, stdout, stderr), argv) if err == nil { return 0, nil } From 3328ecec195633d250e8f33fd3753a992548f1cd Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 10:56:28 -0400 Subject: [PATCH 14/36] Fix remediation review findings --- SHELL_FEATURES.md | 2 +- allowedpaths/portable_unix.go | 72 +++++++++++++++++++++++++++++ allowedpaths/portable_windows.go | 7 +++ allowedpaths/sandbox.go | 16 ++----- analysis/symbols_allowedpaths.go | 11 +++++ builtins/builtins.go | 8 ++-- builtins/kill/kill.go | 32 +++++++------ builtins/logrotate/logrotate.go | 32 +++++++------ builtins/systemctl/systemctl.go | 28 ++++++----- builtins/truncate/truncate.go | 32 +++++++------ interp/remediation_commands_test.go | 39 ++++++++++++++++ interp/runner_exec.go | 12 +++-- interp/runner_expand.go | 4 ++ 13 files changed, 221 insertions(+), 74 deletions(-) diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 8d9ee0433..35c2dc61f 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -118,7 +118,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed - ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories - ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler; file-mutating commands pass sandbox-opened descriptors via handler context extra files -- ✅ Structured remediation receipts — guarded remediation commands accept `--json` where command-specific receipts are useful, while preserving normal shell stdout/stderr behavior by default +- ✅ Structured remediation receipts — guarded remediation commands accept `--json` where command-specific receipts are useful, while preserving normal shell stdout/stderr behavior by default; captured host stdout/stderr in receipts is capped and reports `stdout_truncated` / `stderr_truncated` when the cap is hit - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments) - ❌ External commands — blocked by default; require an ExecHandler and the command name must pass AllowedCommands diff --git a/allowedpaths/portable_unix.go b/allowedpaths/portable_unix.go index 830d65d97..b93fb9bf0 100644 --- a/allowedpaths/portable_unix.go +++ b/allowedpaths/portable_unix.go @@ -11,7 +11,11 @@ import ( "errors" "io/fs" "os" + "path/filepath" + "strings" "syscall" + + "golang.org/x/sys/unix" ) // IsErrIsDirectory reports whether err is an "is a directory" error. @@ -112,6 +116,74 @@ func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.Fil return f, err } +func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, _ bool) (*os.File, error) { + rel = filepath.Clean(rel) + if rel == "." { + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + + rootDir, err := r.root.Open(".") + if err != nil { + return nil, err + } + defer rootDir.Close() + + rootFD := int(rootDir.Fd()) + dirFD := rootFD + closeDirFD := func() { + if dirFD != rootFD { + _ = unix.Close(dirFD) + dirFD = rootFD + } + } + defer closeDirFD() + + components := strings.Split(rel, string(filepath.Separator)) + for _, component := range components[:len(components)-1] { + if component == "" || component == "." { + continue + } + fd, err := unix.Openat(dirFD, component, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, noFollowOpenPathError(rel, err) + } + closeDirFD() + dirFD = fd + } + + leaf := components[len(components)-1] + if leaf == "" || leaf == "." { + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + + fd, err := unix.Openat(dirFD, leaf, flag|unix.O_NOFOLLOW|unix.O_CLOEXEC|unix.O_NONBLOCK, uint32(perm)) + if err != nil { + return nil, noFollowOpenPathError(rel, err) + } + f := os.NewFile(uintptr(fd), rel) + info, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, err + } + if info.IsDir() { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + if !info.Mode().IsRegular() { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return f, nil +} + +func noFollowOpenPathError(rel string, err error) error { + if errors.Is(err, unix.ELOOP) || errors.Is(err, unix.ENOTDIR) || errors.Is(err, unix.ENXIO) { + return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return &os.PathError{Op: "open", Path: rel, Err: err} +} + // effectiveHasPerm checks whether the current process has the requested // permission by inspecting the file's owner/group/other permission class // that applies to the effective UID and GID of the running process. diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index a99771967..06540cd5d 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -89,3 +89,10 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) { return r.root.OpenFile(rel, flag, perm) } + +func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, allowMissingFinal bool) (*os.File, error) { + if err := r.validateWritePath(rel, allowMissingFinal); err != nil { + return nil, err + } + return r.openFileNoFollow(rel, flag, perm) +} diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index 8279ea138..e54ba3331 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -376,13 +376,9 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - if err := ar.validateWritePath(relPath, true); err != nil { - return nil, err - } - - f, err := ar.openFileNoFollow(relPath, flag, perm) + f, err := ar.openFileValidatedNoFollow(relPath, flag, perm, true) if err != nil { - return nil, PortablePathError(err) + return nil, err } return f, nil } @@ -401,13 +397,9 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} } - if err := ar.validateWritePath(relPath, false); err != nil { - return nil, err - } - - f, err := ar.openFileNoFollow(relPath, os.O_WRONLY, 0) + f, err := ar.openFileValidatedNoFollow(relPath, os.O_WRONLY, 0, false) if err != nil { - return nil, PortablePathError(err) + return nil, err } return f, nil } diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index f2bd0e901..8eef20565 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -47,6 +47,7 @@ var allowedpathsAllowedSymbols = []string{ "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. "os.O_TRUNC", // 🟢 truncate file flag constant; only accepted by the dedicated redirection write-open path. "os.O_WRONLY", // 🟢 write-only file flag constant; only accepted by the dedicated redirection write-open path. + "os.NewFile", // 🟠 wraps a sandbox-opened file descriptor after fd-relative openat validation; does not open paths itself. "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. "os.PathError", // 🟢 error type wrapping path and operation; pure type. "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. @@ -66,6 +67,16 @@ var allowedpathsAllowedSymbols = []string{ "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. "strings.Join", // 🟢 joins string slices; pure function, no I/O. "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. + "golang.org/x/sys/unix.Close", // 🟠 closes intermediate directory file descriptors opened during fd-relative write-path validation. + "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; normalized to permission denied for no-follow write opens. + "golang.org/x/sys/unix.ENOTDIR", // 🟢 not-a-directory errno constant; normalized when no-follow parent traversal rejects a symlink directory. + "golang.org/x/sys/unix.ENXIO", // 🟢 no-device errno constant; normalized when non-blocking write-open races to a FIFO. + "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag; prevents leaking validation descriptors to child processes. + "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag for parent component traversal. + "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag; rejects symlink parent/final components during write opens. + "golang.org/x/sys/unix.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking if a final component races to a FIFO. + "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag for parent directory traversal. + "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep no-symlink write validation tied to the opened parent directory. "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. diff --git a/builtins/builtins.go b/builtins/builtins.go index ebf522aae..8f3f37589 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -47,9 +47,11 @@ type HandlerFunc func(ctx context.Context, callCtx *CallContext, args []string) // CapturedHostCommand is the output from a guarded host command run with // stdout/stderr captured instead of streamed to the shell's current fds. type CapturedHostCommand struct { - Code uint8 - Stdout string - Stderr string + Code uint8 + Stdout string + Stderr string + StdoutTruncated bool + StderrTruncated bool } // Command pairs a builtin name with its flag-declaring factory. MakeFlags diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index 4ca5118a8..8f839e1ad 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -75,13 +75,15 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } type receipt struct { - PID int64 `json:"pid"` - Force bool `json:"force"` - Signal string `json:"signal"` - TimedOut bool `json:"timed_out"` - ExitCode uint8 `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` + PID int64 `json:"pid"` + Force bool `json:"force"` + Signal string `json:"signal"` + TimedOut bool `json:"timed_out"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` } func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, force bool, timeout time.Duration, argv []string) builtins.Result { @@ -102,13 +104,15 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, forc signal = "SIGKILL" } outRes := callCtx.OutJSON(receipt{ - PID: pid, - Force: force, - Signal: signal, - TimedOut: timedOut, - ExitCode: host.Code, - Stdout: host.Stdout, - Stderr: host.Stderr, + PID: pid, + Force: force, + Signal: signal, + TimedOut: timedOut, + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + StdoutTruncated: host.StdoutTruncated, + StderrTruncated: host.StderrTruncated, }) if outRes.Code != 0 || outRes.Exiting { return outRes diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go index f5d204f06..a613334c0 100644 --- a/builtins/logrotate/logrotate.go +++ b/builtins/logrotate/logrotate.go @@ -70,13 +70,15 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } type receipt struct { - Path string `json:"path"` - RotatedPath string `json:"rotated_path"` - BytesBefore int64 `json:"bytes_before"` - BytesAfter int64 `json:"bytes_after"` - ExitCode uint8 `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` + Path string `json:"path"` + RotatedPath string `json:"rotated_path"` + BytesBefore int64 `json:"bytes_before"` + BytesAfter int64 `json:"bytes_after"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` } type rotateCandidate struct { @@ -99,13 +101,15 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, path string, by } after := collectRotateCandidates(ctx, callCtx, path) outRes := callCtx.OutJSON(receipt{ - Path: path, - RotatedPath: discoverRotatedPath(before, after), - BytesBefore: bytesBefore, - BytesAfter: afterInfo.Size(), - ExitCode: host.Code, - Stdout: host.Stdout, - Stderr: host.Stderr, + Path: path, + RotatedPath: discoverRotatedPath(before, after), + BytesBefore: bytesBefore, + BytesAfter: afterInfo.Size(), + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + StdoutTruncated: host.StdoutTruncated, + StderrTruncated: host.StderrTruncated, }) if outRes.Code != 0 || outRes.Exiting { return outRes diff --git a/builtins/systemctl/systemctl.go b/builtins/systemctl/systemctl.go index 0bda5bd2f..c35eb559d 100644 --- a/builtins/systemctl/systemctl.go +++ b/builtins/systemctl/systemctl.go @@ -84,12 +84,14 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } type receipt struct { - Unit string `json:"unit"` - Action string `json:"action"` - ActiveState string `json:"active_state"` - ExitCode uint8 `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` + Unit string `json:"unit"` + Action string `json:"action"` + ActiveState string `json:"active_state"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` } func runJSON(ctx context.Context, callCtx *builtins.CallContext, action, unit string, hostArgs []string) builtins.Result { @@ -112,12 +114,14 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, action, unit st stderr += stateHost.Stderr } outRes := callCtx.OutJSON(receipt{ - Unit: unit, - Action: action, - ActiveState: activeState, - ExitCode: exitCode, - Stdout: actionHost.Stdout, - Stderr: stderr, + Unit: unit, + Action: action, + ActiveState: activeState, + ExitCode: exitCode, + Stdout: actionHost.Stdout, + Stderr: stderr, + StdoutTruncated: actionHost.StdoutTruncated || stateHost.StdoutTruncated, + StderrTruncated: actionHost.StderrTruncated || stateHost.StderrTruncated, }) if outRes.Code != 0 || outRes.Exiting { return outRes diff --git a/builtins/truncate/truncate.go b/builtins/truncate/truncate.go index 8c6b548a6..aeadc6130 100644 --- a/builtins/truncate/truncate.go +++ b/builtins/truncate/truncate.go @@ -93,13 +93,15 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } type receipt struct { - Path string `json:"path"` - BytesBefore int64 `json:"bytes_before"` - BytesAfter int64 `json:"bytes_after"` - SizeBytes int64 `json:"size_bytes"` - ExitCode uint8 `json:"exit_code"` - Stdout string `json:"stdout"` - Stderr string `json:"stderr"` + Path string `json:"path"` + BytesBefore int64 `json:"bytes_before"` + BytesAfter int64 `json:"bytes_after"` + SizeBytes int64 `json:"size_bytes"` + ExitCode uint8 `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` } func runJSON(ctx context.Context, callCtx *builtins.CallContext, path string, size int64, bytesBefore int64, hostArgs []string, f *os.File) builtins.Result { @@ -113,13 +115,15 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, path string, si return builtins.Result{Code: 1} } outRes := callCtx.OutJSON(receipt{ - Path: path, - BytesBefore: bytesBefore, - BytesAfter: afterInfo.Size(), - SizeBytes: size, - ExitCode: host.Code, - Stdout: host.Stdout, - Stderr: host.Stderr, + Path: path, + BytesBefore: bytesBefore, + BytesAfter: afterInfo.Size(), + SizeBytes: size, + ExitCode: host.Code, + Stdout: host.Stdout, + Stderr: host.Stderr, + StdoutTruncated: host.StdoutTruncated, + StderrTruncated: host.StderrTruncated, }) if outRes.Code != 0 || outRes.Exiting { return outRes diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index d7507c7d4..804f8fc3a 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -7,10 +7,12 @@ package interp_test import ( "context" + "encoding/json" "io" "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -386,6 +388,43 @@ func TestRemediationSystemctlJSONReportsActiveState(t *testing.T) { }, got) } +func TestRemediationSystemctlJSONCapsCapturedOutput(t *testing.T) { + dir := t.TempDir() + large := strings.Repeat("x", 2<<20) + + stdout, stderr, code := runScript(t, "systemctl --json status app.service", dir, + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + hc := interp.HandlerCtx(ctx) + if len(args) > 1 && args[1] == "show" { + _, err := io.WriteString(hc.Stdout, "active\n") + return err + } + if _, err := io.WriteString(hc.Stdout, large); err != nil { + return err + } + _, err := io.WriteString(hc.Stderr, large) + return err + }), + ) + + require.Equal(t, 0, code) + assert.Equal(t, "", stderr) + + var got struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + StdoutTruncated bool `json:"stdout_truncated"` + StderrTruncated bool `json:"stderr_truncated"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + assert.True(t, got.StdoutTruncated) + assert.True(t, got.StderrTruncated) + assert.NotEmpty(t, got.Stdout) + assert.NotEmpty(t, got.Stderr) + assert.Less(t, len(got.Stdout), len(large)) + assert.Less(t, len(got.Stderr), len(large)) +} + func TestRemediationSystemctlRejectsUnsupportedAction(t *testing.T) { dir := t.TempDir() called := false diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 0ecaed595..14920f998 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -873,11 +873,15 @@ func (r *Runner) runHostCommand(ctx context.Context, pos syntax.Pos, dir string, func (r *Runner) runHostCommandCapture(ctx context.Context, pos syntax.Pos, dir string, caller string, name string, args []string, extraFiles []*os.File, stdin io.Reader) (builtins.CapturedHostCommand, error) { var stdout, stderr bytes.Buffer - code, err := r.runHostCommandWithIO(ctx, pos, dir, caller, name, args, extraFiles, stdin, &stdout, &stderr) + stdoutCap := &limitWriter{w: &stdout, limit: maxHostCommandCaptureOutput} + stderrCap := &limitWriter{w: &stderr, limit: maxHostCommandCaptureOutput} + code, err := r.runHostCommandWithIO(ctx, pos, dir, caller, name, args, extraFiles, stdin, stdoutCap, stderrCap) return builtins.CapturedHostCommand{ - Code: code, - Stdout: stdout.String(), - Stderr: stderr.String(), + Code: code, + Stdout: stdout.String(), + Stderr: stderr.String(), + StdoutTruncated: stdoutCap.isExceeded(), + StderrTruncated: stderrCap.isExceeded(), }, err } diff --git a/interp/runner_expand.go b/interp/runner_expand.go index 327db984f..9d1505660 100644 --- a/interp/runner_expand.go +++ b/interp/runner_expand.go @@ -50,6 +50,10 @@ func (r *Runner) updateExpandOpts() { // commands that produce unbounded output. const maxCmdSubstOutput = 1 << 20 // 1 MiB +// maxHostCommandCaptureOutput is the maximum stdout/stderr bytes captured from +// each guarded host command when a builtin builds a JSON receipt. +const maxHostCommandCaptureOutput = 1 << 20 // 1 MiB + // maxStdoutBytes is the maximum number of bytes a script can write to stdout // before further output is silently discarded. This caps total script output // to prevent memory exhaustion from runaway commands (e.g. infinite loops From a27575bd7a707470439f07361ab29de025fe4a67 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 11:13:27 -0400 Subject: [PATCH 15/36] Guard write redirects behind command policy --- interp/runner_exec.go | 167 +++++++++++------- interp/tests/redir_devnull_test.go | 29 +++ .../disallowed_command_does_not_write.yaml | 23 +++ 3 files changed, 158 insertions(+), 61 deletions(-) create mode 100644 tests/scenarios/shell/blocked_redirects/disallowed_command_does_not_write.yaml diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 14920f998..e4fbce1e8 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -36,7 +36,26 @@ func (r *Runner) stmt(ctx context.Context, st *syntax.Stmt) { func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr oldOutFile, oldErrFile := r.stdoutFileRedirect, r.stderrFileRedirect + var ( + callExpr *syntax.CallExpr + callFields []string + callPrechecked bool + ) + // Destructive stdout redirects must not be opened until the command name + // has passed AllowedCommands. Otherwise a blocked command could still + // create or truncate files inside AllowedPaths. + if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { + callExpr = cm + callFields = r.expandCallFields(cm) + callPrechecked = true + if len(callFields) > 0 && !r.commandAllowed(callFields[0]) { + r.cmdCallFields(ctx, cm, callFields) + } + } for _, rd := range st.Redirs { + if !r.exit.ok() { + break + } cls, err := r.redir(ctx, rd) if err != nil { r.exit.code = 1 @@ -47,7 +66,11 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { } } if r.exit.ok() && st.Cmd != nil { - r.cmd(ctx, st.Cmd) + if callPrechecked { + r.cmdCallFields(ctx, callExpr, callFields) + } else { + r.cmd(ctx, st.Cmd) + } } if st.Negated && !r.exit.exiting { wasOk := r.exit.ok() @@ -58,6 +81,86 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { r.stdoutFileRedirect, r.stderrFileRedirect = oldOutFile, oldErrFile } +func stmtHasPotentialFileWriteRedirect(st *syntax.Stmt) bool { + for _, rd := range st.Redirs { + switch rd.Op { + case syntax.RdrOut, syntax.AppOut: + if !redirectTargetIsDevNull(rd) { + return true + } + } + } + return false +} + +func (r *Runner) commandAllowed(name string) bool { + return r.allowAllCommands || r.allowedCommands[name] +} + +func (r *Runner) expandCallFields(cm *syntax.CallExpr) []string { + r.lastExpandExit = exitStatus{} + return r.fields(cm.Args...) +} + +func (r *Runner) cmdCall(ctx context.Context, cm *syntax.CallExpr) { + r.cmdCallFields(ctx, cm, r.expandCallFields(cm)) +} + +func (r *Runner) cmdCallFields(ctx context.Context, cm *syntax.CallExpr, fields []string) { + if len(fields) == 0 { + for _, as := range cm.Assigns { + prev := r.lookupVar(as.Name.Value) + prev.Local = false + + vr := r.assignVal(prev, as, "") + r.setVarWithIndex(prev, as.Name.Value, as.Index, vr) + } + // If interpreting the last expansion like $(foo) failed, and the + // expansion and assignments otherwise succeeded, surface that exit code. + if r.exit.ok() { + r.exit = r.lastExpandExit + } + return + } + + type restoreVar struct { + name string + vr expand.Variable + } + var restores []restoreVar + + for _, as := range cm.Assigns { + name := as.Name.Value + prev := r.lookupVar(name) + + vr := r.assignVal(prev, as, "") + // Inline command vars are always exported. + vr.Exported = true + + restores = append(restores, restoreVar{name, prev}) + + r.setVar(name, vr) + } + + defer func() { + // cd intentionally writes $PWD and $OLDPWD as part of its semantics. + // Reverting those after a successful cd would leave the env vars + // disagreeing with the shell's tracked working directory; bash skips + // the revert in the same case. The skip is scoped to a successful cd + // so a cd that errored still gets its temp PWD assignment reverted. + isCd := fields[0] == "cd" && r.exit.ok() + for _, restore := range restores { + if isCd && (restore.name == "PWD" || restore.name == "OLDPWD") { + continue + } + r.setVarRestore(restore.name, restore.vr) + } + }() + if r.exit.ok() { + r.call(ctx, cm.Args[0].Pos(), fields) + } +} + func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { if r.stop(ctx) { return @@ -79,65 +182,7 @@ func (r *Runner) cmd(ctx context.Context, cm syntax.Command) { case *syntax.Block: r.stmts(ctx, cm.Stmts) case *syntax.CallExpr: - args := cm.Args - r.lastExpandExit = exitStatus{} - fields := r.fields(args...) - if len(fields) == 0 { - for _, as := range cm.Assigns { - prev := r.lookupVar(as.Name.Value) - prev.Local = false - - vr := r.assignVal(prev, as, "") - r.setVarWithIndex(prev, as.Name.Value, as.Index, vr) - } - // If interpreting the last expansion like $(foo) failed, - // and the expansion and assignments otherwise succeeded, - // we need to surface that last exit code. - if r.exit.ok() { - r.exit = r.lastExpandExit - } - break - } - - type restoreVar struct { - name string - vr expand.Variable - } - var restores []restoreVar - - for _, as := range cm.Assigns { - name := as.Name.Value - prev := r.lookupVar(name) - - vr := r.assignVal(prev, as, "") - // Inline command vars are always exported. - vr.Exported = true - - restores = append(restores, restoreVar{name, prev}) - - r.setVar(name, vr) - } - - defer func() { - // cd intentionally writes $PWD and $OLDPWD as part of - // its semantics. Reverting those after a successful cd - // would leave the env vars disagreeing with the shell's - // tracked working directory — bash skips the revert in - // the same case (e.g. `PWD=/bogus cd b` keeps PWD at - // the new dir afterwards). The skip is scoped to a - // successful cd so a cd that errored still gets its - // temp PWD assignment reverted normally. - isCd := len(fields) > 0 && fields[0] == "cd" && r.exit.ok() - for _, restore := range restores { - if isCd && (restore.name == "PWD" || restore.name == "OLDPWD") { - continue - } - r.setVarRestore(restore.name, restore.vr) - } - }() - if r.exit.ok() { - r.call(ctx, cm.Args[0].Pos(), fields) - } + r.cmdCall(ctx, cm) case *syntax.BinaryCmd: switch cm.Op { case syntax.AndStmt, syntax.OrStmt: @@ -517,7 +562,7 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { // Evaluate both policy checks upfront so the span tags reflect the // independent facts about the command name regardless of which gate // short-circuits dispatch. - isAllowed := r.allowAllCommands || r.allowedCommands[name] + isAllowed := r.commandAllowed(name) fn, isKnown := builtins.Lookup(name) span, ctx := telemetry.StartSpanFromContext(ctx, "command") diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index e00c2a2cc..c0e712d8d 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -248,6 +248,35 @@ func TestRedirAppendToFile(t *testing.T) { assert.Equal(t, "old\nnew\n", string(data)) } +func TestRedirDeniedCommandDoesNotCreateOrModifyFile(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "output.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + stdout, stderr, code := redirRunWithOpts(t, "nope > created.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 127, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "rshell: nope: command not allowed") + _, err := os.Stat(filepath.Join(dir, "created.txt")) + assert.True(t, os.IsNotExist(err), "denied command created redirected file") + + stdout, stderr, code = redirRunWithOpts(t, "echo new > output.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 127, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "rshell: echo: command not allowed") + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) + + stdout, stderr, code = redirRunWithOpts(t, "cmd=echo; $cmd new >> output.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 127, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "rshell: echo: command not allowed") + data, err = os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRedirStdoutToFileRejectsTrailingSeparator(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "output.txt") diff --git a/tests/scenarios/shell/blocked_redirects/disallowed_command_does_not_write.yaml b/tests/scenarios/shell/blocked_redirects/disallowed_command_does_not_write.yaml new file mode 100644 index 000000000..f7ed01e8b --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/disallowed_command_does_not_write.yaml @@ -0,0 +1,23 @@ +description: Disallowed commands do not create, truncate, or append redirected files. +skip_assert_against_bash: true +setup: + files: + - path: output.txt + content: "keep\n" +input: + allowed_paths: ["$DIR"] + allowed_commands: ["rshell:cat"] + script: |+ + echo new > output.txt + cat output.txt + cmd=echo + $cmd later >> output.txt + cat output.txt +expect: + stdout: |+ + keep + keep + stderr: |+ + rshell: echo: command not allowed + rshell: echo: command not allowed + exit_code: 0 From 2b50b4478fe7dc0283052252af48b130cb5faa6c Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 11:23:46 -0400 Subject: [PATCH 16/36] Block compound command file redirects --- README.md | 2 +- SHELL_FEATURES.md | 1 + interp/tests/cmdsubst_pentest_test.go | 6 ++--- interp/tests/redir_devnull_test.go | 27 +++++++++++++++++++ interp/validate.go | 6 +++++ .../compound_file_redirect_blocked.yaml | 11 ++++++++ 6 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 tests/scenarios/shell/blocked_redirects/compound_file_redirect_blocked.yaml diff --git a/README.md b/README.md index 2abec2afd..062dfd9ba 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Every access path is default-deny: | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | | Filesystem access | Blocked | Configure `AllowedPaths` with directory list | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | -| Output redirections | File writes blocked unless `AllowedPaths` permits the target | `> FILE`, `>> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | +| Output redirections | File writes blocked unless `AllowedPaths` permits the target | Simple-command `> FILE` / `>> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 35c2dc61f..8ca66f54b 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -93,6 +93,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null - ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate) - ✅ `2>&1`, `>&2` — file descriptor duplication between stdout (1) and stderr (2) +- ❌ `{ ...; } > FILE`, `( ... ) > FILE`, `while ...; done > FILE` — compound-command stdout redirects to real files are blocked; use a simple command redirect or `/dev/null` - ❌ `|&` — pipe stdout and stderr (bash extension) - ❌ `<<<` — herestring (bash extension) - ❌ `2> FILE` — redirect stderr to a real file diff --git a/interp/tests/cmdsubst_pentest_test.go b/interp/tests/cmdsubst_pentest_test.go index c942bbdc0..ad6bd01e9 100644 --- a/interp/tests/cmdsubst_pentest_test.go +++ b/interp/tests/cmdsubst_pentest_test.go @@ -176,13 +176,13 @@ func TestCmdSubstPentestCatShortcutEmptyFile(t *testing.T) { // --- Subshell with redirect --- -func TestSubshellPentestRedirectOutBlocked(t *testing.T) { +func TestSubshellPentestFileOutputRedirectBlocked(t *testing.T) { dir := t.TempDir() outside := filepath.ToSlash(filepath.Join(t.TempDir(), "evil.txt")) quotedOutside := "'" + strings.ReplaceAll(outside, "'", "'\\''") + "'" _, stderr, code := subshellRun(t, "(echo data) > "+quotedOutside, dir) - assert.Equal(t, 1, code) - assert.Contains(t, stderr, "permission denied") + assert.Equal(t, 2, code) + assert.Contains(t, stderr, "stdout file redirection on compound commands is not supported") } // --- Context cancellation --- diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index c0e712d8d..e1417d23f 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -277,6 +277,33 @@ func TestRedirDeniedCommandDoesNotCreateOrModifyFile(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestRedirCompoundCommandFileOutputBlockedBeforeOpen(t *testing.T) { + tests := []struct { + name string + script string + }{ + {"brace_group", "{ nope; } > output.txt"}, + {"subshell", "(nope) > output.txt"}, + {"while_loop", "while false; do echo new; done > output.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "output.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + stdout, stderr, code := redirRunWithOpts(t, tt.script, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 2, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stdout file redirection on compound commands is not supported") + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) + }) + } +} + func TestRedirStdoutToFileRejectsTrailingSeparator(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "output.txt") diff --git a/interp/validate.go b/interp/validate.go index ad3c8c4fa..d24e83105 100644 --- a/interp/validate.go +++ b/interp/validate.go @@ -90,6 +90,12 @@ func validateNode(node syntax.Node) error { err = fmt.Errorf("background execution (&) is not supported") return false } + if n.Cmd != nil { + if _, ok := n.Cmd.(*syntax.CallExpr); !ok && stmtHasPotentialFileWriteRedirect(n) { + err = fmt.Errorf("stdout file redirection on compound commands is not supported") + return false + } + } // Blocked pipe operators. case *syntax.BinaryCmd: diff --git a/tests/scenarios/shell/blocked_redirects/compound_file_redirect_blocked.yaml b/tests/scenarios/shell/blocked_redirects/compound_file_redirect_blocked.yaml new file mode 100644 index 000000000..43da1a32a --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/compound_file_redirect_blocked.yaml @@ -0,0 +1,11 @@ +description: Compound commands cannot redirect stdout to real files. +skip_assert_against_bash: true +input: + allowed_paths: ["$DIR"] + script: |+ + { echo new; } > output.txt +expect: + stdout: |+ + stderr: |+ + stdout file redirection on compound commands is not supported + exit_code: 2 From e40ff10cbf054d90efba52299c3a86162c4597b9 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 11:34:28 -0400 Subject: [PATCH 17/36] Reject commandless file redirects --- README.md | 2 +- SHELL_FEATURES.md | 5 +-- interp/runner_exec.go | 5 ++- interp/tests/redir_devnull_test.go | 35 +++++++++++++++++++ interp/validate.go | 20 +++++++++-- .../commandless_file_redirect_blocked.yaml | 11 ++++++ 6 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 tests/scenarios/shell/blocked_redirects/commandless_file_redirect_blocked.yaml diff --git a/README.md b/README.md index 062dfd9ba..c666760dd 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Every access path is default-deny: | External commands | Blocked (exit code 127) | Provide an `ExecHandler` | | Filesystem access | Blocked | Configure `AllowedPaths` with directory list | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | -| Output redirections | File writes blocked unless `AllowedPaths` permits the target | Simple-command `> FILE` / `>> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | +| Output redirections | File writes blocked unless `AllowedPaths` permits the target | Simple-command `COMMAND > FILE` / `COMMAND >> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index 8ca66f54b..b815645b9 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -87,12 +87,13 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `<` — input redirection (read-only, within AllowedPaths) - ✅ `< FILE` — redirect stdout to FILE, creating/truncating within AllowedPaths -- ✅ `>> FILE` — append stdout to FILE, creating within AllowedPaths +- ✅ `COMMAND > FILE` — redirect simple-command stdout to FILE, creating/truncating within AllowedPaths +- ✅ `COMMAND >> FILE` — append simple-command stdout to FILE, creating within AllowedPaths - ✅ `>/dev/null`, `2>/dev/null` — redirect stdout or stderr to /dev/null (output is discarded; only `/dev/null` is allowed as target) - ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null - ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate) - ✅ `2>&1`, `>&2` — file descriptor duplication between stdout (1) and stderr (2) +- ❌ `> FILE`, `VAR=x > FILE` — commandless stdout redirects to real files are blocked; use `write_file` or a simple command redirect - ❌ `{ ...; } > FILE`, `( ... ) > FILE`, `while ...; done > FILE` — compound-command stdout redirects to real files are blocked; use a simple command redirect or `/dev/null` - ❌ `|&` — pipe stdout and stderr (bash extension) - ❌ `<<<` — herestring (bash extension) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index e4fbce1e8..4c87a214b 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -48,7 +48,10 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callExpr = cm callFields = r.expandCallFields(cm) callPrechecked = true - if len(callFields) > 0 && !r.commandAllowed(callFields[0]) { + if len(callFields) == 0 { + r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) + r.exit.code = 2 + } else if !r.commandAllowed(callFields[0]) { r.cmdCallFields(ctx, cm, callFields) } } diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index e1417d23f..3b74fbc18 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -277,6 +277,41 @@ func TestRedirDeniedCommandDoesNotCreateOrModifyFile(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestRedirCommandlessFileOutputBlockedBeforeOpen(t *testing.T) { + tests := []struct { + name string + script string + }{ + {"bare_redirect", "> output.txt"}, + {"assignment_redirect", "VAR=x > output.txt"}, + {"expanded_empty_command", "cmd=; $cmd > output.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "output.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + stdout, stderr, code := redirRunWithOpts(t, tt.script, dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 2, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stdout file redirection without a command is not supported") + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) + }) + } + + dir := t.TempDir() + stdout, stderr, code := redirRunWithOpts(t, "> created.txt", dir, interp.AllowedPaths([]string{dir})) + assert.Equal(t, 2, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stdout file redirection without a command is not supported") + _, err := os.Stat(filepath.Join(dir, "created.txt")) + assert.True(t, os.IsNotExist(err), "commandless redirect created redirected file") +} + func TestRedirCompoundCommandFileOutputBlockedBeforeOpen(t *testing.T) { tests := []struct { name string diff --git a/interp/validate.go b/interp/validate.go index d24e83105..7c23c6f10 100644 --- a/interp/validate.go +++ b/interp/validate.go @@ -14,6 +14,11 @@ import ( "github.com/DataDog/rshell/allowedpaths" ) +const ( + stdoutFileRedirectionOnCompoundCommandError = "stdout file redirection on compound commands is not supported" + stdoutFileRedirectionWithoutCommandError = "stdout file redirection without a command is not supported" +) + // validateNode walks the AST and rejects shell constructs that are not // supported in the safe-shell interpreter. It is called before execution // so that disallowed features are caught early with a clear error message. @@ -90,9 +95,18 @@ func validateNode(node syntax.Node) error { err = fmt.Errorf("background execution (&) is not supported") return false } - if n.Cmd != nil { - if _, ok := n.Cmd.(*syntax.CallExpr); !ok && stmtHasPotentialFileWriteRedirect(n) { - err = fmt.Errorf("stdout file redirection on compound commands is not supported") + if stmtHasPotentialFileWriteRedirect(n) { + if n.Cmd == nil { + err = fmt.Errorf(stdoutFileRedirectionWithoutCommandError) + return false + } + if cm, ok := n.Cmd.(*syntax.CallExpr); ok { + if len(cm.Args) == 0 { + err = fmt.Errorf(stdoutFileRedirectionWithoutCommandError) + return false + } + } else { + err = fmt.Errorf(stdoutFileRedirectionOnCompoundCommandError) return false } } diff --git a/tests/scenarios/shell/blocked_redirects/commandless_file_redirect_blocked.yaml b/tests/scenarios/shell/blocked_redirects/commandless_file_redirect_blocked.yaml new file mode 100644 index 000000000..5a81d5002 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/commandless_file_redirect_blocked.yaml @@ -0,0 +1,11 @@ +description: Commandless statements cannot redirect stdout to real files. +skip_assert_against_bash: true +input: + allowed_paths: ["$DIR"] + script: |+ + > output.txt +expect: + stdout: |+ + stderr: |+ + stdout file redirection without a command is not supported + exit_code: 2 From 1599a2e5a93abf4bd5ed7a7703c4b4249a6ce687 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 12:22:03 -0400 Subject: [PATCH 18/36] Fix redirect preflight and Windows write opens --- allowedpaths/portable_windows.go | 33 ++++- allowedpaths/sandbox_windows_test.go | 38 +++++ analysis/symbols_allowedpaths.go | 139 +++++++++--------- interp/runner_exec.go | 6 + interp/runner_redir.go | 69 ++++++++- interp/tests/redir_devnull_test.go | 10 +- .../stderr_dup_to_stdout_file.yaml | 5 +- 7 files changed, 222 insertions(+), 78 deletions(-) diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index 06540cd5d..89266a993 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -9,6 +9,7 @@ import ( "errors" "io/fs" "os" + "path/filepath" "syscall" ) @@ -87,12 +88,36 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f } func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) { - return r.root.OpenFile(rel, flag, perm) + // Keep no-follow on the same open that returns the writable handle; + // a separate pre-check can be raced by swapping in a reparse point. + f, err := r.root.OpenFile(rel, flag|syscall.FILE_FLAG_OPEN_REPARSE_POINT, perm) + if errors.Is(err, syscall.ELOOP) { + return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return f, err } -func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, allowMissingFinal bool) (*os.File, error) { - if err := r.validateWritePath(rel, allowMissingFinal); err != nil { +func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, _ bool) (*os.File, error) { + rel = filepath.Clean(rel) + if rel == "." { + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + f, err := r.openFileNoFollow(rel, flag, perm) + if err != nil { + return nil, PortablePathError(err) + } + info, err := f.Stat() + if err != nil { + _ = f.Close() return nil, err } - return r.openFileNoFollow(rel, flag, perm) + if info.IsDir() { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + if !info.Mode().IsRegular() { + _ = f.Close() + return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return f, nil } diff --git a/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go index 317281b93..aed735460 100644 --- a/allowedpaths/sandbox_windows_test.go +++ b/allowedpaths/sandbox_windows_test.go @@ -126,3 +126,41 @@ func TestAccessExecAlwaysDeniedWindows(t *testing.T) { // Windows has no POSIX execute bits — always denied. assert.ErrorIs(t, sb.Access("data.txt", dir, 0x01), os.ErrPermission) } + +func TestOpenForWriteRejectsWindowsSymlinkTargetWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + if err := os.Symlink("target.txt", filepath.Join(dir, "link.txt")); err != nil { + t.Skipf("creating symlink: %v", err) + } + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite("link.txt", dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + +func TestOpenForWriteRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + if err := os.Symlink("real", filepath.Join(dir, "linkdir")); err != nil { + t.Skipf("creating symlink: %v", err) + } + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite(filepath.Join("linkdir", "new.txt"), dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + assert.NoFileExists(t, filepath.Join(dir, "real", "new.txt")) +} diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 8eef20565..33a45f7f8 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -17,73 +17,74 @@ package analysis // // The permanently banned packages (reflect, unsafe) apply here too. var allowedpathsAllowedSymbols = []string{ - "bytes.Buffer", // 🟢 in-memory byte buffer; collects sandbox warnings for deferred output. - "context.Context", // 🟢 context type used to signal cancellation; no I/O or side effects. - "errors.As", // 🟢 error type assertion; pure function, no I/O. - "errors.Is", // 🟢 error comparison; pure function, no I/O. - "errors.New", // 🟢 creates a simple error value; pure function, no I/O. - "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. - "fmt.Fprintf", // 🟠 writes warning messages to in-memory buffer during sandbox construction. - "io.EOF", // 🟢 sentinel error value; pure constant. - "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. - "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. - "io/fs.ModeSymlink", // 🟢 file mode bit for symlinks; pure constant. - "io/fs.ErrExist", // 🟢 sentinel error for "already exists"; pure constant. - "io/fs.ErrNotExist", // 🟢 sentinel error for "does not exist"; pure constant. - "io/fs.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. - "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. - "io/fs.FileMode", // 🟢 file permission bits type; pure type. - "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. - "os.DevNull", // 🟢 platform null device path constant; pure constant. - "os.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. - "os.File", // 🟠 file handle returned by os.Root.Open; needed for cross-root symlink fallback. - "os.FileMode", // 🟢 file permission bits type; pure type. - "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. - "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. - "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. - "os.IsPathSeparator", // 🟢 checks whether a byte is a platform path separator; pure function, no I/O. - "os.O_APPEND", // 🟢 append file flag constant; only accepted by the dedicated redirection write-open path. - "os.O_CREATE", // 🟢 create file flag constant; only accepted by the dedicated redirection write-open path. - "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. - "os.O_TRUNC", // 🟢 truncate file flag constant; only accepted by the dedicated redirection write-open path. - "os.O_WRONLY", // 🟢 write-only file flag constant; only accepted by the dedicated redirection write-open path. - "os.NewFile", // 🟠 wraps a sandbox-opened file descriptor after fd-relative openat validation; does not open paths itself. - "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. - "os.PathError", // 🟢 error type wrapping path and operation; pure type. - "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. - "os.Stat", // 🟠 returns file info for a path; needed for sandbox path validation. - "path/filepath.Abs", // 🟢 returns absolute path; pure path computation. - "path/filepath.Clean", // 🟢 normalizes a path; pure function, no I/O. - "path/filepath.Dir", // 🟢 returns directory portion of a path; pure function, no I/O. - "path/filepath.EvalSymlinks", // 🟠 resolves symlinks via os.Lstat; the sandbox uses this at setup time to record canonical root paths so builtins like `pwd -P` can reflect the symlink resolution that os.Root has implicitly followed. - "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. - "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. - "path/filepath.Rel", // 🟢 returns relative path; pure path computation. - "path/filepath.Separator", // 🟢 OS path separator constant; pure constant. - "slices.SortFunc", // 🟢 sorts a slice with a comparison function; pure function, no I/O. - "sync.Once", // 🟢 ensures one-time execution; used to close file descriptors at most once. - "strings.Compare", // 🟢 compares two strings lexicographically; pure function, no I/O. - "strings.EqualFold", // 🟢 case-insensitive string comparison; pure function, no I/O. - "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. - "strings.Join", // 🟢 joins string slices; pure function, no I/O. - "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. - "golang.org/x/sys/unix.Close", // 🟠 closes intermediate directory file descriptors opened during fd-relative write-path validation. - "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; normalized to permission denied for no-follow write opens. - "golang.org/x/sys/unix.ENOTDIR", // 🟢 not-a-directory errno constant; normalized when no-follow parent traversal rejects a symlink directory. - "golang.org/x/sys/unix.ENXIO", // 🟢 no-device errno constant; normalized when non-blocking write-open races to a FIFO. - "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag; prevents leaking validation descriptors to child processes. - "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag for parent component traversal. - "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag; rejects symlink parent/final components during write opens. - "golang.org/x/sys/unix.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking if a final component races to a FIFO. - "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag for parent directory traversal. - "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep no-symlink write validation tied to the opened parent directory. - "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. - "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. - "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. - "syscall.Errno", // 🟢 system call error number type; pure type. - "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. - "syscall.Handle", // 🟢 Windows file handle type; pure type alias. - "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. - "syscall.O_NOFOLLOW", // 🟢 no-follow open flag; prevents terminal symlink writes when opening sandboxed write targets. - "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. + "bytes.Buffer", // 🟢 in-memory byte buffer; collects sandbox warnings for deferred output. + "context.Context", // 🟢 context type used to signal cancellation; no I/O or side effects. + "errors.As", // 🟢 error type assertion; pure function, no I/O. + "errors.Is", // 🟢 error comparison; pure function, no I/O. + "errors.New", // 🟢 creates a simple error value; pure function, no I/O. + "fmt.Errorf", // 🟢 formatted error creation; pure function, no I/O. + "fmt.Fprintf", // 🟠 writes warning messages to in-memory buffer during sandbox construction. + "io.EOF", // 🟢 sentinel error value; pure constant. + "io.ReadWriteCloser", // 🟢 combined interface type; no side effects. + "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. + "io/fs.ModeSymlink", // 🟢 file mode bit for symlinks; pure constant. + "io/fs.ErrExist", // 🟢 sentinel error for "already exists"; pure constant. + "io/fs.ErrNotExist", // 🟢 sentinel error for "does not exist"; pure constant. + "io/fs.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. + "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. + "io/fs.FileMode", // 🟢 file permission bits type; pure type. + "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. + "os.DevNull", // 🟢 platform null device path constant; pure constant. + "os.ErrPermission", // 🟢 sentinel error for permission denied; pure constant. + "os.File", // 🟠 file handle returned by os.Root.Open; needed for cross-root symlink fallback. + "os.FileMode", // 🟢 file permission bits type; pure type. + "os.Getgid", // 🟠 returns the numeric group id of the caller; read-only syscall. + "os.Getgroups", // 🟠 returns supplementary group ids; read-only syscall. + "os.Getuid", // 🟠 returns the numeric user id of the caller; read-only syscall. + "os.IsPathSeparator", // 🟢 checks whether a byte is a platform path separator; pure function, no I/O. + "os.O_APPEND", // 🟢 append file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_CREATE", // 🟢 create file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.O_TRUNC", // 🟢 truncate file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_WRONLY", // 🟢 write-only file flag constant; only accepted by the dedicated redirection write-open path. + "os.NewFile", // 🟠 wraps a sandbox-opened file descriptor after fd-relative openat validation; does not open paths itself. + "os.OpenRoot", // 🟠 opens a directory as a root for sandboxed file access; needed for sandbox. + "os.PathError", // 🟢 error type wrapping path and operation; pure type. + "os.Root", // 🟠 sandboxed directory root type; core of the filesystem sandbox. + "os.Stat", // 🟠 returns file info for a path; needed for sandbox path validation. + "path/filepath.Abs", // 🟢 returns absolute path; pure path computation. + "path/filepath.Clean", // 🟢 normalizes a path; pure function, no I/O. + "path/filepath.Dir", // 🟢 returns directory portion of a path; pure function, no I/O. + "path/filepath.EvalSymlinks", // 🟠 resolves symlinks via os.Lstat; the sandbox uses this at setup time to record canonical root paths so builtins like `pwd -P` can reflect the symlink resolution that os.Root has implicitly followed. + "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. + "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. + "path/filepath.Rel", // 🟢 returns relative path; pure path computation. + "path/filepath.Separator", // 🟢 OS path separator constant; pure constant. + "slices.SortFunc", // 🟢 sorts a slice with a comparison function; pure function, no I/O. + "sync.Once", // 🟢 ensures one-time execution; used to close file descriptors at most once. + "strings.Compare", // 🟢 compares two strings lexicographically; pure function, no I/O. + "strings.EqualFold", // 🟢 case-insensitive string comparison; pure function, no I/O. + "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. + "strings.Join", // 🟢 joins string slices; pure function, no I/O. + "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. + "golang.org/x/sys/unix.Close", // 🟠 closes intermediate directory file descriptors opened during fd-relative write-path validation. + "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; normalized to permission denied for no-follow write opens. + "golang.org/x/sys/unix.ENOTDIR", // 🟢 not-a-directory errno constant; normalized when no-follow parent traversal rejects a symlink directory. + "golang.org/x/sys/unix.ENXIO", // 🟢 no-device errno constant; normalized when non-blocking write-open races to a FIFO. + "golang.org/x/sys/unix.O_CLOEXEC", // 🟢 close-on-exec open flag; prevents leaking validation descriptors to child processes. + "golang.org/x/sys/unix.O_DIRECTORY", // 🟢 directory-only open flag for parent component traversal. + "golang.org/x/sys/unix.O_NOFOLLOW", // 🟢 no-follow open flag; rejects symlink parent/final components during write opens. + "golang.org/x/sys/unix.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking if a final component races to a FIFO. + "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag for parent directory traversal. + "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep no-symlink write validation tied to the opened parent directory. + "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. + "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. + "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. + "syscall.Errno", // 🟢 system call error number type; pure type. + "syscall.FILE_FLAG_OPEN_REPARSE_POINT", // 🟢 Windows no-follow open flag; opens reparse points themselves so sandbox write opens can reject them without following. + "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. + "syscall.Handle", // 🟢 Windows file handle type; pure type alias. + "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. + "syscall.O_NOFOLLOW", // 🟢 no-follow open flag; prevents terminal symlink writes when opening sandboxed write targets. + "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. } diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 4c87a214b..5fe9772d7 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -55,6 +55,12 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { r.cmdCallFields(ctx, cm, callFields) } } + if r.exit.ok() { + if err := r.preflightFileBackedFdDupRedirects(st.Redirs); err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } + } for _, rd := range st.Redirs { if !r.exit.ok() { break diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 35e212fbe..08321d6b9 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -200,6 +200,70 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, return pr, nil } +func stderrFileDupToFileRedirectError(target string) error { + return fmt.Errorf("2>&%s: stderr file redirection via fd duplication is not supported", target) +} + +// preflightFileBackedFdDupRedirects rejects unsupported fd duplication before +// any earlier redirect in the same statement can create or truncate a file. +func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { + stdoutFileRedirect := r.stdoutFileRedirect + stderrFileRedirect := r.stderrFileRedirect + for _, rd := range redirs { + switch rd.Op { + case syntax.RdrOut, syntax.AppOut: + arg := r.literal(rd.Word) + if !r.exit.ok() { + return nil + } + if rd.N != nil && rd.N.Value == "2" { + stderrFileRedirect = !isDevNull(arg) + } else { + stdoutFileRedirect = !isDevNull(arg) + } + case syntax.ClbOut: + if rd.N != nil && rd.N.Value == "2" { + stderrFileRedirect = false + } else { + stdoutFileRedirect = false + } + case syntax.RdrAll, syntax.AppAll: + arg := r.literal(rd.Word) + if !r.exit.ok() { + return nil + } + if isDevNull(arg) { + stdoutFileRedirect = false + stderrFileRedirect = false + } + case syntax.DplOut: + arg := r.literal(rd.Word) + if !r.exit.ok() { + return nil + } + var targetFileRedirect bool + switch arg { + case "1": + targetFileRedirect = stdoutFileRedirect + case "2": + targetFileRedirect = stderrFileRedirect + default: + continue + } + redirectsStderr := rd.N != nil && rd.N.Value == "2" + if redirectsStderr && targetFileRedirect { + return stderrFileDupToFileRedirectError(arg) + } + if redirectsStderr { + stderrFileRedirect = targetFileRedirect + } else { + stdoutFileRedirect = targetFileRedirect + } + } + } + return nil +} + func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { if rd.Hdoc != nil { pr, err := r.hdocReader(ctx, rd) @@ -311,8 +375,9 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err return nil, fmt.Errorf(">&%s: unsupported fd", arg) } if redirectsStderr && targetFileRedirect { - r.errf("2>&%s: stderr file redirection via fd duplication is not supported\n", arg) - return nil, fmt.Errorf("2>&%s: stderr file redirection via fd duplication is not supported", arg) + err := stderrFileDupToFileRedirectError(arg) + r.errf("%s\n", err) + return nil, err } *orig = target *origFileRedirect = targetFileRedirect diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 3b74fbc18..6ae22737a 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -154,13 +154,14 @@ func TestRedirDupStderrToStdout(t *testing.T) { func TestRedirDupStderrToStdoutFileRejected(t *testing.T) { dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "output.txt"), []byte("keep\n"), 0644)) stdout, stderr, code := redirRun(t, "cat nonexistent > output.txt 2>&1", dir) assert.Equal(t, 1, code) assert.Equal(t, "", stdout) assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") data, err := os.ReadFile(filepath.Join(dir, "output.txt")) require.NoError(t, err) - assert.NotContains(t, string(data), "nonexistent") + assert.Equal(t, "keep\n", string(data)) require.NoError(t, os.WriteFile(filepath.Join(dir, "append.txt"), []byte("keep\n"), 0644)) stdout, stderr, code = redirRun(t, "cat nonexistent >> append.txt 2>&1", dir) @@ -170,6 +171,13 @@ func TestRedirDupStderrToStdoutFileRejected(t *testing.T) { data, err = os.ReadFile(filepath.Join(dir, "append.txt")) require.NoError(t, err) assert.Equal(t, "keep\n", string(data)) + + stdout, stderr, code = redirRun(t, "cat nonexistent > created.txt 2>&1", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + _, err = os.Stat(filepath.Join(dir, "created.txt")) + require.ErrorIs(t, err, os.ErrNotExist) } func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { diff --git a/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml index 70512ca25..d49b1c9ef 100644 --- a/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml +++ b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml @@ -14,6 +14,7 @@ input: expect: stdout: |+ status=1 - stderr_contains: - - "stderr file redirection via fd duplication is not supported" + keep + stderr: |+ + 2>&1: stderr file redirection via fd duplication is not supported exit_code: 0 From 9bd0812a6f85a5d47b22345ef4bea64062db9790 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 12:37:19 -0400 Subject: [PATCH 19/36] Cap stdout file redirection output --- interp/api.go | 39 ++++++++++------- interp/runner_redir.go | 69 +++++++++++++++++++++++++++++- interp/tests/redir_devnull_test.go | 66 +++++++++++++++++++++++----- 3 files changed, 146 insertions(+), 28 deletions(-) diff --git a/interp/api.go b/interp/api.go index 4fa16b94a..efd072fa7 100644 --- a/interp/api.go +++ b/interp/api.go @@ -209,6 +209,11 @@ type runnerState struct { // (including concurrent pipe subshells) via pointer, and must be // accessed atomically. globReadDirCount *atomic.Int64 + + // redirectOutputLimit tracks bytes written through stdout file redirects. + // It is shared with subshells so redirected output produced inside + // pipelines is still visible to Run(). + redirectOutputLimit *redirectOutputLimit } // A Runner interprets shell programs. It can be reused, but it is not safe for @@ -604,6 +609,7 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { r.runStdout = r.stdout r.startTime = time.Now() r.globReadDirCount = &atomic.Int64{} + r.redirectOutputLimit = &redirectOutputLimit{limit: maxStdoutBytes} r.fillExpandConfig(ctx) if err := validateNode(node); err != nil { fmt.Fprintln(r.stderr, err) @@ -628,7 +634,7 @@ func (r *Runner) Run(ctx context.Context, node syntax.Node) (retErr error) { if err := r.exit.err; err != nil { return err } - if stdoutCap.isExceeded() { + if stdoutCap.isExceeded() || (r.redirectOutputLimit != nil && r.redirectOutputLimit.isExceeded()) { return ErrOutputLimitExceeded } if code := r.exit.code; code != 0 { @@ -856,21 +862,22 @@ func (r *Runner) subshell(background bool) *Runner { r2 := &Runner{ runnerConfig: r.runnerConfig, runnerState: runnerState{ - Dir: r.Dir, - Params: r.Params, - stdin: r.stdin, - stdout: r.stdout, - stderr: r.stderr, - stdoutFileRedirect: r.stdoutFileRedirect, - stderrFileRedirect: r.stderrFileRedirect, - runStdin: r.runStdin, - runStdout: r.runStdout, - inPipeline: r.inPipeline, - filename: r.filename, - exit: r.exit, - lastExit: r.lastExit, - startTime: r.startTime, - globReadDirCount: r.globReadDirCount, + Dir: r.Dir, + Params: r.Params, + stdin: r.stdin, + stdout: r.stdout, + stderr: r.stderr, + stdoutFileRedirect: r.stdoutFileRedirect, + stderrFileRedirect: r.stderrFileRedirect, + runStdin: r.runStdin, + runStdout: r.runStdout, + inPipeline: r.inPipeline, + filename: r.filename, + exit: r.exit, + lastExit: r.lastExit, + startTime: r.startTime, + globReadDirCount: r.globReadDirCount, + redirectOutputLimit: r.redirectOutputLimit, }, } r2.writeEnv = newOverlayEnviron(r.writeEnv, background) diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 08321d6b9..45546336c 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -12,6 +12,7 @@ import ( "io" "os" "strings" + "sync" "mvdan.cc/sh/v3/syntax" ) @@ -204,6 +205,69 @@ func stderrFileDupToFileRedirectError(target string) error { return fmt.Errorf("2>&%s: stderr file redirection via fd duplication is not supported", target) } +type writeCloser interface { + io.Writer + io.Closer +} + +type redirectOutputLimit struct { + mu sync.Mutex + limit int64 + n int64 + exceeded bool +} + +func (l *redirectOutputLimit) write(w io.Writer, p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.n >= l.limit { + l.exceeded = true + return len(p), nil + } + remaining := l.limit - l.n + if int64(len(p)) > remaining { + if _, err := w.Write(p[:remaining]); err != nil { + return int(remaining), err + } + l.n = l.limit + l.exceeded = true + return len(p), nil + } + n, err := w.Write(p) + l.n += int64(n) + return n, err +} + +func (l *redirectOutputLimit) isExceeded() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.exceeded +} + +type cappedRedirectFile struct { + file writeCloser + limit *redirectOutputLimit +} + +func (f *cappedRedirectFile) Write(p []byte) (int, error) { + if f.limit == nil { + return f.file.Write(p) + } + return f.limit.write(f.file, p) +} + +func (f *cappedRedirectFile) Close() error { + return f.file.Close() +} + +func (r *Runner) cappedRedirectWriter(f writeCloser) writeCloser { + w := &cappedRedirectFile{ + file: f, + limit: r.redirectOutputLimit, + } + return w +} + // preflightFileBackedFdDupRedirects rejects unsupported fd duplication before // any earlier redirect in the same statement can create or truncate a file. func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { @@ -327,9 +391,10 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err if err != nil { return nil, err } - *orig = f + capped := r.cappedRedirectWriter(f) + *orig = capped *origFileRedirect = true - return f, nil + return capped, nil } *orig = io.Discard *origFileRedirect = false diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 6ae22737a..40602ce5b 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -37,6 +37,21 @@ func redirRunNoAllowed(t *testing.T, script, dir string) (string, string, int) { } func redirRunWithOpts(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, int) { + t.Helper() + stdout, stderr, err := redirRunErr(t, script, dir, opts...) + exitCode := 0 + if err != nil { + var es interp.ExitStatus + if errors.As(err, &es) { + exitCode = int(es) + } else { + t.Fatalf("unexpected error: %v", err) + } + } + return stdout, stderr, exitCode +} + +func redirRunErr(t *testing.T, script, dir string, opts ...interp.RunnerOption) (string, string, error) { t.Helper() parser := syntax.NewParser() prog, err := parser.Parse(strings.NewReader(script), "") @@ -54,16 +69,7 @@ func redirRunWithOpts(t *testing.T, script, dir string, opts ...interp.RunnerOpt } err = runner.Run(context.Background(), prog) - exitCode := 0 - if err != nil { - var es interp.ExitStatus - if errors.As(err, &es) { - exitCode = int(es) - } else { - t.Fatalf("unexpected error: %v", err) - } - } - return outBuf.String(), errBuf.String(), exitCode + return outBuf.String(), errBuf.String(), err } // --- Stdout redirect to /dev/null --- @@ -256,6 +262,46 @@ func TestRedirAppendToFile(t *testing.T) { assert.Equal(t, "old\nnew\n", string(data)) } +func TestRedirStdoutToFileRespectsOutputCap(t *testing.T) { + dir := t.TempDir() + content := strings.Repeat("A", 1<<20) + require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(content), 0644)) + + script := "cat" + strings.Repeat(" mb.txt", 11) + " > output.txt" + stdout, stderr, err := redirRunErr(t, script, dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption)) + assert.ErrorIs(t, err, interp.ErrOutputLimitExceeded) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + + info, statErr := os.Stat(filepath.Join(dir, "output.txt")) + require.NoError(t, statErr) + assert.Equal(t, int64(10*1024*1024), info.Size()) +} + +func TestRedirStdoutToFilesShareOutputCap(t *testing.T) { + dir := t.TempDir() + content := strings.Repeat("A", 1<<20) + require.NoError(t, os.WriteFile(filepath.Join(dir, "mb.txt"), []byte(content), 0644)) + + sixMiB := "cat" + strings.Repeat(" mb.txt", 6) + script := sixMiB + " > one.txt\n" + sixMiB + " > two.txt" + stdout, stderr, err := redirRunErr(t, script, dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption)) + assert.ErrorIs(t, err, interp.ErrOutputLimitExceeded) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + + one, statErr := os.Stat(filepath.Join(dir, "one.txt")) + require.NoError(t, statErr) + assert.Equal(t, int64(6*1024*1024), one.Size()) + two, statErr := os.Stat(filepath.Join(dir, "two.txt")) + require.NoError(t, statErr) + assert.Equal(t, int64(4*1024*1024), two.Size()) +} + func TestRedirDeniedCommandDoesNotCreateOrModifyFile(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "output.txt") From decdf166918028ded83b9787645970c852c4a3e2 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 12:45:28 -0400 Subject: [PATCH 20/36] Avoid duplicate redirect target expansion --- interp/runner_redir.go | 36 ++++++++++++++++++------------ interp/tests/redir_devnull_test.go | 16 +++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 45546336c..a5481589d 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -276,14 +276,10 @@ func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) er for _, rd := range redirs { switch rd.Op { case syntax.RdrOut, syntax.AppOut: - arg := r.literal(rd.Word) - if !r.exit.ok() { - return nil - } if rd.N != nil && rd.N.Value == "2" { - stderrFileRedirect = !isDevNull(arg) + stderrFileRedirect = !redirectTargetIsDevNull(rd) } else { - stdoutFileRedirect = !isDevNull(arg) + stdoutFileRedirect = !redirectTargetIsDevNull(rd) } case syntax.ClbOut: if rd.N != nil && rd.N.Value == "2" { @@ -292,18 +288,14 @@ func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) er stdoutFileRedirect = false } case syntax.RdrAll, syntax.AppAll: - arg := r.literal(rd.Word) - if !r.exit.ok() { - return nil - } - if isDevNull(arg) { + if redirectTargetIsDevNull(rd) { stdoutFileRedirect = false stderrFileRedirect = false } case syntax.DplOut: - arg := r.literal(rd.Word) - if !r.exit.ok() { - return nil + arg, ok := literalRedirectTargetFD(rd) + if !ok { + continue } var targetFileRedirect bool switch arg { @@ -328,6 +320,22 @@ func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) er return nil } +func literalRedirectTargetFD(rd *syntax.Redirect) (string, bool) { + if rd.Word == nil || len(rd.Word.Parts) != 1 { + return "", false + } + lit, ok := rd.Word.Parts[0].(*syntax.Lit) + if !ok { + return "", false + } + switch lit.Value { + case "1", "2": + return lit.Value, true + default: + return "", false + } +} + func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { if rd.Hdoc != nil { pr, err := r.hdocReader(ctx, rd) diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 40602ce5b..31e053184 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -302,6 +302,22 @@ func TestRedirStdoutToFilesShareOutputCap(t *testing.T) { assert.Equal(t, int64(4*1024*1024), two.Size()) } +func TestRedirTargetCommandSubstitutionRunsOnce(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `echo data > "$(echo hit >> marker; echo output.txt)"`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + + marker, err := os.ReadFile(filepath.Join(dir, "marker")) + require.NoError(t, err) + assert.Equal(t, "hit\n", string(marker)) + output, err := os.ReadFile(filepath.Join(dir, "output.txt")) + require.NoError(t, err) + assert.Equal(t, "data\n", string(output)) +} + func TestRedirDeniedCommandDoesNotCreateOrModifyFile(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "output.txt") From 640efa596d6bd580e948b9b13365a9e04cb59f9b Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 12:54:37 -0400 Subject: [PATCH 21/36] Preflight fd redirects before command expansion --- interp/runner_exec.go | 49 +++++++++++++++--------------- interp/tests/redir_devnull_test.go | 13 ++++++++ 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 5fe9772d7..f868d2584 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -41,37 +41,36 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callFields []string callPrechecked bool ) + if err := r.preflightFileBackedFdDupRedirects(st.Redirs); err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } // Destructive stdout redirects must not be opened until the command name // has passed AllowedCommands. Otherwise a blocked command could still // create or truncate files inside AllowedPaths. - if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { - callExpr = cm - callFields = r.expandCallFields(cm) - callPrechecked = true - if len(callFields) == 0 { - r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) - r.exit.code = 2 - } else if !r.commandAllowed(callFields[0]) { - r.cmdCallFields(ctx, cm, callFields) - } - } if r.exit.ok() { - if err := r.preflightFileBackedFdDupRedirects(st.Redirs); err != nil { - r.errf("%s\n", err) - r.exit.code = 1 + if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { + callExpr = cm + callFields = r.expandCallFields(cm) + callPrechecked = true + if len(callFields) == 0 { + r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) + r.exit.code = 2 + } else if !r.commandAllowed(callFields[0]) { + r.cmdCallFields(ctx, cm, callFields) + } } } - for _, rd := range st.Redirs { - if !r.exit.ok() { - break - } - cls, err := r.redir(ctx, rd) - if err != nil { - r.exit.code = 1 - break - } - if cls != nil { - defer cls.Close() + if r.exit.ok() { + for _, rd := range st.Redirs { + cls, err := r.redir(ctx, rd) + if err != nil { + r.exit.code = 1 + break + } + if cls != nil { + defer cls.Close() + } } } if r.exit.ok() && st.Cmd != nil { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 31e053184..6f6f90abc 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -186,6 +186,19 @@ func TestRedirDupStderrToStdoutFileRejected(t *testing.T) { require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToStdoutFileRejectedBeforeCommandExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `echo "$(echo side | write_file side.txt)" > output.txt 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From 6ca2e2aca9423850d9c7a7fb94f0cc9135ed48af Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:03:44 -0400 Subject: [PATCH 22/36] Handle dynamic devnull fd redirects --- interp/runner_exec.go | 5 +- interp/runner_redir.go | 84 +++++++++++++++++++++++------- interp/tests/redir_devnull_test.go | 22 ++++++++ 3 files changed, 90 insertions(+), 21 deletions(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index f868d2584..ef4dc1aee 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -41,7 +41,8 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callFields []string callPrechecked bool ) - if err := r.preflightFileBackedFdDupRedirects(st.Redirs); err != nil { + redirectArgs, err := r.preflightFileBackedFdDupRedirects(st.Redirs) + if err != nil { r.errf("%s\n", err) r.exit.code = 1 } @@ -63,7 +64,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { } if r.exit.ok() { for _, rd := range st.Redirs { - cls, err := r.redir(ctx, rd) + cls, err := r.redir(ctx, rd, redirectArgs) if err != nil { r.exit.code = 1 break diff --git a/interp/runner_redir.go b/interp/runner_redir.go index a5481589d..1ec454aba 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -268,56 +268,99 @@ func (r *Runner) cappedRedirectWriter(f writeCloser) writeCloser { return w } +type preflightFDState struct { + known bool + fileRedirect bool + source *syntax.Redirect +} + // preflightFileBackedFdDupRedirects rejects unsupported fd duplication before // any earlier redirect in the same statement can create or truncate a file. -func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { - stdoutFileRedirect := r.stdoutFileRedirect - stderrFileRedirect := r.stderrFileRedirect +func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + stdoutState := preflightFDState{known: true, fileRedirect: r.stdoutFileRedirect} + stderrState := preflightFDState{known: true, fileRedirect: r.stderrFileRedirect} + redirectArgs := make(map[*syntax.Redirect]string) for _, rd := range redirs { switch rd.Op { case syntax.RdrOut, syntax.AppOut: + state := preflightRedirectTargetState(rd) if rd.N != nil && rd.N.Value == "2" { - stderrFileRedirect = !redirectTargetIsDevNull(rd) + stderrState = state } else { - stdoutFileRedirect = !redirectTargetIsDevNull(rd) + stdoutState = state } case syntax.ClbOut: if rd.N != nil && rd.N.Value == "2" { - stderrFileRedirect = false + stderrState = preflightFDState{known: true} } else { - stdoutFileRedirect = false + stdoutState = preflightFDState{known: true} } case syntax.RdrAll, syntax.AppAll: if redirectTargetIsDevNull(rd) { - stdoutFileRedirect = false - stderrFileRedirect = false + stdoutState = preflightFDState{known: true} + stderrState = preflightFDState{known: true} } case syntax.DplOut: arg, ok := literalRedirectTargetFD(rd) if !ok { continue } - var targetFileRedirect bool + var targetState preflightFDState switch arg { case "1": - targetFileRedirect = stdoutFileRedirect + targetState = stdoutState case "2": - targetFileRedirect = stderrFileRedirect + targetState = stderrState default: continue } + if !targetState.known && targetState.source != nil { + source := targetState.source + expandedArg, ok := redirectArgs[targetState.source] + if !ok { + expandedArg = r.literal(targetState.source.Word) + redirectArgs[targetState.source] = expandedArg + if !r.exit.ok() { + return redirectArgs, nil + } + } + targetState = preflightFDState{ + known: true, + fileRedirect: !isDevNull(expandedArg), + } + if source == stdoutState.source { + stdoutState = targetState + } + if source == stderrState.source { + stderrState = targetState + } + } redirectsStderr := rd.N != nil && rd.N.Value == "2" - if redirectsStderr && targetFileRedirect { - return stderrFileDupToFileRedirectError(arg) + if redirectsStderr && targetState.fileRedirect { + return redirectArgs, stderrFileDupToFileRedirectError(arg) } if redirectsStderr { - stderrFileRedirect = targetFileRedirect + stderrState = targetState } else { - stdoutFileRedirect = targetFileRedirect + stdoutState = targetState } } } - return nil + return redirectArgs, nil +} + +func preflightRedirectTargetState(rd *syntax.Redirect) preflightFDState { + if rd.Word == nil || len(rd.Word.Parts) != 1 { + return preflightFDState{source: rd} + } + lit, ok := rd.Word.Parts[0].(*syntax.Lit) + if !ok { + return preflightFDState{source: rd} + } + return preflightFDState{ + known: true, + fileRedirect: !isDevNull(lit.Value), + } } func literalRedirectTargetFD(rd *syntax.Redirect) (string, bool) { @@ -336,7 +379,7 @@ func literalRedirectTargetFD(rd *syntax.Redirect) (string, bool) { } } -func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { +func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect, redirectArgs map[*syntax.Redirect]string) (io.Closer, error) { if rd.Hdoc != nil { pr, err := r.hdocReader(ctx, rd) if err != nil { @@ -355,7 +398,10 @@ func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, err return pr, nil } - arg := r.literal(rd.Word) + arg, ok := redirectArgs[rd] + if !ok { + arg = r.literal(rd.Word) + } // Determine which fd this redirect targets (default: stdout for output ops). orig := &r.stdout diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 6f6f90abc..f509211b8 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -199,6 +199,28 @@ func TestRedirDupStderrToStdoutFileRejectedBeforeCommandExpansion(t *testing.T) require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToDynamicStdoutFileRejectedBeforeCommandExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `TARGET=output.txt; echo "$(echo side | write_file side.txt)" > "$TARGET" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestRedirDupStderrToDynamicDevNull(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `TARGET=/dev/null; cat missing > "$TARGET" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From 915f47f7a92f357b14f4dc07e2b9d76e9516f04f Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:16:54 -0400 Subject: [PATCH 23/36] Check literal command policy before fd preflight --- interp/runner_exec.go | 97 +++++++++++++++++++++++++----- interp/tests/redir_devnull_test.go | 18 ++++++ 2 files changed, 100 insertions(+), 15 deletions(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index ef4dc1aee..28c446d39 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -14,6 +14,7 @@ import ( "io/fs" "os" "path/filepath" + "strings" "sync" "mvdan.cc/sh/v3/expand" @@ -41,27 +42,39 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callFields []string callPrechecked bool ) - redirectArgs, err := r.preflightFileBackedFdDupRedirects(st.Redirs) - if err != nil { - r.errf("%s\n", err) - r.exit.code = 1 - } - // Destructive stdout redirects must not be opened until the command name - // has passed AllowedCommands. Otherwise a blocked command could still - // create or truncate files inside AllowedPaths. + + // If the simple command name is statically known, enforce AllowedCommands + // before redirect preflight can expand dynamic redirect targets. if r.exit.ok() { if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { callExpr = cm - callFields = r.expandCallFields(cm) - callPrechecked = true - if len(callFields) == 0 { - r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) - r.exit.code = 2 - } else if !r.commandAllowed(callFields[0]) { - r.cmdCallFields(ctx, cm, callFields) + if name, ok := staticCallName(cm); ok && !r.commandAllowed(name) { + r.call(ctx, cm.Args[0].Pos(), []string{name}) } } } + var redirectArgs map[*syntax.Redirect]string + if r.exit.ok() { + var err error + redirectArgs, err = r.preflightFileBackedFdDupRedirects(st.Redirs) + if err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } + } + // Destructive stdout redirects must not be opened until the command name + // has passed AllowedCommands. Otherwise a blocked command could still + // create or truncate files inside AllowedPaths. + if r.exit.ok() && callExpr != nil { + callFields = r.expandCallFields(callExpr) + callPrechecked = true + if len(callFields) == 0 { + r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) + r.exit.code = 2 + } else if !r.commandAllowed(callFields[0]) { + r.cmdCallFields(ctx, callExpr, callFields) + } + } if r.exit.ok() { for _, rd := range st.Redirs { cls, err := r.redir(ctx, rd, redirectArgs) @@ -111,6 +124,60 @@ func (r *Runner) expandCallFields(cm *syntax.CallExpr) []string { return r.fields(cm.Args...) } +func staticCallName(cm *syntax.CallExpr) (string, bool) { + if len(cm.Args) == 0 { + return "", false + } + return staticWordValue(cm.Args[0]) +} + +func staticWordValue(word *syntax.Word) (string, bool) { + var buf strings.Builder + for _, part := range word.Parts { + value, ok := staticWordPartValue(part) + if !ok { + return "", false + } + buf.WriteString(value) + } + name := buf.String() + return name, name != "" +} + +func staticWordPartValue(part syntax.WordPart) (string, bool) { + switch x := part.(type) { + case *syntax.Lit: + if containsGlobMeta(x.Value) { + return "", false + } + return x.Value, true + case *syntax.SglQuoted: + return x.Value, true + case *syntax.DblQuoted: + var buf strings.Builder + for _, part := range x.Parts { + value, ok := staticWordPartValue(part) + if !ok { + return "", false + } + buf.WriteString(value) + } + return buf.String(), true + default: + return "", false + } +} + +func containsGlobMeta(value string) bool { + for _, r := range value { + switch r { + case '*', '?', '[': + return true + } + } + return false +} + func (r *Runner) cmdCall(ctx context.Context, cm *syntax.CallExpr) { r.cmdCallFields(ctx, cm, r.expandCallFields(cm)) } diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index f509211b8..882e48bf6 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -212,6 +212,24 @@ func TestRedirDupStderrToDynamicStdoutFileRejectedBeforeCommandExpansion(t *test require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToDynamicStdoutFileBlockedCommandRejectedBeforeRedirectExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRunWithOpts(t, + `echo x > "$(printf output.txt; printf side | write_file side.txt >/dev/null)" 2>&1`, + dir, + interp.AllowedPaths([]string{dir}), + interp.AllowedCommands([]string{"rshell:printf", "rshell:write_file"}), + ) + assert.Equal(t, 127, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "rshell: echo: command not allowed") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirDupStderrToDynamicDevNull(t *testing.T) { dir := t.TempDir() From 4b8f735e0fad5cedc05a34483f0023b1f649c599 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:27:57 -0400 Subject: [PATCH 24/36] Check dynamic command policy before fd preflight --- interp/runner_exec.go | 80 +++++++++--------------------- interp/tests/redir_devnull_test.go | 18 +++++++ 2 files changed, 42 insertions(+), 56 deletions(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 28c446d39..b42e9da4e 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -14,7 +14,6 @@ import ( "io/fs" "os" "path/filepath" - "strings" "sync" "mvdan.cc/sh/v3/expand" @@ -38,18 +37,25 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { oldIn, oldOut, oldErr := r.stdin, r.stdout, r.stderr oldOutFile, oldErrFile := r.stdoutFileRedirect, r.stderrFileRedirect var ( - callExpr *syntax.CallExpr - callFields []string - callPrechecked bool + callExpr *syntax.CallExpr + callCommandFields []string + callFields []string + callPrechecked bool ) - // If the simple command name is statically known, enforce AllowedCommands - // before redirect preflight can expand dynamic redirect targets. + // Destructive stdout redirects must not be opened until the command name + // has passed AllowedCommands. Expand only the command word here so argument + // substitutions still cannot run before fd-dup preflight rejects an + // unsupported redirect. if r.exit.ok() { if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { callExpr = cm - if name, ok := staticCallName(cm); ok && !r.commandAllowed(name) { - r.call(ctx, cm.Args[0].Pos(), []string{name}) + callCommandFields = r.expandCallCommandFields(cm) + if len(callCommandFields) == 0 { + r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) + r.exit.code = 2 + } else if !r.commandAllowed(callCommandFields[0]) { + r.call(ctx, cm.Args[0].Pos(), []string{callCommandFields[0]}) } } } @@ -66,7 +72,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { // has passed AllowedCommands. Otherwise a blocked command could still // create or truncate files inside AllowedPaths. if r.exit.ok() && callExpr != nil { - callFields = r.expandCallFields(callExpr) + callFields = r.expandRemainingCallFields(callExpr, callCommandFields) callPrechecked = true if len(callFields) == 0 { r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) @@ -124,58 +130,20 @@ func (r *Runner) expandCallFields(cm *syntax.CallExpr) []string { return r.fields(cm.Args...) } -func staticCallName(cm *syntax.CallExpr) (string, bool) { +func (r *Runner) expandCallCommandFields(cm *syntax.CallExpr) []string { + r.lastExpandExit = exitStatus{} if len(cm.Args) == 0 { - return "", false + return nil } - return staticWordValue(cm.Args[0]) + return r.fields(cm.Args[0]) } -func staticWordValue(word *syntax.Word) (string, bool) { - var buf strings.Builder - for _, part := range word.Parts { - value, ok := staticWordPartValue(part) - if !ok { - return "", false - } - buf.WriteString(value) +func (r *Runner) expandRemainingCallFields(cm *syntax.CallExpr, commandFields []string) []string { + fields := append([]string(nil), commandFields...) + if len(cm.Args) > 1 { + fields = append(fields, r.fields(cm.Args[1:]...)...) } - name := buf.String() - return name, name != "" -} - -func staticWordPartValue(part syntax.WordPart) (string, bool) { - switch x := part.(type) { - case *syntax.Lit: - if containsGlobMeta(x.Value) { - return "", false - } - return x.Value, true - case *syntax.SglQuoted: - return x.Value, true - case *syntax.DblQuoted: - var buf strings.Builder - for _, part := range x.Parts { - value, ok := staticWordPartValue(part) - if !ok { - return "", false - } - buf.WriteString(value) - } - return buf.String(), true - default: - return "", false - } -} - -func containsGlobMeta(value string) bool { - for _, r := range value { - switch r { - case '*', '?', '[': - return true - } - } - return false + return fields } func (r *Runner) cmdCall(ctx context.Context, cm *syntax.CallExpr) { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 882e48bf6..ad4d31143 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -230,6 +230,24 @@ func TestRedirDupStderrToDynamicStdoutFileBlockedCommandRejectedBeforeRedirectEx require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToDynamicStdoutFileDynamicBlockedCommandRejectedBeforeRedirectExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRunWithOpts(t, + `cmd=echo; $cmd x > "$(printf output.txt; printf side | write_file side.txt >/dev/null)" 2>&1`, + dir, + interp.AllowedPaths([]string{dir}), + interp.AllowedCommands([]string{"rshell:printf", "rshell:write_file"}), + ) + assert.Equal(t, 127, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "rshell: echo: command not allowed") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirDupStderrToDynamicDevNull(t *testing.T) { dir := t.TempDir() From b767b33d3e79c0d524671aff9a1f40bf78fa4dd3 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:37:07 -0400 Subject: [PATCH 25/36] Preflight static fd-dup redirects before command expansion --- interp/runner_exec.go | 7 +++++++ interp/runner_redir.go | 13 ++++++++++++- interp/tests/redir_devnull_test.go | 13 +++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index b42e9da4e..a4daf52ec 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -43,6 +43,13 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callPrechecked bool ) + if r.exit.ok() { + if err := r.preflightKnownFileBackedFdDupRedirects(st.Redirs); err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } + } + // Destructive stdout redirects must not be opened until the command name // has passed AllowedCommands. Expand only the command word here so argument // substitutions still cannot run before fd-dup preflight rejects an diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 1ec454aba..a567de20b 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -277,6 +277,17 @@ type preflightFDState struct { // preflightFileBackedFdDupRedirects rejects unsupported fd duplication before // any earlier redirect in the same statement can create or truncate a file. func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, true) +} + +// preflightKnownFileBackedFdDupRedirects rejects statically known unsupported +// fd duplication before command-word expansion can run substitutions. +func (r *Runner) preflightKnownFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { + _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, false) + return err +} + +func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax.Redirect, expandUnknown bool) (map[*syntax.Redirect]string, error) { stdoutState := preflightFDState{known: true, fileRedirect: r.stdoutFileRedirect} stderrState := preflightFDState{known: true, fileRedirect: r.stderrFileRedirect} redirectArgs := make(map[*syntax.Redirect]string) @@ -314,7 +325,7 @@ func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) (m default: continue } - if !targetState.known && targetState.source != nil { + if !targetState.known && targetState.source != nil && expandUnknown { source := targetState.source expandedArg, ok := redirectArgs[targetState.source] if !ok { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index ad4d31143..a4e265875 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -199,6 +199,19 @@ func TestRedirDupStderrToStdoutFileRejectedBeforeCommandExpansion(t *testing.T) require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToStdoutFileRejectedBeforeCommandWordExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `$(printf echo; printf side | write_file side.txt >/dev/null) hi > output.txt 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirDupStderrToDynamicStdoutFileRejectedBeforeCommandExpansion(t *testing.T) { dir := t.TempDir() From 4048913cc2b976d605cb24ca9afb4b467224f4cf Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:46:10 -0400 Subject: [PATCH 26/36] Preflight safe dynamic fd-dup targets before command expansion --- interp/runner_exec.go | 22 ++++++++++++++++++++++ interp/runner_redir.go | 27 +++++++++++++++++++++++---- interp/tests/redir_devnull_test.go | 22 ++++++++++++++++++++++ 3 files changed, 67 insertions(+), 4 deletions(-) diff --git a/interp/runner_exec.go b/interp/runner_exec.go index a4daf52ec..a6a603cdc 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -43,6 +43,15 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { callPrechecked bool ) + if r.exit.ok() { + if cm, ok := st.Cmd.(*syntax.CallExpr); ok && len(cm.Args) > 0 && wordRunsCommands(cm.Args[0]) { + if _, err := r.preflightSafeFileBackedFdDupRedirects(st.Redirs); err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } + } + } + if r.exit.ok() { if err := r.preflightKnownFileBackedFdDupRedirects(st.Redirs); err != nil { r.errf("%s\n", err) @@ -128,6 +137,19 @@ func stmtHasPotentialFileWriteRedirect(st *syntax.Stmt) bool { return false } +func wordRunsCommands(word *syntax.Word) bool { + runsCommands := false + syntax.Walk(word, func(node syntax.Node) bool { + switch node.(type) { + case *syntax.CmdSubst, *syntax.ProcSubst: + runsCommands = true + return false + } + return true + }) + return runsCommands +} + func (r *Runner) commandAllowed(name string) bool { return r.allowAllCommands || r.allowedCommands[name] } diff --git a/interp/runner_redir.go b/interp/runner_redir.go index a567de20b..c39491b7d 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -274,20 +274,36 @@ type preflightFDState struct { source *syntax.Redirect } +type fdDupPreflightMode int + +const ( + fdDupPreflightNoExpansion fdDupPreflightMode = iota + fdDupPreflightSafeExpansion + fdDupPreflightFullExpansion +) + // preflightFileBackedFdDupRedirects rejects unsupported fd duplication before // any earlier redirect in the same statement can create or truncate a file. func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { - return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, true) + return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightFullExpansion) } // preflightKnownFileBackedFdDupRedirects rejects statically known unsupported // fd duplication before command-word expansion can run substitutions. func (r *Runner) preflightKnownFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { - _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, false) + _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightNoExpansion) return err } -func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax.Redirect, expandUnknown bool) (map[*syntax.Redirect]string, error) { +// preflightSafeFileBackedFdDupRedirects expands only redirect targets that +// cannot run command substitutions. It catches dynamic variable targets before +// command-word expansion, while preserving command-policy checks before +// side-effecting redirect-target expansions. +func (r *Runner) preflightSafeFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightSafeExpansion) +} + +func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax.Redirect, mode fdDupPreflightMode) (map[*syntax.Redirect]string, error) { stdoutState := preflightFDState{known: true, fileRedirect: r.stdoutFileRedirect} stderrState := preflightFDState{known: true, fileRedirect: r.stderrFileRedirect} redirectArgs := make(map[*syntax.Redirect]string) @@ -325,8 +341,11 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax default: continue } - if !targetState.known && targetState.source != nil && expandUnknown { + if !targetState.known && targetState.source != nil && mode != fdDupPreflightNoExpansion { source := targetState.source + if mode == fdDupPreflightSafeExpansion && wordRunsCommands(source.Word) { + return redirectArgs, stderrFileDupToFileRedirectError(arg) + } expandedArg, ok := redirectArgs[targetState.source] if !ok { expandedArg = r.literal(targetState.source.Word) diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index a4e265875..bae616875 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -212,6 +212,19 @@ func TestRedirDupStderrToStdoutFileRejectedBeforeCommandWordExpansion(t *testing require.ErrorIs(t, err, os.ErrNotExist) } +func TestRedirDupStderrToDynamicStdoutFileRejectedBeforeCommandWordExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `target=output.txt; $(printf echo; printf side | write_file side.txt >/dev/null) hi > "$target" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "stderr file redirection via fd duplication is not supported") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirDupStderrToDynamicStdoutFileRejectedBeforeCommandExpansion(t *testing.T) { dir := t.TempDir() @@ -270,6 +283,15 @@ func TestRedirDupStderrToDynamicDevNull(t *testing.T) { assert.Equal(t, "", stderr) } +func TestRedirDupStderrToDynamicDevNullWithCommandWordExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `TARGET=/dev/null; $(printf cat) missing > "$TARGET" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From 2da2636cc24c3c6b171f6851094b2c8e0dbf54bd Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 13:54:05 -0400 Subject: [PATCH 27/36] Defer command-substituted fd-dup redirect targets --- interp/runner_redir.go | 6 +++--- interp/tests/redir_devnull_test.go | 9 +++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/interp/runner_redir.go b/interp/runner_redir.go index c39491b7d..48de313e5 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -297,8 +297,8 @@ func (r *Runner) preflightKnownFileBackedFdDupRedirects(redirs []*syntax.Redirec // preflightSafeFileBackedFdDupRedirects expands only redirect targets that // cannot run command substitutions. It catches dynamic variable targets before -// command-word expansion, while preserving command-policy checks before -// side-effecting redirect-target expansions. +// command-word expansion, while leaving side-effecting redirect-target +// expansions for the later command-policy-gated preflight. func (r *Runner) preflightSafeFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightSafeExpansion) } @@ -344,7 +344,7 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax if !targetState.known && targetState.source != nil && mode != fdDupPreflightNoExpansion { source := targetState.source if mode == fdDupPreflightSafeExpansion && wordRunsCommands(source.Word) { - return redirectArgs, stderrFileDupToFileRedirectError(arg) + continue } expandedArg, ok := redirectArgs[targetState.source] if !ok { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index bae616875..4003047d8 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -292,6 +292,15 @@ func TestRedirDupStderrToDynamicDevNullWithCommandWordExpansion(t *testing.T) { assert.Equal(t, "", stderr) } +func TestRedirDupStderrToCommandSubstitutedDevNullWithCommandWordExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `$(printf cat) missing > "$(printf /dev/null)" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From d8436fd973600f58227eec71d8890fd7fffaf94e Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 14:02:47 -0400 Subject: [PATCH 28/36] Preserve redirect expansion order in fd-dup preflight --- interp/runner_redir.go | 27 ++++++++++++++++++++++++++- interp/tests/redir_devnull_test.go | 12 ++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 48de313e5..07cfe2ab0 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -310,7 +310,10 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax for _, rd := range redirs { switch rd.Op { case syntax.RdrOut, syntax.AppOut: - state := preflightRedirectTargetState(rd) + state, ok := r.preflightRedirectTargetState(rd, mode, redirectArgs) + if !ok { + return redirectArgs, nil + } if rd.N != nil && rd.N.Value == "2" { stderrState = state } else { @@ -379,6 +382,28 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax return redirectArgs, nil } +func (r *Runner) preflightRedirectTargetState(rd *syntax.Redirect, mode fdDupPreflightMode, redirectArgs map[*syntax.Redirect]string) (preflightFDState, bool) { + state := preflightRedirectTargetState(rd) + if state.known || state.source == nil || mode == fdDupPreflightNoExpansion { + return state, true + } + if mode == fdDupPreflightSafeExpansion && wordRunsCommands(rd.Word) { + return state, true + } + expandedArg, ok := redirectArgs[rd] + if !ok { + expandedArg = r.literal(rd.Word) + redirectArgs[rd] = expandedArg + if !r.exit.ok() { + return state, false + } + } + return preflightFDState{ + known: true, + fileRedirect: !isDevNull(expandedArg), + }, true +} + func preflightRedirectTargetState(rd *syntax.Redirect) preflightFDState { if rd.Word == nil || len(rd.Word.Parts) != 1 { return preflightFDState{source: rd} diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 4003047d8..5098420bb 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -301,6 +301,18 @@ func TestRedirDupStderrToCommandSubstitutedDevNullWithCommandWordExpansion(t *te assert.Equal(t, "", stderr) } +func TestRedirDupStderrToDynamicDevNullPreservesRedirectExpansionOrder(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, `echo ok > "$(printf first >&2; printf first.txt)" > "$(printf second >&2; printf /dev/null)" 2>&1`, dir) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "firstsecond", stderr) + data, err := os.ReadFile(filepath.Join(dir, "first.txt")) + require.NoError(t, err) + assert.Equal(t, "", string(data)) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From 28593f3bd5398a18780757c846ce7a68cf367d03 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 14:22:42 -0400 Subject: [PATCH 29/36] Preserve redirect short-circuiting in fd-dup preflight --- analysis/symbols_interp.go | 3 + interp/runner_exec.go | 6 +- interp/runner_redir.go | 95 +++++++++++++++++++++++++++--- interp/tests/redir_devnull_test.go | 29 +++++++++ 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/analysis/symbols_interp.go b/analysis/symbols_interp.go index 3ac86d56b..19aa3cf42 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -41,9 +41,11 @@ var interpAllowedSymbols = []string{ "io.Writer", // 🟢 interface type for writing; no side effects. "io/fs.DirEntry", // 🟢 interface type for directory entries; no side effects. "io/fs.FileInfo", // 🟢 interface type for file metadata; no side effects. + "io/fs.ModeSymlink", // 🟢 file mode bit constant for symlinks; pure constant. "io/fs.ReadDirFile", // 🟢 read-only directory handle interface; no write capability. "maps.Insert", // 🟢 inserts all key-value pairs from one map into another; pure function. "os.DirEntry", // 🟢 type alias for fs.DirEntry; no side effects. + "os.ErrNotExist", // 🟢 sentinel error value indicating a file or directory does not exist; read-only constant, no I/O. "os.File", // 🟠 file handle type; interpreter needs file I/O for redirects and pipes. "os.FileMode", // 🟢 file permission bits type; pure type. "os.Getwd", // 🟠 returns current working directory; read-only. @@ -55,6 +57,7 @@ var interpAllowedSymbols = []string{ "os.PathError", // 🟢 error type wrapping path and operation; pure type. "os.Pipe", // 🟠 creates an OS pipe pair; needed for shell pipelines. "path/filepath.Clean", // 🟢 normalizes a path lexically; pure function, no I/O. + "path/filepath.Dir", // 🟢 returns the directory component of a path; pure function, no I/O. "path/filepath.IsAbs", // 🟢 checks if path is absolute; pure function, no I/O. "path/filepath.Join", // 🟢 joins path elements; pure function, no I/O. "path/filepath.ListSeparator", // 🟢 OS-specific path list separator; pure constant. diff --git a/interp/runner_exec.go b/interp/runner_exec.go index a6a603cdc..4bff2d91f 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -45,7 +45,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { if r.exit.ok() { if cm, ok := st.Cmd.(*syntax.CallExpr); ok && len(cm.Args) > 0 && wordRunsCommands(cm.Args[0]) { - if _, err := r.preflightSafeFileBackedFdDupRedirects(st.Redirs); err != nil { + if _, err := r.preflightSafeFileBackedFdDupRedirects(ctx, st.Redirs); err != nil { r.errf("%s\n", err) r.exit.code = 1 } @@ -53,7 +53,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { } if r.exit.ok() { - if err := r.preflightKnownFileBackedFdDupRedirects(st.Redirs); err != nil { + if err := r.preflightKnownFileBackedFdDupRedirects(ctx, st.Redirs); err != nil { r.errf("%s\n", err) r.exit.code = 1 } @@ -78,7 +78,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { var redirectArgs map[*syntax.Redirect]string if r.exit.ok() { var err error - redirectArgs, err = r.preflightFileBackedFdDupRedirects(st.Redirs) + redirectArgs, err = r.preflightFileBackedFdDupRedirects(ctx, st.Redirs) if err != nil { r.errf("%s\n", err) r.exit.code = 1 diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 07cfe2ab0..e3543cc33 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -8,9 +8,12 @@ package interp import ( "bytes" "context" + "errors" "fmt" "io" + "io/fs" "os" + "path/filepath" "strings" "sync" @@ -272,6 +275,7 @@ type preflightFDState struct { known bool fileRedirect bool source *syntax.Redirect + target string } type fdDupPreflightMode int @@ -282,16 +286,18 @@ const ( fdDupPreflightFullExpansion ) +const preflightAccessWrite = 0x02 // allowedpaths.Access write bit. + // preflightFileBackedFdDupRedirects rejects unsupported fd duplication before // any earlier redirect in the same statement can create or truncate a file. -func (r *Runner) preflightFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { - return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightFullExpansion) +func (r *Runner) preflightFileBackedFdDupRedirects(ctx context.Context, redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + return r.preflightFileBackedFdDupRedirectsWithExpansion(ctx, redirs, fdDupPreflightFullExpansion) } // preflightKnownFileBackedFdDupRedirects rejects statically known unsupported // fd duplication before command-word expansion can run substitutions. -func (r *Runner) preflightKnownFileBackedFdDupRedirects(redirs []*syntax.Redirect) error { - _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightNoExpansion) +func (r *Runner) preflightKnownFileBackedFdDupRedirects(ctx context.Context, redirs []*syntax.Redirect) error { + _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(ctx, redirs, fdDupPreflightNoExpansion) return err } @@ -299,21 +305,31 @@ func (r *Runner) preflightKnownFileBackedFdDupRedirects(redirs []*syntax.Redirec // cannot run command substitutions. It catches dynamic variable targets before // command-word expansion, while leaving side-effecting redirect-target // expansions for the later command-policy-gated preflight. -func (r *Runner) preflightSafeFileBackedFdDupRedirects(redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { - return r.preflightFileBackedFdDupRedirectsWithExpansion(redirs, fdDupPreflightSafeExpansion) +func (r *Runner) preflightSafeFileBackedFdDupRedirects(ctx context.Context, redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + return r.preflightFileBackedFdDupRedirectsWithExpansion(ctx, redirs, fdDupPreflightSafeExpansion) } -func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax.Redirect, mode fdDupPreflightMode) (map[*syntax.Redirect]string, error) { +func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(ctx context.Context, redirs []*syntax.Redirect, mode fdDupPreflightMode) (map[*syntax.Redirect]string, error) { stdoutState := preflightFDState{known: true, fileRedirect: r.stdoutFileRedirect} stderrState := preflightFDState{known: true, fileRedirect: r.stderrFileRedirect} redirectArgs := make(map[*syntax.Redirect]string) - for _, rd := range redirs { + lastDplOut := lastFdDupPreflightRedirect(redirs) + if lastDplOut < 0 { + return redirectArgs, nil + } + for i, rd := range redirs { + if i > lastDplOut { + break + } switch rd.Op { case syntax.RdrOut, syntax.AppOut: state, ok := r.preflightRedirectTargetState(rd, mode, redirectArgs) if !ok { return redirectArgs, nil } + if state.known && r.redirectOutputWouldFailBeforeOpen(ctx, rd, state.target) { + return redirectArgs, nil + } if rd.N != nil && rd.N.Value == "2" { stderrState = state } else { @@ -360,6 +376,7 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax targetState = preflightFDState{ known: true, fileRedirect: !isDevNull(expandedArg), + target: expandedArg, } if source == stdoutState.source { stdoutState = targetState @@ -382,6 +399,16 @@ func (r *Runner) preflightFileBackedFdDupRedirectsWithExpansion(redirs []*syntax return redirectArgs, nil } +func lastFdDupPreflightRedirect(redirs []*syntax.Redirect) int { + last := -1 + for i, rd := range redirs { + if rd.Op == syntax.DplOut { + last = i + } + } + return last +} + func (r *Runner) preflightRedirectTargetState(rd *syntax.Redirect, mode fdDupPreflightMode, redirectArgs map[*syntax.Redirect]string) (preflightFDState, bool) { state := preflightRedirectTargetState(rd) if state.known || state.source == nil || mode == fdDupPreflightNoExpansion { @@ -401,6 +428,7 @@ func (r *Runner) preflightRedirectTargetState(rd *syntax.Redirect, mode fdDupPre return preflightFDState{ known: true, fileRedirect: !isDevNull(expandedArg), + target: expandedArg, }, true } @@ -415,7 +443,58 @@ func preflightRedirectTargetState(rd *syntax.Redirect) preflightFDState { return preflightFDState{ known: true, fileRedirect: !isDevNull(lit.Value), + target: lit.Value, + } +} + +// redirectOutputWouldFailBeforeOpen identifies redirects that would stop the +// actual redirect loop before any later redirect word is expanded. The later +// redir call still performs the authoritative sandboxed open. +func (r *Runner) redirectOutputWouldFailBeforeOpen(ctx context.Context, rd *syntax.Redirect, arg string) bool { + if rd.N != nil { + switch rd.N.Value { + case "1": + case "2": + if !isDevNull(arg) { + return true + } + default: + return true + } + } + if isDevNull(arg) { + return false + } + + dir := HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir + info, err := r.sandbox.Lstat(arg, dir) + if err == nil { + if info.Mode()&fs.ModeSymlink != 0 || info.IsDir() || !info.Mode().IsRegular() { + return true + } + return r.sandbox.Access(arg, dir, preflightAccessWrite) != nil + } + if !redirectPathDoesNotExist(err) { + return true + } + + parent := filepath.Dir(arg) + parentInfo, err := r.sandbox.Lstat(parent, dir) + if err != nil { + return true + } + if parentInfo.Mode()&fs.ModeSymlink != 0 || !parentInfo.IsDir() { + return true + } + return r.sandbox.Access(parent, dir, preflightAccessWrite) != nil +} + +func redirectPathDoesNotExist(err error) bool { + if errors.Is(err, os.ErrNotExist) { + return true } + var pathErr *os.PathError + return errors.As(err, &pathErr) && pathErr.Err != nil && pathErr.Err.Error() == "no such file or directory" } func literalRedirectTargetFD(rd *syntax.Redirect) (string, bool) { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 5098420bb..7c533a0a3 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -313,6 +313,35 @@ func TestRedirDupStderrToDynamicDevNullPreservesRedirectExpansionOrder(t *testin assert.Equal(t, "", string(data)) } +func TestRedirPreflightPreservesFailedEarlierRedirectShortCircuit(t *testing.T) { + for _, tt := range []struct { + name string + script string + }{ + { + name: "without_fd_dup", + script: `echo hi > ../blocked-out > "$(printf side | write_file side.txt >/dev/null; printf out2)"`, + }, + { + name: "before_fd_dup", + script: `target=../blocked-out; echo hi > "$target" > "$(printf side | write_file side.txt >/dev/null; printf out2)" 2>&1`, + }, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRun(t, tt.script, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.NotEmpty(t, stderr) + _, err := os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "out2")) + require.ErrorIs(t, err, os.ErrNotExist) + }) + } +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() From 62e1f53b1f242965a584b8409bd2d57d9edc1804 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 14:51:12 -0400 Subject: [PATCH 30/36] Harden remediation commands and file writes --- README.md | 8 +- SHELL_FEATURES.md | 9 +- allowedpaths/sandbox.go | 20 +++- allowedpaths/sandbox_test.go | 22 ++++ allowedpaths/sandbox_unix_test.go | 14 +++ allowedpaths/sandbox_windows_test.go | 16 +++ analysis/symbols_builtins.go | 15 ++- builtins/kill/kill.go | 88 ++++++++-------- builtins/kill/signal_unix.go | 48 +++++++++ builtins/kill/signal_windows.go | 35 +++++++ cmd/rshell/main.go | 58 ++++++++--- cmd/rshell/main_test.go | 41 ++++++++ interp/api.go | 20 +++- interp/remediation_commands_test.go | 146 +++++++++++++++++++++++---- interp/runner.go | 7 ++ interp/runner_exec.go | 52 ++++++---- interp/runner_redir.go | 7 ++ interp/tests/redir_devnull_test.go | 86 ++++++++++++++++ 18 files changed, 578 insertions(+), 114 deletions(-) create mode 100644 builtins/kill/signal_unix.go create mode 100644 builtins/kill/signal_windows.go diff --git a/README.md b/README.md index c666760dd..d02786e27 100644 --- a/README.md +++ b/README.md @@ -59,20 +59,22 @@ Every access path is default-deny: | Resource | Default | Opt-in | |----------------------|-------------------------------------|----------------------------------------------| | Command execution | All commands blocked (exit code 127)| `AllowedCommands` with namespaced command list (e.g. `rshell:cat`) | -| External commands | Blocked (exit code 127) | Provide an `ExecHandler` | +| External commands | Blocked (exit code 127) | Provide an `ExecHandler`; the CLI also wires a guarded host-command path for remediation builtins | | Filesystem access | Blocked | Configure `AllowedPaths` with directory list | | Environment variables| Empty (no host env inherited) | Pass variables via the `Env` option | -| Output redirections | File writes blocked unless `AllowedPaths` permits the target | Simple-command `COMMAND > FILE` / `COMMAND >> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | +| Output redirections | File writes blocked unless `AllowedPaths` permits the target and file writes are enabled | Simple-command `COMMAND > FILE` / `COMMAND >> FILE`, `>/dev/null`, `2>/dev/null`, `&>/dev/null`, `2>&1` | **AllowedCommands** restricts which commands (builtins or external) the interpreter may execute. Commands must be specified with the `rshell:` namespace prefix (e.g. `rshell:cat`, `rshell:echo`). If not set, no commands are allowed. **AllowedPaths** restricts all file operations to specified directories using Go's `os.Root` API (`openat` syscalls), making it immune to symlink traversal, TOCTOU races, and `..` escape attacks. Configured directories that cannot be opened (missing, not a directory, no permission) are skipped with a diagnostic message; by default these messages are flushed once to the runner's stderr at construction time. Callers that need to keep stderr clean of sandbox diagnostics can route them to a dedicated sink with `WarningsWriter(io.Writer)` or retrieve them programmatically via `Runner.Warnings()`. +**DisableFileWrites** blocks filesystem creation and mutation even inside `AllowedPaths`. The API option is `DisableFileWrites()` and the CLI flag is `--disable-file-writes`; read-only file access remains governed by `AllowedPaths`, and redirects to `/dev/null` are still allowed. + > **Note:** The `ss`, `ip route`, and `df` builtins bypass `AllowedPaths` for their kernel-state reads. `ss` and `ip route` open `/proc/net/*` paths directly; `df` reads `/proc/self/mountinfo` (Linux) or calls `getfsstat(2)` (macOS), then issues `unix.Statfs(2)` against every kernel-reported mount point. These paths are hardcoded — never derived from user input — and `Statfs` returns metadata only (block / inode counts, filesystem type, block size). There is no sandbox-escape risk, but operators cannot use `AllowedPaths` to block `ss` from enumerating local sockets, `ip route` from reading the routing table, or `df` from reporting mount-table capacity — these reads succeed regardless of the configured path policy. **ProcPath** (Linux-only) overrides the proc filesystem root used by the `ps` builtin (default `/proc`). This is a privileged option set at runner construction time by trusted caller code — scripts cannot influence it. Access to the proc path is intentionally not subject to `AllowedPaths` restrictions, since proc is a read-only virtual filesystem that does not expose host data under the normal file hierarchy. -**Guarded host commands** (`truncate`, `systemctl`, `kill`, `logrotate`, and `tee`) validate a narrow rshell contract before invoking the host command handler. File-mutating commands hand the handler sandbox-opened descriptors instead of raw writable paths. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. +**Guarded remediation commands** validate a narrow rshell contract before doing host-affecting work. `kill` signals the requested PID directly after validating its single-pid shape. `truncate`, `systemctl`, `logrotate`, and `tee` delegate to a guarded host command handler; file-mutating commands hand the handler sandbox-opened descriptors instead of raw writable paths. When file writes are disabled, those builtins receive no write-open capability and fail before host delegation. The CLI wires that controlled host-command path automatically, while API callers can provide `HostCommandHandler` to integrate their own execution environment. Their command shapes intentionally mirror the remediation primitives exposed by benchmark tooling; broader native command flags remain blocked by rshell argument validation or by the caller-provided handler. **Explicit remediation writes** can use `write_file [--mode overwrite|append] [--json] FILE` when a benchmark wants a command-shaped `write_file` action instead of redirection syntax. The command reads stdin and writes only through `AllowedPaths`. diff --git a/SHELL_FEATURES.md b/SHELL_FEATURES.md index b815645b9..036d9a61e 100644 --- a/SHELL_FEATURES.md +++ b/SHELL_FEATURES.md @@ -24,7 +24,7 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `ip [-o|-4|-6|--brief] addr|link [show] [dev IFNAME]` — show network interface addresses and link-layer info (read-only); write ops (`add`, `del`, `flush`, `set`), namespace ops (`netns`, `-n`), and batch mode (`-b`/`-B`/`--force`) are blocked - ✅ `ip route [show|list]` — show IPv4 routing table (Linux only; reads `/proc/net/route` directly via `os.Open`, bypassing `AllowedPaths`); at most 10 000 entries loaded; lines longer than 1 MiB abort parsing with an error (exit 1) - ✅ `ip route get ADDRESS` — show the route selected by longest-prefix-match for ADDRESS (Linux only); write ops (`add`, `del`, `flush`, `replace`, `change`, `save`, `restore`) are blocked; `-6` (IPv6 routing) is not supported -- ✅ `kill [-9] [--timeout DURATION] [--json] PID` — guarded remediation command; sends SIGTERM or SIGKILL through the host command handler after validating a single positive PID, then polls with `kill -0` until the PID exits or the timeout elapses +- ✅ `kill [-9] [--timeout DURATION] [--json] PID` — guarded remediation command; sends SIGTERM or SIGKILL directly after validating a single positive PID, then polls until the PID exits or the timeout elapses - ✅ `logrotate [--json] PATH` — guarded remediation command; delegates one existing allowed file descriptor to the host command handler, usually a scenario-provided wrapper; `--json` reports before/after size and best-effort rotated path discovery - ✅ `sort [-rnhubfds] [-k KEYDEF] [-t SEP] [-c|-C] [FILE]...` — sort lines of text files; `-h`/`--human-numeric-sort` orders by SI suffix (none < K/k < M < G < T < P < E < Z < Y < R < Q) then by numeric value (single-letter suffixes only — `Ki`, `Mi`, etc. are not recognised); `-o`, `--compress-program`, and `-T` are rejected (filesystem write / exec) - ✅ `ss [-tuaxlans4689Hoehs] [OPTION]...` — display network socket statistics; reads kernel socket state directly via `os.Open` (bypassing `AllowedPaths`) from: Linux: `/proc/net/`; macOS: sysctl; Windows: iphlpapi.dll; `-F`/`--filter` (GTFOBins file-read), `-p`/`--processes` (PID disclosure), `-K`/`--kill`, `-E`/`--events`, and `-N`/`--net` are rejected @@ -87,8 +87,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ `<` — input redirection (read-only, within AllowedPaths) - ✅ `< FILE` — redirect simple-command stdout to FILE, creating/truncating within AllowedPaths -- ✅ `COMMAND >> FILE` — append simple-command stdout to FILE, creating within AllowedPaths +- ✅ `COMMAND > FILE` — redirect simple-command stdout to FILE, creating/truncating within AllowedPaths unless file writes are disabled +- ✅ `COMMAND >> FILE` — append simple-command stdout to FILE, creating within AllowedPaths unless file writes are disabled - ✅ `>/dev/null`, `2>/dev/null` — redirect stdout or stderr to /dev/null (output is discarded; only `/dev/null` is allowed as target) - ✅ `&>/dev/null` — redirect both stdout and stderr to /dev/null - ✅ `>>/dev/null`, `&>>/dev/null` — append redirect to /dev/null (same effect as truncate) @@ -119,7 +119,8 @@ The in-shell `help` command mirrors these feature categories: run `help` for a c - ✅ AllowedCommands — restricts which commands (builtins or external) may be executed; commands require the `rshell:` namespace prefix (e.g. `rshell:cat`); if not set, no commands are allowed - ✅ AllowedPaths filesystem sandboxing — restricts all file access to specified directories -- ✅ Guarded host command handler — remediation builtins (`truncate`, `systemctl`, `kill`, `logrotate`, `tee`) validate their restricted contract before delegating to a caller-provided host command handler; file-mutating commands pass sandbox-opened descriptors via handler context extra files +- ✅ File write disable option — `DisableFileWrites()` / `--disable-file-writes` blocks file creation and mutation through redirects and write-style builtins while preserving read-only AllowedPaths access and `/dev/null` redirects +- ✅ Guarded remediation commands — remediation builtins validate their restricted contract before host-affecting work; `kill` signals the target PID directly, while `truncate`, `systemctl`, `logrotate`, and `tee` delegate to a guarded host command handler. File-mutating commands pass sandbox-opened descriptors via handler context extra files. The CLI wires the controlled host-command path automatically; API callers can provide `HostCommandHandler`. - ✅ Structured remediation receipts — guarded remediation commands accept `--json` where command-specific receipts are useful, while preserving normal shell stdout/stderr behavior by default; captured host stdout/stderr in receipts is capped and reports `stdout_truncated` / `stderr_truncated` when the cap is hit - ✅ Whole-run execution timeout — callers can bound a `Run()` call via `context.Context`, `interp.MaxExecutionTime`, or the CLI `--timeout` flag; the deadline applies to the entire script, not each individual command - ✅ ProcPath — overrides the proc filesystem path used by `ps` (default `/proc`; Linux-only; useful for testing/container environments) diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index e54ba3331..ec98b5e20 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -383,6 +383,24 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo return f, nil } +// ValidateRedirectWritePreflightPath checks the no-follow path-shape +// invariants that OpenForWrite enforces without opening, creating, truncating, +// or appending to the target. This is only for preserving shell redirect +// expansion order; callers must still use OpenForWrite for any actual write. +func (s *Sandbox) ValidateRedirectWritePreflightPath(path string, cwd string) error { + if hasTrailingPathSeparator(path) { + return &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } + + absPath := toAbs(path, cwd) + ar, relPath, ok := s.resolve(absPath) + if !ok { + return &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + return ar.validateWritePath(relPath, true) +} + // OpenExistingForWrite opens an existing file for write through the sandbox // without creating, truncating, or appending. It is used by guarded host // commands that need a stable fd for an already-existing mutation target. @@ -407,7 +425,7 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error func (r *root) validateWritePath(rel string, allowMissingFinal bool) error { rel = filepath.Clean(rel) if rel == "." { - return nil + return &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} } components := strings.Split(rel, string(filepath.Separator)) diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 4142b2f59..2fdf93eb2 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -122,6 +122,28 @@ func TestSandboxOpenForWriteRejectsTrailingSeparator(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestSandboxValidateRedirectWritePreflightPath(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("keep\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + assert.NoError(t, sb.ValidateRedirectWritePreflightPath("existing.txt", dir)) + assert.NoError(t, sb.ValidateRedirectWritePreflightPath("created.txt", dir)) + + err = sb.ValidateRedirectWritePreflightPath(filepath.Join(outside, "evil.txt"), dir) + assert.ErrorIs(t, err, os.ErrPermission) + + err = sb.ValidateRedirectWritePreflightPath("existing.txt"+string(filepath.Separator), dir) + assert.Contains(t, err.Error(), "not a directory") + + err = sb.ValidateRedirectWritePreflightPath(".", dir) + assert.Contains(t, err.Error(), "is a directory") +} + func TestSandboxOpenExistingForWriteAllowsExistingInsideAllowedPaths(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "existing.txt"), []byte("old\n"), 0644)) diff --git a/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go index eb6520902..a11b0a204 100644 --- a/allowedpaths/sandbox_unix_test.go +++ b/allowedpaths/sandbox_unix_test.go @@ -260,6 +260,20 @@ func TestOpenForWriteRejectsSymlinkParentWithinAllowedPath(t *testing.T) { assert.NoFileExists(t, filepath.Join(dir, "real", "new.txt")) } +func TestValidateRedirectWritePreflightPathRejectsSymlinkParentWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "out.txt"), []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("real", filepath.Join(dir, "linkdir"))) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.ValidateRedirectWritePreflightPath(filepath.Join("linkdir", "out.txt"), dir) + assert.ErrorIs(t, err, os.ErrPermission) +} + func TestOpenExistingForWriteRejectsSymlinkTargetWithinAllowedPath(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "target.txt") diff --git a/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go index aed735460..60482932c 100644 --- a/allowedpaths/sandbox_windows_test.go +++ b/allowedpaths/sandbox_windows_test.go @@ -164,3 +164,19 @@ func TestOpenForWriteRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) assert.ErrorIs(t, err, os.ErrPermission) assert.NoFileExists(t, filepath.Join(dir, "real", "new.txt")) } + +func TestValidateRedirectWritePreflightPathRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "out.txt"), []byte("keep\n"), 0644)) + if err := os.Symlink("real", filepath.Join(dir, "linkdir")); err != nil { + t.Skipf("creating symlink: %v", err) + } + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + err = sb.ValidateRedirectWritePreflightPath(filepath.Join("linkdir", "out.txt"), dir) + assert.ErrorIs(t, err, os.ErrPermission) +} diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 690edf66f..fb63700b8 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -196,8 +196,15 @@ var builtinPerCommandSymbols = map[string][]string{ }, "kill": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. - "strconv.FormatInt", // 🟢 int-to-string conversion; pure function, no I/O. + "errors.Is", // 🟢 error comparison; pure function, no I/O. + "fmt.Sprintf", // 🟢 formats structured receipt stderr strings in memory; no I/O. + "os.ErrProcessDone", // 🟢 sentinel error for a finished process handle; pure constant. + "os.FindProcess", // 🟠 obtains an OS process handle for the validated PID; needed for the guarded kill remediation command. "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. + "syscall.ESRCH", // 🟢 POSIX "no such process" errno constant; pure constant. + "syscall.SIGKILL", // 🟠 process termination signal; only used after kill validates a single positive PID. + "syscall.SIGTERM", // 🟠 process termination signal; only used after kill validates a single positive PID. + "syscall.Signal", // 🟠 signal type used for signal 0 liveness probing; no process mutation for signal 0. "time.Duration", // 🟢 duration type; pure integer alias, no I/O. "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. "time.NewTicker", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. @@ -639,8 +646,10 @@ var builtinAllowedSymbols = []string{ "net.Interface", // 🟢 OS network interface descriptor; read-only struct, no network connections. "net.Interfaces", // 🟠 read-only OS interface enumeration function; no network connections or writes. "os.ErrDeadlineExceeded", // 🟢 sentinel error value for *os.File read/write deadline expiry; pure constant. + "os.ErrProcessDone", // 🟢 sentinel error for a finished process handle; pure constant. "os.File", // 🟠 *os.File type, used for type-asserting callCtx.Stdin to access SetReadDeadline/Stat (e.g. read -t timeout, TTY detection); no constructors invoked. "os.FileInfo", // 🟢 file metadata interface returned by Stat; no I/O side effects. + "os.FindProcess", // 🟠 obtains an OS process handle for the guarded kill builtin after strict PID validation. "os.IsNotExist", // 🟢 checks if error is "not exist"; pure function, no I/O. "os.O_RDONLY", // 🟢 read-only file flag constant; cannot open files by itself. "os.PathError", // 🟢 error type for filesystem path errors; pure type, no I/O. @@ -692,10 +701,14 @@ var builtinAllowedSymbols = []string{ "syscall.EISDIR", // 🟢 error number constant for "is a directory"; pure constant, no I/O. "syscall.EPERM", // 🟢 POSIX errno constant for operation not permitted; pure constant, no I/O. "syscall.EPROTONOSUPPORT", // 🟢 POSIX errno constant for protocol not supported; pure constant, no I/O. + "syscall.ESRCH", // 🟢 POSIX errno constant for "no such process"; pure constant, no I/O. "syscall.ENOENT", // 🟢 error constant for "no such file or directory"; pure constant, no I/O. "syscall.Errno", // 🟢 error type for system call error numbers; pure type, no I/O. "syscall.GetFileInformationByHandle", // 🟠 Windows API to query file metadata by handle; read-only, no I/O side effects. "syscall.Handle", // 🟢 Windows file handle type; pure type alias, no I/O. + "syscall.SIGKILL", // 🟠 POSIX kill signal used only by the guarded kill builtin for a validated PID. + "syscall.SIGTERM", // 🟠 POSIX termination signal used only by the guarded kill builtin for a validated PID. + "syscall.Signal", // 🟠 POSIX signal type; used for signal 0 liveness probing and validated kill signals. "syscall.Stat_t", // 🟢 file stat struct for extracting UID/GID/nlink; read-only type, no I/O. "time.Duration", // 🟢 duration type; pure integer alias, no I/O. "time.Hour", // 🟢 constant representing one hour; no side effects. diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index 8f839e1ad..12ed4d861 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -8,6 +8,7 @@ package kill import ( "context" + "fmt" "strconv" "time" @@ -52,25 +53,25 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("kill: invalid timeout: %s\n", timeoutFlag.String()) return builtins.Result{Code: 1} } - argv := []string{strconv.FormatInt(pid, 10)} - if *forceFlag { - argv = []string{"-9", strconv.FormatInt(pid, 10)} - } if *jsonFlag { - return runJSON(ctx, callCtx, pid, *forceFlag, *timeoutFlag, argv) + return runJSON(ctx, callCtx, pid, *forceFlag, *timeoutFlag) } - res := callCtx.InvokeHostCommand(ctx, "kill", argv) - if res.Code != 0 || res.Exiting { - return res + if err := signalPID(pid, *forceFlag); err != nil { + callCtx.Errf("kill: %s\n", err) + return builtins.Result{Code: 1} } - timedOut, waitRes, ok := waitForExit(ctx, callCtx, strconv.FormatInt(pid, 10), *timeoutFlag) + timedOut, waitErr, waitRes, ok := waitForExit(ctx, pid, *timeoutFlag) if !ok { return waitRes } + if waitErr != nil { + callCtx.Errf("kill: %s\n", waitErr) + return builtins.Result{Code: 1} + } if timedOut { callCtx.Errf("kill: timed out waiting for pid %d to exit\n", pid) } - return res + return builtins.Result{} } } @@ -86,43 +87,44 @@ type receipt struct { StderrTruncated bool `json:"stderr_truncated,omitempty"` } -func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, force bool, timeout time.Duration, argv []string) builtins.Result { - host, res, ok := callCtx.CaptureHostCommand(ctx, "kill", argv) - if !ok { - return res - } +func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, force bool, timeout time.Duration) builtins.Result { + exitCode := uint8(0) + stderr := "" timedOut := false - if host.Code == 0 { + if err := signalPID(pid, force); err != nil { + exitCode = 1 + stderr = fmt.Sprintf("kill: %s\n", err) + } else { var waitRes builtins.Result - timedOut, waitRes, ok = waitForExit(ctx, callCtx, strconv.FormatInt(pid, 10), timeout) + var ok bool + var waitErr error + timedOut, waitErr, waitRes, ok = waitForExit(ctx, pid, timeout) if !ok { return waitRes } - } - signal := "SIGTERM" - if force { - signal = "SIGKILL" + if waitErr != nil { + exitCode = 1 + stderr = fmt.Sprintf("kill: %s\n", waitErr) + } } outRes := callCtx.OutJSON(receipt{ - PID: pid, - Force: force, - Signal: signal, - TimedOut: timedOut, - ExitCode: host.Code, - Stdout: host.Stdout, - Stderr: host.Stderr, - StdoutTruncated: host.StdoutTruncated, - StderrTruncated: host.StderrTruncated, + PID: pid, + Force: force, + Signal: signalName(force), + TimedOut: timedOut, + ExitCode: exitCode, + Stdout: "", + Stderr: stderr, }) if outRes.Code != 0 || outRes.Exiting { return outRes } - return builtins.Result{Code: host.Code} + return builtins.Result{Code: exitCode} } -func waitForExit(ctx context.Context, callCtx *builtins.CallContext, pid string, timeout time.Duration) (bool, builtins.Result, bool) { +func waitForExit(ctx context.Context, pid int64, timeout time.Duration) (bool, error, builtins.Result, bool) { if timeout == 0 { - return false, builtins.Result{}, true + return false, nil, builtins.Result{}, true } timer := time.NewTimer(timeout) defer timer.Stop() @@ -130,27 +132,19 @@ func waitForExit(ctx context.Context, callCtx *builtins.CallContext, pid string, defer ticker.Stop() for { - alive, res, ok := processAlive(ctx, callCtx, pid) - if !ok { - return false, res, false + alive, err := pidAlive(pid) + if err != nil { + return false, err, builtins.Result{}, true } if !alive { - return false, builtins.Result{}, true + return false, nil, builtins.Result{}, true } select { case <-ctx.Done(): - return false, builtins.Result{Code: 1, Exiting: true}, false + return false, nil, builtins.Result{Code: 1, Exiting: true}, false case <-timer.C: - return true, builtins.Result{}, true + return true, nil, builtins.Result{}, true case <-ticker.C: } } } - -func processAlive(ctx context.Context, callCtx *builtins.CallContext, pid string) (bool, builtins.Result, bool) { - host, res, ok := callCtx.CaptureHostCommand(ctx, "kill", []string{"-0", pid}) - if !ok { - return false, res, false - } - return host.Code == 0, builtins.Result{}, true -} diff --git a/builtins/kill/signal_unix.go b/builtins/kill/signal_unix.go new file mode 100644 index 000000000..3c8e52afa --- /dev/null +++ b/builtins/kill/signal_unix.go @@ -0,0 +1,48 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package kill + +import ( + "errors" + "os" + "syscall" +) + +func signalPID(pid int64, force bool) error { + proc, err := os.FindProcess(int(pid)) + if err != nil { + return err + } + sig := syscall.SIGTERM + if force { + sig = syscall.SIGKILL + } + return proc.Signal(sig) +} + +func pidAlive(pid int64) (bool, error) { + proc, err := os.FindProcess(int(pid)) + if err != nil { + return false, err + } + err = proc.Signal(syscall.Signal(0)) + if err == nil { + return true, nil + } + if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { + return false, nil + } + return false, err +} + +func signalName(force bool) string { + if force { + return "SIGKILL" + } + return "SIGTERM" +} diff --git a/builtins/kill/signal_windows.go b/builtins/kill/signal_windows.go new file mode 100644 index 000000000..07c3b4a26 --- /dev/null +++ b/builtins/kill/signal_windows.go @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build windows + +package kill + +import "os" + +func signalPID(pid int64, _ bool) error { + proc, err := os.FindProcess(int(pid)) + if err != nil { + return err + } + defer proc.Release() + return proc.Kill() +} + +func pidAlive(pid int64) (bool, error) { + proc, err := os.FindProcess(int(pid)) + if err != nil { + return false, nil + } + defer proc.Release() + return true, nil +} + +func signalName(force bool) string { + if force { + return "SIGKILL" + } + return "SIGTERM" +} diff --git a/cmd/rshell/main.go b/cmd/rshell/main.go index beeacee47..1cb2dfc58 100644 --- a/cmd/rshell/main.go +++ b/cmd/rshell/main.go @@ -13,6 +13,7 @@ import ( "fmt" "io" "os" + "os/exec" "strings" "time" @@ -34,12 +35,13 @@ func main() { func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) int { var ( - command string - allowedPaths string - allowedCommands string - allowAllCmds bool - timeout time.Duration - procPath string + command string + allowedPaths string + allowedCommands string + allowAllCmds bool + disableFileWrites bool + timeout time.Duration + procPath string ) cmd := &cobra.Command{ @@ -81,10 +83,11 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. } execOpts := executeOpts{ - allowedPaths: paths, - allowedCommands: cmds, - allowAllCommands: allowAllCmds, - procPath: procPath, + allowedPaths: paths, + allowedCommands: cmds, + allowAllCommands: allowAllCmds, + disableFileWrites: disableFileWrites, + procPath: procPath, } if commandSet { @@ -135,6 +138,7 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cmd.Flags().StringVarP(&allowedPaths, "allowed-paths", "p", "", "comma-separated list of directories the shell is allowed to access") cmd.Flags().StringVar(&allowedCommands, "allowed-commands", "", "comma-separated list of namespaced commands (e.g. rshell:cat,rshell:find)") cmd.Flags().BoolVar(&allowAllCmds, "allow-all-commands", false, "allow execution of all commands (builtins and external)") + cmd.Flags().BoolVar(&disableFileWrites, "disable-file-writes", false, "disable filesystem writes through redirects and write-style builtins") cmd.Flags().DurationVar(&timeout, "timeout", 0, "maximum execution time for the entire shell run (e.g. 100ms, 5s, 1m)") cmd.Flags().StringVar(&procPath, "proc-path", "", "path to the proc filesystem used by ps (default \"/proc\")") @@ -202,10 +206,11 @@ func rejectLongCommand(rawArgs []string) error { // executeOpts holds options for the execute function. type executeOpts struct { - allowedPaths []string - allowedCommands []string - allowAllCommands bool - procPath string + allowedPaths []string + allowedCommands []string + allowAllCommands bool + disableFileWrites bool + procPath string } func execute(ctx context.Context, script, name string, opts executeOpts, stdin io.Reader, stdout, stderr io.Writer) error { @@ -220,6 +225,7 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i // Build runner options. runOpts := []interp.RunnerOption{ interp.StdIO(stdin, stdout, stderr), + interp.HostCommandHandler(runGuardedHostCommand), } if len(opts.allowedPaths) > 0 { runOpts = append(runOpts, interp.AllowedPaths(opts.allowedPaths)) @@ -232,6 +238,9 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i if opts.procPath != "" { runOpts = append(runOpts, interp.ProcPath(opts.procPath)) } + if opts.disableFileWrites { + runOpts = append(runOpts, interp.DisableFileWrites()) + } runner, err := interp.New(runOpts...) if err != nil { @@ -241,3 +250,24 @@ func execute(ctx context.Context, script, name string, opts executeOpts, stdin i return runner.Run(ctx, prog) } + +func runGuardedHostCommand(ctx context.Context, args []string) error { + if len(args) == 0 { + return fmt.Errorf("host command handler called with no arguments") + } + hc := interp.HandlerCtx(ctx) + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + cmd.Dir = hc.Dir + cmd.Stdin = hc.Stdin + cmd.Stdout = hc.Stdout + cmd.Stderr = hc.Stderr + cmd.ExtraFiles = hc.ExtraFiles + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return interp.ExitStatus(exitErr.ExitCode()) + } + return err + } + return nil +} diff --git a/cmd/rshell/main_test.go b/cmd/rshell/main_test.go index 0ae52aca5..c12003c50 100644 --- a/cmd/rshell/main_test.go +++ b/cmd/rshell/main_test.go @@ -178,10 +178,36 @@ func TestHelp(t *testing.T) { assert.Contains(t, stdout, "--allowed-paths") assert.Contains(t, stdout, "--allowed-commands") assert.Contains(t, stdout, "--allow-all-commands") + assert.Contains(t, stdout, "--disable-file-writes") assert.Contains(t, stdout, "--timeout") assert.NotContains(t, stdout, "--command", "-c/--command should be hidden from help") } +func TestDisableFileWritesFlagBlocksRedirectAndAllowsDevNull(t *testing.T) { + dir := t.TempDir() + + code, stdout, stderr := runCLI(t, + "--allow-all-commands", + "--disable-file-writes", + "-p", dir, + "-c", "echo hello > output.txt", + ) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "file writes disabled") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + + code, stdout, stderr = runCLI(t, + "--allow-all-commands", + "--disable-file-writes", + "-c", "echo hello >/dev/null", + ) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + // TestVersion verifies that --version exits 0 and prints the version. // In tests rshell is the main module, so debug.ReadBuildInfo returns "(devel)" // and the version falls back to "dev". When imported as a library (e.g. by the @@ -287,6 +313,21 @@ func TestAllowAllCommandsFlag(t *testing.T) { assert.Equal(t, "hello\n", stdout) } +func TestCLIExecutesGuardedHostCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test uses a Unix executable script as the fake host command") + } + dir := t.TempDir() + fakeSystemctl := filepath.Join(dir, "systemctl") + require.NoError(t, os.WriteFile(fakeSystemctl, []byte("#!/bin/sh\nprintf '%s\\n' \"$*\"\n"), 0o755)) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + code, stdout, stderr := runCLI(t, "--allow-all-commands", "-c", `systemctl show --property=ActiveState --value app.service`) + assert.Equal(t, 0, code) + assert.Equal(t, "show --property=ActiveState --value -- app.service\n", stdout) + assert.Equal(t, "", stderr) +} + func TestCommandLongFormRejected(t *testing.T) { code, _, stderr := runCLI(t, "--command", "echo hi") assert.NotEqual(t, 0, code) diff --git a/interp/api.go b/interp/api.go index efd072fa7..39dd2b00d 100644 --- a/interp/api.go +++ b/interp/api.go @@ -49,7 +49,7 @@ type runnerConfig struct { execHandlerConfigured bool // hostCommandHandler executes host commands on behalf of guarded builtins - // such as truncate, systemctl, kill, logrotate, and tee. + // such as truncate, systemctl, logrotate, and tee. hostCommandHandler ExecHandlerFunc // hostCommandHandlerConfigured is true when callers explicitly provided a @@ -67,6 +67,11 @@ type runnerConfig struct { // nil (default) blocks all file access; populate via AllowedPaths option. sandbox *allowedpaths.Sandbox + // disableFileWrites blocks all file-writing surfaces even when + // AllowedPaths would otherwise permit them. Read-only file access remains + // governed by sandbox. + disableFileWrites bool + // sandboxWarnings holds diagnostic messages about skipped AllowedPaths // entries. Flushed to warningsWriter after all options are applied and // defaults are set, so the output target is independent of option @@ -699,6 +704,19 @@ func WarningsWriter(w io.Writer) RunnerOption { } } +// DisableFileWrites blocks file creation and mutation through shell output +// redirects and write-style builtins such as write_file, tee, truncate, and +// logrotate. Read-only file access remains governed by [AllowedPaths]. +// +// Redirects to /dev/null remain allowed because they discard output without +// creating or mutating filesystem content. +func DisableFileWrites() RunnerOption { + return func(r *Runner) error { + r.disableFileWrites = true + return nil + } +} + // HostCommandHandler configures the host-command execution capability used by // guarded remediation builtins. The handler receives argv after the builtin has // validated the PAR-shaped command contract. The runner still enforces diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 804f8fc3a..fad64b7b3 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -10,8 +10,10 @@ import ( "encoding/json" "io" "os" + "os/exec" "path/filepath" "runtime" + "strconv" "strings" "testing" "time" @@ -53,6 +55,50 @@ func requireHostExtraFilesSupported(t *testing.T) { } } +type killHelperProcess struct { + cmd *exec.Cmd + waited bool +} + +func startKillHelperProcess(t *testing.T) *killHelperProcess { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=TestKillHelperProcess") + cmd.Env = append(os.Environ(), "RSHELL_KILL_HELPER=1") + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + require.NoError(t, cmd.Start()) + helper := &killHelperProcess{cmd: cmd} + t.Cleanup(func() { + if helper.waited || helper.cmd.Process == nil { + return + } + _ = helper.cmd.Process.Kill() + _ = helper.cmd.Wait() + }) + return helper +} + +func (p *killHelperProcess) waitForExit(t *testing.T) { + t.Helper() + done := make(chan error, 1) + go func() { + done <- p.cmd.Wait() + }() + select { + case <-done: + p.waited = true + case <-time.After(2 * time.Second): + t.Fatalf("kill helper process %d did not exit", p.cmd.Process.Pid) + } +} + +func TestKillHelperProcess(t *testing.T) { + if os.Getenv("RSHELL_KILL_HELPER") != "1" { + return + } + select {} +} + func TestRemediationTruncateDelegatesShrinksOnly(t *testing.T) { requireHostExtraFilesSupported(t) dir := t.TempDir() @@ -463,16 +509,14 @@ func TestRemediationSystemctlRejectsUnsupportedShowShape(t *testing.T) { } } -func TestRemediationKillDelegatesForcePid(t *testing.T) { +func TestRemediationKillTerminatesProcessDirectly(t *testing.T) { dir := t.TempDir() - var got [][]string + helper := startKillHelperProcess(t) + called := false - stdout, stderr, code := runScript(t, "kill -9 123", dir, + stdout, stderr, code := runScript(t, "kill -9 --timeout 0 "+strconv.Itoa(helper.cmd.Process.Pid), dir, interp.HostCommandHandler(func(ctx context.Context, args []string) error { - got = append(got, append([]string(nil), args...)) - if len(args) > 1 && args[1] == "-0" { - return interp.ExitStatus(1) - } + called = true return nil }), ) @@ -480,28 +524,20 @@ func TestRemediationKillDelegatesForcePid(t *testing.T) { assert.Equal(t, 0, code) assert.Equal(t, "", stdout) assert.Equal(t, "", stderr) - assert.Equal(t, [][]string{ - {"kill", "-9", "123"}, - {"kill", "-0", "123"}, - }, got) + assert.False(t, called) + helper.waitForExit(t) } -func TestRemediationKillJSONReportsTimedOut(t *testing.T) { +func TestRemediationKillJSONReportsDirectResult(t *testing.T) { dir := t.TempDir() - var got [][]string + helper := startKillHelperProcess(t) - stdout, stderr, code := runScript(t, "kill --json --timeout 1ms 123", dir, - interp.HostCommandHandler(func(ctx context.Context, args []string) error { - got = append(got, append([]string(nil), args...)) - return nil - }), - ) + stdout, stderr, code := runScript(t, "kill --json --timeout 0 "+strconv.Itoa(helper.cmd.Process.Pid), dir) assert.Equal(t, 0, code) - assert.JSONEq(t, `{"pid":123,"force":false,"signal":"SIGTERM","timed_out":true,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.JSONEq(t, `{"pid":`+strconv.Itoa(helper.cmd.Process.Pid)+`,"force":false,"signal":"SIGTERM","timed_out":false,"exit_code":0,"stdout":"","stderr":""}`, stdout) assert.Equal(t, "", stderr) - assert.NotEmpty(t, got) - assert.Equal(t, []string{"kill", "123"}, got[0]) + helper.waitForExit(t) } func TestRemediationKillRejectsInvalidPid(t *testing.T) { @@ -551,6 +587,72 @@ func TestRemediationWriteFileAppendReportsExistingTarget(t *testing.T) { assert.Equal(t, "old\nnew\n", string(data)) } +func TestDisableFileWritesRemovesWriteCapabilitiesFromRemediationBuiltins(t *testing.T) { + tests := []struct { + name string + script string + errContains string + target string + initial string + }{ + { + name: "write_file", + script: "write_file output.txt <<'EOF'\npayload\nEOF\n", + errContains: "write_file: file write is not available", + target: "output.txt", + }, + { + name: "tee", + script: "tee output.txt <<'EOF'\npayload\nEOF\n", + errContains: "tee: file write is not available", + target: "output.txt", + }, + { + name: "truncate", + script: "truncate -s 0 app.log", + errContains: "truncate: file write is not available", + target: "app.log", + initial: "keep\n", + }, + { + name: "logrotate", + script: "logrotate app.log", + errContains: "logrotate: file write is not available", + target: "app.log", + initial: "keep\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + if tt.initial != "" { + require.NoError(t, os.WriteFile(filepath.Join(dir, tt.target), []byte(tt.initial), 0644)) + } + + stdout, stderr, code := runScript(t, tt.script, dir, + interp.AllowedPaths([]string{dir}), + interp.DisableFileWrites(), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + t.Fatalf("host command should not run with file writes disabled: %v", args) + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, tt.errContains) + data, err := os.ReadFile(filepath.Join(dir, tt.target)) + if tt.initial == "" { + require.ErrorIs(t, err, os.ErrNotExist) + return + } + require.NoError(t, err) + assert.Equal(t, tt.initial, string(data)) + }) + } +} + func TestRemediationTeeDelegatesAppendWithStdin(t *testing.T) { requireHostExtraFilesSupported(t) dir := t.TempDir() diff --git a/interp/runner.go b/interp/runner.go index 80675f89e..bf753692e 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -7,6 +7,7 @@ package interp import ( "context" + "errors" "fmt" "io" "os" @@ -18,6 +19,8 @@ import ( var todoPos syntax.Pos // for handlerCtx callers where we don't yet have a position +var errFileWritesDisabled = errors.New("file writes disabled") + func (r *Runner) handlerCtx(ctx context.Context, pos syntax.Pos) context.Context { return r.handlerCtxWithDir(ctx, pos, r.Dir) } @@ -78,6 +81,10 @@ func (r *Runner) open(ctx context.Context, path string, flags int, mode os.FileM } func (r *Runner) openForWrite(ctx context.Context, path string, flags int, mode os.FileMode) (io.ReadWriteCloser, error) { + if r.disableFileWrites { + r.errf("%s\n", errFileWritesDisabled) + return nil, errFileWritesDisabled + } f, err := r.sandbox.OpenForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, flags, mode) switch err.(type) { case nil: diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 4bff2d91f..6bf8a5d71 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -154,6 +154,28 @@ func (r *Runner) commandAllowed(name string) bool { return r.allowAllCommands || r.allowedCommands[name] } +func (r *Runner) openFileForWriteCapability(dir func(context.Context) string) func(context.Context, string, bool) (*os.File, error) { + if r.disableFileWrites { + return nil + } + return func(ctx context.Context, path string, appendMode bool) (*os.File, error) { + flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC + if appendMode { + flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND + } + return r.sandbox.OpenForWrite(path, dir(ctx), flags, 0666) + } +} + +func (r *Runner) openExistingFileForWriteCapability(dir func(context.Context) string) func(context.Context, string) (*os.File, error) { + if r.disableFileWrites { + return nil + } + return func(ctx context.Context, path string) (*os.File, error) { + return r.sandbox.OpenExistingForWrite(path, dir(ctx)) + } +} + func (r *Runner) expandCallFields(cm *syntax.CallExpr) []string { r.lastExpandExit = exitStatus{} return r.fields(cm.Args...) @@ -717,16 +739,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, - OpenFileForWrite: func(ctx context.Context, path string, appendMode bool) (*os.File, error) { - flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC - if appendMode { - flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND - } - return r.sandbox.OpenForWrite(path, dir, flags, 0666) - }, - OpenExistingFileForWrite: func(ctx context.Context, path string) (*os.File, error) { - return r.sandbox.OpenExistingForWrite(path, dir) - }, + OpenFileForWrite: r.openFileForWriteCapability(func(context.Context) string { return dir }), + OpenExistingFileForWrite: r.openExistingFileForWriteCapability(func(context.Context) string { return dir }), ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { return r.sandbox.ReadDir(path, dir) }, @@ -859,16 +873,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, - OpenFileForWrite: func(ctx context.Context, path string, appendMode bool) (*os.File, error) { - flags := os.O_WRONLY | os.O_CREATE | os.O_TRUNC - if appendMode { - flags = os.O_WRONLY | os.O_CREATE | os.O_APPEND - } - return r.sandbox.OpenForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir, flags, 0666) - }, - OpenExistingFileForWrite: func(ctx context.Context, path string) (*os.File, error) { - return r.sandbox.OpenExistingForWrite(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) - }, + OpenFileForWrite: r.openFileForWriteCapability(func(ctx context.Context) string { + return HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir + }), + OpenExistingFileForWrite: r.openExistingFileForWriteCapability(func(ctx context.Context) string { + return HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir + }), ReadDir: func(ctx context.Context, path string) ([]fs.DirEntry, error) { return r.sandbox.ReadDir(path, HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir) }, @@ -1036,7 +1046,7 @@ func (r *Runner) hostCommandAvailable(name string) bool { func isGuardedHostCommand(name string) bool { switch name { - case "kill", "logrotate", "systemctl", "tee", "truncate": + case "logrotate", "systemctl", "tee", "truncate": return true default: return false diff --git a/interp/runner_redir.go b/interp/runner_redir.go index e3543cc33..ab6e9b18e 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -465,8 +465,15 @@ func (r *Runner) redirectOutputWouldFailBeforeOpen(ctx context.Context, rd *synt if isDevNull(arg) { return false } + if r.disableFileWrites { + return true + } dir := HandlerCtx(r.handlerCtx(ctx, todoPos)).Dir + if r.sandbox.ValidateRedirectWritePreflightPath(arg, dir) != nil { + return true + } + info, err := r.sandbox.Lstat(arg, dir) if err == nil { if info.Mode()&fs.ModeSymlink != 0 || info.IsDir() || !info.Mode().IsRegular() { diff --git a/interp/tests/redir_devnull_test.go b/interp/tests/redir_devnull_test.go index 7c533a0a3..c40984466 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -342,6 +342,28 @@ func TestRedirPreflightPreservesFailedEarlierRedirectShortCircuit(t *testing.T) } } +func TestRedirPreflightRejectsSymlinkParentBeforeLaterExpansion(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior is platform-specific on Windows") + } + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "out.txt"), []byte("keep\n"), 0644)) + require.NoError(t, os.Symlink("real", filepath.Join(dir, "link"))) + + stdout, stderr, code := redirRun(t, `echo hi > link/out.txt > "$(printf side | write_file side.txt >/dev/null; printf out2)" 2>&1`, dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.NotEmpty(t, stderr) + _, err := os.Stat(filepath.Join(dir, "side.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "out2")) + require.ErrorIs(t, err, os.ErrNotExist) + data, err := os.ReadFile(filepath.Join(dir, "real", "out.txt")) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestRedirDupStderrToOriginalStdoutBeforeFileRedirect(t *testing.T) { dir := t.TempDir() @@ -392,6 +414,70 @@ func TestRedirStdoutToFileCreates(t *testing.T) { assert.Equal(t, "hello\n", string(data)) } +func TestDisableFileWritesBlocksFileRedirectsButAllowsDevNull(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRunWithOpts(t, + "echo hello > output.txt", + dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.DisableFileWrites(), + ) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "file writes disabled") + _, err := os.Stat(filepath.Join(dir, "output.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + + target := filepath.Join(dir, "existing.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + stdout, stderr, code = redirRunWithOpts(t, + "echo hello >> existing.txt", + dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.DisableFileWrites(), + ) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "file writes disabled") + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) + + stdout, stderr, code = redirRunWithOpts(t, + "echo hello >/dev/null", + dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.DisableFileWrites(), + ) + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) +} + +func TestDisableFileWritesShortCircuitsLaterRedirectExpansion(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, code := redirRunWithOpts(t, + `echo hi > blocked.txt > "$(printf later >&2; printf out2)"`, + dir, + interp.AllowedPaths([]string{dir}), + interpoption.AllowAllCommands().(interp.RunnerOption), + interp.DisableFileWrites(), + ) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "file writes disabled") + assert.NotContains(t, stderr, "later") + _, err := os.Stat(filepath.Join(dir, "blocked.txt")) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = os.Stat(filepath.Join(dir, "out2")) + require.ErrorIs(t, err, os.ErrNotExist) +} + func TestRedirStdoutToFileOverwrites(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "output.txt"), []byte("old\n"), 0644)) From 65b8ed4f5486837e7aa5b0bc4ddcec0faa2f0317 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 14:56:44 -0400 Subject: [PATCH 31/36] Fix kill PID range validation --- analysis/symbols_builtins.go | 2 +- builtins/kill/kill.go | 8 ++++---- builtins/kill/signal_unix.go | 8 ++++---- builtins/kill/signal_windows.go | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index fb63700b8..b64e0cdb0 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -200,7 +200,7 @@ var builtinPerCommandSymbols = map[string][]string{ "fmt.Sprintf", // 🟢 formats structured receipt stderr strings in memory; no I/O. "os.ErrProcessDone", // 🟢 sentinel error for a finished process handle; pure constant. "os.FindProcess", // 🟠 obtains an OS process handle for the validated PID; needed for the guarded kill remediation command. - "strconv.ParseInt", // 🟢 string-to-int conversion with overflow checking; pure function, no I/O. + "strconv.Atoi", // 🟢 string-to-int conversion with int-range overflow checking; pure function, no I/O. "syscall.ESRCH", // 🟢 POSIX "no such process" errno constant; pure constant. "syscall.SIGKILL", // 🟠 process termination signal; only used after kill validates a single positive PID. "syscall.SIGTERM", // 🟠 process termination signal; only used after kill validates a single positive PID. diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index 12ed4d861..0bc91ac44 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -44,7 +44,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { callCtx.Errf("kill: expected exactly one pid\n") return builtins.Result{Code: 1} } - pid, err := strconv.ParseInt(args[0], 10, 64) + pid, err := strconv.Atoi(args[0]) if err != nil || pid <= 0 { callCtx.Errf("kill: invalid pid: %s\n", args[0]) return builtins.Result{Code: 1} @@ -76,7 +76,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } type receipt struct { - PID int64 `json:"pid"` + PID int `json:"pid"` Force bool `json:"force"` Signal string `json:"signal"` TimedOut bool `json:"timed_out"` @@ -87,7 +87,7 @@ type receipt struct { StderrTruncated bool `json:"stderr_truncated,omitempty"` } -func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, force bool, timeout time.Duration) builtins.Result { +func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int, force bool, timeout time.Duration) builtins.Result { exitCode := uint8(0) stderr := "" timedOut := false @@ -122,7 +122,7 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int64, forc return builtins.Result{Code: exitCode} } -func waitForExit(ctx context.Context, pid int64, timeout time.Duration) (bool, error, builtins.Result, bool) { +func waitForExit(ctx context.Context, pid int, timeout time.Duration) (bool, error, builtins.Result, bool) { if timeout == 0 { return false, nil, builtins.Result{}, true } diff --git a/builtins/kill/signal_unix.go b/builtins/kill/signal_unix.go index 3c8e52afa..31561ad6b 100644 --- a/builtins/kill/signal_unix.go +++ b/builtins/kill/signal_unix.go @@ -13,8 +13,8 @@ import ( "syscall" ) -func signalPID(pid int64, force bool) error { - proc, err := os.FindProcess(int(pid)) +func signalPID(pid int, force bool) error { + proc, err := os.FindProcess(pid) if err != nil { return err } @@ -25,8 +25,8 @@ func signalPID(pid int64, force bool) error { return proc.Signal(sig) } -func pidAlive(pid int64) (bool, error) { - proc, err := os.FindProcess(int(pid)) +func pidAlive(pid int) (bool, error) { + proc, err := os.FindProcess(pid) if err != nil { return false, err } diff --git a/builtins/kill/signal_windows.go b/builtins/kill/signal_windows.go index 07c3b4a26..a5a9fe182 100644 --- a/builtins/kill/signal_windows.go +++ b/builtins/kill/signal_windows.go @@ -9,8 +9,8 @@ package kill import "os" -func signalPID(pid int64, _ bool) error { - proc, err := os.FindProcess(int(pid)) +func signalPID(pid int, _ bool) error { + proc, err := os.FindProcess(pid) if err != nil { return err } @@ -18,8 +18,8 @@ func signalPID(pid int64, _ bool) error { return proc.Kill() } -func pidAlive(pid int64) (bool, error) { - proc, err := os.FindProcess(int(pid)) +func pidAlive(pid int) (bool, error) { + proc, err := os.FindProcess(pid) if err != nil { return false, nil } From 00f123ecce1ea1c2284abe3f1740ee848ffd878a Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 15:04:47 -0400 Subject: [PATCH 32/36] Return failure when kill times out --- builtins/kill/kill.go | 4 ++ interp/remediation_kill_unix_test.go | 89 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 interp/remediation_kill_unix_test.go diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index 0bc91ac44..e7cade8fa 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -70,6 +70,7 @@ func registerFlags(fs *builtins.FlagSet) builtins.HandlerFunc { } if timedOut { callCtx.Errf("kill: timed out waiting for pid %d to exit\n", pid) + return builtins.Result{Code: 1} } return builtins.Result{} } @@ -105,6 +106,9 @@ func runJSON(ctx context.Context, callCtx *builtins.CallContext, pid int, force if waitErr != nil { exitCode = 1 stderr = fmt.Sprintf("kill: %s\n", waitErr) + } else if timedOut { + exitCode = 1 + stderr = fmt.Sprintf("kill: timed out waiting for pid %d to exit\n", pid) } } outRes := callCtx.OutJSON(receipt{ diff --git a/interp/remediation_kill_unix_test.go b/interp/remediation_kill_unix_test.go new file mode 100644 index 000000000..3b57845ac --- /dev/null +++ b/interp/remediation_kill_unix_test.go @@ -0,0 +1,89 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package interp_test + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "os/signal" + "strconv" + "strings" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func startKillIgnoreTermHelperProcess(t *testing.T) *killHelperProcess { + t.Helper() + cmd := exec.Command(os.Args[0], "-test.run=TestKillIgnoreTermHelperProcess") + cmd.Env = append(os.Environ(), "RSHELL_KILL_IGNORE_TERM_HELPER=1") + stdout, err := cmd.StdoutPipe() + require.NoError(t, err) + cmd.Stderr = io.Discard + require.NoError(t, cmd.Start()) + ready, err := bufio.NewReader(stdout).ReadString('\n') + require.NoError(t, err) + require.Equal(t, "ready\n", ready) + helper := &killHelperProcess{cmd: cmd} + t.Cleanup(func() { + if helper.waited || helper.cmd.Process == nil { + return + } + _ = helper.cmd.Process.Kill() + _ = helper.cmd.Wait() + }) + return helper +} + +func TestKillIgnoreTermHelperProcess(t *testing.T) { + if os.Getenv("RSHELL_KILL_IGNORE_TERM_HELPER") != "1" { + return + } + signal.Ignore(syscall.SIGTERM) + fmt.Fprintln(os.Stdout, "ready") + select {} +} + +func TestRemediationKillTimeoutReturnsFailure(t *testing.T) { + dir := t.TempDir() + helper := startKillIgnoreTermHelperProcess(t) + + stdout, stderr, code := runScript(t, "kill --timeout 20ms "+strconv.Itoa(helper.cmd.Process.Pid), dir) + + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "kill: timed out waiting for pid "+strconv.Itoa(helper.cmd.Process.Pid)) +} + +func TestRemediationKillJSONTimeoutReturnsFailureReceipt(t *testing.T) { + dir := t.TempDir() + helper := startKillIgnoreTermHelperProcess(t) + + stdout, stderr, code := runScript(t, "kill --json --timeout 20ms "+strconv.Itoa(helper.cmd.Process.Pid), dir) + + assert.Equal(t, 1, code) + assert.Equal(t, "", stderr) + + var got struct { + PID int `json:"pid"` + TimedOut bool `json:"timed_out"` + ExitCode uint8 `json:"exit_code"` + Stderr string `json:"stderr"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + assert.Equal(t, helper.cmd.Process.Pid, got.PID) + assert.True(t, got.TimedOut) + assert.Equal(t, uint8(1), got.ExitCode) + assert.True(t, strings.HasPrefix(got.Stderr, "kill: timed out waiting for pid ")) +} From ec8487e81d15831844538acc135fbf7266d65f31 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 15:19:21 -0400 Subject: [PATCH 33/36] Detect exited processes during kill wait --- analysis/symbols_builtins.go | 45 +++++++++++++++++++---------- builtins/kill/kill.go | 2 +- builtins/kill/signal_unix.go | 20 +++++++++---- builtins/kill/signal_windows.go | 32 ++++++++++++++++---- interp/remediation_commands_test.go | 12 ++++++++ 5 files changed, 85 insertions(+), 26 deletions(-) diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index b64e0cdb0..ab25169ee 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -195,21 +195,29 @@ var builtinPerCommandSymbols = map[string][]string{ "strconv.ParseInt", // 🟢 string-to-int conversion with base/bit-size; pure function, no I/O. }, "kill": { - "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. - "errors.Is", // 🟢 error comparison; pure function, no I/O. - "fmt.Sprintf", // 🟢 formats structured receipt stderr strings in memory; no I/O. - "os.ErrProcessDone", // 🟢 sentinel error for a finished process handle; pure constant. - "os.FindProcess", // 🟠 obtains an OS process handle for the validated PID; needed for the guarded kill remediation command. - "strconv.Atoi", // 🟢 string-to-int conversion with int-range overflow checking; pure function, no I/O. - "syscall.ESRCH", // 🟢 POSIX "no such process" errno constant; pure constant. - "syscall.SIGKILL", // 🟠 process termination signal; only used after kill validates a single positive PID. - "syscall.SIGTERM", // 🟠 process termination signal; only used after kill validates a single positive PID. - "syscall.Signal", // 🟠 signal type used for signal 0 liveness probing; no process mutation for signal 0. - "time.Duration", // 🟢 duration type; pure integer alias, no I/O. - "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. - "time.NewTicker", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. - "time.NewTimer", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. - "time.Second", // 🟢 constant representing one second; no side effects. + "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. + "errors.Is", // 🟢 error comparison; pure function, no I/O. + "fmt.Errorf", // 🟢 error formatting for unexpected Windows wait states; pure function, no I/O. + "fmt.Sprintf", // 🟢 formats structured receipt stderr strings in memory; no I/O. + "golang.org/x/sys/windows.CloseHandle", // 🟠 closes a process handle opened only for zero-time liveness polling. + "golang.org/x/sys/windows.ERROR_INVALID_PARAMETER", // 🟢 Windows sentinel for a missing/invalid process id; pure constant. + "golang.org/x/sys/windows.OpenProcess", // 🟠 opens a validated PID with SYNCHRONIZE only so kill can poll liveness without mutation. + "golang.org/x/sys/windows.SYNCHRONIZE", // 🟢 Windows process access mask for waiting on process state; pure constant. + "golang.org/x/sys/windows.WAIT_OBJECT_0", // 🟢 Windows wait result meaning the process handle is signaled/exited; pure constant. + "golang.org/x/sys/windows.WAIT_TIMEOUT", // 🟢 Windows wait result meaning the process handle is still running; pure constant. + "golang.org/x/sys/windows.WaitForSingleObject", // 🟠 zero-time wait used to read process liveness state; no process mutation. + "os.ErrProcessDone", // 🟢 sentinel error for a finished process handle; pure constant. + "os.FindProcess", // 🟠 obtains an OS process handle for the validated PID; needed for the guarded kill remediation command. + "strconv.Atoi", // 🟢 string-to-int conversion with int-range overflow checking; pure function, no I/O. + "syscall.ESRCH", // 🟢 POSIX "no such process" errno constant; pure constant. + "syscall.SIGKILL", // 🟠 process termination signal; only used after kill validates a single positive PID. + "syscall.SIGTERM", // 🟠 process termination signal; only used after kill validates a single positive PID. + "syscall.Signal", // 🟠 signal type used for signal 0 liveness probing; no process mutation for signal 0. + "time.Duration", // 🟢 duration type; pure integer alias, no I/O. + "time.Millisecond", // 🟢 constant representing one millisecond; no side effects. + "time.NewTicker", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. + "time.NewTimer", // 🟢 creates an in-memory timer channel for bounded polling; no I/O. + "time.Second", // 🟢 constant representing one second; no side effects. }, "logrotate": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -595,6 +603,13 @@ var builtinAllowedSymbols = []string{ "golang.org/x/sys/unix.Poll", // 🟠 Unix poll(2) with timeout 0; non-consuming readability probe for read -t 0. Read-only descriptor state; no data transferred. "golang.org/x/sys/unix.PollFd", // 🟢 PollFd struct passed to unix.Poll; pure data type, no I/O. "golang.org/x/sys/unix.SysctlRaw", // 🟠 macOS: reads kernel socket tables (read-only, no exec, no filesystem). + "golang.org/x/sys/windows.CloseHandle", // 🟠 closes Windows handles opened by guarded builtins; no data read/write or exec capability. + "golang.org/x/sys/windows.ERROR_INVALID_PARAMETER", // 🟢 Windows sentinel error for invalid/missing process IDs; pure constant. + "golang.org/x/sys/windows.OpenProcess", // 🟠 opens a validated PID with limited requested rights for guarded process polling. + "golang.org/x/sys/windows.SYNCHRONIZE", // 🟢 Windows access-mask constant allowing wait-only process handles; pure constant. + "golang.org/x/sys/windows.WAIT_OBJECT_0", // 🟢 Windows wait result for a signaled handle; pure constant. + "golang.org/x/sys/windows.WAIT_TIMEOUT", // 🟢 Windows wait result for a still-unsignaled handle; pure constant. + "golang.org/x/sys/windows.WaitForSingleObject", // 🟠 zero-time wait to read Windows handle state; no process mutation. "golang.org/x/term.IsTerminal", // 🟠 platform-specific isatty check (TIOCGETA / GetConsoleMode); used to gate read -p prompt emission. Read-only inspection of the file descriptor; no I/O. "io.EOF", // 🟢 sentinel error value; pure constant. "io.LimitReader", // 🟢 wraps a Reader with a byte cap; no I/O beyond reads requested by caller. diff --git a/builtins/kill/kill.go b/builtins/kill/kill.go index e7cade8fa..39fef3f8e 100644 --- a/builtins/kill/kill.go +++ b/builtins/kill/kill.go @@ -136,7 +136,7 @@ func waitForExit(ctx context.Context, pid int, timeout time.Duration) (bool, err defer ticker.Stop() for { - alive, err := pidAlive(pid) + alive, err := pidAlive(ctx, pid) if err != nil { return false, err, builtins.Result{}, true } diff --git a/builtins/kill/signal_unix.go b/builtins/kill/signal_unix.go index 31561ad6b..a32f41b31 100644 --- a/builtins/kill/signal_unix.go +++ b/builtins/kill/signal_unix.go @@ -8,9 +8,12 @@ package kill import ( + "context" "errors" "os" "syscall" + + "github.com/DataDog/rshell/builtins/internal/procinfo" ) func signalPID(pid int, force bool) error { @@ -25,19 +28,26 @@ func signalPID(pid int, force bool) error { return proc.Signal(sig) } -func pidAlive(pid int) (bool, error) { +func pidAlive(ctx context.Context, pid int) (bool, error) { proc, err := os.FindProcess(pid) if err != nil { return false, err } err = proc.Signal(syscall.Signal(0)) - if err == nil { - return true, nil - } if errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { return false, nil } - return false, err + if err != nil { + return false, err + } + + infos, infoErr := procinfo.GetByPIDs(ctx, "", []int{pid}) + if infoErr == nil { + if len(infos) == 0 || infos[0].State == "Z" { + return false, nil + } + } + return true, nil } func signalName(force bool) string { diff --git a/builtins/kill/signal_windows.go b/builtins/kill/signal_windows.go index a5a9fe182..cc4acf013 100644 --- a/builtins/kill/signal_windows.go +++ b/builtins/kill/signal_windows.go @@ -7,7 +7,14 @@ package kill -import "os" +import ( + "context" + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) func signalPID(pid int, _ bool) error { proc, err := os.FindProcess(pid) @@ -18,13 +25,28 @@ func signalPID(pid int, _ bool) error { return proc.Kill() } -func pidAlive(pid int) (bool, error) { - proc, err := os.FindProcess(pid) +func pidAlive(_ context.Context, pid int) (bool, error) { + handle, err := windows.OpenProcess(windows.SYNCHRONIZE, false, uint32(pid)) if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return false, nil + } + return false, err + } + defer windows.CloseHandle(handle) + + event, err := windows.WaitForSingleObject(handle, 0) + if err != nil { + return false, err + } + switch event { + case uint32(windows.WAIT_OBJECT_0): return false, nil + case uint32(windows.WAIT_TIMEOUT): + return true, nil + default: + return false, fmt.Errorf("unexpected wait status for pid %d: %d", pid, event) } - defer proc.Release() - return true, nil } func signalName(force bool) string { diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index fad64b7b3..8b772f2c7 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -528,6 +528,18 @@ func TestRemediationKillTerminatesProcessDirectly(t *testing.T) { helper.waitForExit(t) } +func TestRemediationKillDefaultTimeoutConfirmsProcessExit(t *testing.T) { + dir := t.TempDir() + helper := startKillHelperProcess(t) + + stdout, stderr, code := runScript(t, "kill "+strconv.Itoa(helper.cmd.Process.Pid), dir) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + helper.waitForExit(t) +} + func TestRemediationKillJSONReportsDirectResult(t *testing.T) { dir := t.TempDir() helper := startKillHelperProcess(t) From 453f2a96b058f898127e665abf471897df3926b1 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 15:28:17 -0400 Subject: [PATCH 34/36] Preserve kill cancellation during liveness checks --- builtins/kill/signal_unix.go | 8 +++++++- builtins/kill/signal_unix_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 builtins/kill/signal_unix_test.go diff --git a/builtins/kill/signal_unix.go b/builtins/kill/signal_unix.go index a32f41b31..baaba3f2a 100644 --- a/builtins/kill/signal_unix.go +++ b/builtins/kill/signal_unix.go @@ -43,7 +43,13 @@ func pidAlive(ctx context.Context, pid int) (bool, error) { infos, infoErr := procinfo.GetByPIDs(ctx, "", []int{pid}) if infoErr == nil { - if len(infos) == 0 || infos[0].State == "Z" { + if len(infos) == 0 { + if ctx.Err() != nil { + return true, nil + } + return false, nil + } + if infos[0].State == "Z" { return false, nil } } diff --git a/builtins/kill/signal_unix_test.go b/builtins/kill/signal_unix_test.go new file mode 100644 index 000000000..f85305f7c --- /dev/null +++ b/builtins/kill/signal_unix_test.go @@ -0,0 +1,27 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2026-present Datadog, Inc. + +//go:build !windows + +package kill + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPIDAlivePreservesCanceledContext(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + alive, err := pidAlive(ctx, os.Getpid()) + + require.NoError(t, err) + assert.True(t, alive) +} From 5ce4e4f7985170650eab4b30c6d3d41335f0c0f0 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 15:44:51 -0400 Subject: [PATCH 35/36] Reject Windows reparse points in write path --- allowedpaths/portable_windows.go | 182 ++++++++++++++++++++++++++- allowedpaths/sandbox_windows_test.go | 43 +++++++ analysis/symbols_allowedpaths.go | 69 ++++++++-- 3 files changed, 277 insertions(+), 17 deletions(-) diff --git a/allowedpaths/portable_windows.go b/allowedpaths/portable_windows.go index 89266a993..509a6ccbd 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -10,7 +10,11 @@ import ( "io/fs" "os" "path/filepath" + "strings" "syscall" + "unsafe" + + "golang.org/x/sys/windows" ) // IsErrIsDirectory checks if the error is the Windows equivalent of EISDIR. @@ -88,13 +92,55 @@ func (r *root) accessCheck(rel string, checkRead, checkWrite, checkExec bool) (f } func (r *root) openFileNoFollow(rel string, flag int, perm os.FileMode) (*os.File, error) { - // Keep no-follow on the same open that returns the writable handle; - // a separate pre-check can be raced by swapping in a reparse point. - f, err := r.root.OpenFile(rel, flag|syscall.FILE_FLAG_OPEN_REPARSE_POINT, perm) - if errors.Is(err, syscall.ELOOP) { - return nil, &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + rel = filepath.Clean(rel) + if rel == "." { + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + + rootDir, err := r.root.Open(".") + if err != nil { + return nil, err + } + defer rootDir.Close() + + rootHandle := windows.Handle(rootDir.Fd()) + dirHandle := rootHandle + closeDirHandle := func() { + if dirHandle != rootHandle { + _ = windows.CloseHandle(dirHandle) + dirHandle = rootHandle + } + } + defer closeDirHandle() + + components := strings.Split(rel, string(filepath.Separator)) + for _, component := range components[:len(components)-1] { + if component == "" || component == "." { + continue + } + handle, err := openDirectoryComponentNoFollow(dirHandle, component) + if err != nil { + return nil, noFollowOpenPathError(rel, err) + } + closeDirHandle() + dirHandle = handle + } + + leaf := components[len(components)-1] + if leaf == "" || leaf == "." { + return nil, &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + + handle, err := openFileComponentNoFollow(dirHandle, leaf, flag, perm) + if err != nil { + return nil, noFollowOpenPathError(rel, err) } - return f, err + f := os.NewFile(uintptr(handle), rel) + if f == nil { + _ = windows.CloseHandle(handle) + return nil, &os.PathError{Op: "open", Path: rel, Err: syscall.EINVAL} + } + return f, nil } func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, _ bool) (*os.File, error) { @@ -121,3 +167,127 @@ func (r *root) openFileValidatedNoFollow(rel string, flag int, perm os.FileMode, } return f, nil } + +func openDirectoryComponentNoFollow(dir windows.Handle, name string) (windows.Handle, error) { + return ntCreateFileNoFollow( + dir, + name, + windows.FILE_GENERIC_READ|windows.FILE_LIST_DIRECTORY, + windows.FILE_ATTRIBUTE_NORMAL, + windows.FILE_OPEN, + windows.FILE_DIRECTORY_FILE|windows.FILE_OPEN_FOR_BACKUP_INTENT|windows.FILE_SYNCHRONOUS_IO_NONALERT, + ) +} + +func openFileComponentNoFollow(dir windows.Handle, name string, flag int, perm os.FileMode) (windows.Handle, error) { + access := uint32(0) + options := uint32(windows.FILE_NON_DIRECTORY_FILE | windows.FILE_OPEN_FOR_BACKUP_INTENT | windows.FILE_SYNCHRONOUS_IO_NONALERT) + switch flag & (os.O_RDONLY | os.O_WRONLY | os.O_RDWR) { + case os.O_WRONLY: + access |= windows.FILE_GENERIC_WRITE + case os.O_RDWR: + access |= windows.FILE_GENERIC_READ | windows.FILE_GENERIC_WRITE + default: + access |= windows.FILE_GENERIC_READ + } + if flag&os.O_CREATE != 0 { + access |= windows.FILE_GENERIC_WRITE + } + if flag&os.O_APPEND != 0 { + access |= windows.FILE_APPEND_DATA + if flag&os.O_TRUNC == 0 { + access &^= windows.FILE_WRITE_DATA + } + } + access |= windows.STANDARD_RIGHTS_READ | windows.FILE_READ_ATTRIBUTES | windows.FILE_READ_EA + + disposition := uint32(windows.FILE_OPEN) + switch { + case flag&(os.O_CREATE|os.O_EXCL) == os.O_CREATE|os.O_EXCL: + disposition = windows.FILE_CREATE + case flag&os.O_CREATE != 0: + disposition = windows.FILE_OPEN_IF + } + + attrs := uint32(windows.FILE_ATTRIBUTE_NORMAL) + if uint32(perm)&syscall.S_IWRITE == 0 { + attrs = windows.FILE_ATTRIBUTE_READONLY + } + + handle, err := ntCreateFileNoFollow(dir, name, access, attrs, disposition, options) + if err != nil { + return windows.InvalidHandle, err + } + if flag&os.O_TRUNC != 0 { + err = syscall.Ftruncate(syscall.Handle(handle), 0) + if err == windows.ERROR_INVALID_PARAMETER { + if t, err1 := syscall.GetFileType(syscall.Handle(handle)); err1 == nil && (t == syscall.FILE_TYPE_PIPE || t == syscall.FILE_TYPE_CHAR) { + err = nil + } + } + if err != nil { + _ = windows.CloseHandle(handle) + return windows.InvalidHandle, err + } + } + return handle, nil +} + +func ntCreateFileNoFollow(dir windows.Handle, name string, access, attrs, disposition, options uint32) (windows.Handle, error) { + if name == "" { + return windows.InvalidHandle, syscall.ERROR_FILE_NOT_FOUND + } + objectName, err := windows.NewNTUnicodeString(name) + if err != nil { + return windows.InvalidHandle, err + } + objectAttrs := &windows.OBJECT_ATTRIBUTES{ + Length: uint32(unsafe.Sizeof(windows.OBJECT_ATTRIBUTES{})), + RootDirectory: dir, + ObjectName: objectName, + Attributes: windows.OBJ_CASE_INSENSITIVE | windows.OBJ_DONT_REPARSE, + } + var handle windows.Handle + err = windows.NtCreateFile( + &handle, + windows.SYNCHRONIZE|access, + objectAttrs, + &windows.IO_STATUS_BLOCK{}, + nil, + attrs, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + disposition, + options, + 0, + 0, + ) + if err != nil { + return windows.InvalidHandle, ntCreateFileError(err) + } + return handle, nil +} + +func ntCreateFileError(err error) error { + status, ok := err.(windows.NTStatus) + if !ok { + return err + } + switch status { + case windows.STATUS_REPARSE_POINT_ENCOUNTERED: + return syscall.ELOOP + case windows.STATUS_NOT_A_DIRECTORY: + return syscall.ENOTDIR + case windows.STATUS_FILE_IS_A_DIRECTORY: + return syscall.EISDIR + case windows.STATUS_OBJECT_NAME_COLLISION: + return syscall.EEXIST + } + return status.Errno() +} + +func noFollowOpenPathError(rel string, err error) error { + if errors.Is(err, syscall.ELOOP) || errors.Is(err, syscall.ENOTDIR) { + return &os.PathError{Op: "open", Path: rel, Err: os.ErrPermission} + } + return &os.PathError{Op: "open", Path: rel, Err: err} +} diff --git a/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go index 60482932c..89533f0c5 100644 --- a/allowedpaths/sandbox_windows_test.go +++ b/allowedpaths/sandbox_windows_test.go @@ -165,6 +165,49 @@ func TestOpenForWriteRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) assert.NoFileExists(t, filepath.Join(dir, "real", "new.txt")) } +func TestOpenForWriteRejectsWindowsIntermediateSymlinkEscape(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + target := filepath.Join(outside, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + if err := os.Symlink(outside, filepath.Join(dir, "linkdir")); err != nil { + t.Skipf("creating symlink: %v", err) + } + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite(filepath.Join("linkdir", "target.txt"), dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + +func TestOpenExistingForWriteRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "real", "target.txt"), []byte("keep\n"), 0644)) + if err := os.Symlink("real", filepath.Join(dir, "linkdir")); err != nil { + t.Skipf("creating symlink: %v", err) + } + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenExistingForWrite(filepath.Join("linkdir", "target.txt"), dir) + assert.Nil(t, f) + assert.ErrorIs(t, err, os.ErrPermission) + + data, err := os.ReadFile(filepath.Join(dir, "real", "target.txt")) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestValidateRedirectWritePreflightPathRejectsWindowsSymlinkParentWithinAllowedPath(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(dir, "real"), 0755)) diff --git a/analysis/symbols_allowedpaths.go b/analysis/symbols_allowedpaths.go index 33a45f7f8..cb5eefa73 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -15,7 +15,7 @@ package analysis // Internal module imports (github.com/DataDog/rshell/*) are auto-allowed // and do not appear here. // -// The permanently banned packages (reflect, unsafe) apply here too. +// The permanently banned packages (for example reflect) apply here too. var allowedpathsAllowedSymbols = []string{ "bytes.Buffer", // 🟢 in-memory byte buffer; collects sandbox warnings for deferred output. "context.Context", // 🟢 context type used to signal cancellation; no I/O or side effects. @@ -44,7 +44,9 @@ var allowedpathsAllowedSymbols = []string{ "os.IsPathSeparator", // 🟢 checks whether a byte is a platform path separator; pure function, no I/O. "os.O_APPEND", // 🟢 append file flag constant; only accepted by the dedicated redirection write-open path. "os.O_CREATE", // 🟢 create file flag constant; only accepted by the dedicated redirection write-open path. + "os.O_EXCL", // 🟢 exclusive-create file flag constant; preserved when the dedicated write-open path creates files. "os.O_RDONLY", // 🟢 read-only file flag constant; pure constant. + "os.O_RDWR", // 🟢 read-write file flag constant; preserved by the dedicated write-open path. "os.O_TRUNC", // 🟢 truncate file flag constant; only accepted by the dedicated redirection write-open path. "os.O_WRONLY", // 🟢 write-only file flag constant; only accepted by the dedicated redirection write-open path. "os.NewFile", // 🟠 wraps a sandbox-opened file descriptor after fd-relative openat validation; does not open paths itself. @@ -67,6 +69,7 @@ var allowedpathsAllowedSymbols = []string{ "strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O. "strings.Join", // 🟢 joins string slices; pure function, no I/O. "strings.Split", // 🟢 splits a string by separator; pure function, no I/O. + "unsafe.Sizeof", // 🔴 computes native Windows OBJECT_ATTRIBUTES struct size for NtCreateFile; no pointer arithmetic or memory dereference. "golang.org/x/sys/unix.Close", // 🟠 closes intermediate directory file descriptors opened during fd-relative write-path validation. "golang.org/x/sys/unix.ELOOP", // 🟢 symlink-loop errno constant; normalized to permission denied for no-follow write opens. "golang.org/x/sys/unix.ENOTDIR", // 🟢 not-a-directory errno constant; normalized when no-follow parent traversal rejects a symlink directory. @@ -77,14 +80,58 @@ var allowedpathsAllowedSymbols = []string{ "golang.org/x/sys/unix.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking if a final component races to a FIFO. "golang.org/x/sys/unix.O_RDONLY", // 🟢 read-only open flag for parent directory traversal. "golang.org/x/sys/unix.Openat", // 🟠 fd-relative open used to keep no-symlink write validation tied to the opened parent directory. - "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. - "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. - "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. - "syscall.Errno", // 🟢 system call error number type; pure type. - "syscall.FILE_FLAG_OPEN_REPARSE_POINT", // 🟢 Windows no-follow open flag; opens reparse points themselves so sandbox write opens can reject them without following. - "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. - "syscall.Handle", // 🟢 Windows file handle type; pure type alias. - "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. - "syscall.O_NOFOLLOW", // 🟢 no-follow open flag; prevents terminal symlink writes when opening sandboxed write targets. - "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. + "golang.org/x/sys/windows.CloseHandle", // 🟠 closes intermediate Windows directory handles opened during fd-relative write-path validation. + "golang.org/x/sys/windows.ERROR_INVALID_PARAMETER", // 🟢 Windows errno constant; used to ignore unsupported truncation on special handles. + "golang.org/x/sys/windows.FILE_APPEND_DATA", // 🟢 Windows access right; preserves append-only semantics when opening sandboxed write handles. + "golang.org/x/sys/windows.FILE_ATTRIBUTE_NORMAL", // 🟢 Windows file attribute constant for normal file creation. + "golang.org/x/sys/windows.FILE_ATTRIBUTE_READONLY", // 🟢 Windows file attribute constant mirroring non-writable creation modes. + "golang.org/x/sys/windows.FILE_CREATE", // 🟢 Windows NtCreateFile disposition for exclusive creation. + "golang.org/x/sys/windows.FILE_DIRECTORY_FILE", // 🟢 Windows NtCreateFile option requiring an intermediate component to be a directory. + "golang.org/x/sys/windows.FILE_GENERIC_READ", // 🟠 Windows read access right for opening intermediate directories and read-write handles. + "golang.org/x/sys/windows.FILE_GENERIC_WRITE", // 🟠 Windows write access right for sandboxed write handles. + "golang.org/x/sys/windows.FILE_LIST_DIRECTORY", // 🟢 Windows directory-list access right for intermediate directory handles. + "golang.org/x/sys/windows.FILE_NON_DIRECTORY_FILE", // 🟢 Windows NtCreateFile option requiring the final component not to be a directory. + "golang.org/x/sys/windows.FILE_OPEN", // 🟢 Windows NtCreateFile disposition for opening existing components. + "golang.org/x/sys/windows.FILE_OPEN_FOR_BACKUP_INTENT", // 🟠 Windows option matching Go's root open behavior for traversing directories with ACLs. + "golang.org/x/sys/windows.FILE_OPEN_IF", // 🟢 Windows NtCreateFile disposition for create-if-missing write opens. + "golang.org/x/sys/windows.FILE_READ_ATTRIBUTES", // 🟢 Windows access right needed so os.File.Stat works on returned handles. + "golang.org/x/sys/windows.FILE_READ_EA", // 🟢 Windows access right needed so os.File.Stat works on returned handles. + "golang.org/x/sys/windows.FILE_SHARE_DELETE", // 🟢 Windows share mode matching Go's root open behavior for race-safe traversal. + "golang.org/x/sys/windows.FILE_SHARE_READ", // 🟢 Windows share mode allowing concurrent readers of sandbox-opened handles. + "golang.org/x/sys/windows.FILE_SHARE_WRITE", // 🟢 Windows share mode allowing concurrent writers of sandbox-opened handles. + "golang.org/x/sys/windows.FILE_SYNCHRONOUS_IO_NONALERT", // 🟢 Windows option for synchronous file handles compatible with os.File. + "golang.org/x/sys/windows.FILE_WRITE_DATA", // 🟢 Windows access bit removed for append-only handles unless truncation is requested. + "golang.org/x/sys/windows.Handle", // 🟢 Windows file handle type; pure type alias. + "golang.org/x/sys/windows.IO_STATUS_BLOCK", // 🟢 Windows NtCreateFile status structure; pure type. + "golang.org/x/sys/windows.InvalidHandle", // 🟢 Windows invalid handle sentinel; pure constant. + "golang.org/x/sys/windows.NTStatus", // 🟢 Windows NT status error type; used for deterministic errno normalization. + "golang.org/x/sys/windows.NewNTUnicodeString", // 🟠 converts one path component to the NT string form required by NtCreateFile. + "golang.org/x/sys/windows.NtCreateFile", // 🟠 fd-relative Windows open used with OBJ_DONT_REPARSE to avoid following reparse points on write paths. + "golang.org/x/sys/windows.OBJ_CASE_INSENSITIVE", // 🟢 Windows object attribute matching normal case-insensitive path lookup. + "golang.org/x/sys/windows.OBJ_DONT_REPARSE", // 🟠 Windows object attribute that rejects reparse points during component traversal. + "golang.org/x/sys/windows.OBJECT_ATTRIBUTES", // 🟢 Windows NtCreateFile object attributes structure; pure type. + "golang.org/x/sys/windows.STANDARD_RIGHTS_READ", // 🟢 Windows access right needed so os.File.Stat works on returned handles. + "golang.org/x/sys/windows.STATUS_FILE_IS_A_DIRECTORY", // 🟢 Windows NT status mapped to POSIX-style is-a-directory errors. + "golang.org/x/sys/windows.STATUS_NOT_A_DIRECTORY", // 🟢 Windows NT status mapped to permission denial for no-follow parent traversal. + "golang.org/x/sys/windows.STATUS_OBJECT_NAME_COLLISION", // 🟢 Windows NT status mapped to already-exists errors for exclusive creation. + "golang.org/x/sys/windows.STATUS_REPARSE_POINT_ENCOUNTERED", // 🟢 Windows NT status mapped to permission denial for no-follow reparse point rejection. + "golang.org/x/sys/windows.SYNCHRONIZE", // 🟢 Windows access right required for synchronous file handles. + "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. + "syscall.EEXIST", // 🟢 "file exists" errno constant; used to normalize Windows exclusive-create failures. + "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. + "syscall.ELOOP", // 🟢 "too many levels of symbolic links" errno constant; used to normalize no-follow write-open rejections. + "syscall.ENOTDIR", // 🟢 "not a directory" errno constant; used to normalize no-follow parent traversal failures. + "syscall.Errno", // 🟢 system call error number type; pure type. + "syscall.ERROR_FILE_NOT_FOUND", // 🟢 Windows errno constant returned for empty or missing path components. + "syscall.EINVAL", // 🟢 invalid argument errno constant used when os.NewFile rejects an invalid Windows handle. + "syscall.FILE_TYPE_CHAR", // 🟢 Windows file type constant; used to match Go truncation semantics for special handles. + "syscall.FILE_TYPE_PIPE", // 🟢 Windows file type constant; used to match Go truncation semantics for special handles. + "syscall.Ftruncate", // 🟠 truncates the already sandbox-opened Windows file handle when O_TRUNC is requested. + "syscall.GetFileInformationByHandle", // 🟠 Windows API for file identity (vol serial + file index); read-only syscall. + "syscall.GetFileType", // 🟠 reads the type of an already-open Windows handle to preserve Go's O_TRUNC special-file behavior. + "syscall.Handle", // 🟢 Windows file handle type; pure type alias. + "syscall.O_NONBLOCK", // 🟢 non-blocking open flag; prevents blocking on FIFOs during access checks. Pure constant. + "syscall.O_NOFOLLOW", // 🟢 no-follow open flag; prevents terminal symlink writes when opening sandboxed write targets. + "syscall.S_IWRITE", // 🟢 Windows write permission bit used to translate Go create modes into file attributes. + "syscall.Stat_t", // 🟢 file stat structure type; pure type for Unix file metadata. } From fa2c71956f290958df0cb3875149d517eba567b1 Mon Sep 17 00:00:00 2001 From: Matthew DeGuzman Date: Wed, 20 May 2026 15:59:31 -0400 Subject: [PATCH 36/36] fix redirect preflight edge cases --- allowedpaths/sandbox.go | 19 +++++++++ allowedpaths/sandbox_test.go | 39 +++++++++++++++++++ interp/remediation_commands_test.go | 23 +++++++++++ interp/runner_exec.go | 25 ++++++++---- .../empty_first_word_redirect_command.yaml | 12 ++++++ 5 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 tests/scenarios/shell/blocked_redirects/empty_first_word_redirect_command.yaml diff --git a/allowedpaths/sandbox.go b/allowedpaths/sandbox.go index ec98b5e20..05b0eed61 100644 --- a/allowedpaths/sandbox.go +++ b/allowedpaths/sandbox.go @@ -308,6 +308,16 @@ func hasTrailingPathSeparator(path string) bool { return os.IsPathSeparator(path[len(path)-1]) } +func hasFinalDotPathComponent(path string) bool { + for len(path) > 0 && os.IsPathSeparator(path[len(path)-1]) { + path = path[:len(path)-1] + } + if path == "" || path == "." || path[len(path)-1] != '.' { + return false + } + return len(path) == 1 || os.IsPathSeparator(path[len(path)-2]) +} + // IsDevNull reports whether path refers to the platform's null device. func IsDevNull(path string) bool { if path == "/dev/null" { @@ -368,6 +378,9 @@ func (s *Sandbox) OpenForWrite(path string, cwd string, flag int, perm os.FileMo if hasTrailingPathSeparator(path) { return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} } + if hasFinalDotPathComponent(path) { + return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } absPath := toAbs(path, cwd) @@ -391,6 +404,9 @@ func (s *Sandbox) ValidateRedirectWritePreflightPath(path string, cwd string) er if hasTrailingPathSeparator(path) { return &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} } + if hasFinalDotPathComponent(path) { + return &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } absPath := toAbs(path, cwd) ar, relPath, ok := s.resolve(absPath) @@ -408,6 +424,9 @@ func (s *Sandbox) OpenExistingForWrite(path string, cwd string) (*os.File, error if hasTrailingPathSeparator(path) { return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} } + if hasFinalDotPathComponent(path) { + return nil, &os.PathError{Op: "open", Path: path, Err: errors.New("not a directory")} + } absPath := toAbs(path, cwd) ar, relPath, ok := s.resolve(absPath) diff --git a/allowedpaths/sandbox_test.go b/allowedpaths/sandbox_test.go index 2fdf93eb2..4b74a9f66 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -122,6 +122,24 @@ func TestSandboxOpenForWriteRejectsTrailingSeparator(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestSandboxOpenForWriteRejectsFinalDotComponent(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenForWrite("target.txt"+string(filepath.Separator)+".", dir, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + assert.Nil(t, f) + assert.Contains(t, err.Error(), "not a directory") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestSandboxValidateRedirectWritePreflightPath(t *testing.T) { dir := t.TempDir() outside := t.TempDir() @@ -140,6 +158,9 @@ func TestSandboxValidateRedirectWritePreflightPath(t *testing.T) { err = sb.ValidateRedirectWritePreflightPath("existing.txt"+string(filepath.Separator), dir) assert.Contains(t, err.Error(), "not a directory") + err = sb.ValidateRedirectWritePreflightPath("existing.txt"+string(filepath.Separator)+".", dir) + assert.Contains(t, err.Error(), "not a directory") + err = sb.ValidateRedirectWritePreflightPath(".", dir) assert.Contains(t, err.Error(), "is a directory") } @@ -199,6 +220,24 @@ func TestSandboxOpenExistingForWriteRejectsTrailingSeparator(t *testing.T) { assert.Equal(t, "keep\n", string(data)) } +func TestSandboxOpenExistingForWriteRejectsFinalDotComponent(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "existing.txt") + require.NoError(t, os.WriteFile(target, []byte("keep\n"), 0644)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + f, err := sb.OpenExistingForWrite("existing.txt"+string(filepath.Separator)+".", dir) + assert.Nil(t, f) + assert.Contains(t, err.Error(), "not a directory") + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "keep\n", string(data)) +} + func TestReadDirLimited(t *testing.T) { dir := t.TempDir() diff --git a/interp/remediation_commands_test.go b/interp/remediation_commands_test.go index 8b772f2c7..f71ddccf6 100644 --- a/interp/remediation_commands_test.go +++ b/interp/remediation_commands_test.go @@ -322,6 +322,29 @@ func TestRemediationTruncateRejectsTrailingSeparatorBeforeHostExecution(t *testi assert.Equal(t, "abcdef", string(data)) } +func TestRemediationTruncateRejectsFinalDotBeforeHostExecution(t *testing.T) { + requireHostExtraFilesSupported(t) + dir := t.TempDir() + target := filepath.Join(dir, "target.log") + require.NoError(t, os.WriteFile(target, []byte("abcdef"), 0644)) + called := false + + _, stderr, code := runScript(t, "truncate -s 0 target.log/.", dir, + interp.AllowedPaths([]string{dir}), + interp.HostCommandHandler(func(ctx context.Context, args []string) error { + called = true + return nil + }), + ) + + assert.Equal(t, 1, code) + assert.Contains(t, stderr, "not a directory") + assert.False(t, called) + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "abcdef", string(data)) +} + func TestExecHandlerOptionRunsAllowedExternalCommand(t *testing.T) { dir := t.TempDir() var got []string diff --git a/interp/runner_exec.go b/interp/runner_exec.go index 6bf8a5d71..f69692d6d 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -39,6 +39,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { var ( callExpr *syntax.CallExpr callCommandFields []string + callArgsStart int callFields []string callPrechecked bool ) @@ -66,7 +67,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { if r.exit.ok() { if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { callExpr = cm - callCommandFields = r.expandCallCommandFields(cm) + callCommandFields, callArgsStart = r.expandCallCommandPrefixFields(cm) if len(callCommandFields) == 0 { r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) r.exit.code = 2 @@ -88,7 +89,7 @@ func (r *Runner) stmtSync(ctx context.Context, st *syntax.Stmt) { // has passed AllowedCommands. Otherwise a blocked command could still // create or truncate files inside AllowedPaths. if r.exit.ok() && callExpr != nil { - callFields = r.expandRemainingCallFields(callExpr, callCommandFields) + callFields = r.expandRemainingCallFields(callExpr, callCommandFields, callArgsStart) callPrechecked = true if len(callFields) == 0 { r.errf("%s\n", stdoutFileRedirectionWithoutCommandError) @@ -182,17 +183,25 @@ func (r *Runner) expandCallFields(cm *syntax.CallExpr) []string { } func (r *Runner) expandCallCommandFields(cm *syntax.CallExpr) []string { + fields, _ := r.expandCallCommandPrefixFields(cm) + return fields +} + +func (r *Runner) expandCallCommandPrefixFields(cm *syntax.CallExpr) ([]string, int) { r.lastExpandExit = exitStatus{} - if len(cm.Args) == 0 { - return nil + for i, arg := range cm.Args { + fields := r.fields(arg) + if len(fields) > 0 { + return fields, i + 1 + } } - return r.fields(cm.Args[0]) + return nil, len(cm.Args) } -func (r *Runner) expandRemainingCallFields(cm *syntax.CallExpr, commandFields []string) []string { +func (r *Runner) expandRemainingCallFields(cm *syntax.CallExpr, commandFields []string, start int) []string { fields := append([]string(nil), commandFields...) - if len(cm.Args) > 1 { - fields = append(fields, r.fields(cm.Args[1:]...)...) + if len(cm.Args) > start { + fields = append(fields, r.fields(cm.Args[start:]...)...) } return fields } diff --git a/tests/scenarios/shell/blocked_redirects/empty_first_word_redirect_command.yaml b/tests/scenarios/shell/blocked_redirects/empty_first_word_redirect_command.yaml new file mode 100644 index 000000000..ac1825d60 --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/empty_first_word_redirect_command.yaml @@ -0,0 +1,12 @@ +description: Output redirection honors a command promoted after an empty first-word expansion. +input: + allowed_paths: ["$DIR"] + script: |+ + empty= + $empty echo ok > output.txt + cat output.txt +expect: + stdout: |+ + ok + stderr: |+ + exit_code: 0