diff --git a/README.md b/README.md index b172de3f7..d02786e27 100644 --- a/README.md +++ b/README.md @@ -59,19 +59,25 @@ 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 | 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 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 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`. + ## 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..036d9a61e 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] [--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 - ✅ `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,13 +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 [--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 [--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 @@ -81,16 +87,20 @@ 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 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) - ✅ `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) -- ❌ `> 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 +119,12 @@ 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 +- ✅ 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) -- ❌ 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/portable_unix.go b/allowedpaths/portable_unix.go index 96d395b88..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. @@ -104,6 +108,82 @@ 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 +} + +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 888582fd9..509a6ccbd 100644 --- a/allowedpaths/portable_windows.go +++ b/allowedpaths/portable_windows.go @@ -9,7 +9,12 @@ import ( "errors" "io/fs" "os" + "path/filepath" + "strings" "syscall" + "unsafe" + + "golang.org/x/sys/windows" ) // IsErrIsDirectory checks if the error is the Windows equivalent of EISDIR. @@ -85,3 +90,204 @@ 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) { + 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) + } + 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) { + 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 + } + 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 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.go b/allowedpaths/sandbox.go index 76263d410..05b0eed61 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" @@ -298,6 +301,23 @@ 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]) +} + +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" { @@ -310,9 +330,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 +365,123 @@ 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 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: + default: + 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")} + } + 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) + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + f, err := ar.openFileValidatedNoFollow(relPath, flag, perm, true) + if err != nil { + return nil, err + } + 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")} + } + 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) + 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. +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) + if !ok { + return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission} + } + + f, err := ar.openFileValidatedNoFollow(relPath, os.O_WRONLY, 0, false) + if err != nil { + return nil, err + } + return f, nil +} + +func (r *root) validateWritePath(rel string, allowMissingFinal bool) error { + rel = filepath.Clean(rel) + if rel == "." { + return &os.PathError{Op: "open", Path: rel, Err: errors.New("is a directory")} + } + + components := strings.Split(rel, string(filepath.Separator)) + partial := "" + for i, component := range components { + isFinal := i == len(components)-1 + 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 && isFinal && 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} + } + 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 +} + // 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..4b74a9f66 100644 --- a/allowedpaths/sandbox_test.go +++ b/allowedpaths/sandbox_test.go @@ -66,6 +66,178 @@ 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 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 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() + 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("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)) + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + 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 TestSandboxOpenExistingForWriteRejectsMissingAndOutsideAllowedPaths(t *testing.T) { + dir := t.TempDir() + outside := t.TempDir() + + sb, _, err := New([]string{dir}) + require.NoError(t, err) + defer sb.Close() + + 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) +} + +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 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/allowedpaths/sandbox_unix_test.go b/allowedpaths/sandbox_unix_test.go index e6a85924b..a11b0a204 100644 --- a/allowedpaths/sandbox_unix_test.go +++ b/allowedpaths/sandbox_unix_test.go @@ -226,6 +226,133 @@ 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 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") + 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)) +} + +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/allowedpaths/sandbox_windows_test.go b/allowedpaths/sandbox_windows_test.go index 317281b93..89533f0c5 100644 --- a/allowedpaths/sandbox_windows_test.go +++ b/allowedpaths/sandbox_windows_test.go @@ -126,3 +126,100 @@ 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")) +} + +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)) + 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_allowedpaths.go b/analysis/symbols_allowedpaths.go index 4ba8f501c..cb5eefa73 100644 --- a/analysis/symbols_allowedpaths.go +++ b/analysis/symbols_allowedpaths.go @@ -15,57 +15,123 @@ 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. - "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.O_RDONLY", // 🟢 read-only file flag constant; pure constant. - "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. - "syscall.ByHandleFileInformation", // 🟢 Windows file identity structure; pure type for file metadata. - "syscall.EISDIR", // 🟢 "is a directory" errno constant; pure constant. - "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.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_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. + "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. + "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. + "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. + "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. } diff --git a/analysis/symbols_builtins.go b/analysis/symbols_builtins.go index 8842d1226..ab25169ee 100644 --- a/analysis/symbols_builtins.go +++ b/analysis/symbols_builtins.go @@ -194,6 +194,40 @@ 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. + "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. + "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. "errors.New", // 🟢 creates a simple error value; pure function, no I/O. @@ -344,6 +378,10 @@ 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. + "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. "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. @@ -360,6 +398,10 @@ 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. + "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. "io/fs.FileInfo", // 🟢 interface type for file information; no side effects. @@ -375,6 +417,12 @@ 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. + "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. + }, "true": { "context.Context", // 🟢 deadline/cancellation plumbing; pure interface, no side effects. }, @@ -443,6 +491,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. @@ -550,10 +603,19 @@ 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. "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. @@ -599,11 +661,14 @@ 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. + "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. @@ -619,6 +684,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. @@ -650,15 +716,21 @@ 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. "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/analysis/symbols_interp.go b/analysis/symbols_interp.go index 7d04865e7..19aa3cf42 100644 --- a/analysis/symbols_interp.go +++ b/analysis/symbols_interp.go @@ -41,16 +41,23 @@ 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. + "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. + "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/builtins/builtins.go b/builtins/builtins.go index 0e8c5b1c8..8f3f37589 100644 --- a/builtins/builtins.go +++ b/builtins/builtins.go @@ -7,11 +7,13 @@ package builtins import ( "context" + "encoding/json" "errors" "fmt" "io" "io/fs" "os" + "runtime" "sort" "syscall" "time" @@ -42,6 +44,16 @@ 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 + StdoutTruncated bool + StderrTruncated bool +} + // 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. @@ -141,6 +153,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. @@ -260,6 +282,29 @@ 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) + + // 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 + // 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. @@ -287,11 +332,109 @@ 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...) } +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. 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 { + 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() + } + 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 + 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} + } + 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} +} + +// 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/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..39fef3f8e --- /dev/null +++ b/builtins/kill/kill.go @@ -0,0 +1,154 @@ +// 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" + "fmt" + "strconv" + "time" + + "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] [--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 { + 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.Atoi(args[0]) + if err != nil || pid <= 0 { + 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} + } + if *jsonFlag { + return runJSON(ctx, callCtx, pid, *forceFlag, *timeoutFlag) + } + if err := signalPID(pid, *forceFlag); err != nil { + callCtx.Errf("kill: %s\n", err) + return builtins.Result{Code: 1} + } + 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 builtins.Result{Code: 1} + } + return builtins.Result{} + } +} + +type receipt struct { + PID int `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 int, force bool, timeout time.Duration) builtins.Result { + exitCode := uint8(0) + stderr := "" + timedOut := false + if err := signalPID(pid, force); err != nil { + exitCode = 1 + stderr = fmt.Sprintf("kill: %s\n", err) + } else { + var waitRes builtins.Result + var ok bool + var waitErr error + timedOut, waitErr, waitRes, ok = waitForExit(ctx, pid, timeout) + if !ok { + return waitRes + } + 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{ + 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: exitCode} +} + +func waitForExit(ctx context.Context, pid int, timeout time.Duration) (bool, error, builtins.Result, bool) { + if timeout == 0 { + return false, nil, builtins.Result{}, true + } + timer := time.NewTimer(timeout) + defer timer.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + alive, err := pidAlive(ctx, pid) + if err != nil { + return false, err, builtins.Result{}, true + } + if !alive { + return false, nil, builtins.Result{}, true + } + select { + case <-ctx.Done(): + return false, nil, builtins.Result{Code: 1, Exiting: true}, false + case <-timer.C: + return true, nil, builtins.Result{}, true + case <-ticker.C: + } + } +} diff --git a/builtins/kill/signal_unix.go b/builtins/kill/signal_unix.go new file mode 100644 index 000000000..baaba3f2a --- /dev/null +++ b/builtins/kill/signal_unix.go @@ -0,0 +1,64 @@ +// 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" + "errors" + "os" + "syscall" + + "github.com/DataDog/rshell/builtins/internal/procinfo" +) + +func signalPID(pid int, force bool) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + sig := syscall.SIGTERM + if force { + sig = syscall.SIGKILL + } + return proc.Signal(sig) +} + +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 errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) { + return false, nil + } + if err != nil { + return false, err + } + + infos, infoErr := procinfo.GetByPIDs(ctx, "", []int{pid}) + if infoErr == nil { + if len(infos) == 0 { + if ctx.Err() != nil { + return true, nil + } + return false, nil + } + if infos[0].State == "Z" { + return false, nil + } + } + return true, nil +} + +func signalName(force bool) string { + if force { + return "SIGKILL" + } + return "SIGTERM" +} 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) +} diff --git a/builtins/kill/signal_windows.go b/builtins/kill/signal_windows.go new file mode 100644 index 000000000..cc4acf013 --- /dev/null +++ b/builtins/kill/signal_windows.go @@ -0,0 +1,57 @@ +// 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" + "errors" + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +func signalPID(pid int, _ bool) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + defer proc.Release() + return proc.Kill() +} + +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) + } +} + +func signalName(force bool) string { + if force { + return "SIGKILL" + } + return "SIGTERM" +} diff --git a/builtins/logrotate/logrotate.go b/builtins/logrotate/logrotate.go new file mode 100644 index 000000000..a613334c0 --- /dev/null +++ b/builtins/logrotate/logrotate.go @@ -0,0 +1,184 @@ +// 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" + "os" + "path/filepath" + "sort" + "strings" + + "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 [--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 { + 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 callCtx.OpenExistingFileForWrite == nil { + 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)) + 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"` + StdoutTruncated bool `json:"stdout_truncated,omitempty"` + StderrTruncated bool `json:"stderr_truncated,omitempty"` +} + +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, + StdoutTruncated: host.StdoutTruncated, + StderrTruncated: host.StderrTruncated, + }) + 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 new file mode 100644 index 000000000..c35eb559d --- /dev/null +++ b/builtins/systemctl/systemctl.go @@ -0,0 +1,134 @@ +// 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" + "strings" + + "github.com/DataDog/rshell/builtins" +) + +// Cmd is the systemctl builtin command descriptor. +var Cmd = builtins.Command{ + Name: "systemctl", + Description: "run a restricted service lifecycle or status action", + MakeFlags: registerFlags, +} + +func printUsage(callCtx *builtins.CallContext) { + 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") +} + +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 { + if *helpFlag { + printUsage(callCtx) + fs.SetOutput(callCtx.Stdout) + fs.PrintDefaults() + return builtins.Result{} + } + 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", "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} + } + 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") + 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} + } + 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"` + 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 { + 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, + StdoutTruncated: actionHost.StdoutTruncated || stateHost.StdoutTruncated, + StderrTruncated: actionHost.StderrTruncated || stateHost.StderrTruncated, + }) + 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/tee/tee.go b/builtins/tee/tee.go new file mode 100644 index 000000000..36c27f29c --- /dev/null +++ b/builtins/tee/tee.go @@ -0,0 +1,67 @@ +// 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" + "os" + + "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} + } + if callCtx.OpenFileForWrite == nil { + 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) + } + 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)) + return builtins.Result{Code: 1} + } + files := []*os.File{f} + target := builtins.HostExtraFilePath(0) + argv := []string{"--", target} + if *appendFlag { + argv = []string{"-a", "--", target} + } + return callCtx.InvokeHostCommandWithFiles(ctx, "tee", argv, files) + } +} 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..aeadc6130 --- /dev/null +++ b/builtins/truncate/truncate.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 truncate implements a guarded truncate command. +package truncate + +import ( + "context" + "os" + "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 [--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 { + 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} + } + if callCtx.OpenExistingFileForWrite == nil { + 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)) + 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} + } + 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"` + 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 { + 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, + StdoutTruncated: host.StdoutTruncated, + StderrTruncated: host.StderrTruncated, + }) + if outRes.Code != 0 || outRes.Exiting { + return outRes + } + return builtins.Result{Code: host.Code} +} + +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/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/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/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..39dd2b00d 100644 --- a/interp/api.go +++ b/interp/api.go @@ -43,6 +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, 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 @@ -54,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 @@ -127,6 +145,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. @@ -193,6 +214,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 @@ -457,11 +483,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 @@ -586,6 +614,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) @@ -610,7 +639,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 { @@ -675,6 +704,55 @@ 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 +// 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. +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 + r.hostCommandHandlerConfigured = true + 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 + r.execHandlerConfigured = true + 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 @@ -802,19 +880,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, - 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/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/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/register_builtins.go b/interp/register_builtins.go index 520fc02f9..cc2e681a1 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,13 +36,17 @@ 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" + "github.com/DataDog/rshell/builtins/write_file" "github.com/DataDog/rshell/builtins/xargs" ) @@ -64,7 +70,9 @@ func registerBuiltins() { head.Cmd, help.Cmd, ip.Cmd, + killcmd.Cmd, ls.Cmd, + logrotate.Cmd, ping.Cmd, sortcmd.Cmd, printfcmd.Cmd, @@ -74,14 +82,18 @@ 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, 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 new file mode 100644 index 000000000..f71ddccf6 --- /dev/null +++ b/interp/remediation_commands_test.go @@ -0,0 +1,1038 @@ +// 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" + "encoding/json" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/DataDog/rshell/builtins" + "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 requireHostExtraFilesSupported(t *testing.T) { + t.Helper() + if !builtins.HostExtraFilesSupported() { + t.Skip("host file descriptor handoff is not supported on this platform") + } +} + +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() + 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...) + 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 + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"truncate", "-s", "3", "--", builtins.HostExtraFilePath(0)}, got) +} + +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 + + 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...) + hc := interp.HandlerCtx(ctx) + assert.Equal(t, dir, hc.Dir) + require.Len(t, hc.ExtraFiles, 1) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + 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() + 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...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"truncate", "-s", "0", "--", builtins.HostExtraFilePath(0)}, got) +} + +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 + + _, 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 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 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 TestRemediationTruncateRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, 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 TestRemediationTruncateRejectsTrailingSeparatorBeforeHostExecution(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 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 + + 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 + + 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 + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"systemctl", "restart", "--", "-app.service"}, got) +} + +func TestRemediationSystemctlShowActiveStateDelegates(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 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 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 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 + + _, 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 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 TestRemediationKillTerminatesProcessDirectly(t *testing.T) { + dir := t.TempDir() + helper := startKillHelperProcess(t) + called := false + + 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 { + called = true + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.False(t, called) + 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) + + 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":`+strconv.Itoa(helper.cmd.Process.Pid)+`,"force":false,"signal":"SIGTERM","timed_out":false,"exit_code":0,"stdout":"","stderr":""}`, stdout) + assert.Equal(t, "", stderr) + helper.waitForExit(t) +} + +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 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 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() + 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...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + 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", "--", builtins.HostExtraFilePath(0)}, got) + assert.Equal(t, "payload\n", stdin) +} + +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 + + 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...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + assert.Equal(t, []string{"tee", "--", builtins.HostExtraFilePath(0)}, got) +} + +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)) + 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 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 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, 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 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)) + 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") + 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) { + requireHostExtraFilesSupported(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...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + 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() + 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...) + require.Len(t, interp.HandlerCtx(ctx).ExtraFiles, 1) + return nil + }), + ) + + assert.Equal(t, 0, code) + assert.Equal(t, "", stdout) + assert.Equal(t, "", stderr) + 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) +} + +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)) +} + +func TestRemediationLogrotateRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, 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) +} + +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)) + 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/remediation_commands_windows_test.go b/interp/remediation_commands_windows_test.go new file mode 100644 index 000000000..0a3f8d777 --- /dev/null +++ b/interp/remediation_commands_windows_test.go @@ -0,0 +1,87 @@ +// 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 ( + "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/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 ")) +} diff --git a/interp/runner.go b/interp/runner.go index f6a87e4d6..bf753692e 100644 --- a/interp/runner.go +++ b/interp/runner.go @@ -7,6 +7,7 @@ package interp import ( "context" + "errors" "fmt" "io" "os" @@ -18,16 +19,31 @@ 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) +} + +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 { + 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: r.Dir, - Pos: pos, - Stdout: r.stdout, - Stderr: r.stderr, + Env: &overlayEnviron{parent: r.writeEnv}, + Dir: dir, + Pos: pos, + 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) } @@ -63,3 +79,21 @@ 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) { + 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: + 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..f69692d6d 100644 --- a/interp/runner_exec.go +++ b/interp/runner_exec.go @@ -6,6 +6,7 @@ package interp import ( + "bytes" "context" "errors" "fmt" @@ -34,18 +35,87 @@ 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 - for _, rd := range st.Redirs { - cls, err := r.redir(ctx, rd) + oldOutFile, oldErrFile := r.stdoutFileRedirect, r.stderrFileRedirect + var ( + callExpr *syntax.CallExpr + callCommandFields []string + callArgsStart int + callFields []string + 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(ctx, st.Redirs); err != nil { + r.errf("%s\n", err) + r.exit.code = 1 + } + } + } + + if r.exit.ok() { + if err := r.preflightKnownFileBackedFdDupRedirects(ctx, 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 + // unsupported redirect. + if r.exit.ok() { + if cm, ok := st.Cmd.(*syntax.CallExpr); ok && stmtHasPotentialFileWriteRedirect(st) { + callExpr = cm + callCommandFields, callArgsStart = r.expandCallCommandPrefixFields(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]}) + } + } + } + var redirectArgs map[*syntax.Redirect]string + if r.exit.ok() { + var err error + redirectArgs, err = r.preflightFileBackedFdDupRedirects(ctx, st.Redirs) if err != nil { + r.errf("%s\n", err) r.exit.code = 1 - break } - if cls != nil { - defer cls.Close() + } + // 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.expandRemainingCallFields(callExpr, callCommandFields, callArgsStart) + 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) + if err != nil { + r.exit.code = 1 + break + } + if cls != nil { + defer cls.Close() + } } } 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() @@ -53,6 +123,146 @@ 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 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 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] +} + +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...) +} + +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{} + for i, arg := range cm.Args { + fields := r.fields(arg) + if len(fields) > 0 { + return fields, i + 1 + } + } + return nil, len(cm.Args) +} + +func (r *Runner) expandRemainingCallFields(cm *syntax.CallExpr, commandFields []string, start int) []string { + fields := append([]string(nil), commandFields...) + if len(cm.Args) > start { + fields = append(fields, r.fields(cm.Args[start:]...)...) + } + return fields +} + +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) { @@ -76,65 +286,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: @@ -514,7 +666,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") @@ -596,6 +748,8 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, + 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) }, @@ -662,6 +816,16 @@ 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, 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) + }, + 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 @@ -718,6 +882,12 @@ func (r *Runner) call(ctx context.Context, pos syntax.Pos, args []string) { } return allowedpaths.WithContextClose(ctx, f), nil }, + 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) }, @@ -768,6 +938,16 @@ 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, 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) + }, + 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) @@ -824,6 +1004,64 @@ 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, 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 + 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(), + StdoutTruncated: stdoutCap.isExceeded(), + StderrTruncated: stderrCap.isExceeded(), + }, 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) + } + 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.handlerCtxWithDirFilesIO(ctx, pos, dir, extraFiles, stdin, stdout, stderr), argv) + if err == nil { + return 0, nil + } + var status ExitStatus + if errors.As(err, &status) { + return uint8(status), nil + } + 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 "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_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 diff --git a/interp/runner_redir.go b/interp/runner_redir.go index 15c8e9272..ab6e9b18e 100644 --- a/interp/runner_redir.go +++ b/interp/runner_redir.go @@ -8,10 +8,14 @@ package interp import ( "bytes" "context" + "errors" "fmt" "io" + "io/fs" "os" + "path/filepath" "strings" + "sync" "mvdan.cc/sh/v3/syntax" ) @@ -200,7 +204,323 @@ func (r *Runner) hdocReader(ctx context.Context, rd *syntax.Redirect) (*os.File, return pr, nil } -func (r *Runner) redir(ctx context.Context, rd *syntax.Redirect) (io.Closer, error) { +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 +} + +type preflightFDState struct { + known bool + fileRedirect bool + source *syntax.Redirect + target string +} + +type fdDupPreflightMode int + +const ( + fdDupPreflightNoExpansion fdDupPreflightMode = iota + fdDupPreflightSafeExpansion + 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(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(ctx context.Context, redirs []*syntax.Redirect) error { + _, err := r.preflightFileBackedFdDupRedirectsWithExpansion(ctx, redirs, fdDupPreflightNoExpansion) + return err +} + +// preflightSafeFileBackedFdDupRedirects expands only redirect targets that +// 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(ctx context.Context, redirs []*syntax.Redirect) (map[*syntax.Redirect]string, error) { + return r.preflightFileBackedFdDupRedirectsWithExpansion(ctx, redirs, fdDupPreflightSafeExpansion) +} + +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) + 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 { + stdoutState = state + } + case syntax.ClbOut: + if rd.N != nil && rd.N.Value == "2" { + stderrState = preflightFDState{known: true} + } else { + stdoutState = preflightFDState{known: true} + } + case syntax.RdrAll, syntax.AppAll: + if redirectTargetIsDevNull(rd) { + stdoutState = preflightFDState{known: true} + stderrState = preflightFDState{known: true} + } + case syntax.DplOut: + arg, ok := literalRedirectTargetFD(rd) + if !ok { + continue + } + var targetState preflightFDState + switch arg { + case "1": + targetState = stdoutState + case "2": + targetState = stderrState + default: + continue + } + if !targetState.known && targetState.source != nil && mode != fdDupPreflightNoExpansion { + source := targetState.source + if mode == fdDupPreflightSafeExpansion && wordRunsCommands(source.Word) { + continue + } + 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), + target: expandedArg, + } + if source == stdoutState.source { + stdoutState = targetState + } + if source == stderrState.source { + stderrState = targetState + } + } + redirectsStderr := rd.N != nil && rd.N.Value == "2" + if redirectsStderr && targetState.fileRedirect { + return redirectArgs, stderrFileDupToFileRedirectError(arg) + } + if redirectsStderr { + stderrState = targetState + } else { + stdoutState = targetState + } + } + } + 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 { + 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), + target: expandedArg, + }, true +} + +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), + 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 + } + 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() { + 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) { + 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, redirectArgs map[*syntax.Redirect]string) (io.Closer, error) { if rd.Hdoc != nil { pr, err := r.hdocReader(ctx, rd) if err != nil { @@ -219,10 +539,15 @@ 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 + origFileRedirect := &r.stdoutFileRedirect + redirectsStderr := false if rd.N != nil { switch rd.N.Value { case "0": @@ -235,6 +560,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) @@ -245,14 +572,36 @@ 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) { + 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 + } + capped := r.cappedRedirectWriter(f) + *orig = capped + *origFileRedirect = true + return capped, nil + } + *orig = io.Discard + *origFileRedirect = false + return nil, nil + + case syntax.ClbOut: 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) + r.errf(">| %s: file redirection is not supported\n", arg) + return nil, fmt.Errorf(">| %s: file redirection is not supported", arg) } *orig = io.Discard + *origFileRedirect = false return nil, nil case syntax.RdrAll, syntax.AppAll: @@ -265,18 +614,33 @@ 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 { + err := stderrFileDupToFileRedirectError(arg) + r.errf("%s\n", err) + return nil, err + } + *orig = target + *origFileRedirect = targetFileRedirect return nil, nil default: diff --git a/interp/tests/cmdsubst_pentest_test.go b/interp/tests/cmdsubst_pentest_test.go index 06f772c3a..ad6bd01e9 100644 --- a/interp/tests/cmdsubst_pentest_test.go +++ b/interp/tests/cmdsubst_pentest_test.go @@ -176,11 +176,13 @@ func TestCmdSubstPentestCatShortcutEmptyFile(t *testing.T) { // --- Subshell with redirect --- -func TestSubshellPentestRedirectOutBlocked(t *testing.T) { +func TestSubshellPentestFileOutputRedirectBlocked(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, 2, code) - assert.Contains(t, stderr, "file redirection is not supported") + assert.Contains(t, stderr, "stdout file redirection on compound commands is not supported") } // --- Context cancellation --- 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_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..c40984466 100644 --- a/interp/tests/redir_devnull_test.go +++ b/interp/tests/redir_devnull_test.go @@ -11,8 +11,10 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -35,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), "") @@ -52,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 --- @@ -149,6 +157,225 @@ func TestRedirDupStderrToStdout(t *testing.T) { assert.Equal(t, "", stderr) } +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.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) + 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)) + + 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 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 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 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() + + 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 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 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() + + 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 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 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 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 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 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() + + 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 @@ -174,15 +401,356 @@ 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() - // The validation should reject this - stdout, stderr, code := redirRunNoAllowed(t, "echo hello > /tmp/output.txt", dir) + 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 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)) + + 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 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 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") + 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 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, "file redirection is not supported") + 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 + 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") + 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") + } + 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 TestRedirStdoutToFileRejectsFIFOWithoutBlocking(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("FIFOs are Unix-specific") + } + dir := t.TempDir() + require.NoError(t, 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) + 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() + stdout, stderr, code := redirRunNoAllowed(t, "echo hello > /tmp/output.txt", dir) + assert.Equal(t, 1, code) + assert.Equal(t, "", stdout) + assert.Contains(t, stderr, "permission denied") } func TestRedirStderrToFileStillBlocked(t *testing.T) { @@ -190,15 +758,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 +782,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 +802,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 +836,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..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,6 +95,21 @@ func validateNode(node syntax.Node) error { err = fmt.Errorf("background execution (&) is not supported") return false } + 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 + } + } // Blocked pipe operators. case *syntax.BinaryCmd: @@ -209,16 +229,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/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 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 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 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 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_dup_to_stdout_file.yaml b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml new file mode 100644 index 000000000..d49b1c9ef --- /dev/null +++ b/tests/scenarios/shell/blocked_redirects/stderr_dup_to_stdout_file.yaml @@ -0,0 +1,20 @@ +# 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 + keep + stderr: |+ + 2>&1: stderr file redirection via fd duplication is not supported + 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/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 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