sesame/
env.rs

1use anyhow::Context;
2use zeroize::Zeroize;
3
4use crate::cli::ExportFormat;
5use crate::ipc::{connect, fetch_multi_profile_secrets, resolve_profile_specs};
6
7/// Transform a secret key name into an environment variable name.
8///
9/// Rules: uppercase, hyphens and dots become underscores, strip non-alphanumeric
10/// except underscores. With prefix "MYAPP": "api-key" -> "MYAPP_API_KEY".
11pub(crate) fn secret_key_to_env_var(key: &str, prefix: Option<&str>) -> String {
12    let var: String = key
13        .chars()
14        .map(|c| match c {
15            '-' | '.' => '_',
16            c if c.is_ascii_alphanumeric() || c == '_' => c.to_ascii_uppercase(),
17            _ => '_',
18        })
19        .collect();
20
21    match prefix {
22        Some(p) => format!("{p}_{var}"),
23        None => var,
24    }
25}
26
27pub(crate) async fn cmd_env(
28    profile: Option<&str>,
29    prefix: Option<&str>,
30    command: &[String],
31) -> anyhow::Result<()> {
32    if command.is_empty() {
33        anyhow::bail!("no command specified");
34    }
35
36    if command.first().is_some_and(|c| c.starts_with('-')) {
37        eprintln!("hint: use '--' to separate sesame options from the command, e.g.:");
38        eprintln!("  sesame env -p default -- {}", command.join(" "));
39    }
40
41    let specs = resolve_profile_specs(profile);
42    let client = connect().await?;
43    let env_vars = fetch_multi_profile_secrets(&client, &specs, prefix).await?;
44
45    // Spawn child process with secrets as env vars.
46    let mut cmd = std::process::Command::new(&command[0]);
47    cmd.args(&command[1..]);
48
49    // Inject SESAME_PROFILES so the child knows its context.
50    let profiles_csv: String = specs
51        .iter()
52        .map(|s| match &s.org {
53            Some(org) => format!("{org}:{}", s.vault),
54            None => s.vault.clone(),
55        })
56        .collect::<Vec<_>>()
57        .join(",");
58    cmd.env("SESAME_PROFILES", &profiles_csv);
59
60    // Inject each secret as an env var.
61    for (env_name, value) in &env_vars {
62        let val_str = String::from_utf8_lossy(value);
63        cmd.env(env_name, val_str.as_ref());
64    }
65
66    let mut child = cmd.spawn().context("failed to spawn command")?;
67
68    let status = child.wait().context("failed to wait for child process")?;
69
70    for (_, mut value) in env_vars {
71        value.zeroize();
72    }
73
74    std::process::exit(status.code().unwrap_or(1));
75}
76
77/// Env var names that must never be overwritten by secret export.
78/// Covers dynamic linker, shell execution, path hijack, and privilege escalation vectors.
79const DENIED_ENV_VARS: &[&str] = &[
80    // Dynamic linker — arbitrary code execution
81    "LD_PRELOAD",
82    "LD_LIBRARY_PATH",
83    "LD_AUDIT",
84    "LD_DEBUG",
85    "LD_DEBUG_OUTPUT",
86    "LD_DYNAMIC_WEAK",
87    "LD_PROFILE",
88    "LD_SHOW_AUXV",
89    "LD_BIND_NOW",
90    "LD_BIND_NOT",
91    "DYLD_INSERT_LIBRARIES",
92    "DYLD_LIBRARY_PATH",
93    "DYLD_FRAMEWORK_PATH",
94    // Core execution environment
95    "PATH",
96    "HOME",
97    "USER",
98    "SHELL",
99    "LOGNAME",
100    "LANG",
101    "TERM",
102    "DISPLAY",
103    "WAYLAND_DISPLAY",
104    "XDG_RUNTIME_DIR",
105    // Shell injection vectors
106    "BASH_ENV",
107    "ENV",
108    "BASH_FUNC_",
109    "CDPATH",
110    "GLOBIGNORE",
111    "SHELLOPTS",
112    "BASHOPTS",
113    "PROMPT_COMMAND",
114    "PS1",
115    "PS2",
116    "PS4",
117    "MAIL",
118    "MAILPATH",
119    "MAILCHECK",
120    "IFS",
121    // Language runtime code execution
122    "PYTHONPATH",
123    "PYTHONSTARTUP",
124    "PYTHONHOME",
125    "NODE_OPTIONS",
126    "NODE_PATH",
127    "NODE_EXTRA_CA_CERTS",
128    "PERL5LIB",
129    "PERL5OPT",
130    "RUBYLIB",
131    "RUBYOPT",
132    "GOPATH",
133    "GOROOT",
134    "GOFLAGS",
135    "JAVA_HOME",
136    "CLASSPATH",
137    "JAVA_TOOL_OPTIONS",
138    // Security / auth
139    "SSH_AUTH_SOCK",
140    "GPG_AGENT_INFO",
141    "KRB5_CONFIG",
142    "KRB5CCNAME",
143    "SSL_CERT_FILE",
144    "SSL_CERT_DIR",
145    "CURL_CA_BUNDLE",
146    "REQUESTS_CA_BUNDLE",
147    "GIT_SSL_CAINFO",
148    "NIX_SSL_CERT_FILE",
149    // Nix
150    "NIX_PATH",
151    "NIX_CONF_DIR",
152    // Sudo / privilege
153    "SUDO_ASKPASS",
154    "SUDO_EDITOR",
155    "VISUAL",
156    "EDITOR",
157    // Systemd
158    "SYSTEMD_UNIT_PATH",
159    "DBUS_SESSION_BUS_ADDRESS",
160    // Open Sesame's own namespace
161    "SESAME_PROFILE",
162];
163
164/// Returns true if `name` is a denied env var (case-insensitive prefix match for BASH_FUNC_).
165pub(crate) fn is_denied_env_var(name: &str) -> bool {
166    if name.starts_with("BASH_FUNC_") {
167        return true;
168    }
169    DENIED_ENV_VARS
170        .iter()
171        .any(|&d| d.eq_ignore_ascii_case(name))
172}
173
174/// Shell-escape a value for safe embedding in `export K="V"`.
175/// Strips null bytes (C string truncation), escapes shell metacharacters.
176pub(crate) fn shell_escape(s: &str) -> String {
177    let mut out = String::with_capacity(s.len() + 8);
178    for c in s.chars() {
179        match c {
180            '\0' => {} // strip null bytes — C string truncation risk
181            '"' | '\\' | '$' | '`' | '!' => {
182                out.push('\\');
183                out.push(c);
184            }
185            '\n' => out.push_str("\\n"),
186            '\r' => out.push_str("\\r"),
187            _ => out.push(c),
188        }
189    }
190    out
191}
192
193/// JSON-escape a string value.
194pub(crate) fn json_escape(s: &str) -> String {
195    s.chars()
196        .map(|c| match c {
197            '\0' => String::new(),
198            '"' => "\\\"".to_string(),
199            '\\' => "\\\\".to_string(),
200            '\n' => "\\n".to_string(),
201            '\r' => "\\r".to_string(),
202            '\t' => "\\t".to_string(),
203            c if c.is_control() => format!("\\u{:04x}", c as u32),
204            c => c.to_string(),
205        })
206        .collect()
207}
208
209pub(crate) async fn cmd_export(
210    profile: Option<&str>,
211    format: &ExportFormat,
212    prefix: Option<&str>,
213) -> anyhow::Result<()> {
214    let specs = resolve_profile_specs(profile);
215    let client = connect().await?;
216    let raw_secrets = fetch_multi_profile_secrets(&client, &specs, prefix).await?;
217    if raw_secrets.is_empty() {
218        return Ok(());
219    }
220
221    // Convert byte values to strings for text output formats.
222    let entries: Vec<(String, String)> = raw_secrets
223        .into_iter()
224        .map(|(k, v)| {
225            let val_str = String::from_utf8_lossy(&v).into_owned();
226            (k, val_str)
227        })
228        .collect();
229
230    // Output in requested format.
231    match format {
232        ExportFormat::Shell => {
233            for (k, v) in &entries {
234                println!("export {}=\"{}\"", k, shell_escape(v));
235            }
236        }
237        ExportFormat::Dotenv => {
238            for (k, v) in &entries {
239                println!("{}=\"{}\"", k, shell_escape(v));
240            }
241        }
242        ExportFormat::Json => {
243            print!("{{");
244            for (i, (k, v)) in entries.iter().enumerate() {
245                if i > 0 {
246                    print!(",");
247                }
248                print!("\"{}\":\"{}\"", json_escape(k), json_escape(v));
249            }
250            println!("}}");
251        }
252    }
253
254    // 4. Zeroize secret copies.
255    for (_, mut v) in entries {
256        unsafe {
257            v.as_bytes_mut().zeroize();
258        }
259    }
260
261    Ok(())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn shell_escape_plain() {
270        assert_eq!(shell_escape("hello"), "hello");
271    }
272
273    #[test]
274    fn shell_escape_special_chars() {
275        assert_eq!(shell_escape(r#"a"b$c`d\e!f"#), r#"a\"b\$c\`d\\e\!f"#);
276    }
277
278    #[test]
279    fn shell_escape_newlines() {
280        assert_eq!(shell_escape("line1\nline2\r"), "line1\\nline2\\r");
281    }
282
283    #[test]
284    fn secret_name_to_env_var_basic() {
285        assert_eq!(secret_key_to_env_var("api-key", None), "API_KEY");
286    }
287
288    #[test]
289    fn secret_name_to_env_var_with_prefix() {
290        assert_eq!(
291            secret_key_to_env_var("api-key", Some("MYAPP")),
292            "MYAPP_API_KEY"
293        );
294    }
295
296    #[test]
297    fn secret_name_to_env_var_dots_and_mixed() {
298        assert_eq!(secret_key_to_env_var("db.host-name", None), "DB_HOST_NAME");
299    }
300
301    #[test]
302    fn shell_escape_strips_null_bytes() {
303        assert_eq!(shell_escape("before\0after"), "beforeafter");
304    }
305
306    #[test]
307    fn denied_env_var_ld_preload() {
308        assert!(is_denied_env_var("LD_PRELOAD"));
309    }
310
311    #[test]
312    fn denied_env_var_path() {
313        assert!(is_denied_env_var("PATH"));
314    }
315
316    #[test]
317    fn denied_env_var_case_insensitive() {
318        assert!(is_denied_env_var("ld_preload"));
319        assert!(is_denied_env_var("Path"));
320    }
321
322    #[test]
323    fn denied_env_var_bash_func_prefix() {
324        assert!(is_denied_env_var("BASH_FUNC_evil%%"));
325    }
326
327    #[test]
328    fn denied_env_var_allows_normal_names() {
329        assert!(!is_denied_env_var("GITHUB_TOKEN"));
330        assert!(!is_denied_env_var("AWS_SECRET_ACCESS_KEY"));
331        assert!(!is_denied_env_var("CORP_API_KEY"));
332    }
333
334    #[test]
335    fn denied_env_var_sesame_profile() {
336        assert!(is_denied_env_var("SESAME_PROFILE"));
337    }
338
339    #[test]
340    fn json_escape_special_chars() {
341        assert_eq!(json_escape("a\"b\\c\nd"), "a\\\"b\\\\c\\nd");
342    }
343
344    #[test]
345    fn json_escape_strips_null() {
346        assert_eq!(json_escape("ab\0cd"), "abcd");
347    }
348
349    #[test]
350    fn json_escape_control_chars() {
351        assert_eq!(json_escape("\x01\x1f"), "\\u0001\\u001f");
352    }
353}