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
This commit is contained in:
1 parent
09ee561aa7
commit
ed5bd92e1b
2 files changed
+130
-23
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
|
||||
Reference in new issue
Block a user