108 lines
3.4 KiB
Go
108 lines
3.4 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func newTestShell(t *testing.T) *Shell {
|
|
t.Helper()
|
|
s, err := NewShell(t.TempDir(), []string{"echo", "ls", "cat", "wc", "printenv", "sleep", "false", "true", "head", "find"}, 2*time.Second, 200)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func run(t *testing.T, s *Shell, cmd string) (string, error) {
|
|
t.Helper()
|
|
b, _ := json.Marshal(map[string]string{"command": cmd})
|
|
return s.Call(context.Background(), b)
|
|
}
|
|
|
|
func TestShellRejectsBypassAttempts(t *testing.T) {
|
|
s := newTestShell(t)
|
|
for _, c := range []string{
|
|
"rm -rf x", "echo hi && rm x", "echo hi; rm x", "echo hi | sh", "false || bash",
|
|
"echo $(id)", "echo `id`", "cat <(id)", "echo ${HOME}", "echo $HOME", "echo hi > f", "cat < /etc/passwd",
|
|
"cat /etc/passwd", "cat ../secret", "cat a/../../secret", "ls ~", "cat --file=/etc/passwd",
|
|
"sleep 10 &", "(echo hi)", "{ echo hi; }", "/bin/echo hi", "./echo hi", "echo hi\nid",
|
|
"echo 'unterminated", "echo a\\;id", "find . -exec id ;", "find . -delete", "", "echo hi &&", "| echo",
|
|
"e''cho hi; r''m x",
|
|
} {
|
|
if _, err := s.Parse(c); err == nil || !errors.Is(err, ErrRejected) {
|
|
t.Errorf("%q should be rejected, got %v", c, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestShellNeverAllowed(t *testing.T) {
|
|
for _, c := range []string{"sh", "bash", "env", "xargs", "python3", "/bin/ls"} {
|
|
if _, err := NewShell(t.TempDir(), []string{c}, time.Second, 10); err == nil {
|
|
t.Errorf("%q must not be allowlistable", c)
|
|
}
|
|
}
|
|
if _, err := NewShell(t.TempDir(), nil, time.Second, 10); err == nil {
|
|
t.Error("empty allowlist must fail")
|
|
}
|
|
}
|
|
|
|
func TestShellRunsChainsAndPipes(t *testing.T) {
|
|
s := newTestShell(t)
|
|
os.WriteFile(filepath.Join(s.Dir, "f.txt"), []byte("a\nb\nc\n"), 0o600)
|
|
out, err := run(t, s, `cat f.txt | wc -l && echo "ok yes" ; false || echo fallback`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(out, "3\nok yes\nfallback") || !strings.HasSuffix(out, "[exit 0]") {
|
|
t.Fatalf("%q", out)
|
|
}
|
|
out, _ = run(t, s, "false && echo never")
|
|
if strings.Contains(out, "never") || !strings.HasSuffix(out, "[exit 1]") {
|
|
t.Fatalf("%q", out)
|
|
}
|
|
// metacharacters inside quotes are literal arguments, not operators
|
|
out, _ = run(t, s, `echo "a && b | c ; d"`)
|
|
if !strings.HasPrefix(out, "a && b | c ; d") {
|
|
t.Fatalf("%q", out)
|
|
}
|
|
}
|
|
|
|
func TestShellEnvironmentIsScrubbed(t *testing.T) {
|
|
t.Setenv("BYTECLAW_AUTH_TOKEN", "supersecret")
|
|
t.Setenv("DAHL_API_KEY", "dahl_secret")
|
|
s := newTestShell(t)
|
|
out, _ := run(t, s, "printenv")
|
|
if strings.Contains(out, "secret") || !strings.Contains(out, "HOME="+s.Dir) {
|
|
t.Fatalf("env leaked or wrong: %q", out)
|
|
}
|
|
}
|
|
|
|
func TestShellRunsInWorkspace(t *testing.T) {
|
|
s := newTestShell(t)
|
|
os.WriteFile(filepath.Join(s.Dir, "inside.txt"), nil, 0o600)
|
|
if out, _ := run(t, s, "ls"); !strings.Contains(out, "inside.txt") {
|
|
t.Fatalf("%q", out)
|
|
}
|
|
}
|
|
|
|
func TestShellTimeoutAndOutputLimit(t *testing.T) {
|
|
s := newTestShell(t)
|
|
s.Timeout = 300 * time.Millisecond
|
|
start := time.Now()
|
|
out, _ := run(t, s, "sleep 5")
|
|
if time.Since(start) > 3*time.Second || !strings.Contains(out, "timeout") {
|
|
t.Fatalf("timeout not enforced: %q after %s", out, time.Since(start))
|
|
}
|
|
s.Timeout = 2 * time.Second
|
|
out, _ = run(t, s, "echo "+strings.Repeat("x", 500))
|
|
if !strings.Contains(out, "[output truncated]") || len(out) > 300 {
|
|
t.Fatalf("limit not enforced: %d", len(out))
|
|
}
|
|
}
|