Skip to main content

safe_shell_scanner/
env_scrub.rs

1use std::collections::HashMap;
2
3use crate::scanner::Scanner;
4
5/// Scrub secret values from environment variables.
6///
7/// - `env`: the current environment
8/// - `scrub_patterns`: glob patterns for keys to remove (e.g. `*_KEY`, `*_SECRET`)
9/// - `pass_patterns`: glob patterns for keys to always keep (e.g. `PATH`, `HOME`)
10/// - `scanner`: also scan values for secret content patterns
11///
12/// Returns the cleaned environment.
13pub fn scrub_env(
14    env: &HashMap<String, String>,
15    scrub_patterns: &[String],
16    pass_patterns: &[String],
17    scanner: &Scanner,
18) -> HashMap<String, String> {
19    env.iter()
20        .filter(|(key, value)| {
21            // Always keep if key matches a pass pattern
22            if pass_patterns.iter().any(|p| glob_match(p, key)) {
23                return true;
24            }
25
26            // Remove if key matches a scrub pattern
27            if scrub_patterns.iter().any(|p| glob_match(p, key)) {
28                return false;
29            }
30
31            // Remove if value looks like a secret
32            if scanner.contains_secret(value) {
33                return false;
34            }
35
36            // Keep everything else
37            true
38        })
39        .map(|(k, v)| (k.clone(), v.clone()))
40        .collect()
41}
42
43/// Simple glob matching for environment variable key patterns.
44/// Supports `*` as wildcard prefix/suffix (e.g. `*_KEY`, `NODE_*`, `LC_*`).
45fn glob_match(pattern: &str, text: &str) -> bool {
46    if pattern == "*" {
47        return true;
48    }
49
50    if let Some(suffix) = pattern.strip_prefix('*') {
51        return text.ends_with(suffix);
52    }
53
54    if let Some(prefix) = pattern.strip_suffix('*') {
55        return text.starts_with(prefix);
56    }
57
58    pattern == text
59}
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64
65    #[test]
66    fn glob_match_suffix() {
67        assert!(glob_match("*_KEY", "AWS_SECRET_KEY"));
68        assert!(glob_match("*_KEY", "STRIPE_KEY"));
69        assert!(!glob_match("*_KEY", "PATH"));
70        assert!(!glob_match("*_KEY", "KEY_NAME"));
71    }
72
73    #[test]
74    fn glob_match_prefix() {
75        assert!(glob_match("NODE_*", "NODE_ENV"));
76        assert!(glob_match("NODE_*", "NODE_PATH"));
77        assert!(!glob_match("NODE_*", "PATH"));
78    }
79
80    #[test]
81    fn glob_match_exact() {
82        assert!(glob_match("PATH", "PATH"));
83        assert!(!glob_match("PATH", "HOME"));
84    }
85
86    #[test]
87    fn glob_match_wildcard_all() {
88        assert!(glob_match("*", "ANYTHING"));
89    }
90
91    #[test]
92    fn scrubs_secret_keys() {
93        let scanner = Scanner::new();
94        let mut env = HashMap::new();
95        env.insert("AWS_SECRET_KEY".into(), "mysecret".into());
96        env.insert("GITHUB_TOKEN".into(), "ghp_fake".into());
97        env.insert("PATH".into(), "/usr/bin".into());
98        env.insert("HOME".into(), "/home/user".into());
99
100        let scrub = vec!["*_KEY".into(), "*_TOKEN".into()];
101        let pass = vec!["PATH".into(), "HOME".into()];
102
103        let result = scrub_env(&env, &scrub, &pass, &scanner);
104        assert!(!result.contains_key("AWS_SECRET_KEY"));
105        assert!(!result.contains_key("GITHUB_TOKEN"));
106        assert!(result.contains_key("PATH"));
107        assert!(result.contains_key("HOME"));
108    }
109
110    #[test]
111    fn pass_overrides_scrub() {
112        let scanner = Scanner::new();
113        let mut env = HashMap::new();
114        // NPM_TOKEN matches *_TOKEN scrub pattern, but also matches NPM_* pass pattern
115        env.insert("NPM_TOKEN".into(), "some-token".into());
116
117        let scrub = vec!["*_TOKEN".into()];
118        let pass = vec!["NPM_*".into()];
119
120        let result = scrub_env(&env, &scrub, &pass, &scanner);
121        assert!(
122            result.contains_key("NPM_TOKEN"),
123            "pass should override scrub"
124        );
125    }
126
127    #[test]
128    fn scrubs_by_value_content() {
129        let scanner = Scanner::new();
130        let mut env = HashMap::new();
131        // Key doesn't match any scrub pattern, but value contains an AWS key
132        env.insert("MY_CUSTOM_VAR".into(), "AKIAIOSFODNN7EXAMPLE".into());
133        env.insert("NORMAL_VAR".into(), "hello world".into());
134
135        let scrub: Vec<String> = vec![];
136        let pass: Vec<String> = vec![];
137
138        let result = scrub_env(&env, &scrub, &pass, &scanner);
139        assert!(
140            !result.contains_key("MY_CUSTOM_VAR"),
141            "should scrub value with AWS key"
142        );
143        assert!(result.contains_key("NORMAL_VAR"));
144    }
145
146    #[test]
147    fn keeps_unmatched_vars() {
148        let scanner = Scanner::new();
149        let mut env = HashMap::new();
150        env.insert("EDITOR".into(), "vim".into());
151        env.insert("TERM".into(), "xterm-256color".into());
152
153        let scrub = vec!["*_KEY".into()];
154        let pass: Vec<String> = vec![];
155
156        let result = scrub_env(&env, &scrub, &pass, &scanner);
157        assert!(result.contains_key("EDITOR"));
158        assert!(result.contains_key("TERM"));
159    }
160
161    #[test]
162    fn empty_env_returns_empty() {
163        let scanner = Scanner::new();
164        let env = HashMap::new();
165        let result = scrub_env(&env, &[], &[], &scanner);
166        assert!(result.is_empty());
167    }
168}