fix(exec): 沙箱子进程不再继承宿主完整环境(P0-7,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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
This commit is contained in:
AskaEth committed 2026-10-04 12:29:43 +08:00
1 parent 09ee561aa7
commit ed5bd92e1b
2 files changed
+130 -23

No files matched your search

+84 -23
View File
@@ -9,6 +9,7 @@ import (
"log/slog"
"os"
"os/exec"
"runtime"
"sort"
"strings"
"sync"
@@ -192,14 +193,75 @@ func (t *TerminalExecutor) resolveWorkdir(workdir string) (string, error) {
return abs, nil
}
// buildEnv returns the child environment: the base process environment plus any
// allowlisted overrides from req.Env. Names outside the allowlist are rejected.
func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) {
base := os.Environ()
if len(extra) == 0 {
return base, nil
// terminalEnvAllowlist 返回运行终端子进程所必需的最小宿主环境变量键集合。
//
// 为什么是这些(审计口径):终端命令可由模型/插件触发,若继承 os.Environ() 全集,
// 子进程就能读到宿主的 API key、token、DB 口令等一切凭据。但子进程又必须拿到一
// 撮由操作系统/运行时提供的定位与查找变量,否则连可执行文件都解析不了。这里只保
// 留「去哪找可执行文件、临时目录在哪、用户目录在哪、常规系统信息」这类不含凭据的
// 键:
// - Windows:PATH/PATHEXT 定位可执行文件;SYSTEMROOT/WINDIR/COMSPEC 是 cmd 与
// 系统 DLL 加载的前提;TEMP/TMP 是临时目录;USERPROFILE/HOMEDRIVE/HOMEPATH/
// APPDATA/LOCALAPPDATA 是用户目录;NUMBER_OF_PROCESSORS/OS/
// PROCESSOR_ARCHITECTURE 是常规系统信息(部分程序依赖)。
// - Unix:PATH 定位可执行文件;HOME/TMPDIR 是用户与临时目录;USER/SHELL/LANG/
// LC_ALL 是常规终端身份与本地化变量。
//
// 其余变量(如 *_API_KEY、*_TOKEN、DB_*、JWT_*)一律不透传;调用方确需某个键时,
// 由 cfg.AllowedEnv 显式放行并经 req.Env 覆盖(见 envAllowed)。
func terminalEnvAllowlist() []string {
if runtime.GOOS == "windows" {
windowsKeys := []string{
"PATH", "PATHEXT", "SYSTEMROOT", "WINDIR", "COMSPEC",
"TEMP", "TMP", "USERPROFILE", "HOMEDRIVE", "HOMEPATH",
"APPDATA", "LOCALAPPDATA", "NUMBER_OF_PROCESSORS", "OS",
"PROCESSOR_ARCHITECTURE",
}
return windowsKeys
}
unixKeys := []string{"PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "USER", "SHELL"}
return unixKeys
}
// envKeyEqual 比较两个环境变量名是否指向同一变量:Windows 的名字大小写不敏感
// (宿主常写成 Path,不能因此漏掉 PATH),Unix 敏感。
func envKeyEqual(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
// envKeyIn 判断 name 是否属于 keys(按平台口径比较)。
func envKeyIn(name string, keys []string) bool {
for _, k := range keys {
if envKeyEqual(k, name) {
return true
}
}
return false
}
// envKeyOf 返回 m 中与 name 等价的键的原始拼写,用于覆盖时避免同名重复键。
func envKeyOf(m map[string]string, name string) (string, bool) {
if _, ok := m[name]; ok {
return name, true
}
if runtime.GOOS != "windows" {
return "", false
}
for k := range m {
if strings.EqualFold(k, name) {
return k, true
}
}
return "", false
}
// buildEnv 组装子进程环境:只透传运行终端所必需的最小宿主键集合
// (terminalEnvAllowlist),再叠加 req.Env 中经 envAllowed 显式放行的覆盖值。
// 宿主其余变量(含密钥)一律不透传;req.Env 里非白名单的键直接拒绝。
func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) {
overlays := make(map[string]string, len(extra))
for k, v := range extra {
if !t.envAllowed(k) {
@@ -208,29 +270,28 @@ func (t *TerminalExecutor) buildEnv(extra map[string]string) ([]string, error) {
overlays[k] = v
}
out := make([]string, 0, len(base)+len(overlays))
seen := make(map[string]struct{}, len(base)+len(overlays))
for _, e := range base {
k, _, _ := strings.Cut(e, "=")
if v, ok := overlays[k]; ok {
out = append(out, k+"="+v)
delete(overlays, k)
} else {
out = append(out, e)
allow := terminalEnvAllowlist()
kept := make(map[string]string)
for _, e := range os.Environ() {
k, v, ok := strings.Cut(e, "=")
if !ok || k == "" || !envKeyIn(k, allow) {
continue
}
seen[k] = struct{}{}
kept[k] = v
}
rest := make([]string, 0, len(overlays))
for k := range overlays {
if _, ok := seen[k]; !ok {
rest = append(rest, k)
for k, v := range overlays {
if existing, ok := envKeyOf(kept, k); ok {
delete(kept, existing)
}
kept[k] = v
}
sort.Strings(rest)
for _, k := range rest {
out = append(out, k+"="+overlays[k])
out := make([]string, 0, len(kept))
for k, v := range kept {
out = append(out, k+"="+v)
}
sort.Strings(out)
return out, nil
}
+46
View File
@@ -296,6 +296,52 @@ func TestTerminal_Env_Whitelist(t *testing.T) {
}
}
// 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()))