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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SHELL_FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Blocked features are rejected before execution with exit code 2.
- ✅ `echo [-neE] [ARG]...` — write arguments to stdout; `-n` suppresses trailing newline, `-e` enables backslash escapes, `-E` disables them (default)
- ✅ `exit [N]` — exit the shell with status N (default 0)
- ✅ `false` — return exit code 1
- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-exec`, `-delete`, `-regex` for sandbox safety
- ✅ `find [-L] [-P] [PATH...] [EXPRESSION]` — search for files in a directory hierarchy; supports `--help`, `-name`, `-iname`, `-path`, `-ipath`, `-type` (b,c,d,f,l,p,s), `-size`, `-empty`, `-newer`, `-mtime`, `-mmin`, `-perm`, `-maxdepth`, `-mindepth`, `-print`, `-print0`, `-exec CMD {} \;`, `-execdir CMD {} \;`, `-prune`, `-quit`, logical operators (`!`, `-a`, `-o`, `()`); blocks `-delete`, `-regex` for sandbox safety
- ✅ `grep [-EFGivclLnHhoqsxw] [-e PATTERN] [-m NUM] [-A NUM] [-B NUM] [-C NUM] PATTERN [FILE]...` — print lines that match patterns; uses RE2 regex engine (linear-time, no backtracking)
- ✅ `head [-n N|-c N] [-q|-v] [FILE]...` — output the first part of files (default: first 10 lines); `-z`/`--zero-terminated` and `--follow` are rejected
- ✅ `help` — display all available builtin commands with brief descriptions; for detailed flag info, use `<command> --help`
Expand Down
3 changes: 2 additions & 1 deletion allowedsymbols/symbols_builtins.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ var builtinPerCommandSymbols = map[string][]string{
"strconv.ErrRange", // 🟢 sentinel error value for overflow; pure constant.
"strconv.ParseInt", // 🟢 string-to-int conversion; pure function, no I/O.
"strconv.ParseUint", // 🟢 string-to-unsigned-int conversion; pure function, no I/O.
"strings.Contains", // 🟢 substring search for {} detection in command validation; pure function, no I/O.
"strings.HasPrefix", // 🟢 pure function for prefix matching; no I/O.
"strings.ReplaceAll", // 🟢 replaces all {} occurrences in -execdir args; pure function, no I/O.
"strings.ReplaceAll", // 🟢 replaces all {} occurrences in -exec/-execdir args; pure function, no I/O.
"strings.Split", // 🟢 splits a string by separator into a slice; pure function, no I/O.
"strings.ToLower", // 🟢 converts string to lowercase; pure function, no I/O.
"time.Duration", // 🟢 duration type; pure integer alias, no I/O.
Expand Down
366 changes: 359 additions & 7 deletions builtins/find/builtin_find_pentest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -422,13 +422,365 @@ func TestFindExecDirWindowsDriveRoot(t *testing.T) {
"drive root must be preserved as C:/, not truncated to C:")
}

// ---- Command token substitution ----

// TestFindExecDirCommandTokenSubstitution verifies that {} in the command name
// (argv[0]) is substituted with ./basename, matching GNU find behavior.
func TestFindExecDirCommandTokenSubstitution(t *testing.T) {
var stdout, stderr bytes.Buffer
var capturedCmd string

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, _ string, cmd string, _ []string) (uint8, error) {
capturedCmd = cmd
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "echo",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExecDir,
execCmd: "{}",
execArgs: []string{"hello"},
}

result := evalExecDir(ec, e)

assert.True(t, result.matched)
assert.Equal(t, "./echo", capturedCmd,
"command token {} must be substituted with ./basename")
}

// TestFindExecCommandTokenSubstitution verifies that {} in the command name
// is substituted with the full printPath, matching GNU find behavior.
func TestFindExecCommandTokenSubstitution(t *testing.T) {
var stdout, stderr bytes.Buffer
var capturedCmd string

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, _ string, cmd string, _ []string) (uint8, error) {
capturedCmd = cmd
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "dir/echo",
printPath: "dir/echo",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "{}",
execArgs: []string{"hello"},
}

result := evalExec(ec, e)

assert.True(t, result.matched)
assert.Equal(t, "dir/echo", capturedCmd,
"command token {} must be substituted with full printPath")
}

// ---- GTFOBins ----

// TestFindExecDirExecStillBlocked verifies that -exec remains in the blocked
// predicates list even after -execdir was introduced. This prevents -exec from
// being used as a higher-privilege GTFOBins escalation path.
func TestFindExecDirExecStillBlocked(t *testing.T) {
_, err := parseExpression([]string{"-exec", "id", ";"})
require.Error(t, err, "-exec must still be blocked")
assert.Contains(t, err.Error(), "blocked", "error must mention 'blocked'")
// TestFindExecParses verifies that -exec echo {} ; parses successfully now
// that -exec is implemented and no longer in the blocked predicates list.
func TestFindExecParses(t *testing.T) {
_, err := parseExpression([]string{"-exec", "echo", "{}", ";"})
require.NoError(t, err, "-exec must parse successfully")
}

// ---- -exec tests ----

// ---- Sandbox & AllowedCommands (CWE-284) ----

// TestFindExecCommandNotAllowed verifies that CommandAllowed is consulted
// at eval-time and that a blocked command causes a non-match with an error on
// stderr, without invoking RunCommand.
func TestFindExecCommandNotAllowed(t *testing.T) {
var stdout, stderr bytes.Buffer
runCalled := false

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, _ string, _ string, _ []string) (uint8, error) {
runCalled = true
return 0, nil
}
callCtx.CommandAllowed = func(name string) bool {
return false // block everything
}

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "somefile.txt",
printPath: "dir/somefile.txt",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "echo",
execArgs: []string{"{}"},
}

result := evalExec(ec, e)

assert.False(t, result.matched, "blocked command must not match")
assert.True(t, ec.failed, "ec.failed must be set when command is blocked")
assert.False(t, runCalled, "RunCommand must not be invoked for a blocked command")
assert.Contains(t, stderr.String(), "not allowed", "stderr must mention 'not allowed'")
}

// TestFindExecNilRunCommand verifies that a nil RunCommand is handled
// gracefully: evalExec must set ec.failed, write to stderr, and return a
// non-matched result rather than panicking.
func TestFindExecNilRunCommand(t *testing.T) {
var stdout, stderr bytes.Buffer

callCtx := newPentestCallCtx(&stdout, &stderr)
// RunCommand deliberately left nil.

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "somefile.txt",
printPath: "dir/somefile.txt",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "echo",
execArgs: []string{"{}"},
}

require.NotPanics(t, func() {
result := evalExec(ec, e)
assert.False(t, result.matched)
})
assert.True(t, ec.failed, "ec.failed must be set when RunCommand is nil")
assert.NotEmpty(t, stderr.String(), "an error message must appear on stderr")
}

// ---- Command Injection via Filenames (CWE-78) ----

// testExecFilename is a helper that verifies the argument passed to
// RunCommand for a given (relPath, printPath) pair is exactly printPath,
// ensuring that shell metacharacters in filenames are never interpreted.
// Unlike -execdir, -exec uses the full printPath (no "./" prefix).
func testExecFilename(t *testing.T, relPath, printPath string) {
t.Helper()

var stdout, stderr bytes.Buffer
var capturedArgs []string

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) {
capturedArgs = make([]string, len(args))
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: relPath,
printPath: printPath,
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "echo",
execArgs: []string{"{}"},
}

result := evalExec(ec, e)

assert.True(t, result.matched, "echo should succeed and match")
require.Len(t, capturedArgs, 1, "exactly one argument must be passed to the command")
assert.Equal(t, printPath, capturedArgs[0],
"printPath %q must be passed verbatim, not interpreted as shell syntax", printPath)
}

// TestFindExecSemicolonInFilename ensures a filename containing a semicolon
// is passed literally and cannot terminate the command or inject a new command.
func TestFindExecSemicolonInFilename(t *testing.T) {
testExecFilename(t, "dir/hi; ls", "dir/hi; ls")
}

// TestFindExecPipeInFilename ensures a pipe character in a filename does
// not result in stdout being piped to another process.
func TestFindExecPipeInFilename(t *testing.T) {
testExecFilename(t, "dir/data|cat", "dir/data|cat")
}

// TestFindExecBacktickInFilename ensures backtick command substitution
// characters in filenames are not executed by the shell.
func TestFindExecBacktickInFilename(t *testing.T) {
testExecFilename(t, "dir/`rm`", "dir/`rm`")
}

// TestFindExecDollarParenInFilename ensures $(...) command substitution
// syntax in filenames is not interpreted.
func TestFindExecDollarParenInFilename(t *testing.T) {
testExecFilename(t, "dir/$(rm)", "dir/$(rm)")
}

// TestFindExecNewlineInFilename ensures a filename containing a newline
// character is passed as a single argument without being split.
func TestFindExecNewlineInFilename(t *testing.T) {
testExecFilename(t, "dir/file\nname", "dir/file\nname")
}

// TestFindExecSpacesInFilename ensures a filename with multiple spaces is
// passed as a single argument and not word-split into multiple tokens.
func TestFindExecSpacesInFilename(t *testing.T) {
testExecFilename(t, "dir/a b c", "dir/a b c")
}

// TestFindExecGlobCharsInFilename ensures glob metacharacters in filenames
// are not expanded by the shell.
func TestFindExecGlobCharsInFilename(t *testing.T) {
testExecFilename(t, "dir/*.txt", "dir/*.txt")
}

// TestFindExecQuotesInFilename ensures single and double quote characters
// in filenames are passed literally without breaking argument quoting.
func TestFindExecQuotesInFilename(t *testing.T) {
testExecFilename(t, `dir/it's a "test"`, `dir/it's a "test"`)
}

// ---- Argument Injection (CWE-88) ----

// TestFindExecDashInPath verifies that a path beginning with a dash
// (e.g. "dir/-rf") is passed as-is without a "./" prefix, unlike -execdir.
// Note: -exec does NOT protect against dash-injection; this is expected behavior.
func TestFindExecDashInPath(t *testing.T) {
testExecFilename(t, "dir/-rf", "dir/-rf")
}

// ---- Correctness ----

// TestFindExecFullPathReplacement verifies that {} is replaced with the full
// printPath (not the relPath or a ./-prefixed basename).
func TestFindExecFullPathReplacement(t *testing.T) {
tests := []struct {
name string
relPath string
printPath string
}{
{"file in cwd", "file.txt", "./file.txt"},
{"file in subdir", "sub/deep.txt", "dir/sub/deep.txt"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testExecFilename(t, tt.relPath, tt.printPath)
})
}
}

// TestFindExecWorkDir verifies that evalExec passes ec.execWorkDir as the
// dir argument to RunCommand, not ec.execDirParent.
func TestFindExecWorkDir(t *testing.T) {
var stdout, stderr bytes.Buffer
var capturedDir string

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, dir string, _ string, _ []string) (uint8, error) {
capturedDir = dir
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "sub/file.txt",
printPath: "dir/sub/file.txt",
info: &mockFileInfo{},
execWorkDir: "/absolute/cwd",
execDirParent: "/absolute/cwd/sub",
}
e := &expr{
kind: exprExec,
execCmd: "echo",
execArgs: []string{"{}"},
}

result := evalExec(ec, e)

assert.True(t, result.matched)
assert.Equal(t, "/absolute/cwd", capturedDir,
"RunCommand dir must equal ec.execWorkDir, not ec.execDirParent")
}

// TestFindExecEmbeddedBracesReplacement verifies that {} embedded in an
// argument (e.g. {}.bak) is replaced with the full printPath.
func TestFindExecEmbeddedBracesReplacement(t *testing.T) {
var stdout, stderr bytes.Buffer
var capturedArgs []string

callCtx := newPentestCallCtx(&stdout, &stderr)
callCtx.RunCommand = func(_ context.Context, _ string, _ string, args []string) (uint8, error) {
capturedArgs = make([]string, len(args))
copy(capturedArgs, args)
return 0, nil
}
callCtx.CommandAllowed = func(_ string) bool { return true }

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "file.txt",
printPath: "dir/file.txt",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "echo",
execArgs: []string{"{}.bak"},
}

result := evalExec(ec, e)

assert.True(t, result.matched)
require.Len(t, capturedArgs, 1)
assert.Equal(t, "dir/file.txt.bak", capturedArgs[0])
}

// TestFindExecNoPathLookup verifies that specifying a path-relative command
// such as "./malicious" via -exec fails because RunCommand is not provided.
func TestFindExecNoPathLookup(t *testing.T) {
var stdout, stderr bytes.Buffer

callCtx := newPentestCallCtx(&stdout, &stderr)
// RunCommand is nil — simulates an environment where no external execution
// is available, exercising that ./malicious cannot be launched.

ec := &evalContext{
callCtx: callCtx,
ctx: context.Background(),
relPath: "target.txt",
printPath: "dir/target.txt",
info: &mockFileInfo{},
}
e := &expr{
kind: exprExec,
execCmd: "./malicious",
execArgs: []string{"{}"},
}

result := evalExec(ec, e)

assert.False(t, result.matched, "./malicious must not be executed via path lookup")
assert.True(t, ec.failed, "ec.failed must be set")
assert.NotEmpty(t, stderr.String(), "an error must be reported on stderr")
}
Loading
Loading