claude_agent/security/bash/
env.rs

1//! Environment variable sanitization.
2
3use std::collections::HashMap;
4
5const SAFE_ENV_VARS: &[&str] = &[
6    "HOME",
7    "USER",
8    "SHELL",
9    "LANG",
10    "LC_ALL",
11    "LC_CTYPE",
12    "TERM",
13    "PATH",
14    "PWD",
15    "TMPDIR",
16    "TMP",
17    "TEMP",
18    "CARGO_HOME",
19    "RUSTUP_HOME",
20    "NODE_PATH",
21    "NPM_CONFIG_PREFIX",
22    "VIRTUAL_ENV",
23    "CONDA_PREFIX",
24    "GIT_AUTHOR_NAME",
25    "GIT_AUTHOR_EMAIL",
26    "GIT_COMMITTER_NAME",
27    "GIT_COMMITTER_EMAIL",
28    "EDITOR",
29    "VISUAL",
30    "XDG_CONFIG_HOME",
31    "XDG_DATA_HOME",
32    "XDG_CACHE_HOME",
33    "XDG_RUNTIME_DIR",
34    "DISPLAY",
35    "WAYLAND_DISPLAY",
36    "SSH_AUTH_SOCK",
37];
38
39const BLOCKED_ENV_PATTERNS: &[&str] = &[
40    // Dynamic linker injection
41    "LD_PRELOAD",
42    "LD_LIBRARY_PATH",
43    "LD_AUDIT",
44    "LD_DEBUG",
45    "LD_PROFILE",
46    "LD_DEBUG_OUTPUT",
47    "LD_HWCAP_MASK",
48    "LD_BIND_",
49    "LD_TRACE_",
50    // macOS dynamic linker
51    "DYLD_INSERT_LIBRARIES",
52    "DYLD_LIBRARY_PATH",
53    "DYLD_FRAMEWORK_PATH",
54    "DYLD_FALLBACK_",
55    "DYLD_IMAGE_",
56    "DYLD_PRINT_",
57    // Compiler/build tool injection
58    "CC",
59    "CXX",
60    "LD",
61    "AR",
62    "AS",
63    "CFLAGS",
64    "CXXFLAGS",
65    "LDFLAGS",
66    "CPPFLAGS",
67    "MAKEFLAGS",
68    "CMAKE_",
69    // Python injection
70    "PYTHONSTARTUP",
71    "PYTHONHOME",
72    "PYTHONUSERBASE",
73    "PYTHONWARNINGS",
74    "PYTHONEXECUTABLE",
75    "PYTHONDONTWRITEBYTECODE",
76    // Shell startup injection
77    "BASH_ENV",
78    "ENV",
79    "BASH_FUNC_",
80    "ZDOTDIR",
81    "FPATH",
82    "CDPATH",
83    // Password/auth prompts
84    "SSH_ASKPASS",
85    "SUDO_ASKPASS",
86    "GIT_ASKPASS",
87    // SSH/Git command override
88    "GIT_SSH",
89    "GIT_SSH_COMMAND",
90    "SVN_SSH",
91    "GIT_EXEC_PATH",
92    "GIT_TEMPLATE_DIR",
93    // Shell prompt commands
94    "PROMPT_COMMAND",
95    "PS1",
96    "PS2",
97    "PS4",
98    // Language runtime injection
99    "PERL5OPT",
100    "PERL5LIB",
101    "PERL_HASH_SEED_DEBUG",
102    "PERL_MB_OPT",
103    "PERL_MM_OPT",
104    "RUBYOPT",
105    "RUBYLIB",
106    "NODE_OPTIONS",
107    "JAVA_TOOL_OPTIONS",
108    "_JAVA_OPTIONS",
109    "JAVA_HOME",
110    // Rust injection
111    "RUSTFLAGS",
112    "RUSTC_WRAPPER",
113    "RUSTC_LOG",
114    "CARGO_BUILD_",
115    // Debugger/tracing injection
116    "STRACE_OPTS",
117    "VALGRIND_OPTS",
118    "GDB_STARTUP_COMMANDS",
119    "LLDB_",
120    // glibc exploits
121    "GLIBC_TUNABLES",
122    "MALLOC_CHECK_",
123    "MALLOC_PERTURB_",
124    // IFS (field separator attacks)
125    "IFS",
126    // Pager exploits (less/more can execute commands)
127    "LESS",
128    "LESSOPEN",
129    "LESSCLOSE",
130    "MORE",
131    "MOST",
132    // .NET/PowerShell injection
133    "DOTNET_",
134    "POWERSHELL_",
135    "PSModulePath",
136    // Go injection
137    "GOPROXY",
138    "GOFLAGS",
139    // Package manager injection
140    "npm_config_",
141    "NPM_CONFIG_REGISTRY",
142    "NPM_CONFIG_CAFILE",
143    "NODE_EXTRA_CA_CERTS",
144    "YARN_",
145    "PIP_INDEX_URL",
146    "PIP_EXTRA_INDEX_URL",
147    "PIP_TRUSTED_HOST",
148    "PIPENV_",
149    "UV_INDEX_URL",
150    "UV_EXTRA_INDEX_URL",
151    "CARGO_REGISTRIES_",
152    "CARGO_NET_",
153    // Misc dangerous
154    "BROWSER",
155    "TEXINPUTS",
156    "TERMCAP",
157    "TERMINFO",
158];
159
160const SAFE_PATH: &str = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin";
161
162#[derive(Debug, Clone)]
163pub struct SanitizedEnv {
164    vars: HashMap<String, String>,
165}
166
167impl SanitizedEnv {
168    pub fn from_current() -> Self {
169        Self::from_env(std::env::vars())
170    }
171
172    pub fn from_env(env: impl Iterator<Item = (String, String)>) -> Self {
173        let mut vars = HashMap::new();
174
175        for (key, value) in env {
176            if Self::is_blocked(&key) {
177                continue;
178            }
179            if Self::is_safe(&key) {
180                vars.insert(key, value);
181            }
182        }
183
184        vars.insert("PATH".to_string(), SAFE_PATH.to_string());
185
186        Self { vars }
187    }
188
189    pub fn with_var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
190        let key = key.into();
191        if !Self::is_blocked(&key) {
192            self.vars.insert(key, value.into());
193        }
194        self
195    }
196
197    pub fn with_working_dir(mut self, dir: impl AsRef<std::path::Path>) -> Self {
198        self.vars
199            .insert("PWD".to_string(), dir.as_ref().display().to_string());
200        self
201    }
202
203    pub fn with_vars(mut self, vars: HashMap<String, String>) -> Self {
204        for (key, value) in vars {
205            if !Self::is_blocked(&key) {
206                self.vars.insert(key, value);
207            }
208        }
209        self
210    }
211
212    pub fn vars(&self) -> &HashMap<String, String> {
213        &self.vars
214    }
215
216    pub fn into_vec(self) -> Vec<(String, String)> {
217        self.vars.into_iter().collect()
218    }
219
220    fn is_blocked(key: &str) -> bool {
221        BLOCKED_ENV_PATTERNS
222            .iter()
223            .any(|pattern| key.starts_with(pattern))
224    }
225
226    fn is_safe(key: &str) -> bool {
227        SAFE_ENV_VARS.contains(&key)
228    }
229}
230
231impl Default for SanitizedEnv {
232    fn default() -> Self {
233        Self::from_current()
234    }
235}
236
237impl IntoIterator for SanitizedEnv {
238    type Item = (String, String);
239    type IntoIter = std::collections::hash_map::IntoIter<String, String>;
240
241    fn into_iter(self) -> Self::IntoIter {
242        self.vars.into_iter()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_ld_preload_blocked() {
252        let env = vec![
253            ("LD_PRELOAD".to_string(), "/evil.so".to_string()),
254            ("HOME".to_string(), "/home/user".to_string()),
255        ];
256        let sanitized = SanitizedEnv::from_env(env.into_iter());
257
258        assert!(!sanitized.vars.contains_key("LD_PRELOAD"));
259        assert!(sanitized.vars.contains_key("HOME"));
260    }
261
262    #[test]
263    fn test_bash_env_blocked() {
264        let env = vec![
265            ("BASH_ENV".to_string(), "/evil.sh".to_string()),
266            ("USER".to_string(), "test".to_string()),
267        ];
268        let sanitized = SanitizedEnv::from_env(env.into_iter());
269
270        assert!(!sanitized.vars.contains_key("BASH_ENV"));
271        assert!(sanitized.vars.contains_key("USER"));
272    }
273
274    #[test]
275    fn test_bash_func_blocked() {
276        let env = vec![(
277            "BASH_FUNC_evil%%".to_string(),
278            "() { /bin/rm -rf /; }".to_string(),
279        )];
280        let sanitized = SanitizedEnv::from_env(env.into_iter());
281
282        assert!(!sanitized.vars.contains_key("BASH_FUNC_evil%%"));
283    }
284
285    #[test]
286    fn test_safe_path_forced() {
287        let env = vec![("PATH".to_string(), "/evil:/bin".to_string())];
288        let sanitized = SanitizedEnv::from_env(env.into_iter());
289
290        assert_eq!(sanitized.vars.get("PATH").unwrap(), SAFE_PATH);
291    }
292
293    #[test]
294    fn test_with_working_dir() {
295        let sanitized = SanitizedEnv::from_env(std::iter::empty()).with_working_dir("/tmp/sandbox");
296
297        assert_eq!(sanitized.vars.get("PWD").unwrap(), "/tmp/sandbox");
298    }
299
300    #[test]
301    fn test_dyld_blocked() {
302        let env = vec![
303            (
304                "DYLD_INSERT_LIBRARIES".to_string(),
305                "/evil.dylib".to_string(),
306            ),
307            ("DYLD_LIBRARY_PATH".to_string(), "/evil/libs".to_string()),
308        ];
309        let sanitized = SanitizedEnv::from_env(env.into_iter());
310
311        assert!(!sanitized.vars.contains_key("DYLD_INSERT_LIBRARIES"));
312        assert!(!sanitized.vars.contains_key("DYLD_LIBRARY_PATH"));
313    }
314
315    #[test]
316    fn test_package_manager_blocked() {
317        let env = vec![
318            (
319                "npm_config_registry".to_string(),
320                "https://evil.com".to_string(),
321            ),
322            ("PIP_INDEX_URL".to_string(), "https://evil.com".to_string()),
323            ("YARN_REGISTRY".to_string(), "https://evil.com".to_string()),
324            (
325                "CARGO_REGISTRIES_EVIL".to_string(),
326                "https://evil.com".to_string(),
327            ),
328        ];
329        let sanitized = SanitizedEnv::from_env(env.into_iter());
330
331        assert!(!sanitized.vars.contains_key("npm_config_registry"));
332        assert!(!sanitized.vars.contains_key("PIP_INDEX_URL"));
333        assert!(!sanitized.vars.contains_key("YARN_REGISTRY"));
334        assert!(!sanitized.vars.contains_key("CARGO_REGISTRIES_EVIL"));
335    }
336}