Files
Cyrene_v2/internal/runtime/exec/terminal_test.go
T
AskaEth ed5bd92e1b
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(exec): 沙箱子进程不再继承宿主完整环境(P0-7,审计 §4.3)
- buildEnv 不再以 os.Environ() 全集为基,改为「平台最小必需键白名单」裁剪后再叠加 req.Env
  · Windows:PATH/PATHEXT/SYSTEMROOT/WINDIR/COMSPEC/TEMP/TMP/USERPROFILE/HOMEDRIVE/HOMEPATH/APPDATA/LOCALAPPDATA/NUMBER_OF_PROCESSORS/OS/PROCESSOR_ARCHITECTURE
  · Unix:PATH/HOME/TMPDIR/LANG/LC_ALL/USER/SHELL
- 键名匹配在 Windows 上大小写不敏感(避免宿主写 Path 时漏掉),overlay 覆盖 base 时同口径去重
- envAllowed 语义不变:调用方能显式放行的键照旧,仅改变 base 来源
- 新增 TestTerminal_Env_HostNotInherited(非并行,t.Setenv):父进程设非白名单变量,断言子进程读不到;反证=把该键加进白名单立刻 FAIL
2026-10-04 12:29:43 +08:00

353 lines
11 KiB
Go

package exec
import (
"context"
"errors"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// discardLogger returns a logger that writes nowhere, keeping test output clean.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
// requireBin skips the test when name is not on PATH.
func requireBin(t *testing.T, name string) {
t.Helper()
if _, err := exec.LookPath(name); err != nil {
t.Skipf("%s not available: %v", name, err)
}
}
// enabledCfg builds an enabled sandbox config with a single allowlisted command
// and optional allowed roots.
func enabledCfg(command string, roots ...string) config.ExecSandbox {
return config.ExecSandbox{
Enabled: true,
CommandList: []string{command},
AllowedRoots: roots,
}
}
func newEnabledExecutor(t *testing.T, cfg config.ExecSandbox) *TerminalExecutor {
t.Helper()
ex, err := New(cfg, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}
return ex
}
func TestTerminal_DefaultDisabled_Refuses(t *testing.T) {
t.Parallel()
ex, err := New(config.ExecSandbox{Enabled: false}, discardLogger())
if err != nil {
t.Fatalf("New: %v", err)
}
_, err = ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
if !errors.Is(err, ErrExecutionDisabled) {
t.Fatalf("err = %v, want ErrExecutionDisabled", err)
}
}
func TestTerminal_NewNilLogger(t *testing.T) {
t.Parallel()
if _, err := New(config.ExecSandbox{}, nil); !errors.Is(err, ErrNilLogger) {
t.Fatalf("err = %v, want ErrNilLogger", err)
}
}
func TestTerminal_NewEnabledNeedsRoot(t *testing.T) {
t.Parallel()
_, err := New(enabledCfg("echo"), discardLogger())
if !errors.Is(err, ErrNoAllowedRoot) {
t.Fatalf("err = %v, want ErrNoAllowedRoot", err)
}
}
func TestTerminal_Allowlist_Allowed(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hello"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 0 {
t.Errorf("ExitCode = %d, want 0", res.ExitCode)
}
if !strings.Contains(res.Stdout, "hello") {
t.Errorf("Stdout = %q, want contain hello", res.Stdout)
}
if res.Truncated {
t.Errorf("Truncated = true, want false")
}
if res.PID <= 0 {
t.Errorf("PID = %d, want > 0", res.PID)
}
if res.StartedAt.IsZero() || res.EndedAt.IsZero() {
t.Errorf("timestamps must be set")
}
}
func TestTerminal_Allowlist_Denied(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
// pwd is allowlisted; echo is not.
ex := newEnabledExecutor(t, enabledCfg("pwd", t.TempDir()))
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", "hi"}})
if !errors.Is(err, ErrCommandNotAllowed) {
t.Fatalf("err = %v, want ErrCommandNotAllowed", err)
}
}
func TestTerminal_Injection_Rejected(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
cases := []struct {
name string
arg string
}{
{"semicolon", "a;b"},
{"pipe", "a|b"},
{"ampersand", "a&b"},
{"subshell", "$(rm -rf /)"},
{"backtick", "`id`"},
{"redirect-out", ">out"},
{"redirect-in", "<in"},
{"newline", "a\nb"},
{"traversal", "../etc"},
}
for _, c := range cases {
c := c
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"echo", c.arg}})
if !errors.Is(err, ErrCommandInjection) {
t.Errorf("arg %q: err = %v, want ErrCommandInjection", c.arg, err)
}
})
}
}
func TestTerminal_WorkDir_DefaultToRoot(t *testing.T) {
t.Parallel()
// Probe the default working directory without comparing rendered path
// strings. On Windows the command may resolve to Git's MSYS coreutils, which
// reports the Windows cwd it was handed as a POSIX path (the user Temp
// directory comes back as /tmp/...), so a "Stdout contains root" check is not
// portable. A marker file that exists only in the sandbox root, read through
// a relative name, is found only when the process really runs in that root.
const (
probeFile = "cwd-probe.txt"
probeBody = "cwd-probe"
)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, probeFile), []byte(probeBody), 0o600); err != nil {
t.Fatalf("write probe: %v", err)
}
requireBin(t, "cat")
catEx := newEnabledExecutor(t, enabledCfg("cat", root))
res, err := catEx.Execute(context.Background(), ExecRequest{Command: []string{"cat", probeFile}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 0 || res.Stdout != probeBody {
t.Errorf("cat %s with no WorkDir: exit=%d stdout=%q stderr=%q, want %q read from the sandbox root %s",
probeFile, res.ExitCode, res.Stdout, res.Stderr, probeBody, root)
}
// And the default must be the root itself, not a parent or a subdirectory:
// pwd with no WorkDir must print exactly what pwd prints for WorkDir=root.
// Both runs share pwd's own rendering, so the comparison stays platform
// independent.
requireBin(t, "pwd")
pwdEx := newEnabledExecutor(t, enabledCfg("pwd", root))
deflt, err := pwdEx.Execute(context.Background(), ExecRequest{Command: []string{"pwd"}})
if err != nil {
t.Fatalf("Execute pwd (default workdir): %v", err)
}
explicit, err := pwdEx.Execute(context.Background(), ExecRequest{
Command: []string{"pwd"},
WorkDir: root,
})
if err != nil {
t.Fatalf("Execute pwd (WorkDir=%s): %v", root, err)
}
if got, want := strings.TrimSpace(deflt.Stdout), strings.TrimSpace(explicit.Stdout); got != want {
t.Errorf("pwd with no WorkDir = %q, want the sandbox root as pwd renders it with WorkDir=%s: %q", got, root, want)
}
}
func TestTerminal_WorkDir_OutsideRejected(t *testing.T) {
t.Parallel()
requireBin(t, "echo")
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
_, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"echo", "hi"},
WorkDir: os.TempDir(),
})
if !errors.Is(err, ErrWorkDirOutsideRoot) {
t.Fatalf("err = %v, want ErrWorkDirOutsideRoot", err)
}
}
func TestTerminal_Timeout_Killed(t *testing.T) {
t.Parallel()
requireBin(t, "sleep")
ex := newEnabledExecutor(t, enabledCfg("sleep", t.TempDir()))
start := time.Now()
_, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"sleep", "5"},
Timeout: 200 * time.Millisecond,
})
if !errors.Is(err, ErrTimeout) {
t.Fatalf("err = %v, want ErrTimeout", err)
}
if elapsed := time.Since(start); elapsed > 3*time.Second {
t.Errorf("timeout took %v, want ~200ms (process not killed)", elapsed)
}
}
func TestTerminal_Output_Truncated(t *testing.T) {
t.Parallel()
requireBin(t, "head")
ex := newEnabledExecutor(t, enabledCfg("head", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{
Command: []string{"head", "-c", "2000000", "/dev/zero"},
MaxOutputBytes: 100,
})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !res.Truncated {
t.Errorf("Truncated = false, want true")
}
if len(res.Stdout) != 100 {
t.Errorf("len(Stdout) = %d, want 100", len(res.Stdout))
}
}
func TestTerminal_NonZeroExit_ReportedNotError(t *testing.T) {
t.Parallel()
requireBin(t, "false")
ex := newEnabledExecutor(t, enabledCfg("false", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"false"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 1 {
t.Errorf("ExitCode = %d, want 1", res.ExitCode)
}
}
func TestTerminal_Stdin_Passthrough(t *testing.T) {
t.Parallel()
requireBin(t, "cat")
ex := newEnabledExecutor(t, enabledCfg("cat", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"cat"}, Stdin: []byte("ping")})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.Stdout != "ping" {
t.Errorf("Stdout = %q, want ping", res.Stdout)
}
}
func TestTerminal_Env_Whitelist(t *testing.T) {
t.Parallel()
requireBin(t, "env")
root := t.TempDir()
// No allowlist: any injected variable is refused before launching.
noEnv := newEnabledExecutor(t, enabledCfg("env", root))
_, err := noEnv.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
if !errors.Is(err, ErrEnvNotAllowed) {
t.Fatalf("no-allowlist err = %v, want ErrEnvNotAllowed", err)
}
// Allowlisted variable is applied.
cfg := enabledCfg("env", root)
cfg.AllowedEnv = []string{"FOO"}
ex := newEnabledExecutor(t, cfg)
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}, Env: map[string]string{"FOO": "bar"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if !strings.Contains(res.Stdout, "FOO=bar") {
t.Errorf("Stdout = %q, want contain FOO=bar", res.Stdout)
}
}
// TestTerminal_Env_HostNotInherited 验证子进程不再继承宿主环境变量全集:宿主里
// 任意非白名单变量(如真实的 API key)都不会泄漏给沙箱命令;同时运行终端所必需的
// 基础键(PATH)仍在,否则命令无法启动。
// 注意:t.Setenv 与 t.Parallel 互斥,本用例不得并行。
func TestTerminal_Env_HostNotInherited(t *testing.T) {
requireBin(t, "env")
const (
secretName = "CYRENE_TEST_SECRET_XYZ"
secretValue = "s3cr3t-do-not-leak"
)
t.Setenv(secretName, secretValue)
ex := newEnabledExecutor(t, enabledCfg("env", t.TempDir()))
res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"env"}})
if err != nil {
t.Fatalf("Execute: %v", err)
}
if res.ExitCode != 0 {
t.Fatalf("ExitCode = %d, want 0; stderr=%q", res.ExitCode, res.Stderr)
}
if got := childEnvValue(res.Stdout, secretName); got != "" {
t.Errorf("子进程读到了非白名单宿主变量 %s=%q,宿主全集仍被透传", secretName, got)
}
if got := childEnvValue(res.Stdout, "PATH"); got == "" {
t.Errorf("子进程 PATH 为空,终端命令将无法解析可执行文件;stdout=%q", res.Stdout)
}
}
// childEnvValue 从 `env` 命令的输出里取 key 的值,未找到返回空串。键名比较走平台
// 口径(Windows 大小写不敏感),避免把宿主的 `Path` 拼写误判成缺失。
func childEnvValue(stdout, key string) string {
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimRight(line, "\r")
k, v, ok := strings.Cut(line, "=")
if !ok || k == "" {
continue
}
if envKeyEqual(k, key) {
return v
}
}
return ""
}
func TestTerminal_EmptyCommand(t *testing.T) {
t.Parallel()
ex := newEnabledExecutor(t, enabledCfg("echo", t.TempDir()))
_, err := ex.Execute(context.Background(), ExecRequest{})
if !errors.Is(err, ErrEmptyCommand) {
t.Fatalf("err = %v, want ErrEmptyCommand", err)
}
}